oclaw/runtime/scheduler/session_resolver.py
oliver 47813f6a94 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>
2026-06-26 15:56:10 +08:00

262 lines
9.6 KiB
Python

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