♻️ 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:
@@ -2,22 +2,7 @@
|
||||
|
||||
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 ..config import command, get_settings_flat, reload_settings, update_settings_from_api
|
||||
from ..response import error, ok
|
||||
|
||||
|
||||
@@ -25,8 +10,7 @@ from ..response import error, ok
|
||||
|
||||
async def api_get_settings(request: web.Request) -> web.Response:
|
||||
"""GET /api/settings — 返回全部动态配置。"""
|
||||
settings = await get_settings()
|
||||
return ok(data=settings)
|
||||
return ok(data=get_settings_flat())
|
||||
|
||||
|
||||
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):
|
||||
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}
|
||||
filtered = update_settings_from_api(data)
|
||||
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="配置已从数据库重新加载")
|
||||
"""POST /api/settings/reload — 从 settings.yaml 重新加载配置。"""
|
||||
reload_settings()
|
||||
return ok(msg="配置已从 settings.yaml 重新加载")
|
||||
|
||||
|
||||
# ── 管理页面 ─────────────────────────────────────────────────
|
||||
@@ -568,7 +532,7 @@ body {
|
||||
<div class="header-right">
|
||||
<span class="header-badge" id="statusBadge">● 运行中</span>
|
||||
<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>
|
||||
</header>
|
||||
|
||||
@@ -750,7 +714,7 @@ body {
|
||||
</div>
|
||||
<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>SQLite</span>
|
||||
<span>YAML</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -933,13 +897,13 @@ async function saveAll() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 从数据库重新加载 ──────────────────────────────────────
|
||||
async function reloadFromDb() {
|
||||
// ── 从 YAML 重新加载 ──────────────────────────────────────
|
||||
async function reloadFromYaml() {
|
||||
try {
|
||||
const resp = await fetch('/api/settings/reload', { method: 'POST' });
|
||||
const json = await resp.json();
|
||||
if (json.code === 0) {
|
||||
showToast('已从数据库重新加载', 'success');
|
||||
showToast('已从 YAML 重新加载', 'success');
|
||||
await loadSettings();
|
||||
} else {
|
||||
showToast('重新加载失败: ' + json.msg, 'error');
|
||||
|
||||
@@ -4,7 +4,7 @@ import re
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ def build_command_pattern() -> re.Pattern:
|
||||
- 其他非空白字符 = 1
|
||||
"""
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -56,28 +56,28 @@ async def send_command_callback(data: dict, event, api, logger) -> None:
|
||||
{"type": "file", "url": "..."},
|
||||
{"type": "video", "url": "..."}
|
||||
],
|
||||
"at_sender": true // 是否 @发送者(默认取 COMMAND_AT_SENDER 配置,仅群聊)
|
||||
"at_sender": true // 是否 @发送者(默认取配置,仅群聊)
|
||||
}
|
||||
|
||||
所有字段均为可选,无回复内容时返回空 JSON 即可。
|
||||
回复会引用触发命令的原消息。
|
||||
"""
|
||||
if not COMMAND_CALLBACK_URL:
|
||||
logger.warning("COMMAND_CALLBACK_URL 未配置,跳过命令回调")
|
||||
if not command.callback_url:
|
||||
logger.warning("callback_url 未配置,跳过命令回调")
|
||||
return
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
COMMAND_CALLBACK_URL,
|
||||
command.callback_url,
|
||||
json=data,
|
||||
timeout=aiohttp.ClientTimeout(total=COMMAND_CALLBACK_TIMEOUT),
|
||||
timeout=aiohttp.ClientTimeout(total=command.callback_timeout),
|
||||
) 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],
|
||||
resp.status, command.callback_url, body[:200],
|
||||
)
|
||||
return
|
||||
|
||||
@@ -93,13 +93,13 @@ async def send_command_callback(data: dict, event, api, logger) -> None:
|
||||
await _handle_reply(result, event.data, api, logger)
|
||||
|
||||
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:
|
||||
"""处理回调响应,引用原消息自动回复。msg_event 是 GroupMessageEvent / PrivateMessageEvent。"""
|
||||
# at_sender: 回调响应中的值优先,未指定则使用全局配置
|
||||
at_sender = result.get("at_sender", COMMAND_AT_SENDER)
|
||||
at_sender = result.get("at_sender", command.at_sender)
|
||||
messages = result.get("messages")
|
||||
reply = result.get("reply")
|
||||
group_id = getattr(msg_event, "group_id", None)
|
||||
|
||||
Reference in New Issue
Block a user