diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index b91c57c63..221727e1f 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -7,6 +7,14 @@ from astrbot.core.utils.astrbot_path import get_astrbot_data_path VERSION = "4.7.4" DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db") +WEBHOOK_SUPPORTED_PLATFORMS = [ + "qq_official_webhook", + "weixin_official_account", + "wecom", + "wecom_ai_bot", + "slack", +] + # 默认配置 DEFAULT_CONFIG = { "config_version": 2, @@ -185,6 +193,8 @@ CONFIG_METADATA_2 = { "appid": "", "secret": "", "is_sandbox": False, + "unified_webhook_mode": True, + "webhook_uuid": "", "callback_server_host": "0.0.0.0", "port": 6196, }, @@ -215,6 +225,8 @@ CONFIG_METADATA_2 = { "token": "", "encoding_aes_key": "", "api_base_url": "https://api.weixin.qq.com/cgi-bin/", + "unified_webhook_mode": True, + "webhook_uuid": "", "callback_server_host": "0.0.0.0", "port": 6194, "active_send_mode": False, @@ -229,6 +241,8 @@ CONFIG_METADATA_2 = { "encoding_aes_key": "", "kf_name": "", "api_base_url": "https://qyapi.weixin.qq.com/cgi-bin/", + "unified_webhook_mode": True, + "webhook_uuid": "", "callback_server_host": "0.0.0.0", "port": 6195, }, @@ -241,6 +255,8 @@ CONFIG_METADATA_2 = { "wecom_ai_bot_name": "", "token": "", "encoding_aes_key": "", + "unified_webhook_mode": True, + "webhook_uuid": "", "callback_server_host": "0.0.0.0", "port": 6198, }, @@ -308,6 +324,8 @@ CONFIG_METADATA_2 = { "app_token": "", "signing_secret": "", "slack_connection_mode": "socket", # webhook, socket + "unified_webhook_mode": True, + "webhook_uuid": "", "slack_webhook_host": "0.0.0.0", "slack_webhook_port": 6197, "slack_webhook_path": "/astrbot-slack-webhook/callback", @@ -387,16 +405,28 @@ CONFIG_METADATA_2 = { "description": "Slack Webhook Host", "type": "string", "hint": "Only valid when Slack connection mode is `webhook`.", + "condition": { + "slack_connection_mode": "webhook", + "unified_webhook_mode": False, + }, }, "slack_webhook_port": { "description": "Slack Webhook Port", "type": "int", "hint": "Only valid when Slack connection mode is `webhook`.", + "condition": { + "slack_connection_mode": "webhook", + "unified_webhook_mode": False, + }, }, "slack_webhook_path": { "description": "Slack Webhook Path", "type": "string", "hint": "Only valid when Slack connection mode is `webhook`.", + "condition": { + "slack_connection_mode": "webhook", + "unified_webhook_mode": False, + }, }, "active_send_mode": { "description": "是否换用主动发送接口", @@ -587,6 +617,33 @@ CONFIG_METADATA_2 = { "type": "string", "hint": "可选的 Discord 活动名称。留空则不设置活动。", }, + "port": { + "description": "回调服务器端口", + "type": "int", + "hint": "回调服务器端口。留空则不启用回调服务器。", + "condition": { + "unified_webhook_mode": False, + }, + }, + "callback_server_host": { + "description": "回调服务器主机", + "type": "string", + "hint": "回调服务器主机。留空则不启用回调服务器。", + "condition": { + "unified_webhook_mode": False, + }, + }, + "unified_webhook_mode": { + "description": "统一 Webhook 模式", + "type": "bool", + "hint": "启用后,将使用 AstrBot 统一 Webhook 入口,无需单独开启端口。回调地址为 /api/platform/webhook/{webhook_uuid}。", + }, + "webhook_uuid": { + "invisible": True, + "description": "Webhook UUID", + "type": "string", + "hint": "统一 Webhook 模式下的唯一标识符,创建平台时自动生成。", + }, }, }, "platform_settings": { diff --git a/astrbot/core/platform/platform.py b/astrbot/core/platform/platform.py index 3f36e17f3..ef5a0c07e 100644 --- a/astrbot/core/platform/platform.py +++ b/astrbot/core/platform/platform.py @@ -13,8 +13,10 @@ from .platform_metadata import PlatformMetadata class Platform(abc.ABC): - def __init__(self, event_queue: Queue): + def __init__(self, config: dict, event_queue: Queue): super().__init__() + # 平台配置 + self.config = config # 维护了消息平台的事件队列,EventBus 会从这里取出事件并处理。 self._event_queue = event_queue self.client_self_id = uuid.uuid4().hex @@ -36,7 +38,7 @@ class Platform(abc.ABC): self, session: MessageSesion, message_chain: MessageChain, - ) -> Awaitable[Any]: + ): """通过会话发送消息。该方法旨在让插件能够直接通过**可持久化的会话数据**发送消息,而不需要保存 event 对象。 异步方法。 @@ -49,3 +51,20 @@ class Platform(abc.ABC): def get_client(self): """获取平台的客户端对象。""" + + async def webhook_callback(self, request: Any) -> Any: + """统一 Webhook 回调入口。 + + 支持统一 Webhook 模式的平台需要实现此方法。 + 当 Dashboard 收到 /api/platform/webhook/{uuid} 请求时,会调用此方法。 + + Args: + request: Quart 请求对象 + + Returns: + 响应内容,格式取决于具体平台的要求 + + Raises: + NotImplementedError: 平台未实现统一 Webhook 模式 + """ + raise NotImplementedError(f"平台 {self.meta().name} 未实现统一 Webhook 模式") diff --git a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py index 8e8bcdb30..bfefa2f68 100644 --- a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py +++ b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py @@ -38,9 +38,8 @@ class AiocqhttpAdapter(Platform): platform_settings: dict, event_queue: asyncio.Queue, ) -> None: - super().__init__(event_queue) + super().__init__(platform_config, event_queue) - self.config = platform_config self.settings = platform_settings self.unique_session = platform_settings["unique_session"] self.host = platform_config["ws_reverse_host"] @@ -154,7 +153,9 @@ class AiocqhttpAdapter(Platform): """OneBot V11 通知类事件""" abm = AstrBotMessage() abm.self_id = str(event.self_id) - abm.sender = MessageMember(user_id=str(event.user_id), nickname=event.user_id) + abm.sender = MessageMember( + user_id=str(event.user_id), nickname=str(event.user_id) + ) abm.type = MessageType.OTHER_MESSAGE if event.get("group_id"): abm.group_id = str(event.group_id) diff --git a/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py b/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py index 7ad612ef6..8ccbf8b9a 100644 --- a/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py +++ b/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py @@ -47,9 +47,7 @@ class DingtalkPlatformAdapter(Platform): platform_settings: dict, event_queue: asyncio.Queue, ) -> None: - super().__init__(event_queue) - - self.config = platform_config + super().__init__(platform_config, event_queue) self.unique_session = platform_settings["unique_session"] @@ -76,13 +74,13 @@ class DingtalkPlatformAdapter(Platform): ) self.client_ = client # 用于 websockets 的 client - def _id_to_sid(self, dingtalk_id: str | None) -> str | None: + def _id_to_sid(self, dingtalk_id: str | None) -> str: if not dingtalk_id: - return dingtalk_id + return dingtalk_id or "unknown" prefix = "$:LWCP_v1:$" if dingtalk_id.startswith(prefix): return dingtalk_id[len(prefix) :] - return dingtalk_id + return dingtalk_id or "unknown" async def send_by_session( self, diff --git a/astrbot/core/platform/sources/discord/discord_platform_adapter.py b/astrbot/core/platform/sources/discord/discord_platform_adapter.py index 49b886dea..17002c06f 100644 --- a/astrbot/core/platform/sources/discord/discord_platform_adapter.py +++ b/astrbot/core/platform/sources/discord/discord_platform_adapter.py @@ -44,8 +44,7 @@ class DiscordPlatformAdapter(Platform): platform_settings: dict, event_queue: asyncio.Queue, ) -> None: - super().__init__(event_queue) - self.config = platform_config + super().__init__(platform_config, event_queue) self.settings = platform_settings self.client_self_id = None self.registered_handlers = [] diff --git a/astrbot/core/platform/sources/lark/lark_adapter.py b/astrbot/core/platform/sources/lark/lark_adapter.py index e6e6d4d2b..59626f78d 100644 --- a/astrbot/core/platform/sources/lark/lark_adapter.py +++ b/astrbot/core/platform/sources/lark/lark_adapter.py @@ -33,9 +33,7 @@ class LarkPlatformAdapter(Platform): platform_settings: dict, event_queue: asyncio.Queue, ) -> None: - super().__init__(event_queue) - - self.config = platform_config + super().__init__(platform_config, event_queue) self.unique_session = platform_settings["unique_session"] diff --git a/astrbot/core/platform/sources/misskey/misskey_adapter.py b/astrbot/core/platform/sources/misskey/misskey_adapter.py index ddeec93bc..528ef8122 100644 --- a/astrbot/core/platform/sources/misskey/misskey_adapter.py +++ b/astrbot/core/platform/sources/misskey/misskey_adapter.py @@ -55,8 +55,7 @@ class MisskeyPlatformAdapter(Platform): platform_settings: dict, event_queue: asyncio.Queue, ) -> None: - super().__init__(event_queue) - self.config = platform_config or {} + super().__init__(platform_config or {}, event_queue) self.settings = platform_settings or {} self.instance_url = self.config.get("misskey_instance_url", "") self.access_token = self.config.get("misskey_token", "") diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py index 96be734fd..9b1637b22 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py @@ -97,9 +97,7 @@ class QQOfficialPlatformAdapter(Platform): platform_settings: dict, event_queue: asyncio.Queue, ) -> None: - super().__init__(event_queue) - - self.config = platform_config + super().__init__(platform_config, event_queue) self.appid = platform_config["appid"] self.secret = platform_config["secret"] diff --git a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py index 2b8c0b420..fcb41ca2f 100644 --- a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py +++ b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py @@ -1,5 +1,6 @@ import asyncio import logging +from typing import Any import botpy import botpy.message @@ -11,6 +12,7 @@ from astrbot import logger from astrbot.api.event import MessageChain from astrbot.api.platform import AstrBotMessage, MessageType, Platform, PlatformMetadata from astrbot.core.platform.astr_message_event import MessageSesion +from astrbot.core.utils.webhook_utils import log_webhook_info from ...register import register_platform_adapter from ..qqofficial.qqofficial_platform_adapter import QQOfficialPlatformAdapter @@ -87,13 +89,12 @@ class QQOfficialWebhookPlatformAdapter(Platform): platform_settings: dict, event_queue: asyncio.Queue, ) -> None: - super().__init__(event_queue) - - self.config = platform_config + super().__init__(platform_config, event_queue) self.appid = platform_config["appid"] self.secret = platform_config["secret"] self.unique_session = platform_settings["unique_session"] + self.unified_webhook_mode = platform_config.get("unified_webhook_mode", False) intents = botpy.Intents( public_messages=True, @@ -106,6 +107,7 @@ class QQOfficialWebhookPlatformAdapter(Platform): timeout=20, ) self.client.set_platform(self) + self.webhook_helper = None async def send_by_session( self, @@ -128,16 +130,37 @@ class QQOfficialWebhookPlatformAdapter(Platform): self.client, ) await self.webhook_helper.initialize() - await self.webhook_helper.start_polling() + + # 如果启用统一 webhook 模式,则不启动独立服务器 + webhook_uuid = self.config.get("webhook_uuid") + if self.unified_webhook_mode and webhook_uuid: + log_webhook_info(f"{self.meta().id}(QQ 官方机器人 Webhook)", webhook_uuid) + # 保持运行状态,等待 shutdown + await self.webhook_helper.shutdown_event.wait() + else: + await self.webhook_helper.start_polling() def get_client(self) -> botClient: return self.client + async def webhook_callback(self, request: Any) -> Any: + """统一 Webhook 回调入口""" + if not self.webhook_helper: + return {"error": "Webhook helper not initialized"}, 500 + + # 复用 webhook_helper 的回调处理逻辑 + return await self.webhook_helper.handle_callback(request) + async def terminate(self): - self.webhook_helper.shutdown_event.set() + if self.webhook_helper: + self.webhook_helper.shutdown_event.set() await self.client.close() - try: - await self.webhook_helper.server.shutdown() - except Exception as _: - pass + if self.webhook_helper and not self.unified_webhook_mode: + try: + await self.webhook_helper.server.shutdown() + except Exception as exc: + logger.warning( + f"Exception occurred during QQOfficialWebhook server shutdown: {exc}", + exc_info=True, + ) logger.info("QQ 机器人官方 API 适配器已经被优雅地关闭") diff --git a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py index 65b7c701a..bce45e892 100644 --- a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py +++ b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py @@ -78,7 +78,19 @@ class QQOfficialWebhook: return response async def callback(self): - msg: dict = await quart.request.json + """内部服务器的回调入口""" + return await self.handle_callback(quart.request) + + async def handle_callback(self, request) -> dict: + """处理 webhook 回调,可被统一 webhook 入口复用 + + Args: + request: Quart 请求对象 + + Returns: + 响应数据 + """ + msg: dict = await request.json logger.debug(f"收到 qq_official_webhook 回调: {msg}") event = msg.get("t") diff --git a/astrbot/core/platform/sources/satori/satori_adapter.py b/astrbot/core/platform/sources/satori/satori_adapter.py index fd90804f0..46f9a4e0f 100644 --- a/astrbot/core/platform/sources/satori/satori_adapter.py +++ b/astrbot/core/platform/sources/satori/satori_adapter.py @@ -38,8 +38,7 @@ class SatoriPlatformAdapter(Platform): platform_settings: dict, event_queue: asyncio.Queue, ) -> None: - super().__init__(event_queue) - self.config = platform_config + super().__init__(platform_config, event_queue) self.settings = platform_settings self.api_base_url = self.config.get( diff --git a/astrbot/core/platform/sources/slack/client.py b/astrbot/core/platform/sources/slack/client.py index 0411f73a4..574fe40bc 100644 --- a/astrbot/core/platform/sources/slack/client.py +++ b/astrbot/core/platform/sources/slack/client.py @@ -47,51 +47,62 @@ class SlackWebhookClient: @self.app.route(self.path, methods=["POST"]) async def slack_events(): - """处理 Slack 事件""" - try: - # 获取请求体和头部 - body = await request.get_data() - event_data = json.loads(body.decode("utf-8")) - - # Verify Slack request signature - timestamp = request.headers.get("X-Slack-Request-Timestamp") - signature = request.headers.get("X-Slack-Signature") - if not timestamp or not signature: - return Response("Missing headers", status=400) - # Calculate the HMAC signature - sig_basestring = f"v0:{timestamp}:{body.decode('utf-8')}" - my_signature = ( - "v0=" - + hmac.new( - self.signing_secret.encode("utf-8"), - sig_basestring.encode("utf-8"), - hashlib.sha256, - ).hexdigest() - ) - # Verify the signature - if not hmac.compare_digest(my_signature, signature): - logger.warning("Slack request signature verification failed") - return Response("Invalid signature", status=400) - logger.info(f"Received Slack event: {event_data}") - - # 处理 URL 验证事件 - if event_data.get("type") == "url_verification": - return {"challenge": event_data.get("challenge")} - # 处理事件 - if self.event_handler and event_data.get("type") == "event_callback": - await self.event_handler(event_data) - - return Response("", status=200) - - except Exception as e: - logger.error(f"处理 Slack 事件时出错: {e}") - return Response("Internal Server Error", status=500) + """内部服务器的 POST 回调入口""" + return await self.handle_callback(request) @self.app.route("/health", methods=["GET"]) async def health_check(): """健康检查端点""" return {"status": "ok", "service": "slack-webhook"} + async def handle_callback(self, req): + """处理 Slack 回调请求,可被统一 webhook 入口复用 + + Args: + req: Quart 请求对象 + + Returns: + Response 对象或字典 + """ + try: + # 获取请求体和头部 + body = await req.get_data() + event_data = json.loads(body.decode("utf-8")) + + # Verify Slack request signature + timestamp = req.headers.get("X-Slack-Request-Timestamp") + signature = req.headers.get("X-Slack-Signature") + if not timestamp or not signature: + return Response("Missing headers", status=400) + # Calculate the HMAC signature + sig_basestring = f"v0:{timestamp}:{body.decode('utf-8')}" + my_signature = ( + "v0=" + + hmac.new( + self.signing_secret.encode("utf-8"), + sig_basestring.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + ) + # Verify the signature + if not hmac.compare_digest(my_signature, signature): + logger.warning("Slack request signature verification failed") + return Response("Invalid signature", status=400) + logger.info(f"Received Slack event: {event_data}") + + # 处理 URL 验证事件 + if event_data.get("type") == "url_verification": + return {"challenge": event_data.get("challenge")} + # 处理事件 + if self.event_handler and event_data.get("type") == "event_callback": + await self.event_handler(event_data) + + return Response("", status=200) + + except Exception as e: + logger.error(f"处理 Slack 事件时出错: {e}") + return Response("Internal Server Error", status=500) + async def start(self): """启动 Webhook 服务器""" logger.info( diff --git a/astrbot/core/platform/sources/slack/slack_adapter.py b/astrbot/core/platform/sources/slack/slack_adapter.py index d5427deb7..81936f903 100644 --- a/astrbot/core/platform/sources/slack/slack_adapter.py +++ b/astrbot/core/platform/sources/slack/slack_adapter.py @@ -21,6 +21,7 @@ from astrbot.api.platform import ( PlatformMetadata, ) from astrbot.core.platform.astr_message_event import MessageSesion +from astrbot.core.utils.webhook_utils import log_webhook_info from ...register import register_platform_adapter from .client import SlackSocketClient, SlackWebhookClient @@ -39,9 +40,7 @@ class SlackAdapter(Platform): platform_settings: dict, event_queue: asyncio.Queue, ) -> None: - super().__init__(event_queue) - - self.config = platform_config + super().__init__(platform_config, event_queue) self.settings = platform_settings self.unique_session = platform_settings.get("unique_session", False) @@ -49,6 +48,7 @@ class SlackAdapter(Platform): self.app_token = platform_config.get("app_token") self.signing_secret = platform_config.get("signing_secret") self.connection_mode = platform_config.get("slack_connection_mode", "socket") + self.unified_webhook_mode = platform_config.get("unified_webhook_mode", False) self.webhook_host = platform_config.get("slack_webhook_host", "0.0.0.0") self.webhook_port = platform_config.get("slack_webhook_port", 3000) self.webhook_path = platform_config.get( @@ -361,10 +361,17 @@ class SlackAdapter(Platform): self._handle_webhook_event, ) - logger.info( - f"Slack 适配器 (Webhook Mode) 启动中,监听 {self.webhook_host}:{self.webhook_port}{self.webhook_path}...", - ) - await self.webhook_client.start() + # 如果启用统一 webhook 模式,则不启动独立服务器 + webhook_uuid = self.config.get("webhook_uuid") + if self.unified_webhook_mode and webhook_uuid: + log_webhook_info(f"{self.meta().id}(Slack)", webhook_uuid) + # 保持运行状态,等待 shutdown + await self.webhook_client.shutdown_event.wait() + else: + logger.info( + f"Slack 适配器 (Webhook Mode) 启动中,监听 {self.webhook_host}:{self.webhook_port}{self.webhook_path}...", + ) + await self.webhook_client.start() else: raise ValueError( @@ -391,6 +398,13 @@ class SlackAdapter(Platform): if abm: await self.handle_msg(abm) + async def webhook_callback(self, request: Any) -> Any: + """统一 Webhook 回调入口""" + if self.connection_mode != "webhook" or not self.webhook_client: + return {"error": "Slack adapter is not in webhook mode"}, 400 + + return await self.webhook_client.handle_callback(request) + async def terminate(self): if self.socket_client: await self.socket_client.stop() diff --git a/astrbot/core/platform/sources/telegram/tg_adapter.py b/astrbot/core/platform/sources/telegram/tg_adapter.py index 6b4d23f65..bca45ea8d 100644 --- a/astrbot/core/platform/sources/telegram/tg_adapter.py +++ b/astrbot/core/platform/sources/telegram/tg_adapter.py @@ -42,8 +42,7 @@ class TelegramPlatformAdapter(Platform): platform_settings: dict, event_queue: asyncio.Queue, ) -> None: - super().__init__(event_queue) - self.config = platform_config + super().__init__(platform_config, event_queue) self.settings = platform_settings self.client_self_id = uuid.uuid4().hex[:8] diff --git a/astrbot/core/platform/sources/webchat/webchat_adapter.py b/astrbot/core/platform/sources/webchat/webchat_adapter.py index 80df6d80d..42f79b80d 100644 --- a/astrbot/core/platform/sources/webchat/webchat_adapter.py +++ b/astrbot/core/platform/sources/webchat/webchat_adapter.py @@ -76,9 +76,8 @@ class WebChatAdapter(Platform): platform_settings: dict, event_queue: asyncio.Queue, ) -> None: - super().__init__(event_queue) + super().__init__(platform_config, event_queue) - self.config = platform_config self.settings = platform_settings self.unique_session = platform_settings["unique_session"] self.imgs_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs") diff --git a/astrbot/core/platform/sources/wechatpadpro/wechatpadpro_adapter.py b/astrbot/core/platform/sources/wechatpadpro/wechatpadpro_adapter.py index e8629ec11..8186dd1ca 100644 --- a/astrbot/core/platform/sources/wechatpadpro/wechatpadpro_adapter.py +++ b/astrbot/core/platform/sources/wechatpadpro/wechatpadpro_adapter.py @@ -42,10 +42,9 @@ class WeChatPadProAdapter(Platform): platform_settings: dict, event_queue: asyncio.Queue, ) -> None: - super().__init__(event_queue) + super().__init__(platform_config, event_queue) self._shutdown_event = None self.wxnewpass = None - self.config = platform_config self.settings = platform_settings self.unique_session = platform_settings.get("unique_session", False) diff --git a/astrbot/core/platform/sources/wecom/wecom_adapter.py b/astrbot/core/platform/sources/wecom/wecom_adapter.py index 1ea4c8e20..9bbed276b 100644 --- a/astrbot/core/platform/sources/wecom/wecom_adapter.py +++ b/astrbot/core/platform/sources/wecom/wecom_adapter.py @@ -2,6 +2,7 @@ import asyncio import os import sys import uuid +from typing import Any import quart from requests import Response @@ -24,6 +25,7 @@ from astrbot.api.platform import ( from astrbot.core import logger from astrbot.core.platform.astr_message_event import MessageSesion from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.webhook_utils import log_webhook_info from .wecom_event import WecomPlatformEvent from .wecom_kf import WeChatKF @@ -62,8 +64,20 @@ class WecomServer: self.shutdown_event = asyncio.Event() async def verify(self): - logger.info(f"验证请求有效性: {quart.request.args}") - args = quart.request.args + """内部服务器的 GET 验证入口""" + return await self.handle_verify(quart.request) + + async def handle_verify(self, request) -> str: + """处理验证请求,可被统一 webhook 入口复用 + + Args: + request: Quart 请求对象 + + Returns: + 验证响应 + """ + logger.info(f"验证请求有效性: {request.args}") + args = request.args try: echo_str = self.crypto.check_signature( args.get("msg_signature"), @@ -78,10 +92,22 @@ class WecomServer: raise async def callback_command(self): - data = await quart.request.get_data() - msg_signature = quart.request.args.get("msg_signature") - timestamp = quart.request.args.get("timestamp") - nonce = quart.request.args.get("nonce") + """内部服务器的 POST 回调入口""" + return await self.handle_callback(quart.request) + + async def handle_callback(self, request) -> str: + """处理回调请求,可被统一 webhook 入口复用 + + Args: + request: Quart 请求对象 + + Returns: + 响应内容 + """ + data = await request.get_data() + msg_signature = request.args.get("msg_signature") + timestamp = request.args.get("timestamp") + nonce = request.args.get("nonce") try: xml = self.crypto.decrypt_message(data, msg_signature, timestamp, nonce) except InvalidSignatureException: @@ -118,14 +144,14 @@ class WecomPlatformAdapter(Platform): platform_settings: dict, event_queue: asyncio.Queue, ) -> None: - super().__init__(event_queue) - self.config = platform_config + super().__init__(platform_config, event_queue) self.settingss = platform_settings self.client_self_id = uuid.uuid4().hex[:8] self.api_base_url = platform_config.get( "api_base_url", "https://qyapi.weixin.qq.com/cgi-bin/", ) + self.unified_webhook_mode = platform_config.get("unified_webhook_mode", False) if not self.api_base_url: self.api_base_url = "https://qyapi.weixin.qq.com/cgi-bin/" @@ -232,7 +258,23 @@ class WecomPlatformAdapter(Platform): ) except Exception as e: logger.error(e) - await self.server.start_polling() + + # 如果启用统一 webhook 模式,则不启动独立服务器 + webhook_uuid = self.config.get("webhook_uuid") + if self.unified_webhook_mode and webhook_uuid: + log_webhook_info(f"{self.meta().id}(企业微信)", webhook_uuid) + # 保持运行状态,等待 shutdown + await self.server.shutdown_event.wait() + else: + await self.server.start_polling() + + async def webhook_callback(self, request: Any) -> Any: + """统一 Webhook 回调入口""" + # 根据请求方法分发到不同的处理函数 + if request.method == "GET": + return await self.server.handle_verify(request) + else: + return await self.server.handle_callback(request) async def convert_message(self, msg: BaseMessage) -> AstrBotMessage | None: abm = AstrBotMessage() diff --git a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py index 9c13cfeff..70581e7ea 100644 --- a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py +++ b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py @@ -22,6 +22,7 @@ from astrbot.api.platform import ( PlatformMetadata, ) from astrbot.core.platform.astr_message_event import MessageSesion +from astrbot.core.utils.webhook_utils import log_webhook_info from ...register import register_platform_adapter from .wecomai_api import ( @@ -103,9 +104,7 @@ class WecomAIBotAdapter(Platform): platform_settings: dict, event_queue: asyncio.Queue, ) -> None: - super().__init__(event_queue) - - self.config = platform_config + super().__init__(platform_config, event_queue) self.settings = platform_settings # 初始化配置参数 @@ -122,6 +121,7 @@ class WecomAIBotAdapter(Platform): "wecomaibot_friend_message_welcome_text", "", ) + self.unified_webhook_mode = self.config.get("unified_webhook_mode", False) # 平台元数据 self.metadata = PlatformMetadata( @@ -425,17 +425,34 @@ class WecomAIBotAdapter(Platform): def run(self) -> Awaitable[Any]: """运行适配器,同时启动HTTP服务器和队列监听器""" - logger.info("启动企业微信智能机器人适配器,监听 %s:%d", self.host, self.port) async def run_both(): - # 同时运行HTTP服务器和队列监听器 - await asyncio.gather( - self.server.start_server(), - self.queue_listener.run(), - ) + # 如果启用统一 webhook 模式,则不启动独立服务器 + webhook_uuid = self.config.get("webhook_uuid") + if self.unified_webhook_mode and webhook_uuid: + log_webhook_info(f"{self.meta().id}(企业微信智能机器人)", webhook_uuid) + # 只运行队列监听器 + await self.queue_listener.run() + else: + logger.info( + "启动企业微信智能机器人适配器,监听 %s:%d", self.host, self.port + ) + # 同时运行HTTP服务器和队列监听器 + await asyncio.gather( + self.server.start_server(), + self.queue_listener.run(), + ) return run_both() + async def webhook_callback(self, request: Any) -> Any: + """统一 Webhook 回调入口""" + # 根据请求方法分发到不同的处理函数 + if request.method == "GET": + return await self.server.handle_verify(request) + else: + return await self.server.handle_callback(request) + async def terminate(self): """终止适配器""" logger.info("企业微信智能机器人适配器正在关闭...") diff --git a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_server.py b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_server.py index 35acd9066..5cbdd1130 100644 --- a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_server.py +++ b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_server.py @@ -59,8 +59,19 @@ class WecomAIBotServer: ) async def verify_url(self): - """验证回调 URL""" - args = quart.request.args + """内部服务器的 GET 验证入口""" + return await self.handle_verify(quart.request) + + async def handle_verify(self, request): + """处理 URL 验证请求,可被统一 webhook 入口复用 + + Args: + request: Quart 请求对象 + + Returns: + 验证响应元组 (content, status_code, headers) + """ + args = request.args msg_signature = args.get("msg_signature") timestamp = args.get("timestamp") nonce = args.get("nonce") @@ -81,8 +92,19 @@ class WecomAIBotServer: return result, 200, {"Content-Type": "text/plain"} async def handle_message(self): - """处理消息回调""" - args = quart.request.args + """内部服务器的 POST 消息回调入口""" + return await self.handle_callback(quart.request) + + async def handle_callback(self, request): + """处理消息回调,可被统一 webhook 入口复用 + + Args: + request: Quart 请求对象 + + Returns: + 响应元组 (content, status_code, headers) + """ + args = request.args msg_signature = args.get("msg_signature") timestamp = args.get("timestamp") nonce = args.get("nonce") @@ -102,7 +124,7 @@ class WecomAIBotServer: try: # 获取请求体 - post_data = await quart.request.get_data() + post_data = await request.get_data() # 确保 post_data 是 bytes 类型 if isinstance(post_data, str): diff --git a/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py b/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py index d1309374f..c84e2865b 100644 --- a/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py +++ b/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py @@ -1,6 +1,7 @@ import asyncio import sys import uuid +from typing import Any import quart from requests import Response @@ -22,6 +23,7 @@ from astrbot.api.platform import ( ) from astrbot.core import logger from astrbot.core.platform.astr_message_event import MessageSesion +from astrbot.core.utils.webhook_utils import log_webhook_info from .weixin_offacc_event import WeixinOfficialAccountPlatformEvent @@ -31,7 +33,7 @@ else: from typing_extensions import override -class WecomServer: +class WeixinOfficialAccountServer: def __init__(self, event_queue: asyncio.Queue, config: dict): self.server = quart.Quart(__name__) self.port = int(config.get("port")) @@ -57,9 +59,21 @@ class WecomServer: self.shutdown_event = asyncio.Event() async def verify(self): - logger.info(f"验证请求有效性: {quart.request.args}") + """内部服务器的 GET 验证入口""" + return await self.handle_verify(quart.request) - args = quart.request.args + async def handle_verify(self, request) -> str: + """处理验证请求,可被统一 webhook 入口复用 + + Args: + request: Quart 请求对象 + + Returns: + 验证响应 + """ + logger.info(f"验证请求有效性: {request.args}") + + args = request.args if not args.get("signature", None): logger.error("未知的响应,请检查回调地址是否填写正确。") return "err" @@ -77,10 +91,22 @@ class WecomServer: return "err" async def callback_command(self): - data = await quart.request.get_data() - msg_signature = quart.request.args.get("msg_signature") - timestamp = quart.request.args.get("timestamp") - nonce = quart.request.args.get("nonce") + """内部服务器的 POST 回调入口""" + return await self.handle_callback(quart.request) + + async def handle_callback(self, request) -> str: + """处理回调请求,可被统一 webhook 入口复用 + + Args: + request: Quart 请求对象 + + Returns: + 响应内容 + """ + data = await request.get_data() + msg_signature = request.args.get("msg_signature") + timestamp = request.args.get("timestamp") + nonce = request.args.get("nonce") try: xml = self.crypto.decrypt_message(data, msg_signature, timestamp, nonce) except InvalidSignatureException: @@ -123,8 +149,7 @@ class WeixinOfficialAccountPlatformAdapter(Platform): platform_settings: dict, event_queue: asyncio.Queue, ) -> None: - super().__init__(event_queue) - self.config = platform_config + super().__init__(platform_config, event_queue) self.settingss = platform_settings self.client_self_id = uuid.uuid4().hex[:8] self.api_base_url = platform_config.get( @@ -132,6 +157,7 @@ class WeixinOfficialAccountPlatformAdapter(Platform): "https://api.weixin.qq.com/cgi-bin/", ) self.active_send_mode = self.config.get("active_send_mode", False) + self.unified_webhook_mode = platform_config.get("unified_webhook_mode", False) if not self.api_base_url: self.api_base_url = "https://api.weixin.qq.com/cgi-bin/" @@ -143,7 +169,7 @@ class WeixinOfficialAccountPlatformAdapter(Platform): if not self.api_base_url.endswith("/"): self.api_base_url += "/" - self.server = WecomServer(self._event_queue, self.config) + self.server = WeixinOfficialAccountServer(self._event_queue, self.config) self.client = WeChatClient( self.config["appid"].strip(), @@ -202,7 +228,22 @@ class WeixinOfficialAccountPlatformAdapter(Platform): @override async def run(self): - await self.server.start_polling() + # 如果启用统一 webhook 模式,则不启动独立服务器 + webhook_uuid = self.config.get("webhook_uuid") + if self.unified_webhook_mode and webhook_uuid: + log_webhook_info(f"{self.meta().id}(微信公众平台)", webhook_uuid) + # 保持运行状态,等待 shutdown + await self.server.shutdown_event.wait() + else: + await self.server.start_polling() + + async def webhook_callback(self, request: Any) -> Any: + """统一 Webhook 回调入口""" + # 根据请求方法分发到不同的处理函数 + if request.method == "GET": + return await self.server.handle_verify(request) + else: + return await self.server.handle_callback(request) async def convert_message( self, diff --git a/astrbot/core/utils/webhook_utils.py b/astrbot/core/utils/webhook_utils.py new file mode 100644 index 000000000..c56d00b37 --- /dev/null +++ b/astrbot/core/utils/webhook_utils.py @@ -0,0 +1,47 @@ +from astrbot.core import astrbot_config, logger + + +def _get_callback_api_base() -> str: + try: + return astrbot_config.get("callback_api_base", "").rstrip("/") + except Exception as e: + logger.error(f"获取 callback_api_base 失败: {e!s}") + return "" + + +def _get_dashboard_port() -> int: + try: + return astrbot_config.get("dashboard", {}).get("port", 6185) + except Exception as e: + logger.error(f"获取 dashboard 端口失败: {e!s}") + return 6185 + + +def log_webhook_info(platform_name: str, webhook_uuid: str): + """打印美观的 webhook 信息日志 + + Args: + platform_name: 平台名称 + webhook_uuid: webhook 的 UUID + """ + + callback_base = _get_callback_api_base() + + if not callback_base: + callback_base = "http(s)://" + + if not callback_base.startswith("http"): + callback_base = f"http(s)://{callback_base}" + + callback_base = callback_base.rstrip("/") + webhook_url = f"{callback_base}/api/platform/webhook/{webhook_uuid}" + + display_log = ( + "\n====================\n" + f"🔗 机器人平台 {platform_name} 已启用统一 Webhook 模式\n" + f"📍 Webhook 回调地址: \n" + f" ➜ http://:{_get_dashboard_port()}/api/platform/webhook/{webhook_uuid}\n" + f" ➜ {webhook_url}\n" + "====================\n" + ) + logger.info(display_log) diff --git a/astrbot/dashboard/routes/__init__.py b/astrbot/dashboard/routes/__init__.py index b7997cf8e..514e6d6ed 100644 --- a/astrbot/dashboard/routes/__init__.py +++ b/astrbot/dashboard/routes/__init__.py @@ -6,6 +6,7 @@ from .file import FileRoute from .knowledge_base import KnowledgeBaseRoute from .log import LogRoute from .persona import PersonaRoute +from .platform import PlatformRoute from .plugin import PluginRoute from .session_management import SessionManagementRoute from .stat import StatRoute @@ -22,6 +23,7 @@ __all__ = [ "KnowledgeBaseRoute", "LogRoute", "PersonaRoute", + "PlatformRoute", "PluginRoute", "SessionManagementRoute", "StatRoute", diff --git a/astrbot/dashboard/routes/config.py b/astrbot/dashboard/routes/config.py index 1089d8f81..c22f0f3ee 100644 --- a/astrbot/dashboard/routes/config.py +++ b/astrbot/dashboard/routes/config.py @@ -2,6 +2,7 @@ import asyncio import inspect import os import traceback +import uuid from quart import request @@ -13,6 +14,7 @@ from astrbot.core.config.default import ( CONFIG_METADATA_3_SYSTEM, DEFAULT_CONFIG, DEFAULT_VALUE_MAP, + WEBHOOK_SUPPORTED_PLATFORMS, ) from astrbot.core.config.i18n_utils import ConfigMetadataI18n from astrbot.core.core_lifecycle import AstrBotCoreLifecycle @@ -555,6 +557,15 @@ class ConfigRoute(Route): async def post_new_platform(self): new_platform_config = await request.json + + # 如果是支持统一 webhook 模式的平台,且启用了统一 webhook 模式,自动生成 webhook_uuid + platform_type = new_platform_config.get("type", "") + if platform_type in WEBHOOK_SUPPORTED_PLATFORMS: + if new_platform_config.get("unified_webhook_mode", False): + # 如果没有 webhook_uuid 或为空,自动生成 + if not new_platform_config.get("webhook_uuid"): + new_platform_config["webhook_uuid"] = uuid.uuid4().hex[:16] + self.config["platform"].append(new_platform_config) try: save_config(self.config, self.config, is_core=True) @@ -584,6 +595,14 @@ class ConfigRoute(Route): if not platform_id or not new_config: return Response().error("参数错误").__dict__ + # 如果是支持统一 webhook 模式的平台,且启用了统一 webhook 模式,确保有 webhook_uuid + platform_type = new_config.get("type", "") + if platform_type in WEBHOOK_SUPPORTED_PLATFORMS: + if new_config.get("unified_webhook_mode", False): + # 如果没有 webhook_uuid 或为空,自动生成 + if not new_config.get("webhook_uuid"): + new_config["webhook_uuid"] = uuid.uuid4().hex + for i, platform in enumerate(self.config["platform"]): if platform["id"] == platform_id: self.config["platform"][i] = new_config diff --git a/astrbot/dashboard/routes/platform.py b/astrbot/dashboard/routes/platform.py new file mode 100644 index 000000000..1a7394187 --- /dev/null +++ b/astrbot/dashboard/routes/platform.py @@ -0,0 +1,82 @@ +"""统一 Webhook 路由 + +提供统一的 webhook 回调入口,支持多个平台使用同一端口接收回调。 +""" + +from quart import request + +from astrbot.core import logger +from astrbot.core.core_lifecycle import AstrBotCoreLifecycle +from astrbot.core.platform import Platform + +from .route import Response, Route, RouteContext + + +class PlatformRoute(Route): + """统一 Webhook 路由""" + + def __init__( + self, + context: RouteContext, + core_lifecycle: AstrBotCoreLifecycle, + ) -> None: + super().__init__(context) + self.core_lifecycle = core_lifecycle + self.platform_manager = core_lifecycle.platform_manager + + # 路由不使用标准的 /api 前缀,因为 webhook 回调需要直接访问 + # 所以我们手动注册路由 + self._register_webhook_routes() + + def _register_webhook_routes(self): + """注册 webhook 路由""" + # 统一 webhook 入口,支持 GET 和 POST + self.app.add_url_rule( + "/api/platform/webhook/", + view_func=self.unified_webhook_callback, + methods=["GET", "POST"], + ) + + async def unified_webhook_callback(self, webhook_uuid: str): + """统一 webhook 回调入口 + + Args: + webhook_uuid: 平台配置中的 webhook_uuid + + Returns: + 根据平台适配器返回相应的响应 + """ + # 根据 webhook_uuid 查找对应的平台 + platform_adapter = self._find_platform_by_uuid(webhook_uuid) + + if not platform_adapter: + logger.warning(f"未找到 webhook_uuid 为 {webhook_uuid} 的平台") + return Response().error("未找到对应平台").__dict__, 404 + + # 调用平台适配器的 webhook_callback 方法 + try: + result = await platform_adapter.webhook_callback(request) + return result + except NotImplementedError: + logger.error( + f"平台 {platform_adapter.meta().name} 未实现 webhook_callback 方法" + ) + return Response().error("平台未支持统一 Webhook 模式").__dict__, 500 + except Exception as e: + logger.error(f"处理 webhook 回调时发生错误: {e}", exc_info=True) + return Response().error("处理回调失败").__dict__, 500 + + def _find_platform_by_uuid(self, webhook_uuid: str) -> Platform | None: + """根据 webhook_uuid 查找对应的平台适配器 + + Args: + webhook_uuid: webhook UUID + + Returns: + 平台适配器实例,未找到则返回 None + """ + for platform in self.platform_manager.platform_insts: + if platform.config.get("webhook_uuid") == webhook_uuid: + if platform.config.get("unified_webhook_mode", False): + return platform + return None diff --git a/astrbot/dashboard/server.py b/astrbot/dashboard/server.py index 84976f2ba..22eb2474c 100644 --- a/astrbot/dashboard/server.py +++ b/astrbot/dashboard/server.py @@ -16,6 +16,7 @@ from astrbot.core.utils.astrbot_path import get_astrbot_data_path from astrbot.core.utils.io import get_local_ip_addresses from .routes import * +from .routes.platform import PlatformRoute from .routes.route import Response, RouteContext from .routes.session_management import SessionManagementRoute from .routes.t2i import T2iRoute @@ -79,6 +80,7 @@ class AstrBotDashboard: self.persona_route = PersonaRoute(self.context, db, core_lifecycle) self.t2i_route = T2iRoute(self.context, core_lifecycle) self.kb_route = KnowledgeBaseRoute(self.context, core_lifecycle) + self.platform_route = PlatformRoute(self.context, core_lifecycle) self.app.add_url_rule( "/api/plug/", @@ -102,7 +104,7 @@ class AstrBotDashboard: async def auth_middleware(self): if not request.path.startswith("/api"): return None - allowed_endpoints = ["/api/auth/login", "/api/file"] + allowed_endpoints = ["/api/auth/login", "/api/file", "/api/platform/webhook"] if any(request.path.startswith(prefix) for prefix in allowed_endpoints): return None # 声明 JWT diff --git a/dashboard/src/i18n/locales/en-US/features/platform.json b/dashboard/src/i18n/locales/en-US/features/platform.json index bf5f6150b..20ea1d0ff 100644 --- a/dashboard/src/i18n/locales/en-US/features/platform.json +++ b/dashboard/src/i18n/locales/en-US/features/platform.json @@ -4,6 +4,14 @@ "adapters": "Platform Adapters", "addAdapter": "Add Adapter", "emptyText": "No platform adapters yet, click Add Adapter to create one", + "viewWebhook": "View Webhook URL", + "webhookCopied": "Webhook URL copied to clipboard", + "webhookCopyFailed": "Copy failed, please copy manually", + "webhookDialog": { + "title": "Webhook Callback URL", + "description": "The callback address is as follows, please ensure that the network environment can be accessed. You can also view the callback address information in the logs.", + "close": "Close" + }, "details": { "adapterType": "Adapter Type", "token": "Token", diff --git a/dashboard/src/i18n/locales/zh-CN/features/platform.json b/dashboard/src/i18n/locales/zh-CN/features/platform.json index fc460b903..3ffcbb7a1 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/platform.json +++ b/dashboard/src/i18n/locales/zh-CN/features/platform.json @@ -4,6 +4,14 @@ "adapters": "平台适配器", "addAdapter": "创建机器人", "emptyText": "暂无平台适配器,点击 创建机器人 添加", + "viewWebhook": "查看 Webhook 链接", + "webhookCopied": "Webhook URL 已复制到剪贴板", + "webhookCopyFailed": "复制失败,请手动复制", + "webhookDialog": { + "title": "Webhook 回调地址", + "description": "回调地址如下,请确保网络环境可以公网访问。也可以在日志中查看回调地址信息。建议填写 配置文件 -> 系统 中的「对外可达的回调接口地址」配置项。", + "close": "关闭" + }, "details": { "adapterType": "适配器类型", "token": "Token", diff --git a/dashboard/src/views/PlatformPage.vue b/dashboard/src/views/PlatformPage.vue index 60a841049..821e74626 100644 --- a/dashboard/src/views/PlatformPage.vue +++ b/dashboard/src/views/PlatformPage.vue @@ -29,6 +29,20 @@ + @@ -60,6 +74,43 @@ :updating-mode="updatingMode" :updating-platform-config="updatingPlatformConfig" @update="getConfig" @show-toast="showToast" @refresh-config="getConfig"/> + + + + + mdi-webhook + {{ tm('webhookDialog.title') }} + + +

