mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:50:43 +00:00
feat(whatsapp): support native outbound mentions (#73961)
Merged via squash.
Prepared head SHA: bb1df9e681
Co-authored-by: BunsDev <68980965+BunsDev@users.noreply.github.com>
Reviewed-by: @BunsDev
This commit is contained in:
committed by
GitHub
parent
b1f8172867
commit
22748b1c36
@@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Channels/secrets: resolve SecretRef-backed channel credentials through external plugin secret contracts after the plugin split, covering runtime startup, target discovery, webhook auth, disabled-account enumeration, and late-bound web_search config. (#76449) Thanks @joshavant.
|
||||
- Docker/Gateway: pass Docker setup `.env` values into gateway and CLI containers and preserve exec SecretRef `passEnv` keys in managed service plans, so 1Password Connect-backed Discord tokens keep resolving after doctor or plugin repair. Thanks @vincentkoc.
|
||||
- Control UI/WebChat: explain compaction boundaries in chat history and link directly to session checkpoint controls so pre-compaction turns no longer look silently lost after refresh. Fixes #76415. Thanks @BunsDev.
|
||||
- Channels/WhatsApp: attach native outbound mention metadata for group text and media captions by resolving `@+<digits>` and `@<digits>` tokens against WhatsApp participant data, including LID groups. Fixes #39879; carries forward #56863. Thanks @kengi1437, @joe2643, and @fridayck.
|
||||
- Gateway/sessions: keep async `sessions.list` title and preview hydration bounded to transcript head/tail reads so Control UI polling cannot full-scan large session transcripts every refresh. Thanks @vincentkoc.
|
||||
- Gateway/sessions: keep bulk `sessions.list` rows lightweight by skipping per-row transcript usage fallback, display model inference, and plugin projection, avoiding event-loop stalls in large session stores. Thanks @Marvinthebored and @vincentkoc.
|
||||
- CLI/plugins: reject missing plugin ids before config writes in `plugins enable` and `plugins disable` so a typo no longer persists a stale config entry. (#73554) Thanks @ai-hpc.
|
||||
|
||||
@@ -153,6 +153,7 @@ OpenClaw recommends running WhatsApp on a separate number when possible. (The ch
|
||||
- The reconnect watchdog uses WhatsApp Web transport activity, not only inbound app-message volume, so a quiet linked-device session is not restarted solely because nobody has sent a message recently. A longer application-silence cap still forces a reconnect if transport frames keep arriving but no application messages are handled for the watchdog window; after a transient reconnect for a recently active session, that application-silence check uses the normal message timeout for the first recovery window.
|
||||
- Baileys socket timings are explicit under `web.whatsapp.*`: `keepAliveIntervalMs` controls WhatsApp Web application pings, `connectTimeoutMs` controls the opening handshake timeout, and `defaultQueryTimeoutMs` controls Baileys query timeouts.
|
||||
- Outbound sends require an active WhatsApp listener for the target account.
|
||||
- Group sends attach native mention metadata for `@+<digits>` and `@<digits>` tokens in text and media captions when the token matches current WhatsApp participant metadata, including LID-backed groups.
|
||||
- Status and broadcast chats are ignored (`@status`, `@broadcast`).
|
||||
- The reconnect watchdog follows WhatsApp Web transport activity, not only inbound app-message volume: quiet linked-device sessions stay up while transport frames continue, but a transport stall forces reconnect well before the later remote disconnect path.
|
||||
- Direct chats use DM session rules (`session.dmScope`; default `main` collapses DMs to the agent main session).
|
||||
|
||||
@@ -40,6 +40,12 @@ import {
|
||||
} from "./extract.js";
|
||||
import { attachEmitterListener, closeInboundMonitorSocket } from "./lifecycle.js";
|
||||
import { downloadInboundMedia, downloadQuotedInboundMedia } from "./media.js";
|
||||
import {
|
||||
addWhatsAppOutboundMentionsToContent,
|
||||
mayContainWhatsAppOutboundMention,
|
||||
resolveWhatsAppOutboundMentions,
|
||||
type WhatsAppOutboundMentionParticipant,
|
||||
} from "./outbound-mentions.js";
|
||||
import { DisconnectReason, isJidGroup, saveMediaBuffer } from "./runtime-api.js";
|
||||
import { createWebSendApi } from "./send-api.js";
|
||||
import { normalizeWhatsAppSendResult } from "./send-result.js";
|
||||
@@ -57,6 +63,7 @@ type WhatsAppGroupMetadataCacheEntry = {
|
||||
export type WhatsAppGroupMetadataCache = Map<string, WhatsAppGroupMetadataCacheEntry>;
|
||||
type LocalGroupMetadataCacheEntry = WhatsAppGroupMetadataCacheEntry & {
|
||||
participants?: string[];
|
||||
mentionParticipants?: WhatsAppOutboundMentionParticipant[];
|
||||
};
|
||||
|
||||
function rememberGroupMetadataCacheEntry<T extends WhatsAppGroupMetadataCacheEntry>(
|
||||
@@ -355,18 +362,26 @@ export async function attachWebInboxToSocket(
|
||||
};
|
||||
|
||||
const summarizeGroupMeta = async (meta: GroupMetadata) => {
|
||||
const participants =
|
||||
(
|
||||
await Promise.all(
|
||||
meta.participants?.map(async (p) => {
|
||||
const mapped = await resolveInboundJid(p.id);
|
||||
return mapped ?? p.id;
|
||||
}) ?? [],
|
||||
)
|
||||
).filter(Boolean) ?? [];
|
||||
const participantEntries = await Promise.all(
|
||||
meta.participants?.map(async (p) => {
|
||||
const mapped = await resolveInboundJid(p.id);
|
||||
return {
|
||||
display: mapped ?? p.id,
|
||||
mention: {
|
||||
id: p.id,
|
||||
lid: p.lid,
|
||||
phoneNumber: p.phoneNumber,
|
||||
e164: mapped,
|
||||
} satisfies WhatsAppOutboundMentionParticipant,
|
||||
};
|
||||
}) ?? [],
|
||||
);
|
||||
const participants = participantEntries.map((entry) => entry.display).filter(Boolean);
|
||||
const mentionParticipants = participantEntries.map((entry) => entry.mention);
|
||||
return {
|
||||
subject: meta.subject,
|
||||
participants,
|
||||
mentionParticipants,
|
||||
expires: Date.now() + GROUP_META_TTL_MS,
|
||||
};
|
||||
};
|
||||
@@ -384,7 +399,7 @@ export async function attachWebInboxToSocket(
|
||||
return cached;
|
||||
}
|
||||
try {
|
||||
const meta = await sock.groupMetadata(jid);
|
||||
const meta = await (getCurrentSock() ?? sock).groupMetadata(jid);
|
||||
const entry = await summarizeGroupMeta(meta);
|
||||
rememberGroupMetadataCacheEntry(groupMetadataCache, jid, {
|
||||
subject: entry.subject,
|
||||
@@ -410,6 +425,43 @@ export async function attachWebInboxToSocket(
|
||||
}
|
||||
};
|
||||
|
||||
const resolveOutboundMentionsForGroup = async (
|
||||
jid: string,
|
||||
text: string,
|
||||
): Promise<{ text: string; mentionedJids: string[] }> => {
|
||||
if (!isGroupJid(jid) || !mayContainWhatsAppOutboundMention(text)) {
|
||||
return { text, mentionedJids: [] };
|
||||
}
|
||||
const meta = await getGroupMeta(jid);
|
||||
return resolveWhatsAppOutboundMentions({
|
||||
chatJid: jid,
|
||||
text,
|
||||
participants: meta.mentionParticipants,
|
||||
});
|
||||
};
|
||||
|
||||
const applyOutboundMentionsToContent = async (
|
||||
jid: string,
|
||||
content: AnyMessageContent,
|
||||
): Promise<AnyMessageContent> => {
|
||||
if ("text" in content && typeof content.text === "string") {
|
||||
const resolved = await resolveOutboundMentionsForGroup(jid, content.text);
|
||||
return addWhatsAppOutboundMentionsToContent(
|
||||
{ ...content, text: resolved.text } as AnyMessageContent,
|
||||
resolved.mentionedJids,
|
||||
);
|
||||
}
|
||||
const caption = (content as { caption?: unknown }).caption;
|
||||
if (typeof caption === "string") {
|
||||
const resolved = await resolveOutboundMentionsForGroup(jid, caption);
|
||||
return addWhatsAppOutboundMentionsToContent(
|
||||
{ ...content, caption: resolved.text } as AnyMessageContent,
|
||||
resolved.mentionedJids,
|
||||
);
|
||||
}
|
||||
return content;
|
||||
};
|
||||
|
||||
type NormalizedInboundMessage = {
|
||||
id?: string;
|
||||
remoteJid: string;
|
||||
@@ -632,14 +684,23 @@ export async function attachWebInboxToSocket(
|
||||
}
|
||||
};
|
||||
const reply = async (text: string, options?: MiscMessageGenerationOptions) => {
|
||||
const result = await sendTrackedMessage(chatJid, { text }, options);
|
||||
const resolved = await resolveOutboundMentionsForGroup(chatJid, text);
|
||||
const result = await sendTrackedMessage(
|
||||
chatJid,
|
||||
addWhatsAppOutboundMentionsToContent({ text: resolved.text }, resolved.mentionedJids),
|
||||
options,
|
||||
);
|
||||
return normalizeWhatsAppSendResult(result, "text");
|
||||
};
|
||||
const sendMedia = async (
|
||||
payload: AnyMessageContent,
|
||||
options?: MiscMessageGenerationOptions,
|
||||
) => {
|
||||
const result = await sendTrackedMessage(chatJid, payload, options);
|
||||
const result = await sendTrackedMessage(
|
||||
chatJid,
|
||||
await applyOutboundMentionsToContent(chatJid, payload),
|
||||
options,
|
||||
);
|
||||
return normalizeWhatsAppSendResult(result, "media");
|
||||
};
|
||||
const timestamp = inbound.messageTimestampMs;
|
||||
@@ -856,6 +917,7 @@ export async function attachWebInboxToSocket(
|
||||
},
|
||||
},
|
||||
defaultAccountId: options.accountId,
|
||||
resolveOutboundMentions: ({ jid, text }) => resolveOutboundMentionsForGroup(jid, text),
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
133
extensions/whatsapp/src/inbound/outbound-mentions.test.ts
Normal file
133
extensions/whatsapp/src/inbound/outbound-mentions.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveWhatsAppOutboundMentions } from "./outbound-mentions.js";
|
||||
|
||||
describe("resolveWhatsAppOutboundMentions", () => {
|
||||
it("resolves phone-number tokens to WhatsApp participant JIDs", () => {
|
||||
expect(
|
||||
resolveWhatsAppOutboundMentions({
|
||||
chatJid: "120363000000000000@g.us",
|
||||
text: "hi @+15551234567 and @15557654321",
|
||||
participants: [{ id: "15551234567@s.whatsapp.net" }, { id: "15557654321@s.whatsapp.net" }],
|
||||
}),
|
||||
).toEqual({
|
||||
text: "hi @+15551234567 and @15557654321",
|
||||
mentionedJids: ["15551234567@s.whatsapp.net", "15557654321@s.whatsapp.net"],
|
||||
});
|
||||
});
|
||||
|
||||
it("rewrites phone-number tokens to LID mention text without device suffixes", () => {
|
||||
expect(
|
||||
resolveWhatsAppOutboundMentions({
|
||||
chatJid: "120363000000000000@g.us",
|
||||
text: "ping @+5511976136970",
|
||||
participants: [
|
||||
{
|
||||
id: "277038292303944:2@lid",
|
||||
phoneNumber: "5511976136970@s.whatsapp.net",
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
text: "ping @277038292303944",
|
||||
mentionedJids: ["277038292303944@lid"],
|
||||
});
|
||||
});
|
||||
|
||||
it("uses resolved E.164 metadata when LID participant records omit phoneNumber", () => {
|
||||
expect(
|
||||
resolveWhatsAppOutboundMentions({
|
||||
chatJid: "120363000000000000@g.us",
|
||||
text: "ping @15551234567",
|
||||
participants: [
|
||||
{
|
||||
id: "277038292303944@lid",
|
||||
e164: "+15551234567",
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
text: "ping @277038292303944",
|
||||
mentionedJids: ["277038292303944@lid"],
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers explicit LID metadata over a phone JID id", () => {
|
||||
expect(
|
||||
resolveWhatsAppOutboundMentions({
|
||||
chatJid: "120363000000000000@g.us",
|
||||
text: "ping @15551234567 and @277038292303944",
|
||||
participants: [
|
||||
{
|
||||
id: "15551234567@s.whatsapp.net",
|
||||
lid: "277038292303944@lid",
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
text: "ping @277038292303944 and @277038292303944",
|
||||
mentionedJids: ["277038292303944@lid"],
|
||||
});
|
||||
});
|
||||
|
||||
it("uses bare digit tokens for LIDs before phone numbers when participant keys collide", () => {
|
||||
expect(
|
||||
resolveWhatsAppOutboundMentions({
|
||||
chatJid: "120363000000000000@g.us",
|
||||
text: "ping @277038292303944 and @+277038292303944",
|
||||
participants: [{ id: "277038292303944@s.whatsapp.net" }, { id: "277038292303944@lid" }],
|
||||
}),
|
||||
).toEqual({
|
||||
text: "ping @277038292303944 and @+277038292303944",
|
||||
mentionedJids: ["277038292303944@lid", "277038292303944@s.whatsapp.net"],
|
||||
});
|
||||
});
|
||||
|
||||
it("applies LID rewrites by match position while skipping code spans", () => {
|
||||
expect(
|
||||
resolveWhatsAppOutboundMentions({
|
||||
chatJid: "120363000000000000@g.us",
|
||||
text: [
|
||||
"visible @+5511976136970",
|
||||
"`inline @+5511976136970`",
|
||||
"```",
|
||||
"fenced @+5511976136970",
|
||||
"```",
|
||||
"again @+5511976136970",
|
||||
].join("\n"),
|
||||
participants: [
|
||||
{
|
||||
id: "277038292303944:9@lid",
|
||||
phoneNumber: "5511976136970@s.whatsapp.net",
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
text: [
|
||||
"visible @277038292303944",
|
||||
"`inline @+5511976136970`",
|
||||
"```",
|
||||
"fenced @+5511976136970",
|
||||
"```",
|
||||
"again @277038292303944",
|
||||
].join("\n"),
|
||||
mentionedJids: ["277038292303944@lid"],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not add mention metadata for direct chats or unmatched group participants", () => {
|
||||
expect(
|
||||
resolveWhatsAppOutboundMentions({
|
||||
chatJid: "15551234567@s.whatsapp.net",
|
||||
text: "hi @+15551234567",
|
||||
participants: [{ id: "15551234567@s.whatsapp.net" }],
|
||||
}),
|
||||
).toEqual({ text: "hi @+15551234567", mentionedJids: [] });
|
||||
expect(
|
||||
resolveWhatsAppOutboundMentions({
|
||||
chatJid: "120363000000000000@g.us",
|
||||
text: "hi @+15551234567",
|
||||
participants: [{ id: "15550000000@s.whatsapp.net" }],
|
||||
}),
|
||||
).toEqual({ text: "hi @+15551234567", mentionedJids: [] });
|
||||
});
|
||||
});
|
||||
258
extensions/whatsapp/src/inbound/outbound-mentions.ts
Normal file
258
extensions/whatsapp/src/inbound/outbound-mentions.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import type { AnyMessageContent } from "@whiskeysockets/baileys";
|
||||
|
||||
export type WhatsAppOutboundMentionParticipant =
|
||||
| string
|
||||
| {
|
||||
id?: string | null;
|
||||
lid?: string | null;
|
||||
phoneNumber?: string | null;
|
||||
e164?: string | null;
|
||||
};
|
||||
|
||||
export type WhatsAppOutboundMentionResolution = {
|
||||
text: string;
|
||||
mentionedJids: string[];
|
||||
};
|
||||
|
||||
const CODE_FENCE_RE = /```[\s\S]*?```/g;
|
||||
const INLINE_CODE_RE = /`[^`\n]+`/g;
|
||||
const OUTBOUND_MENTION_RE = /@(\+?\d+)/g;
|
||||
const KNOWN_USER_JID_RE = /^(\d+)(?::\d+)?@(s\.whatsapp\.net|hosted|lid|hosted\.lid|c\.us)$/i;
|
||||
const PHONE_JID_DOMAIN_RE = /^(s\.whatsapp\.net|hosted|c\.us)$/i;
|
||||
const LID_JID_DOMAIN_RE = /^(lid|hosted\.lid)$/i;
|
||||
|
||||
type TextRange = {
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
|
||||
type MentionTarget = {
|
||||
mentionJid: string;
|
||||
replacementText?: string;
|
||||
};
|
||||
|
||||
function isWhatsAppGroupJid(jid: string): boolean {
|
||||
return jid.endsWith("@g.us");
|
||||
}
|
||||
|
||||
export function mayContainWhatsAppOutboundMention(text: string): boolean {
|
||||
return /@\+?\d/.test(text);
|
||||
}
|
||||
|
||||
function collectCodeRanges(text: string): TextRange[] {
|
||||
const ranges: TextRange[] = [];
|
||||
for (const match of text.matchAll(CODE_FENCE_RE)) {
|
||||
ranges.push({ start: match.index, end: match.index + match[0].length });
|
||||
}
|
||||
for (const match of text.matchAll(INLINE_CODE_RE)) {
|
||||
const start = match.index;
|
||||
if (ranges.some((range) => start >= range.start && start < range.end)) {
|
||||
continue;
|
||||
}
|
||||
ranges.push({ start, end: start + match[0].length });
|
||||
}
|
||||
return ranges.toSorted((a, b) => a.start - b.start);
|
||||
}
|
||||
|
||||
function isInRange(index: number, ranges: readonly TextRange[]): boolean {
|
||||
return ranges.some((range) => index >= range.start && index < range.end);
|
||||
}
|
||||
|
||||
function normalizeKnownUserJid(value: string): string | null {
|
||||
const trimmed = value.replace(/^whatsapp:/i, "").trim();
|
||||
const jidMatch = trimmed.match(KNOWN_USER_JID_RE);
|
||||
if (jidMatch) {
|
||||
const domain =
|
||||
jidMatch[2].toLowerCase() === "c.us" ? "s.whatsapp.net" : jidMatch[2].toLowerCase();
|
||||
return `${jidMatch[1]}@${domain}`;
|
||||
}
|
||||
const digits = trimmed.startsWith("+")
|
||||
? trimmed.replace(/\D/g, "")
|
||||
: /^\d+$/.test(trimmed)
|
||||
? trimmed
|
||||
: "";
|
||||
return digits ? `${digits}@s.whatsapp.net` : null;
|
||||
}
|
||||
|
||||
function extractKnownJidParts(value: string): { user: string; domain: string } | null {
|
||||
const normalized = normalizeKnownUserJid(value);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
const match = normalized.match(/^(\d+)@(.+)$/);
|
||||
return match ? { user: match[1], domain: match[2] } : null;
|
||||
}
|
||||
|
||||
function extractPhoneDigits(value: string | null | undefined): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.replace(/^whatsapp:/i, "").trim();
|
||||
if (trimmed.startsWith("+") || /^\d+$/.test(trimmed)) {
|
||||
const digits = trimmed.replace(/\D/g, "");
|
||||
return digits || null;
|
||||
}
|
||||
const parts = extractKnownJidParts(trimmed);
|
||||
return parts && PHONE_JID_DOMAIN_RE.test(parts.domain) ? parts.user : null;
|
||||
}
|
||||
|
||||
function extractLidDigits(value: string | null | undefined): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const parts = extractKnownJidParts(value);
|
||||
return parts && LID_JID_DOMAIN_RE.test(parts.domain) ? parts.user : null;
|
||||
}
|
||||
|
||||
function isLidJid(jid: string): boolean {
|
||||
const parts = extractKnownJidParts(jid);
|
||||
return Boolean(parts && LID_JID_DOMAIN_RE.test(parts.domain));
|
||||
}
|
||||
|
||||
function lidReplacementText(jid: string): string | undefined {
|
||||
const parts = extractKnownJidParts(jid);
|
||||
if (!parts || !LID_JID_DOMAIN_RE.test(parts.domain)) {
|
||||
return undefined;
|
||||
}
|
||||
return `@${parts.user}`;
|
||||
}
|
||||
|
||||
function participantValues(participant: WhatsAppOutboundMentionParticipant): {
|
||||
id?: string | null;
|
||||
lid?: string | null;
|
||||
phoneNumber?: string | null;
|
||||
e164?: string | null;
|
||||
} {
|
||||
return typeof participant === "string" ? { id: participant } : participant;
|
||||
}
|
||||
|
||||
function chooseMentionJid(participant: WhatsAppOutboundMentionParticipant): string | null {
|
||||
const values = participantValues(participant);
|
||||
const idJid = normalizeKnownUserJid(values.id ?? "");
|
||||
const lidJid = normalizeKnownUserJid(values.lid ?? "");
|
||||
return (
|
||||
(idJid && isLidJid(idJid) ? idJid : null) ??
|
||||
(lidJid && isLidJid(lidJid) ? lidJid : null) ??
|
||||
idJid ??
|
||||
lidJid ??
|
||||
normalizeKnownUserJid(values.phoneNumber ?? "") ??
|
||||
normalizeKnownUserJid(values.e164 ?? "")
|
||||
);
|
||||
}
|
||||
|
||||
function buildMentionTargetMaps(participants: readonly WhatsAppOutboundMentionParticipant[]): {
|
||||
byPhone: Map<string, MentionTarget>;
|
||||
byLid: Map<string, MentionTarget>;
|
||||
} {
|
||||
const byPhone = new Map<string, MentionTarget>();
|
||||
const byLid = new Map<string, MentionTarget>();
|
||||
for (const participant of participants) {
|
||||
const mentionJid = chooseMentionJid(participant);
|
||||
if (!mentionJid) {
|
||||
continue;
|
||||
}
|
||||
const target = {
|
||||
mentionJid,
|
||||
...(isLidJid(mentionJid) ? { replacementText: lidReplacementText(mentionJid) } : {}),
|
||||
};
|
||||
const values = participantValues(participant);
|
||||
for (const value of [values.id, values.phoneNumber, values.e164]) {
|
||||
const digits = extractPhoneDigits(value);
|
||||
if (digits && !byPhone.has(digits)) {
|
||||
byPhone.set(digits, target);
|
||||
}
|
||||
}
|
||||
for (const value of [values.id, values.lid]) {
|
||||
const digits = extractLidDigits(value);
|
||||
if (digits && !byLid.has(digits)) {
|
||||
byLid.set(digits, target);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { byPhone, byLid };
|
||||
}
|
||||
|
||||
function shouldSkipMentionAt(
|
||||
text: string,
|
||||
index: number,
|
||||
codeRanges: readonly TextRange[],
|
||||
): boolean {
|
||||
if (isInRange(index, codeRanges)) {
|
||||
return true;
|
||||
}
|
||||
const previous = index > 0 ? text[index - 1] : "";
|
||||
return Boolean(previous && /[\w@]/.test(previous));
|
||||
}
|
||||
|
||||
export function resolveWhatsAppOutboundMentions(params: {
|
||||
chatJid: string;
|
||||
text: string;
|
||||
participants?: readonly WhatsAppOutboundMentionParticipant[];
|
||||
}): WhatsAppOutboundMentionResolution {
|
||||
if (
|
||||
!isWhatsAppGroupJid(params.chatJid) ||
|
||||
!mayContainWhatsAppOutboundMention(params.text) ||
|
||||
!params.participants?.length
|
||||
) {
|
||||
return { text: params.text, mentionedJids: [] };
|
||||
}
|
||||
|
||||
const { byPhone, byLid } = buildMentionTargetMaps(params.participants);
|
||||
if (byPhone.size === 0 && byLid.size === 0) {
|
||||
return { text: params.text, mentionedJids: [] };
|
||||
}
|
||||
|
||||
const codeRanges = collectCodeRanges(params.text);
|
||||
const replacements: Array<{ start: number; end: number; text: string }> = [];
|
||||
const mentionedJids: string[] = [];
|
||||
const seenMentionJids = new Set<string>();
|
||||
|
||||
for (const match of params.text.matchAll(OUTBOUND_MENTION_RE)) {
|
||||
const start = match.index;
|
||||
if (shouldSkipMentionAt(params.text, start, codeRanges)) {
|
||||
continue;
|
||||
}
|
||||
const token = match[0];
|
||||
const digits = match[1].replace(/\D/g, "");
|
||||
const target = token.startsWith("@+")
|
||||
? (byPhone.get(digits) ?? byLid.get(digits))
|
||||
: (byLid.get(digits) ?? byPhone.get(digits));
|
||||
if (!target) {
|
||||
continue;
|
||||
}
|
||||
if (!seenMentionJids.has(target.mentionJid)) {
|
||||
seenMentionJids.add(target.mentionJid);
|
||||
mentionedJids.push(target.mentionJid);
|
||||
}
|
||||
if (target.replacementText && target.replacementText !== token) {
|
||||
replacements.push({
|
||||
start,
|
||||
end: start + token.length,
|
||||
text: target.replacementText,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (replacements.length === 0) {
|
||||
return { text: params.text, mentionedJids };
|
||||
}
|
||||
|
||||
let text = "";
|
||||
let cursor = 0;
|
||||
for (const replacement of replacements) {
|
||||
text += params.text.slice(cursor, replacement.start);
|
||||
text += replacement.text;
|
||||
cursor = replacement.end;
|
||||
}
|
||||
text += params.text.slice(cursor);
|
||||
return { text, mentionedJids };
|
||||
}
|
||||
|
||||
export function addWhatsAppOutboundMentionsToContent(
|
||||
content: AnyMessageContent,
|
||||
mentionedJids: readonly string[],
|
||||
): AnyMessageContent {
|
||||
return mentionedJids.length > 0
|
||||
? ({ ...content, mentions: [...mentionedJids] } as AnyMessageContent)
|
||||
: content;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
WAMessage,
|
||||
} from "@whiskeysockets/baileys";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolveWhatsAppOutboundMentions } from "./outbound-mentions.js";
|
||||
import { createWebSendApi } from "./send-api.js";
|
||||
|
||||
const recordChannelActivity = vi.hoisted(() => vi.fn());
|
||||
@@ -86,6 +87,31 @@ describe("createWebSendApi", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("adds native mention metadata to group text sends", async () => {
|
||||
api = createWebSendApi({
|
||||
sock: { sendMessage, sendPresenceUpdate },
|
||||
defaultAccountId: "main",
|
||||
resolveOutboundMentions: ({ jid, text }) =>
|
||||
resolveWhatsAppOutboundMentions({
|
||||
chatJid: jid,
|
||||
text,
|
||||
participants: [
|
||||
{
|
||||
id: "277038292303944:4@lid",
|
||||
phoneNumber: "5511976136970@s.whatsapp.net",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
await api.sendMessage("120363000000000000@g.us", "ping @+5511976136970");
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith("120363000000000000@g.us", {
|
||||
text: "ping @277038292303944",
|
||||
mentions: ["277038292303944@lid"],
|
||||
});
|
||||
});
|
||||
|
||||
it("supports image media with caption", async () => {
|
||||
const payload = Buffer.from("img");
|
||||
await api.sendMessage("+1555", "cap", payload, "image/jpeg");
|
||||
@@ -99,6 +125,32 @@ describe("createWebSendApi", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("adds native mention metadata to group media captions", async () => {
|
||||
api = createWebSendApi({
|
||||
sock: { sendMessage, sendPresenceUpdate },
|
||||
defaultAccountId: "main",
|
||||
resolveOutboundMentions: ({ jid, text }) =>
|
||||
resolveWhatsAppOutboundMentions({
|
||||
chatJid: jid,
|
||||
text,
|
||||
participants: [{ id: "15551234567@s.whatsapp.net" }],
|
||||
}),
|
||||
});
|
||||
const payload = Buffer.from("img");
|
||||
|
||||
await api.sendMessage("120363000000000000@g.us", "cap @15551234567", payload, "image/jpeg");
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
"120363000000000000@g.us",
|
||||
expect.objectContaining({
|
||||
image: payload,
|
||||
caption: "cap @15551234567",
|
||||
mimetype: "image/jpeg",
|
||||
mentions: ["15551234567@s.whatsapp.net"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("supports audio as push-to-talk voice note", async () => {
|
||||
const payload = Buffer.from("aud");
|
||||
await api.sendMessage("+1555", "", payload, "audio/ogg", { accountId: "alt" });
|
||||
|
||||
@@ -8,6 +8,10 @@ import { recordChannelActivity } from "openclaw/plugin-sdk/channel-activity-runt
|
||||
import { isWhatsAppNewsletterJid } from "../normalize.js";
|
||||
import { buildQuotedMessageOptions } from "../quoted-message.js";
|
||||
import { toWhatsappJid } from "../text-runtime.js";
|
||||
import {
|
||||
addWhatsAppOutboundMentionsToContent,
|
||||
type WhatsAppOutboundMentionResolution,
|
||||
} from "./outbound-mentions.js";
|
||||
import {
|
||||
combineWhatsAppSendResults,
|
||||
normalizeWhatsAppSendResult,
|
||||
@@ -33,7 +37,19 @@ export function createWebSendApi(params: {
|
||||
sendPresenceUpdate: (presence: WAPresence, jid?: string) => Promise<unknown>;
|
||||
};
|
||||
defaultAccountId: string;
|
||||
resolveOutboundMentions?: (params: {
|
||||
jid: string;
|
||||
text: string;
|
||||
}) => Promise<WhatsAppOutboundMentionResolution> | WhatsAppOutboundMentionResolution;
|
||||
}) {
|
||||
const resolveMentions = async (
|
||||
jid: string,
|
||||
text: string,
|
||||
): Promise<WhatsAppOutboundMentionResolution> =>
|
||||
params.resolveOutboundMentions
|
||||
? await params.resolveOutboundMentions({ jid, text })
|
||||
: { text, mentionedJids: [] };
|
||||
|
||||
return {
|
||||
sendMessage: async (
|
||||
to: string,
|
||||
@@ -47,11 +63,17 @@ export function createWebSendApi(params: {
|
||||
if (mediaBuffer) {
|
||||
mediaType ??= "application/octet-stream";
|
||||
}
|
||||
const shouldSendAudioText = Boolean(
|
||||
mediaBuffer && mediaType?.startsWith("audio/") && text.trim(),
|
||||
);
|
||||
const resolvedPayloadText = shouldSendAudioText
|
||||
? { text, mentionedJids: [] }
|
||||
: await resolveMentions(jid, text);
|
||||
if (mediaBuffer && mediaType) {
|
||||
if (mediaType.startsWith("image/")) {
|
||||
payload = {
|
||||
image: mediaBuffer,
|
||||
caption: text || undefined,
|
||||
caption: resolvedPayloadText.text || undefined,
|
||||
mimetype: mediaType,
|
||||
};
|
||||
} else if (mediaType.startsWith("audio/")) {
|
||||
@@ -60,7 +82,7 @@ export function createWebSendApi(params: {
|
||||
const gifPlayback = sendOptions?.gifPlayback;
|
||||
payload = {
|
||||
video: mediaBuffer,
|
||||
caption: text || undefined,
|
||||
caption: resolvedPayloadText.text || undefined,
|
||||
mimetype: mediaType,
|
||||
...(gifPlayback ? { gifPlayback: true } : {}),
|
||||
};
|
||||
@@ -69,13 +91,14 @@ export function createWebSendApi(params: {
|
||||
payload = {
|
||||
document: mediaBuffer,
|
||||
fileName,
|
||||
caption: text || undefined,
|
||||
caption: resolvedPayloadText.text || undefined,
|
||||
mimetype: mediaType,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
payload = { text };
|
||||
payload = { text: resolvedPayloadText.text };
|
||||
}
|
||||
payload = addWhatsAppOutboundMentionsToContent(payload, resolvedPayloadText.mentionedJids);
|
||||
const quotedOpts = buildQuotedMessageOptions({
|
||||
messageId: sendOptions?.quotedMessageKey?.id,
|
||||
remoteJid: sendOptions?.quotedMessageKey?.remoteJid,
|
||||
@@ -87,8 +110,12 @@ export function createWebSendApi(params: {
|
||||
? await params.sock.sendMessage(jid, payload, quotedOpts)
|
||||
: await params.sock.sendMessage(jid, payload);
|
||||
const results = [normalizeWhatsAppSendResult(result, mediaBuffer ? "media" : "text")];
|
||||
if (mediaBuffer && mediaType?.startsWith("audio/") && text.trim()) {
|
||||
const textPayload: AnyMessageContent = { text };
|
||||
if (shouldSendAudioText) {
|
||||
const resolvedAudioText = await resolveMentions(jid, text);
|
||||
const textPayload = addWhatsAppOutboundMentionsToContent(
|
||||
{ text: resolvedAudioText.text },
|
||||
resolvedAudioText.mentionedJids,
|
||||
);
|
||||
const textResult = quotedOpts
|
||||
? await params.sock.sendMessage(jid, textPayload, quotedOpts)
|
||||
: await params.sock.sendMessage(jid, textPayload);
|
||||
|
||||
Reference in New Issue
Block a user