wow that took awhile

This commit is contained in:
cottongin
2025-11-01 10:40:53 -04:00
parent 9143a0bc60
commit bd9513b86c
44 changed files with 4484 additions and 76 deletions

200
CAPTURE_UPLOAD_MANUALLY.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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)

View 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
View 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`

BIN
blurt.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

15
bridge/irc/formatting.go Normal file
View 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
}

View File

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

View File

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

Binary file not shown.

399
bridge/jackbox/client.go Normal file
View 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)
}
})
}

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

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

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

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

View File

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

View File

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

View 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")
}

View 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
View 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!")
}

View File

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

View File

@@ -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
View File

@@ -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
View File

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

View File

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

Binary file not shown.

BIN
roomcode_ABCD.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

BIN
roomcode_TEST.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

BIN
roomcode_XY12.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
roomcode_ZZZZ.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

BIN
test-image-upload Executable file

Binary file not shown.

BIN
test-long-title Executable file

Binary file not shown.

BIN
test-proper-roomcodes Executable file

Binary file not shown.

BIN
test-roomcode-image Executable file

Binary file not shown.

BIN
test-upload Executable file

Binary file not shown.

BIN
test_upload.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB