feat(scheduler): add scheduled jobs with channel-aware delivery

Introduce persisted cron/interval jobs, gateway scheduler loop, worker turns,
and admin CRUD/edit UI. Route proactive reminders via the originating chat
channel (WhatsApp vs WeChat), harden Weixin outbound with durable queue and
PG-compatible polling, and fix chat UI to show each scheduled reminder separately.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-06-26 15:56:10 +08:00
parent 5336f8835f
commit 47813f6a94
45 changed files with 4956 additions and 43 deletions

View file

@ -0,0 +1,3 @@
from __future__ import annotations
__all__ = []

View file

@ -0,0 +1,252 @@
from __future__ import annotations
import json
import os
from typing import Any
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
def _encode_weixin_outbound_source(*, context_token: str) -> str:
return json.dumps(
{"kind": "scheduled_job", "context_token": str(context_token or "").strip()},
ensure_ascii=False,
)
def _decode_weixin_outbound_source(raw: str) -> dict[str, Any]:
text = str(raw or "").strip()
if not text:
return {}
try:
data = json.loads(text)
return data if isinstance(data, dict) else {}
except Exception:
return {}
def enqueue_weixin_reply(
*,
channel: str,
account_id: str,
chat_id: str,
text: str,
context_token: str = "",
store: Any = None,
tenant_id: str = "",
) -> dict[str, Any]:
from runtime.scheduler.weixin_delivery import normalize_weixin_channel
ctx_tok = str(context_token or "").strip()
if not ctx_tok:
return {
"ok": False,
"channel": normalize_weixin_channel(channel),
"error": "context_token_missing",
"hint": "Send any message to the bot on WeChat first, then retry the scheduled job.",
}
durable_id = ""
enqueuer = getattr(store, "enqueue_channel_outbound_message", None)
if store is not None and callable(enqueuer):
try:
durable_id = str(
enqueuer(
channel=normalize_weixin_channel(channel),
chat_id=str(chat_id or "").strip(),
text=str(text or ""),
tenant_id=str(tenant_id or ""),
account_id=str(account_id or "").strip(),
source=_encode_weixin_outbound_source(context_token=ctx_tok),
)
or ""
).strip()
except Exception:
durable_id = ""
try:
from interfaces.http.weixin_ilink_api import enqueue_weixin_outbound_reply
except Exception as exc:
if durable_id:
return {
"ok": True,
"channel": normalize_weixin_channel(channel),
"message_id": durable_id,
"queued": True,
"durable": True,
"context_token_present": True,
"bridge_error": f"{type(exc).__name__}: {exc}",
}
return {"ok": False, "channel": channel, "error": f"{type(exc).__name__}: {exc}"}
try:
bridge_seq = enqueue_weixin_outbound_reply(
channel=normalize_weixin_channel(channel),
account_id=str(account_id or "").strip(),
chat_id=str(chat_id or "").strip(),
text=text,
context_token=ctx_tok,
)
return {
"ok": True,
"channel": normalize_weixin_channel(channel),
"message_id": durable_id or bridge_seq,
"bridge_seq": bridge_seq,
"queued": True,
"durable": bool(durable_id),
"context_token_present": True,
"account_id": str(account_id or "").strip(),
"chat_id": str(chat_id or "").strip(),
}
except Exception as exc:
if durable_id:
return {
"ok": True,
"channel": normalize_weixin_channel(channel),
"message_id": durable_id,
"queued": True,
"durable": True,
"context_token_present": True,
"bridge_error": f"{type(exc).__name__}: {exc}",
}
return {"ok": False, "channel": channel, "error": f"{type(exc).__name__}: {exc}"}
def persist_channel_context_token(
store: Any,
*,
tenant_id: str,
channel: str,
account_id: str,
external_chat_id: str,
context_token: str,
) -> None:
from runtime.scheduler.weixin_delivery import normalize_weixin_channel
setter = getattr(store, "set_channel_context_token", None)
if not callable(setter):
return
tok = str(context_token or "").strip()
chat_id = str(external_chat_id or "").strip()
if not tok or not chat_id:
return
tid = str(tenant_id or "")
acct = str(account_id or "").strip()
channels = []
for ch in (channel, "wechat", "weixin"):
c = normalize_weixin_channel(str(ch or "")) if str(ch or "").lower() in {"wechat", "weixin"} else str(ch or "").strip().lower()
if c and c not in channels:
channels.append(c)
account_ids = [acct] if acct else [""]
if acct:
account_ids.append("")
for ch in channels:
for aid in account_ids:
setter(
tenant_id=tid,
channel=ch,
account_id=aid,
external_chat_id=chat_id,
context_token=tok,
)
def extract_context_token_from_inbound_metadata(metadata: dict[str, Any] | None) -> str:
meta = metadata if isinstance(metadata, dict) else {}
raw = meta.get("raw") if isinstance(meta.get("raw"), dict) else {}
msg = raw.get("msg") if isinstance(raw.get("msg"), dict) else {}
for candidate in (
msg.get("context_token"),
(raw.get("metadata") or {}).get("context_token") if isinstance(raw.get("metadata"), dict) else None,
raw.get("context_token"),
meta.get("context_token"),
):
tok = str(candidate or "").strip()
if tok:
return tok
return ""
def deliver_scheduled_reply(
store: Any,
*,
tenant_id: str,
reply_text: str,
delivery_json: str,
resolved_channel: str = "",
resolved_chat_id: str = "",
resolved_account_id: str = "",
session_id: str = "",
) -> dict[str, Any]:
text = str(reply_text or "").strip()
if not text or is_nonsend_channel_reply_text(text):
return {"ok": False, "skipped": True, "reason": "empty_or_silent_reply"}
ch_lower = str(resolved_channel or "").strip().lower()
if ch_lower in {"wechat", "weixin"}:
from runtime.application.gateway.inbound_service import _user_facing_wechat_reply
text = _user_facing_wechat_reply(reply=text)
delivery = parse_delivery_json(delivery_json)
results: dict[str, Any] = {}
wa = delivery.get("whatsapp") if isinstance(delivery.get("whatsapp"), dict) else {}
wa_enabled = bool(wa.get("enabled")) and str(wa.get("target_type") or "none") != "none"
chat_id = str(wa.get("chat_id") or resolved_chat_id or "").strip()
account_id = str(
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 should_send_channel_reply_text(text):
msg_id = store.enqueue_channel_outbound_message(
channel="whatsapp",
chat_id=chat_id,
text=text,
tenant_id=tenant_id,
account_id=account_id,
source="scheduled_job",
)
results["whatsapp"] = {"ok": True, "message_id": msg_id, "chat_id": chat_id}
wx = delivery.get("weixin") if isinstance(delivery.get("weixin"), dict) else {}
wx_enabled = bool(wx.get("enabled", True))
if wx_enabled and should_send_channel_reply_text(text):
from runtime.scheduler.weixin_delivery import resolve_weixin_delivery_target
target = resolve_weixin_delivery_target(
store,
tenant_id=tenant_id,
session_id=session_id,
delivery=delivery,
resolved_channel=resolved_channel,
resolved_chat_id=resolved_chat_id,
resolved_account_id=resolved_account_id,
)
wx_chat = str(target.get("chat_id") or "").strip()
wx_account = str(target.get("account_id") or "").strip()
wx_channel = str(target.get("channel") or "wechat")
context_token = str(target.get("context_token") or "").strip()
if wx_chat:
results["weixin"] = enqueue_weixin_reply(
channel=wx_channel,
account_id=wx_account,
chat_id=wx_chat,
text=text,
context_token=context_token,
store=store,
tenant_id=tenant_id,
)
else:
results["weixin"] = {"ok": False, "error": "weixin_chat_missing"}
if not results:
return {"ok": True, "skipped": True, "reason": "no_delivery_targets"}
ok = all(bool((v or {}).get("ok")) for v in results.values() if isinstance(v, dict))
return {"ok": ok, "channels": results}
__all__ = [
"deliver_scheduled_reply",
"enqueue_weixin_reply",
"extract_context_token_from_inbound_metadata",
"persist_channel_context_token",
]

View file

@ -0,0 +1,201 @@
from __future__ import annotations
import json
from typing import Any
from runtime.scheduler.expressions import compute_next_run_at, normalize_schedule_kind
from runtime.scheduler.service import run_scheduled_job_now
from runtime.scheduler.session_resolver import parse_delivery_json, resolve_weixin_binding
class CronService:
def __init__(self, *, store: Any) -> None:
self.store = store
def status(self) -> dict[str, Any]:
return {"running": True}
def wake(self, params: dict[str, Any]) -> dict[str, Any]:
return {"ok": True, **dict(params or {})}
def listPage(self, params: dict[str, Any]) -> dict[str, Any]:
tenant_id = str((params or {}).get("tenantId") or (params or {}).get("tenant_id") or "default").strip()
status = str((params or {}).get("enabled") or (params or {}).get("status") or "").strip() or None
if status in {"true", "1"}:
status = "active"
elif status in {"false", "0"}:
status = "paused"
limit = int((params or {}).get("limit") or 50)
offset = int((params or {}).get("offset") or 0)
rows = self.store.scheduled_job_list(
tenant_id=tenant_id,
status=status,
limit=limit,
offset=offset,
)
items = [self._job_to_gateway_item(self.store.scheduled_job_to_dict(r)) for r in rows]
return {"items": items, "total": len(items)}
def add(self, params: dict[str, Any]) -> dict[str, Any]:
p = dict(params or {})
tenant_id = str(p.get("tenantId") or p.get("tenant_id") or "default").strip()
name = str(p.get("name") or p.get("schedule") or "cron job").strip()
schedule = str(p.get("schedule") or p.get("schedule_expr") or "").strip()
schedule_kind = normalize_schedule_kind(p.get("schedule_kind") or p.get("scheduleKind") or "cron")
prompt = str(p.get("prompt") or p.get("prompt_text") or p.get("text") or name).strip()
job = self.store.scheduled_job_create(
tenant_id=tenant_id,
name=name,
prompt_text=prompt,
schedule_kind=schedule_kind,
schedule_expr=schedule,
timezone_name=str(p.get("timezone") or "Asia/Shanghai"),
description=str(p.get("description") or ""),
interaction_mode=str(p.get("interaction_mode") or "expert"),
specialist=str(p.get("specialist") or "generalist"),
lang=str(p.get("lang") or "zh"),
delivery=p.get("delivery") if isinstance(p.get("delivery"), dict) else {},
source="gateway",
)
return self._job_to_gateway_item(self.store.scheduled_job_to_dict(job))
def update(self, job_id: str, patch: dict[str, Any]) -> dict[str, Any]:
p = dict(patch or {})
tenant_id = str(p.pop("tenantId", None) or p.pop("tenant_id", None) or "default").strip()
mapped: dict[str, Any] = {}
for src, dst in (
("name", "name"),
("schedule", "schedule_expr"),
("schedule_kind", "schedule_kind"),
("prompt", "prompt_text"),
("prompt_text", "prompt_text"),
("timezone", "timezone"),
("interaction_mode", "interaction_mode"),
("specialist", "specialist"),
("lang", "lang"),
("delivery", "delivery"),
("enabled", "status"),
):
if src in p:
mapped[dst] = p[src]
if "enabled" in mapped:
mapped["status"] = "active" if bool(mapped.pop("enabled")) else "paused"
job = self.store.scheduled_job_update(tenant_id=tenant_id, job_id=str(job_id), patch=mapped)
if not job:
return {"id": job_id, "ok": False}
return self._job_to_gateway_item(self.store.scheduled_job_to_dict(job))
def remove(self, job_id: str) -> dict[str, Any]:
rows = self.store.scheduled_job_list(tenant_id="default", limit=500)
for row in rows:
if str(row.id) == str(job_id):
self.store.scheduled_job_delete(tenant_id=row.tenant_id, job_id=str(job_id))
return {"removed": True, "id": job_id}
self.store.scheduled_job_delete(tenant_id="default", job_id=str(job_id))
return {"removed": True, "id": job_id}
def enqueueRun(self, job_id: str, mode: str = "force") -> dict[str, Any]:
rows = self.store.scheduled_job_list(tenant_id="default", limit=500)
tenant_id = "default"
for row in rows:
if str(row.id) == str(job_id):
tenant_id = str(row.tenant_id)
break
out = run_scheduled_job_now(self.store, tenant_id=tenant_id, job_id=str(job_id))
return {"ok": bool(out.get("ok")), "ran": bool(out.get("ok")), "jobId": job_id, "mode": mode, **out}
def listRuns(self, params: dict[str, Any]) -> dict[str, Any]:
p = dict(params or {})
job_id = str(p.get("jobId") or p.get("id") or "").strip()
tenant_id = str(p.get("tenantId") or p.get("tenant_id") or "default").strip()
limit = int(p.get("limit") or 50)
if not job_id:
return {"items": [], "total": 0}
rows = self.store.scheduled_job_run_list(job_id=job_id, tenant_id=tenant_id, limit=limit)
items = [self.store.scheduled_job_run_to_dict(r) for r in rows]
return {"items": items, "total": len(items), "jobId": job_id}
def _job_to_gateway_item(self, row: dict[str, Any]) -> dict[str, Any]:
return {
"id": row.get("id"),
"name": row.get("name"),
"schedule": row.get("schedule_expr"),
"schedule_kind": row.get("schedule_kind"),
"enabled": str(row.get("status") or "") == "active",
"prompt": row.get("prompt_text"),
"timezone": row.get("timezone"),
"nextRunAt": row.get("next_run_at"),
"lastRunAt": row.get("last_run_at"),
"specialist": row.get("specialist"),
"interaction_mode": row.get("interaction_mode"),
"delivery": row.get("delivery"),
}
def build_default_delivery(*, store: Any, tenant_id: str, whatsapp_chat_id: str = "") -> dict[str, Any]:
import os
delivery: dict[str, Any] = {
"whatsapp": {
"enabled": bool(str(whatsapp_chat_id or "").strip()),
"target_type": "group" if str(whatsapp_chat_id or "").endswith("@g.us") else "direct",
"chat_id": str(whatsapp_chat_id or ""),
"account_id": str(os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default"),
},
"weixin": {"enabled": True, "fixed": True},
}
if not delivery["whatsapp"]["enabled"]:
delivery["whatsapp"]["target_type"] = "none"
binding = resolve_weixin_binding(store, tenant_id=tenant_id)
if binding:
ext = str(binding.get("external_user_id") or "")
delivery["weixin"]["external_user_id"] = ext
delivery["weixin"]["external_chat_id"] = str(binding.get("external_chat_id") or ext)
delivery["weixin"]["account_id"] = str(binding.get("account_id") or "weixin-default")
return delivery
def build_delivery_for_session(
store: Any,
*,
tenant_id: str,
session_id: str = "",
whatsapp_chat_id: str = "",
) -> dict[str, Any]:
"""Pick delivery targets from the chat session that created the job (WhatsApp vs WeChat)."""
import os
tid = str(tenant_id or "").strip()
sid = str(session_id or "").strip()
explicit_wa = str(whatsapp_chat_id or "").strip()
lookup = getattr(store, "lookup_channel_session_by_session_id", None)
if sid and callable(lookup):
ctx = lookup(tenant_id=tid, session_id=sid)
if isinstance(ctx, dict):
ch = str(ctx.get("channel") or "").strip().lower()
chat_id = str(ctx.get("external_chat_id") or "").strip()
acct = str(ctx.get("account_id") or "").strip()
if ch == "whatsapp" and chat_id:
return {
"whatsapp": {
"enabled": True,
"target_type": "group" if chat_id.endswith("@g.us") else "direct",
"chat_id": chat_id,
"account_id": acct or str(os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default"),
},
"weixin": {"enabled": False, "fixed": False},
}
if ch in {"weixin", "wechat"}:
delivery = build_default_delivery(store=store, tenant_id=tid, whatsapp_chat_id="")
wa = delivery.get("whatsapp") if isinstance(delivery.get("whatsapp"), dict) else {}
delivery["whatsapp"] = {
**wa,
"enabled": False,
"target_type": "none",
"chat_id": "",
}
return delivery
return build_default_delivery(store=store, tenant_id=tid, whatsapp_chat_id=explicit_wa)
__all__ = ["CronService", "build_default_delivery", "build_delivery_for_session", "compute_next_run_at"]

View file

@ -0,0 +1,77 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any
from zoneinfo import ZoneInfo
try:
from croniter import croniter
except ImportError: # pragma: no cover - guarded in requirements
croniter = None # type: ignore[assignment,misc]
def normalize_schedule_kind(raw: Any) -> str:
kind = str(raw or "").strip().lower()
if kind in {"cron", "once", "interval"}:
return kind
return "cron"
def _parse_iso_dt(value: str) -> datetime:
text = str(value or "").strip()
if not text:
raise ValueError("empty datetime")
if text.endswith("Z"):
text = text[:-1] + "+00:00"
dt = datetime.fromisoformat(text)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def compute_next_run_at(
*,
schedule_kind: str,
schedule_expr: str,
timezone_name: str = "Asia/Shanghai",
from_dt: datetime | None = None,
) -> str | None:
kind = normalize_schedule_kind(schedule_kind)
expr = str(schedule_expr or "").strip()
if not expr:
return None
base = from_dt or datetime.now(timezone.utc)
if base.tzinfo is None:
base = base.replace(tzinfo=timezone.utc)
else:
base = base.astimezone(timezone.utc)
if kind == "once":
target = _parse_iso_dt(expr)
if target <= base:
return None
return target.isoformat()
if kind == "interval":
try:
seconds = max(1, int(expr))
except ValueError as exc:
raise ValueError(f"invalid interval seconds: {expr}") from exc
nxt = base + timedelta(seconds=seconds)
return nxt.isoformat()
if croniter is None:
raise RuntimeError("croniter is required for cron schedules")
try:
tz = ZoneInfo(str(timezone_name or "Asia/Shanghai"))
except Exception:
tz = ZoneInfo("Asia/Shanghai")
local_base = base.astimezone(tz)
itr = croniter(expr, local_base)
nxt_local = itr.get_next(datetime)
if nxt_local.tzinfo is None:
nxt_local = nxt_local.replace(tzinfo=tz)
return nxt_local.astimezone(timezone.utc).isoformat()
__all__ = ["compute_next_run_at", "normalize_schedule_kind"]

View file

@ -0,0 +1,200 @@
from __future__ import annotations
import json
import threading
import time
import uuid
from datetime import datetime, timezone
from typing import Any
from runtime.scheduler.session_resolver import resolve_scheduled_session, resolve_scheduled_viewer_username
from runtime.scheduler.turn_text import build_scheduled_turn_instruction
from runtime.worker import ensure_worker_started
_LOCK = threading.Lock()
_THREAD: threading.Thread | None = None
_RUNNING = False
def _tick_interval_seconds() -> float:
import os
raw = str(os.getenv("AIA_SCHEDULER_TICK_SECONDS") or "30").strip()
try:
return max(5.0, min(float(raw), 3600.0))
except Exception:
return 30.0
def enqueue_scheduled_job_run(
store: Any,
*,
job: Any,
mode: str = "scheduled",
) -> dict[str, Any]:
tenant_id = str(getattr(job, "tenant_id", "") or "")
job_id = str(getattr(job, "id") or "")
run = store.scheduled_job_run_create(
job_id=job_id,
tenant_id=tenant_id,
scheduled_at=str(getattr(job, "next_run_at", "") or datetime.now(timezone.utc).isoformat()),
status="queued",
)
try:
resolved = resolve_scheduled_session(
store,
job=job,
created_by_user_id=str(getattr(job, "created_by_user_id", "") or ""),
)
except Exception as exc:
store.scheduled_job_run_update(
run_id=run.id,
tenant_id=tenant_id,
patch={
"status": "failed",
"finished_at": datetime.now(timezone.utc).isoformat(),
"error": str(exc),
},
)
store.scheduled_job_mark_run(
job_id=job_id,
tenant_id=tenant_id,
last_run_status="failed",
pause_after=False,
)
return {"ok": False, "error": str(exc), "run_id": run.id}
delivery: dict[str, Any] = {}
try:
raw = json.loads(str(getattr(job, "delivery_json", "") or "{}"))
if isinstance(raw, dict):
delivery = raw
except Exception:
delivery = {}
trace_id = uuid.uuid4().hex
agent_run_id = uuid.uuid4().hex
prompt_text = str(getattr(job, "prompt_text", "") or "").strip()
lang = str(getattr(job, "lang", "") or "zh")
user_text = build_scheduled_turn_instruction(prompt_text=prompt_text, mode=mode, lang=lang)
viewer_username = resolve_scheduled_viewer_username(
store,
tenant_id=tenant_id,
user_id=resolved.user_id,
channel=resolved.channel,
)
payload = {
"trace_id": trace_id,
"run_id": agent_run_id,
"session_id": resolved.session_id,
"tenant_id": tenant_id,
"user_id": resolved.user_id,
"viewer_username": viewer_username,
"role": "member",
"channel": resolved.channel if resolved.channel != "admin_chat" else "admin_chat",
"lang": lang,
"text": user_text,
"prompt_text": prompt_text,
"attachments": [],
"metadata": {
"scheduled_job_id": job_id,
"scheduled_run_id": run.id,
"interaction_mode": str(getattr(job, "interaction_mode", "") or "expert"),
"selected_specialist": str(getattr(job, "specialist", "") or "generalist"),
"scheduled_mode": mode,
"scheduled_proactive": True,
},
"interaction_mode": str(getattr(job, "interaction_mode", "") or "expert"),
"requested_specialist": str(getattr(job, "specialist", "") or "generalist"),
"selected_specialist": str(getattr(job, "specialist", "") or "generalist"),
"job_id": job_id,
"run_id_scheduled": run.id,
"delivery": delivery,
"resolved_channel": resolved.channel,
"resolved_chat_id": resolved.external_chat_id,
"resolved_account_id": resolved.account_id,
}
worker_id = ensure_worker_started(store=store)
task = store.oclaw_task_create(
tenant_id=tenant_id,
session_id=resolved.session_id,
task_type="scheduled_turn",
payload=payload,
)
store.scheduled_job_run_update(
run_id=run.id,
tenant_id=tenant_id,
patch={
"status": "running",
"started_at": datetime.now(timezone.utc).isoformat(),
"session_id": resolved.session_id,
"oclaw_task_id": task.id,
"run_id": agent_run_id,
},
)
store.scheduled_job_reserve_next_run(job_id=job_id, tenant_id=tenant_id)
pause_after = str(getattr(job, "schedule_kind", "") or "") == "once"
return {
"ok": True,
"run_id": run.id,
"task_id": task.id,
"worker_id": worker_id,
"pause_after": pause_after,
}
def scheduler_tick(store: Any) -> dict[str, Any]:
due = store.scheduled_job_list_due(limit=20)
triggered = 0
errors: list[str] = []
for job in due:
try:
out = enqueue_scheduled_job_run(store, job=job, mode="scheduled")
if out.get("ok"):
triggered += 1
else:
errors.append(str(out.get("error") or "enqueue_failed"))
except Exception as exc:
errors.append(f"{getattr(job, 'id', '')}: {type(exc).__name__}: {exc}")
return {"ok": True, "due": len(due), "triggered": triggered, "errors": errors}
def run_scheduled_job_now(store: Any, *, tenant_id: str, job_id: str) -> dict[str, Any]:
job = store.scheduled_job_get(job_id=job_id, tenant_id=tenant_id)
if not job:
return {"ok": False, "error": "job_not_found"}
if str(job.status or "") != "active":
return {"ok": False, "error": "job_not_active"}
return enqueue_scheduled_job_run(store, job=job, mode="manual")
def _scheduler_loop(*, store: Any) -> None:
global _RUNNING
interval = _tick_interval_seconds()
while _RUNNING:
try:
scheduler_tick(store)
except Exception:
pass
time.sleep(interval)
def ensure_scheduler_started(*, store: Any) -> str:
global _THREAD, _RUNNING
with _LOCK:
if _THREAD and _THREAD.is_alive():
return _THREAD.name
_RUNNING = True
tid = f"oclaw-scheduler-{uuid.uuid4().hex[:8]}"
t = threading.Thread(target=_scheduler_loop, kwargs={"store": store}, name=tid, daemon=True)
t.start()
_THREAD = t
return tid
__all__ = [
"ensure_scheduler_started",
"enqueue_scheduled_job_run",
"run_scheduled_job_now",
"scheduler_tick",
]

View file

@ -0,0 +1,262 @@
from __future__ import annotations
import hashlib
import json
import os
from dataclasses import dataclass
from typing import Any
from runtime.orchestration.group_ingest import session_user_key
@dataclass(frozen=True)
class ResolvedSession:
session_id: str
tenant_id: str
user_id: str
channel: str
account_id: str
external_chat_id: str
external_user_id: str
is_group: bool
def _ensure_administrator_owner(store: Any, *, tenant_id: str) -> dict[str, Any] | None:
user = store.get_user_by_username(tenant_id=tenant_id, username="administrator")
if not user:
try:
from svc.config.passwords import load_expected_password
except Exception:
load_expected_password = None # type: ignore
pwd = load_expected_password(store) if callable(load_expected_password) else None
if not pwd:
return None
user = store.create_user_account(
tenant_id=tenant_id,
username="administrator",
password_hash=hashlib.sha256(pwd.encode("utf-8")).hexdigest(),
display_name="Administrator",
role="owner",
is_active=True,
)
user_id = str((user or {}).get("id") or "")
if not user_id:
return None
return {
"tenant_id": tenant_id,
"user_id": user_id,
"display_name": (user or {}).get("display_name") or "Administrator",
"role": str((user or {}).get("role") or "owner"),
}
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:
return None
user_id = str(owner.get("user_id") or "")
rows = store.list_channel_identities_v2(
tenant_id=tenant_id,
channel="weixin",
user_id=user_id,
limit=20,
)
if not rows:
for ch in ("wechat", "weixin"):
rows = store.list_channel_identities_v2(
tenant_id=tenant_id,
channel=ch,
user_id=user_id,
limit=20,
)
if rows:
break
if not rows:
return None
row = rows[0]
account_id = str(row.get("account_id") or "weixin-default").strip() or "weixin-default"
external_user_id = str(row.get("external_user_id") or "").strip()
if not external_user_id:
return None
return {
"tenant_id": tenant_id,
"user_id": user_id,
"channel": str(row.get("channel") or "weixin"),
"account_id": account_id,
"external_user_id": external_user_id,
"external_chat_id": external_user_id,
"is_group": False,
}
def parse_delivery_json(raw: str) -> dict[str, Any]:
try:
data = json.loads(raw or "{}")
return data if isinstance(data, dict) else {}
except Exception:
return {}
def resolve_scheduled_viewer_username(
store: Any,
*,
tenant_id: str,
user_id: str,
channel: str,
) -> str:
"""Channel proactive jobs use the administrator model pool (same as gateway inbound)."""
ch = str(channel or "").strip().lower()
if ch in {"weixin", "wechat", "whatsapp"}:
return "administrator"
uid = str(user_id or "").strip()
if uid:
user = store.get_user_by_id(tenant_id=str(tenant_id or ""), user_id=uid)
if isinstance(user, dict):
uname = str(user.get("username") or "").strip()
if uname:
return uname
return "administrator"
def resolve_scheduled_session(
store: Any,
*,
job: Any,
created_by_user_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}",
)
if user_id:
store.ensure_ui_session_owner(session_id=session_id, tenant_id=tenant_id, 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}",
)
if user_id:
store.ensure_ui_session_owner(session_id=session_id, tenant_id=tenant_id, 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=tenant_id,
user_id=user_id,
)
return ResolvedSession(
session_id=str(sess.id),
tenant_id=tenant_id,
user_id=user_id,
channel="admin_chat",
account_id="",
external_chat_id="",
external_user_id="",
is_group=False,
)
__all__ = [
"ResolvedSession",
"resolve_scheduled_session",
"resolve_scheduled_viewer_username",
"resolve_weixin_binding",
"parse_delivery_json",
]

View file

@ -0,0 +1,50 @@
from __future__ import annotations
def format_scheduled_user_reminder(prompt_text: str) -> str:
body = str(prompt_text or "").strip()
if not body:
return ""
if body.startswith("⏰"):
return body
return f"⏰ 提醒:{body}"
def build_scheduled_turn_instruction(*, prompt_text: str, mode: str, lang: str) -> str:
"""Internal LLM instruction for proactive scheduled reminders (not user-facing)."""
intent = str(prompt_text or "").strip()
_ = str(mode or "scheduled").strip()
is_en = str(lang or "").lower().startswith("en")
if is_en:
return (
"[Scheduled proactive reminder — internal instruction, not a user message]\n"
f"Reminder intent: {intent}\n"
"Write a short, friendly proactive reminder TO the user (second person). "
"Do not say you received a reminder or that you will remind someone; speak directly to the user."
)
return (
"【定时主动提醒·内部指令,不是用户发言】\n"
f"提醒意图:{intent}\n"
"请生成一条简短、自然、第二人称的主动提醒消息直接对用户说。"
"不要写「收到提醒」「好的我来提醒用户」等元对话;不要假装用户刚说了话。"
)
def scheduled_turn_system_suffix(*, lang: str) -> str:
is_en = str(lang or "").lower().startswith("en")
if is_en:
return (
"\n\n[Scheduled job mode] You are sending a proactive reminder to the user. "
"Reply with the reminder text only; do not role-play as the user."
)
return (
"\n\n【定时任务模式】你正在主动向用户发送提醒。"
"只输出提醒正文,不要扮演用户,不要写「收到/好的」等对话式应答。"
)
__all__ = [
"build_scheduled_turn_instruction",
"format_scheduled_user_reminder",
"scheduled_turn_system_suffix",
]

View file

@ -0,0 +1,90 @@
from __future__ import annotations
from typing import Any
from runtime.scheduler.session_resolver import resolve_weixin_binding
def normalize_weixin_channel(channel: str) -> str:
ch = str(channel or "wechat").strip().lower()
return "wechat" if ch in {"wechat", "weixin"} else ch
def resolve_weixin_delivery_target(
store: Any,
*,
tenant_id: str,
session_id: str,
delivery: dict[str, Any],
resolved_channel: str,
resolved_chat_id: str,
resolved_account_id: str,
) -> dict[str, Any]:
wx = delivery.get("weixin") if isinstance(delivery.get("weixin"), dict) else {}
channel = normalize_weixin_channel(
str(resolved_channel or wx.get("channel") or "wechat")
)
chat_id = str(
resolved_chat_id or wx.get("external_chat_id") or wx.get("external_user_id") or ""
).strip()
account_id = str(
resolved_account_id or wx.get("account_id") or ""
).strip()
sid = str(session_id or "").strip()
lookup_sess = getattr(store, "lookup_channel_session_by_session_id", None)
if sid and callable(lookup_sess):
ctx = lookup_sess(tenant_id=str(tenant_id or ""), session_id=sid)
if isinstance(ctx, dict):
if not chat_id:
chat_id = str(ctx.get("external_chat_id") or ctx.get("external_user_id") or "").strip()
if not account_id or account_id == "weixin-default":
account_id = str(ctx.get("account_id") or account_id or "").strip()
if channel in {"wechat", "weixin"}:
channel = normalize_weixin_channel(str(ctx.get("channel") or channel))
lookup_chat = getattr(store, "lookup_channel_session_by_chat_v2", None)
if chat_id and callable(lookup_chat) and (not account_id or account_id == "weixin-default"):
ctx = lookup_chat(
tenant_id=str(tenant_id or ""),
channel=channel,
external_chat_id=chat_id,
)
if isinstance(ctx, dict):
acct = str(ctx.get("account_id") or "").strip()
if acct:
account_id = acct
if not chat_id:
binding = resolve_weixin_binding(store, tenant_id=str(tenant_id or ""))
if isinstance(binding, dict):
chat_id = str(
binding.get("external_chat_id") or binding.get("external_user_id") or ""
).strip()
if not account_id or account_id == "weixin-default":
account_id = str(binding.get("account_id") or account_id or "").strip()
if channel in {"wechat", "weixin"}:
channel = normalize_weixin_channel(str(binding.get("channel") or channel))
context_token = str(wx.get("context_token") or "").strip()
getter = getattr(store, "get_channel_context_token_fuzzy", None)
if callable(getter) and chat_id and not context_token:
context_token = str(
getter(
tenant_id=str(tenant_id or ""),
channel=channel,
account_id=account_id,
external_chat_id=chat_id,
)
or ""
).strip()
return {
"channel": channel,
"chat_id": chat_id,
"account_id": account_id,
"context_token": context_token,
}
__all__ = ["normalize_weixin_channel", "resolve_weixin_delivery_target"]

View file

@ -0,0 +1,184 @@
from __future__ import annotations
import json
from datetime import datetime, timezone
from typing import Any
from runtime.orchestration.group_ingest import is_nonsend_channel_reply_text
from runtime.scheduler.turn_text import format_scheduled_user_reminder
def resolve_scheduled_outbound_text(*, payload: dict[str, Any], reply_text: str) -> str:
text = str(reply_text or "").strip()
if text and not is_nonsend_channel_reply_text(text):
return text
prompt = str(payload.get("prompt_text") or "").strip()
if prompt:
return format_scheduled_user_reminder(prompt)
return ""
def _persist_scheduled_assistant_reply(
store: Any,
*,
session_id: str,
turn_uuid: str,
reply_text: str,
) -> None:
sid = str(session_id or "").strip()
body = str(reply_text or "").strip()
if not sid or not body:
return
tu = str(turn_uuid or "").strip()
payload = {"scheduled_proactive": True}
try:
rows = store.get_messages(session_id=sid, limit=80)
except Exception:
rows = []
if tu:
for m in rows or []:
if str(getattr(m, "role", "") or "").lower() != "assistant":
continue
if str(getattr(m, "turn_uuid", "") or "").strip() != tu:
continue
existing = str(getattr(m, "content", "") or "").strip()
mid = int(getattr(m, "id", 0) or 0)
updater = getattr(store, "update_message_content", None)
if existing == body:
if mid > 0 and callable(updater):
merged = dict(payload)
raw_ep = getattr(m, "event_payload", None)
if isinstance(raw_ep, dict):
merged = {**raw_ep, **merged}
elif isinstance(raw_ep, str) and raw_ep.strip():
try:
parsed = json.loads(raw_ep)
if isinstance(parsed, dict):
merged = {**parsed, **merged}
except Exception:
pass
updater(
session_id=sid,
message_id=mid,
content=body,
event_payload=merged,
)
return
if not existing:
if mid > 0 and callable(updater):
updater(
session_id=sid,
message_id=mid,
content=body,
event_payload=payload,
)
return
break
for m in rows or []:
if str(getattr(m, "role", "") or "").lower() != "assistant":
continue
if str(getattr(m, "content", "") or "").strip() == body:
return
try:
store.add_message(
session_id=sid,
role="assistant",
content=body,
turn_uuid=tu or None,
event_type="assistant_text",
event_payload=payload,
)
except Exception:
pass
def finalize_scheduled_turn_success(
store: Any,
*,
task: Any,
payload: dict[str, Any],
base_result: dict[str, Any],
) -> None:
from runtime.scheduler.channel_delivery import deliver_scheduled_reply
tenant_id = str(payload.get("tenant_id") or "")
job_id = str(payload.get("job_id") or "")
scheduled_run_id = str(payload.get("run_id_scheduled") or "")
reply_text = resolve_scheduled_outbound_text(payload=payload, reply_text=str(base_result.get("reply_text") or ""))
delivery = payload.get("delivery") if isinstance(payload.get("delivery"), dict) else {}
delivery_json = json.dumps(delivery, ensure_ascii=False)
job = store.scheduled_job_get(job_id=job_id, tenant_id=tenant_id) if job_id else None
if job:
delivery_json = str(getattr(job, "delivery_json", "") or delivery_json)
_persist_scheduled_assistant_reply(
store,
session_id=str(payload.get("session_id") or ""),
turn_uuid=str(base_result.get("turn_uuid") or payload.get("run_id") or ""),
reply_text=reply_text,
)
delivery_status = deliver_scheduled_reply(
store,
tenant_id=tenant_id,
reply_text=reply_text,
delivery_json=delivery_json,
resolved_channel=str(payload.get("resolved_channel") or ""),
resolved_chat_id=str(payload.get("resolved_chat_id") or ""),
resolved_account_id=str(payload.get("resolved_account_id") or ""),
session_id=str(payload.get("session_id") or ""),
)
if scheduled_run_id:
store.scheduled_job_run_update(
run_id=scheduled_run_id,
tenant_id=tenant_id,
patch={
"status": "success" if delivery_status.get("ok") else "failed",
"finished_at": datetime.now(timezone.utc).isoformat(),
"reply_text": reply_text,
"delivery_status": delivery_status,
"session_id": str(payload.get("session_id") or ""),
},
)
if job_id and job:
pause_after = str(getattr(job, "schedule_kind", "") or "") == "once"
store.scheduled_job_mark_run(
job_id=job_id,
tenant_id=tenant_id,
last_run_status="success" if delivery_status.get("ok") else "failed",
pause_after=pause_after,
)
def finalize_scheduled_turn_failure(
store: Any,
*,
payload: dict[str, Any],
error: str,
) -> None:
tenant_id = str(payload.get("tenant_id") or "")
job_id = str(payload.get("job_id") or "")
scheduled_run_id = str(payload.get("run_id_scheduled") or "")
if scheduled_run_id:
store.scheduled_job_run_update(
run_id=scheduled_run_id,
tenant_id=tenant_id,
patch={
"status": "failed",
"finished_at": datetime.now(timezone.utc).isoformat(),
"error": str(error or "")[:500],
},
)
if job_id:
job = store.scheduled_job_get(job_id=job_id, tenant_id=tenant_id)
pause_after = bool(job and str(getattr(job, "schedule_kind", "") or "") == "once")
store.scheduled_job_mark_run(
job_id=job_id,
tenant_id=tenant_id,
last_run_status="failed",
pause_after=pause_after,
)
__all__ = ["finalize_scheduled_turn_failure", "finalize_scheduled_turn_success"]