mercury-agent 0.7.0-beta.1 → 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.
- package/docs/extensions.md +5 -0
- package/docs/setup-whatsapp.md +32 -0
- package/examples/extensions/voice-transcribe/index.ts +6 -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/handler.ts +9 -0
- package/src/core/profiles.ts +25 -0
- package/src/main.ts +18 -1
- package/src/storage/db.ts +107 -0
package/docs/extensions.md
CHANGED
|
@@ -85,6 +85,11 @@ mercury.permission({ defaultRoles: ["admin", "member"] });
|
|
|
85
85
|
- `defaultRoles` — roles that get this permission by default
|
|
86
86
|
- `admin` always gets all permissions automatically
|
|
87
87
|
- Per-space overrides in `space_config` take precedence
|
|
88
|
+
- **Applicative profiles override `defaultRoles`**: when the active profile
|
|
89
|
+
defines `member_permissions`, that list is exhaustive for the `member` role —
|
|
90
|
+
an extension is only usable by members if its name appears there. Mercury logs
|
|
91
|
+
a startup warning for each loaded extension whose `defaultRoles` include
|
|
92
|
+
`member` but which the profile excludes.
|
|
88
93
|
|
|
89
94
|
Can only be called once per extension.
|
|
90
95
|
|
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
|
|
@@ -309,6 +309,7 @@ function localTimeoutMs(): number {
|
|
|
309
309
|
type HookCtx = {
|
|
310
310
|
db: { getSpaceConfig: (spaceId: string, key: string) => string | null };
|
|
311
311
|
log: {
|
|
312
|
+
debug: (msg: string, extra?: unknown) => void;
|
|
312
313
|
info: (msg: string, extra?: unknown) => void;
|
|
313
314
|
warn: (msg: string, extra?: unknown) => void;
|
|
314
315
|
error: (msg: string, extra?: unknown) => void;
|
|
@@ -399,6 +400,11 @@ export default function (mercury: {
|
|
|
399
400
|
|
|
400
401
|
mercury.on("before_container", async (event, ctx) => {
|
|
401
402
|
if (!ctx.hasCallerPermission(event.spaceId, event.callerId, EXT)) {
|
|
403
|
+
ctx.log.debug("Skipping voice transcription: caller lacks permission", {
|
|
404
|
+
extension: EXT,
|
|
405
|
+
spaceId: event.spaceId,
|
|
406
|
+
callerId: event.callerId,
|
|
407
|
+
});
|
|
402
408
|
return undefined;
|
|
403
409
|
}
|
|
404
410
|
|
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/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) {
|
package/src/core/profiles.ts
CHANGED
|
@@ -233,6 +233,31 @@ export function getActiveProfilePrompt(): string | null {
|
|
|
233
233
|
return activeProfilePromptValue;
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
+
/**
|
|
237
|
+
* Warn about loaded extensions whose permission grants `member` by default but
|
|
238
|
+
* whose permission name is missing from the active profile's exhaustive
|
|
239
|
+
* `member_permissions` list. The override itself is by design (profiles scope
|
|
240
|
+
* customer-facing members to least privilege); the warning exists because the
|
|
241
|
+
* resulting per-message hook skips are otherwise invisible in the logs.
|
|
242
|
+
*/
|
|
243
|
+
export function logProfileMemberPermissionExclusions(
|
|
244
|
+
extensions: Array<{ name: string; permission?: { defaultRoles: string[] } }>,
|
|
245
|
+
memberPermissions: string[] | null,
|
|
246
|
+
log: { warn: (msg: string, obj?: Record<string, unknown>) => void },
|
|
247
|
+
): void {
|
|
248
|
+
if (!Array.isArray(memberPermissions)) return;
|
|
249
|
+
// Match toPermissionSet's trimming so a padded entry doesn't false-positive.
|
|
250
|
+
const granted = new Set(memberPermissions.map((s) => s.trim()));
|
|
251
|
+
for (const ext of extensions) {
|
|
252
|
+
if (!ext.permission?.defaultRoles.includes("member")) continue;
|
|
253
|
+
if (granted.has(ext.name)) continue;
|
|
254
|
+
log.warn(
|
|
255
|
+
`Extension "${ext.name}" grants role "member" by default, but the active profile's member_permissions excludes it — member callers cannot use it unless a space-level permission override grants it. Add "${ext.name}" to the profile's member_permissions (or the deployed active-profile.json) to enable it.`,
|
|
256
|
+
{ extension: ext.name },
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
236
261
|
/**
|
|
237
262
|
* Throw if any of the profile's declared `capabilities` is not present as an
|
|
238
263
|
* installed extension directory under `<dataDir>/extensions`. Capabilities may
|
package/src/main.ts
CHANGED
|
@@ -35,7 +35,11 @@ import { createChatShim } from "./chat-shim.js";
|
|
|
35
35
|
import { loadConfig, resolveProjectPath } from "./config.js";
|
|
36
36
|
import { createMessageHandler } from "./core/handler.js";
|
|
37
37
|
import { setActiveProfileMemberPermissions } from "./core/permissions.js";
|
|
38
|
-
import {
|
|
38
|
+
import {
|
|
39
|
+
loadActiveProfile,
|
|
40
|
+
logProfileMemberPermissionExclusions,
|
|
41
|
+
setActiveProfilePrompt,
|
|
42
|
+
} from "./core/profiles.js";
|
|
39
43
|
import { MercuryCoreRuntime } from "./core/runtime.js";
|
|
40
44
|
import { runStorageCleanup } from "./core/storage-cleanup.js";
|
|
41
45
|
import { isOverQuota } from "./core/storage-guard.js";
|
|
@@ -228,6 +232,11 @@ async function main() {
|
|
|
228
232
|
memberPermissions:
|
|
229
233
|
activeProfile.memberPermissions?.join(",") ?? "(unscoped)",
|
|
230
234
|
});
|
|
235
|
+
logProfileMemberPermissionExclusions(
|
|
236
|
+
registry.list(),
|
|
237
|
+
activeProfile.memberPermissions ?? null,
|
|
238
|
+
logger,
|
|
239
|
+
);
|
|
231
240
|
}
|
|
232
241
|
|
|
233
242
|
logExtensionCapabilityMismatches(
|
|
@@ -278,6 +287,14 @@ async function main() {
|
|
|
278
287
|
bridges.whatsapp = new WhatsAppBridge(
|
|
279
288
|
adapters.whatsapp as WhatsAppBaileysAdapter,
|
|
280
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
|
+
};
|
|
281
298
|
(adapters.whatsapp as WhatsAppBaileysAdapter).onGroupRemoval = (
|
|
282
299
|
chatJid,
|
|
283
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(
|