Files
IRC-kosmi-relay/bridge/kosmi/kosmi.go
cottongin f764519a30 nailed it
2025-11-02 16:04:03 -05:00

272 lines
7.3 KiB
Go

package bkosmi
import (
"fmt"
"regexp"
"strings"
"time"
"github.com/42wim/matterbridge/bridge"
"github.com/42wim/matterbridge/bridge/config"
"github.com/42wim/matterbridge/bridge/jackbox"
)
const (
defaultWebSocketURL = "wss://engine.kosmi.io/gql-ws"
)
// KosmiClient interface for different client implementations
type KosmiClient interface {
Connect() error
Disconnect() error
SendMessage(text string) error
OnMessage(callback func(*NewMessagePayload))
IsConnected() bool
}
// Bkosmi represents the Kosmi bridge
type Bkosmi struct {
*bridge.Config
client KosmiClient
roomID string
roomURL string
connected bool
authDone bool // Signals that authentication is complete (like IRC bridge)
msgChannel chan config.Message
jackboxClient *jackbox.Client
}
// New creates a new Kosmi bridge instance
func New(cfg *bridge.Config) bridge.Bridger {
b := &Bkosmi{
Config: cfg,
msgChannel: make(chan config.Message, 100),
}
return b
}
// Connect establishes connection to the Kosmi room
func (b *Bkosmi) Connect() error {
b.Log.Info("Connecting to Kosmi")
// Get room URL from config
b.roomURL = b.GetString("RoomURL")
if b.roomURL == "" {
return fmt.Errorf("RoomURL is required in configuration")
}
// Extract room ID from URL
roomID, err := extractRoomID(b.roomURL)
if err != nil {
return fmt.Errorf("failed to extract room ID from URL %s: %w", b.roomURL, err)
}
b.roomID = roomID
b.Log.Infof("Extracted room ID: %s", b.roomID)
// Check if we need authentication
email := b.GetString("Email")
password := b.GetString("Password")
var token string
if email != "" && password != "" {
b.Log.Info("Authenticating with email/password...")
token, err = loginWithChromedp(email, password, b.Log)
if err != nil {
return fmt.Errorf("authentication failed: %w", err)
}
b.Log.Info("✅ Authentication successful")
} else {
b.Log.Info("No credentials provided, using anonymous access")
// token will be empty, client will get anonymous token
}
// Create GraphQL WebSocket client with token
b.client = NewGraphQLWSClient(b.roomURL, b.roomID, token, b.Log)
// Register message handler
b.client.OnMessage(b.handleIncomingMessage)
// Connect to Kosmi
if err := b.client.Connect(); err != nil {
return fmt.Errorf("failed to connect to Kosmi: %w", err)
}
b.connected = true
b.authDone = true // Signal that authentication is complete
b.Log.Info("Successfully connected to Kosmi")
return nil
}
// Disconnect closes the connection to Kosmi
func (b *Bkosmi) Disconnect() error {
b.Log.Info("Disconnecting from Kosmi")
if b.client != nil {
if err := b.client.Disconnect(); err != nil {
b.Log.Errorf("Error closing Kosmi client: %v", err)
}
}
close(b.msgChannel)
b.connected = false
return nil
}
// JoinChannel joins a Kosmi room (no-op as we connect to a specific room)
func (b *Bkosmi) JoinChannel(channel config.ChannelInfo) error {
// Wait for authentication to complete before proceeding
// This ensures the WebSocket connection is fully established (like IRC bridge)
for {
if b.authDone {
break
}
time.Sleep(time.Second)
}
// Kosmi doesn't have a concept of joining channels after connection
// The room is specified in the configuration and joined on Connect()
b.Log.Debugf("Channel ready: %s (connected via room URL)", channel.Name)
return nil
}
// Send sends a message to Kosmi
func (b *Bkosmi) Send(msg config.Message) (string, error) {
b.Log.Debugf("=> Sending message to Kosmi: %#v", msg)
// Ignore delete messages
if msg.Event == config.EventMsgDelete {
return "", nil
}
// Check if we're connected
if !b.connected || b.client == nil {
b.Log.Error("Not connected to Kosmi, dropping message")
return "", nil
}
// The gateway already formatted the username with RemoteNickFormat
// So msg.Username contains the formatted string like "[irc] <cottongin>"
// Just send: username + text
formattedMsg := fmt.Sprintf("%s%s", msg.Username, msg.Text)
// Send message to Kosmi
if err := b.client.SendMessage(formattedMsg); err != nil {
b.Log.Errorf("Failed to send message to Kosmi: %v", err)
return "", err
}
return "", nil
}
// handleIncomingMessage processes messages received from Kosmi
func (b *Bkosmi) handleIncomingMessage(payload *NewMessagePayload) {
// Extract message details
body := payload.Data.NewMessage.Body
username := payload.Data.NewMessage.User.DisplayName
if username == "" {
username = payload.Data.NewMessage.User.Username
}
if username == "" {
username = "Unknown"
}
timestamp := time.Unix(payload.Data.NewMessage.Time, 0)
b.Log.Infof("Received message from Kosmi: [%s] %s: %s", timestamp.Format(time.RFC3339), username, body)
// Check if this is our own message (to avoid echo)
// Messages we send have [irc] prefix (from RemoteNickFormat)
if strings.HasPrefix(body, "[irc]") {
b.Log.Debug("Ignoring our own echoed message (has [irc] prefix)")
return
}
// Check for votes (thisgame++ or thisgame--)
// Only process votes from non-relayed messages
if !jackbox.IsRelayedMessage(body) {
if isVote, voteType := jackbox.DetectVote(body); isVote {
b.Log.Debugf("Detected vote from %s: %s", username, voteType)
if b.jackboxClient != nil {
go func() {
if err := b.jackboxClient.SendVote(username, voteType, timestamp); err != nil {
b.Log.Errorf("Failed to send vote to Jackbox API: %v", err)
}
}()
}
}
}
// Create Matterbridge message
// Use "main" as the channel name for gateway matching
// Don't add prefix here - let the gateway's RemoteNickFormat handle it
rmsg := config.Message{
Username: username,
Text: body,
Channel: "main",
Account: b.Account,
UserID: username,
Timestamp: timestamp,
Protocol: "kosmi",
}
// Send to Matterbridge
b.Log.Debugf("Forwarding to Matterbridge channel=%s account=%s: %s", rmsg.Channel, rmsg.Account, rmsg.Text)
if b.Remote == nil {
b.Log.Error("Remote channel is nil! Cannot forward message")
return
}
b.Remote <- rmsg
}
// extractRoomID extracts the room ID from a Kosmi room URL
// Supports formats:
// - https://app.kosmi.io/room/@roomname
// - https://app.kosmi.io/room/roomid
func extractRoomID(url string) (string, error) {
// Remove trailing slash if present
url = strings.TrimSuffix(url, "/")
// Pattern to match Kosmi room URLs
patterns := []string{
`https?://app\.kosmi\.io/room/(@?[a-zA-Z0-9_-]+)`,
`app\.kosmi\.io/room/(@?[a-zA-Z0-9_-]+)`,
`/room/(@?[a-zA-Z0-9_-]+)`,
}
for _, pattern := range patterns {
re := regexp.MustCompile(pattern)
matches := re.FindStringSubmatch(url)
if len(matches) >= 2 {
roomID := matches[1]
// Ensure @ prefix is present (required for WebSocket API)
if !strings.HasPrefix(roomID, "@") {
roomID = "@" + roomID
}
return roomID, nil
}
}
// If no pattern matches, assume the entire string is the room ID
// This allows for simple room ID configuration
parts := strings.Split(url, "/")
if len(parts) > 0 {
lastPart := parts[len(parts)-1]
if lastPart != "" {
return strings.TrimPrefix(lastPart, "@"), nil
}
}
return "", fmt.Errorf("could not extract room ID from URL: %s", url)
}
// SetJackboxClient sets the Jackbox API client for this bridge
func (b *Bkosmi) SetJackboxClient(client *jackbox.Client) {
b.jackboxClient = client
b.Log.Info("Jackbox client injected into Kosmi bridge")
}