nonebot-plugin-botui 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,954 @@
1
+ """插件唯一的媒体库:机器人收到的、WebUI 上传的图片 / 语音 / 文件都放这里。
2
+
3
+ **为什么要有这个统一存储。** 之前有两套东西在做同一件事:``uploads.py`` 存
4
+ WebUI 上传的附件、``filecache.py`` 存收到的媒体。两套的布局、过期规则、取回
5
+ 接口都不一样,于是出现三种地址(``/api/upload``、``/api/cache``、代理原链接),
6
+ 「自己发的图」和「收到的图」走的是两条互不相干的保命路径 —— 一条断了就查不出
7
+ 原因。现在合成一个:**一次落盘、一份目录、一张表、一个取回地址**。
8
+
9
+ **为什么按内容(MD5)命名。** 资源 id 就是它内容的 MD5:文件按
10
+ ``blobs/<前两位>/<md5>`` 落盘,消息里引用的地址是 ``/api/media/<md5>``。
11
+ 内容相同的东西天然只有一份,不必再维护「同一个链接只存一次」的去重表,也不
12
+ 会因为在两个消息里出现就各占一份磁盘。文件名与 MIME 属于「展示信息」,存在
13
+ 数据库里(``name`` / ``names``),不参与落盘。
14
+
15
+ **为什么用数据库而不是散落的 JSON。** 每个资源一个 ``meta.json`` 加一个全局
16
+ ``index.json``,等于把索引状态摊在文件系统上:查询要遍历目录、并发要抢文件锁、
17
+ 崩溃会留下半个索引(还得写扫盘重建的兜底代码)。这些正是 SQLite 擅长的事,
18
+ 插件的消息本来就存在同一个库里 —— 媒体元数据表直接建在那条连接上,与消息共享
19
+ 同一个事务和锁,不再有第二份需要同步的状态。
20
+
21
+ **上传走临时区。** WebUI 选好附件到真正发送之间,文件只是「待发送」的:它
22
+ 还不是聊天记录的一部分,也就不该进媒体库占配额、更不该被内容去重(同一个
23
+ 文件改个名字重发是很正常的)。所以 :meth:`MediaStore.stage` 用 :mod:`tempfile`
24
+ 把它放在系统临时目录,:meth:`MediaStore.cancel` 直接删掉;直到
25
+ :meth:`MediaStore.commit`(发送那一刻)才按内容 md5 搬进媒体库。如果用户选了
26
+ 附件却没发,临时文件随进程结束或按 ttl 回收。
27
+
28
+ 磁盘布局::
29
+
30
+ blobs/
31
+ aB/
32
+ aBcD…xyz # 正文,文件名就是内容的 md5
33
+ blobs/_tmp/ # 下载中等候入库的临时文件,不参与索引
34
+
35
+ 发送文件时为了不影响 QQ 适配器(它取的是**磁盘文件名**)而临时链接出一份带
36
+ 真实文件名的副本,由调用方发完即删,见 ``api._send_path``。
37
+
38
+ 引用(``refs`` 列,JSON 数组)是回收策略的关键:``msg:<会话>:<行号>`` 表示
39
+ 「哪条聊天记录在用它」,``msgid:<消息 ID>`` 表示自己发出的那条。**有引用的资源
40
+ 按 ``retention`` 保留,没有引用的按 ``ttl`` 回收** —— 否则聊天记录里的图会莫名
41
+ 其妙变坏;反过来,选完却没发出去的附件也不会赖着不走。
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import os
47
+ import re
48
+ import json
49
+ import time
50
+ import shutil
51
+ import asyncio
52
+ import hashlib
53
+ import secrets
54
+ import tempfile
55
+ import mimetypes
56
+ from typing import Any
57
+ from pathlib import Path
58
+ from dataclasses import dataclass
59
+
60
+ import aiosqlite
61
+ from nonebot import logger
62
+
63
+ #: 资源 id 的形状:内容 MD5(32 位十六进制)。取回时严格校验,杜绝路径穿越。
64
+ ID_PATTERN = r'^[0-9a-f]{32}$'
65
+ _VALID_ID = re.compile(ID_PATTERN)
66
+ #: 从取回地址里抠出资源 id(``/api/media/<md5>``)
67
+ _URL_ID = re.compile(r'/api/media/([0-9a-f]{32})')
68
+
69
+ _TMP_DIR = '_tmp'
70
+ #: 目录按 id 前两位分桶,避免一个目录塞几万个文件
71
+ _BUCKET_LEN = 2
72
+
73
+ #: 可以(也值得)缓存到本地的消息段类型
74
+ MEDIA_TYPES = frozenset({'image', 'file', 'voice', 'audio', 'video'})
75
+
76
+ #: 待发送附件(系统临时区的内存索引)最多留几份。
77
+ #: 防止认证用户在发送前反复上传,把临时目录撑大 —— 超出时按最旧优先丢弃
78
+ #: (正文一并删掉),与 ``ttl`` 回收拍在同一处。
79
+ MAX_PENDING = 64
80
+
81
+ #: 引用前缀:聊天记录 / 已发出的消息
82
+ _MSG_PREFIX = 'msg:'
83
+ _MSGID_PREFIX = 'msgid:'
84
+
85
+ #: 常见图片后缀(决定附件该按图片还是按文件发送)
86
+ IMAGE_SUFFIXES = frozenset(
87
+ {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg', '.ico', '.tif', '.tiff'}
88
+ )
89
+
90
+ SCHEMA = """
91
+ CREATE TABLE IF NOT EXISTS blobs (
92
+ id TEXT PRIMARY KEY,
93
+ name TEXT DEFAULT '',
94
+ names TEXT DEFAULT '[]',
95
+ mime TEXT DEFAULT '',
96
+ kind TEXT DEFAULT 'file',
97
+ size INTEGER DEFAULT 0,
98
+ source TEXT DEFAULT '',
99
+ refs TEXT DEFAULT '[]',
100
+ referenced INTEGER DEFAULT 0,
101
+ created REAL DEFAULT 0,
102
+ accessed REAL DEFAULT 0
103
+ );
104
+ CREATE INDEX IF NOT EXISTS idx_blobs_accessed ON blobs(accessed);
105
+ CREATE INDEX IF NOT EXISTS idx_blobs_source ON blobs(source);
106
+ """
107
+
108
+
109
+ # ── 文件名与类型 ────────────────────────────────────────────────────────
110
+ def safe_filename(raw: Any, fallback: str = 'file') -> str:
111
+ """把外部给的文件名清洗成可安全展示/落盘的名字。
112
+
113
+ 只保留基名并剔掉路径分隔符与控制字符:浏览器偶尔会带上完整路径
114
+ (老 IE 的 ``C:\\path\\a.txt``),直接拿来拼路径就是路径穿越。
115
+ """
116
+ text = str(raw or '').replace('\\', '/').split('/')[-1]
117
+ cleaned = ''.join(
118
+ ch for ch in text if ch.isprintable() and ch not in '<>:"|?*\r\n\t'
119
+ ).strip()
120
+ cleaned = cleaned.strip('. ')
121
+ return (cleaned or fallback)[:120]
122
+
123
+
124
+ def kind_of(name: str, mime: str = '') -> str:
125
+ """判断资源该按图片还是按文件对待(决定发送时的段类型)。"""
126
+ if mime.lower().startswith('image/'):
127
+ return 'image'
128
+ return 'image' if Path(name).suffix.lower() in IMAGE_SUFFIXES else 'file'
129
+
130
+
131
+ def guess_type(name: str) -> str:
132
+ """按文件名猜 MIME(猜不出返回空串,交由适配器自行判断)。"""
133
+ return mimetypes.guess_type(name)[0] or ''
134
+
135
+
136
+ # ── 地址 ────────────────────────────────────────────────────────────────
137
+ def url_of(fid: str) -> str:
138
+ """资源 id 对应的取回地址(延迟读配置,避免与 config 形成导入环)。"""
139
+ if not fid:
140
+ return ''
141
+ from .config import plugin_config as cfg
142
+
143
+ return f'{cfg.botui_route}/api/media/{fid}'
144
+
145
+
146
+ def id_from_url(url: Any) -> str:
147
+ """从消息段里的地址取出资源 id(不是本插件地址时返回空串)。"""
148
+ if not isinstance(url, str) or not url:
149
+ return ''
150
+ match = _URL_ID.search(url)
151
+ return match.group(1) if match else ''
152
+
153
+
154
+ def is_plugin_url(url: Any) -> bool:
155
+ """是不是本插件自己的媒体地址(不必下载,但要登记引用)。"""
156
+ if not isinstance(url, str) or not url.startswith('/'):
157
+ return False
158
+ from .config import plugin_config as cfg
159
+
160
+ route = str(cfg.botui_route or '').rstrip('/')
161
+ if not route:
162
+ return False
163
+ return url.startswith(f'{route}/api/media/')
164
+
165
+
166
+ def ref_for(chat_key: str, row_id: int) -> str:
167
+ """聊天记录引用标记(``msg:<会话>:<行号>``)。"""
168
+ if not chat_key or row_id <= 0:
169
+ return ''
170
+ return f'{_MSG_PREFIX}{chat_key}:{int(row_id)}'
171
+
172
+
173
+ def ref_of_message_id(message_id: Any) -> str:
174
+ """按消息 ID 的引用标记(机器人发出的消息可能只有 message_id)。"""
175
+ text = str(message_id or '').strip()
176
+ return f'{_MSGID_PREFIX}{text}' if text else ''
177
+
178
+
179
+ def _is_real_ref(ref: str) -> bool:
180
+ return ref.startswith(_MSG_PREFIX) or ref.startswith(_MSGID_PREFIX)
181
+
182
+
183
+ def _md5_of(path: Path) -> str:
184
+ """分块计算文件内容的 MD5(大文件也不占内存)。"""
185
+ digest = hashlib.md5()
186
+ with Path(path).open('rb') as handle:
187
+ for chunk in iter(lambda: handle.read(1024 * 1024), b''):
188
+ digest.update(chunk)
189
+ return digest.hexdigest()
190
+
191
+
192
+ @dataclass(slots=True)
193
+ class MediaRecord:
194
+ """一条已经落到本地磁盘的媒体资源。"""
195
+
196
+ id: str
197
+ name: str
198
+ size: int
199
+ mime: str
200
+ kind: str
201
+ source: str
202
+ created: float
203
+ accessed: float
204
+ path: Path
205
+ #: 是否还处在「待发送」的临时区(尚未搬进媒体库)
206
+ pending: bool = False
207
+
208
+ def to_dict(self) -> dict[str, Any]:
209
+ return {
210
+ 'id': self.id,
211
+ 'name': self.name,
212
+ 'size': self.size,
213
+ 'mime': self.mime or None,
214
+ 'kind': self.kind,
215
+ 'source': self.source or None,
216
+ 'created': self.created,
217
+ 'accessed': self.accessed,
218
+ 'pending': self.pending,
219
+ 'url': url_of(self.id),
220
+ }
221
+
222
+
223
+ @dataclass(slots=True)
224
+ class PendingUpload:
225
+ """一份「选了但还没发」的附件,正放在系统临时目录里。
226
+
227
+ 元数据只在内存里 —— 它不是聊天记录的一部分,重启后本就该丢;正文由
228
+ :mod:`tempfile` 保管,取消或过期即删。
229
+ """
230
+
231
+ id: str
232
+ name: str
233
+ size: int
234
+ mime: str
235
+ kind: str
236
+ created: float
237
+ path: Path
238
+
239
+ def to_record(self) -> MediaRecord:
240
+ return MediaRecord(
241
+ id=self.id,
242
+ name=self.name,
243
+ size=self.size,
244
+ mime=self.mime,
245
+ kind=self.kind,
246
+ source='',
247
+ created=self.created,
248
+ accessed=self.created,
249
+ path=self.path,
250
+ pending=True,
251
+ )
252
+
253
+
254
+ class MediaStore:
255
+ """媒体资源的持久化与回收。
256
+
257
+ 跑在 ``MessageStore`` 的那条 SQLite 连接上(见 :meth:`attach`):媒体元数据
258
+ 与消息记录因此共享同一个事务、同一把锁,不存在「两份状态谁先写」的问题。
259
+ """
260
+
261
+ def __init__(
262
+ self,
263
+ directory: Path,
264
+ *,
265
+ max_bytes: int = 0,
266
+ max_files: int = 0,
267
+ ttl: float = 0.0,
268
+ retention: float = 7 * 24 * 3600.0,
269
+ file_max_bytes: int = 0,
270
+ ) -> None:
271
+ self.dir = Path(directory)
272
+ self.max_bytes = int(max_bytes)
273
+ self.max_files = int(max_files)
274
+ self.ttl = float(ttl)
275
+ self.retention = float(retention)
276
+ #: 单个资源超过这个大小就不入库(0 表示只受总量限制)
277
+ self.file_max_bytes = int(file_max_bytes)
278
+ self._db: aiosqlite.Connection | None = None
279
+ self._lock: asyncio.Lock | None = None
280
+ #: 待发送附件(内存索引,正文在系统临时目录里,见 :meth:`stage`)
281
+ self._pending: dict[str, PendingUpload] = {}
282
+ #: 本次进程内的运行统计(重启归零,界面上给个近似值即可)
283
+ self._counters: dict[str, int] = {
284
+ 'fetched': 0,
285
+ 'hits': 0,
286
+ 'misses': 0,
287
+ 'dropped': 0,
288
+ }
289
+ #: 最近一次刷新 accessed 的时间,避免每次读都写库
290
+ self._touched: dict[str, float] = {}
291
+
292
+ # ── 生命周期 ────────────────────────────────────────────────────────
293
+ @property
294
+ def enabled(self) -> bool:
295
+ """是否有配额(两个上限都为 0 表示不保存媒体)。"""
296
+ return self.max_bytes > 0 or self.max_files > 0
297
+
298
+ @property
299
+ def ready(self) -> bool:
300
+ return self._db is not None
301
+
302
+ async def attach(self, db: aiosqlite.Connection, lock: asyncio.Lock) -> None:
303
+ """挂到消息库的连接上,建表并清掉上次运行残留的临时文件。"""
304
+ self._db = db
305
+ self._lock = lock
306
+ await db.executescript(SCHEMA)
307
+ await db.commit()
308
+ await asyncio.to_thread(self._prepare_dirs)
309
+
310
+ def _prepare_dirs(self) -> None:
311
+ self.dir.mkdir(parents=True, exist_ok=True)
312
+ shutil.rmtree(self.dir / _TMP_DIR, ignore_errors=True)
313
+
314
+ def allows(self, size: int) -> bool:
315
+ """单个资源是否值得保存。"""
316
+ if not self.enabled or size <= 0:
317
+ return False
318
+ if self.file_max_bytes > 0 and size > self.file_max_bytes:
319
+ return False
320
+ return not (self.max_bytes > 0 and size > self.max_bytes)
321
+
322
+ def reset_stats(self) -> None:
323
+ """把「本次运行」的统计清零(界面上的重置按钮用)。"""
324
+ for key in self._counters:
325
+ self._counters[key] = 0
326
+
327
+ # ── 路径 ────────────────────────────────────────────────────────────
328
+ def _path(self, fid: str) -> Path:
329
+ if not fid or not _VALID_ID.match(fid):
330
+ raise ValueError('非法的资源 id')
331
+ return self.dir / fid[:_BUCKET_LEN] / fid
332
+
333
+ def tmp_dir(self) -> Path:
334
+ """给「先下载到临时文件再入库」用的目录(不会被当成资源)。"""
335
+ path = self.dir / _TMP_DIR
336
+ path.mkdir(parents=True, exist_ok=True)
337
+ return path
338
+
339
+ # ── 行 -> 记录 ──────────────────────────────────────────────────────
340
+ def _row_to_record(self, row: aiosqlite.Row) -> MediaRecord | None:
341
+ fid = str(row['id'])
342
+ try:
343
+ path = self._path(fid)
344
+ except ValueError: # pragma: no cover - 库里不该有非法 id
345
+ return None
346
+ if not path.is_file():
347
+ return None
348
+ return MediaRecord(
349
+ id=fid,
350
+ name=str(row['name'] or ''),
351
+ size=int(row['size'] or 0),
352
+ mime=str(row['mime'] or ''),
353
+ kind='image' if str(row['kind']) == 'image' else 'file',
354
+ source=str(row['source'] or ''),
355
+ created=float(row['created'] or 0.0),
356
+ accessed=float(row['accessed'] or 0.0),
357
+ path=path,
358
+ )
359
+
360
+ async def _fetch(self, sql: str, params: tuple[Any, ...]) -> aiosqlite.Row | None:
361
+ if self._db is None:
362
+ return None
363
+ async with self._db.execute(sql, params) as cur:
364
+ return await cur.fetchone()
365
+
366
+ # ── 读取 ────────────────────────────────────────────────────────────
367
+ async def get(self, fid: str) -> MediaRecord | None:
368
+ """按 id 取回媒体库里的资源(id 非法或文件已丢失时返回 None)。"""
369
+ if not fid or not _VALID_ID.match(fid):
370
+ return None
371
+ row = await self._fetch('SELECT * FROM blobs WHERE id = ?', (fid,))
372
+ return self._row_to_record(row) if row is not None else None
373
+
374
+ def pending(self, fid: str) -> PendingUpload | None:
375
+ """按 id 取一份待发送附件(只在内存索引里找)。"""
376
+ return self._pending.get(str(fid or ''))
377
+
378
+ async def resolve(self, fid: str) -> MediaRecord | None:
379
+ """按 id 取回资源,媒体库与待发送临时区**都查**(取回接口用这个)。"""
380
+ record = await self.get(fid)
381
+ if record is not None:
382
+ return record
383
+ item = self._pending.get(str(fid or ''))
384
+ return item.to_record() if item is not None else None
385
+
386
+ async def lookup(self, key: str) -> MediaRecord | None:
387
+ """按「来源链接」或「取回地址」取回资源,命中时刷新访问时间。
388
+
389
+ 同一个资源有两个门牌:``https://…``(来源)与 ``/api/media/<md5>``
390
+ (消息段里存的地址)。消息段去重比对的是前者,读取时拿到的是后者,
391
+ 两种情况都要能查到同一条目。
392
+
393
+ 「命中 / 未命中」只在这里计数:``has`` 之类的内部存在性检查改走
394
+ :meth:`_find`,免得一次「取回」被内部查询重复计成好几次。
395
+ """
396
+ record = await self._find(key)
397
+ if record is None:
398
+ self._counters['misses'] += 1
399
+ return None
400
+ self._counters['hits'] += 1
401
+ await self._touch(record.id)
402
+ return record
403
+
404
+ async def _find(self, key: str) -> MediaRecord | None:
405
+ """按来源链接或取回地址查一条记录(不计入命中统计、不刷新访问时间)。"""
406
+ text = str(key or '').strip()
407
+ if not text:
408
+ return None
409
+ fid = id_from_url(text)
410
+ if fid:
411
+ return await self.get(fid)
412
+ row = await self._fetch(
413
+ 'SELECT * FROM blobs WHERE source = ? ORDER BY created DESC LIMIT 1',
414
+ (text,),
415
+ )
416
+ return self._row_to_record(row) if row is not None else None
417
+
418
+ async def has(self, key: str) -> bool:
419
+ return await self._find(key) is not None
420
+
421
+ async def _touch(self, fid: str) -> None:
422
+ """刷新 accessed(同一分钟内只写一次,避免读图片就打一次库)。"""
423
+ now = time.time()
424
+ if now - self._touched.get(fid, 0.0) < 60.0:
425
+ return
426
+ self._touched[fid] = now
427
+ if not self.ready:
428
+ return
429
+ assert self._db is not None
430
+ assert self._lock is not None
431
+ async with self._lock:
432
+ await self._db.execute(
433
+ 'UPDATE blobs SET accessed = ? WHERE id = ?', (now, fid)
434
+ )
435
+ await self._db.commit()
436
+
437
+ # ── 待发送附件(临时区) ────────────────────────────────────────────
438
+ async def stage(
439
+ self, data: bytes, *, name: str = 'file', mime: str = '', kind: str = ''
440
+ ) -> MediaRecord | None:
441
+ """把一份待发送的附件放进系统临时区,返回一个可直接预览 / 取回的 id。
442
+
443
+ 这里刻意**不**写进媒体库:还没发送的东西不该占配额,也不该被内容去重
444
+ (同一个文件改名重发是常事)。真正的入库发生在 :meth:`commit`。
445
+ """
446
+ if not data or not self.enabled or not self.allows(len(data)):
447
+ if data:
448
+ self._counters['dropped'] += 1
449
+ return None
450
+ filename = safe_filename(name)
451
+ mime = (mime or '').split(';')[0].strip() or guess_type(filename)
452
+ uid = f'p{secrets.token_urlsafe(16)}'
453
+ handle, raw_path = await asyncio.to_thread(
454
+ tempfile.mkstemp, prefix='botui-upload-', suffix='.part'
455
+ )
456
+ path = Path(raw_path)
457
+ try:
458
+ await asyncio.to_thread(os.write, handle, data)
459
+ except OSError as e: # pragma: no cover - 磁盘异常
460
+ logger.debug(f'BotUI 写上传临时文件失败:{e}')
461
+ await asyncio.to_thread(os.close, handle)
462
+ await asyncio.to_thread(path.unlink, True)
463
+ return None
464
+ await asyncio.to_thread(os.close, handle)
465
+ item = PendingUpload(
466
+ id=uid,
467
+ name=filename,
468
+ size=len(data),
469
+ mime=mime,
470
+ kind=kind if kind == 'image' else kind_of(filename, mime),
471
+ created=time.time(),
472
+ path=path,
473
+ )
474
+ self._pending[uid] = item
475
+ await self._evict_pending()
476
+ return item.to_record()
477
+
478
+ async def _evict_pending(self) -> int:
479
+ """待发送附件超出 :data:`MAX_PENDING` 时按最旧优先丢弃。"""
480
+ overflow = len(self._pending) - MAX_PENDING
481
+ if overflow <= 0:
482
+ return 0
483
+ oldest = sorted(self._pending.values(), key=lambda i: i.created)[:overflow]
484
+ for item in oldest:
485
+ self._pending.pop(item.id, None)
486
+ await asyncio.to_thread(item.path.unlink, True)
487
+ return overflow
488
+
489
+ async def save(
490
+ self,
491
+ data: bytes,
492
+ *,
493
+ name: str = 'file',
494
+ mime: str = '',
495
+ kind: str = '',
496
+ source: str = '',
497
+ ref: str = '',
498
+ ) -> MediaRecord | None:
499
+ """把一段字节直接存进媒体库(测试与内部调用走这里)。"""
500
+ if not data or not self.allows(len(data)):
501
+ if data:
502
+ self._counters['dropped'] += 1
503
+ return None
504
+ tmp = self.tmp_dir() / f'{secrets.token_urlsafe(8)}.part'
505
+ try:
506
+ await asyncio.to_thread(tmp.write_bytes, data)
507
+ except OSError as e: # pragma: no cover - 磁盘异常
508
+ logger.debug(f'BotUI 写媒体临时文件失败:{e}')
509
+ tmp.unlink(missing_ok=True)
510
+ return None
511
+ return await self.put(
512
+ tmp, name=name, mime=mime, kind=kind, source=source, ref=ref
513
+ )
514
+
515
+ async def put(
516
+ self,
517
+ src: Path,
518
+ *,
519
+ name: str = 'file',
520
+ mime: str = '',
521
+ kind: str = '',
522
+ source: str = '',
523
+ ref: str = '',
524
+ ) -> MediaRecord | None:
525
+ """把一个临时文件按内容 md5 搬进媒体库;不适合保存时删掉并返回 None。
526
+
527
+ 落盘名就是内容 MD5,因此内容相同的文件天然共用一份;命中已有条目时
528
+ 只把「文件名 / 来源 / 引用」这些展示信息并进去,临时文件直接丢弃。
529
+ """
530
+ src = Path(src)
531
+ try:
532
+ size = src.stat().st_size
533
+ except OSError:
534
+ return None
535
+ if not self.allows(size):
536
+ self._counters['dropped'] += 1
537
+ src.unlink(missing_ok=True)
538
+ return None
539
+
540
+ fid = await asyncio.to_thread(_md5_of, src)
541
+ existing = await self.get(fid)
542
+ if existing is not None:
543
+ src.unlink(missing_ok=True)
544
+ await self._merge(existing, name=name, mime=mime, kind=kind, source=source)
545
+ if ref:
546
+ await self.add_ref(fid, ref)
547
+ return await self.get(fid)
548
+
549
+ filename = safe_filename(name)
550
+ mime = (mime or '').split(';')[0].strip() or guess_type(filename)
551
+ kind = 'image' if kind == 'image' else kind_of(filename, mime)
552
+ target = self._path(fid)
553
+ try:
554
+ await asyncio.to_thread(target.parent.mkdir, parents=True, exist_ok=True)
555
+ # 同分区时 replace 是原子的;跨设备(临时目录在别的挂载点)退回复制
556
+ try:
557
+ await asyncio.to_thread(os.replace, src, target)
558
+ except OSError:
559
+ await asyncio.to_thread(shutil.copyfile, src, target)
560
+ src.unlink(missing_ok=True)
561
+ except OSError as e:
562
+ shutil.rmtree(target.parent, ignore_errors=True)
563
+ src.unlink(missing_ok=True)
564
+ logger.debug(f'BotUI 写入媒体失败:{e}')
565
+ return None
566
+
567
+ now = time.time()
568
+ refs = [ref] if ref else []
569
+ await self._execute(
570
+ 'INSERT OR REPLACE INTO blobs (id, name, names, mime, kind, size, source, '
571
+ 'refs, referenced, created, accessed) '
572
+ 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
573
+ (
574
+ fid,
575
+ filename,
576
+ json.dumps([filename], ensure_ascii=False),
577
+ mime,
578
+ kind,
579
+ size,
580
+ str(source or ''),
581
+ json.dumps(refs, ensure_ascii=False),
582
+ 1 if any(_is_real_ref(r) for r in refs) else 0,
583
+ now,
584
+ now,
585
+ ),
586
+ )
587
+ self._counters['fetched'] += 1
588
+ await self._evict()
589
+ return await self.get(fid)
590
+
591
+ async def commit(self, fid: str) -> MediaRecord | None:
592
+ """把一份待发送附件从临时区搬进媒体库(按内容 md5 命名)。
593
+
594
+ 已经是媒体库里的资源时直接返回;返回 None 表示 id 不存在或已过期。
595
+ """
596
+ record = await self.get(fid)
597
+ if record is not None:
598
+ return record
599
+ item = self._pending.pop(str(fid or ''), None)
600
+ if item is None:
601
+ return None
602
+ return await self.put(item.path, name=item.name, mime=item.mime, kind=item.kind)
603
+
604
+ async def cancel(self, fid: str) -> bool:
605
+ """取消一份待发送附件(删掉临时文件),返回是否真的删掉了。"""
606
+ item = self._pending.pop(str(fid or ''), None)
607
+ if item is None:
608
+ return False
609
+ await asyncio.to_thread(item.path.unlink, True)
610
+ return True
611
+
612
+ async def discard_pending(self) -> int:
613
+ """清空全部待发送附件(关闭插件时用,别把临时文件留在系统里)。"""
614
+ items = list(self._pending.values())
615
+ self._pending.clear()
616
+ for item in items:
617
+ await asyncio.to_thread(item.path.unlink, True)
618
+ return len(items)
619
+
620
+ async def _merge(
621
+ self, record: MediaRecord, *, name: str, mime: str, kind: str, source: str
622
+ ) -> None:
623
+ """把新出现的展示信息并进已有条目(内容相同 = 同一个资源)。"""
624
+ new_name = safe_filename(name)
625
+ assert self._db is not None
626
+ assert self._lock is not None
627
+ async with self._lock:
628
+ row = await self._fetch(
629
+ 'SELECT names, mime, kind, source FROM blobs WHERE id = ?',
630
+ (record.id,),
631
+ )
632
+ if row is None: # pragma: no cover - 并发删除
633
+ return
634
+ names = self._parse_list(row['names'])
635
+ if new_name not in names:
636
+ names.append(new_name)
637
+ new_mime = str(row['mime'] or '') or (
638
+ (mime or '').split(';')[0].strip() or guess_type(new_name)
639
+ )
640
+ new_kind = str(row['kind'] or '') or (
641
+ 'image' if kind == 'image' else kind_of(new_name, new_mime)
642
+ )
643
+ await self._db.execute(
644
+ 'UPDATE blobs SET names = ?, name = ?, mime = ?, kind = ?, source = ? '
645
+ 'WHERE id = ?',
646
+ (
647
+ json.dumps(names, ensure_ascii=False),
648
+ record.name or new_name,
649
+ new_mime,
650
+ new_kind,
651
+ str(row['source'] or '') or str(source or ''),
652
+ record.id,
653
+ ),
654
+ )
655
+ await self._db.commit()
656
+
657
+ async def delete(self, fid: str) -> bool:
658
+ """删除一条资源(前端移除待发送附件、或手动删除媒体时用)。"""
659
+ if await self.cancel(fid):
660
+ return True
661
+ record = await self.get(fid)
662
+ if record is None:
663
+ return False
664
+ await self._remove(fid)
665
+ return True
666
+
667
+ async def _execute(self, sql: str, params: tuple[Any, ...]) -> None:
668
+ if not self.ready:
669
+ return
670
+ assert self._db is not None
671
+ assert self._lock is not None
672
+ async with self._lock:
673
+ await self._db.execute(sql, params)
674
+ await self._db.commit()
675
+
676
+ # ── 引用 ────────────────────────────────────────────────────────────
677
+ async def add_ref(self, key: str, ref: str) -> bool:
678
+ """给某个资源追加一条引用(资源不存在时静默跳过)。"""
679
+ if not ref:
680
+ return False
681
+ record = await self._find(key)
682
+ if record is None:
683
+ return False
684
+ assert self._db is not None
685
+ assert self._lock is not None
686
+ async with self._lock:
687
+ row = await self._fetch('SELECT refs FROM blobs WHERE id = ?', (record.id,))
688
+ refs = self._parse_list(row['refs'] if row is not None else None)
689
+ if ref in refs:
690
+ return False
691
+ refs.append(ref)
692
+ await self._db.execute(
693
+ 'UPDATE blobs SET refs = ?, referenced = ? WHERE id = ?',
694
+ (
695
+ json.dumps(refs, ensure_ascii=False),
696
+ 1 if any(_is_real_ref(r) for r in refs) else 0,
697
+ record.id,
698
+ ),
699
+ )
700
+ await self._db.commit()
701
+ return True
702
+
703
+ async def drop_ref(self, key: str, ref: str) -> bool:
704
+ """撤销一条引用(消息被删掉时用)。"""
705
+ record = await self._find(key)
706
+ if record is None:
707
+ return False
708
+ assert self._db is not None
709
+ assert self._lock is not None
710
+ async with self._lock:
711
+ row = await self._fetch('SELECT refs FROM blobs WHERE id = ?', (record.id,))
712
+ refs = self._parse_list(row['refs'] if row is not None else None)
713
+ if ref not in refs:
714
+ return False
715
+ refs.remove(ref)
716
+ await self._db.execute(
717
+ 'UPDATE blobs SET refs = ?, referenced = ? WHERE id = ?',
718
+ (
719
+ json.dumps(refs, ensure_ascii=False),
720
+ 1 if any(_is_real_ref(r) for r in refs) else 0,
721
+ record.id,
722
+ ),
723
+ )
724
+ await self._db.commit()
725
+ return True
726
+
727
+ async def drop_message_refs(self, chat_key: str, row_ids: list[int]) -> None:
728
+ """删掉若干条聊天记录时,同步摘掉它们留下的引用。
729
+
730
+ 引用不跟着消息走的话,媒体会被永久当成「还有人用」,占着配额不放。
731
+ """
732
+ if not row_ids:
733
+ return
734
+ prefixes = [f'{_MSG_PREFIX}{chat_key}:{int(rid)}' for rid in row_ids]
735
+ await self._drop_refs_matching(prefixes)
736
+
737
+ async def drop_refs_for(self, chat_key: str) -> None:
738
+ """删掉整个会话时,摘掉该会话的全部引用。"""
739
+ prefix = f'{_MSG_PREFIX}{chat_key}:'
740
+ await self._drop_refs_matching([prefix])
741
+
742
+ async def _drop_refs_matching(self, prefixes: list[str]) -> None:
743
+ if not self.ready:
744
+ return
745
+ assert self._db is not None
746
+ rows = await self._all_rows()
747
+ changed: list[tuple[str, int, str]] = []
748
+ for row in rows:
749
+ refs = self._parse_list(row['refs'])
750
+ kept = [
751
+ r for r in refs if not any(r == p or r.startswith(p) for p in prefixes)
752
+ ]
753
+ if len(kept) != len(refs):
754
+ changed.append(
755
+ (
756
+ json.dumps(kept, ensure_ascii=False),
757
+ 1 if any(_is_real_ref(r) for r in kept) else 0,
758
+ str(row['id']),
759
+ )
760
+ )
761
+ if not changed:
762
+ return
763
+ assert self._lock is not None
764
+ async with self._lock:
765
+ await self._db.executemany(
766
+ 'UPDATE blobs SET refs = ?, referenced = ? WHERE id = ?', changed
767
+ )
768
+ await self._db.commit()
769
+
770
+ @staticmethod
771
+ def _parse_list(value: Any) -> list[str]:
772
+ if isinstance(value, str):
773
+ try:
774
+ value = json.loads(value or '[]')
775
+ except ValueError:
776
+ value = []
777
+ if not isinstance(value, list):
778
+ return []
779
+ return [str(r) for r in value if r]
780
+
781
+ # ── 淘汰与清理 ──────────────────────────────────────────────────────
782
+ async def _all_rows(self) -> list[aiosqlite.Row]:
783
+ if not self.ready:
784
+ return []
785
+ assert self._db is not None
786
+ async with self._db.execute('SELECT * FROM blobs') as cur:
787
+ return list(await cur.fetchall())
788
+
789
+ async def _remove(self, fid: str) -> None:
790
+ assert self._db is not None
791
+ assert self._lock is not None
792
+ async with self._lock:
793
+ await self._db.execute('DELETE FROM blobs WHERE id = ?', (fid,))
794
+ await self._db.commit()
795
+ try:
796
+ self._path(fid).unlink(missing_ok=True)
797
+ except ValueError: # pragma: no cover - 非法 id
798
+ pass
799
+ self._touched.pop(fid, None)
800
+ self._counters['dropped'] += 1
801
+
802
+ async def _totals(self) -> tuple[int, int]:
803
+ if not self.ready:
804
+ return 0, 0
805
+ assert self._db is not None
806
+ async with self._db.execute(
807
+ 'SELECT COUNT(*) AS n, COALESCE(SUM(size), 0) AS b FROM blobs'
808
+ ) as cur:
809
+ row = await cur.fetchone()
810
+ return (int(row['n']), int(row['b'])) if row is not None else (0, 0)
811
+
812
+ async def _over_quota(self) -> bool:
813
+ count, size = await self._totals()
814
+ if self.max_files > 0 and count > self.max_files:
815
+ return True
816
+ return bool(self.max_bytes > 0 and size > self.max_bytes)
817
+
818
+ async def _evict(self) -> int:
819
+ """超出配额时按「先删没人引用的、再删最旧的」淘汰,返回删除数量。
820
+
821
+ 被聊天记录引用的资源尽量留着 —— 删掉它聊天里的图就真的坏了;但配额是
822
+ 硬约束,实在超了就只好牺牲最旧的那批(记一条 debug 日志)。
823
+ """
824
+ if not self.enabled or not await self._over_quota():
825
+ return 0
826
+ removed = 0
827
+ for referenced in (0, 1):
828
+ while await self._over_quota():
829
+ rows = await self._all_rows()
830
+ candidates = [
831
+ r for r in rows if int(r['referenced'] or 0) == referenced
832
+ ]
833
+ if not candidates:
834
+ break
835
+ if referenced:
836
+ logger.debug('BotUI 媒体库超出配额,删除了仍被聊天记录引用的资源')
837
+ oldest = min(candidates, key=lambda r: float(r['accessed'] or 0.0))
838
+ await self._remove(str(oldest['id']))
839
+ removed += 1
840
+ return removed
841
+
842
+ async def cleanup(self) -> int:
843
+ """按 ttl / retention 回收,并顺带清掉过期的待发送附件,返回删除数量。"""
844
+ if not self.ready:
845
+ return 0
846
+ now = time.time()
847
+ removed = 0
848
+ for row in await self._all_rows():
849
+ fid = str(row['id'])
850
+ base = float(row['accessed'] or row['created'] or 0.0)
851
+ if not base:
852
+ continue
853
+ window = self.retention if int(row['referenced'] or 0) else self.ttl
854
+ if window > 0 and base + window < now:
855
+ await self._remove(fid)
856
+ removed += 1
857
+ removed += await self._evict()
858
+ removed += await self._cleanup_pending(now)
859
+ if removed:
860
+ logger.debug(f'BotUI 媒体库回收了 {removed} 个资源')
861
+ return removed
862
+
863
+ async def _cleanup_pending(self, now: float) -> int:
864
+ """回收「选了但一直没发」的临时附件(按 ttl)。"""
865
+ if self.ttl <= 0:
866
+ return 0
867
+ removed = 0
868
+ for uid, item in list(self._pending.items()):
869
+ if item.created + self.ttl < now:
870
+ self._pending.pop(uid, None)
871
+ await asyncio.to_thread(item.path.unlink, True)
872
+ removed += 1
873
+ return removed
874
+
875
+ async def clear(self, mode: str = 'all') -> dict[str, int]:
876
+ """清理媒体库,返回 ``{'removed': n, 'bytes': n, 'files': n}``。
877
+
878
+ - ``all``:全部清空(含待发送临时附件);
879
+ - ``orphans``:只删**没有被任何聊天记录引用**的资源;
880
+ - ``image`` / ``file``:只清某一类。
881
+ """
882
+ rows = await self._all_rows()
883
+ removed = 0
884
+ freed = 0
885
+ for row in rows:
886
+ if mode == 'orphans' and int(row['referenced'] or 0):
887
+ continue
888
+ if mode in ('image', 'file') and str(row['kind']) != mode:
889
+ continue
890
+ freed += int(row['size'] or 0)
891
+ await self._remove(str(row['id']))
892
+ removed += 1
893
+ if mode == 'all':
894
+ await self.discard_pending()
895
+ await asyncio.to_thread(shutil.rmtree, self.dir, True)
896
+ await asyncio.to_thread(self._prepare_dirs)
897
+ if removed:
898
+ freed_mb = freed / 1048576
899
+ logger.info(
900
+ f'BotUI 清理媒体库:删除 {removed} 个资源(释放 {freed_mb:.1f} MB)'
901
+ )
902
+ self.reset_stats()
903
+ files, _ = await self._totals()
904
+ return {'removed': removed, 'bytes': freed, 'files': files}
905
+
906
+ # ── 统计 ────────────────────────────────────────────────────────────
907
+ async def stats(self) -> dict[str, Any]:
908
+ """给界面用的媒体库概况(含每类的条数与体积)。"""
909
+ buckets = {'image': {'files': 0, 'bytes': 0}, 'file': {'files': 0, 'bytes': 0}}
910
+ referenced = 0
911
+ for row in await self._all_rows():
912
+ kind = 'image' if str(row['kind']) == 'image' else 'file'
913
+ buckets[kind]['files'] += 1
914
+ buckets[kind]['bytes'] += int(row['size'] or 0)
915
+ if int(row['referenced'] or 0):
916
+ referenced += 1
917
+ count, size = await self._totals()
918
+ return {
919
+ 'enabled': self.enabled,
920
+ 'dir': str(self.dir),
921
+ 'files': count,
922
+ 'bytes': size,
923
+ 'images': buckets['image'],
924
+ 'others': buckets['file'],
925
+ 'referenced': referenced,
926
+ 'pending': len(self._pending),
927
+ 'max_bytes': self.max_bytes,
928
+ 'max_files': self.max_files,
929
+ 'file_max_bytes': self.file_max_bytes,
930
+ 'ttl': self.ttl,
931
+ 'retention': self.retention,
932
+ 'fetched': self._counters['fetched'],
933
+ 'hits': self._counters['hits'],
934
+ 'misses': self._counters['misses'],
935
+ 'dropped': self._counters['dropped'],
936
+ }
937
+
938
+
939
+ __all__ = [
940
+ 'ID_PATTERN',
941
+ 'IMAGE_SUFFIXES',
942
+ 'MEDIA_TYPES',
943
+ 'MediaRecord',
944
+ 'MediaStore',
945
+ 'PendingUpload',
946
+ 'guess_type',
947
+ 'id_from_url',
948
+ 'is_plugin_url',
949
+ 'kind_of',
950
+ 'ref_for',
951
+ 'ref_of_message_id',
952
+ 'safe_filename',
953
+ 'url_of',
954
+ ]