mirror of
https://github.com/ancsemi/Haven.git
synced 2026-10-28 01:26:14 +00:00
414 lines
19 KiB
JavaScript
414 lines
19 KiB
JavaScript
'use strict';
|
||
|
||
const assert = require('node:assert/strict');
|
||
const test = require('node:test');
|
||
|
||
const {
|
||
resolveFerryTarget,
|
||
sanitizeWebhookUsername,
|
||
buildHavenContent,
|
||
discordAvatarUrl,
|
||
translateHavenEmotes,
|
||
translateDiscordRefs,
|
||
translateHavenRefs,
|
||
roleMap,
|
||
isForumType,
|
||
matchForumTags,
|
||
discordTagIdsFor,
|
||
forumThreadName,
|
||
} = require('../src/ferry');
|
||
|
||
// Two pairings on one Haven channel, one of them sharing a channel name with
|
||
// the other so the guild-qualified form is the only way to tell them apart.
|
||
const LINKS = [
|
||
{ id: 1, guild_name: 'My Server', discord_channel_name: 'general', out_mode: 'command' },
|
||
{ id: 2, guild_name: 'Other Server', discord_channel_name: 'general', out_mode: 'all' },
|
||
{ id: 3, guild_name: 'My Server', discord_channel_name: 'dev', out_mode: 'command' },
|
||
];
|
||
|
||
const base = { trigger: '=>', links: LINKS, allowDms: false, dmUserId: null };
|
||
|
||
test('a message with no trigger is not addressed', () => {
|
||
assert.equal(resolveFerryTarget({ ...base, content: 'just talking' }), null);
|
||
// A lone ">" is Haven's blockquote marker and must not be mistaken for one.
|
||
assert.equal(resolveFerryTarget({ ...base, content: '> quoted text' }), null);
|
||
});
|
||
|
||
test('guild-qualified target resolves and is stripped from the body', () => {
|
||
const r = resolveFerryTarget({ ...base, content: '=>My Server#general hello there' });
|
||
assert.equal(r.link.id, 1);
|
||
assert.equal(r.body, 'hello there');
|
||
});
|
||
|
||
test('a guild name containing spaces matches in full', () => {
|
||
const r = resolveFerryTarget({ ...base, content: '=>Other Server#general hi' });
|
||
assert.equal(r.link.id, 2);
|
||
assert.equal(r.body, 'hi');
|
||
});
|
||
|
||
test('the bare #channel form resolves when it is unambiguous', () => {
|
||
const r = resolveFerryTarget({ ...base, content: '=>#dev shipping now' });
|
||
assert.equal(r.link.id, 3);
|
||
assert.equal(r.body, 'shipping now');
|
||
});
|
||
|
||
test('longest label wins, so the qualified form is never shadowed', () => {
|
||
// "#general" is also a valid label, but "Other Server#general" is longer and
|
||
// must be preferred or the message would silently go to the wrong server.
|
||
const r = resolveFerryTarget({ ...base, content: '=>Other Server#general x' });
|
||
assert.equal(r.link.id, 2);
|
||
});
|
||
|
||
test('matching is case insensitive but the body keeps its original case', () => {
|
||
const r = resolveFerryTarget({ ...base, content: '=>my server#GENERAL Hello World' });
|
||
assert.equal(r.link.id, 1);
|
||
assert.equal(r.body, 'Hello World');
|
||
});
|
||
|
||
test('an unknown target does not resolve, so the prefix stays visible', () => {
|
||
assert.equal(resolveFerryTarget({ ...base, content: '=>Nope#nowhere hi' }), null);
|
||
});
|
||
|
||
test('a target with no message body resolves to an empty body', () => {
|
||
const r = resolveFerryTarget({ ...base, content: '=>My Server#dev' });
|
||
assert.equal(r.link.id, 3);
|
||
assert.equal(r.body, '');
|
||
});
|
||
|
||
test('a partial target does not resolve', () => {
|
||
// "=>My Serv" is mid-autocomplete, not a destination.
|
||
assert.equal(resolveFerryTarget({ ...base, content: '=>My Serv hello' }), null);
|
||
});
|
||
|
||
test('DMs need both the server setting and a resolved Discord user id', () => {
|
||
const content = '=>@Alice hey';
|
||
assert.equal(resolveFerryTarget({ ...base, content }), null, 'DMs off');
|
||
assert.equal(resolveFerryTarget({ ...base, allowDms: true, content }), null, 'no user id');
|
||
|
||
const r = resolveFerryTarget({ ...base, allowDms: true, dmUserId: '123456789012345678', content });
|
||
assert.equal(r.dm, true);
|
||
assert.equal(r.discordUserId, '123456789012345678');
|
||
assert.equal(r.body, 'hey');
|
||
});
|
||
|
||
test('a malformed Discord user id is refused before it can reach the API', () => {
|
||
// The id comes from the client. Anything that is not a snowflake must not
|
||
// reach a Discord request path or body.
|
||
const dm = { ...base, allowDms: true, content: '=>@Alice hey' };
|
||
for (const bad of ['../../channels/1', '123', 'abc', '1'.repeat(30), '12345678901234567890abc', '']) {
|
||
assert.equal(resolveFerryTarget({ ...dm, dmUserId: bad }), null, `accepted ${bad}`);
|
||
}
|
||
assert.equal(resolveFerryTarget({ ...dm, dmUserId: { toString: () => '123456789012345678' } }), null,
|
||
'a non-string id must not be coerced into an API call');
|
||
assert.equal(resolveFerryTarget({ ...dm, dmUserId: '123456789012345678' }).discordUserId, '123456789012345678');
|
||
});
|
||
|
||
test('Discord mentions become readable names', () => {
|
||
// Discord writes mentions as <@id>. Relayed raw a Haven reader sees a bare
|
||
// number, so the names come from the message's own resolved mentions list.
|
||
const msg = {
|
||
content: 'hey <@111111111111111111> and <@!222222222222222222>',
|
||
mentions: [
|
||
{ id: '111111111111111111', username: 'alice', global_name: 'Alice' },
|
||
{ id: '222222222222222222', username: 'bob' },
|
||
],
|
||
};
|
||
assert.equal(buildHavenContent(msg), 'hey @Alice and @bob');
|
||
|
||
// An id with nobody attached is left alone rather than guessed at.
|
||
assert.equal(
|
||
buildHavenContent({ content: 'ping <@999999999999999999>', mentions: [] }),
|
||
'ping <@999999999999999999>'
|
||
);
|
||
});
|
||
|
||
test('an image bot embed relays the picture instead of a link to unfurl', () => {
|
||
// The SaucyBot shape: no body, the picture in embed.image, the source page in
|
||
// embed.url. Relaying the summary alone left Haven a link to preview, which is
|
||
// the path that dies in bulk. (#5426-adjacent, reported against Ferry)
|
||
const saucy = {
|
||
content: '',
|
||
embeds: [{
|
||
title: 'Sonic and Amy',
|
||
url: 'https://www.pixiv.net/artworks/12345678',
|
||
description: 'by someone',
|
||
image: { url: 'https://i.pximg.net/img-master/img/12345678_p0.jpg' },
|
||
}],
|
||
};
|
||
const out = buildHavenContent(saucy);
|
||
// The image is carried so Haven renders it as a chat image.
|
||
assert.ok(out.includes('https://i.pximg.net/img-master/img/12345678_p0.jpg'));
|
||
// The source link is dropped, because that is the thing that would be unfurled.
|
||
assert.ok(!out.includes('pixiv.net/artworks'));
|
||
// The readable parts survive as context.
|
||
assert.ok(out.includes('Sonic and Amy'));
|
||
|
||
// A thumbnail-only embed is still an image.
|
||
assert.ok(buildHavenContent({
|
||
content: '',
|
||
embeds: [{ thumbnail: { url: 'https://example.test/thumb.png' } }],
|
||
}).includes('https://example.test/thumb.png'));
|
||
|
||
// Multi-image posts carry every picture, not just the first.
|
||
const multi = buildHavenContent({
|
||
content: '',
|
||
embeds: [
|
||
{ image: { url: 'https://example.test/a.png' } },
|
||
{ image: { url: 'https://example.test/b.png' } },
|
||
],
|
||
});
|
||
assert.ok(multi.includes('a.png') && multi.includes('b.png'));
|
||
|
||
// An embed with no image at all keeps the old summary, link included, so a
|
||
// plain shared link still relays as before.
|
||
const linkOnly = buildHavenContent({
|
||
content: '',
|
||
embeds: [{ title: 'A page', url: 'https://example.test/page', description: 'about things' }],
|
||
});
|
||
assert.ok(linkOnly.includes('https://example.test/page'));
|
||
|
||
// A message the author actually typed is untouched by any of this.
|
||
assert.equal(
|
||
buildHavenContent({ content: 'hello', embeds: [{ image: { url: 'https://example.test/x.png' } }] }),
|
||
'hello'
|
||
);
|
||
});
|
||
|
||
test('a stream announcement bot relays its link and thumbnail next to the text', () => {
|
||
// The CouchBot / stream-notifier shape: the bot types the ping line and puts
|
||
// everything worth having (stream link, title, thumbnail) in a rich embed.
|
||
// Reading the embed only for empty bodies relayed the ping line alone.
|
||
const live = {
|
||
content: '@everyone Streamer is now live!',
|
||
// Discord sets this when the ping really went out (the bot may ping everyone).
|
||
mention_everyone: true,
|
||
embeds: [{
|
||
type: 'rich',
|
||
title: 'Streamer is playing Spira Speedruns',
|
||
url: 'https://www.twitch.tv/streamer',
|
||
description: 'Come hang out',
|
||
image: { url: 'https://static-cdn.jtvnw.net/previews-ttv/live_user_streamer-1280x720.jpg' },
|
||
}],
|
||
};
|
||
const out = buildHavenContent(live);
|
||
assert.ok(out.startsWith('@everyone Streamer is now live!'));
|
||
assert.ok(out.includes('https://www.twitch.tv/streamer'));
|
||
assert.ok(out.includes('Streamer is playing Spira Speedruns'));
|
||
assert.ok(out.includes('https://static-cdn.jtvnw.net/previews-ttv/live_user_streamer-1280x720.jpg'));
|
||
|
||
// Thumbnail-shaped previews (YouTube, Kick) count as the picture too.
|
||
const video = buildHavenContent({
|
||
content: 'New upload!',
|
||
embeds: [{
|
||
type: 'rich',
|
||
url: 'https://www.youtube.com/watch?v=abc123',
|
||
thumbnail: { url: 'https://i.ytimg.com/vi/abc123/maxresdefault.jpg' },
|
||
}],
|
||
});
|
||
assert.ok(video.includes('https://www.youtube.com/watch?v=abc123'));
|
||
assert.ok(video.includes('https://i.ytimg.com/vi/abc123/maxresdefault.jpg'));
|
||
|
||
// A bot that already typed the link does not get it twice.
|
||
const typed = buildHavenContent({
|
||
content: 'Live now https://kick.com/streamer',
|
||
embeds: [{ type: 'rich', url: 'https://kick.com/streamer', title: 'Live now' }],
|
||
});
|
||
assert.equal(typed, 'Live now https://kick.com/streamer');
|
||
|
||
// Discord's own unfurl of a typed link is still ignored, or Haven would
|
||
// unfurl the same link a second time.
|
||
assert.equal(
|
||
buildHavenContent({
|
||
content: 'watch https://www.youtube.com/watch?v=abc123',
|
||
embeds: [{ type: 'video', url: 'https://www.youtube.com/watch?v=abc123', thumbnail: { url: 'https://i.ytimg.com/vi/abc123/hq.jpg' } }],
|
||
}),
|
||
'watch https://www.youtube.com/watch?v=abc123'
|
||
);
|
||
});
|
||
|
||
test('Discord custom emotes keep their id so clients can draw them', () => {
|
||
// Clients render <:name:id> as the emote through the server's emote cache.
|
||
// Folding it to :name: left a bare shortcode on any server without a
|
||
// same-named emoji.
|
||
assert.equal(buildHavenContent({ content: 'hi <:wave:1178833036244652178> there' }), 'hi <:wave:1178833036244652178> there');
|
||
assert.equal(buildHavenContent({ content: '<a:spin:1178833036244652178>' }), '<a:spin:1178833036244652178>');
|
||
// A lone angle-bracket expression that is not an emote is left alone.
|
||
assert.equal(buildHavenContent({ content: 'a < b and c > d' }), 'a < b and c > d');
|
||
// No mentions array means nothing to resolve it against, so it stays put.
|
||
assert.equal(buildHavenContent({ content: '<@1178833036244652178>' }), '<@1178833036244652178>');
|
||
});
|
||
|
||
test('a Haven :name: goes to Discord as the guild emote of that name', () => {
|
||
const emojis = new Map([
|
||
['wave', { id: '1178833036244652178', name: 'wave', animated: false }],
|
||
['spin', { id: '1178833036244652179', name: 'spin', animated: true }],
|
||
]);
|
||
assert.equal(
|
||
translateHavenEmotes('hi :wave: :Spin: :wave::spin:', emojis),
|
||
'hi <:wave:1178833036244652178> <a:spin:1178833036244652179> <:wave:1178833036244652178><a:spin:1178833036244652179>'
|
||
);
|
||
// Unknown names, the colons in a time, and tokens already in Discord's
|
||
// form all stay put.
|
||
assert.equal(
|
||
translateHavenEmotes(':nope: at 10:30:45 <:wave:1178833036244652178>', emojis),
|
||
':nope: at 10:30:45 <:wave:1178833036244652178>'
|
||
);
|
||
assert.equal(translateHavenEmotes(':wave:', null), ':wave:');
|
||
});
|
||
|
||
test('a custom trigger is honored and the default is not', () => {
|
||
const custom = { ...base, trigger: '>>' };
|
||
assert.equal(resolveFerryTarget({ ...custom, content: '=>My Server#dev hi' }), null);
|
||
assert.equal(resolveFerryTarget({ ...custom, content: '>>My Server#dev hi' }).link.id, 3);
|
||
});
|
||
|
||
test('webhook usernames are repaired rather than rejected by Discord', () => {
|
||
// Discord 400s on webhook usernames containing "discord" or "clyde", which
|
||
// would fail the whole send rather than just the name.
|
||
assert.doesNotMatch(sanitizeWebhookUsername('discord fan'), /discord/i);
|
||
assert.doesNotMatch(sanitizeWebhookUsername('CLYDE'), /clyde/i);
|
||
// Newlines would break out of the JSON field's intent; length is capped at 80.
|
||
assert.doesNotMatch(sanitizeWebhookUsername('two\nlines'), /\n/);
|
||
assert.equal(sanitizeWebhookUsername('x'.repeat(200)).length, 80);
|
||
assert.equal(sanitizeWebhookUsername(''), 'Haven user');
|
||
assert.equal(sanitizeWebhookUsername(null), 'Haven user');
|
||
});
|
||
|
||
test('inbound Discord messages flatten text, attachments and stickers', () => {
|
||
assert.equal(buildHavenContent({ content: 'hello' }), 'hello');
|
||
|
||
const withFile = buildHavenContent({
|
||
content: 'look',
|
||
attachments: [{ url: 'https://cdn.discordapp.com/a.png' }],
|
||
});
|
||
assert.equal(withFile, 'look\nhttps://cdn.discordapp.com/a.png');
|
||
|
||
// An attachment-only message must still relay something.
|
||
assert.equal(
|
||
buildHavenContent({ content: '', attachments: [{ url: 'https://cdn.discordapp.com/b.png' }] }),
|
||
'https://cdn.discordapp.com/b.png'
|
||
);
|
||
|
||
// A link-only message arrives with an empty body and one embed.
|
||
assert.match(
|
||
buildHavenContent({ content: '', embeds: [{ title: 'A page', url: 'https://example.com' }] }),
|
||
/A page/
|
||
);
|
||
|
||
// Nothing to say means nothing is relayed, rather than an empty row.
|
||
assert.equal(buildHavenContent({ content: ' ' }), '');
|
||
});
|
||
|
||
test('avatar URLs cover custom, animated, and both default schemes', () => {
|
||
assert.match(
|
||
discordAvatarUrl({ id: '80351110224678912', avatar: 'abc123' }),
|
||
/avatars\/80351110224678912\/abc123\.png/
|
||
);
|
||
assert.match(
|
||
discordAvatarUrl({ id: '80351110224678912', avatar: 'a_abc123' }),
|
||
/\.gif/,
|
||
'animated avatars must keep their extension or Discord serves a still'
|
||
);
|
||
// Post-migration accounts (discriminator "0") index by id, legacy ones by
|
||
// discriminator. Getting this wrong yields a 404 image, not an error.
|
||
assert.match(discordAvatarUrl({ id: '80351110224678912', discriminator: '0' }), /embed\/avatars\/[0-5]\.png/);
|
||
assert.match(discordAvatarUrl({ id: '80351110224678912', discriminator: '1234' }), /embed\/avatars\/[0-4]\.png/);
|
||
assert.equal(discordAvatarUrl(null), null);
|
||
});
|
||
|
||
// Roles and channels between the two sides. "hunters" is a role anybody on
|
||
// Discord may mention; "Mods" is not, so it never pings from either side.
|
||
const GUILD = {
|
||
roles: roleMap([
|
||
{ id: '111111111111111111', name: '@everyone', mentionable: true },
|
||
{ id: '222222222222222222', name: 'hunters', mentionable: true },
|
||
{ id: '333333333333333333', name: 'Mods', mentionable: false },
|
||
{ id: '444444444444444444', name: 'Game Night', mentionable: true },
|
||
], '111111111111111111'),
|
||
channelNames: new Map([['555555555555555555', 'general-chat'], ['666666666666666666', 'clips']]),
|
||
channels: new Map(),
|
||
};
|
||
|
||
test('@everyone from Discord pings only when Discord says it did', () => {
|
||
const typed = buildHavenContent({ content: 'hey @everyone and @here', attachments: [], embeds: [] });
|
||
assert.equal(typed, 'hey @\u200Beveryone and @\u200Bhere');
|
||
const real = buildHavenContent({ content: '@everyone meeting now', mention_everyone: true, attachments: [], embeds: [] });
|
||
assert.equal(real, '@everyone meeting now');
|
||
});
|
||
|
||
test('Discord role and channel mentions arrive as names', () => {
|
||
const out = translateDiscordRefs('<@&222222222222222222> <@&333333333333333333> <#555555555555555555> <#666666666666666666> <#999999999999999999>', GUILD, {
|
||
pingRoles: true,
|
||
havenChannelFor: (id) => (id === '666666666666666666' ? 'video clips' : null),
|
||
});
|
||
// The mentionable role pings its Haven namesake; the other only shows its name.
|
||
// A paired channel reads as its Haven channel, an unknown one is left alone.
|
||
assert.equal(out, '@hunters @Mods #general-chat #video_clips <#999999999999999999>');
|
||
// With pings off nothing pings, but the names still read.
|
||
assert.equal(translateDiscordRefs('<@&222222222222222222>', GUILD, { pingRoles: false }), '@hunters');
|
||
assert.equal(roleMap([{ id: '111111111111111111', name: '@everyone' }], '111111111111111111').size, 0);
|
||
});
|
||
|
||
test('a Haven @Role and #channel go to Discord as its own tokens', () => {
|
||
const r = translateHavenRefs('@hunters @game night @Mods @hunters a@hunters.com #general_chat #clips #nope https://x.io/#clips', GUILD, { pingRoles: true });
|
||
assert.equal(r.content, '<@&222222222222222222> <@&444444444444444444> @Mods @hunters a@hunters.com <#555555555555555555> <#666666666666666666> #nope https://x.io/#clips');
|
||
assert.deepEqual(r.roleIds, ['222222222222222222', '444444444444444444']);
|
||
// Pings off: roles stay text, channels still link since they ping nobody.
|
||
const off = translateHavenRefs('@hunters #clips', GUILD, { pingRoles: false });
|
||
assert.equal(off.content, '@hunters <#666666666666666666>');
|
||
assert.deepEqual(off.roleIds, []);
|
||
// A paired channel wins over a Discord channel that happens to share the name.
|
||
assert.equal(translateHavenRefs('#clips', GUILD, { discordChannelFor: () => '777777777777777777' }).content, '<#777777777777777777>');
|
||
});
|
||
|
||
test('two Discord roles with one name are ambiguous and neither is pinged', () => {
|
||
const g = { roles: roleMap([
|
||
{ id: '222222222222222222', name: 'hunters', mentionable: true },
|
||
{ id: '888888888888888888', name: 'Hunters', mentionable: true },
|
||
], '1'), channelNames: new Map() };
|
||
const r = translateHavenRefs('@hunters go', g, { pingRoles: true });
|
||
assert.equal(r.content, '@hunters go');
|
||
assert.deepEqual(r.roleIds, []);
|
||
});
|
||
|
||
// ── Forums ──────────────────────────────────────────────────
|
||
// The full Discord <-> Haven forum flow runs against a stand-in Discord in
|
||
// test/ferryForums.test.js. These are the pure pieces it relies on.
|
||
|
||
test('only forum and media channels count as forums', () => {
|
||
assert.equal(isForumType(15), true);
|
||
assert.equal(isForumType(16), true);
|
||
for (const t of [0, 5, 11, 12, null, undefined, '0']) assert.equal(isForumType(t), false, `type ${t}`);
|
||
});
|
||
|
||
test('Discord tags map onto the Haven forum tags by name, ignoring case', () => {
|
||
const haven = JSON.stringify([{ name: 'Bug' }, { name: 'idea', emoji: '💡' }]);
|
||
assert.deepEqual(matchForumTags(['bug', 'IDEA', 'Unknown'], haven), ['Bug', 'idea']);
|
||
// No Haven tags, bad JSON, or no Discord tags all give nothing.
|
||
assert.deepEqual(matchForumTags(['bug'], null), []);
|
||
assert.deepEqual(matchForumTags(['bug'], 'not json'), []);
|
||
assert.deepEqual(matchForumTags([], haven), []);
|
||
// Discord allows five on a post and so does Haven.
|
||
const many = JSON.stringify('abcdefg'.split('').map(name => ({ name })));
|
||
assert.equal(matchForumTags('abcdefg'.split(''), many).length, 5);
|
||
});
|
||
|
||
test('Haven tags become Discord tag ids, never a moderated one', () => {
|
||
const discord = [
|
||
{ id: '111111111111111111', name: 'Bug' },
|
||
{ id: '222222222222222222', name: 'Staff pick', moderated: true },
|
||
];
|
||
assert.deepEqual(discordTagIdsFor(['bug', 'staff pick', 'nope'], discord), ['111111111111111111']);
|
||
assert.deepEqual(discordTagIdsFor(['bug'], undefined), []);
|
||
assert.deepEqual(discordTagIdsFor(null, discord), []);
|
||
});
|
||
|
||
test('a Discord post name is 1 to 100 characters and never empty', () => {
|
||
assert.equal(forumThreadName(' Hello world ', 'body'), 'Hello world');
|
||
// An untitled topic is named after its first line.
|
||
assert.equal(forumThreadName('', 'first line\nsecond line'), 'first line');
|
||
assert.equal(forumThreadName(null, ' '), 'Haven topic');
|
||
const long = forumThreadName('x'.repeat(300), '');
|
||
assert.equal(long.length, 100);
|
||
});
|