Merge remote-tracking branch 'origin/refactor/eba' into dev_4_11

# Conflicts:
#	pyproject.toml
#	src/langbot/pkg/pipeline/preproc/preproc.py
#	uv.lock
This commit is contained in:
huanghuoguoguo
2026-06-22 23:06:02 +08:00
174 changed files with 27232 additions and 3832 deletions
+23 -11
View File
@@ -5,6 +5,7 @@ import sqlalchemy
import typing
from ....core import app
from ....discover import engine
from ....entity.persistence import bot as persistence_bot
from ....entity.persistence import pipeline as persistence_pipeline
@@ -17,6 +18,24 @@ class BotService:
def __init__(self, ap: app.Application) -> None:
self.ap = ap
def _get_adapter_component(self, adapter_name: str) -> engine.Component | None:
"""Return the discovered platform adapter component for an adapter name."""
for component in self.ap.discover.get_components_by_kind('MessagePlatformAdapter'):
if component.metadata.name == adapter_name:
return component
return None
def _adapter_declares_webhook_url(self, adapter_name: str) -> bool:
"""Whether the adapter manifest declares a generated webhook URL config item."""
component = self._get_adapter_component(adapter_name)
if component is None:
return False
for config_item in component.spec.get('config', []):
if config_item.get('type') == 'webhook-url':
return True
return False
async def get_bots(self, include_secret: bool = True) -> list[dict]:
"""获取所有机器人"""
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_bot.Bot))
@@ -58,17 +77,10 @@ class BotService:
if runtime_bot is not None:
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',
'lark',
]:
# Webhook URL for adapters that declare a generated webhook config item.
# This is manifest-driven so EBA adapters do not need to be mirrored in a
# second hard-coded list.
if self._adapter_declares_webhook_url(persistence_bot['adapter']):
webhook_prefix = self.ap.instance_config.data['api'].get('webhook_prefix', 'http://127.0.0.1:5300')
extra_webhook_prefix = self.ap.instance_config.data['api'].get('extra_webhook_prefix', '')
webhook_url = f'/bots/{bot_uuid}'
+6 -4
View File
@@ -241,12 +241,14 @@ class ComponentDiscoveryEngine:
return
for file in importutil.list_resource_files(path):
if (not os.path.isdir(os.path.join(path, file))) and (file.endswith('.yaml') or file.endswith('.yml')):
comp = self.load_component_manifest(os.path.join(path, file), owner, no_save)
file_path = os.path.join(path, file)
is_dir = importutil.is_resource_dir(file_path)
if (not is_dir) and (file.endswith('.yaml') or file.endswith('.yml')):
comp = self.load_component_manifest(file_path, owner, no_save)
if comp is not None:
components.append(comp)
elif os.path.isdir(os.path.join(path, file)):
recursive_load_component_manifests_in_dir(os.path.join(path, file), depth + 1)
elif is_dir:
recursive_load_component_manifests_in_dir(file_path, depth + 1)
recursive_load_component_manifests_in_dir(path)
return components
+13 -2
View File
@@ -170,13 +170,21 @@ class PreProcessor(stage.PipelineStage):
plain_text = ''
quote_msg = query.pipeline_config['trigger'].get('misc', '').get('combine-quote-message')
local_agent_without_vision = (
selected_runner == 'local-agent'
and llm_model
and not llm_model.model_entity.abilities.__contains__('vision')
)
for me in query.message_chain:
if isinstance(me, platform_message.Plain):
content_list.append(provider_message.ContentElement.from_text(me.text))
plain_text += me.text
elif isinstance(me, platform_message.Image):
if selected_runner != 'local-agent' or (
if local_agent_without_vision:
content_list.append(provider_message.ContentElement.from_text('[Image]'))
plain_text += '[Image]'
elif selected_runner != 'local-agent' or (
llm_model and 'vision' in (llm_model.model_entity.abilities or [])
):
if me.base64 is not None:
@@ -197,7 +205,10 @@ class PreProcessor(stage.PipelineStage):
if isinstance(msg, platform_message.Plain):
content_list.append(provider_message.ContentElement.from_text(msg.text))
elif isinstance(msg, platform_message.Image):
if selected_runner != 'local-agent' or (
if local_agent_without_vision:
content_list.append(provider_message.ContentElement.from_text('[Image]'))
plain_text += '[Image]'
elif selected_runner != 'local-agent' or (
llm_model and 'vision' in (llm_model.model_entity.abilities or [])
):
if msg.base64 is not None:
@@ -0,0 +1,5 @@
from __future__ import annotations
from langbot.pkg.platform.adapters.aiocqhttp.adapter import AiocqhttpAdapter
__all__ = ['AiocqhttpAdapter']
@@ -0,0 +1,172 @@
from __future__ import annotations
import asyncio
import traceback
import typing
import aiocqhttp
import pydantic
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
from langbot.pkg.platform.adapters.aiocqhttp.api_impl import AiocqhttpAPIMixin
from langbot.pkg.platform.adapters.aiocqhttp.event_converter import AiocqhttpEventConverter
from langbot.pkg.platform.adapters.aiocqhttp.message_converter import AiocqhttpMessageConverter
from langbot.pkg.platform.adapters.aiocqhttp.platform_api import PLATFORM_API_MAP
from langbot_plugin.api.entities.builtin.platform import events as platform_events
from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError
class AiocqhttpAdapter(AiocqhttpAPIMixin, abstract_platform_adapter.AbstractPlatformAdapter):
bot: aiocqhttp.CQHttp = pydantic.Field(exclude=True)
message_converter: AiocqhttpMessageConverter = AiocqhttpMessageConverter()
event_converter: AiocqhttpEventConverter = AiocqhttpEventConverter()
config: dict
listeners: dict[
typing.Type[platform_events.Event],
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
] = {}
class Config:
arbitrary_types_allowed = True
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
run_config = dict(config)
async def shutdown_trigger_placeholder():
while True:
await asyncio.sleep(1)
run_config['shutdown_trigger'] = shutdown_trigger_placeholder
access_token = run_config.pop('access-token', '') or None
bot = aiocqhttp.CQHttp(access_token=access_token)
super().__init__(
config=run_config,
logger=logger,
bot=bot,
bot_account_id='',
listeners={},
)
self._register_native_handlers()
def get_supported_events(self) -> list[str]:
return [
'message.received',
'message.deleted',
'group.member_joined',
'group.member_left',
'group.member_banned',
'friend.request_received',
'friend.added',
'bot.invited_to_group',
'bot.removed_from_group',
'bot.muted',
'bot.unmuted',
'platform.specific',
]
def get_supported_apis(self) -> list[str]:
return [
'send_message',
'reply_message',
'delete_message',
'forward_message',
'get_message',
'get_group_info',
'get_group_list',
'get_group_member_list',
'get_group_member_info',
'set_group_name',
'get_user_info',
'get_friend_list',
'approve_friend_request',
'approve_group_invite',
'mute_member',
'unmute_member',
'kick_member',
'leave_group',
'call_platform_api',
]
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
handler = PLATFORM_API_MAP.get(action)
if handler is None:
raise NotSupportedError(f'call_platform_api:{action}')
return await handler(self.bot, params)
def register_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable[
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
],
):
self.listeners[event_type] = callback
def unregister_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable[
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
],
):
registered = self.listeners.get(event_type)
if registered is callback:
self.listeners.pop(event_type, None)
async def run_async(self):
await self.bot._server_app.run_task(**self.config)
async def kill(self) -> bool:
return False
def _register_native_handlers(self):
@self.bot.on_message()
async def on_message(event: aiocqhttp.Event):
await self._handle_native_event(event)
@self.bot.on_notice()
async def on_notice(event: aiocqhttp.Event):
await self._handle_native_event(event)
@self.bot.on_request()
async def on_request(event: aiocqhttp.Event):
await self._handle_native_event(event)
@self.bot.on_websocket_connection
async def on_websocket_connection(event: aiocqhttp.Event):
self.bot_account_id = str(getattr(event, 'self_id', '') or self.bot_account_id)
await self.logger.info(f'WebSocket connection established, bot id: {self.bot_account_id}')
await self._dispatch_native_event(event)
async def _handle_native_event(self, event: aiocqhttp.Event):
self.bot_account_id = str(getattr(event, 'self_id', '') or self.bot_account_id)
if getattr(event, 'type', None) == 'message' and str(getattr(event, 'user_id', '')) == self.bot_account_id:
return
try:
if getattr(event, 'type', None) == 'message' and (
platform_events.FriendMessage in self.listeners or platform_events.GroupMessage in self.listeners
):
legacy_event = await self.event_converter.target2legacy(event, self.bot)
if legacy_event:
callback = self.listeners.get(type(legacy_event))
if callback:
await callback(legacy_event, self)
await self._dispatch_native_event(event)
except Exception:
await self.logger.error(f'Error in aiocqhttp native event: {traceback.format_exc()}')
async def _dispatch_native_event(self, event: aiocqhttp.Event):
eba_event = await self.event_converter.target2yiri(event, self.bot, self.bot_account_id)
if eba_event:
await self._dispatch_eba_event(eba_event)
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
callback = self.listeners.get(event_type)
if callback:
await callback(event, self)
return
@@ -0,0 +1,238 @@
from __future__ import annotations
import typing
import aiocqhttp
from langbot.pkg.platform.adapters.aiocqhttp.event_converter import AiocqhttpEventConverter
from langbot.pkg.platform.adapters.aiocqhttp.message_converter import AiocqhttpMessageConverter
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
from langbot_plugin.api.entities.builtin.platform import message as platform_message
from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError
class AiocqhttpAPIMixin:
bot: aiocqhttp.CQHttp
async def send_message(
self,
target_type: str,
target_id: str,
message: platform_message.MessageChain,
) -> platform_events.MessageResult:
forward = message.get_first(platform_message.Forward)
if forward and target_type == 'group':
raw = await self._send_forward_message(int(target_id), typing.cast(platform_message.Forward, forward))
return platform_events.MessageResult(message_id=raw.get('message_id'), raw=raw)
aiocq_msg, _, _ = await AiocqhttpMessageConverter.yiri2target(message)
if target_type == 'group':
raw = await self.bot.send_group_msg(group_id=int(target_id), message=aiocq_msg)
elif target_type in ('person', 'private'):
raw = await self.bot.send_private_msg(user_id=int(target_id), message=aiocq_msg)
else:
raise ValueError(f'Unsupported aiocqhttp target_type: {target_type}')
return platform_events.MessageResult(message_id=(raw or {}).get('message_id'), raw=raw or {})
async def reply_message(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
quote_origin: bool = False,
) -> platform_events.MessageResult:
assert isinstance(message_source.source_platform_object, aiocqhttp.Event)
aiocq_msg, _, _ = await AiocqhttpMessageConverter.yiri2target(message)
if quote_origin:
source_id = getattr(message_source, 'message_id', None) or message_source.message_chain.message_id
aiocq_msg = aiocqhttp.MessageSegment.reply(source_id) + aiocq_msg
raw = await self.bot.send(message_source.source_platform_object, aiocq_msg)
return platform_events.MessageResult(message_id=(raw or {}).get('message_id'), raw=raw or {})
async def delete_message(
self,
chat_type: str,
chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
) -> None:
await self.bot.delete_msg(message_id=int(message_id))
async def forward_message(
self,
from_chat_type: str,
from_chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
to_chat_type: str,
to_chat_id: typing.Union[int, str],
) -> platform_events.MessageResult:
raw_message = await self.bot.get_msg(message_id=int(message_id))
target_message = aiocqhttp.Message(raw_message.get('message', []))
if to_chat_type == 'group':
raw = await self.bot.send_group_msg(group_id=int(to_chat_id), message=target_message)
elif to_chat_type in ('person', 'private'):
raw = await self.bot.send_private_msg(user_id=int(to_chat_id), message=target_message)
else:
raise ValueError(f'Unsupported aiocqhttp to_chat_type: {to_chat_type}')
return platform_events.MessageResult(message_id=(raw or {}).get('message_id'), raw=raw or {})
async def get_message(
self,
chat_type: str,
chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
) -> platform_events.MessageReceivedEvent:
raw = await self.bot.get_msg(message_id=int(message_id))
message_type = raw.get('message_type') or chat_type
event = aiocqhttp.Event.from_payload(
{
'post_type': 'message',
'message_type': 'group' if message_type == 'group' else 'private',
'sub_type': raw.get('sub_type', 'normal'),
'time': raw.get('time', 0),
'self_id': self.bot_account_id or 0,
'message_id': raw.get('message_id', message_id),
'user_id': raw.get('sender', {}).get('user_id') or raw.get('user_id') or chat_id,
'group_id': raw.get('group_id') or (chat_id if message_type == 'group' else None),
'message': raw.get('message', []),
'raw_message': raw.get('raw_message', ''),
'sender': raw.get('sender', {}),
}
)
return await AiocqhttpEventConverter.message_to_eba(event, self.bot)
async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup:
raw = await self.bot.get_group_info(group_id=int(group_id))
return platform_entities.UserGroup(
id=raw.get('group_id', group_id),
name=raw.get('group_name', ''),
member_count=raw.get('member_count'),
)
async def get_group_list(self) -> list[platform_entities.UserGroup]:
raw_list = await self.bot.get_group_list()
return [
platform_entities.UserGroup(
id=item.get('group_id', ''),
name=item.get('group_name', ''),
member_count=item.get('member_count'),
)
for item in raw_list
]
async def get_group_member_list(
self,
group_id: typing.Union[int, str],
) -> list[platform_entities.UserGroupMember]:
raw_list = await self.bot.get_group_member_list(group_id=int(group_id))
return [self._member_to_entity(item, group_id) for item in raw_list]
async def get_group_member_info(
self,
group_id: typing.Union[int, str],
user_id: typing.Union[int, str],
) -> platform_entities.UserGroupMember:
raw = await self.bot.get_group_member_info(group_id=int(group_id), user_id=int(user_id), no_cache=True)
return self._member_to_entity(raw, group_id)
async def set_group_name(self, group_id: typing.Union[int, str], name: str) -> None:
await self.bot.set_group_name(group_id=int(group_id), group_name=name)
async def mute_member(
self,
group_id: typing.Union[int, str],
user_id: typing.Union[int, str],
duration: int = 0,
) -> None:
await self.bot.set_group_ban(group_id=int(group_id), user_id=int(user_id), duration=int(duration))
async def unmute_member(self, group_id: typing.Union[int, str], user_id: typing.Union[int, str]) -> None:
await self.bot.set_group_ban(group_id=int(group_id), user_id=int(user_id), duration=0)
async def kick_member(self, group_id: typing.Union[int, str], user_id: typing.Union[int, str]) -> None:
await self.bot.set_group_kick(group_id=int(group_id), user_id=int(user_id), reject_add_request=False)
async def leave_group(self, group_id: typing.Union[int, str]) -> None:
await self.bot.set_group_leave(group_id=int(group_id), is_dismiss=False)
async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User:
raw = await self.bot.get_stranger_info(user_id=int(user_id), no_cache=True)
return platform_entities.User(
id=raw.get('user_id', user_id),
nickname=raw.get('nickname', ''),
avatar_url=raw.get('avatar_url'),
)
async def get_friend_list(self) -> list[platform_entities.User]:
raw_list = await self.bot.get_friend_list()
return [
platform_entities.User(
id=item.get('user_id', ''),
nickname=item.get('nickname', ''),
remark=item.get('remark'),
)
for item in raw_list
]
async def approve_friend_request(
self,
request_id: typing.Union[int, str],
approve: bool = True,
remark: typing.Optional[str] = None,
) -> None:
await self.bot.set_friend_add_request(flag=str(request_id), approve=approve, remark=remark or '')
async def approve_group_invite(self, request_id: typing.Union[int, str], approve: bool = True) -> None:
await self.bot.set_group_add_request(flag=str(request_id), sub_type='invite', approve=approve, reason='')
async def upload_file(self, file_data: bytes, filename: str) -> str:
raise NotSupportedError('upload_file')
async def get_file_url(self, file_id: str) -> str:
raise NotSupportedError('get_file_url')
@staticmethod
def _member_to_entity(raw: dict, group_id: typing.Union[int, str]) -> platform_entities.UserGroupMember:
role = platform_entities.MemberRole.MEMBER
if raw.get('role') == 'owner':
role = platform_entities.MemberRole.OWNER
elif raw.get('role') == 'admin':
role = platform_entities.MemberRole.ADMIN
return platform_entities.UserGroupMember(
user=platform_entities.User(
id=raw.get('user_id', ''),
nickname=raw.get('nickname', ''),
remark=raw.get('card') or raw.get('remark'),
),
group_id=group_id,
role=role,
display_name=raw.get('card') or raw.get('nickname'),
joined_at=float(raw['join_time']) if raw.get('join_time') else None,
title=raw.get('title'),
)
async def _send_forward_message(self, group_id: int, forward: platform_message.Forward) -> dict:
messages = []
for node in forward.node_list:
if not node.message_chain:
continue
content, _, _ = await AiocqhttpMessageConverter.yiri2target(node.message_chain)
if not content:
continue
messages.append(
{
'type': 'node',
'data': {
'user_id': str(node.sender_id or self.bot_account_id or '10000'),
'nickname': node.sender_name or 'LangBot',
'content': list(content),
},
}
)
if not messages:
return {}
try:
return await self.bot.call_action(
'send_forward_msg', group_id=group_id, user_id=str(self.bot_account_id), messages=messages
)
except Exception:
return await self.bot.call_action('send_group_forward_msg', group_id=group_id, messages=messages)
@@ -0,0 +1,244 @@
from __future__ import annotations
import typing
import aiocqhttp
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
from langbot.pkg.platform.adapters.aiocqhttp.message_converter import AiocqhttpMessageConverter
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
@staticmethod
async def yiri2target(event: platform_events.Event, bot_account_id: int | str | None = None):
return getattr(event, 'source_platform_object', None)
@staticmethod
async def target2yiri(
event: aiocqhttp.Event,
bot: aiocqhttp.CQHttp | None = None,
bot_user_id: int | str | None = None,
) -> platform_events.Event | None:
event_type = getattr(event, 'type', None)
if event_type == 'message':
return await AiocqhttpEventConverter.message_to_eba(event, bot)
if event_type == 'notice':
return AiocqhttpEventConverter.notice_to_eba(event, bot_user_id)
if event_type == 'request':
return AiocqhttpEventConverter.request_to_eba(event)
if event_type == 'meta_event':
return AiocqhttpEventConverter.platform_specific(event, f'meta.{getattr(event, "detail_type", "")}')
return None
@staticmethod
async def target2legacy(
event: aiocqhttp.Event,
bot: aiocqhttp.CQHttp | None = None,
) -> platform_events.FriendMessage | platform_events.GroupMessage | None:
eba_event = await AiocqhttpEventConverter.message_to_eba(event, bot)
if eba_event:
return eba_event.to_legacy_event()
return None
@staticmethod
async def message_to_eba(
event: aiocqhttp.Event,
bot: aiocqhttp.CQHttp | None = None,
) -> platform_events.MessageReceivedEvent:
message_chain = await AiocqhttpMessageConverter.target2yiri(
getattr(event, 'message', []),
getattr(event, 'message_id', -1),
getattr(event, 'time', None),
bot,
)
message_type = getattr(event, 'message_type', getattr(event, 'detail_type', 'private'))
group = None
chat_type = platform_entities.ChatType.PRIVATE
chat_id = getattr(event, 'user_id', '')
if message_type == 'group':
chat_type = platform_entities.ChatType.GROUP
chat_id = getattr(event, 'group_id', '')
group = AiocqhttpEventConverter.group_from_event(event)
return platform_events.MessageReceivedEvent(
type='message.received',
adapter_name='aiocqhttp',
message_id=getattr(event, 'message_id', ''),
message_chain=message_chain,
sender=AiocqhttpEventConverter.user_from_sender(event),
chat_type=chat_type,
chat_id=chat_id,
group=group,
timestamp=float(getattr(event, 'time', 0) or 0),
source_platform_object=event,
)
@staticmethod
def notice_to_eba(
event: aiocqhttp.Event,
bot_user_id: int | str | None = None,
) -> platform_events.EBAEvent:
notice_type = getattr(event, 'notice_type', getattr(event, 'detail_type', ''))
if notice_type in ('group_recall', 'friend_recall'):
return platform_events.MessageDeletedEvent(
type='message.deleted',
adapter_name='aiocqhttp',
message_id=getattr(event, 'message_id', ''),
operator=AiocqhttpEventConverter.user(getattr(event, 'operator_id', None)),
chat_type=platform_entities.ChatType.GROUP
if notice_type == 'group_recall'
else platform_entities.ChatType.PRIVATE,
chat_id=getattr(event, 'group_id', getattr(event, 'user_id', '')),
group=AiocqhttpEventConverter.group_from_event(event) if notice_type == 'group_recall' else None,
timestamp=float(getattr(event, 'time', 0) or 0),
source_platform_object=event,
)
if notice_type == 'group_increase':
group = AiocqhttpEventConverter.group_from_event(event)
user = AiocqhttpEventConverter.user(getattr(event, 'user_id', ''))
inviter_id = getattr(event, 'operator_id', None)
if AiocqhttpEventConverter._is_bot_user(getattr(event, 'user_id', None), bot_user_id, event):
return platform_events.BotInvitedToGroupEvent(
type='bot.invited_to_group',
adapter_name='aiocqhttp',
group=group,
inviter=AiocqhttpEventConverter.user(inviter_id) if inviter_id else None,
timestamp=float(getattr(event, 'time', 0) or 0),
source_platform_object=event,
)
return platform_events.MemberJoinedEvent(
type='group.member_joined',
adapter_name='aiocqhttp',
group=group,
member=user,
inviter=AiocqhttpEventConverter.user(inviter_id) if inviter_id else None,
join_type=getattr(event, 'sub_type', None) or 'direct',
timestamp=float(getattr(event, 'time', 0) or 0),
source_platform_object=event,
)
if notice_type == 'group_decrease':
group = AiocqhttpEventConverter.group_from_event(event)
operator = AiocqhttpEventConverter.user(getattr(event, 'operator_id', None))
if AiocqhttpEventConverter._is_bot_user(getattr(event, 'user_id', None), bot_user_id, event):
return platform_events.BotRemovedFromGroupEvent(
type='bot.removed_from_group',
adapter_name='aiocqhttp',
group=group,
operator=operator,
timestamp=float(getattr(event, 'time', 0) or 0),
source_platform_object=event,
)
return platform_events.MemberLeftEvent(
type='group.member_left',
adapter_name='aiocqhttp',
group=group,
member=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')),
is_kicked=getattr(event, 'sub_type', '') in ('kick', 'kick_me'),
operator=operator,
timestamp=float(getattr(event, 'time', 0) or 0),
source_platform_object=event,
)
if notice_type == 'group_ban':
group = AiocqhttpEventConverter.group_from_event(event)
duration = int(getattr(event, 'duration', 0) or 0)
operator = AiocqhttpEventConverter.user(getattr(event, 'operator_id', None))
if AiocqhttpEventConverter._is_bot_user(getattr(event, 'user_id', None), bot_user_id, event):
event_cls = platform_events.BotMutedEvent if duration > 0 else platform_events.BotUnmutedEvent
kwargs: dict[str, typing.Any] = {
'type': 'bot.muted' if duration > 0 else 'bot.unmuted',
'adapter_name': 'aiocqhttp',
'group': group,
'operator': operator,
'timestamp': float(getattr(event, 'time', 0) or 0),
'source_platform_object': event,
}
if duration > 0:
kwargs['duration'] = duration
return event_cls(**kwargs)
if duration > 0:
return platform_events.MemberBannedEvent(
type='group.member_banned',
adapter_name='aiocqhttp',
group=group,
member=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')),
operator=operator,
duration=duration,
timestamp=float(getattr(event, 'time', 0) or 0),
source_platform_object=event,
)
if notice_type == 'friend_add':
return platform_events.FriendAddedEvent(
type='friend.added',
adapter_name='aiocqhttp',
user=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')),
timestamp=float(getattr(event, 'time', 0) or 0),
source_platform_object=event,
)
return AiocqhttpEventConverter.platform_specific(event, f'notice.{notice_type}')
@staticmethod
def request_to_eba(event: aiocqhttp.Event) -> platform_events.EBAEvent:
request_type = getattr(event, 'request_type', getattr(event, 'detail_type', ''))
if request_type == 'friend':
return platform_events.FriendRequestReceivedEvent(
type='friend.request_received',
adapter_name='aiocqhttp',
request_id=getattr(event, 'flag', ''),
user=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')),
message=getattr(event, 'comment', None),
timestamp=float(getattr(event, 'time', 0) or 0),
source_platform_object=event,
)
if request_type == 'group' and getattr(event, 'sub_type', '') == 'invite':
return platform_events.BotInvitedToGroupEvent(
type='bot.invited_to_group',
adapter_name='aiocqhttp',
group=AiocqhttpEventConverter.group_from_event(event),
inviter=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')),
request_id=getattr(event, 'flag', ''),
timestamp=float(getattr(event, 'time', 0) or 0),
source_platform_object=event,
)
return AiocqhttpEventConverter.platform_specific(event, f'request.{request_type}')
@staticmethod
def user_from_sender(event: aiocqhttp.Event) -> platform_entities.User:
sender = getattr(event, 'sender', {}) or {}
nickname = sender.get('card') or sender.get('nickname') or ''
return platform_entities.User(
id=sender.get('user_id', getattr(event, 'user_id', '')),
nickname=nickname,
remark=sender.get('remark'),
)
@staticmethod
def user(user_id: typing.Union[int, str, None], nickname: str = '') -> platform_entities.User | None:
if user_id is None or user_id == '':
return None
return platform_entities.User(id=user_id, nickname=nickname)
@staticmethod
def group_from_event(event: aiocqhttp.Event) -> platform_entities.UserGroup:
return platform_entities.UserGroup(
id=getattr(event, 'group_id', ''),
name=getattr(event, 'group_name', '') or '',
member_count=getattr(event, 'member_count', None),
)
@staticmethod
def platform_specific(event: aiocqhttp.Event, action: str) -> platform_events.PlatformSpecificEvent:
return platform_events.PlatformSpecificEvent(
type='platform.specific',
adapter_name='aiocqhttp',
action=action,
data={key: value for key, value in dict(event).items() if key not in {'message'}},
timestamp=float(getattr(event, 'time', 0) or 0),
source_platform_object=event,
)
@staticmethod
def _is_bot_user(user_id: typing.Any, bot_user_id: typing.Any, event: aiocqhttp.Event) -> bool:
candidate = bot_user_id or getattr(event, 'self_id', None)
return candidate is not None and user_id is not None and str(user_id) == str(candidate)
@@ -0,0 +1,131 @@
apiVersion: v1
kind: MessagePlatformAdapter
metadata:
name: aiocqhttp-eba
label:
en_US: OneBot v11 (EBA)
zh_Hans: OneBot v11 (EBA)
zh_Hant: OneBot v11 (EBA)
description:
en_US: OneBot v11 adapter for QQ-compatible protocol endpoints (EBA architecture)
zh_Hans: OneBot v11 适配器,用于接入 QQ 兼容协议端(EBA 架构版本)
zh_Hant: OneBot v11 適配器,用於接入 QQ 相容協定端(EBA 架構版本)
icon: onebot.svg
spec:
categories:
- protocol
help_links:
zh: https://link.langbot.app/zh/platforms/aiocqhttp
en: https://link.langbot.app/en/platforms/aiocqhttp
ja: https://link.langbot.app/ja/platforms/aiocqhttp
config:
- name: host
label:
en_US: Host
zh_Hans: 主机
zh_Hant: 主機
description:
en_US: The host that OneBot v11 listens on for reverse WebSocket connections. Unless you know what you're doing, use 0.0.0.0
zh_Hans: OneBot v11 反向 WebSocket 监听主机,除非你知道自己在做什么,否则请写 0.0.0.0
zh_Hant: OneBot v11 反向 WebSocket 監聽主機,除非你知道自己在做什麼,否則請填 0.0.0.0
type: string
required: true
default: 0.0.0.0
- name: port
label:
en_US: Port
zh_Hans: 端口
zh_Hant: 連接埠
description:
en_US: Reverse WebSocket listen port
zh_Hans: 反向 WebSocket 监听端口
zh_Hant: 反向 WebSocket 監聽連接埠
type: integer
required: true
default: 2280
- name: access-token
label:
en_US: Access Token
zh_Hans: 访问令牌
zh_Hant: 存取令牌
description:
en_US: Custom connection token for the protocol endpoint. Leave empty if the endpoint has no token configured
zh_Hans: 自定义的协议端连接令牌;若协议端未设置,则不填
zh_Hant: 自訂的協定端連線令牌;若協定端未設定,則不填
type: string
required: false
default: ""
supported_events:
- message.received
- message.deleted
- group.member_joined
- group.member_left
- group.member_banned
- friend.request_received
- friend.added
- bot.invited_to_group
- bot.removed_from_group
- bot.muted
- bot.unmuted
- platform.specific
supported_apis:
required:
- send_message
- reply_message
optional:
- delete_message
- forward_message
- get_message
- get_group_info
- get_group_list
- get_group_member_list
- get_group_member_info
- set_group_name
- get_user_info
- get_friend_list
- approve_friend_request
- approve_group_invite
- mute_member
- unmute_member
- kick_member
- leave_group
- call_platform_api
platform_specific_apis:
- action: get_login_info
description: { en_US: "Get current bot account information", zh_Hans: "获取当前机器人账号信息" }
- action: get_status
description: { en_US: "Get endpoint status", zh_Hans: "获取协议端状态" }
- action: get_version_info
description: { en_US: "Get endpoint version information", zh_Hans: "获取协议端版本信息" }
- action: get_group_honor_info
description: { en_US: "Get group honor information", zh_Hans: "获取群荣誉信息" }
- action: set_group_card
description: { en_US: "Set a member group card", zh_Hans: "设置群名片" }
- action: set_group_special_title
description: { en_US: "Set a member special title", zh_Hans: "设置群专属头衔" }
- action: set_group_admin
description: { en_US: "Set group administrator status", zh_Hans: "设置群管理员" }
- action: set_group_whole_ban
description: { en_US: "Enable or disable whole-group mute", zh_Hans: "设置全员禁言" }
- action: send_group_forward_msg
description: { en_US: "Send a merged forward message", zh_Hans: "发送合并转发消息" }
- action: get_forward_msg
description: { en_US: "Get merged forward message content", zh_Hans: "获取合并转发消息内容" }
- action: get_record
description: { en_US: "Get voice file", zh_Hans: "获取语音文件" }
- action: get_image
description: { en_US: "Get image file", zh_Hans: "获取图片文件" }
- action: can_send_image
description: { en_US: "Check whether images can be sent", zh_Hans: "检查是否可以发送图片" }
- action: can_send_record
description: { en_US: "Check whether voice messages can be sent", zh_Hans: "检查是否可以发送语音" }
execution:
python:
path: ./adapter.py
attr: AiocqhttpAdapter
@@ -0,0 +1,259 @@
from __future__ import annotations
import datetime
import typing
import aiocqhttp
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
from langbot_plugin.api.entities.builtin.platform import message as platform_message
FACE_NAMES = {
'14': '微笑',
'21': '可爱',
'23': '傲慢',
'24': '饥饿',
'25': '',
'26': '惊恐',
'27': '流汗',
'28': '憨笑',
'29': '悠闲',
'30': '奋斗',
'32': '疑问',
'33': '',
'34': '',
'38': '敲打',
'39': '再见',
'42': '爱情',
'43': '跳跳',
'49': '拥抱',
'53': '蛋糕',
'63': '玫瑰',
'66': '爱心',
'74': '太阳',
'75': '月亮',
'76': '',
'78': '握手',
'79': '胜利',
'85': '飞吻',
'89': '西瓜',
'96': '冷汗',
'97': '擦汗',
'98': '抠鼻',
'99': '鼓掌',
'100': '糗大了',
'101': '坏笑',
'102': '左哼哼',
'103': '右哼哼',
'104': '哈欠',
'106': '委屈',
'111': '可怜',
'120': '拳头',
'122': '爱你',
'123': 'NO',
'124': 'OK',
'129': '挥手',
'144': '喝彩',
'147': '棒棒糖',
'171': '',
'173': '泪奔',
'174': '无奈',
'175': '卖萌',
'179': 'doge',
'180': '惊喜',
'182': '笑哭',
'201': '点赞',
'203': '托脸',
'212': '托腮',
'264': '捂脸',
'271': '吃瓜',
'285': '摸鱼',
}
class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
@staticmethod
async def yiri2target(
message_chain: platform_message.MessageChain,
) -> tuple[aiocqhttp.Message, typing.Union[int, str, None], datetime.datetime | None]:
target = aiocqhttp.Message()
source_id: typing.Union[int, str, None] = None
source_time: datetime.datetime | None = None
for component in message_chain:
if isinstance(component, platform_message.Source):
source_id = component.id
source_time = component.time
elif isinstance(component, platform_message.Plain):
target.append(aiocqhttp.MessageSegment.text(component.text))
elif isinstance(component, platform_message.At):
target.append(aiocqhttp.MessageSegment.at(component.target))
elif isinstance(component, platform_message.AtAll):
target.append(aiocqhttp.MessageSegment.at('all'))
elif isinstance(component, platform_message.Image):
file_arg = AiocqhttpMessageConverter._file_arg(component)
if file_arg:
target.append(aiocqhttp.MessageSegment.image(file_arg))
elif isinstance(component, platform_message.Voice):
file_arg = AiocqhttpMessageConverter._file_arg(component)
if file_arg:
target.append(aiocqhttp.MessageSegment.record(file_arg))
elif isinstance(component, platform_message.File):
file_arg = component.url or component.path or component.base64 or component.id
target.append(
aiocqhttp.MessageSegment(
type_='file',
data={
'file': file_arg,
'name': component.name or 'file',
},
)
)
elif isinstance(component, platform_message.Face):
if component.face_type == 'rps':
target.append(aiocqhttp.MessageSegment.rps())
elif component.face_type == 'dice':
target.append(aiocqhttp.MessageSegment.dice())
else:
target.append(aiocqhttp.MessageSegment.face(component.face_id))
elif isinstance(component, platform_message.Forward):
for node in component.node_list:
if node.message_chain:
node_message, _, _ = await AiocqhttpMessageConverter.yiri2target(node.message_chain)
target.extend(node_message)
elif isinstance(component, platform_message.Quote) and component.id is not None:
target.append(aiocqhttp.MessageSegment.reply(component.id))
else:
target.append(aiocqhttp.MessageSegment.text(str(component)))
return target, source_id, source_time
@staticmethod
async def target2yiri(
message: typing.Any,
message_id: typing.Union[int, str] = -1,
timestamp: float | None = None,
bot: aiocqhttp.CQHttp | None = None,
) -> platform_message.MessageChain:
target = aiocqhttp.Message(message)
message_time = datetime.datetime.fromtimestamp(timestamp) if timestamp else datetime.datetime.now()
components: list[platform_message.MessageComponent] = [
platform_message.Source(id=message_id, time=message_time),
]
for segment in target:
if segment.type == 'text':
components.append(platform_message.Plain(text=segment.data.get('text', '')))
elif segment.type == 'at':
qq = str(segment.data.get('qq', ''))
components.append(platform_message.AtAll() if qq == 'all' else platform_message.At(target=qq))
elif segment.type == 'image':
if segment.data.get('emoji_package_id'):
components.append(
platform_message.Face(
face_id=int(segment.data.get('emoji_package_id') or 0),
face_name=segment.data.get('summary', ''),
)
)
else:
components.append(
platform_message.Image(
image_id=str(segment.data.get('file', '')),
url=segment.data.get('url') or segment.data.get('file') or '',
)
)
elif segment.type == 'record':
components.append(
platform_message.Voice(
voice_id=str(segment.data.get('file', '')),
url=segment.data.get('url') or segment.data.get('file') or '',
)
)
elif segment.type == 'file':
components.append(
platform_message.File(
id=str(segment.data.get('file_id') or segment.data.get('file') or ''),
name=segment.data.get('name') or segment.data.get('file') or '',
size=int(segment.data.get('size') or segment.data.get('file_size') or 0),
url=segment.data.get('url') or segment.data.get('file_url') or '',
)
)
elif segment.type == 'reply':
quote = await AiocqhttpMessageConverter._quote_from_reply_segment(segment, bot)
components.append(quote)
elif segment.type == 'face':
face_id = str(segment.data.get('id', 0))
face_name = ''
raw = segment.data.get('raw')
if isinstance(raw, dict):
face_name = str(raw.get('faceText') or '')
components.append(
platform_message.Face(
face_id=int(face_id or 0),
face_name=face_name.replace('/', '') or FACE_NAMES.get(face_id, ''),
)
)
elif segment.type == 'rps':
components.append(
platform_message.Face(
face_type='rps',
face_id=int(segment.data.get('result') or 0),
face_name='猜拳',
)
)
elif segment.type == 'dice':
components.append(
platform_message.Face(
face_type='dice',
face_id=int(segment.data.get('result') or 0),
face_name='骰子',
)
)
else:
components.append(platform_message.Unknown(text=f'{segment.type}:{segment.data}'))
return platform_message.MessageChain(components)
@staticmethod
def _file_arg(component: platform_message.Image | platform_message.Voice) -> str:
if component.base64:
_, _, payload = component.base64.partition(',')
return f'base64://{payload or component.base64}'
if component.url:
return component.url
if component.path:
return str(component.path)
return ''
@staticmethod
async def _quote_from_reply_segment(
segment: aiocqhttp.MessageSegment,
bot: aiocqhttp.CQHttp | None,
) -> platform_message.Quote:
reply_id = segment.data.get('id')
origin = platform_message.MessageChain([])
sender_id = None
group_id = None
target_id = None
if bot is not None and reply_id is not None:
try:
message_data = await bot.get_msg(message_id=int(reply_id))
sender_id = message_data.get('sender', {}).get('user_id') or message_data.get('user_id')
group_id = message_data.get('group_id')
target_id = group_id or sender_id
origin = await AiocqhttpMessageConverter.target2yiri(
message_data.get('message', []),
message_data.get('message_id', reply_id),
message_data.get('time'),
bot=None,
)
except Exception:
origin = platform_message.MessageChain([])
return platform_message.Quote(
id=reply_id,
group_id=group_id,
sender_id=sender_id,
target_id=target_id,
origin=origin,
)
@@ -0,0 +1,7 @@
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="96" height="96" rx="20" fill="#16A34A"/>
<path d="M24 33C24 25.268 30.268 19 38 19H58C65.732 19 72 25.268 72 33V51C72 58.732 65.732 65 58 65H41.5L29 77V64.059C26.024 61.514 24 57.729 24 51V33Z" fill="white"/>
<circle cx="39" cy="42" r="5" fill="#16A34A"/>
<circle cx="57" cy="42" r="5" fill="#16A34A"/>
<path d="M39 53C44.5 57 51.5 57 57 53" stroke="#16A34A" stroke-width="5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 527 B

