Skip to content

feat: 为插件添加统一webhook#5758

Closed
moemoli wants to merge 0 commit into
AstrBotDevs:masterfrom
moemoli:master
Closed

feat: 为插件添加统一webhook#5758
moemoli wants to merge 0 commit into
AstrBotDevs:masterfrom
moemoli:master

Conversation

@moemoli

@moemoli moemoli commented Mar 5, 2026

Copy link
Copy Markdown
Contributor

Modifications / 改动点

本次改动的动机是为插件体系补齐“统一 webhook 注册与回调分发”能力,解决插件无法像平台适配器一样通过统一入口接收外部回调的问题;同时提供最小示例插件,便于快速验证链路是否可用。

  • 在 astrbot/core/star/context.py 中新增插件统一 webhook 注册表与注册方法:

    • 新增 registered_unified_webhooks
    • 新增 register_unified_webhook(webhook_uuid, view_handler, methods, desc)
  • 在 astrbot/core/star/base.py 中为 Star 新增便捷方法:

    • Star.register_unified_webhook(...)
    • 便于插件内直接注册统一 webhook 回调
  • 在 astrbot/dashboard/server.py 中新增插件统一 webhook 路由与分发:

    • 新增 /api/plug/webhook/<webhook_uuid>
    • 按 webhook_uuid 从 Star Context 注册表查找并转发
    • 增加该路由到鉴权放行列表,支持外部平台直接回调
  • This is NOT a breaking change. / 这不是一个破坏性变更。

Screenshots or Test Results / 运行截图或测试结果

image image

Usage / 使用方法

在插件 initialize 方法注册:

import uuid

from quart import jsonify, request

from astrbot.api import logger
from astrbot.api.star import Context, Star


class Main(Star):
    def __init__(self, context: Context) -> None:
        super().__init__(context)
        self.webhook_uuid = ""

    async def initialize(self) -> None:
        webhook_uuid = await self.get_kv_data("webhook_uuid", "")
        if not webhook_uuid:
            webhook_uuid = uuid.uuid4().hex[:16]
            await self.put_kv_data("webhook_uuid", webhook_uuid)
        self.webhook_uuid = webhook_uuid
        self.register_unified_webhook(
            webhook_uuid=self.webhook_uuid,
            view_handler=self.unified_webhook_callback,
            methods=["GET", "POST"],
            desc="Minimal unified webhook callback for plugin testing.",
        )

        cfg = self.context.get_config()
        callback_base = str(cfg.get("callback_api_base", "")).strip().rstrip("/")
        if callback_base:
            if not callback_base.startswith("http"):
                callback_base = f"http://{callback_base}"
            webhook_url = f"{callback_base}/api/plug/webhook/{self.webhook_uuid}"
        else:
            dashboard_port = cfg.get("dashboard", {}).get("port", 6185)
            webhook_url = f"http://<your-ip>:{dashboard_port}/api/plug/webhook/{self.webhook_uuid}"

        logger.info(f"[unified_webhook_demo] Unified webhook registered: {webhook_url}")

    async def unified_webhook_callback(self):
        if request.method == "GET":
            return jsonify(
                {
                    "ok": True,
                    "plugin": "unified_webhook_demo",
                    "method": "GET",
                    "webhook_uuid": self.webhook_uuid,
                    "message": "plugin unified webhook is reachable",
                }
            )

        payload = await request.get_json(silent=True) or {}
        return jsonify(
            {
                "ok": True,
                "plugin": "unified_webhook_demo",
                "method": "POST",
                "webhook_uuid": self.webhook_uuid,
                "received": payload,
            }
        )

Checklist / 检查清单

  • 😊 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。/ If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
  • 👀 我的更改经过了良好的测试,并已在上方提供了“验证步骤”和“运行截图”。/ My changes have been well-tested, and "Verification Steps" and "Screenshots" have been provided above.
  • 🤓 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到了 requirements.txtpyproject.toml 文件相应位置。/ I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in requirements.txt and pyproject.toml.
  • 😮 我的更改没有引入恶意代码。/ My changes do not introduce malicious code.

Summary by Sourcery

通过仪表盘(dashboard)API,为插件添加统一的 webhook 注册和分发支持。

New Features:

  • 在插件的 Context 和 Star 基类上提供统一的 webhook 注册 API,使插件能够通过 UUID 注册 webhook 回调。
  • 新增一个仪表盘路由,用于接收统一的插件 webhook 回调,并将其分发到已注册的插件处理程序。

Enhancements:

  • 规范化并存储已注册统一 webhooks 的 HTTP 方法和元数据,包括支持覆盖现有的注册信息。
  • 允许对统一插件 webhook 端点进行未认证访问,以支持外部回调集成。
Original summary in English

Summary by Sourcery

Add unified webhook registration and dispatch support for plugins via the dashboard API.

New Features:

  • Expose a unified webhook registration API on the plugin Context and Star base class, enabling plugins to register webhook callbacks by UUID.
  • Introduce a dashboard route to receive unified plugin webhook callbacks and dispatch them to the registered plugin handlers.

Enhancements:

  • Normalize and store HTTP methods and metadata for registered unified webhooks, including support for overriding existing registrations.
  • Allow unauthenticated access to the unified plugin webhook endpoint to support external callback integrations.

@auto-assign auto-assign Bot requested review from Raven95676 and advent259141 March 5, 2026 03:07
@dosubot dosubot Bot added the size:M This PR changes 30-99 lines, ignoring generated files. label Mar 5, 2026
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

此拉取请求旨在通过引入统一的 Webhook 注册和分发机制,增强插件系统的功能。此前,插件无法像平台适配器那样通过统一入口接收外部回调,限制了其与外部服务的集成能力。本次变更通过在核心上下文中建立 Webhook 注册表、为插件提供便捷的注册方法以及在 Dashboard 中实现专用的路由和分发逻辑,解决了这一问题。这使得插件能够无缝地接收和处理来自外部系统的回调,从而扩展了其潜在的应用场景和集成能力。

Highlights

  • 统一 Webhook 注册与分发: 为插件体系引入了统一的 Webhook 注册与回调分发能力,解决了插件无法通过统一入口接收外部回调的问题。
  • 核心组件更新: 在 astrbot/core/star/context.py 中新增了 registered_unified_webhooks 注册表和 register_unified_webhook 方法,用于管理插件的 Webhook 注册。
  • 插件便捷方法: 在 astrbot/core/star/base.py 中为 Star 类新增了 register_unified_webhook 便捷方法,方便插件直接注册统一 Webhook 回调。
  • Dashboard 路由与鉴权: 在 astrbot/dashboard/server.py 中新增了 /api/plug/webhook/<webhook_uuid> 路由,负责接收外部 Webhook 请求并转发给对应的插件处理函数,并将其添加到鉴权放行列表。
  • 提供最小示例插件: 提供了最小示例插件,以便快速验证统一 Webhook 链路是否可用。
Changelog
  • astrbot/core/star/base.py
    • 导入了 CallableAwaitable 类型提示。
    • 更新了 _ContextLike 协议,使其包含 register_unified_webhook 方法的定义。
    • Star 类中新增了 register_unified_webhook 方法,作为插件注册统一 Webhook 的便捷入口。
  • astrbot/core/star/context.py
    • 新增了 registered_unified_webhooks 字典,用于存储已注册的统一 Webhook 回调函数及其相关信息。
    • 实现了 register_unified_webhook 方法,用于向 registered_unified_webhooks 注册新的 Webhook 回调,并支持覆盖相同 webhook_uuid 的现有注册。
  • astrbot/dashboard/server.py
    • /api/plug/webhook/<webhook_uuid> 路径添加了新的 URL 规则,将其映射到 srv_plug_webhook_route 方法。
    • 新增了 srv_plug_webhook_route 异步方法,负责根据 webhook_uuid 从上下文查找并调用对应的插件 Webhook 处理函数,并处理未找到 Webhook 或请求方法不被允许的情况。
    • /api/plug/webhook 添加到 auth_middleware 的鉴权放行列表中,允许外部请求直接访问插件 Webhook 路由。
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hey - 我发现了 1 个问题,并留下了一些整体性的反馈:

  • 统一 webhook 注册表目前作为类级可变字典(registered_unified_webhooks)存储在 Context 上,这可能会在多个 Context 实例之间泄露状态(例如在测试或多进程/多线程环境中);建议将其移动为在 __init__ 中初始化的实例属性,以获得更好的隔离性。
  • srv_plug_webhook_route 中,你将 request.method 直接与已存储的 methods 列表进行比较,而该列表已被规范化为大写;为了避免任何细微的大小写问题并让逻辑更加自洽,建议改为使用 if request.method.upper() not in methods:
  • 鉴权白名单中添加的是 "/api/plug/webhook",而实际路由是 "/api/plug/webhook/<webhook_uuid>";请仔细检查匹配逻辑(例如是全等匹配还是前缀匹配)是否确实把所有 webhook 回调都视作在白名单中,并根据 /api/platform/webhook 的处理方式调整字符串或条件以保持一致。
