Skip to content

feat: add driver selection config & external driver import support#22

Open
RhenCloud wants to merge 2 commits into
devfrom
feat/driver-selection
Open

feat: add driver selection config & external driver import support#22
RhenCloud wants to merge 2 commits into
devfrom
feat/driver-selection

Conversation

@RhenCloud

@RhenCloud RhenCloud commented Jun 14, 2026

Copy link
Copy Markdown
Collaborator
  • Add DriversConfig/ExternalDriverConfig models to PluginConfig
  • Support explicit driver enable list in config
  • Support importing drivers from external Python packages
  • External drivers take priority over built-in (override-capable)
  • Backwards compatible: empty enabled list falls back to auto-discovery
  • Fix ty check errors in drivers/qq.py

由 Sourcery 总结

添加可配置的驱动选择功能,支持外部驱动模块,并改进跨驱动的私信处理和指令路由。

新功能:

  • 引入 DriversConfigExternalDriverConfig,通过配置显式选择启用的驱动,并定义外部驱动的导入。
  • 允许加载外部驱动实现,这些实现可以覆盖内置驱动,同时与现有的插件发现机制(entrypoint 和本地)集成。
  • 为 QQ 驱动新增私信/直接消息的规范化与发送支持,并通过新的 is_dm 标记,在多个驱动中标记私信消息。
  • 允许在源频道尚未配置的情况下,从私信中执行内部 bind 命令。

漏洞修复:

  • 解决 QQ 驱动中的类型标注问题,并调整插件加载逻辑,以一致地处理外部来源。
Original summary in English

Summary by Sourcery

Add configurable driver selection with support for external driver modules and improve direct message handling and command routing across drivers.

New Features:

  • Introduce DriversConfig and ExternalDriverConfig to explicitly select enabled drivers and define external driver imports via configuration.
  • Allow loading external driver implementations that can override built-in drivers while integrating with existing plugin discovery (entrypoint and local).
  • Add direct/private message normalization and sending support for the QQ driver and mark DM messages across multiple drivers via a new is_dm flag.
  • Permit the internal bind command to be executed from direct messages even when the source channel is not yet configured.

Bug Fixes:

  • Resolve typing issues in the QQ driver and adjust plugin loading to handle external sources consistently.

- Add DriversConfig/ExternalDriverConfig models to PluginConfig
- Support explicit driver enable list in config
- Support importing drivers from external Python packages
- External drivers take priority over built-in (override-capable)
- Backwards compatible: empty enabled list falls back to auto-discovery
- Fix ty check errors in drivers/qq.py
@siiway-code-review

siiway-code-review Bot commented Jun 14, 2026

Copy link
Copy Markdown

Summary by Claude Review

No significant changes categorized.


Overall Comments

  • Review output could not be parsed (response may have been truncated). Try increasing max_tokens in your config.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- _Review output could not be parsed (response may have been truncated). Try increasing `max_tokens` in your config._

Reviewed by Claude Review

@sourcery-ai

sourcery-ai Bot commented Jun 14, 2026

Copy link
Copy Markdown

审阅者指南

实现可配置的驱动选择,支持外部驱动模块;调整启动时的驱动发现逻辑,使其在保持向后兼容的同时,尊重显式启用列表和外部覆盖配置;并增强消息处理,用于跟踪私信(DM),支持 QQ 私聊以及基于 DM 的内部命令。

通过 NormalizedMessage.is_dm 处理 DM bind 命令的时序图

sequenceDiagram
    participant Driver as PlatformDriver
    participant Bridge as Bridge

    Driver->>Bridge: on_message(NormalizedMessage(is_dm=True))
    Bridge->>Bridge: _parse_internal_command(msg.text)
    alt command detected
        Bridge->>Bridge: _is_allowed_command_source(msg)
        alt allowed source
            Bridge->>Bridge: handle command
        else not allowed source
            alt action == bind and msg.is_dm
                Bridge-->>Bridge: allow bind from DM
                Bridge->>Bridge: handle bind
            else other command or not DM
                Bridge-->>Bridge: ignore command
            end
        end
    else no command
        Bridge-->>Bridge: normal message handling
    end
