nonebot-plugin-shiro-web-console 0.1.6__py3-none-any.whl → 0.1.8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of nonebot-plugin-shiro-web-console might be problematic. Click here for more details.

@@ -4,6 +4,9 @@ import httpx
4
4
  import os
5
5
  import random
6
6
  import time
7
+ import secrets
8
+ import hashlib
9
+ import re
7
10
  from datetime import datetime, timedelta
8
11
  from urllib.parse import quote, unquote
9
12
  from typing import Dict, List, Set, Optional, Any
@@ -13,14 +16,15 @@ from collections import deque
13
16
  from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request, Response, Depends, HTTPException
14
17
  from fastapi.staticfiles import StaticFiles
15
18
  from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
16
- from nonebot import get_app, get_bot, get_driver, logger, on_message, on_command, require
17
- require("nonebot_plugin_localstore")
19
+ from nonebot import get_app, get_bot, get_bots, get_driver, logger, on_message, on_command, require, on_bot_connect
18
20
  import nonebot_plugin_localstore
19
21
  from .config import Config, config
20
22
  from nonebot.permission import SUPERUSER
21
- from nonebot.adapters.onebot.v11 import Bot, MessageEvent, GroupMessageEvent, PrivateMessageEvent, MessageSegment
23
+ from nonebot.adapters.onebot.v11 import Bot, MessageEvent, GroupMessageEvent, PrivateMessageEvent, MessageSegment, Message
22
24
  from nonebot.plugin import PluginMetadata
23
25
 
