package bkosmi import ( "fmt" "regexp" "strings" "time" "github.com/42wim/matterbridge/bridge" "github.com/42wim/matterbridge/bridge/config" ) 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 msgChannel chan config.Message } // 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) // Create GraphQL WebSocket client (pure Go, no Playwright!) b.client = NewGraphQLWSClient(b.roomURL, b.roomID, 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.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 { // Kosmi doesn't have a concept of joining channels after connection // The room is specified in the configuration and joined on Connect() b.Log.Infof("Channel %s is already 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 } // 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) }