Skip to content

feat(safety): add group access control with blacklist/whitelist support#15

Merged
FlanChanXwO merged 9 commits into
masterfrom
v1.3.0
Apr 12, 2026
Merged

feat(safety): add group access control with blacklist/whitelist support#15
FlanChanXwO merged 9 commits into
masterfrom
v1.3.0

Conversation

@FlanChanXwO
Copy link
Copy Markdown
Owner

@FlanChanXwO FlanChanXwO commented Apr 10, 2026

Close #16

  • Introduce access_control_mode config to switch between none/blacklist/whitelist modes
  • Add whitelist_groups for explicit allowlist, keep blocked_groups for backwards compatibility
  • Implement unified permission check logic in SafetyConfigMixin
  • Auto-degrade to all-groups-allowed when whitelist is empty to prevent misconfiguration
  • Fix session config lock consistency in get_config

Summary by Sourcery

为安全功能添加可配置的用户组访问控制模式,并相应更新文档和元数据。

New Features:

  • 引入访问控制模式设置,以支持 noneblacklistwhitelist 三种用户组权限策略。
  • 在现有的受阻止(黑名单)用户组配置基础上,新增基于白名单的用户组配置,以实现更精细的访问控制。

Enhancements:

  • 优化用户组阻止逻辑,使用统一的访问控制流程,包括在白名单为空时的安全回退行为。

Documentation:

  • 在 README 中记录新的访问控制配置选项和使用方式,并提供示例。

Chores:

  • 将插件版本提升到 1.3.0,并在更新日志中记录新的访问控制功能。
Original summary in English

Summary by Sourcery

Add configurable group access control modes for safety features and update documentation and metadata accordingly.

New Features:

  • Introduce an access control mode setting to support none, blacklist, and whitelist group permission strategies.
  • Add a whitelist-based group configuration alongside the existing blocked (blacklist) groups for finer access control.

Enhancements:

  • Refine group blocking logic to use a unified access control flow, including safe fallback behavior when the whitelist is empty.

Documentation:

  • Document the new access control configuration options and usage patterns in the README, including examples.

Chores:

  • Bump plugin version to 1.3.0 and record the new access control functionality in the changelog.

Sourcery 总结

为 setu 和 fortune 功能引入特性级安全访问控制模式,分别配置黑名单/白名单,并将其集成到 AstrBot 的主配置和指令处理器中。

新功能:

  • 为 setu 和 fortune 的用户及群组添加独立的黑名单和白名单配置,并提供可配置的访问控制模式。
  • 暴露新的中文指令,用于管理 setu 和 fortune 各自的用户及群组访问权限。
  • 提供字体子集化脚本,并将 fortune 渲染器切换为使用 WOFF2 子集字体,以减小资源体积。

增强:

  • 将访问控制逻辑重构为 AccessControlManager 和 SafetyConfigMixin 中的特性级检查,解耦用户和群组列表,并兼容旧版配置键。
  • 在插件本地配置文件与 AstrBot 主配置之间同步安全配置,以实现与 Web UI 的兼容。
  • 使用 asyncio.wait_for 包装图片 URL 获取过程,以强制执行超时并提高健壮性。
  • 改进限流器的锁处理逻辑,避免任务被取消后遗留陈旧锁。

文档:

  • 在 README 中记录新的 setu-only 和 fortune-only 黑名单选项及其访问控制行为。
Original summary in English

Summary by Sourcery

Introduce feature-level safety access control modes with distinct blacklist/whitelist configurations for setu and fortune features, and integrate these with AstrBot's main config and command handlers.

New Features:

  • Add separate blacklist and whitelist configurations for setu and fortune users and groups with configurable access control modes.
  • Expose new Chinese commands for managing feature-specific user and group access for setu and fortune.
  • Provide a font subsetting script and switch fortune renderer to use WOFF2 subset fonts to reduce asset size.

Enhancements:

  • Refactor access control logic into feature-specific checks in AccessControlManager and SafetyConfigMixin, decoupling user and group lists and supporting legacy config keys.
  • Synchronize safety configuration between the plugin's local config file and AstrBot's main configuration for web UI compatibility.
  • Wrap image URL fetching in asyncio.wait_for to enforce a timeout and improve robustness.
  • Improve rate limiter lock handling to avoid stale locks when tasks are cancelled.

Documentation:

  • Document new setu-only and fortune-only blacklist options and access control behavior in README.

@sourcery-ai
Copy link
Copy Markdown
Contributor

sourcery-ai Bot commented Apr 10, 2026

Reviewer's Guide

实现了可配置的安全访问控制,为 setu 和 fortune 功能分别提供黑名单/白名单模式;将安全列表与 AstrBot 的 WebUI 主配置同步;重构指令处理器以使用按功能划分的用户/群组列表和模式;为图片 URL 拉取添加超时;改进限流锁的处理方式;并为 fortune 渲染器引入 WOFF2 字体子集。

Setu 指令在按功能模式下进行访问控制的时序图

sequenceDiagram
    actor User
    participant AstrBot
    participant SetuPlugin
    participant CommandHandler
    participant SetuCore
    participant SetuConfig
    participant AccessControlManager
    participant ConfigManager
    participant provider

    User->>AstrBot: send "/setu" message
    AstrBot->>SetuPlugin: dispatch command event
    SetuPlugin->>CommandHandler: handle_setu_command_internal(event)

    CommandHandler->>SetuCore: is_group_blocked(event, feature=setu)

    SetuCore->>SetuConfig: get setu_user_access_control_mode
    SetuCore->>SetuConfig: get setu_group_access_control_mode
    SetuConfig-->>SetuCore: user_mode, group_mode

    SetuCore->>AccessControlManager: check_setu_access(user_id, group_id, user_mode, group_mode)

    alt user_mode is blacklist
        AccessControlManager->>ConfigManager: get_list(KEY_SETU_BLOCKED_USERS)
        ConfigManager-->>AccessControlManager: blocked_users
        AccessControlManager-->>SetuCore: (is_blocked, reason)
    else user_mode is whitelist
        AccessControlManager->>ConfigManager: get_list(KEY_SETU_WHITELIST_USERS)
        ConfigManager-->>AccessControlManager: whitelist_users
        AccessControlManager-->>SetuCore: (is_blocked, reason)
    else user_mode is none
        AccessControlManager-->>SetuCore: (False, "")
    end

    rect rgb(245,245,245)
    Note over AccessControlManager,ConfigManager: Group level check (group_mode) is performed similarly using setu_blocked_groups and setu_whitelist_groups
    end

    SetuCore-->>CommandHandler: True or False

    alt blocked
        CommandHandler-->>AstrBot: stop, no images sent
        AstrBot-->>User: (no response or error message)
    else allowed
        CommandHandler->>SetuCore: _get_provider()
        SetuCore-->>CommandHandler: provider
        CommandHandler->>provider: fetch_image_urls(...)
        provider-->>CommandHandler: urls
        CommandHandler->>SetuCore: send_images_by_url(...)
        SetuCore-->>User: images
    end
Loading

安全访问控制与配置管理的类图

classDiagram

class ConfigManager {
  -Path _data_dir
  -Path _config_file
  -dict _cache
  -AstrBotConfig _astrbot_config
  +SAFETY_LIST_KEYS
  +SAFETY_MODE_KEYS
  +LEGACY_MODE_TO_NEW
  +initialize()
  -_load_config()
  -_save_config() bool
  -_iter_main_config_candidates() list_Path
  -_get_safety_value_from_file(key) Any
  -_sync_to_astrbot_config() void
  -_sync_from_astrbot_config() bool
  +get(key, default) Any
  -_get_access_control_mode_from_file(key, default) Any
  +set(key, value) bool
  +get_list(key) list_str
  +add_to_list(key, item) bool
  +remove_from_list(key, item) bool
  +is_in_list(key, item) bool
}