{{ tm('webhookDialog.description') }}

+ + + +
+ + + + {{ tm('webhookDialog.close') }} + + +
+
+ @@ -97,18 +148,6 @@ export default { tm }; }, - computed: { - // 安全访问翻译的计算属性 - messages() { - return { - updateSuccess: this.tm('messages.updateSuccess'), - addSuccess: this.tm('messages.addSuccess'), - deleteSuccess: this.tm('messages.deleteSuccess'), - statusUpdateSuccess: this.tm('messages.statusUpdateSuccess'), - deleteConfirm: this.tm('messages.deleteConfirm') - }; - } - }, data() { return { config_data: {}, @@ -125,6 +164,9 @@ export default { showConsole: false, + showWebhookDialog: false, + currentWebhookUuid: '', + store: useCommonStore() } }, @@ -224,6 +266,47 @@ export default { this.save_message = message; this.save_message_success = "error"; this.save_message_snack = true; + }, + + getWebhookUrl(webhookUuid) { + let callbackBase = this.config_data.callback_api_base || ''; + if (!callbackBase) { + callbackBase = "http(s)://"; + } + if (callbackBase) { + return `${callbackBase.replace(/\/$/, '')}/api/platform/webhook/${webhookUuid}`; + } + return `/api/platform/webhook/${webhookUuid}`; + }, + + openWebhookDialog(webhookUuid) { + this.currentWebhookUuid = webhookUuid; + this.showWebhookDialog = true; + }, + + async copyWebhookUrl(webhookUuid) { + const url = this.getWebhookUrl(webhookUuid); + try { + await navigator.clipboard.writeText(url); + this.showSuccess(this.tm('webhookCopied')); + } catch (err) { + this.showError(this.tm('webhookCopyFailed')); + } + } + }, + computed: { + // 安全访问翻译的计算属性 + messages() { + return { + updateSuccess: this.tm('messages.updateSuccess'), + addSuccess: this.tm('messages.addSuccess'), + deleteSuccess: this.tm('messages.deleteSuccess'), + statusUpdateSuccess: this.tm('messages.statusUpdateSuccess'), + deleteConfirm: this.tm('messages.deleteConfirm') + }; + }, + currentWebhookUrl() { + return this.getWebhookUrl(this.currentWebhookUuid); } } } @@ -235,4 +318,12 @@ export default { padding-top: 8px; padding-bottom: 40px; } + +.webhook-info { + margin-top: 4px; +} + +.webhook-chip { + cursor: pointer; +}