From 7898db9291e2c3fb777b23510f735a4f6ee8f7e4 Mon Sep 17 00:00:00 2001 From: hp0912 <809211365@qq.com> Date: Tue, 28 Jul 2026 13:25:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=90=88=E5=B9=B6=E5=8F=91=E9=80=81?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E6=8A=80=E8=83=BD=E3=80=81=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=8F=91=E9=80=81=E6=96=87=E4=BB=B6=E6=8A=80=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- skills/send-file/SKILL.md | 82 ++++++++ skills/send-file/agents/openai.yaml | 4 + skills/send-file/scripts/send_file.py | 199 ++++++++++++++++++ skills/send-image/SKILL.md | 82 ++++++++ skills/send-image/agents/openai.yaml | 4 + skills/send-image/scripts/send_image.py | 193 +++++++++++++++++ skills/send-local-image/SKILL.md | 67 ------ .../scripts/send_local_image.py | 125 ----------- skills/send-remote-image/SKILL.md | 67 ------ .../scripts/send_remote_image.py | 116 ---------- skills/web-page/SKILL.md | 2 +- 11 files changed, 565 insertions(+), 376 deletions(-) create mode 100644 skills/send-file/SKILL.md create mode 100644 skills/send-file/agents/openai.yaml create mode 100644 skills/send-file/scripts/send_file.py create mode 100644 skills/send-image/SKILL.md create mode 100644 skills/send-image/agents/openai.yaml create mode 100644 skills/send-image/scripts/send_image.py delete mode 100644 skills/send-local-image/SKILL.md delete mode 100644 skills/send-local-image/scripts/send_local_image.py delete mode 100644 skills/send-remote-image/SKILL.md delete mode 100644 skills/send-remote-image/scripts/send_remote_image.py diff --git a/skills/send-file/SKILL.md b/skills/send-file/SKILL.md new file mode 100644 index 0000000..3fa2574 --- /dev/null +++ b/skills/send-file/SKILL.md @@ -0,0 +1,82 @@ +--- +name: send-file +description: "发送本地或远程文件到当前微信会话。用户要求发送、转发或分享一个或多个已有文档、压缩包及其他普通文件,且输入是机器人可访问的本地路径或 http/https 文件 URL 时使用;图片应优先使用 send-image。" +--- + +# 发送文件 + +使用 `scripts/send_file.py` 调用机器人客户端文件接口,将一个或多个本地文件、远程文件 URL 发送到当前微信会话。 + +## 入参 + +```json +{ + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "要发送的本地文件路径,必须是机器人运行环境可访问的文件。" + }, + "file_paths": { + "type": "array", + "items": { + "type": "string" + }, + "description": "要发送的多个本地文件路径。" + }, + "file_url": { + "type": "string", + "description": "要发送的远程文件 URL,必须以 http 或 https 开头。" + }, + "file_urls": { + "type": "array", + "items": { + "type": "string" + }, + "description": "要发送的多个远程文件 URL。" + } + }, + "anyOf": [ + { "required": ["file_path"] }, + { "required": ["file_paths"] }, + { "required": ["file_url"] }, + { "required": ["file_urls"] } + ], + "additionalProperties": false +} +``` + +可同时提供本地路径和远程 URL。对应命令行参数: + +- `--file_path <本地文件路径>`:可重复传入。 +- `--file_paths `:一次传入多个本地文件路径。 +- `--file_url <远程文件URL>`:可重复传入。 +- `--file_urls `:一次传入多个远程文件 URL。 + +## 执行 + +在技能目录运行脚本: + +```bash +python3 scripts/send_file.py --file_path '/tmp/report.pdf' +``` + +发送远程文件: + +```bash +python3 scripts/send_file.py --file_urls '["https://example.com/a.pdf","https://example.com/b.zip"]' +``` + +本地文件逐个调用 `POST /api/v1/robot/message/send/file/local`;远程文件合并调用 `POST /api/v1/robot/message/send/file/url`。接收方固定读取 `ROBOT_FROM_WX_ID`,客户端端口读取 `ROBOT_WECHAT_CLIENT_PORT`。 + +## 约束 + +- 至少提供一个非空本地路径或远程 URL。 +- 本地路径必须指向已存在、非空且不超过 25MB 的文件,不能把 URL 作为本地路径传入。 +- 远程 URL 必须包含有效的 `http` 或 `https` 协议及主机名;客户端下载后的文件也不能超过 25MB。 +- 脚本按首次出现顺序去重;所有参数通过校验后,单个发送失败时会继续尝试其余文件,最终返回全部失败原因。 + +## 输出 + +- 全部成功时输出「文件发送成功」,无需再发送额外消息。 +- 任一失败时返回具体错误信息,不要声称文件已全部发送。 diff --git a/skills/send-file/agents/openai.yaml b/skills/send-file/agents/openai.yaml new file mode 100644 index 0000000..61c285d --- /dev/null +++ b/skills/send-file/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "发送文件" + short_description: "将一个或多个本地文件或远程文件链接发送到当前微信会话" + default_prompt: "使用 $send-file 将这些本地文件或远程文件发送到当前微信会话。" diff --git a/skills/send-file/scripts/send_file.py b/skills/send-file/scripts/send_file.py new file mode 100644 index 0000000..e9015fa --- /dev/null +++ b/skills/send-file/scripts/send_file.py @@ -0,0 +1,199 @@ +#!/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 + +MAX_FILE_SIZE = 25 * 1024 * 1024 + + +def _raise_for_client_error(response: dict) -> None: + if not response: + return + + code = response.get("code") + if code is not None and str(code) != "200": + message = str(response.get("message") or f"客户端返回错误码 {code}") + raise RuntimeError(message) + + data = response.get("data") + if isinstance(data, list): + failures = [ + item.strip() + for item in data + if isinstance(item, str) and item.lstrip().startswith("失败:") + ] + if failures: + raise RuntimeError(";".join(failures)) + + +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 {} + + response = json.loads(text) + if not isinstance(response, dict): + raise RuntimeError("客户端返回格式不正确") + _raise_for_client_error(response) + return response + + +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) or not all( + isinstance(item, str) for item in parsed + ): + raise ValueError(f"{label} 必须是字符串数组") + expanded.extend(item.strip() for item in parsed if item.strip()) + continue + expanded.append(stripped) + return expanded + + +def _dedupe(values: list[str]) -> list[str]: + deduped: list[str] = [] + seen: set[str] = set() + for value in values: + if value not in seen: + seen.add(value) + deduped.append(value) + return deduped + + +def _parse_cli_params(argv: list[str]) -> tuple[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=[]) + parser.add_argument("--file_url", action="append", default=[]) + parser.add_argument("--file_urls", 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, "file_paths" + ) + file_urls = _expand_json_array_values( + namespace.file_url + namespace.file_urls, "file_urls" + ) + return _dedupe(file_paths), _dedupe(file_urls) + + +def _is_remote_url(value: str) -> bool: + return urllib.parse.urlparse(value).scheme.lower() in {"http", "https"} + + +def _normalize_local_file_path(value: str) -> str: + if _is_remote_url(value): + raise ValueError(f"本地文件路径不能是远程 URL: {value}") + + path = Path(value).expanduser() + if not path.is_file(): + raise ValueError(f"本地文件不存在: {value}") + + file_size = path.stat().st_size + if file_size <= 0: + raise ValueError(f"本地文件不能为空: {value}") + if file_size > MAX_FILE_SIZE: + raise ValueError(f"本地文件大小不能超过 25MB: {value}") + return str(path.resolve()) + + +def _validate_remote_file_url(value: str) -> str: + parsed = urllib.parse.urlparse(value) + if parsed.scheme.lower() not in {"http", "https"} or not parsed.netloc: + raise ValueError(f"远程文件 URL 格式不正确: {value}") + return value + + +def _send_local_file(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/file/local" + ) + _http_post_json(send_url, {"to_wxid": to_wxid, "file_path": file_path}) + + +def _send_remote_files( + client_port: str, to_wxid: str, file_urls: list[str] +) -> None: + send_url = f"http://127.0.0.1:{client_port}/api/v1/robot/message/send/file/url" + _http_post_json(send_url, {"to_wxid": to_wxid, "file_urls": file_urls}) + + +def main() -> int: + if len(sys.argv) < 2: + sys.stdout.write("缺少本地文件路径或远程文件 URL\n") + return 1 + + try: + raw_file_paths, raw_file_urls = _parse_cli_params(sys.argv[1:]) + if not raw_file_paths and not raw_file_urls: + sys.stdout.write("缺少本地文件路径或远程文件 URL\n") + return 1 + file_paths = _dedupe( + [_normalize_local_file_path(value) for value in raw_file_paths] + ) + file_urls = [ + _validate_remote_file_url(value) for value in raw_file_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 + + failures: list[str] = [] + for file_path in file_paths: + try: + _send_local_file(client_port, to_wxid, file_path) + except Exception as exc: + failures.append(f"本地文件 {file_path}: {exc}") + + if file_urls: + try: + _send_remote_files(client_port, to_wxid, file_urls) + except Exception as exc: + failures.append(f"远程文件: {exc}") + + if failures: + sys.stdout.write(f"文件发送失败: {';'.join(failures)}\n") + return 1 + + sys.stdout.write("文件发送成功\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/send-image/SKILL.md b/skills/send-image/SKILL.md new file mode 100644 index 0000000..b4af538 --- /dev/null +++ b/skills/send-image/SKILL.md @@ -0,0 +1,82 @@ +--- +name: send-image +description: "发送本地或远程图片到当前微信会话。用户要求发送、转发或分享一个或多个已有图片文件/图片 URL,或其他技能产出了需要发送的本地图片路径时使用;不要用于生成或修改图片。" +--- + +# 发送图片 + +使用 `scripts/send_image.py` 调用机器人客户端接口,将一个或多个本地图片文件、远程图片 URL 发送到当前微信会话。 + +## 入参 + +```json +{ + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "要发送的本地图片路径,必须是机器人运行环境可访问的文件。" + }, + "file_paths": { + "type": "array", + "items": { + "type": "string" + }, + "description": "要发送的多个本地图片路径。" + }, + "image_url": { + "type": "string", + "description": "要发送的远程图片 URL,必须以 http 或 https 开头。" + }, + "image_urls": { + "type": "array", + "items": { + "type": "string" + }, + "description": "要发送的多个远程图片 URL。" + } + }, + "anyOf": [ + { "required": ["file_path"] }, + { "required": ["file_paths"] }, + { "required": ["image_url"] }, + { "required": ["image_urls"] } + ], + "additionalProperties": false +} +``` + +可同时提供本地路径和远程 URL。对应命令行参数: + +- `--file_path <本地图片路径>`:可重复传入。 +- `--file_paths `:一次传入多个本地图片路径。 +- `--image_url <远程图片URL>`:可重复传入。 +- `--image_urls `:一次传入多个远程图片 URL。 + +## 执行 + +在技能目录运行脚本: + +```bash +python3 scripts/send_image.py --file_path '/tmp/example.png' +``` + +发送远程图片: + +```bash +python3 scripts/send_image.py --image_urls '["https://example.com/a.png","https://example.com/b.jpg"]' +``` + +本地图片逐个调用 `POST /api/v1/robot/message/send/image/local`;远程图片合并调用 `POST /api/v1/robot/message/send/image/url`。接收方固定读取 `ROBOT_FROM_WX_ID`,客户端端口读取 `ROBOT_WECHAT_CLIENT_PORT`。 + +## 约束 + +- 至少提供一个非空本地路径或远程 URL。 +- 本地路径必须指向已存在且非空的文件,不能把 URL 作为本地路径传入。 +- 远程 URL 必须包含有效的 `http` 或 `https` 协议及主机名。 +- 脚本按首次出现顺序去重;所有参数通过校验后,单个发送失败时会继续尝试其余图片,最终返回全部失败原因。 + +## 输出 + +- 全部成功时输出「图片发送成功」,无需再发送额外消息。 +- 任一失败时返回具体错误信息,不要声称图片已全部发送。 diff --git a/skills/send-image/agents/openai.yaml b/skills/send-image/agents/openai.yaml new file mode 100644 index 0000000..081d003 --- /dev/null +++ b/skills/send-image/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "发送图片" + short_description: "将一个或多个本地图片文件或远程图片链接发送到当前微信会话" + default_prompt: "使用 $send-image 将这些本地图片或远程图片发送到当前微信会话。" diff --git a/skills/send-image/scripts/send_image.py b/skills/send-image/scripts/send_image.py new file mode 100644 index 0000000..e0220a5 --- /dev/null +++ b/skills/send-image/scripts/send_image.py @@ -0,0 +1,193 @@ +#!/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 _raise_for_client_error(response: dict) -> None: + if not response: + return + + code = response.get("code") + if code is not None and str(code) != "200": + message = str(response.get("message") or f"客户端返回错误码 {code}") + raise RuntimeError(message) + + data = response.get("data") + if isinstance(data, list): + failures = [ + item.strip() + for item in data + if isinstance(item, str) and item.lstrip().startswith("失败:") + ] + if failures: + raise RuntimeError(";".join(failures)) + + +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 {} + + response = json.loads(text) + if not isinstance(response, dict): + raise RuntimeError("客户端返回格式不正确") + _raise_for_client_error(response) + return response + + +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) or not all( + isinstance(item, str) for item in parsed + ): + raise ValueError(f"{label} 必须是字符串数组") + expanded.extend(item.strip() for item in parsed if item.strip()) + continue + expanded.append(stripped) + return expanded + + +def _dedupe(values: list[str]) -> list[str]: + deduped: list[str] = [] + seen: set[str] = set() + for value in values: + if value not in seen: + seen.add(value) + deduped.append(value) + return deduped + + +def _parse_cli_params(argv: list[str]) -> tuple[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=[]) + 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)}") + + file_paths = _expand_json_array_values( + namespace.file_path + namespace.file_paths, "file_paths" + ) + image_urls = _expand_json_array_values( + namespace.image_url + namespace.image_urls, "image_urls" + ) + return _dedupe(file_paths), _dedupe(image_urls) + + +def _is_remote_url(value: str) -> bool: + return urllib.parse.urlparse(value).scheme.lower() in {"http", "https"} + + +def _normalize_local_image_path(value: str) -> str: + if _is_remote_url(value): + raise ValueError(f"本地图片路径不能是远程 URL: {value}") + + path = Path(value).expanduser() + if not path.is_file(): + raise ValueError(f"本地图片文件不存在: {value}") + if path.stat().st_size <= 0: + raise ValueError(f"本地图片文件不能为空: {value}") + return str(path.resolve()) + + +def _validate_remote_image_url(value: str) -> str: + parsed = urllib.parse.urlparse(value) + if parsed.scheme.lower() not in {"http", "https"} or not parsed.netloc: + raise ValueError(f"远程图片 URL 格式不正确: {value}") + return value + + +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 _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_file_paths, raw_image_urls = _parse_cli_params(sys.argv[1:]) + if not raw_file_paths and not raw_image_urls: + sys.stdout.write("缺少本地图片路径或远程图片 URL\n") + return 1 + file_paths = _dedupe( + [_normalize_local_image_path(value) for value in raw_file_paths] + ) + 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 + + failures: list[str] = [] + for file_path in file_paths: + try: + _send_local_image(client_port, to_wxid, file_path) + except Exception as exc: + failures.append(f"本地图片 {file_path}: {exc}") + + if image_urls: + try: + _send_remote_images(client_port, to_wxid, image_urls) + except Exception as exc: + failures.append(f"远程图片: {exc}") + + if failures: + sys.stdout.write(f"图片发送失败: {';'.join(failures)}\n") + return 1 + + sys.stdout.write("图片发送成功\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/send-local-image/SKILL.md b/skills/send-local-image/SKILL.md deleted file mode 100644 index fffbe19..0000000 --- a/skills/send-local-image/SKILL.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -name: send-local-image -description: "发送本地图片技能。当你需要将本地图片文件发送到当前微信会话时使用。" ---- - -# 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 deleted file mode 100644 index 408532b..0000000 --- a/skills/send-local-image/scripts/send_local_image.py +++ /dev/null @@ -1,125 +0,0 @@ -#!/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-remote-image/SKILL.md b/skills/send-remote-image/SKILL.md deleted file mode 100644 index 32ade6c..0000000 --- a/skills/send-remote-image/SKILL.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -name: send-remote-image -description: "发送远程图片技能。当你需要将一个或多个远程图片 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 deleted file mode 100644 index 99284b8..0000000 --- a/skills/send-remote-image/scripts/send_remote_image.py +++ /dev/null @@ -1,116 +0,0 @@ -#!/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 diff --git a/skills/web-page/SKILL.md b/skills/web-page/SKILL.md index 0f7ca0e..9e931eb 100644 --- a/skills/web-page/SKILL.md +++ b/skills/web-page/SKILL.md @@ -245,5 +245,5 @@ tsx scripts/web_page.ts --url 'https://example.com' --mode screenshot --screensh - `content` 模式成功时,智能体应基于脚本输出给用户总结、解释或回答问题。 - 自动化动作失败时,脚本会输出具体失败在第几个 action 及失败原因。 - `screenshot` 模式成功且已发送图片时,脚本输出「页面截图已发送」。 -- `screenshot` 模式成功但未发送图片时,脚本输出本地 PNG 路径,智能体可继续用 `send-local-image` 技能发送。 +- `screenshot` 模式成功但未发送图片时,脚本输出本地 PNG 路径,智能体可继续用 `send-image` 技能发送。 - 失败时,返回脚本输出的具体错误信息。