fix(migrate-v2): resolve Discord DMs as discord:@me:<id>

The resolver only enumerated guild channels, so any v1 install whose
registered Discord chat was a DM (a common case for personal-bot
installs) failed 1b-db with "not found in any guild" — leaving the
migration without an agent_group or wiring, and the user with a bot that
received messages but had nowhere to route them.

Add an unresolved-channel classification pass: for any v1 channel id not
found in a guild, GET /channels/<id> and emit discord:@me:<id> when the
type is DM (1) or GROUP_DM (3). Matches the runtime adapter's
guild_id || "@me" encoding. Other types / 404 / 403 keep current
skip-with-warning behavior.

Caller passes the v1 channel id list (already on hand). Test coverage
extends the existing mock-fetch pattern with DM, GROUP_DM, orphan, and
dedupe cases.
This commit is contained in:
Gavriel Cohen
2026-05-02 16:04:39 +00:00
committed by exe.dev user
parent 7922a19af7
commit 8181054bdb
3 changed files with 174 additions and 32 deletions
+15 -9
View File
@@ -82,21 +82,27 @@ async function main(): Promise<void> {
let skipped = 0;
const errors: string[] = [];
// v1 stored Discord groups as `dc:<channelId>` (no guildId). v2 needs
// `discord:<guildId>:<channelId>`. 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:<channelId>` with no guild/DM signal.
// v2 needs either `discord:<guildId>:<channelId>` (guild) or
// `discord:@me:<channelId>` (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<typeof p> => 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)`,
);
}
}
+86 -6
View File
@@ -20,7 +20,7 @@ function mockFetch(handlers: Record<string, unknown>): 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:<id>', 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');
});
});
+73 -17
View File
@@ -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:<channelId>` — only the channel id, not
* the guild id. v2's `@chat-adapter/discord` encodes `platform_id` as
* `discord:<guildId>:<channelId>`, 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:<channelId>` — only the channel id, with
* no signal for guild vs. DM. v2's `@chat-adapter/discord` encodes
* `platform_id` as either `discord:<guildId>:<channelId>` (guild channel)
* or `discord:@me:<channelId>` (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/<id>` — DM (type=1) and GROUP_DM (type=3) get
* `discord:@me:<id>`. 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:<guildId>:<channelId>` 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:<guildId>:<channelId>` for guild
* channels and `discord:@me:<channelId>` 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<T>(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/<id>` (so DMs and
* group DMs can be encoded as `discord:@me:<id>`).
*
* 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<DiscordResolver> {
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<string>();
const seen = new Set<string>();
for (const channelId of unresolvedChannelIds) {
if (channelToGuild.has(channelId)) continue;
if (seen.has(channelId)) continue;
seen.add(channelId);
try {
const ch = await getJson<ChannelInfo>(
`${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,
}),
};
}