feat: 添加WEBUI功能,允许实时修改群聊/私聊的黑白名单

pull/75/head
Alnnt 2025-12-31 23:00:02 +08:00
parent 417e30daca
commit 9914bac5a6
6 changed files with 606 additions and 1 deletions

View File

@ -12,6 +12,7 @@ from src.send_handler.nc_sending import nc_message_sender
from src.config import global_config
from src.mmc_com_layer import mmc_start_com, mmc_stop_com, router
from src.response_pool import put_response, check_timeout_response
from src.webui import start_webui, stop_webui
message_queue = asyncio.Queue()
@ -48,7 +49,7 @@ async def message_process():
async def main():
message_send_instance.maibot_router = router
_ = await asyncio.gather(napcat_server(), mmc_start_com(), message_process(), check_timeout_response())
_ = await asyncio.gather(napcat_server(), mmc_start_com(), message_process(), check_timeout_response(), start_webui())
def check_napcat_server_token(conn, request):
token = global_config.napcat_server.token
@ -80,6 +81,7 @@ async def graceful_shutdown():
if not task.done():
task.cancel()
await asyncio.wait_for(asyncio.gather(*tasks, return_exceptions=True), 15)
await stop_webui() # 停止 WebUI
await mmc_stop_com() # 后置避免神秘exception
logger.info("Adapter已成功关闭")
except Exception as e:

View File

@ -0,0 +1,8 @@
"""
WebUI - 用于实时修改配置
"""
from .app import start_webui, stop_webui, webui_router
__all__ = ["start_webui", "stop_webui", "webui_router"]

45
src/webui/app.py 100644
View File

@ -0,0 +1,45 @@
"""
WebUI 应用 - 基于 aiohttp Web 服务器
"""
import asyncio
from aiohttp import web
from typing import Optional
from src.logger import logger
from src.config import global_config
from .routes import setup_routes
_runner: Optional[web.AppRunner] = None
_site: Optional[web.TCPSite] = None
# WebUI 配置
WEBUI_HOST = "0.0.0.0"
WEBUI_PORT = 8096
async def start_webui():
"""启动 WebUI 服务"""
global _runner, _site
app = web.Application()
setup_routes(app)
_runner = web.AppRunner(app)
await _runner.setup()
_site = web.TCPSite(_runner, WEBUI_HOST, WEBUI_PORT)
await _site.start()
logger.info(f"WebUI 已启动,访问地址: http://{WEBUI_HOST}:{WEBUI_PORT}")
async def stop_webui():
"""停止 WebUI 服务"""
global _runner
if _runner:
await _runner.cleanup()
logger.info("WebUI 已停止")
# 兼容 router 接口
webui_router = None

View File

@ -0,0 +1,89 @@
"""
配置管理器 - 处理 ChatConfig 的读写
"""
import tomlkit
from typing import Literal, List, Dict, Any
from src.config import global_config
from src.logger import logger
CONFIG_FILE_PATH = "config.toml"
def get_chat_config() -> Dict[str, Any]:
"""获取当前 ChatConfig 配置"""
chat = global_config.chat
return {
"group_list_type": chat.group_list_type,
"group_list": list(chat.group_list),
"private_list_type": chat.private_list_type,
"private_list": list(chat.private_list),
}
def update_group_list_type(value: Literal["whitelist", "blacklist"]) -> bool:
"""更新群聊列表类型"""
try:
global_config.chat.group_list_type = value
return True
except Exception as e:
logger.error(f"更新 group_list_type 失败: {e}")
return False
def update_group_list(value: List[int]) -> bool:
"""更新群聊列表"""
try:
global_config.chat.group_list = value
return True
except Exception as e:
logger.error(f"更新 group_list 失败: {e}")
return False
def update_private_list_type(value: Literal["whitelist", "blacklist"]) -> bool:
"""更新私聊列表类型"""
try:
global_config.chat.private_list_type = value
return True
except Exception as e:
logger.error(f"更新 private_list_type 失败: {e}")
return False
def update_private_list(value: List[int]) -> bool:
"""更新私聊列表"""
try:
global_config.chat.private_list = value
return True
except Exception as e:
logger.error(f"更新 private_list 失败: {e}")
return False
def save_config_to_file() -> bool:
"""将当前配置保存到文件"""
try:
# 读取现有配置文件以保留注释和格式
with open(CONFIG_FILE_PATH, "r", encoding="utf-8") as f:
config_doc = tomlkit.load(f)
# 更新 chat 配置
if "chat" not in config_doc:
config_doc["chat"] = tomlkit.table()
config_doc["chat"]["group_list_type"] = global_config.chat.group_list_type
config_doc["chat"]["group_list"] = global_config.chat.group_list
config_doc["chat"]["private_list_type"] = global_config.chat.private_list_type
config_doc["chat"]["private_list"] = global_config.chat.private_list
# 写回文件
with open(CONFIG_FILE_PATH, "w", encoding="utf-8") as f:
f.write(tomlkit.dumps(config_doc))
logger.info("[WebUI] 配置已保存到文件")
return True
except Exception as e:
logger.error(f"[WebUI] 保存配置到文件失败: {e}")
return False

