diff --git a/runtime/application/gateway/inbound_service.py b/runtime/application/gateway/inbound_service.py index c3806bef..15346441 100644 --- a/runtime/application/gateway/inbound_service.py +++ b/runtime/application/gateway/inbound_service.py @@ -334,6 +334,7 @@ def _handle_productivity_commands( session_id=str(session_id or "").strip(), ) from runtime.scheduler.job_delete import merge_delivery_creator + from runtime.scheduler.whatsapp_mentions import finalize_whatsapp_scheduled_delivery delivery = merge_delivery_creator( delivery, @@ -342,6 +343,11 @@ def _handle_productivity_commands( push_name=str(creator_push_name or ""), session_id=str(session_id or "").strip(), ) + delivery = finalize_whatsapp_scheduled_delivery( + delivery, + creator_external_user_id=str(creator_external_user_id or ""), + creator_push_name=str(creator_push_name or ""), + ) row = store.scheduled_job_create( tenant_id=tenant_id, name=prompt_text[:40] or "定时提醒", diff --git a/runtime/chat/tool_runtime.py b/runtime/chat/tool_runtime.py index f3aa5019..0cc8b56b 100644 --- a/runtime/chat/tool_runtime.py +++ b/runtime/chat/tool_runtime.py @@ -552,6 +552,12 @@ class ToolExecutor: tool_args["creator_external_user_id"] = external_uid if push_name and not str(tool_args.get("creator_push_name") or "").strip(): tool_args["creator_push_name"] = push_name + bot_jid_raw = str(md.get("bot_jid") or raw_block.get("botJid") or "").strip() + bot_lid_raw = str(md.get("bot_lid") or md.get("botLid") or raw_block.get("botLid") or "").strip() + if bot_jid_raw and not str(tool_args.get("whatsapp_bot_jid") or "").strip(): + tool_args["whatsapp_bot_jid"] = bot_jid_raw + if bot_lid_raw and not str(tool_args.get("whatsapp_bot_lid") or "").strip(): + tool_args["whatsapp_bot_lid"] = bot_lid_raw 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() diff --git a/runtime/scheduler/channel_delivery.py b/runtime/scheduler/channel_delivery.py index 780860f6..dc822460 100644 --- a/runtime/scheduler/channel_delivery.py +++ b/runtime/scheduler/channel_delivery.py @@ -9,8 +9,7 @@ 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, + resolve_scheduled_whatsapp_mention_targets, ) @@ -282,30 +281,13 @@ def deliver_scheduled_reply( wa.get("account_id") or resolved_account_id or os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default" ).strip() if wa_enabled and chat_id and (text or has_attachments): - 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 + mention_jids, mention_names = resolve_scheduled_whatsapp_mention_targets( + delivery=delivery, + reply_text=text, + store=store, + tenant_id=tenant_id, + account_id=account_id, + ) out_text = format_whatsapp_mention_text( text, mention_jids, diff --git a/runtime/scheduler/service.py b/runtime/scheduler/service.py index 83da0d6e..8526305d 100644 --- a/runtime/scheduler/service.py +++ b/runtime/scheduler/service.py @@ -46,6 +46,7 @@ def enqueue_scheduled_job_run( store, job=job, created_by_user_id=str(getattr(job, "created_by_user_id", "") or ""), + run_id=str(run.id), ) except Exception as exc: store.scheduled_job_run_update( @@ -95,6 +96,7 @@ def enqueue_scheduled_job_run( "trace_id": trace_id, "run_id": agent_run_id, "session_id": resolved.session_id, + "source_session_id": str(resolved.source_session_id or getattr(job, "source_session_id", "") or ""), "tenant_id": tenant_id, "user_id": resolved.user_id, "viewer_username": viewer_username, diff --git a/runtime/scheduler/session_resolver.py b/runtime/scheduler/session_resolver.py index f25aee57..302924c9 100644 --- a/runtime/scheduler/session_resolver.py +++ b/runtime/scheduler/session_resolver.py @@ -19,6 +19,7 @@ class ResolvedSession: external_chat_id: str external_user_id: str is_group: bool + source_session_id: str = "" def _ensure_administrator_owner(store: Any, *, tenant_id: str) -> dict[str, Any] | None: @@ -50,6 +51,137 @@ def _ensure_administrator_owner(store: Any, *, tenant_id: str) -> dict[str, Any] } +def _create_scheduled_execution_session( + store: Any, + *, + tenant_id: str, + user_id: str, + job_name: str, + run_id: str = "", +) -> str: + rid = str(run_id or "").strip() + title = f"Scheduled · {job_name}" + if rid: + title = f"{title} · {rid[:8]}" + tid = str(tenant_id or "").strip() + uid = str(user_id or "").strip() + if uid and tid: + sess = store.create_session_for_user(title=title, tenant_id=tid, user_id=uid) + else: + sess = store.create_session(title) + return str(sess.id) + + +def _resolve_scheduled_channel_context( + store: Any, + *, + job: Any, + created_by_user_id: str = "", +) -> tuple[str, str, str, str, str, str, bool, str]: + """Return tenant_id, user_id, channel, account_id, external_chat_id, external_user_id, is_group, source_session_id.""" + tenant_id = str(getattr(job, "tenant_id", "") or "") + delivery = parse_delivery_json(str(getattr(job, "delivery_json", "") or "{}")) + source_session_id = str(getattr(job, "source_session_id", "") or "").strip() + + if source_session_id: + sess = store.get_session_in_tenant(session_id=source_session_id, tenant_id=tenant_id) + if sess: + owner = store.get_ui_session_owner(session_id=source_session_id) or {} + user_id = str(owner.get("user_id") or created_by_user_id or "").strip() + if not user_id: + admin = _ensure_administrator_owner(store, tenant_id=tenant_id) + user_id = str((admin or {}).get("user_id") or "") + channel_ctx = None + lookup = getattr(store, "lookup_channel_session_by_session_id", None) + if callable(lookup): + channel_ctx = lookup(tenant_id=tenant_id, session_id=source_session_id) + if isinstance(channel_ctx, dict) and str(channel_ctx.get("channel") or "").strip(): + ch = str(channel_ctx.get("channel") or "").strip().lower() + account_id = str(channel_ctx.get("account_id") or "").strip() + external_chat_id = str(channel_ctx.get("external_chat_id") or "").strip() + external_user_id = str(channel_ctx.get("external_user_id") or "").strip() + is_group = ch == "whatsapp" and external_chat_id.endswith("@g.us") + return ( + tenant_id, + user_id, + ch, + account_id or ("weixin-default" if ch in {"weixin", "wechat"} else ""), + external_chat_id, + external_user_id, + is_group, + source_session_id, + ) + return ( + tenant_id, + user_id, + "admin_chat", + "", + "", + "", + False, + source_session_id, + ) + + wa = delivery.get("whatsapp") if isinstance(delivery.get("whatsapp"), dict) else {} + wx = delivery.get("weixin") if isinstance(delivery.get("weixin"), dict) else {} + wa_enabled = bool(wa.get("enabled")) and str(wa.get("target_type") or "none") != "none" + wx_enabled = bool(wx.get("enabled", True)) + + if wa_enabled and str(wa.get("chat_id") or "").strip(): + chat_id = str(wa.get("chat_id") or "").strip() + account_id = str(wa.get("account_id") or os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default").strip() + target_type = str(wa.get("target_type") or "direct").strip().lower() + is_group = target_type == "group" or chat_id.endswith("@g.us") + external_user_id = session_user_key(is_group=is_group, external_user_id=chat_id.split("@", 1)[0]) + admin = _ensure_administrator_owner(store, tenant_id=tenant_id) + user_id = str(created_by_user_id or (admin or {}).get("user_id") or "") + return ( + tenant_id, + user_id, + "whatsapp", + account_id, + chat_id, + external_user_id, + is_group, + "", + ) + + if wx_enabled: + binding = resolve_weixin_binding(store, tenant_id=tenant_id) + if not binding: + raise RuntimeError("weixin_binding_missing") + channel = str(binding.get("channel") or "weixin") + account_id = str(binding.get("account_id") or "weixin-default") + external_user_id = str(binding.get("external_user_id") or "") + external_chat_id = str(binding.get("external_chat_id") or external_user_id) + user_id = str(binding.get("user_id") or created_by_user_id or "") + return ( + tenant_id, + user_id, + channel, + account_id, + external_chat_id, + external_user_id, + False, + "", + ) + + admin = _ensure_administrator_owner(store, tenant_id=tenant_id) + user_id = str(created_by_user_id or (admin or {}).get("user_id") or "") + if not user_id: + raise RuntimeError("scheduled_session_owner_missing") + return ( + tenant_id, + user_id, + "admin_chat", + "", + "", + "", + False, + source_session_id, + ) + + def resolve_weixin_binding(store: Any, *, tenant_id: str) -> dict[str, Any] | None: owner = _ensure_administrator_owner(store, tenant_id=tenant_id) if not owner: @@ -123,153 +255,40 @@ def resolve_scheduled_session( *, job: Any, created_by_user_id: str = "", + run_id: str = "", ) -> ResolvedSession: - tenant_id = str(getattr(job, "tenant_id", "") or "") - delivery = parse_delivery_json(str(getattr(job, "delivery_json", "") or "{}")) - source_session_id = str(getattr(job, "source_session_id", "") or "").strip() job_name = str(getattr(job, "name", "") or "Scheduled task") - - if source_session_id: - sess = store.get_session_in_tenant(session_id=source_session_id, tenant_id=tenant_id) - if sess: - owner = store.get_ui_session_owner(session_id=source_session_id) or {} - user_id = str(owner.get("user_id") or created_by_user_id or "").strip() - if not user_id: - admin = _ensure_administrator_owner(store, tenant_id=tenant_id) - user_id = str((admin or {}).get("user_id") or "") - channel_ctx = None - lookup = getattr(store, "lookup_channel_session_by_session_id", None) - if callable(lookup): - channel_ctx = lookup(tenant_id=tenant_id, session_id=source_session_id) - if isinstance(channel_ctx, dict) and str(channel_ctx.get("channel") or "").strip(): - ch = str(channel_ctx.get("channel") or "").strip().lower() - account_id = str(channel_ctx.get("account_id") or "").strip() - external_chat_id = str(channel_ctx.get("external_chat_id") or "").strip() - external_user_id = str(channel_ctx.get("external_user_id") or "").strip() - is_group = ch == "whatsapp" and external_chat_id.endswith("@g.us") - return ResolvedSession( - session_id=source_session_id, - tenant_id=tenant_id, - user_id=user_id, - channel=ch, - account_id=account_id or ("weixin-default" if ch in {"weixin", "wechat"} else ""), - external_chat_id=external_chat_id, - external_user_id=external_user_id, - is_group=is_group, - ) - return ResolvedSession( - session_id=source_session_id, - tenant_id=tenant_id, - user_id=user_id, - channel="admin_chat", - account_id="", - external_chat_id="", - external_user_id="", - is_group=False, - ) - - wa = delivery.get("whatsapp") if isinstance(delivery.get("whatsapp"), dict) else {} - wx = delivery.get("weixin") if isinstance(delivery.get("weixin"), dict) else {} - wa_enabled = bool(wa.get("enabled")) and str(wa.get("target_type") or "none") != "none" - wx_enabled = bool(wx.get("enabled", True)) - - if wa_enabled and str(wa.get("chat_id") or "").strip(): - chat_id = str(wa.get("chat_id") or "").strip() - account_id = str(wa.get("account_id") or os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default").strip() - target_type = str(wa.get("target_type") or "direct").strip().lower() - is_group = target_type == "group" or chat_id.endswith("@g.us") - external_user_id = session_user_key(is_group=is_group, external_user_id=chat_id.split("@", 1)[0]) - admin = _ensure_administrator_owner(store, tenant_id=tenant_id) - user_id = str(created_by_user_id or (admin or {}).get("user_id") or "") - session_id = store.get_or_create_channel_session_v2( - tenant_id=tenant_id, - channel="whatsapp", - account_id=account_id, - external_chat_id=chat_id, - external_user_id=external_user_id, - session_title=f"Scheduled · {job_name}", - ) - 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="whatsapp", - account_id=account_id, - tenant_id=tenant_id, - fallback_user_id=user_id, - ) - return ResolvedSession( - session_id=session_id, - tenant_id=tenant_id, - user_id=user_id, - channel="whatsapp", - account_id=account_id, - external_chat_id=chat_id, - external_user_id=external_user_id, - is_group=is_group, - ) - - if wx_enabled: - binding = resolve_weixin_binding(store, tenant_id=tenant_id) - if not binding: - raise RuntimeError("weixin_binding_missing") - channel = str(binding.get("channel") or "weixin") - account_id = str(binding.get("account_id") or "weixin-default") - external_user_id = str(binding.get("external_user_id") or "") - external_chat_id = str(binding.get("external_chat_id") or external_user_id) - user_id = str(binding.get("user_id") or created_by_user_id or "") - session_id = store.get_or_create_channel_session_v2( - tenant_id=tenant_id, - channel=channel, - account_id=account_id, - external_chat_id=external_chat_id, - external_user_id=external_user_id, - session_title=f"Scheduled · {job_name}", - ) - 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=channel, - account_id=account_id, - tenant_id=tenant_id, - fallback_user_id=user_id, - ) - return ResolvedSession( - session_id=session_id, - tenant_id=tenant_id, - user_id=user_id, - channel=channel, - account_id=account_id, - external_chat_id=external_chat_id, - external_user_id=external_user_id, - is_group=False, - ) - - admin = _ensure_administrator_owner(store, tenant_id=tenant_id) - user_id = str(created_by_user_id or (admin or {}).get("user_id") or "") - if not user_id: - raise RuntimeError("scheduled_session_owner_missing") - sess = store.create_session_for_user( - title=f"Scheduled · {job_name}", + ( + tenant_id, + user_id, + channel, + account_id, + external_chat_id, + external_user_id, + is_group, + source_session_id, + ) = _resolve_scheduled_channel_context( + store, + job=job, + created_by_user_id=created_by_user_id, + ) + execution_session_id = _create_scheduled_execution_session( + store, tenant_id=tenant_id, user_id=user_id, + job_name=job_name, + run_id=run_id, ) return ResolvedSession( - session_id=str(sess.id), + session_id=execution_session_id, tenant_id=tenant_id, user_id=user_id, - channel="admin_chat", - account_id="", - external_chat_id="", - external_user_id="", - is_group=False, + channel=channel, + account_id=account_id, + external_chat_id=external_chat_id, + external_user_id=external_user_id, + is_group=is_group, + source_session_id=source_session_id, ) diff --git a/runtime/scheduler/whatsapp_mentions.py b/runtime/scheduler/whatsapp_mentions.py index 8acd7d90..854d7e22 100644 --- a/runtime/scheduler/whatsapp_mentions.py +++ b/runtime/scheduler/whatsapp_mentions.py @@ -273,6 +273,116 @@ def mention_tag_for_jid(jid: str, *, push_name: str = "") -> str: 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], @@ -311,6 +421,8 @@ __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", diff --git a/runtime/tools/experts/productivity/schedule_tools.py b/runtime/tools/experts/productivity/schedule_tools.py index e78e0a9e..859cc258 100644 --- a/runtime/tools/experts/productivity/schedule_tools.py +++ b/runtime/tools/experts/productivity/schedule_tools.py @@ -24,7 +24,11 @@ from runtime.scheduler.recipe import ( ) from runtime.scheduler.service import run_scheduled_job_now from runtime.scheduler.system_timezone import default_system_timezone -from runtime.scheduler.whatsapp_mentions import merge_whatsapp_mention_jids, merge_whatsapp_mention_names +from runtime.scheduler.whatsapp_mentions import ( + finalize_whatsapp_scheduled_delivery, + merge_whatsapp_mention_jids, + merge_whatsapp_mention_names, +) from runtime.tools.base import ToolSpec from runtime.tools.context_inject import enrich_tool_arguments from runtime.types import normalize_interaction_mode, normalize_requested_specialist @@ -243,6 +247,13 @@ def schedule_create_tool() -> ToolSpec: push_name=str(args.get("creator_push_name") or "").strip(), session_id=str(session_id or ""), ) + delivery = finalize_whatsapp_scheduled_delivery( + delivery, + creator_external_user_id=str(args.get("creator_external_user_id") or "").strip(), + creator_push_name=str(args.get("creator_push_name") or "").strip(), + bot_jid=str(args.get("whatsapp_bot_jid") or "").strip(), + bot_lid=str(args.get("whatsapp_bot_lid") or "").strip(), + ) interaction_mode = normalize_interaction_mode( str(args.get("interaction_mode") or "expert") ) @@ -325,6 +336,14 @@ def schedule_create_tool() -> ToolSpec: "type": "string", "description": "Auto-filled channel display name of the creator.", }, + "whatsapp_bot_jid": { + "type": "string", + "description": "Auto-filled bot JID for filtering self-mentions on delivery.", + }, + "whatsapp_bot_lid": { + "type": "string", + "description": "Auto-filled bot LID for filtering self-mentions on delivery.", + }, "delivery": {"type": "object"}, "description": {"type": "string"}, }, diff --git a/tests/test_scheduled_session_isolation.py b/tests/test_scheduled_session_isolation.py new file mode 100644 index 00000000..47f989f4 --- /dev/null +++ b/tests/test_scheduled_session_isolation.py @@ -0,0 +1,212 @@ +from __future__ import annotations + +import json +import os +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from runtime.scheduler.channel_delivery import deliver_scheduled_reply +from runtime.scheduler.session_resolver import resolve_scheduled_session +from runtime.scheduler.whatsapp_mentions import ( + finalize_whatsapp_scheduled_delivery, + resolve_scheduled_whatsapp_mention_targets, +) +from svc.persistence.sqlite_store import SqliteStore + + +class ScheduledSessionIsolationTests(unittest.TestCase): + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True) + self.db = Path(self._tmp.name) / "sched_iso.sqlite" + os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db) + self.store = SqliteStore(str(self.db)) + self.tenant_id = str(self.store.create_tenant("Team")["id"]) + admin = self.store.create_user_account( + tenant_id=self.tenant_id, + username="administrator", + display_name="Admin", + role="owner", + password_hash="x", + is_active=True, + ) + self.admin_id = str(admin["id"]) + interactive = self.store.create_session("WA interactive") + self.interactive_session_id = str(interactive.id) + self.store.ensure_ui_session_owner( + session_id=self.interactive_session_id, + tenant_id=self.tenant_id, + user_id=self.admin_id, + ) + with self.store._connect() as conn: + conn.execute( + """ + INSERT INTO channel_session_v2 + (tenant_id, channel, account_id, external_chat_id, external_user_id, session_id, created_at) + VALUES (?, ?, ?, ?, ?, ?, datetime('now')) + """, + ( + self.tenant_id, + "whatsapp", + "wa-default", + "120363012345678@g.us", + "333465375410398@lid", + self.interactive_session_id, + ), + ) + self.store.add_message( + session_id=self.interactive_session_id, + role="user", + content="帮我设个喝水提醒", + ) + + def tearDown(self) -> None: + self._tmp.cleanup() + + def test_resolve_scheduled_session_uses_fresh_execution_session(self) -> None: + job = mock.MagicMock() + job.tenant_id = self.tenant_id + job.source_session_id = self.interactive_session_id + job.delivery_json = json.dumps( + { + "whatsapp": { + "enabled": True, + "target_type": "group", + "chat_id": "120363012345678@g.us", + "account_id": "wa-default", + }, + "weixin": {"enabled": False}, + } + ) + job.name = "喝水提醒" + + resolved = resolve_scheduled_session( + self.store, + job=job, + created_by_user_id=self.admin_id, + run_id="run-abc12345", + ) + self.assertNotEqual(resolved.session_id, self.interactive_session_id) + self.assertEqual(resolved.source_session_id, self.interactive_session_id) + self.assertEqual(resolved.external_chat_id, "120363012345678@g.us") + rows = self.store.get_messages(session_id=resolved.session_id, limit=10) + self.assertEqual(len(rows), 0) + + +class ScheduledMentionCreatorTests(unittest.TestCase): + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True) + self.db = Path(self._tmp.name) / "sched_mention.sqlite" + 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", + ) + self.store.upsert_whatsapp_contact( + tenant_id=self.tenant_id, + account_id="wa-default", + external_user_id="999999999999@lid", + push_name="Oliver", + phone="", + list_type="whitelist", + ) + + def tearDown(self) -> None: + self._tmp.cleanup() + + def test_finalize_defaults_mention_to_creator_and_filters_bot(self) -> None: + delivery = finalize_whatsapp_scheduled_delivery( + { + "whatsapp": { + "enabled": True, + "target_type": "group", + "chat_id": "120363012345678@g.us", + "mention_jids": ["999999999999@lid"], + }, + "creator": { + "external_user_id": "333465375410398@lid", + "push_name": "Egista Hadi Putranto", + }, + }, + creator_external_user_id="333465375410398@lid", + creator_push_name="Egista Hadi Putranto", + bot_jid="", + bot_lid="999999999999@lid", + ) + wa = delivery["whatsapp"] + self.assertEqual(wa["mention_jids"], ["333465375410398@lid"]) + self.assertEqual(wa["mention_names"], ["Egista Hadi Putranto"]) + + def test_resolve_scheduled_mention_targets_prefers_creator_when_only_bot_configured(self) -> None: + delivery = { + "whatsapp": { + "enabled": True, + "mention_jids": ["999999999999@lid"], + "bot_lid": "999999999999@lid", + }, + "creator": { + "external_user_id": "333465375410398@lid", + "push_name": "Egista Hadi Putranto", + }, + } + jids, names = resolve_scheduled_whatsapp_mention_targets( + delivery=delivery, + reply_text="该喝水啦", + store=self.store, + tenant_id=self.tenant_id, + account_id="wa-default", + ) + self.assertEqual(jids, ["333465375410398@lid"]) + self.assertEqual(names, ["Egista Hadi Putranto"]) + + def test_deliver_scheduled_reply_mentions_creator_not_bot(self) -> None: + delivery = finalize_whatsapp_scheduled_delivery( + { + "whatsapp": { + "enabled": True, + "target_type": "group", + "chat_id": "120363012345678@g.us", + "account_id": "wa-default", + "mention_jids": ["999999999999@lid"], + }, + }, + creator_external_user_id="333465375410398@lid", + creator_push_name="Egista Hadi Putranto", + bot_lid="999999999999@lid", + ) + delivery = { + **delivery, + "weixin": {"enabled": False}, + "creator": { + "external_user_id": "333465375410398@lid", + "push_name": "Egista Hadi Putranto", + }, + } + result = deliver_scheduled_reply( + self.store, + tenant_id=self.tenant_id, + reply_text="💧 该喝水啦!", + delivery_json=json.dumps(delivery, ensure_ascii=False), + ) + 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) + out_text = str(pending[0].get("text") or "") + self.assertTrue(out_text.startswith("@Egista Hadi Putranto")) + source = json.loads(str(pending[0].get("source") or "{}")) + self.assertEqual(source.get("mention_jids"), ["333465375410398@lid"]) + + +if __name__ == "__main__": + unittest.main()