diff --git a/migrate-v2.sh b/migrate-v2.sh index d790c32..38a0f0d 100644 --- a/migrate-v2.sh +++ b/migrate-v2.sh @@ -259,6 +259,18 @@ run_step() { step_ok "$label $(dim "$result")" log "$name: $result" STEP_RESULTS[$name]="success" + # Surface partial errors (rows skipped due to parse/lookup failures) + # even when the step exited successfully — they're easy to miss in the + # raw log and have caused silent migrations before. + if grep -q '^ERROR:' "$raw" 2>/dev/null; then + local err_count + err_count=$(grep -c '^ERROR:' "$raw") + echo " $(dim "${err_count} error(s) reported — see $raw")" + grep '^ERROR:' "$raw" | head -3 | while IFS= read -r line; do + echo " $(dim "$line")" + done + log "$name: ${err_count} non-fatal errors" + fi elif grep -q '^SKIPPED:' "$raw" 2>/dev/null; then local reason reason=$(grep '^SKIPPED:' "$raw" | head -1 | sed 's/^SKIPPED://') @@ -340,14 +352,17 @@ else # 2c. Install channel code for ch in "${SELECTED_CHANNELS[@]}"; do INSTALL_SCRIPT="setup/install-${ch}.sh" + STEP_NAME="2c-install-${ch}" if [ -f "$INSTALL_SCRIPT" ]; then - STEP_LOG="$STEPS_DIR/2c-install-${ch}.log" + STEP_LOG="$STEPS_DIR/${STEP_NAME}.log" if bash "$INSTALL_SCRIPT" > "$STEP_LOG" 2>&1; then STATUS_LINE=$(grep '^STATUS:' "$STEP_LOG" | head -1 | sed 's/^STATUS: *//') if [ "$STATUS_LINE" = "already-installed" ]; then step_skip "Install $ch $(dim "(already installed)")" + STEP_RESULTS[$STEP_NAME]="skipped" else step_ok "Install $ch" + STEP_RESULTS[$STEP_NAME]="success" fi log "install-$ch: $STATUS_LINE" else @@ -356,9 +371,12 @@ else echo " $(dim "$line")" done log "install-$ch: FAILED (see $STEP_LOG)" + STEP_RESULTS[$STEP_NAME]="failed" fi else step_skip "Install $ch $(dim "(no install script)")" + log "install-$ch: no install script" + STEP_RESULTS[$STEP_NAME]="failed" fi done fi diff --git a/setup/migrate-v2/db.ts b/setup/migrate-v2/db.ts index f33ec2b..fb15ab0 100644 --- a/setup/migrate-v2/db.ts +++ b/setup/migrate-v2/db.ts @@ -25,10 +25,13 @@ import { getMessagingGroupByPlatform, } from '../../src/db/messaging-groups.js'; import { runMigrations } from '../../src/db/migrations/index.js'; +import { readEnvFile } from '../../src/env.js'; +import { buildDiscordResolver, type DiscordResolver } from './discord-resolver.js'; import { generateId, parseJid, triggerToEngage, + v2PlatformId, } from './shared.js'; interface V1Group { @@ -40,7 +43,7 @@ interface V1Group { is_main: number | null; } -function main(): void { +async function main(): Promise { const v1Path = process.argv[2]; if (!v1Path) { console.error('Usage: tsx setup/migrate-v2/db.ts '); @@ -78,6 +81,24 @@ function main(): void { 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. + let discordResolver: DiscordResolver | null = null; + const hasDiscord = v1Groups.some((g) => parseJid(g.jid)?.channel_type === 'discord'); + if (hasDiscord) { + const env = readEnvFile(['DISCORD_BOT_TOKEN']); + discordResolver = await buildDiscordResolver(env.DISCORD_BOT_TOKEN ?? ''); + 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)`); + } + } + for (const g of v1Groups) { const parsed = parseJid(g.jid); if (!parsed) { @@ -87,9 +108,22 @@ function main(): void { } const channelType = parsed.channel_type; - const platformId = parsed.raw.startsWith(`${channelType}:`) - ? parsed.raw - : `${channelType}:${parsed.id}`; + let platformId: string; + if (channelType === 'discord') { + const resolved = discordResolver?.resolve(parsed.id) ?? null; + if (!resolved) { + const stats = discordResolver?.stats(); + const why = stats?.reason + ? `discord resolver unavailable (${stats.reason})` + : 'not found in any guild the bot can see — re-add the bot to that server and re-run, or rewire after migration'; + skipped++; + errors.push(`Discord channel ${parsed.id} (${g.folder}): ${why}`); + continue; + } + platformId = resolved; + } else { + platformId = v2PlatformId(channelType, parsed.raw); + } const createdAt = new Date().toISOString(); try { @@ -152,10 +186,23 @@ function main(): void { v2Db.close(); + // If every group was skipped, the migration didn't actually do anything. + // Treat that as failure so the wrapper script surfaces it instead of + // hiding it under an `OK:` line. + const totalDone = created + reused; + if (v1Groups.length > 0 && totalDone === 0) { + console.error(`FAIL:groups=${v1Groups.length},created=0,reused=0,skipped=${skipped}`); + for (const e of errors) console.error(`ERROR:${e}`); + process.exit(1); + } + console.log(`OK:groups=${v1Groups.length},created=${created},reused=${reused},skipped=${skipped}`); if (errors.length > 0) { for (const e of errors) console.log(`ERROR:${e}`); } } -main(); +main().catch((err) => { + console.error(`FAIL:${err instanceof Error ? err.message : String(err)}`); + process.exit(1); +}); diff --git a/setup/migrate-v2/discord-resolver.test.ts b/setup/migrate-v2/discord-resolver.test.ts new file mode 100644 index 0000000..31e63f7 --- /dev/null +++ b/setup/migrate-v2/discord-resolver.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { buildDiscordResolver } from './discord-resolver.js'; + +function mockFetch(handlers: Record): 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 }); + 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 }); + 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'); + }); +}); diff --git a/setup/migrate-v2/discord-resolver.ts b/setup/migrate-v2/discord-resolver.ts new file mode 100644 index 0000000..17b9a9f --- /dev/null +++ b/setup/migrate-v2/discord-resolver.ts @@ -0,0 +1,120 @@ +/** + * Discord channel → guild 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. + * + * 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::` 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(url: string, token: string, fetchImpl: FetchFn): Promise { + 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 { + 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(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(); + for (const guild of guilds) { + try { + const channels = await getJson( + `${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 }), + }; +} diff --git a/setup/migrate-v2/sessions.ts b/setup/migrate-v2/sessions.ts index 0299dec..7ca8516 100644 --- a/setup/migrate-v2/sessions.ts +++ b/setup/migrate-v2/sessions.ts @@ -50,6 +50,8 @@ function copyTree(src: string, dst: string): number { written += copyTree(s, d); continue; } + // Skip dangling symlinks (e.g. v1's .claude/debug/latest pointer). + if (entry.isSymbolicLink() && !fs.existsSync(s)) continue; if (fs.existsSync(d)) continue; fs.copyFileSync(s, d); written += 1; diff --git a/setup/migrate-v2/shared.ts b/setup/migrate-v2/shared.ts index 62f2236..ff219df 100644 --- a/setup/migrate-v2/shared.ts +++ b/setup/migrate-v2/shared.ts @@ -32,7 +32,19 @@ export interface ParsedJid { channel_type: string; } +/** WhatsApp (Baileys) JID hosts. v1 stored these raw, with no `wa:` prefix. */ +const WA_JID_HOSTS = new Set(['s.whatsapp.net', 'g.us', 'lid', 'broadcast', 'newsletter']); + +function isWhatsappJid(raw: string): boolean { + const at = raw.lastIndexOf('@'); + if (at === -1) return false; + return WA_JID_HOSTS.has(raw.slice(at + 1).toLowerCase()); +} + export function parseJid(raw: string): ParsedJid | null { + if (isWhatsappJid(raw)) { + return { raw, prefix: 'whatsapp', id: raw, channel_type: 'whatsapp' }; + } const colon = raw.indexOf(':'); if (colon === -1) return null; const prefix = raw.slice(0, colon).toLowerCase(); @@ -47,10 +59,16 @@ export function parseJid(raw: string): ParsedJid | null { } /** - * Build a v2 platform_id from a v1 JID. v2's messaging_groups.platform_id - * is always `:`. + * Build a v2 platform_id from a v1 JID, in the format the runtime adapter + * for that channel emits. WhatsApp uses the raw Baileys JID (`@`, + * no prefix). Other channels use `:`. */ export function v2PlatformId(channelType: string, jid: string): string { + if (channelType === 'whatsapp') { + // Strip any v1 `wa:`/`whatsapp:` prefix; otherwise pass through raw. + const parsed = parseJid(jid); + return parsed?.channel_type === 'whatsapp' ? parsed.id : jid; + } const parsed = parseJid(jid); const id = parsed?.id ?? jid; return id.startsWith(`${channelType}:`) ? id : `${channelType}:${id}`; diff --git a/setup/migrate-v2/tasks.ts b/setup/migrate-v2/tasks.ts index 6a7efbe..4d3b3b5 100644 --- a/setup/migrate-v2/tasks.ts +++ b/setup/migrate-v2/tasks.ts @@ -22,6 +22,8 @@ import { getMessagingGroupByPlatform } from '../../src/db/messaging-groups.js'; import { runMigrations } from '../../src/db/migrations/index.js'; import { insertTask } from '../../src/modules/scheduling/db.js'; import { openInboundDb, resolveSession } from '../../src/session-manager.js'; +import { readEnvFile } from '../../src/env.js'; +import { buildDiscordResolver, type DiscordResolver } from './discord-resolver.js'; import { parseJid, v2PlatformId } from './shared.js'; interface V1Task { @@ -67,7 +69,7 @@ function toCron(t: V1Task): { processAfter: string; recurrence: string | null } return null; } -function main(): void { +async function main(): Promise { const v1Path = process.argv[2]; if (!v1Path) { console.error('Usage: tsx setup/migrate-v2/tasks.ts '); @@ -104,6 +106,14 @@ function main(): void { let skipped = 0; let failed = 0; + // Mirrors db.ts: Discord platform_id needs API lookup to recover guildId. + let discordResolver: DiscordResolver | null = null; + const hasDiscord = activeTasks.some((t) => parseJid(t.chat_jid)?.channel_type === 'discord'); + if (hasDiscord) { + const env = readEnvFile(['DISCORD_BOT_TOKEN']); + discordResolver = await buildDiscordResolver(env.DISCORD_BOT_TOKEN ?? ''); + } + for (const t of activeTasks) { try { const ag = getAgentGroupByFolder(t.group_folder); @@ -112,7 +122,14 @@ function main(): void { const parsed = parseJid(t.chat_jid); if (!parsed) { skipped++; continue; } - const platformId = v2PlatformId(parsed.channel_type, t.chat_jid); + let platformId: string; + if (parsed.channel_type === 'discord') { + const resolved = discordResolver?.resolve(parsed.id) ?? null; + if (!resolved) { skipped++; continue; } + platformId = resolved; + } else { + platformId = v2PlatformId(parsed.channel_type, t.chat_jid); + } const mg = getMessagingGroupByPlatform(parsed.channel_type, platformId); if (!mg) { skipped++; continue; } @@ -155,4 +172,7 @@ function main(): void { console.log(`OK:active=${activeTasks.length},migrated=${migrated},skipped=${skipped},failed=${failed}`); } -main(); +main().catch((err) => { + console.error(`FAIL:${err instanceof Error ? err.message : String(err)}`); + process.exit(1); +});