102
src/webui/routes.py 100644
View File

@ -0,0 +1,102 @@
"""
WebUI 路由定义
"""
import json
from aiohttp import web
from src.config import global_config
from src.logger import logger
from .config_manager import (
get_chat_config,
update_group_list_type,
update_group_list,
update_private_list_type,
update_private_list,
save_config_to_file,
)
from .static import get_index_html
async def index_handler(request: web.Request) -> web.Response:
"""返回主页面"""
return web.Response(text=get_index_html(), content_type="text/html")
async def get_config_handler(request: web.Request) -> web.Response:
"""获取当前配置"""
config = get_chat_config()
return web.json_response(config)
async def update_config_handler(request: web.Request) -> web.Response:
"""更新配置"""
try:
data = await request.json()
field = data.get("field")
value = data.get("value")
if field is None or value is None:
return web.json_response({"success": False, "error": "缺少 field 或 value 参数"}, status=400)
success = False
message = ""
if field == "group_list_type":
if value not in ["whitelist", "blacklist"]:
return web.json_response({"success": False, "error": "group_list_type 必须是 whitelist 或 blacklist"}, status=400)
success = update_group_list_type(value)
message = f"群聊列表类型已更新为: {value}"
elif field == "group_list":
if not isinstance(value, list):
return web.json_response({"success": False, "error": "group_list 必须是数组"}, status=400)
# 确保所有元素都是整数
try:
value = [int(v) for v in value]
except (ValueError, TypeError):
return web.json_response({"success": False, "error": "group_list 中的元素必须是数字"}, status=400)
success = update_group_list(value)
message = f"群聊列表已更新,共 {len(value)} 个群组"
elif field == "private_list_type":
if value not in ["whitelist", "blacklist"]:
return web.json_response({"success": False, "error": "private_list_type 必须是 whitelist 或 blacklist"}, status=400)
success = update_private_list_type(value)
message = f"私聊列表类型已更新为: {value}"
elif field == "private_list":
if not isinstance(value, list):
return web.json_response({"success": False, "error": "private_list 必须是数组"}, status=400)
# 确保所有元素都是整数
try:
value = [int(v) for v in value]
except (ValueError, TypeError):
return web.json_response({"success": False, "error": "private_list 中的元素必须是数字"}, status=400)
success = update_private_list(value)
message = f"私聊列表已更新,共 {len(value)} 个用户"
else:
return web.json_response({"success": False, "error": f"未知的字段: {field}"}, status=400)
if success:
# 保存配置到文件
save_config_to_file()
logger.info(f"[WebUI] {message}")
return web.json_response({"success": True, "message": message, "config": get_chat_config()})
else:
return web.json_response({"success": False, "error": "更新配置失败"}, status=500)
except json.JSONDecodeError:
return web.json_response({"success": False, "error": "无效的 JSON 数据"}, status=400)
except Exception as e:
logger.error(f"[WebUI] 更新配置时发生错误: {e}")
return web.json_response({"success": False, "error": str(e)}, status=500)
def setup_routes(app: web.Application):
"""设置路由"""
app.router.add_get("/", index_handler)
app.router.add_get("/api/config", get_config_handler)
app.router.add_post("/api/config", update_config_handler)

359
src/webui/static.py 100644
View File

