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,570 @@
1
+ """消息采集:拦截机器人收发的消息并写入存储。
2
+
3
+ - 收到的消息:通过 ``event_preprocessor`` 钩子获取事件与会话信息;
4
+ - 发出的消息:通过 ``Bot.on_calling_api`` 钩子拦截 send_msg 一类的接口。
5
+
6
+ 之所以能在发出前就记录,是因为适配器正常发送消息时都会经由 ``Bot.call_api``。
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ from typing import TYPE_CHECKING, Any
13
+ from contextvars import Token, ContextVar
14
+
15
+ from nonebot import logger
16
+ from nonebot.typing import T_State
17
+ from nonebot.message import event_preprocessor
18
+ from nonebot.adapters import Bot, Event
19
+ from nonebot_plugin_uninfo import get_session
20
+
21
+ from .media import avatar_of
22
+ from .config import NICKNAME, Config
23
+ from .models import (
24
+ DIR_IN,
25
+ DIR_OUT,
26
+ KIND_GROUP,
27
+ KIND_PRIVATE,
28
+ ChatRecord,
29
+ MessageRecord,
30
+ now_ts,
31
+ chat_key,
32
+ self_id_of,
33
+ describe_segments,
34
+ split_chat_key_parts,
35
+ )
36
+ from .segments import to_segments, extract_text, to_segments_async
37
+
38
+ if TYPE_CHECKING:
39
+ from .store import MessageStore
40
+
41
+ _store: MessageStore | None = None
42
+ _cfg: Config | None = None
43
+
44
+ # 待关联结果的发送调用:id(payload) -> 记录
45
+ _pending_sends: dict[int, MessageRecord] = {}
46
+ _PENDING_LIMIT = 256
47
+
48
+ # 抑制采集的标记。
49
+ # WebUI 自己发消息时会先调 API、再手工补一条记录(因为要拿到行号返回给前端),
50
+ # 如果不抑制,采集钩子会再记一条,导致同一条消息在界面上出现两次、
51
+ # 会话的 message_count 也会多算一次。
52
+ #
53
+ # 用 ContextVar 而不是全局集合:NoneBot 的 call_api 里 ``data`` 是临时构造的
54
+ # dict,调用方拿不到它的 id;而 contextvar 在 anyio 任务组派生子任务时会被继承,
55
+ # 且天然按请求隔离,多个 WebUI 请求并发发送时不会互相误伤。
56
+ _suppress_sent: ContextVar[bool] = ContextVar('botui_suppress_sent', default=False)
57
+
58
+ # @昵称 的解析缓存:{chat_key}:{user_id} -> 昵称
59
+ _at_names: dict[str, str] = {}
60
+ _AT_NAME_LIMIT = 4096
61
+
62
+ _prune_counter = 0
63
+ _PRUNE_EVERY = 200
64
+
65
+ # 后台任务的强引用集合。
66
+ # 事件循环对任务只持弱引用(见 asyncio.create_task 文档),不自己留一份引用的话,
67
+ # 任务可能在跑完之前就被 GC 回收——清理任务正好是「发完就不管」的那类。
68
+ _bg_tasks: set[asyncio.Task] = set()
69
+
70
+
71
+ def _spawn(coro) -> None:
72
+ """跑一个后台任务并持有引用,避免中途被 GC 回收。
73
+
74
+ 只在 already-running 的事件循环里调用(``_persist`` 由消息钩子触发)。
75
+ """
76
+ task = asyncio.get_running_loop().create_task(coro)
77
+ _bg_tasks.add(task)
78
+ task.add_done_callback(_bg_tasks.discard)
79
+
80
+
81
+ def setup(store: 'MessageStore', config: Config) -> None:
82
+ """注册采集钩子(媒体入库由 ``store.on_persist`` 驱动,见 ``__init__.py``)"""
83
+ global _store, _cfg
84
+ _store = store
85
+ _cfg = config
86
+
87
+
88
+ def store_ref() -> 'MessageStore | None':
89
+ return _store
90
+
91
+
92
+ def suppress_sent() -> Token[bool]:
93
+ """在当前上下文中关闭「发出的消息」采集,返回用于恢复的 token。
94
+
95
+ 供 WebUI 自己发送消息时使用:它发送后会手工补一条记录(需要行号),
96
+ 如果不关闭采集就会重复记录。
97
+ """
98
+ return _suppress_sent.set(True)
99
+
100
+
101
+ def resume_sent(token: Token[bool]) -> None:
102
+ """恢复采集(配合 :func:`suppress_sent` 使用)"""
103
+ _suppress_sent.reset(token)
104
+
105
+
106
+ def _sent_suppressed() -> bool:
107
+ return _suppress_sent.get()
108
+
109
+
110
+ def _enabled_for(adapter: str) -> bool:
111
+ """该适配器的消息要不要记(``BOTUI_EXCLUDE_ADAPTERS``)。
112
+
113
+ 适配器名可能取不到(空串),那种情况下照常记录 —— 排除名单是用户显式
114
+ 写下的,不该因为拿不到名字就把消息全丢掉。
115
+ """
116
+ if _cfg is None:
117
+ return False
118
+ return not (adapter and adapter in _cfg.botui_exclude_adapters)
119
+
120
+
121
+ def _persist(record: MessageRecord) -> None:
122
+ """入队并周期性做清理。
123
+
124
+ 媒体入库不在这里做:记录落库拿到行号后,``store.on_persist`` 会回调
125
+ (见 ``__init__._on_record_persisted``),那边才 spawn 下载任务。这样
126
+ 「谁负责登记引用」只有一处,不会这里也记、那里也记。
127
+ """
128
+ global _prune_counter
129
+ if _store is None:
130
+ return
131
+ _store.enqueue(record)
132
+ _prune_counter += 1
133
+ if _prune_counter >= _PRUNE_EVERY:
134
+ _prune_counter = 0
135
+ _cfg_now = _cfg
136
+ if _cfg_now and (_cfg_now.botui_max_records or _cfg_now.botui_retention_days):
137
+ _spawn(_store.cleanup())
138
+
139
+
140
+ # ── 收到的消息 ──────────────────────────────────────────────────────────
141
+ @event_preprocessor
142
+ async def _capture_received(bot: Bot, event: Event, state: T_State) -> None:
143
+ if _store is None or _cfg is None or not _store.ready:
144
+ return
145
+ if not _cfg.botui_capture_received:
146
+ return
147
+ adapter = bot.adapter.get_name()
148
+ if not _enabled_for(adapter):
149
+ return
150
+ try:
151
+ await _record_received(bot, event)
152
+ except Exception as e: # 采集失败不能影响机器人本身
153
+ logger.opt(exception=True).warning(f'BotUI failed to capture message: {e}')
154
+
155
+
156
+ async def _record_received(bot: Bot, event: Event) -> None:
157
+ assert _store is not None
158
+ assert _cfg is not None # setup() 时一定会赋值,这里只是给类型检查看
159
+ session = await get_session(bot, event)
160
+ if session is None:
161
+ record = _fallback_record(bot, event)
162
+ if record is None:
163
+ return
164
+ else:
165
+ from .store import record_from_session
166
+
167
+ record = record_from_session(session, DIR_IN)
168
+ record.role = _role_of(session)
169
+ # 会话元信息(成员数等)随消息一起刷新
170
+ await _store.touch_chat(
171
+ record.chat_key,
172
+ name=record.chat_name,
173
+ avatar=record.chat_avatar,
174
+ member_count=record.member_count,
175
+ adapter=record.adapter,
176
+ scope=record.scope,
177
+ self_id=record.self_id,
178
+ parent_id=record.parent_id,
179
+ )
180
+
181
+ # 默认忽略「自己发的消息被适配器回传成事件」这种情况:否则同一条消息会
182
+ # 既被发送钩子记成 out,又被这里记成 in,界面上出现两份。
183
+ # 打开 BOTUI_CAPTURE_SELF 就按收到的消息照常记录。
184
+ if not _cfg.botui_capture_self and _is_from_self(bot, session, record):
185
+ return
186
+
187
+ message = None
188
+ try:
189
+ message = event.get_message()
190
+ except Exception as e:
191
+ logger.debug(f'BotUI: event has no message content ({e})')
192
+ if message is not None:
193
+ # 传 event 是为了把 reply 段补全:适配器的 reply 段只有 id,
194
+ # 被引用的发送者与正文在事件上(attach_reply 会取)。
195
+ record.segments = await to_segments_async(message, bot, event=event)
196
+ else:
197
+ record.segments = []
198
+ record.text = extract_text(message) if message is not None else ''
199
+ record.message_id = _message_id_of(event)
200
+ record.recallable = False
201
+ record.api = 'event'
202
+
203
+ # 尝试把 @某人 解析成群名片
204
+ for seg in record.segments:
205
+ if seg.get('type') == 'at' and not seg.get('name'):
206
+ target = str(seg.get('target') or '')
207
+ if target and target != 'all':
208
+ name = await resolve_at_name(bot, record.chat_key, target)
209
+ if name:
210
+ seg['name'] = name
211
+ # 顺手回填到成员名册:下次在 WebUI 里 @ 就有昵称可选了
212
+ if _store is not None:
213
+ await _store.rename_member(record.chat_key, target, name)
214
+
215
+ if _cfg is not None and _cfg.botui_debug:
216
+ logger.debug(
217
+ f'BotUI captured [{record.chat_key}] '
218
+ f'{record.user_name}: {record.preview[:80]!r}'
219
+ )
220
+ _persist(record)
221
+
222
+
223
+ def _is_from_self(bot: Bot, session: Any, record: MessageRecord) -> bool:
224
+ """判断这条「收到的」消息是不是机器人自己发的。
225
+
226
+ 有些适配器(或配置了自收自发)会把机器人刚发出去的消息再作为事件推回来,
227
+ 不排除的话同一条消息会被记两次。
228
+ """
229
+ self_id = str(getattr(bot, 'self_id', '') or '')
230
+ if not self_id:
231
+ return False
232
+ user_id = ''
233
+ try:
234
+ user = getattr(session, 'user', None)
235
+ user_id = str(getattr(user, 'id', '') or '') if user is not None else ''
236
+ except Exception:
237
+ user_id = ''
238
+ user_id = user_id or record.user_id
239
+ return bool(user_id) and user_id == self_id
240
+
241
+
242
+ def _fallback_record(bot: Bot, event: Event) -> MessageRecord | None:
243
+ """拿不到 uninfo 会话信息时的兜底(部分适配器/通知事件)"""
244
+ try:
245
+ user_id = str(event.get_user_id())
246
+ except Exception:
247
+ return None
248
+ group_id = ''
249
+ try:
250
+ group_id = str(getattr(event, 'group_id', '') or '')
251
+ except Exception:
252
+ group_id = ''
253
+ if group_id:
254
+ kind, chat_id, name = KIND_GROUP, group_id, ''
255
+ else:
256
+ kind, chat_id, name = KIND_PRIVATE, user_id, ''
257
+ try:
258
+ adapter = bot.adapter.get_name()
259
+ except Exception:
260
+ adapter = ''
261
+ self_id = str(getattr(bot, 'self_id', '') or '')
262
+ return MessageRecord(
263
+ chat_key=chat_key(kind, chat_id, self_id),
264
+ chat_kind=kind,
265
+ chat_id=chat_id,
266
+ direction=DIR_IN,
267
+ ts=now_ts(),
268
+ adapter=adapter,
269
+ self_id=self_id,
270
+ chat_name=name,
271
+ user_id=user_id,
272
+ user_name='',
273
+ )
274
+
275
+
276
+ def _role_of(session: Any) -> str | None:
277
+ member = getattr(session, 'member', None)
278
+ role = getattr(member, 'role', None) if member is not None else None
279
+ if role is None:
280
+ return None
281
+ name = getattr(role, 'name', None)
282
+ return str(name).lower() if name else None
283
+
284
+
285
+ def _message_id_of(event: Event) -> str:
286
+ for attr in ('message_id', 'msg_id', 'message_seq', 'id'):
287
+ value = getattr(event, attr, None)
288
+ if value not in (None, ''):
289
+ return str(value)
290
+ return ''
291
+
292
+
293
+ async def resolve_at_name(bot: Bot, key: str, user_id: str) -> str:
294
+ """把 @ 的用户 id 解析成昵称(带缓存,失败则返回空串)"""
295
+ assert _cfg is not None
296
+ if not _cfg.botui_resolve_at_name:
297
+ return ''
298
+ cache_key = f'{key}:{user_id}'
299
+ if cache_key in _at_names:
300
+ return _at_names[cache_key]
301
+ name = ''
302
+ try:
303
+ _, kind, chat_id = split_chat_key_parts(key)
304
+ if kind == KIND_GROUP:
305
+ info = await asyncio.wait_for(
306
+ bot.call_api(
307
+ 'get_group_member_info',
308
+ group_id=int(chat_id),
309
+ user_id=int(user_id),
310
+ no_cache=True,
311
+ ),
312
+ timeout=_cfg.botui_api_timeout,
313
+ )
314
+ if isinstance(info, dict):
315
+ name = str(info.get('card') or info.get('nickname') or '')
316
+ except Exception as e:
317
+ logger.debug(f'BotUI: cannot resolve name of {user_id}: {e}')
318
+ return ''
319
+ if name:
320
+ if len(_at_names) > _AT_NAME_LIMIT:
321
+ _at_names.clear()
322
+ _at_names[cache_key] = name
323
+ return name
324
+
325
+
326
+ # ── 发出的消息 ──────────────────────────────────────────────────────────
327
+ def register_hooks() -> None:
328
+ """注册发送相关钩子(由插件在启动时调用)"""
329
+ Bot.on_calling_api(_on_calling_api)
330
+ Bot.on_called_api(_on_called_api)
331
+
332
+
333
+ def _extract_target(
334
+ api: str, data: dict[str, Any], self_id: str
335
+ ) -> tuple[str, str] | None:
336
+ """从调用参数里推断发送目标,返回 (会话key, 目标id);认不出就返回 None。
337
+
338
+ 各适配器的参数形状差别很大,这里只认明确表示会话的字段。**不要**去猜
339
+ 「名字里有 group 就随便挑一个数字字段」:``upload_group_file`` 这类接口
340
+ 根本没有会话参数,猜出来的会是文件名或文件夹序号,于是聊天列表里凭空
341
+ 多出一堆幽灵会话。
342
+ """
343
+ group_id = data.get('group_id')
344
+ user_id = data.get('user_id')
345
+ channel_id = data.get('channel_id') or data.get('guild_id')
346
+
347
+ if group_id not in (None, ''):
348
+ return chat_key(KIND_GROUP, str(group_id), self_id), str(group_id)
349
+ if channel_id not in (None, ''):
350
+ return chat_key(KIND_GROUP, str(channel_id), self_id), str(channel_id)
351
+ if user_id not in (None, ''):
352
+ return chat_key(KIND_PRIVATE, str(user_id), self_id), str(user_id)
353
+ return None
354
+
355
+
356
+ async def _on_calling_api(bot: Bot, api: str, data: dict[str, Any]) -> None:
357
+ if _store is None or _cfg is None or not _store.ready:
358
+ return
359
+ if not _cfg.botui_capture_sent:
360
+ return
361
+ if _sent_suppressed():
362
+ return
363
+ from .config import SEND_APIS
364
+
365
+ if api not in SEND_APIS:
366
+ return
367
+ adapter = bot.adapter.get_name()
368
+ if not _enabled_for(adapter):
369
+ return
370
+ try:
371
+ self_id = str(getattr(bot, 'self_id', '') or '')
372
+ target = _extract_target(api, data, self_id)
373
+ if target is None:
374
+ logger.debug(f'BotUI: cannot infer target for api {api}, skip')
375
+ return
376
+ key, target_id = target
377
+ _, kind, _ = split_chat_key_parts(key)
378
+ record = MessageRecord(
379
+ chat_key=key,
380
+ chat_kind=kind,
381
+ chat_id=target_id,
382
+ direction=DIR_OUT,
383
+ ts=now_ts(),
384
+ adapter=adapter,
385
+ self_id=self_id,
386
+ user_id=self_id,
387
+ user_name=NICKNAME or self_id,
388
+ is_self=True,
389
+ recallable=True,
390
+ api=api,
391
+ )
392
+ cached = _store.chat(key)
393
+ if cached is not None:
394
+ record.chat_name = cached.name
395
+ record.chat_avatar = cached.avatar
396
+ record.scope = cached.scope
397
+ record.parent_id = cached.parent_id
398
+ record.member_count = cached.member_count
399
+ message = data.get('message')
400
+ if message is not None:
401
+ record.segments = _segments_of_payload(message, bot)
402
+ record.text = _text_of_payload(message)
403
+ else:
404
+ # 文件/合并转发等没有 message 字段的接口
405
+ name = str(data.get('name') or data.get('file') or '')
406
+ record.segments = [
407
+ {'type': 'file', 'name': name or '文件', 'url': '', 'file': name}
408
+ ]
409
+ if len(_pending_sends) > _PENDING_LIMIT:
410
+ _pending_sends.clear()
411
+ _pending_sends[id(data)] = record
412
+ except Exception as e:
413
+ logger.opt(exception=True).warning(f'BotUI failed to prepare sent message: {e}')
414
+
415
+
416
+ def _message_class_of(payload: list[Any]) -> type[Any] | None:
417
+ """从列表里的消息段推出该适配器的 ``Message`` 类,推不出返回 None。
418
+
419
+ ``get_message_class()`` 是 ``MessageSegment`` 上的 classmethod(alconna 里
420
+ 也是这么用的:``message.get_message_class()``),``Adapter`` 上并没有这个
421
+ 方法,所以只能从消息段本身问。
422
+ """
423
+ from nonebot.adapters import Message as BaseMessage
424
+
425
+ for item in payload:
426
+ getter = getattr(item, 'get_message_class', None)
427
+ if not callable(getter):
428
+ continue
429
+ try:
430
+ cls = getter()
431
+ except Exception:
432
+ continue
433
+ if isinstance(cls, type) and issubclass(cls, BaseMessage):
434
+ return cls
435
+ return None
436
+
437
+
438
+ def _segments_of_payload(message: Any, bot: Bot) -> list[dict[str, Any]]:
439
+ """把待发送的消息内容转成消息段"""
440
+ from nonebot.adapters import Message as BaseMessage
441
+ from nonebot.adapters import MessageSegment
442
+
443
+ # 注意:适配器的 Message 本身是 list 的子类,所以必须先判断 MessageSegment,
444
+ # 再判断 Message,最后才轮到 list,否则会被当成普通列表处理。
445
+ if isinstance(message, MessageSegment):
446
+ return to_segments([message], adapter=_adapter_name(bot))
447
+ if isinstance(message, BaseMessage):
448
+ return to_segments(message, bot=bot)
449
+ if isinstance(message, str):
450
+ return [{'type': 'text', 'text': message}]
451
+ if isinstance(message, list):
452
+ message_class = _message_class_of(message)
453
+ if message_class is not None:
454
+ try:
455
+ return to_segments(message_class(message), bot=bot)
456
+ except Exception:
457
+ pass
458
+ return [{'type': 'unknown', 'raw': str(message)[:500]}]
459
+
460
+
461
+ def _adapter_name(bot: Bot) -> str | None:
462
+ try:
463
+ return bot.adapter.get_name()
464
+ except Exception:
465
+ return None
466
+
467
+
468
+ def _text_of_payload(message: Any) -> str:
469
+ """取出待发送内容里的纯文本(用于聊天列表预览)"""
470
+ from nonebot.adapters import Message as BaseMessage
471
+
472
+ if isinstance(message, str):
473
+ return message[:4000]
474
+ if isinstance(message, BaseMessage):
475
+ return extract_text(message)
476
+ if isinstance(message, list):
477
+ parts = [
478
+ str(getattr(seg, 'data', {}).get('text', ''))
479
+ for seg in message
480
+ if getattr(seg, 'type', '') == 'text'
481
+ ]
482
+ return ''.join(parts).strip()[:4000]
483
+ return ''
484
+
485
+
486
+ async def _on_called_api(
487
+ bot: Bot, exception: Exception | None, api: str, data: dict[str, Any], result: Any
488
+ ) -> None:
489
+ record = _pending_sends.pop(id(data), None)
490
+ if record is None:
491
+ return
492
+ if exception is not None:
493
+ logger.debug(f'BotUI: api {api} failed, message not recorded: {exception}')
494
+ return
495
+ try:
496
+ record.message_id = _result_message_id(result)
497
+ if not record.segments:
498
+ record.segments = []
499
+ if _cfg is not None and _cfg.botui_debug:
500
+ logger.debug(
501
+ f'BotUI captured sent [{record.chat_key}]: {record.preview[:80]!r}'
502
+ )
503
+ _persist(record)
504
+ except Exception as e:
505
+ logger.opt(exception=True).warning(f'BotUI failed to record sent message: {e}')
506
+
507
+
508
+ def _result_message_id(result: Any) -> str:
509
+ if isinstance(result, dict):
510
+ for attr in ('message_id', 'msg_id', 'id'):
511
+ value = result.get(attr)
512
+ if value not in (None, ''):
513
+ return str(value)
514
+ if isinstance(result, (str, int)):
515
+ return str(result)
516
+ for attr in ('message_id', 'msg_id', 'id'):
517
+ value = getattr(result, attr, None)
518
+ if value not in (None, ''):
519
+ return str(value)
520
+ return ''
521
+
522
+
523
+ # ── 供 WebUI 使用的构造逻辑 ─────────────────────────────────────────────
524
+ def build_outgoing(
525
+ chat: ChatRecord,
526
+ segments: list[dict[str, Any]],
527
+ text: str,
528
+ self_id: str = '',
529
+ adapter: str = '',
530
+ scope: str = '',
531
+ ) -> MessageRecord:
532
+ """根据 WebUI 的发送请求构造一条“发出”记录"""
533
+ kind = chat.kind or (
534
+ KIND_GROUP if split_chat_key_parts(chat.key)[1] == KIND_GROUP else KIND_PRIVATE
535
+ )
536
+ bot_id = self_id or chat.self_id or self_id_of(chat.key)
537
+ resolved_key = chat.key or chat_key(kind, chat.chat_id, bot_id)
538
+ name = NICKNAME or bot_id or 'BOT'
539
+ return MessageRecord(
540
+ chat_key=resolved_key,
541
+ chat_kind=kind,
542
+ chat_id=chat.chat_id,
543
+ direction=DIR_OUT,
544
+ ts=now_ts(),
545
+ adapter=adapter or chat.adapter,
546
+ scope=scope or chat.scope,
547
+ self_id=bot_id,
548
+ parent_id=chat.parent_id,
549
+ chat_name=chat.name,
550
+ chat_avatar=chat.avatar,
551
+ member_count=chat.member_count,
552
+ user_id=bot_id,
553
+ user_name=name,
554
+ is_self=True,
555
+ text=text,
556
+ segments=segments,
557
+ recallable=True,
558
+ api='webui',
559
+ )
560
+
561
+
562
+ __all__ = [
563
+ 'avatar_of',
564
+ 'build_outgoing',
565
+ 'describe_segments',
566
+ 'register_hooks',
567
+ 'resolve_at_name',
568
+ 'setup',
569
+ 'store_ref',
570
+ ]