simple-auth working on test client, about to implement in main client

This commit is contained in:
cottongin
2025-11-02 15:39:49 -05:00
parent dd398c9a8c
commit 1cad3cb47f
10 changed files with 954 additions and 4 deletions

248
cmd/kosmi-client/README.md Normal file
View File

@@ -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.

181
cmd/kosmi-client/auth.go Normal file
View File

@@ -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
}

334
cmd/kosmi-client/client.go Normal file
View File

@@ -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)
}

19
cmd/kosmi-client/go.mod Normal file
View File

@@ -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
)

25
cmd/kosmi-client/go.sum Normal file
View File

@@ -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=

BIN
cmd/kosmi-client/kosmi-clien Executable file

Binary file not shown.

BIN
cmd/kosmi-client/kosmi-client Executable file

Binary file not shown.

146
cmd/kosmi-client/main.go Normal file
View File

@@ -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.")
}

3
go.mod
View File

@@ -9,6 +9,7 @@ require (
github.com/d5/tengo/v2 v2.17.0 github.com/d5/tengo/v2 v2.17.0
github.com/fsnotify/fsnotify v1.9.0 github.com/fsnotify/fsnotify v1.9.0
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a 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/google/gops v0.3.28
github.com/gorilla/schema v1.4.1 github.com/gorilla/schema v1.4.1
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
@@ -42,9 +43,7 @@ require (
github.com/gobwas/pool v0.2.1 // indirect github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect github.com/gobwas/ws v1.4.0 // indirect
github.com/gonutz/fontstash.go v1.0.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/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-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect

2
go.sum
View File

@@ -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/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 h1:dnCYnTSltLuPMfc7dMrkz2uBUcEf/OFBR8yRh3oRT98=
github.com/kettek/apng v0.0.0-20191108220231-414630eed80f/go.mod h1:x78/VRQYKuCftMWS0uK5e+F5RJ7S4gSlESRWI0Prl6Q= 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 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=