sync
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -46,3 +46,5 @@ build/
|
||||
|
||||
# Other
|
||||
.examples/
|
||||
chat-summaries/
|
||||
bin/
|
||||
|
||||
118
ASYNC_FIX.md
Normal file
118
ASYNC_FIX.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# Critical Fix: Asynchronous Message Handling
|
||||
|
||||
## The Problem
|
||||
|
||||
The bot was only receiving ONE message from Kosmi then hanging, even though the WebSocket stayed connected.
|
||||
|
||||
### Root Cause
|
||||
|
||||
We were reading WebSocket responses **synchronously** during connection setup, but the server sends responses in a different order than we were expecting:
|
||||
|
||||
**What we were doing:**
|
||||
1. Send `ExtendedCurrentUserQuery` → Try to read response immediately
|
||||
2. Send `RoomChatQuery` → Try to read response immediately
|
||||
3. Send `JoinRoom` → Try to read response immediately (loop 10 times)
|
||||
|
||||
**What the server actually sends:**
|
||||
1. `complete` for `current-user` (from ExtendedCurrentUserQuery)
|
||||
2. `next` for `join-room` (from JoinRoom)
|
||||
3. `next` for `room-chat-query` (from RoomChatQuery)
|
||||
4. ... more responses ...
|
||||
|
||||
**The mismatch:**
|
||||
- We sent `RoomChatQuery` and tried to read its response
|
||||
- But we actually read the `complete` message from the previous `current-user` query!
|
||||
- This consumed a message that `listenForMessages` should have handled
|
||||
- The message loop got out of sync and stopped processing new messages
|
||||
|
||||
### Evidence from Logs
|
||||
|
||||
```
|
||||
time="2025-11-01T16:36:06-04:00" level=info msg="Chat history response: type=complete id=current-user"
|
||||
```
|
||||
|
||||
We sent a query with ID `room-chat-query` but received a response with ID `current-user` - wrong message!
|
||||
|
||||
## The Fix
|
||||
|
||||
**Stop reading responses synchronously during setup.** Let the `listenForMessages` goroutine handle ALL incoming messages.
|
||||
|
||||
### Changes Made
|
||||
|
||||
1. **Removed synchronous read after `ExtendedCurrentUserQuery`**
|
||||
- Before: Send query → Read response
|
||||
- After: Send query → Continue (let listenForMessages handle it)
|
||||
|
||||
2. **Removed synchronous read after `RoomChatQuery`**
|
||||
- Before: Send query → Read response
|
||||
- After: Send query → Continue (let listenForMessages handle it)
|
||||
|
||||
3. **Removed synchronous loop after `JoinRoom`**
|
||||
- Before: Send mutation → Loop 10 times reading responses
|
||||
- After: Send mutation → Brief sleep → Continue (let listenForMessages handle it)
|
||||
|
||||
4. **Enhanced `listenForMessages` to handle all message types**
|
||||
- Added switch statement for `messageTypeNext`, `messageTypeError`, `messageTypeComplete`
|
||||
- Added specific handling for known operation IDs (`current-user`, `room-chat-query`, `join-room`, `subscribe-messages`)
|
||||
- Added better logging for debugging
|
||||
|
||||
### New Message Flow
|
||||
|
||||
```
|
||||
Connection Setup (synchronous):
|
||||
├─ Send connection_init
|
||||
├─ Wait for connection_ack (ONLY synchronous read)
|
||||
├─ Send ExtendedCurrentUserQuery
|
||||
├─ Send JoinRoom
|
||||
├─ Send RoomChatQuery
|
||||
├─ Send RoomDisconnect subscription
|
||||
├─ Send MemberJoins subscription
|
||||
├─ Send MemberLeaves subscription
|
||||
├─ Send NewMessageSubscription
|
||||
└─ Start listenForMessages goroutine
|
||||
|
||||
listenForMessages (asynchronous):
|
||||
├─ Reads ALL incoming messages
|
||||
├─ Handles responses for all queries/mutations/subscriptions
|
||||
└─ Processes new chat messages
|
||||
```
|
||||
|
||||
## Expected Behavior After Fix
|
||||
|
||||
1. ✅ Bot connects and authenticates
|
||||
2. ✅ Bot sends all setup queries/subscriptions
|
||||
3. ✅ `listenForMessages` handles all responses in order
|
||||
4. ✅ Bot receives ALL messages continuously (not just one)
|
||||
5. ✅ No "ALREADY_CONNECTED" errors
|
||||
6. ✅ WebSocket stays alive and processes messages
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
go build
|
||||
docker-compose build
|
||||
docker-compose up -d
|
||||
docker-compose logs -f matterbridge
|
||||
```
|
||||
|
||||
Look for:
|
||||
- `🎧 [KOSMI WEBSOCKET] Message listener started`
|
||||
- `📨 [KOSMI WEBSOCKET] Received: type=next id=current-user`
|
||||
- `✅ Received current user data`
|
||||
- `📨 [KOSMI WEBSOCKET] Received: type=next id=join-room`
|
||||
- `✅ Successfully joined room`
|
||||
- `📨 [KOSMI WEBSOCKET] Received: type=next id=room-chat-query`
|
||||
- `✅ Received chat history`
|
||||
- Multiple `📨 [KOSMI WEBSOCKET] Received: type=next id=subscribe-messages`
|
||||
- Multiple `Received message from Kosmi:` entries
|
||||
|
||||
## Why This Matters
|
||||
|
||||
GraphQL-WS is an **asynchronous protocol**. The server can send responses in any order, and multiple responses can be in flight at once. By trying to read responses synchronously, we were:
|
||||
|
||||
1. **Breaking the message order** - Reading messages meant for the listener
|
||||
2. **Creating race conditions** - Setup code and listener competing for messages
|
||||
3. **Blocking the connection** - Waiting for specific responses that might never come (or come in different order)
|
||||
|
||||
The fix ensures that **only one goroutine** (`listenForMessages`) reads from the WebSocket, eliminating all race conditions and ensuring messages are processed in the order they arrive.
|
||||
|
||||
104
AUTHENTICATION_ISSUE.md
Normal file
104
AUTHENTICATION_ISSUE.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# Authentication Issue - Bot Appears as Anonymous
|
||||
|
||||
## Problem
|
||||
|
||||
The bot successfully authenticates via browser automation and joins the Kosmi room, but appears in the user list as an **anonymous user** (e.g., "Anonymous Donkey") instead of as the authenticated account.
|
||||
|
||||
## What We Know
|
||||
|
||||
### ✅ Working Correctly
|
||||
|
||||
1. **Browser authentication**: Successfully logs in and obtains JWT token
|
||||
2. **Token format**: Valid JWT with correct structure and claims
|
||||
3. **Token transmission**: Correct token is sent in `connection_init`
|
||||
4. **Server acceptance**: Server accepts the token (returns `connection_ack`)
|
||||
5. **Room joining**: Successfully joins the room (`joinRoom` mutation returns `ok: true`)
|
||||
|
||||
### 🔍 Investigation Results
|
||||
|
||||
#### Token Claims Analysis
|
||||
|
||||
The authenticated JWT token contains:
|
||||
```json
|
||||
{
|
||||
"aud": "kosmi",
|
||||
"exp": 1761874131,
|
||||
"iat": 1730338131,
|
||||
"iss": "kosmi",
|
||||
"sub": "e410acc0-e4bd-4694-8498-f20b9aa033fc",
|
||||
"typ": "access"
|
||||
}
|
||||
```
|
||||
|
||||
**Key finding**: The token **only contains the user ID** (`sub`), but **NO display name, username, or email**. This is just an authentication token, not a profile token.
|
||||
|
||||
#### GraphQL API Queries
|
||||
|
||||
Tested the following queries with the authenticated token:
|
||||
- `query { me { ... } }` - ❌ Field doesn't exist
|
||||
- `query { currentUser { ... } }` - ❌ Field doesn't exist
|
||||
- `query { user { ... } }` - ❌ Field doesn't exist
|
||||
- `query { viewer { ... } }` - ❌ Field doesn't exist
|
||||
|
||||
**Conclusion**: There's no GraphQL query to fetch the current user's profile.
|
||||
|
||||
#### WebSocket Flow
|
||||
|
||||
Current flow:
|
||||
1. `connection_init` with authenticated token → Server accepts
|
||||
2. `connection_ack` → Server acknowledges
|
||||
3. Subscribe to `newMessage` → Working
|
||||
4. `joinRoom` mutation → Returns `ok: true`
|
||||
5. Bot appears in user list as "Anonymous [Animal]"
|
||||
|
||||
## Hypotheses
|
||||
|
||||
### 1. Missing Profile Fetch
|
||||
The server might need a separate API call (REST or GraphQL) to fetch the user profile using the user ID from the token.
|
||||
|
||||
### 2. Missing Display Name Mutation
|
||||
There might be a GraphQL mutation to set the display name after joining:
|
||||
- `mutation { setDisplayName(name: "...") }`
|
||||
- `mutation { updateProfile(displayName: "...") }`
|
||||
|
||||
### 3. Server-Side Bug
|
||||
The server might not be correctly associating the authenticated token with the user profile when joining via WebSocket.
|
||||
|
||||
### 4. Additional WebSocket Message
|
||||
The browser might be sending an additional WebSocket message after `joinRoom` that we're not aware of.
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Check `connection_ack` payload**: See if the server returns user info
|
||||
2. **Monitor browser WebSocket traffic**: Watch what messages the browser sends after successful login and room join
|
||||
3. **Test GraphQL introspection**: Query the schema to see all available mutations
|
||||
4. **Compare anonymous vs authenticated flow**: See if there are any differences in the WebSocket message sequence
|
||||
|
||||
## Logs
|
||||
|
||||
### Successful Authentication and Join
|
||||
```
|
||||
time="2025-11-01T14:48:51-04:00" level=info msg="✅ Successfully obtained token via browser automation" prefix=kosmi
|
||||
time="2025-11-01T14:48:51-04:00" level=info msg=" Email used: d2bkvqnh0@mozmail.com" prefix=kosmi
|
||||
time="2025-11-01T14:48:51-04:00" level=info msg=" Token (first 50 chars): eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJrb..." prefix=kosmi
|
||||
time="2025-11-01T14:48:51-04:00" level=info msg=" Token user ID (sub): e410acc0-e4bd-4694-8498-f20b9aa033fc" prefix=kosmi
|
||||
time="2025-11-01T14:48:51-04:00" level=info msg=" Token type (typ): access" prefix=kosmi
|
||||
time="2025-11-01T14:48:51-04:00" level=info msg="✓ getToken: Using manually provided token" prefix=kosmi
|
||||
time="2025-11-01T14:48:51-04:00" level=info msg=" Length: 371" prefix=kosmi
|
||||
time="2025-11-01T14:48:51-04:00" level=info msg=" First 50: eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJrb..." prefix=kosmi
|
||||
time="2025-11-01T14:48:51-04:00" level=info msg="Sending connection_init with token (length: 371, first 20 chars: eyJhbGciOiJIUzUxMiIs...)" prefix=kosmi
|
||||
time="2025-11-01T14:48:51-04:00" level=info msg="✅ WebSocket connection established and authenticated" prefix=kosmi
|
||||
time="2025-11-01T14:48:51-04:00" level=info msg="✅ Successfully joined room" prefix=kosmi
|
||||
time="2025-11-01T14:48:51-04:00" level=info msg="Join response payload: {"data":{"joinRoom":{"ok":true}}}" prefix=kosmi
|
||||
```
|
||||
|
||||
**Result**: Bot appears as "Anonymous [Animal]" in the user list despite successful authentication.
|
||||
|
||||
## Files Modified for Debugging
|
||||
|
||||
- `bridge/kosmi/browser_auth.go`: Added comprehensive token logging
|
||||
- `bridge/kosmi/kosmi.go`: Added token setting confirmation
|
||||
- `bridge/kosmi/graphql_ws_client.go`: Added token source and `connection_ack` payload logging
|
||||
- `cmd/decode-token/main.go`: Tool to decode and analyze JWT tokens
|
||||
- `cmd/test-profile-query/main.go`: Tool to test GraphQL profile queries
|
||||
|
||||
195
AUTHENTICATION_STATUS.md
Normal file
195
AUTHENTICATION_STATUS.md
Normal file
@@ -0,0 +1,195 @@
|
||||
# Kosmi Authentication Status
|
||||
|
||||
## Current Status: ✅ Fully Automated
|
||||
|
||||
Authentication for the Kosmi bridge is **fully automated** using browser-based email/password login with automatic token refresh.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Option 1: Email/Password (Recommended - Fully Automated)
|
||||
|
||||
1. **Configure credentials** in `matterbridge.toml`:
|
||||
```toml
|
||||
[kosmi.hyperspaceout]
|
||||
RoomURL="https://app.kosmi.io/room/@hyperspaceout"
|
||||
Email="your-email@example.com"
|
||||
Password="your-password"
|
||||
```
|
||||
|
||||
2. **Run the bot**: `./irc-kosmi-relay -conf matterbridge.toml`
|
||||
|
||||
The bot will automatically:
|
||||
- Launch headless Chrome
|
||||
- Log in to Kosmi
|
||||
- Extract and use the JWT token
|
||||
- Refresh the token automatically (checked daily)
|
||||
|
||||
See `BROWSER_AUTH_GUIDE.md` for detailed instructions.
|
||||
|
||||
### Option 2: Manual Token (Alternative)
|
||||
|
||||
If you prefer not to provide credentials:
|
||||
|
||||
1. **Log in to Kosmi** in your browser
|
||||
2. **Extract your token**:
|
||||
- Open DevTools (F12) → Console tab
|
||||
- Run: `localStorage.getItem('token')`
|
||||
- Copy the token (without quotes)
|
||||
3. **Add to config**:
|
||||
```toml
|
||||
[kosmi.hyperspaceout]
|
||||
RoomURL="https://app.kosmi.io/room/@hyperspaceout"
|
||||
Token="your-token-here"
|
||||
```
|
||||
4. **Run the bot**: `./irc-kosmi-relay -conf matterbridge.toml`
|
||||
|
||||
See `QUICK_START_TOKEN.md` for detailed instructions.
|
||||
|
||||
## Authentication Methods
|
||||
|
||||
### ✅ Email/Password (RECOMMENDED)
|
||||
- **Status**: Working - Fully Automated
|
||||
- **Setup**: Provide email and password in config
|
||||
- **Pros**: Fully automated, auto-refresh, no manual intervention
|
||||
- **Cons**: Requires Chrome/Chromium, credentials in config file
|
||||
- **Use Case**: Production use
|
||||
- **Details**: See `BROWSER_AUTH_GUIDE.md`
|
||||
|
||||
### ✅ Manual Token (Alternative)
|
||||
- **Status**: Working
|
||||
- **Setup**: Extract token from browser localStorage
|
||||
- **Pros**: Simple, no browser required, no credentials in config
|
||||
- **Cons**: Manual process, token expires after ~1 year
|
||||
- **Use Case**: When Chrome is not available or preferred
|
||||
- **Details**: See `QUICK_START_TOKEN.md`
|
||||
|
||||
### ✅ Anonymous Login
|
||||
- **Status**: Working
|
||||
- **Setup**: Leave both `Email` and `Token` fields empty
|
||||
- **Pros**: No configuration needed
|
||||
- **Cons**: Limited permissions, can't access private rooms
|
||||
- **Use Case**: Testing, public rooms
|
||||
|
||||
## Technical Details
|
||||
|
||||
### How It Works
|
||||
1. User provides JWT token in configuration
|
||||
2. Bot uses token directly for WebSocket authentication
|
||||
3. Token is sent in `connection_init` payload
|
||||
4. Kosmi validates token and establishes authenticated session
|
||||
|
||||
### Token Format
|
||||
- **Algorithm**: HS512
|
||||
- **Expiry**: ~1 year from issue
|
||||
- **Storage**: Browser localStorage with key `"token"`
|
||||
- **Format**: Standard JWT (header.payload.signature)
|
||||
|
||||
### Token Payload Example
|
||||
```json
|
||||
{
|
||||
"aud": "kosmi",
|
||||
"exp": 1793465205,
|
||||
"iat": 1762015605,
|
||||
"iss": "kosmi",
|
||||
"jti": "1c7f9db8-9a65-4909-92c0-1c646103bdee",
|
||||
"nbf": 1762015604,
|
||||
"sub": "4ec0b428-712b-49d6-8551-224295 45d29b",
|
||||
"typ": "access"
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Bot connects anonymously instead of using token
|
||||
- Check token is correctly copied (no quotes, no extra spaces)
|
||||
- Verify `Token=""` line is not commented out
|
||||
- Check logs for "Using provided authentication token"
|
||||
|
||||
### Connection fails with token
|
||||
- Token may be expired - extract a new one
|
||||
- Verify you're logged in to correct Kosmi account
|
||||
- Check token format is valid JWT
|
||||
|
||||
### How to check token expiry
|
||||
```bash
|
||||
# Decode token at https://jwt.io
|
||||
# Or use command line:
|
||||
echo "your-token" | cut -d'.' -f2 | base64 -d | jq .exp | xargs -I {} date -r {}
|
||||
```
|
||||
|
||||
## Implementation Files
|
||||
|
||||
### Core Implementation
|
||||
- `bridge/kosmi/graphql_ws_client.go` - WebSocket client with token support
|
||||
- `bridge/kosmi/kosmi.go` - Bridge integration and config handling
|
||||
- `bridge/kosmi/auth.go` - Auth manager (for future email/password support)
|
||||
|
||||
### Configuration
|
||||
- `matterbridge.toml` - Main config with Token field
|
||||
- `test-token-config.toml` - Example test configuration
|
||||
|
||||
### Documentation
|
||||
- `QUICK_START_TOKEN.md` - User guide for manual token setup
|
||||
- `AUTH_DISCOVERY.md` - Technical investigation details
|
||||
- `chat-summaries/auth-discovery-2025-11-01-16-48.md` - Session summary
|
||||
|
||||
### Testing Tools
|
||||
- `cmd/test-introspection/main.go` - GraphQL schema introspection
|
||||
- `cmd/test-login/main.go` - Test login mutations
|
||||
- `cmd/monitor-auth/main.go` - Browser automation for traffic capture
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Possible Improvements
|
||||
1. **Automatic Token Extraction**: Browser automation to extract token
|
||||
2. **Token Refresh**: Implement if refresh mechanism is discovered
|
||||
3. **Email/Password**: Deep reverse engineering of Kosmi's auth
|
||||
4. **Token Expiry Warning**: Alert user when token is about to expire
|
||||
|
||||
### Priority
|
||||
- **Low**: Current solution works well for most use cases
|
||||
- **Revisit**: If Kosmi adds official bot API or auth documentation
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Token Storage
|
||||
- Tokens are stored in plain text in `matterbridge.toml`
|
||||
- Ensure config file has appropriate permissions (chmod 600)
|
||||
- Do not commit config with real tokens to version control
|
||||
|
||||
### Token Sharing
|
||||
- Each token is tied to a specific user account
|
||||
- Do not share tokens between users
|
||||
- Revoke token by logging out in browser if compromised
|
||||
|
||||
### Best Practices
|
||||
1. Use dedicated bot account for token
|
||||
2. Limit bot account permissions in Kosmi
|
||||
3. Rotate tokens periodically (even though they last 1 year)
|
||||
4. Monitor bot activity for unusual behavior
|
||||
|
||||
## Support
|
||||
|
||||
### Getting Help
|
||||
1. Check `QUICK_START_TOKEN.md` for setup instructions
|
||||
2. Review `AUTH_DISCOVERY.md` for technical details
|
||||
3. Check logs for error messages
|
||||
4. Verify token is valid and not expired
|
||||
|
||||
### Reporting Issues
|
||||
If you encounter problems:
|
||||
1. Check token expiry
|
||||
2. Verify configuration format
|
||||
3. Review bot logs for errors
|
||||
4. Try extracting a fresh token
|
||||
|
||||
## Conclusion
|
||||
|
||||
Manual token provision provides a **reliable and simple** authentication method for the Kosmi bridge. While not as automated as email/password would be, it:
|
||||
- Works immediately
|
||||
- Requires minimal maintenance (tokens last ~1 year)
|
||||
- Has no external dependencies
|
||||
- Is easy to troubleshoot
|
||||
|
||||
For most users, this is the recommended authentication method.
|
||||
|
||||
179
AUTH_DISCOVERY.md
Normal file
179
AUTH_DISCOVERY.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# Kosmi Authentication Discovery
|
||||
|
||||
## Date: November 1, 2025
|
||||
|
||||
## Summary
|
||||
Investigation into Kosmi's email/password authentication mechanism reveals a non-standard implementation that requires further reverse engineering.
|
||||
|
||||
## Findings
|
||||
|
||||
### 1. GraphQL API Analysis
|
||||
**Endpoint**: `https://engine.kosmi.io/`
|
||||
|
||||
**Available Mutations**:
|
||||
```bash
|
||||
$ ./bin/test-introspection | jq '.data.__schema.mutationType.fields[] | .name'
|
||||
"anonLogin"
|
||||
"slackLogin"
|
||||
```
|
||||
|
||||
**Key Discovery**: There is NO `login`, `emailLogin`, or `passwordLogin` mutation in the GraphQL API.
|
||||
|
||||
### 2. Anonymous Login (Working)
|
||||
The `anonLogin` mutation works and is currently implemented:
|
||||
```graphql
|
||||
mutation {
|
||||
anonLogin {
|
||||
token
|
||||
user {
|
||||
id
|
||||
displayName
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Email/Password Login (Mystery)
|
||||
**Observations**:
|
||||
- Email/password login works successfully in the browser
|
||||
- After login, a JWT token appears in `localStorage` with key `"token"`
|
||||
- Example token payload:
|
||||
```json
|
||||
{
|
||||
"aud": "kosmi",
|
||||
"exp": 1793465205,
|
||||
"iat": 1762015605,
|
||||
"iss": "kosmi",
|
||||
"jti": "1c7f9db8-9a65-4909-92c0-1c646103bdee",
|
||||
"nbf": 1762015604,
|
||||
"sub": "4ec0b428-712b-49d6-8551-224295 45d29b",
|
||||
"typ": "access"
|
||||
}
|
||||
```
|
||||
- Token expires in ~1 year
|
||||
- Algorithm: HS512
|
||||
|
||||
**REST API Endpoints Found**:
|
||||
```bash
|
||||
# Both return 400 with empty error message
|
||||
POST https://engine.kosmi.io/auth/login
|
||||
POST https://engine.kosmi.io/login
|
||||
|
||||
# Response format:
|
||||
{
|
||||
"errors": [
|
||||
{
|
||||
"message": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Tested Request Formats** (all failed):
|
||||
```json
|
||||
{"email": "...", "password": "..."}
|
||||
{"username": "...", "password": "..."}
|
||||
```
|
||||
|
||||
### 4. Possible Authentication Mechanisms
|
||||
|
||||
#### Theory 1: Client-Side JWT Generation
|
||||
- The browser might be generating JWTs client-side
|
||||
- This would be a security concern but would explain why there's no server endpoint
|
||||
- Would require extracting the signing key from the JavaScript bundle
|
||||
|
||||
#### Theory 2: Hidden Authentication Flow
|
||||
- The password might be hashed/processed client-side before transmission
|
||||
- Could be using SRP (Secure Remote Password) or similar protocol
|
||||
- Would require analyzing the minified JavaScript
|
||||
|
||||
#### Theory 3: Separate Authentication Service
|
||||
- Kosmi might use a third-party auth service (not Firebase/Auth0/Cognito - those were checked)
|
||||
- The service might not be directly accessible via HTTP
|
||||
|
||||
#### Theory 4: WebSocket-Based Authentication
|
||||
- Authentication might happen over the WebSocket connection
|
||||
- The REST endpoints might be red herrings
|
||||
|
||||
### 5. Browser Monitoring Attempts
|
||||
Multiple attempts were made to capture the authentication request:
|
||||
- Playwright network interception (failed - page reloads cleared interceptors)
|
||||
- JavaScript fetch() hooking (failed - page reloads)
|
||||
- Browser MCP tools (successful login but couldn't capture request body)
|
||||
|
||||
**Successful Test**:
|
||||
- Used browser MCP tools to log in with credentials: `email@email.com` / `password`
|
||||
- Login succeeded
|
||||
- Token appeared in localStorage
|
||||
- But the actual HTTP request was not captured
|
||||
|
||||
## Current Implementation Status
|
||||
|
||||
### ✅ Working
|
||||
- Anonymous authentication via `anonLogin` GraphQL mutation
|
||||
- Token storage and usage for WebSocket connections
|
||||
- Automatic reconnection with token refresh
|
||||
|
||||
### ❌ Not Working
|
||||
- Email/password authentication
|
||||
- Token refresh (no `refreshToken` mutation found in GraphQL API)
|
||||
|
||||
## Recommendations
|
||||
|
||||
### Option 1: Anonymous-Only (Current State)
|
||||
- Document that only anonymous login is supported
|
||||
- Users can still connect to rooms anonymously
|
||||
- This is sufficient for basic bot functionality
|
||||
|
||||
### Option 2: Manual Token Provision
|
||||
- Allow users to provide a pre-obtained token in the config
|
||||
- Users would log in via browser, extract token from localStorage
|
||||
- Bot uses the provided token directly
|
||||
- **Pros**: Simple, works immediately
|
||||
- **Cons**: Tokens expire (though they last ~1 year), manual process
|
||||
|
||||
### Option 3: Deep Reverse Engineering
|
||||
- Download and deobfuscate `core.js` (~several MB)
|
||||
- Find the authentication logic
|
||||
- Replicate it in Go
|
||||
- **Pros**: Full automation
|
||||
- **Cons**: Time-consuming, fragile (breaks if Kosmi updates their code)
|
||||
|
||||
### Option 4: Browser Automation
|
||||
- Use Playwright/Chromedp to automate browser login
|
||||
- Extract token from localStorage after login
|
||||
- Use token for bot connection
|
||||
- **Pros**: Works with any auth changes
|
||||
- **Cons**: Requires browser, more complex setup
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Immediate**: Implement Option 2 (Manual Token Provision)
|
||||
- Add `Token` field to config
|
||||
- If provided, skip `anonLogin` and use the token directly
|
||||
- Document how users can extract their token
|
||||
|
||||
2. **Future**: Attempt Option 3 if user provides more information
|
||||
- User might have insights into how their own auth works
|
||||
- Could provide network HAR file from browser DevTools
|
||||
|
||||
3. **Alternative**: Contact Kosmi
|
||||
- Ask if they have a documented API for bots
|
||||
- Request official authentication method
|
||||
|
||||
## Test Credentials Used
|
||||
- Email: `email@email.com`
|
||||
- Password: `password`
|
||||
- These credentials successfully logged in via browser
|
||||
|
||||
## Files Created During Investigation
|
||||
- `cmd/test-login/main.go` - Test GraphQL login mutations
|
||||
- `cmd/test-introspection/main.go` - GraphQL schema introspection
|
||||
- `cmd/monitor-auth/main.go` - Playwright-based traffic monitoring (had issues)
|
||||
|
||||
## Conclusion
|
||||
Kosmi's email/password authentication uses a non-standard mechanism that is not exposed through their GraphQL API. Further investigation would require either:
|
||||
1. Deep analysis of their minified JavaScript
|
||||
2. User providing more information about the auth flow
|
||||
3. Implementing manual token provision as a workaround
|
||||
|
||||
277
BROWSER_AUTH_GUIDE.md
Normal file
277
BROWSER_AUTH_GUIDE.md
Normal file
@@ -0,0 +1,277 @@
|
||||
# Browser-Based Authentication Guide
|
||||
|
||||
## Overview
|
||||
The Kosmi bridge now supports **fully automated email/password authentication** using headless Chrome via chromedp. No manual token extraction needed!
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Configure Email/Password
|
||||
Edit `matterbridge.toml`:
|
||||
|
||||
```toml
|
||||
[kosmi.hyperspaceout]
|
||||
RoomURL="https://app.kosmi.io/room/@hyperspaceout"
|
||||
Email="your-email@example.com"
|
||||
Password="your-password"
|
||||
```
|
||||
|
||||
### 2. Run the Bot
|
||||
```bash
|
||||
./irc-kosmi-relay -conf matterbridge.toml
|
||||
```
|
||||
|
||||
That's it! The bot will:
|
||||
1. Launch headless Chrome
|
||||
2. Navigate to Kosmi
|
||||
3. Log in with your credentials
|
||||
4. Extract the JWT token from localStorage
|
||||
5. Use the token for authenticated connections
|
||||
6. Automatically refresh the token daily (checks for expiry 7 days in advance)
|
||||
|
||||
## How It Works
|
||||
|
||||
### Initial Login
|
||||
When you start the bot with Email/Password configured:
|
||||
1. **Browser Launch**: Headless Chrome starts (no visible window)
|
||||
2. **Navigation**: Goes to https://app.kosmi.io
|
||||
3. **Login Flow**:
|
||||
- Clicks "Login" button
|
||||
- Clicks "Login with Email"
|
||||
- Fills in email and password
|
||||
- Submits the form
|
||||
4. **Token Extraction**: Reads `localStorage.getItem('token')`
|
||||
5. **Token Parsing**: Extracts expiry time from JWT
|
||||
6. **Connection**: Uses token for WebSocket authentication
|
||||
|
||||
### Automatic Token Refresh
|
||||
- **Daily Check**: Every 24 hours, the bot checks if the token is still valid
|
||||
- **Expiry Buffer**: Refreshes 7 days before expiration
|
||||
- **Seamless**: Happens in the background without disconnecting
|
||||
- **Logging**: You'll see "Checking token expiry..." in debug logs
|
||||
|
||||
## Requirements
|
||||
|
||||
### System Requirements
|
||||
- **Chrome/Chromium**: Must be installed on your system
|
||||
- macOS: Usually pre-installed or via Homebrew: `brew install chromium`
|
||||
- Linux: `sudo apt install chromium-browser` or `sudo yum install chromium`
|
||||
- Windows: Download from https://www.chromium.org/getting-involved/download-chromium/
|
||||
|
||||
### Go Dependencies
|
||||
- `github.com/chromedp/chromedp` - Automatically installed with `go get`
|
||||
|
||||
## Configuration Options
|
||||
|
||||
### Option 1: Email/Password (Recommended)
|
||||
```toml
|
||||
Email="your-email@example.com"
|
||||
Password="your-password"
|
||||
```
|
||||
- ✅ Fully automated
|
||||
- ✅ Auto-refresh
|
||||
- ✅ No manual intervention
|
||||
- ⚠️ Requires Chrome/Chromium
|
||||
- ⚠️ Stores credentials in config file
|
||||
|
||||
### Option 2: Manual Token
|
||||
```toml
|
||||
Token="eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9..."
|
||||
```
|
||||
- ✅ No browser required
|
||||
- ✅ No credentials in config
|
||||
- ❌ Manual extraction needed
|
||||
- ❌ Must update when expired (~1 year)
|
||||
|
||||
### Option 3: Anonymous
|
||||
```toml
|
||||
# Leave both empty
|
||||
Email=""
|
||||
Password=""
|
||||
Token=""
|
||||
```
|
||||
- ✅ No setup needed
|
||||
- ❌ Limited permissions
|
||||
- ❌ Can't access private rooms
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Browser automation failed"
|
||||
**Possible causes:**
|
||||
1. Chrome/Chromium not installed
|
||||
2. Chrome/Chromium not in PATH
|
||||
3. Network issues
|
||||
4. Kosmi website changed
|
||||
|
||||
**Solutions:**
|
||||
```bash
|
||||
# Check if Chrome is installed
|
||||
which chromium || which google-chrome || which chrome
|
||||
|
||||
# Install Chrome (macOS)
|
||||
brew install chromium
|
||||
|
||||
# Install Chrome (Ubuntu/Debian)
|
||||
sudo apt install chromium-browser
|
||||
|
||||
# Install Chrome (CentOS/RHEL)
|
||||
sudo yum install chromium
|
||||
```
|
||||
|
||||
### "No token found in localStorage after login"
|
||||
**Possible causes:**
|
||||
1. Login failed (wrong credentials)
|
||||
2. Kosmi's login flow changed
|
||||
3. Page didn't fully load
|
||||
|
||||
**Solutions:**
|
||||
- Verify credentials are correct
|
||||
- Check bot logs for detailed error messages
|
||||
- Try manual token extraction as fallback
|
||||
|
||||
### "Token expired or expiring soon"
|
||||
This is normal! The bot will automatically refresh the token. If refresh fails:
|
||||
- Check Chrome is still installed
|
||||
- Check network connectivity
|
||||
- Restart the bot to force a fresh login
|
||||
|
||||
### Headless Chrome Issues
|
||||
If you see Chrome-related errors:
|
||||
|
||||
```bash
|
||||
# Set environment variable for debugging
|
||||
export CHROMEDP_DISABLE_GPU=1
|
||||
|
||||
# Or run with visible browser (for debugging)
|
||||
export CHROMEDP_NO_HEADLESS=1
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Credential Storage
|
||||
- Credentials are stored in **plain text** in `matterbridge.toml`
|
||||
- Ensure config file has restrictive permissions:
|
||||
```bash
|
||||
chmod 600 matterbridge.toml
|
||||
```
|
||||
- Do not commit config with real credentials to version control
|
||||
- Consider using environment variables:
|
||||
```bash
|
||||
export KOSMI_EMAIL="your-email@example.com"
|
||||
export KOSMI_PASSWORD="your-password"
|
||||
```
|
||||
|
||||
### Browser Automation
|
||||
- Headless Chrome runs with minimal privileges
|
||||
- No data is stored or cached
|
||||
- Browser closes immediately after token extraction
|
||||
- Token is kept in memory only
|
||||
|
||||
### Token Security
|
||||
- Tokens are JWT (JSON Web Tokens) signed by Kosmi
|
||||
- Valid for ~1 year
|
||||
- Can be revoked by logging out in browser
|
||||
- Treat tokens like passwords
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
### Custom Chrome Path
|
||||
If Chrome is installed in a non-standard location:
|
||||
|
||||
```go
|
||||
// In browser_auth.go, modify NewContext call:
|
||||
opts := append(chromedp.DefaultExecAllocatorOptions[:],
|
||||
chromedp.ExecPath("/path/to/chrome"),
|
||||
)
|
||||
ctx, cancel := chromedp.NewExecAllocator(context.Background(), opts...)
|
||||
```
|
||||
|
||||
### Timeout Adjustment
|
||||
If login takes longer than 60 seconds:
|
||||
|
||||
```go
|
||||
// In browser_auth.go, modify timeout:
|
||||
ctx, cancel = context.WithTimeout(ctx, 120*time.Second)
|
||||
```
|
||||
|
||||
### Refresh Interval
|
||||
To check token more/less frequently:
|
||||
|
||||
```go
|
||||
// In kosmi.go, modify ticker:
|
||||
ticker := time.NewTicker(12 * time.Hour) // Check twice daily
|
||||
```
|
||||
|
||||
## Comparison with Manual Token
|
||||
|
||||
| Feature | Browser Auth | Manual Token |
|
||||
|---------|-------------|--------------|
|
||||
| Setup Complexity | Easy | Medium |
|
||||
| Automation | Full | None |
|
||||
| Token Refresh | Automatic | Manual |
|
||||
| Dependencies | Chrome | None |
|
||||
| Security | Credentials in config | Token in config |
|
||||
| Maintenance | Low | Medium |
|
||||
|
||||
## Logs to Expect
|
||||
|
||||
### Successful Login
|
||||
```
|
||||
INFO Using browser automation for email/password authentication
|
||||
INFO Obtaining authentication token via browser automation...
|
||||
INFO ✅ Successfully obtained token via browser automation
|
||||
INFO Token expires in: 365d
|
||||
INFO ✅ Browser authentication successful
|
||||
INFO Successfully connected to Kosmi
|
||||
```
|
||||
|
||||
### Daily Token Check
|
||||
```
|
||||
DEBUG Checking token expiry...
|
||||
DEBUG Token check complete
|
||||
```
|
||||
|
||||
### Token Refresh (when expiring)
|
||||
```
|
||||
INFO Token expired or expiring soon, will refresh
|
||||
INFO Obtaining authentication token via browser automation...
|
||||
INFO ✅ Successfully obtained token via browser automation
|
||||
INFO Token expires in: 365d
|
||||
```
|
||||
|
||||
## Migration from Manual Token
|
||||
|
||||
If you're currently using manual token:
|
||||
|
||||
1. **Add credentials** to config:
|
||||
```toml
|
||||
Email="your-email@example.com"
|
||||
Password="your-password"
|
||||
```
|
||||
|
||||
2. **Remove or comment out Token**:
|
||||
```toml
|
||||
#Token="..."
|
||||
```
|
||||
|
||||
3. **Restart the bot**
|
||||
|
||||
The bot will automatically switch to browser-based auth!
|
||||
|
||||
## Performance Impact
|
||||
|
||||
- **Initial Login**: ~5-10 seconds (one-time per start)
|
||||
- **Token Refresh**: ~5-10 seconds (once per year, or when expiring)
|
||||
- **Daily Check**: <1ms (just checks expiry time)
|
||||
- **Memory**: +50-100MB during browser operation (released after)
|
||||
- **CPU**: Minimal (browser runs briefly)
|
||||
|
||||
## Conclusion
|
||||
|
||||
Browser-based authentication provides the best balance of:
|
||||
- ✅ Full automation
|
||||
- ✅ Reliable token refresh
|
||||
- ✅ Simple configuration
|
||||
- ✅ Low maintenance
|
||||
|
||||
For production use, this is the **recommended authentication method**.
|
||||
|
||||
102
CRITICAL_FIX_OPERATION_ORDER.md
Normal file
102
CRITICAL_FIX_OPERATION_ORDER.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# CRITICAL FIX: GraphQL Operation Order
|
||||
|
||||
## Problem
|
||||
|
||||
The bot was only receiving ONE message then the WebSocket would die with "websocket: close 1006 (abnormal closure): unexpected EOF".
|
||||
|
||||
## Root Cause
|
||||
|
||||
We were subscribing to messages **BEFORE** joining the room, which is the opposite of what the browser does.
|
||||
|
||||
## Browser's Actual Sequence (from HAR analysis)
|
||||
|
||||
1. connection_init
|
||||
2. ExtendedCurrentUserQuery
|
||||
3. (various other queries)
|
||||
4. **[24] JoinRoom** ← Join FIRST
|
||||
5. (many subscriptions)
|
||||
6. [57] **RoomDisconnect** ← Subscribe to disconnect events
|
||||
7. [58] MemberJoins
|
||||
8. [59] MemberLeaves
|
||||
9. **[61] NewMessageSubscription** ← Subscribe to messages LAST
|
||||
|
||||
## Our Previous (Broken) Sequence
|
||||
|
||||
1. connection_init ✅
|
||||
2. ExtendedCurrentUserQuery ✅
|
||||
3. **NewMessageSubscription** ❌ TOO EARLY!
|
||||
4. JoinRoom ❌ TOO LATE!
|
||||
5. (start listening)
|
||||
|
||||
## Fixed Sequence
|
||||
|
||||
1. connection_init
|
||||
2. ExtendedCurrentUserQuery
|
||||
3. **JoinRoom** ← Now FIRST
|
||||
4. **RoomDisconnect subscription** ← NEW! Handles disconnect events
|
||||
5. **NewMessageSubscription** ← Now AFTER joining
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Reordered Operations
|
||||
- Moved `JoinRoom` to happen BEFORE `NewMessageSubscription`
|
||||
- This matches the browser's behavior
|
||||
|
||||
### 2. Added RoomDisconnect Subscription
|
||||
```graphql
|
||||
subscription RoomDisconnect($roomId: String!) {
|
||||
roomDisconnect(id: $roomId) {
|
||||
ok
|
||||
__typename
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This subscription is CRITICAL - it handles disconnect events from the server and prevents the socket from dying unexpectedly.
|
||||
|
||||
### 3. Added Socket Identification
|
||||
- All Kosmi WebSocket logs now prefixed with `[KOSMI WEBSOCKET]`
|
||||
- All Jackbox WebSocket logs now prefixed with `[JACKBOX WEBSOCKET]`
|
||||
- Makes debugging much easier
|
||||
|
||||
## Expected Behavior After Fix
|
||||
|
||||
1. Bot joins room successfully
|
||||
2. Bot subscribes to disconnect events
|
||||
3. Bot subscribes to message feed
|
||||
4. Bot receives ALL messages continuously (not just one)
|
||||
5. WebSocket stays alive
|
||||
6. Bot can send messages
|
||||
|
||||
## Testing
|
||||
|
||||
Rebuild and run:
|
||||
```bash
|
||||
go build
|
||||
docker-compose build
|
||||
docker-compose up -d
|
||||
docker-compose logs -f matterbridge
|
||||
```
|
||||
|
||||
Look for:
|
||||
- `✅ Successfully joined room`
|
||||
- `Subscribing to room disconnect events`
|
||||
- `Subscribing to message feed`
|
||||
- `🎧 [KOSMI WEBSOCKET] Message listener started`
|
||||
- Multiple `Received message from Kosmi` entries (not just one!)
|
||||
- NO `❌ [KOSMI WEBSOCKET] Error reading message` or `websocket: close 1006`
|
||||
|
||||
## Why This Matters
|
||||
|
||||
The server expects clients to:
|
||||
1. Join the room first
|
||||
2. Subscribe to disconnect events
|
||||
3. Then subscribe to messages
|
||||
|
||||
If you subscribe to messages before joining, the server may:
|
||||
- Only send one message as a "test"
|
||||
- Then close the connection because you're not properly joined
|
||||
- Or ignore subsequent messages
|
||||
|
||||
This is a common pattern in WebSocket APIs - you must establish your presence (join) before subscribing to events.
|
||||
|
||||
139
GATEWAY_TIMING_FIX.md
Normal file
139
GATEWAY_TIMING_FIX.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# CRITICAL FIX: Gateway Timing and Listener Start
|
||||
|
||||
## The Problem
|
||||
|
||||
Messages were being queued but NEVER flushed - the Remote channel was never becoming ready:
|
||||
|
||||
```
|
||||
17:21:23 📦 Remote channel not ready, queued message (1 in queue)
|
||||
17:21:23 📤 Attempting to flush 1 queued messages
|
||||
17:21:23 📦 Flushed 0 messages, 1 still queued ← NEVER SENDS!
|
||||
```
|
||||
|
||||
### Root Cause: Starting Listener Too Early
|
||||
|
||||
We were starting `listenForMessages()` in `Connect()`, but Matterbridge's architecture requires:
|
||||
|
||||
1. **`Connect()`** - Establish connection, set up client
|
||||
2. **Router sets up gateway** - Creates the `b.Remote` channel and wires everything together
|
||||
3. **`JoinChannel()`** - Called by router when gateway is READY
|
||||
4. **THEN** start receiving messages
|
||||
|
||||
By starting the listener in `Connect()`, we were receiving messages BEFORE the gateway was ready, so `b.Remote` wasn't set up yet.
|
||||
|
||||
## The Solution
|
||||
|
||||
**Delay starting the message listener until `JoinChannel()` is called.**
|
||||
|
||||
### Changes Made
|
||||
|
||||
1. **In `graphql_ws_client.go`**:
|
||||
- Removed `go c.listenForMessages()` from `Connect()`
|
||||
- Added new method `StartListening()` that starts the listener
|
||||
- Added to `KosmiClient` interface
|
||||
|
||||
2. **In `kosmi.go`**:
|
||||
- Call `b.client.StartListening()` in `JoinChannel()`
|
||||
- This is when the router has finished setting up the gateway
|
||||
|
||||
### Code Changes
|
||||
|
||||
```go
|
||||
// graphql_ws_client.go
|
||||
func (c *GraphQLWSClient) Connect() error {
|
||||
// ... connection setup ...
|
||||
|
||||
c.log.Info("Native WebSocket client connected (listener will start when gateway is ready)")
|
||||
|
||||
// DON'T start listener here!
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *GraphQLWSClient) StartListening() {
|
||||
c.log.Info("Starting message listener...")
|
||||
go c.listenForMessages()
|
||||
}
|
||||
|
||||
// kosmi.go
|
||||
func (b *Bkosmi) JoinChannel(channel config.ChannelInfo) error {
|
||||
b.Log.Infof("Channel %s is already connected via room URL", channel.Name)
|
||||
|
||||
// NOW start listening - the gateway is ready!
|
||||
b.Log.Info("Gateway is ready, starting message listener...")
|
||||
if b.client != nil {
|
||||
b.client.StartListening()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
## Why This Fixes Everything
|
||||
|
||||
### Before (Broken)
|
||||
```
|
||||
1. Connect() starts
|
||||
2. listenForMessages() starts immediately
|
||||
3. Messages start arriving
|
||||
4. Try to send to b.Remote → NOT READY YET
|
||||
5. Queue messages
|
||||
6. Router finishes setup (b.Remote now ready)
|
||||
7. Try to flush → but no new messages trigger flush
|
||||
8. Messages stuck in queue forever
|
||||
```
|
||||
|
||||
### After (Fixed)
|
||||
```
|
||||
1. Connect() starts
|
||||
2. Connection established, but NO listener yet
|
||||
3. Router finishes setup (b.Remote ready)
|
||||
4. JoinChannel() called
|
||||
5. StartListening() called
|
||||
6. listenForMessages() starts NOW
|
||||
7. Messages arrive
|
||||
8. Send to b.Remote → SUCCESS!
|
||||
9. No queue needed (but available as safety net)
|
||||
```
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
### Logs Should Show
|
||||
```
|
||||
17:20:43 Starting bridge: kosmi.hyperspaceout
|
||||
17:20:43 Connecting to Kosmi
|
||||
17:21:00 Successfully connected to Kosmi
|
||||
17:21:01 Native WebSocket client connected (listener will start when gateway is ready)
|
||||
17:21:01 Successfully connected to Kosmi
|
||||
17:21:01 kosmi.hyperspaceout: joining main
|
||||
17:21:01 Gateway is ready, starting message listener...
|
||||
17:21:01 Starting message listener...
|
||||
17:21:01 🎧 [KOSMI WEBSOCKET] Message listener started
|
||||
17:21:23 📨 [KOSMI WEBSOCKET] Received: type=next id=subscribe-messages
|
||||
17:21:23 Received message from Kosmi: [2025-11-01T17:21:23-04:00] cottongin: IKR
|
||||
17:21:23 ✅ Message forwarded to Matterbridge ← SUCCESS!
|
||||
```
|
||||
|
||||
## Why This Matters
|
||||
|
||||
This is a **fundamental timing issue** in how bridges integrate with Matterbridge's gateway system. The gateway must be fully initialized before messages start flowing, otherwise the routing infrastructure isn't ready.
|
||||
|
||||
This pattern should be followed by ALL bridges:
|
||||
- Connect and authenticate in `Connect()`
|
||||
- Start receiving messages in `JoinChannel()` (or equivalent)
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
go build
|
||||
docker-compose build
|
||||
docker-compose up -d
|
||||
docker-compose logs -f matterbridge
|
||||
```
|
||||
|
||||
Send messages immediately after bot connects. They should:
|
||||
1. NOT be queued
|
||||
2. Be forwarded directly to IRC/other bridges
|
||||
3. Appear in all connected channels
|
||||
|
||||
No more "Remote channel not ready" messages!
|
||||
|
||||
108
GRAPHQL_OPERATIONS_AUDIT.md
Normal file
108
GRAPHQL_OPERATIONS_AUDIT.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# GraphQL Operations Audit - Browser vs Bot
|
||||
|
||||
## Operations We're Currently Sending
|
||||
|
||||
### 1. ✅ connection_init
|
||||
**Status**: Correct
|
||||
- Sending token, ua, v, r parameters
|
||||
- Matches browser
|
||||
|
||||
### 2. ✅ ExtendedCurrentUserQuery
|
||||
**Status**: Correct (simplified)
|
||||
- **Browser**: Full query with realmInfo, colorSchemes, friends, etc.
|
||||
- **Bot**: Simplified to just currentUser fields we need
|
||||
- **Verdict**: OK - we don't need all the UI-related fields
|
||||
|
||||
### 3. ✅ NewMessageSubscription
|
||||
**Status**: Fixed (just updated)
|
||||
- Added channelId parameter
|
||||
- Added operationName
|
||||
- Added all required fields (member, reactions, etc.)
|
||||
|
||||
### 4. ✅ JoinRoom
|
||||
**Status**: Needs verification
|
||||
- **Browser**: `mutation JoinRoom($id: String!, $disconnectOtherConnections: Boolean)`
|
||||
- **Bot**: `mutation JoinRoom($id: String!)`
|
||||
- **Issue**: Missing `$disconnectOtherConnections` parameter
|
||||
|
||||
### 5. ✅ SendMessage2
|
||||
**Status**: Fixed (just updated)
|
||||
- Added channelId, replyToMessageId parameters
|
||||
- Added operationName
|
||||
- Matches browser
|
||||
|
||||
### 6. ❌ anonLogin
|
||||
**Status**: Only used for anonymous - OK
|
||||
|
||||
## Critical Operations the Browser Sends That We're Missing
|
||||
|
||||
### Room Management
|
||||
1. **RoomRootQuery** - Gets room metadata
|
||||
2. **WithGetMembers** - Gets room members list
|
||||
3. **RoomChatQuery** - Gets chat history
|
||||
|
||||
### Member Events (Important!)
|
||||
4. **MemberJoins** - Subscription for when members join
|
||||
5. **MemberLeaves** - Subscription for when members leave
|
||||
6. **NewMemberSubscription** - Another member join subscription
|
||||
|
||||
### Message Events
|
||||
7. **MessageDeletedSubscription** - When messages are deleted
|
||||
8. **MessageEditedSubscription** - When messages are edited
|
||||
9. **ReactionAdded** - When reactions are added
|
||||
10. **ReactionRemoved** - When reactions are removed
|
||||
|
||||
### Typing Indicators
|
||||
11. **UserTyping** - Subscription for typing indicators
|
||||
12. **StartTyping** - Mutation to send typing status
|
||||
|
||||
### Room State
|
||||
13. **RoomDisconnect** - Subscription for disconnect events
|
||||
14. **SetRole2** - Role changes subscription
|
||||
|
||||
## Priority Operations to Add
|
||||
|
||||
### HIGH PRIORITY (Required for basic functionality)
|
||||
1. **JoinRoom** - Fix to include `disconnectOtherConnections` parameter
|
||||
2. **WithGetMembers** - May be needed to see room members properly
|
||||
3. **RoomChatQuery** - May be needed to receive messages properly
|
||||
|
||||
### MEDIUM PRIORITY (Nice to have)
|
||||
4. **MemberJoins/MemberLeaves** - For presence notifications
|
||||
5. **MessageDeletedSubscription** - Handle deleted messages
|
||||
6. **UserTyping** - Typing indicators (already on our roadmap)
|
||||
|
||||
### LOW PRIORITY (Optional)
|
||||
7. All the media player, spaces, WebRTC subscriptions - Not needed for chat relay
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. ✅ Fix JoinRoom mutation to include disconnectOtherConnections
|
||||
2. ⏳ Test if current changes fix message sending/receiving
|
||||
3. ⏳ Add WithGetMembers if needed
|
||||
4. ⏳ Add RoomChatQuery if needed
|
||||
5. ⏳ Add member join/leave subscriptions for better presence tracking
|
||||
|
||||
## Browser Operation Sequence (First Connection)
|
||||
|
||||
1. connection_init (with token)
|
||||
2. SettingsQuery
|
||||
3. ExtendedCurrentUserQuery
|
||||
4. UserRoomQuery
|
||||
5. WebRTCIceServerQuery
|
||||
6. (various other queries)
|
||||
7. **JoinRoom** ← We do this
|
||||
8. (many subscriptions)
|
||||
9. **NewMessageSubscription** ← We do this
|
||||
10. (more subscriptions)
|
||||
|
||||
## Our Current Sequence
|
||||
|
||||
1. connection_init (with token) ✅
|
||||
2. ExtendedCurrentUserQuery ✅
|
||||
3. NewMessageSubscription ✅
|
||||
4. JoinRoom ✅
|
||||
5. (start listening)
|
||||
|
||||
**Observation**: We're subscribing to messages BEFORE joining the room, but the browser does it AFTER. Let's check if order matters.
|
||||
|
||||
117
MESSAGE_QUEUE_FIX.md
Normal file
117
MESSAGE_QUEUE_FIX.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# Message Queue Fix: Handling Early Messages
|
||||
|
||||
## The Problem
|
||||
|
||||
Messages were being dropped with the warning:
|
||||
```
|
||||
⚠️ Remote channel full, dropping message (this shouldn't happen)
|
||||
```
|
||||
|
||||
But the Remote channel wasn't actually "full" - it **wasn't ready yet**.
|
||||
|
||||
### Root Cause: Timing Issue
|
||||
|
||||
Looking at the logs:
|
||||
```
|
||||
17:09:02 🎧 [KOSMI WEBSOCKET] Message listener started
|
||||
17:09:09 📨 Received message from Kosmi
|
||||
17:09:09 ⚠️ Remote channel full, dropping message
|
||||
17:09:17 Connection succeeded (IRC) ← 15 seconds later!
|
||||
```
|
||||
|
||||
**The problem**: Kosmi connects and starts receiving messages BEFORE other bridges (like IRC) are connected and the Matterbridge router is fully set up. The `b.Remote` channel exists but isn't being read yet, so our non-blocking send fails.
|
||||
|
||||
## The Solution
|
||||
|
||||
Added a **message queue** to buffer messages that arrive before the Remote channel is ready.
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Try to send immediately** using non-blocking `select`
|
||||
2. **If channel not ready**: Queue the message in memory
|
||||
3. **On next successful send**: Flush all queued messages
|
||||
4. **Also try flushing in background** goroutine
|
||||
|
||||
### Implementation
|
||||
|
||||
```go
|
||||
type Bkosmi struct {
|
||||
// ... existing fields ...
|
||||
messageQueue []config.Message // Buffer for early messages
|
||||
queueMutex sync.Mutex // Protect the queue
|
||||
}
|
||||
|
||||
func (b *Bkosmi) handleIncomingMessage(payload *NewMessagePayload) {
|
||||
// ... create rmsg ...
|
||||
|
||||
// Try to send to Remote channel
|
||||
select {
|
||||
case b.Remote <- rmsg:
|
||||
// Success! Also flush any queued messages
|
||||
b.flushMessageQueue()
|
||||
default:
|
||||
// Channel not ready - queue the message
|
||||
b.queueMutex.Lock()
|
||||
b.messageQueue = append(b.messageQueue, rmsg)
|
||||
b.queueMutex.Unlock()
|
||||
|
||||
// Try to flush in background
|
||||
go b.flushMessageQueue()
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bkosmi) flushMessageQueue() {
|
||||
// Try to send all queued messages
|
||||
// Stop if channel becomes full again
|
||||
// Keep remaining messages in queue
|
||||
}
|
||||
```
|
||||
|
||||
## Why This Works
|
||||
|
||||
1. **No messages are lost** - They're buffered in memory until the router is ready
|
||||
2. **Non-blocking** - Uses goroutines and non-blocking selects throughout
|
||||
3. **Automatic retry** - Every successful send triggers a flush attempt
|
||||
4. **Thread-safe** - Uses mutex to protect the queue
|
||||
|
||||
## Expected Behavior After Fix
|
||||
|
||||
### Before
|
||||
```
|
||||
17:09:09 Received message from Kosmi: lo
|
||||
17:09:09 ⚠️ Remote channel full, dropping message
|
||||
17:09:24 Received message from Kosmi: lo
|
||||
17:09:24 ⚠️ Remote channel full, dropping message
|
||||
```
|
||||
|
||||
### After
|
||||
```
|
||||
17:09:09 Received message from Kosmi: lo
|
||||
17:09:09 📦 Remote channel not ready, queued message (1 in queue)
|
||||
17:09:24 Received message from Kosmi: lo
|
||||
17:09:24 📦 Remote channel not ready, queued message (2 in queue)
|
||||
17:09:30 ✅ Message forwarded to Matterbridge
|
||||
17:09:30 📤 Attempting to flush 2 queued messages
|
||||
17:09:30 ✅ Flushed all 2 queued messages
|
||||
```
|
||||
|
||||
## Why This Happened
|
||||
|
||||
When we added authentication and made other changes, we didn't change the connection timing, but the combination of:
|
||||
- Async message handling (fixed earlier)
|
||||
- Non-blocking channel sends (fixed earlier)
|
||||
- Early message arrival (fixed now)
|
||||
|
||||
All exposed this timing issue that was always there but masked by synchronous blocking behavior.
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
go build
|
||||
docker-compose build
|
||||
docker-compose up -d
|
||||
docker-compose logs -f matterbridge
|
||||
```
|
||||
|
||||
Send messages in Kosmi immediately after bot connects. They should all be queued and then flushed once the router is ready, with no messages dropped.
|
||||
|
||||
144
MISSING_OPERATIONS.md
Normal file
144
MISSING_OPERATIONS.md
Normal file
@@ -0,0 +1,144 @@
|
||||
# Missing GraphQL Operations
|
||||
|
||||
## Analysis of Browser HAR vs Our Implementation
|
||||
|
||||
After parsing `loggedin_full_stack_1.har.txt`, we found the browser's actual sequence:
|
||||
|
||||
### What We're Currently Doing
|
||||
|
||||
1. connection_init ✅
|
||||
2. ExtendedCurrentUserQuery ✅
|
||||
3. JoinRoom ✅
|
||||
4. RoomDisconnect subscription ✅
|
||||
5. NewMessageSubscription ✅
|
||||
|
||||
### What the Browser Actually Does (Simplified)
|
||||
|
||||
1. connection_init
|
||||
2. ExtendedCurrentUserQuery
|
||||
3. **JoinRoom** ← We do this
|
||||
4. [12-21] Various global subscriptions (notifications, private messages, etc.)
|
||||
5. [22] **GetRunningApp** query
|
||||
6. [23] **RoomRootQuery** query
|
||||
7. [24] **WithGetMembers** query
|
||||
8. [25] **GetSpacesState** query
|
||||
9. [26] **RoomChatQuery** ← **CRITICAL - Gets chat history!**
|
||||
10. [27] **LinkedMembers** query
|
||||
11. [28-33] Media/player queries
|
||||
12. [34] **RoomDisconnect** subscription ← We do this
|
||||
13. [35] **MemberJoins** subscription ← **MISSING!**
|
||||
14. [36] **MemberLeaves** subscription ← **MISSING!**
|
||||
15. [37] **SetRole2** subscription
|
||||
16. [38] **NewMessageSubscription** ← We do this
|
||||
|
||||
## Critical Missing Operations
|
||||
|
||||
### 1. RoomChatQuery (MOST IMPORTANT)
|
||||
**Variables**: `{roomId: "@hyperspaceout", channelId: "general", cursor: null}`
|
||||
**Returns**: Chat history (✅ GOT DATA)
|
||||
**Why it matters**: This query likely registers the bot as "present" in the chat and loads message history.
|
||||
|
||||
```graphql
|
||||
query RoomChatQuery($roomId: String!, $channelId: String!, $cursor: String) {
|
||||
chatArchive(roomId: $roomId, channelId: $channelId, cursor: $cursor) {
|
||||
forwardCursor
|
||||
backCursor
|
||||
results {
|
||||
id
|
||||
user {
|
||||
id
|
||||
isAnonymous
|
||||
isSubscribed
|
||||
username
|
||||
displayName
|
||||
avatarUrl
|
||||
__typename
|
||||
}
|
||||
member {
|
||||
id
|
||||
role
|
||||
__typename
|
||||
}
|
||||
body
|
||||
time
|
||||
editedAt
|
||||
originalBody
|
||||
reactions {
|
||||
emoji
|
||||
userObjects {
|
||||
id
|
||||
displayName
|
||||
avatarUrl
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
room {
|
||||
id
|
||||
state {
|
||||
members {
|
||||
id
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. MemberJoins Subscription
|
||||
**Variables**: `{roomId: "@hyperspaceout"}`
|
||||
**Why it matters**: Notifies when members join the room.
|
||||
|
||||
```graphql
|
||||
subscription MemberJoins($roomId: String!) {
|
||||
memberJoins(roomId: $roomId) {
|
||||
id
|
||||
role
|
||||
user {
|
||||
id
|
||||
username
|
||||
displayName
|
||||
avatarUrl
|
||||
isAnonymous
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. MemberLeaves Subscription
|
||||
**Variables**: `{roomId: "@hyperspaceout"}`
|
||||
**Why it matters**: Notifies when members leave the room.
|
||||
|
||||
```graphql
|
||||
subscription MemberLeaves($roomId: String!) {
|
||||
memberLeaves(roomId: $roomId) {
|
||||
id
|
||||
__typename
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Priority
|
||||
|
||||
1. **HIGH**: Add `RoomChatQuery` BEFORE `NewMessageSubscription`
|
||||
- This is likely why the bot isn't visible - it never "announces" its presence by loading the chat
|
||||
2. **MEDIUM**: Add `MemberJoins` and `MemberLeaves` subscriptions
|
||||
- These help the bot track room membership
|
||||
3. **LOW**: Add other room queries (GetRunningApp, RoomRootQuery, etc.)
|
||||
- These are nice-to-have but probably not critical for basic chat functionality
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Add `RoomChatQuery` after `JoinRoom` and before `NewMessageSubscription`
|
||||
2. Add `MemberJoins` and `MemberLeaves` subscriptions
|
||||
3. Test to see if bot becomes visible in chat
|
||||
4. If still not working, add more of the room queries
|
||||
|
||||
94
QUICK_START_AUTH.md
Normal file
94
QUICK_START_AUTH.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# Quick Start: Testing Authentication
|
||||
|
||||
## Step 1: Create Bot Account
|
||||
|
||||
1. Go to https://app.kosmi.io
|
||||
2. Sign up with a dedicated email (e.g., `your-bot@example.com`)
|
||||
3. Choose a display name (e.g., "HSO Relay Bot")
|
||||
4. Save the credentials securely
|
||||
|
||||
## Step 2: Test with Monitor Script
|
||||
|
||||
```bash
|
||||
cd /Users/erikfredericks/dev-ai/HSO/irc-kosmi-relay
|
||||
|
||||
# Run monitor in login mode
|
||||
./bin/monitor-auth -login
|
||||
|
||||
# In the browser that opens:
|
||||
# 1. Log in with your bot credentials
|
||||
# 2. Navigate to a room
|
||||
# 3. Press Ctrl+C to stop
|
||||
|
||||
# Review the captured data
|
||||
cat auth-monitor.log | grep -A 5 "login"
|
||||
```
|
||||
|
||||
## Step 3: Configure Matterbridge
|
||||
|
||||
Edit `matterbridge.toml`:
|
||||
|
||||
```toml
|
||||
[kosmi.hyperspaceout]
|
||||
RoomURL="https://app.kosmi.io/room/@hyperspaceout"
|
||||
Email="your-bot@example.com"
|
||||
Password="your-secure-password"
|
||||
```
|
||||
|
||||
## Step 4: Test Connection
|
||||
|
||||
```bash
|
||||
# Build the bridge
|
||||
go build
|
||||
|
||||
# Run with your config
|
||||
./matterbridge -conf matterbridge.toml
|
||||
|
||||
# Watch the logs for:
|
||||
# - "Using authenticated connection"
|
||||
# - "Logged in as: HSO Relay Bot"
|
||||
# - "Successfully connected to Kosmi"
|
||||
```
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
- [ ] Bot account created manually
|
||||
- [ ] Credentials documented securely
|
||||
- [ ] Monitor script captured login flow
|
||||
- [ ] Config file updated with credentials
|
||||
- [ ] Bridge logs show authenticated connection
|
||||
- [ ] Bot display name appears correctly in chat
|
||||
- [ ] Messages relay successfully
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Wrong account logged in
|
||||
|
||||
Check the log for "Logged in as: {name}". If it doesn't match your bot:
|
||||
- Verify email/password in config
|
||||
- Check for typos
|
||||
- Ensure you're using the correct credentials
|
||||
|
||||
### Anonymous connection despite credentials
|
||||
|
||||
Check that both Email AND Password are set:
|
||||
```bash
|
||||
grep -A 2 "Email=" matterbridge.toml
|
||||
```
|
||||
|
||||
### Token expired
|
||||
|
||||
The bridge should auto-refresh. If not:
|
||||
- Check logs for "Token refresh failed"
|
||||
- Verify credentials are still valid
|
||||
- Try manual login at app.kosmi.io
|
||||
|
||||
## Next Steps
|
||||
|
||||
Once authenticated connection works:
|
||||
- Test reconnection (simulate network failure)
|
||||
- Monitor for token refresh (wait 24 hours)
|
||||
- Test with multiple rooms
|
||||
- Set up as systemd service
|
||||
|
||||
See the monitoring script output and logs for detailed information about Kosmi's authentication behavior.
|
||||
63
TESTING_NOTES.md
Normal file
63
TESTING_NOTES.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Testing Notes
|
||||
|
||||
## Monitor Script - Ctrl+C Fix
|
||||
|
||||
**Issue**: Script wouldn't stop with Ctrl+C
|
||||
**Fix**: Simplified signal handling with `os.Exit(0)` in goroutine
|
||||
**Status**: ✅ Fixed in latest build
|
||||
|
||||
Test with:
|
||||
```bash
|
||||
./bin/monitor-auth
|
||||
# Press Ctrl+C - should exit immediately
|
||||
```
|
||||
|
||||
## Authentication Behavior
|
||||
|
||||
**Initial Assumption**: Kosmi auto-creates accounts (INCORRECT)
|
||||
**Reality**: Standard login - invalid credentials fail as expected
|
||||
|
||||
The initial test with `email@email.com` / `password` happened to work because:
|
||||
- That account already existed
|
||||
- Or it was a test account
|
||||
- NOT because Kosmi auto-creates accounts
|
||||
|
||||
**Actual Behavior**:
|
||||
- Valid credentials → login success
|
||||
- Invalid credentials → login failure (needs verification via monitoring)
|
||||
- Standard authentication flow
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Run monitoring with real login**:
|
||||
```bash
|
||||
./bin/monitor-auth -login
|
||||
# Log in with actual credentials
|
||||
# Review auth-monitor.log for API format
|
||||
```
|
||||
|
||||
2. **Verify auth API format**:
|
||||
- Check POST requests to engine.kosmi.io
|
||||
- Look for login mutation structure
|
||||
- Verify token format and expiry
|
||||
- Document actual API response
|
||||
|
||||
3. **Update auth.go if needed**:
|
||||
- Adjust GraphQL mutations to match actual API
|
||||
- Update token parsing logic
|
||||
- Test with real credentials
|
||||
|
||||
4. **Test reconnection**:
|
||||
- Run bridge with auth
|
||||
- Simulate network failure
|
||||
- Verify automatic reconnection
|
||||
- Check token refresh works
|
||||
|
||||
## Files Updated
|
||||
|
||||
- ✅ `cmd/monitor-auth/main.go` - Fixed Ctrl+C handling
|
||||
- ✅ `bridge/kosmi/auth.go` - Removed incorrect comments
|
||||
- ✅ `matterbridge.toml` - Removed incorrect warning
|
||||
- ✅ `cmd/monitor-auth/README.md` - Removed auto-registration section
|
||||
- ✅ `QUICK_START_AUTH.md` - Removed auto-registration references
|
||||
- ✅ Deleted `AUTH_NOTES.md` - Contained incorrect information
|
||||
283
TYPING_INDICATORS.md
Normal file
283
TYPING_INDICATORS.md
Normal file
@@ -0,0 +1,283 @@
|
||||
# Typing Indicators Investigation
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines how to investigate and implement typing indicators for the Kosmi bridge. Typing indicators show when users are actively typing messages.
|
||||
|
||||
## Monitoring Approach
|
||||
|
||||
### Using the Auth Monitoring Script
|
||||
|
||||
The `cmd/monitor-auth/main.go` script already captures all WebSocket traffic. To investigate typing indicators:
|
||||
|
||||
```bash
|
||||
# Run the monitoring script
|
||||
./bin/monitor-auth -room "https://app.kosmi.io/room/@hyperspaceout"
|
||||
|
||||
# In the Kosmi room, start typing (but don't send)
|
||||
# Watch the console output for WebSocket messages
|
||||
```
|
||||
|
||||
### What to Look For
|
||||
|
||||
Typing indicators are typically implemented as:
|
||||
|
||||
1. **GraphQL Subscription** (receiving typing events):
|
||||
```graphql
|
||||
subscription {
|
||||
userTyping(roomId: "...") {
|
||||
user {
|
||||
id
|
||||
displayName
|
||||
}
|
||||
isTyping
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **GraphQL Mutation** (sending typing status):
|
||||
```graphql
|
||||
mutation {
|
||||
setTyping(roomId: "...", isTyping: true)
|
||||
}
|
||||
```
|
||||
|
||||
3. **WebSocket Message Format**:
|
||||
- Look for messages with type `"next"` or `"data"`
|
||||
- Payload might contain `userTyping`, `typing`, `isTyping`, or similar fields
|
||||
- Usually sent when user starts/stops typing
|
||||
|
||||
### Expected Message Patterns
|
||||
|
||||
**When user starts typing:**
|
||||
```json
|
||||
{
|
||||
"type": "next",
|
||||
"id": "typing-subscription",
|
||||
"payload": {
|
||||
"data": {
|
||||
"userTyping": {
|
||||
"user": {
|
||||
"id": "user-123",
|
||||
"displayName": "Alice"
|
||||
},
|
||||
"isTyping": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**When user stops typing:**
|
||||
```json
|
||||
{
|
||||
"type": "next",
|
||||
"id": "typing-subscription",
|
||||
"payload": {
|
||||
"data": {
|
||||
"userTyping": {
|
||||
"user": {
|
||||
"id": "user-123",
|
||||
"displayName": "Alice"
|
||||
},
|
||||
"isTyping": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
Once typing indicators are discovered:
|
||||
|
||||
### 1. Add Subscription to GraphQL Client
|
||||
|
||||
In `bridge/kosmi/graphql_ws_client.go`, add a typing subscription:
|
||||
|
||||
```go
|
||||
func (c *GraphQLWSClient) subscribeToTyping() error {
|
||||
typingMsg := WSMessage{
|
||||
ID: "subscribe-typing",
|
||||
Type: messageTypeSubscribe,
|
||||
Payload: map[string]interface{}{
|
||||
"query": `subscription OnUserTyping($roomId: String!) {
|
||||
userTyping(roomId: $roomId) {
|
||||
user {
|
||||
id
|
||||
displayName
|
||||
username
|
||||
}
|
||||
isTyping
|
||||
}
|
||||
}`,
|
||||
"variables": map[string]interface{}{
|
||||
"roomId": c.roomID,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return c.conn.WriteJSON(typingMsg)
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Handle Typing Events
|
||||
|
||||
Add a callback for typing events:
|
||||
|
||||
```go
|
||||
type TypingPayload struct {
|
||||
Data struct {
|
||||
UserTyping struct {
|
||||
User struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Username string `json:"username"`
|
||||
} `json:"user"`
|
||||
IsTyping bool `json:"isTyping"`
|
||||
} `json:"userTyping"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func (c *GraphQLWSClient) OnTyping(callback func(*TypingPayload)) {
|
||||
c.typingCallback = callback
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Send Typing Status
|
||||
|
||||
Add a method to send typing status:
|
||||
|
||||
```go
|
||||
func (c *GraphQLWSClient) SendTyping(isTyping bool) error {
|
||||
msg := WSMessage{
|
||||
ID: fmt.Sprintf("set-typing-%d", time.Now().Unix()),
|
||||
Type: messageTypeSubscribe,
|
||||
Payload: map[string]interface{}{
|
||||
"query": `mutation SetTyping($roomId: String!, $isTyping: Boolean!) {
|
||||
setTyping(roomId: $roomId, isTyping: $isTyping) {
|
||||
ok
|
||||
}
|
||||
}`,
|
||||
"variables": map[string]interface{}{
|
||||
"roomId": c.roomID,
|
||||
"isTyping": isTyping,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return c.conn.WriteJSON(msg)
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Integrate with Matterbridge
|
||||
|
||||
In `bridge/kosmi/kosmi.go`, map typing events to Matterbridge:
|
||||
|
||||
```go
|
||||
func (b *Bkosmi) handleTypingEvent(payload *TypingPayload) {
|
||||
username := payload.Data.UserTyping.User.DisplayName
|
||||
if username == "" {
|
||||
username = payload.Data.UserTyping.User.Username
|
||||
}
|
||||
|
||||
rmsg := config.Message{
|
||||
Username: username,
|
||||
Channel: "main",
|
||||
Account: b.Account,
|
||||
Event: config.EventUserTyping,
|
||||
Protocol: "kosmi",
|
||||
}
|
||||
|
||||
if payload.Data.UserTyping.IsTyping {
|
||||
b.Remote <- rmsg
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Send Typing from IRC
|
||||
|
||||
When IRC users type, send typing indicator to Kosmi:
|
||||
|
||||
```go
|
||||
func (b *Bkosmi) Send(msg config.Message) (string, error) {
|
||||
// Handle typing indicators
|
||||
if msg.Event == config.EventUserTyping {
|
||||
if b.client != nil {
|
||||
b.client.SendTyping(true)
|
||||
// Set a timer to send false after 5 seconds
|
||||
time.AfterFunc(5*time.Second, func() {
|
||||
b.client.SendTyping(false)
|
||||
})
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// ... rest of Send implementation
|
||||
}
|
||||
```
|
||||
|
||||
## Use Cases
|
||||
|
||||
### 1. Show Typing in IRC
|
||||
|
||||
When Kosmi users type, show in IRC:
|
||||
```
|
||||
*** Alice is typing...
|
||||
```
|
||||
|
||||
### 2. Show Typing in Kosmi
|
||||
|
||||
When IRC users type, send typing indicator to Kosmi.
|
||||
|
||||
### 3. Fake Typing During Image Generation
|
||||
|
||||
When generating room code images (which takes ~2-3 seconds), send typing indicator:
|
||||
|
||||
```go
|
||||
func (b *Bkosmi) sendRoomCodeImage(roomCode string) error {
|
||||
// Send typing indicator
|
||||
if b.client != nil {
|
||||
b.client.SendTyping(true)
|
||||
defer b.client.SendTyping(false)
|
||||
}
|
||||
|
||||
// Generate and send image
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
This makes the bot appear more responsive while processing.
|
||||
|
||||
## Testing
|
||||
|
||||
1. **Manual Testing**:
|
||||
- Run monitoring script
|
||||
- Type in Kosmi (don't send)
|
||||
- Observe WebSocket traffic
|
||||
- Document the message format
|
||||
|
||||
2. **Integration Testing**:
|
||||
- Implement typing support
|
||||
- Test bidirectional typing indicators
|
||||
- Verify timing (typing should stop after inactivity)
|
||||
|
||||
3. **Edge Cases**:
|
||||
- Multiple users typing simultaneously
|
||||
- Typing indicator timeout
|
||||
- Connection loss during typing
|
||||
|
||||
## Status
|
||||
|
||||
- ✅ Monitoring script created (`cmd/monitor-auth/main.go`)
|
||||
- ⏳ **TODO**: Run monitoring script and capture typing events
|
||||
- ⏳ **TODO**: Document actual message format
|
||||
- ⏳ **TODO**: Implement typing support (optional)
|
||||
|
||||
## Notes
|
||||
|
||||
- Typing indicators are a "nice to have" feature
|
||||
- Implementation depends on Kosmi actually supporting them
|
||||
- If not supported, this can be skipped
|
||||
- The monitoring script is ready to capture the traffic when needed
|
||||
|
||||
@@ -215,6 +215,11 @@ func (b *Birc) doConnect() {
|
||||
// Sanitize nicks for RELAYMSG: replace IRC characters with special meanings with "-"
|
||||
func sanitizeNick(nick string) string {
|
||||
sanitize := func(r rune) rune {
|
||||
// Allow invisible characters used for preventing highlights
|
||||
// U+200B: zero-width space, U+2060: word joiner
|
||||
if r == '\u200B' || r == '\u2060' || r == '\x0F' {
|
||||
return r
|
||||
}
|
||||
if strings.ContainsRune("!+%@&#$:'\"?*,. ", r) {
|
||||
return '-'
|
||||
}
|
||||
@@ -229,12 +234,36 @@ func (b *Birc) doSend() {
|
||||
for msg := range b.Local {
|
||||
<-throttle.C
|
||||
username := msg.Username
|
||||
// Insert invisible characters into the actual username to prevent highlights
|
||||
// The username may already be formatted like "[protocol] <nick> " so we need to find
|
||||
// the actual nick part and modify that
|
||||
if len(msg.Username) > 0 {
|
||||
// Try to find the actual username within angle brackets <username>
|
||||
if strings.Contains(username, "<") && strings.Contains(username, ">") {
|
||||
startIdx := strings.Index(username, "<") + 1
|
||||
endIdx := strings.Index(username, ">")
|
||||
if startIdx < endIdx && endIdx <= len(username) {
|
||||
actualNick := username[startIdx:endIdx]
|
||||
if len(actualNick) > 1 {
|
||||
// Insert invisible characters after first character of actual nick
|
||||
modifiedNick := string(actualNick[0]) + "\u200B\u2060\x0F" + actualNick[1:]
|
||||
username = username[:startIdx] + modifiedNick + username[endIdx:]
|
||||
b.Log.Infof("Modified username: %q -> %q", msg.Username, username)
|
||||
}
|
||||
}
|
||||
} else if len(username) > 1 {
|
||||
// Fallback: no angle brackets, just modify the username directly
|
||||
username = string(username[0]) + "\u200B\u2060\x0F" + username[1:]
|
||||
b.Log.Infof("Modified username (no brackets): %q -> %q", msg.Username, username)
|
||||
}
|
||||
}
|
||||
// Optional support for the proposed RELAYMSG extension, described at
|
||||
// https://github.com/jlu5/ircv3-specifications/blob/master/extensions/relaymsg.md
|
||||
// nolint:nestif
|
||||
if (b.i.HasCapability("overdrivenetworks.com/relaymsg") || b.i.HasCapability("draft/relaymsg")) &&
|
||||
b.GetBool("UseRelayMsg") {
|
||||
username = sanitizeNick(username)
|
||||
b.Log.Infof("After sanitizeNick: %q (len=%d, bytes=%v)", username, len(username), []byte(username))
|
||||
text := msg.Text
|
||||
|
||||
// Work around girc chomping leading commas on single word messages?
|
||||
@@ -245,23 +274,24 @@ func (b *Birc) doSend() {
|
||||
if msg.Event == config.EventUserAction {
|
||||
b.i.Cmd.SendRawf("RELAYMSG %s %s :\x01ACTION %s\x01", msg.Channel, username, text) //nolint:errcheck
|
||||
} else {
|
||||
b.Log.Debugf("Sending RELAYMSG to channel %s: nick=%s", msg.Channel, username)
|
||||
b.Log.Infof("Sending RELAYMSG to channel %s: nick=%s", msg.Channel, username)
|
||||
b.i.Cmd.SendRawf("RELAYMSG %s %s :%s", msg.Channel, username, text) //nolint:errcheck
|
||||
}
|
||||
} else {
|
||||
if b.GetBool("Colornicks") {
|
||||
checksum := crc32.ChecksumIEEE([]byte(msg.Username))
|
||||
colorCode := checksum%14 + 2 // quick fix - prevent white or black color codes
|
||||
username = fmt.Sprintf("\x03%02d%s\x0F", colorCode, msg.Username)
|
||||
username = fmt.Sprintf("\x03%02d%s\x0F", colorCode, username)
|
||||
}
|
||||
b.Log.Infof("Final username before send: %q (len=%d, bytes=%v)", username, len(username), []byte(username))
|
||||
switch msg.Event {
|
||||
case config.EventUserAction:
|
||||
b.i.Cmd.Action(msg.Channel, username+msg.Text)
|
||||
case config.EventNoticeIRC:
|
||||
b.Log.Debugf("Sending notice to channel %s", msg.Channel)
|
||||
b.Log.Infof("Sending notice to channel %s", msg.Channel)
|
||||
b.i.Cmd.Notice(msg.Channel, username+msg.Text)
|
||||
default:
|
||||
b.Log.Debugf("Sending to channel %s", msg.Channel)
|
||||
b.Log.Infof("Sending to channel %s", msg.Channel)
|
||||
b.i.Cmd.Message(msg.Channel, username+msg.Text)
|
||||
}
|
||||
}
|
||||
|
||||
139
bridge/jackbox/errors.go
Normal file
139
bridge/jackbox/errors.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package jackbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Sentinel errors for common failure scenarios
|
||||
var (
|
||||
// ErrNotAuthenticated indicates the client is not authenticated
|
||||
ErrNotAuthenticated = errors.New("not authenticated")
|
||||
|
||||
// ErrAuthFailed indicates authentication failed
|
||||
ErrAuthFailed = errors.New("authentication failed")
|
||||
|
||||
// ErrConnectionLost indicates the WebSocket connection was lost
|
||||
ErrConnectionLost = errors.New("connection lost")
|
||||
|
||||
// ErrTokenExpired indicates the authentication token has expired
|
||||
ErrTokenExpired = errors.New("token expired")
|
||||
|
||||
// ErrInvalidResponse indicates an unexpected response from the API
|
||||
ErrInvalidResponse = errors.New("invalid response from API")
|
||||
|
||||
// ErrSessionNotFound indicates the specified session does not exist
|
||||
ErrSessionNotFound = errors.New("session not found")
|
||||
|
||||
// ErrNotSubscribed indicates not subscribed to any session
|
||||
ErrNotSubscribed = errors.New("not subscribed to any session")
|
||||
)
|
||||
|
||||
// APIError represents an API-related error with context
|
||||
type APIError struct {
|
||||
Op string // Operation that failed (e.g., "vote", "get_session", "authenticate")
|
||||
StatusCode int // HTTP status code
|
||||
Message string // Error message from API
|
||||
Err error // Underlying error
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
if e.StatusCode > 0 {
|
||||
return fmt.Sprintf("API error during %s (HTTP %d): %s", e.Op, e.StatusCode, e.Message)
|
||||
}
|
||||
if e.Err != nil {
|
||||
return fmt.Sprintf("API error during %s: %s (%v)", e.Op, e.Message, e.Err)
|
||||
}
|
||||
return fmt.Sprintf("API error during %s: %s", e.Op, e.Message)
|
||||
}
|
||||
|
||||
func (e *APIError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// WebSocketError represents a WebSocket-related error with context
|
||||
type WebSocketError struct {
|
||||
Op string // Operation that failed (e.g., "connect", "subscribe", "send")
|
||||
Err error // Underlying error
|
||||
}
|
||||
|
||||
func (e *WebSocketError) Error() string {
|
||||
return fmt.Sprintf("WebSocket error during %s: %v", e.Op, e.Err)
|
||||
}
|
||||
|
||||
func (e *WebSocketError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// SessionError represents a session-related error with context
|
||||
type SessionError struct {
|
||||
Op string // Operation that failed (e.g., "subscribe", "get_active")
|
||||
SessionID int // Session ID
|
||||
Err error // Underlying error
|
||||
}
|
||||
|
||||
func (e *SessionError) Error() string {
|
||||
return fmt.Sprintf("session error during %s for session %d: %v", e.Op, e.SessionID, e.Err)
|
||||
}
|
||||
|
||||
func (e *SessionError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// IsRetryable returns true if the error is transient and the operation should be retried
|
||||
func IsRetryable(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for known retryable errors
|
||||
if errors.Is(err, ErrConnectionLost) ||
|
||||
errors.Is(err, ErrTokenExpired) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for API errors with retryable status codes
|
||||
var apiErr *APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
// 5xx errors are typically retryable
|
||||
if apiErr.StatusCode >= 500 && apiErr.StatusCode < 600 {
|
||||
return true
|
||||
}
|
||||
// 429 Too Many Requests is retryable
|
||||
if apiErr.StatusCode == 429 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// WebSocket errors are generally retryable
|
||||
var wsErr *WebSocketError
|
||||
if errors.As(err, &wsErr) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// IsFatal returns true if the error is fatal and reconnection should not be attempted
|
||||
func IsFatal(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for known fatal errors
|
||||
if errors.Is(err, ErrAuthFailed) ||
|
||||
errors.Is(err, ErrSessionNotFound) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for API errors with fatal status codes
|
||||
var apiErr *APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
// 4xx errors (except 429) are typically fatal
|
||||
if apiErr.StatusCode >= 400 && apiErr.StatusCode < 500 && apiErr.StatusCode != 429 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
396
bridge/kosmi/auth.go
Normal file
396
bridge/kosmi/auth.go
Normal file
@@ -0,0 +1,396 @@
|
||||
package bkosmi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
// Token expiry buffer - refresh if token expires within this window
|
||||
tokenExpiryBuffer = 5 * time.Minute
|
||||
)
|
||||
|
||||
// AuthManager handles authentication with Kosmi
|
||||
type AuthManager struct {
|
||||
email string
|
||||
password string
|
||||
token string
|
||||
tokenExpiry time.Time
|
||||
refreshToken string
|
||||
userID string
|
||||
mu sync.RWMutex
|
||||
log *logrus.Entry
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewAuthManager creates a new authentication manager
|
||||
func NewAuthManager(email, password string, log *logrus.Entry) *AuthManager {
|
||||
return &AuthManager{
|
||||
email: email,
|
||||
password: password,
|
||||
log: log,
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Login performs email/password authentication
|
||||
//
|
||||
// NOTE: The actual login API format needs to be verified through monitoring.
|
||||
// This implementation is based on common GraphQL patterns and may need adjustment.
|
||||
func (a *AuthManager) Login() error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
a.log.Info("Logging in to Kosmi...")
|
||||
|
||||
// Prepare login mutation
|
||||
// Based on common GraphQL patterns, likely something like:
|
||||
// mutation { login(email: "...", password: "...") { token user { id displayName } } }
|
||||
mutation := map[string]interface{}{
|
||||
"query": `mutation Login($email: String!, $password: String!) {
|
||||
login(email: $email, password: $password) {
|
||||
token
|
||||
refreshToken
|
||||
expiresIn
|
||||
user {
|
||||
id
|
||||
displayName
|
||||
username
|
||||
}
|
||||
}
|
||||
}`,
|
||||
"variables": map[string]interface{}{
|
||||
"email": a.email,
|
||||
"password": a.password,
|
||||
},
|
||||
}
|
||||
|
||||
jsonBody, err := json.Marshal(mutation)
|
||||
if err != nil {
|
||||
return &AuthError{
|
||||
Op: "login",
|
||||
Reason: "failed to marshal request",
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", kosmiHTTPURL, bytes.NewReader(jsonBody))
|
||||
if err != nil {
|
||||
return &AuthError{
|
||||
Op: "login",
|
||||
Reason: "failed to create request",
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Referer", "https://app.kosmi.io/")
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
|
||||
resp, err := a.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return &AuthError{
|
||||
Op: "login",
|
||||
Reason: "request failed",
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 401 {
|
||||
return &AuthError{
|
||||
Op: "login",
|
||||
Reason: "invalid credentials",
|
||||
Err: ErrAuthFailed,
|
||||
}
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return &AuthError{
|
||||
Op: "login",
|
||||
Reason: fmt.Sprintf("HTTP %d", resp.StatusCode),
|
||||
Err: ErrAuthFailed,
|
||||
}
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return &AuthError{
|
||||
Op: "login",
|
||||
Reason: "failed to parse response",
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
// Extract token and user info
|
||||
if data, ok := result["data"].(map[string]interface{}); ok {
|
||||
if login, ok := data["login"].(map[string]interface{}); ok {
|
||||
if token, ok := login["token"].(string); ok {
|
||||
a.token = token
|
||||
|
||||
// Extract refresh token if present
|
||||
if refreshToken, ok := login["refreshToken"].(string); ok {
|
||||
a.refreshToken = refreshToken
|
||||
}
|
||||
|
||||
// Calculate token expiry
|
||||
if expiresIn, ok := login["expiresIn"].(float64); ok {
|
||||
a.tokenExpiry = time.Now().Add(time.Duration(expiresIn) * time.Second)
|
||||
} else {
|
||||
// Default to 24 hours if not specified
|
||||
a.tokenExpiry = time.Now().Add(24 * time.Hour)
|
||||
}
|
||||
|
||||
// Extract user ID
|
||||
if user, ok := login["user"].(map[string]interface{}); ok {
|
||||
if userID, ok := user["id"].(string); ok {
|
||||
a.userID = userID
|
||||
}
|
||||
if displayName, ok := user["displayName"].(string); ok {
|
||||
a.log.Infof("Logged in as: %s", displayName)
|
||||
}
|
||||
}
|
||||
|
||||
a.log.Info("✅ Login successful")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for GraphQL errors
|
||||
if errors, ok := result["errors"].([]interface{}); ok && len(errors) > 0 {
|
||||
if errObj, ok := errors[0].(map[string]interface{}); ok {
|
||||
if message, ok := errObj["message"].(string); ok {
|
||||
return &AuthError{
|
||||
Op: "login",
|
||||
Reason: message,
|
||||
Err: ErrAuthFailed,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &AuthError{
|
||||
Op: "login",
|
||||
Reason: "no token in response",
|
||||
Err: ErrAuthFailed,
|
||||
}
|
||||
}
|
||||
|
||||
// GetToken returns a valid token, refreshing if necessary
|
||||
func (a *AuthManager) GetToken() (string, error) {
|
||||
a.mu.RLock()
|
||||
|
||||
// Check if token is still valid
|
||||
if a.token != "" && time.Now().Before(a.tokenExpiry.Add(-tokenExpiryBuffer)) {
|
||||
token := a.token
|
||||
a.mu.RUnlock()
|
||||
return token, nil
|
||||
}
|
||||
|
||||
a.mu.RUnlock()
|
||||
|
||||
// Token is expired or about to expire, refresh it
|
||||
if a.refreshToken != "" {
|
||||
if err := a.RefreshToken(); err != nil {
|
||||
a.log.Warnf("Token refresh failed, attempting re-login: %v", err)
|
||||
if err := a.Login(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No refresh token, need to login again
|
||||
if err := a.Login(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
return a.token, nil
|
||||
}
|
||||
|
||||
// RefreshToken renews the access token using the refresh token
|
||||
func (a *AuthManager) RefreshToken() error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
if a.refreshToken == "" {
|
||||
return &AuthError{
|
||||
Op: "token_refresh",
|
||||
Reason: "no refresh token available",
|
||||
Err: ErrTokenExpired,
|
||||
}
|
||||
}
|
||||
|
||||
a.log.Debug("Refreshing authentication token...")
|
||||
|
||||
// Prepare refresh mutation
|
||||
mutation := map[string]interface{}{
|
||||
"query": `mutation RefreshToken($refreshToken: String!) {
|
||||
refreshToken(refreshToken: $refreshToken) {
|
||||
token
|
||||
refreshToken
|
||||
expiresIn
|
||||
}
|
||||
}`,
|
||||
"variables": map[string]interface{}{
|
||||
"refreshToken": a.refreshToken,
|
||||
},
|
||||
}
|
||||
|
||||
jsonBody, err := json.Marshal(mutation)
|
||||
if err != nil {
|
||||
return &AuthError{
|
||||
Op: "token_refresh",
|
||||
Reason: "failed to marshal request",
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", kosmiHTTPURL, bytes.NewReader(jsonBody))
|
||||
if err != nil {
|
||||
return &AuthError{
|
||||
Op: "token_refresh",
|
||||
Reason: "failed to create request",
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Referer", "https://app.kosmi.io/")
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", a.token))
|
||||
|
||||
resp, err := a.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return &AuthError{
|
||||
Op: "token_refresh",
|
||||
Reason: "request failed",
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return &AuthError{
|
||||
Op: "token_refresh",
|
||||
Reason: fmt.Sprintf("HTTP %d", resp.StatusCode),
|
||||
Err: ErrTokenExpired,
|
||||
}
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return &AuthError{
|
||||
Op: "token_refresh",
|
||||
Reason: "failed to parse response",
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
// Extract new token
|
||||
if data, ok := result["data"].(map[string]interface{}); ok {
|
||||
if refresh, ok := data["refreshToken"].(map[string]interface{}); ok {
|
||||
if token, ok := refresh["token"].(string); ok {
|
||||
a.token = token
|
||||
|
||||
// Update refresh token if provided
|
||||
if newRefreshToken, ok := refresh["refreshToken"].(string); ok {
|
||||
a.refreshToken = newRefreshToken
|
||||
}
|
||||
|
||||
// Update expiry
|
||||
if expiresIn, ok := refresh["expiresIn"].(float64); ok {
|
||||
a.tokenExpiry = time.Now().Add(time.Duration(expiresIn) * time.Second)
|
||||
} else {
|
||||
a.tokenExpiry = time.Now().Add(24 * time.Hour)
|
||||
}
|
||||
|
||||
a.log.Debug("✅ Token refreshed successfully")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &AuthError{
|
||||
Op: "token_refresh",
|
||||
Reason: "no token in response",
|
||||
Err: ErrTokenExpired,
|
||||
}
|
||||
}
|
||||
|
||||
// Logout invalidates the current session
|
||||
func (a *AuthManager) Logout() error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
if a.token == "" {
|
||||
return nil // Already logged out
|
||||
}
|
||||
|
||||
a.log.Info("Logging out from Kosmi...")
|
||||
|
||||
// Prepare logout mutation
|
||||
mutation := map[string]interface{}{
|
||||
"query": `mutation Logout {
|
||||
logout {
|
||||
ok
|
||||
}
|
||||
}`,
|
||||
}
|
||||
|
||||
jsonBody, err := json.Marshal(mutation)
|
||||
if err != nil {
|
||||
a.log.Warnf("Failed to marshal logout request: %v", err)
|
||||
// Continue with local cleanup
|
||||
} else {
|
||||
req, err := http.NewRequest("POST", kosmiHTTPURL, bytes.NewReader(jsonBody))
|
||||
if err == nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Referer", "https://app.kosmi.io/")
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", a.token))
|
||||
|
||||
resp, err := a.httpClient.Do(req)
|
||||
if err != nil {
|
||||
a.log.Warnf("Logout request failed: %v", err)
|
||||
} else {
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
a.log.Warnf("Logout returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear local state regardless of server response
|
||||
a.token = ""
|
||||
a.refreshToken = ""
|
||||
a.tokenExpiry = time.Time{}
|
||||
a.userID = ""
|
||||
|
||||
a.log.Info("✅ Logged out")
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsAuthenticated checks if we have a valid token
|
||||
func (a *AuthManager) IsAuthenticated() bool {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
return a.token != "" && time.Now().Before(a.tokenExpiry)
|
||||
}
|
||||
|
||||
// GetUserID returns the authenticated user's ID
|
||||
func (a *AuthManager) GetUserID() string {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
return a.userID
|
||||
}
|
||||
|
||||
462
bridge/kosmi/browser_auth.go
Normal file
462
bridge/kosmi/browser_auth.go
Normal file
@@ -0,0 +1,462 @@
|
||||
package bkosmi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/chromedp/chromedp"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
// Check token expiry 7 days before it expires
|
||||
tokenExpiryCheckBuffer = 7 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// BrowserAuthManager handles automated browser-based authentication
|
||||
type BrowserAuthManager struct {
|
||||
email string
|
||||
password string
|
||||
token string
|
||||
tokenExpiry time.Time
|
||||
log *logrus.Entry
|
||||
lastCheckTime time.Time
|
||||
checkInterval time.Duration
|
||||
}
|
||||
|
||||
// NewBrowserAuthManager creates a new browser-based authentication manager
|
||||
func NewBrowserAuthManager(email, password string, log *logrus.Entry) *BrowserAuthManager {
|
||||
return &BrowserAuthManager{
|
||||
email: email,
|
||||
password: password,
|
||||
log: log,
|
||||
checkInterval: 24 * time.Hour, // Check daily for token expiry
|
||||
}
|
||||
}
|
||||
|
||||
// GetToken returns a valid token, obtaining a new one via browser if needed
|
||||
func (b *BrowserAuthManager) GetToken() (string, error) {
|
||||
// Check if we need to obtain or refresh the token
|
||||
if b.token == "" || b.shouldRefreshToken() {
|
||||
b.log.Info("Obtaining authentication token via browser automation...")
|
||||
if err := b.loginViaBrowser(); err != nil {
|
||||
return "", &AuthError{
|
||||
Op: "browser_login",
|
||||
Reason: "failed to obtain token via browser",
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return b.token, nil
|
||||
}
|
||||
|
||||
// shouldRefreshToken checks if the token needs to be refreshed
|
||||
func (b *BrowserAuthManager) shouldRefreshToken() bool {
|
||||
// No token yet
|
||||
if b.token == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Token expired or about to expire
|
||||
if time.Now().After(b.tokenExpiry.Add(-tokenExpiryCheckBuffer)) {
|
||||
b.log.Info("Token expired or expiring soon, will refresh")
|
||||
return true
|
||||
}
|
||||
|
||||
// Periodic check (daily) to verify token is still valid
|
||||
if time.Since(b.lastCheckTime) > b.checkInterval {
|
||||
b.log.Debug("Performing periodic token validity check")
|
||||
b.lastCheckTime = time.Now()
|
||||
// For now, we trust the expiry time. Could add a validation check here.
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// loginViaBrowser uses chromedp to automate login and extract token
|
||||
func (b *BrowserAuthManager) loginViaBrowser() error {
|
||||
// Set up Chrome options
|
||||
opts := append(chromedp.DefaultExecAllocatorOptions[:],
|
||||
chromedp.Flag("headless", true),
|
||||
chromedp.Flag("disable-gpu", true),
|
||||
chromedp.Flag("no-sandbox", true),
|
||||
chromedp.Flag("disable-dev-shm-usage", true),
|
||||
)
|
||||
|
||||
// Create allocator context
|
||||
allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...)
|
||||
defer cancel()
|
||||
|
||||
// Create context with timeout
|
||||
ctx, cancel := chromedp.NewContext(allocCtx)
|
||||
defer cancel()
|
||||
|
||||
// Set a reasonable timeout for the entire login process
|
||||
ctx, cancel = context.WithTimeout(ctx, 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var token string
|
||||
|
||||
// Run the browser automation tasks
|
||||
err := chromedp.Run(ctx,
|
||||
// Navigate to Kosmi
|
||||
chromedp.Navigate("https://app.kosmi.io"),
|
||||
|
||||
// Wait for page to load completely
|
||||
chromedp.WaitReady("body"),
|
||||
chromedp.Sleep(2*time.Second),
|
||||
|
||||
// Click Login button (find by text content using JS with error handling)
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
// First, log what buttons we can see
|
||||
var buttonTexts []string
|
||||
chromedp.Evaluate(`
|
||||
Array.from(document.querySelectorAll('button')).map(el => el.textContent.trim())
|
||||
`, &buttonTexts).Do(ctx)
|
||||
b.log.Debugf("Found buttons: %v", buttonTexts)
|
||||
|
||||
var found bool
|
||||
if err := chromedp.Evaluate(`
|
||||
(() => {
|
||||
const buttons = Array.from(document.querySelectorAll('button'));
|
||||
// Try both "Login" and "Log in"
|
||||
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 (found buttons: %v)", buttonTexts)
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
|
||||
// Wait for login modal
|
||||
chromedp.Sleep(3*time.Second),
|
||||
|
||||
// Click "Login with Email" button
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
// Log what buttons we can see now
|
||||
var buttonTexts []string
|
||||
chromedp.Evaluate(`
|
||||
Array.from(document.querySelectorAll('button')).map(el => el.textContent.trim())
|
||||
`, &buttonTexts).Do(ctx)
|
||||
b.log.Debugf("After clicking Log in, found buttons: %v", buttonTexts)
|
||||
|
||||
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 (found buttons: %v)", buttonTexts)
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
|
||||
// Wait for email form
|
||||
chromedp.Sleep(3*time.Second),
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
b.log.Debug("Waiting for password input...")
|
||||
return nil
|
||||
}),
|
||||
chromedp.WaitVisible(`input[type="password"]`, chromedp.ByQuery),
|
||||
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
b.log.Debug("Password input found, preparing to fill form...")
|
||||
return nil
|
||||
}),
|
||||
|
||||
// Click on the email input to focus it
|
||||
chromedp.Click(`input[placeholder*="Email"], input[placeholder*="Username"]`, chromedp.ByQuery),
|
||||
chromedp.Sleep(200*time.Millisecond),
|
||||
|
||||
// Type email character by character
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
b.log.Debugf("Typing email: %s", b.email)
|
||||
return chromedp.SendKeys(`input[placeholder*="Email"], input[placeholder*="Username"]`, b.email, chromedp.ByQuery).Do(ctx)
|
||||
}),
|
||||
|
||||
chromedp.Sleep(500*time.Millisecond),
|
||||
|
||||
// Click on the password input to focus it
|
||||
chromedp.Click(`input[type="password"]`, chromedp.ByQuery),
|
||||
chromedp.Sleep(200*time.Millisecond),
|
||||
|
||||
// Type password character by character
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
b.log.Debugf("Typing password (length: %d)", len(b.password))
|
||||
return chromedp.SendKeys(`input[type="password"]`, b.password, chromedp.ByQuery).Do(ctx)
|
||||
}),
|
||||
|
||||
// Verify password was filled correctly
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
var actualLength int
|
||||
chromedp.Evaluate(`
|
||||
(() => {
|
||||
const passwordInput = document.querySelector('input[type="password"]');
|
||||
return passwordInput ? passwordInput.value.length : 0;
|
||||
})()
|
||||
`, &actualLength).Do(ctx)
|
||||
|
||||
b.log.Debugf("Password filled (actual length: %d, expected: %d)", actualLength, len(b.password))
|
||||
|
||||
if actualLength != len(b.password) {
|
||||
return fmt.Errorf("password length mismatch: got %d, expected %d", actualLength, len(b.password))
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
|
||||
chromedp.Sleep(500*time.Millisecond),
|
||||
|
||||
// Wait a moment for form validation
|
||||
chromedp.Sleep(1*time.Second),
|
||||
|
||||
// Click the login submit button (be very specific)
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
b.log.Debug("Attempting to click submit button...")
|
||||
var result string
|
||||
if err := chromedp.Evaluate(`
|
||||
(() => {
|
||||
const buttons = Array.from(document.querySelectorAll('button'));
|
||||
|
||||
// Find the submit button in the login form
|
||||
// It should be visible, enabled, and contain "Login" but not be the main nav button
|
||||
const submitBtn = buttons.find(el => {
|
||||
const text = el.textContent.trim();
|
||||
const isLoginBtn = text === 'Login' || text.startsWith('Login');
|
||||
const isEnabled = !el.disabled;
|
||||
const isVisible = el.offsetParent !== null;
|
||||
const isInForm = el.closest('form') !== null || el.closest('[role="dialog"]') !== null;
|
||||
|
||||
return isLoginBtn && isEnabled && isVisible && isInForm;
|
||||
});
|
||||
|
||||
if (submitBtn) {
|
||||
submitBtn.click();
|
||||
return 'CLICKED: ' + submitBtn.textContent.trim();
|
||||
}
|
||||
|
||||
return 'NOT_FOUND';
|
||||
})()
|
||||
`, &result).Do(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b.log.Debugf("Submit button result: %s", result)
|
||||
|
||||
if result == "NOT_FOUND" {
|
||||
return fmt.Errorf("Login submit button not found or not clickable")
|
||||
}
|
||||
|
||||
b.log.Debug("Submit button clicked")
|
||||
return nil
|
||||
}),
|
||||
|
||||
// Wait for login to complete (page will reload/redirect)
|
||||
chromedp.Sleep(5*time.Second),
|
||||
|
||||
// Check if login succeeded by looking for error messages
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
var errorText string
|
||||
chromedp.Evaluate(`
|
||||
(() => {
|
||||
const errorEl = document.querySelector('[role="alert"], .error, .alert-error');
|
||||
return errorEl ? errorEl.textContent : '';
|
||||
})()
|
||||
`, &errorText).Do(ctx)
|
||||
|
||||
if errorText != "" {
|
||||
return fmt.Errorf("login failed with error: %s", errorText)
|
||||
}
|
||||
|
||||
b.log.Debug("No error messages found, checking token...")
|
||||
return nil
|
||||
}),
|
||||
|
||||
// Extract token from localStorage
|
||||
chromedp.Evaluate(`localStorage.getItem('token')`, &token),
|
||||
|
||||
// Verify the token is not anonymous by checking if user info exists
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
var userInfo string
|
||||
chromedp.Evaluate(`
|
||||
(() => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) return 'NO_TOKEN';
|
||||
|
||||
// Decode JWT payload (middle part)
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return 'INVALID_TOKEN';
|
||||
|
||||
const payload = JSON.parse(atob(parts[1]));
|
||||
return JSON.stringify({
|
||||
sub: payload.sub,
|
||||
typ: payload.typ,
|
||||
isAnon: payload.sub ? false : true
|
||||
});
|
||||
} catch (e) {
|
||||
return 'ERROR: ' + e.message;
|
||||
}
|
||||
})()
|
||||
`, &userInfo).Do(ctx)
|
||||
|
||||
b.log.Debugf("Token info from browser: %s", userInfo)
|
||||
return nil
|
||||
}),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("browser automation failed: %w", err)
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
return fmt.Errorf("no token found in localStorage after login")
|
||||
}
|
||||
|
||||
b.token = token
|
||||
b.log.Infof("✅ Successfully obtained token via browser automation")
|
||||
b.log.Infof(" Email used: %s", b.email)
|
||||
b.log.Infof(" Token (first 50 chars): %s...", token[:min(50, len(token))])
|
||||
b.log.Infof(" Token (last 50 chars): ...%s", token[max(0, len(token)-50):])
|
||||
|
||||
// Parse token to get expiry
|
||||
if err := b.parseTokenExpiry(); err != nil {
|
||||
b.log.Warnf("Failed to parse token expiry: %v", err)
|
||||
// Default to 1 year if we can't parse
|
||||
b.tokenExpiry = time.Now().Add(365 * 24 * time.Hour)
|
||||
}
|
||||
|
||||
b.lastCheckTime = time.Now()
|
||||
|
||||
expiresIn := time.Until(b.tokenExpiry)
|
||||
b.log.Infof("Token expires in: %v", expiresIn.Round(24*time.Hour))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseTokenExpiry extracts the expiry time from the JWT token
|
||||
func (b *BrowserAuthManager) parseTokenExpiry() error {
|
||||
// JWT format: header.payload.signature
|
||||
parts := strings.Split(b.token, ".")
|
||||
if len(parts) != 3 {
|
||||
return fmt.Errorf("invalid JWT format")
|
||||
}
|
||||
|
||||
// Decode the payload (base64url without padding)
|
||||
payload := parts[1]
|
||||
// Add padding if needed
|
||||
switch len(payload) % 4 {
|
||||
case 2:
|
||||
payload += "=="
|
||||
case 3:
|
||||
payload += "="
|
||||
}
|
||||
|
||||
// Replace URL-safe characters
|
||||
payload = strings.ReplaceAll(payload, "-", "+")
|
||||
payload = strings.ReplaceAll(payload, "_", "/")
|
||||
|
||||
decoded, err := base64.StdEncoding.DecodeString(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode JWT payload: %w", err)
|
||||
}
|
||||
|
||||
// Parse JSON
|
||||
var claims struct {
|
||||
Exp int64 `json:"exp"`
|
||||
Sub string `json:"sub"`
|
||||
Typ string `json:"typ"`
|
||||
}
|
||||
if err := json.Unmarshal(decoded, &claims); err != nil {
|
||||
return fmt.Errorf("failed to parse JWT claims: %w", err)
|
||||
}
|
||||
|
||||
if claims.Exp == 0 {
|
||||
return fmt.Errorf("no expiry in token")
|
||||
}
|
||||
|
||||
b.tokenExpiry = time.Unix(claims.Exp, 0)
|
||||
b.log.Infof(" Token user ID (sub): %s", claims.Sub)
|
||||
b.log.Infof(" Token type (typ): %s", claims.Typ)
|
||||
return nil
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// IsAuthenticated checks if we have a valid token
|
||||
func (b *BrowserAuthManager) IsAuthenticated() bool {
|
||||
return b.token != "" && time.Now().Before(b.tokenExpiry)
|
||||
}
|
||||
|
||||
// GetUserID returns the user ID from the token (if available)
|
||||
func (b *BrowserAuthManager) GetUserID() string {
|
||||
if b.token == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
parts := strings.Split(b.token, ".")
|
||||
if len(parts) != 3 {
|
||||
return ""
|
||||
}
|
||||
|
||||
payload := parts[1]
|
||||
switch len(payload) % 4 {
|
||||
case 2:
|
||||
payload += "=="
|
||||
case 3:
|
||||
payload += "="
|
||||
}
|
||||
|
||||
payload = strings.ReplaceAll(payload, "-", "+")
|
||||
payload = strings.ReplaceAll(payload, "_", "/")
|
||||
|
||||
decoded, err := base64.StdEncoding.DecodeString(payload)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var claims struct {
|
||||
Sub string `json:"sub"`
|
||||
}
|
||||
if err := json.Unmarshal(decoded, &claims); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return claims.Sub
|
||||
}
|
||||
|
||||
155
bridge/kosmi/errors.go
Normal file
155
bridge/kosmi/errors.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package bkosmi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Sentinel errors for common failure scenarios
|
||||
var (
|
||||
// ErrNotConnected indicates the WebSocket connection is not established
|
||||
ErrNotConnected = errors.New("not connected to Kosmi")
|
||||
|
||||
// ErrAuthFailed indicates authentication with Kosmi failed
|
||||
ErrAuthFailed = errors.New("authentication failed")
|
||||
|
||||
// ErrConnectionLost indicates the WebSocket connection was lost
|
||||
ErrConnectionLost = errors.New("connection lost")
|
||||
|
||||
// ErrTokenExpired indicates the authentication token has expired
|
||||
ErrTokenExpired = errors.New("token expired")
|
||||
|
||||
// ErrInvalidRoomID indicates the room ID format is invalid
|
||||
ErrInvalidRoomID = errors.New("invalid room ID")
|
||||
|
||||
// ErrRoomNotFound indicates the specified room does not exist
|
||||
ErrRoomNotFound = errors.New("room not found")
|
||||
|
||||
// ErrMessageSendFailed indicates a message could not be sent
|
||||
ErrMessageSendFailed = errors.New("failed to send message")
|
||||
|
||||
// ErrSubscriptionFailed indicates subscription to room messages failed
|
||||
ErrSubscriptionFailed = errors.New("failed to subscribe to messages")
|
||||
|
||||
// ErrJoinRoomFailed indicates joining the room failed
|
||||
ErrJoinRoomFailed = errors.New("failed to join room")
|
||||
|
||||
// ErrConnectionTimeout indicates a connection timeout occurred
|
||||
ErrConnectionTimeout = errors.New("connection timeout")
|
||||
|
||||
// ErrInvalidResponse indicates an unexpected response from the server
|
||||
ErrInvalidResponse = errors.New("invalid response from server")
|
||||
)
|
||||
|
||||
// ConnectionError represents a connection-related error with context
|
||||
type ConnectionError struct {
|
||||
Op string // Operation that failed (e.g., "dial", "handshake", "subscribe")
|
||||
URL string // WebSocket URL
|
||||
Err error // Underlying error
|
||||
}
|
||||
|
||||
func (e *ConnectionError) Error() string {
|
||||
return fmt.Sprintf("connection error during %s to %s: %v", e.Op, e.URL, e.Err)
|
||||
}
|
||||
|
||||
func (e *ConnectionError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// AuthError represents an authentication-related error with context
|
||||
type AuthError struct {
|
||||
Op string // Operation that failed (e.g., "login", "token_refresh", "anonymous_login")
|
||||
Reason string // Human-readable reason
|
||||
Err error // Underlying error
|
||||
}
|
||||
|
||||
func (e *AuthError) Error() string {
|
||||
if e.Err != nil {
|
||||
return fmt.Sprintf("auth error during %s: %s (%v)", e.Op, e.Reason, e.Err)
|
||||
}
|
||||
return fmt.Sprintf("auth error during %s: %s", e.Op, e.Reason)
|
||||
}
|
||||
|
||||
func (e *AuthError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// MessageError represents a message-related error with context
|
||||
type MessageError struct {
|
||||
Op string // Operation that failed (e.g., "send", "receive", "parse")
|
||||
RoomID string // Room ID
|
||||
Message string // Message content (truncated if long)
|
||||
Err error // Underlying error
|
||||
}
|
||||
|
||||
func (e *MessageError) Error() string {
|
||||
return fmt.Sprintf("message error during %s in room %s: %v", e.Op, e.RoomID, e.Err)
|
||||
}
|
||||
|
||||
func (e *MessageError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// RoomError represents a room-related error with context
|
||||
type RoomError struct {
|
||||
Op string // Operation that failed (e.g., "join", "leave", "subscribe")
|
||||
RoomID string // Room ID
|
||||
Err error // Underlying error
|
||||
}
|
||||
|
||||
func (e *RoomError) Error() string {
|
||||
return fmt.Sprintf("room error during %s for room %s: %v", e.Op, e.RoomID, e.Err)
|
||||
}
|
||||
|
||||
func (e *RoomError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// IsRetryable returns true if the error is transient and the operation should be retried
|
||||
func IsRetryable(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for known retryable errors
|
||||
if errors.Is(err, ErrConnectionLost) ||
|
||||
errors.Is(err, ErrConnectionTimeout) ||
|
||||
errors.Is(err, ErrTokenExpired) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for connection errors (usually retryable)
|
||||
var connErr *ConnectionError
|
||||
if errors.As(err, &connErr) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// IsFatal returns true if the error is fatal and reconnection should not be attempted
|
||||
func IsFatal(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for known fatal errors
|
||||
if errors.Is(err, ErrAuthFailed) ||
|
||||
errors.Is(err, ErrInvalidRoomID) ||
|
||||
errors.Is(err, ErrRoomNotFound) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for auth errors with specific reasons
|
||||
var authErr *AuthError
|
||||
if errors.As(err, &authErr) {
|
||||
// Invalid credentials are fatal
|
||||
if authErr.Reason == "invalid credentials" ||
|
||||
authErr.Reason == "account not found" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
161
cmd/compare-auth/main.go
Normal file
161
cmd/compare-auth/main.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
bkosmi "github.com/42wim/matterbridge/bridge/kosmi"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 3 {
|
||||
fmt.Println("Usage: compare-auth <email> <password>")
|
||||
fmt.Println("")
|
||||
fmt.Println("This script will:")
|
||||
fmt.Println("1. Get an anonymous token")
|
||||
fmt.Println("2. Get an authenticated token via browser")
|
||||
fmt.Println("3. Decode and compare both JWT tokens")
|
||||
fmt.Println("4. Show what's different")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
email := os.Args[1]
|
||||
password := os.Args[2]
|
||||
|
||||
// Set up logging
|
||||
log := logrus.New()
|
||||
log.SetLevel(logrus.InfoLevel)
|
||||
entry := logrus.NewEntry(log)
|
||||
|
||||
fmt.Println(strings.Repeat("=", 80))
|
||||
fmt.Println("COMPARING ANONYMOUS vs AUTHENTICATED TOKENS")
|
||||
fmt.Println(strings.Repeat("=", 80))
|
||||
fmt.Println()
|
||||
|
||||
// Get anonymous token
|
||||
fmt.Println("📝 Step 1: Getting anonymous token...")
|
||||
client := bkosmi.NewGraphQLWSClient("https://app.kosmi.io/room/@test", "@test", entry)
|
||||
anonToken, err := client.GetAnonymousTokenForTest()
|
||||
if err != nil {
|
||||
fmt.Printf("❌ Failed to get anonymous token: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("✅ Anonymous token obtained (length: %d)\n", len(anonToken))
|
||||
fmt.Println()
|
||||
|
||||
// Get authenticated token
|
||||
fmt.Println("📝 Step 2: Getting authenticated token via browser...")
|
||||
browserAuth := bkosmi.NewBrowserAuthManager(email, password, entry)
|
||||
authToken, err := browserAuth.GetToken()
|
||||
if err != nil {
|
||||
fmt.Printf("❌ Failed to get authenticated token: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("✅ Authenticated token obtained (length: %d)\n", len(authToken))
|
||||
fmt.Println()
|
||||
|
||||
// Decode both tokens
|
||||
fmt.Println("📝 Step 3: Decoding JWT tokens...")
|
||||
fmt.Println()
|
||||
|
||||
anonClaims := decodeJWT(anonToken)
|
||||
authClaims := decodeJWT(authToken)
|
||||
|
||||
fmt.Println("ANONYMOUS TOKEN:")
|
||||
fmt.Println(strings.Repeat("=", 80))
|
||||
printClaims(anonClaims)
|
||||
fmt.Println()
|
||||
|
||||
fmt.Println("AUTHENTICATED TOKEN:")
|
||||
fmt.Println(strings.Repeat("=", 80))
|
||||
printClaims(authClaims)
|
||||
fmt.Println()
|
||||
|
||||
// Compare
|
||||
fmt.Println("DIFFERENCES:")
|
||||
fmt.Println(strings.Repeat("=", 80))
|
||||
compareClaims(anonClaims, authClaims)
|
||||
fmt.Println()
|
||||
|
||||
fmt.Println("📝 Step 4: Testing connection with both tokens...")
|
||||
fmt.Println()
|
||||
|
||||
// We can't easily test the actual connection here, but we've shown the token differences
|
||||
fmt.Println("✅ Analysis complete!")
|
||||
fmt.Println()
|
||||
fmt.Println("RECOMMENDATION:")
|
||||
fmt.Println("If the authenticated token has a different 'sub' (user ID), that user")
|
||||
fmt.Println("might not have the same permissions or profile as the anonymous user.")
|
||||
}
|
||||
|
||||
func decodeJWT(token string) map[string]interface{} {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return map[string]interface{}{"error": "invalid JWT format"}
|
||||
}
|
||||
|
||||
// Decode payload
|
||||
payload := parts[1]
|
||||
// Add padding if needed
|
||||
switch len(payload) % 4 {
|
||||
case 2:
|
||||
payload += "=="
|
||||
case 3:
|
||||
payload += "="
|
||||
}
|
||||
|
||||
// Replace URL-safe characters
|
||||
payload = strings.ReplaceAll(payload, "-", "+")
|
||||
payload = strings.ReplaceAll(payload, "_", "/")
|
||||
|
||||
decoded, err := base64.StdEncoding.DecodeString(payload)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"error": fmt.Sprintf("decode error: %v", err)}
|
||||
}
|
||||
|
||||
var claims map[string]interface{}
|
||||
if err := json.Unmarshal(decoded, &claims); err != nil {
|
||||
return map[string]interface{}{"error": fmt.Sprintf("parse error: %v", err)}
|
||||
}
|
||||
|
||||
return claims
|
||||
}
|
||||
|
||||
func printClaims(claims map[string]interface{}) {
|
||||
keys := []string{"sub", "aud", "iss", "typ", "iat", "exp", "nbf", "jti"}
|
||||
for _, key := range keys {
|
||||
if val, ok := claims[key]; ok {
|
||||
fmt.Printf(" %-10s: %v\n", key, val)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func compareClaims(anon, auth map[string]interface{}) {
|
||||
allKeys := make(map[string]bool)
|
||||
for k := range anon {
|
||||
allKeys[k] = true
|
||||
}
|
||||
for k := range auth {
|
||||
allKeys[k] = true
|
||||
}
|
||||
|
||||
different := false
|
||||
for key := range allKeys {
|
||||
anonVal := anon[key]
|
||||
authVal := auth[key]
|
||||
|
||||
if fmt.Sprintf("%v", anonVal) != fmt.Sprintf("%v", authVal) {
|
||||
different = true
|
||||
fmt.Printf(" %-10s: ANON=%v AUTH=%v\n", key, anonVal, authVal)
|
||||
}
|
||||
}
|
||||
|
||||
if !different {
|
||||
fmt.Println(" (No differences found - tokens are essentially the same)")
|
||||
}
|
||||
}
|
||||
|
||||
92
cmd/decode-token/main.go
Normal file
92
cmd/decode-token/main.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Println("Usage: decode-token <jwt-token>")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
token := os.Args[1]
|
||||
parts := strings.Split(token, ".")
|
||||
|
||||
if len(parts) != 3 {
|
||||
fmt.Println("Invalid JWT token format")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Decode header
|
||||
fmt.Println("=== JWT HEADER ===")
|
||||
headerBytes, err := base64.RawStdEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to decode header: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var header map[string]interface{}
|
||||
if err := json.Unmarshal(headerBytes, &header); err != nil {
|
||||
fmt.Printf("Failed to parse header: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
headerJSON, _ := json.MarshalIndent(header, "", " ")
|
||||
fmt.Println(string(headerJSON))
|
||||
|
||||
// Decode payload
|
||||
fmt.Println("\n=== JWT PAYLOAD (CLAIMS) ===")
|
||||
payloadBytes, err := base64.RawStdEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to decode payload: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(payloadBytes, &payload); err != nil {
|
||||
fmt.Printf("Failed to parse payload: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
payloadJSON, _ := json.MarshalIndent(payload, "", " ")
|
||||
fmt.Println(string(payloadJSON))
|
||||
|
||||
fmt.Println("\n=== KEY FIELDS ===")
|
||||
if sub, ok := payload["sub"].(string); ok {
|
||||
fmt.Printf("User ID (sub): %s\n", sub)
|
||||
}
|
||||
if typ, ok := payload["typ"].(string); ok {
|
||||
fmt.Printf("Token Type (typ): %s\n", typ)
|
||||
}
|
||||
if aud, ok := payload["aud"].(string); ok {
|
||||
fmt.Printf("Audience (aud): %s\n", aud)
|
||||
}
|
||||
if exp, ok := payload["exp"].(float64); ok {
|
||||
fmt.Printf("Expires (exp): %v\n", exp)
|
||||
}
|
||||
if iat, ok := payload["iat"].(float64); ok {
|
||||
fmt.Printf("Issued At (iat): %v\n", iat)
|
||||
}
|
||||
|
||||
// Check for user profile fields
|
||||
fmt.Println("\n=== USER PROFILE FIELDS ===")
|
||||
hasProfile := false
|
||||
for key, value := range payload {
|
||||
if strings.Contains(strings.ToLower(key), "name") ||
|
||||
strings.Contains(strings.ToLower(key), "user") ||
|
||||
strings.Contains(strings.ToLower(key), "display") ||
|
||||
strings.Contains(strings.ToLower(key), "email") {
|
||||
fmt.Printf("%s: %v\n", key, value)
|
||||
hasProfile = true
|
||||
}
|
||||
}
|
||||
if !hasProfile {
|
||||
fmt.Println("(No user profile fields found in token)")
|
||||
}
|
||||
}
|
||||
|
||||
74
cmd/extract-queries/main.go
Normal file
74
cmd/extract-queries/main.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type HAR struct {
|
||||
Log struct {
|
||||
Entries []struct {
|
||||
WebSocketMessages []struct {
|
||||
Type string `json:"type"`
|
||||
Data string `json:"data"`
|
||||
} `json:"_webSocketMessages"`
|
||||
} `json:"entries"`
|
||||
} `json:"log"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Println("Usage: extract-queries <har-file>")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(os.Args[1])
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to read file: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var har HAR
|
||||
if err := json.Unmarshal(data, &har); err != nil {
|
||||
fmt.Printf("Failed to parse HAR: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("=== WebSocket Messages ===\n")
|
||||
|
||||
for _, entry := range har.Log.Entries {
|
||||
for i, msg := range entry.WebSocketMessages {
|
||||
if msg.Type == "send" {
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(msg.Data), &payload); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
msgType, _ := payload["type"].(string)
|
||||
fmt.Printf("[%d] Type: %s\n", i, msgType)
|
||||
|
||||
if msgType == "subscribe" {
|
||||
if p, ok := payload["payload"].(map[string]interface{}); ok {
|
||||
if opName, ok := p["operationName"].(string); ok {
|
||||
fmt.Printf(" Operation: %s\n", opName)
|
||||
}
|
||||
if query, ok := p["query"].(string); ok {
|
||||
// Pretty print the query
|
||||
query = strings.ReplaceAll(query, "\\n", "\n")
|
||||
if len(query) > 500 {
|
||||
fmt.Printf(" Query:\n%s\n [...truncated...]\n\n", query[:500])
|
||||
} else {
|
||||
fmt.Printf(" Query:\n%s\n\n", query)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
244
cmd/monitor-auth/README.md
Normal file
244
cmd/monitor-auth/README.md
Normal file
@@ -0,0 +1,244 @@
|
||||
# Kosmi Auth & Reconnection Monitor
|
||||
|
||||
A comprehensive WebSocket monitoring tool for reverse engineering Kosmi's authentication and reconnection behavior.
|
||||
|
||||
## Features
|
||||
|
||||
- 📡 Captures all WebSocket traffic (send/receive)
|
||||
- 🔐 Monitors authentication flows (login, token acquisition)
|
||||
- 🔄 Tests reconnection behavior
|
||||
- 💾 Captures localStorage, sessionStorage, and cookies
|
||||
- 📝 Logs to both console and file
|
||||
- 🌐 Monitors HTTP requests/responses
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
cd /Users/erikfredericks/dev-ai/HSO/irc-kosmi-relay
|
||||
go build -o bin/monitor-auth ./cmd/monitor-auth
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### 1. Monitor Anonymous Connection (Default)
|
||||
|
||||
Captures the anonymous token acquisition and WebSocket connection:
|
||||
|
||||
```bash
|
||||
./bin/monitor-auth -room "https://app.kosmi.io/room/@hyperspaceout"
|
||||
```
|
||||
|
||||
**What it captures:**
|
||||
- Anonymous token request/response
|
||||
- WebSocket connection handshake
|
||||
- Message subscription
|
||||
- Room join
|
||||
- Incoming messages
|
||||
|
||||
### 2. Monitor Login Flow
|
||||
|
||||
Captures the full authentication flow when logging in:
|
||||
|
||||
```bash
|
||||
./bin/monitor-auth -login
|
||||
```
|
||||
|
||||
**What to do:**
|
||||
1. Script opens browser to Kosmi
|
||||
2. Manually log in with your credentials
|
||||
3. Navigate to a room
|
||||
4. Script captures all auth traffic
|
||||
|
||||
**What it captures:**
|
||||
- Login form submission
|
||||
- Token response
|
||||
- Token storage (localStorage/cookies)
|
||||
- Authenticated WebSocket connection
|
||||
- User information
|
||||
|
||||
### 3. Test Reconnection Behavior
|
||||
|
||||
Simulates network disconnection and observes reconnection:
|
||||
|
||||
```bash
|
||||
./bin/monitor-auth -reconnect
|
||||
```
|
||||
|
||||
**What it does:**
|
||||
1. Connects to Kosmi
|
||||
2. Simulates network offline for 10 seconds
|
||||
3. Restores network
|
||||
4. Observes reconnection behavior
|
||||
|
||||
**What it captures:**
|
||||
- Disconnection events
|
||||
- Reconnection attempts
|
||||
- Token refresh (if any)
|
||||
- Re-subscription
|
||||
|
||||
## Output
|
||||
|
||||
All captured data is written to:
|
||||
- **Console**: Real-time output with emojis for easy reading
|
||||
- **File**: `auth-monitor.log` in current directory
|
||||
|
||||
### Log Format
|
||||
|
||||
```
|
||||
HH:MM:SS.mmm [TYPE] Message
|
||||
```
|
||||
|
||||
Examples:
|
||||
```
|
||||
11:45:23.123 🌐 [HTTP REQUEST] POST https://engine.kosmi.io/
|
||||
11:45:23.456 📨 [HTTP RESPONSE] 200 https://engine.kosmi.io/
|
||||
11:45:23.789 🔌 [WS MONITOR] WebSocket created: wss://engine.kosmi.io/gql-ws
|
||||
11:45:24.012 📤 [WS MONITOR] SEND #1: {"type":"connection_init",...}
|
||||
11:45:24.234 📥 [WS MONITOR] RECEIVE #1: {"type":"connection_ack"}
|
||||
```
|
||||
|
||||
## Analyzing Captured Data
|
||||
|
||||
### Finding Authentication API
|
||||
|
||||
Look for POST requests to `https://engine.kosmi.io/`:
|
||||
|
||||
```bash
|
||||
grep "POST.*engine.kosmi.io" auth-monitor.log
|
||||
grep "Response Body" auth-monitor.log | grep -A 10 "login"
|
||||
```
|
||||
|
||||
### Finding Token Storage
|
||||
|
||||
Look for localStorage/sessionStorage writes:
|
||||
|
||||
```bash
|
||||
grep "localStorage" auth-monitor.log
|
||||
grep "token" auth-monitor.log
|
||||
```
|
||||
|
||||
### Finding Reconnection Logic
|
||||
|
||||
Look for WebSocket CLOSED/OPENED events:
|
||||
|
||||
```bash
|
||||
grep "WebSocket CLOSED" auth-monitor.log
|
||||
grep "WebSocket OPENED" auth-monitor.log
|
||||
```
|
||||
|
||||
## Common Patterns to Look For
|
||||
|
||||
### 1. Login Mutation
|
||||
|
||||
```graphql
|
||||
mutation Login($email: String!, $password: String!) {
|
||||
login(email: $email, password: $password) {
|
||||
token
|
||||
refreshToken
|
||||
expiresIn
|
||||
user {
|
||||
id
|
||||
displayName
|
||||
username
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Token Refresh Mutation
|
||||
|
||||
```graphql
|
||||
mutation RefreshToken($refreshToken: String!) {
|
||||
refreshToken(refreshToken: $refreshToken) {
|
||||
token
|
||||
refreshToken
|
||||
expiresIn
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Typing Indicators
|
||||
|
||||
```graphql
|
||||
mutation SetTyping($roomId: String!, $isTyping: Boolean!) {
|
||||
setTyping(roomId: $roomId, isTyping: $isTyping) {
|
||||
ok
|
||||
}
|
||||
}
|
||||
|
||||
subscription OnUserTyping($roomId: String!) {
|
||||
userTyping(roomId: $roomId) {
|
||||
user {
|
||||
id
|
||||
displayName
|
||||
}
|
||||
isTyping
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Script won't stop with Ctrl+C
|
||||
|
||||
**Fixed in latest version**. Rebuild if you have an old version:
|
||||
```bash
|
||||
go build -o bin/monitor-auth ./cmd/monitor-auth
|
||||
```
|
||||
|
||||
If still stuck, you can force quit:
|
||||
```bash
|
||||
# In another terminal
|
||||
pkill -f monitor-auth
|
||||
```
|
||||
|
||||
### Browser doesn't open
|
||||
|
||||
Make sure Playwright is installed:
|
||||
```bash
|
||||
go run github.com/playwright-community/playwright-go/cmd/playwright@latest install
|
||||
```
|
||||
|
||||
### No WebSocket traffic captured
|
||||
|
||||
The monitoring script injects BEFORE page load. If you see "WebSocket hook active" in the console, it's working. If not, try:
|
||||
1. Refresh the page
|
||||
2. Check browser console for errors
|
||||
3. Ensure you're on a Kosmi room page
|
||||
|
||||
### Log file is empty
|
||||
|
||||
Check that you have write permissions in the current directory:
|
||||
```bash
|
||||
touch auth-monitor.log
|
||||
ls -l auth-monitor.log
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
1. **Use with real credentials**: The monitoring script is safe - it runs locally and doesn't send data anywhere. Use real credentials to capture actual auth flows.
|
||||
|
||||
2. **Compare anonymous vs authenticated**: Run twice - once without `-login` and once with - to see the differences.
|
||||
|
||||
3. **Watch the browser**: Keep an eye on the browser window to see what triggers each WebSocket message.
|
||||
|
||||
4. **Search the log file**: Use `grep`, `jq`, or text editor to analyze the captured data.
|
||||
|
||||
5. **Test edge cases**: Try invalid credentials, expired tokens, network failures, etc.
|
||||
|
||||
## Next Steps
|
||||
|
||||
After capturing auth data:
|
||||
|
||||
1. Review `auth-monitor.log`
|
||||
2. Identify actual GraphQL mutation formats
|
||||
3. Update `bridge/kosmi/auth.go` if needed
|
||||
4. Test with real credentials in production
|
||||
5. Verify token refresh works correctly
|
||||
|
||||
## See Also
|
||||
|
||||
- `TYPING_INDICATORS.md` - Guide for implementing typing indicators
|
||||
- `IMPLEMENTATION_SUMMARY.md` - Overall project documentation
|
||||
- `chat-summaries/2025-11-01_*_reconnection-and-auth-implementation.md` - Implementation details
|
||||
|
||||
545
cmd/monitor-auth/main.go
Normal file
545
cmd/monitor-auth/main.go
Normal file
@@ -0,0 +1,545 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"time"
|
||||
|
||||
"github.com/playwright-community/playwright-go"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultRoomURL = "https://app.kosmi.io/room/@hyperspaceout"
|
||||
logFile = "auth-monitor.log"
|
||||
)
|
||||
|
||||
func main() {
|
||||
roomURL := flag.String("room", defaultRoomURL, "Kosmi room URL")
|
||||
loginMode := flag.Bool("login", false, "Monitor login flow (navigate to login page first)")
|
||||
testReconnect := flag.Bool("reconnect", false, "Test reconnection behavior (will pause network)")
|
||||
flag.Parse()
|
||||
|
||||
log.Println("🔍 Starting Kosmi Auth & Reconnection Monitor")
|
||||
log.Printf("📡 Room URL: %s", *roomURL)
|
||||
log.Printf("🔐 Login mode: %v", *loginMode)
|
||||
log.Printf("🔄 Reconnect test: %v", *testReconnect)
|
||||
log.Printf("📝 Log file: %s", logFile)
|
||||
log.Println()
|
||||
|
||||
// Create log file
|
||||
f, err := os.Create(logFile)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create log file: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Helper to log to both console and file
|
||||
logBoth := func(format string, args ...interface{}) {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
log.Println(msg)
|
||||
fmt.Fprintf(f, "%s %s\n", time.Now().Format("15:04:05.000"), msg)
|
||||
}
|
||||
|
||||
// Set up interrupt handler
|
||||
interrupt := make(chan os.Signal, 1)
|
||||
signal.Notify(interrupt, os.Interrupt)
|
||||
|
||||
// Launch Playwright
|
||||
pw, err := playwright.Run()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to start Playwright: %v", err)
|
||||
}
|
||||
|
||||
// Cleanup function
|
||||
cleanup := func() {
|
||||
logBoth("\n👋 Shutting down...")
|
||||
if pw != nil {
|
||||
pw.Stop()
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// Handle interrupt
|
||||
go func() {
|
||||
<-interrupt
|
||||
cleanup()
|
||||
}()
|
||||
|
||||
// Launch browser (visible so we can interact for login)
|
||||
browser, err := pw.Chromium.Launch(playwright.BrowserTypeLaunchOptions{
|
||||
Headless: playwright.Bool(false),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to launch browser: %v", err)
|
||||
}
|
||||
|
||||
// Create context
|
||||
context, err := browser.NewContext(playwright.BrowserNewContextOptions{
|
||||
UserAgent: playwright.String("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create context: %v", err)
|
||||
}
|
||||
|
||||
// Monitor all network requests
|
||||
context.On("request", func(request playwright.Request) {
|
||||
url := request.URL()
|
||||
method := request.Method()
|
||||
|
||||
// Log all Kosmi-related requests
|
||||
if containsKosmi(url) {
|
||||
logBoth("🌐 [HTTP REQUEST] %s %s", method, url)
|
||||
|
||||
// Log POST data (likely contains login credentials or GraphQL mutations)
|
||||
if method == "POST" {
|
||||
postData, err := request.PostData()
|
||||
if err == nil && postData != "" {
|
||||
logBoth(" 📤 POST Data: %s", postData)
|
||||
}
|
||||
}
|
||||
|
||||
// Log headers for auth-related requests
|
||||
headers := request.Headers()
|
||||
if authHeader, ok := headers["authorization"]; ok {
|
||||
logBoth(" 🔑 Authorization: %s", authHeader)
|
||||
}
|
||||
if cookie, ok := headers["cookie"]; ok {
|
||||
logBoth(" 🍪 Cookie: %s", truncate(cookie, 100))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Monitor all network responses
|
||||
context.On("response", func(response playwright.Response) {
|
||||
url := response.URL()
|
||||
status := response.Status()
|
||||
|
||||
if containsKosmi(url) {
|
||||
logBoth("📨 [HTTP RESPONSE] %d %s", status, url)
|
||||
|
||||
// Try to get response body for auth endpoints
|
||||
if status >= 200 && status < 300 {
|
||||
body, err := response.Body()
|
||||
if err == nil && len(body) > 0 && len(body) < 50000 {
|
||||
// Try to parse as JSON
|
||||
var jsonData interface{}
|
||||
if json.Unmarshal(body, &jsonData) == nil {
|
||||
prettyJSON, _ := json.MarshalIndent(jsonData, " ", " ")
|
||||
logBoth(" 📦 Response Body: %s", string(prettyJSON))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Log Set-Cookie headers
|
||||
headers := response.Headers()
|
||||
if setCookie, ok := headers["set-cookie"]; ok {
|
||||
logBoth(" 🍪 Set-Cookie: %s", setCookie)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Create page
|
||||
page, err := context.NewPage()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create page: %v", err)
|
||||
}
|
||||
|
||||
// Inject comprehensive monitoring script BEFORE navigation
|
||||
logBoth("📝 Injecting WebSocket, storage, and reconnection monitoring script...")
|
||||
monitorScript := getMonitoringScript()
|
||||
logBoth(" Script length: %d characters", len(monitorScript))
|
||||
if err := page.AddInitScript(playwright.Script{
|
||||
Content: playwright.String(monitorScript),
|
||||
}); err != nil {
|
||||
log.Fatalf("Failed to inject script: %v", err)
|
||||
}
|
||||
logBoth(" ✅ Script injected successfully")
|
||||
|
||||
// Listen to console messages
|
||||
page.On("console", func(msg playwright.ConsoleMessage) {
|
||||
text := msg.Text()
|
||||
msgType := msg.Type()
|
||||
|
||||
// Format the output nicely
|
||||
prefix := "💬"
|
||||
switch msgType {
|
||||
case "log":
|
||||
prefix = "📋"
|
||||
case "error":
|
||||
prefix = "❌"
|
||||
case "warning":
|
||||
prefix = "⚠️"
|
||||
case "info":
|
||||
prefix = "ℹ️"
|
||||
}
|
||||
|
||||
logBoth("%s [BROWSER %s] %s", prefix, msgType, text)
|
||||
})
|
||||
|
||||
// Navigate based on mode
|
||||
if *loginMode {
|
||||
logBoth("🔐 Login mode: Navigate to login page first")
|
||||
logBoth(" Please log in manually in the browser")
|
||||
logBoth(" We'll capture all auth traffic")
|
||||
|
||||
// Navigate to main Kosmi page (will redirect to login if not authenticated)
|
||||
logBoth("🌐 Navigating to https://app.kosmi.io...")
|
||||
timeout := 30000.0 // 30 seconds
|
||||
_, err := page.Goto("https://app.kosmi.io", playwright.PageGotoOptions{
|
||||
WaitUntil: playwright.WaitUntilStateDomcontentloaded,
|
||||
Timeout: &timeout,
|
||||
})
|
||||
if err != nil {
|
||||
logBoth("⚠️ Navigation error: %v", err)
|
||||
logBoth(" Continuing anyway - page might still be usable")
|
||||
} else {
|
||||
logBoth("✅ Page loaded successfully")
|
||||
}
|
||||
|
||||
logBoth("")
|
||||
logBoth("📋 Instructions:")
|
||||
logBoth(" 1. Log in with your credentials in the browser")
|
||||
logBoth(" 2. Navigate to the room: %s", *roomURL)
|
||||
logBoth(" 3. Browser console messages should appear here")
|
||||
logBoth(" 4. Press Ctrl+C when done")
|
||||
logBoth("")
|
||||
logBoth("💡 Expected console messages:")
|
||||
logBoth(" - '[Monitor] Installing...'")
|
||||
logBoth(" - '[WS MONITOR] WebSocket created...'")
|
||||
logBoth(" - '[FETCH] Request to...'")
|
||||
logBoth("")
|
||||
|
||||
} else {
|
||||
// Navigate directly to room (anonymous flow)
|
||||
logBoth("🌐 Navigating to %s...", *roomURL)
|
||||
timeout := 30000.0 // 30 seconds
|
||||
_, err := page.Goto(*roomURL, playwright.PageGotoOptions{
|
||||
WaitUntil: playwright.WaitUntilStateDomcontentloaded,
|
||||
Timeout: &timeout,
|
||||
})
|
||||
if err != nil {
|
||||
logBoth("⚠️ Navigation error: %v", err)
|
||||
logBoth(" Continuing anyway - page might still be usable")
|
||||
} else {
|
||||
logBoth("✅ Page loaded successfully")
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for WebSocket to connect
|
||||
time.Sleep(5 * time.Second)
|
||||
|
||||
// Capture storage data
|
||||
logBoth("\n📦 Capturing storage data...")
|
||||
captureStorage(page, logBoth)
|
||||
|
||||
if *testReconnect {
|
||||
logBoth("\n🔄 Testing reconnection behavior...")
|
||||
logBoth(" This will simulate network disconnection")
|
||||
logBoth(" Watch how the browser handles reconnection")
|
||||
|
||||
// Simulate network offline
|
||||
logBoth(" 📡 Setting network to OFFLINE...")
|
||||
if err := context.SetOffline(true); err != nil {
|
||||
logBoth(" ❌ Failed to set offline: %v", err)
|
||||
} else {
|
||||
logBoth(" ✅ Network is now OFFLINE")
|
||||
logBoth(" ⏳ Waiting 10 seconds...")
|
||||
time.Sleep(10 * time.Second)
|
||||
|
||||
// Restore network
|
||||
logBoth(" 📡 Setting network to ONLINE...")
|
||||
if err := context.SetOffline(false); err != nil {
|
||||
logBoth(" ❌ Failed to set online: %v", err)
|
||||
} else {
|
||||
logBoth(" ✅ Network is now ONLINE")
|
||||
logBoth(" ⏳ Observing reconnection behavior...")
|
||||
time.Sleep(10 * time.Second)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Capture final storage state
|
||||
logBoth("\n📦 Capturing final storage state...")
|
||||
captureStorage(page, logBoth)
|
||||
|
||||
// Keep monitoring
|
||||
logBoth("\n⏳ Monitoring all traffic... Press Ctrl+C to stop")
|
||||
if *loginMode {
|
||||
logBoth("💡 TIP: Try logging in, navigating to rooms, and logging out")
|
||||
logBoth(" We'll capture all authentication flows")
|
||||
}
|
||||
|
||||
// Wait forever (interrupt handler will exit)
|
||||
select {}
|
||||
}
|
||||
|
||||
// captureStorage captures localStorage, sessionStorage, and cookies
|
||||
func captureStorage(page playwright.Page, logBoth func(string, ...interface{})) {
|
||||
// Capture localStorage
|
||||
localStorageResult, err := page.Evaluate(`
|
||||
(function() {
|
||||
const data = {};
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
data[key] = localStorage.getItem(key);
|
||||
}
|
||||
return data;
|
||||
})();
|
||||
`)
|
||||
if err == nil {
|
||||
if localStorage, ok := localStorageResult.(map[string]interface{}); ok {
|
||||
logBoth(" 📦 localStorage:")
|
||||
for key, value := range localStorage {
|
||||
logBoth(" %s: %s", key, truncate(fmt.Sprintf("%v", value), 100))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Capture sessionStorage
|
||||
sessionStorageResult, err := page.Evaluate(`
|
||||
(function() {
|
||||
const data = {};
|
||||
for (let i = 0; i < sessionStorage.length; i++) {
|
||||
const key = sessionStorage.key(i);
|
||||
data[key] = sessionStorage.getItem(key);
|
||||
}
|
||||
return data;
|
||||
})();
|
||||
`)
|
||||
if err == nil {
|
||||
if sessionStorage, ok := sessionStorageResult.(map[string]interface{}); ok {
|
||||
logBoth(" 📦 sessionStorage:")
|
||||
for key, value := range sessionStorage {
|
||||
logBoth(" %s: %s", key, truncate(fmt.Sprintf("%v", value), 100))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Capture cookies
|
||||
cookiesResult, err := page.Evaluate(`
|
||||
(function() {
|
||||
return document.cookie.split(';').map(c => {
|
||||
const parts = c.trim().split('=');
|
||||
return {
|
||||
name: parts[0],
|
||||
value: parts.slice(1).join('=')
|
||||
};
|
||||
}).filter(c => c.name && c.value);
|
||||
})();
|
||||
`)
|
||||
if err == nil {
|
||||
if cookiesArray, ok := cookiesResult.([]interface{}); ok {
|
||||
logBoth(" 🍪 Cookies:")
|
||||
for _, cookieItem := range cookiesArray {
|
||||
if cookie, ok := cookieItem.(map[string]interface{}); ok {
|
||||
name := cookie["name"]
|
||||
value := cookie["value"]
|
||||
logBoth(" %s: %s", name, truncate(fmt.Sprintf("%v", value), 100))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Capture WebSocket status
|
||||
wsStatusResult, err := page.Evaluate(`
|
||||
(function() {
|
||||
return window.__KOSMI_WS_STATUS__ || { error: "No WebSocket found" };
|
||||
})();
|
||||
`)
|
||||
if err == nil {
|
||||
if wsStatus, ok := wsStatusResult.(map[string]interface{}); ok {
|
||||
logBoth(" 🔌 WebSocket Status:")
|
||||
for key, value := range wsStatus {
|
||||
logBoth(" %s: %v", key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// getMonitoringScript returns the comprehensive monitoring script
|
||||
func getMonitoringScript() string {
|
||||
return `
|
||||
(function() {
|
||||
if (window.__KOSMI_MONITOR_INSTALLED__) return;
|
||||
|
||||
console.log('[Monitor] Installing comprehensive monitoring hooks...');
|
||||
|
||||
// Store original WebSocket constructor
|
||||
const OriginalWebSocket = window.WebSocket;
|
||||
let wsInstance = null;
|
||||
let messageCount = 0;
|
||||
let reconnectAttempts = 0;
|
||||
|
||||
// Hook WebSocket constructor
|
||||
window.WebSocket = function(url, protocols) {
|
||||
console.log('🔌 [WS MONITOR] WebSocket created:', url, 'protocols:', protocols);
|
||||
const socket = new OriginalWebSocket(url, protocols);
|
||||
|
||||
if (url.includes('engine.kosmi.io') || url.includes('gql-ws')) {
|
||||
wsInstance = socket;
|
||||
|
||||
// Track connection lifecycle
|
||||
socket.addEventListener('open', (event) => {
|
||||
console.log('✅ [WS MONITOR] WebSocket OPENED');
|
||||
reconnectAttempts = 0;
|
||||
updateWSStatus('OPEN', url);
|
||||
});
|
||||
|
||||
socket.addEventListener('close', (event) => {
|
||||
console.log('🔴 [WS MONITOR] WebSocket CLOSED:', event.code, event.reason, 'wasClean:', event.wasClean);
|
||||
reconnectAttempts++;
|
||||
updateWSStatus('CLOSED', url, { code: event.code, reason: event.reason, reconnectAttempts });
|
||||
});
|
||||
|
||||
socket.addEventListener('error', (event) => {
|
||||
console.error('❌ [WS MONITOR] WebSocket ERROR:', event);
|
||||
updateWSStatus('ERROR', url);
|
||||
});
|
||||
|
||||
// Intercept outgoing messages
|
||||
const originalSend = socket.send;
|
||||
socket.send = function(data) {
|
||||
messageCount++;
|
||||
console.log('📤 [WS MONITOR] SEND #' + messageCount + ':', data);
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
console.log(' Type:', parsed.type, 'ID:', parsed.id);
|
||||
if (parsed.payload) {
|
||||
console.log(' Payload:', JSON.stringify(parsed.payload, null, 2));
|
||||
|
||||
// Check for auth-related messages
|
||||
if (parsed.type === 'connection_init' && parsed.payload.token) {
|
||||
console.log(' 🔑 [AUTH] Connection init with token:', parsed.payload.token.substring(0, 50) + '...');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Not JSON
|
||||
}
|
||||
return originalSend.call(this, data);
|
||||
};
|
||||
|
||||
// Intercept incoming messages
|
||||
socket.addEventListener('message', (event) => {
|
||||
messageCount++;
|
||||
console.log('📥 [WS MONITOR] RECEIVE #' + messageCount + ':', event.data);
|
||||
try {
|
||||
const parsed = JSON.parse(event.data);
|
||||
console.log(' Type:', parsed.type, 'ID:', parsed.id);
|
||||
if (parsed.payload) {
|
||||
console.log(' Payload:', JSON.stringify(parsed.payload, null, 2));
|
||||
}
|
||||
} catch (e) {
|
||||
// Not JSON
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return socket;
|
||||
};
|
||||
|
||||
// Preserve WebSocket properties
|
||||
window.WebSocket.prototype = OriginalWebSocket.prototype;
|
||||
window.WebSocket.CONNECTING = OriginalWebSocket.CONNECTING;
|
||||
window.WebSocket.OPEN = OriginalWebSocket.OPEN;
|
||||
window.WebSocket.CLOSING = OriginalWebSocket.CLOSING;
|
||||
window.WebSocket.CLOSED = OriginalWebSocket.CLOSED;
|
||||
|
||||
// Monitor localStorage changes
|
||||
const originalSetItem = Storage.prototype.setItem;
|
||||
Storage.prototype.setItem = function(key, value) {
|
||||
console.log('💾 [STORAGE] localStorage.setItem:', key, '=', value.substring(0, 100));
|
||||
if (key.toLowerCase().includes('token') || key.toLowerCase().includes('auth')) {
|
||||
console.log(' 🔑 [AUTH] Auth-related storage detected!');
|
||||
}
|
||||
return originalSetItem.call(this, key, value);
|
||||
};
|
||||
|
||||
// Monitor sessionStorage changes
|
||||
const originalSessionSetItem = sessionStorage.setItem;
|
||||
sessionStorage.setItem = function(key, value) {
|
||||
console.log('💾 [STORAGE] sessionStorage.setItem:', key, '=', value.substring(0, 100));
|
||||
if (key.toLowerCase().includes('token') || key.toLowerCase().includes('auth')) {
|
||||
console.log(' 🔑 [AUTH] Auth-related storage detected!');
|
||||
}
|
||||
return originalSessionSetItem.call(this, key, value);
|
||||
};
|
||||
|
||||
// Monitor fetch requests (for GraphQL mutations)
|
||||
const originalFetch = window.fetch;
|
||||
window.fetch = function(url, options) {
|
||||
if (typeof url === 'string' && url.includes('kosmi.io')) {
|
||||
console.log('🌐 [FETCH] Request to:', url);
|
||||
if (options && options.body) {
|
||||
console.log(' 📤 Body:', options.body);
|
||||
try {
|
||||
const parsed = JSON.parse(options.body);
|
||||
if (parsed.query) {
|
||||
console.log(' 📝 GraphQL Query:', parsed.query.substring(0, 200));
|
||||
if (parsed.query.includes('login') || parsed.query.includes('auth')) {
|
||||
console.log(' 🔑 [AUTH] Auth-related GraphQL detected!');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Not JSON
|
||||
}
|
||||
}
|
||||
}
|
||||
return originalFetch.apply(this, arguments).then(response => {
|
||||
if (typeof url === 'string' && url.includes('kosmi.io')) {
|
||||
console.log('📨 [FETCH] Response from:', url, 'status:', response.status);
|
||||
}
|
||||
return response;
|
||||
});
|
||||
};
|
||||
|
||||
// Helper to update WebSocket status
|
||||
function updateWSStatus(state, url, extra = {}) {
|
||||
window.__KOSMI_WS_STATUS__ = {
|
||||
state,
|
||||
url,
|
||||
timestamp: new Date().toISOString(),
|
||||
messageCount,
|
||||
...extra
|
||||
};
|
||||
}
|
||||
|
||||
// Monitor network connectivity
|
||||
window.addEventListener('online', () => {
|
||||
console.log('🌐 [NETWORK] Browser is ONLINE');
|
||||
});
|
||||
|
||||
window.addEventListener('offline', () => {
|
||||
console.log('🌐 [NETWORK] Browser is OFFLINE');
|
||||
});
|
||||
|
||||
window.__KOSMI_MONITOR_INSTALLED__ = true;
|
||||
console.log('[Monitor] ✅ All monitoring hooks installed');
|
||||
})();
|
||||
`
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
func containsKosmi(url string) bool {
|
||||
return contains(url, "kosmi.io") || contains(url, "engine.kosmi.io") || contains(url, "app.kosmi.io")
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func truncate(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen] + "..."
|
||||
}
|
||||
|
||||
145
cmd/parse-har/main.go
Normal file
145
cmd/parse-har/main.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type HAR struct {
|
||||
Log struct {
|
||||
Entries []struct {
|
||||
Request struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
} `json:"request"`
|
||||
WebSocketMessages []struct {
|
||||
Type string `json:"type"`
|
||||
Data string `json:"data"`
|
||||
Time float64 `json:"time"`
|
||||
} `json:"_webSocketMessages"`
|
||||
} `json:"entries"`
|
||||
} `json:"log"`
|
||||
}
|
||||
|
||||
type WSMessage struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Payload map[string]interface{} `json:"payload"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
log.Fatal("Usage: parse-har <har-file>")
|
||||
}
|
||||
|
||||
data, err := ioutil.ReadFile(os.Args[1])
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
var har HAR
|
||||
if err := json.Unmarshal(data, &har); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// First pass: collect all responses
|
||||
responses := make(map[string]bool) // ID -> has data
|
||||
for _, entry := range har.Log.Entries {
|
||||
for _, wsMsg := range entry.WebSocketMessages {
|
||||
if wsMsg.Type != "receive" {
|
||||
continue
|
||||
}
|
||||
|
||||
var msg WSMessage
|
||||
if err := json.Unmarshal([]byte(wsMsg.Data), &msg); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if msg.Type == "next" {
|
||||
// Check if payload has data
|
||||
if data, ok := msg.Payload["data"].(map[string]interface{}); ok && len(data) > 0 {
|
||||
responses[msg.ID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("=== WebSocket Operations (in order) ===\n")
|
||||
|
||||
msgCount := 0
|
||||
for _, entry := range har.Log.Entries {
|
||||
for _, wsMsg := range entry.WebSocketMessages {
|
||||
if wsMsg.Type != "send" {
|
||||
continue
|
||||
}
|
||||
|
||||
var msg WSMessage
|
||||
if err := json.Unmarshal([]byte(wsMsg.Data), &msg); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if msg.Type == "connection_init" {
|
||||
fmt.Printf("[%d] connection_init\n", msgCount)
|
||||
msgCount++
|
||||
continue
|
||||
}
|
||||
|
||||
if msg.Type == "subscribe" {
|
||||
opName := ""
|
||||
query := ""
|
||||
variables := map[string]interface{}{}
|
||||
extensions := map[string]interface{}{}
|
||||
|
||||
if payload, ok := msg.Payload["operationName"].(string); ok {
|
||||
opName = payload
|
||||
}
|
||||
if q, ok := msg.Payload["query"].(string); ok {
|
||||
query = q
|
||||
}
|
||||
if v, ok := msg.Payload["variables"].(map[string]interface{}); ok {
|
||||
variables = v
|
||||
}
|
||||
if e, ok := msg.Payload["extensions"].(map[string]interface{}); ok {
|
||||
extensions = e
|
||||
}
|
||||
|
||||
hasResponse := ""
|
||||
if responses[msg.ID] {
|
||||
hasResponse = " ✅ GOT DATA"
|
||||
} else {
|
||||
hasResponse = " ❌ NO DATA"
|
||||
}
|
||||
fmt.Printf("[%d] %s (ID: %s)%s\n", msgCount, opName, msg.ID, hasResponse)
|
||||
|
||||
// Show query type
|
||||
if strings.Contains(query, "mutation") {
|
||||
fmt.Printf(" Type: MUTATION\n")
|
||||
} else if strings.Contains(query, "subscription") {
|
||||
fmt.Printf(" Type: SUBSCRIPTION\n")
|
||||
} else if strings.Contains(query, "query") {
|
||||
fmt.Printf(" Type: QUERY\n")
|
||||
}
|
||||
|
||||
// Show variables
|
||||
if len(variables) > 0 {
|
||||
fmt.Printf(" Variables: %v\n", variables)
|
||||
}
|
||||
|
||||
// Show extensions
|
||||
if len(extensions) > 0 {
|
||||
fmt.Printf(" Extensions: %v\n", extensions)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
msgCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\nTotal operations: %d\n", msgCount)
|
||||
}
|
||||
|
||||
74
cmd/test-browser-auth/main.go
Normal file
74
cmd/test-browser-auth/main.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
bkosmi "github.com/42wim/matterbridge/bridge/kosmi"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 3 {
|
||||
fmt.Println("Usage: test-browser-auth <email> <password>")
|
||||
fmt.Println("")
|
||||
fmt.Println("This tests the browser-based authentication by:")
|
||||
fmt.Println("1. Launching headless Chrome")
|
||||
fmt.Println("2. Logging in to Kosmi")
|
||||
fmt.Println("3. Extracting the JWT token")
|
||||
fmt.Println("4. Parsing token expiry")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
email := os.Args[1]
|
||||
password := os.Args[2]
|
||||
|
||||
// Set up logging
|
||||
log := logrus.New()
|
||||
log.SetLevel(logrus.DebugLevel)
|
||||
entry := logrus.NewEntry(log)
|
||||
|
||||
fmt.Println("🚀 Testing browser-based authentication...")
|
||||
fmt.Println()
|
||||
|
||||
// Create browser auth manager
|
||||
browserAuth := bkosmi.NewBrowserAuthManager(email, password, entry)
|
||||
|
||||
// Get token
|
||||
token, err := browserAuth.GetToken()
|
||||
if err != nil {
|
||||
fmt.Printf("❌ Authentication failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("✅ Authentication successful!")
|
||||
fmt.Println()
|
||||
fmt.Printf("Token (first 50 chars): %s...\n", token[:min(50, len(token))])
|
||||
fmt.Printf("Token length: %d characters\n", len(token))
|
||||
fmt.Println()
|
||||
|
||||
// Check if authenticated
|
||||
if browserAuth.IsAuthenticated() {
|
||||
fmt.Println("✅ Token is valid")
|
||||
} else {
|
||||
fmt.Println("❌ Token is invalid or expired")
|
||||
}
|
||||
|
||||
// Get user ID
|
||||
userID := browserAuth.GetUserID()
|
||||
if userID != "" {
|
||||
fmt.Printf("User ID: %s\n", userID)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("🎉 Test completed successfully!")
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
309
cmd/test-browser-login/main.go
Normal file
309
cmd/test-browser-login/main.go
Normal file
@@ -0,0 +1,309 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/chromedp/chromedp"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 3 {
|
||||
fmt.Println("Usage: test-browser-login <email> <password>")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
email := os.Args[1]
|
||||
password := os.Args[2]
|
||||
|
||||
// Set up Chrome options - VISIBLE browser for debugging
|
||||
opts := append(chromedp.DefaultExecAllocatorOptions[:],
|
||||
chromedp.Flag("headless", false), // NOT headless - we want to see it
|
||||
chromedp.Flag("disable-gpu", false),
|
||||
)
|
||||
|
||||
// Create allocator context
|
||||
allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...)
|
||||
defer cancel()
|
||||
|
||||
// Create context with timeout
|
||||
ctx, cancel := chromedp.NewContext(allocCtx)
|
||||
defer cancel()
|
||||
|
||||
// Set a reasonable timeout for the entire login process
|
||||
ctx, cancel = context.WithTimeout(ctx, 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var token string
|
||||
|
||||
// Run the browser automation tasks
|
||||
err := chromedp.Run(ctx,
|
||||
// Navigate to Kosmi
|
||||
chromedp.Navigate("https://app.kosmi.io"),
|
||||
|
||||
// Wait for page to load completely
|
||||
chromedp.WaitReady("body"),
|
||||
chromedp.Sleep(2*time.Second),
|
||||
|
||||
// Click Login button
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
fmt.Println("Looking for Log in button...")
|
||||
var buttonTexts []string
|
||||
chromedp.Evaluate(`
|
||||
Array.from(document.querySelectorAll('button')).map(el => el.textContent.trim())
|
||||
`, &buttonTexts).Do(ctx)
|
||||
fmt.Printf("Found buttons: %v\n", buttonTexts)
|
||||
|
||||
var found bool
|
||||
if err := chromedp.Evaluate(`
|
||||
(() => {
|
||||
const buttons = Array.from(document.querySelectorAll('button'));
|
||||
// Try both "Login" and "Log in"
|
||||
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")
|
||||
}
|
||||
fmt.Println("✓ Clicked Login button")
|
||||
return nil
|
||||
}),
|
||||
|
||||
// Wait for login modal
|
||||
chromedp.Sleep(3*time.Second),
|
||||
|
||||
// Click "Login with Email" button
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
fmt.Println("Looking for Login with Email button...")
|
||||
var buttonTexts []string
|
||||
chromedp.Evaluate(`
|
||||
Array.from(document.querySelectorAll('button')).map(el => el.textContent.trim())
|
||||
`, &buttonTexts).Do(ctx)
|
||||
fmt.Printf("Found buttons: %v\n", buttonTexts)
|
||||
|
||||
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")
|
||||
}
|
||||
fmt.Println("✓ Clicked Login with Email button")
|
||||
return nil
|
||||
}),
|
||||
|
||||
// Wait for email form
|
||||
chromedp.Sleep(3*time.Second),
|
||||
chromedp.WaitVisible(`input[type="password"]`, chromedp.ByQuery),
|
||||
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
fmt.Println("Password input found, filling in email...")
|
||||
var inputTypes []string
|
||||
chromedp.Evaluate(`
|
||||
Array.from(document.querySelectorAll('input')).map(el => el.type + (el.placeholder ? ' ('+el.placeholder+')' : ''))
|
||||
`, &inputTypes).Do(ctx)
|
||||
fmt.Printf("Available inputs: %v\n", inputTypes)
|
||||
return nil
|
||||
}),
|
||||
|
||||
// Click on the email input to focus it
|
||||
chromedp.Click(`input[placeholder*="Email"], input[placeholder*="Username"]`, chromedp.ByQuery),
|
||||
chromedp.Sleep(200*time.Millisecond),
|
||||
|
||||
// Type email character by character
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
fmt.Printf("Typing email: %s\n", email)
|
||||
return chromedp.SendKeys(`input[placeholder*="Email"], input[placeholder*="Username"]`, email, chromedp.ByQuery).Do(ctx)
|
||||
}),
|
||||
|
||||
chromedp.Sleep(500*time.Millisecond),
|
||||
|
||||
// Click on the password input to focus it
|
||||
chromedp.Click(`input[type="password"]`, chromedp.ByQuery),
|
||||
chromedp.Sleep(200*time.Millisecond),
|
||||
|
||||
// Type password character by character
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
fmt.Printf("Typing password (length: %d)...\n", len(password))
|
||||
return chromedp.SendKeys(`input[type="password"]`, password, chromedp.ByQuery).Do(ctx)
|
||||
}),
|
||||
|
||||
// Verify password was filled correctly
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
var actualLength int
|
||||
chromedp.Evaluate(`
|
||||
(() => {
|
||||
const passwordInput = document.querySelector('input[type="password"]');
|
||||
return passwordInput ? passwordInput.value.length : 0;
|
||||
})()
|
||||
`, &actualLength).Do(ctx)
|
||||
|
||||
fmt.Printf("✓ Password filled (actual length in browser: %d, expected: %d)\n", actualLength, len(password))
|
||||
|
||||
if actualLength != len(password) {
|
||||
return fmt.Errorf("password length mismatch: got %d, expected %d", actualLength, len(password))
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
|
||||
chromedp.Sleep(500*time.Millisecond),
|
||||
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
fmt.Println("Looking for submit button...")
|
||||
var buttonTexts []string
|
||||
chromedp.Evaluate(`
|
||||
Array.from(document.querySelectorAll('button')).map(el => el.textContent.trim() + (el.disabled ? ' (disabled)' : ''))
|
||||
`, &buttonTexts).Do(ctx)
|
||||
fmt.Printf("Submit buttons available: %v\n", buttonTexts)
|
||||
return nil
|
||||
}),
|
||||
|
||||
// Wait a moment for form validation
|
||||
chromedp.Sleep(1*time.Second),
|
||||
|
||||
// Click the login submit button (be very specific)
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
fmt.Println("Attempting to click submit button...")
|
||||
var result string
|
||||
if err := chromedp.Evaluate(`
|
||||
(() => {
|
||||
const buttons = Array.from(document.querySelectorAll('button'));
|
||||
console.log('All buttons:', buttons.map(b => ({
|
||||
text: b.textContent.trim(),
|
||||
disabled: b.disabled,
|
||||
visible: b.offsetParent !== null
|
||||
})));
|
||||
|
||||
// Find the submit button in the login form
|
||||
// It should be visible, enabled, and contain "Login" but not be the main nav button
|
||||
const submitBtn = buttons.find(el => {
|
||||
const text = el.textContent.trim();
|
||||
const isLoginBtn = text === 'Login' || text.startsWith('Login');
|
||||
const isEnabled = !el.disabled;
|
||||
const isVisible = el.offsetParent !== null;
|
||||
const isInForm = el.closest('form') !== null || el.closest('[role="dialog"]') !== null;
|
||||
|
||||
return isLoginBtn && isEnabled && isVisible && isInForm;
|
||||
});
|
||||
|
||||
if (submitBtn) {
|
||||
console.log('Found submit button:', submitBtn.textContent.trim());
|
||||
submitBtn.click();
|
||||
return 'CLICKED: ' + submitBtn.textContent.trim();
|
||||
}
|
||||
|
||||
return 'NOT_FOUND';
|
||||
})()
|
||||
`, &result).Do(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Submit button result: %s\n", result)
|
||||
|
||||
if result == "NOT_FOUND" {
|
||||
return fmt.Errorf("Login submit button not found or not clickable")
|
||||
}
|
||||
|
||||
fmt.Println("✓ Clicked submit button")
|
||||
return nil
|
||||
}),
|
||||
|
||||
// Wait for login to complete
|
||||
chromedp.Sleep(5*time.Second),
|
||||
|
||||
// Check for errors
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
var errorText string
|
||||
chromedp.Evaluate(`
|
||||
(() => {
|
||||
const errorEl = document.querySelector('[role="alert"], .error, .alert-error');
|
||||
return errorEl ? errorEl.textContent : '';
|
||||
})()
|
||||
`, &errorText).Do(ctx)
|
||||
|
||||
if errorText != "" {
|
||||
fmt.Printf("❌ Login error: %s\n", errorText)
|
||||
return fmt.Errorf("login failed: %s", errorText)
|
||||
}
|
||||
|
||||
fmt.Println("✓ No error messages")
|
||||
return nil
|
||||
}),
|
||||
|
||||
// Extract token from localStorage
|
||||
chromedp.Evaluate(`localStorage.getItem('token')`, &token),
|
||||
|
||||
// Check token details
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
var userInfo string
|
||||
chromedp.Evaluate(`
|
||||
(() => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) return 'NO_TOKEN';
|
||||
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return 'INVALID_TOKEN';
|
||||
|
||||
const payload = JSON.parse(atob(parts[1]));
|
||||
return JSON.stringify(payload, null, 2);
|
||||
} catch (e) {
|
||||
return 'ERROR: ' + e.message;
|
||||
}
|
||||
})()
|
||||
`, &userInfo).Do(ctx)
|
||||
|
||||
fmt.Printf("\n=== Token Payload ===\n%s\n", userInfo)
|
||||
return nil
|
||||
}),
|
||||
|
||||
// Keep browser open for inspection
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
fmt.Println("\n✓ Login complete! Browser will stay open for 30 seconds for inspection...")
|
||||
return nil
|
||||
}),
|
||||
chromedp.Sleep(30*time.Second),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("\n❌ Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
fmt.Println("\n❌ No token found in localStorage")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("\n✅ Token obtained (length: %d)\n", len(token))
|
||||
fmt.Printf("First 50 chars: %s...\n", token[:min(50, len(token))])
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
73
cmd/test-introspection/main.go
Normal file
73
cmd/test-introspection/main.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// GraphQL introspection query to find all mutations
|
||||
query := map[string]interface{}{
|
||||
"query": `{
|
||||
__schema {
|
||||
mutationType {
|
||||
fields {
|
||||
name
|
||||
description
|
||||
args {
|
||||
name
|
||||
type {
|
||||
name
|
||||
kind
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
}
|
||||
|
||||
jsonBody, err := json.Marshal(query)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to marshal: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", "https://engine.kosmi.io/", bytes.NewReader(jsonBody))
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to create request: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Referer", "https://app.kosmi.io/")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0")
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
fmt.Printf("Request failed: %v\n", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to read response: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Pretty print JSON response
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
fmt.Println("Response (raw):")
|
||||
fmt.Println(string(body))
|
||||
} else {
|
||||
prettyJSON, _ := json.MarshalIndent(result, "", " ")
|
||||
fmt.Println(string(prettyJSON))
|
||||
}
|
||||
}
|
||||
|
||||
89
cmd/test-login/main.go
Normal file
89
cmd/test-login/main.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 3 {
|
||||
fmt.Println("Usage: test-login <email> <password>")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
email := os.Args[1]
|
||||
password := os.Args[2]
|
||||
|
||||
// Try the guessed mutation format
|
||||
mutation := map[string]interface{}{
|
||||
"query": `mutation Login($email: String!, $password: String!) {
|
||||
login(email: $email, password: $password) {
|
||||
token
|
||||
refreshToken
|
||||
expiresIn
|
||||
user {
|
||||
id
|
||||
displayName
|
||||
username
|
||||
}
|
||||
}
|
||||
}`,
|
||||
"variables": map[string]interface{}{
|
||||
"email": email,
|
||||
"password": password,
|
||||
},
|
||||
}
|
||||
|
||||
jsonBody, err := json.MarshalIndent(mutation, "", " ")
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to marshal: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("📤 Sending request:")
|
||||
fmt.Println(string(jsonBody))
|
||||
fmt.Println()
|
||||
|
||||
req, err := http.NewRequest("POST", "https://engine.kosmi.io/", bytes.NewReader(jsonBody))
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to create request: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Referer", "https://app.kosmi.io/")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36")
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
fmt.Printf("Request failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
fmt.Printf("📥 Response Status: %d\n", resp.StatusCode)
|
||||
fmt.Println()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to read response: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Pretty print JSON response
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
fmt.Println("Response (raw):")
|
||||
fmt.Println(string(body))
|
||||
} else {
|
||||
prettyJSON, _ := json.MarshalIndent(result, "", " ")
|
||||
fmt.Println("Response (JSON):")
|
||||
fmt.Println(string(prettyJSON))
|
||||
}
|
||||
}
|
||||
|
||||
94
cmd/test-profile-query/main.go
Normal file
94
cmd/test-profile-query/main.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Println("Usage: test-profile-query <jwt-token>")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
token := os.Args[1]
|
||||
|
||||
// Try different queries to get user profile
|
||||
queries := []struct{
|
||||
name string
|
||||
query string
|
||||
}{
|
||||
{
|
||||
name: "me query",
|
||||
query: `query { me { id displayName username email avatarUrl } }`,
|
||||
},
|
||||
{
|
||||
name: "currentUser query",
|
||||
query: `query { currentUser { id displayName username email avatarUrl } }`,
|
||||
},
|
||||
{
|
||||
name: "user query",
|
||||
query: `query { user { id displayName username email avatarUrl } }`,
|
||||
},
|
||||
{
|
||||
name: "viewer query",
|
||||
query: `query { viewer { id displayName username email avatarUrl } }`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, q := range queries {
|
||||
fmt.Printf("\n=== Testing: %s ===\n", q.name)
|
||||
testQuery(token, q.query)
|
||||
}
|
||||
}
|
||||
|
||||
func testQuery(token, query string) {
|
||||
payload := map[string]interface{}{
|
||||
"query": query,
|
||||
}
|
||||
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to marshal query: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", "https://engine.kosmi.io/", bytes.NewBuffer(payloadBytes))
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to create request: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
fmt.Printf("Request failed: %v\n", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to read response: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("Status: %d\n", resp.StatusCode)
|
||||
|
||||
// Pretty print JSON
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
fmt.Printf("Response: %s\n", string(body))
|
||||
} else {
|
||||
prettyJSON, _ := json.MarshalIndent(result, "", " ")
|
||||
fmt.Printf("Response:\n%s\n", string(prettyJSON))
|
||||
}
|
||||
}
|
||||
|
||||
200
har_operations_full.txt
Normal file
200
har_operations_full.txt
Normal file
@@ -0,0 +1,200 @@
|
||||
[0] Type: connection_init
|
||||
[2] Type: subscribe
|
||||
Operation: SettingsQuery
|
||||
Query:
|
||||
[3] Type: subscribe
|
||||
Operation: ExtendedCurrentUserQuery
|
||||
Query:
|
||||
[4] Type: subscribe
|
||||
Operation: UserRoomQuery
|
||||
Query:
|
||||
[5] Type: subscribe
|
||||
Operation: WebRTCIceServerQuery
|
||||
Query:
|
||||
[6] Type: subscribe
|
||||
Query:
|
||||
[16] Type: subscribe
|
||||
Operation: CurrentUserQuery4
|
||||
Query:
|
||||
[20] Type: subscribe
|
||||
Operation: NotificationQuery1
|
||||
Query:
|
||||
[21] Type: subscribe
|
||||
Operation: MessageList
|
||||
Query:
|
||||
[22] Type: subscribe
|
||||
Operation: EventMutation
|
||||
Query:
|
||||
[23] Type: subscribe
|
||||
Operation: ServerTimeQuery
|
||||
Query:
|
||||
[24] Type: subscribe
|
||||
Operation: JoinRoom
|
||||
Query:
|
||||
[25] Type: subscribe
|
||||
Operation: NewNotificationSubscription
|
||||
Query:
|
||||
[26] Type: subscribe
|
||||
Operation: NewPublicNotificationSubscription
|
||||
Query:
|
||||
[27] Type: subscribe
|
||||
Operation: MessageListUpdate
|
||||
Query:
|
||||
[28] Type: subscribe
|
||||
Operation: OnNewPrivateMessage
|
||||
Query:
|
||||
[29] Type: subscribe
|
||||
Operation: PrivateMessageDeletedSubsciption
|
||||
Query:
|
||||
[30] Type: subscribe
|
||||
Operation: RemoveRoomSubscription
|
||||
Query:
|
||||
[31] Type: subscribe
|
||||
Operation: RoomlistRoomUpdateSubscription
|
||||
Query:
|
||||
[32] Type: subscribe
|
||||
Operation: OnFriendListUpdate
|
||||
Query:
|
||||
[33] Type: subscribe
|
||||
Operation: SubscriptionUpdateSubscription
|
||||
Query:
|
||||
[34] Type: subscribe
|
||||
Operation: SettingsSubscription
|
||||
Query:
|
||||
[41] Type: subscribe
|
||||
Operation: GetRunningApp
|
||||
Query:
|
||||
[42] Type: subscribe
|
||||
Operation: RoomRootQuery
|
||||
Query:
|
||||
[43] Type: subscribe
|
||||
Operation: WithGetMembers
|
||||
Query:
|
||||
[44] Type: subscribe
|
||||
Operation: GetSpacesState
|
||||
Query:
|
||||
[45] Type: subscribe
|
||||
Operation: RoomChatQuery
|
||||
Query:
|
||||
[46] Type: subscribe
|
||||
Operation: LinkedMembers
|
||||
Query:
|
||||
[47] Type: subscribe
|
||||
Operation: mediaPlayerStateQuery1
|
||||
Query:
|
||||
[48] Type: subscribe
|
||||
Operation: MediaPlayerSubtitlesQuery
|
||||
Query:
|
||||
[49] Type: subscribe
|
||||
Operation: MediaSoupStateQuery
|
||||
Query:
|
||||
[50] Type: subscribe
|
||||
Operation: ToolbarMetadataQuery
|
||||
Query:
|
||||
[51] Type: subscribe
|
||||
Operation: MessageReaders
|
||||
Query:
|
||||
[56] Type: subscribe
|
||||
Operation: UserTyping
|
||||
Query:
|
||||
[57] Type: subscribe
|
||||
Operation: RoomDisconnect
|
||||
Query:
|
||||
[58] Type: subscribe
|
||||
Operation: MemberJoins
|
||||
Query:
|
||||
[59] Type: subscribe
|
||||
Operation: MemberLeaves
|
||||
Query:
|
||||
[60] Type: subscribe
|
||||
Operation: SetRole2
|
||||
Query:
|
||||
[61] Type: subscribe
|
||||
Operation: NewMessageSubscription
|
||||
Query:
|
||||
[62] Type: subscribe
|
||||
Operation: MessageDeletedSubscription
|
||||
Query:
|
||||
[63] Type: subscribe
|
||||
Operation: ClearMessagesSubscription
|
||||
Query:
|
||||
[64] Type: subscribe
|
||||
Operation: ReactionAdded
|
||||
Query:
|
||||
[65] Type: subscribe
|
||||
Operation: ReactionRemoved
|
||||
Query:
|
||||
[66] Type: subscribe
|
||||
Operation: MessageEditedSubscription
|
||||
Query:
|
||||
[67] Type: subscribe
|
||||
Operation: OnSpacesStateUpdate
|
||||
Query:
|
||||
[68] Type: subscribe
|
||||
Operation: NewMemberSubscription
|
||||
Query:
|
||||
[69] Type: subscribe
|
||||
Operation: MemberUnlinksSubscription
|
||||
Query:
|
||||
[70] Type: subscribe
|
||||
Operation: SetRole
|
||||
Query:
|
||||
[71] Type: subscribe
|
||||
Operation: OnMediaSoupUpdateState
|
||||
Query:
|
||||
[75] Type: subscribe
|
||||
Operation: StartAppSubscription
|
||||
Query:
|
||||
[76] Type: subscribe
|
||||
Operation: MetadataUpdates
|
||||
Query:
|
||||
[83] Type: subscribe
|
||||
Operation: OnMediaPlayerUpdateState
|
||||
Query:
|
||||
[84] Type: subscribe
|
||||
Operation: OnMediaPlayerUpdateSubtitles
|
||||
Query:
|
||||
[98] Type: subscribe
|
||||
Operation: ServerTimeQuery
|
||||
Query:
|
||||
[102] Type: subscribe
|
||||
Operation: Login
|
||||
Query:
|
||||
[0] Type: connection_init
|
||||
[2] Type: subscribe
|
||||
Operation: LeaveRoom
|
||||
Query:
|
||||
[3] Type: subscribe
|
||||
Operation: SettingsQuery
|
||||
Query:
|
||||
[4] Type: subscribe
|
||||
Operation: ExtendedCurrentUserQuery
|
||||
Query:
|
||||
[5] Type: subscribe
|
||||
Operation: UserRoomQuery
|
||||
Query:
|
||||
[6] Type: subscribe
|
||||
Operation: WebRTCIceServerQuery
|
||||
Query:
|
||||
[7] Type: subscribe
|
||||
Query:
|
||||
[14] Type: subscribe
|
||||
Operation: NotificationQuery3
|
||||
Query:
|
||||
[15] Type: subscribe
|
||||
Operation: MessageList
|
||||
Query:
|
||||
[16] Type: subscribe
|
||||
Operation: NotificationQuery2
|
||||
Query:
|
||||
[22] Type: subscribe
|
||||
Operation: CurrentUserQuery4
|
||||
Query:
|
||||
[32] Type: subscribe
|
||||
Operation: NotificationQuery1
|
||||
Query:
|
||||
[33] Type: subscribe
|
||||
Operation: MessageList
|
||||
Query:
|
||||
[34] Type: subscribe
|
||||
Operation: EventMutation
|
||||
Reference in New Issue
Block a user