diff --git a/CAPTURE_UPLOAD_MANUALLY.md b/CAPTURE_UPLOAD_MANUALLY.md new file mode 100644 index 0000000..9c4bac9 --- /dev/null +++ b/CAPTURE_UPLOAD_MANUALLY.md @@ -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 + diff --git a/JACKBOX_INTEGRATION.md b/JACKBOX_INTEGRATION.md new file mode 100644 index 0000000..42c3975 --- /dev/null +++ b/JACKBOX_INTEGRATION.md @@ -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 : 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: !` + +## 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=` +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. + diff --git a/JACKBOX_TESTING.md b/JACKBOX_TESTING.md new file mode 100644 index 0000000..7de068a --- /dev/null +++ b/JACKBOX_TESTING.md @@ -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 : up` +- Relay logs show: `[jackbox] Vote recorded for : - 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 : down` +- Relay logs show: `[jackbox] Vote recorded for : - 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] 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] 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: !` +- Both Kosmi and IRC chats receive the notification +- Message appears as: `🎮 Coming up next: !` + +**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 (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) + diff --git a/KOSMI_IMAGE_UPLOAD.md b/KOSMI_IMAGE_UPLOAD.md new file mode 100644 index 0000000..3e27985 --- /dev/null +++ b/KOSMI_IMAGE_UPLOAD.md @@ -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 + diff --git a/MUTE_CONTROL.md b/MUTE_CONTROL.md new file mode 100644 index 0000000..d4fcda4 --- /dev/null +++ b/MUTE_CONTROL.md @@ -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 +``` + +**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 +``` + +**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 or docker kill -s SIGUSR1 ) +``` + +**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 +``` + diff --git a/ROOM_CODE_IMAGE_FEATURE.md b/ROOM_CODE_IMAGE_FEATURE.md new file mode 100644 index 0000000..fd62267 --- /dev/null +++ b/ROOM_CODE_IMAGE_FEATURE.md @@ -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) + diff --git a/ROOM_CODE_IMAGE_STATUS.md b/ROOM_CODE_IMAGE_STATUS.md new file mode 100644 index 0000000..bdbaea0 --- /dev/null +++ b/ROOM_CODE_IMAGE_STATUS.md @@ -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) + diff --git a/ROOM_CODE_IMPLEMENTATION_SUMMARY.md b/ROOM_CODE_IMPLEMENTATION_SUMMARY.md new file mode 100644 index 0000000..c1d7112 --- /dev/null +++ b/ROOM_CODE_IMPLEMENTATION_SUMMARY.md @@ -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 ✅ + diff --git a/WEBSOCKET_EVENT_FLOW.md b/WEBSOCKET_EVENT_FLOW.md new file mode 100644 index 0000000..82e5e06 --- /dev/null +++ b/WEBSOCKET_EVENT_FLOW.md @@ -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` + diff --git a/blurt.jpg b/blurt.jpg new file mode 100644 index 0000000..aba41cc Binary files /dev/null and b/blurt.jpg differ diff --git a/bridge/irc/formatting.go b/bridge/irc/formatting.go new file mode 100644 index 0000000..2f6107b --- /dev/null +++ b/bridge/irc/formatting.go @@ -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 +} + diff --git a/bridge/irc/handlers.go b/bridge/irc/handlers.go index cb2cc85..7c695dd 100644 --- a/bridge/irc/handlers.go +++ b/bridge/irc/handlers.go @@ -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 } diff --git a/bridge/irc/irc.go b/bridge/irc/irc.go index 7202df5..2e08893 100644 --- a/bridge/irc/irc.go +++ b/bridge/irc/irc.go @@ -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") +} diff --git a/bridge/jackbox/FiraMono-Bold.ttf b/bridge/jackbox/FiraMono-Bold.ttf new file mode 100644 index 0000000..6d4ffb0 Binary files /dev/null and b/bridge/jackbox/FiraMono-Bold.ttf differ diff --git a/bridge/jackbox/client.go b/bridge/jackbox/client.go new file mode 100644 index 0000000..e451719 --- /dev/null +++ b/bridge/jackbox/client.go @@ -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) + } + }) +} diff --git a/bridge/jackbox/image_upload.go b/bridge/jackbox/image_upload.go new file mode 100644 index 0000000..31977f4 --- /dev/null +++ b/bridge/jackbox/image_upload.go @@ -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 +} + diff --git a/bridge/jackbox/manager.go b/bridge/jackbox/manager.go new file mode 100644 index 0000000..34273bc --- /dev/null +++ b/bridge/jackbox/manager.go @@ -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 +} diff --git a/bridge/jackbox/roomcode_image.go b/bridge/jackbox/roomcode_image.go new file mode 100644 index 0000000..3b5000c --- /dev/null +++ b/bridge/jackbox/roomcode_image.go @@ -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 +} diff --git a/bridge/jackbox/votes.go b/bridge/jackbox/votes.go new file mode 100644 index 0000000..3e64a58 --- /dev/null +++ b/bridge/jackbox/votes.go @@ -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]") +} + diff --git a/bridge/jackbox/webhook.go b/bridge/jackbox/webhook.go new file mode 100644 index 0000000..fe39548 --- /dev/null +++ b/bridge/jackbox/webhook.go @@ -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) + } +} + diff --git a/bridge/jackbox/websocket_client.go b/bridge/jackbox/websocket_client.go new file mode 100644 index 0000000..d365e87 --- /dev/null +++ b/bridge/jackbox/websocket_client.go @@ -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 +} + diff --git a/bridge/kosmi/image_upload.go b/bridge/kosmi/image_upload.go new file mode 100644 index 0000000..0fac85f --- /dev/null +++ b/bridge/kosmi/image_upload.go @@ -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 +} + diff --git a/bridge/kosmi/kosmi.go b/bridge/kosmi/kosmi.go index 3583caf..39d58b2 100644 --- a/bridge/kosmi/kosmi.go +++ b/bridge/kosmi/kosmi.go @@ -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 @@ -169,12 +186,12 @@ func (b *Bkosmi) handleIncomingMessage(payload *NewMessagePayload) { // Send to Matterbridge b.Log.Debugf("Forwarding to Matterbridge channel=%s account=%s: %s", rmsg.Channel, rmsg.Account, rmsg.Text) - + if b.Remote == nil { b.Log.Error("Remote channel is nil! Cannot forward message") return } - + b.Remote <- rmsg } @@ -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") +} diff --git a/cmd/monitor-ws/README.md b/cmd/monitor-ws/README.md new file mode 100644 index 0000000..11fae2f --- /dev/null +++ b/cmd/monitor-ws/README.md @@ -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 + +------WebKitFormBoundary... +Content-Disposition: form-data; name="file"; filename="blurt.jpg" +Content-Type: image/jpeg + + +------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 + diff --git a/cmd/monitor-ws/main.go b/cmd/monitor-ws/main.go index 1a26512..dd97948 100644 --- a/cmd/monitor-ws/main.go +++ b/cmd/monitor-ws/main.go @@ -1,9 +1,12 @@ package main import ( + "encoding/json" + "fmt" "log" "os" "os/signal" + "path/filepath" "time" "github.com/playwright-community/playwright-go" @@ -11,17 +14,33 @@ 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() @@ -39,13 +58,51 @@ 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() @@ -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(); - - // 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 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' }; + } + + // 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) } diff --git a/cmd/test-image-upload/main.go b/cmd/test-image-upload/main.go new file mode 100644 index 0000000..3afb994 --- /dev/null +++ b/cmd/test-image-upload/main.go @@ -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") +} + diff --git a/cmd/test-roomcode-image/main.go b/cmd/test-roomcode-image/main.go new file mode 100644 index 0000000..7835206 --- /dev/null +++ b/cmd/test-roomcode-image/main.go @@ -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") +} + diff --git a/cmd/test-upload/main.go b/cmd/test-upload/main.go new file mode 100644 index 0000000..82ca885 --- /dev/null +++ b/cmd/test-upload/main.go @@ -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!") +} + diff --git a/docker-compose.yml b/docker-compose.yml index 3480eaf..8a8f2ca 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/gateway/router.go b/gateway/router.go index a0d5f40..1093e7d 100644 --- a/gateway/router.go +++ b/gateway/router.go @@ -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) + } + } + } +} diff --git a/go.mod b/go.mod index f9cc0c7..b539584 100644 --- a/go.mod +++ b/go.mod @@ -41,7 +41,10 @@ require ( github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/pool v0.2.1 // indirect github.com/gobwas/ws v1.4.0 // indirect + github.com/gonutz/fontstash.go v1.0.0 // indirect + github.com/gonutz/gofont v1.0.0 // indirect github.com/kettek/apng v0.0.0-20191108220231-414630eed80f // indirect + github.com/kolesa-team/go-webp v1.0.5 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect diff --git a/go.sum b/go.sum index b3fa902..44196b0 100644 --- a/go.sum +++ b/go.sum @@ -49,6 +49,12 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a h1:l7A0loSszR5zHd/qK53ZIHMO8b3bBSmENnQ6eKnUT0A= github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= +github.com/gonutz/fontstash.go v1.0.0 h1:2uYAfMSxMMX/1mkHxrJmG336uPQQYX4fBhHBISv6Kkw= +github.com/gonutz/fontstash.go v1.0.0/go.mod h1:kqaKWa1nZi1KmrHqxHNTnFsu65a4NkQLgPU60030yMg= +github.com/gonutz/gl v1.0.0/go.mod h1:W+YuOtOvWK8ITUbz/5vm43HIU0OTFzM1q1rEj1VBi4A= +github.com/gonutz/glfw v1.0.0/go.mod h1:ztHop1Nq2cOXD+1cX2OIcbIpWEDaU8SyBkNe0odynic= +github.com/gonutz/gofont v1.0.0 h1:kXZdf7MzOa0tIkDnjxpESnJRaV9aG7lgO45luG/Ds04= +github.com/gonutz/gofont v1.0.0/go.mod h1:1no14OryAqVZV8fvhRtbRmNU3reEHqItL6uLeySq0fs= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= @@ -66,6 +72,8 @@ github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uG github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/kettek/apng v0.0.0-20191108220231-414630eed80f h1:dnCYnTSltLuPMfc7dMrkz2uBUcEf/OFBR8yRh3oRT98= github.com/kettek/apng v0.0.0-20191108220231-414630eed80f/go.mod h1:x78/VRQYKuCftMWS0uK5e+F5RJ7S4gSlESRWI0Prl6Q= +github.com/kolesa-team/go-webp v1.0.5 h1:GZQHJBaE8dsNKZltfwqsL0qVJ7vqHXsfA+4AHrQW3pE= +github.com/kolesa-team/go-webp v1.0.5/go.mod h1:QmJu0YHXT3ex+4SgUvs+a+1SFCDcCqyZg+LbIuNNTnE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= diff --git a/matterbridge.go b/matterbridge.go index 397852a..764bf2d 100644 --- a/matterbridge.go +++ b/matterbridge.go @@ -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() { @@ -44,6 +47,11 @@ func main() { if strings.Contains(version.Release, "-dev") { 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 or docker kill -s SIGUSR1 )") +} + func setupLogger() *logrus.Logger { logger := &logrus.Logger{ Out: os.Stdout, diff --git a/monitor-ws b/monitor-ws new file mode 100755 index 0000000..9860045 Binary files /dev/null and b/monitor-ws differ diff --git a/roomcode_ABCD.gif b/roomcode_ABCD.gif new file mode 100644 index 0000000..8e52845 Binary files /dev/null and b/roomcode_ABCD.gif differ diff --git a/roomcode_TEST.gif b/roomcode_TEST.gif new file mode 100644 index 0000000..0f152ea Binary files /dev/null and b/roomcode_TEST.gif differ diff --git a/roomcode_XY12.gif b/roomcode_XY12.gif new file mode 100644 index 0000000..7d59572 Binary files /dev/null and b/roomcode_XY12.gif differ diff --git a/roomcode_ZZZZ.gif b/roomcode_ZZZZ.gif new file mode 100644 index 0000000..f9c8262 Binary files /dev/null and b/roomcode_ZZZZ.gif differ diff --git a/test-image-upload b/test-image-upload new file mode 100755 index 0000000..13cd272 Binary files /dev/null and b/test-image-upload differ diff --git a/test-long-title b/test-long-title new file mode 100755 index 0000000..fba9514 Binary files /dev/null and b/test-long-title differ diff --git a/test-proper-roomcodes b/test-proper-roomcodes new file mode 100755 index 0000000..2a53fe7 Binary files /dev/null and b/test-proper-roomcodes differ diff --git a/test-roomcode-image b/test-roomcode-image new file mode 100755 index 0000000..6dd3ea8 Binary files /dev/null and b/test-roomcode-image differ diff --git a/test-upload b/test-upload new file mode 100755 index 0000000..32ed283 Binary files /dev/null and b/test-upload differ diff --git a/test_upload.gif b/test_upload.gif new file mode 100644 index 0000000..4c102d7 Binary files /dev/null and b/test_upload.gif differ