Loading

文件级改动

Change Details Files
添加驱动选择和外部驱动导入配置模型,并将其集成到插件配置中。
  • 引入 ExternalDriverConfig 模型,用于指定外部驱动的 Python 模块路径。
  • 引入 DriversConfig 模型,包含启用的驱动列表和外部驱动映射,并补充相关文档和默认值。
  • 扩展 PluginConfig,新增 drivers 字段,用于封装驱动选择和外部配置。
services/config_schema.py
重构驱动加载逻辑,以支持外部驱动、显式启用列表,并保留现有发现行为作为回退方案。
  • 扩展 PluginInfo,增加外部来源类型字段。
  • 将插件解析逻辑抽取到 _resolve_drivers 辅助函数中,通过构建有序插件映射来尊重外部覆盖配置。
  • 更新 load_all_drivers,使其接受 DriversConfig,在缺失时使用默认值,并通过 _resolve_drivers 进行统一解析。
  • 修改模块加载逻辑,将外部驱动在导入上视为与 entrypoint 模块相同。
services/plugin_loader.py
在启动流程中接入新的驱动选择配置,使启用列表和外部驱动定义决定加载哪些驱动。
  • 移除之前基于顶层配置键推导的通用 enabled_platforms 逻辑。
  • 从已校验的全局插件配置中读取 DriversConfig,如存在启用列表则从中推导出启用的平台。
  • 确保任何通过外部配置的驱动,即使未在启用列表中显式列出,也会被加入到启用平台集合中。
  • 将 DriversConfig 传入 load_all_drivers,并调整日志描述以提及外部驱动。
main.py
在各驱动中跟踪入站消息是否为私信(DM),并据此仅对 DM 放宽 bind 命令限制。
  • 在 NormalizedMessage 中新增布尔字段 is_dm,用于标记直接/私密消息。
  • 在 Discord、飞书、Telegram 和 云湖 驱动中,根据各平台的消息元数据正确设置 is_dm。
  • 调整内部命令处理逻辑:当消息为 DM 时,允许在未配置的频道中使用 bind 命令,而其他命令仍保持受限。
services/message.py
drivers/discord.py
drivers/feishu.py
drivers/telegram.py
drivers/yunhu.py
services/bridge.py
扩展 QQ 驱动以支持私聊消息的处理与发送,并修复轻微的类型标注和日志行为问题。
  • 在主处理函数中处理私聊消息事件,并分发给新的 _on_private_message 辅助函数。
  • 将 QQ 私聊消息负载规范化为具有 DM 语义的 NormalizedMessage 实例,包括 @ 提及解析和头像 URL 构造。
  • 新增 _api_send_private_msg 辅助函数,通过 OneBot 发送私聊消息,并在仅提供 user_id 时将其集成到 send() 中。
  • 收紧 send() 的频道校验逻辑,要求必须提供 group_id 或 user_id,并在群聊流程中添加断言以确保 group_id 存在。
  • 调整消息段组合逻辑的格式,使其更有利于类型检查器并提高可读性。
drivers/qq.py

提示和命令

与 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 摘要。你也可以在 Pull Request 中评论 @sourcery-ai summary 来(重新)生成摘要。
  • 生成审阅者指南: 在 Pull Request 中评论 @sourcery-ai guide,即可随时(重新)生成审阅者指南。
  • 解决所有 Sourcery 评论: 在 Pull Request 中评论 @sourcery-ai resolve,以解决所有 Sourcery 评论。如果你已经处理完所有评论且不希望再看到它们,这会很有用。
  • 忽略所有 Sourcery 审阅: 在 Pull Request 中评论 @sourcery-ai dismiss,以忽略所有现有的 Sourcery 审阅。特别适用于你想从头开始一次新的审阅 —— 别忘了再评论 @sourcery-ai review 来触发新一轮审阅!