@@ -0,0 +1,84 @@
from __future__ import annotations
import typing
import aiocqhttp
async def _call(bot: aiocqhttp.CQHttp, action: str, params: dict[str, typing.Any]) -> dict:
result = await bot.call_action(action, **params)
return result or {}
async def get_login_info(bot: aiocqhttp.CQHttp, params: dict) -> dict:
return await _call(bot, 'get_login_info', params)
async def get_status(bot: aiocqhttp.CQHttp, params: dict) -> dict:
return await _call(bot, 'get_status', params)
async def get_version_info(bot: aiocqhttp.CQHttp, params: dict) -> dict:
return await _call(bot, 'get_version_info', params)
async def get_group_honor_info(bot: aiocqhttp.CQHttp, params: dict) -> dict:
return await _call(bot, 'get_group_honor_info', params)
async def set_group_card(bot: aiocqhttp.CQHttp, params: dict) -> dict:
return await _call(bot, 'set_group_card', params)
async def set_group_special_title(bot: aiocqhttp.CQHttp, params: dict) -> dict:
return await _call(bot, 'set_group_special_title', params)
async def set_group_admin(bot: aiocqhttp.CQHttp, params: dict) -> dict:
return await _call(bot, 'set_group_admin', params)
async def set_group_whole_ban(bot: aiocqhttp.CQHttp, params: dict) -> dict:
return await _call(bot, 'set_group_whole_ban', params)
async def send_group_forward_msg(bot: aiocqhttp.CQHttp, params: dict) -> dict:
return await _call(bot, 'send_group_forward_msg', params)
async def get_forward_msg(bot: aiocqhttp.CQHttp, params: dict) -> dict:
return await _call(bot, 'get_forward_msg', params)
async def get_record(bot: aiocqhttp.CQHttp, params: dict) -> dict:
return await _call(bot, 'get_record', params)
async def get_image(bot: aiocqhttp.CQHttp, params: dict) -> dict:
return await _call(bot, 'get_image', params)
async def can_send_image(bot: aiocqhttp.CQHttp, params: dict) -> dict:
return await _call(bot, 'can_send_image', params)
async def can_send_record(bot: aiocqhttp.CQHttp, params: dict) -> dict:
return await _call(bot, 'can_send_record', params)
PLATFORM_API_MAP = {
'get_login_info': get_login_info,
'get_status': get_status,
'get_version_info': get_version_info,
'get_group_honor_info': get_group_honor_info,
'set_group_card': set_group_card,
'set_group_special_title': set_group_special_title,
'set_group_admin': set_group_admin,
'set_group_whole_ban': set_group_whole_ban,
'send_group_forward_msg': send_group_forward_msg,
'get_forward_msg': get_forward_msg,
'get_record': get_record,
'get_image': get_image,
'can_send_image': can_send_image,
'can_send_record': can_send_record,
}
@@ -0,0 +1,9 @@
from __future__ import annotations
import typing
import aiocqhttp
TargetMessage = typing.Union[str, list, dict, aiocqhttp.Message]
OneBotResponse = dict[str, typing.Any] | None
@@ -0,0 +1 @@
"""DingTalk EBA platform adapter."""
@@ -0,0 +1,235 @@
from __future__ import annotations
import traceback
import typing
import pydantic
from langbot.libs.dingtalk_api.api import DingTalkClient
from langbot.libs.dingtalk_api.dingtalkevent import DingTalkEvent
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
from langbot.pkg.platform.adapters.dingtalk.api_impl import DingTalkAPIMixin
from langbot.pkg.platform.adapters.dingtalk.event_converter import DingTalkEventConverter
from langbot.pkg.platform.adapters.dingtalk.message_converter import DingTalkMessageConverter
from langbot.pkg.platform.adapters.dingtalk.platform_api import PLATFORM_API_MAP
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
from langbot_plugin.api.entities.builtin.platform import message as platform_message
from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError
class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapter):
bot: DingTalkClient = pydantic.Field(exclude=True)
message_converter: DingTalkMessageConverter = DingTalkMessageConverter()
event_converter: DingTalkEventConverter = DingTalkEventConverter()
config: dict
listeners: dict[
typing.Type[platform_events.Event],
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
] = {}
card_instance_id_dict: dict = {}
_message_cache: dict[str, platform_events.MessageReceivedEvent] = {}
_user_cache: dict[str, platform_entities.User] = {}
_group_cache: dict[str, platform_entities.UserGroup] = {}
class Config:
arbitrary_types_allowed = True
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
required_keys = ['client_id', 'client_secret', 'robot_name', 'robot_code']
missing_keys = [key for key in required_keys if key not in config]
if missing_keys:
raise Exception('钉钉缺少相关配置项,请查看文档或联系管理员')
bot = DingTalkClient(
client_id=config['client_id'],
client_secret=config['client_secret'],
robot_name=config['robot_name'],
robot_code=config['robot_code'],
markdown_card=config.get('markdown_card', True),
logger=logger,
)
super().__init__(
config=config,
logger=logger,
card_instance_id_dict={},
bot_account_id=config['robot_name'],
bot=bot,
listeners={},
_message_cache={},
_user_cache={},
_group_cache={},
)
self._register_native_handlers()
def get_supported_events(self) -> list[str]:
return [
'message.received',
'platform.specific',
]
def get_supported_apis(self) -> list[str]:
return [
'send_message',
'reply_message',
'get_message',
'get_group_info',
'get_group_list',
'get_group_member_info',
'get_user_info',
'get_friend_list',
'get_file_url',
'call_platform_api',
]
async def send_message(
self,
target_type: str,
target_id: str,
message: platform_message.MessageChain,
) -> platform_events.MessageResult:
markdown_enabled = self.config.get('markdown_card', False)
content, _ = await DingTalkMessageConverter.yiri2target(message, markdown_enabled)
if target_type in ('person', 'private'):
raw = await self.bot.send_proactive_message_to_one(target_id, content)
elif target_type == 'group':
raw = await self.bot.send_proactive_message_to_group(target_id, content)
else:
raise ValueError(f'Unsupported dingtalk target_type: {target_type}')
return platform_events.MessageResult(raw=raw if isinstance(raw, dict) else {'result': raw})
async def reply_message(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
quote_origin: bool = False,
) -> platform_events.MessageResult:
assert isinstance(message_source.source_platform_object, DingTalkEvent)
incoming_message = message_source.source_platform_object.incoming_message
markdown_enabled = self.config.get('markdown_card', False)
content, at = await DingTalkMessageConverter.yiri2target(message, markdown_enabled)
raw = await self.bot.send_message(content, incoming_message, at)
return platform_events.MessageResult(
message_id=getattr(incoming_message, 'message_id', None),
raw=raw if isinstance(raw, dict) else {'result': raw},
)
async def reply_message_chunk(
self,
message_source: platform_events.MessageEvent,
bot_message,
message: platform_message.MessageChain,
quote_origin: bool = False,
is_final: bool = False,
):
message_id = bot_message.resp_message_id
msg_seq = bot_message.msg_sequence
if (msg_seq - 1) % 8 != 0 and not is_final:
return
markdown_enabled = self.config.get('markdown_card', False)
content, _ = await DingTalkMessageConverter.yiri2target(message, markdown_enabled)
card_instance, card_instance_id = self.card_instance_id_dict[message_id]
if not content and bot_message.content:
content = bot_message.content
if content:
await self.bot.send_card_message(card_instance, card_instance_id, content, is_final)
if is_final and bot_message.tool_calls is None:
self.card_instance_id_dict.pop(message_id)
async def create_message_card(self, message_id, event):
card_template_id = self.config['card_template_id']
incoming_message = event.source_platform_object.incoming_message
card_auto_layout = self.config.get('card_auto_layout', False)
card_instance, card_instance_id = await self.bot.create_and_card(
card_template_id,
incoming_message,
card_auto_layout=card_auto_layout,
)
self.card_instance_id_dict[message_id] = (card_instance, card_instance_id)
return True
async def is_stream_output_supported(self) -> bool:
return bool(self.config.get('enable-stream-reply', False))
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
handler = PLATFORM_API_MAP.get(action)
if handler is None:
raise NotSupportedError(f'call_platform_api:{action}')
return await handler(self.bot, params)
def register_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable[
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
],
):
self.listeners[event_type] = callback
def unregister_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable[
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
],
):
registered = self.listeners.get(event_type)
if registered is callback:
self.listeners.pop(event_type, None)
async def run_async(self):
await self.logger.info('DingTalk EBA adapter starting')
await self.bot.start()
async def kill(self) -> bool:
await self.bot.stop()
return True
async def is_muted(self, group_id: int | None = None) -> bool:
return False
def _register_native_handlers(self):
async def on_message(event: DingTalkEvent):
await self._handle_native_event(event)
self.bot.on_message('FriendMessage')(on_message)
self.bot.on_message('GroupMessage')(on_message)
async def _handle_native_event(self, event: DingTalkEvent):
try:
await self.logger.debug(
'DingTalk EBA event received: '
f'conversation={event.conversation}, message_id={getattr(event.incoming_message, "message_id", None)}'
)
if platform_events.FriendMessage in self.listeners or platform_events.GroupMessage in self.listeners:
legacy_event = await self.event_converter.target2legacy(event, self.config['robot_name'])
if legacy_event:
callback = self.listeners.get(type(legacy_event))
if callback:
await callback(legacy_event, self)
eba_event = await self.event_converter.target2yiri(event, self.config['robot_name'])
if eba_event:
self._cache_event(eba_event)
await self._dispatch_eba_event(eba_event)
except Exception:
await self.logger.error(f'Error in dingtalk native event: {traceback.format_exc()}')
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
callback = self.listeners.get(event_type)
if callback:
await callback(event, self)
return
def _cache_event(self, event: platform_events.Event):
if not isinstance(event, platform_events.MessageReceivedEvent):
return
self._message_cache[str(event.message_id)] = event
self._user_cache[str(event.sender.id)] = event.sender
if event.group:
self._group_cache[str(event.group.id)] = event.group
@@ -0,0 +1,65 @@
from __future__ import annotations
import typing
from langbot.libs.dingtalk_api.api import DingTalkClient
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError
class DingTalkAPIMixin:
bot: DingTalkClient
_message_cache: dict[str, platform_events.MessageReceivedEvent]
_user_cache: dict[str, platform_entities.User]
_group_cache: dict[str, platform_entities.UserGroup]
async def get_message(
self,
chat_type: str,
chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
) -> platform_events.MessageReceivedEvent:
event = self._message_cache.get(str(message_id))
if event is None:
raise NotSupportedError('get_message:message_not_cached')
return event
async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup:
return self._group_cache.get(str(group_id)) or platform_entities.UserGroup(id=group_id, name='')
async def get_group_list(self) -> list[platform_entities.UserGroup]:
return list(self._group_cache.values())
async def get_group_member_list(
self,
group_id: typing.Union[int, str],
) -> list[platform_entities.UserGroupMember]:
raise NotSupportedError('get_group_member_list')
async def get_group_member_info(
self,
group_id: typing.Union[int, str],
user_id: typing.Union[int, str],
) -> platform_entities.UserGroupMember:
user = self._user_cache.get(str(user_id))
if user is None:
raise NotSupportedError('get_group_member_info:user_not_cached')
return platform_entities.UserGroupMember(
user=user,
group_id=group_id,
role=platform_entities.MemberRole.MEMBER,
display_name=user.nickname,
)
async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User:
return self._user_cache.get(str(user_id)) or platform_entities.User(id=user_id, nickname='')
async def get_friend_list(self) -> list[platform_entities.User]:
return list(self._user_cache.values())
async def upload_file(self, file_data: bytes, filename: str) -> str:
raise NotSupportedError('upload_file')
async def get_file_url(self, file_id: str) -> str:
return await self.bot.get_file_url(file_id)
@@ -0,0 +1,7 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<svg fill="#4aa4f8" width="800px" height="800px" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" class="icon" stroke="#4aa4f8">
<g id="SVGRepo_bgCarrier" stroke-width="0"/>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,97 @@
from __future__ import annotations
import typing
from langbot.libs.dingtalk_api.dingtalkevent import DingTalkEvent
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
from langbot.pkg.platform.adapters.dingtalk.message_converter import DingTalkMessageConverter
from langbot.pkg.platform.adapters.dingtalk.types import ADAPTER_NAME
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
class DingTalkEventConverter(abstract_platform_adapter.AbstractEventConverter):
@staticmethod
async def yiri2target(event: platform_events.Event):
return getattr(event, 'source_platform_object', None)
@staticmethod
async def target2yiri(event: DingTalkEvent, bot_name: str) -> platform_events.Event | None:
if event.conversation in {'FriendMessage', 'GroupMessage'}:
return await DingTalkEventConverter.message_to_eba(event, bot_name)
return DingTalkEventConverter.platform_specific(event, f'message.{event.conversation or "unknown"}')
@staticmethod
async def target2legacy(
event: DingTalkEvent,
bot_name: str,
) -> platform_events.FriendMessage | platform_events.GroupMessage | None:
eba_event = await DingTalkEventConverter.message_to_eba(event, bot_name)
if eba_event:
return eba_event.to_legacy_event()
return None
@staticmethod
async def message_to_eba(event: DingTalkEvent, bot_name: str) -> platform_events.MessageReceivedEvent:
incoming_message = event.incoming_message
message_chain = await DingTalkMessageConverter.target2yiri(event, bot_name)
sender = DingTalkEventConverter.user_from_event(event)
chat_type = platform_entities.ChatType.PRIVATE
chat_id = getattr(incoming_message, 'sender_staff_id', '')
group = None
if event.conversation == 'GroupMessage':
chat_type = platform_entities.ChatType.GROUP
chat_id = getattr(incoming_message, 'conversation_id', '')
group = DingTalkEventConverter.group_from_event(event)
return platform_events.MessageReceivedEvent(
type='message.received',
adapter_name=ADAPTER_NAME,
message_id=getattr(incoming_message, 'message_id', ''),
message_chain=message_chain,
sender=sender,
chat_type=chat_type,
chat_id=chat_id,
group=group,
timestamp=DingTalkEventConverter._timestamp(incoming_message),
source_platform_object=event,
)
@staticmethod
def user_from_event(event: DingTalkEvent) -> platform_entities.User:
incoming_message = event.incoming_message
return platform_entities.User(
id=getattr(incoming_message, 'sender_staff_id', ''),
nickname=getattr(incoming_message, 'sender_nick', '') or '',
)
@staticmethod
def group_from_event(event: DingTalkEvent) -> platform_entities.UserGroup:
incoming_message = event.incoming_message
return platform_entities.UserGroup(
id=getattr(incoming_message, 'conversation_id', ''),
name=getattr(incoming_message, 'conversation_title', '') or '',
)
@staticmethod
def platform_specific(event: DingTalkEvent, action: str) -> platform_events.PlatformSpecificEvent:
return platform_events.PlatformSpecificEvent(
type='platform.specific',
adapter_name=ADAPTER_NAME,
action=action,
data={
key: value for key, value in dict(event).items() if key not in {'IncomingMessage', 'Picture', 'Audio'}
},
timestamp=DingTalkEventConverter._timestamp(event.incoming_message),
source_platform_object=event,
)
@staticmethod
def _timestamp(incoming_message: typing.Any) -> float:
value = getattr(incoming_message, 'create_at', None)
if isinstance(value, (int, float)):
timestamp = float(value)
return timestamp / 1000 if timestamp > 10_000_000_000 else timestamp
if hasattr(value, 'timestamp'):
return float(value.timestamp())
return 0.0
@@ -0,0 +1,126 @@
apiVersion: v1
kind: MessagePlatformAdapter
metadata:
name: dingtalk-eba
label:
en_US: DingTalk (EBA)
zh_Hans: 钉钉 (EBA)
zh_Hant: 釘釘 (EBA)
description:
en_US: DingTalk adapter (EBA architecture)
zh_Hans: 钉钉适配器(EBA 架构版本)
zh_Hant: 釘釘適配器(EBA 架構版本)
icon: dingtalk.svg
spec:
categories:
- china
help_links:
zh: https://link.langbot.app/zh/platforms/dingtalk
en: https://link.langbot.app/en/platforms/dingtalk
ja: https://link.langbot.app/ja/platforms/dingtalk
config:
- name: client_id
label:
en_US: Client ID
zh_Hans: 客户端ID
zh_Hant: 用戶端ID
type: string
required: true
default: ""
- name: client_secret
label:
en_US: Client Secret
zh_Hans: 客户端密钥
zh_Hant: 用戶端密鑰
type: string
required: true
default: ""
- name: robot_code
label:
en_US: Robot Code
zh_Hans: 机器人代码
zh_Hant: 機器人代碼
type: string
required: true
default: ""
- name: robot_name
label:
en_US: Robot Name
zh_Hans: 机器人名称
zh_Hant: 機器人名稱
type: string
required: true
default: ""
- name: markdown_card
label:
en_US: Markdown Card
zh_Hans: 是否使用 Markdown 卡片
zh_Hant: 是否使用 Markdown 卡片
type: boolean
required: false
default: true
- name: enable-stream-reply
label:
en_US: Enable Stream Reply Mode
zh_Hans: 启用钉钉卡片流式回复模式
zh_Hant: 啟用釘釘卡片串流回覆模式
description:
en_US: If enabled, the bot will use DingTalk card streaming replies.
zh_Hans: 如果启用,将使用钉钉卡片流式方式来回复内容
zh_Hant: 如果啟用,將使用釘釘卡片串流方式來回覆內容
type: boolean
required: true
default: false
- name: card_auto_layout
label:
en_US: Card Auto Layout
zh_Hans: 卡片宽屏自动布局
zh_Hant: 卡片寬螢幕自動佈局
type: boolean
required: false
default: false
- name: card_template_id
label:
en_US: Card Template ID
zh_Hans: 卡片模板ID
zh_Hant: 卡片範本ID
type: string
required: true
default: "填写你的卡片template_id"
supported_events:
- message.received
- platform.specific
supported_apis:
required:
- send_message
- reply_message
optional:
- get_message
- get_group_info
- get_group_list
- get_group_member_info
- get_user_info
- get_friend_list
- get_file_url
- call_platform_api
platform_specific_apis:
- action: check_access_token
description: { en_US: "Check whether the current DingTalk access token is usable", zh_Hans: "检查当前钉钉 access token 是否可用" }
- action: refresh_access_token
description: { en_US: "Refresh the DingTalk access token", zh_Hans: "刷新钉钉 access token" }
- action: get_file_url
description: { en_US: "Resolve a DingTalk download code to a file URL", zh_Hans: "将钉钉 downloadCode 解析为文件 URL" }
- action: get_audio_base64
description: { en_US: "Download DingTalk audio as base64 by download code", zh_Hans: "通过 downloadCode 下载钉钉语音并转为 base64" }
- action: download_image_base64
description: { en_US: "Download DingTalk image as base64 by download code", zh_Hans: "通过 downloadCode 下载钉钉图片并转为 base64" }
execution:
python:
path: ./adapter.py
attr: DingTalkAdapter
@@ -0,0 +1,177 @@
from __future__ import annotations
import datetime
import typing
from langbot.libs.dingtalk_api.dingtalkevent import DingTalkEvent
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
from langbot_plugin.api.entities.builtin.platform import message as platform_message
class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
@staticmethod
def _format_image_as_markdown(msg: platform_message.Image) -> str:
if msg.url:
return f'\n![image]({msg.url})\n'
if msg.base64:
if msg.base64.startswith('data:'):
return f'\n![image]({msg.base64})\n'
return f'\n![image](data:image/png;base64,{msg.base64})\n'
return ''
@staticmethod
def _component_text_fallback(component: platform_message.MessageComponent) -> str:
if isinstance(component, platform_message.At):
return f'@{component.display or component.target}'
if isinstance(component, platform_message.AtAll):
return '@所有人'
if isinstance(component, platform_message.File):
if component.url:
return f'\n[{component.name or "file"}]({component.url})\n'
return f'\n[File]{component.name or component.id or "file"}\n'
if isinstance(component, platform_message.Voice):
return component.url or '[Voice]'
if isinstance(component, platform_message.Face):
return str(component)
if isinstance(component, platform_message.Unknown):
return component.text
return str(component)
@staticmethod
async def yiri2target(
message_chain: platform_message.MessageChain,
markdown_enabled: bool = True,
) -> tuple[str, bool]:
content = ''
at = False
for msg in message_chain:
if isinstance(msg, platform_message.Source):
continue
if isinstance(msg, platform_message.Plain):
content += msg.text
elif isinstance(msg, platform_message.At):
at = True
content += DingTalkMessageConverter._component_text_fallback(msg)
elif isinstance(msg, platform_message.AtAll):
content += DingTalkMessageConverter._component_text_fallback(msg)
elif isinstance(msg, platform_message.Image):
if markdown_enabled:
content += DingTalkMessageConverter._format_image_as_markdown(msg)
else:
content += '[Image]'
elif isinstance(msg, platform_message.File):
content += DingTalkMessageConverter._component_text_fallback(msg)
elif isinstance(msg, platform_message.Voice):
content += DingTalkMessageConverter._component_text_fallback(msg)
elif isinstance(msg, platform_message.Quote):
if msg.id is not None:
content += f'[引用消息 {msg.id}] '
if msg.origin:
quote_content, quote_at = await DingTalkMessageConverter.yiri2target(msg.origin, markdown_enabled)
content += quote_content
at = at or quote_at
elif isinstance(msg, platform_message.Forward):
for node in msg.node_list:
sender = node.sender_name or node.sender_id or ''
if sender:
content += f'\n[{sender}] '
if node.message_chain:
forwarded_content, forwarded_at = await DingTalkMessageConverter.yiri2target(
node.message_chain, markdown_enabled
)
content += forwarded_content
at = at or forwarded_at
else:
content += DingTalkMessageConverter._component_text_fallback(msg)
return content, at
@staticmethod
async def target2yiri(event: DingTalkEvent, bot_name: str) -> platform_message.MessageChain:
incoming_message = event.incoming_message
components: list[platform_message.MessageComponent] = [
platform_message.Source(
id=getattr(incoming_message, 'message_id', ''),
time=DingTalkMessageConverter._message_time(incoming_message),
)
]
for at_user in getattr(incoming_message, 'at_users', []) or []:
if getattr(at_user, 'dingtalk_id', None) == getattr(incoming_message, 'chatbot_user_id', None):
components.append(platform_message.At(target=bot_name, display=bot_name))
rich_content = event.rich_content
if rich_content:
for element in rich_content.get('Elements') or []:
if element.get('Type') == 'text':
text = DingTalkMessageConverter._strip_bot_mention(element.get('Content', ''), bot_name)
if text.strip():
components.append(platform_message.Plain(text=text))
elif element.get('Type') == 'image' and element.get('Picture'):
components.append(platform_message.Image(base64=element['Picture']))
else:
if event.content and event.type != 'audio':
components.append(
platform_message.Plain(
text=DingTalkMessageConverter._strip_bot_mention(event.content, bot_name),
)
)
if event.picture:
components.append(platform_message.Image(base64=event.picture))
if event.file:
components.append(platform_message.File(url=event.file, name=event.name or 'file'))
if event.audio:
if event.content and event.type == 'audio':
components.append(platform_message.Plain(text=event.content))
else:
components.append(platform_message.Voice(base64=event.audio))
quote = DingTalkMessageConverter._quote_component(event)
if quote:
components.append(quote)
return platform_message.MessageChain(components)
@staticmethod
def _quote_component(event: DingTalkEvent) -> platform_message.Quote | None:
quote_info = event.quoted_message
if not quote_info:
return None
origin_components: list[platform_message.MessageComponent] = []
msg_type = quote_info.get('msg_type', '')
if msg_type == 'file' and quote_info.get('file_url'):
origin_components.append(
platform_message.File(url=quote_info['file_url'], name=quote_info.get('file_name', 'file'))
)
elif msg_type == 'picture' and quote_info.get('picture'):
origin_components.append(platform_message.Image(base64=quote_info['picture']))
elif msg_type == 'audio' and quote_info.get('audio'):
origin_components.append(platform_message.Voice(base64=quote_info['audio']))
elif quote_info.get('content'):
origin_components.append(platform_message.Plain(text=str(quote_info['content'])))
incoming_message = event.incoming_message
return platform_message.Quote(
id=quote_info.get('message_id') or None,
group_id=getattr(incoming_message, 'conversation_id', None),
sender_id=quote_info.get('sender_id') or None,
target_id=getattr(incoming_message, 'conversation_id', None)
or getattr(incoming_message, 'sender_staff_id', None),
origin=platform_message.MessageChain(origin_components),
)
@staticmethod
def _strip_bot_mention(text: str, bot_name: str) -> str:
return text.replace('@' + bot_name, '')
@staticmethod
def _message_time(incoming_message: typing.Any) -> datetime.datetime:
value = getattr(incoming_message, 'create_at', None)
if isinstance(value, datetime.datetime):
return value
if isinstance(value, (int, float)):
timestamp = float(value)
if timestamp > 10_000_000_000:
timestamp = timestamp / 1000
return datetime.datetime.fromtimestamp(timestamp)
return datetime.datetime.now()
@@ -0,0 +1,44 @@
from __future__ import annotations
import typing
from langbot.libs.dingtalk_api.api import DingTalkClient
async def check_access_token(bot: DingTalkClient, params: dict) -> dict:
return {'valid': await bot.check_access_token()}
async def refresh_access_token(bot: DingTalkClient, params: dict) -> dict:
await bot.get_access_token()
return {'ok': bool(bot.access_token)}
async def get_file_url(bot: DingTalkClient, params: dict) -> dict:
download_code = params.get('download_code') or params.get('downloadCode') or params.get('file_id')
if not download_code:
raise ValueError('download_code is required')
return {'url': await bot.get_file_url(str(download_code))}
async def get_audio_base64(bot: DingTalkClient, params: dict) -> dict:
download_code = params.get('download_code') or params.get('downloadCode') or params.get('file_id')
if not download_code:
raise ValueError('download_code is required')
return {'base64': await bot.get_audio_url(str(download_code))}
async def download_image_base64(bot: DingTalkClient, params: dict) -> dict:
download_code = params.get('download_code') or params.get('downloadCode') or params.get('file_id')
if not download_code:
raise ValueError('download_code is required')
return {'base64': await bot.download_image(str(download_code))}
PLATFORM_API_MAP: dict[str, typing.Callable[[DingTalkClient, dict], typing.Awaitable[dict]]] = {
'check_access_token': check_access_token,
'refresh_access_token': refresh_access_token,
'get_file_url': get_file_url,
'get_audio_base64': get_audio_base64,
'download_image_base64': download_image_base64,
}
@@ -0,0 +1,3 @@
from __future__ import annotations
ADAPTER_NAME = 'dingtalk'
@@ -0,0 +1,5 @@
from __future__ import annotations
from langbot.pkg.platform.adapters.discord.adapter import DiscordAdapter
__all__ = ['DiscordAdapter']
@@ -0,0 +1,253 @@
from __future__ import annotations
import os
import traceback
import typing
import discord
import pydantic
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
from langbot.pkg.platform.adapters.discord.api_impl import DiscordAPIMixin
from langbot.pkg.platform.adapters.discord.event_converter import DiscordEventConverter
from langbot.pkg.platform.adapters.discord.message_converter import DiscordMessageConverter
from langbot.pkg.platform.adapters.discord.platform_api import PLATFORM_API_MAP
from langbot_plugin.api.entities.builtin.platform import events as platform_events
from langbot_plugin.api.entities.builtin.platform import message as platform_message
class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatformAdapter):
bot: discord.Client = pydantic.Field(exclude=True)
message_converter: DiscordMessageConverter = DiscordMessageConverter()
event_converter: DiscordEventConverter = DiscordEventConverter()
config: dict
listeners: dict[
typing.Type[platform_events.Event],
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
] = {}
class Config:
arbitrary_types_allowed = True
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
adapter_self = self
class LangBotDiscordClient(discord.Client):
async def on_ready(self: discord.Client):
adapter_self.bot_account_id = str(self.user.id) if self.user else ''
await adapter_self.logger.info(f'Discord adapter running as {self.user}')
async def on_message(self: discord.Client, message: discord.Message):
if self.user and message.author.id == self.user.id:
return
if message.author.bot:
return
try:
if (
platform_events.FriendMessage in adapter_self.listeners
or platform_events.GroupMessage in adapter_self.listeners
):
legacy_event = await adapter_self.event_converter.target2legacy(message)
callback = adapter_self.listeners.get(type(legacy_event))
if callback:
await callback(legacy_event, adapter_self)
eba_event = await adapter_self.event_converter.target2yiri(
message, self.user.id if self.user else None
)
if eba_event:
await adapter_self._dispatch_eba_event(eba_event)
except Exception:
await adapter_self.logger.error(f'Error in discord on_message: {traceback.format_exc()}')
async def on_message_edit(self: discord.Client, before: discord.Message, after: discord.Message):
await adapter_self._dispatch_gateway_tuple(
'message_edit', (before, after), self.user.id if self.user else None
)
async def on_message_delete(self: discord.Client, message: discord.Message):
await adapter_self._dispatch_gateway_tuple(
'message_delete', message, self.user.id if self.user else None
)
async def on_raw_message_delete(self: discord.Client, payload: discord.RawMessageDeleteEvent):
await adapter_self._dispatch_gateway_tuple(
'raw_message_delete',
payload,
self.user.id if self.user else None,
)
async def on_reaction_add(
self: discord.Client, reaction: discord.Reaction, user: discord.User | discord.Member
):
if self.user and user.id == self.user.id:
return
await adapter_self._dispatch_gateway_tuple(
'reaction_add', (reaction, user), self.user.id if self.user else None
)
async def on_reaction_remove(
self: discord.Client, reaction: discord.Reaction, user: discord.User | discord.Member
):
if self.user and user.id == self.user.id:
return
await adapter_self._dispatch_gateway_tuple(
'reaction_remove', (reaction, user), self.user.id if self.user else None
)
async def on_raw_reaction_add(self: discord.Client, payload: discord.RawReactionActionEvent):
if self.user and payload.user_id == self.user.id:
return
await adapter_self._dispatch_gateway_tuple(
'raw_reaction_add',
payload,
self.user.id if self.user else None,
)
async def on_raw_reaction_remove(self: discord.Client, payload: discord.RawReactionActionEvent):
if self.user and payload.user_id == self.user.id:
return
await adapter_self._dispatch_gateway_tuple(
'raw_reaction_remove',
payload,
self.user.id if self.user else None,
)
async def on_member_join(self: discord.Client, member: discord.Member):
await adapter_self._dispatch_gateway_tuple('member_join', member, self.user.id if self.user else None)
async def on_member_remove(self: discord.Client, member: discord.Member):
await adapter_self._dispatch_gateway_tuple('member_remove', member, self.user.id if self.user else None)
async def on_guild_join(self: discord.Client, guild: discord.Guild):
await adapter_self._dispatch_gateway_tuple('guild_join', guild, self.user.id if self.user else None)
async def on_guild_remove(self: discord.Client, guild: discord.Guild):
await adapter_self._dispatch_gateway_tuple('guild_remove', guild, self.user.id if self.user else None)
intents = discord.Intents.default()
intents.message_content = True
intents.members = True
intents.reactions = True
args = {}
if os.getenv('http_proxy'):
args['proxy'] = os.getenv('http_proxy')
bot = LangBotDiscordClient(intents=intents, **args)
super().__init__(
config=config,
logger=logger,
bot_account_id=config.get('client_id', ''),
listeners={},
bot=bot,
)
def get_supported_events(self) -> list[str]:
return [
'message.received',
'message.edited',
'message.deleted',
'message.reaction',
'group.member_joined',
'group.member_left',
'bot.invited_to_group',
'bot.removed_from_group',
'platform.specific',
]
def get_supported_apis(self) -> list[str]:
return [
'send_message',
'reply_message',
'edit_message',
'delete_message',
'forward_message',
'get_group_info',
'get_group_member_list',
'get_group_member_info',
'get_user_info',
'get_file_url',
'mute_member',
'unmute_member',
'kick_member',
'leave_group',
'call_platform_api',
]
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
content, files = await self.message_converter.yiri2target(message)
channel = await self._get_channel(target_id)
kwargs = {'content': content}
if files:
kwargs['files'] = files
sent = await channel.send(**kwargs)
return platform_events.MessageResult(message_id=sent.id, raw={'message_id': sent.id})
async def reply_message(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
quote_origin: bool = False,
):
assert isinstance(message_source.source_platform_object, discord.Message)
content, files = await self.message_converter.yiri2target(message)
kwargs = {'content': content}
if files:
kwargs['files'] = files
if quote_origin:
kwargs['reference'] = message_source.source_platform_object
kwargs['mention_author'] = any(isinstance(component, platform_message.At) for component in message.root)
sent = await message_source.source_platform_object.channel.send(**kwargs)
return platform_events.MessageResult(message_id=sent.id, raw={'message_id': sent.id})
async def _dispatch_gateway_tuple(self, kind: str, payload, bot_user_id: int | None):
try:
event = await self.event_converter.target2yiri((kind, payload), bot_user_id)
if event:
await self._dispatch_eba_event(event)
except Exception:
await self.logger.error(f'Error in discord {kind}: {traceback.format_exc()}')
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
callback = self.listeners.get(event_type)
if callback:
await callback(event, self)
return
def register_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable[
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
],
):
self.listeners[event_type] = callback
def unregister_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable[
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
],
):
self.listeners.pop(event_type, None)
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
handler = PLATFORM_API_MAP.get(action)
if handler is None:
from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError
raise NotSupportedError(f'call_platform_api:{action}')
return await handler(self.bot, params)
async def run_async(self):
await self.bot.start(self.config['token'], reconnect=True)
async def kill(self) -> bool:
await self.bot.close()
return True
@@ -0,0 +1,153 @@
from __future__ import annotations
import datetime
import typing
import discord
from langbot.pkg.platform.adapters.discord.event_converter import DiscordEventConverter
from langbot.pkg.platform.adapters.discord.message_converter import DiscordMessageConverter
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
from langbot_plugin.api.entities.builtin.platform import message as platform_message
class DiscordAPIMixin:
bot: discord.Client
async def edit_message(
self,
chat_type: str,
chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
new_content: platform_message.MessageChain,
) -> None:
channel = await self._get_channel(chat_id)
message = await channel.fetch_message(int(message_id))
content, files = await DiscordMessageConverter.yiri2target(new_content)
if files:
await message.edit(content=content, attachments=[])
await channel.send(content=content, files=files)
return
await message.edit(content=content)
async def delete_message(
self,
chat_type: str,
chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
) -> None:
channel = await self._get_channel(chat_id)
message = await channel.fetch_message(int(message_id))
await message.delete()
async def forward_message(
self,
from_chat_type: str,
from_chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
to_chat_type: str,
to_chat_id: typing.Union[int, str],
) -> platform_events.MessageResult:
from_channel = await self._get_channel(from_chat_id)
to_channel = await self._get_channel(to_chat_id)
message = await from_channel.fetch_message(int(message_id))
files = [await attachment.to_file() for attachment in message.attachments]
sent = await to_channel.send(content=message.content, files=files)
return platform_events.MessageResult(message_id=sent.id, raw={'message_id': sent.id})
async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup:
guild = await self._get_guild(group_id)
return DiscordEventConverter.group_from_guild(guild)
async def get_group_member_list(
self,
group_id: typing.Union[int, str],
) -> list[platform_entities.UserGroupMember]:
guild = await self._get_guild(group_id)
members = guild.members or [member async for member in guild.fetch_members(limit=None)]
return [self._member_to_entity(member) for member in members]
async def get_group_member_info(
self,
group_id: typing.Union[int, str],
user_id: typing.Union[int, str],
) -> platform_entities.UserGroupMember:
guild = await self._get_guild(group_id)
member = guild.get_member(int(user_id)) or await guild.fetch_member(int(user_id))
return self._member_to_entity(member)
async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User:
user = self.bot.get_user(int(user_id)) or await self.bot.fetch_user(int(user_id))
return DiscordEventConverter.user_from_author(user)
async def upload_file(self, file_data: bytes, filename: str) -> str:
from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError
raise NotSupportedError('upload_file')
async def get_file_url(self, file_id: str) -> str:
return file_id
async def mute_member(
self,
group_id: typing.Union[int, str],
user_id: typing.Union[int, str],
duration: int = 0,
) -> None:
guild = await self._get_guild(group_id)
member = guild.get_member(int(user_id)) or await guild.fetch_member(int(user_id))
until = None
if duration > 0:
until = datetime.datetime.now(datetime.UTC) + datetime.timedelta(seconds=duration)
await member.timeout(until, reason='LangBot EBA mute_member')
async def unmute_member(
self,
group_id: typing.Union[int, str],
user_id: typing.Union[int, str],
) -> None:
guild = await self._get_guild(group_id)
member = guild.get_member(int(user_id)) or await guild.fetch_member(int(user_id))
await member.timeout(None, reason='LangBot EBA unmute_member')
async def kick_member(
self,
group_id: typing.Union[int, str],
user_id: typing.Union[int, str],
) -> None:
guild = await self._get_guild(group_id)
member = guild.get_member(int(user_id)) or await guild.fetch_member(int(user_id))
await member.kick(reason='LangBot EBA kick_member')
async def leave_group(self, group_id: typing.Union[int, str]) -> None:
guild = await self._get_guild(group_id)
await guild.leave()
async def _get_channel(self, channel_id: typing.Union[int, str]) -> discord.abc.Messageable:
channel = self.bot.get_channel(int(channel_id))
if channel is None:
channel = await self.bot.fetch_channel(int(channel_id))
return channel
async def _get_guild(self, guild_id: typing.Union[int, str]) -> discord.Guild:
guild = self.bot.get_guild(int(guild_id))
if guild is None:
guild = await self.bot.fetch_guild(int(guild_id))
return guild
@staticmethod
def _member_to_entity(member: discord.Member) -> platform_entities.UserGroupMember:
role = platform_entities.MemberRole.MEMBER
if member.guild.owner_id == member.id:
role = platform_entities.MemberRole.OWNER
elif member.guild_permissions.administrator or member.guild_permissions.manage_guild:
role = platform_entities.MemberRole.ADMIN
return platform_entities.UserGroupMember(
user=DiscordEventConverter.user_from_author(member),
group_id=member.guild.id,
role=role,
display_name=member.display_name,
joined_at=member.joined_at.timestamp() if member.joined_at else None,
title=member.top_role.name if member.top_role else None,
)
@@ -0,0 +1,7 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<svg width="80px" height="80px" viewBox="0 -28.5 256 256" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid" fill="#000000">
<g id="SVGRepo_bgCarrier" stroke-width="0"/>

