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,900 @@
1
+ """把通用消息序列(uniseg)转成可存储、可序列化的消息段。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+ from urllib.parse import unquote, urlsplit, unquote_plus
7
+
8
+ from nonebot import logger
9
+
10
+ MAX_TEXT = 4000
11
+ MAX_RAW = 2000
12
+ # 单条合并转发最多展开的节点数(防止恶意超长转发把响应撑爆)
13
+ MAX_FORWARD_NODES = 200
14
+
15
+ # 常见的卡片类原始段类型
16
+ _HYPER_TYPES = {'json', 'xml'}
17
+
18
+ # 各适配器对语音/媒体段的原始命名不一,统一映射到通用段类型。
19
+ # 例如 OneBot V11 用 ``record`` 表示语音。
20
+ _RAW_MEDIA_TYPES = {
21
+ 'record': 'voice',
22
+ 'voice': 'voice',
23
+ 'audio': 'audio',
24
+ 'video': 'video',
25
+ 'file': 'file',
26
+ }
27
+
28
+
29
+ def _clip(value: Any, limit: int = MAX_RAW) -> str:
30
+ text = '' if value is None else str(value)
31
+ return text[:limit]
32
+
33
+
34
+ # 各种适配器/通用段把「真实文件名」放在占位名里表示「没拿到」,见到就当没有。
35
+ _PLACEHOLDER_NAMES = frozenset(
36
+ {
37
+ 'file.bin',
38
+ 'file',
39
+ 'files',
40
+ 'media',
41
+ 'media.bin',
42
+ 'image',
43
+ 'image.png',
44
+ 'audio.mp3',
45
+ 'voice.wav',
46
+ 'video.mp4',
47
+ }
48
+ )
49
+
50
+ # 文件名里不能出现的字符(保留中文等非 ASCII 字符)
51
+ _BAD_NAME_CHARS = set('\\/:*?"<>|\r\n\t')
52
+
53
+ # URL 查询串里可能带文件名的参数名(不区分大小写)
54
+ _NAME_QUERY_KEYS = (
55
+ 'fname',
56
+ 'filename',
57
+ 'file_name',
58
+ 'name',
59
+ 'realname',
60
+ 'real_name',
61
+ 'displayname',
62
+ 'fn',
63
+ )
64
+
65
+
66
+ def _clean_file_name(value: Any) -> str:
67
+ """把候选文件名清洗成可以直接展示/写进响应头的字符串。
68
+
69
+ QQ 的下载链接经常写成 ``404+-+页面未找到.html``(空格被编成 ``+``),
70
+ 这里顺手把加号还原成空格;路径分隔符等非法字符一并剔掉。
71
+ """
72
+ if value is None:
73
+ return ''
74
+ text = unquote_plus(unquote(str(value))).strip()
75
+ if text.endswith('"') and text.startswith('"'):
76
+ text = text[1:-1].strip()
77
+ text = ''.join(ch for ch in text if ch not in _BAD_NAME_CHARS)
78
+ text = text.strip().strip('.')
79
+ return text[:MAX_RAW]
80
+
81
+
82
+ def _placeholder_name(name: Any) -> bool:
83
+ """判断这个名字是不是通用段给的占位名(file.bin / media 之类)。"""
84
+ text = _clean_file_name(name)
85
+ if not text:
86
+ return True
87
+ return text.strip().lower() in _PLACEHOLDER_NAMES
88
+
89
+
90
+ def _name_from_url(url: Any) -> str:
91
+ """从下载直链里还原真实文件名。
92
+
93
+ 很多适配器(尤以 QQ 官方机器人适配器为典型)的 ``File`` 段只带一个
94
+ ``url``,``name`` 一直是默认的 ``file.bin``;而真实文件名就藏在链接里:
95
+
96
+ - 查询串:``?fname=404+-+页面未找到.html``、``?filename=报表.xlsx``
97
+ - 路径段:``.../a1b2c3/报告.pdf``、``.../download/photo.jpg``
98
+ - 响应头:``Content-Disposition: ... filename="报表.xlsx"``
99
+
100
+ 这里按「查询串 → 路径末段」的顺序猜,取不到就返回空串。
101
+ """
102
+ text = _clip(url, 4096).strip()
103
+ if not text:
104
+ return ''
105
+ if '://' not in text:
106
+ text = 'http://' + text
107
+ try:
108
+ parts = urlsplit(text)
109
+ except ValueError:
110
+ return ''
111
+
112
+ lowered = {k.lower(): v for k, v in _parse_query(parts.query).items()}
113
+ for key in _NAME_QUERY_KEYS:
114
+ if key in lowered:
115
+ candidate = _clean_file_name(lowered[key])
116
+ if candidate and not _placeholder_name(candidate):
117
+ return candidate
118
+
119
+ decoded_path = unquote(parts.path)
120
+ last = decoded_path.rstrip('/').rsplit('/', 1)[-1]
121
+ candidate = _clean_file_name(last)
122
+ if candidate and not _placeholder_name(candidate) and '.' in candidate:
123
+ return candidate
124
+ return ''
125
+
126
+
127
+ def _parse_query(query: str) -> dict[str, str]:
128
+ """解析查询串(不依赖 parse_qs,避免对空值/重复键的特殊处理)。"""
129
+ out: dict[str, str] = {}
130
+ for item in query.split('&'):
131
+ if not item:
132
+ continue
133
+ key, _, value = item.partition('=')
134
+ if key and key not in out:
135
+ out[key] = value
136
+ return out
137
+
138
+
139
+ def resolve_file_name(raw_name: Any, url: Any, fallback: str = '文件') -> str:
140
+ """给出一个尽量真实的文件名:段里的候选名优先,不行再从链接里猜。"""
141
+ candidate = _clean_file_name(raw_name)
142
+ if candidate and not _placeholder_name(candidate):
143
+ return candidate
144
+ from_url = _name_from_url(url)
145
+ if from_url:
146
+ return from_url
147
+ # 段里给的是 file.bin 这类占位名时,宁可显示兜底名也不要露出占位名
148
+ return fallback
149
+
150
+
151
+ def guess_mime(url: Any) -> str | None:
152
+ """按链接里的文件名猜 MIME,供界面挑预览方式(拿不到就返回 None)。"""
153
+ name = _name_from_url(url)
154
+ if not name or '.' not in name:
155
+ return None
156
+ suffix = '.' + name.rsplit('.', 1)[-1].lower()
157
+ try:
158
+ import mimetypes
159
+
160
+ mime, _ = mimetypes.guess_type('x' + suffix)
161
+ except Exception: # pragma: no cover - mimetypes 极少出错
162
+ return None
163
+ return mime
164
+
165
+
166
+ def _seg_media_dict(stype: str, url: str = '', **extra: Any) -> dict[str, Any]:
167
+ """构造语音/音频/视频段的统一形状。"""
168
+ item: dict[str, Any] = {'type': stype, 'url': url}
169
+ item.update(extra)
170
+ return item
171
+
172
+
173
+ def _seg_image(seg: Any) -> dict[str, Any]:
174
+ return {
175
+ 'type': 'image',
176
+ # 优先用 url;只有 file 时也能显示(可能是本地路径或 base64)
177
+ 'url': _clip(
178
+ getattr(seg, 'url', None)
179
+ or getattr(seg, 'path', None)
180
+ or getattr(seg, 'file', None)
181
+ ),
182
+ 'file': _clip(getattr(seg, 'file', None) or getattr(seg, 'name', None)),
183
+ 'name': _clip(getattr(seg, 'name', None)) or None,
184
+ 'width': getattr(seg, 'width', None),
185
+ 'height': getattr(seg, 'height', None),
186
+ }
187
+
188
+
189
+ def _seg_media(seg: Any, stype: str) -> dict[str, Any]:
190
+ url = _clip(
191
+ getattr(seg, 'url', None)
192
+ or getattr(seg, 'path', None)
193
+ or getattr(seg, 'file', None)
194
+ )
195
+ return _seg_media_dict(
196
+ stype,
197
+ url,
198
+ file=_clip(getattr(seg, 'file', None) or getattr(seg, 'name', None)),
199
+ # QQ 适配器的音频/视频 name 也恒为 audio.mp3 / video.mp4,
200
+ # 同样从链接里还原真实文件名
201
+ name=resolve_file_name(getattr(seg, 'name', None), url, fallback='') or None,
202
+ mime=_clip(getattr(seg, 'mimetype', None)) or guess_mime(url),
203
+ duration=getattr(seg, 'duration', None),
204
+ )
205
+
206
+
207
+ def _seg_at(seg: Any) -> dict[str, Any] | None:
208
+ flag = str(getattr(seg, 'flag', 'user') or 'user')
209
+ target = _clip(getattr(seg, 'target', ''))
210
+ display = _clip(getattr(seg, 'display', None))
211
+ if flag == 'user' and target.lower() in {'all', 'everyone'}:
212
+ return {'type': 'at', 'target': 'all', 'name': '全体成员'}
213
+ if flag == 'role':
214
+ return {'type': 'at', 'target': f'role:{target}', 'name': display or '某身份组'}
215
+ if flag == 'channel':
216
+ return {
217
+ 'type': 'at',
218
+ 'target': f'channel:{target}',
219
+ 'name': display or '某子频道',
220
+ }
221
+ return {'type': 'at', 'target': target, 'name': display or None}
222
+
223
+
224
+ def _flatten_text(value: Any) -> str:
225
+ """把各种「消息内容」压平成一行文本。
226
+
227
+ 引用消息里带的内容可能是纯字符串、适配器消息段列表(``Message`` 就是
228
+ ``list`` 的子类)、uniseg 段列表,或者是一整个消息对象。这里全部兜住,
229
+ 拿不到就返回空串。
230
+ """
231
+ if value is None:
232
+ return ''
233
+ if isinstance(value, str):
234
+ return value
235
+ if isinstance(value, dict):
236
+ return _flatten_text(value.get('message') or value.get('content'))
237
+ if isinstance(value, (list, tuple)):
238
+ parts: list[str] = []
239
+ for item in value:
240
+ if isinstance(item, dict):
241
+ data = item.get('data')
242
+ parts.append(
243
+ _flatten_text(data.get('text') if isinstance(data, dict) else None)
244
+ or _flatten_text(item.get('text'))
245
+ )
246
+ continue
247
+ data = getattr(item, 'data', None)
248
+ if isinstance(data, dict):
249
+ # 适配器消息段
250
+ parts.append(_flatten_text(data.get('text')))
251
+ continue
252
+ text = getattr(item, 'text', None)
253
+ parts.append(text if isinstance(text, str) else '')
254
+ return ''.join(parts)
255
+ text = getattr(value, 'text', None)
256
+ if isinstance(text, str):
257
+ return text
258
+ try:
259
+ return str(value.extract_plain_text())
260
+ except Exception:
261
+ return ''
262
+
263
+
264
+ def _reply_payload(source: Any) -> dict[str, Any]:
265
+ """把引用来源归一化成字典,方便统一取 sender / message。
266
+
267
+ ``source`` 形态多变:
268
+
269
+ - ``UniMessage.of`` 直接构造时 ``origin`` 是适配器消息段,数据在 ``.data``;
270
+ - ``attach_reply`` 从事件抽取时 ``origin`` 是适配器引用模型,取 ``model_dump``;
271
+ - 也可能本来就是 dict。
272
+ """
273
+ if source is None:
274
+ return {}
275
+ if isinstance(source, dict):
276
+ return source
277
+ data = getattr(source, 'data', None)
278
+ if isinstance(data, dict):
279
+ return data
280
+ for attr in ('model_dump', 'dict'):
281
+ dump = getattr(source, attr, None)
282
+ if not callable(dump):
283
+ continue
284
+ try:
285
+ result = dump()
286
+ except Exception: # pragma: no cover - 防御性
287
+ continue
288
+ if isinstance(result, dict):
289
+ return result
290
+ return {}
291
+
292
+
293
+ def _reply_identity(source: Any) -> tuple[str, str]:
294
+ """从引用来源里取「被回复的人」,返回 ``(user_id, name)``。"""
295
+ payload = _reply_payload(source)
296
+ sender = payload.get('sender')
297
+ if isinstance(sender, dict):
298
+ name = sender.get('card') or sender.get('nickname') or sender.get('name') or ''
299
+ uid = sender.get('user_id') or sender.get('id') or ''
300
+ return _clip(uid), _clip(name)
301
+ uid = payload.get('user_id') or payload.get('uin') or ''
302
+ name = payload.get('nickname') or payload.get('name') or ''
303
+ return _clip(uid), _clip(name)
304
+
305
+
306
+ def _reply_dict(
307
+ reply_id: Any,
308
+ *,
309
+ text: str = '',
310
+ uid: str = '',
311
+ name: str = '',
312
+ ) -> dict[str, Any]:
313
+ """构造统一形状的 reply 段,空字段直接省略。
314
+
315
+ ``segment_to_dict`` 与 ``_fallback_segments`` 两条路径都走这里,免得
316
+ 「字段怎么拼」的逻辑散落两处、改一处漏一处。
317
+ """
318
+ item: dict[str, Any] = {'type': 'reply', 'id': _clip(reply_id)}
319
+ if uid:
320
+ item['user_id'] = _clip(uid)
321
+ if name:
322
+ item['name'] = _clip(name)
323
+ if text:
324
+ item['text'] = text[:MAX_TEXT]
325
+ item['preview'] = text[:120]
326
+ return {k: v for k, v in item.items() if v not in ('', None)}
327
+
328
+
329
+ def _seg_reply(seg: Any) -> dict[str, Any]:
330
+ """``Reply`` 段。
331
+
332
+ 只存一个 id 的话界面上只能显示「回复 12345」,看不出回复的是什么。uniseg
333
+ 在 ``UniMessage.attach_reply`` 之后其实已经把引用消息备好了:
334
+
335
+ - 被引用正文在 ``seg.msg`` 上(官方字段,直接展开即可);
336
+ - 被引用者 ``sender`` 在原始引用对象 ``seg.origin`` 上(uniseg 的 ``Reply``
337
+ 段本身不暴露 sender,只有 origin 里有)。
338
+
339
+ 有些适配器会把 sender / message 直接塞进 reply 段数据里,``origin`` 那份
340
+ 也一并兜住。两者都拿不到时只留 id,由前端退化成「点击定位」。
341
+ """
342
+ origin = getattr(seg, 'origin', None)
343
+ payload = _reply_payload(origin)
344
+ text = _flatten_text(getattr(seg, 'msg', None)) or _flatten_text(
345
+ payload.get('message') or payload.get('content') or payload.get('text')
346
+ )
347
+ uid, name = _reply_identity(origin)
348
+ return _reply_dict(getattr(seg, 'id', ''), text=text, uid=uid, name=name)
349
+
350
+
351
+ def _seg_emoji(seg: Any) -> dict[str, Any]:
352
+ """``Emoji`` 段。``url`` 是官方字段(部分平台的表情是图片),有就一起存。"""
353
+ return {
354
+ 'type': 'face',
355
+ 'id': _clip(getattr(seg, 'id', '')),
356
+ 'name': _clip(getattr(seg, 'name', None)) or None,
357
+ 'url': _clip(getattr(seg, 'url', None)) or None,
358
+ }
359
+
360
+
361
+ def _seg_button(seg: Any) -> dict[str, Any]:
362
+ """``Button`` / ``Keyboard`` 段。
363
+
364
+ uniseg 里 ``Keyboard`` 是「一行按钮」,``Button`` 是单个按钮。这里统一压成
365
+ 一条 ``button`` 段:保留可见文字与行为类型,界面上给个「[按钮]」占位,
366
+ 至少不会整段凭空消失。
367
+ """
368
+ from nonebot_plugin_alconna.uniseg import Button, Keyboard
369
+
370
+ if isinstance(seg, Keyboard):
371
+ labels = [
372
+ _clip(getattr(b.label, 'text', b.label))
373
+ for b in getattr(seg, 'children', [])
374
+ ]
375
+ label = ' / '.join(x for x in labels if x)
376
+ return {'type': 'button', 'label': label or None, 'flag': 'keyboard'}
377
+
378
+ label = getattr(seg, 'label', None)
379
+ if isinstance(label, Button): # pragma: no cover - 防御性
380
+ label = None
381
+ return {
382
+ 'type': 'button',
383
+ 'label': _clip(getattr(label, 'text', label)) or None,
384
+ 'flag': str(getattr(seg, 'flag', '') or ''),
385
+ 'url': _clip(getattr(seg, 'url', None)) or None,
386
+ }
387
+
388
+
389
+ def _seg_reference(seg: Any) -> dict[str, Any]:
390
+ """``Reference`` / ``Forward`` 段。
391
+
392
+ 部分适配器(Satori 等)会把转发内容直接挂在 ``children`` 上,那就顺手
393
+ 内联存下来,前端不用再调接口;OneBot V11 只有 ``id``,由前端按需拉取。
394
+ """
395
+ item: dict[str, Any] = {
396
+ 'type': 'forward',
397
+ 'id': _clip(getattr(seg, 'id', None)),
398
+ 'count': None,
399
+ }
400
+ nodes = _reference_nodes(seg)
401
+ if nodes:
402
+ item['nodes'] = nodes
403
+ item['count'] = len(nodes)
404
+ return item
405
+
406
+
407
+ def _reference_nodes(seg: Any) -> list[dict[str, Any]]:
408
+ """把 Reference 已带的子节点转成统一形状(拿不到就返回空列表)。"""
409
+ try:
410
+ from nonebot_plugin_alconna.uniseg import CustomNode
411
+ except Exception: # pragma: no cover - 防御性
412
+ return []
413
+ out: list[dict[str, Any]] = []
414
+ for child in list(getattr(seg, 'children', []) or [])[:MAX_FORWARD_NODES]:
415
+ if not isinstance(child, CustomNode):
416
+ continue
417
+ segments = _node_segments(child.content)
418
+ if not segments:
419
+ continue
420
+ out.append(
421
+ {
422
+ 'name': _clip(child.name),
423
+ 'user_id': _clip(child.uid),
424
+ 'time': child.time.timestamp() if child.time else 0.0,
425
+ 'segments': segments,
426
+ }
427
+ )
428
+ return out
429
+
430
+
431
+ def _node_segments(content: Any) -> list[dict[str, Any]]:
432
+ """把转发节点的内容(str / UniMessage / 段列表)转成消息段列表。"""
433
+ if content is None:
434
+ return []
435
+ if isinstance(content, str):
436
+ return [{'type': 'text', 'text': content[:MAX_TEXT]}] if content else []
437
+ try:
438
+ from nonebot_plugin_alconna.uniseg import UniMessage
439
+
440
+ if isinstance(content, UniMessage):
441
+ return _convert(content)
442
+ except Exception: # pragma: no cover - 防御性
443
+ pass
444
+ return _fallback_segments(content)
445
+
446
+
447
+ def _seg_other(seg: Any) -> dict[str, Any]:
448
+ """适配器私有消息段:尽量保留原始信息,方便前端兜底展示。"""
449
+ origin = getattr(seg, 'origin', None)
450
+ raw_type = ''
451
+ data: dict[str, Any] = {}
452
+ if origin is not None:
453
+ raw_type = str(getattr(origin, 'type', '') or '')
454
+ raw_data = getattr(origin, 'data', None)
455
+ if isinstance(raw_data, dict):
456
+ data = {str(k): _clip(v) for k, v in raw_data.items()}
457
+ stype = (
458
+ raw_type
459
+ if raw_type in _HYPER_TYPES
460
+ else ('unknown' if not raw_type else raw_type)
461
+ )
462
+ item: dict[str, Any] = {'type': stype, 'raw': data or _clip(origin)}
463
+ if raw_type == 'json':
464
+ item['data'] = data.get('data', '')
465
+ return item
466
+
467
+
468
+ def segment_to_dict(seg: Any) -> dict[str, Any] | None:
469
+ """把单个通用消息段转成字典;无法识别的返回 None。
470
+
471
+ 覆盖 uniseg 文档里列出的段类型(见 ``docs/.../uniseg/segment.md``):
472
+ ``Text`` / ``At`` / ``AtAll`` / ``Emoji`` / ``Image`` / ``Audio`` /
473
+ ``Voice`` / ``Video`` / ``File`` / ``Reply`` / ``Reference`` / ``Hyper`` /
474
+ ``Button`` / ``Keyboard``,以及适配器私有的 ``Other`` 兜底。
475
+ """
476
+ # 延迟导入,避免与 alconna 的循环依赖
477
+ from nonebot_plugin_alconna.uniseg import (
478
+ At,
479
+ File,
480
+ Text,
481
+ AtAll,
482
+ Audio,
483
+ Emoji,
484
+ Hyper,
485
+ Image,
486
+ Other,
487
+ Reply,
488
+ Video,
489
+ Voice,
490
+ Button,
491
+ Keyboard,
492
+ Reference,
493
+ )
494
+
495
+ try:
496
+ if isinstance(seg, Text):
497
+ text = str(getattr(seg, 'text', '') or '')
498
+ return {'type': 'text', 'text': text[:MAX_TEXT]} if text else None
499
+ if isinstance(seg, AtAll):
500
+ return {'type': 'at', 'target': 'all', 'name': '全体成员'}
501
+ if isinstance(seg, At):
502
+ return _seg_at(seg)
503
+ if isinstance(seg, Image):
504
+ return _seg_image(seg)
505
+ if isinstance(seg, (Voice, Video, Audio)):
506
+ stype = str(getattr(seg, 'type', 'voice') or 'voice')
507
+ return _seg_media(seg, stype)
508
+ if isinstance(seg, File):
509
+ url = _clip(
510
+ getattr(seg, 'url', None)
511
+ or getattr(seg, 'path', None)
512
+ or getattr(seg, 'file', None)
513
+ )
514
+ return {
515
+ 'type': 'file',
516
+ # QQ 机器人适配器只给 url、name 恒为 file.bin,真实文件名在链接里
517
+ 'name': resolve_file_name(getattr(seg, 'name', None), url),
518
+ 'url': url,
519
+ 'file': _clip(getattr(seg, 'file', None)),
520
+ 'mime': _clip(getattr(seg, 'mimetype', None)) or guess_mime(url),
521
+ 'size': getattr(seg, 'size', None) or None,
522
+ }
523
+ if isinstance(seg, Reply):
524
+ return _seg_reply(seg)
525
+ if isinstance(seg, Reference):
526
+ return _seg_reference(seg)
527
+ if isinstance(seg, Emoji):
528
+ return _seg_emoji(seg)
529
+ if isinstance(seg, (Button, Keyboard)):
530
+ return _seg_button(seg)
531
+ if isinstance(seg, Hyper):
532
+ raw = _clip(getattr(seg, 'raw', None))
533
+ content = getattr(seg, 'content', None)
534
+ return {
535
+ 'type': str(getattr(seg, 'format', 'json') or 'json'),
536
+ 'data': raw or _clip(content),
537
+ }
538
+ if isinstance(seg, Other):
539
+ return _seg_other(seg)
540
+ except Exception as e: # pragma: no cover - 防御性兜底
541
+ logger.opt(exception=True).debug(f'Failed to convert segment {seg!r}: {e}')
542
+ return None
543
+ return None
544
+
545
+
546
+ def _convert(uni: Any) -> list[dict[str, Any]]:
547
+ """把 ``UniMessage`` 逐段转成字典,转不出来的段直接跳过。"""
548
+ segments: list[dict[str, Any]] = []
549
+ for seg in uni:
550
+ item = segment_to_dict(seg)
551
+ if item:
552
+ segments.append(item)
553
+ return segments
554
+
555
+
556
+ def _build_uni(message: Any, bot: Any, adapter: str | None) -> Any:
557
+ """构造 ``UniMessage``;拿不到就返回 None,由调用方走兜底路径。"""
558
+ from nonebot_plugin_alconna.uniseg import UniMessage
559
+
560
+ return UniMessage.of(message, bot=bot, adapter=adapter)
561
+
562
+
563
+ def to_segments(
564
+ message: Any,
565
+ bot: Any = None,
566
+ adapter: str | None = None,
567
+ ) -> list[dict[str, Any]]:
568
+ """把适配器消息转换成通用消息段列表。
569
+
570
+ 优先使用 alconna 的跨平台转换;失败时退化为通用兜底。
571
+ 没有 bot 时可以通过 ``adapter`` 指定适配器名称(例如 ``'OneBot V11'``)。
572
+
573
+ 注意:多数适配器的 reply 段只带一个消息 id,被引用的「谁、什么内容」挂在
574
+ 事件上。要拿全引用信息请用 :func:`to_segments_async` 并传入 ``event``。
575
+ """
576
+ try:
577
+ segments = _convert(_build_uni(message, bot, adapter))
578
+ except Exception as e:
579
+ logger.debug(f'UniMessage.of failed ({e!r}), falling back to raw conversion')
580
+ segments = []
581
+ return segments or _fallback_segments(message)
582
+
583
+
584
+ async def to_segments_async(
585
+ message: Any,
586
+ bot: Any = None,
587
+ adapter: str | None = None,
588
+ event: Any = None,
589
+ ) -> list[dict[str, Any]]:
590
+ """异步版 :func:`to_segments`。
591
+
592
+ 多了一步 ``UniMessage.attach_reply``:reply 段本身只有一个 id,被引用消息
593
+ 的发送者与内容在事件里,需要异步抽取。拿不到事件时行为与同步版一致。
594
+ """
595
+ if event is None:
596
+ return to_segments(message, bot=bot, adapter=adapter)
597
+
598
+ try:
599
+ uni = _build_uni(message, bot, adapter)
600
+ try:
601
+ await uni.attach_reply(event, bot)
602
+ except Exception as e:
603
+ logger.debug(f'UniMessage.attach_reply failed ({e!r})')
604
+ segments = _convert(uni)
605
+ except Exception as e:
606
+ logger.debug(f'UniMessage.of failed ({e!r}), falling back to raw conversion')
607
+ segments = []
608
+ if not segments:
609
+ segments = _fallback_segments(message)
610
+ # 兜底:attach_reply 没拿到(或适配器不支持)时,自己从事件上补一次。
611
+ _fill_reply_from_event(segments, event)
612
+ return segments
613
+
614
+
615
+ def _event_reply(event: Any) -> Any:
616
+ """从事件上取引用对象:优先 ``reply`` 属性,退回 ``get_reply()``。"""
617
+ reply = getattr(event, 'reply', None)
618
+ if reply is not None:
619
+ return reply
620
+ getter = getattr(event, 'get_reply', None)
621
+ if not callable(getter):
622
+ return None
623
+ try:
624
+ return getter()
625
+ except Exception: # pragma: no cover - 防御性
626
+ return None
627
+
628
+
629
+ def _fill_reply_from_event(segments: list[dict[str, Any]], event: Any) -> None:
630
+ """兜底路径:把事件上的引用信息补进 reply 段。
631
+
632
+ 适配器消息段转出来的 reply 只有 id(OneBot V11 就是这种情况),这里从
633
+ ``event.reply`` / ``event.get_reply()`` 里取发送者与正文补上。仅填空缺字段,
634
+ 不覆盖已有值。
635
+ """
636
+ reply = _event_reply(event)
637
+ if reply is None:
638
+ return
639
+ target = next((s for s in segments if s.get('type') == 'reply'), None)
640
+ if target is None:
641
+ return
642
+
643
+ payload = _reply_payload(reply)
644
+ uid, name = _reply_identity(reply)
645
+ if uid and not target.get('user_id'):
646
+ target['user_id'] = uid
647
+ if name and not target.get('name'):
648
+ target['name'] = name
649
+ if not target.get('text'):
650
+ text = _flatten_text(payload.get('message') or payload.get('content'))
651
+ if text:
652
+ target['text'] = text[:MAX_TEXT]
653
+ target['preview'] = text[:120]
654
+
655
+
656
+ def _raw_type_data(raw: Any) -> tuple[str, dict[str, Any]]:
657
+ """把原始消息段归一成 ``(类型, 数据)``,兼容对象与 dict 两种形态。
658
+
659
+ ``to_segments`` 拿到的可能是适配器的 ``MessageSegment``,也可能是从
660
+ ``call_api`` 结果里剥出来的普通 dict(合并转发接口就是这种),两种都要认。
661
+ """
662
+ if isinstance(raw, dict):
663
+ rtype = str(raw.get('type') or '')
664
+ data = raw.get('data')
665
+ return rtype, data if isinstance(data, dict) else {}
666
+ rtype = str(getattr(raw, 'type', '') or '')
667
+ data = getattr(raw, 'data', None)
668
+ return rtype, data if isinstance(data, dict) else {}
669
+
670
+
671
+ def _fallback_segments(message: Any) -> list[dict[str, Any]]:
672
+ """拿不到通用消息段时,直接按原始消息段做最小转换。"""
673
+ out: list[dict[str, Any]] = []
674
+ # 传入的是单个消息段时,包一层方便统一处理
675
+ if isinstance(message, dict) or (
676
+ hasattr(message, 'type') and hasattr(message, 'data')
677
+ ):
678
+ message = [message]
679
+ try:
680
+ news = list(message)
681
+ except TypeError:
682
+ text = message if isinstance(message, str) else ''
683
+ if text:
684
+ out.append({'type': 'text', 'text': text[:MAX_TEXT]})
685
+ return out
686
+ for raw in news:
687
+ rtype, data = _raw_type_data(raw)
688
+ if rtype == 'text':
689
+ out.append({'type': 'text', 'text': _clip(data.get('text'), MAX_TEXT)})
690
+ elif rtype == 'at':
691
+ target = _clip(data.get('qq', ''))
692
+ if target.lower() in {'all', 'everyone'}:
693
+ out.append({'type': 'at', 'target': 'all', 'name': '全体成员'})
694
+ else:
695
+ out.append({'type': 'at', 'target': target, 'name': None})
696
+ elif rtype == 'image':
697
+ out.append(
698
+ {
699
+ 'type': 'image',
700
+ # OneBot V11 的图片地址可能只放在 file 字段里
701
+ 'url': _clip(data.get('url') or data.get('file') or ''),
702
+ 'file': _clip(data.get('file', '')),
703
+ 'name': None,
704
+ 'width': None,
705
+ 'height': None,
706
+ }
707
+ )
708
+ elif rtype in _RAW_MEDIA_TYPES:
709
+ # 各适配器对语音/媒体段命名不一(OneBot 用 record),统一成通用名,
710
+ # 免得前端只认 voice/video 而把 audio 段当未知段丢掉。
711
+ kind = _RAW_MEDIA_TYPES[rtype]
712
+ url = _clip(data.get('url') or data.get('file') or '')
713
+ if kind == 'file':
714
+ out.append(
715
+ {
716
+ 'type': 'file',
717
+ 'name': resolve_file_name(
718
+ data.get('name') or data.get('file'), url
719
+ ),
720
+ 'url': url,
721
+ 'file': _clip(data.get('file', '')),
722
+ 'mime': _clip(data.get('content_type') or data.get('mime'))
723
+ or guess_mime(url),
724
+ 'size': data.get('size') or None,
725
+ }
726
+ )
727
+ else:
728
+ out.append(_seg_media_dict(kind, url))
729
+ elif rtype == 'face':
730
+ out.append(
731
+ {
732
+ 'type': 'face',
733
+ 'id': _clip(data.get('id', '')),
734
+ 'name': _clip(data.get('name', '')),
735
+ }
736
+ )
737
+ elif rtype == 'reply':
738
+ # 兜底路径拿到的也是原始段:同样把 sender / message 一起带上,
739
+ # 界面上才能显示「回复 谁:什么内容」而不只是一串 id
740
+ uid, name = _reply_identity(data)
741
+ text = _flatten_text(data.get('message') or data.get('content'))
742
+ out.append(_reply_dict(data.get('id', ''), text=text, uid=uid, name=name))
743
+ elif rtype in ('forward', 'node'):
744
+ # 合并转发:段本身往往只有一个 id;若数据里带上了展开后的节点
745
+ # (部分适配器会直接给 content),就一并存下来。
746
+ item: dict[str, Any] = {
747
+ 'type': 'forward',
748
+ 'id': _clip(data.get('id') or data.get('message_id') or ''),
749
+ 'count': None,
750
+ }
751
+ nodes = parse_forward_nodes(data)
752
+ if nodes:
753
+ item['nodes'] = nodes
754
+ item['count'] = len(nodes)
755
+ out.append(item)
756
+ else:
757
+ out.append(
758
+ {
759
+ 'type': rtype or 'unknown',
760
+ 'raw': {str(k): _clip(v) for k, v in data.items()},
761
+ }
762
+ )
763
+ return out
764
+
765
+
766
+ def parse_forward_nodes(raw: Any) -> list[dict[str, Any]]:
767
+ """把合并转发的节点归一成统一形状。
768
+
769
+ 输入形态很多:``get_forward_msg`` 的整个返回值(``{'messages': [...]}``)、
770
+ 节点列表、适配器的 ``MessageSegment('node', ...)``,或者 uniseg 的
771
+ ``CustomNode``。统一输出::
772
+
773
+ [{'name': ..., 'user_id': ..., 'time': ..., 'segments': [...]}, ...]
774
+
775
+ 每个节点的正文走与普通消息相同的段转换逻辑,因此图片、文件等段在展开
776
+ 后仍然可预览。
777
+ """
778
+ out: list[dict[str, Any]] = []
779
+ for node in _iter_nodes(raw)[:MAX_FORWARD_NODES]:
780
+ if isinstance(node, dict):
781
+ candidate = node.get('data')
782
+ payload = candidate if isinstance(candidate, dict) else node
783
+ else:
784
+ data = getattr(node, 'data', None)
785
+ payload = data if isinstance(data, dict) else {}
786
+ content = payload.get('content')
787
+ if content is None:
788
+ content = payload.get('message')
789
+ item = {
790
+ 'name': _clip(payload.get('nickname') or payload.get('name') or ''),
791
+ 'user_id': _clip(payload.get('user_id') or payload.get('uin') or ''),
792
+ 'time': _safe_ts(payload.get('time') or payload.get('timestamp')),
793
+ 'segments': _node_segments(content),
794
+ }
795
+ if not item['segments'] and not item['name'] and not item['user_id']:
796
+ continue
797
+ out.append(item)
798
+ return out
799
+
800
+
801
+ def _iter_nodes(raw: Any) -> list[Any]:
802
+ """从各种「合并转发结果」形状里取出节点列表。
803
+
804
+ 注意不要把单节点的 ``content`` 当成节点容器:那是**节点内的消息内容**,
805
+ 把它当成节点列表会得到一个「把消息段当节点」的错误结果。
806
+ """
807
+ if raw is None:
808
+ return []
809
+ if isinstance(raw, dict):
810
+ for key in ('messages', 'nodes'):
811
+ value = raw.get(key)
812
+ if isinstance(value, list):
813
+ return value
814
+ # 单个节点 dict(带 content / message)
815
+ if 'content' in raw or 'message' in raw:
816
+ return [raw]
817
+ return []
818
+ if isinstance(raw, (list, tuple)):
819
+ return list(raw)
820
+ # uniseg 的 Reference / CustomNode
821
+ children = getattr(raw, 'children', None)
822
+ if isinstance(children, list):
823
+ return children
824
+ return []
825
+
826
+
827
+ def _safe_ts(value: Any) -> float:
828
+ try:
829
+ return float(value)
830
+ except (TypeError, ValueError):
831
+ return 0.0
832
+
833
+
834
+ def extract_text(message: Any) -> str:
835
+ """提取消息的纯文本内容"""
836
+ try:
837
+ return str(message.extract_plain_text()).strip()
838
+ except Exception:
839
+ try:
840
+ return ''.join(
841
+ str(getattr(seg, 'data', {}).get('text', ''))
842
+ for seg in message
843
+ if getattr(seg, 'type', '') == 'text'
844
+ ).strip()
845
+ except Exception:
846
+ return str(message)[:MAX_TEXT]
847
+
848
+
849
+ def at_targets(segments: list[dict[str, Any]]) -> list[str]:
850
+ """取出消息里 @ 的用户 id(不含全体成员)"""
851
+ out: list[str] = []
852
+ for seg in segments:
853
+ if seg.get('type') != 'at':
854
+ continue
855
+ target = str(seg.get('target') or '')
856
+ if target and target != 'all' and ':' not in target:
857
+ out.append(target)
858
+ return out
859
+
860
+
861
+ def _is_plugin_url(url: str) -> bool:
862
+ """是不是本插件自己的媒体地址(见 ``mediastore.is_plugin_url``)。
863
+
864
+ 这类地址本来就指向服务端已有的本地副本,采集时不需要重新下载;但**要交给
865
+ 媒体库登记引用**,否则自己发出的图片会因为「没被引用」而被按 ttl 早早回收。
866
+ """
867
+ from .mediastore import is_plugin_url
868
+
869
+ return is_plugin_url(url)
870
+
871
+
872
+ def cacheable_segments(
873
+ segments: list[dict[str, Any]], limit: int = 0
874
+ ) -> list[dict[str, Any]]:
875
+ """挑出值得存进媒体库的段(图片 / 文件 / 语音 / 音频 / 视频)。
876
+
877
+ 只认 **http(s) 直链**与**本插件自己的媒体地址**:``base64://`` /
878
+ ``file://`` 这类本地已有的内容不必再复制一份,而 ``file://`` 只在机器人
879
+ 本机有效、对 WebUI 也没意义。插件地址虽然不必下载,但要交给媒体库登记
880
+ 引用(见 ``ingest.py``),所以一并选出来。
881
+
882
+ ``limit > 0`` 时按顺序最多返回 ``limit`` 个 —— 媒体直链普遍只有几分钟的
883
+ 有效期,收到消息的那一刻就该把它们抓下来,但一条消息里塞几十个文件时
884
+ 也不该让采集任务跑上几分钟。
885
+ """
886
+ from .mediastore import MEDIA_TYPES
887
+
888
+ out: list[dict[str, Any]] = []
889
+ for seg in segments:
890
+ if not isinstance(seg, dict):
891
+ continue
892
+ if seg.get('type') not in MEDIA_TYPES:
893
+ continue
894
+ url = str(seg.get('url') or '').strip()
895
+ if not url.startswith(('http://', 'https://')) and not _is_plugin_url(url):
896
+ continue
897
+ out.append(seg)
898
+ if limit > 0 and len(out) >= limit:
899
+ break
900
+ return out