oclaw/runtime/scheduler/expressions.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

77 lines
2.2 KiB
Python

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