After

Width:  |  Height:  |  Size: 2.2 KiB

@@ -0,0 +1,296 @@
from __future__ import annotations
import typing
import discord
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
from langbot.pkg.platform.adapters.discord.message_converter import DiscordMessageConverter
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
class DiscordEventConverter(abstract_platform_adapter.AbstractEventConverter):
@staticmethod
async def yiri2target(event: platform_events.Event) -> discord.Message:
raise NotImplementedError
@staticmethod
async def target2yiri(event: typing.Any, bot_user_id: int | None = None) -> platform_events.Event | None:
if isinstance(event, discord.Message):
return await DiscordEventConverter.message_to_eba(event)
if isinstance(event, tuple) and len(event) == 2:
kind, payload = event
if kind == 'message_edit':
before, after = payload
return await DiscordEventConverter.message_edit_to_eba(before, after)
if kind == 'message_delete':
return await DiscordEventConverter.message_delete_to_eba(payload)
if kind == 'raw_message_delete':
return DiscordEventConverter.raw_message_delete_to_eba(payload)
if kind == 'reaction_add':
reaction, user = payload
return DiscordEventConverter.reaction_to_eba(reaction, user, True)
if kind == 'reaction_remove':
reaction, user = payload
return DiscordEventConverter.reaction_to_eba(reaction, user, False)
if kind == 'raw_reaction_add':
return DiscordEventConverter.raw_reaction_to_eba(payload, True)
if kind == 'raw_reaction_remove':
return DiscordEventConverter.raw_reaction_to_eba(payload, False)
if kind == 'member_join':
return DiscordEventConverter.member_join_to_eba(payload, bot_user_id)
if kind == 'member_remove':
return DiscordEventConverter.member_left_to_eba(payload, bot_user_id)
if kind == 'guild_join':
return DiscordEventConverter.guild_join_to_eba(payload)
if kind == 'guild_remove':
return DiscordEventConverter.guild_remove_to_eba(payload)
return None
@staticmethod
async def message_to_eba(message: discord.Message) -> platform_events.MessageReceivedEvent:
message_chain = await DiscordMessageConverter.target2yiri(message)
group = DiscordEventConverter.group_from_message(message)
return platform_events.MessageReceivedEvent(
type='message.received',
adapter_name='discord',
message_id=message.id,
message_chain=message_chain,
sender=DiscordEventConverter.user_from_author(message.author),
chat_type=platform_entities.ChatType.PRIVATE
if isinstance(message.channel, discord.DMChannel)
else platform_entities.ChatType.GROUP,
chat_id=message.channel.id,
group=group,
timestamp=message.created_at.timestamp(),
source_platform_object=message,
)
@staticmethod
async def message_edit_to_eba(
before: discord.Message, after: discord.Message
) -> platform_events.MessageEditedEvent:
return platform_events.MessageEditedEvent(
type='message.edited',
adapter_name='discord',
message_id=after.id,
new_content=await DiscordMessageConverter.target2yiri(after),
editor=DiscordEventConverter.user_from_author(after.author),
chat_type=platform_entities.ChatType.PRIVATE
if isinstance(after.channel, discord.DMChannel)
else platform_entities.ChatType.GROUP,
chat_id=after.channel.id,
group=DiscordEventConverter.group_from_message(after),
timestamp=after.edited_at.timestamp() if after.edited_at else after.created_at.timestamp(),
source_platform_object=after,
)
@staticmethod
async def message_delete_to_eba(message: discord.Message) -> platform_events.MessageDeletedEvent:
return platform_events.MessageDeletedEvent(
type='message.deleted',
adapter_name='discord',
message_id=message.id,
operator=None,
chat_type=platform_entities.ChatType.PRIVATE
if isinstance(message.channel, discord.DMChannel)
else platform_entities.ChatType.GROUP,
chat_id=message.channel.id,
group=DiscordEventConverter.group_from_message(message),
timestamp=message.created_at.timestamp() if message.created_at else 0.0,
source_platform_object=message,
)
@staticmethod
def raw_message_delete_to_eba(payload: discord.RawMessageDeleteEvent) -> platform_events.MessageDeletedEvent:
return platform_events.MessageDeletedEvent(
type='message.deleted',
adapter_name='discord',
message_id=payload.message_id,
operator=None,
chat_type=platform_entities.ChatType.PRIVATE
if payload.guild_id is None
else platform_entities.ChatType.GROUP,
chat_id=payload.channel_id,
group=platform_entities.UserGroup(id=payload.guild_id) if payload.guild_id is not None else None,
source_platform_object=payload,
)
@staticmethod
def reaction_to_eba(
reaction: discord.Reaction,
user: discord.User | discord.Member,
is_add: bool,
) -> platform_events.MessageReactionEvent:
message = reaction.message
return platform_events.MessageReactionEvent(
type='message.reaction',
adapter_name='discord',
message_id=message.id,
user=DiscordEventConverter.user_from_author(user),
reaction=str(reaction.emoji),
is_add=is_add,
chat_type=platform_entities.ChatType.PRIVATE
if isinstance(message.channel, discord.DMChannel)
else platform_entities.ChatType.GROUP,
chat_id=message.channel.id,
group=DiscordEventConverter.group_from_message(message),
source_platform_object=reaction,
)
@staticmethod
def raw_reaction_to_eba(
payload: discord.RawReactionActionEvent,
is_add: bool,
) -> platform_events.MessageReactionEvent:
member = getattr(payload, 'member', None)
user = member or getattr(payload, 'user', None)
if user is None:
user = platform_entities.User(id=payload.user_id)
else:
user = DiscordEventConverter.user_from_author(user)
return platform_events.MessageReactionEvent(
type='message.reaction',
adapter_name='discord',
message_id=payload.message_id,
user=user,
reaction=str(payload.emoji),
is_add=is_add,
chat_type=platform_entities.ChatType.PRIVATE
if payload.guild_id is None
else platform_entities.ChatType.GROUP,
chat_id=payload.channel_id,
group=platform_entities.UserGroup(id=payload.guild_id) if payload.guild_id is not None else None,
source_platform_object=payload,
)
@staticmethod
def member_join_to_eba(
member: discord.Member,
bot_user_id: int | None,
) -> platform_events.BotInvitedToGroupEvent | platform_events.MemberJoinedEvent:
group = DiscordEventConverter.group_from_guild(member.guild)
user = DiscordEventConverter.user_from_author(member)
if bot_user_id is not None and member.id == bot_user_id:
return platform_events.BotInvitedToGroupEvent(
type='bot.invited_to_group',
adapter_name='discord',
group=group,
inviter=None,
timestamp=member.joined_at.timestamp() if member.joined_at else 0.0,
source_platform_object=member,
)
return platform_events.MemberJoinedEvent(
type='group.member_joined',
adapter_name='discord',
group=group,
member=user,
inviter=None,
join_type='direct',
timestamp=member.joined_at.timestamp() if member.joined_at else 0.0,
source_platform_object=member,
)
@staticmethod
def member_left_to_eba(
member: discord.Member,
bot_user_id: int | None,
) -> platform_events.BotRemovedFromGroupEvent | platform_events.MemberLeftEvent:
group = DiscordEventConverter.group_from_guild(member.guild)
user = DiscordEventConverter.user_from_author(member)
if bot_user_id is not None and member.id == bot_user_id:
return platform_events.BotRemovedFromGroupEvent(
type='bot.removed_from_group',
adapter_name='discord',
group=group,
operator=None,
source_platform_object=member,
)
return platform_events.MemberLeftEvent(
type='group.member_left',
adapter_name='discord',
group=group,
member=user,
is_kicked=False,
operator=None,
source_platform_object=member,
)
@staticmethod
def guild_join_to_eba(guild: discord.Guild) -> platform_events.BotInvitedToGroupEvent:
return platform_events.BotInvitedToGroupEvent(
type='bot.invited_to_group',
adapter_name='discord',
group=DiscordEventConverter.group_from_guild(guild),
inviter=None,
source_platform_object=guild,
)
@staticmethod
def guild_remove_to_eba(guild: discord.Guild) -> platform_events.BotRemovedFromGroupEvent:
return platform_events.BotRemovedFromGroupEvent(
type='bot.removed_from_group',
adapter_name='discord',
group=DiscordEventConverter.group_from_guild(guild),
operator=None,
source_platform_object=guild,
)
@staticmethod
async def target2legacy(message: discord.Message) -> platform_events.FriendMessage | platform_events.GroupMessage:
message_chain = await DiscordMessageConverter.target2yiri(message)
if isinstance(message.channel, discord.DMChannel):
return platform_events.FriendMessage(
sender=platform_entities.Friend(
id=message.author.id,
nickname=message.author.name,
remark=str(message.channel.id),
),
message_chain=message_chain,
time=message.created_at.timestamp(),
source_platform_object=message,
)
return platform_events.GroupMessage(
sender=platform_entities.GroupMember(
id=message.author.id,
member_name=message.author.display_name,
permission=platform_entities.Permission.Member,
group=platform_entities.Group(
id=message.channel.id,
name=message.channel.name,
permission=platform_entities.Permission.Member,
),
special_title='',
),
message_chain=message_chain,
time=message.created_at.timestamp(),
source_platform_object=message,
)
@staticmethod
def user_from_author(author: discord.User | discord.Member) -> platform_entities.User:
return platform_entities.User(
id=author.id,
nickname=getattr(author, 'display_name', None) or author.name,
avatar_url=str(author.display_avatar.url) if getattr(author, 'display_avatar', None) else None,
is_bot=author.bot,
username=author.name,
)
@staticmethod
def group_from_message(message: discord.Message) -> platform_entities.UserGroup | None:
guild = getattr(message, 'guild', None)
if guild is None:
return None
return DiscordEventConverter.group_from_guild(guild)
@staticmethod
def group_from_guild(guild: discord.Guild) -> platform_entities.UserGroup:
return platform_entities.UserGroup(
id=guild.id,
name=guild.name,
member_count=guild.member_count,
avatar_url=str(guild.icon.url) if guild.icon else None,
owner_id=guild.owner_id,
)
@@ -0,0 +1,89 @@
apiVersion: v1
kind: MessagePlatformAdapter
metadata:
name: discord-eba
label:
en_US: Discord (EBA)
zh_Hans: Discord (EBA)
description:
en_US: Discord adapter (EBA architecture)
zh_Hans: Discord 适配器(EBA 架构版本)
icon: discord.svg
spec:
categories:
- popular
- global
config:
- name: client_id
label:
en_US: Client ID
zh_Hans: 客户端 ID
type: string
required: true
default: ""
- name: token
label:
en_US: Token
zh_Hans: 令牌
type: string
required: true
default: ""
supported_events:
- message.received
- message.edited
- message.deleted
- message.reaction
- group.member_joined
- group.member_left
- bot.invited_to_group
- bot.removed_from_group
- platform.specific
supported_apis:
required:
- send_message
- reply_message
optional:
- edit_message
- delete_message
- forward_message
- get_group_info
- get_group_member_list
- get_group_member_info
- get_user_info
- get_file_url
- mute_member
- unmute_member
- kick_member
- leave_group
- call_platform_api
platform_specific_apis:
- action: get_channel
description: { en_US: "Get channel information", zh_Hans: "获取频道信息" }
- action: get_guild
description: { en_US: "Get guild information", zh_Hans: "获取服务器信息" }
- action: get_guild_channels
description: { en_US: "Get guild channels", zh_Hans: "获取服务器频道列表" }
- action: get_guild_roles
description: { en_US: "Get guild roles", zh_Hans: "获取服务器角色列表" }
- action: create_invite
description: { en_US: "Create channel invite", zh_Hans: "创建频道邀请链接" }
- action: pin_message
description: { en_US: "Pin a message", zh_Hans: "置顶消息" }
- action: unpin_message
description: { en_US: "Unpin a message", zh_Hans: "取消置顶消息" }
- action: add_reaction
description: { en_US: "Add a reaction", zh_Hans: "添加表情回应" }
- action: remove_reaction
description: { en_US: "Remove a reaction", zh_Hans: "移除表情回应" }
- action: typing
description: { en_US: "Send typing indicator", zh_Hans: "发送正在输入状态" }
execution:
python:
path: ./adapter.py
attr: DiscordAdapter
@@ -0,0 +1,162 @@
from __future__ import annotations
import base64
import datetime
import io
import os
import re
import uuid
import discord
from langbot.pkg.utils import httpclient
from langbot_plugin.api.entities.builtin.platform import message as platform_message
class DiscordMessageConverter:
@staticmethod
async def yiri2target(
message_chain: platform_message.MessageChain,
) -> tuple[str, list[discord.File]]:
text_parts: list[str] = []
files: list[discord.File] = []
for element in list(message_chain):
if isinstance(element, platform_message.At):
text_parts.append(f'<@{element.target}>')
elif isinstance(element, platform_message.AtAll):
text_parts.append('@everyone')
elif isinstance(element, platform_message.Plain):
text_parts.append(element.text)
elif isinstance(element, platform_message.Image):
file_bytes, filename = await DiscordMessageConverter._load_image(element)
if file_bytes:
files.append(discord.File(fp=io.BytesIO(file_bytes), filename=filename))
elif isinstance(element, platform_message.Voice):
file_bytes, filename = await DiscordMessageConverter._load_voice(element)
if file_bytes:
files.append(discord.File(fp=io.BytesIO(file_bytes), filename=filename))
elif isinstance(element, platform_message.File):
file_bytes = await DiscordMessageConverter._load_file(element)
if file_bytes:
filename = element.name or f'{uuid.uuid4()}.bin'
files.append(discord.File(fp=io.BytesIO(file_bytes), filename=filename))
elif isinstance(element, platform_message.Forward):
for node in element.node_list:
node_text, node_files = await DiscordMessageConverter.yiri2target(node.message_chain)
text_parts.append(node_text)
files.extend(node_files)
return ''.join(text_parts), files
@staticmethod
async def target2yiri(message: discord.Message) -> platform_message.MessageChain:
message_time = datetime.datetime.fromtimestamp(int(message.created_at.timestamp()))
elements: list[platform_message.MessageComponent] = [platform_message.Source(id=message.id, time=message_time)]
elements.extend(DiscordMessageConverter._text_components(message.content))
for attachment in message.attachments:
if DiscordMessageConverter._is_image_attachment(attachment):
elements.append(platform_message.Image(url=attachment.url))
else:
elements.append(
platform_message.File(
name=attachment.filename,
size=attachment.size or 0,
url=attachment.url,
)
)
return platform_message.MessageChain(elements)
@staticmethod
def _text_components(text: str) -> list[platform_message.MessageComponent]:
if not text:
return []
pattern = re.compile(r'(@everyone|@here|<@!?(\d+)>)')
components: list[platform_message.MessageComponent] = []
last = 0
for match in pattern.finditer(text):
if match.start() > last:
components.append(platform_message.Plain(text=text[last : match.start()]))
if match.group(1) in ('@everyone', '@here'):
components.append(platform_message.AtAll())
else:
components.append(platform_message.At(target=match.group(2)))
last = match.end()
if last < len(text):
components.append(platform_message.Plain(text=text[last:]))
return components
@staticmethod
async def _load_image(element: platform_message.Image) -> tuple[bytes | None, str]:
filename = f'{uuid.uuid4()}.png'
if element.base64:
header, _, payload = element.base64.partition(',')
data = payload or header
if 'jpeg' in header or 'jpg' in header:
filename = f'{uuid.uuid4()}.jpg'
elif 'gif' in header:
filename = f'{uuid.uuid4()}.gif'
elif 'webp' in header:
filename = f'{uuid.uuid4()}.webp'
return base64.b64decode(data), filename
if element.url:
data, content_type = await DiscordMessageConverter._download(element.url)
if 'jpeg' in content_type or 'jpg' in content_type:
filename = f'{uuid.uuid4()}.jpg'
elif 'gif' in content_type:
filename = f'{uuid.uuid4()}.gif'
elif 'webp' in content_type:
filename = f'{uuid.uuid4()}.webp'
return data, filename
if element.path:
path = os.path.abspath(element.path.replace('\x00', ''))
if not os.path.exists(path):
return None, filename
with open(path, 'rb') as fp:
data = fp.read()
ext = os.path.splitext(path)[1]
if ext:
filename = f'{uuid.uuid4()}{ext}'
return data, filename
return None, filename
@staticmethod
async def _load_voice(element: platform_message.Voice) -> tuple[bytes | None, str]:
filename = f'{uuid.uuid4()}.mp3'
if element.base64:
header, _, payload = element.base64.partition(',')
data = payload or header
for ext in ('wav', 'mp3', 'ogg', 'm4a', 'aac', 'flac', 'opus', 'webm'):
if ext in header:
filename = f'{uuid.uuid4()}.{ext}'
break
return base64.b64decode(data), filename
if element.url:
data, _ = await DiscordMessageConverter._download(element.url)
return data, filename
return None, filename
@staticmethod
async def _load_file(element: platform_message.File) -> bytes | None:
if element.base64:
return base64.b64decode(element.base64.split(',')[-1])
if element.url:
data, _ = await DiscordMessageConverter._download(element.url)
return data
return None
@staticmethod
async def _download(url: str) -> tuple[bytes, str]:
session = httpclient.get_session(trust_env=True)
async with session.get(url) as response:
return await response.read(), response.headers.get('Content-Type', '')
@staticmethod
def _is_image_attachment(attachment: discord.Attachment) -> bool:
content_type = attachment.content_type or ''
return content_type.startswith('image/') or attachment.filename.lower().endswith(
('.png', '.jpg', '.jpeg', '.gif', '.webp')
)
@@ -0,0 +1,95 @@
from __future__ import annotations
import typing
import discord
async def get_channel(bot: discord.Client, params: dict) -> dict:
channel = bot.get_channel(int(params['channel_id'])) or await bot.fetch_channel(int(params['channel_id']))
return {
'id': channel.id,
'name': getattr(channel, 'name', ''),
'type': str(channel.type),
'guild_id': getattr(getattr(channel, 'guild', None), 'id', None),
}
async def get_guild(bot: discord.Client, params: dict) -> dict:
guild = bot.get_guild(int(params['guild_id'])) or await bot.fetch_guild(int(params['guild_id']))
return {'id': guild.id, 'name': guild.name, 'member_count': guild.member_count, 'owner_id': guild.owner_id}
async def get_guild_channels(bot: discord.Client, params: dict) -> dict:
guild = bot.get_guild(int(params['guild_id'])) or await bot.fetch_guild(int(params['guild_id']))
channels = guild.channels or await guild.fetch_channels()
return {'channels': [{'id': channel.id, 'name': channel.name, 'type': str(channel.type)} for channel in channels]}
async def get_guild_roles(bot: discord.Client, params: dict) -> dict:
guild = bot.get_guild(int(params['guild_id'])) or await bot.fetch_guild(int(params['guild_id']))
return {'roles': [{'id': role.id, 'name': role.name, 'position': role.position} for role in guild.roles]}
async def create_invite(bot: discord.Client, params: dict) -> dict:
channel = bot.get_channel(int(params['channel_id'])) or await bot.fetch_channel(int(params['channel_id']))
invite = await channel.create_invite(
max_age=params.get('max_age', 0),
max_uses=params.get('max_uses', 0),
unique=params.get('unique', True),
reason=params.get('reason', 'LangBot EBA create_invite'),
)
return {'url': invite.url, 'code': invite.code}
async def pin_message(bot: discord.Client, params: dict) -> dict:
channel = bot.get_channel(int(params['channel_id'])) or await bot.fetch_channel(int(params['channel_id']))
message = await channel.fetch_message(int(params['message_id']))
await message.pin(reason=params.get('reason', 'LangBot EBA pin_message'))
return {'ok': True}
async def unpin_message(bot: discord.Client, params: dict) -> dict:
channel = bot.get_channel(int(params['channel_id'])) or await bot.fetch_channel(int(params['channel_id']))
message = await channel.fetch_message(int(params['message_id']))
await message.unpin(reason=params.get('reason', 'LangBot EBA unpin_message'))
return {'ok': True}
async def add_reaction(bot: discord.Client, params: dict) -> dict:
channel = bot.get_channel(int(params['channel_id'])) or await bot.fetch_channel(int(params['channel_id']))
message = await channel.fetch_message(int(params['message_id']))
await message.add_reaction(params['emoji'])
return {'ok': True}
async def remove_reaction(bot: discord.Client, params: dict) -> dict:
channel = bot.get_channel(int(params['channel_id'])) or await bot.fetch_channel(int(params['channel_id']))
message = await channel.fetch_message(int(params['message_id']))
user = (
bot.user
if 'user_id' not in params
else bot.get_user(int(params['user_id'])) or await bot.fetch_user(int(params['user_id']))
)
await message.remove_reaction(params['emoji'], user)
return {'ok': True}
async def send_typing(bot: discord.Client, params: dict) -> dict:
channel = bot.get_channel(int(params['channel_id'])) or await bot.fetch_channel(int(params['channel_id']))
async with channel.typing():
return {'ok': True}
PLATFORM_API_MAP: dict[str, typing.Callable[[discord.Client, dict], typing.Awaitable[dict]]] = {
'get_channel': get_channel,
'get_guild': get_guild,
'get_guild_channels': get_guild_channels,
'get_guild_roles': get_guild_roles,
'create_invite': create_invite,
'pin_message': pin_message,
'unpin_message': unpin_message,
'add_reaction': add_reaction,
'remove_reaction': remove_reaction,
'typing': send_typing,
}
@@ -0,0 +1,12 @@
from __future__ import annotations
import typing
import pydantic
class DiscordAdapterConfig(pydantic.BaseModel):
client_id: str
token: str
guild_id: typing.Optional[str] = None
debug_channel_id: typing.Optional[str] = None
@@ -0,0 +1,5 @@
from __future__ import annotations
# Voice support is still implemented by the legacy Discord source adapter. The
# EBA adapter exposes text, guild, member, moderation, and platform-specific APIs
# first; voice-specific EBA actions will move here when that surface is migrated.
@@ -0,0 +1,5 @@
from __future__ import annotations
from langbot.pkg.platform.adapters.kook.adapter import KookAdapter
__all__ = ['KookAdapter']
@@ -0,0 +1,318 @@
from __future__ import annotations
import asyncio
import json
import traceback
import typing
import zlib
import aiohttp
import pydantic
import websockets
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
from langbot.pkg.platform.adapters.kook.api_impl import KookAPIMixin
from langbot.pkg.platform.adapters.kook.event_converter import KookEventConverter
from langbot.pkg.platform.adapters.kook.message_converter import KookMessageConverter
from langbot.pkg.platform.adapters.kook.platform_api import PLATFORM_API_MAP
from langbot.pkg.platform.adapters.kook.errors import NotSupportedError
from langbot.pkg.utils import httpclient
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
BasePlatformAdapter = getattr(
abstract_platform_adapter,
'AbstractPlatformAdapter',
abstract_platform_adapter.AbstractMessagePlatformAdapter,
)
class KookAdapter(KookAPIMixin, BasePlatformAdapter):
message_converter: KookMessageConverter = KookMessageConverter()
event_converter: KookEventConverter = KookEventConverter()
config: dict
listeners: dict[
typing.Type[platform_events.Event],
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
] = {}
ws: typing.Any = pydantic.Field(exclude=True, default=None)
ws_task: typing.Optional[asyncio.Task] = pydantic.Field(exclude=True, default=None)
heartbeat_task: typing.Optional[asyncio.Task] = pydantic.Field(exclude=True, default=None)
running: bool = pydantic.Field(exclude=True, default=False)
session_id: str = pydantic.Field(exclude=True, default='')
current_sn: int = pydantic.Field(exclude=True, default=0)
gateway_url: str = pydantic.Field(exclude=True, default='')
http_session: typing.Optional[aiohttp.ClientSession] = pydantic.Field(exclude=True, default=None)
_message_cache: dict[str, platform_events.MessageReceivedEvent] = {}
_user_cache: dict[str, platform_entities.User] = {}
_group_cache: dict[str, platform_entities.UserGroup] = {}
class Config:
arbitrary_types_allowed = True
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
if not config.get('token'):
raise Exception('KOOK adapter requires "token" in config')
super().__init__(
config=config,
logger=logger,
bot_account_id='',
listeners={},
running=False,
session_id='',
current_sn=0,
gateway_url='',
http_session=None,
_message_cache={},
_user_cache={},
_group_cache={},
**kwargs,
)
def get_supported_events(self) -> list[str]:
return [
'message.received',
'platform.specific',
]
def get_supported_apis(self) -> list[str]:
return [
'send_message',
'reply_message',
'get_message',
'get_group_info',
'get_group_list',
'get_group_member_info',
'get_user_info',
'get_friend_list',
'upload_file',
'get_file_url',
'delete_message',
'forward_message',
'call_platform_api',
]
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
handler = PLATFORM_API_MAP.get(action)
if handler is None:
raise NotSupportedError(f'call_platform_api:{action}')
return await handler(self, params)
def register_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable[
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter],
None,
],
):
self.listeners[event_type] = callback
def unregister_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable[
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter],
None,
],
):
registered = self.listeners.get(event_type)
if registered is callback:
self.listeners.pop(event_type, None)
async def run_async(self):
self.running = True
self.http_session = httpclient.get_session()
await self.logger.info('KOOK EBA adapter starting')
try:
bot_info = await self._get_bot_user_info()
self.bot_account_id = str(bot_info.get('id') or '')
except Exception as e:
await self.logger.error(f'Failed to get KOOK bot user info: {e}')
self.ws_task = asyncio.create_task(self._websocket_loop())
try:
await self.ws_task
finally:
self.running = False
async def kill(self) -> bool:
self.running = False
for task in (self.heartbeat_task, self.ws_task):
if task:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
if self.ws:
await self.ws.close()
await self.logger.info('KOOK EBA adapter stopped')
return True
async def is_muted(self, group_id: int | None = None) -> bool:
return False
async def _handle_hello(self, data: dict):
self.session_id = str(data.get('session_id') or '')
await self.logger.info(f'KOOK WebSocket HELLO received, session_id: {self.session_id}')
async def _handle_event(self, data: dict, sn: int):
self.current_sn = max(self.current_sn, sn)
event_type = int(data.get('type', 0) or 0)
channel_type = data.get('channel_type')
author_id = str(data.get('author_id') or '')
is_message_event = event_type in KookEventConverter.MESSAGE_TYPES and channel_type in {'GROUP', 'PERSON'}
if is_message_event and self.bot_account_id and author_id == self.bot_account_id:
return
try:
if is_message_event and (
platform_events.FriendMessage in self.listeners or platform_events.GroupMessage in self.listeners
):
legacy_event = await self.event_converter.target2legacy(data, self.bot_account_id)
callback = self.listeners.get(type(legacy_event))
if callback:
await callback(legacy_event, self)
eba_event = await self.event_converter.target2yiri(data, self.bot_account_id)
if eba_event:
self._cache_event(eba_event)
await self._dispatch_eba_event(eba_event)
except Exception:
await self.logger.error(f'Error handling KOOK event: {traceback.format_exc()}')
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
callback = self.listeners.get(event_type)
if callback:
await callback(event, self)
return
def _cache_event(self, event: platform_events.Event):
if not isinstance(event, platform_events.MessageReceivedEvent):
return
self._message_cache[str(event.message_id)] = event
self._user_cache[str(event.sender.id)] = event.sender
if event.group:
self._group_cache[str(event.group.id)] = event.group
async def _websocket_loop(self):
retry_count = 0
max_retries = int(self.config.get('max_retries', 3))
while self.running and retry_count < max_retries:
try:
if not self.gateway_url:
self.gateway_url = await self._get_gateway_url()
async with websockets.connect(self.gateway_url) as ws:
self.ws = ws
await self.logger.info('Connected to KOOK WebSocket')
self.heartbeat_task = asyncio.create_task(self._heartbeat_loop())
hello_msg = await asyncio.wait_for(ws.recv(), timeout=6.0)
hello_data = json.loads(self._decode_ws_message(hello_msg))
if hello_data.get('s') != 1:
raise Exception(f'Expected KOOK HELLO signal, got {hello_data.get("s")}')
await self._handle_hello(hello_data.get('d') or {})
retry_count = 0
async for message in ws:
msg_data = json.loads(self._decode_ws_message(message))
signal = msg_data.get('s')
if signal == 0:
await self._handle_event(msg_data.get('d') or {}, int(msg_data.get('sn') or 0))
elif signal == 5:
break
except websockets.exceptions.ConnectionClosed:
retry_count += 1
await self.logger.warning('KOOK WebSocket connection closed, reconnecting')
await asyncio.sleep(min(2**retry_count, 30))
except asyncio.CancelledError:
raise
except Exception:
retry_count += 1
await self.logger.error(f'KOOK WebSocket error: {traceback.format_exc()}')
await asyncio.sleep(min(2**retry_count, 30))
finally:
if self.heartbeat_task:
self.heartbeat_task.cancel()
try:
await self.heartbeat_task
except asyncio.CancelledError:
pass
self.ws = None
if retry_count >= max_retries:
await self.logger.error(f'Failed to connect to KOOK after {max_retries} retries')
async def _heartbeat_loop(self):
try:
while self.running and self.ws:
await asyncio.sleep(30)
if self.ws:
await self.ws.send(json.dumps({'s': 2, 'sn': self.current_sn}))
except asyncio.CancelledError:
pass
except Exception as e:
await self.logger.error(f'KOOK heartbeat error: {e}')
async def _get_gateway_url(self) -> str:
raw = await self._request('GET', '/gateway/index', params={'compress': 1})
return str(raw['data']['url'])
async def _get_bot_user_info(self) -> dict:
raw = await self._request('GET', '/user/me')
return raw.get('data') or {}
async def _request(
self,
method: str,
endpoint: str,
*,
params: dict | None = None,
json: dict | None = None,
data: dict | None = None,
filename: str | None = None,
) -> dict:
session = self.http_session or httpclient.get_session()
self.http_session = session
url = f'https://www.kookapp.cn/api/v3{endpoint}'
headers = {'Authorization': f'Bot {self.config["token"]}'}
request_kwargs: dict[str, typing.Any] = {'params': params, 'headers': headers}
if json is not None:
request_kwargs['json'] = json
if data is not None and filename is not None:
form = aiohttp.FormData()
form.add_field('file', data['file'], filename=filename)
request_kwargs['data'] = form
elif data is not None:
request_kwargs['data'] = data
async with session.request(method, url, **request_kwargs) as response:
payload = await response.json(content_type=None)
if response.status != 200:
raise Exception(f'KOOK API HTTP {response.status}: {payload}')
if payload.get('code') != 0:
raise Exception(f'KOOK API error {payload.get("code")}: {payload.get("message")}')
return payload
@staticmethod
def _decode_ws_message(message) -> str:
if isinstance(message, bytes):
try:
return zlib.decompress(message).decode('utf-8')
except Exception:
return message.decode('utf-8')
return str(message)
@@ -0,0 +1,211 @@
from __future__ import annotations
import typing
from langbot.pkg.platform.adapters.kook.message_converter import KookMessageConverter
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
from langbot_plugin.api.entities.builtin.platform import message as platform_message
from langbot.pkg.platform.adapters.kook.errors import NotSupportedError
class KookAPIMixin:
_message_cache: dict[str, platform_events.MessageReceivedEvent]
_user_cache: dict[str, platform_entities.User]
_group_cache: dict[str, platform_entities.UserGroup]
async def send_message(
self,
target_type: str,
target_id: str,
message: platform_message.MessageChain,
) -> platform_events.MessageResult:
content, msg_type = await KookMessageConverter.yiri2target(message)
endpoint = '/message/create' if target_type.lower() in {'group', 'channel'} else '/direct-message/create'
raw = await self._request(
'POST',
endpoint,
json={
'target_id': str(target_id),
'content': content,
'type': msg_type,
},
)
data = raw.get('data') or {}
return platform_events.MessageResult(message_id=data.get('msg_id'), raw=raw)
async def reply_message(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
quote_origin: bool = False,
) -> platform_events.MessageResult:
content, msg_type = await KookMessageConverter.yiri2target(message)
kook_event = message_source.source_platform_object or {}
channel_type = kook_event.get('channel_type')
msg_id = kook_event.get('msg_id')
if channel_type == 'GROUP':
endpoint = '/message/create'
payload = {
'target_id': str(kook_event.get('target_id') or message_source.chat_id),
'content': content,
'type': msg_type,
}
else:
endpoint = '/direct-message/create'
extra = kook_event.get('extra') or {}
payload = {
'content': content,
'type': msg_type,
}
if extra.get('code'):
payload['chat_code'] = extra['code']
else:
payload['target_id'] = str(kook_event.get('author_id') or message_source.chat_id)
if msg_id:
payload['reply_msg_id'] = msg_id
if quote_origin:
payload['quote'] = msg_id
raw = await self._request('POST', endpoint, json=payload)
data = raw.get('data') or {}
return platform_events.MessageResult(message_id=data.get('msg_id'), raw=raw)
async def get_message(
self,
chat_type: str,
chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
) -> platform_events.MessageReceivedEvent:
event = self._message_cache.get(str(message_id))
if event is None:
raise NotSupportedError('get_message:message_not_cached')
return event
async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup:
cached = self._group_cache.get(str(group_id))
if cached:
return cached
raw = await self._request('GET', '/channel/view', params={'target_id': str(group_id)})
data = raw.get('data') or {}
return platform_entities.UserGroup(
id=str(data.get('id') or group_id),
name=str(data.get('name') or ''),
member_count=data.get('user_count'),
)
async def get_group_list(self) -> list[platform_entities.UserGroup]:
return list(self._group_cache.values())
async def get_group_member_list(
self,
group_id: typing.Union[int, str],
) -> list[platform_entities.UserGroupMember]:
raise NotSupportedError('get_group_member_list')
async def get_group_member_info(
self,
group_id: typing.Union[int, str],
user_id: typing.Union[int, str],
) -> platform_entities.UserGroupMember:
user = self._user_cache.get(str(user_id))
if user is None:
raw = await self._request('GET', '/user/view', params={'user_id': str(user_id)})
data = raw.get('data') or {}
user = platform_entities.User(
id=str(data.get('id') or user_id),
nickname=str(data.get('nickname') or data.get('username') or ''),
username=data.get('username'),
avatar_url=data.get('avatar'),
is_bot=bool(data.get('bot', False)),
)
return platform_entities.UserGroupMember(
user=user,
group_id=str(group_id),
role=platform_entities.MemberRole.MEMBER,
display_name=user.nickname,
)
async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User:
cached = self._user_cache.get(str(user_id))
if cached:
return cached
raw = await self._request('GET', '/user/view', params={'user_id': str(user_id)})
data = raw.get('data') or {}
return platform_entities.User(
id=str(data.get('id') or user_id),
nickname=str(data.get('nickname') or data.get('username') or ''),
username=data.get('username'),
avatar_url=data.get('avatar'),
is_bot=bool(data.get('bot', False)),
)
async def get_friend_list(self) -> list[platform_entities.User]:
return list(self._user_cache.values())
async def upload_file(self, file_data: bytes, filename: str) -> str:
data = {'file': file_data}
raw = await self._request('POST', '/asset/create', data=data, filename=filename)
result = raw.get('data') or {}
return str(result.get('url') or result.get('id') or '')
async def get_file_url(self, file_id: str) -> str:
return file_id
async def edit_message(
self,
chat_type: str,
chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
new_content: platform_message.MessageChain,
) -> None:
raise NotSupportedError('edit_message')
async def delete_message(
self,
chat_type: str,
chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
) -> None:
endpoint = '/message/delete' if str(chat_type).lower() in {'group', 'channel'} else '/direct-message/delete'
await self._request('POST', endpoint, json={'msg_id': str(message_id)})
async def forward_message(
self,
from_chat_type: str,
from_chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
to_chat_type: str,
to_chat_id: typing.Union[int, str],
) -> platform_events.MessageResult:
cached = self._message_cache.get(str(message_id))
if cached is None:
raise NotSupportedError('forward_message:message_not_cached')
return await self.send_message(to_chat_type, str(to_chat_id), cached.message_chain)
async def mute_member(
self,
group_id: typing.Union[int, str],
user_id: typing.Union[int, str],
duration: int = 0,
) -> None:
raise NotSupportedError('mute_member')
async def unmute_member(
self,
group_id: typing.Union[int, str],
user_id: typing.Union[int, str],
) -> None:
raise NotSupportedError('unmute_member')
async def kick_member(
self,
group_id: typing.Union[int, str],
user_id: typing.Union[int, str],
) -> None:
raise NotSupportedError('kick_member')
async def leave_group(self, group_id: typing.Union[int, str]) -> None:
raise NotSupportedError('leave_group')
@@ -0,0 +1,13 @@
from __future__ import annotations
try:
from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError
except ModuleNotFoundError:
class NotSupportedError(Exception):
def __init__(self, api_name: str, *args):
super().__init__(f"API '{api_name}' is not supported by this adapter", *args)
self.api_name = api_name
__all__ = ['NotSupportedError']
@@ -0,0 +1,111 @@
from __future__ import annotations
import time
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
from langbot.pkg.platform.adapters.kook.message_converter import KookMessageConverter
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
class KookEventConverter(abstract_platform_adapter.AbstractEventConverter):
MESSAGE_TYPES = {1, 2, 4, 8, 9, 10}
@staticmethod
async def yiri2target(event: platform_events.Event):
raise NotImplementedError
@staticmethod
async def target2yiri(kook_event: dict, bot_account_id: str = '') -> platform_events.Event | None:
event_type = int(kook_event.get('type', 0) or 0)
channel_type = kook_event.get('channel_type')
if event_type in KookEventConverter.MESSAGE_TYPES and channel_type in {'GROUP', 'PERSON'}:
return await KookEventConverter.message_to_eba(kook_event, bot_account_id)
return platform_events.PlatformSpecificEvent(
type='platform.specific',
adapter_name='kook',
action=str(kook_event.get('type') or 'gateway_event'),
data=KookEventConverter._compact_data(kook_event),
timestamp=KookEventConverter._timestamp(kook_event),
source_platform_object=kook_event,
)
@staticmethod
async def message_to_eba(kook_event: dict, bot_account_id: str = '') -> platform_events.MessageReceivedEvent:
channel_type = kook_event.get('channel_type')
author = KookEventConverter._author(kook_event)
chat_type = platform_entities.ChatType.PRIVATE if channel_type == 'PERSON' else platform_entities.ChatType.GROUP
chat_id = KookEventConverter._chat_id(kook_event)
group = None
if chat_type == platform_entities.ChatType.GROUP:
group = KookEventConverter._group(kook_event)
return platform_events.MessageReceivedEvent(
type='message.received',
adapter_name='kook',
message_id=str(kook_event.get('msg_id') or ''),
message_chain=await KookMessageConverter.target2yiri(kook_event, bot_account_id),
sender=author,
chat_type=chat_type,
chat_id=chat_id,
group=group,
timestamp=KookEventConverter._timestamp(kook_event),
source_platform_object=kook_event,
)
@staticmethod
async def target2legacy(
kook_event: dict, bot_account_id: str = ''
) -> platform_events.FriendMessage | platform_events.GroupMessage:
eba_event = await KookEventConverter.message_to_eba(kook_event, bot_account_id)
return eba_event.to_legacy_event()
@staticmethod
def _author(kook_event: dict) -> platform_entities.User:
extra = kook_event.get('extra') or {}
author = extra.get('author') or {}
user_id = str(kook_event.get('author_id') or author.get('id') or '')
return platform_entities.User(
id=user_id,
nickname=str(author.get('nickname') or author.get('username') or user_id),
username=author.get('username'),
avatar_url=author.get('avatar'),
is_bot=bool(author.get('bot', False)),
remark=user_id,
)
@staticmethod
def _group(kook_event: dict) -> platform_entities.UserGroup:
extra = kook_event.get('extra') or {}
return platform_entities.UserGroup(
id=str(kook_event.get('target_id') or ''),
name=str(extra.get('channel_name') or kook_event.get('target_id') or ''),
description=extra.get('guild_name'),
owner_id=extra.get('guild_id'),
)
@staticmethod
def _chat_id(kook_event: dict) -> str:
if kook_event.get('channel_type') == 'PERSON':
extra = kook_event.get('extra') or {}
return str(extra.get('code') or kook_event.get('author_id') or kook_event.get('target_id') or '')
return str(kook_event.get('target_id') or '')
@staticmethod
def _timestamp(kook_event: dict) -> float:
raw_timestamp = kook_event.get('msg_timestamp') or time.time()
timestamp = float(raw_timestamp)
if timestamp > 10_000_000_000:
timestamp = timestamp / 1000.0
return timestamp
@staticmethod
def _compact_data(kook_event: dict) -> dict:
return {
'type': kook_event.get('type'),
'channel_type': kook_event.get('channel_type'),
'target_id': kook_event.get('target_id'),
'author_id': kook_event.get('author_id'),
'msg_id': kook_event.get('msg_id'),
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

@@ -0,0 +1,79 @@
apiVersion: v1
kind: MessagePlatformAdapter
metadata:
name: kook-eba
label:
en_US: KOOK (EBA)
zh_Hans: KOOK (EBA)
zh_Hant: KOOK (EBA)
description:
en_US: KOOK adapter (EBA architecture), supporting channel and direct messages.
zh_Hans: KOOK 适配器(EBA 架构版本),支持频道消息和私聊消息。
zh_Hant: KOOK 適配器(EBA 架構版本),支援頻道訊息和私聊訊息。
icon: kook.png
docs:
zh: https://link.langbot.app/zh/platforms/kook
en: https://link.langbot.app/en/platforms/kook
ja: https://link.langbot.app/ja/platforms/kook
spec:
categories:
- global
config:
- name: token
label:
en_US: Bot Token
zh_Hans: Bot Token
zh_Hant: Bot Token
type: string
required: true
default: ""
- name: enable-stream-reply
label:
en_US: Enable stream reply
zh_Hans: 启用流式回复
zh_Hant: 啟用串流回覆
type: boolean
required: true
default: false
supported_events:
- message.received
- platform.specific
supported_apis:
required:
- send_message
- reply_message
optional:
- get_message
- get_group_info
- get_group_list
- get_group_member_info
- get_user_info
- get_friend_list
- upload_file
- get_file_url
- delete_message
- forward_message
- call_platform_api
platform_specific_apis:
- action: get_current_user
description: { en_US: "Get current bot user", zh_Hans: "获取当前机器人用户" }
- action: get_user
description: { en_US: "Get user information", zh_Hans: "获取用户信息" }
- action: get_channel
description: { en_US: "Get channel information", zh_Hans: "获取频道信息" }
- action: get_guild
description: { en_US: "Get guild information", zh_Hans: "获取服务器信息" }
- action: get_gateway
description: { en_US: "Get WebSocket gateway URL", zh_Hans: "获取 WebSocket 网关地址" }
- action: send_direct_message
description: { en_US: "Send a direct KOOK message", zh_Hans: "发送 KOOK 私聊消息" }
execution:
python:
path: ./adapter.py
attr: KookAdapter
@@ -0,0 +1,139 @@
from __future__ import annotations
import datetime
import re
from langbot_plugin.api.entities.builtin.platform import message as platform_message
MENTION_PATTERN = re.compile(r'(\(met\)(?P<met>[^()]+)\(met\)|\(rol\)(?P<role>[^()]+)\(rol\))')
class KookMessageConverter:
@staticmethod
async def yiri2target(message_chain: platform_message.MessageChain) -> tuple[str, int]:
content_parts: list[str] = []
message_type = 1
for component in message_chain:
if isinstance(component, platform_message.Source):
continue
if isinstance(component, platform_message.Plain):
content_parts.append(component.text)
elif isinstance(component, platform_message.At):
if component.target:
content_parts.append(f'(met){component.target}(met)')
elif isinstance(component, platform_message.AtAll):
content_parts.append('(met)all(met)')
elif isinstance(component, platform_message.Image):
if component.url:
content_parts.append(component.url)
message_type = 2
elif component.image_id:
content_parts.append(component.image_id)
message_type = 2
elif isinstance(component, platform_message.File):
if component.url:
content_parts.append(component.url)
message_type = 4
elif isinstance(component, platform_message.Voice):
if component.url:
content_parts.append(component.url)
message_type = 8
elif isinstance(component, platform_message.Forward):
for node in component.node_list:
if node.message_chain:
forward_content, _ = await KookMessageConverter.yiri2target(node.message_chain)
content_parts.append(forward_content)
return ''.join(content_parts), message_type
@staticmethod
async def target2yiri(kook_message: dict, bot_account_id: str = '') -> platform_message.MessageChain:
components: list[platform_message.MessageComponent] = []
msg_id = kook_message.get('msg_id') or kook_message.get('id') or ''
timestamp = KookMessageConverter._timestamp(kook_message.get('msg_timestamp'))
if msg_id:
components.append(platform_message.Source(id=str(msg_id), time=timestamp))
msg_type = int(kook_message.get('type', 1) or 1)
content = str(kook_message.get('content') or '')
extra = kook_message.get('extra') or {}
if msg_type in (1, 9):
components.extend(KookMessageConverter._parse_text_components(content, extra, bot_account_id))
elif msg_type == 2:
if content:
components.append(platform_message.Image(url=content))
elif msg_type == 4:
attachments = extra.get('attachments') or {}
components.append(
platform_message.File(
id=str(attachments.get('id') or ''),
name=str(attachments.get('name') or 'file'),
size=int(attachments.get('size') or 0),
url=content,
)
)
elif msg_type == 8:
attachments = extra.get('attachments') or {}
components.append(platform_message.Voice(url=content, length=int(attachments.get('duration') or 0)))
elif msg_type == 10:
components.append(platform_message.Unknown(text=content or '[KOOK card message]'))
else:
components.append(platform_message.Unknown(text=content or f'Unsupported KOOK message type: {msg_type}'))
if len(components) == 1 and isinstance(components[0], platform_message.Source):
components.append(platform_message.Plain(text=''))
return platform_message.MessageChain(components)
@staticmethod
def _parse_text_components(
content: str,
extra: dict,
bot_account_id: str,
) -> list[platform_message.MessageComponent]:
components: list[platform_message.MessageComponent] = []
mention_all = bool(extra.get('mention_all', False))
mentions = {str(item) for item in extra.get('mention', [])}
mention_roles = {str(item) for item in extra.get('mention_roles', [])}
last = 0
for match in MENTION_PATTERN.finditer(content):
if match.start() > last:
components.append(platform_message.Plain(text=content[last : match.start()]))
met = match.group('met')
role = match.group('role')
if met == 'all':
components.append(platform_message.AtAll())
elif met:
components.append(platform_message.At(target=met))
mentions.discard(str(met))
elif role:
mention_roles.discard(str(role))
if bot_account_id:
components.append(platform_message.At(target=bot_account_id))
last = match.end()
if last < len(content):
components.append(platform_message.Plain(text=content[last:]))
if mention_all and not any(isinstance(item, platform_message.AtAll) for item in components):
components.insert(0, platform_message.AtAll())
for mention_id in sorted(mentions):
components.insert(0, platform_message.At(target=mention_id))
if mention_roles and bot_account_id:
components.insert(0, platform_message.At(target=bot_account_id))
return components
@staticmethod
def _timestamp(raw_timestamp) -> datetime.datetime:
if raw_timestamp is None:
return datetime.datetime.now()
timestamp = float(raw_timestamp)
if timestamp > 10_000_000_000:
timestamp = timestamp / 1000.0
return datetime.datetime.fromtimestamp(timestamp)
@@ -0,0 +1,60 @@
from __future__ import annotations
import typing
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
async def get_current_user(adapter, params: dict) -> dict:
return await adapter._request('GET', '/user/me')
async def get_user(adapter, params: dict) -> dict:
return await adapter._request('GET', '/user/view', params={'user_id': params['user_id']})
async def get_channel(adapter, params: dict) -> dict:
return await adapter._request('GET', '/channel/view', params={'target_id': params['target_id']})
async def get_guild(adapter, params: dict) -> dict:
return await adapter._request('GET', '/guild/view', params={'guild_id': params['guild_id']})
async def get_gateway(adapter, params: dict) -> dict:
raw = await adapter._request('GET', '/gateway/index', params={'compress': int(params.get('compress', 1))})
data = raw.get('data')
if isinstance(data, dict) and data.get('url'):
data = {**data, 'url': _redact_url_token(str(data['url']))}
raw = {**raw, 'data': data}
return raw
async def send_direct_message(adapter, params: dict) -> dict:
payload = {
'content': params['content'],
'type': params.get('type', 1),
}
if params.get('chat_code'):
payload['chat_code'] = params['chat_code']
else:
payload['target_id'] = params['target_id']
return await adapter._request('POST', '/direct-message/create', json=payload)
PLATFORM_API_MAP: dict[str, typing.Callable[[typing.Any, dict], typing.Awaitable[dict]]] = {
'get_current_user': get_current_user,
'get_user': get_user,
'get_channel': get_channel,
'get_guild': get_guild,
'get_gateway': get_gateway,
'send_direct_message': send_direct_message,
}
def _redact_url_token(url: str) -> str:
parts = urlsplit(url)
query = urlencode(
[(key, '<redacted>' if key.lower() == 'token' else value) for key, value in parse_qsl(parts.query)],
doseq=True,
)
return urlunsplit((parts.scheme, parts.netloc, parts.path, query, parts.fragment))
@@ -0,0 +1,17 @@
from __future__ import annotations
from enum import Enum
class KookChannelType(str, Enum):
GROUP = 'GROUP'
PERSON = 'PERSON'
class KookMessageType(int, Enum):
TEXT = 1
IMAGE = 2
FILE = 4
AUDIO = 8
KMARKDOWN = 9
CARD = 10
@@ -0,0 +1 @@
"""Lark/Feishu EBA platform adapter."""
@@ -0,0 +1,680 @@
from __future__ import annotations
import asyncio
import base64
import hashlib
import json
import time
import traceback
import typing
import uuid
from Crypto.Cipher import AES
import lark_oapi
from lark_oapi.api.auth.v3 import (
CreateAppAccessTokenRequest,
CreateAppAccessTokenRequestBody,
CreateAppAccessTokenResponse,
CreateTenantAccessTokenRequest,
CreateTenantAccessTokenRequestBody,
CreateTenantAccessTokenResponse,
ResendAppTicketRequest,
ResendAppTicketRequestBody,
ResendAppTicketResponse,
)
from lark_oapi.api.cardkit.v1 import (
ContentCardElementRequest,
ContentCardElementRequestBody,
ContentCardElementResponse,
CreateCardRequest,
CreateCardRequestBody,
CreateCardResponse,
)
from lark_oapi.api.im.v1 import (
CreateMessageRequest,
CreateMessageRequestBody,
CreateMessageResponse,
EventMessage,
EventSender,
P2ImMessageReceiveV1,
P2ImMessageReceiveV1Data,
ReplyMessageRequest,
ReplyMessageRequestBody,
ReplyMessageResponse,
)
import lark_oapi.ws.exception
import pydantic
import quart
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
from langbot.pkg.platform.adapters.lark.api_impl import LarkAPIMixin
from langbot.pkg.platform.adapters.lark.event_converter import LarkEventConverter
from langbot.pkg.platform.adapters.lark.message_converter import LarkMessageConverter
from langbot.pkg.platform.adapters.lark.platform_api import PLATFORM_API_MAP
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
from langbot_plugin.api.entities.builtin.platform import message as platform_message
from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError
class AESCipher:
def __init__(self, key: str):
self.key = hashlib.sha256(self.str_to_bytes(key)).digest()
@staticmethod
def str_to_bytes(data):
if isinstance(data, str):
return data.encode('utf8')
return data
@staticmethod
def _unpad(value: bytes) -> bytes:
return value[: -value[len(value) - 1]]
def decrypt_string(self, encrypted: str) -> str:
encrypted_bytes = base64.b64decode(encrypted)
iv = encrypted_bytes[: AES.block_size]
cipher = AES.new(self.key, AES.MODE_CBC, iv)
return self._unpad(cipher.decrypt(encrypted_bytes[AES.block_size :])).decode('utf8')
class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapter):
bot: lark_oapi.ws.Client = pydantic.Field(exclude=True)
api_client: lark_oapi.Client = pydantic.Field(exclude=True)
quart_app: quart.Quart = pydantic.Field(exclude=True)
cipher: AESCipher = pydantic.Field(exclude=True)
config: dict
lark_tenant_key: str = pydantic.Field(exclude=True, default='')
app_ticket: str | None = None
app_access_token: str | None = None
app_access_token_expire_at: int | None = None
tenant_access_tokens: dict[str, dict[str, typing.Any]] = pydantic.Field(default_factory=dict)
bot_uuid: str | None = None
event_loop: asyncio.AbstractEventLoop | None = pydantic.Field(exclude=True, default=None)
message_converter: LarkMessageConverter = LarkMessageConverter()
event_converter: LarkEventConverter = LarkEventConverter()
listeners: dict[
typing.Type[platform_events.Event],
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
] = pydantic.Field(default_factory=dict)
card_id_dict: dict[str, str] = pydantic.Field(default_factory=dict)
pending_monitoring_msg: dict[str, str] = pydantic.Field(default_factory=dict)
reply_to_monitoring_msg: dict[str, tuple[str, float]] = pydantic.Field(default_factory=dict)
_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)
_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']
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)}')
api_client = self.build_api_client(config)
event_handler = self._build_event_handler()
bot = lark_oapi.ws.Client(config['app_id'], config['app_secret'], event_handler=event_handler)
cipher = AESCipher(config.get('encrypt-key', ''))
super().__init__(
config=config,
logger=logger,
lark_tenant_key=config.get('lark_tenant_key', ''),
bot_account_id=config['bot_name'],
bot=bot,
api_client=api_client,
quart_app=quart.Quart(__name__),
cipher=cipher,
listeners={},
card_id_dict={},
pending_monitoring_msg={},
reply_to_monitoring_msg={},
event_loop=None,
**kwargs,
)
self._message_cache = {}
self._user_cache = {}
self._group_cache = {}
self.request_app_ticket()
def _build_event_handler(self):
async def on_message(event: lark_oapi.im.v1.P2ImMessageReceiveV1):
await self._handle_message_event(event)
def sync_on_message(event: lark_oapi.im.v1.P2ImMessageReceiveV1):
self._submit_coro(on_message(event))
def sync_on_card_action(event):
return self._handle_card_action_sync(event)
return (
lark_oapi.EventDispatcherHandler.builder('', '')
.register_p2_im_message_receive_v1(sync_on_message)
.register_p2_card_action_trigger(sync_on_card_action)
.build()
)
def get_supported_events(self) -> list[str]:
return ['message.received', 'bot.invited_to_group', 'platform.specific']
def get_supported_apis(self) -> list[str]:
return [
'send_message',
'reply_message',
'get_message',
'get_group_info',
'get_group_member_info',
'get_user_info',
'get_file_url',
'call_platform_api',
]
def build_api_client(self, config: dict) -> lark_oapi.Client:
builder = lark_oapi.Client.builder().app_id(config['app_id']).app_secret(config['app_secret'])
if config.get('app_type', 'self') == 'isv':
builder = builder.app_type(lark_oapi.AppType.ISV)
return builder.build()
def request_app_ticket(self):
if self.config.get('app_type', 'self') != 'isv':
return
request = (
ResendAppTicketRequest.builder()
.request_body(
ResendAppTicketRequestBody.builder()
.app_id(self.config['app_id'])
.app_secret(self.config['app_secret'])
.build()
)
.build()
)
response: ResendAppTicketResponse = self.api_client.auth.v3.app_ticket.resend(request)
if not response.success():
raise RuntimeError(f'Lark app_ticket resend failed: {response.code} {response.msg}')
def request_app_access_token(self):
if self.config.get('app_type', 'self') != 'isv':
return
request = (
CreateAppAccessTokenRequest.builder()
.request_body(
CreateAppAccessTokenRequestBody.builder()
.app_id(self.config['app_id'])
.app_secret(self.config['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 RuntimeError(f'Lark app_access_token failed: {response.code} {response.msg}')
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 self.config.get('app_type', 'self') != 'isv':
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):
if self.config.get('app_type', 'self') != 'isv':
return
request = (
CreateTenantAccessTokenRequest.builder()
.request_body(
CreateTenantAccessTokenRequestBody.builder()
.app_access_token(self.get_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 RuntimeError(f'Lark tenant_access_token failed: {response.code} {response.msg}')
content = json.loads(response.raw.content)
self.tenant_access_tokens[tenant_key] = {
'token': content['tenant_access_token'],
'expire_at': int(time.time()) + content['expire'] - 300,
}
def get_tenant_access_token(self, tenant_key: str | None):
if self.config.get('app_type', 'self') != 'isv' or not tenant_key:
return None
cached = self.tenant_access_tokens.get(tenant_key)
if cached is None or int(time.time()) >= cached['expire_at']:
self.request_tenant_access_token(tenant_key)
return self.tenant_access_tokens.get(tenant_key, {}).get('token')
async def send_message(
self,
target_type: str,
target_id: str,
message: platform_message.MessageChain,
) -> platform_events.MessageResult:
text_elements, media_items = await self.message_converter.yiri2target(message, self.api_client)
receive_id_type = 'chat_id' if target_type == 'group' else 'open_id'
message_ids: list[str] = []
for msg_type, content in self._outbound_payloads(text_elements, media_items):
request = (
CreateMessageRequest.builder()
.receive_id_type(receive_id_type)
.request_body(
CreateMessageRequestBody.builder()
.receive_id(str(target_id))
.content(json.dumps(content, ensure_ascii=False))
.msg_type(msg_type)
.uuid(str(uuid.uuid4()))
.build()
)
.build()
)
response: CreateMessageResponse = await self.api_client.im.v1.message.acreate(request)
if not response.success():
raise RuntimeError(f'Lark send_message failed: {response.code} {response.msg}')
message_ids.append(getattr(response.data, 'message_id', ''))
return platform_events.MessageResult(
message_id=message_ids[-1] if message_ids else '', raw={'message_ids': message_ids}
)
async def reply_message(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
quote_origin: bool = False,
) -> platform_events.MessageResult:
text_elements, media_items = await self.message_converter.yiri2target(message, self.api_client)
tenant_key = self._tenant_key_from_source(message_source)
message_ids: list[str] = []
for msg_type, content in self._outbound_payloads(text_elements, media_items):
request = (
ReplyMessageRequest.builder()
.message_id(self._message_id_from_source(message_source))
.request_body(
ReplyMessageRequestBody.builder()
.content(json.dumps(content, ensure_ascii=False))
.msg_type(msg_type)
.reply_in_thread(False)
.uuid(str(uuid.uuid4()))
.build()
)
.build()
)
response: ReplyMessageResponse = await self.api_client.im.v1.message.areply(
request, self.request_option(tenant_key)
)
if not response.success():
raise RuntimeError(f'Lark reply_message failed: {response.code} {response.msg}')
message_ids.append(getattr(response.data, 'message_id', ''))
return platform_events.MessageResult(
message_id=message_ids[-1] if message_ids else '', raw={'message_ids': message_ids}
)
def _outbound_payloads(self, text_elements: list[list[dict]], media_items: list[dict]) -> list[tuple[str, dict]]:
payloads: list[tuple[str, dict]] = []
if text_elements:
needs_post = any(ele.get('tag') == 'at' for paragraph in text_elements for ele in paragraph)
if needs_post:
payloads.append(('post', {'zh_Hans': {'title': '', 'content': text_elements}}))
else:
parts = []
for paragraph in text_elements:
text = ''.join(ele.get('text', '') for ele in paragraph)
if text:
parts.append(text)
payloads.append(('text', {'text': '\n\n'.join(parts)}))
for media in media_items:
payloads.append((media['msg_type'], media['content']))
return payloads
async def is_stream_output_supported(self) -> bool:
return bool(self.config.get('enable-stream-reply', False))
async def on_monitoring_message_created(self, query, monitoring_message_id: str):
user_msg_id = getattr(query.message_event, 'message_id', None)
if user_msg_id:
self.pending_monitoring_msg[str(user_msg_id)] = monitoring_message_id
async def create_message_card(self, message_id, event) -> bool:
card_id = await self.create_card_id(message_id)
content = {'type': 'card', 'data': {'card_id': card_id, 'template_variable': {'content': 'Thinking...'}}}
request = (
ReplyMessageRequest.builder()
.message_id(self._message_id_from_source(event))
.request_body(
ReplyMessageRequestBody.builder().content(json.dumps(content)).msg_type('interactive').build()
)
.build()
)
response: ReplyMessageResponse = await self.api_client.im.v1.message.areply(
request, self.request_option(self._tenant_key_from_source(event))
)
if not response.success():
raise RuntimeError(f'Lark create_message_card failed: {response.code} {response.msg}')
return True
async def create_card_id(self, message_id) -> str:
card_data = {
'schema': '2.0',
'config': {'update_multi': True, 'streaming_mode': True},
'body': {
'direction': 'vertical',
'elements': [{'tag': 'markdown', 'content': '', 'element_id': 'streaming_txt'}],
},
}
request = (
CreateCardRequest.builder()
.request_body(CreateCardRequestBody.builder().type('card_json').data(json.dumps(card_data)).build())
.build()
)
response: CreateCardResponse = self.api_client.cardkit.v1.card.create(request)
if not response.success():
raise RuntimeError(f'Lark create_card failed: {response.code} {response.msg}')
self.card_id_dict[str(message_id)] = response.data.card_id
return response.data.card_id
async def reply_message_chunk(
self,
message_source: platform_events.MessageEvent,
bot_message,
message: platform_message.MessageChain,
quote_origin: bool = False,
is_final: bool = False,
):
if bot_message.msg_sequence % 8 != 0 and not is_final:
return
text_elements, _ = await self.message_converter.yiri2target(message, self.api_client)
content = '\n\n'.join(
''.join(ele.get('text', '') for ele in paragraph if ele.get('tag') in {'text', 'md'})
for paragraph in text_elements
)
request = (
ContentCardElementRequest.builder()
.card_id(self.card_id_dict[bot_message.resp_message_id])
.element_id('streaming_txt')
.request_body(
ContentCardElementRequestBody.builder().content(content).sequence(bot_message.msg_sequence).build()
)
.build()
)
response: ContentCardElementResponse = self.api_client.cardkit.v1.card_element.content(
request, self.request_option(self._tenant_key_from_source(message_source))
)
if not response.success():
raise RuntimeError(f'Lark card_element update failed: {response.code} {response.msg}')
if is_final and bot_message.tool_calls is None:
self.card_id_dict.pop(bot_message.resp_message_id, None)
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
handler = PLATFORM_API_MAP.get(action)
if handler is None:
raise NotSupportedError(f'call_platform_api:{action}')
return await handler(self, params)
def register_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable[
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
],
):
self.listeners[event_type] = callback
def unregister_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable[
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
],
):
if self.listeners.get(event_type) is callback:
self.listeners.pop(event_type, None)
def set_bot_uuid(self, bot_uuid: str):
self.bot_uuid = bot_uuid
def get_launcher_id(self, event: platform_events.MessageEvent) -> str | None:
source_event = getattr(event.source_platform_object, 'event', None)
message = getattr(source_event, 'message', None) if source_event else None
thread_id = getattr(message, 'thread_id', None)
if thread_id and isinstance(event, platform_events.MessageReceivedEvent) and event.group:
return f'{event.group.id}_{thread_id}'
return None
async def handle_unified_webhook(self, bot_uuid: str, path: str, request):
try:
data = await request.json
if 'encrypt' in data:
data = json.loads(self.cipher.decrypt_string(data['encrypt']))
event_type = self.get_event_type(data)
if event_type == 'url_verification':
return {'challenge': data.get('challenge')}
if event_type == 'app_ticket':
self.app_ticket = self._webhook_event(data).get('app_ticket')
return {'code': 200, 'message': 'ok'}
if event_type == 'im.message.receive_v1':
p2v1 = P2ImMessageReceiveV1()
p2v1.header = self._webhook_header(data)
event_data = P2ImMessageReceiveV1Data()
raw_event = self._webhook_event(data)
event_data.message = EventMessage(raw_event['message'])
event_data.sender = EventSender(raw_event['sender'])
p2v1.event = event_data
p2v1.schema = data.get('schema', '2.0')
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))
return {'code': 200, 'message': 'ok'}
if event_type == 'card.action.trigger':
feedback_event = self._feedback_event_from_webhook(data)
if feedback_event and platform_events.FeedbackEvent in self.listeners:
await self.listeners[platform_events.FeedbackEvent](feedback_event, self)
return {'toast': {'type': 'success', 'content': '感谢您的反馈'}}
await self._dispatch_eba_event(LarkEventConverter.platform_specific(data, event_type, data))
return {'code': 200, 'message': 'ok'}
except Exception:
await self.logger.error(f'Error in lark webhook: {traceback.format_exc()}')
return {'code': 500, 'message': 'error'}
def get_event_type(self, data: dict) -> str:
schema = data.get('schema', '1.0')
if schema == '2.0':
return data.get('header', {}).get('event_type', '')
if 'event' in data:
return data['event'].get('type', '')
return data.get('type', '')
def _webhook_event(self, data: dict) -> dict:
return data.get('event', {})
def _webhook_header(self, data: dict):
return type('LarkWebhookHeader', (), data.get('header', {}))()
async def run_async(self):
self.event_loop = asyncio.get_running_loop()
if not self.config.get('enable-webhook', False):
try:
await self.bot._connect()
except lark_oapi.ws.exception.ClientException:
raise
except Exception:
await self.bot._disconnect()
if self.bot._auto_reconnect:
await self.bot._reconnect()
else:
raise
else:
while True:
await asyncio.sleep(1)
async def kill(self) -> bool:
self.bot._auto_reconnect = False
await self.bot._disconnect()
return True
async def is_muted(self, group_id: int | None = None) -> bool:
return False
async def _handle_message_event(self, event: lark_oapi.im.v1.P2ImMessageReceiveV1):
try:
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)
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)
if eba_event:
self._cache_event(eba_event)
await self._dispatch_eba_event(eba_event)
except Exception:
await self.logger.error(f'Error in lark message event: {traceback.format_exc()}')
async def _dispatch_eba_event(self, event: platform_events.Event):
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
callback = self.listeners.get(event_type)
if callback:
await callback(event, self)
return
def _cache_event(self, event: platform_events.Event):
if not isinstance(event, platform_events.MessageReceivedEvent):
return
self._message_cache[str(event.message_id)] = event
self._user_cache[str(event.sender.id)] = event.sender
if event.group:
self._group_cache[str(event.group.id)] = event.group
def _handle_card_action_sync(self, event):
feedback_event = self._feedback_event_from_callback(event)
if feedback_event and platform_events.FeedbackEvent in self.listeners:
self._submit_coro(self.listeners[platform_events.FeedbackEvent](feedback_event, self))
from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse
return P2CardActionTriggerResponse({'toast': {'type': 'success', 'content': '感谢您的反馈'}})
def _submit_coro(self, coro):
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = self.event_loop
if loop and loop.is_running():
asyncio.run_coroutine_threadsafe(coro, loop)
return
coro.close()
raise
else:
loop.create_task(coro)
def _feedback_event_from_callback(self, event) -> platform_events.FeedbackEvent | None:
value = getattr(getattr(event.event, 'action', None), 'value', {}) or {}
return self._feedback_event(
raw=event,
feedback_id=getattr(event.header, 'event_id', str(uuid.uuid4())),
feedback_value=value.get('feedback', ''),
user_id=getattr(getattr(event.event, 'operator', None), 'open_id', None),
chat_id=getattr(getattr(event.event, 'context', None), 'open_chat_id', None),
message_id=getattr(getattr(event.event, 'context', None), 'open_message_id', None),
)
def _feedback_event_from_webhook(self, data: dict) -> platform_events.FeedbackEvent | None:
event = data.get('event', {})
value = event.get('action', {}).get('value', {}) or {}
operator = event.get('operator', {})
context = event.get('context', {})
return self._feedback_event(
raw=data,
feedback_id=data.get('header', {}).get('event_id', str(uuid.uuid4())),
feedback_value=value.get('feedback', ''),
user_id=operator.get('open_id') or operator.get('user_id'),
chat_id=context.get('open_chat_id'),
message_id=context.get('open_message_id'),
)
def _feedback_event(
self,
raw,
feedback_id: str,
feedback_value: str,
user_id: str | None,
chat_id: str | None,
message_id: str | None,
) -> platform_events.FeedbackEvent | None:
if feedback_value == '有帮助':
feedback_type = 1
elif feedback_value == '无帮助':
feedback_type = 2
else:
return None
return platform_events.FeedbackEvent(
feedback_id=feedback_id,
feedback_type=feedback_type,
feedback_content=feedback_value,
user_id=user_id,
session_id=f'group_{chat_id}' if chat_id else (f'person_{user_id}' if user_id else None),
message_id=message_id,
stream_id=self.reply_to_monitoring_msg.get(message_id, (None, 0))[0] if message_id else None,
source_platform_object=raw,
)
async def _send_bot_added_welcome(self, chat_id: str, tenant_key: str | None):
welcome = self.config.get('bot_added_welcome', '')
if not welcome or not chat_id:
return
content = {'zh_Hans': {'title': '', 'content': [[{'tag': 'md', 'text': welcome}]]}}
request = (
CreateMessageRequest.builder()
.receive_id_type('chat_id')
.request_body(
CreateMessageRequestBody.builder()
.receive_id(chat_id)
.content(json.dumps(content, ensure_ascii=False))
.msg_type('post')
.uuid(str(uuid.uuid4()))
.build()
)
.build()
)
response: CreateMessageResponse = await self.api_client.im.v1.message.acreate(
request, self.request_option(tenant_key)
)
if not response.success():
await self.logger.warning(f'Lark bot_added_welcome failed: {response.code} {response.msg}')
def _tenant_key_from_source(self, event: platform_events.Event) -> str | None:
source = getattr(event, 'source_platform_object', None)
header = getattr(source, 'header', None)
return getattr(header, 'tenant_key', None)
def _message_id_from_source(self, event: platform_events.Event) -> str:
message_id = getattr(event, 'message_id', None)
if message_id:
return str(message_id)
source = getattr(event, 'source_platform_object', None)
source_event = getattr(source, 'event', None)
message = getattr(source_event, 'message', None) if source_event else None
message_id = getattr(message, 'message_id', None)
if message_id:
return str(message_id)
raise RuntimeError('Lark message source does not contain message_id')
@@ -0,0 +1,103 @@
from __future__ import annotations
import typing
from lark_oapi.api.im.v1 import GetChatRequest, GetMessageRequest
from lark_oapi.core.model import RequestOption
from langbot.pkg.platform.adapters.lark.event_converter import LarkEventConverter
from langbot.pkg.platform.adapters.lark.message_converter import LarkMessageConverter
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError
class LarkAPIMixin:
_message_cache: dict[str, platform_events.MessageReceivedEvent]
_user_cache: dict[str, platform_entities.User]
_group_cache: dict[str, platform_entities.UserGroup]
async def get_message(
self,
chat_type: str,
chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
) -> platform_events.MessageReceivedEvent:
cached = self._message_cache.get(str(message_id))
if cached:
return cached
request = GetMessageRequest.builder().message_id(str(message_id)).build()
response = await self.api_client.im.v1.message.aget(request, self.request_option(None))
if not response.success():
raise NotSupportedError(f'get_message:{message_id}')
items = getattr(response.data, 'items', None) or []
if not items:
raise NotSupportedError(f'get_message:{message_id}')
event_message = LarkEventConverter._build_event_message_from_message_item(items[0])
if event_message is None:
raise NotSupportedError(f'get_message:{message_id}')
message_chain = await LarkMessageConverter.target2yiri(event_message, self.api_client)
event = platform_events.MessageReceivedEvent(
type='message.received',
adapter_name='lark-eba',
message_id=str(message_id),
message_chain=message_chain,
sender=platform_entities.User(id=''),
chat_type=platform_entities.ChatType.GROUP if chat_type == 'group' else platform_entities.ChatType.PRIVATE,
chat_id=chat_id,
group=platform_entities.UserGroup(id=chat_id, name='') if chat_type == 'group' else None,
timestamp=0,
source_platform_object=items[0],
)
self._message_cache[str(message_id)] = event
return event
async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup:
cached = self._group_cache.get(str(group_id))
if cached:
return cached
request = GetChatRequest.builder().chat_id(str(group_id)).build()
response = await self.api_client.im.v1.chat.aget(request, self.request_option(None))
if not response.success():
raise NotSupportedError(f'get_group_info:{group_id}')
data = response.data
group = platform_entities.UserGroup(
id=getattr(data, 'chat_id', group_id),
name=getattr(data, 'name', '') or '',
description=getattr(data, 'description', None),
avatar_url=getattr(data, 'avatar', None),
owner_id=getattr(data, 'owner_id', None),
)
self._group_cache[str(group.id)] = group
return group
async def get_group_member_info(
self,
group_id: typing.Union[int, str],
user_id: typing.Union[int, str],
) -> platform_entities.UserGroupMember:
user = self._user_cache.get(str(user_id)) or platform_entities.User(id=user_id)
return platform_entities.UserGroupMember(user=user, group_id=group_id, role=platform_entities.MemberRole.MEMBER)
async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User:
cached = self._user_cache.get(str(user_id))
if cached:
return cached
return platform_entities.User(id=user_id)
async def get_file_url(self, file_id: str) -> str:
if str(file_id).startswith('file://'):
return str(file_id)
raise NotSupportedError('get_file_url requires a file:// path or platform-specific resource download params')
def request_option(self, tenant_key: str | None) -> RequestOption:
app_access_token = self.get_app_access_token()
tenant_access_token = self.get_tenant_access_token(tenant_key)
return (
RequestOption.builder()
.app_ticket(self.app_ticket)
.tenant_key(tenant_key)
.app_access_token(app_access_token)
.tenant_access_token(tenant_access_token)
.build()
)
@@ -0,0 +1,205 @@
from __future__ import annotations
import time
import typing
import lark_oapi
from lark_oapi.api.im.v1 import EventMessage, GetMessageRequest, Message
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
from langbot.pkg.platform.adapters.lark.message_converter import LarkMessageConverter
from langbot.pkg.platform.adapters.lark.types import ADAPTER_NAME
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
from langbot_plugin.api.entities.builtin.platform import message as platform_message
class LarkEventConverter(abstract_platform_adapter.AbstractEventConverter):
_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
@staticmethod
async def yiri2target(event: platform_events.Event):
return getattr(event, 'source_platform_object', None)
@staticmethod
async def target2yiri(
event: lark_oapi.im.v1.P2ImMessageReceiveV1,
api_client: lark_oapi.Client,
) -> platform_events.Event | None:
return await LarkEventConverter.message_to_eba(event, api_client)
@staticmethod
async def target2legacy(
event: lark_oapi.im.v1.P2ImMessageReceiveV1,
api_client: lark_oapi.Client,
) -> platform_events.FriendMessage | platform_events.GroupMessage | None:
eba_event = await LarkEventConverter.message_to_eba(event, api_client)
if eba_event:
return eba_event.to_legacy_event()
return None
@staticmethod
async def message_to_eba(
event: lark_oapi.im.v1.P2ImMessageReceiveV1,
api_client: lark_oapi.Client,
) -> platform_events.MessageReceivedEvent:
message = event.event.message
message_chain = await LarkMessageConverter.target2yiri(message, api_client)
await LarkEventConverter._append_quote_content(message, message_chain, api_client)
sender = LarkEventConverter.user_from_event(event)
chat_type = platform_entities.ChatType.PRIVATE
chat_id = LarkEventConverter.sender_id(event)
group = None
if getattr(message, 'chat_type', '') == 'group':
chat_type = platform_entities.ChatType.GROUP
chat_id = getattr(message, 'chat_id', '') or chat_id
group = platform_entities.UserGroup(id=chat_id, name='')
return platform_events.MessageReceivedEvent(
type='message.received',
adapter_name=ADAPTER_NAME,
message_id=getattr(message, 'message_id', ''),
message_chain=message_chain,
sender=sender,
chat_type=chat_type,
chat_id=chat_id,
group=group,
timestamp=LarkEventConverter._timestamp(getattr(message, 'create_time', None)),
source_platform_object=event,
)
@staticmethod
def user_from_event(event: lark_oapi.im.v1.P2ImMessageReceiveV1) -> platform_entities.User:
sender_id = getattr(getattr(event.event.sender, 'sender_id', None), 'open_id', '') or ''
union_id = getattr(getattr(event.event.sender, 'sender_id', None), 'union_id', '') or ''
return platform_entities.User(id=sender_id, nickname=union_id)
@staticmethod
def sender_id(event: lark_oapi.im.v1.P2ImMessageReceiveV1) -> str:
return getattr(getattr(event.event.sender, 'sender_id', None), 'open_id', '') or ''
@staticmethod
def bot_invited_to_group(
raw_event: typing.Any,
chat_id: str,
operator_id: str | None = None,
) -> platform_events.BotInvitedToGroupEvent:
return platform_events.BotInvitedToGroupEvent(
type='bot.invited_to_group',
adapter_name=ADAPTER_NAME,
group=platform_entities.UserGroup(id=chat_id, name=''),
inviter=platform_entities.User(id=operator_id) if operator_id else None,
timestamp=time.time(),
source_platform_object=raw_event,
)
@staticmethod
def platform_specific(
raw_event: typing.Any, action: str, data: dict | None = None
) -> platform_events.PlatformSpecificEvent:
return platform_events.PlatformSpecificEvent(
type='platform.specific',
adapter_name=ADAPTER_NAME,
action=action,
data=data or {},
timestamp=time.time(),
source_platform_object=raw_event,
)
@classmethod
def _prune_processed_thread_quote_cache(cls, now: float | None = None) -> None:
if now is None:
now = time.time()
expire_before = now - cls._processed_thread_quote_cache_ttl_seconds
while cls._processed_thread_quote_cache:
oldest_key, oldest_ts = next(iter(cls._processed_thread_quote_cache.items()))
if oldest_ts >= expire_before:
break
cls._processed_thread_quote_cache.pop(oldest_key, None)
while len(cls._processed_thread_quote_cache) > cls._processed_thread_quote_cache_max_size:
cls._processed_thread_quote_cache.pop(next(iter(cls._processed_thread_quote_cache)), None)
@classmethod
def _extract_quote_message_id(cls, message: EventMessage) -> str | None:
parent_id = getattr(message, 'parent_id', None)
if not parent_id or parent_id == getattr(message, 'message_id', None):
return None
thread_id = getattr(message, 'thread_id', None)
if thread_id:
cls._prune_processed_thread_quote_cache()
if thread_id in cls._processed_thread_quote_cache:
return None
cls._processed_thread_quote_cache[thread_id] = time.time()
return parent_id
@staticmethod
async def _append_quote_content(
message: EventMessage,
message_chain: platform_message.MessageChain,
api_client: lark_oapi.Client,
) -> None:
quote_message_id = LarkEventConverter._extract_quote_message_id(message)
if not quote_message_id:
return
quote_chain = await LarkEventConverter._fetch_quoted_message(quote_message_id, api_client)
if not quote_chain:
return
origin = platform_message.MessageChain(
[comp for comp in quote_chain if not isinstance(comp, platform_message.Source)]
)
message_chain.append(
platform_message.Quote(
id=quote_message_id,
group_id=getattr(message, 'chat_id', None),
target_id=getattr(message, 'chat_id', None),
origin=origin,
)
)
@staticmethod
async def _fetch_quoted_message(
quote_message_id: str,
api_client: lark_oapi.Client,
) -> platform_message.MessageChain | None:
request = GetMessageRequest.builder().message_id(quote_message_id).build()
response = await api_client.im.v1.message.aget(request)
if not response.success() or not getattr(response.data, 'items', None):
return None
event_message = LarkEventConverter._build_event_message_from_message_item(response.data.items[0])
if event_message is None:
return None
return await LarkMessageConverter.target2yiri(event_message, api_client)
@staticmethod
def _build_event_message_from_message_item(message_item: Message) -> EventMessage | None:
body = getattr(message_item, 'body', None)
content = getattr(body, 'content', None) if body else None
if not content:
return None
event_data = {
'message_id': message_item.message_id,
'message_type': message_item.msg_type,
'content': content,
'create_time': message_item.create_time,
'mentions': getattr(message_item, 'mentions', []) or [],
}
for key in ('parent_id', 'root_id', 'thread_id', 'chat_id'):
value = getattr(message_item, key, None)
if value:
event_data[key] = value
return EventMessage(event_data)
@staticmethod
def _timestamp(value: typing.Any) -> float:
if isinstance(value, (int, float, str)):
try:
timestamp = float(value)
return timestamp / 1000 if timestamp > 10_000_000_000 else timestamp
except ValueError:
pass
if hasattr(value, 'timestamp'):
return float(value.timestamp())
return 0.0
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1711946937387" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5208" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M262.339048 243.809524h326.070857s91.672381 84.504381 91.672381 200.655238l-152.81981 105.569524S445.781333 359.960381 262.339048 243.809524z" fill="#00DAB8" p-id="5209"></path><path d="M853.333333 423.350857s-112.103619-42.276571-183.393523-10.581333c-71.338667 31.695238-101.912381 73.923048-132.486096 105.618286-40.71619 42.22781-112.054857 116.150857-173.202285 73.923047-61.147429-42.276571 244.540952 147.846095 244.540952 147.846095s127.463619-71.631238 173.202286-190.122666C822.759619 444.464762 853.333333 423.350857 853.333333 423.350857z" fill="#0C3AA0" p-id="5210"></path><path d="M170.666667 402.236952v316.757334s112.298667 138.142476 376.978285 63.390476c112.103619-31.695238 203.824762-179.541333 203.824762-179.541333S618.934857 824.612571 170.666667 402.285714z" fill="#296DFF" p-id="5211"></path></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,185 @@
apiVersion: v1
kind: MessagePlatformAdapter
metadata:
name: lark-eba
label:
en_US: Lark / Feishu (EBA)
zh_Hans: 飞书 (EBA)
zh_Hant: 飛書 (EBA)
ja_JP: Lark (EBA)
description:
en_US: Lark/Feishu adapter (EBA architecture), supporting self-built/store apps and WebSocket/Webhook modes.
zh_Hans: 飞书适配器(EBA 架构版本),支持自建/商店应用和长连接/Webhook 两种通信模式。
zh_Hant: 飛書適配器(EBA 架構版本),支援自建/商店應用和長連線/Webhook 兩種通訊模式。
ja_JP: Lark アダプター(EBA アーキテクチャ)、カスタム/ストアアプリと WebSocket/Webhook モードをサポートします。
icon: lark.svg
spec:
categories:
- popular
- china
- global
help_links:
zh: https://link.langbot.app/zh/platforms/lark
en: https://link.langbot.app/en/platforms/lark
ja: https://link.langbot.app/ja/platforms/lark
config:
- name: app_id
label:
en_US: App ID
zh_Hans: 应用ID
zh_Hant: 應用ID
ja_JP: アプリ ID
type: string
required: true
default: ""
- name: app_secret
label:
en_US: App Secret
zh_Hans: 应用密钥
zh_Hant: 應用密鑰
ja_JP: アプリシークレット
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
zh_Hans: 启用 Webhook 模式
zh_Hant: 啟用 Webhook 模式
ja_JP: Webhook モードを有効化
description:
en_US: Enable request URL callback mode. Disable it to use WebSocket long connection mode.
zh_Hans: 启用 Request URL 回调模式。关闭时使用 WebSocket 长连接模式。
zh_Hant: 啟用 Request URL 回調模式。關閉時使用 WebSocket 長連線模式。
ja_JP: Request URL コールバックモードを有効化します。無効時は WebSocket 長期接続を使用します。
type: boolean
required: true
default: false
- name: webhook_url
label:
en_US: Webhook Callback URL
zh_Hans: Webhook 回调地址
zh_Hant: Webhook 回調地址
ja_JP: Webhook コールバック URL
description:
en_US: Copy this URL to the Lark app event subscription request URL.
zh_Hans: 复制此地址并粘贴到飞书应用事件订阅的 Request URL 中。
zh_Hant: 複製此地址並貼到飛書應用事件訂閱的 Request URL 中。
ja_JP: この URL を Lark アプリのイベント購読 Request URL に貼り付けてください。
type: webhook-url
required: false
default: ""
show_if:
field: enable-webhook
operator: eq
value: true
- name: encrypt-key
label:
en_US: Encrypt Key
zh_Hans: 加密密钥
zh_Hant: 加密密鑰
ja_JP: 暗号化キー
type: string
required: false
default: ""
show_if:
field: enable-webhook
operator: eq
value: true
- name: enable-stream-reply
label:
en_US: Enable Stream Reply Mode
zh_Hans: 启用飞书流式回复模式
zh_Hant: 啟用飛書串流回覆模式
ja_JP: ストリーミング返信モードを有効化
description:
en_US: If enabled, replies are rendered through an updating Lark card.
zh_Hans: 如果启用,将使用可更新的飞书卡片进行流式回复。
zh_Hant: 如果啟用,將使用可更新的飛書卡片進行串流回覆。
ja_JP: 有効にすると、更新可能な Lark カードでストリーミング返信します。
type: boolean
required: true
default: false
- name: app_type
label:
en_US: App Type
zh_Hans: 应用类型
zh_Hant: 應用類型
ja_JP: アプリタイプ
type: select
options:
- name: self
label:
en_US: Self-built Application
zh_Hans: 自建应用
zh_Hant: 自建應用
ja_JP: カスタムアプリ
- name: isv
label:
en_US: Store Application
zh_Hans: 商店应用
zh_Hant: 商店應用
ja_JP: ストアアプリ
required: false
default: self
- name: bot_added_welcome
label:
en_US: Bot Welcome Message
zh_Hans: 机器人进群欢迎语
zh_Hant: 機器人進群歡迎語
ja_JP: ボット参加時のウェルカムメッセージ
type: text
required: false
default: ""
supported_events:
- message.received
- bot.invited_to_group
- platform.specific
supported_apis:
required:
- send_message
- reply_message
optional:
- get_message
- get_group_info
- get_group_member_info
- get_user_info
- get_file_url
- call_platform_api
platform_specific_apis:
- action: check_tenant_access_token
description: { en_US: "Check whether the tenant access token can be obtained", zh_Hans: "检查 tenant access token 是否可获取" }
- action: refresh_app_access_token
description: { en_US: "Refresh store-app app access token", zh_Hans: "刷新商店应用 app access token" }
- action: refresh_tenant_access_token
description: { en_US: "Refresh store-app tenant access token", zh_Hans: "刷新商店应用 tenant access token" }
- action: get_chat
description: { en_US: "Get Lark chat metadata", zh_Hans: "获取飞书会话信息" }
- action: get_message
description: { en_US: "Get a Lark message", zh_Hans: "获取飞书消息" }
- action: get_message_resource
description: { en_US: "Download message image/file resource", zh_Hans: "下载消息图片/文件资源" }
execution:
python:
path: ./adapter.py
attr: LarkAdapter
@@ -0,0 +1,405 @@
from __future__ import annotations
import base64
import datetime
import json
import mimetypes
import os
import re
import tempfile
import traceback
import lark_oapi
from lark_oapi.api.im.v1 import (
CreateFileRequest,
CreateFileRequestBody,
CreateImageRequest,
CreateImageRequestBody,
EventMessage,
GetMessageResourceRequest,
GetMessageResourceResponse,
)
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
from langbot.pkg.utils import httpclient
from langbot_plugin.api.entities.builtin.platform import message as platform_message
class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
@staticmethod
async def upload_image_to_lark(msg: platform_message.Image, api_client: lark_oapi.Client) -> str | None:
image_bytes = await LarkMessageConverter._get_component_bytes(msg)
if image_bytes is None:
return None
temp_file_path = ''
try:
with tempfile.NamedTemporaryFile(delete=False) as temp_file:
temp_file.write(image_bytes)
temp_file.flush()
temp_file_path = temp_file.name
request = (
CreateImageRequest.builder()
.request_body(
CreateImageRequestBody.builder().image_type('message').image(open(temp_file_path, 'rb')).build()
)
.build()
)
response = await api_client.im.v1.image.acreate(request)
if not response.success():
return None
return response.data.image_key
except Exception:
traceback.print_exc()
return None
finally:
if temp_file_path:
try:
os.unlink(temp_file_path)
except FileNotFoundError:
pass
@staticmethod
async def upload_file_to_lark(
file_bytes: bytes,
api_client: lark_oapi.Client,
file_type: str,
file_name: str = 'file',
duration: int | None = None,
) -> str | None:
temp_file_path = ''
try:
with tempfile.NamedTemporaryFile(delete=False) as temp_file:
temp_file.write(file_bytes)
temp_file.flush()
temp_file_path = temp_file.name
body_builder = (
CreateFileRequestBody.builder()
.file_type(file_type)
.file_name(file_name)
.file(open(temp_file_path, 'rb'))
)
if duration is not None:
body_builder = body_builder.duration(duration)
request = CreateFileRequest.builder().request_body(body_builder.build()).build()
response = await api_client.im.v1.file.acreate(request)
if not response.success():
return None
return response.data.file_key
except Exception:
traceback.print_exc()
return None
finally:
if temp_file_path:
try:
os.unlink(temp_file_path)
except FileNotFoundError:
pass
@staticmethod
async def _get_component_bytes(
msg: platform_message.Image | platform_message.Voice | platform_message.File,
) -> bytes | None:
if getattr(msg, 'base64', None):
try:
base64_data = msg.base64
if ',' in base64_data:
base64_data = base64_data.split(',', 1)[1]
return base64.b64decode(base64_data)
except Exception:
return None
if getattr(msg, 'url', None):
try:
if str(msg.url).startswith('file://'):
with open(str(msg.url)[7:], 'rb') as f:
return f.read()
session = httpclient.get_session()
async with session.get(msg.url) as response:
if response.status == 200:
return await response.read()
except Exception:
return None
if getattr(msg, 'path', None):
try:
with open(msg.path, 'rb') as f:
return f.read()
except Exception:
return None
return None
@staticmethod
def _lark_file_type(file_name: str) -> str:
ext = os.path.splitext(file_name)[1].lstrip('.').lower()
return {
'opus': 'opus',
'mp4': 'mp4',
'pdf': 'pdf',
'doc': 'doc',
'docx': 'doc',
'xls': 'xls',
'xlsx': 'xls',
'ppt': 'ppt',
'pptx': 'ppt',
}.get(ext, 'stream')
@staticmethod
async def yiri2target(
message_chain: platform_message.MessageChain,
api_client: lark_oapi.Client,
) -> tuple[list[list[dict]], list[dict]]:
message_elements: list[list[dict]] = []
media_items: list[dict] = []
pending_paragraph: list[dict] = []
markdown_image_pattern = re.compile(r'!\[([^\]]*)\]\(([^)]+)\)')
async def process_text_with_images(text: str) -> tuple[str, list[str]]:
matches = list(markdown_image_pattern.finditer(text))
if not matches:
return text, []
cleaned_text = text
extracted_urls: list[str] = []
for match in reversed(matches):
extracted_urls.insert(0, match.group(2))
cleaned_text = cleaned_text[: match.start()] + cleaned_text[match.end() :]
cleaned_text = re.sub(r'\n{3,}', '\n\n', cleaned_text).strip()
return cleaned_text, extracted_urls
for msg in message_chain:
if isinstance(msg, platform_message.Source):
continue
if isinstance(msg, platform_message.Plain):
cleaned_text, extracted_urls = await process_text_with_images(msg.text)
if cleaned_text:
segments = re.split(r'\n\s*\n', cleaned_text)
for i, segment in enumerate(segments):
segment = segment.strip()
if not segment:
continue
if i > 0 and pending_paragraph:
message_elements.append(pending_paragraph)
pending_paragraph = []
pending_paragraph.append({'tag': 'md', 'text': segment})
for url in extracted_urls:
image_key = await LarkMessageConverter.upload_image_to_lark(
platform_message.Image(url=url), api_client
)
if image_key:
media_items.append({'msg_type': 'image', 'content': {'image_key': image_key}})
elif isinstance(msg, platform_message.At):
pending_paragraph.append({'tag': 'at', 'user_id': str(msg.target), 'style': []})
elif isinstance(msg, platform_message.AtAll):
pending_paragraph.append({'tag': 'at', 'user_id': 'all', 'style': []})
elif isinstance(msg, platform_message.Image):
image_key = await LarkMessageConverter.upload_image_to_lark(msg, api_client)
if image_key:
media_items.append({'msg_type': 'image', 'content': {'image_key': image_key}})
elif isinstance(msg, platform_message.Voice):
data = await LarkMessageConverter._get_component_bytes(msg)
if data:
duration = int(msg.length * 1000) if msg.length else None
file_key = await LarkMessageConverter.upload_file_to_lark(
data, api_client, file_type='opus', file_name='voice.opus', duration=duration
)
if file_key:
media_items.append({'msg_type': 'audio', 'content': {'file_key': file_key}})
elif isinstance(msg, platform_message.File):
data = await LarkMessageConverter._get_component_bytes(msg)
if data:
file_name = msg.name or 'file'
file_key = await LarkMessageConverter.upload_file_to_lark(
data,
api_client,
file_type=LarkMessageConverter._lark_file_type(file_name),
file_name=file_name,
)
if file_key:
media_items.append({'msg_type': 'file', 'content': {'file_key': file_key}})
elif isinstance(msg, platform_message.Quote):
if msg.id:
pending_paragraph.append({'tag': 'md', 'text': f'[引用消息 {msg.id}] '})
if msg.origin:
sub_elements, sub_media = await LarkMessageConverter.yiri2target(msg.origin, api_client)
message_elements.extend(sub_elements)
media_items.extend(sub_media)
elif isinstance(msg, platform_message.Forward):
for node in msg.node_list:
if node.sender_name or node.sender_id:
pending_paragraph.append({'tag': 'md', 'text': f'\n[{node.sender_name or node.sender_id}] '})
sub_elements, sub_media = await LarkMessageConverter.yiri2target(node.message_chain, api_client)
message_elements.extend(sub_elements)
media_items.extend(sub_media)
if pending_paragraph:
message_elements.append(pending_paragraph)
return message_elements, media_items
@staticmethod
async def target2yiri(
message: EventMessage,
api_client: lark_oapi.Client,
) -> platform_message.MessageChain:
message_content = json.loads(message.content or '{}')
create_time = LarkMessageConverter._message_time(message)
components: list[platform_message.MessageComponent] = [
platform_message.Source(id=message.message_id, time=create_time)
]
normalized = LarkMessageConverter._normalize_inbound_content(message, message_content)
for ele in normalized:
tag = ele.get('tag')
if tag in {'text', 'md'}:
text = ele.get('text') or ''
if text:
components.append(platform_message.Plain(text=text))
elif tag == 'at':
user_id = ele.get('user_id') or ele.get('user_name') or ''
display = ele.get('user_name') or user_id
if user_id == 'all':
components.append(platform_message.AtAll())
else:
components.append(platform_message.At(target=user_id, display=display))
elif tag == 'img':
image_key = ele.get('image_key') or ''
image = await LarkMessageConverter._download_resource(
api_client, message.message_id, image_key, 'image'
)
components.append(platform_message.Image(image_id=image_key, **image))
elif tag == 'audio':
file_key = ele.get('file_key') or ''
audio = await LarkMessageConverter._download_resource(api_client, message.message_id, file_key, 'file')
components.append(
platform_message.Voice(
voice_id=file_key,
length=(ele.get('duration', 0) // 1000) if ele.get('duration') else None,
**audio,
)
)
elif tag == 'file':
file_key = ele.get('file_key') or ''
file_name = ele.get('file_name') or 'file'
file_data = await LarkMessageConverter._download_resource(
api_client, message.message_id, file_key, 'file'
)
components.append(
platform_message.File(
id=file_key,
name=file_name,
size=file_data.pop('size', 0),
**file_data,
)
)
return platform_message.MessageChain(components)
@staticmethod
def _normalize_inbound_content(message: EventMessage, content: dict) -> list[dict]:
if message.message_type == 'text':
text = content.get('text', '')
return LarkMessageConverter._split_text_mentions(text, getattr(message, 'mentions', []) or [])
if message.message_type == 'post':
post_content = content.get('content', [])
flattened: list[dict] = []
for ele in post_content:
if isinstance(ele, dict):
flattened.append(ele)
elif isinstance(ele, list):
flattened.extend(item for item in ele if isinstance(item, dict))
return flattened
if message.message_type == 'image':
return [{'tag': 'img', 'image_key': content.get('image_key', ''), 'style': []}]
if message.message_type == 'file':
return [
{
'tag': 'file',
'file_key': content.get('file_key', ''),
'file_name': content.get('file_name', 'file'),
}
]
if message.message_type == 'audio':
return [
{
'tag': 'audio',
'file_key': content.get('file_key', ''),
'duration': content.get('duration', 0),
}
]
return [{'tag': 'text', 'text': json.dumps(content, ensure_ascii=False), 'style': []}]
@staticmethod
def _split_text_mentions(text: str, mentions: list) -> list[dict]:
if not text:
return []
mention_by_key = {getattr(m, 'key', ''): m for m in mentions}
pattern = re.compile(r'@_user_\d+')
result: list[dict] = []
pos = 0
for match in pattern.finditer(text):
if match.start() > pos:
result.append({'tag': 'text', 'text': text[pos : match.start()], 'style': []})
mention = mention_by_key.get(match.group(0))
if mention:
result.append(
{
'tag': 'at',
'user_id': getattr(mention, 'id', None)
or getattr(mention, 'open_id', None)
or getattr(mention, 'user_id', None)
or getattr(mention, 'key', match.group(0)),
'user_name': getattr(mention, 'name', ''),
'style': [],
}
)
else:
result.append({'tag': 'text', 'text': match.group(0), 'style': []})
pos = match.end()
if pos < len(text):
result.append({'tag': 'text', 'text': text[pos:], 'style': []})
return result
@staticmethod
async def _download_resource(
api_client: lark_oapi.Client,
message_id: str,
file_key: str,
resource_type: str,
) -> dict:
if not file_key:
return {}
request = (
GetMessageResourceRequest.builder().message_id(message_id).file_key(file_key).type(resource_type).build()
)
response: GetMessageResourceResponse = await api_client.im.v1.message_resource.aget(request)
if not response.success():
return {}
data = response.file.read()
content_type = response.raw.headers.get('content-type', 'application/octet-stream')
base64_data = base64.b64encode(data).decode()
ext = mimetypes.guess_extension(content_type.split(';')[0].strip()) or '.bin'
temp_path = os.path.join(tempfile.gettempdir(), f'lark_{file_key}{ext}')
with open(temp_path, 'wb') as f:
f.write(data)
return {
'url': f'file://{temp_path}',
'path': temp_path,
'base64': f'data:{content_type};base64,{base64_data}',
'size': len(data),
}
@staticmethod
def _message_time(message: EventMessage) -> datetime.datetime:
value = getattr(message, 'create_time', None)
if isinstance(value, datetime.datetime):
return value
if isinstance(value, (int, float, str)):
try:
timestamp = float(value)
if timestamp > 10_000_000_000:
timestamp = timestamp / 1000
return datetime.datetime.fromtimestamp(timestamp)
except ValueError:
pass
return datetime.datetime.now()
@@ -0,0 +1,96 @@
from __future__ import annotations
import json
from lark_oapi.api.im.v1 import GetChatRequest, GetMessageRequest, GetMessageResourceRequest
async def check_tenant_access_token(adapter, params: dict) -> dict:
tenant_key = params.get('tenant_key') or getattr(adapter, 'lark_tenant_key', None)
token = adapter.get_tenant_access_token(tenant_key)
return {'ok': bool(token) or adapter.config.get('app_type', 'self') != 'isv'}
async def refresh_app_access_token(adapter, params: dict) -> dict:
adapter.app_access_token = None
adapter.app_access_token_expire_at = None
token = adapter.get_app_access_token()
return {'ok': bool(token) or adapter.config.get('app_type', 'self') != 'isv'}
async def refresh_tenant_access_token(adapter, params: dict) -> dict:
tenant_key = params.get('tenant_key') or getattr(adapter, 'lark_tenant_key', None)
if tenant_key:
adapter.tenant_access_tokens.pop(tenant_key, None)
token = adapter.get_tenant_access_token(tenant_key)
return {'ok': bool(token) or adapter.config.get('app_type', 'self') != 'isv'}
async def get_chat(adapter, params: dict) -> dict:
request = GetChatRequest.builder().chat_id(params['chat_id']).build()
response = await adapter.api_client.im.v1.chat.aget(request, adapter.request_option(params.get('tenant_key')))
return _response_to_dict(response)
async def get_message(adapter, params: dict) -> dict:
request = GetMessageRequest.builder().message_id(params['message_id']).build()
response = await adapter.api_client.im.v1.message.aget(request, adapter.request_option(params.get('tenant_key')))
return _response_to_dict(response)
async def get_message_resource(adapter, params: dict) -> dict:
request = (
GetMessageResourceRequest.builder()
.message_id(params['message_id'])
.file_key(params['file_key'])
.type(params.get('type', 'file'))
.build()
)
response = await adapter.api_client.im.v1.message_resource.aget(
request, adapter.request_option(params.get('tenant_key'))
)
if not response.success():
return _response_to_dict(response)
content_type = response.raw.headers.get('content-type', 'application/octet-stream')
data = response.file.read()
return {'ok': True, 'content_type': content_type, 'size': len(data)}
def _response_to_dict(response) -> dict:
if not response.success():
return {'ok': False, 'code': response.code, 'msg': response.msg, 'log_id': response.get_log_id()}
data = getattr(response, 'data', None)
if hasattr(data, 'to_json'):
data = data.to_json()
return {'ok': True, 'data': _jsonable(data)}
def _jsonable(value):
if value is None or isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, bytes):
return {'bytes': len(value)}
if isinstance(value, (list, tuple, set)):
return [_jsonable(item) for item in value]
if isinstance(value, dict):
return {str(key): _jsonable(item) for key, item in value.items()}
if isinstance(value, str):
return value
try:
return json.loads(value)
except Exception:
pass
raw = getattr(value, '__dict__', None)
if raw:
return {key: _jsonable(item) for key, item in raw.items() if not key.startswith('_')}
return str(value)
PLATFORM_API_MAP = {
'check_tenant_access_token': check_tenant_access_token,
'refresh_app_access_token': refresh_app_access_token,
'refresh_tenant_access_token': refresh_tenant_access_token,
'get_chat': get_chat,
'get_message': get_message,
'get_message_resource': get_message_resource,
}
@@ -0,0 +1,3 @@
from __future__ import annotations
ADAPTER_NAME = 'lark-eba'
@@ -0,0 +1,5 @@
from __future__ import annotations
from langbot.pkg.platform.adapters.officialaccount.adapter import OfficialAccountAdapter
__all__ = ['OfficialAccountAdapter']
@@ -0,0 +1,195 @@
from __future__ import annotations
import asyncio
import traceback
import typing
import pydantic
from langbot.libs.official_account_api.api import OAClient, OAClientForLongerResponse
from langbot.libs.official_account_api.oaevent import OAEvent
from langbot.pkg.platform.adapters.officialaccount.api_impl import OfficialAccountAPIMixin
from langbot.pkg.platform.adapters.officialaccount.event_converter import OfficialAccountEventConverter
from langbot.pkg.platform.adapters.officialaccount.errors import NotSupportedError
from langbot.pkg.platform.adapters.officialaccount.message_converter import OfficialAccountMessageConverter
from langbot.pkg.platform.adapters.officialaccount.platform_api import PLATFORM_API_MAP
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
from langbot_plugin.api.entities.builtin.platform import message as platform_message
class OfficialAccountAdapter(OfficialAccountAPIMixin, abstract_platform_adapter.AbstractPlatformAdapter):
bot: typing.Any = pydantic.Field(exclude=True)
message_converter: OfficialAccountMessageConverter = OfficialAccountMessageConverter()
event_converter: OfficialAccountEventConverter = OfficialAccountEventConverter()
config: dict
bot_uuid: str | None = None
listeners: dict[
typing.Type[platform_events.Event],
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
] = {}
_message_cache: dict[str, platform_events.MessageReceivedEvent] = {}
_user_cache: dict[str, platform_entities.User] = {}
class Config:
arbitrary_types_allowed = True
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
required_keys = ['token', 'EncodingAESKey', 'AppSecret', 'AppID', 'Mode']
missing_keys = [key for key in required_keys if not config.get(key)]
if missing_keys:
raise Exception(f'OfficialAccount EBA adapter missing config: {missing_keys}')
mode = config['Mode']
common_kwargs = {
'token': config['token'],
'EncodingAESKey': config['EncodingAESKey'],
'Appsecret': config['AppSecret'],
'AppID': config['AppID'],
'logger': logger,
'unified_mode': True,
'api_base_url': config.get('api_base_url', 'https://api.weixin.qq.com'),
}
if mode == 'drop':
bot = OAClient(**common_kwargs)
elif mode == 'passive':
bot = OAClientForLongerResponse(
**common_kwargs,
LoadingMessage=config.get('LoadingMessage', ''),
)
else:
raise KeyError('OfficialAccount Mode must be "drop" or "passive"')
super().__init__(
config=config,
logger=logger,
bot=bot,
bot_account_id=config.get('AppID', ''),
bot_uuid=None,
listeners={},
_message_cache={},
_user_cache={},
)
self._register_native_handlers()
def set_bot_uuid(self, bot_uuid: str):
self.bot_uuid = bot_uuid
def get_supported_events(self) -> list[str]:
return [
'message.received',
'platform.specific',
]
def get_supported_apis(self) -> list[str]:
return [
'reply_message',
'get_message',
'get_user_info',
'get_friend_list',
'call_platform_api',
]
async def send_message(
self,
target_type: str,
target_id: str,
message: platform_message.MessageChain,
) -> platform_events.MessageResult:
raise NotSupportedError('send_message:official_account_requires_inbound_webhook_reply')
async def reply_message(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
quote_origin: bool = False,
) -> platform_events.MessageResult:
source = await OfficialAccountEventConverter.yiri2target(message_source)
if not isinstance(source, OAEvent):
raise ValueError('OfficialAccount reply_message requires an OAEvent source object')
content = await OfficialAccountMessageConverter.yiri2target(message)
if self.config.get('Mode') == 'passive':
await self.bot.set_message(source.user_id, source.message_id, content)
else:
await self.bot.set_message(source.message_id, content)
return platform_events.MessageResult(message_id=source.message_id, raw={'queued': True})
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
handler = PLATFORM_API_MAP.get(action)
if handler is None:
raise NotSupportedError(f'call_platform_api:{action}')
params = dict(params or {})
params.setdefault('mode', self.config.get('Mode'))
return await handler(self.bot, params)
def register_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable[
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
],
):
self.listeners[event_type] = callback
def unregister_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable[
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
],
):
registered = self.listeners.get(event_type)
if registered is callback:
self.listeners.pop(event_type, None)
async def handle_unified_webhook(self, bot_uuid: str, path: str, request):
return await self.bot.handle_unified_webhook(request)
async def run_async(self):
async def keep_alive():
while True:
await asyncio.sleep(1)
await self.logger.info('OfficialAccount EBA adapter running in unified webhook mode')
await keep_alive()
async def kill(self) -> bool:
return True
async def is_muted(self, group_id: int | None = None) -> bool:
return False
def _register_native_handlers(self):
for msg_type in ('text', 'image', 'voice', 'event'):
self.bot.on_message(msg_type)(self._handle_native_event)
async def _handle_native_event(self, event: OAEvent):
self.bot_account_id = event.receiver_id or self.bot_account_id
try:
if platform_events.FriendMessage in self.listeners:
legacy_event = await self.event_converter.target2legacy(event)
if legacy_event and platform_events.FriendMessage in self.listeners:
await self.listeners[platform_events.FriendMessage](legacy_event, self)
eba_event = await self.event_converter.target2yiri(event)
if eba_event:
self._cache_event(eba_event)
await self._dispatch_eba_event(eba_event)
except Exception:
await self.logger.error(f'Error in officialaccount native event: {traceback.format_exc()}')
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
callback = self.listeners.get(event_type)
if callback:
await callback(event, self)
return
def _cache_event(self, event: platform_events.Event):
if isinstance(event, platform_events.MessageReceivedEvent):
self._message_cache[str(event.message_id)] = event
self._user_cache[str(event.sender.id)] = event.sender
@@ -0,0 +1,85 @@
from __future__ import annotations
import typing
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
from langbot_plugin.api.entities.builtin.platform import message as platform_message
from langbot.pkg.platform.adapters.officialaccount.errors import NotSupportedError
class OfficialAccountAPIMixin:
_message_cache: dict[str, platform_events.MessageReceivedEvent]
_user_cache: dict[str, platform_entities.User]
async def get_message(
self,
chat_type: str,
chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
) -> platform_events.MessageReceivedEvent:
event = self._message_cache.get(str(message_id))
if event is None:
raise NotSupportedError('get_message:message_not_cached')
return event
async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User:
user = self._user_cache.get(str(user_id))
if user is None:
raise NotSupportedError('get_user_info:not_cached')
return user
async def get_friend_list(self) -> list[platform_entities.User]:
return list(self._user_cache.values())
async def edit_message(
self,
chat_type: str,
chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
new_content: platform_message.MessageChain,
) -> None:
raise NotSupportedError('edit_message')
async def delete_message(
self,
chat_type: str,
chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
) -> None:
raise NotSupportedError('delete_message')
async def forward_message(
self,
from_chat_type: str,
from_chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
to_chat_type: str,
to_chat_id: typing.Union[int, str],
) -> platform_events.MessageResult:
raise NotSupportedError('forward_message')
async def upload_file(self, file_data: bytes, filename: str) -> str:
raise NotSupportedError('upload_file')
async def get_file_url(self, file_id: str) -> str:
raise NotSupportedError('get_file_url')
async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup:
raise NotSupportedError('get_group_info')
async def get_group_list(self) -> list[platform_entities.UserGroup]:
raise NotSupportedError('get_group_list')
async def get_group_member_list(
self,
group_id: typing.Union[int, str],
) -> list[platform_entities.UserGroupMember]:
raise NotSupportedError('get_group_member_list')
async def get_group_member_info(
self,
group_id: typing.Union[int, str],
user_id: typing.Union[int, str],
) -> platform_entities.UserGroupMember:
raise NotSupportedError('get_group_member_info')
@@ -0,0 +1,10 @@
from __future__ import annotations
try:
from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError
except ModuleNotFoundError:
class NotSupportedError(Exception):
def __init__(self, api_name: str, *args):
super().__init__(f"API '{api_name}' is not supported by this adapter", *args)
self.api_name = api_name
@@ -0,0 +1,66 @@
from __future__ import annotations
import time
import typing
from langbot.libs.official_account_api.oaevent import OAEvent
from langbot.pkg.platform.adapters.officialaccount.message_converter import OfficialAccountMessageConverter
from langbot.pkg.platform.adapters.officialaccount.types import ADAPTER_NAME
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
class OfficialAccountEventConverter(abstract_platform_adapter.AbstractEventConverter):
@staticmethod
async def yiri2target(event: platform_events.Event) -> typing.Any:
return getattr(event, 'source_platform_object', None)
async def target2legacy(self, event: OAEvent) -> platform_events.FriendMessage | None:
eba_event = await self.target2yiri(event)
if not isinstance(eba_event, platform_events.MessageReceivedEvent):
return None
return platform_events.FriendMessage(
sender=platform_entities.Friend(
id=eba_event.sender.id,
nickname=eba_event.sender.nickname,
remark='',
),
message_chain=eba_event.message_chain,
time=eba_event.timestamp,
source_platform_object=event,
)
async def target2yiri(self, event: OAEvent) -> platform_events.Event | None:
if event.type in {'text', 'image', 'voice'}:
return await self.message_to_eba(event)
return self.platform_specific(event, f'officialaccount.{event.detail_type or event.type or "unknown"}')
async def message_to_eba(self, event: OAEvent) -> platform_events.MessageReceivedEvent:
sender_id = event.user_id or ''
timestamp = float(event.timestamp or time.time())
return platform_events.MessageReceivedEvent(
type='message.received',
adapter_name=ADAPTER_NAME,
message_id=event.message_id or f'{sender_id}:{int(timestamp)}',
message_chain=await OfficialAccountMessageConverter.target2yiri(event),
sender=platform_entities.User(
id=sender_id,
nickname=sender_id,
),
chat_type=platform_entities.ChatType.PRIVATE,
chat_id=sender_id,
timestamp=timestamp,
source_platform_object=event,
)
@staticmethod
def platform_specific(event: OAEvent, action: str) -> platform_events.PlatformSpecificEvent:
return platform_events.PlatformSpecificEvent(
type='platform.specific',
adapter_name=ADAPTER_NAME,
action=action,
data=dict(event),
timestamp=float(event.timestamp or time.time()),
source_platform_object=event,
)
@@ -0,0 +1,123 @@
apiVersion: v1
kind: MessagePlatformAdapter
metadata:
name: officialaccount-eba
label:
en_US: Official Account (EBA)
zh_Hans: 微信公众号 (EBA)
zh_Hant: 微信公眾號 (EBA)
description:
en_US: WeChat Official Account adapter with Event-Based Agents support
zh_Hans: 微信公众号适配器(EBA 架构版本),通过统一 Webhook 接收公众号消息
zh_Hant: 微信公眾號適配器(EBA 架構版本),透過統一 Webhook 接收公眾號訊息
icon: officialaccount.png
spec:
categories:
- china
help_links:
zh: https://link.langbot.app/zh/platforms/officialaccount
en: https://link.langbot.app/en/platforms/officialaccount
ja: https://link.langbot.app/ja/platforms/officialaccount
config:
- name: webhook_url
label:
en_US: Webhook Callback URL
zh_Hans: Webhook 回调地址
zh_Hant: Webhook 回調地址
description:
en_US: Copy this URL and paste it into your Official Account webhook configuration.
zh_Hans: 复制此地址并粘贴到微信公众号的 Webhook 配置中。
zh_Hant: 複製此地址並貼到微信公眾號的 Webhook 設定中。
type: webhook-url
required: false
default: ""
- name: token
label:
en_US: Token
zh_Hans: 令牌
zh_Hant: 令牌
type: string
required: true
default: ""
- name: EncodingAESKey
label:
en_US: EncodingAESKey
zh_Hans: 消息加解密密钥
zh_Hant: 訊息加解密密鑰
type: string
required: true
default: ""
- name: AppID
label:
en_US: App ID
zh_Hans: 应用 ID
zh_Hant: 應用 ID
type: string
required: true
default: ""
- name: AppSecret
label:
en_US: App Secret
zh_Hans: 应用密钥
zh_Hant: 應用密鑰
type: string
required: true
default: ""
- name: Mode
label:
en_US: Mode
zh_Hans: 接入模式
zh_Hant: 接入模式
description:
en_US: "drop replies within the current callback; passive returns a loading message first and queues the real reply for the user's next message."
zh_Hans: "drop 会在当前回调内等待回复;passive 会先返回加载提示,并将真实回复排队到用户下一条消息。"
zh_Hant: "drop 會在目前回調內等待回覆;passive 會先回傳載入提示,並將真實回覆排隊到使用者下一則訊息。"
type: string
required: true
default: "drop"
- name: LoadingMessage
label:
en_US: Loading Message
zh_Hans: 加载消息
zh_Hant: 載入訊息
type: string
required: false
default: "AI正在思考中,请发送任意内容获取回复。"
- name: api_base_url
label:
en_US: API Base URL
zh_Hans: API 基础 URL
zh_Hant: API 基礎 URL
description:
en_US: Optional Official Account API base URL, useful when routing through a reverse proxy.
zh_Hans: 可选,若通过反向代理访问微信公众号 API,可修改此项。
zh_Hant: 可選,若透過反向代理存取微信公眾號 API,可修改此項。
type: string
required: false
default: "https://api.weixin.qq.com"
supported_events:
- message.received
- platform.specific
supported_apis:
required:
- reply_message
optional:
- get_message
- get_user_info
- get_friend_list
- call_platform_api
platform_specific_apis:
- action: get_mode
description: { en_US: "Return the configured Official Account reply mode", zh_Hans: "返回当前微信公众号回复模式" }
- action: get_cached_response_status
description: { en_US: "Inspect cached passive/drop reply state for diagnostics", zh_Hans: "查看被动回复缓存状态,用于诊断" }
execution:
python:
path: ./adapter.py
attr: OfficialAccountAdapter
@@ -0,0 +1,72 @@
from __future__ import annotations
import datetime
from langbot.libs.official_account_api.oaevent import OAEvent
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
from langbot_plugin.api.entities.builtin.platform import message as platform_message
class OfficialAccountMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
@staticmethod
async def yiri2target(message_chain: platform_message.MessageChain) -> str:
content_parts: list[str] = []
for component in message_chain:
if isinstance(component, platform_message.Source):
continue
if isinstance(component, platform_message.Plain):
content_parts.append(component.text)
elif isinstance(component, platform_message.At):
content_parts.append(f'@{component.display or component.target}')
elif isinstance(component, platform_message.AtAll):
content_parts.append('@all')
elif isinstance(component, platform_message.Image):
content_parts.append('[Image]')
elif isinstance(component, platform_message.Voice):
content_parts.append('[Voice]')
elif isinstance(component, platform_message.File):
content_parts.append(f'[File: {component.name or component.id or component.url or "file"}]')
elif isinstance(component, platform_message.Quote):
if component.id is not None:
content_parts.append(f'[Quote {component.id}]')
if component.origin:
content_parts.append(await OfficialAccountMessageConverter.yiri2target(component.origin))
elif isinstance(component, platform_message.Forward):
for node in component.node_list:
if node.message_chain:
content_parts.append(await OfficialAccountMessageConverter.yiri2target(node.message_chain))
else:
content_parts.append(str(component))
return '\n'.join(part for part in content_parts if part)
@staticmethod
async def target2yiri(event: OAEvent) -> platform_message.MessageChain:
timestamp = event.timestamp or int(datetime.datetime.now().timestamp())
components: list[platform_message.MessageComponent] = [
platform_message.Source(
id=event.message_id or f'{event.user_id}:{timestamp}',
time=datetime.datetime.fromtimestamp(timestamp),
)
]
if event.type == 'text' and event.message:
components.append(platform_message.Plain(text=event.message))
elif event.type == 'image':
image_kwargs = {}
if event.picurl:
image_kwargs['url'] = event.picurl
if event.media_id:
image_kwargs['image_id'] = event.media_id
if image_kwargs:
components.append(platform_message.Image(**image_kwargs))
elif event.type == 'voice':
if event.media_id:
components.append(platform_message.Voice(voice_id=event.media_id))
else:
components.append(platform_message.Unknown(text='[officialaccount voice message without media id]'))
elif event.type == 'event':
components.append(platform_message.Unknown(text=f'[officialaccount event: {event.detail_type or "unknown"}]'))
else:
components.append(platform_message.Unknown(text=f'[unsupported officialaccount msgtype: {event.type or "unknown"}]'))
return platform_message.MessageChain(components)
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -0,0 +1,27 @@
from __future__ import annotations
import typing
async def get_mode(bot, params: dict) -> dict:
return {
'mode': params.get('mode') or ('passive' if hasattr(bot, 'msg_queue') else 'drop'),
'longer_response': hasattr(bot, 'msg_queue'),
}
async def get_cached_response_status(bot, params: dict) -> dict:
message_id = params.get('message_id') or params.get('msg_id')
user_id = params.get('user_id') or params.get('from_user')
if hasattr(bot, 'generated_content'):
return {'pending': str(message_id) in {str(key) for key in bot.generated_content}}
if hasattr(bot, 'msg_queue'):
queue = bot.msg_queue.get(str(user_id), []) if user_id is not None else []
return {'queued': len(queue)}
return {'pending': False}
PLATFORM_API_MAP: dict[str, typing.Callable[[typing.Any, dict], typing.Awaitable[dict]]] = {
'get_mode': get_mode,
'get_cached_response_status': get_cached_response_status,
}
@@ -0,0 +1,3 @@
from __future__ import annotations
ADAPTER_NAME = 'officialaccount-eba'
@@ -0,0 +1,6 @@
"""QQ Official API EBA platform adapter."""
from langbot.pkg.platform.adapters.qqofficial.adapter import QQOfficialAdapter
__all__ = ['QQOfficialAdapter']
@@ -0,0 +1,400 @@
from __future__ import annotations
import asyncio
import time
import traceback
import typing
import pydantic
from langbot.libs.qq_official_api.api import QQOfficialClient
from langbot.libs.qq_official_api.qqofficialevent import QQOfficialEvent
from langbot.pkg.platform.adapters.qqofficial.api_impl import QQOfficialAPIMixin
from langbot.pkg.platform.adapters.qqofficial.errors import NotSupportedError
from langbot.pkg.platform.adapters.qqofficial.event_converter import QQOfficialEventConverter
from langbot.pkg.platform.adapters.qqofficial.message_converter import QQOfficialMessageConverter
from langbot.pkg.platform.adapters.qqofficial.platform_api import PLATFORM_API_MAP
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
from langbot_plugin.api.entities.builtin.platform import message as platform_message
class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPlatformAdapter):
bot: typing.Any = pydantic.Field(exclude=True)
message_converter: QQOfficialMessageConverter = QQOfficialMessageConverter()
event_converter: QQOfficialEventConverter = QQOfficialEventConverter()
config: dict
bot_uuid: str | None = None
enable_webhook: bool = False
listeners: dict[
typing.Type[platform_events.Event],
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
] = {}
_message_cache: dict[str, platform_events.MessageReceivedEvent] = {}
_user_cache: dict[str, platform_entities.User] = {}
_group_cache: dict[str, platform_entities.UserGroup] = {}
_member_cache: dict[tuple[str, str], platform_entities.UserGroupMember] = {}
_stream_ctx: dict[str, dict] = {}
_stream_ctx_ts: dict[str, float] = {}
_fallback_text: dict[str, str] = {}
_fallback_text_ts: dict[str, float] = {}
_ws_task: asyncio.Task | None = None
_STREAM_CTX_TTL = 300
class Config:
arbitrary_types_allowed = True
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
required_keys = ['appid', 'secret', 'token']
missing_keys = [key for key in required_keys if not config.get(key)]
if missing_keys:
raise Exception(f'QQOfficial EBA adapter missing config: {missing_keys}')
enable_webhook = config.get('enable-webhook', config.get('enable_webhook', False))
bot = QQOfficialClient(
app_id=config['appid'],
secret=config['secret'],
token=config['token'],
logger=logger,
unified_mode=enable_webhook,
)
super().__init__(
config=config,
logger=logger,
bot=bot,
bot_account_id=config['appid'],
bot_uuid=None,
enable_webhook=enable_webhook,
listeners={},
_message_cache={},
_user_cache={},
_group_cache={},
_member_cache={},
_stream_ctx={},
_stream_ctx_ts={},
_fallback_text={},
_fallback_text_ts={},
_ws_task=None,
)
self._register_native_handlers()
def set_bot_uuid(self, bot_uuid: str):
self.bot_uuid = bot_uuid
def get_supported_events(self) -> list[str]:
return [
'message.received',
'platform.specific',
]
def get_supported_apis(self) -> list[str]:
return [
'send_message',
'reply_message',
'get_message',
'get_user_info',
'get_friend_list',
'get_group_info',
'get_group_member_list',
'get_group_member_info',
'call_platform_api',
]
async def send_message(
self,
target_type: str,
target_id: str,
message: platform_message.MessageChain,
) -> platform_events.MessageResult:
raw = await self._send_content_list(str(target_type), str(target_id), await QQOfficialMessageConverter.yiri2target(message))
return platform_events.MessageResult(raw={'results': raw})
async def reply_message(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
quote_origin: bool = False,
) -> platform_events.MessageResult:
source = await QQOfficialEventConverter.yiri2target(message_source)
if not isinstance(source, QQOfficialEvent):
raise ValueError('QQOfficial reply_message requires a QQOfficialEvent source object')
target_type, target_id = self._reply_target(source)
raw = await self._send_content_list(
target_type,
target_id,
await QQOfficialMessageConverter.yiri2target(message),
msg_id=source.d_id,
)
return platform_events.MessageResult(message_id=source.d_id or source.id, raw={'results': raw})
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
handler = PLATFORM_API_MAP.get(action)
if handler is None:
raise NotSupportedError(f'call_platform_api:{action}')
return await handler(self, dict(params or {}))
def register_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable[
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
],
):
self.listeners[event_type] = callback
def unregister_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable[
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
],
):
registered = self.listeners.get(event_type)
if registered is callback:
self.listeners.pop(event_type, None)
async def handle_unified_webhook(self, bot_uuid: str, path: str, request):
return await self.bot.handle_unified_webhook(request)
async def run_async(self):
if self.enable_webhook:
await self.logger.info('QQ Official EBA adapter running in unified webhook mode')
while True:
await asyncio.sleep(1)
else:
await self._run_websocket()
async def kill(self) -> bool:
if self._ws_task:
self._ws_task.cancel()
try:
await self._ws_task
except asyncio.CancelledError:
pass
self._ws_task = None
return True
async def is_muted(self, group_id: int | None = None) -> bool:
return False
async def is_stream_output_supported(self) -> bool:
return bool(self.config.get('enable-stream-reply') or self.config.get('enable_stream_reply'))
async def create_message_card(self, message_id: str, event: platform_events.MessageEvent) -> bool:
source = event.source_platform_object
if not isinstance(source, QQOfficialEvent) or source.t != 'C2C_MESSAGE_CREATE':
return False
self._stream_ctx[message_id] = {
'user_openid': source.user_openid,
'msg_id': source.d_id,
'stream_msg_id': None,
'msg_seq': 1,
'index': 0,
'last_update_ts': 0,
'accumulated_text': '',
'sent_length': 0,
'session_started': False,
}
self._stream_ctx_ts[message_id] = time.time()
return True
async def reply_message_chunk(
self,
message_source: platform_events.MessageEvent,
bot_message: dict,
message: platform_message.MessageChain,
quote_origin: bool = False,
is_final: bool = False,
):
await self._cleanup_stale_streams()
chunk_text = '\n\n'.join(component.text for component in message if isinstance(component, platform_message.Plain))
message_id = bot_message.get('resp_message_id') if isinstance(bot_message, dict) else getattr(bot_message, 'resp_message_id', None)
if not message_id or message_id not in self._stream_ctx:
if chunk_text:
self._fallback_text[message_id] = self._fallback_text.get(message_id, '') + chunk_text
self._fallback_text_ts[message_id] = time.time()
if is_final:
full_text = self._fallback_text.pop(message_id, '')
if full_text:
await self.reply_message(message_source, platform_message.MessageChain([platform_message.Plain(text=full_text)]), quote_origin)
return
ctx = self._stream_ctx[message_id]
if chunk_text:
ctx['accumulated_text'] += chunk_text
if not ctx['session_started']:
if not ctx['accumulated_text']:
return
ctx['session_started'] = True
content_to_send = ctx['accumulated_text'][ctx['sent_length'] :]
if not content_to_send and not is_final:
return
now = time.time()
if not is_final and (now - ctx['last_update_ts']) < 0.5:
return
ctx['last_update_ts'] = now
resp = await self.bot.send_stream_msg(
user_openid=ctx['user_openid'],
content=content_to_send,
event_id=ctx['msg_id'],
msg_id=ctx['msg_id'],
msg_seq=ctx['msg_seq'],
index=ctx['index'],
stream_msg_id=ctx['stream_msg_id'],
input_state=10 if is_final else 1,
)
if isinstance(resp, dict) and resp.get('id'):
ctx['stream_msg_id'] = resp['id']
ctx['sent_length'] = len(ctx['accumulated_text'])
ctx['index'] += 1
if is_final:
self._stream_ctx.pop(message_id, None)
self._stream_ctx_ts.pop(message_id, None)
def _register_native_handlers(self):
for event_type in ('C2C_MESSAGE_CREATE', 'DIRECT_MESSAGE_CREATE', 'GROUP_AT_MESSAGE_CREATE', 'AT_MESSAGE_CREATE'):
self.bot.on_message(event_type)(self._handle_native_event)
async def _handle_native_event(self, event: QQOfficialEvent):
self.bot_account_id = self.config.get('appid', self.bot_account_id)
try:
if platform_events.FriendMessage in self.listeners or platform_events.GroupMessage in self.listeners:
legacy_event = await self.event_converter.target2legacy(event)
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)
if eba_event:
self._cache_event(eba_event)
await self._dispatch_eba_event(eba_event)
except Exception:
await self.logger.error(f'Error in qqofficial native event: {traceback.format_exc()}')
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
callback = self.listeners.get(event_type)
if callback:
await callback(event, self)
return
def _cache_event(self, event: platform_events.Event):
if not isinstance(event, platform_events.MessageReceivedEvent):
return
self._message_cache[str(event.message_id)] = event
self._user_cache[str(event.sender.id)] = event.sender
if event.group:
self._group_cache[str(event.group.id)] = event.group
self._member_cache[(str(event.group.id), str(event.sender.id))] = platform_entities.UserGroupMember(
user=event.sender,
group_id=event.group.id,
role=platform_entities.MemberRole.MEMBER,
display_name=event.sender.nickname,
)
async def _run_websocket(self):
await self.logger.info('QQ Official EBA adapter starting in WebSocket mode')
async def on_ready():
await self.logger.info('QQ Official WebSocket connected and ready')
async def on_event(event_type: str, event_data: dict):
if event_type not in {'C2C_MESSAGE_CREATE', 'DIRECT_MESSAGE_CREATE', 'GROUP_AT_MESSAGE_CREATE', 'AT_MESSAGE_CREATE'}:
await self._dispatch_eba_event(QQOfficialEventConverter.platform_specific(QQOfficialEvent({'t': event_type, **(event_data or {})}), f'qqofficial.{event_type}'))
return
if not isinstance(event_data, dict):
await self.logger.warning(f'Event data is not dict, skipping: {event_type} -> {type(event_data)}')
return
payload = {'t': event_type, 'd': event_data}
message_data = await self.bot.get_message(payload)
if message_data:
await self.bot._handle_message(QQOfficialEvent.from_payload(message_data))
async def on_error(error: Exception):
await self.logger.error(f'QQ Official WebSocket error: {error}')
self._ws_task = asyncio.create_task(self.bot.connect_gateway_loop(on_event, on_ready, on_error))
try:
await self._ws_task
except asyncio.CancelledError:
pass
@staticmethod
def _reply_target(event: QQOfficialEvent) -> tuple[str, str]:
if event.t == 'C2C_MESSAGE_CREATE':
return 'person', event.user_openid
if event.t == 'GROUP_AT_MESSAGE_CREATE':
return 'group', event.group_openid
if event.t == 'AT_MESSAGE_CREATE':
return 'channel', event.channel_id
if event.t == 'DIRECT_MESSAGE_CREATE':
return 'channel_private', event.guild_id
raise NotSupportedError(f'reply_message:{event.t or "unknown_event"}')
async def _send_content_list(self, target_type: str, target_id: str, content_list: list[dict], msg_id: str | None = None) -> list[dict]:
target_type = self._normalize_target_type(target_type)
results: list[dict] = []
for content in content_list:
content_type = content.get('type', 'text')
if target_type == 'channel':
if content_type == 'text':
raw = await self.bot.send_channle_group_text_msg(target_id, content.get('content', ''), msg_id)
results.append({'type': content_type, 'raw': raw})
continue
if target_type == 'channel_private':
if content_type == 'text':
raw = await self.bot.send_channle_private_text_msg(target_id, content.get('content', ''), msg_id)
results.append({'type': content_type, 'raw': raw})
continue
if content_type == 'text':
if target_type == 'c2c':
raw = await self.bot.send_private_text_msg(target_id, content.get('content', ''), msg_id)
elif target_type == 'group':
raw = await self.bot.send_group_text_msg(target_id, content.get('content', ''), msg_id)
else:
raise NotSupportedError(f'send_message:{target_type}')
results.append({'type': content_type, 'raw': raw})
elif content_type == 'image':
raw = await self.bot.send_image_msg(target_type, target_id, file_url=content.get('url'), file_data=content.get('base64'), msg_id=msg_id)
results.append({'type': content_type, 'raw': raw})
elif content_type == 'voice':
raw = await self.bot.send_voice_msg(target_type, target_id, file_url=content.get('url'), file_data=content.get('base64'), msg_id=msg_id)
results.append({'type': content_type, 'raw': raw})
elif content_type == 'file':
raw = await self.bot.send_file_msg(
target_type,
target_id,
file_url=content.get('url'),
file_data=content.get('base64'),
file_name=content.get('name', 'file'),
msg_id=msg_id,
)
results.append({'type': content_type, 'raw': raw})
return results
@staticmethod
def _normalize_target_type(target_type: str) -> str:
if target_type in {'person', 'private', 'friend', 'c2c'}:
return 'c2c'
if target_type in {'group', 'group_openid'}:
return 'group'
if target_type in {'channel', 'guild'}:
return 'channel'
if target_type in {'channel_private', 'direct', 'dm'}:
return 'channel_private'
return target_type
async def _cleanup_stale_streams(self):
now = time.time()
for message_id in [key for key, ts in self._stream_ctx_ts.items() if now - ts > self._STREAM_CTX_TTL]:
self._stream_ctx.pop(message_id, None)
self._stream_ctx_ts.pop(message_id, None)
for message_id in [key for key, ts in self._fallback_text_ts.items() if now - ts > self._STREAM_CTX_TTL]:
self._fallback_text.pop(message_id, None)
self._fallback_text_ts.pop(message_id, None)
@@ -0,0 +1,103 @@
from __future__ import annotations
import typing
from langbot.pkg.platform.adapters.qqofficial.errors import NotSupportedError
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
from langbot_plugin.api.entities.builtin.platform import message as platform_message
class QQOfficialAPIMixin:
_message_cache: dict[str, platform_events.MessageReceivedEvent]
_user_cache: dict[str, platform_entities.User]
_group_cache: dict[str, platform_entities.UserGroup]
_member_cache: dict[tuple[str, str], platform_entities.UserGroupMember]
async def get_message(
self,
chat_type: str,
chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
) -> platform_events.MessageReceivedEvent:
event = self._message_cache.get(str(message_id))
if event is None:
raise NotSupportedError('get_message:message_not_cached')
return event
async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User:
user = self._user_cache.get(str(user_id))
if user is None:
raise NotSupportedError('get_user_info:not_cached')
return user
async def get_friend_list(self) -> list[platform_entities.User]:
return list(self._user_cache.values())
async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup:
group = self._group_cache.get(str(group_id))
if group is None:
raise NotSupportedError('get_group_info:not_cached')
return group
async def get_group_member_info(
self,
group_id: typing.Union[int, str],
user_id: typing.Union[int, str],
) -> platform_entities.UserGroupMember:
member = self._member_cache.get((str(group_id), str(user_id)))
if member is None:
raise NotSupportedError('get_group_member_info:not_cached')
return member
async def get_group_member_list(
self,
group_id: typing.Union[int, str],
) -> list[platform_entities.UserGroupMember]:
return [member for (cached_group_id, _), member in self._member_cache.items() if cached_group_id == str(group_id)]
async def edit_message(
self,
chat_type: str,
chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
new_content: platform_message.MessageChain,
) -> None:
raise NotSupportedError('edit_message')
async def delete_message(
self,
chat_type: str,
chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
) -> None:
raise NotSupportedError('delete_message')
async def forward_message(
self,
from_chat_type: str,
from_chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
to_chat_type: str,
to_chat_id: typing.Union[int, str],
) -> platform_events.MessageResult:
raise NotSupportedError('forward_message')
async def upload_file(self, file_data: bytes, filename: str) -> str:
raise NotSupportedError('upload_file')
async def get_file_url(self, file_id: str) -> str:
raise NotSupportedError('get_file_url')
async def mute_member(self, group_id: typing.Union[int, str], user_id: typing.Union[int, str], duration: int = 0):
raise NotSupportedError('mute_member')
async def unmute_member(self, group_id: typing.Union[int, str], user_id: typing.Union[int, str]):
raise NotSupportedError('unmute_member')
async def kick_member(self, group_id: typing.Union[int, str], user_id: typing.Union[int, str]):
raise NotSupportedError('kick_member')
async def leave_group(self, group_id: typing.Union[int, str]):
raise NotSupportedError('leave_group')
@@ -0,0 +1,11 @@
from __future__ import annotations
try:
from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError
except ModuleNotFoundError:
class NotSupportedError(Exception):
def __init__(self, api_name: str, *args):
super().__init__(f"API '{api_name}' is not supported by this adapter", *args)
self.api_name = api_name
@@ -0,0 +1,116 @@
from __future__ import annotations
import datetime
import time
import typing
from langbot.libs.qq_official_api.qqofficialevent import QQOfficialEvent
from langbot.pkg.platform.adapters.qqofficial.message_converter import QQOfficialMessageConverter
from langbot.pkg.platform.adapters.qqofficial.types import ADAPTER_NAME
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
class QQOfficialEventConverter(abstract_platform_adapter.AbstractEventConverter):
@staticmethod
async def yiri2target(event: platform_events.Event) -> typing.Any:
return getattr(event, 'source_platform_object', None)
async def target2legacy(self, event: QQOfficialEvent) -> platform_events.FriendMessage | platform_events.GroupMessage | None:
eba_event = await self.target2yiri(event)
if not isinstance(eba_event, platform_events.MessageReceivedEvent):
return None
if eba_event.chat_type == platform_entities.ChatType.PRIVATE:
return platform_events.FriendMessage(
sender=platform_entities.Friend(
id=eba_event.sender.id,
nickname=eba_event.sender.nickname,
remark='',
),
message_chain=eba_event.message_chain,
time=eba_event.timestamp,
source_platform_object=event,
)
return platform_events.GroupMessage(
sender=platform_entities.GroupMember(
id=eba_event.sender.id,
member_name=eba_event.sender.nickname,
permission='MEMBER',
group=platform_entities.Group(
id=eba_event.group.id if eba_event.group else eba_event.chat_id,
name=eba_event.group.name if eba_event.group else '',
permission=platform_entities.Permission.Member,
),
special_title='',
),
message_chain=eba_event.message_chain,
time=eba_event.timestamp,
source_platform_object=event,
)
async def target2yiri(self, event: QQOfficialEvent) -> platform_events.Event:
if event.t in {'C2C_MESSAGE_CREATE', 'DIRECT_MESSAGE_CREATE', 'GROUP_AT_MESSAGE_CREATE', 'AT_MESSAGE_CREATE'}:
return await self.message_to_eba(event)
return self.platform_specific(event, f'qqofficial.{event.t or "unknown"}')
async def message_to_eba(self, event: QQOfficialEvent) -> platform_events.MessageReceivedEvent:
timestamp = _timestamp_value(event.timestamp)
sender = platform_entities.User(
id=self._sender_id(event),
nickname=event.username or self._sender_id(event),
)
chat_type = platform_entities.ChatType.PRIVATE
chat_id = self._private_chat_id(event)
group = None
if event.t in {'GROUP_AT_MESSAGE_CREATE', 'AT_MESSAGE_CREATE'}:
chat_type = platform_entities.ChatType.GROUP
chat_id = event.channel_id if event.t == 'AT_MESSAGE_CREATE' else event.group_openid
chat_id = chat_id or event.group_openid or event.channel_id or ''
group = platform_entities.UserGroup(id=str(chat_id), name=str(chat_id))
return platform_events.MessageReceivedEvent(
type='message.received',
adapter_name=ADAPTER_NAME,
message_id=event.d_id or event.id or '',
message_chain=await QQOfficialMessageConverter.target2yiri(event),
sender=sender,
chat_type=chat_type,
chat_id=chat_id or '',
group=group,
timestamp=timestamp,
source_platform_object=event,
)
@staticmethod
def _sender_id(event: QQOfficialEvent) -> str:
member_openid = event.member_openid or event.get('member_openid', '')
if event.t in {'GROUP_AT_MESSAGE_CREATE', 'AT_MESSAGE_CREATE'}:
return member_openid or event.user_openid or event.d_author_id or ''
return event.user_openid or member_openid or event.d_author_id or event.guild_id or event.group_openid or ''
@staticmethod
def _private_chat_id(event: QQOfficialEvent) -> str:
if event.t == 'DIRECT_MESSAGE_CREATE':
return event.guild_id or event.user_openid or ''
return event.user_openid or event.guild_id or ''
@staticmethod
def platform_specific(event: QQOfficialEvent, action: str) -> platform_events.PlatformSpecificEvent:
return platform_events.PlatformSpecificEvent(
type='platform.specific',
adapter_name=ADAPTER_NAME,
action=action,
data=dict(event),
timestamp=_timestamp_value(event.timestamp),
source_platform_object=event,
)
def _timestamp_value(value: str) -> float:
if not value:
return time.time()
try:
return float(datetime.datetime.strptime(value, '%Y-%m-%dT%H:%M:%S%z').timestamp())
except (TypeError, ValueError):
return time.time()
@@ -0,0 +1,120 @@
apiVersion: v1
kind: MessagePlatformAdapter
metadata:
name: qqofficial-eba
label:
en_US: QQ Official API (EBA)
zh_Hans: QQ 官方 API (EBA)
zh_Hant: QQ 官方 API (EBA)
description:
en_US: QQ Official API adapter with Event-Based Agents support, using Webhook or WebSocket mode.
zh_Hans: QQ 官方 API 适配器(EBA 架构版本),支持 Webhook 和 WebSocket 两种连接模式。
zh_Hant: QQ 官方 API 適配器(EBA 架構版本),支援 Webhook 和 WebSocket 兩種連線模式。
icon: qqofficial.svg
spec:
categories:
- china
help_links:
zh: https://link.langbot.app/zh/platforms/qqofficial
en: https://link.langbot.app/en/platforms/qqofficial
ja: https://link.langbot.app/ja/platforms/qqofficial
config:
- name: appid
label:
en_US: App ID
zh_Hans: 应用 ID
zh_Hant: 應用 ID
type: string
required: true
default: ""
- name: secret
label:
en_US: Secret
zh_Hans: 密钥
zh_Hant: 密鑰
type: string
required: true
default: ""
- name: token
label:
en_US: Token
zh_Hans: 令牌
zh_Hant: 令牌
type: string
required: true
default: ""
- name: enable-webhook
label:
en_US: Enable Webhook Mode
zh_Hans: 启用 Webhook 模式
zh_Hant: 啟用 Webhook 模式
description:
en_US: If enabled, the bot receives messages through LangBot's unified webhook endpoint. Otherwise it uses the QQ WebSocket gateway.
zh_Hans: 启用后,机器人通过 LangBot 统一 Webhook 接收消息;否则使用 QQ WebSocket 网关。
zh_Hant: 啟用後,機器人透過 LangBot 統一 Webhook 接收訊息;否則使用 QQ WebSocket 閘道。
type: boolean
required: true
default: false
- name: enable-stream-reply
label:
en_US: Enable Stream Reply Mode
zh_Hans: 启用流式回复模式
zh_Hant: 啟用串流回覆模式
description:
en_US: If enabled, the adapter uses QQ Official streaming replies for C2C private messages.
zh_Hans: 启用后,适配器会对 C2C 私聊使用 QQ 官方流式回复。
zh_Hant: 啟用後,適配器會對 C2C 私聊使用 QQ 官方串流回覆。
type: boolean
required: true
default: false
- name: webhook_url
label:
en_US: Webhook Callback URL
zh_Hans: Webhook 回调地址
zh_Hant: Webhook 回調地址
description:
en_US: Copy this URL and paste it into your QQ Official API webhook configuration.
zh_Hans: 复制此地址并粘贴到 QQ 官方 API 的 Webhook 配置中。
zh_Hant: 複製此地址並貼到 QQ 官方 API 的 Webhook 設定中。
type: webhook-url
required: false
default: ""
show_if:
field: enable-webhook
operator: eq
value: true
supported_events:
- message.received
- platform.specific
supported_apis:
required:
- send_message
- reply_message
optional:
- get_message
- get_user_info
- get_friend_list
- get_group_info
- get_group_member_list
- get_group_member_info
- call_platform_api
platform_specific_apis:
- action: check_access_token
description: { en_US: "Check whether the cached QQ Official access token is usable", zh_Hans: "检查当前缓存的 QQ 官方 access token 是否可用" }
- action: refresh_access_token
description: { en_US: "Force refresh the QQ Official access token", zh_Hans: "强制刷新 QQ 官方 access token" }
- action: get_gateway_url
description: { en_US: "Return the QQ Official WebSocket gateway URL", zh_Hans: "获取 QQ 官方 WebSocket 网关地址" }
- action: get_mode
description: { en_US: "Return adapter receive and stream-reply mode", zh_Hans: "返回适配器接收模式和流式回复模式" }
execution:
python:
path: ./adapter.py
attr: QQOfficialAdapter
@@ -0,0 +1,104 @@
from __future__ import annotations
import datetime
import re
from langbot.libs.qq_official_api.qqofficialevent import QQOfficialEvent
from langbot.pkg.utils import image
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
from langbot_plugin.api.entities.builtin.platform import message as platform_message
def _is_base64_data(value: str) -> bool:
if not value:
return False
if value.startswith('data:'):
return True
if value.startswith(('http://', 'https://', '/', './', '../')):
return False
return bool(re.fullmatch(r'[A-Za-z0-9+/=\s]{20,}', value))
class QQOfficialMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
@staticmethod
async def yiri2target(message_chain: platform_message.MessageChain) -> list[dict]:
content_list: list[dict] = []
for component in message_chain:
if isinstance(component, platform_message.Source):
continue
if isinstance(component, platform_message.Plain):
content_list.append({'type': 'text', 'content': component.text})
elif isinstance(component, platform_message.At):
content_list.append({'type': 'text', 'content': f'@{component.display or component.target}'})
elif isinstance(component, platform_message.AtAll):
content_list.append({'type': 'text', 'content': '@all'})
elif isinstance(component, platform_message.Image):
content_list.append(QQOfficialMessageConverter._media_payload(component, 'image'))
elif isinstance(component, platform_message.Voice):
content_list.append(QQOfficialMessageConverter._media_payload(component, 'voice'))
elif isinstance(component, platform_message.File):
payload = QQOfficialMessageConverter._media_payload(component, 'file')
payload['name'] = component.name or component.id or 'file'
content_list.append(payload)
elif isinstance(component, platform_message.Quote):
if component.id is not None:
content_list.append({'type': 'text', 'content': f'[Quote {component.id}]'})
if component.origin:
content_list.extend(await QQOfficialMessageConverter.yiri2target(component.origin))
elif isinstance(component, platform_message.Forward):
for node in component.node_list:
if node.message_chain:
content_list.extend(await QQOfficialMessageConverter.yiri2target(node.message_chain))
else:
text = str(component)
if text:
content_list.append({'type': 'text', 'content': text})
return content_list
@staticmethod
def _media_payload(component, content_type: str) -> dict:
url = getattr(component, 'url', '') or getattr(component, 'path', '') or None
b64 = getattr(component, 'base64', '') or None
if url and not b64 and _is_base64_data(url):
b64 = url
url = None
return {'type': content_type, 'url': url, 'base64': b64}
@staticmethod
async def target2yiri(event: QQOfficialEvent) -> platform_message.MessageChain:
components: list[platform_message.MessageComponent] = [
platform_message.Source(id=event.d_id or event.id or '', time=_parse_timestamp(event.timestamp)),
]
if event.t in {'GROUP_AT_MESSAGE_CREATE', 'AT_MESSAGE_CREATE'}:
components.append(platform_message.At(target='justbot'))
if event.attachments:
try:
base64_url = await image.get_qq_official_image_base64(
pic_url=event.attachments,
content_type=event.content_type,
)
components.append(platform_message.Image(base64=base64_url))
except Exception:
components.append(platform_message.Image(url=event.attachments))
if event.content:
components.append(platform_message.Plain(text=event.content))
if len(components) == 1 or (
len(components) == 2 and isinstance(components[1], platform_message.At)
):
components.append(platform_message.Unknown(text=f'[unsupported qqofficial event: {event.t or "unknown"}]'))
return platform_message.MessageChain(components)
def _parse_timestamp(value: str) -> datetime.datetime:
if not value:
return datetime.datetime.now()
try:
return datetime.datetime.strptime(value, '%Y-%m-%dT%H:%M:%S%z')
except (TypeError, ValueError):
return datetime.datetime.now()
@@ -0,0 +1,37 @@
from __future__ import annotations
import typing
async def check_access_token(adapter, params: dict) -> dict:
ok = await adapter.bot.check_access_token()
return {'ok': bool(ok), 'expires_at': getattr(adapter.bot, 'access_token_expiry_time', None)}
async def refresh_access_token(adapter, params: dict) -> dict:
adapter.bot.access_token = ''
adapter.bot.access_token_expiry_time = None
await adapter.bot.get_access_token()
return {'ok': bool(adapter.bot.access_token), 'expires_at': adapter.bot.access_token_expiry_time}
async def get_gateway_url(adapter, params: dict) -> dict:
url = await adapter.bot.get_gateway_url()
return {'url': url}
async def get_mode(adapter, params: dict) -> dict:
return {
'webhook': bool(adapter.enable_webhook),
'stream_reply': bool(adapter.config.get('enable-stream-reply') or adapter.config.get('enable_stream_reply')),
'bot_account_id': adapter.bot_account_id,
}
PLATFORM_API_MAP: dict[str, typing.Callable[[typing.Any, dict], typing.Awaitable[dict]]] = {
'check_access_token': check_access_token,
'refresh_access_token': refresh_access_token,
'get_gateway_url': get_gateway_url,
'get_mode': get_mode,
}
@@ -0,0 +1,2 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="96px" height="96px"><path fill="#FFC107" d="M17.5,44c-3.6,0-6.5-1.6-6.5-3.5s2.9-3.5,6.5-3.5s6.5,1.6,6.5,3.5S21.1,44,17.5,44z M37,40.5c0-1.9-2.9-3.5-6.5-3.5S24,38.6,24,40.5s2.9,3.5,6.5,3.5S37,42.4,37,40.5z"/><path fill="#37474F" d="M37.2,22.2c-0.1-0.3-0.2-0.6-0.3-1c0.1-0.5,0.1-1,0.1-1.5c0-1.4-0.1-2.6-0.1-3.6C36.9,9.4,31.1,4,24,4S11,9.4,11,16.1c0,0.9,0,2.2,0,3.6c0,0.5,0,1,0.1,1.5c-0.1,0.3-0.2,0.6-0.3,1c-1.9,2.7-3.8,6-3.8,8.5C7,35.5,8.4,35,8.4,35c0.6,0,1.6-1,2.5-2.1C13,38.8,18,43,24,43s11-4.2,13.1-10.1C38,34,39,35,39.6,35c0,0,1.4,0.5,1.4-4.3C41,28.2,39.1,24.8,37.2,22.2z"/><path fill="#ECEFF1" d="M14.7,23c-0.5,1.5-0.7,3.1-0.7,4.8C14,35.1,18.5,41,24,41s10-5.9,10-13.2c0-1.7-0.3-3.3-0.7-4.8H14.7z"/><path fill="#FFF" d="M23,13.5c0,1.9-1.1,3.5-2.5,3.5S18,15.4,18,13.5s1.1-3.5,2.5-3.5S23,11.6,23,13.5z M27.5,10c-1.4,0-2.5,1.6-2.5,3.5s1.1,3.5,2.5,3.5s2.5-1.6,2.5-3.5S28.9,10,27.5,10z"/><path fill="#37474F" d="M22,13.5c0,0.8-0.4,1.5-1,1.5s-1-0.7-1-1.5s0.4-1.5,1-1.5S22,12.7,22,13.5z M27,12c-0.6,0-1,0.7-1,1.5s0.4-0.5,1-0.5s1,1.3,1,0.5S27.6,12,27,12z"/><path fill="#FFC107" d="M32,19.5c0,0.8-3.6,2.5-8,2.5s-8-1.7-8-2.5s3.6-1.5,8-1.5S32,18.7,32,19.5z"/><path fill="#FF3D00" d="M38.7,21.2c-0.4-1.5-1-2.2-2.1-1.3c0,0-5.9,3.1-12.5,3.1v0.1l0-0.1c-6.6,0-12.5-3.1-12.5-3.1c-1.1-0.8-1.7-0.2-2.1,1.3c-0.4,1.5-0.7,2,0.7,2.8c0.1,0.1,1.4,0.8,3.4,1.7c-0.6,3.5-0.5,6.8-0.5,7c0.1,1.5,1.3,1.3,2.9,1.3c1.6-0.1,2.9,0,2.9-1.6c0-0.9,0-2.9,0.3-5c1.6,0.3,3.2,0.6,5,0.6l0,0v0c7.3,0,13.7-3.9,13.9-4C39.3,23.3,39,22.8,38.7,21.2z"/><path fill="#DD2C00" d="M13.2,27.7c1.6,0.6,3.5,1.3,5.6,1.7c0-0.6,0.1-1.3,0.2-2c-2.1-0.5-4-1.1-5.5-1.7C13.4,26.4,13.3,27.1,13.2,27.7z"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,14 @@
from __future__ import annotations
import pydantic
ADAPTER_NAME = 'qqofficial-eba'
class QQOfficialAdapterConfig(pydantic.BaseModel):
appid: str
secret: str
token: str
enable_webhook: bool = False
enable_stream_reply: bool = False
@@ -0,0 +1,5 @@
from __future__ import annotations
from langbot.pkg.platform.adapters.slack.adapter import SlackAdapter
__all__ = ['SlackAdapter']
@@ -0,0 +1,212 @@
from __future__ import annotations
import asyncio
import traceback
import typing
import pydantic
from langbot.libs.slack_api.api import SlackClient
from langbot.libs.slack_api.slackevent import SlackEvent
from langbot.pkg.platform.adapters.slack.api_impl import SlackAPIMixin
from langbot.pkg.platform.adapters.slack.errors import NotSupportedError
from langbot.pkg.platform.adapters.slack.event_converter import SlackEventConverter
from langbot.pkg.platform.adapters.slack.message_converter import SlackMessageConverter
from langbot.pkg.platform.adapters.slack.platform_api import PLATFORM_API_MAP
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
from langbot_plugin.api.entities.builtin.platform import message as platform_message
class SlackAdapter(SlackAPIMixin, abstract_platform_adapter.AbstractPlatformAdapter):
bot: typing.Any = pydantic.Field(exclude=True)
message_converter: SlackMessageConverter = SlackMessageConverter()
event_converter: SlackEventConverter = SlackEventConverter()
config: dict
bot_uuid: str | None = None
listeners: dict[
typing.Type[platform_events.Event],
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
] = {}
_message_cache: dict[str, platform_events.MessageReceivedEvent] = {}
_user_cache: dict[str, platform_entities.User] = {}
_group_cache: dict[str, platform_entities.UserGroup] = {}
_member_cache: dict[tuple[str, str], platform_entities.UserGroupMember] = {}
class Config:
arbitrary_types_allowed = True
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
required_keys = ['bot_token', 'signing_secret']
missing_keys = [key for key in required_keys if not config.get(key)]
if missing_keys:
raise Exception(f'Slack EBA adapter missing config: {missing_keys}')
bot = SlackClient(
bot_token=config['bot_token'],
signing_secret=config['signing_secret'],
logger=logger,
unified_mode=True,
)
super().__init__(
config=config,
logger=logger,
bot=bot,
bot_account_id=config.get('bot_user_id', ''),
bot_uuid=None,
listeners={},
_message_cache={},
_user_cache={},
_group_cache={},
_member_cache={},
)
self.event_converter = SlackEventConverter(config['bot_token'])
self._register_native_handlers()
def set_bot_uuid(self, bot_uuid: str):
self.bot_uuid = bot_uuid
def get_supported_events(self) -> list[str]:
return [
'message.received',
'platform.specific',
]
def get_supported_apis(self) -> list[str]:
return [
'send_message',
'reply_message',
'get_message',
'get_user_info',
'get_friend_list',
'get_group_info',
'get_group_list',
'get_group_member_list',
'get_group_member_info',
'call_platform_api',
]
async def send_message(
self,
target_type: str,
target_id: str,
message: platform_message.MessageChain,
) -> platform_events.MessageResult:
content = await SlackMessageConverter.yiri2target(message)
raw = await self._send_text(str(target_type), str(target_id), content)
return platform_events.MessageResult(raw=raw)
async def reply_message(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
quote_origin: bool = False,
) -> platform_events.MessageResult:
source = await SlackEventConverter.yiri2target(message_source)
if not isinstance(source, SlackEvent):
raise ValueError('Slack reply_message requires a SlackEvent source object')
target_type = 'channel' if source.type == 'channel' else 'person'
target_id = source.channel_id if source.type == 'channel' else source.user_id
raw = await self._send_text(target_type, target_id, await SlackMessageConverter.yiri2target(message))
return platform_events.MessageResult(message_id=source.message_id, raw=raw)
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
handler = PLATFORM_API_MAP.get(action)
if handler is None:
raise NotSupportedError(f'call_platform_api:{action}')
return await handler(self, dict(params or {}))
def register_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable[
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
],
):
self.listeners[event_type] = callback
def unregister_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable[
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
],
):
registered = self.listeners.get(event_type)
if registered is callback:
self.listeners.pop(event_type, None)
async def handle_unified_webhook(self, bot_uuid: str, path: str, request):
return await self.bot.handle_unified_webhook(request)
async def run_async(self):
await self.logger.info('Slack EBA adapter running in unified webhook mode')
while True:
await asyncio.sleep(1)
async def kill(self) -> bool:
return True
async def is_muted(self, group_id: int | None = None) -> bool:
return False
def _register_native_handlers(self):
for msg_type in ('im', 'channel'):
self.bot.on_message(msg_type)(self._handle_native_event)
async def _handle_native_event(self, event: SlackEvent):
try:
if platform_events.FriendMessage in self.listeners or platform_events.GroupMessage in self.listeners:
legacy_event = await self.event_converter.target2legacy(event)
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)
if eba_event:
self._cache_event(eba_event)
await self._dispatch_eba_event(eba_event)
except Exception:
await self.logger.error(f'Error in slack native event: {traceback.format_exc()}')
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
callback = self.listeners.get(event_type)
if callback:
await callback(event, self)
return
def _cache_event(self, event: platform_events.Event):
if not isinstance(event, platform_events.MessageReceivedEvent):
return
self._message_cache[str(event.message_id)] = event
self._user_cache[str(event.sender.id)] = event.sender
if event.group:
self._group_cache[str(event.group.id)] = event.group
self._member_cache[(str(event.group.id), str(event.sender.id))] = platform_entities.UserGroupMember(
user=event.sender,
group_id=event.group.id,
role=platform_entities.MemberRole.MEMBER,
display_name=event.sender.nickname,
)
async def _send_text(self, target_type: str, target_id: str, content: str) -> dict:
target_type = self._normalize_target_type(target_type)
if target_type == 'person':
raw = await self.bot.send_message_to_one(content, target_id)
elif target_type == 'channel':
raw = await self.bot.send_message_to_channel(content, target_id)
else:
raise NotSupportedError(f'send_message:{target_type}')
return {'target_type': target_type, 'target_id': target_id, 'raw': raw}
@staticmethod
def _normalize_target_type(target_type: str) -> str:
if target_type in {'person', 'private', 'friend', 'im', 'dm'}:
return 'person'
if target_type in {'group', 'channel'}:
return 'channel'
return target_type
@@ -0,0 +1,93 @@
from __future__ import annotations
import typing
from langbot.pkg.platform.adapters.slack.errors import NotSupportedError
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
from langbot_plugin.api.entities.builtin.platform import message as platform_message
class SlackAPIMixin:
_message_cache: dict[str, platform_events.MessageReceivedEvent]
_user_cache: dict[str, platform_entities.User]
_group_cache: dict[str, platform_entities.UserGroup]
_member_cache: dict[tuple[str, str], platform_entities.UserGroupMember]
async def get_message(
self,
chat_type: str,
chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
) -> platform_events.MessageReceivedEvent:
event = self._message_cache.get(str(message_id))
if event is None:
raise NotSupportedError('get_message:message_not_cached')
return event
async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User:
user = self._user_cache.get(str(user_id))
if user is None:
raise NotSupportedError('get_user_info:not_cached')
return user
async def get_friend_list(self) -> list[platform_entities.User]:
return list(self._user_cache.values())
async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup:
group = self._group_cache.get(str(group_id))
if group is None:
raise NotSupportedError('get_group_info:not_cached')
return group
async def get_group_list(self) -> list[platform_entities.UserGroup]:
return list(self._group_cache.values())
async def get_group_member_list(
self,
group_id: typing.Union[int, str],
) -> list[platform_entities.UserGroupMember]:
return [member for (cached_group_id, _), member in self._member_cache.items() if cached_group_id == str(group_id)]
async def get_group_member_info(
self,
group_id: typing.Union[int, str],
user_id: typing.Union[int, str],
) -> platform_entities.UserGroupMember:
member = self._member_cache.get((str(group_id), str(user_id)))
if member is None:
raise NotSupportedError('get_group_member_info:not_cached')
return member
async def edit_message(
self,
chat_type: str,
chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
new_content: platform_message.MessageChain,
) -> None:
raise NotSupportedError('edit_message')
async def delete_message(
self,
chat_type: str,
chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
) -> None:
raise NotSupportedError('delete_message')
async def forward_message(
self,
from_chat_type: str,
from_chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
to_chat_type: str,
to_chat_id: typing.Union[int, str],
) -> platform_events.MessageResult:
raise NotSupportedError('forward_message')
async def upload_file(self, file_data: bytes, filename: str) -> str:
raise NotSupportedError('upload_file')
async def get_file_url(self, file_id: str) -> str:
raise NotSupportedError('get_file_url')
@@ -0,0 +1,10 @@
from __future__ import annotations
try:
from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError
except ModuleNotFoundError:
class NotSupportedError(Exception):
def __init__(self, api_name: str, *args):
super().__init__(f"API '{api_name}' is not supported by this adapter", *args)
self.api_name = api_name
@@ -0,0 +1,103 @@
from __future__ import annotations
import time
import typing
from langbot.libs.slack_api.slackevent import SlackEvent
from langbot.pkg.platform.adapters.slack.message_converter import SlackMessageConverter
from langbot.pkg.platform.adapters.slack.types import ADAPTER_NAME
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
class SlackEventConverter(abstract_platform_adapter.AbstractEventConverter):
def __init__(self, bot_token: str = ''):
self.bot_token = bot_token
@staticmethod
async def yiri2target(event: platform_events.Event) -> typing.Any:
return getattr(event, 'source_platform_object', None)
async def target2legacy(self, event: SlackEvent) -> platform_events.FriendMessage | platform_events.GroupMessage | None:
eba_event = await self.target2yiri(event)
if not isinstance(eba_event, platform_events.MessageReceivedEvent):
return None
if eba_event.chat_type == platform_entities.ChatType.PRIVATE:
return platform_events.FriendMessage(
sender=platform_entities.Friend(
id=eba_event.sender.id,
nickname=eba_event.sender.nickname,
remark='',
),
message_chain=eba_event.message_chain,
time=eba_event.timestamp,
source_platform_object=event,
)
return platform_events.GroupMessage(
sender=platform_entities.GroupMember(
id=eba_event.sender.id,
member_name=eba_event.sender.nickname,
permission='MEMBER',
group=platform_entities.Group(
id=eba_event.group.id if eba_event.group else eba_event.chat_id,
name=eba_event.group.name if eba_event.group else '',
permission=platform_entities.Permission.Member,
),
special_title='',
),
message_chain=eba_event.message_chain,
time=eba_event.timestamp,
source_platform_object=event,
)
async def target2yiri(self, event: SlackEvent) -> platform_events.Event:
if event.type in {'im', 'channel'}:
return await self.message_to_eba(event)
return self.platform_specific(event, f'slack.{event.type or "unknown"}')
async def message_to_eba(self, event: SlackEvent) -> platform_events.MessageReceivedEvent:
sender_id = event.user_id or ''
sender = platform_entities.User(
id=sender_id,
nickname=event.sender_name or sender_id,
)
chat_type = platform_entities.ChatType.PRIVATE
chat_id = sender_id
group = None
if event.type == 'channel':
chat_type = platform_entities.ChatType.GROUP
chat_id = event.channel_id or ''
group = platform_entities.UserGroup(id=str(chat_id), name=str(chat_id))
return platform_events.MessageReceivedEvent(
type='message.received',
adapter_name=ADAPTER_NAME,
message_id=event.message_id or event.get('event', {}).get('event_ts') or '',
message_chain=await SlackMessageConverter.target2yiri(event, self.bot_token),
sender=sender,
chat_type=chat_type,
chat_id=chat_id or '',
group=group,
timestamp=_timestamp_value(event),
source_platform_object=event,
)
@staticmethod
def platform_specific(event: SlackEvent, action: str) -> platform_events.PlatformSpecificEvent:
return platform_events.PlatformSpecificEvent(
type='platform.specific',
adapter_name=ADAPTER_NAME,
action=action,
data=dict(event),
timestamp=_timestamp_value(event),
source_platform_object=event,
)
def _timestamp_value(event: SlackEvent) -> float:
raw_ts = event.get('event', {}).get('ts') or event.get('event', {}).get('event_ts')
try:
return float(raw_ts)
except (TypeError, ValueError):
return time.time()
@@ -0,0 +1,81 @@
apiVersion: v1
kind: MessagePlatformAdapter
metadata:
name: slack-eba
label:
en_US: Slack (EBA)
zh_Hans: Slack (EBA)
zh_Hant: Slack (EBA)
description:
en_US: Slack adapter with Event-Based Agents support, using LangBot's unified webhook endpoint.
zh_Hans: Slack 适配器(EBA 架构版本),通过 LangBot 统一 Webhook 接收 Slack 事件订阅消息。
zh_Hant: Slack 適配器(EBA 架構版本),透過 LangBot 統一 Webhook 接收 Slack 事件訂閱訊息。
icon: slack.png
spec:
categories:
- popular
- global
help_links:
zh: https://link.langbot.app/zh/platforms/slack
en: https://link.langbot.app/en/platforms/slack
ja: https://link.langbot.app/ja/platforms/slack
config:
- name: webhook_url
label:
en_US: Webhook Callback URL
zh_Hans: Webhook 回调地址
zh_Hant: Webhook 回調地址
description:
en_US: Copy this URL and paste it into your Slack app's event subscription configuration.
zh_Hans: 复制此地址并粘贴到 Slack 应用的事件订阅配置中。
zh_Hant: 複製此地址並貼到 Slack 應用的事件訂閱設定中。
type: webhook-url
required: false
default: ""
- name: bot_token
label:
en_US: Bot Token
zh_Hans: 机器人令牌
zh_Hant: 機器人令牌
type: string
required: true
default: ""
- name: signing_secret
label:
en_US: Signing Secret
zh_Hans: 签名密钥
zh_Hant: 簽名密鑰
type: string
required: true
default: ""
supported_events:
- message.received
- platform.specific
supported_apis:
required:
- send_message
- reply_message
optional:
- get_message
- get_user_info
- get_friend_list
- get_group_info
- get_group_list
- get_group_member_list
- get_group_member_info
- call_platform_api
platform_specific_apis:
- action: get_mode
description: { en_US: "Return adapter webhook mode", zh_Hans: "返回适配器 Webhook 模式" }
- action: auth_test
description: { en_US: "Call Slack auth.test with the configured bot token", zh_Hans: "使用配置的机器人令牌调用 Slack auth.test" }
execution:
python:
path: ./adapter.py
attr: SlackAdapter
@@ -0,0 +1,80 @@
from __future__ import annotations
import datetime
from langbot.libs.slack_api.slackevent import SlackEvent
from langbot.pkg.utils import image
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
from langbot_plugin.api.entities.builtin.platform import message as platform_message
class SlackMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
@staticmethod
async def yiri2target(message_chain: platform_message.MessageChain) -> str:
parts: list[str] = []
for component in message_chain:
if isinstance(component, platform_message.Source):
continue
if isinstance(component, platform_message.Plain):
parts.append(component.text)
elif isinstance(component, platform_message.At):
parts.append(f'<@{component.target}>')
elif isinstance(component, platform_message.AtAll):
parts.append('<!channel>')
elif isinstance(component, platform_message.Image):
parts.append(component.url or '[Image]')
elif isinstance(component, platform_message.Voice):
parts.append(component.url or '[Voice]')
elif isinstance(component, platform_message.File):
parts.append(component.url or component.name or component.id or '[File]')
elif isinstance(component, platform_message.Quote):
if component.id is not None:
parts.append(f'[Quote {component.id}]')
if component.origin:
parts.append(await SlackMessageConverter.yiri2target(component.origin))
elif isinstance(component, platform_message.Forward):
for node in component.node_list:
if node.message_chain:
parts.append(await SlackMessageConverter.yiri2target(node.message_chain))
else:
text = str(component)
if text:
parts.append(text)
return '\n'.join(part for part in parts if part)
@staticmethod
async def target2yiri(event: SlackEvent, bot_token: str = '') -> platform_message.MessageChain:
message_id = event.message_id or event.get('event', {}).get('event_ts') or ''
components: list[platform_message.MessageComponent] = [
platform_message.Source(
id=message_id,
time=_event_datetime(event),
)
]
if event.type == 'channel':
components.append(platform_message.At(target='SlackBot'))
if event.pic_url:
try:
components.append(platform_message.Image(base64=await image.get_slack_image_to_base64(event.pic_url, bot_token)))
except Exception:
components.append(platform_message.Image(url=event.pic_url))
if event.text:
components.append(platform_message.Plain(text=event.text))
if len(components) == 1 or (
len(components) == 2 and isinstance(components[1], platform_message.At)
):
components.append(platform_message.Unknown(text=f'[unsupported slack event: {event.type or "unknown"}]'))
return platform_message.MessageChain(components)
def _event_datetime(event: SlackEvent) -> datetime.datetime:
raw_ts = event.get('event', {}).get('ts') or event.get('event', {}).get('event_ts')
try:
return datetime.datetime.fromtimestamp(float(raw_ts))
except (TypeError, ValueError):
return datetime.datetime.now()
@@ -0,0 +1,23 @@
from __future__ import annotations
import typing
async def get_mode(adapter, params: dict) -> dict:
return {
'webhook': True,
'bot_account_id': adapter.bot_account_id,
}
async def auth_test(adapter, params: dict) -> dict:
response = await adapter.bot.client.auth_test()
if hasattr(response, 'data'):
return dict(response.data)
return dict(response)
PLATFORM_API_MAP: dict[str, typing.Callable[[typing.Any, dict], typing.Awaitable[dict]]] = {
'get_mode': get_mode,
'auth_test': auth_test,
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

@@ -0,0 +1,3 @@
from __future__ import annotations
ADAPTER_NAME = 'slack-eba'
@@ -0,0 +1,3 @@
from langbot.pkg.platform.adapters.telegram.adapter import TelegramAdapter
__all__ = ["TelegramAdapter"]
@@ -0,0 +1,435 @@
"""Telegram adapter main class (EBA version).
Inherits AbstractPlatformAdapter, integrating all modules.
Preserves all existing functionality (messaging, streaming output, markdown card, forum topics, etc.).
"""
from __future__ import annotations
import time
import typing
import traceback
import telegram
import telegram.ext
from telegram import Update
from telegram.ext import (
ApplicationBuilder,
CallbackQueryHandler,
ChatMemberHandler,
ContextTypes,
MessageHandler,
MessageReactionHandler,
filters,
)
import telegramify_markdown
import pydantic
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.events as platform_events
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
from langbot.pkg.platform.adapters.telegram.message_converter import TelegramMessageConverter
from langbot.pkg.platform.adapters.telegram.event_converter import TelegramEventConverter, LegacyEventConverter
from langbot.pkg.platform.adapters.telegram.api_impl import TelegramAPIMixin
from langbot.pkg.platform.adapters.telegram.platform_api import PLATFORM_API_MAP
class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatformAdapter):
"""Telegram adapter (EBA version)."""
bot: telegram.Bot = pydantic.Field(exclude=True)
application: telegram.ext.Application = pydantic.Field(exclude=True)
message_converter: TelegramMessageConverter = TelegramMessageConverter()
event_converter: TelegramEventConverter = TelegramEventConverter()
legacy_event_converter: LegacyEventConverter = LegacyEventConverter()
config: dict
msg_stream_id: dict
"""Stream message ID map. Key: stream message ID, value: first message source ID."""
seq: int
"""Sequence number for message ordering."""
listeners: typing.Dict[
typing.Type[platform_events.Event],
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
] = {}
class Config:
arbitrary_types_allowed = True
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
async def telegram_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
if (
not update.message
and not update.edited_message
and not update.chat_member
and not update.my_chat_member
and not update.callback_query
and not update.message_reaction
):
return
# Skip messages from the bot itself
if update.message and update.message.from_user and update.message.from_user.is_bot:
return
try:
# Legacy event type callbacks (compat with existing botmgr FriendMessage / GroupMessage listeners)
if update.message and (
platform_events.FriendMessage in self.listeners or platform_events.GroupMessage in self.listeners
):
legacy_event = await self.legacy_event_converter.target2yiri(update, self.bot, self.bot_account_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(update, self.bot, self.bot_account_id)
if eba_event:
await self._dispatch_eba_event(eba_event)
except Exception:
await self.logger.error(f'Error in telegram callback: {traceback.format_exc()}')
application = ApplicationBuilder().token(config['token']).build()
bot = application.bot
# Register handler for all common update types
application.add_handler(
MessageHandler(
filters.TEXT | (filters.COMMAND) | filters.PHOTO | filters.VOICE | filters.Document.ALL,
telegram_callback,
)
)
# Register edited message handler
application.add_handler(
MessageHandler(
filters.UpdateType.EDITED_MESSAGE,
telegram_callback,
)
)
application.add_handler(
ChatMemberHandler(
telegram_callback,
ChatMemberHandler.CHAT_MEMBER,
)
)
application.add_handler(
ChatMemberHandler(
telegram_callback,
ChatMemberHandler.MY_CHAT_MEMBER,
)
)
application.add_handler(CallbackQueryHandler(telegram_callback))
application.add_handler(
MessageReactionHandler(
telegram_callback,
MessageReactionHandler.MESSAGE_REACTION,
)
)
super().__init__(
config=config,
logger=logger,
msg_stream_id={},
seq=1,
bot=bot,
application=application,
bot_account_id='',
listeners={},
)
# ---- Capability Declaration ----
def get_supported_events(self) -> list[str]:
return [
'message.received',
'message.edited',
'message.reaction',
'group.member_joined',
'group.member_left',
'group.member_banned',
'bot.invited_to_group',
'bot.removed_from_group',
'bot.muted',
'bot.unmuted',
'platform.specific',
]
def get_supported_apis(self) -> list[str]:
return [
'send_message',
'reply_message',
'edit_message',
'delete_message',
'forward_message',
'get_group_info',
'get_group_member_list',
'get_group_member_info',
'get_user_info',
'get_file_url',
'mute_member',
'unmute_member',
'kick_member',
'leave_group',
'call_platform_api',
]
# ---- Message Send / Reply (preserving original logic) ----
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
components = await TelegramMessageConverter.yiri2target(message, self.bot)
chat_id_str, _, thread_id_str = str(target_id).partition('#')
chat_id: int | str = int(chat_id_str) if chat_id_str.lstrip('-').isdigit() else chat_id_str
message_thread_id = int(thread_id_str) if thread_id_str and thread_id_str.isdigit() else None
for component in components:
component_type = component.get('type')
args = {'chat_id': chat_id}
if message_thread_id is not None:
args['message_thread_id'] = message_thread_id
if component_type == 'text':
text = component.get('text', '')
if self.config['markdown_card'] is True:
text = telegramify_markdown.markdownify(content=text)
args['parse_mode'] = 'MarkdownV2'
args['text'] = text
await self.bot.send_message(**args)
elif component_type == 'photo':
photo = component.get('photo')
if photo is None:
continue
args['photo'] = telegram.InputFile(photo)
await self.bot.send_photo(**args)
elif component_type == 'document':
doc = component.get('document')
if doc is None:
continue
filename = component.get('filename', 'file')
args['document'] = telegram.InputFile(doc, filename=filename)
await self.bot.send_document(**args)
async def reply_message(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
quote_origin: bool = False,
):
assert isinstance(message_source.source_platform_object, Update)
components = await TelegramMessageConverter.yiri2target(message, self.bot)
for component in components:
component_type = component.get('type')
args = {
'chat_id': message_source.source_platform_object.effective_chat.id,
}
if message_source.source_platform_object.message.message_thread_id:
args['message_thread_id'] = message_source.source_platform_object.message.message_thread_id
if quote_origin:
args['reply_to_message_id'] = message_source.source_platform_object.message.id
if component_type == 'text':
if self.config['markdown_card'] is True:
content = telegramify_markdown.markdownify(
content=component['text'],
)
else:
content = component['text']
if self.config['markdown_card'] is True:
args['parse_mode'] = 'MarkdownV2'
args['text'] = content
await self.bot.send_message(**args)
elif component_type == 'photo':
photo = component.get('photo')
if photo is None:
continue
args['photo'] = telegram.InputFile(photo)
await self.bot.send_photo(**args)
elif component_type == 'document':
doc = component.get('document')
if doc is None:
continue
filename = component.get('filename', 'file')
args['document'] = telegram.InputFile(doc, filename=filename)
await self.bot.send_document(**args)
# ---- Streaming Output (preserving original logic) ----
def _process_markdown(self, text: str) -> str:
if self.config.get('markdown_card', False):
return telegramify_markdown.markdownify(content=text)
return text
def _build_message_args(self, chat_id: int, text: str, message_thread_id: int = None, **extra_args) -> dict:
args = {'chat_id': chat_id, 'text': self._process_markdown(text), **extra_args}
if message_thread_id:
args['message_thread_id'] = message_thread_id
if self.config.get('markdown_card', False):
args['parse_mode'] = 'MarkdownV2'
return args
async def create_message_card(self, message_id, event):
assert isinstance(event.source_platform_object, Update)
update = event.source_platform_object
chat_id = update.effective_chat.id
chat_type = update.effective_chat.type
message_thread_id = update.message.message_thread_id
if chat_type == 'private':
draft_id = int(time.time() * 1000)
self.msg_stream_id[message_id] = ('private', draft_id)
args = self._build_message_args(chat_id, 'Thinking...', message_thread_id, draft_id=draft_id)
await self.bot.send_message_draft(**args)
else:
args = self._build_message_args(chat_id, 'Thinking...', message_thread_id)
send_msg = await self.bot.send_message(**args)
self.msg_stream_id[message_id] = ('group', send_msg.message_id)
return True
async def reply_message_chunk(
self,
message_source: platform_events.MessageEvent,
bot_message,
message: platform_message.MessageChain,
quote_origin: bool = False,
is_final: bool = False,
):
message_id = bot_message.resp_message_id
msg_seq = bot_message.msg_sequence
assert isinstance(message_source.source_platform_object, Update)
update = message_source.source_platform_object
chat_id = update.effective_chat.id
message_thread_id = update.message.message_thread_id
if message_id not in self.msg_stream_id:
return
chat_mode, draft_id = self.msg_stream_id[message_id]
components = await TelegramMessageConverter.yiri2target(message, self.bot)
if not components or components[0]['type'] != 'text':
if is_final and bot_message.tool_calls is None:
self.msg_stream_id.pop(message_id)
return
content = components[0]['text']
if chat_mode == 'private':
args = self._build_message_args(chat_id, content, message_thread_id, draft_id=draft_id)
await self.bot.send_message_draft(**args)
if is_final and bot_message.tool_calls is None:
del args['draft_id']
await self.bot.send_message(**args)
self.msg_stream_id.pop(message_id)
else:
stream_id = draft_id
if (msg_seq - 1) % 8 == 0 or is_final:
args = {
'message_id': stream_id,
'chat_id': chat_id,
'text': self._process_markdown(content),
}
if self.config.get('markdown_card', False):
args['parse_mode'] = 'MarkdownV2'
await self.bot.edit_message_text(**args)
if is_final and bot_message.tool_calls is None:
self.msg_stream_id.pop(message_id)
# ---- Forum Topic / Custom launcher_id (preserving original logic) ----
def get_launcher_id(self, event: platform_events.MessageEvent) -> str | None:
if not isinstance(event.source_platform_object, Update):
return None
message = event.source_platform_object.message
if not message:
return None
if message.message_thread_id:
if isinstance(event, platform_events.GroupMessage):
return f'{event.group.id}#{message.message_thread_id}'
elif isinstance(event, platform_events.FriendMessage):
return f'{event.sender.id}#{message.message_thread_id}'
return None
# ---- Stream Output Support Check ----
async def is_stream_output_supported(self) -> bool:
is_stream = False
if self.config.get('enable-stream-reply', None):
is_stream = True
return is_stream
async def is_muted(self, group_id: int) -> bool:
return False
# ---- Event Listeners ----
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
"""Dispatch once, preferring the most specific registered listener."""
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
callback = self.listeners.get(event_type)
if callback:
await callback(event, self)
return
def register_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable[
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
],
):
self.listeners[event_type] = callback
def unregister_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable[
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
],
):
self.listeners.pop(event_type, None)
# ---- Pass-through API ----
async def call_platform_api(
self,
action: str,
params: dict = {},
) -> dict:
"""Call a Telegram-specific platform API."""
handler = PLATFORM_API_MAP.get(action)
if handler is None:
from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError
raise NotSupportedError(f'call_platform_api:{action}')
return await handler(self.bot, params)
# ---- Lifecycle ----
async def run_async(self):
await self.application.initialize()
self.bot_account_id = (await self.bot.get_me()).username
await self.application.updater.start_polling(allowed_updates=Update.ALL_TYPES)
await self.application.start()
await self.logger.info('Telegram adapter running')
async def kill(self) -> bool:
if self.application.running:
await self.application.stop()
if self.application.updater:
await self.application.updater.stop()
await self.logger.info('Telegram adapter stopped')
return True
@@ -0,0 +1,252 @@
"""Telegram universal API implementation (EBA version).
Implements optional API methods defined in AbstractPlatformAdapter.
"""
from __future__ import annotations
import typing
import telegram
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.platform.events as platform_events
from langbot.pkg.platform.adapters.telegram.message_converter import TelegramMessageConverter
class TelegramAPIMixin:
"""Telegram universal API implementation mixin.
Used via multiple inheritance in TelegramAdapter.
Requires self.bot: telegram.Bot and self.config: dict attributes.
"""
bot: telegram.Bot
async def edit_message(
self,
chat_type: str,
chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
new_content: platform_message.MessageChain,
) -> None:
"""Edit a previously sent message."""
components = await TelegramMessageConverter.yiri2target(new_content, self.bot)
for component in components:
if component['type'] == 'text':
text = component['text']
if self.config.get('markdown_card', False):
import telegramify_markdown
text = telegramify_markdown.markdownify(content=text)
args = {
'chat_id': chat_id,
'message_id': message_id,
'text': text,
}
if self.config.get('markdown_card', False):
args['parse_mode'] = 'MarkdownV2'
await self.bot.edit_message_text(**args)
return
async def delete_message(
self,
chat_type: str,
chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
) -> None:
"""Delete / recall a message."""
await self.bot.delete_message(chat_id=chat_id, message_id=message_id)
async def forward_message(
self,
from_chat_type: str,
from_chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
to_chat_type: str,
to_chat_id: typing.Union[int, str],
) -> platform_events.MessageResult:
"""Forward a message to another chat."""
result = await self.bot.forward_message(
chat_id=to_chat_id,
from_chat_id=from_chat_id,
message_id=message_id,
)
return platform_events.MessageResult(
message_id=result.message_id,
raw={'message_id': result.message_id},
)
async def get_group_info(
self,
group_id: typing.Union[int, str],
) -> platform_entities.UserGroup:
"""Get group information."""
chat = await self.bot.get_chat(chat_id=group_id)
return platform_entities.UserGroup(
id=chat.id,
name=chat.title or '',
description=chat.description or None,
member_count=await self._get_member_count(group_id),
)
async def _get_member_count(self, group_id: typing.Union[int, str]) -> typing.Optional[int]:
"""Get group member count."""
try:
return await self.bot.get_chat_member_count(chat_id=group_id)
except Exception:
return None
async def get_group_member_list(
self,
group_id: typing.Union[int, str],
) -> list[platform_entities.UserGroupMember]:
"""Get group member list.
Note: Telegram Bot API only supports fetching the admin list
(get_chat_administrators), not the full member list.
This method returns the admin list.
"""
admins = await self.bot.get_chat_administrators(chat_id=group_id)
members = []
for admin in admins:
role = platform_entities.MemberRole.MEMBER
if admin.status == 'creator':
role = platform_entities.MemberRole.OWNER
elif admin.status == 'administrator':
role = platform_entities.MemberRole.ADMIN
members.append(
platform_entities.UserGroupMember(
user=platform_entities.User(
id=admin.user.id,
nickname=admin.user.first_name or '',
username=admin.user.username,
is_bot=admin.user.is_bot,
),
group_id=group_id,
role=role,
display_name=admin.custom_title if hasattr(admin, 'custom_title') else None,
)
)
return members
async def get_group_member_info(
self,
group_id: typing.Union[int, str],
user_id: typing.Union[int, str],
) -> platform_entities.UserGroupMember:
"""Get information about a specific group member."""
member = await self.bot.get_chat_member(chat_id=group_id, user_id=user_id)
role = platform_entities.MemberRole.MEMBER
if member.status == 'creator':
role = platform_entities.MemberRole.OWNER
elif member.status == 'administrator':
role = platform_entities.MemberRole.ADMIN
return platform_entities.UserGroupMember(
user=platform_entities.User(
id=member.user.id,
nickname=member.user.first_name or '',
username=member.user.username,
is_bot=member.user.is_bot,
),
group_id=group_id,
role=role,
display_name=member.custom_title if hasattr(member, 'custom_title') else None,
)
async def get_user_info(
self,
user_id: typing.Union[int, str],
) -> platform_entities.User:
"""Get user information."""
chat = await self.bot.get_chat(chat_id=user_id)
return platform_entities.User(
id=chat.id,
nickname=chat.first_name or '',
username=chat.username,
)
async def upload_file(
self,
file_data: bytes,
filename: str,
) -> str:
"""Upload a file.
Telegram does not support standalone file uploads; files are sent as
part of messages. This method raises NotSupportedError.
"""
from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError
raise NotSupportedError('upload_file')
async def get_file_url(
self,
file_id: str,
) -> str:
"""Get file download URL."""
file = await self.bot.get_file(file_id)
return file.file_path
async def mute_member(
self,
group_id: typing.Union[int, str],
user_id: typing.Union[int, str],
duration: int = 0,
) -> None:
"""Mute a group member."""
import datetime
permissions = telegram.ChatPermissions(can_send_messages=False)
kwargs = {
'chat_id': group_id,
'user_id': user_id,
'permissions': permissions,
}
if duration > 0:
kwargs['until_date'] = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=duration)
await self.bot.restrict_chat_member(**kwargs)
async def unmute_member(
self,
group_id: typing.Union[int, str],
user_id: typing.Union[int, str],
) -> None:
"""Unmute a group member."""
permissions = telegram.ChatPermissions(
can_send_messages=True,
can_send_other_messages=True,
can_add_web_page_previews=True,
can_send_audios=True,
can_send_documents=True,
can_send_photos=True,
can_send_videos=True,
can_send_video_notes=True,
can_send_voice_notes=True,
)
await self.bot.restrict_chat_member(
chat_id=group_id,
user_id=user_id,
permissions=permissions,
)
async def kick_member(
self,
group_id: typing.Union[int, str],
user_id: typing.Union[int, str],
) -> None:
"""Kick a member from the group."""
await self.bot.ban_chat_member(chat_id=group_id, user_id=user_id)
async def leave_group(
self,
group_id: typing.Union[int, str],
) -> None:
"""Make the bot leave a group."""
await self.bot.leave_chat(chat_id=group_id)
@@ -0,0 +1,424 @@
"""Telegram event converter (EBA version).
Converts all Telegram Update types to unified EBA events, not just messages.
"""
from __future__ import annotations
import typing
import telegram
from telegram import Update
import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
from langbot.pkg.platform.adapters.telegram.message_converter import TelegramMessageConverter
def _make_user(tg_user: telegram.User) -> platform_entities.User:
"""Convert a Telegram User to a unified User entity."""
return platform_entities.User(
id=tg_user.id,
nickname=tg_user.first_name or '',
username=tg_user.username,
is_bot=tg_user.is_bot,
)
def _make_user_group(tg_chat: telegram.Chat) -> platform_entities.UserGroup:
"""Convert a Telegram Chat to a unified UserGroup entity."""
return platform_entities.UserGroup(
id=tg_chat.id,
name=tg_chat.title or tg_chat.first_name or '',
description=tg_chat.description if hasattr(tg_chat, 'description') else None,
)
def _chat_type(tg_chat: telegram.Chat) -> platform_entities.ChatType:
"""Map Telegram Chat type to unified ChatType."""
if tg_chat.type == 'private':
return platform_entities.ChatType.PRIVATE
return platform_entities.ChatType.GROUP
class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):
"""Telegram event converter (EBA version)."""
@staticmethod
async def yiri2target(event: platform_events.Event, bot: telegram.Bot):
"""Convert a unified event to a raw Telegram event (generally not needed)."""
if hasattr(event, 'source_platform_object'):
return event.source_platform_object
return None
@staticmethod
async def target2yiri(
update: Update,
bot: telegram.Bot,
bot_account_id: str,
) -> typing.Optional[platform_events.EBAEvent]:
"""Convert a Telegram Update to a unified EBA event.
Supports: message, edited_message, chat_member, my_chat_member,
callback_query, message_reaction, etc.
Unmappable events are wrapped as PlatformSpecificEvent.
"""
import time
# ---- Message event ----
if (
update.message
and update.message.text is not None
or (update.message and (update.message.photo or update.message.voice or update.message.document))
):
return await TelegramEventConverter._convert_message(update, bot, bot_account_id)
# ---- Edited message event ----
if update.edited_message:
return await TelegramEventConverter._convert_edited_message(update, bot, bot_account_id)
# ---- Member change event (chat_member) ----
if update.chat_member:
return TelegramEventConverter._convert_chat_member(update)
# ---- Bot's own member status change (my_chat_member) ----
if update.my_chat_member:
return TelegramEventConverter._convert_my_chat_member(update)
# ---- Callback query (button clicks, etc.) ----
if update.callback_query:
return platform_events.PlatformSpecificEvent(
type='platform.specific',
timestamp=time.time(),
adapter_name='telegram',
action='callback_query',
data={
'callback_query_id': update.callback_query.id,
'data': update.callback_query.data,
'from_user_id': update.callback_query.from_user.id if update.callback_query.from_user else None,
'message_id': update.callback_query.message.message_id if update.callback_query.message else None,
},
source_platform_object=update,
)
# ---- Message reaction ----
if update.message_reaction:
return TelegramEventConverter._convert_reaction(update)
# ---- Fallback: wrap as PlatformSpecificEvent ----
return platform_events.PlatformSpecificEvent(
type='platform.specific',
timestamp=time.time(),
adapter_name='telegram',
action='unknown_update',
data={'update_id': update.update_id},
source_platform_object=update,
)
@staticmethod
async def _convert_message(
update: Update,
bot: telegram.Bot,
bot_account_id: str,
) -> platform_events.MessageReceivedEvent:
"""Convert a Telegram message to MessageReceivedEvent."""
message = update.message
lb_message = await TelegramMessageConverter.target2yiri(message, bot, bot_account_id)
sender = _make_user(message.from_user) if message.from_user else platform_entities.User(id='')
chat = message.chat
ct = _chat_type(chat)
group = None
if ct == platform_entities.ChatType.GROUP:
group = _make_user_group(chat)
return platform_events.MessageReceivedEvent(
type='message.received',
timestamp=message.date.timestamp() if message.date else 0.0,
adapter_name='telegram',
message_id=message.message_id,
message_chain=lb_message,
sender=sender,
chat_type=ct,
chat_id=chat.id,
group=group,
source_platform_object=update,
)
@staticmethod
async def _convert_edited_message(
update: Update,
bot: telegram.Bot,
bot_account_id: str,
) -> platform_events.MessageEditedEvent:
"""Convert a Telegram edited message to MessageEditedEvent."""
message = update.edited_message
lb_message = await TelegramMessageConverter.target2yiri(message, bot, bot_account_id)
editor = _make_user(message.from_user) if message.from_user else platform_entities.User(id='')
chat = message.chat
ct = _chat_type(chat)
group = None
if ct == platform_entities.ChatType.GROUP:
group = _make_user_group(chat)
return platform_events.MessageEditedEvent(
type='message.edited',
timestamp=message.edit_date.timestamp() if message.edit_date else 0.0,
adapter_name='telegram',
message_id=message.message_id,
new_content=lb_message,
editor=editor,
chat_type=ct,
chat_id=chat.id,
group=group,
source_platform_object=update,
)
@staticmethod
def _convert_chat_member(update: Update) -> typing.Optional[platform_events.EBAEvent]:
"""Convert a chat_member update to MemberJoinedEvent / MemberLeftEvent / etc."""
import time
cm = update.chat_member
chat = cm.chat
group = _make_user_group(chat)
member = _make_user(cm.new_chat_member.user) if cm.new_chat_member else platform_entities.User(id='')
inviter = _make_user(cm.from_user) if cm.from_user else None
old_status = cm.old_chat_member.status if cm.old_chat_member else None
new_status = cm.new_chat_member.status if cm.new_chat_member else None
# Member joined
if old_status in (None, 'left', 'kicked') and new_status in (
'member',
'administrator',
'creator',
'restricted',
):
return platform_events.MemberJoinedEvent(
type='group.member_joined',
timestamp=cm.date.timestamp() if cm.date else time.time(),
adapter_name='telegram',
group=group,
member=member,
inviter=inviter,
join_type='invite' if inviter and inviter.id != member.id else 'direct',
source_platform_object=update,
)
# Member left / kicked
if old_status in ('member', 'administrator', 'creator', 'restricted') and new_status in ('left', 'kicked'):
is_kicked = new_status == 'kicked'
return platform_events.MemberLeftEvent(
type='group.member_left',
timestamp=cm.date.timestamp() if cm.date else time.time(),
adapter_name='telegram',
group=group,
member=member,
is_kicked=is_kicked,
operator=inviter if is_kicked else None,
source_platform_object=update,
)
# Member muted (restricted with can_send_messages == False)
if new_status == 'restricted' and cm.new_chat_member:
restricted = cm.new_chat_member
if hasattr(restricted, 'can_send_messages') and not restricted.can_send_messages:
duration = None
if hasattr(restricted, 'until_date') and restricted.until_date:
duration = int(restricted.until_date.timestamp() - time.time())
return platform_events.MemberBannedEvent(
type='group.member_banned',
timestamp=cm.date.timestamp() if cm.date else time.time(),
adapter_name='telegram',
group=group,
member=member,
operator=inviter,
duration=duration,
source_platform_object=update,
)
# Other chat_member changes -> PlatformSpecificEvent
return platform_events.PlatformSpecificEvent(
type='platform.specific',
timestamp=cm.date.timestamp() if cm.date else time.time(),
adapter_name='telegram',
action='chat_member_updated',
data={
'old_status': old_status,
'new_status': new_status,
'chat_id': chat.id,
'user_id': member.id,
},
source_platform_object=update,
)
@staticmethod
def _convert_my_chat_member(update: Update) -> typing.Optional[platform_events.EBAEvent]:
"""Convert a my_chat_member update to bot status events."""
import time
mcm = update.my_chat_member
chat = mcm.chat
group = _make_user_group(chat)
inviter = _make_user(mcm.from_user) if mcm.from_user else None
old_status = mcm.old_chat_member.status if mcm.old_chat_member else None
new_status = mcm.new_chat_member.status if mcm.new_chat_member else None
# Bot invited to group
if old_status in (None, 'left', 'kicked') and new_status in ('member', 'administrator'):
return platform_events.BotInvitedToGroupEvent(
type='bot.invited_to_group',
timestamp=mcm.date.timestamp() if mcm.date else time.time(),
adapter_name='telegram',
group=group,
inviter=inviter,
source_platform_object=update,
)
# Bot removed from group
if old_status in ('member', 'administrator', 'creator') and new_status in ('left', 'kicked'):
return platform_events.BotRemovedFromGroupEvent(
type='bot.removed_from_group',
timestamp=mcm.date.timestamp() if mcm.date else time.time(),
adapter_name='telegram',
group=group,
operator=inviter,
source_platform_object=update,
)
# Bot muted
if new_status == 'restricted' and mcm.new_chat_member:
restricted = mcm.new_chat_member
if hasattr(restricted, 'can_send_messages') and not restricted.can_send_messages:
duration = None
if hasattr(restricted, 'until_date') and restricted.until_date:
duration = int(restricted.until_date.timestamp() - time.time())
return platform_events.BotMutedEvent(
type='bot.muted',
timestamp=mcm.date.timestamp() if mcm.date else time.time(),
adapter_name='telegram',
group=group,
operator=inviter,
duration=duration,
source_platform_object=update,
)
if old_status == 'restricted' and new_status in ('member', 'administrator') and mcm.new_chat_member:
return platform_events.BotUnmutedEvent(
type='bot.unmuted',
timestamp=mcm.date.timestamp() if mcm.date else time.time(),
adapter_name='telegram',
group=group,
operator=inviter,
source_platform_object=update,
)
return platform_events.PlatformSpecificEvent(
type='platform.specific',
timestamp=mcm.date.timestamp() if mcm.date else time.time(),
adapter_name='telegram',
action='my_chat_member_updated',
data={
'old_status': old_status,
'new_status': new_status,
'chat_id': chat.id,
},
source_platform_object=update,
)
@staticmethod
def _convert_reaction(update: Update) -> platform_events.MessageReactionEvent:
"""Convert a Telegram message_reaction to MessageReactionEvent."""
import time
reaction = update.message_reaction
chat = reaction.chat
# Extract newly added emojis
new_emojis = []
if reaction.new_reaction:
for r in reaction.new_reaction:
if hasattr(r, 'emoji'):
new_emojis.append(r.emoji)
elif hasattr(r, 'custom_emoji_id'):
new_emojis.append(str(r.custom_emoji_id))
user = platform_entities.User(id='')
if reaction.user:
user = _make_user(reaction.user)
ct = _chat_type(chat)
group = _make_user_group(chat) if ct == platform_entities.ChatType.GROUP else None
return platform_events.MessageReactionEvent(
type='message.reaction',
timestamp=reaction.date.timestamp() if reaction.date else time.time(),
adapter_name='telegram',
message_id=reaction.message_id,
user=user,
reaction=new_emojis[0] if new_emojis else '',
is_add=len(new_emojis) > 0,
chat_type=ct,
chat_id=chat.id,
group=group,
source_platform_object=update,
)
class LegacyEventConverter(abstract_platform_adapter.AbstractEventConverter):
"""Legacy event converter (compatibility layer).
Converts Telegram Updates to the old FriendMessage / GroupMessage format.
Used during the transition period to maintain backward compatibility.
"""
@staticmethod
async def yiri2target(event: platform_events.MessageEvent, bot: telegram.Bot):
return event.source_platform_object
@staticmethod
async def target2yiri(event: Update, bot: telegram.Bot, bot_account_id: str):
"""Convert to legacy format (FriendMessage / GroupMessage)."""
import langbot_plugin.api.entities.builtin.platform.events as legacy_events
import langbot_plugin.api.entities.builtin.platform.entities as legacy_entities
if not event.message:
return None
lb_message = await TelegramMessageConverter.target2yiri(event.message, bot, bot_account_id)
if event.effective_chat.type == 'private':
return legacy_events.FriendMessage(
sender=legacy_entities.Friend(
id=event.effective_chat.id,
nickname=event.effective_chat.first_name,
remark=str(event.effective_chat.id),
),
message_chain=lb_message,
time=event.message.date.timestamp(),
source_platform_object=event,
)
else:
return legacy_events.GroupMessage(
sender=legacy_entities.GroupMember(
id=event.effective_chat.id,
member_name=event.effective_chat.title,
permission=legacy_entities.Permission.Member,
group=legacy_entities.Group(
id=event.effective_chat.id,
name=event.effective_chat.title,
permission=legacy_entities.Permission.Member,
),
special_title='',
),
message_chain=lb_message,
time=event.message.date.timestamp(),
source_platform_object=event,
)
@@ -0,0 +1,98 @@
apiVersion: v1
kind: MessagePlatformAdapter
metadata:
name: telegram-eba
label:
en_US: Telegram (EBA)
zh_Hans: 电报 (EBA)
description:
en_US: Telegram Bot adapter (EBA architecture)
zh_Hans: 电报 Bot 适配器(EBA 架构版本)
icon: telegram.svg
spec:
config:
- name: token
label:
en_US: Token
zh_Hans: 令牌
type: string
required: true
default: ""
- name: markdown_card
label:
en_US: Markdown Card
zh_Hans: 是否使用 Markdown 卡片
type: boolean
required: false
default: true
- name: enable-stream-reply
label:
en_US: Enable Stream Reply Mode
zh_Hans: 启用电报流式回复模式
description:
en_US: If enabled, the bot will use the stream of telegram reply mode
zh_Hans: 如果启用,将使用电报流式方式来回复内容
type: boolean
required: true
default: false
supported_events:
- message.received
- message.edited
- message.reaction
- group.member_joined
- group.member_left
- group.member_banned
- bot.invited_to_group
- bot.removed_from_group
- bot.muted
- bot.unmuted
- platform.specific
supported_apis:
required:
- send_message
- reply_message
optional:
- edit_message
- delete_message
- forward_message
- get_group_info
- get_group_member_list
- get_group_member_info
- get_user_info
- get_file_url
- mute_member
- unmute_member
- kick_member
- leave_group
- call_platform_api
platform_specific_apis:
- action: pin_message
description: { en_US: "Pin a message", zh_Hans: "置顶消息" }
- action: unpin_message
description: { en_US: "Unpin a message", zh_Hans: "取消置顶" }
- action: unpin_all_messages
description: { en_US: "Unpin all messages", zh_Hans: "取消所有置顶" }
- action: get_chat_administrators
description: { en_US: "Get chat admins", zh_Hans: "获取群管理员列表" }
- action: set_chat_title
description: { en_US: "Set chat title", zh_Hans: "修改群名称" }
- action: set_chat_description
description: { en_US: "Set chat description", zh_Hans: "修改群描述" }
- action: get_chat_member_count
description: { en_US: "Get member count", zh_Hans: "获取群成员数量" }
- action: send_chat_action
description: { en_US: "Send chat action (typing, etc.)", zh_Hans: "发送聊天动作" }
- action: create_chat_invite_link
description: { en_US: "Create invite link", zh_Hans: "创建邀请链接" }
- action: answer_callback_query
description: { en_US: "Answer callback query", zh_Hans: "应答回调查询" }
execution:
python:
path: ./adapter.py
attr: TelegramAdapter
@@ -0,0 +1,145 @@
"""Telegram message chain converter.
Migrated from the original sources/telegram.py TelegramMessageConverter. Logic unchanged.
"""
from __future__ import annotations
import base64
import telegram
from langbot.pkg.utils import httpclient
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.entities.builtin.platform.message as platform_message
class TelegramMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
@staticmethod
async def yiri2target(message_chain: platform_message.MessageChain, bot: telegram.Bot) -> list[dict]:
"""Convert a LangBot MessageChain to a list of Telegram-sendable components."""
components = []
for component in message_chain:
if isinstance(component, platform_message.Plain):
components.append({'type': 'text', 'text': component.text})
elif isinstance(component, platform_message.Image):
photo_bytes = None
if component.base64:
b64_data = component.base64
if ';base64,' in b64_data:
b64_data = b64_data.split(';base64,', 1)[1]
photo_bytes = base64.b64decode(b64_data)
elif component.url:
session = httpclient.get_session()
async with session.get(component.url) as response:
photo_bytes = await response.read()
elif component.path:
with open(component.path, 'rb') as f:
photo_bytes = f.read()
components.append({'type': 'photo', 'photo': photo_bytes})
elif isinstance(component, platform_message.File):
file_bytes = None
if component.base64:
b64_data = component.base64
if ';base64,' in b64_data:
b64_data = b64_data.split(';base64,', 1)[1]
file_bytes = base64.b64decode(b64_data)
elif component.url:
session = httpclient.get_session()
async with session.get(component.url) as response:
file_bytes = await response.read()
elif component.path:
with open(component.path, 'rb') as f:
file_bytes = f.read()
file_name = getattr(component, 'name', None) or 'file'
components.append({'type': 'document', 'document': file_bytes, 'filename': file_name})
elif isinstance(component, platform_message.Forward):
for node in component.node_list:
components.extend(await TelegramMessageConverter.yiri2target(node.message_chain, bot))
return components
@staticmethod
async def target2yiri(message: telegram.Message, bot: telegram.Bot, bot_account_id: str):
"""Convert a Telegram Message to a LangBot MessageChain."""
message_components = []
def parse_message_text(text: str) -> list[platform_message.MessageComponent]:
msg_components = []
if f'@{bot_account_id}' in text:
msg_components.append(platform_message.At(target=bot_account_id))
text = text.replace(f'@{bot_account_id}', '')
msg_components.append(platform_message.Plain(text=text))
return msg_components
if message.text:
message_text = message.text
message_components.extend(parse_message_text(message_text))
if message.photo:
if message.caption:
message_components.extend(parse_message_text(message.caption))
file = await message.photo[-1].get_file()
file_bytes = None
file_format = ''
async with httpclient.get_session(trust_env=True).get(file.file_path) as response:
file_bytes = await response.read()
file_format = 'image/jpeg'
message_components.append(
platform_message.Image(
base64=f'data:{file_format};base64,{base64.b64encode(file_bytes).decode("utf-8")}'
)
)
if message.voice:
if message.caption:
message_components.extend(parse_message_text(message.caption))
file = await message.voice.get_file()
file_bytes = None
file_format = message.voice.mime_type or 'audio/ogg'
async with httpclient.get_session(trust_env=True).get(file.file_path) as response:
file_bytes = await response.read()
message_components.append(
platform_message.Voice(
base64=f'data:{file_format};base64,{base64.b64encode(file_bytes).decode("utf-8")}',
length=message.voice.duration,
)
)
if message.document:
if message.caption:
message_components.extend(parse_message_text(message.caption))
file = await message.document.get_file()
file_name = message.document.file_name or 'document'
file_size = message.document.file_size or 0
file_format = message.document.mime_type or 'application/octet-stream'
file_bytes = None
async with httpclient.get_session(trust_env=True).get(file.file_path) as response:
file_bytes = await response.read()
message_components.append(
platform_message.File(
name=file_name,
size=file_size,
base64=f'data:{file_format};base64,{base64.b64encode(file_bytes).decode("utf-8")}',
)
)
return platform_message.MessageChain(message_components)
@@ -0,0 +1,125 @@
"""Telegram platform-specific API dispatch table for call_platform_api."""
from __future__ import annotations
import typing
import telegram
async def pin_message(bot: telegram.Bot, params: dict) -> dict:
"""Pin a message in a chat."""
await bot.pin_chat_message(
chat_id=params['chat_id'],
message_id=params['message_id'],
disable_notification=params.get('disable_notification', False),
)
return {"ok": True}
async def unpin_message(bot: telegram.Bot, params: dict) -> dict:
"""Unpin a message in a chat."""
await bot.unpin_chat_message(
chat_id=params['chat_id'],
message_id=params.get('message_id'),
)
return {"ok": True}
async def unpin_all_messages(bot: telegram.Bot, params: dict) -> dict:
"""Unpin all messages in a chat."""
await bot.unpin_all_chat_messages(chat_id=params['chat_id'])
return {"ok": True}
async def get_chat_administrators(bot: telegram.Bot, params: dict) -> dict:
"""Get chat administrator list."""
admins = await bot.get_chat_administrators(chat_id=params['chat_id'])
return {
"administrators": [
{
"user_id": a.user.id,
"username": a.user.username,
"first_name": a.user.first_name,
"status": a.status,
"custom_title": getattr(a, 'custom_title', None),
}
for a in admins
]
}
async def set_chat_title(bot: telegram.Bot, params: dict) -> dict:
"""Set chat title."""
await bot.set_chat_title(
chat_id=params['chat_id'],
title=params['title'],
)
return {"ok": True}
async def set_chat_description(bot: telegram.Bot, params: dict) -> dict:
"""Set chat description."""
await bot.set_chat_description(
chat_id=params['chat_id'],
description=params.get('description', ''),
)
return {"ok": True}
async def get_chat_member_count(bot: telegram.Bot, params: dict) -> dict:
"""Get chat member count."""
count = await bot.get_chat_member_count(chat_id=params['chat_id'])
return {"count": count}
async def send_chat_action(bot: telegram.Bot, params: dict) -> dict:
"""Send a chat action (e.g. typing)."""
await bot.send_chat_action(
chat_id=params['chat_id'],
action=params.get('action', 'typing'),
)
return {"ok": True}
async def create_chat_invite_link(bot: telegram.Bot, params: dict) -> dict:
"""Create a chat invite link."""
link = await bot.create_chat_invite_link(
chat_id=params['chat_id'],
name=params.get('name'),
expire_date=params.get('expire_date'),
member_limit=params.get('member_limit'),
)
return {
"invite_link": link.invite_link,
"name": link.name,
"is_primary": link.is_primary,
"is_revoked": link.is_revoked,
}
async def answer_callback_query(bot: telegram.Bot, params: dict) -> dict:
"""Answer a callback query."""
await bot.answer_callback_query(
callback_query_id=params['callback_query_id'],
text=params.get('text'),
show_alert=params.get('show_alert', False),
url=params.get('url'),
)
return {"ok": True}
# ---- Action dispatch table ----
PLATFORM_API_MAP: dict[str, typing.Callable[[telegram.Bot, dict], typing.Awaitable[dict]]] = {
"pin_message": pin_message,
"unpin_message": unpin_message,
"unpin_all_messages": unpin_all_messages,
"get_chat_administrators": get_chat_administrators,
"set_chat_title": set_chat_title,
"set_chat_description": set_chat_description,
"get_chat_member_count": get_chat_member_count,
"send_chat_action": send_chat_action,
"create_chat_invite_link": create_chat_invite_link,
"answer_callback_query": answer_callback_query,
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="96px" height="96px"><path fill="#29b6f6" d="M24,4C13,4,4,13,4,24s9,20,20,20s20-9,20-20S35,4,24,4z"/><path fill="#fff" d="M34,15l-3.7,19.1c0,0-0.2,0.9-1.2,0.9c-0.6,0-0.9-0.3-0.9-0.3L20,28l-4-2l-5.1-1.4c0,0-0.9-0.3-0.9-1 c0-0.6,0.9-0.9,0.9-0.9l21.3-8.5c0,0,0.7-0.2,1.1-0.2c0.3,0,0.6,0.1,0.6,0.5C34,14.8,34,15,34,15z"/><path fill="#b0bec5" d="M23,30.5l-3.4,3.4c0,0-0.1,0.1-0.3,0.1c-0.1,0-0.1,0-0.2,0l1-6L23,30.5z"/><path fill="#cfd8dc" d="M29.9,18.2c-0.2-0.2-0.5-0.3-0.7-0.1L16,26c0,0,2.1,5.9,2.4,6.9c0.3,1,0.6,1,0.6,1l1-6l9.8-9.1 C30,18.7,30.1,18.4,29.9,18.2z"/></svg>

After

Width:  |  Height:  |  Size: 634 B

@@ -0,0 +1,13 @@
"""Telegram platform-specific type definitions."""
from __future__ import annotations
from enum import Enum
class TelegramChatType(str, Enum):
"""Telegram chat type."""
PRIVATE = "private"
GROUP = "group"
SUPERGROUP = "supergroup"
CHANNEL = "channel"
@@ -0,0 +1 @@
@@ -0,0 +1,221 @@
from __future__ import annotations
import asyncio
import traceback
import typing
import pydantic
from langbot.libs.wecom_api.api import WecomClient
from langbot.libs.wecom_api.wecomevent import WecomEvent
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
from langbot.pkg.platform.adapters.wecom.api_impl import WecomAPIMixin
from langbot.pkg.platform.adapters.wecom.event_converter import WecomEventConverter
from langbot.pkg.platform.adapters.wecom.message_converter import WecomMessageConverter
from langbot.pkg.platform.adapters.wecom.platform_api import PLATFORM_API_MAP
from langbot_plugin.api.entities.builtin.platform import events as platform_events
from langbot_plugin.api.entities.builtin.platform import message as platform_message
from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError
class WecomAdapter(WecomAPIMixin, abstract_platform_adapter.AbstractPlatformAdapter):
bot: WecomClient = pydantic.Field(exclude=True)
message_converter: WecomMessageConverter = WecomMessageConverter()
event_converter: WecomEventConverter = WecomEventConverter()
config: dict
bot_uuid: str | None = None
listeners: dict[
typing.Type[platform_events.Event],
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
] = {}
_message_cache: dict[str, platform_events.MessageReceivedEvent] = {}
_user_cache: dict[str, typing.Any] = {}
class Config:
arbitrary_types_allowed = True
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
required_keys = [
'corpid',
'secret',
'token',
'EncodingAESKey',
]
missing_keys = [key for key in required_keys if key not in config]
if missing_keys:
raise Exception(f'WeCom missing required config fields: {missing_keys}')
bot = WecomClient(
corpid=config['corpid'],
secret=config['secret'],
token=config['token'],
EncodingAESKey=config['EncodingAESKey'],
contacts_secret=config.get('contacts_secret', ''),
logger=logger,
unified_mode=True,
api_base_url=config.get('api_base_url', 'https://qyapi.weixin.qq.com/cgi-bin'),
)
super().__init__(
config=config,
logger=logger,
bot=bot,
bot_account_id='',
bot_uuid=None,
listeners={},
_message_cache={},
_user_cache={},
)
self._register_native_handlers()
def set_bot_uuid(self, bot_uuid: str):
self.bot_uuid = bot_uuid
def get_supported_events(self) -> list[str]:
return [
'message.received',
'platform.specific',
]
def get_supported_apis(self) -> list[str]:
return [
'send_message',
'reply_message',
'get_message',
'get_user_info',
'get_friend_list',
'call_platform_api',
]
async def send_message(
self,
target_type: str,
target_id: str,
message: platform_message.MessageChain,
) -> platform_events.MessageResult:
if target_type not in ('person', 'private'):
raise NotSupportedError(f'send_message:{target_type}')
user_id, agent_id = self._parse_target_id(target_id)
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
raw_results = []
for content in content_list:
raw_results.append(await self._send_content(user_id, agent_id, content))
return platform_events.MessageResult(raw={'results': raw_results})
async def reply_message(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
quote_origin: bool = False,
) -> platform_events.MessageResult:
wecom_event = await WecomEventConverter.yiri2target(message_source)
if not isinstance(wecom_event, WecomEvent):
raise ValueError('WeCom reply_message requires a WecomEvent source object')
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
raw_results = []
for content in content_list:
raw_results.append(await self._send_content(wecom_event.user_id, int(wecom_event.agent_id), content))
return platform_events.MessageResult(message_id=wecom_event.message_id, raw={'results': raw_results})
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
handler = PLATFORM_API_MAP.get(action)
if handler is None:
raise NotSupportedError(f'call_platform_api:{action}')
return await handler(self.bot, params)
def register_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable[
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
],
):
self.listeners[event_type] = callback
def unregister_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable[
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
],
):
registered = self.listeners.get(event_type)
if registered is callback:
self.listeners.pop(event_type, None)
async def handle_unified_webhook(self, bot_uuid: str, path: str, request):
return await self.bot.handle_unified_webhook(request)
async def run_async(self):
async def keep_alive():
while True:
await asyncio.sleep(1)
await self.logger.info('WeCom EBA adapter running in unified webhook mode')
await keep_alive()
async def kill(self) -> bool:
return True
async def is_muted(self, group_id: int | None = None) -> bool:
return False
def _register_native_handlers(self):
async def on_message(event: WecomEvent):
await self._handle_native_event(event)
self.bot.on_message('text')(on_message)
self.bot.on_message('image')(on_message)
async def _handle_native_event(self, event: WecomEvent):
self.bot_account_id = event.receiver_id or self.bot_account_id
try:
if platform_events.FriendMessage in self.listeners:
legacy_event = await self.event_converter.target2legacy(event, self.bot)
if legacy_event:
callback = self.listeners.get(type(legacy_event))
if callback:
await callback(legacy_event, self)
eba_event = await self.event_converter.target2yiri(event, self.bot)
if eba_event:
self._cache_event(eba_event)
await self._dispatch_eba_event(eba_event)
except Exception:
await self.logger.error(f'Error in wecom native event: {traceback.format_exc()}')
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
callback = self.listeners.get(event_type)
if callback:
await callback(event, self)
return
def _cache_event(self, event: platform_events.Event):
if not isinstance(event, platform_events.MessageReceivedEvent):
return
self._message_cache[str(event.message_id)] = event
self._user_cache[str(event.sender.id)] = event.sender
async def _send_content(self, user_id: str, agent_id: int, content: dict):
content_type = content.get('type')
if content_type == 'text':
return await self.bot.send_private_msg(user_id, agent_id, content.get('content', ''))
if content_type == 'image':
return await self.bot.send_image(user_id, agent_id, content['media_id'])
if content_type == 'voice':
return await self.bot.send_voice(user_id, agent_id, content['media_id'])
if content_type == 'file':
return await self.bot.send_file(user_id, agent_id, content['media_id'])
raise NotSupportedError(f'send_content:{content_type}')
@staticmethod
def _parse_target_id(target_id: str) -> tuple[str, int]:
user_id, sep, agent_id = str(target_id).partition('|')
if not user_id or not sep or not agent_id:
raise ValueError('WeCom target_id must be formatted as "user_id|agent_id"')
return user_id, int(agent_id)
@@ -0,0 +1,79 @@
from __future__ import annotations
import typing
from langbot.libs.wecom_api.api import WecomClient
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
from langbot_plugin.api.entities.builtin.platform import message as platform_message
from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError
class WecomAPIMixin:
bot: WecomClient
_message_cache: dict[str, platform_events.MessageReceivedEvent]
_user_cache: dict[str, platform_entities.User]
async def get_message(
self,
chat_type: str,
chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
) -> platform_events.MessageReceivedEvent:
event = self._message_cache.get(str(message_id))
if event is None:
raise NotSupportedError('get_message:message_not_cached')
return event
async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User:
cached = self._user_cache.get(str(user_id))
if cached is not None:
return cached
info = await self.bot.get_user_info(str(user_id))
return platform_entities.User(
id=info.get('userid') or user_id,
nickname=info.get('name') or str(user_id),
username=info.get('alias') or info.get('userid') or None,
)
async def get_friend_list(self) -> list[platform_entities.User]:
return list(self._user_cache.values())
async def upload_file(self, file_data: bytes, filename: str) -> str:
raise NotSupportedError('upload_file')
async def get_file_url(self, file_id: str) -> str:
raise NotSupportedError('get_file_url')
async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup:
raise NotSupportedError('get_group_info')
async def get_group_member_list(
self,
group_id: typing.Union[int, str],
) -> list[platform_entities.UserGroupMember]:
raise NotSupportedError('get_group_member_list')
async def get_group_member_info(
self,
group_id: typing.Union[int, str],
user_id: typing.Union[int, str],
) -> platform_entities.UserGroupMember:
raise NotSupportedError('get_group_member_info')
async def edit_message(
self,
chat_type: str,
chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
new_content: platform_message.MessageChain,
) -> None:
raise NotSupportedError('edit_message')
async def delete_message(
self,
chat_type: str,
chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
) -> None:
raise NotSupportedError('delete_message')
@@ -0,0 +1,91 @@
from __future__ import annotations
import typing
from langbot.libs.wecom_api.api import WecomClient
from langbot.libs.wecom_api.wecomevent import WecomEvent
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
from langbot.pkg.platform.adapters.wecom.message_converter import WecomMessageConverter
from langbot.pkg.platform.adapters.wecom.types import ADAPTER_NAME, make_private_chat_id
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
@staticmethod
async def yiri2target(event: platform_events.Event) -> WecomEvent | None:
return getattr(event, 'source_platform_object', None)
@staticmethod
async def target2legacy(event: WecomEvent, bot: WecomClient | None = None) -> platform_events.FriendMessage | None:
eba_event = await WecomEventConverter.target2yiri(event, bot)
if hasattr(eba_event, 'to_legacy_event'):
return eba_event.to_legacy_event()
if event.type in {'text', 'image'} and eba_event is not None:
friend = platform_entities.Friend(
id=f'u{event.user_id}',
nickname=getattr(getattr(eba_event, 'sender', None), 'nickname', str(event.user_id or '')),
remark='',
)
return platform_events.FriendMessage(
sender=friend,
message_chain=eba_event.message_chain,
time=getattr(eba_event, 'timestamp', None),
source_platform_object=event,
)
return None
@staticmethod
async def target2yiri(event: WecomEvent, bot: WecomClient | None = None) -> platform_events.Event | None:
if event.type in {'text', 'image'}:
return await WecomEventConverter.message_to_eba(event, bot)
return WecomEventConverter.platform_specific(event, f'message.{event.detail_type or event.type or "unknown"}')
@staticmethod
async def message_to_eba(event: WecomEvent, bot: WecomClient | None = None) -> platform_events.MessageReceivedEvent:
if event.type == 'image':
message_chain = await WecomMessageConverter.target2yiri_image(event.picurl, event.message_id)
else:
message_chain = await WecomMessageConverter.target2yiri_text(event.message, event.message_id)
sender = await WecomEventConverter.user_from_event(event, bot)
return platform_events.MessageReceivedEvent(
type='message.received',
adapter_name=ADAPTER_NAME,
message_id=event.message_id or '',
message_chain=message_chain,
sender=sender,
chat_type=platform_entities.ChatType.PRIVATE,
chat_id=make_private_chat_id(event.user_id, event.agent_id),
group=None,
timestamp=float(event.timestamp or 0),
source_platform_object=event,
)
@staticmethod
async def user_from_event(event: WecomEvent, bot: WecomClient | None = None) -> platform_entities.User:
nickname = str(event.user_id or '')
raw: dict[str, typing.Any] = {}
if bot and event.user_id:
try:
raw = await bot.get_user_info(event.user_id)
nickname = raw.get('name') or nickname
except Exception:
raw = {}
return platform_entities.User(
id=event.user_id or '',
nickname=nickname,
username=raw.get('alias') or raw.get('userid') or None,
)
@staticmethod
def platform_specific(event: WecomEvent, action: str) -> platform_events.PlatformSpecificEvent:
return platform_events.PlatformSpecificEvent(
type='platform.specific',
adapter_name=ADAPTER_NAME,
action=action,
data=dict(event),
timestamp=float(event.timestamp or 0),
source_platform_object=event,
)
@@ -0,0 +1,117 @@
apiVersion: v1
kind: MessagePlatformAdapter
metadata:
name: wecom-eba
label:
en_US: WeCom (EBA)
zh_Hans: 企业微信 (EBA)
zh_Hant: 企業微信 (EBA)
description:
en_US: WeCom application message adapter (EBA architecture)
zh_Hans: 企业微信内部应用消息适配器(EBA 架构版本)
zh_Hant: 企業微信內部應用訊息適配器(EBA 架構版本)
icon: wecom.png
spec:
categories:
- popular
- china
help_links:
zh: https://link.langbot.app/zh/platforms/wecom
en: https://link.langbot.app/en/platforms/wecom
ja: https://link.langbot.app/ja/platforms/wecom
config:
- name: webhook_url
label:
en_US: Webhook Callback URL
zh_Hans: Webhook 回调地址
zh_Hant: Webhook 回調地址
description:
en_US: Copy this URL and paste it into your WeCom app's webhook configuration
zh_Hans: 复制此地址并粘贴到企业微信应用的 Webhook 配置中
zh_Hant: 複製此地址並貼到企業微信應用的 Webhook 設定中
type: webhook-url
required: false
default: ""
- name: corpid
label:
en_US: Corpid
zh_Hans: 企业ID
zh_Hant: 企業ID
type: string
required: true
default: ""
- name: secret
label:
en_US: Secret
zh_Hans: 密钥 (Secret)
zh_Hant: 密鑰 (Secret)
type: string
required: true
default: ""
- name: token
label:
en_US: Token
zh_Hans: 令牌 (Token)
zh_Hant: 令牌 (Token)
type: string
required: true
default: ""
- name: EncodingAESKey
label:
en_US: EncodingAESKey
zh_Hans: 消息加解密密钥 (EncodingAESKey)
zh_Hant: 訊息加解密密鑰 (EncodingAESKey)
type: string
required: true
default: ""
- name: contacts_secret
label:
en_US: Contacts Secret
zh_Hans: 通讯录密钥
zh_Hant: 通訊錄密鑰
type: string
required: false
default: ""
- name: api_base_url
label:
en_US: API Base URL
zh_Hans: API 基础 URL
zh_Hant: API 基礎 URL
description:
en_US: Optional WeCom API base URL for private network or reverse proxy deployments.
zh_Hans: 可选,若部署在内网环境并通过反向代理访问企业微信 API,可根据文档填写此项
zh_Hant: 可選,若部署在內網環境並透過反向代理存取企業微信 API,可根據文件填寫此項
type: string
required: false
default: "https://qyapi.weixin.qq.com/cgi-bin"
supported_events:
- message.received
- platform.specific
supported_apis:
required:
- send_message
- reply_message
optional:
- get_message
- get_user_info
- get_friend_list
- call_platform_api
platform_specific_apis:
- action: check_access_token
description: { en_US: "Check whether the current WeCom access token is usable", zh_Hans: "检查当前企业微信 access token 是否可用" }
- action: refresh_access_token
description: { en_US: "Refresh the WeCom access token", zh_Hans: "刷新企业微信 access token" }
- action: get_user_info
description: { en_US: "Get WeCom user information by user ID", zh_Hans: "按用户 ID 获取企业微信用户信息" }
- action: send_to_all
description: { en_US: "Send an application text message to all contacts available to the configured contacts secret", zh_Hans: "使用配置的通讯录密钥向可见成员群发应用文本消息" }
execution:
python:
path: ./adapter.py
attr: WecomAdapter
@@ -0,0 +1,82 @@
from __future__ import annotations
import datetime
from langbot.libs.wecom_api.api import WecomClient
from langbot.pkg.utils import image
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
from langbot_plugin.api.entities.builtin.platform import message as platform_message
def split_string_by_bytes(text: str, limit: int = 2048, encoding: str = 'utf-8') -> list[str]:
"""Split text without cutting a multi-byte character in half."""
bytes_data = text.encode(encoding)
total_len = len(bytes_data)
parts: list[str] = []
start = 0
while start < total_len:
end = min(start + limit, total_len)
chunk = bytes_data[start:end]
part = chunk.decode(encoding, errors='ignore')
part_len = len(part.encode(encoding))
if part_len == 0 and end < total_len:
start += 1
continue
parts.append(part)
start += part_len
return parts
class WecomMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
@staticmethod
async def yiri2target(message_chain: platform_message.MessageChain, bot: WecomClient) -> list[dict]:
content_list: list[dict] = []
for msg in message_chain:
if isinstance(msg, platform_message.Source):
continue
if isinstance(msg, platform_message.Plain):
content_list.extend({'type': 'text', 'content': chunk} for chunk in split_string_by_bytes(msg.text))
elif isinstance(msg, platform_message.Image):
content_list.append({'type': 'image', 'media_id': await bot.get_media_id(msg)})
elif isinstance(msg, platform_message.Voice):
content_list.append({'type': 'voice', 'media_id': await bot.get_media_id(msg)})
elif isinstance(msg, platform_message.File):
content_list.append({'type': 'file', 'media_id': await bot.get_media_id(msg)})
elif isinstance(msg, platform_message.Forward):
for node in msg.node_list:
content_list.extend(await WecomMessageConverter.yiri2target(node.message_chain, bot))
elif isinstance(msg, platform_message.Quote):
if msg.id is not None:
content_list.append({'type': 'text', 'content': f'[Quote {msg.id}] '})
if msg.origin:
content_list.extend(await WecomMessageConverter.yiri2target(msg.origin, bot))
elif isinstance(msg, platform_message.At):
content_list.append({'type': 'text', 'content': f'@{msg.display or msg.target}'})
elif isinstance(msg, platform_message.AtAll):
content_list.append({'type': 'text', 'content': '@all'})
else:
content_list.append({'type': 'text', 'content': str(msg)})
return content_list
@staticmethod
async def target2yiri_text(message: str | None, message_id: int | str | None = -1) -> platform_message.MessageChain:
return platform_message.MessageChain(
[
platform_message.Source(id=message_id, time=datetime.datetime.now()),
platform_message.Plain(text=message or ''),
]
)
@staticmethod
async def target2yiri_image(picurl: str, message_id: int | str | None = -1) -> platform_message.MessageChain:
image_base64, image_format = await image.get_wecom_image_base64(pic_url=picurl)
return platform_message.MessageChain(
[
platform_message.Source(id=message_id, time=datetime.datetime.now()),
platform_message.Image(base64=f'data:image/{image_format};base64,{image_base64}'),
]
)
@@ -0,0 +1,40 @@
from __future__ import annotations
import typing
from langbot.libs.wecom_api.api import WecomClient
async def check_access_token(bot: WecomClient, params: dict) -> dict:
return {'valid': await bot.check_access_token()}
async def refresh_access_token(bot: WecomClient, params: dict) -> dict:
bot.access_token = await bot.get_access_token(bot.secret)
return {'ok': bool(bot.access_token)}
async def get_user_info(bot: WecomClient, params: dict) -> dict:
user_id = params.get('user_id') or params.get('userid')
if not user_id:
raise ValueError('user_id is required')
return await bot.get_user_info(str(user_id))
async def send_to_all(bot: WecomClient, params: dict) -> dict:
content = params.get('content')
agent_id = params.get('agent_id') or params.get('agentid')
if not content:
raise ValueError('content is required')
if agent_id is None:
raise ValueError('agent_id is required')
await bot.send_to_all(str(content), int(agent_id))
return {'ok': True}
PLATFORM_API_MAP: dict[str, typing.Callable[[WecomClient, dict], typing.Awaitable[dict]]] = {
'check_access_token': check_access_token,
'refresh_access_token': refresh_access_token,
'get_user_info': get_user_info,
'send_to_all': send_to_all,
}
@@ -0,0 +1,12 @@
from __future__ import annotations
ADAPTER_NAME = 'wecom-eba'
def make_private_chat_id(user_id: str | int | None, agent_id: str | int | None) -> str:
"""Build the routable private chat id used by the WeCom EBA adapter."""
user = str(user_id or '')
agent = str(agent_id or '')
if not user or not agent:
return user
return f'{user}|{agent}'
Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

@@ -0,0 +1 @@
from __future__ import annotations

Some files were not shown because too many files have changed in this diff Show More