26
+ START_TIME = time.time()
27
+
24
28
  __plugin_meta__ = PluginMetadata(
25
29
  name="Shiro Web Console",
26
30
  description="通过浏览器查看日志、管理机器人并发送消息",
@@ -31,20 +35,43 @@ __plugin_meta__ = PluginMetadata(
31
35
  supported_adapters={"~onebot.v11"},
32
36
  extra={
33
37
  "author": "luojisama",
34
- "version": "0.1.6",
38
+ "version": "0.1.8",
35
39
  "pypi_test": "nonebot-plugin-shiro-web-console",
36
40
  },
37
41
  )
38
42
 
43
+ # WebSocket 连接池
44
+ active_connections: Set[WebSocket] = set()
45
+
46
+ async def broadcast_message(data: dict):
47
+ if not active_connections:
48
+ return
49
+
50
+ dead_connections = set()
51
+ for ws in active_connections:
52
+ try:
53
+ await ws.send_json(data)
54
+ except Exception:
55
+ dead_connections.add(ws)
56
+
57
+ for ws in dead_connections:
58
+ active_connections.remove(ws)
59
+
39
60
  # 日志缓冲区,保留最近 200 条日志
40
61
  log_buffer = deque(maxlen=200)
41
62
 
42
- def log_sink(message):
43
- log_buffer.append({
63
+ async def log_sink(message):
64
+ log_entry = {
44
65
  "time": datetime.now().strftime("%H:%M:%S"),
45
66
  "level": message.record["level"].name,
46
67
  "message": message.record["message"],
47
68
  "module": message.record["module"]
69
+ }
70
+ log_buffer.append(log_entry)
71
+ # 推送日志
72
+ await broadcast_message({
73
+ "type": "new_log",
74
+ "data": log_entry
48
75
  })
49
76
 
50
77
  # 注册 loguru sink
@@ -63,20 +90,30 @@ class AuthManager:
63
90
  self.password_file = self.data_dir / "password.json"
64
91
 
65
92
  # 初始加载密码
66
- self.admin_password = self._load_password()
93
+ self.admin_password_hash = self._load_password_hash()
67
94
 
68
- def _load_password(self) -> str:
95
+ def _load_password_hash(self) -> str:
96
+ pwd = "admin123"
69
97
  if self.password_file.exists():
70
98
  try:
71
99
  data = json.loads(self.password_file.read_text(encoding="utf-8"))
72
- return data.get("password", "admin123")
100
+ if "password_hash" in data:
101
+ return data["password_hash"]
102
+ pwd = data.get("password", "admin123")
73
103
  except:
74
104
  pass
75
- return config.web_console_password
105
+ else:
106
+ pwd = config.web_console_password
107
+
108
+ # 迁移或初始化:将明文转换为哈希
109
+ return hashlib.sha256(pwd.encode()).hexdigest()
76
110
 
77
111
  def save_password(self, new_password: str):
78
- self.admin_password = new_password
79
- self.password_file.write_text(json.dumps({"password": new_password}), encoding="utf-8")
112
+ pwd_hash = hashlib.sha256(new_password.encode()).hexdigest()
113
+ self.admin_password_hash = pwd_hash
114
+ self.password_file.write_text(json.dumps({"password_hash": pwd_hash}), encoding="utf-8")
115
+ # 修改密码后使旧 token 失效
116
+ self.token = None
80
117
 
81
118
  def generate_code(self) -> str:
82
119
  self.code = "".join([str(random.randint(0, 9)) for _ in range(6)])
@@ -96,13 +133,14 @@ class AuthManager:
96
133
  return False
97
134
 
98
135
  def verify_password(self, password: str) -> bool:
99
- if password == self.admin_password:
136
+ input_hash = hashlib.sha256(password.encode()).hexdigest()
137
+ if input_hash == self.admin_password_hash:
100
138
  self.generate_token()
101
139
  return True
102
140
  return False
103
141
 
104
142
  def generate_token(self):
105
- self.token = "".join([random.choice("abcdefghijklmnopqrstuvwxyz0123456789") for _ in range(32)])
143
+ self.token = secrets.token_hex(16)
106
144
  self.token_expire = datetime.now() + timedelta(days=7)
107
145
 
108
146
  def verify_token(self, token: str) -> bool:
@@ -126,32 +164,36 @@ async def check_auth(request: Request):
126
164
  return True
127
165
 
128
166
  try:
129
- app: Optional[FastAPI] = get_app()
167
+ _app = get_app()
130
168
  except (ValueError, AssertionError):
131
- app = None
169
+ _app = None
132
170
 
133
- if app is None:
134
- app = FastAPI()
135
- logger.warning("FastAPI app not found, created a new one. This might happen during plugin test.")
171
+ # 只有在驱动器支持 FastAPI 时才挂载
172
+ if isinstance(_app, FastAPI):
173
+ app = _app
174
+ else:
175
+ app = None
176
+ logger.warning("驱动器不支持 FastAPI,Web 控制台路由将无法访问。")
136
177
 
137
- static_path = Path(__file__).parent / "static"
138
- index_html = static_path / "index.html"
178
+ if app:
179
+ static_path = Path(__file__).parent / "static"
180
+ index_html = static_path / "index.html"
139
181
 
140
- # 挂载静态文件
141
- if static_path.exists():
142
- app.mount("/web_console/static", StaticFiles(directory=str(static_path)), name="web_console_static")
182
+ # 挂载静态文件
183
+ if static_path.exists():
184
+ app.mount("/web_console/static", StaticFiles(directory=str(static_path)), name="web_console_static")
143
185
 
144
- # Web 控制台入口路由
145
- @app.get("/web_console", response_class=HTMLResponse)
146
- async def serve_console():
147
- if not index_html.exists():
148
- return HTMLResponse("<h1>index.html not found</h1>", status_code=404)
149
- return HTMLResponse(content=index_html.read_text(encoding="utf-8"), status_code=200)
186
+ # Web 控制台入口路由
187
+ @app.get("/web_console", response_class=HTMLResponse)
188
+ async def serve_console():
189
+ if not index_html.exists():
190
+ return HTMLResponse("<h1>index.html not found</h1>", status_code=404)
191
+ return HTMLResponse(content=index_html.read_text(encoding="utf-8"), status_code=200)
150
192
 
151
- # 兼容 /web_console/ 路径
152
- @app.get("/web_console/", response_class=HTMLResponse)
153
- async def serve_console_slash():
154
- return await serve_console()
193
+ # 兼容 /web_console/ 路径
194
+ @app.get("/web_console/", response_class=HTMLResponse)
195
+ async def serve_console_slash():
196
+ return await serve_console()
155
197
 
156
198
  # 消息缓存 {chat_id: [messages]}
157
199
  message_cache: Dict[str, List[dict]] = {}
@@ -160,7 +202,8 @@ image_cache: Dict[str, dict] = {}
160
202
  CACHE_SIZE = 100
161
203
 
162
204
  # WebSocket 连接池
163
- active_connections: Set[WebSocket] = set()
205
+ # active_connections defined at top
206
+
164
207
 
165
208
  # 基础人设
166
209
  def get_chat_id(event: MessageEvent) -> str:
@@ -179,7 +222,7 @@ async def handle_password_cmd(bot: Bot, event: MessageEvent):
179
222
  await password_cmd.finish("请在命令后输入新密码,例如:web密码 mynewpassword")
180
223
 
181
224
  auth_manager.save_password(new_password)
182
- await password_cmd.finish(f"Web控制台密码已修改为: {new_password}\n请妥善保存。")
225
+ await password_cmd.finish(f"Web控制台密码已修改。\n请妥善保存。")
183
226
 
184
227
  @login_cmd.handle()
185
228
  async def handle_login_cmd(bot: Bot, event: MessageEvent):
@@ -273,35 +316,34 @@ async def handle_login_cmd(bot: Bot, event: MessageEvent):
273
316
  first_url = f"http://{public_ips[0]}:{port}/web_console" if public_ips else f"http://127.0.0.1:{port}/web_console"
274
317
  await login_cmd.finish(f"私聊发送失败,请确保您已添加机器人为好友。\n(当前环境访问地址提示:{first_url})")
275
318
 
276
- # 监听所有消息
277
- msg_matcher = on_message(priority=1, block=False)
278
-
279
- @msg_matcher.handle()
280
- async def handle_all_messages(bot: Bot, event: MessageEvent):
281
- chat_id = get_chat_id(event)
282
-
283
- # 尝试通过 get_msg 获取更详细的消息内容(尤其是 NapCat 等框架提供的 URL)
284
- sender_name = event.sender.nickname or str(event.user_id)
285
- try:
286
- msg_details = await bot.get_msg(message_id=event.message_id)
287
- message = msg_details["message"]
288
- # 如果 get_msg 返回了 sender 信息,则优先使用
289
- if "sender" in msg_details:
290
- sender_name = msg_details["sender"].get("nickname") or msg_details["sender"].get("card") or sender_name
291
- except Exception as e:
292
- logger.warning(f"获取消息详情失败: {e},将使用事件自带消息内容")
293
- message = event.get_message()
294
-
295
- # 消息内容解析
319
+ # 辅助函数:解析消息段
320
+ def parse_message_elements(message_segments) -> List[dict]:
296
321
  elements = []
297
- # 消息唯一 ID:使用适配器提供的原始 ID,以便前端去重
298
- msg_id = str(event.message_id)
299
322
 
300
- # 如果 message 是列表(get_msg 返回格式),直接遍历;如果是 Message 对象,也可以遍历
301
- for seg in message:
302
- # 处理 get_msg 返回的字典格式或 MessageSegment 对象
303
- seg_type = seg["type"] if isinstance(seg, dict) else seg.type
304
- seg_data = seg["data"] if isinstance(seg, dict) else seg.data
323
+ # 鲁棒性处理:如果是字符串,尝试转为 Message 对象
324
+ if isinstance(message_segments, str):
325
+ try:
326
+ # Message(str) 会自动解析 CQ 码(如果适配器支持)或作为纯文本
327
+ message_segments = Message(message_segments)
328
+ except Exception:
329
+ # 降级处理
330
+ return [{"type": "text", "data": {"text": message_segments}}]
331
+
332
+ # 如果是 Message 对象,转为 list
333
+ if hasattr(message_segments, "__iter__") and not isinstance(message_segments, (list, tuple)):
334
+ # Message 对象迭代出来是 MessageSegment
335
+ segments = list(message_segments)
336
+ else:
337
+ segments = message_segments
338
+
339
+ for seg in segments:
340
+ # 兼容 dict 和 MessageSegment
341
+ if isinstance(seg, dict):
342
+ seg_type = seg.get("type")
343
+ seg_data = seg.get("data", {})
344
+ else:
345
+ seg_type = seg.type
346
+ seg_data = seg.data
305
347
 
306
348
  if seg_type == "text":
307
349
  elements.append({"type": "text", "data": seg_data.get("text", "")})
@@ -311,32 +353,126 @@ async def handle_all_messages(bot: Bot, event: MessageEvent):
311
353
  # 优先从 get_msg 的数据中获取 url,NapCat 在 Linux 下可能返回 path 或 file 字段
312
354
  raw_url = seg_data.get("url") or seg_data.get("file") or seg_data.get("path") or ""
313
355
 
314
- # 如果是本地文件路径或非 http 开头的,构造代理 URL
315
- if raw_url and not raw_url.startswith("http"):
316
- # 这种情况下可能是 base64 或者本地路径,或者是 CQ 码中的 file 字段
317
- # 我们先尝试直接传给前端,由前端处理或后续通过代理获取
318
- pass
319
-
320
- # 为了确保显示,所有图片都尝试通过代理中转,除非是 base64
356
+ # 代理链接不带 token,由前端动态注入或 check_auth 处理
357
+ final_url = f"/web_console/proxy/image?url={quote(raw_url)}" if raw_url else ""
321
358
  if raw_url.startswith("data:image"):
322
359
  final_url = raw_url
323
- else:
324
- # 代理链接不带 token,由前端动态注入或 check_auth 处理
325
- final_url = f"/web_console/proxy/image?url={quote(raw_url)}" if raw_url else ""
326
-
360
+
327
361
  elements.append({"type": "image", "data": final_url, "raw": raw_url})
328
362
  elif seg_type == "face":
329
363
  face_id = seg_data.get("id")
330
364
  face_url = f"https://s.p.qq.com/pub/get_face?img_type=3&face_id={face_id}"
331
365
  elements.append({"type": "face", "data": face_url, "id": face_id})
332
366
  elif seg_type == "mface":
333
- # 商城表情(Stickers)
334
367
  url = seg_data.get("url")
335
368
  elements.append({"type": "image", "data": url})
336
369
  elif seg_type == "at":
337
370
  elements.append({"type": "at", "data": seg_data.get("qq")})
338
371
  elif seg_type == "reply":
339
372
  elements.append({"type": "reply", "data": seg_data.get("id")})
373
+
374
+ return elements
375
+
376
+ # Hook: 监听 Bot API 调用,捕获发送的消息
377
+ async def on_api_called(bot: Bot, exception: Optional[Exception], api: str, data: Dict[str, Any], result: Any):
378
+ if exception:
379
+ return
380
+
381
+ if api in ["send_group_msg", "send_private_msg", "send_msg"]:
382
+ try:
383
+ # Parse data
384
+ message = data.get("message")
385
+ if isinstance(message, str):
386
+ msg_obj = Message(message)
387
+ elif isinstance(message, list):
388
+ # 假设是 list of dicts
389
+ msg_obj = message
390
+ else:
391
+ msg_obj = message
392
+
393
+ elements = parse_message_elements(msg_obj)
394
+
395
+ # Determine chat_id
396
+ chat_id = ""
397
+ if api == "send_group_msg":
398
+ chat_id = f"group_{data.get('group_id')}"
399
+ elif api == "send_private_msg":
400
+ chat_id = f"private_{data.get('user_id')}"
401
+ elif api == "send_msg":
402
+ if data.get("message_type") == "group":
403
+ chat_id = f"group_{data.get('group_id')}"
404
+ else:
405
+ chat_id = f"private_{data.get('user_id')}"
406
+
407
+ if not chat_id:
408
+ return
409
+
410
+ # Construct msg_data
411
+ msg_id = 0
412
+ if isinstance(result, dict):
413
+ msg_id = result.get("message_id", 0)
414
+ elif isinstance(result, int):
415
+ msg_id = result
416
+
417
+ # 获取 content 字符串表示
418
+ content_str = str(message) if not isinstance(message, list) else "[Message]"
419
+
420
+ msg_data = {
421
+ "id": msg_id,
422
+ "chat_id": chat_id,
423
+ "time": int(time.time()),
424
+ "type": "group" if "group" in chat_id else "private",
425
+ "sender_id": bot.self_id,
426
+ "sender_name": "我",
427
+ "sender_avatar": f"https://q1.qlogo.cn/g?b=qq&nk={bot.self_id}&s=640",
428
+ "elements": elements,
429
+ "content": content_str,
430
+ "self_id": bot.self_id,
431
+ "is_self": True
432
+ }
433
+
434
+ # Add to cache and broadcast
435
+ if chat_id not in message_cache:
436
+ message_cache[chat_id] = []
437
+
438
+ message_cache[chat_id].append(msg_data)
439
+ if len(message_cache[chat_id]) > CACHE_SIZE:
440
+ message_cache[chat_id].pop(0)
441
+
442
+ await broadcast_message({
443
+ "type": "new_message",
444
+ "chat_id": chat_id,
445
+ "data": msg_data
446
+ })
447
+ except Exception as e:
448
+ logger.error(f"处理 Bot 发送消息 Hook 失败: {e}")
449
+
450
+ @on_bot_connect
451
+ async def _(bot: Bot):
452
+ if hasattr(bot, "on_called_api"):
453
+ bot.on_called_api(on_api_called)
454
+
455
+ # 监听所有消息
456
+ msg_matcher = on_message(priority=1, block=False)
457
+
458
+ @msg_matcher.handle()
459
+ async def handle_all_messages(bot: Bot, event: MessageEvent):
460
+ chat_id = get_chat_id(event)
461
+
462
+ # 尝试通过 get_msg 获取更详细的消息内容(尤其是 NapCat 等框架提供的 URL)
463
+ sender_name = event.sender.nickname or str(event.user_id)
464
+ try:
465
+ msg_details = await bot.get_msg(message_id=event.message_id)
466
+ message = msg_details["message"]
467
+ # 如果 get_msg 返回了 sender 信息,则优先使用
468
+ if "sender" in msg_details:
469
+ sender_name = msg_details["sender"].get("nickname") or msg_details["sender"].get("card") or sender_name
470
+ except Exception as e:
471
+ logger.warning(f"获取消息详情失败: {e},将使用事件自带消息内容")
472
+ message = event.get_message()
473
+
474
+ # 使用辅助函数解析消息内容
475
+ elements = parse_message_elements(message)
340
476
 
341
477
  msg_data = {
342
478
  "id": event.message_id,
@@ -366,481 +502,563 @@ async def handle_all_messages(bot: Bot, event: MessageEvent):
366
502
  "data": msg_data
367
503
  })
368
504
 
369
- async def broadcast_message(data: dict):
370
- if not active_connections:
371
- return
372
-
373
- dead_connections = set()
374
- for ws in active_connections:
375
- try:
376
- await ws.send_json(data)
377
- except Exception:
378
- dead_connections.add(ws)
379
-
380
- for ws in dead_connections:
381
- active_connections.remove(ws)
382
505
 
383
- # 认证 API
384
- @app.post("/web_console/api/send_code")
385
- async def send_code():
386
- if not superusers:
387
- return {"error": "未设置 SUPERUSERS 管理员列表"}
388
-
389
- code = auth_manager.generate_code()
390
- bot = get_bot()
391
-
392
- success_count = 0
393
- for user_id in superusers:
394
- try:
395
- await bot.send_private_msg(user_id=int(user_id), message=f"【Web控制台】您的登录验证码为:{code},5分钟内有效。")
396
- success_count += 1
397
- except Exception as e:
398
- logger.error(f"发送验证码给管理员 {user_id} 失败: {e}")
399
-
400
- if success_count > 0:
401
- return {"msg": "验证码已发送至管理员 QQ"}
402
- return {"error": "验证码发送失败,请检查机器人是否在线或管理员账号是否正确"}
403
-
404
- @app.post("/web_console/api/login")
405
- async def login(data: dict):
406
- code = data.get("code")
407
- password = data.get("password")
408
-
409
- if code:
410
- if auth_manager.verify_code(code):
411
- return {"token": auth_manager.token}
412
- return {"error": "验证码错误或已过期", "code": 401}
413
- elif password:
414
- if auth_manager.verify_password(password):
415
- return {"token": auth_manager.token}
416
- return {"error": "密码错误", "code": 401}
506
+ if app:
507
+ # 认证 API
508
+ @app.post("/web_console/api/send_code")
509
+ async def send_code():
510
+ if not superusers:
511
+ return {"error": "未设置 SUPERUSERS 管理员列表"}
417
512
 
418
- return {"error": "请输入验证码或密码", "code": 400}
419
-
420
- @app.get("/web_console/api/status", dependencies=[Depends(check_auth)])
421
- async def get_system_status():
422
- from nonebot import get_bots
423
- import psutil
424
- import platform
425
- import time
426
- import datetime
427
-
428
- # 系统性能
429
- cpu_percent = psutil.cpu_percent()
430
- memory = psutil.virtual_memory()
431
- disk = psutil.disk_usage('/')
432
-
433
- # 网络流量
434
- net_io = psutil.net_io_counters()
435
-
436
- # 运行时间
437
- boot_time = psutil.boot_time()
438
- uptime = time.time() - boot_time
439
- uptime_str = str(datetime.timedelta(seconds=int(uptime)))
440
-
441
- # 机器人信息
442
- bots_info = []
443
- for bot_id, bot in get_bots().items():
444
- try:
445
- profile = await bot.get_login_info()
446
- bots_info.append({
447
- "id": bot_id,
448
- "nickname": profile.get("nickname", "未知"),
449
- "avatar": f"https://q.qlogo.cn/headimg_dl?dst_uin={bot_id}&spec=640",
450
- "status": "在线"
451
- })
452
- except:
453
- bots_info.append({
454
- "id": bot_id,
455
- "nickname": "机器人",
456
- "avatar": f"https://q.qlogo.cn/headimg_dl?dst_uin={bot_id}&spec=640",
457
- "status": "离线"
458
- })
459
-
460
- return {
461
- "system": {
462
- "os": platform.system(),
463
- "cpu": f"{cpu_percent}%",
464
- "memory": f"{memory.percent}%",
465
- "memory_used": f"{round(memory.used / 1024 / 1024 / 1024, 2)} GB",
466
- "memory_total": f"{round(memory.total / 1024 / 1024 / 1024, 2)} GB",
467
- "disk": f"{disk.percent}%",
468
- "disk_used": f"{round(disk.used / 1024 / 1024 / 1024, 2)} GB",
469
- "disk_total": f"{round(disk.total / 1024 / 1024 / 1024, 2)} GB",
470
- "net_sent": f"{round(net_io.bytes_sent / 1024 / 1024, 2)} MB",
471
- "net_recv": f"{round(net_io.bytes_recv / 1024 / 1024, 2)} MB",
472
- "uptime": uptime_str,
473
- "python": platform.python_version()
474
- },
475
- "bots": bots_info
476
- }
477
-
478
- @app.get("/web_console/api/logs", dependencies=[Depends(check_auth)])
479
- async def get_logs():
480
- return list(log_buffer)
481
-
482
- @app.get("/web_console/api/plugins", dependencies=[Depends(check_auth)])
483
- async def get_plugins():
484
- from nonebot import get_loaded_plugins
485
- import os
486
- plugins = []
487
- for p in get_loaded_plugins():
488
- metadata = p.metadata
513
+ code = auth_manager.generate_code()
489
514
 
490
- # 识别插件来源
491
- plugin_type = "local"
492
- module_name = p.module_name
515
+ # 兼容多 Bot 场景
516
+ from nonebot import get_bots
517
+ bots = get_bots()
518
+ if not bots:
519
+ return {"error": "未连接任何 Bot"}
520
+ bot = list(bots.values())[0]
521
+
522
+ success_count = 0
523
+ for user_id in superusers:
524
+ try:
525
+ await bot.send_private_msg(user_id=int(user_id), message=f"【Web控制台】您的登录验证码为:{code},5分钟内有效。")
526
+ success_count += 1
527
+ except Exception as e:
528
+ logger.error(f"发送验证码给管理员 {user_id} 失败: {e}")
529
+
530
+ if success_count > 0:
531
+ return {"msg": "验证码已发送至管理员 QQ"}
532
+ return {"error": "验证码发送失败,请检查机器人是否在线或管理员账号是否正确"}
533
+
534
+ @app.post("/web_console/api/login")
535
+ async def login(data: dict):
536
+ code = data.get("code")
537
+ password = data.get("password")
493
538
 
494
- if module_name.startswith("nonebot.plugins"):
495
- plugin_type = "builtin"
496
- elif metadata and metadata.homepage and ("github.com/nonebot" in metadata.homepage or "nonebot.dev" in metadata.homepage):
497
- plugin_type = "official"
498
- elif module_name.startswith("nonebot_plugin_"):
499
- plugin_type = "store"
539
+ if code:
540
+ if auth_manager.verify_code(code):
541
+ return {"token": auth_manager.token}
542
+ return {"error": "验证码错误或已过期", "code": 401}
543
+ elif password:
544
+ if auth_manager.verify_password(password):
545
+ return {"token": auth_manager.token}
546
+ return {"error": "密码错误", "code": 401}
500
547
 
501
- plugins.append({
502
- "id": p.name,
503
- "name": metadata.name if metadata else p.name,
504
- "description": metadata.description if metadata else "暂无描述",
505
- "version": metadata.extra.get("version", "1.0.0") if metadata and metadata.extra else "1.0.0",
506
- "type": plugin_type,
507
- "module": module_name,
508
- "homepage": metadata.homepage if metadata else None
509
- })
510
- return plugins
511
-
512
- @app.post("/web_console/api/system/action", dependencies=[Depends(check_auth)])
513
- async def system_action(request: Request):
514
- data = await request.json()
515
- action = data.get("action")
516
-
517
- if action not in ["reboot", "shutdown"]:
518
- return {"error": "无效操作"}
548
+ return {"error": "请输入验证码或密码", "code": 400}
549
+
550
+ @app.get("/web_console/api/status", dependencies=[Depends(check_auth)])
551
+ async def get_system_status():
552
+ from nonebot import get_bots
553
+ import psutil
554
+ import platform
555
+ import time
556
+ import datetime
519
557
 
520
- import os
521
- import sys
522
- import subprocess
523
- import asyncio
524
-
525
- logger.warning(f"收到系统指令: {action}")
526
-
527
- if action == "shutdown":
528
- # 延迟执行关闭,确保响应能发出去
529
- loop = asyncio.get_event_loop()
530
- loop.call_later(1.0, lambda: os._exit(0))
531
- return {"msg": "Bot 正在关闭..."}
558
+ # 系统性能
559
+ cpu_percent = psutil.cpu_percent()
560
+ memory = psutil.virtual_memory()
561
+ disk = psutil.disk_usage('.')
532
562
 
533
- elif action == "reboot":
534
- # 获取项目根目录
535
- root_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
536
- bot_py = os.path.join(root_dir, "bot.py")
563
+ # 网络流量
564
+ net_io = psutil.net_io_counters()
537
565
 
538
- if os.path.exists(bot_py):
539
- cmd = [sys.executable, bot_py]
540
- else:
541
- cmd = [sys.executable] + sys.argv
542
-
543
- def do_reboot():
566
+ # 运行时间
567
+ uptime = time.time() - START_TIME
568
+ uptime_str = str(datetime.timedelta(seconds=int(uptime)))
569
+
570
+ # 机器人信息
571
+ bots_info = []
572
+ for bot_id, bot in get_bots().items():
544
573
  try:
545
- if sys.platform == "win32":
546
- subprocess.Popen(cmd, cwd=root_dir)
547
- os._exit(0)
548
- else:
549
- os.chdir(root_dir)
550
- os.execv(sys.executable, cmd)
551
- except Exception as e:
552
- logger.error(f"重启执行失败: {e}")
553
- os._exit(1)
574
+ profile = await bot.get_login_info()
575
+ bots_info.append({
576
+ "id": bot_id,
577
+ "nickname": profile.get("nickname", "未知"),
578
+ "avatar": f"https://q.qlogo.cn/headimg_dl?dst_uin={bot_id}&spec=640",
579
+ "status": "在线"
580
+ })
581
+ except:
582
+ bots_info.append({
583
+ "id": bot_id,
584
+ "nickname": "机器人",
585
+ "avatar": f"https://q.qlogo.cn/headimg_dl?dst_uin={bot_id}&spec=640",
586
+ "status": "离线"
587
+ })
588
+
589
+ return {
590
+ "system": {
591
+ "os": platform.system(),
592
+ "cpu": f"{cpu_percent}%",
593
+ "memory": f"{memory.percent}%",
594
+ "memory_used": f"{round(memory.used / 1024 / 1024 / 1024, 2)} GB",
595
+ "memory_total": f"{round(memory.total / 1024 / 1024 / 1024, 2)} GB",
596
+ "disk": f"{disk.percent}%",
597
+ "disk_used": f"{round(disk.used / 1024 / 1024 / 1024, 2)} GB",
598
+ "disk_total": f"{round(disk.total / 1024 / 1024 / 1024, 2)} GB",
599
+ "net_sent": f"{round(net_io.bytes_sent / 1024 / 1024, 2)} MB",
600
+ "net_recv": f"{round(net_io.bytes_recv / 1024 / 1024, 2)} MB",
601
+ "uptime": uptime_str,
602
+ "python": platform.python_version()
603
+ },
604
+ "bots": bots_info
605
+ }
554
606
 
555
- # 延迟执行重启
556
- loop = asyncio.get_event_loop()
557
- loop.call_later(1.0, do_reboot)
558
- return {"msg": "Bot 正在重启..."}
607
+ @app.get("/web_console/api/logs", dependencies=[Depends(check_auth)])
608
+ async def get_logs():
609
+ return list(log_buffer)
559
610
 
560
- @app.get("/web_console/api/plugins/{plugin_id}/config", dependencies=[Depends(check_auth)])
561
- async def get_plugin_config(plugin_id: str):
562
- from nonebot import get_loaded_plugins, get_driver
563
-
564
- # 查找插件
565
- target_plugin = None
566
- for p in get_loaded_plugins():
567
- if p.name == plugin_id:
568
- target_plugin = p
569
- break
611
+ @app.get("/web_console/api/plugins", dependencies=[Depends(check_auth)])
612
+ async def get_plugins():
613
+ from nonebot import get_loaded_plugins
614
+ import os
615
+ plugins = []
616
+ for p in get_loaded_plugins():
617
+ metadata = p.metadata
570
618
 
571
- if not target_plugin:
572
- raise HTTPException(status_code=404, detail="Plugin not found")
573
-
574
- # 获取配置元数据 (NoneBot 插件通常通过 metadata.config 导出 Config 类)
575
- config_schema = {}
576
- current_config = {}
577
-
578
- if target_plugin.metadata and target_plugin.metadata.config:
579
- try:
580
- config_class = target_plugin.metadata.config
581
- if hasattr(config_class, "schema"):
582
- schema = config_class.schema()
583
- config_schema = schema.get("properties", {})
584
- # 注入当前值
585
- driver_config = get_driver().config
586
- for key in config_schema:
587
- current_config[key] = getattr(driver_config, key, None)
588
- except Exception as e:
589
- logger.error(f"解析插件 {plugin_id} 配置失败: {e}")
619
+ # 识别插件来源
620
+ plugin_type = "local"
621
+ module_name = p.module_name
590
622
 
591
- return {"config": current_config, "schema": config_schema}
623
+ if module_name.startswith("nonebot.plugins"):
624
+ plugin_type = "builtin"
625
+ elif metadata and metadata.homepage and ("github.com/nonebot" in metadata.homepage or "nonebot.dev" in metadata.homepage):
626
+ plugin_type = "official"
627
+ elif module_name.startswith("nonebot_plugin_"):
628
+ plugin_type = "store"
629
+
630
+ plugins.append({
631
+ "id": p.name,
632
+ "name": metadata.name if metadata else p.name,
633
+ "description": metadata.description if metadata else "暂无描述",
634
+ "version": metadata.extra.get("version", "1.0.0") if metadata and metadata.extra else "1.0.0",
635
+ "type": plugin_type,
636
+ "module": module_name,
637
+ "homepage": metadata.homepage if metadata else None
638
+ })
639
+ return plugins
592
640
 
593
- # --- 插件商店相关 API ---
641
+ @app.post("/web_console/api/system/action", dependencies=[Depends(check_auth)])
642
+ async def system_action(request: Request):
643
+ data = await request.json()
644
+ action = data.get("action")
645
+ confirm = data.get("confirm")
646
+
647
+ if action not in ["reboot", "shutdown"]:
648
+ return {"error": "无效操作"}
649
+
650
+ if not confirm:
651
+ return {"error": "请确认操作", "need_confirm": True}
652
+
653
+ import os
654
+ import sys
655
+ import subprocess
656
+ import asyncio
657
+
658
+ logger.warning(f"收到系统指令: {action}")
659
+
660
+ if action == "shutdown":
661
+ # 延迟执行关闭,确保响应能发出去
662
+ loop = asyncio.get_event_loop()
663
+ loop.call_later(1.0, lambda: os._exit(0))
664
+ return {"msg": "Bot 正在关闭..."}
665
+
666
+ elif action == "reboot":
667
+ # 获取项目根目录 (通常是当前工作目录)
668
+ root_dir = Path.cwd()
669
+ bot_py = root_dir / "bot.py"
670
+
671
+ if bot_py.exists():
672
+ cmd = [sys.executable, str(bot_py)]
673
+ else:
674
+ cmd = [sys.executable] + sys.argv
675
+
676
+ def do_reboot():
677
+ try:
678
+ if sys.platform == "win32":
679
+ subprocess.Popen(cmd, cwd=str(root_dir))
680
+ os._exit(0)
681
+ else:
682
+ os.chdir(root_dir)
683
+ os.execv(sys.executable, cmd)
684
+ except Exception as e:
685
+ logger.error(f"重启执行失败: {e}")
686
+ os._exit(1)
594
687
 
595
- STORE_URL = "https://registry.nonebot.dev/plugins.json"
596
- store_cache = {"data": [], "time": 0}
688
+ # 延迟执行重启
689
+ loop = asyncio.get_event_loop()
690
+ loop.call_later(1.0, do_reboot)
691
+ return {"msg": "Bot 正在重启..."}
597
692
 
598
- @app.get("/web_console/api/store", dependencies=[Depends(check_auth)])
599
- async def get_store():
600
- # 缓存 1 小时
601
- if not store_cache["data"] or time.time() - store_cache["time"] > 3600:
602
- try:
603
- async with httpx.AsyncClient(follow_redirects=True, verify=False) as client:
604
- resp = await client.get(STORE_URL, timeout=15.0)
605
- if resp.status_code == 200:
606
- store_cache["data"] = resp.json()
607
- store_cache["time"] = time.time()
608
- else:
609
- logger.error(f"获取 NoneBot 商店数据失败: HTTP {resp.status_code}")
610
- except Exception as e:
611
- logger.error(f"获取 NoneBot 商店数据失败: {e}")
612
- # 如果之前有缓存,即使失败也返回旧缓存,避免页面空白
613
- if store_cache["data"]:
614
- return store_cache["data"]
615
- return {"error": "无法连接到 NoneBot 商店,请检查服务器网络或稍后再试"}
693
+ @app.get("/web_console/api/plugins/{plugin_id}/config", dependencies=[Depends(check_auth)])
694
+ async def get_plugin_config(plugin_id: str):
695
+ from nonebot import get_loaded_plugins, get_driver
696
+
697
+ # 查找插件
698
+ target_plugin = None
699
+ for p in get_loaded_plugins():
700
+ if p.name == plugin_id:
701
+ target_plugin = p
702
+ break
703
+
704
+ if not target_plugin:
705
+ raise HTTPException(status_code=404, detail="Plugin not found")
616
706
 
617
- return store_cache["data"]
618
-
619
- @app.post("/web_console/api/store/action", dependencies=[Depends(check_auth)])
620
- async def store_action(request: Request):
621
- data = await request.json()
622
- action = data.get("action") # install, update, uninstall
623
- plugin_name = data.get("plugin")
624
-
625
- if not action or not plugin_name:
626
- return {"error": "参数错误"}
707
+ # 获取配置元数据 (NoneBot 插件通常通过 metadata.config 导出 Config 类)
708
+ config_schema = {}
709
+ current_config = {}
627
710
 
628
- # 执行命令
629
- import asyncio
630
- import sys
631
-
632
- # 构建命令
633
- cmd = []
634
- # 尝试定位 nb 命令
635
- import shutil
636
- nb_path = shutil.which("nb")
637
-
638
- if not nb_path:
639
- # 如果系统 PATH 中找不到,再尝试在 Python 脚本目录下找
640
- script_dir = os.path.dirname(sys.executable)
641
- possible_nb = os.path.join(script_dir, "nb.exe" if sys.platform == "win32" else "nb")
642
- if os.path.exists(possible_nb):
643
- nb_path = possible_nb
644
- else:
645
- nb_path = "nb" # 最后的保底,尝试直接运行 nb
711
+ if target_plugin.metadata and target_plugin.metadata.config:
712
+ try:
713
+ config_class = target_plugin.metadata.config
714
+ if hasattr(config_class, "schema"):
715
+ schema = config_class.schema()
716
+ config_schema = schema.get("properties", {})
717
+ # 注入当前值
718
+ driver_config = get_driver().config
719
+ for key in config_schema:
720
+ current_config[key] = getattr(driver_config, key, None)
721
+ except Exception as e:
722
+ logger.error(f"解析插件 {plugin_id} 配置失败: {e}")
723
+
724
+ return {"config": current_config, "schema": config_schema}
646
725
 
647
- # 获取项目根目录 (bot.py 所在目录)
648
- root_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
726
+ # --- 插件商店相关 API ---
649
727
 
650
- if action == "install":
651
- cmd = [nb_path, "plugin", "install", plugin_name]
652
- elif action == "update":
653
- cmd = [nb_path, "plugin", "update", plugin_name]
654
- elif action == "uninstall":
655
- cmd = [nb_path, "plugin", "uninstall", "-y", plugin_name]
656
- else:
657
- return {"error": "无效操作"}
728
+ STORE_URL = "https://registry.nonebot.dev/plugins.json"
729
+ store_cache = {"data": [], "time": 0}
730
+
731
+ @app.get("/web_console/api/store", dependencies=[Depends(check_auth)])
732
+ async def get_store():
733
+ # 缓存 1 小时
734
+ if not store_cache["data"] or time.time() - store_cache["time"] > 3600:
735
+ try:
736
+ async with httpx.AsyncClient(follow_redirects=True, verify=False) as client:
737
+ resp = await client.get(STORE_URL, timeout=15.0)
738
+ if resp.status_code == 200:
739
+ store_cache["data"] = resp.json()
740
+ store_cache["time"] = time.time()
741
+ else:
742
+ logger.error(f"获取 NoneBot 商店数据失败: HTTP {resp.status_code}")
743
+ except Exception as e:
744
+ logger.error(f"获取 NoneBot 商店数据失败: {e}")
745
+ # 如果之前有缓存,即使失败也返回旧缓存,避免页面空白
746
+ if store_cache["data"]:
747
+ return store_cache["data"]
748
+ return {"error": "无法连接到 NoneBot 商店,请检查服务器网络或稍后再试"}
749
+
750
+ return store_cache["data"]
751
+
752
+ @app.post("/web_console/api/store/action", dependencies=[Depends(check_auth)])
753
+ async def store_action(request: Request):
754
+ data = await request.json()
755
+ action = data.get("action") # install, update, uninstall
756
+ plugin_name = data.get("plugin")
658
757
 
659
- logger.info(f"开始执行插件操作: {' '.join(cmd)} (工作目录: {root_dir})")
660
-
661
- try:
662
- process = await asyncio.create_subprocess_exec(
663
- *cmd,
664
- stdout=asyncio.subprocess.PIPE,
665
- stderr=asyncio.subprocess.PIPE,
666
- cwd=root_dir
667
- )
758
+ if not action or not plugin_name:
759
+ return {"error": "参数错误"}
760
+
761
+ if not re.match(r'^[a-zA-Z0-9_-]+$', plugin_name):
762
+ return {"error": "非法插件名称"}
763
+
764
+ # 执行命令
765
+ import asyncio
766
+ import sys
668
767
 
669
- stdout_bytes, stderr_bytes = await process.communicate()
768
+ # 构建命令
769
+ cmd = []
770
+ # 尝试定位 nb 命令
771
+ import shutil
772
+ nb_path = shutil.which("nb")
670
773
 
671
- def safe_decode(data: bytes) -> str:
672
- if not data:
673
- return ""
674
- for encoding in ["utf-8", "gbk", "cp936"]:
675
- try:
676
- return data.decode(encoding).strip()
677
- except UnicodeDecodeError:
678
- continue
679
- return data.decode("utf-8", errors="replace").strip()
774
+ if not nb_path:
775
+ # 如果系统 PATH 中找不到,再尝试在 Python 脚本目录下找
776
+ script_dir = os.path.dirname(sys.executable)
777
+ possible_nb = os.path.join(script_dir, "nb.exe" if sys.platform == "win32" else "nb")
778
+ if os.path.exists(possible_nb):
779
+ nb_path = possible_nb
780
+ else:
781
+ nb_path = "nb" # 最后的保底,尝试直接运行 nb
680
782
 
681
- stdout = safe_decode(stdout_bytes)
682
- stderr = safe_decode(stderr_bytes)
683
-
684
- if process.returncode == 0:
685
- msg = f"插件 {plugin_name} {action} 成功"
686
- logger.info(msg)
687
- return {"msg": msg, "output": stdout}
783
+ # 获取项目根目录 (通常是当前工作目录)
784
+ root_dir = Path.cwd()
785
+
786
+ if action == "install":
787
+ cmd = [nb_path, "plugin", "install", plugin_name]
788
+ elif action == "update":
789
+ cmd = [nb_path, "plugin", "update", plugin_name]
790
+ elif action == "uninstall":
791
+ cmd = [nb_path, "plugin", "uninstall", plugin_name]
688
792
  else:
689
- error_msg = stderr or stdout
690
- logger.error(f"插件操作失败: {error_msg}")
691
- return {"error": error_msg}
692
-
693
- except Exception as e:
694
- logger.error(f"执行插件命令时发生异常: {e}")
695
- return {"error": str(e)}
793
+ return {"error": "无效操作"}
696
794
 
697
- except Exception as e:
698
- logger.error(f"执行命令异常: {e}")
699
- return {"error": str(e)}
700
-
701
- @app.post("/web_console/api/plugins/{plugin_id}/config", dependencies=[Depends(check_auth)])
702
- async def update_plugin_config(plugin_id: str, new_config: dict):
703
- # 这里仅做模拟保存逻辑,实际应用中通常需要修改 .env 文件或数据库
704
- # 由于直接修改运行中的 config 风险较大,此处返回成功,并提示需要重启
705
- logger.info(f"收到插件 {plugin_id} 的新配置: {new_config}")
706
- return {"success": True}
707
-
708
- # API 路由
709
- @app.get("/web_console/api/chats", dependencies=[Depends(check_auth)])
710
- async def get_chats():
711
- try:
712
- bot = get_bot()
713
- if not isinstance(bot, Bot):
714
- return {"error": "Only OneBot v11 is supported"}
795
+ logger.info(f"开始执行插件操作: {' '.join(cmd)} (工作目录: {root_dir})")
715
796
 
716
- groups = await bot.get_group_list()
717
- friends = await bot.get_friend_list()
797
+ try:
798
+ process = await asyncio.create_subprocess_exec(
799
+ *cmd,
800
+ stdout=asyncio.subprocess.PIPE,
801
+ stderr=asyncio.subprocess.PIPE,
802
+ cwd=str(root_dir)
803
+ )
804
+
805
+ stdout_bytes, stderr_bytes = await process.communicate()
806
+
807
+ def safe_decode(data: bytes) -> str:
808
+ if not data:
809
+ return ""
810
+ for encoding in ["utf-8", "gbk", "cp936"]:
811
+ try:
812
+ return data.decode(encoding).strip()
813
+ except UnicodeDecodeError:
814
+ continue
815
+ return data.decode("utf-8", errors="replace").strip()
816
+
817
+ stdout = safe_decode(stdout_bytes)
818
+ stderr = safe_decode(stderr_bytes)
819
+
820
+ if process.returncode == 0:
821
+ msg = f"插件 {plugin_name} {action} 成功"
822
+ logger.info(msg)
823
+ return {"msg": msg, "output": stdout}
824
+ else:
825
+ error_msg = stderr or stdout
826
+ logger.error(f"插件操作失败: {error_msg}")
827
+ return {"error": error_msg}
828
+
829
+ except Exception as e:
830
+ logger.error(f"执行插件命令时发生异常: {e}")
831
+ return {"error": str(e)}
832
+
833
+ @app.post("/web_console/api/plugins/{plugin_id}/config", dependencies=[Depends(check_auth)])
834
+ async def update_plugin_config(plugin_id: str, new_config: dict):
835
+ # 尝试更新 .env 文件
836
+ env_path = Path.cwd() / ".env"
837
+ # 简单查找逻辑
838
+ if not env_path.exists():
839
+ for name in [".env.prod", ".env.dev"]:
840
+ p = Path.cwd() / name
841
+ if p.exists():
842
+ env_path = p
843
+ break
718
844
 
719
- return {
720
- "groups": [
721
- {
722
- "id": f"group_{g['group_id']}",
723
- "name": g['group_name'],
724
- "avatar": f"https://p.qlogo.cn/gh/{g['group_id']}/{g['group_id']}/640"
725
- } for g in groups
726
- ],
727
- "private": [
728
- {
729
- "id": f"private_{f['user_id']}",
730
- "name": f['nickname'] or f['remark'] or str(f['user_id']),
731
- "avatar": f"https://q1.qlogo.cn/g?b=qq&nk={f['user_id']}&s=640"
732
- } for f in friends
733
- ]
734
- }
735
- except Exception as e:
736
- return {"error": str(e)}
845
+ try:
846
+ if env_path.exists():
847
+ content = env_path.read_text(encoding="utf-8")
848
+ lines = content.splitlines()
849
+ new_lines = []
850
+ keys_updated = set()
851
+
852
+ for line in lines:
853
+ line_strip = line.strip()
854
+ if not line_strip or line_strip.startswith("#"):
855
+ new_lines.append(line)
856
+ continue
857
+
858
+ if "=" in line:
859
+ key = line.split("=", 1)[0].strip()
860
+ if key in new_config:
861
+ val = new_config[key]
862
+ if isinstance(val, bool):
863
+ val_str = str(val).lower()
864
+ else:
865
+ val_str = str(val)
866
+ new_lines.append(f"{key}={val_str}")
867
+ keys_updated.add(key)
868
+ else:
869
+ new_lines.append(line)
870
+ else:
871
+ new_lines.append(line)
872
+
873
+ # 追加新配置
874
+ for key, val in new_config.items():
875
+ if key not in keys_updated:
876
+ if isinstance(val, bool):
877
+ val_str = str(val).lower()
878
+ else:
879
+ val_str = str(val)
880
+ new_lines.append(f"{key}={val_str}")
881
+
882
+ env_path.write_text("\n".join(new_lines), encoding="utf-8")
883
+ logger.info(f"已更新配置文件 {env_path}")
884
+ else:
885
+ logger.warning("未找到 .env 文件,无法持久化配置")
886
+
887
+ except Exception as e:
888
+ logger.error(f"保存配置失败: {e}")
889
+ return {"error": str(e)}
737
890
 
738
- @app.get("/web_console/api/history/{chat_id}", dependencies=[Depends(check_auth)])
739
- async def get_history(chat_id: str):
740
- return message_cache.get(chat_id, [])
891
+ logger.info(f"收到插件 {plugin_id} 的新配置: {new_config}")
892
+ return {"success": True, "msg": "配置已保存至 .env (需重启生效)"}
741
893
 
742
- @app.get("/web_console/proxy/image", dependencies=[Depends(check_auth)])
743
- async def proxy_image(url: str):
744
- url = unquote(url)
745
-
746
- # 处理 file:// 协议头 (Linux 下常见)
747
- if url.startswith("file://"):
748
- url = url.replace("file:///", "/").replace("file://", "")
749
- # Windows 下剥离开头的斜杠,例如 /C:/Users -> C:/Users
750
- if os.name == "nt" and url.startswith("/") and ":" in url:
751
- url = url.lstrip("/")
894
+ # API 路由
895
+ @app.get("/web_console/api/chats", dependencies=[Depends(check_auth)])
896
+ async def get_chats():
897
+ try:
898
+ from nonebot import get_bots
899
+ bots = get_bots()
900
+ if not bots:
901
+ return {"error": "No bot connected"}
902
+ bot = list(bots.values())[0]
903
+
904
+ if not isinstance(bot, Bot):
905
+ return {"error": "Only OneBot v11 is supported"}
752
906
 
753
- if url.startswith("http"):
754
- # 尝试从缓存获取
755
- if url in image_cache:
756
- return Response(content=image_cache[url]["content"], media_type=image_cache[url]["type"])
757
-
907
+ groups = await bot.get_group_list()
908
+ friends = await bot.get_friend_list()
909
+
910
+ return {
911
+ "groups": [
912
+ {
913
+ "id": f"group_{g['group_id']}",
914
+ "name": g['group_name'],
915
+ "avatar": f"https://p.qlogo.cn/gh/{g['group_id']}/{g['group_id']}/640"
916
+ } for g in groups
917
+ ],
918
+ "private": [
919
+ {
920
+ "id": f"private_{f['user_id']}",
921
+ "name": f['nickname'] or f['remark'] or str(f['user_id']),
922
+ "avatar": f"https://q1.qlogo.cn/g?b=qq&nk={f['user_id']}&s=640"
923
+ } for f in friends
924
+ ]
925
+ }
926
+ except Exception as e:
927
+ return {"error": str(e)}
928
+
929
+ @app.get("/web_console/api/history/{chat_id}", dependencies=[Depends(check_auth)])
930
+ async def get_history(chat_id: str):
931
+ # 优先返回缓存
932
+ if chat_id in message_cache and len(message_cache[chat_id]) > 0:
933
+ return message_cache[chat_id]
934
+
935
+ # 尝试从 Bot 获取历史消息 (OneBot v11 get_group_msg_history)
758
936
  try:
759
- async with httpx.AsyncClient() as client:
760
- resp = await client.get(url, timeout=10.0, follow_redirects=True)
761
- if resp.status_code == 200:
762
- content = resp.content
763
- media_type = resp.headers.get("content-type", "image/jpeg")
764
- # 写入缓存
765
- if len(image_cache) >= CACHE_SIZE:
766
- image_cache.pop(next(iter(image_cache)))
767
- image_cache[url] = {"content": content, "type": media_type}
768
- return Response(content=content, media_type=media_type)
937
+ from nonebot import get_bots
938
+ bots = get_bots()
939
+ if bots:
940
+ bot = list(bots.values())[0]
941
+ if chat_id.startswith("group_"):
942
+ group_id = int(chat_id.replace("group_", ""))
943
+ # 尝试调用 NapCat/Go-CQHTTP 的 get_group_msg_history
944
+ res = await bot.call_api("get_group_msg_history", group_id=group_id)
945
+ messages = res.get("messages", [])
946
+
947
+ parsed_msgs = []
948
+ for raw in messages:
949
+ # raw: {message_id, time, sender: {...}, message: [...], raw_message: ...}
950
+ sender = raw.get("sender", {})
951
+ sender_id = sender.get("user_id") or 0
952
+ is_self = str(sender_id) == str(bot.self_id)
953
+
954
+ parsed_msgs.append({
955
+ "id": raw.get("message_id"),
956
+ "chat_id": chat_id,
957
+ "time": raw.get("time"),
958
+ "type": "group",
959
+ "sender_id": sender_id,
960
+ "sender_name": sender.get("nickname") or sender.get("card") or str(sender_id),
961
+ "sender_avatar": f"https://q1.qlogo.cn/g?b=qq&nk={sender_id}&s=640",
962
+ "elements": parse_message_elements(raw.get("message", [])),
963
+ "content": raw.get("raw_message", ""),
964
+ "self_id": bot.self_id,
965
+ "is_self": is_self
966
+ })
967
+
968
+ if parsed_msgs:
969
+ message_cache[chat_id] = parsed_msgs[-CACHE_SIZE:]
970
+ return message_cache[chat_id]
769
971
  except Exception as e:
770
- logger.error(f"代理图片下载失败: {e}")
972
+ logger.warning(f"获取历史消息失败: {e}")
771
973
 
772
- # 尝试作为本地路径处理
773
- try:
774
- path = Path(url)
775
- if path.exists() and path.is_file():
776
- return FileResponse(str(path))
777
- except Exception as e:
778
- logger.error(f"本地图片读取失败: {e}")
779
-
780
- return Response(status_code=404)
974
+ return message_cache.get(chat_id, [])
781
975
 
782
- @app.post("/web_console/api/send", dependencies=[Depends(check_auth)])
783
- async def send_message(data: dict):
784
- try:
785
- bot = get_bot()
786
- chat_id = data.get("chat_id")
787
- content = data.get("content")
976
+ @app.get("/web_console/proxy/image", dependencies=[Depends(check_auth)])
977
+ async def proxy_image(url: str):
978
+ url = unquote(url)
788
979
 
789
- if not chat_id or not content:
790
- return {"error": "Invalid data"}
791
-
792
- if chat_id.startswith("group_"):
793
- group_id = int(chat_id.replace("group_", ""))
794
- await bot.send_group_msg(group_id=group_id, message=content)
795
- else:
796
- user_id = int(chat_id.replace("private_", ""))
797
- await bot.send_private_msg(user_id=user_id, message=content)
980
+ # 处理 file:// 协议头 (Linux 下常见)
981
+ if url.startswith("file://"):
982
+ url = url.replace("file:///", "/").replace("file://", "")
983
+ # 在 Windows 下剥离开头的斜杠,例如 /C:/Users -> C:/Users
984
+ if os.name == "nt" and url.startswith("/") and ":" in url:
985
+ url = url.lstrip("/")
986
+
987
+ if url.startswith("http"):
988
+ # 尝试从缓存获取
989
+ if url in image_cache:
990
+ return Response(content=image_cache[url]["content"], media_type=image_cache[url]["type"])
798
991
 
799
- # 发送成功后手动添加一条自己的消息到缓存并推送
800
- my_msg = {
801
- "id": 0,
802
- "time": int(time.time()),
803
- "type": "group" if chat_id.startswith("group_") else "private",
804
- "sender_id": bot.self_id,
805
- "sender_name": "我",
806
- "sender_avatar": f"https://q1.qlogo.cn/g?b=qq&nk={bot.self_id}&s=640",
807
- "elements": [{"type": "text", "data": content}],
808
- "content": content,
809
- "is_self": True
810
- }
811
-
812
- if chat_id not in message_cache:
813
- message_cache[chat_id] = []
814
- message_cache[chat_id].append(my_msg)
815
-
816
- await broadcast_message({
817
- "type": "new_message",
818
- "chat_id": chat_id,
819
- "data": my_msg
820
- })
821
-
822
- return {"status": "ok"}
823
- except Exception as e:
824
- return {"error": str(e)}
992
+ try:
993
+ async with httpx.AsyncClient() as client:
994
+ resp = await client.get(url, timeout=10.0, follow_redirects=True)
995
+ if resp.status_code == 200:
996
+ content = resp.content
997
+ media_type = resp.headers.get("content-type", "image/jpeg")
998
+ # 写入缓存
999
+ if len(image_cache) >= CACHE_SIZE:
1000
+ image_cache.pop(next(iter(image_cache)))
1001
+ image_cache[url] = {"content": content, "type": media_type}
1002
+ return Response(content=content, media_type=media_type)
1003
+ except Exception as e:
1004
+ logger.error(f"代理图片下载失败: {e}")
1005
+
1006
+ # 尝试作为本地路径处理
1007
+ try:
1008
+ path = Path(url).resolve()
1009
+ # 安全检查:只允许访问当前工作目录下的文件
1010
+ if not str(path).startswith(str(Path.cwd())):
1011
+ return Response(status_code=403)
1012
+
1013
+ if path.exists() and path.is_file():
1014
+ return FileResponse(str(path))
1015
+ except Exception as e:
1016
+ logger.error(f"本地图片读取失败: {e}")
1017
+
1018
+ return Response(status_code=404)
825
1019
 
826
- # WebSocket 端点
827
- @app.websocket("/web_console/ws")
828
- async def websocket_endpoint(websocket: WebSocket):
829
- await websocket.accept()
830
- active_connections.add(websocket)
831
- try:
832
- while True:
833
- # 保持连接,接收心跳或其他
834
- await websocket.receive_text()
835
- except WebSocketDisconnect:
836
- active_connections.remove(websocket)
837
- except Exception:
838
- if websocket in active_connections:
839
- active_connections.remove(websocket)
1020
+ @app.post("/web_console/api/send", dependencies=[Depends(check_auth)])
1021
+ async def send_message(data: dict):
1022
+ try:
1023
+ from nonebot import get_bots
1024
+ bots = get_bots()
1025
+ if not bots:
1026
+ return {"error": "No bot connected"}
1027
+ bot = list(bots.values())[0]
840
1028
 
841
- # 挂载静态文件
842
- app.mount("/web_console/static", StaticFiles(directory=str(static_path)), name="web_console_static")
1029
+ chat_id = data.get("chat_id")
1030
+ content = data.get("content")
1031
+
1032
+ if not chat_id or not content:
1033
+ return {"error": "Invalid data"}
1034
+
1035
+ if chat_id.startswith("group_"):
1036
+ group_id = int(chat_id.replace("group_", ""))
1037
+ await bot.send_group_msg(group_id=group_id, message=content)
1038
+ else:
1039
+ user_id = int(chat_id.replace("private_", ""))
1040
+ await bot.send_private_msg(user_id=user_id, message=content)
1041
+
1042
+ return {"status": "ok"}
1043
+ except Exception as e:
1044
+ return {"error": str(e)}
843
1045
 
844
- @app.get("/web_console")
845
- async def index():
846
- return FileResponse(static_path / "index.html")
1046
+ # WebSocket 端点
1047
+ @app.websocket("/web_console/ws")
1048
+ async def websocket_endpoint(websocket: WebSocket):
1049
+ token = websocket.query_params.get("token")
1050
+ if not token or not auth_manager.verify_token(token):
1051
+ await websocket.close(code=1008)
1052
+ return
1053
+
1054
+ await websocket.accept()
1055
+ active_connections.add(websocket)
1056
+ try:
1057
+ while True:
1058
+ # 保持连接,接收心跳或其他
1059
+ await websocket.receive_text()
1060
+ except WebSocketDisconnect:
1061
+ active_connections.remove(websocket)
1062
+ except Exception:
1063
+ if websocket in active_connections:
1064
+ active_connections.remove(websocket)