Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,20 @@

## [Unreleased]

### 新增

- **跨群记忆 LLM 摘要**:新增配置 `cross_group_summary_enable`(默认 `false`)、`cross_group_summary_threshold`(默认 `20`,slider 5~100)、`cross_group_summary_provider_id`(专用模型,留空复用当前会话模型)。注入 LLM 的记录条数超过阈值时,先调 LLM 把原始记录压缩成一段话题摘要(「最近大家在聊 XX、YY」)再注入正文,而非堆砌几十条原始消息——省 token,也让回复更像「记得住重点」;摘要失败(无 provider/超时/返回空)自动降级为原始记录注入,同一份记忆 5 分钟内复用摘要缓存,避免频繁调用
- **按群聊开关 scope 分级**:`/开关` 命令新增可选功能域参数,支持只关某一类功能(如 `/开关 off media 2h` 只关媒体解析、`/开关 off memory` 只关跨群记忆),不再一刀切关闭全部命令;`GroupSwitchStore` 存储 key 从纯 `umo` 升级为 `umo|scope`(全局禁用仍为纯 `umo`,旧格式加载后自动视为全局禁用,无需迁移);守卫检查点从单一全局守卫分散到各功能入口(媒体解析/图片/点歌/工具/郊狼/记忆),`/开关列表` 同步展示功能域与恢复时间
- **媒体解析失败原因分级**:新增 `MediaParserError.kind` 分类(链接过期 / 内容已删除或私密 / 平台反爬拦截 / 网络超时 / 网络异常 / 接口异常 / 格式不识别),解析失败时按原因给出精确中文提示并附截断后的原始信息,不再甩一坨原始异常文本
- **媒体解析结果缓存**:新增内存 LRU 缓存(url → 解析结果,TTL 默认 600 秒),命中直接返回,减少对小红书/B站/抖音/微博的请求频率、降低触发反爬/封 IP 概率;仅缓存成功结果,失败实时重试。配置 `media_parse_cache_enable`(默认 `true`)、`media_parse_cache_ttl`(默认 `600`,slider 60~3600)
- **媒体解析批量链接**:`/解析` 支持一条消息粘贴多个链接,去重保序后依次解析并逐条回复(单次上限 5 条,防刷屏与平台限流),`_parse_media_url` 拆分为 `_parse_media_urls`
- **媒体解析联动跨群记忆**:解析成功后把「谁解析了什么链接」写入跨群记忆(`tag=media`),后续对话机器人可自然回溯「你刚才发的那个视频」,正式接线 v2.1.0 预留的话题标签 API;记忆为增强项,任何失败静默,不影响解析主流程
- **群开关静默期仅提醒一次**:关闭期间第一次收到本插件指令时回复一句简短提示(「本群已关闭机器人,管理员可发 /开关 on 恢复」),同一群同一功能 1 小时内仅提醒一次,避免新用户 @ 机器人没反应摸不着头脑。配置 `group_switch_hint_enable`(默认 `true`)

### 变更

- **命令总览更新**:`/cc`(别名 `/帮助` `/help` `/菜单`)总入口的命令总览数据与媒体解析帮助文本同步纳入本次新增能力(scope 分级开关、批量解析、失败原因分级、结果缓存),媒体解析帮助文本补充批量与失败分级用法

## [2.1.0] - 2026-08-21

### 新增
Expand Down
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ pip install websockets>=10.0 # 仅 DG-LAB 功能需要

| 指令 | 说明 |
| --- | --- |
| `/解析 <链接>` | 自动识别平台并解析 |
| `/解析 <链接>` | 自动识别平台并解析;**一条消息含多个链接时依次解析(最多 5 条)** |
| `/xhs <链接>`(`/小红书`) | 小红书解析 |
| `/bilibili <链接>`(`/B站` `/b站`) | B站视频解析 |
| `/douyin <链接>`(`/抖音`) | 抖音视频解析 |
Expand All @@ -304,6 +304,7 @@ pip install websockets>=10.0 # 仅 DG-LAB 功能需要

```text
/解析 https://www.xiaohongshu.com/explore/abc123
/解析 https://b23.tv/xxxx https://v.douyin.com/yyyy # 批量:一条消息多个链接,依次解析
/xhs https://xhslink.com/xxxx
/bilibili https://www.bilibili.com/video/BV1xx411c7mD
/douyin https://v.douyin.com/xxxx
Expand All @@ -319,6 +320,17 @@ pip install websockets>=10.0 # 仅 DG-LAB 功能需要

> ⚠️ 请确保链接可公开访问;部分平台可能因反爬策略导致解析失败。下载链接仅供个人学习使用,请遵守平台规范。

#### 解析增强

- **失败原因分级提示**:解析失败不再甩原始异常文本,而是按原因分类提示——链接过期(短链失效)、内容已删除/私密、平台反爬拦截、网络超时、链接格式不识别等,便于判断是链接问题还是网络问题。
- **结果缓存**:相同链接在有效期内(默认 10 分钟,`media_parse_cache_ttl` 可调)直接返回上次解析结果,减少对目标平台的请求频率,降低触发反爬/封 IP 的概率;仅缓存成功结果,失败会实时重试。
- **联动跨群记忆**:跨群聊记忆开启时,解析成功会以 `media` 标签记录「谁解析了什么内容」,后续对话中机器人可以自然回溯「你刚才发的那个视频」。

| 配置项 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `media_parse_cache_enable` | bool | true | 是否启用解析结果缓存 |
| `media_parse_cache_ttl` | int | 600 | 缓存有效期(秒)。下载/播放链接本身有时效性,不建议设置过长 |

---

### 漫画内容(原第 3 节,已移除)
Expand Down
35 changes: 34 additions & 1 deletion _conf_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,36 @@
"type": "int",
"default": 0
},
"cross_group_summary_enable": {
"description": "【跨群聊记忆 LLM 摘要】开启后,注入条数超过下方阈值时,先调用 LLM 把原始记录压缩成一段话题摘要(「最近大家在聊 XX、YY」)再注入正文,而不是把几十条原始消息全部塞进 prompt——省 token,也让回复更像「记得住重点」。摘要失败(无模型/超时)自动降级为原始记录注入。注意:开启后每次记忆变化都会产生一次额外的 LLM 调用(同一份记忆 5 分钟内复用摘要)",
"type": "bool",
"default": false
},
"cross_group_summary_threshold": {
"description": "跨群聊记忆 LLM 摘要:触发压缩的记录条数阈值(注入条数超过该值才调 LLM 摘要,低于则直接注入原始记录)",
"type": "int",
"default": 20,
"slider": {"min": 5, "max": 100, "step": 5}
},
"cross_group_summary_provider_id": {
"description": "跨群聊记忆 LLM 摘要专用模型。留空则复用当前会话使用的模型;建议选择廉价快速的非推理模型以省时省钱",
"type": "string",
"default": "",
"_special": "select_provider"
},
"media_parse_cache_enable": {
"description": "【媒体解析结果缓存】开启后,相同链接在缓存有效期内直接返回上次解析结果,减少对小红书/B站/抖音/微博的请求频率,降低触发反爬/封 IP 的概率。仅缓存成功结果,失败会实时重试",
"type": "bool",
"default": true
},
"media_parse_cache_ttl": {
"description": "媒体解析结果缓存有效期(秒)。下载/播放链接本身有时效性,不建议设置过长;默认 600(10 分钟)",
"type": "int",
"default": 600,
"slider": {"min": 60, "max": 3600, "step": 60}
},
"group_switch_enable": {
"description": "启用「按群聊独立开关插件」功能。开启后,可用 /开关 命令在单个群聊中关闭/开启本插件全部命令",
"description": "启用「按群聊独立开关插件」功能。开启后,可用 /开关 命令在单个群聊中关闭/开启本插件,支持只关某一类功能(如 /开关 off media 2h 只关媒体解析)",
"type": "bool",
"default": true
},
Expand All @@ -120,6 +148,11 @@
"type": "bool",
"default": true
},
"group_switch_hint_enable": {
"description": "群聊功能被关闭期间,第一次收到本插件指令时回复一句简短提示(如「本群已关闭机器人,管理员可发 /开关 on 恢复」),同一群同一功能 1 小时内仅提醒一次,避免新用户 @机器人没反应摸不着头脑",
"type": "bool",
"default": true
},
"reply_seg_enable": {
"description": "【分段回复总开关】开启后,机器人回复会按规则拆成多条消息分次发送(模拟逐条回复)。注意:AstrBot 框架自带全局分段回复(platform_settings.segmented_reply),本功能与之独立,二者请勿同时开启以免重复分段",
"type": "bool",
Expand Down
142 changes: 104 additions & 38 deletions group_switch_store.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""按会话(群聊)的插件开关状态存储。
"""按会话(群聊)的插件开关状态存储(支持按功能域 scope 分级)

存储后端为 JSON 文件(data/currentcortex_group_switch.json),用 threading.Lock
保护读写,与插件内 CrossGroupMemoryStore / DeviceStore 等持久化风格一致,完全自
Expand All @@ -8,8 +8,16 @@
(set_disabled)才会被守卫处理器拦截。这样既能精准关闭个别群,又不会影响老群或
新群。

每个被禁用的会话额外记录一个可选的到期时间戳(until):
- until 为 None:永久禁用,需手动 /开关 on 重新启用。
分级开关:存储 key 由两部分组成——
- 全局禁用:key 就是原始 umo(关闭本群全部插件命令,等价旧版本行为)
- 域级禁用:key 为 ``umo|scope``(如 ``aiocqhttp:GroupMessage:123|media``),
只关闭该群某一类功能(媒体解析/点歌/图片等),其余功能不受影响。
umo 内部不含 ``|`` 字符(各平台 umo 均以 ``:`` 分段),scope 名为受控白名单
(media/image/music/utility/dglab/memory),因此 ``|`` 可安全作为分隔符;
旧版本落盘的纯 umo key 加载后自动视为全局禁用,无需迁移。

每个被禁用的条目额外记录一个可选的到期时间戳(until):
- until 为 None:永久禁用,需手动 /开关 on [scope] 重新启用。
- until 为具体时间戳:到期后自动视为启用(懒惰过期,在 is_enabled /
list_disabled 调用时惰性清理,不需要额外的定时任务或后台线程)。
"""
Expand All @@ -23,9 +31,12 @@

from astrbot.api import logger

# 全局禁用 key 与域级禁用 key 的分隔符(umo 与 scope 名均不含该字符)
SCOPE_SEPARATOR = "|"


class GroupSwitchStore:
"""按 unified_msg_origin 记录「是否被禁用」的持久化存储。
"""按 unified_msg_origin 记录「是否被禁用」的持久化存储(支持 scope 分级)

Args:
data_dir: 数据目录(通常为 "data")。
Expand All @@ -37,14 +48,30 @@ def __init__(self, data_dir: str = "data") -> None:
data_dir, "currentcortex_group_switch.json"
)
self._lock = threading.Lock()
# 被禁用的会话:umo -> until(unix 时间戳;None 表示永久禁用)
# 被禁用的条目:key -> until(unix 时间戳;None 表示永久禁用)
# key 为原始 umo(全局禁用)或 "umo|scope"(仅禁用该功能域)
self._disabled: dict[str, Optional[float]] = {}
self._ensure_data_dir()
self._load()

def _ensure_data_dir(self) -> None:
os.makedirs(self._data_dir, exist_ok=True)

@staticmethod
def _scoped_key(umo: str, scope: Optional[str]) -> str:
"""把 (umo, scope) 组合成存储 key;scope 为 None 时即原始 umo(全局)。"""
if scope:
return f"{umo}{SCOPE_SEPARATOR}{scope}"
return umo

@staticmethod
def _split_key(key: str) -> tuple:
"""把存储 key 拆回 (umo, scope);无分隔符的旧条目 scope 为 None。"""
if SCOPE_SEPARATOR in key:
umo, _, scope = key.partition(SCOPE_SEPARATOR)
return umo, (scope or None)
return key, None

def _load(self) -> None:
"""从 JSON 文件加载被禁用的会话集合(兼容旧版 list 格式)。"""
if not os.path.exists(self._file_path):
Expand All @@ -55,21 +82,22 @@ def _load(self) -> None:
if isinstance(data, dict):
disabled = data.get("disabled", [])
if isinstance(disabled, list):
# 旧版本格式:["umo1", "umo2", ...],均视为永久禁用
# 旧版本格式:["umo1", "umo2", ...],均视为全局永久禁用
for x in disabled:
if isinstance(x, str):
self._disabled[x] = None
elif isinstance(disabled, dict):
# 新版本格式:{"umo1": until|null, ...}
for umo, until in disabled.items():
if not isinstance(umo, str):
# 新版本格式:{"umo1|scope": until|null, ...}
# (无 "|" 的 key 即旧版全局禁用条目,自然兼容)
for key, until in disabled.items():
if not isinstance(key, str):
continue
if until is None or isinstance(until, (int, float)):
self._disabled[umo] = (
self._disabled[key] = (
float(until) if until is not None else None
)
logger.info(
f"[GroupSwitch] 已加载 {len(self._disabled)} 个被禁用的会话"
f"[GroupSwitch] 已加载 {len(self._disabled)} 个被禁用的条目"
)
except (json.JSONDecodeError, OSError) as e:
logger.warning(f"[GroupSwitch] 加载开关状态失败: {e}")
Expand All @@ -93,75 +121,113 @@ def _purge_expired_locked(self) -> bool:
"""
now = time.time()
expired = [
umo
for umo, until in self._disabled.items()
key
for key, until in self._disabled.items()
if until is not None and until <= now
]
for umo in expired:
del self._disabled[umo]
for key in expired:
del self._disabled[key]
return bool(expired)

def is_enabled(self, umo: str) -> bool:
"""该会话是否启用(未被显式禁用,或禁用已到期,即视为启用)。"""
def is_enabled(self, umo: str, scope: Optional[str] = None) -> bool:
"""该会话(或其某个功能域)是否启用。

Args:
umo: 会话标识。
scope: 功能域名(如 "media");None 表示查询全局开关。

判定规则:
- 全局被禁用 → 任何 scope 都视为禁用(全局优先)。
- scope=None:仅看全局条目(保持旧版本行为)。
- scope 非空:全局启用且该 scope 未被单独禁用才为启用。
"""
with self._lock:
if self._purge_expired_locked():
self._save()
return umo not in self._disabled

def set_disabled(self, umo: str, duration_seconds: Optional[float] = None) -> None:
"""显式禁用某会话。
if umo in self._disabled:
return False
if scope and self._scoped_key(umo, scope) in self._disabled:
return False
return True

def set_disabled(
self,
umo: str,
scope: Optional[str] = None,
duration_seconds: Optional[float] = None,
) -> None:
"""显式禁用某会话(全局)或其某个功能域。

Args:
umo: 会话标识。
scope: 功能域名;None 表示全局禁用(关闭全部命令)。
duration_seconds: 禁用时长(秒);None 表示永久禁用,需手动重新启用。
"""
with self._lock:
until = time.time() + duration_seconds if duration_seconds else None
self._disabled[umo] = until
self._disabled[self._scoped_key(umo, scope)] = until
self._save()

def set_enabled(self, umo: str) -> bool:
"""重新启用某会话。
def set_enabled(self, umo: str, scope: Optional[str] = None) -> bool:
"""重新启用某会话(全局)或其某个功能域。

Args:
umo: 会话标识。
scope: 功能域名;None 表示解除全局禁用(不影响各 scope 条目)。

Returns:
是否实际发生了状态变更(即之前确实是禁用状态)。
"""
with self._lock:
self._purge_expired_locked()
if umo in self._disabled:
del self._disabled[umo]
key = self._scoped_key(umo, scope)
if key in self._disabled:
del self._disabled[key]
self._save()
return True
return False

def get_until(self, umo: str) -> Optional[float]:
"""返回某会话的禁用到期时间戳;未禁用或永久禁用则分别返回 None。
def get_until(
self, umo: str, scope: Optional[str] = None
) -> Optional[float]:
"""返回指定条目的禁用到期时间戳;未禁用或永久禁用则分别返回 None。

调用前建议先用 is_enabled 判断,避免把「已过期」误当「永久禁用」。
"""
with self._lock:
self._purge_expired_locked()
return self._disabled.get(umo)
return self._disabled.get(self._scoped_key(umo, scope))

def list_disabled(self) -> List[str]:
"""返回所有(未过期)被禁用的会话列表(排序后)。"""
"""返回所有(未过期)被禁用的存储 key 列表(排序后)。

全局条目即原始 umo;域级条目为 ``umo|scope`` 复合 key。
"""
with self._lock:
if self._purge_expired_locked():
self._save()
return sorted(self._disabled.keys())

def list_disabled_detail(self) -> List[dict]:
"""返回所有(未过期)被禁用会话的详情列表,用于可视化展示。
"""返回所有(未过期)被禁用条目的详情列表,用于可视化展示。

Returns:
umo 排序的列表,每项为
``{"umo": str, "until": float|None, "permanent": bool}``。
permanent=True 表示永久禁用;否则 until 为到期 unix 时间戳
key 排序的列表,每项为
``{"umo": str, "scope": str|None, "until": float|None,
"permanent": bool}``。scope=None 表示全局禁用
"""
with self._lock:
if self._purge_expired_locked():
self._save()
return [
{"umo": umo, "until": until, "permanent": until is None}
for umo, until in sorted(self._disabled.items())
]
details = []
for key, until in sorted(self._disabled.items()):
umo, scope = self._split_key(key)
details.append(
{
"umo": umo,
"scope": scope,
"until": until,
"permanent": until is None,
}
)
return details
Loading
Loading