nonebot-plugin-group-chat-memory 0.1.2__tar.gz → 0.1.4__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.
Files changed (13) hide show
  1. {nonebot_plugin_group_chat_memory-0.1.2 → nonebot_plugin_group_chat_memory-0.1.4}/PKG-INFO +10 -3
  2. {nonebot_plugin_group_chat_memory-0.1.2 → nonebot_plugin_group_chat_memory-0.1.4}/README.md +9 -2
  3. {nonebot_plugin_group_chat_memory-0.1.2 → nonebot_plugin_group_chat_memory-0.1.4}/nonebot_plugin_group_chat_memory/config.py +16 -2
  4. {nonebot_plugin_group_chat_memory-0.1.2 → nonebot_plugin_group_chat_memory-0.1.4}/nonebot_plugin_group_chat_memory/handlers.py +43 -9
  5. {nonebot_plugin_group_chat_memory-0.1.2 → nonebot_plugin_group_chat_memory-0.1.4}/nonebot_plugin_group_chat_memory/store.py +118 -5
  6. {nonebot_plugin_group_chat_memory-0.1.2 → nonebot_plugin_group_chat_memory-0.1.4}/pyproject.toml +1 -1
  7. {nonebot_plugin_group_chat_memory-0.1.2 → nonebot_plugin_group_chat_memory-0.1.4}/LICENSE +0 -0
  8. {nonebot_plugin_group_chat_memory-0.1.2 → nonebot_plugin_group_chat_memory-0.1.4}/nonebot_plugin_group_chat_memory/__init__.py +0 -0
  9. {nonebot_plugin_group_chat_memory-0.1.2 → nonebot_plugin_group_chat_memory-0.1.4}/nonebot_plugin_group_chat_memory/api.py +0 -0
  10. {nonebot_plugin_group_chat_memory-0.1.2 → nonebot_plugin_group_chat_memory-0.1.4}/nonebot_plugin_group_chat_memory/context.py +0 -0
  11. {nonebot_plugin_group_chat_memory-0.1.2 → nonebot_plugin_group_chat_memory-0.1.4}/nonebot_plugin_group_chat_memory/memory.py +0 -0
  12. {nonebot_plugin_group_chat_memory-0.1.2 → nonebot_plugin_group_chat_memory-0.1.4}/nonebot_plugin_group_chat_memory/models.py +0 -0
  13. {nonebot_plugin_group_chat_memory-0.1.2 → nonebot_plugin_group_chat_memory-0.1.4}/nonebot_plugin_group_chat_memory/replies.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nonebot-plugin-group-chat-memory
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: 为 NoneBot2 群聊提供共享长期记忆、点名对话和按群概率回复
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -105,8 +105,9 @@ COMMAND_START=["/"]
105
105
  | `GROUP_CHAT_MEMORY_LONG_TERM_MEMORIES` | `5` | 每次回复最多检索的长期片段数;设为 0 可停用长期检索。 |
106
106
  | `GROUP_CHAT_MEMORY_CONTEXT_MAX_CHARS` | `6000` | 发送给聊天模型的上下文总字符预算,至少为 1;优先保留当前消息和最近消息。 |
107
107
  | `GROUP_CHAT_MEMORY_DEFAULT_PROBABILITY` | `0` | 新群随机回复概率,范围 0~100;已有群使用数据库中的概率。 |
108
+ | `GROUP_CHAT_MEMORY_HOURLY_REQUEST_LIMIT` | `10` | 新群默认每小时模型请求上限,范围 1~10000;已有群可用 `/每小时次数` 修改。 |
108
109
  | `GROUP_CHAT_MEMORY_IGNORED_QQ` | `[]` | 完全忽略的 QQ 号 JSON 数组,例如 `[12345, 67890]`;这些账号的消息不记忆也不触发回复。 |
109
- | `GROUP_CHAT_MEMORY_SYSTEM_PROMPT` | `你是一个群聊助手。回答自然、简洁,像群友一样交流。只使用提供的聊天记录和长期记忆,不要假装记得没有出现过的内容。` | 自定义系统提示词;设为空时仅使用内置的发言者区分和当前消息约束。 |
110
+ | `GROUP_CHAT_MEMORY_SYSTEM_PROMPT` | 内置“馒头 + 五更琉璃(黑猫)式性格”人格 | 自定义系统提示词并完全覆盖默认人格;设为空时仅使用内置的发言者区分和当前消息约束。 |
110
111
 