自定义你的体验

访问你的 仪表盘 以:

  • 启用或停用诸如 Sourcery 自动生成的 Pull Request 摘要、审阅者指南等审阅功能。
  • 修改审阅语言。
  • 添加、删除或编辑自定义审阅指令。
  • 调整其他审阅设置。

获取帮助

Original review guide in English

Reviewer's Guide

Implements configurable driver selection with support for external driver modules, adjusts startup driver discovery to honor explicit enable lists and external overrides while remaining backwards compatible, and enhances message handling to track direct messages and support QQ private messaging and DM-based internal commands.

Sequence diagram for DM bind command handling via NormalizedMessage.is_dm

sequenceDiagram
    participant Driver as PlatformDriver
    participant Bridge as Bridge

    Driver->>Bridge: on_message(NormalizedMessage(is_dm=True))
    Bridge->>Bridge: _parse_internal_command(msg.text)
    alt command detected
        Bridge->>Bridge: _is_allowed_command_source(msg)
        alt allowed source
            Bridge->>Bridge: handle command
        else not allowed source
            alt action == bind and msg.is_dm
                Bridge-->>Bridge: allow bind from DM
                Bridge->>Bridge: handle bind
            else other command or not DM
                Bridge-->>Bridge: ignore command
            end
        end
    else no command
        Bridge-->>Bridge: normal message handling
    end
Loading

File-Level Changes

Change Details Files
Add driver selection and external driver import configuration models and integrate them into plugin configuration.
  • Introduce ExternalDriverConfig model specifying Python module path for external drivers.
  • Introduce DriversConfig model with enabled driver list and external driver map, including documentation and defaults.
  • Extend PluginConfig to include a drivers field that encapsulates driver selection and external configuration.
services/config_schema.py
Refactor driver loading to support external drivers, explicit enable lists, and preserve existing discovery behavior as a fallback.
  • Extend PluginInfo to include an external source type.
  • Factor plugin resolution into a _resolve_drivers helper that builds an ordered plugin map honoring external overrides.
  • Update load_all_drivers to accept DriversConfig, default it when absent, and use _resolve_drivers for unified resolution.
  • Change module loading logic to treat external drivers like entrypoint modules for import purposes.
services/plugin_loader.py
Wire the new driver selection configuration into startup so enabled lists and external driver definitions control which drivers are loaded.
  • Remove previous generic enabled_platforms derivation from top-level config keys.
  • Read DriversConfig from validated global plugin configuration and derive enabled platforms from the enabled list when present.
  • Ensure any externally configured drivers are added to the enabled platform set even if not explicitly listed.
  • Pass DriversConfig into load_all_drivers and adjust logging description to mention external drivers.
main.py
Track whether inbound messages are direct messages (DMs) across drivers and use this to relax bind-command restrictions for DMs only.
  • Add an is_dm boolean field to NormalizedMessage to flag direct/private messages.
  • Set is_dm appropriately in Discord, Feishu, Telegram, and Yunhu drivers based on platform-specific message metadata.
  • Adjust internal command handling to allow the bind command from unconfigured channels when the message is a DM, while keeping other commands restricted.
services/message.py
drivers/discord.py
drivers/feishu.py
drivers/telegram.py
drivers/yunhu.py
services/bridge.py
Extend QQ driver to support private message handling and sending, and fix minor typing and logging behavior.
  • Handle private message events in the main handler by dispatching to a new _on_private_message helper.
  • Normalize QQ private message payloads into NormalizedMessage instances with DM semantics, including mention parsing and avatar URL construction.
  • Add _api_send_private_msg helper for sending private messages via OneBot and integrate it into send() when only a user_id is provided.
  • Tighten send() channel validation to require either group_id or user_id and add an assertion ensuring group_id presence in group flows.
  • Adjust segment combination logic formatting for type checker friendliness and readability.
drivers/qq.py

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

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

  • services/plugin_loader._resolve_drivers 中,drivers_cfg: DriversConfig 这个注解引用的 DriversConfig 只在 TYPE_CHECKING 分支下被导入;如果注解会在运行时被求值(没有使用 from __future__ import annotations),这会在运行时报 NameError。建议要么把注解写成字符串形式('DriversConfig'),要么在运行时同样导入 DriversConfig
  • DriversConfig 中,字段 enabled: list[str] = []external: dict[str, ExternalDriverConfig] = {} 使用了可变的默认值,这可能导致多个实例之间共享状态;对于 Pydantic 模型,更安全的写法是使用 Field(default_factory=list)Field(default_factory=dict)
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `services/plugin_loader._resolve_drivers`, the `drivers_cfg: DriversConfig` annotation refers to `DriversConfig` only imported under `TYPE_CHECKING`; if annotations are evaluated at runtime (no `from __future__ import annotations`), this will raise a `NameError`—consider using a string annotation (`'DriversConfig'`) or importing `DriversConfig` at runtime as well.
- In `DriversConfig` the fields `enabled: list[str] = []` and `external: dict[str, ExternalDriverConfig] = {}` use mutable defaults, which can lead to shared state across instances; for Pydantic models it’s safer to use `Field(default_factory=list)` and `Field(default_factory=dict)` instead.

## Individual Comments

### Comment 1
<location path="drivers/qq.py" line_range="532-541" />
<code_context>
+
+        time = event.get("time")
+
+        segments = event.get("message", [])
+        if isinstance(segments, str):
+            text = segments
+            mentions = []
+        else:
+            text_parts = []
+            mentions = []
+            for seg in segments:
+                t = seg.get("type", "")
+                d = seg.get("data", {})
+                if t == "text":
+                    text_parts.append(d.get("text", ""))
+                elif t == "at":
+                    qq = str(d.get("qq", ""))
+                    if qq:
+                        mentions.append({"id": qq, "name": qq})
+                        text_parts.append(f"@{qq}")
+            text = "".join(text_parts)
+
+        if not text.strip():
</code_context>
<issue_to_address>
**issue (bug_risk):** 非文本的私聊消息片段会被悄悄丢弃,从而导致仅包含媒体内容的私信看起来是空的。

只有 `text``at` 片段会参与构造 `text`,所以如果一条私信只包含媒体(图片/文件等),最终会得到 `text == ""`,并被 `if not text.strip()` 这行代码过滤掉。如果你希望转发仅含媒体的私信,可以考虑以下两种方式:

- 将非文本片段映射为占位符(例如 `[image]`),或者
- 保留针对空文本的检查,但在存在非文本片段时跳过该检查。

如果设计上私信只用于文本命令,建议显式地强制这一约束(例如拒绝非文本命令),而不是隐式地把仅含媒体的私信当作空消息处理。
</issue_to_address>

### Comment 2
<location path="drivers/qq.py" line_range="2204-2208" />
<code_context>
             )
             return None

+        if user_id and not group_id:
+            segments: list[dict] = []
+            if text:
+                segments.append({"type": "text", "data": {"text": text}})
+            msg_id = await self._api_send_private_msg(user_id, segments)
+            return [msg_id] if msg_id else None
+
</code_context>
<issue_to_address>
**issue (bug_risk):** 发送私聊消息时会忽略附件以及回复/提及的元数据。

