proof-of-concept worked. rolling into main

This commit is contained in:
cottongin
2025-10-31 20:38:38 -04:00
parent 020daea391
commit c63c29efc9
5 changed files with 855 additions and 2 deletions

64
WEBSOCKET_FINDINGS.md Normal file
View File

@@ -0,0 +1,64 @@
# Kosmi WebSocket Protocol Findings
## Key Discoveries from Browser Monitoring
### Room ID Format
- Browser uses `"@hyperspaceout"` (WITH @ symbol) for all operations
- Our script was using `"hyperspaceout"` (WITHOUT @) - this is why `joinRoom` failed with `ROOM_NOT_FOUND`
### Page Load Sequence
The browser does NOT call `joinRoom` mutation. Instead, it:
1. **Connects to WebSocket** at `wss://engine.kosmi.io/gql-ws`
2. **Sends `connection_init`** with auth token
3. **Immediately starts subscribing** to various topics:
- `OnNewMessage(roomId: "@hyperspaceout")` - for chat messages
- `OnLinkedMembers(roomId: "@hyperspaceout")` - for room members
- `OnMediaPlayerUpdateState(roomId: "@hyperspaceout")` - for media player
- `OnMediaPlayerUpdateSubtitles(roomId: "@hyperspaceout")` - for subtitles
- `OnMediasoupUpdate(roomId: "@hyperspaceout")` - for WebRTC
- `OnRoomUpdate(roomId: "@hyperspaceout")` - for room state
- `OnMessageReadersUpdate(roomId: "@hyperspaceout")` - for read receipts
### Sending Messages
To send a message, the browser likely uses:
```graphql
mutation SendMessage($body: String!, $roomId: String!) {
sendMessage(body: $body, roomId: $roomId) {
ok
}
}
```
With variables:
```json
{
"body": "message text",
"roomId": "@hyperspaceout"
}
```
### Authentication
The `connection_init` payload includes:
- `token`: JWT from `anonLogin` mutation
- `ua`: Base64-encoded User-Agent
- `v`: App version "4364"
- `r`: Empty string for anonymous users
## Next Steps
1. ✅ Use `"@hyperspaceout"` (with @) instead of `"hyperspaceout"`
2. ✅ Skip the `joinRoom` mutation entirely
3. ✅ Just send the `sendMessage` mutation directly after `connection_ack`
4. Test if the message appears in chat
## Important Note
The `sendMessage` mutation returns `{ ok: true }` even when the message doesn't appear in chat. This suggests that:
- The mutation succeeds on the server side
- But the message might be filtered or not broadcast
- Possibly because we're not "subscribed" to the room's message feed
- Or because anonymous users have restrictions
We should subscribe to `OnNewMessage` to see if our sent messages come back through the subscription.

View File

@@ -169,12 +169,12 @@ func (b *Bkosmi) handleIncomingMessage(payload *NewMessagePayload) {
// Send to Matterbridge
b.Log.Debugf("Forwarding to Matterbridge channel=%s account=%s: %s", rmsg.Channel, rmsg.Account, rmsg.Text)
if b.Remote == nil {
b.Log.Error("Remote channel is nil! Cannot forward message")
return
}
b.Remote <- rmsg
}

226
cmd/monitor-ws/main.go Normal file
View File

