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,3 +1,5 @@
CLEARBOT_URL=https://3314-69-246-93-196.ngrok.io/
CLEARBOT_URL=ws://localhost:3000 # or wss://clearbot.yourdomain.com
RELAY_TOKEN=<shared secret, also set on the clearbot server>
SONOS_BRIDGE_URL=http://localhost:5005
# USE_LOCAL_SOUNDS=true
SONOS_ROOMS=Back Office # comma-separated for multiple speakers, e.g. Living Room,Kitchen
# USE_LOCAL_SOUNDS=true
32 changes: 22 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,30 +4,41 @@ This code is a proxy between the
[clearbot](https://github.com/clearfunction/clearbot) and the
[node-sonos-http-api](https://github.com/jishi/node-sonos-http-api) library.

It connects _out_ to ClearBot over a native WebSocket (authenticating with a
shared `RELAY_TOKEN`), receives JSON commands, and proxies them to
node-sonos-http-api. The connection reconnects automatically with exponential
backoff if it drops.

## Architecture

```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

- NPM
- Node
- Node 20–22 (uses the built-in global `WebSocket`)

## Running Locally

- Set up [clearbot](https://github.com/clearfunction/clearbot)
- Put some mp3s in the `static/clips` directory (they correspond to the clearbot sounds defined in <https://github.com/clearfunction/clearbot/blob/main/src/responses.ts>.)
- Set up your `.env` file (see `.env.example`)
- Set up your `.env` file (see `.env.example`):
- `CLEARBOT_URL` — the bot's WebSocket URL (`ws://localhost:3000` locally, `wss://…` in production)
- `RELAY_TOKEN` — must match the value set on `clearbot`
- `SONOS_BRIDGE_URL` — base URL of `node-sonos-http-api` (e.g. `http://localhost:5005`)
- `SONOS_ROOMS` — the speaker room name(s); comma-separated to play on multiple (e.g. `Living Room,Kitchen`)
- If you don't have a Sonos speaker, then you can still use the local player... just ensure you've got `USE_LOCAL_SOUNDS` set to `true`
- If you _do_ have a Sonos speaker, then you'll also need the `node-sonos-http-api` running locally
- Ensure your `clearbot` is running. It has its own documentation.
- Run `npm run dev`
- Wait for the `Connected to http://...` message. It finds the speaker prior to starting socket connection to the bot.
- Watch for `Connecting to <CLEARBOT_URL>` followed by `Connected to server` once it attaches to the bot. If you instead see a reconnect loop with a `code 1006` close immediately after connecting, the `RELAY_TOKEN` (or URL) is wrong.
- Enjoy!

## In the office
Expand All @@ -38,10 +49,11 @@ to daemonize it on the Mac Mini in our closet. Let's see how it works out for
us!

```sh
npm install # installs this app
npm install pm2 -g # installs the daemonizer
pm2 start ./server.js --name sonos_proxy # assumes you're in this app's folder, starts the daemon
pm2 save # saves the running process as a daemon that will be auto-restarted even after reboots
npm install # installs this app
npm run tsc # compiles to build/app.js
npm install pm2 -g # installs the daemonizer
pm2 start ./build/app.js --name sonos_proxy # assumes you're in this app's folder, starts the daemon
pm2 save # saves the running process as a daemon that will be auto-restarted even after reboots
```

## Resources
Expand Down
124 changes: 67 additions & 57 deletions app.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,18 @@
import 'dotenv/config';

import { io, Socket } from 'socket.io-client';
import { spawn } from 'node:child_process';
import player from 'play-sound';

type PlayUrl = {
url: string;
};

type PlayText = {
text: string;
volume: number;
};
import { handleMessage } from './messages';
import { nextBackoffMs } from './backoff';

type PlayClip = {
file: string;
volume: number;
};

type ServerToClientEvents = {
play_url: (data: PlayUrl) => void;
play_text: (data: PlayText) => void;
close: () => void;
};

// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface ClientToServerEvents {}
process.on('unhandledRejection', reason => {
console.warn('Unhandled promise rejection:', reason);
});

// connect to socket for bot commands
// the basic idea is that we just proxy commands to the referenced HTTP API
Expand All @@ -41,7 +28,9 @@ function playClip(roomName: string, data: PlayClip): void {
if (process.env.USE_LOCAL_SOUNDS === 'true') {
player().play(`static/clips/${data.file}`);
} else {
fetch(`${urlForRoom(roomName)}/clip/${file}/${volume}`);
fetch(`${urlForRoom(roomName)}/clip/${file}/${volume}`).catch(err =>
console.warn('Sonos bridge request failed:', err)
);
}
}

Expand All @@ -54,66 +43,87 @@ function localSay(text: string): void {
}
}

function sayClip(roomName: string, data: PlayText): void {
function sayClip(
roomName: string,
data: { text: string; volume: number }
): void {
const text = encodeURIComponent(data.text);
const volume = encodeURIComponent(data.volume);

if (process.env.USE_LOCAL_SOUNDS === 'true') {
localSay(text);
} else {
fetch(`${urlForRoom(roomName)}/say/${text}/${volume}`);
fetch(`${urlForRoom(roomName)}/say/${text}/${volume}`).catch(err =>
console.warn('Sonos bridge request failed:', err)
);
}
}

function enumeratePlayers(callback: (roomName: string) => void): void {
const roomName = 'Back Office';
callback(roomName);
// Rooms to play on, from SONOS_ROOMS (comma-separated for multiple speakers).
const rooms = (process.env.SONOS_ROOMS ?? 'Back Office')
.split(',')
.map(room => room.trim())
.filter(Boolean);

// TODO: at some point if we get another Sonos in here, we can add the
// enumerate rooms method back...
function enumeratePlayers(callback: (roomName: string) => void): void {
rooms.forEach(callback);
}

function registerListener(): void {
function connect(): void {
const serviceUrl = process.env.CLEARBOT_URL;

const token = process.env.RELAY_TOKEN;
if (!serviceUrl) throw new Error('CLEARBOT_URL was not defined.');
if (!token) throw new Error('RELAY_TOKEN was not defined.');

console.log('Connecting to', serviceUrl);

const socket: Socket<ServerToClientEvents, ClientToServerEvents> =
io(serviceUrl);

socket.on('connect', () => {
console.log(`Connected to server: ${serviceUrl}`);
});
let attempt = 0;

socket.on('play_url', data => {
console.log('Received play_url: ', data);
const open = () => {
console.log('Connecting to', serviceUrl);
// token rides as the WebSocket subprotocol (Sec-WebSocket-Protocol)
const ws = new WebSocket(serviceUrl, token);

enumeratePlayers(roomName => {
// HACK: switch to new format for now...
const { url } = data;
ws.addEventListener('open', () => {
attempt = 0;
console.log(`Connected to server: ${serviceUrl}`);
});

playClip(roomName, {
file: url,
volume: 20,
ws.addEventListener('message', event => {
handleMessage(String(event.data), {
onPlayUrl: data => {
console.log('Received play_url: ', data);
// HACK: switch to new format for now...
enumeratePlayers(roomName =>
playClip(roomName, { file: data.url, volume: 20 })
);
},
onPlayText: data => {
console.log('Received say: ', data);
enumeratePlayers(roomName => sayClip(roomName, data));
},
onClose: () => console.log('Server asked us to close; will reconnect.'),
});
});
});

socket.on('play_text', data => {
console.log('Received say: ', data);

enumeratePlayers(roomName => {
sayClip(roomName, data);
// A 1006 close right after connecting usually means a rejected token, not a down server.
ws.addEventListener('close', event => {
const delay = nextBackoffMs(attempt++);
console.log(
`Disconnected (code ${event.code}${event.reason ? `: ${event.reason}` : ''}); reconnecting in ${Math.round(delay / 1000)}s`
);
setTimeout(open, delay);
});
ws.addEventListener('error', () => {
// 'close' fires after 'error'; avoid double-scheduling
console.warn('WebSocket error; closing to trigger reconnect');
try {
ws.close();
} catch {
/* noop */
}
});
});
};

socket.on('close', () => {
console.log(`Lost contact with server: ${serviceUrl}`);
console.log("I don't know how to reconnect yet. Please help!");
process.exit(1);
});
open();
}

registerListener();
connect();
19 changes: 19 additions & 0 deletions backoff.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { expect, test } from 'vitest';
import { nextBackoffMs } from './backoff';

test('grows exponentially from 1s', () => {
expect(nextBackoffMs(0, 0)).toBe(1000);
expect(nextBackoffMs(1, 0)).toBe(2000);
expect(nextBackoffMs(2, 0)).toBe(4000);
expect(nextBackoffMs(4, 0)).toBe(16000);
});

test('caps at 30s', () => {
expect(nextBackoffMs(10, 0)).toBe(30000);
});

test('adds jitter in [0, 1000)', () => {
const v = nextBackoffMs(0, 0.5);
expect(v).toBeGreaterThanOrEqual(1000);
expect(v).toBeLessThan(2000);
});
13 changes: 13 additions & 0 deletions backoff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
const BASE_MS = 1000;
const CAP_MS = 30_000;
const JITTER_MS = 1000;

/**
* Exponential backoff with a cap and additive jitter.
* @param attempt consecutive failed attempts (0-based)
* @param rand a value in [0,1); defaults to Math.random() (injectable for tests)
*/
export function nextBackoffMs(attempt: number, rand: number = Math.random()): number {
const base = Math.min(CAP_MS, BASE_MS * 2 ** attempt);
return base + Math.floor(rand * JITTER_MS);
}
2 changes: 2 additions & 0 deletions environment.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ declare global {
namespace NodeJS {
interface ProcessEnv {
CLEARBOT_URL: string;
RELAY_TOKEN: string;
SONOS_BRIDGE_URL: string;
SONOS_ROOMS: string;
USE_LOCAL_SOUNDS: string;
}
}
Expand Down
28 changes: 28 additions & 0 deletions messages.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { expect, test, vi } from 'vitest';
import { handleMessage } from './messages';

const handlers = () => ({ onPlayUrl: vi.fn(), onPlayText: vi.fn(), onClose: vi.fn() });

test('routes play_url', () => {
const h = handlers();
handleMessage(JSON.stringify({ type: 'play_url', url: 'u' }), h);
expect(h.onPlayUrl).toHaveBeenCalledWith({ type: 'play_url', url: 'u' });
});

test('routes play_text', () => {
const h = handlers();
handleMessage(JSON.stringify({ type: 'play_text', text: 't', volume: 60 }), h);
expect(h.onPlayText).toHaveBeenCalledWith({ type: 'play_text', text: 't', volume: 60 });
});

test('ignores malformed JSON without throwing', () => {
const h = handlers();
expect(() => handleMessage('not json', h)).not.toThrow();
expect(h.onPlayUrl).not.toHaveBeenCalled();
});

test('ignores unknown type', () => {
const h = handlers();
handleMessage(JSON.stringify({ type: 'wat' }), h);
expect(h.onPlayUrl).not.toHaveBeenCalled();
});
39 changes: 39 additions & 0 deletions messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
export interface PlayUrl {
type: 'play_url';
url: string;
}
export interface PlayText {
type: 'play_text';
text: string;
volume: number;
}
export interface CloseCmd {
type: 'close';
}

export interface MessageHandlers {
onPlayUrl: (cmd: PlayUrl) => void;
onPlayText: (cmd: PlayText) => void;
onClose: (cmd: CloseCmd) => void;
}

export function handleMessage(raw: string, handlers: MessageHandlers): void {
let cmd: unknown;
try {
cmd = JSON.parse(raw);
} catch {
console.warn('Ignoring malformed relay message');
return;
}
if (!cmd || typeof cmd !== 'object' || !('type' in cmd)) return;
switch ((cmd as { type: string }).type) {
case 'play_url':
return handlers.onPlayUrl(cmd as PlayUrl);
case 'play_text':
return handlers.onPlayText(cmd as PlayText);
case 'close':
return handlers.onClose(cmd as CloseCmd);
default:
console.warn('Ignoring unknown relay message type');
}
}
Loading