feat: 添加WEBUI功能,允许实时修改群聊/私聊的黑白名单
parent
417e30daca
commit
9914bac5a6
4
main.py
4
main.py
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
"""
|
||||
WebUI 包 - 用于实时修改配置
|
||||
"""
|
||||
|
||||
from .app import start_webui, stop_webui, webui_router
|
||||
|
||||
__all__ = ["start_webui", "stop_webui", "webui_router"]
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
@ -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>'''
|
||||
|
||||
Loading…
Reference in New Issue