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,170 @@
|
|
|
1
|
+
"""nonebot-plugin-botui 的配置与全局常量。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from nonebot import get_driver, get_plugin_config
|
|
6
|
+
from pydantic import BaseModel, field_validator
|
|
7
|
+
|
|
8
|
+
# 机器人发送消息时可能调用的 API 名称(用于区分“发出”的消息)
|
|
9
|
+
SEND_APIS: frozenset[str] = frozenset(
|
|
10
|
+
{
|
|
11
|
+
# OneBot V11 / V12
|
|
12
|
+
'send_msg',
|
|
13
|
+
'send_private_msg',
|
|
14
|
+
'send_group_msg',
|
|
15
|
+
'send_group_forward_msg',
|
|
16
|
+
'send_private_forward_msg',
|
|
17
|
+
# 通用 / 其他适配器
|
|
18
|
+
'send_message',
|
|
19
|
+
'send_group_message',
|
|
20
|
+
'send_private_message',
|
|
21
|
+
'send_channel_message',
|
|
22
|
+
'post_message',
|
|
23
|
+
'create_message',
|
|
24
|
+
'send',
|
|
25
|
+
'send_group_file',
|
|
26
|
+
'upload_group_file',
|
|
27
|
+
'upload_private_file',
|
|
28
|
+
'send_group_notice',
|
|
29
|
+
}
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Config(BaseModel):
|
|
34
|
+
"""插件配置项(环境变量前缀:BOTUI_)"""
|
|
35
|
+
|
|
36
|
+
model_config = {'extra': 'ignore'}
|
|
37
|
+
|
|
38
|
+
# ── 基础开关 ────────────────────────────────────────────────────────
|
|
39
|
+
botui_enabled: bool = True
|
|
40
|
+
"""是否启用 WebUI"""
|
|
41
|
+
botui_route: str = '/botui'
|
|
42
|
+
"""WebUI 挂载路径(需以 / 开头)"""
|
|
43
|
+
botui_host: str = '127.0.0.1'
|
|
44
|
+
"""提示链接里使用的主机名(只影响链接显示,不影响服务实际监听地址)"""
|
|
45
|
+
botui_allow_remote: bool = False
|
|
46
|
+
"""是否允许非本机来源访问。开启前请确保已启用令牌鉴权或放在反向代理之后"""
|
|
47
|
+
botui_allowed_hosts: tuple[str, ...] = ()
|
|
48
|
+
"""允许的 ``Host`` 头(走反向代理用域名访问时填写)。
|
|
49
|
+
|
|
50
|
+
用来挡 DNS rebinding:浏览器访问 ``http://attacker.example`` 时,该域名可以
|
|
51
|
+
先解析到攻击者服务器、再重新解析到 ``127.0.0.1``,此时来源就成了环回,只
|
|
52
|
+
看来源会被放行。因此这里再校验 ``Host`` —— 本机主机名(``localhost`` /
|
|
53
|
+
环回 IP)始终允许,用域名访问就把域名写进来。``BOTUI_ALLOW_REMOTE=true``
|
|
54
|
+
时不再限制。"""
|
|
55
|
+
|
|
56
|
+
# ── 鉴权 ────────────────────────────────────────────────────────────
|
|
57
|
+
botui_auth: bool = True
|
|
58
|
+
"""是否启用 Token 鉴权(强烈建议保持开启)"""
|
|
59
|
+
botui_token: str = ''
|
|
60
|
+
"""访问令牌;留空则自动生成并保存到数据目录的 token.txt"""
|
|
61
|
+
|
|
62
|
+
# ── 记录 ────────────────────────────────────────────────────────────
|
|
63
|
+
botui_capture_received: bool = True
|
|
64
|
+
"""是否记录机器人收到的消息"""
|
|
65
|
+
botui_capture_sent: bool = True
|
|
66
|
+
"""是否记录机器人发出的消息"""
|
|
67
|
+
botui_capture_self: bool = False
|
|
68
|
+
"""是否记录「自己发出的消息被适配器回传成事件」;关闭可避免同一条消息记两次"""
|
|
69
|
+
botui_exclude_adapters: tuple[str, ...] = ()
|
|
70
|
+
"""不记录这些适配器的消息,例如 ('OneBot V12',)"""
|
|
71
|
+
botui_max_records: int = 200_000
|
|
72
|
+
"""消息记录上限,超出后自动删除最旧的记录;0 表示不限制"""
|
|
73
|
+
botui_retention_days: int = 0
|
|
74
|
+
"""消息保留天数,超期自动清理;0 表示不限制"""
|
|
75
|
+
botui_resolve_at_name: bool = True
|
|
76
|
+
"""是否尝试把 @某人 解析成群名片(会调用适配器接口并缓存)"""
|
|
77
|
+
botui_api_timeout: float = 10.0
|
|
78
|
+
"""插件内部调用适配器接口的超时时间(秒)"""
|
|
79
|
+
|
|
80
|
+
# ── WebUI 行为 ──────────────────────────────────────────────────────
|
|
81
|
+
botui_page_size: int = 50
|
|
82
|
+
"""聊天记录每页条数"""
|
|
83
|
+
botui_write_enabled: bool = True
|
|
84
|
+
"""是否允许在 WebUI 中代替机器人发送消息"""
|
|
85
|
+
botui_allow_recall: bool = True
|
|
86
|
+
"""是否允许在 WebUI 中撤回消息"""
|
|
87
|
+
botui_send_interval: float = 1.0
|
|
88
|
+
"""同一会话两次发送之间的最小间隔(秒),用于限流"""
|
|
89
|
+
botui_media_allow_private: bool = False
|
|
90
|
+
"""是否允许 /media 代理内网/环回地址。
|
|
91
|
+
|
|
92
|
+
默认 false,只允许公网图片链接——否则 /media 会变成 SSRF 跳板,能被用来
|
|
93
|
+
探测内网服务或读取云元数据接口(169.254.169.254)。仅当你确实要显示来自
|
|
94
|
+
内网的图片时才打开。"""
|
|
95
|
+
botui_file_preview: bool = True
|
|
96
|
+
"""是否允许在网页上在线预览收到的文件(文本 / 图片 / PDF / 音视频)。"""
|
|
97
|
+
botui_file_max_bytes: int = 100 * 1024 * 1024
|
|
98
|
+
"""文件下载代理允许的最大体积(字节),101 MB。
|
|
99
|
+
|
|
100
|
+
``0`` 表示不限制。文本在线预览另有 512 KB 的读取上限,不受这里影响。"""
|
|
101
|
+
|
|
102
|
+
# ── 媒体库(图片 / 语音 / 文件)──────────────────────────────────────
|
|
103
|
+
# 机器人收到的媒体直链大多带防盗链、而且**会过期**:过一阵子再点开聊天
|
|
104
|
+
# 记录,链接就已经打不开了。WebUI 上传的附件也有同样的问题。所以统一把
|
|
105
|
+
# 它们存进一个本地媒体库(见 mediastore.py),之后一律读本地副本。
|
|
106
|
+
# 资源按**内容 md5** 命名,内容相同天然只有一份。
|
|
107
|
+
botui_media_enabled: bool = True
|
|
108
|
+
"""是否把机器人收发的媒体 / 文件保存到本地媒体库。
|
|
109
|
+
|
|
110
|
+
数据放在 localstore 插件数据目录下的 ``blobs/``(与数据库同一个目录),
|
|
111
|
+
元数据写在 SQLite 的 ``blobs`` 表里。"""
|
|
112
|
+
botui_media_max_bytes: int = 512 * 1024 * 1024
|
|
113
|
+
"""媒体库总体积上限(字节),默认 512 MB。
|
|
114
|
+
|
|
115
|
+
超出后先淘汰**没有被任何聊天记录引用**的资源(按最久未访问),仍超才动
|
|
116
|
+
被引用的那批。``0`` 表示不限制 —— 与 ``BOTUI_MEDIA_MAX_FILES`` 同时为
|
|
117
|
+
``0`` 时等于关闭媒体库。"""
|
|
118
|
+
botui_media_max_files: int = 20000
|
|
119
|
+
"""媒体库的资源条数上限,默认 2 万条;``0`` 表示不限制。
|
|
120
|
+
|
|
121
|
+
资源按内容去重,条数通常远小于消息数。"""
|
|
122
|
+
botui_media_file_max_bytes: int = 20 * 1024 * 1024
|
|
123
|
+
"""单个资源超过这个体积就不入库(字节),默认 20 MB;``0`` 表示只受总量限制。
|
|
124
|
+
|
|
125
|
+
大视频这类动辄几百 MB 的文件没必要为了「防链接失效」占满磁盘。"""
|
|
126
|
+
botui_media_ttl: float = 3 * 24 * 3600.0
|
|
127
|
+
"""**没有被聊天记录引用**的资源保留多久(秒),默认 3 天;``0`` 表示不回收。
|
|
128
|
+
|
|
129
|
+
覆盖「选了附件没发出去」「抓回来但消息很快被删了」这类情况。"""
|
|
130
|
+
botui_media_retention: float = 7 * 24 * 3600.0
|
|
131
|
+
"""**被聊天记录引用**的资源最多保留多久(秒),默认 7 天。
|
|
132
|
+
|
|
133
|
+
``0`` 表示永不按时间回收(只受体积 / 条数上限约束)—— 想「一直留着」就
|
|
134
|
+
设成 0,同时把两个上限调大。"""
|
|
135
|
+
botui_media_fetch_timeout: float = 20.0
|
|
136
|
+
"""下载单个媒体 / 文件到媒体库时的超时时间(秒)。"""
|
|
137
|
+
botui_media_max_bytes_per_message: int = 5 * 1024 * 1024 * 4
|
|
138
|
+
"""单条消息里所有待入库内容加起来的上限(字节),默认 20 MB。
|
|
139
|
+
|
|
140
|
+
防止「一条消息里塞几十个文件」把一次采集拖成几十秒。"""
|
|
141
|
+
botui_media_cleanup_interval: int = 10
|
|
142
|
+
"""媒体库与过期数据自动回收的间隔(分钟),默认 10 分钟;``0`` 表示不周期回收。
|
|
143
|
+
|
|
144
|
+
启动时会先回收一次,之后按这个间隔轮询 —— 上传后不发送的附件因此最多
|
|
145
|
+
留一个周期就被清掉,不会一直占着磁盘。"""
|
|
146
|
+
|
|
147
|
+
# ── 其他 ────────────────────────────────────────────────────────────
|
|
148
|
+
botui_debug: bool = False
|
|
149
|
+
"""打印更详细的调试日志"""
|
|
150
|
+
|
|
151
|
+
@field_validator('botui_route')
|
|
152
|
+
@classmethod
|
|
153
|
+
def _check_route(cls, v: str) -> str:
|
|
154
|
+
route = (v or '').strip()
|
|
155
|
+
if not route.startswith('/'):
|
|
156
|
+
route = '/' + route
|
|
157
|
+
return '/' if route == '/' else route.rstrip('/')
|
|
158
|
+
|
|
159
|
+
@field_validator('botui_page_size')
|
|
160
|
+
@classmethod
|
|
161
|
+
def _check_page_size(cls, v: int) -> int:
|
|
162
|
+
return min(200, max(10, int(v)))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# 配置加载
|
|
166
|
+
plugin_config: Config = get_plugin_config(Config)
|
|
167
|
+
global_config = get_driver().config
|
|
168
|
+
|
|
169
|
+
# 机器人昵称(用于给自己发出的消息署名)
|
|
170
|
+
NICKNAME: str = next(iter(global_config.nickname), '') if global_config.nickname else ''
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""把消息里可抓的媒体 / 文件收进媒体库(见 ``mediastore.py``)。
|
|
2
|
+
|
|
3
|
+
为什么要单独一个模块、而不是直接写在 ``capture.py`` 里:下载是**纯 IO**,
|
|
4
|
+
既要能被「收到消息」的钩子调用,也必须能被定时任务、测试单独驱动;把「从
|
|
5
|
+
消息段里挑出可入库的项 → 下载 → 落盘 → 回填地址」这套流程收在一处,采集侧
|
|
6
|
+
只需调一个 :func:`ingest_message`。
|
|
7
|
+
|
|
8
|
+
回填(把段里的 ``url`` 换成 ``/api/media/<id>``)是这里的关键副作用:一旦换成
|
|
9
|
+
媒体库地址,WebUI 就再也不会去碰那个随时会失效的原始链接 —— 这也正是「避免
|
|
10
|
+
url 失效以后打不开」的落点。
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import time
|
|
16
|
+
import asyncio
|
|
17
|
+
from typing import TYPE_CHECKING, Any
|
|
18
|
+
|
|
19
|
+
from nonebot import logger
|
|
20
|
+
|
|
21
|
+
from .media import Blocked, TooLarge, download
|
|
22
|
+
from .models import DIR_OUT, MessageRecord
|
|
23
|
+
from .segments import cacheable_segments
|
|
24
|
+
from .mediastore import (
|
|
25
|
+
url_of,
|
|
26
|
+
kind_of,
|
|
27
|
+
ref_for,
|
|
28
|
+
id_from_url,
|
|
29
|
+
ref_of_message_id,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from .config import Config
|
|
34
|
+
from .mediastore import MediaStore
|
|
35
|
+
|
|
36
|
+
#: 媒体直链普遍几分钟就失效,收到消息后多久之内要开始抓(秒)
|
|
37
|
+
FETCH_DELAY = 0.5
|
|
38
|
+
#: 一次并发下载几个(够快,又不至于同时朝上游开几十条连接)
|
|
39
|
+
MAX_CONCURRENCY = 4
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def ingest_segment(
|
|
43
|
+
media: 'MediaStore',
|
|
44
|
+
seg: dict[str, Any],
|
|
45
|
+
*,
|
|
46
|
+
cfg: 'Config',
|
|
47
|
+
ref: str = '',
|
|
48
|
+
) -> bool:
|
|
49
|
+
"""下载并保存单个段,成功时把段里的 ``url`` 换成媒体库地址。
|
|
50
|
+
|
|
51
|
+
返回是否「已经就位」;失败一律静默(媒体库只是「锦上添花」,绝不能影响
|
|
52
|
+
消息记录本身),但会把原因记进 debug 日志。
|
|
53
|
+
|
|
54
|
+
先查库再决定要不要下载 —— 一次查询就同时覆盖了三种情况:原链接已有本地
|
|
55
|
+
副本(命中)、段里本来就是媒体库地址(重复处理)、以及自己发出的附件
|
|
56
|
+
(发送时已入库)。命中时把 ``url`` 统一换成媒体库地址:它是这套东西里最稳
|
|
57
|
+
的门牌,图片、文件、音视频都由同一个接口带着 Range 支持供出。
|
|
58
|
+
|
|
59
|
+
注意回填的是**内存里的段**(返回给前端 / WebSocket 推送的那份,也因此第一
|
|
60
|
+
时间就是稳定地址)。数据库里存的仍是原始地址:万一媒体库被清空,库里如果
|
|
61
|
+
只剩媒体库地址就再也取不回来了,而存原始地址还能回源重下。
|
|
62
|
+
"""
|
|
63
|
+
source = str(seg.get('url') or '').strip()
|
|
64
|
+
if not source:
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
hit = await media.lookup(source)
|
|
68
|
+
if hit is not None:
|
|
69
|
+
seg['url'] = url_of(hit.id)
|
|
70
|
+
if not seg.get('size'):
|
|
71
|
+
seg['size'] = hit.size
|
|
72
|
+
if not seg.get('mime'):
|
|
73
|
+
seg['mime'] = hit.mime or None
|
|
74
|
+
if ref:
|
|
75
|
+
await media.add_ref(source, ref)
|
|
76
|
+
return True
|
|
77
|
+
|
|
78
|
+
# 媒体库地址但取不到条目(被清理了):没有可抓的原始链接,放弃
|
|
79
|
+
if id_from_url(source) or not source.startswith(('http://', 'https://')):
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
limit = int(cfg.botui_media_file_max_bytes)
|
|
83
|
+
name = _name_of(seg)
|
|
84
|
+
kind = kind_of(name, str(seg.get('mime') or ''))
|
|
85
|
+
tmp = await asyncio.to_thread(media.tmp_dir)
|
|
86
|
+
target = tmp / f'{int(time.time() * 1000)}-{abs(hash(source)) % 10**8}.part'
|
|
87
|
+
try:
|
|
88
|
+
size, mime = await download(
|
|
89
|
+
source,
|
|
90
|
+
target,
|
|
91
|
+
max_bytes=limit,
|
|
92
|
+
timeout=float(cfg.botui_media_fetch_timeout),
|
|
93
|
+
)
|
|
94
|
+
except TooLarge:
|
|
95
|
+
logger.debug(f'BotUI 跳过媒体:资源超过上限 {source[:64]}')
|
|
96
|
+
return False
|
|
97
|
+
except Blocked as e:
|
|
98
|
+
logger.debug(f'BotUI 跳过媒体:{e}')
|
|
99
|
+
return False
|
|
100
|
+
except Exception as e:
|
|
101
|
+
logger.debug(f'BotUI 下载媒体失败 {source[:64]}:{e}')
|
|
102
|
+
return False
|
|
103
|
+
|
|
104
|
+
record = await media.put(
|
|
105
|
+
target,
|
|
106
|
+
name=name,
|
|
107
|
+
mime=str(seg.get('mime') or mime or ''),
|
|
108
|
+
kind=kind,
|
|
109
|
+
source=source,
|
|
110
|
+
ref=ref,
|
|
111
|
+
)
|
|
112
|
+
if record is None:
|
|
113
|
+
return False
|
|
114
|
+
seg['url'] = url_of(record.id)
|
|
115
|
+
seg['name'] = name
|
|
116
|
+
if not seg.get('mime'):
|
|
117
|
+
seg['mime'] = record.mime or None
|
|
118
|
+
if not seg.get('size'):
|
|
119
|
+
seg['size'] = size
|
|
120
|
+
return True
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _name_of(seg: dict[str, Any], fallback: str = 'file') -> str:
|
|
124
|
+
from .segments import resolve_file_name
|
|
125
|
+
|
|
126
|
+
return resolve_file_name(
|
|
127
|
+
seg.get('name'), str(seg.get('url') or ''), fallback=fallback
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
async def ingest_segments(
|
|
132
|
+
media: 'MediaStore | None',
|
|
133
|
+
segments: list[dict[str, Any]],
|
|
134
|
+
*,
|
|
135
|
+
cfg: 'Config',
|
|
136
|
+
ref: str = '',
|
|
137
|
+
budget: int = 0,
|
|
138
|
+
) -> int:
|
|
139
|
+
"""批量入库(受并发与总预算限制),返回成功条数。
|
|
140
|
+
|
|
141
|
+
传了 ``budget`` 时改成**逐个**下载:下载前不可能知道真实大小,只有串行才能
|
|
142
|
+
保证「这一条消息总共抓的字节数」不越过上限 —— 并发会让几个资源一起冲进
|
|
143
|
+
闸门,把上限顶穿。预算是给「一条消息塞几十张图」兜底的,串行一点换取硬
|
|
144
|
+
上限是划算的;不同消息之间仍是并发(各自一个后台任务)。
|
|
145
|
+
"""
|
|
146
|
+
if media is None or not media.enabled or not segments:
|
|
147
|
+
return 0
|
|
148
|
+
picked = cacheable_segments(segments)
|
|
149
|
+
if not picked:
|
|
150
|
+
return 0
|
|
151
|
+
|
|
152
|
+
max_workers = 1 if budget > 0 else MAX_CONCURRENCY
|
|
153
|
+
semaphore = asyncio.Semaphore(max_workers)
|
|
154
|
+
spent = 0
|
|
155
|
+
done = 0
|
|
156
|
+
lock = asyncio.Lock()
|
|
157
|
+
|
|
158
|
+
async def _one(seg: dict[str, Any]) -> None:
|
|
159
|
+
nonlocal spent, done
|
|
160
|
+
async with semaphore:
|
|
161
|
+
async with lock:
|
|
162
|
+
if budget > 0 and spent >= budget:
|
|
163
|
+
return
|
|
164
|
+
ok = await ingest_segment(media, seg, cfg=cfg, ref=ref)
|
|
165
|
+
if ok:
|
|
166
|
+
async with lock:
|
|
167
|
+
done += 1
|
|
168
|
+
spent += int(seg.get('size') or 0)
|
|
169
|
+
|
|
170
|
+
await asyncio.gather(*(_one(seg) for seg in picked), return_exceptions=True)
|
|
171
|
+
return done
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
async def ingest_message(
|
|
175
|
+
media: 'MediaStore | None',
|
|
176
|
+
record: MessageRecord,
|
|
177
|
+
*,
|
|
178
|
+
cfg: 'Config',
|
|
179
|
+
) -> int:
|
|
180
|
+
"""把一条记录里可入库的段收进媒体库,并回填段里的链接。
|
|
181
|
+
|
|
182
|
+
- 收到的消息:先落库拿到行号,再抓媒体 —— 抓完用 ``msg:<会话>:<行号>``
|
|
183
|
+
建立引用,回收时就知道这条资源还被聊天记录用着;
|
|
184
|
+
- 自己发出的消息:段里本来就是媒体库地址或本地附件(**不需要也不想**重新
|
|
185
|
+
下载),带 ``msgid:<消息 ID>`` 建立引用即可。
|
|
186
|
+
|
|
187
|
+
两条路径共用 :func:`ingest_segments`::func:`ingest_segment` 自己会跳过
|
|
188
|
+
已经指向媒体库的段、命中已有副本时复用本地文件,所以这里不必分叉。
|
|
189
|
+
"""
|
|
190
|
+
if media is None or not media.enabled or not record.segments:
|
|
191
|
+
return 0
|
|
192
|
+
if record.direction == DIR_OUT:
|
|
193
|
+
ref = ref_of_message_id(record.message_id) or ref_for(
|
|
194
|
+
record.chat_key, record.row_id
|
|
195
|
+
)
|
|
196
|
+
else:
|
|
197
|
+
ref = ref_for(record.chat_key, record.row_id)
|
|
198
|
+
try:
|
|
199
|
+
return await ingest_segments(
|
|
200
|
+
media,
|
|
201
|
+
record.segments,
|
|
202
|
+
cfg=cfg,
|
|
203
|
+
ref=ref,
|
|
204
|
+
budget=int(cfg.botui_media_max_bytes_per_message),
|
|
205
|
+
)
|
|
206
|
+
except Exception as e: # pragma: no cover - 入库失败不影响记录
|
|
207
|
+
logger.debug(f'BotUI 保存消息媒体失败:{e}')
|
|
208
|
+
return 0
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
__all__ = [
|
|
212
|
+
'FETCH_DELAY',
|
|
213
|
+
'ingest_message',
|
|
214
|
+
'ingest_segment',
|
|
215
|
+
'ingest_segments',
|
|
216
|
+
]
|