refactor: extract is_ban_cached method to BanConsole

This commit is contained in:
HibiKier
2025-12-24 14:54:39 +08:00
parent ff0b37123e
commit be316a5caf
17 changed files with 478 additions and 1360 deletions
@@ -1,249 +0,0 @@
# 权限检查系统 - 缓存优化指南
## DataAccess 缓存机制说明
### 已实现的缓存功能
`DataAccess` 类已经实现了完整的 Redis 缓存机制,包括:
1. **自动缓存查询结果**
```python
# 第一次查询:从数据库获取并缓存
plugin = await plugin_dao.safe_get_or_none(module="example")
# 后续查询:直接从缓存获取(如果缓存未过期)
plugin = await plugin_dao.safe_get_or_none(module="example")
```
2. **空结果缓存(5分钟TTL)**
```python
# 查询不存在的记录时,会缓存空结果标记
# 避免频繁查询数据库确认记录不存在
user = await user_dao.safe_get_or_none(user_id="nonexistent")
# 5分钟内再次查询相同记录,直接从缓存返回 None
```
3. **缓存统计**
```python
from zhenxun.services.data_access import DataAccess
# 获取缓存统计信息
stats = DataAccess.get_cache_stats()
# 返回格式:
# [
# {
# "cache_type": "GLOBAL_ALL_PLUGINS",
# "hits": 1000, # 缓存命中次数
# "null_hits": 50, # 空结果缓存命中次数
# "misses": 200, # 缓存未命中次数
# "hit_rate": "84.00%" # 缓存命中率
# },
# ...
# ]
```
4. **自动缓存失效**
- 数据创建、更新、删除时自动清除相关缓存
- 支持手动清除:`await dao.clear_cache(**kwargs)`
## 权限检查中的缓存优化
### 当前缓存使用情况
在权限检查系统中,以下数据访问已经自动使用缓存:
1. **PluginInfo(插件信息)**
```python
plugin_dao = DataAccess(PluginInfo)
plugin = await plugin_dao.safe_get_or_none(module=module)
# ✅ 自动缓存,基于 module 字段
```
2. **UserConsole(用户信息)**
```python
user_dao = DataAccess(UserConsole)
user = await user_dao.get_by_func_or_none(
UserConsole.get_user, False, user_id=user_id
)
# ✅ 自动缓存,基于 user_id 字段
```
3. **GroupConsole(群组信息)**
```python
group_dao = DataAccess(GroupConsole)
group = await group_dao.safe_get_or_none(
group_id=group_id, channel_id__isnull=True
)
# ✅ 自动缓存,基于 group_id 字段
```
4. **BanConsole(ban记录)**
```python
ban_dao = DataAccess(BanConsole)
ban = await ban_dao.safe_get_or_none(
user_id=user_id, group_id=group_id
)
# ✅ 自动缓存,基于 user_id 和 group_id
```
5. **BotConsole(Bot信息)**
```python
bot_dao = DataAccess(BotConsole)
bot = await bot_dao.safe_get_or_none(bot_id=bot_id)
# ✅ 自动缓存,基于 bot_id 字段
```
6. **LevelUser(用户权限)**
```python
level_dao = DataAccess(LevelUser)
level = await level_dao.safe_get_or_none(
user_id=user_id, group_id=group_id
)
# ✅ 自动缓存,基于 user_id 和 group_id
```
### 优化建议
#### 1. 并行获取数据,提高缓存命中率
**当前实现**:
```python
# 串行获取,可能错过缓存优势
plugin = await plugin_dao.safe_get_or_none(module=module)
user = await user_dao.get_by_func_or_none(...)
group = await group_dao.safe_get_or_none(...)
```
**优化后**:
```python
# 并行获取,DataAccess 会自动处理缓存
plugin, user, group = await asyncio.gather(
plugin_dao.safe_get_or_none(module=module),
user_dao.get_by_func_or_none(UserConsole.get_user, False, user_id=user_id),
group_dao.safe_get_or_none(group_id=group_id, channel_id__isnull=True)
if group_id else asyncio.sleep(0)
)
```
#### 2. 提前获取所有需要的数据
**当前实现**:
```python
# group 数据在 hooks 执行时才获取
# 在 auth 函数中
group = None
if entity.group_id:
group = await group_dao.safe_get_or_none(...)
```
**优化后**:
```python
# 在获取 plugin 和 user 时,同时获取 group
# 统一数据获取,充分利用缓存
async def get_all_auth_data(module, user_id, group_id):
plugin_dao = DataAccess(PluginInfo)
user_dao = DataAccess(UserConsole)
group_dao = DataAccess(GroupConsole) if group_id else None
tasks = [
plugin_dao.safe_get_or_none(module=module),
user_dao.get_by_func_or_none(UserConsole.get_user, False, user_id=user_id),
]
if group_id and group_dao:
tasks.append(
group_dao.safe_get_or_none(group_id=group_id, channel_id__isnull=True)
)
results = await asyncio.gather(*tasks)
return results
```
#### 3. 监控缓存效果
**实现方案**:
```python
from zhenxun.services.data_access import DataAccess
async def auth(...):
# 记录开始时的缓存统计
cache_stats_before = DataAccess.get_cache_stats()
try:
# 执行权限检查
...
finally:
# 记录结束时的缓存统计
cache_stats_after = DataAccess.get_cache_stats()
# 分析缓存效果
for before, after in zip(cache_stats_before, cache_stats_after):
cache_type = after["cache_type"]
hits_diff = after["hits"] - before["hits"]
misses_diff = after["misses"] - before["misses"]
total = hits_diff + misses_diff
if total > 0:
hit_rate = (hits_diff / total) * 100
logger.debug(
f"缓存统计 - {cache_type}: "
f"命中率={hit_rate:.1f}%, "
f"命中={hits_diff}, 未命中={misses_diff}",
LOGGER_COMMAND
)
# 如果缓存命中率过低,记录警告
if hit_rate < 30:
logger.warning(
f"缓存命中率过低: {cache_type} = {hit_rate:.1f}%",
LOGGER_COMMAND
)
```
#### 4. 调整空结果缓存时间
**根据实际需求调整**:
```python
from zhenxun.services.data_access import DataAccess
# 对于频繁查询但可能不存在的记录,可以增加空结果缓存时间
# 例如:ban记录查询,如果用户未被ban,可以缓存更长时间
DataAccess.set_null_result_ttl(600) # 10分钟
# 对于需要实时性的数据,可以减少缓存时间
# 例如:用户权限查询
DataAccess.set_null_result_ttl(60) # 1分钟
```
## 缓存性能优化检查清单
- [ ] 所有数据访问都通过 `DataAccess`,而不是直接使用模型类
- [ ] 并行获取多个数据,而不是串行获取
- [ ] 提前获取所有需要的数据,避免在检查过程中重复获取
- [ ] 监控缓存命中率,确保缓存效果良好
- [ ] 根据实际需求调整空结果缓存时间
- [ ] 定期查看缓存统计,优化缓存策略
## 预期缓存效果
### 高并发场景下的缓存命中率预期
- **PluginInfo(插件信息)**:90-95%(插件信息变化不频繁)
- **UserConsole(用户信息)**:80-90%(用户信息相对稳定)
- **GroupConsole(群组信息)**:85-95%(群组信息变化不频繁)
- **BanConsole(ban记录)**:70-85%(ban状态可能变化)
- **BotConsole(Bot信息)**:95-99%(Bot信息很少变化)
- **LevelUser(用户权限)**:75-85%(权限可能变化)
### 性能提升预期
- **缓存命中时**:查询时间从 10-50ms 降低到 1-5ms(减少 80-90%)
- **总体性能**:在高并发场景下,总体执行时间减少 30-50%
- **数据库压力**:减少数据库查询 40-60%
## 注意事项
1. **缓存一致性**:DataAccess 在数据更新时会自动清除缓存,确保一致性
2. **缓存失效**:如果需要强制刷新缓存,可以使用 `await dao.clear_cache(**kwargs)`
3. **监控缓存**:定期查看缓存统计,确保缓存效果良好
4. **空结果缓存**:空结果缓存有助于减少对不存在记录的查询,但需要根据实际需求调整TTL
@@ -1,373 +0,0 @@
# 权限检查系统 - 立即可以实施的改进
## 当前问题总结
经过分析,当前权限检查系统存在以下主要问题:
### 1. 检查执行顺序不合理
- **问题**:所有检查并行执行,ban 检查应该优先执行
- **影响**:用户被 ban 后,其他检查(金币、限制等)仍然会执行,浪费资源
- **优先级**:高
### 2. 缺乏早期退出机制
- **问题**:即使某个检查失败,其他检查仍会继续执行
- **影响**:浪费 CPU 和数据库资源
- **优先级**:高
### 3. 数据获取可以优化
- **问题**:group 数据在 hooks 执行时才获取,可以提前获取
- **影响**:增加总体执行时间
- **优先级**:中
### 4. 充分利用 DataAccess 的 Redis 缓存
- **现状**:`DataAccess` 已经实现了完整的 Redis 缓存机制(包括空结果缓存 5 分钟)
- **问题**:没有充分利用缓存,数据获取顺序不够优化
- **影响**:缓存命中率可能不够高,重复查询数据库
- **优先级**:高
## 立即可以实施的改进
### 改进 1:调整检查顺序,ban 检查优先执行
**位置**:`auth_checker.py` 第 498-514 行
**当前代码**:
```python
hook_tasks = [
time_hook(auth_ban(...), "auth_ban", hook_times),
time_hook(auth_bot(...), "auth_bot", hook_times),
# ... 其他检查
]
await asyncio.gather(*hook_tasks) # 并行执行
```
**改进方案**:
```python
# 1. 先执行关键检查(ban、bot),如果失败立即退出
critical_checks = [
("auth_ban", lambda: time_hook(auth_ban(...), "auth_ban", hook_times)),
("auth_bot", lambda: time_hook(auth_bot(...), "auth_bot", hook_times)),
]
for check_name, check_func in critical_checks:
try:
await check_func()
except SkipPluginException:
raise # 立即退出
except Exception as e:
logger.error(f"{check_name} 检查失败: {e}", LOGGER_COMMAND, session=session)
# 关键检查失败,可以选择继续或退出
# 2. 关键检查通过后,再并行执行其他检查
other_checks = [
time_hook(auth_group(...), "auth_group", hook_times),
time_hook(auth_admin(...), "auth_admin", hook_times),
time_hook(auth_plugin(...), "auth_plugin", hook_times),
time_hook(auth_limit(...), "auth_limit", hook_times),
]
await asyncio.gather(*other_checks)
```
### 改进 2:提前获取 group 数据
**位置**:`auth_checker.py` 第 485-493 行
**当前代码**:
```python
# 在hooks执行时才获取group
group = None
if entity.group_id:
group_dao = DataAccess(GroupConsole)
group = await with_timeout(...)
```
**改进方案**:
```python
# 在获取plugin和user时,同时获取group
# 在 get_plugin_and_user 函数中或auth函数开始处
group = None
if entity.group_id:
group_dao = DataAccess(GroupConsole)
group_task = group_dao.safe_get_or_none(
group_id=entity.group_id, channel_id__isnull=True
)
# 可以并行获取
plugin, user, group = await asyncio.gather(
plugin_task, user_task, group_task
)
```
### 改进 3:充分利用 DataAccess 的 Redis 缓存机制
**重要说明**:`DataAccess` 已经实现了完整的 Redis 缓存机制,包括:
- ✅ 自动缓存查询结果
- ✅ 空结果缓存(5 分钟 TTL,避免频繁查询不存在的记录)
- ✅ 缓存统计(命中率、命中次数等)
- ✅ 自动处理缓存的获取和设置
**优化策略**:
#### 3.1 优化数据获取顺序,提高缓存命中率
**位置**:`auth_checker.py` 的 `get_plugin_and_user` 函数和 `auth` 函数
**当前问题**:
- plugin、user、group 数据分别获取,可能错过并行获取的缓存优势
- group 数据在 hooks 执行时才获取,应该提前获取
**改进方案**:
```python
async def get_plugin_user_and_group(
module: str, user_id: str, group_id: str | None
) -> tuple[PluginInfo, UserConsole, GroupConsole | None]:
"""统一获取插件、用户和群组数据(充分利用 DataAccess 缓存)
注意:DataAccess 会自动使用 Redis 缓存,包括:
- 查询结果缓存
- 空结果缓存(5分钟)
- 缓存统计
"""
user_dao = DataAccess(UserConsole)
plugin_dao = DataAccess(PluginInfo)
group_dao = DataAccess(GroupConsole) if group_id else None
# 并行获取所有数据,DataAccess 会自动处理缓存
tasks = [
plugin_dao.safe_get_or_none(module=module),
user_dao.get_by_func_or_none(
UserConsole.get_user, False, user_id=user_id
)
]
if group_id and group_dao:
tasks.append(
group_dao.safe_get_or_none(
group_id=group_id, channel_id__isnull=True
)
)
else:
tasks.append(asyncio.create_task(asyncio.sleep(0))) # 占位
try:
results = await with_timeout(
asyncio.gather(*tasks), name="get_plugin_user_group"
)
plugin = results[0]
user = results[1]
group = results[2] if len(results) > 2 and group_id else None
# 验证数据
if not plugin:
raise PermissionExemption(f"插件:{module} 数据不存在...")
if plugin.plugin_type == PluginType.HIDDEN:
raise PermissionExemption(f"插件: {plugin.name}:{plugin.module} 为HIDDEN...")
if not user:
raise PermissionExemption("用户数据不存在...")
return plugin, user, group
except asyncio.TimeoutError:
# 超时时尝试从缓存获取(DataAccess 已经缓存了)
logger.warning(
f"数据获取超时,尝试从缓存获取,模块: {module}",
LOGGER_COMMAND
)
# DataAccess 的缓存会自动生效,这里只需要重试一次
plugin = await plugin_dao.safe_get_or_none(module=module)
user = await user_dao.get_by_func_or_none(
UserConsole.get_user, False, user_id=user_id
)
group = None
if group_id and group_dao:
group = await group_dao.safe_get_or_none(
group_id=group_id, channel_id__isnull=True
)
if not plugin or not user:
raise PermissionExemption("获取数据失败,请稍后再试...")
return plugin, user, group
```
#### 3.2 监控缓存命中率
**位置**:在权限检查中添加缓存统计监控
**实现方案**:
```python
from zhenxun.services.data_access import DataAccess
# 在 auth 函数开始和结束时记录缓存统计
async def auth(...):
# 记录开始时的缓存统计
cache_stats_before = DataAccess.get_cache_stats()
try:
# ... 执行权限检查 ...
pass
finally:
# 记录结束时的缓存统计
cache_stats_after = DataAccess.get_cache_stats()
# 计算本次检查的缓存命中情况
for before, after in zip(cache_stats_before, cache_stats_after):
hits_diff = after["hits"] - before["hits"]
misses_diff = after["misses"] - before["misses"]
total = hits_diff + misses_diff
if total > 0:
hit_rate = (hits_diff / total) * 100
if hit_rate < 50: # 缓存命中率低于50%时记录警告
logger.warning(
f"缓存命中率较低: {after['cache_type']} = {hit_rate:.1f}%",
LOGGER_COMMAND
)
```
#### 3.3 优化检查函数中的数据访问
**说明**:各个检查函数(auth_ban、auth_bot 等)中使用的 `DataAccess` 已经自动缓存,无需额外处理。
**建议**:
- 确保所有数据访问都通过 `DataAccess`,而不是直接使用模型类
- 对于频繁查询的数据(如 ban 记录),`DataAccess` 会自动缓存
- 空结果也会被缓存 5 分钟,避免频繁查询不存在的记录
### 改进 4:统一错误处理策略
**位置**:各个检查函数
**当前问题**:
- `auth_ban` 超时时不阻塞
- `auth_bot` 超时时不阻塞
- `auth_limit` 超时时不阻塞
- 但 `get_plugin_and_user` 超时时会抛出异常
**改进方案**:
```python
# 定义统一的超时处理策略
class TimeoutStrategy:
FAIL_FAST = "fail_fast" # 超时立即失败
CONTINUE = "continue" # 超时继续执行
CACHE_FALLBACK = "cache_fallback" # 超时使用缓存
# 根据检查的重要性设置策略
CHECK_TIMEOUT_STRATEGY = {
"auth_ban": TimeoutStrategy.FAIL_FAST, # 关键检查,超时失败
"auth_bot": TimeoutStrategy.FAIL_FAST, # 关键检查,超时失败
"auth_group": TimeoutStrategy.CONTINUE, # 非关键,超时继续
"auth_limit": TimeoutStrategy.CONTINUE, # 非关键,超时继续
"get_plugin_and_user": TimeoutStrategy.CACHE_FALLBACK, # 使用缓存降级
}
```
## 实施优先级
### 高优先级(立即实施)
1. ✅ **调整检查顺序**:ban 检查优先执行
- 风险:低
- 收益:高
- 工作量:小(1-2 小时)
### 中优先级(本周实施)
2. ✅ **提前获取 group 数据,统一数据获取**
- 风险:低
- 收益:高(充分利用 DataAccess 缓存)
- 工作量:小(1-2 小时)
3. ✅ **优化数据获取顺序,提高缓存命中率**
- 风险:低(DataAccess 已实现缓存)
- 收益:高(减少数据库查询)
- 工作量:小(1 小时)
4. ✅ **添加缓存统计监控**
- 风险:低
- 收益:中(可以监控缓存效果)
- 工作量:小(30 分钟)
### 低优先级(后续优化)
4. ⏳ **统一错误处理策略**
- 风险:中(可能影响现有逻辑)
- 收益:中
- 工作量:中(3-4 小时)
## 代码示例:改进后的 auth 函数结构
```python
async def auth(...):
# 1. 获取所有需要的数据(并行)
plugin, user, group = await gather_all_data(...)
# 2. 关键检查(串行,失败立即退出)
await critical_checks(plugin, user, group, ...)
# 3. 其他检查(并行)
await other_checks(plugin, user, group, ...)
# 4. 金币检查(最后执行,超时不影响)
cost_gold = await check_cost(user, plugin, ...)
```
## 注意事项
1. **向后兼容**:确保改进不影响现有功能
2. **充分测试**:特别是缓存相关的改进
3. **监控指标**:记录改进前后的性能指标
4. **逐步实施**:先实施低风险改进,再实施高风险改进
## 预期效果
- **性能提升**:总体执行时间减少 20-30%
- **资源节省**:减少不必要的检查执行 30-50%
- **缓存优化**:充分利用 DataAccess 的 Redis 缓存,缓存命中率提升 40-60%
- **数据库压力**:减少数据库查询 30-50%(通过缓存)
- **用户体验**:响应速度提升,特别是在高并发场景下
## 关于 DataAccess 缓存的说明
### DataAccess 已实现的缓存机制
1. **自动缓存查询结果**
- 所有通过 `DataAccess.safe_get_or_none()` 等方法的查询结果都会自动缓存
- 缓存键基于模型的主键字段(如 `user_id`、`module` 等)
2. **空结果缓存**
- 查询结果为 `None` 时,会缓存一个特殊标记(`_NULL_RESULT`)
- 默认 TTL 为 5 分钟,避免频繁查询不存在的记录
- 可通过 `DataAccess.set_null_result_ttl()` 调整
3. **缓存统计**
- 自动统计缓存命中率、命中次数、未命中次数等
- 可通过 `DataAccess.get_cache_stats()` 获取统计信息
4. **缓存失效**
- 数据更新、创建、删除时自动清除相关缓存
- 支持手动清除:`DataAccess.clear_cache(**kwargs)`
### 优化建议
1. **统一使用 DataAccess**:确保所有数据访问都通过 `DataAccess`,而不是直接使用模型类
2. **并行获取数据**:利用 `asyncio.gather()` 并行获取多个数据,提高缓存命中率
3. **监控缓存效果**:定期查看 `DataAccess.get_cache_stats()`,了解缓存命中率
4. **调整空结果 TTL**:根据实际需求调整 `DataAccess.set_null_result_ttl()`,平衡缓存效果和实时性
@@ -1,234 +0,0 @@
# 权限检查系统优化方案
## 当前设计分析
### 优点
1. ✅ 模块化设计,每个检查独立
2. ✅ 并行执行检查,提高效率
3. ✅ 有超时控制和重试机制
4. ✅ 有缓存降级策略
5. ✅ 有并发控制(信号量)
### 存在的问题
#### 1. **缺乏优先级机制**
- 所有检查并行执行,没有优先级区分
- ban检查应该优先执行,如果用户被ban,其他检查就不需要执行
- 浪费计算资源
#### 2. **缺乏早期退出机制**
- 即使某个检查已经失败,其他检查仍然会继续执行
- 例如:用户被ban了,但金币检查、限制检查等仍然会执行
#### 3. **数据获取分散**
- 每个检查函数可能都需要查询数据库
- 缺乏统一的数据上下文管理
- 可能导致重复查询
#### 4. **错误处理不一致**
- 有些检查超时时抛出异常
- 有些检查超时时只是记录日志继续执行
- 缺乏统一的错误处理策略
#### 5. **缺乏检查结果缓存**
- 对于相同的请求(相同的user_id, group_id, plugin),可能会重复执行相同的检查
- ban检查、bot检查等结果可以缓存一段时间
## 优化方案
### 方案1:优先级分层检查(推荐)
#### 核心思想
将检查分为多个优先级阶段,高优先级检查失败时立即退出,不执行后续检查。
#### 检查优先级划分
```
阶段1(CRITICAL - 关键检查):
- auth_ban: 用户/群组ban检查
- auth_bot: Bot状态检查
→ 如果失败,立即退出
阶段2(HIGH - 高优先级):
- auth_plugin: 插件状态检查
- auth_group: 群组状态检查
→ 并行执行,任一失败则退出
阶段3(MEDIUM - 中等优先级):
- auth_admin: 管理员权限检查
- auth_limit: 限制检查
→ 并行执行,任一失败则退出
阶段4(LOW - 低优先级):
- auth_cost: 金币检查
→ 超时不影响其他检查
```
#### 实现要点
1. **统一数据上下文**
```python
@dataclass
class AuthContext:
"""统一管理所有需要的数据"""
plugin: PluginInfo
user: UserConsole
group: GroupConsole | None
bot_id: str
entity: EntityIDs
# ... 其他数据
```
2. **早期退出机制**
```python
# 阶段1检查
for check in critical_checks:
result = await execute_check(check)
if not result.success:
raise result.error # 立即退出,不执行后续检查
```
3. **检查结果缓存**
```python
# 对ban、bot等检查结果进行缓存
cache_key = f"ban:{user_id}:{group_id}:{module}"
if cached_result := get_cached_result(cache_key):
return cached_result
```
### 方案2:责任链模式
#### 核心思想
使用责任链模式,按优先级顺序执行检查,任一检查失败则中断链。
#### 优点
- 清晰的检查顺序
- 易于添加新的检查
- 支持动态调整检查顺序
#### 缺点
- 无法充分利用并行执行的优势
- 可能增加总体执行时间
### 方案3:混合模式(最佳实践)
#### 核心思想
结合优先级和并行执行:
- 关键检查(ban、bot)串行执行,失败立即退出
- 同优先级检查并行执行
- 不同优先级按顺序执行
## 具体优化建议
### 1. 立即实施的优化(低风险)
#### a) 调整检查顺序
```python
# 在 auth_checker.py 中调整检查顺序
# 1. 先执行ban检查(如果失败,立即退出)
# 2. 再执行其他检查
```
#### b) 统一数据获取
```python
# 在auth函数开始处,统一获取所有需要的数据
plugin, user, group = await gather_all_data(...)
# 然后传递给各个检查函数
```
#### c) 添加检查结果缓存
```python
# 对ban检查结果缓存30秒
@lru_cache(maxsize=1000, ttl=30)
async def check_ban_cached(user_id, group_id):
return await is_ban(user_id, group_id)
```
### 2. 中期优化(需要重构)
#### a) 实现优先级机制
- 将检查函数注册到优先级系统
- 按优先级顺序执行
- 高优先级失败时立即退出
#### b) 统一错误处理
- 定义统一的检查结果类型
- 所有检查返回统一格式的结果
- 统一的超时处理策略
### 3. 长期优化(架构级改进)
#### a) 检查结果持久化
- 将检查结果存储到Redis
- 支持分布式缓存
- 减少数据库查询
#### b) 异步检查队列
- 对于非关键检查,可以异步执行
- 使用消息队列处理检查任务
- 提高响应速度
## 性能提升预期
### 当前性能
- 所有检查并行执行:~200-500ms
- 高并发时可能超时
### 优化后预期
- 关键检查优先:~50-100ms(如果失败)
- 完整检查:~150-300ms
- 缓存命中:~10-20ms
### 资源节省
- 减少不必要的检查执行:~30-50%
- 减少数据库查询:~20-30%
- 提高并发处理能力:~40-60%
## 实施步骤
### 阶段1:准备(1-2天)
1. 分析当前检查函数的依赖关系
2. 确定优先级划分
3. 设计统一的数据上下文
### 阶段2:实现(3-5天)
1. 实现优先级机制
2. 实现早期退出机制
3. 实现检查结果缓存
4. 统一错误处理
### 阶段3:测试(2-3天)
1. 单元测试
2. 集成测试
3. 性能测试
4. 压力测试
### 阶段4:部署(1天)
1. 灰度发布
2. 监控性能指标
3. 逐步全量
## 风险评估
### 低风险
- 调整检查顺序
- 添加缓存
- 统一数据获取
### 中风险
- 实现优先级机制(需要充分测试)
- 早期退出机制(可能影响某些边界情况)
### 高风险
- 架构级重构(需要完整的测试覆盖)
## 建议
1. **先实施低风险优化**:立即调整检查顺序,添加缓存
2. **逐步实施中风险优化**:实现优先级机制,充分测试
3. **长期规划高风险优化**:根据实际需求决定是否进行架构级重构
## 参考实现
详见 `auth_checker_optimized.py`(设计示例,仅供参考)
+3 -19
View File
@@ -9,11 +9,9 @@ from nonebot_plugin_uninfo import Uninfo
from zhenxun.configs.config import Config from zhenxun.configs.config import Config
from zhenxun.models.ban_console import BanConsole from zhenxun.models.ban_console import BanConsole
from zhenxun.models.plugin_info import PluginInfo from zhenxun.models.plugin_info import PluginInfo
from zhenxun.services.cache import CacheRoot
from zhenxun.services.data_access import DataAccess
from zhenxun.services.db_context import DB_TIMEOUT_SECONDS from zhenxun.services.db_context import DB_TIMEOUT_SECONDS
from zhenxun.services.log import logger from zhenxun.services.log import logger
from zhenxun.utils.enum import CacheType, PluginType from zhenxun.utils.enum import PluginType
from zhenxun.utils.utils import EntityIDs, get_entity_ids from zhenxun.utils.utils import EntityIDs, get_entity_ids
from .config import LOGGER_COMMAND, WARNING_THRESHOLD from .config import LOGGER_COMMAND, WARNING_THRESHOLD
@@ -164,23 +162,9 @@ async def auth_ban(
if entity.user_id in bot.config.superusers: if entity.user_id in bot.config.superusers:
return return
cache_key = f"{entity.user_id}_{entity.group_id}" results = await BanConsole.is_ban_cached(entity.user_id, entity.group_id)
results = await CacheRoot.get(CacheType.BAN, cache_key)
if not results: if not results:
results = await BanConsole.is_ban(entity.user_id, entity.group_id) return
await CacheRoot.set(
CacheType.BAN,
cache_key,
results or DataAccess._NULL_RESULT,
)
else:
if results == DataAccess._NULL_RESULT:
return
tmp_results: list[BanConsole] = []
for r in results:
tmp_results.append(CacheRoot._deserialize_value(r, BanConsole))
results = tmp_results
for result in results: for result in results:
if not result.user_id and result.group_id: if not result.user_id and result.group_id:
+59
View File
@@ -0,0 +1,59 @@
import asyncio
import random
from arclet.alconna import Args
from nonebot import get_driver
from nonebot.adapters.onebot.v11 import (
Bot,
Event,
GroupMessageEvent,
Message,
PrivateMessageEvent,
)
from nonebot.compat import model_dump, type_validate_python
from nonebot_plugin_alconna import Alconna, on_alconna
from zhenxun.services.log import logger
tasks: set["asyncio.Task"] = set()
@get_driver().on_shutdown
async def cancel_tasks():
for task in tasks:
if not task.done():
task.cancel()
await asyncio.gather(
*(asyncio.wait_for(task, timeout=10) for task in tasks),
return_exceptions=True,
)
def push_event(bot: Bot, event: PrivateMessageEvent | GroupMessageEvent):
event.message = Message("签到")
event.user_id = random.randint(1, 99999999999) + random.randint(1, 99999999999)
task = asyncio.create_task(bot.handle_event(event))
task.add_done_callback(tasks.discard)
tasks.add(task)
logger.info(f"发送消息 --> {event.user_id} {event.message}")
return event
_matcher = on_alconna(
Alconna("test", Args["n", int]), priority=5, block=True, temp=True
)
@_matcher.handle()
async def handle_event(event: Event, bot: Bot, n: int):
for _ in range(n):
data = model_dump(event)
if data.get("message_type") == "private":
data["post_type"] = "message"
push_event(bot, type_validate_python(PrivateMessageEvent, data))
elif data.get("message_type") == "group":
data["post_type"] = "message"
push_event(bot, type_validate_python(GroupMessageEvent, data))
await asyncio.sleep(0.1)
logger.info(f"发送消息次数 --> {_ + 1}")
+34
View File
@@ -181,6 +181,40 @@ class BanConsole(Model):
return results return results
@classmethod
async def is_ban_cached(
cls, user_id: str | None, group_id: str | None
) -> list[Self]:
"""带缓存的 ban 状态检查
参数:
user_id: 用户id
group_id: 群组id
返回:
list[Self]: ban记录列表,空列表表示未被ban
"""
from zhenxun.services.cache import CacheRoot
from zhenxun.services.data_access import DataAccess
from zhenxun.utils.enum import CacheType
cache_key = f"{user_id}_{group_id}"
results = await CacheRoot.get(CacheType.BAN, cache_key)
if not results:
results = await cls.is_ban(user_id, group_id)
await CacheRoot.set(
CacheType.BAN,
cache_key,
results or DataAccess._NULL_RESULT,
)
return results
if results == DataAccess._NULL_RESULT:
return []
return [CacheRoot._deserialize_value(r, cls) for r in results]
@classmethod @classmethod
async def ban( async def ban(
cls, cls,
-187
View File
@@ -1,187 +0,0 @@
from collections import OrderedDict
import random
from typing import Any
from nonebot import on_message
from nonebot.adapters import Event
from nonebot.plugin import PluginMetadata
from nonebot_plugin_alconna import Image, UniMsg
from nonebot_plugin_uninfo import Uninfo
from zhenxun.configs.config import Config
from zhenxun.configs.path_config import TEMP_PATH
from zhenxun.configs.utils import PluginExtraData, RegisterConfig, Task
from zhenxun.utils.common_utils import CommonUtils
from zhenxun.utils.enum import PluginType
from zhenxun.utils.image_utils import get_download_image_hash
from zhenxun.utils.message import MessageUtils
__plugin_meta__ = PluginMetadata(
name="复读",
description="群友的本质是什么?是复读机哒!",
usage="""
usage:
重复3次相同的消息时会复读
""".strip(),
extra=PluginExtraData(
author="HibiKier",
version="0.2",
menu_type="其他",
plugin_type=PluginType.DEPENDANT,
tasks=[Task(module="fudu", name="复读")],
ignore_prompt=True,
configs=[
RegisterConfig(
key="FUDU_PROBABILITY",
value=0.7,
help="复读概率",
default_value=0.7,
type=float,
),
RegisterConfig(
module="_task",
key="DEFAULT_FUDU",
value=True,
help="被动 复读 进群默认开关状态",
default_value=True,
type=bool,
),
],
).to_dict(),
)
class Fudu:
"""复读数据管理器,使用 LRU 策略限制内存占用"""
MAX_GROUPS = 500 # 最大缓存群组数量
def __init__(self):
# 使用 OrderedDict 实现 LRU 缓存
self._data: OrderedDict[str, dict[str, Any]] = OrderedDict()
def _get_or_create(self, key: str) -> dict[str, Any]:
"""获取或创建群组数据,同时维护 LRU 顺序"""
if key in self._data:
# 移动到末尾(最近使用)
self._data.move_to_end(key)
return self._data[key]
# 如果超出最大限制,删除最旧的条目
while len(self._data) >= self.MAX_GROUPS:
self._data.popitem(last=False)
self._data[key] = {"is_repeater": False, "data": []}
return self._data[key]
def append(self, key: str, content: str) -> None:
"""添加消息内容"""
self._get_or_create(key)["data"].append(content)
def clear(self, key: str) -> None:
"""清空群组的复读数据"""
group_data = self._get_or_create(key)
group_data["data"] = []
group_data["is_repeater"] = False
def size(self, key: str) -> int:
"""获取当前消息数量"""
return len(self._get_or_create(key)["data"])
def check(self, key: str, content: str) -> bool:
"""检查内容是否与第一条消息相同"""
data_list = self._get_or_create(key)["data"]
return bool(data_list) and data_list[0] == content
def get_first(self, key: str) -> str | None:
"""获取第一条消息内容"""
data_list = self._get_or_create(key)["data"]
return data_list[0] if data_list else None
def is_repeater(self, key: str) -> bool:
"""检查是否已经复读过"""
return self._get_or_create(key)["is_repeater"]
def set_repeater(self, key: str) -> None:
"""标记已复读"""
self._get_or_create(key)["is_repeater"] = True
_manager = Fudu()
base_config = Config.get("fudu")
async def rule(message: UniMsg, session: Uninfo, event: Event) -> bool:
"""消息匹配规则:仅匹配群聊中的有效消息"""
if not session.group:
return False
if event.is_tome():
return False
plain_text = message.extract_plain_text().strip()
image_list = [m.url for m in message if isinstance(m, Image) and m.url]
if not plain_text and not image_list:
return False
return not await CommonUtils.task_is_block(
session, "fudu", session.group.id if session.group else None
)
_matcher = on_message(rule=rule, priority=999)
@_matcher.handle()
async def _(message: UniMsg, session: Uninfo):
# rule 已经确保 session.group 存在
group_id = session.group.id # type: ignore
plain_text = message.extract_plain_text().strip()
image_list = [m.url for m in message if isinstance(m, Image) and m.url]
# 计算图片哈希(如果有图片)
img_hash = ""
if image_list:
img_hash = await get_download_image_hash(image_list[0], group_id)
add_msg = f"{plain_text}|-|{img_hash}"
# 更新复读状态
if _manager.size(group_id) == 0 or _manager.check(group_id, add_msg):
_manager.append(group_id, add_msg)
else:
_manager.clear(group_id)
_manager.append(group_id, add_msg)
# 检查是否触发复读
if _manager.size(group_id) <= 2:
return
if _manager.is_repeater(group_id):
return
if random.random() >= base_config.get("FUDU_PROBABILITY"):
return
# 20% 概率打断施法
if random.random() < 0.2:
is_interrupt = plain_text.replace("打断", "").strip() == "施法"
if plain_text.startswith("打断") and is_interrupt:
await MessageUtils.build_message(f"打断{plain_text}").finish()
else:
await MessageUtils.build_message("打断施法!").finish()
# 执行复读
_manager.set_repeater(group_id)
if image_list and plain_text:
result = MessageUtils.build_message(
[plain_text, TEMP_PATH / f"compare_download_{group_id}_img.jpg"]
)
elif image_list:
result = MessageUtils.build_message(
TEMP_PATH / f"compare_download_{group_id}_img.jpg"
)
elif plain_text:
result = MessageUtils.build_message(plain_text)
else:
return
await result.finish()
-283
View File
@@ -1,283 +0,0 @@
import base64
from collections import OrderedDict, deque
from pathlib import Path
import random
from typing import Any
from nonebot import on_message
from nonebot.adapters import Bot, Event
from nonebot.adapters.onebot.v11 import Message as V11Message
from nonebot.adapters.onebot.v11 import MessageSegment
from nonebot.plugin import PluginMetadata
from nonebot.utils import run_sync
from nonebot_plugin_alconna import Image as alcImg
from nonebot_plugin_alconna import UniMsg
from nonebot_plugin_uninfo import Uninfo
from zhenxun.configs.config import BotConfig, Config
from zhenxun.configs.path_config import DATA_PATH, TEMP_PATH
from zhenxun.configs.utils import PluginExtraData, RegisterConfig, Task
from zhenxun.services.log import logger
from zhenxun.utils.common_utils import CommonUtils
from zhenxun.utils.enum import PluginType
from zhenxun.utils.http_utils import AsyncHttpx
from zhenxun.utils.image_utils import get_img_hash
from zhenxun.utils.message import MessageUtils
FUDU_IMAGE_PATH = DATA_PATH / "fudu"
FUDU_IMAGE_PATH.mkdir(parents=True, exist_ok=True)
FUDU_CACHE_PATH = TEMP_PATH / "fudu"
FUDU_CACHE_PATH.mkdir(parents=True, exist_ok=True)
# 支持的图片扩展名
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp"}
__plugin_meta__ = PluginMetadata(
name="复读",
description="群友的本质是什么?是复读机哒!",
usage="""
usage:
重复3次相同的消息时会复读
""".strip(),
extra=PluginExtraData(
author="HibiKier",
version="0.3",
menu_type="其他",
plugin_type=PluginType.DEPENDANT,
tasks=[Task(module="fudu", name="复读")],
ignore_prompt=True,
configs=[
RegisterConfig(
key="FUDU_PROBABILITY",
value=0.8,
help="复读概率",
default_value=0.8,
type=float,
),
RegisterConfig(
module="_task",
key="DEFAULT_FUDU",
value=True,
help="被动 复读 进群默认开关状态",
default_value=True,
type=bool,
),
RegisterConfig(
key="FUDU_TRIGGER_COUNT",
value=3,
help="触发复读所需的消息重复次数",
default_value=3,
type=int,
),
RegisterConfig(
key="FUDU_BREAK_PROBABILITY",
value=0.2,
help="打断复读的概率(基于复读概率)",
default_value=0.2,
type=float,
),
RegisterConfig(
key="FUDU_BREAK_TEXTS",
value=["打断施法!"],
help="用于打断复读时随机发送的文本列表",
type=list[str],
),
RegisterConfig(
key="FUDU_BREAK_USE_IMAGE",
value=True,
help="是否启用图片作为打断复读的内容 (图片存放于 data/fudu/ 目录下)",
default_value=True,
type=bool,
),
RegisterConfig(
key="FUDU_BREAK_USE_TEXT",
value=True,
help="是否启用文本作为打断复读的内容",
default_value=True,
type=bool,
),
],
).to_dict(),
)
class Fudu:
"""复读数据管理器,使用 LRU 策略限制内存占用"""
MAX_GROUPS = 500 # 最大缓存群组数量
def __init__(self):
self._data: OrderedDict[str, dict[str, Any]] = OrderedDict()
def _get_or_create(self, key: str) -> dict[str, Any]:
"""获取或创建群组数据,同时维护 LRU 顺序"""
if key in self._data:
self._data.move_to_end(key)
return self._data[key]
# 超出限制时删除最旧的条目
while len(self._data) >= self.MAX_GROUPS:
self._data.popitem(last=False)
trigger_count = Config.get_config("fudu", "FUDU_TRIGGER_COUNT", 3)
self._data[key] = {
"is_repeater": False,
"data": deque(maxlen=trigger_count),
"message_obj": None,
"reply_info": None,
}
return self._data[key]
def append(self, key: str, content: str, msg_obj: Any, reply_info: Any) -> None:
"""添加消息内容及原始消息对象"""
group_data = self._get_or_create(key)
group_data["data"].append(content)
group_data["message_obj"] = msg_obj
group_data["reply_info"] = reply_info
def clear(self, key: str) -> None:
"""清空群组的复读数据"""
group_data = self._get_or_create(key)
group_data["data"].clear()
group_data["is_repeater"] = False
group_data["message_obj"] = None
group_data["reply_info"] = None
def size(self, key: str) -> int:
"""获取当前消息数量"""
return len(self._get_or_create(key)["data"])
def check(self, key: str, content: str) -> bool:
"""检查内容是否与第一条消息相同"""
data_list = self._get_or_create(key)["data"]
return bool(data_list) and data_list[0] == content
def get_repeat_target(self, key: str) -> tuple[Any, Any]:
"""获取要复读的消息对象和回复信息"""
group_data = self._get_or_create(key)
return group_data["message_obj"], group_data["reply_info"]
def is_repeater(self, key: str) -> bool:
"""检查是否已经复读过"""
return self._get_or_create(key)["is_repeater"]
def set_repeater(self, key: str) -> None:
"""标记已复读"""
self._get_or_create(key)["is_repeater"] = True
_manager = Fudu()
base_config = Config.get("fudu")
def get_break_images() -> list[Path]:
"""获取打断复读用的图片列表"""
if not FUDU_IMAGE_PATH.exists():
return []
return [
f
for f in FUDU_IMAGE_PATH.iterdir()
if f.is_file() and f.suffix.lower() in IMAGE_EXTENSIONS
]
async def send_break_response() -> None:
"""发送打断复读的响应"""
response_pool: list[str | Path] = []
if base_config.get("FUDU_BREAK_USE_TEXT"):
if break_texts := base_config.get("FUDU_BREAK_TEXTS"):
response_pool.extend(break_texts)
if base_config.get("FUDU_BREAK_USE_IMAGE"):
response_pool.extend(get_break_images())
if not response_pool:
response_pool.append("打断施法!")
response = random.choice(response_pool)
if isinstance(response, Path):
file_data = f"base64://{base64.b64encode(response.read_bytes()).decode()}"
await _matcher.finish(
MessageSegment("image", {"file": file_data, "sub_type": "1"})
)
else:
await MessageUtils.build_message(response).finish()
async def rule(message: UniMsg, session: Uninfo, event: Event) -> bool:
"""消息匹配规则:仅匹配群聊中的有效消息"""
if not session.group:
return False
if event.is_tome():
return False
plain_text = message.extract_plain_text()
image_list = [m.url for m in message if isinstance(m, alcImg) and m.url]
if not plain_text and not image_list:
return False
return not await CommonUtils.task_is_block(session, "fudu")
_matcher = on_message(rule=rule, priority=999)
@_matcher.handle()
async def _(bot: Bot, message: UniMsg, event: Event, session: Uninfo):
# rule 已确保 session.group 存在
group_id = session.group.id # type: ignore
raw_message = event.get_message()
reply_info = getattr(event, "reply", None)
plain_text = message.extract_plain_text()
image_list = [m.url for m in message if isinstance(m, alcImg) and m.url]
# 检测虚空艾特
if plain_text and plain_text.startswith(f"@可爱的{BotConfig.self_nickname}"):
await MessageUtils.build_message("复制粘贴的虚空艾特?").send(reply_to=True)
# 计算图片哈希
img_hash = ""
if image_list:
temp_image_path = FUDU_CACHE_PATH / f"fudu_cache_{group_id}.jpg"
try:
if await AsyncHttpx.download_file(image_list[0], temp_image_path):
img_hash = await run_sync(get_img_hash)(temp_image_path)
except Exception as e:
logger.warning("下载复读图片以获取Hash时出错", "复读", e=e)
add_msg = f"{plain_text}|-|{img_hash}"
# 更新复读状态
if _manager.size(group_id) == 0 or _manager.check(group_id, add_msg):
_manager.append(group_id, add_msg, raw_message, reply_info)
else:
_manager.clear(group_id)
_manager.append(group_id, add_msg, raw_message, reply_info)
# 检查是否触发复读
trigger_count = base_config.get("FUDU_TRIGGER_COUNT")
if _manager.size(group_id) < trigger_count:
return
if _manager.is_repeater(group_id):
return
if random.random() >= base_config.get("FUDU_PROBABILITY"):
return
# 判断是否打断复读
if random.random() < base_config.get("FUDU_BREAK_PROBABILITY"):
_manager.clear(group_id)
await send_break_response()
# 执行复读
message_to_send, reply = _manager.get_repeat_target(group_id)
if not message_to_send:
return
_manager.set_repeater(group_id)
# 处理回复消息
if reply and isinstance(message_to_send, V11Message):
message_to_send.insert(0, MessageSegment.reply(reply.message_id))
await _matcher.finish(message_to_send)
+20
View File
@@ -0,0 +1,20 @@
from pathlib import Path
import nonebot
from nonebot.plugin import PluginMetadata
from zhenxun.configs.utils import PluginExtraData
from zhenxun.utils.enum import PluginType
__plugin_meta__ = PluginMetadata(
name="刷屏禁言检测",
description="",
usage="",
extra=PluginExtraData(
author="HibiKier",
version="0.1-473ecd8",
plugin_type=PluginType.PARENT,
).to_dict(),
)
nonebot.load_plugins(str(Path(__file__).parent.resolve()))
+122
View File
@@ -0,0 +1,122 @@
import time
from pydantic import BaseModel
import ujson as json
from zhenxun.configs.config import Config
from zhenxun.configs.path_config import DATA_PATH
base_config = Config.get("mute_setting")
class GroupData(BaseModel):
count: int
"""次数"""
time: int
"""检测时长"""
duration: int
"""禁言时长"""
message_data: dict = {}
"""消息存储"""
class MuteManager:
file = DATA_PATH / "group_mute_data.json"
def __init__(self) -> None:
self._group_data: dict[str, GroupData] = {}
if self.file.exists():
_data = json.load(open(self.file))
for gid in _data:
self._group_data[gid] = GroupData(
count=_data[gid]["count"],
time=_data[gid]["time"],
duration=_data[gid]["duration"],
)
def get_group_data(self, group_id: str) -> GroupData:
"""获取群组数据
参数:
group_id: 群组id
返回:
GroupData: GroupData
"""
if group_id not in self._group_data:
self._group_data[group_id] = GroupData(
count=base_config.get("MUTE_DEFAULT_COUNT", 10) or 10,
time=base_config.get("MUTE_DEFAULT_TIME", 7) or 7,
duration=base_config.get("MUTE_DEFAULT_DURATION", 10) or 10,
)
return self._group_data[group_id]
def reset(self, user_id: str, group_id: str):
"""重置用户检查次数
参数:
user_id: 用户id
group_id: 群组id
"""
if group_data := self._group_data.get(group_id):
if user_id in group_data.message_data:
group_data.message_data[user_id]["count"] = 0
def save_data(self):
"""保存数据"""
data = {}
for gid in self._group_data:
data[gid] = {
"count": self._group_data[gid].count,
"time": self._group_data[gid].time,
"duration": self._group_data[gid].duration,
}
with open(self.file, "w") as f:
json.dump(data, f, indent=4, ensure_ascii=False)
def add_message(self, user_id: str, group_id: str, message: str) -> int:
"""添加消息
参数:
user_id: 用户id
group_id: 群组id
message: 消息内容
返回:
int: 禁言时长
"""
if group_id not in self._group_data:
self._group_data[group_id] = GroupData(
count=base_config.get("MUTE_DEFAULT_COUNT"),
time=base_config.get("MUTE_DEFAULT_TIME"),
duration=base_config.get("MUTE_DEFAULT_DURATION"),
)
group_data = self._group_data[group_id]
if group_data.duration == 0:
return 0
message_data = group_data.message_data
if not message_data.get(user_id):
message_data[user_id] = {
"time": time.time(),
"count": 1,
"message": message,
}
else:
if message.find(message_data[user_id]["message"]) != -1:
message_data[user_id]["count"] += 1
else:
message_data[user_id]["time"] = time.time()
message_data[user_id]["count"] = 1
message_data[user_id]["message"] = message
if time.time() - message_data[user_id]["time"] > group_data.time:
message_data[user_id]["time"] = time.time()
message_data[user_id]["count"] = 1
if (
message_data[user_id]["count"] > group_data.count
and time.time() - message_data[user_id]["time"] < group_data.time
):
return group_data.duration
return 0
mute_manager = MuteManager()
+78
View File
@@ -0,0 +1,78 @@
from nonebot import on_message
from nonebot.adapters import Bot
from nonebot.plugin import PluginMetadata
from nonebot_plugin_alconna import Image, UniMsg
from nonebot_plugin_uninfo import Uninfo
from zhenxun.configs.config import BotConfig
from zhenxun.configs.utils import PluginExtraData
from zhenxun.models.ban_console import BanConsole
from zhenxun.services.data_access import DataAccess
from zhenxun.services.log import logger
from zhenxun.utils.enum import PluginType
from zhenxun.utils.image_utils import get_download_image_hash
from zhenxun.utils.message import MessageUtils
from zhenxun.utils.platform import PlatformUtils
from zhenxun.utils.utils import FreqLimiter, get_entity_ids
from ._data_source import mute_manager
__plugin_meta__ = PluginMetadata(
name="刷屏监听",
description="",
usage="",
extra=PluginExtraData(
author="HibiKier",
version="0.1-473ecd8",
menu_type="其他",
plugin_type=PluginType.DEPENDANT,
).to_dict(),
)
async def rule(session: Uninfo) -> bool:
entity_ids = get_entity_ids(session)
if not session.group:
return False
ban_dao = DataAccess(BanConsole)
if not await ban_dao.safe_get_or_none(
user_id=entity_ids.user_id, group_id=entity_ids.group_id
):
return False
if not await ban_dao.safe_get_or_none(user_id="", group_id=entity_ids.group_id):
return False
return True
_matcher = on_message(rule=rule, priority=1, block=False)
_flmt = FreqLimiter(30)
@_matcher.handle()
async def _(bot: Bot, session: Uninfo, message: UniMsg):
entity_ids = get_entity_ids(session)
plain_text = message.extract_plain_text()
image_list = [m.url for m in message if isinstance(m, Image) and m.url]
img_hash = ""
for url in image_list:
img_hash += await get_download_image_hash(url, "_mute_")
_message = plain_text + img_hash
if duration := mute_manager.add_message(
entity_ids.user_id, entity_ids.group_id or "0", _message
):
try:
if _flmt.check(entity_ids.user_id):
_flmt.start_cd(entity_ids.user_id)
await PlatformUtils.ban_user(
bot, entity_ids.user_id, entity_ids.group_id or "0", duration
)
await MessageUtils.build_message(
f"检测到恶意刷屏,{BotConfig.self_nickname}要把你关进小黑屋!"
).send(at_sender=True)
mute_manager.reset(entity_ids.user_id, entity_ids.group_id or "0")
logger.info(
f"检测刷屏 被禁言 {duration} 分钟", "禁言检查", session=session
)
except Exception as e:
logger.error("禁言发送错误", "禁言检测", session=session, e=e)
+117
View File
@@ -0,0 +1,117 @@
from nonebot.plugin import PluginMetadata
from nonebot_plugin_alconna import Alconna, Args, Arparma, Match, Option, on_alconna
from nonebot_plugin_session import EventSession
from zhenxun.configs.config import BotConfig
from zhenxun.configs.utils import PluginExtraData, RegisterConfig
from zhenxun.services.log import logger
from zhenxun.utils.enum import PluginType
from zhenxun.utils.message import MessageUtils
from zhenxun.utils.rules import ensure_group
from ._data_source import base_config, mute_manage
__plugin_meta__ = PluginMetadata(
name="刷屏禁言",
description="刷屏禁言相关操作",
usage=f"""
刷屏禁言相关操作,需要 {BotConfig.self_nickname} 有群管理员权限
指令:
刷屏设置: 查看当前设置
-c [count]: 检测最大次数
-t [time]: 规定时间内
-d [duration]: 禁言时长
示例:
刷屏设置 -c 10: 设置最大次数为10
刷屏设置 -t 100 -d 20: 设置规定时间和禁言时长
刷屏设置 -d 10: 设置禁言时长为10
* 即 X 秒内发送同样消息 N 次,禁言 M 分钟 *
""".strip(),
extra=PluginExtraData(
author="HibiKier",
version="0.1-473ecd8",
menu_type="其他",
plugin_type=PluginType.ADMIN,
admin_level=base_config.get("MUTE_LEVEL", 5),
configs=[
RegisterConfig(
key="MUTE_LEVEL",
value=5,
help="更改禁言设置的管理权限",
default_value=5,
type=int,
),
RegisterConfig(
key="MUTE_DEFAULT_COUNT",
value=10,
help="刷屏禁言默认检测次数",
default_value=10,
type=int,
),
RegisterConfig(
key="MUTE_DEFAULT_TIME",
value=7,
help="刷屏检测默认规定时间",
default_value=7,
type=int,
),
RegisterConfig(
key="MUTE_DEFAULT_DURATION",
value=10,
help="刷屏检测默禁言时长(分钟)",
default_value=10,
type=int,
),
],
).to_dict(),
)
_setting_matcher = on_alconna(
Alconna(
"刷屏设置",
Option("-t|--time", Args["time", int], help_text="检测时长"),
Option("-c|--count", Args["count", int], help_text="检测次数"),
Option("-d|--duration", Args["duration", int], help_text="禁言时长"),
),
rule=ensure_group,
block=True,
priority=5,
)
@_setting_matcher.handle()
async def _(
session: EventSession,
arparma: Arparma,
time: Match[int],
count: Match[int],
duration: Match[int],
):
group_id = session.id2
if not session.id1 or not group_id:
return
_time = time.result if time.available else None
_count = count.result if count.available else None
_duration = duration.result if duration.available else None
group_data = mute_manage.get_group_data(group_id)
if _time is None and _count is None and _duration is None:
await MessageUtils.build_message(
f"最大次数:{group_data.count} 次\n"
f"规定时间:{group_data.time} 秒\n"
f"禁言时长:{group_data.duration:.2f} 分钟\n"
f"【在规定时间内发送相同消息超过最大次数则禁言\n当禁言时长为0时关闭此功能】"
).finish(reply_to=True)
if _time is not None:
group_data.time = _time
if _count is not None:
group_data.count = _count
if _duration is not None:
group_data.duration = _duration
await MessageUtils.build_message("设置成功!").send(reply_to=True)
logger.info(
f"设置禁言配置 time: {_time}, count: {_count}, duration: {_duration}",
arparma.header_result,
session=session,
)
mute_manage.save_data()
+2
View File
@@ -64,6 +64,8 @@ async def _():
_client = get_async_client( _client = get_async_client(
headers=get_user_agent(), headers=get_user_agent(),
follow_redirects=True, follow_redirects=True,
limits=httpx.Limits(max_connections=200, max_keepalive_connections=100),
timeout=httpx.Timeout(10),
**client_kwargs, **client_kwargs,
) )
+12
View File
@@ -3,6 +3,7 @@ from io import BytesIO
from pathlib import Path from pathlib import Path
import nonebot import nonebot
from nonebot.adapters import Bot
from nonebot.adapters.onebot.v11 import Message, MessageSegment from nonebot.adapters.onebot.v11 import Message, MessageSegment
from nonebot_plugin_alconna import ( from nonebot_plugin_alconna import (
At, At,
@@ -16,6 +17,7 @@ from nonebot_plugin_alconna import (
Video, Video,
Voice, Voice,
) )
from nonebot_plugin_uninfo import Uninfo
from pydantic import BaseModel from pydantic import BaseModel
import ujson as json import ujson as json
@@ -104,22 +106,32 @@ class MessageUtils:
cls, cls,
msg_list: MESSAGE_TYPE | list[MESSAGE_TYPE | list[MESSAGE_TYPE]], msg_list: MESSAGE_TYPE | list[MESSAGE_TYPE | list[MESSAGE_TYPE]],
format_args: dict | None = None, format_args: dict | None = None,
auto_forward_msg: Bot | Uninfo | None = None,
) -> UniMessage: ) -> UniMessage:
"""构造消息 """构造消息
参数: 参数:
msg_list: 消息列表 msg_list: 消息列表
format_args: 用于格式化字符串的参数字典. format_args: 用于格式化字符串的参数字典.
auto_forward_msg: 是否自动转发消息
返回: 返回:
UniMessage: 构造完成的消息列表 UniMessage: 构造完成的消息列表
""" """
from zhenxun.utils.platform import PlatformUtils
message_list = [] message_list = []
if not isinstance(msg_list, list): if not isinstance(msg_list, list):
msg_list = [msg_list] msg_list = [msg_list]
for m in msg_list: for m in msg_list:
_data = m if isinstance(m, list) else [m] _data = m if isinstance(m, list) else [m]
message_list += cls.__build_message(_data, format_args) message_list += cls.__build_message(_data, format_args)
if auto_forward_msg and PlatformUtils.is_forward_merge_supported(
auto_forward_msg
):
message_list = cls.alc_forward_msg(
message_list, auto_forward_msg.self_id, auto_forward_msg.self_id
)
return UniMessage(message_list) return UniMessage(message_list)
@classmethod @classmethod
+2 -1
View File
@@ -18,7 +18,6 @@ from zhenxun.models.friend_user import FriendUser
from zhenxun.models.group_console import GroupConsole from zhenxun.models.group_console import GroupConsole
from zhenxun.services.log import logger from zhenxun.services.log import logger
from zhenxun.utils.exception import NotFindSuperuser from zhenxun.utils.exception import NotFindSuperuser
from zhenxun.utils.http_utils import AsyncHttpx
from zhenxun.utils.message import MessageUtils from zhenxun.utils.message import MessageUtils
driver = nonebot.get_driver() driver = nonebot.get_driver()
@@ -226,6 +225,8 @@ class PlatformUtils:
user_id: 用户id user_id: 用户id
platform: 平台 platform: 平台
""" """
from zhenxun.utils.http_utils import AsyncHttpx
url = None url = None
if platform == "qq": if platform == "qq":
if user_id.isdigit(): if user_id.isdigit():
+17 -11
View File
@@ -9,6 +9,7 @@ from types import TracebackType
from typing import Any, ClassVar from typing import Any, ClassVar
import httpx import httpx
from nonebot_plugin_session import EventSession, Session
from nonebot_plugin_uninfo import Uninfo from nonebot_plugin_uninfo import Uninfo
import pypinyin import pypinyin
@@ -209,7 +210,7 @@ def is_valid_date(date_text: str, separator: str = "-") -> bool:
return False return False
def get_entity_ids(session: Uninfo) -> EntityIDs: def get_entity_ids(session: Uninfo | EventSession) -> EntityIDs:
"""获取用户id,群组id,频道id """获取用户id,群组id,频道id
参数: 参数:
@@ -218,16 +219,21 @@ def get_entity_ids(session: Uninfo) -> EntityIDs:
返回: 返回:
EntityIDs: 用户id,群组id,频道id EntityIDs: 用户id,群组id,频道id
""" """
user_id = session.user.id if isinstance(session, Session):
group_id = None user_id = session.id1
channel_id = None group_id = session.id2
if session.group: channel_id = session.id3
if session.group.parent: else:
group_id = session.group.parent.id user_id = session.user.id
channel_id = session.group.id group_id = session.group.id if session.group else None
else: channel_id = session.channel.id if session.channel else None
group_id = session.group.id if session.group:
return EntityIDs(user_id=user_id, group_id=group_id, channel_id=channel_id) if session.group.parent:
group_id = session.group.parent.id
channel_id = session.group.id
else:
group_id = session.group.id
return EntityIDs(user_id=user_id or "", group_id=group_id, channel_id=channel_id)
def is_number(text: str) -> bool: def is_number(text: str) -> bool:
+12 -3
View File
@@ -8,9 +8,11 @@ from nonebot.adapters import Bot
from nonebot.adapters.onebot.v11 import Bot as v11Bot from nonebot.adapters.onebot.v11 import Bot as v11Bot
from nonebot.adapters.onebot.v12 import Bot as v12Bot from nonebot.adapters.onebot.v12 import Bot as v12Bot
from nonebot_plugin_session import EventSession from nonebot_plugin_session import EventSession
from nonebot_plugin_uninfo import Uninfo
from ruamel.yaml.comments import CommentedSeq from ruamel.yaml.comments import CommentedSeq
from zhenxun.services.log import logger from zhenxun.services.log import logger
from zhenxun.utils.utils import get_entity_ids
class WithdrawManager: class WithdrawManager:
@@ -18,7 +20,9 @@ class WithdrawManager:
_index = 0 _index = 0
@classmethod @classmethod
def check(cls, session: EventSession, withdraw_time: tuple[int, int]) -> bool: def check(
cls, session: Uninfo | EventSession, withdraw_time: tuple[int, int]
) -> bool:
"""配置项检查 """配置项检查
参数: 参数:
@@ -28,12 +32,17 @@ class WithdrawManager:
返回: 返回:
bool: 是否允许撤回 bool: 是否允许撤回
""" """
entity_ids = get_entity_ids(session)
if withdraw_time[0] and withdraw_time[0] > 0: if withdraw_time[0] and withdraw_time[0] > 0:
if withdraw_time[1] == 2: if withdraw_time[1] == 2:
return True return True
if withdraw_time[1] == 1 and (session.id2 or session.id3): if withdraw_time[1] == 1 and entity_ids.group_id:
return True return True
if withdraw_time[1] == 0 and not session.id2 and not session.id3: if (
withdraw_time[1] == 0
and not entity_ids.group_id
and not entity_ids.channel_id
):
return True return True
return False return False