@ -0,0 +1,359 @@
"""
静态资源 - 提供 HTML 页面
"""
def get_index_html() -> str:
"""返回主页面 HTML"""
return '''<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MaiBot Adapter 配置管理</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 800px;
margin: 0 auto;
}
h1 {
color: white;
text-align: center;
margin-bottom: 30px;
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
}
.card {
background: white;
border-radius: 12px;
padding: 24px;
margin-bottom: 20px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
}
.card h2 {
color: #333;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 2px solid #667eea;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
font-weight: 600;
color: #555;
margin-bottom: 8px;
}
.form-group select {
width: 100%;
padding: 12px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 16px;
transition: border-color 0.3s;
}
.form-group select:focus {
outline: none;
border-color: #667eea;
}
.list-container {
background: #f8f9fa;
border-radius: 8px;
padding: 16px;
}
.list-items {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
min-height: 40px;
}
.list-item {
background: #667eea;
color: white;
padding: 6px 12px;
border-radius: 20px;
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
}
.list-item .remove-btn {
background: rgba(255,255,255,0.3);
border: none;
color: white;
width: 20px;
height: 20px;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
transition: background 0.3s;
}
.list-item .remove-btn:hover {
background: rgba(255,255,255,0.5);
}
.add-form {
display: flex;
gap: 10px;
}
.add-form input {
flex: 1;
padding: 10px 14px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 14px;
}
.add-form input:focus {
outline: none;
border-color: #667eea;
}
.add-form button {
padding: 10px 20px;
background: #667eea;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
transition: background 0.3s;
}
.add-form button:hover {
background: #5a6fd6;
}
.status {
position: fixed;
top: 20px;
right: 20px;
padding: 12px 24px;
border-radius: 8px;
color: white;
font-weight: 600;
opacity: 0;
transform: translateY(-20px);
transition: all 0.3s;
z-index: 1000;
}
.status.show {
opacity: 1;
transform: translateY(0);
}
.status.success {
background: #28a745;
}
.status.error {
background: #dc3545;
}
.loading {
text-align: center;
padding: 40px;
color: #666;
}
.empty-list {
color: #999;
font-style: italic;
padding: 10px 0;
}
</style>
</head>
<body>
<div class="container">
<h1>🤖 MaiBot Adapter 配置管理</h1>
<div class="card">
<h2>📋 群聊设置</h2>
<div class="form-group">
<label for="group_list_type">群聊列表类型</label>
<select id="group_list_type" onchange="updateConfig('group_list_type', this.value)">
<option value="whitelist">白名单 (仅允许列表中的群聊)</option>
<option value="blacklist">黑名单 (禁止列表中的群聊)</option>
</select>
</div>
<div class="form-group">
<label>群聊列表</label>
<div class="list-container">
<div id="group_list" class="list-items">
<div class="loading">加载中...</div>
</div>
<div class="add-form">
<input type="number" id="new_group" placeholder="输入群号" />
<button onclick="addItem('group_list')">添加</button>
</div>
</div>
</div>
</div>
<div class="card">
<h2>💬 私聊设置</h2>
<div class="form-group">
<label for="private_list_type">私聊列表类型</label>
<select id="private_list_type" onchange="updateConfig('private_list_type', this.value)">
<option value="whitelist">白名单 (仅允许列表中的用户)</option>
<option value="blacklist">黑名单 (禁止列表中的用户)</option>
</select>
</div>
<div class="form-group">
<label>私聊列表</label>
<div class="list-container">
<div id="private_list" class="list-items">
<div class="loading">加载中...</div>
</div>
<div class="add-form">
<input type="number" id="new_private" placeholder="输入QQ号" />
<button onclick="addItem('private_list')">添加</button>
</div>
</div>
</div>
</div>
</div>
<div id="status" class="status"></div>
<script>
let config = {
group_list_type: 'whitelist',
group_list: [],
private_list_type: 'whitelist',
private_list: []
};
// 加载配置
async function loadConfig() {
try {
const response = await fetch('/api/config');
config = await response.json();
renderConfig();
} catch (error) {
showStatus('加载配置失败: ' + error.message, 'error');
}
}
// 渲染配置
function renderConfig() {
// 设置选择框
document.getElementById('group_list_type').value = config.group_list_type;
document.getElementById('private_list_type').value = config.private_list_type;
// 渲染列表
renderList('group_list', config.group_list);
renderList('private_list', config.private_list);
}
// 渲染列表
function renderList(listId, items) {
const container = document.getElementById(listId);
if (items.length === 0) {
container.innerHTML = '<div class="empty-list">列表为空</div>';
} else {
container.innerHTML = items.map(item => `
<div class="list-item">
<span>${item}</span>
<button class="remove-btn" onclick="removeItem('${listId}', ${item})">×</button>
</div>
`).join('');
}
}
// 更新配置
async function updateConfig(field, value) {
try {
const response = await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ field, value })
});
const result = await response.json();
if (result.success) {
config = result.config;
renderConfig();
showStatus(result.message, 'success');
} else {
showStatus(result.error, 'error');
}
} catch (error) {
showStatus('更新失败: ' + error.message, 'error');
}
}
// 添加项目
function addItem(listId) {
const inputId = listId === 'group_list' ? 'new_group' : 'new_private';
const input = document.getElementById(inputId);
const value = parseInt(input.value);
if (isNaN(value) || value <= 0) {
showStatus('请输入有效的数字', 'error');
return;
}
const list = [...config[listId]];
if (list.includes(value)) {
showStatus('该项已存在', 'error');
return;
}
list.push(value);
updateConfig(listId, list);
input.value = '';
}
// 删除项目
function removeItem(listId, value) {
const list = config[listId].filter(item => item !== value);
updateConfig(listId, list);
}
// 显示状态提示
function showStatus(message, type) {
const status = document.getElementById('status');
status.textContent = message;
status.className = 'status show ' + type;
setTimeout(() => {
status.className = 'status';
}, 3000);
}
// 页面加载时获取配置
loadConfig();
</script>
</body>
</html>'''