Files
IRC-kosmi-relay/cmd/test-graphql-ws/main.go
2025-10-31 20:38:38 -04:00

513 lines
12 KiB
Go
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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!")
}