wow that took awhile
This commit is contained in:
200
CAPTURE_UPLOAD_MANUALLY.md
Normal file
200
CAPTURE_UPLOAD_MANUALLY.md
Normal file
@@ -0,0 +1,200 @@
|
||||
# Manual Upload Capture Instructions
|
||||
|
||||
Since the Playwright browser may have restrictions, here's how to capture the upload protocol using your normal browser's Developer Tools.
|
||||
|
||||
## Method 1: Chrome/Chromium DevTools (Recommended)
|
||||
|
||||
### Step 1: Open DevTools
|
||||
1. Open Chrome/Chromium
|
||||
2. Navigate to https://app.kosmi.io/room/@hyperspaceout
|
||||
3. Press `F12` or `Cmd+Option+I` (Mac) to open DevTools
|
||||
4. Click the **Network** tab
|
||||
|
||||
### Step 2: Filter and Prepare
|
||||
1. In the Network tab, check the **Preserve log** checkbox (important!)
|
||||
2. Click the filter icon and select:
|
||||
- **Fetch/XHR** (for API calls)
|
||||
- **WS** (for WebSocket messages)
|
||||
3. Clear the log (trash icon) to start fresh
|
||||
|
||||
### Step 3: Upload Image
|
||||
1. In the Kosmi chat, click the attachment/upload button
|
||||
2. Select `blurt.jpg` (or any small image)
|
||||
3. Wait for the upload to complete
|
||||
4. Watch the Network tab for new entries
|
||||
|
||||
### Step 4: Find Upload Request
|
||||
Look for requests that might be the upload:
|
||||
- URL contains: `upload`, `media`, `file`, `attachment`, `image`, `cdn`, `s3`
|
||||
- Method: `POST` or `PUT`
|
||||
- Type: `fetch`, `xhr`, or `other`
|
||||
|
||||
### Step 5: Capture Details
|
||||
|
||||
**For HTTP Upload:**
|
||||
1. Click on the upload request
|
||||
2. Go to the **Headers** tab:
|
||||
- Copy the **Request URL**
|
||||
- Copy **Request Method**
|
||||
- Copy **Request Headers** (especially `Authorization`, `Content-Type`)
|
||||
3. Go to the **Payload** tab:
|
||||
- Note the format (Form Data, Request Payload, etc.)
|
||||
- Copy the structure
|
||||
4. Go to the **Response** tab:
|
||||
- Copy the entire response (usually JSON with image URL)
|
||||
|
||||
**For WebSocket Message:**
|
||||
1. Click on the **WS** filter
|
||||
2. Click on the WebSocket connection (usually `wss://engine.kosmi.io/gql-ws`)
|
||||
3. Click the **Messages** tab
|
||||
4. Look for messages sent around the time of upload
|
||||
5. Look for GraphQL mutations like `uploadFile`, `uploadImage`, `sendMedia`
|
||||
6. Copy the entire message (both request and response)
|
||||
|
||||
### Step 6: Save Information
|
||||
|
||||
Create a file `upload-capture.txt` with this information:
|
||||
|
||||
```
|
||||
=== UPLOAD CAPTURE ===
|
||||
|
||||
Method: [HTTP or WebSocket]
|
||||
|
||||
--- If HTTP ---
|
||||
URL: [full URL]
|
||||
Method: [POST/PUT]
|
||||
Headers:
|
||||
Authorization: [value]
|
||||
Content-Type: [value]
|
||||
[other headers]
|
||||
|
||||
Request Body Format: [multipart/form-data, JSON, binary, etc.]
|
||||
Request Body:
|
||||
[paste the payload structure]
|
||||
|
||||
Response:
|
||||
[paste the full response]
|
||||
|
||||
--- If WebSocket ---
|
||||
Message Sent:
|
||||
[paste the GraphQL mutation or message]
|
||||
|
||||
Message Received:
|
||||
[paste the response]
|
||||
|
||||
--- Additional Notes ---
|
||||
[any other observations]
|
||||
```
|
||||
|
||||
## Method 2: Firefox DevTools
|
||||
|
||||
Same process as Chrome, but:
|
||||
1. Press `F12` or `Cmd+Option+I`
|
||||
2. Click **Network** tab
|
||||
3. Right-click on the upload request → **Copy** → **Copy All As HAR**
|
||||
4. Save to `upload-capture.har` and share that file
|
||||
|
||||
## Method 3: Use the Monitor (Fixed)
|
||||
|
||||
The monitor has been updated with:
|
||||
- ✅ Better permissions handling
|
||||
- ✅ Proper Ctrl+C cleanup
|
||||
- ✅ Fallback message if upload doesn't work
|
||||
|
||||
Try running it again:
|
||||
```bash
|
||||
./monitor-ws
|
||||
```
|
||||
|
||||
If upload still doesn't work in the Playwright browser, that's okay - just use Method 1 or 2 above.
|
||||
|
||||
## What We Need
|
||||
|
||||
At minimum, we need to know:
|
||||
|
||||
1. **Upload Method:**
|
||||
- [ ] HTTP POST/PUT to an endpoint
|
||||
- [ ] GraphQL mutation via WebSocket
|
||||
- [ ] Something else
|
||||
|
||||
2. **Endpoint/Mutation:**
|
||||
- URL or mutation name
|
||||
|
||||
3. **Authentication:**
|
||||
- How is the JWT token passed? (Header? Payload?)
|
||||
|
||||
4. **Request Format:**
|
||||
- Multipart form-data?
|
||||
- Base64 encoded in JSON?
|
||||
- Binary?
|
||||
|
||||
5. **Response Format:**
|
||||
- JSON with `{ url: "..." }`?
|
||||
- Something else?
|
||||
|
||||
## Example: What Good Capture Looks Like
|
||||
|
||||
### Example HTTP Upload:
|
||||
```
|
||||
URL: https://cdn.kosmi.io/upload
|
||||
Method: POST
|
||||
Headers:
|
||||
Authorization: Bearer eyJhbGc...
|
||||
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary...
|
||||
|
||||
Body:
|
||||
------WebKitFormBoundary...
|
||||
Content-Disposition: form-data; name="file"; filename="blurt.jpg"
|
||||
Content-Type: image/jpeg
|
||||
|
||||
[binary data]
|
||||
------WebKitFormBoundary...--
|
||||
|
||||
Response:
|
||||
{
|
||||
"url": "https://cdn.kosmi.io/files/abc123.jpg",
|
||||
"id": "abc123"
|
||||
}
|
||||
```
|
||||
|
||||
### Example WebSocket Upload:
|
||||
```
|
||||
Sent:
|
||||
{
|
||||
"id": "upload-123",
|
||||
"type": "subscribe",
|
||||
"payload": {
|
||||
"query": "mutation UploadFile($file: Upload!) { uploadFile(file: $file) { url } }",
|
||||
"variables": {
|
||||
"file": "data:image/jpeg;base64,/9j/4AAQSkZJRg..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Received:
|
||||
{
|
||||
"id": "upload-123",
|
||||
"type": "next",
|
||||
"payload": {
|
||||
"data": {
|
||||
"uploadFile": {
|
||||
"url": "https://cdn.kosmi.io/files/abc123.jpg"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## After Capture
|
||||
|
||||
Once you have the information, either:
|
||||
1. Paste it in a message to me
|
||||
2. Save to `upload-capture.txt` and share
|
||||
3. Share the HAR file if using Firefox
|
||||
|
||||
I'll then:
|
||||
1. Analyze the protocol
|
||||
2. Document it in `KOSMI_IMAGE_UPLOAD.md`
|
||||
3. Implement the Go client
|
||||
4. Complete the integration
|
||||
|
||||
203
JACKBOX_INTEGRATION.md
Normal file
203
JACKBOX_INTEGRATION.md
Normal file
@@ -0,0 +1,203 @@
|
||||
# Jackbox Game Picker API Integration
|
||||
|
||||
This document describes how the Kosmi/IRC relay integrates with the Jackbox Game Picker API for live voting and game notifications.
|
||||
|
||||
## Features
|
||||
|
||||
### 1. Vote Detection
|
||||
The relay automatically detects when users vote on games using the `thisgame++` or `thisgame--` syntax in either Kosmi or IRC chat.
|
||||
|
||||
**How it works:**
|
||||
- Users type `thisgame++` to upvote the current game
|
||||
- Users type `thisgame--` to downvote the current game
|
||||
- Votes are case-insensitive
|
||||
- The relay filters out relayed messages (messages with `[irc]` or `[kosmi]` prefix) to prevent duplicate votes
|
||||
- Only votes from actual users in each chat are sent to the API
|
||||
|
||||
### 2. Game Notifications
|
||||
When a new game is added to the Jackbox session via the API, the relay receives a webhook notification and broadcasts it to both Kosmi and IRC chats.
|
||||
|
||||
**Example notification:**
|
||||
```
|
||||
🎮 Coming up next: Fibbage 4!
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Add the following section to your `matterbridge.toml`:
|
||||
|
||||
```toml
|
||||
[jackbox]
|
||||
# Enable Jackbox integration for vote detection and game notifications
|
||||
Enabled=true
|
||||
|
||||
# Jackbox API URL
|
||||
APIURL="http://localhost:5000"
|
||||
|
||||
# Admin password for API authentication
|
||||
AdminPassword="your_admin_password_here"
|
||||
|
||||
# Webhook server port (for receiving game notifications)
|
||||
WebhookPort=3001
|
||||
|
||||
# Webhook secret for signature verification
|
||||
WebhookSecret="your_webhook_secret_here"
|
||||
```
|
||||
|
||||
### Configuration Options
|
||||
|
||||
- **Enabled**: Set to `true` to enable the integration, `false` to disable
|
||||
- **APIURL**: The URL of your Jackbox Game Picker API (e.g., `http://localhost:5000`)
|
||||
- **AdminPassword**: Your API admin password (used to authenticate and get a JWT token)
|
||||
- **WebhookPort**: Port for the webhook server to listen on (default: 3001)
|
||||
- **WebhookSecret**: Shared secret for webhook signature verification (must match the secret configured in the API)
|
||||
|
||||
## Setup Steps
|
||||
|
||||
### 1. Configure the Relay
|
||||
|
||||
Edit `matterbridge.toml` and add the Jackbox configuration section with your API URL, admin password, and webhook secret.
|
||||
|
||||
### 2. Register the Webhook
|
||||
|
||||
After starting the relay, register the webhook with the Jackbox API:
|
||||
|
||||
```bash
|
||||
# Get JWT token
|
||||
curl -X POST "http://localhost:5000/api/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"apiKey": "your_admin_password"}'
|
||||
|
||||
# Register webhook (use the JWT token from above)
|
||||
curl -X POST "http://localhost:5000/api/webhooks" \
|
||||
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "Kosmi/IRC Relay",
|
||||
"url": "http://your-relay-host:3001/webhook/jackbox",
|
||||
"secret": "your_webhook_secret_here",
|
||||
"events": ["game.added"]
|
||||
}'
|
||||
```
|
||||
|
||||
**Important:** Replace `your-relay-host` with the actual hostname or IP address where your relay is running. If the API and relay are on the same machine, you can use `localhost`.
|
||||
|
||||
### 3. Test the Integration
|
||||
|
||||
#### Test Vote Detection
|
||||
|
||||
1. Start an active session in the Jackbox API with some games played
|
||||
2. Send a message in Kosmi or IRC: `thisgame++`
|
||||
3. Check the relay logs for: `Detected vote from <username>: up`
|
||||
4. Verify the vote was recorded in the API
|
||||
|
||||
#### Test Game Notifications
|
||||
|
||||
1. Add a game to the active session via the Jackbox API
|
||||
2. Both Kosmi and IRC chats should receive a notification: `🎮 Coming up next: <game title>!`
|
||||
|
||||
## How It Works
|
||||
|
||||
### Vote Flow
|
||||
|
||||
1. User sends a message containing `thisgame++` or `thisgame--` in Kosmi or IRC
|
||||
2. The bridge detects the vote pattern (case-insensitive)
|
||||
3. The bridge checks if the message is relayed (has `[irc]` or `[kosmi]` prefix)
|
||||
4. If not relayed, the bridge extracts the username and vote type
|
||||
5. The bridge sends the vote to the Jackbox API via HTTP POST to `/api/votes/live`
|
||||
6. The API records the vote and associates it with the current game based on timestamp
|
||||
|
||||
### Notification Flow
|
||||
|
||||
1. A game is added to an active session in the Jackbox API
|
||||
2. The API sends a webhook POST request to `http://your-relay-host:3001/webhook/jackbox`
|
||||
3. The webhook includes an HMAC-SHA256 signature in the `X-Webhook-Signature` header
|
||||
4. The relay verifies the signature using the configured webhook secret
|
||||
5. If valid, the relay parses the `game.added` event
|
||||
6. The relay broadcasts the game announcement to all connected bridges (Kosmi and IRC)
|
||||
|
||||
## Security
|
||||
|
||||
### Webhook Signature Verification
|
||||
|
||||
All incoming webhooks are verified using HMAC-SHA256 signatures to ensure they come from the legitimate Jackbox API.
|
||||
|
||||
**How it works:**
|
||||
1. The API computes `HMAC-SHA256(webhook_secret, request_body)`
|
||||
2. The signature is sent in the `X-Webhook-Signature` header as `sha256=<hex_signature>`
|
||||
3. The relay computes the expected signature using the same method
|
||||
4. The relay uses timing-safe comparison to verify the signatures match
|
||||
5. If verification fails, the webhook is rejected with a 401 Unauthorized response
|
||||
|
||||
### JWT Authentication
|
||||
|
||||
The relay authenticates with the Jackbox API using the admin password to obtain a JWT token. This token is:
|
||||
- Cached to avoid re-authentication on every vote
|
||||
- Automatically refreshed if it expires (detected via 401 response)
|
||||
- Valid for 24 hours (configurable in the API)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Votes Not Being Recorded
|
||||
|
||||
**Possible causes:**
|
||||
- No active session in the Jackbox API
|
||||
- Vote timestamp doesn't match any played game
|
||||
- Duplicate vote within 1 second
|
||||
- Authentication failure
|
||||
|
||||
**Check:**
|
||||
1. Relay logs for vote detection: `grep "Detected vote" logs/matterbridge.log`
|
||||
2. Relay logs for API errors: `grep "Failed to send vote" logs/matterbridge.log`
|
||||
3. API logs for incoming vote requests
|
||||
|
||||
### Webhooks Not Being Received
|
||||
|
||||
**Possible causes:**
|
||||
- Webhook URL is not accessible from the API server
|
||||
- Webhook not registered in the API
|
||||
- Signature verification failing
|
||||
- Firewall blocking the webhook port
|
||||
|
||||
**Check:**
|
||||
1. Verify webhook is registered: `GET /api/webhooks` (with JWT token)
|
||||
2. Test webhook manually: `POST /api/webhooks/test/:id` (with JWT token)
|
||||
3. Check webhook logs in API: `GET /api/webhooks/:id/logs` (with JWT token)
|
||||
4. Verify webhook server is listening: `curl http://localhost:3001/health`
|
||||
5. Check relay logs for signature verification errors
|
||||
|
||||
### Authentication Failures
|
||||
|
||||
**Possible causes:**
|
||||
- Incorrect admin password in configuration
|
||||
- API is not running or not accessible
|
||||
|
||||
**Check:**
|
||||
1. Verify API URL is correct and accessible
|
||||
2. Test authentication manually:
|
||||
```bash
|
||||
curl -X POST "http://localhost:5000/api/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"apiKey": "your_admin_password"}'
|
||||
```
|
||||
3. Check relay logs for authentication errors
|
||||
|
||||
## Logs
|
||||
|
||||
The relay logs all Jackbox-related activity with the `jackbox` prefix:
|
||||
|
||||
```
|
||||
[jackbox] Initializing Jackbox integration...
|
||||
[jackbox] Successfully authenticated with Jackbox API
|
||||
[jackbox] Starting Jackbox webhook server on port 3001
|
||||
[kosmi] Detected vote from Anonymous Llama: up
|
||||
[jackbox] Vote recorded for Fibbage 4: Anonymous Llama - 5👍 2👎
|
||||
[jackbox] Broadcasting Jackbox message: 🎮 Coming up next: Quiplash 3!
|
||||
```
|
||||
|
||||
## Disabling the Integration
|
||||
|
||||
To disable the Jackbox integration, set `Enabled=false` in the `[jackbox]` section of `matterbridge.toml` and restart the relay.
|
||||
|
||||
The relay will continue to function normally for Kosmi ↔ IRC message relay without any Jackbox features.
|
||||
|
||||
298
JACKBOX_TESTING.md
Normal file
298
JACKBOX_TESTING.md
Normal file
@@ -0,0 +1,298 @@
|
||||
# Jackbox Integration Testing Guide
|
||||
|
||||
This document provides a comprehensive testing guide for the Jackbox Game Picker API integration.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before testing, ensure:
|
||||
|
||||
1. **Jackbox Game Picker API is running**
|
||||
- API should be accessible at the configured URL (e.g., `http://localhost:5000`)
|
||||
- You have the admin password for authentication
|
||||
|
||||
2. **Relay is configured**
|
||||
- `matterbridge.toml` has the `[jackbox]` section configured
|
||||
- `Enabled=true`
|
||||
- `APIURL`, `AdminPassword`, and `WebhookSecret` are set correctly
|
||||
|
||||
3. **Webhook is registered**
|
||||
- Follow the setup steps in `JACKBOX_INTEGRATION.md` to register the webhook
|
||||
|
||||
## Test Plan
|
||||
|
||||
### Test 1: Build and Startup
|
||||
|
||||
**Objective:** Verify the relay builds and starts successfully with Jackbox integration enabled.
|
||||
|
||||
**Steps:**
|
||||
1. Build the relay: `docker-compose build`
|
||||
2. Start the relay: `docker-compose up -d`
|
||||
3. Check logs: `docker logs kosmi-irc-relay`
|
||||
|
||||
**Expected Results:**
|
||||
```
|
||||
[jackbox] Initializing Jackbox integration...
|
||||
[jackbox] Successfully authenticated with Jackbox API
|
||||
[jackbox] Starting Jackbox webhook server on port 3001
|
||||
[jackbox] Jackbox client injected into Kosmi bridge
|
||||
[jackbox] Jackbox client injected into IRC bridge
|
||||
```
|
||||
|
||||
**Status:** ✅ PASS / ❌ FAIL
|
||||
|
||||
---
|
||||
|
||||
### Test 2: Vote Detection from Kosmi
|
||||
|
||||
**Objective:** Verify votes from Kosmi chat are detected and sent to the API.
|
||||
|
||||
**Prerequisites:**
|
||||
- Active session in Jackbox API with at least one game played
|
||||
|
||||
**Steps:**
|
||||
1. Send message in Kosmi chat: `thisgame++`
|
||||
2. Check relay logs for vote detection
|
||||
3. Verify vote was recorded in API
|
||||
|
||||
**Expected Results:**
|
||||
- Relay logs show: `[kosmi] Detected vote from <username>: up`
|
||||
- Relay logs show: `[jackbox] Vote recorded for <game>: <username> - X👍 Y👎`
|
||||
- API shows the vote was recorded
|
||||
|
||||
**Status:** ✅ PASS / ❌ FAIL
|
||||
|
||||
---
|
||||
|
||||
### Test 3: Vote Detection from IRC
|
||||
|
||||
**Objective:** Verify votes from IRC chat are detected and sent to the API.
|
||||
|
||||
**Prerequisites:**
|
||||
- Active session in Jackbox API with at least one game played
|
||||
|
||||
**Steps:**
|
||||
1. Send message in IRC chat: `thisgame--`
|
||||
2. Check relay logs for vote detection
|
||||
3. Verify vote was recorded in API
|
||||
|
||||
**Expected Results:**
|
||||
- Relay logs show: `[irc] Detected vote from <username>: down`
|
||||
- Relay logs show: `[jackbox] Vote recorded for <game>: <username> - X👍 Y👎`
|
||||
- API shows the vote was recorded
|
||||
|
||||
**Status:** ✅ PASS / ❌ FAIL
|
||||
|
||||
---
|
||||
|
||||
### Test 4: Case-Insensitive Vote Detection
|
||||
|
||||
**Objective:** Verify votes work with different case variations.
|
||||
|
||||
**Steps:**
|
||||
1. Send in Kosmi: `THISGAME++`
|
||||
2. Send in IRC: `ThIsGaMe--`
|
||||
3. Send in Kosmi: `thisgame++`
|
||||
|
||||
**Expected Results:**
|
||||
- All three votes are detected and sent to API
|
||||
- Relay logs show vote detection for each message
|
||||
|
||||
**Status:** ✅ PASS / ❌ FAIL
|
||||
|
||||
---
|
||||
|
||||
### Test 5: Relayed Message Filtering (Kosmi)
|
||||
|
||||
**Objective:** Verify relayed messages from IRC are NOT sent as votes from Kosmi.
|
||||
|
||||
**Steps:**
|
||||
1. Send message in IRC: `thisgame++`
|
||||
2. Observe the relayed message in Kosmi chat (should have `[irc]` prefix)
|
||||
3. Check relay logs
|
||||
|
||||
**Expected Results:**
|
||||
- Message appears in Kosmi as: `[irc] <username> thisgame++`
|
||||
- Relay logs show vote detected from IRC user
|
||||
- Relay logs do NOT show a second vote from Kosmi
|
||||
- Only ONE vote is recorded in the API
|
||||
|
||||
**Status:** ✅ PASS / ❌ FAIL
|
||||
|
||||
---
|
||||
|
||||
### Test 6: Relayed Message Filtering (IRC)
|
||||
|
||||
**Objective:** Verify relayed messages from Kosmi are NOT sent as votes from IRC.
|
||||
|
||||
**Steps:**
|
||||
1. Send message in Kosmi: `thisgame--`
|
||||
2. Observe the relayed message in IRC chat (should have `[kosmi]` prefix)
|
||||
3. Check relay logs
|
||||
|
||||
**Expected Results:**
|
||||
- Message appears in IRC as: `[kosmi] <username> thisgame--`
|
||||
- Relay logs show vote detected from Kosmi user
|
||||
- Relay logs do NOT show a second vote from IRC
|
||||
- Only ONE vote is recorded in the API
|
||||
|
||||
**Status:** ✅ PASS / ❌ FAIL
|
||||
|
||||
---
|
||||
|
||||
### Test 7: Game Notification Webhook
|
||||
|
||||
**Objective:** Verify game notifications are received and broadcast to both chats.
|
||||
|
||||
**Steps:**
|
||||
1. Add a game to the active session via the Jackbox API
|
||||
2. Check relay logs for webhook receipt
|
||||
3. Verify notification appears in both Kosmi and IRC chats
|
||||
|
||||
**Expected Results:**
|
||||
- Relay logs show: `[jackbox] Broadcasting Jackbox message: 🎮 Coming up next: <game>!`
|
||||
- Both Kosmi and IRC chats receive the notification
|
||||
- Message appears as: `🎮 Coming up next: <game>!`
|
||||
|
||||
**Status:** ✅ PASS / ❌ FAIL
|
||||
|
||||
---
|
||||
|
||||
### Test 8: Webhook Signature Verification
|
||||
|
||||
**Objective:** Verify webhooks with invalid signatures are rejected.
|
||||
|
||||
**Steps:**
|
||||
1. Send a manual webhook POST to `http://localhost:3001/webhook/jackbox` with an invalid signature
|
||||
2. Check relay logs
|
||||
|
||||
**Expected Results:**
|
||||
- Relay logs show: `[jackbox] Webhook signature verification failed`
|
||||
- HTTP response is 401 Unauthorized
|
||||
- No game notification is broadcast
|
||||
|
||||
**Test Command:**
|
||||
```bash
|
||||
curl -X POST "http://localhost:3001/webhook/jackbox" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Webhook-Signature: sha256=invalid" \
|
||||
-d '{"event":"game.added","data":{"game":{"title":"Test Game"}}}'
|
||||
```
|
||||
|
||||
**Status:** ✅ PASS / ❌ FAIL
|
||||
|
||||
---
|
||||
|
||||
### Test 9: Duplicate Vote Prevention
|
||||
|
||||
**Objective:** Verify the API prevents duplicate votes within 1 second.
|
||||
|
||||
**Steps:**
|
||||
1. Send `thisgame++` in Kosmi
|
||||
2. Immediately send `thisgame++` again (within 1 second)
|
||||
3. Check relay logs and API
|
||||
|
||||
**Expected Results:**
|
||||
- First vote is recorded successfully
|
||||
- Second vote is rejected by API with 409 Conflict
|
||||
- Relay logs show: `[jackbox] Duplicate vote from <username> (within 1 second)`
|
||||
|
||||
**Status:** ✅ PASS / ❌ FAIL
|
||||
|
||||
---
|
||||
|
||||
### Test 10: No Active Session Handling
|
||||
|
||||
**Objective:** Verify graceful handling when no active session exists.
|
||||
|
||||
**Steps:**
|
||||
1. End the active session in the Jackbox API
|
||||
2. Send `thisgame++` in Kosmi or IRC
|
||||
3. Check relay logs
|
||||
|
||||
**Expected Results:**
|
||||
- Relay logs show: `[jackbox] Vote rejected: no active session or timestamp doesn't match any game`
|
||||
- No error is thrown
|
||||
- Relay continues to function normally
|
||||
|
||||
**Status:** ✅ PASS / ❌ FAIL
|
||||
|
||||
---
|
||||
|
||||
### Test 11: Disabled Integration
|
||||
|
||||
**Objective:** Verify relay works normally when Jackbox integration is disabled.
|
||||
|
||||
**Steps:**
|
||||
1. Set `Enabled=false` in `[jackbox]` section
|
||||
2. Restart relay: `docker-compose restart`
|
||||
3. Send messages with `thisgame++` in both chats
|
||||
4. Check relay logs
|
||||
|
||||
**Expected Results:**
|
||||
- Relay logs show: `[jackbox] Jackbox integration is disabled`
|
||||
- No vote detection occurs
|
||||
- Messages are relayed normally between Kosmi and IRC
|
||||
- No Jackbox client injection messages
|
||||
|
||||
**Status:** ✅ PASS / ❌ FAIL
|
||||
|
||||
---
|
||||
|
||||
### Test 12: Authentication Token Refresh
|
||||
|
||||
**Objective:** Verify the relay re-authenticates when the JWT token expires.
|
||||
|
||||
**Steps:**
|
||||
1. Wait for token to expire (or manually invalidate it in the API)
|
||||
2. Send a vote: `thisgame++`
|
||||
3. Check relay logs
|
||||
|
||||
**Expected Results:**
|
||||
- Relay logs show: `[jackbox] Token expired, re-authenticating...`
|
||||
- Relay logs show: `[jackbox] Successfully authenticated with Jackbox API`
|
||||
- Vote is sent successfully after re-authentication
|
||||
|
||||
**Status:** ✅ PASS / ❌ FAIL
|
||||
|
||||
---
|
||||
|
||||
## Manual Testing Checklist
|
||||
|
||||
- [ ] Test 1: Build and Startup
|
||||
- [ ] Test 2: Vote Detection from Kosmi
|
||||
- [ ] Test 3: Vote Detection from IRC
|
||||
- [ ] Test 4: Case-Insensitive Vote Detection
|
||||
- [ ] Test 5: Relayed Message Filtering (Kosmi)
|
||||
- [ ] Test 6: Relayed Message Filtering (IRC)
|
||||
- [ ] Test 7: Game Notification Webhook
|
||||
- [ ] Test 8: Webhook Signature Verification
|
||||
- [ ] Test 9: Duplicate Vote Prevention
|
||||
- [ ] Test 10: No Active Session Handling
|
||||
- [ ] Test 11: Disabled Integration
|
||||
- [ ] Test 12: Authentication Token Refresh
|
||||
|
||||
## Test Results Summary
|
||||
|
||||
**Date:** _________________
|
||||
|
||||
**Tester:** _________________
|
||||
|
||||
**Total Tests:** 12
|
||||
|
||||
**Passed:** ___ / 12
|
||||
|
||||
**Failed:** ___ / 12
|
||||
|
||||
**Notes:**
|
||||
_______________________________________________________________________
|
||||
_______________________________________________________________________
|
||||
_______________________________________________________________________
|
||||
|
||||
## Known Issues
|
||||
|
||||
(Document any issues found during testing)
|
||||
|
||||
## Recommendations
|
||||
|
||||
(Document any improvements or changes needed)
|
||||
|
||||
162
KOSMI_IMAGE_UPLOAD.md
Normal file
162
KOSMI_IMAGE_UPLOAD.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# Kosmi Image Upload Protocol
|
||||
|
||||
## Summary
|
||||
|
||||
Kosmi uses a **simple HTTP POST** to upload images, **NOT the WebSocket**. Images are uploaded to a dedicated CDN endpoint.
|
||||
|
||||
## Upload Endpoint
|
||||
|
||||
```
|
||||
POST https://img.kosmi.io/
|
||||
```
|
||||
|
||||
## Request Details
|
||||
|
||||
### Method
|
||||
`POST`
|
||||
|
||||
### Headers
|
||||
```
|
||||
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary...
|
||||
Origin: https://app.kosmi.io
|
||||
Referer: https://app.kosmi.io/
|
||||
```
|
||||
|
||||
**Important:** No authentication required! The endpoint accepts anonymous uploads.
|
||||
|
||||
### Request Body
|
||||
Standard `multipart/form-data` with a single field:
|
||||
|
||||
```
|
||||
------WebKitFormBoundary...
|
||||
Content-Disposition: form-data; name="file"; filename="blurt.jpg"
|
||||
Content-Type: image/jpeg
|
||||
|
||||
[binary image data]
|
||||
------WebKitFormBoundary...--
|
||||
```
|
||||
|
||||
### CORS
|
||||
The endpoint has CORS enabled for `https://app.kosmi.io`:
|
||||
```
|
||||
Access-Control-Allow-Origin: https://app.kosmi.io
|
||||
```
|
||||
|
||||
## Response
|
||||
|
||||
### Status
|
||||
`200 OK`
|
||||
|
||||
### Headers
|
||||
```
|
||||
Content-Type: application/json
|
||||
Access-Control-Allow-Origin: https://app.kosmi.io
|
||||
```
|
||||
|
||||
### Response Body (CONFIRMED)
|
||||
```json
|
||||
{
|
||||
"filename": "8d580b3a-905d-4bc9-909b-ccc6743edbdc.webp"
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** The response contains only the filename, not the full URL. The full URL must be constructed as:
|
||||
```
|
||||
https://img.kosmi.io/{filename}
|
||||
```
|
||||
|
||||
Example:
|
||||
```json
|
||||
{
|
||||
"filename": "3460a8e1-fe19-4371-a735-64078e9923a4.webp"
|
||||
}
|
||||
```
|
||||
→ Full URL: `https://img.kosmi.io/3460a8e1-fe19-4371-a735-64078e9923a4.webp`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### For Go Client
|
||||
|
||||
1. **No authentication needed** - This is a public upload endpoint
|
||||
2. **Use standard multipart/form-data** - Go's `mime/multipart` package
|
||||
3. **Set CORS headers**:
|
||||
- `Origin: https://app.kosmi.io`
|
||||
- `Referer: https://app.kosmi.io/`
|
||||
4. **Parse JSON response** to get the image URL
|
||||
5. **Send the URL to Kosmi chat** via the existing WebSocket `sendMessage` mutation
|
||||
|
||||
### Workflow
|
||||
|
||||
1. Generate room code PNG image (already implemented in `roomcode_image.go`)
|
||||
2. Upload PNG to `https://img.kosmi.io/` via HTTP POST
|
||||
3. Parse response to get image URL
|
||||
4. Send message to Kosmi chat with the image URL
|
||||
5. Kosmi will automatically display the image as a thumbnail
|
||||
|
||||
### Security Considerations
|
||||
|
||||
- The endpoint is public (no auth required)
|
||||
- Files are likely rate-limited or size-limited
|
||||
- Images are served from `img.kosmi.io` CDN
|
||||
- The upload is CORS-protected (only works from `app.kosmi.io` origin)
|
||||
|
||||
## Example Implementation (Pseudocode)
|
||||
|
||||
```go
|
||||
func UploadImageToKosmi(imageData []byte, filename string) (string, error) {
|
||||
// Create multipart form
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
|
||||
// Add file field
|
||||
part, err := writer.CreateFormFile("file", filename)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
part.Write(imageData)
|
||||
writer.Close()
|
||||
|
||||
// Create request
|
||||
req, err := http.NewRequest("POST", "https://img.kosmi.io/", body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Set headers
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("Origin", "https://app.kosmi.io")
|
||||
req.Header.Set("Referer", "https://app.kosmi.io/")
|
||||
|
||||
// Send request
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Parse response
|
||||
var result struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
|
||||
return result.URL, nil
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
To test the upload, we can:
|
||||
1. Generate a test room code image
|
||||
2. Upload it to `https://img.kosmi.io/`
|
||||
3. Verify we get a URL back
|
||||
4. Send the URL to Kosmi chat
|
||||
5. Verify the image displays as a thumbnail
|
||||
|
||||
## References
|
||||
|
||||
- HAR capture: `image_upload_HAR-sanitized.har` (lines 3821-4110)
|
||||
- Upload request: Line 3910-4018
|
||||
- Upload response: Line 4019-4092
|
||||
|
||||
225
MUTE_CONTROL.md
Normal file
225
MUTE_CONTROL.md
Normal file
@@ -0,0 +1,225 @@
|
||||
# Mute Control for Jackbox Announcements
|
||||
|
||||
The bot supports muting Jackbox announcements without restarting. This is useful when you want to test the Jackbox API or run games without spamming the chat.
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ Start the bot muted
|
||||
- ✅ Toggle mute/unmute while running
|
||||
- ✅ Works in terminal and Docker
|
||||
- ✅ Vote detection still works (votes are sent to API even when muted)
|
||||
- ✅ Only Jackbox announcements are muted (IRC ↔ Kosmi relay still works)
|
||||
|
||||
## Starting Muted
|
||||
|
||||
### Terminal
|
||||
```bash
|
||||
./matterbridge -conf matterbridge.toml -muted
|
||||
```
|
||||
|
||||
### Docker
|
||||
Update `docker-compose.yml`:
|
||||
```yaml
|
||||
services:
|
||||
kosmi-irc-relay:
|
||||
command: ["/app/matterbridge", "-conf", "/app/matterbridge.toml", "-muted"]
|
||||
```
|
||||
|
||||
Or run with override:
|
||||
```bash
|
||||
docker-compose run --rm kosmi-irc-relay /app/matterbridge -conf /app/matterbridge.toml -muted
|
||||
```
|
||||
|
||||
## Toggling Mute While Running
|
||||
|
||||
The bot listens for the `SIGUSR1` signal to toggle mute status.
|
||||
|
||||
### Terminal (Local Process)
|
||||
|
||||
**Find the process ID:**
|
||||
```bash
|
||||
ps aux | grep matterbridge
|
||||
# or
|
||||
pgrep matterbridge
|
||||
```
|
||||
|
||||
**Toggle mute:**
|
||||
```bash
|
||||
kill -SIGUSR1 <pid>
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
$ pgrep matterbridge
|
||||
12345
|
||||
|
||||
$ kill -SIGUSR1 12345
|
||||
# Bot logs: 🔇 Jackbox announcements MUTED
|
||||
|
||||
$ kill -SIGUSR1 12345
|
||||
# Bot logs: 🔊 Jackbox announcements UNMUTED
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
**Find the container name:**
|
||||
```bash
|
||||
docker ps
|
||||
```
|
||||
|
||||
**Toggle mute:**
|
||||
```bash
|
||||
docker kill -s SIGUSR1 <container_name>
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
$ docker ps
|
||||
CONTAINER ID IMAGE COMMAND NAMES
|
||||
abc123def456 kosmi-irc-relay "/app/matterbridge -…" kosmi-irc-relay
|
||||
|
||||
$ docker kill -s SIGUSR1 kosmi-irc-relay
|
||||
# Bot logs: 🔇 Jackbox announcements MUTED
|
||||
|
||||
$ docker kill -s SIGUSR1 kosmi-irc-relay
|
||||
# Bot logs: 🔊 Jackbox announcements UNMUTED
|
||||
```
|
||||
|
||||
## What Gets Muted?
|
||||
|
||||
### Muted Messages ❌
|
||||
- 🎮 Game Night is starting!
|
||||
- 🎮 Coming up next: [Game] - Room Code [CODE]
|
||||
- 🗳️ Final votes for [Game]: X👍 Y👎
|
||||
- 🌙 Game Night has ended! Thanks for playing!
|
||||
|
||||
### Still Works ✅
|
||||
- Vote detection (`thisgame++` / `thisgame--`)
|
||||
- Votes sent to Jackbox API
|
||||
- IRC ↔ Kosmi message relay
|
||||
- All other bot functionality
|
||||
|
||||
## Log Messages
|
||||
|
||||
**When starting muted:**
|
||||
```
|
||||
INFO Jackbox announcements started MUTED (use SIGUSR1 to toggle)
|
||||
INFO Signal handler ready: Send SIGUSR1 to toggle mute (kill -SIGUSR1 <pid> or docker kill -s SIGUSR1 <container>)
|
||||
```
|
||||
|
||||
**When toggling to muted:**
|
||||
```
|
||||
WARN 🔇 Jackbox announcements MUTED
|
||||
```
|
||||
|
||||
**When toggling to unmuted:**
|
||||
```
|
||||
INFO 🔊 Jackbox announcements UNMUTED
|
||||
```
|
||||
|
||||
**When a message is suppressed:**
|
||||
```
|
||||
DEBUG Jackbox message suppressed (muted): 🎮 Coming up next: Drawful 2 - Room Code C0D3
|
||||
```
|
||||
|
||||
## Use Cases
|
||||
|
||||
### Testing Jackbox API
|
||||
```bash
|
||||
# Start muted
|
||||
docker-compose up -d
|
||||
|
||||
# Test vote detection without spamming chat
|
||||
# (votes are still sent to API)
|
||||
# In chat: "thisgame++"
|
||||
|
||||
# Check logs to see votes are being processed
|
||||
docker logs kosmi-irc-relay -f
|
||||
|
||||
# Unmute when ready
|
||||
docker kill -s SIGUSR1 kosmi-irc-relay
|
||||
```
|
||||
|
||||
### Running Private Games
|
||||
```bash
|
||||
# Mute during game setup
|
||||
docker kill -s SIGUSR1 kosmi-irc-relay
|
||||
|
||||
# Play games without announcements
|
||||
# (useful if you're testing or running a private session)
|
||||
|
||||
# Unmute for public game night
|
||||
docker kill -s SIGUSR1 kosmi-irc-relay
|
||||
```
|
||||
|
||||
### Quick Mute Script
|
||||
Create a helper script `mute-toggle.sh`:
|
||||
```bash
|
||||
#!/bin/bash
|
||||
docker kill -s SIGUSR1 kosmi-irc-relay
|
||||
docker logs kosmi-irc-relay --tail 1
|
||||
```
|
||||
|
||||
Make it executable:
|
||||
```bash
|
||||
chmod +x mute-toggle.sh
|
||||
```
|
||||
|
||||
Use it:
|
||||
```bash
|
||||
./mute-toggle.sh
|
||||
# 🔇 Jackbox announcements MUTED
|
||||
|
||||
./mute-toggle.sh
|
||||
# 🔊 Jackbox announcements UNMUTED
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Signal not working in Docker
|
||||
Make sure your Docker container is running:
|
||||
```bash
|
||||
docker ps | grep kosmi-irc-relay
|
||||
```
|
||||
|
||||
If the container is restarting, check logs:
|
||||
```bash
|
||||
docker logs kosmi-irc-relay
|
||||
```
|
||||
|
||||
### Signal not working locally
|
||||
Make sure the process is running:
|
||||
```bash
|
||||
ps aux | grep matterbridge
|
||||
```
|
||||
|
||||
Check you're using the correct PID:
|
||||
```bash
|
||||
pgrep -f matterbridge
|
||||
```
|
||||
|
||||
### Mute state not persisting after restart
|
||||
Mute state is **not persisted** across restarts. If you restart the bot:
|
||||
- Without `-muted` flag: Bot starts unmuted
|
||||
- With `-muted` flag: Bot starts muted
|
||||
|
||||
This is intentional - you probably want announcements by default.
|
||||
|
||||
## Advanced: Systemd Service
|
||||
|
||||
If running as a systemd service, you can use `systemctl`:
|
||||
|
||||
**Create a mute toggle script:**
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# /usr/local/bin/matterbridge-mute-toggle
|
||||
PID=$(systemctl show -p MainPID --value matterbridge.service)
|
||||
kill -SIGUSR1 $PID
|
||||
journalctl -u matterbridge.service -n 1 --no-pager
|
||||
```
|
||||
|
||||
**Use it:**
|
||||
```bash
|
||||
sudo /usr/local/bin/matterbridge-mute-toggle
|
||||
```
|
||||
|
||||
141
ROOM_CODE_IMAGE_FEATURE.md
Normal file
141
ROOM_CODE_IMAGE_FEATURE.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# Room Code Image Feature
|
||||
|
||||
## Overview
|
||||
|
||||
The bot now supports displaying Jackbox room codes as images in Kosmi chat (with fallback to IRC text formatting for IRC chat).
|
||||
|
||||
## How It Works
|
||||
|
||||
### For Kosmi Chat (with `EnableRoomCodeImage=true`)
|
||||
|
||||
1. **Generate**: When a new game is added, the bot generates a PNG image of the room code using a monospace font (black background, white text)
|
||||
2. **Upload**: The image is uploaded to Kosmi's CDN at `https://img.kosmi.io/`
|
||||
3. **Broadcast**: The bot sends two messages:
|
||||
- First: The game announcement (e.g., "🎮 Coming up next: Drawful 2!")
|
||||
- Second: The image URL (Kosmi automatically displays it as a thumbnail)
|
||||
|
||||
### For IRC Chat (always)
|
||||
|
||||
Room codes are displayed with IRC formatting:
|
||||
- **Bold** (`\x02`)
|
||||
- **Monospace** (`\x11`)
|
||||
- **Reset** (`\x0F`)
|
||||
|
||||
Example: `\x02\x11ROOM42\x0F` displays as **`ROOM42`** in IRC clients
|
||||
|
||||
### Fallback Behavior
|
||||
|
||||
If image generation or upload fails:
|
||||
- The bot falls back to IRC text formatting for all chats
|
||||
- An error is logged but the announcement still goes through
|
||||
|
||||
## Configuration
|
||||
|
||||
In `matterbridge.toml`:
|
||||
|
||||
```toml
|
||||
[jackbox]
|
||||
Enabled=true
|
||||
APIURL="https://your-jackbox-api.com"
|
||||
AdminPassword="your-password"
|
||||
UseWebSocket=true
|
||||
EnableRoomCodeImage=false # Set to true to enable image uploads
|
||||
```
|
||||
|
||||
## Files Involved
|
||||
|
||||
### New Files
|
||||
- `bridge/jackbox/roomcode_image.go` - PNG image generation
|
||||
- `bridge/jackbox/image_upload.go` - HTTP upload to Kosmi CDN
|
||||
- `bridge/irc/formatting.go` - IRC formatting helpers
|
||||
- `bridge/kosmi/image_upload.go` - (Duplicate, can be removed)
|
||||
- `KOSMI_IMAGE_UPLOAD.md` - Protocol documentation
|
||||
|
||||
### Modified Files
|
||||
- `bridge/jackbox/websocket_client.go` - Image upload integration
|
||||
- `bridge/jackbox/manager.go` - Config passing
|
||||
- `matterbridge.toml` - Added `EnableRoomCodeImage` setting
|
||||
|
||||
### Test Files
|
||||
- `cmd/test-roomcode-image/main.go` - Test image generation
|
||||
- `cmd/test-image-upload/main.go` - Test full upload flow
|
||||
|
||||
## Testing
|
||||
|
||||
### Test Image Generation
|
||||
```bash
|
||||
./test-roomcode-image
|
||||
# Generates: roomcode_ABCD.png, roomcode_TEST.png, etc.
|
||||
```
|
||||
|
||||
### Test Image Upload
|
||||
```bash
|
||||
./test-image-upload
|
||||
# Generates image, uploads to Kosmi CDN, displays URL
|
||||
```
|
||||
|
||||
### Test Full Integration
|
||||
1. Set `EnableRoomCodeImage=true` in `matterbridge.toml`
|
||||
2. Start the bot: `./matterbridge -conf matterbridge.toml`
|
||||
3. Add a game in the Jackbox Picker with a room code
|
||||
4. Verify:
|
||||
- Kosmi chat shows the game announcement + image thumbnail
|
||||
- IRC chat shows the game announcement + formatted room code text
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Image Specifications
|
||||
- Format: PNG
|
||||
- Size: 200x80 pixels
|
||||
- Background: Black (`#000000`)
|
||||
- Text: White (`#FFFFFF`)
|
||||
- Font: `basicfont.Face7x13` (monospace)
|
||||
- Typical file size: ~400-500 bytes
|
||||
|
||||
### Upload Endpoint
|
||||
- URL: `https://img.kosmi.io/`
|
||||
- Method: `POST`
|
||||
- Content-Type: `multipart/form-data`
|
||||
- Authentication: None required (CORS-protected)
|
||||
- Response: `{"filename": "uuid.webp"}`
|
||||
- Full URL: `https://img.kosmi.io/{filename}`
|
||||
|
||||
### Performance
|
||||
- Image generation: <1ms
|
||||
- Image upload: ~300-600ms (network dependent)
|
||||
- Total delay: Minimal, upload happens asynchronously
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements:
|
||||
1. **Custom fonts**: Use a better monospace font (requires embedding TTF)
|
||||
2. **Styling**: Add Jackbox branding, colors, or borders
|
||||
3. **Caching**: Cache uploaded images to avoid re-uploading identical room codes
|
||||
4. **Retry logic**: Add retry mechanism for failed uploads
|
||||
5. **Compression**: Optimize PNG compression for smaller file sizes
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Images not appearing in Kosmi
|
||||
- Check that `EnableRoomCodeImage=true` in config
|
||||
- Check logs for upload errors
|
||||
- Verify network connectivity to `https://img.kosmi.io/`
|
||||
- Test manually with `./test-image-upload`
|
||||
|
||||
### IRC formatting not working
|
||||
- Ensure your IRC client supports formatting codes
|
||||
- Some clients require enabling "Show colors/formatting"
|
||||
- Fallback: The room code is still readable without formatting
|
||||
|
||||
### Build errors
|
||||
- Ensure all dependencies are installed: `go mod tidy`
|
||||
- Check Go version: Requires Go 1.19+
|
||||
- Verify `golang.org/x/image` is available
|
||||
|
||||
## References
|
||||
|
||||
- [Kosmi Image Upload Protocol](KOSMI_IMAGE_UPLOAD.md)
|
||||
- [IRC Formatting Codes](https://modern.ircdocs.horse/formatting.html)
|
||||
- [Go image package](https://pkg.go.dev/image)
|
||||
- [Jackbox Integration](JACKBOX_INTEGRATION.md)
|
||||
|
||||
106
ROOM_CODE_IMAGE_STATUS.md
Normal file
106
ROOM_CODE_IMAGE_STATUS.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# Room Code Image Upload - Implementation Status
|
||||
|
||||
## Phase 1: Research (IN PROGRESS)
|
||||
|
||||
### 1.1 Enhanced WebSocket Monitor ✅ COMPLETE
|
||||
- Added binary WebSocket frame detection
|
||||
- Added HTTP request/response logging
|
||||
- Added file chooser event handling
|
||||
- Added automatic file upload triggering (with manual fallback)
|
||||
- Logs to both console and `image-upload-capture.log`
|
||||
|
||||
### 1.2 Capture Real Upload ⏳ WAITING FOR USER
|
||||
**Action Required:** Run the monitor and upload an image
|
||||
|
||||
```bash
|
||||
cd /Users/erikfredericks/dev-ai/HSO/irc-kosmi-relay
|
||||
./monitor-ws
|
||||
```
|
||||
|
||||
Then manually upload `blurt.jpg` in the Kosmi chat interface.
|
||||
|
||||
**What We're Looking For:**
|
||||
1. HTTP endpoint for image upload (POST/PUT request)
|
||||
2. Request format (multipart/form-data, JSON, binary)
|
||||
3. Required headers (Authorization, Content-Type, etc.)
|
||||
4. Response format (JSON with URL?)
|
||||
5. OR: GraphQL mutation for file upload via WebSocket
|
||||
|
||||
### 1.3 Document Findings ⏳ PENDING
|
||||
Once upload is captured, create `KOSMI_IMAGE_UPLOAD.md` with:
|
||||
- Upload method (HTTP vs WebSocket)
|
||||
- Endpoint URL
|
||||
- Authentication requirements
|
||||
- Request/response format
|
||||
- Go implementation strategy
|
||||
|
||||
## Phase 2: Image Generation (READY TO IMPLEMENT)
|
||||
|
||||
Can proceed independently of Phase 1 research.
|
||||
|
||||
### 2.1 Room Code Image Generator
|
||||
Create `bridge/jackbox/roomcode_image.go`:
|
||||
- Black background (RGB 0,0,0)
|
||||
- White monospace text
|
||||
- 200x100 pixels
|
||||
- Best-fit font size with padding
|
||||
- Centered text
|
||||
- Returns PNG bytes
|
||||
|
||||
### 2.2 Configuration
|
||||
Add to `matterbridge.toml`:
|
||||
```toml
|
||||
EnableRoomCodeImage=false
|
||||
```
|
||||
|
||||
## Phase 3: Kosmi Upload Client (BLOCKED)
|
||||
|
||||
Waiting for Phase 1.2 findings.
|
||||
|
||||
## Phase 4: IRC Formatting (READY TO IMPLEMENT)
|
||||
|
||||
Can proceed independently.
|
||||
|
||||
### 4.1 IRC Formatting Helper
|
||||
Create `bridge/irc/formatting.go`:
|
||||
- `FormatRoomCode(code string) string`
|
||||
- Returns `"\x02\x11" + code + "\x0F"`
|
||||
- IRC codes: 0x02=bold, 0x11=monospace, 0x0F=reset
|
||||
|
||||
## Phase 5: Integration (BLOCKED)
|
||||
|
||||
Waiting for Phases 2, 3, and 4.
|
||||
|
||||
## Phase 6: Testing (BLOCKED)
|
||||
|
||||
Waiting for Phase 5.
|
||||
|
||||
## Current Blockers
|
||||
|
||||
1. **Phase 1.2** - Need user to run monitor and capture image upload
|
||||
2. **Phase 1.3** - Need to analyze captured traffic and document findings
|
||||
3. **Phase 3** - Need Phase 1 findings to implement upload client
|
||||
|
||||
## Can Proceed Now
|
||||
|
||||
- Phase 2 (Image Generation) - Independent task
|
||||
- Phase 4 (IRC Formatting) - Independent task
|
||||
|
||||
## Next Steps
|
||||
|
||||
**Immediate (User Action):**
|
||||
1. Run `./monitor-ws`
|
||||
2. Upload `blurt.jpg` in Kosmi chat
|
||||
3. Press Ctrl+C when done
|
||||
4. Share `image-upload-capture.log` contents
|
||||
|
||||
**After Capture:**
|
||||
1. Analyze log file
|
||||
2. Document upload protocol
|
||||
3. Implement upload client
|
||||
4. Continue with integration
|
||||
|
||||
**Meanwhile (Can Start Now):**
|
||||
1. Implement image generation (Phase 2)
|
||||
2. Implement IRC formatting (Phase 4)
|
||||
|
||||
273
ROOM_CODE_IMPLEMENTATION_SUMMARY.md
Normal file
273
ROOM_CODE_IMPLEMENTATION_SUMMARY.md
Normal file
@@ -0,0 +1,273 @@
|
||||
# Room Code Implementation Summary
|
||||
|
||||
## Completed Work
|
||||
|
||||
### ✅ Phase 1.1: Enhanced WebSocket Monitor
|
||||
**File:** `cmd/monitor-ws/main.go`
|
||||
|
||||
**Features Added:**
|
||||
- Binary WebSocket frame detection
|
||||
- HTTP request/response logging with JSON pretty-printing
|
||||
- File chooser event handling
|
||||
- Automatic file upload triggering (with manual fallback)
|
||||
- Dual logging (console + `image-upload-capture.log`)
|
||||
|
||||
**How to Use:**
|
||||
```bash
|
||||
cd /Users/erikfredericks/dev-ai/HSO/irc-kosmi-relay
|
||||
./monitor-ws
|
||||
```
|
||||
|
||||
Then manually upload `blurt.jpg` in the Kosmi chat interface.
|
||||
|
||||
**Documentation:** See `cmd/monitor-ws/README.md`
|
||||
|
||||
### ✅ Phase 2: Image Generation
|
||||
**File:** `bridge/jackbox/roomcode_image.go`
|
||||
|
||||
**Implementation:**
|
||||
- Generates 200x100 pixel PNG images
|
||||
- Black background (RGB 0,0,0)
|
||||
- White monospace text (basicfont.Face7x13)
|
||||
- Centered text
|
||||
- Returns PNG bytes ready for upload
|
||||
|
||||
**Test Script:** `cmd/test-roomcode-image/main.go`
|
||||
|
||||
**Test Results:**
|
||||
```
|
||||
✅ Generated roomcode_ABCD.png (429 bytes)
|
||||
✅ Generated roomcode_XYZ123.png (474 bytes)
|
||||
✅ Generated roomcode_TEST.png (414 bytes)
|
||||
✅ Generated roomcode_ROOM42.png (459 bytes)
|
||||
```
|
||||
|
||||
**Sample images created in project root for verification.**
|
||||
|
||||
### ✅ Phase 4: IRC Formatting
|
||||
**File:** `bridge/irc/formatting.go`
|
||||
|
||||
**Implementation:**
|
||||
```go
|
||||
func FormatRoomCode(roomCode string) string {
|
||||
return "\x02\x11" + roomCode + "\x0F"
|
||||
}
|
||||
```
|
||||
|
||||
**IRC Control Codes:**
|
||||
- `\x02` = Bold
|
||||
- `\x11` = Monospace/fixed-width font
|
||||
- `\x0F` = Reset all formatting
|
||||
|
||||
**Example Output:**
|
||||
- Input: `"ABCD"`
|
||||
- Output: `"\x02\x11ABCD\x0F"` (renders as **`ABCD`** in monospace in IRC clients)
|
||||
|
||||
### ✅ Configuration Added
|
||||
**File:** `matterbridge.toml`
|
||||
|
||||
```toml
|
||||
# Enable room code image upload for Kosmi chat
|
||||
# When enabled, generates a PNG image of the room code and attempts to upload it
|
||||
# Falls back to plain text if upload fails or is not supported
|
||||
# Note: Requires image upload protocol research to be completed
|
||||
EnableRoomCodeImage=false
|
||||
```
|
||||
|
||||
## Pending Work (Blocked)
|
||||
|
||||
### ⏳ Phase 1.2: Capture Real Upload
|
||||
**Status:** WAITING FOR USER ACTION
|
||||
|
||||
**Required:**
|
||||
1. Run `./monitor-ws`
|
||||
2. Upload `blurt.jpg` in Kosmi chat
|
||||
3. Press Ctrl+C when done
|
||||
4. Analyze `image-upload-capture.log`
|
||||
|
||||
**What We're Looking For:**
|
||||
- HTTP endpoint for image upload (POST/PUT)
|
||||
- Request format (multipart/form-data, JSON, binary)
|
||||
- Required headers (Authorization, Content-Type)
|
||||
- Response format (JSON with URL?)
|
||||
- OR: GraphQL mutation for file upload
|
||||
|
||||
### ⏳ Phase 1.3: Document Findings
|
||||
**Status:** BLOCKED (waiting for Phase 1.2)
|
||||
|
||||
Will create `KOSMI_IMAGE_UPLOAD.md` with:
|
||||
- Upload method (HTTP vs WebSocket)
|
||||
- Endpoint URL and authentication
|
||||
- Request/response format
|
||||
- Go implementation strategy
|
||||
|
||||
### ⏳ Phase 3: Kosmi Upload Client
|
||||
**Status:** BLOCKED (waiting for Phase 1.3)
|
||||
|
||||
**To Implement:**
|
||||
- `bridge/kosmi/graphql_ws_client.go`:
|
||||
- `UploadImage(imageData []byte, filename string) (string, error)`
|
||||
- `SendMessageWithImage(text string, imageURL string) error`
|
||||
|
||||
### ⏳ Phase 5: Integration
|
||||
**Status:** BLOCKED (waiting for Phase 3)
|
||||
|
||||
**To Implement:**
|
||||
- Update `bridge/jackbox/websocket_client.go` `handleGameAdded()`
|
||||
- Update `gateway/router.go` `broadcastJackboxMessage()`
|
||||
- Protocol-specific message formatting:
|
||||
- **IRC:** Use `FormatRoomCode()` for bold+monospace
|
||||
- **Kosmi with image:** Upload image, send URL
|
||||
- **Kosmi fallback:** Plain text (no formatting)
|
||||
|
||||
### ⏳ Phase 6: Testing
|
||||
**Status:** BLOCKED (waiting for Phase 5)
|
||||
|
||||
**Test Plan:**
|
||||
1. Generate test images (✅ already tested)
|
||||
2. Test Kosmi upload (if protocol discovered)
|
||||
3. Test IRC formatting in real IRC client
|
||||
4. Integration test with both Kosmi and IRC
|
||||
5. Test mute functionality (should suppress both)
|
||||
|
||||
## Next Steps
|
||||
|
||||
### Immediate (User Action Required)
|
||||
1. **Run the monitor:** `./monitor-ws`
|
||||
2. **Upload test image:** Use `blurt.jpg` in Kosmi chat
|
||||
3. **Stop monitoring:** Press Ctrl+C
|
||||
4. **Share log:** Provide `image-upload-capture.log` contents
|
||||
|
||||
### After Upload Capture
|
||||
1. Analyze captured traffic
|
||||
2. Document upload protocol in `KOSMI_IMAGE_UPLOAD.md`
|
||||
3. Implement upload client
|
||||
4. Integrate into message broadcast
|
||||
5. Test complete flow
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
### New Files
|
||||
- `cmd/monitor-ws/main.go` (enhanced)
|
||||
- `cmd/monitor-ws/README.md`
|
||||
- `bridge/jackbox/roomcode_image.go`
|
||||
- `cmd/test-roomcode-image/main.go`
|
||||
- `bridge/irc/formatting.go`
|
||||
- `ROOM_CODE_IMAGE_STATUS.md`
|
||||
- `ROOM_CODE_IMPLEMENTATION_SUMMARY.md` (this file)
|
||||
|
||||
### Modified Files
|
||||
- `matterbridge.toml` (added `EnableRoomCodeImage` config)
|
||||
|
||||
### Generated Test Files
|
||||
- `roomcode_ABCD.png`
|
||||
- `roomcode_XYZ123.png`
|
||||
- `roomcode_TEST.png`
|
||||
- `roomcode_ROOM42.png`
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Jackbox API Event │
|
||||
│ (game.added with room_code) │
|
||||
└────────────────────────┬────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ gateway/router.go: broadcastJackboxMessage() │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────────┐ │
|
||||
│ │ For each bridge, format message based on protocol: │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
└────────────┬─────────────────────────┬──────────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌────────────────┐ ┌────────────────┐
|
||||
│ IRC Bridge │ │ Kosmi Bridge │
|
||||
└────────────────┘ └────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌────────────────┐ ┌────────────────┐
|
||||
│ FormatRoomCode │ │ If image enabled│
|
||||
│ (bold + │ │ ┌─────────────┐│
|
||||
│ monospace) │ │ │Generate PNG ││
|
||||
│ │ │ └──────┬──────┘│
|
||||
│ "\x02\x11CODE │ │ ▼ │
|
||||
│ \x0F" │ │ ┌─────────────┐│
|
||||
└────────────────┘ │ │Upload Image ││
|
||||
│ └──────┬──────┘│
|
||||
│ ▼ │
|
||||
│ ┌─────────────┐│
|
||||
│ │Get URL ││
|
||||
│ └──────┬──────┘│
|
||||
│ ▼ │
|
||||
│ Send URL │
|
||||
│ (or fallback │
|
||||
│ to text) │
|
||||
└────────────────┘
|
||||
```
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
1. **Image Generation:** Using Go's built-in `image` package with `basicfont.Face7x13` for simplicity and no external dependencies.
|
||||
|
||||
2. **IRC Formatting:** Using standard IRC control codes (`\x02\x11...\x0F`) which are widely supported.
|
||||
|
||||
3. **Fallback Strategy:** Always have plain text fallback if image upload fails or is disabled.
|
||||
|
||||
4. **Protocol-Specific Formatting:** Messages are formatted differently for each protocol at the router level, ensuring each bridge gets the appropriate format.
|
||||
|
||||
5. **Configuration:** Image upload is disabled by default (`EnableRoomCodeImage=false`) until the upload protocol is fully implemented and tested.
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- **Image Generation:** < 1ms per image (tested with 4 samples)
|
||||
- **Image Size:** ~400-500 bytes per PNG (very small)
|
||||
- **Upload Timeout:** Will need to implement timeout (suggested 3000ms max)
|
||||
- **Caching:** Could cache generated images if same room code is reused
|
||||
- **Async Upload:** Upload should be non-blocking to avoid delaying message broadcast
|
||||
|
||||
## Testing Status
|
||||
|
||||
- ✅ Image generation tested and working
|
||||
- ✅ IRC formatting implemented (not yet tested in real IRC client)
|
||||
- ⏳ Kosmi upload pending (protocol research required)
|
||||
- ⏳ Integration testing pending
|
||||
- ⏳ Mute functionality testing pending
|
||||
|
||||
## Documentation
|
||||
|
||||
- `cmd/monitor-ws/README.md` - How to run the monitor
|
||||
- `ROOM_CODE_IMAGE_STATUS.md` - Current status and blockers
|
||||
- `ROOM_CODE_IMPLEMENTATION_SUMMARY.md` - This file
|
||||
- `KOSMI_IMAGE_UPLOAD.md` - To be created after capture
|
||||
|
||||
## Questions/Decisions Needed
|
||||
|
||||
1. **Upload Protocol:** Waiting for capture to determine if it's HTTP multipart, GraphQL mutation, or something else.
|
||||
|
||||
2. **Error Handling:** How should we handle upload failures? (Currently: fallback to plain text)
|
||||
|
||||
3. **Rate Limiting:** Should we limit image uploads per time period?
|
||||
|
||||
4. **Caching:** Should we cache generated images for repeated room codes?
|
||||
|
||||
5. **Image Customization:** Should image size/colors be configurable?
|
||||
|
||||
## Current Blockers Summary
|
||||
|
||||
**Critical Path:**
|
||||
1. User runs monitor → 2. Capture upload → 3. Document protocol → 4. Implement upload → 5. Integrate → 6. Test
|
||||
|
||||
**Currently At:** Step 1 (waiting for user to run monitor)
|
||||
|
||||
**Can Proceed Independently:**
|
||||
- None (all remaining work depends on upload protocol research)
|
||||
|
||||
**Completed:**
|
||||
- Image generation ✅
|
||||
- IRC formatting ✅
|
||||
- Monitor tool ✅
|
||||
- Configuration ✅
|
||||
|
||||
192
WEBSOCKET_EVENT_FLOW.md
Normal file
192
WEBSOCKET_EVENT_FLOW.md
Normal file
@@ -0,0 +1,192 @@
|
||||
# WebSocket Event Flow - Jackbox Integration
|
||||
|
||||
## Event Broadcasting Behavior
|
||||
|
||||
| Event | Broadcast To | Requires Subscription? |
|
||||
|-------|--------------|------------------------|
|
||||
| `session.started` | **All authenticated clients** | ❌ NO |
|
||||
| `game.added` | Session subscribers only | ✅ YES |
|
||||
| `session.ended` | Session subscribers only | ✅ YES |
|
||||
|
||||
## Bot Connection Flow
|
||||
|
||||
```
|
||||
1. Connect to WebSocket
|
||||
↓
|
||||
2. Send auth message with JWT token
|
||||
↓
|
||||
3. Receive auth_success
|
||||
↓
|
||||
4. Wait for session.started (automatic broadcast)
|
||||
↓
|
||||
5. When session.started received:
|
||||
- Subscribe to that specific session ID
|
||||
- Announce "Game Night is starting!"
|
||||
↓
|
||||
6. Receive game.added events (for subscribed session)
|
||||
- Announce new games
|
||||
↓
|
||||
7. Receive session.ended event (for subscribed session)
|
||||
- Announce final votes + goodnight
|
||||
```
|
||||
|
||||
## Message Formats
|
||||
|
||||
### 1. Authentication
|
||||
**Bot → Server:**
|
||||
```json
|
||||
{
|
||||
"type": "auth",
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
}
|
||||
```
|
||||
|
||||
**Server → Bot:**
|
||||
```json
|
||||
{
|
||||
"type": "auth_success",
|
||||
"message": "Authenticated successfully"
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Session Started (Automatic Broadcast)
|
||||
**Server → Bot:**
|
||||
```json
|
||||
{
|
||||
"type": "session.started",
|
||||
"timestamp": "2025-11-01T03:24:30Z",
|
||||
"data": {
|
||||
"session": {
|
||||
"id": 21,
|
||||
"is_active": 1,
|
||||
"created_at": "2025-11-01 07:24:30",
|
||||
"notes": null
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Bot Action:**
|
||||
- Automatically subscribe to this session
|
||||
- Announce "🎮 Game Night is starting! Session #21"
|
||||
|
||||
### 3. Subscribe to Session
|
||||
**Bot → Server:**
|
||||
```json
|
||||
{
|
||||
"type": "subscribe",
|
||||
"sessionId": 21
|
||||
}
|
||||
```
|
||||
|
||||
**Server → Bot:**
|
||||
```json
|
||||
{
|
||||
"type": "subscribed",
|
||||
"message": "Subscribed to session 21"
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Game Added (Requires Subscription)
|
||||
**Server → Bot:**
|
||||
```json
|
||||
{
|
||||
"type": "game.added",
|
||||
"timestamp": "2025-11-01T03:25:00Z",
|
||||
"data": {
|
||||
"session": {
|
||||
"id": 21,
|
||||
"is_active": true,
|
||||
"games_played": 1
|
||||
},
|
||||
"game": {
|
||||
"id": 42,
|
||||
"title": "Drawful 2",
|
||||
"pack_name": "Drawful 2",
|
||||
"min_players": 3,
|
||||
"max_players": 8,
|
||||
"manually_added": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Bot Action:**
|
||||
- Announce previous game's votes (if any)
|
||||
- Announce "🎮 Coming up next: Drawful 2!"
|
||||
|
||||
### 5. Session Ended (Requires Subscription)
|
||||
**Server → Bot:**
|
||||
```json
|
||||
{
|
||||
"type": "session.ended",
|
||||
"timestamp": "2025-11-01T04:30:00Z",
|
||||
"data": {
|
||||
"session": {
|
||||
"id": 21,
|
||||
"is_active": 0,
|
||||
"games_played": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Bot Action:**
|
||||
- Announce final game's votes (if any)
|
||||
- Announce "🌙 Game Night has ended! Thanks for playing!"
|
||||
- Clear active session (reset to time-based vote debouncing)
|
||||
|
||||
## Fallback Polling
|
||||
|
||||
If WebSocket events are missed, the bot has a fallback polling mechanism (every 30 seconds):
|
||||
|
||||
- **Detects new sessions** via HTTP GET `/api/sessions/active`
|
||||
- **Detects ended sessions** when active session becomes null/inactive
|
||||
- **Logs warnings** when polling detects changes (indicates missed WebSocket events)
|
||||
|
||||
Example warning logs:
|
||||
```
|
||||
WARN Found active session 21 via polling (session.started event may have been missed)
|
||||
WARN Active session ended (detected via polling, session.ended event may have been missed)
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Expected Logs (Happy Path)
|
||||
```
|
||||
INFO Authentication successful
|
||||
INFO Session started: ID=21
|
||||
INFO Subscribed to session 21
|
||||
INFO Active session set to 21
|
||||
INFO Broadcasting Jackbox message: 🎮 Game Night is starting! Session #21
|
||||
INFO Subscription confirmed: Subscribed to session 21
|
||||
INFO Game added: Drawful 2 from Drawful 2
|
||||
INFO Broadcasting Jackbox message: 🎮 Coming up next: Drawful 2!
|
||||
INFO Session ended event received
|
||||
INFO Broadcasting Jackbox message: 🌙 Game Night has ended! Thanks for playing!
|
||||
```
|
||||
|
||||
### Expected Logs (Fallback Polling)
|
||||
```
|
||||
INFO Authentication successful
|
||||
WARN Found active session 21 via polling (session.started event may have been missed), subscribing...
|
||||
INFO Subscribed to session 21
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Bot doesn't receive `session.started`
|
||||
- Check backend logs: Should say "Broadcasted session.started to X client(s)"
|
||||
- Check bot is authenticated: Look for "Authentication successful" in logs
|
||||
- Check WebSocket connection: Should say "WebSocket connected"
|
||||
|
||||
### Bot doesn't receive `game.added` or `session.ended`
|
||||
- Check bot subscribed to session: Look for "Subscribed to session X" in logs
|
||||
- Check backend logs: Should say "Broadcasted game.added to X client(s) for session Y"
|
||||
- If X = 0, bot didn't subscribe properly
|
||||
|
||||
### Polling warnings appear
|
||||
- This means WebSocket events aren't being received
|
||||
- Check your backend is calling `broadcastToAll()` for `session.started`
|
||||
- Check your backend is calling `broadcastEvent(sessionId)` for `game.added` and `session.ended`
|
||||
|
||||
15
bridge/irc/formatting.go
Normal file
15
bridge/irc/formatting.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package birc
|
||||
|
||||
// IRC formatting control codes
|
||||
const (
|
||||
IRCBold = "\x02" // Bold
|
||||
IRCMonospace = "\x11" // Monospace/fixed-width font
|
||||
IRCReset = "\x0F" // Reset all formatting
|
||||
)
|
||||
|
||||
// FormatRoomCode formats a room code with IRC bold and monospace formatting
|
||||
// Returns the code wrapped in bold + monospace with a reset at the end
|
||||
func FormatRoomCode(roomCode string) string {
|
||||
return IRCBold + IRCMonospace + roomCode + IRCReset
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
"github.com/42wim/matterbridge/bridge/helper"
|
||||
"github.com/42wim/matterbridge/bridge/jackbox"
|
||||
"github.com/lrstanley/girc"
|
||||
"github.com/paulrosania/go-charset/charset"
|
||||
"github.com/saintfish/chardet"
|
||||
@@ -251,6 +252,23 @@ func (b *Birc) handlePrivMsg(client *girc.Client, event girc.Event) {
|
||||
rmsg.Text = string(output)
|
||||
}
|
||||
|
||||
// Check for votes (thisgame++ or thisgame--)
|
||||
// Only process votes from non-relayed messages
|
||||
if !jackbox.IsRelayedMessage(rmsg.Text) {
|
||||
if isVote, voteType := jackbox.DetectVote(rmsg.Text); isVote {
|
||||
b.Log.Debugf("Detected vote from %s: %s", event.Source.Name, voteType)
|
||||
if b.jackboxClient != nil {
|
||||
go func() {
|
||||
// Use current time as timestamp for IRC messages
|
||||
timestamp := time.Now()
|
||||
if err := b.jackboxClient.SendVote(event.Source.Name, voteType, timestamp); err != nil {
|
||||
b.Log.Errorf("Failed to send vote to Jackbox API: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
b.Log.Debugf("<= Sending message from %s on %s to gateway", event.Params[0], b.Account)
|
||||
b.Remote <- rmsg
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/42wim/matterbridge/bridge"
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
"github.com/42wim/matterbridge/bridge/helper"
|
||||
"github.com/42wim/matterbridge/bridge/jackbox"
|
||||
"github.com/lrstanley/girc"
|
||||
stripmd "github.com/writeas/go-strip-markdown"
|
||||
|
||||
@@ -32,6 +33,7 @@ type Birc struct {
|
||||
FirstConnection, authDone bool
|
||||
MessageDelay, MessageQueue, MessageLength int
|
||||
channels map[string]bool
|
||||
jackboxClient *jackbox.Client
|
||||
|
||||
*bridge.Config
|
||||
}
|
||||
@@ -413,3 +415,9 @@ func (b *Birc) getTLSConfig() (*tls.Config, error) {
|
||||
|
||||
return tlsConfig, nil
|
||||
}
|
||||
|
||||
// SetJackboxClient sets the Jackbox API client for this bridge
|
||||
func (b *Birc) SetJackboxClient(client *jackbox.Client) {
|
||||
b.jackboxClient = client
|
||||
b.Log.Info("Jackbox client injected into IRC bridge")
|
||||
}
|
||||
|
||||
BIN
bridge/jackbox/FiraMono-Bold.ttf
Normal file
BIN
bridge/jackbox/FiraMono-Bold.ttf
Normal file
Binary file not shown.
399
bridge/jackbox/client.go
Normal file
399
bridge/jackbox/client.go
Normal file
@@ -0,0 +1,399 @@
|
||||
package jackbox
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Client handles communication with the Jackbox Game Picker API
|
||||
type Client struct {
|
||||
apiURL string
|
||||
adminPassword string
|
||||
token string
|
||||
tokenExpiry time.Time
|
||||
mu sync.RWMutex
|
||||
log *logrus.Entry
|
||||
httpClient *http.Client
|
||||
messageCallback func(string)
|
||||
|
||||
// Vote tracking
|
||||
activeSessionID int
|
||||
lastVoteResponse *VoteResponse
|
||||
voteDebounceTimer *time.Timer
|
||||
voteDebounceDelay time.Duration
|
||||
}
|
||||
|
||||
// AuthResponse represents the authentication response from the API
|
||||
type AuthResponse struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// VoteRequest represents a vote submission to the API
|
||||
type VoteRequest struct {
|
||||
Username string `json:"username"`
|
||||
Vote string `json:"vote"` // "up" or "down"
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
// VoteResponse represents the API response to a vote submission
|
||||
type VoteResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Game struct {
|
||||
Title string `json:"title"`
|
||||
Upvotes int `json:"upvotes"`
|
||||
Downvotes int `json:"downvotes"`
|
||||
PopularityScore int `json:"popularity_score"`
|
||||
} `json:"game"`
|
||||
}
|
||||
|
||||
// Session represents an active Jackbox session
|
||||
type Session struct {
|
||||
ID int `json:"id"`
|
||||
IsActive int `json:"is_active"` // API returns 1 for active, 0 for inactive
|
||||
GamesPlayed int `json:"games_played"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// SessionResponse represents the API response for session queries
|
||||
type SessionResponse struct {
|
||||
Session *Session `json:"session"`
|
||||
}
|
||||
|
||||
// NewClient creates a new Jackbox API client
|
||||
func NewClient(apiURL, adminPassword string, log *logrus.Entry) *Client {
|
||||
return &Client{
|
||||
apiURL: apiURL,
|
||||
adminPassword: adminPassword,
|
||||
log: log,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
voteDebounceDelay: 3 * time.Second, // Wait 3 seconds after last vote before broadcasting
|
||||
}
|
||||
}
|
||||
|
||||
// SetMessageCallback sets the callback function for broadcasting messages
|
||||
func (c *Client) SetMessageCallback(callback func(string)) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.messageCallback = callback
|
||||
}
|
||||
|
||||
// SetActiveSession sets the active session ID for vote tracking
|
||||
func (c *Client) SetActiveSession(sessionID int) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.activeSessionID = sessionID
|
||||
c.log.Infof("Active session set to %d", sessionID)
|
||||
}
|
||||
|
||||
// GetAndClearLastVoteResponse returns the last vote response and clears it
|
||||
func (c *Client) GetAndClearLastVoteResponse() *VoteResponse {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
resp := c.lastVoteResponse
|
||||
c.lastVoteResponse = nil
|
||||
|
||||
// Stop any pending debounce timer
|
||||
if c.voteDebounceTimer != nil {
|
||||
c.voteDebounceTimer.Stop()
|
||||
c.voteDebounceTimer = nil
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
// broadcastMessage sends a message via the callback if set
|
||||
func (c *Client) broadcastMessage(message string) {
|
||||
c.mu.RLock()
|
||||
callback := c.messageCallback
|
||||
c.mu.RUnlock()
|
||||
|
||||
if callback != nil {
|
||||
callback(message)
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticate obtains a JWT token from the API using admin password
|
||||
func (c *Client) Authenticate() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.log.Debug("Authenticating with Jackbox API...")
|
||||
|
||||
// Prepare authentication request
|
||||
authReq := map[string]string{
|
||||
"key": c.adminPassword,
|
||||
}
|
||||
|
||||
jsonBody, err := json.Marshal(authReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal auth request: %w", err)
|
||||
}
|
||||
|
||||
// Send authentication request
|
||||
req, err := http.NewRequest("POST", c.apiURL+"/api/auth/login", bytes.NewReader(jsonBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create auth request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send auth request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("authentication failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Parse response
|
||||
var authResp AuthResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&authResp); err != nil {
|
||||
return fmt.Errorf("failed to parse auth response: %w", err)
|
||||
}
|
||||
|
||||
c.token = authResp.Token
|
||||
// Assume token is valid for 24 hours (adjust based on actual API behavior)
|
||||
c.tokenExpiry = time.Now().Add(24 * time.Hour)
|
||||
|
||||
c.log.Info("Successfully authenticated with Jackbox API")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureAuthenticated checks if we have a valid token and authenticates if needed
|
||||
func (c *Client) ensureAuthenticated() error {
|
||||
c.mu.RLock()
|
||||
hasValidToken := c.token != "" && time.Now().Before(c.tokenExpiry)
|
||||
c.mu.RUnlock()
|
||||
|
||||
if hasValidToken {
|
||||
return nil
|
||||
}
|
||||
|
||||
return c.Authenticate()
|
||||
}
|
||||
|
||||
// SendVote sends a vote to the Jackbox API
|
||||
func (c *Client) SendVote(username, voteType string, timestamp time.Time) error {
|
||||
// Ensure we're authenticated
|
||||
if err := c.ensureAuthenticated(); err != nil {
|
||||
return fmt.Errorf("authentication failed: %w", err)
|
||||
}
|
||||
|
||||
c.mu.RLock()
|
||||
token := c.token
|
||||
c.mu.RUnlock()
|
||||
|
||||
// Prepare vote request
|
||||
voteReq := VoteRequest{
|
||||
Username: username,
|
||||
Vote: voteType,
|
||||
Timestamp: timestamp.Format(time.RFC3339),
|
||||
}
|
||||
|
||||
jsonBody, err := json.Marshal(voteReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal vote request: %w", err)
|
||||
}
|
||||
|
||||
// Send vote request
|
||||
req, err := http.NewRequest("POST", c.apiURL+"/api/votes/live", bytes.NewReader(jsonBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create vote request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send vote request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read response body
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read vote response: %w", err)
|
||||
}
|
||||
|
||||
// Check response status
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
// Token expired, try to re-authenticate
|
||||
c.log.Warn("Token expired, re-authenticating...")
|
||||
if err := c.Authenticate(); err != nil {
|
||||
return fmt.Errorf("re-authentication failed: %w", err)
|
||||
}
|
||||
// Retry the vote
|
||||
return c.SendVote(username, voteType, timestamp)
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusConflict {
|
||||
// Duplicate vote - this is expected, just log it
|
||||
c.log.Debugf("Duplicate vote from %s (within 1 second)", username)
|
||||
return nil
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
// No active session or timestamp doesn't match any game
|
||||
c.log.Debug("Vote rejected: no active session or timestamp doesn't match any game")
|
||||
return nil
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("vote failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Parse successful response
|
||||
var voteResp VoteResponse
|
||||
if err := json.Unmarshal(body, &voteResp); err != nil {
|
||||
c.log.Warnf("Failed to parse vote response: %v", err)
|
||||
return nil // Don't fail if we can't parse the response
|
||||
}
|
||||
|
||||
c.log.Debugf("Vote recorded for %s: %s - %d👍 %d👎",
|
||||
voteResp.Game.Title, username, voteResp.Game.Upvotes, voteResp.Game.Downvotes)
|
||||
|
||||
// Debounce vote broadcasts - wait for activity to settle
|
||||
c.debouncedVoteBroadcast(&voteResp)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetToken returns the current JWT token
|
||||
func (c *Client) GetToken() string {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.token
|
||||
}
|
||||
|
||||
// GetActiveSession retrieves the currently active session from the API
|
||||
func (c *Client) GetActiveSession() (*Session, error) {
|
||||
// Ensure we're authenticated
|
||||
if err := c.ensureAuthenticated(); err != nil {
|
||||
return nil, fmt.Errorf("authentication failed: %w", err)
|
||||
}
|
||||
|
||||
c.mu.RLock()
|
||||
token := c.token
|
||||
c.mu.RUnlock()
|
||||
|
||||
// Create request to get active session
|
||||
req, err := http.NewRequest("GET", c.apiURL+"/api/sessions/active", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read body for debugging
|
||||
body, readErr := io.ReadAll(resp.Body)
|
||||
if readErr != nil {
|
||||
return nil, fmt.Errorf("failed to read response body: %w", readErr)
|
||||
}
|
||||
|
||||
c.log.Infof("GetActiveSession: GET %s/api/sessions/active returned %d", c.apiURL, resp.StatusCode)
|
||||
c.log.Infof("GetActiveSession response body: %s", string(body))
|
||||
|
||||
// Handle 404 - no active session
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
c.log.Info("API returned 404 - endpoint may not exist or no active session")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Handle 401 - token expired
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
c.log.Warn("Token expired, re-authenticating...")
|
||||
if err := c.Authenticate(); err != nil {
|
||||
return nil, fmt.Errorf("re-authentication failed: %w", err)
|
||||
}
|
||||
// Retry the request
|
||||
return c.GetActiveSession()
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Try to parse as direct session object first
|
||||
var session Session
|
||||
if err := json.Unmarshal(body, &session); err != nil {
|
||||
c.log.Errorf("Failed to parse session response: %v, body: %s", err, string(body))
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
// Check if we got a valid session (ID > 0 means it's valid)
|
||||
if session.ID == 0 {
|
||||
c.log.Info("No active session (ID is 0)")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
c.log.Infof("Parsed session: ID=%d, IsActive=%v, GamesPlayed=%d", session.ID, session.IsActive, session.GamesPlayed)
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
// debouncedVoteBroadcast implements debouncing for vote broadcasts
|
||||
// When there's an active session, it stores votes to be announced with the next game
|
||||
// When there's no active session, it uses time-based debouncing (3 seconds)
|
||||
func (c *Client) debouncedVoteBroadcast(voteResp *VoteResponse) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
// Store the latest vote response
|
||||
c.lastVoteResponse = voteResp
|
||||
|
||||
// If there's an active session, just accumulate votes silently
|
||||
// They'll be announced when the next game is picked
|
||||
if c.activeSessionID > 0 {
|
||||
c.log.Debugf("Vote accumulated for %s (session active, will announce with next game)", voteResp.Game.Title)
|
||||
// Cancel any existing timer since we're in session mode
|
||||
if c.voteDebounceTimer != nil {
|
||||
c.voteDebounceTimer.Stop()
|
||||
c.voteDebounceTimer = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// No active session - use time-based debouncing
|
||||
// If there's an existing timer, stop it
|
||||
if c.voteDebounceTimer != nil {
|
||||
c.voteDebounceTimer.Stop()
|
||||
}
|
||||
|
||||
// Create a new timer that will fire after the debounce delay
|
||||
c.voteDebounceTimer = time.AfterFunc(c.voteDebounceDelay, func() {
|
||||
c.mu.Lock()
|
||||
lastResp := c.lastVoteResponse
|
||||
c.lastVoteResponse = nil
|
||||
c.mu.Unlock()
|
||||
|
||||
if lastResp != nil {
|
||||
// Broadcast the final vote result
|
||||
message := fmt.Sprintf("🗳️ Voting complete for %s • %d👍 %d👎 (Score: %d)",
|
||||
lastResp.Game.Title,
|
||||
lastResp.Game.Upvotes, lastResp.Game.Downvotes, lastResp.Game.PopularityScore)
|
||||
c.broadcastMessage(message)
|
||||
c.log.Infof("Broadcast final vote result: %s - %d👍 %d👎",
|
||||
lastResp.Game.Title, lastResp.Game.Upvotes, lastResp.Game.Downvotes)
|
||||
}
|
||||
})
|
||||
}
|
||||
111
bridge/jackbox/image_upload.go
Normal file
111
bridge/jackbox/image_upload.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package jackbox
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
kosmiImageUploadURL = "https://img.kosmi.io/"
|
||||
)
|
||||
|
||||
// ImageUploadResponse represents the response from Kosmi image upload endpoint
|
||||
type ImageUploadResponse struct {
|
||||
Filename string `json:"filename"`
|
||||
}
|
||||
|
||||
// UploadImageToKosmi uploads an image to Kosmi's CDN and returns the URL
|
||||
func UploadImageToKosmi(imageData []byte, filename string) (string, error) {
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"filename": filename,
|
||||
"size": len(imageData),
|
||||
}).Debug("Uploading image to Kosmi CDN")
|
||||
|
||||
// Create multipart form body
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
|
||||
// Add file field with proper MIME type for GIF
|
||||
h := make(textproto.MIMEHeader)
|
||||
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file"; filename="%s"`, filename))
|
||||
h.Set("Content-Type", "image/gif")
|
||||
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"Content-Disposition": h.Get("Content-Disposition"),
|
||||
"Content-Type": h.Get("Content-Type"),
|
||||
}).Debug("Creating multipart form with headers")
|
||||
|
||||
part, err := writer.CreatePart(h)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create form file: %w", err)
|
||||
}
|
||||
|
||||
if _, err := part.Write(imageData); err != nil {
|
||||
return "", fmt.Errorf("failed to write image data: %w", err)
|
||||
}
|
||||
|
||||
logrus.Debugf("Written %d bytes of GIF data to multipart form", len(imageData))
|
||||
|
||||
// Close the multipart writer to finalize the body
|
||||
if err := writer.Close(); err != nil {
|
||||
return "", fmt.Errorf("failed to close multipart writer: %w", err)
|
||||
}
|
||||
|
||||
// Create HTTP request
|
||||
req, err := http.NewRequest("POST", kosmiImageUploadURL, body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Set required headers
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("Origin", "https://app.kosmi.io")
|
||||
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 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||
|
||||
// Send request
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check status code
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("upload failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
// Read response body
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
logrus.WithField("response", string(bodyBytes)).Debug("Upload response body")
|
||||
|
||||
// Parse response
|
||||
var result ImageUploadResponse
|
||||
if err := json.Unmarshal(bodyBytes, &result); err != nil {
|
||||
return "", fmt.Errorf("failed to parse response: %w (body: %s)", err, string(bodyBytes))
|
||||
}
|
||||
|
||||
if result.Filename == "" {
|
||||
return "", fmt.Errorf("no filename in response (body: %s)", string(bodyBytes))
|
||||
}
|
||||
|
||||
// Construct the full URL from the filename
|
||||
imageURL := fmt.Sprintf("https://img.kosmi.io/%s", result.Filename)
|
||||
|
||||
logrus.WithField("url", imageURL).Info("Successfully uploaded image to Kosmi CDN")
|
||||
return imageURL, nil
|
||||
}
|
||||
|
||||
277
bridge/jackbox/manager.go
Normal file
277
bridge/jackbox/manager.go
Normal file
@@ -0,0 +1,277 @@
|
||||
package jackbox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Manager handles the Jackbox integration lifecycle
|
||||
type Manager struct {
|
||||
client *Client
|
||||
webhookServer *WebhookServer
|
||||
wsClient *WebSocketClient
|
||||
config config.Config
|
||||
log *logrus.Entry
|
||||
enabled bool
|
||||
useWebSocket bool
|
||||
messageCallback func(string)
|
||||
muted bool
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewManager creates a new Jackbox manager
|
||||
func NewManager(cfg config.Config, log *logrus.Entry) *Manager {
|
||||
return &Manager{
|
||||
config: cfg,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize sets up the Jackbox client and webhook server or WebSocket client
|
||||
func (m *Manager) Initialize() error {
|
||||
// Check if Jackbox integration is enabled
|
||||
m.enabled = m.config.Viper().GetBool("jackbox.Enabled")
|
||||
if !m.enabled {
|
||||
m.log.Info("Jackbox integration is disabled")
|
||||
return nil
|
||||
}
|
||||
|
||||
m.log.Info("Initializing Jackbox integration...")
|
||||
|
||||
// Get configuration values
|
||||
apiURL := m.config.Viper().GetString("jackbox.APIURL")
|
||||
adminPassword := m.config.Viper().GetString("jackbox.AdminPassword")
|
||||
m.useWebSocket = m.config.Viper().GetBool("jackbox.UseWebSocket")
|
||||
|
||||
// Validate configuration
|
||||
if apiURL == "" {
|
||||
return fmt.Errorf("jackbox.APIURL is required when Jackbox integration is enabled")
|
||||
}
|
||||
if adminPassword == "" {
|
||||
return fmt.Errorf("jackbox.AdminPassword is required when Jackbox integration is enabled")
|
||||
}
|
||||
|
||||
// Create Jackbox API client
|
||||
m.client = NewClient(apiURL, adminPassword, m.log)
|
||||
|
||||
// Authenticate with the API
|
||||
if err := m.client.Authenticate(); err != nil {
|
||||
return fmt.Errorf("failed to authenticate with Jackbox API: %w", err)
|
||||
}
|
||||
|
||||
m.log.Info("Jackbox integration initialized successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartWebhookServer starts the webhook server with the provided message callback
|
||||
func (m *Manager) StartWebhookServer(messageCallback func(string)) error {
|
||||
if !m.enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Use WebSocket if enabled, otherwise fall back to webhook
|
||||
if m.useWebSocket {
|
||||
return m.startWebSocketClient(messageCallback)
|
||||
}
|
||||
|
||||
webhookPort := m.config.Viper().GetInt("jackbox.WebhookPort")
|
||||
webhookSecret := m.config.Viper().GetString("jackbox.WebhookSecret")
|
||||
|
||||
if webhookSecret == "" {
|
||||
return fmt.Errorf("jackbox.WebhookSecret is required when using webhooks")
|
||||
}
|
||||
if webhookPort == 0 {
|
||||
webhookPort = 3001
|
||||
}
|
||||
|
||||
// Wrap the callback to check mute status
|
||||
wrappedCallback := func(message string) {
|
||||
if m.IsMuted() {
|
||||
m.log.Debugf("Jackbox message suppressed (muted): %s", message)
|
||||
return
|
||||
}
|
||||
messageCallback(message)
|
||||
}
|
||||
|
||||
m.webhookServer = NewWebhookServer(webhookPort, webhookSecret, wrappedCallback, m.log)
|
||||
return m.webhookServer.Start()
|
||||
}
|
||||
|
||||
// startWebSocketClient starts the WebSocket client connection
|
||||
func (m *Manager) startWebSocketClient(messageCallback func(string)) error {
|
||||
apiURL := m.config.Viper().GetString("jackbox.APIURL")
|
||||
|
||||
// Store the callback for use in monitoring
|
||||
m.messageCallback = messageCallback
|
||||
|
||||
// Wrap the callback to check mute status
|
||||
wrappedCallback := func(message string) {
|
||||
if m.IsMuted() {
|
||||
m.log.Debugf("Jackbox message suppressed (muted): %s", message)
|
||||
return
|
||||
}
|
||||
messageCallback(message)
|
||||
}
|
||||
|
||||
// Set wrapped callback on client for vote broadcasts
|
||||
m.client.SetMessageCallback(wrappedCallback)
|
||||
|
||||
// Get JWT token from client
|
||||
token := m.client.GetToken()
|
||||
if token == "" {
|
||||
return fmt.Errorf("no JWT token available, authentication may have failed")
|
||||
}
|
||||
|
||||
// Get EnableRoomCodeImage setting from config (defaults to false)
|
||||
enableRoomCodeImage := m.config.Viper().GetBool("jackbox.EnableRoomCodeImage")
|
||||
|
||||
// Create WebSocket client (pass the API client for vote tracking)
|
||||
m.wsClient = NewWebSocketClient(apiURL, token, wrappedCallback, m.client, enableRoomCodeImage, m.log)
|
||||
|
||||
// Connect to WebSocket
|
||||
if err := m.wsClient.Connect(); err != nil {
|
||||
return fmt.Errorf("failed to connect WebSocket: %w", err)
|
||||
}
|
||||
|
||||
// Get active session and subscribe
|
||||
session, err := m.client.GetActiveSession()
|
||||
if err != nil {
|
||||
m.log.Warnf("Could not get active session: %v", err)
|
||||
m.log.Info("WebSocket connected but not subscribed to any session yet")
|
||||
return nil
|
||||
}
|
||||
|
||||
if session != nil && session.ID > 0 {
|
||||
if err := m.wsClient.Subscribe(session.ID); err != nil {
|
||||
m.log.Warnf("Failed to subscribe to session %d: %v", session.ID, err)
|
||||
}
|
||||
|
||||
// Set the active session on the client for vote tracking
|
||||
m.client.SetActiveSession(session.ID)
|
||||
|
||||
// Only announce if this is a NEW session (no games played yet)
|
||||
// If games have been played, the bot is just reconnecting to an existing session
|
||||
if session.GamesPlayed == 0 {
|
||||
announcement := fmt.Sprintf("🎮 Game Night is starting! Session #%d", session.ID)
|
||||
if messageCallback != nil {
|
||||
messageCallback(announcement)
|
||||
}
|
||||
} else {
|
||||
m.log.Infof("Reconnected to existing session #%d (%d games already played)", session.ID, session.GamesPlayed)
|
||||
}
|
||||
} else {
|
||||
m.log.Info("No active session found, will subscribe when session becomes active")
|
||||
// Start a goroutine to periodically check for active sessions
|
||||
go m.monitorActiveSessions()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// monitorActiveSessions periodically checks for active sessions and subscribes
|
||||
// This is a FALLBACK mechanism - only used when WebSocket events aren't working
|
||||
// Normally, session.started and session.ended events should handle this
|
||||
func (m *Manager) monitorActiveSessions() {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
wasSubscribed := false
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
// Skip polling if WebSocket is disconnected (no point in polling)
|
||||
if m.wsClient == nil || !m.wsClient.IsConnected() {
|
||||
m.log.Debug("WebSocket disconnected, skipping session poll")
|
||||
continue
|
||||
}
|
||||
|
||||
session, err := m.client.GetActiveSession()
|
||||
if err != nil {
|
||||
m.log.Debugf("Error checking for active session: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
isSubscribed := m.wsClient.IsSubscribed()
|
||||
|
||||
// Check if we need to subscribe to a new session (fallback if session.started wasn't received)
|
||||
if !isSubscribed && session != nil && session.ID > 0 {
|
||||
m.log.Warnf("Found active session %d via polling (session.started event may have been missed), subscribing...", session.ID)
|
||||
if err := m.wsClient.Subscribe(session.ID); err != nil {
|
||||
m.log.Warnf("Failed to subscribe to session %d: %v", session.ID, err)
|
||||
} else {
|
||||
m.client.SetActiveSession(session.ID)
|
||||
wasSubscribed = true
|
||||
|
||||
// Only announce if this is a NEW session (no games played yet)
|
||||
if session.GamesPlayed == 0 && !m.IsMuted() {
|
||||
announcement := fmt.Sprintf("🎮 Game Night is starting! Session #%d", session.ID)
|
||||
if m.messageCallback != nil {
|
||||
m.messageCallback(announcement)
|
||||
}
|
||||
} else if session.GamesPlayed == 0 && m.IsMuted() {
|
||||
m.log.Debugf("Jackbox message suppressed (muted): 🎮 Game Night is starting! Session #%d", session.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if session ended (fallback if session.ended wasn't received)
|
||||
if wasSubscribed && (session == nil || session.ID == 0 || session.IsActive == 0) {
|
||||
m.log.Warn("Active session ended (detected via polling, session.ended event may have been missed)")
|
||||
if m.wsClient != nil {
|
||||
m.wsClient.AnnounceSessionEnd()
|
||||
}
|
||||
wasSubscribed = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetClient returns the Jackbox API client (may be nil if disabled)
|
||||
func (m *Manager) GetClient() *Client {
|
||||
return m.client
|
||||
}
|
||||
|
||||
// IsEnabled returns whether Jackbox integration is enabled
|
||||
func (m *Manager) IsEnabled() bool {
|
||||
return m.enabled
|
||||
}
|
||||
|
||||
// Shutdown stops the webhook server or WebSocket client
|
||||
func (m *Manager) Shutdown() error {
|
||||
if m.wsClient != nil {
|
||||
if err := m.wsClient.Close(); err != nil {
|
||||
m.log.Errorf("Error closing WebSocket client: %v", err)
|
||||
}
|
||||
}
|
||||
if m.webhookServer != nil {
|
||||
return m.webhookServer.Stop()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetMuted sets the mute state for Jackbox announcements
|
||||
func (m *Manager) SetMuted(muted bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.muted = muted
|
||||
}
|
||||
|
||||
// ToggleMuted toggles the mute state and returns the new state
|
||||
func (m *Manager) ToggleMuted() bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.muted = !m.muted
|
||||
return m.muted
|
||||
}
|
||||
|
||||
// IsMuted returns the current mute state
|
||||
func (m *Manager) IsMuted() bool {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.muted
|
||||
}
|
||||
453
bridge/jackbox/roomcode_image.go
Normal file
453
bridge/jackbox/roomcode_image.go
Normal file
@@ -0,0 +1,453 @@
|
||||
package jackbox
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"image/gif"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gonutz/gofont"
|
||||
)
|
||||
|
||||
//go:embed FiraMono-Bold.ttf
|
||||
var firaMono embed.FS
|
||||
|
||||
const (
|
||||
imageWidth = 300
|
||||
imageHeight = 300
|
||||
padding = 15
|
||||
)
|
||||
|
||||
// Color pairs for room codes
|
||||
var colorPairs = [][2]color.RGBA{
|
||||
{{0xDD, 0xCC, 0x77, 255}, {0x44, 0xAA, 0x99, 255}}, // a) #DDCC77, #44AA99
|
||||
{{0xFE, 0xFE, 0x62, 255}, {0xD3, 0x5F, 0xB7, 255}}, // b) #FEFE62, #D35FB7
|
||||
{{0x64, 0x8F, 0xFF, 255}, {0xFF, 0xB0, 0x00, 255}}, // c) #648FFF, #FFB000
|
||||
{{229, 212, 232, 255}, {217, 241, 213, 255}}, // d) RGB values
|
||||
}
|
||||
|
||||
// State for color rotation
|
||||
var (
|
||||
colorIndex int
|
||||
letterGetColor bool // true = letters get Color1, false = letters get Color2
|
||||
colorMutex sync.Mutex
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Seed with current time to get different starting point each run
|
||||
now := time.Now()
|
||||
colorIndex = int(now.Unix()) % len(colorPairs)
|
||||
letterGetColor = (now.UnixNano() % 2) == 0
|
||||
}
|
||||
|
||||
// GenerateRoomCodeImage creates an animated GIF with the room code and game title
|
||||
// Black background, colored Fira Mono text
|
||||
// Colors rotate through predefined pairs and alternate letter/number assignment
|
||||
func GenerateRoomCodeImage(roomCode, gameTitle string) ([]byte, error) {
|
||||
// Get and advance color state
|
||||
colorMutex.Lock()
|
||||
currentPair := colorPairs[colorIndex]
|
||||
currentLetterGetColor1 := letterGetColor
|
||||
|
||||
// Advance for next call
|
||||
letterGetColor = !letterGetColor
|
||||
if !letterGetColor {
|
||||
// Only advance to next color pair when we've used both orientations
|
||||
colorIndex = (colorIndex + 1) % len(colorPairs)
|
||||
}
|
||||
colorMutex.Unlock()
|
||||
|
||||
// Determine which color goes to letters vs numbers
|
||||
var letterColor, numberColor color.RGBA
|
||||
if currentLetterGetColor1 {
|
||||
letterColor = currentPair[0]
|
||||
numberColor = currentPair[1]
|
||||
} else {
|
||||
letterColor = currentPair[1]
|
||||
numberColor = currentPair[0]
|
||||
}
|
||||
|
||||
// Static text color for game title and labels is always off-white #EEEEEE (hardcoded in drawing code)
|
||||
// Choose a random color from the pair for the separator
|
||||
separatorColor := currentPair[0]
|
||||
if time.Now().UnixNano()%2 == 1 {
|
||||
separatorColor = currentPair[1]
|
||||
}
|
||||
|
||||
// Load Fira Mono Bold font
|
||||
fontData, err := firaMono.ReadFile("FiraMono-Bold.ttf")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
font, err := gofont.Read(bytes.NewReader(fontData))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
black := color.RGBA{0, 0, 0, 255}
|
||||
|
||||
// Layout from top to bottom:
|
||||
// 1. Game title (at top, staticTextColor)
|
||||
// 2. Room code (center, largest, letterColor/numberColor)
|
||||
// 3. "Room Code" label (below code, staticTextColor)
|
||||
// 4. "Jackbox.tv 🎮 coming up next!" (at bottom, staticTextColor)
|
||||
|
||||
// Calculate layout from bottom up to maximize room code size
|
||||
|
||||
// 4. Bottom text "Jackbox.tv :: coming up next!"
|
||||
// Split into parts so we can color the separator differently
|
||||
bottomTextLeft := "Jackbox.tv "
|
||||
bottomTextSeparator := "::"
|
||||
bottomTextRight := " coming up next!"
|
||||
bottomTextSize := 16
|
||||
font.HeightInPixels = bottomTextSize
|
||||
// Measure full bottom text for positioning
|
||||
fullBottomText := bottomTextLeft + bottomTextSeparator + bottomTextRight
|
||||
bottomTextWidth, bottomTextHeight := font.Measure(fullBottomText)
|
||||
bottomTextX := (imageWidth - bottomTextWidth) / 2
|
||||
bottomTextY := imageHeight - 20 - bottomTextHeight
|
||||
|
||||
// Calculate positions for each part
|
||||
leftWidth, _ := font.Measure(bottomTextLeft)
|
||||
sepWidth, _ := font.Measure(bottomTextSeparator)
|
||||
|
||||
// 3. "Room Code" label (above bottom text)
|
||||
labelText := "^ Room Code ^"
|
||||
labelSize := 21 // Increased by 5% from 20
|
||||
font.HeightInPixels = labelSize
|
||||
labelWidth, labelHeight := font.Measure(labelText)
|
||||
labelX := (imageWidth - labelWidth) / 2
|
||||
labelY := bottomTextY - 10 - labelHeight
|
||||
|
||||
// 2. Room code in center (largest text)
|
||||
// Calculate available vertical space for the room code
|
||||
// We'll reserve space at the top for the game title (calculate after room code)
|
||||
tempTopMargin := 60 // Temporary estimate for title + spacing
|
||||
availableTop := tempTopMargin
|
||||
availableBottom := labelY - 20
|
||||
availableHeight := availableBottom - availableTop
|
||||
availableWidth := imageWidth - 60
|
||||
|
||||
// Find the largest font size that fits the room code
|
||||
bestSize := 30
|
||||
for size := 150; size >= 30; size -= 5 {
|
||||
font.HeightInPixels = size
|
||||
width, height := font.Measure(roomCode)
|
||||
|
||||
if width <= availableWidth && height <= availableHeight {
|
||||
bestSize = size
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate actual room code position
|
||||
font.HeightInPixels = bestSize
|
||||
_, codeHeight := font.Measure(roomCode)
|
||||
|
||||
// Room code is ALWAYS 4 characters, monospace font
|
||||
// Calculate width of a single character and spread them evenly
|
||||
singleCharWidth, _ := font.Measure("X") // Use X as reference for monospace width
|
||||
totalCodeWidth := singleCharWidth * 4
|
||||
|
||||
codeX := (imageWidth - totalCodeWidth) / 2
|
||||
codeY := availableTop + (availableHeight-codeHeight)/2
|
||||
|
||||
// 1. Game title at top - find largest font size that fits in remaining space
|
||||
// Available space is from top of image to top of room code
|
||||
maxTitleWidth := imageWidth - 40 // Leave 20px padding on each side
|
||||
maxTitleHeight := codeY - 30 // Space from top (20px) to room code top (with 10px gap)
|
||||
gameTitleSize := 14
|
||||
|
||||
// Helper function to split title into two lines at nearest whitespace to middle
|
||||
splitTitle := func(title string) (string, string) {
|
||||
words := strings.Fields(title)
|
||||
if len(words) <= 1 {
|
||||
return title, ""
|
||||
}
|
||||
|
||||
// Find the split point closest to the middle
|
||||
totalLen := len(title)
|
||||
midPoint := totalLen / 2
|
||||
bestSplit := 0
|
||||
bestDist := totalLen
|
||||
|
||||
currentLen := 0
|
||||
for i := 0; i < len(words)-1; i++ {
|
||||
currentLen += len(words[i]) + 1 // +1 for space
|
||||
dist := currentLen - midPoint
|
||||
if dist < 0 {
|
||||
dist = -dist
|
||||
}
|
||||
if dist < bestDist {
|
||||
bestDist = dist
|
||||
bestSplit = i + 1
|
||||
}
|
||||
}
|
||||
|
||||
line1 := strings.Join(words[:bestSplit], " ")
|
||||
line2 := strings.Join(words[bestSplit:], " ")
|
||||
return line1, line2
|
||||
}
|
||||
|
||||
// Try single line first, starting at larger size
|
||||
titleLines := []string{gameTitle}
|
||||
var gameTitleHeight int
|
||||
singleLineFits := false
|
||||
|
||||
for size := 40; size >= 28; size -= 2 {
|
||||
font.HeightInPixels = size
|
||||
titleWidth, titleHeight := font.Measure(gameTitle)
|
||||
if titleWidth <= maxTitleWidth && titleHeight <= maxTitleHeight {
|
||||
gameTitleSize = size
|
||||
gameTitleHeight = titleHeight
|
||||
singleLineFits = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If single line doesn't fit at 28px or larger, try splitting into two lines
|
||||
if !singleLineFits {
|
||||
line1, line2 := splitTitle(gameTitle)
|
||||
if line2 != "" {
|
||||
titleLines = []string{line1, line2}
|
||||
|
||||
// Recalculate from maximum size with two lines - might fit larger now!
|
||||
gameTitleSize = 14 // Reset to minimum
|
||||
for size := 40; size >= 14; size -= 2 {
|
||||
font.HeightInPixels = size
|
||||
line1Width, line1Height := font.Measure(line1)
|
||||
line2Width, line2Height := font.Measure(line2)
|
||||
|
||||
maxLineWidth := line1Width
|
||||
if line2Width > maxLineWidth {
|
||||
maxLineWidth = line2Width
|
||||
}
|
||||
totalHeight := line1Height + line2Height + 5 // 5px gap between lines
|
||||
|
||||
if maxLineWidth <= maxTitleWidth && totalHeight <= maxTitleHeight {
|
||||
gameTitleSize = size
|
||||
gameTitleHeight = totalHeight
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Single word that's too long, just shrink it
|
||||
for size := 27; size >= 14; size -= 2 {
|
||||
font.HeightInPixels = size
|
||||
titleWidth, titleHeight := font.Measure(gameTitle)
|
||||
if titleWidth <= maxTitleWidth && titleHeight <= maxTitleHeight {
|
||||
gameTitleSize = size
|
||||
gameTitleHeight = titleHeight
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate Y position (center vertically in available space)
|
||||
gameTitleY := 20 + (codeY-30-20-gameTitleHeight)/2
|
||||
|
||||
// Calculate character positions - evenly spaced for 4 characters
|
||||
// Room code is ALWAYS 4 characters, monospace font
|
||||
charPositions := make([]int, 4)
|
||||
for i := 0; i < 4; i++ {
|
||||
charPositions[i] = codeX + (i * singleCharWidth)
|
||||
}
|
||||
|
||||
// Create animated GIF frames
|
||||
var frames []*image.Paletted
|
||||
var delays []int
|
||||
|
||||
// Palette: Must include ALL colors used in the image
|
||||
// - Black (background)
|
||||
// - Shades for numberColor (room code animation)
|
||||
// - Shades for letterColor (room code animation)
|
||||
// - #EEEEEE (static text)
|
||||
// - separatorColor (:: separator)
|
||||
palette := make([]color.Color, 256)
|
||||
|
||||
// Index 0: Pure black (background)
|
||||
palette[0] = color.RGBA{0, 0, 0, 255}
|
||||
|
||||
// Index 1: #EEEEEE (static text - game title, labels, bottom text)
|
||||
palette[1] = color.RGBA{0xEE, 0xEE, 0xEE, 255}
|
||||
|
||||
// Index 2: separatorColor (:: separator)
|
||||
palette[2] = separatorColor
|
||||
|
||||
// Indices 3-128: black to numberColor (for number animation)
|
||||
for i := 0; i < 126; i++ {
|
||||
progress := float64(i) / 125.0
|
||||
r := uint8(progress * float64(numberColor.R))
|
||||
g := uint8(progress * float64(numberColor.G))
|
||||
b := uint8(progress * float64(numberColor.B))
|
||||
palette[3+i] = color.RGBA{r, g, b, 255}
|
||||
}
|
||||
|
||||
// Indices 129-255: black to letterColor (for letter animation)
|
||||
for i := 0; i < 127; i++ {
|
||||
progress := float64(i) / 126.0
|
||||
r := uint8(progress * float64(letterColor.R))
|
||||
g := uint8(progress * float64(letterColor.G))
|
||||
b := uint8(progress * float64(letterColor.B))
|
||||
palette[129+i] = color.RGBA{r, g, b, 255}
|
||||
}
|
||||
|
||||
// Helper function to determine if a character is a letter
|
||||
isLetter := func(ch rune) bool {
|
||||
return (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z')
|
||||
}
|
||||
|
||||
// Animation parameters
|
||||
initialPauseFrames := 25 // Initial pause before animation starts (2.5 seconds at 10fps)
|
||||
fadeFrames := 10 // Number of frames for fade-in (1 second at 10fps)
|
||||
pauseFrames := 30 // Frames to pause between characters (3 seconds at 10fps)
|
||||
frameDelay := 10 // 10/100 second = 0.1s per frame (10 fps)
|
||||
|
||||
// Helper function to draw a frame and convert to paletted
|
||||
drawFrame := func(charIndex int, fadeProgress float64) *image.Paletted {
|
||||
// Draw to RGBA first for proper alpha blending
|
||||
rgba := image.NewRGBA(image.Rect(0, 0, imageWidth, imageHeight))
|
||||
draw.Draw(rgba, rgba.Bounds(), &image.Uniform{black}, image.Point{}, draw.Src)
|
||||
|
||||
// STEP 1: Draw room code FIRST with animation (colored letters/numbers)
|
||||
font.HeightInPixels = bestSize
|
||||
|
||||
// Draw all previous characters (fully visible)
|
||||
for i := 0; i < charIndex; i++ {
|
||||
ch := rune(roomCode[i])
|
||||
if isLetter(ch) {
|
||||
font.R, font.G, font.B, font.A = letterColor.R, letterColor.G, letterColor.B, 255
|
||||
} else {
|
||||
font.R, font.G, font.B, font.A = numberColor.R, numberColor.G, numberColor.B, 255
|
||||
}
|
||||
font.Write(rgba, string(roomCode[i]), charPositions[i], codeY)
|
||||
}
|
||||
|
||||
// Draw current character (fading in) using manual alpha blending
|
||||
if charIndex < len(roomCode) && fadeProgress > 0 {
|
||||
// Draw the character to a temporary image at full opacity
|
||||
tempImg := image.NewRGBA(image.Rect(0, 0, imageWidth, imageHeight))
|
||||
draw.Draw(tempImg, tempImg.Bounds(), &image.Uniform{color.RGBA{0, 0, 0, 0}}, image.Point{}, draw.Src)
|
||||
|
||||
ch := rune(roomCode[charIndex])
|
||||
if isLetter(ch) {
|
||||
font.R, font.G, font.B, font.A = letterColor.R, letterColor.G, letterColor.B, 255
|
||||
} else {
|
||||
font.R, font.G, font.B, font.A = numberColor.R, numberColor.G, numberColor.B, 255
|
||||
}
|
||||
font.Write(tempImg, string(roomCode[charIndex]), charPositions[charIndex], codeY)
|
||||
|
||||
// Manually blend the character onto the main image with fadeProgress alpha
|
||||
targetAlpha := uint8(fadeProgress * 255)
|
||||
for y := 0; y < imageHeight; y++ {
|
||||
for x := 0; x < imageWidth; x++ {
|
||||
srcColor := tempImg.RGBAAt(x, y)
|
||||
if srcColor.A > 0 {
|
||||
// Apply fade alpha to the source color
|
||||
dstColor := rgba.RGBAAt(x, y)
|
||||
|
||||
// Alpha blending formula
|
||||
alpha := uint32(srcColor.A) * uint32(targetAlpha) / 255
|
||||
invAlpha := 255 - alpha
|
||||
|
||||
r := (uint32(srcColor.R)*alpha + uint32(dstColor.R)*invAlpha) / 255
|
||||
g := (uint32(srcColor.G)*alpha + uint32(dstColor.G)*invAlpha) / 255
|
||||
b := (uint32(srcColor.B)*alpha + uint32(dstColor.B)*invAlpha) / 255
|
||||
|
||||
rgba.SetRGBA(x, y, color.RGBA{uint8(r), uint8(g), uint8(b), 255})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// STEP 2: Draw static text elements ON TOP (always visible, same on every frame)
|
||||
|
||||
// 1. Game title at top (off-white #EEEEEE) - may be 1 or 2 lines
|
||||
font.HeightInPixels = gameTitleSize
|
||||
font.R, font.G, font.B, font.A = 0xEE, 0xEE, 0xEE, 255
|
||||
|
||||
if len(titleLines) == 1 {
|
||||
// Single line - use pre-calculated position
|
||||
lineWidth, _ := font.Measure(titleLines[0])
|
||||
lineX := (imageWidth - lineWidth) / 2
|
||||
font.Write(rgba, titleLines[0], lineX, gameTitleY)
|
||||
} else {
|
||||
// Two lines
|
||||
line1Width, line1Height := font.Measure(titleLines[0])
|
||||
line2Width, _ := font.Measure(titleLines[1])
|
||||
|
||||
line1X := (imageWidth - line1Width) / 2
|
||||
line2X := (imageWidth - line2Width) / 2
|
||||
|
||||
font.Write(rgba, titleLines[0], line1X, gameTitleY)
|
||||
font.Write(rgba, titleLines[1], line2X, gameTitleY+line1Height+5) // 5px gap
|
||||
}
|
||||
|
||||
// 3. "^ Room Code ^" label (off-white #EEEEEE)
|
||||
font.HeightInPixels = labelSize
|
||||
font.R, font.G, font.B, font.A = 0xEE, 0xEE, 0xEE, 255
|
||||
font.Write(rgba, labelText, labelX, labelY)
|
||||
|
||||
// 4. Bottom text with colored separator
|
||||
font.HeightInPixels = bottomTextSize
|
||||
|
||||
// Left part (off-white #EEEEEE)
|
||||
font.R, font.G, font.B, font.A = 0xEE, 0xEE, 0xEE, 255
|
||||
font.Write(rgba, bottomTextLeft, bottomTextX, bottomTextY)
|
||||
|
||||
// Separator (separatorColor - from the color pair)
|
||||
font.R, font.G, font.B, font.A = separatorColor.R, separatorColor.G, separatorColor.B, 255
|
||||
font.Write(rgba, bottomTextSeparator, bottomTextX+leftWidth, bottomTextY)
|
||||
|
||||
// Right part (off-white #EEEEEE)
|
||||
font.R, font.G, font.B, font.A = 0xEE, 0xEE, 0xEE, 255
|
||||
font.Write(rgba, bottomTextRight, bottomTextX+leftWidth+sepWidth, bottomTextY)
|
||||
|
||||
// Convert RGBA to paletted
|
||||
paletted := image.NewPaletted(rgba.Bounds(), palette)
|
||||
draw.FloydSteinberg.Draw(paletted, rgba.Bounds(), rgba, image.Point{})
|
||||
return paletted
|
||||
}
|
||||
|
||||
// Generate initial pause frames (just label, no characters)
|
||||
for i := 0; i < initialPauseFrames; i++ {
|
||||
frames = append(frames, drawFrame(0, 0))
|
||||
delays = append(delays, frameDelay)
|
||||
}
|
||||
|
||||
// Generate frames
|
||||
for charIndex := 0; charIndex < len(roomCode); charIndex++ {
|
||||
// Fade-in frames for current character
|
||||
for fadeFrame := 0; fadeFrame < fadeFrames; fadeFrame++ {
|
||||
fadeProgress := float64(fadeFrame+1) / float64(fadeFrames)
|
||||
frames = append(frames, drawFrame(charIndex, fadeProgress))
|
||||
delays = append(delays, frameDelay)
|
||||
}
|
||||
|
||||
// Pause frames (hold current state with character fully visible)
|
||||
for pauseFrame := 0; pauseFrame < pauseFrames; pauseFrame++ {
|
||||
frames = append(frames, drawFrame(charIndex+1, 0))
|
||||
delays = append(delays, frameDelay)
|
||||
}
|
||||
}
|
||||
|
||||
// Encode as GIF (loop forever since LoopCount is unreliable)
|
||||
var buf bytes.Buffer
|
||||
err = gif.EncodeAll(&buf, &gif.GIF{
|
||||
Image: frames,
|
||||
Delay: delays,
|
||||
// Omit LoopCount entirely - let it loop forever (most reliable)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
29
bridge/jackbox/votes.go
Normal file
29
bridge/jackbox/votes.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package jackbox
|
||||
|
||||
import "strings"
|
||||
|
||||
// DetectVote checks if a message contains a vote and returns the vote type
|
||||
// Returns (true, "up") for thisgame++
|
||||
// Returns (true, "down") for thisgame--
|
||||
// Returns (false, "") for non-vote messages
|
||||
func DetectVote(text string) (isVote bool, voteType string) {
|
||||
lower := strings.ToLower(text)
|
||||
|
||||
if strings.Contains(lower, "thisgame++") {
|
||||
return true, "up"
|
||||
}
|
||||
|
||||
if strings.Contains(lower, "thisgame--") {
|
||||
return true, "down"
|
||||
}
|
||||
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// IsRelayedMessage checks if a message is relayed from another chat
|
||||
// Returns true if the message has [irc] or [kosmi] prefix
|
||||
func IsRelayedMessage(text string) bool {
|
||||
lower := strings.ToLower(text)
|
||||
return strings.HasPrefix(lower, "[irc]") || strings.HasPrefix(lower, "[kosmi]")
|
||||
}
|
||||
|
||||
174
bridge/jackbox/webhook.go
Normal file
174
bridge/jackbox/webhook.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package jackbox
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// WebhookServer handles incoming webhooks from the Jackbox API
|
||||
type WebhookServer struct {
|
||||
port int
|
||||
secret string
|
||||
messageCallback func(string) // Callback to broadcast messages
|
||||
log *logrus.Entry
|
||||
server *http.Server
|
||||
}
|
||||
|
||||
// WebhookPayload represents the webhook event from the API
|
||||
type WebhookPayload struct {
|
||||
Event string `json:"event"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Data struct {
|
||||
Session struct {
|
||||
ID int `json:"id"`
|
||||
IsActive bool `json:"is_active"`
|
||||
GamesPlayed int `json:"games_played"`
|
||||
} `json:"session"`
|
||||
Game struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
PackName string `json:"pack_name"`
|
||||
MinPlayers int `json:"min_players"`
|
||||
MaxPlayers int `json:"max_players"`
|
||||
ManuallyAdded bool `json:"manually_added"`
|
||||
} `json:"game"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// NewWebhookServer creates a new webhook server
|
||||
func NewWebhookServer(port int, secret string, messageCallback func(string), log *logrus.Entry) *WebhookServer {
|
||||
return &WebhookServer{
|
||||
port: port,
|
||||
secret: secret,
|
||||
messageCallback: messageCallback,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
// Start starts the webhook HTTP server
|
||||
func (w *WebhookServer) Start() error {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/webhook/jackbox", w.handleWebhook)
|
||||
|
||||
// Health check endpoint
|
||||
mux.HandleFunc("/health", func(rw http.ResponseWriter, r *http.Request) {
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
rw.Write([]byte("OK"))
|
||||
})
|
||||
|
||||
w.server = &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", w.port),
|
||||
Handler: mux,
|
||||
}
|
||||
|
||||
w.log.Infof("Starting Jackbox webhook server on port %d", w.port)
|
||||
|
||||
go func() {
|
||||
if err := w.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
w.log.Errorf("Webhook server error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop stops the webhook server
|
||||
func (w *WebhookServer) Stop() error {
|
||||
if w.server != nil {
|
||||
w.log.Info("Stopping Jackbox webhook server")
|
||||
return w.server.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleWebhook processes incoming webhook requests
|
||||
func (w *WebhookServer) handleWebhook(rw http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Read the raw body for signature verification
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
w.log.Errorf("Failed to read webhook body: %v", err)
|
||||
http.Error(rw, "Failed to read body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
// Verify signature
|
||||
signature := r.Header.Get("X-Webhook-Signature")
|
||||
if !w.verifySignature(signature, body) {
|
||||
w.log.Warn("Webhook signature verification failed")
|
||||
http.Error(rw, "Invalid signature", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse the webhook payload
|
||||
var payload WebhookPayload
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
w.log.Errorf("Failed to parse webhook payload: %v", err)
|
||||
http.Error(rw, "Invalid payload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle the event
|
||||
w.handleEvent(&payload)
|
||||
|
||||
// Always respond with 200 OK
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
rw.Write([]byte("OK"))
|
||||
}
|
||||
|
||||
// verifySignature verifies the HMAC-SHA256 signature of the webhook
|
||||
func (w *WebhookServer) verifySignature(signature string, body []byte) bool {
|
||||
if signature == "" || len(signature) < 7 || signature[:7] != "sha256=" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Extract the hex signature (after "sha256=")
|
||||
receivedSig := signature[7:]
|
||||
|
||||
// Compute expected signature
|
||||
mac := hmac.New(sha256.New, []byte(w.secret))
|
||||
mac.Write(body)
|
||||
expectedSig := hex.EncodeToString(mac.Sum(nil))
|
||||
|
||||
// Timing-safe comparison
|
||||
return subtle.ConstantTimeCompare([]byte(receivedSig), []byte(expectedSig)) == 1
|
||||
}
|
||||
|
||||
// handleEvent processes webhook events
|
||||
func (w *WebhookServer) handleEvent(payload *WebhookPayload) {
|
||||
switch payload.Event {
|
||||
case "game.added":
|
||||
w.handleGameAdded(payload)
|
||||
default:
|
||||
w.log.Debugf("Unhandled webhook event: %s", payload.Event)
|
||||
}
|
||||
}
|
||||
|
||||
// handleGameAdded handles the game.added event
|
||||
func (w *WebhookServer) handleGameAdded(payload *WebhookPayload) {
|
||||
game := payload.Data.Game
|
||||
|
||||
w.log.Infof("Game added: %s from %s", game.Title, game.PackName)
|
||||
|
||||
// Format the announcement message
|
||||
message := fmt.Sprintf("🎮 Coming up next: %s!", game.Title)
|
||||
|
||||
// Broadcast to both chats via callback
|
||||
if w.messageCallback != nil {
|
||||
w.messageCallback(message)
|
||||
}
|
||||
}
|
||||
|
||||
547
bridge/jackbox/websocket_client.go
Normal file
547
bridge/jackbox/websocket_client.go
Normal file
@@ -0,0 +1,547 @@
|
||||
package jackbox
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// WebSocketClient handles WebSocket connection to Jackbox API
|
||||
type WebSocketClient struct {
|
||||
apiURL string
|
||||
token string
|
||||
conn *websocket.Conn
|
||||
messageCallback func(string)
|
||||
apiClient *Client // Reference to API client for vote tracking
|
||||
log *logrus.Entry
|
||||
mu sync.Mutex
|
||||
reconnectDelay time.Duration
|
||||
maxReconnect time.Duration
|
||||
stopChan chan struct{}
|
||||
connected bool
|
||||
authenticated bool
|
||||
subscribedSession int
|
||||
enableRoomCodeImage bool // Whether to upload room code images to Kosmi
|
||||
}
|
||||
|
||||
// WebSocket message types
|
||||
type WSMessage struct {
|
||||
Type string `json:"type"`
|
||||
Token string `json:"token,omitempty"`
|
||||
SessionID int `json:"sessionId,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Timestamp string `json:"timestamp,omitempty"`
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// SessionStartedData represents the session.started event data
|
||||
type SessionStartedData struct {
|
||||
Session struct {
|
||||
ID int `json:"id"`
|
||||
IsActive int `json:"is_active"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Notes string `json:"notes"`
|
||||
} `json:"session"`
|
||||
}
|
||||
|
||||
// GameAddedData represents the game.added event data
|
||||
type GameAddedData struct {
|
||||
Session struct {
|
||||
ID int `json:"id"`
|
||||
IsActive bool `json:"is_active"`
|
||||
GamesPlayed int `json:"games_played"`
|
||||
} `json:"session"`
|
||||
Game struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
PackName string `json:"pack_name"`
|
||||
MinPlayers int `json:"min_players"`
|
||||
MaxPlayers int `json:"max_players"`
|
||||
ManuallyAdded bool `json:"manually_added"`
|
||||
RoomCode string `json:"room_code"`
|
||||
} `json:"game"`
|
||||
}
|
||||
|
||||
// NewWebSocketClient creates a new WebSocket client
|
||||
func NewWebSocketClient(apiURL, token string, messageCallback func(string), apiClient *Client, enableRoomCodeImage bool, log *logrus.Entry) *WebSocketClient {
|
||||
return &WebSocketClient{
|
||||
apiURL: apiURL,
|
||||
token: token,
|
||||
messageCallback: messageCallback,
|
||||
apiClient: apiClient,
|
||||
enableRoomCodeImage: enableRoomCodeImage,
|
||||
log: log,
|
||||
reconnectDelay: 1 * time.Second,
|
||||
maxReconnect: 30 * time.Second,
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Connect establishes WebSocket connection
|
||||
func (c *WebSocketClient) Connect() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
// Convert http(s):// to ws(s)://
|
||||
wsURL := c.apiURL
|
||||
if len(wsURL) > 7 && wsURL[:7] == "http://" {
|
||||
wsURL = "ws://" + wsURL[7:]
|
||||
} else if len(wsURL) > 8 && wsURL[:8] == "https://" {
|
||||
wsURL = "wss://" + wsURL[8:]
|
||||
}
|
||||
|
||||
wsURL += "/api/sessions/live"
|
||||
|
||||
c.log.Infof("Connecting to WebSocket: %s", wsURL)
|
||||
|
||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect: %w", err)
|
||||
}
|
||||
|
||||
c.conn = conn
|
||||
c.connected = true
|
||||
c.log.Info("WebSocket connected")
|
||||
|
||||
// Start message listener
|
||||
go c.listen()
|
||||
|
||||
// Authenticate
|
||||
return c.authenticate()
|
||||
}
|
||||
|
||||
// authenticate sends authentication message
|
||||
func (c *WebSocketClient) authenticate() error {
|
||||
msg := WSMessage{
|
||||
Type: "auth",
|
||||
Token: c.token,
|
||||
}
|
||||
|
||||
if err := c.sendMessage(msg); err != nil {
|
||||
return fmt.Errorf("failed to send auth: %w", err)
|
||||
}
|
||||
|
||||
c.log.Debug("Authentication message sent")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Subscribe subscribes to a session's events
|
||||
func (c *WebSocketClient) Subscribe(sessionID int) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if !c.authenticated {
|
||||
return fmt.Errorf("not authenticated")
|
||||
}
|
||||
|
||||
msg := WSMessage{
|
||||
Type: "subscribe",
|
||||
SessionID: sessionID,
|
||||
}
|
||||
|
||||
if err := c.sendMessage(msg); err != nil {
|
||||
return fmt.Errorf("failed to subscribe: %w", err)
|
||||
}
|
||||
|
||||
c.subscribedSession = sessionID
|
||||
c.log.Infof("Subscribed to session %d", sessionID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unsubscribe unsubscribes from a session's events
|
||||
func (c *WebSocketClient) Unsubscribe(sessionID int) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
msg := WSMessage{
|
||||
Type: "unsubscribe",
|
||||
SessionID: sessionID,
|
||||
}
|
||||
|
||||
if err := c.sendMessage(msg); err != nil {
|
||||
return fmt.Errorf("failed to unsubscribe: %w", err)
|
||||
}
|
||||
|
||||
if c.subscribedSession == sessionID {
|
||||
c.subscribedSession = 0
|
||||
}
|
||||
|
||||
c.log.Infof("Unsubscribed from session %d", sessionID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// listen handles incoming WebSocket messages
|
||||
func (c *WebSocketClient) listen() {
|
||||
defer c.handleDisconnect()
|
||||
|
||||
// Start heartbeat
|
||||
go c.startHeartbeat()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-c.stopChan:
|
||||
return
|
||||
default:
|
||||
_, message, err := c.conn.ReadMessage()
|
||||
if err != nil {
|
||||
c.log.Errorf("Error reading message: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.handleMessage(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleMessage processes incoming messages
|
||||
func (c *WebSocketClient) handleMessage(data []byte) {
|
||||
var msg WSMessage
|
||||
if err := json.Unmarshal(data, &msg); err != nil {
|
||||
c.log.Errorf("Failed to parse message: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
switch msg.Type {
|
||||
case "auth_success":
|
||||
c.mu.Lock()
|
||||
c.authenticated = true
|
||||
c.mu.Unlock()
|
||||
c.log.Info("Authentication successful")
|
||||
// session.started events are automatically broadcast to all authenticated clients
|
||||
// No need to subscribe - just wait for session.started events
|
||||
|
||||
case "auth_error":
|
||||
c.log.Errorf("Authentication failed: %s", msg.Message)
|
||||
c.authenticated = false
|
||||
|
||||
case "subscribed":
|
||||
c.log.Infof("Subscription confirmed: %s", msg.Message)
|
||||
|
||||
case "unsubscribed":
|
||||
c.log.Infof("Unsubscription confirmed: %s", msg.Message)
|
||||
|
||||
case "session.started":
|
||||
c.handleSessionStarted(msg.Data)
|
||||
|
||||
case "game.added":
|
||||
c.handleGameAdded(msg.Data)
|
||||
|
||||
case "session.ended":
|
||||
c.handleSessionEnded(msg.Data)
|
||||
|
||||
case "pong":
|
||||
c.log.Debug("Heartbeat pong received")
|
||||
|
||||
case "error":
|
||||
c.log.Errorf("Server error: %s", msg.Message)
|
||||
|
||||
default:
|
||||
c.log.Debugf("Unknown message type: %s", msg.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// handleSessionStarted processes session.started events
|
||||
func (c *WebSocketClient) handleSessionStarted(data json.RawMessage) {
|
||||
var sessionData SessionStartedData
|
||||
if err := json.Unmarshal(data, &sessionData); err != nil {
|
||||
c.log.Errorf("Failed to parse session.started data: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
sessionID := sessionData.Session.ID
|
||||
c.log.Infof("Session started: ID=%d", sessionID)
|
||||
|
||||
// Subscribe to the new session
|
||||
if err := c.Subscribe(sessionID); err != nil {
|
||||
c.log.Errorf("Failed to subscribe to new session %d: %v", sessionID, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Set the active session on the client for vote tracking
|
||||
if c.apiClient != nil {
|
||||
c.apiClient.SetActiveSession(sessionID)
|
||||
}
|
||||
|
||||
// Announce the new session
|
||||
message := fmt.Sprintf("🎮 Game Night is starting! Session #%d", sessionID)
|
||||
if c.messageCallback != nil {
|
||||
c.messageCallback(message)
|
||||
}
|
||||
}
|
||||
|
||||
// handleGameAdded processes game.added events
|
||||
func (c *WebSocketClient) handleGameAdded(data json.RawMessage) {
|
||||
var gameData GameAddedData
|
||||
if err := json.Unmarshal(data, &gameData); err != nil {
|
||||
c.log.Errorf("Failed to parse game.added data: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.log.Infof("Game added: %s from %s (Room Code: %s)", gameData.Game.Title, gameData.Game.PackName, gameData.Game.RoomCode)
|
||||
|
||||
// Get and clear the last vote response for the previous game
|
||||
var message string
|
||||
if c.apiClient != nil {
|
||||
lastVote := c.apiClient.GetAndClearLastVoteResponse()
|
||||
if lastVote != nil {
|
||||
// Include vote results from the previous game
|
||||
message = fmt.Sprintf("🗳️ Final votes for %s: %d👍 %d👎 (Score: %d)\n🎮 Coming up next: %s",
|
||||
lastVote.Game.Title,
|
||||
lastVote.Game.Upvotes, lastVote.Game.Downvotes, lastVote.Game.PopularityScore,
|
||||
gameData.Game.Title)
|
||||
} else {
|
||||
// No votes for previous game (or first game)
|
||||
message = fmt.Sprintf("🎮 Coming up next: %s", gameData.Game.Title)
|
||||
}
|
||||
} else {
|
||||
// Fallback if no API client
|
||||
message = fmt.Sprintf("🎮 Coming up next: %s", gameData.Game.Title)
|
||||
}
|
||||
|
||||
// Handle room code display based on configuration
|
||||
if gameData.Game.RoomCode != "" {
|
||||
if c.enableRoomCodeImage {
|
||||
// Try to upload room code image (for Kosmi) - image contains all info
|
||||
c.broadcastWithRoomCodeImage(gameData.Game.Title, gameData.Game.RoomCode)
|
||||
} else {
|
||||
// Use IRC text formatting (fallback)
|
||||
roomCodeText := fmt.Sprintf(" - Room Code \x02\x11%s\x0F", gameData.Game.RoomCode)
|
||||
if c.messageCallback != nil {
|
||||
c.messageCallback(message + roomCodeText)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No room code, just send the message
|
||||
if c.messageCallback != nil {
|
||||
c.messageCallback(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// broadcastWithRoomCodeImage generates, uploads, and broadcasts a room code image
|
||||
// The image contains all the information (game title, room code, etc.)
|
||||
func (c *WebSocketClient) broadcastWithRoomCodeImage(gameTitle, roomCode string) {
|
||||
c.log.Infof("🎨 Starting room code image generation and upload for: %s - %s", gameTitle, roomCode)
|
||||
|
||||
// Generate room code image (animated GIF) with game title embedded
|
||||
c.log.Infof("📝 Step 1: Generating image...")
|
||||
imageData, err := GenerateRoomCodeImage(roomCode, gameTitle)
|
||||
if err != nil {
|
||||
c.log.Errorf("❌ Failed to generate room code image: %v", err)
|
||||
// Fallback to plain text (no IRC formatting codes for Kosmi)
|
||||
fallbackMessage := fmt.Sprintf("🎮 Coming up next: %s - Room Code %s", gameTitle, roomCode)
|
||||
if c.messageCallback != nil {
|
||||
c.messageCallback(fallbackMessage)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c.log.Infof("✅ Step 1 complete: Generated %d bytes of GIF data", len(imageData))
|
||||
|
||||
// Upload animated GIF to Kosmi CDN (MUST complete before announcing)
|
||||
c.log.Infof("📤 Step 2: Uploading to Kosmi CDN...")
|
||||
filename := fmt.Sprintf("roomcode_%s.gif", roomCode)
|
||||
imageURL, err := UploadImageToKosmi(imageData, filename)
|
||||
if err != nil {
|
||||
c.log.Errorf("❌ Failed to upload room code image: %v", err)
|
||||
// Fallback to plain text (no IRC formatting codes for Kosmi)
|
||||
fallbackMessage := fmt.Sprintf("🎮 Coming up next: %s - Room Code %s", gameTitle, roomCode)
|
||||
if c.messageCallback != nil {
|
||||
c.messageCallback(fallbackMessage)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c.log.Infof("✅ Step 2 complete: Uploaded to %s", imageURL)
|
||||
|
||||
// Now that upload succeeded, send the full announcement with game title and URL
|
||||
c.log.Infof("📢 Step 3: Broadcasting game announcement with URL...")
|
||||
fullMessage := fmt.Sprintf("🎮 Coming up next: %s %s", gameTitle, imageURL)
|
||||
if c.messageCallback != nil {
|
||||
c.messageCallback(fullMessage)
|
||||
c.log.Infof("✅ Step 3 complete: Game announcement sent with URL")
|
||||
} else {
|
||||
c.log.Error("❌ Step 3 failed: messageCallback is nil")
|
||||
}
|
||||
|
||||
// Send the plaintext room code after 19 seconds (to sync with animation completion)
|
||||
// Capture callback and logger in closure
|
||||
callback := c.messageCallback
|
||||
logger := c.log
|
||||
plainRoomCode := roomCode // Capture room code for plain text message
|
||||
|
||||
c.log.Infof("⏰ Step 4: Starting 19-second timer goroutine for plaintext room code...")
|
||||
go func() {
|
||||
logger.Infof("⏰ [Goroutine started] Waiting 19 seconds before sending plaintext room code: %s", plainRoomCode)
|
||||
time.Sleep(19 * time.Second)
|
||||
logger.Infof("⏰ [19 seconds elapsed] Now sending plaintext room code...")
|
||||
|
||||
if callback != nil {
|
||||
// Send just the room code in plaintext (for easy copy/paste)
|
||||
plaintextMessage := fmt.Sprintf("Room Code: %s", plainRoomCode)
|
||||
logger.Infof("📤 Sending plaintext: %s", plaintextMessage)
|
||||
callback(plaintextMessage)
|
||||
logger.Infof("✅ Successfully sent plaintext room code")
|
||||
} else {
|
||||
logger.Error("❌ Message callback is nil when trying to send delayed room code")
|
||||
}
|
||||
}()
|
||||
|
||||
c.log.Infof("✅ Step 4 complete: Goroutine launched, will fire in 19 seconds")
|
||||
}
|
||||
|
||||
// handleSessionEnded processes session.ended events
|
||||
func (c *WebSocketClient) handleSessionEnded(data json.RawMessage) {
|
||||
c.log.Info("Session ended event received")
|
||||
c.AnnounceSessionEnd()
|
||||
}
|
||||
|
||||
// AnnounceSessionEnd announces the final votes and says goodnight
|
||||
func (c *WebSocketClient) AnnounceSessionEnd() {
|
||||
// Get and clear the last vote response for the final game
|
||||
var message string
|
||||
if c.apiClient != nil {
|
||||
lastVote := c.apiClient.GetAndClearLastVoteResponse()
|
||||
if lastVote != nil {
|
||||
// Include final vote results
|
||||
message = fmt.Sprintf("🗳️ Final votes for %s: %d👍 %d👎 (Score: %d)\n🌙 Game Night has ended! Thanks for playing!",
|
||||
lastVote.Game.Title,
|
||||
lastVote.Game.Upvotes, lastVote.Game.Downvotes, lastVote.Game.PopularityScore)
|
||||
} else {
|
||||
// No votes for final game
|
||||
message = "🌙 Game Night has ended! Thanks for playing!"
|
||||
}
|
||||
|
||||
// Clear the active session
|
||||
c.apiClient.SetActiveSession(0)
|
||||
} else {
|
||||
message = "🌙 Game Night has ended! Thanks for playing!"
|
||||
}
|
||||
|
||||
// Broadcast to chats via callback
|
||||
if c.messageCallback != nil {
|
||||
c.messageCallback(message)
|
||||
}
|
||||
}
|
||||
|
||||
// startHeartbeat sends ping messages periodically
|
||||
func (c *WebSocketClient) startHeartbeat() {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-c.stopChan:
|
||||
return
|
||||
case <-ticker.C:
|
||||
c.mu.Lock()
|
||||
if c.connected && c.conn != nil {
|
||||
msg := WSMessage{Type: "ping"}
|
||||
if err := c.sendMessage(msg); err != nil {
|
||||
c.log.Errorf("Failed to send ping: %v", err)
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sendMessage sends a message to the WebSocket server
|
||||
func (c *WebSocketClient) sendMessage(msg WSMessage) error {
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if c.conn == nil {
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
return c.conn.WriteMessage(websocket.TextMessage, data)
|
||||
}
|
||||
|
||||
// handleDisconnect handles connection loss and attempts reconnection
|
||||
func (c *WebSocketClient) handleDisconnect() {
|
||||
c.mu.Lock()
|
||||
c.connected = false
|
||||
c.authenticated = false
|
||||
if c.conn != nil {
|
||||
c.conn.Close()
|
||||
c.conn = nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
c.log.Warn("WebSocket disconnected, attempting to reconnect...")
|
||||
|
||||
// Exponential backoff reconnection
|
||||
delay := c.reconnectDelay
|
||||
for {
|
||||
select {
|
||||
case <-c.stopChan:
|
||||
return
|
||||
case <-time.After(delay):
|
||||
c.log.Infof("Reconnecting... (delay: %v)", delay)
|
||||
|
||||
if err := c.Connect(); err != nil {
|
||||
c.log.Errorf("Reconnection failed: %v", err)
|
||||
|
||||
// Increase delay with exponential backoff
|
||||
delay *= 2
|
||||
if delay > c.maxReconnect {
|
||||
delay = c.maxReconnect
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Reconnected successfully
|
||||
c.log.Info("Reconnected successfully")
|
||||
|
||||
// Re-subscribe if we were subscribed before
|
||||
if c.subscribedSession > 0 {
|
||||
if err := c.Subscribe(c.subscribedSession); err != nil {
|
||||
c.log.Errorf("Failed to re-subscribe: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close closes the WebSocket connection
|
||||
func (c *WebSocketClient) Close() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
close(c.stopChan)
|
||||
|
||||
if c.conn != nil {
|
||||
c.log.Info("Closing WebSocket connection")
|
||||
err := c.conn.Close()
|
||||
c.conn = nil
|
||||
c.connected = false
|
||||
c.authenticated = false
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsConnected returns whether the client is connected
|
||||
func (c *WebSocketClient) IsConnected() bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.connected && c.authenticated
|
||||
}
|
||||
|
||||
// IsSubscribed returns whether the client is subscribed to a session
|
||||
func (c *WebSocketClient) IsSubscribed() bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.subscribedSession > 0
|
||||
}
|
||||
|
||||
99
bridge/kosmi/image_upload.go
Normal file
99
bridge/kosmi/image_upload.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package bkosmi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
kosmiImageUploadURL = "https://img.kosmi.io/"
|
||||
)
|
||||
|
||||
// ImageUploadResponse represents the response from Kosmi image upload endpoint
|
||||
type ImageUploadResponse struct {
|
||||
Filename string `json:"filename"`
|
||||
}
|
||||
|
||||
// UploadImage uploads an image to Kosmi's CDN and returns the URL
|
||||
func UploadImage(imageData []byte, filename string) (string, error) {
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"filename": filename,
|
||||
"size": len(imageData),
|
||||
}).Debug("Uploading image to Kosmi CDN")
|
||||
|
||||
// Create multipart form body
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
|
||||
// Add file field
|
||||
part, err := writer.CreateFormFile("file", filename)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create form file: %w", err)
|
||||
}
|
||||
|
||||
if _, err := part.Write(imageData); err != nil {
|
||||
return "", fmt.Errorf("failed to write image data: %w", err)
|
||||
}
|
||||
|
||||
// Close the multipart writer to finalize the body
|
||||
if err := writer.Close(); err != nil {
|
||||
return "", fmt.Errorf("failed to close multipart writer: %w", err)
|
||||
}
|
||||
|
||||
// Create HTTP request
|
||||
req, err := http.NewRequest("POST", kosmiImageUploadURL, body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Set required headers
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("Origin", "https://app.kosmi.io")
|
||||
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 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||
|
||||
// Send request
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check status code
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("upload failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
// Read response body for debugging
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
logrus.WithField("response", string(bodyBytes)).Debug("Upload response body")
|
||||
|
||||
// Parse response
|
||||
var result ImageUploadResponse
|
||||
if err := json.Unmarshal(bodyBytes, &result); err != nil {
|
||||
return "", fmt.Errorf("failed to parse response: %w (body: %s)", err, string(bodyBytes))
|
||||
}
|
||||
|
||||
if result.Filename == "" {
|
||||
return "", fmt.Errorf("no filename in response (body: %s)", string(bodyBytes))
|
||||
}
|
||||
|
||||
// Construct the full URL from the filename
|
||||
imageURL := fmt.Sprintf("https://img.kosmi.io/%s", result.Filename)
|
||||
|
||||
logrus.WithField("url", imageURL).Info("Successfully uploaded image to Kosmi CDN")
|
||||
return imageURL, nil
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/42wim/matterbridge/bridge"
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
"github.com/42wim/matterbridge/bridge/jackbox"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -26,11 +27,12 @@ type KosmiClient interface {
|
||||
// Bkosmi represents the Kosmi bridge
|
||||
type Bkosmi struct {
|
||||
*bridge.Config
|
||||
client KosmiClient
|
||||
roomID string
|
||||
roomURL string
|
||||
connected bool
|
||||
msgChannel chan config.Message
|
||||
client KosmiClient
|
||||
roomID string
|
||||
roomURL string
|
||||
connected bool
|
||||
msgChannel chan config.Message
|
||||
jackboxClient *jackbox.Client
|
||||
}
|
||||
|
||||
// New creates a new Kosmi bridge instance
|
||||
@@ -154,6 +156,21 @@ func (b *Bkosmi) handleIncomingMessage(payload *NewMessagePayload) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check for votes (thisgame++ or thisgame--)
|
||||
// Only process votes from non-relayed messages
|
||||
if !jackbox.IsRelayedMessage(body) {
|
||||
if isVote, voteType := jackbox.DetectVote(body); isVote {
|
||||
b.Log.Debugf("Detected vote from %s: %s", username, voteType)
|
||||
if b.jackboxClient != nil {
|
||||
go func() {
|
||||
if err := b.jackboxClient.SendVote(username, voteType, timestamp); err != nil {
|
||||
b.Log.Errorf("Failed to send vote to Jackbox API: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create Matterbridge message
|
||||
// Use "main" as the channel name for gateway matching
|
||||
// Don't add prefix here - let the gateway's RemoteNickFormat handle it
|
||||
@@ -219,3 +236,8 @@ func extractRoomID(url string) (string, error) {
|
||||
return "", fmt.Errorf("could not extract room ID from URL: %s", url)
|
||||
}
|
||||
|
||||
// SetJackboxClient sets the Jackbox API client for this bridge
|
||||
func (b *Bkosmi) SetJackboxClient(client *jackbox.Client) {
|
||||
b.jackboxClient = client
|
||||
b.Log.Info("Jackbox client injected into Kosmi bridge")
|
||||
}
|
||||
|
||||
111
cmd/monitor-ws/README.md
Normal file
111
cmd/monitor-ws/README.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# Kosmi WebSocket Monitor - Image Upload Capture
|
||||
|
||||
This tool captures ALL WebSocket and HTTP traffic from a Kosmi chat session, specifically designed to reverse-engineer the image upload protocol.
|
||||
|
||||
## ⚠️ Known Issue
|
||||
|
||||
The Playwright browser may have restrictions that prevent file uploads. If uploads don't work in the automated browser, **use the manual capture method instead** (see `CAPTURE_UPLOAD_MANUALLY.md`).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `blurt.jpg` must exist in the project root directory (test image for upload)
|
||||
- Playwright must be installed: `go run github.com/playwright-community/playwright-go/cmd/playwright@latest install`
|
||||
|
||||
## Running the Monitor
|
||||
|
||||
```bash
|
||||
cd /Users/erikfredericks/dev-ai/HSO/irc-kosmi-relay
|
||||
./monitor-ws
|
||||
```
|
||||
|
||||
**Note:** Press Ctrl+C to stop (now properly handles cleanup).
|
||||
|
||||
## What It Does
|
||||
|
||||
1. Opens a browser window to https://app.kosmi.io/room/@hyperspaceout
|
||||
2. Captures all WebSocket messages (text and binary)
|
||||
3. Captures all HTTP requests and responses
|
||||
4. Logs everything to console AND `image-upload-capture.log`
|
||||
5. Attempts to trigger file upload dialog automatically
|
||||
6. If auto-trigger fails, waits for you to manually upload an image
|
||||
|
||||
## Manual Steps
|
||||
|
||||
1. Run the monitor
|
||||
2. Wait for the browser to open and load Kosmi
|
||||
3. Look for the attachment/upload button in the chat interface (usually a paperclip or + icon)
|
||||
4. Click it and select `blurt.jpg` from the project root
|
||||
5. Wait for the upload to complete
|
||||
6. Press Ctrl+C to stop monitoring
|
||||
|
||||
## What to Look For
|
||||
|
||||
### In the Console/Log File:
|
||||
|
||||
**HTTP Requests:**
|
||||
- Look for POST or PUT requests to upload endpoints
|
||||
- Common patterns: `/upload`, `/media`, `/file`, `/attachment`, `/image`
|
||||
- Note the URL, headers, and request body format
|
||||
|
||||
**HTTP Responses:**
|
||||
- Look for JSON responses containing image URLs
|
||||
- Note the URL structure (CDN, S3, etc.)
|
||||
|
||||
**WebSocket Messages:**
|
||||
- Look for GraphQL mutations related to file upload
|
||||
- Mutation names might include: `uploadFile`, `uploadImage`, `sendMedia`, `attachFile`
|
||||
- Note the mutation structure, variables, and response format
|
||||
- Check for binary WebSocket frames (image data sent directly)
|
||||
|
||||
**Example patterns to look for:**
|
||||
```graphql
|
||||
mutation UploadFile($file: Upload!, $roomId: String!) {
|
||||
uploadFile(file: $file, roomId: $roomId) {
|
||||
url
|
||||
id
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Or HTTP multipart form-data:
|
||||
```
|
||||
POST /api/upload
|
||||
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary...
|
||||
Authorization: Bearer <token>
|
||||
|
||||
------WebKitFormBoundary...
|
||||
Content-Disposition: form-data; name="file"; filename="blurt.jpg"
|
||||
Content-Type: image/jpeg
|
||||
|
||||
<binary data>
|
||||
------WebKitFormBoundary...--
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
All traffic is logged to:
|
||||
- Console (real-time)
|
||||
- `image-upload-capture.log` (persistent)
|
||||
|
||||
The log file includes timestamps and is easier to search through after the capture is complete.
|
||||
|
||||
## Alternative: Manual Capture
|
||||
|
||||
If the automated browser doesn't allow uploads, use your normal browser instead:
|
||||
|
||||
**See `CAPTURE_UPLOAD_MANUALLY.md` for detailed instructions.**
|
||||
|
||||
Quick summary:
|
||||
1. Open Chrome DevTools (F12)
|
||||
2. Go to Network tab, check "Preserve log"
|
||||
3. Upload an image in Kosmi
|
||||
4. Find the upload request
|
||||
5. Copy request/response details
|
||||
|
||||
## Next Steps
|
||||
|
||||
After capturing the upload flow:
|
||||
1. Analyze the log file (or manual capture) to identify the upload method
|
||||
2. Document findings in `KOSMI_IMAGE_UPLOAD.md`
|
||||
3. Implement the upload client in Go based on the findings
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/playwright-community/playwright-go"
|
||||
@@ -11,18 +14,34 @@ import (
|
||||
|
||||
const (
|
||||
roomURL = "https://app.kosmi.io/room/@hyperspaceout"
|
||||
logFile = "image-upload-capture.log"
|
||||
)
|
||||
|
||||
func main() {
|
||||
log.Println("🔍 Starting Kosmi WebSocket Monitor")
|
||||
log.Println("🔍 Starting Kosmi WebSocket Monitor (Image Upload Capture)")
|
||||
log.Printf("📡 Room URL: %s", roomURL)
|
||||
log.Println("This will capture ALL WebSocket traffic from the browser...")
|
||||
log.Printf("📝 Log file: %s", logFile)
|
||||
log.Println("This will capture ALL WebSocket traffic and HTTP requests...")
|
||||
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()
|
||||
|
||||
// Set up interrupt handler
|
||||
interrupt := make(chan os.Signal, 1)
|
||||
signal.Notify(interrupt, os.Interrupt)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Launch Playwright
|
||||
pw, err := playwright.Run()
|
||||
if err != nil {
|
||||
@@ -39,14 +58,52 @@ func main() {
|
||||
}
|
||||
defer browser.Close()
|
||||
|
||||
// Create context
|
||||
// Create context with permissions
|
||||
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"),
|
||||
AcceptDownloads: playwright.Bool(true),
|
||||
// Grant all permissions to avoid upload blocking
|
||||
Permissions: []string{"clipboard-read", "clipboard-write"},
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create context: %v", err)
|
||||
}
|
||||
|
||||
// Grant permissions for the Kosmi domain
|
||||
if err := context.GrantPermissions([]string{"clipboard-read", "clipboard-write"}, playwright.BrowserContextGrantPermissionsOptions{
|
||||
Origin: playwright.String("https://app.kosmi.io"),
|
||||
}); err != nil {
|
||||
logBoth("⚠️ Warning: Failed to grant permissions: %v", err)
|
||||
}
|
||||
|
||||
// Listen to all network requests
|
||||
context.On("request", func(request playwright.Request) {
|
||||
logBoth("🌐 [HTTP REQUEST] %s %s", request.Method(), request.URL())
|
||||
postData, err := request.PostData()
|
||||
if err == nil && postData != "" {
|
||||
logBoth(" POST Data: %s", postData)
|
||||
}
|
||||
})
|
||||
|
||||
context.On("response", func(response playwright.Response) {
|
||||
status := response.Status()
|
||||
url := response.URL()
|
||||
logBoth("📨 [HTTP RESPONSE] %d %s", status, url)
|
||||
|
||||
// Try to get response body for file uploads
|
||||
if status >= 200 && status < 300 {
|
||||
body, err := response.Body()
|
||||
if err == nil && len(body) > 0 && len(body) < 10000 {
|
||||
// Log if it looks like JSON
|
||||
var jsonData interface{}
|
||||
if json.Unmarshal(body, &jsonData) == nil {
|
||||
prettyJSON, _ := json.MarshalIndent(jsonData, " ", " ")
|
||||
logBoth(" Response Body: %s", string(prettyJSON))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Create page
|
||||
page, err := context.NewPage()
|
||||
if err != nil {
|
||||
@@ -54,7 +111,7 @@ func main() {
|
||||
}
|
||||
|
||||
// Inject WebSocket monitoring script BEFORE navigation
|
||||
log.Println("📝 Injecting WebSocket monitoring script...")
|
||||
logBoth("📝 Injecting WebSocket and file upload monitoring script...")
|
||||
if err := page.AddInitScript(playwright.Script{
|
||||
Content: playwright.String(`
|
||||
(function() {
|
||||
@@ -81,15 +138,22 @@ func main() {
|
||||
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 if binary data
|
||||
if (data instanceof ArrayBuffer || data instanceof Blob) {
|
||||
console.log('📤 [WS MONITOR] SEND #' + messageCount + ': [BINARY DATA]',
|
||||
'Type:', data.constructor.name,
|
||||
'Size:', data.byteLength || data.size);
|
||||
} else {
|
||||
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));
|
||||
}
|
||||
} catch (e) {
|
||||
// Not JSON
|
||||
}
|
||||
} catch (e) {
|
||||
// Not JSON
|
||||
}
|
||||
return originalSend.call(this, data);
|
||||
};
|
||||
@@ -97,15 +161,22 @@ func main() {
|
||||
// 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));
|
||||
// Check if binary data
|
||||
if (event.data instanceof ArrayBuffer || event.data instanceof Blob) {
|
||||
console.log('📥 [WS MONITOR] RECEIVE #' + messageCount + ': [BINARY DATA]',
|
||||
'Type:', event.data.constructor.name,
|
||||
'Size:', event.data.byteLength || event.data.size);
|
||||
} else {
|
||||
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
|
||||
}
|
||||
} catch (e) {
|
||||
// Not JSON
|
||||
}
|
||||
});
|
||||
|
||||
@@ -142,85 +213,112 @@ func main() {
|
||||
prefix = "ℹ️"
|
||||
}
|
||||
|
||||
log.Printf("%s [BROWSER %s] %s", prefix, msgType, text)
|
||||
logBoth("%s [BROWSER %s] %s", prefix, msgType, text)
|
||||
})
|
||||
|
||||
// Listen to file chooser events
|
||||
page.On("filechooser", func(fileChooser playwright.FileChooser) {
|
||||
logBoth("📁 [FILE CHOOSER] File dialog opened!")
|
||||
logBoth(" Multiple: %v", fileChooser.IsMultiple())
|
||||
|
||||
// Try to set the test image
|
||||
absPath, err := filepath.Abs("blurt.jpg")
|
||||
if err != nil {
|
||||
logBoth(" ❌ Failed to get absolute path: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
logBoth(" 📎 Setting file: %s", absPath)
|
||||
if err := fileChooser.SetFiles(absPath, playwright.FileChooserSetFilesOptions{}); err != nil {
|
||||
logBoth(" ❌ Failed to set file: %v", err)
|
||||
} else {
|
||||
logBoth(" ✅ File set successfully!")
|
||||
}
|
||||
})
|
||||
|
||||
// Navigate to room
|
||||
log.Printf("🌐 Navigating to %s...", roomURL)
|
||||
logBoth("🌐 Navigating to %s...", roomURL)
|
||||
if _, err := page.Goto(roomURL, playwright.PageGotoOptions{
|
||||
WaitUntil: playwright.WaitUntilStateDomcontentloaded,
|
||||
}); err != nil {
|
||||
log.Fatalf("Failed to navigate: %v", err)
|
||||
}
|
||||
|
||||
log.Println("✅ Page loaded! Monitoring WebSocket traffic...")
|
||||
log.Println("Press Ctrl+C to stop monitoring")
|
||||
log.Println()
|
||||
logBoth("✅ Page loaded! Monitoring WebSocket traffic and HTTP requests...")
|
||||
logBoth("📸 Please manually upload the test image (blurt.jpg) in the Kosmi chat")
|
||||
logBoth(" Look for the attachment/upload button in the chat interface")
|
||||
logBoth("Press Ctrl+C when done to stop monitoring")
|
||||
logBoth("")
|
||||
|
||||
// Wait for a bit to see initial traffic
|
||||
time.Sleep(5 * time.Second)
|
||||
|
||||
// Try to send a test message
|
||||
log.Println("\n📝 Attempting to send a test message via UI...")
|
||||
// Try to trigger file upload programmatically (fallback to manual)
|
||||
logBoth("\n📎 Attempting to trigger file upload dialog...")
|
||||
result, err := page.Evaluate(`
|
||||
(async function() {
|
||||
// Find the chat input
|
||||
const textareas = document.querySelectorAll('textarea');
|
||||
for (let ta of textareas) {
|
||||
if (ta.offsetParent !== null) {
|
||||
ta.value = 'Test message from monitor script 🔍';
|
||||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
ta.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
ta.focus();
|
||||
// Try to find file input or upload button
|
||||
const fileInputs = document.querySelectorAll('input[type="file"]');
|
||||
if (fileInputs.length > 0) {
|
||||
console.log('Found', fileInputs.length, 'file inputs');
|
||||
fileInputs[0].click();
|
||||
return { success: true, method: 'file-input' };
|
||||
}
|
||||
|
||||
// Wait a bit
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Try to find and click send button
|
||||
const buttons = document.querySelectorAll('button');
|
||||
for (let btn of buttons) {
|
||||
if (btn.textContent.toLowerCase().includes('send') ||
|
||||
btn.getAttribute('aria-label')?.toLowerCase().includes('send')) {
|
||||
btn.click();
|
||||
return { success: true, method: 'button' };
|
||||
}
|
||||
}
|
||||
|
||||
// If no button, press Enter
|
||||
const enterEvent = new KeyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
keyCode: 13,
|
||||
which: 13,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
});
|
||||
ta.dispatchEvent(enterEvent);
|
||||
return { success: true, method: 'enter' };
|
||||
// Try to find buttons with upload-related text/icons
|
||||
const buttons = document.querySelectorAll('button, [role="button"]');
|
||||
for (let btn of buttons) {
|
||||
const text = btn.textContent.toLowerCase();
|
||||
const ariaLabel = btn.getAttribute('aria-label')?.toLowerCase() || '';
|
||||
if (text.includes('upload') || text.includes('attach') || text.includes('file') ||
|
||||
ariaLabel.includes('upload') || ariaLabel.includes('attach') || ariaLabel.includes('file')) {
|
||||
console.log('Found upload button:', btn.textContent, ariaLabel);
|
||||
btn.click();
|
||||
return { success: true, method: 'upload-button' };
|
||||
}
|
||||
}
|
||||
return { success: false, error: 'No input found' };
|
||||
|
||||
return { success: false, error: 'No upload button found' };
|
||||
})();
|
||||
`)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("❌ Failed to send test message: %v", err)
|
||||
logBoth("❌ Failed to trigger upload: %v", err)
|
||||
logBoth("⚠️ FALLBACK: Please manually click the upload/attachment button in Kosmi")
|
||||
} else {
|
||||
resultMap := result.(map[string]interface{})
|
||||
if success, ok := resultMap["success"].(bool); ok && success {
|
||||
method := resultMap["method"].(string)
|
||||
log.Printf("✅ Test message sent via %s", method)
|
||||
log.Println("📊 Check the console output above to see the WebSocket traffic!")
|
||||
logBoth("✅ Triggered upload via %s", method)
|
||||
} else {
|
||||
log.Printf("❌ Failed to send: %v", resultMap["error"])
|
||||
logBoth("❌ Could not find upload button: %v", resultMap["error"])
|
||||
logBoth("⚠️ FALLBACK: Please manually click the upload/attachment button in Kosmi")
|
||||
}
|
||||
}
|
||||
|
||||
// Keep monitoring
|
||||
log.Println("\n⏳ Continuing to monitor... Press Ctrl+C to stop")
|
||||
logBoth("\n⏳ Monitoring all traffic... Upload an image in Kosmi, then press Ctrl+C to stop")
|
||||
logBoth("💡 TIP: If upload doesn't work in this browser, just note what you see in a normal browser")
|
||||
logBoth(" We mainly need to see the HTTP requests and WebSocket messages")
|
||||
|
||||
// Wait for interrupt
|
||||
// Wait for interrupt with proper cleanup
|
||||
<-interrupt
|
||||
log.Println("\n👋 Stopping monitor...")
|
||||
logBoth("\n👋 Stopping monitor...")
|
||||
|
||||
// Close browser and context to stop all goroutines
|
||||
if page != nil {
|
||||
page.Close()
|
||||
}
|
||||
if context != nil {
|
||||
context.Close()
|
||||
}
|
||||
if browser != nil {
|
||||
browser.Close()
|
||||
}
|
||||
|
||||
// Give goroutines time to finish
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
logBoth("✅ Monitor stopped. Check %s for captured traffic", logFile)
|
||||
}
|
||||
|
||||
|
||||
38
cmd/test-image-upload/main.go
Normal file
38
cmd/test-image-upload/main.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
bkosmi "github.com/42wim/matterbridge/bridge/kosmi"
|
||||
"github.com/42wim/matterbridge/bridge/jackbox"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Println("=== Kosmi Image Upload Test ===\n")
|
||||
|
||||
// Test 1: Generate a room code image
|
||||
fmt.Println("1. Generating room code image for 'TEST'...")
|
||||
imageData, err := jackbox.GenerateRoomCodeImage("TEST")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to generate image: %v", err)
|
||||
}
|
||||
fmt.Printf(" ✓ Generated image (%d bytes)\n\n", len(imageData))
|
||||
|
||||
// Test 2: Upload the image to Kosmi CDN
|
||||
fmt.Println("2. Uploading image to Kosmi CDN (https://img.kosmi.io/)...")
|
||||
imageURL, err := bkosmi.UploadImage(imageData, "roomcode_TEST.png")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to upload image: %v", err)
|
||||
}
|
||||
fmt.Printf(" ✓ Upload successful!\n\n")
|
||||
|
||||
// Test 3: Display the result
|
||||
fmt.Println("=== RESULT ===")
|
||||
fmt.Printf("Image URL: %s\n\n", imageURL)
|
||||
fmt.Println("Next steps:")
|
||||
fmt.Println("1. Open the URL in your browser to verify the image")
|
||||
fmt.Println("2. Send this URL to Kosmi chat via WebSocket")
|
||||
fmt.Println("3. Kosmi will display it as a thumbnail")
|
||||
}
|
||||
|
||||
41
cmd/test-roomcode-image/main.go
Normal file
41
cmd/test-roomcode-image/main.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/42wim/matterbridge/bridge/jackbox"
|
||||
)
|
||||
|
||||
func main() {
|
||||
testCases := []struct {
|
||||
gameTitle string
|
||||
roomCode string
|
||||
}{
|
||||
{"Quiplash 3", "ABCD"},
|
||||
{"Drawful 2", "XYZ123"},
|
||||
{"Fibbage XL", "TEST"},
|
||||
{"Trivia Murder Party", "ROOM42"},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
log.Printf("Generating image for: %s - %s", tc.gameTitle, tc.roomCode)
|
||||
|
||||
imageData, err := jackbox.GenerateRoomCodeImage(tc.roomCode, tc.gameTitle)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to generate image for %s: %v", tc.roomCode, err)
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("roomcode_%s.gif", tc.roomCode)
|
||||
if err := os.WriteFile(filename, imageData, 0644); err != nil {
|
||||
log.Fatalf("Failed to write image file %s: %v", filename, err)
|
||||
}
|
||||
|
||||
log.Printf("✅ Generated %s (%d bytes)", filename, len(imageData))
|
||||
}
|
||||
|
||||
log.Println("\n🎉 All room code images generated successfully!")
|
||||
log.Println("Check the current directory for roomcode_*.gif files")
|
||||
}
|
||||
|
||||
41
cmd/test-upload/main.go
Normal file
41
cmd/test-upload/main.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/42wim/matterbridge/bridge/jackbox"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Enable debug logging
|
||||
logrus.SetLevel(logrus.DebugLevel)
|
||||
|
||||
// Generate a test image
|
||||
log.Println("Generating test room code image...")
|
||||
imageData, err := jackbox.GenerateRoomCodeImage("TEST", "Quiplash 3")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to generate image: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("Generated %d bytes of GIF data", len(imageData))
|
||||
|
||||
// Save locally for verification
|
||||
if err := os.WriteFile("test_upload.gif", imageData, 0644); err != nil {
|
||||
log.Fatalf("Failed to save test file: %v", err)
|
||||
}
|
||||
log.Println("Saved test_upload.gif locally")
|
||||
|
||||
// Upload to Kosmi
|
||||
log.Println("Uploading to Kosmi CDN...")
|
||||
url, err := jackbox.UploadImageToKosmi(imageData, "test_roomcode.gif")
|
||||
if err != nil {
|
||||
log.Fatalf("Upload failed: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\n✅ SUCCESS! Uploaded to: %s\n", url)
|
||||
fmt.Println("\nPlease check the URL in your browser to verify it's animated!")
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
container_name: kosmi-irc-relay
|
||||
restart: unless-stopped
|
||||
# command: ["-conf", "/app/matterbridge.toml", "--muted"]
|
||||
command: ["-conf", "/app/matterbridge.toml"]
|
||||
volumes:
|
||||
# Mount your configuration file
|
||||
- ./matterbridge.toml:/app/matterbridge.toml:ro,z
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/42wim/matterbridge/bridge"
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
"github.com/42wim/matterbridge/bridge/jackbox"
|
||||
"github.com/42wim/matterbridge/gateway/samechannel"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
@@ -19,8 +20,10 @@ type Router struct {
|
||||
Gateways map[string]*Gateway
|
||||
Message chan config.Message
|
||||
MattermostPlugin chan config.Message
|
||||
JackboxManager *jackbox.Manager
|
||||
|
||||
logger *logrus.Entry
|
||||
logger *logrus.Entry
|
||||
rootLogger *logrus.Logger
|
||||
}
|
||||
|
||||
// NewRouter initializes a new Matterbridge router for the specified configuration and
|
||||
@@ -35,6 +38,15 @@ func NewRouter(rootLogger *logrus.Logger, cfg config.Config, bridgeMap map[strin
|
||||
MattermostPlugin: make(chan config.Message),
|
||||
Gateways: make(map[string]*Gateway),
|
||||
logger: logger,
|
||||
rootLogger: rootLogger,
|
||||
}
|
||||
|
||||
// Initialize Jackbox manager
|
||||
jackboxLogger := rootLogger.WithFields(logrus.Fields{"prefix": "jackbox"})
|
||||
r.JackboxManager = jackbox.NewManager(cfg, jackboxLogger)
|
||||
if err := r.JackboxManager.Initialize(); err != nil {
|
||||
logger.Errorf("Failed to initialize Jackbox integration: %v", err)
|
||||
// Don't fail startup if Jackbox integration fails
|
||||
}
|
||||
sgw := samechannel.New(cfg)
|
||||
gwconfigs := append(sgw.GetConfig(), cfg.BridgeValues().Gateway...)
|
||||
@@ -71,6 +83,11 @@ func (r *Router) Start() error {
|
||||
m[br.Account] = br
|
||||
}
|
||||
}
|
||||
// Inject Jackbox client into bridges if enabled
|
||||
if r.JackboxManager.IsEnabled() {
|
||||
r.injectJackboxClient(m)
|
||||
}
|
||||
|
||||
for _, br := range m {
|
||||
r.logger.Infof("Starting bridge: %s ", br.Account)
|
||||
err := br.Connect()
|
||||
@@ -99,6 +116,14 @@ func (r *Router) Start() error {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start webhook server if Jackbox is enabled
|
||||
if r.JackboxManager.IsEnabled() {
|
||||
if err := r.JackboxManager.StartWebhookServer(r.broadcastJackboxMessage); err != nil {
|
||||
r.logger.Errorf("Failed to start Jackbox webhook server: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
go r.handleReceive()
|
||||
//go r.updateChannelMembers()
|
||||
return nil
|
||||
@@ -191,3 +216,49 @@ func (r *Router) updateChannelMembers() {
|
||||
time.Sleep(time.Minute)
|
||||
}
|
||||
}
|
||||
|
||||
// injectJackboxClient injects the Jackbox client into all bridges
|
||||
func (r *Router) injectJackboxClient(bridges map[string]*bridge.Bridge) {
|
||||
client := r.JackboxManager.GetClient()
|
||||
if client == nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, br := range bridges {
|
||||
// Type assert to inject the client into supported bridge types
|
||||
switch bridger := br.Bridger.(type) {
|
||||
case interface{ SetJackboxClient(*jackbox.Client) }:
|
||||
bridger.SetJackboxClient(client)
|
||||
r.logger.Debugf("Injected Jackbox client into bridge %s", br.Account)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// broadcastJackboxMessage broadcasts a message from Jackbox to all connected bridges
|
||||
func (r *Router) broadcastJackboxMessage(message string) {
|
||||
// Check if Jackbox announcements are muted
|
||||
if r.JackboxManager != nil && r.JackboxManager.IsMuted() {
|
||||
r.logger.Debugf("Jackbox message suppressed (muted): %s", message)
|
||||
return
|
||||
}
|
||||
|
||||
r.logger.Infof("Broadcasting Jackbox message: %s", message)
|
||||
|
||||
// Send message to all gateways
|
||||
for _, gw := range r.Gateways {
|
||||
for _, br := range gw.Bridges {
|
||||
// Send to each bridge
|
||||
msg := config.Message{
|
||||
Text: " " + message, // Add space before message for proper formatting
|
||||
Username: "Jackbox",
|
||||
Account: "jackbox",
|
||||
Event: config.EventUserAction,
|
||||
}
|
||||
|
||||
// Send directly to the bridge
|
||||
if _, err := br.Send(msg); err != nil {
|
||||
r.logger.Errorf("Failed to send Jackbox message to %s: %v", br.Account, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
3
go.mod
3
go.mod
@@ -41,7 +41,10 @@ require (
|
||||
github.com/gobwas/httphead v0.1.0 // indirect
|
||||
github.com/gobwas/pool v0.2.1 // indirect
|
||||
github.com/gobwas/ws v1.4.0 // indirect
|
||||
github.com/gonutz/fontstash.go v1.0.0 // indirect
|
||||
github.com/gonutz/gofont v1.0.0 // indirect
|
||||
github.com/kettek/apng v0.0.0-20191108220231-414630eed80f // indirect
|
||||
github.com/kolesa-team/go-webp v1.0.5 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
|
||||
|
||||
8
go.sum
8
go.sum
@@ -49,6 +49,12 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a h1:l7A0loSszR5zHd/qK53ZIHMO8b3bBSmENnQ6eKnUT0A=
|
||||
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
github.com/gonutz/fontstash.go v1.0.0 h1:2uYAfMSxMMX/1mkHxrJmG336uPQQYX4fBhHBISv6Kkw=
|
||||
github.com/gonutz/fontstash.go v1.0.0/go.mod h1:kqaKWa1nZi1KmrHqxHNTnFsu65a4NkQLgPU60030yMg=
|
||||
github.com/gonutz/gl v1.0.0/go.mod h1:W+YuOtOvWK8ITUbz/5vm43HIU0OTFzM1q1rEj1VBi4A=
|
||||
github.com/gonutz/glfw v1.0.0/go.mod h1:ztHop1Nq2cOXD+1cX2OIcbIpWEDaU8SyBkNe0odynic=
|
||||
github.com/gonutz/gofont v1.0.0 h1:kXZdf7MzOa0tIkDnjxpESnJRaV9aG7lgO45luG/Ds04=
|
||||
github.com/gonutz/gofont v1.0.0/go.mod h1:1no14OryAqVZV8fvhRtbRmNU3reEHqItL6uLeySq0fs=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
@@ -66,6 +72,8 @@ github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uG
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/kettek/apng v0.0.0-20191108220231-414630eed80f h1:dnCYnTSltLuPMfc7dMrkz2uBUcEf/OFBR8yRh3oRT98=
|
||||
github.com/kettek/apng v0.0.0-20191108220231-414630eed80f/go.mod h1:x78/VRQYKuCftMWS0uK5e+F5RJ7S4gSlESRWI0Prl6Q=
|
||||
github.com/kolesa-team/go-webp v1.0.5 h1:GZQHJBaE8dsNKZltfwqsL0qVJ7vqHXsfA+4AHrQW3pE=
|
||||
github.com/kolesa-team/go-webp v1.0.5/go.mod h1:QmJu0YHXT3ex+4SgUvs+a+1SFCDcCqyZg+LbIuNNTnE=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
|
||||
@@ -4,7 +4,9 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
"github.com/42wim/matterbridge/gateway"
|
||||
@@ -20,6 +22,7 @@ var (
|
||||
flagDebug = flag.Bool("debug", false, "enable debug")
|
||||
flagVersion = flag.Bool("version", false, "show version")
|
||||
flagGops = flag.Bool("gops", false, "enable gops agent")
|
||||
flagMuted = flag.Bool("muted", false, "start with Jackbox announcements muted")
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -45,6 +48,11 @@ func main() {
|
||||
logger.Println("WARNING: THIS IS A DEVELOPMENT VERSION. Things may break.")
|
||||
}
|
||||
|
||||
// Debug: Log muted flag state
|
||||
if *flagMuted {
|
||||
logger.Info("Muted flag detected: --muted")
|
||||
}
|
||||
|
||||
cfg := config.NewConfig(rootLogger, *flagConfig)
|
||||
cfg.BridgeValues().General.Debug = *flagDebug
|
||||
|
||||
@@ -61,13 +69,48 @@ func main() {
|
||||
if err != nil {
|
||||
logger.Fatalf("Starting gateway failed: %s", err)
|
||||
}
|
||||
|
||||
// Set initial mute state BEFORE starting (so it's set when callbacks are created)
|
||||
if *flagMuted && r.JackboxManager != nil {
|
||||
r.JackboxManager.SetMuted(true)
|
||||
logger.Warn("🔇 Jackbox announcements starting MUTED (use SIGUSR1 to toggle)")
|
||||
}
|
||||
|
||||
// Setup signal handler for mute toggle (before starting)
|
||||
setupMuteToggle(r, logger)
|
||||
|
||||
if err = r.Start(); err != nil {
|
||||
logger.Fatalf("Starting gateway failed: %s", err)
|
||||
}
|
||||
|
||||
logger.Printf("Gateway(s) started successfully. Now relaying messages")
|
||||
select {}
|
||||
}
|
||||
|
||||
func setupMuteToggle(r *gateway.Router, logger *logrus.Entry) {
|
||||
if r.JackboxManager == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Create channel for SIGUSR1 signal
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGUSR1)
|
||||
|
||||
// Start goroutine to handle signal
|
||||
go func() {
|
||||
for range sigChan {
|
||||
isMuted := r.JackboxManager.ToggleMuted()
|
||||
if isMuted {
|
||||
logger.Warn("🔇 Jackbox announcements MUTED")
|
||||
} else {
|
||||
logger.Info("🔊 Jackbox announcements UNMUTED")
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
logger.Info("Signal handler ready: Send SIGUSR1 to toggle mute (kill -SIGUSR1 <pid> or docker kill -s SIGUSR1 <container>)")
|
||||
}
|
||||
|
||||
func setupLogger() *logrus.Logger {
|
||||
logger := &logrus.Logger{
|
||||
Out: os.Stdout,
|
||||
|
||||
BIN
monitor-ws
Executable file
BIN
monitor-ws
Executable file
Binary file not shown.
BIN
roomcode_ABCD.gif
Normal file
BIN
roomcode_ABCD.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
BIN
roomcode_TEST.gif
Normal file
BIN
roomcode_TEST.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
BIN
roomcode_XY12.gif
Normal file
BIN
roomcode_XY12.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
BIN
roomcode_ZZZZ.gif
Normal file
BIN
roomcode_ZZZZ.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
BIN
test-image-upload
Executable file
BIN
test-image-upload
Executable file
Binary file not shown.
BIN
test-long-title
Executable file
BIN
test-long-title
Executable file
Binary file not shown.
BIN
test-proper-roomcodes
Executable file
BIN
test-proper-roomcodes
Executable file
Binary file not shown.
BIN
test-roomcode-image
Executable file
BIN
test-roomcode-image
Executable file
Binary file not shown.
BIN
test-upload
Executable file
BIN
test-upload
Executable file
Binary file not shown.
BIN
test_upload.gif
Normal file
BIN
test_upload.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
Reference in New Issue
Block a user