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""" + +
+ + +