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,1683 @@
|
|
|
1
|
+
"""WebUI 的 HTTP 接口(httpx/FastAPI 路由)。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import json
|
|
8
|
+
import time
|
|
9
|
+
import shutil
|
|
10
|
+
import asyncio
|
|
11
|
+
import secrets
|
|
12
|
+
import tempfile
|
|
13
|
+
import ipaddress
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from dataclasses import replace
|
|
17
|
+
from urllib.parse import quote, urlsplit
|
|
18
|
+
|
|
19
|
+
import nonebot
|
|
20
|
+
from fastapi import Query, Request, APIRouter, WebSocket, HTTPException
|
|
21
|
+
from nonebot import logger
|
|
22
|
+
from nonebot.adapters import Bot
|
|
23
|
+
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
|
|
24
|
+
from starlette.requests import HTTPConnection
|
|
25
|
+
|
|
26
|
+
from .config import plugin_config as cfg
|
|
27
|
+
from .models import (
|
|
28
|
+
DIR_OUT,
|
|
29
|
+
KIND_GROUP,
|
|
30
|
+
ChatRecord,
|
|
31
|
+
self_id_of,
|
|
32
|
+
split_chat_key,
|
|
33
|
+
describe_segments,
|
|
34
|
+
)
|
|
35
|
+
from .capture import resume_sent, suppress_sent, build_outgoing
|
|
36
|
+
from .segments import guess_mime, resolve_file_name
|
|
37
|
+
from .mediastore import MediaStore, url_of, safe_filename
|
|
38
|
+
|
|
39
|
+
if TYPE_CHECKING:
|
|
40
|
+
from .store import MessageStore
|
|
41
|
+
from .webui import WebUIServer
|
|
42
|
+
from .mediastore import MediaRecord
|
|
43
|
+
|
|
44
|
+
VERSION = '0.1.0'
|
|
45
|
+
|
|
46
|
+
# WebSocket 空闲心跳间隔(秒):两层作用,一是挡掉反向代理的空闲断连,
|
|
47
|
+
# 二是让服务端在下一次发送时发现已经断掉的连接。
|
|
48
|
+
WS_HEARTBEAT = 25.0
|
|
49
|
+
|
|
50
|
+
#: 一次发送最多带几个附件(多了适配器多半也发不出去,还会把请求撑得很大)
|
|
51
|
+
MAX_UPLOADS_PER_MESSAGE = 10
|
|
52
|
+
|
|
53
|
+
#: 清理媒体库时允许的模式(前端设置面板的按钮各对应一个)
|
|
54
|
+
MEDIA_CLEAR_MODES = frozenset({'all', 'orphans', 'image', 'file'})
|
|
55
|
+
|
|
56
|
+
router = APIRouter()
|
|
57
|
+
|
|
58
|
+
_store: 'MessageStore | None' = None
|
|
59
|
+
_server: 'WebUIServer | None' = None
|
|
60
|
+
_media: MediaStore | None = None
|
|
61
|
+
_token: str = ''
|
|
62
|
+
_last_send: dict[str, float] = {}
|
|
63
|
+
_static_dir = Path(__file__).parent / 'static'
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def setup(
|
|
67
|
+
store: 'MessageStore',
|
|
68
|
+
server: 'WebUIServer',
|
|
69
|
+
token: str,
|
|
70
|
+
media: MediaStore | None = None,
|
|
71
|
+
) -> None:
|
|
72
|
+
"""注入依赖(由插件在启动时调用一次)。
|
|
73
|
+
|
|
74
|
+
存储、事件服务、令牌都由这里一次性注入,而不是一半用模块变量、一半塞进
|
|
75
|
+
``app.state`` —— 两套机制混用会让「谁先谁后」变得难以推断。
|
|
76
|
+
|
|
77
|
+
``media`` 是媒体库(见 ``mediastore.py``):机器人收发的图片/文件、WebUI
|
|
78
|
+
上传的附件都放在一起。不传时按配置建一个默认实例,方便测试直接调用本模块。
|
|
79
|
+
"""
|
|
80
|
+
global _store, _server, _token, _media
|
|
81
|
+
_store = store
|
|
82
|
+
_server = server
|
|
83
|
+
_token = token
|
|
84
|
+
if media is not None:
|
|
85
|
+
_media = media
|
|
86
|
+
elif _media is None:
|
|
87
|
+
from .paths import BLOB_DIR
|
|
88
|
+
|
|
89
|
+
_media = MediaStore(
|
|
90
|
+
BLOB_DIR,
|
|
91
|
+
max_bytes=cfg.botui_media_max_bytes if cfg.botui_media_enabled else 0,
|
|
92
|
+
max_files=cfg.botui_media_max_files if cfg.botui_media_enabled else 0,
|
|
93
|
+
ttl=cfg.botui_media_ttl,
|
|
94
|
+
retention=cfg.botui_media_retention,
|
|
95
|
+
file_max_bytes=cfg.botui_media_file_max_bytes,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def auth_required() -> bool:
|
|
100
|
+
return bool(cfg.botui_auth)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ── 鉴权 ────────────────────────────────────────────────────────────────
|
|
104
|
+
#: 允许用 ``?token=`` 传令牌的**路由内相对路径**(挂载前缀由
|
|
105
|
+
#: :func:`_relative_path` 剥掉)。两类场景必须走 query:``<img>`` /
|
|
106
|
+
#: ``<a download>`` 这类浏览器自发请求带不上自定义头;WebSocket 的
|
|
107
|
+
#: ``new WebSocket(url)`` 也**无法设置请求头**。其余接口(含所有写接口)只认
|
|
108
|
+
#: ``X-BotUI-Token``,免得令牌被反向代理日志、浏览器历史或子资源请求带出去。
|
|
109
|
+
_QUERY_TOKEN_PATHS = frozenset(
|
|
110
|
+
{'/media', '/api/media', '/api/file', '/api/preview', '/api/export', '/api/ws'}
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
#: 始终允许的 ``Host``(本机名,与来源限制配套)
|
|
114
|
+
_LOCAL_HOSTS = frozenset({'localhost', '127.0.0.1', '::1', '0.0.0.0'})
|
|
115
|
+
|
|
116
|
+
#: 鉴权失败限速:同一来源在窗口内失败到这个次数后一律 429
|
|
117
|
+
_AUTH_FAIL_LIMIT = 10
|
|
118
|
+
_AUTH_WINDOW = 60.0
|
|
119
|
+
|
|
120
|
+
#: 内存里最多跟踪多少条一次性记录(鉴权失败窗口与发送限流各留一份额度)
|
|
121
|
+
_TRACK_LIMIT = 1024
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class _AuthThrottle:
|
|
125
|
+
"""按来源记录鉴权失败时间戳的滑动窗口。
|
|
126
|
+
|
|
127
|
+
同一来源在 :data:`_AUTH_WINDOW` 秒内失败到 :data:`_AUTH_FAIL_LIMIT` 次后,
|
|
128
|
+
后续请求一律 429 —— 令牌熵足够高,这层只是不给「无成本反复猜」留余地。
|
|
129
|
+
过期记录在读写时顺带清掉,字典本身再留一个条数上限。
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
def __init__(
|
|
133
|
+
self,
|
|
134
|
+
limit: int = _AUTH_FAIL_LIMIT,
|
|
135
|
+
window: float = _AUTH_WINDOW,
|
|
136
|
+
max_keys: int = _TRACK_LIMIT,
|
|
137
|
+
) -> None:
|
|
138
|
+
self._limit = limit
|
|
139
|
+
self._window = window
|
|
140
|
+
self._max_keys = max_keys
|
|
141
|
+
self._fails: dict[str, list[float]] = {}
|
|
142
|
+
|
|
143
|
+
def exceeded(self, key: str) -> bool:
|
|
144
|
+
"""窗口内的失败次数是否已达上限。"""
|
|
145
|
+
return len(self._fresh(key, time.time())) >= self._limit
|
|
146
|
+
|
|
147
|
+
def record(self, key: str) -> None:
|
|
148
|
+
"""记一次失败。"""
|
|
149
|
+
now = time.time()
|
|
150
|
+
self._fails[key] = [*self._fresh(key, now), now]
|
|
151
|
+
if len(self._fails) > self._max_keys:
|
|
152
|
+
self._fails.pop(next(iter(self._fails)), None)
|
|
153
|
+
|
|
154
|
+
def clear(self) -> None:
|
|
155
|
+
self._fails.clear()
|
|
156
|
+
|
|
157
|
+
def _fresh(self, key: str, now: float) -> list[float]:
|
|
158
|
+
"""窗口内的失败时间戳;顺手丢掉过期的(该来源没剩记录就移除键)。"""
|
|
159
|
+
stamps = [t for t in self._fails.get(key, ()) if now - t < self._window]
|
|
160
|
+
if stamps:
|
|
161
|
+
self._fails[key] = stamps
|
|
162
|
+
else:
|
|
163
|
+
self._fails.pop(key, None)
|
|
164
|
+
return stamps
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
_auth_throttle = _AuthThrottle()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _client_host(conn: HTTPConnection) -> str:
|
|
171
|
+
"""请求来源地址;拿不到时返回 ``-``(既不匹配环回,也不会与真实地址撞车)。"""
|
|
172
|
+
return (conn.client.host if conn.client else '') or '-'
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _relative_path(path: str) -> str:
|
|
176
|
+
"""剥掉挂载前缀,得到路由内路径(``/botui/api/x`` → ``/api/x``)。
|
|
177
|
+
|
|
178
|
+
路由以 ``include_router(prefix=route)`` 挂载,``request.url.path`` 一定带着
|
|
179
|
+
前缀;而地址白名单按路由内路径书写,比对前必须先剥掉。
|
|
180
|
+
"""
|
|
181
|
+
route = str(cfg.botui_route or '').rstrip('/')
|
|
182
|
+
return path[len(route) :] if route and path.startswith(route) else path
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _query_token_ok(path: str) -> bool:
|
|
186
|
+
"""该路径是否允许用 ``?token=`` 鉴权。"""
|
|
187
|
+
relative = _relative_path(path)
|
|
188
|
+
return any(
|
|
189
|
+
relative == p or relative.startswith(f'{p}/') for p in _QUERY_TOKEN_PATHS
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _token_ok(conn: HTTPConnection) -> bool:
|
|
194
|
+
"""校验令牌。
|
|
195
|
+
|
|
196
|
+
前端只走 ``X-BotUI-Token`` 请求头;地址栏里的 ``?token=`` 与
|
|
197
|
+
``Authorization: Bearer`` 是为「curl / 直接点链接」留的便利,其中
|
|
198
|
+
``?token=`` 只对 :data:`_QUERY_TOKEN_PATHS` 里的只读路径放行(否则令牌容易
|
|
199
|
+
被反向代理日志、浏览器历史或第三方子资源请求记录走)。
|
|
200
|
+
|
|
201
|
+
参数类型是 ``HTTPConnection``:``Request`` 与 ``WebSocket`` 都是它的子类,
|
|
202
|
+
校验逻辑(headers / query_params / client)两边完全一致。
|
|
203
|
+
"""
|
|
204
|
+
if not cfg.botui_auth:
|
|
205
|
+
return True
|
|
206
|
+
if not _token:
|
|
207
|
+
return False
|
|
208
|
+
supplied = [
|
|
209
|
+
conn.headers.get('x-botui-token', ''),
|
|
210
|
+
conn.headers.get('authorization', '').removeprefix('Bearer ').strip(),
|
|
211
|
+
]
|
|
212
|
+
if _query_token_ok(conn.url.path):
|
|
213
|
+
supplied.append(conn.query_params.get('token', ''))
|
|
214
|
+
return any(secrets.compare_digest(value, _token) for value in supplied if value)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _is_loopback(host: str) -> bool:
|
|
218
|
+
"""判断来源地址是否为本机。
|
|
219
|
+
|
|
220
|
+
用 ``ipaddress`` 而不是字符串比对:除了 ``127.0.0.1``,本机还可能以
|
|
221
|
+
``127.0.0.2``、IPv6 的 ``::1``,或 IPv4-mapped 形式 ``::ffff:127.0.0.1``
|
|
222
|
+
出现(不同 ASGI 服务器给的写法不一样)。
|
|
223
|
+
"""
|
|
224
|
+
if not host:
|
|
225
|
+
return False
|
|
226
|
+
try:
|
|
227
|
+
ip = ipaddress.ip_address(host)
|
|
228
|
+
except ValueError:
|
|
229
|
+
return host == 'localhost'
|
|
230
|
+
# IPv4-mapped IPv6(::ffff:127.0.0.1)要先还原成 IPv4 再判断:
|
|
231
|
+
# ipaddress 对 ``::ffff:7f00:1`` 的 is_loopback 是 False。
|
|
232
|
+
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
|
|
233
|
+
ip = ip.ipv4_mapped
|
|
234
|
+
return ip.is_loopback
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _host_only(header: str) -> str:
|
|
238
|
+
"""从 ``Host`` 头里取出主机名(去掉端口与 IPv6 的方括号)。"""
|
|
239
|
+
text = header.strip().lower()
|
|
240
|
+
if text.startswith('['): # [::1] 或 [::1]:8080
|
|
241
|
+
return text[1:].split(']', 1)[0]
|
|
242
|
+
return text.rsplit(':', 1)[0] if ':' in text else text
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _host_allowed(conn: HTTPConnection) -> bool:
|
|
246
|
+
"""校验请求的 ``Host`` 头(用于挡 DNS rebinding,**按需启用**)。
|
|
247
|
+
|
|
248
|
+
``BOTUI_ALLOWED_HOSTS`` 为空时**不校验** —— 否则用主机名访问本机
|
|
249
|
+
(如 ``http://my-nas:8080``)都会被挡住,而默认开启令牌鉴权时 DNS
|
|
250
|
+
rebinding 也拿不到数据。把它当纵深项:走域名 / 反向代理部署时,把域名写进
|
|
251
|
+
``BOTUI_ALLOWED_HOSTS`` 即启用白名单,本机主机名与环回 IP 始终允许。
|
|
252
|
+
"""
|
|
253
|
+
if cfg.botui_allow_remote:
|
|
254
|
+
return True
|
|
255
|
+
allowed = {
|
|
256
|
+
str(item).strip().strip('[]').lower() for item in cfg.botui_allowed_hosts
|
|
257
|
+
}
|
|
258
|
+
if not allowed:
|
|
259
|
+
return True
|
|
260
|
+
header = (conn.headers.get('host') or '').strip().lower()
|
|
261
|
+
if not header:
|
|
262
|
+
return True
|
|
263
|
+
host = _host_only(header)
|
|
264
|
+
return host in _LOCAL_HOSTS or _is_loopback(host) or host in allowed
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _client_allowed(conn: HTTPConnection) -> bool:
|
|
268
|
+
"""是否允许该来源访问。
|
|
269
|
+
|
|
270
|
+
默认只允许本机;要开放给外部必须显式设置 ``BOTUI_ALLOW_REMOTE=true``。
|
|
271
|
+
这里刻意**不**根据 ``botui_host`` 推断:那个配置只用来拼提示链接,
|
|
272
|
+
如果顺带决定安全策略,把链接改成局域网地址就会意外放开访问。
|
|
273
|
+
|
|
274
|
+
仅本机模式下,若配置了 ``BOTUI_ALLOWED_HOSTS`` 还会校验 ``Host``
|
|
275
|
+
(见 :func:`_host_allowed`),用于挡 DNS rebinding。
|
|
276
|
+
"""
|
|
277
|
+
if not _host_allowed(conn):
|
|
278
|
+
return False
|
|
279
|
+
if cfg.botui_allow_remote:
|
|
280
|
+
return True
|
|
281
|
+
return _is_loopback(_client_host(conn))
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _guard(request: Request) -> None:
|
|
285
|
+
if not _client_allowed(request):
|
|
286
|
+
raise HTTPException(
|
|
287
|
+
status_code=403,
|
|
288
|
+
detail=(
|
|
289
|
+
'BotUI 仅允许本机访问(如确需远程访问,请设置 BOTUI_ALLOW_REMOTE=true)'
|
|
290
|
+
),
|
|
291
|
+
)
|
|
292
|
+
client = _client_host(request)
|
|
293
|
+
if _auth_throttle.exceeded(client):
|
|
294
|
+
raise HTTPException(status_code=429, detail='鉴权失败次数过多,请稍后再试')
|
|
295
|
+
if not _token_ok(request):
|
|
296
|
+
_auth_throttle.record(client)
|
|
297
|
+
raise HTTPException(status_code=401, detail='BotUI 令牌无效或缺失')
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _guard_write(request: Request) -> None:
|
|
301
|
+
_guard(request)
|
|
302
|
+
if not cfg.botui_write_enabled:
|
|
303
|
+
raise HTTPException(status_code=403, detail='BotUI 当前为只读模式')
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
# ── 机器人选择 ──────────────────────────────────────────────────────────
|
|
307
|
+
def _bots() -> list[Bot]:
|
|
308
|
+
"""当前已连接的机器人(NoneBot 内置)"""
|
|
309
|
+
return list(nonebot.get_bots().values())
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _live_bot(self_id: str) -> Bot | None:
|
|
313
|
+
"""按 ID 取当前在线的机器人,没有就返回 None"""
|
|
314
|
+
self_id = str(self_id or '')
|
|
315
|
+
if not self_id:
|
|
316
|
+
return None
|
|
317
|
+
for bot in _bots():
|
|
318
|
+
if str(bot.self_id) == self_id:
|
|
319
|
+
return bot
|
|
320
|
+
return None
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _pick_bot(chat: ChatRecord | None, bots: list[Bot] | None = None) -> Bot | None:
|
|
324
|
+
"""为一条会话挑选要使用的机器人。
|
|
325
|
+
|
|
326
|
+
会话 key 里内嵌了机器人 ID(``12345:group_678``),所以按它精确匹配
|
|
327
|
+
—— 这正是「会话按机器人隔离」的落点:给 A 机器人的群发消息,绝不能从
|
|
328
|
+
B 机器人发出去。拿不到会话或该机器人已离线时返回 None。
|
|
329
|
+
"""
|
|
330
|
+
items = bots if bots is not None else _bots()
|
|
331
|
+
if not items or chat is None:
|
|
332
|
+
return None
|
|
333
|
+
# 会话所属机器人不在线:宁可失败也不要用别的机器人冒名发送
|
|
334
|
+
wanted = chat.self_id or self_id_of(chat.key)
|
|
335
|
+
if not wanted:
|
|
336
|
+
return None
|
|
337
|
+
for bot in items:
|
|
338
|
+
if str(bot.self_id) == wanted:
|
|
339
|
+
return bot
|
|
340
|
+
return None
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _request_self_id(request: Request) -> str:
|
|
344
|
+
"""从请求里读取前端当前选中的机器人 ID(``?bot=`` 或请求头)"""
|
|
345
|
+
supplied = request.query_params.get('bot', '') or request.headers.get(
|
|
346
|
+
'x-botui-bot', ''
|
|
347
|
+
)
|
|
348
|
+
return str(supplied or '').strip()
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _make_target(chat: ChatRecord) -> Any:
|
|
352
|
+
"""把会话转成 alconna 的 ``Target``。
|
|
353
|
+
|
|
354
|
+
``scope`` 直接用 uninfo 存下来的字符串(如 ``'QQClient'``):alconna 的
|
|
355
|
+
``SCOPES`` 就是以这些值为键的,原样传进去即可。
|
|
356
|
+
"""
|
|
357
|
+
from nonebot_plugin_alconna.uniseg import Target
|
|
358
|
+
|
|
359
|
+
kind, chat_id = split_chat_key(chat.key)
|
|
360
|
+
if kind == KIND_GROUP:
|
|
361
|
+
return Target(
|
|
362
|
+
chat_id,
|
|
363
|
+
adapter=chat.adapter or None,
|
|
364
|
+
scope=chat.scope or None,
|
|
365
|
+
self_id=chat.self_id or None,
|
|
366
|
+
)
|
|
367
|
+
return Target(
|
|
368
|
+
chat_id,
|
|
369
|
+
private=True,
|
|
370
|
+
adapter=chat.adapter or None,
|
|
371
|
+
scope=chat.scope or None,
|
|
372
|
+
self_id=chat.self_id or None,
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _require_store() -> 'MessageStore':
|
|
377
|
+
if _store is None or not _store.ready:
|
|
378
|
+
raise HTTPException(status_code=503, detail='BotUI 存储尚未就绪')
|
|
379
|
+
return _store
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
# ── 页面 ────────────────────────────────────────────────────────────────
|
|
383
|
+
@router.get('', include_in_schema=False)
|
|
384
|
+
async def index_redirect() -> RedirectResponse:
|
|
385
|
+
return RedirectResponse(url=f'{cfg.botui_route}/')
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
@router.get('/', include_in_schema=False)
|
|
389
|
+
async def index() -> FileResponse:
|
|
390
|
+
page = _static_dir / 'index.html'
|
|
391
|
+
if not page.is_file(): # pragma: no cover - 打包异常时
|
|
392
|
+
raise HTTPException(status_code=500, detail='BotUI 前端资源缺失')
|
|
393
|
+
return FileResponse(page, media_type='text/html; charset=utf-8')
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
@router.get('/favicon.ico', include_in_schema=False)
|
|
397
|
+
async def favicon() -> JSONResponse:
|
|
398
|
+
# 不额外打包图标资源,交给前端直接返回 204
|
|
399
|
+
return JSONResponse(status_code=204, content=None)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
# ── 接口 ────────────────────────────────────────────────────────────────
|
|
403
|
+
@router.get('/api/meta')
|
|
404
|
+
async def get_meta(request: Request) -> dict[str, Any]:
|
|
405
|
+
"""前端启动时要读的元信息。
|
|
406
|
+
|
|
407
|
+
这个接口不能直接 ``_guard``:前端在**还没有令牌**的时候就要靠它知道
|
|
408
|
+
「要不要弹令牌框」(``auth_required``)。所以这里把返回值分成两段——
|
|
409
|
+
引导用的字段谁都给,涉及环境细节的字段(数据库路径、机器人账号清单、
|
|
410
|
+
消息总数)只在通过校验时才带上。
|
|
411
|
+
"""
|
|
412
|
+
store = _require_store()
|
|
413
|
+
bots = _bots()
|
|
414
|
+
bot = _pick_bot(None, bots)
|
|
415
|
+
data: dict[str, Any] = {
|
|
416
|
+
'name': 'BotUI',
|
|
417
|
+
'version': VERSION,
|
|
418
|
+
'auth_required': auth_required(),
|
|
419
|
+
'write_enabled': bool(cfg.botui_write_enabled),
|
|
420
|
+
'page_size': int(cfg.botui_page_size),
|
|
421
|
+
'route': cfg.botui_route,
|
|
422
|
+
'capabilities': {
|
|
423
|
+
'recall': bool(cfg.botui_allow_recall),
|
|
424
|
+
'image': True,
|
|
425
|
+
'at': True,
|
|
426
|
+
'reply': True,
|
|
427
|
+
'file': True,
|
|
428
|
+
'preview': bool(cfg.botui_file_preview),
|
|
429
|
+
'export': True,
|
|
430
|
+
'forward': True,
|
|
431
|
+
'upload': bool(cfg.botui_media_enabled and cfg.botui_write_enabled),
|
|
432
|
+
},
|
|
433
|
+
'file_preview': bool(cfg.botui_file_preview),
|
|
434
|
+
'file_max_bytes': int(cfg.botui_file_max_bytes),
|
|
435
|
+
'robots_tag': 'noindex, nofollow',
|
|
436
|
+
'upload_enabled': bool(cfg.botui_media_enabled and cfg.botui_write_enabled),
|
|
437
|
+
'upload_max_bytes': int(
|
|
438
|
+
cfg.botui_media_file_max_bytes or cfg.botui_file_max_bytes
|
|
439
|
+
),
|
|
440
|
+
'cache_enabled': bool(_media is not None and _media.enabled),
|
|
441
|
+
}
|
|
442
|
+
# 这些是环境细节:数据库在磁盘上的位置、机器人的 self_id 清单、消息总量。
|
|
443
|
+
# 未通过校验时不返回,避免匿名请求就能摸清部署情况。
|
|
444
|
+
if _client_allowed(request) and _token_ok(request):
|
|
445
|
+
data.update(
|
|
446
|
+
{
|
|
447
|
+
'self_id': str(bot.self_id) if bot else None,
|
|
448
|
+
'adapter': bot.adapter.get_name() if bot else None,
|
|
449
|
+
'bots': [str(b.self_id) for b in bots],
|
|
450
|
+
# 连接过的全部机器人(含离线),供右上角切换器列表
|
|
451
|
+
'bot_list': [b.to_dict() for b in store.bots()],
|
|
452
|
+
'db': str(store.path),
|
|
453
|
+
'message_count': await store.count(),
|
|
454
|
+
}
|
|
455
|
+
)
|
|
456
|
+
return data
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
@router.get('/api/bots')
|
|
460
|
+
async def get_bots(request: Request) -> dict[str, Any]:
|
|
461
|
+
"""连接过的机器人清单(含在线标记)。
|
|
462
|
+
|
|
463
|
+
右上角的切换器用它列出所有机器人:「连接过」的概念存在 ``bots`` 表里,
|
|
464
|
+
机器人断开后仍然保留(``online=false``),因此列表不会因为离线而缩水。
|
|
465
|
+
"""
|
|
466
|
+
_guard(request)
|
|
467
|
+
store = _require_store()
|
|
468
|
+
records = store.bots()
|
|
469
|
+
# 以 NoneBot 当前实际连接为准刷新在线状态:钩子可能因为异常漏掉一次事件,
|
|
470
|
+
# 这里按 ``get_bots()`` 校正,避免界面显示「离线」但实际还连着。
|
|
471
|
+
live = {str(b.self_id) for b in _bots()}
|
|
472
|
+
for record in records:
|
|
473
|
+
record.online = record.self_id in live
|
|
474
|
+
return {'bots': [b.to_dict() for b in records]}
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
@router.get('/api/health')
|
|
478
|
+
async def get_health(request: Request) -> dict[str, Any]:
|
|
479
|
+
"""健康检查。
|
|
480
|
+
|
|
481
|
+
和别的接口一样走 ``_guard``:不加的话它会在 ``BOTUI_ALLOW_REMOTE=false`` 下
|
|
482
|
+
也能访问,泄露机器人 ``self_id`` 清单与在线状态。
|
|
483
|
+
"""
|
|
484
|
+
_guard(request)
|
|
485
|
+
store = _store
|
|
486
|
+
return {
|
|
487
|
+
'ok': bool(store and store.ready),
|
|
488
|
+
'db': bool(store and store.ready),
|
|
489
|
+
'bots': [str(b.self_id) for b in _bots()],
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
@router.get('/api/chats')
|
|
494
|
+
async def get_chats(
|
|
495
|
+
request: Request,
|
|
496
|
+
q: str = Query('', max_length=100),
|
|
497
|
+
limit: int = Query(200, ge=1, le=1000),
|
|
498
|
+
bot: str = Query('', max_length=64),
|
|
499
|
+
) -> dict[str, Any]:
|
|
500
|
+
"""会话列表。
|
|
501
|
+
|
|
502
|
+
``bot`` 是要筛选的机器人 ID(切换器选中的那个):会话按机器人完全隔离,
|
|
503
|
+
只返回该机器人的会话。
|
|
504
|
+
"""
|
|
505
|
+
_guard(request)
|
|
506
|
+
store = _require_store()
|
|
507
|
+
self_id = str(bot or _request_self_id(request) or '').strip()
|
|
508
|
+
chats = store.chats(q, limit, self_id=self_id if self_id else None)
|
|
509
|
+
return {'chats': [c.to_dict() for c in chats], 'bot': self_id or None}
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
@router.get('/api/messages')
|
|
513
|
+
async def get_messages(
|
|
514
|
+
request: Request,
|
|
515
|
+
chat: str = Query(..., min_length=1, max_length=200),
|
|
516
|
+
limit: int = Query(50, ge=1, le=500),
|
|
517
|
+
before: float | None = Query(None),
|
|
518
|
+
before_id: int | None = Query(None),
|
|
519
|
+
) -> dict[str, Any]:
|
|
520
|
+
_guard(request)
|
|
521
|
+
store = _require_store()
|
|
522
|
+
if store.chat(chat) is None and '_' not in chat:
|
|
523
|
+
raise HTTPException(status_code=400, detail='会话 key 格式不正确')
|
|
524
|
+
items = await store.messages(chat, limit, before, before_id)
|
|
525
|
+
return {'messages': [m.to_dict() for m in items], 'chat': chat}
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
@router.get('/api/members')
|
|
529
|
+
async def get_members(
|
|
530
|
+
request: Request,
|
|
531
|
+
chat: str = Query(..., min_length=1, max_length=200),
|
|
532
|
+
q: str = Query('', max_length=100),
|
|
533
|
+
limit: int = Query(200, ge=1, le=1000),
|
|
534
|
+
) -> dict[str, Any]:
|
|
535
|
+
"""会话里出现过的成员(WebUI 的 @ 菜单用)。
|
|
536
|
+
|
|
537
|
+
名册来自实际记录过的消息:发送者、被 @ 的人,以及 @ 解析出的昵称。
|
|
538
|
+
没有名册时前端会退化成「手动填 QQ 号」,所以这里不做 404,一律返回列表。
|
|
539
|
+
"""
|
|
540
|
+
_guard(request)
|
|
541
|
+
store = _require_store()
|
|
542
|
+
items = store.members(chat, q, limit)
|
|
543
|
+
return {'chat': chat, 'members': [m.to_dict() for m in items]}
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
#: 备注最大长度(按字符计):太长会撑破界面,也没必要
|
|
547
|
+
MAX_ALIAS_LEN = 64
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _clean_alias(value: Any) -> str:
|
|
551
|
+
"""规整备注文本:去首尾空白、折行,并限制长度。
|
|
552
|
+
|
|
553
|
+
备注是纯展示信息,不做任何转义——前端统一用 textContent 插入,天然防 XSS。
|
|
554
|
+
"""
|
|
555
|
+
text = str(value or '').replace('\r', ' ').replace('\n', ' ').strip()
|
|
556
|
+
return text[:MAX_ALIAS_LEN]
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
@router.post('/api/rename')
|
|
560
|
+
async def post_rename(request: Request) -> dict[str, Any]:
|
|
561
|
+
"""设置会话/成员/机器人的备注(别名)。
|
|
562
|
+
|
|
563
|
+
有些适配器拿不到真实的群名或用户昵称(拿到的可能只是一串 ID,或者干脆
|
|
564
|
+
是空的),界面上就只剩群号可看。备注让用户自己给它们起个认得出的名字,
|
|
565
|
+
只影响展示,不写入聊天记录,也不会被适配器后续上报的名称覆盖。
|
|
566
|
+
|
|
567
|
+
请求体:``{kind: 'chat'|'member'|'bot', alias: str, ...}``;
|
|
568
|
+
``chat``/``member`` 需要 ``chat``;``member`` 还需要 ``user_id``;
|
|
569
|
+
``bot`` 需要 ``self_id``。``alias`` 传空串表示清除备注。
|
|
570
|
+
"""
|
|
571
|
+
_guard_write(request)
|
|
572
|
+
store = _require_store()
|
|
573
|
+
try:
|
|
574
|
+
payload = await request.json()
|
|
575
|
+
except Exception as e:
|
|
576
|
+
raise HTTPException(status_code=400, detail='请求体不是合法 JSON') from e
|
|
577
|
+
|
|
578
|
+
kind = str(payload.get('kind') or '').strip()
|
|
579
|
+
alias = _clean_alias(payload.get('alias'))
|
|
580
|
+
if kind == 'chat':
|
|
581
|
+
key = str(payload.get('chat') or '').strip()
|
|
582
|
+
if not key:
|
|
583
|
+
raise HTTPException(status_code=400, detail='缺少 chat 参数')
|
|
584
|
+
chat = await store.set_chat_alias(key, alias)
|
|
585
|
+
if chat is None:
|
|
586
|
+
raise HTTPException(status_code=404, detail='会话不存在')
|
|
587
|
+
if _server is not None:
|
|
588
|
+
_server.publish_chat(chat)
|
|
589
|
+
return {
|
|
590
|
+
'ok': True,
|
|
591
|
+
'kind': kind,
|
|
592
|
+
'alias': alias or None,
|
|
593
|
+
'chat': chat.to_dict(),
|
|
594
|
+
}
|
|
595
|
+
if kind == 'member':
|
|
596
|
+
key = str(payload.get('chat') or '').strip()
|
|
597
|
+
user_id = str(payload.get('user_id') or '').strip()
|
|
598
|
+
if not key or not user_id:
|
|
599
|
+
raise HTTPException(status_code=400, detail='缺少 chat 或 user_id 参数')
|
|
600
|
+
await store.set_member_alias(key, user_id, alias)
|
|
601
|
+
member = store.member(key, user_id)
|
|
602
|
+
return {
|
|
603
|
+
'ok': True,
|
|
604
|
+
'kind': kind,
|
|
605
|
+
'alias': alias or None,
|
|
606
|
+
'member': member.to_dict() if member is not None else None,
|
|
607
|
+
}
|
|
608
|
+
if kind == 'bot':
|
|
609
|
+
self_id = str(payload.get('self_id') or '').strip()
|
|
610
|
+
if not self_id:
|
|
611
|
+
raise HTTPException(status_code=400, detail='缺少 self_id 参数')
|
|
612
|
+
bot = await store.set_bot_alias(self_id, alias)
|
|
613
|
+
if bot is None:
|
|
614
|
+
raise HTTPException(status_code=404, detail='机器人不存在')
|
|
615
|
+
if _server is not None:
|
|
616
|
+
_server.publish_bot(bot)
|
|
617
|
+
return {'ok': True, 'kind': kind, 'alias': alias or None, 'bot': bot.to_dict()}
|
|
618
|
+
raise HTTPException(status_code=400, detail=f'未知的 kind:{kind or "(空)"}')
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
@router.get('/api/search')
|
|
622
|
+
async def get_search(
|
|
623
|
+
request: Request,
|
|
624
|
+
q: str = Query(..., min_length=1, max_length=100),
|
|
625
|
+
chat: str = Query('', max_length=200),
|
|
626
|
+
bot: str = Query('', max_length=64),
|
|
627
|
+
limit: int = Query(100, ge=1, le=500),
|
|
628
|
+
) -> dict[str, Any]:
|
|
629
|
+
"""按内容搜索消息。
|
|
630
|
+
|
|
631
|
+
界面上搜索框本来就写着「搜索会话 / 群号 / 消息」,之前只做了会话名的本地
|
|
632
|
+
过滤,消息正文其实搜不到;这里补上真正的全文检索。
|
|
633
|
+
|
|
634
|
+
``bot`` 限定只在某个机器人的消息里搜,避免切换机器人后搜出别的机器人的
|
|
635
|
+
聊天记录。
|
|
636
|
+
"""
|
|
637
|
+
_guard(request)
|
|
638
|
+
store = _require_store()
|
|
639
|
+
self_id = str(bot or _request_self_id(request) or '').strip()
|
|
640
|
+
items = await store.search(
|
|
641
|
+
q, limit, chat or None, self_id=self_id if self_id else None
|
|
642
|
+
)
|
|
643
|
+
return {'query': q, 'messages': [m.to_dict() for m in items]}
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
@router.websocket('/api/ws')
|
|
647
|
+
async def ws_events(websocket: WebSocket, since: int = Query(0, ge=0)) -> None:
|
|
648
|
+
"""事件长连接。
|
|
649
|
+
|
|
650
|
+
连上后服务端先补发 ``since`` 之后(订阅之前)落下的那一段,再持续推送新
|
|
651
|
+
事件。客户端断线重连时把「最后收到的事件序号」放进 ``since`` 即可,不需要
|
|
652
|
+
轮询。
|
|
653
|
+
|
|
654
|
+
``since = 0`` 表示「刚打开页面」:历史消息由前端自己去 ``/api/messages``
|
|
655
|
+
拉,这里不做补发,免得把记录重复推一遍。
|
|
656
|
+
|
|
657
|
+
鉴权用**先 accept 再关闭**的写法:直接拒绝握手的话浏览器只能看到一个笼统的
|
|
658
|
+
连接失败,拿不到原因;accept 之后用 4401/4403 关闭,前端能区分「令牌不对」
|
|
659
|
+
和「不让远程访问」,也便于弹出令牌页。
|
|
660
|
+
"""
|
|
661
|
+
await websocket.accept()
|
|
662
|
+
|
|
663
|
+
if not _client_allowed(websocket):
|
|
664
|
+
await websocket.close(code=4403, reason='BotUI 仅允许本机访问')
|
|
665
|
+
return
|
|
666
|
+
if not _token_ok(websocket):
|
|
667
|
+
await websocket.close(code=4401, reason='BotUI 令牌无效或缺失')
|
|
668
|
+
return
|
|
669
|
+
if _server is None: # pragma: no cover - 未挂载时不会走到这里
|
|
670
|
+
await websocket.close(code=1011, reason='BotUI 尚未就绪')
|
|
671
|
+
return
|
|
672
|
+
|
|
673
|
+
# 用局部变量捕获,闭包里才能保住「非 None」的收窄(全局变量在闭包内会被放宽)
|
|
674
|
+
server = _server
|
|
675
|
+
queue, boundary = server.subscribe()
|
|
676
|
+
|
|
677
|
+
async def _pump() -> None:
|
|
678
|
+
"""推送循环:补发漏掉的、报一个游标、然后一直推新事件。"""
|
|
679
|
+
for event in server.backlog(since, boundary):
|
|
680
|
+
await websocket.send_json(event)
|
|
681
|
+
await websocket.send_json({'type': 'ready', 'cursor': server.latest})
|
|
682
|
+
while True:
|
|
683
|
+
try:
|
|
684
|
+
event = await asyncio.wait_for(queue.get(), timeout=WS_HEARTBEAT)
|
|
685
|
+
except (TimeoutError, asyncio.TimeoutError):
|
|
686
|
+
# 心跳:既保活(挡掉中间代理的空闲断连),也顺便探一次连接。
|
|
687
|
+
# 两个 TimeoutError 都要捕获:Python 3.11 之前 asyncio.TimeoutError
|
|
688
|
+
# 与内置 TimeoutError 不是同一个类,wait_for 抛的是前者。
|
|
689
|
+
event = {'type': 'ping', 'now': time.time()}
|
|
690
|
+
await websocket.send_json(event)
|
|
691
|
+
|
|
692
|
+
async def _reader() -> None:
|
|
693
|
+
"""读循环:只为感知断开。
|
|
694
|
+
|
|
695
|
+
客户端不需要发任何东西;收到什么都忽略(except 留个 ``close`` 方便调试)。
|
|
696
|
+
断开时 ``receive_text`` 抛 ``WebSocketDisconnect``,``_reader`` 结束,
|
|
697
|
+
下面等两个任务谁先结束 —— 单靠推送侧发现断开要等到下一次发送,最长
|
|
698
|
+
要等一个心跳周期,那期间订阅队列会一直挂在总线上。
|
|
699
|
+
"""
|
|
700
|
+
try:
|
|
701
|
+
while await websocket.receive_text() != 'close':
|
|
702
|
+
pass
|
|
703
|
+
except Exception:
|
|
704
|
+
pass
|
|
705
|
+
|
|
706
|
+
pump = asyncio.ensure_future(_pump())
|
|
707
|
+
reader = asyncio.ensure_future(_reader())
|
|
708
|
+
try:
|
|
709
|
+
await asyncio.wait({pump, reader}, return_when=asyncio.FIRST_COMPLETED)
|
|
710
|
+
finally:
|
|
711
|
+
_server.unsubscribe(queue)
|
|
712
|
+
pump.cancel()
|
|
713
|
+
reader.cancel()
|
|
714
|
+
# 收尾时的异常(多半是「连接已断开」)不需要上报
|
|
715
|
+
await asyncio.gather(pump, reader, return_exceptions=True)
|
|
716
|
+
try:
|
|
717
|
+
await websocket.close()
|
|
718
|
+
except Exception: # pragma: no cover - 已经断开
|
|
719
|
+
pass
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _message_id_of(receipt: Any) -> str:
|
|
723
|
+
"""从 alconna 的发送回执里取出消息 ID(拿不到就返回空串)。
|
|
724
|
+
|
|
725
|
+
不同适配器把 ID 放在不同位置:优先看 ``get_reply()``(跨平台那份),
|
|
726
|
+
再退回 OneBot 风格的 ``msg_ids``。
|
|
727
|
+
"""
|
|
728
|
+
try:
|
|
729
|
+
replies = receipt.get_reply() or []
|
|
730
|
+
if replies:
|
|
731
|
+
return str(getattr(replies[0], 'id', '') or '')
|
|
732
|
+
except Exception: # pragma: no cover - 回执形状取决于适配器
|
|
733
|
+
pass
|
|
734
|
+
try:
|
|
735
|
+
ids = getattr(receipt, 'msg_ids', None) or []
|
|
736
|
+
if ids:
|
|
737
|
+
first = ids[0]
|
|
738
|
+
return str(first.get('message_id') if isinstance(first, dict) else first)
|
|
739
|
+
except Exception: # pragma: no cover - 同上
|
|
740
|
+
pass
|
|
741
|
+
return ''
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
async def _throttle(key: str) -> None:
|
|
745
|
+
"""同一会话两次发送之间至少间隔 ``BOTUI_SEND_INTERVAL`` 秒。
|
|
746
|
+
|
|
747
|
+
间隔大到离谱时(配置写错,比如 3600)直接拒绝而不是干等:请求挂在那里
|
|
748
|
+
不返回比报错更难排查。
|
|
749
|
+
"""
|
|
750
|
+
interval = cfg.botui_send_interval
|
|
751
|
+
if interval <= 0:
|
|
752
|
+
return
|
|
753
|
+
wait = interval - (time.time() - _last_send.get(key, 0.0))
|
|
754
|
+
if wait > 0:
|
|
755
|
+
if wait > 5: # pragma: no cover - 配置异常时避免长时间挂起
|
|
756
|
+
raise HTTPException(status_code=429, detail='发送过于频繁,请稍后再试')
|
|
757
|
+
await asyncio.sleep(wait)
|
|
758
|
+
if len(_last_send) >= _TRACK_LIMIT:
|
|
759
|
+
_last_send.pop(next(iter(_last_send)), None)
|
|
760
|
+
_last_send[key] = time.time()
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
# ── 附件上传(WebUI 里选图/选文件后以机器人身份发出)────────────────────
|
|
764
|
+
#: 取回 / 删除附件时允许的 id 形状:媒体库里是内容 md5,待发送的是临时 id
|
|
765
|
+
_ANY_ID = re.compile(r'^[A-Za-z0-9_-]{6,80}$')
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
async def _read_uploaded(request: Request, limit: int) -> tuple[bytes, str, str]:
|
|
769
|
+
"""从上传请求里取出 ``(字节, 文件名, 类型)``,并保证不超过 ``limit`` 字节。
|
|
770
|
+
|
|
771
|
+
刻意**不**解析 multipart/form-data:那需要额外依赖 python-multipart,
|
|
772
|
+
而前端完全不必装成表单 —— 直接把文件字节放在请求体里、把文件名与类型放进
|
|
773
|
+
查询参数(``?name=`` / ``?type=``)即可。请求体就是一段裸字节流,读取、
|
|
774
|
+
限制大小、测试都简单得多(curl 用 ``--data-binary @a.png`` 即可)。
|
|
775
|
+
|
|
776
|
+
先看 ``Content-Length`` 再**分块**读取:绝不能 ``await request.body()``
|
|
777
|
+
一把梭 —— 那会先把整个 body 缓冲进内存、超限判断就形同虚设,拿一个
|
|
778
|
+
``Content-Length: 4GB`` 的请求就能把进程内存打爆。超限时抛 413。
|
|
779
|
+
"""
|
|
780
|
+
_raise_if_oversized(request.headers.get('content-length'), limit)
|
|
781
|
+
chunks: list[bytes] = []
|
|
782
|
+
total = 0
|
|
783
|
+
async for chunk in request.stream():
|
|
784
|
+
if not chunk:
|
|
785
|
+
continue
|
|
786
|
+
total += len(chunk)
|
|
787
|
+
_raise_if_oversized(total, limit)
|
|
788
|
+
chunks.append(chunk)
|
|
789
|
+
name = request.query_params.get('name', '')
|
|
790
|
+
mime = request.query_params.get('type', '')
|
|
791
|
+
return b''.join(chunks), name, mime
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def _raise_if_oversized(size: int | str | None, limit: int) -> None:
|
|
795
|
+
"""体积超限时抛 413。``limit <= 0`` 视为不限制;``size`` 可以是
|
|
796
|
+
``Content-Length`` 或已读字节数(非数字则忽略)。"""
|
|
797
|
+
if limit <= 0:
|
|
798
|
+
return
|
|
799
|
+
value = int(size) if isinstance(size, str) and size.isdigit() else size
|
|
800
|
+
if isinstance(value, int) and value > limit:
|
|
801
|
+
raise HTTPException(
|
|
802
|
+
status_code=413, detail=f'文件过大(上限 {limit // 1048576} MB)'
|
|
803
|
+
)
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
@router.post('/api/upload')
|
|
807
|
+
async def post_upload(request: Request) -> dict[str, Any]:
|
|
808
|
+
"""把待发送附件暂存进系统临时目录,返回一个可在 ``/api/send`` 里引用的 id。
|
|
809
|
+
|
|
810
|
+
前端「选附件」这一步就调这里:附件先暂存,之后带上 ``uploads: [id]`` 发送。
|
|
811
|
+
这么拆是因为发送前往往要展示预览(图片缩略图、文件名),而发送时才需要
|
|
812
|
+
真实文件;一次性把文件塞进发送请求会让「预览」变得没有意义。
|
|
813
|
+
|
|
814
|
+
刻意**不**在这一步入库:还没发送的东西不该占媒体库配额,也不该被内容去重
|
|
815
|
+
(同一个文件改名重发是常事)。暂存用 :mod:`tempfile`(系统临时目录),
|
|
816
|
+
前端点「移除」或超时未发送时直接删掉;直到 ``/api/send`` 那一刻才按内容
|
|
817
|
+
md5 正式进媒体库(见 ``MediaStore.commit``)。
|
|
818
|
+
"""
|
|
819
|
+
_guard_write(request)
|
|
820
|
+
if not cfg.botui_media_enabled:
|
|
821
|
+
raise HTTPException(status_code=403, detail='BotUI 未开启附件上传')
|
|
822
|
+
if _media is None: # pragma: no cover - setup 一定会注入
|
|
823
|
+
raise HTTPException(status_code=503, detail='BotUI 媒体库尚未就绪')
|
|
824
|
+
|
|
825
|
+
data, name, mime = await _read_uploaded(
|
|
826
|
+
request, int(cfg.botui_media_file_max_bytes)
|
|
827
|
+
)
|
|
828
|
+
if not data:
|
|
829
|
+
raise HTTPException(status_code=400, detail='没有收到文件内容')
|
|
830
|
+
record = await _media.stage(data, name=name, mime=mime)
|
|
831
|
+
if record is None:
|
|
832
|
+
raise HTTPException(status_code=413, detail='附件过大或媒体库未启用')
|
|
833
|
+
return {'ok': True, 'file': record.to_dict()}
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
@router.get('/api/media/{fid}')
|
|
837
|
+
async def get_media(
|
|
838
|
+
request: Request,
|
|
839
|
+
fid: str,
|
|
840
|
+
download: int = Query(0, ge=0, le=1),
|
|
841
|
+
) -> Any:
|
|
842
|
+
"""按 id 取回资源(消息段里的地址就指向这里)。
|
|
843
|
+
|
|
844
|
+
机器人收到的图片/文件、自己发出的附件都从这里取,因此前端只需要认识一个
|
|
845
|
+
地址形态。id 既可能是媒体库里的内容 md5,也可能是**还没发送**的暂存附件
|
|
846
|
+
(``staging``),两种都能取 —— 后者正是「选完附件立刻看到缩略图」所依赖的。
|
|
847
|
+
"""
|
|
848
|
+
_guard(request)
|
|
849
|
+
if _media is None: # pragma: no cover - setup 一定会注入
|
|
850
|
+
raise HTTPException(status_code=503, detail='BotUI 媒体库尚未就绪')
|
|
851
|
+
if not fid or not _ANY_ID.match(fid):
|
|
852
|
+
raise HTTPException(status_code=404, detail='媒体不存在')
|
|
853
|
+
record = await _media.resolve(fid)
|
|
854
|
+
if record is None:
|
|
855
|
+
raise HTTPException(status_code=404, detail='媒体不存在或已被清理')
|
|
856
|
+
return _media_response(record, download=bool(download))
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
@router.delete('/api/media/{fid}')
|
|
860
|
+
async def delete_media(request: Request, fid: str) -> dict[str, Any]:
|
|
861
|
+
"""删除一个资源(前端移除待发送附件时调用)。
|
|
862
|
+
|
|
863
|
+
待发送的暂存附件直接删临时文件;已入库的媒体则连同磁盘文件一起删掉。
|
|
864
|
+
"""
|
|
865
|
+
_guard_write(request)
|
|
866
|
+
if _media is None: # pragma: no cover - setup 一定会注入
|
|
867
|
+
raise HTTPException(status_code=503, detail='BotUI 媒体库尚未就绪')
|
|
868
|
+
if not fid or not _ANY_ID.match(fid):
|
|
869
|
+
raise HTTPException(status_code=404, detail='媒体不存在')
|
|
870
|
+
removed = await _media.delete(fid)
|
|
871
|
+
return {'ok': True, 'removed': removed}
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
async def _send_path(record: 'MediaRecord') -> tuple[Path, Path | None]:
|
|
875
|
+
"""取出发送用的磁盘路径,返回 ``(正文路径, 临时副本或 None)``。
|
|
876
|
+
|
|
877
|
+
QQ 适配器发本地文件时取的是**磁盘名**,而媒体库里的文件叫内容 md5、暂存
|
|
878
|
+
附件叫随机临时名,直接发过去对方收到的是「未命名」。所以发送时按真实文件名
|
|
879
|
+
**硬链接**一份临时副本(同一分区,不占额外磁盘),调用方发完即删。
|
|
880
|
+
"""
|
|
881
|
+
path = record.path
|
|
882
|
+
name = safe_filename(record.name)
|
|
883
|
+
if not name or path.name == name:
|
|
884
|
+
return path, None
|
|
885
|
+
directory = Path(tempfile.mkdtemp(prefix='botui-send-'))
|
|
886
|
+
target = directory / name
|
|
887
|
+
try:
|
|
888
|
+
await asyncio.to_thread(os.link, path, target)
|
|
889
|
+
except OSError:
|
|
890
|
+
try:
|
|
891
|
+
await asyncio.to_thread(shutil.copyfile, path, target)
|
|
892
|
+
except OSError: # pragma: no cover - 磁盘异常,退回直接发原名
|
|
893
|
+
shutil.rmtree(directory, ignore_errors=True)
|
|
894
|
+
return path, None
|
|
895
|
+
return target, directory
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
async def _cleanup_send(record: 'MediaRecord', directory: Path | None) -> None:
|
|
899
|
+
"""删掉 :func:`_send_path` 临时链接出来的那棵目录。"""
|
|
900
|
+
if directory is None:
|
|
901
|
+
return
|
|
902
|
+
await asyncio.to_thread(shutil.rmtree, directory, ignore_errors=True)
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
async def _upload_segment(
|
|
906
|
+
record: 'MediaRecord',
|
|
907
|
+
) -> tuple[Any, dict[str, Any], Path | None]:
|
|
908
|
+
"""把一条媒体资源转成 ``(uniseg 段, 记录里的段字典, 发送临时目录)``。
|
|
909
|
+
|
|
910
|
+
图片按 ``Image`` 发(聊天里直接显示),其余按 ``File`` 发;两者都用文件
|
|
911
|
+
内容/路径而不是 URL:适配器把 ``Image(path=...)`` 转成 ``file://`` 只有
|
|
912
|
+
当它在同一台机器上时才读得到,而 ``raw=``(适配器会转成 ``base64://``)
|
|
913
|
+
在任何部署下都能用 —— 代价是传输体积涨三分之一,对附件这个量级可以接受。
|
|
914
|
+
|
|
915
|
+
``File`` 段的 exporter 只认 ``path``(不认 ``raw``),所以文件走路径;又因为
|
|
916
|
+
exporter 取的是磁盘名,路径要用 :func:`_send_path` 链接出真实文件名的那份
|
|
917
|
+
临时副本,发送结束后由调用方删掉(第三个返回值)。
|
|
918
|
+
|
|
919
|
+
段里的 url 指向媒体库地址(内容 md5),前端带令牌就能取回,因此**发出的
|
|
920
|
+
消息在记录里依然能显示图片**。请求发送那一刻附件已经 ``commit`` 入库,所以
|
|
921
|
+
这里的地址与库里、与推给前端的完全一致。
|
|
922
|
+
"""
|
|
923
|
+
from nonebot_plugin_alconna.uniseg import File, Image
|
|
924
|
+
|
|
925
|
+
url = url_of(record.id)
|
|
926
|
+
kind = record.kind
|
|
927
|
+
if kind == 'image':
|
|
928
|
+
data = await asyncio.to_thread(record.path.read_bytes)
|
|
929
|
+
seg: Any = Image(raw=data, name=record.name, mimetype=record.mime or None)
|
|
930
|
+
payload: dict[str, Any] = {
|
|
931
|
+
'type': 'image',
|
|
932
|
+
'url': url,
|
|
933
|
+
'name': record.name,
|
|
934
|
+
'mime': record.mime or None,
|
|
935
|
+
}
|
|
936
|
+
return seg, payload, None
|
|
937
|
+
path, cleanup = await _send_path(record)
|
|
938
|
+
seg = File(path=path, name=record.name, mimetype=record.mime or None)
|
|
939
|
+
payload = {
|
|
940
|
+
'type': 'file',
|
|
941
|
+
'url': url,
|
|
942
|
+
'name': record.name,
|
|
943
|
+
'mime': record.mime or None,
|
|
944
|
+
'size': record.size,
|
|
945
|
+
}
|
|
946
|
+
return seg, payload, cleanup
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
async def _resolve_upload(raw: Any) -> 'MediaRecord':
|
|
950
|
+
"""按 id 取回附件(媒体库或待发送暂存区),取不到时抛 404。"""
|
|
951
|
+
uid = str(raw or '').strip()
|
|
952
|
+
if _media is None: # pragma: no cover - setup 一定会注入
|
|
953
|
+
raise HTTPException(status_code=503, detail='BotUI 媒体库尚未就绪')
|
|
954
|
+
record = await _media.resolve(uid)
|
|
955
|
+
if record is None:
|
|
956
|
+
raise HTTPException(status_code=404, detail=f'附件 {uid or "?"} 不存在或已过期')
|
|
957
|
+
return record
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
async def _send_via_bot(
|
|
961
|
+
bot: Bot,
|
|
962
|
+
chat: ChatRecord,
|
|
963
|
+
at_list,
|
|
964
|
+
text: str,
|
|
965
|
+
reply_to: str,
|
|
966
|
+
extra: list[Any] | None = None,
|
|
967
|
+
):
|
|
968
|
+
"""真正把消息发出去,返回回执。
|
|
969
|
+
|
|
970
|
+
``extra`` 是夹在正文后面的附加消息段(图片 / 文件等),由调用方按发送顺序
|
|
971
|
+
排好;正文(@、文本)在前,附件在后,与常见聊天软件的排版一致。
|
|
972
|
+
"""
|
|
973
|
+
from nonebot_plugin_alconna.uniseg import At, Text, Reply, UniMessage
|
|
974
|
+
|
|
975
|
+
outgoing: list[Any] = []
|
|
976
|
+
if reply_to:
|
|
977
|
+
outgoing.append(Reply(reply_to))
|
|
978
|
+
outgoing.extend(At('user', target) for target in at_list)
|
|
979
|
+
if at_list and text:
|
|
980
|
+
outgoing.append(Text(' '))
|
|
981
|
+
if text:
|
|
982
|
+
outgoing.append(Text(text))
|
|
983
|
+
if extra:
|
|
984
|
+
outgoing.extend(extra)
|
|
985
|
+
|
|
986
|
+
# 关闭采集再发送:下面会手工补一条记录(为了拿到行号返回给前端),
|
|
987
|
+
# 不关闭的话采集钩子会重复记一条,界面上就会看到两条一样的消息。
|
|
988
|
+
token = suppress_sent()
|
|
989
|
+
try:
|
|
990
|
+
return await UniMessage(outgoing).send(
|
|
991
|
+
target=_make_target(chat), bot=bot, fallback=True
|
|
992
|
+
)
|
|
993
|
+
finally:
|
|
994
|
+
resume_sent(token)
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
@router.post('/api/send')
|
|
998
|
+
async def post_send(request: Request) -> dict[str, Any]:
|
|
999
|
+
_guard_write(request)
|
|
1000
|
+
store = _require_store()
|
|
1001
|
+
try:
|
|
1002
|
+
payload = await request.json()
|
|
1003
|
+
except Exception as e:
|
|
1004
|
+
raise HTTPException(status_code=400, detail='请求体不是合法 JSON') from e
|
|
1005
|
+
|
|
1006
|
+
key = str(payload.get('chat') or '').strip()
|
|
1007
|
+
text = str(payload.get('text') or '')
|
|
1008
|
+
at_list = [str(x) for x in (payload.get('at') or []) if str(x).strip()]
|
|
1009
|
+
reply_to = str(payload.get('reply_to') or '').strip()
|
|
1010
|
+
upload_ids = [str(x) for x in (payload.get('uploads') or []) if str(x).strip()]
|
|
1011
|
+
|
|
1012
|
+
if not key:
|
|
1013
|
+
raise HTTPException(status_code=400, detail='缺少 chat 参数')
|
|
1014
|
+
if not text.strip() and not at_list and not upload_ids:
|
|
1015
|
+
raise HTTPException(status_code=400, detail='消息内容为空')
|
|
1016
|
+
if len(text) > 4000:
|
|
1017
|
+
raise HTTPException(status_code=400, detail='消息过长(最多 4000 字)')
|
|
1018
|
+
if len(upload_ids) > MAX_UPLOADS_PER_MESSAGE:
|
|
1019
|
+
raise HTTPException(
|
|
1020
|
+
status_code=400, detail=f'一次最多发送 {MAX_UPLOADS_PER_MESSAGE} 个附件'
|
|
1021
|
+
)
|
|
1022
|
+
if upload_ids and not cfg.botui_media_enabled:
|
|
1023
|
+
raise HTTPException(status_code=403, detail='BotUI 未开启附件上传')
|
|
1024
|
+
|
|
1025
|
+
# 附件要在发送前取好:一是确认它们都还在(过期会 404),二是把 uniseg 段
|
|
1026
|
+
# 提前构造出来。发送时附件排在正文之后。取好后立刻 commit 进媒体库:按
|
|
1027
|
+
# 内容 md5 落盘,消息段里引用的就是这个稳定地址。
|
|
1028
|
+
extra: list[Any] = []
|
|
1029
|
+
upload_segments: list[dict[str, Any]] = []
|
|
1030
|
+
cleanup_dirs: list[Path | None] = []
|
|
1031
|
+
for uid in upload_ids:
|
|
1032
|
+
item = await _resolve_upload(uid)
|
|
1033
|
+
item = await _media.commit(item.id) if _media is not None else item
|
|
1034
|
+
if item is None: # pragma: no cover - resolve 已确认存在
|
|
1035
|
+
raise HTTPException(status_code=404, detail=f'附件 {uid} 不存在或已过期')
|
|
1036
|
+
seg, seg_dict, cleanup = await _upload_segment(item)
|
|
1037
|
+
extra.append(seg)
|
|
1038
|
+
upload_segments.append(seg_dict)
|
|
1039
|
+
cleanup_dirs.append(cleanup)
|
|
1040
|
+
|
|
1041
|
+
chat = store.chat(key)
|
|
1042
|
+
if chat is None:
|
|
1043
|
+
kind, chat_id = split_chat_key(key)
|
|
1044
|
+
if not chat_id:
|
|
1045
|
+
raise HTTPException(status_code=400, detail='会话 key 格式不正确')
|
|
1046
|
+
chat = ChatRecord(key=key, kind=kind, chat_id=chat_id, self_id=self_id_of(key))
|
|
1047
|
+
else:
|
|
1048
|
+
# store 返回的是热缓存里的活对象,直接改它会污染缓存;复制一份再改。
|
|
1049
|
+
chat = replace(chat)
|
|
1050
|
+
|
|
1051
|
+
await _throttle(key)
|
|
1052
|
+
|
|
1053
|
+
bot = _pick_bot(chat)
|
|
1054
|
+
if bot is None:
|
|
1055
|
+
# 会话指定了机器人但它当前不在线时,不要用别的机器人顶替——那会让
|
|
1056
|
+
# 「A 机器人的群」里冒出 B 机器人发的消息。
|
|
1057
|
+
raise HTTPException(
|
|
1058
|
+
status_code=503, detail='该会话所属的机器人当前不在线,无法发送'
|
|
1059
|
+
)
|
|
1060
|
+
|
|
1061
|
+
try:
|
|
1062
|
+
receipt = await _send_via_bot(bot, chat, at_list, text, reply_to, extra)
|
|
1063
|
+
except Exception as e:
|
|
1064
|
+
logger.opt(exception=True).warning(f'BotUI failed to send message: {e}')
|
|
1065
|
+
raise HTTPException(status_code=502, detail='发送失败,请查看服务端日志') from e
|
|
1066
|
+
finally:
|
|
1067
|
+
# 发送用的临时文件名副本(见 _send_path)只在发送期间需要
|
|
1068
|
+
for directory in cleanup_dirs:
|
|
1069
|
+
if directory is not None:
|
|
1070
|
+
await asyncio.to_thread(shutil.rmtree, directory, ignore_errors=True)
|
|
1071
|
+
|
|
1072
|
+
# 主动补一条记录:即使采集钩子因故没生效,WebUI 里也能看到。
|
|
1073
|
+
# 段里用的就是媒体库地址,与库里、与推送出去的完全一致(不再有竞态回填)。
|
|
1074
|
+
record = build_outgoing(
|
|
1075
|
+
chat,
|
|
1076
|
+
await _payload_segments(
|
|
1077
|
+
at_list,
|
|
1078
|
+
text,
|
|
1079
|
+
reply_to,
|
|
1080
|
+
store=store,
|
|
1081
|
+
chat_key=chat.key,
|
|
1082
|
+
extra=upload_segments,
|
|
1083
|
+
),
|
|
1084
|
+
text,
|
|
1085
|
+
self_id=str(bot.self_id),
|
|
1086
|
+
adapter=bot.adapter.get_name(),
|
|
1087
|
+
)
|
|
1088
|
+
record.message_id = _message_id_of(receipt)
|
|
1089
|
+
store.enqueue(record)
|
|
1090
|
+
# 落库后行号才可用;store.on_persist 会给附件补上指向这条记录的引用
|
|
1091
|
+
await store.flush()
|
|
1092
|
+
return {'ok': True, 'message': record.to_dict()}
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
async def _quoted_segment(store: 'MessageStore', message_id: str) -> dict[str, Any]:
|
|
1096
|
+
"""给「回复」补一条带摘要的 reply 段。
|
|
1097
|
+
|
|
1098
|
+
记录里只留一个 id 的话,界面刷新后引用块就退化成「回复 12345」了。
|
|
1099
|
+
这里按消息 ID 反查那条被回复的记录,把「谁: 什么内容」一起存下来。
|
|
1100
|
+
"""
|
|
1101
|
+
seg: dict[str, Any] = {'type': 'reply', 'id': message_id}
|
|
1102
|
+
try:
|
|
1103
|
+
quoted = await store.message_by_message_id(message_id)
|
|
1104
|
+
except Exception: # pragma: no cover - 反查失败不影响发送
|
|
1105
|
+
return seg
|
|
1106
|
+
if quoted is None:
|
|
1107
|
+
return seg
|
|
1108
|
+
who = quoted.user_name or quoted.user_id
|
|
1109
|
+
if who:
|
|
1110
|
+
seg['name'] = who
|
|
1111
|
+
summary = quoted.text.strip() or describe_segments(quoted.segments)
|
|
1112
|
+
if summary:
|
|
1113
|
+
seg['text'] = summary[:4000]
|
|
1114
|
+
seg['preview'] = summary[:120]
|
|
1115
|
+
return seg
|
|
1116
|
+
|
|
1117
|
+
|
|
1118
|
+
async def _payload_segments(
|
|
1119
|
+
at_list: list[str],
|
|
1120
|
+
text: str,
|
|
1121
|
+
reply_to: str = '',
|
|
1122
|
+
store: 'MessageStore | None' = None,
|
|
1123
|
+
chat_key: str = '',
|
|
1124
|
+
extra: list[dict[str, Any]] | None = None,
|
|
1125
|
+
) -> list[dict[str, Any]]:
|
|
1126
|
+
"""把 WebUI 的发送内容转成用于记录的消息段
|
|
1127
|
+
|
|
1128
|
+
``extra`` 是附件(图片 / 文件)的段字典,顺序与下面 uniseg 的发送顺序一致:
|
|
1129
|
+
引用、@、正文,最后是附件。
|
|
1130
|
+
"""
|
|
1131
|
+
segments: list[dict[str, Any]] = []
|
|
1132
|
+
if reply_to and store is not None:
|
|
1133
|
+
segments.append(await _quoted_segment(store, reply_to))
|
|
1134
|
+
for target in at_list:
|
|
1135
|
+
name = ''
|
|
1136
|
+
if store is not None and chat_key:
|
|
1137
|
+
# 名册里记过昵称/备注就带上,界面上显示「@小明」而不是「@10001」
|
|
1138
|
+
member = store.member(chat_key, target)
|
|
1139
|
+
if member is not None:
|
|
1140
|
+
label = member.alias or member.name
|
|
1141
|
+
if label and label != target:
|
|
1142
|
+
name = label
|
|
1143
|
+
segments.append({'type': 'at', 'target': target, 'name': name or None})
|
|
1144
|
+
if at_list and text:
|
|
1145
|
+
segments.append({'type': 'text', 'text': ' '})
|
|
1146
|
+
if text:
|
|
1147
|
+
segments.append({'type': 'text', 'text': text})
|
|
1148
|
+
if extra:
|
|
1149
|
+
segments.extend(extra)
|
|
1150
|
+
return segments
|
|
1151
|
+
|
|
1152
|
+
|
|
1153
|
+
@router.post('/api/recall')
|
|
1154
|
+
async def post_recall(request: Request) -> dict[str, Any]:
|
|
1155
|
+
_guard_write(request)
|
|
1156
|
+
store = _require_store()
|
|
1157
|
+
if not cfg.botui_allow_recall:
|
|
1158
|
+
raise HTTPException(status_code=403, detail='BotUI 未开启撤回功能')
|
|
1159
|
+
try:
|
|
1160
|
+
payload = await request.json()
|
|
1161
|
+
except Exception as e:
|
|
1162
|
+
raise HTTPException(status_code=400, detail='请求体不是合法 JSON') from e
|
|
1163
|
+
|
|
1164
|
+
row_id = payload.get('id')
|
|
1165
|
+
if row_id in (None, ''):
|
|
1166
|
+
raise HTTPException(status_code=400, detail='缺少 id 参数')
|
|
1167
|
+
try:
|
|
1168
|
+
record = await store.message_by_id(int(row_id))
|
|
1169
|
+
except (TypeError, ValueError) as e:
|
|
1170
|
+
raise HTTPException(status_code=400, detail='id 必须是整数') from e
|
|
1171
|
+
if record is None:
|
|
1172
|
+
raise HTTPException(status_code=404, detail='找不到这条消息')
|
|
1173
|
+
if record.direction != DIR_OUT:
|
|
1174
|
+
raise HTTPException(status_code=400, detail='只能撤回机器人自己发送的消息')
|
|
1175
|
+
if not record.message_id:
|
|
1176
|
+
raise HTTPException(
|
|
1177
|
+
status_code=400, detail='这条消息没有可用的消息 ID,无法撤回'
|
|
1178
|
+
)
|
|
1179
|
+
|
|
1180
|
+
chat = store.chat(record.chat_key) or ChatRecord(
|
|
1181
|
+
key=record.chat_key,
|
|
1182
|
+
kind=record.chat_kind,
|
|
1183
|
+
chat_id=record.chat_id,
|
|
1184
|
+
self_id=record.self_id or self_id_of(record.chat_key),
|
|
1185
|
+
)
|
|
1186
|
+
bot = _pick_bot(chat)
|
|
1187
|
+
if bot is None:
|
|
1188
|
+
raise HTTPException(
|
|
1189
|
+
status_code=503, detail='该消息所属的机器人当前不在线,无法撤回'
|
|
1190
|
+
)
|
|
1191
|
+
|
|
1192
|
+
try:
|
|
1193
|
+
# 从 .adapters 导入而不是 uniseg 顶层:顶层那行是普通 import(没有
|
|
1194
|
+
# `as` 再导出形式),类型检查会认为它不属于公开 API 而报警告
|
|
1195
|
+
from nonebot_plugin_alconna.uniseg.adapters import alter_get_exporter
|
|
1196
|
+
|
|
1197
|
+
exporter = alter_get_exporter(bot.adapter.get_name())
|
|
1198
|
+
if exporter is None:
|
|
1199
|
+
raise RuntimeError(f'适配器 {bot.adapter.get_name()} 不支持撤回')
|
|
1200
|
+
await exporter.recall(record.message_id, bot, _make_target(chat))
|
|
1201
|
+
except Exception as e:
|
|
1202
|
+
logger.opt(exception=True).warning(f'BotUI failed to recall message: {e}')
|
|
1203
|
+
raise HTTPException(status_code=502, detail='撤回失败,请查看服务端日志') from e
|
|
1204
|
+
|
|
1205
|
+
await store.mark_recalled(record.row_id)
|
|
1206
|
+
if _server is not None:
|
|
1207
|
+
_server.publish_recall(record.row_id)
|
|
1208
|
+
return {'ok': True, 'id': record.row_id}
|
|
1209
|
+
|
|
1210
|
+
|
|
1211
|
+
# ── 页面资源 ────────────────────────────────────────────────────────────
|
|
1212
|
+
@router.get('/static/{filename}')
|
|
1213
|
+
async def get_static(filename: str) -> FileResponse:
|
|
1214
|
+
if not filename or '/' in filename or '\\' in filename or filename.startswith('.'):
|
|
1215
|
+
raise HTTPException(status_code=400, detail='非法的资源名')
|
|
1216
|
+
path = (_static_dir / filename).resolve()
|
|
1217
|
+
if _static_dir.resolve() not in path.parents or not path.is_file():
|
|
1218
|
+
raise HTTPException(status_code=404, detail='资源不存在')
|
|
1219
|
+
media = {
|
|
1220
|
+
'.css': 'text/css; charset=utf-8',
|
|
1221
|
+
'.js': 'application/javascript; charset=utf-8',
|
|
1222
|
+
'.html': 'text/html; charset=utf-8',
|
|
1223
|
+
'.svg': 'image/svg+xml',
|
|
1224
|
+
'.png': 'image/png',
|
|
1225
|
+
'.ico': 'image/x-icon',
|
|
1226
|
+
}.get(path.suffix.lower(), 'application/octet-stream')
|
|
1227
|
+
return FileResponse(path, media_type=media)
|
|
1228
|
+
|
|
1229
|
+
|
|
1230
|
+
# ── 媒体代理 ────────────────────────────────────────────────────────────
|
|
1231
|
+
@router.get('/media')
|
|
1232
|
+
async def proxy_media(request: Request, u: str = Query(..., min_length=8)) -> Any:
|
|
1233
|
+
"""代理远程图片:QQ 的图片链接大多带防盗链,直接 <img src> 会加载失败。
|
|
1234
|
+
|
|
1235
|
+
这个接口是「按用户给的 URL 去取内容」,所以必须和别的数据接口一样过
|
|
1236
|
+
``_guard``:否则任何人都能拿它当代理去访问内网(SSRF)。目标地址的
|
|
1237
|
+
限制在 ``media._blocked_reason`` 里。
|
|
1238
|
+
"""
|
|
1239
|
+
from fastapi.responses import Response
|
|
1240
|
+
|
|
1241
|
+
from .media import fetch
|
|
1242
|
+
|
|
1243
|
+
_guard(request)
|
|
1244
|
+
parts = urlsplit(u)
|
|
1245
|
+
if parts.scheme not in ('http', 'https'):
|
|
1246
|
+
raise HTTPException(status_code=400, detail='仅支持 http/https 链接')
|
|
1247
|
+
# 本地媒体库优先:图片是最先失效的一类资源,命中就不再联网
|
|
1248
|
+
cached = await _local_media(u)
|
|
1249
|
+
if cached is not None:
|
|
1250
|
+
return _media_response(cached)
|
|
1251
|
+
result = await fetch(u)
|
|
1252
|
+
if result is None:
|
|
1253
|
+
# 拿不到就退回原链接,前端仍然显示为破图而不是报错
|
|
1254
|
+
return RedirectResponse(url=u, status_code=302)
|
|
1255
|
+
data, ctype = result
|
|
1256
|
+
return Response(
|
|
1257
|
+
content=data,
|
|
1258
|
+
media_type=ctype,
|
|
1259
|
+
headers={'Cache-Control': 'private, max-age=3600'},
|
|
1260
|
+
)
|
|
1261
|
+
|
|
1262
|
+
|
|
1263
|
+
# ── 文件:下载 / 预览 / 合并转发 / 导出 ────────────────────────────────────
|
|
1264
|
+
async def _resolve_media_url(store: 'MessageStore | None', raw: str) -> str:
|
|
1265
|
+
"""把消息段里的文件地址解析成可直接访问的 http(s) 链接。
|
|
1266
|
+
|
|
1267
|
+
有些适配器只给一个文件 ID(拿不到直链),那就用 ``message_by_message_id``
|
|
1268
|
+
反查那条记录,从它已经存下的段里找可用的 url。纯文件名则无从下手,
|
|
1269
|
+
返回空串由调用方报错。
|
|
1270
|
+
"""
|
|
1271
|
+
url = str(raw or '').strip()
|
|
1272
|
+
if not url:
|
|
1273
|
+
return ''
|
|
1274
|
+
if url.startswith(('http://', 'https://')):
|
|
1275
|
+
return url
|
|
1276
|
+
if store is not None and len(url) <= 256:
|
|
1277
|
+
record = await store.message_by_message_id(url)
|
|
1278
|
+
if record is not None:
|
|
1279
|
+
for seg in record.segments:
|
|
1280
|
+
candidate = str(seg.get('url') or '').strip()
|
|
1281
|
+
if candidate.startswith(('http://', 'https://')):
|
|
1282
|
+
return candidate
|
|
1283
|
+
return ''
|
|
1284
|
+
|
|
1285
|
+
|
|
1286
|
+
async def _local_media(u: str) -> 'MediaRecord | None':
|
|
1287
|
+
"""如果这个地址在媒体库里已有副本,返回对应的 :class:`MediaRecord`。
|
|
1288
|
+
|
|
1289
|
+
有了它,媒体/文件就再也不用去碰那个随时会失效的原始链接 —— 这正是媒体库
|
|
1290
|
+
的收益所在。地址既可能是原始链接,也可能是媒体库地址本身(``/api/media``)。
|
|
1291
|
+
"""
|
|
1292
|
+
if _media is None:
|
|
1293
|
+
return None
|
|
1294
|
+
text = str(u or '').strip()
|
|
1295
|
+
if not text:
|
|
1296
|
+
return None
|
|
1297
|
+
return await _media.lookup(text)
|
|
1298
|
+
|
|
1299
|
+
|
|
1300
|
+
def _media_response(
|
|
1301
|
+
record: 'MediaRecord', *, download: bool = False, name: str = ''
|
|
1302
|
+
) -> Any:
|
|
1303
|
+
"""媒体库副本的响应(与 ``/api/file`` 的对外行为保持一致)。
|
|
1304
|
+
|
|
1305
|
+
统一交给 ``FileResponse``:它自带 Range 支持(音视频拖进度条靠它),
|
|
1306
|
+
自己拼 ``StreamingResponse`` 反而要重写一遍 Range 解析。
|
|
1307
|
+
"""
|
|
1308
|
+
filename = resolve_file_name(name or record.name, record.source, fallback='file')
|
|
1309
|
+
media = (
|
|
1310
|
+
record.mime
|
|
1311
|
+
or guess_mime(record.source or filename)
|
|
1312
|
+
or 'application/octet-stream'
|
|
1313
|
+
)
|
|
1314
|
+
return FileResponse(
|
|
1315
|
+
record.path,
|
|
1316
|
+
media_type=media,
|
|
1317
|
+
headers={
|
|
1318
|
+
'Cache-Control': 'private, max-age=600',
|
|
1319
|
+
# 响应头不能把「命中了媒体库」变成浏览器的缓存键,所以只做一个诊断标记
|
|
1320
|
+
'X-BotUI-Cache': 'hit' if not download else 'hit-download',
|
|
1321
|
+
'Content-Disposition': _content_disposition(
|
|
1322
|
+
'attachment' if download else 'inline', filename
|
|
1323
|
+
),
|
|
1324
|
+
},
|
|
1325
|
+
)
|
|
1326
|
+
|
|
1327
|
+
|
|
1328
|
+
async def _preview_media(record: 'MediaRecord', name: str) -> dict[str, Any]:
|
|
1329
|
+
"""用媒体库副本做文本预览(不联网,因而不受链接失效影响)。"""
|
|
1330
|
+
from .media import MAX_TEXT_BYTES, is_textual, decode_text
|
|
1331
|
+
|
|
1332
|
+
filename = resolve_file_name(name or record.name, record.source)
|
|
1333
|
+
if not is_textual(record.mime, filename):
|
|
1334
|
+
raise HTTPException(status_code=415, detail='该文件不是文本类型,无法在线预览')
|
|
1335
|
+
|
|
1336
|
+
def _read() -> bytes:
|
|
1337
|
+
with record.path.open('rb') as handle:
|
|
1338
|
+
return handle.read(MAX_TEXT_BYTES)
|
|
1339
|
+
|
|
1340
|
+
data = await asyncio.to_thread(_read)
|
|
1341
|
+
text, truncated = decode_text(data)
|
|
1342
|
+
if len(data) >= MAX_TEXT_BYTES:
|
|
1343
|
+
truncated = True
|
|
1344
|
+
return {
|
|
1345
|
+
'ok': True,
|
|
1346
|
+
'name': filename,
|
|
1347
|
+
'text': text,
|
|
1348
|
+
'truncated': truncated,
|
|
1349
|
+
'content_type': record.mime or None,
|
|
1350
|
+
'bytes': record.size,
|
|
1351
|
+
'url': record.source,
|
|
1352
|
+
'cached': True,
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
|
|
1356
|
+
def _content_disposition(disposition: str, filename: str) -> str:
|
|
1357
|
+
"""构造 Content-Disposition,兼顾非 ASCII 文件名。"""
|
|
1358
|
+
safe = filename.replace('\\', '_').replace('/', '_').replace('"', '')
|
|
1359
|
+
ascii_name = safe.encode('ascii', 'ignore').decode('ascii').strip() or 'file'
|
|
1360
|
+
return (
|
|
1361
|
+
f'{disposition}; '
|
|
1362
|
+
f'filename="{ascii_name}"; '
|
|
1363
|
+
f"filename*=UTF-8''{quote(safe, safe='')}"
|
|
1364
|
+
)
|
|
1365
|
+
|
|
1366
|
+
|
|
1367
|
+
def _upstream_filename(header: str | None) -> str:
|
|
1368
|
+
"""从上游的 ``Content-Disposition`` 里取出文件名(优先 RFC 5987)。"""
|
|
1369
|
+
if not header:
|
|
1370
|
+
return ''
|
|
1371
|
+
text = str(header)
|
|
1372
|
+
idx = text.lower().find("filename*=utf-8''")
|
|
1373
|
+
if idx >= 0:
|
|
1374
|
+
tail = text[idx + len("filename*=utf-8''") :]
|
|
1375
|
+
return tail.split(';')[0].strip().strip('"')
|
|
1376
|
+
idx = text.lower().find('filename=')
|
|
1377
|
+
if idx >= 0:
|
|
1378
|
+
tail = text[idx + len('filename=') :]
|
|
1379
|
+
return tail.split(';')[0].strip().strip('"')
|
|
1380
|
+
return ''
|
|
1381
|
+
|
|
1382
|
+
|
|
1383
|
+
# 逐跳透传的响应头(缓存类头自己定,避免把上游的私密策略拍给浏览器)
|
|
1384
|
+
_PASSTHROUGH_HEADERS = (
|
|
1385
|
+
'content-length',
|
|
1386
|
+
'content-range',
|
|
1387
|
+
'accept-ranges',
|
|
1388
|
+
'etag',
|
|
1389
|
+
'last-modified',
|
|
1390
|
+
)
|
|
1391
|
+
|
|
1392
|
+
|
|
1393
|
+
# ── 媒体库:取回与管理 ──────────────────────────────────────────────────
|
|
1394
|
+
@router.get('/api/cache')
|
|
1395
|
+
async def get_cache(request: Request) -> dict[str, Any]:
|
|
1396
|
+
"""媒体库概况(设置面板显示占用与命中数)。"""
|
|
1397
|
+
_guard(request)
|
|
1398
|
+
if _media is None: # pragma: no cover - setup 一定会注入
|
|
1399
|
+
raise HTTPException(status_code=503, detail='BotUI 媒体库尚未就绪')
|
|
1400
|
+
return {'ok': True, 'cache': await _media.stats()}
|
|
1401
|
+
|
|
1402
|
+
|
|
1403
|
+
@router.post('/api/cache')
|
|
1404
|
+
async def post_cache(request: Request) -> dict[str, Any]:
|
|
1405
|
+
"""媒体库维护:清理(按模式)与重置统计。
|
|
1406
|
+
|
|
1407
|
+
清理是真的删文件,所以走 ``_guard_write`` —— 只读模式下不该动磁盘。
|
|
1408
|
+
"""
|
|
1409
|
+
_guard_write(request)
|
|
1410
|
+
if _media is None: # pragma: no cover - setup 一定会注入
|
|
1411
|
+
raise HTTPException(status_code=503, detail='BotUI 媒体库尚未就绪')
|
|
1412
|
+
try:
|
|
1413
|
+
payload = await request.json()
|
|
1414
|
+
except Exception:
|
|
1415
|
+
payload = None
|
|
1416
|
+
body = payload if isinstance(payload, dict) else {}
|
|
1417
|
+
|
|
1418
|
+
action = str(body.get('action') or 'clear')
|
|
1419
|
+
if action == 'reset_stats':
|
|
1420
|
+
_media.reset_stats()
|
|
1421
|
+
return {'ok': True, 'cache': await _media.stats()}
|
|
1422
|
+
if action != 'clear':
|
|
1423
|
+
raise HTTPException(status_code=400, detail=f'不支持的操作:{action}')
|
|
1424
|
+
|
|
1425
|
+
mode = str(body.get('mode') or 'all')
|
|
1426
|
+
if mode not in MEDIA_CLEAR_MODES:
|
|
1427
|
+
raise HTTPException(status_code=400, detail=f'不支持的清理方式:{mode}')
|
|
1428
|
+
result = await _media.clear(mode)
|
|
1429
|
+
return {
|
|
1430
|
+
'ok': True,
|
|
1431
|
+
'mode': mode,
|
|
1432
|
+
'removed': int(result.get('removed') or 0),
|
|
1433
|
+
'bytes': int(result.get('bytes') or 0),
|
|
1434
|
+
'cache': await _media.stats(),
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
|
|
1438
|
+
@router.get('/api/file')
|
|
1439
|
+
async def get_file(
|
|
1440
|
+
request: Request,
|
|
1441
|
+
u: str = Query('', max_length=4096),
|
|
1442
|
+
name: str = Query('', max_length=300),
|
|
1443
|
+
chat: str = Query('', max_length=200),
|
|
1444
|
+
download: int = Query(0, ge=0, le=1),
|
|
1445
|
+
) -> Any:
|
|
1446
|
+
"""文件代理:在线预览与下载都走这里。
|
|
1447
|
+
|
|
1448
|
+
直接用文件直链会踩两个坑:一是 QQ 的链接带防盗链会 403,二是很多适配器
|
|
1449
|
+
只给一个文件 ID 根本没直链。所以统一由服务端代取:
|
|
1450
|
+
|
|
1451
|
+
- ``Range`` 原样透传,音视频可以拖进度条、大文件可以断点续传;
|
|
1452
|
+
- 体积超过 ``BOTUI_FILE_MAX_BYTES`` 时直接 413,不当无限流量通道;
|
|
1453
|
+
- 只做直通转发,不落盘。
|
|
1454
|
+
"""
|
|
1455
|
+
from fastapi.responses import StreamingResponse
|
|
1456
|
+
|
|
1457
|
+
from .media import Blocked, probe, stream
|
|
1458
|
+
|
|
1459
|
+
_guard(request)
|
|
1460
|
+
store = _require_store()
|
|
1461
|
+
|
|
1462
|
+
# 有本地媒体库副本就直接回本地文件:既能规避防盗链,也不再受链接过期影响
|
|
1463
|
+
cached = await _local_media(u)
|
|
1464
|
+
if cached is not None:
|
|
1465
|
+
return _media_response(cached, download=bool(download), name=name)
|
|
1466
|
+
|
|
1467
|
+
url = await _resolve_media_url(store, u)
|
|
1468
|
+
if not url:
|
|
1469
|
+
raise HTTPException(status_code=400, detail='缺少可用的文件链接')
|
|
1470
|
+
|
|
1471
|
+
range_header = request.headers.get('range') or ''
|
|
1472
|
+
upstream_headers = {'Range': range_header} if range_header else None
|
|
1473
|
+
try:
|
|
1474
|
+
head = await probe(url, headers=upstream_headers)
|
|
1475
|
+
except Blocked as e:
|
|
1476
|
+
raise HTTPException(status_code=403, detail=str(e)) from e
|
|
1477
|
+
except Exception as e:
|
|
1478
|
+
logger.opt(exception=True).warning(f'BotUI failed to open file: {e}')
|
|
1479
|
+
raise HTTPException(status_code=502, detail='获取文件失败') from e
|
|
1480
|
+
|
|
1481
|
+
max_bytes = int(cfg.botui_file_max_bytes)
|
|
1482
|
+
length = head.headers.get('content-length')
|
|
1483
|
+
if max_bytes > 0 and head.status_code == 200 and length:
|
|
1484
|
+
try:
|
|
1485
|
+
if int(length) > max_bytes:
|
|
1486
|
+
raise HTTPException(
|
|
1487
|
+
status_code=413,
|
|
1488
|
+
detail=(
|
|
1489
|
+
f'文件过大({(int(length) / 1048576):.1f} MB,'
|
|
1490
|
+
f'上限 {max_bytes // 1048576} MB)'
|
|
1491
|
+
),
|
|
1492
|
+
)
|
|
1493
|
+
except ValueError: # pragma: no cover - 上游乱给 content-length
|
|
1494
|
+
pass
|
|
1495
|
+
|
|
1496
|
+
ctype = (head.headers.get('content-type') or '').split(';')[0].strip()
|
|
1497
|
+
# QQ 这类直链的 content-type 常是 octet-stream;用链接里的后缀补一个更准的
|
|
1498
|
+
if not ctype or ctype == 'application/octet-stream':
|
|
1499
|
+
ctype = guess_mime(url) or ctype
|
|
1500
|
+
filename = resolve_file_name(
|
|
1501
|
+
name or _upstream_filename(head.headers.get('content-disposition')),
|
|
1502
|
+
url,
|
|
1503
|
+
)
|
|
1504
|
+
headers = {
|
|
1505
|
+
key: head.headers[key] for key in _PASSTHROUGH_HEADERS if key in head.headers
|
|
1506
|
+
}
|
|
1507
|
+
headers['Cache-Control'] = 'private, max-age=600'
|
|
1508
|
+
headers['Content-Disposition'] = _content_disposition(
|
|
1509
|
+
'attachment' if download else 'inline', filename
|
|
1510
|
+
)
|
|
1511
|
+
|
|
1512
|
+
async def _body():
|
|
1513
|
+
try:
|
|
1514
|
+
async for chunk in stream(url, headers=upstream_headers):
|
|
1515
|
+
yield chunk
|
|
1516
|
+
except Blocked as e: # pragma: no cover - 重定向后才发现也在上面拦
|
|
1517
|
+
logger.warning(f'BotUI file stream blocked: {e}')
|
|
1518
|
+
except Exception as e:
|
|
1519
|
+
# 响应头已经发出,只能中断传输(浏览器会提示下载失败)
|
|
1520
|
+
logger.debug(f'BotUI file stream aborted: {e}')
|
|
1521
|
+
|
|
1522
|
+
return StreamingResponse(
|
|
1523
|
+
_body(),
|
|
1524
|
+
status_code=head.status_code,
|
|
1525
|
+
media_type=ctype or 'application/octet-stream',
|
|
1526
|
+
headers=headers,
|
|
1527
|
+
)
|
|
1528
|
+
|
|
1529
|
+
|
|
1530
|
+
@router.get('/api/preview')
|
|
1531
|
+
async def get_preview(
|
|
1532
|
+
request: Request,
|
|
1533
|
+
u: str = Query('', max_length=4096),
|
|
1534
|
+
name: str = Query('', max_length=300),
|
|
1535
|
+
chat: str = Query('', max_length=200),
|
|
1536
|
+
) -> dict[str, Any]:
|
|
1537
|
+
"""文本文件在线预览:只读前 512 KB,避免大文件把内存吃光。"""
|
|
1538
|
+
from .media import fetch_text, is_textual
|
|
1539
|
+
|
|
1540
|
+
_guard(request)
|
|
1541
|
+
if not cfg.botui_file_preview:
|
|
1542
|
+
raise HTTPException(status_code=403, detail='BotUI 未开启文件在线预览')
|
|
1543
|
+
store = _require_store()
|
|
1544
|
+
|
|
1545
|
+
# 本地缓存优先:读到本地副本就干脆不必联网
|
|
1546
|
+
cached = await _local_media(u)
|
|
1547
|
+
if cached is not None:
|
|
1548
|
+
return await _preview_media(cached, name)
|
|
1549
|
+
|
|
1550
|
+
url = await _resolve_media_url(store, u)
|
|
1551
|
+
if not url:
|
|
1552
|
+
raise HTTPException(status_code=400, detail='缺少可用的文件链接')
|
|
1553
|
+
|
|
1554
|
+
filename = resolve_file_name(name, url)
|
|
1555
|
+
result = await fetch_text(url)
|
|
1556
|
+
if result is None:
|
|
1557
|
+
raise HTTPException(status_code=502, detail='无法读取文件内容')
|
|
1558
|
+
text, truncated, ctype, total = result
|
|
1559
|
+
if not is_textual(ctype, filename):
|
|
1560
|
+
raise HTTPException(status_code=415, detail='该文件不是文本类型,无法在线预览')
|
|
1561
|
+
return {
|
|
1562
|
+
'ok': True,
|
|
1563
|
+
'name': filename,
|
|
1564
|
+
'text': text,
|
|
1565
|
+
'truncated': truncated,
|
|
1566
|
+
'content_type': ctype or None,
|
|
1567
|
+
'bytes': total,
|
|
1568
|
+
'url': url,
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
|
|
1572
|
+
@router.get('/api/forward')
|
|
1573
|
+
async def get_forward(
|
|
1574
|
+
request: Request,
|
|
1575
|
+
id: str = Query(..., min_length=1, max_length=200),
|
|
1576
|
+
chat: str = Query('', max_length=200),
|
|
1577
|
+
) -> dict[str, Any]:
|
|
1578
|
+
"""展开合并转发。
|
|
1579
|
+
|
|
1580
|
+
优先用记录里已内联的节点(部分适配器把内容直接放在了段里);没有就调用
|
|
1581
|
+
适配器的 ``get_forward_msg``,失败时换成 ``get_forward_message`` 再试一次:
|
|
1582
|
+
不同适配器称呼不一样。
|
|
1583
|
+
"""
|
|
1584
|
+
from .segments import parse_forward_nodes
|
|
1585
|
+
|
|
1586
|
+
_guard(request)
|
|
1587
|
+
store = _require_store()
|
|
1588
|
+
|
|
1589
|
+
inline = await store.forward_nodes(chat or None, id)
|
|
1590
|
+
if inline:
|
|
1591
|
+
return {'ok': True, 'id': id, 'source': 'record', 'nodes': inline}
|
|
1592
|
+
|
|
1593
|
+
chat_record = store.chat(chat) if chat else None
|
|
1594
|
+
bot = _pick_bot(chat_record)
|
|
1595
|
+
if bot is None:
|
|
1596
|
+
raise HTTPException(
|
|
1597
|
+
status_code=404, detail='展开合并转发需要该会话的机器人在线'
|
|
1598
|
+
)
|
|
1599
|
+
|
|
1600
|
+
timeout = float(cfg.botui_api_timeout)
|
|
1601
|
+
attempts = (
|
|
1602
|
+
('get_forward_msg', {'id': id}),
|
|
1603
|
+
('get_forward_msg', {'message_id': id}),
|
|
1604
|
+
('get_forward_message', {'message_id': id}),
|
|
1605
|
+
)
|
|
1606
|
+
last_error: Exception | None = None
|
|
1607
|
+
for api_name, params in attempts:
|
|
1608
|
+
try:
|
|
1609
|
+
result = await asyncio.wait_for(
|
|
1610
|
+
bot.call_api(api_name, **params), timeout=timeout
|
|
1611
|
+
)
|
|
1612
|
+
except Exception as e:
|
|
1613
|
+
last_error = e
|
|
1614
|
+
continue
|
|
1615
|
+
nodes = parse_forward_nodes(result)
|
|
1616
|
+
if nodes:
|
|
1617
|
+
return {'ok': True, 'id': id, 'source': 'api', 'nodes': nodes}
|
|
1618
|
+
logger.debug(f'BotUI cannot expand forward {id}: {last_error!r}')
|
|
1619
|
+
adapter = bot.adapter.get_name()
|
|
1620
|
+
raise HTTPException(
|
|
1621
|
+
status_code=404,
|
|
1622
|
+
detail=f'无法展开这条合并转发(适配器 {adapter} 可能不支持)',
|
|
1623
|
+
)
|
|
1624
|
+
|
|
1625
|
+
|
|
1626
|
+
@router.get('/api/export')
|
|
1627
|
+
async def get_export(
|
|
1628
|
+
request: Request,
|
|
1629
|
+
chat: str = Query(..., min_length=1, max_length=200),
|
|
1630
|
+
limit: int = Query(20000, ge=1, le=200000),
|
|
1631
|
+
) -> Any:
|
|
1632
|
+
"""导出某个会话的聊天记录(JSON,含全部消息段)。"""
|
|
1633
|
+
from fastapi.responses import Response
|
|
1634
|
+
|
|
1635
|
+
_guard(request)
|
|
1636
|
+
store = _require_store()
|
|
1637
|
+
record = store.chat(chat)
|
|
1638
|
+
kind, chat_id = split_chat_key(chat)
|
|
1639
|
+
|
|
1640
|
+
messages: list[dict[str, Any]] = []
|
|
1641
|
+
after: float | None = None
|
|
1642
|
+
after_id: int | None = None
|
|
1643
|
+
while len(messages) < limit:
|
|
1644
|
+
batch = await store.stream_messages(
|
|
1645
|
+
chat, min(1000, limit - len(messages)), after, after_id
|
|
1646
|
+
)
|
|
1647
|
+
if not batch:
|
|
1648
|
+
break
|
|
1649
|
+
messages.extend(m.to_dict() for m in batch)
|
|
1650
|
+
after = batch[-1].ts
|
|
1651
|
+
after_id = batch[-1].row_id
|
|
1652
|
+
if len(batch) < 1000:
|
|
1653
|
+
break
|
|
1654
|
+
|
|
1655
|
+
payload = {
|
|
1656
|
+
'name': 'BotUI',
|
|
1657
|
+
'version': VERSION,
|
|
1658
|
+
'exported_at': time.time(),
|
|
1659
|
+
'chat': record.to_dict()
|
|
1660
|
+
if record is not None
|
|
1661
|
+
else {
|
|
1662
|
+
'key': chat,
|
|
1663
|
+
'kind': kind,
|
|
1664
|
+
'id': chat_id,
|
|
1665
|
+
'name': chat_id,
|
|
1666
|
+
},
|
|
1667
|
+
'count': len(messages),
|
|
1668
|
+
'messages': messages,
|
|
1669
|
+
}
|
|
1670
|
+
text = json.dumps(payload, ensure_ascii=False, indent=2)
|
|
1671
|
+
title = (record.name if record is not None else chat_id) or chat_id
|
|
1672
|
+
filename = f'{title}-{int(payload["exported_at"])}.json'
|
|
1673
|
+
return Response(
|
|
1674
|
+
content=text.encode('utf-8'),
|
|
1675
|
+
media_type='application/json; charset=utf-8',
|
|
1676
|
+
headers={
|
|
1677
|
+
'Content-Disposition': _content_disposition('attachment', filename),
|
|
1678
|
+
'Cache-Control': 'no-store',
|
|
1679
|
+
},
|
|
1680
|
+
)
|
|
1681
|
+
|
|
1682
|
+
|
|
1683
|
+
__all__ = ['VERSION', 'auth_required', 'router', 'setup']
|