nonebot-plugin-botui 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.
@@ -0,0 +1,382 @@
1
+ """nonebot-plugin-botui:给机器人配一个 QQ 风格的 WebUI 控制台。
2
+
3
+ 核心能力:
4
+ - 记录机器人收到的所有消息(私聊/群聊),以及机器人自己发出的消息;
5
+ - 提供一个类似 QQ 的网页界面:左侧会话列表、右侧聊天记录,支持搜索、翻页;
6
+ - 可以直接在网页上以机器人的身份发消息(支持 @ 和回复),也可以撤回;
7
+ - 全部基于 nonebot-plugin-alconna 的跨平台消息发送能力与
8
+ nonebot-plugin-uninfo 的跨平台用户/场景信息,不绑定具体适配器;
9
+ - WebUI 复用 NoneBot 自带的 FastAPI 应用,不额外占用端口。
10
+
11
+ 使用方式:启动时会把带令牌的访问地址打印到控制台日志,直接点开即可。
12
+ 插件**不注册任何聊天指令**,不会占用任何命令名,也不会拦截用户消息。
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import secrets
19
+ from typing import Any
20
+
21
+ from nonebot import logger, require, get_driver
22
+ from nonebot.plugin import PluginMetadata, inherit_supported_adapters
23
+
24
+ require('nonebot_plugin_uninfo')
25
+ require('nonebot_plugin_alconna')
26
+ require('nonebot_plugin_localstore')
27
+ require('nonebot_plugin_apscheduler')
28
+
29
+ from nonebot_plugin_apscheduler import scheduler
30
+
31
+ from .paths import (
32
+ DB_FILE,
33
+ BLOB_DIR,
34
+ TOKEN_FILE,
35
+ )
36
+ from .store import MessageStore
37
+ from .webui import WebUIServer
38
+ from .config import Config
39
+ from .config import plugin_config as cfg
40
+ from .ingest import ingest_message
41
+ from .models import MessageRecord
42
+ from .capture import setup as setup_capture
43
+ from .capture import store_ref, register_hooks
44
+ from .mediastore import MediaStore
45
+
46
+ __plugin_meta__ = PluginMetadata(
47
+ name='BotUI 控制台',
48
+ description=(
49
+ '一个 QQ 风格的 WebUI:查看机器人收发的全部消息,'
50
+ '并以机器人的身份发消息、撤回消息'
51
+ ),
52
+ usage=(
53
+ '插件不注册聊天指令:启动时会把 WebUI 地址和访问令牌打印到控制台日志,'
54
+ '浏览器打开该地址即可使用。'
55
+ ),
56
+ type='application',
57
+ homepage='https://github.com/Lonely-Sails/nonebot-plugin-botui',
58
+ config=Config,
59
+ supported_adapters=inherit_supported_adapters(
60
+ 'nonebot_plugin_alconna', 'nonebot_plugin_uninfo'
61
+ ),
62
+ )
63
+
64
+ driver = get_driver()
65
+
66
+ # ── 运行时状态(首次用到时创建) ────────────────────────────────────────
67
+ _store: MessageStore | None = None
68
+ _server: WebUIServer | None = None
69
+ _media: MediaStore | None = None
70
+ _token: str = ''
71
+ _hooks_ready = False
72
+ #: 后台任务(媒体抓取)持有引用,避免中途被 GC 回收
73
+ _bg_tasks: set[asyncio.Task] = set()
74
+
75
+
76
+ def _load_token() -> str:
77
+ """读取或生成访问令牌。
78
+
79
+ 路径由 nonebot-plugin-localstore 给出(见 ``.paths``),这里只负责读写。
80
+ """
81
+ if cfg.botui_token:
82
+ return cfg.botui_token
83
+ path = TOKEN_FILE
84
+ try:
85
+ if path.is_file():
86
+ token = path.read_text(encoding='utf-8').strip()
87
+ if token:
88
+ return token
89
+ except Exception as e: # pragma: no cover - 文件系统异常
90
+ logger.warning(f'BotUI 读取令牌失败,将重新生成:{e}')
91
+ token = secrets.token_urlsafe(24)
92
+ try:
93
+ path.write_text(token, encoding='utf-8')
94
+ path.chmod(0o600)
95
+ except Exception as e: # pragma: no cover - 只读文件系统
96
+ logger.warning(f'BotUI 保存令牌失败(重启后会变化):{e}')
97
+ return token
98
+
99
+
100
+ def _get_media() -> MediaStore:
101
+ """插件唯一的媒体库(收到的媒体与上传的附件都在这里,见 mediastore.py)。"""
102
+ global _media
103
+ if _media is None:
104
+ _media = MediaStore(
105
+ BLOB_DIR,
106
+ max_bytes=cfg.botui_media_max_bytes if cfg.botui_media_enabled else 0,
107
+ max_files=cfg.botui_media_max_files if cfg.botui_media_enabled else 0,
108
+ ttl=cfg.botui_media_ttl,
109
+ retention=cfg.botui_media_retention,
110
+ file_max_bytes=cfg.botui_media_file_max_bytes,
111
+ )
112
+ return _media
113
+
114
+
115
+ def _get_store() -> MessageStore:
116
+ global _store, _hooks_ready
117
+ if _store is None:
118
+ _store = MessageStore(cfg, DB_FILE)
119
+ _store.on_insert = _on_record_inserted
120
+ # 记录落库后:给媒体登记引用并抓取尚未入库的媒体(见 ingest.py)
121
+ _store.on_persist = _on_record_persisted
122
+ # 消息被清理时,媒体库要同步摘掉引用(见 store.cleanup)
123
+ _store.media = _get_media()
124
+ if not _hooks_ready:
125
+ # 采集钩子只注册一次(Bot.on_calling_api 会累加回调,重复注册会记多条)
126
+ setup_capture(_store, cfg)
127
+ register_hooks()
128
+ _hooks_ready = True
129
+ return _store
130
+
131
+
132
+ def _get_server() -> WebUIServer:
133
+ global _server
134
+ if _server is None:
135
+ _server = WebUIServer()
136
+ return _server
137
+
138
+
139
+ def _on_record_inserted(record: MessageRecord) -> None:
140
+ """记录落库后推送增量事件(WebUI 靠它实时刷新)"""
141
+ if _store is not None and _server is not None:
142
+ _server.publish_message(record, _store.chat(record.chat_key))
143
+
144
+
145
+ async def _on_record_persisted(record: MessageRecord) -> None:
146
+ """记录落库并拿到行号后,把消息里的媒体收进媒体库(抓取是纯 IO,不挡写入)。
147
+
148
+ spawn 成后台任务是因为下载往往要花几秒;``ingest_message`` 内部会把段里的
149
+ url 回填成媒体库地址(内存副本),因此不影响已经推送给前端的记录。
150
+ """
151
+ media = _get_media()
152
+ if not media.enabled:
153
+ return
154
+ coro = ingest_message(media, record, cfg=cfg)
155
+ task = asyncio.get_running_loop().create_task(coro)
156
+ _bg_tasks.add(task)
157
+ task.add_done_callback(_bg_tasks.discard)
158
+
159
+
160
+ def _bot_info(bot) -> dict[str, str]:
161
+ """凑出机器人的展示信息(昵称 / 头像 / 适配器)。
162
+
163
+ 昵称/头像只从适配器**真实存在**的属性里读(如 OneBot 的 ``bot.self_info``、
164
+ 其他适配器的 ``bot.info``)。**不能**用 ``getattr(bot, 'nickname', '')`` 这种
165
+ 写法:NoneBot 的基类 ``Bot`` 重写了 ``__getattr__``,任何未知属性都会返回
166
+ ``partial(bot.call_api, name)``,于是默认值永远不会生效,界面上就会出现
167
+ ``functools.partial(<bound method Bot.call_api ...>)``。因此这里先用
168
+ ``type(bot)`` / ``vars(bot)`` 判定属性确实存在,取不到就留空,交给前端用
169
+ ``self_id`` 顶替显示。
170
+
171
+ 这里刻意**不**主动调适配器接口去查昵称:连接钩子里多发一次 API 调用既可能
172
+ 拖慢上线,也会打乱调用方的调用序列,得不偿失。
173
+ """
174
+ info: dict[str, str] = {'self_id': str(getattr(bot, 'self_id', '') or '')}
175
+ try:
176
+ info['adapter'] = bot.adapter.get_name()
177
+ except Exception:
178
+ info['adapter'] = ''
179
+ for attr in ('self_info', 'info'):
180
+ if not hasattr(type(bot), attr) and attr not in vars(bot):
181
+ continue
182
+ try:
183
+ payload = getattr(bot, attr)
184
+ except Exception:
185
+ continue
186
+ if isinstance(payload, dict):
187
+ info['name'] = str(payload.get('nickname') or payload.get('name') or '')
188
+ avatar = payload.get('avatar') or payload.get('head_image')
189
+ if avatar:
190
+ info['avatar'] = str(avatar)
191
+ break
192
+ return info
193
+
194
+
195
+ async def _register_bot(bot, online: bool) -> None:
196
+ """记录机器人连接/断开:写库、更新热缓存并推送事件。"""
197
+ store = _get_store()
198
+ if not store.ready:
199
+ return
200
+ info = _bot_info(bot)
201
+ if not info['self_id']:
202
+ return
203
+ try:
204
+ record = await store.upsert_bot(
205
+ info['self_id'],
206
+ adapter=info.get('adapter', ''),
207
+ name=info.get('name', ''),
208
+ avatar=info.get('avatar', ''),
209
+ online=online,
210
+ )
211
+ except Exception as e: # pragma: no cover - 记录失败不影响机器人本身
212
+ logger.warning(f'BotUI 记录机器人状态失败:{e}')
213
+ return
214
+ if _server is not None:
215
+ _server.publish_bot(record)
216
+ logger.debug(f'BotUI 机器人 {info["self_id"]} {"上线" if online else "离线"}')
217
+
218
+
219
+ @driver.on_bot_connect
220
+ async def _on_bot_connect(bot) -> None:
221
+ if not cfg.botui_enabled:
222
+ return
223
+ await _register_bot(bot, True)
224
+
225
+
226
+ @driver.on_bot_disconnect
227
+ async def _on_bot_disconnect(bot) -> None:
228
+ if not cfg.botui_enabled:
229
+ return
230
+ await _register_bot(bot, False)
231
+
232
+
233
+ async def _seed_bots() -> None:
234
+ """启动时把当前已连接的机器人补进记录。
235
+
236
+ ``on_bot_connect`` 只在**新**连接时触发;如果插件是热重载、或机器人先
237
+ 连上、WebUI 后启动,那些已经连着的机器人不会再过一次钩子,这里主动扫
238
+ 一遍 ``get_bots()``,保证「连接过的机器人」列表从一开始就是完整的。
239
+ """
240
+ import nonebot
241
+
242
+ store = _get_store()
243
+ if not store.ready:
244
+ return
245
+ for bot in list(nonebot.get_bots().values()):
246
+ try:
247
+ info = _bot_info(bot)
248
+ if not info['self_id']:
249
+ continue
250
+ await store.upsert_bot(
251
+ info['self_id'],
252
+ adapter=info.get('adapter', ''),
253
+ name=info.get('name', ''),
254
+ avatar=info.get('avatar', ''),
255
+ online=True,
256
+ )
257
+ except Exception as e: # pragma: no cover - 补录失败不影响启动
258
+ logger.debug(f'BotUI 补录已连接机器人失败:{e}')
259
+
260
+
261
+ def _try_mount() -> bool:
262
+ """挂载 WebUI;``BOTUI_ENABLED=false`` 时永远不挂。"""
263
+ if not cfg.botui_enabled:
264
+ logger.info('BotUI 已关闭(BOTUI_ENABLED=false),不会挂载 WebUI')
265
+ return False
266
+ if _get_server().mounted:
267
+ return True
268
+ _get_store() # 确保采集已就绪
269
+ return _get_server().mount()
270
+
271
+
272
+ # ── 生命周期 ────────────────────────────────────────────────────────────
273
+ async def _cleanup_job() -> None:
274
+ store = store_ref()
275
+ if store is not None:
276
+ removed = await store.cleanup()
277
+ if removed:
278
+ logger.info(f'BotUI 已清理 {removed} 条超出上限的历史记录')
279
+ # 回收媒体库:未被引用的按 ttl、被引用的按保留期——上传后没发出去的附件
280
+ # (ttl)与收到的、链接已失效的媒体都在这里一起回收。
281
+ if _media is None or not _media.ready:
282
+ return
283
+ try:
284
+ dropped = await _media.cleanup()
285
+ except Exception as e: # pragma: no cover - 清理失败不影响机器人
286
+ logger.debug(f'BotUI 清理媒体库失败:{e}')
287
+ return
288
+ if dropped:
289
+ logger.info(f'BotUI 已清理 {dropped} 个过期媒体文件')
290
+
291
+
292
+ def _log_banner(mounted: bool, store: MessageStore) -> None:
293
+ """把访问方式打到控制台。
294
+
295
+ 插件不注册任何聊天指令,控制台日志就是唯一的「入口」,所以这里必须给出
296
+ 带令牌的完整地址。
297
+
298
+ 一条信息一行、各自调用一次 logger:loguru 会给每条记录都加上
299
+ ``时间 [级别] 插件名 |`` 前缀,多行挤在一条记录里的话,第二行开始就没有
300
+ 前缀了,看上去像悬在空中。
301
+
302
+ 除此之外只打两类信息:数据存到哪了,以及**偏离默认值**的开关。采集开关、
303
+ 记录上限之类保持默认时不打 —— 这些在配置文件里本来就能看到,每次都念一遍
304
+ 只会把真正要看的那行地址淹掉。
305
+ """
306
+ if mounted:
307
+ logger.info(_get_server().help_url(_token))
308
+ if not cfg.botui_allow_remote:
309
+ logger.info('仅本机可访问;需要远程访问请设置 BOTUI_ALLOW_REMOTE=true')
310
+ else:
311
+ logger.warning('未能挂载 WebUI,请使用 FastAPI 驱动(nonebot2[fastapi])')
312
+ logger.info(f'数据:{store.path}')
313
+
314
+ if not cfg.botui_auth:
315
+ logger.warning('令牌鉴权已关闭:能访问该地址的人都能冒充机器人发言')
316
+ if not (cfg.botui_capture_received and cfg.botui_capture_sent):
317
+ received = '开' if cfg.botui_capture_received else '关'
318
+ sent = '开' if cfg.botui_capture_sent else '关'
319
+ logger.info(f'采集已按配置调整(收到={received},发出={sent})')
320
+
321
+
322
+ @driver.on_startup
323
+ async def _on_startup() -> None:
324
+ global _token
325
+ if not cfg.botui_enabled:
326
+ logger.info('BotUI 已在配置中关闭(BOTUI_ENABLED=false)')
327
+ return
328
+ store = _get_store()
329
+ await store.start()
330
+ _token = _load_token()
331
+ media = _get_media()
332
+ # 媒体库跑在消息库的同一条连接上(见 mediastore.py):连接刚建立、尚无任何
333
+ # 后台任务,正是建表(attach)的时机。
334
+ if media.enabled and store.db is not None:
335
+ await media.attach(store.db, store.lock)
336
+ from . import api
337
+
338
+ server = _get_server()
339
+ api.setup(store, server, _token, media)
340
+ mounted = _try_mount()
341
+ await _seed_bots()
342
+ # 启动时先清一次:上次运行留下的过期媒体不必等到下一个周期
343
+ await _cleanup_job()
344
+ if cfg.botui_allow_remote and not cfg.botui_auth:
345
+ logger.warning(
346
+ 'BotUI 已允许非本机访问(BOTUI_ALLOW_REMOTE=true)且未开启令牌鉴权'
347
+ '(BOTUI_AUTH=false):任何能访问该地址的人都能查看全部聊天记录并'
348
+ '冒充机器人发言,请务必确认!'
349
+ )
350
+ _log_banner(mounted, store)
351
+ # 清理周期:媒体库清理要「及时」(上传了没发的附件不该赖着),默认 10 分钟;
352
+ # 配成 0 时退回 6 小时一轮,只做消息记录的回收。
353
+ interval = int(cfg.botui_media_cleanup_interval)
354
+ trigger: dict[str, Any] = {'minutes': interval} if interval > 0 else {'hours': 6}
355
+ scheduler.add_job(
356
+ _cleanup_job,
357
+ 'interval',
358
+ id='botui_cleanup',
359
+ replace_existing=True,
360
+ max_instances=1,
361
+ coalesce=True,
362
+ **trigger,
363
+ )
364
+
365
+
366
+ @driver.on_shutdown
367
+ async def _on_shutdown() -> None:
368
+ # 待发送的临时附件不属于任何记录,进程结束前主动清掉(别留在系统临时目录)
369
+ if _media is not None:
370
+ try:
371
+ await _media.discard_pending()
372
+ except Exception as e: # pragma: no cover - 收尾尽力而为
373
+ logger.debug(f'BotUI 清理临时附件失败:{e}')
374
+ store = store_ref()
375
+ if store is None:
376
+ return
377
+ try:
378
+ await store.flush()
379
+ await store.stop()
380
+ logger.debug('BotUI 存储已关闭')
381
+ except Exception as e: # pragma: no cover - 关闭阶段尽力而为
382
+ logger.warning(f'BotUI 关闭存储时出错:{e}')