diff --git a/cmd/kosmi-client/README.md b/cmd/kosmi-client/README.md new file mode 100644 index 0000000..ceccfe3 --- /dev/null +++ b/cmd/kosmi-client/README.md @@ -0,0 +1,248 @@ +# Kosmi Client - Standalone CLI Chat Client + +A simple, standalone command-line client for Kosmi that demonstrates WebSocket communication, GraphQL operations, and real-time chat functionality. + +## Features + +- ✅ Manual JWT token authentication +- ✅ Email/password authentication with browser automation +- ✅ Interactive CLI for sending/receiving messages +- ✅ Real-time message subscription +- ✅ Graceful shutdown (Ctrl+C) + +## Prerequisites + +- Go 1.21 or higher +- A Kosmi account (for authenticated access) +- JWT token from your browser session + +## Installation + +```bash +cd cmd/kosmi-client +go mod download +go build +``` + +## Getting Your JWT Token + +To use this client, you need to extract your JWT token from the Kosmi web application: + +### Method 1: Browser DevTools (Recommended) + +1. Open [https://app.kosmi.io](https://app.kosmi.io) in your browser +2. Log in to your account +3. Open Developer Tools (F12 or Right-click → Inspect) +4. Go to the **Console** tab +5. Type the following command and press Enter: + ```javascript + localStorage.getItem('token') + ``` +6. Copy the token (it will be a long string starting with `eyJ...`) + +### Method 2: Network Tab + +1. Open [https://app.kosmi.io](https://app.kosmi.io) in your browser +2. Open Developer Tools (F12) +3. Go to the **Network** tab +4. Filter by "WS" (WebSocket) +5. Look for the connection to `engine.kosmi.io` +6. Click on it and view the **Messages** tab +7. Find the `connection_init` message +8. Copy the token from the payload + +## Usage + +### Basic Usage + +**Option 1: Email/Password (Recommended)** +```bash +./kosmi-client --email "your@email.com" --password "yourpassword" --room "@hyperspaceout" +``` + +**Option 2: Manual Token** +```bash +./kosmi-client --token "YOUR_JWT_TOKEN" --room "@hyperspaceout" +``` + +### Command-Line Options + +| Flag | Description | Required | Example | +|------|-------------|----------|---------| +| `--token` | JWT authentication token | Yes* | `--token "eyJ..."` | +| `--room` | Room ID to join | Yes | `--room "@hyperspaceout"` | +| `--email` | Email for login | Yes* | `--email "user@example.com"` | +| `--password` | Password for login | Yes* | `--password "secret"` | + +\* Either `--token` OR both `--email` and `--password` are required + +### Interactive Commands + +Once connected, you can use these commands: + +- **Send a message**: Just type your message and press Enter +- `/help` - Show available commands +- `/quit` or `/exit` - Exit the client +- **Ctrl+C** - Graceful shutdown + +### Example Session + +``` +$ ./kosmi-client --email "email@email.com" --password "password" --room "@hyperspaceout" + +Connecting to Kosmi WebSocket... +Sending connection_init... +✓ Connection acknowledged +Querying current user... +✓ Logged in as: John Doe (@johndoe) [Anonymous: false] +Joining room: @hyperspaceout... +✓ Successfully joined room +Subscribing to new messages... +✓ Subscription active +Connection setup complete! + +============================================================ +Connected! Type messages and press Enter to send. +Press Ctrl+C to exit. +============================================================ + +> Hello, world! +> [14:23:45] Jane Smith: Hey there! +> How are you? +> [14:24:01] Jane Smith: I'm good, thanks! +> /quit +Exiting... +Closing Kosmi client... +✓ Client closed +Goodbye! +``` + +## Architecture + +The client consists of three main components: + +### 1. `auth.go` - Authentication Management + +- `GetToken()` - Retrieves token (manual or via chromedp) +- `loginWithChromedp()` - Browser automation for email/password login + +### 2. `client.go` - WebSocket Client + +- `KosmiClient` - Main client struct +- `Connect()` - Establishes connection and performs GraphQL handshake +- `SendMessage()` - Sends messages to the room +- `listenForMessages()` - Receives messages in real-time +- `Close()` - Graceful shutdown + +### 3. `main.go` - CLI Interface + +- Command-line argument parsing +- Interactive message input/output +- Signal handling for graceful shutdown + +## GraphQL Operations + +The client uses these minimal GraphQL operations: + +1. **connection_init** - Authenticate with JWT token +2. **ExtendedCurrentUserQuery** - Get current user info +3. **JoinRoom** - Join the specified room +4. **NewMessageSubscription** - Subscribe to new messages +5. **SendMessage2** - Send messages to the room + +## Troubleshooting + +### "Authentication error: no authentication method provided" + +You must provide either `--token` or both `--email` and `--password`. + +### "Connection error: failed to connect to WebSocket" + +- Check your internet connection +- Verify the Kosmi service is accessible +- Ensure you're not behind a restrictive firewall + +### "expected connection_ack, got: connection_error" + +Your JWT token is likely invalid or expired. Get a fresh token from your browser. + +### Messages not appearing + +- Verify you've joined the correct room ID +- Check that the room exists and you have access to it +- Ensure your token has the necessary permissions + +## Helper Tools + +### `cmd/get-kosmi-token` - Standalone Token Extractor + +A separate utility for extracting JWT tokens without running the full client: + +```bash +cd cmd/get-kosmi-token +go run main.go --email "your@email.com" --password "yourpassword" +``` + +Options: +- `--email` - Your Kosmi email +- `--password` - Your Kosmi password +- `--headless` - Run browser in headless mode (default: true) +- `--verbose` - Enable verbose logging + +The token will be printed to stdout, making it easy to capture: +```bash +TOKEN=$(go run main.go --email "your@email.com" --password "yourpassword" 2>/dev/null) +./kosmi-client --token "$TOKEN" --room "@hyperspaceout" +``` + +## Future Enhancements + +### Additional Features (Ideas) + +- [ ] Room chat history retrieval (`RoomChatQuery`) +- [ ] Member join/leave notifications +- [ ] Typing indicators +- [ ] File/image upload support +- [ ] Multiple room support +- [ ] Persistent token storage +- [ ] Reconnection with exponential backoff +- [ ] Message editing and deletion +- [ ] Reactions and emojis + +## Development + +### Running from Source + +```bash +go run . --token "YOUR_TOKEN" --room "@hyperspaceout" +``` + +### Building + +```bash +go build -o kosmi-client +``` + +### Dependencies + +- `github.com/gorilla/websocket` - WebSocket client library +- `github.com/chromedp/chromedp` - Browser automation (for future use) + +## License + +This is a proof-of-concept demonstration client. Use responsibly and in accordance with Kosmi's Terms of Service. + +## Contributing + +This is a standalone proof of concept. Feel free to fork and extend it for your own use cases! + +## Support + +For issues related to: +- **Kosmi service**: Contact Kosmi support +- **This client**: Check the code comments and implementation details + +--- + +**Note**: This client is not officially affiliated with or endorsed by Kosmi. It's an independent implementation for educational and development purposes. + diff --git a/cmd/kosmi-client/auth.go b/cmd/kosmi-client/auth.go new file mode 100644 index 0000000..3336c99 --- /dev/null +++ b/cmd/kosmi-client/auth.go @@ -0,0 +1,181 @@ +package main + +import ( + "context" + "fmt" + "time" + + "github.com/chromedp/chromedp" +) + +// GetToken retrieves a Kosmi authentication token. +// Priority: manual > email/password > error +func GetToken(manual string, email string, password string) (string, error) { + // If manual token provided, use it immediately + if manual != "" { + return manual, nil + } + + // If email and password provided, attempt chromedp login + if email != "" && password != "" { + return loginWithChromedp(email, password) + } + + // No authentication method provided + return "", fmt.Errorf("no authentication method provided: use --token or --email/--password") +} + +// loginWithChromedp uses browser automation to log in and extract the JWT token. +func loginWithChromedp(email, password string) (string, error) { + // Create context with timeout + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + // Set up chromedp options (headless mode) + opts := []chromedp.ExecAllocatorOption{ + chromedp.NoFirstRun, + chromedp.NoDefaultBrowserCheck, + chromedp.DisableGPU, + chromedp.NoSandbox, + chromedp.Headless, + } + + // Create allocator context + allocCtx, cancel := chromedp.NewExecAllocator(ctx, opts...) + defer cancel() + + // Create browser context with no logging to suppress cookie errors + ctx, cancel = chromedp.NewContext(allocCtx, chromedp.WithLogf(func(string, ...interface{}) {})) + defer cancel() + + var token string + + // Run the automation tasks + err := chromedp.Run(ctx, + // Navigate to Kosmi + chromedp.Navigate("https://app.kosmi.io"), + chromedp.WaitReady("body"), + chromedp.Sleep(3*time.Second), + + // Find and click Login button + chromedp.ActionFunc(func(ctx context.Context) error { + var found bool + if err := chromedp.Evaluate(` + (() => { + const buttons = Array.from(document.querySelectorAll('button')); + const btn = buttons.find(el => { + const text = el.textContent.trim(); + return text === 'Login' || text === 'Log in'; + }); + if (btn) { + btn.click(); + return true; + } + return false; + })() + `, &found).Do(ctx); err != nil { + return err + } + if !found { + return fmt.Errorf("Login button not found") + } + return nil + }), + + // Wait and click "Login with Email" + chromedp.Sleep(3*time.Second), + chromedp.ActionFunc(func(ctx context.Context) error { + var found bool + if err := chromedp.Evaluate(` + (() => { + const btn = Array.from(document.querySelectorAll('button')).find(el => + el.textContent.includes('Email') + ); + if (btn) { + btn.click(); + return true; + } + return false; + })() + `, &found).Do(ctx); err != nil { + return err + } + if !found { + return fmt.Errorf("'Login with Email' button not found") + } + return nil + }), + + // Wait for form and fill credentials + chromedp.Sleep(3*time.Second), + chromedp.WaitVisible(`input[type="password"]`, chromedp.ByQuery), + chromedp.Click(`input[placeholder*="Email"], input[placeholder*="Username"]`, chromedp.ByQuery), + chromedp.Sleep(200*time.Millisecond), + chromedp.SendKeys(`input[placeholder*="Email"], input[placeholder*="Username"]`, email, chromedp.ByQuery), + chromedp.Sleep(500*time.Millisecond), + chromedp.Click(`input[type="password"]`, chromedp.ByQuery), + chromedp.Sleep(200*time.Millisecond), + chromedp.SendKeys(`input[type="password"]`, password+"\n", chromedp.ByQuery), + chromedp.Sleep(500*time.Millisecond), + + // Wait for login to complete - check for modal to close + chromedp.ActionFunc(func(ctx context.Context) error { + // Wait for the login modal to disappear (indicates successful login) + maxAttempts := 30 // 15 seconds total + for i := 0; i < maxAttempts; i++ { + time.Sleep(500 * time.Millisecond) + + // Check if login modal is gone (successful login) + var modalGone bool + chromedp.Evaluate(` + (() => { + // Check if the email/password form is still visible + const emailInput = document.querySelector('input[placeholder*="Email"], input[placeholder*="Username"]'); + const passwordInput = document.querySelector('input[type="password"]'); + return !emailInput && !passwordInput; + })() + `, &modalGone).Do(ctx) + + if modalGone { + // Modal is gone, wait a bit more for token to be set + time.Sleep(2 * time.Second) + chromedp.Evaluate(`localStorage.getItem('token')`, &token).Do(ctx) + if token != "" { + return nil + } + } + + // Check for error messages + var errorText string + chromedp.Evaluate(` + (() => { + const errorEl = document.querySelector('[role="alert"], .error, .error-message'); + return errorEl ? errorEl.textContent.trim() : ''; + })() + `, &errorText).Do(ctx) + + if errorText != "" && errorText != "null" { + return fmt.Errorf("login failed: %s", errorText) + } + } + + // Timeout - get whatever token is there + chromedp.Evaluate(`localStorage.getItem('token')`, &token).Do(ctx) + if token == "" { + return fmt.Errorf("login timeout: no token found") + } + return nil + }), + ) + + if err != nil { + return "", fmt.Errorf("browser automation failed: %w", err) + } + + if token == "" { + return "", fmt.Errorf("failed to extract token from localStorage") + } + + return token, nil +} + diff --git a/cmd/kosmi-client/client.go b/cmd/kosmi-client/client.go new file mode 100644 index 0000000..ab9e339 --- /dev/null +++ b/cmd/kosmi-client/client.go @@ -0,0 +1,334 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +const ( + kosmiWebSocketURL = "wss://engine.kosmi.io/gql-ws" +) + +// KosmiClient represents a WebSocket client for Kosmi +type KosmiClient struct { + conn *websocket.Conn + token string + roomID string + mu sync.Mutex + msgID int + onMessage func(Message) + closeChan chan struct{} + closedOnce sync.Once +} + +// Message represents an incoming chat message +type Message struct { + ID string + Username string + DisplayName string + Body string + Time string +} + +// GraphQL message types +type gqlMessage struct { + ID string `json:"id,omitempty"` + Type string `json:"type"` + Payload json.RawMessage `json:"payload,omitempty"` +} + +// NewKosmiClient creates a new Kosmi client +func NewKosmiClient(token, roomID string) *KosmiClient { + return &KosmiClient{ + token: token, + roomID: roomID, + closeChan: make(chan struct{}), + } +} + +// SetMessageHandler sets the callback for incoming messages +func (c *KosmiClient) SetMessageHandler(handler func(Message)) { + c.onMessage = handler +} + +// Connect establishes the WebSocket connection and performs the handshake +func (c *KosmiClient) Connect() error { + log.Println("Connecting to Kosmi WebSocket...") + + // Establish WebSocket connection + conn, _, err := websocket.DefaultDialer.Dial(kosmiWebSocketURL, nil) + if err != nil { + return fmt.Errorf("failed to connect to WebSocket: %w", err) + } + c.conn = conn + + // Step 1: Send connection_init with token + log.Println("Sending connection_init...") + initMsg := gqlMessage{ + Type: "connection_init", + Payload: json.RawMessage(fmt.Sprintf(`{"token":"%s"}`, c.token)), + } + if err := c.conn.WriteJSON(initMsg); err != nil { + return fmt.Errorf("failed to send connection_init: %w", err) + } + + // Step 2: Wait for connection_ack (synchronous) + var ackMsg gqlMessage + if err := c.conn.ReadJSON(&ackMsg); err != nil { + return fmt.Errorf("failed to read connection_ack: %w", err) + } + if ackMsg.Type != "connection_ack" { + return fmt.Errorf("expected connection_ack, got: %s", ackMsg.Type) + } + log.Println("✓ Connection acknowledged") + + // Step 3: Send ExtendedCurrentUserQuery (synchronous) + log.Println("Querying current user...") + userQueryMsg := gqlMessage{ + ID: c.nextMsgID(), + Type: "subscribe", + Payload: json.RawMessage(`{"query":"query ExtendedCurrentUserQuery { currentUser { id connectionId user { id displayName username isAnonymous avatarUrl email } } }","operationName":"ExtendedCurrentUserQuery","variables":{},"extensions":{}}`), + } + if err := c.conn.WriteJSON(userQueryMsg); err != nil { + return fmt.Errorf("failed to send ExtendedCurrentUserQuery: %w", err) + } + + // Read user query response (synchronous) + var userResponse gqlMessage + if err := c.conn.ReadJSON(&userResponse); err != nil { + return fmt.Errorf("failed to read user query response: %w", err) + } + if userResponse.Type == "next" { + var userData struct { + Data struct { + CurrentUser struct { + User struct { + DisplayName string `json:"displayName"` + Username string `json:"username"` + IsAnonymous bool `json:"isAnonymous"` + } `json:"user"` + } `json:"currentUser"` + } `json:"data"` + } + if err := json.Unmarshal(userResponse.Payload, &userData); err == nil { + log.Printf("✓ Logged in as: %s (@%s) [Anonymous: %v]", + userData.Data.CurrentUser.User.DisplayName, + userData.Data.CurrentUser.User.Username, + userData.Data.CurrentUser.User.IsAnonymous) + } + } + + // Read complete message for user query + var completeMsg gqlMessage + if err := c.conn.ReadJSON(&completeMsg); err != nil { + return fmt.Errorf("failed to read complete message: %w", err) + } + + // Step 4: Send JoinRoom mutation (synchronous) + log.Printf("Joining room: %s...", c.roomID) + joinRoomMsg := gqlMessage{ + ID: c.nextMsgID(), + Type: "subscribe", + Payload: json.RawMessage(fmt.Sprintf(`{"query":"mutation JoinRoom($id: String!, $disconnectOtherConnections: Boolean) { joinRoom(id: $id, disconnectOtherConnections: $disconnectOtherConnections) { ok __typename } }","operationName":"JoinRoom","variables":{"id":"%s","disconnectOtherConnections":false},"extensions":{}}`, c.roomID)), + } + if err := c.conn.WriteJSON(joinRoomMsg); err != nil { + return fmt.Errorf("failed to send JoinRoom: %w", err) + } + + // Read join room response (synchronous) + var joinResponse gqlMessage + if err := c.conn.ReadJSON(&joinResponse); err != nil { + return fmt.Errorf("failed to read join room response: %w", err) + } + if joinResponse.Type == "next" { + var joinData struct { + Data struct { + JoinRoom struct { + Ok bool `json:"ok"` + } `json:"joinRoom"` + } `json:"data"` + } + if err := json.Unmarshal(joinResponse.Payload, &joinData); err == nil { + if joinData.Data.JoinRoom.Ok { + log.Println("✓ Successfully joined room") + } else { + log.Println("⚠ Join room returned ok=false") + } + } + } + + // Read complete message for join room + if err := c.conn.ReadJSON(&completeMsg); err != nil { + return fmt.Errorf("failed to read join complete message: %w", err) + } + + // Step 5: Subscribe to NewMessageSubscription (synchronous) + log.Println("Subscribing to new messages...") + subscribeMsg := gqlMessage{ + ID: c.nextMsgID(), + Type: "subscribe", + Payload: json.RawMessage(fmt.Sprintf(`{"query":"subscription NewMessageSubscription($roomId: String!, $channelId: String) { newMessage(roomId: $roomId, channelId: $channelId) { id user { id displayName username } body time } }","operationName":"NewMessageSubscription","variables":{"roomId":"%s","channelId":null},"extensions":{}}`, c.roomID)), + } + if err := c.conn.WriteJSON(subscribeMsg); err != nil { + return fmt.Errorf("failed to send NewMessageSubscription: %w", err) + } + + log.Println("✓ Subscription active") + log.Println("Connection setup complete!") + + // Step 6: Start listening for messages in goroutine + go c.listenForMessages() + + return nil +} + +// listenForMessages continuously reads messages from the WebSocket +func (c *KosmiClient) listenForMessages() { + defer func() { + if r := recover(); r != nil { + log.Printf("Panic in listenForMessages: %v", r) + } + }() + + for { + select { + case <-c.closeChan: + log.Println("Message listener shutting down...") + return + default: + } + + var msg gqlMessage + if err := c.conn.ReadJSON(&msg); err != nil { + select { + case <-c.closeChan: + // Expected error during shutdown + return + default: + log.Printf("Error reading message: %v", err) + return + } + } + + // Handle different message types + switch msg.Type { + case "next": + c.handleNextMessage(msg.Payload) + case "error": + log.Printf("GraphQL error: %s", string(msg.Payload)) + case "complete": + log.Printf("Subscription completed: %s", msg.ID) + case "ka": + // Keep-alive, ignore + default: + log.Printf("Unknown message type: %s", msg.Type) + } + } +} + +// handleNextMessage processes a "next" type message (data payload) +func (c *KosmiClient) handleNextMessage(payload json.RawMessage) { + var response struct { + Data struct { + NewMessage struct { + ID string `json:"id"` + User struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + Username string `json:"username"` + } `json:"user"` + Body string `json:"body"` + Time interface{} `json:"time"` // Can be string or number + } `json:"newMessage"` + } `json:"data"` + } + + if err := json.Unmarshal(payload, &response); err != nil { + log.Printf("Failed to parse message payload: %v", err) + return + } + + // Check if this is a new message + if response.Data.NewMessage.ID != "" { + // Convert time to string + timeStr := "" + switch t := response.Data.NewMessage.Time.(type) { + case string: + timeStr = t + case float64: + timeStr = time.Unix(int64(t), 0).Format(time.RFC3339) + } + + msg := Message{ + ID: response.Data.NewMessage.ID, + Username: response.Data.NewMessage.User.Username, + DisplayName: response.Data.NewMessage.User.DisplayName, + Body: response.Data.NewMessage.Body, + Time: timeStr, + } + + // Call the message handler if set + if c.onMessage != nil { + c.onMessage(msg) + } + } +} + +// SendMessage sends a message to the room +func (c *KosmiClient) SendMessage(text string) error { + c.mu.Lock() + defer c.mu.Unlock() + + sendMsg := gqlMessage{ + ID: c.nextMsgID(), + Type: "subscribe", + Payload: json.RawMessage(fmt.Sprintf(`{"query":"mutation SendMessage2($roomId: String!, $body: String!, $channelId: String, $replyToMessageId: String) { sendMessage(roomId: $roomId, body: $body, channelId: $channelId, replyToMessageId: $replyToMessageId) { ok __typename } }","operationName":"SendMessage2","variables":{"roomId":"%s","body":%s,"channelId":null,"replyToMessageId":null},"extensions":{}}`, c.roomID, jsonEscape(text))), + } + + if err := c.conn.WriteJSON(sendMsg); err != nil { + return fmt.Errorf("failed to send message: %w", err) + } + + return nil +} + +// Close gracefully closes the WebSocket connection +func (c *KosmiClient) Close() error { + var err error + c.closedOnce.Do(func() { + log.Println("Closing Kosmi client...") + close(c.closeChan) + + // Give the listener goroutine a moment to exit + time.Sleep(100 * time.Millisecond) + + if c.conn != nil { + // Send close message + closeMsg := websocket.FormatCloseMessage(websocket.CloseNormalClosure, "") + c.conn.WriteMessage(websocket.CloseMessage, closeMsg) + + // Close the connection + err = c.conn.Close() + } + log.Println("✓ Client closed") + }) + return err +} + +// nextMsgID generates the next message ID +func (c *KosmiClient) nextMsgID() string { + c.msgID++ + return fmt.Sprintf("%d", c.msgID) +} + +// jsonEscape properly escapes a string for JSON +func jsonEscape(s string) string { + b, _ := json.Marshal(s) + return string(b) +} + diff --git a/cmd/kosmi-client/go.mod b/cmd/kosmi-client/go.mod new file mode 100644 index 0000000..bbc7bbb --- /dev/null +++ b/cmd/kosmi-client/go.mod @@ -0,0 +1,19 @@ +module github.com/erikfredericks/kosmi-client + +go 1.21 + +require ( + github.com/chromedp/chromedp v0.9.2 + github.com/gorilla/websocket v1.5.0 +) + +require ( + github.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89 // indirect + github.com/chromedp/sysutil v1.0.0 // indirect + github.com/gobwas/httphead v0.1.0 // indirect + github.com/gobwas/pool v0.2.1 // indirect + github.com/gobwas/ws v1.2.1 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + golang.org/x/sys v0.11.0 // indirect +) diff --git a/cmd/kosmi-client/go.sum b/cmd/kosmi-client/go.sum new file mode 100644 index 0000000..98e74d0 --- /dev/null +++ b/cmd/kosmi-client/go.sum @@ -0,0 +1,25 @@ +github.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89 h1:aPflPkRFkVwbW6dmcVqfgwp1i+UWGFH6VgR1Jim5Ygc= +github.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= +github.com/chromedp/chromedp v0.9.2 h1:dKtNz4kApb06KuSXoTQIyUC2TrA0fhGDwNZf3bcgfKw= +github.com/chromedp/chromedp v0.9.2/go.mod h1:LkSXJKONWTCHAfQasKFUZI+mxqS4tZqhmtGzzhLsnLs= +github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic= +github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww= +github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= +github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= +github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= +github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.2.1 h1:F2aeBZrm2NDsc7vbovKrWSogd4wvfAxg0FQ89/iqOTk= +github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/cmd/kosmi-client/kosmi-clien b/cmd/kosmi-client/kosmi-clien new file mode 100755 index 0000000..a0b6d64 Binary files /dev/null and b/cmd/kosmi-client/kosmi-clien differ diff --git a/cmd/kosmi-client/kosmi-client b/cmd/kosmi-client/kosmi-client new file mode 100755 index 0000000..51b687c Binary files /dev/null and b/cmd/kosmi-client/kosmi-client differ diff --git a/cmd/kosmi-client/main.go b/cmd/kosmi-client/main.go new file mode 100644 index 0000000..ea43a83 --- /dev/null +++ b/cmd/kosmi-client/main.go @@ -0,0 +1,146 @@ +package main + +import ( + "bufio" + "flag" + "fmt" + "log" + "os" + "os/signal" + "strings" + "syscall" + "time" +) + +var ( + token = flag.String("token", "", "JWT token for authentication (get from browser DevTools)") + room = flag.String("room", "", "Room ID to join (e.g., @hyperspaceout)") + email = flag.String("email", "", "Email for login (requires --password, not yet implemented)") + password = flag.String("password", "", "Password for login (requires --email, not yet implemented)") +) + +func main() { + flag.Parse() + + // Validate required flags + if *room == "" { + log.Fatal("Error: --room is required") + } + + // Get authentication token + authToken, err := GetToken(*token, *email, *password) + if err != nil { + log.Fatalf("Authentication error: %v", err) + } + + // Create client + client := NewKosmiClient(authToken, *room) + + // Set up message handler to print incoming messages + client.SetMessageHandler(func(msg Message) { + timestamp := formatTime(msg.Time) + fmt.Printf("\r[%s] %s: %s\n> ", timestamp, msg.DisplayName, msg.Body) + }) + + // Connect to Kosmi + if err := client.Connect(); err != nil { + log.Fatalf("Connection error: %v", err) + } + + // Set up graceful shutdown + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + + // Start goroutine to handle shutdown signal + shutdownChan := make(chan struct{}) + go func() { + <-sigChan + fmt.Println("\n\nReceived interrupt signal, shutting down gracefully...") + close(shutdownChan) + }() + + // Print usage instructions + fmt.Println("\n" + strings.Repeat("=", 60)) + fmt.Println("Connected! Type messages and press Enter to send.") + fmt.Println("Press Ctrl+C to exit.") + fmt.Println(strings.Repeat("=", 60) + "\n") + + // Start interactive CLI loop + go func() { + scanner := bufio.NewScanner(os.Stdin) + fmt.Print("> ") + + for scanner.Scan() { + select { + case <-shutdownChan: + return + default: + } + + text := strings.TrimSpace(scanner.Text()) + + // Skip empty messages + if text == "" { + fmt.Print("> ") + continue + } + + // Handle special commands + if text == "/quit" || text == "/exit" { + fmt.Println("Exiting...") + close(shutdownChan) + return + } + + if text == "/help" { + printHelp() + fmt.Print("> ") + continue + } + + // Send the message + if err := client.SendMessage(text); err != nil { + fmt.Printf("\rError sending message: %v\n> ", err) + } else { + // Clear the line and reprint prompt + fmt.Print("> ") + } + } + + if err := scanner.Err(); err != nil { + log.Printf("Scanner error: %v", err) + } + }() + + // Wait for shutdown signal + <-shutdownChan + + // Close the client gracefully + if err := client.Close(); err != nil { + log.Printf("Error closing client: %v", err) + } + + // Give a moment for cleanup + time.Sleep(200 * time.Millisecond) + + fmt.Println("Goodbye!") +} + +// formatTime converts ISO timestamp to readable format +func formatTime(isoTime string) string { + t, err := time.Parse(time.RFC3339, isoTime) + if err != nil { + return isoTime + } + return t.Format("15:04:05") +} + +// printHelp displays available commands +func printHelp() { + fmt.Println("\nAvailable commands:") + fmt.Println(" /help - Show this help message") + fmt.Println(" /quit - Exit the client") + fmt.Println(" /exit - Exit the client") + fmt.Println("\nJust type any text and press Enter to send a message.") +} + diff --git a/go.mod b/go.mod index b539584..b15c1b6 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/d5/tengo/v2 v2.17.0 github.com/fsnotify/fsnotify v1.9.0 github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a + github.com/gonutz/gofont v1.0.0 github.com/google/gops v0.3.28 github.com/gorilla/schema v1.4.1 github.com/gorilla/websocket v1.5.3 @@ -42,9 +43,7 @@ require ( github.com/gobwas/pool v0.2.1 // indirect github.com/gobwas/ws v1.4.0 // indirect github.com/gonutz/fontstash.go v1.0.0 // indirect - github.com/gonutz/gofont v1.0.0 // indirect github.com/kettek/apng v0.0.0-20191108220231-414630eed80f // indirect - github.com/kolesa-team/go-webp v1.0.5 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect diff --git a/go.sum b/go.sum index 44196b0..cb2fba1 100644 --- a/go.sum +++ b/go.sum @@ -72,8 +72,6 @@ github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uG github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/kettek/apng v0.0.0-20191108220231-414630eed80f h1:dnCYnTSltLuPMfc7dMrkz2uBUcEf/OFBR8yRh3oRT98= github.com/kettek/apng v0.0.0-20191108220231-414630eed80f/go.mod h1:x78/VRQYKuCftMWS0uK5e+F5RJ7S4gSlESRWI0Prl6Q= -github.com/kolesa-team/go-webp v1.0.5 h1:GZQHJBaE8dsNKZltfwqsL0qVJ7vqHXsfA+4AHrQW3pE= -github.com/kolesa-team/go-webp v1.0.5/go.mod h1:QmJu0YHXT3ex+4SgUvs+a+1SFCDcCqyZg+LbIuNNTnE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=