Files
nanoclaw/setup/migrate-v2/discord-resolver.ts
Gavriel Cohen aec7ddd099 fix(migrate-v2): correct JID parsing, Discord guildId lookup, silent failures
- shared.ts: parseJid now recognizes raw Baileys WhatsApp JIDs
  (`<id>@s.whatsapp.net`, `@g.us`, etc.); v2PlatformId returns the raw
  JID for whatsapp to match what the runtime adapter emits. Without this,
  every WhatsApp group in a v1 install was silently skipped.

- discord-resolver.ts: new helper that uses DISCORD_BOT_TOKEN to look up
  channelId → guildId via the Discord API, since v1 stored only the
  channel id but v2 needs `discord:<guildId>:<channelId>`. Best-effort:
  on missing/invalid token or network error, returns empty resolver and
  the affected groups are skipped with the reason surfaced per channel.

- db.ts, tasks.ts: route Discord groups through the resolver; other
  channels go through v2PlatformId unchanged. Resolver only built when
  at least one Discord group exists, so non-Discord installs incur no
  network.

- db.ts: when every v1 group is skipped, exit non-zero with a FAIL line
  instead of `OK:groups=N,...,skipped=N`, so the wrapper doesn't hide
  total failure under a successful-looking summary.

- migrate-v2.sh: run_step now surfaces ERROR: lines from successful
  steps (with count + first 3 + raw log path); phase 2c install loop
  populates STEP_RESULTS so install failures show in handoff.json
  instead of silently passing.

- sessions.ts: copyTree skips dangling symlinks (e.g. v1's
  `.claude/debug/latest`) instead of crashing the entire step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 14:32:34 +03:00

121 lines
3.9 KiB
TypeScript

/**
* Discord channel → guild 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.
*
* Network calls are best-effort: on auth failure or network error, the
* resolver returns null for every channel and the caller falls back to
* skipping with a clear warning.
*/
const DISCORD_API = 'https://discord.com/api/v10';
interface Guild {
id: string;
name: string;
}
interface Channel {
id: string;
name?: string;
}
export interface DiscordResolver {
/** Returns `discord:<guildId>:<channelId>` or null if the channel isn't visible to the bot. */
resolve(channelId: string): string | null;
/** Diagnostic info — guild count and total channel count discovered. */
stats(): { guilds: number; channels: 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 }),
};
}
type FetchFn = typeof fetch;
async function getJson<T>(url: string, token: string, fetchImpl: FetchFn): Promise<T> {
const res = await fetchImpl(url, {
headers: {
Authorization: `Bot ${token}`,
'User-Agent': 'NanoClaw-Migration (https://github.com/qwibitai/nanoclaw, 2.x)',
},
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`Discord API ${res.status} ${res.statusText}: ${body.slice(0, 200)}`);
}
return (await res.json()) as T;
}
/**
* 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.
*
* 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.
*/
export async function buildDiscordResolver(
token: string,
fetchImpl: FetchFn = fetch,
): Promise<DiscordResolver> {
if (!token) return emptyResolver('no DISCORD_BOT_TOKEN in .env');
// Page through guilds. Default page size is 200; loop until short page.
const guilds: Guild[] = [];
let after: string | null = null;
try {
while (true) {
const url = new URL(`${DISCORD_API}/users/@me/guilds`);
url.searchParams.set('limit', '200');
if (after) url.searchParams.set('after', after);
const page = await getJson<Guild[]>(url.toString(), token, fetchImpl);
guilds.push(...page);
if (page.length < 200) break;
after = page[page.length - 1].id;
}
} catch (err) {
return emptyResolver(`failed to list guilds: ${err instanceof Error ? err.message : String(err)}`);
}
// Per-guild channel enumeration.
const channelToGuild = new Map<string, string>();
for (const guild of guilds) {
try {
const channels = await getJson<Channel[]>(
`${DISCORD_API}/guilds/${guild.id}/channels`,
token,
fetchImpl,
);
for (const ch of channels) {
channelToGuild.set(ch.id, guild.id);
}
} catch (err) {
// Skip this guild but keep going — partial results are still useful.
// The caller logs which channels couldn't be resolved.
console.error(
`WARN:discord-resolver: failed to enumerate guild ${guild.id} (${guild.name}): ${
err instanceof Error ? err.message : String(err)
}`,
);
}
}
return {
resolve(channelId: string): string | null {
const guildId = channelToGuild.get(channelId);
if (!guildId) return null;
return `discord:${guildId}:${channelId}`;
},
stats: () => ({ guilds: guilds.length, channels: channelToGuild.size }),
};
}