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