♻️ refactor(command): 配置系统从 SQLite 迁移至 YAML 并修复白名单失效

- 用 CommandConfig dataclass 单例替代模块级变量,解决 from import 造成的本地绑定不随 global 更新的 bug
- 删除 db.py,改用 settings.yaml 存储动态配置,首次启动自动创建并合并 .env 默认值
- 新增文件轮询 watcher(2 秒),检测 YAML 变更自动热重载
- 管理界面 API 改为直接读写 YAML,即时生效
- 依赖 aiosqlite 替换为 pyyaml
This commit is contained in:
2026-05-03 18:23:29 +08:00
parent 9ffe78a9c2
commit f82363f45f
8 changed files with 314 additions and 270 deletions

6
.gitignore vendored
View File

@@ -15,3 +15,9 @@ uploads/
# Environment # Environment
.env .env
# Dynamic config
settings.yaml
# Legacy data
data/

307
config.py
View File

@@ -1,107 +1,270 @@
"""项目配置:静态配置从环境变量读取,命令监听配置支持数据库动态修改""" """项目配置:静态配置从环境变量读取,命令监听配置通过 settings.yaml 动态管理"""
import logging
import os import os
import uuid import uuid
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
import yaml
from dotenv import load_dotenv from dotenv import load_dotenv
# 加载 .env 文件(优先从插件目录查找) # 加载 .env 文件(优先从插件目录查找)
load_dotenv(Path(__file__).parent / ".env") load_dotenv(Path(__file__).parent / ".env")
# ── 鉴权 ──────────────────────────────────────────────────── logger = logging.getLogger("webhook-plugin.config")
WEBHOOK_API_KEY: str = os.environ.get("WEBHOOK_API_KEY", "") or uuid.uuid4().hex
# ── 网络 ───────────────────────────────────────────────────── # ── 静态配置(环境变量,运行时不变)────────────────────────────
WEBHOOK_API_KEY: str = os.environ.get("WEBHOOK_API_KEY", "") or uuid.uuid4().hex
HOST: str = os.environ.get("WEBHOOK_HOST", "0.0.0.0") HOST: str = os.environ.get("WEBHOOK_HOST", "0.0.0.0")
try: try:
PORT: int = int(os.environ.get("WEBHOOK_PORT", "8081")) PORT: int = int(os.environ.get("WEBHOOK_PORT", "8081"))
except ValueError: except ValueError:
PORT = 8081 PORT = 8081
# ── 上传 ─────────────────────────────────────────────────────
UPLOAD_DIR: Path = Path(os.environ.get("UPLOAD_DIR", str(Path(__file__).parent / "uploads"))) UPLOAD_DIR: Path = Path(os.environ.get("UPLOAD_DIR", str(Path(__file__).parent / "uploads")))
# 单个文件最大 20 MB
MAX_UPLOAD_SIZE: int = int(os.environ.get("MAX_UPLOAD_SIZE", str(20 * 1024 * 1024))) MAX_UPLOAD_SIZE: int = int(os.environ.get("MAX_UPLOAD_SIZE", str(20 * 1024 * 1024)))
# 允许的文件扩展名(小写,不含点),为空则不限制
ALLOWED_EXTENSIONS: set[str] = set( ALLOWED_EXTENSIONS: set[str] = set(
filter(None, os.environ.get("ALLOWED_EXTENSIONS", "").lower().split(",")) filter(None, os.environ.get("ALLOWED_EXTENSIONS", "").lower().split(","))
) )
# ── QQ API ───────────────────────────────────────────────────
QQ_API_TIMEOUT: float = float(os.environ.get("QQ_API_TIMEOUT", "10")) 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")) QQ_API_MAX_RETRIES: int = int(os.environ.get("QQ_API_MAX_RETRIES", "2"))
# ── 命令监听(可动态修改,从数据库加载) ────────────────────── # ── YAML 文件路径 ────────────────────────────────────────────
COMMAND_PREFIX: str = os.environ.get("COMMAND_PREFIX", "#") SETTINGS_YAML_PATH: Path = Path(__file__).parent / "settings.yaml"
COMMAND_LENGTH_MIN: int = int(os.environ.get("COMMAND_LENGTH_MIN", "2"))
COMMAND_LENGTH_MAX: int = int(os.environ.get("COMMAND_LENGTH_MAX", "4")) # ── 动态配置 dataclass ──────────────────────────────────────
COMMAND_CALLBACK_URL: str = os.environ.get("COMMAND_CALLBACK_URL", "") @dataclass
COMMAND_CALLBACK_TIMEOUT: int = int(os.environ.get("COMMAND_CALLBACK_TIMEOUT", "180")) class CommandConfig:
COMMAND_SCOPE: str = os.environ.get("COMMAND_SCOPE", "all") # all / group / private """命令监听动态配置,通过 settings.yaml 热重载。"""
COMMAND_LIST_MODE: str = os.environ.get("COMMAND_LIST_MODE", "allow") # allow / deny
COMMAND_ALLOWED_GROUPS: frozenset[str] = frozenset( prefix: str = "#"
filter(None, os.environ.get("COMMAND_ALLOWED_GROUPS", "").split(",")) length_min: int = 2
length_max: int = 4
scope: str = "all" # all / group / private
list_mode: str = "allow" # allow / deny
allowed_groups: frozenset[str] = frozenset()
denied_groups: frozenset[str] = frozenset()
allowed_users: frozenset[str] = frozenset()
denied_users: frozenset[str] = frozenset()
at_sender: bool = True
callback_url: str = ""
callback_timeout: int = 180
# 全局单例 —— 所有消费者通过 config.command.xxx 访问,避免 stale import 问题
command = CommandConfig()
# ── 环境变量默认值YAML 缺失 key 时的回退)────────────────────
_ENV_DEFAULTS: dict = {
"prefix": os.environ.get("COMMAND_PREFIX", "#"),
"length_min": int(os.environ.get("COMMAND_LENGTH_MIN", "2")),
"length_max": int(os.environ.get("COMMAND_LENGTH_MAX", "4")),
"scope": os.environ.get("COMMAND_SCOPE", "all"),
"list_mode": os.environ.get("COMMAND_LIST_MODE", "allow"),
"allowed_groups": os.environ.get("COMMAND_ALLOWED_GROUPS", ""),
"denied_groups": os.environ.get("COMMAND_DENIED_GROUPS", ""),
"allowed_users": os.environ.get("COMMAND_ALLOWED_USERS", ""),
"denied_users": os.environ.get("COMMAND_DENIED_USERS", ""),
"at_sender": os.environ.get("COMMAND_AT_SENDER", "true").lower() in ("true", "1", "yes"),
"callback_url": os.environ.get("COMMAND_CALLBACK_URL", ""),
"callback_timeout": int(os.environ.get("COMMAND_CALLBACK_TIMEOUT", "180")),
}
# ── YAML I/O ─────────────────────────────────────────────────
_YAML_HEADER = (
"# settings.yaml - 命令监听动态配置\n"
"# 可通过后台管理界面修改,也可手动编辑(自动热重载)\n\n"
) )
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 _yaml_defaults() -> dict:
def _parse_frozenset(value: str) -> frozenset[str]: """返回默认 YAML 结构(首次启动时写入)。"""
"""将逗号分隔字符串解析为 frozenset。""" return {
return frozenset(filter(None, (v.strip() for v in value.split(",")))) "command": {
"prefix": "#",
"length_min": 2,
"length_max": 4,
"scope": "all",
"list_mode": "allow",
"allowed_groups": [],
"denied_groups": [],
"allowed_users": [],
"denied_users": [],
"at_sender": True,
"callback_url": "",
"callback_timeout": 180,
}
}
def reload_settings(settings: dict[str, str]) -> None: def load_yaml() -> dict:
"""从数据库读取的配置覆盖模块级变量,使配置动态生效""" """读取 settings.yaml文件不存在时返回空字典"""
global COMMAND_PREFIX, COMMAND_LENGTH_MIN, COMMAND_LENGTH_MAX if not SETTINGS_YAML_PATH.exists():
global COMMAND_CALLBACK_URL, COMMAND_CALLBACK_TIMEOUT, COMMAND_SCOPE return {}
global COMMAND_LIST_MODE, COMMAND_ALLOWED_GROUPS, COMMAND_DENIED_GROUPS with open(SETTINGS_YAML_PATH, "r", encoding="utf-8") as f:
global COMMAND_ALLOWED_USERS, COMMAND_DENIED_USERS, COMMAND_AT_SENDER data = yaml.safe_load(f)
return data if isinstance(data, dict) else {}
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")
# 刷新正则编译缓存 def save_yaml(data: dict) -> None:
"""将配置写入 settings.yaml。"""
with open(SETTINGS_YAML_PATH, "w", encoding="utf-8") as f:
f.write(_YAML_HEADER)
yaml.dump(data, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
def ensure_settings_yaml() -> None:
"""首次启动时创建 settings.yaml合并环境变量覆盖。"""
if SETTINGS_YAML_PATH.exists():
return
data = _yaml_defaults()
# 用环境变量覆盖默认值
env_map = {
"prefix": ("COMMAND_PREFIX", str),
"length_min": ("COMMAND_LENGTH_MIN", int),
"length_max": ("COMMAND_LENGTH_MAX", int),
"scope": ("COMMAND_SCOPE", str),
"list_mode": ("COMMAND_LIST_MODE", str),
"allowed_groups": ("COMMAND_ALLOWED_GROUPS", "csv_list"),
"denied_groups": ("COMMAND_DENIED_GROUPS", "csv_list"),
"allowed_users": ("COMMAND_ALLOWED_USERS", "csv_list"),
"denied_users": ("COMMAND_DENIED_USERS", "csv_list"),
"at_sender": ("COMMAND_AT_SENDER", "bool"),
"callback_url": ("COMMAND_CALLBACK_URL", str),
"callback_timeout": ("COMMAND_CALLBACK_TIMEOUT", int),
}
for key, (env_name, conv) in env_map.items():
env_val = os.environ.get(env_name)
if env_val is not None:
if conv == "csv_list":
data["command"][key] = [v.strip() for v in env_val.split(",") if v.strip()]
elif conv == "bool":
data["command"][key] = env_val.lower() in ("true", "1", "yes")
else:
data["command"][key] = conv(env_val)
save_yaml(data)
logger.info("Created settings.yaml with defaults (env-var overrides applied)")
# ── 将 YAML 数据应用到全局 command 对象 ───────────────────────
def _apply_yaml_to_command(data: dict) -> None:
"""将 YAML command 段合并到全局 command 对象,缺失 key 回退到环境变量默认值。"""
cmd_data = data.get("command", {})
command.prefix = str(cmd_data.get("prefix", _ENV_DEFAULTS["prefix"]))
command.length_min = int(cmd_data.get("length_min", _ENV_DEFAULTS["length_min"]))
command.length_max = int(cmd_data.get("length_max", _ENV_DEFAULTS["length_max"]))
command.scope = str(cmd_data.get("scope", _ENV_DEFAULTS["scope"]))
command.list_mode = str(cmd_data.get("list_mode", _ENV_DEFAULTS["list_mode"]))
command.callback_url = str(cmd_data.get("callback_url", _ENV_DEFAULTS["callback_url"]))
command.callback_timeout = int(cmd_data.get("callback_timeout", _ENV_DEFAULTS["callback_timeout"]))
# at_sender: YAML bool → Python bool
at_sender_val = cmd_data.get("at_sender", _ENV_DEFAULTS["at_sender"])
if isinstance(at_sender_val, str):
command.at_sender = at_sender_val.lower() in ("true", "1", "yes")
else:
command.at_sender = bool(at_sender_val)
# 列表字段: YAML list → frozenset
for field_name in ("allowed_groups", "denied_groups", "allowed_users", "denied_users"):
raw = cmd_data.get(field_name)
if raw is None:
# 回退到环境变量默认值
env_default = _ENV_DEFAULTS[field_name]
if isinstance(env_default, str):
setattr(command, field_name, frozenset(
v.strip() for v in env_default.split(",") if v.strip()
))
else:
setattr(command, field_name, frozenset(env_default) if env_default else frozenset())
elif isinstance(raw, str):
setattr(command, field_name, frozenset(
v.strip() for v in raw.split(",") if v.strip()
))
elif isinstance(raw, list):
setattr(command, field_name, frozenset(str(v).strip() for v in raw if str(v).strip()))
else:
setattr(command, field_name, frozenset())
# 重建命令匹配正则
from .handlers.command import rebuild_pattern from .handlers.command import rebuild_pattern
rebuild_pattern() rebuild_pattern()
# ── 公共 API ─────────────────────────────────────────────────
def reload_settings() -> None:
"""重新读取 settings.yaml 并应用到全局 command 对象。"""
data = load_yaml()
_apply_yaml_to_command(data)
logger.info("Config reloaded from settings.yaml")
def get_settings_flat() -> dict[str, str]:
"""返回所有动态配置的扁平字典 {key: str_value},供管理 API 使用。"""
return {
"command_prefix": command.prefix,
"command_length_min": str(command.length_min),
"command_length_max": str(command.length_max),
"command_scope": command.scope,
"command_list_mode": command.list_mode,
"command_allowed_groups": ",".join(sorted(command.allowed_groups)),
"command_denied_groups": ",".join(sorted(command.denied_groups)),
"command_allowed_users": ",".join(sorted(command.allowed_users)),
"command_denied_users": ",".join(sorted(command.denied_users)),
"command_at_sender": str(command.at_sender).lower(),
"command_callback_url": command.callback_url,
"command_callback_timeout": str(command.callback_timeout),
}
# API 平坦 key → (YAML key, 类型转换) 映射
_API_KEY_MAP: dict[str, tuple[str, ...]] = {
"command_prefix": ("prefix", str),
"command_length_min": ("length_min", int),
"command_length_max": ("length_max", int),
"command_scope": ("scope", str),
"command_list_mode": ("list_mode", str),
"command_at_sender": ("at_sender", "bool"),
"command_callback_url": ("callback_url", str),
"command_callback_timeout": ("callback_timeout", int),
"command_allowed_groups": ("allowed_groups", "csv_list"),
"command_denied_groups": ("denied_groups", "csv_list"),
"command_allowed_users": ("allowed_users", "csv_list"),
"command_denied_users": ("denied_users", "csv_list"),
}
def update_settings_from_api(data: dict[str, str]) -> dict[str, str]:
"""从管理 API 接收扁平字典,合并到 settings.yaml 并热重载。返回已应用的字段。"""
allowed_keys = set(_API_KEY_MAP.keys())
filtered = {k: str(v) for k, v in data.items() if k in allowed_keys}
if not filtered:
return {}
# 读取当前 YAML合并修改
yaml_data = load_yaml()
if "command" not in yaml_data:
yaml_data["command"] = {}
cmd = yaml_data["command"]
for flat_key, value in filtered.items():
yaml_key, conv = _API_KEY_MAP[flat_key]
if conv == "csv_list":
cmd[yaml_key] = [v.strip() for v in value.split(",") if v.strip()]
elif conv == "bool":
cmd[yaml_key] = value.lower() in ("true", "1", "yes")
elif conv is int:
try:
cmd[yaml_key] = int(value)
except ValueError:
pass
else:
cmd[yaml_key] = value
save_yaml(yaml_data)
# 写入后立即应用(不等文件 watcher
_apply_yaml_to_command(yaml_data)
return filtered

82
db.py
View File

@@ -1,82 +0,0 @@
"""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()

View File

@@ -2,22 +2,7 @@
from aiohttp import web from aiohttp import web
from ..config import ( from ..config import command, get_settings_flat, reload_settings, update_settings_from_api
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 from ..response import error, ok
@@ -25,8 +10,7 @@ from ..response import error, ok
async def api_get_settings(request: web.Request) -> web.Response: async def api_get_settings(request: web.Request) -> web.Response:
"""GET /api/settings — 返回全部动态配置。""" """GET /api/settings — 返回全部动态配置。"""
settings = await get_settings() return ok(data=get_settings_flat())
return ok(data=settings)
async def api_update_settings(request: web.Request) -> web.Response: async def api_update_settings(request: web.Request) -> web.Response:
@@ -39,37 +23,17 @@ async def api_update_settings(request: web.Request) -> web.Response:
if not isinstance(data, dict): if not isinstance(data, dict):
return error("request body must be a json object") return error("request body must be a json object")
# 允许更新的 key 白名单 filtered = update_settings_from_api(data)
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: if not filtered:
return error("no valid settings to update") return error("no valid settings to update")
await update_settings(filtered)
reload_settings(filtered)
return ok(data=filtered, msg="配置已更新并生效") return ok(data=filtered, msg="配置已更新并生效")
async def api_reload_settings(request: web.Request) -> web.Response: async def api_reload_settings(request: web.Request) -> web.Response:
"""POST /api/settings/reload — 从数据库重新加载配置。""" """POST /api/settings/reload — 从 settings.yaml 重新加载配置。"""
settings = await get_settings() reload_settings()
reload_settings(settings) return ok(msg="配置已从 settings.yaml 重新加载")
return ok(msg="配置已从数据库重新加载")
# ── 管理页面 ───────────────────────────────────────────────── # ── 管理页面 ─────────────────────────────────────────────────
@@ -568,7 +532,7 @@ body {
<div class="header-right"> <div class="header-right">
<span class="header-badge" id="statusBadge">● 运行中</span> <span class="header-badge" id="statusBadge">● 运行中</span>
<button class="btn btn-sm btn-primary" onclick="saveAll()">保存配置</button> <button class="btn btn-sm btn-primary" onclick="saveAll()">保存配置</button>
<button class="btn btn-sm btn-default" onclick="reloadFromDb()">重新加载</button> <button class="btn btn-sm btn-default" onclick="reloadFromYaml()">重新加载</button>
</div> </div>
</header> </header>
@@ -750,7 +714,7 @@ body {
</div> </div>
<div style="display:flex; justify-content:space-between; padding:8px 0; border-bottom:1px solid var(--border-color); font-size:13px;"> <div style="display:flex; justify-content:space-between; padding:8px 0; border-bottom:1px solid var(--border-color); font-size:13px;">
<span style="color:var(--text-secondary);">存储</span> <span style="color:var(--text-secondary);">存储</span>
<span>SQLite</span> <span>YAML</span>
</div> </div>
</div> </div>
</div> </div>
@@ -933,13 +897,13 @@ async function saveAll() {
} }
} }
// ── 从数据库重新加载 ────────────────────────────────────── // ── 从 YAML 重新加载 ──────────────────────────────────────
async function reloadFromDb() { async function reloadFromYaml() {
try { try {
const resp = await fetch('/api/settings/reload', { method: 'POST' }); const resp = await fetch('/api/settings/reload', { method: 'POST' });
const json = await resp.json(); const json = await resp.json();
if (json.code === 0) { if (json.code === 0) {
showToast('已从数据库重新加载', 'success'); showToast('已从 YAML 重新加载', 'success');
await loadSettings(); await loadSettings();
} else { } else {
showToast('重新加载失败: ' + json.msg, 'error'); showToast('重新加载失败: ' + json.msg, 'error');

View File

@@ -4,7 +4,7 @@ import re
import aiohttp import aiohttp
from ..config import COMMAND_AT_SENDER, COMMAND_CALLBACK_TIMEOUT, COMMAND_CALLBACK_URL, COMMAND_LENGTH_MAX, COMMAND_LENGTH_MIN, COMMAND_PREFIX, UPLOAD_DIR from ..config import UPLOAD_DIR, command
from ..handlers.message import _resolve_url from ..handlers.message import _resolve_url
@@ -18,7 +18,7 @@ def build_command_pattern() -> re.Pattern:
- 其他非空白字符 = 1 - 其他非空白字符 = 1
""" """
return re.compile( return re.compile(
rf"^{re.escape(COMMAND_PREFIX)}(\S{{{COMMAND_LENGTH_MIN},{COMMAND_LENGTH_MAX}}})(?:\s+(.+))?$", rf"^{re.escape(command.prefix)}(\S{{{command.length_min},{command.length_max}}})(?:\s+(.+))?$",
re.DOTALL, re.DOTALL,
) )
@@ -56,28 +56,28 @@ 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 // 是否 @发送者(默认取 COMMAND_AT_SENDER 配置,仅群聊) "at_sender": true // 是否 @发送者(默认取配置,仅群聊)
} }
所有字段均为可选,无回复内容时返回空 JSON 即可。 所有字段均为可选,无回复内容时返回空 JSON 即可。
回复会引用触发命令的原消息。 回复会引用触发命令的原消息。
""" """
if not COMMAND_CALLBACK_URL: if not command.callback_url:
logger.warning("COMMAND_CALLBACK_URL 未配置,跳过命令回调") logger.warning("callback_url 未配置,跳过命令回调")
return return
try: try:
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.post( async with session.post(
COMMAND_CALLBACK_URL, command.callback_url,
json=data, json=data,
timeout=aiohttp.ClientTimeout(total=COMMAND_CALLBACK_TIMEOUT), timeout=aiohttp.ClientTimeout(total=command.callback_timeout),
) as resp: ) as resp:
if resp.status >= 400: if resp.status >= 400:
body = await resp.text() body = await resp.text()
logger.error( logger.error(
"命令回调失败: status=%d url=%s body=%s", "命令回调失败: status=%d url=%s body=%s",
resp.status, COMMAND_CALLBACK_URL, body[:200], resp.status, command.callback_url, body[:200],
) )
return return
@@ -93,13 +93,13 @@ async def send_command_callback(data: dict, event, api, logger) -> None:
await _handle_reply(result, event.data, api, logger) await _handle_reply(result, event.data, api, logger)
except Exception as exc: except Exception as exc:
logger.error("命令回调异常: url=%s error=%s", COMMAND_CALLBACK_URL, exc) logger.error("命令回调异常: url=%s error=%s", command.callback_url, exc)
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: 回调响应中的值优先,未指定则使用全局配置 # at_sender: 回调响应中的值优先,未指定则使用全局配置
at_sender = result.get("at_sender", COMMAND_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)

View File

@@ -8,24 +8,15 @@ from aiohttp import web
from ncatbot.plugin import NcatBotPlugin from ncatbot.plugin import NcatBotPlugin
from .config import ( from .config import (
COMMAND_ALLOWED_GROUPS,
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, HOST,
PORT, PORT,
UPLOAD_DIR, UPLOAD_DIR,
WEBHOOK_API_KEY, WEBHOOK_API_KEY,
SETTINGS_YAML_PATH,
command,
ensure_settings_yaml,
reload_settings, 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.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.command import parse_command, send_command_callback
from .handlers.health import health_handler from .handlers.health import health_handler
@@ -47,12 +38,12 @@ class WebHookPlugin(NcatBotPlugin):
self._webhook_runner: web.AppRunner | None = None self._webhook_runner: web.AppRunner | None = None
self._cleanup_task: asyncio.Task | None = None self._cleanup_task: asyncio.Task | None = None
self._listener_task: asyncio.Task | None = None self._listener_task: asyncio.Task | None = None
self._watcher_task: asyncio.Task | None = None
async def on_load(self): async def on_load(self):
# 初始化数据库并加载动态配置 # 初始化 settings.yaml 并加载配置
await init_db() ensure_settings_yaml()
settings = await get_settings() reload_settings()
reload_settings(settings)
self.logger.info("Webhook 插件已加载") self.logger.info("Webhook 插件已加载")
self.logger.info( self.logger.info(
@@ -61,32 +52,28 @@ class WebHookPlugin(NcatBotPlugin):
) )
self.logger.info( self.logger.info(
"命令监听: 前缀=%s 长度=%d~%d 范围=%s 名单=%s 回调=%s", "命令监听: 前缀=%s 长度=%d~%d 范围=%s 名单=%s 回调=%s",
COMMAND_PREFIX, command.prefix,
COMMAND_LENGTH_MIN, command.length_min,
COMMAND_LENGTH_MAX, command.length_max,
COMMAND_SCOPE, command.scope,
COMMAND_LIST_MODE, command.list_mode,
COMMAND_CALLBACK_URL or "未配置", 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())
self._watcher_task = asyncio.create_task(self._config_watcher())
async def on_close(self): async def on_close(self):
if self._listener_task is not None: for task_attr in ("_watcher_task", "_listener_task", "_cleanup_task"):
self._listener_task.cancel() task = getattr(self, task_attr)
try: if task is not None:
await self._listener_task task.cancel()
except asyncio.CancelledError: try:
pass await task
self._listener_task = None except asyncio.CancelledError:
if self._cleanup_task is not None: pass
self._cleanup_task.cancel() setattr(self, task_attr, None)
try:
await self._cleanup_task
except asyncio.CancelledError:
pass
self._cleanup_task = None
await self._stop_webhook() await self._stop_webhook()
self.logger.info("Webhook 插件已卸载") self.logger.info("Webhook 插件已卸载")
@@ -99,6 +86,21 @@ class WebHookPlugin(NcatBotPlugin):
except Exception as exc: except Exception as exc:
self.logger.error("清理过期文件失败: %s", exc) self.logger.error("清理过期文件失败: %s", exc)
async def _config_watcher(self) -> None:
"""轮询 settings.yaml 的 mtime变更时热重载配置。"""
last_mtime: float = 0.0
while True:
await asyncio.sleep(2)
try:
if SETTINGS_YAML_PATH.exists():
current_mtime = SETTINGS_YAML_PATH.stat().st_mtime
if current_mtime != last_mtime:
last_mtime = current_mtime
reload_settings()
self.logger.info("settings.yaml changed, config reloaded")
except Exception as exc:
self.logger.error("Config watcher error: %s", exc)
async def _message_listener(self) -> None: async def _message_listener(self) -> None:
"""监听 QQ 消息,匹配命令模式后转发到外部回调。""" """监听 QQ 消息,匹配命令模式后转发到外部回调。"""
try: try:
@@ -114,25 +116,25 @@ class WebHookPlugin(NcatBotPlugin):
# 范围过滤group / private / all # 范围过滤group / private / all
is_group = hasattr(event.data, "group_id") is_group = hasattr(event.data, "group_id")
if COMMAND_SCOPE == "group" and not is_group: if command.scope == "group" and not is_group:
continue continue
if COMMAND_SCOPE == "private" and is_group: if command.scope == "private" and is_group:
continue continue
# 黑白名单过滤 # 黑白名单过滤
if COMMAND_LIST_MODE == "allow": if command.list_mode == "allow":
# 白名单模式:在名单内才放行 # 白名单模式:在名单内才放行
if COMMAND_ALLOWED_GROUPS and is_group: if command.allowed_groups and is_group:
if event.data.group_id not in COMMAND_ALLOWED_GROUPS: if event.data.group_id not in command.allowed_groups:
continue continue
if COMMAND_ALLOWED_USERS and event.data.user_id not in COMMAND_ALLOWED_USERS: if command.allowed_users and event.data.user_id not in command.allowed_users:
continue continue
elif COMMAND_LIST_MODE == "deny": elif command.list_mode == "deny":
# 黑名单模式:在名单内则拒绝 # 黑名单模式:在名单内则拒绝
if COMMAND_DENIED_GROUPS and is_group: if command.denied_groups and is_group:
if event.data.group_id in COMMAND_DENIED_GROUPS: if event.data.group_id in command.denied_groups:
continue continue
if COMMAND_DENIED_USERS and event.data.user_id in COMMAND_DENIED_USERS: if command.denied_users and event.data.user_id in command.denied_users:
continue continue
# 构建回调数据 # 构建回调数据

View File

@@ -8,5 +8,5 @@ dependencies = [
"ncatbot5>=5.5.2.post3", "ncatbot5>=5.5.2.post3",
"aiohttp>=3.9", "aiohttp>=3.9",
"python-dotenv>=1.0", "python-dotenv>=1.0",
"aiosqlite>=0.20", "pyyaml>=6.0",
] ]

13
uv.lock generated
View File

@@ -109,15 +109,6 @@ 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" }, { 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]] [[package]]
name = "annotated-types" name = "annotated-types"
version = "0.7.0" version = "0.7.0"
@@ -918,17 +909,17 @@ version = "0.1.1"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "aiohttp" }, { name = "aiohttp" },
{ name = "aiosqlite" },
{ name = "ncatbot5" }, { name = "ncatbot5" },
{ name = "python-dotenv" }, { name = "python-dotenv" },
{ name = "pyyaml" },
] ]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "aiohttp", specifier = ">=3.9" }, { name = "aiohttp", specifier = ">=3.9" },
{ name = "aiosqlite", specifier = ">=0.20" },
{ name = "ncatbot5", specifier = ">=5.5.2.post3" }, { name = "ncatbot5", specifier = ">=5.5.2.post3" },
{ name = "python-dotenv", specifier = ">=1.0" }, { name = "python-dotenv", specifier = ">=1.0" },
{ name = "pyyaml", specifier = ">=6.0" },
] ]
[[package]] [[package]]