From 784ff49529cebc5c50bd48de400e1060e983d285 Mon Sep 17 00:00:00 2001 From: hp0912 <809211365@qq.com> Date: Thu, 17 Sep 2026 21:24:48 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BC=98=E5=8C=96=E5=8F=91=E9=80=81?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E6=8A=80=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- skills/send-complex-message/SKILL.md | 82 ++++++--- .../scripts/send_complex_message.py | 106 +++++++----- tests/test_send_complex_message.py | 158 +++++++++++++++++- 3 files changed, 277 insertions(+), 69 deletions(-) diff --git a/skills/send-complex-message/SKILL.md b/skills/send-complex-message/SKILL.md index 41b26a0..3f57fed 100644 --- a/skills/send-complex-message/SKILL.md +++ b/skills/send-complex-message/SKILL.md @@ -1,6 +1,6 @@ --- name: send-complex-message -description: "在当前微信会话中发送纯文本、引用回复或群聊艾特/@/提及消息,也可按时间、关键词、消息类型和发送人查询当前群聊或私聊最近24小时内的聊天记录。用户要求发送、艾特、引用回复或查找近期历史消息时使用,可在发送后结束当前 Agent 对话。" +description: "在当前微信会话中发送纯文本、引用回复或群聊艾特/@/提及消息,支持用昵称、曾用名、外号或微信 ID 查找群成员。也可按时间、关键词、消息类型和发送人查询当前群聊或私聊最近24小时内的聊天记录。用户要求发送、艾特、引用回复或查找近期历史消息时使用,可在发送后结束当前 Agent 对话。" --- # Send Complex Message Skill @@ -9,7 +9,9 @@ description: "在当前微信会话中发送纯文本、引用回复或群聊艾 本技能是当前微信会话中纯文本、艾特和引用回复的统一发送入口。仅艾特时不需要正文;引用消息必须有正文,也可以附带成员艾特参数。艾特支持指定一个或多个成员,也支持微信原生的 `@所有人`。 -技能脚本位于 `scripts/send_complex_message.py`。加上 `--query-history` 时只查询当前会话历史消息;发送模式统一调用客户端的 `/message/send/refermessage` 接口。`refer_message_id` 是可选参数:不传时由客户端调用普通文本消息方法,传入时发送引用消息。指定成员时,根据昵称或备注查询当前群内未退群成员;@所有人时使用客户端协议值 `notify@all`,不要把 `@昵称` 或 `@所有人` 当普通正文拼接。 +技能脚本位于 `scripts/send_complex_message.py`。加上 `--query-history` 时只查询当前会话历史消息;发送模式调用客户端的 `/message/send/refermessage` 接口。`refer_message_id` 是可选参数:不传时由客户端调用普通文本消息方法,传入时发送引用消息。 + +按名称艾特成员时,先查记忆找到对应的微信 ID,再发送消息。@所有人使用客户端协议值 `notify@all`,不要把 `@昵称` 或 `@所有人` 当普通正文拼接。 本技能不带引用 ID 时通过普通文本消息实现原生艾特,支持只艾特而不附加正文。带引用 ID 时,客户端接收 `at` 并显示艾特名称,但尚未实现引用消息中的原生艾特提醒,不能把引用发送成功表述为已经提醒成员。 @@ -93,18 +95,26 @@ python3 scripts/send_complex_message.py --query-history --start-time "$START_TIM "properties": { "mention": { "type": "string", - "description": "要艾特的群成员昵称或备注。按用户原话提取,不要改写。" + "description": "用户给出的成员名称。先查记忆,将找到的微信 ID 填入 mention_wxids;查不到或记忆工具不可用时,用此参数查当前群成员。" }, "mentions": { "type": "array", "items": { "type": "string" }, - "description": "要艾特的多个群成员昵称或备注。" + "description": "多个成员名称,用法同 mention。" + }, + "mention_wxids": { + "type": "array", + "items": { + "type": "string", + "pattern": "\\S" + }, + "description": "用户明确提供或从当前会话工具结果唯一确认的微信 ID。脚本只按微信 ID 校验当前群成员,可与 mention/mentions 混用,不得填入昵称或猜测 ID。" }, "all": { "type": "boolean", - "description": "是否 @所有人。用户明确要求 @所有人或通知全体成员时设为 true,不能与 mention/mentions 同时使用。" + "description": "是否 @所有人。用户明确要求 @所有人或通知全体成员时设为 true,不能与 mention/mentions/mention_wxids 同时使用。" }, "content": { "type": "string", @@ -136,6 +146,12 @@ python3 scripts/send_complex_message.py --query-history --start-time "$START_TIM "mentions": { "contains": { "type": "string", "pattern": "\\S" } } } }, + { + "required": ["mention_wxids"], + "properties": { + "mention_wxids": { "minItems": 1 } + } + }, { "required": ["all"], "properties": { "all": { "const": true } } @@ -154,9 +170,10 @@ python3 scripts/send_complex_message.py --query-history --start-time "$START_TIM 对应命令行参数: - `--refer-message-id ` 可选,传入后发送引用消息,必须同时提供非空 `--content` -- `--mention <昵称或备注>` 指定成员时使用,可重复传入 -- `--mentions ` 指定成员时可选,用于一次传入多个昵称或备注 -- `--all`(也支持 `--mention-all`)可选,用于真正 @所有人,不能与 mention 参数同用 +- `--mention <名称>` 仅在记忆工具不可用或无候选时传入原名称,脚本按当前昵称或备注匹配,可重复传入 +- `--mentions ` 与 `--mention` 相同,用于一次传入多个名称 +- `--mention-wxid <微信ID>` 对应 `mention_wxids` 中的一个 ID,可重复传入,也可与名称参数混用;只精确匹配微信 ID +- `--all`(也支持 `--mention-all`)可选,用于真正 @所有人,不能与任何指定成员参数同用 - `--content <文本内容>` 纯文本和引用消息必填,单独艾特时可省略或为空 - `--ended` 可选标志。当 Agent 已完成消息发送、要说的话已说完时传入。 @@ -171,26 +188,41 @@ python3 scripts/send_complex_message.py --query-history --start-time "$START_TIM ## 成员匹配规则 -仅指定成员时执行以下匹配;`--all` 不查询成员表: +发送前,脚本会检查当前群聊 `ROBOT_FROM_WX_ID` 对应的 `chat_room_members`,只接受 `is_leaved` 为空或 `0` 的成员。`--all` 不查询成员表。 -1. 只在当前群聊 `ROBOT_FROM_WX_ID` 对应的 `chat_room_members` 记录中查找。 -2. 只匹配 `is_leaved` 为空或 `0` 的成员,已经退群的成员不能被艾特。 -3. 使用用户给出的昵称或备注做模糊查询,字段优先级为 `remark`,然后是 `nickname`。 -4. 查询到候选成员后,优先选择 `remark` 完全等于输入值的成员。 -5. 如果没有完全相等的 `remark`,选择 `nickname` 完全等于输入值的成员。 -6. 如果没有完全相等结果,选择第一个 `remark` 包含输入值的成员。 -7. 如果仍未命中,选择第一个 `nickname` 包含输入值的成员。 +- `--mention-wxid`:只按 `wechat_id` 精确匹配;不存在、已退群或只存在于其他群时失败,不回退到昵称匹配。 +- `--mention`/`--mentions`:先查当前备注和昵称是否与输入完全相同,找不到再查是否包含输入名称;找到多人就停止。仅在记忆工具不可用或查不到人时使用。如果记忆已经查出多个候选,应请用户明确要艾特谁。 + +## 查找要艾特的成员 + +1. 用 `search_chat_room_memory` 查询用户给出的名称。如果用户已经提供微信 ID,或当前会话的工具结果已经确认了微信 ID,直接用 `--mention-wxid` 发送。 +2. 将用户给出的原名称放入 `member_names`,将原始艾特请求放入 `query`;每次最多查询 10 个名称,超出时分批。只有 `search_chat_room_memory` 不可用而 `search_memory` 可用时,才使用后者并同样传入 `member_names` 和 `query`。 +3. 检查 `name_resolutions` 中每个名称的 `candidates`。只有一个候选且 `current_in_room` 为 `true` 时,取其 `wechat_id`。名称记录的 `is_active: false` 表示旧称呼,是否退群看 `current_in_room`。查到多人或对方已退群时,停止发送并请用户明确要艾特谁。 +4. 两个记忆工具都不可用,或某个名称没有候选时,把原名称传给 `--mention`/`--mentions`,查当前群成员。仍然找不到或找到多人时,请用户补充名称或微信 ID。返回 `no_reliable_match: true` 时,检查每个名称的候选,分别处理未找到和重名的情况。 +5. 用 `--mention-wxid` 传入查到的微信 ID。脚本会再次检查对方是否还在群内。艾特多人时,保留全部收件人、正文和引用参数,所有人都查清楚后一起发送。 + +例如用户说“帮我艾特 xxxx,提醒他看群公告”,先调用 `search_chat_room_memory`: + +```json +{"member_names": ["xxxx"], "query": "帮我艾特 xxxx,提醒他看群公告"} +``` + +仅当返回唯一且仍在群内的候选,并且其 `wechat_id` 为 `wxid_example` 时执行(将示例 ID 替换为实际结果): + +```bash +python3 scripts/send_complex_message.py --mention-wxid 'wxid_example' --content '请看群公告' --ended +``` ## 发送执行步骤 1. 判断用户需要纯文本、仅艾特、引用回复,还是引用时同时艾特。纯文本和引用回复必须准备非空正文 `content`;引用时按上面的规则确定 `refer_message_id`。 -2. 如需指定成员,把用户原话中的昵称或备注写入 `mention`/`mentions`;@所有人时设置 `all: true` 并使用 `--all`。 +2. 按上面的步骤查找成员,把确认的微信 ID 写入 `mention_wxids`;记忆工具不可用或查不到人时使用 `mention`/`mentions`。@所有人时设置 `all: true` 并使用 `--all`。 3. 在该技能目录执行脚本,例如: -仅艾特某人,不附加正文: +以下指定成员的示例均假设已确认其微信 ID 为 `wxid_zhangsan`,执行时使用实际 ID。仅艾特某人,不附加正文: ```bash -python3 scripts/send_complex_message.py --mention '张三' --ended +python3 scripts/send_complex_message.py --mention-wxid 'wxid_zhangsan' --ended ``` 仅发送文本,不艾特、不引用: @@ -214,13 +246,13 @@ python3 scripts/send_complex_message.py --refer-message-id "$ROBOT_REF_MESSAGE_I 引用已确认的历史消息并附带成员显示(当前客户端不产生原生艾特提醒): ```bash -python3 scripts/send_complex_message.py --refer-message-id 12345 --mention '张三' --content '请看一下这个' +python3 scripts/send_complex_message.py --refer-message-id 12345 --mention-wxid 'wxid_zhangsan' --content '请看一下这个' ``` 艾特群成员并附加正文: ```bash -python3 scripts/send_complex_message.py --mention '张三' --content '看一下这个' +python3 scripts/send_complex_message.py --mention-wxid 'wxid_zhangsan' --content '看一下这个' ``` 用户要求 @所有人时传 `--all`,不要把“所有人”当成员昵称查询: @@ -232,10 +264,10 @@ python3 scripts/send_complex_message.py --all --content '请大家查看群公 当 Agent 认为任务已完成、对话可以结束时,加上 `--ended` 标志: ```bash -python3 scripts/send_complex_message.py --mention '张三' --content '看一下这个' --ended +python3 scripts/send_complex_message.py --mention-wxid 'wxid_zhangsan' --content '看一下这个' --ended ``` -4. 指定成员时,脚本查询数据库表 `chat_room_members` 并解析微信 ID;`--all` 时跳过数据库查询,使用 `at: ["notify@all"]`。如果指定成员未命中,可以查询记忆里是否记录了对方的别称。 +4. 脚本会检查所有指定成员,任何一个人找不到或有重名,整条消息都不发送。如果还没查过记忆,先查记忆;查过仍无法确定是谁,请用户补充信息。`--all` 时跳过数据库查询,使用 `at: ["notify@all"]`。 5. 脚本通过 `X-Private-Token` 请求头传递环境变量 `ROBOT_CLIENT_PRIVATE_TOKEN`,统一调用 `POST http://127.0.0.1:{ROBOT_WECHAT_CLIENT_PORT}/api/v1/robot/message/send/refermessage`。请求体包含 `to_wxid`、`content`、`at`;只有引用时才添加 `refer_message_id`。不引用时,客户端直接转入普通文本发送方法;纯文本的 `at` 为空数组,仅艾特的 `content` 为空字符串。 `to_wxid` 固定取当前会话 `ROBOT_FROM_WX_ID`。技能在客户端内执行,无需携带管理后台的机器人实例 query `id`。 @@ -248,7 +280,7 @@ python3 scripts/send_complex_message.py --mention '张三' --content '看一下 - 传入 `refer_message_id` 时,必须是 int64 范围内的正整数,引用正文不能是空字符串或纯空白;不引用时直接省略 ID,不能为满足校验而猜测或自动填入消息 ID。 - `--all` 必须独占,不能再指定成员。 - 每个要艾特的人都必须能在当前群内匹配到未退群成员。 -- 如果同一个微信 ID 被多个昵称命中,只会艾特一次。 +- 如果同一个微信 ID 被多个昵称或 ID 参数命中,只会艾特一次。 ## 依赖安装 @@ -266,4 +298,4 @@ python3 scripts/send_complex_message.py --mention '张三' --content '看一下 - 成功时,脚本输出「文本消息发送成功」「引用消息发送成功」「艾特消息发送成功」或「艾特所有人消息发送成功」,表示消息已通过客户端接口直接发送,不要再重复发送正文。 - HTTP 200 不等于业务成功,必须检查响应中的 `code`。当前引用接口成功时可能返回 `data: null`,不能因没有消息对象重发。 - 如果传入 `--ended`,输出末尾会追加 `ended`,Agent 会自动结束对话。 -- 失败时,返回脚本输出的具体错误信息,不输出 `ended`。引用消息不存在、原消息内容不完整或原发送人不存在时,按客户端错误说明原因;请求超时或发送结果不确定时,不自动重试,以免重复发送。 +- 失败时不输出 `ended`。找不到成员时,按上面的步骤查询;仍无法确定是谁就说明原因。引用消息不存在、原消息内容不完整或原发送人不存在时,按客户端错误说明原因;请求超时或发送结果不确定时,不自动重试,以免重复发送。 diff --git a/skills/send-complex-message/scripts/send_complex_message.py b/skills/send-complex-message/scripts/send_complex_message.py index b8be29a..56fcae4 100644 --- a/skills/send-complex-message/scripts/send_complex_message.py +++ b/skills/send-complex-message/scripts/send_complex_message.py @@ -175,6 +175,8 @@ def _parse_cli_params(argv: list[str]) -> argparse.Namespace: parser = SkillArgumentParser(description="发送消息或查询当前会话最近 24 小时内的聊天记录", allow_abbrev=False) parser.add_argument("--mention", action="append", default=[]) parser.add_argument("--mentions", action="append", default=[]) + parser.add_argument("--mention-wxid", dest="mention_wxids", action="append", default=[], + help="按已确认的微信 ID 艾特当前群成员,可重复,不按昵称回退匹配") parser.add_argument("--all", "--mention-all", dest="mention_all", action="store_true") parser.add_argument("--refer-message-id", type=int) parser.add_argument("--content") @@ -193,7 +195,7 @@ def _parse_cli_params(argv: list[str]) -> argparse.Namespace: namespace = parser.parse_args(argv) if namespace.query_history: - if (namespace.mention or namespace.mentions or namespace.mention_all + if (namespace.mention or namespace.mentions or namespace.mention_wxids or namespace.mention_all or namespace.refer_message_id is not None or namespace.content is not None or namespace.ended): raise ValueError("--query-history 不能与发送参数或 --ended 同时使用") @@ -215,8 +217,13 @@ def _parse_cli_params(argv: list[str]) -> argparse.Namespace: seen.add(key) deduped.append(mention) - if namespace.mention_all and deduped: - raise ValueError("all 不能和 mention 或 mentions 同时使用") + mention_wxids = [value.strip() for value in namespace.mention_wxids] + if any(not value for value in mention_wxids): + raise ValueError("mention-wxid 不能为空") + namespace.mention_wxids = list(dict.fromkeys(mention_wxids)) + + if namespace.mention_all and (deduped or namespace.mention_wxids): + raise ValueError("all 不能和 mention、mentions 或 mention-wxid 同时使用") if namespace.refer_message_id is not None: if not 0 < namespace.refer_message_id <= 2**63 - 1: @@ -224,7 +231,7 @@ def _parse_cli_params(argv: list[str]) -> argparse.Namespace: if not namespace.content.strip(): raise ValueError("发送引用消息必须提供非空 content") - if not deduped and not namespace.mention_all and not namespace.content.strip(): + if not deduped and not namespace.mention_wxids and not namespace.mention_all and not namespace.content.strip(): raise ValueError("请提供非空 content,或指定要艾特的成员/--all") namespace.mentions = deduped @@ -366,55 +373,65 @@ def _normalize(value) -> str: def _find_member(conn, chat_room_id: str, mention: str) -> dict | None: keyword = mention.strip() - like_keyword = f"%{_escape_like(keyword)}%" + for condition, value in ( + ("= LOWER(%s)", keyword), + ("LIKE LOWER(%s) ESCAPE '\\\\'", f"%{_escape_like(keyword)}%"), + ): + sql = f""" + SELECT DISTINCT wechat_id + FROM chat_room_members + WHERE chat_room_id = %s + AND (is_leaved IS NULL OR is_leaved = 0) + AND ( + LOWER(TRIM(remark)) {condition} + OR LOWER(TRIM(nickname)) {condition} + ) + ORDER BY wechat_id + LIMIT 2 + """ + with conn.cursor() as cursor: + cursor.execute(sql, (chat_room_id, value, value)) + candidates = list(cursor.fetchall()) + if len(candidates) > 1: + raise ValueError(f"成员名称有歧义: {mention},匹配到多个当前群成员;本次未发送消息,请明确微信 ID") + if candidates: + return candidates[0] + return None + + +def _find_member_by_wechat_id(conn, chat_room_id: str, wechat_id: str) -> dict | None: sql = """ SELECT wechat_id, remark, nickname FROM chat_room_members WHERE chat_room_id = %s AND (is_leaved IS NULL OR is_leaved = 0) - AND ( - (remark IS NOT NULL AND remark LIKE %s ESCAPE '\\\\') - OR (nickname IS NOT NULL AND nickname LIKE %s ESCAPE '\\\\') - ) - ORDER BY id ASC - LIMIT 50 + AND wechat_id = %s + LIMIT 1 """ - with conn.cursor() as cursor: - cursor.execute(sql, (chat_room_id, like_keyword, like_keyword)) - candidates = list(cursor.fetchall()) - - keyword_folded = keyword.casefold() - - for field in ("remark", "nickname"): - for candidate in candidates: - if _normalize(candidate.get(field)).casefold() == keyword_folded: - return candidate - - for field in ("remark", "nickname"): - for candidate in candidates: - value = _normalize(candidate.get(field)).casefold() - if keyword_folded in value: - return candidate - - return None + cursor.execute(sql, (chat_room_id, wechat_id)) + members = list(cursor.fetchall()) + return members[0] if members else None -def _resolve_mentions(conn, chat_room_id: str, mentions: list[str]) -> tuple[list[str], list[str]]: +def _resolve_mentions( + conn, chat_room_id: str, mentions: list[str], mention_wxids: list[str], +) -> tuple[list[str], list[str]]: at_wechat_ids: list[str] = [] seen = set() missing: list[str] = [] - for mention in mentions: - member = _find_member(conn, chat_room_id, mention) - if not member: - missing.append(mention) - continue + for values, find_member in ((mentions, _find_member), (mention_wxids, _find_member_by_wechat_id)): + for mention in values: + member = find_member(conn, chat_room_id, mention) + if not member: + missing.append(mention) + continue - wechat_id = _normalize(member.get("wechat_id")) - if wechat_id and wechat_id not in seen: - seen.add(wechat_id) - at_wechat_ids.append(wechat_id) + wechat_id = _normalize(member.get("wechat_id")) + if wechat_id and wechat_id not in seen: + seen.add(wechat_id) + at_wechat_ids.append(wechat_id) return at_wechat_ids, missing @@ -455,9 +472,9 @@ def main() -> int: if args.query_history: return _run_history_query(to_wxid, args) - mentions, content = args.mentions, args.content + mentions, mention_wxids, content = args.mentions, args.mention_wxids, args.content ended, mention_all, refer_message_id = args.ended, args.mention_all, args.refer_message_id - if (mention_all or mentions) and not to_wxid.endswith("@chatroom"): + if (mention_all or mentions or mention_wxids) and not to_wxid.endswith("@chatroom"): sys.stdout.write("当前会话不是群聊,不能发送艾特消息\n") return 1 @@ -469,7 +486,7 @@ def main() -> int: at_wechat_ids: list[str] = [] if mention_all: at_wechat_ids = ["notify@all"] - elif mentions: + elif mentions or mention_wxids: try: conn = _mysql_connect() except Exception as exc: @@ -477,7 +494,7 @@ def main() -> int: return 1 try: - at_wechat_ids, missing = _resolve_mentions(conn, to_wxid, mentions) + at_wechat_ids, missing = _resolve_mentions(conn, to_wxid, mentions, mention_wxids) except Exception as exc: sys.stdout.write(f"查询群成员失败: {exc}\n") return 1 @@ -489,6 +506,9 @@ def main() -> int: if missing: sys.stdout.write(f"未找到当前群内未退群成员: {', '.join(missing)}\n") + if any(value in mentions for value in missing): + sys.stdout.write("本次未发送消息;若还没查过记忆且工具可用,先用 search_chat_room_memory 查找成员," + "确认是谁后,用 --mention-wxid 传入微信 ID;查过仍无法确定时,请用户补充信息\n") return 1 if not at_wechat_ids: sys.stdout.write("未找到可艾特的群成员\n") diff --git a/tests/test_send_complex_message.py b/tests/test_send_complex_message.py index 268681c..9549d22 100644 --- a/tests/test_send_complex_message.py +++ b/tests/test_send_complex_message.py @@ -72,6 +72,26 @@ class HistoryConnection: self.database.close() +class MemberConnection(HistoryConnection): + def __init__(self, members: list[dict]) -> None: + super().__init__([]) + self.database.execute(""" + CREATE TABLE chat_room_members ( + id INTEGER PRIMARY KEY, chat_room_id TEXT, wechat_id TEXT, + remark TEXT, nickname TEXT, is_leaved INTEGER + ) + """) + for member in members: + row = { + "chat_room_id": "room@chatroom", "remark": "", "nickname": "", + "is_leaved": 0, **member, + } + self.database.execute( + f"INSERT INTO chat_room_members ({', '.join(row)}) VALUES ({', '.join(['?'] * len(row))})", + tuple(row.values()), + ) + + class SendComplexMessageTests(unittest.TestCase): @classmethod def setUpClass(cls) -> None: @@ -120,7 +140,7 @@ class SendComplexMessageTests(unittest.TestCase): body, ) if "--mention" in args: - resolve.assert_called_once_with(connect.return_value, "room@chatroom", ["张三"]) + resolve.assert_called_once_with(connect.return_value, "room@chatroom", ["张三"], []) connect.return_value.close.assert_called_once() else: connect.assert_not_called() @@ -137,6 +157,9 @@ class SendComplexMessageTests(unittest.TestCase): ["--refer-message-id", "0", "--content", "收到"], ["--refer-message-id", "-1", "--content", "收到"], ["--refer-message-id", "9223372036854775808", "--content", "收到"], + ["--mention-wxid", " \t", "--content", "收到"], + ["--mention-wxid", "wxid_alice", "--all"], + ["--mention-wxid", "wxid_alice", "--refer-message-id", "12"], ] for args in cases: with self.subTest(args=args), contextlib.ExitStack() as stack: @@ -150,6 +173,138 @@ class SendComplexMessageTests(unittest.TestCase): post.assert_not_called() self.assertFalse(output.getvalue().endswith("ended")) + def run_member_send(self, members, args): + connection = MemberConnection(members) + self.addCleanup(connection.close) + with mock.patch.object(sys, "argv", [str(SCRIPT_PATH), *args, "--ended"]), \ + mock.patch.object(self.module, "_mysql_connect", return_value=connection), \ + mock.patch.object(self.module, "_http_post_json", return_value={"code": 200}) as post, \ + contextlib.redirect_stdout(io.StringIO()) as output: + result = self.module.main() + self.assertTrue(connection.closed) + return result, output.getvalue(), post + + def test_memory_resolved_id_targets_renamed_member_despite_duplicate_names(self) -> None: + result, output, post = self.run_member_send([ + {"id": 1, "wechat_id": "wxid_other", "nickname": "现在的名字"}, + {"id": 2, "wechat_id": "wxid_renamed", "nickname": "现在的名字", "is_leaved": None}, + ], ["--mention-wxid", " wxid_renamed "]) + self.assertEqual(result, 0) + post.assert_called_once_with( + "http://127.0.0.1:9000/api/v1/robot/message/send/refermessage", + {"to_wxid": "room@chatroom", "content": "", "at": ["wxid_renamed"]}, + ) + self.assertTrue(output.endswith("ended")) + + def test_explicit_ids_require_exact_current_room_membership(self) -> None: + cases = [ + {"wechat_id": "wxid_target", "is_leaved": 1}, + {"wechat_id": "wxid_target", "chat_room_id": "other@chatroom"}, + {"wechat_id": "wxid_target_suffix"}, + ] + for member in cases: + with self.subTest(member=member): + result, output, post = self.run_member_send([ + {"id": 1, **member}, + # 即使别人的当前昵称或备注恰好等于目标 ID,也不能回退匹配。 + {"id": 2, "wechat_id": "wxid_other", "nickname": "wxid_target", "remark": "wxid_target"}, + ], ["--mention-wxid", "wxid_target", "--content", "请看公告"]) + self.assertEqual(result, 1) + self.assertIn("未找到当前群内未退群成员: wxid_target", output) + self.assertFalse(output.endswith("ended")) + post.assert_not_called() + + def test_missing_old_name_stops_entire_send_then_accepts_resolved_id(self) -> None: + members = [ + {"id": 1, "wechat_id": "wxid_zhangsan", "nickname": "张三"}, + {"id": 2, "wechat_id": "wxid_renamed", "nickname": "新名字"}, + ] + common_args = ["--mention", "张三", "--content", "请看公告", "--refer-message-id", "12"] + result, output, post = self.run_member_send(members, [*common_args, "--mention", "旧名字"]) + self.assertEqual(result, 1) + self.assertIn("旧名字", output) + self.assertIn("本次未发送消息", output) + self.assertIn("search_chat_room_memory", output) + self.assertIn("--mention-wxid", output) + self.assertFalse(output.endswith("ended")) + post.assert_not_called() + + result, _, post = self.run_member_send(members, [*common_args, "--mention-wxid", "wxid_renamed"]) + self.assertEqual(result, 0) + post.assert_called_once_with( + "http://127.0.0.1:9000/api/v1/robot/message/send/refermessage", + {"to_wxid": "room@chatroom", "content": "请看公告", + "at": ["wxid_zhangsan", "wxid_renamed"], "refer_message_id": 12}, + ) + + def test_mixed_names_and_repeated_ids_mention_each_person_once(self) -> None: + result, _, post = self.run_member_send([ + {"id": 1, "wechat_id": "wxid_zhangsan", "nickname": "张三"}, + {"id": 2, "wechat_id": "wxid_lisi", "nickname": "李四"}, + ], ["--mention", "张三", "--mention-wxid", "wxid_zhangsan", + "--mention-wxid", "wxid_lisi", "--mention-wxid", "wxid_lisi"]) + self.assertEqual(result, 0) + self.assertEqual(post.call_args.args[1]["at"], ["wxid_zhangsan", "wxid_lisi"]) + + def test_current_name_fallback_rejects_exact_and_partial_ambiguity(self) -> None: + cases = [ + [{"remark": "张三"}, {"nickname": "张三"}], + [{"nickname": "张三"}, {"nickname": "张三"}], + [{"remark": "张三甲"}, {"nickname": "张三乙"}], + ] + for names in cases: + with self.subTest(names=names): + members = [ + {"id": index, "wechat_id": f"wxid_{index}", **name} + for index, name in enumerate(names, start=1) + ] + result, output, post = self.run_member_send(members, ["--mention", "张三"]) + self.assertEqual(result, 1) + self.assertIn("成员名称有歧义", output) + self.assertFalse(output.endswith("ended")) + post.assert_not_called() + + def test_current_name_fallback_finds_exact_match_beyond_many_partial_matches(self) -> None: + members = [ + {"id": index, "wechat_id": f"wxid_{index}", "nickname": f"张三{index}"} + for index in range(1, 61) + ] + members.extend([ + {"id": 61, "wechat_id": "wxid_target", "nickname": " 张三 "}, + {"id": 62, "wechat_id": "wxid_left", "nickname": "张三", "is_leaved": 1}, + {"id": 63, "wechat_id": "wxid_other_room", "nickname": "张三", "chat_room_id": "other@chatroom"}, + ]) + result, _, post = self.run_member_send(members, ["--mention", "张三"]) + self.assertEqual(result, 0) + self.assertEqual(post.call_args.args[1]["at"], ["wxid_target"]) + + def test_current_name_partial_matching_treats_wildcards_as_literal(self) -> None: + result, _, post = self.run_member_send([ + {"id": 1, "wechat_id": "wxid_target", "nickname": "前缀100%_\\后缀"}, + {"id": 2, "wechat_id": "wxid_other", "nickname": "前缀100AB\\后缀"}, + ], ["--mention", "100%_\\"]) + self.assertEqual(result, 0) + self.assertEqual(post.call_args.args[1]["at"], ["wxid_target"]) + + def test_unresolved_id_does_not_send_other_resolved_members(self) -> None: + result, output, post = self.run_member_send([ + {"id": 1, "wechat_id": "wxid_zhangsan", "nickname": "张三"}, + ], ["--mention", "张三", "--mention-wxid", "wxid_missing", "--content", "请看公告"]) + self.assertEqual(result, 1) + self.assertFalse(output.endswith("ended")) + post.assert_not_called() + + def test_mention_ids_are_rejected_in_private_conversations(self) -> None: + with mock.patch.dict(os.environ, {"ROBOT_FROM_WX_ID": "wxid_friend"}), \ + mock.patch.object(sys, "argv", [str(SCRIPT_PATH), "--mention-wxid", "wxid_alice"]), \ + mock.patch.object(self.module, "_mysql_connect") as connect, \ + mock.patch.object(self.module, "_http_post_json") as post, \ + contextlib.redirect_stdout(io.StringIO()) as output: + self.assertEqual(self.module.main(), 1) + self.assertIn("当前会话不是群聊", output.getvalue()) + connect.assert_not_called() + post.assert_not_called() + def run_history(self, rows, args=(), conversation="room@chatroom") -> dict: connection = HistoryConnection(rows) self.addCleanup(connection.close) @@ -285,6 +440,7 @@ class SendComplexMessageTests(unittest.TestCase): ["--message-type", "bad"], ["--message-type", "0"], ["--app-msg-type", "-1"], ["--message-type", "text", "--app-msg-type", "57"], ["--content", "你好"], ["--content", ""], ["--mention", "张三"], ["--mentions", "[]"], + ["--mention-wxid", "wxid_alice"], ["--all"], ["--refer-message-id", "1"], ["--ended"], ["--conversation-id", "other@chatroom"], ["--from-wxid", "other@chatroom"], ]