class AccessControlManager {
  -ConfigManager _cfg
  +KEY_SETU_ACCESS_CONTROL_MODE
  +KEY_SETU_BLOCKED_USERS
  +KEY_SETU_WHITELIST_USERS
  +KEY_SETU_BLOCKED_GROUPS
  +KEY_SETU_WHITELIST_GROUPS
  +KEY_FORTUNE_ACCESS_CONTROL_MODE
  +KEY_FORTUNE_BLOCKED_USERS
  +KEY_FORTUNE_WHITELIST_USERS
  +KEY_FORTUNE_BLOCKED_GROUPS
  +KEY_FORTUNE_WHITELIST_GROUPS
  +add_setu_blocked_user(user_id) bool
  +remove_setu_blocked_user(user_id) bool
  +is_setu_user_blocked(user_id) bool
  +get_setu_blocked_users() list_str
  +add_setu_whitelist_user(user_id) bool
  +remove_setu_whitelist_user(user_id) bool
  +is_setu_user_whitelisted(user_id) bool
  +get_setu_whitelist_users() list_str
  +add_setu_blocked_group(group_id) bool
  +remove_setu_blocked_group(group_id) bool
  +is_setu_group_blocked(group_id) bool
  +get_setu_blocked_groups() list_str
  +add_setu_whitelist_group(group_id) bool
  +remove_setu_whitelist_group(group_id) bool
  +is_setu_group_whitelisted(group_id) bool
  +get_setu_whitelist_groups() list_str
  +add_fortune_blocked_user(user_id) bool
  +remove_fortune_blocked_user(user_id) bool
  +is_fortune_user_blocked(user_id) bool
  +get_fortune_blocked_users() list_str
  +add_fortune_whitelist_user(user_id) bool
  +remove_fortune_whitelist_user(user_id) bool
  +is_fortune_user_whitelisted(user_id) bool
  +get_fortune_whitelist_users() list_str
  +add_fortune_blocked_group(group_id) bool
  +remove_fortune_blocked_group(group_id) bool
  +is_fortune_group_blocked(group_id) bool
  +get_fortune_blocked_groups() list_str
  +add_fortune_whitelist_group(group_id) bool
  +remove_fortune_whitelist_group(group_id) bool
  +is_fortune_group_whitelisted(group_id) bool
  +get_fortune_whitelist_groups() list_str
  +check_setu_access(user_id, group_id, user_access_control_mode, group_access_control_mode) tuple_bool_str
  +check_fortune_access(user_id, group_id, user_access_control_mode, group_access_control_mode) tuple_bool_str
  +get_all_lists() dict_str_list_str
}

class SafetyConfigMixin {
  +_read
  +_normalize_access_mode(value, default) str
  +setu_access_control_mode str
  +fortune_access_control_mode str
  +setu_user_access_control_mode str
  +setu_group_access_control_mode str
  +fortune_user_access_control_mode str
  +fortune_group_access_control_mode str
  +cache_enabled bool
}

class SetuConfig {
}

class SetuCore {
  -ConfigManager _config_manager
  -AccessControlManager _access_control
  -SetuConfig _config
  +is_group_blocked(event, feature) bool
  +_get_provider()
  +_get_fortune_provider()
}

class CommandHandler {
  -SetuCore _core
  -SetuConfig _config
  +handle_setu_block_user(event, args)
  +handle_setu_unblock_user(event, args)
  +handle_setu_trust_user(event, args)
  +handle_setu_untrust_user(event, args)
  +handle_fortune_block_user(event, args)
  +handle_fortune_unblock_user(event, args)
  +handle_fortune_trust_user(event, args)
  +handle_fortune_untrust_user(event, args)
  +handle_enable_setu_group(event, args)
  +handle_disable_setu_group(event, args)
  +handle_enable_fortune_group(event, args)
  +handle_disable_fortune_group(event, args)
  -_extract_target_id(event, args) str
}

class AstrBotConfig {
  +save_config()
  +__getitem__(key)
  +get(key, default)
}

SetuConfig ..|> SafetyConfigMixin
SetuCore --> ConfigManager : uses
SetuCore --> AccessControlManager : uses
SetuCore --> SetuConfig : reads_access_modes
CommandHandler --> SetuCore : calls_is_group_blocked
AccessControlManager --> ConfigManager : reads_writes_safety_lists
ConfigManager --> AstrBotConfig : syncs_safety_section
Loading

文件级改动

Change Details Files
重构指令处理器和功能级访问检查,使用新的按功能划分的安全访问控制以及黑/白名单。
  • 将 setu 和 fortune 处理器中直接使用全局 access_control 的检查,替换为 core.is_group_blocked(event, feature=...),以便遵守按功能划分的模式和列表。
  • 重命名并重写中文黑名单/白名单指令,将其拆分为 setu 与 fortune 的用户/群组管理,并绑定到新的 AccessControlManager 方法。
  • 简化目标用户 ID 提取的辅助函数,并确保在部分指令中不允许对自己进行封禁。
handlers/command_handlers.py
main.py
core/__init__.py
fortune/handlers.py
扩展配置管理,使其支持在插件 config.json 与 AstrBot 顶层安全配置之间同步安全列表和访问控制模式,并兼容旧版键名。
  • 引入 SAFETY_LIST_KEYS/SAFETY_MODE_KEYS 以及旧键映射,并在 ConfigManager 中接受可选的 AstrBotConfig。
  • 在 initialize 和保存时,在 AstrBotConfig 与 astrbot_plugin_setu_config.json 之间同步安全列表和模式,优先采用 WebUI 的值,并避免陈旧配置覆盖。
  • 调整 get/get_list/add/remove/is_in_list,使其对安全相关键进行特殊处理,包括访问模式的归一化,并在合适的情况下从主配置文件中读取。
services/config_manager.py
core/__init__.py
main.py
重新设计 AccessControlManager,使其按功能(setu 与 fortune)作用域划分,对用户/群组具有独立的黑名单和白名单以及专门的访问检查例程。
  • 用 setu_* 和 fortune_* 变体(用户和群组)替换全局的 blocked/whitelist user/group 键。
  • 为每个列表添加 add/remove/is/get 辅助方法,确保在同一功能下,黑/白名单对同一用户保持互斥。
  • 实现 check_setu_access 和 check_fortune_access,这两个方法分别接收用户/群组模式,并在没有全局耦合的前提下执行黑名单/白名单语义;更新 get_all_lists 以暴露所有列表。
services/config_manager.py
重构 SafetyConfigMixin,使其暴露按功能划分的用户/群组访问控制模式,并带有归一化和旧版兼容性。
  • 从 SafetyConfigMixin 中移除旧的全局 access_control_mode 以及群组/用户列表辅助方法;列表改由 AccessControlManager 负责。
  • 添加 setu_access_control_mode/fortune_access_control_mode 以做为旧版默认值,并新增 setu_user/group_access_control_mode 和 fortune_user/group_access_control_mode 属性,对值进行归一化。
  • 移除冲突日志记录和已被 core.is_group_blocked 中按功能检查替代的旧 check_access 逻辑。
config/safety.py
config/__init__.py
提升与限流和图片获取相关的异步操作的健壮性。
  • 用 asyncio.wait_for(timeout) 包裹 provider.fetch_image_urls 调用,以避免上游 API 卡住时导致挂起。
  • 修改 RateLimiter.acquire:创建按用户划分的锁,在全局锁下注册,然后在外层获取该锁;在发生 CancelledError 时回滚锁注册。
handlers/command_handlers.py
core/rate_limiter.py
通过子集化和 WOFF2 优化 fortune 字体资源,并添加用于生成子集的工具。
  • 将 fortune 渲染器的字体 CSS 切换为引用 NotoSansSC 和 SSFangTangTi 的 .woff2 变体,并更新 MIME 类型和 format。
  • 新增 scripts/subset_fonts.py 与 scripts/chars.txt,用于将字体子集化到限定字符集并输出 WOFF2 文件以供部署。