给 AI Agent 的提示词
请根据这次代码评审中的评论进行修改:

## 整体性评论
- 统一 webhook 注册表目前作为类级可变字典(`registered_unified_webhooks`)存储在 `Context` 上,这可能会在多个 `Context` 实例之间泄露状态(例如在测试或多进程/多核环境中);建议将其移动为在 `__init__` 中初始化的实例属性,以获得更好的隔离性。
-`srv_plug_webhook_route` 中,你将 `request.method` 直接与已存储的 `methods` 列表进行比较,而该列表已被规范化为大写;为了避免任何细微的大小写问题并让逻辑更加自洽,建议改为使用 `if request.method.upper() not in methods:`- 鉴权白名单中添加的是 `"/api/plug/webhook"`,而实际路由是 `"/api/plug/webhook/<webhook_uuid>"`;请仔细检查匹配逻辑(例如是全等匹配还是前缀匹配)是否确实把所有 webhook 回调都视作在白名单中,并根据 `/api/platform/webhook` 的处理方式调整字符串或条件以保持一致。

## 单独评论

### 评论 1
<location path="astrbot/core/star/context.py" line_range="60" />
<code_context>
     """暴露给插件的接口上下文。"""

     registered_web_apis: list = []
+    registered_unified_webhooks: dict[
+        str, tuple[Callable[..., Awaitable[Any]], list[str], str]
+    ] = {}
</code_context>
<issue_to_address>
**issue (complexity):** 考虑用一个带类型的小容器替换现在的位置参数元组,并将 webhook 注册表改为实例属性而不是类级状态,以简化理解和使用。

你可以通过两点来降低这里的心智负担:(1) 用一个带类型的小容器替换位置参数元组;(2) 避免将注册表定义为类级可变状态。

### 1. 用小型 dataclass / TypedDict 替换元组

现在调用方必须记住 `(handler, methods, desc)` 的顺序。用一个小 dataclass 可以让结构自解释:

```python
from dataclasses import dataclass
from typing import Callable, Awaitable, Any

@dataclass
class UnifiedWebhook:
    handler: Callable[..., Awaitable[Any]]
    methods: list[str]
    desc: str = ""
```

然后修改注册表类型:

```python
class Context:
    # before:
    # registered_unified_webhooks: dict[
    #     str, tuple[Callable[..., Awaitable[Any]], list[str], str]
    # ] = {}

    registered_unified_webhooks: dict[str, UnifiedWebhook]
```

再更新 setter:

```python
def register_unified_webhook(
    self,
    webhook_uuid: str,
    view_handler: Callable[..., Awaitable[Any]],
    methods: list[str] | None = None,
    desc: str = "",
) -> None:
    normalized_methods = [method.upper() for method in (methods or ["GET", "POST"])]
    self.registered_unified_webhooks[webhook_uuid] = UnifiedWebhook(
        handler=view_handler,
        methods=normalized_methods,
        desc=desc,
    )
```

这样可以保持行为完全一致,但在调用点和阅读注册表时结构会更加清晰。

### 2. 将注册表移动为实例属性

当前的类属性让该注册表对所有 `Context` 实例来说实际上是全局共享的。除非你明确需要跨实例共享,否则可以通过按实例初始化来降低生命周期复杂度:

```python
class Context:
    def __init__(
        self,
        event_queue: Queue,
        config: AstrBotConfig,
        db: BaseDatabase,
        # ...
    ):
        self.registered_unified_webhooks: dict[str, UnifiedWebhook] = {}
        # existing init logic...
```

