feat: unified platform webhook url (#3889)

* feat: unified platform webhook url

* chore: ruff format

* fix: 修复 Telegram 语音使用 Whisper API 报错 (#3884)

* Update whisper_api_source.py

* chore: ruff format

---------

Co-authored-by: Soulter <905617992@qq.com>

* Update astrbot/dashboard/routes/platform.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* chore: ruff format

* fix: update webhook dialog descriptions for clarity in English and Chinese locales

* fix: update webhook URL paths to include '/api' prefix for consistency across the application

---------

Co-authored-by: 易推倒白毛 <zhaixingbi@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Soulter
2025-12-03 15:44:52 +08:00
committed by GitHub
parent defc46b6c9
commit 5714944eef
28 changed files with 638 additions and 132 deletions
+57
View File
@@ -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": {
+21 -2
View File
@@ -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 模式")
@@ -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)
@@ -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,
@@ -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 = []
@@ -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"]
@@ -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", "")
@@ -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"]
@@ -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 适配器已经被优雅地关闭")
@@ -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")
@@ -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(
+50 -39
View File
@@ -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(
@@ -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()
@@ -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]
@@ -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")
@@ -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)
@@ -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()
@@ -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("企业微信智能机器人适配器正在关闭...")
@@ -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):
@@ -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,
+47
View File
@@ -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)://<your-astrbot-domain>"
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://<your-ip>:{_get_dashboard_port()}/api/platform/webhook/{webhook_uuid}\n"
f"{webhook_url}\n"
"====================\n"
)
logger.info(display_log)
+2
View File
@@ -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",
+19
View File
@@ -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
+82
View File
@@ -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/<webhook_uuid>",
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
+3 -1
View File
@@ -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/<path:subpath>",
@@ -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
@@ -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",
@@ -4,6 +4,14 @@
"adapters": "平台适配器",
"addAdapter": "创建机器人",
"emptyText": "暂无平台适配器,点击 创建机器人 添加",
"viewWebhook": "查看 Webhook 链接",
"webhookCopied": "Webhook URL 已复制到剪贴板",
"webhookCopyFailed": "复制失败,请手动复制",
"webhookDialog": {
"title": "Webhook 回调地址",
"description": "回调地址如下,请确保网络环境可以公网访问。也可以在日志中查看回调地址信息。建议填写 配置文件 -> 系统 中的「对外可达的回调接口地址」配置项。",
"close": "关闭"
},
"details": {
"adapterType": "适配器类型",
"token": "Token",
+103 -12
View File
@@ -29,6 +29,20 @@
<item-card :item="platform" title-field="id" enabled-field="enable"
:bglogo="getPlatformIcon(platform.type || platform.id)" @toggle-enabled="platformStatusChange"
@delete="deletePlatform" @edit="editPlatform">
<template #item-details="{ item }">
<div v-if="item.unified_webhook_mode && item.webhook_uuid" class="webhook-info">
<v-chip
size="small"
color="primary"
variant="tonal"
class="webhook-chip"
@click.stop="openWebhookDialog(item.webhook_uuid)"
>
<v-icon size="small" start>mdi-webhook</v-icon>
{{ tm('viewWebhook') }}
</v-chip>
</div>
</template>
</item-card>
</v-col>
</v-row>
@@ -60,6 +74,43 @@
:updating-mode="updatingMode" :updating-platform-config="updatingPlatformConfig" @update="getConfig"
@show-toast="showToast" @refresh-config="getConfig"/>
<!-- Webhook URL 对话框 -->
<v-dialog v-model="showWebhookDialog" max-width="600">
<v-card>
<v-card-title class="d-flex align-center pa-4">
<v-icon class="me-2" color="primary">mdi-webhook</v-icon>
{{ tm('webhookDialog.title') }}
</v-card-title>
<v-card-text class="px-4 pb-2">
<p class="text-body-2 text-medium-emphasis mb-3">{{ tm('webhookDialog.description') }}</p>
<v-text-field
:model-value="currentWebhookUrl"
readonly
variant="outlined"
hide-details
class="webhook-url-field"
>
<template v-slot:append-inner>
<v-btn
icon
size="small"
variant="text"
@click="copyWebhookUrl(currentWebhookUuid)"
>
<v-icon>mdi-content-copy</v-icon>
</v-btn>
</template>
</v-text-field>
</v-card-text>
<v-card-actions class="pa-4 pt-2">
<v-spacer></v-spacer>
<v-btn variant="tonal" color="primary" @click="showWebhookDialog = false">
{{ tm('webhookDialog.close') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 消息提示 -->
<v-snackbar :timeout="3000" elevation="24" :color="save_message_success" v-model="save_message_snack"
location="top">
@@ -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)://<your-domain-or-ip>";
}
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;
}
</style>