Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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=<shared secret, also set on each relay>
PORT=3000
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ node_modules/**/*
npm-debug.*
*.orig.*
build/
*.bun-build
*.swp
.env
.DS_Store
15 changes: 14 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
44 changes: 32 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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/)
Expand Down
Binary file modified bun.lockb
Binary file not shown.
21 changes: 10 additions & 11 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,9 @@
"author": "David Mohundro <david@clearfunction.com>",
"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'",
Expand All @@ -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"
}
}
13 changes: 7 additions & 6 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}!`
);
})();
19 changes: 19 additions & 0 deletions src/relay-auth.ts
Original file line number Diff line number Diff line change
@@ -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);
}
52 changes: 52 additions & 0 deletions src/relay-registry.ts
Original file line number Diff line number Diff line change
@@ -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<T extends RelaySocket = RelaySocket> {
private entries = new Map<T, Entry>();

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;
});
}
}
Loading
Loading