Skip to content
Open
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
3 changes: 2 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
FROM oven/bun:1 AS base
# Pinned: floating `oven/bun:1` silently pulled a newer Bun and caused a crash loop. Native Bun.serve WebSockets are stable at 1.3.14.
FROM oven/bun:1.3.14 AS base
WORKDIR /usr/src/app

FROM base AS install
Expand Down
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
Binary file modified bun.lockb
Binary file not shown.
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
"license": "MIT",
"dependencies": {
"@slack/bolt": "^4.1.1",
"socket.io": "^4.8.0",
"typescript": "^5.0.4",
"typescript-eslint": "^8.17.0"
},
Expand Down
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