这一执行路径只为 `_api_send_private_msg` 构造了一个包含 `text` 片段的列表,因此通过 `kwargs` 传入的任何 `attachments` 或回复/提及上下文都会被悄悄忽略,这和群聊发送路径的行为不一致。为了保持行为一致,可以复用群聊消息使用的那套片段构造 + 延迟上传逻辑,或者明确规定/文档化“私信只支持纯文本”,并确保调用方遵守这一约定。
</issue_to_address>

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

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

  • In services/plugin_loader._resolve_drivers, the drivers_cfg: DriversConfig annotation refers to DriversConfig only imported under TYPE_CHECKING; if annotations are evaluated at runtime (no from __future__ import annotations), this will raise a NameError—consider using a string annotation ('DriversConfig') or importing DriversConfig at runtime as well.
  • In DriversConfig the fields enabled: list[str] = [] and external: dict[str, ExternalDriverConfig] = {} use mutable defaults, which can lead to shared state across instances; for Pydantic models it’s safer to use Field(default_factory=list) and Field(default_factory=dict) instead.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `services/plugin_loader._resolve_drivers`, the `drivers_cfg: DriversConfig` annotation refers to `DriversConfig` only imported under `TYPE_CHECKING`; if annotations are evaluated at runtime (no `from __future__ import annotations`), this will raise a `NameError`—consider using a string annotation (`'DriversConfig'`) or importing `DriversConfig` at runtime as well.
- In `DriversConfig` the fields `enabled: list[str] = []` and `external: dict[str, ExternalDriverConfig] = {}` use mutable defaults, which can lead to shared state across instances; for Pydantic models it’s safer to use `Field(default_factory=list)` and `Field(default_factory=dict)` instead.

## Individual Comments

### Comment 1
<location path="drivers/qq.py" line_range="532-541" />
<code_context>
+
+        time = event.get("time")
+
+        segments = event.get("message", [])
+        if isinstance(segments, str):
+            text = segments
+            mentions = []
+        else:
+            text_parts = []
+            mentions = []
+            for seg in segments:
+                t = seg.get("type", "")
+                d = seg.get("data", {})
+                if t == "text":
+                    text_parts.append(d.get("text", ""))
+                elif t == "at":
+                    qq = str(d.get("qq", ""))
+                    if qq:
+                        mentions.append({"id": qq, "name": qq})
+                        text_parts.append(f"@{qq}")
+            text = "".join(text_parts)
+
+        if not text.strip():
</code_context>
<issue_to_address>
**issue (bug_risk):** Non-text private message segments are silently dropped, making pure-media DMs appear empty.

Only `text` and `at` segments contribute to `text`, so a DM containing only media (image/file/etc.) ends up with `text == ""` and is dropped by `if not text.strip()`. If media-only DMs should be bridged, you could either:

- Map non-text segments to a placeholder (e.g. `[image]`), or
- Keep the empty-text check but bypass it when non-text segments are present.

If DMs are intended to be text-only commands, consider explicitly enforcing that (e.g. by rejecting non-text commands) rather than implicitly treating media-only DMs as empty.
</issue_to_address>

### Comment 2
<location path="drivers/qq.py" line_range="2204-2208" />
<code_context>
             )
             return None

+        if user_id and not group_id:
+            segments: list[dict] = []
+            if text:
+                segments.append({"type": "text", "data": {"text": text}})
+            msg_id = await self._api_send_private_msg(user_id, segments)
+            return [msg_id] if msg_id else None
+
</code_context>
<issue_to_address>
**issue (bug_risk):** Sending private messages ignores attachments and reply/mention metadata.

This path only builds a `text` segment list for `_api_send_private_msg`, so any `attachments` or reply/mention context passed via `kwargs` are silently ignored, unlike the group send path. To keep behavior consistent, either reuse the same segment-building + deferred upload logic used for group messages, or explicitly define/document that DMs are text-only and ensure callers adhere to that contract.
</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 drivers/qq.py Outdated
Comment thread drivers/qq.py Outdated
- _on_private_message: reuse _parse_message to handle all segment types
  (images, files, replies, cqface, etc.) instead of only text/at
- send: remove early return for private messages; use shared segment
  construction + unified _send_msg helper that dispatches to
  _api_send_group_msg or _api_send_private_msg
- _do_upload: branch on is_group for upload_group_file/upload_private_file
- _upload_group_file_from_bytes -> _upload_file_from_bytes with
  configurable upload_api/id_key params
- at segments converted to plain text in private chats
@siiway-code-review

