mercury-agent 0.7.0 → 0.8.1
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.
- package/docs/setup-whatsapp.md +32 -0
- package/package.json +1 -1
- package/src/adapters/whatsapp-identity.ts +183 -0
- package/src/adapters/whatsapp.ts +71 -11
- package/src/core/conversation.ts +72 -1
- package/src/core/global-admin.ts +26 -2
- package/src/core/handler.ts +9 -0
- package/src/core/routes/broadcast.ts +2 -2
- package/src/core/routes/character.ts +3 -3
- package/src/core/routes/send.ts +2 -2
- package/src/main.ts +8 -0
- package/src/storage/db.ts +107 -0
package/docs/setup-whatsapp.md
CHANGED
|
@@ -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
|
@@ -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
|
+
}
|
package/src/adapters/whatsapp.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
416
|
-
|
|
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(
|
|
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
|
|
423
|
-
threadJid:
|
|
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
|
-
|
|
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(
|
|
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
|
|
552
|
-
threadJid:
|
|
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: [],
|
package/src/core/conversation.ts
CHANGED
|
@@ -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) =>
|
|
134
|
+
(n) =>
|
|
135
|
+
normalizePhone(n) === senderNormalized ||
|
|
136
|
+
(aliasNormalized !== undefined && normalizePhone(n) === aliasNormalized),
|
|
66
137
|
);
|
|
67
138
|
|
|
68
139
|
if (isAdmin) {
|
package/src/core/global-admin.ts
CHANGED
|
@@ -2,10 +2,23 @@
|
|
|
2
2
|
* Check if a caller is a global admin (configured in mercury.yaml / env).
|
|
3
3
|
* Global admins are identified by `config.admins` and `config.dmAutoSpaceAdminIds`.
|
|
4
4
|
* Platform-specific ID prefixes, + signs, and @domain suffixes are normalized.
|
|
5
|
+
*
|
|
6
|
+
* WhatsApp callers arrive canonicalized to their phone JID, but older configs
|
|
7
|
+
* may still list the LID digits (or vice versa when no mapping was known at
|
|
8
|
+
* config time). When an alias lookup is provided, the caller's learned
|
|
9
|
+
* LID↔phone counterpart is matched against the configured ids too.
|
|
5
10
|
*/
|
|
11
|
+
|
|
12
|
+
/** Subset of Db used to resolve WhatsApp LID↔phone pairs. */
|
|
13
|
+
export interface WaAliasLookup {
|
|
14
|
+
getWaPnForLid(lid: string): string | null;
|
|
15
|
+
getWaLidForPn(pn: string): string | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
6
18
|
export function isGlobalAdmin(
|
|
7
19
|
callerId: string,
|
|
8
20
|
config: { admins?: string; dmAutoSpaceAdminIds?: string },
|
|
21
|
+
aliases?: WaAliasLookup,
|
|
9
22
|
): boolean {
|
|
10
23
|
const globalAdmins = [
|
|
11
24
|
...(config.admins
|
|
@@ -28,9 +41,20 @@ export function isGlobalAdmin(
|
|
|
28
41
|
.replace(/^[+]+/, "")
|
|
29
42
|
.replace(/@.*$/, "");
|
|
30
43
|
|
|
31
|
-
const
|
|
44
|
+
const callerCandidates = new Set([normalize(callerId)]);
|
|
45
|
+
|
|
46
|
+
if (aliases) {
|
|
47
|
+
const jid = callerId.replace(/^[^:]+:/, "");
|
|
48
|
+
if (jid.endsWith("@s.whatsapp.net")) {
|
|
49
|
+
const lid = aliases.getWaLidForPn(jid);
|
|
50
|
+
if (lid) callerCandidates.add(normalize(lid));
|
|
51
|
+
} else if (jid.endsWith("@lid")) {
|
|
52
|
+
const pn = aliases.getWaPnForLid(jid);
|
|
53
|
+
if (pn) callerCandidates.add(normalize(pn));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
32
56
|
|
|
33
57
|
return globalAdmins.some(
|
|
34
|
-
(id) => id === callerId || normalize(id)
|
|
58
|
+
(id) => id === callerId || callerCandidates.has(normalize(id)),
|
|
35
59
|
);
|
|
36
60
|
}
|
package/src/core/handler.ts
CHANGED
|
@@ -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) {
|
|
@@ -7,13 +7,13 @@ export const broadcast = new Hono<Env>();
|
|
|
7
7
|
|
|
8
8
|
broadcast.post("/", async (c) => {
|
|
9
9
|
const { callerId } = getAuth(c);
|
|
10
|
-
const { config, runtime } = getApiCtx(c);
|
|
10
|
+
const { config, db, runtime } = getApiCtx(c);
|
|
11
11
|
|
|
12
12
|
if (!config.dmAutoSpaceEnabled) {
|
|
13
13
|
return c.json({ error: "dm_auto_space is not enabled" }, 503);
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
if (!isGlobalAdmin(callerId, config)) {
|
|
16
|
+
if (!isGlobalAdmin(callerId, config, db)) {
|
|
17
17
|
logger.warn("Broadcast denied — caller is not a global admin", {
|
|
18
18
|
callerId,
|
|
19
19
|
});
|
|
@@ -11,7 +11,7 @@ character.get("/", (c) => {
|
|
|
11
11
|
const { callerId } = getAuth(c);
|
|
12
12
|
const { config, db } = getApiCtx(c);
|
|
13
13
|
|
|
14
|
-
if (!isGlobalAdmin(callerId, config)) {
|
|
14
|
+
if (!isGlobalAdmin(callerId, config, db)) {
|
|
15
15
|
logger.warn("Character get denied — caller is not a global admin", {
|
|
16
16
|
callerId,
|
|
17
17
|
});
|
|
@@ -28,7 +28,7 @@ character.put("/", async (c) => {
|
|
|
28
28
|
const { callerId } = getAuth(c);
|
|
29
29
|
const { config, db } = getApiCtx(c);
|
|
30
30
|
|
|
31
|
-
if (!isGlobalAdmin(callerId, config)) {
|
|
31
|
+
if (!isGlobalAdmin(callerId, config, db)) {
|
|
32
32
|
logger.warn("Character set denied — caller is not a global admin", {
|
|
33
33
|
callerId,
|
|
34
34
|
});
|
|
@@ -57,7 +57,7 @@ character.delete("/", (c) => {
|
|
|
57
57
|
const { callerId } = getAuth(c);
|
|
58
58
|
const { config, db } = getApiCtx(c);
|
|
59
59
|
|
|
60
|
-
if (!isGlobalAdmin(callerId, config)) {
|
|
60
|
+
if (!isGlobalAdmin(callerId, config, db)) {
|
|
61
61
|
logger.warn("Character clear denied — caller is not a global admin", {
|
|
62
62
|
callerId,
|
|
63
63
|
});
|
package/src/core/routes/send.ts
CHANGED
|
@@ -13,9 +13,9 @@ export const send = new Hono<Env>();
|
|
|
13
13
|
|
|
14
14
|
send.post("/", async (c) => {
|
|
15
15
|
const { callerId } = getAuth(c);
|
|
16
|
-
const { config, runtime } = getApiCtx(c);
|
|
16
|
+
const { config, db, runtime } = getApiCtx(c);
|
|
17
17
|
|
|
18
|
-
if (!isGlobalAdmin(callerId, config)) {
|
|
18
|
+
if (!isGlobalAdmin(callerId, config, db)) {
|
|
19
19
|
logger.warn("Direct send denied — caller is not a global admin", {
|
|
20
20
|
callerId,
|
|
21
21
|
});
|
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(
|