fortune/renderer.py
scripts/subset_fonts.py
scripts/chars.txt
更新 README 的安全章节,说明新的 setu 专用群组黑名单并阐明其与功能级控制的优先级关系。
  • 在示例 JSON 和选项列表中提到 setu_blocked_groups,并描述其与 fortune_blocked_groups 及全局列表的交互。
  • 添加在群组中仅禁用 setu(通过 /关闭色图)而保留 fortune 可用的使用示例。
  • 调整围绕功能级群组黑名单的说明,以同时提及 setu 和 fortune 相关键。
README.md

针对关联 issue 的评估

Issue Objective Addressed Explanation
#16 添加一个启用群组列表(白名单)的配置选项,使功能仅在该配置中列出的群组中生效。

Tips and commands

Interacting with Sourcery

  • 触发新的代码审查: 在 Pull Request 中评论 @sourcery-ai review
  • 继续讨论: 直接回复 Sourcery 的审查评论。
  • 从审查评论生成 GitHub issue: 在某条审查评论下回复,请求 Sourcery 从该评论创建一个 issue。你也可以直接回复 @sourcery-ai issue,从该评论生成 issue。
  • 生成 Pull Request 标题: 在 Pull Request 标题中任意位置写入 @sourcery-ai,即可随时生成标题。也可以在 Pull Request 中评论 @sourcery-ai title 以(重新)生成标题。
  • 生成 Pull Request 摘要: 在 Pull Request 正文任意位置写入 @sourcery-ai summary,即可在该位置生成 PR 摘要。你也可以评论 @sourcery-ai summary,在任意时间(重新)生成摘要。
  • 生成 Reviewer's Guide: 在 Pull Request 中评论 @sourcery-ai guide,即可在任意时间(重新)生成 reviewer's guide。
  • 解决所有 Sourcery 评论: 在 Pull Request 中评论 @sourcery-ai resolve,以将所有 Sourcery 评论标记为已解决。如果你已经处理完所有评论且不想再看到它们,这会很有用。
  • 忽略所有 Sourcery 审查: 在 Pull Request 中评论 @sourcery-ai dismiss,以忽略所有现有的 Sourcery 审查。特别适合想要重新开始一次全新审查的情况——别忘了之后再评论 @sourcery-ai review 触发新一轮审查!

自定义你的体验

访问你的 dashboard 以:

  • 启用或禁用诸如 Sourcery 生成的 Pull Request 摘要、reviewer's guide 等审查功能。
  • 更改审查语言。
  • 添加、删除或编辑自定义审查说明。
  • 调整其他审查相关设置。

获取帮助

Original review guide in English

Reviewer's Guide

Implements configurable safety access control with separate blacklist/whitelist modes for setu and fortune features, synchronizes safety lists with AstrBot’s main config for WebUI, refactors command handlers to use feature-specific user/group lists and modes, adds timeouts to image URL fetches, improves rate limiter lock handling, and introduces font subsetting to WOFF2 for the fortune renderer.

Sequence diagram for Setu command access control with feature-specific modes

sequenceDiagram
    actor User
    participant AstrBot
    participant SetuPlugin
    participant CommandHandler
    participant SetuCore
    participant SetuConfig
    participant AccessControlManager
    participant ConfigManager
    participant provider

    User->>AstrBot: send "/setu" message
    AstrBot->>SetuPlugin: dispatch command event
    SetuPlugin->>CommandHandler: handle_setu_command_internal(event)

    CommandHandler->>SetuCore: is_group_blocked(event, feature=setu)

    SetuCore->>SetuConfig: get setu_user_access_control_mode
    SetuCore->>SetuConfig: get setu_group_access_control_mode
    SetuConfig-->>SetuCore: user_mode, group_mode

    SetuCore->>AccessControlManager: check_setu_access(user_id, group_id, user_mode, group_mode)

    alt user_mode is blacklist
        AccessControlManager->>ConfigManager: get_list(KEY_SETU_BLOCKED_USERS)
        ConfigManager-->>AccessControlManager: blocked_users
        AccessControlManager-->>SetuCore: (is_blocked, reason)
    else user_mode is whitelist
        AccessControlManager->>ConfigManager: get_list(KEY_SETU_WHITELIST_USERS)
        ConfigManager-->>AccessControlManager: whitelist_users
        AccessControlManager-->>SetuCore: (is_blocked, reason)
    else user_mode is none
        AccessControlManager-->>SetuCore: (False, "")
    end

    rect rgb(245,245,245)
    Note over AccessControlManager,ConfigManager: Group level check (group_mode) is performed similarly using setu_blocked_groups and setu_whitelist_groups
    end

    SetuCore-->>CommandHandler: True or False

    alt blocked
        CommandHandler-->>AstrBot: stop, no images sent
        AstrBot-->>User: (no response or error message)
    else allowed
        CommandHandler->>SetuCore: _get_provider()
        SetuCore-->>CommandHandler: provider
        CommandHandler->>provider: fetch_image_urls(...)
        provider-->>CommandHandler: urls
        CommandHandler->>SetuCore: send_images_by_url(...)
        SetuCore-->>User: images
    end
Loading

Class diagram for safety access control and config management

classDiagram

class ConfigManager {
  -Path _data_dir
  -Path _config_file
  -dict _cache
  -AstrBotConfig _astrbot_config
  +SAFETY_LIST_KEYS
  +SAFETY_MODE_KEYS
  +LEGACY_MODE_TO_NEW
  +initialize()
  -_load_config()
  -_save_config() bool
  -_iter_main_config_candidates() list_Path
  -_get_safety_value_from_file(key) Any
  -_sync_to_astrbot_config() void
  -_sync_from_astrbot_config() bool
  +get(key, default) Any
  -_get_access_control_mode_from_file(key, default) Any
  +set(key, value) bool
  +get_list(key) list_str
  +add_to_list(key, item) bool
  +remove_from_list(key, item) bool
  +is_in_list(key, item) bool
}

class AccessControlManager {
  -ConfigManager _cfg
  +KEY_SETU_ACCESS_CONTROL_MODE
  +KEY_SETU_BLOCKED_USERS
  +KEY_SETU_WHITELIST_USERS
  +KEY_SETU_BLOCKED_GROUPS
  +KEY_SETU_WHITELIST_GROUPS
  +KEY_FORTUNE_ACCESS_CONTROL_MODE
  +KEY_FORTUNE_BLOCKED_USERS
  +KEY_FORTUNE_WHITELIST_USERS
  +KEY_FORTUNE_BLOCKED_GROUPS
  +KEY_FORTUNE_WHITELIST_GROUPS
  +add_setu_blocked_user(user_id) bool
  +remove_setu_blocked_user(user_id) bool
  +is_setu_user_blocked(user_id) bool
  +get_setu_blocked_users() list_str
  +add_setu_whitelist_user(user_id) bool
  +remove_setu_whitelist_user(user_id) bool
  +is_setu_user_whitelisted(user_id) bool
  +get_setu_whitelist_users() list_str
  +add_setu_blocked_group(group_id) bool
  +remove_setu_blocked_group(group_id) bool
  +is_setu_group_blocked(group_id) bool
  +get_setu_blocked_groups() list_str
  +add_setu_whitelist_group(group_id) bool
  +remove_setu_whitelist_group(group_id) bool
  +is_setu_group_whitelisted(group_id) bool
  +get_setu_whitelist_groups() list_str
  +add_fortune_blocked_user(user_id) bool
  +remove_fortune_blocked_user(user_id) bool
  +is_fortune_user_blocked(user_id) bool
  +get_fortune_blocked_users() list_str
  +add_fortune_whitelist_user(user_id) bool
  +remove_fortune_whitelist_user(user_id) bool
  +is_fortune_user_whitelisted(user_id) bool
  +get_fortune_whitelist_users() list_str
  +add_fortune_blocked_group(group_id) bool
  +remove_fortune_blocked_group(group_id) bool
  +is_fortune_group_blocked(group_id) bool
  +get_fortune_blocked_groups() list_str
  +add_fortune_whitelist_group(group_id) bool
  +remove_fortune_whitelist_group(group_id) bool
  +is_fortune_group_whitelisted(group_id) bool
  +get_fortune_whitelist_groups() list_str
  +check_setu_access(user_id, group_id, user_access_control_mode, group_access_control_mode) tuple_bool_str
  +check_fortune_access(user_id, group_id, user_access_control_mode, group_access_control_mode) tuple_bool_str
  +get_all_lists() dict_str_list_str
}

