mirror of
https://github.com/ancsemi/Haven.git
synced 2026-10-27 00:54:56 +00:00
343 lines
14 KiB
JavaScript
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');
|
|
});
|