From 20e494224999ae7e8c734b7d72d3f9d090994409 Mon Sep 17 00:00:00 2001 From: hp0912 <809211365@qq.com> Date: Sat, 30 May 2026 00:53:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8F=91=E9=80=81=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E6=8A=80=E8=83=BD=E3=80=81=E6=9F=A5=E6=89=BE=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E6=8A=80=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 14 + skills/find-recent-chat-media/SKILL.md | 113 ++++ .../scripts/bootstrap.py | 111 ++++ .../scripts/find_recent_chat_media.py | 491 ++++++++++++++++++ .../scripts/requirements.txt | 2 + skills/send-local-image/SKILL.md | 68 +++ .../scripts/send_local_image.py | 125 +++++ skills/send-mention-message/SKILL.md | 94 ++++ .../send-mention-message/scripts/bootstrap.py | 111 ++++ .../scripts/requirements.txt | 2 + .../scripts/send_mention_message.py | 294 +++++++++++ skills/send-remote-image/SKILL.md | 68 +++ .../scripts/send_remote_image.py | 116 +++++ 13 files changed, 1609 insertions(+) create mode 100644 skills/find-recent-chat-media/SKILL.md create mode 100644 skills/find-recent-chat-media/scripts/bootstrap.py create mode 100644 skills/find-recent-chat-media/scripts/find_recent_chat_media.py create mode 100644 skills/find-recent-chat-media/scripts/requirements.txt create mode 100644 skills/send-local-image/SKILL.md create mode 100644 skills/send-local-image/scripts/send_local_image.py create mode 100644 skills/send-mention-message/SKILL.md create mode 100644 skills/send-mention-message/scripts/bootstrap.py create mode 100644 skills/send-mention-message/scripts/requirements.txt create mode 100644 skills/send-mention-message/scripts/send_mention_message.py create mode 100644 skills/send-remote-image/SKILL.md create mode 100644 skills/send-remote-image/scripts/send_remote_image.py diff --git a/README.md b/README.md index 734655e..0e632c9 100644 --- a/README.md +++ b/README.md @@ -116,3 +116,17 @@ "voice": "@/path/to/voice.amr" } ``` + +**Agent 发送文本消息接口(可以 艾特/@/提及 某个人/某些人)** + +``` +[POST] http://127.0.0.1:{ROBOT_WECHAT_CLIENT_PORT}/api/v1/robot/message/send/text + +请求体 Body: + +{ + "to_wxid": "{{ROBOT_FROM_WX_ID}}", + "content": "", // 你需要发送的文本内容 + "at": [], // 字符串数组,你要艾特的人的微信 id +} +``` diff --git a/skills/find-recent-chat-media/SKILL.md b/skills/find-recent-chat-media/SKILL.md new file mode 100644 index 0000000..3b8dc4d --- /dev/null +++ b/skills/find-recent-chat-media/SKILL.md @@ -0,0 +1,113 @@ +--- +name: find-recent-chat-media +description: "从当前会话历史消息中查找最近十分钟内由当前用户发送的图片、视频或语音,并下载后上传 CDN 返回可供 AI 使用的媒体 URL。当你觉得你需要图片/视频/语音但当前上下文没有的时候使用。" +argument-hint: "需要 media_type;可选 count,最多 5。media_type 可为 image、video、voice 或 all。" +--- + +# Find Recent Chat Media Skill + +## 描述 + +这是一个从历史消息中查找媒体并转换为 CDN URL 的技能。 + +当你觉得你需要图片/视频/语音,但当前上下文里没有可用媒体信息的时候,优先使用本技能在当前会话历史中查找最近十分钟内由当前用户发送的图片、视频或语音。 + +技能脚本位于 `scripts/find_recent_chat_media.py`。 + +## 触发条件 + +- 用户说「看看刚才那张图」「识别一下我刚发的图片」「这张图是什么」,但当前上下文没有图片。 +- 用户说「总结刚才的视频」「分析我刚发的视频」,但当前上下文没有视频。 +- 用户说「听一下刚才那条语音」「转写我刚发的语音」,但当前上下文没有语音。 +- 用户明确说「前面那几张图」「刚刚发的两个视频」「最近的语音」等,需要从历史消息中补齐媒体 URL。 + +如果当前上下文已经有可用的媒体 URL 或引用媒体消息,不需要触发本技能。 + +## 入参规范 + +```json +{ + "type": "object", + "properties": { + "media_type": { + "type": "string", + "enum": ["image", "video", "voice", "all"], + "description": "要查找的媒体类型。图片用 image,视频用 video,语音用 voice;上下文同时可能是多种媒体时用 all。" + }, + "media_types": { + "type": "array", + "items": { + "type": "string", + "enum": ["image", "video", "voice"] + }, + "description": "可选,要查找的多个媒体类型。" + }, + "count": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "description": "需要查找的媒体数量。默认 1,最多 5。" + } + }, + "anyOf": [{ "required": ["media_type"] }, { "required": ["media_types"] }], + "additionalProperties": false +} +``` + +对应命令行参数: + +- `--media_type ` 必填或可重复传入 +- `--media_types ` 可选 +- `--count <数量>` 可选,默认 `1`,最大 `5` + +## 查找规则 + +1. 只查找当前会话 `ROBOT_FROM_WX_ID` 的历史消息。 +2. 只查找当前消息发送人 `ROBOT_SENDER_WX_ID` 发出的历史消息。 +3. 只查找最近十分钟内的消息。 +4. 根据 `media_type` / `media_types` 查找图片、视频、语音,可一次查找多种类型。 +5. 最多返回 5 条。脚本会先取最近匹配的 N 条,再按时间升序输出,因此第一条最早,最后一条最晚。 +6. 历史消息查询直接读取数据库 `messages` 表,不调用历史消息 HTTP 接口。 +7. 查到消息记录后,脚本必须先调用客户端下载接口下载媒体,再调用客户端上传接口上传到 CDN,最后只把 CDN URL 返回给智能体。 + +## 执行步骤 + +1. 判断当前用户请求需要哪种媒体。如果用户说图片,传 `--media_type image`;视频传 `video`;语音传 `voice`;上下文不确定时传 `all`。 +2. 根据用户表达决定 `count`。例如「那张图」传 `1`;「刚发的三张图」传 `3`;「这些图」可传 `5`。 +3. 在该技能目录执行脚本,例如: + +```bash +python3 scripts/find_recent_chat_media.py --media_type image --count 1 +``` + +4. 成功时脚本输出 JSON,包含 `media_urls` 和按类型拆分的 `image_urls`、`video_urls`、`voice_urls`。 +5. 智能体拿到 URL 后,再继续调用图片识别、视频理解、语音理解/转写等后续能力。 + +## 数据库与客户端接口依赖 + +脚本会直接查询数据库表 `messages`,筛选字段包括: + +- `from_wxid = ROBOT_FROM_WX_ID` +- `sender_wxid = ROBOT_SENDER_WX_ID` +- `created_at` 在最近十分钟内 +- `type` 为图片 `3`、语音 `34`、视频 `43` + +脚本会调用以下客户端接口下载和上传媒体: + +- 下载图片:`GET http://127.0.0.1:{ROBOT_WECHAT_CLIENT_PORT}/api/v1/robot/chat/image/download?message_id=...` +- 下载视频:`GET http://127.0.0.1:{ROBOT_WECHAT_CLIENT_PORT}/api/v1/robot/chat/video/download?message_id=...` +- 下载语音:`GET http://127.0.0.1:{ROBOT_WECHAT_CLIENT_PORT}/api/v1/robot/chat/voice/download?message_id=...` +- 上传 CDN:`POST http://127.0.0.1:{ROBOT_WECHAT_CLIENT_PORT}/api/v1/robot/chat/media/upload` + +上传接口为 `multipart/form-data`,表单字段: + +- `message_id`: 历史消息 ID +- `media_type`: `image` / `video` / `voice` +- `extension`: 文件扩展名,可选 +- `media`: 下载到的媒体文件 + +## 回复要求 + +- 成功时,使用脚本输出的 CDN URL 继续完成用户原始请求,不要把查找过程当成最终回复。 +- 如果脚本输出未找到媒体,应提示用户先发送一张图片、一个视频或一条语音。 +- 如果脚本返回上传或下载错误,按脚本输出向用户说明原因。 diff --git a/skills/find-recent-chat-media/scripts/bootstrap.py b/skills/find-recent-chat-media/scripts/bootstrap.py new file mode 100644 index 0000000..0e96053 --- /dev/null +++ b/skills/find-recent-chat-media/scripts/bootstrap.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import hashlib +import subprocess +import sys +import traceback +from pathlib import Path + +sys.stderr = sys.stdout + + +def _skill_root_from(script_dir: Path) -> Path: + return script_dir.parent + + +def _venv_dir(script_dir: Path) -> Path: + return _skill_root_from(script_dir) / ".venv" + + +def _venv_python(venv_dir: Path) -> Path: + if sys.platform == "win32": + return venv_dir / "Scripts" / "python.exe" + return venv_dir / "bin" / "python" + + +def _stamp_file(venv_dir: Path) -> Path: + return venv_dir / ".req_hash" + + +def _file_hash(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _deps_up_to_date(requirements_file: Path, venv_dir: Path) -> bool: + stamp = _stamp_file(venv_dir) + if not stamp.is_file(): + return False + return stamp.read_text().strip() == _file_hash(requirements_file) + + +def _write_stamp(requirements_file: Path, venv_dir: Path) -> None: + _stamp_file(venv_dir).write_text(_file_hash(requirements_file)) + + +def _ensure_venv(venv_dir: Path, venv_python: Path) -> int: + if venv_python.is_file(): + return 0 + + sys.stdout.write(f"未检测到技能虚拟环境,正在创建: {venv_dir}\n") + import shutil + py = sys.executable or next( + (shutil.which(candidate) for candidate in ("python3", "python") if shutil.which(candidate)), + None, + ) + if not py: + raise RuntimeError("无法找到 Python 解释器路径") + + try: + subprocess.run([py, "-m", "venv", str(venv_dir)], check=True, stdout=sys.stdout, stderr=sys.stdout) + except subprocess.CalledProcessError as exc: + sys.stdout.write(f"创建虚拟环境失败,退出码: {exc.returncode}\n") + return exc.returncode or 1 + + return 0 + + +def main() -> int: + script_dir = Path(__file__).resolve().parent + requirements_file = script_dir / "requirements.txt" + venv_dir = _venv_dir(script_dir) + venv_python = _venv_python(venv_dir) + + if not requirements_file.is_file(): + sys.stdout.write(f"未找到依赖文件: {requirements_file}\n") + return 1 + + ensure_result = _ensure_venv(venv_dir, venv_python) + if ensure_result != 0: + return ensure_result + + if _deps_up_to_date(requirements_file, venv_dir): + sys.stdout.write("依赖已是最新,跳过安装\n") + return 0 + + try: + subprocess.run([str(venv_python), "-m", "pip", "install", "--upgrade", "pip"], check=True, stdout=sys.stdout, stderr=sys.stdout) + except subprocess.CalledProcessError as exc: + sys.stdout.write(f"升级 pip 失败,退出码: {exc.returncode}\n") + return exc.returncode or 1 + + try: + subprocess.run([str(venv_python), "-m", "pip", "install", "-r", str(requirements_file)], check=True, stdout=sys.stdout, stderr=sys.stdout) + except subprocess.CalledProcessError as exc: + sys.stdout.write(f"安装依赖失败,退出码: {exc.returncode}\n") + return exc.returncode or 1 + + _write_stamp(requirements_file, venv_dir) + sys.stdout.write(f"依赖安装完成,当前技能虚拟环境: {venv_dir}\n") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except SystemExit: + raise + except Exception: + traceback.print_exc(file=sys.stdout) + raise SystemExit(1) diff --git a/skills/find-recent-chat-media/scripts/find_recent_chat_media.py b/skills/find-recent-chat-media/scripts/find_recent_chat_media.py new file mode 100644 index 0000000..adbd30c --- /dev/null +++ b/skills/find-recent-chat-media/scripts/find_recent_chat_media.py @@ -0,0 +1,491 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import json +import mimetypes +import os +import re +import subprocess +import sys +import time +import traceback +import urllib.error +import urllib.parse +import urllib.request +import uuid +from email.message import Message +from pathlib import Path +from typing import Any + +sys.stderr = sys.stdout + +MEDIA_TYPES = ("image", "video", "voice") +MEDIA_MESSAGE_TYPES: dict[str, set[int]] = { + "image": {3}, + "video": {43}, + "voice": {34}, +} +MESSAGE_TYPE_TO_MEDIA_TYPE = { + message_type: media_type + for media_type, message_types in MEDIA_MESSAGE_TYPES.items() + for message_type in message_types +} +DOWNLOAD_PATHS = { + "image": "/api/v1/robot/chat/image/download", + "video": "/api/v1/robot/chat/video/download", + "voice": "/api/v1/robot/chat/voice/download", +} +DEFAULT_EXTENSIONS = { + "image": ".jpg", + "video": ".mp4", + "voice": ".wav", +} +MEDIA_LABELS = { + "image": "图片", + "video": "视频", + "voice": "语音", +} + +MAX_COUNT = 5 +HISTORY_MINUTES = 10 + + +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").resolve() + if Path(sys.prefix).resolve() == venv_dir: + return + + os.execv(str(venv_python), [str(venv_python), str(Path(__file__).resolve()), *sys.argv[1:]]) + + +_ensure_skill_venv_python() + +try: + import pymysql # type: ignore[import-untyped] # noqa: E402 +except ModuleNotFoundError: + _run_bootstrap() + python_executable = _get_python_executable() + os.execv(python_executable, [python_executable, str(Path(__file__).resolve()), *sys.argv[1:]]) + + +def _mysql_connect() -> Any: + 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 _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 _parse_media_types(single_values: list[str], array_values: list[str]) -> list[str]: + values = _expand_json_array_values(single_values + array_values, "media_types") + if not values: + raise ValueError("缺少 media_type") + + result: list[str] = [] + seen: set[str] = set() + for value in values: + media_type = value.strip().lower() + media_candidates = list(MEDIA_TYPES) if media_type == "all" else [media_type] + for candidate in media_candidates: + if candidate not in MEDIA_MESSAGE_TYPES: + raise ValueError(f"不支持的媒体类型: {value}") + if candidate not in seen: + seen.add(candidate) + result.append(candidate) + return result + + +def _parse_cli_params(argv: list[str]) -> tuple[list[str], int]: + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument("--media_type", action="append", default=[]) + parser.add_argument("--media_types", action="append", default=[]) + parser.add_argument("--count", type=int, default=1) + + namespace, unknown = parser.parse_known_args(argv) + if unknown: + raise ValueError(f"存在不支持的参数: {' '.join(unknown)}") + + media_types = _parse_media_types(namespace.media_type, namespace.media_types) + if namespace.count <= 0: + raise ValueError("count 必须大于 0") + count = min(namespace.count, MAX_COUNT) + return media_types, count + + +def _client_base_url(client_port: str) -> str: + return f"http://127.0.0.1:{client_port}" + + +def _build_url(base_url: str, path: str, params: dict[str, object] | None = None) -> str: + url = f"{base_url}{path}" + if not params: + return url + query = urllib.parse.urlencode(params) + return f"{url}?{query}" + + +def _http_get_bytes(url: str, timeout: int = 300) -> tuple[bytes, dict[str, str]]: + try: + with urllib.request.urlopen(url, timeout=timeout) as resp: + headers = {key.lower(): value for key, value in resp.headers.items()} + return resp.read(), headers + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") + raise RuntimeError(f"HTTP {exc.code}: {body}") from exc + except urllib.error.URLError as exc: + raise RuntimeError(str(exc)) from exc + + +def _http_post_multipart( + url: str, + fields: dict[str, str], + file_field: str, + filename: str, + content_type: str, + data: bytes, + timeout: int = 300, +) -> dict[str, Any]: + boundary = f"----wechatRobotSkill{uuid.uuid4().hex}" + chunks: list[bytes] = [] + + for name, value in fields.items(): + chunks.append(f"--{boundary}\r\n".encode("utf-8")) + chunks.append(f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode("utf-8")) + chunks.append(value.encode("utf-8")) + chunks.append(b"\r\n") + + safe_filename = filename.replace('"', "_") + chunks.append(f"--{boundary}\r\n".encode("utf-8")) + chunks.append(f'Content-Disposition: form-data; name="{file_field}"; filename="{safe_filename}"\r\n'.encode("utf-8")) + chunks.append(f"Content-Type: {content_type}\r\n\r\n".encode("utf-8")) + chunks.append(data) + chunks.append(b"\r\n") + chunks.append(f"--{boundary}--\r\n".encode("utf-8")) + + body = b"".join(chunks) + req = urllib.request.Request( + url, + data=body, + headers={"Content-Type": f"multipart/form-data; boundary={boundary}"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + text = resp.read().decode("utf-8") + 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 as exc: + raise RuntimeError(str(exc)) from exc + + if not text.strip(): + return {} + payload = json.loads(text) + if not isinstance(payload, dict): + raise RuntimeError("接口响应不是 JSON 对象") + return payload + + +def _check_api_payload(payload: dict[str, Any], action: str) -> Any: + code = payload.get("code") + if code not in (None, 200): + message = payload.get("message") or "接口返回失败" + raise RuntimeError(f"{action}失败: {message}") + return payload.get("data") + + +def _to_int(value: Any) -> int: + if isinstance(value, int): + return value + if isinstance(value, float): + return int(value) + if isinstance(value, (str, bytes, bytearray)): + try: + return int(value) + except ValueError: + return 0 + return 0 + + +def _fetch_history_media_messages( + conn: Any, + from_wx_id: str, + sender_wx_id: str, + wanted_media_types: list[str], + start_time: int, + end_time: int, + limit: int, +) -> list[dict[str, Any]]: + message_types = sorted({message_type for media_type in wanted_media_types for message_type in MEDIA_MESSAGE_TYPES[media_type]}) + if not message_types: + return [] + + placeholders = ", ".join(["%s"] * len(message_types)) + sql = f""" + SELECT id, type, from_wxid, sender_wxid, created_at + FROM messages + WHERE from_wxid = %s + AND sender_wxid = %s + AND created_at >= %s + AND created_at <= %s + AND `type` IN ({placeholders}) + ORDER BY created_at DESC, id DESC + LIMIT %s + """ + params: tuple[Any, ...] = (from_wx_id, sender_wx_id, start_time, end_time, *message_types, limit) + + with conn.cursor() as cursor: + cursor.execute(sql, params) + rows = cursor.fetchall() + + messages: list[dict[str, Any]] = [] + for row in rows: + if not isinstance(row, dict): + continue + media_type = MESSAGE_TYPE_TO_MEDIA_TYPE.get(_to_int(row.get("type")), "") + if not media_type: + continue + message = dict(row) + message["media_type"] = media_type + messages.append(message) + + return sorted(messages, key=lambda item: (_to_int(item.get("created_at")), _to_int(item.get("id")))) + + +def _filename_from_content_disposition(value: str) -> str: + if not value: + return "" + message = Message() + message["content-disposition"] = value + filename = message.get_filename() + if filename: + return filename + match = re.search(r'filename="?([^";]+)"?', value) + if match: + return match.group(1).strip() + return "" + + +def _extension_from_download(headers: dict[str, str], media_type: str, message_id: int) -> tuple[str, str, str]: + content_type = headers.get("content-type", "").split(";", 1)[0].strip() or "application/octet-stream" + filename = _filename_from_content_disposition(headers.get("content-disposition", "")) + extension = "" + if "." in filename: + extension = "." + filename.rsplit(".", 1)[-1].strip().lower() + if not extension: + extension = mimetypes.guess_extension(content_type) or "" + if not extension: + extension = DEFAULT_EXTENSIONS[media_type] + if not extension.startswith("."): + extension = "." + extension + if not filename: + filename = f"{message_id}{extension}" + return filename, content_type, extension + + +def _download_media(base_url: str, message_id: int, media_type: str) -> tuple[bytes, str, str, str]: + path = DOWNLOAD_PATHS[media_type] + url = _build_url(base_url, path, {"message_id": message_id}) + data, headers = _http_get_bytes(url) + if not data: + raise RuntimeError(f"下载{MEDIA_LABELS[media_type]}失败: 响应为空") + filename, content_type, extension = _extension_from_download(headers, media_type, message_id) + return data, filename, content_type, extension + + +def _upload_media(base_url: str, message_id: int, media_type: str, data: bytes, filename: str, content_type: str, extension: str) -> str: + url = _build_url(base_url, "/api/v1/robot/chat/media/upload") + payload = _http_post_multipart( + url, + { + "message_id": str(message_id), + "media_type": media_type, + "extension": extension, + }, + "media", + filename, + content_type, + data, + ) + response_data = _check_api_payload(payload, "上传媒体到 CDN") + if not isinstance(response_data, dict): + raise RuntimeError("上传媒体到 CDN 失败: 响应 data 格式错误") + media_url = str(response_data.get("url") or "").strip() + if not media_url: + raise RuntimeError("上传媒体到 CDN 失败: 未返回 URL") + return media_url + + +def _media_label(media_types: list[str]) -> str: + return "/".join(MEDIA_LABELS[media_type] for media_type in media_types) + + +def main() -> int: + if len(sys.argv) < 2: + sys.stdout.write("缺少媒体类型参数\n") + return 1 + + try: + media_types, count = _parse_cli_params(sys.argv[1:]) + except (ValueError, json.JSONDecodeError) as exc: + sys.stdout.write(f"参数格式错误: {exc}\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 + + from_wx_id = os.environ.get("ROBOT_FROM_WX_ID", "").strip() + if not from_wx_id: + sys.stdout.write("环境变量 ROBOT_FROM_WX_ID 未配置\n") + return 1 + + sender_wx_id = os.environ.get("ROBOT_SENDER_WX_ID", "").strip() + if not sender_wx_id: + sys.stdout.write("环境变量 ROBOT_SENDER_WX_ID 未配置\n") + return 1 + + base_url = _client_base_url(client_port) + end_time = int(time.time()) + start_time = end_time - HISTORY_MINUTES * 60 + + try: + conn = _mysql_connect() + except Exception as exc: + sys.stdout.write(f"数据库连接失败: {exc}\n") + return 1 + + try: + selected_messages = _fetch_history_media_messages(conn, from_wx_id, sender_wx_id, media_types, start_time, end_time, count) + except Exception as exc: + sys.stdout.write(f"查询历史媒体失败: {exc}\n") + return 1 + finally: + try: + conn.close() + except Exception: + pass + + if not selected_messages: + label = _media_label(media_types) + sys.stdout.write(f"未找到十分钟内由你在当前会话发送的{label},你要先发送一条{label}再让我处理。\n") + return 0 + + items: list[dict[str, Any]] = [] + urls_by_type: dict[str, list[str]] = {"image": [], "video": [], "voice": []} + try: + for message in selected_messages: + message_id = _to_int(message.get("id")) + media_type = str(message.get("media_type") or "") + if message_id <= 0 or media_type not in MEDIA_MESSAGE_TYPES: + continue + data, filename, content_type, extension = _download_media(base_url, message_id, media_type) + media_url = _upload_media(base_url, message_id, media_type, data, filename, content_type, extension) + urls_by_type[media_type].append(media_url) + items.append( + { + "message_id": message_id, + "media_type": media_type, + "created_at": _to_int(message.get("created_at")), + "url": media_url, + } + ) + except Exception as exc: + sys.stdout.write(f"下载或上传历史媒体失败: {exc}\n") + return 1 + + result = { + "media_urls": [item["url"] for item in items], + "image_urls": urls_by_type["image"], + "video_urls": urls_by_type["video"], + "voice_urls": urls_by_type["voice"], + "items": items, + } + sys.stdout.write(json.dumps(result, ensure_ascii=False, indent=2)) + sys.stdout.write("\n") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except SystemExit: + raise + except Exception: + traceback.print_exc(file=sys.stdout) + raise SystemExit(1) diff --git a/skills/find-recent-chat-media/scripts/requirements.txt b/skills/find-recent-chat-media/scripts/requirements.txt new file mode 100644 index 0000000..1bde0c0 --- /dev/null +++ b/skills/find-recent-chat-media/scripts/requirements.txt @@ -0,0 +1,2 @@ +cryptography +pymysql>=1.1,<2 diff --git a/skills/send-local-image/SKILL.md b/skills/send-local-image/SKILL.md new file mode 100644 index 0000000..881fd97 --- /dev/null +++ b/skills/send-local-image/SKILL.md @@ -0,0 +1,68 @@ +--- +name: send-local-image +description: "发送本地图片技能。当你需要将本地图片文件发送到当前微信会话时使用。" +argument-hint: "需要 file_path;可重复传 file_path 发送多张本地图片。" +--- + +# Send Local Image Skill + +## 描述 + +这是一个发送本地图片文件到当前微信会话的技能。 + +技能脚本位于 `scripts/send_local_image.py`,会直接调用机器人客户端接口发送图片,不会修改或生成图片。 + +## 触发条件 + +- 当你有本地图片地址,需要将图片发送到当前微信会话时触发。 + +## 入参规范 + +```json +{ + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "要发送的本地图片文件路径。必须是机器人运行环境可访问的路径。" + }, + "file_paths": { + "type": "array", + "items": { + "type": "string" + }, + "description": "可选,要发送的多个本地图片文件路径。" + } + }, + "anyOf": [{ "required": ["file_path"] }, { "required": ["file_paths"] }], + "additionalProperties": false +} +``` + +对应命令行参数: + +- `--file_path <本地图片路径>` 必填或可重复传入 +- `--file_paths ` 可选,用于一次传入多个本地图片路径 + +## 执行步骤 + +1. 仅当用户明确要发送已有本地图片文件时触发该技能。 +2. 从用户输入或上下文中提取本地图片路径,不要把远程 URL 当成本地路径。 +3. 在仓库根目录执行脚本,例如: + +```bash +python3 scripts/send_local_image.py --file_path '/tmp/example.png' +``` + +4. 脚本会调用客户端接口 `POST http://127.0.0.1:{ROBOT_WECHAT_CLIENT_PORT}/api/v1/robot/message/send/image/local` 将图片发送给当前会话。 + +## 校验规则 + +- `file_path` 不能为空。 +- 传入的路径必须指向一个已存在的本地文件。 +- 远程 `http` 或 `https` 图片地址应使用 `send-remote-image` 技能。 + +## 回复要求 + +- 成功时,脚本输出「图片发送成功」,表示图片已通过客户端接口直接发送,无需 AI 智能体再做额外处理。 +- 失败时,返回脚本输出的具体错误信息。 diff --git a/skills/send-local-image/scripts/send_local_image.py b/skills/send-local-image/scripts/send_local_image.py new file mode 100644 index 0000000..408532b --- /dev/null +++ b/skills/send-local-image/scripts/send_local_image.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import json +import os +import sys +import urllib.parse +import urllib.request +from pathlib import Path + +sys.stderr = sys.stdout + + +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"}, + 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]) -> 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("file_paths 必须是字符串数组") + for item in parsed: + if not isinstance(item, str): + raise ValueError("file_paths 必须是字符串数组") + if item.strip(): + expanded.append(item.strip()) + continue + expanded.append(stripped) + return expanded + + +def _parse_cli_params(argv: list[str]) -> list[str]: + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument("--file_path", action="append", default=[]) + parser.add_argument("--file_paths", action="append", default=[]) + + namespace, unknown = parser.parse_known_args(argv) + if unknown: + raise ValueError(f"存在不支持的参数: {' '.join(unknown)}") + + file_paths = _expand_json_array_values(namespace.file_path + namespace.file_paths) + deduped: list[str] = [] + seen = set() + for file_path in file_paths: + if file_path not in seen: + seen.add(file_path) + deduped.append(file_path) + return deduped + + +def _is_remote_url(value: str) -> bool: + return urllib.parse.urlparse(value).scheme in {"http", "https"} + + +def _normalize_local_file_path(value: str) -> str: + if _is_remote_url(value): + raise ValueError("本地图片技能不支持远程 URL,请使用 send-remote-image") + + path = Path(value).expanduser() + if not path.is_file(): + raise ValueError(f"本地图片文件不存在: {value}") + return str(path.resolve()) + + +def _send_local_image(client_port: str, to_wxid: str, file_path: str) -> None: + send_url = f"http://127.0.0.1:{client_port}/api/v1/robot/message/send/image/local" + _http_post_json(send_url, {"to_wxid": to_wxid, "file_path": file_path}) + + +def main() -> int: + if len(sys.argv) < 2: + sys.stdout.write("缺少本地图片路径\n") + return 1 + + try: + raw_file_paths = _parse_cli_params(sys.argv[1:]) + if not raw_file_paths: + sys.stdout.write("缺少本地图片路径\n") + return 1 + file_paths = [_normalize_local_file_path(value) for value in raw_file_paths] + except (ValueError, json.JSONDecodeError) as exc: + sys.stdout.write(f"参数格式错误: {exc}\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 + + to_wxid = os.environ.get("ROBOT_FROM_WX_ID", "").strip() + if not to_wxid: + sys.stdout.write("环境变量 ROBOT_FROM_WX_ID 未配置\n") + return 1 + + try: + for file_path in file_paths: + _send_local_image(client_port, to_wxid, file_path) + sys.stdout.write("图片发送成功\n") + return 0 + except Exception as exc: + sys.stdout.write(f"图片发送失败: {exc}\n") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/skills/send-mention-message/SKILL.md b/skills/send-mention-message/SKILL.md new file mode 100644 index 0000000..9fa19fa --- /dev/null +++ b/skills/send-mention-message/SKILL.md @@ -0,0 +1,94 @@ +--- +name: send-mention-message +description: "发送艾特/@/提及消息技能。当需要在当前群聊中艾特某个人或某些人,或用户要求你艾特某个/某些群成员时使用。" +argument-hint: "需要 mention;可选 content。mention 可重复传入多个昵称或备注。" +--- + +# Send Mention Message Skill + +## 描述 + +这是一个在当前微信群聊中发送艾特消息的技能。 + +技能脚本位于 `scripts/send_mention_message.py`,会根据用户提供的昵称或备注在当前群成员表里查找未退群成员,得到微信 ID 后调用机器人客户端文本消息接口发送带 `at` 数组的消息。 + +## 触发条件 + +- 需要艾特、@、提及某个群成员或多个群成员。 +- 用户要求「帮我艾特下 xxx」「@ 一下 xxx」「提一下 xxx 和 yyy」。 +- 需要在群聊里点名提醒某人。 + +私聊场景一般不触发本技能;脚本会校验 `ROBOT_FROM_WX_ID` 必须是群聊 ID。 + +## 入参规范 + +```json +{ + "type": "object", + "properties": { + "mention": { + "type": "string", + "description": "要艾特的群成员昵称或备注。按用户原话提取,不要改写。" + }, + "mentions": { + "type": "array", + "items": { + "type": "string" + }, + "description": "要艾特的多个群成员昵称或备注。" + }, + "content": { + "type": "string", + "description": "要发送的文本内容,可选。只艾特不附加正文时可以为空字符串。" + } + }, + "anyOf": [{ "required": ["mention"] }, { "required": ["mentions"] }], + "additionalProperties": false +} +``` + +对应命令行参数: + +- `--mention <昵称或备注>` 必填或可重复传入 +- `--mentions ` 可选,用于一次传入多个昵称或备注 +- `--content <文本内容>` 可选 + +## 成员匹配规则 + +1. 只在当前群聊 `ROBOT_FROM_WX_ID` 对应的 `chat_room_members` 记录中查找。 +2. 只匹配 `is_leaved` 为空或 `0` 的成员,已经退群的成员不能被艾特。 +3. 使用用户给出的昵称或备注做模糊查询,字段优先级为 `remark`,然后是 `nickname`。 +4. 查询到候选成员后,优先选择 `remark` 完全等于输入值的成员。 +5. 如果没有完全相等的 `remark`,选择 `nickname` 完全等于输入值的成员。 +6. 如果没有完全相等结果,选择第一个 `remark` 包含输入值的成员。 +7. 如果仍未命中,选择第一个 `nickname` 包含输入值的成员。 + +## 执行步骤 + +1. 判断用户是否需要在群聊中艾特某人或某些人。 +2. 从用户输入中提取要艾特的昵称或备注,写入 `mention` 或 `mentions`;如用户要求附带正文,写入 `content`。 +3. 在该技能目录执行脚本,例如: + +```bash +python3 scripts/send_mention_message.py --mention '张三' --content '看一下这个' +``` + +4. 脚本会查询数据库表 `chat_room_members`,找到当前群内未退群成员的微信 ID。如果数据库没查询到这个人,你可能需要查询你的记忆,看看有没有一个人的别称叫这个名字。 +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`。 +- 每个要艾特的人都必须能在当前群内匹配到未退群成员。 +- 如果同一个微信 ID 被多个昵称命中,只会艾特一次。 + +## 依赖安装 + +- 脚本首次运行时会自动创建虚拟环境并安装依赖,无需手动执行。 +- 如需手动重新安装,可执行:`python3 scripts/bootstrap.py` + +## 回复要求 + +- 成功时,脚本输出「艾特消息发送成功」,表示消息已通过客户端接口直接发送,无需 AI 智能体再做额外处理。 +- 失败时,返回脚本输出的具体错误信息。 diff --git a/skills/send-mention-message/scripts/bootstrap.py b/skills/send-mention-message/scripts/bootstrap.py new file mode 100644 index 0000000..45005f7 --- /dev/null +++ b/skills/send-mention-message/scripts/bootstrap.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import hashlib +import subprocess +import sys +import traceback +from pathlib import Path + +sys.stderr = sys.stdout + + +def _skill_root_from(script_dir: Path) -> Path: + return script_dir.parent + + +def _venv_dir(script_dir: Path) -> Path: + return _skill_root_from(script_dir) / ".venv" + + +def _venv_python(venv_dir: Path) -> Path: + if sys.platform == "win32": + return venv_dir / "Scripts" / "python.exe" + return venv_dir / "bin" / "python" + + +def _stamp_file(venv_dir: Path) -> Path: + return venv_dir / ".req_hash" + + +def _file_hash(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _deps_up_to_date(requirements_file: Path, venv_dir: Path) -> bool: + stamp = _stamp_file(venv_dir) + if not stamp.is_file(): + return False + return stamp.read_text().strip() == _file_hash(requirements_file) + + +def _write_stamp(requirements_file: Path, venv_dir: Path) -> None: + _stamp_file(venv_dir).write_text(_file_hash(requirements_file)) + + +def _ensure_venv(venv_dir: Path, venv_python: Path) -> int: + if venv_python.is_file(): + return 0 + + sys.stdout.write(f"未检测到技能虚拟环境,正在创建: {venv_dir}\n") + import shutil + py = sys.executable or next( + (shutil.which(candidate) for candidate in ("python3", "python") if shutil.which(candidate)), + None, + ) + if not py: + raise RuntimeError("无法找到 Python 解释器路径") + + try: + subprocess.run([py, "-m", "venv", str(venv_dir)], check=True, stdout=sys.stdout, stderr=sys.stdout) + except subprocess.CalledProcessError as exc: + sys.stdout.write(f"创建虚拟环境失败,退出码: {exc.returncode}\n") + return exc.returncode or 1 + + return 0 + + +def main() -> int: + script_dir = Path(__file__).resolve().parent + requirements_file = script_dir / "requirements.txt" + venv_dir = _venv_dir(script_dir) + venv_python = _venv_python(venv_dir) + + if not requirements_file.is_file(): + sys.stdout.write(f"未找到依赖文件: {requirements_file}\n") + return 1 + + ensure_result = _ensure_venv(venv_dir, venv_python) + if ensure_result != 0: + return ensure_result + + if _deps_up_to_date(requirements_file, venv_dir): + sys.stdout.write("依赖已是最新,跳过安装\n") + return 0 + + try: + subprocess.run([str(venv_python), "-m", "pip", "install", "--upgrade", "pip"], check=True, stdout=sys.stdout, stderr=sys.stdout) + except subprocess.CalledProcessError as exc: + sys.stdout.write(f"升级 pip 失败,退出码: {exc.returncode}\n") + return exc.returncode or 1 + + try: + subprocess.run([str(venv_python), "-m", "pip", "install", "-r", str(requirements_file)], check=True, stdout=sys.stdout, stderr=sys.stdout) + except subprocess.CalledProcessError as exc: + sys.stdout.write(f"安装依赖失败,退出码: {exc.returncode}\n") + return exc.returncode or 1 + + _write_stamp(requirements_file, venv_dir) + sys.stdout.write(f"依赖安装完成,当前技能虚拟环境: {venv_dir}\n") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except SystemExit: + raise + except Exception: + traceback.print_exc(file=sys.stdout) + raise SystemExit(1) \ No newline at end of file diff --git a/skills/send-mention-message/scripts/requirements.txt b/skills/send-mention-message/scripts/requirements.txt new file mode 100644 index 0000000..35f2cf7 --- /dev/null +++ b/skills/send-mention-message/scripts/requirements.txt @@ -0,0 +1,2 @@ +cryptography +pymysql>=1.1,<2 \ No newline at end of file diff --git a/skills/send-mention-message/scripts/send_mention_message.py b/skills/send-mention-message/scripts/send_mention_message.py new file mode 100644 index 0000000..fa13478 --- /dev/null +++ b/skills/send-mention-message/scripts/send_mention_message.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import traceback +import urllib.request +from pathlib import Path + +sys.stderr = sys.stdout + + +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:]]) + + +_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(): + 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"}, + 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 _parse_cli_params(argv: list[str]) -> tuple[list[str], str]: + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument("--mention", action="append", default=[]) + parser.add_argument("--mentions", action="append", default=[]) + parser.add_argument("--content", default="") + + namespace, unknown = parser.parse_known_args(argv) + if unknown: + raise ValueError(f"存在不支持的参数: {' '.join(unknown)}") + + 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) + + return deduped, namespace.content + + +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() + like_keyword = f"%{_escape_like(keyword)}%" + 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 + """ + + 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 + + +def _resolve_mentions(conn, chat_room_id: str, mentions: 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 + + 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_text_message(client_port: str, to_wxid: str, content: str, at_wechat_ids: list[str]) -> None: + send_url = f"http://127.0.0.1:{client_port}/api/v1/robot/message/send/text" + body = { + "to_wxid": to_wxid, + "content": content, + "at": at_wechat_ids, + } + _http_post_json(send_url, body) + + +def main() -> int: + if len(sys.argv) < 2: + sys.stdout.write("缺少要艾特的成员昵称或备注\n") + return 1 + + try: + mentions, content = _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") + return 1 + + chat_room_id = os.environ.get("ROBOT_FROM_WX_ID", "").strip() + if not chat_room_id: + sys.stdout.write("环境变量 ROBOT_FROM_WX_ID 未配置\n") + return 1 + if not chat_room_id.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 + + 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: + 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") + return 0 + except Exception as exc: + sys.stdout.write(f"艾特消息发送失败: {exc}\n") + return 1 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except SystemExit: + raise + except Exception: + traceback.print_exc(file=sys.stdout) + raise SystemExit(1) \ No newline at end of file diff --git a/skills/send-remote-image/SKILL.md b/skills/send-remote-image/SKILL.md new file mode 100644 index 0000000..b205ede --- /dev/null +++ b/skills/send-remote-image/SKILL.md @@ -0,0 +1,68 @@ +--- +name: send-remote-image +description: "发送远程图片技能。当你需要将一个或多个远程图片 URL 发送到当前微信会话时使用。" +argument-hint: "需要 image_url;可重复传 image_url 发送多张远程图片。" +--- + +# Send Remote Image Skill + +## 描述 + +这是一个发送远程图片 URL 到当前微信会话的技能。 + +技能脚本位于 `scripts/send_remote_image.py`,会直接调用机器人客户端接口发送图片,不会下载、修改或生成图片。 + +## 触发条件 + +- 当你有远程图片 URL,需要将图片发送到当前微信会话时触发。 + +## 入参规范 + +```json +{ + "type": "object", + "properties": { + "image_url": { + "type": "string", + "description": "要发送的远程图片 URL,必须以 http 或 https 开头。" + }, + "image_urls": { + "type": "array", + "items": { + "type": "string" + }, + "description": "可选,要发送的多个远程图片 URL。" + } + }, + "anyOf": [{ "required": ["image_url"] }, { "required": ["image_urls"] }], + "additionalProperties": false +} +``` + +对应命令行参数: + +- `--image_url <远程图片URL>` 必填或可重复传入 +- `--image_urls ` 可选,用于一次传入多个远程图片 URL + +## 执行步骤 + +1. 仅当用户明确要发送已有远程图片 URL 时触发该技能。 +2. 从用户输入或上下文中提取图片 URL,不要把本地路径当成远程图片。 +3. 在仓库根目录执行脚本,例如: + +```bash +python3 scripts/send_remote_image.py --image_url 'https://example.com/image.png' +``` + +4. 脚本会调用客户端接口 `POST http://127.0.0.1:{ROBOT_WECHAT_CLIENT_PORT}/api/v1/robot/message/send/image/url` 将图片发送给当前会话。 + +## 校验规则 + +- `image_url` 不能为空。 +- 图片 URL 必须以 `http` 或 `https` 开头。 +- 本地图片文件路径应使用 `send-local-image` 技能。 + +## 回复要求 + +- 成功时,脚本输出「图片发送成功」,表示图片已通过客户端接口直接发送,无需 AI 智能体再做额外处理。 +- 失败时,返回脚本输出的具体错误信息。 diff --git a/skills/send-remote-image/scripts/send_remote_image.py b/skills/send-remote-image/scripts/send_remote_image.py new file mode 100644 index 0000000..99284b8 --- /dev/null +++ b/skills/send-remote-image/scripts/send_remote_image.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import json +import os +import sys +import urllib.parse +import urllib.request + +sys.stderr = sys.stdout + + +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"}, + 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]) -> 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("image_urls 必须是字符串数组") + for item in parsed: + if not isinstance(item, str): + raise ValueError("image_urls 必须是字符串数组") + if item.strip(): + expanded.append(item.strip()) + continue + expanded.append(stripped) + return expanded + + +def _parse_cli_params(argv: list[str]) -> list[str]: + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument("--image_url", action="append", default=[]) + parser.add_argument("--image_urls", action="append", default=[]) + + namespace, unknown = parser.parse_known_args(argv) + if unknown: + raise ValueError(f"存在不支持的参数: {' '.join(unknown)}") + + image_urls = _expand_json_array_values(namespace.image_url + namespace.image_urls) + deduped: list[str] = [] + seen = set() + for image_url in image_urls: + if image_url not in seen: + seen.add(image_url) + deduped.append(image_url) + return deduped + + +def _validate_remote_image_url(value: str) -> str: + parsed = urllib.parse.urlparse(value) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise ValueError(f"远程图片 URL 格式不正确: {value}") + return value + + +def _send_remote_images(client_port: str, to_wxid: str, image_urls: list[str]) -> None: + send_url = f"http://127.0.0.1:{client_port}/api/v1/robot/message/send/image/url" + _http_post_json(send_url, {"to_wxid": to_wxid, "image_urls": image_urls}) + + +def main() -> int: + if len(sys.argv) < 2: + sys.stdout.write("缺少远程图片 URL\n") + return 1 + + try: + raw_image_urls = _parse_cli_params(sys.argv[1:]) + if not raw_image_urls: + sys.stdout.write("缺少远程图片 URL\n") + return 1 + image_urls = [_validate_remote_image_url(value) for value in raw_image_urls] + except (ValueError, json.JSONDecodeError) as exc: + sys.stdout.write(f"参数格式错误: {exc}\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 + + to_wxid = os.environ.get("ROBOT_FROM_WX_ID", "").strip() + if not to_wxid: + sys.stdout.write("环境变量 ROBOT_FROM_WX_ID 未配置\n") + return 1 + + try: + _send_remote_images(client_port, to_wxid, image_urls) + sys.stdout.write("图片发送成功\n") + return 0 + except Exception as exc: + sys.stdout.write(f"图片发送失败: {exc}\n") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file