这样可以把 webhook 回调限定在各自的 `Context` 中,使测试中的状态更容易推理,并避免不同测试/不同实例之间的意外交叉污染,同时保留当前所有功能。
</issue_to_address>

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

Hey - I've found 1 issue, and left some high level feedback:

  • The unified webhook registry is stored on Context as a class-level mutable dict (registered_unified_webhooks), which can leak state across multiple Context instances (e.g., in tests or multi-core setups); consider moving this to an instance attribute initialized in __init__ for better isolation.
  • In srv_plug_webhook_route, you compare request.method directly against the stored methods list, which is normalized to upper case; to avoid any subtle case issues and keep the logic self-contained, consider doing if request.method.upper() not in methods:.
  • The auth whitelist adds "/api/plug/webhook" while the actual route is "/api/plug/webhook/<webhook_uuid>"; double-check that the matching logic (e.g., equality vs prefix) actually treats all webhook callbacks as whitelisted, and adjust the string or condition to be consistent with how /api/platform/webhook is handled.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The unified webhook registry is stored on `Context` as a class-level mutable dict (`registered_unified_webhooks`), which can leak state across multiple `Context` instances (e.g., in tests or multi-core setups); consider moving this to an instance attribute initialized in `__init__` for better isolation.
- In `srv_plug_webhook_route`, you compare `request.method` directly against the stored `methods` list, which is normalized to upper case; to avoid any subtle case issues and keep the logic self-contained, consider doing `if request.method.upper() not in methods:`.
- The auth whitelist adds `"/api/plug/webhook"` while the actual route is `"/api/plug/webhook/<webhook_uuid>"`; double-check that the matching logic (e.g., equality vs prefix) actually treats all webhook callbacks as whitelisted, and adjust the string or condition to be consistent with how `/api/platform/webhook` is handled.

## Individual Comments

### Comment 1
<location path="astrbot/core/star/context.py" line_range="60" />
<code_context>
     """暴露给插件的接口上下文。"""

     registered_web_apis: list = []
+    registered_unified_webhooks: dict[
+        str, tuple[Callable[..., Awaitable[Any]], list[str], str]
+    ] = {}
</code_context>
<issue_to_address>
**issue (complexity):** Consider replacing the positional tuple with a small typed container and making the webhook registry an instance attribute instead of class-level state to simplify understanding and usage.

You can reduce the mental overhead here by (1) replacing the positional tuple with a small typed container and (2) avoiding class-level mutable state for the registry.

### 1. Replace the tuple with a small dataclass / TypedDict

Right now callers have to remember `(handler, methods, desc)` ordering. A small dataclass makes this self-documenting:

```python
from dataclasses import dataclass
from typing import Callable, Awaitable, Any

@dataclass
class UnifiedWebhook:
    handler: Callable[..., Awaitable[Any]]
    methods: list[str]
    desc: str = ""
```

Then change the registry type:

```python
class Context:
    # before:
    # registered_unified_webhooks: dict[
    #     str, tuple[Callable[..., Awaitable[Any]], list[str], str]
    # ] = {}

    registered_unified_webhooks: dict[str, UnifiedWebhook]
```

And update the setter:

```python
def register_unified_webhook(
    self,
    webhook_uuid: str,
    view_handler: Callable[..., Awaitable[Any]],
    methods: list[str] | None = None,
    desc: str = "",
) -> None:
    normalized_methods = [method.upper() for method in (methods or ["GET", "POST"])]
    self.registered_unified_webhooks[webhook_uuid] = UnifiedWebhook(
        handler=view_handler,
        methods=normalized_methods,
        desc=desc,
    )
```

This keeps behavior identical but makes the structure obvious at call sites and when reading the registry.

### 2. Move registry to an instance attribute

The current class attribute makes the registry effectively global for all `Context` instances. Unless you explicitly rely on cross-instance sharing, you can reduce lifecycle complexity by initializing it per instance:

```python
class Context:
    def __init__(
        self,
        event_queue: Queue,
        config: AstrBotConfig,
        db: BaseDatabase,
        # ...
    ):
        self.registered_unified_webhooks: dict[str, UnifiedWebhook] = {}
        # existing init logic...
```

