package bkosmi import ( "encoding/json" "fmt" "sync" "time" "github.com/playwright-community/playwright-go" "github.com/sirupsen/logrus" ) // PlaywrightClient manages a Playwright browser instance to connect to Kosmi type PlaywrightClient struct { roomURL string log *logrus.Entry pw *playwright.Playwright browser playwright.Browser page playwright.Page messageCallback func(*NewMessagePayload) connected bool mu sync.RWMutex } // NewPlaywrightClient creates a new Playwright-based Kosmi client func NewPlaywrightClient(roomURL string, log *logrus.Entry) *PlaywrightClient { return &PlaywrightClient{ roomURL: roomURL, log: log, } } // Connect launches Playwright and navigates to the Kosmi room func (c *PlaywrightClient) Connect() error { c.log.Info("Launching Playwright browser for Kosmi connection") // Create Playwright instance (using system Chromium, no install needed) pw, err := playwright.Run() if err != nil { return fmt.Errorf("failed to start Playwright: %w", err) } c.pw = pw // Launch browser using system Chromium browser, err := pw.Chromium.Launch(playwright.BrowserTypeLaunchOptions{ Headless: playwright.Bool(true), ExecutablePath: playwright.String("/usr/bin/chromium"), Args: []string{ "--no-sandbox", "--disable-dev-shm-usage", "--disable-blink-features=AutomationControlled", }, }) if err != nil { return fmt.Errorf("failed to launch browser: %w", err) } c.browser = browser // Create context and page context, err := browser.NewContext(playwright.BrowserNewContextOptions{ UserAgent: playwright.String("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"), }) if err != nil { return fmt.Errorf("failed to create context: %w", err) } page, err := context.NewPage() if err != nil { return fmt.Errorf("failed to create page: %w", err) } c.page = page // Inject WebSocket hook before navigation c.log.Info("Injecting WebSocket interceptor...") if err := c.injectWebSocketHook(); err != nil { return fmt.Errorf("failed to inject WebSocket hook: %w", err) } // Navigate to the room c.log.Infof("Navigating to Kosmi room: %s", c.roomURL) if _, err := page.Goto(c.roomURL, playwright.PageGotoOptions{ WaitUntil: playwright.WaitUntilStateNetworkidle, }); err != nil { return fmt.Errorf("failed to navigate to room: %w", err) } // Wait for page to be ready if err := page.WaitForLoadState(playwright.PageWaitForLoadStateOptions{ State: playwright.LoadStateNetworkidle, }); err != nil { c.log.Warnf("Page load state warning: %v", err) } c.log.Info("Page loaded, waiting for WebSocket connection...") time.Sleep(3 * time.Second) c.mu.Lock() c.connected = true c.mu.Unlock() c.log.Info("Successfully connected to Kosmi via Playwright") // Start message listener go c.listenForMessages() return nil } // injectWebSocketHook injects the WebSocket interception script func (c *PlaywrightClient) injectWebSocketHook() error { script := c.getWebSocketHookScript() return c.page.AddInitScript(playwright.Script{ Content: playwright.String(script), }) } // getWebSocketHookScript returns the JavaScript to hook WebSocket func (c *PlaywrightClient) getWebSocketHookScript() string { return ` (function() { if (window.__KOSMI_WS_HOOK_INSTALLED__) { return; } const OriginalWebSocket = window.WebSocket; window.__KOSMI_MESSAGE_QUEUE__ = []; window.WebSocket = function(url, protocols) { const socket = new OriginalWebSocket(url, protocols); if (url.includes('engine.kosmi.io') || url.includes('gql-ws')) { console.log('[Kosmi Bridge] WebSocket hook active for:', url); window.__KOSMI_WS_CONNECTED__ = true; const originalAddEventListener = socket.addEventListener.bind(socket); socket.addEventListener = function(type, listener, options) { if (type === 'message') { const wrappedListener = function(event) { try { const data = JSON.parse(event.data); window.__KOSMI_MESSAGE_QUEUE__.push({ timestamp: Date.now(), data: data }); } catch (e) {} return listener.call(this, event); }; return originalAddEventListener(type, wrappedListener, options); } return originalAddEventListener(type, listener, options); }; let realOnMessage = null; Object.defineProperty(socket, 'onmessage', { get: function() { return realOnMessage; }, set: function(handler) { realOnMessage = function(event) { try { const data = JSON.parse(event.data); window.__KOSMI_MESSAGE_QUEUE__.push({ timestamp: Date.now(), data: data }); } catch (e) {} if (handler) { handler.call(socket, event); } }; }, configurable: true }); } return socket; }; window.WebSocket.prototype = OriginalWebSocket.prototype; window.WebSocket.CONNECTING = OriginalWebSocket.CONNECTING; window.WebSocket.OPEN = OriginalWebSocket.OPEN; window.WebSocket.CLOSING = OriginalWebSocket.CLOSING; window.WebSocket.CLOSED = OriginalWebSocket.CLOSED; window.__KOSMI_WS_HOOK_INSTALLED__ = true; console.log('[Kosmi Bridge] WebSocket hook installed'); })(); ` } // listenForMessages polls for new messages from the WebSocket queue func (c *PlaywrightClient) listenForMessages() { ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() for { c.mu.RLock() if !c.connected { c.mu.RUnlock() return } c.mu.RUnlock() // Poll for messages result, err := c.page.Evaluate(` (function() { if (!window.__KOSMI_MESSAGE_QUEUE__) return []; const messages = window.__KOSMI_MESSAGE_QUEUE__.slice(); window.__KOSMI_MESSAGE_QUEUE__ = []; return messages; })(); `) if err != nil { c.log.Debugf("Error polling messages: %v", err) <-ticker.C continue } // Parse messages var messages []struct { Timestamp int64 `json:"timestamp"` Data map[string]interface{} `json:"data"` } if err := json.Unmarshal([]byte(fmt.Sprintf("%v", result)), &messages); err != nil { <-ticker.C continue } if len(messages) > 0 { c.log.Infof("Processing %d messages from queue", len(messages)) } for _, msg := range messages { c.processMessage(msg.Data) } <-ticker.C } } // processMessage processes a single WebSocket message func (c *PlaywrightClient) processMessage(data map[string]interface{}) { // Check if this is a newMessage subscription event msgType, ok := data["type"].(string) if !ok || msgType != "next" { return } payload, ok := data["payload"].(map[string]interface{}) if !ok { return } dataField, ok := payload["data"].(map[string]interface{}) if !ok { return } newMessage, ok := dataField["newMessage"].(map[string]interface{}) if !ok { return } // Parse into our struct jsonBytes, err := json.Marshal(map[string]interface{}{ "data": map[string]interface{}{ "newMessage": newMessage, }, }) if err != nil { return } var msgPayload NewMessagePayload if err := json.Unmarshal(jsonBytes, &msgPayload); err != nil { return } // Call the callback if c.messageCallback != nil { c.messageCallback(&msgPayload) } } // SendMessage sends a message to the Kosmi chat func (c *PlaywrightClient) SendMessage(text string) error { c.mu.RLock() if !c.connected { c.mu.RUnlock() return fmt.Errorf("not connected") } c.mu.RUnlock() selector := `div[role="textbox"][contenteditable="true"]` // Wait for the input to be available _, err := c.page.WaitForSelector(selector, playwright.PageWaitForSelectorOptions{ Timeout: playwright.Float(5000), }) if err != nil { return fmt.Errorf("chat input not available: %w", err) } // Get the input element input := c.page.Locator(selector) // Clear and type the message if err := input.Fill(text); err != nil { return fmt.Errorf("failed to fill message: %w", err) } // Press Enter to send if err := input.Press("Enter"); err != nil { return fmt.Errorf("failed to press Enter: %w", err) } c.log.Debugf("Sent message: %s", text) return nil } // OnMessage sets the callback for new messages func (c *PlaywrightClient) OnMessage(callback func(*NewMessagePayload)) { c.messageCallback = callback } // Disconnect closes the browser func (c *PlaywrightClient) Disconnect() error { c.mu.Lock() c.connected = false c.mu.Unlock() c.log.Info("Closing Playwright browser") if c.browser != nil { if err := c.browser.Close(); err != nil { c.log.Warnf("Error closing browser: %v", err) } } if c.pw != nil { if err := c.pw.Stop(); err != nil { c.log.Warnf("Error stopping Playwright: %v", err) } } return nil }