mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-12 16:33:22 +08:00
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:
parent
5336f8835f
commit
47813f6a94
45 changed files with 4956 additions and 43 deletions
3
runtime/scheduler/__init__.py
Normal file
3
runtime/scheduler/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from __future__ import annotations
|
||||
|
||||
__all__ = []
|
||||
252
runtime/scheduler/channel_delivery.py
Normal file
252
runtime/scheduler/channel_delivery.py
Normal 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",
|
||||
]
|
||||
201
runtime/scheduler/cron_service.py
Normal file
201
runtime/scheduler/cron_service.py
Normal 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"]
|
||||
77
runtime/scheduler/expressions.py
Normal file
77
runtime/scheduler/expressions.py
Normal 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"]
|
||||
200
runtime/scheduler/service.py
Normal file
200
runtime/scheduler/service.py
Normal 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",
|
||||
]
|
||||
262
runtime/scheduler/session_resolver.py
Normal file
262
runtime/scheduler/session_resolver.py
Normal 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",
|
||||
]
|
||||
50
runtime/scheduler/turn_text.py
Normal file
50
runtime/scheduler/turn_text.py
Normal 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",
|
||||
]
|
||||
90
runtime/scheduler/weixin_delivery.py
Normal file
90
runtime/scheduler/weixin_delivery.py
Normal 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"]
|
||||
184
runtime/scheduler/worker_turn.py
Normal file
184
runtime/scheduler/worker_turn.py
Normal 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"]
|
||||
Loading…
Add table
Add a link
Reference in a new issue