This isolates webhook callbacks to each `Context`, makes the state easier to reason about in tests, and avoids accidental cross-test / cross-instance leakage, while preserving all current functionality.
</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 astrbot/core/star/context.py Outdated
@dosubot dosubot Bot added the feature:plugin The bug / feature is about AstrBot plugin system. label Mar 5, 2026
@dosubot

dosubot Bot commented Mar 5, 2026

Copy link
Copy Markdown

Related Documentation

Checked 1 published document(s) in 1 knowledge base(s). No updates required.

How did I do? Any feedback?  Join Discord

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This PR introduces a unified webhook capability for the plugin system, with a clear design and implementation. However, the implementation contains a significant authentication bypass vulnerability due to overly broad prefix matching in the authentication middleware. Additionally, the webhook registry lacks a cleanup mechanism, potentially leading to stale webhooks, and there's a functional bug that could cause most webhook handlers to crash due to unexpected arguments. A minor issue was also identified in dashboard/server.py, where the new webhook route only accepts GET and POST methods, limiting plugin flexibility. These issues should be addressed before merging.

Comment thread astrbot/core/star/context.py Outdated
Comment on lines +60 to +62
registered_unified_webhooks: dict[
str, tuple[Callable[..., Awaitable[Any]], list[str], str]
] = {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

security-high high

The registered_unified_webhooks dictionary is defined as a class attribute in the Context class. It is never cleared when plugins are reloaded or uninstalled. This means that webhooks registered by a plugin will remain active even after the plugin has been disabled or removed. An attacker who knows the webhook_uuid can still trigger the old plugin's code, which may lead to unexpected behavior or security risks. This registry should be cleared or updated during the plugin unbinding process in PluginManager.

Comment thread astrbot/dashboard/server.py Outdated
allowed_endpoints = [
"/api/auth/login",
"/api/file",
"/api/plug/webhook",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

security-medium medium

The auth_middleware uses request.path.startswith(prefix) to bypass authentication for certain endpoints. By adding "/api/plug/webhook" to the allowed_endpoints list, any plugin-registered route that starts with webhook (e.g., /api/plug/webhook_info, /api/plug/webhook_settings) will also bypass authentication. This is because all plugin routes are mounted under /api/plug/. This leads to an unintended authentication bypass for potentially sensitive plugin APIs.

Comment thread astrbot/dashboard/server.py Outdated
if request.method not in methods:
return jsonify(Response().error("请求方法不被允许").__dict__), 405

return await view_handler(*args, **kwargs)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

security-medium medium

In srv_plug_webhook_route, the view_handler is called with *args and **kwargs. Since webhook_uuid is a variable part of the URL rule, it is passed as a keyword argument in kwargs. If the view_handler registered by the plugin does not accept this argument (as shown in the example in the PR description), the request will fail with a TypeError. Consider removing webhook_uuid from kwargs before calling the handler if it's not intended for the plugin's consumption.

Comment thread astrbot/dashboard/server.py Outdated
self.app.add_url_rule(
"/api/plug/webhook/<webhook_uuid>",
view_func=self.srv_plug_webhook_route,
methods=["GET", "POST"],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

当前 add_url_rule 中硬编码了 methods=["GET", "POST"],这会导致插件无法注册和使用 GETPOST 之外的其他 HTTP 方法(如 PUT, DELETE 等)。即使插件在 register_unified_webhook 中指定了其他方法,请求也会在到达 srv_plug_webhook_route 视图函数之前被 Quart 框架以 405 Method Not Allowed 拒绝。

为了让插件能够灵活使用各种 HTTP 方法,建议在这里允许更多常见的方法。具体的方法校验已在 srv_plug_webhook_route 函数中正确处理。

另外,/api/plug/<path:subpath> 路由似乎也存在类似限制,可以考虑一并修改。

Suggested change
methods=["GET", "POST"],
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"],

@moemoli moemoli closed this Mar 5, 2026
@dosubot dosubot Bot added size:XS This PR changes 0-9 lines, ignoring generated files. and removed size:M This PR changes 30-99 lines, ignoring generated files. labels Mar 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature:plugin The bug / feature is about AstrBot plugin system. size:XS This PR changes 0-9 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant