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

@ -217,6 +217,15 @@ def _run_startup_hooks(app: FastAPI) -> None:
_log_info(f"[startup-prebuild] scheduler_started interval_s={_prewarm_interval_seconds()}")
except Exception as exc:
_log_warn(f"[startup-prebuild] scheduler_start_failed: {exc}")
try:
from svc.persistence.assistant_store import get_assistant_store
from runtime.scheduler.service import ensure_scheduler_started
store = get_assistant_store()
sid = ensure_scheduler_started(store=store)
_log_info(f"[scheduled-jobs] scheduler_started thread={sid}")
except Exception as exc:
_log_warn(f"[scheduled-jobs] scheduler_start_failed: {exc}")
_relocate_root_scan_artifacts()
startup_targets = _resolve_startup_workspace_dirs(cfg)
initialize_hooks_runtime(cfg=cfg, workspace_dir=startup_targets[0][1])
@ -302,6 +311,31 @@ def create_app() -> FastAPI:
changed = store.ack_channel_outbound_message(message_id=msg_id, ok=ok, error=err)
return {"ok": changed}
@app.get("/weixin/outbound/pending")
def weixin_outbound_pending(account_id: str = "", limit: int = 20) -> dict[str, Any]:
from svc.persistence.assistant_store import get_assistant_store
store = get_assistant_store()
lister = getattr(store, "list_pending_weixin_outbound_messages", None)
if not callable(lister):
return {"ok": True, "items": []}
items = lister(account_id=str(account_id or "").strip(), limit=limit)
return {"ok": True, "items": items}
@app.post("/weixin/outbound/ack")
def weixin_outbound_ack(payload: dict[str, Any]) -> dict[str, Any]:
from svc.persistence.assistant_store import get_assistant_store
body = payload if isinstance(payload, dict) else {}
msg_id = str(body.get("id") or "").strip()
if not msg_id:
return {"ok": False, "error": "missing id"}
ok = bool(body.get("ok", True))
err = str(body.get("error") or "").strip()
store = get_assistant_store()
changed = store.ack_channel_outbound_message(message_id=msg_id, ok=ok, error=err)
return {"ok": changed}
@app.post("/wecom/inbound")
async def wecom_inbound(payload: dict[str, Any]) -> dict[str, Any]:
payload = payload if isinstance(payload, dict) else {}

View file

@ -262,12 +262,23 @@ class _IlinkBridge:
if eid <= cursor:
continue
if str(event.get("token") or "") != token:
continue
ev_tok = str(event.get("token") or "")
if ev_tok and ev_tok not in ("default", token):
continue
payload = event.get("payload") if isinstance(event.get("payload"), dict) else {}
if channel and str(payload.get("channel") or "") != channel:
continue
if account_id and str(payload.get("account_id") or "") != account_id:
continue
alt = {"wechat", "weixin"}
if not (
channel in alt
and str(payload.get("channel") or "") in alt
):
continue
req_acct = str(account_id or "").strip()
pay_acct = str(payload.get("account_id") or "").strip()
if req_acct and pay_acct and pay_acct != req_acct:
legacy = {"", "weixin-default"}
if pay_acct not in legacy and req_acct not in legacy:
continue
next_cursor = max(next_cursor, eid)
item = dict(payload)
item["id"] = str(eid)
@ -319,6 +330,23 @@ async def _process_inbound_and_enqueue(
return ""
ctx_token = _extract_context_token(payload)
try:
from runtime.scheduler.channel_delivery import persist_channel_context_token
from svc.persistence.assistant_store import get_assistant_store
store = get_assistant_store()
persist_channel_context_token(
store,
tenant_id=str(payload.get("tenant_id") or ""),
channel=channel,
account_id=account_id,
external_chat_id=str(chat_id or user_id or ""),
context_token=ctx_token,
)
except Exception:
pass
try:
out = await asyncio.wait_for(asyncio.to_thread(_process_inbound_payload_usecase, payload), timeout=60.0)
except asyncio.TimeoutError:
@ -534,5 +562,32 @@ async def whatsapp_native_reply(
}
__all__ = ["router"]
def enqueue_weixin_outbound_reply(
*,
channel: str,
account_id: str,
chat_id: str,
text: str,
context_token: str = "",
token: str = "",
) -> str:
"""Enqueue a proactive Weixin/WeChat outbound message for the sidecar poll loop."""
tok = str(
token or os.getenv("AIA_WEIXIN_ILINK_TOKEN") or os.getenv("AIA_ILINK_TOKEN") or "default"
).strip()
ch = str(channel or "wechat").strip().lower()
if ch == "weixin":
ch = "wechat"
_BRIDGE.enqueue_reply(
token=tok,
channel=ch,
account_id=str(account_id or "").strip(),
chat_id=str(chat_id or "").strip(),
text=str(text or ""),
context_token=str(context_token or "").strip(),
)
return str(_BRIDGE._seq)
__all__ = ["router", "enqueue_weixin_outbound_reply"]