feat(lark): 支持商店应用机器人 (#1855)

* feat(lark): 支持商店应用机器人

* feat(lark): app_type改成select模式,修复select配置无效,按照copilot建议隐藏log敏感信息

* fix: KeyError for backward compatibility

---------

Co-authored-by: Junyan Qin <rockchinq@gmail.com>
This commit is contained in:
Hadong
2025-12-11 16:54:28 +08:00
committed by GitHub
parent a610c72067
commit 173f9e9c30
4 changed files with 231 additions and 22 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
.github
.venv
.vscode
.data
.temp
web/.next
web/node_modules
web/.env

View File

@@ -9,6 +9,7 @@ import re
import base64 import base64
import uuid import uuid
import json import json
import time
import datetime import datetime
import hashlib import hashlib
from Crypto.Cipher import AES from Crypto.Cipher import AES
@@ -19,6 +20,8 @@ import quart
from lark_oapi.api.im.v1 import * from lark_oapi.api.im.v1 import *
import pydantic import pydantic
from lark_oapi.api.cardkit.v1 import * 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.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.entities.builtin.platform.message as platform_message 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, message_chain=message_chain,
time=event.event.message.create_time, time=event.event.message.create_time,
source_platform_object=event,
) )
elif event.event.message.chat_type == 'group': elif event.event.message.chat_type == 'group':
return platform_events.GroupMessage( return platform_events.GroupMessage(
@@ -400,6 +404,7 @@ class LarkEventConverter(abstract_platform_adapter.AbstractEventConverter):
), ),
message_chain=message_chain, message_chain=message_chain,
time=event.event.message.create_time, time=event.event.message.create_time,
source_platform_object=event,
) )
@@ -429,6 +434,10 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
seq: int # 用于在发送卡片消息中识别消息顺序直接以seq作为标识 seq: int # 用于在发送卡片消息中识别消息顺序直接以seq作为标识
bot_uuid: str = None # 机器人UUID 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): def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
quart_app = quart.Quart(__name__) quart_app = quart.Quart(__name__)
@@ -448,8 +457,9 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
bot_account_id = config['bot_name'] bot_account_id = config['bot_name']
bot = lark_oapi.ws.Client(config['app_id'], config['app_secret'], event_handler=event_handler) 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', '')) cipher = AESCipher(config.get('encrypt-key', ''))
self.request_app_ticket(api_client, config)
super().__init__( super().__init__(
config=config, config=config,
@@ -466,6 +476,101 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
**kwargs, **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): async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
pass pass
@@ -693,9 +798,19 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
) )
.build() .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(): if not response.success():
@@ -722,7 +837,6 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
'content': text_elements, 'content': text_elements,
}, },
} }
request: ReplyMessageRequest = ( request: ReplyMessageRequest = (
ReplyMessageRequest.builder() ReplyMessageRequest.builder()
.message_id(message_source.message_chain.message_id) .message_id(message_source.message_chain.message_id)
@@ -737,7 +851,22 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
.build() .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(): if not response.success():
raise Exception( raise Exception(
@@ -762,7 +891,22 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
.build() .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(): if not response.success():
raise Exception( raise Exception(
@@ -816,8 +960,24 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
if is_final and bot_message.tool_calls is None: if is_final and bot_message.tool_calls is None:
# self.seq = 1 # 消息回复结束之后重置seq # self.seq = 1 # 消息回复结束之后重置seq
self.card_id_dict.pop(message_id) # 清理已经使用过的卡片 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(): if not response.success():
@@ -851,6 +1011,17 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
"""设置 bot UUID用于生成 webhook URL""" """设置 bot UUID用于生成 webhook URL"""
self.bot_uuid = bot_uuid 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): async def handle_unified_webhook(self, bot_uuid: str, path: str, request):
"""处理统一 webhook 请求。 """处理统一 webhook 请求。
Args: Args:
@@ -866,21 +1037,18 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
if 'encrypt' in data: if 'encrypt' in data:
data = self.cipher.decrypt_string(data['encrypt']) data = self.cipher.decrypt_string(data['encrypt'])
data = json.loads(data) data = json.loads(data)
type = data.get('type') type = self.get_event_type(data)
if type is None: context = EventContext(data)
context = EventContext(data)
type = context.header.event_type
if 'url_verification' == type: if 'url_verification' == type:
# todo 验证verification token # todo 验证verification token
return {'challenge': data.get('challenge')} return {'challenge': data.get('challenge')}
context = EventContext(data) elif 'app_ticket' == type:
type = context.header.event_type self.app_ticket = context.event['app_ticket']
p2v1 = P2ImMessageReceiveV1() elif 'im.message.receive_v1' == type:
p2v1.header = context.header
event = P2ImMessageReceiveV1Data()
if 'im.message.receive_v1' == type:
try: try:
p2v1 = P2ImMessageReceiveV1()
p2v1.header = context.header
event = P2ImMessageReceiveV1Data()
event.message = EventMessage(context.event['message']) event.message = EventMessage(context.event['message'])
event.sender = EventSender(context.event['sender']) event.sender = EventSender(context.event['sender'])
p2v1.event = event p2v1.event = event
@@ -898,7 +1066,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
final_content = { final_content = {
'zh_Hans': { 'zh_Hans': {
'title': '', 'title': '',
'content': bot_added_welcome_msg, 'content': [[{'tag': 'md', 'text': bot_added_welcome_msg}]],
}, },
} }
chat_id = context.event['chat_id'] chat_id = context.event['chat_id']
@@ -915,17 +1083,30 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
) )
.build() .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(): if not response.success():
raise Exception( 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)}' 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()}') await self.logger.error(f'Error in lark callback: {traceback.format_exc()}')
return {'code': 200, 'message': 'ok'} 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()}') await self.logger.error(f'Error in lark callback: {traceback.format_exc()}')
return {'code': 500, 'message': 'error'} return {'code': 500, 'message': 'error'}

View File

@@ -65,6 +65,25 @@ spec:
type: boolean type: boolean
required: true required: true
default: false 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 - name: bot_added_welcome
label: label:
en_US: Bot Welcome Message en_US: Bot Welcome Message

View File

@@ -310,6 +310,7 @@ export default function BotForm({
name: item.name, name: item.name,
required: item.required, required: item.required,
type: parseDynamicFormItemType(item.type), type: parseDynamicFormItemType(item.type),
options: item.options,
}), }),
), ),
); );