'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 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> <:wave:1178833036244652178>' ); // 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); });