feat(scheduler): confirm before deleting scheduled jobs

Require preview confirmation for schedule_delete, extra confirm_foreign for others' jobs in groups, and mirror the same flow in chat slash commands.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-07-14 10:11:04 +08:00
parent d036190195
commit 30202dd291
6 changed files with 692 additions and 63 deletions

View file

@ -113,7 +113,15 @@ def _menu_text() -> str:
)
def _handle_productivity_commands(*, text: str, tenant_id: str, user_id: str, session_id: str = "") -> str | None:
def _handle_productivity_commands(
*,
text: str,
tenant_id: str,
user_id: str,
session_id: str = "",
creator_external_user_id: str = "",
creator_push_name: str = "",
) -> str | None:
t = (text or "").strip()
t_low = t.lower()
if not t:
@ -206,15 +214,95 @@ def _handle_productivity_commands(*, text: str, tenant_id: str, user_id: str, se
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"
from runtime.scheduler.job_delete import (
job_delete_preview,
job_is_foreign,
resolve_scheduled_jobs_by_id,
)
matches = resolve_scheduled_jobs_by_id(store, tenant_id=tenant_id, job_id=jid)
if not matches:
return "未找到该定时任务。"
if len(matches) > 1:
lines = [
f"- {r.name} | id={r.id[:8]}… | {r.schedule_kind}:{r.schedule_expr}" for r in matches[:8]
]
return "匹配到多个任务,请用更完整的 id:\n" + "\n".join(lines)
job = matches[0]
foreign = job_is_foreign(
job,
actor_user_id=str(user_id or ""),
actor_external_user_id=str(creator_external_user_id or ""),
)
if foreign:
return (
f"该任务看起来是其他人创建的({job.name} / id={job.id[:8]})。"
"如需删除,请明确说「确认删除他人定时 <完整id>」。"
)
ok = store.scheduled_job_delete(tenant_id=tenant_id, job_id=str(job.id))
if not ok:
return "未找到该定时任务。"
preview = job_delete_preview(job)
return f"已删除:{preview.get('name')}(id={str(preview.get('id') or '')[:8]})"
if t.startswith("确认删除他人定时任务 ") or t.startswith("确认删除他人定时 "):
prefix = "确认删除他人定时任务 " if t.startswith("确认删除他人定时任务 ") else "确认删除他人定时 "
jid = t[len(prefix) :].strip()
if not jid:
return "请提供完整任务 id。示例:确认删除他人定时 1234abcd-...."
from runtime.scheduler.job_delete import job_delete_preview, resolve_scheduled_jobs_by_id
matches = resolve_scheduled_jobs_by_id(store, tenant_id=tenant_id, job_id=jid)
if not matches:
return "未找到该定时任务。"
if len(matches) > 1:
return "删除他人任务须使用唯一完整 id,当前前缀匹配到多个任务。"
job = matches[0]
ok = store.scheduled_job_delete(tenant_id=tenant_id, job_id=str(job.id))
if not ok:
return "未找到该定时任务。"
preview = job_delete_preview(job)
return f"已删除他人任务:{preview.get('name')}(id={str(preview.get('id') or '')[:8]})"
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 "未找到该定时任务。"
return "请提供任务 id 前缀。示例:删除定时 1234abcd"
from runtime.scheduler.job_delete import (
job_delete_preview_markdown,
job_delete_preview,
job_is_foreign,
resolve_scheduled_jobs_by_id,
)
matches = resolve_scheduled_jobs_by_id(store, tenant_id=tenant_id, job_id=jid)
if not matches:
return "未找到该定时任务。"
if len(matches) > 1:
lines = [
f"- {r.name} | id={r.id[:8]}… | {r.schedule_kind}:{r.schedule_expr}" for r in matches[:8]
]
return "匹配到多个任务,请指定更完整的 id:\n" + "\n".join(lines)
job = matches[0]
foreign = job_is_foreign(
job,
actor_user_id=str(user_id or ""),
actor_external_user_id=str(creator_external_user_id or ""),
)
preview = job_delete_preview(job)
md = job_delete_preview_markdown(preview, foreign=foreign, lang="zh")
if foreign:
return (
md
+ f"\n\n如确认删除,请发送:确认删除他人定时 {job.id}"
)
return md + f"\n\n如确认删除,请发送:确认删除定时 {job.id}"
schedule_prefix = ""
if t.startswith("记定时 ") or t.startswith("创建定时 "):
@ -245,6 +333,15 @@ def _handle_productivity_commands(*, text: str, tenant_id: str, user_id: str, se
tenant_id=tenant_id,
session_id=str(session_id or "").strip(),
)
from runtime.scheduler.job_delete import merge_delivery_creator
delivery = merge_delivery_creator(
delivery,
user_id=str(user_id or ""),
external_user_id=str(creator_external_user_id or ""),
push_name=str(creator_push_name or ""),
session_id=str(session_id or "").strip(),
)
row = store.scheduled_job_create(
tenant_id=tenant_id,
name=prompt_text[:40] or "定时提醒",
@ -1027,6 +1124,21 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
tenant_id=tenant_id,
user_id=user_id,
session_id=str(session_id),
creator_external_user_id=str(getattr(inbound, "external_user_id", "") or ""),
creator_push_name=str(
(
(inbound.metadata or {}).get("push_name")
if isinstance(inbound.metadata, dict)
else ""
)
or (
((inbound.metadata or {}).get("raw") or {}).get("pushName")
if isinstance(inbound.metadata, dict)
and isinstance((inbound.metadata or {}).get("raw"), dict)
else ""
)
or ""
),
)
if cmd_reply is not None:
reply = cmd_reply

View file

@ -529,60 +529,79 @@ class ToolExecutor:
# which is important for "created-by specialist" semantics and for later scheduled runs.
try:
tname = str(tc.name or "")
if tname == "schedule_create":
if tname in {"schedule_create", "schedule_delete"}:
md = ctx.inbound_metadata if isinstance(ctx.inbound_metadata, dict) else {}
cur_spec = str(ctx.specialist or "").strip().lower()
if cur_spec and not str(tool_args.get("specialist") or "").strip() and not str(
tool_args.get("selected_specialist") or ""
).strip():
tool_args["selected_specialist"] = cur_spec
raw_mentions = md.get("mentioned_jids") or md.get("mentionedJids") or md.get("mentions") or []
mention_list = raw_mentions if isinstance(raw_mentions, list) else []
bot_jid = str(md.get("bot_jid") or "").strip().lower()
bot_lid = str(md.get("bot_lid") or "").strip().lower()
cleaned: list[str] = []
seen: set[str] = set()
for m in mention_list:
jid = str(m or "").strip()
if not jid:
continue
low = jid.lower()
if (bot_jid and low == bot_jid) or (bot_lid and low == bot_lid):
continue
if low in seen:
continue
seen.add(low)
cleaned.append(jid)
if cleaned:
tool_args["whatsapp_mention_jids"] = cleaned
elif tool_args.get("whatsapp_mention_jids") is not None:
from runtime.scheduler.whatsapp_mentions import normalize_whatsapp_mention_jids
raw_block = md.get("raw") if isinstance(md.get("raw"), dict) else {}
push_name = str(
md.get("push_name") or md.get("pushName") or raw_block.get("pushName") or ""
).strip()
external_uid = str(
md.get("external_user_id")
or md.get("sender_jid")
or raw_block.get("participant")
or raw_block.get("sender")
or ""
).strip()
if tname == "schedule_create":
cur_spec = str(ctx.specialist or "").strip().lower()
if cur_spec and not str(tool_args.get("specialist") or "").strip() and not str(
tool_args.get("selected_specialist") or ""
).strip():
tool_args["selected_specialist"] = cur_spec
if external_uid and not str(tool_args.get("creator_external_user_id") or "").strip():
tool_args["creator_external_user_id"] = external_uid
if push_name and not str(tool_args.get("creator_push_name") or "").strip():
tool_args["creator_push_name"] = push_name
raw_mentions = md.get("mentioned_jids") or md.get("mentionedJids") or md.get("mentions") or []
mention_list = raw_mentions if isinstance(raw_mentions, list) else []
bot_jid = str(md.get("bot_jid") or "").strip().lower()
bot_lid = str(md.get("bot_lid") or "").strip().lower()
cleaned: list[str] = []
seen: set[str] = set()
for m in mention_list:
jid = str(m or "").strip()
if not jid:
continue
low = jid.lower()
if (bot_jid and low == bot_jid) or (bot_lid and low == bot_lid):
continue
if low in seen:
continue
seen.add(low)
cleaned.append(jid)
if cleaned:
tool_args["whatsapp_mention_jids"] = cleaned
elif tool_args.get("whatsapp_mention_jids") is not None:
from runtime.scheduler.whatsapp_mentions import normalize_whatsapp_mention_jids
tool_args["whatsapp_mention_jids"] = normalize_whatsapp_mention_jids(
tool_args.get("whatsapp_mention_jids")
)
if tool_args.get("whatsapp_mention_names") is None:
from runtime.scheduler.whatsapp_mentions import extract_whatsapp_mention_names
user_text = str(ctx.user_text or "")
names = extract_whatsapp_mention_names(user_text)
bot_names = {
str(x or "").strip().lower()
for x in (
md.get("bot_push_name"),
"oliver",
tool_args["whatsapp_mention_jids"] = normalize_whatsapp_mention_jids(
tool_args.get("whatsapp_mention_jids")
)
if str(x or "").strip()
}
filtered_names = [n for n in names if n.lower() not in bot_names]
if filtered_names:
tool_args["whatsapp_mention_names"] = filtered_names
elif md.get("mention_names"):
names_md = md.get("mention_names")
if isinstance(names_md, list):
tool_args["whatsapp_mention_names"] = [
str(x or "").strip() for x in names_md if str(x or "").strip()
]
if tool_args.get("whatsapp_mention_names") is None:
from runtime.scheduler.whatsapp_mentions import extract_whatsapp_mention_names
user_text = str(ctx.user_text or "")
names = extract_whatsapp_mention_names(user_text)
bot_names = {
str(x or "").strip().lower()
for x in (
md.get("bot_push_name"),
"oliver",
)
if str(x or "").strip()
}
filtered_names = [n for n in names if n.lower() not in bot_names]
if filtered_names:
tool_args["whatsapp_mention_names"] = filtered_names
elif md.get("mention_names"):
names_md = md.get("mention_names")
if isinstance(names_md, list):
tool_args["whatsapp_mention_names"] = [
str(x or "").strip() for x in names_md if str(x or "").strip()
]
else:
if external_uid and not str(tool_args.get("actor_external_user_id") or "").strip():
tool_args["actor_external_user_id"] = external_uid
except Exception:
pass
tool_args = filter_arguments_to_schema(tool.parameters, tool_args)

View file

@ -0,0 +1,197 @@
from __future__ import annotations
import json
from typing import Any
def _delivery_dict(job: Any) -> dict[str, Any]:
raw = getattr(job, "delivery_json", None)
if isinstance(job, dict):
if isinstance(job.get("delivery"), dict):
return dict(job.get("delivery") or {})
raw = job.get("delivery_json")
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 creator_from_delivery(delivery: dict[str, Any] | None) -> dict[str, str]:
raw = (delivery or {}).get("creator") if isinstance(delivery, dict) else None
if not isinstance(raw, dict):
return {}
return {
"user_id": str(raw.get("user_id") or "").strip(),
"external_user_id": str(raw.get("external_user_id") or "").strip(),
"push_name": str(raw.get("push_name") or "").strip(),
"session_id": str(raw.get("session_id") or "").strip(),
}
def merge_delivery_creator(
delivery: dict[str, Any] | None,
*,
user_id: str = "",
external_user_id: str = "",
push_name: str = "",
session_id: str = "",
) -> dict[str, Any]:
out = dict(delivery or {})
creator = {
"user_id": str(user_id or "").strip(),
"external_user_id": str(external_user_id or "").strip(),
"push_name": str(push_name or "").strip(),
"session_id": str(session_id or "").strip(),
}
if any(creator.values()):
out["creator"] = creator
return out
def job_is_foreign(
job: Any,
*,
actor_user_id: str = "",
actor_external_user_id: str = "",
) -> bool:
"""True when the actor is clearly not the creator (best-effort for group chats)."""
delivery = _delivery_dict(job)
creator = creator_from_delivery(delivery)
actor_uid = str(actor_user_id or "").strip()
actor_ext = str(actor_external_user_id or "").strip().lower()
creator_uid = str(creator.get("user_id") or getattr(job, "created_by_user_id", "") or "").strip()
if isinstance(job, dict) and not creator_uid:
creator_uid = str(job.get("created_by_user_id") or "").strip()
creator_ext = str(creator.get("external_user_id") or "").strip().lower()
if actor_ext and creator_ext and actor_ext != creator_ext:
return True
if actor_uid and creator_uid and actor_uid != creator_uid:
# Only treat as foreign when we also lack matching external ids
# (same account owner may create many channel jobs).
if creator_ext or actor_ext:
if creator_ext and actor_ext:
return actor_ext != creator_ext
# external known on one side only — prefer not to block
return False
return True
return False
def resolve_scheduled_jobs_by_id(
store: Any,
*,
tenant_id: str,
job_id: str,
limit: int = 200,
) -> list[Any]:
jid = str(job_id or "").strip()
if not jid:
return []
exact = store.scheduled_job_get(job_id=jid, tenant_id=tenant_id)
if exact is not None:
return [exact]
rows = store.scheduled_job_list(tenant_id=tenant_id, status=None, limit=max(1, min(int(limit), 500)))
return [r for r in rows if str(getattr(r, "id", "") or "").startswith(jid)]
def job_delete_preview(job: Any) -> dict[str, Any]:
delivery = _delivery_dict(job)
creator = creator_from_delivery(delivery)
recipe: dict[str, Any] = {}
try:
raw = getattr(job, "recipe_json", None)
if isinstance(job, dict):
if isinstance(job.get("recipe"), dict):
recipe = dict(job.get("recipe") or {})
else:
raw = job.get("recipe_json")
if not recipe and raw:
parsed = json.loads(str(raw or "{}"))
if isinstance(parsed, dict):
recipe = parsed
except Exception:
recipe = {}
goal = str((recipe or {}).get("goal") or getattr(job, "prompt_text", "") or "").strip()
if isinstance(job, dict) and not goal:
goal = str(job.get("prompt_text") or "").strip()
return {
"id": str(getattr(job, "id", None) or (job.get("id") if isinstance(job, dict) else "") or ""),
"name": str(getattr(job, "name", None) or (job.get("name") if isinstance(job, dict) else "") or ""),
"status": str(getattr(job, "status", None) or (job.get("status") if isinstance(job, dict) else "") or ""),
"schedule_kind": str(
getattr(job, "schedule_kind", None) or (job.get("schedule_kind") if isinstance(job, dict) else "") or ""
),
"schedule_expr": str(
getattr(job, "schedule_expr", None) or (job.get("schedule_expr") if isinstance(job, dict) else "") or ""
),
"prompt_text": str(
getattr(job, "prompt_text", None) or (job.get("prompt_text") if isinstance(job, dict) else "") or ""
),
"goal": goal,
"created_by_user_id": str(
getattr(job, "created_by_user_id", None)
or (job.get("created_by_user_id") if isinstance(job, dict) else "")
or ""
),
"creator": creator,
"next_run_at": str(
getattr(job, "next_run_at", None) or (job.get("next_run_at") if isinstance(job, dict) else "") or ""
)
or None,
}
def job_delete_preview_markdown(preview: dict[str, Any], *, foreign: bool = False, lang: str = "zh") -> str:
creator = preview.get("creator") if isinstance(preview.get("creator"), dict) else {}
creator_label = str(creator.get("push_name") or "").strip()
if not creator_label:
creator_label = str(creator.get("external_user_id") or preview.get("created_by_user_id") or "").strip() or "(未知)"
is_en = str(lang or "").lower().startswith("en")
if is_en:
lines = [
"## Scheduled job delete preview (confirm required)",
f"- **Id**: `{preview.get('id')}`",
f"- **Name**: {preview.get('name') or '(untitled)'}",
f"- **Schedule**: `{preview.get('schedule_kind')}` `{preview.get('schedule_expr')}`",
f"- **Status**: {preview.get('status')}",
f"- **Goal / prompt**: {preview.get('goal') or preview.get('prompt_text') or '(empty)'}",
f"- **Created by**: {creator_label}",
]
if foreign:
lines.append("- **Warning**: This job appears to be created by someone else.")
lines.append("")
lines.append("Reply **confirm delete** with this id, then call schedule_delete with confirmed=true.")
if foreign:
lines.append("Also set confirm_foreign=true after the owner explicitly agrees.")
return "\n".join(lines)
lines = [
"## 删除定时任务预览(需确认)",
f"- **Id**:`{preview.get('id')}`",
f"- **名称**:{preview.get('name') or '(未命名)'}",
f"- **时间**:`{preview.get('schedule_kind')}` `{preview.get('schedule_expr')}`",
f"- **状态**:{preview.get('status')}",
f"- **目标/内容**:{preview.get('goal') or preview.get('prompt_text') or '(空)'}",
f"- **创建者**:{creator_label}",
]
if foreign:
lines.append("- **注意**:该任务看起来是**其他人**创建的。")
lines.append("")
lines.append("请回复**确认删除**并带上该 id;确认后调用 `schedule_delete` 且 `confirmed=true`。")
if foreign:
lines.append("若删除他人任务,还需用户明确同意,并设置 `confirm_foreign=true`。")
return "\n".join(lines)
__all__ = [
"creator_from_delivery",
"job_delete_preview",
"job_delete_preview_markdown",
"job_is_foreign",
"merge_delivery_creator",
"resolve_scheduled_jobs_by_id",
]

View file

@ -6,6 +6,13 @@ 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.job_delete import (
job_delete_preview,
job_delete_preview_markdown,
job_is_foreign,
merge_delivery_creator,
resolve_scheduled_jobs_by_id,
)
from runtime.scheduler.recipe import (
looks_like_complex_schedule_prompt,
normalize_recipe,
@ -228,13 +235,20 @@ def schedule_create_tool() -> ToolSpec:
delivery,
args.get("whatsapp_mention_names") if args.get("whatsapp_mention_names") is not None else None,
)
session_id = str(args.get("session_id") or "").strip() or None
delivery = merge_delivery_creator(
delivery,
user_id=owner_user_id,
external_user_id=str(args.get("creator_external_user_id") or "").strip(),
push_name=str(args.get("creator_push_name") or "").strip(),
session_id=str(session_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")
)
session_id = str(args.get("session_id") or "").strip() or None
if recipe_has_playbook(recipe):
if session_id and not str((recipe.get("source") or {}).get("session_id") or "").strip():
recipe["source"]["session_id"] = session_id
@ -303,6 +317,14 @@ def schedule_create_tool() -> ToolSpec:
"type": "array",
"items": {"type": "string"},
},
"creator_external_user_id": {
"type": "string",
"description": "Auto-filled channel sender id for group ownership checks.",
},
"creator_push_name": {
"type": "string",
"description": "Auto-filled channel display name of the creator.",
},
"delivery": {"type": "object"},
"description": {"type": "string"},
},
@ -483,17 +505,109 @@ def schedule_delete_tool() -> ToolSpec:
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}
confirmed = bool(args.get("confirmed"))
confirm_foreign = bool(args.get("confirm_foreign"))
lang = str(args.get("lang") or "zh")
actor_user_id = str(args.get("owner_user_id") or args.get("user_id") or "").strip()
actor_external_user_id = str(args.get("actor_external_user_id") or "").strip()
matches = resolve_scheduled_jobs_by_id(store, tenant_id=tenant_id, job_id=job_id)
if not matches:
return {"ok": False, "error": "job_not_found", "job_id": job_id}
if len(matches) > 1:
candidates = [job_delete_preview(j) for j in matches[:10]]
return {
"ok": False,
"error": "ambiguous_job",
"candidates": candidates,
"hint": (
"Multiple jobs match this id prefix. Show candidates to the user and "
"retry schedule_delete with the full job id."
),
}
job = matches[0]
preview = job_delete_preview(job)
foreign = job_is_foreign(
job,
actor_user_id=actor_user_id,
actor_external_user_id=actor_external_user_id,
)
preview_md = job_delete_preview_markdown(preview, foreign=foreign, lang=lang)
if not confirmed:
return {
"ok": False,
"error": "confirmation_required",
"needs_confirm": True,
"foreign_job": foreign,
"job_id": preview["id"],
"preview": preview,
"preview_markdown": preview_md,
"next_step": (
"Show preview_markdown to the user. After they explicitly confirm deleting "
"THIS job, call schedule_delete again with the full job_id and confirmed=true"
+ (", and confirm_foreign=true if foreign_job" if foreign else "")
+ "."
),
}
if foreign and not confirm_foreign:
return {
"ok": False,
"error": "foreign_job_confirmation_required",
"needs_confirm": True,
"foreign_job": True,
"job_id": preview["id"],
"preview": preview,
"preview_markdown": preview_md,
"hint": (
"This job was created by someone else. Only delete after the user clearly "
"agrees, then set confirmed=true and confirm_foreign=true."
),
}
ok = store.scheduled_job_delete(tenant_id=tenant_id, job_id=str(preview["id"]))
if not ok:
return {"ok": False, "error": "job_not_found", "job_id": preview["id"]}
return {"ok": True, "job_id": preview["id"], "deleted": preview}
except Exception as e:
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
return ToolSpec(
name="schedule_delete",
description="Delete (soft) a scheduled job.",
description=(
"Soft-delete a scheduled job WITH confirmation. "
"First call WITHOUT confirmed (or confirmed=false) to get preview_markdown; "
"show it to the user. After explicit user confirmation, call again with "
"confirmed=true. If foreign_job (created by someone else in a group), also set "
"confirm_foreign=true. Never guess when multiple jobs match — resolve ambiguity first. "
"Prefer pausing instead of deleting when the user only wants to stop temporary runs."
),
parameters={
"type": "object",
"properties": {"tenant_id": {"type": "string"}, "job_id": {"type": "string"}},
"properties": {
"tenant_id": {"type": "string"},
"job_id": {
"type": "string",
"description": "Full job id preferred; prefix allowed only when unique.",
},
"confirmed": {
"type": "boolean",
"description": "Must be true to actually delete after user confirmation.",
"default": False,
},
"confirm_foreign": {
"type": "boolean",
"description": "Required when deleting a job created by someone else.",
"default": False,
},
"actor_external_user_id": {
"type": "string",
"description": "Auto-filled channel sender id for ownership checks.",
},
"lang": {"type": "string"},
},
"required": ["job_id"],
"additionalProperties": False,
},