fix(scheduler): isolate scheduled runs and @mention job creator on WhatsApp

Use a fresh execution session per run while keeping source_session_id for delivery only; filter bot JIDs and default scheduled @mentions to the job creator.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-07-21 14:49:31 +08:00
parent f5cb64a3a4
commit 62ac04c40d
8 changed files with 524 additions and 166 deletions

View file

@ -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 "定时提醒",

View file

@ -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()

View file

@ -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,

View file

@ -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,

View file

@ -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,
)

View file

@ -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",

View file

@ -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"},
},

View file

@ -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()