482 lines
13 KiB
Go
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
|
|
}
|
|
|