package bkosmi import ( "context" "encoding/json" "fmt" "net/http" "net/http/cookiejar" "net/url" "strings" "sync" "time" "github.com/chromedp/cdproto/page" "github.com/chromedp/chromedp" "github.com/sirupsen/logrus" ) // HybridClient uses ChromeDP for auth/cookies and GraphQL for sending messages type HybridClient struct { roomURL string roomID string log *logrus.Entry ctx context.Context cancel context.CancelFunc httpClient *http.Client messageCallback func(*NewMessagePayload) connected bool mu sync.RWMutex } // NewHybridClient creates a new hybrid client func NewHybridClient(roomURL string, log *logrus.Entry) *HybridClient { return &HybridClient{ roomURL: roomURL, log: log, } } // Connect launches Chrome, gets cookies, and sets up GraphQL client func (c *HybridClient) Connect() error { c.log.Info("Launching Chrome to obtain session cookies") // Extract room ID roomID, err := extractRoomID(c.roomURL) if err != nil { return fmt.Errorf("failed to extract room ID: %w", err) } c.roomID = roomID // Create Chrome context with anti-detection opts := append(chromedp.DefaultExecAllocatorOptions[:], chromedp.Flag("headless", true), chromedp.Flag("disable-gpu", false), chromedp.Flag("no-sandbox", true), chromedp.Flag("disable-dev-shm-usage", true), chromedp.Flag("disable-blink-features", "AutomationControlled"), chromedp.Flag("disable-infobars", true), chromedp.Flag("window-size", "1920,1080"), chromedp.UserAgent("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"), ) allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(), opts...) ctx, cancel := chromedp.NewContext(allocCtx) c.ctx = ctx c.cancel = func() { cancel() allocCancel() } // Inject scripts to run on every new document BEFORE creating any pages // This ensures they run BEFORE any page JavaScript c.log.Info("Injecting scripts to run on every page load...") antiDetectionScript := ` Object.defineProperty(navigator, 'webdriver', { get: () => false }); Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] }); Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] }); window.chrome = { runtime: {} }; ` wsHookScript := c.getWebSocketHookScript() // Use Page.addScriptToEvaluateOnNewDocument via CDP if err := chromedp.Run(ctx, chromedp.ActionFunc(func(ctx context.Context) error { _, err := page.AddScriptToEvaluateOnNewDocument(antiDetectionScript).Do(ctx) if err != nil { return fmt.Errorf("failed to add anti-detection script: %w", err) } _, err = page.AddScriptToEvaluateOnNewDocument(wsHookScript).Do(ctx) if err != nil { return fmt.Errorf("failed to add WebSocket hook script: %w", err) } return nil }), ); err != nil { return fmt.Errorf("failed to inject scripts: %w", err) } // Now navigate to the room - scripts will run before page JS c.log.Infof("Navigating to Kosmi room: %s", c.roomURL) if err := chromedp.Run(ctx, chromedp.Navigate(c.roomURL), chromedp.WaitReady("body"), ); err != nil { return fmt.Errorf("failed to navigate to room: %w", err) } // Wait for page to load and WebSocket to connect c.log.Info("Waiting for page to load and WebSocket to connect...") time.Sleep(5 * time.Second) // Check if WebSocket is connected var wsStatus map[string]interface{} checkScript := ` (function() { return { hookInstalled: !!window.__KOSMI_WS_HOOK_INSTALLED__, wsFound: !!window.__KOSMI_WS__, wsConnected: window.__KOSMI_WS__ ? window.__KOSMI_WS__.readyState === WebSocket.OPEN : false, wsState: window.__KOSMI_WS__ ? window.__KOSMI_WS__.readyState : -1 }; })(); ` if err := chromedp.Run(ctx, chromedp.Evaluate(checkScript, &wsStatus)); err == nil { c.log.Infof("WebSocket status: %+v", wsStatus) } // Get cookies from the browser c.log.Info("Extracting cookies from browser session...") cookies, err := c.getCookies() if err != nil { return fmt.Errorf("failed to get cookies: %w", err) } c.log.Infof("Obtained %d cookies from browser", len(cookies)) // Set up HTTP client with cookies jar, err := cookiejar.New(nil) if err != nil { return fmt.Errorf("failed to create cookie jar: %w", err) } c.httpClient = &http.Client{ Jar: jar, Timeout: 30 * time.Second, } // Add cookies to the jar u, _ := url.Parse("https://engine.kosmi.io") c.httpClient.Jar.SetCookies(u, cookies) c.mu.Lock() c.connected = true c.mu.Unlock() c.log.Info("Successfully connected - browser session established with cookies") // Start message listener (using WebSocket hook in browser) go c.listenForMessages() return nil } // getCookies extracts cookies from the Chrome session func (c *HybridClient) getCookies() ([]*http.Cookie, error) { var cookiesData []map[string]interface{} script := ` (function() { return document.cookie.split(';').map(c => { const parts = c.trim().split('='); return { name: parts[0], value: parts.slice(1).join('=') }; }); })(); ` if err := chromedp.Run(c.ctx, chromedp.Evaluate(script, &cookiesData)); err != nil { return nil, err } cookies := make([]*http.Cookie, 0, len(cookiesData)) for _, cd := range cookiesData { if name, ok := cd["name"].(string); ok { if value, ok := cd["value"].(string); ok { cookies = append(cookies, &http.Cookie{ Name: name, Value: value, }) } } } return cookies, nil } // injectAntiDetection injects anti-detection scripts func (c *HybridClient) injectAntiDetection() error { script := ` Object.defineProperty(navigator, 'webdriver', { get: () => false }); Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] }); Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] }); window.chrome = { runtime: {} }; ` return chromedp.Run(c.ctx, chromedp.ActionFunc(func(ctx context.Context) error { return chromedp.Evaluate(script, nil).Do(ctx) })) } // injectWebSocketHook injects the WebSocket interception script func (c *HybridClient) injectWebSocketHook() error { script := c.getWebSocketHookScript() return chromedp.Run(c.ctx, chromedp.ActionFunc(func(ctx context.Context) error { return chromedp.Evaluate(script, nil).Do(ctx) })) } // getWebSocketHookScript returns the WebSocket hook JavaScript func (c *HybridClient) getWebSocketHookScript() string { return ` (function() { if (window.__KOSMI_WS_HOOK_INSTALLED__) return; const OriginalWebSocket = window.WebSocket; window.__KOSMI_MESSAGE_QUEUE__ = []; window.__KOSMI_WS__ = null; // Store reference to the WebSocket window.WebSocket = function(url, protocols) { const socket = new OriginalWebSocket(url, protocols); if (url.includes('engine.kosmi.io') || url.includes('gql-ws')) { window.__KOSMI_WS_CONNECTED__ = true; window.__KOSMI_WS__ = socket; // Store the WebSocket reference 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; })(); ` } // listenForMessages polls for messages from the WebSocket queue func (c *HybridClient) 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 var messages []struct { Timestamp int64 `json:"timestamp"` Data map[string]interface{} `json:"data"` } script := ` (function() { if (!window.__KOSMI_MESSAGE_QUEUE__) return []; const messages = window.__KOSMI_MESSAGE_QUEUE__.slice(); window.__KOSMI_MESSAGE_QUEUE__ = []; return messages; })(); ` if err := chromedp.Run(c.ctx, chromedp.Evaluate(script, &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 WebSocket message func (c *HybridClient) 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 } 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 via WebSocket using browser automation func (c *HybridClient) SendMessage(text string) error { c.log.Infof("SendMessage called with text: %s", text) c.mu.RLock() if !c.connected { c.mu.RUnlock() c.log.Error("SendMessage: not connected") return fmt.Errorf("not connected") } ctx := c.ctx c.mu.RUnlock() c.log.Infof("Sending message to room %s via WebSocket", c.roomID) // Escape the text for JavaScript escapedText := strings.ReplaceAll(text, `\`, `\\`) escapedText = strings.ReplaceAll(escapedText, `"`, `\"`) escapedText = strings.ReplaceAll(escapedText, "\n", `\n`) // JavaScript to send message via WebSocket script := fmt.Sprintf(` (function() { // Find the Kosmi WebSocket if (!window.__KOSMI_WS__) { return { success: false, error: "WebSocket not found" }; } const ws = window.__KOSMI_WS__; if (ws.readyState !== WebSocket.OPEN) { return { success: false, error: "WebSocket not open, state: " + ws.readyState }; } // GraphQL-WS message format const message = { id: "send-" + Date.now(), type: "start", payload: { query: "mutation SendMessage($body: String!, $roomID: ID!) { sendMessage(body: $body, roomID: $roomID) { id body time user { id username displayName } } }", variables: { body: "%s", roomID: "%s" } } }; try { ws.send(JSON.stringify(message)); return { success: true, message: "Sent via WebSocket" }; } catch (e) { return { success: false, error: e.toString() }; } })(); `, escapedText, c.roomID) var result map[string]interface{} err := chromedp.Run(ctx, chromedp.Evaluate(script, &result), ) if err != nil { c.log.Errorf("Failed to execute send script: %v", err) return fmt.Errorf("failed to execute send script: %w", err) } c.log.Debugf("Send result: %+v", result) if success, ok := result["success"].(bool); !ok || !success { errorMsg := "unknown error" if errStr, ok := result["error"].(string); ok { errorMsg = errStr } c.log.Errorf("Failed to send message: %s", errorMsg) return fmt.Errorf("failed to send message: %s", errorMsg) } c.log.Infof("✅ Successfully sent message via WebSocket: %s", text) return nil } // OnMessage sets the callback for new messages func (c *HybridClient) OnMessage(callback func(*NewMessagePayload)) { c.messageCallback = callback } // Disconnect closes the browser and cleans up func (c *HybridClient) Disconnect() error { c.mu.Lock() c.connected = false c.mu.Unlock() c.log.Info("Closing hybrid client") if c.cancel != nil { c.cancel() } return nil }