package bkosmi import ( "encoding/json" "fmt" "sync" "time" "github.com/playwright-community/playwright-go" "github.com/sirupsen/logrus" ) // NativeClient uses Playwright to establish WebSocket, then interacts directly via JavaScript type NativeClient struct { roomURL string roomID string log *logrus.Entry pw *playwright.Playwright browser playwright.Browser page playwright.Page messageCallback func(*NewMessagePayload) connected bool mu sync.RWMutex } // NewNativeClient creates a new native client with Playwright-assisted connection func NewNativeClient(roomURL, roomID string, log *logrus.Entry) *NativeClient { return &NativeClient{ roomURL: roomURL, roomID: roomID, log: log, } } // Connect launches Playwright and establishes the WebSocket connection func (c *NativeClient) Connect() error { c.log.Info("Starting Playwright native client") // Launch Playwright pw, err := playwright.Run() if err != nil { return fmt.Errorf("failed to start Playwright: %w", err) } c.pw = pw // Launch browser with resource optimizations browser, err := pw.Chromium.Launch(playwright.BrowserTypeLaunchOptions{ Headless: playwright.Bool(true), Args: []string{ "--no-sandbox", "--disable-dev-shm-usage", "--disable-blink-features=AutomationControlled", // Resource optimizations for reduced CPU/memory usage "--disable-gpu", // No GPU needed for chat "--disable-software-rasterizer", // No rendering needed "--disable-extensions", // No extensions needed "--disable-background-networking", // No background requests "--disable-background-timer-throttling", "--disable-backgrounding-occluded-windows", "--disable-breakpad", // No crash reporting "--disable-component-extensions-with-background-pages", "--disable-features=TranslateUI", // No translation UI "--disable-ipc-flooding-protection", "--disable-renderer-backgrounding", "--force-color-profile=srgb", "--metrics-recording-only", "--no-first-run", // Skip first-run tasks "--mute-audio", // No audio needed }, }) if err != nil { c.pw.Stop() return fmt.Errorf("failed to launch browser: %w", err) } c.browser = browser // Create context 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 { c.browser.Close() c.pw.Stop() return fmt.Errorf("failed to create context: %w", err) } // Create page page, err := context.NewPage() if err != nil { c.browser.Close() c.pw.Stop() return fmt.Errorf("failed to create page: %w", err) } c.page = page // Inject WebSocket interceptor c.log.Debug("Injecting WebSocket access layer") if err := c.injectWebSocketAccess(); err != nil { c.Disconnect() return fmt.Errorf("failed to inject WebSocket access: %w", err) } // Navigate to room c.log.Infof("Navigating to Kosmi room: %s", c.roomURL) if _, err := page.Goto(c.roomURL, playwright.PageGotoOptions{ WaitUntil: playwright.WaitUntilStateDomcontentloaded, // Wait for DOM only, not all resources }); err != nil { c.Disconnect() return fmt.Errorf("failed to navigate: %w", err) } // Wait for WebSocket to establish c.log.Debug("Waiting for WebSocket connection") if err := c.waitForWebSocket(); err != nil { c.Disconnect() return fmt.Errorf("WebSocket not established: %w", err) } // Subscribe to room messages c.log.Debugf("Subscribing to messages in room %s", c.roomID) if err := c.subscribeToMessages(); err != nil { c.Disconnect() return fmt.Errorf("failed to subscribe: %w", err) } c.mu.Lock() c.connected = true c.mu.Unlock() c.log.Info("Native client connected successfully") // Start message listener go c.listenForMessages() return nil } // injectWebSocketAccess injects JavaScript that provides direct WebSocket access func (c *NativeClient) injectWebSocketAccess() error { script := ` (function() { if (window.__KOSMI_NATIVE_CLIENT__) return; const OriginalWebSocket = window.WebSocket; window.__KOSMI_WS__ = null; window.__KOSMI_MESSAGE_QUEUE__ = []; window.__KOSMI_READY__ = false; // Hook WebSocket constructor to capture the connection window.WebSocket = function(url, protocols) { const socket = new OriginalWebSocket(url, protocols); if (url.includes('engine.kosmi.io') || url.includes('gql-ws')) { window.__KOSMI_WS__ = socket; // Hook message handler to queue messages socket.addEventListener('message', (event) => { try { const data = JSON.parse(event.data); window.__KOSMI_MESSAGE_QUEUE__.push({ timestamp: Date.now(), data: data }); } catch (e) { // Ignore non-JSON messages } }); // Mark as ready when connection opens socket.addEventListener('open', () => { window.__KOSMI_READY__ = true; }); } return socket; }; // Preserve WebSocket properties 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_NATIVE_CLIENT__ = true; })(); ` return c.page.AddInitScript(playwright.Script{ Content: playwright.String(script), }) } // waitForWebSocket waits for the WebSocket to be established func (c *NativeClient) waitForWebSocket() error { for i := 0; i < 30; i++ { // 15 seconds max result, err := c.page.Evaluate(` (function() { return { ready: !!window.__KOSMI_READY__, wsExists: !!window.__KOSMI_WS__, wsState: window.__KOSMI_WS__ ? window.__KOSMI_WS__.readyState : -1 }; })(); `) if err != nil { return err } status := result.(map[string]interface{}) ready := status["ready"].(bool) if ready { c.log.Info("✅ WebSocket is ready") return nil } if i%5 == 0 { c.log.Debugf("Waiting for WebSocket... (attempt %d/30)", i+1) } time.Sleep(500 * time.Millisecond) } return fmt.Errorf("timeout waiting for WebSocket") } // subscribeToMessages subscribes to room messages via the WebSocket func (c *NativeClient) subscribeToMessages() error { script := fmt.Sprintf(` (function() { if (!window.__KOSMI_WS__ || window.__KOSMI_WS__.readyState !== WebSocket.OPEN) { return { success: false, error: 'WebSocket not ready' }; } const subscription = { id: 'native-client-subscription', type: 'subscribe', payload: { query: 'subscription { newMessage(roomId: "%s") { body time user { displayName username } } }', variables: {} } }; try { window.__KOSMI_WS__.send(JSON.stringify(subscription)); return { success: true }; } catch (e) { return { success: false, error: e.toString() }; } })(); `, c.roomID) result, err := c.page.Evaluate(script) if err != nil { return err } response := result.(map[string]interface{}) if success, ok := response["success"].(bool); !ok || !success { errMsg := "unknown error" if e, ok := response["error"].(string); ok { errMsg = e } return fmt.Errorf("subscription failed: %s", errMsg) } return nil } // listenForMessages continuously polls for new messages func (c *NativeClient) listenForMessages() { ticker := time.NewTicker(500 * time.Millisecond) defer ticker.Stop() for { c.mu.RLock() if !c.connected { c.mu.RUnlock() return } c.mu.RUnlock() if err := c.pollMessages(); err != nil { c.log.Errorf("Error polling messages: %v", err) } <-ticker.C } } // pollMessages retrieves and processes messages from the queue func (c *NativeClient) pollMessages() error { result, err := c.page.Evaluate(` (function() { if (!window.__KOSMI_MESSAGE_QUEUE__) return null; if (window.__KOSMI_MESSAGE_QUEUE__.length === 0) return null; const messages = window.__KOSMI_MESSAGE_QUEUE__.slice(); window.__KOSMI_MESSAGE_QUEUE__ = []; return messages; })(); `) if err != nil { return err } // Early return if no messages (reduces CPU during idle) if result == nil { return nil } messagesJSON, err := json.Marshal(result) if err != nil { return err } var messages []struct { Timestamp int64 `json:"timestamp"` Data map[string]interface{} `json:"data"` } if err := json.Unmarshal(messagesJSON, &messages); err != nil { return err } for _, msg := range messages { c.processMessage(msg.Data) } return nil } // processMessage processes a single WebSocket message func (c *NativeClient) processMessage(data map[string]interface{}) { 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 } if c.messageCallback != nil { c.messageCallback(&msgPayload) } } // SendMessage sends a message by typing into the Kosmi chat input field func (c *NativeClient) SendMessage(text string) error { c.mu.RLock() if !c.connected { c.mu.RUnlock() return fmt.Errorf("not connected") } c.mu.RUnlock() c.log.Debugf("Sending message to Kosmi: %s", text) // Escape the message text for JavaScript textJSON, _ := json.Marshal(text) script := fmt.Sprintf(` (async function() { try { // Try multiple strategies to find the chat input let input = null; // Strategy 1: Look for textarea const textareas = document.querySelectorAll('textarea'); for (let ta of textareas) { if (ta.offsetParent !== null) { // visible input = ta; break; } } // Strategy 2: Look for contenteditable if (!input) { const editables = document.querySelectorAll('[contenteditable="true"]'); for (let ed of editables) { if (ed.offsetParent !== null) { // visible input = ed; break; } } } // Strategy 3: Look for input text if (!input) { const inputs = document.querySelectorAll('input[type="text"]'); for (let inp of inputs) { if (inp.offsetParent !== null) { // visible input = inp; break; } } } if (!input) { return { success: false, error: 'Could not find any visible input element' }; } // Set the value based on element type if (input.contentEditable === 'true') { input.textContent = %s; input.dispatchEvent(new Event('input', { bubbles: true })); } else { input.value = %s; input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); } // Focus the input input.focus(); // Wait a tiny bit await new Promise(resolve => setTimeout(resolve, 100)); // Find and click the send button, or press Enter const sendButton = document.querySelector('button[type="submit"], button[class*="send" i], button[aria-label*="send" i]'); if (sendButton && sendButton.offsetParent !== null) { sendButton.click(); } else { // Simulate Enter key press const enterEvent = new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true }); input.dispatchEvent(enterEvent); } return { success: true }; } catch (e) { return { success: false, error: e.toString() }; } })(); `, string(textJSON), string(textJSON)) result, err := c.page.Evaluate(script) if err != nil { c.log.Errorf("Failed to execute send script: %v", err) return fmt.Errorf("failed to execute send: %w", err) } response := result.(map[string]interface{}) if success, ok := response["success"].(bool); !ok || !success { errMsg := "unknown error" if e, ok := response["error"].(string); ok { errMsg = e } c.log.Errorf("Send failed: %s", errMsg) return fmt.Errorf("send failed: %s", errMsg) } c.log.Debug("Successfully sent message to Kosmi") return nil } // OnMessage registers a callback for incoming messages func (c *NativeClient) OnMessage(callback func(*NewMessagePayload)) { c.messageCallback = callback } // Disconnect closes the Playwright browser func (c *NativeClient) Disconnect() error { c.mu.Lock() c.connected = false c.mu.Unlock() c.log.Debug("Closing Playwright browser") if c.browser != nil { c.browser.Close() } if c.pw != nil { c.pw.Stop() } return nil } // IsConnected returns whether the client is connected func (c *NativeClient) IsConnected() bool { c.mu.RLock() defer c.mu.RUnlock() return c.connected }