Files
nanoclaw/setup/migrate-v2/discord-resolver.test.ts
Gavriel Cohen 8181054bdb 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.
2026-05-02 16:09:06 +00:00

196 lines
7.6 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
import { buildDiscordResolver } from './discord-resolver.js';
function mockFetch(handlers: Record<string, unknown>): typeof fetch {
return vi.fn(async (input: string | URL | Request) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
const match = Object.keys(handlers).find((k) => url.startsWith(k));
if (!match) throw new Error(`unexpected fetch: ${url}`);
const body = handlers[match];
if (body instanceof Error) throw body;
if (typeof body === 'object' && body !== null && 'status' in body && (body as { status?: number }).status) {
const r = body as { status: number; statusText?: string; body?: string };
return new Response(r.body ?? '', { status: r.status, statusText: r.statusText ?? '' });
}
return new Response(JSON.stringify(body), { status: 200 });
}) as unknown as 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, dms: 0 });
expect(r.stats().reason).toMatch(/no DISCORD_BOT_TOKEN/);
expect(r.resolve('any')).toBeNull();
});
it('resolves channels to guild-prefixed platform ids', async () => {
const fetchImpl = mockFetch({
'https://discord.com/api/v10/users/@me/guilds': [
{ id: 'g1', name: 'Guild 1' },
{ id: 'g2', name: 'Guild 2' },
],
'https://discord.com/api/v10/guilds/g1/channels': [
{ id: 'c1' },
{ id: 'c2' },
],
'https://discord.com/api/v10/guilds/g2/channels': [
{ id: 'c3' },
],
});
const r = await buildDiscordResolver('valid-token', [], fetchImpl);
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');
expect(r.resolve('cX')).toBeNull();
});
it('returns disabled resolver on 401', async () => {
const fetchImpl = mockFetch({
'https://discord.com/api/v10/users/@me/guilds': {
status: 401,
statusText: 'Unauthorized',
body: '{"message":"401: Unauthorized","code":0}',
},
});
const r = await buildDiscordResolver('bad-token', [], fetchImpl);
expect(r.stats().guilds).toBe(0);
expect(r.stats().reason).toMatch(/401/);
expect(r.resolve('c1')).toBeNull();
});
it('keeps partial results when one guild lookup fails', async () => {
const fetchImpl = mockFetch({
'https://discord.com/api/v10/users/@me/guilds': [
{ id: 'g1', name: 'Good Guild' },
{ id: 'g2', name: 'Bad Guild' },
],
'https://discord.com/api/v10/guilds/g1/channels': [{ id: 'c1' }],
'https://discord.com/api/v10/guilds/g2/channels': {
status: 403,
statusText: 'Forbidden',
body: '{}',
},
});
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const r = await buildDiscordResolver('valid-token', [], fetchImpl);
errSpy.mockRestore();
expect(r.resolve('c1')).toBe('discord:g1:c1');
expect(r.stats().guilds).toBe(2);
expect(r.stats().channels).toBe(1);
});
it('paginates the guild list', async () => {
// First page: 200 guilds (g0..g199); second page: 1 guild (g200); third call would not happen.
const page1 = Array.from({ length: 200 }, (_, i) => ({ id: `g${i}`, name: `G${i}` }));
const page2 = [{ id: 'g200', name: 'G200' }];
let call = 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')) {
call++;
const body = call === 1 ? page1 : page2;
return new Response(JSON.stringify(body), { status: 200 });
}
// Every guild has one channel named after itself
const m = /\/guilds\/([^/]+)\/channels/.exec(url);
const gid = m ? m[1] : '';
return new Response(JSON.stringify([{ id: `c-${gid}` }]), { status: 200 });
}) as unknown as typeof fetch;
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');
});
});