nonebot-plugin-group-chat-memory 0.1.0__tar.gz

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 TonyLiangP2010405
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,173 @@
1
+ Metadata-Version: 2.4
2
+ Name: nonebot-plugin-group-chat-memory
3
+ Version: 0.1.0
4
+ Summary: 为 NoneBot2 群聊提供共享长期记忆、点名对话和按群概率回复
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Keywords: nonebot,nonebot2,onebot,group,memory,embedding
8
+ Author: TonyLiangP2010405
9
+ Requires-Python: >=3.10,<4.0
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Requires-Dist: aiosqlite (>=0.19)
17
+ Requires-Dist: httpx (>=0.27,<1.0)
18
+ Requires-Dist: nonebot-adapter-onebot (>=2.4.0,<3.0.0)
19
+ Requires-Dist: nonebot-plugin-localstore (>=0.7.0,<1.0.0)
20
+ Requires-Dist: nonebot2 (>=2.2.0,<3.0.0)
21
+ Requires-Dist: pydantic (>=2.0,<3.0)
22
+ Requires-Dist: sqlite-vec (>=0.1.6,<0.2.0)
23
+ Project-URL: Homepage, https://github.com/TonyLiangP2010405/nonebot-plugin-group-chat-memory
24
+ Project-URL: Repository, https://github.com/TonyLiangP2010405/nonebot-plugin-group-chat-memory
25
+ Description-Content-Type: text/markdown
26
+
27
+ # nonebot-plugin-group-chat-memory
28
+
29
+ 为 NoneBot2 OneBot v11 群聊提供按群隔离的长期向量记忆、@/回复对话和按群概率随机回复。插件使用 SQLite + sqlite-vec 保存原文、分块、向量和群配置,聊天与 Embedding 均请求可配置的 OpenAI 兼容 HTTP API。
30
+
31
+ ## 环境与安装
32
+
33
+ - Python 3.10+
34
+ - NoneBot2 2.2+
35
+ - OneBot v11 适配器与对应的 OneBot 协议端
36
+
37
+ 选择一种安装方式:
38
+
39
+ ```bash
40
+ # nb-cli
41
+ nb plugin install nonebot-plugin-group-chat-memory
42
+ nb adapter install nonebot-adapter-onebot
43
+
44
+ # pip
45
+ pip install nonebot-plugin-group-chat-memory nonebot-adapter-onebot
46
+
47
+ # Poetry
48
+ poetry add nonebot-plugin-group-chat-memory nonebot-adapter-onebot
49
+ ```
50
+
51
+ nb-cli 通常会写入插件配置。使用 pip 或 Poetry 手动安装后,确认项目的 `pyproject.toml` 已加载插件:
52
+
53
+ ```toml
54
+ [tool.nonebot]
55
+ plugins = ["nonebot_plugin_group_chat_memory"]
56
+ ```
57
+
58
+ 并在 NoneBot 启动代码中注册 OneBot v11 Adapter。仓库中的 `.env.example` 使用 `DRIVER=~fastapi`;该示例驱动不是本插件的运行时依赖,需要在你的 Bot 项目中另行安装:
59
+
60
+ ```bash
61
+ pip install "nonebot2[fastapi]"
62
+ # 或
63
+ poetry add "nonebot2[fastapi]"
64
+ ```
65
+
66
+ ## 快速配置
67
+
68
+ 复制 `.env.example` 为 `.env` 并设置 SUPERUSER:
69
+
70
+ ```dotenv
71
+ SUPERUSERS=["10001"]
72
+ COMMAND_START=["/"]
73
+ ```
74
+
75
+ 启动 Bot 后,由 SUPERUSER 在**私聊**中依次设置四项运行时配置:
76
+
77
+ ```text
78
+ /设置聊天地址 https://your-chat-provider.example/v1
79
+ /设置聊天密钥 sk-your-chat-key
80
+ /设置聊天模型 your-provider-model-id
81
+ /设置向量密钥 sk-your-embedding-key
82
+ ```
83
+
84
+ 聊天地址填到 API 根路径(通常包含 `/v1`),插件分别请求其下的 `/chat/completions` 和 `/embeddings`。默认聊天模型标识是 `deepseek-v4.1-flash`,但**模型标识必须以实际服务商支持的值为准**。这四项保存到 SQLite,不再读取同名环境变量,更新后立即生效且重启后保留。使用 `/模型配置` 可查询脱敏状态。
85
+
86
+ ## 其余环境配置字段
87
+
88
+ 四个模型运行时字段不再通过 env 设置。其余以下字段可写在 NoneBot `.env` 中;集合类值使用 JSON 格式。
89
+
90
+ | 环境变量 | 默认值 | 说明 |
91
+ |---|---|---|
92
+ | `GROUP_CHAT_MEMORY_CHAT_TIMEOUT` | `60` | 单次聊天 HTTP 请求超时秒数,必须大于 0。 |
93
+ | `GROUP_CHAT_MEMORY_EMBEDDING_BASE_URL` | `https://api.siliconflow.cn/v1` | OpenAI 兼容 Embedding API 根地址。 |
94
+ | `GROUP_CHAT_MEMORY_EMBEDDING_MODEL` | `BAAI/bge-m3` | Embedding 模型标识。 |
95
+ | `GROUP_CHAT_MEMORY_EMBEDDING_DIMENSIONS` | `1024` | 向量维度,必须大于 0,必须与模型及既有数据库一致。 |
96
+ | `GROUP_CHAT_MEMORY_EMBEDDING_TIMEOUT` | `10` | 后台分块 Embedding HTTP 请求超时秒数,必须大于 0。 |
97
+ | `GROUP_CHAT_MEMORY_QUERY_TIMEOUT` | `3` | 回复前“查询向量 + 本地检索”的整体短超时秒数;超时后只用最近消息聊天。 |
98
+ | `GROUP_CHAT_MEMORY_DB_PATH_OVERRIDE` | 空 | 数据库路径覆盖,支持 `~`;留空时使用 nonebot-plugin-localstore 的本插件数据目录下 `group_chat_memory.db`。 |
99
+ | `GROUP_CHAT_MEMORY_RECENT_MESSAGES` | `20` | 每次回复最多读取的当前群最近消息数,至少为 1。 |
100
+ | `GROUP_CHAT_MEMORY_CHUNK_SIZE` | `10` | 每个长期记忆片段包含的消息数,至少为 1。 |
101
+ | `GROUP_CHAT_MEMORY_LONG_TERM_MEMORIES` | `5` | 每次回复最多检索的长期片段数;设为 0 可停用长期检索。 |
102
+ | `GROUP_CHAT_MEMORY_CONTEXT_MAX_CHARS` | `6000` | 发送给聊天模型的上下文总字符预算,至少为 1;优先保留当前消息和最近消息。 |
103
+ | `GROUP_CHAT_MEMORY_DEFAULT_PROBABILITY` | `0` | 新群随机回复概率,范围 0~100;已有群使用数据库中的概率。 |
104
+ | `GROUP_CHAT_MEMORY_IGNORED_QQ` | `[]` | 完全忽略的 QQ 号 JSON 数组,例如 `[12345, 67890]`;这些账号的消息不记忆也不触发回复。 |
105
+ | `GROUP_CHAT_MEMORY_SYSTEM_PROMPT` | `你是一个群聊助手。回答自然、简洁,像群友一样交流。只使用提供的聊天记录和长期记忆,不要假装记得没有出现过的内容。` | 自定义系统提示词;设为空时仅使用内置的发言者区分和当前消息约束。 |
106
+
107
+ 更改 `GROUP_CHAT_MEMORY_EMBEDDING_DIMENSIONS` 不会迁移既有向量。如果与数据库首次创建时记录的维度不同,插件会拒绝打开该数据库;请恢复原配置,或备份后另行重建数据库。
108
+
109
+ ## 使用与权限
110
+
111
+ 在群里 @Bot 或回复 Bot 的消息即可对话;这两种定向消息必定进入回复队列,不受随机概率影响。普通纯文本按当前群概率触发,默认 0% 表示关闭随机回复,100% 表示每条符合条件的普通文本都回复。
112
+
113
+ | 命令 | 权限 | 行为 |
114
+ |---|---|---|
115
+ | `/设置聊天地址 <URL>` | 仅私聊 SUPERUSER | 设置全局 OpenAI 兼容聊天 API 根地址。 |
116
+ | `/设置聊天密钥 <密钥>` | 仅私聊 SUPERUSER | 设置全局聊天 API 密钥。 |
117
+ | `/设置聊天模型 <模型标识>` | 仅私聊 SUPERUSER | 设置全局聊天模型。 |
118
+ | `/设置向量密钥 <密钥>` | 仅私聊 SUPERUSER | 设置全局 Embedding API 密钥。 |
119
+ | `/模型配置` | SUPERUSER,私聊或群聊 | 查询全局配置状态,密钥只显示掩码。 |
120
+ | `/回复概率` | 仅 SUPERUSER | 查询当前群概率,显示一位小数。 |
121
+ | `/回复概率 <0~100>` | 仅 SUPERUSER | 设置当前群概率,最多一位小数,例如 `/回复概率 5.5`。 |
122
+ | `/清空群记忆` | 仅 SUPERUSER | 删除当前群原始消息、分块和向量;保留当前群概率,不影响其他群。 |
123
+
124
+ 非 SUPERUSER 会收到简短权限提示。命令不进入记忆,也不触发随机回复。SUPERUSER 通过 NoneBot 的 `SUPERUSERS` 配置,而不是群管理员身份确定。
125
+
126
+ 同一群的模型请求串行执行:定向请求逐条排队;等待中的随机请求只保留最新一条,避免集中补发。不同群可以并行处理。
127
+
128
+ ## 记忆范围与隐私
129
+
130
+ **这是全群文本记忆,不只是与 Bot 的对话。** 符合条件的群成员纯文本、定向文本以及 Bot 成功发送的模型回复都会保存;其他机器人账号也会保存,除非列入 `GROUP_CHAT_MEMORY_IGNORED_QQ`。命令、私聊、临时会话、匿名消息、Bot 自身消息、纯非文本及含不支持消息段的内容不会进入记忆。
131
+
132
+ 每积累 10 条消息(可由 `GROUP_CHAT_MEMORY_CHUNK_SIZE` 修改)形成的完整群聊片段会发送给配置的**第三方 Embedding 服务商**;每次回复前的当前查询文本也会发送给 Embedding 服务商。回复时,当前群最近消息、检索到的长期片段和当前输入会发送给聊天模型服务商。不同群的记忆和检索在数据库层面隔离,但同一群的成员内容可能进入该群后续模型上下文。
133
+
134
+ 原文、向量与命令设置的 API 密钥保存在本地 SQLite 文件中,数据库本身不加密。请保护好数据库与备份,并限制文件访问权限。虽然设置密钥只允许私聊 SUPERUSER,但命令内容仍可能保留在你和 OneBot 协议端的聊天记录或日志中;不要把密钥发到群里,泄露后请立即更换。请根据群成员授权、服务商数据政策和适用法律决定是否启用。
135
+
136
+ ## 缺失凭据与故障降级
137
+
138
+ - 零配置导入插件和 `nonebot.load_plugin("nonebot_plugin_group_chat_memory")` 均可成功,不要求先填写 API 密钥。
139
+ - 聊天地址、密钥或模型为空时,收到定向/随机触发后只发送通用错误提示;传入消息仍会保存,不会把密钥或内部响应发到群里。
140
+ - Embedding 密钥缺失或后台向量化失败时,原始消息仍保留,未完成片段等待后续重试或重启恢复,但暂时不能用于长期检索。
141
+ - 查询 Embedding 超时或失败时,回复会降级为最近消息上下文,不阻断聊天。
142
+ - 数据库向量维度不匹配属于配置/数据错误,应先按备份说明处理,而不是直接删除生产数据。
143
+
144
+ ## 随机回复风险
145
+
146
+ 随机回复可能接错话题、打扰群友、放大敏感内容传播,并产生额外模型费用和限流压力;100% 会对每条符合条件的普通文本调用模型。“等待中只保留最新随机请求”只能减少积压,不能限制每条新消息继续触发。建议保持默认 0%,先在测试群验证,再从较低概率开始,并用忽略列表排除已知机器人。
147
+
148
+ ## 数据库备份
149
+
150
+ 默认数据库位于 nonebot-plugin-localstore 为本插件分配的数据目录,文件名为 `group_chat_memory.db`;配置 `GROUP_CHAT_MEMORY_DB_PATH_OVERRIDE` 时使用该路径。插件启用了 WAL,运行中可能出现同名 `-wal` 和 `-shm` 文件。
151
+
152
+ 运行中备份推荐使用 SQLite CLI 的在线备份命令,它会正确处理 WAL:
153
+
154
+ ```bash
155
+ sqlite3 /path/to/group_chat_memory.db ".backup '/path/to/group_chat_memory.backup.db'"
156
+ ```
157
+
158
+ 也可以先正常停止 Bot,再复制数据库文件及存在的 `-wal`、`-shm` 文件。**不要在运行中只复制主 `.db` 文件**,否则可能得到不完整备份。恢复后必须使用与原数据库相同的 Embedding 维度配置。
159
+
160
+ ## 开发验证
161
+
162
+ ```bash
163
+ poetry install
164
+ poetry run pytest -v
165
+ poetry run ruff check .
166
+ poetry run python -m compileall nonebot_plugin_group_chat_memory
167
+ poetry build
168
+ ```
169
+
170
+ ## 许可证
171
+
172
+ MIT,见 [LICENSE](LICENSE)。
173
+
@@ -0,0 +1,146 @@
1
+ # nonebot-plugin-group-chat-memory
2
+
3
+ 为 NoneBot2 OneBot v11 群聊提供按群隔离的长期向量记忆、@/回复对话和按群概率随机回复。插件使用 SQLite + sqlite-vec 保存原文、分块、向量和群配置,聊天与 Embedding 均请求可配置的 OpenAI 兼容 HTTP API。
4
+
5
+ ## 环境与安装
6
+
7
+ - Python 3.10+
8
+ - NoneBot2 2.2+
9
+ - OneBot v11 适配器与对应的 OneBot 协议端
10
+
11
+ 选择一种安装方式:
12
+
13
+ ```bash
14
+ # nb-cli
15
+ nb plugin install nonebot-plugin-group-chat-memory
16
+ nb adapter install nonebot-adapter-onebot
17
+
18
+ # pip
19
+ pip install nonebot-plugin-group-chat-memory nonebot-adapter-onebot
20
+
21
+ # Poetry
22
+ poetry add nonebot-plugin-group-chat-memory nonebot-adapter-onebot
23
+ ```
24
+
25
+ nb-cli 通常会写入插件配置。使用 pip 或 Poetry 手动安装后,确认项目的 `pyproject.toml` 已加载插件:
26
+
27
+ ```toml
28
+ [tool.nonebot]
29
+ plugins = ["nonebot_plugin_group_chat_memory"]
30
+ ```
31
+
32
+ 并在 NoneBot 启动代码中注册 OneBot v11 Adapter。仓库中的 `.env.example` 使用 `DRIVER=~fastapi`;该示例驱动不是本插件的运行时依赖,需要在你的 Bot 项目中另行安装:
33
+
34
+ ```bash
35
+ pip install "nonebot2[fastapi]"
36
+ # 或
37
+ poetry add "nonebot2[fastapi]"
38
+ ```
39
+
40
+ ## 快速配置
41
+
42
+ 复制 `.env.example` 为 `.env` 并设置 SUPERUSER:
43
+
44
+ ```dotenv
45
+ SUPERUSERS=["10001"]
46
+ COMMAND_START=["/"]
47
+ ```
48
+
49
+ 启动 Bot 后,由 SUPERUSER 在**私聊**中依次设置四项运行时配置:
50
+
51
+ ```text
52
+ /设置聊天地址 https://your-chat-provider.example/v1
53
+ /设置聊天密钥 sk-your-chat-key
54
+ /设置聊天模型 your-provider-model-id
55
+ /设置向量密钥 sk-your-embedding-key
56
+ ```
57
+
58
+ 聊天地址填到 API 根路径(通常包含 `/v1`),插件分别请求其下的 `/chat/completions` 和 `/embeddings`。默认聊天模型标识是 `deepseek-v4.1-flash`,但**模型标识必须以实际服务商支持的值为准**。这四项保存到 SQLite,不再读取同名环境变量,更新后立即生效且重启后保留。使用 `/模型配置` 可查询脱敏状态。
59
+
60
+ ## 其余环境配置字段
61
+
62
+ 四个模型运行时字段不再通过 env 设置。其余以下字段可写在 NoneBot `.env` 中;集合类值使用 JSON 格式。
63
+
64
+ | 环境变量 | 默认值 | 说明 |
65
+ |---|---|---|
66
+ | `GROUP_CHAT_MEMORY_CHAT_TIMEOUT` | `60` | 单次聊天 HTTP 请求超时秒数,必须大于 0。 |
67
+ | `GROUP_CHAT_MEMORY_EMBEDDING_BASE_URL` | `https://api.siliconflow.cn/v1` | OpenAI 兼容 Embedding API 根地址。 |
68
+ | `GROUP_CHAT_MEMORY_EMBEDDING_MODEL` | `BAAI/bge-m3` | Embedding 模型标识。 |
69
+ | `GROUP_CHAT_MEMORY_EMBEDDING_DIMENSIONS` | `1024` | 向量维度,必须大于 0,必须与模型及既有数据库一致。 |
70
+ | `GROUP_CHAT_MEMORY_EMBEDDING_TIMEOUT` | `10` | 后台分块 Embedding HTTP 请求超时秒数,必须大于 0。 |
71
+ | `GROUP_CHAT_MEMORY_QUERY_TIMEOUT` | `3` | 回复前“查询向量 + 本地检索”的整体短超时秒数;超时后只用最近消息聊天。 |
72
+ | `GROUP_CHAT_MEMORY_DB_PATH_OVERRIDE` | 空 | 数据库路径覆盖,支持 `~`;留空时使用 nonebot-plugin-localstore 的本插件数据目录下 `group_chat_memory.db`。 |
73
+ | `GROUP_CHAT_MEMORY_RECENT_MESSAGES` | `20` | 每次回复最多读取的当前群最近消息数,至少为 1。 |
74
+ | `GROUP_CHAT_MEMORY_CHUNK_SIZE` | `10` | 每个长期记忆片段包含的消息数,至少为 1。 |
75
+ | `GROUP_CHAT_MEMORY_LONG_TERM_MEMORIES` | `5` | 每次回复最多检索的长期片段数;设为 0 可停用长期检索。 |
76
+ | `GROUP_CHAT_MEMORY_CONTEXT_MAX_CHARS` | `6000` | 发送给聊天模型的上下文总字符预算,至少为 1;优先保留当前消息和最近消息。 |
77
+ | `GROUP_CHAT_MEMORY_DEFAULT_PROBABILITY` | `0` | 新群随机回复概率,范围 0~100;已有群使用数据库中的概率。 |
78
+ | `GROUP_CHAT_MEMORY_IGNORED_QQ` | `[]` | 完全忽略的 QQ 号 JSON 数组,例如 `[12345, 67890]`;这些账号的消息不记忆也不触发回复。 |
79
+ | `GROUP_CHAT_MEMORY_SYSTEM_PROMPT` | `你是一个群聊助手。回答自然、简洁,像群友一样交流。只使用提供的聊天记录和长期记忆,不要假装记得没有出现过的内容。` | 自定义系统提示词;设为空时仅使用内置的发言者区分和当前消息约束。 |
80
+
81
+ 更改 `GROUP_CHAT_MEMORY_EMBEDDING_DIMENSIONS` 不会迁移既有向量。如果与数据库首次创建时记录的维度不同,插件会拒绝打开该数据库;请恢复原配置,或备份后另行重建数据库。
82
+
83
+ ## 使用与权限
84
+
85
+ 在群里 @Bot 或回复 Bot 的消息即可对话;这两种定向消息必定进入回复队列,不受随机概率影响。普通纯文本按当前群概率触发,默认 0% 表示关闭随机回复,100% 表示每条符合条件的普通文本都回复。
86
+
87
+ | 命令 | 权限 | 行为 |
88
+ |---|---|---|
89
+ | `/设置聊天地址 <URL>` | 仅私聊 SUPERUSER | 设置全局 OpenAI 兼容聊天 API 根地址。 |
90
+ | `/设置聊天密钥 <密钥>` | 仅私聊 SUPERUSER | 设置全局聊天 API 密钥。 |
91
+ | `/设置聊天模型 <模型标识>` | 仅私聊 SUPERUSER | 设置全局聊天模型。 |
92
+ | `/设置向量密钥 <密钥>` | 仅私聊 SUPERUSER | 设置全局 Embedding API 密钥。 |
93
+ | `/模型配置` | SUPERUSER,私聊或群聊 | 查询全局配置状态,密钥只显示掩码。 |
94
+ | `/回复概率` | 仅 SUPERUSER | 查询当前群概率,显示一位小数。 |
95
+ | `/回复概率 <0~100>` | 仅 SUPERUSER | 设置当前群概率,最多一位小数,例如 `/回复概率 5.5`。 |
96
+ | `/清空群记忆` | 仅 SUPERUSER | 删除当前群原始消息、分块和向量;保留当前群概率,不影响其他群。 |
97
+
98
+ 非 SUPERUSER 会收到简短权限提示。命令不进入记忆,也不触发随机回复。SUPERUSER 通过 NoneBot 的 `SUPERUSERS` 配置,而不是群管理员身份确定。
99
+
100
+ 同一群的模型请求串行执行:定向请求逐条排队;等待中的随机请求只保留最新一条,避免集中补发。不同群可以并行处理。
101
+
102
+ ## 记忆范围与隐私
103
+
104
+ **这是全群文本记忆,不只是与 Bot 的对话。** 符合条件的群成员纯文本、定向文本以及 Bot 成功发送的模型回复都会保存;其他机器人账号也会保存,除非列入 `GROUP_CHAT_MEMORY_IGNORED_QQ`。命令、私聊、临时会话、匿名消息、Bot 自身消息、纯非文本及含不支持消息段的内容不会进入记忆。
105
+
106
+ 每积累 10 条消息(可由 `GROUP_CHAT_MEMORY_CHUNK_SIZE` 修改)形成的完整群聊片段会发送给配置的**第三方 Embedding 服务商**;每次回复前的当前查询文本也会发送给 Embedding 服务商。回复时,当前群最近消息、检索到的长期片段和当前输入会发送给聊天模型服务商。不同群的记忆和检索在数据库层面隔离,但同一群的成员内容可能进入该群后续模型上下文。
107
+
108
+ 原文、向量与命令设置的 API 密钥保存在本地 SQLite 文件中,数据库本身不加密。请保护好数据库与备份,并限制文件访问权限。虽然设置密钥只允许私聊 SUPERUSER,但命令内容仍可能保留在你和 OneBot 协议端的聊天记录或日志中;不要把密钥发到群里,泄露后请立即更换。请根据群成员授权、服务商数据政策和适用法律决定是否启用。
109
+
110
+ ## 缺失凭据与故障降级
111
+
112
+ - 零配置导入插件和 `nonebot.load_plugin("nonebot_plugin_group_chat_memory")` 均可成功,不要求先填写 API 密钥。
113
+ - 聊天地址、密钥或模型为空时,收到定向/随机触发后只发送通用错误提示;传入消息仍会保存,不会把密钥或内部响应发到群里。
114
+ - Embedding 密钥缺失或后台向量化失败时,原始消息仍保留,未完成片段等待后续重试或重启恢复,但暂时不能用于长期检索。
115
+ - 查询 Embedding 超时或失败时,回复会降级为最近消息上下文,不阻断聊天。
116
+ - 数据库向量维度不匹配属于配置/数据错误,应先按备份说明处理,而不是直接删除生产数据。
117
+
118
+ ## 随机回复风险
119
+
120
+ 随机回复可能接错话题、打扰群友、放大敏感内容传播,并产生额外模型费用和限流压力;100% 会对每条符合条件的普通文本调用模型。“等待中只保留最新随机请求”只能减少积压,不能限制每条新消息继续触发。建议保持默认 0%,先在测试群验证,再从较低概率开始,并用忽略列表排除已知机器人。
121
+
122
+ ## 数据库备份
123
+
124
+ 默认数据库位于 nonebot-plugin-localstore 为本插件分配的数据目录,文件名为 `group_chat_memory.db`;配置 `GROUP_CHAT_MEMORY_DB_PATH_OVERRIDE` 时使用该路径。插件启用了 WAL,运行中可能出现同名 `-wal` 和 `-shm` 文件。
125
+
126
+ 运行中备份推荐使用 SQLite CLI 的在线备份命令,它会正确处理 WAL:
127
+
128
+ ```bash
129
+ sqlite3 /path/to/group_chat_memory.db ".backup '/path/to/group_chat_memory.backup.db'"
130
+ ```
131
+
132
+ 也可以先正常停止 Bot,再复制数据库文件及存在的 `-wal`、`-shm` 文件。**不要在运行中只复制主 `.db` 文件**,否则可能得到不完整备份。恢复后必须使用与原数据库相同的 Embedding 维度配置。
133
+
134
+ ## 开发验证
135
+
136
+ ```bash
137
+ poetry install
138
+ poetry run pytest -v
139
+ poetry run ruff check .
140
+ poetry run python -m compileall nonebot_plugin_group_chat_memory
141
+ poetry build
142
+ ```
143
+
144
+ ## 许可证
145
+
146
+ MIT,见 [LICENSE](LICENSE)。
@@ -0,0 +1,26 @@
1
+ from nonebot import get_driver, require
2
+ from nonebot.plugin import PluginMetadata
3
+
4
+ from .config import Config
5
+
6
+ __plugin_meta__ = PluginMetadata(
7
+ name="群聊记忆",
8
+ description="为群聊提供共享长期记忆、点名对话和按群概率回复",
9
+ usage=(
10
+ "在群聊中 @机器人 或回复机器人的消息即可对话。\n"
11
+ "SUPERUSER:/回复概率 [0~100] 查询或设置当前群随机回复概率;"
12
+ "/清空群记忆 清除当前群聊天记录与长期记忆。"
13
+ ),
14
+ type="application",
15
+ homepage="https://github.com/TonyLiangP2010405/nonebot-plugin-group-chat-memory",
16
+ config=Config,
17
+ supported_adapters={"~onebot.v11"},
18
+ )
19
+
20
+ try:
21
+ get_driver()
22
+ except ValueError:
23
+ pass # 允许在 NoneBot 初始化前单独导入模块
24
+ else:
25
+ require("nonebot_plugin_localstore")
26
+ from . import handlers as handlers
@@ -0,0 +1,185 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import math
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from .config import Config
10
+ from .context import build_chat_messages
11
+ from .models import ChatContext
12
+
13
+ CHAT_RETRY_DELAYS: tuple[float, ...] = (0.5, 1.0)
14
+
15
+
16
+ class ModelAPIError(RuntimeError):
17
+ """模型 API 调用失败;异常消息只包含状态或原因,不含密钥与响应正文。"""
18
+
19
+
20
+ class EmbeddingAPIError(ModelAPIError):
21
+ """Embedding 请求失败或响应不可用。"""
22
+
23
+
24
+ class ChatAPIError(ModelAPIError):
25
+ """聊天请求失败或响应不可用。"""
26
+
27
+ def __init__(self, message: str, *, retryable: bool = False) -> None:
28
+ super().__init__(message)
29
+ self.retryable = retryable
30
+
31
+
32
+ def _make_client(timeout: float) -> httpx.AsyncClient:
33
+ """为单次请求创建客户端,使聊天与 Embedding 各自使用独立超时。"""
34
+ return httpx.AsyncClient(timeout=timeout)
35
+
36
+
37
+ def _endpoint(base_url: str, path: str) -> str:
38
+ return f"{base_url.rstrip('/')}/{path}"
39
+
40
+
41
+ def _auth_headers(api_key: str) -> dict[str, str]:
42
+ return {"Authorization": f"Bearer {api_key.strip()}"}
43
+
44
+
45
+ class EmbeddingClient:
46
+ """OpenAI 兼容 `/embeddings` 客户端,用于批量向量化。"""
47
+
48
+ def __init__(self, config: Config) -> None:
49
+ self._config = config
50
+
51
+ async def embed(self, texts: list[str]) -> list[list[float]]:
52
+ """批量向量化 texts,返回与输入顺序一致的向量;失败时抛出 EmbeddingAPIError。"""
53
+ if not texts:
54
+ return []
55
+ self._config.require_embedding_config()
56
+ payload = {"model": self._config.group_chat_memory_embedding_model, "input": list(texts)}
57
+ response = await self._post(payload)
58
+ return self._parse(response, len(texts))
59
+
60
+ async def _post(self, payload: dict[str, Any]) -> httpx.Response:
61
+ url = _endpoint(self._config.group_chat_memory_embedding_base_url, "embeddings")
62
+ headers = _auth_headers(self._config.group_chat_memory_embedding_api_key.get_secret_value())
63
+ timeout = self._config.group_chat_memory_embedding_timeout
64
+ try:
65
+ async with _make_client(timeout) as client:
66
+ response = await client.post(url, json=payload, headers=headers)
67
+ except httpx.TimeoutException as exc:
68
+ raise EmbeddingAPIError("Embedding 请求超时。") from exc
69
+ except httpx.HTTPError as exc:
70
+ raise EmbeddingAPIError("Embedding 请求失败,无法连接服务。") from exc
71
+ if not 200 <= response.status_code < 300:
72
+ raise EmbeddingAPIError(f"Embedding 服务返回 HTTP {response.status_code}。")
73
+ return response
74
+
75
+ def _parse(self, response: httpx.Response, expected: int) -> list[list[float]]:
76
+ try:
77
+ payload = response.json()
78
+ except ValueError as exc:
79
+ raise EmbeddingAPIError("Embedding 响应不是合法 JSON。") from exc
80
+ data = payload.get("data") if isinstance(payload, dict) else None
81
+ if not isinstance(data, list) or not data:
82
+ raise EmbeddingAPIError("Embedding 响应缺少向量数据。")
83
+ if len(data) != expected:
84
+ raise EmbeddingAPIError(
85
+ f"Embedding 响应返回 {len(data)} 条向量,与请求的 {expected} 条不一致。"
86
+ )
87
+
88
+ ordered = sorted(
89
+ enumerate(data),
90
+ key=lambda item: _index_of(item[1], item[0]),
91
+ )
92
+ dimensions = self._config.group_chat_memory_embedding_dimensions
93
+ return [_parse_vector(item, dimensions) for _, item in ordered]
94
+
95
+
96
+ class ChatClient:
97
+ """OpenAI 兼容 `/chat/completions` 客户端,带有限重试。"""
98
+
99
+ def __init__(
100
+ self,
101
+ config: Config,
102
+ *,
103
+ retry_delays: tuple[float, ...] = CHAT_RETRY_DELAYS,
104
+ ) -> None:
105
+ self._config = config
106
+ self._retry_delays = tuple(retry_delays)
107
+
108
+ async def complete(self, context: ChatContext, current_text: str) -> str:
109
+ """按上下文请求一条回复;瞬时失败按配置次数重试,最终失败抛出 ChatAPIError。"""
110
+ self._config.require_chat_config()
111
+ if not current_text.strip():
112
+ raise ChatAPIError("当前消息为空,无法请求聊天模型。")
113
+ payload = {
114
+ "model": self._config.group_chat_memory_chat_model,
115
+ "messages": build_chat_messages(self._config, context, current_text),
116
+ }
117
+
118
+ attempts = len(self._retry_delays) + 1
119
+ for attempt in range(attempts):
120
+ try:
121
+ response = await self._post(payload)
122
+ except ChatAPIError as exc:
123
+ if not exc.retryable or attempt + 1 >= attempts:
124
+ raise
125
+ await asyncio.sleep(self._retry_delays[attempt])
126
+ continue
127
+ return self._parse(response)
128
+ raise ChatAPIError("聊天请求重试次数已用尽。")
129
+
130
+ async def _post(self, payload: dict[str, Any]) -> httpx.Response:
131
+ url = _endpoint(self._config.group_chat_memory_chat_base_url, "chat/completions")
132
+ headers = _auth_headers(self._config.group_chat_memory_chat_api_key.get_secret_value())
133
+ timeout = self._config.group_chat_memory_chat_timeout
134
+ try:
135
+ async with _make_client(timeout) as client:
136
+ response = await client.post(url, json=payload, headers=headers)
137
+ except httpx.TimeoutException as exc:
138
+ raise ChatAPIError("聊天请求超时。", retryable=True) from exc
139
+ except httpx.HTTPError as exc:
140
+ raise ChatAPIError("聊天请求失败,无法连接服务。", retryable=True) from exc
141
+ if not 200 <= response.status_code < 300:
142
+ raise ChatAPIError(
143
+ f"聊天服务返回 HTTP {response.status_code}。",
144
+ retryable=response.status_code == 429 or response.status_code >= 500,
145
+ )
146
+ return response
147
+
148
+ @staticmethod
149
+ def _parse(response: httpx.Response) -> str:
150
+ try:
151
+ payload = response.json()
152
+ except ValueError as exc:
153
+ raise ChatAPIError("聊天响应不是合法 JSON。") from exc
154
+ choices = payload.get("choices") if isinstance(payload, dict) else None
155
+ if not isinstance(choices, list) or not choices:
156
+ raise ChatAPIError("聊天响应缺少 choices。")
157
+ message = choices[0].get("message") if isinstance(choices[0], dict) else None
158
+ content = message.get("content") if isinstance(message, dict) else None
159
+ if not isinstance(content, str) or not content.strip():
160
+ raise ChatAPIError("聊天响应缺少回复内容。")
161
+ return content.strip()
162
+
163
+
164
+ def _index_of(item: Any, fallback: int) -> int:
165
+ index = item.get("index") if isinstance(item, dict) else None
166
+ if isinstance(index, int) and not isinstance(index, bool):
167
+ return index
168
+ return fallback
169
+
170
+
171
+ def _parse_vector(item: Any, dimensions: int) -> list[float]:
172
+ vector = item.get("embedding") if isinstance(item, dict) else None
173
+ if not isinstance(vector, list):
174
+ raise EmbeddingAPIError("Embedding 响应缺少向量。")
175
+ if len(vector) != dimensions:
176
+ raise EmbeddingAPIError(f"Embedding 向量维度为 {len(vector)},配置要求 {dimensions}。")
177
+ values: list[float] = []
178
+ for value in vector:
179
+ if isinstance(value, bool) or not isinstance(value, int | float):
180
+ raise EmbeddingAPIError("Embedding 向量包含非数值。")
181
+ number = float(value)
182
+ if not math.isfinite(number):
183
+ raise EmbeddingAPIError("Embedding 向量包含非有限数值。")
184
+ values.append(number)
185
+ return values
@@ -0,0 +1,117 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from nonebot import get_plugin_config
6
+ from pydantic import BaseModel, ConfigDict, Field, SecretStr
7
+
8
+ DEFAULT_DB_FILENAME = "group_chat_memory.db"
9
+
10
+ DEFAULT_SYSTEM_PROMPT = (
11
+ "你是一个群聊助手。回答自然、简洁,像群友一样交流。"
12
+ "只使用提供的聊天记录和长期记忆,不要假装记得没有出现过的内容。"
13
+ )
14
+
15
+ RUNTIME_CONFIG_DEFAULTS = {
16
+ "chat_base_url": "",
17
+ "chat_api_key": "",
18
+ "chat_model": "deepseek-v4.1-flash",
19
+ "embedding_api_key": "",
20
+ }
21
+
22
+
23
+ class ConfigError(RuntimeError):
24
+ """功能所需的配置缺失时抛出。"""
25
+
26
+
27
+ def _localstore_data_dir() -> Path:
28
+ from nonebot_plugin_localstore import get_plugin_data_dir
29
+
30
+ return get_plugin_data_dir()
31
+
32
+
33
+ class Config(BaseModel):
34
+ """群聊记忆插件配置;四个模型运行时字段由私聊命令和数据库覆盖。"""
35
+
36
+ model_config = ConfigDict(extra="ignore")
37
+
38
+ group_chat_memory_chat_base_url: str = ""
39
+ group_chat_memory_chat_api_key: SecretStr = SecretStr("")
40
+ group_chat_memory_chat_model: str = "deepseek-v4.1-flash"
41
+ group_chat_memory_chat_timeout: float = Field(default=60.0, gt=0)
42
+
43
+ group_chat_memory_embedding_base_url: str = "https://api.siliconflow.cn/v1"
44
+ group_chat_memory_embedding_api_key: SecretStr = SecretStr("")
45
+ group_chat_memory_embedding_model: str = "BAAI/bge-m3"
46
+ group_chat_memory_embedding_dimensions: int = Field(default=1024, gt=0)
47
+ group_chat_memory_embedding_timeout: float = Field(default=10.0, gt=0)
48
+ group_chat_memory_query_timeout: float = Field(default=3.0, gt=0)
49
+
50
+ group_chat_memory_db_path_override: str | None = None
51
+
52
+ group_chat_memory_recent_messages: int = Field(default=20, ge=1)
53
+ group_chat_memory_chunk_size: int = Field(default=10, ge=1)
54
+ group_chat_memory_long_term_memories: int = Field(default=5, ge=0)
55
+ group_chat_memory_context_max_chars: int = Field(default=6000, ge=1)
56
+ group_chat_memory_default_probability: float = Field(default=0.0, ge=0.0, le=100.0)
57
+ group_chat_memory_ignored_qq: set[int] = Field(default_factory=set)
58
+ group_chat_memory_system_prompt: str = DEFAULT_SYSTEM_PROMPT
59
+
60
+ @property
61
+ def database_path(self) -> Path:
62
+ """数据库文件路径;未配置时使用 localstore 的插件数据目录。"""
63
+ configured = (self.group_chat_memory_db_path_override or "").strip()
64
+ if configured:
65
+ return Path(configured).expanduser()
66
+ return _localstore_data_dir() / DEFAULT_DB_FILENAME
67
+
68
+ def apply_runtime_config(self, values: dict[str, str]) -> None:
69
+ """应用数据库中的四项全局配置,未保存的字段恢复内置默认值。"""
70
+ for key, default in RUNTIME_CONFIG_DEFAULTS.items():
71
+ value = values.get(key, default)
72
+ if key == "chat_base_url":
73
+ self.group_chat_memory_chat_base_url = value
74
+ elif key == "chat_api_key":
75
+ self.group_chat_memory_chat_api_key = SecretStr(value)
76
+ elif key == "chat_model":
77
+ self.group_chat_memory_chat_model = value
78
+ elif key == "embedding_api_key":
79
+ self.group_chat_memory_embedding_api_key = SecretStr(value)
80
+
81
+ def require_chat_config(self) -> None:
82
+ """检查聊天模型配置,仅在真正调用聊天功能时使用。"""
83
+ missing = self._missing(
84
+ ("GROUP_CHAT_MEMORY_CHAT_BASE_URL", self.group_chat_memory_chat_base_url),
85
+ (
86
+ "GROUP_CHAT_MEMORY_CHAT_API_KEY",
87
+ self.group_chat_memory_chat_api_key.get_secret_value(),
88
+ ),
89
+ ("GROUP_CHAT_MEMORY_CHAT_MODEL", self.group_chat_memory_chat_model),
90
+ )
91
+ if missing:
92
+ raise ConfigError(f"聊天模型未配置,请设置:{'、'.join(missing)}")
93
+
94
+ def require_embedding_config(self) -> None:
95
+ """检查 Embedding 配置,仅在真正调用向量化功能时使用。"""
96
+ missing = self._missing(
97
+ (
98
+ "GROUP_CHAT_MEMORY_EMBEDDING_BASE_URL",
99
+ self.group_chat_memory_embedding_base_url,
100
+ ),
101
+ (
102
+ "GROUP_CHAT_MEMORY_EMBEDDING_API_KEY",
103
+ self.group_chat_memory_embedding_api_key.get_secret_value(),
104
+ ),
105
+ ("GROUP_CHAT_MEMORY_EMBEDDING_MODEL", self.group_chat_memory_embedding_model),
106
+ )
107
+ if missing:
108
+ raise ConfigError(f"Embedding 未配置,请设置:{'、'.join(missing)}")
109
+
110
+ @staticmethod
111
+ def _missing(*items: tuple[str, str]) -> list[str]:
112
+ return [name for name, value in items if not value.strip()]
113
+
114
+
115
+ def get_settings() -> Config:
116
+ """从 NoneBot 全局配置读取本插件配置。"""
117
+ return get_plugin_config(Config)