@@ -0,0 +1,226 @@
package main
import (
"log"
"os"
"os/signal"
"time"
"github.com/playwright-community/playwright-go"
)
const (
roomURL = "https://app.kosmi.io/room/@hyperspaceout"
)
func main() {
log.Println("🔍 Starting Kosmi WebSocket Monitor")
log.Printf("📡 Room URL: %s", roomURL)
log.Println("This will capture ALL WebSocket traffic from the browser...")
log.Println()
// Set up interrupt handler
interrupt := make(chan os.Signal, 1)
signal.Notify(interrupt, os.Interrupt)
// Launch Playwright
pw, err := playwright.Run()
if err != nil {
log.Fatalf("Failed to start Playwright: %v", err)
}
defer pw.Stop()
// Launch browser
browser, err := pw.Chromium.Launch(playwright.BrowserTypeLaunchOptions{
Headless: playwright.Bool(false), // Keep visible so we can see what's happening
})
if err != nil {
log.Fatalf("Failed to launch browser: %v", err)
}
defer browser.Close()
// 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 {
log.Fatalf("Failed to create context: %v", err)
}
// Create page
page, err := context.NewPage()
if err != nil {
log.Fatalf("Failed to create page: %v", err)
}
// Inject WebSocket monitoring script BEFORE navigation
log.Println("📝 Injecting WebSocket monitoring script...")
if err := page.AddInitScript(playwright.Script{
Content: playwright.String(`
(function() {
const OriginalWebSocket = window.WebSocket;
let messageCount = 0;
window.WebSocket = function(url, protocols) {
console.log('🔌 [WS MONITOR] WebSocket created:', url, 'protocols:', protocols);
const socket = new OriginalWebSocket(url, protocols);
socket.addEventListener('open', (event) => {
console.log('✅ [WS MONITOR] WebSocket OPENED');
});
socket.addEventListener('close', (event) => {
console.log('🔴 [WS MONITOR] WebSocket CLOSED:', event.code, event.reason);
});
socket.addEventListener('error', (event) => {
console.error('❌ [WS MONITOR] WebSocket ERROR:', event);
});
// Intercept outgoing messages
const originalSend = socket.send;
socket.send = function(data) {
messageCount++;
console.log('📤 [WS MONITOR] SEND #' + messageCount + ':', data);
try {
const parsed = JSON.parse(data);
console.log(' Type:', parsed.type, 'ID:', parsed.id);
if (parsed.payload) {
console.log(' Payload:', JSON.stringify(parsed.payload, null, 2));
}
} catch (e) {
// Not JSON
}
return originalSend.call(this, data);
};
// Intercept incoming messages
socket.addEventListener('message', (event) => {
messageCount++;
console.log('📥 [WS MONITOR] RECEIVE #' + messageCount + ':', event.data);
try {
const parsed = JSON.parse(event.data);
console.log(' Type:', parsed.type, 'ID:', parsed.id);
if (parsed.payload) {
console.log(' Payload:', JSON.stringify(parsed.payload, null, 2));
}
} catch (e) {
// Not JSON
}
});
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;
})();
`),
}); err != nil {
log.Fatalf("Failed to inject script: %v", err)
}
// Listen to console messages
page.On("console", func(msg playwright.ConsoleMessage) {
text := msg.Text()
msgType := msg.Type()
// Format the output nicely
prefix := "💬"
switch msgType {
case "log":
prefix = "📋"
case "error":
prefix = "❌"
case "warning":
prefix = "⚠️"
case "info":
prefix = ""
}
log.Printf("%s [BROWSER %s] %s", prefix, msgType, text)
})
// Navigate to room
log.Printf("🌐 Navigating to %s...", roomURL)
if _, err := page.Goto(roomURL, playwright.PageGotoOptions{
WaitUntil: playwright.WaitUntilStateDomcontentloaded,
}); err != nil {
log.Fatalf("Failed to navigate: %v", err)
}
log.Println("✅ Page loaded! Monitoring WebSocket traffic...")
log.Println("Press Ctrl+C to stop monitoring")
log.Println()
// Wait for a bit to see initial traffic
time.Sleep(5 * time.Second)
// Try to send a test message
log.Println("\n📝 Attempting to send a test message via UI...")
result, err := page.Evaluate(`
(async function() {
// Find the chat input
const textareas = document.querySelectorAll('textarea');
for (let ta of textareas) {
if (ta.offsetParent !== null) {
ta.value = 'Test message from monitor script 🔍';
ta.dispatchEvent(new Event('input', { bubbles: true }));
ta.dispatchEvent(new Event('change', { bubbles: true }));
ta.focus();
// Wait a bit
await new Promise(resolve => setTimeout(resolve, 100));
// Try to find and click send button
const buttons = document.querySelectorAll('button');
for (let btn of buttons) {
if (btn.textContent.toLowerCase().includes('send') ||
btn.getAttribute('aria-label')?.toLowerCase().includes('send')) {
btn.click();
return { success: true, method: 'button' };
}
}
// If no button, press Enter
const enterEvent = new KeyboardEvent('keydown', {
key: 'Enter',
code: 'Enter',
keyCode: 13,
which: 13,
bubbles: true,
cancelable: true
});
ta.dispatchEvent(enterEvent);
return { success: true, method: 'enter' };
}
}
return { success: false, error: 'No input found' };
})();
`)
if err != nil {
log.Printf("❌ Failed to send test message: %v", err)
} else {
resultMap := result.(map[string]interface{})
if success, ok := resultMap["success"].(bool); ok && success {
method := resultMap["method"].(string)
log.Printf("✅ Test message sent via %s", method)
log.Println("📊 Check the console output above to see the WebSocket traffic!")
} else {
log.Printf("❌ Failed to send: %v", resultMap["error"])
}
}
// Keep monitoring
log.Println("\n⏳ Continuing to monitor... Press Ctrl+C to stop")
// Wait for interrupt
<-interrupt
log.Println("\n👋 Stopping monitor...")
}

