simple-auth working on test client, about to implement in main client
This commit is contained in:
248
cmd/kosmi-client/README.md
Normal file
248
cmd/kosmi-client/README.md
Normal 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
181
cmd/kosmi-client/auth.go
Normal 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
334
cmd/kosmi-client/client.go
Normal 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
19
cmd/kosmi-client/go.mod
Normal 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
25
cmd/kosmi-client/go.sum
Normal 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
BIN
cmd/kosmi-client/kosmi-clien
Executable file
Binary file not shown.
BIN
cmd/kosmi-client/kosmi-client
Executable file
BIN
cmd/kosmi-client/kosmi-client
Executable file
Binary file not shown.
146
cmd/kosmi-client/main.go
Normal file
146
cmd/kosmi-client/main.go
Normal 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
3
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
|
||||
|
||||
2
go.sum
2
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=
|
||||
|
||||
Reference in New Issue
Block a user