siiway-code-review Bot commented Jun 14, 2026

Copy link
Copy Markdown

Summary by Claude Review

New Features:

  • Add DriversConfig and ExternalDriverConfig models to PluginConfig for explicit driver selection
  • Support importing drivers from external Python packages with override capability over built-ins
  • Add private/direct message handling and sending in the QQ (NapCat) driver
  • Introduce is_dm flag on NormalizedMessage, set by Discord, Feishu, Telegram, QQ, and Yunhu drivers
  • Allow the internal bind command to run from DMs even when the channel is not yet configured

Enhancements:

  • Generalize QQ file upload to support both group and private files via upload_api/id_key parameters
  • Convert @mentions to plain text when sending in QQ private chats
  • Plugin loader now resolves drivers from external > local > entrypoint > builtin priority
  • main.py uses configured drivers.enabled list when provided, falling back to auto-discovery

Bug Fixes:

  • Fix ty type-check errors in drivers/qq.py (assertions and ignore comments)

Documentation:

  • Add docstrings and YAML example for DriversConfig and ExternalDriverConfig

Chores:

  • Refactor plugin_loader to split _resolve_drivers from load_all_drivers
  • Update plugin_loader module docstring for the new source ordering

Reviewer's Guide by Claude Review

This PR introduces configurable driver selection via a new DriversConfig (with an external import map) wired into PluginConfig, and updates main.py + plugin_loader to honor it. External drivers take priority over built-in/entrypoint/local with the same name. In parallel, it adds direct-message (DM) handling: a new is_dm flag on NormalizedMessage, populated by Discord/Feishu/Telegram/Yunhu/QQ drivers; QQ gains a full _on_private_message handler and a private-message send path (including private file uploads and at→text conversion in private chats). Bridge command routing is loosened so that bind can be issued from DMs even when the source channel isn't yet configured. The plugin loader gets a new 'external' source kind and a refactored resolution helper.

File-Level Changes

File Changes
drivers/discord.py Set is_dm=True when server_id is empty on incoming messages.
drivers/feishu.py Set is_dm based on chat_type == 'p2p_chat' on incoming messages.
drivers/qq.py Add _on_private_message handler; rename/extend _upload_group_file_from_bytes to generic _upload_file_from_bytes; add _api_send_private_msg; refactor send() to route by group_id/user_id, converting @mentions to text in private chats and using upload_private_file for DMs; minor ty-ignore formatting.
drivers/telegram.py Set is_dm=True when chat_id > 0.
drivers/yunhu.py Set is_dm based on chat_type == 'user'.
main.py Read plugin_cfg.drivers; build enabled_platforms from drivers_cfg.enabled (else auto-discover), append externals, pass drivers_cfg through to load_all_drivers.
services/bridge.py Allow bind command from DMs even when source channel is not whitelisted; otherwise still drop.
services/config_schema.py Add ExternalDriverConfig and DriversConfig models; attach DriversConfig to PluginConfig.
services/message.py Add is_dm: bool field defaulting to False on NormalizedMessage.
services/plugin_loader.py Add 'external' source; split _resolve_drivers from load_all_drivers; accept DriversConfig; externals override built-in/entrypoint/local with same name.

Diagram

flowchart TD
    A[main.py] --> B{drivers_cfg.enabled?}
    B -- yes --> C[enabled = drivers_cfg.enabled]
    B -- no --> D[enabled = top-level config keys]
    C --> E[append external names]
    D --> E
    E --> F[load_all_drivers]
    F --> G[_resolve_drivers]
    G --> H1[builtin drivers.<name>]
    G --> H2[external module override]
    G --> H3[entrypoint if not external]
    G --> H4[local path if not external]
    H1 --> I[importlib.import_module]
    H2 --> I
    H3 --> I
    H4 --> I
Loading

Overall Comments

  • The DM-bind exception in services/bridge.py loosens command-source restrictions. Anyone able to DM a connected bot can now invoke bind regardless of channel allow-listing. Make sure bind itself enforces authorization (admin user list / token / shared secret) and isn't merely gated by the channel allow-list, otherwise this becomes a privilege-escalation vector for any platform user that can DM the bot. Consider documenting the required auth model for bind explicitly.
  • There are no tests added for: (a) DriversConfig precedence (external > local > entrypoint > builtin), (b) the fallback to auto-discovery when enabled is empty, (c) DM routing in QQ send(), or (d) the bind-from-DM bridge change. Given these are configuration/security-sensitive paths, unit tests would significantly reduce regression risk.
  • The is_dm heuristics vary by platform (Telegram: chat_id > 0; Discord: server_id == ''; Feishu: chat_type == 'p2p_chat'; Yunhu: chat_type == 'user'; QQ: hard-coded True in private handler). Consider centralizing a brief comment/spec for what is_dm semantically means (1:1 user↔bot only, or any non-guild context including group DMs?) so future drivers set it consistently. Telegram's chat_id > 0 will be True for both private chats AND certain non-supergroup IDs in theory; double-check it doesn't classify e.g. bot-to-bot or channel posts incorrectly.
  • Naming nit: _api_send_private_msg's user_id and message parameters are untyped while the surrounding API helpers seem to follow a similar untyped style — at minimum, add the str | int annotation you already rely on with int(user_id) to make intent clear.
  • The external-driver feature imports arbitrary modules named in user config. That's fine for a self-hosted bridge, but worth a one-line note in the README/docs that external drivers run with full process privileges (no sandbox) so operators don't treat the config as untrusted input.
  • Note: 3 finding(s) were filtered by the output safety check.

Found 2 issue(s) — see inline comments.

Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The DM-bind exception in services/bridge.py loosens command-source restrictions. Anyone able to DM a connected bot can now invoke `bind` regardless of channel allow-listing. Make sure `bind` itself enforces authorization (admin user list / token / shared secret) and isn't merely gated by the channel allow-list, otherwise this becomes a privilege-escalation vector for any platform user that can DM the bot. Consider documenting the required auth model for bind explicitly.
- There are no tests added for: (a) DriversConfig precedence (external > local > entrypoint > builtin), (b) the fallback to auto-discovery when `enabled` is empty, (c) DM routing in QQ send(), or (d) the bind-from-DM bridge change. Given these are configuration/security-sensitive paths, unit tests would significantly reduce regression risk.
- The is_dm heuristics vary by platform (Telegram: chat_id > 0; Discord: server_id == ''; Feishu: chat_type == 'p2p_chat'; Yunhu: chat_type == 'user'; QQ: hard-coded True in private handler). Consider centralizing a brief comment/spec for what is_dm semantically means (1:1 user↔bot only, or any non-guild context including group DMs?) so future drivers set it consistently. Telegram's `chat_id > 0` will be True for both private chats AND certain non-supergroup IDs in theory; double-check it doesn't classify e.g. bot-to-bot or channel posts incorrectly.
- Naming nit: `_api_send_private_msg`'s `user_id` and `message` parameters are untyped while the surrounding API helpers seem to follow a similar untyped style — at minimum, add the `str | int` annotation you already rely on with `int(user_id)` to make intent clear.
- The external-driver feature imports arbitrary modules named in user config. That's fine for a self-hosted bridge, but worth a one-line note in the README/docs that external drivers run with full process privileges (no sandbox) so operators don't treat the config as untrusted input.
- _Note: 3 finding(s) were filtered by the output safety check._

## Individual Comments

### Comment 1
<location path="drivers/qq.py" line="562" />
<code_context>
</code_context>
<issue_to_address>
**warning (bug_risk):** `datetime.datetime.fromtimestamp(time)` uses local time without a tz. Elsewhere in this codebase ISO strings are typically expected to be timezone-aware/UTC; emitting a naive local-time ISO string for DMs will be inconsistent with group messages and any downstream consumer that parses these. It also silently breaks if `time` is a string from some OneBot impls.

