mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 00:40:45 +08:00
feat(scheduler): add scheduled jobs with channel-aware delivery
Introduce persisted cron/interval jobs, gateway scheduler loop, worker turns, and admin CRUD/edit UI. Route proactive reminders via the originating chat channel (WhatsApp vs WeChat), harden Weixin outbound with durable queue and PG-compatible polling, and fix chat UI to show each scheduled reminder separately. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
5336f8835f
commit
47813f6a94
45 changed files with 4956 additions and 43 deletions
80
assistant_migrations/versions/002_scheduled_jobs.py
Normal file
80
assistant_migrations/versions/002_scheduled_jobs.py
Normal 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"))
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
|||
3
runtime/scheduler/__init__.py
Normal file
3
runtime/scheduler/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from __future__ import annotations
|
||||
|
||||
__all__ = []
|
||||
252
runtime/scheduler/channel_delivery.py
Normal file
252
runtime/scheduler/channel_delivery.py
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from runtime.orchestration.group_ingest import is_nonsend_channel_reply_text, should_send_channel_reply_text
|
||||
from runtime.scheduler.session_resolver import parse_delivery_json
|
||||
|
||||
|
||||
def _encode_weixin_outbound_source(*, context_token: str) -> str:
|
||||
return json.dumps(
|
||||
{"kind": "scheduled_job", "context_token": str(context_token or "").strip()},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
def _decode_weixin_outbound_source(raw: str) -> dict[str, Any]:
|
||||
text = str(raw or "").strip()
|
||||
if not text:
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(text)
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def enqueue_weixin_reply(
|
||||
*,
|
||||
channel: str,
|
||||
account_id: str,
|
||||
chat_id: str,
|
||||
text: str,
|
||||
context_token: str = "",
|
||||
store: Any = None,
|
||||
tenant_id: str = "",
|
||||
) -> dict[str, Any]:
|
||||
from runtime.scheduler.weixin_delivery import normalize_weixin_channel
|
||||
|
||||
ctx_tok = str(context_token or "").strip()
|
||||
if not ctx_tok:
|
||||
return {
|
||||
"ok": False,
|
||||
"channel": normalize_weixin_channel(channel),
|
||||
"error": "context_token_missing",
|
||||
"hint": "Send any message to the bot on WeChat first, then retry the scheduled job.",
|
||||
}
|
||||
durable_id = ""
|
||||
enqueuer = getattr(store, "enqueue_channel_outbound_message", None)
|
||||
if store is not None and callable(enqueuer):
|
||||
try:
|
||||
durable_id = str(
|
||||
enqueuer(
|
||||
channel=normalize_weixin_channel(channel),
|
||||
chat_id=str(chat_id or "").strip(),
|
||||
text=str(text or ""),
|
||||
tenant_id=str(tenant_id or ""),
|
||||
account_id=str(account_id or "").strip(),
|
||||
source=_encode_weixin_outbound_source(context_token=ctx_tok),
|
||||
)
|
||||
or ""
|
||||
).strip()
|
||||
except Exception:
|
||||
durable_id = ""
|
||||
try:
|
||||
from interfaces.http.weixin_ilink_api import enqueue_weixin_outbound_reply
|
||||
except Exception as exc:
|
||||
if durable_id:
|
||||
return {
|
||||
"ok": True,
|
||||
"channel": normalize_weixin_channel(channel),
|
||||
"message_id": durable_id,
|
||||
"queued": True,
|
||||
"durable": True,
|
||||
"context_token_present": True,
|
||||
"bridge_error": f"{type(exc).__name__}: {exc}",
|
||||
}
|
||||
return {"ok": False, "channel": channel, "error": f"{type(exc).__name__}: {exc}"}
|
||||
try:
|
||||
bridge_seq = enqueue_weixin_outbound_reply(
|
||||
channel=normalize_weixin_channel(channel),
|
||||
account_id=str(account_id or "").strip(),
|
||||
chat_id=str(chat_id or "").strip(),
|
||||
text=text,
|
||||
context_token=ctx_tok,
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"channel": normalize_weixin_channel(channel),
|
||||
"message_id": durable_id or bridge_seq,
|
||||
"bridge_seq": bridge_seq,
|
||||
"queued": True,
|
||||
"durable": bool(durable_id),
|
||||
"context_token_present": True,
|
||||
"account_id": str(account_id or "").strip(),
|
||||
"chat_id": str(chat_id or "").strip(),
|
||||
}
|
||||
except Exception as exc:
|
||||
if durable_id:
|
||||
return {
|
||||
"ok": True,
|
||||
"channel": normalize_weixin_channel(channel),
|
||||
"message_id": durable_id,
|
||||
"queued": True,
|
||||
"durable": True,
|
||||
"context_token_present": True,
|
||||
"bridge_error": f"{type(exc).__name__}: {exc}",
|
||||
}
|
||||
return {"ok": False, "channel": channel, "error": f"{type(exc).__name__}: {exc}"}
|
||||
|
||||
|
||||
def persist_channel_context_token(
|
||||
store: Any,
|
||||
*,
|
||||
tenant_id: str,
|
||||
channel: str,
|
||||
account_id: str,
|
||||
external_chat_id: str,
|
||||
context_token: str,
|
||||
) -> None:
|
||||
from runtime.scheduler.weixin_delivery import normalize_weixin_channel
|
||||
|
||||
setter = getattr(store, "set_channel_context_token", None)
|
||||
if not callable(setter):
|
||||
return
|
||||
tok = str(context_token or "").strip()
|
||||
chat_id = str(external_chat_id or "").strip()
|
||||
if not tok or not chat_id:
|
||||
return
|
||||
tid = str(tenant_id or "")
|
||||
acct = str(account_id or "").strip()
|
||||
channels = []
|
||||
for ch in (channel, "wechat", "weixin"):
|
||||
c = normalize_weixin_channel(str(ch or "")) if str(ch or "").lower() in {"wechat", "weixin"} else str(ch or "").strip().lower()
|
||||
if c and c not in channels:
|
||||
channels.append(c)
|
||||
account_ids = [acct] if acct else [""]
|
||||
if acct:
|
||||
account_ids.append("")
|
||||
for ch in channels:
|
||||
for aid in account_ids:
|
||||
setter(
|
||||
tenant_id=tid,
|
||||
channel=ch,
|
||||
account_id=aid,
|
||||
external_chat_id=chat_id,
|
||||
context_token=tok,
|
||||
)
|
||||
|
||||
|
||||
def extract_context_token_from_inbound_metadata(metadata: dict[str, Any] | None) -> str:
|
||||
meta = metadata if isinstance(metadata, dict) else {}
|
||||
raw = meta.get("raw") if isinstance(meta.get("raw"), dict) else {}
|
||||
msg = raw.get("msg") if isinstance(raw.get("msg"), dict) else {}
|
||||
for candidate in (
|
||||
msg.get("context_token"),
|
||||
(raw.get("metadata") or {}).get("context_token") if isinstance(raw.get("metadata"), dict) else None,
|
||||
raw.get("context_token"),
|
||||
meta.get("context_token"),
|
||||
):
|
||||
tok = str(candidate or "").strip()
|
||||
if tok:
|
||||
return tok
|
||||
return ""
|
||||
|
||||
|
||||
def deliver_scheduled_reply(
|
||||
store: Any,
|
||||
*,
|
||||
tenant_id: str,
|
||||
reply_text: str,
|
||||
delivery_json: str,
|
||||
resolved_channel: str = "",
|
||||
resolved_chat_id: str = "",
|
||||
resolved_account_id: str = "",
|
||||
session_id: str = "",
|
||||
) -> dict[str, Any]:
|
||||
text = str(reply_text or "").strip()
|
||||
if not text or is_nonsend_channel_reply_text(text):
|
||||
return {"ok": False, "skipped": True, "reason": "empty_or_silent_reply"}
|
||||
|
||||
ch_lower = str(resolved_channel or "").strip().lower()
|
||||
if ch_lower in {"wechat", "weixin"}:
|
||||
from runtime.application.gateway.inbound_service import _user_facing_wechat_reply
|
||||
|
||||
text = _user_facing_wechat_reply(reply=text)
|
||||
|
||||
delivery = parse_delivery_json(delivery_json)
|
||||
results: dict[str, Any] = {}
|
||||
|
||||
wa = delivery.get("whatsapp") if isinstance(delivery.get("whatsapp"), dict) else {}
|
||||
wa_enabled = bool(wa.get("enabled")) and str(wa.get("target_type") or "none") != "none"
|
||||
chat_id = str(wa.get("chat_id") or resolved_chat_id or "").strip()
|
||||
account_id = str(
|
||||
wa.get("account_id") or resolved_account_id or os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default"
|
||||
).strip()
|
||||
if wa_enabled and chat_id and should_send_channel_reply_text(text):
|
||||
msg_id = store.enqueue_channel_outbound_message(
|
||||
channel="whatsapp",
|
||||
chat_id=chat_id,
|
||||
text=text,
|
||||
tenant_id=tenant_id,
|
||||
account_id=account_id,
|
||||
source="scheduled_job",
|
||||
)
|
||||
results["whatsapp"] = {"ok": True, "message_id": msg_id, "chat_id": chat_id}
|
||||
|
||||
wx = delivery.get("weixin") if isinstance(delivery.get("weixin"), dict) else {}
|
||||
wx_enabled = bool(wx.get("enabled", True))
|
||||
if wx_enabled and should_send_channel_reply_text(text):
|
||||
from runtime.scheduler.weixin_delivery import resolve_weixin_delivery_target
|
||||
|
||||
target = resolve_weixin_delivery_target(
|
||||
store,
|
||||
tenant_id=tenant_id,
|
||||
session_id=session_id,
|
||||
delivery=delivery,
|
||||
resolved_channel=resolved_channel,
|
||||
resolved_chat_id=resolved_chat_id,
|
||||
resolved_account_id=resolved_account_id,
|
||||
)
|
||||
wx_chat = str(target.get("chat_id") or "").strip()
|
||||
wx_account = str(target.get("account_id") or "").strip()
|
||||
wx_channel = str(target.get("channel") or "wechat")
|
||||
context_token = str(target.get("context_token") or "").strip()
|
||||
if wx_chat:
|
||||
results["weixin"] = enqueue_weixin_reply(
|
||||
channel=wx_channel,
|
||||
account_id=wx_account,
|
||||
chat_id=wx_chat,
|
||||
text=text,
|
||||
context_token=context_token,
|
||||
store=store,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
else:
|
||||
results["weixin"] = {"ok": False, "error": "weixin_chat_missing"}
|
||||
|
||||
if not results:
|
||||
return {"ok": True, "skipped": True, "reason": "no_delivery_targets"}
|
||||
|
||||
ok = all(bool((v or {}).get("ok")) for v in results.values() if isinstance(v, dict))
|
||||
return {"ok": ok, "channels": results}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"deliver_scheduled_reply",
|
||||
"enqueue_weixin_reply",
|
||||
"extract_context_token_from_inbound_metadata",
|
||||
"persist_channel_context_token",
|
||||
]
|
||||
201
runtime/scheduler/cron_service.py
Normal file
201
runtime/scheduler/cron_service.py
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from runtime.scheduler.expressions import compute_next_run_at, normalize_schedule_kind
|
||||
from runtime.scheduler.service import run_scheduled_job_now
|
||||
from runtime.scheduler.session_resolver import parse_delivery_json, resolve_weixin_binding
|
||||
|
||||
|
||||
class CronService:
|
||||
def __init__(self, *, store: Any) -> None:
|
||||
self.store = store
|
||||
|
||||
def status(self) -> dict[str, Any]:
|
||||
return {"running": True}
|
||||
|
||||
def wake(self, params: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"ok": True, **dict(params or {})}
|
||||
|
||||
def listPage(self, params: dict[str, Any]) -> dict[str, Any]:
|
||||
tenant_id = str((params or {}).get("tenantId") or (params or {}).get("tenant_id") or "default").strip()
|
||||
status = str((params or {}).get("enabled") or (params or {}).get("status") or "").strip() or None
|
||||
if status in {"true", "1"}:
|
||||
status = "active"
|
||||
elif status in {"false", "0"}:
|
||||
status = "paused"
|
||||
limit = int((params or {}).get("limit") or 50)
|
||||
offset = int((params or {}).get("offset") or 0)
|
||||
rows = self.store.scheduled_job_list(
|
||||
tenant_id=tenant_id,
|
||||
status=status,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
items = [self._job_to_gateway_item(self.store.scheduled_job_to_dict(r)) for r in rows]
|
||||
return {"items": items, "total": len(items)}
|
||||
|
||||
def add(self, params: dict[str, Any]) -> dict[str, Any]:
|
||||
p = dict(params or {})
|
||||
tenant_id = str(p.get("tenantId") or p.get("tenant_id") or "default").strip()
|
||||
name = str(p.get("name") or p.get("schedule") or "cron job").strip()
|
||||
schedule = str(p.get("schedule") or p.get("schedule_expr") or "").strip()
|
||||
schedule_kind = normalize_schedule_kind(p.get("schedule_kind") or p.get("scheduleKind") or "cron")
|
||||
prompt = str(p.get("prompt") or p.get("prompt_text") or p.get("text") or name).strip()
|
||||
job = self.store.scheduled_job_create(
|
||||
tenant_id=tenant_id,
|
||||
name=name,
|
||||
prompt_text=prompt,
|
||||
schedule_kind=schedule_kind,
|
||||
schedule_expr=schedule,
|
||||
timezone_name=str(p.get("timezone") or "Asia/Shanghai"),
|
||||
description=str(p.get("description") or ""),
|
||||
interaction_mode=str(p.get("interaction_mode") or "expert"),
|
||||
specialist=str(p.get("specialist") or "generalist"),
|
||||
lang=str(p.get("lang") or "zh"),
|
||||
delivery=p.get("delivery") if isinstance(p.get("delivery"), dict) else {},
|
||||
source="gateway",
|
||||
)
|
||||
return self._job_to_gateway_item(self.store.scheduled_job_to_dict(job))
|
||||
|
||||
def update(self, job_id: str, patch: dict[str, Any]) -> dict[str, Any]:
|
||||
p = dict(patch or {})
|
||||
tenant_id = str(p.pop("tenantId", None) or p.pop("tenant_id", None) or "default").strip()
|
||||
mapped: dict[str, Any] = {}
|
||||
for src, dst in (
|
||||
("name", "name"),
|
||||
("schedule", "schedule_expr"),
|
||||
("schedule_kind", "schedule_kind"),
|
||||
("prompt", "prompt_text"),
|
||||
("prompt_text", "prompt_text"),
|
||||
("timezone", "timezone"),
|
||||
("interaction_mode", "interaction_mode"),
|
||||
("specialist", "specialist"),
|
||||
("lang", "lang"),
|
||||
("delivery", "delivery"),
|
||||
("enabled", "status"),
|
||||
):
|
||||
if src in p:
|
||||
mapped[dst] = p[src]
|
||||
if "enabled" in mapped:
|
||||
mapped["status"] = "active" if bool(mapped.pop("enabled")) else "paused"
|
||||
job = self.store.scheduled_job_update(tenant_id=tenant_id, job_id=str(job_id), patch=mapped)
|
||||
if not job:
|
||||
return {"id": job_id, "ok": False}
|
||||
return self._job_to_gateway_item(self.store.scheduled_job_to_dict(job))
|
||||
|
||||
def remove(self, job_id: str) -> dict[str, Any]:
|
||||
rows = self.store.scheduled_job_list(tenant_id="default", limit=500)
|
||||
for row in rows:
|
||||
if str(row.id) == str(job_id):
|
||||
self.store.scheduled_job_delete(tenant_id=row.tenant_id, job_id=str(job_id))
|
||||
return {"removed": True, "id": job_id}
|
||||
self.store.scheduled_job_delete(tenant_id="default", job_id=str(job_id))
|
||||
return {"removed": True, "id": job_id}
|
||||
|
||||
def enqueueRun(self, job_id: str, mode: str = "force") -> dict[str, Any]:
|
||||
rows = self.store.scheduled_job_list(tenant_id="default", limit=500)
|
||||
tenant_id = "default"
|
||||
for row in rows:
|
||||
if str(row.id) == str(job_id):
|
||||
tenant_id = str(row.tenant_id)
|
||||
break
|
||||
out = run_scheduled_job_now(self.store, tenant_id=tenant_id, job_id=str(job_id))
|
||||
return {"ok": bool(out.get("ok")), "ran": bool(out.get("ok")), "jobId": job_id, "mode": mode, **out}
|
||||
|
||||
def listRuns(self, params: dict[str, Any]) -> dict[str, Any]:
|
||||
p = dict(params or {})
|
||||
job_id = str(p.get("jobId") or p.get("id") or "").strip()
|
||||
tenant_id = str(p.get("tenantId") or p.get("tenant_id") or "default").strip()
|
||||
limit = int(p.get("limit") or 50)
|
||||
if not job_id:
|
||||
return {"items": [], "total": 0}
|
||||
rows = self.store.scheduled_job_run_list(job_id=job_id, tenant_id=tenant_id, limit=limit)
|
||||
items = [self.store.scheduled_job_run_to_dict(r) for r in rows]
|
||||
return {"items": items, "total": len(items), "jobId": job_id}
|
||||
|
||||
def _job_to_gateway_item(self, row: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"id": row.get("id"),
|
||||
"name": row.get("name"),
|
||||
"schedule": row.get("schedule_expr"),
|
||||
"schedule_kind": row.get("schedule_kind"),
|
||||
"enabled": str(row.get("status") or "") == "active",
|
||||
"prompt": row.get("prompt_text"),
|
||||
"timezone": row.get("timezone"),
|
||||
"nextRunAt": row.get("next_run_at"),
|
||||
"lastRunAt": row.get("last_run_at"),
|
||||
"specialist": row.get("specialist"),
|
||||
"interaction_mode": row.get("interaction_mode"),
|
||||
"delivery": row.get("delivery"),
|
||||
}
|
||||
|
||||
|
||||
def build_default_delivery(*, store: Any, tenant_id: str, whatsapp_chat_id: str = "") -> dict[str, Any]:
|
||||
import os
|
||||
|
||||
delivery: dict[str, Any] = {
|
||||
"whatsapp": {
|
||||
"enabled": bool(str(whatsapp_chat_id or "").strip()),
|
||||
"target_type": "group" if str(whatsapp_chat_id or "").endswith("@g.us") else "direct",
|
||||
"chat_id": str(whatsapp_chat_id or ""),
|
||||
"account_id": str(os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default"),
|
||||
},
|
||||
"weixin": {"enabled": True, "fixed": True},
|
||||
}
|
||||
if not delivery["whatsapp"]["enabled"]:
|
||||
delivery["whatsapp"]["target_type"] = "none"
|
||||
binding = resolve_weixin_binding(store, tenant_id=tenant_id)
|
||||
if binding:
|
||||
ext = str(binding.get("external_user_id") or "")
|
||||
delivery["weixin"]["external_user_id"] = ext
|
||||
delivery["weixin"]["external_chat_id"] = str(binding.get("external_chat_id") or ext)
|
||||
delivery["weixin"]["account_id"] = str(binding.get("account_id") or "weixin-default")
|
||||
return delivery
|
||||
|
||||
|
||||
def build_delivery_for_session(
|
||||
store: Any,
|
||||
*,
|
||||
tenant_id: str,
|
||||
session_id: str = "",
|
||||
whatsapp_chat_id: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Pick delivery targets from the chat session that created the job (WhatsApp vs WeChat)."""
|
||||
import os
|
||||
|
||||
tid = str(tenant_id or "").strip()
|
||||
sid = str(session_id or "").strip()
|
||||
explicit_wa = str(whatsapp_chat_id or "").strip()
|
||||
lookup = getattr(store, "lookup_channel_session_by_session_id", None)
|
||||
if sid and callable(lookup):
|
||||
ctx = lookup(tenant_id=tid, session_id=sid)
|
||||
if isinstance(ctx, dict):
|
||||
ch = str(ctx.get("channel") or "").strip().lower()
|
||||
chat_id = str(ctx.get("external_chat_id") or "").strip()
|
||||
acct = str(ctx.get("account_id") or "").strip()
|
||||
if ch == "whatsapp" and chat_id:
|
||||
return {
|
||||
"whatsapp": {
|
||||
"enabled": True,
|
||||
"target_type": "group" if chat_id.endswith("@g.us") else "direct",
|
||||
"chat_id": chat_id,
|
||||
"account_id": acct or str(os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default"),
|
||||
},
|
||||
"weixin": {"enabled": False, "fixed": False},
|
||||
}
|
||||
if ch in {"weixin", "wechat"}:
|
||||
delivery = build_default_delivery(store=store, tenant_id=tid, whatsapp_chat_id="")
|
||||
wa = delivery.get("whatsapp") if isinstance(delivery.get("whatsapp"), dict) else {}
|
||||
delivery["whatsapp"] = {
|
||||
**wa,
|
||||
"enabled": False,
|
||||
"target_type": "none",
|
||||
"chat_id": "",
|
||||
}
|
||||
return delivery
|
||||
return build_default_delivery(store=store, tenant_id=tid, whatsapp_chat_id=explicit_wa)
|
||||
|
||||
|
||||
__all__ = ["CronService", "build_default_delivery", "build_delivery_for_session", "compute_next_run_at"]
|
||||
77
runtime/scheduler/expressions.py
Normal file
77
runtime/scheduler/expressions.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
try:
|
||||
from croniter import croniter
|
||||
except ImportError: # pragma: no cover - guarded in requirements
|
||||
croniter = None # type: ignore[assignment,misc]
|
||||
|
||||
|
||||
def normalize_schedule_kind(raw: Any) -> str:
|
||||
kind = str(raw or "").strip().lower()
|
||||
if kind in {"cron", "once", "interval"}:
|
||||
return kind
|
||||
return "cron"
|
||||
|
||||
|
||||
def _parse_iso_dt(value: str) -> datetime:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
raise ValueError("empty datetime")
|
||||
if text.endswith("Z"):
|
||||
text = text[:-1] + "+00:00"
|
||||
dt = datetime.fromisoformat(text)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def compute_next_run_at(
|
||||
*,
|
||||
schedule_kind: str,
|
||||
schedule_expr: str,
|
||||
timezone_name: str = "Asia/Shanghai",
|
||||
from_dt: datetime | None = None,
|
||||
) -> str | None:
|
||||
kind = normalize_schedule_kind(schedule_kind)
|
||||
expr = str(schedule_expr or "").strip()
|
||||
if not expr:
|
||||
return None
|
||||
base = from_dt or datetime.now(timezone.utc)
|
||||
if base.tzinfo is None:
|
||||
base = base.replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
base = base.astimezone(timezone.utc)
|
||||
|
||||
if kind == "once":
|
||||
target = _parse_iso_dt(expr)
|
||||
if target <= base:
|
||||
return None
|
||||
return target.isoformat()
|
||||
|
||||
if kind == "interval":
|
||||
try:
|
||||
seconds = max(1, int(expr))
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"invalid interval seconds: {expr}") from exc
|
||||
nxt = base + timedelta(seconds=seconds)
|
||||
return nxt.isoformat()
|
||||
|
||||
if croniter is None:
|
||||
raise RuntimeError("croniter is required for cron schedules")
|
||||
try:
|
||||
tz = ZoneInfo(str(timezone_name or "Asia/Shanghai"))
|
||||
except Exception:
|
||||
tz = ZoneInfo("Asia/Shanghai")
|
||||
local_base = base.astimezone(tz)
|
||||
itr = croniter(expr, local_base)
|
||||
nxt_local = itr.get_next(datetime)
|
||||
if nxt_local.tzinfo is None:
|
||||
nxt_local = nxt_local.replace(tzinfo=tz)
|
||||
return nxt_local.astimezone(timezone.utc).isoformat()
|
||||
|
||||
|
||||
__all__ = ["compute_next_run_at", "normalize_schedule_kind"]
|
||||
200
runtime/scheduler/service.py
Normal file
200
runtime/scheduler/service.py
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from runtime.scheduler.session_resolver import resolve_scheduled_session, resolve_scheduled_viewer_username
|
||||
from runtime.scheduler.turn_text import build_scheduled_turn_instruction
|
||||
from runtime.worker import ensure_worker_started
|
||||
|
||||
_LOCK = threading.Lock()
|
||||
_THREAD: threading.Thread | None = None
|
||||
_RUNNING = False
|
||||
|
||||
|
||||
def _tick_interval_seconds() -> float:
|
||||
import os
|
||||
|
||||
raw = str(os.getenv("AIA_SCHEDULER_TICK_SECONDS") or "30").strip()
|
||||
try:
|
||||
return max(5.0, min(float(raw), 3600.0))
|
||||
except Exception:
|
||||
return 30.0
|
||||
|
||||
|
||||
def enqueue_scheduled_job_run(
|
||||
store: Any,
|
||||
*,
|
||||
job: Any,
|
||||
mode: str = "scheduled",
|
||||
) -> dict[str, Any]:
|
||||
tenant_id = str(getattr(job, "tenant_id", "") or "")
|
||||
job_id = str(getattr(job, "id") or "")
|
||||
run = store.scheduled_job_run_create(
|
||||
job_id=job_id,
|
||||
tenant_id=tenant_id,
|
||||
scheduled_at=str(getattr(job, "next_run_at", "") or datetime.now(timezone.utc).isoformat()),
|
||||
status="queued",
|
||||
)
|
||||
try:
|
||||
resolved = resolve_scheduled_session(
|
||||
store,
|
||||
job=job,
|
||||
created_by_user_id=str(getattr(job, "created_by_user_id", "") or ""),
|
||||
)
|
||||
except Exception as exc:
|
||||
store.scheduled_job_run_update(
|
||||
run_id=run.id,
|
||||
tenant_id=tenant_id,
|
||||
patch={
|
||||
"status": "failed",
|
||||
"finished_at": datetime.now(timezone.utc).isoformat(),
|
||||
"error": str(exc),
|
||||
},
|
||||
)
|
||||
store.scheduled_job_mark_run(
|
||||
job_id=job_id,
|
||||
tenant_id=tenant_id,
|
||||
last_run_status="failed",
|
||||
pause_after=False,
|
||||
)
|
||||
return {"ok": False, "error": str(exc), "run_id": run.id}
|
||||
|
||||
delivery: dict[str, Any] = {}
|
||||
try:
|
||||
raw = json.loads(str(getattr(job, "delivery_json", "") or "{}"))
|
||||
if isinstance(raw, dict):
|
||||
delivery = raw
|
||||
except Exception:
|
||||
delivery = {}
|
||||
|
||||
trace_id = uuid.uuid4().hex
|
||||
agent_run_id = uuid.uuid4().hex
|
||||
prompt_text = str(getattr(job, "prompt_text", "") or "").strip()
|
||||
lang = str(getattr(job, "lang", "") or "zh")
|
||||
user_text = build_scheduled_turn_instruction(prompt_text=prompt_text, mode=mode, lang=lang)
|
||||
viewer_username = resolve_scheduled_viewer_username(
|
||||
store,
|
||||
tenant_id=tenant_id,
|
||||
user_id=resolved.user_id,
|
||||
channel=resolved.channel,
|
||||
)
|
||||
payload = {
|
||||
"trace_id": trace_id,
|
||||
"run_id": agent_run_id,
|
||||
"session_id": resolved.session_id,
|
||||
"tenant_id": tenant_id,
|
||||
"user_id": resolved.user_id,
|
||||
"viewer_username": viewer_username,
|
||||
"role": "member",
|
||||
"channel": resolved.channel if resolved.channel != "admin_chat" else "admin_chat",
|
||||
"lang": lang,
|
||||
"text": user_text,
|
||||
"prompt_text": prompt_text,
|
||||
"attachments": [],
|
||||
"metadata": {
|
||||
"scheduled_job_id": job_id,
|
||||
"scheduled_run_id": run.id,
|
||||
"interaction_mode": str(getattr(job, "interaction_mode", "") or "expert"),
|
||||
"selected_specialist": str(getattr(job, "specialist", "") or "generalist"),
|
||||
"scheduled_mode": mode,
|
||||
"scheduled_proactive": True,
|
||||
},
|
||||
"interaction_mode": str(getattr(job, "interaction_mode", "") or "expert"),
|
||||
"requested_specialist": str(getattr(job, "specialist", "") or "generalist"),
|
||||
"selected_specialist": str(getattr(job, "specialist", "") or "generalist"),
|
||||
"job_id": job_id,
|
||||
"run_id_scheduled": run.id,
|
||||
"delivery": delivery,
|
||||
"resolved_channel": resolved.channel,
|
||||
"resolved_chat_id": resolved.external_chat_id,
|
||||
"resolved_account_id": resolved.account_id,
|
||||
}
|
||||
worker_id = ensure_worker_started(store=store)
|
||||
task = store.oclaw_task_create(
|
||||
tenant_id=tenant_id,
|
||||
session_id=resolved.session_id,
|
||||
task_type="scheduled_turn",
|
||||
payload=payload,
|
||||
)
|
||||
store.scheduled_job_run_update(
|
||||
run_id=run.id,
|
||||
tenant_id=tenant_id,
|
||||
patch={
|
||||
"status": "running",
|
||||
"started_at": datetime.now(timezone.utc).isoformat(),
|
||||
"session_id": resolved.session_id,
|
||||
"oclaw_task_id": task.id,
|
||||
"run_id": agent_run_id,
|
||||
},
|
||||
)
|
||||
store.scheduled_job_reserve_next_run(job_id=job_id, tenant_id=tenant_id)
|
||||
pause_after = str(getattr(job, "schedule_kind", "") or "") == "once"
|
||||
return {
|
||||
"ok": True,
|
||||
"run_id": run.id,
|
||||
"task_id": task.id,
|
||||
"worker_id": worker_id,
|
||||
"pause_after": pause_after,
|
||||
}
|
||||
|
||||
|
||||
def scheduler_tick(store: Any) -> dict[str, Any]:
|
||||
due = store.scheduled_job_list_due(limit=20)
|
||||
triggered = 0
|
||||
errors: list[str] = []
|
||||
for job in due:
|
||||
try:
|
||||
out = enqueue_scheduled_job_run(store, job=job, mode="scheduled")
|
||||
if out.get("ok"):
|
||||
triggered += 1
|
||||
else:
|
||||
errors.append(str(out.get("error") or "enqueue_failed"))
|
||||
except Exception as exc:
|
||||
errors.append(f"{getattr(job, 'id', '')}: {type(exc).__name__}: {exc}")
|
||||
return {"ok": True, "due": len(due), "triggered": triggered, "errors": errors}
|
||||
|
||||
|
||||
def run_scheduled_job_now(store: Any, *, tenant_id: str, job_id: str) -> dict[str, Any]:
|
||||
job = store.scheduled_job_get(job_id=job_id, tenant_id=tenant_id)
|
||||
if not job:
|
||||
return {"ok": False, "error": "job_not_found"}
|
||||
if str(job.status or "") != "active":
|
||||
return {"ok": False, "error": "job_not_active"}
|
||||
return enqueue_scheduled_job_run(store, job=job, mode="manual")
|
||||
|
||||
|
||||
def _scheduler_loop(*, store: Any) -> None:
|
||||
global _RUNNING
|
||||
interval = _tick_interval_seconds()
|
||||
while _RUNNING:
|
||||
try:
|
||||
scheduler_tick(store)
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(interval)
|
||||
|
||||
|
||||
def ensure_scheduler_started(*, store: Any) -> str:
|
||||
global _THREAD, _RUNNING
|
||||
with _LOCK:
|
||||
if _THREAD and _THREAD.is_alive():
|
||||
return _THREAD.name
|
||||
_RUNNING = True
|
||||
tid = f"oclaw-scheduler-{uuid.uuid4().hex[:8]}"
|
||||
t = threading.Thread(target=_scheduler_loop, kwargs={"store": store}, name=tid, daemon=True)
|
||||
t.start()
|
||||
_THREAD = t
|
||||
return tid
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ensure_scheduler_started",
|
||||
"enqueue_scheduled_job_run",
|
||||
"run_scheduled_job_now",
|
||||
"scheduler_tick",
|
||||
]
|
||||
262
runtime/scheduler/session_resolver.py
Normal file
262
runtime/scheduler/session_resolver.py
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from runtime.orchestration.group_ingest import session_user_key
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolvedSession:
|
||||
session_id: str
|
||||
tenant_id: str
|
||||
user_id: str
|
||||
channel: str
|
||||
account_id: str
|
||||
external_chat_id: str
|
||||
external_user_id: str
|
||||
is_group: bool
|
||||
|
||||
|
||||
def _ensure_administrator_owner(store: Any, *, tenant_id: str) -> dict[str, Any] | None:
|
||||
user = store.get_user_by_username(tenant_id=tenant_id, username="administrator")
|
||||
if not user:
|
||||
try:
|
||||
from svc.config.passwords import load_expected_password
|
||||
except Exception:
|
||||
load_expected_password = None # type: ignore
|
||||
pwd = load_expected_password(store) if callable(load_expected_password) else None
|
||||
if not pwd:
|
||||
return None
|
||||
user = store.create_user_account(
|
||||
tenant_id=tenant_id,
|
||||
username="administrator",
|
||||
password_hash=hashlib.sha256(pwd.encode("utf-8")).hexdigest(),
|
||||
display_name="Administrator",
|
||||
role="owner",
|
||||
is_active=True,
|
||||
)
|
||||
user_id = str((user or {}).get("id") or "")
|
||||
if not user_id:
|
||||
return None
|
||||
return {
|
||||
"tenant_id": tenant_id,
|
||||
"user_id": user_id,
|
||||
"display_name": (user or {}).get("display_name") or "Administrator",
|
||||
"role": str((user or {}).get("role") or "owner"),
|
||||
}
|
||||
|
||||
|
||||
def resolve_weixin_binding(store: Any, *, tenant_id: str) -> dict[str, Any] | None:
|
||||
owner = _ensure_administrator_owner(store, tenant_id=tenant_id)
|
||||
if not owner:
|
||||
return None
|
||||
user_id = str(owner.get("user_id") or "")
|
||||
rows = store.list_channel_identities_v2(
|
||||
tenant_id=tenant_id,
|
||||
channel="weixin",
|
||||
user_id=user_id,
|
||||
limit=20,
|
||||
)
|
||||
if not rows:
|
||||
for ch in ("wechat", "weixin"):
|
||||
rows = store.list_channel_identities_v2(
|
||||
tenant_id=tenant_id,
|
||||
channel=ch,
|
||||
user_id=user_id,
|
||||
limit=20,
|
||||
)
|
||||
if rows:
|
||||
break
|
||||
if not rows:
|
||||
return None
|
||||
row = rows[0]
|
||||
account_id = str(row.get("account_id") or "weixin-default").strip() or "weixin-default"
|
||||
external_user_id = str(row.get("external_user_id") or "").strip()
|
||||
if not external_user_id:
|
||||
return None
|
||||
return {
|
||||
"tenant_id": tenant_id,
|
||||
"user_id": user_id,
|
||||
"channel": str(row.get("channel") or "weixin"),
|
||||
"account_id": account_id,
|
||||
"external_user_id": external_user_id,
|
||||
"external_chat_id": external_user_id,
|
||||
"is_group": False,
|
||||
}
|
||||
|
||||
|
||||
def parse_delivery_json(raw: str) -> dict[str, Any]:
|
||||
try:
|
||||
data = json.loads(raw or "{}")
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def resolve_scheduled_viewer_username(
|
||||
store: Any,
|
||||
*,
|
||||
tenant_id: str,
|
||||
user_id: str,
|
||||
channel: str,
|
||||
) -> str:
|
||||
"""Channel proactive jobs use the administrator model pool (same as gateway inbound)."""
|
||||
ch = str(channel or "").strip().lower()
|
||||
if ch in {"weixin", "wechat", "whatsapp"}:
|
||||
return "administrator"
|
||||
uid = str(user_id or "").strip()
|
||||
if uid:
|
||||
user = store.get_user_by_id(tenant_id=str(tenant_id or ""), user_id=uid)
|
||||
if isinstance(user, dict):
|
||||
uname = str(user.get("username") or "").strip()
|
||||
if uname:
|
||||
return uname
|
||||
return "administrator"
|
||||
|
||||
|
||||
def resolve_scheduled_session(
|
||||
store: Any,
|
||||
*,
|
||||
job: Any,
|
||||
created_by_user_id: str = "",
|
||||
) -> ResolvedSession:
|
||||
tenant_id = str(getattr(job, "tenant_id", "") or "")
|
||||
delivery = parse_delivery_json(str(getattr(job, "delivery_json", "") or "{}"))
|
||||
source_session_id = str(getattr(job, "source_session_id", "") or "").strip()
|
||||
job_name = str(getattr(job, "name", "") or "Scheduled task")
|
||||
|
||||
if source_session_id:
|
||||
sess = store.get_session_in_tenant(session_id=source_session_id, tenant_id=tenant_id)
|
||||
if sess:
|
||||
owner = store.get_ui_session_owner(session_id=source_session_id) or {}
|
||||
user_id = str(owner.get("user_id") or created_by_user_id or "").strip()
|
||||
if not user_id:
|
||||
admin = _ensure_administrator_owner(store, tenant_id=tenant_id)
|
||||
user_id = str((admin or {}).get("user_id") or "")
|
||||
channel_ctx = None
|
||||
lookup = getattr(store, "lookup_channel_session_by_session_id", None)
|
||||
if callable(lookup):
|
||||
channel_ctx = lookup(tenant_id=tenant_id, session_id=source_session_id)
|
||||
if isinstance(channel_ctx, dict) and str(channel_ctx.get("channel") or "").strip():
|
||||
ch = str(channel_ctx.get("channel") or "").strip().lower()
|
||||
account_id = str(channel_ctx.get("account_id") or "").strip()
|
||||
external_chat_id = str(channel_ctx.get("external_chat_id") or "").strip()
|
||||
external_user_id = str(channel_ctx.get("external_user_id") or "").strip()
|
||||
is_group = ch == "whatsapp" and external_chat_id.endswith("@g.us")
|
||||
return ResolvedSession(
|
||||
session_id=source_session_id,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
channel=ch,
|
||||
account_id=account_id or ("weixin-default" if ch in {"weixin", "wechat"} else ""),
|
||||
external_chat_id=external_chat_id,
|
||||
external_user_id=external_user_id,
|
||||
is_group=is_group,
|
||||
)
|
||||
return ResolvedSession(
|
||||
session_id=source_session_id,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
channel="admin_chat",
|
||||
account_id="",
|
||||
external_chat_id="",
|
||||
external_user_id="",
|
||||
is_group=False,
|
||||
)
|
||||
|
||||
wa = delivery.get("whatsapp") if isinstance(delivery.get("whatsapp"), dict) else {}
|
||||
wx = delivery.get("weixin") if isinstance(delivery.get("weixin"), dict) else {}
|
||||
wa_enabled = bool(wa.get("enabled")) and str(wa.get("target_type") or "none") != "none"
|
||||
wx_enabled = bool(wx.get("enabled", True))
|
||||
|
||||
if wa_enabled and str(wa.get("chat_id") or "").strip():
|
||||
chat_id = str(wa.get("chat_id") or "").strip()
|
||||
account_id = str(wa.get("account_id") or os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default").strip()
|
||||
target_type = str(wa.get("target_type") or "direct").strip().lower()
|
||||
is_group = target_type == "group" or chat_id.endswith("@g.us")
|
||||
external_user_id = session_user_key(is_group=is_group, external_user_id=chat_id.split("@", 1)[0])
|
||||
admin = _ensure_administrator_owner(store, tenant_id=tenant_id)
|
||||
user_id = str(created_by_user_id or (admin or {}).get("user_id") or "")
|
||||
session_id = store.get_or_create_channel_session_v2(
|
||||
tenant_id=tenant_id,
|
||||
channel="whatsapp",
|
||||
account_id=account_id,
|
||||
external_chat_id=chat_id,
|
||||
external_user_id=external_user_id,
|
||||
session_title=f"Scheduled · {job_name}",
|
||||
)
|
||||
if user_id:
|
||||
store.ensure_ui_session_owner(session_id=session_id, tenant_id=tenant_id, user_id=user_id)
|
||||
return ResolvedSession(
|
||||
session_id=session_id,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
channel="whatsapp",
|
||||
account_id=account_id,
|
||||
external_chat_id=chat_id,
|
||||
external_user_id=external_user_id,
|
||||
is_group=is_group,
|
||||
)
|
||||
|
||||
if wx_enabled:
|
||||
binding = resolve_weixin_binding(store, tenant_id=tenant_id)
|
||||
if not binding:
|
||||
raise RuntimeError("weixin_binding_missing")
|
||||
channel = str(binding.get("channel") or "weixin")
|
||||
account_id = str(binding.get("account_id") or "weixin-default")
|
||||
external_user_id = str(binding.get("external_user_id") or "")
|
||||
external_chat_id = str(binding.get("external_chat_id") or external_user_id)
|
||||
user_id = str(binding.get("user_id") or created_by_user_id or "")
|
||||
session_id = store.get_or_create_channel_session_v2(
|
||||
tenant_id=tenant_id,
|
||||
channel=channel,
|
||||
account_id=account_id,
|
||||
external_chat_id=external_chat_id,
|
||||
external_user_id=external_user_id,
|
||||
session_title=f"Scheduled · {job_name}",
|
||||
)
|
||||
if user_id:
|
||||
store.ensure_ui_session_owner(session_id=session_id, tenant_id=tenant_id, user_id=user_id)
|
||||
return ResolvedSession(
|
||||
session_id=session_id,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
channel=channel,
|
||||
account_id=account_id,
|
||||
external_chat_id=external_chat_id,
|
||||
external_user_id=external_user_id,
|
||||
is_group=False,
|
||||
)
|
||||
|
||||
admin = _ensure_administrator_owner(store, tenant_id=tenant_id)
|
||||
user_id = str(created_by_user_id or (admin or {}).get("user_id") or "")
|
||||
if not user_id:
|
||||
raise RuntimeError("scheduled_session_owner_missing")
|
||||
sess = store.create_session_for_user(
|
||||
title=f"Scheduled · {job_name}",
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
return ResolvedSession(
|
||||
session_id=str(sess.id),
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
channel="admin_chat",
|
||||
account_id="",
|
||||
external_chat_id="",
|
||||
external_user_id="",
|
||||
is_group=False,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ResolvedSession",
|
||||
"resolve_scheduled_session",
|
||||
"resolve_scheduled_viewer_username",
|
||||
"resolve_weixin_binding",
|
||||
"parse_delivery_json",
|
||||
]
|
||||
50
runtime/scheduler/turn_text.py
Normal file
50
runtime/scheduler/turn_text.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
from __future__ import annotations
|
||||
|
||||
|
||||
def format_scheduled_user_reminder(prompt_text: str) -> str:
|
||||
body = str(prompt_text or "").strip()
|
||||
if not body:
|
||||
return ""
|
||||
if body.startswith("⏰"):
|
||||
return body
|
||||
return f"⏰ 提醒:{body}"
|
||||
|
||||
|
||||
def build_scheduled_turn_instruction(*, prompt_text: str, mode: str, lang: str) -> str:
|
||||
"""Internal LLM instruction for proactive scheduled reminders (not user-facing)."""
|
||||
intent = str(prompt_text or "").strip()
|
||||
_ = str(mode or "scheduled").strip()
|
||||
is_en = str(lang or "").lower().startswith("en")
|
||||
if is_en:
|
||||
return (
|
||||
"[Scheduled proactive reminder — internal instruction, not a user message]\n"
|
||||
f"Reminder intent: {intent}\n"
|
||||
"Write a short, friendly proactive reminder TO the user (second person). "
|
||||
"Do not say you received a reminder or that you will remind someone; speak directly to the user."
|
||||
)
|
||||
return (
|
||||
"【定时主动提醒·内部指令,不是用户发言】\n"
|
||||
f"提醒意图:{intent}\n"
|
||||
"请生成一条简短、自然、第二人称的主动提醒消息直接对用户说。"
|
||||
"不要写「收到提醒」「好的我来提醒用户」等元对话;不要假装用户刚说了话。"
|
||||
)
|
||||
|
||||
|
||||
def scheduled_turn_system_suffix(*, lang: str) -> str:
|
||||
is_en = str(lang or "").lower().startswith("en")
|
||||
if is_en:
|
||||
return (
|
||||
"\n\n[Scheduled job mode] You are sending a proactive reminder to the user. "
|
||||
"Reply with the reminder text only; do not role-play as the user."
|
||||
)
|
||||
return (
|
||||
"\n\n【定时任务模式】你正在主动向用户发送提醒。"
|
||||
"只输出提醒正文,不要扮演用户,不要写「收到/好的」等对话式应答。"
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"build_scheduled_turn_instruction",
|
||||
"format_scheduled_user_reminder",
|
||||
"scheduled_turn_system_suffix",
|
||||
]
|
||||
90
runtime/scheduler/weixin_delivery.py
Normal file
90
runtime/scheduler/weixin_delivery.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from runtime.scheduler.session_resolver import resolve_weixin_binding
|
||||
|
||||
|
||||
def normalize_weixin_channel(channel: str) -> str:
|
||||
ch = str(channel or "wechat").strip().lower()
|
||||
return "wechat" if ch in {"wechat", "weixin"} else ch
|
||||
|
||||
|
||||
def resolve_weixin_delivery_target(
|
||||
store: Any,
|
||||
*,
|
||||
tenant_id: str,
|
||||
session_id: str,
|
||||
delivery: dict[str, Any],
|
||||
resolved_channel: str,
|
||||
resolved_chat_id: str,
|
||||
resolved_account_id: str,
|
||||
) -> dict[str, Any]:
|
||||
wx = delivery.get("weixin") if isinstance(delivery.get("weixin"), dict) else {}
|
||||
channel = normalize_weixin_channel(
|
||||
str(resolved_channel or wx.get("channel") or "wechat")
|
||||
)
|
||||
chat_id = str(
|
||||
resolved_chat_id or wx.get("external_chat_id") or wx.get("external_user_id") or ""
|
||||
).strip()
|
||||
account_id = str(
|
||||
resolved_account_id or wx.get("account_id") or ""
|
||||
).strip()
|
||||
|
||||
sid = str(session_id or "").strip()
|
||||
lookup_sess = getattr(store, "lookup_channel_session_by_session_id", None)
|
||||
if sid and callable(lookup_sess):
|
||||
ctx = lookup_sess(tenant_id=str(tenant_id or ""), session_id=sid)
|
||||
if isinstance(ctx, dict):
|
||||
if not chat_id:
|
||||
chat_id = str(ctx.get("external_chat_id") or ctx.get("external_user_id") or "").strip()
|
||||
if not account_id or account_id == "weixin-default":
|
||||
account_id = str(ctx.get("account_id") or account_id or "").strip()
|
||||
if channel in {"wechat", "weixin"}:
|
||||
channel = normalize_weixin_channel(str(ctx.get("channel") or channel))
|
||||
|
||||
lookup_chat = getattr(store, "lookup_channel_session_by_chat_v2", None)
|
||||
if chat_id and callable(lookup_chat) and (not account_id or account_id == "weixin-default"):
|
||||
ctx = lookup_chat(
|
||||
tenant_id=str(tenant_id or ""),
|
||||
channel=channel,
|
||||
external_chat_id=chat_id,
|
||||
)
|
||||
if isinstance(ctx, dict):
|
||||
acct = str(ctx.get("account_id") or "").strip()
|
||||
if acct:
|
||||
account_id = acct
|
||||
|
||||
if not chat_id:
|
||||
binding = resolve_weixin_binding(store, tenant_id=str(tenant_id or ""))
|
||||
if isinstance(binding, dict):
|
||||
chat_id = str(
|
||||
binding.get("external_chat_id") or binding.get("external_user_id") or ""
|
||||
).strip()
|
||||
if not account_id or account_id == "weixin-default":
|
||||
account_id = str(binding.get("account_id") or account_id or "").strip()
|
||||
if channel in {"wechat", "weixin"}:
|
||||
channel = normalize_weixin_channel(str(binding.get("channel") or channel))
|
||||
|
||||
context_token = str(wx.get("context_token") or "").strip()
|
||||
getter = getattr(store, "get_channel_context_token_fuzzy", None)
|
||||
if callable(getter) and chat_id and not context_token:
|
||||
context_token = str(
|
||||
getter(
|
||||
tenant_id=str(tenant_id or ""),
|
||||
channel=channel,
|
||||
account_id=account_id,
|
||||
external_chat_id=chat_id,
|
||||
)
|
||||
or ""
|
||||
).strip()
|
||||
|
||||
return {
|
||||
"channel": channel,
|
||||
"chat_id": chat_id,
|
||||
"account_id": account_id,
|
||||
"context_token": context_token,
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["normalize_weixin_channel", "resolve_weixin_delivery_target"]
|
||||
184
runtime/scheduler/worker_turn.py
Normal file
184
runtime/scheduler/worker_turn.py
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
from runtime.orchestration.group_ingest import is_nonsend_channel_reply_text
|
||||
from runtime.scheduler.turn_text import format_scheduled_user_reminder
|
||||
|
||||
|
||||
def resolve_scheduled_outbound_text(*, payload: dict[str, Any], reply_text: str) -> str:
|
||||
text = str(reply_text or "").strip()
|
||||
if text and not is_nonsend_channel_reply_text(text):
|
||||
return text
|
||||
prompt = str(payload.get("prompt_text") or "").strip()
|
||||
if prompt:
|
||||
return format_scheduled_user_reminder(prompt)
|
||||
return ""
|
||||
|
||||
|
||||
def _persist_scheduled_assistant_reply(
|
||||
store: Any,
|
||||
*,
|
||||
session_id: str,
|
||||
turn_uuid: str,
|
||||
reply_text: str,
|
||||
) -> None:
|
||||
sid = str(session_id or "").strip()
|
||||
body = str(reply_text or "").strip()
|
||||
if not sid or not body:
|
||||
return
|
||||
tu = str(turn_uuid or "").strip()
|
||||
payload = {"scheduled_proactive": True}
|
||||
try:
|
||||
rows = store.get_messages(session_id=sid, limit=80)
|
||||
except Exception:
|
||||
rows = []
|
||||
if tu:
|
||||
for m in rows or []:
|
||||
if str(getattr(m, "role", "") or "").lower() != "assistant":
|
||||
continue
|
||||
if str(getattr(m, "turn_uuid", "") or "").strip() != tu:
|
||||
continue
|
||||
existing = str(getattr(m, "content", "") or "").strip()
|
||||
mid = int(getattr(m, "id", 0) or 0)
|
||||
updater = getattr(store, "update_message_content", None)
|
||||
if existing == body:
|
||||
if mid > 0 and callable(updater):
|
||||
merged = dict(payload)
|
||||
raw_ep = getattr(m, "event_payload", None)
|
||||
if isinstance(raw_ep, dict):
|
||||
merged = {**raw_ep, **merged}
|
||||
elif isinstance(raw_ep, str) and raw_ep.strip():
|
||||
try:
|
||||
parsed = json.loads(raw_ep)
|
||||
if isinstance(parsed, dict):
|
||||
merged = {**parsed, **merged}
|
||||
except Exception:
|
||||
pass
|
||||
updater(
|
||||
session_id=sid,
|
||||
message_id=mid,
|
||||
content=body,
|
||||
event_payload=merged,
|
||||
)
|
||||
return
|
||||
if not existing:
|
||||
if mid > 0 and callable(updater):
|
||||
updater(
|
||||
session_id=sid,
|
||||
message_id=mid,
|
||||
content=body,
|
||||
event_payload=payload,
|
||||
)
|
||||
return
|
||||
break
|
||||
for m in rows or []:
|
||||
if str(getattr(m, "role", "") or "").lower() != "assistant":
|
||||
continue
|
||||
if str(getattr(m, "content", "") or "").strip() == body:
|
||||
return
|
||||
try:
|
||||
store.add_message(
|
||||
session_id=sid,
|
||||
role="assistant",
|
||||
content=body,
|
||||
turn_uuid=tu or None,
|
||||
event_type="assistant_text",
|
||||
event_payload=payload,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def finalize_scheduled_turn_success(
|
||||
store: Any,
|
||||
*,
|
||||
task: Any,
|
||||
payload: dict[str, Any],
|
||||
base_result: dict[str, Any],
|
||||
) -> None:
|
||||
from runtime.scheduler.channel_delivery import deliver_scheduled_reply
|
||||
|
||||
tenant_id = str(payload.get("tenant_id") or "")
|
||||
job_id = str(payload.get("job_id") or "")
|
||||
scheduled_run_id = str(payload.get("run_id_scheduled") or "")
|
||||
reply_text = resolve_scheduled_outbound_text(payload=payload, reply_text=str(base_result.get("reply_text") or ""))
|
||||
delivery = payload.get("delivery") if isinstance(payload.get("delivery"), dict) else {}
|
||||
delivery_json = json.dumps(delivery, ensure_ascii=False)
|
||||
|
||||
job = store.scheduled_job_get(job_id=job_id, tenant_id=tenant_id) if job_id else None
|
||||
if job:
|
||||
delivery_json = str(getattr(job, "delivery_json", "") or delivery_json)
|
||||
|
||||
_persist_scheduled_assistant_reply(
|
||||
store,
|
||||
session_id=str(payload.get("session_id") or ""),
|
||||
turn_uuid=str(base_result.get("turn_uuid") or payload.get("run_id") or ""),
|
||||
reply_text=reply_text,
|
||||
)
|
||||
delivery_status = deliver_scheduled_reply(
|
||||
store,
|
||||
tenant_id=tenant_id,
|
||||
reply_text=reply_text,
|
||||
delivery_json=delivery_json,
|
||||
resolved_channel=str(payload.get("resolved_channel") or ""),
|
||||
resolved_chat_id=str(payload.get("resolved_chat_id") or ""),
|
||||
resolved_account_id=str(payload.get("resolved_account_id") or ""),
|
||||
session_id=str(payload.get("session_id") or ""),
|
||||
)
|
||||
if scheduled_run_id:
|
||||
store.scheduled_job_run_update(
|
||||
run_id=scheduled_run_id,
|
||||
tenant_id=tenant_id,
|
||||
patch={
|
||||
"status": "success" if delivery_status.get("ok") else "failed",
|
||||
"finished_at": datetime.now(timezone.utc).isoformat(),
|
||||
"reply_text": reply_text,
|
||||
"delivery_status": delivery_status,
|
||||
"session_id": str(payload.get("session_id") or ""),
|
||||
},
|
||||
)
|
||||
if job_id and job:
|
||||
pause_after = str(getattr(job, "schedule_kind", "") or "") == "once"
|
||||
store.scheduled_job_mark_run(
|
||||
job_id=job_id,
|
||||
tenant_id=tenant_id,
|
||||
last_run_status="success" if delivery_status.get("ok") else "failed",
|
||||
pause_after=pause_after,
|
||||
)
|
||||
|
||||
|
||||
def finalize_scheduled_turn_failure(
|
||||
store: Any,
|
||||
*,
|
||||
payload: dict[str, Any],
|
||||
error: str,
|
||||
) -> None:
|
||||
tenant_id = str(payload.get("tenant_id") or "")
|
||||
job_id = str(payload.get("job_id") or "")
|
||||
scheduled_run_id = str(payload.get("run_id_scheduled") or "")
|
||||
if scheduled_run_id:
|
||||
store.scheduled_job_run_update(
|
||||
run_id=scheduled_run_id,
|
||||
tenant_id=tenant_id,
|
||||
patch={
|
||||
"status": "failed",
|
||||
"finished_at": datetime.now(timezone.utc).isoformat(),
|
||||
"error": str(error or "")[:500],
|
||||
},
|
||||
)
|
||||
if job_id:
|
||||
job = store.scheduled_job_get(job_id=job_id, tenant_id=tenant_id)
|
||||
pause_after = bool(job and str(getattr(job, "schedule_kind", "") or "") == "once")
|
||||
store.scheduled_job_mark_run(
|
||||
job_id=job_id,
|
||||
tenant_id=tenant_id,
|
||||
last_run_status="failed",
|
||||
pause_after=pause_after,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["finalize_scheduled_turn_failure", "finalize_scheduled_turn_success"]
|
||||
70
runtime/tools/context_inject.py
Normal file
70
runtime/tools/context_inject.py
Normal 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"]
|
||||
334
runtime/tools/experts/productivity/schedule_tools.py
Normal file
334
runtime/tools/experts/productivity/schedule_tools.py
Normal 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",
|
||||
]
|
||||
|
|
@ -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"]
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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': ...
|
||||
|
|
|
|||
634
svc/persistence/scheduled_job_store.py
Normal file
634
svc/persistence/scheduled_job_store.py
Normal 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",
|
||||
]
|
||||
|
|
@ -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,
|
||||
*,
|
||||
|
|
|
|||
114
tests/test_admin_scheduled_jobs_api.py
Normal file
114
tests/test_admin_scheduled_jobs_api.py
Normal 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()
|
||||
49
tests/test_gateway_cron_service.py
Normal file
49
tests/test_gateway_cron_service.py
Normal 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()
|
||||
20
tests/test_schedule_duration_parse.py
Normal file
20
tests/test_schedule_duration_parse.py
Normal 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()
|
||||
59
tests/test_schedule_tools.py
Normal file
59
tests/test_schedule_tools.py
Normal 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()
|
||||
133
tests/test_scheduler_ephemeral_context.py
Normal file
133
tests/test_scheduler_ephemeral_context.py
Normal 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()
|
||||
130
tests/test_scheduler_expressions.py
Normal file
130
tests/test_scheduler_expressions.py
Normal 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()
|
||||
180
tests/test_scheduler_proactive_delivery.py
Normal file
180
tests/test_scheduler_proactive_delivery.py
Normal 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()
|
||||
116
tests/test_scheduler_viewer_username.py
Normal file
116
tests/test_scheduler_viewer_username.py
Normal 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()
|
||||
93
tests/test_tool_context_inject.py
Normal file
93
tests/test_tool_context_inject.py
Normal 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()
|
||||
77
tests/test_tool_runtime_schedule_validation.py
Normal file
77
tests/test_tool_runtime_schedule_validation.py
Normal 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()
|
||||
148
tests/test_weixin_scheduled_delivery.py
Normal file
148
tests/test_weixin_scheduled_delivery.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue