-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathplugin.py
More file actions
1857 lines (1694 loc) · 94 KB
/
Copy pathplugin.py
File metadata and controls
1857 lines (1694 loc) · 94 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""MaiBot 文件读取插件(File Reader)—— 入口文件
参考 astrbot_plugin_file_reader_pro 移植,架构对齐:
文件 → 解析(file_parser) → 递归分块(chunker) → 向量化(llm.embed) → 会话级向量库(vector_store)
提问时 → 语义检索 Top-K → 注入 LLM 上下文(before_model_request hook)
与 AstrBot 版的差异(MaiBot 无内置 FaissVecDB / RerankProvider):
- 向量库用 numpy 余弦相似度自实现(vector_store.py)
- 注入走 `maisaka.replyer.before_model_request`,传 items 而非 messages
- 文件内容从入站消息的 file/attachment 段提取(chat.receive.after_process)
铁律遵循:入口文件不写 `from __future__ import annotations`(见 skill runtime-gotchas 5.1)。
"""
import asyncio
import base64
import json
import os
import re
import ssl
import sys
import tempfile
import time
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any, ClassVar, Iterable, Optional
# 补 sys.path,让同目录辅助模块可导入(runtime-gotchas 5.2)
_PLUGIN_DIR = str(Path(__file__).resolve().parent)
if _PLUGIN_DIR not in sys.path:
sys.path.insert(0, _PLUGIN_DIR)
from maibot_sdk import ( # noqa: E402
Command,
EventHandler,
Field,
HookHandler,
MaiBotPlugin,
PluginConfigBase,
Tool,
)
from maibot_sdk.types import ( # noqa: E402
ErrorPolicy,
EventType,
HookMode,
HookOrder,
ToolParameterInfo,
ToolParamType,
)
from chunker import RecursiveCharacterChunker # noqa: E402
from file_parser import describe_supported_types, read_any_file_to_text # noqa: E402
from vector_store import SessionStore # noqa: E402
# v1.1.0:降级文本解析正则提到模块级预编译(每次 hook 现编译是无谓开销)
_RE_HINT_URL = re.compile(r"链接[::]\s*(https?://\S+)")
_RE_HINT_NAME = re.compile(
r"\[文件\]\s*(?P<name>.+?)(?:\s*[,,]\s*大小[::]\s*(?P<size>\d+))?\s*[,,]?\s*$"
)
class _DownloadTooLargeError(Exception):
"""下载前/下载中即判定超限(v1.1.0):不再把超限文件全量拉进内存。"""
def __init__(self, size_bytes: float) -> None:
super().__init__(f"download exceeds limit: {size_bytes:.0f} bytes")
self.size_mb = size_bytes / 1024 / 1024
# ─── 配置模型 ────────────────────────────────────────────────────
class PluginSectionConfig(PluginConfigBase):
"""插件基础配置。"""
__ui_label__ = "插件"
__ui_icon__ = "file"
__ui_order__ = 0
enabled: bool = Field(default=True, description="是否启用插件")
config_version: str = Field(default="1.0.0", description="配置版本")
class ReaderConfig(PluginConfigBase):
"""文件读取与检索配置。"""
__ui_label__ = "文件读取"
__ui_icon__ = "folder"
__ui_order__ = 1
max_file_size: int = Field(default=30, description="单文件大小上限(MB)。注意内存占用约为文件大小的 2~3 倍(字节+解析文本+向量同时驻留),大内存机器可调高")
chunk_size: int = Field(default=512, description="分块大小(字符数)")
chunk_overlap: int = Field(default=100, description="分块重叠(字符数)")
chunk_merge_enabled: bool = Field(default=True, description="短碎片合并(v1.0.18 性能优化):递归切分出的相邻短碎片合并到接近 chunk_size 再成块,块数降至约 1/3,入库向量化耗时同步下降;关闭则回退旧的逐碎片成块行为")
retrieve_top_k: int = Field(default=6, description="检索返回的相关块数量")
retrieve_use_matrix: bool = Field(default=True, description="矩阵化检索(v1.0.18 性能优化):缓存 L2 归一化向量矩阵,查询时一次点积算全库相似度(入库/清理/删文件自动置脏重建);块数多时检索开销显著下降,结果与逐条余弦一致;异常自动回退旧路径")
file_retention_time: int = Field(default=60, description="文件有效时间(分钟)")
file_max_rounds: int = Field(default=5, description="文件最大参与轮数")
cleanup_interval: int = Field(default=5, description="后台清理间隔(分钟):清理过期文件、回收空会话向量库、裁剪概要冷却表")
enable_group: bool = Field(default=True, description="是否处理群聊文件")
insecure_download: bool = Field(default=False, description="下载文件时跳过 SSL 证书校验(运行机器存在 TLS MITM 代理、报 CERTIFICATE_VERIFY_FAILED 时开启)")
injection_marker: str = Field(default="【文件检索】", description="注入文本的幂等标记")
inject_memo_enabled: bool = Field(default=True, description="注入去重缓存(v1.0.18):同一会话同一问题在 TTL 内直接复用上次注入文本(零 embedding、零检索),覆盖 hook 每次尝试重跑与 Planner/回复双触发;文件入库、清文件、会话清理时自动失效")
inject_memo_ttl: int = Field(default=90, description="注入去重缓存的存活秒数")
silent_success: bool = Field(default=True, description="文件入库成功后保持静默(不发回执,日志仍记录);关闭后每次入库都回复「已解析 N 块」")
silent_errors: bool = Field(default=True, description="文件处理失败(不支持的类型/下载失败/超限/嵌入耗尽等)时保持静默(不发回执,日志仍记录,可用 /file_status 排查);关闭后失败会回复 ⚠️ 提示")
embed_retry_interval: float = Field(default=2.0, description="嵌入失败后台重试间隔(分钟);入库时 embedding 超时会先在后台队列排队,定时重试")
embed_max_retries: int = Field(default=30, description="后台重试最大次数(超过后放弃并提示重发文件;默认 30 次 × 2 分钟 ≈ 覆盖 1 小时拥塞)")
embed_batch_size: int = Field(default=64, description="单次 llm.embed RPC 调用的最大文本条数;v1.0.18 配合分块合并把默认从 16 提到 64(合并后单批约 8K 字仍远低于 30s RPC 上限),批数下降进一步缩短入库耗时;若 host 限流可调回 16")
embed_concurrency: int = Field(default=2, description="入库拆批后的并发批数:多批同时调用 llm.embed(1 = 旧串行行为);host 出现限流/批量报错时调回 1")
query_embed_timeout: float = Field(default=8.0, description="提问时查询 embedding 的超时秒数:仅单次调用、超时立即放弃并降级到概要兜底,不重试(避免拥塞期把用户卡在请求模型之前);0 = 关闭超时(旧行为,最坏可卡 ~96s)")
direct_inject_max_chars: int = Field(default=6000, description="全文直注阈值(解析后总字符数):会话内所有文件全文合计不超过该值时,跳过 RAG 检索、直接把文件全文注入上下文(小文件无需检索即可全量可见,还省一次查询 embedding 调用);设为 0 关闭该行为,始终走检索")
summary_enabled: bool = Field(default=True, description="大文件 LLM 概要:全文超 direct_inject_max_chars 的文件在入库后用 llm.generate 生成一段概要,注入上下文时附在检索片段前,让 LLM 对大文件先有整体认识;生成失败静默降级(不影响检索)")
summary_source_chars: int = Field(default=12000, description="生成概要时截取的源文本长度(字符数):从文件头、中、尾三段均匀取样拼接,控制摘要调用的 token 开销")
summary_max_chars: int = Field(default=500, description="概要文本的最大长度(字符数)")
summary_await_on_inject: bool = Field(default=False, description="提问注入时是否同步等待概要生成(v1.0.17 旧行为):概要缺失时同步调用 llm.generate 会把 BLOCKING hook 卡住数秒;默认 false = 缺失时立即用已有内容检索注入,概要在后台生成、下次提问自然带上")
summary_retry_interval: float = Field(default=600.0, description="概要生成失败后的冷却秒数:冷却期内不再尝试生成(避免每次提问都撞一次超时);0 = 不冷却(每次注入都重试)")
class NapcatConfig(PluginConfigBase):
"""NapCat HTTP API 兜底配置。
真机实测(v1.0.6):napcat-adapter 会把 file 段降级成纯文本
`[文件] xxx.docx,大小: 9547`,**连 chat.receive.before_process(SessionMessage.process()
之前)拿到的也已经是 text 段** —— hook 层拿不到文件本体,只能回头找 NapCat 要原始事件。
"""
__ui_label__ = "NapCat 兜底"
__ui_icon__ = "cloud-download"
__ui_order__ = 2
enabled: bool = Field(
default=False,
description="启用 NapCat HTTP API 兜底取文件(适配器会把文件段转成纯文本,不启用则读不到内容)",
)
http_url: str = Field(default="http://127.0.0.1:3001", description="NapCat OneBot HTTP 服务地址")
access_token: str = Field(default="", description="NapCat HTTP access_token(未设置则留空)")
timeout: float = Field(default=15.0, description="HTTP 请求超时(秒)")
verify_ssl: bool = Field(default=False, description="HTTPS 时校验证书(本地 http 无需开启)")
cache_dir: str = Field(
default="",
description="可选:NapCat 文件缓存目录,get_file 失败时按文件名+大小在此目录搜索",
)
class FileReaderConfig(PluginConfigBase):
"""插件配置。"""
plugin: PluginSectionConfig = Field(default_factory=PluginSectionConfig)
reader: ReaderConfig = Field(default_factory=ReaderConfig)
napcat: NapcatConfig = Field(default_factory=NapcatConfig)
class FileReaderPlugin(MaiBotPlugin):
"""文件读取插件。"""
config_model = FileReaderConfig
# 订阅全局模型配置热重载(embedding 模型变化时重建)
config_reload_subscriptions: ClassVar[Iterable[str]] = ("model",)
# ─── 生命周期 ────────────────────────────────────────────────
async def on_load(self) -> None:
"""插件加载时初始化。"""
self._store = SessionStore(Path(self.ctx.paths.data_dir) / "file_reader")
self._cleanup_task: Optional[asyncio.Task] = None
self._embedding_ok = True
self._embedding_err = ""
# 双 hook(before/after)去重:key = f"{session_id}:{file_name}" -> 上次处理时刻
self._recent_file_keys: dict[str, float] = {}
# 最近一次"被降级成纯文本"的文件消息留档(供 /file_status 排查)
self._last_file_hint: dict[str, Any] = {}
# embedding 失败重试队列(v1.0.11):[{session_id, conversation_id, stream_id, name, tmp_path, attempts, next_ts}]
# v1.1.0:队列条目存临时文件路径而非解析全文——全文(可达数 MB)不再驻留队列最长 1 小时
self._embed_retry_queue: list[dict[str, Any]] = []
self._retry_task: Optional[asyncio.Task] = None
# 注入去重缓存(v1.0.18 C3):(session_id, query_key) -> (ts, inject_text)
# 覆盖 hook 每次尝试重跑与 Planner/回复双触发;入库/清文件/会话清理时必须失效
self._inject_memo: dict[tuple[str, str], tuple[float, str]] = {}
# 概要异步化(v1.0.18 C4):in-flight 去重(同一文件的概要任务不重复起)
# 与失败冷却(file_name -> 上次失败时刻,冷却期内不再撞 llm.generate 超时)
# v1.1.0:pending 键从 id(entry) 改为 file_name——id() 在 entry 释放后可能被新对象复用,
# 理论上会误判 in-flight 导致该文件概要永远不生成
self._summary_pending: set[str] = set()
self._summary_failed_ts: dict[str, float] = {}
# v1.1.0:后台任务句柄注册表——create_task 裸调只靠事件循环弱引用持有,
# 有 GC 提前回收风险;且 on_unload 需要统一取消,防任务泄漏到重载后的旧 store
self._bg_tasks: set[asyncio.Task] = set()
# v1.1.0:下载用 httpx 单例(连接复用,免每次新建 client + SSL 上下文)
self._http_client: Optional[Any] = None
self._http_client_verify: bool = True
# v1.1.0:NapCat urllib SSL 上下文缓存(create_default_context 是 ms 级 CPU 调用)
self._napcat_ssl_ctx: Optional[ssl.SSLContext] = None
self._napcat_ssl_verify: bool = False
# 行为自检:确认 chunker / 解析器在进程内可用(runtime-gotchas 5.1 的对策)
self._run_self_check()
self.ctx.logger.info(
"文件读取插件已加载:data_dir=%s,chunk_size=%d,top_k=%d,有效时间=%dmin,最大轮数=%d",
self._store.data_dir,
self.config.reader.chunk_size,
self.config.reader.retrieve_top_k,
self.config.reader.file_retention_time,
self.config.reader.file_max_rounds,
)
# NapCat 兜底开关是"能否读到文件"的关键,加载时显式打出来
self.ctx.logger.info(
"NapCat 兜底: %s (url=%s, token=%s, cache_dir=%s)",
"启用" if self.config.napcat.enabled else "停用(适配器降级的文件读不到内容!)",
self.config.napcat.http_url,
"已设置" if (self.config.napcat.access_token or "").strip() else "空",
self.config.napcat.cache_dir or "未设置",
)
await self._start_cleanup_loop()
await self._start_retry_loop()
async def on_unload(self) -> None:
"""插件卸载时停止后台任务。"""
if self._cleanup_task:
self._cleanup_task.cancel()
try:
await self._cleanup_task
except (asyncio.CancelledError, Exception):
pass
self._cleanup_task = None
if self._retry_task:
self._retry_task.cancel()
try:
await self._retry_task
except (asyncio.CancelledError, Exception):
pass
self._retry_task = None
# v1.1.0:统一取消进行中的下载/解析/概要任务(旧版泄漏到重载后仍写旧 store)
for task in list(self._bg_tasks):
task.cancel()
if self._bg_tasks:
await asyncio.gather(*self._bg_tasks, return_exceptions=True)
self._bg_tasks.clear()
# v1.1.0:关闭 httpx 单例连接池
if self._http_client is not None:
try:
await self._http_client.aclose()
except Exception: # noqa: BLE001
pass
self._http_client = None
# v1.1.0:清掉重试队列残留的临时文件(条目已改存路径)
for item in self._embed_retry_queue:
tmp = item.get("tmp_path")
if tmp:
try:
os.remove(str(tmp))
except OSError:
pass
self._embed_retry_queue = []
self._store.save_meta()
self.ctx.logger.info("文件读取插件已卸载")
def _spawn_bg(self, coro: Any) -> None:
"""注册式 create_task(v1.1.0):持句柄防 GC 提前回收,on_unload 统一取消。"""
task = asyncio.create_task(coro)
self._bg_tasks.add(task)
task.add_done_callback(self._bg_tasks.discard)
async def on_config_update(self, scope: str, config_data: dict[str, Any], version: str) -> None:
"""配置热重载。"""
del scope, config_data, version
# v1.0.18 C5:热重载同步矩阵检索开关到已存在的会话库
use_matrix = bool(self.config.reader.retrieve_use_matrix)
for vs in self._store._sessions.values():
vs.retrieve_use_matrix = use_matrix
self.ctx.logger.info(
"配置已热重载:chunk_size=%d, top_k=%d, retention=%dmin, max_rounds=%d, matrix_retrieve=%s",
self.config.reader.chunk_size,
self.config.reader.retrieve_top_k,
self.config.reader.file_retention_time,
self.config.reader.file_max_rounds,
use_matrix,
)
# ─── 行为自检 ────────────────────────────────────────────────
def _run_self_check(self) -> None:
"""拿固定样例喂进程内函数对象,验证行为而非读源码文本(runtime-gotchas)。"""
try:
c = RecursiveCharacterChunker(10, 2)
out = c.chunk("一二三四五六七八九十十一十二十三十四")
assert isinstance(out, list) and out and all(isinstance(x, str) for x in out), "分块结果异常"
self.ctx.logger.info("[自检] 分块器: PASS (%d 块)", len(out))
except Exception as e: # noqa: BLE001
self.ctx.logger.error("[自检] 分块器: FAIL (%s)", e)
try:
from file_parser import get_extension, is_supported
assert get_extension("a.PDF") == "pdf"
assert is_supported("report.xlsx")
assert not is_supported("photo.mp4")
self.ctx.logger.info("[自检] 解析器类型表: PASS")
except Exception as e: # noqa: BLE001
self.ctx.logger.error("[自检] 解析器类型表: FAIL (%s)", e)
# ─── embedding 封装 ──────────────────────────────────────────
async def _embed(self, texts: list[str], *, query_mode: bool = False) -> Any:
"""调用 MaiBot llm.embed,带自动重试(指数退避),最终失败返回空(上层抛可读错误)。
v1.0.18:拆批后多批并发(embed_concurrency,默认 2)+ 批大小提到 64;
max_concurrent 一并透传给 host 侧调度。任一批失败仍整单返回 {}(防残缺向量)。
query_mode=True 时走查询分层:单次调用 + wait_for 超时,不重试(拥塞期不卡用户)。
"""
batch_size = max(1, int(self.config.reader.embed_batch_size))
if len(texts) <= batch_size:
return await self._embed_once(texts, query_mode=query_mode)
# 拆批
batches = [texts[i : i + batch_size] for i in range(0, len(texts), batch_size)]
total_batches = len(batches)
concurrency = max(1, int(self.config.reader.embed_concurrency)) if not query_mode else 1
if concurrency <= 1:
# 串行路径(embed_concurrency=1 等价旧行为)
results: list[Any] = []
for idx, part in enumerate(batches):
part_result = await self._embed_once(part, query_mode=query_mode)
if part_result == {} or part_result is None:
self.ctx.logger.warning(
"embedding 拆批调用第 %d/%d 批失败,整单失败待重试", idx + 1, total_batches
)
return {}
results.append(part_result)
self.ctx.logger.info("embedding 拆批进度: %d/%d 批(%d 块)", idx + 1, total_batches, len(part))
return self._merge_embed_results(results)
# 并发路径:Semaphore 限流,return_exceptions 收集失败批
sem = asyncio.Semaphore(concurrency)
done_log: dict[int, str] = {}
async def _run_one(idx: int, part: list[str]) -> Any:
async with sem:
return await self._embed_once(part, query_mode=query_mode)
async def _guarded(idx: int, part: list[str]) -> tuple[int, Any]:
try:
r = await _run_one(idx, part)
done_log[idx] = "ok"
return (idx, r)
except Exception as e: # noqa: BLE001
done_log[idx] = f"{type(e).__name__}: {e}"
return (idx, {})
gathered = await asyncio.gather(*(_guarded(i, b) for i, b in enumerate(batches)))
gathered.sort(key=lambda x: x[0]) # 按原批序拼回,保证向量与文本对齐
failed = [i for i, r in gathered if r == {} or r is None]
if failed:
self.ctx.logger.warning(
"embedding 并发拆批 %d/%d 批失败(%s),整单失败待重试",
len(failed), total_batches, ",".join(str(i + 1) for i in failed),
)
return {}
for i in range(total_batches):
self.ctx.logger.info("embedding 拆批进度: %d/%d 批(%d 块)", i + 1, total_batches, len(batches[i]))
return self._merge_embed_results([r for _, r in gathered])
def _merge_embed_results(self, results: list[Any]) -> Any:
"""把多批 embed 结果拼成一个与单批同构的结果。"""
# 纯 list 形态:直接拼接
if all(isinstance(r, list) for r in results):
merged: list[Any] = []
for r in results:
merged.extend(r)
return merged
# dict 形态:兼容 results / embeddings 两种键
if all(isinstance(r, dict) for r in results):
merged_dict: dict[str, Any] = {}
for key in ("results", "embeddings"):
if all(key in r for r in results):
items: list[Any] = []
for r in results:
items.extend(r[key])
merged_dict[key] = items
if merged_dict:
return merged_dict
# 单条形态(embedding 键):每批只有 1 条时退化为列表语义不可行,直接取第一键拼接不了,
# 保守返回第一批(调用方 _embed_query 只用于单条查询,不会走拆批路径)
return results[0]
return results[0]
async def _embed_once(self, texts: list[str], *, query_mode: bool = False) -> Any:
"""单批 embed 调用 + 自动重试。
v1.0.18 重试分层:
- 入库(query_mode=False):2 次尝试、退避 2s——入库走后台任务,可等;
- 查询(query_mode=True):单次调用 + wait_for(query_embed_timeout)——
BLOCKING hook 卡在请求模型之前是用户感知延迟主因(旧行为最坏 ~96s),
超时立即返回 {},上层注入路径会降级到概要兜底。
"""
if query_mode:
timeout = float(self.config.reader.query_embed_timeout or 0)
try:
if timeout > 0:
return await asyncio.wait_for(self.ctx.llm.embed(texts=texts), timeout=timeout)
return await self.ctx.llm.embed(texts=texts)
except (asyncio.TimeoutError, Exception) as e: # noqa: BLE001
self._embedding_ok = False
self._embedding_err = f"{type(e).__name__}: {e}"
self.ctx.logger.warning("查询 embedding 失败(不重试,降级概要兜底): %s", e)
return {}
last_err: Optional[Exception] = None
for attempt in range(2):
try:
result = await self.ctx.llm.embed(texts=texts)
self._embedding_ok = True
self._embedding_err = ""
return result
except Exception as e: # noqa: BLE001
last_err = e
self._embedding_ok = False
self._embedding_err = f"{type(e).__name__}: {e}"
if attempt < 1: # 还有重试机会
delay = 2.0
self.ctx.logger.warning(
"embedding 调用失败(第 %d 次),%.0fs 后重试: %s",
attempt + 1,
delay,
e,
)
await asyncio.sleep(delay)
self.ctx.logger.error("embedding 调用失败(已重试 1 次): %s", last_err)
return {}
async def _embed_query(self, query: str) -> Optional[list[float]]:
result = await self._embed([query], query_mode=True)
if isinstance(result, dict):
if isinstance(result.get("results"), list) and result["results"]:
item = result["results"][0]
if isinstance(item, dict) and "embedding" in item:
return list(item["embedding"])
if result.get("embedding") is not None:
return list(result["embedding"])
if isinstance(result, list) and result:
return list(result[0])
return None
# ─── 文件消息检测(双阶段 hook:before_process 拿原始段,after_process 兜底) ───
@HookHandler("chat.receive.before_process", name="detect_file_early", mode=HookMode.OBSERVE, error_policy=ErrorPolicy.SKIP)
async def on_file_message_early(self, **kwargs: Any) -> dict[str, Any]:
"""预处理阶段检测文件。
官方事件管线:before_process 在 SessionMessage.process() 轻量转换**之前**触发,
raw_message 可能仍是原始消息段(含 file 段的 url/base64/file_id)——
after_process 阶段实测文件已被转换成 text 描述(raw=list[text(-)]),故加此 hook。
"""
return await self._detect_file("before", **kwargs)
@HookHandler("chat.receive.after_process", name="detect_file", mode=HookMode.OBSERVE, error_policy=ErrorPolicy.SKIP)
async def on_file_message(self, **kwargs: Any) -> dict[str, Any]:
"""处理完成阶段检测文件(兜底:before_process 未提取到时再试一次)。"""
return await self._detect_file("after", **kwargs)
async def _detect_file(self, stage: str, **kwargs: Any) -> dict[str, Any]:
"""双阶段共用的文件检测逻辑;同文件 10 秒窗口去重防重复入库。"""
message = kwargs.get("message")
# 诊断:疑似文件消息必打(看清载荷),普通消息每阶段只打一次
self._log_file_hook_diag(stage, kwargs, message)
if not isinstance(message, dict) or not message:
return {"action": "continue"}
if not self.config.plugin.enabled:
self.ctx.logger.info("[诊断] 插件已停用(plugin.enabled=false),跳过文件检测")
return {"action": "continue"}
# 群聊开关(兼容 message_info.group_id 平铺与 group_info 嵌套两种形态)
if not self.config.reader.enable_group:
mi = message.get("message_info") or {}
gi = mi.get("group_info") if isinstance(mi, dict) else None
is_group = bool(
(isinstance(mi, dict) and mi.get("group_id"))
or (isinstance(gi, dict) and (gi.get("group_id") or gi.get("group_name") or gi.get("group_all")))
or kwargs.get("is_group")
)
if is_group:
return {"action": "continue"}
# 提取文件段
files = self._extract_files(message)
if not files and self._looks_like_file(message):
# 真机形态:适配器已把 file 段降级成 `[文件] xxx.docx,大小: 9547` 纯文本,
# 本体只能拿 message_id 回头找 NapCat 要。
# v1.0.19:防抖插件会把连续文件消息合并成一条,需解析全部 [文件] 行
hints = self._parse_file_hints(message)
if hints:
mid = message.get("message_id")
for hint in hints:
hint["napcat_message_id"] = mid
# 留档:/file_status 里能看到最近一次文件消息的 message_id,便于核对 NapCat 回溯
self._last_file_hint = {
"name": "、".join(h["name"] for h in hints[:5]) + ("…" if len(hints) > 5 else ""),
"size": hints[0]["size_hint"],
"mid": repr(mid),
"stage": stage,
}
files = hints
if not files:
return {"action": "continue"}
session_id = str(self._pick_session_id(message, kwargs) or "default")
conversation_id = str(kwargs.get("session_id") or session_id)
stream_id = str(self._pick_stream_id(message, kwargs) or "")
# 去重:同一 session 同名文件 10 秒内只处理一次(before/after 双 hook 各看到一次)
now = time.time()
to_handle: list[dict[str, Any]] = []
for fdata in files:
key = f"{session_id}:{fdata.get('name', '')}"
last = self._recent_file_keys.get(key, 0.0)
if now - last < 10.0:
continue
self._recent_file_keys[key] = now
to_handle.append(fdata)
# 顺带清理过期键
if len(self._recent_file_keys) > 64:
self._recent_file_keys = {k: v for k, v in self._recent_file_keys.items() if now - v < 60.0}
# 后台处理,不阻塞聊天主流程(runtime-gotchas 5.3)
for fdata in to_handle:
self._spawn_bg(self._handle_file(session_id, conversation_id, stream_id, fdata))
return {"action": "continue"}
def _looks_like_file(self, message: dict[str, Any]) -> bool:
"""轻量判断:该消息是否疑似文件消息(不做内容提取,只判标记与段类型)。"""
plain = str(message.get("processed_plain_text") or "")
if "[文件]" in plain or "[文件]" in str(message.get("text") or ""):
return True
segs = message.get("raw_message")
if isinstance(segs, list):
for s in segs:
if isinstance(s, dict) and str(s.get("type", "")).lower() in ("file", "attachment"):
return True
return False
def _parse_file_hints(self, message: dict[str, Any]) -> list[dict[str, Any]]:
"""从降级文本里解析全部文件(v1.0.19):防抖插件会把连续多条文件消息合并成一条,
文本里出现多行 `[文件] xxx,大小: N,链接: https://...`——必须逐行解析,漏一行丢一个文件。
单行形态(v1.0.8 既有):
- 私聊:`[文件] 文章.docx,大小: 9547`
- 群聊:`[文件] 文章.docx,大小: 9547,链接: https://tjc-download.ftn.qq.com/...`
返回 [{"name", "size_hint", "url"?}, ...];一行解析不出则跳过该行。
"""
plain = str(message.get("processed_plain_text") or "") or str(message.get("text") or "")
if "[文件]" not in plain:
return []
hints: list[dict[str, Any]] = []
# 逐行解析:链接可能含逗号,先按行切分再在行内摘 URL
for line in plain.splitlines():
line = line.strip()
if "[文件]" not in line:
continue
url_m = _RE_HINT_URL.search(line)
url = url_m.group(1).rstrip(",,。") if url_m else ""
# 去掉链接尾巴再匹配文件名,避免长 URL 被吞进 name
name_part = line[: url_m.start()] if url_m else line
m = _RE_HINT_NAME.search(name_part)
if not m:
continue
name = m.group("name").strip()
if not name:
continue
size_raw = m.group("size")
hint: dict[str, Any] = {"name": name, "size_hint": int(size_raw) if size_raw else 0}
if url:
hint["url"] = url
hints.append(hint)
return hints
def _parse_file_hint(self, message: dict[str, Any]) -> Optional[dict[str, Any]]:
"""单文件形态兼容入口(v1.0.8 版语义,内部走 _parse_file_hints)。"""
hints = self._parse_file_hints(message)
return hints[0] if hints else None
def _log_file_hook_diag(self, stage: str, kwargs: dict[str, Any], message: Any) -> None:
"""诊断载荷结构。
策略(v1.0.5):
- 疑似文件消息:每阶段最多打 5 条完整段结构——排障靠的就是这些行,
不能让第一条普通文本消息把一次性标志消耗掉(v1.0.4 踩过)。
- 普通消息:每阶段只打一行,防刷屏。
"""
if not isinstance(message, dict) or not message:
flag = f"_file_diag_logged_{stage}"
if getattr(self, flag, False):
return
setattr(self, flag, True)
self.ctx.logger.info(
"[诊断] file hook[%s] 载荷非 dict: message_type=%s kwargs_keys=%s",
stage,
type(message).__name__,
sorted(kwargs.keys()),
)
return
looks_file = self._looks_like_file(message)
if looks_file:
cnt_attr = f"_file_diag_file_count_{stage}"
cnt = int(getattr(self, cnt_attr, 0))
if cnt >= 5:
return
setattr(self, cnt_attr, cnt + 1)
tag = "FILE"
else:
flag = f"_file_diag_logged_{stage}"
if getattr(self, flag, False):
return
setattr(self, flag, True)
tag = "msg"
self._log_diag_detail(stage, tag, kwargs, message)
def _log_diag_detail(self, stage: str, tag: str, kwargs: dict[str, Any], message: dict[str, Any]) -> None:
"""打印单条诊断:载荷键结构 + raw_message 段细节 + 纯文本预览。"""
try:
mi = message.get("message_info")
raw = message.get("raw_message")
raw_desc = self._describe_raw(raw)
plain = str(message.get("processed_plain_text") or "")
self.ctx.logger.info(
"[诊断] file hook[%s][%s] 触发: kwargs_keys=%s message_keys=%s message_info_keys=%s mid=%s raw=%s plain=%s",
stage,
tag,
sorted(kwargs.keys()),
sorted(message.keys()),
sorted(mi.keys()) if isinstance(mi, dict) else "-",
repr(message.get("message_id")),
raw_desc,
repr(plain[:80]),
)
except Exception: # noqa: BLE001
pass
def _describe_raw(self, raw: Any) -> str:
"""描述 raw_message 结构:list → 各段 type + data 摘要(data 非 dict 时打印值与类型)。"""
if isinstance(raw, list):
descs = []
for s in raw[:8]:
if not isinstance(s, dict):
descs.append(f"{type(s).__name__}={str(s)[:40]!r}")
continue
data = s.get("data")
if isinstance(data, dict):
detail = "|".join(sorted(data.keys()))
elif data is None:
detail = "-"
else:
# data 为非 dict(如纯字符串):打印类型与值,便于看清 text 段到底存了什么
detail = f"{type(data).__name__}={str(data)[:60]!r}"
descs.append(f"{s.get('type')}({detail})")
return f"list[{', '.join(descs)}]"
if isinstance(raw, dict):
return f"dict keys={sorted(raw.keys())}"
return type(raw).__name__
def _extract_files(self, message: dict[str, Any]) -> list[dict[str, Any]]:
"""从消息里找出文件段,返回 [{name, bytes|path|url}, ...]。
兼容多种结构:
- segments / message_chain / content / message 列表里的 {"type": "file", ...}
- OneBot 风格 {"type": "file", "data": {...}}(napcat 等适配器)
- raw_message 里嵌套的 file / attachment / files
- raw_message.message 数组(OneBot 原始事件)
"""
found: list[dict[str, Any]] = []
def _scan_segments(segs: Any) -> None:
if not isinstance(segs, list):
return
for seg in segs:
if not isinstance(seg, dict):
continue
stype = str(seg.get("type", "")).lower()
if stype in ("file", "attachment"):
# OneBot 风格:实际字段嵌在 data 里
payload = seg.get("data") if isinstance(seg.get("data"), dict) else seg
fdata = self._seg_to_file(payload)
if fdata:
found.append(fdata)
else:
self.ctx.logger.warning(
"文件段无法提取内容: payload_keys=%s", sorted(payload.keys())
)
# 1) 消息级段列表
for key in ("segments", "message_chain", "content", "message"):
_scan_segments(message.get(key))
# 2) raw_message:list(napcat 真机形态:消息段数组)或 dict(嵌套容器)
raw = message.get("raw_message")
if isinstance(raw, list):
# 真机确认:raw_message 本身就是 [{"type": "file", "data": {...}}, ...] 段数组
_scan_segments(raw)
elif isinstance(raw, dict):
for key in ("file", "attachment", "files"):
val = raw.get(key)
if isinstance(val, dict):
fdata = self._seg_to_file(val)
if fdata:
found.append(fdata)
else:
_scan_segments(val)
# OneBot 原始事件:message 数组
if isinstance(raw.get("message"), list):
_scan_segments(raw["message"])
return found
def _seg_to_file(self, seg: dict[str, Any]) -> Optional[dict[str, Any]]:
"""从单个文件段提取 name + 内容(base64 / 本地路径 / URL)。
兼容字段名:name / file_name / filename / file / file_id / title;
内容:base64 / data / content_base64 / url / file_url / path / file_path / local_path。
OneBot 的 data.file 可能是 URL、本地缓存路径或纯文件名,按形态分发。
"""
raw_file = str(seg.get("file") or "")
name = (
seg.get("name")
or seg.get("file_name")
or seg.get("filename")
or seg.get("title")
or Path(raw_file).name # OneBot data.file 常带路径,取文件名
or seg.get("file_id")
or "unnamed_file"
)
content_b64 = seg.get("base64") or seg.get("content_base64")
url = seg.get("url") or seg.get("file_url")
path = seg.get("path") or seg.get("file_path") or seg.get("local_path") or seg.get("tmp_path")
# OneBot data.file 形态分发:URL / 本地路径 / 纯文件名
if raw_file:
if raw_file.startswith(("http://", "https://")) and not url:
url = raw_file
elif not path and Path(raw_file).exists():
path = raw_file
if content_b64:
try:
raw_bytes = base64.b64decode(content_b64)
return {"name": str(name), "bytes": raw_bytes}
except Exception: # noqa: BLE001
pass
if path and Path(str(path)).exists():
return {"name": str(name), "path": str(path)}
if url:
return {"name": str(name), "url": str(url)}
# 无可用内容
return None
def _pick_session_id(self, message: dict[str, Any], kwargs: dict[str, Any]) -> str:
for key in ("session_id", "user_id", "sender_id", "user_openid"):
if kwargs.get(key):
return str(kwargs[key])
# 真机形态:message 顶层 session_id(napcat,与 before_model_request 的 session 一致)
if message.get("session_id"):
return str(message["session_id"])
mi = message.get("message_info") or {}
if not isinstance(mi, dict):
return ""
# 真机形态:user_info / group_info 嵌套
ui = mi.get("user_info")
if isinstance(ui, dict):
for key in ("user_id", "id", "qq", "user_openid"):
if ui.get(key):
return str(ui[key])
gi = mi.get("group_info")
if isinstance(gi, dict):
for key in ("group_id", "id", "group_all"):
if gi.get(key):
return str(gi[key])
for key in ("user_id", "sender_id", "user_openid", "group_id"):
if mi.get(key):
return str(mi[key])
return ""
def _pick_stream_id(self, message: dict[str, Any], kwargs: dict[str, Any]) -> str:
for key in ("stream_id", "chat_id", "session_id", "stream"):
if kwargs.get(key):
return str(kwargs[key])
# 真机形态:message 顶层 session_id(napcat,回执发送目标)
if message.get("session_id"):
return str(message["session_id"])
mi = message.get("message_info") or {}
if isinstance(mi, dict):
for key in ("stream_id", "chat_id", "group_id"):
if mi.get(key):
return str(mi[key])
gi = mi.get("group_info")
if isinstance(gi, dict):
for key in ("group_id", "id"):
if gi.get(key):
return str(gi[key])
return ""
async def _handle_file(self, session_id: str, conversation_id: str, stream_id: str, fdata: dict[str, Any]) -> None:
"""后台:写临时文件 → 解析 → 向量化 → 落库 → 回执。
embedding 失败(真机 14:27 日志:服务拥塞持续超 6s 退避窗口)时:
已解析文本放入后台重试队列,由 _retry_loop 定时重试入库,对用户透明。
"""
name = str(fdata.get("name", "unnamed_file"))
vs = self._store.get_or_create(
session_id,
conversation_id,
self._embed,
self.config.reader.chunk_size,
self.config.reader.chunk_overlap,
self.config.reader.retrieve_top_k,
chunk_merge=bool(self.config.reader.chunk_merge_enabled),
retrieve_use_matrix=bool(self.config.reader.retrieve_use_matrix),
)
tmp_path: Optional[str] = None
queued_for_retry = False # v1.1.0:入队后临时文件归重试队列持有,finally 不再删
# 大小上限提前算好:下载路径在下载前/下载中就要用它做预检(v1.1.0)
max_bytes = self.config.reader.max_file_size * 1024 * 1024
try:
# 取文件字节
# v1.1.0:本地路径读文件包 to_thread——100MB 级同步 read_bytes 会冻结整个事件循环
raw_bytes = fdata.get("bytes")
if raw_bytes is None and fdata.get("path"):
raw_bytes = await asyncio.to_thread(Path(str(fdata["path"])).read_bytes)
if raw_bytes is None and fdata.get("url"):
try:
raw_bytes = await self._download(str(fdata["url"]), max_bytes)
except _DownloadTooLargeError as e:
await self._reply_error(
stream_id,
f"⚠️ 文件「{name}」大小约 {e.size_mb:.1f}MB 超过上限 {self.config.reader.max_file_size}MB,已跳过。",
reason="too_large",
)
return
if raw_bytes is None and fdata.get("napcat_message_id"):
# 适配器降级形态:拿 message_id 回头找 NapCat 要原始文件
if not self.config.napcat.enabled:
await self._reply_error(
stream_id,
f"⚠️ 文件「{name}」读不到内容:适配器把文件降级成了纯文本,"
"请在插件配置里启用「NapCat 兜底」并填好 HTTP 地址与 token。",
reason="napcat_disabled",
)
return
raw_bytes, napcat_path = await asyncio.to_thread(self._resolve_via_napcat, fdata)
if raw_bytes is None and napcat_path:
raw_bytes = await asyncio.to_thread(Path(str(napcat_path)).read_bytes)
if raw_bytes is None:
if fdata.get("napcat_message_id"):
await self._reply_error(
stream_id,
f"⚠️ 文件「{name}」无法读取:NapCat 未返回文件内容"
"(检查 HTTP 服务/token/message_id 是否对得上,或配置 cache_dir)。",
reason="napcat_no_content",
)
else:
await self._reply_error(
stream_id,
f"⚠️ 文件「{name}」无法读取:消息里没有文件内容,也没有可用于回溯的 message_id。",
reason="no_content",
)
return
# 大小检查(base64/本地路径来源在此兜底;URL 来源已在下载中预检)
if len(raw_bytes) > max_bytes:
await self._reply_error(
stream_id,
f"⚠️ 文件「{name}」大小 {len(raw_bytes) / 1024 / 1024:.1f}MB 超过上限 {self.config.reader.max_file_size}MB,已跳过。",
reason="too_large",
)
return
# 写临时文件
suffix = Path(name).suffix or ""
fd, tmp_path = tempfile.mkstemp(suffix=suffix)
with os.fdopen(fd, "wb") as f:
f.write(raw_bytes)
# 解析前释放原始字节引用,降低瞬时内存峰值(raw_bytes + 文本 + 向量曾同时驻留)
raw_bytes = None
# 解析
text = await asyncio.to_thread(read_any_file_to_text, tmp_path, name)
# 向量化 + 入库
try:
entry = await vs.add_file(name, text)
except RuntimeError as e:
# embedding 为空/超时:放入后台重试队列(v1.0.11)
# 注意:ValueError(内容为空)不在此列,走下方通用分支
# v1.1.0:队列条目存临时文件路径而非全文,临时文件转交队列持有
await self._enqueue_embed_retry(session_id, conversation_id, stream_id, name, str(tmp_path))
queued_for_retry = True
self.ctx.logger.warning("文件 %s 入库失败(embedding),已入后台重试队列: %s", name, e)
return
self.ctx.logger.info(
"已入库文件 %s:%d 块(session=%s)",
name,
len(entry.chunks),
session_id,
)
# v1.0.17:大文件后台预热概要(不阻塞回执;注入时 _ensure_summary 也会懒生成,这里只是让首次提问即命中)
# v1.0.18 C4:改走 _ensure_summary_async(in-flight 去重 + 失败冷却),注入触发时不会重复起任务
# v1.0.18 C1:阈值判定用 entry.total_chars()(原文长度),overlap 合并会让 join(chunks) 膨胀
if (
bool(self.config.reader.summary_enabled)
and entry.total_chars() > max(1, int(self.config.reader.direct_inject_max_chars))
and not entry.summary
):
self._ensure_summary_async(entry)
# v1.0.18 C3:文件变动必须失效注入缓存,否则同一问题会复用旧文件的注入内容
self._memo_invalidate_session(session_id)
if not self.config.reader.silent_success:
await self._reply(
stream_id,
f"📄 已解析「{name}」,切成 {len(entry.chunks)} 块并向量化。现在可以直接问我文件内容了。",
)
except ValueError as e:
await self._reply_error(stream_id, f"⚠️ {e}", reason="value_error")
except RuntimeError as e:
await self._reply_error(stream_id, f"⚠️ {e}", reason="runtime_error")
except Exception as e: # noqa: BLE001
self.ctx.logger.error("处理文件 %s 失败: %s", name, e, exc_info=True)
await self._reply_error(
stream_id, f"⚠️ 处理文件「{name}」失败:{type(e).__name__}", reason="exception"
)
finally:
if tmp_path and not queued_for_retry:
try:
os.remove(tmp_path)
except OSError:
pass
def _napcat_ssl_context(self) -> ssl.SSLContext:
"""NapCat urllib SSL 上下文缓存(v1.1.0):create_default_context 是 ms 级 CPU 调用,
旧版每次 _napcat_post 新建(每文件 2~3 次请求);verify_ssl 配置变化时重建。"""
verify = bool(self.config.napcat.verify_ssl)
if self._napcat_ssl_ctx is None or self._napcat_ssl_verify != verify:
ctx = ssl.create_default_context()
if not verify:
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
self._napcat_ssl_ctx = ctx
self._napcat_ssl_verify = verify
return self._napcat_ssl_ctx
# ─── NapCat HTTP 兜底(同步实现,调用处包 asyncio.to_thread) ───
def _napcat_post(self, endpoint: str, payload: dict[str, Any]) -> Optional[Any]:
"""调 NapCat OneBot HTTP API,返回 data 字段;失败返回 None。"""
cfg = self.config.napcat
url = f"{str(cfg.http_url).rstrip('/')}/{endpoint}"
body = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
url, data=body, headers={"Content-Type": "application/json"}, method="POST"
)
token = (cfg.access_token or "").strip()
if token:
req.add_header("Authorization", f"Bearer {token}")
ctx = self._napcat_ssl_context()
try:
with urllib.request.urlopen(req, timeout=cfg.timeout, context=ctx) as resp:
data = json.loads(resp.read().decode("utf-8", errors="replace"))
except urllib.error.HTTPError as e:
self.ctx.logger.error("[napcat] %s HTTP %s: %s", endpoint, e.code, e.read()[:200])
return None
except Exception as e: # noqa: BLE001
self.ctx.logger.error("[napcat] %s 请求失败: %s: %s", endpoint, type(e).__name__, e)
return None
if not isinstance(data, dict):
return None
status = str(data.get("status", ""))
retcode = data.get("retcode")
if status == "ok" or retcode == 0 or (status == "" and retcode is None and "data" in data):
return data.get("data")
self.ctx.logger.error(
"[napcat] %s 返回异常: status=%s retcode=%s wording=%s",
endpoint,
status,
retcode,
data.get("wording") or data.get("message"),
)
return None
def _napcat_get_msg(self, message_id: Any) -> Optional[dict[str, Any]]:
"""拿原始 OneBot 事件。message_id 形态未知,先原样试,失败再转 int 重试。"""
if message_id in (None, ""):
self.ctx.logger.error("[napcat] 消息没有 message_id,无法回溯原始事件")
return None
candidates: list[Any] = [message_id]
try:
as_int = int(str(message_id))
if as_int != message_id:
candidates.append(as_int)
except (TypeError, ValueError):
pass
for cand in candidates:
data = self._napcat_post("get_msg", {"message_id": cand})
if isinstance(data, dict):
self.ctx.logger.info(
"[napcat] get_msg 成功: message_id=%r raw_keys=%s",