From 87de625dc91070125728f08cbe002b5dde02e1e4 Mon Sep 17 00:00:00 2001 From: Dong_master <2213070223@qq.com> Date: Mon, 1 Dec 2025 21:52:13 +0800 Subject: [PATCH] feat:add lark unified_webhook but bug webhook url 404 --- src/langbot/pkg/api/http/service/bot.py | 2 +- src/langbot/pkg/platform/sources/lark.py | 129 +++++++++++------- .../home/bots/components/bot-form/BotForm.tsx | 37 ++++- 3 files changed, 114 insertions(+), 54 deletions(-) diff --git a/src/langbot/pkg/api/http/service/bot.py b/src/langbot/pkg/api/http/service/bot.py index a761ab8b..366a2027 100644 --- a/src/langbot/pkg/api/http/service/bot.py +++ b/src/langbot/pkg/api/http/service/bot.py @@ -59,7 +59,7 @@ class BotService: adapter_runtime_values['bot_account_id'] = runtime_bot.adapter.bot_account_id # Webhook URL for unified webhook adapters (independent of bot running state) - if persistence_bot['adapter'] in ['wecom', 'wecombot', 'officialaccount', 'qqofficial', 'slack', 'wecomcs', 'LINE']: + if persistence_bot['adapter'] in ['wecom', 'wecombot', 'officialaccount', 'qqofficial', 'slack', 'wecomcs', 'LINE', "lark"]: webhook_prefix = self.ap.instance_config.data['api'].get('webhook_prefix', 'http://127.0.0.1:5300') webhook_url = f'/bots/{bot_uuid}' adapter_runtime_values['webhook_url'] = webhook_url diff --git a/src/langbot/pkg/platform/sources/lark.py b/src/langbot/pkg/platform/sources/lark.py index 684091a2..0449281c 100644 --- a/src/langbot/pkg/platform/sources/lark.py +++ b/src/langbot/pkg/platform/sources/lark.py @@ -342,6 +342,8 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): quart_app: quart.Quart = pydantic.Field(exclude=True) + bot_uuid: str = None + card_id_dict: dict[str, str] # 消息id到卡片id的映射,便于创建卡片后的发送消息到指定卡片 seq: int # 用于在发送卡片消息中识别消息顺序,直接以seq作为标识 @@ -349,46 +351,6 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs): quart_app = quart.Quart(__name__) - @quart_app.route('/lark/callback', methods=['POST']) - async def lark_callback(): - try: - data = await quart.request.json - - if 'encrypt' in data: - cipher = AESCipher(config['encrypt-key']) - data = cipher.decrypt_string(data['encrypt']) - data = json.loads(data) - - type = data.get('type') - if type is None: - context = EventContext(data) - type = context.header.event_type - - if 'url_verification' == type: - # todo 验证verification token - return {'challenge': data.get('challenge')} - context = EventContext(data) - type = context.header.event_type - p2v1 = P2ImMessageReceiveV1() - p2v1.header = context.header - event = P2ImMessageReceiveV1Data() - event.message = EventMessage(context.event['message']) - event.sender = EventSender(context.event['sender']) - p2v1.event = event - p2v1.schema = context.schema - if 'im.message.receive_v1' == type: - try: - event = await self.event_converter.target2yiri(p2v1, self.api_client) - except Exception: - await self.logger.error(f'Error in lark callback: {traceback.format_exc()}') - - if event.__class__ in self.listeners: - await self.listeners[event.__class__](event, self) - - return {'code': 200, 'message': 'ok'} - except Exception: - await self.logger.error(f'Error in lark callback: {traceback.format_exc()}') - return {'code': 500, 'message': 'error'} async def on_message(event: lark_oapi.im.v1.P2ImMessageReceiveV1): lb_event = await self.event_converter.target2yiri(event, self.api_client) @@ -774,8 +736,65 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): ): self.listeners.pop(event_type) + + + def set_bot_uuid(self, bot_uuid: str): + """设置 bot UUID(用于生成 webhook URL)""" + self.bot_uuid = bot_uuid + + async def handle_unified_webhook(self, bot_uuid: str, path: str, request): + """处理统一 webhook 请求。 + + Args: + bot_uuid: Bot 的 UUID + path: 子路径(如果有的话) + request: Quart Request 对象 + + Returns: + 响应数据 + """ + try: + data = await quart.request.json + + if 'encrypt' in data: + cipher = AESCipher(config['encrypt-key']) + data = cipher.decrypt_string(data['encrypt']) + data = json.loads(data) + + type = data.get('type') + if type is None: + context = EventContext(data) + type = context.header.event_type + + if 'url_verification' == type: + # todo 验证verification token + return {'challenge': data.get('challenge')} + context = EventContext(data) + type = context.header.event_type + p2v1 = P2ImMessageReceiveV1() + p2v1.header = context.header + event = P2ImMessageReceiveV1Data() + event.message = EventMessage(context.event['message']) + event.sender = EventSender(context.event['sender']) + p2v1.event = event + p2v1.schema = context.schema + if 'im.message.receive_v1' == type: + try: + event = await self.event_converter.target2yiri(p2v1, self.api_client) + except Exception: + await self.logger.error(f'Error in lark callback: {traceback.format_exc()}') + + if event.__class__ in self.listeners: + await self.listeners[event.__class__](event, self) + + return {'code': 200, 'message': 'ok'} + except Exception: + await self.logger.error(f'Error in lark callback: {traceback.format_exc()}') + return {'code': 500, 'message': 'error'} + + + async def run_async(self): - port = self.config['port'] enable_webhook = self.config['enable-webhook'] if not enable_webhook: @@ -791,16 +810,28 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): raise e else: - async def shutdown_trigger_placeholder(): - while True: - await asyncio.sleep(1) + # 统一 webhook 模式下,不启动独立的 Quart 应用 + # 保持运行但不启动独立端口 - await self.quart_app.run_task( - host='0.0.0.0', - port=port, - shutdown_trigger=shutdown_trigger_placeholder, - ) + # 打印 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('Lark 机器人 Webhook 回调地址:') + await self.logger.info(f' 本地地址: {webhook_url}') + await self.logger.info(f' 公网地址: {webhook_url_public}') + await self.logger.info('请在 Lark 机器人后台配置此回调地址') + except Exception as e: + await self.logger.warning(f'无法生成 webhook URL: {e}') + + async def keep_alive(): + while True: + await asyncio.sleep(1) + + await keep_alive() async def kill(self) -> bool: # 需要断开连接,不然旧的连接会继续运行,导致飞书消息来时会随机选择一个连接 # 断开时lark.ws.Client的_receive_message_loop会打印error日志: receive message loop exit。然后进行重连, 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 48f7c05a..2066470b 100644 --- a/web/src/app/home/bots/components/bot-form/BotForm.tsx +++ b/web/src/app/home/bots/components/bot-form/BotForm.tsx @@ -361,6 +361,20 @@ export default function BotForm({ console.log('update bot success', res); onFormSubmit(form.getValues()); toast.success(t('bots.saveSuccess')); + + // 保存成功后重新拉取机器人配置,更新 webhook 地址(依赖后端 runtime 计算) + httpClient + .getBot(initBotId) + .then((getRes) => { + const runtimeValues = getRes.bot.adapter_runtime_values as Record | undefined; + const newWebhookUrl = runtimeValues + ? ((runtimeValues.webhook_full_url as string) || '') + : ''; + setWebhookUrl(newWebhookUrl); + }) + .catch((err) => { + console.warn('refresh webhook url failed', err); + }); }) .catch((err) => { toast.error(t('bots.saveError') + err.message); @@ -415,6 +429,18 @@ export default function BotForm({ } } + // 计算动态表单渲染列表:启用 Lark 的 Webhook 时隐藏 app_id 与 app_secret + const dynamicFormConfigListToRender = (() => { + const isLark = form.watch('adapter') === 'lark'; + const enableWebhook = !!form.watch('adapter_config')?.['enable-webhook']; + if (isLark && enableWebhook) { + return dynamicFormConfigList.filter( + (item) => item.name !== 'app_id' && item.name !== 'app_secret', + ); + } + return dynamicFormConfigList; + })(); + return (
{/* Webhook 地址显示(统一 Webhook 模式) */} - {webhookUrl && ( + {form.watch('adapter') === 'lark' && + !!form.watch('adapter_config')?.['enable-webhook'] && + webhookUrl && ( {t('bots.webhookUrl')}
@@ -630,16 +658,17 @@ export default function BotForm({
)} - {showDynamicForm && dynamicFormConfigList.length > 0 && ( + {showDynamicForm && dynamicFormConfigListToRender.length > 0 && (
{t('bots.adapterConfig')}
{ - form.setValue('adapter_config', values); + const prev = form.getValues('adapter_config') || {}; + form.setValue('adapter_config', { ...prev, ...values }); }} />