mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-14 16:30:47 +08:00
fix(whatsapp): restore group @ replies with LID matching and send fallback
Match bot mentions across JID/LID formats, accept reply-to-bot triggers, keep raw quote participants, and fall back to plain text when quoted sends fail. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
a218139da6
commit
9657b313a3
4 changed files with 171 additions and 39 deletions
|
|
@ -707,6 +707,7 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
bot_jid=bot_jid,
|
bot_jid=bot_jid,
|
||||||
require_mention=group_policy.require_mention,
|
require_mention=group_policy.require_mention,
|
||||||
triggers=list(group_policy.triggers),
|
triggers=list(group_policy.triggers),
|
||||||
|
metadata=inbound.metadata if isinstance(inbound.metadata, dict) else {},
|
||||||
):
|
):
|
||||||
return {"ok": True, "replies": []}
|
return {"ok": True, "replies": []}
|
||||||
reply = ""
|
reply = ""
|
||||||
|
|
|
||||||
|
|
@ -45,34 +45,74 @@ function ensureDir(dir: string): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickText(m: proto.IMessage | null | undefined): string {
|
function pickText(m: proto.IMessage | null | undefined): string {
|
||||||
if (!m) return "";
|
const u = unwrapMessage(m);
|
||||||
const c = (m.conversation || "").trim();
|
if (!u) return "";
|
||||||
|
const c = (u.conversation || "").trim();
|
||||||
if (c) return c;
|
if (c) return c;
|
||||||
const ext = (m.extendedTextMessage?.text || "").trim();
|
const ext = (u.extendedTextMessage?.text || "").trim();
|
||||||
if (ext) return ext;
|
if (ext) return ext;
|
||||||
const imgCap = (m.imageMessage?.caption || "").trim();
|
const imgCap = (u.imageMessage?.caption || "").trim();
|
||||||
if (imgCap) return imgCap;
|
if (imgCap) return imgCap;
|
||||||
const vidCap = (m.videoMessage?.caption || "").trim();
|
const vidCap = (u.videoMessage?.caption || "").trim();
|
||||||
if (vidCap) return vidCap;
|
if (vidCap) return vidCap;
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function isStatusOrBroadcastJid(jid: string): boolean {
|
function unwrapMessage(m: proto.IMessage | null | undefined): proto.IMessage | null {
|
||||||
const low = String(jid || "").toLowerCase();
|
if (!m) return null;
|
||||||
return low === "status@broadcast" || low.endsWith("@broadcast");
|
const nested =
|
||||||
|
m.ephemeralMessage?.message ||
|
||||||
|
m.viewOnceMessage?.message ||
|
||||||
|
m.viewOnceMessageV2?.message ||
|
||||||
|
m.documentWithCaptionMessage?.message ||
|
||||||
|
m.editedMessage?.message ||
|
||||||
|
null;
|
||||||
|
if (nested) return unwrapMessage(nested);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageContextInfo(m: proto.IMessage | null | undefined): proto.IContextInfo | null | undefined {
|
||||||
|
const u = unwrapMessage(m);
|
||||||
|
if (!u) return null;
|
||||||
|
return (
|
||||||
|
u.extendedTextMessage?.contextInfo ||
|
||||||
|
u.imageMessage?.contextInfo ||
|
||||||
|
u.videoMessage?.contextInfo ||
|
||||||
|
u.documentMessage?.contextInfo ||
|
||||||
|
u.buttonsResponseMessage?.contextInfo ||
|
||||||
|
u.listResponseMessage?.contextInfo ||
|
||||||
|
u.templateButtonReplyMessage?.contextInfo ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function jidPhone(jid: string): string {
|
||||||
|
const head = String(jid || "").split("@")[0]?.split(":")[0] || "";
|
||||||
|
return head.replace(/\D/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function jidsSameUser(a: string, b: string): boolean {
|
||||||
|
const na = jidNormalizedUser(String(a || "").trim());
|
||||||
|
const nb = jidNormalizedUser(String(b || "").trim());
|
||||||
|
if (na && nb && na === nb) return true;
|
||||||
|
const pa = jidPhone(a);
|
||||||
|
const pb = jidPhone(b);
|
||||||
|
return pa.length >= 6 && pa === pb;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractMentions(m: proto.IMessage | null | undefined): string[] {
|
function extractMentions(m: proto.IMessage | null | undefined): string[] {
|
||||||
if (!m) return [];
|
const ctx = messageContextInfo(m);
|
||||||
const ctx =
|
|
||||||
m.extendedTextMessage?.contextInfo ||
|
|
||||||
m.imageMessage?.contextInfo ||
|
|
||||||
m.videoMessage?.contextInfo ||
|
|
||||||
m.documentMessage?.contextInfo ||
|
|
||||||
null;
|
|
||||||
const raw = ctx?.mentionedJid;
|
const raw = ctx?.mentionedJid;
|
||||||
if (!Array.isArray(raw)) return [];
|
if (!Array.isArray(raw)) return [];
|
||||||
return raw.map((j) => jidNormalizedUser(String(j || "").trim())).filter(Boolean);
|
return raw.map((j) => String(j || "").trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractQuoteContext(m: proto.IMessage | null | undefined): { participant: string; stanzaId: string } {
|
||||||
|
const ctx = messageContextInfo(m);
|
||||||
|
return {
|
||||||
|
participant: String(ctx?.participant || "").trim(),
|
||||||
|
stanzaId: String(ctx?.stanzaId || "").trim(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldProcessGroupMessage(params: {
|
function shouldProcessGroupMessage(params: {
|
||||||
|
|
@ -80,16 +120,18 @@ function shouldProcessGroupMessage(params: {
|
||||||
text: string;
|
text: string;
|
||||||
mentions: string[];
|
mentions: string[];
|
||||||
botJid: string;
|
botJid: string;
|
||||||
|
quotedParticipant?: string;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
if (!params.isGroup) return true;
|
if (!params.isGroup) return true;
|
||||||
const bot = jidNormalizedUser(params.botJid || "");
|
const bot = String(params.botJid || "").trim();
|
||||||
|
if (bot && params.quotedParticipant && jidsSameUser(bot, params.quotedParticipant)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (bot) {
|
if (bot) {
|
||||||
const botUser = bot.split("@")[0] || "";
|
|
||||||
for (const m of params.mentions || []) {
|
for (const m of params.mentions || []) {
|
||||||
const norm = jidNormalizedUser(String(m || "").trim());
|
const raw = String(m || "").trim();
|
||||||
if (!norm) continue;
|
if (!raw) continue;
|
||||||
if (norm === bot) return true;
|
if (jidsSameUser(raw, bot)) return true;
|
||||||
if (botUser && norm.split("@")[0] === botUser) return true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const body = String(params.text || "");
|
const body = String(params.text || "");
|
||||||
|
|
@ -99,6 +141,11 @@ function shouldProcessGroupMessage(params: {
|
||||||
return !GROUP_REQUIRE_MENTION;
|
return !GROUP_REQUIRE_MENTION;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isStatusOrBroadcastJid(jid: string): boolean {
|
||||||
|
const low = String(jid || "").toLowerCase();
|
||||||
|
return low === "status@broadcast" || low.endsWith("@broadcast");
|
||||||
|
}
|
||||||
|
|
||||||
function buildInboundPayload(params: {
|
function buildInboundPayload(params: {
|
||||||
chatId: string;
|
chatId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
|
@ -155,9 +202,9 @@ function readMentionJids(meta: Json): string[] {
|
||||||
const raw = (meta as any).mention_jids ?? (meta as any).mentionJids;
|
const raw = (meta as any).mention_jids ?? (meta as any).mentionJids;
|
||||||
if (!Array.isArray(raw)) {
|
if (!Array.isArray(raw)) {
|
||||||
const single = String((meta as any).reply_to_user_id || (meta as any).replyToUserId || "").trim();
|
const single = String((meta as any).reply_to_user_id || (meta as any).replyToUserId || "").trim();
|
||||||
return single ? [jidNormalizedUser(single)] : [];
|
return single ? [single] : [];
|
||||||
}
|
}
|
||||||
return raw.map((j) => jidNormalizedUser(String(j || "").trim())).filter(Boolean);
|
return raw.map((j) => String(j || "").trim()).filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMentionPrefix(mentionJids: string[]): string {
|
function buildMentionPrefix(mentionJids: string[]): string {
|
||||||
|
|
@ -179,7 +226,7 @@ function buildQuotedMessage(params: {
|
||||||
const stanzaId = String(params.stanzaId || "").trim();
|
const stanzaId = String(params.stanzaId || "").trim();
|
||||||
const chatId = String(params.chatId || "").trim();
|
const chatId = String(params.chatId || "").trim();
|
||||||
if (!stanzaId || !chatId) return undefined;
|
if (!stanzaId || !chatId) return undefined;
|
||||||
const participant = jidNormalizedUser(String(params.participant || "").trim());
|
const participant = String(params.participant || "").trim();
|
||||||
const quoteText = String(params.quoteText || "").trim() || "...";
|
const quoteText = String(params.quoteText || "").trim() || "...";
|
||||||
return {
|
return {
|
||||||
key: {
|
key: {
|
||||||
|
|
@ -270,11 +317,16 @@ async function sendReplyWithAttachments(params: {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (outText) {
|
if (outText) {
|
||||||
await s.sendMessage(
|
try {
|
||||||
params.deliverTo,
|
await s.sendMessage(
|
||||||
textOpts.content as any,
|
params.deliverTo,
|
||||||
textOpts.quoted ? { quoted: textOpts.quoted } : undefined,
|
textOpts.content as any,
|
||||||
);
|
textOpts.quoted ? { quoted: textOpts.quoted } : undefined,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
log(`send reply failed (${String(err)}); retry plain text`);
|
||||||
|
await s.sendMessage(params.deliverTo, { text: outText });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -457,14 +509,18 @@ async function main(): Promise<void> {
|
||||||
const userId = from ? jidNormalizedUser(from) : jidNormalizedUser(remoteJid);
|
const userId = from ? jidNormalizedUser(from) : jidNormalizedUser(remoteJid);
|
||||||
const chatId = jidNormalizedUser(remoteJid);
|
const chatId = jidNormalizedUser(remoteJid);
|
||||||
const mentions = extractMentions(msg.message);
|
const mentions = extractMentions(msg.message);
|
||||||
const botJid = sock?.user?.id ? jidNormalizedUser(sock.user.id) : "";
|
const quote = extractQuoteContext(msg.message);
|
||||||
|
const botJidRaw = sock?.user?.id ? String(sock.user.id).trim() : "";
|
||||||
|
const botJid = botJidRaw ? jidNormalizedUser(botJidRaw) : "";
|
||||||
|
const isReplyToBot = Boolean(botJidRaw && quote.participant && jidsSameUser(botJidRaw, quote.participant));
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!shouldProcessGroupMessage({
|
!shouldProcessGroupMessage({
|
||||||
isGroup,
|
isGroup,
|
||||||
text,
|
text,
|
||||||
mentions,
|
mentions,
|
||||||
botJid,
|
botJid: botJidRaw,
|
||||||
|
quotedParticipant: quote.participant,
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
if (VERBOSE) log(`skip group message chat=${chatId} user=${userId} (no mention/trigger)`);
|
if (VERBOSE) log(`skip group message chat=${chatId} user=${userId} (no mention/trigger)`);
|
||||||
|
|
@ -479,6 +535,9 @@ async function main(): Promise<void> {
|
||||||
participant: key.participant || null,
|
participant: key.participant || null,
|
||||||
pushName: (msg as any).pushName || null,
|
pushName: (msg as any).pushName || null,
|
||||||
messageTimestamp: (msg as any).messageTimestamp || null,
|
messageTimestamp: (msg as any).messageTimestamp || null,
|
||||||
|
quotedParticipant: quote.participant || null,
|
||||||
|
quotedStanzaId: quote.stanzaId || null,
|
||||||
|
isReplyToBot,
|
||||||
};
|
};
|
||||||
|
|
||||||
const inbound = buildInboundPayload({
|
const inbound = buildInboundPayload({
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,44 @@ def normalize_jid(jid: str) -> str:
|
||||||
return s.split(":")[0]
|
return s.split(":")[0]
|
||||||
|
|
||||||
|
|
||||||
|
def jid_phone(jid: str) -> str:
|
||||||
|
head = str(jid or "").strip().split("@", 1)[0]
|
||||||
|
head = head.split(":", 1)[0]
|
||||||
|
return re.sub(r"\D", "", head)
|
||||||
|
|
||||||
|
|
||||||
|
def jids_same_user(a: str, b: str) -> bool:
|
||||||
|
na = normalize_jid(a)
|
||||||
|
nb = normalize_jid(b)
|
||||||
|
if na and nb and na == nb:
|
||||||
|
return True
|
||||||
|
pa = jid_phone(a)
|
||||||
|
pb = jid_phone(b)
|
||||||
|
return len(pa) >= 6 and pa == pb
|
||||||
|
|
||||||
|
|
||||||
|
def _metadata_raw(metadata: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
|
if not isinstance(metadata, dict):
|
||||||
|
return {}
|
||||||
|
raw = metadata.get("raw")
|
||||||
|
return raw if isinstance(raw, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def is_reply_to_bot(*, metadata: dict[str, Any] | None, bot_jid: str | None) -> bool:
|
||||||
|
raw = _metadata_raw(metadata)
|
||||||
|
if raw.get("isReplyToBot") is True or raw.get("is_reply_to_bot") is True:
|
||||||
|
return True
|
||||||
|
quoted = ""
|
||||||
|
if isinstance(metadata, dict):
|
||||||
|
quoted = str(metadata.get("quoted_participant") or "").strip()
|
||||||
|
if not quoted:
|
||||||
|
quoted = str(raw.get("quotedParticipant") or raw.get("quoted_participant") or "").strip()
|
||||||
|
bot = str(bot_jid or "").strip()
|
||||||
|
if quoted and bot and jids_same_user(quoted, bot):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def normalize_jids(jids: list[str]) -> set[str]:
|
def normalize_jids(jids: list[str]) -> set[str]:
|
||||||
out: set[str] = set()
|
out: set[str] = set()
|
||||||
for raw in jids or []:
|
for raw in jids or []:
|
||||||
|
|
@ -127,9 +165,12 @@ def should_process_group_inbound(
|
||||||
bot_jid: str | None,
|
bot_jid: str | None,
|
||||||
require_mention: bool = True,
|
require_mention: bool = True,
|
||||||
triggers: list[str] | tuple[str, ...] | None = None,
|
triggers: list[str] | tuple[str, ...] | None = None,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
if not is_group:
|
if not is_group:
|
||||||
return True
|
return True
|
||||||
|
if is_reply_to_bot(metadata=metadata, bot_jid=bot_jid):
|
||||||
|
return True
|
||||||
mention_set = normalize_jids(list(mentions or []))
|
mention_set = normalize_jids(list(mentions or []))
|
||||||
bot = normalize_jid(str(bot_jid or ""))
|
bot = normalize_jid(str(bot_jid or ""))
|
||||||
if bot and bot in mention_set:
|
if bot and bot in mention_set:
|
||||||
|
|
@ -140,6 +181,10 @@ def should_process_group_inbound(
|
||||||
for m in mention_set:
|
for m in mention_set:
|
||||||
if "@" in m and m.split("@", 1)[0] == bot_user:
|
if "@" in m and m.split("@", 1)[0] == bot_user:
|
||||||
return True
|
return True
|
||||||
|
if bot:
|
||||||
|
for m in list(mentions or []):
|
||||||
|
if jids_same_user(str(m or ""), bot):
|
||||||
|
return True
|
||||||
trigger_list = [str(t) for t in (triggers or []) if str(t)]
|
trigger_list = [str(t) for t in (triggers or []) if str(t)]
|
||||||
body = str(text or "")
|
body = str(text or "")
|
||||||
if trigger_list and any(t in body for t in trigger_list):
|
if trigger_list and any(t in body for t in trigger_list):
|
||||||
|
|
@ -165,19 +210,19 @@ def build_whatsapp_group_reply_metadata(
|
||||||
"""Outbound hints for WhatsApp sidecar: @ sender + quote original message."""
|
"""Outbound hints for WhatsApp sidecar: @ sender + quote original message."""
|
||||||
meta = inbound.metadata if isinstance(getattr(inbound, "metadata", None), dict) else {}
|
meta = inbound.metadata if isinstance(getattr(inbound, "metadata", None), dict) else {}
|
||||||
raw = meta.get("raw") if isinstance(meta.get("raw"), dict) else {}
|
raw = meta.get("raw") if isinstance(meta.get("raw"), dict) else {}
|
||||||
sender_jid = normalize_jid(str(getattr(inbound, "external_user_id", "") or ""))
|
sender_jid = str(getattr(inbound, "external_user_id", "") or "").strip()
|
||||||
chat_id = str(getattr(inbound, "external_chat_id", "") or "").strip()
|
chat_id = str(getattr(inbound, "external_chat_id", "") or "").strip()
|
||||||
|
participant_raw = str(raw.get("participant") or sender_jid or "").strip()
|
||||||
stanza_id = str(raw.get("id") or meta.get("message_id") or "").strip()
|
stanza_id = str(raw.get("id") or meta.get("message_id") or "").strip()
|
||||||
participant = normalize_jid(str(raw.get("participant") or sender_jid or ""))
|
|
||||||
quote_text = str(getattr(inbound, "text", "") or "").strip()
|
quote_text = str(getattr(inbound, "text", "") or "").strip()
|
||||||
push_name = str(raw.get("pushName") or meta.get("push_name") or "").strip()
|
push_name = str(raw.get("pushName") or meta.get("push_name") or "").strip()
|
||||||
out: dict[str, Any] = {
|
out: dict[str, Any] = {
|
||||||
"is_group": True,
|
"is_group": True,
|
||||||
"reply_to_user_id": sender_jid,
|
"reply_to_user_id": participant_raw or normalize_jid(sender_jid),
|
||||||
"mention_jids": [sender_jid] if sender_jid else [],
|
"mention_jids": [participant_raw] if participant_raw else ([normalize_jid(sender_jid)] if sender_jid else []),
|
||||||
"quote_remote_jid": chat_id,
|
"quote_remote_jid": chat_id,
|
||||||
"quote_stanza_id": stanza_id,
|
"quote_stanza_id": stanza_id,
|
||||||
"quote_participant": participant,
|
"quote_participant": participant_raw or normalize_jid(str(raw.get("participant") or sender_jid or "")),
|
||||||
"quote_text": quote_text,
|
"quote_text": quote_text,
|
||||||
}
|
}
|
||||||
if push_name:
|
if push_name:
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,33 @@ def test_should_accept_group_when_bot_mentioned() -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_accept_group_mention_with_lid_phone_match() -> None:
|
||||||
|
assert (
|
||||||
|
should_process_group_inbound(
|
||||||
|
is_group=True,
|
||||||
|
text="@bot hello",
|
||||||
|
mentions=["999@lid"],
|
||||||
|
bot_jid="999@s.whatsapp.net",
|
||||||
|
require_mention=True,
|
||||||
|
)
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_accept_group_reply_to_bot() -> None:
|
||||||
|
assert (
|
||||||
|
should_process_group_inbound(
|
||||||
|
is_group=True,
|
||||||
|
text="follow up",
|
||||||
|
mentions=[],
|
||||||
|
bot_jid="999@s.whatsapp.net",
|
||||||
|
require_mention=True,
|
||||||
|
metadata={"raw": {"quotedParticipant": "999:0@s.whatsapp.net", "isReplyToBot": True}},
|
||||||
|
)
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_should_accept_group_trigger_without_mention() -> None:
|
def test_should_accept_group_trigger_without_mention() -> None:
|
||||||
assert (
|
assert (
|
||||||
should_process_group_inbound(
|
should_process_group_inbound(
|
||||||
|
|
@ -207,9 +234,9 @@ def test_build_whatsapp_group_reply_metadata() -> None:
|
||||||
)
|
)
|
||||||
meta = build_whatsapp_group_reply_metadata(inbound=inbound)
|
meta = build_whatsapp_group_reply_metadata(inbound=inbound)
|
||||||
assert meta["quote_stanza_id"] == "MSG123"
|
assert meta["quote_stanza_id"] == "MSG123"
|
||||||
assert meta["mention_jids"] == ["111@s.whatsapp.net"]
|
assert meta["mention_jids"] == ["111:12@s.whatsapp.net"]
|
||||||
assert meta["quote_text"] == "明天几点?"
|
assert meta["quote_text"] == "明天几点?"
|
||||||
assert meta["quote_participant"] == "111@s.whatsapp.net"
|
assert meta["quote_participant"] == "111:12@s.whatsapp.net"
|
||||||
|
|
||||||
|
|
||||||
def test_shared_group_session_for_multiple_senders(fresh_sqlite_store: SqliteStore) -> None:
|
def test_shared_group_session_for_multiple_senders(fresh_sqlite_store: SqliteStore) -> None:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue