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 != "" { // Try to load cached token first cachedToken, err := loadTokenCache(email, b.Log) if err != nil { b.Log.Warnf("Failed to load token cache: %v", err) } if cachedToken != nil { // Use cached token token = cachedToken.Token } else { // No valid cache, authenticate with browser 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") // Save token to cache if err := saveTokenCache(token, email, b.Log); err != nil { b.Log.Warnf("Failed to cache token: %v", err) } } } 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] " // 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") }