feat: 群分析本地消息库 / 处理中占位图 / Web 日志窗口重构

- 群分析: 新增 history_store 只读本地消息源(读 learning_chat 落库,含 uninfo
  昵称补齐/去重/截断判定),适配器优先读本地库、失败回退 OneBot 分页;分页
  锚点字段回退链修复 NapCat 传 message_seq 翻页断裂;新增「本地记录」开关
- core/message_utils: 新增 common_proc_reply 占位图通用回复(引用消息 + 处理中
  动图,支持后台任务显式指定 target),群分析/战况/倒放改用
- web_hub + web: 日志页改固定窗口滚动 + 翻页锚定 + 自动换行,SSE 日志轮转发
  reset 帧,入口 HTML no-cache,行数统计增量缓存,CPU 改非阻塞采样,登录信息缓存
- 插件内 CLAUDE.md / DESIGN.md 不入库(.gitignore),galgame_card 两份文档取消
  跟踪(文件保留在磁盘)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-14 18:59:01 +08:00
co-authored by Claude Code
parent 662c28cb2f
commit 51b08ccb68
23 changed files with 1100 additions and 393 deletions
@@ -1,81 +0,0 @@
# CLAUDE.md — 群聊人设卡插件开发文档
本文件是**本插件内开发**的唯一入口文档;需要项目全局信息(Poetry 命令、启动方式、测试约定)时再查项目根目录的 CLAUDE.md。详细设计定稿见同目录 `DESIGN.md`。
## 插件概述
基于群聊语料蒸馏群成员的形象风格,生成 galgame 风格人物卡(九段画像)。
- **目标**:生成"人的画像",不是关系网分析;样貌参考为虚构,永远带标注
- **隐私硬约束**:只采集 opt-in 成员;敏感数据本地正则替换为占位符,**绝不经过 LLM**
- **维度隔离**:每个 `(group_id, user_id)` 是独立人设,跨群不混
## 目录结构
```
nonebot_plugin_galgame_card/
├── __init__.py 入口层:on_message 采集器(鉴权/采样/调治理落库)
├── models.py 数据层:五张表(persona_group/user/chat_log/impression/summary)
├── repository.py 数据层:仓储(来源无关,所有读写唯一入口)
├── processor.py 治理层:纯函数(五维提取/噪声/脱敏),可单测
├── config.py 插件配置(仅图片识别开关等;Web 鉴权统一走 /hub)
├── web_hub.py Web API 子应用(挂载到 /api/galgame_card,auth=hexi.web_hub.web_auth)
├── DESIGN.md 设计定稿(数据模型/流水线/九段协议/证据纪律)
└── CLAUDE.md 本文件
```
**Web 管理后台**:已嵌入统一管理台 `/hub/`,前端由 `hexi/web` 渲染,API 挂载到 `/api/galgame_card`,鉴权与 /hub 共用 `hexi.web_hub.web_auth`(OAuth2 + SQLite)。功能:群开关、参与者增删、语料/印象/画像浏览与删除、清空群数据。改后端需重启 bot。
## 核心设计(速览,细节见 DESIGN.md)
1. **两级闸门**:群开关 `persona_group.enabled`(默认关)+ 个人 opt-in `persona_user`,都过才采集
2. **两级流水线**:语料 →(攒够 N 条)→ 印象(LLM 自然语言,增量中间层)→(攒够 M 条)→ 画像(九段 markdown,版本化)
3. **消息五维**:内容 / 谁发的 / 发给谁(回复/@)/ 几点发的 / 被回复内容快照——只存治理后纯文本
4. **脱敏四层**(`desensitize()`):明确模式(最长优先排序防截胡)→ 定位式(同条关键词+值)→ 跨条语境(关键词在附近消息)→ 兜底(保守替换)
5. **九段画像协议**:身份印象/性格特征/说话风格/口头禅语录/兴趣话题/相处模式/时间画像/样貌参考(虚构)/不确定信息
6. **总结路径不进消息 handler**:LLM 调用只在调度器触发,防延迟/限流
## 开发命令
```bash
# 跑本插件测试(治理层纯函数,9+ 个用例)
poetry run pytest tests/test_persona_processor.py
# 全量测试
poetry run pytest
# 启动/重启验证:PyCharm 的 "start bot" 运行配置(勿用 bat 脚本)
```
## 数据库
- orm 默认库:`data/nonebot_plugin_orm/db.sqlite3`(不是 `hexi/data/data.db`)
- 建表:bot 启动时 orm 自动 create_all(新表加在 `models.py` 里即可,重启生效)
- 配置键是 `SQLALCHEMY_DATABASE_URL`(本插件未设置,走默认库)
## 开发注意事项(踩过的坑)
0. **⚠️ orm 启动自动同步会清空表数据**:`.env` 里 `ALEMBIC_STARTUP_CHECK=false` 时,nonebot_plugin_orm 每次启动都 autogenerate 同步数据库模式,**模型一有变更(改 models.py)就会重建表、清空全部数据**(2026-08-11 实测踩坑,全表被清)。已在本插件 `__init__.py` 导入期把 `migrate.sync` 替换为安全空操作。**今后 schema 演进只准通过 `repository.ensure_schema()` 显式 ALTER**,改完 models.py 后要在重启前手动执行对应 ALTER(或加进 ensure_schema)。
1. **`on_message` 必须 `block=False`**:否则事件流被拦截,群里其他插件全废
2. **只收群消息**:handler 参数注解 `GroupMessageEvent`(类型注解即过滤器)
3. **脱敏正则排序**:身份证/银行卡必须在手机号之前,否则手机号截胡身份证数字段
4. **定位式替换只替换值、保留关键词**:`密码是 xyz789` → `密码是 [密码]`,关键词不能丢
5. **测试不能裸 import 包**:`__init__.py` 触发 NoneBot 初始化,用 importlib 按路径加载 processor(见 `tests/test_persona_processor.py`,与 test_rate_limit 同款)
6. **总结/印象生成**:绝不在消息 handler 里调 LLM,走调度层(apscheduler)
7. **仓储并发**:`add_summary` 版本自增有并发撞 UNIQUE 风险,调度层加锁保护
## 当前进度
- ✅ 数据层:五表 + 仓储(含群开关、滚动淘汰、版本自增)
- ✅ 采集层:消息路径(监控→鉴权→治理→脱敏→采样→落库)
- ✅ Web 管理后台:嵌入 /hub(/api/galgame_card + hexi/web 页面;群开关、参与者、数据浏览/清理)
- ⏳ QQ 命令集:`开启人设采集` / `加入人设` / `退出人设` / `查看人设`(Web 已覆盖同等功能,QQ 命令可选做)
- ⏳ 总结路径:LLM 客户端、印象生成、九段画像生成、调度触发
- ⏳ 呈现层:人物卡展示/图片渲染
## 待决策点
- 触发阈值(印象 ≥50 条新语料 / 画像 ≥5 条新印象,⏳ 待调)
- 脱敏兜底位数(裸数字 ≥6 位默认替换 `[账号]`,保守优先;误杀多可提到 8 位,动 `BARE_DIGITS_RE`)
- 密保答案场景(中文值正则误杀率高,方案待定)
- 命令名与权限(超管/群主)
@@ -1,194 +0,0 @@
# 群聊人设卡(Galgame 风格人物卡构建器)设计文档
> 本文档固化设计决策,作为各层实现的唯一依据。标注 ⏳ 的为草案/待定项。
## 1. 定位
**目标**:基于群聊发言语料,蒸馏群成员的形象风格,生成 galgame 风格的人物卡(人设)。
**非目标**:
- 不做关系网分析("相处模式"只是画像的一个段落,不是独立产品)
- 不做真实身份推断(年龄/职业/住址等现实信息只能进"不确定信息"段)
- 不生成真实样貌(样貌参考为虚构,永远带标注)
**消费方**:① 展示给人看(人物卡);② 可选:作为扮演 prompt 注入(MaiBot 兼容方向)。
## 2. 整体架构
功能分层:采集 → 治理 → 存储 → 调度 → 分析 → 呈现。
两级流水线(借鉴 MaiBot 印象机制):
```
群聊消息 ──五维落库──> 语料 ──(攒够 N 条)──> 印象(LLM 自然语言) ──(攒够 M 条)──> 画像(九段协议) ──> 版本化快照
```
- **语料 → 印象**:每次对"上次印象之后的新语料"生成一段自然语言印象(话题/氛围/互动),存 `persona_impression`。印象是增量中间产物,画像不重读全部原文。
- **印象 → 画像**:从印象集 + 规则统计(@/回复 互动、活跃时段)生成九段人物卡。
- 画像每次生成都是新版本(version +1),永久留档可对比。
## 3. 数据层(已实现 ✅)
五张表,前缀 `persona_`:
### persona_group —— 群采集开关(数据来源总闸门)
| 字段 | 类型 | 说明 |
|---|---|---|
| group_id | BigInteger PK | 群号 |
| enabled | Boolean 默认 False | 该群是否开启采集(默认关,需显式开启) |
| updated_at | DateTime | 最后变更时间 |
群关闭 → 该群所有人一律不采集;群开启后,个人还需 opt-in(两级闸门)。
### persona_user —— 参与者名单(按群 opt-in)
| 字段 | 类型 | 说明 |
|---|---|---|
| user_id | BigInteger PK | 参与人 QQ |
| group_id | BigInteger PK | 所在群 |
| joined_at | DateTime | 加入时间 |
### persona_chat_log —— 采集语料(五维 + 发言段链条)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | Integer PK 自增 | 全局有序,印象覆盖区间用它表示 |
| user_id | BigInteger | 谁发的 |
| group_id | BigInteger | |
| nickname | String(64) | 群昵称快照 |
| content | Text | 内容(纯文本,已脱敏截断) |
| target_user_id | BigInteger NULL | 发给谁(ev.reply 的 sender / @ 对象;无则 NULL=群聊漫谈) |
| target_inherited | Boolean | target 是否从发言段链条继承(对上一句的解释/补充仍算发给同一对象) |
| follows_id | Integer NULL | 发言段链条:同一说话人的上一条语料 id(间隔 ≤ 5 分钟) |
| reply_to_content | Text NULL | 被回复内容快照(对方不在语料里也能知道他在回应什么) |
| created_at | DateTime | 几点发的 |
索引:`(user_id, group_id, created_at)`。滚动保留:单用户单群上限 3000 条(⏳ 常量待定)。
**发言段链条**(解决"不带 @/回复 的后续补充丢目标"):`@B 借我玩玩` → 下一条 `我的意思是借号不是借人`(无显式目标)继承 target=B 并打 `target_inherited` 标记;分析层窗口组装可沿 `follows_id` 回溯整段发言。
### persona_impression —— 印象(两级流水线中间产物)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | Integer PK 自增 | |
| user_id / group_id | BigInteger | |
| content | Text | LLM 生成的自然语言印象 |
| cover_from_id / cover_to_id | Integer | 覆盖的语料 id 区间(增量依据) |
| model | String(64) | 生成模型 |
| created_at | DateTime | |
### persona_image —— 图片识别结果缓存(⏳ 多模态预留,未启用)
| 字段 | 类型 | 说明 |
|---|---|---|
| hash | String(64) PK | 图片 hash(对应 chat_log.image_hashes) |
| description | Text | 多模态识别结果(表情包梗/截图内容) |
| model | String(64) | 识别模型 |
| recognized_at | DateTime | |
**预留接口**:`vision.py`(BaseImageRecognizer,当前为 Noop 占位)。将来接入多模态 LLM 后:异步后台识别(绝不在消息路径同步调)、同一 hash 只识别一次(缓存复用)、失败不影响采集。识别描述供分析层窗口组装喂给总结 LLM。
### persona_summary —— 画像快照(版本化)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | Integer PK 自增 | |
| user_id / group_id | BigInteger | |
| version | Integer | 每次生成 +1,同人同群唯一 |
| card_text | Text | 九段 markdown 画像原文 |
| corpus_count | Integer | 语料覆盖条数(元信息) |
| impression_count | Integer | 使用的印象条数(元信息) |
| model | String(64) | 生成模型 |
| created_at | DateTime | |
约束:`UNIQUE(user_id, group_id, version)`(并发写入由调度层锁保护)。
## 4. 九段画像协议(已定稿 ✅)
格式:markdown 固定标题 + 有界 bullet,代码可解析、人可编辑、LLM 可生成、可注入 prompt。
```
# 人物卡 · {主称呼}
语料 {N} 条 · 时间跨度 {start}-{end} · 版本 v{n} · 生成于 {date}
## 身份印象 ≤4 条 群内可见的:自称方式、群角色(吐槽役)、常用昵称
## 性格特征 ≤6 条 毒舌但心软 / 重度拖延 / 嘴硬
## 说话风格 ≤5 条 爱用"草"开头、句尾 wwww、先吐槽再给结论
## 口头禅语录 ≤6 条 带原文引用:"有一说一,这个图确实带"
## 兴趣话题 ≤5 条 明日方舟(资深);聊工作→抱怨、聊感情→回避
## 相处模式 ≤4 条 对小B互怼最多,对新人客气(一句话式,非关系网)
## 时间画像 ≤3 条 深夜 22-02 点活跃,白天潜水
## 样貌参考 ≤3 条 🎨 虚构标注:基于气质的参考描述
## 不确定信息 ≤3 条 疑是学生(语料出现"上课"),未证实
```
- 段内 bullet 为纯文本,可带原文引用(口头禅语录段必须带原文)
- 样貌参考段**必须**带 🎨 虚构标注与设计依据("基于 XX 气质")
- 现实身份信息只能出现在"不确定信息"段
- ⏳ 每段具体生成约束(prompt 细则)属分析层,待细化
## 5. 证据纪律(已定稿 ✅)
三层防编造(借鉴 MaiBot):
1. 印象 prompt 明文约束:"不要添加语料中没有依据的新事实"
2. 规则统计(互动对象、活跃时段)优先于 LLM 分类结果
3. LLM 分类结果默认降级进"不确定信息"段——**模型说的不算稳定真相**
指纹缓存(⏳):证据(印象集 + 统计)hash 未变则不重新生成画像。
## 6. 采集与治理(✅ 消息路径已实现,⏳ 阈值待调)
实现位置:`__init__.py`(on_message 入口 + 鉴权 + 采样)+ `processor.py`(纯函数治理,可单测)。
**两级闸门**:群开关 `persona_group.enabled`(群级,默认关)+ 个人 opt-in `persona_user`(个人,群内开启才生效)。两条同时满足才采集。
- 群开关控制:`开启人设采集 @群` / `关闭人设采集`(⏳ 命令名待定,权限:超管/群主)
- 只采集 `persona_user` 名单内成员(opt-in),退出即停
**脱敏(硬约束:本地正则完成,绝不经过 LLM——LLM 只接触脱敏后文本)**:
敏感值**替换为占位符**而非丢弃整条,保留对话语境(如"借号"互动是人格素材,凭证不是)。
实现:`processor.desensitize()`,四层,覆盖场景:借号/验证码代收/密码口令/兑换码卡密/密保/收入/联系方式变体/位置。
| 规则层 | 模式 | 占位符 |
|---|---|---|
| 明确模式(按最长优先排序,防截胡) | 身份证 → 银行卡 → 邮箱 → 手机号(含分隔变体) → IP → wxid → 坐标 → 车牌 | `[身份证]` `[银行卡]` `[邮箱]` `[手机号]` `[IP]` `[微信号]` `[坐标]` `[车牌]` |
| 定位式(同条"关键词+值") | `密码是 xyz789` `账号 abc123` `激活码 ABCDE-1` `验证码是 123456` `VX: xxx` `月薪 25000`(连接词支持 是/为/冒号/空格) | `[密码]` `[账号]` `[兑换码]` `[验证码]` `[微信号]` `[收入]` |
| 跨条语境 | 关键词在附近消息(如"收下验证码"→ 下一条 `123456`)→ 本条值按语境类型替换 | `[验证码]` `[密码]` `[兑换码]` |
| 兜底 | 裸长数字串 ≥6 位 → `[账号]`;字母+数字混合 ≥6 位 → `[密码]`(保守替换,宁误杀不放过) | `[账号]` `[密码]` |
语境来源:本群最近 3 条已治理文本(复用复读检测的内存窗口)。
待扩展场景(⏳):密保答案("你妈妈的名字")、QQ 号文本、代充代练语境。
- 噪声过滤:纯表情图(无文字)、复读、命令/签到、长链接轰炸
- 长文截断(约 200 字/条)
- 采样:连续刷屏 5 秒内只记 1 条
## 7. 调度与触发(⏳ 草案)
| 方式 | 条件 |
|---|---|
| 手动 | 管理员 `生成人物卡 @xxx`(强制,无视阈值) |
| 印象 | 新语料 ≥ 50 条(⏳)且距上次印象 ≥ 24h |
| 画像 | 新印象 ≥ 5 条(⏳)或语料显著增长;首次需语料 ≥ 200 条 |
| 防重入 | 同人同群生成中加锁 |
## 8. 入口层命令集(⏳ 草案)
`加入人设` `退出人设`(opt-in 控制)、`查看人设 @xxx`(展示九段卡)、`生成人设 @xxx`(管理员强制)。
## 9. 借鉴与不借鉴 MaiBot(已定稿 ✅)
**借鉴**:两级流水线(印象机制)、九段协议格式(段落文本协议)、证据纪律三层、指纹缓存、防串人(证据绑定 user_id)。
**不借鉴**:向量库 + BM25 双路召回 + PPR(语料量级 SQL 直查即可)、完整 A_memorix 记忆系统、md5 person_id(QQ 号即 id)。
## 10. 开发阶段
- **Phase 1**:数据层(四表 + 仓储)✅ 本文档落盘时完成
- **Phase 2**:治理层(采集过滤 + opt-in 命令)
- **Phase 3**:分析层(印象/画像生成,LLM 客户端)
- **Phase 4**:调度层(触发/锁)+ 呈现层(人物卡展示)
- ⏳ 后续可选:galgame 风格卡面图片渲染(协议文本为渲染源)
@@ -17,6 +17,8 @@ from nonebot.params import CommandArg
from nonebot.plugin import PluginMetadata
from nonebot.rule import to_me
from ...core import message_utils
require("nonebot_plugin_alconna")
from nonebot_plugin_alconna import UniMessage # noqa: E402
@@ -31,7 +33,6 @@ from .service import get_services, register_bot_adapter # noqa: E402
from .renderer import html_render # noqa: E402
from .templates import list_templates, template_exists # noqa: E402
# ---------- 指令定义(全部要求 @ 机器人) ----------
analysis_cmd = on_command(
@@ -192,7 +193,7 @@ async def _(bot: Bot, event: GroupMessageEvent):
group_id = str(event.group_id)
days = _parse_days(event)
await UniMessage.text("正在启动分析引擎,正在拉取最近消息...").send()
await message_utils.common_proc_reply(event.message_id)
try:
svc = get_services()
@@ -347,6 +348,7 @@ async def _(bot: Bot, event: GroupMessageEvent):
f"用户称号: {'开' if cm.get_user_title_analysis_enabled() else '关'}",
f"金句分析: {'开' if cm.get_golden_quote_analysis_enabled() else '关'}",
f"聊天质量: {'开' if cm.get_chat_quality_analysis_enabled() else '关'}",
f"本地记录: {'开' if cm.get_use_local_history() else '关'}",
"用法:设置分析 [参数] [值],如:设置分析 天数 3",
]
await UniMessage.text(chr(10).join(lines)).send()
@@ -362,8 +364,12 @@ _SETTING_MAP = {
"称号": ("set_user_title_analysis_enabled", str, "用户称号已{}"),
"金句": ("set_golden_quote_analysis_enabled", str, "金句分析已{}"),
"聊天质量": ("set_chat_quality_analysis_enabled", str, "聊天质量已{}"),
"本地记录": ("set_use_local_history", str, "本地记录已{}"),
}
# 走开/关布尔语义的键(值为 _BOOL_WORDS 里的词)
_BOOL_KEYS = {"话题", "称号", "金句", "聊天质量", "本地记录"}
_BOOL_WORDS = {"开": True, "on": True, "true": True, "关": False, "off": False, "false": False}
@@ -375,7 +381,9 @@ async def _(bot: Bot, event: GroupMessageEvent, args: tuple = CommandArg()):
tokens = _cmd_tokens(args)
if len(tokens) < 2:
await UniMessage.text(
"用法:设置分析 [参数] [值]" + chr(10) + "参数:天数/窗口/最大消息/最小消息/输出格式/话题/称号/金句/聊天质量"
"用法:设置分析 [参数] [值]"
+ chr(10)
+ "参数:天数/窗口/最大消息/最小消息/输出格式/话题/称号/金句/聊天质量/本地记录"
).send()
return
key = tokens[0]
@@ -396,7 +404,7 @@ async def _(bot: Bot, event: GroupMessageEvent, args: tuple = CommandArg()):
await UniMessage.text(f"{key} 已设为 {v}").send()
elif conv is str:
v = val.lower()
if key in ("话题", "称号", "金句", "聊天质量"):
if key in _BOOL_KEYS:
if v not in _BOOL_WORDS:
await UniMessage.text("布尔值请填:开/关 或 on/off").send()
return
@@ -3,12 +3,10 @@
from __future__ import annotations
import asyncio
import base64
import time
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any
from . import history_store
from .core.domain.value_objects.unified_group import UnifiedGroup, UnifiedMember
from .core.domain.value_objects.unified_message import (
MessageContent,
@@ -17,6 +15,29 @@ from .core.domain.value_objects.unified_message import (
)
from .core.utils.logger import logger
# 分页锚点字段链(后端差异实测):NapCat 的 message_seq 参数按 OB11
# message_id(shortId 短 ID 映射) 解析,传消息内的 message_seq(内核 msgSeq)
# 命中不了映射 → NapCat 抛"消息不存在",翻页断裂;go-cqhttp/LLOneBot 等
# 则识别 message_seq。优先 message_id,翻页无进度时依次回退。
ANCHOR_FIELDS = ("message_id", "message_seq", "seq", "real_id")
def _fmt_ts(ts: int) -> str:
"""时间戳转日志用的可读时间。"""
try:
return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M")
except (OSError, OverflowError, ValueError):
return str(ts)
def _pick_anchor(raw: dict[str, Any], fields: tuple[str, ...], start: int):
"""从消息中按字段优先级提取分页锚点值"""
for field in fields[start:]:
val = raw.get(field)
if val is not None:
return val
return None
class OneBotAdapter:
"""面向 NoneBot OneBot V11 的最小适配器。"""
@@ -31,6 +52,8 @@ class OneBotAdapter:
self.platform_id = str(self.config.get("platform_id") or "onebot")
self.bot_self_ids = [str(x) for x in self.config.get("bot_self_ids", [])]
self.filter_bot_messages = bool(self.config.get("filter_bot_messages", True))
# 优先读本地消息库(learning_chat 落库),取不到再回退接口分页
self.use_local_history = bool(self.config.get("use_local_history", True))
# —— 消息拉取 ——
async def fetch_messages(
@@ -52,8 +75,21 @@ class OneBotAdapter:
start_ts = int(
(datetime.now() - timedelta(days=days)).timestamp()
)
end_ts = int(datetime.now().timestamp())
# 本地消息库优先(完整且毫秒级);before_id 无法映射到时间戳,故跳过
if self.use_local_history and not before_id:
local = await self._fetch_from_local_store(
group_id, start_ts, end_ts, max_count
)
if local:
return local
current_anchor = before_id
anchor_idx = 0
no_progress_pages = 0
last_earliest: dict[str, Any] | None = None
while len(all_raw) < max_count:
fetch_count = min(chunk_size, max_count - len(all_raw))
params: dict[str, Any] = {
@@ -85,13 +121,34 @@ class OneBotAdapter:
break
messages = result.get("messages", [])
if not messages:
break
# 空页可能意味着当前锚点字段不被后端识别(如 NapCat 外的
# 实现收到 message_id),换下一字段重试一次,仍空则结束。
if current_anchor is None or last_earliest is None:
break
if no_progress_pages >= 1 or anchor_idx >= len(ANCHOR_FIELDS) - 1:
logger.warning(
"OneBot 分页拉取: 返回空页且锚点字段已耗尽,停止回溯"
)
break
no_progress_pages += 1
anchor_idx += 1
current_anchor = _pick_anchor(
last_earliest, ANCHOR_FIELDS, anchor_idx
)
if current_anchor is None:
break
logger.warning(
f"OneBot 分页拉取: 空页,锚点字段切换为 {ANCHOR_FIELDS[anchor_idx]}"
)
continue
first = messages[0]
last = messages[-1]
earliest = first if first.get("time", 0) <= last.get("time", 0) else last
last_earliest = earliest
chunk_earliest_ts = earliest.get("time", 0)
prev_len = len(all_raw)
for raw in messages:
msg_time = raw.get("time", 0)
msg_id = str(raw.get("message_id", ""))
@@ -100,19 +157,42 @@ class OneBotAdapter:
if start_ts <= msg_time <= int(datetime.now().timestamp()):
all_raw.append(raw)
seen_raw_ids.add(msg_id)
added = len(all_raw) - prev_len
seq_val = (
earliest.get("message_seq")
or earliest.get("real_id")
or earliest.get("seq")
)
mid_val = earliest.get("message_id")
new_anchor = seq_val if seq_val is not None else mid_val
if chunk_earliest_ts <= start_ts:
logger.info(
f"OneBot 分页拉取: 已到达起始时间,共 {len(all_raw)} 条"
)
break
if added == 0:
# 本页没有新增消息:锚点不生效(后端不支持该字段)或数据已取尽。
# 先切换锚点字段再试一次,仍无新增则结束。
no_progress_pages += 1
if no_progress_pages >= 2:
logger.warning(
"OneBot 分页拉取: 连续 2 页无新增,停止回溯"
)
break
if anchor_idx < len(ANCHOR_FIELDS) - 1:
anchor_idx += 1
logger.warning(
f"OneBot 分页拉取: 锚点字段切换为 {ANCHOR_FIELDS[anchor_idx]}"
)
else:
no_progress_pages = 0
new_anchor = _pick_anchor(earliest, ANCHOR_FIELDS, anchor_idx)
if new_anchor is None:
break
if current_anchor and str(new_anchor) == str(current_anchor):
logger.info("OneBot 分页拉取: 锚点未位移,历史已取尽")
break
current_anchor = new_anchor
logger.info(
f"OneBot 分页拉取进度: {len(all_raw)} 条,"
f"锚点({ANCHOR_FIELDS[anchor_idx]}): {new_anchor}"
)
await asyncio.sleep(0.05)
unified: list[UnifiedMessage] = []
@@ -131,6 +211,54 @@ class OneBotAdapter:
logger.warning(f"OneBot 分页获取消息失败: {e}")
return []
async def _fetch_from_local_store(
self, group_id: str, start_ts: int, end_ts: int, max_count: int
) -> list[UnifiedMessage]:
"""从本地消息库(learning_chat 落库)取群历史;不可用时返回空以回退分页。"""
try:
result = await asyncio.to_thread(
history_store.fetch_group_messages,
group_id,
start_ts,
end_ts,
max_count,
)
except Exception as e:
logger.warning(f"本地历史读取失败,回退接口分页: {e}")
return []
if not result:
logger.info(
f"本地历史无数据({result.error or '窗口内无消息'}),"
"回退 OneBot 分页拉取"
)
return []
logger.info(
f"本地历史记录拉取: group={group_id}, source={history_store.MESSAGE_TABLE}, "
f"count={len(result.messages)}, "
f"窗口=[{_fmt_ts(start_ts)}..{_fmt_ts(end_ts)}], "
f"名称覆盖={result.names_resolved}/{len(result.messages)}, "
f"去重={result.duplicates}"
)
if result.truncated:
window_total = (
result.window_total if result.window_total is not None else "?"
)
earliest = result.messages[0]["time"] if result.messages else end_ts
logger.warning(
f"本地历史截断: group={group_id}, max_messages={max_count}, "
f"窗口内共 {window_total} 条, 实际取最近 {len(result.messages)} 条, "
f"最早={_fmt_ts(earliest)}, 窗口起点={_fmt_ts(start_ts)}"
)
unified: list[UnifiedMessage] = []
for raw in result.messages:
converted = self._convert_message(raw, group_id)
if converted:
unified.append(converted)
return unified
def _convert_message(self, raw: dict, group_id: str) -> UnifiedMessage | None:
try:
sender = raw.get("sender", {})
@@ -98,6 +98,8 @@ def _default_config() -> dict:
cfg.setdefault("basic", {}).setdefault("max_messages", 1000)
cfg.setdefault("basic", {}).setdefault("min_messages_threshold", 50)
cfg.setdefault("basic", {}).setdefault("filter_bot_messages", True)
# 优先读本地消息库(learning_chat 落库),取不到再回退接口分页
cfg.setdefault("basic", {}).setdefault("use_local_history", True)
cfg.setdefault("analysis_features", {}).setdefault("chat_quality_analysis_enabled", False)
cfg.setdefault("incremental", {}).setdefault("incremental_enabled", False)
return cfg
@@ -770,6 +770,15 @@ class ConfigManager:
self._ensure_group("basic")["filter_bot_messages"] = enabled
self.config.save_config()
def get_use_local_history(self) -> bool:
"""获取是否优先从本地消息库读取群历史。"""
return self._get_group("basic").get("use_local_history", True)
def set_use_local_history(self, enabled: bool):
"""设置是否优先从本地消息库读取群历史(关闭则始终走接口分页)。"""
self._ensure_group("basic")["use_local_history"] = enabled
self.config.save_config()
def get_html_output_dir(self) -> str:
"""获取HTML输出目录"""
@@ -0,0 +1,281 @@
"""本地群历史记录源:只读读取 learning_chat 落库的群消息。
`nonebot_plugin_learning_chat` 会把机器人收到的每条群消息写入
`learning_chat_message`(与该群是否开启学习无关),比 OneBot
`get_group_msg_history` 分页更完整——部分后端只能取回一页就被截断。
昵称/群名片从同库的 `nonebot_plugin_uninfo_*` 表离线补齐,不额外调接口。
模块只依赖标准库(logger 做守卫导入),便于单测用 importlib 裸加载;
读连接一律 `mode=ro`(绝不建库),任何失败都返回带 error 的空结果。
"""
from __future__ import annotations
import json
import os
import sqlite3
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
try: # 插件包内导入:走插件 logger
from .core.utils.logger import logger
except Exception: # 单测裸加载(无包上下文)时降级为标准库 logger
import logging
logger = logging.getLogger(__name__)
MESSAGE_TABLE = "learning_chat_message"
# nonebot_plugin_uninfo 的 SceneType.GROUP
SCENE_TYPE_GROUP = 1
# 显式指定本地消息库位置(测试或非默认部署用)
DB_PATH_ENV = "HEXI_GROUP_DAILY_HISTORY_DB"
_MESSAGE_SQL = (
"SELECT id, user_id, message_id, raw_message, message, plain_text, time "
f"FROM {MESSAGE_TABLE} "
"WHERE group_id = ? AND time >= ? AND time <= ? "
"ORDER BY time DESC, id DESC LIMIT ?"
)
_COUNT_SQL = (
f"SELECT COUNT(*) FROM {MESSAGE_TABLE} "
"WHERE group_id = ? AND time >= ? AND time <= ?"
)
# 同库 uninfo 三表:拿本群成员的 QQ 昵称与群名片
_NAMES_SQL = (
"SELECT u.user_id AS user_id, "
"u.user_data AS user_data, "
"s.member_data AS member_data "
"FROM nonebot_plugin_uninfo_scenemodel AS sc "
"JOIN nonebot_plugin_uninfo_sessionmodel AS s "
"ON s.scene_persist_id = sc.id "
"JOIN nonebot_plugin_uninfo_usermodel AS u "
"ON u.id = s.user_persist_id "
"WHERE sc.scene_id = ? AND sc.scene_type = ?"
)
@dataclass
class LocalHistoryResult:
"""本地历史读取结果;空 messages 表示需要回退到接口分页。"""
messages: list[dict[str, Any]] = field(default_factory=list)
truncated: bool = False
window_total: int | None = None
names_resolved: int = 0
duplicates: int = 0
error: str | None = None
def __bool__(self) -> bool:
return bool(self.messages)
def _parse_sqlite_url(raw: Any) -> Path | None:
"""从 SQLAlchemy 数据库配置里解析出 sqlite 文件路径。"""
if raw is None:
return None
text = str(raw).strip()
if not text.startswith("sqlite") or "memory" in text:
return None
# sqlite+aiosqlite:///D:/path/db.sqlite3 或 sqlite:///./rel/db.sqlite3
_, _, tail = text.partition("///")
tail = tail.strip()
if not tail:
return None
try:
return Path(tail).expanduser()
except (OSError, ValueError):
return None
def _driver_db_path() -> Path | None:
"""读取 `SQLALCHEMY_DATABASE_URL`(环境变量优先,其次 .env 配置)。"""
for key in ("SQLALCHEMY_DATABASE_URL", "sqlalchemy_database_url"):
raw = os.environ.get(key)
if raw:
return _parse_sqlite_url(raw)
try:
from nonebot import get_driver
raw = getattr(get_driver().config, "sqlalchemy_database_url", None)
except Exception:
return None
return _parse_sqlite_url(raw)
def _localstore_db_path() -> Path | None:
"""nonebot_plugin_orm 默认库:localstore 数据目录下的 db.sqlite3。"""
try:
from nonebot_plugin_localstore import get_data_dir
return get_data_dir("nonebot_plugin_orm") / "db.sqlite3"
except Exception:
return None
def resolve_db_path() -> Path:
"""解析本地消息库路径:环境变量 → ORM 配置 → localstore → 仓库默认位置。"""
override = os.environ.get(DB_PATH_ENV)
if override:
return Path(override).expanduser()
for candidate in (_driver_db_path(), _localstore_db_path()):
if candidate is not None:
return candidate
# hexi/plugins/<plugin>/history_store.py -> hexi/
return (
Path(__file__).resolve().parents[2]
/ "data"
/ "nonebot_plugin_orm"
/ "db.sqlite3"
)
def _open_readonly(path: Path) -> sqlite3.Connection:
"""只读打开。路径可能含空格,必须用 as_uri 转义后的 file URI。"""
con = sqlite3.connect(f"{path.resolve().as_uri()}?mode=ro", uri=True, timeout=3.0)
con.row_factory = sqlite3.Row
return con
def _loads(raw: Any) -> dict[str, Any]:
if not raw:
return {}
if isinstance(raw, dict):
return raw
try:
data = json.loads(raw)
except (TypeError, ValueError):
return {}
return data if isinstance(data, dict) else {}
def _load_display_names(con: sqlite3.Connection, group_id: int) -> dict[str, tuple[str, str | None]]:
"""取本群成员的 (昵称, 群名片);uninfo 表缺失时返回空表。"""
names: dict[str, tuple[str, str | None]] = {}
for row in con.execute(_NAMES_SQL, (str(group_id), SCENE_TYPE_GROUP)):
user_data = _loads(row["user_data"])
member_data = _loads(row["member_data"])
nickname = str(user_data.get("name") or "")
card = str(member_data.get("nick") or "") or None
if nickname or card:
names[str(row["user_id"])] = (nickname, card)
return names
def _parse_cq(cq: str) -> list[dict[str, Any]]:
"""CQ 串转 OneBot 消息段(复用适配器自带解析,含反转义)。"""
try:
from nonebot.adapters.onebot.v11 import Message
return [{"type": seg.type, "data": dict(seg.data)} for seg in Message(cq)]
except Exception as exc:
logger.debug(f"本地历史 CQ 解析失败,降级为纯文本: {exc}")
return [{"type": "text", "data": {"text": cq}}]
def _build_raw_message(
row: sqlite3.Row, names: dict[str, tuple[str, str | None]]
) -> dict[str, Any] | None:
"""组装成 adapter._convert_message 能直接消费的 OneBot 原始结构。"""
message_id = row["message_id"]
if message_id is None:
return None
user_id = str(row["user_id"])
nickname, card = names.get(user_id, ("", None))
cq = row["raw_message"] or row["message"] or row["plain_text"] or ""
return {
"message_id": message_id,
"time": int(row["time"] or 0),
"sender": {"user_id": user_id, "nickname": nickname, "card": card or ""},
"message": _parse_cq(cq),
}
def _fetch_group_messages(
group_id: str | int,
start_ts: int,
end_ts: int,
limit: int,
db_path: Path | str | None,
) -> LocalHistoryResult:
try:
gid = int(group_id)
except (TypeError, ValueError):
return LocalHistoryResult(error=f"群号非法: {group_id!r}")
try:
row_limit = max(1, int(limit))
except (TypeError, ValueError):
row_limit = 1000
path = Path(db_path) if db_path is not None else resolve_db_path()
if not path.exists():
return LocalHistoryResult(error=f"本地消息库不存在: {path}")
start, end = int(start_ts), int(end_ts)
con = _open_readonly(path)
try:
# 多取一条用于判定截断(多出来的那条正是窗口内最旧的消息)
rows = con.execute(_MESSAGE_SQL, (gid, start, end, row_limit + 1)).fetchall()
truncated = len(rows) > row_limit
if truncated:
rows = rows[:row_limit]
window_total = None
if truncated:
try:
window_total = int(con.execute(_COUNT_SQL, (gid, start, end)).fetchone()[0])
except (sqlite3.Error, TypeError, ValueError):
window_total = None
try:
names = _load_display_names(con, gid)
except sqlite3.Error as exc:
logger.debug(f"本地历史昵称补齐失败(忽略): {exc}")
names = {}
finally:
con.close()
messages: list[dict[str, Any]] = []
seen: set[str] = set()
duplicates = 0
names_resolved = 0
for row in rows:
raw = _build_raw_message(row, names)
if raw is None:
continue
message_id = str(raw["message_id"])
if not message_id:
continue
if message_id in seen:
duplicates += 1
continue
seen.add(message_id)
sender = raw["sender"]
if sender["nickname"] or sender["card"]:
names_resolved += 1
messages.append(raw)
# 查询按 (time, id) 倒序取最近 N 条,翻回时间升序
messages.reverse()
return LocalHistoryResult(
messages=messages,
truncated=truncated,
window_total=window_total,
names_resolved=names_resolved,
duplicates=duplicates,
)
def fetch_group_messages(
group_id: str | int,
start_ts: int,
end_ts: int,
limit: int = 1000,
db_path: Path | str | None = None,
) -> LocalHistoryResult:
"""读取 [start_ts, end_ts] 闭区间内的群消息(时间升序,最多 limit 条)。
同步函数,调用方用 `asyncio.to_thread` 包;失败只返回带 error 的空结果。
"""
try:
return _fetch_group_messages(group_id, start_ts, end_ts, limit, db_path)
except Exception as exc: # 兜底:本地库问题不该影响分析主链路
logger.warning(f"本地历史读取异常: {exc}", exc_info=True)
return LocalHistoryResult(error=str(exc))
@@ -119,6 +119,7 @@ def register_bot_adapter(bot: Any, platform_id: str | None = None) -> OneBotAdap
"platform_id": platform_id or "onebot",
"bot_self_ids": bot_self_ids,
"filter_bot_messages": config_manager.get_filter_bot_messages(),
"use_local_history": config_manager.get_use_local_history(),
},
)
bot_manager.register_adapter(adapter, platform_id or "onebot")
@@ -14,6 +14,7 @@ from nonebot.internal.params import ArgPlainText
from nonebot.matcher import Matcher
from PIL import Image
from hexi.core import message_utils
from ..services.equipment import get_equipment_by_combination, get_random_equipment
from ..services.hd2_api import get_briefing_data
from ..utils import gen_ms_img, pic2b64
@@ -24,7 +25,7 @@ _IMG_DIR = _BASE_DIR / "res" / "img"
async def _send_war_card(matcher: Matcher, ev: MessageEvent, top_per_race: Optional[int]) -> None:
await matcher.send("正在获取前线战况,请民主的等待!")
await message_utils.common_proc_reply(ev.message_id)
try:
data = await get_briefing_data()
png = await render_war_briefing(data, top_per_race)
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

@@ -8,6 +8,7 @@ from nonebot.log import logger
from nonebot.plugin.on import on_keyword
from nonebot_plugin_alconna import UniMessage
from hexi.core import message_utils
from hexi.core.message_utils import get_reply_message
from ..services.gif import reverse_gif_bytes
@@ -21,10 +22,11 @@ re_gif = on_keyword({"倒放"})
async def rev_gif(event: MessageEvent, bot: Bot):
# 情况1,用户对需要倒放的gif进行回复
messages = get_reply_message(event)
await match_revgif(messages)
message_id = event.message_id
await match_revgif(messages,message_id)
async def match_revgif(messages):
async def match_revgif(messages,message_id):
img_urls = []
for message in messages:
logger.info(f"遍历消息: {message}")
@@ -38,7 +40,7 @@ async def match_revgif(messages):
return
for url in img_urls:
logger.info(f"获取到的图片链接:{url}")
await UniMessage.text("ℹ正在翻转图片序列,请稍候").send()
await message_utils.common_proc_reply(message_id)
data, err = await reverse_gif_bytes(img_urls)
if err:
await UniMessage.text(err).send()
@@ -203,7 +203,7 @@ def format_api_key_stats(stats: Dict) -> str:
stats 结构: {key: {"call_count": int, "last_called": str | None}}
"""
lines = ["Steam API key 使用统计:"]
lines = ["\nSteam API key 使用统计:"]
for key, info in stats.items():
call_count = info.get("call_count", 0)
last_called = info.get("last_called")