sync
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -46,3 +46,5 @@ build/
|
|||||||
|
|
||||||
# Other
|
# Other
|
||||||
.examples/
|
.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 "-"
|
// Sanitize nicks for RELAYMSG: replace IRC characters with special meanings with "-"
|
||||||
func sanitizeNick(nick string) string {
|
func sanitizeNick(nick string) string {
|
||||||
sanitize := func(r rune) rune {
|
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) {
|
if strings.ContainsRune("!+%@&#$:'\"?*,. ", r) {
|
||||||
return '-'
|
return '-'
|
||||||
}
|
}
|
||||||
@@ -229,12 +234,36 @@ func (b *Birc) doSend() {
|
|||||||
for msg := range b.Local {
|
for msg := range b.Local {
|
||||||
<-throttle.C
|
<-throttle.C
|
||||||
username := msg.Username
|
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
|
// Optional support for the proposed RELAYMSG extension, described at
|
||||||
// https://github.com/jlu5/ircv3-specifications/blob/master/extensions/relaymsg.md
|
// https://github.com/jlu5/ircv3-specifications/blob/master/extensions/relaymsg.md
|
||||||
// nolint:nestif
|
// nolint:nestif
|
||||||
if (b.i.HasCapability("overdrivenetworks.com/relaymsg") || b.i.HasCapability("draft/relaymsg")) &&
|
if (b.i.HasCapability("overdrivenetworks.com/relaymsg") || b.i.HasCapability("draft/relaymsg")) &&
|
||||||
b.GetBool("UseRelayMsg") {
|
b.GetBool("UseRelayMsg") {
|
||||||
username = sanitizeNick(username)
|
username = sanitizeNick(username)
|
||||||
|
b.Log.Infof("After sanitizeNick: %q (len=%d, bytes=%v)", username, len(username), []byte(username))
|
||||||
text := msg.Text
|
text := msg.Text
|
||||||
|
|
||||||
// Work around girc chomping leading commas on single word messages?
|
// Work around girc chomping leading commas on single word messages?
|
||||||
@@ -245,23 +274,24 @@ func (b *Birc) doSend() {
|
|||||||
if msg.Event == config.EventUserAction {
|
if msg.Event == config.EventUserAction {
|
||||||
b.i.Cmd.SendRawf("RELAYMSG %s %s :\x01ACTION %s\x01", msg.Channel, username, text) //nolint:errcheck
|
b.i.Cmd.SendRawf("RELAYMSG %s %s :\x01ACTION %s\x01", msg.Channel, username, text) //nolint:errcheck
|
||||||
} else {
|
} 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
|
b.i.Cmd.SendRawf("RELAYMSG %s %s :%s", msg.Channel, username, text) //nolint:errcheck
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if b.GetBool("Colornicks") {
|
if b.GetBool("Colornicks") {
|
||||||
checksum := crc32.ChecksumIEEE([]byte(msg.Username))
|
checksum := crc32.ChecksumIEEE([]byte(msg.Username))
|
||||||
colorCode := checksum%14 + 2 // quick fix - prevent white or black color codes
|
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 {
|
switch msg.Event {
|
||||||
case config.EventUserAction:
|
case config.EventUserAction:
|
||||||
b.i.Cmd.Action(msg.Channel, username+msg.Text)
|
b.i.Cmd.Action(msg.Channel, username+msg.Text)
|
||||||
case config.EventNoticeIRC:
|
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)
|
b.i.Cmd.Notice(msg.Channel, username+msg.Text)
|
||||||
default:
|
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)
|
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