nonebot-plugin-shiro-web-console 0.1.0__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.

@@ -0,0 +1,835 @@
1
+ import asyncio
2
+ import json
3
+ import httpx
4
+ import os
5
+ import random
6
+ import time
7
+ from datetime import datetime, timedelta
8
+ from urllib.parse import quote, unquote
9
+ from typing import Dict, List, Set, Optional, Any
10
+ from pathlib import Path
11
+ from collections import deque
12
+
13
+ from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request, Response, Depends, HTTPException
14
+ from fastapi.staticfiles import StaticFiles
15
+ from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
16
+ from nonebot import get_app, get_bot, get_driver, logger, on_message, on_command
17
+ from nonebot.permission import SUPERUSER
18
+ from nonebot.adapters.onebot.v11 import Bot, MessageEvent, GroupMessageEvent, PrivateMessageEvent, MessageSegment
19
+ from nonebot.plugin import PluginMetadata
20
+
21
+ __plugin_meta__ = PluginMetadata(
22
+ name="Shiro Web Console",
23
+ description="通过浏览器查看日志、管理机器人并发送消息",
24
+ usage="访问 /web_console 查看,在机器人聊天框发送“web控制台”获取登录码",
25
+ type="tool",
26
+ homepage="https://github.com/luojisama/nonebot-plugin-shiro-web-console",
27
+ supported_adapters={"~onebot.v11"},
28
+ extra={
29
+ "author": "luojisama",
30
+ "version": "0.1.0",
31
+ "pypi_test": "nonebot-plugin-shiro-web-console",
32
+ },
33
+ )
34
+
35
+ # 日志缓冲区,保留最近 200 条日志
36
+ log_buffer = deque(maxlen=200)
37
+
38
+ def log_sink(message):
39
+ log_buffer.append({
40
+ "time": datetime.now().strftime("%H:%M:%S"),
41
+ "level": message.record["level"].name,
42
+ "message": message.record["message"],
43
+ "module": message.record["module"]
44
+ })
45
+
46
+ # 注册 loguru sink
47
+ logger.add(log_sink, format="{time} {level} {message}", level="INFO")
48
+
49
+ # 验证码管理
50
+ class AuthManager:
51
+ def __init__(self):
52
+ self.code: Optional[str] = None
53
+ self.expire_time: Optional[datetime] = None
54
+ self.token: Optional[str] = None
55
+ self.token_expire: Optional[datetime] = None
56
+
57
+ # 密码持久化文件路径
58
+ self.data_dir = Path(__file__).parent / "data"
59
+ self.data_dir.mkdir(exist_ok=True)
60
+ self.password_file = self.data_dir / "password.json"
61
+
62
+ # 初始加载密码
63
+ self.admin_password = self._load_password()
64
+
65
+ def _load_password(self) -> str:
66
+ if self.password_file.exists():
67
+ try:
68
+ data = json.loads(self.password_file.read_text(encoding="utf-8"))
69
+ return data.get("password", "admin123")
70
+ except:
71
+ pass
72
+ return getattr(get_driver().config, "web_console_password", "admin123")
73
+
74
+ def save_password(self, new_password: str):
75
+ self.admin_password = new_password
76
+ self.password_file.write_text(json.dumps({"password": new_password}), encoding="utf-8")
77
+
78
+ def generate_code(self) -> str:
79
+ self.code = "".join([str(random.randint(0, 9)) for _ in range(6)])
80
+ self.expire_time = datetime.now() + timedelta(minutes=5)
81
+ return self.code
82
+
83
+ def verify_code(self, code: str) -> bool:
84
+ if not self.code or not self.expire_time:
85
+ return False
86
+ if datetime.now() > self.expire_time:
87
+ self.code = None
88
+ return False
89
+ if self.code == code:
90
+ self.code = None # 验证码一次性
91
+ self.generate_token()
92
+ return True
93
+ return False
94
+
95
+ def verify_password(self, password: str) -> bool:
96
+ if password == self.admin_password:
97
+ self.generate_token()
98
+ return True
99
+ return False
100
+
101
+ def generate_token(self):
102
+ self.token = "".join([random.choice("abcdefghijklmnopqrstuvwxyz0123456789") for _ in range(32)])
103
+ self.token_expire = datetime.now() + timedelta(days=7)
104
+
105
+ def verify_token(self, token: str) -> bool:
106
+ if not self.token or not self.token_expire:
107
+ return False
108
+ if datetime.now() > self.token_expire:
109
+ return False
110
+ return self.token == token
111
+
112
+ auth_manager = AuthManager()
113
+
114
+ # 获取管理员列表
115
+ driver = get_driver()
116
+ superusers = driver.config.superusers
117
+
118
+ async def check_auth(request: Request):
119
+ # 优先从 Header 获取,其次从 Query Params 获取(用于 <img> 标签)
120
+ token = request.headers.get("Authorization") or request.query_params.get("token")
121
+ if not token or not auth_manager.verify_token(token):
122
+ raise HTTPException(status_code=401, detail="Unauthorized")
123
+ return True
124
+
125
+ app:FastAPI = get_app()
126
+ static_path = Path(__file__).parent / "static"
127
+ index_html = static_path / "index.html"
128
+
129
+ # 挂载静态文件
130
+ if static_path.exists():
131
+ app.mount("/web_console/static", StaticFiles(directory=str(static_path)), name="web_console_static")
132
+
133
+ # Web 控制台入口路由
134
+ @app.get("/web_console", response_class=HTMLResponse)
135
+ async def serve_console():
136
+ if not index_html.exists():
137
+ return HTMLResponse("<h1>index.html not found</h1>", status_code=404)
138
+ return HTMLResponse(content=index_html.read_text(encoding="utf-8"), status_code=200)
139
+
140
+ # 兼容 /web_console/ 路径
141
+ @app.get("/web_console/", response_class=HTMLResponse)
142
+ async def serve_console_slash():
143
+ return await serve_console()
144
+
145
+ # 消息缓存 {chat_id: [messages]}
146
+ message_cache: Dict[str, List[dict]] = {}
147
+ # 图片缓存 {url: {"content": bytes, "type": str}}
148
+ image_cache: Dict[str, dict] = {}
149
+ CACHE_SIZE = 100
150
+
151
+ # WebSocket 连接池
152
+ active_connections: Set[WebSocket] = set()
153
+
154
+ # 基础人设
155
+ def get_chat_id(event: MessageEvent) -> str:
156
+ if isinstance(event, GroupMessageEvent):
157
+ return f"group_{event.group_id}"
158
+ return f"private_{event.user_id}"
159
+
160
+ # 命令:获取控制台登录码
161
+ login_cmd = on_command("web控制台", aliases={"console", "控制台"}, permission=SUPERUSER, priority=1, block=True)
162
+ password_cmd = on_command("web密码", aliases={"修改web密码"}, permission=SUPERUSER, priority=1, block=True)
163
+
164
+ @password_cmd.handle()
165
+ async def handle_password_cmd(bot: Bot, event: MessageEvent):
166
+ new_password = event.get_plaintext().strip().replace("web密码", "").replace("修改web密码", "").strip()
167
+ if not new_password:
168
+ await password_cmd.finish("请在命令后输入新密码,例如:web密码 mynewpassword")
169
+
170
+ auth_manager.save_password(new_password)
171
+ await password_cmd.finish(f"Web控制台密码已修改为: {new_password}\n请妥善保存。")
172
+
173
+ @login_cmd.handle()
174
+ async def handle_login_cmd(bot: Bot, event: MessageEvent):
175
+ # 搜集所有可能的 IP
176
+ ips = []
177
+
178
+ # 1. 获取公网 IP
179
+ try:
180
+ async with httpx.AsyncClient() as client:
181
+ # 尝试多个服务以提高可靠性
182
+ for service in ["https://api.ipify.org", "https://ifconfig.me/ip", "https://icanhazip.com"]:
183
+ try:
184
+ resp = await client.get(service, timeout=3.0)
185
+ if resp.status_code == 200:
186
+ ip = resp.text.strip()
187
+ if ip and ip not in ips:
188
+ ips.append(ip)
189
+ break
190
+ except:
191
+ continue
192
+ except:
193
+ pass
194
+
195
+ # 2. 获取内网 IP (通用方法)
196
+ import socket
197
+ try:
198
+ # 获取所有网卡信息
199
+ interfaces = socket.getaddrinfo(socket.gethostname(), None)
200
+ for iface in interfaces:
201
+ if iface[0] == socket.AF_INET: # IPv4
202
+ ip = iface[4][0]
203
+ if ip not in ips and not ip.startswith("127."):
204
+ ips.append(ip)
205
+ except:
206
+ pass
207
+
208
+ # 3. 备选内网 IP 获取 (UDP 技巧)
209
+ try:
210
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
211
+ s.connect(("8.8.8.8", 80))
212
+ main_ip = s.getsockname()[0]
213
+ if main_ip not in ips:
214
+ ips.append(main_ip)
215
+ s.close()
216
+ except:
217
+ pass
218
+
219
+ # 4. 获取所有网卡 IP (备选方法)
220
+ try:
221
+ hostname = socket.gethostname()
222
+ for info in socket.getaddrinfo(hostname, None):
223
+ if info[0] == socket.AF_INET: # IPv4
224
+ ip = info[4][0]
225
+ if ip not in ips and not ip.startswith("127."):
226
+ ips.append(ip)
227
+ except:
228
+ pass
229
+
230
+ # 5. 始终添加 127.0.0.1 (本地回环)
231
+ if "127.0.0.1" not in ips:
232
+ ips.append("127.0.0.1")
233
+
234
+ port = get_driver().config.port
235
+
236
+ # 按照分类构造消息
237
+ public_ips = [ip for ip in ips if ip != "127.0.0.1"]
238
+
239
+ msg_parts = ["【Web控制台】"]
240
+
241
+ if public_ips:
242
+ msg_parts.append("访问地址:")
243
+ for i, ip in enumerate(public_ips):
244
+ msg_parts.append(f" - http://{ip}:{port}/web_console")
245
+
246
+ msg_parts.append(f"本地地址:http://127.0.0.1:{port}/web_console")
247
+
248
+ code = auth_manager.generate_code()
249
+ msg_parts.append(f"您的登录验证码为:{code}")
250
+ msg_parts.append("5分钟内有效。")
251
+
252
+ msg = "\n".join(msg_parts)
253
+
254
+ if isinstance(event, PrivateMessageEvent):
255
+ await login_cmd.finish(msg)
256
+ else:
257
+ try:
258
+ await bot.send_private_msg(user_id=event.user_id, message=msg)
259
+ await login_cmd.finish("访问地址与验证码已通过私聊发送给您,请查收。")
260
+ except Exception as e:
261
+ logger.error(f"发送私聊验证码失败: {e}")
262
+ first_url = f"http://{public_ips[0]}:{port}/web_console" if public_ips else f"http://127.0.0.1:{port}/web_console"
263
+ await login_cmd.finish(f"私聊发送失败,请确保您已添加机器人为好友。\n(当前环境访问地址提示:{first_url})")
264
+
265
+ # 监听所有消息
266
+ msg_matcher = on_message(priority=1, block=False)
267
+
268
+ @msg_matcher.handle()
269
+ async def handle_all_messages(bot: Bot, event: MessageEvent):
270
+ chat_id = get_chat_id(event)
271
+
272
+ # 尝试通过 get_msg 获取更详细的消息内容(尤其是 NapCat 等框架提供的 URL)
273
+ sender_name = event.sender.nickname or str(event.user_id)
274
+ try:
275
+ msg_details = await bot.get_msg(message_id=event.message_id)
276
+ message = msg_details["message"]
277
+ # 如果 get_msg 返回了 sender 信息,则优先使用
278
+ if "sender" in msg_details:
279
+ sender_name = msg_details["sender"].get("nickname") or msg_details["sender"].get("card") or sender_name
280
+ except Exception as e:
281
+ logger.warning(f"获取消息详情失败: {e},将使用事件自带消息内容")
282
+ message = event.get_message()
283
+
284
+ # 消息内容解析
285
+ elements = []
286
+ # 消息唯一 ID:使用适配器提供的原始 ID,以便前端去重
287
+ msg_id = str(event.message_id)
288
+
289
+ # 如果 message 是列表(get_msg 返回格式),直接遍历;如果是 Message 对象,也可以遍历
290
+ for seg in message:
291
+ # 处理 get_msg 返回的字典格式或 MessageSegment 对象
292
+ seg_type = seg["type"] if isinstance(seg, dict) else seg.type
293
+ seg_data = seg["data"] if isinstance(seg, dict) else seg.data
294
+
295
+ if seg_type == "text":
296
+ elements.append({"type": "text", "data": seg_data.get("text", "")})
297
+ elif seg_type == "image":
298
+ # 记录图片数据以便排查
299
+ logger.debug(f"解析到图片数据: {seg_data}")
300
+ # 优先从 get_msg 的数据中获取 url,NapCat 在 Linux 下可能返回 path 或 file 字段
301
+ raw_url = seg_data.get("url") or seg_data.get("file") or seg_data.get("path") or ""
302
+
303
+ # 如果是本地文件路径或非 http 开头的,构造代理 URL
304
+ if raw_url and not raw_url.startswith("http"):
305
+ # 这种情况下可能是 base64 或者本地路径,或者是 CQ 码中的 file 字段
306
+ # 我们先尝试直接传给前端,由前端处理或后续通过代理获取
307
+ pass
308
+
309
+ # 为了确保显示,所有图片都尝试通过代理中转,除非是 base64
310
+ if raw_url.startswith("data:image"):
311
+ final_url = raw_url
312
+ else:
313
+ # 代理链接不带 token,由前端动态注入或 check_auth 处理
314
+ final_url = f"/web_console/proxy/image?url={quote(raw_url)}" if raw_url else ""
315
+
316
+ elements.append({"type": "image", "data": final_url, "raw": raw_url})
317
+ elif seg_type == "face":
318
+ face_id = seg_data.get("id")
319
+ face_url = f"https://s.p.qq.com/pub/get_face?img_type=3&face_id={face_id}"
320
+ elements.append({"type": "face", "data": face_url, "id": face_id})
321
+ elif seg_type == "mface":
322
+ # 商城表情(Stickers)
323
+ url = seg_data.get("url")
324
+ elements.append({"type": "image", "data": url})
325
+ elif seg_type == "at":
326
+ elements.append({"type": "at", "data": seg_data.get("qq")})
327
+ elif seg_type == "reply":
328
+ elements.append({"type": "reply", "data": seg_data.get("id")})
329
+
330
+ msg_data = {
331
+ "id": event.message_id,
332
+ "chat_id": chat_id,
333
+ "time": event.time,
334
+ "type": "group" if isinstance(event, GroupMessageEvent) else "private",
335
+ "sender_id": event.user_id,
336
+ "sender_name": sender_name,
337
+ "sender_avatar": f"https://q1.qlogo.cn/g?b=qq&nk={event.user_id}&s=640",
338
+ "elements": elements,
339
+ "content": event.get_plaintext(),
340
+ "self_id": bot.self_id,
341
+ "is_self": False
342
+ }
343
+
344
+ # 存入缓存
345
+ if chat_id not in message_cache:
346
+ message_cache[chat_id] = []
347
+ message_cache[chat_id].append(msg_data)
348
+ if len(message_cache[chat_id]) > CACHE_SIZE:
349
+ message_cache[chat_id].pop(0)
350
+
351
+ # 通过 WebSocket 推送
352
+ await broadcast_message({
353
+ "type": "new_message",
354
+ "chat_id": chat_id,
355
+ "data": msg_data
356
+ })
357
+
358
+ async def broadcast_message(data: dict):
359
+ if not active_connections:
360
+ return
361
+
362
+ dead_connections = set()
363
+ for ws in active_connections:
364
+ try:
365
+ await ws.send_json(data)
366
+ except Exception:
367
+ dead_connections.add(ws)
368
+
369
+ for ws in dead_connections:
370
+ active_connections.remove(ws)
371
+
372
+ # 认证 API
373
+ @app.post("/web_console/api/send_code")
374
+ async def send_code():
375
+ if not superusers:
376
+ return {"error": "未设置 SUPERUSERS 管理员列表"}
377
+
378
+ code = auth_manager.generate_code()
379
+ bot = get_bot()
380
+
381
+ success_count = 0
382
+ for user_id in superusers:
383
+ try:
384
+ await bot.send_private_msg(user_id=int(user_id), message=f"【Web控制台】您的登录验证码为:{code},5分钟内有效。")
385
+ success_count += 1
386
+ except Exception as e:
387
+ logger.error(f"发送验证码给管理员 {user_id} 失败: {e}")
388
+
389
+ if success_count > 0:
390
+ return {"msg": "验证码已发送至管理员 QQ"}
391
+ return {"error": "验证码发送失败,请检查机器人是否在线或管理员账号是否正确"}
392
+
393
+ @app.post("/web_console/api/login")
394
+ async def login(data: dict):
395
+ code = data.get("code")
396
+ password = data.get("password")
397
+
398
+ if code:
399
+ if auth_manager.verify_code(code):
400
+ return {"token": auth_manager.token}
401
+ return {"error": "验证码错误或已过期", "code": 401}
402
+ elif password:
403
+ if auth_manager.verify_password(password):
404
+ return {"token": auth_manager.token}
405
+ return {"error": "密码错误", "code": 401}
406
+
407
+ return {"error": "请输入验证码或密码", "code": 400}
408
+
409
+ @app.get("/web_console/api/status", dependencies=[Depends(check_auth)])
410
+ async def get_system_status():
411
+ from nonebot import get_bots
412
+ import psutil
413
+ import platform
414
+ import time
415
+ import datetime
416
+
417
+ # 系统性能
418
+ cpu_percent = psutil.cpu_percent()
419
+ memory = psutil.virtual_memory()
420
+ disk = psutil.disk_usage('/')
421
+
422
+ # 网络流量
423
+ net_io = psutil.net_io_counters()
424
+
425
+ # 运行时间
426
+ boot_time = psutil.boot_time()
427
+ uptime = time.time() - boot_time
428
+ uptime_str = str(datetime.timedelta(seconds=int(uptime)))
429
+
430
+ # 机器人信息
431
+ bots_info = []
432
+ for bot_id, bot in get_bots().items():
433
+ try:
434
+ profile = await bot.get_login_info()
435
+ bots_info.append({
436
+ "id": bot_id,
437
+ "nickname": profile.get("nickname", "未知"),
438
+ "avatar": f"https://q.qlogo.cn/headimg_dl?dst_uin={bot_id}&spec=640",
439
+ "status": "在线"
440
+ })
441
+ except:
442
+ bots_info.append({
443
+ "id": bot_id,
444
+ "nickname": "机器人",
445
+ "avatar": f"https://q.qlogo.cn/headimg_dl?dst_uin={bot_id}&spec=640",
446
+ "status": "离线"
447
+ })
448
+
449
+ return {
450
+ "system": {
451
+ "os": platform.system(),
452
+ "cpu": f"{cpu_percent}%",
453
+ "memory": f"{memory.percent}%",
454
+ "memory_used": f"{round(memory.used / 1024 / 1024 / 1024, 2)} GB",
455
+ "memory_total": f"{round(memory.total / 1024 / 1024 / 1024, 2)} GB",
456
+ "disk": f"{disk.percent}%",
457
+ "disk_used": f"{round(disk.used / 1024 / 1024 / 1024, 2)} GB",
458
+ "disk_total": f"{round(disk.total / 1024 / 1024 / 1024, 2)} GB",
459
+ "net_sent": f"{round(net_io.bytes_sent / 1024 / 1024, 2)} MB",
460
+ "net_recv": f"{round(net_io.bytes_recv / 1024 / 1024, 2)} MB",
461
+ "uptime": uptime_str,
462
+ "python": platform.python_version()
463
+ },
464
+ "bots": bots_info
465
+ }
466
+
467
+ @app.get("/web_console/api/logs", dependencies=[Depends(check_auth)])
468
+ async def get_logs():
469
+ return list(log_buffer)
470
+
471
+ @app.get("/web_console/api/plugins", dependencies=[Depends(check_auth)])
472
+ async def get_plugins():
473
+ from nonebot import get_loaded_plugins
474
+ import os
475
+ plugins = []
476
+ for p in get_loaded_plugins():
477
+ metadata = p.metadata
478
+
479
+ # 识别插件来源
480
+ plugin_type = "local"
481
+ module_name = p.module_name
482
+
483
+ if module_name.startswith("nonebot.plugins"):
484
+ plugin_type = "builtin"
485
+ elif metadata and metadata.homepage and ("github.com/nonebot" in metadata.homepage or "nonebot.dev" in metadata.homepage):
486
+ plugin_type = "official"
487
+ elif module_name.startswith("nonebot_plugin_"):
488
+ plugin_type = "store"
489
+
490
+ plugins.append({
491
+ "id": p.name,
492
+ "name": metadata.name if metadata else p.name,
493
+ "description": metadata.description if metadata else "暂无描述",
494
+ "version": metadata.extra.get("version", "1.0.0") if metadata and metadata.extra else "1.0.0",
495
+ "type": plugin_type,
496
+ "module": module_name,
497
+ "homepage": metadata.homepage if metadata else None
498
+ })
499
+ return plugins
500
+
501
+ @app.post("/web_console/api/system/action", dependencies=[Depends(check_auth)])
502
+ async def system_action(request: Request):
503
+ data = await request.json()
504
+ action = data.get("action")
505
+
506
+ if action not in ["reboot", "shutdown"]:
507
+ return {"error": "无效操作"}
508
+
509
+ import os
510
+ import sys
511
+ import subprocess
512
+ import asyncio
513
+
514
+ logger.warning(f"收到系统指令: {action}")
515
+
516
+ if action == "shutdown":
517
+ # 延迟执行关闭,确保响应能发出去
518
+ loop = asyncio.get_event_loop()
519
+ loop.call_later(1.0, lambda: os._exit(0))
520
+ return {"msg": "Bot 正在关闭..."}
521
+
522
+ elif action == "reboot":
523
+ # 获取项目根目录
524
+ root_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
525
+ bot_py = os.path.join(root_dir, "bot.py")
526
+
527
+ if os.path.exists(bot_py):
528
+ cmd = [sys.executable, bot_py]
529
+ else:
530
+ cmd = [sys.executable] + sys.argv
531
+
532
+ def do_reboot():
533
+ try:
534
+ if sys.platform == "win32":
535
+ subprocess.Popen(cmd, cwd=root_dir)
536
+ os._exit(0)
537
+ else:
538
+ os.chdir(root_dir)
539
+ os.execv(sys.executable, cmd)
540
+ except Exception as e:
541
+ logger.error(f"重启执行失败: {e}")
542
+ os._exit(1)
543
+
544
+ # 延迟执行重启
545
+ loop = asyncio.get_event_loop()
546
+ loop.call_later(1.0, do_reboot)
547
+ return {"msg": "Bot 正在重启..."}
548
+
549
+ @app.get("/web_console/api/plugins/{plugin_id}/config", dependencies=[Depends(check_auth)])
550
+ async def get_plugin_config(plugin_id: str):
551
+ from nonebot import get_loaded_plugins, get_driver
552
+
553
+ # 查找插件
554
+ target_plugin = None
555
+ for p in get_loaded_plugins():
556
+ if p.name == plugin_id:
557
+ target_plugin = p
558
+ break
559
+
560
+ if not target_plugin:
561
+ raise HTTPException(status_code=404, detail="Plugin not found")
562
+
563
+ # 获取配置元数据 (NoneBot 插件通常通过 metadata.config 导出 Config 类)
564
+ config_schema = {}
565
+ current_config = {}
566
+
567
+ if target_plugin.metadata and target_plugin.metadata.config:
568
+ try:
569
+ config_class = target_plugin.metadata.config
570
+ if hasattr(config_class, "schema"):
571
+ schema = config_class.schema()
572
+ config_schema = schema.get("properties", {})
573
+ # 注入当前值
574
+ driver_config = get_driver().config
575
+ for key in config_schema:
576
+ current_config[key] = getattr(driver_config, key, None)
577
+ except Exception as e:
578
+ logger.error(f"解析插件 {plugin_id} 配置失败: {e}")
579
+
580
+ return {"config": current_config, "schema": config_schema}
581
+
582
+ # --- 插件商店相关 API ---
583
+
584
+ STORE_URL = "https://registry.nonebot.dev/plugins.json"
585
+ store_cache = {"data": [], "time": 0}
586
+
587
+ @app.get("/web_console/api/store", dependencies=[Depends(check_auth)])
588
+ async def get_store():
589
+ # 缓存 1 小时
590
+ if not store_cache["data"] or time.time() - store_cache["time"] > 3600:
591
+ try:
592
+ async with httpx.AsyncClient(follow_redirects=True, verify=False) as client:
593
+ resp = await client.get(STORE_URL, timeout=15.0)
594
+ if resp.status_code == 200:
595
+ store_cache["data"] = resp.json()
596
+ store_cache["time"] = time.time()
597
+ else:
598
+ logger.error(f"获取 NoneBot 商店数据失败: HTTP {resp.status_code}")
599
+ except Exception as e:
600
+ logger.error(f"获取 NoneBot 商店数据失败: {e}")
601
+ # 如果之前有缓存,即使失败也返回旧缓存,避免页面空白
602
+ if store_cache["data"]:
603
+ return store_cache["data"]
604
+ return {"error": "无法连接到 NoneBot 商店,请检查服务器网络或稍后再试"}
605
+
606
+ return store_cache["data"]
607
+
608
+ @app.post("/web_console/api/store/action", dependencies=[Depends(check_auth)])
609
+ async def store_action(request: Request):
610
+ data = await request.json()
611
+ action = data.get("action") # install, update, uninstall
612
+ plugin_name = data.get("plugin")
613
+
614
+ if not action or not plugin_name:
615
+ return {"error": "参数错误"}
616
+
617
+ # 执行命令
618
+ import asyncio
619
+ import sys
620
+
621
+ # 构建命令
622
+ cmd = []
623
+ # 尝试定位 nb 命令
624
+ import shutil
625
+ nb_path = shutil.which("nb")
626
+
627
+ if not nb_path:
628
+ # 如果系统 PATH 中找不到,再尝试在 Python 脚本目录下找
629
+ script_dir = os.path.dirname(sys.executable)
630
+ possible_nb = os.path.join(script_dir, "nb.exe" if sys.platform == "win32" else "nb")
631
+ if os.path.exists(possible_nb):
632
+ nb_path = possible_nb
633
+ else:
634
+ nb_path = "nb" # 最后的保底,尝试直接运行 nb
635
+
636
+ # 获取项目根目录 (bot.py 所在目录)
637
+ root_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
638
+
639
+ if action == "install":
640
+ cmd = [nb_path, "plugin", "install", plugin_name]
641
+ elif action == "update":
642
+ cmd = [nb_path, "plugin", "update", plugin_name]
643
+ elif action == "uninstall":
644
+ cmd = [nb_path, "plugin", "uninstall", "-y", plugin_name]
645
+ else:
646
+ return {"error": "无效操作"}
647
+
648
+ logger.info(f"开始执行插件操作: {' '.join(cmd)} (工作目录: {root_dir})")
649
+
650
+ try:
651
+ process = await asyncio.create_subprocess_exec(
652
+ *cmd,
653
+ stdout=asyncio.subprocess.PIPE,
654
+ stderr=asyncio.subprocess.PIPE,
655
+ cwd=root_dir
656
+ )
657
+
658
+ stdout_bytes, stderr_bytes = await process.communicate()
659
+
660
+ def safe_decode(data: bytes) -> str:
661
+ if not data:
662
+ return ""
663
+ for encoding in ["utf-8", "gbk", "cp936"]:
664
+ try:
665
+ return data.decode(encoding).strip()
666
+ except UnicodeDecodeError:
667
+ continue
668
+ return data.decode("utf-8", errors="replace").strip()
669
+
670
+ stdout = safe_decode(stdout_bytes)
671
+ stderr = safe_decode(stderr_bytes)
672
+
673
+ if process.returncode == 0:
674
+ msg = f"插件 {plugin_name} {action} 成功"
675
+ logger.info(msg)
676
+ return {"msg": msg, "output": stdout}
677
+ else:
678
+ error_msg = stderr or stdout
679
+ logger.error(f"插件操作失败: {error_msg}")
680
+ return {"error": error_msg}
681
+
682
+ except Exception as e:
683
+ logger.error(f"执行插件命令时发生异常: {e}")
684
+ return {"error": str(e)}
685
+
686
+ except Exception as e:
687
+ logger.error(f"执行命令异常: {e}")
688
+ return {"error": str(e)}
689
+
690
+ @app.post("/web_console/api/plugins/{plugin_id}/config", dependencies=[Depends(check_auth)])
691
+ async def update_plugin_config(plugin_id: str, new_config: dict):
692
+ # 这里仅做模拟保存逻辑,实际应用中通常需要修改 .env 文件或数据库
693
+ # 由于直接修改运行中的 config 风险较大,此处返回成功,并提示需要重启
694
+ logger.info(f"收到插件 {plugin_id} 的新配置: {new_config}")
695
+ return {"success": True}
696
+
697
+ # API 路由
698
+ @app.get("/web_console/api/chats", dependencies=[Depends(check_auth)])
699
+ async def get_chats():
700
+ try:
701
+ bot = get_bot()
702
+ if not isinstance(bot, Bot):
703
+ return {"error": "Only OneBot v11 is supported"}
704
+
705
+ groups = await bot.get_group_list()
706
+ friends = await bot.get_friend_list()
707
+
708
+ return {
709
+ "groups": [
710
+ {
711
+ "id": f"group_{g['group_id']}",
712
+ "name": g['group_name'],
713
+ "avatar": f"https://p.qlogo.cn/gh/{g['group_id']}/{g['group_id']}/640"
714
+ } for g in groups
715
+ ],
716
+ "private": [
717
+ {
718
+ "id": f"private_{f['user_id']}",
719
+ "name": f['nickname'] or f['remark'] or str(f['user_id']),
720
+ "avatar": f"https://q1.qlogo.cn/g?b=qq&nk={f['user_id']}&s=640"
721
+ } for f in friends
722
+ ]
723
+ }
724
+ except Exception as e:
725
+ return {"error": str(e)}
726
+
727
+ @app.get("/web_console/api/history/{chat_id}", dependencies=[Depends(check_auth)])
728
+ async def get_history(chat_id: str):
729
+ return message_cache.get(chat_id, [])
730
+
731
+ @app.get("/web_console/proxy/image", dependencies=[Depends(check_auth)])
732
+ async def proxy_image(url: str):
733
+ url = unquote(url)
734
+
735
+ # 处理 file:// 协议头 (Linux 下常见)
736
+ if url.startswith("file://"):
737
+ url = url.replace("file:///", "/").replace("file://", "")
738
+ # 在 Windows 下剥离开头的斜杠,例如 /C:/Users -> C:/Users
739
+ if os.name == "nt" and url.startswith("/") and ":" in url:
740
+ url = url.lstrip("/")
741
+
742
+ if url.startswith("http"):
743
+ # 尝试从缓存获取
744
+ if url in image_cache:
745
+ return Response(content=image_cache[url]["content"], media_type=image_cache[url]["type"])
746
+
747
+ try:
748
+ async with httpx.AsyncClient() as client:
749
+ resp = await client.get(url, timeout=10.0, follow_redirects=True)
750
+ if resp.status_code == 200:
751
+ content = resp.content
752
+ media_type = resp.headers.get("content-type", "image/jpeg")
753
+ # 写入缓存
754
+ if len(image_cache) >= CACHE_SIZE:
755
+ image_cache.pop(next(iter(image_cache)))
756
+ image_cache[url] = {"content": content, "type": media_type}
757
+ return Response(content=content, media_type=media_type)
758
+ except Exception as e:
759
+ logger.error(f"代理图片下载失败: {e}")
760
+
761
+ # 尝试作为本地路径处理
762
+ try:
763
+ path = Path(url)
764
+ if path.exists() and path.is_file():
765
+ return FileResponse(str(path))
766
+ except Exception as e:
767
+ logger.error(f"本地图片读取失败: {e}")
768
+
769
+ return Response(status_code=404)
770
+
771
+ @app.post("/web_console/api/send", dependencies=[Depends(check_auth)])
772
+ async def send_message(data: dict):
773
+ try:
774
+ bot = get_bot()
775
+ chat_id = data.get("chat_id")
776
+ content = data.get("content")
777
+
778
+ if not chat_id or not content:
779
+ return {"error": "Invalid data"}
780
+
781
+ if chat_id.startswith("group_"):
782
+ group_id = int(chat_id.replace("group_", ""))
783
+ await bot.send_group_msg(group_id=group_id, message=content)
784
+ else:
785
+ user_id = int(chat_id.replace("private_", ""))
786
+ await bot.send_private_msg(user_id=user_id, message=content)
787
+
788
+ # 发送成功后手动添加一条自己的消息到缓存并推送
789
+ my_msg = {
790
+ "id": 0,
791
+ "time": int(time.time()),
792
+ "type": "group" if chat_id.startswith("group_") else "private",
793
+ "sender_id": bot.self_id,
794
+ "sender_name": "我",
795
+ "sender_avatar": f"https://q1.qlogo.cn/g?b=qq&nk={bot.self_id}&s=640",
796
+ "elements": [{"type": "text", "data": content}],
797
+ "content": content,
798
+ "is_self": True
799
+ }
800
+
801
+ if chat_id not in message_cache:
802
+ message_cache[chat_id] = []
803
+ message_cache[chat_id].append(my_msg)
804
+
805
+ await broadcast_message({
806
+ "type": "new_message",
807
+ "chat_id": chat_id,
808
+ "data": my_msg
809
+ })
810
+
811
+ return {"status": "ok"}
812
+ except Exception as e:
813
+ return {"error": str(e)}
814
+
815
+ # WebSocket 端点
816
+ @app.websocket("/web_console/ws")
817
+ async def websocket_endpoint(websocket: WebSocket):
818
+ await websocket.accept()
819
+ active_connections.add(websocket)
820
+ try:
821
+ while True:
822
+ # 保持连接,接收心跳或其他
823
+ await websocket.receive_text()
824
+ except WebSocketDisconnect:
825
+ active_connections.remove(websocket)
826
+ except Exception:
827
+ if websocket in active_connections:
828
+ active_connections.remove(websocket)
829
+
830
+ # 挂载静态文件
831
+ app.mount("/web_console/static", StaticFiles(directory=str(static_path)), name="web_console_static")
832
+
833
+ @app.get("/web_console")
834
+ async def index():
835
+ return FileResponse(static_path / "index.html")