working v1
This commit is contained in:
481
bridge/kosmi/hybrid_client.go
Normal file
481
bridge/kosmi/hybrid_client.go
Normal file
@@ -0,0 +1,481 @@
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user