From c63c29efc9660269223eda6ac5925e5fe6b5075a Mon Sep 17 00:00:00 2001 From: cottongin Date: Fri, 31 Oct 2025 20:38:38 -0400 Subject: [PATCH] proof-of-concept worked. rolling into main --- WEBSOCKET_FINDINGS.md | 64 +++++ bridge/kosmi/kosmi.go | 4 +- cmd/monitor-ws/main.go | 226 +++++++++++++++ cmd/test-graphql-ws/README.md | 51 ++++ cmd/test-graphql-ws/main.go | 512 ++++++++++++++++++++++++++++++++++ 5 files changed, 855 insertions(+), 2 deletions(-) create mode 100644 WEBSOCKET_FINDINGS.md create mode 100644 cmd/monitor-ws/main.go create mode 100644 cmd/test-graphql-ws/README.md create mode 100644 cmd/test-graphql-ws/main.go diff --git a/WEBSOCKET_FINDINGS.md b/WEBSOCKET_FINDINGS.md new file mode 100644 index 0000000..af70f9a --- /dev/null +++ b/WEBSOCKET_FINDINGS.md @@ -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. + diff --git a/bridge/kosmi/kosmi.go b/bridge/kosmi/kosmi.go index 93ee726..0449d71 100644 --- a/bridge/kosmi/kosmi.go +++ b/bridge/kosmi/kosmi.go @@ -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 } diff --git a/cmd/monitor-ws/main.go b/cmd/monitor-ws/main.go new file mode 100644 index 0000000..1a26512 --- /dev/null +++ b/cmd/monitor-ws/main.go @@ -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...") +} + diff --git a/cmd/test-graphql-ws/README.md b/cmd/test-graphql-ws/README.md new file mode 100644 index 0000000..dbe5d3d --- /dev/null +++ b/cmd/test-graphql-ws/README.md @@ -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 + diff --git a/cmd/test-graphql-ws/main.go b/cmd/test-graphql-ws/main.go new file mode 100644 index 0000000..f8ff3ab --- /dev/null +++ b/cmd/test-graphql-ws/main.go @@ -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!") +} +