diff --git a/config.py b/config.py index c20c3b1..5faad36 100644 --- a/config.py +++ b/config.py @@ -1,4 +1,4 @@ -"""项目配置:所有值从环境变量读取,未配置时使用安全默认值。""" +"""项目配置:静态配置从环境变量读取,命令监听配置支持数据库动态修改。""" import os import uuid @@ -32,17 +32,76 @@ ALLOWED_EXTENSIONS: set[str] = set( 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_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", "") 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_LIST_MODE: str = os.environ.get("COMMAND_LIST_MODE", "allow") # allow / deny COMMAND_ALLOWED_GROUPS: frozenset[str] = frozenset( filter(None, os.environ.get("COMMAND_ALLOWED_GROUPS", "").split(",")) ) +COMMAND_DENIED_GROUPS: frozenset[str] = frozenset( + filter(None, os.environ.get("COMMAND_DENIED_GROUPS", "").split(",")) +) COMMAND_ALLOWED_USERS: frozenset[str] = frozenset( filter(None, os.environ.get("COMMAND_ALLOWED_USERS", "").split(",")) ) +COMMAND_DENIED_USERS: frozenset[str] = frozenset( + filter(None, os.environ.get("COMMAND_DENIED_USERS", "").split(",")) +) COMMAND_AT_SENDER: bool = os.environ.get("COMMAND_AT_SENDER", "true").lower() in ("true", "1", "yes") + + +# ── 动态配置刷新 ───────────────────────────────────────────── +def _parse_frozenset(value: str) -> frozenset[str]: + """将逗号分隔字符串解析为 frozenset。""" + return frozenset(filter(None, (v.strip() for v in value.split(",")))) + + +def reload_settings(settings: dict[str, str]) -> None: + """从数据库读取的配置覆盖模块级变量,使配置动态生效。""" + global COMMAND_PREFIX, COMMAND_LENGTH_MIN, COMMAND_LENGTH_MAX + global COMMAND_CALLBACK_URL, COMMAND_CALLBACK_TIMEOUT, COMMAND_SCOPE + global COMMAND_LIST_MODE, COMMAND_ALLOWED_GROUPS, COMMAND_DENIED_GROUPS + global COMMAND_ALLOWED_USERS, COMMAND_DENIED_USERS, COMMAND_AT_SENDER + + if "command_prefix" in settings: + COMMAND_PREFIX = settings["command_prefix"] + if "command_length_min" in settings: + try: + COMMAND_LENGTH_MIN = int(settings["command_length_min"]) + except ValueError: + pass + if "command_length_max" in settings: + try: + COMMAND_LENGTH_MAX = int(settings["command_length_max"]) + except ValueError: + pass + if "command_callback_url" in settings: + COMMAND_CALLBACK_URL = settings["command_callback_url"] + if "command_callback_timeout" in settings: + try: + COMMAND_CALLBACK_TIMEOUT = int(settings["command_callback_timeout"]) + except ValueError: + pass + if "command_scope" in settings: + COMMAND_SCOPE = settings["command_scope"] + if "command_list_mode" in settings: + COMMAND_LIST_MODE = settings["command_list_mode"] + if "command_allowed_groups" in settings: + COMMAND_ALLOWED_GROUPS = _parse_frozenset(settings["command_allowed_groups"]) + if "command_denied_groups" in settings: + COMMAND_DENIED_GROUPS = _parse_frozenset(settings["command_denied_groups"]) + if "command_allowed_users" in settings: + COMMAND_ALLOWED_USERS = _parse_frozenset(settings["command_allowed_users"]) + if "command_denied_users" in settings: + COMMAND_DENIED_USERS = _parse_frozenset(settings["command_denied_users"]) + if "command_at_sender" in settings: + COMMAND_AT_SENDER = settings["command_at_sender"].lower() in ("true", "1", "yes") + + # 刷新正则编译缓存 + from .handlers.command import rebuild_pattern + rebuild_pattern() diff --git a/db.py b/db.py new file mode 100644 index 0000000..0084843 --- /dev/null +++ b/db.py @@ -0,0 +1,82 @@ +"""SQLite 数据库层:存储可动态修改的配置项。""" + +import aiosqlite +from pathlib import Path + +DB_PATH = Path(__file__).parent / "data" / "webhook.db" + +# 需要持久化的配置项及其默认值(从 .env 加载时的回退) +SETTINGS_DEFAULTS: dict[str, str] = { + "command_prefix": "#", + "command_length_min": "2", + "command_length_max": "4", + "command_scope": "all", + "command_list_mode": "allow", + "command_allowed_groups": "", + "command_denied_groups": "", + "command_allowed_users": "", + "command_denied_users": "", + "command_at_sender": "true", + "command_callback_url": "", + "command_callback_timeout": "180", +} + + +async def init_db() -> None: + """建表 + 首次启动时写入默认值。""" + DB_PATH.parent.mkdir(parents=True, exist_ok=True) + async with aiosqlite.connect(DB_PATH) as db: + await db.execute( + """ + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL DEFAULT '' + ) + """ + ) + await db.commit() + + # 首次启动:插入不存在的默认值 + for key, default in SETTINGS_DEFAULTS.items(): + await db.execute( + "INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", + (key, default), + ) + await db.commit() + + +async def get_setting(key: str) -> str | None: + """读取单个配置值,不存在返回 None。""" + async with aiosqlite.connect(DB_PATH) as db: + cursor = await db.execute("SELECT value FROM settings WHERE key = ?", (key,)) + row = await cursor.fetchone() + return row[0] if row else None + + +async def get_settings() -> dict[str, str]: + """读取全部配置,返回 {key: value} 字典。""" + async with aiosqlite.connect(DB_PATH) as db: + cursor = await db.execute("SELECT key, value FROM settings") + rows = await cursor.fetchall() + return {row[0]: row[1] for row in rows} + + +async def update_setting(key: str, value: str) -> None: + """更新单个配置值。""" + async with aiosqlite.connect(DB_PATH) as db: + await db.execute( + "INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?", + (key, value, value), + ) + await db.commit() + + +async def update_settings(data: dict[str, str]) -> None: + """批量更新配置值。""" + async with aiosqlite.connect(DB_PATH) as db: + for key, value in data.items(): + await db.execute( + "INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?", + (key, value, value), + ) + await db.commit() diff --git a/handlers/admin.py b/handlers/admin.py new file mode 100644 index 0000000..421789a --- /dev/null +++ b/handlers/admin.py @@ -0,0 +1,996 @@ +"""后台管理:提供 Web 管理界面和 REST API,动态修改命令监听配置。""" + +from aiohttp import web + +from ..config import ( + COMMAND_AT_SENDER, + COMMAND_CALLBACK_TIMEOUT, + COMMAND_CALLBACK_URL, + COMMAND_DENIED_GROUPS, + COMMAND_DENIED_USERS, + COMMAND_ALLOWED_GROUPS, + COMMAND_ALLOWED_USERS, + COMMAND_LENGTH_MAX, + COMMAND_LENGTH_MIN, + COMMAND_LIST_MODE, + COMMAND_PREFIX, + COMMAND_SCOPE, + reload_settings, +) +from ..db import get_settings, update_settings +from ..response import error, ok + + +# ── API ────────────────────────────────────────────────────── + +async def api_get_settings(request: web.Request) -> web.Response: + """GET /api/settings — 返回全部动态配置。""" + settings = await get_settings() + return ok(data=settings) + + +async def api_update_settings(request: web.Request) -> web.Response: + """PUT /api/settings — 批量更新配置并立即生效。""" + try: + data = await request.json() + except Exception: + return error("invalid json") + + if not isinstance(data, dict): + return error("request body must be a json object") + + # 允许更新的 key 白名单 + allowed_keys = { + "command_prefix", + "command_length_min", + "command_length_max", + "command_scope", + "command_list_mode", + "command_allowed_groups", + "command_denied_groups", + "command_allowed_users", + "command_denied_users", + "command_at_sender", + "command_callback_url", + "command_callback_timeout", + } + + filtered = {k: str(v) for k, v in data.items() if k in allowed_keys} + if not filtered: + return error("no valid settings to update") + + await update_settings(filtered) + reload_settings(filtered) + + return ok(data=filtered, msg="配置已更新并生效") + + +async def api_reload_settings(request: web.Request) -> web.Response: + """POST /api/settings/reload — 从数据库重新加载配置。""" + settings = await get_settings() + reload_settings(settings) + return ok(msg="配置已从数据库重新加载") + + +# ── 管理页面 ───────────────────────────────────────────────── + +ADMIN_HTML = r""" + + + + +NcatBot Webhook 管理 + + + + + + + + +
+ +
+
命令监听配置
+
+ ● 运行中 + + +
+
+ + +
+ + +
+
+

