diff --git a/skills/create-scheduled-task/SKILL.md b/skills/create-scheduled-task/SKILL.md index acad206..8348879 100644 --- a/skills/create-scheduled-task/SKILL.md +++ b/skills/create-scheduled-task/SKILL.md @@ -1,6 +1,6 @@ --- name: create-scheduled-task -description: "创建当前微信会话中的提醒或定时任务。当用户说“X 分钟后提醒我 Y”“X 分钟提醒我 Y”“过一会提醒我”“每天/每周/工作日几点提醒我”“帮我设个提醒”,或“创建一个……定时任务”时使用。支持一次性延时、每日、每周和中国法定工作日任务,也支持按时生成 AI 内容。" +description: "创建当前微信会话中的提醒或定时任务。当用户说“X 分钟后提醒我/群友/所有人 Y”“过一会提醒我”“每天/每周/工作日几点提醒我”“帮我设个提醒”或“创建一个……定时任务”时使用。支持一次性延时、每日、每周和中国法定工作日任务,可在群聊中真正 @ 指定成员或 @所有人,也支持按时生成 AI 内容。" --- # 创建定时任务 @@ -9,11 +9,11 @@ description: "创建当前微信会话中的提醒或定时任务。当用户说 ## 执行流程 -1. 从用户原话中提取任务名称、执行规则和发送内容。 +1. 从用户原话中提取任务名称、执行规则、发送内容,以及要 @ 创建人、指定群成员、所有人还是不 @。 2. 缺少执行时间或发送内容时,只追问缺失项,不运行脚本。 3. 按下表选择调度类型和参数。 4. 在本 Skill 目录执行 `scripts/create_scheduled_task.py`,且不要在真实请求中传 `--dry-run`。 -5. 仅在脚本返回 `ok: true` 后确认创建成功。回复任务名称、执行规则、下一次执行时间、提醒内容和发送位置;群聊任务同时说明触发时会自动 @ 创建人。 +5. 仅在脚本返回 `ok: true` 后确认创建成功。回复任务名称、执行规则、下一次执行时间、提醒内容、发送位置和实际 @ 对象。以返回的 `task.mention` 为准:`all` 说明会 @所有人,`custom` 列出 `display_names`,`creator` 说明会 @ 创建人,`none` 说明不 @ 任何人。 6. 脚本失败时立即向用户反馈原始错误含义,不得声称任务已创建。 ## 调度规则 @@ -41,11 +41,24 @@ description: "创建当前微信会话中的提醒或定时任务。当用户说 ## 内容规则 - 对普通提醒使用 `--content`。默认把“提醒我喝水”整理为面向用户的 `提醒:喝水`,但用户给出精确文案时保持原文。 +- 真正的 @ 由 `--mention`/`--mentions`/`--mention-all` 单独配置,不要在正文中拼接普通文本 `@昵称` 或 `@所有人`。若正文只是以被提醒人的名字作称呼(如“张三,该吃饭了”),通常把 `--content` 整理为“该吃饭了”,避免客户端自动添加 `@张三` 后重复人名;用户明确要求保留精确正文时除外。 - 对“每天生成一份早报”这类动态内容使用 `--ai-prompt`,不要把普通固定提醒升级为 AI 任务。 - 可以同时传 `--content` 和 `--ai-prompt`;执行时先发送固定文本,再发送 AI 结果。 - 至少传 `--content` 或 `--ai-prompt` 之一。 - 任务名应简短、可识别,不超过 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_WECHAT_CLIENT_PORT`:机器人客户端端口。 -私聊中把当前好友作为创建人和发送目标。群聊中把当前发言人作为创建人、当前群聊作为发送目标。因此,群聊里的“提醒我”会在当前群聊发送,并在任务触发后发送的第一条文本中自动 @ 创建人,不会私聊群成员。成功回复时明确说明这一点。不要让用户提供或猜测这些 ID。 +私聊中把当前好友作为创建人和发送目标。群聊中把当前发言人作为创建人、当前群聊作为发送目标。因此任务始终发回当前会话,不会私聊群成员。每个发送目标都显式保存 `mention_wechat_ids`:群聊里的“提醒我”保存创建人 ID,明确指定成员时保存成员 ID,`--mention-all` 保存 `notify@all`,传 `--no-mention` 或私聊任务时保存空数组。成功回复必须按脚本实际返回说明,不能笼统声称总会 @ 创建人。不要让用户提供或猜测这些 ID。 ## 脚本参数 @@ -65,6 +78,10 @@ description: "创建当前微信会话中的提醒或定时任务。当用户说 --ai-prompt <动态内容提示词> 与 --content 至少一个 --time daily/weekly/cn_workday 必填 --weekdays weekly 必填,1=周一,7=周日 +--mention <当前群成员昵称或备注> 可选,可重复;指定真正 @ 的成员 +--mentions <字符串JSON数组> 可选;一次指定多个真正 @ 的成员 +--mention-all 可选;真正 @ 当前群所有人 +--no-mention 可选;明确不 @ 任何人,不能与其他 mention 参数同用 --delay-seconds <整数> delay_once 四选一 --delay-minutes <整数> 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 '提醒:喝水' ``` +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 @@ -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 配置缺失或调度器未初始化。 - 创建接口不是幂等接口。发生超时、连接中断、无效响应,或错误中含“任务已保存,但刷新调度器失败”时,任务可能已经入库;明确告知用户先到定时任务列表核对,禁止自动重试,以免创建重复任务。 - 普通好友和普通群成员最多创建 5 个任务;群主、群管理员和后台管理员不受此配额限制。 diff --git a/skills/create-scheduled-task/agents/openai.yaml b/skills/create-scheduled-task/agents/openai.yaml index b911dad..7ac81cb 100644 --- a/skills/create-scheduled-task/agents/openai.yaml +++ b/skills/create-scheduled-task/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "创建定时任务" - short_description: "根据当前微信会话创建一次性提醒、每日、每周或法定工作日任务" - default_prompt: "使用 $create-scheduled-task 在 10 分钟后提醒我喝水。" + short_description: "创建当前会话提醒任务,并可定时 @ 指定群成员或所有人" + default_prompt: "使用 $create-scheduled-task 在 5 分钟后 @所有人,提醒大家查看群公告。" diff --git a/skills/create-scheduled-task/scripts/create_scheduled_task.py b/skills/create-scheduled-task/scripts/create_scheduled_task.py index 5a2fa32..a162b24 100644 --- a/skills/create-scheduled-task/scripts/create_scheduled_task.py +++ b/skills/create-scheduled-task/scripts/create_scheduled_task.py @@ -10,9 +10,10 @@ import re import socket import sys import urllib.error +import urllib.parse import urllib.request from datetime import datetime, timedelta, timezone -from typing import Any, NoReturn +from typing import Any, Literal, NoReturn, TypedDict try: from zoneinfo import ZoneInfo @@ -23,6 +24,19 @@ sys.stderr = sys.stdout SCHEDULE_TYPES = ("delay_once", "daily", "weekly", "cn_workday") 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): @@ -61,6 +75,10 @@ def _parse_args(argv: list[str]) -> argparse.Namespace: parser.add_argument("--time", default="") parser.add_argument("--weekday", 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.add_argument("--delay-seconds", "--delay_seconds", dest="delay_seconds", type=int) @@ -79,15 +97,19 @@ def _require_env(name: str) -> str: 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") sender_wechat_id = os.environ.get("ROBOT_SENDER_WX_ID", "").strip() if from_wechat_id.endswith("@chatroom"): if not sender_wechat_id: raise ValueError("当前群聊缺少消息发送人微信 ID") - target = {"wechat_id": from_wechat_id, "type": "chat_room"} - creator = { + target: ScheduledTaskTarget = { + "wechat_id": from_wechat_id, + "type": "chat_room", + "mention_wechat_ids": [], + } + creator: ScheduledTaskCreator = { "type": "chat_room", "wechat_id": sender_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, "当前群聊" 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": ""} return target, creator, "当前私聊" @@ -139,6 +165,31 @@ def _parse_weekdays(values: list[str]) -> list[int]: 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: text = value.strip() parsed: datetime | None = None @@ -201,7 +252,124 @@ def _build_schedule_config(args: argparse.Namespace, now: datetime) -> dict[str, 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() content = args.content.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 至少需要提供一项") 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)) payload: dict[str, Any] = { "name": name, @@ -228,7 +427,7 @@ def _build_payload(args: argparse.Namespace) -> tuple[dict[str, Any], str]: "ai_prompt": ai_prompt, "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]: @@ -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") -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") first_target = targets[0] if isinstance(targets, list) and targets else {} 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")), "target_label": target_label, "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: try: args = _parse_args(argv) - payload, target_label = _build_payload(args) + payload, target_label, mention_summary = _build_payload(args) if args.dry_run: print( 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, 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" response = _post_json(url, payload) 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 except (ValueError, RuntimeError) as exc: print(f"创建定时任务失败:{exc}") diff --git a/skills/send-mention-message/SKILL.md b/skills/send-mention-message/SKILL.md index 79aab96..dede3c5 100644 --- a/skills/send-mention-message/SKILL.md +++ b/skills/send-mention-message/SKILL.md @@ -1,21 +1,21 @@ --- name: send-mention-message -description: "发送艾特/@/提及消息技能。当需要在当前群聊中艾特某个人或某些人,或用户要求你艾特某个/某些群成员时使用。" -argument-hint: "需要 mention;可选 content。mention 可重复传入多个昵称或备注。" +description: "在当前微信群聊中发送真正的艾特/@/提及消息。用户要求 @ 某个或多个群成员、@所有人、提醒全体成员、通知群里所有人时使用;支持附带正文并可在发送后结束当前 Agent 对话。" --- # Send Mention Message Skill ## 描述 -这是一个在当前微信群聊中发送艾特消息的技能。 +在当前微信群聊中发送艾特消息,支持指定一个或多个成员,也支持微信原生的 `@所有人`。 -技能脚本位于 `scripts/send_mention_message.py`,会根据用户提供的昵称或备注在当前群成员表里查找未退群成员,得到微信 ID 后调用机器人客户端文本消息接口发送带 `at` 数组的消息。 +技能脚本位于 `scripts/send_mention_message.py`。指定成员时,根据昵称或备注查询当前群内未退群成员;@所有人时直接使用客户端协议值 `notify@all`。两种模式最终都会调用文本消息接口发送真正的 `at` 数组,不要把 `@昵称` 或 `@所有人` 当普通正文拼接。 ## 触发条件 - 需要艾特、@、提及某个群成员或多个群成员。 - 用户要求「帮我艾特下 xxx」「@ 一下 xxx」「提一下 xxx 和 yyy」。 +- 用户要求「@所有人」「提醒全体成员」「通知群里所有人」。 - 需要在群聊里点名提醒某人。 私聊场景一般不触发本技能;脚本会校验 `ROBOT_FROM_WX_ID` 必须是群聊 ID。 @@ -37,6 +37,10 @@ argument-hint: "需要 mention;可选 content。mention 可重复传入多个 }, "description": "要艾特的多个群成员昵称或备注。" }, + "all": { + "type": "boolean", + "description": "是否 @所有人。用户明确要求 @所有人或通知全体成员时设为 true,不能与 mention/mentions 同时使用。" + }, "content": { "type": "string", "description": "要发送的文本内容,可选。只艾特不附加正文时可以为空字符串。" @@ -46,20 +50,23 @@ argument-hint: "需要 mention;可选 content。mention 可重复传入多个 "description": "是否结束当前对话。当 Agent 已经完成艾特和消息发送、要说的话已说完、要做的事已做完时,设置为 true。" } }, - "anyOf": [{ "required": ["mention"] }, { "required": ["mentions"] }], + "anyOf": [{ "required": ["mention"] }, { "required": ["mentions"] }, { "required": ["all"] }], "additionalProperties": false } ``` 对应命令行参数: -- `--mention <昵称或备注>` 必填或可重复传入 -- `--mentions ` 可选,用于一次传入多个昵称或备注 +- `--mention <昵称或备注>` 指定成员时使用,可重复传入 +- `--mentions ` 指定成员时可选,用于一次传入多个昵称或备注 +- `--all`(也支持 `--mention-all`)可选,用于真正 @所有人,不能与 mention 参数同用 - `--content <文本内容>` 可选 - `--ended` 可选标志。当 Agent 已完成艾特和消息发送、要说的话已说完时传入。 ## 成员匹配规则 +仅指定成员时执行以下匹配;`--all` 不查询成员表: + 1. 只在当前群聊 `ROBOT_FROM_WX_ID` 对应的 `chat_room_members` 记录中查找。 2. 只匹配 `is_leaved` 为空或 `0` 的成员,已经退群的成员不能被艾特。 3. 使用用户给出的昵称或备注做模糊查询,字段优先级为 `remark`,然后是 `nickname`。 @@ -71,26 +78,33 @@ argument-hint: "需要 mention;可选 content。mention 可重复传入多个 ## 执行步骤 1. 判断用户是否需要在群聊中艾特某人或某些人。 -2. 从用户输入中提取要艾特的昵称或备注,写入 `mention` 或 `mentions`;如用户要求附带正文,写入 `content`。 +2. 指定成员时,把用户原话中的昵称或备注写入 `mention`/`mentions`;@所有人时设置 `all: true` 并使用 `--all`。如用户要求附带正文,写入 `content`。 3. 在该技能目录执行脚本,例如: ```bash python3 scripts/send_mention_message.py --mention '张三' --content '看一下这个' ``` +用户要求 @所有人时传 `--all`,不要把“所有人”当成员昵称查询: + +```bash +python3 scripts/send_mention_message.py --all --content '请大家查看群公告' +``` + 当 Agent 认为任务已完成、对话可以结束时,加上 `--ended` 标志: ```bash 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`。 ## 校验规则 - `ROBOT_FROM_WX_ID` 必须是群聊 ID,通常以 `@chatroom` 结尾。 -- 至少提供一个 `mention`。 +- 至少提供一个 `mention`/`mentions`,或者传 `--all`。 +- `--all` 必须独占,不能再指定成员。 - 每个要艾特的人都必须能在当前群内匹配到未退群成员。 - 如果同一个微信 ID 被多个昵称命中,只会艾特一次。 @@ -107,6 +121,6 @@ python3 scripts/send_mention_message.py --mention '张三' --content '看一下 ## 回复要求 -- 成功时,脚本输出「艾特消息发送成功」,表示消息已通过客户端接口直接发送,无需 AI 智能体再做额外处理。 +- 成功时,脚本输出「艾特消息发送成功」或「艾特所有人消息发送成功」,表示消息已通过客户端接口直接发送,无需 AI 智能体再做额外处理。 - 如果传入 `--ended`,输出末尾会追加 `ended`,Agent 会自动结束对话。 - 失败时,返回脚本输出的具体错误信息。 diff --git a/skills/send-mention-message/scripts/send_mention_message.py b/skills/send-mention-message/scripts/send_mention_message.py index af4cfc1..e828cf7 100644 --- a/skills/send-mention-message/scripts/send_mention_message.py +++ b/skills/send-mention-message/scripts/send_mention_message.py @@ -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:]]) -_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(): + _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") port = int(os.environ.get("MYSQL_PORT", "3306")) 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 -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.add_argument("--mention", 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("--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) 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: @@ -226,17 +228,17 @@ def _send_text_message(client_port: str, to_wxid: str, content: str, at_wechat_i def main() -> int: if len(sys.argv) < 2: - sys.stdout.write("缺少要艾特的成员昵称或备注\n") + sys.stdout.write("缺少要艾特的成员昵称、备注或 --all\n") return 1 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: sys.stdout.write(f"参数格式错误: {exc}\n") return 1 - if not mentions: - sys.stdout.write("缺少要艾特的成员昵称或备注\n") + if not mention_all and not mentions: + sys.stdout.write("缺少要艾特的成员昵称、备注或 --all\n") return 1 chat_room_id = os.environ.get("ROBOT_FROM_WX_ID", "").strip() @@ -252,33 +254,36 @@ def main() -> int: sys.stdout.write("环境变量 ROBOT_WECHAT_CLIENT_PORT 未配置\n") return 1 - try: - conn = _mysql_connect() - except Exception as exc: - sys.stdout.write(f"数据库连接失败: {exc}\n") - return 1 - - try: - at_wechat_ids, missing = _resolve_mentions(conn, chat_room_id, mentions) - except Exception as exc: - sys.stdout.write(f"查询群成员失败: {exc}\n") - return 1 - finally: + if mention_all: + at_wechat_ids = ["notify@all"] + else: try: - conn.close() - except Exception: - pass + conn = _mysql_connect() + except Exception as exc: + sys.stdout.write(f"数据库连接失败: {exc}\n") + return 1 - if missing: - sys.stdout.write(f"未找到当前群内未退群成员: {', '.join(missing)}\n") - return 1 - if not at_wechat_ids: - sys.stdout.write("未找到可艾特的群成员\n") - return 1 + try: + at_wechat_ids, missing = _resolve_mentions(conn, chat_room_id, mentions) + except Exception as exc: + sys.stdout.write(f"查询群成员失败: {exc}\n") + return 1 + finally: + try: + conn.close() + except Exception: + pass + + if missing: + sys.stdout.write(f"未找到当前群内未退群成员: {', '.join(missing)}\n") + return 1 + if not at_wechat_ids: + sys.stdout.write("未找到可艾特的群成员\n") + return 1 try: _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: sys.stdout.write("ended") return 0 @@ -299,4 +304,4 @@ if __name__ == "__main__": raise except Exception: traceback.print_exc(file=sys.stdout) - raise SystemExit(1) \ No newline at end of file + raise SystemExit(1)