diff --git a/setup/migrate-v2/db.ts b/setup/migrate-v2/db.ts index da2bab755..c2b5b4864 100644 --- a/setup/migrate-v2/db.ts +++ b/setup/migrate-v2/db.ts @@ -82,21 +82,27 @@ async function main(): Promise { let skipped = 0; const errors: string[] = []; - // v1 stored Discord groups as `dc:` (no guildId). v2 needs - // `discord::`. If there are any Discord groups, use - // the bot token (carried forward by 1a-env) to look up each channel's - // guild via the Discord API. On any failure the resolver returns null - // for every channel and the affected groups skip with a clear warning. + // v1 stored Discord groups as `dc:` with no guild/DM signal. + // v2 needs either `discord::` (guild) or + // `discord:@me:` (DM / group DM). Use the v1 bot token to + // enumerate guilds + channels and to classify any leftover ids as DMs. + // On any failure the resolver returns null for every channel and the + // affected groups skip with a clear warning. let discordResolver: DiscordResolver | null = null; - const hasDiscord = v1Groups.some((g) => parseJid(g.jid)?.channel_type === 'discord'); - if (hasDiscord) { + const discordChannelIds = v1Groups + .map((g) => parseJid(g.jid)) + .filter((p): p is NonNullable => p?.channel_type === 'discord') + .map((p) => p.id); + if (discordChannelIds.length > 0) { const env = readEnvFile(['DISCORD_BOT_TOKEN']); - discordResolver = await buildDiscordResolver(env.DISCORD_BOT_TOKEN ?? ''); + discordResolver = await buildDiscordResolver(env.DISCORD_BOT_TOKEN ?? '', discordChannelIds); const stats = discordResolver.stats(); if (stats.reason) { console.log(`WARN:discord resolver disabled: ${stats.reason}`); } else { - console.log(`INFO:discord resolver: ${stats.guilds} guild(s), ${stats.channels} channel(s)`); + console.log( + `INFO:discord resolver: ${stats.guilds} guild(s), ${stats.channels} guild channel(s), ${stats.dms} DM(s)`, + ); } } diff --git a/setup/migrate-v2/discord-resolver.test.ts b/setup/migrate-v2/discord-resolver.test.ts index 31e63f752..e74fc4001 100644 --- a/setup/migrate-v2/discord-resolver.test.ts +++ b/setup/migrate-v2/discord-resolver.test.ts @@ -20,7 +20,7 @@ function mockFetch(handlers: Record): typeof fetch { describe('buildDiscordResolver', () => { it('returns empty resolver when token is missing', async () => { const r = await buildDiscordResolver(''); - expect(r.stats()).toMatchObject({ guilds: 0, channels: 0 }); + expect(r.stats()).toMatchObject({ guilds: 0, channels: 0, dms: 0 }); expect(r.stats().reason).toMatch(/no DISCORD_BOT_TOKEN/); expect(r.resolve('any')).toBeNull(); }); @@ -40,9 +40,9 @@ describe('buildDiscordResolver', () => { ], }); - const r = await buildDiscordResolver('valid-token', fetchImpl); + const r = await buildDiscordResolver('valid-token', [], fetchImpl); - expect(r.stats()).toEqual({ guilds: 2, channels: 3 }); + expect(r.stats()).toEqual({ guilds: 2, channels: 3, dms: 0 }); expect(r.resolve('c1')).toBe('discord:g1:c1'); expect(r.resolve('c2')).toBe('discord:g1:c2'); expect(r.resolve('c3')).toBe('discord:g2:c3'); @@ -58,7 +58,7 @@ describe('buildDiscordResolver', () => { }, }); - const r = await buildDiscordResolver('bad-token', fetchImpl); + const r = await buildDiscordResolver('bad-token', [], fetchImpl); expect(r.stats().guilds).toBe(0); expect(r.stats().reason).toMatch(/401/); expect(r.resolve('c1')).toBeNull(); @@ -79,7 +79,7 @@ describe('buildDiscordResolver', () => { }); const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const r = await buildDiscordResolver('valid-token', fetchImpl); + const r = await buildDiscordResolver('valid-token', [], fetchImpl); errSpy.mockRestore(); expect(r.resolve('c1')).toBe('discord:g1:c1'); @@ -105,11 +105,91 @@ describe('buildDiscordResolver', () => { return new Response(JSON.stringify([{ id: `c-${gid}` }]), { status: 200 }); }) as unknown as typeof fetch; - const r = await buildDiscordResolver('valid-token', fetchImpl); + const r = await buildDiscordResolver('valid-token', [], fetchImpl); expect(r.stats().guilds).toBe(201); expect(r.stats().channels).toBe(201); expect(r.resolve('c-g0')).toBe('discord:g0:c-g0'); expect(r.resolve('c-g200')).toBe('discord:g200:c-g200'); }); + + it('classifies unresolved ids as DMs and emits discord:@me:', async () => { + const fetchImpl = mockFetch({ + 'https://discord.com/api/v10/users/@me/guilds': [{ id: 'g1', name: 'G1' }], + 'https://discord.com/api/v10/guilds/g1/channels': [{ id: 'guild-chan' }], + // dmId is a 1:1 DM (type=1) + 'https://discord.com/api/v10/channels/dmId': { id: 'dmId', type: 1 }, + // groupDmId is a multi-recipient DM (type=3) + 'https://discord.com/api/v10/channels/groupDmId': { id: 'groupDmId', type: 3 }, + }); + + const r = await buildDiscordResolver( + 'valid-token', + ['guild-chan', 'dmId', 'groupDmId'], + fetchImpl, + ); + + expect(r.stats()).toEqual({ guilds: 1, channels: 1, dms: 2 }); + expect(r.resolve('guild-chan')).toBe('discord:g1:guild-chan'); + expect(r.resolve('dmId')).toBe('discord:@me:dmId'); + expect(r.resolve('groupDmId')).toBe('discord:@me:groupDmId'); + }); + + it('leaves ids unresolved when classify returns 404 or non-DM type', async () => { + const fetchImpl = mockFetch({ + 'https://discord.com/api/v10/users/@me/guilds': [], + // 404 — bot has no access (typical when bot was kicked from the guild) + 'https://discord.com/api/v10/channels/orphanId': { + status: 404, + statusText: 'Not Found', + body: '{"message":"Unknown Channel","code":10003}', + }, + // type=0 — guild text channel in a guild we no longer enumerate (shouldn't happen, + // but the fallback is conservative: only emit @me for type 1/3) + 'https://discord.com/api/v10/channels/leftoverGuildChan': { + id: 'leftoverGuildChan', + type: 0, + }, + }); + + const r = await buildDiscordResolver( + 'valid-token', + ['orphanId', 'leftoverGuildChan'], + fetchImpl, + ); + + expect(r.stats()).toEqual({ guilds: 0, channels: 0, dms: 0 }); + expect(r.resolve('orphanId')).toBeNull(); + expect(r.resolve('leftoverGuildChan')).toBeNull(); + }); + + it('skips classify for ids already found in a guild and dedupes input', async () => { + let dmCallCount = 0; + const fetchImpl = vi.fn(async (input: string | URL | Request) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes('/users/@me/guilds')) { + return new Response(JSON.stringify([{ id: 'g1', name: 'G1' }]), { status: 200 }); + } + if (url.includes('/guilds/g1/channels')) { + return new Response(JSON.stringify([{ id: 'guild-chan' }]), { status: 200 }); + } + if (url.includes('/channels/dmId')) { + dmCallCount++; + return new Response(JSON.stringify({ id: 'dmId', type: 1 }), { status: 200 }); + } + throw new Error(`unexpected fetch: ${url}`); + }) as unknown as typeof fetch; + + // 'guild-chan' is in the guild map (skip classify); 'dmId' appears twice + // in the input (classify exactly once). + const r = await buildDiscordResolver( + 'valid-token', + ['guild-chan', 'dmId', 'dmId'], + fetchImpl, + ); + + expect(dmCallCount).toBe(1); + expect(r.resolve('guild-chan')).toBe('discord:g1:guild-chan'); + expect(r.resolve('dmId')).toBe('discord:@me:dmId'); + }); }); diff --git a/setup/migrate-v2/discord-resolver.ts b/setup/migrate-v2/discord-resolver.ts index 17b9a9f99..ecc1d5e9c 100644 --- a/setup/migrate-v2/discord-resolver.ts +++ b/setup/migrate-v2/discord-resolver.ts @@ -1,11 +1,18 @@ /** - * Discord channel → guild resolver for the v1 → v2 migration. + * Discord channel → platform_id resolver for the v1 → v2 migration. * - * v1 stored Discord groups as `dc:` — only the channel id, not - * the guild id. v2's `@chat-adapter/discord` encodes `platform_id` as - * `discord::`, so we can't reconstruct it from v1 data - * alone. Instead, we use the v1 bot token (carried forward by 1a-env) to - * query the Discord API and build a channelId → guildId map. + * v1 stored Discord groups as `dc:` — only the channel id, with + * no signal for guild vs. DM. v2's `@chat-adapter/discord` encodes + * `platform_id` as either `discord::` (guild channel) + * or `discord:@me:` (DM / group DM) — see `guild_id || "@me"` + * in the runtime adapter. We can't reconstruct that from v1 data alone, so + * we use the v1 bot token (carried forward by 1a-env) to query Discord: + * 1. Enumerate every guild the bot is in and every channel in those + * guilds → channelId → guildId map. + * 2. For any v1 channel id NOT in that map, classify via `GET + * /channels/` — DM (type=1) and GROUP_DM (type=3) get + * `discord:@me:`. Anything else returns null and the caller + * skips with a warning. * * Network calls are best-effort: on auth failure or network error, the * resolver returns null for every channel and the caller falls back to @@ -14,6 +21,11 @@ const DISCORD_API = 'https://discord.com/api/v10'; +// Discord channel types we care about. See: +// https://discord.com/developers/docs/resources/channel#channel-object-channel-types +const CHANNEL_TYPE_DM = 1; +const CHANNEL_TYPE_GROUP_DM = 3; + interface Guild { id: string; name: string; @@ -24,18 +36,27 @@ interface Channel { name?: string; } +interface ChannelInfo { + id: string; + type: number; +} + export interface DiscordResolver { - /** Returns `discord::` or null if the channel isn't visible to the bot. */ + /** + * Returns the v2 `platform_id` for a v1 channel id, or null if the bot + * can't see it. Format is `discord::` for guild + * channels and `discord:@me:` for DMs / group DMs. + */ resolve(channelId: string): string | null; - /** Diagnostic info — guild count and total channel count discovered. */ - stats(): { guilds: number; channels: number; reason?: string }; + /** Diagnostic info — guild count, channel count, DM count, optional disable reason. */ + stats(): { guilds: number; channels: number; dms: number; reason?: string }; } /** A no-op resolver that returns null for every lookup with a stored reason. */ function emptyResolver(reason: string): DiscordResolver { return { resolve: () => null, - stats: () => ({ guilds: 0, channels: 0, reason }), + stats: () => ({ guilds: 0, channels: 0, dms: 0, reason }), }; } @@ -57,14 +78,20 @@ async function getJson(url: string, token: string, fetchImpl: FetchFn): Promi /** * Build a Discord resolver by enumerating every guild the bot is in and - * every channel in those guilds. Returns an empty resolver on any error. + * every channel in those guilds, then classifying any `unresolvedChannelIds` + * that didn't show up in a guild via `GET /channels/` (so DMs and + * group DMs can be encoded as `discord:@me:`). * - * Costs: 1 + N HTTP calls (N = guild count). Discord's global rate limit - * is 50 req/s; even installs with hundreds of guilds finish in under a - * second of network time. + * Returns an empty resolver on any error during guild enumeration. + * + * Costs: 1 + N + K HTTP calls — N = guild count (enumerated channels per + * guild), K = unresolved-channel classification calls. Discord's global + * rate limit is 50 req/s; even installs with hundreds of guilds finish in + * under a second of network time. */ export async function buildDiscordResolver( token: string, + unresolvedChannelIds: string[] = [], fetchImpl: FetchFn = fetch, ): Promise { if (!token) return emptyResolver('no DISCORD_BOT_TOKEN in .env'); @@ -109,12 +136,41 @@ export async function buildDiscordResolver( } } + // Classify any v1 channel ids that didn't surface in a guild — they're + // most likely DMs (type=1) or group DMs (type=3). Anything else (404, + // 403, type=0 in a guild the bot left) stays unresolved so the caller's + // existing skip-with-warning path fires. + const dmChannels = new Set(); + const seen = new Set(); + for (const channelId of unresolvedChannelIds) { + if (channelToGuild.has(channelId)) continue; + if (seen.has(channelId)) continue; + seen.add(channelId); + try { + const ch = await getJson( + `${DISCORD_API}/channels/${channelId}`, + token, + fetchImpl, + ); + if (ch.type === CHANNEL_TYPE_DM || ch.type === CHANNEL_TYPE_GROUP_DM) { + dmChannels.add(channelId); + } + } catch { + // Channel not visible to the bot — leave it unresolved. + } + } + return { resolve(channelId: string): string | null { const guildId = channelToGuild.get(channelId); - if (!guildId) return null; - return `discord:${guildId}:${channelId}`; + if (guildId) return `discord:${guildId}:${channelId}`; + if (dmChannels.has(channelId)) return `discord:@me:${channelId}`; + return null; }, - stats: () => ({ guilds: guilds.length, channels: channelToGuild.size }), + stats: () => ({ + guilds: guilds.length, + channels: channelToGuild.size, + dms: dmChannels.size, + }), }; }