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,1271 @@
1
+ """基于 SQLite 的消息存储(写入队列 + 内存热缓存)。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ import asyncio
8
+ from typing import Any
9
+ from pathlib import Path
10
+ from dataclasses import dataclass
11
+
12
+ import aiosqlite
13
+ from nonebot import logger
14
+
15
+ from .media import avatar_of
16
+ from .config import Config
17
+ from .models import (
18
+ DIR_IN,
19
+ KIND_GROUP,
20
+ KIND_PRIVATE,
21
+ BotRecord,
22
+ ChatRecord,
23
+ MessageRecord,
24
+ now_ts,
25
+ chat_key,
26
+ self_id_of,
27
+ split_chat_key,
28
+ )
29
+
30
+ SCHEMA = """
31
+ CREATE TABLE IF NOT EXISTS chats (
32
+ key TEXT PRIMARY KEY,
33
+ kind TEXT NOT NULL,
34
+ chat_id TEXT NOT NULL,
35
+ adapter TEXT DEFAULT '',
36
+ scope TEXT DEFAULT '',
37
+ self_id TEXT DEFAULT '',
38
+ parent_id TEXT DEFAULT '',
39
+ name TEXT DEFAULT '',
40
+ alias TEXT DEFAULT '',
41
+ avatar TEXT DEFAULT '',
42
+ member_count INTEGER,
43
+ last_text TEXT DEFAULT '',
44
+ last_at REAL DEFAULT 0,
45
+ last_direction TEXT DEFAULT '',
46
+ message_count INTEGER DEFAULT 0
47
+ );
48
+ CREATE INDEX IF NOT EXISTS idx_chats_last_at ON chats(last_at DESC);
49
+
50
+ CREATE TABLE IF NOT EXISTS messages (
51
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
52
+ chat_key TEXT NOT NULL,
53
+ chat_kind TEXT NOT NULL,
54
+ chat_id TEXT NOT NULL,
55
+ direction TEXT NOT NULL,
56
+ ts REAL NOT NULL,
57
+ adapter TEXT DEFAULT '',
58
+ scope TEXT DEFAULT '',
59
+ self_id TEXT DEFAULT '',
60
+ parent_id TEXT DEFAULT '',
61
+ chat_name TEXT DEFAULT '',
62
+ chat_avatar TEXT DEFAULT '',
63
+ member_count INTEGER,
64
+ user_id TEXT DEFAULT '',
65
+ user_name TEXT DEFAULT '',
66
+ user_avatar TEXT DEFAULT '',
67
+ role TEXT,
68
+ is_self INTEGER DEFAULT 0,
69
+ text TEXT DEFAULT '',
70
+ segments TEXT DEFAULT '[]',
71
+ message_id TEXT DEFAULT '',
72
+ recallable INTEGER DEFAULT 1,
73
+ api TEXT DEFAULT '',
74
+ recalled INTEGER DEFAULT 0
75
+ );
76
+ CREATE INDEX IF NOT EXISTS idx_messages_chat ON messages(chat_key, ts DESC);
77
+ CREATE INDEX IF NOT EXISTS idx_messages_ts ON messages(ts DESC);
78
+ CREATE INDEX IF NOT EXISTS idx_messages_msgid ON messages(message_id);
79
+
80
+ -- 会话里出现过的成员名册。
81
+ -- 目的是让 WebUI 的「@」能点选成员,而不是让用户自己敲 QQ 号;名册来自
82
+ -- 实际收到的消息(发送者)、@ 过的目标,以及 uninfo 拿到 @ 昵称的缓存。
83
+ CREATE TABLE IF NOT EXISTS members (
84
+ chat_key TEXT NOT NULL,
85
+ user_id TEXT NOT NULL,
86
+ name TEXT DEFAULT '',
87
+ alias TEXT DEFAULT '',
88
+ role TEXT DEFAULT '',
89
+ avatar TEXT DEFAULT '',
90
+ last_at REAL DEFAULT 0,
91
+ appearances INTEGER DEFAULT 0,
92
+ PRIMARY KEY (chat_key, user_id)
93
+ );
94
+ CREATE INDEX IF NOT EXISTS idx_members_chat ON members(chat_key, last_at DESC);
95
+
96
+ -- 连接过的机器人账号。WebUI 右上角的切换器用它列出所有机器人并标记在线状态;
97
+ -- 机器人断开后记录仍然保留(online=0),所以「连接过的」都能看到。
98
+ CREATE TABLE IF NOT EXISTS bots (
99
+ self_id TEXT PRIMARY KEY,
100
+ adapter TEXT DEFAULT '',
101
+ scope TEXT DEFAULT '',
102
+ name TEXT DEFAULT '',
103
+ alias TEXT DEFAULT '',
104
+ avatar TEXT DEFAULT '',
105
+ online INTEGER DEFAULT 0,
106
+ first_seen REAL DEFAULT 0,
107
+ last_seen REAL DEFAULT 0
108
+ );
109
+ CREATE INDEX IF NOT EXISTS idx_bots_last_seen ON bots(last_seen DESC);
110
+
111
+ CREATE TABLE IF NOT EXISTS meta (
112
+ key TEXT PRIMARY KEY,
113
+ value TEXT
114
+ );
115
+ """
116
+
117
+ # 会话热缓存条数上限
118
+ _CHAT_CACHE_LIMIT = 2000
119
+
120
+ # 成员热缓存条数上限(跨所有会话的总条数,超出后丢掉最久没出现的)
121
+ _MEMBER_CACHE_LIMIT = 20000
122
+
123
+ # 单个会话内存里保留的成员数上限
124
+ _MEMBER_PER_CHAT_LIMIT = 500
125
+
126
+ # 写入消息时顺带更新会话摘要。
127
+ # 行拆得多是为了让每行都不超过 88 列(ruff 的 E501),同时保持列对齐便于阅读。
128
+ _UPSERT_CHAT_MESSAGE_SQL = """
129
+ INSERT INTO chats (key, kind, chat_id, adapter, scope, self_id, parent_id,
130
+ name, avatar, member_count, last_text, last_at,
131
+ last_direction, message_count)
132
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
133
+ ON CONFLICT(key) DO UPDATE SET
134
+ adapter = COALESCE(NULLIF(excluded.adapter, ''), chats.adapter),
135
+ scope = COALESCE(NULLIF(excluded.scope, ''), chats.scope),
136
+ self_id = COALESCE(NULLIF(excluded.self_id, ''), chats.self_id),
137
+ parent_id = COALESCE(NULLIF(excluded.parent_id, ''), chats.parent_id),
138
+ name = COALESCE(NULLIF(excluded.name, ''), chats.name),
139
+ avatar = COALESCE(NULLIF(excluded.avatar, ''), chats.avatar),
140
+ member_count = COALESCE(excluded.member_count, chats.member_count),
141
+ last_text = excluded.last_text,
142
+ last_at = MAX(excluded.last_at, chats.last_at),
143
+ last_direction = excluded.last_direction,
144
+ message_count = chats.message_count + 1
145
+ """
146
+
147
+ # 更新会话资料(不动消息计数与摘要)
148
+ _UPSERT_CHAT_SQL = """
149
+ INSERT INTO chats (key, kind, chat_id, adapter, scope, self_id,
150
+ parent_id, name, avatar, member_count)
151
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
152
+ ON CONFLICT(key) DO UPDATE SET
153
+ adapter = COALESCE(NULLIF(excluded.adapter, ''), chats.adapter),
154
+ scope = COALESCE(NULLIF(excluded.scope, ''), chats.scope),
155
+ self_id = COALESCE(NULLIF(excluded.self_id, ''), chats.self_id),
156
+ parent_id = COALESCE(NULLIF(excluded.parent_id, ''), chats.parent_id),
157
+ name = COALESCE(NULLIF(excluded.name, ''), chats.name),
158
+ avatar = COALESCE(NULLIF(excluded.avatar, ''), chats.avatar),
159
+ member_count = COALESCE(excluded.member_count, chats.member_count)
160
+ """
161
+
162
+
163
+ def _safe_json(text: str) -> list[dict[str, Any]]:
164
+ try:
165
+ data = json.loads(text or '[]')
166
+ except Exception:
167
+ return []
168
+ return data if isinstance(data, list) else []
169
+
170
+
171
+ @dataclass(slots=True)
172
+ class MemberRecord:
173
+ """会话成员名册里的一项(用于 WebUI 的 @ 选择菜单)"""
174
+
175
+ user_id: str
176
+ name: str = ''
177
+ alias: str = ''
178
+ role: str = ''
179
+ avatar: str = ''
180
+ last_at: float = 0.0
181
+ appearances: int = 0
182
+
183
+ def to_dict(self) -> dict[str, Any]:
184
+ return {
185
+ 'id': self.user_id,
186
+ # 展示名:用户备注优先,其次才是适配器给的昵称/号码
187
+ 'name': self.alias or self.name or self.user_id,
188
+ 'alias': self.alias or None,
189
+ 'raw_name': self.name or None,
190
+ 'role': self.role or None,
191
+ 'avatar': self.avatar or None,
192
+ 'last_at': self.last_at,
193
+ 'appearances': self.appearances,
194
+ }
195
+
196
+
197
+ class MessageStore:
198
+ """消息与会话的持久化存储。
199
+
200
+ 写入走内部队列,避免直接在事件钩子里阻塞;
201
+ 读取主要依赖内存中的会话热缓存 + SQLite 查询。
202
+ """
203
+
204
+ def __init__(self, config: Config, db_file: Path):
205
+ self._cfg = config
206
+ self._path = db_file
207
+ # 路径通常来自 localstore(目录已建好);这里再兜一次底,
208
+ # 好让 store 也能直接用在任意路径上(例如测试里的 tmp_path)。
209
+ self._path.parent.mkdir(parents=True, exist_ok=True)
210
+ self._db: aiosqlite.Connection | None = None
211
+ self._queue: asyncio.Queue[MessageRecord | None] = asyncio.Queue()
212
+ self._writer: asyncio.Task | None = None
213
+ self._lock = asyncio.Lock()
214
+ self._ready = False
215
+ self._since_start = now_ts()
216
+ # 会话热缓存:WebUI 的聊天列表直接读它
217
+ self._chats: dict[str, ChatRecord] = {}
218
+ # 会话成员热缓存:chat_key -> (user_id -> MemberRecord)
219
+ self._members: dict[str, dict[str, MemberRecord]] = {}
220
+ # 连接过的机器人:self_id -> BotRecord(在线状态由连接钩子维护)
221
+ self._bots: dict[str, BotRecord] = {}
222
+ # 每条记录落库后回调(用于推送给 WebUI 的增量事件流)
223
+ self.on_insert: Any = None
224
+ # 每条记录落库后回调(用于给媒体登记引用,见 mediastore.py)
225
+ self.on_persist: Any = None
226
+ # 媒体库:消息被清理时顺带摘掉引用(由插件在启动后注入)
227
+ self.media: Any = None
228
+
229
+ # ── 生命周期 ────────────────────────────────────────────────────────
230
+ @property
231
+ def path(self) -> Path:
232
+ return self._path
233
+
234
+ @property
235
+ def db(self) -> aiosqlite.Connection | None:
236
+ """底层连接(媒体库与消息共用一个 SQLite 文件,见 mediastore.py)。"""
237
+ return self._db
238
+
239
+ @property
240
+ def lock(self) -> asyncio.Lock:
241
+ """写锁(媒体库写元数据时复用它,避免两套锁互相打架)。"""
242
+ return self._lock
243
+
244
+ @property
245
+ def ready(self) -> bool:
246
+ return self._ready
247
+
248
+ async def start(self) -> None:
249
+ if self._db is not None:
250
+ return
251
+ self._db = await aiosqlite.connect(self._path)
252
+ self._db.row_factory = aiosqlite.Row
253
+ await self._db.execute('PRAGMA journal_mode=WAL')
254
+ await self._db.execute('PRAGMA synchronous=NORMAL')
255
+ await self._db.executescript(SCHEMA)
256
+ await self._db.commit()
257
+ await self._load_chats()
258
+ self._writer = asyncio.create_task(self._write_loop())
259
+ self._ready = True
260
+ logger.debug(f'BotUI store ready at {self._path}')
261
+
262
+ async def stop(self) -> None:
263
+ if self._writer is not None:
264
+ await self._queue.put(None)
265
+ try:
266
+ await asyncio.wait_for(self._writer, timeout=5)
267
+ except (TimeoutError, asyncio.TimeoutError):
268
+ self._writer.cancel()
269
+ self._writer = None
270
+ if self._db is not None:
271
+ try:
272
+ await self._db.commit()
273
+ finally:
274
+ await self._db.close()
275
+ self._db = None
276
+ self._ready = False
277
+
278
+ async def _load_chats(self) -> None:
279
+ if self._db is None:
280
+ return
281
+ try:
282
+ async with self._db.execute(
283
+ 'SELECT * FROM chats ORDER BY last_at DESC LIMIT ?',
284
+ (_CHAT_CACHE_LIMIT,),
285
+ ) as cur:
286
+ rows = await cur.fetchall()
287
+ except Exception as e:
288
+ logger.warning(f'Failed to load chat cache: {e}')
289
+ return
290
+ # fetchall() 在类型上是 Iterable[Row],len() 不接受;转成 list 即可,
291
+ # 顺便让下面的循环有个具体容器
292
+ rows = list(rows)
293
+ for row in rows:
294
+ chat = ChatRecord(
295
+ key=row['key'],
296
+ kind=row['kind'],
297
+ chat_id=row['chat_id'],
298
+ adapter=row['adapter'] or '',
299
+ scope=row['scope'] or '',
300
+ self_id=row['self_id'] or '',
301
+ parent_id=row['parent_id'] or '',
302
+ name=row['name'] or '',
303
+ alias=row['alias'] or '',
304
+ avatar=row['avatar'] or '',
305
+ member_count=row['member_count'],
306
+ last_text=row['last_text'] or '',
307
+ last_at=row['last_at'] or 0.0,
308
+ last_direction=row['last_direction'] or '',
309
+ message_count=row['message_count'] or 0,
310
+ )
311
+ self._chats[chat.key] = chat
312
+ logger.debug(f'Loaded {len(rows)} chat(s) into cache')
313
+ await self._load_members()
314
+ await self._load_bots()
315
+
316
+ async def _load_bots(self) -> None:
317
+ """把连接过的机器人读进内存(启动时一律先标记为离线)。
318
+
319
+ 在线状态由 ``Bot.on_connect`` / ``on_disconnect`` 钩子维护:进程刚起来
320
+ 时还没有任何连接,所以先全部置为离线,等钩子把当前连上的机器人点亮。
321
+ """
322
+ if self._db is None:
323
+ return
324
+ try:
325
+ async with self._db.execute(
326
+ 'SELECT * FROM bots ORDER BY last_seen DESC LIMIT 200'
327
+ ) as cur:
328
+ rows = list(await cur.fetchall())
329
+ except Exception as e:
330
+ logger.debug(f'Failed to load bot cache: {e}')
331
+ return
332
+ for row in rows:
333
+ self._bots[row['self_id']] = BotRecord(
334
+ self_id=row['self_id'],
335
+ adapter=row['adapter'] or '',
336
+ scope=row['scope'] or '',
337
+ name=row['name'] or '',
338
+ alias=row['alias'] or '',
339
+ avatar=row['avatar'] or '',
340
+ online=False,
341
+ first_seen=row['first_seen'] or 0.0,
342
+ last_seen=row['last_seen'] or 0.0,
343
+ )
344
+ logger.debug(f'Loaded {len(self._bots)} bot(s) into cache')
345
+
346
+ async def _load_members(self) -> None:
347
+ """把最近活跃的成员名册读进内存(供 WebUI 的 @ 菜单用)"""
348
+ if self._db is None:
349
+ return
350
+ try:
351
+ async with self._db.execute(
352
+ 'SELECT * FROM members ORDER BY last_at DESC LIMIT ?',
353
+ (_MEMBER_CACHE_LIMIT,),
354
+ ) as cur:
355
+ rows = list(await cur.fetchall())
356
+ except Exception as e:
357
+ logger.debug(f'Failed to load member cache: {e}')
358
+ return
359
+ for row in rows:
360
+ bucket = self._members.setdefault(row['chat_key'], {})
361
+ bucket[row['user_id']] = MemberRecord(
362
+ user_id=row['user_id'],
363
+ name=row['name'] or '',
364
+ alias=row['alias'] or '',
365
+ role=row['role'] or '',
366
+ avatar=row['avatar'] or '',
367
+ last_at=row['last_at'] or 0.0,
368
+ appearances=row['appearances'] or 0,
369
+ )
370
+ logger.debug(f'Loaded {len(rows)} member(s) into cache')
371
+
372
+ # ── 写入 ────────────────────────────────────────────────────────────
373
+ def enqueue(self, record: MessageRecord) -> None:
374
+ """把消息放进写入队列(同步、非阻塞)"""
375
+ try:
376
+ self._queue.put_nowait(record)
377
+ except asyncio.QueueFull: # pragma: no cover - 队列无上限
378
+ logger.warning('BotUI write queue is full, dropping a message record')
379
+
380
+ async def _write_loop(self) -> None:
381
+ while True:
382
+ item = await self._queue.get()
383
+ if item is None:
384
+ self._queue.task_done()
385
+ return
386
+ batch = [item]
387
+ # 尽量批量落库,降低 IO 次数
388
+ while len(batch) < 50:
389
+ try:
390
+ nxt = self._queue.get_nowait()
391
+ except asyncio.QueueEmpty:
392
+ break
393
+ if nxt is None:
394
+ await self._flush(batch)
395
+ self._queue.task_done()
396
+ return
397
+ batch.append(nxt)
398
+ await self._flush(batch)
399
+ for _ in batch:
400
+ self._queue.task_done()
401
+
402
+ async def _flush(self, batch: list[MessageRecord]) -> None:
403
+ if self._db is None or not batch:
404
+ return
405
+ async with self._lock:
406
+ try:
407
+ for rec in batch:
408
+ await self._insert(rec)
409
+ await self._db.commit()
410
+ except Exception as e:
411
+ logger.opt(exception=True).error(f'Failed to persist messages: {e}')
412
+
413
+ async def _upsert_chat(self, rec: MessageRecord) -> None:
414
+ assert self._db is not None
415
+ preview = rec.preview
416
+ await self._db.execute(
417
+ _UPSERT_CHAT_MESSAGE_SQL,
418
+ (
419
+ rec.chat_key,
420
+ rec.chat_kind,
421
+ rec.chat_id,
422
+ rec.adapter,
423
+ rec.scope,
424
+ rec.self_id,
425
+ rec.parent_id,
426
+ rec.chat_name,
427
+ rec.chat_avatar,
428
+ rec.member_count,
429
+ preview[:500],
430
+ rec.ts,
431
+ rec.direction,
432
+ ),
433
+ )
434
+
435
+ async def _upsert_members(self, rec: MessageRecord) -> None:
436
+ """从一条消息里收集成员,写进名册(WebUI 的 @ 菜单靠它列人)。
437
+
438
+ 记两类人:消息的发送者,以及消息里 @ 到的目标。机器人自己也会进名册
439
+ —— 群里 @ 机器人是很常见的需求。「全体成员」没有 user_id,跳过。
440
+ """
441
+ if self._db is None:
442
+ return
443
+ seen: dict[str, tuple[str, str, str]] = {}
444
+ if rec.user_id:
445
+ seen[rec.user_id] = (rec.user_name, rec.role or '', rec.user_avatar)
446
+ for seg in rec.segments:
447
+ if not isinstance(seg, dict) or seg.get('type') != 'at':
448
+ continue
449
+ target = str(seg.get('target') or '')
450
+ if not target or target == 'all' or ':' in target:
451
+ continue
452
+ name = str(seg.get('name') or '')
453
+ # 解析不到昵称时不要用「已有名字」以外的空串覆盖旧值
454
+ seen.setdefault(target, (name, '', ''))
455
+ if not seen:
456
+ return
457
+
458
+ rows = []
459
+ for user_id, (name, role, avatar) in seen.items():
460
+ rows.append(
461
+ (
462
+ rec.chat_key,
463
+ user_id,
464
+ self._member_name(rec.chat_key, user_id) or name,
465
+ role,
466
+ avatar,
467
+ rec.ts,
468
+ )
469
+ )
470
+ self._remember_member(
471
+ rec.chat_key, user_id, name=name, role=role, avatar=avatar, ts=rec.ts
472
+ )
473
+ try:
474
+ await self._db.executemany(
475
+ """
476
+ INSERT INTO members (chat_key, user_id, name, role, avatar,
477
+ last_at, appearances)
478
+ VALUES (?, ?, ?, ?, ?, ?, 1)
479
+ ON CONFLICT(chat_key, user_id) DO UPDATE SET
480
+ name = COALESCE(NULLIF(excluded.name, ''), members.name),
481
+ role = COALESCE(NULLIF(excluded.role, ''), members.role),
482
+ avatar = COALESCE(NULLIF(excluded.avatar, ''), members.avatar),
483
+ last_at = MAX(excluded.last_at, members.last_at),
484
+ appearances = members.appearances + 1
485
+ """,
486
+ rows,
487
+ )
488
+ except Exception as e: # pragma: no cover - 名册失败不影响消息落库
489
+ logger.debug(f'Failed to upsert members for {rec.chat_key}: {e}')
490
+
491
+ def _member_name(self, key: str, user_id: str) -> str:
492
+ """内存里已有的成员名(避免 @ 解析结果被空串覆盖)"""
493
+ member = self._members.get(key, {}).get(user_id)
494
+ return member.name if member is not None else ''
495
+
496
+ def _remember_member(
497
+ self,
498
+ key: str,
499
+ user_id: str,
500
+ *,
501
+ name: str = '',
502
+ role: str = '',
503
+ avatar: str = '',
504
+ ts: float = 0.0,
505
+ ) -> MemberRecord:
506
+ bucket = self._members.setdefault(key, {})
507
+ member = bucket.get(user_id)
508
+ if member is None:
509
+ if len(bucket) >= _MEMBER_PER_CHAT_LIMIT:
510
+ oldest = min(bucket.values(), key=lambda m: m.last_at)
511
+ bucket.pop(oldest.user_id, None)
512
+ member = MemberRecord(user_id=user_id)
513
+ bucket[user_id] = member
514
+ if name:
515
+ member.name = name
516
+ if role:
517
+ member.role = role
518
+ if avatar:
519
+ member.avatar = avatar
520
+ member.last_at = max(ts, member.last_at)
521
+ member.appearances += 1
522
+ return member
523
+
524
+ async def _insert(self, rec: MessageRecord) -> None:
525
+ assert self._db is not None
526
+ await self._upsert_chat(rec)
527
+ await self._upsert_members(rec)
528
+ cursor = await self._db.execute(
529
+ """
530
+ INSERT INTO messages (chat_key, chat_kind, chat_id, direction, ts, adapter,
531
+ scope, self_id, parent_id, chat_name, chat_avatar,
532
+ member_count, user_id, user_name, user_avatar, role,
533
+ is_self, text, segments, message_id, recallable, api)
534
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
535
+ """,
536
+ (
537
+ rec.chat_key,
538
+ rec.chat_kind,
539
+ rec.chat_id,
540
+ rec.direction,
541
+ rec.ts,
542
+ rec.adapter,
543
+ rec.scope,
544
+ rec.self_id,
545
+ rec.parent_id,
546
+ rec.chat_name,
547
+ rec.chat_avatar,
548
+ rec.member_count,
549
+ rec.user_id,
550
+ rec.user_name,
551
+ rec.user_avatar,
552
+ rec.role,
553
+ 1 if rec.is_self else 0,
554
+ rec.text,
555
+ json.dumps(rec.segments, ensure_ascii=False),
556
+ rec.message_id,
557
+ 1 if rec.recallable else 0,
558
+ rec.api,
559
+ ),
560
+ )
561
+ rec.row_id = int(cursor.lastrowid or 0)
562
+
563
+ # 更新内存热缓存
564
+ key = rec.chat_key
565
+ chat = self._chats.get(key)
566
+ if chat is None:
567
+ chat = ChatRecord(
568
+ key=key, kind=rec.chat_kind, chat_id=rec.chat_id, self_id=rec.self_id
569
+ )
570
+ self._chats[key] = chat
571
+ while len(self._chats) > _CHAT_CACHE_LIMIT:
572
+ oldest = min(self._chats.values(), key=lambda c: c.last_at)
573
+ self._chats.pop(oldest.key, None)
574
+ chat.kind = rec.chat_kind or chat.kind
575
+ chat.chat_id = rec.chat_id or chat.chat_id
576
+ chat.adapter = rec.adapter or chat.adapter
577
+ chat.scope = rec.scope or chat.scope
578
+ chat.self_id = rec.self_id or chat.self_id
579
+ chat.parent_id = rec.parent_id or chat.parent_id
580
+ chat.name = rec.chat_name or chat.name
581
+ chat.avatar = rec.chat_avatar or chat.avatar
582
+ if rec.member_count is not None:
583
+ chat.member_count = rec.member_count
584
+ chat.last_text = rec.preview[:500]
585
+ chat.last_at = max(rec.ts, chat.last_at)
586
+ chat.last_direction = rec.direction
587
+ chat.message_count += 1
588
+
589
+ if self.on_insert is not None:
590
+ try:
591
+ self.on_insert(rec)
592
+ except Exception as e: # pragma: no cover - 推送失败不影响存储
593
+ logger.debug(f'BotUI on_insert callback failed: {e}')
594
+
595
+ # 到这里行号已经有了:让媒体库据此登记引用(见 mediastore.py)。
596
+ # 放在落库之后是**关键** —— 引用标记里带行号,早于插入是拿不到的。
597
+ if self.on_persist is not None:
598
+ try:
599
+ await self.on_persist(rec)
600
+ except Exception as e: # pragma: no cover - 记引用失败不影响存储
601
+ logger.debug(f'BotUI on_persist callback failed: {e}')
602
+
603
+ async def flush(self) -> None:
604
+ """等待队列中的写入完成(测试与关闭时使用)"""
605
+ await self._queue.join()
606
+
607
+ # ── 会话元信息 ──────────────────────────────────────────────────────
608
+ async def touch_chat(
609
+ self,
610
+ key: str,
611
+ *,
612
+ name: str = '',
613
+ avatar: str = '',
614
+ member_count: int | None = None,
615
+ adapter: str = '',
616
+ scope: str = '',
617
+ self_id: str = '',
618
+ parent_id: str = '',
619
+ ) -> None:
620
+ """更新会话名称/头像等元信息(不产生消息记录)"""
621
+ kind, chat_id = split_chat_key(key)
622
+ chat = self._chats.get(key)
623
+ if chat is None:
624
+ chat = ChatRecord(
625
+ key=key, kind=kind, chat_id=chat_id, self_id=self_id_of(key)
626
+ )
627
+ self._chats[key] = chat
628
+ chat.name = name or chat.name
629
+ chat.avatar = avatar or chat.avatar
630
+ chat.adapter = adapter or chat.adapter
631
+ chat.scope = scope or chat.scope
632
+ chat.self_id = self_id or chat.self_id
633
+ chat.parent_id = parent_id or chat.parent_id
634
+ if member_count is not None:
635
+ chat.member_count = member_count
636
+ if self._db is None:
637
+ return
638
+ try:
639
+ async with self._lock:
640
+ await self._db.execute(
641
+ _UPSERT_CHAT_SQL,
642
+ (
643
+ key,
644
+ kind,
645
+ chat_id,
646
+ adapter,
647
+ scope,
648
+ self_id,
649
+ parent_id,
650
+ name,
651
+ avatar,
652
+ member_count,
653
+ ),
654
+ )
655
+ await self._db.commit()
656
+ except Exception as e:
657
+ logger.debug(f'Failed to touch chat {key}: {e}')
658
+
659
+ # ── 读取 ────────────────────────────────────────────────────────────
660
+ def chats(
661
+ self, query: str = '', limit: int = 200, self_id: str | None = None
662
+ ) -> list[ChatRecord]:
663
+ """从热缓存里取会话列表(已按最后消息时间倒序)。
664
+
665
+ ``self_id`` 不为 None 时只返回属于该机器人的会话。
666
+ """
667
+ items = sorted(self._chats.values(), key=lambda c: c.last_at, reverse=True)
668
+ if self_id is not None:
669
+ items = [c for c in items if c.self_id == self_id]
670
+ if query:
671
+ q = query.lower()
672
+ items = [
673
+ c
674
+ for c in items
675
+ if q in (c.name or '').lower()
676
+ or q in (c.alias or '').lower()
677
+ or q in c.chat_id.lower()
678
+ or q in c.last_text.lower()
679
+ ]
680
+ return items[:limit]
681
+
682
+ def chat(self, key: str) -> ChatRecord | None:
683
+ return self._chats.get(key)
684
+
685
+ # ── 名字备注(别名) ────────────────────────────────────────────
686
+ # 有些适配器拿不到真实的群名或用户昵称(要么只有一串 ID,要么名称
687
+ # 随时变),所以允许给会话、成员、机器人起一个人工备注。备注只影响
688
+ # **界面展示**:它不进入聊天记录,也不会被适配器后续上报的名称覆盖。
689
+
690
+ async def set_chat_alias(self, key: str, alias: str) -> ChatRecord | None:
691
+ """给会话起备注(传空串则清除备注)"""
692
+ alias = (alias or '').strip()
693
+ chat = self._chats.get(key)
694
+ if chat is None:
695
+ return None
696
+ chat.alias = alias
697
+ if self._db is None:
698
+ return chat
699
+ try:
700
+ async with self._lock:
701
+ await self._db.execute(
702
+ 'UPDATE chats SET alias = ? WHERE key = ?', (alias, key)
703
+ )
704
+ await self._db.commit()
705
+ except Exception as e: # pragma: no cover - 备注失败不影响主流程
706
+ logger.debug(f'Failed to rename chat {key}: {e}')
707
+ return chat
708
+
709
+ async def set_member_alias(self, key: str, user_id: str, alias: str) -> None:
710
+ """给成员起备注(传空串则清除备注)"""
711
+ alias = (alias or '').strip()
712
+ member = self._members.get(key, {}).get(str(user_id))
713
+ if member is None:
714
+ member = self._remember_member(key, str(user_id), ts=now_ts())
715
+ member.alias = alias
716
+ if self._db is None:
717
+ return
718
+ try:
719
+ async with self._lock:
720
+ await self._db.execute(
721
+ 'INSERT INTO members (chat_key, user_id, alias, last_at) '
722
+ 'VALUES (?, ?, ?, ?) '
723
+ 'ON CONFLICT(chat_key, user_id) DO UPDATE SET '
724
+ 'alias = excluded.alias',
725
+ (key, str(user_id), alias, now_ts()),
726
+ )
727
+ await self._db.commit()
728
+ except Exception as e: # pragma: no cover - 备注失败不影响主流程
729
+ logger.debug(f'Failed to rename member {key}/{user_id}: {e}')
730
+
731
+ async def set_bot_alias(self, self_id: str, alias: str) -> BotRecord | None:
732
+ """给机器人起备注(传空串则清除备注)"""
733
+ alias = (alias or '').strip()
734
+ record = self._bots.get(str(self_id or ''))
735
+ if record is None:
736
+ return None
737
+ record.alias = alias
738
+ if self._db is None:
739
+ return record
740
+ try:
741
+ async with self._lock:
742
+ await self._db.execute(
743
+ 'UPDATE bots SET alias = ? WHERE self_id = ?',
744
+ (alias, record.self_id),
745
+ )
746
+ await self._db.commit()
747
+ except Exception as e: # pragma: no cover - 备注失败不影响主流程
748
+ logger.debug(f'Failed to rename bot {self_id}: {e}')
749
+ return record
750
+
751
+ # ── 机器人账号 ──────────────────────────────────────────────────────
752
+ def bots(self) -> list[BotRecord]:
753
+ """所有连接过的机器人,在线的排前面,其次按最近活跃倒序。"""
754
+ items = sorted(
755
+ self._bots.values(),
756
+ key=lambda b: (b.online, b.last_seen),
757
+ reverse=True,
758
+ )
759
+ return items
760
+
761
+ def bot(self, self_id: str) -> BotRecord | None:
762
+ return self._bots.get(str(self_id))
763
+
764
+ async def upsert_bot(
765
+ self,
766
+ self_id: str,
767
+ *,
768
+ adapter: str = '',
769
+ scope: str = '',
770
+ name: str = '',
771
+ avatar: str = '',
772
+ online: bool | None = None,
773
+ ) -> BotRecord:
774
+ """记录/更新一个机器人账号,返回更新后的记录。
775
+
776
+ ``online`` 为 None 表示不改动在线状态(例如只是补昵称);字段为空的
777
+ 参数不会覆盖已有值(COALESCE 风格),免得一次降级的适配器信息把之前
778
+ 记下的昵称、头像抹掉。
779
+
780
+ ``alias``(用户备注)不在这里设置:连接钩子只上报适配器给的名称,
781
+ 备注由 :meth:`set_bot_alias` 单独维护,两者互不覆盖。
782
+ """
783
+ self_id = str(self_id or '')
784
+ if not self_id:
785
+ raise ValueError('self_id is required')
786
+ now = now_ts()
787
+ record = self._bots.get(self_id)
788
+ if record is None:
789
+ record = BotRecord(self_id=self_id, first_seen=now, last_seen=now)
790
+ self._bots[self_id] = record
791
+ record.adapter = adapter or record.adapter
792
+ record.scope = scope or record.scope
793
+ record.name = name or record.name
794
+ record.avatar = avatar or record.avatar
795
+ if online is not None:
796
+ record.online = bool(online)
797
+ record.last_seen = max(now, record.last_seen)
798
+
799
+ if self._db is None:
800
+ return record
801
+ try:
802
+ async with self._lock:
803
+ await self._db.execute(
804
+ """
805
+ INSERT INTO bots (self_id, adapter, scope, name, alias, avatar,
806
+ online, first_seen, last_seen)
807
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
808
+ ON CONFLICT(self_id) DO UPDATE SET
809
+ adapter = COALESCE(NULLIF(excluded.adapter, ''), bots.adapter),
810
+ scope = COALESCE(NULLIF(excluded.scope, ''), bots.scope),
811
+ name = COALESCE(NULLIF(excluded.name, ''), bots.name),
812
+ avatar = COALESCE(NULLIF(excluded.avatar, ''), bots.avatar),
813
+ online = excluded.online,
814
+ last_seen = MAX(excluded.last_seen, bots.last_seen)
815
+ """,
816
+ (
817
+ self_id,
818
+ record.adapter,
819
+ record.scope,
820
+ record.name,
821
+ record.alias,
822
+ record.avatar,
823
+ 1 if record.online else 0,
824
+ record.first_seen,
825
+ record.last_seen,
826
+ ),
827
+ )
828
+ await self._db.commit()
829
+ except Exception as e: # pragma: no cover - 机器人信息写入失败不影响主流程
830
+ logger.debug(f'Failed to upsert bot {self_id}: {e}')
831
+ return record
832
+
833
+ async def set_bot_online(self, self_id: str, online: bool) -> BotRecord | None:
834
+ """更新机器人在线状态(连接钩子调用)"""
835
+ self_id = str(self_id or '')
836
+ if not self_id:
837
+ return None
838
+ return await self.upsert_bot(self_id, online=online)
839
+
840
+ # ── 成员名册 ────────────────────────────────────────────────────────
841
+ def members(
842
+ self, key: str, query: str = '', limit: int = 200
843
+ ) -> list[MemberRecord]:
844
+ """某个会话里出现过的成员,按最近活跃排序。
845
+
846
+ ``query`` 同时匹配昵称和 id,方便前端做「输入过滤 + 点选」,
847
+ 而不是让用户自己记住并敲 QQ 号。
848
+ """
849
+ items = sorted(
850
+ self._members.get(key, {}).values(),
851
+ key=lambda m: (m.last_at, m.appearances),
852
+ reverse=True,
853
+ )
854
+ if query:
855
+ q = query.strip().lower()
856
+ items = [
857
+ m
858
+ for m in items
859
+ if q in m.name.lower() or q in m.alias.lower() or q in m.user_id.lower()
860
+ ]
861
+ return items[: max(1, min(1000, int(limit)))]
862
+
863
+ def member(self, key: str, user_id: str) -> MemberRecord | None:
864
+ """按 id 取单个成员(没有就返回 None)"""
865
+ return self._members.get(key, {}).get(str(user_id))
866
+
867
+ async def rename_member(self, key: str, user_id: str, name: str) -> None:
868
+ """给成员补一个名字(@ 解析出昵称后回填,省得下次再查)"""
869
+ if not name:
870
+ return
871
+ self._remember_member(key, user_id, name=name)
872
+ if self._db is None:
873
+ return
874
+ try:
875
+ async with self._lock:
876
+ await self._db.execute(
877
+ 'INSERT INTO members (chat_key, user_id, name, last_at) '
878
+ 'VALUES (?, ?, ?, ?) '
879
+ 'ON CONFLICT(chat_key, user_id) DO UPDATE SET '
880
+ "name = CASE WHEN excluded.name != '' "
881
+ 'THEN excluded.name ELSE members.name END',
882
+ (key, user_id, name, now_ts()),
883
+ )
884
+ await self._db.commit()
885
+ except Exception as e: # pragma: no cover - 回填失败不影响主流程
886
+ logger.debug(f'Failed to rename member {key}/{user_id}: {e}')
887
+
888
+ def _row_to_message(self, row: aiosqlite.Row) -> MessageRecord:
889
+ keys = row.keys()
890
+ return MessageRecord(
891
+ row_id=int(row['id']),
892
+ chat_key=row['chat_key'],
893
+ chat_kind=row['chat_kind'],
894
+ chat_id=row['chat_id'],
895
+ direction=row['direction'],
896
+ ts=float(row['ts']),
897
+ adapter=row['adapter'] or '',
898
+ scope=row['scope'] or '',
899
+ self_id=row['self_id'] or '',
900
+ parent_id=row['parent_id'] or '',
901
+ chat_name=row['chat_name'] or '',
902
+ chat_avatar=row['chat_avatar'] or '',
903
+ member_count=row['member_count'] if 'member_count' in keys else None,
904
+ user_id=row['user_id'] or '',
905
+ user_name=row['user_name'] or '',
906
+ user_avatar=row['user_avatar'] or '',
907
+ role=row['role'],
908
+ is_self=bool(row['is_self']),
909
+ text=row['text'] or '',
910
+ segments=_safe_json(row['segments']),
911
+ message_id=row['message_id'] or '',
912
+ recallable=bool(row['recallable']),
913
+ api=row['api'] or '',
914
+ recalled=bool(row['recalled']),
915
+ )
916
+
917
+ async def messages(
918
+ self,
919
+ key: str,
920
+ limit: int = 50,
921
+ before: float | None = None,
922
+ before_id: int | None = None,
923
+ ) -> list[MessageRecord]:
924
+ """按时间正序返回某个会话的消息。
925
+
926
+ ``before`` / ``before_id`` 用于翻页加载更早的记录,按 (ts, id) 严格向前,
927
+ 因此不会重复返回已经加载过的消息。
928
+ """
929
+ if self._db is None:
930
+ return []
931
+ limit = max(1, min(500, int(limit)))
932
+ if before is None:
933
+ sql = (
934
+ 'SELECT * FROM messages WHERE chat_key = ? AND recalled = 0 '
935
+ 'ORDER BY ts DESC, id DESC LIMIT ?'
936
+ )
937
+ params: tuple[Any, ...] = (key, limit)
938
+ elif before_id is None:
939
+ # 只给了时间:返回严格更早的消息
940
+ sql = (
941
+ 'SELECT * FROM messages WHERE chat_key = ? AND recalled = 0 '
942
+ 'AND ts < ? ORDER BY ts DESC, id DESC LIMIT ?'
943
+ )
944
+ params = (key, before, limit)
945
+ else:
946
+ # 给了时间 + 行号:按 (ts, id) 严格向前,避免同一秒内的消息重复
947
+ sql = (
948
+ 'SELECT * FROM messages WHERE chat_key = ? AND recalled = 0 '
949
+ 'AND (ts < ? OR (ts = ? AND id < ?)) '
950
+ 'ORDER BY ts DESC, id DESC LIMIT ?'
951
+ )
952
+ params = (key, before, before, before_id, limit)
953
+ async with self._db.execute(sql, params) as cur:
954
+ rows = await cur.fetchall()
955
+ items = [self._row_to_message(r) for r in rows]
956
+ items.reverse()
957
+ return items
958
+
959
+ async def stream_messages(
960
+ self,
961
+ key: str,
962
+ limit: int = 2000,
963
+ after: float | None = None,
964
+ after_id: int | None = None,
965
+ ) -> list[MessageRecord]:
966
+ """按时间正序返回某会话的消息,用于「导出聊天记录」。
967
+
968
+ 与 :meth:`messages` 的区别是方向相反:这边从最早开始向后取,供导出时
969
+ 按顺序写入;``after`` / ``after_id`` 用于分批继续取下一页。
970
+ """
971
+ if self._db is None:
972
+ return []
973
+ limit = max(1, min(2000, int(limit)))
974
+ if after is None:
975
+ sql = (
976
+ 'SELECT * FROM messages WHERE chat_key = ? AND recalled = 0 '
977
+ 'ORDER BY ts ASC, id ASC LIMIT ?'
978
+ )
979
+ params: tuple[Any, ...] = (key, limit)
980
+ elif after_id is None:
981
+ sql = (
982
+ 'SELECT * FROM messages WHERE chat_key = ? AND recalled = 0 '
983
+ 'AND ts > ? ORDER BY ts ASC, id ASC LIMIT ?'
984
+ )
985
+ params = (key, after, limit)
986
+ else:
987
+ sql = (
988
+ 'SELECT * FROM messages WHERE chat_key = ? AND recalled = 0 '
989
+ 'AND (ts > ? OR (ts = ? AND id > ?)) '
990
+ 'ORDER BY ts ASC, id ASC LIMIT ?'
991
+ )
992
+ params = (key, after, after, after_id, limit)
993
+ async with self._db.execute(sql, params) as cur:
994
+ rows = await cur.fetchall()
995
+ return [self._row_to_message(r) for r in rows]
996
+
997
+ async def search(
998
+ self,
999
+ keyword: str,
1000
+ limit: int = 100,
1001
+ chat_key: str | None = None,
1002
+ self_id: str | None = None,
1003
+ ) -> list[MessageRecord]:
1004
+ """按关键词搜索消息内容(不区分大小写),按时间倒序返回。
1005
+
1006
+ ``keyword`` 里的 ``%`` ``_`` 是 LIKE 的通配符,必须转义掉,否则用户
1007
+ 搜一个 ``%`` 会把所有消息都捞出来。
1008
+
1009
+ ``self_id`` 不为 None 时只在指定机器人的消息里搜:会话 key 以
1010
+ ``self_id:`` 开头,因此用前缀匹配即可。
1011
+ """
1012
+ if self._db is None:
1013
+ return []
1014
+ keyword = (keyword or '').strip()
1015
+ if not keyword:
1016
+ return []
1017
+ limit = max(1, min(500, int(limit)))
1018
+ # 转义 LIKE 元字符:先用 \ 转义,再声明 ESCAPE '\'
1019
+ escaped = keyword.replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_')
1020
+ pattern = f'%{escaped}%'
1021
+ clauses = ['recalled = 0', "text LIKE ? ESCAPE '\\'"]
1022
+ params: list[Any] = [pattern]
1023
+ if chat_key:
1024
+ clauses.append('chat_key = ?')
1025
+ params.append(chat_key)
1026
+ elif self_id is not None:
1027
+ clauses.append('chat_key LIKE ?')
1028
+ params.append(f'{self_id}:%')
1029
+ params.append(limit)
1030
+ sql = (
1031
+ f'SELECT * FROM messages WHERE {" AND ".join(clauses)} '
1032
+ 'ORDER BY ts DESC, id DESC LIMIT ?'
1033
+ )
1034
+ async with self._db.execute(sql, tuple(params)) as cur:
1035
+ rows = await cur.fetchall()
1036
+ return [self._row_to_message(r) for r in rows]
1037
+
1038
+ async def message_by_id(self, row_id: int) -> MessageRecord | None:
1039
+ if self._db is None:
1040
+ return None
1041
+ async with self._db.execute(
1042
+ 'SELECT * FROM messages WHERE id = ?', (row_id,)
1043
+ ) as cur:
1044
+ row = await cur.fetchone()
1045
+ return self._row_to_message(row) if row else None
1046
+
1047
+ async def forward_nodes(
1048
+ self, key: str | None, forward_id: str
1049
+ ) -> list[dict[str, Any]]:
1050
+ """在已记录的消息里找某条合并转发内联的节点。
1051
+
1052
+ 有些适配器(Satori 等)把转发内容直接放在段里,那就无需再调接口。
1053
+ 只扫最近的一批消息:转发内容只在原消息附近可查,全表扫描不划算。
1054
+ """
1055
+ if self._db is None or not forward_id:
1056
+ return []
1057
+ if key:
1058
+ sql = (
1059
+ 'SELECT segments FROM messages WHERE chat_key = ? AND recalled = 0 '
1060
+ 'ORDER BY id DESC LIMIT 200'
1061
+ )
1062
+ params: tuple[Any, ...] = (key,)
1063
+ else:
1064
+ sql = (
1065
+ 'SELECT segments FROM messages WHERE recalled = 0 '
1066
+ 'ORDER BY id DESC LIMIT 200'
1067
+ )
1068
+ params = ()
1069
+ async with self._db.execute(sql, params) as cur:
1070
+ rows = await cur.fetchall()
1071
+ for row in rows:
1072
+ for seg in _safe_json(row['segments']):
1073
+ if not isinstance(seg, dict) or seg.get('type') != 'forward':
1074
+ continue
1075
+ if str(seg.get('id') or '') != str(forward_id):
1076
+ continue
1077
+ nodes = seg.get('nodes')
1078
+ if isinstance(nodes, list) and nodes:
1079
+ return nodes
1080
+ return []
1081
+
1082
+ async def message_by_message_id(self, message_id: str) -> MessageRecord | None:
1083
+ """按适配器的消息 ID 反查记录(用于给「回复」补上被引用内容)"""
1084
+ if self._db is None or not message_id:
1085
+ return None
1086
+ async with self._db.execute(
1087
+ 'SELECT * FROM messages WHERE message_id = ? AND recalled = 0 '
1088
+ 'ORDER BY id DESC LIMIT 1',
1089
+ (str(message_id),),
1090
+ ) as cur:
1091
+ row = await cur.fetchone()
1092
+ return self._row_to_message(row) if row else None
1093
+
1094
+ async def mark_recalled(self, row_id: int) -> None:
1095
+ if self._db is None:
1096
+ return
1097
+ async with self._lock:
1098
+ await self._db.execute(
1099
+ 'UPDATE messages SET recalled = 1 WHERE id = ?', (row_id,)
1100
+ )
1101
+ await self._db.commit()
1102
+
1103
+ async def count(self, self_id: str | None = None) -> int:
1104
+ if self._db is None:
1105
+ return 0
1106
+ if self_id is None:
1107
+ sql, params = 'SELECT COUNT(*) AS n FROM messages', ()
1108
+ else:
1109
+ sql = 'SELECT COUNT(*) AS n FROM messages WHERE chat_key LIKE ?'
1110
+ params = (f'{self_id}:%',)
1111
+ async with self._db.execute(sql, params) as cur:
1112
+ row = await cur.fetchone()
1113
+ return int(row['n']) if row else 0
1114
+
1115
+ # ── 维护 ────────────────────────────────────────────────────────────
1116
+ async def cleanup(self) -> int:
1117
+ """按条数/天数上限清理旧记录,返回删除条数。
1118
+
1119
+ 删掉的记录同时要把它们给媒体留下的引用摘掉(见 ``mediastore.py``):
1120
+ 否则媒体会被永久当成「还有人用」而占着配额。摘引用要与删除在**同一处**
1121
+ 发生,不然迟早漏掉一条路径。
1122
+ """
1123
+ if self._db is None:
1124
+ return 0
1125
+ removed = 0
1126
+ dropped: list[tuple[str, str]] = [] # (chat_key, 行号)
1127
+ try:
1128
+ async with self._lock:
1129
+ if self._cfg.botui_max_records > 0:
1130
+ count = await self.count()
1131
+ extra = count - self._cfg.botui_max_records
1132
+ if extra > 0:
1133
+ expired = await self._delete_messages(
1134
+ 'SELECT id, chat_key FROM messages ORDER BY id ASC LIMIT ?',
1135
+ (extra,),
1136
+ )
1137
+ dropped += expired
1138
+ removed += len(expired)
1139
+ if self._cfg.botui_retention_days > 0:
1140
+ cutoff = time.time() - self._cfg.botui_retention_days * 86400
1141
+ expired = await self._delete_messages(
1142
+ 'SELECT id, chat_key FROM messages WHERE ts < ?', (cutoff,)
1143
+ )
1144
+ dropped += expired
1145
+ removed += len(expired)
1146
+ # 名册跟着保留期一起清:否则长期跑下来 members 会无限增长
1147
+ await self._db.execute(
1148
+ 'DELETE FROM members WHERE last_at < ?', (cutoff,)
1149
+ )
1150
+ if removed:
1151
+ await self._db.commit()
1152
+ except Exception as e:
1153
+ logger.warning(f'Failed to clean up old messages: {e}')
1154
+ await self._drop_media_refs(dropped)
1155
+ return removed
1156
+
1157
+ async def _delete_messages(
1158
+ self, select_sql: str, params: tuple[Any, ...]
1159
+ ) -> list[tuple[str, str]]:
1160
+ """先查出待删行,再按行号删除;返回 ``(chat_key, 行号)`` 列表。
1161
+
1162
+ 先查后删(而不是直接 ``DELETE``)是为了拿到行号 —— 媒体的引用标记里
1163
+ 记的就是行号,删完就再也对不上账了。
1164
+ """
1165
+ assert self._db is not None
1166
+ async with self._db.execute(select_sql, params) as cur:
1167
+ rows = list(await cur.fetchall())
1168
+ if not rows:
1169
+ return []
1170
+ ids = [int(row['id']) for row in rows]
1171
+ marks = ','.join('?' * len(ids))
1172
+ await self._db.execute(f'DELETE FROM messages WHERE id IN ({marks})', ids)
1173
+ return [(str(row['chat_key']), str(row['id'])) for row in rows]
1174
+
1175
+ async def _drop_media_refs(self, pairs: list[tuple[str, str]]) -> None:
1176
+ """把上面删掉的那些行在媒体库里留下的引用摘掉。"""
1177
+ if not pairs or self.media is None:
1178
+ return
1179
+ grouped: dict[str, list[int]] = {}
1180
+ for key, row_id in pairs:
1181
+ grouped.setdefault(key, []).append(int(row_id))
1182
+ try:
1183
+ for key, ids in grouped.items():
1184
+ await self.media.drop_message_refs(key, ids)
1185
+ except Exception as e: # pragma: no cover - 摘引用失败不影响清理
1186
+ logger.debug(f'BotUI 摘除媒体引用失败:{e}')
1187
+
1188
+ async def vacuum(self) -> None:
1189
+ if self._db is None:
1190
+ return
1191
+ try:
1192
+ await self._db.execute('VACUUM')
1193
+ await self._db.commit()
1194
+ except Exception as e:
1195
+ logger.debug(f'Vacuum skipped: {e}')
1196
+
1197
+ async def reset(self) -> None:
1198
+ """清空全部记录(会话与消息)"""
1199
+ if self._db is None:
1200
+ return
1201
+ try:
1202
+ await self.flush()
1203
+ except Exception: # pragma: no cover - 队列异常时仍然尝试清空
1204
+ pass
1205
+ async with self._lock:
1206
+ await self._db.execute('DELETE FROM messages')
1207
+ await self._db.execute('DELETE FROM chats')
1208
+ await self._db.execute('DELETE FROM members')
1209
+ # 机器人记录保留:它表示「连接过的账号」,不属于聊天内容
1210
+ # 让行号从头开始,避免「清空后第一条消息的 id 还是很大」
1211
+ await self._db.execute(
1212
+ "DELETE FROM sqlite_sequence WHERE name IN ('messages', 'chats')"
1213
+ )
1214
+ await self._db.commit()
1215
+ self._chats.clear()
1216
+ self._members.clear()
1217
+ # 消息没了,它们给媒体留下的引用也一并作废(否则媒体永远不回收)
1218
+ if self.media is not None:
1219
+ try:
1220
+ await self.media.clear('orphans')
1221
+ except Exception as e: # pragma: no cover - 清理失败不影响重置
1222
+ logger.debug(f'BotUI 重置时清理媒体失败:{e}')
1223
+ logger.info('BotUI 已清空全部聊天记录')
1224
+
1225
+ async def get_meta(self, key: str, default: str = '') -> str:
1226
+ if self._db is None:
1227
+ return default
1228
+ async with self._db.execute(
1229
+ 'SELECT value FROM meta WHERE key = ?', (key,)
1230
+ ) as cur:
1231
+ row = await cur.fetchone()
1232
+ return str(row['value']) if row else default
1233
+
1234
+ async def set_meta(self, key: str, value: str) -> None:
1235
+ if self._db is None:
1236
+ return
1237
+ async with self._lock:
1238
+ await self._db.execute(
1239
+ 'INSERT INTO meta (key, value) VALUES (?, ?) '
1240
+ 'ON CONFLICT(key) DO UPDATE SET value = excluded.value',
1241
+ (key, value),
1242
+ )
1243
+ await self._db.commit()
1244
+
1245
+
1246
+ # 便捷构造:从 uninfo 的 Session 生成一条记录
1247
+ def record_from_session(session: Any, direction: str = DIR_IN) -> MessageRecord:
1248
+ kind = KIND_PRIVATE if getattr(session.scene, 'is_private', False) else KIND_GROUP
1249
+ chat_id = str(session.scene.id)
1250
+ self_id = str(getattr(session, 'self_id', '') or '')
1251
+ # 会话 key 带上机器人 ID:同一平台的多个机器人各自一份会话,互不混淆
1252
+ key = chat_key(kind, chat_id, self_id)
1253
+ parent_id = str(getattr(session.scene.parent, 'id', '') or '')
1254
+ member_count = getattr(session.scene, 'member_count', None)
1255
+ return MessageRecord(
1256
+ chat_key=key,
1257
+ chat_kind=kind,
1258
+ chat_id=chat_id,
1259
+ direction=direction,
1260
+ ts=now_ts(),
1261
+ adapter=str(getattr(session, 'adapter', '') or ''),
1262
+ scope=str(getattr(session, 'scope', '') or ''),
1263
+ self_id=self_id,
1264
+ parent_id=parent_id,
1265
+ chat_name=str(getattr(session.scene, 'name', '') or ''),
1266
+ chat_avatar=avatar_of(getattr(session.scene, 'avatar', None)),
1267
+ member_count=member_count if isinstance(member_count, int) else None,
1268
+ user_id=str(getattr(session.user, 'id', '') or ''),
1269
+ user_name=str(getattr(session.user, 'name', '') or ''),
1270
+ user_avatar=avatar_of(getattr(session.user, 'avatar', None)),
1271
+ )