class SafetyConfigMixin {
  +_read
  +_normalize_access_mode(value, default) str
  +setu_access_control_mode str
  +fortune_access_control_mode str
  +setu_user_access_control_mode str
  +setu_group_access_control_mode str
  +fortune_user_access_control_mode str
  +fortune_group_access_control_mode str
  +cache_enabled bool
}

class SetuConfig {
}

class SetuCore {
  -ConfigManager _config_manager
  -AccessControlManager _access_control
  -SetuConfig _config
  +is_group_blocked(event, feature) bool
  +_get_provider()
  +_get_fortune_provider()
}

class CommandHandler {
  -SetuCore _core
  -SetuConfig _config
  +handle_setu_block_user(event, args)
  +handle_setu_unblock_user(event, args)
  +handle_setu_trust_user(event, args)
  +handle_setu_untrust_user(event, args)
  +handle_fortune_block_user(event, args)
  +handle_fortune_unblock_user(event, args)
  +handle_fortune_trust_user(event, args)
  +handle_fortune_untrust_user(event, args)
  +handle_enable_setu_group(event, args)
  +handle_disable_setu_group(event, args)
  +handle_enable_fortune_group(event, args)
  +handle_disable_fortune_group(event, args)
  -_extract_target_id(event, args) str
}

class AstrBotConfig {
  +save_config()
  +__getitem__(key)
  +get(key, default)
}

SetuConfig ..|> SafetyConfigMixin
SetuCore --> ConfigManager : uses
SetuCore --> AccessControlManager : uses
SetuCore --> SetuConfig : reads_access_modes
CommandHandler --> SetuCore : calls_is_group_blocked
AccessControlManager --> ConfigManager : reads_writes_safety_lists
ConfigManager --> AstrBotConfig : syncs_safety_section
Loading

File-Level Changes

Change Details Files
Refactor command handlers and feature-level access checks to use new per-feature safety access control and black/white lists.
  • Replace direct global access_control checks in setu and fortune handlers with core.is_group_blocked(event, feature=...) so they honor per-feature modes and lists.
  • Rename and rewrite Chinese blacklist/whitelist commands to separate setu and fortune user/group management, wiring them to new AccessControlManager methods.
  • Simplify target user extraction helper and ensure self-blocking is disallowed for some commands.
handlers/command_handlers.py
main.py
core/__init__.py
fortune/handlers.py
Extend configuration management to support synchronized safety lists and access control modes between plugin config.json and AstrBot’s top-level safety config, with legacy key compatibility.
  • Introduce SAFETY_LIST_KEYS/SAFETY_MODE_KEYS and legacy mappings, and accept optional AstrBotConfig in ConfigManager.
  • On initialize and save, sync safety lists and modes from/to AstrBotConfig and astrbot_plugin_setu_config.json, preferring WebUI values and avoiding stale overwrites.
  • Adjust get/get_list/add/remove/is_in_list to treat safety keys specially, including normalized modes and reading from main config files when appropriate.
services/config_manager.py
core/__init__.py
main.py
Redesign AccessControlManager to be feature-scoped (setu vs fortune) with independent user/group blacklists and whitelists plus dedicated access-check routines.
  • Replace global blocked/whitelist user/group keys with setu_* and fortune_* variants for both users and groups.
  • Add add/remove/is/get helpers for each list that keep black/white lists mutually exclusive per user and feature.
  • Implement check_setu_access and check_fortune_access that take separate user/group modes and enforce blacklist/whitelist semantics without global coupling, and update get_all_lists to expose all lists.
services/config_manager.py
Refactor SafetyConfigMixin to expose per-feature user/group access control modes with normalization and legacy compatibility.
  • Remove legacy global access_control_mode and group/user list helpers from SafetyConfigMixin; rely instead on AccessControlManager for lists.
  • Add setu_access_control_mode/fortune_access_control_mode for legacy defaults, and new setu_user/group_access_control_mode and fortune_user/group_access_control_mode properties that normalize values.
  • Drop conflict logging and old check_access plumbing now superseded by feature-specific checks in core.is_group_blocked.
config/safety.py
config/__init__.py
Improve robustness of async operations for rate limiting and image fetching.
  • Wrap provider.fetch_image_urls calls with asyncio.wait_for(timeouts) to avoid hanging when upstream APIs stall.
  • Change RateLimiter.acquire to create a per-user lock, register it under a global lock, then acquire it with cancellation handling that rolls back the lock registration on CancelledError.
handlers/command_handlers.py
core/rate_limiter.py
Optimize fortune font assets via subsetting and WOFF2, and add tooling to generate subsets.
  • Switch fortune renderer font CSS to reference .woff2 variants of NotoSansSC and SSFangTangTi with updated MIME types and formats.
  • Add scripts/subset_fonts.py plus scripts/chars.txt to subset fonts to a limited character set and output WOFF2 files for deployment.
fortune/renderer.py
scripts/subset_fonts.py
scripts/chars.txt
Update README safety section to document new setu-specific group blacklist and clarify priority with feature-level controls.
  • Mention setu_blocked_groups in the example JSON and option list and describe its interaction with fortune_blocked_groups and global lists.
  • Add usage example for disabling only setu in a group via /关闭色图 while keeping fortune available.
  • Adjust narrative around feature-level group blacklists to mention both setu and fortune keys.
README.md

Assessment against linked issues

Issue Objective Addressed Explanation
#16 Add a configuration option for an enabled group list (whitelist), so that the feature is only active in the groups listed in that configuration.

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - 我发现了两个问题,并给出了一些整体性的反馈:

  • access_control_mode 中,非法模式静默回退到 blacklist 可能会掩盖配置错误;建议记录一条警告日志或抛出显式错误,让运维人员能看到错误配置。
  • README 中的 JSON 配置示例使用了全角/中文引号(/),这会导致示例并非合法 JSON;请改用标准 ASCII 引号,方便用户直接复制粘贴使用。
给 AI Agent 的提示
Please address the comments from this code review:

## Overall Comments
-`access_control_mode` 中,非法模式静默回退到 `blacklist` 可能会掩盖配置错误;建议记录一条警告日志或抛出显式错误,让运维人员能看到错误配置。
- README 中的 JSON 配置示例使用了全角/中文引号(``/``),这会导致示例并非合法 JSON;请改用标准 ASCII 引号,方便用户直接复制粘贴使用。

## Individual Comments

