feat: 定时任务支持艾特指定的人

This commit is contained in:
hp0912 2026-07-19 21:52:18 +08:00
parent 4247b64a52
commit 885e7c401c
5 changed files with 338 additions and 68 deletions

View File

@ -1,6 +1,6 @@
--- ---
name: create-scheduled-task name: create-scheduled-task
description: "创建当前微信会话中的提醒或定时任务。当用户说“X 分钟后提醒我 Y”“X 分钟提醒我 Y”“过一会提醒我”“每天/每周/工作日几点提醒我”“帮我设个提醒”或“创建一个……定时任务”时使用。支持一次性延时、每日、每周和中国法定工作日任务,也支持按时生成 AI 内容。" description: "创建当前微信会话中的提醒或定时任务。当用户说“X 分钟后提醒我/群友/所有人 Y”“过一会提醒我”“每天/每周/工作日几点提醒我”“帮我设个提醒”或“创建一个……定时任务”时使用。支持一次性延时、每日、每周和中国法定工作日任务,可在群聊中真正 @ 指定成员或 @所有人,也支持按时生成 AI 内容。"
--- ---
# 创建定时任务 # 创建定时任务
@ -9,11 +9,11 @@ description: "创建当前微信会话中的提醒或定时任务。当用户说
## 执行流程 ## 执行流程
1. 从用户原话中提取任务名称、执行规则和发送内容 1. 从用户原话中提取任务名称、执行规则、发送内容,以及要 @ 创建人、指定群成员、所有人还是不 @
2. 缺少执行时间或发送内容时,只追问缺失项,不运行脚本。 2. 缺少执行时间或发送内容时,只追问缺失项,不运行脚本。
3. 按下表选择调度类型和参数。 3. 按下表选择调度类型和参数。
4. 在本 Skill 目录执行 `scripts/create_scheduled_task.py`,且不要在真实请求中传 `--dry-run` 4. 在本 Skill 目录执行 `scripts/create_scheduled_task.py`,且不要在真实请求中传 `--dry-run`
5. 仅在脚本返回 `ok: true` 后确认创建成功。回复任务名称、执行规则、下一次执行时间、提醒内容和发送位置;群聊任务同时说明触发时会自动 @ 创建人。 5. 仅在脚本返回 `ok: true` 后确认创建成功。回复任务名称、执行规则、下一次执行时间、提醒内容、发送位置和实际 @ 对象。以返回的 `task.mention` 为准:`all` 说明会 @所有人`custom` 列出 `display_names``creator` 说明会 @ 创建人,`none` 说明不 @ 任何人。
6. 脚本失败时立即向用户反馈原始错误含义,不得声称任务已创建。 6. 脚本失败时立即向用户反馈原始错误含义,不得声称任务已创建。
## 调度规则 ## 调度规则
@ -41,11 +41,24 @@ description: "创建当前微信会话中的提醒或定时任务。当用户说
## 内容规则 ## 内容规则
- 对普通提醒使用 `--content`。默认把“提醒我喝水”整理为面向用户的 `提醒:喝水`,但用户给出精确文案时保持原文。 - 对普通提醒使用 `--content`。默认把“提醒我喝水”整理为面向用户的 `提醒:喝水`,但用户给出精确文案时保持原文。
- 真正的 @ 由 `--mention`/`--mentions`/`--mention-all` 单独配置,不要在正文中拼接普通文本 `@昵称``@所有人`。若正文只是以被提醒人的名字作称呼(如“张三,该吃饭了”),通常把 `--content` 整理为“该吃饭了”,避免客户端自动添加 `@张三` 后重复人名;用户明确要求保留精确正文时除外。
- 对“每天生成一份早报”这类动态内容使用 `--ai-prompt`,不要把普通固定提醒升级为 AI 任务。 - 对“每天生成一份早报”这类动态内容使用 `--ai-prompt`,不要把普通固定提醒升级为 AI 任务。
- 可以同时传 `--content``--ai-prompt`;执行时先发送固定文本,再发送 AI 结果。 - 可以同时传 `--content``--ai-prompt`;执行时先发送固定文本,再发送 AI 结果。
- 至少传 `--content``--ai-prompt` 之一。 - 至少传 `--content``--ai-prompt` 之一。
- 任务名应简短、可识别,不超过 100 个字符;固定文本不超过 500 个字符。 - 任务名应简短、可识别,不超过 100 个字符;固定文本不超过 500 个字符。
## 群成员 @ 规则
- “提醒我……”或用户没有指定被 @ 人时,不传任何 mention 参数。脚本会为群聊显式保存创建人的微信 ID私聊会显式保存空数组。
- “提醒这位群友 张三……”“到点 @ 张三……”或其他明确点名表达,按用户原话提取群昵称或备注并传 `--mention '张三'`
- 指定多人时重复传 `--mention`,或使用 `--mentions '["张三","李四"]'`。脚本会按群备注、群昵称依次匹配并去重。
- 用户明确说“@所有人”“提醒全体成员”“通知群里所有人”时传 `--mention-all`。不要把“所有人”当成员昵称查询,也不要在正文里拼接普通文本。
- 用户明确说“不要 @ 人”“只发消息”时传 `--no-mention`。不要用它替代缺省行为。
- `--mention-all`、`--no-mention` 和指定成员参数三种模式互斥。
- `--mention` 只允许用于当前群聊。不要让用户提供或猜测微信 ID脚本会通过客户端查询当前群内未退群成员并保存真实微信 ID。
- 找不到成员或同一优先级匹配到多人时,脚本会在创建任务前失败。向用户说明需要更准确、唯一的完整群备注或昵称,不要退回为 @ 创建人,也不要只把名字拼进正文。
- 固定文本和 AI 内容同时存在时,只在第一条固定文本中 @;只有 AI 内容时在 AI 回复中 @。
## 会话目标 ## 会话目标
脚本自动读取以下环境变量: 脚本自动读取以下环境变量:
@ -54,7 +67,7 @@ description: "创建当前微信会话中的提醒或定时任务。当用户说
- `ROBOT_SENDER_WX_ID`:当前消息发送人 ID。 - `ROBOT_SENDER_WX_ID`:当前消息发送人 ID。
- `ROBOT_WECHAT_CLIENT_PORT`:机器人客户端端口。 - `ROBOT_WECHAT_CLIENT_PORT`:机器人客户端端口。
私聊中把当前好友作为创建人和发送目标。群聊中把当前发言人作为创建人、当前群聊作为发送目标。因此,群聊里的“提醒我”会在当前群聊发送,并在任务触发后发送的第一条文本中自动 @ 创建人,不会私聊群成员。成功回复时明确说明这一点。不要让用户提供或猜测这些 ID。 私聊中把当前好友作为创建人和发送目标。群聊中把当前发言人作为创建人、当前群聊作为发送目标。因此任务始终发回当前会话,不会私聊群成员。每个发送目标都显式保存 `mention_wechat_ids`:群聊里的“提醒我”保存创建人 ID明确指定成员时保存成员 ID`--mention-all` 保存 `notify@all`,传 `--no-mention` 或私聊任务时保存空数组。成功回复必须按脚本实际返回说明,不能笼统声称总会 @ 创建人。不要让用户提供或猜测这些 ID。
## 脚本参数 ## 脚本参数
@ -65,6 +78,10 @@ description: "创建当前微信会话中的提醒或定时任务。当用户说
--ai-prompt <动态内容提示词> 与 --content 至少一个 --ai-prompt <动态内容提示词> 与 --content 至少一个
--time <HH:mm> daily/weekly/cn_workday 必填 --time <HH:mm> daily/weekly/cn_workday 必填
--weekdays <JSON数组或逗号列表> weekly 必填1=周一7=周日 --weekdays <JSON数组或逗号列表> weekly 必填1=周一7=周日
--mention <当前群成员昵称或备注> 可选,可重复;指定真正 @ 的成员
--mentions <字符串JSON数组> 可选;一次指定多个真正 @ 的成员
--mention-all 可选;真正 @ 当前群所有人
--no-mention 可选;明确不 @ 任何人,不能与其他 mention 参数同用
--delay-seconds <整数> delay_once 四选一 --delay-seconds <整数> delay_once 四选一
--delay-minutes <整数> delay_once 四选一 --delay-minutes <整数> delay_once 四选一
--delay-hours <整数> delay_once 四选一 --delay-hours <整数> delay_once 四选一
@ -80,6 +97,24 @@ description: "创建当前微信会话中的提醒或定时任务。当用户说
python3 scripts/create_scheduled_task.py --name '喝水提醒' --schedule-type delay_once --delay-minutes 10 --content '提醒:喝水' python3 scripts/create_scheduled_task.py --name '喝水提醒' --schedule-type delay_once --delay-minutes 10 --content '提醒:喝水'
``` ```
5 分钟后在当前群聊 @ 指定群友:
```bash
python3 scripts/create_scheduled_task.py --name '晚饭提醒' --schedule-type delay_once --delay-minutes 5 --mention '又双叒叕' --content '该吃晚饭了🍚'
```
每天同时 @ 多名群友:
```bash
python3 scripts/create_scheduled_task.py --name '打卡提醒' --schedule-type daily --time 09:00 --mentions '["张三","李四"]' --content '记得打卡'
```
每天在当前群聊 @所有人
```bash
python3 scripts/create_scheduled_task.py --name '全员打卡提醒' --schedule-type daily --time 09:00 --mention-all --content '请大家记得打卡'
```
每周一和周三提醒: 每周一和周三提醒:
```bash ```bash
@ -96,7 +131,7 @@ python3 scripts/create_scheduled_task.py --name '工作日早报' --schedule-typ
## 成功与失败处理 ## 成功与失败处理
- 成功 JSON 包含 `task.id`、`task.schedule_summary`、`task.next_run_time``task.target_type`。使用这些实际返回值回复,不要只复述用户输入。 - 成功 JSON 包含 `task.id`、`task.schedule_summary`、`task.next_run_time`、`task.target_type` 和 `task.mention`。使用这些实际返回值回复,不要只复述用户输入或根据创建人猜测 @ 对象
- 接口业务错误会以“创建定时任务失败……”返回。说明可操作的原因例如发送目标不存在、任务数量达到上限、AI 配置缺失或调度器未初始化。 - 接口业务错误会以“创建定时任务失败……”返回。说明可操作的原因例如发送目标不存在、任务数量达到上限、AI 配置缺失或调度器未初始化。
- 创建接口不是幂等接口。发生超时、连接中断、无效响应,或错误中含“任务已保存,但刷新调度器失败”时,任务可能已经入库;明确告知用户先到定时任务列表核对,禁止自动重试,以免创建重复任务。 - 创建接口不是幂等接口。发生超时、连接中断、无效响应,或错误中含“任务已保存,但刷新调度器失败”时,任务可能已经入库;明确告知用户先到定时任务列表核对,禁止自动重试,以免创建重复任务。
- 普通好友和普通群成员最多创建 5 个任务;群主、群管理员和后台管理员不受此配额限制。 - 普通好友和普通群成员最多创建 5 个任务;群主、群管理员和后台管理员不受此配额限制。

