Merge remote-tracking branch 'origin/refactor/eba' into dev_4_11
# Conflicts: # pyproject.toml # src/langbot/pkg/pipeline/preproc/preproc.py # uv.lock
@@ -438,8 +438,13 @@ class DingTalkClient:
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(url, headers=headers, json=data)
|
||||
try:
|
||||
body = response.json()
|
||||
except Exception:
|
||||
body = {'text': response.text}
|
||||
if response.status_code == 200:
|
||||
return
|
||||
return body
|
||||
raise Exception(f'Error: {response.status_code}, {body}')
|
||||
except Exception:
|
||||
await self.logger.error(f'failed to send proactive massage to person: {traceback.format_exc()}')
|
||||
raise Exception(f'failed to send proactive massage to person: {traceback.format_exc()}')
|
||||
@@ -464,8 +469,13 @@ class DingTalkClient:
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(url, headers=headers, json=data)
|
||||
try:
|
||||
body = response.json()
|
||||
except Exception:
|
||||
body = {'text': response.text}
|
||||
if response.status_code == 200:
|
||||
return
|
||||
return body
|
||||
raise Exception(f'Error: {response.status_code}, {body}')
|
||||
except Exception:
|
||||
await self.logger.error(f'failed to send proactive massage to group: {traceback.format_exc()}')
|
||||
raise Exception(f'failed to send proactive massage to group: {traceback.format_exc()}')
|
||||
|
||||
@@ -93,15 +93,30 @@ class OAClient:
|
||||
raise Exception('msg_signature不在请求体中')
|
||||
|
||||
if req.method == 'GET':
|
||||
# 校验签名
|
||||
if msg_signature:
|
||||
wxcpt = WXBizMsgCrypt(self.token, self.aes, self.appid)
|
||||
ret, reply_echo = wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr)
|
||||
if ret == 0:
|
||||
return reply_echo
|
||||
await self.logger.error(
|
||||
'OfficialAccount encrypted URL verification failed: '
|
||||
f'ret={ret}, timestamp_present={bool(timestamp)}, nonce_present={bool(nonce)}, '
|
||||
f'echostr_present={bool(echostr)}'
|
||||
)
|
||||
|
||||
# Plaintext callback verification.
|
||||
check_str = ''.join(sorted([self.token, timestamp, nonce]))
|
||||
check_signature = hashlib.sha1(check_str.encode('utf-8')).hexdigest()
|
||||
|
||||
if check_signature == signature:
|
||||
return echostr # 验证成功返回echostr
|
||||
else:
|
||||
await self.logger.error('拒绝请求')
|
||||
raise Exception('拒绝请求')
|
||||
await self.logger.error(
|
||||
'OfficialAccount plaintext URL verification failed: '
|
||||
f'signature_present={bool(signature)}, timestamp_present={bool(timestamp)}, '
|
||||
f'nonce_present={bool(nonce)}, echostr_present={bool(echostr)}'
|
||||
)
|
||||
return 'signature verification failed', 403
|
||||
elif req.method == 'POST':
|
||||
encryt_msg = await req.data
|
||||
wxcpt = WXBizMsgCrypt(self.token, self.aes, self.appid)
|
||||
@@ -279,9 +294,27 @@ class OAClientForLongerResponse:
|
||||
raise Exception('msg_signature不在请求体中')
|
||||
|
||||
if req.method == 'GET':
|
||||
if msg_signature:
|
||||
wxcpt = WXBizMsgCrypt(self.token, self.aes, self.appid)
|
||||
ret, reply_echo = wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr)
|
||||
if ret == 0:
|
||||
return reply_echo
|
||||
await self.logger.error(
|
||||
'OfficialAccount encrypted URL verification failed: '
|
||||
f'ret={ret}, timestamp_present={bool(timestamp)}, nonce_present={bool(nonce)}, '
|
||||
f'echostr_present={bool(echostr)}'
|
||||
)
|
||||
|
||||
check_str = ''.join(sorted([self.token, timestamp, nonce]))
|
||||
check_signature = hashlib.sha1(check_str.encode('utf-8')).hexdigest()
|
||||
return echostr if check_signature == signature else '拒绝请求'
|
||||
if check_signature == signature:
|
||||
return echostr
|
||||
await self.logger.error(
|
||||
'OfficialAccount plaintext URL verification failed: '
|
||||
f'signature_present={bool(signature)}, timestamp_present={bool(timestamp)}, '
|
||||
f'nonce_present={bool(nonce)}, echostr_present={bool(echostr)}'
|
||||
)
|
||||
return 'signature verification failed', 403
|
||||
|
||||
elif req.method == 'POST':
|
||||
encryt_msg = await req.data
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
@@ -7,7 +9,7 @@ import uuid
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass, field
|
||||
import re
|
||||
from typing import Any, Callable, Optional, Tuple
|
||||
from typing import TYPE_CHECKING, Any, Callable, Optional, Tuple
|
||||
from urllib.parse import unquote
|
||||
|
||||
import httpx
|
||||
@@ -16,7 +18,9 @@ from quart import Quart, request, Response, jsonify
|
||||
|
||||
from langbot.libs.wecom_ai_bot_api import wecombotevent
|
||||
from langbot.libs.wecom_ai_bot_api.WXBizMsgCrypt3 import WXBizMsgCrypt
|
||||
from langbot.pkg.platform.logger import EventLogger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from langbot.pkg.platform.logger import EventLogger
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -15,13 +15,15 @@ import json
|
||||
import secrets
|
||||
import time
|
||||
import traceback
|
||||
from typing import Any, Callable, Optional
|
||||
from typing import TYPE_CHECKING, Any, Callable, Optional
|
||||
|
||||
import aiohttp
|
||||
|
||||
from langbot.libs.wecom_ai_bot_api import wecombotevent
|
||||
from langbot.libs.wecom_ai_bot_api.api import parse_wecom_bot_message, StreamSession
|
||||
from langbot.pkg.platform.logger import EventLogger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from langbot.pkg.platform.logger import EventLogger
|
||||
|
||||
DEFAULT_WS_URL = 'wss://openws.work.weixin.qq.com'
|
||||
|
||||
|
||||
@@ -207,7 +207,33 @@ class WecomCSClient:
|
||||
return await self.send_text_msg(open_kfid, external_userid, msgid, content)
|
||||
if data['errcode'] != 0:
|
||||
await self.logger.error(f'发送消息失败:{data}')
|
||||
raise Exception('Failed to send message')
|
||||
raise Exception(f'Failed to send message: {data}')
|
||||
return data
|
||||
|
||||
async def send_image_msg(self, open_kfid: str, external_userid: str, msgid: str, media_id: str):
|
||||
if not await self.check_access_token():
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
|
||||
url = f'{self.base_url}/kf/send_msg?access_token={self.access_token}'
|
||||
payload = {
|
||||
'touser': external_userid,
|
||||
'open_kfid': open_kfid,
|
||||
'msgid': msgid,
|
||||
'msgtype': 'image',
|
||||
'image': {
|
||||
'media_id': media_id,
|
||||
},
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(url, json=payload)
|
||||
data = response.json()
|
||||
if data['errcode'] == 40014 or data['errcode'] == 42001:
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
return await self.send_image_msg(open_kfid, external_userid, msgid, media_id)
|
||||
if data['errcode'] != 0:
|
||||
await self.logger.error(f'发送图片消息失败:{data}')
|
||||
raise Exception('Failed to send image message')
|
||||
return data
|
||||
|
||||
async def handle_callback_request(self):
|
||||
@@ -322,7 +348,7 @@ class WecomCSClient:
|
||||
if not await self.check_access_token():
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
|
||||
url = self.base_url + '/media/upload?access_token=' + self.access_token + '&type=file'
|
||||
url = self.base_url + '/media/upload?access_token=' + self.access_token + '&type=image'
|
||||
file_bytes = None
|
||||
file_name = 'uploaded_file.txt'
|
||||
|
||||
@@ -368,7 +394,7 @@ class WecomCSClient:
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
media_id = await self.upload_to_work(image)
|
||||
if data.get('errcode', 0) != 0:
|
||||
raise Exception('failed to upload file')
|
||||
raise Exception(f'failed to upload image: {data}')
|
||||
|
||||
media_id = data.get('media_id')
|
||||
return media_id
|
||||
|
||||
@@ -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}'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"/>
|
||||
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<g id="SVGRepo_iconCarrier"> <path d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm227 385.3c-1 4.2-3.5 10.4-7 17.8h.1l-.4.7c-20.3 43.1-73.1 127.7-73.1 127.7s-.1-.2-.3-.5l-15.5 26.8h74.5L575.1 810l32.3-128h-58.6l20.4-84.7c-16.5 3.9-35.9 9.4-59 16.8 0 0-31.2 18.2-89.9-35 0 0-39.6-34.7-16.6-43.4 9.8-3.7 47.4-8.4 77-12.3 40-5.4 64.6-8.2 64.6-8.2S422 517 392.7 512.5c-29.3-4.6-66.4-53.1-74.3-95.8 0 0-12.2-23.4 26.3-12.3 38.5 11.1 197.9 43.2 197.9 43.2s-207.4-63.3-221.2-78.7c-13.8-15.4-40.6-84.2-37.1-126.5 0 0 1.5-10.5 12.4-7.7 0 0 153.3 69.7 258.1 107.9 104.8 37.9 195.9 57.3 184.2 106.7z"/> </g>
|
||||
</svg>
|
||||
|
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\n'
|
||||
if msg.base64:
|
||||
if msg.base64.startswith('data:'):
|
||||
return f'\n\n'
|
||||
return f'\n\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"/>
|
||||
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<g id="SVGRepo_iconCarrier"> <g> <path d="M216.856339,16.5966031 C200.285002,8.84328665 182.566144,3.2084988 164.041564,0 C161.766523,4.11318106 159.108624,9.64549908 157.276099,14.0464379 C137.583995,11.0849896 118.072967,11.0849896 98.7430163,14.0464379 C96.9108417,9.64549908 94.1925838,4.11318106 91.8971895,0 C73.3526068,3.2084988 55.6133949,8.86399117 39.0420583,16.6376612 C5.61752293,67.146514 -3.4433191,116.400813 1.08711069,164.955721 C23.2560196,181.510915 44.7403634,191.567697 65.8621325,198.148576 C71.0772151,190.971126 75.7283628,183.341335 79.7352139,175.300261 C72.104019,172.400575 64.7949724,168.822202 57.8887866,164.667963 C59.7209612,163.310589 61.5131304,161.891452 63.2445898,160.431257 C105.36741,180.133187 151.134928,180.133187 192.754523,160.431257 C194.506336,161.891452 196.298154,163.310589 198.110326,164.667963 C191.183787,168.842556 183.854737,172.420929 176.223542,175.320965 C180.230393,183.341335 184.861538,190.991831 190.096624,198.16893 C211.238746,191.588051 232.743023,181.531619 254.911949,164.955721 C260.227747,108.668201 245.831087,59.8662432 216.856339,16.5966031 Z M85.4738752,135.09489 C72.8290281,135.09489 62.4592217,123.290155 62.4592217,108.914901 C62.4592217,94.5396472 72.607595,82.7145587 85.4738752,82.7145587 C98.3405064,82.7145587 108.709962,94.5189427 108.488529,108.914901 C108.508531,123.290155 98.3405064,135.09489 85.4738752,135.09489 Z M170.525237,135.09489 C157.88039,135.09489 147.510584,123.290155 147.510584,108.914901 C147.510584,94.5396472 157.658606,82.7145587 170.525237,82.7145587 C183.391518,82.7145587 193.761324,94.5189427 193.539891,108.914901 C193.539891,123.290155 183.391518,135.09489 170.525237,135.09489 Z" fill="#5865F2" fill-rule="nonzero"> </path> </g> </g>
|
||||
</svg>
|
||||
|
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'),
|
||||
}
|
||||
|
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)
|
||||
|
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,
|
||||
}
|
||||
|
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
|
||||