refactor: unify whatsapp identity handling

This commit is contained in:
Peter Steinberger
2026-03-25 04:45:40 -07:00
parent cdba1e6771
commit 3b6d980c52
15 changed files with 475 additions and 165 deletions

View File

@@ -8,7 +8,8 @@ import { getChildLogger } from "openclaw/plugin-sdk/runtime-env";
import { defaultRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { resolveOAuthDir } from "openclaw/plugin-sdk/state-paths";
import type { WebChannel } from "openclaw/plugin-sdk/text-runtime";
import { jidToE164, resolveUserPath } from "openclaw/plugin-sdk/text-runtime";
import { resolveUserPath } from "openclaw/plugin-sdk/text-runtime";
import { resolveComparableIdentity, type WhatsAppSelfIdentity } from "./identity.js";
export function resolveDefaultWebAuthDir(): string {
return path.join(resolveOAuthDir(), "whatsapp", DEFAULT_ACCOUNT_ID);
@@ -154,15 +155,51 @@ export function readWebSelfId(authDir: string = resolveDefaultWebAuthDir()) {
try {
const credsPath = resolveWebCredsPath(resolveUserPath(authDir));
if (!fsSync.existsSync(credsPath)) {
return { e164: null, jid: null } as const;
return { e164: null, jid: null, lid: null } as const;
}
const raw = fsSync.readFileSync(credsPath, "utf-8");
const parsed = JSON.parse(raw) as { me?: { id?: string } } | undefined;
const jid = parsed?.me?.id ?? null;
const e164 = jid ? jidToE164(jid, { authDir }) : null;
return { e164, jid } as const;
const parsed = JSON.parse(raw) as { me?: { id?: string; lid?: string } } | undefined;
const identity = resolveComparableIdentity(
{
jid: parsed?.me?.id ?? null,
lid: parsed?.me?.lid ?? null,
},
authDir,
);
return {
e164: identity.e164 ?? null,
jid: identity.jid ?? null,
lid: identity.lid ?? null,
} as const;
} catch {
return { e164: null, jid: null } as const;
return { e164: null, jid: null, lid: null } as const;
}
}
export async function readWebSelfIdentity(
authDir: string = resolveDefaultWebAuthDir(),
fallback?: { id?: string | null; lid?: string | null } | null,
): Promise<WhatsAppSelfIdentity> {
const resolvedAuthDir = resolveUserPath(authDir);
maybeRestoreCredsFromBackup(resolvedAuthDir);
try {
const raw = await fs.readFile(resolveWebCredsPath(resolvedAuthDir), "utf-8");
const parsed = JSON.parse(raw) as { me?: { id?: string; lid?: string } } | undefined;
return resolveComparableIdentity(
{
jid: parsed?.me?.id ?? null,
lid: parsed?.me?.lid ?? null,
},
resolvedAuthDir,
);
} catch {
return resolveComparableIdentity(
{
jid: fallback?.id ?? null,
lid: fallback?.lid ?? null,
},
resolvedAuthDir,
);
}
}
@@ -185,8 +222,14 @@ export function logWebSelfId(
includeChannelPrefix = false,
) {
// Human-friendly log of the currently linked personal web session.
const { e164, jid } = readWebSelfId(authDir);
const details = e164 || jid ? `${e164 ?? "unknown"}${jid ? ` (jid ${jid})` : ""}` : "unknown";
const { e164, jid, lid } = readWebSelfId(authDir);
const parts = [jid ? `jid ${jid}` : null, lid ? `lid ${lid}` : null].filter(
(value): value is string => Boolean(value),
);
const details =
e164 || parts.length > 0
? `${e164 ?? "unknown"}${parts.length > 0 ? ` (${parts.join(", ")})` : ""}`
: "unknown";
const prefix = includeChannelPrefix ? "Web Channel: " : "";
runtime.log(info(`${prefix}${details}`));
}

View File

@@ -1,6 +1,13 @@
import { buildMentionRegexes, normalizeMentionText } from "openclaw/plugin-sdk/channel-inbound";
import type { loadConfig } from "openclaw/plugin-sdk/config-runtime";
import { isSelfChatMode, jidToE164, normalizeE164 } from "openclaw/plugin-sdk/text-runtime";
import { isSelfChatMode, normalizeE164 } from "openclaw/plugin-sdk/text-runtime";
import {
getComparableIdentityValues,
getMentionIdentities,
getSelfIdentity,
identitiesOverlap,
type WhatsAppIdentity,
} from "../identity.js";
import type { WebInboundMsg } from "./types.js";
export type MentionConfig = {
@@ -9,9 +16,8 @@ export type MentionConfig = {
};
export type MentionTargets = {
normalizedMentions: string[];
selfE164: string | null;
selfJid: string | null;
normalizedMentions: WhatsAppIdentity[];
self: WhatsAppIdentity;
};
export function buildMentionConfig(
@@ -23,13 +29,9 @@ export function buildMentionConfig(
}
export function resolveMentionTargets(msg: WebInboundMsg, authDir?: string): MentionTargets {
const jidOptions = authDir ? { authDir } : undefined;
const normalizedMentions = msg.mentionedJids?.length
? msg.mentionedJids.map((jid) => jidToE164(jid, jidOptions) ?? jid).filter(Boolean)
: [];
const selfE164 = msg.selfE164 ?? (msg.selfJid ? jidToE164(msg.selfJid, jidOptions) : null);
const selfJid = msg.selfJid ? msg.selfJid.replace(/:\\d+/, "") : null;
return { normalizedMentions, selfE164, selfJid };
const normalizedMentions = getMentionIdentities(msg, authDir);
const self = getSelfIdentity(msg, authDir);
return { normalizedMentions, self };
}
export function isBotMentionedFromTargets(
@@ -41,16 +43,12 @@ export function isBotMentionedFromTargets(
// Remove zero-width and directionality markers WhatsApp injects around display names
normalizeMentionText(text);
const isSelfChat = isSelfChatMode(targets.selfE164, mentionCfg.allowFrom);
const isSelfChat = isSelfChatMode(targets.self.e164, mentionCfg.allowFrom);
const hasMentions = (msg.mentionedJids?.length ?? 0) > 0;
const hasMentions = targets.normalizedMentions.length > 0;
if (hasMentions && !isSelfChat) {
if (targets.selfE164 && targets.normalizedMentions.includes(targets.selfE164)) {
return true;
}
if (targets.selfJid) {
// Some mentions use the bare JID; match on E.164 to be safe.
if (targets.normalizedMentions.includes(targets.selfJid)) {
for (const mention of targets.normalizedMentions) {
if (identitiesOverlap(targets.self, mention)) {
return true;
}
}
@@ -65,8 +63,8 @@ export function isBotMentionedFromTargets(
}
// Fallback: detect body containing our own number (with or without +, spacing)
if (targets.selfE164) {
const selfDigits = targets.selfE164.replace(/\D/g, "");
if (targets.self.e164) {
const selfDigits = targets.self.e164.replace(/\D/g, "");
if (selfDigits) {
const bodyDigits = bodyClean.replace(/[^\d]/g, "");
if (bodyDigits.includes(selfDigits)) {
@@ -94,14 +92,14 @@ export function debugMention(
from: msg.from,
body: msg.body,
bodyClean: normalizeMentionText(msg.body),
mentionedJids: msg.mentionedJids ?? null,
mentionedJids: msg.mentions ?? msg.mentionedJids ?? null,
normalizedMentionedJids: mentionTargets.normalizedMentions.length
? mentionTargets.normalizedMentions
? mentionTargets.normalizedMentions.map((identity) => getComparableIdentityValues(identity))
: null,
selfJid: msg.selfJid ?? null,
selfJidBare: mentionTargets.selfJid,
selfE164: msg.selfE164 ?? null,
resolvedSelfE164: mentionTargets.selfE164,
selfJid: msg.self?.jid ?? msg.selfJid ?? null,
selfLid: msg.self?.lid ?? msg.selfLid ?? null,
selfE164: msg.self?.e164 ?? msg.selfE164 ?? null,
resolvedSelf: mentionTargets.self,
};
return { wasMentioned: result, details };
}

View File

@@ -1,6 +1,7 @@
import { shouldAckReactionForWhatsApp } from "openclaw/plugin-sdk/channel-feedback";
import type { loadConfig } from "openclaw/plugin-sdk/config-runtime";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { getSenderIdentity } from "../../identity.js";
import { sendReactionWhatsApp } from "../../send.js";
import { formatError } from "../../session.js";
import type { WebInboundMsg } from "../types.js";
@@ -55,10 +56,11 @@ export function maybeSendAckReaction(params: {
{ chatId: params.msg.chatId, messageId: params.msg.id, emoji },
"sending ack reaction",
);
const sender = getSenderIdentity(params.msg);
sendReactionWhatsApp(params.msg.chatId, params.msg.id, emoji, {
verbose: params.verbose,
fromMe: false,
participant: params.msg.senderJid,
participant: sender.jid ?? undefined,
accountId: params.accountId,
}).catch((err) => {
params.warn(

View File

@@ -4,6 +4,13 @@ import type { loadConfig } from "openclaw/plugin-sdk/config-runtime";
import { recordPendingHistoryEntryIfEnabled } from "openclaw/plugin-sdk/reply-history";
import { parseActivationCommand } from "openclaw/plugin-sdk/reply-runtime";
import { normalizeE164 } from "openclaw/plugin-sdk/text-runtime";
import {
getPrimaryIdentityId,
getReplyContext,
getSelfIdentity,
getSenderIdentity,
identitiesOverlap,
} from "../../identity.js";
import type { MentionConfig } from "../mentions.js";
import { buildMentionConfig, debugMention, resolveOwnerList } from "../mentions.js";
import type { WebInboundMsg } from "../types.js";
@@ -36,11 +43,11 @@ type ApplyGroupGatingParams = {
};
function isOwnerSender(baseMentionConfig: MentionConfig, msg: WebInboundMsg) {
const sender = normalizeE164(msg.senderE164 ?? "");
const sender = normalizeE164(getSenderIdentity(msg).e164 ?? "");
if (!sender) {
return false;
}
const owners = resolveOwnerList(baseMentionConfig, msg.selfE164 ?? undefined);
const owners = resolveOwnerList(baseMentionConfig, getSelfIdentity(msg).e164 ?? undefined);
return owners.includes(sender);
}
@@ -50,10 +57,14 @@ function recordPendingGroupHistoryEntry(params: {
groupHistoryKey: string;
groupHistoryLimit: number;
}) {
const senderIdentity = getSenderIdentity(params.msg);
const sender =
params.msg.senderName && params.msg.senderE164
? `${params.msg.senderName} (${params.msg.senderE164})`
: (params.msg.senderName ?? params.msg.senderE164 ?? "Unknown");
senderIdentity.name && senderIdentity.e164
? `${senderIdentity.name} (${senderIdentity.e164})`
: (senderIdentity.name ??
senderIdentity.e164 ??
getPrimaryIdentityId(senderIdentity) ??
"Unknown");
recordPendingHistoryEntryIfEnabled({
historyMap: params.groupHistories,
historyKey: params.groupHistoryKey,
@@ -63,7 +74,7 @@ function recordPendingGroupHistoryEntry(params: {
body: params.msg.body,
timestamp: params.msg.timestamp,
id: params.msg.id,
senderJid: params.msg.senderJid,
senderJid: senderIdentity.jid ?? params.msg.senderJid,
},
});
}
@@ -80,6 +91,8 @@ function skipGroupMessageAndStoreHistory(params: ApplyGroupGatingParams, verbose
}
export function applyGroupGating(params: ApplyGroupGatingParams) {
const sender = getSenderIdentity(params.msg);
const self = getSelfIdentity(params.msg, params.authDir);
const groupPolicy = resolveGroupPolicyFor(params.cfg, params.conversationId);
if (groupPolicy.allowlistEnabled && !groupPolicy.allowed) {
params.logVerbose(`Skipping group message ${params.conversationId} (not in allowlist)`);
@@ -89,15 +102,15 @@ export function applyGroupGating(params: ApplyGroupGatingParams) {
noteGroupMember(
params.groupMemberNames,
params.groupHistoryKey,
params.msg.senderE164,
params.msg.senderName,
sender.e164 ?? undefined,
sender.name ?? undefined,
);
const mentionConfig = buildMentionConfig(params.cfg, params.agentId);
const commandBody = stripMentionsForCommand(
params.msg.body,
mentionConfig.mentionRegexes,
params.msg.selfE164,
self.e164,
);
const activationCommand = parseActivationCommand(commandBody);
const owner = isOwnerSender(params.baseMentionConfig, params.msg);
@@ -127,21 +140,11 @@ export function applyGroupGating(params: ApplyGroupGatingParams) {
conversationId: params.conversationId,
});
const requireMention = activation !== "always";
const selfJid = params.msg.selfJid?.replace(/:\d+/, "");
const selfLid = params.msg.selfLid?.replace(/:\d+/, "");
const replySenderJid = params.msg.replyToSenderJid?.replace(/:\d+/, "");
const selfE164 = params.msg.selfE164 ? normalizeE164(params.msg.selfE164) : null;
const replySenderE164 = params.msg.replyToSenderE164
? normalizeE164(params.msg.replyToSenderE164)
: null;
const replyContext = getReplyContext(params.msg, params.authDir);
// Detect reply-to-bot: compare JIDs, LIDs, and E.164 numbers.
// WhatsApp may report the quoted message sender as either a phone JID
// (xxxxx@s.whatsapp.net) or a LID (xxxxx@lid), so we compare both.
const implicitMention = Boolean(
(selfJid && replySenderJid && selfJid === replySenderJid) ||
(selfLid && replySenderJid && selfLid === replySenderJid) ||
(selfE164 && replySenderE164 && selfE164 === replySenderE164),
);
const implicitMention = identitiesOverlap(self, replyContext?.sender);
const mentionGate = resolveMentionGating({
requireMention,
canDetectMention: true,

View File

@@ -4,15 +4,17 @@ import {
type EnvelopeFormatOptions,
} from "openclaw/plugin-sdk/channel-inbound";
import type { loadConfig } from "openclaw/plugin-sdk/config-runtime";
import { getPrimaryIdentityId, getReplyContext, getSenderIdentity } from "../../identity.js";
import type { WebInboundMsg } from "../types.js";
export function formatReplyContext(msg: WebInboundMsg) {
if (!msg.replyToBody) {
const replyTo = getReplyContext(msg);
if (!replyTo?.body) {
return null;
}
const sender = msg.replyToSender ?? "unknown sender";
const idPart = msg.replyToId ? ` id:${msg.replyToId}` : "";
return `[Replying to ${sender}${idPart}]\n${msg.replyToBody}\n[/Replying]`;
const sender = replyTo.sender?.label ?? replyTo.sender?.e164 ?? "unknown sender";
const idPart = replyTo.id ? ` id:${replyTo.id}` : "";
return `[Replying to ${sender}${idPart}]\n${replyTo.body}\n[/Replying]`;
}
export function buildInboundLine(params: {
@@ -31,6 +33,7 @@ export function buildInboundLine(params: {
const prefixStr = messagePrefix ? `${messagePrefix} ` : "";
const replyContext = formatReplyContext(msg);
const baseLine = `${prefixStr}${msg.body}${replyContext ? `\n\n${replyContext}` : ""}`;
const sender = getSenderIdentity(msg);
// Wrap with standardized envelope for the agent.
return formatInboundEnvelope({
@@ -40,9 +43,9 @@ export function buildInboundLine(params: {
body: baseLine,
chatType: msg.chatType,
sender: {
name: msg.senderName,
e164: msg.senderE164,
id: msg.senderJid,
name: sender.name ?? undefined,
e164: sender.e164 ?? undefined,
id: getPrimaryIdentityId(sender) ?? undefined,
},
previousTimestamp,
envelope,

View File

@@ -5,6 +5,7 @@ import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import { buildGroupHistoryKey } from "openclaw/plugin-sdk/routing";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { normalizeE164 } from "openclaw/plugin-sdk/text-runtime";
import { getPrimaryIdentityId, getSenderIdentity } from "../../identity.js";
import type { MentionConfig } from "../mentions.js";
import type { WebInboundMsg } from "../types.js";
import { maybeBroadcastMessage } from "./broadcast.js";
@@ -96,6 +97,7 @@ export function createWebOnMessageHandler(params: {
}
if (msg.chatType === "group") {
const sender = getSenderIdentity(msg);
const metaCtx = {
From: msg.from,
To: msg.to,
@@ -104,9 +106,9 @@ export function createWebOnMessageHandler(params: {
ChatType: msg.chatType,
ConversationLabel: conversationId,
GroupSubject: msg.groupSubject,
SenderName: msg.senderName,
SenderId: msg.senderJid?.trim() || msg.senderE164,
SenderE164: msg.senderE164,
SenderName: sender.name ?? undefined,
SenderId: getPrimaryIdentityId(sender) ?? undefined,
SenderE164: sender.e164 ?? undefined,
Provider: "whatsapp",
Surface: "whatsapp",
OriginatingChannel: "whatsapp",
@@ -144,8 +146,12 @@ export function createWebOnMessageHandler(params: {
}
} else {
// Ensure `peerId` for DMs is stable and stored as E.164 when possible.
if (!msg.senderE164 && peerId && peerId.startsWith("+")) {
msg.senderE164 = normalizeE164(peerId) ?? msg.senderE164;
if (!msg.sender?.e164 && !msg.senderE164 && peerId && peerId.startsWith("+")) {
const normalized = normalizeE164(peerId);
if (normalized) {
msg.sender = { ...(msg.sender ?? {}), e164: normalized };
msg.senderE164 = normalized;
}
}
}

View File

@@ -1,12 +1,14 @@
import { jidToE164, normalizeE164 } from "openclaw/plugin-sdk/text-runtime";
import { getSenderIdentity } from "../../identity.js";
import type { WebInboundMsg } from "../types.js";
export function resolvePeerId(msg: WebInboundMsg) {
if (msg.chatType === "group") {
return msg.conversationId ?? msg.from;
}
if (msg.senderE164) {
return normalizeE164(msg.senderE164) ?? msg.senderE164;
const sender = getSenderIdentity(msg);
if (sender.e164) {
return normalizeE164(sender.e164) ?? sender.e164;
}
if (msg.from.includes("@")) {
return jidToE164(msg.from) ?? msg.from;

View File

@@ -33,6 +33,12 @@ import {
} from "openclaw/plugin-sdk/security-runtime";
import { jidToE164, normalizeE164 } from "openclaw/plugin-sdk/text-runtime";
import { resolveWhatsAppAccount } from "../../accounts.js";
import {
getPrimaryIdentityId,
getReplyContext,
getSelfIdentity,
getSenderIdentity,
} from "../../identity.js";
import { newConnectionId } from "../../reconnect.js";
import { formatError } from "../../session.js";
import { deliverWebReply } from "../deliver-reply.js";
@@ -62,8 +68,10 @@ async function resolveWhatsAppCommandAuthorized(params: {
}
const isGroup = params.msg.chatType === "group";
const sender = getSenderIdentity(params.msg);
const self = getSelfIdentity(params.msg);
const senderE164 = normalizeE164(
isGroup ? (params.msg.senderE164 ?? "") : (params.msg.senderE164 ?? params.msg.from ?? ""),
isGroup ? (sender.e164 ?? "") : (sender.e164 ?? params.msg.from ?? ""),
);
if (!senderE164) {
return false;
@@ -84,11 +92,7 @@ async function resolveWhatsAppCommandAuthorized(params: {
dmPolicy,
});
const dmAllowFrom =
configuredAllowFrom.length > 0
? configuredAllowFrom
: params.msg.selfE164
? [params.msg.selfE164]
: [];
configuredAllowFrom.length > 0 ? configuredAllowFrom : self.e164 ? [self.e164] : [];
const access = resolveDmGroupAccessWithCommandGate({
isGroup,
dmPolicy,
@@ -244,11 +248,14 @@ export async function processMessage(params: {
whatsappInboundLog.debug(`Inbound body: ${elide(combinedBody, 400)}`);
}
const sender = getSenderIdentity(params.msg);
const self = getSelfIdentity(params.msg);
const replyTo = getReplyContext(params.msg);
const dmRouteTarget =
params.msg.chatType !== "group"
? (() => {
if (params.msg.senderE164) {
return normalizeE164(params.msg.senderE164);
if (sender.e164) {
return normalizeE164(sender.e164);
}
// In direct chats, `msg.from` is already the canonical conversation id.
if (params.msg.from.includes("@")) {
@@ -280,8 +287,8 @@ export async function processMessage(params: {
});
const isSelfChat =
params.msg.chatType !== "group" &&
Boolean(params.msg.selfE164) &&
normalizeE164(params.msg.from) === normalizeE164(params.msg.selfE164 ?? "");
Boolean(self.e164) &&
normalizeE164(params.msg.from) === normalizeE164(self.e164 ?? "");
const responsePrefix =
replyPipeline.responsePrefix ??
(configuredResponsePrefix === undefined && isSelfChat
@@ -310,9 +317,9 @@ export async function processMessage(params: {
SessionKey: params.route.sessionKey,
AccountId: params.route.accountId,
MessageSid: params.msg.id,
ReplyToId: params.msg.replyToId,
ReplyToBody: params.msg.replyToBody,
ReplyToSender: params.msg.replyToSender,
ReplyToId: replyTo?.id,
ReplyToBody: replyTo?.body,
ReplyToSender: replyTo?.sender?.label,
MediaPath: params.msg.mediaPath,
MediaUrl: params.msg.mediaUrl,
MediaType: params.msg.mediaType,
@@ -322,11 +329,11 @@ export async function processMessage(params: {
GroupMembers: formatGroupMembers({
participants: params.msg.groupParticipants,
roster: params.groupMemberNames.get(params.groupHistoryKey),
fallbackE164: params.msg.senderE164,
fallbackE164: sender.e164 ?? undefined,
}),
SenderName: params.msg.senderName,
SenderId: params.msg.senderJid?.trim() || params.msg.senderE164,
SenderE164: params.msg.senderE164,
SenderName: sender.name ?? undefined,
SenderId: getPrimaryIdentityId(sender) ?? undefined,
SenderE164: sender.e164 ?? undefined,
CommandAuthorized: commandAuthorized,
WasMentioned: params.msg.wasMentioned,
...(params.msg.location ? toLocationContext(params.msg.location) : {}),

View File

@@ -115,7 +115,13 @@ describe("resolveMentionTargets with @lid mapping", () => {
}),
authDir,
);
expect(mentionTargets.normalizedMentions).toContain("+1777");
expect(mentionTargets.normalizedMentions).toEqual([
expect.objectContaining({
jid: null,
lid: "777@lid",
e164: "+1777",
}),
]);
const selfTargets = resolveMentionTargets(
makeMsg({
@@ -124,7 +130,8 @@ describe("resolveMentionTargets with @lid mapping", () => {
}),
authDir,
);
expect(selfTargets.selfE164).toBe("+1777");
expect(selfTargets.self.e164).toBe("+1777");
expect(selfTargets.self.lid).toBe("777@lid");
});
});
});

View File

@@ -0,0 +1,164 @@
import { jidToE164, normalizeE164 } from "openclaw/plugin-sdk/text-runtime";
const WHATSAPP_LID_RE = /@(lid|hosted\.lid)$/i;
export type WhatsAppIdentity = {
jid?: string | null;
lid?: string | null;
e164?: string | null;
name?: string | null;
label?: string | null;
};
export type WhatsAppSelfIdentity = {
jid?: string | null;
lid?: string | null;
e164?: string | null;
};
export type WhatsAppReplyContext = {
id?: string;
body: string;
sender?: WhatsAppIdentity | null;
};
type LegacySenderLike = {
sender?: WhatsAppIdentity;
senderJid?: string;
senderE164?: string;
senderName?: string;
};
type LegacySelfLike = {
self?: WhatsAppSelfIdentity;
selfJid?: string | null;
selfLid?: string | null;
selfE164?: string | null;
};
type LegacyReplyLike = {
replyTo?: WhatsAppReplyContext;
replyToId?: string;
replyToBody?: string;
replyToSender?: string;
replyToSenderJid?: string;
replyToSenderE164?: string;
};
type LegacyMentionsLike = {
mentions?: string[];
mentionedJids?: string[];
};
export function normalizeDeviceScopedJid(jid: string | null | undefined): string | null {
return jid ? jid.replace(/:\d+/, "") : null;
}
function isLidJid(jid: string | null | undefined): boolean {
return Boolean(jid && WHATSAPP_LID_RE.test(jid));
}
export function resolveComparableIdentity(
identity: WhatsAppIdentity | WhatsAppSelfIdentity | null | undefined,
authDir?: string,
): WhatsAppIdentity {
const rawJid = normalizeDeviceScopedJid(identity?.jid);
const rawLid = normalizeDeviceScopedJid(identity?.lid);
const lid = rawLid ?? (isLidJid(rawJid) ? rawJid : null);
const jid = rawJid && !isLidJid(rawJid) ? rawJid : null;
const e164 =
identity?.e164 != null
? normalizeE164(identity.e164)
: ((jid ? jidToE164(jid, authDir ? { authDir } : undefined) : null) ??
(lid ? jidToE164(lid, authDir ? { authDir } : undefined) : null));
return {
...identity,
jid,
lid,
e164,
};
}
export function getComparableIdentityValues(
identity: WhatsAppIdentity | WhatsAppSelfIdentity | null | undefined,
): string[] {
const resolved = resolveComparableIdentity(identity);
return [resolved.e164, resolved.jid, resolved.lid].filter((value): value is string =>
Boolean(value),
);
}
export function identitiesOverlap(
left: WhatsAppIdentity | WhatsAppSelfIdentity | null | undefined,
right: WhatsAppIdentity | WhatsAppSelfIdentity | null | undefined,
): boolean {
const leftValues = new Set(getComparableIdentityValues(left));
if (leftValues.size === 0) {
return false;
}
return getComparableIdentityValues(right).some((value) => leftValues.has(value));
}
export function getSenderIdentity(msg: LegacySenderLike, authDir?: string): WhatsAppIdentity {
return resolveComparableIdentity(
msg.sender ?? {
jid: msg.senderJid ?? null,
e164: msg.senderE164 ?? null,
name: msg.senderName ?? null,
},
authDir,
);
}
export function getSelfIdentity(msg: LegacySelfLike, authDir?: string): WhatsAppSelfIdentity {
return resolveComparableIdentity(
msg.self ?? {
jid: msg.selfJid ?? null,
lid: msg.selfLid ?? null,
e164: msg.selfE164 ?? null,
},
authDir,
);
}
export function getReplyContext(
msg: LegacyReplyLike,
authDir?: string,
): WhatsAppReplyContext | null {
if (msg.replyTo) {
return {
...msg.replyTo,
sender: resolveComparableIdentity(msg.replyTo.sender, authDir),
};
}
if (!msg.replyToBody) {
return null;
}
return {
id: msg.replyToId,
body: msg.replyToBody,
sender: resolveComparableIdentity(
{
jid: msg.replyToSenderJid ?? null,
e164: msg.replyToSenderE164 ?? null,
label: msg.replyToSender ?? null,
},
authDir,
),
};
}
export function getMentionJids(msg: LegacyMentionsLike): string[] {
return msg.mentions ?? msg.mentionedJids ?? [];
}
export function getMentionIdentities(
msg: LegacyMentionsLike,
authDir?: string,
): WhatsAppIdentity[] {
return getMentionJids(msg).map((jid) => resolveComparableIdentity({ jid }, authDir));
}
export function getPrimaryIdentityId(identity: WhatsAppIdentity | null | undefined): string | null {
return identity?.e164 || identity?.jid?.trim() || identity?.lid || null;
}

View File

@@ -7,6 +7,7 @@ import {
import { formatLocationText, type NormalizedLocation } from "openclaw/plugin-sdk/channel-inbound";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { jidToE164 } from "openclaw/plugin-sdk/text-runtime";
import { resolveComparableIdentity, type WhatsAppReplyContext } from "../identity.js";
import { parseVcard } from "../vcard.js";
const MESSAGE_WRAPPER_KEYS = [
@@ -107,46 +108,43 @@ function extractMessage(message: proto.IMessage | undefined): proto.IMessage | u
return candidate && typeof candidate === "object" ? (candidate as proto.IMessage) : normalized;
}
function unwrapMessage(message: proto.IMessage | undefined): proto.IMessage | undefined {
let normalized = normalizeMessage(message);
if (!normalized) {
return undefined;
}
// Generic FutureProofMessage unwrap for wrappers that Baileys'
// normalizeMessageContent doesn't handle yet (e.g. botInvokeMessage,
// groupMentionedMessage). These wrappers have shape { message: IMessage }.
// Iterate up to 3 times to handle nested wrappers.
for (let i = 0; i < 3; i++) {
const contentType = getMessageContentType(normalized);
if (!contentType) {
break;
}
const value = (normalized as Record<string, unknown>)[contentType];
if (
value &&
typeof value === "object" &&
"message" in value &&
(value as { message?: unknown }).message &&
typeof (value as { message: unknown }).message === "object"
) {
const inner = normalizeMessage((value as { message: proto.IMessage }).message);
if (inner) {
const innerType = getMessageContentType(inner);
if (innerType && innerType !== contentType) {
normalized = inner;
continue;
}
function getFutureProofInnerMessage(message: proto.IMessage): proto.IMessage | undefined {
const contentType = getMessageContentType(message);
const candidate = contentType ? (message as Record<string, unknown>)[contentType] : undefined;
if (
candidate &&
typeof candidate === "object" &&
"message" in candidate &&
(candidate as { message?: unknown }).message &&
typeof (candidate as { message: unknown }).message === "object"
) {
const inner = normalizeMessage((candidate as { message: proto.IMessage }).message);
if (inner) {
const innerType = getMessageContentType(inner);
if (innerType && innerType !== contentType) {
return inner;
}
}
break;
}
return normalized;
return undefined;
}
function extractContextInfo(message: proto.IMessage | undefined): proto.IContextInfo | undefined {
if (!message) {
return undefined;
function buildMessageChain(message: proto.IMessage | undefined): proto.IMessage[] {
const chain: proto.IMessage[] = [];
let current = normalizeMessage(message);
while (current && chain.length < 4) {
chain.push(current);
current = getFutureProofInnerMessage(current);
}
return chain;
}
function unwrapMessage(message: proto.IMessage | undefined): proto.IMessage | undefined {
const chain = buildMessageChain(message);
return chain.at(-1);
}
function extractContextInfoFromMessage(message: proto.IMessage): proto.IContextInfo | undefined {
const contentType = getMessageContentType(message);
const candidate = contentType ? (message as Record<string, unknown>)[contentType] : undefined;
const contextInfo =
@@ -196,6 +194,16 @@ function extractContextInfo(message: proto.IMessage | undefined): proto.IContext
return undefined;
}
function extractContextInfo(message: proto.IMessage | undefined): proto.IContextInfo | undefined {
for (const candidate of buildMessageChain(message)) {
const contextInfo = extractContextInfoFromMessage(candidate);
if (contextInfo) {
return contextInfo;
}
}
return undefined;
}
export function extractMentionedJids(rawMessage: proto.IMessage | undefined): string[] | undefined {
const message = unwrapMessage(rawMessage);
if (!message) {
@@ -426,13 +434,9 @@ export function extractLocationData(
return null;
}
export function describeReplyContext(rawMessage: proto.IMessage | undefined): {
id?: string;
body: string;
sender: string;
senderJid?: string;
senderE164?: string;
} | null {
export function describeReplyContext(
rawMessage: proto.IMessage | undefined,
): WhatsAppReplyContext | null {
const message = unwrapMessage(rawMessage);
if (!message) {
return null;
@@ -457,13 +461,13 @@ export function describeReplyContext(rawMessage: proto.IMessage | undefined): {
return null;
}
const senderJid = contextInfo?.participant ?? undefined;
const senderE164 = senderJid ? (jidToE164(senderJid) ?? senderJid) : undefined;
const sender = senderE164 ?? "unknown sender";
const sender = resolveComparableIdentity({
jid: senderJid,
label: senderJid ? (jidToE164(senderJid) ?? senderJid) : "unknown sender",
});
return {
id: contextInfo?.stanzaId ? String(contextInfo.stanzaId) : undefined,
body,
sender,
senderJid,
senderE164,
};
}

View File

@@ -1,5 +1,3 @@
import fs from "node:fs";
import path from "node:path";
import type { AnyMessageContent, proto, WAMessage } from "@whiskeysockets/baileys";
import { DisconnectReason, isJidGroup } from "@whiskeysockets/baileys";
import { createInboundDebouncer, formatLocationText } from "openclaw/plugin-sdk/channel-inbound";
@@ -8,7 +6,9 @@ import { saveMediaBuffer } from "openclaw/plugin-sdk/media-runtime";
import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
import { getChildLogger } from "openclaw/plugin-sdk/text-runtime";
import { jidToE164, resolveJidToE164 } from "openclaw/plugin-sdk/text-runtime";
import { resolveJidToE164 } from "openclaw/plugin-sdk/text-runtime";
import { readWebSelfIdentity } from "../auth-store.js";
import { getPrimaryIdentityId, resolveComparableIdentity } from "../identity.js";
import { createWaSocket, getStatusCode, waitForWaConnection } from "../session.js";
import { checkInboundAccessControl } from "./access-control.js";
import {
@@ -77,27 +77,21 @@ export async function monitorWebInbox(options: {
logVerbose(`Failed to send 'available' presence on connect: ${String(err)}`);
}
const selfJid = sock.user?.id;
const selfE164 = selfJid ? jidToE164(selfJid) : null;
// Bot's own LID (Linked Identity) — needed for reply-to-bot detection
// when contextInfo.participant returns a LID instead of a phone JID.
// Baileys 7 rc9 does not expose lid on sock.user, so read from creds file.
const selfLid = await (async () => {
try {
const credsPath = path.join(options.authDir, "creds.json");
const raw = await fs.promises.readFile(credsPath, "utf-8");
const creds = JSON.parse(raw) as { me?: { lid?: string } };
return creds?.me?.lid ?? undefined;
} catch {
return (sock.user as { lid?: string } | undefined)?.lid ?? undefined;
}
})();
const self = await readWebSelfIdentity(
options.authDir,
sock.user as { id?: string | null; lid?: string | null } | undefined,
);
const debouncer = createInboundDebouncer<WebInboundMessage>({
debounceMs: options.debounceMs ?? 0,
buildKey: (msg) => {
const sender = msg.sender;
const senderKey =
msg.chatType === "group"
? (msg.senderJid ?? msg.senderE164 ?? msg.senderName ?? msg.from)
? (getPrimaryIdentityId(sender ?? null) ??
msg.senderJid ??
msg.senderE164 ??
msg.senderName ??
msg.from)
: msg.from;
if (!senderKey) {
return null;
@@ -117,7 +111,7 @@ export async function monitorWebInbox(options: {
}
const mentioned = new Set<string>();
for (const entry of entries) {
for (const jid of entry.mentionedJids ?? []) {
for (const jid of entry.mentions ?? entry.mentionedJids ?? []) {
mentioned.add(jid);
}
}
@@ -128,6 +122,7 @@ export async function monitorWebInbox(options: {
const combinedMessage: WebInboundMessage = {
...last,
body: combinedBody,
mentions: mentioned.size > 0 ? Array.from(mentioned) : undefined,
mentionedJids: mentioned.size > 0 ? Array.from(mentioned) : undefined,
};
await options.onMessage(combinedMessage);
@@ -267,7 +262,7 @@ export async function monitorWebInbox(options: {
const access = await checkInboundAccessControl({
accountId: options.accountId,
from,
selfE164,
selfE164: self.e164 ?? null,
senderE164,
group,
pushName: msg.pushName ?? undefined,
@@ -399,7 +394,7 @@ export async function monitorWebInbox(options: {
inboundLogger.info(
{
from: inbound.from,
to: selfE164 ?? "me",
to: self.e164 ?? "me",
body: enriched.body,
mediaPath: enriched.mediaPath,
mediaType: enriched.mediaType,
@@ -412,27 +407,35 @@ export async function monitorWebInbox(options: {
id: inbound.id,
from: inbound.from,
conversationId: inbound.from,
to: selfE164 ?? "me",
to: self.e164 ?? "me",
accountId: inbound.access.resolvedAccountId,
body: enriched.body,
pushName: senderName,
timestamp,
chatType: inbound.group ? "group" : "direct",
chatId: inbound.remoteJid,
sender: resolveComparableIdentity({
jid: inbound.participantJid,
e164: inbound.senderE164 ?? undefined,
name: senderName,
}),
senderJid: inbound.participantJid,
senderE164: inbound.senderE164 ?? undefined,
senderName,
replyTo: enriched.replyContext ?? undefined,
replyToId: enriched.replyContext?.id,
replyToBody: enriched.replyContext?.body,
replyToSender: enriched.replyContext?.sender,
replyToSenderJid: enriched.replyContext?.senderJid,
replyToSenderE164: enriched.replyContext?.senderE164,
replyToSender: enriched.replyContext?.sender?.label ?? undefined,
replyToSenderJid: enriched.replyContext?.sender?.jid ?? undefined,
replyToSenderE164: enriched.replyContext?.sender?.e164 ?? undefined,
groupSubject: inbound.groupSubject,
groupParticipants: inbound.groupParticipants,
mentions: mentionedJids ?? undefined,
mentionedJids: mentionedJids ?? undefined,
selfJid,
selfLid,
selfE164,
self,
selfJid: self.jid ?? undefined,
selfLid: self.lid ?? undefined,
selfE164: self.e164 ?? undefined,
fromMe: Boolean(msg.key?.fromMe),
location: enriched.location ?? undefined,
sendComposing,

View File

@@ -1,5 +1,6 @@
import type { AnyMessageContent } from "@whiskeysockets/baileys";
import type { NormalizedLocation } from "openclaw/plugin-sdk/channel-inbound";
import type { WhatsAppIdentity, WhatsAppReplyContext, WhatsAppSelfIdentity } from "../identity.js";
export type WebListenerCloseReason = {
status?: number;
@@ -18,9 +19,11 @@ export type WebInboundMessage = {
timestamp?: number;
chatType: "direct" | "group";
chatId: string;
sender?: WhatsAppIdentity;
senderJid?: string;
senderE164?: string;
senderName?: string;
replyTo?: WhatsAppReplyContext;
replyToId?: string;
replyToBody?: string;
replyToSender?: string;
@@ -28,7 +31,9 @@ export type WebInboundMessage = {
replyToSenderE164?: string;
groupSubject?: string;
groupParticipants?: string[];
mentions?: string[];
mentionedJids?: string[];
self?: WhatsAppSelfIdentity;
selfJid?: string | null;
selfLid?: string | null;
selfE164?: string | null;

View File

@@ -60,6 +60,23 @@ describe("web monitor inbox", () => {
replyToId: "q1",
replyToBody: "original",
replyToSender: "+111",
sender: expect.objectContaining({
e164: "+999",
name: "Tester",
}),
replyTo: expect.objectContaining({
id: "q1",
body: "original",
sender: expect.objectContaining({
jid: "111@s.whatsapp.net",
e164: "+111",
label: "+111",
}),
}),
self: expect.objectContaining({
jid: "123@s.whatsapp.net",
e164: "+123",
}),
}),
);
expect(sock.sendMessage).toHaveBeenCalledWith("999@s.whatsapp.net", {
@@ -268,4 +285,20 @@ describe("web monitor inbox", () => {
},
});
});
it("captures reply context from botInvokeMessage wrapped quoted messages", async () => {
await expectQuotedReplyContext({
botInvokeMessage: {
message: { conversation: "original" },
},
});
});
it("captures reply context from groupMentionedMessage wrapped quoted messages", async () => {
await expectQuotedReplyContext({
groupMentionedMessage: {
message: { conversation: "original" },
},
});
});
});

View File

@@ -140,6 +140,36 @@ describe("web session", () => {
readSpy.mockRestore();
});
it("logWebSelfId prints cached lid details when creds include a lid", () => {
const existsSpy = vi.spyOn(fsSync, "existsSync").mockImplementation((p) => {
if (typeof p !== "string") {
return false;
}
return p.endsWith("creds.json");
});
const readSpy = vi.spyOn(fsSync, "readFileSync").mockImplementation((p) => {
if (typeof p === "string" && p.endsWith("creds.json")) {
return JSON.stringify({
me: { id: "12345@s.whatsapp.net", lid: "777@lid" },
});
}
throw new Error(`unexpected readFileSync path: ${String(p)}`);
});
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
logWebSelfId("/tmp/wa-creds", runtime as never, true);
expect(runtime.log).toHaveBeenCalledWith(
expect.stringContaining("Web Channel: +12345 (jid 12345@s.whatsapp.net, lid 777@lid)"),
);
existsSpy.mockRestore();
readSpy.mockRestore();
});
it("formatError prints Boom-like payload message", () => {
const err = {
error: {