nonebot-plugin-ruok 0.2.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.
- nonebot_plugin_ruok/__init__.py +718 -0
- nonebot_plugin_ruok/api.py +348 -0
- nonebot_plugin_ruok/chat_render.py +154 -0
- nonebot_plugin_ruok/collector.py +197 -0
- nonebot_plugin_ruok/collectors/__init__.py +107 -0
- nonebot_plugin_ruok/collectors/metrics.py +659 -0
- nonebot_plugin_ruok/collectors/modules.py +252 -0
- nonebot_plugin_ruok/collectors/monitor.py +231 -0
- nonebot_plugin_ruok/collectors/notifications.py +325 -0
- nonebot_plugin_ruok/collectors/sessions.py +569 -0
- nonebot_plugin_ruok/collectors/trackers.py +119 -0
- nonebot_plugin_ruok/config.py +50 -0
- nonebot_plugin_ruok/protocol.py +248 -0
- nonebot_plugin_ruok/webui/auth.py +1002 -0
- nonebot_plugin_ruok/webui/jinja.py +87 -0
- nonebot_plugin_ruok/webui/router.py +1571 -0
- nonebot_plugin_ruok/webui/sse.py +238 -0
- nonebot_plugin_ruok/webui/static/vendor/chartjs/LICENSE.md +9 -0
- nonebot_plugin_ruok/webui/static/vendor/chartjs/chart.umd.min.js +14 -0
- nonebot_plugin_ruok/webui/static/vendor/htmx/LICENSE +13 -0
- nonebot_plugin_ruok/webui/static/vendor/htmx/htmx.min.js +1 -0
- nonebot_plugin_ruok/webui/static/vendor/idiomorph/LICENSE +13 -0
- nonebot_plugin_ruok/webui/static/vendor/idiomorph/idiomorph-ext.min.js +1 -0
- nonebot_plugin_ruok/webui/static/vendor/pico/LICENSE.md +21 -0
- nonebot_plugin_ruok/webui/static/vendor/pico/pico.min.css +4 -0
- nonebot_plugin_ruok/webui/templates/_auth_shell.html.jinja2 +268 -0
- nonebot_plugin_ruok/webui/templates/_dashboard_connections.html.jinja2 +13 -0
- nonebot_plugin_ruok/webui/templates/_dashboard_disk.html.jinja2 +26 -0
- nonebot_plugin_ruok/webui/templates/_dashboard_hero.html.jinja2 +19 -0
- nonebot_plugin_ruok/webui/templates/_dashboard_metrics.html.jinja2 +39 -0
- nonebot_plugin_ruok/webui/templates/_dashboard_stats.html.jinja2 +46 -0
- nonebot_plugin_ruok/webui/templates/_dashboard_system.html.jinja2 +232 -0
- nonebot_plugin_ruok/webui/templates/_dashboard_trends.html.jinja2 +195 -0
- nonebot_plugin_ruok/webui/templates/_linked_list.html.jinja2 +18 -0
- nonebot_plugin_ruok/webui/templates/_module_form.html.jinja2 +89 -0
- nonebot_plugin_ruok/webui/templates/_modules_table.html.jinja2 +50 -0
- nonebot_plugin_ruok/webui/templates/_notification_form.html.jinja2 +84 -0
- nonebot_plugin_ruok/webui/templates/_notifications_list.html.jinja2 +65 -0
- nonebot_plugin_ruok/webui/templates/_session_card.html.jinja2 +94 -0
- nonebot_plugin_ruok/webui/templates/_session_container.html.jinja2 +18 -0
- nonebot_plugin_ruok/webui/templates/_sessions_list.html.jinja2 +102 -0
- nonebot_plugin_ruok/webui/templates/_user_sessions.html.jinja2 +36 -0
- nonebot_plugin_ruok/webui/templates/_users_panel.html.jinja2 +271 -0
- nonebot_plugin_ruok/webui/templates/base.html.jinja2 +980 -0
- nonebot_plugin_ruok/webui/templates/chat_session_list.html.jinja2 +117 -0
- nonebot_plugin_ruok/webui/templates/chat_status.html.jinja2 +104 -0
- nonebot_plugin_ruok/webui/templates/dashboard.html.jinja2 +362 -0
- nonebot_plugin_ruok/webui/templates/dashboard_user.html.jinja2 +70 -0
- nonebot_plugin_ruok/webui/templates/login.html.jinja2 +54 -0
- nonebot_plugin_ruok/webui/templates/modules.html.jinja2 +16 -0
- nonebot_plugin_ruok/webui/templates/modules_detail.html.jinja2 +70 -0
- nonebot_plugin_ruok/webui/templates/notifications.html.jinja2 +16 -0
- nonebot_plugin_ruok/webui/templates/notifications_detail.html.jinja2 +70 -0
- nonebot_plugin_ruok/webui/templates/register.html.jinja2 +61 -0
- nonebot_plugin_ruok/webui/templates/reset_password.html.jinja2 +55 -0
- nonebot_plugin_ruok/webui/templates/sessions.html.jinja2 +170 -0
- nonebot_plugin_ruok/webui/templates/sessions_detail.html.jinja2 +234 -0
- nonebot_plugin_ruok/webui/templates/users.html.jinja2 +11 -0
- nonebot_plugin_ruok-0.2.0.dist-info/METADATA +614 -0
- nonebot_plugin_ruok-0.2.0.dist-info/RECORD +61 -0
- nonebot_plugin_ruok-0.2.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import secrets
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from collections.abc import Callable, Awaitable
|
|
8
|
+
|
|
9
|
+
from nonebot import logger, require, get_driver, on_command, get_plugin_config
|
|
10
|
+
from nonebot.params import CommandArg
|
|
11
|
+
from nonebot.plugin import PluginMetadata
|
|
12
|
+
from nonebot.drivers import ASGIMixin
|
|
13
|
+
from nonebot.adapters import Bot, Event, Message
|
|
14
|
+
|
|
15
|
+
from .api import create_ruok_router
|
|
16
|
+
from .config import Config
|
|
17
|
+
from .protocol import Session, ReporterInfo, BotConnectionStatus
|
|
18
|
+
from .collector import (
|
|
19
|
+
LogMonitor,
|
|
20
|
+
SessionPluginValidationError,
|
|
21
|
+
get_session,
|
|
22
|
+
list_sessions,
|
|
23
|
+
create_session,
|
|
24
|
+
update_session,
|
|
25
|
+
_handle_ruok_error,
|
|
26
|
+
_connection_history,
|
|
27
|
+
confirm_session_plugins,
|
|
28
|
+
)
|
|
29
|
+
from .webui.auth import (
|
|
30
|
+
WebUIAuth,
|
|
31
|
+
AuthKeyExpired,
|
|
32
|
+
AuthKeyInvalid,
|
|
33
|
+
AuthKeyAlreadyUsed,
|
|
34
|
+
AuthUserAlreadyBound,
|
|
35
|
+
)
|
|
36
|
+
from .chat_render import (
|
|
37
|
+
send_chat_image,
|
|
38
|
+
chat_render_enabled,
|
|
39
|
+
render_status_image,
|
|
40
|
+
render_session_list_image,
|
|
41
|
+
)
|
|
42
|
+
from .webui.router import create_webui_router
|
|
43
|
+
|
|
44
|
+
require("nonebot_plugin_localstore")
|
|
45
|
+
store = __import__("nonebot_plugin_localstore")
|
|
46
|
+
require("nonebot_plugin_htmlrender")
|
|
47
|
+
|
|
48
|
+
__plugin_meta__ = PluginMetadata(
|
|
49
|
+
name="Nonebot, RUOK?",
|
|
50
|
+
description="Bot health monitoring, session tracking, and WebUI",
|
|
51
|
+
usage=(
|
|
52
|
+
"/ruok no <module> <description> — report an issue\n"
|
|
53
|
+
"/ruok bind <auth_key> — bind WebUI account to current platform user\n"
|
|
54
|
+
"/ruok reset — issue a password reset key for bound WebUI user\n"
|
|
55
|
+
"/ruok status — check module health\n"
|
|
56
|
+
"/ruok list — view visible sessions\n"
|
|
57
|
+
"/ruok lookup <session_id> — view visible session details\n"
|
|
58
|
+
"/ruok confirm <session_id> — confirm issue (pending→unsolved)\n"
|
|
59
|
+
"/ruok solve <session_id> — mark as resolved\n"
|
|
60
|
+
"/ruok ignore <session_id> — ignore (false alarm)\n"
|
|
61
|
+
"/ruok raise [message] — create a SUPERUSER-only test internal error\n"
|
|
62
|
+
"Visit /ruok for WebUI dashboard"
|
|
63
|
+
),
|
|
64
|
+
type="application",
|
|
65
|
+
homepage="https://github.com/p0rt39/nonebot-plugin-ruok",
|
|
66
|
+
config=Config,
|
|
67
|
+
supported_adapters=None,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# ────────────────────────────────
|
|
71
|
+
# Config
|
|
72
|
+
# ────────────────────────────────
|
|
73
|
+
|
|
74
|
+
plugin_config = get_plugin_config(Config).ruok
|
|
75
|
+
data_dir = Path(store.get_plugin_data_dir())
|
|
76
|
+
|
|
77
|
+
# ────────────────────────────────
|
|
78
|
+
# Globals
|
|
79
|
+
# ────────────────────────────────
|
|
80
|
+
|
|
81
|
+
log_monitor = LogMonitor(plugin_config, data_dir)
|
|
82
|
+
webui_auth = WebUIAuth(
|
|
83
|
+
plugin_config,
|
|
84
|
+
data_dir,
|
|
85
|
+
superuser_provider=lambda: get_driver().config.superusers,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# ────────────────────────────────
|
|
89
|
+
# Permission checker for report
|
|
90
|
+
# ────────────────────────────────
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def _can_report(event: Event) -> bool:
|
|
94
|
+
"""Check whether a user is allowed to report issues."""
|
|
95
|
+
# crisis_mode: skip all permission checks
|
|
96
|
+
if plugin_config.crisis_mode:
|
|
97
|
+
return True
|
|
98
|
+
|
|
99
|
+
user_id = event.get_user_id()
|
|
100
|
+
|
|
101
|
+
# SUPERUSERS always allowed
|
|
102
|
+
if user_id in get_driver().config.superusers:
|
|
103
|
+
return True
|
|
104
|
+
|
|
105
|
+
# WebUI users bound to this platform user can report from chat.
|
|
106
|
+
if webui_auth.is_user_bound(user_id):
|
|
107
|
+
return True
|
|
108
|
+
|
|
109
|
+
# Whitelist users
|
|
110
|
+
if user_id in plugin_config.report_whitelist_users:
|
|
111
|
+
return True
|
|
112
|
+
|
|
113
|
+
# Group admin / owner
|
|
114
|
+
sender = getattr(event, "sender", None)
|
|
115
|
+
if sender and getattr(sender, "role", None) in ("admin", "owner"):
|
|
116
|
+
return True
|
|
117
|
+
|
|
118
|
+
# Whitelist groups
|
|
119
|
+
group_id = getattr(event, "group_id", None)
|
|
120
|
+
if group_id is not None and str(group_id) in plugin_config.report_whitelist_groups:
|
|
121
|
+
return True
|
|
122
|
+
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _is_superuser(event: Event) -> bool:
|
|
127
|
+
"""Return whether the current platform user is a NoneBot SUPERUSER."""
|
|
128
|
+
return event.get_user_id() in get_driver().config.superusers
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _is_session_owner(session: Session, event: Event) -> bool:
|
|
132
|
+
"""Return whether a session belongs to the current platform user."""
|
|
133
|
+
return session.reporter.user_id == event.get_user_id()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _format_session_list_line(session: Session) -> str:
|
|
137
|
+
"""Return one compact chat list line for a session."""
|
|
138
|
+
icon = {
|
|
139
|
+
"pending": "🟡",
|
|
140
|
+
"unsolved": "🔴",
|
|
141
|
+
"solved": "🟢",
|
|
142
|
+
"ignored": "⚪",
|
|
143
|
+
}.get(session.status, "❓")
|
|
144
|
+
return (
|
|
145
|
+
f"{icon} {session.session_id} | {session.module_name} | "
|
|
146
|
+
f"{session.status} | {session.last_seen_at.astimezone().strftime('%H:%M')}"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ────────────────────────────────
|
|
151
|
+
# RUOK Main Entrypoint
|
|
152
|
+
# ────────────────────────────────
|
|
153
|
+
|
|
154
|
+
ruok_cmd = on_command("ruok", priority=10, block=True)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@ruok_cmd.handle()
|
|
158
|
+
async def handle_ruok(
|
|
159
|
+
bot: Bot,
|
|
160
|
+
event: Event,
|
|
161
|
+
args: Annotated[Message, CommandArg()],
|
|
162
|
+
) -> None:
|
|
163
|
+
text = args.extract_plain_text().strip()
|
|
164
|
+
if not text:
|
|
165
|
+
await ruok_cmd.finish(
|
|
166
|
+
"RUOK — 用法:\n"
|
|
167
|
+
"/ruok no <模块> <描述> — 上报问题\n"
|
|
168
|
+
"/ruok bind <auth_key> — 绑定 WebUI 账户\n"
|
|
169
|
+
"/ruok reset — 重设 WebUI 密码\n"
|
|
170
|
+
"/ruok status — 查看状态\n"
|
|
171
|
+
"/ruok list — 查看可见 session\n"
|
|
172
|
+
"/ruok lookup <id> — 查看可见详情\n"
|
|
173
|
+
"/ruok confirm <id> [插件...] | solve <id> | ignore <id> — 管理\n"
|
|
174
|
+
"/ruok raise [message] — SUPERUSER 测试内部异常"
|
|
175
|
+
)
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
parts = text.split(maxsplit=1)
|
|
179
|
+
subcmd = parts[0].lower()
|
|
180
|
+
rest = parts[1] if len(parts) > 1 else ""
|
|
181
|
+
|
|
182
|
+
if subcmd == "no":
|
|
183
|
+
await _cmd_no(bot, event, rest)
|
|
184
|
+
elif subcmd == "bind":
|
|
185
|
+
await _cmd_bind(bot, event, rest)
|
|
186
|
+
elif subcmd == "reset":
|
|
187
|
+
await _cmd_reset(bot, event)
|
|
188
|
+
elif subcmd == "status":
|
|
189
|
+
await _cmd_status(bot, event)
|
|
190
|
+
elif subcmd == "list":
|
|
191
|
+
await _cmd_list(bot, event)
|
|
192
|
+
elif subcmd == "lookup":
|
|
193
|
+
await _cmd_lookup(event, rest)
|
|
194
|
+
elif subcmd in ("raise", "test"):
|
|
195
|
+
await _cmd_raise(event, rest, subcmd)
|
|
196
|
+
elif subcmd in ("confirm", "solve", "ignore"):
|
|
197
|
+
await _cmd_admin(bot, event, subcmd, rest)
|
|
198
|
+
else:
|
|
199
|
+
await ruok_cmd.finish(
|
|
200
|
+
f"❓ 未知子命令: {subcmd}\n"
|
|
201
|
+
+ (
|
|
202
|
+
"可用: no / bind / reset / status / list / lookup / confirm / "
|
|
203
|
+
"solve / ignore / raise"
|
|
204
|
+
)
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# ────────────────────────────────
|
|
209
|
+
# Subcommand handlers
|
|
210
|
+
# ────────────────────────────────
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
async def _cmd_no(bot: Bot, event: Event, rest: str) -> None:
|
|
214
|
+
"""Handle /ruok no <module> <description>"""
|
|
215
|
+
if not plugin_config.session_enabled:
|
|
216
|
+
await ruok_cmd.finish("⚠️ Session 系统未启用,无法上报问题。")
|
|
217
|
+
return
|
|
218
|
+
|
|
219
|
+
if not await _can_report(event):
|
|
220
|
+
await ruok_cmd.finish(
|
|
221
|
+
"❌ 你没有权限上报问题。需要:"
|
|
222
|
+
"绑定 WebUI 账号 / 群管理员 / 白名单 / SUPERUSER"
|
|
223
|
+
)
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
parts = rest.split(maxsplit=1)
|
|
227
|
+
user_input = parts[0].strip() if parts else ""
|
|
228
|
+
description = parts[1].strip() if len(parts) > 1 else ""
|
|
229
|
+
|
|
230
|
+
if not user_input:
|
|
231
|
+
await ruok_cmd.finish("用法: /ruok no <模块> <描述>")
|
|
232
|
+
return
|
|
233
|
+
|
|
234
|
+
# Resolve user input → module definition
|
|
235
|
+
from .collectors.modules import resolve_module_display
|
|
236
|
+
|
|
237
|
+
resolved = resolve_module_display(user_input, data_dir, plugin_config)
|
|
238
|
+
module_name = resolved.name if resolved is not None else user_input
|
|
239
|
+
|
|
240
|
+
gid = getattr(event, "group_id", None)
|
|
241
|
+
reporter = ReporterInfo(
|
|
242
|
+
type="user",
|
|
243
|
+
user_id=event.get_user_id(),
|
|
244
|
+
group_id=str(gid) if gid is not None else None,
|
|
245
|
+
platform=bot.type,
|
|
246
|
+
)
|
|
247
|
+
session = create_session(
|
|
248
|
+
data_dir,
|
|
249
|
+
module_name=module_name,
|
|
250
|
+
description=description,
|
|
251
|
+
reporter=reporter,
|
|
252
|
+
source="manual",
|
|
253
|
+
)
|
|
254
|
+
from .collectors.notifications import dispatch_notification
|
|
255
|
+
|
|
256
|
+
await dispatch_notification(session, plugin_config, data_dir)
|
|
257
|
+
display = resolved.display_name if resolved is not None else user_input
|
|
258
|
+
await ruok_cmd.finish(
|
|
259
|
+
f"📝 已记录 | Session: {session.session_id}\n"
|
|
260
|
+
f"模块: {display}\n描述: {description}"
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
async def _cmd_bind(bot: Bot, event: Event, rest: str) -> None:
|
|
265
|
+
"""Handle /ruok bind <auth_key>."""
|
|
266
|
+
auth_key = rest.strip()
|
|
267
|
+
if not auth_key:
|
|
268
|
+
await ruok_cmd.finish("用法: /ruok bind <auth_key>")
|
|
269
|
+
return
|
|
270
|
+
|
|
271
|
+
try:
|
|
272
|
+
user = webui_auth.bind_auth_key(auth_key, event.get_user_id(), bot.type)
|
|
273
|
+
except AuthKeyExpired:
|
|
274
|
+
await ruok_cmd.finish("❌ auth_key 已过期,请在 WebUI 重新获取绑定码。")
|
|
275
|
+
return
|
|
276
|
+
except AuthKeyAlreadyUsed:
|
|
277
|
+
await ruok_cmd.finish("❌ auth_key 已被使用,请在 WebUI 重新获取绑定码。")
|
|
278
|
+
return
|
|
279
|
+
except AuthUserAlreadyBound as exc:
|
|
280
|
+
await ruok_cmd.finish(f"❌ {exc}")
|
|
281
|
+
return
|
|
282
|
+
except AuthKeyInvalid:
|
|
283
|
+
await ruok_cmd.finish("❌ auth_key 无效,请检查后重试。")
|
|
284
|
+
return
|
|
285
|
+
|
|
286
|
+
await ruok_cmd.finish(
|
|
287
|
+
f"✅ WebUI 用户 {user.username} 已绑定平台账号 {user.bound_user_id}"
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
async def _cmd_reset(bot: Bot, event: Event) -> None:
|
|
292
|
+
"""Handle /ruok reset."""
|
|
293
|
+
try:
|
|
294
|
+
result = webui_auth.issue_password_reset_key(event.get_user_id(), bot.type)
|
|
295
|
+
except Exception as exc:
|
|
296
|
+
message = str(exc) or "无法生成重置码"
|
|
297
|
+
await ruok_cmd.finish(f"❌ {message}")
|
|
298
|
+
return
|
|
299
|
+
|
|
300
|
+
text = (
|
|
301
|
+
f"RUOK WebUI 用户 {result.user.username} 的一次性密码重置码:\n"
|
|
302
|
+
f"{result.reset_key}\n"
|
|
303
|
+
"请在 10 分钟内打开 /ruok/reset-password 完成重设。"
|
|
304
|
+
)
|
|
305
|
+
if getattr(event, "message_type", None) == "group":
|
|
306
|
+
try:
|
|
307
|
+
await bot.call_api(
|
|
308
|
+
"send_private_msg",
|
|
309
|
+
user_id=int(event.get_user_id()),
|
|
310
|
+
message=text,
|
|
311
|
+
)
|
|
312
|
+
except Exception as exc:
|
|
313
|
+
logger.warning(f"RUOK: password reset private message failed: {exc}")
|
|
314
|
+
await ruok_cmd.finish("❌ 私聊发送重置码失败,请先私聊 bot 后重试。")
|
|
315
|
+
return
|
|
316
|
+
await ruok_cmd.finish("✅ 重置码已通过私聊发送,请在 10 分钟内使用。")
|
|
317
|
+
return
|
|
318
|
+
|
|
319
|
+
await ruok_cmd.finish(text)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
async def _finish_with_optional_image(
|
|
323
|
+
bot: Bot,
|
|
324
|
+
event: Event,
|
|
325
|
+
text: str,
|
|
326
|
+
render_image: Callable[[], Awaitable[bytes]],
|
|
327
|
+
) -> None:
|
|
328
|
+
"""Try to send a rendered image, then fall back to text."""
|
|
329
|
+
if chat_render_enabled(plugin_config):
|
|
330
|
+
try:
|
|
331
|
+
image = await render_image()
|
|
332
|
+
await send_chat_image(bot, event, image)
|
|
333
|
+
except Exception as exc:
|
|
334
|
+
logger.warning(f"RUOK: chat image output failed, falling back: {exc}")
|
|
335
|
+
else:
|
|
336
|
+
await ruok_cmd.finish()
|
|
337
|
+
return
|
|
338
|
+
await ruok_cmd.finish(text)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
async def _cmd_status(bot: Bot, event: Event) -> None:
|
|
342
|
+
"""Handle /ruok status"""
|
|
343
|
+
try:
|
|
344
|
+
from .collector import list_modules as lm
|
|
345
|
+
|
|
346
|
+
modules = lm(data_dir, plugin_config)
|
|
347
|
+
except (OSError, ValueError, ImportError) as exc:
|
|
348
|
+
logger.warning(f"RUOK: status list_modules failed: {exc}")
|
|
349
|
+
modules = []
|
|
350
|
+
except Exception as exc:
|
|
351
|
+
_handle_ruok_error(exc, "_cmd_status list_modules", data_dir)
|
|
352
|
+
modules = []
|
|
353
|
+
|
|
354
|
+
if not modules:
|
|
355
|
+
await ruok_cmd.finish("⚠️ 暂无功能模块定义。在 WebUI 或 .env 中配置模块。")
|
|
356
|
+
return
|
|
357
|
+
|
|
358
|
+
lines: list[str] = []
|
|
359
|
+
for m in modules:
|
|
360
|
+
icon = {"available": "🟢", "degraded": "🟡", "unavailable": "🔴"}.get(
|
|
361
|
+
m.status, "⚪"
|
|
362
|
+
)
|
|
363
|
+
lines.append(f"{icon} {m.display_name or m.name} — {m.status}")
|
|
364
|
+
text = "\n".join(lines)
|
|
365
|
+
await _finish_with_optional_image(
|
|
366
|
+
bot,
|
|
367
|
+
event,
|
|
368
|
+
text,
|
|
369
|
+
lambda: render_status_image(modules, plugin_config),
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
async def _cmd_list(bot: Bot, event: Event) -> None:
|
|
374
|
+
"""Handle /ruok list with scoped visibility."""
|
|
375
|
+
if _is_superuser(event):
|
|
376
|
+
sessions = list_sessions(data_dir)
|
|
377
|
+
active = [s for s in sessions if s.status in ("pending", "unsolved")]
|
|
378
|
+
if not active:
|
|
379
|
+
await ruok_cmd.finish("🎉 没有活跃的 session。")
|
|
380
|
+
return
|
|
381
|
+
|
|
382
|
+
visible = active[:15]
|
|
383
|
+
lines = [f"共 {len(active)} 个活跃 session:"]
|
|
384
|
+
lines.extend(_format_session_list_line(s) for s in visible)
|
|
385
|
+
if len(active) > 15:
|
|
386
|
+
lines.append(
|
|
387
|
+
f"... 还有 {len(active) - 15} 个,使用 /ruok lookup <id> 查看详情"
|
|
388
|
+
)
|
|
389
|
+
text = "\n".join(lines)
|
|
390
|
+
await _finish_with_optional_image(
|
|
391
|
+
bot,
|
|
392
|
+
event,
|
|
393
|
+
text,
|
|
394
|
+
lambda: render_session_list_image(
|
|
395
|
+
title="活跃 Session",
|
|
396
|
+
sessions=visible,
|
|
397
|
+
total_count=len(active),
|
|
398
|
+
hidden_count=max(len(active) - len(visible), 0),
|
|
399
|
+
config=plugin_config,
|
|
400
|
+
),
|
|
401
|
+
)
|
|
402
|
+
return
|
|
403
|
+
|
|
404
|
+
sessions = list_sessions(data_dir, reporter_user_id=event.get_user_id())
|
|
405
|
+
visible = sessions[:5]
|
|
406
|
+
if not visible:
|
|
407
|
+
await ruok_cmd.finish("🎉 你还没有上报过 session。")
|
|
408
|
+
return
|
|
409
|
+
|
|
410
|
+
lines = [f"你的最近 {len(visible)} 个 session:"]
|
|
411
|
+
lines.extend(_format_session_list_line(s) for s in visible)
|
|
412
|
+
if len(sessions) > 5:
|
|
413
|
+
lines.append(
|
|
414
|
+
f"... 还有 {len(sessions) - 5} 个,使用 /ruok lookup <id> 查看详情"
|
|
415
|
+
)
|
|
416
|
+
text = "\n".join(lines)
|
|
417
|
+
await _finish_with_optional_image(
|
|
418
|
+
bot,
|
|
419
|
+
event,
|
|
420
|
+
text,
|
|
421
|
+
lambda: render_session_list_image(
|
|
422
|
+
title="你的 Session",
|
|
423
|
+
sessions=visible,
|
|
424
|
+
total_count=len(sessions),
|
|
425
|
+
hidden_count=max(len(sessions) - len(visible), 0),
|
|
426
|
+
config=plugin_config,
|
|
427
|
+
),
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
async def _cmd_lookup(event: Event, rest: str) -> None:
|
|
432
|
+
"""Handle /ruok lookup <session_id>"""
|
|
433
|
+
sid = rest.strip()
|
|
434
|
+
if not sid:
|
|
435
|
+
await ruok_cmd.finish("用法: /ruok lookup <session_id>")
|
|
436
|
+
return
|
|
437
|
+
|
|
438
|
+
is_superuser = _is_superuser(event)
|
|
439
|
+
session = get_session(data_dir, sid)
|
|
440
|
+
if session is None:
|
|
441
|
+
message = (
|
|
442
|
+
f"❌ Session `{sid}` 未找到。"
|
|
443
|
+
if is_superuser
|
|
444
|
+
else f"❌ Session `{sid}` 未找到或无权查看。"
|
|
445
|
+
)
|
|
446
|
+
await ruok_cmd.finish(message)
|
|
447
|
+
return
|
|
448
|
+
if not is_superuser and not _is_session_owner(session, event):
|
|
449
|
+
await ruok_cmd.finish(f"❌ Session `{sid}` 未找到或无权查看。")
|
|
450
|
+
return
|
|
451
|
+
|
|
452
|
+
status_icon = {
|
|
453
|
+
"pending": "🟡",
|
|
454
|
+
"unsolved": "🔴",
|
|
455
|
+
"solved": "🟢",
|
|
456
|
+
"ignored": "⚪",
|
|
457
|
+
}.get(session.status, "❓")
|
|
458
|
+
lines = [
|
|
459
|
+
f"{status_icon} Session: {session.session_id}",
|
|
460
|
+
f"状态: {session.status} | 来源: {session.source}",
|
|
461
|
+
]
|
|
462
|
+
if session.reporter.user_id:
|
|
463
|
+
lines.append(f"用户: {session.reporter.user_id}")
|
|
464
|
+
if session.reporter.group_id:
|
|
465
|
+
lines.append(f"群号: {session.reporter.group_id}")
|
|
466
|
+
if session.reporter.platform:
|
|
467
|
+
lines.append(f"平台: {session.reporter.platform}")
|
|
468
|
+
lines += [
|
|
469
|
+
f"模块: {session.module_name}",
|
|
470
|
+
f"描述: {session.description[:200]}",
|
|
471
|
+
f"创建: {session.first_seen_at.astimezone().strftime('%Y-%m-%d %H:%M:%S')} | "
|
|
472
|
+
f"最近: {session.last_seen_at.astimezone().strftime('%Y-%m-%d %H:%M:%S')}",
|
|
473
|
+
]
|
|
474
|
+
from .collector import get_linked_sessions
|
|
475
|
+
|
|
476
|
+
linked = get_linked_sessions(data_dir, session.session_id)
|
|
477
|
+
if not is_superuser:
|
|
478
|
+
linked = [s for s in linked if _is_session_owner(s, event)]
|
|
479
|
+
if linked:
|
|
480
|
+
lines.append(f"关联 ({len(linked)}): {', '.join(s.session_id for s in linked)}")
|
|
481
|
+
await ruok_cmd.finish("\n".join(lines))
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
async def _cmd_raise(event: Event, rest: str, action: str) -> None:
|
|
485
|
+
"""Handle /ruok raise|test [message] (SUPERUSER only)."""
|
|
486
|
+
if not _is_superuser(event):
|
|
487
|
+
await ruok_cmd.finish("❌ 此操作仅 SUPERUSER 可用")
|
|
488
|
+
return
|
|
489
|
+
|
|
490
|
+
message = rest.strip() or "manual RUOK test exception"
|
|
491
|
+
try:
|
|
492
|
+
raise RuntimeError(message)
|
|
493
|
+
except RuntimeError as exc:
|
|
494
|
+
sid = _handle_ruok_error(exc, f"manual /ruok {action}", data_dir)
|
|
495
|
+
|
|
496
|
+
await ruok_cmd.finish(f"🧪 已触发 RUOK 测试异常 | Session: {sid}")
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
async def _cmd_admin(bot: Bot, event: Event, action: str, rest: str) -> None:
|
|
500
|
+
"""Handle /ruok confirm|solve|ignore <session_id> (SUPERUSER only)"""
|
|
501
|
+
user_id = event.get_user_id()
|
|
502
|
+
if user_id not in get_driver().config.superusers:
|
|
503
|
+
await ruok_cmd.finish("❌ 此操作仅 SUPERUSER 可用")
|
|
504
|
+
return
|
|
505
|
+
|
|
506
|
+
sid = rest.strip()
|
|
507
|
+
if not sid:
|
|
508
|
+
if action == "confirm":
|
|
509
|
+
await ruok_cmd.finish(f"用法: /ruok {action} <session_id> [插件...]")
|
|
510
|
+
return
|
|
511
|
+
await ruok_cmd.finish(f"用法: /ruok {action} <session_id>")
|
|
512
|
+
return
|
|
513
|
+
|
|
514
|
+
status_map = {"confirm": "unsolved", "solve": "solved", "ignore": "ignored"}
|
|
515
|
+
new_status = status_map[action]
|
|
516
|
+
|
|
517
|
+
if action == "confirm":
|
|
518
|
+
session_id, plugins = _parse_confirm_args(rest)
|
|
519
|
+
try:
|
|
520
|
+
session = confirm_session_plugins(
|
|
521
|
+
data_dir,
|
|
522
|
+
plugin_config,
|
|
523
|
+
session_id,
|
|
524
|
+
plugins,
|
|
525
|
+
)
|
|
526
|
+
except SessionPluginValidationError as exc:
|
|
527
|
+
await ruok_cmd.finish(f"❌ {exc}")
|
|
528
|
+
return
|
|
529
|
+
sid = session_id
|
|
530
|
+
else:
|
|
531
|
+
session = update_session(data_dir, sid, {"status": new_status})
|
|
532
|
+
if session is None:
|
|
533
|
+
await ruok_cmd.finish(f"❌ Session `{sid}` 未找到。")
|
|
534
|
+
return
|
|
535
|
+
|
|
536
|
+
icon = {"unsolved": "🔴", "solved": "🟢", "ignored": "⚪"}[new_status]
|
|
537
|
+
labels = {"unsolved": "已确认", "solved": "已解决", "ignored": "已忽略(误报)"}
|
|
538
|
+
plugin_text = (
|
|
539
|
+
f"\n影响插件: {', '.join(session.affected_plugins)}"
|
|
540
|
+
if action == "confirm" and session.affected_plugins
|
|
541
|
+
else ""
|
|
542
|
+
)
|
|
543
|
+
await ruok_cmd.finish(f"{icon} Session {sid} → {labels[new_status]}{plugin_text}")
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _parse_confirm_args(rest: str) -> tuple[str, list[str]]:
|
|
547
|
+
"""Parse /ruok confirm <session_id> [plugin ...]."""
|
|
548
|
+
session_id, _, raw_plugins = rest.strip().partition(" ")
|
|
549
|
+
plugins = [
|
|
550
|
+
item
|
|
551
|
+
for token in raw_plugins.split()
|
|
552
|
+
for item in token.split(",")
|
|
553
|
+
if item.strip()
|
|
554
|
+
]
|
|
555
|
+
return session_id, plugins
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
# ────────────────────────────────
|
|
559
|
+
# WS connection tracking hooks
|
|
560
|
+
# ────────────────────────────────
|
|
561
|
+
|
|
562
|
+
driver = get_driver()
|
|
563
|
+
|
|
564
|
+
# CORS + SessionMiddleware — at module level before uvicorn starts
|
|
565
|
+
if isinstance(driver, ASGIMixin):
|
|
566
|
+
from fastapi import FastAPI
|
|
567
|
+
|
|
568
|
+
app = driver.server_app
|
|
569
|
+
if isinstance(app, FastAPI):
|
|
570
|
+
# CORS
|
|
571
|
+
try:
|
|
572
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
573
|
+
|
|
574
|
+
app.add_middleware(
|
|
575
|
+
CORSMiddleware,
|
|
576
|
+
allow_origins=plugin_config.cors_origins,
|
|
577
|
+
allow_methods=["*"],
|
|
578
|
+
allow_headers=["*"],
|
|
579
|
+
)
|
|
580
|
+
logger.info("RUOK CORS middleware registered")
|
|
581
|
+
except (ImportError, RuntimeError) as exc:
|
|
582
|
+
logger.warning(f"RUOK CORS setup failed: {exc}")
|
|
583
|
+
except Exception as exc:
|
|
584
|
+
_handle_ruok_error(exc, "CORS middleware setup", data_dir)
|
|
585
|
+
|
|
586
|
+
# SessionMiddleware — always added for request.session support.
|
|
587
|
+
# Auth gating (enabled/disabled) is handled at route level via require_login().
|
|
588
|
+
try:
|
|
589
|
+
from starlette.middleware.sessions import SessionMiddleware
|
|
590
|
+
|
|
591
|
+
secret = plugin_config.webui_secret_key or secrets.token_hex(32)
|
|
592
|
+
if not plugin_config.webui_secret_key:
|
|
593
|
+
logger.warning(
|
|
594
|
+
"RUOK: webui_secret_key not set — sessions invalidate on restart. "
|
|
595
|
+
"Set RUOK__WEBUI_SECRET_KEY in .env for persistence."
|
|
596
|
+
)
|
|
597
|
+
app.add_middleware(SessionMiddleware, secret_key=secret, max_age=None)
|
|
598
|
+
logger.info("RUOK SessionMiddleware registered")
|
|
599
|
+
except (ImportError, RuntimeError) as exc:
|
|
600
|
+
logger.warning(f"RUOK SessionMiddleware setup failed: {exc}")
|
|
601
|
+
except Exception as exc:
|
|
602
|
+
_handle_ruok_error(exc, "SessionMiddleware setup", data_dir)
|
|
603
|
+
|
|
604
|
+
# ── Global ASGI exception handler ──
|
|
605
|
+
# Catches any unhandled exception that escapes route-level try/except
|
|
606
|
+
# (e.g. response header encoding errors, middleware failures).
|
|
607
|
+
# Creates a RUOK session so the error is tracked and visible in WebUI.
|
|
608
|
+
from fastapi.responses import JSONResponse as _JSONResponse
|
|
609
|
+
from starlette.requests import Request as StarletteRequest
|
|
610
|
+
|
|
611
|
+
@app.exception_handler(Exception)
|
|
612
|
+
async def _ruok_global_exception_handler(
|
|
613
|
+
request: StarletteRequest, exc: Exception
|
|
614
|
+
) -> _JSONResponse:
|
|
615
|
+
sid = _handle_ruok_error(
|
|
616
|
+
exc,
|
|
617
|
+
f"ASGI {request.method} {request.url.path}",
|
|
618
|
+
data_dir,
|
|
619
|
+
)
|
|
620
|
+
return _JSONResponse(
|
|
621
|
+
{
|
|
622
|
+
"detail": (
|
|
623
|
+
f"Internal error [{type(exc).__name__}] — Session: {sid}"
|
|
624
|
+
),
|
|
625
|
+
},
|
|
626
|
+
status_code=500,
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
logger.info("RUOK global exception handler registered")
|
|
630
|
+
else:
|
|
631
|
+
logger.warning(
|
|
632
|
+
"RUOK: driver.server_app is not a FastAPI instance, skipping middleware"
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
@driver.on_bot_connect
|
|
637
|
+
async def _on_bot_connect(bot: Bot) -> None:
|
|
638
|
+
_connection_history[bot.self_id] = BotConnectionStatus(
|
|
639
|
+
self_id=bot.self_id,
|
|
640
|
+
adapter=bot.type,
|
|
641
|
+
connected=True,
|
|
642
|
+
connected_at=datetime.now(timezone.utc),
|
|
643
|
+
)
|
|
644
|
+
logger.info(f"RUOK: bot {bot.self_id} connected ({bot.type})")
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
@driver.on_bot_disconnect
|
|
648
|
+
async def _on_bot_disconnect(bot: Bot) -> None:
|
|
649
|
+
entry = _connection_history.get(bot.self_id)
|
|
650
|
+
if entry:
|
|
651
|
+
entry.connected = False
|
|
652
|
+
entry.disconnected_at = datetime.now(timezone.utc)
|
|
653
|
+
logger.info(f"RUOK: bot {bot.self_id} disconnected")
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
# ────────────────────────────────
|
|
657
|
+
# Startup — mount API + LogMonitor
|
|
658
|
+
# ────────────────────────────────
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
@driver.on_startup
|
|
662
|
+
async def _startup() -> None:
|
|
663
|
+
if isinstance(driver, ASGIMixin):
|
|
664
|
+
app = driver.server_app
|
|
665
|
+
|
|
666
|
+
# Auth router (login/register/logout)
|
|
667
|
+
try:
|
|
668
|
+
auth_router = webui_auth.create_router()
|
|
669
|
+
app.include_router(auth_router)
|
|
670
|
+
logger.info("RUOK Auth routes mounted")
|
|
671
|
+
except (ImportError, RuntimeError) as exc:
|
|
672
|
+
logger.warning(f"RUOK Auth mount failed: {exc}")
|
|
673
|
+
except Exception as exc:
|
|
674
|
+
_handle_ruok_error(exc, "Auth routes mount", data_dir)
|
|
675
|
+
|
|
676
|
+
try:
|
|
677
|
+
router = create_ruok_router(plugin_config, data_dir)
|
|
678
|
+
app.include_router(router)
|
|
679
|
+
logger.info("RUOK API mounted at /ruok/api/*")
|
|
680
|
+
except (ImportError, RuntimeError) as exc:
|
|
681
|
+
logger.warning(f"RUOK API mount failed: {exc}")
|
|
682
|
+
except Exception as exc:
|
|
683
|
+
_handle_ruok_error(exc, "API routes mount", data_dir)
|
|
684
|
+
|
|
685
|
+
try:
|
|
686
|
+
webui_router = create_webui_router(plugin_config, data_dir, webui_auth)
|
|
687
|
+
app.include_router(webui_router)
|
|
688
|
+
logger.info("RUOK WebUI SSR mounted at /ruok")
|
|
689
|
+
except (ImportError, RuntimeError) as exc:
|
|
690
|
+
logger.warning(f"RUOK WebUI mount failed: {exc}")
|
|
691
|
+
except Exception as exc:
|
|
692
|
+
_handle_ruok_error(exc, "WebUI routes mount", data_dir)
|
|
693
|
+
else:
|
|
694
|
+
logger.info("RUOK: non-ASGI driver, skipping API/WebUI mount")
|
|
695
|
+
|
|
696
|
+
# Start LogMonitor (works regardless of driver type)
|
|
697
|
+
log_monitor.start()
|
|
698
|
+
|
|
699
|
+
# Ensure notification rules are initialized on first run
|
|
700
|
+
from .collectors.notifications import _load_rules
|
|
701
|
+
|
|
702
|
+
_load_rules(data_dir)
|
|
703
|
+
|
|
704
|
+
# Register summary notification job (best-effort, APScheduler optional)
|
|
705
|
+
try:
|
|
706
|
+
from .collectors.notifications import register_summary_job
|
|
707
|
+
|
|
708
|
+
register_summary_job(plugin_config, data_dir)
|
|
709
|
+
except Exception as exc:
|
|
710
|
+
logger.warning(f"RUOK: summary job registration failed: {exc}")
|
|
711
|
+
|
|
712
|
+
logger.info("RUOK plugin started")
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
@driver.on_shutdown
|
|
716
|
+
async def _shutdown() -> None:
|
|
717
|
+
log_monitor.stop()
|
|
718
|
+
logger.info("RUOK plugin stopped")
|