111
112
  更改 `GROUP_CHAT_MEMORY_EMBEDDING_DIMENSIONS` 不会迁移既有向量。如果与数据库首次创建时记录的维度不同,插件会拒绝打开该数据库;请恢复原配置,或备份后另行重建数据库。
112
113
 
@@ -114,6 +115,10 @@ COMMAND_START=["/"]
114
115
 
115
116
  在群里 @Bot 或回复 Bot 的消息即可对话;这两种定向消息必定进入回复队列,不受随机概率影响。普通纯文本按当前群概率触发,默认 0% 表示关闭随机回复,100% 表示每条符合条件的普通文本都回复。
116
117
 
118
+ 默认人格为“馒头”:Bot 始终以馒头自称,借用五更琉璃(黑猫)的中二、毒舌、傲娇和外冷内热性格,但不会冒充黑猫本人。插件命令优先级为 `-1001`,群聊消息优先级为 `-1000`;@Bot 或回复 Bot 处理完成后会停止事件继续传播,避免其他 NoneBot 插件同时响应。普通群聊仍不会阻塞其他插件。
119
+
120
+ 每个群默认每小时最多触发 10 次模型请求,采用滚动 60 分钟窗口。点名超额会收到简短提示,随机回复超额静默跳过;消息仍写入记忆,命令和 Embedding 不消耗次数。
121
+
117
122
  | 命令 | 权限 | 行为 |
118
123
  |---|---|---|
119
124
  | `/设置聊天地址 <URL>` | 仅私聊 SUPERUSER | 设置全局 OpenAI 兼容聊天 API 根地址。 |
@@ -123,7 +128,9 @@ COMMAND_START=["/"]
123
128
  | `/模型配置` | SUPERUSER,私聊或群聊 | 查询全局配置状态,密钥只显示掩码。 |
124
129
  | `/回复概率` | 仅 SUPERUSER | 查询当前群概率,显示一位小数。 |
125
130
  | `/回复概率 <0~100>` | 仅 SUPERUSER | 设置当前群概率,最多一位小数,例如 `/回复概率 5.5`。 |
126
- | `/清空群记忆` | 仅 SUPERUSER | 删除当前群原始消息、分块和向量;保留当前群概率,不影响其他群。 |
131
+ | `/每小时次数` | 仅 SUPERUSER | 查询当前群每小时模型请求上限。 |
132
+ | `/每小时次数 <1~10000>` | 仅 SUPERUSER | 设置当前群每小时请求上限,例如 `/每小时次数 20`。 |
133
+ | `/清空群记忆` | 仅 SUPERUSER | 删除当前群原始消息、分块、向量和请求计数;保留概率与每小时上限,不影响其他群。 |
127
134
 
128
135
  非 SUPERUSER 会收到简短权限提示。命令不进入记忆,也不触发随机回复。SUPERUSER 通过 NoneBot 的 `SUPERUSERS` 配置,而不是群管理员身份确定。
129
136
 
@@ -79,8 +79,9 @@ COMMAND_START=["/"]
79
79
  | `GROUP_CHAT_MEMORY_LONG_TERM_MEMORIES` | `5` | 每次回复最多检索的长期片段数;设为 0 可停用长期检索。 |
80
80
  | `GROUP_CHAT_MEMORY_CONTEXT_MAX_CHARS` | `6000` | 发送给聊天模型的上下文总字符预算,至少为 1;优先保留当前消息和最近消息。 |
81
81
  | `GROUP_CHAT_MEMORY_DEFAULT_PROBABILITY` | `0` | 新群随机回复概率,范围 0~100;已有群使用数据库中的概率。 |
82
+ | `GROUP_CHAT_MEMORY_HOURLY_REQUEST_LIMIT` | `10` | 新群默认每小时模型请求上限,范围 1~10000;已有群可用 `/每小时次数` 修改。 |
82
83
  | `GROUP_CHAT_MEMORY_IGNORED_QQ` | `[]` | 完全忽略的 QQ 号 JSON 数组,例如 `[12345, 67890]`;这些账号的消息不记忆也不触发回复。 |
83
- | `GROUP_CHAT_MEMORY_SYSTEM_PROMPT` | `你是一个群聊助手。回答自然、简洁,像群友一样交流。只使用提供的聊天记录和长期记忆,不要假装记得没有出现过的内容。` | 自定义系统提示词;设为空时仅使用内置的发言者区分和当前消息约束。 |
84
+ | `GROUP_CHAT_MEMORY_SYSTEM_PROMPT` | 内置“馒头 + 五更琉璃(黑猫)式性格”人格 | 自定义系统提示词并完全覆盖默认人格;设为空时仅使用内置的发言者区分和当前消息约束。 |
84
85
 
85
86
  更改 `GROUP_CHAT_MEMORY_EMBEDDING_DIMENSIONS` 不会迁移既有向量。如果与数据库首次创建时记录的维度不同,插件会拒绝打开该数据库;请恢复原配置,或备份后另行重建数据库。
86
87
 
@@ -88,6 +89,10 @@ COMMAND_START=["/"]
88
89
 
89
90
  在群里 @Bot 或回复 Bot 的消息即可对话;这两种定向消息必定进入回复队列,不受随机概率影响。普通纯文本按当前群概率触发,默认 0% 表示关闭随机回复,100% 表示每条符合条件的普通文本都回复。
90
91
 
92
+ 默认人格为“馒头”:Bot 始终以馒头自称,借用五更琉璃(黑猫)的中二、毒舌、傲娇和外冷内热性格,但不会冒充黑猫本人。插件命令优先级为 `-1001`,群聊消息优先级为 `-1000`;@Bot 或回复 Bot 处理完成后会停止事件继续传播,避免其他 NoneBot 插件同时响应。普通群聊仍不会阻塞其他插件。
93
+
94
+ 每个群默认每小时最多触发 10 次模型请求,采用滚动 60 分钟窗口。点名超额会收到简短提示,随机回复超额静默跳过;消息仍写入记忆,命令和 Embedding 不消耗次数。
95
+
91
96
  | 命令 | 权限 | 行为 |
92
97
  |---|---|---|
93
98
  | `/设置聊天地址 <URL>` | 仅私聊 SUPERUSER | 设置全局 OpenAI 兼容聊天 API 根地址。 |
@@ -97,7 +102,9 @@ COMMAND_START=["/"]
97
102
  | `/模型配置` | SUPERUSER,私聊或群聊 | 查询全局配置状态,密钥只显示掩码。 |
98
103
  | `/回复概率` | 仅 SUPERUSER | 查询当前群概率,显示一位小数。 |
99
104
  | `/回复概率 <0~100>` | 仅 SUPERUSER | 设置当前群概率,最多一位小数,例如 `/回复概率 5.5`。 |
100
- | `/清空群记忆` | 仅 SUPERUSER | 删除当前群原始消息、分块和向量;保留当前群概率,不影响其他群。 |
105
+ | `/每小时次数` | 仅 SUPERUSER | 查询当前群每小时模型请求上限。 |
106
+ | `/每小时次数 <1~10000>` | 仅 SUPERUSER | 设置当前群每小时请求上限,例如 `/每小时次数 20`。 |
107
+ | `/清空群记忆` | 仅 SUPERUSER | 删除当前群原始消息、分块、向量和请求计数;保留概率与每小时上限,不影响其他群。 |
101
108
 
102
109
  非 SUPERUSER 会收到简短权限提示。命令不进入记忆,也不触发随机回复。SUPERUSER 通过 NoneBot 的 `SUPERUSERS` 配置,而不是群管理员身份确定。
103
110
 
@@ -8,8 +8,21 @@ from pydantic import BaseModel, ConfigDict, Field, SecretStr
8
8
  DEFAULT_DB_FILENAME = "group_chat_memory.db"
9
9
 
