Files
Haven/test/botAudioApi.integration.test.js
T
Bernardo Costa 3b14588bae Add dynamic bot voice audio (#5540)
* Add dynamic bot voice audio

* Close bot audio review gaps
2026-08-29 23:43:44 -04:00

343 lines
14 KiB
JavaScript

'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const http = require('node:http');
const net = require('node:net');
const os = require('node:os');
const path = require('node:path');
const { spawn, spawnSync } = require('node:child_process');
const test = require('node:test');
const Database = require('better-sqlite3');
const WebSocket = require('ws');
function createPcmWav(durationSeconds = 2, sampleRate = 8000) {
const samples = Math.floor(durationSeconds * sampleRate);
const dataSize = samples * 2;
const buffer = Buffer.alloc(44 + dataSize);
buffer.write('RIFF', 0);
buffer.writeUInt32LE(36 + dataSize, 4);
buffer.write('WAVE', 8);
buffer.write('fmt ', 12);
buffer.writeUInt32LE(16, 16);
buffer.writeUInt16LE(1, 20);
buffer.writeUInt16LE(1, 22);
buffer.writeUInt32LE(sampleRate, 24);
buffer.writeUInt32LE(sampleRate * 2, 28);
buffer.writeUInt16LE(2, 32);
buffer.writeUInt16LE(16, 34);
buffer.write('data', 36);
buffer.writeUInt32LE(dataSize, 40);
return buffer;
}
async function availablePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const { port } = server.address();
server.close(error => error ? reject(error) : resolve(port));
});
});
}
async function waitForServer(url, child, output) {
const deadline = Date.now() + 10000;
while (Date.now() < deadline) {
if (child.exitCode !== null) throw new Error(`Server exited early:\n${output()}`);
try {
const response = await fetch(url);
if (response.ok) return;
} catch {}
await new Promise(resolve => setTimeout(resolve, 100));
}
throw new Error(`Server did not become ready:\n${output()}`);
}
function connectVoiceSocket(baseUrl, auth, channelCode) {
const socketUrl = baseUrl.replace(/^http/, 'ws') + '/socket.io/?EIO=4&transport=websocket';
const ws = new WebSocket(socketUrl);
const events = [];
const waiters = [];
function pushEvent(event, payload) {
events.push({ event, payload });
for (let index = waiters.length - 1; index >= 0; index--) {
const waiter = waiters[index];
if (waiter.event !== event || !waiter.predicate(payload)) continue;
waiters.splice(index, 1);
clearTimeout(waiter.timer);
waiter.resolve(payload);
}
}
const ready = new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('Timed out joining Socket.IO voice')), 5000);
ws.on('message', raw => {
const packet = raw.toString();
if (packet === '2') return ws.send('3');
if (packet.startsWith('0')) {
ws.send(`40${JSON.stringify(auth)}`);
return;
}
if (packet.startsWith('44')) {
clearTimeout(timer);
reject(new Error(`Socket.IO authentication failed: ${packet}`));
return;
}
if (packet.startsWith('40')) {
ws.send(`42${JSON.stringify(['voice-join', { code: channelCode }])}`);
return;
}
if (!packet.startsWith('42')) return;
const payload = JSON.parse(packet.slice(2));
pushEvent(payload[0], payload[1]);
if (payload[0] === 'voice-existing-users' && payload[1]?.channelCode === channelCode) {
clearTimeout(timer);
resolve();
}
});
ws.once('error', reject);
});
function waitFor(event, predicate = () => true, timeoutMs = 3000) {
const existing = events.find(item => item.event === event && predicate(item.payload));
if (existing) return Promise.resolve(existing.payload);
return new Promise((resolve, reject) => {
const waiter = { event, predicate, resolve, timer: null };
waiter.timer = setTimeout(() => {
const index = waiters.indexOf(waiter);
if (index !== -1) waiters.splice(index, 1);
reject(new Error(`Timed out waiting for ${event}`));
}, timeoutMs);
waiters.push(waiter);
});
}
return { ws, ready, waitFor };
}
async function waitForEmptyDirectory(dir) {
const deadline = Date.now() + 3000;
while (Date.now() < deadline) {
if ((await fs.promises.readdir(dir)).length === 0) return;
await new Promise(resolve => setTimeout(resolve, 10));
}
assert.deepEqual(await fs.promises.readdir(dir), []);
}
async function startAndAbortUpload(baseUrl, token, dataDir) {
const boundary = `haven-abort-${Date.now()}`;
const prefix = Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="audio"; filename="speech.wav"\r\n` +
'Content-Type: audio/wav\r\n\r\n'
);
const request = http.request(`${baseUrl}/api/webhooks/${token}/audio`, {
method: 'POST',
headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}` }
});
request.on('error', () => {});
request.write(prefix);
request.write(createPcmWav().subarray(0, 44));
const audioDir = path.join(dataDir, 'uploads', 'bot-audio');
const deadline = Date.now() + 3000;
while (Date.now() < deadline && (await fs.promises.readdir(audioDir)).length === 0) {
await new Promise(resolve => setTimeout(resolve, 10));
}
assert.ok((await fs.promises.readdir(audioDir)).length > 0, 'Aborted upload did not start');
request.destroy();
await waitForEmptyDirectory(audioDir);
}
async function uploadWhileRevoking(baseUrl, token, audio, dataDir) {
const boundary = `haven-revoke-${Date.now()}`;
const prefix = Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="audio"; filename="speech.wav"\r\n` +
'Content-Type: audio/wav\r\n\r\n'
);
const suffix = Buffer.from(`\r\n--${boundary}--\r\n`);
return new Promise((resolve, reject) => {
const request = http.request(`${baseUrl}/api/webhooks/${token}/audio`, {
method: 'POST',
headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}` }
}, response => {
const chunks = [];
response.on('data', chunk => chunks.push(chunk));
response.on('end', () => resolve({
status: response.statusCode,
body: Buffer.concat(chunks).toString()
}));
});
request.on('error', reject);
request.write(prefix);
request.write(audio.subarray(0, 44));
(async () => {
const audioDir = path.join(dataDir, 'uploads', 'bot-audio');
const deadline = Date.now() + 3000;
while (Date.now() < deadline && (await fs.promises.readdir(audioDir)).length === 0) {
await new Promise(resolve => setTimeout(resolve, 10));
}
assert.ok((await fs.promises.readdir(audioDir)).length > 0, 'Revoked upload did not start');
const db = new Database(path.join(dataDir, 'haven.db'));
db.prepare('UPDATE webhooks SET can_use_voice = 0 WHERE token = ?').run(token);
db.close();
request.end(Buffer.concat([audio.subarray(44), suffix]));
})().catch(error => {
request.destroy(error);
reject(error);
});
});
}
test('dynamic bot audio requires live voice and cleans capabilities, aborts, and revokes end to end', async t => {
const root = path.resolve(__dirname, '..');
const dataDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'haven-bot-audio-api-'));
const botToken = 'a'.repeat(64);
const port = await availablePort();
const env = {
...process.env,
HAVEN_DATA_DIR: dataDir,
FORCE_HTTP: 'true',
HOST: '127.0.0.1',
JWT_SECRET: 'b'.repeat(64),
PORT: String(port)
};
const seed = spawnSync(process.execPath, ['-e', `
const fs = require('fs');
const path = require('path');
const jwt = require('jsonwebtoken');
const { initDatabase } = require('./src/database');
const db = initDatabase();
const user = db.prepare("INSERT INTO users (username, password_hash, display_name, is_admin) VALUES ('owner', 'x', 'Owner', 1)").run();
const channel = db.prepare("INSERT INTO channels (name, code, created_by, is_dm, voice_enabled) VALUES ('Bots', 'abcd1234', ?, 0, 1)").run(user.lastInsertRowid);
db.prepare('INSERT INTO channel_members (channel_id, user_id) VALUES (?, ?)').run(channel.lastInsertRowid, user.lastInsertRowid);
db.prepare('INSERT INTO webhooks (channel_id, name, token, created_by, can_use_voice) VALUES (?, ?, ?, ?, 1)').run(channel.lastInsertRowid, 'Test Bot', '${botToken}', user.lastInsertRowid);
const humanToken = jwt.sign({ id: Number(user.lastInsertRowid), username: 'owner', pwv: 1 }, process.env.JWT_SECRET);
fs.writeFileSync(path.join(process.env.HAVEN_DATA_DIR, 'human-token.txt'), humanToken);
db.close();
`], { cwd: root, env, encoding: 'utf8' });
assert.equal(seed.status, 0, seed.stderr || seed.stdout);
let logs = '';
const child = spawn(process.execPath, ['server.js'], { cwd: root, env, stdio: ['ignore', 'pipe', 'pipe'] });
child.stdout.on('data', chunk => { logs += chunk; });
child.stderr.on('data', chunk => { logs += chunk; });
const sockets = [];
t.after(async () => {
for (const socket of sockets) socket.terminate();
if (child.exitCode === null) child.kill('SIGTERM');
await Promise.race([
child.exitCode === null ? new Promise(resolve => child.once('exit', resolve)) : Promise.resolve(),
new Promise(resolve => setTimeout(resolve, 3000))
]);
if (child.exitCode === null) child.kill('SIGKILL');
await fs.promises.rm(dataDir, { recursive: true, force: true });
});
const baseUrl = `http://127.0.0.1:${port}`;
await waitForServer(`${baseUrl}/api/webhooks/${botToken}/voice/channels`, child, () => logs);
const absentForm = new FormData();
absentForm.append('audio', new Blob([createPcmWav()], { type: 'audio/wav' }), 'speech.wav');
const absentResponse = await fetch(`${baseUrl}/api/webhooks/${botToken}/audio`, {
method: 'POST', body: absentForm
});
assert.equal(absentResponse.status, 409);
const bot = connectVoiceSocket(baseUrl, { botToken }, 'abcd1234');
sockets.push(bot.ws);
await bot.ready;
const humanToken = await fs.promises.readFile(path.join(dataDir, 'human-token.txt'), 'utf8');
const human = connectVoiceSocket(baseUrl, { token: humanToken }, 'abcd1234');
sockets.push(human.ws);
await human.ready;
const playEvent = human.waitFor('bot-audio-play');
const form = new FormData();
form.append('audio', new Blob([createPcmWav()], { type: 'audio/wav' }), 'speech.wav');
form.append('channel_code', 'abcd1234');
const uploadResponse = await fetch(`${baseUrl}/api/webhooks/${botToken}/audio`, {
method: 'POST', body: form
});
const uploadText = await uploadResponse.text();
assert.equal(uploadResponse.status, 202, uploadText);
const uploaded = JSON.parse(uploadText);
assert.equal(uploaded.channel_code, 'abcd1234');
assert.equal(uploaded.queued, false);
const playback = await playEvent;
assert.equal(playback.playbackId, uploaded.playback_id);
assert.equal((await fetch(`${baseUrl}${playback.audioUrl}`)).status, 200);
const staticFilename = `${uploaded.playback_id}.wav`;
assert.equal(fs.existsSync(path.join(dataDir, 'uploads', 'bot-audio', staticFilename)), true);
for (const blockedPath of [
`/uploads/bot-audio/${staticFilename}`,
`/uploads/bot%2Daudio/${staticFilename}`,
`/uploads//bot-audio/${staticFilename}`,
`/uploads/bot-audio%2F${staticFilename}`
]) {
assert.equal((await fetch(`${baseUrl}${blockedPath}`)).status, 404, blockedPath);
}
const skipResponse = await fetch(`${baseUrl}/api/webhooks/${botToken}/audio/skip`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ channel_code: 'abcd1234' })
});
assert.equal(skipResponse.status, 200);
assert.equal((await skipResponse.json()).skipped, true);
assert.equal((await fetch(`${baseUrl}${playback.audioUrl}`)).status, 404);
const audioDir = path.join(dataDir, 'uploads', 'bot-audio');
await waitForEmptyDirectory(audioDir);
const nextPlayEvent = human.waitFor(
'bot-audio-play',
payload => payload.playbackId !== uploaded.playback_id
);
for (let index = 0; index < 2; index++) {
const queuedForm = new FormData();
queuedForm.append('audio', new Blob([createPcmWav()], { type: 'application/octet-stream' }), `queued-${index}.wav`);
queuedForm.append('channel_code', 'abcd1234');
const queuedResponse = await fetch(`${baseUrl}/api/webhooks/${botToken}/audio`, {
method: 'POST', body: queuedForm
});
assert.equal(queuedResponse.status, 202, await queuedResponse.text());
}
const nextPlayback = await nextPlayEvent;
const wrongCapability = nextPlayback.audioUrl.replace(/[a-f0-9]$/, character => character === '0' ? '1' : '0');
assert.equal((await fetch(`${baseUrl}${wrongCapability}`)).status, 404);
const stopResponse = await fetch(
`${baseUrl}/api/webhooks/${botToken}/audio/current?channel_code=abcd1234`,
{ method: 'DELETE' }
);
assert.equal(stopResponse.status, 200);
const stopped = await stopResponse.json();
assert.equal(stopped.stopped, true);
assert.equal(stopped.removed, 2);
assert.equal((await fetch(`${baseUrl}${nextPlayback.audioUrl}`)).status, 404);
await waitForEmptyDirectory(audioDir);
await startAndAbortUpload(baseUrl, botToken, dataDir);
assert.deepEqual(await fs.promises.readdir(audioDir), []);
const revoked = await uploadWhileRevoking(baseUrl, botToken, createPcmWav(), dataDir);
assert.equal(revoked.status, 403, revoked.body);
assert.match(revoked.body, /voice permission/i);
await waitForEmptyDirectory(audioDir);
const unavailableCapability = `/api/bot-audio/${'0'.repeat(8)}-${'0'.repeat(4)}-${'0'.repeat(4)}-${'0'.repeat(4)}-${'0'.repeat(12)}/${'0'.repeat(48)}`;
let limitedResponse;
for (let requestCount = 0; requestCount < 121; requestCount++) {
limitedResponse = await fetch(`${baseUrl}${unavailableCapability}`);
if (limitedResponse.status === 429) break;
assert.equal(limitedResponse.status, 404);
}
assert.equal(limitedResponse.status, 429, 'Bot audio playback route was not rate limited');
});