From 239412d265a2cd5287c080fcd09b48f85edb9bbe Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Sun, 9 Mar 2025 17:47:31 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20=E6=94=AF=E6=8C=81=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E9=92=89=E9=92=89=20#643?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/config/default.py | 7 + astrbot/core/platform/manager.py | 2 + .../sources/dingtalk/dingtalk_adapter.py | 205 ++++++++++++++++++ .../sources/dingtalk/dingtalk_event.py | 60 +++++ .../platform/sources/lark/lark_adapter.py | 2 +- requirements.txt | 1 + 6 files changed, 276 insertions(+), 1 deletion(-) create mode 100644 astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py create mode 100644 astrbot/core/platform/sources/dingtalk/dingtalk_event.py diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 384ae29b..78c56470 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -159,6 +159,13 @@ CONFIG_METADATA_2 = { "app_secret": "", "domain": "https://open.feishu.cn", }, + "dingtalk(钉钉)": { + "id": "dingtalk", + "type": "dingtalk", + "enable": False, + "client_id": "", + "client_secret": "", + }, "telegram": { "id": "telegram", "type": "telegram", diff --git a/astrbot/core/platform/manager.py b/astrbot/core/platform/manager.py index c113bdfa..7acbd5aa 100644 --- a/astrbot/core/platform/manager.py +++ b/astrbot/core/platform/manager.py @@ -64,6 +64,8 @@ class PlatformManager: ) case "lark": from .sources.lark.lark_adapter import LarkPlatformAdapter # noqa: F401 + case "dingtalk": + from .sources.dingtalk.dingtalk_adapter import DingtalkPlatformAdapter # noqa: F401 case "telegram": from .sources.telegram.tg_adapter import TelegramPlatformAdapter # noqa: F401 case "wecom": diff --git a/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py b/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py new file mode 100644 index 00000000..fe9df867 --- /dev/null +++ b/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py @@ -0,0 +1,205 @@ +import asyncio +import uuid +import aiohttp +import dingtalk_stream +import logging + +from astrbot.api.platform import ( + Platform, + AstrBotMessage, + MessageMember, + MessageType, + PlatformMetadata, +) +from astrbot.api.event import MessageChain +from astrbot.api.message_components import Image, Plain, At +from astrbot.core.platform.astr_message_event import MessageSesion +from .dingtalk_event import DingtalkMessageEvent +from ...register import register_platform_adapter +from astrbot import logger +from dingtalk_stream import AckMessage +from astrbot.core.utils.io import download_file + + +class MyEventHandler(dingtalk_stream.EventHandler): + async def process(self, event: dingtalk_stream.EventMessage): + print( + "2", + event.headers.event_type, + event.headers.event_id, + event.headers.event_born_time, + event.data, + ) + return AckMessage.STATUS_OK, "OK" + + +@register_platform_adapter("dingtalk", "钉钉机器人官方 API 适配器") +class DingtalkPlatformAdapter(Platform): + def __init__( + self, platform_config: dict, platform_settings: dict, event_queue: asyncio.Queue + ) -> None: + super().__init__(event_queue) + + self.config = platform_config + + self.unique_session = platform_settings["unique_session"] + + self.client_id = platform_config["client_id"] + self.client_secret = platform_config["client_secret"] + + class AstrCallbackClient(dingtalk_stream.ChatbotHandler): + async def process(self_, message: dingtalk_stream.CallbackMessage): + logger.debug(f"dingtalk: {message.data}") + im = dingtalk_stream.ChatbotMessage.from_dict(message.data) + abm = await self.convert_msg(im) + await self.handle_msg(abm) + + return AckMessage.STATUS_OK, "OK" + + self.client = AstrCallbackClient() + + credential = dingtalk_stream.Credential(self.client_id, self.client_secret) + client = dingtalk_stream.DingTalkStreamClient(credential, logger=logger) + client.register_all_event_handler(MyEventHandler()) + client.register_callback_handler( + dingtalk_stream.ChatbotMessage.TOPIC, self.client + ) + self.client_ = client # 用于 websockets 的 client + + async def send_by_session( + self, session: MessageSesion, message_chain: MessageChain + ): + raise NotImplementedError("钉钉机器人适配器不支持 send_by_session") + + def meta(self) -> PlatformMetadata: + return PlatformMetadata( + "dingtalk", + "钉钉机器人官方 API 适配器", + ) + + async def convert_msg( + self, message: dingtalk_stream.ChatbotMessage + ) -> AstrBotMessage: + abm = AstrBotMessage() + abm.message = [] + abm.message_str = "" + abm.timestamp = int(message.create_at / 1000) + abm.type = ( + MessageType.GROUP_MESSAGE + if message.conversation_type == "2" + else MessageType.FRIEND_MESSAGE + ) + abm.sender = MessageMember( + user_id=message.sender_id, nickname=message.sender_nick + ) + abm.self_id = message.chatbot_user_id + abm.message_id = message.message_id + abm.raw_message = message + + if abm.type == MessageType.GROUP_MESSAGE: + if message.is_in_at_list: + abm.message.append( + At(qq=abm.self_id) + ) + abm.group_id = message.conversation_id + if self.unique_session: + abm.session_id = abm.sender.user_id + else: + abm.session_id = abm.group_id + else: + abm.session_id = abm.sender.user_id + + message_type: str = message.message_type + match message_type: + case "text": + abm.message_str = message.text.content.strip() + abm.message.append(Plain(abm.message_str)) + case "richText": + rtc: dingtalk_stream.RichTextContent = message.rich_text_content + contents: list[dict] = rtc.rich_text_list + for content in contents: + plains = "" + if "text" in content: + plains += content["text"] + abm.message.append(Plain(plains)) + elif "type" in content and content["type"] == "picture": + f_path = await self.download_ding_file( + content["downloadCode"], + message.robot_code, + "jpg", + ) + abm.message.append(Image.fromFileSystem(f_path)) + case "audio": + pass + + return abm # 别忘了返回转换后的消息对象 + + async def download_ding_file( + self, download_code: str, robot_code: str, ext: str + ) -> str: + """下载钉钉文件 + + :param access_token: 钉钉机器人的 access_token + :param download_code: 下载码 + :param robot_code: 机器人码 + :param ext: 文件后缀 + :return: 文件路径 + """ + access_token = await self.get_access_token() + headers = { + "x-acs-dingtalk-access-token": access_token, + } + payload = { + "downloadCode": download_code, + "robotCode": robot_code, + } + f_path = f"data/dingtalk_file_{uuid.uuid4()}.{ext}" + async with aiohttp.ClientSession() as session: + async with session.post( + "https://api.dingtalk.com/v1.0/robot/messageFiles/download", + headers=headers, + json=payload, + ) as resp: + if resp.status != 200: + logger.error( + f"下载钉钉文件失败: {resp.status}, {await resp.text()}" + ) + return None + resp_data = await resp.json() + download_url = resp_data["data"]["downloadUrl"] + await download_file(download_url, f_path) + return f_path + + async def get_access_token(self) -> str: + payload = { + "appKey": self.client_id, + "appSecret": self.client_secret, + } + async with aiohttp.ClientSession() as session: + async with session.post( + "https://api.dingtalk.com/v1.0/oauth2/accessToken", + json=payload, + ) as resp: + if resp.status != 200: + logger.error( + f"获取钉钉机器人 access_token 失败: {resp.status}, {await resp.text()}" + ) + return None + return (await resp.json())["data"]["accessToken"] + + async def handle_msg(self, abm: AstrBotMessage): + event = DingtalkMessageEvent( + message_str=abm.message_str, + message_obj=abm, + platform_meta=self.meta(), + session_id=abm.session_id, + client=self.client, + ) + + self._event_queue.put_nowait(event) + + async def run(self): + await self.client_.start() + + def get_client(self): + return self.client diff --git a/astrbot/core/platform/sources/dingtalk/dingtalk_event.py b/astrbot/core/platform/sources/dingtalk/dingtalk_event.py new file mode 100644 index 00000000..de35742b --- /dev/null +++ b/astrbot/core/platform/sources/dingtalk/dingtalk_event.py @@ -0,0 +1,60 @@ +import json +import uuid +import asyncio +import aiohttp +import dingtalk_stream +import astrbot.api.message_components as Comp +from typing import List +from astrbot.api.event import AstrMessageEvent, MessageChain +from astrbot.core.utils.io import download_image_by_url +from astrbot import logger +from astrbot.core.utils.io import file_to_base64 + + +class DingtalkMessageEvent(AstrMessageEvent): + def __init__( + self, + message_str, + message_obj, + platform_meta, + session_id, + client: dingtalk_stream.ChatbotHandler, + ): + super().__init__(message_str, message_obj, platform_meta, session_id) + self.client = client + + async def send_with_client( + self, client: dingtalk_stream.ChatbotHandler, message: MessageChain + ): + for segment in message.chain: + if isinstance(segment, Comp.Plain): + segment.text = segment.text.strip() + await asyncio.get_event_loop().run_in_executor( + None, client.reply_text, segment.text, self.message_obj.raw_message + ) + elif isinstance(segment, Comp.Image): + markdown_str = "" + if segment.file and segment.file.startswith("file:///"): + logger.warning("dingtalk only support url image, not: " + segment.file) + continue + elif segment.file and segment.file.startswith("http"): + markdown_str += f"![image]({segment.file})\n\n" + elif segment.file and segment.file.startswith("base64://"): + logger.warning("dingtalk only support url image, not base64") + continue + else: + logger.warning("dingtalk only support url image, not: " + segment.file) + continue + + ret = await asyncio.get_event_loop().run_in_executor( + None, + client.reply_markdown, + "😄", + markdown_str, + self.message_obj.raw_message, + ) + logger.debug(f"send image: {ret}") + + async def send(self, message: MessageChain): + await self.send_with_client(self.client, message) + await super().send(message) diff --git a/astrbot/core/platform/sources/lark/lark_adapter.py b/astrbot/core/platform/sources/lark/lark_adapter.py index 4188771d..fd29b360 100644 --- a/astrbot/core/platform/sources/lark/lark_adapter.py +++ b/astrbot/core/platform/sources/lark/lark_adapter.py @@ -66,7 +66,7 @@ class LarkPlatformAdapter(Platform): async def send_by_session( self, session: MessageSesion, message_chain: MessageChain ): - raise NotImplementedError("QQ 机器人官方 API 适配器不支持 send_by_session") + raise NotImplementedError("Lark 适配器不支持 send_by_session") def meta(self) -> PlatformMetadata: return PlatformMetadata( diff --git a/requirements.txt b/requirements.txt index cd1175f6..07b168cc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,3 +24,4 @@ cryptography dashscope python-telegram-bot wechatpy +dingtalk-stream \ No newline at end of file