10
10
  DEFAULT_SYSTEM_PROMPT = (
11
- "你是一个群聊助手。回答自然、简洁,像群友一样交流。"
12
- "只使用提供的聊天记录和长期记忆,不要假装记得没有出现过的内容。"
11
+ "你的名字是馒头。你借用了《我的妹妹哪有这么可爱!》中五更琉璃(网名“黑猫”)的"
12
+ "性格和说话方式,但你不是黑猫或五更琉璃本人。\n\n"
13
+ "性格与表达:\n"
14
+ "- 中二、毒舌、傲娇、外冷内热;对陌生群友称“凡人”,熟悉后可直呼名字。\n"
15
+ "- 偶尔使用“夜魔女王”“黑暗眷属”“诅咒”等中二措辞,保留“……”停顿和"
16
+ "“哼”“别误会”等傲娇表达,但不要连续堆砌,也不要每句话加“喵”。\n"
17
+ "- 被夸奖或关心时容易害羞,用嘴硬掩饰;不真正辱骂、贬低或威胁群友。\n"
18
+ "- 对朋友和群友实际很关心,会认真倾听并给出可靠建议。\n"
19
+ "- 擅长游戏、动画、同人创作、漫画和料理;不了解的内容坦率承认。\n\n"
20
+ "规则:\n"
21
+ "- 始终自称“我”或“馒头”,绝不自称“黑猫”“五更琉璃”或“夜魔女王”本人。\n"
22
+ "- 用自然简洁的中文回答。正经问题优先给准确、实用的答案,角色感不能牺牲可读性。\n"
23
+ "- 不编造聊天记录、记忆或原作剧情;不连续复述原作台词,不输出角色介绍。\n"
24
+ "- 医疗、法律、安全等高风险问题必须准确、谨慎,不因角色扮演弱化警告。\n"
25
+ "- 你只是借用这种性格的群聊助手,不要声称自己是现实人物。"
13
26
  )
14
27
 
