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,80 @@
"""Add scheduled_job tables for PostgreSQL deployments."""
from __future__ import annotations
from alembic import op
from sqlalchemy import text
revision = "002_scheduled_jobs"
down_revision = "001_assistant_pg_initial"
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
if bind.dialect.name != "postgresql":
return
op.execute(
text(
"""
CREATE TABLE IF NOT EXISTS scheduled_job (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'active',
schedule_kind TEXT NOT NULL,
schedule_expr TEXT NOT NULL,
timezone TEXT NOT NULL DEFAULT 'Asia/Shanghai',
prompt_text TEXT NOT NULL,
interaction_mode TEXT NOT NULL DEFAULT 'expert',
specialist TEXT NOT NULL DEFAULT 'generalist',
lang TEXT NOT NULL DEFAULT 'zh',
delivery_json TEXT NOT NULL DEFAULT '{}',
source_session_id TEXT,
created_by_user_id TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT 'admin',
next_run_at TEXT,
last_run_at TEXT,
last_run_status TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
)
op.execute(text("CREATE INDEX IF NOT EXISTS idx_scheduled_job_due ON scheduled_job(status, next_run_at)"))
op.execute(
text(
"""
CREATE TABLE IF NOT EXISTS scheduled_job_run (
id TEXT PRIMARY KEY,
job_id TEXT NOT NULL REFERENCES scheduled_job(id) ON DELETE CASCADE,
tenant_id TEXT NOT NULL,
status TEXT NOT NULL,
scheduled_at TEXT NOT NULL,
started_at TEXT,
finished_at TEXT,
session_id TEXT,
oclaw_task_id TEXT,
run_id TEXT,
reply_text TEXT NOT NULL DEFAULT '',
delivery_status_json TEXT NOT NULL DEFAULT '{}',
error TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
)
"""
)
)
op.execute(
text("CREATE INDEX IF NOT EXISTS idx_scheduled_job_run_job ON scheduled_job_run(job_id, created_at DESC)")
)
def downgrade() -> None:
bind = op.get_bind()
if bind.dialect.name != "postgresql":
return
op.execute(text("DROP TABLE IF EXISTS scheduled_job_run"))
op.execute(text("DROP TABLE IF EXISTS scheduled_job"))

View file

@ -1986,6 +1986,194 @@ def build_admin_router() -> APIRouter:
},
}
@router.get("/admin/api/scheduled-jobs/meta/targets")
def api_scheduled_jobs_meta_targets(
tenant_id: str = Query(default=""),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
from runtime.scheduler.session_resolver import resolve_weixin_binding
store = get_assistant_store()
ctx = _resolve_auth(store, authorization)
_require_permission(ctx, "admin:read")
tid = str(tenant_id or ctx.get("tenant_id") or "default").strip()
_require_tenant_scope(ctx, tid)
aid = str(os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default").strip()
known = store.list_whatsapp_known_groups(tenant_id=tid, account_id=aid)
weixin = resolve_weixin_binding(store, tenant_id=tid)
return {"ok": True, "whatsapp_groups": known, "weixin_binding": weixin, "whatsapp_account_id": aid}
@router.get("/admin/api/scheduled-jobs")
def api_scheduled_jobs_list(
status: str | None = Query(default=None),
limit: int = Query(default=100),
offset: int = Query(default=0),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = get_assistant_store()
ctx = _resolve_auth(store, authorization)
_require_permission(ctx, "admin:read")
tenant_id = str(ctx.get("tenant_id") or "")
rows = store.scheduled_job_list(
tenant_id=tenant_id,
status=str(status or "").strip() or None,
limit=max(1, min(int(limit or 100), 300)),
offset=max(0, int(offset or 0)),
)
return {"ok": True, "items": [store.scheduled_job_to_dict(r) for r in rows]}
@router.get("/admin/api/scheduled-jobs/{job_id}")
def api_scheduled_jobs_get(
job_id: str,
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = get_assistant_store()
ctx = _resolve_auth(store, authorization)
_require_permission(ctx, "admin:read")
tenant_id = str(ctx.get("tenant_id") or "")
row = store.scheduled_job_get(job_id=str(job_id), tenant_id=tenant_id)
return {"ok": True, "job": None if not row else store.scheduled_job_to_dict(row)}
@router.post("/admin/api/scheduled-jobs")
def api_scheduled_jobs_create(
payload: dict[str, Any],
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
from runtime.scheduler.cron_service import build_default_delivery
from runtime.scheduler.expressions import normalize_schedule_kind
store = get_assistant_store()
ctx = _resolve_auth(store, authorization)
_require_permission(ctx, "admin:runtime:write")
tenant_id = str(ctx.get("tenant_id") or "")
name = str(payload.get("name") or "").strip()
prompt_text = str(payload.get("prompt_text") or "").strip()
schedule_kind = normalize_schedule_kind(payload.get("schedule_kind") or "cron")
schedule_expr = str(payload.get("schedule_expr") or "").strip()
if not name or not prompt_text or not schedule_expr:
return {"ok": False, "error": "name, prompt_text, schedule_expr are required"}
delivery = payload.get("delivery") if isinstance(payload.get("delivery"), dict) else None
if delivery is None:
wa_chat = str((payload.get("whatsapp") or {}).get("chat_id") if isinstance(payload.get("whatsapp"), dict) else payload.get("whatsapp_chat_id") or "")
delivery = build_default_delivery(store=store, tenant_id=tenant_id, whatsapp_chat_id=wa_chat)
row = store.scheduled_job_create(
tenant_id=tenant_id,
name=name,
prompt_text=prompt_text,
schedule_kind=schedule_kind,
schedule_expr=schedule_expr,
timezone_name=str(payload.get("timezone") or "Asia/Shanghai"),
description=str(payload.get("description") or ""),
interaction_mode=normalize_interaction_mode(payload.get("interaction_mode") or "expert"),
specialist=normalize_requested_specialist(payload.get("specialist") or "generalist"),
lang=str(payload.get("lang") or "zh"),
delivery=delivery,
source_session_id=str(payload.get("source_session_id") or "").strip() or None,
created_by_user_id=str(ctx.get("user_id") or ""),
source="admin",
)
store.add_admin_audit_log(
actor_tenant_id=tenant_id,
actor_user_id=str(ctx.get("user_id") or ""),
action="scheduled_job_create",
target_type="scheduled_job",
target_id=row.id,
status="ok",
)
return {"ok": True, "job": store.scheduled_job_to_dict(row)}
@router.patch("/admin/api/scheduled-jobs/{job_id}")
def api_scheduled_jobs_patch(
job_id: str,
payload: dict[str, Any],
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = get_assistant_store()
ctx = _resolve_auth(store, authorization)
_require_permission(ctx, "admin:runtime:write")
tenant_id = str(ctx.get("tenant_id") or "")
row = store.scheduled_job_update(tenant_id=tenant_id, job_id=str(job_id), patch=dict(payload or {}))
if not row:
return {"ok": False, "error": "job_not_found"}
store.add_admin_audit_log(
actor_tenant_id=tenant_id,
actor_user_id=str(ctx.get("user_id") or ""),
action="scheduled_job_update",
target_type="scheduled_job",
target_id=str(job_id),
status="ok",
)
return {"ok": True, "job": store.scheduled_job_to_dict(row)}
@router.post("/admin/api/scheduled-jobs/{job_id}/pause")
def api_scheduled_jobs_pause(
job_id: str,
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = get_assistant_store()
ctx = _resolve_auth(store, authorization)
_require_permission(ctx, "admin:runtime:write")
tenant_id = str(ctx.get("tenant_id") or "")
ok = store.scheduled_job_set_status(tenant_id=tenant_id, job_id=str(job_id), status="paused")
return {"ok": bool(ok)}
@router.post("/admin/api/scheduled-jobs/{job_id}/resume")
def api_scheduled_jobs_resume(
job_id: str,
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = get_assistant_store()
ctx = _resolve_auth(store, authorization)
_require_permission(ctx, "admin:runtime:write")
tenant_id = str(ctx.get("tenant_id") or "")
ok = store.scheduled_job_set_status(tenant_id=tenant_id, job_id=str(job_id), status="active")
return {"ok": bool(ok)}
@router.delete("/admin/api/scheduled-jobs/{job_id}")
def api_scheduled_jobs_delete(
job_id: str,
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = get_assistant_store()
ctx = _resolve_auth(store, authorization)
_require_permission(ctx, "admin:runtime:write")
tenant_id = str(ctx.get("tenant_id") or "")
ok = store.scheduled_job_delete(tenant_id=tenant_id, job_id=str(job_id))
return {"ok": bool(ok)}
@router.post("/admin/api/scheduled-jobs/{job_id}/run-now")
def api_scheduled_jobs_run_now(
job_id: str,
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
from runtime.scheduler.service import run_scheduled_job_now
store = get_assistant_store()
ctx = _resolve_auth(store, authorization)
_require_permission(ctx, "admin:runtime:write")
tenant_id = str(ctx.get("tenant_id") or "")
out = run_scheduled_job_now(store, tenant_id=tenant_id, job_id=str(job_id))
return out
@router.get("/admin/api/scheduled-jobs/{job_id}/runs")
def api_scheduled_jobs_runs(
job_id: str,
limit: int = Query(default=50),
offset: int = Query(default=0),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = get_assistant_store()
ctx = _resolve_auth(store, authorization)
_require_permission(ctx, "admin:read")
tenant_id = str(ctx.get("tenant_id") or "")
rows = store.scheduled_job_run_list(
job_id=str(job_id),
tenant_id=tenant_id,
limit=max(1, min(int(limit or 50), 200)),
offset=max(0, int(offset or 0)),
)
return {"ok": True, "items": [store.scheduled_job_run_to_dict(r) for r in rows]}
@router.get("/admin/api/replay/turn")
def api_replay_turn(
session_id: str,

View file

@ -5,6 +5,7 @@ const I18N = {
"nav.models": "模型管理",
"nav.apiGrants": "API 使用授权",
"nav.stack": "运行时",
"nav.scheduledJobs": "定时任务",
"nav.users": "用户管理",
"nav.workspacePaths": "工作区路径",
"nav.memory": "记忆",
@ -18,6 +19,41 @@ const I18N = {
"notice.noLogin": "v2 已启用登录鉴权:请仅在内网访问",
"action.refresh": "刷新",
"title.stack": "运行时",
"title.scheduledJobs": "定时任务",
"scheduledJobs.all": "全部",
"scheduledJobs.statusActive": "运行中",
"scheduledJobs.statusPaused": "已暂停",
"scheduledJobs.loading": "Loading…",
"scheduledJobs.count": "{count} 个任务",
"scheduledJobs.colName": "名称",
"scheduledJobs.colSchedule": "计划",
"scheduledJobs.colStatus": "状态",
"scheduledJobs.colNextRun": "下次运行",
"scheduledJobs.colLastRun": "上次运行",
"scheduledJobs.colSpecialist": "专家",
"scheduledJobs.colDelivery": "投递",
"scheduledJobs.colActions": "操作",
"scheduledJobs.menuTitle": "任务操作",
"scheduledJobs.viewRuns": "查看运行记录",
"scheduledJobs.pause": "暂停",
"scheduledJobs.resume": "恢复",
"scheduledJobs.runNow": "立即运行",
"scheduledJobs.delete": "删除",
"scheduledJobs.deleteConfirm": "确定删除该定时任务?",
"scheduledJobs.triggered": "已触发",
"scheduledJobs.createTitle": "新建任务",
"scheduledJobs.create": "创建",
"scheduledJobs.edit": "编辑",
"scheduledJobs.editTitle": "编辑任务",
"scheduledJobs.updated": "已保存",
"scheduledJobs.cancel": "取消",
"scheduledJobs.runHistory": "运行记录",
"scheduledJobs.runHistoryHint": "最近运行(自动刷新)",
"scheduledJobs.noRuns": "暂无运行记录",
"scheduledJobs.weixinFixed": "微信固定投递:{id}",
"scheduledJobs.weixinMissing": "未找到微信绑定(需管理员渠道身份)",
"scheduledJobs.deliveryWeixin": "微信",
"scheduledJobs.deliveryWhatsapp": "WhatsApp",
"title.users": "用户管理",
"title.workspacePaths": "工作区路径(按用户)",
"title.memory": "记忆",
@ -447,6 +483,7 @@ const I18N = {
"nav.models": "Models",
"nav.apiGrants": "API access",
"nav.stack": "Runtime",
"nav.scheduledJobs": "Scheduled Jobs",
"nav.users": "Users",
"nav.workspacePaths": "Workspace paths",
"nav.memory": "Memory",
@ -460,6 +497,41 @@ const I18N = {
"notice.noLogin": "v2 login enabled: internal network only",
"action.refresh": "Refresh",
"title.stack": "Runtime",
"title.scheduledJobs": "Scheduled Jobs",
"scheduledJobs.all": "All",
"scheduledJobs.statusActive": "active",
"scheduledJobs.statusPaused": "paused",
"scheduledJobs.loading": "Loading…",
"scheduledJobs.count": "{count} job(s)",
"scheduledJobs.colName": "name",
"scheduledJobs.colSchedule": "schedule",
"scheduledJobs.colStatus": "status",
"scheduledJobs.colNextRun": "next_run",
"scheduledJobs.colLastRun": "last_run",
"scheduledJobs.colSpecialist": "specialist",
"scheduledJobs.colDelivery": "delivery",
"scheduledJobs.colActions": "actions",
"scheduledJobs.menuTitle": "Job actions",
"scheduledJobs.viewRuns": "View runs",
"scheduledJobs.pause": "Pause",
"scheduledJobs.resume": "Resume",
"scheduledJobs.runNow": "Run now",
"scheduledJobs.delete": "Delete",
"scheduledJobs.deleteConfirm": "Delete this scheduled job?",
"scheduledJobs.triggered": "Triggered.",
"scheduledJobs.createTitle": "Create job",
"scheduledJobs.create": "Create",
"scheduledJobs.edit": "Edit",
"scheduledJobs.editTitle": "Edit job",
"scheduledJobs.updated": "Saved.",
"scheduledJobs.cancel": "Cancel",
"scheduledJobs.runHistory": "Run history",
"scheduledJobs.runHistoryHint": "Recent runs (auto refresh)",
"scheduledJobs.noRuns": "No runs yet",
"scheduledJobs.weixinFixed": "Weixin fixed target: {id}",
"scheduledJobs.weixinMissing": "Weixin binding not found (administrator channel identity required)",
"scheduledJobs.deliveryWeixin": "WeChat",
"scheduledJobs.deliveryWhatsapp": "WhatsApp",
"title.users": "Users",
"title.workspacePaths": "Workspace paths (per user)",
"title.memory": "Memory",
@ -9221,6 +9293,366 @@ async function renderSkills() {
]);
}
function formatScheduledJobDelivery(job) {
const d = (job && job.delivery && typeof job.delivery === "object") ? job.delivery : {};
const parts = [];
if (d.weixin && d.weixin.enabled) parts.push(t("scheduledJobs.deliveryWeixin"));
if (d.whatsapp && d.whatsapp.enabled) parts.push(t("scheduledJobs.deliveryWhatsapp"));
return parts.length ? parts.join(" + ") : "—";
}
function buildScheduledJobDeliveryPayload(job, waChatId) {
const existing = (job && job.delivery && typeof job.delivery === "object") ? job.delivery : {};
const wa = existing.whatsapp && typeof existing.whatsapp === "object" ? { ...existing.whatsapp } : {};
const wx =
existing.weixin && typeof existing.weixin === "object"
? { ...existing.weixin }
: { enabled: true, fixed: true };
const chat = String(waChatId || "").trim();
wa.enabled = Boolean(chat);
wa.chat_id = chat;
wa.target_type = chat.includes("@g.us") ? "group" : "direct";
if (wx.enabled === undefined) wx.enabled = true;
return { whatsapp: wa, weixin: wx };
}
async function renderScheduledJobs() {
let scheduledJobMenuEl = null;
let editingJob = null;
const canWrite = hasPermission("admin:runtime:write");
const closeScheduledJobMenu = () => {
if (scheduledJobMenuEl && scheduledJobMenuEl.parentNode) {
scheduledJobMenuEl.remove();
}
scheduledJobMenuEl = null;
};
const status = el("select", {}, [
el("option", { value: "", text: t("scheduledJobs.all") }),
el("option", { value: "active", text: t("scheduledJobs.statusActive") }),
el("option", { value: "paused", text: t("scheduledJobs.statusPaused") }),
]);
const tbody = el("tbody", {});
const runsBox = el("pre", { class: "code", style: "max-height:220px;overflow:auto;white-space:pre-wrap;" });
const msg = el("div", { class: "muted", text: "" });
const editModal = el("div", { class: "session-monitor-modal", style: "display:none;" });
const editTitle = el("div", { class: "card__title", text: t("scheduledJobs.editTitle") });
const editNameInput = el("input", { class: "input", placeholder: "name" });
const editKindInput = el("select", {}, [
el("option", { value: "cron", text: "cron" }),
el("option", { value: "once", text: "once" }),
el("option", { value: "interval", text: "interval" }),
]);
const editExprInput = el("input", { class: "input", placeholder: "schedule_expr (cron / ISO / seconds)" });
const editPromptInput = el("textarea", { class: "input", rows: "4", placeholder: "prompt_text" });
const editSpecialistInput = el("input", { class: "input", placeholder: "specialist" });
const editWaChatInput = el("input", { class: "input", placeholder: "whatsapp chat_id (optional)" });
const editWeixinInfo = el("div", { class: "muted", text: "" });
const closeEditModal = () => {
editingJob = null;
editModal.style.display = "none";
};
const openEditModal = (job) => {
if (!job || !job.id) return;
editingJob = job;
editTitle.textContent = `${t("scheduledJobs.editTitle")}: ${String(job.name || job.id)}`;
editNameInput.value = String(job.name || "");
editKindInput.value = String(job.schedule_kind || "cron");
editExprInput.value = String(job.schedule_expr || "");
editPromptInput.value = String(job.prompt_text || "");
editSpecialistInput.value = String(job.specialist || "generalist");
const wa = job.delivery && job.delivery.whatsapp ? job.delivery.whatsapp : {};
editWaChatInput.value = String(wa.chat_id || "");
const wx = job.delivery && job.delivery.weixin ? job.delivery.weixin : {};
editWeixinInfo.textContent = wx.external_user_id
? tf("scheduledJobs.weixinFixed", { id: String(wx.external_user_id) })
: weixinInfo.textContent || t("scheduledJobs.weixinMissing");
editModal.style.display = "flex";
setTimeout(() => editNameInput.focus(), 0);
};
editModal.addEventListener("click", (e) => {
if (e.target === editModal) closeEditModal();
});
const editSaveBtn = el("button", {
class: "btn btn--primary",
text: t("action.save"),
onclick: async () => {
if (!editingJob || !editingJob.id) return;
const name = String(editNameInput.value || "").trim();
const promptText = String(editPromptInput.value || "").trim();
const scheduleExpr = String(editExprInput.value || "").trim();
if (!name || !promptText || !scheduleExpr) {
msg.textContent = "name, prompt_text, schedule_expr are required";
return;
}
try {
await apiRequest("PATCH", `/admin/api/scheduled-jobs/${encodeURIComponent(String(editingJob.id))}`, {
name,
schedule_kind: editKindInput.value,
schedule_expr: scheduleExpr,
prompt_text: promptText,
specialist: String(editSpecialistInput.value || "generalist").trim() || "generalist",
delivery: buildScheduledJobDeliveryPayload(editingJob, editWaChatInput.value),
});
closeEditModal();
msg.textContent = t("scheduledJobs.updated");
await loadJobs();
} catch (e) {
msg.textContent = String(e && e.message ? e.message : e);
}
},
});
const editModalCard = el("div", { class: "card session-monitor-modal__card", style: "width:min(640px,96vw);" }, [
editTitle,
editWeixinInfo,
editNameInput,
el("div", { class: "row", style: "gap:8px;" }, [editKindInput, editExprInput]),
editPromptInput,
editSpecialistInput,
editWaChatInput,
el("div", { class: "row", style: "gap:8px;justify-content:flex-end;margin-top:10px;" }, [
el("button", { class: "btn", text: t("scheduledJobs.cancel"), onclick: closeEditModal }),
editSaveBtn,
]),
]);
editModal.appendChild(editModalCard);
async function loadLatestRuns(items) {
const jobs = Array.isArray(items) ? items : [];
if (!jobs.length) {
runsBox.textContent = t("scheduledJobs.noRuns");
return;
}
const withLast = jobs.filter((j) => String(j.last_run_at || "").trim());
const target = withLast.length ? withLast[0] : jobs[0];
if (!target || !target.id) {
runsBox.textContent = t("scheduledJobs.noRuns");
return;
}
const runs = await apiGet(`/admin/api/scheduled-jobs/${encodeURIComponent(String(target.id))}/runs?limit=10`);
const runItems = runs.items || [];
runsBox.textContent = runItems.length
? JSON.stringify(runItems, null, 2)
: t("scheduledJobs.noRuns");
}
async function loadJobs() {
closeScheduledJobMenu();
msg.textContent = t("scheduledJobs.loading");
const st = String(status.value || "").trim();
const q = st ? `?status=${encodeURIComponent(st)}` : "";
const resp = await apiGet(`/admin/api/scheduled-jobs${q}`);
tbody.replaceChildren();
for (const job of resp.items || []) {
const jobId = String(job.id || "");
const btnMore = el("button", {
class: "chat-sess-more",
text: "⋯",
title: t("scheduledJobs.menuTitle"),
onclick: (ev) => {
ev.stopPropagation();
closeScheduledJobMenu();
const menu = el("div", { class: "chat-sess-menu-pop", style: "position:fixed;z-index:250;" }, [
el("button", {
class: "chat-sess-menu-item",
text: t("scheduledJobs.viewRuns"),
onclick: async () => {
closeScheduledJobMenu();
const runs = await apiGet(`/admin/api/scheduled-jobs/${encodeURIComponent(jobId)}/runs`);
runsBox.textContent = JSON.stringify(runs.items || [], null, 2);
},
}),
...(canWrite
? [
el("button", {
class: "chat-sess-menu-item",
text: t("scheduledJobs.edit"),
onclick: () => {
closeScheduledJobMenu();
openEditModal(job);
},
}),
]
: []),
el("button", {
class: "chat-sess-menu-item",
text: job.status === "active" ? t("scheduledJobs.pause") : t("scheduledJobs.resume"),
onclick: async () => {
closeScheduledJobMenu();
const path =
job.status === "active"
? `/admin/api/scheduled-jobs/${encodeURIComponent(jobId)}/pause`
: `/admin/api/scheduled-jobs/${encodeURIComponent(jobId)}/resume`;
await apiPost(path, {});
await loadJobs();
},
}),
el("button", {
class: "chat-sess-menu-item",
text: t("scheduledJobs.runNow"),
onclick: async () => {
closeScheduledJobMenu();
await apiPost(`/admin/api/scheduled-jobs/${encodeURIComponent(jobId)}/run-now`, {});
msg.textContent = t("scheduledJobs.triggered");
},
}),
...(canWrite
? [
el("button", {
class: "chat-sess-menu-item",
text: t("scheduledJobs.delete"),
onclick: async () => {
closeScheduledJobMenu();
if (!window.confirm(t("scheduledJobs.deleteConfirm"))) return;
await fetch(resolveAdminApiUrl(`/admin/api/scheduled-jobs/${encodeURIComponent(jobId)}`), {
method: "DELETE",
headers: { authorization: `Bearer ${getStoredAuthToken()}`, accept: "application/json" },
});
await loadJobs();
},
}),
]
: []),
]);
const rect = ev.currentTarget.getBoundingClientRect();
document.body.appendChild(menu);
const mrect = menu.getBoundingClientRect();
const pad = 8;
let left = rect.left - 120;
let top = rect.bottom + 4;
if (top + mrect.height > window.innerHeight - pad) {
top = rect.top - 4 - mrect.height;
}
left = Math.max(pad, Math.min(left, window.innerWidth - pad - mrect.width));
top = Math.max(pad, Math.min(top, window.innerHeight - pad - mrect.height));
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
scheduledJobMenuEl = menu;
const close = (e) => {
if (!menu.contains(e.target)) {
closeScheduledJobMenu();
document.removeEventListener("click", close);
}
};
setTimeout(() => document.addEventListener("click", close), 0);
},
});
const lastRun = job.last_run_at
? `${formatSystemLocalDateTime(String(job.last_run_at))}${job.last_run_status ? ` (${job.last_run_status})` : ""}`
: "—";
const tr = el("tr", {}, [
tdCell(job.name || "", 24),
tdCell(`${job.schedule_kind}:${job.schedule_expr}`, 28),
tdCell(job.status || "", 10),
tdCell(job.next_run_at || "—", 20),
tdCell(lastRun, 24),
tdCell(job.specialist || "", 14),
tdCell(formatScheduledJobDelivery(job), 12),
el("td", { class: "table__cell--actions" }, [
el("div", { class: "table__cell-actions" }, [btnMore]),
]),
]);
tbody.appendChild(tr);
}
msg.textContent = tf("scheduledJobs.count", { count: String((resp.items || []).length) });
await loadLatestRuns(resp.items || []);
}
const nameInput = el("input", { class: "input", placeholder: "name" });
const kindInput = el("select", {}, [
el("option", { value: "cron", text: "cron" }),
el("option", { value: "once", text: "once" }),
el("option", { value: "interval", text: "interval" }),
]);
const exprInput = el("input", { class: "input", placeholder: "schedule_expr (cron / ISO / seconds)" });
const promptInput = el("textarea", { class: "input", rows: "3", placeholder: "prompt_text" });
const specialistInput = el("input", { class: "input", placeholder: "specialist", value: "generalist" });
const waChatInput = el("input", { class: "input", placeholder: "whatsapp chat_id (optional)" });
const weixinInfo = el("div", { class: "muted", text: "" });
async function loadMeta() {
const meta = await apiGet("/admin/api/scheduled-jobs/meta/targets");
const wx = meta.weixin_binding || {};
weixinInfo.textContent = wx.external_user_id
? tf("scheduledJobs.weixinFixed", { id: String(wx.external_user_id) })
: t("scheduledJobs.weixinMissing");
}
await loadMeta();
await loadJobs();
const createCardChildren = [
el("div", { class: "card__title", text: t("scheduledJobs.createTitle") }),
weixinInfo,
nameInput,
el("div", { class: "row", style: "gap:8px;" }, [kindInput, exprInput]),
promptInput,
specialistInput,
waChatInput,
el("button", {
class: "btn btn--primary",
text: t("scheduledJobs.create"),
onclick: async () => {
await apiPost("/admin/api/scheduled-jobs", {
name: nameInput.value,
schedule_kind: kindInput.value,
schedule_expr: exprInput.value,
prompt_text: promptInput.value,
specialist: specialistInput.value,
whatsapp_chat_id: waChatInput.value,
delivery: buildScheduledJobDeliveryPayload(null, waChatInput.value),
});
await loadJobs();
},
}),
];
return el("div", {}, [
editModal,
el("div", { class: "card" }, [
el("div", { class: "card__title", text: t("title.scheduledJobs") }),
el("div", { class: "row", style: "gap:8px;align-items:center;" }, [
status,
el("button", { class: "btn", text: t("action.refresh"), onclick: () => loadJobs() }),
msg,
]),
el("div", { class: "table-wrap" }, [
el("table", { class: "table table--compact" }, [
el("colgroup", {}, [
el("col", { style: "width:16%" }),
el("col", { style: "width:18%" }),
el("col", { style: "width:8%" }),
el("col", { style: "width:14%" }),
el("col", { style: "width:14%" }),
el("col", { style: "width:10%" }),
el("col", { style: "width:10%" }),
el("col", { style: "width:10%" }),
]),
el("thead", {}, [
el("tr", {}, [
el("th", { text: t("scheduledJobs.colName") }),
el("th", { text: t("scheduledJobs.colSchedule") }),
el("th", { text: t("scheduledJobs.colStatus") }),
el("th", { text: t("scheduledJobs.colNextRun") }),
el("th", { text: t("scheduledJobs.colLastRun") }),
el("th", { text: t("scheduledJobs.colSpecialist") }),
el("th", { text: t("scheduledJobs.colDelivery") }),
el("th", { text: t("scheduledJobs.colActions") }),
]),
]),
tbody,
]),
]),
]),
...(canWrite ? [el("div", { class: "card" }, createCardChildren)] : []),
el("div", { class: "card" }, [
el("div", { class: "card__title", text: `${t("scheduledJobs.runHistory")} · ${t("scheduledJobs.runHistoryHint")}` }),
runsBox,
]),
]);
}
async function router() {
const route = getRoute();
const page = route.page;
@ -9243,6 +9675,7 @@ async function router() {
document.querySelectorAll(".nav__item").forEach((a) => {
const p = String(a.dataset.page || "");
if (p === "stack" && !hasPermission("admin:runtime:write")) a.style.display = "none";
else if (p === "scheduled-jobs" && !hasPermission("admin:read")) a.style.display = "none";
else if (p === "users" && !hasPermission("admin:user:read")) a.style.display = "none";
else if (p === "session-monitor" && !isAdministratorUsername()) a.style.display = "none";
else if (p === "admin-audit" && !hasPermission("admin:user:write")) a.style.display = "none";
@ -9271,6 +9704,9 @@ async function router() {
);
view = await renderStack();
}
else if (page === "scheduled-jobs") {
view = hasPermission("admin:read") ? await renderScheduledJobs() : forbiddenCard();
}
else if (page === "users") {
view = hasPermission("admin:user:read") ? await renderUserManagement() : forbiddenCard();
} else if (page === "memory") view = await renderMemory();

View file

@ -574,7 +574,7 @@
<script defer src="/admin/assets/admin-theme.js"></script>
<script
defer
src="/admin/assets/chat.js?v=20260515-2"
src="/admin/assets/chat.js?v=20260626-1"
onerror="(function(){var s=document.getElementById('chat-boot-splash');if(s){s.querySelector('.chat-boot-splash__title span:last-child').textContent='无法加载 chat.js';s.querySelector('.chat-boot-splash__muted').textContent='请确认网关已启动且 /admin/assets/chat.js 可访问。';}})()"
></script>
</head>

View file

@ -408,6 +408,58 @@ function _normalizeEventType(v) {
return String(v || "").trim().toLowerCase();
}
function _isAssistantBodyEventType(eventType) {
const et = _normalizeEventType(eventType);
return !et || et === "assistant_text" || et === "assistant" || et === "scheduled_reminder";
}
function _parseEventPayload(raw) {
let ep = raw;
if (typeof ep === "string" && String(ep).trim()) {
try {
ep = JSON.parse(ep);
} catch (_) {
ep = null;
}
}
return ep && typeof ep === "object" && !Array.isArray(ep) ? ep : null;
}
function _isScheduledProactiveMessage(m) {
const eventType = _normalizeEventType(m && m.event_type);
if (eventType === "scheduled_reminder") return true;
const ep = _parseEventPayload(m && m.event_payload);
if (ep && ep.scheduled_proactive) return true;
const rc = String((ep && ep.reasoning_content) || "").trim();
if (!rc) return false;
if (!/定时主动提醒|定时任务模式|scheduled reminder|proactive reminder/i.test(rc)) return false;
return eventType === "assistant_text" || eventType === "assistant" || !eventType;
}
function _messageTurnUuid(m) {
return String((m && m.turn_uuid) || "").trim();
}
function _pushScheduledAssistantRow(rows, m, content, eventType) {
const body = String(content || "").trim();
const attsParsed = parseAttachments(m && m.attachments);
if (!body && !attsParsed.length) return;
const piece = {
kind: "assistant_text",
text: content,
assistantEventType: eventType || "assistant_text",
};
if (attsParsed.length) piece.attachments = attsParsed;
rows.push({
role: "assistant",
content: body,
timestamp: (m && m.timestamp) != null ? m.timestamp : "",
attachments: m && m.attachments ? m.attachments : null,
_items: [piece],
_message_ids: m && m.id != null ? [m.id] : [],
});
}
function _collapsedBlockNode(title, text) {
const raw = String(text || "");
const clipped = raw.length > REASONING_BLOCK_MAX_CHARS ? raw.slice(0, REASONING_BLOCK_MAX_CHARS) : raw;
@ -610,6 +662,15 @@ function _buildRenderRows(msgs) {
continue;
}
if (role === "assistant" || role === "tool" || role === "function") {
const turnUuid = _messageTurnUuid(m);
if (agg && turnUuid && agg._turn_uuid && turnUuid !== agg._turn_uuid) {
flush();
}
if (role === "assistant" && _isScheduledProactiveMessage(m)) {
flush();
_pushScheduledAssistantRow(rows, m, content, eventType);
continue;
}
if (!agg) {
agg = {
role: "assistant",
@ -618,20 +679,16 @@ function _buildRenderRows(msgs) {
attachments: null,
_items: [],
_message_ids: [],
_turn_uuid: turnUuid,
};
} else if (turnUuid && !agg._turn_uuid) {
agg._turn_uuid = turnUuid;
}
if (m && m.id != null) agg._message_ids.push(m.id);
if (role === "assistant") {
// thinking_mode_enabled: reasoning lives in event_payload.reasoning_content (no separate reasoning rows).
let ep = m && m.event_payload;
if (typeof ep === "string" && String(ep).trim()) {
try {
ep = JSON.parse(ep);
} catch (_) {
ep = null;
}
}
if (ep && typeof ep === "object" && !Array.isArray(ep)) {
const ep = _parseEventPayload(m && m.event_payload);
if (ep) {
const rc = String(ep.reasoning_content || "").trim();
if (rc) {
agg._items.push({ kind: "reasoning", text: rc });
@ -649,7 +706,7 @@ function _buildRenderRows(msgs) {
if (attsParsed.length) piece.attachments = attsParsed;
agg._items.push(piece);
}
} else if (eventType === "assistant_text" || eventType === "assistant" || !eventType) {
} else if (_isAssistantBodyEventType(eventType)) {
const hasText = !!String(content || "").trim();
const attsParsed = parseAttachments(m && m.attachments);
if (hasText || attsParsed.length) {
@ -4308,7 +4365,7 @@ async function renderChatUi() {
).trim();
const hasBodyText = out.some((row) => {
const et = _normalizeEventType(row.event_type);
return et === "assistant_text" || et === "tool_call";
return _isAssistantBodyEventType(et) || et === "tool_call";
});
if (textFallback && !hasBodyText) {
out.push({ ...base, content: textFallback, event_type: fallbackEventType });
@ -4327,7 +4384,7 @@ async function renderChatUi() {
if (out.length) {
const _isBodyRow = (row) => {
const et = _normalizeEventType(row && row.event_type);
return et === "assistant_text" || et === "tool_call";
return _isAssistantBodyEventType(et) || et === "tool_call";
};
let attachIdx = out.length - 1;
for (let i = out.length - 1; i >= 0; i -= 1) {
@ -4408,7 +4465,7 @@ async function renderChatUi() {
// Recovery should only accept visible assistant body, not intermediate
// reasoning/tool-call events; otherwise we may terminate on a partial line.
const et = String((m && m.event_type) || "").trim().toLowerCase();
if (et && et !== "assistant_text" && et !== "assistant") continue;
if (et && !_isAssistantBodyEventType(et)) continue;
if (!String((m && m.content) || "").trim()) continue;
{
last = m;

View file

@ -65,6 +65,7 @@
<div class="nav__group">
<div class="nav__groupTitle">系统与运行</div>
<a class="nav__item" data-page="stack" href="#/stack" data-i18n="nav.stack">Runtime</a>
<a class="nav__item" data-page="scheduled-jobs" href="#/scheduled-jobs" data-i18n="nav.scheduledJobs">定时任务</a>
<a class="nav__item" data-page="session-monitor" href="#/session-monitor" data-i18n="nav.sessionMonitor">会话监控</a>
<a class="nav__item" data-page="workspace-paths" href="#/workspace-paths" data-i18n="nav.workspacePaths">工作区路径</a>
<a class="nav__item" data-page="attachments" href="#/attachments" data-i18n="nav.attachments">附件</a>

View file

@ -5,6 +5,7 @@ from typing import Any
from runtime.skill_installer import install_skill_from_registry_archive
from runtime.tools.skills.clawhub_client import get_skill_detail as clawhub_get_skill_detail
from runtime.tools.skills.clawhub_client import search_skills as clawhub_search_skills
from runtime.scheduler.cron_service import CronService
def build_common_gateway_context(*, store: Any) -> dict[str, Any]:
@ -80,6 +81,7 @@ def build_common_gateway_context(*, store: Any) -> dict[str, Any]:
return {
"store": store,
"cron": CronService(store=store),
"search_clawhub_skills": _search_clawhub_skills,
"fetch_clawhub_skill_detail": _fetch_clawhub_skill_detail,
"install_skill_from_clawhub": _install_skill_from_clawhub,

View file

@ -4,6 +4,7 @@ from interfaces.gateway.server_methods.agent import agent_handlers
from interfaces.gateway.server_methods.chat import chat_handlers
from interfaces.gateway.server_methods.commands import commands_handlers
from interfaces.gateway.server_methods.connect import connect_handlers
from interfaces.gateway.server_methods.cron import cron_handlers
from interfaces.gateway.server_methods.sessions import sessions_handlers
from interfaces.gateway.server_methods.skills import skills_handlers
from interfaces.gateway.server_methods.shared_types import GatewayRequestHandlers
@ -19,6 +20,7 @@ def build_gateway_method_handlers() -> GatewayRequestHandlers:
sessions_handlers,
skills_handlers,
agent_handlers,
cron_handlers,
):
for method, handler in (group or {}).items():
merged[str(method)] = handler

View file

@ -70,6 +70,8 @@ def _cron_list_handler(opts: dict[str, Any]) -> None:
"enabled": params.get("enabled"),
"sortBy": params.get("sortBy"),
"sortDir": params.get("sortDir"),
"tenantId": params.get("tenantId"),
"tenant_id": params.get("tenant_id"),
}
),
)

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,11 +262,22 @@ class _IlinkBridge:
if eid <= cursor:
continue
if str(event.get("token") or "") != token:
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:
alt = {"wechat", "weixin"}
if not (
channel in alt
and str(payload.get("channel") or "") in alt
):
continue
if account_id and str(payload.get("account_id") or "") != account_id:
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)
@ -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"]

View file

@ -21,3 +21,4 @@ python-dotenv>=1.0.0
sqlalchemy>=2.0.0
psycopg[binary]>=3.1.0
alembic>=1.13.0
croniter>=2.0.0

View file

@ -161,8 +161,18 @@ def run_attempt(*, store: Any, data: AttemptRunnerInput) -> AttemptRunnerOutput:
on_tool_ui=data.on_tool_ui,
should_stop=data.should_stop,
workspace_owner_session_id=_workspace_owner_session_id_from_msg(data.msg),
path_policy_tenant_id=str(data.msg.metadata.get("tenant_id") or "") if isinstance(data.msg.metadata, dict) else None,
path_policy_user_id=str(data.msg.metadata.get("user_id") or "") if isinstance(data.msg.metadata, dict) else None,
path_policy_tenant_id=str(data.msg.tenant_id or "")
or (
str(data.msg.metadata.get("tenant_id") or "")
if isinstance(data.msg.metadata, dict)
else ""
),
path_policy_user_id=str(data.msg.user_id or "")
or (
str(data.msg.metadata.get("user_id") or "")
if isinstance(data.msg.metadata, dict)
else ""
),
workspace_dir=data.workspace_dir,
memory_context=data.memory_context,
persist_user_message=bool(data.persist_user_message),

View file

@ -48,6 +48,7 @@ class AgentCoreRunInput:
skill_binding_role: str | None = None
wire_policy_role: str | None = None
persisted_user_text: str | None = None
persist_user_message: bool = True
@dataclass(frozen=True)
@ -204,7 +205,7 @@ def run_agent_core(*, store: Any, data: AgentCoreRunInput) -> AgentCoreRunOutput
max_tool_rounds=data.max_tool_rounds,
max_tool_workers=data.max_tool_workers,
memory_context=mem_ctx,
persist_user_message=(idx == 1),
persist_user_message=(idx == 1 and bool(data.persist_user_message)),
on_token=data.on_token,
on_progress=data.on_progress,
on_tool_ui=data.on_tool_ui,

View file

@ -103,7 +103,7 @@ def _menu_text() -> str:
)
def _handle_productivity_commands(*, text: str, tenant_id: str, user_id: str) -> str | None:
def _handle_productivity_commands(*, text: str, tenant_id: str, user_id: str, session_id: str = "") -> str | None:
t = (text or "").strip()
if not t:
return None
@ -175,9 +175,88 @@ def _handle_productivity_commands(*, text: str, tenant_id: str, user_id: str) ->
lines = [f"- {str(h.get('source') or '')}: {str(h.get('snippet') or '')}" for h in hits[:5] if isinstance(h, dict)]
return "知识检索结果:\n" + "\n".join(lines)
if t in ("查定时任务", "定时任务", "schedules", "/schedule list"):
rows = store.scheduled_job_list(tenant_id=tenant_id, status=None, limit=10)
if not rows:
return "当前没有定时任务。"
lines = [
f"- {r.name} | {r.schedule_kind}:{r.schedule_expr} | {r.status} | id={r.id[:8]}"
for r in rows
]
return "定时任务:\n" + "\n".join(lines)
if t.startswith("暂停定时任务 ") or t.startswith("暂停定时 "):
prefix = "暂停定时任务 " if t.startswith("暂停定时任务 ") else "暂停定时 "
jid = t[len(prefix) :].strip()
if not jid:
return "请提供任务 id 前缀。示例:暂停定时任务 1234abcd"
rows = store.scheduled_job_list(tenant_id=tenant_id, status=None, limit=200)
full = next((r.id for r in rows if str(r.id).startswith(jid)), jid)
ok = store.scheduled_job_set_status(tenant_id=tenant_id, job_id=full, status="paused")
return "已暂停。" if ok else "未找到该定时任务。"
if t.startswith("删除定时任务 ") or t.startswith("删除定时 "):
prefix = "删除定时任务 " if t.startswith("删除定时任务 ") else "删除定时 "
jid = t[len(prefix) :].strip()
if not jid:
return "请提供任务 id 前缀。示例:删除定时任务 1234abcd"
rows = store.scheduled_job_list(tenant_id=tenant_id, status=None, limit=200)
full = next((r.id for r in rows if str(r.id).startswith(jid)), jid)
ok = store.scheduled_job_delete(tenant_id=tenant_id, job_id=full)
return "已删除。" if ok else "未找到该定时任务。"
if t.startswith("记定时 ") or t.startswith("创建定时 "):
body = t.split(" ", 1)[1].strip() if " " in t else ""
parts = body.split(None, 1)
if len(parts) < 2:
return "格式:记定时 <时间> <提醒内容>。示例:记定时 5分钟 提醒我休息"
when_raw, prompt_text = parts[0].strip(), parts[1].strip()
seconds = _parse_schedule_duration_seconds(when_raw)
if seconds <= 0:
return "无法识别时间。示例:5分钟、1小时、300秒"
from runtime.scheduler.cron_service import build_delivery_for_session
delivery = build_delivery_for_session(
store,
tenant_id=tenant_id,
session_id=str(session_id or "").strip(),
)
row = store.scheduled_job_create(
tenant_id=tenant_id,
name=prompt_text[:40] or "定时提醒",
prompt_text=prompt_text,
schedule_kind="interval",
schedule_expr=str(seconds),
delivery=delivery,
source_session_id=str(session_id or "").strip() or None,
created_by_user_id=user_id,
source="chat",
)
return f"已创建定时任务:{row.id[:8]} | {prompt_text} | 每 {seconds} 秒"
return None
def _parse_schedule_duration_seconds(raw: str) -> int:
import re
text = str(raw or "").strip().lower()
if not text:
return 0
if text.isdigit():
return max(1, int(text))
m = re.match(r"^(\d+(?:\.\d+)?)\s*(秒|s|sec|secs|second|seconds|分钟|分|min|mins|小时|时|h|hr|hrs|hour|hours)$", text)
if not m:
return 0
val = float(m.group(1))
unit = m.group(2)
if unit in {"秒", "s", "sec", "secs", "second", "seconds"}:
return max(1, int(val))
if unit in {"分钟", "分", "min", "mins"}:
return max(1, int(val * 60))
return max(1, int(val * 3600))
def _role_can_write(role: str, text: str) -> bool:
low = (text or "").strip().lower()
if not low:
@ -810,6 +889,24 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
)
channel_session_id = str(session_id)
store.ensure_ui_session_owner(session_id=session_id, tenant_id=tenant_id, user_id=user_id)
if str(inbound.channel or "").strip().lower() in {"wechat", "weixin"}:
from runtime.scheduler.channel_delivery import (
extract_context_token_from_inbound_metadata,
persist_channel_context_token,
)
ctx_tok = extract_context_token_from_inbound_metadata(
inbound.metadata if isinstance(inbound.metadata, dict) else None
)
if ctx_tok:
persist_channel_context_token(
store,
tenant_id=tenant_id,
channel=str(inbound.channel or "weixin"),
account_id=account_id,
external_chat_id=str(inbound.external_chat_id or inbound.external_user_id or ""),
context_token=ctx_tok,
)
scope = "group" if inbound.is_group else "direct"
pe = PolicyEngine()
blob = (inbound.text or "").lower()
@ -842,6 +939,7 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
text=inbound.text,
tenant_id=tenant_id,
user_id=user_id,
session_id=str(session_id),
)
if cmd_reply is not None:
reply = cmd_reply

View file

@ -493,7 +493,21 @@ class ToolExecutor:
msg = f"Unregistered tool: {tc.name}" if ctx.lang.startswith("en") else f"未注册的工具: {tc.name}"
return {"ok": False, "error_code": "tool_not_registered", "error": msg}, int((time.perf_counter() - t0) * 1000)
ok, v_err = validate_tool_arguments(tool.parameters, tc.arguments)
from runtime.tools.context_inject import enrich_tool_arguments
from runtime.tools.tool_validation import filter_arguments_to_schema
raw_args = tc.arguments if isinstance(tc.arguments, dict) else {}
tool_args = enrich_tool_arguments(
store=ctx.store,
session_id=ctx.session_id,
tool_name=str(tc.name or ""),
arguments=raw_args,
path_policy_tenant_id=ctx.path_policy_tenant_id,
path_policy_user_id=ctx.path_policy_user_id,
)
tool_args = filter_arguments_to_schema(tool.parameters, tool_args)
ok, v_err = validate_tool_arguments(tool.parameters, tool_args)
if not ok:
msg = f"Invalid arguments: {v_err}" if ctx.lang.startswith("en") else f"参数不合法: {v_err}"
return {"ok": False, "error_code": "tool_invalid_arguments", "error": msg}, int((time.perf_counter() - t0) * 1000)
@ -530,7 +544,7 @@ class ToolExecutor:
netx_lang_token = NETX_TOOL_LANG.set(str(ctx.lang or "zh"))
try:
return tool.handler(tc.arguments)
return tool.handler(tool_args)
finally:
if netx_lang_token is not None:
from runtime.tools.experts.network_ops.netx_tools import NETX_TOOL_LANG

View file

@ -690,6 +690,14 @@ def _build_model_context(
)
except Exception:
pass
if str(user_text or "").strip() and str(active_turn_uuid or "").strip():
has_turn_user = any(
str(getattr(m, "role", "") or "") == "user"
and str(getattr(m, "turn_uuid", "") or "") == str(active_turn_uuid)
for m in (rows or [])
)
if not has_turn_user:
llm_messages.append({"role": "user", "content": str(user_text).strip()})
return llm_messages

View file

@ -192,6 +192,154 @@ function resolveHeaders(token: string): Record<string, string> {
};
}
async function postLocalJson(token: string, route: string, body: Json, timeoutMs = 8000): Promise<Json> {
const url = `${LOCAL_BASE_URL.replace(/\/+$/, "")}/${route.replace(/^\/+/, "")}`;
const res = await fetch(url, {
method: "POST",
headers: resolveHeaders(token),
body: JSON.stringify(body),
signal: AbortSignal.timeout(timeoutMs),
});
const text = await res.text();
if (!res.ok) {
throw new HttpStatusError(res.status, `${route} ${res.status}: ${text.slice(0, 300)}`);
}
return text ? (JSON.parse(text) as Json) : {};
}
async function getLocalJson(token: string, route: string, timeoutMs = 8000): Promise<Json> {
const url = `${LOCAL_BASE_URL.replace(/\/+$/, "")}/${route.replace(/^\/+/, "")}`;
const res = await fetch(url, {
method: "GET",
headers: resolveHeaders(token),
signal: AbortSignal.timeout(timeoutMs),
});
const text = await res.text();
if (!res.ok) {
throw new HttpStatusError(res.status, `${route} ${res.status}: ${text.slice(0, 300)}`);
}
return text ? (JSON.parse(text) as Json) : {};
}
async function flushWeixinDbOutbound(args: {
modules: OfficialModules;
token: string;
accountId: string;
cloudBaseUrl: string;
userContextTokens: TokenMap;
}): Promise<void> {
const q = encodeURIComponent(args.accountId);
const out = await getLocalJson(args.token, `weixin/outbound/pending?account_id=${q}&limit=20`, 8000);
const items = Array.isArray(out.items) ? (out.items as Json[]) : [];
for (const item of items) {
const toUser = String(item.chat_id || "").trim();
const text = String(item.text || "").trim();
const msgId = String(item.id || "").trim();
if (!toUser || !text || !msgId) continue;
const contextToken = String(
(item.context_token as string) ||
args.modules.getContextToken(args.accountId, toUser) ||
args.userContextTokens[toUser] ||
"",
).trim();
if (!contextToken) {
log(`db proactive reply missing context_token; keep pending. id=${msgId} to=${toUser}`);
continue;
}
try {
await args.modules.sendMessageWeixin({
to: toUser,
text,
opts: {
baseUrl: args.cloudBaseUrl,
token: args.token,
contextToken,
},
});
await postLocalJson(args.token, "weixin/outbound/ack", { id: msgId, ok: true }, 8000);
log(`db proactive reply sent: id=${msgId} to=${toUser} textLen=${text.length}`);
} catch (err) {
try {
await postLocalJson(
args.token,
"weixin/outbound/ack",
{ id: msgId, ok: false, error: String(err) },
8000,
);
} catch (_) {
// ignore ack failure
}
log(`db proactive reply failed: id=${msgId} to=${toUser} err=${String(err)}`);
}
}
}
async function flushLocalProactiveReplies(args: {
modules: OfficialModules;
token: string;
accountId: string;
cloudBaseUrl: string;
localCursor: string;
userContextTokens: TokenMap;
}): Promise<string> {
let cursor = args.localCursor;
for (let i = 0; i < 3; i += 1) {
const out = await postLocalJson(
args.token,
"ilink/bot/getupdates",
{
channel: "wechat",
account_id: args.accountId,
get_updates_buf: cursor,
longpolling_timeout_ms: 1000,
limit: 20,
},
8000,
);
const msgs = Array.isArray(out.msgs) ? (out.msgs as Json[]) : [];
const next = String(out.get_updates_buf || cursor || "").trim();
const batchCursor = cursor;
const nextCursor = next || cursor;
if (!msgs.length) {
break;
}
let allSucceeded = true;
for (const r of msgs) {
const toUser = String(r.chat_id || "").trim();
const text = String(r.text || "").trim();
if (!toUser || !text) continue;
const contextToken = String(
(r.context_token as string) ||
args.modules.getContextToken(args.accountId, toUser) ||
args.userContextTokens[toUser] ||
"",
).trim();
if (!contextToken) {
allSucceeded = false;
log(`proactive reply missing context_token; keep cursor. to=${toUser} textLen=${text.length}`);
continue;
}
try {
await args.modules.sendMessageWeixin({
to: toUser,
text,
opts: {
baseUrl: args.cloudBaseUrl,
token: args.token,
contextToken,
},
});
log(`proactive reply sent: to=${toUser} textLen=${text.length}`);
} catch (err) {
log(`proactive reply failed: to=${toUser} err=${String(err)}`);
allSucceeded = false;
}
}
cursor = allSucceeded ? nextCursor : batchCursor;
}
return cursor;
}
async function postNativeReply(token: string, body: Json): Promise<Json> {
const url = `${LOCAL_BASE_URL.replace(/\/+$/, "")}/weixin/native/reply`;
const timeoutMs = Number(process.env.OCLAW_WEIXIN_NATIVE_REPLY_TIMEOUT_MS || "100000") || 100000;
@ -490,6 +638,7 @@ async function main(): Promise<void> {
ensureDir(STATE_DIR);
const state = (readJsonFile<Json>(STATE_FILE) || {}) as Json;
let cloudCursor = String(state.cloud_cursor || "").trim();
let localCursor = String(state.local_cursor || "").trim();
const userContextTokens: TokenMap =
state.user_context_tokens && typeof state.user_context_tokens === "object"
? (state.user_context_tokens as TokenMap)
@ -500,6 +649,25 @@ async function main(): Promise<void> {
log(`official runner started account=${accountId} cloud=${cloudBaseUrl} local=${LOCAL_BASE_URL}`);
while (true) {
try {
try {
await flushWeixinDbOutbound({
modules,
token,
accountId,
cloudBaseUrl,
userContextTokens,
});
} catch (err) {
log(`db proactive flush error: ${String(err)}`);
}
localCursor = await flushLocalProactiveReplies({
modules,
token,
accountId,
cloudBaseUrl,
localCursor,
userContextTokens,
});
const out = await modules.getUpdates({
baseUrl: cloudBaseUrl,
token,
@ -530,6 +698,7 @@ async function main(): Promise<void> {
}
writeJsonFile(STATE_FILE, {
cloud_cursor: cloudCursor,
local_cursor: localCursor,
user_context_tokens: userContextTokens,
updated_at: new Date().toISOString(),
});

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

View file

@ -0,0 +1,70 @@
from __future__ import annotations
from typing import Any
_CONTEXT_SCOPED_TOOL_PREFIXES = ("schedule_", "todo_")
_CONTEXT_SCOPED_TOOL_NAMES = frozenset(
{
"kb_add",
"kb_search",
}
)
def _session_owner(store: Any, session_id: str) -> tuple[str, str]:
sid = str(session_id or "").strip()
if not sid:
return "", ""
try:
owner = store.get_ui_session_owner(session_id=sid) or {}
except Exception:
owner = {}
if isinstance(owner, dict):
tid = str(owner.get("tenant_id") or "").strip()
uid = str(owner.get("user_id") or "").strip()
if tid and uid:
return tid, uid
return "", ""
def enrich_tool_arguments(
*,
store: Any,
session_id: str,
tool_name: str,
arguments: dict[str, Any] | None,
path_policy_tenant_id: str | None = None,
path_policy_user_id: str | None = None,
) -> dict[str, Any]:
"""Inject tenant/user/session context for productivity tools (WeChat, WhatsApp, etc.)."""
merged = dict(arguments or {})
name = str(tool_name or "").strip()
if not name:
return merged
scoped = name in _CONTEXT_SCOPED_TOOL_NAMES or any(
name.startswith(p) for p in _CONTEXT_SCOPED_TOOL_PREFIXES
)
if not scoped:
return merged
tenant_id = str(path_policy_tenant_id or merged.get("tenant_id") or "").strip()
user_id = str(path_policy_user_id or merged.get("owner_user_id") or merged.get("user_id") or "").strip()
if not tenant_id or not user_id:
o_tid, o_uid = _session_owner(store, session_id)
tenant_id = tenant_id or o_tid
user_id = user_id or o_uid
if tenant_id:
merged["tenant_id"] = tenant_id
if user_id:
if name in _CONTEXT_SCOPED_TOOL_NAMES:
merged["user_id"] = user_id
else:
merged["owner_user_id"] = user_id
sid = str(session_id or merged.get("session_id") or "").strip()
if sid:
merged["session_id"] = sid
return merged
__all__ = ["enrich_tool_arguments"]

View file

@ -0,0 +1,334 @@
from __future__ import annotations
import json
from typing import Any
from runtime.scheduler.cron_service import build_delivery_for_session
from runtime.scheduler.expressions import normalize_schedule_kind
from runtime.scheduler.service import run_scheduled_job_now
from runtime.types import normalize_interaction_mode, normalize_requested_specialist
from svc.persistence.assistant_store import get_assistant_store
from runtime.tools.base import ToolSpec
from runtime.tools.context_inject import enrich_tool_arguments
def _require(s: str, name: str) -> str:
v = (s or "").strip()
if not v:
raise ValueError(f"{name} is required")
return v
def _parse_delivery_arg(raw: Any) -> dict[str, Any] | None:
if raw is None:
return None
if isinstance(raw, dict):
return raw
if isinstance(raw, str) and raw.strip():
try:
data = json.loads(raw)
return data if isinstance(data, dict) else None
except Exception:
return None
return None
def schedule_create_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
try:
store = get_assistant_store()
args = enrich_tool_arguments(
store=store,
session_id=str(args.get("session_id") or ""),
tool_name="schedule_create",
arguments=args,
)
tenant_id = _require(str(args.get("tenant_id") or ""), "tenant_id")
owner_user_id = _require(
str(args.get("owner_user_id") or args.get("user_id") or ""),
"owner_user_id",
)
name = _require(str(args.get("name") or ""), "name")
prompt_text = _require(str(args.get("prompt_text") or ""), "prompt_text")
schedule_kind = normalize_schedule_kind(str(args.get("schedule_kind") or "cron"))
schedule_expr = _require(str(args.get("schedule_expr") or ""), "schedule_expr")
delivery = _parse_delivery_arg(args.get("delivery"))
if delivery is None:
delivery = build_delivery_for_session(
store,
tenant_id=tenant_id,
session_id=str(args.get("session_id") or ""),
whatsapp_chat_id=str(args.get("whatsapp_chat_id") or ""),
)
interaction_mode = normalize_interaction_mode(
str(args.get("interaction_mode") or "expert")
)
specialist = normalize_requested_specialist(
str(args.get("specialist") or args.get("selected_specialist") or "generalist")
)
row = store.scheduled_job_create(
tenant_id=tenant_id,
name=name,
prompt_text=prompt_text,
schedule_kind=schedule_kind,
schedule_expr=schedule_expr,
timezone_name=str(args.get("timezone") or "Asia/Shanghai"),
description=str(args.get("description") or ""),
interaction_mode=interaction_mode,
specialist=specialist,
lang=str(args.get("lang") or "zh"),
delivery=delivery,
source_session_id=str(args.get("session_id") or "").strip() or None,
created_by_user_id=owner_user_id,
source="chat",
)
return {"ok": True, "job": store.scheduled_job_to_dict(row)}
except Exception as e:
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
return ToolSpec(
name="schedule_create",
description="Create a scheduled job (cron, once, or interval). Delivery follows the current chat channel (WhatsApp vs WeChat) unless delivery is set explicitly.",
parameters={
"type": "object",
"properties": {
"tenant_id": {"type": "string", "description": "Auto-filled from session; do not guess."},
"owner_user_id": {"type": "string", "description": "Auto-filled from session."},
"session_id": {"type": "string", "description": "Auto-filled from session."},
"name": {"type": "string"},
"prompt_text": {"type": "string"},
"schedule_kind": {"type": "string", "enum": ["cron", "once", "interval"]},
"schedule_expr": {"type": "string"},
"timezone": {"type": "string", "default": "Asia/Shanghai"},
"interaction_mode": {"type": "string"},
"specialist": {"type": "string"},
"selected_specialist": {"type": "string"},
"lang": {"type": "string"},
"whatsapp_chat_id": {"type": "string"},
"delivery": {"type": "object"},
"description": {"type": "string"},
},
"required": ["name", "prompt_text", "schedule_kind", "schedule_expr"],
"additionalProperties": False,
},
handler=handler,
tags=frozenset({"productivity", "write", "schedule"}),
)
def schedule_list_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
try:
store = get_assistant_store()
args = enrich_tool_arguments(
store=store,
session_id=str(args.get("session_id") or ""),
tool_name="schedule_list",
arguments=args,
)
tenant_id = _require(str(args.get("tenant_id") or ""), "tenant_id")
status = str(args.get("status") or "").strip() or None
limit = int(args.get("limit") or 50)
rows = store.scheduled_job_list(tenant_id=tenant_id, status=status, limit=limit)
return {"ok": True, "items": [store.scheduled_job_to_dict(r) for r in rows]}
except Exception as e:
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
return ToolSpec(
name="schedule_list",
description="List scheduled jobs for a tenant.",
parameters={
"type": "object",
"properties": {
"tenant_id": {"type": "string"},
"status": {"type": "string"},
"limit": {"type": "integer", "default": 50},
},
"required": [],
"additionalProperties": False,
},
handler=handler,
tags=frozenset({"productivity", "schedule"}),
)
def _scoped_args(store: Any, tool_name: str, args: dict[str, Any]) -> dict[str, Any]:
return enrich_tool_arguments(
store=store,
session_id=str(args.get("session_id") or ""),
tool_name=tool_name,
arguments=args,
)
def schedule_update_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
try:
store = get_assistant_store()
args = _scoped_args(store, "schedule_update", args)
tenant_id = _require(str(args.get("tenant_id") or ""), "tenant_id")
job_id = _require(str(args.get("job_id") or ""), "job_id")
patch: dict[str, Any] = {}
for key in (
"name",
"description",
"prompt_text",
"schedule_kind",
"schedule_expr",
"timezone",
"interaction_mode",
"specialist",
"lang",
):
if key in args and args.get(key) is not None:
patch[key] = args.get(key)
delivery = _parse_delivery_arg(args.get("delivery"))
if delivery is not None:
patch["delivery"] = delivery
row = store.scheduled_job_update(tenant_id=tenant_id, job_id=job_id, patch=patch)
if not row:
return {"ok": False, "error": "job_not_found"}
return {"ok": True, "job": store.scheduled_job_to_dict(row)}
except Exception as e:
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
return ToolSpec(
name="schedule_update",
description="Update a scheduled job.",
parameters={
"type": "object",
"properties": {
"tenant_id": {"type": "string"},
"job_id": {"type": "string"},
"name": {"type": "string"},
"prompt_text": {"type": "string"},
"schedule_kind": {"type": "string"},
"schedule_expr": {"type": "string"},
"timezone": {"type": "string"},
"interaction_mode": {"type": "string"},
"specialist": {"type": "string"},
"lang": {"type": "string"},
"delivery": {"type": "object"},
"description": {"type": "string"},
},
"required": ["job_id"],
"additionalProperties": False,
},
handler=handler,
tags=frozenset({"productivity", "write", "schedule"}),
)
def schedule_pause_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
try:
store = get_assistant_store()
args = _scoped_args(store, "schedule_pause", args)
tenant_id = _require(str(args.get("tenant_id") or ""), "tenant_id")
job_id = _require(str(args.get("job_id") or ""), "job_id")
ok = store.scheduled_job_set_status(tenant_id=tenant_id, job_id=job_id, status="paused")
return {"ok": bool(ok), "job_id": job_id, "status": "paused"}
except Exception as e:
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
return ToolSpec(
name="schedule_pause",
description="Pause a scheduled job.",
parameters={
"type": "object",
"properties": {"tenant_id": {"type": "string"}, "job_id": {"type": "string"}},
"required": ["job_id"],
"additionalProperties": False,
},
handler=handler,
tags=frozenset({"productivity", "write", "schedule"}),
)
def schedule_resume_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
try:
store = get_assistant_store()
args = _scoped_args(store, "schedule_resume", args)
tenant_id = _require(str(args.get("tenant_id") or ""), "tenant_id")
job_id = _require(str(args.get("job_id") or ""), "job_id")
ok = store.scheduled_job_set_status(tenant_id=tenant_id, job_id=job_id, status="active")
return {"ok": bool(ok), "job_id": job_id, "status": "active"}
except Exception as e:
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
return ToolSpec(
name="schedule_resume",
description="Resume a paused scheduled job.",
parameters={
"type": "object",
"properties": {"tenant_id": {"type": "string"}, "job_id": {"type": "string"}},
"required": ["job_id"],
"additionalProperties": False,
},
handler=handler,
tags=frozenset({"productivity", "write", "schedule"}),
)
def schedule_delete_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
try:
store = get_assistant_store()
args = _scoped_args(store, "schedule_delete", args)
tenant_id = _require(str(args.get("tenant_id") or ""), "tenant_id")
job_id = _require(str(args.get("job_id") or ""), "job_id")
ok = store.scheduled_job_delete(tenant_id=tenant_id, job_id=job_id)
return {"ok": bool(ok), "job_id": job_id}
except Exception as e:
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
return ToolSpec(
name="schedule_delete",
description="Delete (soft) a scheduled job.",
parameters={
"type": "object",
"properties": {"tenant_id": {"type": "string"}, "job_id": {"type": "string"}},
"required": ["job_id"],
"additionalProperties": False,
},
handler=handler,
tags=frozenset({"productivity", "write", "schedule"}),
)
def schedule_run_now_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
try:
store = get_assistant_store()
args = _scoped_args(store, "schedule_run_now", args)
tenant_id = _require(str(args.get("tenant_id") or ""), "tenant_id")
job_id = _require(str(args.get("job_id") or ""), "job_id")
out = run_scheduled_job_now(store, tenant_id=tenant_id, job_id=job_id)
return out
except Exception as e:
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
return ToolSpec(
name="schedule_run_now",
description="Trigger a scheduled job immediately.",
parameters={
"type": "object",
"properties": {"tenant_id": {"type": "string"}, "job_id": {"type": "string"}},
"required": ["job_id"],
"additionalProperties": False,
},
handler=handler,
tags=frozenset({"productivity", "write", "schedule"}),
)
__all__ = [
"schedule_create_tool",
"schedule_list_tool",
"schedule_update_tool",
"schedule_pause_tool",
"schedule_resume_tool",
"schedule_delete_tool",
"schedule_run_now_tool",
]

View file

@ -10,6 +10,17 @@ except Exception: # pragma: no cover
validators = None
def filter_arguments_to_schema(parameters: dict[str, Any], arguments: dict[str, Any]) -> dict[str, Any]:
"""Drop keys not declared in tool schema when additionalProperties is false."""
if not isinstance(arguments, dict):
return {}
schema = parameters or {}
props = schema.get("properties") if isinstance(schema.get("properties"), dict) else {}
if schema.get("additionalProperties") is False and props:
return {k: v for k, v in arguments.items() if k in props}
return dict(arguments)
def validate_tool_arguments(parameters: dict[str, Any], arguments: dict[str, Any]) -> tuple[bool, str | None]:
"""校验模型给出的 arguments 是否符合工具的 JSON Schema(OpenAI function parameters)。"""
if not isinstance(arguments, dict):
@ -33,5 +44,5 @@ def validate_tool_arguments(parameters: dict[str, Any], arguments: dict[str, Any
return True, None
__all__ = ["validate_tool_arguments"]
__all__ = ["filter_arguments_to_schema", "validate_tool_arguments"]

View file

@ -238,6 +238,7 @@ def _worker_loop(*, store: Any, worker_id: str, poll_interval_s: float) -> None:
tenant_id = str(payload.get("tenant_id") or "")
user_id = str(payload.get("user_id") or "")
viewer_username = str(payload.get("viewer_username") or "")
is_scheduled_turn = str(task.task_type or "") == "scheduled_turn"
model_profile_id = str(payload.get("model_profile_id") or "") or None
interaction_mode = normalize_interaction_mode(
str(payload.get("interaction_mode") or metadata.get("interaction_mode") or "")
@ -284,6 +285,10 @@ def _worker_loop(*, store: Any, worker_id: str, poll_interval_s: float) -> None:
system_prompt = ""
if not system_prompt:
system_prompt = str(getattr(executor, "system_prompt", "") or "")
if is_scheduled_turn:
from runtime.scheduler.turn_text import scheduled_turn_system_suffix
system_prompt = str(system_prompt or "") + scheduled_turn_system_suffix(lang=lang)
max_messages = int(store.get_setting("AIA_TURN_MAX_CONTEXT_MESSAGES") or 80)
max_tool_rounds = int(store.get_setting("AIA_TURN_MAX_TOOL_ROUNDS") or 100)
@ -299,6 +304,7 @@ def _worker_loop(*, store: Any, worker_id: str, poll_interval_s: float) -> None:
attachments=list(attachments or []),
metadata=dict(metadata or {}),
)
if not is_scheduled_turn:
_maybe_rename_from_first_user_message(
store=store,
session_id=session_id,
@ -310,7 +316,7 @@ def _worker_loop(*, store: Any, worker_id: str, poll_interval_s: float) -> None:
store=store,
data=AgentCoreRunInput(
msg=msg,
persisted_user_text=str(user_text or ""),
persisted_user_text="" if is_scheduled_turn else str(user_text or ""),
lang=lang,
system_prompt=system_prompt,
model=executor.model,
@ -327,10 +333,12 @@ def _worker_loop(*, store: Any, worker_id: str, poll_interval_s: float) -> None:
oclaw_worker_id=worker_id,
skill_binding_role=skill_binding_role,
wire_policy_role=wire_policy_role,
persist_user_message=not is_scheduled_turn,
),
)
base_result = {
"run_id": str(run_out.run_id or ""),
"turn_uuid": str(run_out.outcome.turn_uuid or ""),
"reply_text": run_out.outcome.final_text,
"tool_trace_count": len(run_out.outcome.tool_traces),
"relay_pointer_count": int(payload.get("relay_pointer_count") or 0),
@ -344,6 +352,15 @@ def _worker_loop(*, store: Any, worker_id: str, poll_interval_s: float) -> None:
relay_envelope=relay_share_envelope,
)
)
if str(task.task_type or "") == "scheduled_turn":
from runtime.scheduler.worker_turn import finalize_scheduled_turn_success
finalize_scheduled_turn_success(
store=store,
task=task,
payload=payload,
base_result=base_result,
)
store.oclaw_task_finish(task_id=task.id, result=base_result)
if trace_id:
trace_payload = {
@ -369,6 +386,10 @@ def _worker_loop(*, store: Any, worker_id: str, poll_interval_s: float) -> None:
payload=trace_payload,
)
except Exception as exc:
if str(getattr(task, "task_type", "") or "") == "scheduled_turn":
from runtime.scheduler.worker_turn import finalize_scheduled_turn_failure
finalize_scheduled_turn_failure(store=store, payload=payload, error=str(exc))
store.oclaw_task_fail(task_id=task.id, error=str(exc), result={"ok": False})
try:
if trace_id:

View file

@ -170,6 +170,20 @@ class AssistantStoreProtocol(Protocol):
def todo_create(self, *, tenant_id: 'str', owner_user_id: 'str', title: 'str', due_at: 'str | None' = None, assignee_user_id: 'str | None' = None) -> 'dict[str, Any]': ...
def todo_list(self, *, tenant_id: 'str', assignee_user_id: 'str | None' = None, status: 'str | None' = 'open', limit: 'int' = 50) -> 'list[dict[str, Any]]': ...
def todo_set_status(self, *, tenant_id: 'str', todo_id: 'str', status: 'str') -> 'bool': ...
def scheduled_job_create(self, *, tenant_id: 'str', name: 'str', prompt_text: 'str', schedule_kind: 'str', schedule_expr: 'str', timezone_name: 'str' = 'Asia/Shanghai', description: 'str' = '', interaction_mode: 'str' = 'expert', specialist: 'str' = 'generalist', lang: 'str' = 'zh', delivery: 'dict[str, Any] | None' = None, source_session_id: 'str | None' = None, created_by_user_id: 'str' = '', source: 'str' = 'admin', status: 'str' = 'active') -> 'Any': ...
def scheduled_job_get(self, *, job_id: 'str', tenant_id: 'str | None' = None) -> 'Any | None': ...
def scheduled_job_list(self, *, tenant_id: 'str', status: 'str | None' = None, limit: 'int' = 100, offset: 'int' = 0) -> 'list[Any]': ...
def scheduled_job_update(self, *, tenant_id: 'str', job_id: 'str', patch: 'dict[str, Any]') -> 'Any | None': ...
def scheduled_job_set_status(self, *, tenant_id: 'str', job_id: 'str', status: 'str') -> 'bool': ...
def scheduled_job_delete(self, *, tenant_id: 'str', job_id: 'str') -> 'bool': ...
def scheduled_job_list_due(self, *, limit: 'int' = 20, now_iso: 'str | None' = None) -> 'list[Any]': ...
def scheduled_job_mark_run(self, *, job_id: 'str', tenant_id: 'str', last_run_status: 'str', pause_after: 'bool' = False) -> 'None': ...
def scheduled_job_run_create(self, *, job_id: 'str', tenant_id: 'str', scheduled_at: 'str | None' = None, status: 'str' = 'queued') -> 'Any': ...
def scheduled_job_run_get(self, *, run_id: 'str', tenant_id: 'str | None' = None) -> 'Any | None': ...
def scheduled_job_run_list(self, *, job_id: 'str', tenant_id: 'str', limit: 'int' = 50, offset: 'int' = 0) -> 'list[Any]': ...
def scheduled_job_run_update(self, *, run_id: 'str', tenant_id: 'str', patch: 'dict[str, Any]') -> 'Any | None': ...
def scheduled_job_to_dict(self, job: 'Any') -> 'dict[str, Any]': ...
def scheduled_job_run_to_dict(self, run: 'Any') -> 'dict[str, Any]': ...
def touch_auth_session(self, *, session_token_hash: 'str') -> 'None': ...
def trim_messages(self, session_id: 'str', keep_last: 'int') -> 'None': ...
def update_llm_profile(self, profile_id: 'str', name: 'str', mode: 'str', model: 'str | None', base_url: 'str | None', *, thinking_mode_enabled: 'bool | None' = None, reasoning_effort: 'str | None' = None) -> 'None': ...

View file

@ -0,0 +1,634 @@
from __future__ import annotations
import json
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
from runtime.scheduler.expressions import compute_next_run_at, normalize_schedule_kind
SCHEDULED_JOB_DDL = """
CREATE TABLE IF NOT EXISTS scheduled_job (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'active',
schedule_kind TEXT NOT NULL,
schedule_expr TEXT NOT NULL,
timezone TEXT NOT NULL DEFAULT 'Asia/Shanghai',
prompt_text TEXT NOT NULL,
interaction_mode TEXT NOT NULL DEFAULT 'expert',
specialist TEXT NOT NULL DEFAULT 'generalist',
lang TEXT NOT NULL DEFAULT 'zh',
delivery_json TEXT NOT NULL DEFAULT '{}',
source_session_id TEXT,
created_by_user_id TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT 'admin',
next_run_at TEXT,
last_run_at TEXT,
last_run_status TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
"""
SCHEDULED_JOB_INDEX_DDL = """
CREATE INDEX IF NOT EXISTS idx_scheduled_job_due
ON scheduled_job(status, next_run_at);
"""
SCHEDULED_JOB_RUN_DDL = """
CREATE TABLE IF NOT EXISTS scheduled_job_run (
id TEXT PRIMARY KEY,
job_id TEXT NOT NULL,
tenant_id TEXT NOT NULL,
status TEXT NOT NULL,
scheduled_at TEXT NOT NULL,
started_at TEXT,
finished_at TEXT,
session_id TEXT,
oclaw_task_id TEXT,
run_id TEXT,
reply_text TEXT NOT NULL DEFAULT '',
delivery_status_json TEXT NOT NULL DEFAULT '{}',
error TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
FOREIGN KEY(job_id) REFERENCES scheduled_job(id) ON DELETE CASCADE
);
"""
SCHEDULED_JOB_RUN_INDEX_DDL = """
CREATE INDEX IF NOT EXISTS idx_scheduled_job_run_job
ON scheduled_job_run(job_id, created_at DESC);
"""
def utc_now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
@dataclass(frozen=True)
class ScheduledJob:
id: str
tenant_id: str
name: str
description: str
status: str
schedule_kind: str
schedule_expr: str
timezone: str
prompt_text: str
interaction_mode: str
specialist: str
lang: str
delivery_json: str
source_session_id: str | None
created_by_user_id: str
source: str
next_run_at: str | None
last_run_at: str | None
last_run_status: str
created_at: str
updated_at: str
@dataclass(frozen=True)
class ScheduledJobRun:
id: str
job_id: str
tenant_id: str
status: str
scheduled_at: str
started_at: str | None
finished_at: str | None
session_id: str | None
oclaw_task_id: str | None
run_id: str | None
reply_text: str
delivery_status_json: str
error: str
created_at: str
def _row_to_job(row: Any) -> ScheduledJob:
return ScheduledJob(
id=str(row["id"] or ""),
tenant_id=str(row["tenant_id"] or ""),
name=str(row["name"] or ""),
description=str(row["description"] or ""),
status=str(row["status"] or ""),
schedule_kind=str(row["schedule_kind"] or ""),
schedule_expr=str(row["schedule_expr"] or ""),
timezone=str(row["timezone"] or "Asia/Shanghai"),
prompt_text=str(row["prompt_text"] or ""),
interaction_mode=str(row["interaction_mode"] or "expert"),
specialist=str(row["specialist"] or "generalist"),
lang=str(row["lang"] or "zh"),
delivery_json=str(row["delivery_json"] or "{}"),
source_session_id=str(row["source_session_id"] or "") or None,
created_by_user_id=str(row["created_by_user_id"] or ""),
source=str(row["source"] or ""),
next_run_at=str(row["next_run_at"] or "") or None,
last_run_at=str(row["last_run_at"] or "") or None,
last_run_status=str(row["last_run_status"] or ""),
created_at=str(row["created_at"] or ""),
updated_at=str(row["updated_at"] or ""),
)
def _row_to_run(row: Any) -> ScheduledJobRun:
return ScheduledJobRun(
id=str(row["id"] or ""),
job_id=str(row["job_id"] or ""),
tenant_id=str(row["tenant_id"] or ""),
status=str(row["status"] or ""),
scheduled_at=str(row["scheduled_at"] or ""),
started_at=str(row["started_at"] or "") or None,
finished_at=str(row["finished_at"] or "") or None,
session_id=str(row["session_id"] or "") or None,
oclaw_task_id=str(row["oclaw_task_id"] or "") or None,
run_id=str(row["run_id"] or "") or None,
reply_text=str(row["reply_text"] or ""),
delivery_status_json=str(row["delivery_status_json"] or "{}"),
error=str(row["error"] or ""),
created_at=str(row["created_at"] or ""),
)
_JOB_SELECT = """
SELECT id, tenant_id, name, description, status, schedule_kind, schedule_expr, timezone,
prompt_text, interaction_mode, specialist, lang, delivery_json, source_session_id,
created_by_user_id, source, next_run_at, last_run_at, last_run_status, created_at, updated_at
FROM scheduled_job
"""
class ScheduledJobStoreMixin:
def ensure_scheduled_job_tables(self, conn: Any) -> None:
conn.execute(SCHEDULED_JOB_DDL)
conn.execute(SCHEDULED_JOB_INDEX_DDL)
conn.execute(SCHEDULED_JOB_RUN_DDL)
conn.execute(SCHEDULED_JOB_RUN_INDEX_DDL)
def scheduled_job_create(
self,
*,
tenant_id: str,
name: str,
prompt_text: str,
schedule_kind: str,
schedule_expr: str,
timezone_name: str = "Asia/Shanghai",
description: str = "",
interaction_mode: str = "expert",
specialist: str = "generalist",
lang: str = "zh",
delivery: dict[str, Any] | None = None,
source_session_id: str | None = None,
created_by_user_id: str = "",
source: str = "admin",
status: str = "active",
) -> ScheduledJob:
jid = str(uuid.uuid4())
ts = utc_now_iso()
kind = normalize_schedule_kind(schedule_kind)
tz = str(timezone_name or "Asia/Shanghai").strip() or "Asia/Shanghai"
next_run = compute_next_run_at(
schedule_kind=kind,
schedule_expr=str(schedule_expr or "").strip(),
timezone_name=tz,
from_dt=None,
)
delivery_json = json.dumps(delivery or {}, ensure_ascii=False)
with self._connect() as conn: # type: ignore[attr-defined]
conn.execute(
"""
INSERT INTO scheduled_job
(id, tenant_id, name, description, status, schedule_kind, schedule_expr, timezone,
prompt_text, interaction_mode, specialist, lang, delivery_json, source_session_id,
created_by_user_id, source, next_run_at, last_run_at, last_run_status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, '', ?, ?)
""",
(
jid,
str(tenant_id),
str(name or "").strip() or "Scheduled task",
str(description or ""),
str(status or "active"),
kind,
str(schedule_expr or "").strip(),
tz,
str(prompt_text or "").strip(),
str(interaction_mode or "expert"),
str(specialist or "generalist"),
str(lang or "zh"),
delivery_json,
str(source_session_id) if source_session_id else None,
str(created_by_user_id or ""),
str(source or "admin"),
next_run,
ts,
ts,
),
)
got = self.scheduled_job_get(job_id=jid, tenant_id=tenant_id)
if not got:
raise RuntimeError("failed to create scheduled job")
return got
def scheduled_job_get(self, *, job_id: str, tenant_id: str | None = None) -> ScheduledJob | None:
with self._connect() as conn: # type: ignore[attr-defined]
if tenant_id:
row = conn.execute(
f"{_JOB_SELECT} WHERE id = ? AND tenant_id = ?",
(str(job_id), str(tenant_id)),
).fetchone()
else:
row = conn.execute(f"{_JOB_SELECT} WHERE id = ?", (str(job_id),)).fetchone()
if not row:
return None
return _row_to_job(row)
def scheduled_job_list(
self,
*,
tenant_id: str,
status: str | None = None,
limit: int = 100,
offset: int = 0,
) -> list[ScheduledJob]:
lim = max(1, min(int(limit), 500))
off = max(0, int(offset))
where = ["tenant_id = ?", "status != 'deleted'"]
params: list[Any] = [str(tenant_id)]
if status:
where.append("status = ?")
params.append(str(status))
wsql = " AND ".join(where)
with self._connect() as conn: # type: ignore[attr-defined]
rows = conn.execute(
f"{_JOB_SELECT} WHERE {wsql} ORDER BY updated_at DESC LIMIT ? OFFSET ?",
(*params, lim, off),
).fetchall()
return [_row_to_job(r) for r in rows]
def scheduled_job_update(
self,
*,
tenant_id: str,
job_id: str,
patch: dict[str, Any],
) -> ScheduledJob | None:
cur = self.scheduled_job_get(job_id=job_id, tenant_id=tenant_id)
if not cur:
return None
p = dict(patch or {})
name = str(p.get("name") if "name" in p else cur.name)
description = str(p.get("description") if "description" in p else cur.description)
prompt_text = str(p.get("prompt_text") if "prompt_text" in p else cur.prompt_text)
interaction_mode = str(p.get("interaction_mode") if "interaction_mode" in p else cur.interaction_mode)
specialist = str(p.get("specialist") if "specialist" in p else cur.specialist)
lang = str(p.get("lang") if "lang" in p else cur.lang)
schedule_kind = normalize_schedule_kind(
str(p.get("schedule_kind") if "schedule_kind" in p else cur.schedule_kind)
)
schedule_expr = str(p.get("schedule_expr") if "schedule_expr" in p else cur.schedule_expr)
tz = str(p.get("timezone") if "timezone" in p else cur.timezone)
delivery_json = cur.delivery_json
if "delivery" in p and isinstance(p.get("delivery"), dict):
delivery_json = json.dumps(p["delivery"], ensure_ascii=False)
elif "delivery_json" in p:
delivery_json = str(p.get("delivery_json") or "{}")
source_session_id = cur.source_session_id
if "source_session_id" in p:
raw_sid = str(p.get("source_session_id") or "").strip()
source_session_id = raw_sid or None
status = str(p.get("status") if "status" in p else cur.status)
recompute = any(k in p for k in ("schedule_kind", "schedule_expr", "timezone", "status"))
next_run_at = cur.next_run_at
if recompute and status == "active":
next_run_at = compute_next_run_at(
schedule_kind=schedule_kind,
schedule_expr=schedule_expr,
timezone_name=tz,
from_dt=None,
)
elif status != "active":
next_run_at = None
ts = utc_now_iso()
with self._connect() as conn: # type: ignore[attr-defined]
conn.execute(
"""
UPDATE scheduled_job SET
name = ?, description = ?, prompt_text = ?, interaction_mode = ?, specialist = ?,
lang = ?, schedule_kind = ?, schedule_expr = ?, timezone = ?, delivery_json = ?,
source_session_id = ?, status = ?, next_run_at = ?, updated_at = ?
WHERE id = ? AND tenant_id = ?
""",
(
name,
description,
prompt_text,
interaction_mode,
specialist,
lang,
schedule_kind,
schedule_expr,
tz,
delivery_json,
source_session_id,
status,
next_run_at,
ts,
str(job_id),
str(tenant_id),
),
)
return self.scheduled_job_get(job_id=job_id, tenant_id=tenant_id)
def scheduled_job_set_status(self, *, tenant_id: str, job_id: str, status: str) -> bool:
cur = self.scheduled_job_get(job_id=job_id, tenant_id=tenant_id)
if not cur:
return False
ts = utc_now_iso()
next_run_at = cur.next_run_at
if status == "active":
next_run_at = compute_next_run_at(
schedule_kind=cur.schedule_kind,
schedule_expr=cur.schedule_expr,
timezone_name=cur.timezone,
from_dt=None,
)
else:
next_run_at = None
with self._connect() as conn: # type: ignore[attr-defined]
cur2 = conn.execute(
"""
UPDATE scheduled_job SET status = ?, next_run_at = ?, updated_at = ?
WHERE id = ? AND tenant_id = ?
""",
(str(status), next_run_at, ts, str(job_id), str(tenant_id)),
)
return bool(cur2.rowcount)
def scheduled_job_delete(self, *, tenant_id: str, job_id: str) -> bool:
return self.scheduled_job_set_status(tenant_id=tenant_id, job_id=job_id, status="deleted")
def scheduled_job_list_due(self, *, limit: int = 20, now_iso: str | None = None) -> list[ScheduledJob]:
lim = max(1, min(int(limit), 100))
now = str(now_iso or utc_now_iso())
with self._connect() as conn: # type: ignore[attr-defined]
rows = conn.execute(
f"""
{_JOB_SELECT}
WHERE status = 'active' AND next_run_at IS NOT NULL AND next_run_at <= ?
ORDER BY next_run_at ASC
LIMIT ?
""",
(now, lim),
).fetchall()
return [_row_to_job(r) for r in rows]
def scheduled_job_mark_run(
self,
*,
job_id: str,
tenant_id: str,
last_run_status: str,
pause_after: bool = False,
) -> None:
cur = self.scheduled_job_get(job_id=job_id, tenant_id=tenant_id)
if not cur:
return
ts = utc_now_iso()
status = "paused" if pause_after else cur.status
with self._connect() as conn: # type: ignore[attr-defined]
conn.execute(
"""
UPDATE scheduled_job SET
last_run_at = ?, last_run_status = ?, status = ?, updated_at = ?
WHERE id = ? AND tenant_id = ?
""",
(ts, str(last_run_status), status, ts, str(job_id), str(tenant_id)),
)
def scheduled_job_reserve_next_run(self, *, job_id: str, tenant_id: str) -> None:
"""Advance next_run_at when a run is enqueued to avoid duplicate scheduler ticks."""
cur = self.scheduled_job_get(job_id=job_id, tenant_id=tenant_id)
if not cur:
return
ts = utc_now_iso()
if str(cur.schedule_kind or "") == "once":
with self._connect() as conn: # type: ignore[attr-defined]
conn.execute(
"""
UPDATE scheduled_job SET next_run_at = NULL, status = 'paused', updated_at = ?
WHERE id = ? AND tenant_id = ?
""",
(ts, str(job_id), str(tenant_id)),
)
return
next_run_at = compute_next_run_at(
schedule_kind=cur.schedule_kind,
schedule_expr=cur.schedule_expr,
timezone_name=cur.timezone,
from_dt=datetime.now(timezone.utc),
)
with self._connect() as conn: # type: ignore[attr-defined]
conn.execute(
"""
UPDATE scheduled_job SET next_run_at = ?, updated_at = ?
WHERE id = ? AND tenant_id = ?
""",
(next_run_at, ts, str(job_id), str(tenant_id)),
)
def scheduled_job_run_create(
self,
*,
job_id: str,
tenant_id: str,
scheduled_at: str | None = None,
status: str = "queued",
) -> ScheduledJobRun:
rid = str(uuid.uuid4())
ts = utc_now_iso()
sched = str(scheduled_at or ts)
with self._connect() as conn: # type: ignore[attr-defined]
conn.execute(
"""
INSERT INTO scheduled_job_run
(id, job_id, tenant_id, status, scheduled_at, started_at, finished_at,
session_id, oclaw_task_id, run_id, reply_text, delivery_status_json, error, created_at)
VALUES (?, ?, ?, ?, ?, NULL, NULL, NULL, NULL, NULL, '', '{}', '', ?)
""",
(rid, str(job_id), str(tenant_id), str(status), sched, ts),
)
got = self.scheduled_job_run_get(run_id=rid, tenant_id=tenant_id)
if not got:
raise RuntimeError("failed to create scheduled job run")
return got
def scheduled_job_run_get(self, *, run_id: str, tenant_id: str | None = None) -> ScheduledJobRun | None:
with self._connect() as conn: # type: ignore[attr-defined]
if tenant_id:
row = conn.execute(
"""
SELECT id, job_id, tenant_id, status, scheduled_at, started_at, finished_at,
session_id, oclaw_task_id, run_id, reply_text, delivery_status_json, error, created_at
FROM scheduled_job_run WHERE id = ? AND tenant_id = ?
""",
(str(run_id), str(tenant_id)),
).fetchone()
else:
row = conn.execute(
"""
SELECT id, job_id, tenant_id, status, scheduled_at, started_at, finished_at,
session_id, oclaw_task_id, run_id, reply_text, delivery_status_json, error, created_at
FROM scheduled_job_run WHERE id = ?
""",
(str(run_id),),
).fetchone()
if not row:
return None
return _row_to_run(row)
def scheduled_job_run_list(
self,
*,
job_id: str,
tenant_id: str,
limit: int = 50,
offset: int = 0,
) -> list[ScheduledJobRun]:
lim = max(1, min(int(limit), 200))
off = max(0, int(offset))
with self._connect() as conn: # type: ignore[attr-defined]
rows = conn.execute(
"""
SELECT id, job_id, tenant_id, status, scheduled_at, started_at, finished_at,
session_id, oclaw_task_id, run_id, reply_text, delivery_status_json, error, created_at
FROM scheduled_job_run
WHERE job_id = ? AND tenant_id = ?
ORDER BY created_at DESC
LIMIT ? OFFSET ?
""",
(str(job_id), str(tenant_id), lim, off),
).fetchall()
return [_row_to_run(r) for r in rows]
def scheduled_job_run_update(
self,
*,
run_id: str,
tenant_id: str,
patch: dict[str, Any],
) -> ScheduledJobRun | None:
cur = self.scheduled_job_run_get(run_id=run_id, tenant_id=tenant_id)
if not cur:
return None
p = dict(patch or {})
status = str(p.get("status") if "status" in p else cur.status)
started_at = p.get("started_at") if "started_at" in p else cur.started_at
finished_at = p.get("finished_at") if "finished_at" in p else cur.finished_at
session_id = p.get("session_id") if "session_id" in p else cur.session_id
oclaw_task_id = p.get("oclaw_task_id") if "oclaw_task_id" in p else cur.oclaw_task_id
agent_run_id = p.get("run_id") if "run_id" in p else cur.run_id
reply_text = str(p.get("reply_text") if "reply_text" in p else cur.reply_text)
delivery_status_json = cur.delivery_status_json
if "delivery_status" in p and isinstance(p.get("delivery_status"), dict):
delivery_status_json = json.dumps(p["delivery_status"], ensure_ascii=False)
elif "delivery_status_json" in p:
delivery_status_json = str(p.get("delivery_status_json") or "{}")
error = str(p.get("error") if "error" in p else cur.error)
with self._connect() as conn: # type: ignore[attr-defined]
conn.execute(
"""
UPDATE scheduled_job_run SET
status = ?, started_at = ?, finished_at = ?, session_id = ?,
oclaw_task_id = ?, run_id = ?, reply_text = ?, delivery_status_json = ?, error = ?
WHERE id = ? AND tenant_id = ?
""",
(
status,
started_at,
finished_at,
session_id,
oclaw_task_id,
agent_run_id,
reply_text,
delivery_status_json,
error,
str(run_id),
str(tenant_id),
),
)
return self.scheduled_job_run_get(run_id=run_id, tenant_id=tenant_id)
def scheduled_job_to_dict(self, job: ScheduledJob) -> dict[str, Any]:
delivery: dict[str, Any] = {}
try:
raw = json.loads(job.delivery_json or "{}")
if isinstance(raw, dict):
delivery = raw
except Exception:
delivery = {}
return {
"id": job.id,
"tenant_id": job.tenant_id,
"name": job.name,
"description": job.description,
"status": job.status,
"schedule_kind": job.schedule_kind,
"schedule_expr": job.schedule_expr,
"timezone": job.timezone,
"prompt_text": job.prompt_text,
"interaction_mode": job.interaction_mode,
"specialist": job.specialist,
"lang": job.lang,
"delivery": delivery,
"source_session_id": job.source_session_id,
"created_by_user_id": job.created_by_user_id,
"source": job.source,
"next_run_at": job.next_run_at,
"last_run_at": job.last_run_at,
"last_run_status": job.last_run_status,
"created_at": job.created_at,
"updated_at": job.updated_at,
}
def scheduled_job_run_to_dict(self, run: ScheduledJobRun) -> dict[str, Any]:
delivery_status: dict[str, Any] = {}
try:
raw = json.loads(run.delivery_status_json or "{}")
if isinstance(raw, dict):
delivery_status = raw
except Exception:
delivery_status = {}
return {
"id": run.id,
"job_id": run.job_id,
"tenant_id": run.tenant_id,
"status": run.status,
"scheduled_at": run.scheduled_at,
"started_at": run.started_at,
"finished_at": run.finished_at,
"session_id": run.session_id,
"oclaw_task_id": run.oclaw_task_id,
"run_id": run.run_id,
"reply_text": run.reply_text,
"delivery_status": delivery_status,
"error": run.error,
"created_at": run.created_at,
}
__all__ = [
"ScheduledJob",
"ScheduledJobRun",
"ScheduledJobStoreMixin",
"SCHEDULED_JOB_DDL",
"SCHEDULED_JOB_RUN_DDL",
]

View file

@ -20,6 +20,7 @@ from sqlalchemy.exc import IntegrityError
from svc.persistence.pg_adapter import PgConnShim, connect_postgres
from svc.persistence.pg_compat import scrub_nul_bytes_from_jsonable, scrub_nul_bytes_from_text
from svc.persistence.scheduled_job_store import ScheduledJobStoreMixin
if sys.platform == "win32":
import ctypes
@ -297,7 +298,7 @@ def _decode_secret(secret_text: str) -> str:
raise _CryptoError("未知的密钥编码格式")
class SqliteStore:
class SqliteStore(ScheduledJobStoreMixin):
@staticmethod
def _cap_json_for_log(obj: Any, *, max_chars: int, keep_keys: tuple[str, ...] = ("ok", "error_code", "error")) -> Any:
cap = max(2000, int(max_chars or 0))
@ -677,6 +678,7 @@ class SqliteStore:
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_channel_outbound_pending ON channel_outbound_message(channel, account_id, status, created_at)"
)
self.ensure_scheduled_job_tables(conn)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS todo_item (
@ -1266,6 +1268,7 @@ class SqliteStore:
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_channel_outbound_pending ON channel_outbound_message(channel, account_id, status, created_at)"
)
self.ensure_scheduled_job_tables(conn)
self._seed_builtin_llm_profiles(conn)
self._seed_default_permissions(conn)
conn.execute(
@ -4713,6 +4716,186 @@ class SqliteStore:
for r in rows
]
@staticmethod
def _channel_context_token_key(
*,
tenant_id: str,
channel: str,
account_id: str,
external_chat_id: str,
) -> str:
return (
f"channel_ctx_token:{str(tenant_id or '').strip()}:"
f"{str(channel or '').strip().lower()}:"
f"{str(account_id or '').strip()}:"
f"{str(external_chat_id or '').strip()}"
)
def set_channel_context_token(
self,
*,
tenant_id: str,
channel: str,
account_id: str,
external_chat_id: str,
context_token: str,
) -> None:
tok = str(context_token or "").strip()
chat_id = str(external_chat_id or "").strip()
if not tok or not chat_id:
return
self.set_setting(
self._channel_context_token_key(
tenant_id=tenant_id,
channel=channel,
account_id=account_id,
external_chat_id=chat_id,
),
tok,
)
def get_channel_context_token(
self,
*,
tenant_id: str,
channel: str,
account_id: str,
external_chat_id: str,
) -> str:
chat_id = str(external_chat_id or "").strip()
if not chat_id:
return ""
return str(
self.get_setting(
self._channel_context_token_key(
tenant_id=tenant_id,
channel=channel,
account_id=account_id,
external_chat_id=chat_id,
)
)
or ""
).strip()
def get_channel_context_token_fuzzy(
self,
*,
tenant_id: str,
channel: str,
account_id: str,
external_chat_id: str,
) -> str:
chat_id = str(external_chat_id or "").strip()
if not chat_id:
return ""
channels = []
for ch in (channel, "wechat", "weixin"):
c = str(ch or "").strip().lower()
if c and c not in channels:
channels.append(c)
account_ids: list[str] = []
for aid in (account_id, ""):
a = str(aid or "").strip()
if a not in account_ids:
account_ids.append(a)
for ch in channels:
for aid in account_ids:
tok = self.get_channel_context_token(
tenant_id=tenant_id,
channel=ch,
account_id=aid,
external_chat_id=chat_id,
)
if tok:
return tok
return ""
def lookup_channel_session_by_chat_v2(
self,
*,
tenant_id: str,
channel: str,
external_chat_id: str,
) -> dict[str, Any] | None:
chat_id = str(external_chat_id or "").strip()
tid = str(tenant_id or "").strip()
if not chat_id or not tid:
return None
channels = []
for ch in (channel, "wechat", "weixin"):
c = str(ch or "").strip().lower()
if c and c not in channels:
channels.append(c)
with self._connect() as conn:
for ch in channels:
row = conn.execute(
"""
SELECT tenant_id, channel, account_id, external_chat_id, external_user_id, session_id
FROM channel_session_v2
WHERE tenant_id = ? AND channel = ? AND external_chat_id = ?
ORDER BY created_at DESC
LIMIT 1
""",
(tid, ch, chat_id),
).fetchone()
if row:
return {
"tenant_id": str(row["tenant_id"] or ""),
"channel": str(row["channel"] or ""),
"account_id": str(row["account_id"] or ""),
"external_chat_id": str(row["external_chat_id"] or ""),
"external_user_id": str(row["external_user_id"] or ""),
"session_id": str(row["session_id"] or ""),
}
return None
def lookup_channel_session_by_session_id(
self,
*,
tenant_id: str,
session_id: str,
) -> dict[str, Any] | None:
sid = str(session_id or "").strip()
tid = str(tenant_id or "").strip()
if not sid or not tid:
return None
with self._connect() as conn:
row = conn.execute(
"""
SELECT tenant_id, channel, account_id, external_chat_id, external_user_id
FROM channel_session_v2
WHERE tenant_id = ? AND session_id = ?
LIMIT 1
""",
(tid, sid),
).fetchone()
if row:
return {
"tenant_id": str(row["tenant_id"] or ""),
"channel": str(row["channel"] or ""),
"account_id": str(row["account_id"] or ""),
"external_chat_id": str(row["external_chat_id"] or ""),
"external_user_id": str(row["external_user_id"] or ""),
}
row = conn.execute(
"""
SELECT tenant_id, channel, external_chat_id, external_user_id
FROM channel_session
WHERE tenant_id = ? AND session_id = ?
LIMIT 1
""",
(tid, sid),
).fetchone()
if not row:
return None
return {
"tenant_id": str(row["tenant_id"] or ""),
"channel": str(row["channel"] or ""),
"account_id": "",
"external_chat_id": str(row["external_chat_id"] or ""),
"external_user_id": str(row["external_user_id"] or ""),
}
def get_or_create_channel_session(
self,
*,
@ -5446,18 +5629,68 @@ class SqliteStore:
).fetchall()
return [
{
"id": str(r[0] or ""),
"tenant_id": str(r[1] or ""),
"channel": str(r[2] or ""),
"account_id": str(r[3] or ""),
"chat_id": str(r[4] or ""),
"text": str(r[5] or ""),
"source": str(r[6] or ""),
"created_at": str(r[7] or ""),
"id": str(r["id"] or ""),
"tenant_id": str(r["tenant_id"] or ""),
"channel": str(r["channel"] or ""),
"account_id": str(r["account_id"] or ""),
"chat_id": str(r["chat_id"] or ""),
"text": str(r["text"] or ""),
"source": str(r["source"] or ""),
"created_at": str(r["created_at"] or ""),
}
for r in rows
]
def list_pending_weixin_outbound_messages(
self,
*,
account_id: str,
limit: int = 20,
) -> list[dict[str, Any]]:
lim = max(1, min(int(limit), 100))
aid = str(account_id or "").strip()
with self._connect() as conn:
rows = conn.execute(
"""
SELECT id, tenant_id, channel, account_id, chat_id, text, source, created_at
FROM channel_outbound_message
WHERE channel IN ('wechat', 'weixin') AND status = 'pending'
AND (
account_id = ?
OR account_id = '' OR account_id = 'weixin-default'
OR ? = '' OR ? = 'weixin-default'
)
ORDER BY created_at ASC
LIMIT ?
""",
(aid, aid, aid, lim),
).fetchall()
out: list[dict[str, Any]] = []
for r in rows:
source = str(r["source"] or "")
meta = {}
if source.startswith("{"):
try:
parsed = json.loads(source)
if isinstance(parsed, dict):
meta = parsed
except Exception:
meta = {}
out.append(
{
"id": str(r["id"] or ""),
"tenant_id": str(r["tenant_id"] or ""),
"channel": str(r["channel"] or ""),
"account_id": str(r["account_id"] or ""),
"chat_id": str(r["chat_id"] or ""),
"text": str(r["text"] or ""),
"source": source,
"context_token": str(meta.get("context_token") or ""),
"created_at": str(r["created_at"] or ""),
}
)
return out
def ack_channel_outbound_message(
self,
*,

View file

@ -0,0 +1,114 @@
from __future__ import annotations
import hashlib
import os
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from fastapi.testclient import TestClient
from interfaces.http.fastapi_app import create_app
from svc.persistence.assistant_store import reset_assistant_store_singleton
from svc.persistence.sqlite_store import SqliteStore
class AdminScheduledJobsApiTests(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.db = Path(self._tmp.name) / "sched_admin.sqlite"
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
os.environ["OPS_ASSISTANT_PASSWORD"] = "test-admin-pass"
os.environ["AIA_ASSISTANT_DB_BACKEND"] = "sqlite"
reset_assistant_store_singleton()
self.store = SqliteStore(str(self.db))
t1 = self.store.create_tenant("TeamA")
self.tenant_id = str(t1["id"])
admin = self.store.create_user_account(
tenant_id=self.tenant_id,
username="administrator",
display_name="Admin",
role="owner",
password_hash=hashlib.sha256("test-admin-pass".encode("utf-8")).hexdigest(),
is_active=True,
)
self.user_id = str(admin["id"])
self.client = TestClient(create_app())
self.client.post("/admin/api/auth/bootstrap", json={})
def tearDown(self) -> None:
reset_assistant_store_singleton()
self._tmp.cleanup()
def _login(self) -> str:
resp = self.client.post(
"/admin/api/auth/login",
json={
"tenant_id": self.tenant_id,
"username": "administrator",
"password": "test-admin-pass",
"purpose": "console",
},
)
data = resp.json()
self.assertTrue(data.get("ok"), data)
return str(data.get("token") or "")
def test_scheduled_jobs_crud(self) -> None:
token = self._login()
headers = {"authorization": f"Bearer {token}"}
once_at = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat()
create = self.client.post(
"/admin/api/scheduled-jobs",
headers=headers,
json={
"name": "Test job",
"prompt_text": "Say hello",
"schedule_kind": "once",
"schedule_expr": once_at,
"specialist": "generalist",
},
)
self.assertEqual(create.status_code, 200, create.text)
body = create.json()
self.assertTrue(body.get("ok"), body)
job_id = str((body.get("job") or {}).get("id") or "")
self.assertTrue(job_id)
listed = self.client.get("/admin/api/scheduled-jobs", headers=headers)
self.assertTrue(listed.json().get("ok"))
self.assertGreaterEqual(len(listed.json().get("items") or []), 1)
pause = self.client.post(f"/admin/api/scheduled-jobs/{job_id}/pause", headers=headers, json={})
self.assertTrue(pause.json().get("ok"))
resume = self.client.post(f"/admin/api/scheduled-jobs/{job_id}/resume", headers=headers, json={})
self.assertTrue(resume.json().get("ok"))
runs = self.client.get(f"/admin/api/scheduled-jobs/{job_id}/runs", headers=headers)
self.assertTrue(runs.json().get("ok"))
patch = self.client.patch(
f"/admin/api/scheduled-jobs/{job_id}",
headers=headers,
json={
"name": "Updated job",
"schedule_kind": "interval",
"schedule_expr": "7200",
"prompt_text": "Stand up hourly",
"specialist": "generalist",
},
)
self.assertEqual(patch.status_code, 200, patch.text)
patched = patch.json()
self.assertTrue(patched.get("ok"), patched)
job = patched.get("job") or {}
self.assertEqual(str(job.get("name") or ""), "Updated job")
self.assertEqual(str(job.get("schedule_kind") or ""), "interval")
self.assertEqual(str(job.get("schedule_expr") or ""), "7200")
self.assertEqual(str(job.get("prompt_text") or ""), "Stand up hourly")
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,49 @@
from __future__ import annotations
import os
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from interfaces.gateway.http_adapter import dispatch_gateway_http_method
from svc.persistence.assistant_store import reset_assistant_store_singleton
from svc.persistence.sqlite_store import SqliteStore
class GatewayCronServiceTests(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.db = Path(self._tmp.name) / "cron.sqlite"
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
os.environ["AIA_ASSISTANT_DB_BACKEND"] = "sqlite"
reset_assistant_store_singleton()
self.store = SqliteStore(str(self.db))
t = self.store.create_tenant("default")
self.tenant_id = str(t["id"])
def tearDown(self) -> None:
reset_assistant_store_singleton()
self._tmp.cleanup()
def test_cron_add_and_list(self) -> None:
once_at = (datetime.now(timezone.utc) + timedelta(hours=2)).isoformat()
add = dispatch_gateway_http_method(
"cron.add",
{
"name": "gw job",
"schedule": once_at,
"schedule_kind": "once",
"tenant_id": self.tenant_id,
"prompt": "hello",
},
)
self.assertTrue(add.get("ok"), add)
lst = dispatch_gateway_http_method("cron.list", {"tenant_id": self.tenant_id})
self.assertTrue(lst.get("ok"), lst)
payload = lst.get("payload") or {}
self.assertGreaterEqual(len(payload.get("items") or []), 1)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,20 @@
from __future__ import annotations
import unittest
from runtime.application.gateway.inbound_service import _parse_schedule_duration_seconds
class ScheduleDurationParseTests(unittest.TestCase):
def test_minutes(self) -> None:
self.assertEqual(_parse_schedule_duration_seconds("5分钟"), 300)
def test_hours(self) -> None:
self.assertEqual(_parse_schedule_duration_seconds("1小时"), 3600)
def test_seconds(self) -> None:
self.assertEqual(_parse_schedule_duration_seconds("120秒"), 120)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,59 @@
from __future__ import annotations
import os
import tempfile
import unittest
from pathlib import Path
from runtime.tools.experts.productivity.schedule_tools import schedule_create_tool, schedule_list_tool
from svc.persistence.assistant_store import reset_assistant_store_singleton
from svc.persistence.sqlite_store import SqliteStore
class ScheduleToolsTests(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.db = Path(self._tmp.name) / "tools.sqlite"
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
os.environ["AIA_ASSISTANT_DB_BACKEND"] = "sqlite"
reset_assistant_store_singleton()
self.store = SqliteStore(str(self.db))
t = self.store.create_tenant("Team")
self.tenant_id = str(t["id"])
user = self.store.create_user_account(
tenant_id=self.tenant_id,
username="administrator",
display_name="Admin",
role="owner",
password_hash="x",
is_active=True,
)
self.user_id = str(user["id"])
def tearDown(self) -> None:
reset_assistant_store_singleton()
self._tmp.cleanup()
def test_schedule_create_and_list(self) -> None:
create = schedule_create_tool()
out = create.handler(
{
"tenant_id": self.tenant_id,
"owner_user_id": self.user_id,
"name": "Reminder",
"prompt_text": "Check inbox",
"schedule_kind": "interval",
"schedule_expr": "900",
"specialist": "ops",
"interaction_mode": "expert",
}
)
self.assertTrue(out.get("ok"), out)
lst = schedule_list_tool()
listed = lst.handler({"tenant_id": self.tenant_id})
self.assertTrue(listed.get("ok"))
self.assertEqual(len(listed.get("items") or []), 1)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,133 @@
from __future__ import annotations
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock
from runtime.scheduler.worker_turn import resolve_scheduled_outbound_text
from svc.persistence.sqlite_store import SqliteStore
class EphemeralUserContextTests(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.db = Path(self._tmp.name) / "ctx.sqlite"
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
self.store = SqliteStore(str(self.db))
sess = self.store.create_session("test")
self.session_id = str(sess.id)
def tearDown(self) -> None:
self._tmp.cleanup()
def test_build_model_context_injects_ephemeral_user_text(self) -> None:
from runtime.direct_loop import _build_model_context
from svc.llm.chat_models import RuleBasedChatModel
msgs = _build_model_context(
store=self.store,
session_id=self.session_id,
max_messages=20,
system_prompt="sys",
model=RuleBasedChatModel(),
lang="zh",
memory_context=None,
trace_id=None,
parent_span_id=None,
user_text="【定时主动提醒】站起来",
active_turn_uuid="turn-sched-1",
)
roles = [str(m.get("role") or "") for m in msgs]
self.assertIn("user", roles)
self.assertEqual(str(msgs[-1].get("content") or ""), "【定时主动提醒】站起来")
rows = self.store.get_messages(session_id=self.session_id, limit=10)
self.assertEqual(len(rows), 0)
class ScheduledOutboundFallbackTests(unittest.TestCase):
def test_uses_prompt_when_model_reply_empty(self) -> None:
text = resolve_scheduled_outbound_text(
payload={"prompt_text": "站起来活动一下"},
reply_text="",
)
self.assertIn("站起来活动一下", text)
self.assertTrue(text.startswith("⏰"))
def test_keeps_model_reply_when_present(self) -> None:
text = resolve_scheduled_outbound_text(
payload={"prompt_text": "站起来活动一下"},
reply_text="该起来啦!",
)
self.assertEqual(text, "该起来啦!")
class ScheduledAssistantPersistTests(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.db = Path(self._tmp.name) / "persist.sqlite"
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
self.store = SqliteStore(str(self.db))
sess = self.store.create_session("sched")
self.session_id = str(sess.id)
def tearDown(self) -> None:
self._tmp.cleanup()
def test_finalize_persists_assistant_when_missing(self) -> None:
from runtime.scheduler.worker_turn import finalize_scheduled_turn_success
task = MagicMock()
task.task_type = "scheduled_turn"
payload = {
"tenant_id": "t1",
"job_id": "",
"run_id_scheduled": "",
"session_id": self.session_id,
"prompt_text": "休息",
"delivery": {"weixin": {"enabled": False}},
"resolved_channel": "admin_chat",
}
with unittest.mock.patch(
"runtime.scheduler.channel_delivery.deliver_scheduled_reply",
return_value={"ok": True, "skipped": True},
):
finalize_scheduled_turn_success(
store=self.store,
task=task,
payload={
**payload,
"session_id": self.session_id,
},
base_result={"reply_text": "", "turn_uuid": "tu-1"},
)
rows = self.store.get_messages(session_id=self.session_id, limit=10)
self.assertEqual(len(rows), 1)
self.assertEqual(str(rows[0].role), "assistant")
self.assertEqual(str(getattr(rows[0], "event_type", "") or ""), "assistant_text")
self.assertIn("休息", str(rows[0].content or ""))
def test_updates_empty_assistant_for_same_turn(self) -> None:
from runtime.scheduler.worker_turn import _persist_scheduled_assistant_reply
self.store.add_message(
session_id=self.session_id,
role="assistant",
content="",
turn_uuid="tu-empty",
event_type="assistant_text",
)
_persist_scheduled_assistant_reply(
self.store,
session_id=self.session_id,
turn_uuid="tu-empty",
reply_text="⏰ 提醒:站起来",
)
rows = self.store.get_messages(session_id=self.session_id, limit=10)
self.assertEqual(len(rows), 1)
self.assertIn("站起来", str(rows[0].content or ""))
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,130 @@
from __future__ import annotations
import os
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from runtime.scheduler.expressions import compute_next_run_at, normalize_schedule_kind
from svc.persistence.sqlite_store import SqliteStore
class SchedulerExpressionsTests(unittest.TestCase):
def test_normalize_schedule_kind(self) -> None:
self.assertEqual(normalize_schedule_kind("CRON"), "cron")
self.assertEqual(normalize_schedule_kind("bad"), "cron")
def test_interval_next_run(self) -> None:
base = datetime(2026, 6, 26, 10, 0, 0, tzinfo=timezone.utc)
nxt = compute_next_run_at(
schedule_kind="interval",
schedule_expr="120",
timezone_name="Asia/Shanghai",
from_dt=base,
)
self.assertEqual(nxt, (base + timedelta(seconds=120)).isoformat())
def test_once_future(self) -> None:
future = datetime.now(timezone.utc) + timedelta(hours=2)
nxt = compute_next_run_at(
schedule_kind="once",
schedule_expr=future.isoformat(),
timezone_name="Asia/Shanghai",
)
self.assertIsNotNone(nxt)
class ScheduledJobStoreTests(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.db = Path(self._tmp.name) / "sched.sqlite"
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
self.store = SqliteStore(str(self.db))
t = self.store.create_tenant("Team")
self.tenant_id = str(t["id"])
user = self.store.create_user_account(
tenant_id=self.tenant_id,
username="administrator",
display_name="Admin",
role="owner",
password_hash="x",
is_active=True,
)
self.user_id = str(user["id"])
def tearDown(self) -> None:
self._tmp.cleanup()
def test_row_to_job_dict_row(self) -> None:
from svc.persistence.scheduled_job_store import _row_to_job
row = {
"id": "j1",
"tenant_id": self.tenant_id,
"name": "Test",
"description": "",
"status": "active",
"schedule_kind": "interval",
"schedule_expr": "300",
"timezone": "Asia/Shanghai",
"prompt_text": "ping",
"interaction_mode": "expert",
"specialist": "generalist",
"lang": "zh",
"delivery_json": "{}",
"source_session_id": None,
"created_by_user_id": self.user_id,
"source": "chat",
"next_run_at": "2026-06-26T10:00:00+00:00",
"last_run_at": None,
"last_run_status": "",
"created_at": "2026-06-26T09:00:00+00:00",
"updated_at": "2026-06-26T09:00:00+00:00",
}
job = _row_to_job(row)
self.assertEqual(job.id, "j1")
self.assertEqual(job.name, "Test")
job = self.store.scheduled_job_create(
tenant_id=self.tenant_id,
name="Daily report",
prompt_text="Summarize alarms",
schedule_kind="interval",
schedule_expr="3600",
created_by_user_id=self.user_id,
)
self.assertEqual(job.status, "active")
self.assertTrue(job.next_run_at)
rows = self.store.scheduled_job_list(tenant_id=self.tenant_id)
self.assertEqual(len(rows), 1)
ok = self.store.scheduled_job_set_status(
tenant_id=self.tenant_id,
job_id=job.id,
status="paused",
)
self.assertTrue(ok)
due = self.store.scheduled_job_list_due(limit=10)
self.assertEqual(due, [])
def test_run_record(self) -> None:
job = self.store.scheduled_job_create(
tenant_id=self.tenant_id,
name="Once",
prompt_text="Ping",
schedule_kind="once",
schedule_expr=(datetime.now(timezone.utc) + timedelta(minutes=5)).isoformat(),
created_by_user_id=self.user_id,
)
run = self.store.scheduled_job_run_create(job_id=job.id, tenant_id=self.tenant_id)
self.assertEqual(run.status, "queued")
updated = self.store.scheduled_job_run_update(
run_id=run.id,
tenant_id=self.tenant_id,
patch={"status": "success", "reply_text": "ok"},
)
self.assertIsNotNone(updated)
self.assertEqual(str(updated.reply_text), "ok")
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,180 @@
from __future__ import annotations
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
from runtime.scheduler.channel_delivery import deliver_scheduled_reply
from runtime.scheduler.cron_service import build_delivery_for_session
from runtime.scheduler.session_resolver import resolve_scheduled_session
from runtime.scheduler.turn_text import build_scheduled_turn_instruction
from svc.persistence.sqlite_store import SqliteStore
class ScheduledTurnTextTests(unittest.TestCase):
def test_instruction_is_internal_not_user_facing(self) -> None:
text = build_scheduled_turn_instruction(
prompt_text="站起来活动一下",
mode="scheduled",
lang="zh",
)
self.assertIn("提醒意图", text)
self.assertNotIn("[scheduled:", text)
class ScheduledSessionResolverTests(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.db = Path(self._tmp.name) / "sched.sqlite"
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
self.store = SqliteStore(str(self.db))
t = self.store.create_tenant("Team")
self.tenant_id = str(t["id"])
admin = self.store.create_user_account(
tenant_id=self.tenant_id,
username="administrator",
display_name="Admin",
role="owner",
password_hash="x",
is_active=True,
)
self.admin_id = str(admin["id"])
sess = self.store.create_session("WeChat session")
self.session_id = str(sess.id)
self.store.ensure_ui_session_owner(
session_id=self.session_id,
tenant_id=self.tenant_id,
user_id=self.admin_id,
)
with self.store._connect() as conn:
conn.execute(
"""
INSERT INTO channel_session_v2
(tenant_id, channel, account_id, external_chat_id, external_user_id, session_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
""",
(self.tenant_id, "weixin", "weixin-default", "wx-user-123", "wx-user-123", self.session_id),
)
def tearDown(self) -> None:
self._tmp.cleanup()
def test_source_session_preserves_weixin_delivery_target(self) -> None:
job = MagicMock()
job.tenant_id = self.tenant_id
job.delivery_json = '{"weixin":{"enabled":true}}'
job.source_session_id = self.session_id
job.name = "rest"
resolved = resolve_scheduled_session(store=self.store, job=job, created_by_user_id=self.admin_id)
self.assertEqual(resolved.channel, "weixin")
self.assertEqual(resolved.external_chat_id, "wx-user-123")
class ScheduledDeliveryChannelTests(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.db = Path(self._tmp.name) / "sched.sqlite"
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
self.store = SqliteStore(str(self.db))
t = self.store.create_tenant("Team")
self.tenant_id = str(t["id"])
def tearDown(self) -> None:
self._tmp.cleanup()
def test_whatsapp_session_disables_weixin_delivery(self) -> None:
sess = self.store.create_session("WA session")
session_id = str(sess.id)
with self.store._connect() as conn:
conn.execute(
"""
INSERT INTO channel_session_v2
(tenant_id, channel, account_id, external_chat_id, external_user_id, session_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
""",
(self.tenant_id, "whatsapp", "wa-default", "15551234567@s.whatsapp.net", "15551234567", session_id),
)
delivery = build_delivery_for_session(
self.store,
tenant_id=self.tenant_id,
session_id=session_id,
)
self.assertTrue(delivery["whatsapp"]["enabled"])
self.assertEqual(delivery["whatsapp"]["chat_id"], "15551234567@s.whatsapp.net")
self.assertFalse(delivery["weixin"]["enabled"])
def test_weixin_session_disables_whatsapp_delivery(self) -> None:
sess = self.store.create_session("WX session")
session_id = str(sess.id)
with self.store._connect() as conn:
conn.execute(
"""
INSERT INTO channel_session_v2
(tenant_id, channel, account_id, external_chat_id, external_user_id, session_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
""",
(self.tenant_id, "weixin", "weixin-default", "wx-user-9", "wx-user-9", session_id),
)
delivery = build_delivery_for_session(
self.store,
tenant_id=self.tenant_id,
session_id=session_id,
)
self.assertTrue(delivery["weixin"]["enabled"])
self.assertFalse(delivery["whatsapp"]["enabled"])
class ScheduledDeliveryTests(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.db = Path(self._tmp.name) / "sched.sqlite"
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
self.store = SqliteStore(str(self.db))
t = self.store.create_tenant("Team")
self.tenant_id = str(t["id"])
def tearDown(self) -> None:
self._tmp.cleanup()
@patch("runtime.scheduler.channel_delivery.enqueue_weixin_reply")
def test_deliver_uses_stored_context_token(self, mock_enqueue: MagicMock) -> None:
mock_enqueue.return_value = {"ok": True, "channel": "wechat", "queued": True}
self.store.set_channel_context_token(
tenant_id=self.tenant_id,
channel="wechat",
account_id="real-acct",
external_chat_id="wx-user-123",
context_token="ctx-abc",
)
sess = self.store.create_session("wx-deliver")
session_id = str(sess.id)
with self.store._connect() as conn:
conn.execute(
"""
INSERT INTO channel_session_v2
(tenant_id, channel, account_id, external_chat_id, external_user_id, session_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
""",
(self.tenant_id, "wechat", "real-acct", "wx-user-123", "wx-user-123", session_id),
)
out = deliver_scheduled_reply(
self.store,
tenant_id=self.tenant_id,
reply_text="该起来活动啦",
delivery_json='{"weixin":{"enabled":true}}',
resolved_channel="wechat",
resolved_chat_id="wx-user-123",
resolved_account_id="weixin-default",
session_id=session_id,
)
self.assertTrue(out.get("ok"))
mock_enqueue.assert_called_once()
kwargs = mock_enqueue.call_args.kwargs
self.assertEqual(kwargs.get("context_token"), "ctx-abc")
self.assertEqual(kwargs.get("chat_id"), "wx-user-123")
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,116 @@
from __future__ import annotations
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock
from runtime.scheduler.session_resolver import resolve_scheduled_viewer_username
from runtime.scheduler.service import enqueue_scheduled_job_run
from svc.persistence.sqlite_store import SqliteStore
class ResolveScheduledViewerUsernameTests(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.db = Path(self._tmp.name) / "sched.sqlite"
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
self.store = SqliteStore(str(self.db))
t = self.store.create_tenant("Team")
self.tenant_id = str(t["id"])
self.admin = self.store.create_user_account(
tenant_id=self.tenant_id,
username="administrator",
display_name="Admin",
role="owner",
password_hash="x",
is_active=True,
)
self.member = self.store.create_user_account(
tenant_id=self.tenant_id,
username="alice",
display_name="Alice",
role="member",
password_hash="x",
is_active=True,
)
def tearDown(self) -> None:
self._tmp.cleanup()
def test_weixin_uses_administrator_pool(self) -> None:
uname = resolve_scheduled_viewer_username(
self.store,
tenant_id=self.tenant_id,
user_id=str(self.member["id"]),
channel="weixin",
)
self.assertEqual(uname, "administrator")
def test_admin_chat_uses_job_owner_username(self) -> None:
uname = resolve_scheduled_viewer_username(
self.store,
tenant_id=self.tenant_id,
user_id=str(self.member["id"]),
channel="admin_chat",
)
self.assertEqual(uname, "alice")
def test_enqueue_payload_includes_viewer_username_for_weixin(self) -> None:
job = MagicMock()
job.tenant_id = self.tenant_id
job.id = "job-weixin"
job.next_run_at = "2026-06-26T10:00:00+00:00"
job.delivery_json = '{"weixin":{"enabled":true}}'
job.prompt_text = "stretch"
job.lang = "zh"
job.interaction_mode = "expert"
job.specialist = "generalist"
job.created_by_user_id = str(self.member["id"])
job.schedule_kind = "interval"
run = MagicMock()
run.id = "run-1"
self.store.scheduled_job_run_create = MagicMock(return_value=run) # type: ignore[method-assign]
self.store.scheduled_job_run_update = MagicMock() # type: ignore[method-assign]
self.store.scheduled_job_reserve_next_run = MagicMock() # type: ignore[method-assign]
captured: dict[str, object] = {}
def _capture_create(**kwargs: object) -> MagicMock:
captured.update(kwargs)
return MagicMock(id="task-1")
self.store.oclaw_task_create = _capture_create # type: ignore[method-assign]
from runtime.scheduler import service as sched_service
from runtime.scheduler.session_resolver import ResolvedSession
resolved = ResolvedSession(
session_id="sess-1",
tenant_id=self.tenant_id,
user_id=str(self.admin["id"]),
channel="weixin",
account_id="weixin-default",
external_chat_id="wx-user-1",
external_user_id="wx-user-1",
is_group=False,
)
original_resolve = sched_service.resolve_scheduled_session
original_worker = sched_service.ensure_worker_started
try:
sched_service.resolve_scheduled_session = MagicMock(return_value=resolved)
sched_service.ensure_worker_started = MagicMock(return_value="worker-1")
enqueue_scheduled_job_run(self.store, job=job, mode="scheduled")
finally:
sched_service.resolve_scheduled_session = original_resolve
sched_service.ensure_worker_started = original_worker
payload = captured.get("payload")
self.assertIsInstance(payload, dict)
assert isinstance(payload, dict)
self.assertEqual(payload.get("viewer_username"), "administrator")
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,93 @@
from __future__ import annotations
import os
import tempfile
import unittest
from pathlib import Path
from runtime.tools.context_inject import enrich_tool_arguments
from runtime.tools.experts.productivity.schedule_tools import schedule_create_tool
from svc.persistence.assistant_store import reset_assistant_store_singleton
from svc.persistence.sqlite_store import SqliteStore
class ToolContextInjectTests(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.db = Path(self._tmp.name) / "ctx.sqlite"
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
os.environ["AIA_ASSISTANT_DB_BACKEND"] = "sqlite"
reset_assistant_store_singleton()
self.store = SqliteStore(str(self.db))
t = self.store.create_tenant("Team")
self.tenant_id = str(t["id"])
user = self.store.create_user_account(
tenant_id=self.tenant_id,
username="administrator",
display_name="Admin",
role="owner",
password_hash="x",
is_active=True,
)
self.user_id = str(user["id"])
sess = self.store.create_session_for_user(
title="wx",
tenant_id=self.tenant_id,
user_id=self.user_id,
)
self.session_id = str(sess.id)
self.store.ensure_ui_session_owner(
session_id=self.session_id,
tenant_id=self.tenant_id,
user_id=self.user_id,
)
def tearDown(self) -> None:
reset_assistant_store_singleton()
self._tmp.cleanup()
def test_enrich_from_session_owner(self) -> None:
out = enrich_tool_arguments(
store=self.store,
session_id=self.session_id,
tool_name="schedule_create",
arguments={"name": "x"},
)
self.assertEqual(out.get("tenant_id"), self.tenant_id)
self.assertEqual(out.get("owner_user_id"), self.user_id)
self.assertEqual(out.get("session_id"), self.session_id)
def test_schedule_create_strips_llm_user_id(self) -> None:
tool = schedule_create_tool()
res = tool.handler(
{
"session_id": self.session_id,
"user_id": "wx_fake_external_id",
"name": "Rest",
"prompt_text": "提醒休息",
"schedule_kind": "interval",
"schedule_expr": "300",
}
)
self.assertTrue(res.get("ok"), res)
def test_tool_runtime_filters_user_id_for_schedule_schema(self) -> None:
from runtime.tools.tool_validation import filter_arguments_to_schema
tool = schedule_create_tool()
filtered = filter_arguments_to_schema(
tool.parameters,
{
"user_id": "bad",
"name": "x",
"prompt_text": "y",
"schedule_kind": "interval",
"schedule_expr": "60",
},
)
self.assertNotIn("user_id", filtered)
self.assertIn("name", filtered)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,77 @@
from __future__ import annotations
import os
import tempfile
import unittest
from pathlib import Path
from runtime.chat.tool_runtime import ToolExecutionContext, ToolExecutor
from runtime.tools.experts.productivity.schedule_tools import schedule_create_tool
from runtime.tools.base import ToolRegistry
from svc.llm.transports.base import LLMToolCall
from svc.persistence.assistant_store import reset_assistant_store_singleton
from svc.persistence.sqlite_store import SqliteStore
class ToolRuntimeScheduleValidationTests(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.db = Path(self._tmp.name) / "rt.sqlite"
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
os.environ["AIA_ASSISTANT_DB_BACKEND"] = "sqlite"
reset_assistant_store_singleton()
self.store = SqliteStore(str(self.db))
t = self.store.create_tenant("Team")
self.tenant_id = str(t["id"])
user = self.store.create_user_account(
tenant_id=self.tenant_id,
username="administrator",
display_name="Admin",
role="owner",
password_hash="x",
is_active=True,
)
self.user_id = str(user["id"])
sess = self.store.create_session_for_user(
title="wx",
tenant_id=self.tenant_id,
user_id=self.user_id,
)
self.session_id = str(sess.id)
self.store.ensure_ui_session_owner(
session_id=self.session_id,
tenant_id=self.tenant_id,
user_id=self.user_id,
)
def tearDown(self) -> None:
reset_assistant_store_singleton()
self._tmp.cleanup()
def test_execute_schedule_create_with_hallucinated_user_id(self) -> None:
reg = ToolRegistry([schedule_create_tool()])
ex = ToolExecutor()
ctx = ToolExecutionContext(
store=self.store,
tools=reg,
session_id=self.session_id,
path_policy_tenant_id=self.tenant_id,
path_policy_user_id=self.user_id,
)
tc = LLMToolCall(
id="call1",
name="schedule_create",
arguments={
"user_id": "wechat_external_123",
"name": "Break",
"prompt_text": "Stand up",
"schedule_kind": "interval",
"schedule_expr": "300",
},
)
result, _dur = ex._execute_tool(ctx, tc)
self.assertTrue(result.get("ok"), result)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,148 @@
from __future__ import annotations
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
from runtime.scheduler.channel_delivery import enqueue_weixin_reply
from runtime.scheduler.weixin_delivery import resolve_weixin_delivery_target
from svc.persistence.sqlite_store import SqliteStore
class WeixinBridgePollTests(unittest.TestCase):
def test_poll_accepts_wechat_channel_alias(self) -> None:
from interfaces.http.weixin_ilink_api import _BRIDGE
_BRIDGE._events.clear()
_BRIDGE._seq = 0
_BRIDGE.enqueue_reply(
token="",
channel="weixin",
account_id="acct-1",
chat_id="user-1",
text="hello",
context_token="ctx-1",
)
msgs, _ = _BRIDGE.poll(token="sidecar-token", cursor=0, channel="wechat", account_id="acct-1")
self.assertEqual(len(msgs), 1)
self.assertEqual(str(msgs[0].get("text") or ""), "hello")
def test_poll_accepts_legacy_default_account_id(self) -> None:
from interfaces.http.weixin_ilink_api import _BRIDGE
_BRIDGE._events.clear()
_BRIDGE._seq = 0
_BRIDGE.enqueue_reply(
token="",
channel="wechat",
account_id="weixin-default",
chat_id="user-1",
text="hello",
context_token="ctx-1",
)
msgs, _ = _BRIDGE.poll(token="sidecar-token", cursor=0, channel="wechat", account_id="real-acct")
self.assertEqual(len(msgs), 1)
class WeixinDeliveryTargetTests(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.db = Path(self._tmp.name) / "wx.sqlite"
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
self.store = SqliteStore(str(self.db))
t = self.store.create_tenant("Team")
self.tenant_id = str(t["id"])
sess = self.store.create_session("wx")
self.session_id = str(sess.id)
with self.store._connect() as conn:
conn.execute(
"""
INSERT INTO channel_session_v2
(tenant_id, channel, account_id, external_chat_id, external_user_id, session_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
""",
(self.tenant_id, "wechat", "real-acct-9", "wx-user-9", "wx-user-9", self.session_id),
)
self.store.set_channel_context_token(
tenant_id=self.tenant_id,
channel="wechat",
account_id="real-acct-9",
external_chat_id="wx-user-9",
context_token="ctx-xyz",
)
def tearDown(self) -> None:
self._tmp.cleanup()
def test_resolve_account_and_token_from_session(self) -> None:
target = resolve_weixin_delivery_target(
self.store,
tenant_id=self.tenant_id,
session_id=self.session_id,
delivery={"weixin": {"enabled": True}},
resolved_channel="wechat",
resolved_chat_id="",
resolved_account_id="weixin-default",
)
self.assertEqual(target["account_id"], "real-acct-9")
self.assertEqual(target["chat_id"], "wx-user-9")
self.assertEqual(target["context_token"], "ctx-xyz")
class EnqueueWeixinReplyTests(unittest.TestCase):
@patch("interfaces.http.weixin_ilink_api.enqueue_weixin_outbound_reply", return_value="7")
def test_requires_context_token(self, _mock_enqueue: MagicMock) -> None:
out = enqueue_weixin_reply(
channel="wechat",
account_id="acct",
chat_id="user",
text="hi",
context_token="",
)
self.assertFalse(out.get("ok"))
self.assertEqual(out.get("error"), "context_token_missing")
_mock_enqueue.assert_not_called()
@patch("interfaces.http.weixin_ilink_api.enqueue_weixin_outbound_reply", return_value="8")
def test_queues_with_context_token(self, mock_enqueue: MagicMock) -> None:
out = enqueue_weixin_reply(
channel="wechat",
account_id="acct",
chat_id="user",
text="hi",
context_token="ctx-1",
)
self.assertTrue(out.get("ok"))
self.assertTrue(out.get("queued"))
mock_enqueue.assert_called_once()
@patch("interfaces.http.weixin_ilink_api.enqueue_weixin_outbound_reply", return_value="9")
def test_persists_durable_outbound_when_store_available(self, mock_enqueue: MagicMock) -> None:
tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
db = Path(tmp.name) / "wx.sqlite"
os.environ["OPS_ASSISTANT_DB_PATH"] = str(db)
store = SqliteStore(str(db))
try:
out = enqueue_weixin_reply(
channel="wechat",
account_id="acct",
chat_id="user",
text="hi",
context_token="ctx-1",
store=store,
tenant_id="t1",
)
self.assertTrue(out.get("ok"))
self.assertTrue(out.get("durable"))
pending = store.list_pending_weixin_outbound_messages(account_id="acct", limit=10)
self.assertEqual(len(pending), 1)
self.assertEqual(str(pending[0].get("context_token") or ""), "ctx-1")
mock_enqueue.assert_called_once()
finally:
tmp.cleanup()
if __name__ == "__main__":
unittest.main()