View File

@@ -0,0 +1,51 @@
# GraphQL-WS Proof of Concept
This is a simple test script to verify we can send messages to Kosmi using the WebSocket GraphQL API directly, following the `graphql-ws` protocol.
## What This Tests
1. Connects to Kosmi's WebSocket endpoint (`wss://engine.kosmi.io/gql-ws`)
2. Follows the graphql-ws protocol:
- Sends `connection_init`
- Waits for `connection_ack`
- Sends a mutation using `subscribe` message type
3. Attempts to send "Hello World from GraphQL-WS! 🎉" to the chat
## Protocol Details
Kosmi uses the [graphql-ws protocol](https://github.com/enisdenjo/graphql-ws/blob/master/PROTOCOL.md), which is different from the older `subscriptions-transport-ws`.
Key differences:
- Uses `graphql-transport-ws` subprotocol
- Both queries/mutations AND subscriptions use `type: "subscribe"`
- Must send `connection_init` before any operations
## Running the Test
```bash
# From the project root
go run cmd/test-graphql-ws/main.go
```
## Expected Behavior
If successful, you should see:
1. ✅ WebSocket connected
2. ✅ Connection acknowledged by server
3. ✅ Message sent
4. 📨 Response from server (either success or error)
## What We're Testing
This will help us determine if:
- We can connect to Kosmi's WebSocket without browser automation
- The authentication works (or what auth is needed)
- We can send messages via GraphQL mutations over WebSocket
- We can replace the UI automation approach with direct WebSocket
## Notes
- Change `roomID` in the code to match your Kosmi room
- The script will keep running until you press Ctrl+C
- All messages are logged for debugging

512
cmd/test-graphql-ws/main.go Normal file
View File

@@ -0,0 +1,512 @@
package main
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"os/signal"
"time"
"github.com/gorilla/websocket"
)
const (
// Kosmi WebSocket endpoint
kosmiWSURL = "wss://engine.kosmi.io/gql-ws"
// Your room ID (MUST include @ symbol!)
roomID = "@hyperspaceout"
// User agent and app version (from AUTH_FINDINGS.md)
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"
appVersion = "4364"
)
// GraphQL-WS Protocol message types
const (
MessageTypeConnectionInit = "connection_init"
MessageTypeConnectionAck = "connection_ack"
MessageTypeSubscribe = "subscribe"
MessageTypeNext = "next"
MessageTypeError = "error"
MessageTypeComplete = "complete"
)
// Message represents a graphql-ws protocol message
type Message struct {
ID string `json:"id,omitempty"`
Type string `json:"type"`
Payload interface{} `json:"payload,omitempty"` // Can be map or array
}
func main() {
log.Println("🚀 Starting Kosmi GraphQL-WS Proof of Concept")
log.Printf("📡 WebSocket URL: %s", kosmiWSURL)
log.Printf("🏠 Room ID: %s", roomID)
// Step 1: Get anonymous token
log.Println("\n📝 Step 1: Getting anonymous token...")
token, err := getAnonymousToken()
if err != nil {
log.Fatalf("❌ Failed to get token: %v", err)
}
log.Printf("✅ Got token: %s...", token[:50])
// Set up interrupt handler
interrupt := make(chan os.Signal, 1)
signal.Notify(interrupt, os.Interrupt)
log.Println("\n🔌 Step 2: Connecting to WebSocket...")
// Create WebSocket dialer with headers
dialer := websocket.Dialer{
Subprotocols: []string{"graphql-transport-ws"},
HandshakeTimeout: 10 * time.Second,
}
// Add headers that a browser would send
headers := http.Header{}
headers.Add("Origin", "https://app.kosmi.io")
headers.Add("User-Agent", userAgent)
// Connect to WebSocket
log.Println("🔌 Establishing WebSocket connection...")
conn, resp, err := dialer.Dial(kosmiWSURL, headers)
if err != nil {
log.Fatalf("❌ Failed to connect: %v (response: %+v)", err, resp)
}
defer conn.Close()
log.Println("✅ WebSocket connected!")
log.Printf("📋 Response status: %s", resp.Status)
log.Printf("📋 Subprotocol: %s", conn.Subprotocol())
// Channel for receiving messages
done := make(chan struct{})
// Start reading messages
go func() {
defer close(done)
for {
var msg Message
err := conn.ReadJSON(&msg)
if err != nil {
log.Printf("❌ Read error: %v", err)
return
}
log.Printf("📨 Received: %s (id: %s)", msg.Type, msg.ID)
if msg.Payload != nil {
payloadJSON, _ := json.MarshalIndent(msg.Payload, "", " ")
log.Printf(" Payload: %s", string(payloadJSON))
}
// Handle different message types
switch msg.Type {
case MessageTypeConnectionAck:
log.Println("✅ Connection acknowledged by server!")
// First, subscribe to messages (like the browser does)
go subscribeToMessages(conn)
case MessageTypeNext:
log.Println("✅ Received 'next' - operation successful!")
case MessageTypeError:
log.Println("❌ Received 'error' from server!")
case MessageTypeComplete:
log.Println("✅ Received 'complete' - operation finished!")
// After joining, send a message
if msg.ID == "join-room" {
log.Println("\n⏭ Room joined, now sending message...")
go sendMessage(conn)
}
}
}
}()
// Step 1: Send connection_init with required payload
log.Println("📤 Sending connection_init...")
// Base64 encode the user agent (required by Kosmi)
uaEncoded := base64.StdEncoding.EncodeToString([]byte(userAgent))
initMsg := Message{
Type: MessageTypeConnectionInit,
Payload: map[string]interface{}{
"token": token, // JWT token from anonLogin
"ua": uaEncoded, // Base64-encoded User-Agent
"v": appVersion, // App version "4364"
"r": "", // Room-related field (empty for anonymous)
},
}
if err := conn.WriteJSON(initMsg); err != nil {
log.Fatalf("❌ Failed to send connection_init: %v", err)
}
log.Println(" Payload: token, ua (base64), v=4364, r=\"\"")
// Wait for interrupt or done
select {
case <-done:
log.Println("🛑 Connection closed")
case <-interrupt:
log.Println("🛑 Interrupt received, closing connection...")
// Send close message
err := conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
if err != nil {
log.Printf("❌ Error sending close: %v", err)
}
select {
case <-done:
case <-time.After(time.Second):
}
}
}
func getAnonymousToken() (string, error) {
// GraphQL mutation for anonymous login
mutation := map[string]interface{}{
"query": `mutation { anonLogin { token } }`,
}
jsonBody, err := json.Marshal(mutation)
if err != nil {
return "", err
}
log.Printf(" Sending: %s", string(jsonBody))
// Create HTTP client
client := &http.Client{Timeout: 10 * time.Second}
// Create request with body
req, err := http.NewRequest("POST", "https://engine.kosmi.io/", nil)
if err != nil {
return "", err
}
// Set headers and body
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Referer", "https://app.kosmi.io/")
req.Header.Set("User-Agent", userAgent)
req.Body = io.NopCloser(bytes.NewReader(jsonBody))
req.ContentLength = int64(len(jsonBody))
// Send request
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
log.Printf(" Response status: %d", resp.StatusCode)
if resp.StatusCode != 200 {
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
}
// Parse response
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
// Extract token from response
if data, ok := result["data"].(map[string]interface{}); ok {
if anonLogin, ok := data["anonLogin"].(map[string]interface{}); ok {
if token, ok := anonLogin["token"].(string); ok {
return token, nil
}
}
}
resultJSON, _ := json.MarshalIndent(result, "", " ")
return "", fmt.Errorf("no token in response: %s", string(resultJSON))
}
func subscribeToMessages(conn *websocket.Conn) {
// Wait a bit
time.Sleep(1 * time.Second)
log.Println("\n📨 Step 3: Subscribing to message feed (like browser does)...")
log.Printf(" Room ID: %s", roomID)
// Subscribe to new messages (copied from browser monitoring)
subscription := `subscription OnNewMessage($roomId: String!) {
newMessage(roomId: $roomId) {
id
body
time
user {
id
displayName
username
avatarUrl
}
}
}`
msg := Message{
ID: "subscribe-messages",
Type: MessageTypeSubscribe,
Payload: map[string]interface{}{
"query": subscription,
"variables": map[string]interface{}{
"roomId": roomID,
},
},
}
msgJSON, _ := json.MarshalIndent(msg, "", " ")
log.Printf("📋 Sending subscription:\n%s", string(msgJSON))
if err := conn.WriteJSON(msg); err != nil {
log.Printf("❌ Failed to subscribe: %v", err)
return
}
log.Println("✅ Subscription sent!")
log.Println(" Note: Subscriptions stay open and don't send 'complete'")
log.Println(" Proceeding to next step...")
// Wait a bit, then proceed to join room
time.Sleep(2 * time.Second)
go joinRoomWithCorrectID(conn)
}
func joinRoomWithCorrectID(conn *websocket.Conn) {
// Wait a bit
time.Sleep(1 * time.Second)
log.Println("\n🚪 Step 4: Joining room with correct ID format...")
log.Printf(" Room ID: %s", roomID)
// Join room mutation with correct ID format
mutation := `mutation JoinRoom($id: String!) {
joinRoom(id: $id) {
ok
}
}`
msg := Message{
ID: "join-room",
Type: MessageTypeSubscribe,
Payload: map[string]interface{}{
"query": mutation,
"variables": map[string]interface{}{
"id": roomID,
},
},
}
msgJSON, _ := json.MarshalIndent(msg, "", " ")
log.Printf("📋 Sending joinRoom mutation:\n%s", string(msgJSON))
if err := conn.WriteJSON(msg); err != nil {
log.Printf("❌ Failed to join room: %v", err)
return
}
log.Println("✅ Join room request sent! Waiting for response...")
}
func joinRoom(conn *websocket.Conn) {
// Wait a bit to ensure connection is fully established
time.Sleep(1 * time.Second)
log.Println("\n🚪 Step 3: Getting room info and joining...")
// First, query for the room to get its actual ID
query := `query GetRoom($name: String!) {
roomByName(name: $name) {
id
name
displayName
}
}`
msg := Message{
ID: "get-room",
Type: MessageTypeSubscribe,
Payload: map[string]interface{}{
"query": query,
"variables": map[string]interface{}{
"name": roomID,
},
},
}
msgJSON, _ := json.MarshalIndent(msg, "", " ")
log.Printf("📋 Querying for room by name:\n%s", string(msgJSON))
if err := conn.WriteJSON(msg); err != nil {
log.Printf("❌ Failed to query room: %v", err)
return
}
log.Println("✅ Room query sent! Waiting for response...")
}
func actuallyJoinRoom(conn *websocket.Conn) {
// Wait a bit
time.Sleep(1 * time.Second)
log.Println("\n🚪 Step 3b: Actually joining room with ID...")
// Join room mutation - we'll use the room name for now
// The server might accept the name directly
mutation := `mutation JoinRoom($id: String!) {
joinRoom(id: $id) {
ok
}
}`
msg := Message{
ID: "join-room",
Type: MessageTypeSubscribe,
Payload: map[string]interface{}{
"query": mutation,
"variables": map[string]interface{}{
"id": roomID,
},
},
}
msgJSON, _ := json.MarshalIndent(msg, "", " ")
log.Printf("📋 Sending joinRoom mutation:\n%s", string(msgJSON))
if err := conn.WriteJSON(msg); err != nil {
log.Printf("❌ Failed to join room: %v", err)
return
}
log.Println("✅ Join room request sent! Waiting for response...")
}
func introspectSendMessage(conn *websocket.Conn) {
// Wait a bit
time.Sleep(1 * time.Second)
log.Println("\n🔍 Step 4: Introspecting room and message mutations...")
// Targeted introspection for room/message related mutations only
introspectionQuery := `query IntrospectRoomMutations {
mutations: __type(name: "RootMutationType") {
fields {
name
args {
name
type {
name
kind
ofType {
name
kind
}
}
}
type {
name
kind
fields {
name
type {
name
kind
}
}
}
}
}
queries: __type(name: "RootQueryType") {
fields {
name
args {
name
type {
name
kind
ofType {
name
kind
}
}
}
type {
name
kind
}
}
}
}`
msg := Message{
ID: "introspect-sendMessage",
Type: MessageTypeSubscribe,
Payload: map[string]interface{}{
"query": introspectionQuery,
},
}
msgJSON, _ := json.MarshalIndent(msg, "", " ")
log.Printf("📋 Sending introspection query:\n%s", string(msgJSON))
if err := conn.WriteJSON(msg); err != nil {
log.Printf("❌ Failed to send introspection: %v", err)
return
}
log.Println("✅ Introspection sent! Waiting for response...")
}
func sendMessage(conn *websocket.Conn) {
// Wait a bit
time.Sleep(2 * time.Second)
log.Println("\n📤 Step 5: Attempting to send 'Hello World' message...")
log.Printf(" Using room ID: %s", roomID)
// Fixed mutation based on introspection and browser monitoring:
// - roomId is String!, not ID!
// - roomId MUST include @ symbol (e.g., "@hyperspaceout")
// - sendMessage returns Success type with field "ok: Boolean"
mutation := `mutation SendMessage($body: String!, $roomId: String!) {
sendMessage(body: $body, roomId: $roomId) {
ok
}
}`
// Create subscribe message (yes, mutations use "subscribe" in graphql-ws!)
msg := Message{
ID: fmt.Sprintf("send-message-%d", time.Now().Unix()),
Type: MessageTypeSubscribe,
Payload: map[string]interface{}{
"query": mutation,
"variables": map[string]interface{}{
"body": "Hello World from GraphQL-WS! 🎉",
"roomId": roomID,
},
},
}
msgJSON, _ := json.MarshalIndent(msg, "", " ")
log.Printf("📋 Sending message:\n%s", string(msgJSON))
if err := conn.WriteJSON(msg); err != nil {
log.Printf("❌ Failed to send message: %v", err)
return
}
log.Println("✅ Message sent! Waiting for response...")
log.Println(" Check the Kosmi chat to see if the message appeared!")
}