From cdc4086dea78129aa05033749bf8b4d618b29f1e Mon Sep 17 00:00:00 2001 From: Hyu Date: Fri, 9 Oct 2026 21:00:27 +0800 Subject: [PATCH] fix(lark): identify bots automatically and support standard events --- .../pkg/platform/adapters/lark/adapter.py | 146 ++++++++++++++++-- .../platform/adapters/lark/event_converter.py | 79 ++++++++++ .../pkg/platform/adapters/lark/manifest.yaml | 20 +-- .../adapters/lark/message_converter.py | 14 +- 4 files changed, 235 insertions(+), 24 deletions(-) diff --git a/src/langbot/pkg/platform/adapters/lark/adapter.py b/src/langbot/pkg/platform/adapters/lark/adapter.py index 776c0b7b0..7b32c4400 100644 --- a/src/langbot/pkg/platform/adapters/lark/adapter.py +++ b/src/langbot/pkg/platform/adapters/lark/adapter.py @@ -46,6 +46,8 @@ from lark_oapi.api.im.v1 import ( CreateMessageResponse, EventMessage, EventSender, + GetChatRequest, + GetMessageRequest, P2ImMessageReceiveV1, P2ImMessageReceiveV1Data, ReplyMessageRequest, @@ -140,13 +142,15 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte _message_cache: dict[str, platform_events.MessageReceivedEvent] = pydantic.PrivateAttr(default_factory=dict) _user_cache: dict[str, platform_entities.User] = pydantic.PrivateAttr(default_factory=dict) _group_cache: dict[str, platform_entities.UserGroup] = pydantic.PrivateAttr(default_factory=dict) + _bot_open_ids: dict[str, str] = pydantic.PrivateAttr(default_factory=dict) + _bot_identity_lock: asyncio.Lock = pydantic.PrivateAttr(default_factory=asyncio.Lock) _monitoring_mapping_ttl: int = 600 class Config: arbitrary_types_allowed = True def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs): - required_keys = ['app_id', 'app_secret', 'bot_name'] + required_keys = ['app_id', 'app_secret'] missing_keys = [key for key in required_keys if not config.get(key)] if missing_keys: raise ValueError(f'Lark missing required config: {", ".join(missing_keys)}') @@ -165,7 +169,7 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte config=config, logger=logger, lark_tenant_key=config.get('lark_tenant_key', ''), - bot_account_id=config['bot_name'], + bot_account_id=config['app_id'], bot=bot, api_client=api_client, quart_app=quart.Quart(__name__), @@ -195,17 +199,32 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte def sync_on_card_action(event): return self._handle_card_action_sync(event) - return ( + def sync_on_bot_added(event): + self._submit_coro(self._handle_bot_added_event(event)) + + def sync_on_standard_event(event): + self._submit_coro(self._handle_standard_event(json.loads(lark_oapi.JSON.marshal(event)))) + + builder = ( lark_oapi.EventDispatcherHandler.builder('', '') .register_p2_im_message_receive_v1(sync_on_message) + .register_p2_im_chat_member_bot_added_v1(sync_on_bot_added) .register_p2_card_action_trigger(sync_on_card_action) - .build() ) + for registration in LarkEventConverter.STANDARD_EVENT_CALLBACKS.values(): + getattr(builder, registration)(sync_on_standard_event) + return builder.build() def get_supported_events(self) -> list[str]: return [ 'message.received', 'bot.invited_to_group', + 'bot.removed_from_group', + 'group.member_joined', + 'group.member_left', + 'group.info_updated', + 'message.deleted', + 'message.reaction', 'platform.specific', ] @@ -640,6 +659,9 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte if event_type == 'app_ticket': self.app_ticket = self._webhook_event(data).get('app_ticket') return {'code': 200, 'message': 'ok'} + if event_type in LarkEventConverter.STANDARD_EVENT_CALLBACKS: + await self._handle_standard_event(data) + return {'code': 200, 'message': 'ok'} if event_type == 'im.message.receive_v1': p2v1 = P2ImMessageReceiveV1() p2v1.header = self._webhook_header(data) @@ -652,11 +674,7 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte await self._handle_message_event(p2v1) return {'code': 200, 'message': 'ok'} if event_type == 'im.chat.member.bot.added_v1': - raw_event = self._webhook_event(data) - header = self._webhook_header(data) - chat_id = raw_event.get('chat_id', '') - await self._send_bot_added_welcome(chat_id, getattr(header, 'tenant_key', None)) - await self._dispatch_eba_event(LarkEventConverter.bot_invited_to_group(data, chat_id)) + await self._handle_bot_added_event(lark_oapi.im.v1.P2ImChatMemberBotAddedV1(data)) return {'code': 200, 'message': 'ok'} if event_type == 'card.action.trigger': interaction_event = interaction_event_from_webhook(data) @@ -689,6 +707,11 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte async def run_async(self): self.event_loop = asyncio.get_running_loop() + if self.config.get('app_type', 'self') != 'isv': + try: + await self._get_bot_open_id(None) + except Exception as exc: + await self.logger.warning(f'Lark bot identity lookup failed; will retry on incoming message: {exc}') if not self.config.get('enable-webhook', False): try: await self.bot._connect() @@ -733,13 +756,118 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte async def is_muted(self, group_id: int | None = None) -> bool: return False + async def _standard_message_context(self, raw: dict) -> dict: + data = raw['event'] + cached = self._message_cache.get(data['message_id']) + if cached: + return {'chat_type': cached.chat_type, 'chat_id': cached.chat_id, 'group': cached.group} + tenant_key = (raw.get('header') or {}).get('tenant_key') + option = await asyncio.to_thread(self.request_option, tenant_key) + chat_id = data.get('chat_id') + if not chat_id: + request = GetMessageRequest.builder().message_id(data['message_id']).build() + response = await self.api_client.im.v1.message.aget(request, option) + if not response.success() or not response.data or not response.data.items: + raise RuntimeError(f'Lark message context lookup failed: {response.code} {response.msg}') + chat_id = response.data.items[0].chat_id + request = GetChatRequest.builder().chat_id(chat_id).build() + response = await self.api_client.im.v1.chat.aget(request, option) + if not response.success() or not response.data: + raise RuntimeError(f'Lark chat context lookup failed: {response.code} {response.msg}') + mode = response.data.chat_mode + if mode not in {'group', 'p2p'}: + raise RuntimeError(f'Lark chat context has unknown chat mode: {mode}') + is_group = mode == 'group' + return { + 'chat_type': platform_entities.ChatType.GROUP if is_group else platform_entities.ChatType.PRIVATE, + 'chat_id': chat_id, + 'group': platform_entities.UserGroup(id=chat_id, name=response.data.name or '') if is_group else None, + } + + async def _handle_standard_event(self, raw: dict): + event_type = (raw.get('header') or {}).get('event_type', 'unknown') + await self.logger.info(f'Lark event received: {event_type}') + try: + await self._process_standard_event(raw) + except Exception: + await self.logger.error(f'Lark event processing failed ({event_type}): {traceback.format_exc()}') + raise + + async def _process_standard_event(self, raw: dict): + event_type = raw['header']['event_type'] + context = None + if event_type.startswith('im.message.'): + try: + context = await self._standard_message_context(raw) + except Exception as exc: + # Preserve the event without falsely classifying an unknown conversation as a private chat. + await self.logger.warning(f'Lark {event_type}: {exc}; forwarding original platform event') + await self._dispatch_eba_event(LarkEventConverter.platform_specific(raw, event_type, raw)) + return + events = LarkEventConverter.standard_events(raw, context) + if not events: + await self.logger.warning(f'Lark {event_type}: no standard events could be extracted') + for event in events: + if isinstance(event, (platform_events.GroupInfoUpdatedEvent, platform_events.BotRemovedFromGroupEvent)): + self._group_cache.pop(str(event.group.id), None) + await self._dispatch_eba_event(event) + await self.logger.info(f'Lark event dispatched: {event_type}, count={len(events)}') + if event_type == 'im.message.recalled_v1': + self._message_cache.pop(raw['event']['message_id'], None) + + async def _handle_bot_added_event(self, event: lark_oapi.im.v1.P2ImChatMemberBotAddedV1): + chat_id = event.event.chat_id + operator = event.event.operator_id + operator_id = getattr(operator, 'open_id', None) or getattr(operator, 'user_id', None) + await self._dispatch_eba_event(LarkEventConverter.bot_invited_to_group(event, chat_id, operator_id)) + try: + await self._send_bot_added_welcome(chat_id, getattr(event.header, 'tenant_key', None)) + except Exception: + await self.logger.warning(f'Lark bot_added_welcome failed: {traceback.format_exc()}') + + async def _get_bot_open_id(self, tenant_key: str | None) -> str: + key = tenant_key or '' + async with self._bot_identity_lock: + if key in self._bot_open_ids: + return self._bot_open_ids[key] + request = ( + lark_oapi.BaseRequest.builder() + .http_method(lark_oapi.HttpMethod.GET) + .uri('/open-apis/bot/v3/info') + .token_types({lark_oapi.AccessTokenType.TENANT}) + .build() + ) + option = await asyncio.to_thread(self.request_option, tenant_key) + response = await self.api_client.arequest(request, option) + if not response.success(): + raise RuntimeError(f'Lark bot identity lookup failed: {response.code} {response.msg}') + bot_info = json.loads(response.raw.content).get('bot') or {} + open_id = bot_info.get('open_id') or bot_info.get('openid') + if not isinstance(open_id, str) or not open_id: + raise RuntimeError('Lark bot identity response is missing open_id') + # Keep a stable adapter identity across tenant-specific callbacks. + if not self._bot_open_ids: + self.bot_account_id = open_id + self._bot_open_ids[key] = open_id + return open_id + + def _normalize_bot_mention(self, event, open_id: str) -> None: + if event is not None: + for component in event.message_chain: + if isinstance(component, platform_message.At) and str(component.target) == open_id: + component.target = self.bot_account_id + async def _handle_message_event(self, event: lark_oapi.im.v1.P2ImMessageReceiveV1): try: + tenant_key = getattr(getattr(event, 'header', None), 'tenant_key', None) + open_id = await self._get_bot_open_id(tenant_key if self.config.get('app_type', 'self') == 'isv' else None) if platform_events.FriendMessage in self.listeners or platform_events.GroupMessage in self.listeners: legacy_event = await self.event_converter.target2legacy(event, self.api_client) + self._normalize_bot_mention(legacy_event, open_id) if legacy_event and type(legacy_event) in self.listeners: await self.listeners[type(legacy_event)](legacy_event, self) eba_event = await self.event_converter.target2yiri(event, self.api_client) + self._normalize_bot_mention(eba_event, open_id) if eba_event: self._cache_event(eba_event) await self._dispatch_eba_event(eba_event) diff --git a/src/langbot/pkg/platform/adapters/lark/event_converter.py b/src/langbot/pkg/platform/adapters/lark/event_converter.py index d76a03ab8..cdea19549 100644 --- a/src/langbot/pkg/platform/adapters/lark/event_converter.py +++ b/src/langbot/pkg/platform/adapters/lark/event_converter.py @@ -15,6 +15,85 @@ from langbot_plugin.api.entities.builtin.platform import message as platform_mes class LarkEventConverter(abstract_platform_adapter.AbstractEventConverter): + STANDARD_EVENT_CALLBACKS: typing.ClassVar[dict[str, str]] = { + 'im.chat.member.bot.deleted_v1': 'register_p2_im_chat_member_bot_deleted_v1', + 'im.chat.member.user.added_v1': 'register_p2_im_chat_member_user_added_v1', + 'im.chat.member.user.deleted_v1': 'register_p2_im_chat_member_user_deleted_v1', + 'im.chat.updated_v1': 'register_p2_im_chat_updated_v1', + 'im.message.recalled_v1': 'register_p2_im_message_recalled_v1', + 'im.message.reaction.created_v1': 'register_p2_im_message_reaction_created_v1', + 'im.message.reaction.deleted_v1': 'register_p2_im_message_reaction_deleted_v1', + } + + @staticmethod + def _event_user(identity: dict | None, name: str = '') -> platform_entities.User | None: + identity = identity or {} + user_id = identity.get('open_id') or identity.get('user_id') or identity.get('union_id') + return platform_entities.User(id=user_id, nickname=name) if user_id else None + + @classmethod + def standard_events(cls, raw: dict, message_context: dict | None = None) -> list[platform_events.EBAEvent]: + data = raw.get('event') or {} + header = raw.get('header') or {} + event_type = header.get('event_type', '') + common = { + 'adapter_name': ADAPTER_NAME, + 'timestamp': cls._timestamp(data.get('action_time') or data.get('recall_time') or header.get('create_time')) + or time.time(), + 'source_platform_object': raw, + } + group = platform_entities.UserGroup(id=data.get('chat_id') or '', name=data.get('name') or '') + operator = cls._event_user(data.get('operator_id')) + if event_type == 'im.chat.member.bot.deleted_v1': + return [platform_events.BotRemovedFromGroupEvent(group=group, operator=operator, **common)] + if event_type in { + 'im.chat.member.user.added_v1', 'im.chat.member.user.deleted_v1' + }: + events = [] + for user in data.get('users') or []: + member = cls._event_user(user.get('user_id'), user.get('name') or '') + if member is None: + continue + if event_type == 'im.chat.member.user.added_v1': + inviter = operator if operator and operator.id != member.id else None + events.append(platform_events.MemberJoinedEvent( + group=group, member=member, inviter=inviter, + join_type='invite' if inviter else None, **common, + )) + else: + events.append(platform_events.MemberLeftEvent( + group=group, member=member, operator=operator, + is_kicked=operator is not None and operator.id != member.id, **common, + )) + return events + if event_type == 'im.chat.updated_v1': + after = data.get('after_change') or {} + before = data.get('before_change') or {} + changed = [key for key in before.keys() | after.keys() if before.get(key) != after.get(key)] + if data.get('moderator_list'): + changed.append('moderator_list') + group.name = after.get('name') or '' + group.description = after.get('description') + group.avatar_url = after.get('avatar') + owner = cls._event_user(after.get('owner_id')) + group.owner_id = owner.id if owner else None + return [platform_events.GroupInfoUpdatedEvent( + group=group, operator=operator, changed_fields=sorted(changed), **common, + )] + context = message_context or {} + if event_type == 'im.message.recalled_v1': + return [platform_events.MessageDeletedEvent(message_id=data['message_id'], **context, **common)] + if event_type in {'im.message.reaction.created_v1', 'im.message.reaction.deleted_v1'}: + user = cls._event_user(data.get('user_id')) + if user is None: + user = platform_entities.User(id=data.get('app_id') or '', is_bot=data.get('operator_type') == 'app') + return [platform_events.MessageReactionEvent( + message_id=data['message_id'], user=user, + reaction=(data.get('reaction_type') or {}).get('emoji_type') or '', + is_add=event_type == 'im.message.reaction.created_v1', **context, **common, + )] + return [] + _processed_thread_quote_cache: typing.ClassVar[dict[str, float]] = {} _processed_thread_quote_cache_max_size: typing.ClassVar[int] = 4096 _processed_thread_quote_cache_ttl_seconds: typing.ClassVar[int] = 86400 diff --git a/src/langbot/pkg/platform/adapters/lark/manifest.yaml b/src/langbot/pkg/platform/adapters/lark/manifest.yaml index 5d80781ca..af722d3cf 100644 --- a/src/langbot/pkg/platform/adapters/lark/manifest.yaml +++ b/src/langbot/pkg/platform/adapters/lark/manifest.yaml @@ -108,20 +108,6 @@ spec: type: string required: true default: "" - - name: bot_name - label: - en_US: Bot Name - zh_Hans: 机器人名称 - zh_Hant: 機器人名稱 - ja_JP: ボット名 - description: - en_US: Must match the Lark bot name so group mentions can be recognized. - zh_Hans: 必须与飞书机器人名称一致,否则机器人将无法在群内正常识别 @。 - zh_Hant: 必須與飛書機器人名稱一致,否則機器人將無法在群組內正常識別 @。 - ja_JP: グループメンションを認識するには Lark のボット名と一致する必要があります。 - type: string - required: true - default: "" - name: enable-webhook label: en_US: Enable Webhook Mode @@ -216,6 +202,12 @@ spec: supported_events: - message.received - bot.invited_to_group + - bot.removed_from_group + - group.member_joined + - group.member_left + - group.info_updated + - message.deleted + - message.reaction - platform.specific supported_apis: diff --git a/src/langbot/pkg/platform/adapters/lark/message_converter.py b/src/langbot/pkg/platform/adapters/lark/message_converter.py index 117740d01..510246cf8 100644 --- a/src/langbot/pkg/platform/adapters/lark/message_converter.py +++ b/src/langbot/pkg/platform/adapters/lark/message_converter.py @@ -241,6 +241,13 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter): flattened.append(ele) elif isinstance(ele, list): flattened.extend(item for item in ele if isinstance(item, dict)) + for index, ele in enumerate(flattened): + if ele.get('tag') == 'at': + mentions = LarkMessageConverter._split_text_mentions( + ele.get('user_id') or '', getattr(message, 'mentions', []) or [] + ) + if len(mentions) == 1 and mentions[0].get('tag') == 'at': + flattened[index] = {**ele, **mentions[0]} return flattened if message.message_type == 'image': return [{'tag': 'img', 'image_key': content.get('image_key', ''), 'style': []}] @@ -275,10 +282,15 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter): result.append({'tag': 'text', 'text': text[pos : match.start()], 'style': []}) mention = mention_by_key.get(match.group(0)) if mention: + identity = getattr(mention, 'id', None) + if isinstance(identity, dict): + identity = identity.get('open_id') or identity.get('user_id') + elif identity is not None and not isinstance(identity, str): + identity = getattr(identity, 'open_id', None) or getattr(identity, 'user_id', None) result.append( { 'tag': 'at', - 'user_id': getattr(mention, 'id', None) + 'user_id': identity or getattr(mention, 'open_id', None) or getattr(mention, 'user_id', None) or getattr(mention, 'key', match.group(0)),