mercury-agent 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -86,6 +86,38 @@ mercury service install
86
86
  | Messages not arriving | Check `MERCURY_ENABLE_WHATSAPP=true` and re-auth |
87
87
  | Old messages appear on startup | Normal — Mercury ignores pre-connection messages |
88
88
 
89
+ ## Identity: LID vs Phone Number
90
+
91
+ WhatsApp non-deterministically identifies the same person by either their
92
+ phone-number JID (`972501234567@s.whatsapp.net`) or an anonymized LID
93
+ (`24417056866472@lid`). Mercury canonicalizes every inbound identity to the
94
+ phone form whenever the mapping is known, so a person always resolves to one
95
+ caller id, one conversation, and one DM auto-space.
96
+
97
+ How it works:
98
+
99
+ 1. Baileys attaches the alternate form on many message keys; when it does,
100
+ Mercury uses the phone form and records the LID↔phone pair in the
101
+ `wa_identity_aliases` table (log line: `WhatsApp identity alias learned`).
102
+ 2. When a message arrives LID-tagged without the alternate field, Mercury
103
+ consults Baileys' internal mapping store, then its own alias table.
104
+ 3. If no mapping is known anywhere, the LID is kept as-is. This self-heals:
105
+ the moment a later message reveals the pair, the person's phone-keyed
106
+ conversation **adopts** the space that was created under the LID — history,
107
+ config, and settings are preserved, and per-user roles/mutes are rewritten
108
+ to the canonical caller id (log line: `dm-auto-space: adopted existing
109
+ space for canonical identity`). Existing `dm-<lid-digits>` space ids keep
110
+ their name; only the identity resolution changes.
111
+
112
+ If a person somehow ended up with **two** spaces (one LID-keyed, one
113
+ phone-keyed) before canonicalization existed, the phone-keyed space wins and
114
+ a warning names both spaces (`same person has two spaces`) so you can merge
115
+ or delete the stale one manually.
116
+
117
+ A LID's digits are unrelated to the phone number — Mercury never guesses a
118
+ mapping; pairs only come from WhatsApp itself. For support cases you can
119
+ insert a pair manually into `wa_identity_aliases` with `source = 'manual'`.
120
+
89
121
  ## Security
90
122
 
91
123
  - Credentials in `.mercury/whatsapp-auth/` are sensitive — treat like passwords
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mercury-agent",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Personal AI assistant for chat platforms (WhatsApp, Slack, Discord, Telegram)",
5
5
  "license": "MIT",
6
6
  "author": "Avishai Tsabari",
@@ -0,0 +1,183 @@
1
+ import { jidNormalizedUser } from "@whiskeysockets/baileys";
2
+
3
+ /**
4
+ * WhatsApp canonical caller identity.
5
+ *
6
+ * WhatsApp non-deterministically identifies the same person by either their
7
+ * phone-number JID (`<digits>@s.whatsapp.net`) or an anonymized LID
8
+ * (`<digits>@lid`). Everything here resolves an inbound identity to its
9
+ * phone form whenever a mapping is known, so downstream consumers
10
+ * (callerId, thread ids, spaces, roles, extensions) see one stable identity.
11
+ *
12
+ * Resolution priority:
13
+ * 1. Message-key alt field (`remoteJidAlt` / `participantAlt`)
14
+ * 2. Baileys' internal LID store (`signalRepository.lidMapping`) — async only
15
+ * 3. Mercury's persisted alias table (via WaAliasStore)
16
+ * 4. No mapping → keep the LID (consistent, self-heals once a pair is learned)
17
+ *
18
+ * A LID's digits are NOT a phone number — a pair may only come from Baileys.
19
+ */
20
+
21
+ /** Narrow persistence interface so the adapter never imports Db directly. */
22
+ export interface WaAliasStore {
23
+ getPnForLid(lid: string): string | null;
24
+ learn(lid: string, pn: string, source: string): void;
25
+ }
26
+
27
+ export function isLidJid(jid: string): boolean {
28
+ return jid.endsWith("@lid");
29
+ }
30
+
31
+ export function isPnJid(jid: string): boolean {
32
+ return jid.endsWith("@s.whatsapp.net");
33
+ }
34
+
35
+ /** Strip device suffixes (`123:45@s.whatsapp.net`) so variants compare equal. */
36
+ export function normalizeJid(jid: string): string {
37
+ try {
38
+ return jidNormalizedUser(jid) || jid;
39
+ } catch {
40
+ return jid;
41
+ }
42
+ }
43
+
44
+ export interface CanonicalJid {
45
+ /** The identity to use everywhere downstream. */
46
+ canonical: string;
47
+ /** The normalized input jid (differs from canonical when a mapping applied). */
48
+ original: string;
49
+ changed: boolean;
50
+ }
51
+
52
+ function unchanged(jid: string): CanonicalJid {
53
+ return { canonical: jid, original: jid, changed: false };
54
+ }
55
+
56
+ /**
57
+ * Synchronous canonicalization: key alt field + alias store only.
58
+ * Learns the pair whenever the key carries both forms. Never throws —
59
+ * a malformed key must never crash the Baileys message loop.
60
+ */
61
+ export function canonicalizeJidSync(
62
+ rawJid: string | null | undefined,
63
+ altJid: string | null | undefined,
64
+ aliasStore?: WaAliasStore,
65
+ ): CanonicalJid {
66
+ if (!rawJid) return unchanged(rawJid ?? "");
67
+ try {
68
+ const raw = normalizeJid(rawJid);
69
+ const alt = altJid ? normalizeJid(altJid) : undefined;
70
+
71
+ if (!isLidJid(raw)) {
72
+ // Already canonical. If the key also carries the LID form, learn it so
73
+ // future LID-tagged messages resolve even without an alt field.
74
+ if (isPnJid(raw) && alt && isLidJid(alt)) {
75
+ aliasStore?.learn(alt, raw, "key-alt");
76
+ }
77
+ return unchanged(raw);
78
+ }
79
+
80
+ if (alt && isPnJid(alt)) {
81
+ aliasStore?.learn(raw, alt, "key-alt");
82
+ return { canonical: alt, original: raw, changed: true };
83
+ }
84
+
85
+ const mapped = aliasStore?.getPnForLid(raw);
86
+ if (mapped && isPnJid(mapped)) {
87
+ return { canonical: normalizeJid(mapped), original: raw, changed: true };
88
+ }
89
+
90
+ return unchanged(raw);
91
+ } catch {
92
+ return unchanged(rawJid);
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Full canonicalization: sync chain first, then Baileys' async LID store.
98
+ * `lidLookup` wraps `sock.signalRepository.lidMapping.getPNForLID`.
99
+ */
100
+ export async function canonicalizeJid(
101
+ rawJid: string | null | undefined,
102
+ altJid: string | null | undefined,
103
+ aliasStore?: WaAliasStore,
104
+ lidLookup?: (lid: string) => Promise<string | null>,
105
+ ): Promise<CanonicalJid> {
106
+ const sync = canonicalizeJidSync(rawJid, altJid, aliasStore);
107
+ if (sync.changed || !isLidJid(sync.canonical) || !lidLookup) return sync;
108
+
109
+ try {
110
+ const mapped = await lidLookup(sync.canonical);
111
+ if (mapped && isPnJid(normalizeJid(mapped))) {
112
+ const pn = normalizeJid(mapped);
113
+ aliasStore?.learn(sync.canonical, pn, "lid-mapping");
114
+ return { canonical: pn, original: sync.canonical, changed: true };
115
+ }
116
+ } catch {
117
+ // Lookup failure degrades to the LID — never crash the message loop.
118
+ }
119
+ return sync;
120
+ }
121
+
122
+ /** The subset of a Baileys message key this module reads. */
123
+ export interface WaKeyLike {
124
+ remoteJid?: string | null;
125
+ remoteJidAlt?: string | null;
126
+ participant?: string | null;
127
+ participantAlt?: string | null;
128
+ }
129
+
130
+ export interface ResolvedIdentities {
131
+ /** Canonical sender jid (participant in groups, chat peer in DMs). */
132
+ sender: CanonicalJid;
133
+ /** Canonical chat jid. Group jids (`@g.us`) are never rewritten. */
134
+ chat: CanonicalJid;
135
+ }
136
+
137
+ /**
138
+ * Resolve both identities on an inbound key, async (full chain).
139
+ * Group chat jids pass through untouched; group participants are
140
+ * canonicalized exactly like DM senders.
141
+ */
142
+ export async function resolveKeyIdentities(
143
+ key: WaKeyLike,
144
+ aliasStore?: WaAliasStore,
145
+ lidLookup?: (lid: string) => Promise<string | null>,
146
+ ): Promise<ResolvedIdentities> {
147
+ const remoteJid = key.remoteJid ?? "";
148
+ const isGroup = remoteJid.endsWith("@g.us");
149
+
150
+ const chat = isGroup
151
+ ? unchanged(remoteJid)
152
+ : await canonicalizeJid(remoteJid, key.remoteJidAlt, aliasStore, lidLookup);
153
+
154
+ const sender = key.participant
155
+ ? await canonicalizeJid(
156
+ key.participant,
157
+ key.participantAlt,
158
+ aliasStore,
159
+ lidLookup,
160
+ )
161
+ : chat;
162
+
163
+ return { sender, chat };
164
+ }
165
+
166
+ /** Sync variant of {@link resolveKeyIdentities} (key alts + alias store only). */
167
+ export function resolveKeyIdentitiesSync(
168
+ key: WaKeyLike,
169
+ aliasStore?: WaAliasStore,
170
+ ): ResolvedIdentities {
171
+ const remoteJid = key.remoteJid ?? "";
172
+ const isGroup = remoteJid.endsWith("@g.us");
173
+
174
+ const chat = isGroup
175
+ ? unchanged(remoteJid)
176
+ : canonicalizeJidSync(remoteJid, key.remoteJidAlt, aliasStore);
177
+
178
+ const sender = key.participant
179
+ ? canonicalizeJidSync(key.participant, key.participantAlt, aliasStore)
180
+ : chat;
181
+
182
+ return { sender, chat };
183
+ }
@@ -30,6 +30,13 @@ import {
30
30
  import { logger } from "../logger.js";
31
31
  import { normalizeChatMarkdown } from "../text/markdown.js";
32
32
  import { applyRtlDirection } from "../text/rtl.js";
33
+ import {
34
+ canonicalizeJidSync,
35
+ resolveKeyIdentities,
36
+ resolveKeyIdentitiesSync,
37
+ type WaAliasStore,
38
+ type WaKeyLike,
39
+ } from "./whatsapp-identity.js";
33
40
  import { detectWhatsAppMedia } from "./whatsapp-media.js";
34
41
 
35
42
  type WhatsAppThreadId = {
@@ -68,12 +75,15 @@ function getContextInfo(
68
75
  function buildReplyContext(
69
76
  message?: proto.IMessage | null,
70
77
  pushNames?: Map<string, string>,
78
+ canonicalize?: (jid: string) => string,
71
79
  ): string | undefined {
72
80
  const contextInfo = getContextInfo(message);
73
81
  if (!contextInfo?.quotedMessage) return undefined;
74
82
 
75
83
  const quotedText = extractText(contextInfo.quotedMessage).trim();
76
- const quotedJid = contextInfo.participant || "unknown";
84
+ const rawQuotedJid = contextInfo.participant || "unknown";
85
+ // pushNames is keyed by canonical jid — resolve before lookup.
86
+ const quotedJid = canonicalize ? canonicalize(rawQuotedJid) : rawQuotedJid;
77
87
  const quotedName =
78
88
  pushNames?.get(quotedJid) || quotedJid.split("@")[0] || "unknown";
79
89
  const quotedMessageId = contextInfo.stanzaId || "unknown";
@@ -131,6 +141,7 @@ export type WhatsAppQrStatus =
131
141
  export interface WhatsAppAdapterOptions {
132
142
  userName?: string;
133
143
  authDir?: string;
144
+ aliasStore?: WaAliasStore;
134
145
  }
135
146
 
136
147
  export class WhatsAppBaileysAdapter
@@ -154,12 +165,27 @@ export class WhatsAppBaileysAdapter
154
165
  /** Called when the bot is removed or leaves a WhatsApp group. */
155
166
  onGroupRemoval?: (chatJid: string) => void;
156
167
 
168
+ /** Persistent LID↔phone alias store — assigned by main.ts after core init. */
169
+ aliasStore?: WaAliasStore;
170
+
157
171
  constructor(options?: WhatsAppAdapterOptions) {
158
172
  this.userName = options?.userName ?? "mercury";
159
173
  this.authDir =
160
174
  options?.authDir ?? path.join(process.cwd(), ".mercury", "whatsapp-auth");
175
+ this.aliasStore = options?.aliasStore;
161
176
  }
162
177
 
178
+ /** Async LID→phone lookup backed by Baileys' internal mapping store. */
179
+ private lidLookup = async (lid: string): Promise<string | null> => {
180
+ const store = this.sock?.signalRepository?.lidMapping;
181
+ if (!store) return null;
182
+ return (await store.getPNForLID(lid)) ?? null;
183
+ };
184
+
185
+ /** Sync canonicalization for jids outside the message key (quoted senders). */
186
+ private canonicalizeQuoted = (jid: string): string =>
187
+ canonicalizeJidSync(jid, undefined, this.aliasStore).canonical;
188
+
163
189
  /**
164
190
  * Get current QR status for API endpoint
165
191
  */
@@ -412,15 +438,22 @@ export class WhatsAppBaileysAdapter
412
438
 
413
439
  parseMessage(raw: proto.IWebMessageInfo): Message<proto.IWebMessageInfo> {
414
440
  const key = raw.key;
415
- const remoteJid = key?.remoteJid ?? "unknown@s.whatsapp.net";
416
- const sender = key?.participant || remoteJid;
441
+ // Canonicalize on the raw key the placeholder fallback is applied only
442
+ // after resolution so it can never be learned as a real phone mapping.
443
+ const identities = resolveKeyIdentitiesSync({ ...key }, this.aliasStore);
444
+ const chatJid = identities.chat.canonical || "unknown@s.whatsapp.net";
445
+ const sender = identities.sender.canonical || chatJid;
417
446
  const senderName = raw.pushName || sender.split("@")[0] || "unknown";
418
447
  const baseText = extractText(raw.message).trim();
419
- const replyContext = buildReplyContext(raw.message, this.pushNames);
448
+ const replyContext = buildReplyContext(
449
+ raw.message,
450
+ this.pushNames,
451
+ this.canonicalizeQuoted,
452
+ );
420
453
  const text = [baseText, replyContext].filter(Boolean).join("\n\n").trim();
421
454
  const threadId = this.encodeThreadId({
422
- chatJid: remoteJid,
423
- threadJid: remoteJid,
455
+ chatJid,
456
+ threadJid: chatJid,
424
457
  });
425
458
 
426
459
  return new Message({
@@ -493,7 +526,21 @@ export class WhatsAppBaileysAdapter
493
526
  return;
494
527
  }
495
528
 
496
- const sender = msg.key.participant || remoteJid;
529
+ // Canonicalize identities (LID phone) before anything downstream sees
530
+ // them: sender becomes callerId, DM chat jid becomes the thread/space key.
531
+ const identities = await resolveKeyIdentities(
532
+ msg.key as WaKeyLike,
533
+ this.aliasStore,
534
+ this.lidLookup,
535
+ );
536
+ const chatJid = identities.chat.canonical;
537
+ const sender = identities.sender.canonical;
538
+ if (identities.sender.changed || identities.chat.changed) {
539
+ logger.debug("WhatsApp identity canonicalized", {
540
+ sender: identities.sender,
541
+ chat: identities.chat,
542
+ });
543
+ }
497
544
  const senderName = msg.pushName || sender.split("@")[0] || "unknown";
498
545
 
499
546
  // Track push names for reply context resolution
@@ -502,7 +549,11 @@ export class WhatsAppBaileysAdapter
502
549
  }
503
550
 
504
551
  let baseText = extractText(msg.message).trim();
505
- const replyContext = buildReplyContext(msg.message, this.pushNames);
552
+ const replyContext = buildReplyContext(
553
+ msg.message,
554
+ this.pushNames,
555
+ this.canonicalizeQuoted,
556
+ );
506
557
 
507
558
  // WhatsApp @-mentions embed JIDs in text (e.g. "@52669955764381").
508
559
  // Replace the bot's JID mention with the configured userName so trigger matching works.
@@ -548,12 +599,20 @@ export class WhatsAppBaileysAdapter
548
599
  if (!text && !hasMedia) return;
549
600
 
550
601
  const threadId = this.encodeThreadId({
551
- chatJid: remoteJid,
552
- threadJid: remoteJid,
602
+ chatJid,
603
+ threadJid: chatJid,
553
604
  });
605
+ // When the DM chat jid was canonicalized, expose the original identity's
606
+ // thread id so the core can adopt a space created under the old (LID) key.
607
+ const aliasThreadId = identities.chat.changed
608
+ ? this.encodeThreadId({
609
+ chatJid: identities.chat.original,
610
+ threadJid: identities.chat.original,
611
+ })
612
+ : undefined;
554
613
 
555
614
  logger.info("WhatsApp inbound", {
556
- remoteJid,
615
+ remoteJid: chatJid,
557
616
  sender,
558
617
  isReply: Boolean(replyContext),
559
618
  isReplyToBot,
@@ -589,6 +648,7 @@ export class WhatsAppBaileysAdapter
589
648
  isReplyToBot,
590
649
  replyToMessageId: contextInfo?.stanzaId ?? undefined,
591
650
  platformMessageId: msg.key.id ?? undefined,
651
+ aliasThreadId,
592
652
  } as Record<string, unknown>),
593
653
  },
594
654
  attachments: [],
@@ -38,6 +38,14 @@ function seedSpaceConfigIfAbsent(
38
38
  }
39
39
  }
40
40
 
41
+ /** First jid of a `<chatJid>:<threadJid>` external id, as a callerId. */
42
+ function callerIdFromExternalId(platform: string, externalId: string): string {
43
+ return `${platform}:${externalId.split(":")[0]}`;
44
+ }
45
+
46
+ /** Double-split pairs already warned about — once per process, not per message. */
47
+ const warnedDoubleSplits = new Set<string>();
48
+
41
49
  export function resolveConversation(
42
50
  db: Db,
43
51
  platform: string,
@@ -46,6 +54,7 @@ export function resolveConversation(
46
54
  observedTitle?: string,
47
55
  autoSpace?: AutoSpaceConfig,
48
56
  authorName?: string,
57
+ aliasExternalId?: string,
49
58
  ): ConversationResolution | null {
50
59
  const conversation = db.ensureConversation(
51
60
  platform,
@@ -55,14 +64,76 @@ export function resolveConversation(
55
64
  );
56
65
 
57
66
  if (conversation.spaceId) {
67
+ // Pre-upgrade double split: this person also has a space under their old
68
+ // identity. The canonical space wins; surface both for manual cleanup.
69
+ if (
70
+ kind === "dm" &&
71
+ aliasExternalId &&
72
+ aliasExternalId !== externalId &&
73
+ !warnedDoubleSplits.has(`${platform}:${aliasExternalId}`)
74
+ ) {
75
+ const aliasConversation = db.findConversation(platform, aliasExternalId);
76
+ if (
77
+ aliasConversation?.spaceId &&
78
+ aliasConversation.spaceId !== conversation.spaceId
79
+ ) {
80
+ warnedDoubleSplits.add(`${platform}:${aliasExternalId}`);
81
+ logger.warn(
82
+ "dm-auto-space: same person has two spaces (canonical wins)",
83
+ {
84
+ platform,
85
+ canonicalExternalId: externalId,
86
+ canonicalSpaceId: conversation.spaceId,
87
+ aliasExternalId,
88
+ aliasSpaceId: aliasConversation.spaceId,
89
+ },
90
+ );
91
+ }
92
+ }
58
93
  return { conversation, spaceId: conversation.spaceId };
59
94
  }
60
95
 
96
+ // Sticky-space adoption: the same person previously appeared under another
97
+ // identity (e.g. a WhatsApp LID before its phone mapping was known) and a
98
+ // space already exists there. Link this conversation to that space instead
99
+ // of creating a fresh one, and carry per-user rows (roles, mutes, rate
100
+ // usage) over to the canonical caller id. Runs regardless of auto-space so
101
+ // manually linked conversations keep working too.
102
+ if (kind === "dm" && aliasExternalId && aliasExternalId !== externalId) {
103
+ const aliasConversation = db.findConversation(platform, aliasExternalId);
104
+ if (aliasConversation?.spaceId) {
105
+ const spaceId = aliasConversation.spaceId;
106
+ db.linkConversation(conversation.id, spaceId);
107
+ const migrated = db.migrateCallerId(
108
+ spaceId,
109
+ callerIdFromExternalId(platform, aliasExternalId),
110
+ callerIdFromExternalId(platform, externalId),
111
+ );
112
+ logger.info(
113
+ "dm-auto-space: adopted existing space for canonical identity",
114
+ {
115
+ platform,
116
+ externalId,
117
+ aliasExternalId,
118
+ spaceId,
119
+ migratedRoles: migrated.roles,
120
+ migratedMutes: migrated.mutes,
121
+ },
122
+ );
123
+ return { conversation: { ...conversation, spaceId }, spaceId };
124
+ }
125
+ }
126
+
61
127
  if (!autoSpace?.enabled || kind !== "dm") return null;
62
128
 
63
129
  const senderNormalized = normalizePhone(externalId);
130
+ const aliasNormalized = aliasExternalId
131
+ ? normalizePhone(aliasExternalId)
132
+ : undefined;
64
133
  const isAdmin = autoSpace.adminIds.some(
65
- (n) => normalizePhone(n) === senderNormalized,
134
+ (n) =>
135
+ normalizePhone(n) === senderNormalized ||
136
+ (aliasNormalized !== undefined && normalizePhone(n) === aliasNormalized),
66
137
  );
67
138
 
68
139
  if (isAdmin) {
@@ -168,6 +168,14 @@ export function createMessageHandler(opts: MessageHandlerOptions) {
168
168
 
169
169
  const { externalId, isDM } = bridge.parseThread(threadId);
170
170
  const kind = inferConversationKind(bridge.platform, externalId, isDM);
171
+ // Adapters that canonicalize identities (WhatsApp LID→phone) expose the
172
+ // pre-canonicalization thread id so an existing space can be adopted.
173
+ const aliasThreadId = (
174
+ message.metadata as { aliasThreadId?: string } | undefined
175
+ )?.aliasThreadId;
176
+ const aliasExternalId = aliasThreadId
177
+ ? bridge.parseThread(aliasThreadId).externalId
178
+ : undefined;
171
179
  const resolution = resolveConversation(
172
180
  core.db,
173
181
  bridge.platform,
@@ -176,6 +184,7 @@ export function createMessageHandler(opts: MessageHandlerOptions) {
176
184
  undefined,
177
185
  autoSpaceConfig,
178
186
  message.author.userName ?? message.author.fullName,
187
+ aliasExternalId,
179
188
  );
180
189
 
181
190
  if (!resolution) {
package/src/main.ts CHANGED
@@ -287,6 +287,14 @@ async function main() {
287
287
  bridges.whatsapp = new WhatsAppBridge(
288
288
  adapters.whatsapp as WhatsAppBaileysAdapter,
289
289
  );
290
+ (adapters.whatsapp as WhatsAppBaileysAdapter).aliasStore = {
291
+ getPnForLid: (lid) => core.db.getWaPnForLid(lid),
292
+ learn: (lid, pn, source) => {
293
+ if (core.db.learnWaAlias(lid, pn, source)) {
294
+ logger.info("WhatsApp identity alias learned", { lid, pn, source });
295
+ }
296
+ },
297
+ };
290
298
  (adapters.whatsapp as WhatsAppBaileysAdapter).onGroupRemoval = (
291
299
  chatJid,
292
300
  ) => {
package/src/storage/db.ts CHANGED
@@ -214,6 +214,17 @@ export class Db {
214
214
  created_at INTEGER NOT NULL,
215
215
  updated_at INTEGER NOT NULL
216
216
  );
217
+
218
+ CREATE TABLE IF NOT EXISTS wa_identity_aliases (
219
+ lid TEXT PRIMARY KEY,
220
+ pn TEXT NOT NULL,
221
+ source TEXT NOT NULL,
222
+ created_at INTEGER NOT NULL,
223
+ updated_at INTEGER NOT NULL
224
+ );
225
+
226
+ CREATE INDEX IF NOT EXISTS idx_wa_aliases_pn
227
+ ON wa_identity_aliases(pn);
217
228
  `);
218
229
  this.ensureMessagesRunMetaColumn();
219
230
  this.ensureChatStateClearBoundaryColumn();
@@ -1588,6 +1599,102 @@ export class Db {
1588
1599
  }));
1589
1600
  }
1590
1601
 
1602
+ // ─── WhatsApp Identity Aliases ────────────────────────────────────────
1603
+
1604
+ /**
1605
+ * Upsert a learned LID↔phone pair. Returns true when the pair is new or
1606
+ * changed (callers use this to log the learn exactly once).
1607
+ */
1608
+ learnWaAlias(lid: string, pn: string, source: string): boolean {
1609
+ const now = Date.now();
1610
+ const result = this.db
1611
+ .query(
1612
+ `INSERT INTO wa_identity_aliases(lid, pn, source, created_at, updated_at)
1613
+ VALUES (?, ?, ?, ?, ?)
1614
+ ON CONFLICT(lid) DO UPDATE SET
1615
+ pn = excluded.pn,
1616
+ source = excluded.source,
1617
+ updated_at = excluded.updated_at
1618
+ WHERE wa_identity_aliases.pn != excluded.pn`,
1619
+ )
1620
+ .run(lid, pn, source, now, now);
1621
+ return result.changes > 0;
1622
+ }
1623
+
1624
+ getWaPnForLid(lid: string): string | null {
1625
+ const row = this.db
1626
+ .query("SELECT pn FROM wa_identity_aliases WHERE lid = ?")
1627
+ .get(lid) as { pn: string } | null;
1628
+ return row?.pn ?? null;
1629
+ }
1630
+
1631
+ /** Most recently learned LID for a phone (a phone can map to several LIDs). */
1632
+ getWaLidForPn(pn: string): string | null {
1633
+ const row = this.db
1634
+ .query(
1635
+ `SELECT lid FROM wa_identity_aliases WHERE pn = ?
1636
+ ORDER BY updated_at DESC, lid LIMIT 1`,
1637
+ )
1638
+ .get(pn) as { lid: string } | null;
1639
+ return row?.lid ?? null;
1640
+ }
1641
+
1642
+ /**
1643
+ * Rewrite per-user rows in a space from one caller id to another
1644
+ * (space adoption after identity canonicalization). Rows whose target
1645
+ * already exists are dropped rather than duplicated.
1646
+ */
1647
+ migrateCallerId(
1648
+ spaceId: string,
1649
+ oldCallerId: string,
1650
+ newCallerId: string,
1651
+ ): { roles: number; mutes: number } {
1652
+ if (oldCallerId === newCallerId) return { roles: 0, mutes: 0 };
1653
+ const now = Date.now();
1654
+ let roles = 0;
1655
+ let mutes = 0;
1656
+ const tx = this.db.transaction(() => {
1657
+ roles = this.db
1658
+ .query(
1659
+ `UPDATE OR IGNORE space_roles
1660
+ SET platform_user_id = ?, updated_at = ?
1661
+ WHERE space_id = ? AND platform_user_id = ?`,
1662
+ )
1663
+ .run(newCallerId, now, spaceId, oldCallerId).changes;
1664
+ this.db
1665
+ .query(
1666
+ "DELETE FROM space_roles WHERE space_id = ? AND platform_user_id = ?",
1667
+ )
1668
+ .run(spaceId, oldCallerId);
1669
+
1670
+ mutes = this.db
1671
+ .query(
1672
+ `UPDATE OR IGNORE mutes
1673
+ SET platform_user_id = ?
1674
+ WHERE space_id = ? AND platform_user_id = ?`,
1675
+ )
1676
+ .run(newCallerId, spaceId, oldCallerId).changes;
1677
+ this.db
1678
+ .query("DELETE FROM mutes WHERE space_id = ? AND platform_user_id = ?")
1679
+ .run(spaceId, oldCallerId);
1680
+
1681
+ this.db
1682
+ .query(
1683
+ `UPDATE OR IGNORE daily_rate_usage
1684
+ SET platform_user_id = ?, updated_at = ?
1685
+ WHERE space_id = ? AND platform_user_id = ?`,
1686
+ )
1687
+ .run(newCallerId, now, spaceId, oldCallerId);
1688
+ this.db
1689
+ .query(
1690
+ "DELETE FROM daily_rate_usage WHERE space_id = ? AND platform_user_id = ?",
1691
+ )
1692
+ .run(spaceId, oldCallerId);
1693
+ });
1694
+ tx();
1695
+ return { roles, mutes };
1696
+ }
1697
+
1591
1698
  // ─── Daily Rate Usage ─────────────────────────────────────────────────
1592
1699
 
1593
1700
  checkAndIncrementDailyUsage(