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.
- nonebot_plugin_botui/__init__.py +382 -0
- nonebot_plugin_botui/api.py +1683 -0
- nonebot_plugin_botui/capture.py +570 -0
- nonebot_plugin_botui/config.py +170 -0
- nonebot_plugin_botui/ingest.py +216 -0
- nonebot_plugin_botui/media.py +438 -0
- nonebot_plugin_botui/mediastore.py +954 -0
- nonebot_plugin_botui/models.py +221 -0
- nonebot_plugin_botui/paths.py +36 -0
- nonebot_plugin_botui/segments.py +900 -0
- nonebot_plugin_botui/static/app.css +1774 -0
- nonebot_plugin_botui/static/app.js +3799 -0
- nonebot_plugin_botui/static/index.html +232 -0
- nonebot_plugin_botui/store.py +1271 -0
- nonebot_plugin_botui/webui.py +386 -0
- nonebot_plugin_botui-0.1.0.dist-info/METADATA +231 -0
- nonebot_plugin_botui-0.1.0.dist-info/RECORD +18 -0
- nonebot_plugin_botui-0.1.0.dist-info/WHEEL +4 -0
|
@@ -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
|