613 lines
17 KiB
Go
613 lines
17 KiB
Go
package bkosmi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/chromedp/cdproto/input"
|
|
"github.com/chromedp/cdproto/page"
|
|
"github.com/chromedp/chromedp"
|
|
"github.com/sirupsen/logrus"
|
|
)
|
|
|
|
// ChromeDPClient manages a headless Chrome instance to connect to Kosmi
|
|
type ChromeDPClient struct {
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
roomURL string
|
|
log *logrus.Entry
|
|
messageHandlers []func(*NewMessagePayload)
|
|
mu sync.RWMutex
|
|
connected bool
|
|
}
|
|
|
|
// NewChromeDPClient creates a new ChromeDP-based Kosmi client
|
|
func NewChromeDPClient(roomURL string, log *logrus.Entry) *ChromeDPClient {
|
|
return &ChromeDPClient{
|
|
roomURL: roomURL,
|
|
log: log,
|
|
messageHandlers: []func(*NewMessagePayload){},
|
|
}
|
|
}
|
|
|
|
// Connect launches Chrome and navigates to the Kosmi room
|
|
func (c *ChromeDPClient) Connect() error {
|
|
c.log.Info("Launching headless Chrome for Kosmi connection")
|
|
|
|
// Create Chrome context with flags to avoid headless detection
|
|
opts := append(chromedp.DefaultExecAllocatorOptions[:],
|
|
chromedp.Flag("headless", true),
|
|
chromedp.Flag("disable-gpu", false), // Enable GPU to look more real
|
|
chromedp.Flag("no-sandbox", true),
|
|
chromedp.Flag("disable-dev-shm-usage", true),
|
|
chromedp.Flag("disable-blink-features", "AutomationControlled"), // Hide automation
|
|
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 anti-detection scripts and WebSocket hook BEFORE any navigation
|
|
c.log.Info("Injecting anti-detection and WebSocket interceptor...")
|
|
if err := c.injectAntiDetection(); err != nil {
|
|
return fmt.Errorf("failed to inject anti-detection: %w", err)
|
|
}
|
|
if err := c.injectWebSocketHookBeforeLoad(); err != nil {
|
|
return fmt.Errorf("failed to inject WebSocket hook: %w", err)
|
|
}
|
|
|
|
// Now navigate to the room
|
|
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)
|
|
}
|
|
|
|
c.log.Info("Page loaded, checking if hook is active...")
|
|
|
|
// Verify the hook is installed
|
|
var hookInstalled bool
|
|
if err := chromedp.Run(ctx, chromedp.Evaluate(`window.__KOSMI_WS_HOOK_INSTALLED__ === true`, &hookInstalled)); err != nil {
|
|
c.log.Warnf("Could not verify hook installation: %v", err)
|
|
} else if hookInstalled {
|
|
c.log.Info("✓ WebSocket hook confirmed installed")
|
|
} else {
|
|
c.log.Warn("✗ WebSocket hook not detected!")
|
|
}
|
|
|
|
// Wait a moment for WebSocket to connect
|
|
c.log.Info("Waiting for WebSocket connection...")
|
|
time.Sleep(3 * time.Second)
|
|
|
|
// Check if we've captured any WebSocket connections
|
|
var wsConnected string
|
|
checkScript := `
|
|
(function() {
|
|
if (window.__KOSMI_WS_CONNECTED__) {
|
|
return 'WebSocket connection intercepted';
|
|
}
|
|
return 'No WebSocket connection detected yet';
|
|
})();
|
|
`
|
|
if err := chromedp.Run(ctx, chromedp.Evaluate(checkScript, &wsConnected)); err == nil {
|
|
c.log.Infof("Status: %s", wsConnected)
|
|
}
|
|
|
|
c.mu.Lock()
|
|
c.connected = true
|
|
c.mu.Unlock()
|
|
|
|
c.log.Info("Successfully connected to Kosmi via Chrome")
|
|
|
|
// Start console log listener (for debugging)
|
|
go c.listenToConsole()
|
|
|
|
// Start message listener
|
|
go c.listenForMessages()
|
|
|
|
return nil
|
|
}
|
|
|
|
// injectAntiDetection injects scripts to hide automation/headless detection
|
|
func (c *ChromeDPClient) injectAntiDetection() error {
|
|
script := `
|
|
// Override navigator.webdriver
|
|
Object.defineProperty(navigator, 'webdriver', {
|
|
get: () => false,
|
|
});
|
|
|
|
// Override plugins
|
|
Object.defineProperty(navigator, 'plugins', {
|
|
get: () => [1, 2, 3, 4, 5],
|
|
});
|
|
|
|
// Override languages
|
|
Object.defineProperty(navigator, 'languages', {
|
|
get: () => ['en-US', 'en'],
|
|
});
|
|
|
|
// Chrome runtime
|
|
window.chrome = {
|
|
runtime: {},
|
|
};
|
|
|
|
// Permissions
|
|
const originalQuery = window.navigator.permissions.query;
|
|
window.navigator.permissions.query = (parameters) => (
|
|
parameters.name === 'notifications' ?
|
|
Promise.resolve({ state: Notification.permission }) :
|
|
originalQuery(parameters)
|
|
);
|
|
|
|
console.log('[Kosmi Bridge] Anti-detection scripts injected');
|
|
`
|
|
|
|
return chromedp.Run(c.ctx, chromedp.ActionFunc(func(ctx context.Context) error {
|
|
_, err := page.AddScriptToEvaluateOnNewDocument(script).Do(ctx)
|
|
return err
|
|
}))
|
|
}
|
|
|
|
// injectWebSocketHookBeforeLoad uses CDP to inject script before any page scripts run
|
|
func (c *ChromeDPClient) injectWebSocketHookBeforeLoad() error {
|
|
// Get the WebSocket hook script
|
|
script := c.getWebSocketHookScript()
|
|
|
|
// Use chromedp.ActionFunc to access the CDP directly
|
|
return chromedp.Run(c.ctx, chromedp.ActionFunc(func(ctx context.Context) error {
|
|
// Use Page.addScriptToEvaluateOnNewDocument to inject before page load
|
|
// This is the proper way to inject scripts that run before page JavaScript
|
|
_, err := page.AddScriptToEvaluateOnNewDocument(script).Do(ctx)
|
|
return err
|
|
}))
|
|
}
|
|
|
|
// getWebSocketHookScript returns the WebSocket interception script
|
|
func (c *ChromeDPClient) getWebSocketHookScript() string {
|
|
return `
|
|
(function() {
|
|
if (window.__KOSMI_WS_HOOK_INSTALLED__) {
|
|
console.log('[Kosmi Bridge] WebSocket hook already installed');
|
|
return;
|
|
}
|
|
|
|
// Store original WebSocket constructor
|
|
const OriginalWebSocket = window.WebSocket;
|
|
|
|
// Store messages in a queue
|
|
window.__KOSMI_MESSAGE_QUEUE__ = [];
|
|
|
|
// Hook WebSocket constructor
|
|
window.WebSocket = function(url, protocols) {
|
|
const socket = new OriginalWebSocket(url, protocols);
|
|
|
|
// Check if this is Kosmi's GraphQL WebSocket
|
|
if (url.includes('engine.kosmi.io') || url.includes('gql-ws')) {
|
|
console.log('[Kosmi Bridge] WebSocket hook active for:', url);
|
|
window.__KOSMI_WS_CONNECTED__ = true;
|
|
|
|
// Method 1: Hook addEventListener
|
|
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);
|
|
console.log('[Kosmi Bridge] Message intercepted:', data);
|
|
window.__KOSMI_MESSAGE_QUEUE__.push({
|
|
timestamp: Date.now(),
|
|
data: data,
|
|
source: 'addEventListener'
|
|
});
|
|
} catch (e) {
|
|
// Not JSON, skip
|
|
}
|
|
|
|
// Call original listener
|
|
return listener.call(this, event);
|
|
};
|
|
return originalAddEventListener(type, wrappedListener, options);
|
|
}
|
|
return originalAddEventListener(type, listener, options);
|
|
};
|
|
|
|
// Method 2: Intercept onmessage property setter
|
|
let realOnMessage = null;
|
|
const descriptor = Object.getOwnPropertyDescriptor(WebSocket.prototype, 'onmessage');
|
|
|
|
Object.defineProperty(socket, 'onmessage', {
|
|
get: function() {
|
|
return realOnMessage;
|
|
},
|
|
set: function(handler) {
|
|
realOnMessage = function(event) {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
console.log('[Kosmi Bridge] Message via onmessage:', data);
|
|
window.__KOSMI_MESSAGE_QUEUE__.push({
|
|
timestamp: Date.now(),
|
|
data: data,
|
|
source: 'onmessage'
|
|
});
|
|
} catch (e) {
|
|
// Not JSON, skip
|
|
}
|
|
|
|
// ALWAYS call original handler
|
|
if (handler) {
|
|
handler.call(socket, event);
|
|
}
|
|
};
|
|
|
|
// Set it on the underlying WebSocket
|
|
if (descriptor && descriptor.set) {
|
|
descriptor.set.call(socket, realOnMessage);
|
|
}
|
|
},
|
|
configurable: true
|
|
});
|
|
|
|
console.log('[Kosmi Bridge] WebSocket hooks installed');
|
|
}
|
|
|
|
return socket;
|
|
};
|
|
|
|
// Preserve the original constructor 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_WS_HOOK_INSTALLED__ = true;
|
|
console.log('[Kosmi Bridge] WebSocket hook installed successfully');
|
|
})();
|
|
`
|
|
}
|
|
|
|
// listenToConsole captures console logs from the browser
|
|
func (c *ChromeDPClient) listenToConsole() {
|
|
ticker := time.NewTicker(1 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-c.ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
var logs string
|
|
script := `
|
|
(function() {
|
|
if (!window.__KOSMI_CONSOLE_LOGS__) {
|
|
window.__KOSMI_CONSOLE_LOGS__ = [];
|
|
const originalLog = console.log;
|
|
const originalWarn = console.warn;
|
|
const originalError = console.error;
|
|
|
|
console.log = function(...args) {
|
|
window.__KOSMI_CONSOLE_LOGS__.push({type: 'log', message: args.join(' ')});
|
|
originalLog.apply(console, args);
|
|
};
|
|
console.warn = function(...args) {
|
|
window.__KOSMI_CONSOLE_LOGS__.push({type: 'warn', message: args.join(' ')});
|
|
originalWarn.apply(console, args);
|
|
};
|
|
console.error = function(...args) {
|
|
window.__KOSMI_CONSOLE_LOGS__.push({type: 'error', message: args.join(' ')});
|
|
originalError.apply(console, args);
|
|
};
|
|
}
|
|
|
|
const logs = window.__KOSMI_CONSOLE_LOGS__;
|
|
window.__KOSMI_CONSOLE_LOGS__ = [];
|
|
return JSON.stringify(logs);
|
|
})();
|
|
`
|
|
|
|
if err := chromedp.Run(c.ctx, chromedp.Evaluate(script, &logs)); err != nil {
|
|
continue
|
|
}
|
|
|
|
if logs != "" && logs != "[]" {
|
|
var logEntries []struct {
|
|
Type string `json:"type"`
|
|
Message string `json:"message"`
|
|
}
|
|
if err := json.Unmarshal([]byte(logs), &logEntries); err == nil {
|
|
for _, entry := range logEntries {
|
|
// Only show Kosmi Bridge logs
|
|
if strings.Contains(entry.Message, "[Kosmi Bridge]") {
|
|
switch entry.Type {
|
|
case "error":
|
|
c.log.Errorf("Browser: %s", entry.Message)
|
|
case "warn":
|
|
c.log.Warnf("Browser: %s", entry.Message)
|
|
default:
|
|
c.log.Debugf("Browser: %s", entry.Message)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// listenForMessages continuously polls for new messages from the queue
|
|
func (c *ChromeDPClient) listenForMessages() {
|
|
c.log.Info("Starting message listener")
|
|
ticker := time.NewTicker(500 * time.Millisecond) // Poll every 500ms
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-c.ctx.Done():
|
|
c.log.Info("Message listener stopped")
|
|
return
|
|
case <-ticker.C:
|
|
if err := c.pollMessages(); err != nil {
|
|
c.log.Errorf("Error polling messages: %v", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// pollMessages retrieves and processes messages from the queue
|
|
func (c *ChromeDPClient) pollMessages() error {
|
|
var messagesJSON string
|
|
|
|
// Get and clear the message queue
|
|
script := `
|
|
(function() {
|
|
const messages = window.__KOSMI_MESSAGE_QUEUE__ || [];
|
|
const count = messages.length;
|
|
window.__KOSMI_MESSAGE_QUEUE__ = [];
|
|
|
|
if (count > 0) {
|
|
console.log('[Kosmi Bridge] Polling found', count, 'messages');
|
|
}
|
|
|
|
return JSON.stringify(messages);
|
|
})();
|
|
`
|
|
|
|
if err := chromedp.Run(c.ctx, chromedp.Evaluate(script, &messagesJSON)); err != nil {
|
|
return err
|
|
}
|
|
|
|
if messagesJSON == "" || messagesJSON == "[]" {
|
|
return nil // No messages
|
|
}
|
|
|
|
c.log.Debugf("Retrieved %d bytes of message data", len(messagesJSON))
|
|
|
|
// Parse messages
|
|
var messages []struct {
|
|
Timestamp int64 `json:"timestamp"`
|
|
Data json.RawMessage `json:"data"`
|
|
Source string `json:"source"`
|
|
}
|
|
|
|
if err := json.Unmarshal([]byte(messagesJSON), &messages); err != nil {
|
|
c.log.Errorf("Failed to parse messages JSON: %v", err)
|
|
c.log.Debugf("Raw JSON: %s", messagesJSON)
|
|
return fmt.Errorf("failed to parse messages: %w", err)
|
|
}
|
|
|
|
c.log.Infof("Processing %d messages from queue", len(messages))
|
|
|
|
// Process each message
|
|
for i, msg := range messages {
|
|
c.log.Debugf("Processing message %d/%d from source: %s", i+1, len(messages), msg.Source)
|
|
c.processMessage(msg.Data)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// processMessage handles a single GraphQL message
|
|
func (c *ChromeDPClient) processMessage(data json.RawMessage) {
|
|
// Parse as GraphQL message
|
|
var gqlMsg struct {
|
|
Type string `json:"type"`
|
|
Payload json.RawMessage `json:"payload"`
|
|
}
|
|
|
|
if err := json.Unmarshal(data, &gqlMsg); err != nil {
|
|
c.log.Debugf("Failed to parse GraphQL message: %v", err)
|
|
return
|
|
}
|
|
|
|
// Only process "next" or "data" type messages
|
|
if gqlMsg.Type != "next" && gqlMsg.Type != "data" {
|
|
return
|
|
}
|
|
|
|
// Parse the payload
|
|
var payload NewMessagePayload
|
|
if err := json.Unmarshal(gqlMsg.Payload, &payload); err != nil {
|
|
c.log.Debugf("Failed to parse message payload: %v", err)
|
|
return
|
|
}
|
|
|
|
// Check if this is a newMessage event
|
|
if payload.Data.NewMessage.Body == "" {
|
|
return // Not a message event
|
|
}
|
|
|
|
// Call all registered handlers
|
|
c.mu.RLock()
|
|
handlers := c.messageHandlers
|
|
c.mu.RUnlock()
|
|
|
|
for _, handler := range handlers {
|
|
handler(&payload)
|
|
}
|
|
}
|
|
|
|
// OnMessage registers a handler for incoming messages
|
|
func (c *ChromeDPClient) OnMessage(handler func(*NewMessagePayload)) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.messageHandlers = append(c.messageHandlers, handler)
|
|
}
|
|
|
|
// SendMessage sends a message to the Kosmi room
|
|
func (c *ChromeDPClient) SendMessage(text string) error {
|
|
c.mu.RLock()
|
|
if !c.connected {
|
|
c.mu.RUnlock()
|
|
return fmt.Errorf("not connected")
|
|
}
|
|
c.mu.RUnlock()
|
|
|
|
// Wait for the chat input to be available (with timeout)
|
|
// Kosmi uses a contenteditable div with role="textbox"
|
|
var inputFound bool
|
|
for i := 0; i < 50; i++ {
|
|
// Simple check without string replacement issues
|
|
checkScript := `
|
|
(function() {
|
|
const input = document.querySelector('div[role="textbox"][contenteditable="true"]');
|
|
return input !== null;
|
|
})();
|
|
`
|
|
|
|
if err := chromedp.Run(c.ctx, chromedp.Evaluate(checkScript, &inputFound)); err != nil {
|
|
return fmt.Errorf("failed to check for chat input: %w", err)
|
|
}
|
|
|
|
if inputFound {
|
|
c.log.Infof("Chat input found after %d attempts (%.1f seconds)", i, float64(i)*0.1)
|
|
break
|
|
}
|
|
|
|
// Log progress periodically
|
|
if i == 0 || i == 10 || i == 25 || i == 49 {
|
|
c.log.Debugf("Still waiting for chat input... attempt %d/50", i+1)
|
|
}
|
|
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
|
|
if !inputFound {
|
|
// Diagnostic: Get element counts directly
|
|
var diagInfo struct {
|
|
Textareas int `json:"textareas"`
|
|
Contenteditable int `json:"contenteditable"`
|
|
Textboxes int `json:"textboxes"`
|
|
}
|
|
|
|
diagScript := `
|
|
(function() {
|
|
return {
|
|
textareas: document.querySelectorAll('textarea').length,
|
|
contenteditable: document.querySelectorAll('[contenteditable="true"]').length,
|
|
textboxes: document.querySelectorAll('[role="textbox"]').length
|
|
};
|
|
})();
|
|
`
|
|
|
|
if err := chromedp.Run(c.ctx, chromedp.Evaluate(diagScript, &diagInfo)); err == nil {
|
|
c.log.Errorf("Diagnostic: textareas=%d, contenteditable=%d, textboxes=%d",
|
|
diagInfo.Textareas, diagInfo.Contenteditable, diagInfo.Textboxes)
|
|
}
|
|
|
|
c.log.Error("Chat input not found after 5 seconds")
|
|
return fmt.Errorf("chat input not available after timeout")
|
|
}
|
|
|
|
// Send the message using ChromeDP's native SendKeys
|
|
// This is more reliable than dispatching events manually and mimics actual user input
|
|
selector := `div[role="textbox"][contenteditable="true"]`
|
|
|
|
// First, clear the input and type the message
|
|
err := chromedp.Run(c.ctx,
|
|
chromedp.Focus(selector),
|
|
chromedp.Evaluate(`document.querySelector('div[role="textbox"][contenteditable="true"]').textContent = ''`, nil),
|
|
chromedp.SendKeys(selector, text),
|
|
)
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("failed to type message: %w", err)
|
|
}
|
|
|
|
// Small delay for React to process
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
// Send Enter key using CDP Input API directly
|
|
// This is closer to what Playwright does and should trigger all the right events
|
|
err = chromedp.Run(c.ctx,
|
|
chromedp.ActionFunc(func(ctx context.Context) error {
|
|
// Send keyDown for Enter
|
|
if err := input.DispatchKeyEvent(input.KeyDown).
|
|
WithKey("Enter").
|
|
WithCode("Enter").
|
|
WithNativeVirtualKeyCode(13).
|
|
WithWindowsVirtualKeyCode(13).
|
|
Do(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Send keyUp for Enter
|
|
return input.DispatchKeyEvent(input.KeyUp).
|
|
WithKey("Enter").
|
|
WithCode("Enter").
|
|
WithNativeVirtualKeyCode(13).
|
|
WithWindowsVirtualKeyCode(13).
|
|
Do(ctx)
|
|
}),
|
|
)
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("failed to send message via SendKeys: %w", err)
|
|
}
|
|
|
|
c.log.Debugf("Sent message: %s", text)
|
|
return nil
|
|
}
|
|
|
|
// Close closes the Chrome instance
|
|
func (c *ChromeDPClient) Close() error {
|
|
c.log.Info("Closing ChromeDP client")
|
|
|
|
c.mu.Lock()
|
|
c.connected = false
|
|
c.mu.Unlock()
|
|
|
|
if c.cancel != nil {
|
|
c.cancel()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// IsConnected returns whether the client is connected
|
|
func (c *ChromeDPClient) IsConnected() bool {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.connected
|
|
}
|
|
|
|
// escapeJSString escapes a string for use in JavaScript
|
|
func escapeJSString(s string) string {
|
|
b, _ := json.Marshal(s)
|
|
return string(b)
|
|
}
|
|
|