feat(command): 添加动态配置、黑白名单与后台管理界面

- 新增 SQLite 数据库层(db.py)持久化命令监听配置,支持热更新无需重启
- 命令过滤从白名单扩展为黑白名单双模式(COMMAND_LIST_MODE: allow/deny)
- 新增后台管理页面 /admin/,侧边栏布局,支持在线修改所有命令监听配置
- 新增 REST API:GET/PUT /api/settings、POST /api/settings/reload
- 新增 rebuild_pattern() 支持配置变更后正则动态重编译
- 中间件放行 /admin 和 /api 路径免鉴权
- 添加 aiosqlite 依赖
This commit is contained in:
2026-05-03 15:22:53 +08:00
parent ed6e27f162
commit 9ffe78a9c2
8 changed files with 1194 additions and 14 deletions

View File

@@ -1,4 +1,4 @@
"""项目配置:所有值从环境变量读取,未配置时使用安全默认值""" """项目配置:静态配置从环境变量读取,命令监听配置支持数据库动态修改"""
import os import os
import uuid 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_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"))
# ── 命令监听 ──────────────────────────────────────────────── # ── 命令监听(可动态修改,从数据库加载) ──────────────────────
COMMAND_PREFIX: str = os.environ.get("COMMAND_PREFIX", "#") COMMAND_PREFIX: str = os.environ.get("COMMAND_PREFIX", "#")
COMMAND_LENGTH_MIN: int = int(os.environ.get("COMMAND_LENGTH_MIN", "2")) 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_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( COMMAND_ALLOWED_GROUPS: frozenset[str] = frozenset(
filter(None, os.environ.get("COMMAND_ALLOWED_GROUPS", "").split(",")) 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( COMMAND_ALLOWED_USERS: frozenset[str] = frozenset(
filter(None, os.environ.get("COMMAND_ALLOWED_USERS", "").split(",")) 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") 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()

82
db.py Normal file
View File

@@ -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()

996
handlers/admin.py Normal file
View File

@@ -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"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NcatBot Webhook 管理</title>
<style>
:root {
--sidebar-bg: #001529;
--sidebar-active: #1677FF;
--main-bg: #f0f2f5;
--primary-color: #1677FF;
--primary-hover: #4096FF;
--card-bg: #ffffff;
--text-main: #1F2329;
--text-secondary: #646A73;
--text-light: #8F959E;
--border-color: #DEE0E3;
--success: #52C41A;
--warning: #FAAD14;
--danger: #FF4D4F;
--sidebar-width: 220px;
--header-height: 56px;
--radius: 6px;
--shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: var(--main-bg);
color: var(--text-main);
display: flex;
min-height: 100vh;
}
/* Sidebar */
.sidebar {
width: var(--sidebar-width);
background: var(--sidebar-bg);
height: 100vh;
position: fixed;
left: 0; top: 0;
display: flex;
flex-direction: column;
z-index: 100;
transition: width 0.2s;
}
.sidebar-logo {
height: var(--header-height);
display: flex;
align-items: center;
padding: 0 20px;
color: #fff;
font-size: 16px;
font-weight: 600;
border-bottom: 1px solid rgba(255,255,255,0.08);
white-space: nowrap;
overflow: hidden;
}
.sidebar-logo .logo-icon {
width: 28px; height: 28px;
background: var(--primary-color);
border-radius: 6px;
display: flex; align-items: center; justify-content: center;
margin-right: 10px;
font-size: 14px;
flex-shrink: 0;
}
.sidebar-nav { flex: 1; padding: 8px 0; overflow-y: auto; }
.nav-group-title {
padding: 16px 20px 6px;
font-size: 11px;
color: rgba(255,255,255,0.35);
text-transform: uppercase;
letter-spacing: 1px;
}
.nav-item {
display: flex; align-items: center;
padding: 10px 20px;
color: rgba(255,255,255,0.65);
cursor: pointer;
transition: all 0.15s;
font-size: 14px;
border-left: 3px solid transparent;
}
.nav-item:hover {
color: #fff;
background: rgba(255,255,255,0.06);
}
.nav-item.active {
color: #fff;
background: rgba(22,119,255,0.15);
border-left-color: var(--primary-color);
}
.nav-item .nav-icon {
width: 18px; margin-right: 10px;
text-align: center; font-size: 15px;
flex-shrink: 0;
}
/* Main area */
.main {
margin-left: var(--sidebar-width);
flex: 1;
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* Header */
.header {
height: var(--header-height);
background: var(--card-bg);
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
padding: 0 24px;
position: sticky; top: 0;
z-index: 50;
}
.header-title {
font-size: 16px;
font-weight: 500;
color: var(--text-main);
}
.header-right {
margin-left: auto;
display: flex;
align-items: center;
gap: 12px;
}
.header-badge {
font-size: 12px;
padding: 2px 8px;
border-radius: 10px;
background: rgba(22,119,255,0.1);
color: var(--primary-color);
}
/* Content */
.content {
flex: 1;
padding: 24px;
}
/* Card */
.card {
background: var(--card-bg);
border-radius: var(--radius);
box-shadow: var(--shadow);
margin-bottom: 20px;
}
.card-header {
padding: 16px 20px;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
}
.card-header h3 {
font-size: 15px;
font-weight: 500;
}
.card-body { padding: 20px; }
/* Form */
.form-group {
margin-bottom: 18px;
}
.form-group:last-child { margin-bottom: 0; }
.form-label {
display: flex;
align-items: center;
margin-bottom: 6px;
font-size: 13px;
font-weight: 500;
color: var(--text-main);
}
.form-label .label-hint {
margin-left: 6px;
font-weight: 400;
color: var(--text-light);
font-size: 12px;
}
.form-input, .form-select, .form-textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--border-color);
border-radius: var(--radius);
font-size: 14px;
color: var(--text-main);
background: #fff;
transition: border-color 0.15s, box-shadow 0.15s;
outline: none;
}
.form-input:focus, .form-select:focus, .form-textarea:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(22,119,255,0.15);
}
.form-textarea {
min-height: 80px;
resize: vertical;
font-family: inherit;
}
.form-row {
display: flex;
gap: 16px;
}
.form-row .form-group { flex: 1; }
/* Tag input */
.tag-container {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 6px 10px;
border: 1px solid var(--border-color);
border-radius: var(--radius);
min-height: 38px;
cursor: text;
transition: border-color 0.15s, box-shadow 0.15s;
background: #fff;
}
.tag-container:focus-within {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(22,119,255,0.15);
}
.tag-item {
display: inline-flex;
align-items: center;
padding: 2px 8px;
background: rgba(22,119,255,0.08);
color: var(--primary-color);
border-radius: 4px;
font-size: 13px;
gap: 4px;
}
.tag-item .tag-close {
cursor: pointer;
font-size: 14px;
opacity: 0.6;
line-height: 1;
}
.tag-item .tag-close:hover { opacity: 1; }
.tag-input {
border: none;
outline: none;
flex: 1;
min-width: 80px;
font-size: 14px;
padding: 2px 0;
color: var(--text-main);
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 7px 16px;
border-radius: var(--radius);
font-size: 14px;
cursor: pointer;
border: 1px solid transparent;
transition: all 0.15s;
gap: 6px;
line-height: 1.4;
}
.btn-primary {
background: var(--primary-color);
color: #fff;
}
.btn-primary:hover { background: var(--primary-hover); }
.btn-default {
background: #fff;
border-color: var(--border-color);
color: var(--text-main);
}
.btn-default:hover { border-color: var(--primary-color); color: var(--primary-color); }
.btn-danger {
background: #fff;
border-color: var(--danger);
color: var(--danger);
}
.btn-danger:hover { background: var(--danger); color: #fff; }
.btn-sm { padding: 4px 10px; font-size: 12px; }
/* Switch */
.switch-wrapper {
display: flex;
align-items: center;
gap: 8px;
}
.switch {
position: relative;
width: 40px; height: 22px;
cursor: pointer;
}
.switch input { display: none; }
.switch-slider {
position: absolute;
inset: 0;
background: #ccc;
border-radius: 11px;
transition: 0.2s;
}
.switch-slider::before {
content: '';
position: absolute;
width: 18px; height: 18px;
left: 2px; top: 2px;
background: #fff;
border-radius: 50%;
transition: 0.2s;
}
.switch input:checked + .switch-slider {
background: var(--primary-color);
}
.switch input:checked + .switch-slider::before {
transform: translateX(18px);
}
.switch-text {
font-size: 13px;
color: var(--text-secondary);
}
/* Status dot */
.status-dot {
width: 8px; height: 8px;
border-radius: 50%;
display: inline-block;
margin-right: 6px;
}
.status-dot.on { background: var(--success); }
.status-dot.off { background: var(--text-light); }
/* Toast */
.toast-container {
position: fixed;
top: 16px;
right: 16px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 8px;
}
.toast {
padding: 10px 16px;
border-radius: var(--radius);
font-size: 14px;
box-shadow: 0 4px 12px rgba(0,0,0,0.12);
display: flex;
align-items: center;
gap: 8px;
animation: slideIn 0.2s ease;
min-width: 240px;
}
.toast-success { background: #f6ffed; color: #389e0d; border: 1px solid #b7eb8f; }
.toast-error { background: #fff2f0; color: #cf1322; border: 1px solid #ffccc7; }
.toast-info { background: #e6f7ff; color: #0958d9; border: 1px solid #91caff; }
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
/* Responsive */
@media (max-width: 768px) {
.sidebar { width: 0; overflow: hidden; }
.sidebar.open { width: var(--sidebar-width); }
.main { margin-left: 0; }
.form-row { flex-direction: column; gap: 0; }
}
/* Tabs */
.tabs {
display: flex;
border-bottom: 1px solid var(--border-color);
margin-bottom: 0;
}
.tab-item {
padding: 12px 20px;
font-size: 14px;
color: var(--text-secondary);
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.15s;
}
.tab-item:hover { color: var(--primary-color); }
.tab-item.active {
color: var(--primary-color);
border-bottom-color: var(--primary-color);
font-weight: 500;
}
.tab-panel { display: none; }
.tab-panel.active { display: block; }
/* Info block */
.info-block {
padding: 12px 16px;
background: #f0f5ff;
border-radius: var(--radius);
border-left: 3px solid var(--primary-color);
margin-bottom: 16px;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.6;
}
</style>
</head>
<body>
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-logo">
<div class="logo-icon">N</div>
<span>NcatBot Webhook</span>
</div>
<nav class="sidebar-nav">
<div class="nav-group-title">配置管理</div>
<div class="nav-item active" data-page="command">
<span class="nav-icon">⌘</span>
<span>命令监听</span>
</div>
<div class="nav-item" data-page="list">
<span class="nav-icon">☰</span>
<span>黑白名单</span>
</div>
<div class="nav-item" data-page="callback">
<span class="nav-icon">↗</span>
<span>回调设置</span>
</div>
<div class="nav-group-title">系统</div>
<div class="nav-item" data-page="about">
<span class="nav-icon"></span>
<span>关于</span>
</div>
</nav>
</aside>
<!-- Main -->
<div class="main">
<!-- Header -->
<header class="header">
<div class="header-title" id="headerTitle">命令监听配置</div>
<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>
</div>
</header>
<!-- Content -->
<div class="content">
<!-- 命令监听页 -->
<div class="page" id="page-command">
<div class="card">
<div class="card-header"><h3>基本设置</h3></div>
<div class="card-body">
<div class="info-block">
修改配置后点击右上角「保存配置」即可生效,无需重启服务。
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">命令前缀 <span class="label-hint">触发命令的起始字符</span></label>
<input class="form-input" id="command_prefix" placeholder="#" />
</div>
<div class="form-group">
<label class="form-label">监听范围 <span class="label-hint">消息来源过滤</span></label>
<select class="form-select" id="command_scope">
<option value="all">全部消息</option>
<option value="group">仅群聊</option>
<option value="private">仅私聊</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">命令长度下限 <span class="label-hint">最少字符数</span></label>
<input class="form-input" id="command_length_min" type="number" min="1" max="20" placeholder="2" />
</div>
<div class="form-group">
<label class="form-label">命令长度上限 <span class="label-hint">最多字符数</span></label>
<input class="form-input" id="command_length_max" type="number" min="1" max="20" placeholder="4" />
</div>
</div>
<div class="form-group">
<div class="switch-wrapper">
<label class="switch">
<input type="checkbox" id="command_at_sender" />
<span class="switch-slider"></span>
</label>
<span class="switch-text">回复时 @发送者(仅群聊生效)</span>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header"><h3>命令匹配预览</h3></div>
<div class="card-body">
<div class="form-group">
<label class="form-label">测试消息</label>
<input class="form-input" id="testMessage" placeholder="输入消息测试是否匹配命令,如 #帮助" oninput="testPattern()" />
</div>
<div id="testResult" style="font-size:13px; color:var(--text-secondary);">输入消息后自动检测</div>
</div>
</div>
</div>
<!-- 黑白名单页 -->
<div class="page" id="page-list" style="display:none;">
<div class="card">
<div class="card-header"><h3>名单模式</h3></div>
<div class="card-body">
<div class="info-block">
白名单模式:仅名单内的群/用户可触发命令。<br>
黑名单模式:名单内的群/用户不可触发命令。<br>
名单为空时表示不限制。
</div>
<div class="form-group">
<label class="form-label">名单模式</label>
<select class="form-select" id="command_list_mode">
<option value="allow">白名单(仅允许名单内)</option>
<option value="deny">黑名单(拒绝名单内)</option>
</select>
</div>
</div>
</div>
<div class="card">
<div class="card-header"><h3>群名单</h3></div>
<div class="card-body">
<div class="form-row">
<div class="form-group">
<label class="form-label">白名单群 <span class="label-hint">群号,回车添加</span></label>
<div class="tag-container" id="allowedGroupsContainer" onclick="focusTagInput(this)">
<input class="tag-input" id="allowedGroupsInput" placeholder="输入群号回车添加" onkeydown="handleTagKey(event, 'command_allowed_groups')" />
</div>
</div>
<div class="form-group">
<label class="form-label">黑名单群 <span class="label-hint">群号,回车添加</span></label>
<div class="tag-container" id="deniedGroupsContainer" onclick="focusTagInput(this)">
<input class="tag-input" id="deniedGroupsInput" placeholder="输入群号回车添加" onkeydown="handleTagKey(event, 'command_denied_groups')" />
</div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header"><h3>用户名单</h3></div>
<div class="card-body">
<div class="form-row">
<div class="form-group">
<label class="form-label">白名单用户 <span class="label-hint">QQ号回车添加</span></label>
<div class="tag-container" id="allowedUsersContainer" onclick="focusTagInput(this)">
<input class="tag-input" id="allowedUsersInput" placeholder="输入QQ号回车添加" onkeydown="handleTagKey(event, 'command_allowed_users')" />
</div>
</div>
<div class="form-group">
<label class="form-label">黑名单用户 <span class="label-hint">QQ号回车添加</span></label>
<div class="tag-container" id="deniedUsersContainer" onclick="focusTagInput(this)">
<input class="tag-input" id="deniedUsersInput" placeholder="输入QQ号回车添加" onkeydown="handleTagKey(event, 'command_denied_users')" />
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 回调设置页 -->
<div class="page" id="page-callback" style="display:none;">
<div class="card">
<div class="card-header"><h3>回调配置</h3></div>
<div class="card-body">
<div class="info-block">
命令匹配成功后,插件会将命令数据 POST 到回调 URL。回调服务器返回的 JSON 可控制自动回复。
</div>
<div class="form-group">
<label class="form-label">回调 URL <span class="label-hint">留空则不触发回调</span></label>
<input class="form-input" id="command_callback_url" placeholder="https://example.com/callback" />
</div>
<div class="form-group">
<label class="form-label">回调超时(秒) <span class="label-hint">等待回调服务器响应的最长时间</span></label>
<input class="form-input" id="command_callback_timeout" type="number" min="1" max="600" placeholder="180" />
</div>
</div>
</div>
<div class="card">
<div class="card-header"><h3>回调数据格式</h3></div>
<div class="card-body">
<div class="info-block" style="margin-bottom:0;">
<strong>请求数据:</strong><br>
<code style="font-size:12px;background:#f5f5f5;padding:2px 6px;border-radius:3px;">
{"command":"帮助","content":"参数","raw_message":"#帮助 参数","user_id":"123","message_id":"456","group_id":"789"}
</code>
<br><br>
<strong>响应格式:</strong><br>
<code style="font-size:12px;background:#f5f5f5;padding:2px 6px;border-radius:3px;">
{"reply":"回复文本","messages":[{"type":"text","msg":"..."},{"type":"image","url":"..."}],"at_sender":true}
</code>
</div>
</div>
</div>
</div>
<!-- 关于页 -->
<div class="page" id="page-about" style="display:none;">
<div class="card">
<div class="card-header"><h3>关于</h3></div>
<div class="card-body">
<div style="text-align:center; padding: 20px 0;">
<div style="font-size:40px; margin-bottom:12px;">N</div>
<h2 style="font-size:20px; margin-bottom:8px;">NcatBot Webhook Plugin</h2>
<p style="color:var(--text-secondary); font-size:14px;">对外暴露 HTTP 接口,接收外部消息转发至 QQ</p>
</div>
<div style="margin-top:16px;">
<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>v0.1.2</span>
</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>NcatBot + aiohttp</span>
</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>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Toast container -->
<div class="toast-container" id="toastContainer"></div>
<script>
// ── 全局状态 ──────────────────────────────────────────────
const TAG_FIELDS = {
command_allowed_groups: { container: 'allowedGroupsContainer', input: 'allowedGroupsInput' },
command_denied_groups: { container: 'deniedGroupsContainer', input: 'deniedGroupsInput' },
command_allowed_users: { container: 'allowedUsersContainer', input: 'allowedUsersInput' },
command_denied_users: { container: 'deniedUsersContainer', input: 'deniedUsersInput' },
};
const tagValues = {
command_allowed_groups: [],
command_denied_groups: [],
command_allowed_users: [],
command_denied_users: [],
};
const PAGE_TITLES = {
command: '命令监听配置',
list: '黑白名单管理',
callback: '回调设置',
about: '关于',
};
let currentPage = 'command';
// ── 页面切换 ──────────────────────────────────────────────
document.querySelectorAll('.nav-item[data-page]').forEach(item => {
item.addEventListener('click', () => {
const page = item.dataset.page;
if (page === currentPage) return;
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
item.classList.add('active');
document.querySelectorAll('.page').forEach(p => p.style.display = 'none');
document.getElementById('page-' + page).style.display = 'block';
document.getElementById('headerTitle').textContent = PAGE_TITLES[page] || '';
currentPage = page;
});
});
// ── Tag 输入 ──────────────────────────────────────────────
function focusTagInput(container) {
container.querySelector('.tag-input').focus();
}
function handleTagKey(event, field) {
if (event.key === 'Enter') {
event.preventDefault();
const input = event.target;
const value = input.value.trim();
if (value && !tagValues[field].includes(value)) {
tagValues[field].push(value);
renderTags(field);
}
input.value = '';
}
}
function renderTags(field) {
const cfg = TAG_FIELDS[field];
const container = document.getElementById(cfg.container);
const input = document.getElementById(cfg.input);
// Remove old tags
container.querySelectorAll('.tag-item').forEach(t => t.remove());
// Add tags before input
tagValues[field].forEach((val, idx) => {
const tag = document.createElement('span');
tag.className = 'tag-item';
tag.innerHTML = val + ' <span class="tag-close" onclick="removeTag(\'' + field + '\',' + idx + ')">×</span>';
container.insertBefore(tag, input);
});
}
function removeTag(field, idx) {
tagValues[field].splice(idx, 1);
renderTags(field);
}
// ── Toast ──────────────────────────────────────────────────
function showToast(msg, type = 'info') {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
toast.className = 'toast toast-' + type;
const icons = { success: '', error: '', info: '' };
toast.innerHTML = '<span>' + (icons[type] || '') + '</span><span>' + msg + '</span>';
container.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transition = 'opacity 0.3s';
setTimeout(() => toast.remove(), 300);
}, 3000);
}
// ── 加载配置 ──────────────────────────────────────────────
async function loadSettings() {
try {
const resp = await fetch('/api/settings');
const json = await resp.json();
if (json.code !== 0) { showToast('加载配置失败: ' + json.msg, 'error'); return; }
const data = json.data;
// 普通字段
const fields = [
'command_prefix', 'command_length_min', 'command_length_max',
'command_scope', 'command_list_mode', 'command_callback_url',
'command_callback_timeout', 'command_at_sender',
];
fields.forEach(key => {
const el = document.getElementById(key);
if (!el) return;
if (el.type === 'checkbox') {
el.checked = data[key] === 'true' || data[key] === '1' || data[key] === 'yes';
} else {
el.value = data[key] || '';
}
});
// Tag 字段
['command_allowed_groups', 'command_denied_groups', 'command_allowed_users', 'command_denied_users'].forEach(key => {
const raw = data[key] || '';
tagValues[key] = raw.split(',').map(s => s.trim()).filter(Boolean);
renderTags(key);
});
showToast('配置已加载', 'success');
} catch (e) {
showToast('加载配置异常: ' + e.message, 'error');
}
}
// ── 保存配置 ──────────────────────────────────────────────
async function saveAll() {
const data = {};
// 普通字段
const fields = [
'command_prefix', 'command_length_min', 'command_length_max',
'command_scope', 'command_list_mode', 'command_callback_url',
'command_callback_timeout',
];
fields.forEach(key => {
const el = document.getElementById(key);
if (el) data[key] = el.value;
});
// checkbox
const cb = document.getElementById('command_at_sender');
if (cb) data.command_at_sender = cb.checked ? 'true' : 'false';
// Tag 字段
Object.keys(tagValues).forEach(key => {
data[key] = tagValues[key].join(',');
});
try {
const resp = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
const json = await resp.json();
if (json.code === 0) {
showToast('配置已保存并生效', 'success');
} else {
showToast('保存失败: ' + json.msg, 'error');
}
} catch (e) {
showToast('保存异常: ' + e.message, 'error');
}
}
// ── 从数据库重新加载 ──────────────────────────────────────
async function reloadFromDb() {
try {
const resp = await fetch('/api/settings/reload', { method: 'POST' });
const json = await resp.json();
if (json.code === 0) {
showToast('已从数据库重新加载', 'success');
await loadSettings();
} else {
showToast('重新加载失败: ' + json.msg, 'error');
}
} catch (e) {
showToast('重新加载异常: ' + e.message, 'error');
}
}
// ── 命令匹配测试 ──────────────────────────────────────────
function testPattern() {
const prefix = document.getElementById('command_prefix').value || '#';
const minLen = parseInt(document.getElementById('command_length_min').value) || 2;
const maxLen = parseInt(document.getElementById('command_length_max').value) || 4;
const msg = document.getElementById('testMessage').value.trim();
const result = document.getElementById('testResult');
if (!msg) {
result.innerHTML = '<span style="color:var(--text-light)">输入消息后自动检测</span>';
return;
}
const pattern = new RegExp('^' + escapeRegex(prefix) + '(\\S{' + minLen + ',' + maxLen + '})(?:\\s+(.+))?$');
const match = msg.match(pattern);
if (match) {
result.innerHTML = '<span style="color:var(--success)">✓ 匹配成功</span> — 命令: <strong>' + escapeHtml(match[1]) + '</strong>' +
(match[2] ? ',内容: <strong>' + escapeHtml(match[2].trim()) + '</strong>' : ',无内容');
} else {
result.innerHTML = '<span style="color:var(--danger)">✗ 不匹配</span> — 需满足: ' +
escapeHtml(prefix) + ' + ' + minLen + '~' + maxLen + '个非空白字符';
}
}
function escapeRegex(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function escapeHtml(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
// ── 初始化 ────────────────────────────────────────────────
loadSettings();
</script>
</body>
</html>"""
async def admin_page_handler(request: web.Request) -> web.Response:
"""GET /admin/ — 返回管理页面 HTML。"""
return web.Response(text=ADMIN_HTML, content_type="text/html")

View File

@@ -26,6 +26,12 @@ def build_command_pattern() -> re.Pattern:
COMMAND_PATTERN = build_command_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: def parse_command(raw_message: str) -> dict | None:
"""解析消息,匹配命令模式。返回 {command, content, raw_message} 或 None。""" """解析消息,匹配命令模式。返回 {command, content, raw_message} 或 None。"""
match = COMMAND_PATTERN.match(raw_message.strip()) match = COMMAND_PATTERN.match(raw_message.strip())

View File

@@ -10,9 +10,9 @@ from .response import error
@web.middleware @web.middleware
async def auth_middleware(request: web.Request, handler): async def auth_middleware(request: web.Request, handler):
""" /upload 和 /webhook 路径强制校验 API Key""" """需要鉴权的路径校验 API Key。/healthz 和 /admin/ 及 /api/ 开头的路径不需要鉴权"""
# 健康检查不需要鉴权 # 不需要鉴权的路径
if request.path == "/healthz": if request.path == "/healthz" or request.path.startswith("/admin") or request.path.startswith("/api/"):
return await handler(request) return await handler(request)
auth_header = request.headers.get("Authorization", "") auth_header = request.headers.get("Authorization", "")

View File

@@ -12,15 +12,21 @@ from .config import (
COMMAND_ALLOWED_USERS, COMMAND_ALLOWED_USERS,
COMMAND_AT_SENDER, COMMAND_AT_SENDER,
COMMAND_CALLBACK_URL, COMMAND_CALLBACK_URL,
COMMAND_DENIED_GROUPS,
COMMAND_DENIED_USERS,
COMMAND_LENGTH_MAX, COMMAND_LENGTH_MAX,
COMMAND_LENGTH_MIN, COMMAND_LENGTH_MIN,
COMMAND_LIST_MODE,
COMMAND_PREFIX, COMMAND_PREFIX,
COMMAND_SCOPE, COMMAND_SCOPE,
HOST, HOST,
PORT, PORT,
UPLOAD_DIR, UPLOAD_DIR,
WEBHOOK_API_KEY, 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.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
@@ -43,17 +49,23 @@ class WebHookPlugin(NcatBotPlugin):
self._listener_task: asyncio.Task | None = None self._listener_task: asyncio.Task | None = None
async def on_load(self): async def on_load(self):
# 初始化数据库并加载动态配置
await init_db()
settings = await get_settings()
reload_settings(settings)
self.logger.info("Webhook 插件已加载") self.logger.info("Webhook 插件已加载")
self.logger.info( self.logger.info(
"WEBHOOK_API_KEY: %s", "WEBHOOK_API_KEY: %s",
"已配置" if os.environ.get("WEBHOOK_API_KEY") else "自动生成", "已配置" if os.environ.get("WEBHOOK_API_KEY") else "自动生成",
) )
self.logger.info( self.logger.info(
"命令监听: 前缀=%s 长度=%d~%d 范围=%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_CALLBACK_URL or "未配置", COMMAND_CALLBACK_URL or "未配置",
) )
asyncio.create_task(self._start_webhook()) asyncio.create_task(self._start_webhook())
@@ -107,14 +119,21 @@ class WebHookPlugin(NcatBotPlugin):
if COMMAND_SCOPE == "private" and is_group: if COMMAND_SCOPE == "private" and is_group:
continue continue
# 白名单过滤 # 白名单过滤
if COMMAND_ALLOWED_GROUPS and is_group: if COMMAND_LIST_MODE == "allow":
if event.data.group_id not in COMMAND_ALLOWED_GROUPS: # 白名单模式:在名单内才放行
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 continue
# 用户白名单过滤
if COMMAND_ALLOWED_USERS and event.data.user_id not in COMMAND_ALLOWED_USERS:
continue
# 构建回调数据 # 构建回调数据
data = { data = {
@@ -124,7 +143,7 @@ class WebHookPlugin(NcatBotPlugin):
"user_id": event.data.user_id, "user_id": event.data.user_id,
"message_id": event.data.message_id, "message_id": event.data.message_id,
} }
if hasattr(event.data, "group_id"): if is_group:
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",
@@ -147,6 +166,11 @@ class WebHookPlugin(NcatBotPlugin):
app.router.add_get("/healthz", health_handler) app.router.add_get("/healthz", health_handler)
app.router.add_post("/webhook", webhook_handler) app.router.add_post("/webhook", webhook_handler)
app.router.add_post("/upload", upload_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 return app
async def _start_webhook(self): async def _start_webhook(self):
@@ -158,6 +182,7 @@ class WebHookPlugin(NcatBotPlugin):
await site.start() await site.start()
self.logger.info("Webhook 已启动: %s:%d", HOST, PORT) self.logger.info("Webhook 已启动: %s:%d", HOST, PORT)
self.logger.info("上传目录: %s", UPLOAD_DIR) self.logger.info("上传目录: %s", UPLOAD_DIR)
self.logger.info("后台管理: http://%s:%d/admin/", HOST, PORT)
async def _stop_webhook(self): async def _stop_webhook(self):
if self._webhook_runner is not None: if self._webhook_runner is not None:

View File

@@ -8,4 +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",
] ]

11
uv.lock generated
View File

@@ -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" }, { 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"
@@ -909,6 +918,7 @@ 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" },
] ]
@@ -916,6 +926,7 @@ dependencies = [
[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" },
] ]