From ceb38d91b438197631927846d64f999290275169 Mon Sep 17 00:00:00 2001 From: wangcham Date: Wed, 5 Nov 2025 14:23:10 +0000 Subject: [PATCH] feat: add unified webhook for wecom --- libs/wecom_api/api.py | 52 +++-- .../http/controller/groups/platform/bots.py | 3 +- pkg/api/http/controller/groups/webhooks.py | 69 ++++++ pkg/api/http/service/bot.py | 11 + pkg/platform/botmgr.py | 4 + pkg/platform/sources/wecom.py | 49 ++++- pkg/platform/sources/wecom.yaml | 17 -- .../home/bots/components/bot-form/BotForm.tsx | 200 ++++++++++++++---- web/src/app/infra/entities/api/index.ts | 1 + web/src/i18n/locales/en-US.ts | 5 + web/src/i18n/locales/ja-JP.ts | 5 + web/src/i18n/locales/zh-Hans.ts | 5 + web/src/i18n/locales/zh-Hant.ts | 5 + 13 files changed, 339 insertions(+), 87 deletions(-) create mode 100644 pkg/api/http/controller/groups/webhooks.py diff --git a/libs/wecom_api/api.py b/libs/wecom_api/api.py index d9cb7726..fbad27d9 100644 --- a/libs/wecom_api/api.py +++ b/libs/wecom_api/api.py @@ -21,6 +21,7 @@ class WecomClient: EncodingAESKey: str, contacts_secret: str, logger: None, + unified_mode: bool = False, ): self.corpid = corpid self.secret = secret @@ -31,13 +32,18 @@ class WecomClient: self.access_token = '' self.secret_for_contacts = contacts_secret self.logger = logger + self.unified_mode = unified_mode self.app = Quart(__name__) - self.app.add_url_rule( - '/callback/command', - 'handle_callback', - self.handle_callback_request, - methods=['GET', 'POST'], - ) + + # 只有在非统一模式下才注册独立路由 + if not self.unified_mode: + self.app.add_url_rule( + '/callback/command', + 'handle_callback', + self.handle_callback_request, + methods=['GET', 'POST'], + ) + self._message_handlers = { 'example': [], } @@ -168,25 +174,43 @@ class WecomClient: raise Exception('Failed to send message: ' + str(data)) async def handle_callback_request(self): + """处理回调请求(独立端口模式,使用全局 request)。""" + return await self._handle_callback_internal(request) + + async def handle_unified_webhook(self, req): + """处理回调请求(统一 webhook 模式,显式传递 request)。 + + Args: + req: Quart Request 对象 + + Returns: + 响应数据 """ - 处理回调请求,包括 GET 验证和 POST 消息接收。 + return await self._handle_callback_internal(req) + + async def _handle_callback_internal(self, req): + """ + 处理回调请求的内部实现,包括 GET 验证和 POST 消息接收。 + + Args: + req: Quart Request 对象 """ try: - msg_signature = request.args.get('msg_signature') - timestamp = request.args.get('timestamp') - nonce = request.args.get('nonce') + msg_signature = req.args.get('msg_signature') + timestamp = req.args.get('timestamp') + nonce = req.args.get('nonce') wxcpt = WXBizMsgCrypt(self.token, self.aes, self.corpid) - if request.method == 'GET': - echostr = request.args.get('echostr') + if req.method == 'GET': + echostr = req.args.get('echostr') ret, reply_echo_str = wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr) if ret != 0: await self.logger.error('验证失败') raise Exception(f'验证失败,错误码: {ret}') return reply_echo_str - elif request.method == 'POST': - encrypt_msg = await request.data + elif req.method == 'POST': + encrypt_msg = await req.data ret, xml_msg = wxcpt.DecryptMsg(encrypt_msg, msg_signature, timestamp, nonce) if ret != 0: await self.logger.error('消息解密失败') diff --git a/pkg/api/http/controller/groups/platform/bots.py b/pkg/api/http/controller/groups/platform/bots.py index d6250ac4..fb22eb91 100644 --- a/pkg/api/http/controller/groups/platform/bots.py +++ b/pkg/api/http/controller/groups/platform/bots.py @@ -18,7 +18,8 @@ class BotsRouterGroup(group.RouterGroup): @self.route('/', methods=['GET', 'PUT', 'DELETE']) async def _(bot_uuid: str) -> str: if quart.request.method == 'GET': - bot = await self.ap.bot_service.get_bot(bot_uuid) + # 返回运行时信息,包括webhook地址等 + bot = await self.ap.bot_service.get_runtime_bot_info(bot_uuid) if bot is None: return self.http_status(404, -1, 'bot not found') return self.success(data={'bot': bot}) diff --git a/pkg/api/http/controller/groups/webhooks.py b/pkg/api/http/controller/groups/webhooks.py new file mode 100644 index 00000000..dc4f3e96 --- /dev/null +++ b/pkg/api/http/controller/groups/webhooks.py @@ -0,0 +1,69 @@ +"""统一 Webhook 路由组 + +处理所有外部平台的回调请求,统一在一个端口上通过 bot_uuid 路由到对应的适配器。 + +路由格式: +- /bots/{bot_uuid} - 处理 bot 的 webhook 回调 +- /bots/{bot_uuid}/{path} - 处理带子路径的 webhook 回调 + +Example: + http://your-server.com:5300/bots/550e8400-e29b-41d4-a716-446655440000 +""" + +from __future__ import annotations + +import quart +import traceback + +from .. import group + + +@group.group_class('webhooks', '/bots') +class WebhookRouterGroup(group.RouterGroup): + async def initialize(self) -> None: + @self.route('/', methods=['GET', 'POST'], auth_type=group.AuthType.NONE) + async def handle_webhook(bot_uuid: str): + """处理 bot webhook 回调(无子路径)""" + return await self._dispatch_webhook(bot_uuid, '') + + @self.route('//', methods=['GET', 'POST'], auth_type=group.AuthType.NONE) + async def handle_webhook_with_path(bot_uuid: str, path: str): + """处理 bot webhook 回调(带子路径)""" + return await self._dispatch_webhook(bot_uuid, path) + + async def _dispatch_webhook(self, bot_uuid: str, path: str): + """分发 webhook 请求到对应的 bot adapter + + Args: + bot_uuid: Bot 的 UUID + path: 子路径(如果有的话) + + Returns: + 适配器返回的响应 + """ + try: + # 通过 UUID 获取运行时 bot + runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid) + + if not runtime_bot: + return quart.jsonify({'error': 'Bot not found'}), 404 + + if not runtime_bot.enable: + return quart.jsonify({'error': 'Bot is disabled'}), 403 + + # 检查 adapter 是否支持统一 webhook + if not hasattr(runtime_bot.adapter, 'handle_unified_webhook'): + return quart.jsonify({'error': 'Adapter does not support unified webhook'}), 501 + + # 调用 adapter 的 handle_unified_webhook 方法,显式传递 request 对象 + response = await runtime_bot.adapter.handle_unified_webhook( + bot_uuid=bot_uuid, + path=path, + request=quart.request, + ) + + return response + + except Exception as e: + self.ap.logger.error(f'Webhook dispatch error for bot {bot_uuid}: {traceback.format_exc()}') + return quart.jsonify({'error': str(e)}), 500 diff --git a/pkg/api/http/service/bot.py b/pkg/api/http/service/bot.py index 3ced0e51..8f2f1e5f 100644 --- a/pkg/api/http/service/bot.py +++ b/pkg/api/http/service/bot.py @@ -58,6 +58,17 @@ class BotService: if runtime_bot is not None: adapter_runtime_values['bot_account_id'] = runtime_bot.adapter.bot_account_id + # 为支持统一 webhook 的适配器生成 webhook URL + # 目前只有 wecom 支持 + if persistence_bot['adapter'] == 'wecom': + api_port = self.ap.instance_config.data['api']['port'] + webhook_url = f"/bots/{bot_uuid}" + adapter_runtime_values['webhook_url'] = webhook_url + adapter_runtime_values['webhook_full_url'] = f"http://:{api_port}{webhook_url}" + else: + adapter_runtime_values['webhook_url'] = None + adapter_runtime_values['webhook_full_url'] = None + persistence_bot['adapter_runtime_values'] = adapter_runtime_values return persistence_bot diff --git a/pkg/platform/botmgr.py b/pkg/platform/botmgr.py index ee9bd040..78f3bd0d 100644 --- a/pkg/platform/botmgr.py +++ b/pkg/platform/botmgr.py @@ -232,6 +232,10 @@ class PlatformManager: logger, ) + # 如果 adapter 支持 set_bot_uuid 方法,设置 bot_uuid(用于统一 webhook) + if hasattr(adapter_inst, 'set_bot_uuid'): + adapter_inst.set_bot_uuid(bot_entity.uuid) + runtime_bot = RuntimeBot(ap=self.ap, bot_entity=bot_entity, adapter=adapter_inst, logger=logger) await runtime_bot.initialize() diff --git a/pkg/platform/sources/wecom.py b/pkg/platform/sources/wecom.py index 3f5c0676..dbb09b01 100644 --- a/pkg/platform/sources/wecom.py +++ b/pkg/platform/sources/wecom.py @@ -132,6 +132,7 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): message_converter: WecomMessageConverter = WecomMessageConverter() event_converter: WecomEventConverter = WecomEventConverter() config: dict + bot_uuid: str = None def __init__(self, config: dict, logger: EventLogger): # 校验必填项 @@ -142,11 +143,12 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): 'EncodingAESKey', 'contacts_secret', ] + missing_keys = [key for key in required_keys if key not in config] if missing_keys: raise Exception(f'Wecom 缺少配置项: {missing_keys}') - # 创建运行时 bot 对象 + # 创建运行时 bot 对象,始终使用统一 webhook 模式 bot = WecomClient( corpid=config['corpid'], secret=config['secret'], @@ -154,9 +156,10 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): EncodingAESKey=config['EncodingAESKey'], contacts_secret=config['contacts_secret'], logger=logger, + unified_mode=True, ) - + super().__init__( config=config, logger=logger, @@ -164,6 +167,9 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): bot_account_id="", ) + def set_bot_uuid(self, bot_uuid: str): + """设置 bot UUID(用于生成 webhook URL)""" + self.bot_uuid = bot_uuid async def reply_message( self, @@ -217,16 +223,41 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): elif event_type == platform_events.GroupMessage: pass + async def handle_unified_webhook(self, bot_uuid: str, path: str, request): + """处理统一 webhook 请求。 + + Args: + bot_uuid: Bot 的 UUID + path: 子路径(如果有的话) + request: Quart Request 对象 + + Returns: + 响应数据 + """ + return await self.bot.handle_unified_webhook(request) + async def run_async(self): - async def shutdown_trigger_placeholder(): + # 统一 webhook 模式下,不启动独立的 Quart 应用 + # 保持运行但不启动独立端口 + + # 打印 webhook 回调地址 + if self.bot_uuid and hasattr(self.logger, 'ap'): + try: + api_port = self.logger.ap.instance_config.data['api']['port'] + webhook_url = f"http://127.0.0.1:{api_port}/bots/{self.bot_uuid}" + webhook_url_public = f"http://:{api_port}/bots/{self.bot_uuid}" + + await self.logger.info(f"企业微信 Webhook 回调地址:") + await self.logger.info(f" 本地地址: {webhook_url}") + await self.logger.info(f" 公网地址: {webhook_url_public}") + await self.logger.info(f"请在企业微信后台配置此回调地址") + except Exception as e: + await self.logger.warning(f"无法生成 webhook URL: {e}") + + async def keep_alive(): while True: await asyncio.sleep(1) - - await self.bot.run_task( - host=self.config['host'], - port=self.config['port'], - shutdown_trigger=shutdown_trigger_placeholder, - ) + await keep_alive() async def kill(self) -> bool: return False diff --git a/pkg/platform/sources/wecom.yaml b/pkg/platform/sources/wecom.yaml index f1015518..8720bbdf 100644 --- a/pkg/platform/sources/wecom.yaml +++ b/pkg/platform/sources/wecom.yaml @@ -11,23 +11,6 @@ metadata: icon: wecom.png spec: config: - - name: host - label: - en_US: Host - zh_Hans: 监听主机 - description: - en_US: Webhook host, unless you know what you're doing, please write 0.0.0.0 - zh_Hans: Webhook 监听主机,除非你知道自己在做什么,否则请写 0.0.0.0 - type: string - required: true - default: "0.0.0.0" - - name: port - label: - en_US: Port - zh_Hans: 监听端口 - type: integer - required: true - default: 2290 - name: corpid label: en_US: Corpid diff --git a/web/src/app/home/bots/components/bot-form/BotForm.tsx b/web/src/app/home/bots/components/bot-form/BotForm.tsx index f6aa21c0..999be35b 100644 --- a/web/src/app/home/bots/components/bot-form/BotForm.tsx +++ b/web/src/app/home/bots/components/bot-form/BotForm.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { IChooseAdapterEntity, IPipelineEntity, @@ -112,12 +112,77 @@ export default function BotForm({ IDynamicFormItemSchema[] >([]); const [, setIsLoading] = useState(false); + const [webhookUrl, setWebhookUrl] = useState(''); + const webhookInputRef = React.useRef(null); useEffect(() => { setBotFormValues(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // 复制到剪贴板的辅助函数 - 使用页面上的真实input元素 + const copyToClipboard = () => { + console.log('[Copy] Attempting to copy from input element'); + + const inputElement = webhookInputRef.current; + if (!inputElement) { + console.error('[Copy] Input element not found'); + toast.error(t('common.copyFailed')); + return; + } + + try { + // 确保input元素可见且未被禁用 + inputElement.disabled = false; + inputElement.readOnly = false; + + // 聚焦并选中所有文本 + inputElement.focus(); + inputElement.select(); + + // 尝试使用现代API + if (navigator.clipboard && navigator.clipboard.writeText) { + console.log('[Copy] Using Clipboard API with input value:', inputElement.value); + navigator.clipboard.writeText(inputElement.value) + .then(() => { + console.log('[Copy] Clipboard API success'); + inputElement.blur(); // 取消选中 + inputElement.readOnly = true; + toast.success(t('bots.webhookUrlCopied')); + }) + .catch((err) => { + console.error('[Copy] Clipboard API failed, trying execCommand:', err); + // 降级到execCommand + const successful = document.execCommand('copy'); + console.log('[Copy] execCommand result:', successful); + inputElement.blur(); + inputElement.readOnly = true; + if (successful) { + toast.success(t('bots.webhookUrlCopied')); + } else { + toast.error(t('common.copyFailed')); + } + }); + } else { + // 直接使用execCommand + console.log('[Copy] Using execCommand with input value:', inputElement.value); + const successful = document.execCommand('copy'); + console.log('[Copy] execCommand result:', successful); + inputElement.blur(); + inputElement.readOnly = true; + if (successful) { + toast.success(t('bots.webhookUrlCopied')); + } else { + toast.error(t('common.copyFailed')); + } + } + } catch (err) { + console.error('[Copy] Copy failed:', err); + inputElement.readOnly = true; + toast.error(t('common.copyFailed')); + } + }; + function setBotFormValues() { initBotFormComponent().then(() => { // 拉取初始化表单信息 @@ -133,12 +198,20 @@ export default function BotForm({ console.log('form', form.getValues()); handleAdapterSelect(val.adapter); // dynamicForm.setFieldsValue(val.adapter_config); + + // 设置 webhook 地址(如果有) + if (val.webhook_full_url) { + setWebhookUrl(val.webhook_full_url); + } else { + setWebhookUrl(''); + } }) .catch((err) => { toast.error(t('bots.getBotConfigError') + err.message); }); } else { form.reset(); + setWebhookUrl(''); } }); } @@ -213,7 +286,7 @@ export default function BotForm({ async function getBotConfig( botId: string, - ): Promise> { + ): Promise & { webhook_full_url?: string }> { return new Promise((resolve, reject) => { httpClient .getBot(botId) @@ -226,6 +299,9 @@ export default function BotForm({ adapter_config: bot.adapter_config, enable: bot.enable ?? true, use_pipeline_uuid: bot.use_pipeline_uuid ?? '', + webhook_full_url: bot.adapter_runtime_values + ? (bot.adapter_runtime_values as any).webhook_full_url + : undefined, }); }) .catch((err) => { @@ -369,51 +445,83 @@ export default function BotForm({
{/* 是否启用 & 绑定流水线 仅在编辑模式 */} {initBotId && ( -
- ( - - {t('common.enable')} - - - - - )} - /> + <> +
+ ( + + {t('common.enable')} + + + + + )} + /> - ( - - {t('bots.bindPipeline')} - - - - - )} - /> -
+ ( + + {t('bots.bindPipeline')} + + + + + )} + /> +
+ + {/* Webhook 地址显示(仅企业微信) */} + {webhookUrl && ( + + {t('bots.webhookUrl')} +
+ { + // 点击输入框时自动全选 + (e.target as HTMLInputElement).select(); + }} + /> + +
+

+ {t('bots.webhookUrlHint')} +

+
+ )} + )}