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:
@@ -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", {})
|
||||
|
||||
Reference in New Issue
Block a user