diff --git a/.env.example b/.env.example index acd6c27..2020551 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,4 @@ -SLACK_SIGNING_SECRET=FILL THIS OUT -SLACK_BOT_TOKEN=FILL THIS OUT +SLACK_BOT_TOKEN=xoxb-... +SLACK_APP_TOKEN=xapp-... +RELAY_TOKEN= +PORT=3000 diff --git a/.gitignore b/.gitignore index 73cfcbf..fe017cf 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules/**/* npm-debug.* *.orig.* build/ +*.bun-build *.swp .env .DS_Store diff --git a/Makefile b/Makefile index 9c0b090..6224848 100644 --- a/Makefile +++ b/Makefile @@ -46,10 +46,23 @@ logging: webapp open: logging open https://$(APPNAME).azurewebsites.net +# Run `websockets` and `appsettings` after `webapp` (post-deploy). +# Socket Mode means SLACK_SIGNING_SECRET is no longer used; the bot needs +# SLACK_APP_TOKEN and RELAY_TOKEN instead. WebSockets must be enabled on the +# App Service (off by default) for the native Bun.serve relay connections. +websockets: + az webapp config set -n $(APPNAME) -g $(RG) --web-sockets-enabled true + +appsettings: + az webapp config appsettings set -n $(APPNAME) -g $(RG) --settings \ + SLACK_BOT_TOKEN="$$SLACK_BOT_TOKEN" \ + SLACK_APP_TOKEN="$$SLACK_APP_TOKEN" \ + RELAY_TOKEN="$$RELAY_TOKEN" + logs: az webapp log tail -n $(APPNAME) -g $(RG) rollback: az group delete -n $(RG) -y -.PHONY: build logs rollback open webapp rg plan all +.PHONY: build logs rollback open webapp rg plan all websockets appsettings diff --git a/README.md b/README.md index 5e6ff16..287cae2 100644 --- a/README.md +++ b/README.md @@ -2,18 +2,26 @@ This code is a Slack application (bot) written using [Slack Bolt](https://api.slack.com/bolt). It allows users to play mp3 clips on a Sonos -speaker. It communicates with [Sonos Proxy](https://github.com/clearfunction/sonos_proxy_nodejs) -which then communicates with [node-sonos-http-api](https://github.com/jishi/node-sonos-http-api). +speaker. It connects to Slack using **Socket Mode** (an outbound WebSocket — no +public endpoint required) and relays commands over a native WebSocket to [Sonos +Proxy](https://github.com/clearfunction/sonos_proxy_nodejs), which in turn +communicates with [node-sonos-http-api](https://github.com/jishi/node-sonos-http-api). We affectionately refer to this as "Burn Bot." ## Architecture +Both connections are established _outbound_ — ClearBot dials Slack, and each +Sonos Proxy dials ClearBot — so neither ClearBot nor the proxies need a public +inbound endpoint. Messages then flow back over those sockets: + ```mermaid sequenceDiagram - Slack-->>ClearBot: POST /slack/events { some message } - ClearBot-->>Sonos Proxy: websocket play_url { some message } - Sonos Proxy-->>node-sonos-http-api: GET http://localhost:5001/Office/clip/burn.mp3 + Note over Slack,ClearBot: ClearBot connects out to Slack (Socket Mode) + Slack-->>ClearBot: message event (e.g. "burn") + Note over ClearBot,Sonos Proxy: Sonos Proxy connects out to ClearBot (WebSocket, token auth) + ClearBot-->>Sonos Proxy: { type: play_url, url: burn.mp3 } + Sonos Proxy-->>node-sonos-http-api: GET http://localhost:5005/Office/clip/burn.mp3/20 ``` ## Requirements @@ -22,21 +30,33 @@ sequenceDiagram ## Running Locally -The easiest way to test is to set this up in a standalone Slack instance and -then use a local proxy like [ngrok](https://ngrok.com/). +Because ClearBot uses Socket Mode, it connects _out_ to Slack — there is no +public endpoint to expose. -- Create a Slack application (see Slack Bolt API link below) -- Run `bun install` to install dependencies -- Run `bun run dev` (it defaults to port 3000) -- Run ngrok to create a proxy to your Bolt app (`ngrok serve 3000`) -- Point your Slack's event subscription to your ngrok URL +- Create a Slack app and **enable Socket Mode** (Settings → Socket Mode). +- Generate an **app-level token** (Basic Information → App-Level Tokens) with the + `connections:write` scope — this is the `xapp-…` token. +- Under **Event Subscriptions**, subscribe to the bot message events (e.g. + `message.channels`). With Socket Mode on there is no Request URL to set. +- Copy `.env.example` to `.env` and fill in: + - `SLACK_BOT_TOKEN` — the bot token (`xoxb-…`) + - `SLACK_APP_TOKEN` — the app-level token (`xapp-…`) + - `RELAY_TOKEN` — a shared secret the Sonos Proxy must also use (generate with + `openssl rand -hex 32`) +- Run `bun install`, then `bun run dev` (defaults to port 3000). - Set up [Sonos Proxy](https://github.com/clearfunction/sonos_proxy_nodejs) + pointed at `ws://localhost:3000` with the same `RELAY_TOKEN`. - Enjoy! ## Deployment See the `Makefile`... make sure you are in the expected subscription by running `az account set --subscription YOUR_SUBSCRIPTION_ID`. +The relay uses WebSockets, which are **disabled by default** on Azure App +Service — run `make websockets` to enable them, and `make appsettings` to set +`SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN`, and `RELAY_TOKEN` on the web app. +(`SLACK_SIGNING_SECRET` is no longer used now that the bot runs in Socket Mode.) + ## Resources - [Slack Bolt API](https://slack.dev/bolt/) diff --git a/bun.lockb b/bun.lockb index 02c85c6..d974048 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 0ba440b..79e964f 100644 --- a/package.json +++ b/package.json @@ -7,10 +7,9 @@ "author": "David Mohundro ", "license": "MIT", "dependencies": { - "@slack/bolt": "^4.1.1", - "socket.io": "^4.8.0", - "typescript": "^5.0.4", - "typescript-eslint": "^8.17.0" + "@slack/bolt": "^4.7.3", + "typescript": "^5.9.3", + "typescript-eslint": "^8.60.1" }, "scripts": { "lint": "eslint '**/*.ts'", @@ -20,14 +19,14 @@ "test": "vitest" }, "devDependencies": { - "@types/bun": "latest", - "@typescript-eslint/eslint-plugin": "^8.17.0", - "@typescript-eslint/parser": "^8.17.0", - "eslint": "^9.16.0", + "@types/bun": "^1.3.14", + "@typescript-eslint/eslint-plugin": "^8.60.1", + "@typescript-eslint/parser": "^8.60.1", + "eslint": "^9.39.4", "eslint-config-airbnb-base": "^15.0.0", - "eslint-config-prettier": "^9.1.0", - "eslint-plugin-import": "^2.26.0", - "prettier": "3.4.1", + "eslint-config-prettier": "^9.1.2", + "eslint-plugin-import": "^2.32.0", + "prettier": "^3.8.3", "vitest": "^4.1.8" } } diff --git a/src/app.ts b/src/app.ts index e496351..f5ec10a 100644 --- a/src/app.ts +++ b/src/app.ts @@ -6,15 +6,16 @@ const sonos = new Sonos(); const app = new App({ token: process.env.SLACK_BOT_TOKEN, - signingSecret: process.env.SLACK_SIGNING_SECRET, + appToken: process.env.SLACK_APP_TOKEN, + socketMode: true, }); attachResponses(app, sonos); (async () => { - const port = Number(process.env.PORT) || 3000; - const server = await app.start(port); - sonos.initialize(server, app); - - console.log(`⚡️ Bolt app is running on ${port}!`); + await app.start(); + const relay = sonos.initialize(app); + console.log( + `⚡️ Bolt app running (Socket Mode); relay server on ${relay.port}!` + ); })(); diff --git a/src/relay-auth.ts b/src/relay-auth.ts new file mode 100644 index 0000000..347a433 --- /dev/null +++ b/src/relay-auth.ts @@ -0,0 +1,19 @@ +/** + * Validates a relay's offered WebSocket subprotocol(s) against the configured + * shared secret. The client offers the token as the WebSocket subprotocol + * (Sec-WebSocket-Protocol), which may arrive as a comma-separated list. + * + * Returns false unless a non-empty secret is configured AND one of the offered + * values matches it exactly. Never throws. + */ +export function isValidRelayToken( + offered: string | undefined | null, + secret: string | undefined | null +): boolean { + if (!secret) return false; + if (!offered) return false; + return offered + .split(',') + .map((p) => p.trim()) + .includes(secret); +} diff --git a/src/relay-registry.ts b/src/relay-registry.ts new file mode 100644 index 0000000..41bd8d5 --- /dev/null +++ b/src/relay-registry.ts @@ -0,0 +1,52 @@ +import { randomFromArray } from './utils'; + +/** Minimal shape we need from a relay socket (real: Bun ServerWebSocket). */ +export interface RelaySocket { + send(data: string): void; +} + +interface Entry { + lastPongAt: number; +} + +export class RelayRegistry { + private entries = new Map(); + + add(ws: T, now: number): void { + this.entries.set(ws, { lastPongAt: now }); + } + + remove(ws: T): void { + this.entries.delete(ws); + } + + markPong(ws: T, now: number): void { + const entry = this.entries.get(ws); + if (entry) entry.lastPongAt = now; + } + + count(): number { + return this.entries.size; + } + + all(): T[] { + return [...this.entries.keys()]; + } + + pickRandom(): T | undefined { + const all = this.all(); + return all.length ? randomFromArray(all) : undefined; + } + + /** + * Returns relays that have not ponged within two intervals (the grace + * window). Caller is responsible for terminating + removing them. + */ + sweep(now: number, intervalMs: number): T[] { + const grace = intervalMs * 2; + return this.all().filter((ws) => { + const entry = this.entries.get(ws); + return !!entry && now - entry.lastPongAt > grace; + }); + } +} diff --git a/src/sonos.ts b/src/sonos.ts index e1b4667..5fbeba2 100644 --- a/src/sonos.ts +++ b/src/sonos.ts @@ -1,46 +1,143 @@ import { App, SayFn } from '@slack/bolt'; -import { Server as HttpServer } from 'http'; -import { Server, Socket } from 'socket.io'; +import type { ServerWebSocket, Server } from 'bun'; import { getHelpText } from './responses'; -import { randomFromArray } from './utils'; +import { RelayRegistry } from './relay-registry'; +import { isValidRelayToken } from './relay-auth'; interface PlayUrl { + type: 'play_url'; url: string; } - interface PlayText { + type: 'play_text'; text: string; volume: number; } +interface CloseCmd { + type: 'close'; +} +export type RelayCommand = PlayUrl | PlayText | CloseCmd; -interface ServerToClientEvents { - play_url: (data: PlayUrl) => void; - play_text: (data: PlayText) => void; - close: () => void; +const HEARTBEAT_MS = 30_000; +const IDLE_TIMEOUT_S = 120; + +interface RelayData { + authedAt: number; } -interface ClientToServerEvents {} +export interface RelayServer { + port: number; + relayCount(): number; + /** test seam: push a command to a random relay */ + broadcastTest(cmd: RelayCommand): void; + stop(): void; +} + +export interface StartOptions { + port: number; + token: string | undefined; +} export default class Sonos { - sockets: Socket[] = []; + private registry = new RelayRegistry>(); + private server?: Server; + private heartbeat?: ReturnType; + private token: string | undefined; + + initialize(app: App): RelayServer { + this.token = process.env.RELAY_TOKEN; + const port = Number(process.env.PORT) || 3000; + const handle = this.startServer({ port, token: this.token }); + this.registerSlackHandlers(app); + return handle; + } - initialize(server: HttpServer, app: App): void { - const io = new Server(server); + startServer(opts: StartOptions): RelayServer { + this.token = opts.token; + const registry = this.registry; + const token = this.token; + + this.server = Bun.serve({ + port: opts.port, + fetch(req, server) { + // Non-WebSocket requests are Azure's health/warmup probes. + if (req.headers.get('upgrade')?.toLowerCase() !== 'websocket') { + return new Response('OK'); + } + const offered = req.headers.get('sec-websocket-protocol'); + if (!isValidRelayToken(offered, token)) { + return new Response('Unauthorized', { status: 401 }); + } + const matched = (offered ?? '').split(',')[0].trim(); + const ok = server.upgrade(req, { + data: { authedAt: Date.now() }, + // Echo the selected subprotocol back per the WS spec. + headers: { 'Sec-WebSocket-Protocol': matched }, + }); + return ok ? undefined : new Response('Upgrade failed', { status: 500 }); + }, + websocket: { + idleTimeout: IDLE_TIMEOUT_S, + sendPings: false, // we ping explicitly in the heartbeat + open: (ws) => { + registry.add(ws, Date.now()); + this.logClientStats('New relay connected to Sonos!'); + }, + message: (_ws, message) => { + console.log( + 'Ignoring unexpected relay message:', + String(message).slice(0, 200) + ); + }, + pong: (ws) => registry.markPong(ws, Date.now()), + close: (ws) => { + registry.remove(ws); + this.logClientStats('Relay disconnected from Sonos.'); + }, + }, + }); + + this.heartbeat = setInterval(() => this.runHeartbeat(), HEARTBEAT_MS); + + return { + port: this.server.port, + relayCount: () => registry.count(), + broadcastTest: (cmd) => this.sendToRandom(cmd), + stop: () => this.stop(), + }; + } - io.on( - 'connection', - (socket: Socket) => { - this.onConnection(socket); + private runHeartbeat(): void { + const now = Date.now(); + for (const ws of this.registry.sweep(now, HEARTBEAT_MS)) { + try { + ws.terminate(); + } catch { + /* already gone */ } - ); + this.registry.remove(ws); + } + for (const ws of this.registry.all()) { + try { + ws.ping(); + } catch { + /* will be swept next round */ + } + } + } - io.listen(server); + stop(): void { + if (this.heartbeat) clearInterval(this.heartbeat); + this.heartbeat = undefined; + this.server?.stop(true); + this.server = undefined; + } + private registerSlackHandlers(app: App): void { app.message(/sonos (.+)/, async ({ context, say }) => { - const match = context.matches[0]; - this.playOnSonos(match, say); + // matches[1] = the captured URL (matches[0] would include the "sonos " prefix) + this.playOnSonos(context.matches[1], say); }); - app.message(/health/, async ({ say }) => { const count = this.clientCount(); if (count > 0) { @@ -49,15 +146,12 @@ export default class Sonos { Sonos.alertNoClients(say); } }); - app.message(/help/, async ({ say }) => { say(getHelpText()); }); - app.message(/say (.+)/, async ({ context, say }) => { this.textToSpeech(context.matches[1], say); }); - app.message(/^Reminder: announcement (.+)/, async ({ context, say }) => { this.textToSpeech(context.matches[1], say); }); @@ -67,7 +161,7 @@ export default class Sonos { if (this.clientCount() < 1) { Sonos.alertNoClients(say); } else { - this.getSocket().emit('play_url', { url }); + this.sendToRandom({ type: 'play_url', url }); } } @@ -75,24 +169,21 @@ export default class Sonos { if (this.clientCount() < 1) { Sonos.alertNoClients(say); } else { - this.getSocket().emit('play_text', { text, volume: 60 }); + this.sendToRandom({ type: 'play_text', text, volume: 60 }); } } - private getSocket(): Socket { - console.log('Sonosing a message to a random client...'); - let socket = null; - try { - socket = randomFromArray(this.sockets); - } catch (error) { - console.error(error); - [socket] = this.sockets; + private sendToRandom(cmd: RelayCommand): void { + const ws = this.registry.pickRandom(); + if (!ws) { + console.error('No relay available to receive command', cmd.type); + return; } - return socket; + ws.send(JSON.stringify(cmd)); } private clientCount(): number { - return this.sockets.length; + return this.registry.count(); } private logClientStats(message: string): void { @@ -105,25 +196,15 @@ export default class Sonos { private static alertNoClients(say: SayFn) { const RELAY_CLIENT_DOWNLOAD_URL = 'https://github.com/clearfunction/sonos_proxy_nodejs'; - say( `Sorry, I don't have any Sonos relay clients connected right now. You can download one here... ${RELAY_CLIENT_DOWNLOAD_URL}` ); - setTimeout(() => { say('... Burn! :fire: http://i.imgur.com/4lhFLpO.gif'); }, 3 * 1000); } +} - private onConnection( - socket: Socket - ): void { - this.sockets.unshift(socket); - this.logClientStats('New clients connected to Sonos relay!'); - - socket.on('disconnect', () => { - this.sockets = this.sockets.filter((x) => x !== socket); - this.logClientStats('Client disconnected from Sonos relay.'); - }); - } +export function startRelayServer(opts: StartOptions): RelayServer { + return new Sonos().startServer(opts); } diff --git a/test/relay-auth.test.ts b/test/relay-auth.test.ts new file mode 100644 index 0000000..5d96b51 --- /dev/null +++ b/test/relay-auth.test.ts @@ -0,0 +1,26 @@ +import { expect, test } from 'vitest'; +import { isValidRelayToken } from '../src/relay-auth'; + +const SECRET = 'sekret'; + +test('accepts an exact token match', () => { + expect(isValidRelayToken('sekret', SECRET)).toBe(true); +}); + +test('accepts a token offered among comma-separated subprotocols', () => { + expect(isValidRelayToken('sekret, foo', SECRET)).toBe(true); +}); + +test('rejects a wrong token', () => { + expect(isValidRelayToken('nope', SECRET)).toBe(false); +}); + +test('rejects a missing/undefined token', () => { + expect(isValidRelayToken(undefined, SECRET)).toBe(false); + expect(isValidRelayToken('', SECRET)).toBe(false); +}); + +test('rejects everything when no secret is configured', () => { + expect(isValidRelayToken('anything', '')).toBe(false); + expect(isValidRelayToken('anything', undefined)).toBe(false); +}); diff --git a/test/relay-registry.test.ts b/test/relay-registry.test.ts new file mode 100644 index 0000000..49b6dd7 --- /dev/null +++ b/test/relay-registry.test.ts @@ -0,0 +1,50 @@ +import { expect, test } from 'vitest'; +import { RelayRegistry } from '../src/relay-registry'; + +interface FakeWs { + sent: string[]; + send: (s: string) => void; +} +const fakeWs = (): FakeWs => { + const sent: string[] = []; + return { sent, send: (s) => sent.push(s) }; +}; + +test('add increases count, remove decreases it', () => { + const r = new RelayRegistry(); + const a = fakeWs(); + r.add(a, 0); + expect(r.count()).toBe(1); + r.remove(a); + expect(r.count()).toBe(0); +}); + +test('pickRandom returns a connected relay or undefined when empty', () => { + const r = new RelayRegistry(); + expect(r.pickRandom()).toBeUndefined(); + const a = fakeWs(); + r.add(a, 0); + expect(r.pickRandom()).toBe(a); +}); + +test('sweep returns relays that missed two intervals and keeps fresh ones', () => { + const r = new RelayRegistry(); + const stale = fakeWs(); + const fresh = fakeWs(); + r.add(stale, 0); // last pong at t=0 + r.add(fresh, 0); + r.markPong(fresh, 50_000); // fresh ponged recently + + // interval 30s; stale at t=70s has missed > 2 intervals (60s) + const dropped = r.sweep(70_000, 30_000); + expect(dropped).toContain(stale); + expect(dropped).not.toContain(fresh); +}); + +test('sweep does not drop a relay within the grace window', () => { + const r = new RelayRegistry(); + const ws = fakeWs(); + r.add(ws, 0); + r.markPong(ws, 0); + expect(r.sweep(59_000, 30_000)).toHaveLength(0); // < 60s +}); diff --git a/test/relay-server.test.ts b/test/relay-server.test.ts new file mode 100644 index 0000000..97355a6 --- /dev/null +++ b/test/relay-server.test.ts @@ -0,0 +1,37 @@ +import { expect, test, afterEach } from 'vitest'; +import { startRelayServer, type RelayServer } from '../src/sonos'; + +const RUN = typeof Bun !== 'undefined'; +const TOKEN = 'test-token'; + +let server: RelayServer | undefined; +afterEach(() => server?.stop()); + +test.skipIf(!RUN)('valid token upgrades and receives a play_url command', async () => { + server = startRelayServer({ port: 0, token: TOKEN }); + const url = `ws://localhost:${server.port}`; + + const ws = new WebSocket(url, TOKEN); // token as subprotocol + const opened = new Promise((res) => (ws.onopen = () => res())); + await opened; + + const got = new Promise((res) => (ws.onmessage = (e) => res(String(e.data)))); + // server should now see exactly one relay + expect(server.relayCount()).toBe(1); + server.broadcastTest({ type: 'play_url', url: 'https://x/clip.mp3' }); + + const msg = JSON.parse(await got); + expect(msg).toEqual({ type: 'play_url', url: 'https://x/clip.mp3' }); + ws.close(); +}); + +test.skipIf(!RUN)('rejects an upgrade with a bad token', async () => { + server = startRelayServer({ port: 0, token: TOKEN }); + const ws = new WebSocket(`ws://localhost:${server.port}`, 'wrong-token'); + const closed = new Promise((res) => { + ws.onclose = (e) => res(e.code); + ws.onerror = () => res(-1); + }); + await closed; // never opens + expect(server.relayCount()).toBe(0); +});