From ee1bd583d8277c2f9b2e2c6f59b6cc369cc095d0 Mon Sep 17 00:00:00 2001 From: zhilv Date: Sat, 2 May 2026 19:02:40 +0800 Subject: [PATCH 1/8] =?UTF-8?q?=E2=9C=A8=20feat(command):=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E5=91=BD=E4=BB=A4=E7=9B=91=E5=90=AC=E4=B8=8E=E5=A4=96?= =?UTF-8?q?=E6=8E=A5=E5=9B=9E=E8=B0=83=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 `#四个中文字+空格` 消息匹配规则,可配置前缀和长度 - 匹配成功后 POST 到 COMMAND_CALLBACK_URL,携带命令名、内容、用户信息 - 使用 EventMixin.events() 订阅消息流,on_close 自动取消监听 - 新增配置项:COMMAND_PREFIX、COMMAND_LENGTH、COMMAND_CALLBACK_URL - 更新 .env.example 和 README 文档 --- .env.example | 8 +++++++ README.md | 44 ++++++++++++++++++++++++++++++++++ config.py | 5 ++++ handlers/command.py | 57 +++++++++++++++++++++++++++++++++++++++++++++ plugin.py | 46 +++++++++++++++++++++++++++++++++++- 5 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 handlers/command.py diff --git a/.env.example b/.env.example index 0d349d8..1b356e5 100644 --- a/.env.example +++ b/.env.example @@ -15,3 +15,11 @@ ALLOWED_EXTENSIONS= # ── QQ API ── QQ_API_TIMEOUT=10 QQ_API_MAX_RETRIES=2 + +# ── 命令监听 ── +# 命令前缀,默认 # +COMMAND_PREFIX=# +# 命令名长度(中文字数),默认 4 +COMMAND_LENGTH=4 +# 匹配到命令后的回调 URL,留空则不监听 +COMMAND_CALLBACK_URL= diff --git a/README.md b/README.md index 7bca71d..f902c2e 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,9 @@ uv run python -m ncatbot | `ALLOWED_EXTENSIONS` | 否 | 空(不限) | 允许的扩展名,逗号分隔,如 `jpg,png,pdf` | | `QQ_API_TIMEOUT` | 否 | `10` | QQ API 超时秒数 | | `QQ_API_MAX_RETRIES` | 否 | `2` | QQ API 失败重试次数 | +| `COMMAND_PREFIX` | 否 | `#` | 命令前缀 | +| `COMMAND_LENGTH` | 否 | `4` | 命令名字符数(中文字数) | +| `COMMAND_CALLBACK_URL` | 否 | 空(不监听) | 命令匹配后的回调 URL | ## 接口说明 @@ -182,6 +185,46 @@ curl -X POST http://localhost:8081/webhook \ 每条消息独立处理,一条失败不影响其他消息。`results` 数组按原始 `index` 排序,包含每条消息的发送结果。 +### 命令监听 + +插件会自动监听 QQ 消息,当消息以 `#四个中文字+空格` 开头时,将命令内容 POST 到 `COMMAND_CALLBACK_URL`。 + +**匹配规则:** + +``` +#测试命令 你好世界 +│ │ │ │ +│ │ │ └── 命令内容(content) +│ └── 空格分隔 +└── 命令名(4个中文字) + └── 前缀(默认 #) +``` + +- 前缀、命令名长度可通过 `COMMAND_PREFIX`、`COMMAND_LENGTH` 配置 +- 不配置 `COMMAND_CALLBACK_URL` 则不监听 + +**回调请求体(POST JSON):** + +```json +{ + "command": "测试命令", + "content": "你好世界", + "raw_message": "#测试命令 你好世界", + "user_id": "123456", + "group_id": "789012", + "message_id": "abc123" +} +``` + +| 字段 | 说明 | +|---|---| +| `command` | 命令名(4个中文字) | +| `content` | 命令后的内容 | +| `raw_message` | 原始消息文本 | +| `user_id` | 发送者 QQ 号 | +| `group_id` | 群号(私聊消息无此字段) | +| `message_id` | 消息 ID | + ## 项目结构 ``` @@ -191,6 +234,7 @@ curl -X POST http://localhost:8081/webhook \ ├── response.py # 统一响应格式 ├── handlers/ │ ├── __init__.py +│ ├── command.py # 命令监听匹配与回调 │ ├── health.py # GET /healthz │ ├── message.py # POST /webhook │ └── upload.py # POST /upload diff --git a/config.py b/config.py index bb2083a..7f245ac 100644 --- a/config.py +++ b/config.py @@ -31,3 +31,8 @@ ALLOWED_EXTENSIONS: set[str] = set( # ── QQ API ─────────────────────────────────────────────────── QQ_API_TIMEOUT: float = float(os.environ.get("QQ_API_TIMEOUT", "10")) QQ_API_MAX_RETRIES: int = int(os.environ.get("QQ_API_MAX_RETRIES", "2")) + +# ── 命令监听 ──────────────────────────────────────────────── +COMMAND_PREFIX: str = os.environ.get("COMMAND_PREFIX", "#") +COMMAND_LENGTH: int = int(os.environ.get("COMMAND_LENGTH", "4")) +COMMAND_CALLBACK_URL: str = os.environ.get("COMMAND_CALLBACK_URL", "") diff --git a/handlers/command.py b/handlers/command.py new file mode 100644 index 0000000..1c7d943 --- /dev/null +++ b/handlers/command.py @@ -0,0 +1,57 @@ +"""命令监听处理器:匹配 #命令名+空格 格式的消息,转发到外部回调 URL。""" + +import re + +import aiohttp + +from ..config import COMMAND_CALLBACK_URL, COMMAND_LENGTH, COMMAND_PREFIX +from ..response import error, ok + + +def build_command_pattern() -> re.Pattern: + """构建命令匹配正则:# + N个中文字 + 空格。""" + return re.compile( + rf"^{re.escape(COMMAND_PREFIX)}([\u4e00-\u9fff]{{{COMMAND_LENGTH}}})\s+(.+)", + re.DOTALL, + ) + + +COMMAND_PATTERN = build_command_pattern() + + +def parse_command(raw_message: str) -> dict | None: + """解析消息,匹配命令模式。返回 {command, content, raw_message} 或 None。""" + match = COMMAND_PATTERN.match(raw_message.strip()) + if not match: + return None + return { + "command": match.group(1), + "content": match.group(2).strip(), + "raw_message": raw_message.strip(), + } + + +async def send_command_callback(data: dict, logger) -> bool: + """将命令数据 POST 到外部回调 URL。返回是否成功。""" + if not COMMAND_CALLBACK_URL: + logger.warning("COMMAND_CALLBACK_URL 未配置,跳过命令回调") + return False + + try: + async with aiohttp.ClientSession() as session: + async with session.post( + COMMAND_CALLBACK_URL, + json=data, + timeout=aiohttp.ClientTimeout(total=10), + ) as resp: + if resp.status >= 400: + body = await resp.text() + logger.error( + "命令回调失败: status=%d url=%s body=%s", + resp.status, COMMAND_CALLBACK_URL, body[:200], + ) + return False + return True + except Exception as exc: + logger.error("命令回调异常: url=%s error=%s", COMMAND_CALLBACK_URL, exc) + return False diff --git a/plugin.py b/plugin.py index 87054f1..979b85f 100644 --- a/plugin.py +++ b/plugin.py @@ -7,7 +7,8 @@ import os from aiohttp import web from ncatbot.plugin import NcatBotPlugin -from .config import HOST, PORT, UPLOAD_DIR, WEBHOOK_API_KEY +from .config import COMMAND_CALLBACK_URL, COMMAND_LENGTH, COMMAND_PREFIX, HOST, PORT, UPLOAD_DIR, WEBHOOK_API_KEY +from .handlers.command import parse_command, send_command_callback from .handlers.health import health_handler from .handlers.message import webhook_handler from .handlers.upload import cleanup_expired_files, upload_handler @@ -26,14 +27,25 @@ class WebHookPlugin(NcatBotPlugin): super().__init__(*args, **kwargs) self._webhook_runner: web.AppRunner | None = None self._cleanup_task: asyncio.Task | None = None + self._listener_task: asyncio.Task | None = None async def on_load(self): self.logger.info("Webhook 插件已加载") self.logger.info("WEBHOOK_API_KEY: %s", "已配置" if os.environ.get("WEBHOOK_API_KEY") else "自动生成") + self.logger.info("命令监听: 前缀=%s 长度=%d 回调=%s", COMMAND_PREFIX, COMMAND_LENGTH, + COMMAND_CALLBACK_URL or "未配置") asyncio.create_task(self._start_webhook()) self._cleanup_task = asyncio.create_task(self._cleanup_loop()) + self._listener_task = asyncio.create_task(self._message_listener()) async def on_close(self): + if self._listener_task is not None: + self._listener_task.cancel() + try: + await self._listener_task + except asyncio.CancelledError: + pass + self._listener_task = None if self._cleanup_task is not None: self._cleanup_task.cancel() try: @@ -53,6 +65,38 @@ class WebHookPlugin(NcatBotPlugin): except Exception as exc: self.logger.error("清理过期文件失败: %s", exc) + async def _message_listener(self) -> None: + """监听 QQ 消息,匹配命令模式后转发到外部回调。""" + try: + async with self.events("message") as stream: + async for event in stream: + try: + raw_message = event.data.raw_message + if not raw_message: + continue + parsed = parse_command(raw_message) + if not parsed: + continue + # 构建回调数据 + data = { + "command": parsed["command"], + "content": parsed["content"], + "raw_message": parsed["raw_message"], + "user_id": event.data.user_id, + "message_id": event.data.message_id, + } + if hasattr(event.data, "group_id"): + data["group_id"] = event.data.group_id + self.logger.info( + "命令监听匹配: command=%s user=%s group=%s", + parsed["command"], data["user_id"], data.get("group_id", "-"), + ) + asyncio.create_task(send_command_callback(data, self.logger)) + except Exception as exc: + self.logger.error("消息处理异常: %s", exc) + except asyncio.CancelledError: + return + def _create_app(self) -> web.Application: app = web.Application(middlewares=[request_id_middleware, auth_middleware]) app["qq_api"] = self.api -- 2.49.1 From af0f6c7ec696f940ff77b74da03d2fca67190f34 Mon Sep 17 00:00:00 2001 From: zhilv Date: Sat, 2 May 2026 19:29:18 +0800 Subject: [PATCH 2/8] =?UTF-8?q?=E2=9C=A8=20feat(command):=20=E5=9B=9E?= =?UTF-8?q?=E8=B0=83=E5=93=8D=E5=BA=94=E8=87=AA=E5=8A=A8=E5=9B=9E=E5=A4=8D?= =?UTF-8?q?=E5=88=B0=20QQ?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 回调服务器可返回 reply 或 messages 字段,插件自动回复到原消息来源 - reply 为纯文本回复,messages 格式同 /webhook 接口 - 支持通过 group_id/user_id 覆盖回复目标 - 无需回复时返回空 JSON 即可 - 更新 README 文档说明回调响应格式 --- README.md | 29 +++++++++++++ handlers/command.py | 102 ++++++++++++++++++++++++++++++++++++++++---- plugin.py | 4 +- 3 files changed, 126 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index f902c2e..999e728 100644 --- a/README.md +++ b/README.md @@ -225,6 +225,35 @@ curl -X POST http://localhost:8081/webhook \ | `group_id` | 群号(私聊消息无此字段) | | `message_id` | 消息 ID | +**回调响应格式(自动回复到 QQ):** + +回调服务器返回 JSON,插件会自动将内容回复到原消息来源(群/私聊)。 + +纯文本回复: +```json +{"reply": "收到你的命令了"} +``` + +批量回复(text/image/video 组合发送,file 单独发送): +```json +{ + "messages": [ + {"type": "text", "msg": "处理结果如下"}, + {"type": "image", "url": "https://example.com/result.png"}, + {"type": "file", "url": "report.pdf"} + ] +} +``` + +| 字段 | 说明 | +|---|---| +| `reply` | 纯文本回复(与 `messages` 二选一,`messages` 优先) | +| `messages` | 批量回复数组,格式同 `/webhook` 的 `messages` 字段 | +| `group_id` | 可选,覆盖回复目标群号(默认回复到原群) | +| `user_id` | 可选,覆盖回复目标 QQ 号(默认回复到原发送者) | + +不需要回复时返回 `{}` 或空响应即可。 + ## 项目结构 ``` diff --git a/handlers/command.py b/handlers/command.py index 1c7d943..53ddf9c 100644 --- a/handlers/command.py +++ b/handlers/command.py @@ -1,11 +1,10 @@ -"""命令监听处理器:匹配 #命令名+空格 格式的消息,转发到外部回调 URL。""" +"""命令监听处理器:匹配 #命令名+空格 格式的消息,转发到外部回调 URL 并自动回复。""" import re import aiohttp from ..config import COMMAND_CALLBACK_URL, COMMAND_LENGTH, COMMAND_PREFIX -from ..response import error, ok def build_command_pattern() -> re.Pattern: @@ -31,11 +30,25 @@ def parse_command(raw_message: str) -> dict | None: } -async def send_command_callback(data: dict, logger) -> bool: - """将命令数据 POST 到外部回调 URL。返回是否成功。""" +async def send_command_callback(data: dict, event, api, logger) -> None: + """将命令数据 POST 到外部回调 URL,根据响应自动回复到 QQ。 + + 回调服务器返回格式: + { + "reply": "回复文本", // 纯文本回复 + "messages": [ // 批量回复(可选,优先于 reply) + {"type": "text", "msg": "..."}, + {"type": "image", "url": "..."}, + {"type": "file", "url": "..."}, + {"type": "video", "url": "..."} + ] + } + + 所有字段均为可选,无回复内容时返回空 JSON 即可。 + """ if not COMMAND_CALLBACK_URL: logger.warning("COMMAND_CALLBACK_URL 未配置,跳过命令回调") - return False + return try: async with aiohttp.ClientSession() as session: @@ -50,8 +63,81 @@ async def send_command_callback(data: dict, logger) -> bool: "命令回调失败: status=%d url=%s body=%s", resp.status, COMMAND_CALLBACK_URL, body[:200], ) - return False - return True + return + + # 解析响应,自动回复 + try: + result = await resp.json(content_type=None) + except Exception: + return + + if not isinstance(result, dict): + return + + await _handle_reply(result, event, api, logger) + except Exception as exc: logger.error("命令回调异常: url=%s error=%s", COMMAND_CALLBACK_URL, exc) - return False + + +async def _handle_reply(result: dict, event, api, logger) -> None: + """处理回调响应,自动回复到原消息来源。""" + group_id = result.get("group_id") or (getattr(event.data, "group_id", None)) + user_id = result.get("user_id") or event.data.user_id + messages = result.get("messages") + reply = result.get("reply") + + # 批量回复(使用 post_group_msg / post_private_msg 组合消息段) + if messages and isinstance(messages, list): + text_parts: list[str] = [] + image_url: str | None = None + video_url: str | None = None + file_msgs: list[dict] = [] + + for msg in messages: + msg_type = msg.get("type", "text") + if msg_type == "text": + text_parts.append(msg.get("msg", "")) + elif msg_type == "image": + image_url = msg.get("url") + elif msg_type == "video": + video_url = msg.get("url") + elif msg_type == "file": + file_msgs.append(msg) + + text = "\n".join(text_parts) if text_parts else None + + try: + if group_id: + if text or image_url or video_url: + await api.qq.post_group_msg( + group_id=group_id, text=text, image=image_url, video=video_url, + ) + for fm in file_msgs: + url = fm.get("url", "") + await api.qq.send_group_file( + group_id=group_id, file=url, name=url.split("/")[-1], + ) + else: + if text or image_url or video_url: + await api.qq.post_private_msg( + user_id=user_id, text=text, image=image_url, video=video_url, + ) + for fm in file_msgs: + url = fm.get("url", "") + await api.qq.send_private_file( + user_id=user_id, file=url, name=url.split("/")[-1], + ) + except Exception as exc: + logger.error("命令回复失败: %s", exc) + return + + # 纯文本回复 + if reply and isinstance(reply, str): + try: + if group_id: + await api.qq.send_group_text(group_id=group_id, text=reply) + else: + await api.qq.send_private_text(user_id=user_id, text=reply) + except Exception as exc: + logger.error("命令回复失败: %s", exc) diff --git a/plugin.py b/plugin.py index 979b85f..1f6891e 100644 --- a/plugin.py +++ b/plugin.py @@ -91,7 +91,9 @@ class WebHookPlugin(NcatBotPlugin): "命令监听匹配: command=%s user=%s group=%s", parsed["command"], data["user_id"], data.get("group_id", "-"), ) - asyncio.create_task(send_command_callback(data, self.logger)) + asyncio.create_task( + send_command_callback(data, event, self.api, self.logger) + ) except Exception as exc: self.logger.error("消息处理异常: %s", exc) except asyncio.CancelledError: -- 2.49.1 From 601bce884745fffb97e3ba7ed48d681cbfb6d8c7 Mon Sep 17 00:00:00 2001 From: zhilv Date: Sat, 2 May 2026 19:58:47 +0800 Subject: [PATCH 3/8] =?UTF-8?q?=E2=9C=A8=20feat(command):=20=E5=9B=9E?= =?UTF-8?q?=E5=A4=8D=E6=97=B6=E5=BC=95=E7=94=A8=E5=8E=9F=E6=B6=88=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 使用 event.reply() 替代手动调用 send_group/private_text - 自动引用触发命令的原消息,回复带引用效果 - 群聊默认 @发送者,可通过 at_sender=false 关闭 --- handlers/command.py | 52 ++++++++++++++++++--------------------------- 1 file changed, 21 insertions(+), 31 deletions(-) diff --git a/handlers/command.py b/handlers/command.py index 53ddf9c..4ac5437 100644 --- a/handlers/command.py +++ b/handlers/command.py @@ -35,16 +35,18 @@ async def send_command_callback(data: dict, event, api, logger) -> None: 回调服务器返回格式: { - "reply": "回复文本", // 纯文本回复 + "reply": "回复文本", // 纯文本回复(引用原消息) "messages": [ // 批量回复(可选,优先于 reply) {"type": "text", "msg": "..."}, {"type": "image", "url": "..."}, {"type": "file", "url": "..."}, {"type": "video", "url": "..."} - ] + ], + "at_sender": true // 是否 @发送者(默认 true,仅群聊) } 所有字段均为可选,无回复内容时返回空 JSON 即可。 + 回复会引用触发命令的原消息。 """ if not COMMAND_CALLBACK_URL: logger.warning("COMMAND_CALLBACK_URL 未配置,跳过命令回调") @@ -81,13 +83,12 @@ async def send_command_callback(data: dict, event, api, logger) -> None: async def _handle_reply(result: dict, event, api, logger) -> None: - """处理回调响应,自动回复到原消息来源。""" - group_id = result.get("group_id") or (getattr(event.data, "group_id", None)) - user_id = result.get("user_id") or event.data.user_id + """处理回调响应,引用原消息自动回复。""" + at_sender = result.get("at_sender", True) messages = result.get("messages") reply = result.get("reply") - # 批量回复(使用 post_group_msg / post_private_msg 组合消息段) + # 批量回复:引用 + 批量消息段 if messages and isinstance(messages, list): text_parts: list[str] = [] image_url: str | None = None @@ -106,38 +107,27 @@ async def _handle_reply(result: dict, event, api, logger) -> None: file_msgs.append(msg) text = "\n".join(text_parts) if text_parts else None + group_id = getattr(event.data, "group_id", None) try: - if group_id: - if text or image_url or video_url: - await api.qq.post_group_msg( - group_id=group_id, text=text, image=image_url, video=video_url, - ) - for fm in file_msgs: - url = fm.get("url", "") - await api.qq.send_group_file( - group_id=group_id, file=url, name=url.split("/")[-1], - ) - else: - if text or image_url or video_url: - await api.qq.post_private_msg( - user_id=user_id, text=text, image=image_url, video=video_url, - ) - for fm in file_msgs: - url = fm.get("url", "") - await api.qq.send_private_file( - user_id=user_id, file=url, name=url.split("/")[-1], - ) + # 组合消息用 event.reply 引用原消息 + if text or image_url or video_url: + await event.reply(text=text, image=image_url, video=video_url, at_sender=at_sender) + # 文件单独发(file 是独占段) + for fm in file_msgs: + url = fm.get("url", "") + filename = url.split("/")[-1] + if group_id: + await api.qq.send_group_file(group_id=group_id, file=url, name=filename) + else: + await api.qq.send_private_file(user_id=event.data.user_id, file=url, name=filename) except Exception as exc: logger.error("命令回复失败: %s", exc) return - # 纯文本回复 + # 纯文本回复:引用原消息 if reply and isinstance(reply, str): try: - if group_id: - await api.qq.send_group_text(group_id=group_id, text=reply) - else: - await api.qq.send_private_text(user_id=user_id, text=reply) + await event.reply(text=reply, at_sender=at_sender) except Exception as exc: logger.error("命令回复失败: %s", exc) -- 2.49.1 From 89461b6ed6d9f581ebfb4005c4b0fc636f23ffd9 Mon Sep 17 00:00:00 2001 From: zhilv Date: Sat, 2 May 2026 20:38:09 +0800 Subject: [PATCH 4/8] =?UTF-8?q?=F0=9F=90=9B=20fix(command):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20Event=20=E5=AF=B9=E8=B1=A1=E6=97=A0=20reply=20?= =?UTF-8?q?=E6=96=B9=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - events() 返回的是 Event(data=GroupMessageEvent) 包装对象 - 改用 event.data(消息事件实体)构建回复 - 手动构建 MessageArray + add_reply 实现引用回复 - 群聊默认 @发送者,at_sender=false 可关闭 --- handlers/command.py | 46 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/handlers/command.py b/handlers/command.py index 4ac5437..7efd5e8 100644 --- a/handlers/command.py +++ b/handlers/command.py @@ -76,17 +76,38 @@ async def send_command_callback(data: dict, event, api, logger) -> None: if not isinstance(result, dict): return - await _handle_reply(result, event, api, logger) + await _handle_reply(result, event.data, api, logger) except Exception as exc: logger.error("命令回调异常: url=%s error=%s", COMMAND_CALLBACK_URL, exc) -async def _handle_reply(result: dict, event, api, logger) -> None: - """处理回调响应,引用原消息自动回复。""" +async def _handle_reply(result: dict, msg_event, api, logger) -> None: + """处理回调响应,引用原消息自动回复。msg_event 是 GroupMessageEvent / PrivateMessageEvent。""" at_sender = result.get("at_sender", True) messages = result.get("messages") reply = result.get("reply") + group_id = getattr(msg_event, "group_id", None) + user_id = msg_event.user_id + message_id = msg_event.message_id + + # 构造引用消息段 + from ncatbot.types import MessageArray, Reply + + def build_reply_msg(text=None, image=None, video=None) -> MessageArray: + """构建带引用的消息段。""" + msg = MessageArray() + msg.add_reply(message_id) + if group_id and at_sender: + msg.add_at(user_id) + msg.add_text(" ") + if text is not None: + msg.add_text(text) + if image is not None: + msg.add_image(image) + if video is not None: + msg.add_video(video) + return msg # 批量回复:引用 + 批量消息段 if messages and isinstance(messages, list): @@ -107,20 +128,23 @@ async def _handle_reply(result: dict, event, api, logger) -> None: file_msgs.append(msg) text = "\n".join(text_parts) if text_parts else None - group_id = getattr(event.data, "group_id", None) try: - # 组合消息用 event.reply 引用原消息 + # 组合消息(带引用) if text or image_url or video_url: - await event.reply(text=text, image=image_url, video=video_url, at_sender=at_sender) - # 文件单独发(file 是独占段) + reply_msg = build_reply_msg(text=text, image=image_url, video=video_url) + if group_id: + await api.qq.post_group_array_msg(group_id=group_id, msg=reply_msg) + else: + await api.qq.post_private_array_msg(user_id=user_id, msg=reply_msg) + # 文件单独发 for fm in file_msgs: url = fm.get("url", "") filename = url.split("/")[-1] if group_id: await api.qq.send_group_file(group_id=group_id, file=url, name=filename) else: - await api.qq.send_private_file(user_id=event.data.user_id, file=url, name=filename) + await api.qq.send_private_file(user_id=user_id, file=url, name=filename) except Exception as exc: logger.error("命令回复失败: %s", exc) return @@ -128,6 +152,10 @@ async def _handle_reply(result: dict, event, api, logger) -> None: # 纯文本回复:引用原消息 if reply and isinstance(reply, str): try: - await event.reply(text=reply, at_sender=at_sender) + reply_msg = build_reply_msg(text=reply) + if group_id: + await api.qq.post_group_array_msg(group_id=group_id, msg=reply_msg) + else: + await api.qq.post_private_array_msg(user_id=user_id, msg=reply_msg) except Exception as exc: logger.error("命令回复失败: %s", exc) -- 2.49.1 From c6ba7e2e37e3ae95ae520f7c1fd47dfba3dc77bd Mon Sep 17 00:00:00 2001 From: zhilv Date: Sat, 2 May 2026 21:01:33 +0800 Subject: [PATCH 5/8] =?UTF-8?q?=E2=9C=A8=20feat(command):=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E6=97=A0=E5=8F=82=E6=95=B0=E5=91=BD=E4=BB=A4=E8=A7=A6?= =?UTF-8?q?=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 正则改为允许命令后无空格和内容,如 `#测试命令` 也可触发 - 命令内容为空时 content 返回空字符串而非匹配失败 --- handlers/command.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/handlers/command.py b/handlers/command.py index 7efd5e8..9197d57 100644 --- a/handlers/command.py +++ b/handlers/command.py @@ -1,4 +1,4 @@ -"""命令监听处理器:匹配 #命令名+空格 格式的消息,转发到外部回调 URL 并自动回复。""" +"""命令监听处理器:匹配 #命令名 格式的消息,转发到外部回调 URL 并自动回复。""" import re @@ -8,9 +8,9 @@ from ..config import COMMAND_CALLBACK_URL, COMMAND_LENGTH, COMMAND_PREFIX def build_command_pattern() -> re.Pattern: - """构建命令匹配正则:# + N个中文字 + 空格。""" + """构建命令匹配正则:# + N个中文字,后面可跟空格+内容或无内容。""" return re.compile( - rf"^{re.escape(COMMAND_PREFIX)}([\u4e00-\u9fff]{{{COMMAND_LENGTH}}})\s+(.+)", + rf"^{re.escape(COMMAND_PREFIX)}([\u4e00-\u9fff]{{{COMMAND_LENGTH}}})(?:\s+(.+))?$", re.DOTALL, ) @@ -25,7 +25,7 @@ def parse_command(raw_message: str) -> dict | None: return None return { "command": match.group(1), - "content": match.group(2).strip(), + "content": match.group(2).strip() if match.group(2) else "", "raw_message": raw_message.strip(), } -- 2.49.1 From d4962a840da732812c72c7ff8f3fb4f4db8259d9 Mon Sep 17 00:00:00 2001 From: zhilv Date: Sat, 2 May 2026 21:13:22 +0800 Subject: [PATCH 6/8] =?UTF-8?q?=E2=9C=A8=20feat(command):=20=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E5=90=8D=E9=95=BF=E5=BA=A6=E6=94=B9=E4=B8=BA=E5=8F=AF?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E8=8C=83=E5=9B=B4=202~4=20=E4=B8=AA=E4=B8=AD?= =?UTF-8?q?=E6=96=87=E5=AD=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 COMMAND_LENGTH 拆分为 COMMAND_LENGTH_MIN 和 COMMAND_LENGTH_MAX - 正则匹配支持 2~4 个中文字,范围通过 .env 配置 - 默认最小 2 字、最大 4 字 --- config.py | 3 ++- handlers/command.py | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/config.py b/config.py index 7f245ac..28d6416 100644 --- a/config.py +++ b/config.py @@ -34,5 +34,6 @@ QQ_API_MAX_RETRIES: int = int(os.environ.get("QQ_API_MAX_RETRIES", "2")) # ── 命令监听 ──────────────────────────────────────────────── COMMAND_PREFIX: str = os.environ.get("COMMAND_PREFIX", "#") -COMMAND_LENGTH: int = int(os.environ.get("COMMAND_LENGTH", "4")) +COMMAND_LENGTH_MIN: int = int(os.environ.get("COMMAND_LENGTH_MIN", "2")) +COMMAND_LENGTH_MAX: int = int(os.environ.get("COMMAND_LENGTH_MAX", "4")) COMMAND_CALLBACK_URL: str = os.environ.get("COMMAND_CALLBACK_URL", "") diff --git a/handlers/command.py b/handlers/command.py index 9197d57..545f705 100644 --- a/handlers/command.py +++ b/handlers/command.py @@ -4,13 +4,13 @@ import re import aiohttp -from ..config import COMMAND_CALLBACK_URL, COMMAND_LENGTH, COMMAND_PREFIX +from ..config import COMMAND_CALLBACK_URL, COMMAND_LENGTH_MAX, COMMAND_LENGTH_MIN, COMMAND_PREFIX def build_command_pattern() -> re.Pattern: - """构建命令匹配正则:# + N个中文字,后面可跟空格+内容或无内容。""" + """构建命令匹配正则:# + 2~4个中文字,后面可跟空格+内容或无内容。""" return re.compile( - rf"^{re.escape(COMMAND_PREFIX)}([\u4e00-\u9fff]{{{COMMAND_LENGTH}}})(?:\s+(.+))?$", + rf"^{re.escape(COMMAND_PREFIX)}([\u4e00-\u9fff]{{{COMMAND_LENGTH_MIN},{COMMAND_LENGTH_MAX}}})(?:\s+(.+))?$", re.DOTALL, ) -- 2.49.1 From 7c72b1c97ac99ad69577442a2cda8cbae65b9f08 Mon Sep 17 00:00:00 2001 From: zhilv Date: Sat, 2 May 2026 21:20:57 +0800 Subject: [PATCH 7/8] =?UTF-8?q?=F0=9F=90=9B=20fix(command):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20plugin.py=20=E5=BC=95=E7=94=A8=E5=B7=B2=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E7=9A=84=20COMMAND=5FLENGTH=20=E5=AF=BC=E8=87=B4?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E6=8A=A5=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 导入改为 COMMAND_LENGTH_MIN / COMMAND_LENGTH_MAX - 启动日志适配范围格式 %d~%d --- plugin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugin.py b/plugin.py index 1f6891e..85cf13b 100644 --- a/plugin.py +++ b/plugin.py @@ -7,7 +7,7 @@ import os from aiohttp import web from ncatbot.plugin import NcatBotPlugin -from .config import COMMAND_CALLBACK_URL, COMMAND_LENGTH, COMMAND_PREFIX, HOST, PORT, UPLOAD_DIR, WEBHOOK_API_KEY +from .config import COMMAND_CALLBACK_URL, COMMAND_LENGTH_MAX, COMMAND_LENGTH_MIN, COMMAND_PREFIX, HOST, PORT, UPLOAD_DIR, WEBHOOK_API_KEY from .handlers.command import parse_command, send_command_callback from .handlers.health import health_handler from .handlers.message import webhook_handler @@ -32,7 +32,7 @@ class WebHookPlugin(NcatBotPlugin): async def on_load(self): self.logger.info("Webhook 插件已加载") self.logger.info("WEBHOOK_API_KEY: %s", "已配置" if os.environ.get("WEBHOOK_API_KEY") else "自动生成") - self.logger.info("命令监听: 前缀=%s 长度=%d 回调=%s", COMMAND_PREFIX, COMMAND_LENGTH, + self.logger.info("命令监听: 前缀=%s 长度=%d~%d 回调=%s", COMMAND_PREFIX, COMMAND_LENGTH_MIN, COMMAND_LENGTH_MAX, COMMAND_CALLBACK_URL or "未配置") asyncio.create_task(self._start_webhook()) self._cleanup_task = asyncio.create_task(self._cleanup_loop()) -- 2.49.1 From e62fc13f7cf46e83ac945fffa4925abca31762c1 Mon Sep 17 00:00:00 2001 From: zhilv Date: Sat, 2 May 2026 21:30:19 +0800 Subject: [PATCH 8/8] =?UTF-8?q?=E2=9C=A8=20feat(command):=20=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E5=90=8D=E6=94=AF=E6=8C=81=E4=B8=AD=E6=96=87=E3=80=81?= =?UTF-8?q?=E6=95=B0=E5=AD=97=E3=80=81=E5=AD=97=E6=AF=8D=E7=AD=89=E4=BB=BB?= =?UTF-8?q?=E6=84=8F=E9=9D=9E=E7=A9=BA=E7=99=BD=E5=AD=97=E7=AC=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 正则从仅匹配中文改为匹配任意非空白字符(\S) - 中文、数字、字母、其他字符均按 1 个字符计数 - 长度范围仍由 COMMAND_LENGTH_MIN/MAX 控制 --- handlers/command.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/handlers/command.py b/handlers/command.py index 545f705..54afa68 100644 --- a/handlers/command.py +++ b/handlers/command.py @@ -8,9 +8,16 @@ from ..config import COMMAND_CALLBACK_URL, COMMAND_LENGTH_MAX, COMMAND_LENGTH_MI def build_command_pattern() -> re.Pattern: - """构建命令匹配正则:# + 2~4个中文字,后面可跟空格+内容或无内容。""" + """构建命令匹配正则:# + N个字符(中文/数字/字母/下划线等),后面可跟空格+内容或无内容。 + + 每个"字符"按 Unicode 码点计: + - 一个中文字 = 1 + - 一个数字 = 1 + - 一个英文字母 = 1 + - 其他非空白字符 = 1 + """ return re.compile( - rf"^{re.escape(COMMAND_PREFIX)}([\u4e00-\u9fff]{{{COMMAND_LENGTH_MIN},{COMMAND_LENGTH_MAX}}})(?:\s+(.+))?$", + rf"^{re.escape(COMMAND_PREFIX)}(\S{{{COMMAND_LENGTH_MIN},{COMMAND_LENGTH_MAX}}})(?:\s+(.+))?$", re.DOTALL, ) -- 2.49.1