mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-21 07:55:18 +08:00
fix(lark): identify bots automatically and support standard events
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)),
|
||||
|
||||
Reference in New Issue
Block a user