From 173f9e9c30d446ff8794f33cb45aad2a6f36dfc9 Mon Sep 17 00:00:00 2001 From: Hadong Date: Thu, 11 Dec 2025 16:54:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(lark):=20=E6=94=AF=E6=8C=81=E5=95=86?= =?UTF-8?q?=E5=BA=97=E5=BA=94=E7=94=A8=E6=9C=BA=E5=99=A8=E4=BA=BA=20(#1855?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(lark): 支持商店应用机器人 * feat(lark): app_type改成select模式,修复select配置无效,按照copilot建议隐藏log敏感信息 * fix: KeyError for backward compatibility --------- Co-authored-by: Junyan Qin --- .dockerignore | 8 + src/langbot/pkg/platform/sources/lark.py | 225 ++++++++++++++++-- src/langbot/pkg/platform/sources/lark.yaml | 19 ++ .../home/bots/components/bot-form/BotForm.tsx | 1 + 4 files changed, 231 insertions(+), 22 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..d18057c9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.github +.venv +.vscode +.data +.temp +web/.next +web/node_modules +web/.env diff --git a/src/langbot/pkg/platform/sources/lark.py b/src/langbot/pkg/platform/sources/lark.py index dcb579f7..761de1a5 100644 --- a/src/langbot/pkg/platform/sources/lark.py +++ b/src/langbot/pkg/platform/sources/lark.py @@ -9,6 +9,7 @@ import re import base64 import uuid import json +import time import datetime import hashlib from Crypto.Cipher import AES @@ -19,6 +20,8 @@ import quart from lark_oapi.api.im.v1 import * import pydantic from lark_oapi.api.cardkit.v1 import * +from lark_oapi.api.auth.v3 import * +from lark_oapi.core.model import * import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter import langbot_plugin.api.entities.builtin.platform.message as platform_message @@ -384,6 +387,7 @@ class LarkEventConverter(abstract_platform_adapter.AbstractEventConverter): ), message_chain=message_chain, time=event.event.message.create_time, + source_platform_object=event, ) elif event.event.message.chat_type == 'group': return platform_events.GroupMessage( @@ -400,6 +404,7 @@ class LarkEventConverter(abstract_platform_adapter.AbstractEventConverter): ), message_chain=message_chain, time=event.event.message.create_time, + source_platform_object=event, ) @@ -429,6 +434,10 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): seq: int # 用于在发送卡片消息中识别消息顺序,直接以seq作为标识 bot_uuid: str = None # 机器人UUID + app_ticket: str = None # 商店应用用到 + app_access_token: str = None # 商店应用用到 + app_access_token_expire_at: int = None + tenant_access_tokens: dict[str, dict[str, str]] = {} # 租户access_token映射 def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs): quart_app = quart.Quart(__name__) @@ -448,8 +457,9 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): bot_account_id = config['bot_name'] bot = lark_oapi.ws.Client(config['app_id'], config['app_secret'], event_handler=event_handler) - api_client = lark_oapi.Client.builder().app_id(config['app_id']).app_secret(config['app_secret']).build() + api_client = self.build_api_client(config) cipher = AESCipher(config.get('encrypt-key', '')) + self.request_app_ticket(api_client, config) super().__init__( config=config, @@ -466,6 +476,101 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): **kwargs, ) + def request_app_ticket(self, api_client, config): + app_id = config['app_id'] + app_secret = config['app_secret'] + print(f'Requesting app ticket for app_id: {app_id[:3]}***{app_id[-3:]}') + if 'isv' == config.get('app_type', 'self'): + request: ResendAppTicketRequest = ( + ResendAppTicketRequest.builder() + .request_body(ResendAppTicketRequestBody.builder().app_id(app_id).app_secret(app_secret).build()) + .build() + ) + response: ResendAppTicketResponse = api_client.auth.v3.app_ticket.resend(request) + if not response.success(): + raise Exception( + f'client.auth.v3.auth.app_ticket_resend failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}' + ) + + def request_app_access_token(self): + app_id = self.config['app_id'] + app_secret = self.config['app_secret'] + if 'isv' == self.config.get('app_type', 'self'): + request: CreateAppAccessTokenRequest = ( + CreateAppAccessTokenRequest.builder() + .request_body( + CreateAppAccessTokenRequestBody.builder() + .app_id(app_id) + .app_secret(app_secret) + .app_ticket(self.app_ticket) + .build() + ) + .build() + ) + response: CreateAppAccessTokenResponse = self.api_client.auth.v3.app_access_token.create(request) + if not response.success(): + raise Exception( + f'client.auth.v3.auth.app_access_token failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}' + ) + content = json.loads(response.raw.content) + self.app_access_token = content['app_access_token'] + self.app_access_token_expire_at = int(time.time()) + content['expire'] - 300 + + def get_app_access_token(self): + if 'isv' != self.config.get('app_type', 'self'): + return None + if ( + self.app_access_token is None + or self.app_access_token_expire_at is None + or int(time.time()) >= self.app_access_token_expire_at + ): + self.request_app_access_token() + return self.app_access_token + + def request_tenant_access_token(self, tenant_key: str): + app_access_token = self.get_app_access_token() + if 'isv' == self.config.get('app_type', 'self'): + request: CreateTenantAccessTokenRequest = ( + CreateTenantAccessTokenRequest.builder() + .request_body( + CreateTenantAccessTokenRequestBody.builder() + .app_access_token(app_access_token) + .tenant_key(tenant_key) + .build() + ) + .build() + ) + response: CreateTenantAccessTokenResponse = self.api_client.auth.v3.tenant_access_token.create(request) + if not response.success(): + raise Exception( + f'client.auth.v3.auth.tenant_access_token failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}' + ) + content = json.loads(response.raw.content) + tenant_access_token = content['tenant_access_token'] + expire = content['expire'] + self.tenant_access_tokens[tenant_key] = { + 'token': tenant_access_token, + 'expire_at': int(time.time()) + expire - 300, + } + + def get_tenant_access_token(self, tenant_key: str): + if tenant_key is None or 'isv' != self.config.get('app_type', 'self'): + return None + tenant_access_token = self.tenant_access_tokens.get(tenant_key) + if tenant_access_token is None or int(time.time()) >= tenant_access_token['expire_at']: + self.request_tenant_access_token(tenant_key) + return self.tenant_access_tokens.get(tenant_key)['token'] if self.tenant_access_tokens.get(tenant_key) else None + + def build_api_client(self, config): + app_id = config['app_id'] + app_secret = config['app_secret'] + api_client = lark_oapi.Client.builder().app_id(app_id).app_secret(app_secret).build() + if 'isv' == config.get('app_type', 'self'): + api_client = ( + lark_oapi.Client.builder().app_id(app_id).app_secret(app_secret).app_type(lark_oapi.AppType.ISV).build() + ) + return api_client + async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): pass @@ -693,9 +798,19 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): ) .build() ) - + tenant_key = event.source_platform_object.header.tenant_key if event.source_platform_object else None + app_access_token = self.get_app_access_token() + tenant_access_token = self.get_tenant_access_token(tenant_key) + req_opt: RequestOption = ( + RequestOption.builder() + .app_ticket(self.app_ticket) + .tenant_key(tenant_key) + .app_access_token(app_access_token) + .tenant_access_token(tenant_access_token) + .build() + ) # 发起请求 - response: ReplyMessageResponse = await self.api_client.im.v1.message.areply(request) + response: ReplyMessageResponse = await self.api_client.im.v1.message.areply(request, req_opt) # 处理失败返回 if not response.success(): @@ -722,7 +837,6 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): 'content': text_elements, }, } - request: ReplyMessageRequest = ( ReplyMessageRequest.builder() .message_id(message_source.message_chain.message_id) @@ -737,7 +851,22 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): .build() ) - response: ReplyMessageResponse = await self.api_client.im.v1.message.areply(request) + tenant_key = ( + message_source.source_platform_object.header.tenant_key + if message_source.source_platform_object + else None + ) + app_access_token = self.get_app_access_token() + tenant_access_token = self.get_tenant_access_token(tenant_key) + req_opt: RequestOption = ( + RequestOption.builder() + .app_ticket(self.app_ticket) + .tenant_key(tenant_key) + .app_access_token(app_access_token) + .tenant_access_token(tenant_access_token) + .build() + ) + response: ReplyMessageResponse = await self.api_client.im.v1.message.areply(request, req_opt) if not response.success(): raise Exception( @@ -762,7 +891,22 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): .build() ) - response: ReplyMessageResponse = await self.api_client.im.v1.message.areply(request) + tenant_key = ( + message_source.source_platform_object.header.tenant_key + if message_source.source_platform_object + else None + ) + app_access_token = self.get_app_access_token() + tenant_access_token = self.get_tenant_access_token(tenant_key) + req_opt: RequestOption = ( + RequestOption.builder() + .app_ticket(self.app_ticket) + .tenant_key(tenant_key) + .app_access_token(app_access_token) + .tenant_access_token(tenant_access_token) + .build() + ) + response: ReplyMessageResponse = await self.api_client.im.v1.message.areply(request, req_opt) if not response.success(): raise Exception( @@ -816,8 +960,24 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): if is_final and bot_message.tool_calls is None: # self.seq = 1 # 消息回复结束之后重置seq self.card_id_dict.pop(message_id) # 清理已经使用过的卡片 + + tenant_key = ( + message_source.source_platform_object.header.tenant_key + if message_source.source_platform_object + else None + ) + app_access_token = self.get_app_access_token() + tenant_access_token = self.get_tenant_access_token(tenant_key) + req_opt: RequestOption = ( + RequestOption.builder() + .app_ticket(self.app_ticket) + .tenant_key(tenant_key) + .app_access_token(app_access_token) + .tenant_access_token(tenant_access_token) + .build() + ) # 发起请求 - response: ContentCardElementResponse = self.api_client.cardkit.v1.card_element.content(request) + response: ContentCardElementResponse = self.api_client.cardkit.v1.card_element.content(request, req_opt) # 处理失败返回 if not response.success(): @@ -851,6 +1011,17 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): """设置 bot UUID(用于生成 webhook URL)""" self.bot_uuid = bot_uuid + def get_event_type(self, data): + schema = '1.0' + if 'schema' in data: + schema = data['schema'] + if '2.0' == schema: + return data['header']['event_type'] + elif 'event' in data: + return data['event']['type'] + else: + return data['type'] + async def handle_unified_webhook(self, bot_uuid: str, path: str, request): """处理统一 webhook 请求。 Args: @@ -866,21 +1037,18 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): if 'encrypt' in data: data = self.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 - + type = self.get_event_type(data) + context = EventContext(data) 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() - if 'im.message.receive_v1' == type: + elif 'app_ticket' == type: + self.app_ticket = context.event['app_ticket'] + elif 'im.message.receive_v1' == type: try: + p2v1 = P2ImMessageReceiveV1() + p2v1.header = context.header + event = P2ImMessageReceiveV1Data() event.message = EventMessage(context.event['message']) event.sender = EventSender(context.event['sender']) p2v1.event = event @@ -898,7 +1066,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): final_content = { 'zh_Hans': { 'title': '', - 'content': bot_added_welcome_msg, + 'content': [[{'tag': 'md', 'text': bot_added_welcome_msg}]], }, } chat_id = context.event['chat_id'] @@ -915,17 +1083,30 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): ) .build() ) - response: CreateMessageResponse = self.api_client.im.v1.message.create(request) + tenant_key = context.header.tenant_key if context.header else None + app_access_token = self.get_app_access_token() + tenant_access_token = self.get_tenant_access_token(tenant_key) + req_opt: RequestOption = ( + RequestOption.builder() + .app_ticket(self.app_ticket) + .tenant_key(tenant_key) + .app_access_token(app_access_token) + .tenant_access_token(tenant_access_token) + .build() + ) + response: CreateMessageResponse = self.api_client.im.v1.message.create(request, req_opt) if not response.success(): raise Exception( f'client.im.v1.message.create failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}' ) - except Exception: + except Exception as e: + print(f'im.chat.member.bot.added_v1: {e}') await self.logger.error(f'Error in lark callback: {traceback.format_exc()}') return {'code': 200, 'message': 'ok'} - except Exception: + except Exception as e: + print(f'Error in lark callback: {e}') await self.logger.error(f'Error in lark callback: {traceback.format_exc()}') return {'code': 500, 'message': 'error'} diff --git a/src/langbot/pkg/platform/sources/lark.yaml b/src/langbot/pkg/platform/sources/lark.yaml index 9343837d..7db5cdaa 100644 --- a/src/langbot/pkg/platform/sources/lark.yaml +++ b/src/langbot/pkg/platform/sources/lark.yaml @@ -65,6 +65,25 @@ spec: type: boolean required: true default: false + - name: app_type + label: + en_US: App Type + zh_Hans: 应用类型 + description: + en_US: Default to self-built application, refer to https://open.feishu.cn/document/platform-overveiw/overview + zh_Hans: 默认为企业自建应用,参考 https://open.feishu.cn/document/platform-overveiw/overview + type: select + options: + - name: self + label: + en_US: Self-built Application + zh_Hans: 自建应用 + - name: isv + label: + en_US: Store Application + zh_Hans: 商店应用 + required: false + default: self - name: bot_added_welcome label: en_US: Bot Welcome Message 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 906d126c..bef21443 100644 --- a/web/src/app/home/bots/components/bot-form/BotForm.tsx +++ b/web/src/app/home/bots/components/bot-form/BotForm.tsx @@ -310,6 +310,7 @@ export default function BotForm({ name: item.name, required: item.required, type: parseDynamicFormItemType(item.type), + options: item.options, }), ), );