mirror of
https://github.com/ancsemi/Haven.git
synced 2026-10-28 01:26:14 +00:00
198 lines
7.9 KiB
JavaScript
198 lines
7.9 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* A stand-in for Discord, for testing Ferry without a real bot or a network.
|
|
*
|
|
* One local port answers both halves Ferry talks to: the REST API (token
|
|
* check, webhooks, webhook execution) and the gateway WebSocket. Point a
|
|
* Haven server at it with
|
|
*
|
|
* FERRY_TEST_DISCORD_API=http://127.0.0.1:<port>
|
|
* FERRY_TEST_DISCORD_GATEWAY=ws://127.0.0.1:<port>
|
|
*
|
|
* and NODE_ENV=test. Ferry only honours those under NODE_ENV=test and for a
|
|
* loopback address. The test then plays Discord's side by calling dispatch()
|
|
* with gateway events, and reads what Haven sent through `executions`.
|
|
*/
|
|
|
|
const http = require('node:http');
|
|
const { WebSocketServer } = require('ws');
|
|
|
|
const GUILD_ID = '1300000000000000001';
|
|
const TEXT_ID = '1300000000000000002';
|
|
const FORUM_ID = '1300000000000000003';
|
|
const BOT_ID = '1300000000000000004';
|
|
const TAG_BUG = '1300000000000000005';
|
|
const TAG_IDEA = '1300000000000000006';
|
|
const TAG_STAFF = '1300000000000000007';
|
|
|
|
function startFakeDiscord(port) {
|
|
let nextId = 1300000000000001000n;
|
|
const newId = () => String(nextId++);
|
|
|
|
const sockets = new Set();
|
|
const webhooks = new Map(); // id -> { id, token, channelId }
|
|
const executions = []; // { webhookId, threadId, body }
|
|
const botMessages = [];
|
|
let seq = 0;
|
|
let identified = 0;
|
|
|
|
// The forum as Discord describes it. Not age-restricted: Discord leaves
|
|
// `nsfw` false on a channel until a moderator turns it on.
|
|
const forumChannel = () => ({
|
|
id: FORUM_ID, name: 'help-forum', type: 15, parent_id: null, flags: 0, nsfw: false,
|
|
available_tags: [
|
|
{ id: TAG_BUG, name: 'Bug', moderated: false },
|
|
{ id: TAG_IDEA, name: 'Idea', moderated: false },
|
|
{ id: TAG_STAFF, name: 'Staff', moderated: true },
|
|
],
|
|
});
|
|
|
|
// @everyone can view and read history, and the bot's own member entry
|
|
// comes along as it does from Discord, so Ferry can tell which channels the
|
|
// bot is kept out of.
|
|
const guild = () => ({
|
|
id: GUILD_ID,
|
|
name: 'Test Guild',
|
|
icon: null,
|
|
owner_id: '1300000000000000099',
|
|
roles: [{ id: GUILD_ID, name: '@everyone', mentionable: false, permissions: String(1024 + 2048 + 65536) }],
|
|
members: [{ user: { id: BOT_ID, username: 'FerryBot' }, roles: [] }],
|
|
emojis: [],
|
|
threads: [],
|
|
channels: [
|
|
{ id: TEXT_ID, name: 'general', type: 0, parent_id: null },
|
|
forumChannel(),
|
|
],
|
|
});
|
|
|
|
function send(ws, packet) {
|
|
try { ws.send(JSON.stringify(packet)); } catch { /* closed */ }
|
|
}
|
|
|
|
function dispatch(t, d) {
|
|
seq++;
|
|
for (const ws of sockets) send(ws, { op: 0, t, s: seq, d });
|
|
}
|
|
|
|
function json(res, status, body) {
|
|
const data = body === undefined ? '' : JSON.stringify(body);
|
|
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
res.end(data);
|
|
}
|
|
|
|
const server = http.createServer((req, res) => {
|
|
const chunks = [];
|
|
req.on('data', (c) => { chunks.push(c); });
|
|
req.on('end', () => {
|
|
const url = new URL(req.url, 'http://x');
|
|
const parts = url.pathname.split('/').filter(Boolean);
|
|
const raw = Buffer.concat(chunks).toString('latin1');
|
|
let body = null;
|
|
const boundary = /multipart\/form-data;\s*boundary=(.+)$/.exec(req.headers['content-type'] || '');
|
|
if (boundary) {
|
|
// An upload: the message is in payload_json, and each file is noted
|
|
// with its name and size.
|
|
const files = [];
|
|
for (const part of raw.split('--' + boundary[1].replace(/^"|"$/g, ''))) {
|
|
const name = /name="([^"]+)"/.exec(part);
|
|
const sep = part.indexOf('\r\n\r\n');
|
|
if (!name || sep < 0) continue;
|
|
const value = part.slice(sep + 4).replace(/\r\n$/, '');
|
|
if (name[1] === 'payload_json') {
|
|
try { body = JSON.parse(value); } catch { body = null; }
|
|
} else {
|
|
const filename = /filename="([^"]*)"/.exec(part);
|
|
files.push({ field: name[1], filename: filename ? filename[1] : null, size: Buffer.from(value, 'latin1').length });
|
|
}
|
|
}
|
|
if (body) body.files = files;
|
|
} else {
|
|
try { body = raw ? JSON.parse(raw) : null; } catch { body = null; }
|
|
}
|
|
|
|
if (req.method === 'GET' && url.pathname === '/users/@me') {
|
|
return json(res, 200, { id: BOT_ID, username: 'FerryBot', discriminator: '0', avatar: null });
|
|
}
|
|
// /channels/:id/webhooks
|
|
if (parts[0] === 'channels' && parts[2] === 'webhooks') {
|
|
if (req.method === 'GET') {
|
|
return json(res, 200, [...webhooks.values()].filter(w => w.channelId === parts[1])
|
|
.map(w => ({ id: w.id, token: w.token, application_id: BOT_ID })));
|
|
}
|
|
if (req.method === 'POST') {
|
|
const hook = { id: newId(), token: 'hooktoken' + webhooks.size, channelId: parts[1] };
|
|
webhooks.set(hook.id, hook);
|
|
return json(res, 200, { id: hook.id, token: hook.token, application_id: BOT_ID });
|
|
}
|
|
}
|
|
if (req.method === 'POST' && url.pathname === '/users/@me/channels') return json(res, 200, { id: newId(), type: 1, recipient_id: body && body.recipient_id });
|
|
if (req.method === 'GET' && parts[0] === 'guilds' && parts[2] === 'members' && parts.length === 4) return json(res, 200, { user: { id: parts[3], username: 'member', bot: false } });
|
|
if (req.method === 'POST' && parts[0] === 'channels' && parts[2] === 'messages') {
|
|
botMessages.push({ channelId: parts[1], body });
|
|
return json(res, 200, { id: newId(), channel_id: parts[1], content: body && body.content });
|
|
}
|
|
// /webhooks/:id/:token
|
|
if (req.method === 'POST' && parts[0] === 'webhooks' && parts.length === 3) {
|
|
const hook = webhooks.get(parts[1]);
|
|
if (!hook || hook.token !== parts[2]) return json(res, 404, { message: 'Unknown Webhook', code: 10015 });
|
|
const threadId = url.searchParams.get('thread_id');
|
|
const forum = hook.channelId === FORUM_ID;
|
|
if (forum && !threadId && !(body && body.thread_name)) {
|
|
return json(res, 400, { message: 'Webhooks posted to forum channels must have a thread_name or thread_id', code: 220001 });
|
|
}
|
|
executions.push({ webhookId: hook.id, threadId, body });
|
|
const id = newId();
|
|
// A new forum post's first message has the post's own id.
|
|
const channelId = threadId || (body && body.thread_name ? id : hook.channelId);
|
|
return json(res, 200, { id, channel_id: channelId, webhook_id: hook.id, content: body && body.content });
|
|
}
|
|
json(res, 404, { message: 'Not handled by the fake Discord', code: 0 });
|
|
});
|
|
});
|
|
|
|
const wss = new WebSocketServer({ server });
|
|
wss.on('connection', (ws) => {
|
|
sockets.add(ws);
|
|
ws.on('close', () => sockets.delete(ws));
|
|
send(ws, { op: 10, d: { heartbeat_interval: 45000 } });
|
|
ws.on('message', (raw) => {
|
|
let p;
|
|
try { p = JSON.parse(raw.toString()); } catch { return; }
|
|
if (p.op === 1) send(ws, { op: 11 });
|
|
if (p.op === 2 || p.op === 6) {
|
|
identified++;
|
|
seq++;
|
|
send(ws, { op: 0, t: 'READY', s: seq, d: {
|
|
session_id: 'fake-session-' + identified,
|
|
resume_gateway_url: `ws://127.0.0.1:${port}`,
|
|
user: { id: BOT_ID, username: 'FerryBot', discriminator: '0', avatar: null },
|
|
} });
|
|
seq++;
|
|
send(ws, { op: 0, t: 'GUILD_CREATE', s: seq, d: guild() });
|
|
}
|
|
});
|
|
});
|
|
|
|
return new Promise((resolve) => {
|
|
server.listen(port, '127.0.0.1', () => resolve({
|
|
port,
|
|
dispatch,
|
|
forumChannel,
|
|
executions,
|
|
botMessages,
|
|
webhooks,
|
|
newId,
|
|
get identified() { return identified; },
|
|
get connections() { return sockets.size; },
|
|
close: () => new Promise((r) => {
|
|
for (const ws of sockets) { try { ws.terminate(); } catch { /* gone */ } }
|
|
wss.close();
|
|
server.close(() => r());
|
|
}),
|
|
}));
|
|
});
|
|
}
|
|
|
|
module.exports = { startFakeDiscord, GUILD_ID, TEXT_ID, FORUM_ID, BOT_ID, TAG_BUG, TAG_IDEA, TAG_STAFF };
|