15
28
  RUNTIME_CONFIG_DEFAULTS = {
@@ -54,6 +67,7 @@ class Config(BaseModel):
54
67
  group_chat_memory_long_term_memories: int = Field(default=5, ge=0)
55
68
  group_chat_memory_context_max_chars: int = Field(default=6000, ge=1)
56
69
  group_chat_memory_default_probability: float = Field(default=0.0, ge=0.0, le=100.0)
70
+ group_chat_memory_hourly_request_limit: int = Field(default=10, ge=1, le=10000)
57
71
  group_chat_memory_ignored_qq: set[int] = Field(default_factory=set)
58
72
  group_chat_memory_system_prompt: str = DEFAULT_SYSTEM_PROMPT
59
73
 
@@ -18,6 +18,7 @@ from nonebot.adapters.onebot.v11 import (
18
18
  MessageSegment,
19
19
  PrivateMessageEvent,
20
20
  )
21
+ from nonebot.exception import StopPropagation
21
22
  from nonebot.params import CommandArg
22
23
  from nonebot.permission import SUPERUSER
23
24
 
@@ -34,6 +35,8 @@ logger = logging.getLogger(__name__)
34
35
  GENERIC_REPLY_ERROR = "暂时无法生成回复,请稍后再试。"
35
36
  PERMISSION_DENIED = "仅 SUPERUSER 可执行此命令。"
36
37
  INVALID_PROBABILITY = "概率需为 0~100 的数字,最多一位小数。"
38
+ INVALID_HOURLY_LIMIT = "每小时请求上限必须是 1 到 10000 的整数。"
39
+ RATE_LIMIT_NOTICE = "本群本小时请求次数已用完,请稍后再来。"
37
40
  PROBABILITY_PATTERN = re.compile(r"(?:100(?:\.0)?|\d{1,2}(?:\.\d)?)")
38
41
  SECRET_COMMAND_LOG_PATTERN = re.compile(r"(设置聊天密钥|设置向量密钥)\s+[^\r\n]*")
39
42
 
@@ -137,11 +140,11 @@ async def apply_setting(key: str, value: str) -> None:
137
140
  config.apply_runtime_config(values)
138
141
 
139
142
 
140
- chat_url_matcher = on_command("设置聊天地址", priority=5, block=True)
141
- chat_key_matcher = on_command("设置聊天密钥", priority=5, block=True)
142
- chat_model_matcher = on_command("设置聊天模型", priority=5, block=True)
143
- embedding_key_matcher = on_command("设置向量密钥", priority=5, block=True)
144
- model_config_matcher = on_command("模型配置", priority=5, block=True)
143
+ chat_url_matcher = on_command("设置聊天地址", priority=-1001, block=True)
144
+ chat_key_matcher = on_command("设置聊天密钥", priority=-1001, block=True)
145
+ chat_model_matcher = on_command("设置聊天模型", priority=-1001, block=True)
146
+ embedding_key_matcher = on_command("设置向量密钥", priority=-1001, block=True)
147
+ model_config_matcher = on_command("模型配置", priority=-1001, block=True)
145
148
 
146
149
 
147
150
  def _valid_http_url(value: str) -> bool:
@@ -225,10 +228,15 @@ async def handle_model_config(bot: Bot, event: MessageEvent) -> None:
225
228
 
226
229
 
227
230
  probability_matcher = on_command(
228
- "回复概率", rule=_eligible_group_message, priority=5, block=True
231
+ "回复概率", rule=_eligible_group_message, priority=-1001, block=True
229
232
  )
230
- clear_matcher = on_command("清空群记忆", rule=_eligible_group_message, priority=5, block=True)
231
- group_message_matcher = on_message(rule=_eligible_group_message, priority=20, block=False)
233
+ clear_matcher = on_command(
234
+ "清空群记忆", rule=_eligible_group_message, priority=-1001, block=True
235
+ )
236
+ hourly_limit_matcher = on_command(
237
+ "每小时次数", rule=_eligible_group_message, priority=-1001, block=True
238
+ )
239
+ group_message_matcher = on_message(rule=_eligible_group_message, priority=-1000, block=False)
232
240
 
233
241
 
234
242
  @probability_matcher.handle()
@@ -260,6 +268,27 @@ async def handle_clear(bot: Bot, event: GroupMessageEvent) -> None:
260
268
  await clear_matcher.finish("已清空当前群记忆。")
261
269
 
262
270
 
271
+ @hourly_limit_matcher.handle()
272
+ async def handle_hourly_limit(
273
+ bot: Bot,
274
+ event: GroupMessageEvent,
275
+ args: Annotated[Message, CommandArg()],
276
+ ) -> None:
277
+ if not await SUPERUSER(bot, event):
278
+ await hourly_limit_matcher.finish(PERMISSION_DENIED)
279
+
280
+ text = args.extract_plain_text().strip()
281
+ if not text:
282
+ limit = await store.get_hourly_limit(event.group_id)
283
+ await hourly_limit_matcher.finish(f"当前群每小时请求上限:{limit} 次")
284
+ if not text.isdigit() or not 1 <= int(text) <= 10000:
285
+ await hourly_limit_matcher.finish(INVALID_HOURLY_LIMIT)
286
+
287
+ limit = int(text)
288
+ await store.set_hourly_limit(event.group_id, limit)
289
+ await hourly_limit_matcher.finish(f"已设置当前群每小时请求上限:{limit} 次")
290
+
291
+
263
292
  @group_message_matcher.handle()
264
293
  async def handle_group_message(bot: Bot, event: GroupMessageEvent) -> None:
265
294
  explicit_at = any(
@@ -287,11 +316,16 @@ async def handle_group_message(bot: Bot, event: GroupMessageEvent) -> None:
287
316
  _bots[event.group_id] = bot
288
317
  await writer.record(memory_message)
289
318
  if directed:
290
- coordinator.submit(event.group_id, memory_message, explicit=True)
319
+ if await store.consume_request_slot(event.group_id):
320
+ coordinator.submit(event.group_id, memory_message, explicit=True)
321
+ else:
322
+ await bot.send_group_msg(group_id=event.group_id, message=RATE_LIMIT_NOTICE)
323
+ raise StopPropagation
291
324
  elif (
292
325
  event.reply is None
293
326
  and all(segment.type == "text" for segment in event.message)
294
327
  and await coordinator.should_random_reply(event.group_id)
328
+ and await store.consume_request_slot(event.group_id)
295
329
  ):
296
330
  coordinator.submit(event.group_id, memory_message, explicit=False)
297
331
 
@@ -7,7 +7,7 @@ import sqlite3
7
7
  import struct
8
8
  from collections.abc import AsyncIterator, Sequence
9
9
  from contextlib import asynccontextmanager, suppress
10
- from datetime import datetime, timezone
10
+ from datetime import datetime, timedelta, timezone
11
11
 
12
12
  import aiosqlite
13
13
  import sqlite_vec
@@ -41,10 +41,23 @@ _SCHEMA_STATEMENTS = (
41
41
  group_id INTEGER PRIMARY KEY,
42
42
  probability REAL NOT NULL DEFAULT 0
43
43
  CHECK (probability >= 0 AND probability <= 100),
44
+ hourly_limit INTEGER NOT NULL DEFAULT 10
45
+ CHECK (hourly_limit >= 1 AND hourly_limit <= 10000),
44
46
  updated_at TEXT NOT NULL
45
47
  )
46
48
  """,
47
49
  """
50
+ CREATE TABLE IF NOT EXISTS request_usage (
51
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
52
+ group_id INTEGER NOT NULL REFERENCES groups(group_id) ON DELETE CASCADE,
53
+ requested_at REAL NOT NULL
54
+ )
55
+ """,
56
+ """
57
+ CREATE INDEX IF NOT EXISTS idx_request_usage_group_time
58
+ ON request_usage(group_id, requested_at)
59
+ """,
60
+ """
48
61
  CREATE TABLE IF NOT EXISTS chunks (
49
62
  id INTEGER PRIMARY KEY AUTOINCREMENT,
50
63
  group_id INTEGER NOT NULL REFERENCES groups(group_id) ON DELETE CASCADE,
@@ -104,6 +117,7 @@ class MemoryStore:
104
117
  self._config = config
105
118
  self._dimensions = config.group_chat_memory_embedding_dimensions
106
119
  self._default_probability = config.group_chat_memory_default_probability
120
+ self._default_hourly_limit = config.group_chat_memory_hourly_request_limit
107
121
  self._db: aiosqlite.Connection | None = None
108
122
  self._use_sqlite_vec = False
109
123
  self._lock = asyncio.Lock()
@@ -147,6 +161,7 @@ class MemoryStore:
147
161
  async with self._transaction_on(db) as tx:
148
162
  for statement in _SCHEMA_STATEMENTS:
149
163
  await tx.execute(statement)
164
+ await self._ensure_groups_hourly_limit(tx)
150
165
  await self._ensure_dimensions(tx)
151
166
  if use_sqlite_vec:
152
167
  await tx.execute(
@@ -243,6 +258,88 @@ class MemoryStore:
243
258
  (group_id, probability, _format_timestamp(_utc_now())),
244
259
  )
245
260
 
261
+ async def get_hourly_limit(self, group_id: int) -> int:
262
+ """返回当前群每小时模型请求上限。"""
263
+ async with self._lock:
264
+ db = self._require_db()
265
+ cursor = await db.execute(
266
+ "SELECT hourly_limit FROM groups WHERE group_id = ?", (group_id,)
267
+ )
268
+ row = await cursor.fetchone()
269
+ await cursor.close()
270
+ if row is None:
271
+ return self._default_hourly_limit
272
+ return int(row["hourly_limit"])
273
+
274
+ async def set_hourly_limit(self, group_id: int, value: int) -> None:
275
+ """设置当前群每小时模型请求上限,范围 1 到 10000。"""
276
+ limit = int(value)
277
+ if not 1 <= limit <= 10000:
278
+ raise ValueError("每小时请求上限必须是 1 到 10000 的整数。")
279
+ async with self._transaction() as db:
280
+ await db.execute(
281
+ "INSERT INTO groups(group_id, probability, hourly_limit, updated_at) "
282
+ "VALUES (?, ?, ?, ?) ON CONFLICT(group_id) DO UPDATE SET "
283
+ "hourly_limit = excluded.hourly_limit, updated_at = excluded.updated_at",
284
+ (
285
+ group_id,
286
+ self._default_probability,
287
+ limit,
288
+ _format_timestamp(_utc_now()),
289
+ ),
290
+ )
291
+
292
+ async def consume_request_slot(
293
+ self, group_id: int, now: datetime | None = None
294
+ ) -> bool:
295
+ """在滚动 60 分钟窗口内尝试占用一次模型请求名额。"""
296
+ current = _as_utc(now or _utc_now())
297
+ timestamp = current.timestamp()
298
+ cutoff = (current - timedelta(hours=1)).timestamp()
299
+ async with self._transaction() as db:
300
+ await self._ensure_group(db, group_id)
301
+ await db.execute(
302
+ "DELETE FROM request_usage WHERE group_id = ? AND requested_at <= ?",
303
+ (group_id, cutoff),
304
+ )
305
+ cursor = await db.execute(
306
+ "SELECT count(*) AS usage FROM request_usage WHERE group_id = ?",
307
+ (group_id,),
308
+ )
309
+ row = await cursor.fetchone()
310
+ await cursor.close()
311
+ cursor = await db.execute(
312
+ "SELECT hourly_limit FROM groups WHERE group_id = ?", (group_id,)
313
+ )
314
+ group_row = await cursor.fetchone()
315
+ await cursor.close()
316
+ limit = (
317
+ int(group_row["hourly_limit"])
318
+ if group_row is not None
319
+ else self._default_hourly_limit
320
+ )
321
+ if row is not None and int(row["usage"]) >= limit:
322
+ return False
323
+ await db.execute(
324
+ "INSERT INTO request_usage(group_id, requested_at) VALUES (?, ?)",
325
+ (group_id, timestamp),
326
+ )
327
+ return True
328
+
329
+ async def get_hour_usage(self, group_id: int, now: datetime | None = None) -> int:
330
+ """返回当前群滚动 60 分钟窗口内已占用的请求次数。"""
331
+ cutoff = (_as_utc(now or _utc_now()) - timedelta(hours=1)).timestamp()
332
+ async with self._lock:
333
+ db = self._require_db()
334
+ cursor = await db.execute(
335
+ "SELECT count(*) AS usage FROM request_usage "
336
+ "WHERE group_id = ? AND requested_at > ?",
337
+ (group_id, cutoff),
338
+ )
339
+ row = await cursor.fetchone()
340
+ await cursor.close()
341
+ return int(row["usage"]) if row is not None else 0
342
+
246
343
  async def get_runtime_config(self) -> dict[str, str]:
247
344
  """读取全 Bot 共用的模型运行时配置。"""
248
345
  async with self._lock:
@@ -397,13 +494,14 @@ class MemoryStore:
397
494
  return [_row_to_chunk(row) for _, _, row in scored[:limit]]
398
495
 
399
496
  async def clear_group(self, group_id: int) -> None:
400
- """删除当前群的消息、片段与向量,保留概率配置。"""
497
+ """删除当前群的消息、片段、向量和请求计数,保留概率与每小时上限。"""
401
498
  async with self._transaction() as db:
402
499
  await db.execute(
403
500
  "DELETE FROM memory_vector_blobs WHERE group_id = ?", (group_id,)
404
501
  )
405
502
  if self._use_sqlite_vec:
406
503
  await db.execute("DELETE FROM memory_vectors WHERE group_id = ?", (group_id,))
504
+ await db.execute("DELETE FROM request_usage WHERE group_id = ?", (group_id,))
407
505
  await db.execute("DELETE FROM messages WHERE group_id = ?", (group_id,))
408
506
  await db.execute("DELETE FROM chunks WHERE group_id = ?", (group_id,))
409
507
 
@@ -433,11 +531,26 @@ class MemoryStore:
433
531
 
434
532
  async def _ensure_group(self, db: aiosqlite.Connection, group_id: int) -> None:
435
533
  await db.execute(
436
- "INSERT INTO groups(group_id, probability, updated_at) VALUES (?, ?, ?) "
437
- "ON CONFLICT(group_id) DO NOTHING",
438
- (group_id, self._default_probability, _format_timestamp(_utc_now())),
534
+ "INSERT INTO groups(group_id, probability, hourly_limit, updated_at) "
535
+ "VALUES (?, ?, ?, ?) ON CONFLICT(group_id) DO NOTHING",
536
+ (
537
+ group_id,
538
+ self._default_probability,
539
+ self._default_hourly_limit,
540
+ _format_timestamp(_utc_now()),
541
+ ),
439
542
  )
440
543
 
544
+ async def _ensure_groups_hourly_limit(self, db: aiosqlite.Connection) -> None:
545
+ cursor = await db.execute("PRAGMA table_info(groups)")
546
+ rows = await cursor.fetchall()
547
+ await cursor.close()
548
+ if not any(row["name"] == "hourly_limit" for row in rows):
549
+ await db.execute(
550
+ f"ALTER TABLE groups ADD COLUMN hourly_limit INTEGER NOT NULL DEFAULT "
551
+ f"{self._default_hourly_limit}"
552
+ )
553
+
441
554
  async def _ensure_dimensions(self, db: aiosqlite.Connection) -> None:
442
555
  cursor = await db.execute("SELECT value FROM meta WHERE key = ?", (DIMENSIONS_META_KEY,))
443
556
  row = await cursor.fetchone()
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nonebot-plugin-group-chat-memory"
3
- version = "0.1.2"
3
+ version = "0.1.4"
4
4
  description = "为 NoneBot2 群聊提供共享长期记忆、点名对话和按群概率回复"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "TonyLiangP2010405" }]