### Comment 1
<location path="config/safety.py" line_range="28-32" />
<code_context>
+        返回:
+            none=不启用黑白名单,blacklist=仅黑名单模式,whitelist=仅白名单模式
+        """
+        mode = self._read(
+            ("safety", "access_control_mode"), "access_control_mode", default="blacklist"
+        )
+        valid_modes = ("none", "blacklist", "whitelist")
+        if mode not in valid_modes:
+            return "blacklist"
+        return mode
</code_context>
<issue_to_address>
**suggestion (bug_risk):** 在回退到 `"blacklist"` 之前,考虑对 `access_control_mode` 做更健壮的归一化和校验。

目前,只要不是 `("none", "blacklist", "whitelist")` 中的值(包括大小写不同或多余空格等轻微差异),都会被静默地当作 `"blacklist"` 处理,这可能会掩盖配置错误。建议先对值进行归一化(例如 `mode = str(mode).strip().lower()`),并在值非法时记录日志或抛出错误,而不是默默改变行为。使用更严格的返回类型,例如 `Literal["none", "blacklist", "whitelist"]` 或一个小型 `Enum`,也能让调用端更安全。

Suggested implementation:

```python
    @property
    def access_control_mode(self) -> "Literal['none', 'blacklist', 'whitelist']":
        """访问控制模式。

        返回:
            none=不启用黑白名单,blacklist=仅黑名单模式,whitelist=仅白名单模式
        """
        raw_mode = self._read(
            ("safety", "access_control_mode"), "access_control_mode", default="blacklist"
        )
        mode = str(raw_mode).strip().lower()

        valid_modes: tuple[str, ...] = ("none", "blacklist", "whitelist")
        if mode not in valid_modes:
            # 配置错误时记录警告并回退到安全的默认值
            import logging

            logging.warning(
                "Invalid access_control_mode %r (normalized: %r), falling back to 'blacklist'",
                raw_mode,
                mode,
            )
            return "blacklist"

        # 这里 mode 一定是 valid_modes 之一
        return mode  # type: ignore[return-value]

```

1. 为了让类型注解工作得更好,建议在文件顶部添加:

   ```python
   from typing import Literal
   ```

   并移除返回类型上的字符串引号(如果你启用了 `from __future__ import annotations` 则可以保留字符串形式),例如:

   ```python
   def access_control_mode(self) -> Literal["none", "blacklist", "whitelist"]:
   ```

2. 如果你不希望在方法体中局部 `import logging`,可改为:
   - 在文件顶部添加 `import logging`
   - 删除方法中的 `import logging` 行。
</issue_to_address>

### Comment 2
<location path="config/safety.py" line_range="75-79" />
<code_context>
         """
         if not group_id:
             return False
-        return str(group_id) in self.blocked_groups
+
+        mode = self.access_control_mode
+        gid = str(group_id)
+
+        if mode == "none":
</code_context>
<issue_to_address>
**suggestion (bug_risk):** 明确对假值 `group_id` 的处理逻辑,避免无意中跳过访问控制。

由于这里使用的是 `if not group_id`,空字符串或其他假值(例如 `""`、类似 `0` 的值)会直接跳过访问控制检查并被视为允许。如果只有 `None` 才表示“没有群组”,建议使用 `if group_id is None:`(或在边界位置对 `group_id` 做校验/归一化),以避免在屏蔽逻辑中被悄悄绕过。

```suggestion
        """
        # 仅当 group_id 为 None 时视为“无群组”,直接返回不屏蔽
        # 其他 falsy 值(例如 "", 0 等)仍会参与访问控制检查
        if group_id is None:
            return False

        mode = self.access_control_mode
```
</issue_to_address>

Sourcery 对开源项目免费 —— 如果你觉得这些 review 有帮助,欢迎分享 ✨
帮我变得更有用!请在每条评论上点 👍 或 👎,我会根据你的反馈改进 review 质量。
Original comment in English

Hey - I've found 2 issues, and left some high level feedback:

  • In access_control_mode, invalid modes silently falling back to blacklist may hide misconfigurations; consider logging a warning or surfacing an explicit error to make bad values visible to operators.
  • The JSON config example in the README uses full-width/Chinese quotation marks (/), which makes the snippet invalid JSON; please switch to standard ASCII quotes so users can copy-paste it directly.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `access_control_mode`, invalid modes silently falling back to `blacklist` may hide misconfigurations; consider logging a warning or surfacing an explicit error to make bad values visible to operators.
- The JSON config example in the README uses full-width/Chinese quotation marks (``/``), which makes the snippet invalid JSON; please switch to standard ASCII quotes so users can copy-paste it directly.

## Individual Comments

### Comment 1
<location path="config/safety.py" line_range="28-32" />
<code_context>
+        返回:
+            none=不启用黑白名单,blacklist=仅黑名单模式,whitelist=仅白名单模式
+        """
+        mode = self._read(
+            ("safety", "access_control_mode"), "access_control_mode", default="blacklist"
+        )
+        valid_modes = ("none", "blacklist", "whitelist")
+        if mode not in valid_modes:
+            return "blacklist"
+        return mode
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Consider normalizing and validating `access_control_mode` more robustly before falling back to `"blacklist"`.

Currently, any value outside `("none", "blacklist", "whitelist")` (including trivial variants like different casing or extra spaces) is silently treated as `"blacklist"`, which can hide config mistakes. Consider normalizing the value first (e.g. `mode = str(mode).strip().lower()`) and logging or raising on invalid values instead of changing behavior silently. A stricter return type such as `Literal["none", "blacklist", "whitelist"]` or a small `Enum` would also make call sites safer.

Suggested implementation:

```python
    @property
    def access_control_mode(self) -> "Literal['none', 'blacklist', 'whitelist']":
        """访问控制模式。

        返回:
            none=不启用黑白名单,blacklist=仅黑名单模式,whitelist=仅白名单模式
        """
        raw_mode = self._read(
            ("safety", "access_control_mode"), "access_control_mode", default="blacklist"
        )
        mode = str(raw_mode).strip().lower()

        valid_modes: tuple[str, ...] = ("none", "blacklist", "whitelist")
        if mode not in valid_modes:
            # 配置错误时记录警告并回退到安全的默认值
            import logging

            logging.warning(
                "Invalid access_control_mode %r (normalized: %r), falling back to 'blacklist'",
                raw_mode,
                mode,
            )
            return "blacklist"

        # 这里 mode 一定是 valid_modes 之一
        return mode  # type: ignore[return-value]

```

1. 为了让类型注解工作得更好,建议在文件顶部添加:

   ```python
   from typing import Literal
   ```

   并移除返回类型上的字符串引号(如果你启用了 `from __future__ import annotations` 则可以保留字符串形式),例如:

   ```python
   def access_control_mode(self) -> Literal["none", "blacklist", "whitelist"]:
   ```

2. 如果你不希望在方法体中局部 `import logging`,可改为:
   - 在文件顶部添加 `import logging`
   - 删除方法中的 `import logging` 行。
</issue_to_address>

### Comment 2
<location path="config/safety.py" line_range="75-79" />
<code_context>
         """
         if not group_id:
             return False
-        return str(group_id) in self.blocked_groups
+
+        mode = self.access_control_mode
+        gid = str(group_id)
+
+        if mode == "none":
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Clarify handling of falsy `group_id` values to avoid unintentionally skipping access control.

Because this uses `if not group_id`, empty strings or other falsy values (e.g. `""`, `0`-like) will skip the access-control check and be treated as allowed. If `None` is the only sentinel meaning “no group”, use `if group_id is None:` (or validate/normalize `group_id` at the boundary) to avoid silently bypassing blocking logic.

