wechat-robot-skills/skills/send-complex-message/scripts/send_complex_message.py

548 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import math
import os
import subprocess
import sys
import time
import traceback
import urllib.request
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import NoReturn
sys.stderr = sys.stdout
MAX_HISTORY_SECONDS = 24 * 60 * 60
DEFAULT_HISTORY_LIMIT = 50
MAX_HISTORY_LIMIT = 200
SHANGHAI_TZ = timezone(timedelta(hours=8))
MESSAGE_TYPES = {
"text": 1,
"image": 3,
"voice": 34,
"card": 42,
"video": 43,
"emoji": 47,
"location": 48,
"app": 49,
"system": 10000,
"recall": 10002,
}
class SkillArgumentParser(argparse.ArgumentParser):
def error(self, message: str) -> NoReturn:
raise ValueError(message)
def _client_private_token() -> str:
return os.environ.get("ROBOT_CLIENT_PRIVATE_TOKEN", "").strip()
def _skill_root() -> Path:
return Path(__file__).resolve().parent.parent
def _skill_venv_python() -> Path:
venv_dir = _skill_root() / ".venv"
if sys.platform == "win32":
return venv_dir / "Scripts" / "python.exe"
return venv_dir / "bin" / "python"
def _get_python_executable() -> str:
if sys.executable:
return sys.executable
import shutil
for candidate in ("python3", "python"):
found = shutil.which(candidate)
if found:
return found
raise RuntimeError("无法找到 Python 解释器路径")
def _run_bootstrap() -> None:
bootstrap = Path(__file__).resolve().parent / "bootstrap.py"
result = subprocess.run([_get_python_executable(), str(bootstrap)])
if result.returncode != 0:
raise SystemExit(result.returncode)
def _ensure_skill_venv_python() -> None:
venv_python = _skill_venv_python()
if not venv_python.is_file():
_run_bootstrap()
venv_python = _skill_venv_python()
if not venv_python.is_file():
sys.stdout.write("bootstrap 后仍未找到虚拟环境\n")
raise SystemExit(1)
venv_dir = _skill_root() / ".venv"
if Path(sys.prefix) == venv_dir.resolve():
return
os.execv(str(venv_python), [str(venv_python), str(Path(__file__).resolve()), *sys.argv[1:]])
def _mysql_connect():
_ensure_skill_venv_python()
try:
import pymysql
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")
password = os.environ.get("MYSQL_PASSWORD", "")
database = os.environ.get("ROBOT_CODE", "")
if not database:
raise RuntimeError("环境变量 ROBOT_CODE 未配置")
return pymysql.connect(
host=host,
port=port,
user=user,
password=password,
database=database,
charset="utf8mb4",
connect_timeout=10,
read_timeout=30,
cursorclass=pymysql.cursors.DictCursor,
)
def _http_post_json(url: str, body: dict, timeout: int = 300) -> dict:
data = json.dumps(body).encode("utf-8")
req = urllib.request.Request(
url,
data=data,
headers={
"Content-Type": "application/json",
"X-Private-Token": _client_private_token(),
},
method="POST",
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
text = resp.read().decode("utf-8")
if not text.strip():
return {}
return json.loads(text)
def _expand_json_array_values(values: list[str], label: str) -> list[str]:
expanded: list[str] = []
for value in values:
stripped = value.strip()
if not stripped:
continue
if stripped.startswith("["):
parsed = json.loads(stripped)
if not isinstance(parsed, list):
raise ValueError(f"{label} 必须是字符串数组")
for item in parsed:
if not isinstance(item, str):
raise ValueError(f"{label} 必须是字符串数组")
if item.strip():
expanded.append(item.strip())
continue
expanded.append(stripped)
return expanded
def _positive_int(value: str) -> int:
number = int(value)
if not 0 < number <= 2**63 - 1:
raise ValueError("必须是 int64 范围内的正整数")
return number
def _message_type(value: str) -> int:
normalized = value.strip().lower()
if normalized in MESSAGE_TYPES:
return MESSAGE_TYPES[normalized]
return _positive_int(normalized)
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")
parser.add_argument("--ended", action="store_true", default=False)
parser.add_argument("--query-history", action="store_true", help="只查询历史聊天记录,不发送消息")
parser.add_argument("--hours", type=float, help="查询最近多少小时,支持小数,最多 24 小时")
parser.add_argument("--start-time", help="开始时间:Unix 秒或北京时间 YYYY-MM-DD HH:mm[:ss]")
parser.add_argument("--end-time", help="结束时间:Unix 秒或北京时间 YYYY-MM-DD HH:mm[:ss]")
parser.add_argument("--keyword", action="append", help="正文或显示内容包含的关键词,可重复,多个词须全部匹配")
parser.add_argument("--message-type", action="append", type=_message_type, help="消息类型名称或编号,可重复")
parser.add_argument("--app-msg-type", action="append", type=_positive_int, help="APP 消息子类型编号,可重复")
parser.add_argument("--sender-wxid", help="按发送人微信 ID 精确过滤")
parser.add_argument("--limit", type=int, help="单页条数,默认 50,最多 200")
parser.add_argument("--offset", type=int, help="分页偏移量,默认 0")
namespace = parser.parse_args(argv)
if namespace.query_history:
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 同时使用")
_validate_history_params(namespace)
return namespace
history_fields = ("hours", "start_time", "end_time", "keyword", "message_type",
"app_msg_type", "sender_wxid", "limit", "offset")
if any(getattr(namespace, field) is not None for field in history_fields):
raise ValueError("聊天记录过滤参数必须与 --query-history 一起使用")
namespace.content = namespace.content or ""
mentions = _expand_json_array_values(namespace.mention + namespace.mentions, "mentions")
deduped: list[str] = []
seen = set()
for mention in mentions:
key = mention.casefold()
if key not in seen:
seen.add(key)
deduped.append(mention)
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:
raise ValueError("refer_message_id 必须是正整数,且不能超过 int64 范围")
if not namespace.content.strip():
raise ValueError("发送引用消息必须提供非空 content")
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
return namespace
def _parse_history_time(value: str, field_name: str) -> int:
text = value.strip()
if text.isascii() and text.isdigit():
return int(text)
for pattern in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"):
try:
return int(datetime.strptime(text, pattern).replace(tzinfo=SHANGHAI_TZ).timestamp())
except ValueError:
continue
raise ValueError(f"{field_name} 必须是 Unix 秒或北京时间 YYYY-MM-DD HH:mm[:ss]")
def _resolve_history_time_range(args: argparse.Namespace) -> tuple[int, int]:
now = int(time.time())
earliest = now - MAX_HISTORY_SECONDS
if args.hours is not None:
if args.start_time is not None or args.end_time is not None:
raise ValueError("--hours 不能与 --start-time/--end-time 同时使用")
if not math.isfinite(args.hours) or not 0 < args.hours <= 24:
raise ValueError("hours 必须大于 0 且不超过 24")
seconds = int(args.hours * 3600)
if seconds < 1:
raise ValueError("hours 对应的时间范围不能小于 1 秒")
return now - seconds, now
start = _parse_history_time(args.start_time, "start_time") if args.start_time is not None else earliest
end = _parse_history_time(args.end_time, "end_time") if args.end_time is not None else now
if start < earliest or end > now:
raise ValueError("只能查询最近 24 小时内的聊天记录,不能查询更早或未来的时间")
if start >= end:
raise ValueError("结束时间必须晚于开始时间")
return start, end
def _validate_history_params(args: argparse.Namespace) -> None:
_resolve_history_time_range(args)
args.limit = DEFAULT_HISTORY_LIMIT if args.limit is None else args.limit
args.offset = 0 if args.offset is None else args.offset
if not 1 <= args.limit <= MAX_HISTORY_LIMIT:
raise ValueError(f"limit 必须在 1 到 {MAX_HISTORY_LIMIT} 之间")
if not 0 <= args.offset <= 2**63 - 1:
raise ValueError("offset 必须是 int64 范围内的非负整数")
args.keyword = [keyword.strip() for keyword in (args.keyword or [])]
if any(not keyword for keyword in args.keyword):
raise ValueError("keyword 不能为空或纯空白")
if args.sender_wxid is not None:
args.sender_wxid = args.sender_wxid.strip()
if not args.sender_wxid:
raise ValueError("sender_wxid 不能为空或纯空白")
if args.app_msg_type and args.message_type and set(args.message_type) != {49}:
raise ValueError("app_msg_type 只能与 APP 消息类型 49(app)一起使用")
def _query_history(conn, conversation_id: str, args: argparse.Namespace) -> dict:
# 建立连接/安装依赖可能耗时;在真正查询前重新确定最近 24 小时的边界。
start, end = _resolve_history_time_range(args)
is_chat_room = conversation_id.endswith("@chatroom")
conditions = [
"from_wxid = %s",
"is_chat_room = %s",
"created_at >= %s",
"created_at <= %s",
]
params: list[object] = [conversation_id, is_chat_room, start, end]
for keyword in args.keyword:
conditions.append("(content LIKE %s ESCAPE '\\\\' OR display_full_content LIKE %s ESCAPE '\\\\')")
pattern = f"%{_escape_like(keyword)}%"
params.extend((pattern, pattern))
if args.sender_wxid:
conditions.append("sender_wxid = %s")
params.append(args.sender_wxid)
if args.message_type:
conditions.append(f"`type` IN ({', '.join(['%s'] * len(args.message_type))})")
params.extend(args.message_type)
if args.app_msg_type:
conditions.append("`type` = 49")
conditions.append(f"app_msg_type IN ({', '.join(['%s'] * len(args.app_msg_type))})")
params.extend(args.app_msg_type)
sql = f"""
SELECT id, from_wxid, sender_wxid, to_wxid, is_chat_room,
type, app_msg_type, content, display_full_content, is_recalled, created_at
FROM messages
WHERE {' AND '.join(conditions)}
ORDER BY created_at DESC, id DESC
LIMIT %s OFFSET %s
"""
params.extend((args.limit + 1, args.offset))
with conn.cursor() as cursor:
cursor.execute(sql, tuple(params))
rows = list(cursor.fetchall())
has_more = len(rows) > args.limit
messages = rows[:args.limit]
return {
"conversation_id": conversation_id,
"is_chat_room": is_chat_room,
"start_time": start,
"end_time": end,
"limit": args.limit,
"offset": args.offset,
"count": len(messages),
"has_more": has_more,
"next_offset": args.offset + len(messages) if has_more else None,
"messages": messages,
}
def _run_history_query(conversation_id: str, args: argparse.Namespace) -> int:
try:
conn = _mysql_connect()
except Exception as exc:
sys.stdout.write(f"数据库连接失败: {exc}\n")
return 1
try:
result = _query_history(conn, conversation_id, args)
sys.stdout.write(json.dumps(result, ensure_ascii=False) + "\n")
return 0
except Exception as exc:
sys.stdout.write(f"查询聊天记录失败: {exc}\n")
return 1
finally:
conn.close()
def _escape_like(value: str) -> str:
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def _normalize(value) -> str:
if value is None:
return ""
return str(value).strip()
def _find_member(conn, chat_room_id: str, mention: str) -> dict | None:
keyword = mention.strip()
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 wechat_id = %s
LIMIT 1
"""
with conn.cursor() as cursor:
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], mention_wxids: list[str],
) -> tuple[list[str], list[str]]:
at_wechat_ids: list[str] = []
seen = set()
missing: list[str] = []
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)
return at_wechat_ids, missing
def _send_message(
client_port: str,
to_wxid: str,
content: str,
at_wechat_ids: list[str],
refer_message_id: int | None,
) -> None:
# 未传引用 ID 时,客户端会调用普通文本发送方法,并保留原生艾特功能。
send_url = f"http://127.0.0.1:{client_port}/api/v1/robot/message/send/refermessage"
body = {
"to_wxid": to_wxid,
"content": content,
"at": at_wechat_ids,
}
if refer_message_id is not None:
body["refer_message_id"] = refer_message_id
result = _http_post_json(send_url, body)
if not isinstance(result, dict) or result.get("code") != 200:
message = result.get("message") if isinstance(result, dict) else None
raise RuntimeError(message or "客户端未返回成功状态")
def main() -> int:
try:
args = _parse_cli_params(sys.argv[1:])
except (ValueError, json.JSONDecodeError) as exc:
sys.stdout.write(f"参数格式错误: {exc}\n")
return 1
to_wxid = os.environ.get("ROBOT_FROM_WX_ID", "").strip()
if not to_wxid:
sys.stdout.write("环境变量 ROBOT_FROM_WX_ID 未配置\n")
return 1
if args.query_history:
return _run_history_query(to_wxid, args)
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 or mention_wxids) and not to_wxid.endswith("@chatroom"):
sys.stdout.write("当前会话不是群聊,不能发送艾特消息\n")
return 1
client_port = os.environ.get("ROBOT_WECHAT_CLIENT_PORT", "").strip()
if not client_port:
sys.stdout.write("环境变量 ROBOT_WECHAT_CLIENT_PORT 未配置\n")
return 1
at_wechat_ids: list[str] = []
if mention_all:
at_wechat_ids = ["notify@all"]
elif mentions or mention_wxids:
try:
conn = _mysql_connect()
except Exception as exc:
sys.stdout.write(f"数据库连接失败: {exc}\n")
return 1
try:
at_wechat_ids, missing = _resolve_mentions(conn, to_wxid, mentions, mention_wxids)
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")
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")
return 1
try:
_send_message(client_port, to_wxid, content, at_wechat_ids, refer_message_id)
if refer_message_id is not None:
sys.stdout.write("引用消息发送成功\n")
if at_wechat_ids:
sys.stdout.write("引用中的艾特仅作显示,当前客户端尚未实现原生艾特提醒\n")
elif at_wechat_ids:
sys.stdout.write("艾特所有人消息发送成功\n" if mention_all else "艾特消息发送成功\n")
else:
sys.stdout.write("文本消息发送成功\n")
if ended:
sys.stdout.write("ended")
return 0
except Exception as exc:
sys.stdout.write(f"消息发送失败: {exc}\n")
return 1
if __name__ == "__main__":
try:
exit_code = main()
if exit_code == 0:
# ended may have already been printed above in the success path.
# If main() returned non-zero, ended is not printed.
pass
raise SystemExit(exit_code)
except SystemExit:
raise
except Exception:
traceback.print_exc(file=sys.stdout)
raise SystemExit(1)