522 lines
13 KiB
Go
522 lines
13 KiB
Go
package bkosmi
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/playwright-community/playwright-go"
|
|
"github.com/sirupsen/logrus"
|
|
)
|
|
|
|
// NativeClient uses Playwright to establish WebSocket, then interacts directly via JavaScript
|
|
type NativeClient struct {
|
|
roomURL string
|
|
roomID string
|
|
log *logrus.Entry
|
|
pw *playwright.Playwright
|
|
browser playwright.Browser
|
|
page playwright.Page
|
|
messageCallback func(*NewMessagePayload)
|
|
connected bool
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
// NewNativeClient creates a new native client with Playwright-assisted connection
|
|
func NewNativeClient(roomURL, roomID string, log *logrus.Entry) *NativeClient {
|
|
return &NativeClient{
|
|
roomURL: roomURL,
|
|
roomID: roomID,
|
|
log: log,
|
|
}
|
|
}
|
|
|
|
// Connect launches Playwright and establishes the WebSocket connection
|
|
func (c *NativeClient) Connect() error {
|
|
c.log.Info("Starting Playwright native client")
|
|
|
|
// Launch Playwright
|
|
pw, err := playwright.Run()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to start Playwright: %w", err)
|
|
}
|
|
c.pw = pw
|
|
|
|
// Launch browser with resource optimizations
|
|
browser, err := pw.Chromium.Launch(playwright.BrowserTypeLaunchOptions{
|
|
Headless: playwright.Bool(true),
|
|
Args: []string{
|
|
"--no-sandbox",
|
|
"--disable-dev-shm-usage",
|
|
"--disable-blink-features=AutomationControlled",
|
|
|
|
// Resource optimizations for reduced CPU/memory usage
|
|
"--disable-gpu", // No GPU needed for chat
|
|
"--disable-software-rasterizer", // No rendering needed
|
|
"--disable-extensions", // No extensions needed
|
|
"--disable-background-networking", // No background requests
|
|
"--disable-background-timer-throttling",
|
|
"--disable-backgrounding-occluded-windows",
|
|
"--disable-breakpad", // No crash reporting
|
|
"--disable-component-extensions-with-background-pages",
|
|
"--disable-features=TranslateUI", // No translation UI
|
|
"--disable-ipc-flooding-protection",
|
|
"--disable-renderer-backgrounding",
|
|
"--force-color-profile=srgb",
|
|
"--metrics-recording-only",
|
|
"--no-first-run", // Skip first-run tasks
|
|
"--mute-audio", // No audio needed
|
|
},
|
|
})
|
|
if err != nil {
|
|
c.pw.Stop()
|
|
return fmt.Errorf("failed to launch browser: %w", err)
|
|
}
|
|
c.browser = browser
|
|
|
|
// Create context
|
|
context, err := browser.NewContext(playwright.BrowserNewContextOptions{
|
|
UserAgent: playwright.String("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"),
|
|
})
|
|
if err != nil {
|
|
c.browser.Close()
|
|
c.pw.Stop()
|
|
return fmt.Errorf("failed to create context: %w", err)
|
|
}
|
|
|
|
// Create page
|
|
page, err := context.NewPage()
|
|
if err != nil {
|
|
c.browser.Close()
|
|
c.pw.Stop()
|
|
return fmt.Errorf("failed to create page: %w", err)
|
|
}
|
|
c.page = page
|
|
|
|
// Inject WebSocket interceptor
|
|
c.log.Debug("Injecting WebSocket access layer")
|
|
if err := c.injectWebSocketAccess(); err != nil {
|
|
c.Disconnect()
|
|
return fmt.Errorf("failed to inject WebSocket access: %w", err)
|
|
}
|
|
|
|
// Navigate to room
|
|
c.log.Infof("Navigating to Kosmi room: %s", c.roomURL)
|
|
if _, err := page.Goto(c.roomURL, playwright.PageGotoOptions{
|
|
WaitUntil: playwright.WaitUntilStateDomcontentloaded, // Wait for DOM only, not all resources
|
|
}); err != nil {
|
|
c.Disconnect()
|
|
return fmt.Errorf("failed to navigate: %w", err)
|
|
}
|
|
|
|
// Wait for WebSocket to establish
|
|
c.log.Debug("Waiting for WebSocket connection")
|
|
if err := c.waitForWebSocket(); err != nil {
|
|
c.Disconnect()
|
|
return fmt.Errorf("WebSocket not established: %w", err)
|
|
}
|
|
|
|
// Subscribe to room messages
|
|
c.log.Debugf("Subscribing to messages in room %s", c.roomID)
|
|
if err := c.subscribeToMessages(); err != nil {
|
|
c.Disconnect()
|
|
return fmt.Errorf("failed to subscribe: %w", err)
|
|
}
|
|
|
|
c.mu.Lock()
|
|
c.connected = true
|
|
c.mu.Unlock()
|
|
|
|
c.log.Info("Native client connected successfully")
|
|
|
|
// Start message listener
|
|
go c.listenForMessages()
|
|
|
|
return nil
|
|
}
|
|
|
|
// injectWebSocketAccess injects JavaScript that provides direct WebSocket access
|
|
func (c *NativeClient) injectWebSocketAccess() error {
|
|
script := `
|
|
(function() {
|
|
if (window.__KOSMI_NATIVE_CLIENT__) return;
|
|
|
|
const OriginalWebSocket = window.WebSocket;
|
|
window.__KOSMI_WS__ = null;
|
|
window.__KOSMI_MESSAGE_QUEUE__ = [];
|
|
window.__KOSMI_READY__ = false;
|
|
|
|
// Hook WebSocket constructor to capture the connection
|
|
window.WebSocket = function(url, protocols) {
|
|
const socket = new OriginalWebSocket(url, protocols);
|
|
|
|
if (url.includes('engine.kosmi.io') || url.includes('gql-ws')) {
|
|
window.__KOSMI_WS__ = socket;
|
|
|
|
// Hook message handler to queue messages
|
|
socket.addEventListener('message', (event) => {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
window.__KOSMI_MESSAGE_QUEUE__.push({
|
|
timestamp: Date.now(),
|
|
data: data
|
|
});
|
|
} catch (e) {
|
|
// Ignore non-JSON messages
|
|
}
|
|
});
|
|
|
|
// Mark as ready when connection opens
|
|
socket.addEventListener('open', () => {
|
|
window.__KOSMI_READY__ = true;
|
|
});
|
|
}
|
|
|
|
return socket;
|
|
};
|
|
|
|
// Preserve WebSocket 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_NATIVE_CLIENT__ = true;
|
|
})();
|
|
`
|
|
|
|
return c.page.AddInitScript(playwright.Script{
|
|
Content: playwright.String(script),
|
|
})
|
|
}
|
|
|
|
// waitForWebSocket waits for the WebSocket to be established
|
|
func (c *NativeClient) waitForWebSocket() error {
|
|
for i := 0; i < 30; i++ { // 15 seconds max
|
|
result, err := c.page.Evaluate(`
|
|
(function() {
|
|
return {
|
|
ready: !!window.__KOSMI_READY__,
|
|
wsExists: !!window.__KOSMI_WS__,
|
|
wsState: window.__KOSMI_WS__ ? window.__KOSMI_WS__.readyState : -1
|
|
};
|
|
})();
|
|
`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
status := result.(map[string]interface{})
|
|
ready := status["ready"].(bool)
|
|
|
|
if ready {
|
|
c.log.Info("✅ WebSocket is ready")
|
|
return nil
|
|
}
|
|
|
|
if i%5 == 0 {
|
|
c.log.Debugf("Waiting for WebSocket... (attempt %d/30)", i+1)
|
|
}
|
|
|
|
time.Sleep(500 * time.Millisecond)
|
|
}
|
|
|
|
return fmt.Errorf("timeout waiting for WebSocket")
|
|
}
|
|
|
|
// subscribeToMessages subscribes to room messages via the WebSocket
|
|
func (c *NativeClient) subscribeToMessages() error {
|
|
script := fmt.Sprintf(`
|
|
(function() {
|
|
if (!window.__KOSMI_WS__ || window.__KOSMI_WS__.readyState !== WebSocket.OPEN) {
|
|
return { success: false, error: 'WebSocket not ready' };
|
|
}
|
|
|
|
const subscription = {
|
|
id: 'native-client-subscription',
|
|
type: 'subscribe',
|
|
payload: {
|
|
query: 'subscription { newMessage(roomId: "%s") { body time user { displayName username } } }',
|
|
variables: {}
|
|
}
|
|
};
|
|
|
|
try {
|
|
window.__KOSMI_WS__.send(JSON.stringify(subscription));
|
|
return { success: true };
|
|
} catch (e) {
|
|
return { success: false, error: e.toString() };
|
|
}
|
|
})();
|
|
`, c.roomID)
|
|
|
|
result, err := c.page.Evaluate(script)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
response := result.(map[string]interface{})
|
|
if success, ok := response["success"].(bool); !ok || !success {
|
|
errMsg := "unknown error"
|
|
if e, ok := response["error"].(string); ok {
|
|
errMsg = e
|
|
}
|
|
return fmt.Errorf("subscription failed: %s", errMsg)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// listenForMessages continuously polls for new messages
|
|
func (c *NativeClient) listenForMessages() {
|
|
ticker := time.NewTicker(500 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
c.mu.RLock()
|
|
if !c.connected {
|
|
c.mu.RUnlock()
|
|
return
|
|
}
|
|
c.mu.RUnlock()
|
|
|
|
if err := c.pollMessages(); err != nil {
|
|
c.log.Errorf("Error polling messages: %v", err)
|
|
}
|
|
|
|
<-ticker.C
|
|
}
|
|
}
|
|
|
|
// pollMessages retrieves and processes messages from the queue
|
|
func (c *NativeClient) pollMessages() error {
|
|
result, err := c.page.Evaluate(`
|
|
(function() {
|
|
if (!window.__KOSMI_MESSAGE_QUEUE__) return null;
|
|
if (window.__KOSMI_MESSAGE_QUEUE__.length === 0) return null;
|
|
const messages = window.__KOSMI_MESSAGE_QUEUE__.slice();
|
|
window.__KOSMI_MESSAGE_QUEUE__ = [];
|
|
return messages;
|
|
})();
|
|
`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Early return if no messages (reduces CPU during idle)
|
|
if result == nil {
|
|
return nil
|
|
}
|
|
|
|
messagesJSON, err := json.Marshal(result)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var messages []struct {
|
|
Timestamp int64 `json:"timestamp"`
|
|
Data map[string]interface{} `json:"data"`
|
|
}
|
|
|
|
if err := json.Unmarshal(messagesJSON, &messages); err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, msg := range messages {
|
|
c.processMessage(msg.Data)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// processMessage processes a single WebSocket message
|
|
func (c *NativeClient) 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
|
|
}
|
|
|
|
// Parse into our struct
|
|
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 by typing into the Kosmi chat input field
|
|
func (c *NativeClient) SendMessage(text string) error {
|
|
c.mu.RLock()
|
|
if !c.connected {
|
|
c.mu.RUnlock()
|
|
return fmt.Errorf("not connected")
|
|
}
|
|
c.mu.RUnlock()
|
|
|
|
c.log.Debugf("Sending message to Kosmi: %s", text)
|
|
|
|
// Escape the message text for JavaScript
|
|
textJSON, _ := json.Marshal(text)
|
|
|
|
script := fmt.Sprintf(`
|
|
(async function() {
|
|
try {
|
|
// Try multiple strategies to find the chat input
|
|
let input = null;
|
|
|
|
// Strategy 1: Look for textarea
|
|
const textareas = document.querySelectorAll('textarea');
|
|
for (let ta of textareas) {
|
|
if (ta.offsetParent !== null) { // visible
|
|
input = ta;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Strategy 2: Look for contenteditable
|
|
if (!input) {
|
|
const editables = document.querySelectorAll('[contenteditable="true"]');
|
|
for (let ed of editables) {
|
|
if (ed.offsetParent !== null) { // visible
|
|
input = ed;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Strategy 3: Look for input text
|
|
if (!input) {
|
|
const inputs = document.querySelectorAll('input[type="text"]');
|
|
for (let inp of inputs) {
|
|
if (inp.offsetParent !== null) { // visible
|
|
input = inp;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!input) {
|
|
return { success: false, error: 'Could not find any visible input element' };
|
|
}
|
|
|
|
// Set the value based on element type
|
|
if (input.contentEditable === 'true') {
|
|
input.textContent = %s;
|
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
} else {
|
|
input.value = %s;
|
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
}
|
|
|
|
// Focus the input
|
|
input.focus();
|
|
|
|
// Wait a tiny bit
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
|
// Find and click the send button, or press Enter
|
|
const sendButton = document.querySelector('button[type="submit"], button[class*="send" i], button[aria-label*="send" i]');
|
|
if (sendButton && sendButton.offsetParent !== null) {
|
|
sendButton.click();
|
|
} else {
|
|
// Simulate Enter key press
|
|
const enterEvent = new KeyboardEvent('keydown', {
|
|
key: 'Enter',
|
|
code: 'Enter',
|
|
keyCode: 13,
|
|
which: 13,
|
|
bubbles: true,
|
|
cancelable: true
|
|
});
|
|
input.dispatchEvent(enterEvent);
|
|
}
|
|
|
|
return { success: true };
|
|
} catch (e) {
|
|
return { success: false, error: e.toString() };
|
|
}
|
|
})();
|
|
`, string(textJSON), string(textJSON))
|
|
|
|
result, err := c.page.Evaluate(script)
|
|
if err != nil {
|
|
c.log.Errorf("Failed to execute send script: %v", err)
|
|
return fmt.Errorf("failed to execute send: %w", err)
|
|
}
|
|
|
|
response := result.(map[string]interface{})
|
|
if success, ok := response["success"].(bool); !ok || !success {
|
|
errMsg := "unknown error"
|
|
if e, ok := response["error"].(string); ok {
|
|
errMsg = e
|
|
}
|
|
c.log.Errorf("Send failed: %s", errMsg)
|
|
return fmt.Errorf("send failed: %s", errMsg)
|
|
}
|
|
|
|
c.log.Debug("Successfully sent message to Kosmi")
|
|
return nil
|
|
}
|
|
|
|
// OnMessage registers a callback for incoming messages
|
|
func (c *NativeClient) OnMessage(callback func(*NewMessagePayload)) {
|
|
c.messageCallback = callback
|
|
}
|
|
|
|
// Disconnect closes the Playwright browser
|
|
func (c *NativeClient) Disconnect() error {
|
|
c.mu.Lock()
|
|
c.connected = false
|
|
c.mu.Unlock()
|
|
|
|
c.log.Debug("Closing Playwright browser")
|
|
|
|
if c.browser != nil {
|
|
c.browser.Close()
|
|
}
|
|
|
|
if c.pw != nil {
|
|
c.pw.Stop()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// IsConnected returns whether the client is connected
|
|
func (c *NativeClient) IsConnected() bool {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.connected
|
|
}
|
|
|