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

482 lines
13 KiB
Go

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
}