基本设置

+
+
+ 修改配置后点击右上角「保存配置」即可生效,无需重启服务。 +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + 回复时 @发送者(仅群聊生效) +
+
+
+
+ +
+

命令匹配预览

+
+
+ + +
+
输入消息后自动检测
+
+
+
+ + + + + + + + + + +
+
+ + +
+ + + +""" + + +async def admin_page_handler(request: web.Request) -> web.Response: + """GET /admin/ — 返回管理页面 HTML。""" + return web.Response(text=ADMIN_HTML, content_type="text/html") diff --git a/handlers/command.py b/handlers/command.py index 6f256f0..d73a4b9 100644 --- a/handlers/command.py +++ b/handlers/command.py @@ -26,6 +26,12 @@ def build_command_pattern() -> re.Pattern: COMMAND_PATTERN = build_command_pattern() +def rebuild_pattern() -> None: + """动态配置变更后重新编译正则。""" + global COMMAND_PATTERN + 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()) diff --git a/middleware.py b/middleware.py index dba4e91..9ddd2ff 100644 --- a/middleware.py +++ b/middleware.py @@ -10,9 +10,9 @@ from .response import error @web.middleware async def auth_middleware(request: web.Request, handler): - """对 /upload 和 /webhook 路径强制校验 API Key。""" - # 健康检查不需要鉴权 - if request.path == "/healthz": + """对需要鉴权的路径校验 API Key。/healthz 和 /admin/ 及 /api/ 开头的路径不需要鉴权。""" + # 不需要鉴权的路径 + if request.path == "/healthz" or request.path.startswith("/admin") or request.path.startswith("/api/"): return await handler(request) auth_header = request.headers.get("Authorization", "") diff --git a/plugin.py b/plugin.py index 88a2c41..c71e07f 100644 --- a/plugin.py +++ b/plugin.py @@ -12,15 +12,21 @@ from .config import ( COMMAND_ALLOWED_USERS, COMMAND_AT_SENDER, COMMAND_CALLBACK_URL, + COMMAND_DENIED_GROUPS, + COMMAND_DENIED_USERS, COMMAND_LENGTH_MAX, COMMAND_LENGTH_MIN, + COMMAND_LIST_MODE, COMMAND_PREFIX, COMMAND_SCOPE, HOST, PORT, UPLOAD_DIR, WEBHOOK_API_KEY, + reload_settings, ) +from .db import get_settings, init_db +from .handlers.admin import admin_page_handler, api_get_settings, api_reload_settings, api_update_settings from .handlers.command import parse_command, send_command_callback from .handlers.health import health_handler from .handlers.message import webhook_handler @@ -43,17 +49,23 @@ class WebHookPlugin(NcatBotPlugin): self._listener_task: asyncio.Task | None = None async def on_load(self): + # 初始化数据库并加载动态配置 + await init_db() + settings = await get_settings() + reload_settings(settings) + self.logger.info("Webhook 插件已加载") self.logger.info( "WEBHOOK_API_KEY: %s", "已配置" if os.environ.get("WEBHOOK_API_KEY") else "自动生成", ) self.logger.info( - "命令监听: 前缀=%s 长度=%d~%d 范围=%s 回调=%s", + "命令监听: 前缀=%s 长度=%d~%d 范围=%s 名单=%s 回调=%s", COMMAND_PREFIX, COMMAND_LENGTH_MIN, COMMAND_LENGTH_MAX, COMMAND_SCOPE, + COMMAND_LIST_MODE, COMMAND_CALLBACK_URL or "未配置", ) asyncio.create_task(self._start_webhook()) @@ -107,14 +119,21 @@ class WebHookPlugin(NcatBotPlugin): 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: + # 黑白名单过滤 + if COMMAND_LIST_MODE == "allow": + # 白名单模式:在名单内才放行 + 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 + elif COMMAND_LIST_MODE == "deny": + # 黑名单模式:在名单内则拒绝 + if COMMAND_DENIED_GROUPS and is_group: + if event.data.group_id in COMMAND_DENIED_GROUPS: + continue + if COMMAND_DENIED_USERS and event.data.user_id in COMMAND_DENIED_USERS: continue - - # 用户白名单过滤 - if COMMAND_ALLOWED_USERS and event.data.user_id not in COMMAND_ALLOWED_USERS: - continue # 构建回调数据 data = { @@ -124,7 +143,7 @@ class WebHookPlugin(NcatBotPlugin): "user_id": event.data.user_id, "message_id": event.data.message_id, } - if hasattr(event.data, "group_id"): + if is_group: data["group_id"] = event.data.group_id self.logger.info( "命令监听匹配: command=%s user=%s group=%s", @@ -147,6 +166,11 @@ class WebHookPlugin(NcatBotPlugin): app.router.add_get("/healthz", health_handler) app.router.add_post("/webhook", webhook_handler) app.router.add_post("/upload", upload_handler) + # 后台管理 + app.router.add_get("/admin/", admin_page_handler) + app.router.add_get("/api/settings", api_get_settings) + app.router.add_put("/api/settings", api_update_settings) + app.router.add_post("/api/settings/reload", api_reload_settings) return app async def _start_webhook(self): @@ -158,6 +182,7 @@ class WebHookPlugin(NcatBotPlugin): await site.start() self.logger.info("Webhook 已启动: %s:%d", HOST, PORT) self.logger.info("上传目录: %s", UPLOAD_DIR) + self.logger.info("后台管理: http://%s:%d/admin/", HOST, PORT) async def _stop_webhook(self): if self._webhook_runner is not None: diff --git a/pyproject.toml b/pyproject.toml index 4e87235..eeb1c3a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,4 +8,5 @@ dependencies = [ "ncatbot5>=5.5.2.post3", "aiohttp>=3.9", "python-dotenv>=1.0", + "aiosqlite>=0.20", ] diff --git a/uv.lock b/uv.lock index 695243e..137f749 100644 --- a/uv.lock +++ b/uv.lock @@ -109,6 +109,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, ] +[[package]] +name = "aiosqlite" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -909,6 +918,7 @@ version = "0.1.1" source = { virtual = "." } dependencies = [ { name = "aiohttp" }, + { name = "aiosqlite" }, { name = "ncatbot5" }, { name = "python-dotenv" }, ] @@ -916,6 +926,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "aiohttp", specifier = ">=3.9" }, + { name = "aiosqlite", specifier = ">=0.20" }, { name = "ncatbot5", specifier = ">=5.5.2.post3" }, { name = "python-dotenv", specifier = ">=1.0" }, ]