Files
IRC-kosmi-relay/bridge/kosmi/kosmi.go
2025-10-31 16:17:04 -04:00

219 lines
5.6 KiB
Go

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 Native client (Playwright establishes WebSocket, we control it directly)
b.client = NewNativeClient(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] <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
}
// 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]
// Remove @ prefix if present (Kosmi uses both formats)
return strings.TrimPrefix(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)
}