✨ 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:
63
config.py
63
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()
|
||||
|
||||
82
db.py
Normal file
82
db.py
Normal 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
996
handlers/admin.py
Normal 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")
|
||||
@@ -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())
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
35
plugin.py
35
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_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
|
||||
|
||||
# 构建回调数据
|
||||
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:
|
||||
|
||||
@@ -8,4 +8,5 @@ dependencies = [
|
||||
"ncatbot5>=5.5.2.post3",
|
||||
"aiohttp>=3.9",
|
||||
"python-dotenv>=1.0",
|
||||
"aiosqlite>=0.20",
|
||||
]
|
||||
|
||||
11
uv.lock
generated
11
uv.lock
generated
@@ -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" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user