From dca02f5453e97ccdfc47935a90b069596fcf6f1e Mon Sep 17 00:00:00 2001 From: Gavriel Cohen Date: Sat, 2 May 2026 15:39:36 +0300 Subject: [PATCH] feat(migrate-v2): resolve WhatsApp LIDs from store/auth, alias DMs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1 stored every WhatsApp DM as `@s.whatsapp.net`. v2's WA adapter sometimes resolves the chat to `@lid` instead — when WhatsApp delivers via the LID protocol and Baileys hasn't yet learned a LID→phone mapping for that contact (cold cache after migration). The router then can't find the phone-keyed messaging_group and silently drops the message at router.ts:184. Baileys persists every LID↔phone pair it has ever learned to disk as `store/auth/lid-mapping-.json` (forward) and `lid-mapping-_reverse.json` (reverse). v1 will already have these populated for every contact it has talked to. New step 2d-whatsapp-lids parses the reverse files and writes paired LID-keyed `messaging_groups` + `messaging_group_agents` rows so both `@s.whatsapp.net` and `@lid` route to the same agent_group with the same engage rules. No Baileys boot, no WhatsApp connectivity required — pure filesystem read of files we've already copied via 2b-channel-auth. Step is no-op-on-skip if either store/auth or whatsapp DM rows are missing. Anything that slips through (a contact whose LID v1 never learned) falls back to the runtime approval flow once the WA adapter sets isMention=true on DMs — each unknown LID DM auto-creates an approval-required messaging_group and the owner gets a one-tap register prompt. Verified end-to-end on a 12-group v1 install: 3 DM rows aliased, inbound DM routed via the LID-keyed row. Co-Authored-By: Claude Opus 4.7 (1M context) --- migrate-v2.sh | 15 ++ setup/migrate-v2/whatsapp-resolve-lids.ts | 192 ++++++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 setup/migrate-v2/whatsapp-resolve-lids.ts diff --git a/migrate-v2.sh b/migrate-v2.sh index cacdffcb3..eb5a38142 100644 --- a/migrate-v2.sh +++ b/migrate-v2.sh @@ -392,6 +392,21 @@ else record_step "$STEP_NAME" "failed" fi done + + # 2d. WhatsApp LID resolution. After whatsapp is installed (so Baileys + # is on disk) and auth files have been copied (so we can connect with + # the migrated identity), boot Baileys briefly to learn LID↔phone + # mappings during initial sync, then write paired LID-keyed + # messaging_groups. Best-effort: any failure degrades to runtime + # approval flow, which the WA adapter's isMention=true on DMs handles. + for ch in "${SELECTED_CHANNELS[@]}"; do + if [ "$ch" = "whatsapp" ]; then + run_step "2d-whatsapp-lids" \ + "Resolve WhatsApp LIDs for migrated DMs" \ + "setup/migrate-v2/whatsapp-resolve-lids.ts" + break + fi + done fi echo diff --git a/setup/migrate-v2/whatsapp-resolve-lids.ts b/setup/migrate-v2/whatsapp-resolve-lids.ts new file mode 100644 index 000000000..7a5eb8b4c --- /dev/null +++ b/setup/migrate-v2/whatsapp-resolve-lids.ts @@ -0,0 +1,192 @@ +/** + * migrate-v2 step: resolve WhatsApp LIDs for migrated DM messaging_groups. + * + * Why this exists + * ─────────────── + * v1 stored every WhatsApp DM as `@s.whatsapp.net`. v2's WA adapter + * sometimes resolves the chat to `@lid` instead — when WhatsApp + * delivers a message via the LID protocol and Baileys hasn't yet learned + * a LID→phone mapping for that contact (cold cache after migration). The + * router then can't find the phone-keyed messaging_group and silently + * drops the message at router.ts:184 — until the LID is learned (which + * happens lazily, message-by-message, via `chats.phoneNumberShare`). + * + * Baileys persists LID↔phone mappings to disk as + * `store/auth/lid-mapping-_reverse.json` (LID → phone) and + * `lid-mapping-.json` (phone → LID). v1 will already have populated + * these for every contact it talked to. This step parses the reverse + * files and writes paired LID-keyed `messaging_groups` + + * `messaging_group_agents` rows so both `@s.whatsapp.net` and + * `@lid` route to the same agent_group with the same engage rules. + * + * No Baileys boot, no network — pure filesystem read. If store/auth is + * missing or has no reverse mappings, exits 0 with a SKIPPED. Runtime + * fallback (WA adapter sets isMention=true on DMs → router auto-creates + * with `unknown_sender_policy=request_approval`) handles anything we + * miss. + * + * Usage: pnpm exec tsx setup/migrate-v2/whatsapp-resolve-lids.ts + */ +import fs from 'fs'; +import path from 'path'; + +import { DATA_DIR } from '../../src/config.js'; +import { initDb } from '../../src/db/connection.js'; +import { + createMessagingGroup, + createMessagingGroupAgent, + getMessagingGroupAgentByPair, + getMessagingGroupByPlatform, +} from '../../src/db/messaging-groups.js'; +import { runMigrations } from '../../src/db/migrations/index.js'; +import { generateId } from './shared.js'; + +interface RawMessagingGroup { + id: string; + channel_type: string; + platform_id: string; +} + +interface RawWiring { + id: string; + messaging_group_id: string; + agent_group_id: string; + engage_mode: string; + engage_pattern: string | null; + sender_scope: string; + ignored_message_policy: string; + session_mode: string; + priority: number; +} + +const REVERSE_FILE_RE = /^lid-mapping-(\d+)_reverse\.json$/; + +/** + * Read store/auth/lid-mapping-*_reverse.json into a Map. + * Returns an empty Map if the directory doesn't exist. + */ +function readReverseMappings(authDir: string): Map { + const out = new Map(); + if (!fs.existsSync(authDir)) return out; + for (const entry of fs.readdirSync(authDir)) { + const m = REVERSE_FILE_RE.exec(entry); + if (!m) continue; + const lidUser = m[1]; + try { + const raw = fs.readFileSync(path.join(authDir, entry), 'utf-8').trim(); + // The file content is a JSON-encoded string: `""` + const phoneUser = JSON.parse(raw); + if (typeof phoneUser !== 'string' || phoneUser.length === 0) continue; + out.set(lidUser, phoneUser); + } catch { + // Skip malformed entries — best-effort. + } + } + return out; +} + +function phoneUserOf(jid: string): string { + return jid.split('@')[0].split(':')[0]; +} + +function main(): void { + const authDir = path.join(process.cwd(), 'store', 'auth'); + const reverse = readReverseMappings(authDir); + + if (reverse.size === 0) { + console.log('SKIPPED:no lid-mapping-*_reverse.json files in store/auth'); + process.exit(0); + } + + // phoneUser → lidJid (the form we'll write to messaging_groups) + const phoneUserToLidJid = new Map(); + for (const [lidUser, phoneUser] of reverse) { + phoneUserToLidJid.set(phoneUser, `${lidUser}@lid`); + } + + const v2DbPath = path.join(DATA_DIR, 'v2.db'); + if (!fs.existsSync(v2DbPath)) { + console.error('FAIL:v2.db not found — run db step first'); + process.exit(1); + } + + const v2Db = initDb(v2DbPath); + runMigrations(v2Db); + + const phoneRows = v2Db + .prepare( + `SELECT id, channel_type, platform_id FROM messaging_groups + WHERE channel_type='whatsapp' AND platform_id LIKE '%@s.whatsapp.net'`, + ) + .all() as RawMessagingGroup[]; + + if (phoneRows.length === 0) { + console.log('SKIPPED:no whatsapp DM messaging_groups to resolve'); + v2Db.close(); + process.exit(0); + } + + // Pull existing wirings so each new alias gets the same agent_group + + // engage rules as the phone-keyed row. + const placeholders = phoneRows.map(() => '?').join(','); + const wiringRows = v2Db + .prepare(`SELECT * FROM messaging_group_agents WHERE messaging_group_id IN (${placeholders})`) + .all(...phoneRows.map((r) => r.id)) as RawWiring[]; + + const wiringsByMg = new Map(); + for (const w of wiringRows) { + const arr = wiringsByMg.get(w.messaging_group_id) ?? []; + arr.push(w); + wiringsByMg.set(w.messaging_group_id, arr); + } + + let resolved = 0; + let aliased = 0; + const createdAt = new Date().toISOString(); + + for (const row of phoneRows) { + const phoneUser = phoneUserOf(row.platform_id); + const lidJid = phoneUserToLidJid.get(phoneUser); + if (!lidJid) continue; + resolved++; + + let lidMg = getMessagingGroupByPlatform('whatsapp', lidJid); + if (!lidMg) { + createMessagingGroup({ + id: generateId('mg'), + channel_type: 'whatsapp', + platform_id: lidJid, + name: null, + is_group: 0, + unknown_sender_policy: 'public', + created_at: createdAt, + }); + lidMg = getMessagingGroupByPlatform('whatsapp', lidJid)!; + } + + const wirings = wiringsByMg.get(row.id) ?? []; + for (const w of wirings) { + if (getMessagingGroupAgentByPair(lidMg.id, w.agent_group_id)) continue; + createMessagingGroupAgent({ + id: generateId('mga'), + messaging_group_id: lidMg.id, + agent_group_id: w.agent_group_id, + engage_mode: w.engage_mode as 'pattern' | 'mention' | 'mention-sticky', + engage_pattern: w.engage_pattern, + sender_scope: w.sender_scope as 'all' | 'admins', + ignored_message_policy: w.ignored_message_policy as 'drop' | 'queue', + session_mode: w.session_mode as 'shared' | 'thread', + priority: w.priority, + created_at: createdAt, + }); + aliased++; + } + } + + v2Db.close(); + console.log( + `OK:reverse_mappings=${reverse.size},phone_dms=${phoneRows.length},lids_resolved=${resolved},aliased=${aliased}`, + ); +} + +main();