```suggestion
        """
        # 仅当 group_id 为 None 时视为“无群组”,直接返回不屏蔽
        # 其他 falsy 值(例如 "", 0 等)仍会参与访问控制检查
        if group_id is None:
            return False

        mode = self.access_control_mode
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread config/safety.py Outdated
Comment thread config/safety.py Outdated
@FlanChanXwO
Copy link
Copy Markdown
Owner Author

@sourcery-ai review

Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - 我发现了 3 个问题,并给出了一些整体性的反馈:

  • RateLimiter.acquire 中,新的 lock 变量只在键不在 _locks 中时才会初始化,但在离开 _global_lock 之后会无条件地 await 它,这会在锁已存在时抛出 NameError。建议在 with 块之后统一使用 lock = self._locks[key],让两条分支共享同一个变量。
  • ConfigManager.get 中,访问控制逻辑现在被拆分到了 _get_safety_value_from_file_get_access_control_mode_from_file 之间,导致对 mode key 的逻辑重复。你可以通过为 SAFETY_MODE_KEYS 保留一条统一的逻辑路径来简化(例如:让 _get_safety_value_from_file 既处理 list 又处理 mode,并删除额外的 mode 专用查询)。
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `RateLimiter.acquire`, the new `lock` variable is only initialized when the key is absent in `_locks`, but it is awaited unconditionally after leaving `_global_lock`, which will raise a `NameError` when a lock already exists; consider always using `lock = self._locks[key]` after the `with` block so both paths share the same variable.
- The access control handling is now split between `_get_safety_value_from_file` and `_get_access_control_mode_from_file` in `ConfigManager.get`, leading to duplicated logic for mode keys; you could simplify by having a single code path for SAFETY_MODE_KEYS (e.g., reuse `_get_safety_value_from_file` for both lists and modes, and remove the extra mode-specific lookup).

## Individual Comments

### Comment 1
<location path="core/__init__.py" line_range="261-270" />
<code_context>
             return False

-    def is_group_blocked(self, event: AstrMessageEvent) -> bool:
+    def is_group_blocked(
+        self, event: AstrMessageEvent, feature: str | None = None
+    ) -> bool:
</code_context>
<issue_to_address>
**🚨 issue (security):**`feature` 为 None 时,目前的逻辑会完全跳过访问控制,这可能在无提示的情况下改变既有行为。

之前,`is_group_blocked` 始终会执行 `check_global_access`;而现在的 `else` 分支不会做任何检查,只是记录一条日志 “skipping access control”。任何现有的、未传入 `feature` 的调用方现在都会绕过访问控制。

我建议:
- 要么把 `feature` 设为必填参数,强制调用方明确选择模式;
- 要么让 `else` 分支保留旧行为(例如调用全局/默认访问控制模式),而不是直接跳过检查。
</issue_to_address>

### Comment 2
<location path="services/config_manager.py" line_range="57-66" />
<code_context>
+    def get(self, key: str, default: Any = None) -> Any:
</code_context>
<issue_to_address>
**suggestion (performance):** 在高消息量场景下,`get()` 对安全相关 key 的反复读文件会比较昂贵。

对于 `SAFETY_LIST_KEYS`/`SAFETY_MODE_KEYS``get()` 会调用 `_get_safety_value_from_file`,该函数会遍历 `_iter_main_config_candidates()`,并在每次访问时读取/解析 JSON。由于这是按消息调用的,这可能带来可观的磁盘 I/O 和解析开销。

建议将解析后的主配置(或仅其 `safety` 段)缓存在内存中,并配合失效机制(例如基于时间戳/手动重载);或者让所有磁盘读取都通过 `_sync_from_astrbot_config()` 进行,而 `get()` 从该缓存中提供数据,以在保持原有行为的同时,避免在热点路径中频繁访问文件系统。

Suggested implementation:

```python
    def get(self, key: str, default: Any = None) -> Any:

```

要完整实现缓存并将其接入 `get()`,请在 `services/config_manager.py` 的其他位置做如下修改:

1. **在配置管理类中添加(延迟初始化的)缓存属性**

   在你的配置管理类中(定义了 `get()`, `_iter_main_config_candidates()`, `_get_safety_value_from_file()` 等方法的那个类),添加一些延迟初始化的属性,用于存储缓存的主配置及其最后修改时间。这样如果你不想修改 `__init__`,也可以通过延迟初始化来实现:

   ```python
   # At top of the class (or in __init__), define:
   self._main_config_cache: dict[str, Any] | None = None
   self._main_config_cache_mtime: float | None = None
   self._main_config_cache_path: Path | None = None
   self._main_config_cache_lock = threading.Lock()
   ```

   如果不能修改 `__init__`,可以在后面要用到的 helper 中使用 `setattr` / `getattr` 做延迟创建(见下一步)。

2. **添加一个带基于 mtime 失效机制的主配置加载 helper**`_iter_main_config_candidates` 附近实现类似如下的方法:

   ```python
   def _load_main_config_with_cache(self) -> dict[str, Any] | None:
       """
       加载 AstrBot 主配置文件,并使用内存缓存避免频繁读取磁盘。

       返回完整配置字典;如果找不到主配置文件或解析失败则返回 None。
       """
       # Lazy init to avoid touching __init__
       if not hasattr(self, "_main_config_cache_lock"):
           import threading
           self._main_config_cache_lock = threading.Lock()
           self._main_config_cache = None
           self._main_config_cache_mtime = None
           self._main_config_cache_path = None

       candidates = list(self._iter_main_config_candidates())
       if not candidates:
           return None

       # 选择第一个存在的配置文件作为主配置
       config_path: Path | None = None
       for candidate in candidates:
           if candidate.is_file():
               config_path = candidate
               break

       if config_path is None:
           return None

       try:
           mtime = config_path.stat().st_mtime
       except OSError:
           return None

       with self._main_config_cache_lock:
           # 如果缓存命中且未过期,直接返回
           if (
               getattr(self, "_main_config_cache_path", None) == config_path
               and getattr(self, "_main_config_cache_mtime", None) == mtime
               and getattr(self, "_main_config_cache", None) is not None
           ):
               return self._main_config_cache  # type: ignore[return-value]

           # 否则从磁盘重新加载并更新缓存
           try:
               with config_path.open("r", encoding="utf-8") as f:
                   data = json.load(f)
           except (OSError, json.JSONDecodeError) as e:
               logger.warning("Failed to load main config %s: %s", config_path, e)
               return None

           self._main_config_cache = data
           self._main_config_cache_mtime = mtime
           self._main_config_cache_path = config_path
           return data
   ```

   这样可以集中管理文件系统访问,并保证对同一文件的重复读取都复用内存中的对象,直到磁盘上的文件发生变化。

3. **为 safety 段添加 helper,并让 `_get_safety_value_from_file` 使用它**

   实现一个面向 safety 段的辅助方法:

   ```python
   def _get_safety_section(self) -> dict[str, Any] | None:
       """从主配置中获取 safety 配置段,带缓存。"""
       main_cfg = self._load_main_config_with_cache()
       if not main_cfg:
           return None
       safety = main_cfg.get("safety")
       if not isinstance(safety, dict):
           return None
       return safety
   ```

   然后重构 `_get_safety_value_from_file`(如果还不存在该方法,则新增一个)——让它 **** 从这个带缓存的 safety 段读数据,而不是每次调用都遍历 `_iter_main_config_candidates()` 并重新打开/解析 JSON。例如:

   ```python
   def _get_safety_value_from_file(self, key: str, default: Any = None) -> Any:
       """
       从 AstrBot 主配置的 safety 段获取指定 key 的值。
       使用内存缓存避免在高频调用时频繁访问磁盘。
       """
       safety = self._get_safety_section()
       if safety is None:
           return default
       return safety.get(key, default)
   ```

4. **更新 `get()`,让安全相关 key 走缓存后的 safety 配置**`get()` 中,调整现在调用 `_get_safety_value_from_file` 并触发重读文件的那条分支。大致上你可能有类似代码:

   ```python
   def get(self, key: str, default: Any = None) -> Any:
       # ...
       if key in self.SAFETY_LIST_KEYS or key in self.SAFETY_MODE_KEYS:
           value = self._get_safety_value_from_file(key, default)
           # possibly merge with self._cache overrides
           return value
       # ...
   ```

   确保:
   - 所有 safety key 的解析都通过 `_get_safety_value_from_file`,而后者使用的是带缓存的配置;
   - 如果你允许在 `self._cache` 中做每插件覆盖,那么可以在 `get()` 中先查 `self._cache`,再回退到 `_get_safety_value_from_file(key, default)`5. **可选:在 `_sync_to_astrbot_config()` 或其他写入逻辑中失效缓存**

   如果这个类同时也负责写 AstrBot 主配置(例如通过 `_sync_to_astrbot_config()` 或其他写入方法),那么在写入之后立即失效缓存:

   ```python
   with self._main_config_cache_lock:
       self._main_config_cache = None
       self._main_config_cache_mtime = None
       self._main_config_cache_path = None
   ```

   这样可以确保后续的 `get()` 调用即使在文件 mtime 尚未变化(或存在多进程写入)的情况下,也能看到最新数据。

这些修改会让所有针对安全 key 的文件读取都走主配置的缓存版本,从而在保持行为不变的前提下,消除在每条消息的热点路径上反复的磁盘 I/O 和 JSON 解析开销。
</issue_to_address>

### Comment 3
<location path="handlers/command_handlers.py" line_range="106-113" />
<code_context>
-                        tags=tags,
-                        r18=is_r18,
-                        exclude_ai=self._config.exclude_ai,
+                    img_urls = await asyncio.wait_for(
+                        provider.fetch_image_urls(
+                            num=num,
+                            tags=tags,
+                            r18=is_r18,
+                            exclude_ai=self._config.exclude_ai,
+                        ),
+                        timeout=60.0,
                     )
                     async for result in self._core.send_images_by_url(
</code_context>
<issue_to_address>
**suggestion (bug_risk):**`fetch_image_urls` 包在 `asyncio.wait_for` 中而不处理 `TimeoutError`,可能会导致超时异常未被调用方正确捕获。

当达到 60 秒的时间限制时,`asyncio.wait_for` 会抛出 `asyncio.TimeoutError`,目前该异常会向上传播,被视为一个通用失败。请在这次调用周围捕获 `asyncio.TimeoutError`,将其转换为用户可见的超时提示信息,并可选地记录日志,以便清晰地区分超时与其他错误。

Suggested implementation:

```python
                    try:
                        img_urls = await asyncio.wait_for(
                            provider.fetch_image_urls(
                                num=num,
                                tags=tags,
                                r18=is_r18,
                                exclude_ai=self._config.exclude_ai,
                            ),
                            timeout=60.0,
                        )
                    except asyncio.TimeoutError:
                        logger.warning(
                            "Timed out fetching image URLs from provider=%r after %ss (tags=%r, r18=%r, exclude_ai=%r)",
                            provider,
                            60.0,
                            tags,
                            is_r18,
                            self._config.exclude_ai,
                        )
                        await event.reply("图片获取超时,请稍后重试。")
                        return

                    async for result in self._core.send_images_by_url(
                        event, img_urls, is_r18, tags

```

为了保证代码能够编译并符合现有约定,你可能还需要:

1. 确认在 `handlers/command_handlers.py` 中已经导入了 `asyncio`(如果尚未导入):
   - `import asyncio`
2. 确认在合适的位置定义并使用模块级 logger,例如:
   - 在文件顶部添加 `import logging`- 在顶层定义附近添加 `logger = logging.getLogger(__name__)`3. 如果在你的框架中 `event.reply` 不是正确的发送消息方式,请将 `await event.reply("图片获取超时,请稍后重试。")` 替换为该 handler 中其他地方所使用的、面向用户的消息发送 API</issue_to_address>

Sourcery 对开源项目免费使用——如果你觉得这个评审有帮助,欢迎分享 ✨
帮我变得更有用!请在每条评论上点 👍 或 👎,我会根据你的反馈持续改进评审质量。
Original comment in English

Hey - I've found 3 issues, and left some high level feedback:

  • In RateLimiter.acquire, the new lock variable is only initialized when the key is absent in _locks, but it is awaited unconditionally after leaving _global_lock, which will raise a NameError when a lock already exists; consider always using lock = self._locks[key] after the with block so both paths share the same variable.
  • The access control handling is now split between _get_safety_value_from_file and _get_access_control_mode_from_file in ConfigManager.get, leading to duplicated logic for mode keys; you could simplify by having a single code path for SAFETY_MODE_KEYS (e.g., reuse _get_safety_value_from_file for both lists and modes, and remove the extra mode-specific lookup).
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `RateLimiter.acquire`, the new `lock` variable is only initialized when the key is absent in `_locks`, but it is awaited unconditionally after leaving `_global_lock`, which will raise a `NameError` when a lock already exists; consider always using `lock = self._locks[key]` after the `with` block so both paths share the same variable.
- The access control handling is now split between `_get_safety_value_from_file` and `_get_access_control_mode_from_file` in `ConfigManager.get`, leading to duplicated logic for mode keys; you could simplify by having a single code path for SAFETY_MODE_KEYS (e.g., reuse `_get_safety_value_from_file` for both lists and modes, and remove the extra mode-specific lookup).

## Individual Comments

### Comment 1
<location path="core/__init__.py" line_range="261-270" />
<code_context>
             return False

-    def is_group_blocked(self, event: AstrMessageEvent) -> bool:
+    def is_group_blocked(
+        self, event: AstrMessageEvent, feature: str | None = None
+    ) -> bool:
</code_context>
<issue_to_address>
**🚨 issue (security):** When `feature` is None, access control is now completely skipped, which may silently change previous behavior.

Previously `is_group_blocked` always enforced `check_global_access`; now the `else` branch does no checks and only logs "skipping access control". Any existing callers that don’t pass `feature` will now bypass access control.

I’d recommend either:
- Making `feature` required so callers must choose the correct mode, or
- Having the `else` branch preserve the old behavior (e.g., call a global/default access mode) rather than skipping checks.
</issue_to_address>

### Comment 2
<location path="services/config_manager.py" line_range="57-66" />
<code_context>
+    def get(self, key: str, default: Any = None) -> Any:
</code_context>
<issue_to_address>
**suggestion (performance):** Repeated file reads for safety keys in `get()` can be expensive under high message volume.

For `SAFETY_LIST_KEYS`/`SAFETY_MODE_KEYS`, `get()` calls `_get_safety_value_from_file`, which walks `_iter_main_config_candidates()` and reads/parses JSON on every access. Since this runs per message, it can introduce substantial disk I/O and parsing overhead.

Consider caching the parsed main config (or just the `safety` section) in memory with an invalidation mechanism (e.g., timestamp/manual reload), or routing all disk reads through `_sync_from_astrbot_config()` and having `get()` serve from that cache to keep behavior while avoiding repeated filesystem work in the hot path.

Suggested implementation:

```python
    def get(self, key: str, default: Any = None) -> Any:

```

To fully implement the caching and wire it into `get()`, make the following changes elsewhere in `services/config_manager.py`:

1. **Add cache attributes (lazy-initialized) to the config manager class**

   Inside your config manager class (the one that defines `get()`, `_iter_main_config_candidates()`, `_get_safety_value_from_file()`, etc.), add lazy-initialized attributes to store the cached main config and its last modification time. This avoids needing to edit `__init__` if you prefer:

   ```python
   # At top of the class (or in __init__), define:
   self._main_config_cache: dict[str, Any] | None = None
   self._main_config_cache_mtime: float | None = None
   self._main_config_cache_path: Path | None = None
   self._main_config_cache_lock = threading.Lock()
   ```

   If you cannot modify `__init__`, you can lazy-create them in the helper (see next step) using `setattr` and `getattr`.

2. **Add a helper to get the parsed main config with mtime-based invalidation**

   Near `_iter_main_config_candidates`, implement a method like:

   ```python
   def _load_main_config_with_cache(self) -> dict[str, Any] | None:
       """
       加载 AstrBot 主配置文件,并使用内存缓存避免频繁读取磁盘。

       返回完整配置字典;如果找不到主配置文件或解析失败则返回 None。
       """
       # Lazy init to avoid touching __init__
       if not hasattr(self, "_main_config_cache_lock"):
           import threading
           self._main_config_cache_lock = threading.Lock()
           self._main_config_cache = None
           self._main_config_cache_mtime = None
           self._main_config_cache_path = None

       candidates = list(self._iter_main_config_candidates())
       if not candidates:
           return None

       # 选择第一个存在的配置文件作为主配置
       config_path: Path | None = None
       for candidate in candidates:
           if candidate.is_file():
               config_path = candidate
               break

       if config_path is None:
           return None

       try:
           mtime = config_path.stat().st_mtime
       except OSError:
           return None

       with self._main_config_cache_lock:
           # 如果缓存命中且未过期,直接返回
           if (
               getattr(self, "_main_config_cache_path", None) == config_path
               and getattr(self, "_main_config_cache_mtime", None) == mtime
               and getattr(self, "_main_config_cache", None) is not None
           ):
               return self._main_config_cache  # type: ignore[return-value]

           # 否则从磁盘重新加载并更新缓存
           try:
               with config_path.open("r", encoding="utf-8") as f:
                   data = json.load(f)
           except (OSError, json.JSONDecodeError) as e:
               logger.warning("Failed to load main config %s: %s", config_path, e)
               return None

           self._main_config_cache = data
           self._main_config_cache_mtime = mtime
           self._main_config_cache_path = config_path
           return data
   ```

   This centralizes filesystem access and ensures repeated reads of the same file re-use the in-memory object until the file changes on disk.

3. **Add a helper for the safety section and wire `_get_safety_value_from_file` to it**

   Implement a safety-specific helper:

   ```python
   def _get_safety_section(self) -> dict[str, Any] | None:
       """从主配置中获取 safety 配置段,带缓存。"""
       main_cfg = self._load_main_config_with_cache()
       if not main_cfg:
           return None
       safety = main_cfg.get("safety")
       if not isinstance(safety, dict):
           return None
       return safety
   ```

   Then refactor `_get_safety_value_from_file` (or introduce it if it doesn't exist yet) so that it **only** reads from this cached safety section instead of walking `_iter_main_config_candidates()` and re-opening/parsing JSON each time. For example:

   ```python
   def _get_safety_value_from_file(self, key: str, default: Any = None) -> Any:
       """
       从 AstrBot 主配置的 safety 段获取指定 key 的值。
       使用内存缓存避免在高频调用时频繁访问磁盘。
       """
       safety = self._get_safety_section()
       if safety is None:
           return default
       return safety.get(key, default)
   ```

4. **Update `get()` to use the cached safety config for safety keys**

   Inside `get()`, adjust the branch that currently calls `_get_safety_value_from_file` and triggers the heavy path. Conceptually, you likely have something like:

   ```python
   def get(self, key: str, default: Any = None) -> Any:
       # ...
       if key in self.SAFETY_LIST_KEYS or key in self.SAFETY_MODE_KEYS:
           value = self._get_safety_value_from_file(key, default)
           # possibly merge with self._cache overrides
           return value
       # ...
   ```

   Ensure that:
   - All safety-key resolution goes through `_get_safety_value_from_file`, which now uses the cached config.
   - If you allow per-plugin overrides in `self._cache`, you may want `get()` to check `self._cache` first, then fall back to `_get_safety_value_from_file(key, default)`.

5. **Optionally, invalidate the cache when `_sync_to_astrbot_config()` or related writers run**

   If this class is also responsible for writing the AstrBot main config (e.g. in `_sync_to_astrbot_config()` or other methods), invalidate the cache right after writing:

   ```python
   with self._main_config_cache_lock:
       self._main_config_cache = None
       self._main_config_cache_mtime = None
       self._main_config_cache_path = None
   ```

   This makes sure subsequent `get()` calls see fresh data even if the file’s mtime hasn't changed yet (or if multiple processes may be writing).

These changes will route all filesystem reads for safety keys through the cached main config, keeping behavior intact while eliminating repeated disk I/O and JSON parsing from the per-message hot path.
</issue_to_address>

### Comment 3
<location path="handlers/command_handlers.py" line_range="106-113" />
<code_context>
-                        tags=tags,
-                        r18=is_r18,
-                        exclude_ai=self._config.exclude_ai,
+                    img_urls = await asyncio.wait_for(
+                        provider.fetch_image_urls(
+                            num=num,
+                            tags=tags,
+                            r18=is_r18,
+                            exclude_ai=self._config.exclude_ai,
+                        ),
+                        timeout=60.0,
                     )
                     async for result in self._core.send_images_by_url(
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Wrapping `fetch_image_urls` in `asyncio.wait_for` without handling `TimeoutError` may surface as unhandled exceptions to callers.

`asyncio.wait_for` will raise `asyncio.TimeoutError` when the 60s limit is hit, which will currently propagate as a generic failure. Please catch `asyncio.TimeoutError` around this call, translate it into a user-facing timeout message, and optionally log it so timeouts are clearly distinguished from other errors.

Suggested implementation:

```python
                    try:
                        img_urls = await asyncio.wait_for(
                            provider.fetch_image_urls(
                                num=num,
                                tags=tags,
                                r18=is_r18,
                                exclude_ai=self._config.exclude_ai,
                            ),
                            timeout=60.0,
                        )
                    except asyncio.TimeoutError:
                        logger.warning(
                            "Timed out fetching image URLs from provider=%r after %ss (tags=%r, r18=%r, exclude_ai=%r)",
                            provider,
                            60.0,
                            tags,
                            is_r18,
                            self._config.exclude_ai,
                        )
                        await event.reply("图片获取超时,请稍后重试。")
                        return

                    async for result in self._core.send_images_by_url(
                        event, img_urls, is_r18, tags

```

To make this compile and follow existing conventions, you may also need to:

1. Ensure `asyncio` is imported in `handlers/command_handlers.py` (if not already present):
   - `import asyncio`
2. Ensure a module-level logger is defined and imported where appropriate, for example:
   - Add `import logging` at the top of the file.
   - Add `logger = logging.getLogger(__name__)` near the top-level definitions.
3. If `event.reply` is not the correct way to send a message in your framework, replace `await event.reply("图片获取超时,请稍后重试。")` with the appropriate user-facing message API used elsewhere in this handler.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread core/__init__.py
Comment thread services/config_manager.py
Comment thread handlers/command_handlers.py Outdated
@FlanChanXwO
Copy link
Copy Markdown
Owner Author

@sourcery-ai review

@sourcery-ai
Copy link
Copy Markdown
Contributor

sourcery-ai Bot commented Apr 12, 2026

Sorry @FlanChanXwO, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@FlanChanXwO
Copy link
Copy Markdown
Owner Author

@copilot review

Copy link
Copy Markdown

Copilot AI commented Apr 12, 2026

@copilot review

I reviewed the latest 9 commits on this PR and didn’t find any additional issues requiring changes at this time. Current head: 83cc369.

@FlanChanXwO
Copy link
Copy Markdown
Owner Author

@copilot 这2次 ae51c5a83cc369 提交没有任何问题吗?

Copy link
Copy Markdown

Copilot AI commented Apr 12, 2026

@copilot 这2次 ae51c5a 和 [83cc369](https://github.com/FlanChanXwO/astrb...

我重新逐项复查了:83cc369 是一次同步 origin/v1.3.0 的 merge commit,本身没有新增业务改动;ae51c5a 的改动点(访问控制 fail-close、URL 获取超时处理、配置缓存、限流锁)目前看没有阻断性问题。唯一需要注意的是 fail-close 依赖调用方传 feature,我也确认了现有入口都已传入 setu/fortune。当前结论是这两次提交没有发现必须修复的问题。

@FlanChanXwO FlanChanXwO merged commit 82ad391 into master Apr 12, 2026
4 checks passed
@FlanChanXwO FlanChanXwO deleted the v1.3.0 branch April 12, 2026 11:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: 建议增加一个开启的群组配置,仅在配置里的才开启

2 participants