```python
ts = event.get("time")
time_iso = None
if isinstance(ts, (int, float)):
    time_iso = datetime.datetime.fromtimestamp(ts, tz=datetime.timezone.utc).isoformat()
# ...
    time=time_iso,
```
</issue_to_address>

### Comment 2
<location path="drivers/qq.py" line="2194" />
<code_context>
</code_context>
<issue_to_address>
**info (maintainability):** `assert group_id or user_id` is a runtime no-op under `python -O` and duplicates the check just above. The `assert` lines sprinkled inside `_do_upload` likewise exist only to appease the type checker. Prefer narrowing via explicit branches or local variables so logic stays correct even with assertions disabled.

```python
is_group = bool(group_id)
if is_group:
    assert group_id is not None  # for type checker only
    target_id: str = str(group_id)
else:
    assert user_id is not None
    target_id = str(user_id)

async def _send_msg(segments):
    if is_group:
        return await self._api_send_group_msg(target_id, segments)
    return await self._api_send_private_msg(target_id, segments)
```
Then pass `target_id` into `_do_upload` instead of re-deriving from `group_id`/`user_id` inside the closure.
</issue_to_address>

Reviewed by Claude Review

@siiway-code-review siiway-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

New Features: Add DriversConfig and ExternalDriverConfig models to PluginConfig for explicit driver selection; New Features: Support importing drivers from external Python packages with override capability over built-ins; New Features: Add private/direct message handling and sending in the QQ (NapCat) driver; New Features: Introduce is_dm flag on NormalizedMessage, set by Discord, Feishu, Telegram, QQ, and Yunhu drivers; New Features: Allow the internal bind command to run from DMs even when the channel is not yet configured; Enhancements: Generalize QQ file upload to support both group and private files via upload_api/id_key parameters; Enhancements: Convert @mentions to plain text when sending in QQ private chats; Enhancements: Plugin loader now resolves drivers from external > local > entrypoint > builtin priority; Enhancements: main.py uses configured drivers.enabled list when provided, falling back to auto-discovery; Bug Fixes: Fix ty type-check errors in drivers/qq.py (assertions and ignore comments); Documentation: Add docstrings and YAML example for DriversConfig and ExternalDriverConfig; Chores: Refactor plugin_loader to split _resolve_drivers from load_all_drivers; Chores: Update plugin_loader module docstring for the new source ordering

Comment thread drivers/qq.py
source_self_id=self_id,
source_mentioned_self=source_mentioned_self,
time=datetime.datetime.fromtimestamp(time).isoformat() if time else None,
source_proxy=self._media_proxy,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

warning (bug_risk): datetime.datetime.fromtimestamp(time) uses local time without a tz. Elsewhere in this codebase ISO strings are typically expected to be timezone-aware/UTC; emitting a naive local-time ISO string for DMs will be inconsistent with group messages and any downstream consumer that parses these. It also silently breaks if time is a string from some OneBot impls.

ts = event.get("time")
time_iso = None
if isinstance(ts, (int, float)):
    time_iso = datetime.datetime.fromtimestamp(ts, tz=datetime.timezone.utc).isoformat()
# ...
    time=time_iso,

Comment thread drivers/qq.py
)
return None

is_group = bool(group_id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

info (maintainability): assert group_id or user_id is a runtime no-op under python -O and duplicates the check just above. The assert lines sprinkled inside _do_upload likewise exist only to appease the type checker. Prefer narrowing via explicit branches or local variables so logic stays correct even with assertions disabled.

is_group = bool(group_id)
if is_group:
    assert group_id is not None  # for type checker only
    target_id: str = str(group_id)
else:
    assert user_id is not None
    target_id = str(user_id)

async def _send_msg(segments):
    if is_group:
        return await self._api_send_group_msg(target_id, segments)
    return await self._api_send_private_msg(target_id, segments)

Then pass target_id into _do_upload instead of re-deriving from group_id/user_id inside the closure.

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.

2 participants