View File

@ -1,4 +1,4 @@
interface: interface:
display_name: "创建定时任务" display_name: "创建定时任务"
short_description: "根据当前微信会话创建一次性提醒、每日、每周或法定工作日任务" short_description: "创建当前会话提醒任务,并可定时 @ 指定群成员或所有人"
default_prompt: "使用 $create-scheduled-task 在 10 分钟后提醒我喝水。" default_prompt: "使用 $create-scheduled-task 在 5 分钟后 @所有人,提醒大家查看群公告。"

View File

@ -10,9 +10,10 @@ import re
import socket import socket
import sys import sys
import urllib.error import urllib.error
import urllib.parse
import urllib.request import urllib.request
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any, NoReturn from typing import Any, Literal, NoReturn, TypedDict
try: try:
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
@ -23,6 +24,19 @@ sys.stderr = sys.stdout
SCHEDULE_TYPES = ("delay_once", "daily", "weekly", "cn_workday") SCHEDULE_TYPES = ("delay_once", "daily", "weekly", "cn_workday")
MAX_DELAY_SECONDS = 24 * 60 * 60 MAX_DELAY_SECONDS = 24 * 60 * 60
MENTION_ALL_WECHAT_ID = "notify@all"
class ScheduledTaskTarget(TypedDict):
wechat_id: str
type: Literal["chat_room", "friend"]
mention_wechat_ids: list[str]
class ScheduledTaskCreator(TypedDict):
type: Literal["chat_room", "friend"]
wechat_id: str
chat_room_id: str
class AmbiguousCreateError(RuntimeError): class AmbiguousCreateError(RuntimeError):
@ -61,6 +75,10 @@ def _parse_args(argv: list[str]) -> argparse.Namespace:
parser.add_argument("--time", default="") parser.add_argument("--time", default="")
parser.add_argument("--weekday", action="append", default=[]) parser.add_argument("--weekday", action="append", default=[])
parser.add_argument("--weekdays", action="append", default=[]) parser.add_argument("--weekdays", action="append", default=[])
parser.add_argument("--mention", action="append", default=[])
parser.add_argument("--mentions", action="append", default=[])
parser.add_argument("--mention-all", "--all", dest="mention_all", action="store_true")
parser.add_argument("--no-mention", action="store_true")
delay = parser.add_mutually_exclusive_group() delay = parser.add_mutually_exclusive_group()
delay.add_argument("--delay-seconds", "--delay_seconds", dest="delay_seconds", type=int) delay.add_argument("--delay-seconds", "--delay_seconds", dest="delay_seconds", type=int)
@ -79,15 +97,19 @@ def _require_env(name: str) -> str:
return value return value
def _conversation_identity() -> tuple[dict[str, str], dict[str, str], str]: def _conversation_identity() -> tuple[ScheduledTaskTarget, ScheduledTaskCreator, str]:
from_wechat_id = _require_env("ROBOT_FROM_WX_ID") from_wechat_id = _require_env("ROBOT_FROM_WX_ID")
sender_wechat_id = os.environ.get("ROBOT_SENDER_WX_ID", "").strip() sender_wechat_id = os.environ.get("ROBOT_SENDER_WX_ID", "").strip()
if from_wechat_id.endswith("@chatroom"): if from_wechat_id.endswith("@chatroom"):
if not sender_wechat_id: if not sender_wechat_id:
raise ValueError("当前群聊缺少消息发送人微信 ID") raise ValueError("当前群聊缺少消息发送人微信 ID")
target = {"wechat_id": from_wechat_id, "type": "chat_room"} target: ScheduledTaskTarget = {
creator = { "wechat_id": from_wechat_id,
"type": "chat_room",
"mention_wechat_ids": [],
}
creator: ScheduledTaskCreator = {
"type": "chat_room", "type": "chat_room",
"wechat_id": sender_wechat_id, "wechat_id": sender_wechat_id,
"chat_room_id": from_wechat_id, "chat_room_id": from_wechat_id,
@ -95,7 +117,11 @@ def _conversation_identity() -> tuple[dict[str, str], dict[str, str], str]:
return target, creator, "当前群聊" return target, creator, "当前群聊"
creator_wechat_id = sender_wechat_id or from_wechat_id creator_wechat_id = sender_wechat_id or from_wechat_id
target = {"wechat_id": from_wechat_id, "type": "friend"} target = {
"wechat_id": from_wechat_id,
"type": "friend",
"mention_wechat_ids": [],
}
creator = {"type": "friend", "wechat_id": creator_wechat_id, "chat_room_id": ""} creator = {"type": "friend", "wechat_id": creator_wechat_id, "chat_room_id": ""}
return target, creator, "当前私聊" return target, creator, "当前私聊"
@ -139,6 +165,31 @@ def _parse_weekdays(values: list[str]) -> list[int]:
return result return result
def _parse_mentions(mention_values: list[str], mentions_values: list[str]) -> list[str]:
parsed = [value.strip() for value in mention_values if value.strip()]
for raw in mentions_values:
text = raw.strip()
if not text:
continue
try:
items = json.loads(text)
except json.JSONDecodeError as exc:
raise ValueError(f"mentions JSON 格式错误: {exc.msg}") from exc
if not isinstance(items, list) or any(not isinstance(item, str) for item in items):
raise ValueError("mentions 必须是字符串 JSON 数组")
parsed.extend(item.strip() for item in items if item.strip())
result: list[str] = []
seen: set[str] = set()
for mention in parsed:
key = mention.casefold()
if key not in seen:
seen.add(key)
result.append(mention)
return result
def _parse_run_at(value: str, now: datetime) -> int: def _parse_run_at(value: str, now: datetime) -> int:
text = value.strip() text = value.strip()
parsed: datetime | None = None parsed: datetime | None = None
@ -201,7 +252,124 @@ def _build_schedule_config(args: argparse.Namespace, now: datetime) -> dict[str,
return {"time": clock} return {"time": clock}
def _build_payload(args: argparse.Namespace) -> tuple[dict[str, Any], str]: def _member_text(member: dict[str, Any], field: str) -> str:
value = member.get(field)
return str(value).strip() if value is not None else ""
def _member_candidates(data: Any, chat_room_id: str) -> list[dict[str, Any]]:
if not isinstance(data, list):
raise RuntimeError("群成员查询接口返回的数据不是数组")
candidates: list[dict[str, Any]] = []
for item in data:
if not isinstance(item, dict):
continue
if _member_text(item, "chat_room_id") != chat_room_id:
continue
if item.get("is_leaved") not in (None, False, 0):
continue
if _member_text(item, "wechat_id"):
candidates.append(item)
return candidates
def _describe_member(member: dict[str, Any]) -> str:
remark = _member_text(member, "remark")
nickname = _member_text(member, "nickname")
if remark and nickname and remark != nickname:
return f"{remark}(昵称:{nickname}"
return remark or nickname or _member_text(member, "wechat_id")
def _pick_unique_member(
mention: str,
candidates: list[dict[str, Any]],
) -> dict[str, Any]:
keyword = mention.casefold()
match_groups = [
[item for item in candidates if _member_text(item, "remark").casefold() == keyword],
[item for item in candidates if _member_text(item, "nickname").casefold() == keyword],
[item for item in candidates if keyword in _member_text(item, "remark").casefold()],
[item for item in candidates if keyword in _member_text(item, "nickname").casefold()],
]
for matches in match_groups:
unique: dict[str, dict[str, Any]] = {}
for item in matches:
unique.setdefault(_member_text(item, "wechat_id"), item)
if len(unique) == 1:
return next(iter(unique.values()))
if len(unique) > 1:
labels = "".join(_describe_member(item) for item in list(unique.values())[:5])
raise ValueError(
f"群成员“{mention}”匹配到多人({labels}),请使用唯一的完整群备注或昵称"
)
raise ValueError(f"未找到当前群内未退群成员:“{mention}")
def _get_json(url: str, timeout: int = 15) -> dict[str, Any]:
request = urllib.request.Request(url, method="GET")
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
response_text = response.read().decode("utf-8", errors="replace")
except urllib.error.HTTPError as exc:
error_body = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(f"查询群成员接口返回 HTTP {exc.code}: {error_body}") from exc
except (urllib.error.URLError, TimeoutError, socket.timeout, ConnectionError) as exc:
raise RuntimeError(f"查询群成员失败:{exc}") from exc
if not response_text.strip():
raise RuntimeError("群成员查询接口返回空响应")
try:
result = json.loads(response_text)
except json.JSONDecodeError as exc:
raise RuntimeError("群成员查询接口返回了无效 JSON") from exc
if not isinstance(result, dict):
raise RuntimeError("群成员查询接口响应不是 JSON 对象")
return result
def _resolve_mentions(
client_port: str,
chat_room_id: str,
mentions: list[str],
) -> list[dict[str, str]]:
resolved: list[dict[str, str]] = []
seen_wechat_ids: set[str] = set()
for mention in mentions:
query = urllib.parse.urlencode({"chat_room_id": chat_room_id, "keyword": mention})
url = (
f"http://127.0.0.1:{client_port}"
f"/api/v1/robot/chat-room/not-left-members?{query}"
)
response = _get_json(url)
if response.get("code") != 200:
raise RuntimeError(str(response.get("message") or "查询群成员失败"))
member = _pick_unique_member(
mention,
_member_candidates(response.get("data"), chat_room_id),
)
wechat_id = _member_text(member, "wechat_id")
if wechat_id in seen_wechat_ids:
continue
seen_wechat_ids.add(wechat_id)
resolved.append(
{
"query": mention,
"wechat_id": wechat_id,
"display_name": _member_text(member, "remark")
or _member_text(member, "nickname")
or mention,
}
)
return resolved
def _build_payload(
args: argparse.Namespace,
) -> tuple[dict[str, Any], str, dict[str, Any]]:
name = args.name.strip() name = args.name.strip()
content = args.content.strip() content = args.content.strip()
ai_prompt = args.ai_prompt.strip() ai_prompt = args.ai_prompt.strip()
@ -216,6 +384,37 @@ def _build_payload(args: argparse.Namespace) -> tuple[dict[str, Any], str]:
raise ValueError("content 和 ai-prompt 至少需要提供一项") raise ValueError("content 和 ai-prompt 至少需要提供一项")
target, creator, target_label = _conversation_identity() target, creator, target_label = _conversation_identity()
mentions = _parse_mentions(args.mention, args.mentions)
mention_mode_count = int(bool(mentions)) + int(args.mention_all) + int(args.no_mention)
if mention_mode_count > 1:
raise ValueError("mention-all、no-mention 和 mention/mentions 不能同时使用")
mention_summary: dict[str, Any]
if args.mention_all:
if target["type"] != "chat_room":
raise ValueError("当前会话不是群聊,不能在定时任务中艾特所有人")
target["mention_wechat_ids"] = [MENTION_ALL_WECHAT_ID]
mention_summary = {"mode": "all", "display_names": ["所有人"]}
elif mentions:
if target["type"] != "chat_room":
raise ValueError("当前会话不是群聊,不能在定时任务中艾特群成员")
client_port = _require_env("ROBOT_WECHAT_CLIENT_PORT")
resolved_mentions = _resolve_mentions(client_port, target["wechat_id"], mentions)
target["mention_wechat_ids"] = [item["wechat_id"] for item in resolved_mentions]
mention_summary = {
"mode": "custom",
"display_names": [item["display_name"] for item in resolved_mentions],
}
elif args.no_mention:
target["mention_wechat_ids"] = []
mention_summary = {"mode": "none", "display_names": []}
elif target["type"] == "chat_room":
target["mention_wechat_ids"] = [creator["wechat_id"]]
mention_summary = {"mode": "creator", "display_names": []}
else:
target["mention_wechat_ids"] = []
mention_summary = {"mode": "none", "display_names": []}
schedule_config = _build_schedule_config(args, datetime.now(SHANGHAI_TZ)) schedule_config = _build_schedule_config(args, datetime.now(SHANGHAI_TZ))
payload: dict[str, Any] = { payload: dict[str, Any] = {
"name": name, "name": name,
@ -228,7 +427,7 @@ def _build_payload(args: argparse.Namespace) -> tuple[dict[str, Any], str]:
"ai_prompt": ai_prompt, "ai_prompt": ai_prompt,
"creator": creator, "creator": creator,
} }
return payload, target_label return payload, target_label, mention_summary
def _post_json(url: str, payload: dict[str, Any], timeout: int = 30) -> dict[str, Any]: def _post_json(url: str, payload: dict[str, Any], timeout: int = 30) -> dict[str, Any]:
@ -293,7 +492,11 @@ def _format_next_run(timestamp: Any) -> str | None:
return datetime.fromtimestamp(value, SHANGHAI_TZ).strftime("%Y-%m-%d %H:%M:%S Asia/Shanghai") return datetime.fromtimestamp(value, SHANGHAI_TZ).strftime("%Y-%m-%d %H:%M:%S Asia/Shanghai")
def _success_output(data: dict[str, Any], target_label: str) -> dict[str, Any]: def _success_output(
data: dict[str, Any],
target_label: str,
mention_summary: dict[str, Any],
) -> dict[str, Any]:
targets = data.get("targets") targets = data.get("targets")
first_target = targets[0] if isinstance(targets, list) and targets else {} first_target = targets[0] if isinstance(targets, list) and targets else {}
if not isinstance(first_target, dict): if not isinstance(first_target, dict):
@ -312,6 +515,7 @@ def _success_output(data: dict[str, Any], target_label: str) -> dict[str, Any]:
"uses_ai_prompt": bool(data.get("ai_prompt")), "uses_ai_prompt": bool(data.get("ai_prompt")),
"target_label": target_label, "target_label": target_label,
"target_type": first_target.get("type"), "target_type": first_target.get("type"),
"mention": mention_summary,
}, },
} }
@ -319,12 +523,18 @@ def _success_output(data: dict[str, Any], target_label: str) -> dict[str, Any]:
def main(argv: list[str]) -> int: def main(argv: list[str]) -> int:
try: try:
args = _parse_args(argv) args = _parse_args(argv)
payload, target_label = _build_payload(args) payload, target_label, mention_summary = _build_payload(args)
if args.dry_run: if args.dry_run:
print( print(
json.dumps( json.dumps(
{"ok": True, "dry_run": True, "target_label": target_label, "payload": payload}, {
"ok": True,
"dry_run": True,
"target_label": target_label,
"mention": mention_summary,
"payload": payload,
},
ensure_ascii=False, ensure_ascii=False,
indent=2, indent=2,
) )
@ -335,7 +545,13 @@ def main(argv: list[str]) -> int:
url = f"http://127.0.0.1:{client_port}/api/v1/robot/scheduled-tasks" url = f"http://127.0.0.1:{client_port}/api/v1/robot/scheduled-tasks"
response = _post_json(url, payload) response = _post_json(url, payload)
data = _unwrap_api_response(response) data = _unwrap_api_response(response)
print(json.dumps(_success_output(data, target_label), ensure_ascii=False, indent=2)) print(
json.dumps(
_success_output(data, target_label, mention_summary),
ensure_ascii=False,
indent=2,
)
)
return 0 return 0
except (ValueError, RuntimeError) as exc: except (ValueError, RuntimeError) as exc:
print(f"创建定时任务失败:{exc}") print(f"创建定时任务失败:{exc}")

View File

@ -1,21 +1,21 @@
--- ---
name: send-mention-message name: send-mention-message
description: "发送艾特/@/提及消息技能。当需要在当前群聊中艾特某个人或某些人,或用户要求你艾特某个/某些群成员时使用。" description: "在当前微信群聊中发送真正的艾特/@/提及消息。用户要求 @ 某个或多个群成员、@所有人、提醒全体成员、通知群里所有人时使用;支持附带正文并可在发送后结束当前 Agent 对话。"
argument-hint: "需要 mention可选 content。mention 可重复传入多个昵称或备注。"
--- ---
# Send Mention Message Skill # Send Mention Message Skill
## 描述 ## 描述
这是一个在当前微信群聊中发送艾特消息的技能 在当前微信群聊中发送艾特消息,支持指定一个或多个成员,也支持微信原生的 `@所有人`
技能脚本位于 `scripts/send_mention_message.py`,会根据用户提供的昵称或备注在当前群成员表里查找未退群成员,得到微信 ID 后调用机器人客户端文本消息接口发送带 `at` 数组的消息 技能脚本位于 `scripts/send_mention_message.py`。指定成员时,根据昵称或备注查询当前群内未退群成员;@所有人时直接使用客户端协议值 `notify@all`。两种模式最终都会调用文本消息接口发送真正的 `at` 数组,不要把 `@昵称``@所有人` 当普通正文拼接
## 触发条件 ## 触发条件
- 需要艾特、@、提及某个群成员或多个群成员。 - 需要艾特、@、提及某个群成员或多个群成员。
- 用户要求「帮我艾特下 xxx」「@ 一下 xxx」「提一下 xxx 和 yyy」。 - 用户要求「帮我艾特下 xxx」「@ 一下 xxx」「提一下 xxx 和 yyy」。
- 用户要求「@所有人」「提醒全体成员」「通知群里所有人」。
- 需要在群聊里点名提醒某人。 - 需要在群聊里点名提醒某人。
私聊场景一般不触发本技能;脚本会校验 `ROBOT_FROM_WX_ID` 必须是群聊 ID。 私聊场景一般不触发本技能;脚本会校验 `ROBOT_FROM_WX_ID` 必须是群聊 ID。
@ -37,6 +37,10 @@ argument-hint: "需要 mention可选 content。mention 可重复传入多个
}, },
"description": "要艾特的多个群成员昵称或备注。" "description": "要艾特的多个群成员昵称或备注。"
}, },
"all": {
"type": "boolean",
"description": "是否 @所有人。用户明确要求 @所有人或通知全体成员时设为 true不能与 mention/mentions 同时使用。"
},
"content": { "content": {
"type": "string", "type": "string",
"description": "要发送的文本内容,可选。只艾特不附加正文时可以为空字符串。" "description": "要发送的文本内容,可选。只艾特不附加正文时可以为空字符串。"
@ -46,20 +50,23 @@ argument-hint: "需要 mention可选 content。mention 可重复传入多个
"description": "是否结束当前对话。当 Agent 已经完成艾特和消息发送、要说的话已说完、要做的事已做完时,设置为 true。" "description": "是否结束当前对话。当 Agent 已经完成艾特和消息发送、要说的话已说完、要做的事已做完时,设置为 true。"
} }
}, },
"anyOf": [{ "required": ["mention"] }, { "required": ["mentions"] }], "anyOf": [{ "required": ["mention"] }, { "required": ["mentions"] }, { "required": ["all"] }],
"additionalProperties": false "additionalProperties": false
} }
``` ```
对应命令行参数: 对应命令行参数:
- `--mention <昵称或备注>` 必填或可重复传入 - `--mention <昵称或备注>` 指定成员时使用,可重复传入
- `--mentions <JSON数组>` 可选,用于一次传入多个昵称或备注 - `--mentions <JSON数组>` 指定成员时可选,用于一次传入多个昵称或备注
- `--all`(也支持 `--mention-all`)可选,用于真正 @所有人,不能与 mention 参数同用
- `--content <文本内容>` 可选 - `--content <文本内容>` 可选
- `--ended` 可选标志。当 Agent 已完成艾特和消息发送、要说的话已说完时传入。 - `--ended` 可选标志。当 Agent 已完成艾特和消息发送、要说的话已说完时传入。
## 成员匹配规则 ## 成员匹配规则
仅指定成员时执行以下匹配;`--all` 不查询成员表:
1. 只在当前群聊 `ROBOT_FROM_WX_ID` 对应的 `chat_room_members` 记录中查找。 1. 只在当前群聊 `ROBOT_FROM_WX_ID` 对应的 `chat_room_members` 记录中查找。
2. 只匹配 `is_leaved` 为空或 `0` 的成员,已经退群的成员不能被艾特。 2. 只匹配 `is_leaved` 为空或 `0` 的成员,已经退群的成员不能被艾特。
3. 使用用户给出的昵称或备注做模糊查询,字段优先级为 `remark`,然后是 `nickname` 3. 使用用户给出的昵称或备注做模糊查询,字段优先级为 `remark`,然后是 `nickname`
@ -71,26 +78,33 @@ argument-hint: "需要 mention可选 content。mention 可重复传入多个
## 执行步骤 ## 执行步骤
1. 判断用户是否需要在群聊中艾特某人或某些人。 1. 判断用户是否需要在群聊中艾特某人或某些人。
2. 从用户输入中提取要艾特的昵称或备注,写入 `mention``mentions`如用户要求附带正文,写入 `content` 2. 指定成员时,把用户原话中的昵称或备注写入 `mention`/`mentions`@所有人时设置 `all: true` 并使用 `--all`如用户要求附带正文,写入 `content`
3. 在该技能目录执行脚本,例如: 3. 在该技能目录执行脚本,例如:
```bash ```bash
python3 scripts/send_mention_message.py --mention '张三' --content '看一下这个' python3 scripts/send_mention_message.py --mention '张三' --content '看一下这个'
``` ```
用户要求 @所有人时传 `--all`,不要把“所有人”当成员昵称查询:
```bash
python3 scripts/send_mention_message.py --all --content '请大家查看群公告'
```
当 Agent 认为任务已完成、对话可以结束时,加上 `--ended` 标志: 当 Agent 认为任务已完成、对话可以结束时,加上 `--ended` 标志:
```bash ```bash
python3 scripts/send_mention_message.py --mention '张三' --content '看一下这个' --ended python3 scripts/send_mention_message.py --mention '张三' --content '看一下这个' --ended
``` ```
4. 脚本会查询数据库表 `chat_room_members`,找到当前群内未退群成员的微信 ID。如果数据库没查询到这个人你可能需要查询你的记忆看看有没有一个人的别称叫这个名字 4. 指定成员时,脚本查询数据库表 `chat_room_members` 并解析微信 ID`--all` 时跳过数据库查询,直接发送 `at: ["notify@all"]`。如果指定成员未命中,可以查询记忆里是否记录了对方的别称
5. 脚本调用客户端接口 `POST http://127.0.0.1:{ROBOT_WECHAT_CLIENT_PORT}/api/v1/robot/message/send/text` 发送消息,请求体包含 `to_wxid`、`content`、`at`。 5. 脚本调用客户端接口 `POST http://127.0.0.1:{ROBOT_WECHAT_CLIENT_PORT}/api/v1/robot/message/send/text` 发送消息,请求体包含 `to_wxid`、`content`、`at`。
## 校验规则 ## 校验规则
- `ROBOT_FROM_WX_ID` 必须是群聊 ID通常以 `@chatroom` 结尾。 - `ROBOT_FROM_WX_ID` 必须是群聊 ID通常以 `@chatroom` 结尾。
- 至少提供一个 `mention` - 至少提供一个 `mention`/`mentions`,或者传 `--all`
- `--all` 必须独占,不能再指定成员。
- 每个要艾特的人都必须能在当前群内匹配到未退群成员。 - 每个要艾特的人都必须能在当前群内匹配到未退群成员。
- 如果同一个微信 ID 被多个昵称命中,只会艾特一次。 - 如果同一个微信 ID 被多个昵称命中,只会艾特一次。
@ -107,6 +121,6 @@ python3 scripts/send_mention_message.py --mention '张三' --content '看一下
## 回复要求 ## 回复要求
- 成功时,脚本输出「艾特消息发送成功」,表示消息已通过客户端接口直接发送,无需 AI 智能体再做额外处理。 - 成功时,脚本输出「艾特消息发送成功」或「艾特所有人消息发送成功」,表示消息已通过客户端接口直接发送,无需 AI 智能体再做额外处理。
- 如果传入 `--ended`,输出末尾会追加 `ended`Agent 会自动结束对话。 - 如果传入 `--ended`,输出末尾会追加 `ended`Agent 会自动结束对话。
- 失败时,返回脚本输出的具体错误信息。 - 失败时,返回脚本输出的具体错误信息。

View File

@ -59,17 +59,15 @@ def _ensure_skill_venv_python() -> None:
os.execv(str(venv_python), [str(venv_python), str(Path(__file__).resolve()), *sys.argv[1:]]) os.execv(str(venv_python), [str(venv_python), str(Path(__file__).resolve()), *sys.argv[1:]])
_ensure_skill_venv_python()
try:
import pymysql # type: ignore # noqa: E402
except ModuleNotFoundError:
_run_bootstrap()
_py = _get_python_executable()
os.execv(_py, [_py, str(Path(__file__).resolve()), *sys.argv[1:]])
def _mysql_connect(): def _mysql_connect():
_ensure_skill_venv_python()
try:
import pymysql # type: ignore
except ModuleNotFoundError:
_run_bootstrap()
venv_python = _skill_venv_python()
os.execv(str(venv_python), [str(venv_python), str(Path(__file__).resolve()), *sys.argv[1:]])
host = os.environ.get("MYSQL_HOST", "127.0.0.1") host = os.environ.get("MYSQL_HOST", "127.0.0.1")
port = int(os.environ.get("MYSQL_PORT", "3306")) port = int(os.environ.get("MYSQL_PORT", "3306"))
user = os.environ.get("MYSQL_USER", "root") user = os.environ.get("MYSQL_USER", "root")
@ -126,10 +124,11 @@ def _expand_json_array_values(values: list[str], label: str) -> list[str]:
return expanded return expanded
def _parse_cli_params(argv: list[str]) -> tuple[list[str], str, bool]: def _parse_cli_params(argv: list[str]) -> tuple[list[str], str, bool, bool]:
parser = argparse.ArgumentParser(add_help=False) parser = argparse.ArgumentParser(add_help=False)
parser.add_argument("--mention", action="append", default=[]) parser.add_argument("--mention", action="append", default=[])
parser.add_argument("--mentions", action="append", default=[]) parser.add_argument("--mentions", action="append", default=[])
parser.add_argument("--all", "--mention-all", dest="mention_all", action="store_true")
parser.add_argument("--content", default="") parser.add_argument("--content", default="")
parser.add_argument("--ended", action="store_true", default=False) parser.add_argument("--ended", action="store_true", default=False)
@ -146,7 +145,10 @@ def _parse_cli_params(argv: list[str]) -> tuple[list[str], str, bool]:
seen.add(key) seen.add(key)
deduped.append(mention) deduped.append(mention)
return deduped, namespace.content, namespace.ended if namespace.mention_all and deduped:
raise ValueError("all 不能和 mention 或 mentions 同时使用")
return deduped, namespace.content, namespace.ended, namespace.mention_all
def _escape_like(value: str) -> str: def _escape_like(value: str) -> str:
@ -226,17 +228,17 @@ def _send_text_message(client_port: str, to_wxid: str, content: str, at_wechat_i
def main() -> int: def main() -> int:
if len(sys.argv) < 2: if len(sys.argv) < 2:
sys.stdout.write("缺少要艾特的成员昵称或备注\n") sys.stdout.write("缺少要艾特的成员昵称、备注或 --all\n")
return 1 return 1
try: try:
mentions, content, ended = _parse_cli_params(sys.argv[1:]) mentions, content, ended, mention_all = _parse_cli_params(sys.argv[1:])
except (ValueError, json.JSONDecodeError) as exc: except (ValueError, json.JSONDecodeError) as exc:
sys.stdout.write(f"参数格式错误: {exc}\n") sys.stdout.write(f"参数格式错误: {exc}\n")
return 1 return 1
if not mentions: if not mention_all and not mentions:
sys.stdout.write("缺少要艾特的成员昵称或备注\n") sys.stdout.write("缺少要艾特的成员昵称、备注或 --all\n")
return 1 return 1
chat_room_id = os.environ.get("ROBOT_FROM_WX_ID", "").strip() chat_room_id = os.environ.get("ROBOT_FROM_WX_ID", "").strip()
@ -252,6 +254,9 @@ def main() -> int:
sys.stdout.write("环境变量 ROBOT_WECHAT_CLIENT_PORT 未配置\n") sys.stdout.write("环境变量 ROBOT_WECHAT_CLIENT_PORT 未配置\n")
return 1 return 1
if mention_all:
at_wechat_ids = ["notify@all"]
else:
try: try:
conn = _mysql_connect() conn = _mysql_connect()
except Exception as exc: except Exception as exc:
@ -278,7 +283,7 @@ def main() -> int:
try: try:
_send_text_message(client_port, chat_room_id, content, at_wechat_ids) _send_text_message(client_port, chat_room_id, content, at_wechat_ids)
sys.stdout.write("艾特消息发送成功\n") sys.stdout.write("艾特所有人消息发送成功\n" if mention_all else "艾特消息发送成功\n")
if ended: if ended:
sys.stdout.write("ended") sys.stdout.write("ended")
return 0 return 0