✨ feat(command): 添加监听范围过滤和回复 @控制
- 新增 COMMAND_SCOPE 配置,支持 all/group/private 过滤消息来源 - 新增 COMMAND_ALLOWED_GROUPS 群号白名单,逗号分隔,留空不限制 - 新增 COMMAND_ALLOWED_USERS QQ 号白名单,逗号分隔,留空不限制 - 新增 COMMAND_AT_SENDER 配置,控制回复时是否 @发送者(默认 true) - 回调响应中 at_sender 字段可覆盖全局配置 - 更新 .env.example 和 README.md 文档
This commit is contained in:
16
.env.example
16
.env.example
@@ -19,7 +19,19 @@ QQ_API_MAX_RETRIES=2
|
|||||||
# ── 命令监听 ──
|
# ── 命令监听 ──
|
||||||
# 命令前缀,默认 #
|
# 命令前缀,默认 #
|
||||||
COMMAND_PREFIX=#
|
COMMAND_PREFIX=#
|
||||||
# 命令名长度(中文字数),默认 4
|
# 命令名最小字符数,默认 2
|
||||||
COMMAND_LENGTH=4
|
COMMAND_LENGTH_MIN=2
|
||||||
|
# 命令名最大字符数,默认 4
|
||||||
|
COMMAND_LENGTH_MAX=4
|
||||||
|
# 监听范围:all(群+私)、group(仅群)、private(仅私),默认 all
|
||||||
|
COMMAND_SCOPE=all
|
||||||
|
# 允许的群号,逗号分隔,留空不限制,例:123456,789012
|
||||||
|
COMMAND_ALLOWED_GROUPS=
|
||||||
|
# 允许的 QQ 号,逗号分隔,留空不限制,例:111111,222222
|
||||||
|
COMMAND_ALLOWED_USERS=
|
||||||
|
# 回复时是否 @发送者,默认 true
|
||||||
|
COMMAND_AT_SENDER=true
|
||||||
|
# 回调超时秒数,默认 180(生图等耗时命令需要较长超时)
|
||||||
|
COMMAND_CALLBACK_TIMEOUT=180
|
||||||
# 匹配到命令后的回调 URL,留空则不监听
|
# 匹配到命令后的回调 URL,留空则不监听
|
||||||
COMMAND_CALLBACK_URL=
|
COMMAND_CALLBACK_URL=
|
||||||
|
|||||||
33
README.md
33
README.md
@@ -42,7 +42,13 @@ uv run python -m ncatbot
|
|||||||
| `QQ_API_TIMEOUT` | 否 | `10` | QQ API 超时秒数 |
|
| `QQ_API_TIMEOUT` | 否 | `10` | QQ API 超时秒数 |
|
||||||
| `QQ_API_MAX_RETRIES` | 否 | `2` | QQ API 失败重试次数 |
|
| `QQ_API_MAX_RETRIES` | 否 | `2` | QQ API 失败重试次数 |
|
||||||
| `COMMAND_PREFIX` | 否 | `#` | 命令前缀 |
|
| `COMMAND_PREFIX` | 否 | `#` | 命令前缀 |
|
||||||
| `COMMAND_LENGTH` | 否 | `4` | 命令名字符数(中文字数) |
|
| `COMMAND_LENGTH_MIN` | 否 | `2` | 命令名最小字符数 |
|
||||||
|
| `COMMAND_LENGTH_MAX` | 否 | `4` | 命令名最大字符数 |
|
||||||
|
| `COMMAND_SCOPE` | 否 | `all` | 监听范围:`all`(群+私)、`group`(仅群)、`private`(仅私) |
|
||||||
|
| `COMMAND_ALLOWED_GROUPS` | 否 | 空(不限) | 允许的群号,逗号分隔,如 `123456,789012` |
|
||||||
|
| `COMMAND_ALLOWED_USERS` | 否 | 空(不限) | 允许的 QQ 号,逗号分隔,如 `111111,222222` |
|
||||||
|
| `COMMAND_AT_SENDER` | 否 | `true` | 回复时是否 @发送者 |
|
||||||
|
| `COMMAND_CALLBACK_TIMEOUT` | 否 | `180` | 回调超时秒数 |
|
||||||
| `COMMAND_CALLBACK_URL` | 否 | 空(不监听) | 命令匹配后的回调 URL |
|
| `COMMAND_CALLBACK_URL` | 否 | 空(不监听) | 命令匹配后的回调 URL |
|
||||||
|
|
||||||
## 接口说明
|
## 接口说明
|
||||||
@@ -187,22 +193,34 @@ curl -X POST http://localhost:8081/webhook \
|
|||||||
|
|
||||||
### 命令监听
|
### 命令监听
|
||||||
|
|
||||||
插件会自动监听 QQ 消息,当消息以 `#四个中文字+空格` 开头时,将命令内容 POST 到 `COMMAND_CALLBACK_URL`。
|
插件会自动监听 QQ 消息,当消息以 `#命令名` 开头时,将命令内容 POST 到 `COMMAND_CALLBACK_URL`。
|
||||||
|
|
||||||
**匹配规则:**
|
**匹配规则:**
|
||||||
|
|
||||||
```
|
```
|
||||||
#测试命令 你好世界
|
#测试命令 你好世界
|
||||||
│ │ │ │
|
│ │ │ │
|
||||||
│ │ │ └── 命令内容(content)
|
│ │ │ └── 命令内容(content,可选)
|
||||||
│ └── 空格分隔
|
│ └── 空格分隔(可选)
|
||||||
└── 命令名(4个中文字)
|
└── 命令名(2~4个字符)
|
||||||
└── 前缀(默认 #)
|
└── 前缀(默认 #)
|
||||||
```
|
```
|
||||||
|
|
||||||
- 前缀、命令名长度可通过 `COMMAND_PREFIX`、`COMMAND_LENGTH` 配置
|
- 命令名支持中文、数字、字母等任意非空白字符,每个字符计 1
|
||||||
|
- 前缀通过 `COMMAND_PREFIX` 配置,长度范围通过 `COMMAND_LENGTH_MIN` / `COMMAND_LENGTH_MAX` 配置
|
||||||
|
- `#测试命令`、`#1a`、`#abc` 均可触发
|
||||||
- 不配置 `COMMAND_CALLBACK_URL` 则不监听
|
- 不配置 `COMMAND_CALLBACK_URL` 则不监听
|
||||||
|
|
||||||
|
**监听范围过滤:**
|
||||||
|
|
||||||
|
- `COMMAND_SCOPE`:控制监听范围
|
||||||
|
- `all`(默认):群聊 + 私聊都监听
|
||||||
|
- `group`:仅监听群聊
|
||||||
|
- `private`:仅监听私聊
|
||||||
|
- `COMMAND_ALLOWED_GROUPS`:群号白名单,逗号分隔,留空不限制
|
||||||
|
- `COMMAND_ALLOWED_USERS`:QQ 号白名单,逗号分隔,留空不限制
|
||||||
|
- 三个条件同时生效,必须全部满足才触发回调
|
||||||
|
|
||||||
**回调请求体(POST JSON):**
|
**回调请求体(POST JSON):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -218,7 +236,7 @@ curl -X POST http://localhost:8081/webhook \
|
|||||||
|
|
||||||
| 字段 | 说明 |
|
| 字段 | 说明 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `command` | 命令名(4个中文字) |
|
| `command` | 命令名(2~4个字符) |
|
||||||
| `content` | 命令后的内容 |
|
| `content` | 命令后的内容 |
|
||||||
| `raw_message` | 原始消息文本 |
|
| `raw_message` | 原始消息文本 |
|
||||||
| `user_id` | 发送者 QQ 号 |
|
| `user_id` | 发送者 QQ 号 |
|
||||||
@@ -249,6 +267,7 @@ curl -X POST http://localhost:8081/webhook \
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `reply` | 纯文本回复(与 `messages` 二选一,`messages` 优先) |
|
| `reply` | 纯文本回复(与 `messages` 二选一,`messages` 优先) |
|
||||||
| `messages` | 批量回复数组,格式同 `/webhook` 的 `messages` 字段 |
|
| `messages` | 批量回复数组,格式同 `/webhook` 的 `messages` 字段 |
|
||||||
|
| `at_sender` | 是否 @发送者(默认取 `COMMAND_AT_SENDER` 配置,仅群聊生效) |
|
||||||
| `group_id` | 可选,覆盖回复目标群号(默认回复到原群) |
|
| `group_id` | 可选,覆盖回复目标群号(默认回复到原群) |
|
||||||
| `user_id` | 可选,覆盖回复目标 QQ 号(默认回复到原发送者) |
|
| `user_id` | 可选,覆盖回复目标 QQ 号(默认回复到原发送者) |
|
||||||
|
|
||||||
|
|||||||
@@ -38,3 +38,11 @@ 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_LENGTH_MAX: int = int(os.environ.get("COMMAND_LENGTH_MAX", "4"))
|
||||||
COMMAND_CALLBACK_URL: str = os.environ.get("COMMAND_CALLBACK_URL", "")
|
COMMAND_CALLBACK_URL: str = os.environ.get("COMMAND_CALLBACK_URL", "")
|
||||||
COMMAND_CALLBACK_TIMEOUT: int = int(os.environ.get("COMMAND_CALLBACK_TIMEOUT", "180"))
|
COMMAND_CALLBACK_TIMEOUT: int = int(os.environ.get("COMMAND_CALLBACK_TIMEOUT", "180"))
|
||||||
|
COMMAND_SCOPE: str = os.environ.get("COMMAND_SCOPE", "all") # all / group / private
|
||||||
|
COMMAND_ALLOWED_GROUPS: frozenset[str] = frozenset(
|
||||||
|
filter(None, os.environ.get("COMMAND_ALLOWED_GROUPS", "").split(","))
|
||||||
|
)
|
||||||
|
COMMAND_ALLOWED_USERS: frozenset[str] = frozenset(
|
||||||
|
filter(None, os.environ.get("COMMAND_ALLOWED_USERS", "").split(","))
|
||||||
|
)
|
||||||
|
COMMAND_AT_SENDER: bool = os.environ.get("COMMAND_AT_SENDER", "true").lower() in ("true", "1", "yes")
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import re
|
|||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
from ..config import COMMAND_CALLBACK_TIMEOUT, COMMAND_CALLBACK_URL, COMMAND_LENGTH_MAX, COMMAND_LENGTH_MIN, COMMAND_PREFIX, UPLOAD_DIR
|
from ..config import COMMAND_AT_SENDER, COMMAND_CALLBACK_TIMEOUT, COMMAND_CALLBACK_URL, COMMAND_LENGTH_MAX, COMMAND_LENGTH_MIN, COMMAND_PREFIX, UPLOAD_DIR
|
||||||
from ..handlers.message import _resolve_url
|
from ..handlers.message import _resolve_url
|
||||||
|
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ async def send_command_callback(data: dict, event, api, logger) -> None:
|
|||||||
{"type": "file", "url": "..."},
|
{"type": "file", "url": "..."},
|
||||||
{"type": "video", "url": "..."}
|
{"type": "video", "url": "..."}
|
||||||
],
|
],
|
||||||
"at_sender": true // 是否 @发送者(默认 true,仅群聊)
|
"at_sender": true // 是否 @发送者(默认取 COMMAND_AT_SENDER 配置,仅群聊)
|
||||||
}
|
}
|
||||||
|
|
||||||
所有字段均为可选,无回复内容时返回空 JSON 即可。
|
所有字段均为可选,无回复内容时返回空 JSON 即可。
|
||||||
@@ -92,7 +92,8 @@ async def send_command_callback(data: dict, event, api, logger) -> None:
|
|||||||
|
|
||||||
async def _handle_reply(result: dict, msg_event, api, logger) -> None:
|
async def _handle_reply(result: dict, msg_event, api, logger) -> None:
|
||||||
"""处理回调响应,引用原消息自动回复。msg_event 是 GroupMessageEvent / PrivateMessageEvent。"""
|
"""处理回调响应,引用原消息自动回复。msg_event 是 GroupMessageEvent / PrivateMessageEvent。"""
|
||||||
at_sender = result.get("at_sender", True)
|
# at_sender: 回调响应中的值优先,未指定则使用全局配置
|
||||||
|
at_sender = result.get("at_sender", COMMAND_AT_SENDER)
|
||||||
messages = result.get("messages")
|
messages = result.get("messages")
|
||||||
reply = result.get("reply")
|
reply = result.get("reply")
|
||||||
group_id = getattr(msg_event, "group_id", None)
|
group_id = getattr(msg_event, "group_id", None)
|
||||||
|
|||||||
51
plugin.py
51
plugin.py
@@ -7,7 +7,20 @@ import os
|
|||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from ncatbot.plugin import NcatBotPlugin
|
from ncatbot.plugin import NcatBotPlugin
|
||||||
|
|
||||||
from .config import COMMAND_CALLBACK_URL, COMMAND_LENGTH_MAX, COMMAND_LENGTH_MIN, COMMAND_PREFIX, HOST, PORT, UPLOAD_DIR, WEBHOOK_API_KEY
|
from .config import (
|
||||||
|
COMMAND_ALLOWED_GROUPS,
|
||||||
|
COMMAND_ALLOWED_USERS,
|
||||||
|
COMMAND_AT_SENDER,
|
||||||
|
COMMAND_CALLBACK_URL,
|
||||||
|
COMMAND_LENGTH_MAX,
|
||||||
|
COMMAND_LENGTH_MIN,
|
||||||
|
COMMAND_PREFIX,
|
||||||
|
COMMAND_SCOPE,
|
||||||
|
HOST,
|
||||||
|
PORT,
|
||||||
|
UPLOAD_DIR,
|
||||||
|
WEBHOOK_API_KEY,
|
||||||
|
)
|
||||||
from .handlers.command import parse_command, send_command_callback
|
from .handlers.command import parse_command, send_command_callback
|
||||||
from .handlers.health import health_handler
|
from .handlers.health import health_handler
|
||||||
from .handlers.message import webhook_handler
|
from .handlers.message import webhook_handler
|
||||||
@@ -31,9 +44,18 @@ class WebHookPlugin(NcatBotPlugin):
|
|||||||
|
|
||||||
async def on_load(self):
|
async def on_load(self):
|
||||||
self.logger.info("Webhook 插件已加载")
|
self.logger.info("Webhook 插件已加载")
|
||||||
self.logger.info("WEBHOOK_API_KEY: %s", "已配置" if os.environ.get("WEBHOOK_API_KEY") else "自动生成")
|
self.logger.info(
|
||||||
self.logger.info("命令监听: 前缀=%s 长度=%d~%d 回调=%s", COMMAND_PREFIX, COMMAND_LENGTH_MIN, COMMAND_LENGTH_MAX,
|
"WEBHOOK_API_KEY: %s",
|
||||||
COMMAND_CALLBACK_URL or "未配置")
|
"已配置" if os.environ.get("WEBHOOK_API_KEY") else "自动生成",
|
||||||
|
)
|
||||||
|
self.logger.info(
|
||||||
|
"命令监听: 前缀=%s 长度=%d~%d 范围=%s 回调=%s",
|
||||||
|
COMMAND_PREFIX,
|
||||||
|
COMMAND_LENGTH_MIN,
|
||||||
|
COMMAND_LENGTH_MAX,
|
||||||
|
COMMAND_SCOPE,
|
||||||
|
COMMAND_CALLBACK_URL or "未配置",
|
||||||
|
)
|
||||||
asyncio.create_task(self._start_webhook())
|
asyncio.create_task(self._start_webhook())
|
||||||
self._cleanup_task = asyncio.create_task(self._cleanup_loop())
|
self._cleanup_task = asyncio.create_task(self._cleanup_loop())
|
||||||
self._listener_task = asyncio.create_task(self._message_listener())
|
self._listener_task = asyncio.create_task(self._message_listener())
|
||||||
@@ -77,6 +99,23 @@ class WebHookPlugin(NcatBotPlugin):
|
|||||||
parsed = parse_command(raw_message)
|
parsed = parse_command(raw_message)
|
||||||
if not parsed:
|
if not parsed:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# 范围过滤:group / private / all
|
||||||
|
is_group = hasattr(event.data, "group_id")
|
||||||
|
if COMMAND_SCOPE == "group" and not is_group:
|
||||||
|
continue
|
||||||
|
if COMMAND_SCOPE == "private" and is_group:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 群白名单过滤
|
||||||
|
if COMMAND_ALLOWED_GROUPS and is_group:
|
||||||
|
if event.data.group_id not in COMMAND_ALLOWED_GROUPS:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 用户白名单过滤
|
||||||
|
if COMMAND_ALLOWED_USERS and event.data.user_id not in COMMAND_ALLOWED_USERS:
|
||||||
|
continue
|
||||||
|
|
||||||
# 构建回调数据
|
# 构建回调数据
|
||||||
data = {
|
data = {
|
||||||
"command": parsed["command"],
|
"command": parsed["command"],
|
||||||
@@ -89,7 +128,9 @@ class WebHookPlugin(NcatBotPlugin):
|
|||||||
data["group_id"] = event.data.group_id
|
data["group_id"] = event.data.group_id
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
"命令监听匹配: command=%s user=%s group=%s",
|
"命令监听匹配: command=%s user=%s group=%s",
|
||||||
parsed["command"], data["user_id"], data.get("group_id", "-"),
|
parsed["command"],
|
||||||
|
data["user_id"],
|
||||||
|
data.get("group_id", "-"),
|
||||||
)
|
)
|
||||||
asyncio.create_task(
|
asyncio.create_task(
|
||||||
send_command_callback(data, event, self.api, self.logger)
|
send_command_callback(data, event, self.api, self.logger)
|
||||||
|
|||||||
Reference in New Issue
Block a user