oclaw/runtime/scheduler/whatsapp_mentions.py
oliver 2446993b11 Fix WhatsApp overlap by serial queue, typing, and outbound delivery.
Serialize per-session inbound turns and merge follow-ups after the active run; show composing while busy and enqueue final replies so long agent runs still reach the group.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 10:04:25 +08:00

440 lines
14 KiB
Python

from __future__ import annotations
import json
import re
from typing import Any
from runtime.extensions.whatsapp.access_control import contact_phone_key
from runtime.extensions.whatsapp.api import normalize_whatsapp_target
def encode_whatsapp_outbound_source(
*,
kind: str = "scheduled_job",
mention_jids: list[str] | None = None,
mention_names: list[str] | None = None,
mention_text_ready: bool = False,
attachments: list[dict[str, Any]] | None = None,
media_path: str | None = None,
extra: dict[str, Any] | None = None,
) -> str:
payload: dict[str, Any] = {"kind": str(kind or "scheduled_job")}
if isinstance(extra, dict):
for key, value in extra.items():
k = str(key or "").strip()
if not k or k in payload:
continue
payload[k] = value
jids = normalize_whatsapp_mention_jids(mention_jids)
if jids:
payload["mention_jids"] = jids
names = [str(x or "").strip() for x in (mention_names or []) if str(x or "").strip()]
if names:
payload["mention_names"] = names
if mention_text_ready:
payload["mention_text_ready"] = True
atts = [a for a in (attachments or []) if isinstance(a, dict)]
if atts:
payload["attachments"] = atts
mp = str(media_path or "").strip()
if mp:
payload["media_path"] = mp
return json.dumps(payload, ensure_ascii=False)
def decode_whatsapp_outbound_source(raw: str) -> dict[str, Any]:
text = str(raw or "").strip()
if not text:
return {}
if text.startswith("{"):
try:
data = json.loads(text)
return data if isinstance(data, dict) else {}
except Exception:
return {}
return {"kind": text}
def normalize_whatsapp_mention_jids(raw: Any) -> list[str]:
items: list[str] = []
if isinstance(raw, str):
items = re.split(r"[\s,;]+", raw)
elif isinstance(raw, list):
for entry in raw:
if isinstance(entry, str):
items.extend(re.split(r"[\s,;]+", entry))
seen: set[str] = set()
out: list[str] = []
for item in items:
jid_raw = str(item or "").strip()
jid = jid_raw
low = jid_raw.lower()
if jid and low.endswith("@lid"):
jid = jid_raw
elif jid and ("@" not in jid_raw):
# Bare digits in group mentions are usually LID local parts, not phone numbers.
if re.fullmatch(r"\d{10,20}", jid_raw):
jid = f"{jid_raw}@lid"
else:
try:
jid = normalize_whatsapp_target(jid_raw)
except Exception:
jid = jid_raw
if not jid or jid in seen:
continue
seen.add(jid)
out.append(jid)
return out
def merge_whatsapp_mention_jids(delivery: dict[str, Any] | None, mention_jids: Any) -> dict[str, Any]:
merged = dict(delivery or {})
jids = normalize_whatsapp_mention_jids(mention_jids)
if not jids:
return merged
wa = merged.get("whatsapp") if isinstance(merged.get("whatsapp"), dict) else {}
wa = dict(wa)
wa["mention_jids"] = jids
merged["whatsapp"] = wa
return merged
def merge_whatsapp_mention_names(delivery: dict[str, Any] | None, mention_names: Any) -> dict[str, Any]:
merged = dict(delivery or {})
names = [str(x or "").strip() for x in (mention_names or []) if str(x or "").strip()]
if not names:
return merged
wa = merged.get("whatsapp") if isinstance(merged.get("whatsapp"), dict) else {}
wa = dict(wa)
wa["mention_names"] = names
merged["whatsapp"] = wa
return merged
def _normalize_digits(value: str) -> str:
return re.sub(r"\D", "", str(value or ""))
def _extract_phoneish_mentions(text: str) -> list[str]:
out: list[str] = []
for m in re.finditer(r"@([+\d][\d\s().-]{5,})", str(text or "")):
digits = _normalize_digits(m.group(1))
if len(digits) >= 6:
out.append(digits)
return out
def extract_whatsapp_mention_names(text: str) -> list[str]:
out: list[str] = []
# Keep it conservative: only plain @name tokens, stop at whitespace/punct.
for m in re.finditer(r"@([^\s@]{1,64})", str(text or "")):
token = str(m.group(1) or "").strip()
if not token:
continue
# Skip phone-ish patterns; handled separately.
if re.fullmatch(r"\+?\d[\d\s().-]{5,}", token):
continue
out.append(token)
# de-dupe while preserving order
seen: set[str] = set()
deduped: list[str] = []
for name in out:
if name not in seen:
seen.add(name)
deduped.append(name)
return deduped
def infer_whatsapp_mention_jids_from_names(
mention_names: list[str] | None,
*,
store: Any = None,
tenant_id: str = "",
account_id: str = "",
) -> list[str]:
names = [str(x or "").strip() for x in (mention_names or []) if str(x or "").strip()]
if not names or store is None:
return []
lister = getattr(store, "list_whatsapp_contacts", None)
if not callable(lister):
return []
try:
contacts = lister(tenant_id=str(tenant_id or ""), account_id=str(account_id or ""), limit=500)
except Exception:
return []
by_push: dict[str, list[str]] = {}
for row in contacts or []:
if not isinstance(row, dict):
continue
jid = str(row.get("external_user_id") or "").strip()
push = str(row.get("push_name") or "").strip()
if jid and push:
by_push.setdefault(push, []).append(jid)
out: list[str] = []
for name in names:
hits = by_push.get(name) or []
# Only accept unique match to avoid pinging wrong person.
if len(hits) == 1:
out.append(hits[0])
return out
def _phone_digits_from_jid(jid: str) -> str:
local = str(jid or "").split("@", 1)[0].strip()
return _normalize_digits(local)
def infer_whatsapp_mention_jids_from_text(
text: str,
*,
store: Any = None,
tenant_id: str = "",
account_id: str = "",
) -> list[str]:
if store is None:
return []
lister = getattr(store, "list_whatsapp_contacts", None)
if not callable(lister):
return []
phones = _extract_phoneish_mentions(text)
if not phones:
return []
try:
contacts = lister(tenant_id=str(tenant_id or ""), account_id=str(account_id or ""), limit=500)
except Exception:
return []
out: list[str] = []
seen: set[str] = set()
for phone in phones:
for row in contacts or []:
if not isinstance(row, dict):
continue
jid = str(row.get("external_user_id") or "").strip()
if not jid or jid in seen:
continue
key = _normalize_digits(contact_phone_key(row))
local = _phone_digits_from_jid(jid)
# Accept exact/suffix matches to handle country code and formatting noise.
if key and (key == phone or key.endswith(phone) or phone.endswith(key)):
seen.add(jid)
out.append(jid)
break
if local and (local == phone or local.endswith(phone) or phone.endswith(local)):
seen.add(jid)
out.append(jid)
break
return out
def _lookup_push_name(store: Any, *, tenant_id: str, account_id: str, jid: str) -> str:
getter = getattr(store, "get_whatsapp_contact", None)
if not callable(getter):
return ""
candidates = [str(jid or "").strip()]
local = str(jid or "").split("@", 1)[0].strip()
if local and f"{local}@lid" not in candidates:
candidates.append(f"{local}@lid")
if local and f"{local}@s.whatsapp.net" not in candidates:
candidates.append(f"{local}@s.whatsapp.net")
for candidate in candidates:
if not candidate:
continue
try:
row = getter(
tenant_id=str(tenant_id or ""),
account_id=str(account_id or ""),
external_user_id=candidate,
)
except Exception:
row = None
if isinstance(row, dict):
push = str(row.get("push_name") or "").strip()
if push:
return push
lister = getattr(store, "list_whatsapp_contacts", None)
if not callable(lister) or not local:
return ""
try:
contacts = lister(tenant_id=str(tenant_id or ""), account_id=str(account_id or ""), limit=500)
except Exception:
return ""
for row in contacts or []:
if not isinstance(row, dict):
continue
eid = str(row.get("external_user_id") or "").strip()
if not eid:
continue
elocal = eid.split("@", 1)[0].strip()
if elocal == local:
push = str(row.get("push_name") or "").strip()
if push:
return push
return ""
def mention_tag_for_jid(jid: str, *, push_name: str = "") -> str:
name = str(push_name or "").strip()
if name:
return f"@{name}"
local = str(jid or "").split("@", 1)[0].strip()
return f"@{local}" if local else ""
def resolve_scheduled_whatsapp_mention_targets(
*,
delivery: dict[str, Any],
reply_text: str,
store: Any,
tenant_id: str,
account_id: str,
) -> tuple[list[str], list[str] | None]:
"""Resolve @mention targets for scheduled WhatsApp delivery (creator by default)."""
from runtime.orchestration.group_ingest import _filter_non_bot_mention_jids, jids_same_user
from runtime.scheduler.job_delete import creator_from_delivery
wa = delivery.get("whatsapp") if isinstance(delivery.get("whatsapp"), dict) else {}
creator = creator_from_delivery(delivery)
bot_meta: dict[str, Any] = {}
bot_jid = str(wa.get("bot_jid") or "").strip()
bot_lid = str(wa.get("bot_lid") or "").strip()
if bot_jid:
bot_meta["bot_jid"] = bot_jid
if bot_lid:
bot_meta["bot_lid"] = bot_lid
explicit = _filter_non_bot_mention_jids(
normalize_whatsapp_mention_jids(wa.get("mention_jids")),
bot_jid=bot_jid or None,
metadata=bot_meta or None,
)
creator_jids = normalize_whatsapp_mention_jids([creator.get("external_user_id") or ""])
creator_jid = creator_jids[0] if creator_jids else ""
if explicit:
mention_jids = explicit
elif creator_jids:
mention_jids = creator_jids
else:
mention_jids = infer_whatsapp_mention_jids_from_text(
reply_text,
store=store,
tenant_id=tenant_id,
account_id=account_id,
)
mention_names = wa.get("mention_names") if isinstance(wa.get("mention_names"), list) else None
if mention_names is None and creator.get("push_name") and creator_jids:
if not mention_jids or (
creator_jid and len(mention_jids) == 1 and jids_same_user(mention_jids[0], creator_jid)
):
mention_names = [str(creator.get("push_name") or "").strip()]
if mention_names is None and mention_jids:
from runtime.scheduler.whatsapp_mentions import _lookup_push_name
derived_names: list[str] = []
for jid in mention_jids:
derived_names.append(
_lookup_push_name(
store,
tenant_id=tenant_id,
account_id=account_id,
jid=str(jid or ""),
)
)
if any(derived_names):
mention_names = derived_names
return mention_jids, mention_names
def finalize_whatsapp_scheduled_delivery(
delivery: dict[str, Any] | None,
*,
creator_external_user_id: str = "",
creator_push_name: str = "",
bot_jid: str = "",
bot_lid: str = "",
) -> dict[str, Any]:
"""Store bot identity for delivery filtering and default @mention to the job creator."""
from runtime.orchestration.group_ingest import _filter_non_bot_mention_jids
out = dict(delivery or {})
wa = out.get("whatsapp")
if not isinstance(wa, dict) or not wa.get("enabled"):
return out
wa = dict(wa)
bot = str(bot_jid or "").strip()
lid = str(bot_lid or "").strip()
if bot:
wa["bot_jid"] = bot
if lid:
wa["bot_lid"] = lid
meta: dict[str, Any] = {}
if bot:
meta["bot_jid"] = bot
if lid:
meta["bot_lid"] = lid
jids = _filter_non_bot_mention_jids(
normalize_whatsapp_mention_jids(wa.get("mention_jids")),
bot_jid=bot or None,
metadata=meta or None,
)
creator_ext = str(creator_external_user_id or "").strip()
if not jids and creator_ext:
jids = normalize_whatsapp_mention_jids([creator_ext])
pname = str(creator_push_name or "").strip()
if pname:
wa["mention_names"] = [pname]
if jids:
wa["mention_jids"] = jids
out["whatsapp"] = wa
return out
def format_whatsapp_mention_text(
text: str,
mention_jids: list[str],
*,
store: Any = None,
tenant_id: str = "",
account_id: str = "",
mention_names: list[str] | None = None,
) -> str:
"""Align outbound text with explicit mention JIDs; visible @ uses display name when known."""
body = str(text or "").strip()
jids = normalize_whatsapp_mention_jids(mention_jids)
if not body or not jids:
return body
names = [str(x or "").strip() for x in (mention_names or [])]
tags: list[str] = []
for idx, jid in enumerate(jids):
push_name = names[idx] if idx < len(names) and names[idx] else ""
if not push_name and store is not None:
push_name = _lookup_push_name(store, tenant_id=tenant_id, account_id=account_id, jid=jid)
tag = mention_tag_for_jid(jid, push_name=push_name)
if tag:
tags.append(tag)
if not tags:
return body
if any(tag in body for tag in tags):
return body
cleaned = re.sub(r"@\+?\d+(?:\s+\d+)*\s*", "", body)
cleaned = re.sub(r"@\S+\s*", "", cleaned)
cleaned = re.sub(r"\s{2,}", " ", cleaned).strip()
prefix = " ".join(tags) + " "
return f"{prefix}{cleaned}" if cleaned else prefix.strip()
__all__ = [
"decode_whatsapp_outbound_source",
"encode_whatsapp_outbound_source",
"extract_whatsapp_mention_names",
"finalize_whatsapp_scheduled_delivery",
"resolve_scheduled_whatsapp_mention_targets",
"format_whatsapp_mention_text",
"infer_whatsapp_mention_jids_from_names",
"infer_whatsapp_mention_jids_from_text",
"mention_tag_for_jid",
"merge_whatsapp_mention_jids",
"merge_whatsapp_mention_names",
"normalize_whatsapp_mention_jids",
]