From bcf563f36c5c235abed44b94441053ab91f033f3 Mon Sep 17 00:00:00 2001 From: jevb Date: Sat, 14 Mar 2026 19:57:39 +0100 Subject: [PATCH] chore: initial commit with project specs and configuration Add all specification files (CHATSERVER, PROTOCOL, SCHEMA, API, SETUP), Claude Code config, and skill definitions for the OwnCord chat platform. --- API.md | 237 +++++++++++++ CHATSERVER.md | 186 ++++++++++ PROMPTS.md | 546 +++++++++++++++++++++++++++++ PROTOCOL.md | 275 +++++++++++++++ README.md | 1 + SCHEMA.md | 291 +++++++++++++++ SETUP.md | 116 ++++++ SKILL.md | 230 ++++++++++++ skills/go-server/SKILL.md | 307 ++++++++++++++++ skills/sqlite-patterns/SKILL.md | 326 +++++++++++++++++ skills/webrtc-voice/SKILL.md | 229 ++++++++++++ skills/websocket-protocol/SKILL.md | 303 ++++++++++++++++ 12 files changed, 3047 insertions(+) create mode 100644 API.md create mode 100644 CHATSERVER.md create mode 100644 PROMPTS.md create mode 100644 PROTOCOL.md create mode 100644 README.md create mode 100644 SCHEMA.md create mode 100644 SETUP.md create mode 100644 SKILL.md create mode 100644 skills/go-server/SKILL.md create mode 100644 skills/sqlite-patterns/SKILL.md create mode 100644 skills/webrtc-voice/SKILL.md create mode 100644 skills/websocket-protocol/SKILL.md diff --git a/API.md b/API.md new file mode 100644 index 00000000..93149920 --- /dev/null +++ b/API.md @@ -0,0 +1,237 @@ +# REST API Spec + +Base URL: `https://{server}:{port}/api` + +Auth: session token in cookie `session` (set on login) or `Authorization: Bearer {token}` header for programmatic access. + +All responses are JSON. Errors return `{ "error": "CODE", "message": "Human-readable detail" }`. + +--- + +## Auth + +| Method | Endpoint | Auth | Description | +|--------|----------|------|-------------| +| POST | `/api/auth/register` | None (requires invite code) | Create account | +| POST | `/api/auth/login` | None | Login, returns session token | +| POST | `/api/auth/logout` | Yes | Invalidate current session | +| POST | `/api/auth/verify-totp` | Partial (after login with 2FA) | Submit TOTP code | + +### POST /api/auth/register +```json +// Request +{ "username": "alex", "password": "strongpassword", "invite_code": "abc123" } +// Response 201 +{ "user": { "id": 1, "username": "alex" }, "token": "session-token" } +``` + +### POST /api/auth/login +```json +// Request +{ "username": "alex", "password": "strongpassword" } +// Response 200 (no 2FA) +{ "token": "session-token", "requires_2fa": false } +// Response 200 (2FA required) +{ "partial_token": "temp-token", "requires_2fa": true } +``` + +--- + +## Users + +| Method | Endpoint | Auth | Description | +|--------|----------|------|-------------| +| GET | `/api/users/me` | Yes | Get current user profile | +| PATCH | `/api/users/me` | Yes | Update own profile (username, avatar) | +| PUT | `/api/users/me/password` | Yes | Change password | +| POST | `/api/users/me/totp/enable` | Yes | Start 2FA setup, returns QR URI + backup codes | +| POST | `/api/users/me/totp/confirm` | Yes | Confirm 2FA with first TOTP code | +| DELETE | `/api/users/me/totp` | Yes | Disable 2FA | +| GET | `/api/users/me/sessions` | Yes | List active sessions | +| DELETE | `/api/users/me/sessions/{id}` | Yes | Revoke a session | + +--- + +## Channels + +| Method | Endpoint | Auth | Description | +|--------|----------|------|-------------| +| GET | `/api/channels` | Yes | List all channels user can see | +| GET | `/api/channels/{id}/messages` | Yes | Paginated message history | +| GET | `/api/channels/{id}/pins` | Yes | Get pinned messages | +| POST | `/api/channels/{id}/pins/{msg_id}` | Yes (mod) | Pin a message | +| DELETE | `/api/channels/{id}/pins/{msg_id}` | Yes (mod) | Unpin a message | + +### GET /api/channels/{id}/messages +Query params: `before` (message ID), `limit` (1-100, default 50) +```json +// Response 200 +{ + "messages": [ + { + "id": 1042, + "channel_id": 5, + "user": { "id": 1, "username": "alex", "avatar": "uuid.png" }, + "content": "Hello!", + "reply_to": null, + "attachments": [], + "reactions": [{ "emoji": "πŸ‘", "count": 2, "me": true }], + "pinned": false, + "edited_at": null, + "deleted": false, + "timestamp": "2026-03-14T10:30:00Z" + } + ], + "has_more": true +} +``` + +--- + +## File Uploads + +| Method | Endpoint | Auth | Description | +|--------|----------|------|-------------| +| POST | `/api/uploads` | Yes | Upload a file (multipart) | +| GET | `/api/files/{uuid}` | Yes | Download a file | + +### POST /api/uploads +Multipart form data. Field: `file`. Max size from server config (default 25MB). + +```json +// Response 201 +{ "id": "upload-uuid", "filename": "photo.jpg", "size": 204800, "mime": "image/jpeg", "url": "/api/files/upload-uuid" } +``` + +Server validates: magic bytes, rejects executables, strips EXIF, stores with UUID filename. + +--- + +## Search + +| Method | Endpoint | Auth | Description | +|--------|----------|------|-------------| +| GET | `/api/search` | Yes | Full-text search across accessible channels | + +Query params: `q` (search query), `channel_id` (optional filter), `limit` (default 25) + +```json +// Response 200 +{ + "results": [ + { + "message_id": 1042, + "channel_id": 5, + "channel_name": "general", + "user": { "id": 1, "username": "alex" }, + "content": "...matched text...", + "timestamp": "2026-03-14T10:30:00Z" + } + ] +} +``` + +--- + +## Invites + +| Method | Endpoint | Auth | Description | +|--------|----------|------|-------------| +| GET | `/api/invites` | Yes (admin) | List all invites | +| POST | `/api/invites` | Yes (manage_invites) | Create an invite | +| DELETE | `/api/invites/{id}` | Yes (manage_invites) | Revoke an invite | + +### POST /api/invites +```json +// Request +{ "max_uses": 5, "expires_in_hours": 48 } +// Response 201 +{ "id": 1, "code": "abc123def", "url": "chatserver://invite/abc123def", "max_uses": 5, "expires_at": "2026-03-16T10:30:00Z" } +``` + +--- + +## Admin Endpoints (admin panel uses these) + +| Method | Endpoint | Auth | Description | +|--------|----------|------|-------------| +| GET | `/api/admin/stats` | Admin | Server stats (users, messages, disk, uptime) | +| GET | `/api/admin/users` | Admin | List all users with details | +| PATCH | `/api/admin/users/{id}` | Admin | Update user (role, ban/unban) | +| DELETE | `/api/admin/users/{id}/sessions` | Admin | Force logout a user | +| POST | `/api/admin/channels` | Admin | Create channel | +| PATCH | `/api/admin/channels/{id}` | Admin | Update channel | +| DELETE | `/api/admin/channels/{id}` | Admin | Delete channel | +| GET | `/api/admin/audit-log` | Admin | View audit log (paginated) | +| POST | `/api/admin/backup` | Owner | Trigger manual backup | +| GET | `/api/admin/backups` | Owner | List available backups | +| POST | `/api/admin/backups/{id}/restore` | Owner | Restore from backup | +| GET | `/api/admin/settings` | Admin | Get server settings | +| PATCH | `/api/admin/settings` | Admin | Update server settings | +| GET | `/api/admin/update-check` | Admin | Check for new server version | + +--- + +## WebRTC / TURN Credentials + +| Method | Endpoint | Auth | Description | +|--------|----------|------|-------------| +| GET | `/api/voice/credentials` | Yes | Get time-limited TURN credentials | + +```json +// Response 200 +{ + "ice_servers": [ + { "urls": "stun:server:3478" }, + { "urls": "turn:server:3478", "username": "timestamp:userid", "credential": "hmac-hash" } + ], + "expires_in": 86400 +} +``` + +--- + +## Custom Emoji + +| Method | Endpoint | Auth | Description | +|--------|----------|------|-------------| +| GET | `/api/emoji` | Yes | List all custom emoji | +| POST | `/api/emoji` | Yes (admin) | Upload new emoji | +| DELETE | `/api/emoji/{id}` | Yes (admin) | Delete emoji | + +--- + +## Soundboard + +| Method | Endpoint | Auth | Description | +|--------|----------|------|-------------| +| GET | `/api/sounds` | Yes | List all soundboard sounds | +| POST | `/api/sounds` | Yes (permission) | Upload a sound | +| DELETE | `/api/sounds/{id}` | Yes (admin) | Delete a sound | + +--- + +## Health Check + +| Method | Endpoint | Auth | Description | +|--------|----------|------|-------------| +| GET | `/api/health` | None | Returns 200 if server is running | + +```json +{ "status": "ok", "version": "1.0.0", "uptime": 86400 } +``` + +--- + +## Error Codes + +| Code | HTTP Status | Meaning | +|------|-------------|---------| +| `UNAUTHORIZED` | 401 | Missing or invalid session | +| `FORBIDDEN` | 403 | Insufficient permissions | +| `NOT_FOUND` | 404 | Resource not found | +| `RATE_LIMITED` | 429 | Too many requests (includes `retry_after`) | +| `INVALID_INPUT` | 400 | Bad request body or params | +| `CONFLICT` | 409 | e.g. username already taken | +| `TOO_LARGE` | 413 | File exceeds upload limit | +| `SERVER_ERROR` | 500 | Internal server error | diff --git a/CHATSERVER.md b/CHATSERVER.md new file mode 100644 index 00000000..4b9d7682 --- /dev/null +++ b/CHATSERVER.md @@ -0,0 +1,186 @@ +# ChatServer β€” Self-Hosted Windows Chat Platform + +Native Windows desktop client + self-hosted server. Two executables: `chatserver.exe` (server) and `chatclient.exe` (client app). Server operator runs the server, friends install the client. + +## Tech Stack + +### Server (`chatserver.exe`) +- **Go** β€” Single exe, no dependencies. Embeds admin web UI via `go embed`. +- **SQLite** β€” Single `.db` file. WAL mode. Zero config. +- **Pion** β€” Pure Go WebRTC. Voice/video/TURN built into the exe. +- **Admin panel** β€” Web-based only, served at `/admin`. Browser access, not part of the client. + +### Client (`chatclient.exe`) +Choose the best language and framework for a native Windows desktop app based on these requirements: +- Must be a native desktop application, NOT browser-based (no Electron) +- Small install size (~20-40MB) and low RAM usage (~50-100MB idle) +- WebSocket client for real-time chat +- WebRTC integration for voice/video +- Low-latency audio I/O (WASAPI or equivalent) +- Global keyboard hooks for push-to-talk that work in fullscreen games +- System tray with badge overlay +- Windows toast notifications +- DXGI Desktop Duplication for screen capture +- Windows Credential Manager for secure token storage +- Installer via NSIS or WiX + +## Architecture + +``` +SERVER (chatserver.exe) β€” runs on the host machine +β”œβ”€β”€ REST API (Go net/http) +β”œβ”€β”€ WebSocket Hub (real-time messages, presence, typing) +β”œβ”€β”€ WebRTC SFU + TURN Relay (Pion) +β”œβ”€β”€ SQLite Database (data/chatserver.db) +β”œβ”€β”€ File Storage (data/uploads/) +β”œβ”€β”€ Admin Web UI (embedded, browser-based, /admin) +└── config.yaml + +CLIENT (chatclient.exe) β€” installed by each friend +β”œβ”€β”€ Native Windows UI +β”œβ”€β”€ WebSocket Client (chat connection) +β”œβ”€β”€ WebRTC Client (voice/video) +β”œβ”€β”€ Audio Engine (device management, noise suppression) +β”œβ”€β”€ Local Settings (connection profiles, keybinds, audio config) +└── System Tray Integration +``` + +### How It Works +1. Server operator runs `chatserver.exe` on their PC/home server +2. Friends download and install `chatclient.exe` +3. Client connects to the server via IP/domain + port +4. All chat, voice, video, and file transfers go through the server +5. Admin manages the server through a browser at `https://server-ip:port/admin` + +--- + +## Phase 1: Protocol & Server Core (2–3 weeks) + +- [ ] Define client-server protocol over WebSocket (JSON messages with type/payload structure) +- [ ] Message types: auth, chat, typing, presence, channel_update, voice_signal, file_transfer +- [ ] Server: Go project with `go embed` for admin panel static files only +- [ ] SQLite setup with migrations on startup (users, channels, messages, sessions, roles, invites) +- [ ] config.yaml generation on first run (port, server name, max upload size, voice quality, TLS mode) +- [ ] Server systray icon (getlantern/systray) β€” minimize to tray, status indicator, open admin panel, quit +- [ ] Windows Firewall handling on first launch +- [ ] Optional: register as Windows Service for headless operation + +## Phase 2: Auth & Security (2–3 weeks) + +- [ ] Invite-only registration β€” server generates invite codes, client has "Redeem Invite" flow +- [ ] bcrypt (cost 12+) passwords, server-side session tokens (256-bit random) +- [ ] Client stores auth token securely via Windows Credential Manager / DPAPI +- [ ] Login rate limiting: 5 attempts/min/IP, lockout after 10 failures +- [ ] Optional TOTP 2FA (`pquerna/otp`) β€” client shows QR code during setup, prompts on login +- [ ] Roles: Owner, Admin, Moderator, Member + custom roles with bitfield permissions +- [ ] Per-channel permission overrides, enforced server-side on every action +- [ ] TLS modes: self-signed (default, auto-generated), Let's Encrypt, manual cert, off (Tailscale) +- [ ] Client: certificate pinning or trust-on-first-use (TOFU) for self-signed certs + +## Phase 3: Client App β€” Core UI (3–4 weeks) + +- [ ] Connection dialog: server address, port, login/register, invite code entry +- [ ] Save server profiles (connect to multiple servers like TeamSpeak) +- [ ] Main window layout: server list sidebar β†’ channel list β†’ message area β†’ member list +- [ ] Channel tree view with categories, text channels, voice channels +- [ ] Message rendering: markdown, code blocks, timestamps, avatars, replies, reactions +- [ ] Message input: multi-line, markdown preview, emoji picker, file drag-and-drop +- [ ] Unread indicators, @mention badges per channel +- [ ] System tray: minimize to tray, notification popups, badge count +- [ ] Keyboard shortcuts: Ctrl+K quick switcher, Escape to close panels, customizable push-to-talk key +- [ ] Settings window: account, appearance (light/dark theme), notifications, audio devices, keybinds + +## Phase 4: Real-Time Chat Features (2–3 weeks) + +- [ ] WebSocket client with auto-reconnect, exponential backoff, message replay on reconnect +- [ ] Send/receive messages in real-time, append to scrollback +- [ ] Message history: paginated from server on channel switch, scroll-to-load-more +- [ ] Threads, replies (inline preview), reactions (emoji), edit, delete +- [ ] Typing indicators ("X is typing..." below input) +- [ ] Online/offline/idle/DnD presence with status icons in member list +- [ ] File uploads: drag-and-drop or clipboard paste, progress bar, inline image previews +- [ ] Client-side file validation before upload (size check, warn on large files) +- [ ] Search: query server FTS5 endpoint, display results with jump-to-message +- [ ] Windows toast notifications with action buttons (reply, mark read) +- [ ] Notification sounds (configurable, per-channel mute/override) + +## Phase 5: Voice & Video (3–5 weeks) + +- [ ] WebRTC integration in native client for voice/video +- [ ] Audio device selection: input/output dropdowns in settings, live preview +- [ ] Voice channels: click to join/leave, show connected users with speaking indicators +- [ ] Voice controls: mute (button + keybind), deafen, per-user volume sliders +- [ ] Push-to-talk: configurable global hotkey that works in fullscreen games +- [ ] Voice activity detection with configurable sensitivity +- [ ] Noise suppression (RNNoise or equivalent, bundled with client) +- [ ] Server-side: Pion SFU with DTLS-SRTP, built-in TURN relay with per-session credentials +- [ ] Voice quality: low (32kbps) / medium (64kbps) / high (128kbps Opus) +- [ ] Screen sharing via DXGI Desktop Duplication, sent as video track +- [ ] Video calls: camera capture, displayed in voice channel panel +- [ ] Soundboard: short clips, hotkey triggers, role-based permissions, play cooldown + +## Phase 6: Admin Panel β€” Web-Based (1–2 weeks) + +- [ ] Served by server at `/admin`, browser-only access +- [ ] Auth: admin credentials, session-based +- [ ] Dashboard: connected users, message count, disk usage, CPU/RAM, uptime +- [ ] User management: list all, edit roles, ban/unban, reset password, force disconnect +- [ ] Channel management: create, rename, reorder, set permissions, archive +- [ ] Invite management: generate, view active, set expiry/use limit, revoke +- [ ] Server settings: name, icon, MOTD, max upload size, voice quality, TLS config +- [ ] Moderation: kick, ban, temp ban, slow mode, mute, word filter, audit log +- [ ] Backup: trigger manual backup, configure schedule, view/restore from admin panel +- [ ] Built with simple HTML/CSS/JS embedded in the server binary + +## Phase 7: Distribution & Updates (1–2 weeks) + +- [ ] **Server:** GitHub Actions builds `chatserver.exe` (amd64), SHA256 checksum, GitHub Release +- [ ] **Client:** NSIS or WiX installer β€” Program Files, Start Menu shortcut, optional auto-start, protocol handler for `chatserver://` invite links +- [ ] Client auto-update: check GitHub releases on launch, prompt to download + install +- [ ] Server update: admin panel shows available update, one-click download + restart +- [ ] Docs: Quick Start, Port Forwarding guide, Tailscale guide, Client install guide +- [ ] Security hardening checklist for server operators +- [ ] SECURITY.md, README.md, CONTRIBUTING.md + +--- + +## Windows-Specific Details + +### Client +- **Installer:** NSIS or WiX (~20-40MB). Registers `chatserver://` protocol handler for invite links. +- **Auto-start:** Registry key `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`. +- **Credentials:** Auth tokens stored in Windows Credential Manager (DPAPI). +- **Push-to-talk:** Global keyboard hook via `SetWindowsHookEx` β€” works in fullscreen games. +- **Audio:** WASAPI for low-latency capture/playback. +- **Screen capture:** DXGI Desktop Duplication API. +- **Notifications:** Windows Toast notifications with action buttons. +- **Tray:** System tray icon with unread badge overlay. + +### Server +- **Firewall:** Prompt on first run. Installer can pre-register firewall rule. +- **SmartScreen:** Unsigned exe shows warning. Code signing cert resolves this. +- **Data path:** `data/` next to exe. Installer version uses `%APPDATA%/ChatServer/`. +- **Logs:** `data/logs/` with daily rotation, viewable from admin panel. +- **Service mode:** `chatserver.exe --service install` to register as Windows Service. + +## Security Priorities + +**Critical:** Invite-only registration, bcrypt auth, TLS (self-signed minimum), file upload validation (magic bytes, block executables), input sanitization server-side, credential storage via DPAPI, backup system. + +**High:** Rate limiting, TOTP 2FA, role permissions, WebSocket auth, TURN credentials, cert pinning/TOFU, update integrity (SHA256). + +## Server Libraries (Go) + +| Purpose | Library | +|---|---| +| HTTP/routing | `net/http` + `chi` | +| WebSocket | `nhooyr.io/websocket` | +| WebRTC/TURN | `pion/webrtc` + `pion/turn` | +| SQLite | `modernc.org/sqlite` (pure Go) | +| Auth | `golang.org/x/crypto/bcrypt` | +| TOTP | `pquerna/otp` | +| Sanitization | `bluemonday` | +| TLS | `golang.org/x/crypto/acme/autocert` | +| Systray | `getlantern/systray` | +| Config | `koanf` | +| Logging | `log/slog` | diff --git a/PROMPTS.md b/PROMPTS.md new file mode 100644 index 00000000..9ff5b726 --- /dev/null +++ b/PROMPTS.md @@ -0,0 +1,546 @@ +# Claude Code Prompt Playbook + +Feed these prompts to Claude Code in order. Each step builds on the previous one. Don't move to the next step until the current one compiles, runs, and works. + +Test each step by actually running the exe and trying it yourself. + +--- + +## Milestone 1: Two Exes That Connect (Week 1) + +The goal: `chatserver.exe` runs, `chatclient.exe` connects to it, you see proof of connection on both sides. + +### Prompt 1.1 β€” Server skeleton + +``` +@CLAUDE.md @SCHEMA.md + +Create the server project in the server/ folder. + +- Go module with the folder structure from the go-server skill +- SQLite database that creates itself on first run with the users, sessions, channels, and messages tables from SCHEMA.md +- config.yaml generated on first run with defaults (port 8443, server name "My Server") +- A single REST endpoint: GET /api/health that returns {"status":"ok","version":"0.1.0"} +- TLS using a self-signed certificate generated automatically on first run +- Compiles to chatserver.exe + +I want to run chatserver.exe and hit https://localhost:8443/api/health in my browser and see the JSON response. That's the only goal for now. +``` + +### Prompt 1.2 β€” Registration and login + +``` +@CLAUDE.md @API.md @SCHEMA.md + +Add auth to the server: + +- POST /api/auth/register β€” takes username, password, invite_code. Hashes password with bcrypt. Returns a session token. +- POST /api/auth/login β€” validates credentials, returns session token. +- Auth middleware that validates the session token from a cookie or Authorization header. +- On first server run, auto-generate one invite code and print it to the console so I can use it to register the first account. +- Rate limiting on login: 5 attempts per minute per IP. + +Don't build any other endpoints yet. I just want to be able to register and login using curl or Postman and get back a valid session token. +``` + +### Prompt 1.3 β€” WebSocket with auth + +``` +@CLAUDE.md @PROTOCOL.md + +Add the WebSocket endpoint to the server: + +- GET /ws β€” upgrades to WebSocket +- Client must send an "auth" message with their session token as the first message +- Server responds with "auth_ok" containing the user info, or "auth_error" and closes the connection +- After auth, server sends a "ready" message with the list of channels and online members +- Implement the Hub pattern: register/unregister clients, track who's connected +- Ping/pong heartbeat every 30 seconds +- On first run, create a #general text channel automatically + +Test: I should be able to connect with a WebSocket client (like websocat or a browser console), send auth, and get back auth_ok + ready. +``` + +### Prompt 1.4 β€” Client app skeleton + +``` +@CLAUDE.md + +Now create the client application in the client/ folder. Choose the best language and framework for a native Windows desktop app based on the requirements in CHATSERVER.md. + +For now, build just: +- A connection dialog window: server address field, port field, username field, password field, a "Login" button and a "Register" button (with an invite code field that shows when Register is selected) +- On login/register success: store the session token securely (Windows Credential Manager) +- Connect to the server's WebSocket endpoint with the token +- On auth_ok: show a basic main window that just says "Connected to [server name]" and lists the online members from the ready payload +- On disconnect: show "Disconnected" with a reconnect button + +This is the absolute minimum β€” just prove the client can connect and authenticate with the server. No chat UI yet. Compiles to chatclient.exe with a simple build command. +``` + +### Prompt 1.5 β€” Send and receive messages + +``` +@CLAUDE.md @PROTOCOL.md + +Now wire up basic text chat between server and client. + +Server: +- Handle "chat_send" WebSocket messages: validate permissions, sanitize with bluemonday, store in SQLite, broadcast "chat_message" to all clients in the channel +- Handle GET /api/channels/{id}/messages for paginated history (50 messages, before cursor) + +Client: +- Replace the "Connected" placeholder with a real chat UI: channel name at top, scrollable message area in the center, text input at the bottom with a Send button +- Display incoming messages in real-time as they arrive over WebSocket +- Load message history from the REST endpoint when opening a channel +- Show username, message content, and timestamp for each message +- Basic markdown: **bold** and *italic* only for now + +Test with two instances of the client connecting to the same server. Type a message in one, it should appear in the other instantly. +``` + +--- + +## Milestone 2: Usable Chat App (Week 2-3) + +At this point you have two exes that connect and chat works. Now make it actually usable. + +### Prompt 2.1 β€” Multiple channels + +``` +@CLAUDE.md @PROTOCOL.md @SCHEMA.md + +Add multi-channel support: + +Server: +- Handle channel subscriptions in the hub β€” only broadcast messages to clients viewing that channel +- Send channel_create/channel_update/channel_delete events +- On first run, create #general, #random, and #announcements channels + +Client: +- Add a channel list sidebar on the left showing all channels +- Click a channel to switch to it and load its message history +- Show the active channel name at the top +- Unread indicator (bold channel name) when a channel has new messages you haven't seen +- Remember which channel was last open when switching back +``` + +### Prompt 2.2 β€” Invite system + roles + +``` +@CLAUDE.md @API.md @SCHEMA.md + +Build the invite and role system: + +Server: +- POST /api/invites to generate invite codes (admin only). Support max_uses and expires_in_hours. +- Invite codes required for registration β€” no open signup. +- Implement the role system from SCHEMA.md: Owner, Admin, Moderator, Member. +- First registered user becomes Owner automatically. +- Permission checks on all existing endpoints using the bitfield system. + +Client: +- When registering, require an invite code. +- Show role names/colors next to usernames in the member list. +- The Owner should see a small admin indicator somewhere. + +No admin panel yet β€” just the backend enforcement. +``` + +### Prompt 2.3 β€” Message features + +``` +@CLAUDE.md @PROTOCOL.md + +Add message features: + +Server: +- Handle chat_edit (own messages only), chat_delete (own or moderator+) +- Handle reaction_add, reaction_remove +- Handle replies (reply_to field) +- Typing indicator: typing_start broadcast to channel + +Client: +- Right-click a message: Reply, Edit (own only), Delete (own or mod), Copy Text +- Edit mode: press up arrow to edit last message, or right-click > Edit. Shows original text in input. +- Reply: click Reply, show a small preview above the input, send with reply_to +- Reactions: hover a message to see a small emoji button, click to add reaction. Show reaction badges below messages. +- "X is typing..." indicator below the message input +``` + +### Prompt 2.4 β€” File uploads + +``` +@CLAUDE.md @API.md + +Add file sharing: + +Server: +- POST /api/uploads: multipart upload, validate magic bytes, reject executables (.exe, .bat, .ps1, .cmd, .scr, .msi), strip EXIF from images, store with UUID filename, configurable size limit from config (default 25MB) +- GET /api/files/{uuid}: serve file with auth check +- Link attachments to messages via the attachments table + +Client: +- Drag and drop files onto the message area or input to upload +- Paste images from clipboard (Ctrl+V) +- Show upload progress bar +- Display uploaded images inline in the message (thumbnail, click to open full size) +- Non-image files show as a download link with filename and size +``` + +### Prompt 2.5 β€” Presence + system tray + notifications + +``` +@CLAUDE.md @PROTOCOL.md + +Add presence, tray, and notifications: + +Server: +- Track presence: online, idle, dnd, offline +- Auto-set idle after 10 minutes of no WebSocket activity +- Broadcast presence changes to all clients + +Client: +- Member list shows online status icons (green dot, yellow, red, grey) +- Sort member list: online first, then idle, then offline +- System tray icon β€” minimize to tray on close, left-click to restore +- Unread badge count on the tray icon +- Windows toast notification when a message arrives and the window is unfocused +- Status selector in the bottom bar: online, idle, DnD, invisible +``` + +### Prompt 2.6 β€” Search + settings + +``` +@CLAUDE.md @API.md + +Add search and user settings: + +Server: +- GET /api/search with FTS5 query, scoped to channels the user can read +- PUT /api/users/me/password for password changes + +Client: +- Search bar (Ctrl+K): type to search messages across all channels. Show results with channel name, author, snippet, timestamp. Click to jump to message. +- Settings window with tabs: + - Account: change password, change avatar (upload) + - Appearance: light/dark theme toggle, font size + - Notifications: enable/disable, per-channel mute +``` + +--- + +## Milestone 3: Voice Chat (Week 4-6) + +Text chat is solid. Now add voice. + +### Prompt 3.1 β€” Voice channel UI + signaling + +``` +@CLAUDE.md @PROTOCOL.md + +Add voice channel infrastructure β€” signaling only, no actual audio yet: + +Server: +- Voice channel type in the database +- Handle voice_join, voice_leave WebSocket events +- Track voice states (who's in which channel) +- Broadcast voice_state updates to all clients +- Create one voice channel called "Voice Chat" on first run + +Client: +- Show voice channels in the channel list with a speaker icon +- Click to join (sends voice_join), click again to leave +- Show connected users in the voice channel with their names +- Show a "Connected to Voice" bar at the bottom when in a voice channel with a disconnect button +- Mute and deafen buttons (just UI for now, send voice_mute/voice_deafen events) + +No actual audio β€” just the UI and signaling to prove the voice state tracking works. +``` + +### Prompt 3.2 β€” WebRTC audio + +``` +@CLAUDE.md Read the webrtc-voice skill for architecture patterns. + +Add actual voice audio: + +Server: +- Integrate Pion as an SFU: one PeerConnection per client in a voice channel +- Forward audio tracks between clients (don't decode, just forward RTP) +- Built-in TURN relay with time-limited credentials (GET /api/voice/credentials) +- Handle voice_offer, voice_answer, voice_ice signaling messages + +Client: +- On voice_join: request TURN credentials, create WebRTC PeerConnection +- Capture microphone audio, add as audio track +- Handle incoming audio tracks β€” play through speakers +- Audio device selection in settings (input/output dropdowns) +- Mute actually stops the audio track, deafen stops playback +- Speaking indicator: green highlight on users who are transmitting + +Test: two clients in the same voice channel should hear each other talk. +``` + +### Prompt 3.3 β€” Push-to-talk + noise suppression + +``` +@CLAUDE.md + +Add push-to-talk and noise suppression: + +Client: +- Push-to-talk mode: configurable global hotkey (default: ` backtick key) +- Global keyboard hook that works even when the app is not focused (fullscreen games) +- Toggle between push-to-talk and voice activation in settings +- Voice activation mode: configurable sensitivity threshold with a live meter in settings +- Integrate RNNoise for noise suppression β€” toggle in audio settings +- Visual indicator when transmitting (PTT held or voice active) +``` + +### Prompt 3.4 β€” Screen sharing + video + +``` +@CLAUDE.md + +Add screen sharing and video: + +Server: +- Forward video tracks through the SFU same as audio + +Client: +- "Share Screen" button in the voice channel bar +- Capture screen via DXGI Desktop Duplication, send as video track +- Cap at 720p +- When someone is sharing: show a video panel in the voice area +- Pop-out button to open in a resizable window +- "Stop Sharing" button +- Optional webcam video: toggle camera on/off, small preview +``` + +### Prompt 3.5 β€” Soundboard + +``` +@CLAUDE.md @SCHEMA.md + +Add soundboard: + +Server: +- sounds table from SCHEMA.md +- POST /api/sounds to upload (admin/mod only, <10s, <1MB) +- Handle soundboard_play WebSocket event: validate permissions, enforce 3-second cooldown +- Mix the sound into the voice channel + +Client: +- Soundboard panel accessible from the voice channel bar +- Grid of sound buttons with names +- Click or hotkey to play +- Show cooldown indicator after playing +``` + +--- + +## Milestone 4: Admin Panel + Polish (Week 7-8) + +### Prompt 4.1 β€” Admin panel + +``` +@CLAUDE.md @API.md + +Build the web-based admin panel served at /admin: + +- Simple HTML/CSS/JS (no framework), embedded in the server binary +- Login page using existing auth +- Dashboard: connected users count, total messages, disk usage, uptime +- User management: list all users, change roles, ban/unban, reset password, force logout +- Channel management: create, rename, reorder, delete +- Invite management: generate codes with expiry/max uses, view active, revoke +- Server settings: server name, MOTD, max upload size + +Keep it functional and clean β€” this is a tool for the server admin, not a showcase. +``` + +### Prompt 4.2 β€” Moderation tools + +``` +@CLAUDE.md @SCHEMA.md + +Add moderation: + +Server: +- Kick (disconnect, can rejoin), ban (permanent, by account), temp ban (auto-expires), IP ban +- Slow mode per channel (seconds between messages per user) +- Server mute (prevent sending messages) +- Word filter: configurable blocklist with action (delete message / warn / mute) +- Audit log: every mod action logged with who, what, when, why + +Client: +- Right-click user in member list: Kick, Ban, Mute (for mods+) +- Show "slow mode enabled" indicator in channels with slow mode +- Show "[message deleted by moderator]" for mod-deleted messages + +Admin panel: +- Word filter configuration page +- Audit log viewer with filters +``` + +### Prompt 4.3 β€” Backups + +``` +@CLAUDE.md + +Add backup system: + +Server: +- POST /api/admin/backup to trigger manual backup +- Backup = SQLite VACUUM INTO + zip of uploads folder β†’ data/backups/timestamp.zip +- Scheduled backups: configurable in config.yaml (default daily at 3 AM) +- Retention: keep N most recent (default 7) +- GET /api/admin/backups to list available backups +- POST /api/admin/backups/{id}/restore to restore +- CLI: chatserver.exe --restore backup.zip + +Admin panel: +- Backup page: trigger manual backup button, list backups with dates and sizes, restore button +``` + +--- + +## Milestone 5: Customization + Quality of Life (Week 8-9) + +### Prompt 5.1 β€” Custom emoji + reactions + +``` +@CLAUDE.md @SCHEMA.md + +Add custom emoji: + +Server: +- emoji table from SCHEMA.md +- POST /api/emoji β€” admin uploads image + shortcode +- GET /api/emoji β€” list all, included in the ready payload +- Serve emoji images from uploads/emoji/ + +Client: +- Emoji picker: show built-in unicode emoji + custom server emoji +- Type :shortcode: in a message to auto-replace with emoji +- Autocomplete dropdown when typing : +- Custom emoji show inline in messages +``` + +### Prompt 5.2 β€” Threads + pins + DMs + +``` +@CLAUDE.md @PROTOCOL.md + +Add threads, pins, and direct messages: + +Server: +- Threads: messages with reply_to form a thread, endpoint to get thread messages +- Pins: POST/DELETE /api/channels/{id}/pins/{msg_id}, max 50 per channel +- DMs: private channels between two users, created on first DM + +Client: +- Click "View Thread" on a reply to open thread panel on the right +- Pin icon on pinned messages, "View Pins" button to see all pinned in a channel +- DM section in the channel list above server channels +- Click a user in the member list > "Send Message" to open/create DM +``` + +### Prompt 5.3 β€” Multi-server support + +``` +@CLAUDE.md + +Add multi-server support to the client: + +- Server list sidebar on the far left (vertical strip of server icons, like Discord/TeamSpeak) +- Each server is a separate WebSocket connection +- "+" button to add a new server (opens connection dialog) +- Right-click server icon: Edit, Remove, Copy Address +- Unread badge on server icons that have unread messages +- Switch between servers by clicking their icon β€” switches the channel list and message area +- Store server profiles locally in a config file next to the exe +``` + +--- + +## Milestone 6: Distribution (Week 9-10) + +### Prompt 6.1 β€” Server systray + service mode + +``` +@CLAUDE.md + +Add system tray and service mode to the server: + +- System tray icon using getlantern/systray +- Right-click menu: Open Admin Panel (launches browser to /admin), View Logs, Restart, Quit +- Tray icon shows green when running, yellow when starting up +- chatserver.exe --service install: register as a Windows Service +- chatserver.exe --service uninstall: remove the service +- When running as a service, no tray icon (headless mode) +``` + +### Prompt 6.2 β€” Client installer + +``` +@CLAUDE.md + +Create an NSIS installer for the client: + +- Installs to C:\Program Files\ChatServer Client\ +- Creates Start Menu shortcut +- Optional desktop shortcut +- Optional auto-start on boot (registry key) +- Registers chatserver:// protocol handler so invite links open the client +- Uninstaller that removes everything cleanly +- Installer size should be ~20-40MB +``` + +### Prompt 6.3 β€” Auto-update system + +``` +@CLAUDE.md + +Add update checking to both server and client: + +Server: +- GET /api/admin/update-check queries GitHub Releases API for newer version +- Admin panel shows "Update available: v1.1.0" with download button +- Download new exe, verify SHA256, replace, restart + +Client: +- On launch, check GitHub Releases for new client version +- If available, show a non-blocking notification: "Update available. Download now?" +- Download installer, verify SHA256, launch installer, close current client +``` + +### Prompt 6.4 β€” First-run setup wizard + +``` +@CLAUDE.md + +Add a first-run setup wizard to the server: + +When chatserver.exe starts and no database exists: +- Open browser to https://localhost:8443/setup +- Step 1: Create admin account (username + password) +- Step 2: Name your server, upload an icon (optional) +- Step 3: Choose network mode: "Local network only" / "Port forwarding" / "Tailscale/VPN" +- Step 4: TLS β€” auto-configure based on network choice +- Step 5: Generate first invite link, show it with a copy button +- After completing setup, redirect to the admin panel + +This replaces the "print invite code to console" from prompt 1.2. +``` + +--- + +## Tips + +- **Test after every prompt.** Build, run, try it. Don't stack three prompts before testing. +- **If something breaks**, paste the error into Claude Code: "This error happens when I try to [action]. Fix it." +- **If you want to tweak something**, just tell Claude Code what you want changed. The spec files give it context. +- **The milestones are roughly weekly.** Don't rush β€” a working app at each milestone is better than a broken app that has "more features." +- **Milestone 1 is the most important.** Once two exes connect and chat, everything else is incremental. diff --git a/PROTOCOL.md b/PROTOCOL.md new file mode 100644 index 00000000..23a507d1 --- /dev/null +++ b/PROTOCOL.md @@ -0,0 +1,275 @@ +# WebSocket Protocol Spec + +All client-server communication (except file uploads and admin panel) happens over a single WebSocket connection. Messages are JSON with a `type` and `payload`. + +## Message Format + +```json +{ + "type": "message_type", + "id": "unique-request-id", + "payload": { } +} +``` + +- `type` β€” string, required. Determines how payload is interpreted. +- `id` β€” string, optional. Client-generated UUID for request/response correlation. +- `payload` β€” object, required. Contents vary by type. + +Server responses to client requests include the same `id` for correlation. + +--- + +## Authentication + +### Client β†’ Server + +```json +{ "type": "auth", "payload": { "token": "session-token-here" } } +``` + +### Server β†’ Client (success) + +```json +{ "type": "auth_ok", "payload": { "user": { "id": 1, "username": "alex", "avatar": "uuid.png", "role": "admin" }, "server_name": "My Server", "motd": "Welcome!" } } +``` + +### Server β†’ Client (failure) + +```json +{ "type": "auth_error", "payload": { "message": "Invalid or expired token" } } +``` + +Connection is closed by server after auth_error. + +--- + +## Chat Messages + +### Send Message (Client β†’ Server) + +```json +{ "type": "chat_send", "id": "req-uuid", "payload": { "channel_id": 5, "content": "Hello everyone!", "reply_to": null, "attachments": ["upload-uuid-1"] } } +``` + +### Message Broadcast (Server β†’ Client) + +```json +{ "type": "chat_message", "payload": { "id": 1042, "channel_id": 5, "user": { "id": 1, "username": "alex", "avatar": "uuid.png" }, "content": "Hello everyone!", "reply_to": null, "attachments": [{ "id": "upload-uuid-1", "filename": "photo.jpg", "size": 204800, "mime": "image/jpeg", "url": "/files/upload-uuid-1" }], "timestamp": "2026-03-14T10:30:00Z" } } +``` + +### Send Ack (Server β†’ Client) + +```json +{ "type": "chat_send_ok", "id": "req-uuid", "payload": { "message_id": 1042, "timestamp": "2026-03-14T10:30:00Z" } } +``` + +### Edit Message (Client β†’ Server) + +```json +{ "type": "chat_edit", "id": "req-uuid", "payload": { "message_id": 1042, "content": "Hello everyone! (edited)" } } +``` + +### Edit Broadcast (Server β†’ Client) + +```json +{ "type": "chat_edited", "payload": { "message_id": 1042, "channel_id": 5, "content": "Hello everyone! (edited)", "edited_at": "2026-03-14T10:31:00Z" } } +``` + +### Delete Message (Client β†’ Server) + +```json +{ "type": "chat_delete", "id": "req-uuid", "payload": { "message_id": 1042 } } +``` + +### Delete Broadcast (Server β†’ Client) + +```json +{ "type": "chat_deleted", "payload": { "message_id": 1042, "channel_id": 5 } } +``` + +### Reaction Add/Remove (Client β†’ Server) + +```json +{ "type": "reaction_add", "payload": { "message_id": 1042, "emoji": "πŸ‘" } } +{ "type": "reaction_remove", "payload": { "message_id": 1042, "emoji": "πŸ‘" } } +``` + +### Reaction Broadcast (Server β†’ Client) + +```json +{ "type": "reaction_update", "payload": { "message_id": 1042, "channel_id": 5, "emoji": "πŸ‘", "user_id": 1, "action": "add" } } +``` + +--- + +## Typing Indicators + +### Client β†’ Server (throttle to 1 per 3 seconds) + +```json +{ "type": "typing_start", "payload": { "channel_id": 5 } } +``` + +### Server β†’ Client (broadcast to channel members) + +```json +{ "type": "typing", "payload": { "channel_id": 5, "user_id": 1, "username": "alex" } } +``` + +Client-side: show indicator for 5 seconds, reset on new typing event from same user. + +--- + +## Presence + +### Client β†’ Server + +```json +{ "type": "presence_update", "payload": { "status": "online" } } +``` + +Status values: `online`, `idle`, `dnd`, `offline` + +### Server β†’ Client (broadcast) + +```json +{ "type": "presence", "payload": { "user_id": 1, "status": "online" } } +``` + +Server auto-sets `idle` after 10 minutes of no WebSocket activity. + +--- + +## Channel Updates + +### Server β†’ Client (on channel created/edited/deleted/reordered) + +```json +{ "type": "channel_create", "payload": { "id": 8, "name": "gaming", "type": "text", "category": "Hangout", "position": 3 } } +{ "type": "channel_update", "payload": { "id": 8, "name": "gaming-talk", "position": 4 } } +{ "type": "channel_delete", "payload": { "id": 8 } } +``` + +Channel types: `text`, `voice`, `announcement` + +--- + +## Voice Signaling + +### Join Voice Channel (Client β†’ Server) + +```json +{ "type": "voice_join", "payload": { "channel_id": 10 } } +``` + +### Server β†’ Client (voice state updates, broadcast to channel) + +```json +{ "type": "voice_state", "payload": { "channel_id": 10, "user_id": 1, "username": "alex", "muted": false, "deafened": false, "speaking": false } } +``` + +### Voice User Left (Server β†’ Client) + +```json +{ "type": "voice_leave", "payload": { "channel_id": 10, "user_id": 1 } } +``` + +### WebRTC Signaling (bidirectional) + +```json +{ "type": "voice_offer", "payload": { "channel_id": 10, "sdp": "..." } } +{ "type": "voice_answer", "payload": { "channel_id": 10, "sdp": "..." } } +{ "type": "voice_ice", "payload": { "channel_id": 10, "candidate": "..." } } +``` + +### Voice Control (Client β†’ Server) + +```json +{ "type": "voice_mute", "payload": { "muted": true } } +{ "type": "voice_deafen", "payload": { "deafened": true } } +``` + +### Soundboard (Client β†’ Server) + +```json +{ "type": "soundboard_play", "payload": { "sound_id": "uuid" } } +``` + +--- + +## Member Updates + +### Server β†’ Client + +```json +{ "type": "member_join", "payload": { "user": { "id": 5, "username": "newuser", "avatar": null, "role": "member" } } } +{ "type": "member_leave", "payload": { "user_id": 5 } } +{ "type": "member_update", "payload": { "user_id": 5, "role": "moderator" } } +{ "type": "member_ban", "payload": { "user_id": 5 } } +``` + +--- + +## Initial State (sent after auth_ok) + +### Server β†’ Client + +```json +{ + "type": "ready", + "payload": { + "channels": [ + { "id": 1, "name": "general", "type": "text", "category": "Main", "position": 0, "unread_count": 3, "last_message_id": 1040 }, + { "id": 10, "name": "voice-chat", "type": "voice", "category": "Main", "position": 1 } + ], + "members": [ + { "id": 1, "username": "alex", "avatar": "uuid.png", "role": "admin", "status": "online" }, + { "id": 2, "username": "jordan", "avatar": null, "role": "member", "status": "idle" } + ], + "voice_states": [ + { "channel_id": 10, "user_id": 2, "muted": false, "deafened": false } + ], + "roles": [ + { "id": 1, "name": "Owner", "color": "#E74C3C", "permissions": 2147483647 }, + { "id": 2, "name": "Admin", "color": "#F39C12", "permissions": 1073741823 }, + { "id": 3, "name": "Member", "color": null, "permissions": 1049601 } + ] + } +} +``` + +--- + +## Message History (REST, not WebSocket) + +Fetched via REST API, not WebSocket, to keep the WS connection lean. + +``` +GET /api/channels/{id}/messages?before={message_id}&limit=50 +``` + +--- + +## Error Format + +Any request that fails returns: + +```json +{ "type": "error", "id": "original-req-uuid", "payload": { "code": "FORBIDDEN", "message": "You don't have permission to post in this channel" } } +``` + +Error codes: `FORBIDDEN`, `NOT_FOUND`, `RATE_LIMITED`, `INVALID_INPUT`, `SERVER_ERROR` + +--- + +## Rate Limits + +- Chat messages: 10/sec per user +- Typing events: 1/3sec per user per channel +- Presence updates: 1/10sec per user +- Reactions: 5/sec per user +- Voice signaling: 20/sec per user +- Soundboard: 1/3sec per user + +Server sends `rate_limited` error with `retry_after` in seconds. diff --git a/README.md b/README.md new file mode 100644 index 00000000..b7e97bf7 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# OwnCord diff --git a/SCHEMA.md b/SCHEMA.md new file mode 100644 index 00000000..d0be4b66 --- /dev/null +++ b/SCHEMA.md @@ -0,0 +1,291 @@ +# Database Schema (SQLite) + +Single file: `data/chatserver.db`. WAL mode enabled. Migrations run automatically on server startup. + +--- + +## Users + +```sql +CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE COLLATE NOCASE, + password TEXT NOT NULL, -- bcrypt hash + avatar TEXT, -- filename in uploads/ or NULL + role_id INTEGER NOT NULL DEFAULT 4 REFERENCES roles(id), + totp_secret TEXT, -- encrypted TOTP secret or NULL if 2FA disabled + status TEXT NOT NULL DEFAULT 'offline', -- online, idle, dnd, offline + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_seen TEXT, + banned INTEGER NOT NULL DEFAULT 0, + ban_reason TEXT, + ban_expires TEXT -- NULL = permanent, datetime = temp ban +); +``` + +## Sessions + +```sql +CREATE TABLE sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, -- 256-bit random, hex encoded + device TEXT, -- user-agent or client identifier + ip_address TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_used TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT NOT NULL -- 30 days from creation +); + +CREATE INDEX idx_sessions_token ON sessions(token); +CREATE INDEX idx_sessions_user ON sessions(user_id); +``` + +## Roles + +```sql +CREATE TABLE roles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + color TEXT, -- hex color e.g. #E74C3C, NULL for default + permissions INTEGER NOT NULL DEFAULT 0, -- bitfield + position INTEGER NOT NULL DEFAULT 0, -- hierarchy: higher = more power + is_default INTEGER NOT NULL DEFAULT 0 -- 1 = assigned to new users +); + +-- Default roles (inserted on first run) +-- Owner: permissions = 0x7FFFFFFF (all bits set) +-- Admin: permissions = 0x3FFFFFFF +-- Moderator: permissions = 0x000FFFFF +-- Member: permissions = 0x00100601 +``` + +### Permission Bitfield + +``` +Bit 0: SEND_MESSAGES (0x1) +Bit 1: READ_MESSAGES (0x2) +Bit 5: ATTACH_FILES (0x20) +Bit 6: ADD_REACTIONS (0x40) +Bit 8: USE_SOUNDBOARD (0x100) +Bit 9: CONNECT_VOICE (0x200) +Bit 10: SPEAK_VOICE (0x400) +Bit 11: USE_VIDEO (0x800) +Bit 12: SHARE_SCREEN (0x1000) +Bit 16: MANAGE_MESSAGES (0x10000) -- delete others' messages, pin +Bit 17: MANAGE_CHANNELS (0x20000) +Bit 18: KICK_MEMBERS (0x40000) +Bit 19: BAN_MEMBERS (0x80000) +Bit 20: MUTE_MEMBERS (0x100000) -- server mute/deafen +Bit 24: MANAGE_ROLES (0x1000000) +Bit 25: MANAGE_SERVER (0x2000000) +Bit 26: MANAGE_INVITES (0x4000000) +Bit 27: VIEW_AUDIT_LOG (0x8000000) +Bit 30: ADMINISTRATOR (0x40000000) -- bypasses all checks +``` + +## Channels + +```sql +CREATE TABLE channels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'text', -- text, voice, announcement + category TEXT, -- category name for grouping + topic TEXT, -- channel description + position INTEGER NOT NULL DEFAULT 0, + slow_mode INTEGER NOT NULL DEFAULT 0, -- seconds between messages, 0 = off + archived INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +``` + +## Channel Permission Overrides + +```sql +CREATE TABLE channel_overrides ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + allow INTEGER NOT NULL DEFAULT 0, -- permission bits to grant + deny INTEGER NOT NULL DEFAULT 0, -- permission bits to revoke + UNIQUE(channel_id, role_id) +); +``` + +## Messages + +```sql +CREATE TABLE messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id), + content TEXT NOT NULL, + reply_to INTEGER REFERENCES messages(id) ON DELETE SET NULL, + edited_at TEXT, + deleted INTEGER NOT NULL DEFAULT 0, -- 1 = soft deleted + pinned INTEGER NOT NULL DEFAULT 0, + timestamp TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX idx_messages_channel ON messages(channel_id, id DESC); +CREATE INDEX idx_messages_user ON messages(user_id); +``` + +## Message Full-Text Search + +```sql +CREATE VIRTUAL TABLE messages_fts USING fts5( + content, + content='messages', + content_rowid='id' +); + +-- Triggers to keep FTS in sync +CREATE TRIGGER messages_ai AFTER INSERT ON messages BEGIN + INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content); +END; + +CREATE TRIGGER messages_ad AFTER DELETE ON messages BEGIN + INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', old.id, old.content); +END; + +CREATE TRIGGER messages_au AFTER UPDATE ON messages BEGIN + INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', old.id, old.content); + INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content); +END; +``` + +## Attachments + +```sql +CREATE TABLE attachments ( + id TEXT PRIMARY KEY, -- UUID + message_id INTEGER REFERENCES messages(id) ON DELETE CASCADE, + filename TEXT NOT NULL, -- original filename + stored_as TEXT NOT NULL, -- UUID filename on disk + mime_type TEXT NOT NULL, + size INTEGER NOT NULL, -- bytes + uploaded_at TEXT NOT NULL DEFAULT (datetime('now')) +); +``` + +## Reactions + +```sql +CREATE TABLE reactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + emoji TEXT NOT NULL, + UNIQUE(message_id, user_id, emoji) +); +``` + +## Invites + +```sql +CREATE TABLE invites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL UNIQUE, -- random token + created_by INTEGER NOT NULL REFERENCES users(id), + redeemed_by INTEGER REFERENCES users(id), + max_uses INTEGER, -- NULL = unlimited + use_count INTEGER NOT NULL DEFAULT 0, + expires_at TEXT, -- NULL = never + created_at TEXT NOT NULL DEFAULT (datetime('now')), + revoked INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX idx_invites_code ON invites(code); +``` + +## Read State (unread tracking) + +```sql +CREATE TABLE read_states ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + last_message_id INTEGER NOT NULL DEFAULT 0, + mention_count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, channel_id) +); +``` + +## Audit Log + +```sql +CREATE TABLE audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER REFERENCES users(id), + action TEXT NOT NULL, -- e.g. user_ban, channel_create, message_delete, role_update + target_type TEXT, -- user, channel, message, role, invite + target_id INTEGER, + details TEXT, -- JSON with extra context + timestamp TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX idx_audit_timestamp ON audit_log(timestamp DESC); +``` + +## Login Attempts (rate limiting) + +```sql +CREATE TABLE login_attempts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ip_address TEXT NOT NULL, + username TEXT, + success INTEGER NOT NULL DEFAULT 0, + timestamp TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX idx_login_ip ON login_attempts(ip_address, timestamp); +``` + +## Server Settings (key-value) + +```sql +CREATE TABLE settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +-- Default settings inserted on first run: +-- server_name, server_icon, motd, max_upload_bytes, voice_quality, +-- require_2fa, registration_open (always 0), backup_schedule, backup_retention +``` + +## Custom Emoji + +```sql +CREATE TABLE emoji ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + shortcode TEXT NOT NULL UNIQUE, -- e.g. :pepe: + filename TEXT NOT NULL, -- stored in uploads/emoji/ + uploaded_by INTEGER NOT NULL REFERENCES users(id), + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +``` + +## Soundboard + +```sql +CREATE TABLE sounds ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + filename TEXT NOT NULL, -- stored in uploads/sounds/ + duration_ms INTEGER NOT NULL, + uploaded_by INTEGER NOT NULL REFERENCES users(id), + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +``` + +--- + +## Notes + +- All datetimes stored as ISO 8601 UTC strings. +- Enable WAL mode on connection: `PRAGMA journal_mode=WAL;` +- Enable foreign keys: `PRAGMA foreign_keys=ON;` +- Use `modernc.org/sqlite` (pure Go, no CGO needed). +- Migrations: store schema version in `settings` table, apply incremental SQL on startup. diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 00000000..220467c7 --- /dev/null +++ b/SETUP.md @@ -0,0 +1,116 @@ +# Developer Setup Guide + +What you need to install yourself vs what Claude Code can handle. + +--- + +## You Install (Claude Code can't do these) + +These require GUI installers, admin privileges, or system-level changes. + +### Required + +1. **Git** β€” https://git-scm.com/download/win + - Claude Code needs this to manage the project. Just use the default install options. + +2. **Go** β€” https://go.dev/dl/ + - Download the Windows amd64 `.msi` installer. Default install path is fine. + - After install, open a new terminal and verify: `go version` + +3. **Node.js (LTS)** β€” https://nodejs.org + - Claude Code itself runs on Node. You likely already have this if you're using Claude Code. + - Verify: `node --version` + +4. **Visual Studio Build Tools** (probably needed for the client) + - If Claude Code picks C++ (Qt), C# (WPF/.NET), or Rust β€” it will need a compiler. + - Install **Visual Studio Build Tools 2022**: https://visualstudio.microsoft.com/downloads/#build-tools-for-visual-studio-2022 + - During install, select: + - "Desktop development with C++" (covers C++ and Rust) + - ".NET desktop development" (covers C#/WPF) + - Selecting both covers all possible client language choices (~5-8 GB disk space). + - If Claude Code picks a language that doesn't need this, skip it β€” Claude Code will tell you what's missing. + +### Depends on Client Language (install if Claude Code asks) + +- **Qt 6** β€” If C++ is chosen. Online installer: https://www.qt.io/download-qt-installer + - Select: Qt 6.x for MSVC, Qt WebSockets, Qt Multimedia modules. + - Set `QT_DIR` environment variable to install path. + +- **.NET 8 SDK** β€” If C#/WPF is chosen. https://dotnet.microsoft.com/download/dotnet/8.0 + - Verify: `dotnet --version` + +- **Rust** β€” If Rust is chosen. https://rustup.rs + - Verify: `rustc --version` + +### Optional but Recommended + +5. **Windows Terminal** β€” https://aka.ms/terminal + - Much better than cmd.exe for running Claude Code. Get it from the Microsoft Store. + +6. **VS Code** β€” https://code.visualstudio.com + - For browsing the code Claude Code generates. Install the Go extension. + +--- + +## Claude Code Can Handle These + +Claude Code can install and configure all of the following via the terminal: + +### Go Dependencies (server) +``` +go mod init, go get, go mod tidy +``` +All Go libraries (chi, pion, sqlite, bcrypt, etc.) are installed automatically when Claude Code runs `go get`. No manual action needed. + +### NPM Packages (if any JS tooling is needed for admin panel) +``` +npm install +``` + +### NSIS (installer builder) +Claude Code can download and install NSIS via: +``` +winget install NSIS.NSIS +``` +Or use `choco install nsis` if Chocolatey is installed. + +### Development tools +- `golangci-lint` (Go linter) β€” Claude Code can install via `go install` +- `air` (Go hot-reload) β€” Claude Code can install via `go install` +- `sqlc` (SQL code generator) β€” Claude Code can install via `go install` + +--- + +## Quick Check β€” Run These After Installing + +Open a terminal and verify everything works: + +``` +git --version +go version +node --version +``` + +If all three print version numbers, you're ready. Start Claude Code in your project folder and tell it: + +``` +@CLAUDE.md Start phase 1 β€” set up the server project structure and build a hello world that compiles to chatserver.exe +``` + +Claude Code will read CLAUDE.md, pull in the other spec files, and start building. If it needs something you haven't installed (like Qt or .NET SDK based on the client language it picks), it will tell you. + +--- + +## Summary + +| Tool | You Install | Claude Code Installs | +|------|:-----------:|:-------------------:| +| Git | βœ… | | +| Go | βœ… | | +| Node.js | βœ… | | +| VS Build Tools | βœ… | | +| Qt / .NET SDK / Rust | βœ… (when asked) | | +| Go libraries | | βœ… | +| NSIS | | βœ… (via winget) | +| Linters & dev tools | | βœ… | +| NPM packages | | βœ… | diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 00000000..97f0cfb4 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,230 @@ +--- +name: windows-native +description: Patterns for building the native Windows desktop chat client (chatclient.exe). Use this skill for any work on the client application β€” UI layout, Windows API integration, system tray, notifications, keyboard hooks, audio devices, credential storage, installer creation, or any code in the client/ directory. Also trigger when the user mentions push-to-talk, WASAPI, DXGI, toast notifications, systray, NSIS installer, or any Windows-specific client feature. Use this even for simple client tasks like "add a button" or "fix the settings page." +--- + +# Native Windows Client Patterns + +Read this before writing any client code. The client is a native Windows desktop app β€” NOT Electron, NOT browser-based. + +## Requirements Recap + +The chosen language/framework must support all of these: +- Native Windows desktop UI (no embedded browser engine) +- ~20-40MB install size, ~50-100MB RAM idle +- WebSocket client for real-time chat +- WebRTC for voice/video +- WASAPI for low-latency audio +- Global keyboard hooks (push-to-talk in fullscreen games) +- System tray with badge overlay +- Windows toast notifications with action buttons +- DXGI Desktop Duplication for screen capture +- Windows Credential Manager (DPAPI) for token storage +- NSIS or WiX installer + +## Window Layout + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Server Name ─ β–‘ βœ• β”‚ +β”œβ”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β”‚ CATEGORY β”‚ β”‚ Online β€” 5 β”‚ +β”‚ S1 β”‚ # generalβ”‚ [alex] Hello everyone! β”‚ ● alex β”‚ +β”‚ β”‚ # gaming β”‚ [jordan] Hey what's up β”‚ ● jordan β”‚ +β”‚ S2 β”‚ # random β”‚ β”‚ ● sam β”‚ +β”‚ β”‚ β”‚ [sam] Anyone want to play? β”‚ Offline β€” 2 β”‚ +β”‚ S3 β”‚ VOICE β”‚ β”‚ β—‹ pat β”‚ +β”‚ β”‚ πŸ”Š Voice β”‚ β”‚ β—‹ taylor β”‚ +β”‚ β”‚ ● alex β”‚ β”‚ β”‚ +β”‚ β”‚ ● jordanβ”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ +β”‚ β”‚ β”‚ [message input ] πŸ“Žβ”‚ β”‚ +β”œβ”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ 🎀 Mute 🎧 Deafen βš™ Settings alex ● Online β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +Left edge: Server icons (S1, S2, S3) β€” click to switch servers +Second column: Channel list with categories, text (#) and voice (πŸ”Š) channels +Center: Message area (scrollable, loads history on scroll-up) +Right: Member list (collapsible) +Bottom bar: Voice controls, settings shortcut, current user status +``` + +## Core UI Components + +### Connection Dialog (first screen) +- Server address + port fields +- Login / Register tabs +- "I have an invite code" option on Register tab +- "Remember me" checkbox +- Server profile dropdown (saved bookmarks) +- "Add Server" button to save new profiles + +### Message Area +- Messages grouped by author when consecutive (show avatar + name once, then just messages) +- Timestamp shown on hover or at time gaps (>5 minutes) +- Markdown rendered: **bold**, *italic*, `code`, ```code blocks```, [links](url) +- Reply preview: small quote box above the replied-to content +- Reactions: row of emoji badges below message, click to toggle own reaction +- Edited indicator: "(edited)" text next to timestamp +- Deleted placeholder: "This message was deleted" in italic + +### Settings Window (modal or separate window) +Tabs: +- **Account**: avatar upload, change password, 2FA setup +- **Appearance**: light/dark theme, font size, compact mode toggle +- **Notifications**: enable/disable, sounds on/off, per-channel overrides +- **Audio**: input device dropdown, output device dropdown, input volume slider with live meter, push-to-talk key selector, noise suppression toggle, voice activation sensitivity slider +- **Keybinds**: customizable shortcuts table + +## Windows API Integration + +### System Tray + +``` +Minimize to tray on window close (configurable in settings). +Tray icon: app icon with unread badge overlay. +Left-click: restore/focus window. +Right-click menu: + - Show ChatServer + - Mute All Notifications + - Settings + - ───────────── + - Quit + +Flash tray icon on new @mention. +Badge shows unread count (number overlay on icon). +``` + +### Windows Toast Notifications + +``` +Trigger: new message in channel or DM when window is unfocused or minimized. +Content: "[username] in #channel: message preview..." +Actions: "Reply" (opens input), "Mark Read" (clears unread). +Sound: configurable per event type (message, mention, voice join). +Respect per-channel mute settings β€” don't notify for muted channels. +Group notifications by channel to avoid spam. +``` + +### Global Keyboard Hooks (Push-to-Talk) + +``` +Use SetWindowsHookEx with WH_KEYBOARD_LL for low-level keyboard hook. +This captures key events system-wide, including in fullscreen games. +The hook runs in a separate thread. +When push-to-talk key is held: unmute mic, send audio. +When released: mute mic. +Visual indicator in the client UI: "Transmitting" badge or border glow. +Allow user to configure any key (including mouse buttons via WH_MOUSE_LL). +``` + +### WASAPI Audio + +``` +Enumerate audio devices: input (microphones) and output (speakers/headphones). +Let user select devices in Settings > Audio. +Use WASAPI in shared mode for low-latency capture and playback. +Feed captured audio into WebRTC audio track. +Play received audio from WebRTC to selected output device. +Noise suppression: process captured audio through RNNoise before sending. +Voice activity detection: analyze audio level, show "speaking" indicator. +``` + +### DXGI Desktop Duplication (Screen Sharing) + +``` +Use IDXGIOutputDuplication to capture the desktop. +Efficient β€” hardware-accelerated, low CPU overhead. +Encode captured frames and send as WebRTC video track. +Cap at 720p by default (configurable by server admin). +Show "You are sharing your screen" indicator in the UI. +Stop sharing button. +When someone else is sharing: show in a panel within the voice channel view. +Pop-out button to open screen share in a resizable window. +``` + +### Windows Credential Manager + +``` +Store auth tokens using Windows Credential Manager (DPAPI encryption). +Credential target name: "ChatServer:{server_address}" +On login success: store token. +On app launch: read stored token, attempt auto-login. +On logout: delete stored credential. +On session expired (server returns 401): delete credential, show login dialog. +Never store tokens in plaintext files or registry. +``` + +### Certificate Trust (TOFU) + +``` +When connecting to a server with a self-signed certificate: +1. First connection: show dialog "This server uses a self-signed certificate. + Fingerprint: SHA256:xxxx. Trust this certificate?" +2. If user accepts: save the cert fingerprint locally. +3. Future connections: verify fingerprint matches saved value. +4. If fingerprint changes: show warning "Certificate has changed! + This could indicate a security issue." Require explicit re-trust. +Store trusted fingerprints in local settings (per server profile). +``` + +## Connection & Reconnection + +``` +On startup: +1. Load last server profile +2. Read auth token from Credential Manager +3. Connect WebSocket to server +4. Send auth message with token +5. On auth_ok: receive ready payload, populate UI +6. On auth_error: show login dialog + +On disconnect: +1. Show "Reconnecting..." indicator +2. Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s (cap) +3. On reconnect: re-authenticate, request missed messages +4. If token expired: show login dialog + +Connection indicator in bottom bar: + ● Green = connected + ● Yellow = reconnecting + ● Red = disconnected +``` + +## Multi-Server Support + +``` +Left sidebar shows server icons (like Discord). +Each server is a separate WebSocket connection. +Server profiles stored locally: +{ + "servers": [ + { + "name": "Friends Server", + "address": "myserver.example.com", + "port": 8443, + "icon": "cached_icon.png" + } + ] +} +Only the active server's messages are shown. +Unread badges shown on all server icons. +Click a server icon to switch context. +"+" button at bottom to add new server. +Right-click server icon: Edit, Remove, Copy Invite Link. +``` + +## Installer (NSIS or WiX) + +``` +Install location: C:\Program Files\ChatServer\ +Creates: Start Menu shortcut, optional Desktop shortcut. +Optional auto-start: adds to HKCU\...\Run registry key. +Registers protocol handler: chatserver:// + β†’ opening chatserver://invite/abc123 launches client with invite dialog. +Uninstaller: removes files, registry entries, Start Menu items. +Size: ~20-40MB installed. +Include: client exe, runtime dependencies (if any), RNNoise DLL, default config. +``` diff --git a/skills/go-server/SKILL.md b/skills/go-server/SKILL.md new file mode 100644 index 00000000..b38bc800 --- /dev/null +++ b/skills/go-server/SKILL.md @@ -0,0 +1,307 @@ +--- +name: go-server +description: Patterns and best practices for building the Go chat server backend (chatserver.exe). Use this skill whenever working on the server side of the ChatServer project β€” API handlers, middleware, config loading, file serving, authentication, or any Go code in the server/ directory. Also use when creating new REST endpoints, adding middleware, embedding static files, or structuring Go packages. Trigger on any Go server task even if the user just says "add an endpoint" or "fix the server." +--- + +# Go Server Patterns + +Read this before writing any Go code in the `server/` directory. These patterns keep the codebase consistent. + +## Project Layout + +``` +server/ +β”œβ”€β”€ main.go ← entry point, wires everything together +β”œβ”€β”€ go.mod +β”œβ”€β”€ config/ +β”‚ └── config.go ← load config.yaml, env overrides, defaults +β”œβ”€β”€ db/ +β”‚ β”œβ”€β”€ db.go ← open SQLite, run migrations +β”‚ β”œβ”€β”€ queries.go ← all SQL queries as methods on a DB struct +β”‚ └── migrations/ ← numbered .sql files (001_init.sql, etc.) +β”œβ”€β”€ auth/ +β”‚ β”œβ”€β”€ auth.go ← bcrypt, session create/validate/revoke +β”‚ β”œβ”€β”€ middleware.go ← RequireAuth, RequireRole, RateLimit middleware +β”‚ └── totp.go ← TOTP setup, verify +β”œβ”€β”€ api/ +β”‚ β”œβ”€β”€ router.go ← chi router setup, mount all routes +β”‚ β”œβ”€β”€ auth_handlers.go +β”‚ β”œβ”€β”€ channel_handlers.go +β”‚ β”œβ”€β”€ message_handlers.go +β”‚ β”œβ”€β”€ upload_handlers.go +β”‚ β”œβ”€β”€ admin_handlers.go +β”‚ └── helpers.go ← JSON response helpers, error formatting +β”œβ”€β”€ ws/ +β”‚ β”œβ”€β”€ hub.go ← central hub, channel subscriptions, broadcast +β”‚ β”œβ”€β”€ client.go ← per-connection read/write goroutines +β”‚ β”œβ”€β”€ handlers.go ← handle each message type +β”‚ └── types.go ← message structs matching PROTOCOL.md +β”œβ”€β”€ voice/ +β”‚ β”œβ”€β”€ sfu.go ← Pion SFU setup +β”‚ β”œβ”€β”€ turn.go ← built-in TURN relay +β”‚ └── signaling.go ← WebRTC signaling via WebSocket +β”œβ”€β”€ storage/ +β”‚ └── files.go ← upload validation, EXIF strip, serve with auth +β”œβ”€β”€ admin/ +β”‚ └── static/ ← HTML/CSS/JS for admin panel (embedded) +└── migrations/ + β”œβ”€β”€ 001_init.sql + └── 002_fts.sql +``` + +## Conventions + +### Entry Point (main.go) + +```go +package main + +import ( + "embed" + // ... +) + +//go:embed admin/static/* +var adminFS embed.FS + +var version = "dev" // set via -ldflags at build time + +func main() { + cfg := config.Load("config.yaml") + database := db.Open(cfg.DataDir + "/chatserver.db") + database.Migrate() + + hub := ws.NewHub(database) + go hub.Run() + + router := api.NewRouter(database, hub, adminFS, cfg) + + // TLS or plain HTTP based on config + server := &http.Server{Addr: ":" + cfg.Port, Handler: router} + // ... start server with appropriate TLS mode +} +``` + +### Config Loading + +```go +// Always provide sensible defaults. Never crash on missing config. +type Config struct { + Port string `yaml:"port" env:"PORT" default:"8443"` + ServerName string `yaml:"server_name" default:"My Server"` + DataDir string `yaml:"data_dir" default:"data"` + MaxUploadMB int `yaml:"max_upload_mb" default:"25"` + VoiceQuality string `yaml:"voice_quality" default:"medium"` // low, medium, high + TLSMode string `yaml:"tls_mode" default:"self-signed"` // self-signed, acme, manual, off + TLSDomain string `yaml:"tls_domain"` + TLSCert string `yaml:"tls_cert"` + TLSKey string `yaml:"tls_key"` +} +``` + +### API Handler Pattern + +Every handler follows this structure: + +```go +func (h *Handler) CreateChannel(w http.ResponseWriter, r *http.Request) { + // 1. Get authenticated user from context (set by auth middleware) + user := auth.UserFromContext(r.Context()) + + // 2. Check permissions + if !user.HasPermission(permissions.ManageChannels) { + respondError(w, http.StatusForbidden, "FORBIDDEN", "Insufficient permissions") + return + } + + // 3. Parse and validate input + var input struct { + Name string `json:"name"` + Type string `json:"type"` + Category string `json:"category"` + } + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + respondError(w, http.StatusBadRequest, "INVALID_INPUT", "Invalid JSON") + return + } + input.Name = sanitize(input.Name) + + // 4. Business logic (database call) + channel, err := h.db.CreateChannel(input.Name, input.Type, input.Category) + if err != nil { + respondError(w, http.StatusInternalServerError, "SERVER_ERROR", "Failed to create channel") + return + } + + // 5. Side effects (broadcast via WebSocket, audit log) + h.hub.BroadcastAll(ws.Message{Type: "channel_create", Payload: channel}) + h.db.AuditLog(user.ID, "channel_create", "channel", channel.ID, nil) + + // 6. Respond + respondJSON(w, http.StatusCreated, channel) +} +``` + +### Response Helpers + +```go +func respondJSON(w http.ResponseWriter, status int, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(data) +} + +func respondError(w http.ResponseWriter, status int, code, message string) { + respondJSON(w, status, map[string]string{"error": code, "message": message}) +} +``` + +### Middleware Stack + +```go +r := chi.NewRouter() + +// Global middleware +r.Use(middleware.RealIP) +r.Use(middleware.Logger) // or custom slog middleware +r.Use(middleware.Recoverer) +r.Use(securityHeaders) // HSTS, CSP, X-Frame-Options +r.Use(rateLimiter(30)) // 30 req/sec per IP globally + +// Public routes +r.Post("/api/auth/register", h.Register) +r.Post("/api/auth/login", h.Login) +r.Get("/api/health", h.Health) + +// Authenticated routes +r.Group(func(r chi.Router) { + r.Use(auth.RequireAuth(db)) // validates session token + r.Get("/api/channels", h.ListChannels) + r.Get("/api/channels/{id}/messages", h.GetMessages) + // ... +}) + +// Admin routes +r.Group(func(r chi.Router) { + r.Use(auth.RequireAuth(db)) + r.Use(auth.RequireRole("admin", "owner")) + r.Get("/api/admin/stats", h.AdminStats) + // ... +}) + +// Admin panel static files +r.Handle("/admin/*", http.StripPrefix("/admin/", http.FileServer(http.FS(adminSubFS)))) +``` + +### Permission Checking + +```go +// Permissions are bitfields. Check with bitwise AND. +type Permission uint32 + +const ( + PermSendMessages Permission = 1 << 0 + PermReadMessages Permission = 1 << 1 + PermAttachFiles Permission = 1 << 5 + PermAddReactions Permission = 1 << 6 + // ... see SCHEMA.md for full list + PermAdministrator Permission = 1 << 30 +) + +func (u *User) HasPermission(p Permission) bool { + if u.Permissions&PermAdministrator != 0 { + return true // admin bypasses all + } + return u.Permissions&p != 0 +} + +// For channel-specific overrides: +func (u *User) HasChannelPermission(channelID int, p Permission, db *DB) bool { + if u.HasPermission(PermAdministrator) { + return true + } + base := u.Permissions + override := db.GetChannelOverride(channelID, u.RoleID) + effective := (base | override.Allow) & ^override.Deny + return effective&p != 0 +} +``` + +### Input Sanitization + +```go +import "github.com/microcosm-cc/bluemonday" + +var sanitizer = bluemonday.StrictPolicy() // strips ALL HTML + +func sanitize(input string) string { + // Strip HTML + clean := sanitizer.Sanitize(input) + // Remove null bytes and control characters + clean = strings.Map(func(r rune) rune { + if r < 32 && r != '\n' && r != '\r' && r != '\t' { + return -1 + } + return r + }, clean) + return strings.TrimSpace(clean) +} +``` + +### File Upload Validation + +```go +func validateUpload(file multipart.File, header *multipart.FileHeader, maxBytes int64) error { + // 1. Size check + if header.Size > maxBytes { + return errors.New("file too large") + } + + // 2. Read first 512 bytes for magic byte detection + buf := make([]byte, 512) + n, _ := file.Read(buf) + file.Seek(0, 0) // reset reader + + // 3. Detect real content type (not from extension) + mime := http.DetectContentType(buf[:n]) + + // 4. Block dangerous types + blocked := []string{".exe", ".bat", ".cmd", ".ps1", ".scr", ".msi", ".com", ".vbs", ".js", ".wsf"} + ext := strings.ToLower(filepath.Ext(header.Filename)) + for _, b := range blocked { + if ext == b { + return errors.New("file type not allowed") + } + } + + // 5. Block if MIME doesn't match safe list + if !isAllowedMIME(mime) { + return errors.New("file type not allowed") + } + + return nil +} +``` + +### Build Command + +```bash +# Development +go run . + +# Production build +go build -o chatserver.exe -ldflags "-s -w -X main.version=1.0.0" . + +# Cross-compile (if building on Linux/Mac for Windows) +GOOS=windows GOARCH=amd64 go build -o chatserver.exe -ldflags "-s -w" . +``` + +## Security Checklist (for every new feature) + +- [ ] Input sanitized with bluemonday before storage +- [ ] Permissions checked server-side before any action +- [ ] Rate limiting applied to the endpoint +- [ ] Audit log entry for destructive/admin actions +- [ ] Error messages don't leak internal details +- [ ] File paths don't allow traversal (use UUIDs, not user filenames) +- [ ] SQL queries use parameterized statements (never string concat) diff --git a/skills/sqlite-patterns/SKILL.md b/skills/sqlite-patterns/SKILL.md new file mode 100644 index 00000000..94822dbf --- /dev/null +++ b/skills/sqlite-patterns/SKILL.md @@ -0,0 +1,326 @@ +--- +name: sqlite-patterns +description: Patterns for SQLite database access in the chat server β€” connection setup, migrations, query patterns, full-text search, and backup. Use this skill when working on the db/ package, writing SQL queries, creating migrations, implementing search, or handling database backups. Trigger when the user mentions SQLite, database, migration, query, search, FTS5, backup, or schema changes. Also use when debugging slow queries or data integrity issues. +--- + +# SQLite Patterns for ChatServer + +## Connection Setup + +```go +import ( + "database/sql" + _ "modernc.org/sqlite" +) + +func Open(path string) (*DB, error) { + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, err + } + + // Essential pragmas β€” run on every connection + pragmas := []string{ + "PRAGMA journal_mode=WAL", // concurrent reads, better performance + "PRAGMA foreign_keys=ON", // enforce FK constraints + "PRAGMA busy_timeout=5000", // wait 5s on lock instead of failing + "PRAGMA synchronous=NORMAL", // safe with WAL, faster than FULL + "PRAGMA cache_size=-20000", // 20MB cache + "PRAGMA temp_store=MEMORY", // temp tables in memory + } + for _, p := range pragmas { + db.Exec(p) + } + + return &DB{db: db}, nil +} + +type DB struct { + db *sql.DB +} +``` + +## Migration System + +``` +migrations/ +β”œβ”€β”€ 001_init.sql ← core tables (users, channels, messages, etc.) +β”œβ”€β”€ 002_fts.sql ← FTS5 virtual table + triggers +β”œβ”€β”€ 003_soundboard.sql ← soundboard table +└── ... +``` + +```go +//go:embed migrations/*.sql +var migrationsFS embed.FS + +func (d *DB) Migrate() error { + // Create version tracking + d.db.Exec(`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER)`) + + var current int + d.db.QueryRow("SELECT COALESCE(MAX(version), 0) FROM schema_version").Scan(¤t) + + files, _ := fs.ReadDir(migrationsFS, "migrations") + for _, f := range files { + // Extract version number from filename: "001_init.sql" -> 1 + num := extractVersion(f.Name()) + if num <= current { + continue + } + + data, _ := fs.ReadFile(migrationsFS, "migrations/"+f.Name()) + tx, _ := d.db.Begin() + _, err := tx.Exec(string(data)) + if err != nil { + tx.Rollback() + return fmt.Errorf("migration %s failed: %w", f.Name(), err) + } + tx.Exec("INSERT INTO schema_version (version) VALUES (?)", num) + tx.Commit() + slog.Info("applied migration", "file", f.Name()) + } + return nil +} +``` + +## Query Patterns + +### Always Use Parameterized Queries + +```go +// CORRECT β€” parameterized +row := d.db.QueryRow("SELECT id, username FROM users WHERE username = ?", username) + +// NEVER DO THIS β€” SQL injection +row := d.db.QueryRow("SELECT * FROM users WHERE username = '" + username + "'") +``` + +### Common Query Methods + +```go +// Single row +func (d *DB) GetUser(id int) (*User, error) { + var u User + err := d.db.QueryRow(` + SELECT u.id, u.username, u.avatar, u.status, r.permissions, r.name as role_name + FROM users u JOIN roles r ON u.role_id = r.id + WHERE u.id = ? AND u.banned = 0 + `, id).Scan(&u.ID, &u.Username, &u.Avatar, &u.Status, &u.Permissions, &u.RoleName) + if err == sql.ErrNoRows { + return nil, nil + } + return &u, err +} + +// Multiple rows +func (d *DB) GetMessages(channelID, beforeID, limit int) ([]Message, error) { + query := ` + SELECT m.id, m.channel_id, m.user_id, u.username, u.avatar, + m.content, m.reply_to, m.edited_at, m.deleted, m.pinned, m.timestamp + FROM messages m + JOIN users u ON m.user_id = u.id + WHERE m.channel_id = ? AND m.id < ? + ORDER BY m.id DESC + LIMIT ? + ` + rows, err := d.db.Query(query, channelID, beforeID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var messages []Message + for rows.Next() { + var m Message + rows.Scan(&m.ID, &m.ChannelID, &m.UserID, &m.Username, &m.Avatar, + &m.Content, &m.ReplyTo, &m.EditedAt, &m.Deleted, &m.Pinned, &m.Timestamp) + messages = append(messages, m) + } + return messages, rows.Err() +} + +// Insert returning ID +func (d *DB) CreateMessage(channelID, userID int, content string, replyTo *int, attachments []string) (*Message, error) { + tx, _ := d.db.Begin() + defer tx.Rollback() + + res, err := tx.Exec(` + INSERT INTO messages (channel_id, user_id, content, reply_to) + VALUES (?, ?, ?, ?) + `, channelID, userID, content, replyTo) + if err != nil { + return nil, err + } + + id, _ := res.LastInsertId() + + // Link attachments + for _, aid := range attachments { + tx.Exec("UPDATE attachments SET message_id = ? WHERE id = ?", id, aid) + } + + tx.Commit() + + // Fetch the complete message for broadcasting + return d.GetMessage(int(id)) +} +``` + +### Use Transactions for Multi-Step Operations + +```go +func (d *DB) BanUser(userID int, reason string, expiresAt *time.Time) error { + tx, _ := d.db.Begin() + defer tx.Rollback() + + // Ban the user + tx.Exec("UPDATE users SET banned = 1, ban_reason = ?, ban_expires = ? WHERE id = ?", + reason, expiresAt, userID) + + // Revoke all sessions + tx.Exec("DELETE FROM sessions WHERE user_id = ?", userID) + + return tx.Commit() +} +``` + +## Full-Text Search (FTS5) + +### Setup (in migration 002_fts.sql) + +```sql +-- Virtual table +CREATE VIRTUAL TABLE messages_fts USING fts5( + content, + content='messages', + content_rowid='id' +); + +-- Keep FTS in sync with triggers +CREATE TRIGGER messages_ai AFTER INSERT ON messages BEGIN + INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content); +END; + +CREATE TRIGGER messages_ad AFTER DELETE ON messages BEGIN + INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', old.id, old.content); +END; + +CREATE TRIGGER messages_au AFTER UPDATE ON messages BEGIN + INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', old.id, old.content); + INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content); +END; +``` + +### Search Query + +```go +func (d *DB) Search(userID int, query string, channelID *int, limit int) ([]SearchResult, error) { + // User can only search channels they have read permission for. + // Build list of accessible channel IDs first. + accessibleChannels := d.GetAccessibleChannelIDs(userID) + + sql := ` + SELECT m.id, m.channel_id, c.name, m.user_id, u.username, + snippet(messages_fts, 0, '**', '**', '...', 32) as snippet, + m.timestamp + FROM messages_fts + JOIN messages m ON m.id = messages_fts.rowid + JOIN channels c ON m.channel_id = c.id + JOIN users u ON m.user_id = u.id + WHERE messages_fts MATCH ? + AND m.channel_id IN (` + placeholders(len(accessibleChannels)) + `) + AND m.deleted = 0 + ORDER BY rank + LIMIT ? + ` + args := []interface{}{query} + for _, id := range accessibleChannels { + args = append(args, id) + } + args = append(args, limit) + + // ... execute and scan +} +``` + +## Session Management + +```go +func (d *DB) CreateSession(userID int, ip, device string) (string, error) { + token := generateSecureToken() // 256-bit random, hex encoded + expiresAt := time.Now().Add(30 * 24 * time.Hour) + + _, err := d.db.Exec(` + INSERT INTO sessions (user_id, token, ip_address, device, expires_at) + VALUES (?, ?, ?, ?, ?) + `, userID, token, ip, device, expiresAt) + + return token, err +} + +func (d *DB) ValidateSession(token string) (*User, error) { + var u User + err := d.db.QueryRow(` + SELECT u.id, u.username, u.avatar, u.status, r.permissions, r.name + FROM sessions s + JOIN users u ON s.user_id = u.id + JOIN roles r ON u.role_id = r.id + WHERE s.token = ? AND s.expires_at > datetime('now') AND u.banned = 0 + `, token).Scan(&u.ID, &u.Username, &u.Avatar, &u.Status, &u.Permissions, &u.RoleName) + + if err == nil { + // Update last_used + d.db.Exec("UPDATE sessions SET last_used = datetime('now') WHERE token = ?", token) + } + return &u, err +} +``` + +## Backup + +```go +func (d *DB) Backup(destPath string) error { + // SQLite backup API via SQL + _, err := d.db.Exec("VACUUM INTO ?", destPath) + return err + // VACUUM INTO creates a clean copy, safe to call while the server is running. + // The backup is a standalone .db file. +} +``` + +For full backup (database + uploads): + +```go +func FullBackup(cfg Config) error { + timestamp := time.Now().Format("2006-01-02_150405") + backupDir := filepath.Join(cfg.DataDir, "backups") + os.MkdirAll(backupDir, 0755) + + // 1. Backup database + dbBackup := filepath.Join(backupDir, timestamp+"_db.sqlite") + d.Backup(dbBackup) + + // 2. Create zip of database + uploads + zipPath := filepath.Join(backupDir, timestamp+".zip") + createZip(zipPath, []string{dbBackup, filepath.Join(cfg.DataDir, "uploads")}) + + // 3. Clean up temp db copy + os.Remove(dbBackup) + + // 4. Prune old backups (keep N most recent) + pruneBackups(backupDir, cfg.BackupRetention) + + return nil +} +``` + +## Performance Notes + +- SQLite handles the read/write load of a small chat server trivially. +- WAL mode allows concurrent reads while writing. +- Single-writer is fine β€” at this scale, writes complete in microseconds. +- Index on `messages(channel_id, id DESC)` is critical for paginated history. +- FTS5 queries are very fast β€” sub-millisecond for typical search volumes. +- `VACUUM INTO` for backups doesn't block the main database. +- If the database grows large (>1GB), consider archiving old messages to a separate file. diff --git a/skills/webrtc-voice/SKILL.md b/skills/webrtc-voice/SKILL.md new file mode 100644 index 00000000..cce08e2e --- /dev/null +++ b/skills/webrtc-voice/SKILL.md @@ -0,0 +1,229 @@ +--- +name: webrtc-voice +description: Patterns for implementing WebRTC voice chat, video calls, screen sharing, and the Pion SFU/TURN relay. Use this skill when working on anything related to voice channels, video, screen sharing, the Pion media server, TURN relay, audio processing, noise suppression, soundboard, or WebRTC signaling. Trigger when the user mentions voice, audio, video, call, screen share, SFU, TURN, STUN, Pion, Opus, DTLS, SRTP, or RNNoise. Also use for debugging audio device issues or WebRTC connection problems. +--- + +# WebRTC Voice & Video Patterns + +## Architecture Overview + +``` +Client A Server (Pion SFU) Client B + β”‚ β”‚ β”‚ + │── voice_join ─────────────►│◄──────────── voice_join ────│ + β”‚ β”‚ β”‚ + │── voice_offer (SDP) ──────►│ β”‚ + │◄── voice_answer (SDP) ─────│ β”‚ + │◄─► voice_ice (candidates) ─│ β”‚ + β”‚ │── voice_offer (SDP) ───────►│ + β”‚ │◄── voice_answer (SDP) ──────│ + β”‚ │◄─► voice_ice (candidates) ──│ + β”‚ β”‚ β”‚ + │══ DTLS-SRTP audio ═══════►│═══ DTLS-SRTP audio ════════►│ + │◄══ DTLS-SRTP audio ═══════│◄═══ DTLS-SRTP audio ════════│ +``` + +The server is an SFU (Selective Forwarding Unit): +- Each client sends ONE audio/video stream to the server. +- The server forwards that stream to every other client in the channel. +- The server never decodes or inspects media β€” it forwards encrypted packets. +- Much more efficient than mesh (where every client connects to every other client). + +## Server Side (Go + Pion) + +### SFU Setup + +```go +import ( + "github.com/pion/webrtc/v4" + "github.com/pion/turn/v3" +) + +// One PeerConnection per client per voice channel. +// Track forwarding: when Client A adds a track, create a new track +// on every other client's PeerConnection and forward RTP packets. + +type VoiceChannel struct { + ID int + Clients map[int]*VoiceClient // user_id -> client + mu sync.RWMutex +} + +type VoiceClient struct { + UserID int + PeerConnection *webrtc.PeerConnection + AudioTrack *webrtc.TrackLocalStaticRTP // outgoing track to this client +} +``` + +### Signaling Flow (server handles via WebSocket) + +``` +1. Client sends "voice_join" with channel_id +2. Server creates a PeerConnection for this client +3. Server sends "voice_offer" (SDP) to client +4. Client responds with "voice_answer" (SDP) +5. Both exchange ICE candidates via "voice_ice" +6. Media flows once ICE completes + +When a new client joins an existing channel: +- Create PeerConnection for new client +- For each existing client's audio track: + β†’ Add a forwarding track to the new client's PC +- Add new client's audio track forwarding to all existing clients +- Renegotiate with all affected clients (send new offers) +``` + +### TURN Relay (built into the server binary) + +```go +// Embedded TURN server using pion/turn +// Listens on the same port as the main server or a configurable port + +func startTURN(cfg config.Config) { + // Generate time-limited credentials + // Shared secret between HTTP API and TURN server + // Client requests credentials via GET /api/voice/credentials + // Credentials are HMAC(timestamp:userid, sharedSecret) + // TURN server validates credentials using the same shared secret + // Credentials expire after 24 hours +} +``` + +### TURN Credential Generation (REST endpoint) + +```go +// GET /api/voice/credentials +func (h *Handler) VoiceCredentials(w http.ResponseWriter, r *http.Request) { + user := auth.UserFromContext(r.Context()) + + timestamp := time.Now().Add(24 * time.Hour).Unix() + username := fmt.Sprintf("%d:%d", timestamp, user.ID) + + mac := hmac.New(sha1.New, []byte(h.turnSecret)) + mac.Write([]byte(username)) + credential := base64.StdEncoding.EncodeToString(mac.Sum(nil)) + + respondJSON(w, 200, map[string]interface{}{ + "ice_servers": []map[string]interface{}{ + {"urls": "stun:" + h.cfg.PublicAddr + ":3478"}, + {"urls": "turn:" + h.cfg.PublicAddr + ":3478", + "username": username, "credential": credential}, + }, + "expires_in": 86400, + }) +} +``` + +### Voice Quality Presets + +``` +low: Opus 32kbps mono β€” minimal bandwidth, acceptable quality +medium: Opus 64kbps mono β€” good balance (default) +high: Opus 128kbps stereo β€” best quality, more bandwidth + +Configured in server config.yaml, applied when creating PeerConnections. +Set via SDP codec preferences or Opus parameters. +``` + +## Client Side + +### WebRTC Connection + +``` +1. Request TURN credentials from GET /api/voice/credentials +2. Create RTCPeerConnection with ICE servers from response +3. Get user media (microphone): + - Use selected audio device from settings + - Apply noise suppression (RNNoise) if enabled +4. Add audio track to PeerConnection +5. Handle signaling via existing WebSocket connection +6. On remote track received: play through selected output device +``` + +### Audio Pipeline (client) + +``` +Microphone (WASAPI) + ↓ +Noise Suppression (RNNoise, if enabled) + ↓ +Voice Activity Detection (energy-based threshold) + ↓ (if voice detected OR push-to-talk held) +Opus Encoder (via WebRTC) + ↓ +Send to Server (DTLS-SRTP) + +Received Audio (DTLS-SRTP from server) + ↓ +Opus Decoder (via WebRTC) + ↓ +Per-user Volume Adjustment (client-side mixer) + ↓ +Speaker Output (WASAPI) +``` + +### Push-to-Talk Logic + +``` +if mode == "push_to_talk": + mic_track.enabled = ptt_key_held + +if mode == "voice_activation": + mic_track.enabled = audio_level > sensitivity_threshold + +// Send voice_mute WebSocket event when mic state changes +// so other clients see the mute indicator +``` + +### Screen Sharing + +``` +1. User clicks "Share Screen" +2. Capture screen via DXGI Desktop Duplication +3. Encode as video track (H.264 or VP8) +4. Add video track to PeerConnection +5. Server forwards video track to all other clients in the channel +6. Receiving clients display in a video panel + +Cap at 720p by default. Lower resolution if bandwidth is constrained. +Show "X is sharing their screen" indicator. +Only one screen share per channel at a time. +``` + +### Soundboard + +``` +1. User triggers a soundboard sound (button click or hotkey) +2. Client sends "soundboard_play" WebSocket message +3. Server validates: user has permission, cooldown not active +4. Server loads audio file, encodes as RTP packets +5. Server mixes into the voice channel audio (or sends as separate track) +6. All clients in the channel hear the sound + +Alternative (simpler): +- Client plays the sound locally AND sends the audio via their mic track +- Requires temporarily mixing the soundboard audio into the mic stream +``` + +## Debugging Tips + +### ICE Connection Fails +- Most common cause: NAT traversal failure +- Check TURN server is reachable: `turnutils_uclient -t -u user -w pass server:3478` +- Check firewall allows UDP on TURN port +- Check TURN credentials are valid (not expired) +- Client should log ICE connection state changes + +### Audio Not Working +- Check selected audio device is valid (devices can be unplugged) +- Check mic permissions (Windows may block mic access) +- Check audio track is enabled (not muted) +- Check Opus codec is negotiated in SDP +- Verify audio levels: add a meter before and after the pipeline + +### High Latency +- Prefer UDP (TURN over UDP, not TCP) +- Check if traffic is being relayed through TURN when direct P2P is possible +- Reduce Opus frame size for lower latency (at cost of bandwidth) +- Check server CPU β€” SFU forwarding should be near-zero CPU diff --git a/skills/websocket-protocol/SKILL.md b/skills/websocket-protocol/SKILL.md new file mode 100644 index 00000000..2679ac76 --- /dev/null +++ b/skills/websocket-protocol/SKILL.md @@ -0,0 +1,303 @@ +--- +name: websocket-protocol +description: Patterns for implementing the WebSocket hub, client connections, message routing, and real-time features. Use this skill when working on the WebSocket server (ws/ package), client-side WebSocket connection, message broadcasting, typing indicators, presence tracking, reconnection logic, or any real-time messaging feature. Trigger when the user mentions WebSocket, hub, broadcast, real-time, typing, presence, reconnect, or message delivery. Also use when debugging message delivery issues or connection drops. +--- + +# WebSocket Hub Patterns + +## Server Hub Architecture + +``` + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Hub β”‚ + β”‚ (1 per β”‚ + β”‚ server) β”‚ + β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ β”‚ β”‚ + β”Œβ”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”΄β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”΄β”€β”€β”€β”€β”€β” + β”‚ Client A β”‚ β”‚Client B β”‚ β”‚Client C β”‚ + β”‚ (2 gorout)β”‚ β”‚ β”‚ β”‚ β”‚ + β”‚ read|writeβ”‚ β”‚ β”‚ β”‚ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +Each WebSocket connection gets: +- 1 read goroutine (reads messages from client, sends to hub) +- 1 write goroutine (reads from a channel, writes to WebSocket) + +The Hub is the central router. It holds all connections and their channel subscriptions. + +## Hub Implementation (Go) + +```go +type Hub struct { + clients map[int]*Client // user_id -> client + channels map[int]map[int]bool // channel_id -> set of user_ids + register chan *Client + unregister chan *Client + broadcast chan BroadcastMsg + db *db.DB + mu sync.RWMutex +} + +type Client struct { + UserID int + Conn *websocket.Conn + Send chan []byte // buffered channel, write goroutine reads from this + Hub *Hub + Channels map[int]bool // channels this client is subscribed to +} + +type BroadcastMsg struct { + ChannelID int // 0 = broadcast to all + Exclude int // user_id to exclude (sender) + Data []byte +} + +func (h *Hub) Run() { + for { + select { + case client := <-h.register: + h.mu.Lock() + h.clients[client.UserID] = client + h.mu.Unlock() + h.broadcastPresence(client.UserID, "online") + + case client := <-h.unregister: + h.mu.Lock() + delete(h.clients, client.UserID) + h.mu.Unlock() + close(client.Send) + h.broadcastPresence(client.UserID, "offline") + + case msg := <-h.broadcast: + h.mu.RLock() + if msg.ChannelID == 0 { + // Broadcast to all connected clients + for uid, client := range h.clients { + if uid != msg.Exclude { + select { + case client.Send <- msg.Data: + default: + // Client send buffer full, drop message + } + } + } + } else { + // Broadcast to channel subscribers + for uid := range h.channels[msg.ChannelID] { + if uid != msg.Exclude { + if client, ok := h.clients[uid]; ok { + select { + case client.Send <- msg.Data: + default: + } + } + } + } + } + h.mu.RUnlock() + } + } +} +``` + +## Client Read/Write Goroutines + +```go +// Read goroutine: reads from WebSocket, dispatches to handler +func (c *Client) ReadPump() { + defer func() { + c.Hub.unregister <- c + c.Conn.Close() + }() + c.Conn.SetReadLimit(maxMessageSize) // 64KB + c.Conn.SetReadDeadline(time.Now().Add(pongWait)) + c.Conn.SetPongHandler(func(string) error { + c.Conn.SetReadDeadline(time.Now().Add(pongWait)) + return nil + }) + for { + _, message, err := c.Conn.ReadMessage() + if err != nil { + break + } + c.Hub.handleMessage(c, message) + } +} + +// Write goroutine: reads from Send channel, writes to WebSocket +func (c *Client) WritePump() { + ticker := time.NewTicker(pingPeriod) // 30 seconds + defer func() { + ticker.Stop() + c.Conn.Close() + }() + for { + select { + case message, ok := <-c.Send: + if !ok { + c.Conn.WriteMessage(websocket.CloseMessage, []byte{}) + return + } + c.Conn.SetWriteDeadline(time.Now().Add(writeWait)) + c.Conn.WriteMessage(websocket.TextMessage, message) + + case <-ticker.C: + c.Conn.SetWriteDeadline(time.Now().Add(writeWait)) + c.Conn.WriteMessage(websocket.PingMessage, nil) + } + } +} +``` + +## Message Routing + +```go +func (h *Hub) handleMessage(client *Client, raw []byte) { + var msg struct { + Type string `json:"type"` + ID string `json:"id"` + Payload json.RawMessage `json:"payload"` + } + if err := json.Unmarshal(raw, &msg); err != nil { + client.sendError(msg.ID, "INVALID_INPUT", "Invalid JSON") + return + } + + // Rate limiting per message type + if !h.rateLimiter.Allow(client.UserID, msg.Type) { + client.sendError(msg.ID, "RATE_LIMITED", "Slow down") + return + } + + switch msg.Type { + case "chat_send": + h.handleChatSend(client, msg.ID, msg.Payload) + case "chat_edit": + h.handleChatEdit(client, msg.ID, msg.Payload) + case "chat_delete": + h.handleChatDelete(client, msg.ID, msg.Payload) + case "typing_start": + h.handleTyping(client, msg.Payload) + case "presence_update": + h.handlePresence(client, msg.Payload) + case "reaction_add", "reaction_remove": + h.handleReaction(client, msg.ID, msg.Type, msg.Payload) + case "voice_join": + h.handleVoiceJoin(client, msg.Payload) + case "voice_leave": + h.handleVoiceLeave(client) + case "voice_offer", "voice_answer", "voice_ice": + h.handleVoiceSignal(client, msg.Type, msg.Payload) + case "voice_mute", "voice_deafen": + h.handleVoiceControl(client, msg.Type, msg.Payload) + case "soundboard_play": + h.handleSoundboard(client, msg.Payload) + default: + client.sendError(msg.ID, "INVALID_INPUT", "Unknown message type") + } +} +``` + +## Chat Send Handler (example) + +```go +func (h *Hub) handleChatSend(client *Client, reqID string, payload json.RawMessage) { + var input struct { + ChannelID int `json:"channel_id"` + Content string `json:"content"` + ReplyTo *int `json:"reply_to"` + Attachments []string `json:"attachments"` + } + json.Unmarshal(payload, &input) + + // 1. Permission check + if !client.User.HasChannelPermission(input.ChannelID, PermSendMessages, h.db) { + client.sendError(reqID, "FORBIDDEN", "Cannot send messages here") + return + } + + // 2. Sanitize + input.Content = sanitize(input.Content) + if len(input.Content) == 0 && len(input.Attachments) == 0 { + client.sendError(reqID, "INVALID_INPUT", "Message cannot be empty") + return + } + if len(input.Content) > 2000 { + client.sendError(reqID, "INVALID_INPUT", "Message too long") + return + } + + // 3. Store in database + msg, err := h.db.CreateMessage(input.ChannelID, client.UserID, input.Content, input.ReplyTo, input.Attachments) + if err != nil { + client.sendError(reqID, "SERVER_ERROR", "Failed to save message") + return + } + + // 4. Send ack to sender + client.sendJSON(map[string]interface{}{ + "type": "chat_send_ok", "id": reqID, + "payload": map[string]interface{}{"message_id": msg.ID, "timestamp": msg.Timestamp}, + }) + + // 5. Broadcast to channel (excluding sender) + h.broadcastToChannel(input.ChannelID, client.UserID, map[string]interface{}{ + "type": "chat_message", "payload": msg, + }) + + // 6. Update read states and mention counts + h.db.UpdateReadStates(input.ChannelID, msg.ID, input.Content) +} +``` + +## Client Reconnection (client-side) + +``` +State machine: + CONNECTED β†’ (connection lost) β†’ RECONNECTING β†’ (success) β†’ CONNECTED + ↓ (failure) + RECONNECTING (retry with backoff) + ↓ (max retries or auth expired) + DISCONNECTED (show login) + +Backoff schedule: 1s, 2s, 4s, 8s, 16s, 30s, 30s, 30s... +On reconnect success: + 1. Re-authenticate with stored token + 2. Server sends new "ready" payload with current state + 3. Client requests missed messages: GET /api/channels/{id}/messages?after={last_id} + 4. Client merges missed messages into local scrollback + 5. Update presence and unread counts + +Track last_received_message_id per channel to know what was missed. +``` + +## Rate Limits (enforced server-side) + +``` +chat_send: 10 per second per user +typing_start: 1 per 3 seconds per user per channel +presence_update: 1 per 10 seconds per user +reaction_*: 5 per second per user +voice_*: 20 per second per user (signaling can be bursty) +soundboard_play: 1 per 3 seconds per user + +Implementation: token bucket per (user_id, message_type). +On limit hit: send error with retry_after seconds, don't process the message. +``` + +## Constants + +```go +const ( + maxMessageSize = 65536 // 64KB max WebSocket message + writeWait = 10 * time.Second + pongWait = 60 * time.Second + pingPeriod = 30 * time.Second // must be < pongWait + maxChatLength = 2000 // characters + sendBufferSize = 256 // messages in client Send channel +) +```