mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-14 00:43:28 +08:00
feat(whatsapp): real group @mentions and cleaner inbound text
Wire mention JIDs/names through scheduled delivery and immediate replies (including attachment captions), sanitize group message text for the model, and surface channel-derived sessions in admin Chat. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
cbe68a54cd
commit
ad5d1db3c6
19 changed files with 1507 additions and 58 deletions
|
|
@ -386,9 +386,10 @@ def _resolve_chat_session(store: SqliteStore, ctx: dict[str, Any], session_id: s
|
||||||
user_id = str(ctx.get("user_id") or "")
|
user_id = str(ctx.get("user_id") or "")
|
||||||
sid = str(session_id or "").strip()
|
sid = str(session_id or "").strip()
|
||||||
if _is_administrator_chat_viewer(ctx):
|
if _is_administrator_chat_viewer(ctx):
|
||||||
sess = store.get_session_for_administrator_username(
|
sess = store.get_session_for_administrator_chat_view(
|
||||||
session_id=sid,
|
session_id=sid,
|
||||||
username=_chat_username(ctx),
|
username=_chat_username(ctx),
|
||||||
|
tenant_id=tenant_id,
|
||||||
)
|
)
|
||||||
if sess is not None:
|
if sess is not None:
|
||||||
return sess
|
return sess
|
||||||
|
|
@ -891,9 +892,11 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
|
||||||
user_id = str(ctx.get("user_id") or "")
|
user_id = str(ctx.get("user_id") or "")
|
||||||
if _is_administrator_chat_viewer(ctx):
|
if _is_administrator_chat_viewer(ctx):
|
||||||
uname = _chat_username(ctx)
|
uname = _chat_username(ctx)
|
||||||
meta = store.get_sessions_list_meta_for_administrator_username(username=uname)
|
meta = store.get_sessions_list_meta_for_administrator_chat_view(
|
||||||
rows = store.list_sessions_for_administrator_username(
|
username=uname, tenant_id=tenant_id
|
||||||
username=uname, limit=limit, offset=offset
|
)
|
||||||
|
rows = store.list_sessions_for_administrator_chat_view(
|
||||||
|
username=uname, tenant_id=tenant_id, limit=limit, offset=offset
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
meta = store.get_sessions_list_meta_for_user(tenant_id=tenant_id, user_id=user_id)
|
meta = store.get_sessions_list_meta_for_user(tenant_id=tenant_id, user_id=user_id)
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,7 @@ def run_attempt(*, store: Any, data: AttemptRunnerInput) -> AttemptRunnerOutput:
|
||||||
tools=data.tools,
|
tools=data.tools,
|
||||||
user_text=data.msg.text,
|
user_text=data.msg.text,
|
||||||
attachments=data.msg.attachments,
|
attachments=data.msg.attachments,
|
||||||
|
inbound_metadata=data.msg.metadata if isinstance(data.msg.metadata, dict) else None,
|
||||||
trace_id=data.trace_id,
|
trace_id=data.trace_id,
|
||||||
parent_span_id=data.parent_span_id,
|
parent_span_id=data.parent_span_id,
|
||||||
run_id=data.run_id,
|
run_id=data.run_id,
|
||||||
|
|
|
||||||
57
runtime/application/gateway/channel_session_owner.py
Normal file
57
runtime/application/gateway/channel_session_owner.py
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_channel_account_ui_owner(
|
||||||
|
store: Any,
|
||||||
|
*,
|
||||||
|
channel: str,
|
||||||
|
account_id: str,
|
||||||
|
tenant_id: str,
|
||||||
|
fallback_user_id: str = "",
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
"""UI list owner for channel-derived sessions: the oclaw user who owns the channel account."""
|
||||||
|
acct = store.find_user_by_channel_account(channel=str(channel or ""), account_id=str(account_id or "")) or {}
|
||||||
|
owner_tid = str(acct.get("tenant_id") or tenant_id or "").strip()
|
||||||
|
owner_uid = str(acct.get("user_id") or "").strip()
|
||||||
|
if owner_uid:
|
||||||
|
return owner_tid, owner_uid
|
||||||
|
admin = store.get_user_by_username(tenant_id=owner_tid or tenant_id, username="administrator")
|
||||||
|
if isinstance(admin, dict):
|
||||||
|
owner_uid = str(admin.get("id") or "").strip()
|
||||||
|
owner_tid = str(admin.get("tenant_id") or owner_tid or tenant_id).strip()
|
||||||
|
if owner_uid:
|
||||||
|
return owner_tid, owner_uid
|
||||||
|
return str(tenant_id or "").strip(), str(fallback_user_id or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def assign_channel_session_to_account_owner(
|
||||||
|
store: Any,
|
||||||
|
*,
|
||||||
|
session_id: str,
|
||||||
|
channel: str,
|
||||||
|
account_id: str,
|
||||||
|
tenant_id: str,
|
||||||
|
fallback_user_id: str = "",
|
||||||
|
) -> None:
|
||||||
|
owner_tid, owner_uid = resolve_channel_account_ui_owner(
|
||||||
|
store,
|
||||||
|
channel=channel,
|
||||||
|
account_id=account_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
fallback_user_id=fallback_user_id,
|
||||||
|
)
|
||||||
|
if not owner_tid or not owner_uid:
|
||||||
|
return
|
||||||
|
setter = getattr(store, "set_ui_session_owner", None)
|
||||||
|
if callable(setter):
|
||||||
|
setter(session_id=str(session_id), tenant_id=owner_tid, user_id=owner_uid)
|
||||||
|
return
|
||||||
|
store.ensure_ui_session_owner(session_id=str(session_id), tenant_id=owner_tid, user_id=owner_uid)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"assign_channel_session_to_account_owner",
|
||||||
|
"resolve_channel_account_ui_owner",
|
||||||
|
]
|
||||||
|
|
@ -825,14 +825,13 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
|
||||||
account = store.find_user_by_channel_account(channel=inbound.channel, account_id=account_id) or {}
|
account = store.find_user_by_channel_account(channel=inbound.channel, account_id=account_id) or {}
|
||||||
from runtime.orchestration.group_ingest import (
|
from runtime.orchestration.group_ingest import (
|
||||||
build_group_focus_instruction,
|
|
||||||
build_group_quoted_context_block,
|
build_group_quoted_context_block,
|
||||||
build_group_sender_context,
|
|
||||||
enrich_alert_group_question,
|
enrich_alert_group_question,
|
||||||
extract_group_quoted_message,
|
extract_group_quoted_message,
|
||||||
extract_quoted_ume_alert_text,
|
extract_quoted_ume_alert_text,
|
||||||
mentions_include_bot,
|
mentions_include_bot,
|
||||||
metadata_mentions_bot,
|
metadata_mentions_bot,
|
||||||
|
prepare_group_user_text_for_model,
|
||||||
resolve_group_policy,
|
resolve_group_policy,
|
||||||
session_user_key,
|
session_user_key,
|
||||||
should_inject_quoted_context,
|
should_inject_quoted_context,
|
||||||
|
|
@ -965,7 +964,18 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
channel_session_id = str(session_id)
|
channel_session_id = str(session_id)
|
||||||
store.ensure_ui_session_owner(session_id=session_id, tenant_id=tenant_id, user_id=user_id)
|
from runtime.application.gateway.channel_session_owner import (
|
||||||
|
assign_channel_session_to_account_owner,
|
||||||
|
)
|
||||||
|
|
||||||
|
assign_channel_session_to_account_owner(
|
||||||
|
store,
|
||||||
|
session_id=session_id,
|
||||||
|
channel=str(inbound.channel or ""),
|
||||||
|
account_id=account_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
fallback_user_id=user_id,
|
||||||
|
)
|
||||||
if str(inbound.channel or "").strip().lower() in {"wechat", "weixin"}:
|
if str(inbound.channel or "").strip().lower() in {"wechat", "weixin"}:
|
||||||
from runtime.scheduler.channel_delivery import (
|
from runtime.scheduler.channel_delivery import (
|
||||||
extract_context_token_from_inbound_metadata,
|
extract_context_token_from_inbound_metadata,
|
||||||
|
|
@ -1021,26 +1031,90 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
if cmd_reply is not None:
|
if cmd_reply is not None:
|
||||||
reply = cmd_reply
|
reply = cmd_reply
|
||||||
elif not reply:
|
elif not reply:
|
||||||
user_text = (inbound.text or "").strip()
|
raw_user_text = (inbound.text or "").strip()
|
||||||
|
user_text = raw_user_text
|
||||||
|
meta_for_inbound = inbound.metadata if isinstance(inbound.metadata, dict) else {}
|
||||||
|
mention_jids_for_ctx: list[str] = []
|
||||||
|
seen_mention_jids: set[str] = set()
|
||||||
|
for src in (list(inbound.mentions or []), meta_for_inbound.get("mentioned_jids") or []):
|
||||||
|
if not isinstance(src, list):
|
||||||
|
continue
|
||||||
|
for item in src:
|
||||||
|
jid = str(item or "").strip()
|
||||||
|
if jid and jid not in seen_mention_jids:
|
||||||
|
seen_mention_jids.add(jid)
|
||||||
|
mention_jids_for_ctx.append(jid)
|
||||||
|
bot_jid_for_filter = str(meta_for_inbound.get("bot_jid") or bot_jid or "").strip().lower()
|
||||||
|
bot_lid_for_filter = str(meta_for_inbound.get("bot_lid") or "").strip().lower()
|
||||||
|
filtered_mention_jids: list[str] = []
|
||||||
|
for jid in mention_jids_for_ctx:
|
||||||
|
low = jid.lower()
|
||||||
|
if (bot_jid_for_filter and low == bot_jid_for_filter) or (
|
||||||
|
bot_lid_for_filter and low == bot_lid_for_filter
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
filtered_mention_jids.append(jid)
|
||||||
|
mention_names_for_ctx: list[str] = []
|
||||||
|
if str(inbound.channel or "").strip().lower() == "whatsapp" and filtered_mention_jids:
|
||||||
|
from runtime.orchestration.group_ingest import _bot_identity_jids, jid_base_local, jid_phone
|
||||||
|
from runtime.scheduler.whatsapp_mentions import (
|
||||||
|
_lookup_push_name,
|
||||||
|
extract_whatsapp_mention_names,
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_names = extract_whatsapp_mention_names(raw_user_text)
|
||||||
|
bot_name_hints = {
|
||||||
|
str(meta_for_inbound.get("bot_push_name") or "").strip().lower(),
|
||||||
|
"oliver",
|
||||||
|
}
|
||||||
|
for bid in _bot_identity_jids(
|
||||||
|
bot_jid=bot_jid, metadata=meta_for_inbound
|
||||||
|
):
|
||||||
|
local = jid_base_local(bid)
|
||||||
|
if local:
|
||||||
|
bot_name_hints.add(local.lower())
|
||||||
|
phone = jid_phone(bid)
|
||||||
|
if phone:
|
||||||
|
bot_name_hints.add(phone)
|
||||||
|
text_names = [n for n in raw_names if n.lower() not in bot_name_hints and n]
|
||||||
|
text_name_idx = 0
|
||||||
|
upsert_contact = getattr(store, "upsert_whatsapp_contact", None)
|
||||||
|
wa_acct = str(account_id or "").strip() or "wa-default"
|
||||||
|
for jid in filtered_mention_jids:
|
||||||
|
push_name = _lookup_push_name(
|
||||||
|
store,
|
||||||
|
tenant_id=str(tenant_id or ""),
|
||||||
|
account_id=wa_acct,
|
||||||
|
jid=jid,
|
||||||
|
)
|
||||||
|
if not push_name and text_name_idx < len(text_names):
|
||||||
|
push_name = text_names[text_name_idx]
|
||||||
|
text_name_idx += 1
|
||||||
|
mention_names_for_ctx.append(str(push_name or "").strip())
|
||||||
|
if callable(upsert_contact):
|
||||||
|
try:
|
||||||
|
upsert_contact(
|
||||||
|
tenant_id=str(tenant_id or ""),
|
||||||
|
account_id=wa_acct,
|
||||||
|
external_user_id=jid,
|
||||||
|
push_name=str(push_name or "").strip(),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
if inbound.is_group:
|
if inbound.is_group:
|
||||||
meta_for_group = inbound.metadata if isinstance(inbound.metadata, dict) else {}
|
meta_for_group = meta_for_inbound
|
||||||
bot_reached = metadata_mentions_bot(meta_for_group) or mentions_include_bot(
|
bot_reached = metadata_mentions_bot(meta_for_group) or mentions_include_bot(
|
||||||
mentions=list(inbound.mentions or []),
|
mentions=list(inbound.mentions or []),
|
||||||
bot_jid=bot_jid,
|
bot_jid=bot_jid,
|
||||||
metadata=meta_for_group,
|
metadata=meta_for_group,
|
||||||
) or text_mentions_bot(text=user_text, bot_jid=bot_jid)
|
) or text_mentions_bot(text=raw_user_text, bot_jid=bot_jid)
|
||||||
if bot_reached:
|
if bot_reached:
|
||||||
quoted_alert = extract_quoted_ume_alert_text(metadata=meta_for_group)
|
quoted_alert = extract_quoted_ume_alert_text(metadata=meta_for_group)
|
||||||
if quoted_alert:
|
if quoted_alert:
|
||||||
user_text = enrich_alert_group_question(
|
raw_user_text = enrich_alert_group_question(
|
||||||
user_text=user_text,
|
user_text=raw_user_text,
|
||||||
quoted_alert=quoted_alert,
|
quoted_alert=quoted_alert,
|
||||||
)
|
)
|
||||||
sender_ctx = build_group_sender_context(
|
|
||||||
metadata=meta_for_group,
|
|
||||||
external_user_id=inbound.external_user_id,
|
|
||||||
)
|
|
||||||
group_rule = build_group_focus_instruction()
|
|
||||||
quoted_ctx = ""
|
quoted_ctx = ""
|
||||||
quoted_info = extract_group_quoted_message(metadata=meta_for_group)
|
quoted_info = extract_group_quoted_message(metadata=meta_for_group)
|
||||||
quoted_text = str(quoted_info.get("quoted_text") or "").strip()
|
quoted_text = str(quoted_info.get("quoted_text") or "").strip()
|
||||||
|
|
@ -1051,9 +1125,21 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
recent_messages=recent_messages,
|
recent_messages=recent_messages,
|
||||||
):
|
):
|
||||||
quoted_ctx = build_group_quoted_context_block(metadata=meta_for_group)
|
quoted_ctx = build_group_quoted_context_block(metadata=meta_for_group)
|
||||||
prefix_parts = [sender_ctx, group_rule, quoted_ctx]
|
wa_acct = str(account_id or "").strip() or "wa-default"
|
||||||
prefix = "\n".join(part for part in prefix_parts if str(part).strip())
|
user_text = prepare_group_user_text_for_model(
|
||||||
user_text = f"{prefix}\n{user_text}" if user_text else prefix
|
text=raw_user_text,
|
||||||
|
metadata=meta_for_group,
|
||||||
|
mentions=list(inbound.mentions or []),
|
||||||
|
bot_jid=bot_jid,
|
||||||
|
session_scope=group_policy.session_scope,
|
||||||
|
external_user_id=inbound.external_user_id,
|
||||||
|
filtered_mention_jids=filtered_mention_jids,
|
||||||
|
mention_names=mention_names_for_ctx,
|
||||||
|
store=store,
|
||||||
|
tenant_id=str(tenant_id or ""),
|
||||||
|
account_id=wa_acct,
|
||||||
|
quoted_ctx=quoted_ctx,
|
||||||
|
)
|
||||||
gw_attachments = _channel_attachments_for_gateway(
|
gw_attachments = _channel_attachments_for_gateway(
|
||||||
list(inbound.attachments or [])
|
list(inbound.attachments or [])
|
||||||
)
|
)
|
||||||
|
|
@ -1094,6 +1180,11 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
"external_user_id": inbound.external_user_id,
|
"external_user_id": inbound.external_user_id,
|
||||||
"external_chat_id": inbound.external_chat_id,
|
"external_chat_id": inbound.external_chat_id,
|
||||||
"group_sender_id": inbound.external_user_id,
|
"group_sender_id": inbound.external_user_id,
|
||||||
|
"mentioned_jids": mention_jids_for_ctx,
|
||||||
|
"mention_names": mention_names_for_ctx,
|
||||||
|
"raw_inbound_text": raw_user_text,
|
||||||
|
"bot_jid": str(meta_for_inbound.get("bot_jid") or bot_jid or ""),
|
||||||
|
"bot_lid": str(meta_for_inbound.get("bot_lid") or ""),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
manager = _build_admin_gateway_executor(
|
manager = _build_admin_gateway_executor(
|
||||||
|
|
|
||||||
|
|
@ -479,6 +479,7 @@ class ToolExecutionContext:
|
||||||
session_id: str
|
session_id: str
|
||||||
lang: str = "zh"
|
lang: str = "zh"
|
||||||
user_text: str = ""
|
user_text: str = ""
|
||||||
|
inbound_metadata: dict[str, Any] | None = None
|
||||||
specialist: str = ""
|
specialist: str = ""
|
||||||
task_kind: str = ""
|
task_kind: str = ""
|
||||||
policy_engine: Any | None = None
|
policy_engine: Any | None = None
|
||||||
|
|
@ -529,11 +530,59 @@ class ToolExecutor:
|
||||||
try:
|
try:
|
||||||
tname = str(tc.name or "")
|
tname = str(tc.name or "")
|
||||||
if tname == "schedule_create":
|
if tname == "schedule_create":
|
||||||
|
md = ctx.inbound_metadata if isinstance(ctx.inbound_metadata, dict) else {}
|
||||||
cur_spec = str(ctx.specialist or "").strip().lower()
|
cur_spec = str(ctx.specialist or "").strip().lower()
|
||||||
if cur_spec and not str(tool_args.get("specialist") or "").strip() and not str(
|
if cur_spec and not str(tool_args.get("specialist") or "").strip() and not str(
|
||||||
tool_args.get("selected_specialist") or ""
|
tool_args.get("selected_specialist") or ""
|
||||||
).strip():
|
).strip():
|
||||||
tool_args["selected_specialist"] = cur_spec
|
tool_args["selected_specialist"] = cur_spec
|
||||||
|
raw_mentions = md.get("mentioned_jids") or md.get("mentionedJids") or md.get("mentions") or []
|
||||||
|
mention_list = raw_mentions if isinstance(raw_mentions, list) else []
|
||||||
|
bot_jid = str(md.get("bot_jid") or "").strip().lower()
|
||||||
|
bot_lid = str(md.get("bot_lid") or "").strip().lower()
|
||||||
|
cleaned: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for m in mention_list:
|
||||||
|
jid = str(m or "").strip()
|
||||||
|
if not jid:
|
||||||
|
continue
|
||||||
|
low = jid.lower()
|
||||||
|
if (bot_jid and low == bot_jid) or (bot_lid and low == bot_lid):
|
||||||
|
continue
|
||||||
|
if low in seen:
|
||||||
|
continue
|
||||||
|
seen.add(low)
|
||||||
|
cleaned.append(jid)
|
||||||
|
if cleaned:
|
||||||
|
tool_args["whatsapp_mention_jids"] = cleaned
|
||||||
|
elif tool_args.get("whatsapp_mention_jids") is not None:
|
||||||
|
from runtime.scheduler.whatsapp_mentions import normalize_whatsapp_mention_jids
|
||||||
|
|
||||||
|
tool_args["whatsapp_mention_jids"] = normalize_whatsapp_mention_jids(
|
||||||
|
tool_args.get("whatsapp_mention_jids")
|
||||||
|
)
|
||||||
|
if tool_args.get("whatsapp_mention_names") is None:
|
||||||
|
from runtime.scheduler.whatsapp_mentions import extract_whatsapp_mention_names
|
||||||
|
|
||||||
|
user_text = str(ctx.user_text or "")
|
||||||
|
names = extract_whatsapp_mention_names(user_text)
|
||||||
|
bot_names = {
|
||||||
|
str(x or "").strip().lower()
|
||||||
|
for x in (
|
||||||
|
md.get("bot_push_name"),
|
||||||
|
"oliver",
|
||||||
|
)
|
||||||
|
if str(x or "").strip()
|
||||||
|
}
|
||||||
|
filtered_names = [n for n in names if n.lower() not in bot_names]
|
||||||
|
if filtered_names:
|
||||||
|
tool_args["whatsapp_mention_names"] = filtered_names
|
||||||
|
elif md.get("mention_names"):
|
||||||
|
names_md = md.get("mention_names")
|
||||||
|
if isinstance(names_md, list):
|
||||||
|
tool_args["whatsapp_mention_names"] = [
|
||||||
|
str(x or "").strip() for x in names_md if str(x or "").strip()
|
||||||
|
]
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
tool_args = filter_arguments_to_schema(tool.parameters, tool_args)
|
tool_args = filter_arguments_to_schema(tool.parameters, tool_args)
|
||||||
|
|
|
||||||
|
|
@ -864,6 +864,16 @@ def _dsml_mismatch_user_message(*, lang: str) -> str:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _empty_final_user_message(*, lang: str, had_tools: bool) -> str:
|
||||||
|
if had_tools:
|
||||||
|
if str(lang or "").strip().lower().startswith("zh"):
|
||||||
|
return "工具执行未返回可展示结果,请稍后重试;若持续失败,请更换查询关键词。"
|
||||||
|
return "Tool execution did not produce a user-visible result. Please retry with a narrower query."
|
||||||
|
if str(lang or "").strip().lower().startswith("zh"):
|
||||||
|
return "抱歉,这次没有生成可展示内容,请重试。"
|
||||||
|
return "Sorry, no user-visible content was produced this turn. Please try again."
|
||||||
|
|
||||||
|
|
||||||
def _promote_dsml_tool_calls(
|
def _promote_dsml_tool_calls(
|
||||||
*,
|
*,
|
||||||
allow: bool,
|
allow: bool,
|
||||||
|
|
@ -1131,6 +1141,7 @@ def _execute_tool_step(
|
||||||
run_id: str | None = None,
|
run_id: str | None = None,
|
||||||
attempt_no: int | None = None,
|
attempt_no: int | None = None,
|
||||||
turn_uuid: str | None = None,
|
turn_uuid: str | None = None,
|
||||||
|
inbound_metadata: dict[str, Any] | None = None,
|
||||||
) -> tuple[int, dict[str, tuple[dict[str, Any], int]]]:
|
) -> tuple[int, dict[str, tuple[dict[str, Any], int]]]:
|
||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
_tool_messages, results_by_id = skill_exec.execute_skill_uses(
|
_tool_messages, results_by_id = skill_exec.execute_skill_uses(
|
||||||
|
|
@ -1140,6 +1151,7 @@ def _execute_tool_step(
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
lang=lang,
|
lang=lang,
|
||||||
user_text=user_text,
|
user_text=user_text,
|
||||||
|
inbound_metadata=inbound_metadata,
|
||||||
specialist="oclaw",
|
specialist="oclaw",
|
||||||
trace_id=trace_id,
|
trace_id=trace_id,
|
||||||
parent_span_id=parent_span_id,
|
parent_span_id=parent_span_id,
|
||||||
|
|
@ -1390,6 +1402,7 @@ def run_oclaw_direct_loop(
|
||||||
wire_policy_role: str | None = None,
|
wire_policy_role: str | None = None,
|
||||||
prompt_build_context: dict[str, Any] | None = None,
|
prompt_build_context: dict[str, Any] | None = None,
|
||||||
turn_uuid: str | None = None,
|
turn_uuid: str | None = None,
|
||||||
|
inbound_metadata: dict[str, Any] | None = None,
|
||||||
) -> TurnRunOutcome:
|
) -> TurnRunOutcome:
|
||||||
"""A minimal oclaw-style loop: model -> tool_uses -> execute -> tool_results -> continue."""
|
"""A minimal oclaw-style loop: model -> tool_uses -> execute -> tool_results -> continue."""
|
||||||
_check_stop(should_stop)
|
_check_stop(should_stop)
|
||||||
|
|
@ -1599,6 +1612,7 @@ def run_oclaw_direct_loop(
|
||||||
run_id=run_id,
|
run_id=run_id,
|
||||||
attempt_no=attempt_no,
|
attempt_no=attempt_no,
|
||||||
turn_uuid=turn_uuid,
|
turn_uuid=turn_uuid,
|
||||||
|
inbound_metadata=inbound_metadata,
|
||||||
)
|
)
|
||||||
|
|
||||||
for tc in step.llm_tool_calls:
|
for tc in step.llm_tool_calls:
|
||||||
|
|
@ -1662,6 +1676,18 @@ def run_oclaw_direct_loop(
|
||||||
)
|
)
|
||||||
final_text = step.assistant_text
|
final_text = step.assistant_text
|
||||||
|
|
||||||
|
if not str(final_text or "").strip():
|
||||||
|
fallback = _empty_final_user_message(lang=lang, had_tools=bool(tool_traces))
|
||||||
|
store.add_message(
|
||||||
|
session_id=session_id,
|
||||||
|
role="assistant",
|
||||||
|
content=fallback,
|
||||||
|
turn_uuid=turn_uuid,
|
||||||
|
event_type="assistant_text",
|
||||||
|
event_payload={"runtime_fallback": "empty_final_text"},
|
||||||
|
)
|
||||||
|
final_text = fallback
|
||||||
|
|
||||||
return TurnRunOutcome(
|
return TurnRunOutcome(
|
||||||
final_text=str(final_text or ""),
|
final_text=str(final_text or ""),
|
||||||
tool_traces=tuple(tool_traces),
|
tool_traces=tuple(tool_traces),
|
||||||
|
|
|
||||||
|
|
@ -1251,6 +1251,21 @@ class OclawGateway:
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
reply = specialist_reply
|
reply = specialist_reply
|
||||||
|
if not str(reply or "").strip():
|
||||||
|
rs = getattr(core_out, "run_state", None)
|
||||||
|
if rs is not None and str(getattr(rs, "status", "") or "") == "failed":
|
||||||
|
reason = ""
|
||||||
|
attempts = getattr(rs, "attempts", ()) or ()
|
||||||
|
if attempts:
|
||||||
|
reason = str(getattr(attempts[-1], "reason", "") or "")
|
||||||
|
if not reason:
|
||||||
|
reason = str(getattr(rs, "last_error_code", "") or "unknown_error")
|
||||||
|
base = render_prompt(
|
||||||
|
"fallback/runtime_error.en.md" if str(lang or "").startswith("en") else "fallback/runtime_error.zh.md",
|
||||||
|
strict=True,
|
||||||
|
)
|
||||||
|
detail = str(reason or "").strip().replace("\n", " ")[:400]
|
||||||
|
reply = f"{base}\n(detail: {detail})" if detail else base
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
base = render_prompt(
|
base = render_prompt(
|
||||||
"fallback/runtime_error.en.md" if str(lang or "").startswith("en") else "fallback/runtime_error.zh.md",
|
"fallback/runtime_error.en.md" if str(lang or "").startswith("en") else "fallback/runtime_error.zh.md",
|
||||||
|
|
|
||||||
|
|
@ -527,14 +527,151 @@ function readMentionJids(meta: Json): string[] {
|
||||||
return raw.map((j) => String(j || "").trim()).filter(Boolean);
|
return raw.map((j) => String(j || "").trim()).filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMentionPrefix(mentionJids: string[]): string {
|
function readMentionNames(meta: Json): string[] {
|
||||||
const tags = mentionJids
|
const raw = (meta as any).mention_names ?? (meta as any).mentionNames;
|
||||||
.map((j) => {
|
if (!Array.isArray(raw)) return [];
|
||||||
const user = String(j || "").split("@")[0]?.trim();
|
return raw.map((n) => String(n || "").trim()).filter(Boolean);
|
||||||
return user ? `@${user}` : "";
|
}
|
||||||
})
|
|
||||||
.filter(Boolean);
|
function escapeRegExp(text: string): string {
|
||||||
return tags.length ? `${tags.join(" ")} ` : "";
|
return String(text || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
function mentionTokenForJid(jid: string): string {
|
||||||
|
const local = jidBaseLocal(jid);
|
||||||
|
return local ? `@${local}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveOutboundMentionJids(
|
||||||
|
sock: ReturnType<typeof makeWASocket> | null,
|
||||||
|
chatId: string,
|
||||||
|
mentionJids: string[],
|
||||||
|
): string[] {
|
||||||
|
const isGroup = String(chatId || "").toLowerCase().endsWith("@g.us");
|
||||||
|
const lidMapping = (sock as any)?.signalRepository?.lidMapping;
|
||||||
|
const out: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
const push = (jid: string) => {
|
||||||
|
const j = String(jid || "").trim();
|
||||||
|
if (!j) return;
|
||||||
|
const key = jidBaseLocal(j);
|
||||||
|
if (!key || seen.has(key)) return;
|
||||||
|
seen.add(key);
|
||||||
|
out.push(j);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let raw of mentionJids) {
|
||||||
|
let jid = String(raw || "").trim();
|
||||||
|
if (!jid) continue;
|
||||||
|
|
||||||
|
if (!jid.includes("@") && /^\d+$/.test(jid)) {
|
||||||
|
jid = `${jid}@lid`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const low = jid.toLowerCase();
|
||||||
|
if (low.endsWith("@lid")) {
|
||||||
|
push(jid);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isGroup && lidMapping && typeof lidMapping.getLIDForPN === "function") {
|
||||||
|
try {
|
||||||
|
const pn = low.includes("@s.whatsapp") ? jidNormalizedUser(jid) : jid;
|
||||||
|
const lid = lidMapping.getLIDForPN(pn);
|
||||||
|
if (lid) {
|
||||||
|
push(String(lid));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
push(jid.includes("@") ? jid : jidNormalizedUser(jid));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function alignMentionTextWithJids(text: string, mentionJids: string[], mentionNames: string[]): string {
|
||||||
|
let body = String(text || "").trim();
|
||||||
|
const tokens = mentionJids.map((j) => mentionTokenForJid(j)).filter(Boolean);
|
||||||
|
if (!tokens.length) return body;
|
||||||
|
|
||||||
|
for (let i = 0; i < mentionJids.length; i++) {
|
||||||
|
const token = tokens[i] || "";
|
||||||
|
if (!token) continue;
|
||||||
|
const name = String(mentionNames[i] || "").trim();
|
||||||
|
if (name) {
|
||||||
|
body = body.replace(new RegExp(`@${escapeRegExp(name)}(?=\\s|$|[,。!?!?,.])`, "gu"), token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const missing = tokens.filter((token) => !body.includes(token));
|
||||||
|
if (missing.length) {
|
||||||
|
body = body.replace(/^(@\S+\s*)+/, "").trim();
|
||||||
|
body = `${missing.join(" ")} ${body}`.trim();
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeOutboundSource(raw: string): Json {
|
||||||
|
const text = String(raw || "").trim();
|
||||||
|
if (!text) return {};
|
||||||
|
if (text.startsWith("{")) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(text);
|
||||||
|
return data && typeof data === "object" ? (data as Json) : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { kind: text };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOutboundMentionTextReady(meta: Json): boolean {
|
||||||
|
return Boolean((meta as any).mention_text_ready ?? (meta as any).mentionTextReady);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMentionedOutboundText(
|
||||||
|
body: string,
|
||||||
|
meta: Json,
|
||||||
|
sock?: ReturnType<typeof makeWASocket> | null,
|
||||||
|
chatId?: string,
|
||||||
|
): { text: string; mentions?: string[] } {
|
||||||
|
const trimmed = String(body || "").trim();
|
||||||
|
if (!trimmed) return { text: trimmed };
|
||||||
|
let mentionJids = readMentionJids(meta);
|
||||||
|
if (!mentionJids.length) return { text: trimmed };
|
||||||
|
|
||||||
|
const chat = String(chatId || "").trim();
|
||||||
|
if (sock && chat) {
|
||||||
|
mentionJids = resolveOutboundMentionJids(sock, chat, mentionJids);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mentionNames = readMentionNames(meta);
|
||||||
|
|
||||||
|
if (isOutboundMentionTextReady(meta)) {
|
||||||
|
const aligned = alignMentionTextWithJids(trimmed, mentionJids, mentionNames);
|
||||||
|
return { text: aligned, mentions: mentionJids };
|
||||||
|
}
|
||||||
|
const cleaned = trimmed
|
||||||
|
.replace(/@\+?\d+(?:\s+\d+)*\s*/g, "")
|
||||||
|
.replace(/@\S+\s*/g, "")
|
||||||
|
.trim();
|
||||||
|
const prefix = mentionJids.map((j) => mentionTokenForJid(j)).filter(Boolean).join(" ");
|
||||||
|
const outText = prefix ? `${prefix} ${cleaned}`.trim() : trimmed;
|
||||||
|
return { text: outText, mentions: mentionJids };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOutboundSendContent(
|
||||||
|
text: string,
|
||||||
|
sourceRaw: string,
|
||||||
|
sock?: ReturnType<typeof makeWASocket> | null,
|
||||||
|
chatId?: string,
|
||||||
|
): { text: string; mentions?: string[] } {
|
||||||
|
const meta = decodeOutboundSource(sourceRaw);
|
||||||
|
return buildMentionedOutboundText(text, meta, sock, chatId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildQuotedMessage(params: {
|
function buildQuotedMessage(params: {
|
||||||
|
|
@ -565,13 +702,10 @@ function buildTextSendOptions(params: {
|
||||||
deliverTo: string;
|
deliverTo: string;
|
||||||
text: string;
|
text: string;
|
||||||
reply: Json;
|
reply: Json;
|
||||||
|
sock?: ReturnType<typeof makeWASocket> | null;
|
||||||
}): { content: { text: string; mentions?: string[] }; quoted?: proto.IWebMessageInfo } {
|
}): { content: { text: string; mentions?: string[] }; quoted?: proto.IWebMessageInfo } {
|
||||||
const meta = readReplyMetadata(params.reply);
|
const meta = readReplyMetadata(params.reply);
|
||||||
const mentionJids = readMentionJids(meta);
|
const content = buildMentionedOutboundText(params.text, meta, params.sock, params.deliverTo);
|
||||||
const body = String(params.text || "").trim();
|
|
||||||
const prefix = mentionJids.length ? buildMentionPrefix(mentionJids) : "";
|
|
||||||
const content: { text: string; mentions?: string[] } = { text: prefix ? `${prefix}${body}` : body };
|
|
||||||
if (mentionJids.length) content.mentions = mentionJids;
|
|
||||||
|
|
||||||
const quoted = buildQuotedMessage({
|
const quoted = buildQuotedMessage({
|
||||||
chatId: String((meta as any).quote_remote_jid || (meta as any).quoteRemoteJid || params.deliverTo).trim(),
|
chatId: String((meta as any).quote_remote_jid || (meta as any).quoteRemoteJid || params.deliverTo).trim(),
|
||||||
|
|
@ -628,25 +762,28 @@ function buildOutboundMediaMessage(params: {
|
||||||
mime: string;
|
mime: string;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
caption?: string;
|
caption?: string;
|
||||||
|
mentions?: string[];
|
||||||
}): Json {
|
}): Json {
|
||||||
const mime = String(params.mime || "application/octet-stream").trim() || "application/octet-stream";
|
const mime = String(params.mime || "application/octet-stream").trim() || "application/octet-stream";
|
||||||
const m = mime.toLowerCase();
|
const m = mime.toLowerCase();
|
||||||
const caption = String(params.caption || "").trim();
|
const caption = String(params.caption || "").trim();
|
||||||
const captionOpt = caption ? { caption } : {};
|
const captionOpt = caption ? { caption } : {};
|
||||||
|
const mentionOpt = params.mentions?.length ? { mentions: params.mentions } : {};
|
||||||
if (m.startsWith("image/")) {
|
if (m.startsWith("image/")) {
|
||||||
return { image: params.data as any, mimetype: mime, ...captionOpt };
|
return { image: params.data as any, mimetype: mime, ...captionOpt, ...mentionOpt };
|
||||||
}
|
}
|
||||||
if (m.startsWith("video/")) {
|
if (m.startsWith("video/")) {
|
||||||
return { video: params.data as any, mimetype: mime, ...captionOpt };
|
return { video: params.data as any, mimetype: mime, ...captionOpt, ...mentionOpt };
|
||||||
}
|
}
|
||||||
if (m.startsWith("audio/")) {
|
if (m.startsWith("audio/")) {
|
||||||
return { audio: params.data as any, mimetype: mime, ...captionOpt };
|
return { audio: params.data as any, mimetype: mime, ...captionOpt, ...mentionOpt };
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
document: params.data as any,
|
document: params.data as any,
|
||||||
mimetype: mime,
|
mimetype: mime,
|
||||||
fileName: sanitizeFileName(params.fileName || "attachment.bin"),
|
fileName: sanitizeFileName(params.fileName || "attachment.bin"),
|
||||||
...captionOpt,
|
...captionOpt,
|
||||||
|
...mentionOpt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -663,7 +800,12 @@ async function sendReplyWithAttachments(params: {
|
||||||
const mediaPath = String((reply as any).media_path || (reply as any).mediaPath || "").trim();
|
const mediaPath = String((reply as any).media_path || (reply as any).mediaPath || "").trim();
|
||||||
const mediaUrl = String((reply as any).media_url || (reply as any).mediaUrl || "").trim();
|
const mediaUrl = String((reply as any).media_url || (reply as any).mediaUrl || "").trim();
|
||||||
const attachments = Array.isArray((reply as any).attachments) ? ((reply as any).attachments as Json[]) : [];
|
const attachments = Array.isArray((reply as any).attachments) ? ((reply as any).attachments as Json[]) : [];
|
||||||
const textOpts = buildTextSendOptions({ deliverTo: params.deliverTo, text: outText, reply });
|
const textOpts = buildTextSendOptions({
|
||||||
|
deliverTo: params.deliverTo,
|
||||||
|
text: outText,
|
||||||
|
reply,
|
||||||
|
sock: params.sock,
|
||||||
|
});
|
||||||
const sendOpts = textOpts.quoted ? { quoted: textOpts.quoted } : undefined;
|
const sendOpts = textOpts.quoted ? { quoted: textOpts.quoted } : undefined;
|
||||||
|
|
||||||
const sendMediaBuffer = async (data: Buffer, mime: string, fileName: string): Promise<boolean> => {
|
const sendMediaBuffer = async (data: Buffer, mime: string, fileName: string): Promise<boolean> => {
|
||||||
|
|
@ -673,6 +815,7 @@ async function sendReplyWithAttachments(params: {
|
||||||
mime,
|
mime,
|
||||||
fileName,
|
fileName,
|
||||||
caption: textOpts.content.text,
|
caption: textOpts.content.text,
|
||||||
|
mentions: textOpts.content.mentions,
|
||||||
});
|
});
|
||||||
await s.sendMessage(params.deliverTo, msg as any, sendOpts);
|
await s.sendMessage(params.deliverTo, msg as any, sendOpts);
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -753,13 +896,17 @@ async function pollOutboundQueue(sock: ReturnType<typeof makeWASocket>): Promise
|
||||||
const id = String((item as any).id || "").trim();
|
const id = String((item as any).id || "").trim();
|
||||||
const chatId = String((item as any).chat_id || "").trim();
|
const chatId = String((item as any).chat_id || "").trim();
|
||||||
const text = String((item as any).text || "").trim();
|
const text = String((item as any).text || "").trim();
|
||||||
|
const source = String((item as any).source || "").trim();
|
||||||
if (!id || !chatId || !text) continue;
|
if (!id || !chatId || !text) continue;
|
||||||
let ok = true;
|
let ok = true;
|
||||||
let err = "";
|
let err = "";
|
||||||
try {
|
try {
|
||||||
const sent = await sock.sendMessage(chatId, { text });
|
const sendContent = buildOutboundSendContent(text, source, sock, chatId);
|
||||||
|
const sent = await sock.sendMessage(chatId, sendContent);
|
||||||
const stanzaId = String((sent as any)?.key?.id || "").trim();
|
const stanzaId = String((sent as any)?.key?.id || "").trim();
|
||||||
log(`outbound sent id=${id} chat=${chatId} stanza=${stanzaId || "?"}`);
|
log(
|
||||||
|
`outbound sent id=${id} chat=${chatId} stanza=${stanzaId || "?"} mentions=${(sendContent.mentions || []).length} mention0=${(sendContent.mentions || [])[0] || ""} text=${sendContent.text.slice(0, 80)}`,
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
await fetch(`${LOCAL_BASE_URL.replace(/\/+$/, "")}/whatsapp/outbound/ack`, {
|
await fetch(`${LOCAL_BASE_URL.replace(/\/+$/, "")}/whatsapp/outbound/ack`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
|
||||||
|
|
@ -356,9 +356,178 @@ def build_group_sender_context(*, metadata: dict[str, Any] | None, external_user
|
||||||
push_name = str(raw.get("pushName") or meta.get("push_name") or meta.get("display_name") or "").strip()
|
push_name = str(raw.get("pushName") or meta.get("push_name") or meta.get("display_name") or "").strip()
|
||||||
sender = str(external_user_id or "").strip()
|
sender = str(external_user_id or "").strip()
|
||||||
label = push_name or sender or "unknown"
|
label = push_name or sender or "unknown"
|
||||||
if sender and push_name and sender not in push_name:
|
return f"[发言: {label}]"
|
||||||
return f"[群成员: {label} ({sender})]"
|
|
||||||
return f"[群成员: {label}]"
|
|
||||||
|
def _mention_tokens_for_jids(jids: list[str]) -> list[str]:
|
||||||
|
tokens: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for jid in jids or []:
|
||||||
|
local = jid_base_local(jid)
|
||||||
|
if local:
|
||||||
|
tok = f"@{local}"
|
||||||
|
if tok.lower() not in seen:
|
||||||
|
seen.add(tok.lower())
|
||||||
|
tokens.append(tok)
|
||||||
|
phone = jid_phone(jid)
|
||||||
|
if phone and len(phone) >= 6:
|
||||||
|
tok = f"@{phone}"
|
||||||
|
if tok.lower() not in seen:
|
||||||
|
seen.add(tok.lower())
|
||||||
|
tokens.append(tok)
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
|
||||||
|
def strip_bot_mentions_from_text(
|
||||||
|
*,
|
||||||
|
text: str,
|
||||||
|
bot_jid: str | None,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
) -> str:
|
||||||
|
body = str(text or "")
|
||||||
|
identities = _bot_identity_jids(bot_jid=bot_jid, metadata=metadata)
|
||||||
|
tokens = _mention_tokens_for_jids(identities)
|
||||||
|
extra_names: set[str] = set()
|
||||||
|
if isinstance(metadata, dict):
|
||||||
|
for name in (metadata.get("bot_push_name"), "oliver"):
|
||||||
|
n = str(name or "").strip()
|
||||||
|
if n:
|
||||||
|
extra_names.add(n.lower())
|
||||||
|
for token in sorted(tokens, key=len, reverse=True):
|
||||||
|
body = re.sub(
|
||||||
|
re.escape(token) + r"(?=\s|$|[,。!?!?,.])",
|
||||||
|
"",
|
||||||
|
body,
|
||||||
|
flags=re.IGNORECASE,
|
||||||
|
)
|
||||||
|
for name in sorted(extra_names, key=len, reverse=True):
|
||||||
|
body = re.sub(
|
||||||
|
rf"@{re.escape(name)}(?=\s|$|[,。!?!?,.])",
|
||||||
|
"",
|
||||||
|
body,
|
||||||
|
flags=re.IGNORECASE,
|
||||||
|
)
|
||||||
|
body = re.sub(r"\s{2,}", " ", body).strip()
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_non_bot_mention_jids(
|
||||||
|
mention_jids: list[str],
|
||||||
|
*,
|
||||||
|
bot_jid: str | None,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
identities = _bot_identity_jids(bot_jid=bot_jid, metadata=metadata)
|
||||||
|
out: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for raw in mention_jids or []:
|
||||||
|
jid = str(raw or "").strip()
|
||||||
|
if not jid:
|
||||||
|
continue
|
||||||
|
if any(jids_same_user(jid, identity) for identity in identities):
|
||||||
|
continue
|
||||||
|
key = jid_base_local(jid) or jid.lower()
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
out.append(jid)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_mentioned_users_in_text(
|
||||||
|
*,
|
||||||
|
text: str,
|
||||||
|
mention_jids: list[str],
|
||||||
|
mention_names: list[str] | None = None,
|
||||||
|
store: Any = None,
|
||||||
|
tenant_id: str = "",
|
||||||
|
account_id: str = "",
|
||||||
|
) -> str:
|
||||||
|
body = str(text or "")
|
||||||
|
names = [str(x or "").strip() for x in (mention_names or [])]
|
||||||
|
for idx, jid in enumerate(mention_jids or []):
|
||||||
|
jid_s = str(jid or "").strip()
|
||||||
|
if not jid_s:
|
||||||
|
continue
|
||||||
|
name = names[idx] if idx < len(names) and names[idx] else ""
|
||||||
|
if not name and store is not None:
|
||||||
|
from runtime.scheduler.whatsapp_mentions import _lookup_push_name
|
||||||
|
|
||||||
|
name = _lookup_push_name(
|
||||||
|
store,
|
||||||
|
tenant_id=str(tenant_id or ""),
|
||||||
|
account_id=str(account_id or ""),
|
||||||
|
jid=jid_s,
|
||||||
|
)
|
||||||
|
if not name:
|
||||||
|
local = jid_base_local(jid_s)
|
||||||
|
if local:
|
||||||
|
body = re.sub(
|
||||||
|
rf"@{re.escape(local)}(?=\s|$|[,。!?!?,.])",
|
||||||
|
"",
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
local = jid_base_local(jid_s)
|
||||||
|
if local:
|
||||||
|
body = re.sub(
|
||||||
|
rf"@{re.escape(local)}(?=\s|$|[,。!?!?,.])",
|
||||||
|
f"@{name}",
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
phone = jid_phone(jid_s)
|
||||||
|
if phone and len(phone) >= 6:
|
||||||
|
body = re.sub(
|
||||||
|
rf"@{re.escape(phone)}(?=\s|$|[,。!?!?,.])",
|
||||||
|
f"@{name}",
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
body = re.sub(r"\s{2,}", " ", body).strip()
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_group_user_text_for_model(
|
||||||
|
*,
|
||||||
|
text: str,
|
||||||
|
metadata: dict[str, Any] | None,
|
||||||
|
mentions: list[str],
|
||||||
|
bot_jid: str | None,
|
||||||
|
session_scope: str,
|
||||||
|
external_user_id: str,
|
||||||
|
filtered_mention_jids: list[str] | None = None,
|
||||||
|
mention_names: list[str] | None = None,
|
||||||
|
store: Any = None,
|
||||||
|
tenant_id: str = "",
|
||||||
|
account_id: str = "",
|
||||||
|
quoted_ctx: str = "",
|
||||||
|
) -> str:
|
||||||
|
body = str(text or "").strip()
|
||||||
|
body = strip_bot_mentions_from_text(text=body, bot_jid=bot_jid, metadata=metadata)
|
||||||
|
target_jids = (
|
||||||
|
filtered_mention_jids
|
||||||
|
if filtered_mention_jids is not None
|
||||||
|
else _filter_non_bot_mention_jids(list(mentions or []), bot_jid=bot_jid, metadata=metadata)
|
||||||
|
)
|
||||||
|
body = normalize_mentioned_users_in_text(
|
||||||
|
text=body,
|
||||||
|
mention_jids=target_jids,
|
||||||
|
mention_names=mention_names,
|
||||||
|
store=store,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
account_id=account_id,
|
||||||
|
)
|
||||||
|
prefix_parts: list[str] = []
|
||||||
|
if normalize_group_session_scope(session_scope) == "chat":
|
||||||
|
prefix_parts.append(
|
||||||
|
build_group_sender_context(metadata=metadata, external_user_id=external_user_id)
|
||||||
|
)
|
||||||
|
quote = str(quoted_ctx or "").strip()
|
||||||
|
if quote:
|
||||||
|
prefix_parts.append(quote)
|
||||||
|
prefix = "\n".join(part for part in prefix_parts if str(part).strip())
|
||||||
|
if prefix and body:
|
||||||
|
return f"{prefix}\n{body}"
|
||||||
|
return prefix or body
|
||||||
|
|
||||||
|
|
||||||
def build_group_focus_instruction(*, lang: str = "zh") -> str:
|
def build_group_focus_instruction(*, lang: str = "zh") -> str:
|
||||||
|
|
@ -408,6 +577,7 @@ def build_whatsapp_group_reply_metadata(
|
||||||
}
|
}
|
||||||
if push_name:
|
if push_name:
|
||||||
out["quote_push_name"] = push_name
|
out["quote_push_name"] = push_name
|
||||||
|
out["mention_names"] = [push_name]
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -424,9 +594,12 @@ __all__ = [
|
||||||
"extract_quoted_ume_alert_text",
|
"extract_quoted_ume_alert_text",
|
||||||
"mentions_include_bot",
|
"mentions_include_bot",
|
||||||
"metadata_mentions_bot",
|
"metadata_mentions_bot",
|
||||||
|
"normalize_mentioned_users_in_text",
|
||||||
"normalize_jid",
|
"normalize_jid",
|
||||||
"normalize_group_session_scope",
|
"normalize_group_session_scope",
|
||||||
"normalize_jids",
|
"normalize_jids",
|
||||||
|
"prepare_group_user_text_for_model",
|
||||||
|
"strip_bot_mentions_from_text",
|
||||||
"infer_is_group_from_chat_id",
|
"infer_is_group_from_chat_id",
|
||||||
"is_nonsend_channel_reply_text",
|
"is_nonsend_channel_reply_text",
|
||||||
"resolve_is_group",
|
"resolve_is_group",
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,12 @@ from typing import Any
|
||||||
|
|
||||||
from runtime.orchestration.group_ingest import is_nonsend_channel_reply_text, should_send_channel_reply_text
|
from runtime.orchestration.group_ingest import is_nonsend_channel_reply_text, should_send_channel_reply_text
|
||||||
from runtime.scheduler.session_resolver import parse_delivery_json
|
from runtime.scheduler.session_resolver import parse_delivery_json
|
||||||
|
from runtime.scheduler.whatsapp_mentions import (
|
||||||
|
encode_whatsapp_outbound_source,
|
||||||
|
format_whatsapp_mention_text,
|
||||||
|
infer_whatsapp_mention_jids_from_text,
|
||||||
|
normalize_whatsapp_mention_jids,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _encode_weixin_outbound_source(
|
def _encode_weixin_outbound_source(
|
||||||
|
|
@ -216,15 +222,56 @@ def deliver_scheduled_reply(
|
||||||
wa.get("account_id") or resolved_account_id or os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default"
|
wa.get("account_id") or resolved_account_id or os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default"
|
||||||
).strip()
|
).strip()
|
||||||
if wa_enabled and chat_id and should_send_channel_reply_text(text):
|
if wa_enabled and chat_id and should_send_channel_reply_text(text):
|
||||||
|
mention_jids = normalize_whatsapp_mention_jids(wa.get("mention_jids"))
|
||||||
|
if not mention_jids:
|
||||||
|
mention_jids = infer_whatsapp_mention_jids_from_text(
|
||||||
|
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 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
|
||||||
|
out_text = format_whatsapp_mention_text(
|
||||||
|
text,
|
||||||
|
mention_jids,
|
||||||
|
store=store,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
account_id=account_id,
|
||||||
|
mention_names=mention_names,
|
||||||
|
)
|
||||||
msg_id = store.enqueue_channel_outbound_message(
|
msg_id = store.enqueue_channel_outbound_message(
|
||||||
channel="whatsapp",
|
channel="whatsapp",
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
text=text,
|
text=out_text,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
account_id=account_id,
|
account_id=account_id,
|
||||||
source="scheduled_job",
|
source=encode_whatsapp_outbound_source(
|
||||||
|
mention_jids=mention_jids,
|
||||||
|
mention_names=mention_names,
|
||||||
|
mention_text_ready=bool(mention_jids),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
results["whatsapp"] = {"ok": True, "message_id": msg_id, "chat_id": chat_id}
|
results["whatsapp"] = {
|
||||||
|
"ok": True,
|
||||||
|
"message_id": msg_id,
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"mention_jids": mention_jids,
|
||||||
|
}
|
||||||
|
|
||||||
wx = delivery.get("weixin") if isinstance(delivery.get("weixin"), dict) else {}
|
wx = delivery.get("weixin") if isinstance(delivery.get("weixin"), dict) else {}
|
||||||
wx_enabled = bool(wx.get("enabled", True))
|
wx_enabled = bool(wx.get("enabled", True))
|
||||||
|
|
|
||||||
|
|
@ -189,8 +189,18 @@ def resolve_scheduled_session(
|
||||||
external_user_id=external_user_id,
|
external_user_id=external_user_id,
|
||||||
session_title=f"Scheduled · {job_name}",
|
session_title=f"Scheduled · {job_name}",
|
||||||
)
|
)
|
||||||
if user_id:
|
from runtime.application.gateway.channel_session_owner import (
|
||||||
store.ensure_ui_session_owner(session_id=session_id, tenant_id=tenant_id, user_id=user_id)
|
assign_channel_session_to_account_owner,
|
||||||
|
)
|
||||||
|
|
||||||
|
assign_channel_session_to_account_owner(
|
||||||
|
store,
|
||||||
|
session_id=session_id,
|
||||||
|
channel="whatsapp",
|
||||||
|
account_id=account_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
fallback_user_id=user_id,
|
||||||
|
)
|
||||||
return ResolvedSession(
|
return ResolvedSession(
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
|
|
@ -219,8 +229,18 @@ def resolve_scheduled_session(
|
||||||
external_user_id=external_user_id,
|
external_user_id=external_user_id,
|
||||||
session_title=f"Scheduled · {job_name}",
|
session_title=f"Scheduled · {job_name}",
|
||||||
)
|
)
|
||||||
if user_id:
|
from runtime.application.gateway.channel_session_owner import (
|
||||||
store.ensure_ui_session_owner(session_id=session_id, tenant_id=tenant_id, user_id=user_id)
|
assign_channel_session_to_account_owner,
|
||||||
|
)
|
||||||
|
|
||||||
|
assign_channel_session_to_account_owner(
|
||||||
|
store,
|
||||||
|
session_id=session_id,
|
||||||
|
channel=channel,
|
||||||
|
account_id=account_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
fallback_user_id=user_id,
|
||||||
|
)
|
||||||
return ResolvedSession(
|
return ResolvedSession(
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
|
|
|
||||||
313
runtime/scheduler/whatsapp_mentions.py
Normal file
313
runtime/scheduler/whatsapp_mentions.py
Normal file
|
|
@ -0,0 +1,313 @@
|
||||||
|
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,
|
||||||
|
) -> str:
|
||||||
|
payload: dict[str, Any] = {"kind": str(kind or "scheduled_job")}
|
||||||
|
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
|
||||||
|
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 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",
|
||||||
|
"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",
|
||||||
|
]
|
||||||
|
|
@ -22,6 +22,7 @@ class SkillExecutionContext:
|
||||||
session_id: str
|
session_id: str
|
||||||
lang: str = "zh"
|
lang: str = "zh"
|
||||||
user_text: str = ""
|
user_text: str = ""
|
||||||
|
inbound_metadata: dict[str, Any] | None = None
|
||||||
specialist: str = "oclaw"
|
specialist: str = "oclaw"
|
||||||
trace_id: str | None = None
|
trace_id: str | None = None
|
||||||
parent_span_id: str | None = None
|
parent_span_id: str | None = None
|
||||||
|
|
@ -160,6 +161,7 @@ class SkillExecutor:
|
||||||
session_id=ctx.session_id,
|
session_id=ctx.session_id,
|
||||||
lang=ctx.lang,
|
lang=ctx.lang,
|
||||||
user_text=ctx.user_text,
|
user_text=ctx.user_text,
|
||||||
|
inbound_metadata=ctx.inbound_metadata,
|
||||||
specialist=ctx.specialist,
|
specialist=ctx.specialist,
|
||||||
task_kind="turn",
|
task_kind="turn",
|
||||||
policy_engine=None,
|
policy_engine=None,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import json
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from runtime.scheduler.cron_service import build_delivery_for_session
|
from runtime.scheduler.cron_service import build_delivery_for_session
|
||||||
|
from runtime.scheduler.whatsapp_mentions import merge_whatsapp_mention_jids, merge_whatsapp_mention_names
|
||||||
from runtime.scheduler.expressions import normalize_schedule_kind
|
from runtime.scheduler.expressions import normalize_schedule_kind
|
||||||
from runtime.scheduler.system_timezone import default_system_timezone
|
from runtime.scheduler.system_timezone import default_system_timezone
|
||||||
from runtime.scheduler.service import run_scheduled_job_now
|
from runtime.scheduler.service import run_scheduled_job_now
|
||||||
|
|
@ -61,6 +62,14 @@ def schedule_create_tool() -> ToolSpec:
|
||||||
session_id=str(args.get("session_id") or ""),
|
session_id=str(args.get("session_id") or ""),
|
||||||
whatsapp_chat_id=str(args.get("whatsapp_chat_id") or ""),
|
whatsapp_chat_id=str(args.get("whatsapp_chat_id") or ""),
|
||||||
)
|
)
|
||||||
|
delivery = merge_whatsapp_mention_jids(
|
||||||
|
delivery,
|
||||||
|
args.get("whatsapp_mention_jids") if args.get("whatsapp_mention_jids") is not None else None,
|
||||||
|
)
|
||||||
|
delivery = merge_whatsapp_mention_names(
|
||||||
|
delivery,
|
||||||
|
args.get("whatsapp_mention_names") if args.get("whatsapp_mention_names") is not None else None,
|
||||||
|
)
|
||||||
interaction_mode = normalize_interaction_mode(
|
interaction_mode = normalize_interaction_mode(
|
||||||
str(args.get("interaction_mode") or "expert")
|
str(args.get("interaction_mode") or "expert")
|
||||||
)
|
)
|
||||||
|
|
@ -89,7 +98,7 @@ def schedule_create_tool() -> ToolSpec:
|
||||||
|
|
||||||
return ToolSpec(
|
return ToolSpec(
|
||||||
name="schedule_create",
|
name="schedule_create",
|
||||||
description="Create a scheduled job (cron, once, or interval). Delivery follows the current chat channel (WhatsApp vs WeChat) unless delivery is set explicitly.",
|
description="Create a scheduled job (cron, once, or interval). Delivery follows the current chat channel (WhatsApp vs WeChat) unless delivery is set explicitly. For WhatsApp group @mentions, set whatsapp_mention_jids with explicit JIDs.",
|
||||||
parameters={
|
parameters={
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|
@ -106,6 +115,11 @@ def schedule_create_tool() -> ToolSpec:
|
||||||
"selected_specialist": {"type": "string"},
|
"selected_specialist": {"type": "string"},
|
||||||
"lang": {"type": "string"},
|
"lang": {"type": "string"},
|
||||||
"whatsapp_chat_id": {"type": "string"},
|
"whatsapp_chat_id": {"type": "string"},
|
||||||
|
"whatsapp_mention_jids": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
"description": "WhatsApp JIDs to @mention on delivery (e.g. 628...@s.whatsapp.net). Stored in delivery.whatsapp.mention_jids.",
|
||||||
|
},
|
||||||
"delivery": {"type": "object"},
|
"delivery": {"type": "object"},
|
||||||
"description": {"type": "string"},
|
"description": {"type": "string"},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ from typing import Any, Mapping
|
||||||
from sqlalchemy import and_, delete, distinct, func, insert, literal, or_, select, update
|
from sqlalchemy import and_, delete, distinct, func, insert, literal, or_, select, update
|
||||||
from sqlalchemy.engine import Engine
|
from sqlalchemy.engine import Engine
|
||||||
|
|
||||||
from svc.persistence.db.tables import app_user, chat_message, chat_session, ui_session_owner
|
from svc.persistence.db.tables import app_user, channel_session_v2, chat_message, chat_session, ui_session_owner
|
||||||
from svc.persistence.sqlite_store import ChatSession, SessionsListMeta
|
from svc.persistence.sqlite_store import ChatSession, SessionsListMeta
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -197,6 +197,108 @@ class ChatSessionsSaRepository:
|
||||||
uname = str(username or "").strip().lower()
|
uname = str(username or "").strip().lower()
|
||||||
return func.lower(app_user.c.username) == uname
|
return func.lower(app_user.c.username) == uname
|
||||||
|
|
||||||
|
def _administrator_chat_session_ids_subquery(self, *, username: str, tenant_id: str) -> Any:
|
||||||
|
pred = self._administrator_username_predicate(username)
|
||||||
|
tid = str(tenant_id or "").strip()
|
||||||
|
admin_ids = (
|
||||||
|
select(chat_session.c.id.label("sid"))
|
||||||
|
.select_from(
|
||||||
|
chat_session.join(
|
||||||
|
ui_session_owner,
|
||||||
|
ui_session_owner.c.session_id == chat_session.c.id,
|
||||||
|
).join(
|
||||||
|
app_user,
|
||||||
|
and_(
|
||||||
|
app_user.c.id == ui_session_owner.c.user_id,
|
||||||
|
app_user.c.tenant_id == ui_session_owner.c.tenant_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(pred)
|
||||||
|
)
|
||||||
|
channel_ids = (
|
||||||
|
select(channel_session_v2.c.session_id.label("sid"))
|
||||||
|
.where(
|
||||||
|
channel_session_v2.c.tenant_id == tid,
|
||||||
|
channel_session_v2.c.session_id.isnot(None),
|
||||||
|
channel_session_v2.c.session_id != "",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return admin_ids.union(channel_ids).subquery()
|
||||||
|
|
||||||
|
def list_chat_sessions_for_administrator_chat_view(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
username: str,
|
||||||
|
tenant_id: str,
|
||||||
|
limit: int | None,
|
||||||
|
offset: int,
|
||||||
|
) -> list[ChatSession]:
|
||||||
|
ids = self._administrator_chat_session_ids_subquery(
|
||||||
|
username=username, tenant_id=tenant_id
|
||||||
|
)
|
||||||
|
stmt = (
|
||||||
|
select(
|
||||||
|
chat_session.c.id,
|
||||||
|
chat_session.c.title,
|
||||||
|
chat_session.c.created_at,
|
||||||
|
chat_session.c.last_message_at,
|
||||||
|
)
|
||||||
|
.where(chat_session.c.id.in_(select(ids.c.sid)))
|
||||||
|
.order_by(*_activity_order())
|
||||||
|
)
|
||||||
|
if limit is not None:
|
||||||
|
stmt = stmt.limit(int(limit)).offset(int(offset))
|
||||||
|
with self._engine.connect() as conn:
|
||||||
|
rows = conn.execute(stmt).mappings().all()
|
||||||
|
return [_session_from_row(r) for r in rows]
|
||||||
|
|
||||||
|
def sessions_list_meta_for_administrator_chat_view(
|
||||||
|
self, *, username: str, tenant_id: str
|
||||||
|
) -> SessionsListMeta:
|
||||||
|
ids = self._administrator_chat_session_ids_subquery(
|
||||||
|
username=username, tenant_id=tenant_id
|
||||||
|
)
|
||||||
|
with self._engine.connect() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
select(
|
||||||
|
func.count().label("c"),
|
||||||
|
func.max(
|
||||||
|
func.coalesce(chat_session.c.last_message_at, chat_session.c.created_at)
|
||||||
|
).label("latest_activity_at"),
|
||||||
|
)
|
||||||
|
.select_from(chat_session)
|
||||||
|
.where(chat_session.c.id.in_(select(ids.c.sid)))
|
||||||
|
).mappings().first()
|
||||||
|
return SessionsListMeta(
|
||||||
|
session_count=int(row["c"] or 0) if row else 0,
|
||||||
|
latest_activity_at=str(row["latest_activity_at"])
|
||||||
|
if row and row.get("latest_activity_at") is not None
|
||||||
|
else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def fetch_chat_session_for_administrator_chat_view(
|
||||||
|
self, *, session_id: str, username: str, tenant_id: str
|
||||||
|
) -> ChatSession | None:
|
||||||
|
sid = str(session_id or "").strip()
|
||||||
|
if not sid:
|
||||||
|
return None
|
||||||
|
ids = self._administrator_chat_session_ids_subquery(
|
||||||
|
username=username, tenant_id=tenant_id
|
||||||
|
)
|
||||||
|
with self._engine.connect() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
select(
|
||||||
|
chat_session.c.id,
|
||||||
|
chat_session.c.title,
|
||||||
|
chat_session.c.created_at,
|
||||||
|
chat_session.c.last_message_at,
|
||||||
|
)
|
||||||
|
.where(chat_session.c.id == sid, chat_session.c.id.in_(select(ids.c.sid)))
|
||||||
|
.limit(1)
|
||||||
|
).mappings().first()
|
||||||
|
return _session_from_row(row) if row else None
|
||||||
|
|
||||||
def list_chat_sessions_for_administrator_username(
|
def list_chat_sessions_for_administrator_username(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|
|
||||||
|
|
@ -1546,6 +1546,14 @@ class SqliteStore(ScheduledJobStoreMixin):
|
||||||
created_at=utc_now_iso(),
|
created_at=utc_now_iso(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def set_ui_session_owner(self, *, session_id: str, tenant_id: str, user_id: str) -> None:
|
||||||
|
self._ui_session_owner_repo().upsert_replace(
|
||||||
|
session_id=str(session_id),
|
||||||
|
tenant_id=str(tenant_id),
|
||||||
|
user_id=str(user_id),
|
||||||
|
created_at=utc_now_iso(),
|
||||||
|
)
|
||||||
|
|
||||||
def get_session(self, session_id: str) -> Optional[ChatSession]:
|
def get_session(self, session_id: str) -> Optional[ChatSession]:
|
||||||
return self._chat_sessions_repo().fetch_chat_session_by_id(session_id=session_id)
|
return self._chat_sessions_repo().fetch_chat_session_by_id(session_id=session_id)
|
||||||
|
|
||||||
|
|
@ -1629,6 +1637,35 @@ class SqliteStore(ScheduledJobStoreMixin):
|
||||||
username=username
|
username=username
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def list_sessions_for_administrator_chat_view(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
username: str,
|
||||||
|
tenant_id: str,
|
||||||
|
limit: int | None = None,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> list[ChatSession]:
|
||||||
|
return self._chat_sessions_repo().list_chat_sessions_for_administrator_chat_view(
|
||||||
|
username=username,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
limit=limit,
|
||||||
|
offset=int(offset),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_sessions_list_meta_for_administrator_chat_view(
|
||||||
|
self, *, username: str, tenant_id: str
|
||||||
|
) -> SessionsListMeta:
|
||||||
|
return self._chat_sessions_repo().sessions_list_meta_for_administrator_chat_view(
|
||||||
|
username=username, tenant_id=tenant_id
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_session_for_administrator_chat_view(
|
||||||
|
self, *, session_id: str, username: str, tenant_id: str
|
||||||
|
) -> Optional[ChatSession]:
|
||||||
|
return self._chat_sessions_repo().fetch_chat_session_for_administrator_chat_view(
|
||||||
|
session_id=session_id, username=username, tenant_id=tenant_id
|
||||||
|
)
|
||||||
|
|
||||||
def get_session_for_administrator_username(
|
def get_session_for_administrator_username(
|
||||||
self, *, session_id: str, username: str
|
self, *, session_id: str, username: str
|
||||||
) -> Optional[ChatSession]:
|
) -> Optional[ChatSession]:
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,13 @@ from runtime.orchestration.group_ingest import (
|
||||||
is_nonsend_channel_reply_text,
|
is_nonsend_channel_reply_text,
|
||||||
normalize_jid,
|
normalize_jid,
|
||||||
normalize_group_session_scope,
|
normalize_group_session_scope,
|
||||||
|
normalize_mentioned_users_in_text,
|
||||||
|
prepare_group_user_text_for_model,
|
||||||
resolve_group_policy,
|
resolve_group_policy,
|
||||||
session_user_key,
|
session_user_key,
|
||||||
should_inject_quoted_context,
|
should_inject_quoted_context,
|
||||||
should_process_group_inbound,
|
should_process_group_inbound,
|
||||||
|
strip_bot_mentions_from_text,
|
||||||
text_mentions_bot,
|
text_mentions_bot,
|
||||||
)
|
)
|
||||||
from interfaces.channels.base import InboundMessage
|
from interfaces.channels.base import InboundMessage
|
||||||
|
|
@ -356,8 +359,56 @@ def test_build_group_sender_context() -> None:
|
||||||
metadata={"raw": {"pushName": "Alice"}},
|
metadata={"raw": {"pushName": "Alice"}},
|
||||||
external_user_id="111@s.whatsapp.net",
|
external_user_id="111@s.whatsapp.net",
|
||||||
)
|
)
|
||||||
assert "Alice" in ctx
|
assert ctx == "[发言: Alice]"
|
||||||
assert "111@s.whatsapp.net" in ctx
|
assert "111@s.whatsapp.net" not in ctx
|
||||||
|
|
||||||
|
|
||||||
|
def test_strip_bot_mentions_from_text() -> None:
|
||||||
|
meta = {"bot_lid": "162788605444170@lid", "bot_push_name": "oliver"}
|
||||||
|
out = strip_bot_mentions_from_text(
|
||||||
|
text="@162788605444170 生成小鹿照片",
|
||||||
|
bot_jid="8618142387786@s.whatsapp.net",
|
||||||
|
metadata=meta,
|
||||||
|
)
|
||||||
|
assert out == "生成小鹿照片"
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_mentioned_users_in_text_uses_nickname() -> None:
|
||||||
|
out = normalize_mentioned_users_in_text(
|
||||||
|
text="每三分钟提醒@200846277140511 喝水",
|
||||||
|
mention_jids=["200846277140511@lid"],
|
||||||
|
mention_names=["吴华"],
|
||||||
|
)
|
||||||
|
assert out == "每三分钟提醒@吴华 喝水"
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepare_group_user_text_for_model_user_in_chat() -> None:
|
||||||
|
out = prepare_group_user_text_for_model(
|
||||||
|
text="@162788605444170 每三分钟提醒@200846277140511 喝水",
|
||||||
|
metadata={"bot_lid": "162788605444170@lid", "bot_push_name": "oliver"},
|
||||||
|
mentions=["162788605444170@lid", "200846277140511@lid"],
|
||||||
|
bot_jid="8618142387786@s.whatsapp.net",
|
||||||
|
session_scope="user_in_chat",
|
||||||
|
external_user_id="8618142387786@s.whatsapp.net",
|
||||||
|
filtered_mention_jids=["200846277140511@lid"],
|
||||||
|
mention_names=["吴华"],
|
||||||
|
)
|
||||||
|
assert out == "每三分钟提醒@吴华 喝水"
|
||||||
|
assert "[发言:" not in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepare_group_user_text_for_model_shared_chat_prefix() -> None:
|
||||||
|
out = prepare_group_user_text_for_model(
|
||||||
|
text="@999 帮忙",
|
||||||
|
metadata={"raw": {"pushName": "Alice"}},
|
||||||
|
mentions=["999@s.whatsapp.net"],
|
||||||
|
bot_jid="999@s.whatsapp.net",
|
||||||
|
session_scope="chat",
|
||||||
|
external_user_id="111@s.whatsapp.net",
|
||||||
|
)
|
||||||
|
assert out.startswith("[发言: Alice]")
|
||||||
|
assert out.endswith("帮忙")
|
||||||
|
assert "@999" not in out
|
||||||
|
|
||||||
|
|
||||||
def test_build_group_focus_instruction() -> None:
|
def test_build_group_focus_instruction() -> None:
|
||||||
|
|
@ -421,6 +472,7 @@ def test_build_whatsapp_group_reply_metadata() -> None:
|
||||||
assert meta["mention_jids"] == ["111:12@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:12@s.whatsapp.net"
|
assert meta["quote_participant"] == "111:12@s.whatsapp.net"
|
||||||
|
assert meta["mention_names"] == ["Alice"]
|
||||||
|
|
||||||
|
|
||||||
def test_default_group_session_is_per_user(fresh_sqlite_store: SqliteStore) -> None:
|
def test_default_group_session_is_per_user(fresh_sqlite_store: SqliteStore) -> None:
|
||||||
|
|
@ -608,7 +660,7 @@ def test_inbound_group_mention_uses_per_user_session_and_sender_prefix(
|
||||||
{
|
{
|
||||||
**base,
|
**base,
|
||||||
"user_id": "111@s.whatsapp.net",
|
"user_id": "111@s.whatsapp.net",
|
||||||
"text": "@bot hi",
|
"text": "@999 hi",
|
||||||
"mentions": ["999@s.whatsapp.net"],
|
"mentions": ["999@s.whatsapp.net"],
|
||||||
"metadata": {**base["metadata"], "raw": {"pushName": "Alice"}},
|
"metadata": {**base["metadata"], "raw": {"pushName": "Alice"}},
|
||||||
}
|
}
|
||||||
|
|
@ -617,7 +669,7 @@ def test_inbound_group_mention_uses_per_user_session_and_sender_prefix(
|
||||||
{
|
{
|
||||||
**base,
|
**base,
|
||||||
"user_id": "222@s.whatsapp.net",
|
"user_id": "222@s.whatsapp.net",
|
||||||
"text": "@bot again",
|
"text": "@999 again",
|
||||||
"mentions": ["999@s.whatsapp.net"],
|
"mentions": ["999@s.whatsapp.net"],
|
||||||
"metadata": {**base["metadata"], "raw": {"pushName": "Bob"}},
|
"metadata": {**base["metadata"], "raw": {"pushName": "Bob"}},
|
||||||
}
|
}
|
||||||
|
|
@ -625,9 +677,10 @@ def test_inbound_group_mention_uses_per_user_session_and_sender_prefix(
|
||||||
|
|
||||||
assert len(session_ids) == 2
|
assert len(session_ids) == 2
|
||||||
assert session_ids[0] != session_ids[1]
|
assert session_ids[0] != session_ids[1]
|
||||||
assert "[群成员:" in captured["text"]
|
assert "[群成员:" not in captured["text"]
|
||||||
assert "群聊规则" in captured["text"]
|
assert "[发言:" not in captured["text"]
|
||||||
assert "Bob" in captured["text"]
|
assert captured["text"] == "again"
|
||||||
|
assert "群聊规则" not in captured["text"]
|
||||||
|
|
||||||
sid = store.get_or_create_channel_session_v2(
|
sid = store.get_or_create_channel_session_v2(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
|
|
@ -882,3 +935,112 @@ def test_inbound_group_reply_includes_quote_and_mention_metadata(
|
||||||
assert meta.get("quote_stanza_id") == "ABC123"
|
assert meta.get("quote_stanza_id") == "ABC123"
|
||||||
assert meta.get("mention_jids") == ["111@s.whatsapp.net"]
|
assert meta.get("mention_jids") == ["111@s.whatsapp.net"]
|
||||||
assert meta.get("quote_text") == "@bot 明天几点?"
|
assert meta.get("quote_text") == "@bot 明天几点?"
|
||||||
|
|
||||||
|
|
||||||
|
def test_inbound_group_schedule_mention_metadata_preserved_after_text_clean(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, fresh_sqlite_store: SqliteStore
|
||||||
|
) -> None:
|
||||||
|
store = fresh_sqlite_store
|
||||||
|
tenant_id, _ = _setup_whatsapp_identity(store)
|
||||||
|
store.upsert_whatsapp_contact(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
account_id="wa-default",
|
||||||
|
external_user_id="200846277140511@lid",
|
||||||
|
push_name="WuHua",
|
||||||
|
list_type="whitelist",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("svc.persistence.assistant_store.get_assistant_store", lambda: store)
|
||||||
|
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
|
class _Turn:
|
||||||
|
turn_uuid = "turn-sched"
|
||||||
|
reply_text = "ok"
|
||||||
|
|
||||||
|
class _Gw:
|
||||||
|
def __init__(self, *, store: object) -> None:
|
||||||
|
_ = store
|
||||||
|
|
||||||
|
def handle_turn(self, **kwargs: object) -> _Turn:
|
||||||
|
msg = kwargs.get("msg")
|
||||||
|
captured["text"] = str(getattr(msg, "text", "") or "")
|
||||||
|
captured["metadata"] = getattr(msg, "metadata", None)
|
||||||
|
return _Turn()
|
||||||
|
|
||||||
|
monkeypatch.setattr("runtime.gateway.OclawGateway", _Gw)
|
||||||
|
|
||||||
|
process_inbound_payload(
|
||||||
|
{
|
||||||
|
"channel": "whatsapp",
|
||||||
|
"account_id": "wa-default",
|
||||||
|
"user_id": "111@s.whatsapp.net",
|
||||||
|
"chat_id": "120363012345678@g.us",
|
||||||
|
"text": "@999 remind @200846277140511 water",
|
||||||
|
"is_group": True,
|
||||||
|
"mentions": ["999@s.whatsapp.net", "200846277140511@lid"],
|
||||||
|
"metadata": {
|
||||||
|
"bot_jid": "999@s.whatsapp.net",
|
||||||
|
"bot_lid": "162788605444170@lid",
|
||||||
|
"raw": {"pushName": "Alice"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
meta = captured.get("metadata") if isinstance(captured.get("metadata"), dict) else {}
|
||||||
|
assert "200846277140511@lid" in list(meta.get("mentioned_jids") or [])
|
||||||
|
assert meta.get("raw_inbound_text")
|
||||||
|
text = str(captured.get("text") or "")
|
||||||
|
assert "200846277140511" not in text
|
||||||
|
assert "@WuHua" in text
|
||||||
|
assert "@999" not in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_channel_group_session_visible_in_administrator_chat_list(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, fresh_sqlite_store: SqliteStore
|
||||||
|
) -> None:
|
||||||
|
store = fresh_sqlite_store
|
||||||
|
tenant_id, owner_user_id = _setup_whatsapp_identity(store, extra_user_ids=["222@s.whatsapp.net"])
|
||||||
|
monkeypatch.setattr("svc.persistence.assistant_store.get_assistant_store", lambda: store)
|
||||||
|
|
||||||
|
session_ids: list[str] = []
|
||||||
|
|
||||||
|
class _Turn:
|
||||||
|
turn_uuid = "turn-guest"
|
||||||
|
reply_text = "ok"
|
||||||
|
|
||||||
|
class _Gw:
|
||||||
|
def __init__(self, *, store: object) -> None:
|
||||||
|
_ = store
|
||||||
|
|
||||||
|
def handle_turn(self, **kwargs: object) -> _Turn:
|
||||||
|
msg = kwargs.get("msg")
|
||||||
|
session_ids.append(str(getattr(msg, "session_id", "") or ""))
|
||||||
|
return _Turn()
|
||||||
|
|
||||||
|
monkeypatch.setattr("runtime.gateway.OclawGateway", _Gw)
|
||||||
|
|
||||||
|
process_inbound_payload(
|
||||||
|
{
|
||||||
|
"channel": "whatsapp",
|
||||||
|
"account_id": "wa-default",
|
||||||
|
"user_id": "222@s.whatsapp.net",
|
||||||
|
"chat_id": "120363012345678@g.us",
|
||||||
|
"text": "@999 hello from bob",
|
||||||
|
"is_group": True,
|
||||||
|
"mentions": ["999@s.whatsapp.net"],
|
||||||
|
"metadata": {"bot_jid": "999@s.whatsapp.net", "raw": {"pushName": "Bob"}},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert session_ids
|
||||||
|
owner = store.get_ui_session_owner(session_id=session_ids[0]) or {}
|
||||||
|
assert owner.get("user_id") == owner_user_id
|
||||||
|
|
||||||
|
listed = store.list_sessions_for_administrator_chat_view(
|
||||||
|
username="administrator",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
limit=20,
|
||||||
|
offset=0,
|
||||||
|
)
|
||||||
|
listed_ids = {s.id for s in listed}
|
||||||
|
assert session_ids[0] in listed_ids
|
||||||
|
|
|
||||||
74
tests/test_whatsapp_mention_jid_field.py
Normal file
74
tests/test_whatsapp_mention_jid_field.py
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from runtime.scheduler.whatsapp_mentions import format_whatsapp_mention_text, merge_whatsapp_mention_jids, normalize_whatsapp_mention_jids
|
||||||
|
from svc.persistence.assistant_store import reset_assistant_store_singleton
|
||||||
|
from svc.persistence.sqlite_store import SqliteStore
|
||||||
|
|
||||||
|
|
||||||
|
class WhatsappMentionJidFieldTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||||
|
self.db = Path(self._tmp.name) / "wa_names.sqlite"
|
||||||
|
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
|
||||||
|
os.environ["AIA_ASSISTANT_DB_BACKEND"] = "sqlite"
|
||||||
|
reset_assistant_store_singleton()
|
||||||
|
self.store = SqliteStore(str(self.db))
|
||||||
|
tenant = self.store.create_tenant("Team")
|
||||||
|
self.tenant_id = str(tenant["id"])
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
reset_assistant_store_singleton()
|
||||||
|
self._tmp.cleanup()
|
||||||
|
def test_normalize_whatsapp_mention_jids(self) -> None:
|
||||||
|
out = normalize_whatsapp_mention_jids(
|
||||||
|
["6281@s.whatsapp.net", "6282@s.whatsapp.net, 6283@s.whatsapp.net"]
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
out,
|
||||||
|
["6281@s.whatsapp.net", "6282@s.whatsapp.net", "6283@s.whatsapp.net"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_merge_whatsapp_mention_jids_into_delivery(self) -> None:
|
||||||
|
delivery = merge_whatsapp_mention_jids(
|
||||||
|
{"whatsapp": {"enabled": True, "chat_id": "120@g.us"}},
|
||||||
|
["6281@s.whatsapp.net"],
|
||||||
|
)
|
||||||
|
self.assertEqual(delivery["whatsapp"]["mention_jids"], ["6281@s.whatsapp.net"])
|
||||||
|
|
||||||
|
def test_format_whatsapp_mention_text_uses_push_name(self) -> None:
|
||||||
|
out = format_whatsapp_mention_text(
|
||||||
|
"💧 @+1 33346537541839 该喝水啦",
|
||||||
|
["333465375410398@lid"],
|
||||||
|
store=self.store,
|
||||||
|
tenant_id=self.tenant_id,
|
||||||
|
account_id="wa-default",
|
||||||
|
mention_names=["Egista Hadi Putranto"],
|
||||||
|
)
|
||||||
|
self.assertEqual(out, "@Egista Hadi Putranto 💧 该喝水啦")
|
||||||
|
|
||||||
|
def test_format_whatsapp_mention_text_looks_up_push_name_by_jid(self) -> None:
|
||||||
|
self.store.upsert_whatsapp_contact(
|
||||||
|
tenant_id=self.tenant_id,
|
||||||
|
account_id="wa-default",
|
||||||
|
external_user_id="333465375410398@lid",
|
||||||
|
push_name="Egista Hadi Putranto",
|
||||||
|
phone="33346537541839",
|
||||||
|
list_type="whitelist",
|
||||||
|
)
|
||||||
|
out = format_whatsapp_mention_text(
|
||||||
|
"该喝水啦",
|
||||||
|
["333465375410398@lid"],
|
||||||
|
store=self.store,
|
||||||
|
tenant_id=self.tenant_id,
|
||||||
|
account_id="wa-default",
|
||||||
|
)
|
||||||
|
self.assertEqual(out, "@Egista Hadi Putranto 该喝水啦")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
116
tests/test_whatsapp_scheduled_mentions.py
Normal file
116
tests/test_whatsapp_scheduled_mentions.py
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from runtime.scheduler.channel_delivery import deliver_scheduled_reply
|
||||||
|
from svc.persistence.assistant_store import reset_assistant_store_singleton
|
||||||
|
from svc.persistence.sqlite_store import SqliteStore
|
||||||
|
|
||||||
|
|
||||||
|
class WhatsappScheduledMentionTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||||
|
self.db = Path(self._tmp.name) / "wa_mentions.sqlite"
|
||||||
|
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
|
||||||
|
os.environ["AIA_ASSISTANT_DB_BACKEND"] = "sqlite"
|
||||||
|
reset_assistant_store_singleton()
|
||||||
|
self.store = SqliteStore(str(self.db))
|
||||||
|
tenant = self.store.create_tenant("Team")
|
||||||
|
self.tenant_id = str(tenant["id"])
|
||||||
|
self.store.upsert_whatsapp_contact(
|
||||||
|
tenant_id=self.tenant_id,
|
||||||
|
account_id="wa-default",
|
||||||
|
external_user_id="333465375410398@lid",
|
||||||
|
push_name="Egista Hadi Putranto",
|
||||||
|
phone="33346537541839",
|
||||||
|
list_type="whitelist",
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
reset_assistant_store_singleton()
|
||||||
|
self._tmp.cleanup()
|
||||||
|
|
||||||
|
def test_deliver_scheduled_reply_encodes_explicit_mention_jids(self) -> None:
|
||||||
|
delivery = {
|
||||||
|
"whatsapp": {
|
||||||
|
"enabled": True,
|
||||||
|
"target_type": "group",
|
||||||
|
"chat_id": "120363012345678@g.us",
|
||||||
|
"account_id": "wa-default",
|
||||||
|
"mention_jids": ["333465375410398@lid"],
|
||||||
|
},
|
||||||
|
"weixin": {"enabled": False},
|
||||||
|
}
|
||||||
|
reply_text = "💧 @+1 33346537541839 该喝水啦!记得保持水分哦~"
|
||||||
|
result = deliver_scheduled_reply(
|
||||||
|
self.store,
|
||||||
|
tenant_id=self.tenant_id,
|
||||||
|
reply_text=reply_text,
|
||||||
|
delivery_json=__import__("json").dumps(delivery),
|
||||||
|
)
|
||||||
|
self.assertTrue(result.get("ok"), result)
|
||||||
|
pending = self.store.list_pending_channel_outbound_messages(
|
||||||
|
channel="whatsapp",
|
||||||
|
account_id="wa-default",
|
||||||
|
limit=5,
|
||||||
|
)
|
||||||
|
self.assertEqual(len(pending), 1)
|
||||||
|
source = __import__("json").loads(str(pending[0].get("source") or "{}"))
|
||||||
|
self.assertEqual(source.get("mention_jids"), ["333465375410398@lid"])
|
||||||
|
out_text = str(pending[0].get("text") or "")
|
||||||
|
self.assertTrue(out_text.startswith("@Egista Hadi Putranto"))
|
||||||
|
self.assertIn("该喝水啦", out_text)
|
||||||
|
self.assertNotIn("@+1", out_text)
|
||||||
|
self.assertTrue(source.get("mention_text_ready"))
|
||||||
|
self.assertEqual(source.get("mention_names"), ["Egista Hadi Putranto"])
|
||||||
|
|
||||||
|
def test_deliver_scheduled_reply_infers_mentions_from_phoneish_text(self) -> None:
|
||||||
|
self.store.upsert_whatsapp_contact(
|
||||||
|
tenant_id=self.tenant_id,
|
||||||
|
account_id="wa-default",
|
||||||
|
external_user_id="200846277140511@lid",
|
||||||
|
push_name="吴华",
|
||||||
|
phone="0846277140511",
|
||||||
|
list_type="whitelist",
|
||||||
|
)
|
||||||
|
delivery = {
|
||||||
|
"whatsapp": {
|
||||||
|
"enabled": True,
|
||||||
|
"target_type": "group",
|
||||||
|
"chat_id": "120363012345678@g.us",
|
||||||
|
"account_id": "wa-default",
|
||||||
|
},
|
||||||
|
"weixin": {"enabled": False},
|
||||||
|
}
|
||||||
|
reply_text = "💧 @+20 0846277140511 该喝水啦!快去补充一下水分吧~ 💧"
|
||||||
|
result = deliver_scheduled_reply(
|
||||||
|
self.store,
|
||||||
|
tenant_id=self.tenant_id,
|
||||||
|
reply_text=reply_text,
|
||||||
|
delivery_json=__import__("json").dumps(delivery),
|
||||||
|
)
|
||||||
|
self.assertTrue(result.get("ok"), result)
|
||||||
|
pending = self.store.list_pending_channel_outbound_messages(
|
||||||
|
channel="whatsapp",
|
||||||
|
account_id="wa-default",
|
||||||
|
limit=5,
|
||||||
|
)
|
||||||
|
self.assertEqual(len(pending), 1)
|
||||||
|
source = __import__("json").loads(str(pending[0].get("source") or "{}"))
|
||||||
|
self.assertEqual(source.get("mention_jids"), ["200846277140511@lid"])
|
||||||
|
out_text = str(pending[0].get("text") or "")
|
||||||
|
self.assertTrue(out_text.startswith("@吴华"))
|
||||||
|
self.assertIn("该喝水啦", out_text)
|
||||||
|
self.assertNotIn("@+20", out_text)
|
||||||
|
|
||||||
|
def test_normalize_bare_digits_to_lid(self) -> None:
|
||||||
|
from runtime.scheduler.whatsapp_mentions import normalize_whatsapp_mention_jids
|
||||||
|
|
||||||
|
self.assertEqual(normalize_whatsapp_mention_jids(["200846277140511"]), ["200846277140511@lid"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue