├── .github └── workflows │ └── publish.yml ├── .gitignore ├── LICENSE ├── README.md ├── __init__.py ├── dingtalk_stream ├── __init__.py ├── card_callback.py ├── card_instance.py ├── card_replier.py ├── chatbot.py ├── credential.py ├── frames.py ├── graph.py ├── handlers.py ├── interactive_card.py ├── log.py ├── stream.py ├── utils.py └── version.py ├── examples ├── agent │ ├── hello.py │ └── stream.yaml ├── calcbot │ └── calcbot.py ├── cardbot │ ├── cardbot.py │ └── img.png ├── cardcallback │ └── cardcallback.py └── helloworld │ └── helloworld.py ├── requirements.txt └── setup.py /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Build and Publish Package 2 | on: 3 | release: 4 | types: [published] 5 | jobs: 6 | build-and-publish: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout 10 | uses: actions/checkout@v2 11 | - name: Build Package 12 | run: | 13 | python setup.py sdist bdist_wheel 14 | - name: Publish Package 15 | uses: pypa/gh-action-pypi-publish@master 16 | with: 17 | user: __token__ 18 | password: ${{ secrets.PYPI_API_TOKEN }} 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | *.pyc 3 | /build 4 | /dist 5 | /dingtalk_stream.egg-info 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 钉钉开放平台团队 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
10 | 11 | # DingTalk Stream Mode 介绍 12 | 13 | Python SDK for DingTalk Stream Mode API, Compared with the webhook mode, it is easier to access the DingTalk chatbot 14 | 15 | 钉钉支持 Stream 模式接入事件推送、机器人收消息以及卡片回调,该 SDK 实现了 Stream 模式。相比 Webhook 模式,Stream 模式可以更简单的接入各类事件和回调。 16 | 17 | ## 快速指南 18 | 19 | 1. 安装 SDK 20 | 21 | ```Python 22 | pip install dingtalk-stream 23 | ``` 24 | 25 | 2. 开发一个 Stream 机器人 26 | 27 | 以下示例注册一个加法处理会调,可以实现一个加法机器人(给机器人发送1+1,回复2) 28 | 29 | ```Python 30 | # !/usr/bin/env python 31 | 32 | import argparse 33 | import logging 34 | from dingtalk_stream import AckMessage 35 | import dingtalk_stream 36 | 37 | def setup_logger(): 38 | logger = logging.getLogger() 39 | handler = logging.StreamHandler() 40 | handler.setFormatter( 41 | logging.Formatter('%(asctime)s %(name)-8s %(levelname)-8s %(message)s [%(filename)s:%(lineno)d]')) 42 | logger.addHandler(handler) 43 | logger.setLevel(logging.INFO) 44 | return logger 45 | 46 | 47 | def define_options(): 48 | parser = argparse.ArgumentParser() 49 | parser.add_argument( 50 | '--client_id', dest='client_id', required=True, 51 | help='app_key or suite_key from https://open-dev.digntalk.com' 52 | ) 53 | parser.add_argument( 54 | '--client_secret', dest='client_secret', required=True, 55 | help='app_secret or suite_secret from https://open-dev.digntalk.com' 56 | ) 57 | options = parser.parse_args() 58 | return options 59 | 60 | 61 | class CalcBotHandler(dingtalk_stream.ChatbotHandler): 62 | def __init__(self, logger: logging.Logger = None): 63 | super(dingtalk_stream.ChatbotHandler, self).__init__() 64 | if logger: 65 | self.logger = logger 66 | 67 | async def process(self, callback: dingtalk_stream.CallbackMessage): 68 | incoming_message = dingtalk_stream.ChatbotMessage.from_dict(callback.data) 69 | expression = incoming_message.text.content.strip() 70 | try: 71 | result = eval(expression) 72 | except Exception as e: 73 | result = 'Error: %s' % e 74 | self.logger.info('%s = %s' % (expression, result)) 75 | response = 'Q: %s\nA: %s' % (expression, result) 76 | self.reply_text(response, incoming_message) 77 | 78 | return AckMessage.STATUS_OK, 'OK' 79 | 80 | def main(): 81 | logger = setup_logger() 82 | options = define_options() 83 | 84 | credential = dingtalk_stream.Credential(options.client_id, options.client_secret) 85 | client = dingtalk_stream.DingTalkStreamClient(credential) 86 | client.register_callback_handler(dingtalk_stream.chatbot.ChatbotMessage.TOPIC, CalcBotHandler(logger)) 87 | client.start_forever() 88 | 89 | 90 | if __name__ == '__main__': 91 | main() 92 | ``` 93 | 94 | ## 高阶使用方法 95 | 96 | 以上示例中,采用 `client.start_forever()` 来启动一个 asyncio 的 ioloop。 97 | 98 | 有的时候,你需要在已有的 ioloop 中使用钉钉 Stream 模式,不使用 `start_forever` 方法。 99 | 100 | 此时,可以使用 `client.start()` 代替 `client.start_forever()`。注意:需要在网络异常后重新启动 101 | 102 | ```Python 103 | try: 104 | await client.start() 105 | except (asyncio.exceptions.CancelledError, 106 | websockets.exceptions.ConnectionClosedError) as e: 107 | ... # 处理网络断线异常 108 | ``` 109 | 110 | ## 开发教程 111 | 112 | 在 [教程文档](https://opensource.dingtalk.com/developerpedia/docs/explore/tutorials/stream/overview) 中,你可以找到更多钉钉 Stream 模式的教程文档和示例代码。 113 | 114 | ## 特别说明 115 | 116 | 因拼写错误,从旧版本升级到 v0.13.0 时候,需要将 register_callback_hanlder 修改为 register_callback_handler 117 | 118 | ### 参考资料 119 | 120 | * [Stream 模式说明](https://opensource.dingtalk.com/developerpedia/docs/learn/stream/overview) 121 | * [教程文档](https://opensource.dingtalk.com/developerpedia/docs/explore/tutorials/stream/overview) 122 | * [常见问题](https://opensource.dingtalk.com/developerpedia/docs/learn/stream/faq) 123 | * [Stream 模式共创群](https://opensource.dingtalk.com/developerpedia/docs/explore/support/?via=moon-group) 124 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-dingtalk/dingtalk-stream-sdk-python/c53842ddfc22ad2d7698d9f05553362638e266fa/__init__.py -------------------------------------------------------------------------------- /dingtalk_stream/__init__.py: -------------------------------------------------------------------------------- 1 | from .stream import DingTalkStreamClient 2 | from .credential import Credential 3 | from .handlers import EventHandler 4 | from .handlers import CallbackHandler 5 | from .handlers import SystemHandler 6 | from .frames import EventMessage 7 | from .frames import CallbackMessage 8 | from .frames import SystemMessage 9 | from .frames import AckMessage 10 | from .chatbot import ChatbotMessage, RichTextContent, ImageContent, reply_specified_single_chat, \ 11 | reply_specified_group_chat 12 | from .chatbot import TextContent 13 | from .chatbot import AtUser 14 | from .chatbot import ChatbotHandler, AsyncChatbotHandler 15 | from .graph import RequestLine, StatusLine, GraphMessage, GraphRequest, GraphResponse 16 | from .graph import GraphHandler 17 | from .card_replier import AICardStatus, AICardReplier, CardReplier 18 | from .card_instance import MarkdownCardInstance, AIMarkdownCardInstance, CarouselCardInstance, \ 19 | MarkdownButtonCardInstance, RPAPluginCardInstance 20 | from .card_callback import CardCallbackMessage, Card_Callback_Router_Topic 21 | -------------------------------------------------------------------------------- /dingtalk_stream/card_callback.py: -------------------------------------------------------------------------------- 1 | # -*- coding:utf-8 -*- 2 | 3 | import json 4 | 5 | Card_Callback_Router_Topic = '/v1.0/card/instances/callback' 6 | 7 | 8 | class CardCallbackMessage(object): 9 | 10 | def __init__(self): 11 | self.extension = {} 12 | self.corp_id = "" 13 | self.space_type = "" 14 | self.user_id_type = -1 15 | self.type = "actionCallback" 16 | self.user_id = "" 17 | self.content = {} 18 | self.space_id = "" 19 | self.card_instance_id = "" 20 | 21 | @classmethod 22 | def from_dict(cls, d): 23 | msg = CardCallbackMessage() 24 | for name, value in d.items(): 25 | if name == 'extension': 26 | msg.extension = json.loads(value) 27 | elif name == 'corpId': 28 | msg.corp_id = value 29 | elif name == "userId": 30 | msg.user_id = value 31 | elif name == 'outTrackId': 32 | msg.card_instance_id = value 33 | elif name == "content": 34 | msg.content = json.loads(value) 35 | return msg 36 | 37 | def to_dict(self): 38 | msg = {} 39 | msg["extension"] = json.dumps(self.extension) 40 | msg["corpId"] = self.corp_id 41 | msg["type"] = self.type 42 | msg["userId"] = self.user_id 43 | msg["content"] = json.dumps(self.content) 44 | msg["outTrackId"] = self.card_instance_id 45 | return msg 46 | -------------------------------------------------------------------------------- /dingtalk_stream/card_instance.py: -------------------------------------------------------------------------------- 1 | # -*- coding:utf-8 -*- 2 | 3 | """ 4 | 这里提供了一些常用的卡片模板及其封装类 5 | """ 6 | 7 | from .card_replier import CardReplier, AICardReplier, AICardStatus 8 | import json 9 | 10 | 11 | class MarkdownCardInstance(CardReplier): 12 | """ 13 | 一款超级通用的markdown卡片 14 | """ 15 | 16 | def __init__(self, dingtalk_client, incoming_message): 17 | super(MarkdownCardInstance, self).__init__(dingtalk_client, incoming_message) 18 | self.card_template_id = "589420e2-c1e2-46ef-a5ed-b8728e654da9.schema" 19 | self.card_instance_id = None 20 | self.title = None 21 | self.logo = None 22 | 23 | def set_title_and_logo(self, title: str, logo: str): 24 | self.title = title 25 | self.logo = logo 26 | 27 | def _get_card_data(self, markdown) -> dict: 28 | card_data = { 29 | "markdown": markdown, 30 | } 31 | 32 | if self.title is not None and self.title != "": 33 | card_data["title"] = self.title 34 | 35 | if self.logo is not None and self.logo != "": 36 | card_data["logo"] = self.logo 37 | 38 | return card_data 39 | 40 | def reply(self, 41 | markdown: str, 42 | at_sender: bool = False, 43 | at_all: bool = False, 44 | recipients: list = None, 45 | support_forward: bool = True): 46 | """ 47 | 回复markdown内容 48 | :param recipients: 49 | :param support_forward: 50 | :param markdown: 51 | :param title: 52 | :param logo: 53 | :param at_sender: 54 | :param at_all: 55 | :return: 56 | """ 57 | self.card_instance_id = self.create_and_send_card(self.card_template_id, self._get_card_data(markdown), 58 | at_sender=at_sender, at_all=at_all, 59 | recipients=recipients, 60 | support_forward=support_forward) 61 | 62 | def update(self, markdown: str): 63 | """ 64 | 更新markdown内容,如果你reply了多次,这里只会更新最后一张卡片 65 | :param markdown: 66 | :return: 67 | """ 68 | if self.card_instance_id is None or self.card_instance_id == "": 69 | self.logger.error('MarkdownCardInstance.update failed, you should send card first.') 70 | return 71 | 72 | self.put_card_data(self.card_instance_id, self._get_card_data(markdown)) 73 | 74 | 75 | class MarkdownButtonCardInstance(CardReplier): 76 | """ 77 | 一款超级通用的markdown卡片 78 | """ 79 | 80 | def __init__(self, dingtalk_client, incoming_message): 81 | super(MarkdownButtonCardInstance, self).__init__(dingtalk_client, incoming_message) 82 | self.card_template_id = "1366a1eb-bc54-4859-ac88-517c56a9acb1.schema" 83 | self.card_instance_id = None 84 | self.title = None 85 | self.logo = None 86 | self.button_list = [] 87 | 88 | def set_title_and_logo(self, title: str, logo: str): 89 | self.title = title 90 | self.logo = logo 91 | 92 | def _get_card_data(self, markdown, tips) -> dict: 93 | card_data = { 94 | "markdown": markdown, 95 | "tips": tips 96 | } 97 | 98 | if self.title is not None and self.title != "": 99 | card_data["title"] = self.title 100 | 101 | if self.logo is not None and self.logo != "": 102 | card_data["logo"] = self.logo 103 | 104 | if self.button_list is not None: 105 | sys_full_json_obj = { 106 | "msgButtons": self.button_list 107 | } 108 | 109 | card_data["sys_full_json_obj"] = json.dumps(sys_full_json_obj) 110 | 111 | return card_data 112 | 113 | def reply(self, 114 | markdown: str, 115 | button_list: list, 116 | tips: str = "", 117 | recipients: list = None, 118 | support_forward: bool = True): 119 | """ 120 | 回复markdown内容 121 | :param support_forward: 122 | :param recipients: 123 | :param tips: 124 | :param button_list: [{"text":"text", "url":"url", "iosUrl":"iosUrl", "color":"gray"}] 125 | :param markdown: 126 | :return: 127 | """ 128 | self.button_list = button_list 129 | self.card_instance_id = self.create_and_send_card(self.card_template_id, self._get_card_data(markdown, tips), 130 | recipients=recipients, support_forward=support_forward) 131 | 132 | def update(self, 133 | markdown: str, 134 | button_list: list, 135 | tips: str = ""): 136 | """ 137 | 更新markdown内容,如果你reply了多次,这里只会更新最后一张卡片 138 | :param button_list:[{"text":"text", "url":"url", "iosUrl":"iosUrl", "color":"gray"}] 139 | :param tips: 140 | :param markdown: 141 | :return: 142 | """ 143 | if self.card_instance_id is None or self.card_instance_id == "": 144 | self.logger.error('MarkdownButtonCardInstance.update failed, you should send card first.') 145 | return 146 | 147 | self.button_list = button_list 148 | self.put_card_data(self.card_instance_id, self._get_card_data(markdown, tips)) 149 | 150 | 151 | class AIMarkdownCardInstance(AICardReplier): 152 | """ 153 | 一款超级通用的AI Markdown卡片 154 | ai_start --> ai_streaming --> ai_streaming --> ai_finish/ai_fail 155 | """ 156 | 157 | def __init__(self, dingtalk_client, incoming_message): 158 | super(AIMarkdownCardInstance, self).__init__(dingtalk_client, incoming_message) 159 | self.card_template_id = "382e4302-551d-4880-bf29-a30acfab2e71.schema" 160 | self.card_instance_id = None 161 | self.title = None 162 | self.logo = None 163 | self.markdown = "" 164 | self.static_markdown = "" 165 | self.button_list = None 166 | self.inputing_status = False 167 | self.order = [ 168 | "msgTitle", 169 | "msgContent", 170 | "staticMsgContent", 171 | "msgTextList", 172 | "msgImages", 173 | "msgSlider", 174 | "msgButtons", 175 | ] 176 | 177 | def set_title_and_logo(self, title: str, logo: str): 178 | self.title = title 179 | self.logo = logo 180 | 181 | def set_order(self, order: list): 182 | self.order = order 183 | 184 | def get_card_data(self, flow_status=None): 185 | card_data = { 186 | "msgContent": self.markdown, 187 | "staticMsgContent": self.static_markdown, 188 | } 189 | 190 | if flow_status is not None: 191 | card_data["flowStatus"] = flow_status 192 | 193 | if self.title is not None and self.title != "": 194 | card_data["msgTitle"] = self.title 195 | 196 | if self.logo is not None and self.logo != "": 197 | card_data["logo"] = self.logo 198 | 199 | sys_full_json_obj = { 200 | "order": self.order, 201 | } 202 | 203 | if self.button_list is not None and len(self.button_list) > 0: 204 | sys_full_json_obj["msgButtons"] = self.button_list 205 | 206 | if self.incoming_message.hosting_context is not None: 207 | sys_full_json_obj["source"] = { 208 | "text": "由{nick}的数字助理回答".format(nick=self.incoming_message.hosting_context.nick) 209 | } 210 | 211 | card_data["sys_full_json_obj"] = json.dumps(sys_full_json_obj) 212 | 213 | return card_data 214 | 215 | def ai_start(self, recipients: list = None, support_forward: bool = True): 216 | """ 217 | 开始执行中 218 | :return: 219 | """ 220 | if self.card_instance_id is not None and self.card_instance_id != "": 221 | return 222 | 223 | self.card_instance_id = self.start(self.card_template_id, {}, recipients=recipients, 224 | support_forward=support_forward) 225 | self.inputing_status = False 226 | 227 | def ai_streaming(self, 228 | markdown: str, 229 | append: bool = False): 230 | """ 231 | 打字机模式 232 | :param append: 两种更新模式,append=true,追加的方式;append=false,全量替换。 233 | :param markdown: 234 | :return: 235 | """ 236 | if self.card_instance_id is None or self.card_instance_id == "": 237 | self.logger.error('AIMarkdownCardInstance.ai_streaming failed, you should send card first.') 238 | return 239 | 240 | if not self.inputing_status: 241 | self.put_card_data(self.card_instance_id, self.get_card_data(AICardStatus.INPUTING)) 242 | 243 | self.inputing_status = True 244 | 245 | if append: 246 | self.markdown = self.markdown + markdown 247 | else: 248 | self.markdown = markdown 249 | 250 | self.streaming(self.card_instance_id, "msgContent", self.markdown, append=False, finished=False, 251 | failed=False) 252 | 253 | def ai_finish(self, 254 | markdown: str = None, 255 | button_list: list = None, 256 | tips: str = ""): 257 | """ 258 | 完成态 259 | :param tips: 260 | :param button_list: 261 | :param markdown: 262 | :return: 263 | """ 264 | if self.card_instance_id is None or self.card_instance_id == "": 265 | self.logger.error('AIMarkdownCardInstance.ai_finish failed, you should send card first.') 266 | return 267 | 268 | if markdown is not None: 269 | self.markdown = markdown 270 | 271 | if button_list is not None: 272 | self.button_list = button_list 273 | 274 | self.finish(self.card_instance_id, self.get_card_data()) 275 | 276 | def update(self, 277 | static_markdown: str = None, 278 | button_list: list = None, 279 | tips: str = ""): 280 | """ 281 | 非流式内容输出 282 | :param static_markdown: 283 | :param button_list: 284 | :param tips: 285 | :return: 286 | """ 287 | if self.card_instance_id is None or self.card_instance_id == "": 288 | self.logger.error('AIMarkdownCardInstance.update failed, you should send card first.') 289 | return 290 | 291 | if button_list is not None: 292 | self.button_list = button_list 293 | 294 | if static_markdown is not None: 295 | self.static_markdown = static_markdown 296 | 297 | self.finish(self.card_instance_id, self.get_card_data()) 298 | 299 | def ai_fail(self): 300 | """ 301 | 失败态 302 | :return: 303 | """ 304 | 305 | if self.card_instance_id is None or self.card_instance_id == "": 306 | self.logger.error('AIMarkdownCardInstance.ai_fail failed, you should send card first.') 307 | return 308 | 309 | card_data = {} 310 | 311 | if self.title is not None and self.title != "": 312 | card_data["msgTitle"] = self.title 313 | 314 | if self.logo is not None and self.logo != "": 315 | card_data["logo"] = self.logo 316 | 317 | self.fail(self.card_instance_id, card_data) 318 | 319 | 320 | class CarouselCardInstance(AICardReplier): 321 | """ 322 | 轮播图卡片 323 | """ 324 | 325 | def __init__(self, dingtalk_client, incoming_message): 326 | super(CarouselCardInstance, self).__init__(dingtalk_client, incoming_message) 327 | self.card_template_id = "382e4302-551d-4880-bf29-a30acfab2e71.schema" 328 | self.card_instance_id = None 329 | self.title = None 330 | self.logo = None 331 | 332 | def set_title_and_logo(self, title: str, logo: str): 333 | self.title = title 334 | self.logo = logo 335 | 336 | def ai_start(self): 337 | """ 338 | 开始执行中 339 | :return: 340 | """ 341 | self.card_instance_id = self.start(self.card_template_id, {}) 342 | 343 | def reply(self, 344 | markdown: str, 345 | image_slider_list: list, 346 | button_text: str = "submit", 347 | recipients: list = None, 348 | support_forward: bool = True): 349 | """ 350 | 回复卡片 351 | :param support_forward: 352 | :param recipients: 353 | :param button_text: 354 | :param image_slider_list: 355 | :param markdown: 356 | :return: 357 | """ 358 | 359 | sys_full_json_obj = { 360 | "order": [ 361 | "msgTitle", 362 | "staticMsgContent", 363 | "msgSlider", 364 | "msgImages", 365 | "msgTextList", 366 | "msgButtons", 367 | ], 368 | "msgSlider": [], 369 | "msgButtons": [ 370 | { 371 | "text": button_text, 372 | "color": "blue", 373 | "id": "image_slider_select_button", 374 | "request": True 375 | } 376 | ] 377 | } 378 | 379 | if button_text is not None and button_text != "": 380 | sys_full_json_obj["msgButtons"][0]["text"] = button_text 381 | 382 | for image_slider in image_slider_list: 383 | sys_full_json_obj["msgSlider"].append({ 384 | "title": image_slider[0], 385 | "image": image_slider[1] 386 | }) 387 | 388 | card_data = { 389 | "staticMsgContent": markdown, 390 | "sys_full_json_obj": json.dumps(sys_full_json_obj) 391 | } 392 | 393 | if self.title is not None and self.title != "": 394 | card_data["msgTitle"] = self.title 395 | 396 | if self.logo is not None and self.logo != "": 397 | card_data["logo"] = self.logo 398 | 399 | self.card_instance_id = self.create_and_send_card(self.card_template_id, 400 | {"flowStatus": AICardStatus.PROCESSING}, 401 | callback_type="STREAM", recipients=recipients, 402 | support_forward=support_forward) 403 | 404 | self.finish(self.card_instance_id, card_data) 405 | 406 | 407 | class RPAPluginCardInstance(AICardReplier): 408 | 409 | def __init__(self, dingtalk_client, incoming_message): 410 | super(RPAPluginCardInstance, self).__init__(dingtalk_client, incoming_message) 411 | self.card_template_id = "7f538f6d-ebb7-4533-a9ac-61a32da094cf.schema" 412 | self.card_instance_id = None 413 | self.goal = "" 414 | self.corp_id = "" 415 | 416 | def set_goal(self, goal: str): 417 | self.goal = goal 418 | 419 | def set_corp_id(self, corp_id: str): 420 | self.corp_id = corp_id 421 | 422 | def reply(self, 423 | plugin_id: str, 424 | plugin_version: str, 425 | plugin_name: str, 426 | ability_name: str, 427 | plugin_args: dict, 428 | recipients: list = None, 429 | support_forward: bool = True): 430 | """ 431 | 回复markdown内容 432 | :param support_forward: 433 | :param ability_name: 434 | :param recipients: 435 | :param plugin_version: 436 | :param plugin_args: 437 | :param plugin_name: 438 | :param plugin_id: 439 | :return: 440 | """ 441 | 442 | plan = { 443 | "corpId": self.corp_id, 444 | "goal": self.goal, 445 | "plan": "(function(){dd.callPlugin({'pluginName':'%s','abilityName':'%s','args':%s });})()" % ( 446 | plugin_name, ability_name, json.dumps(plugin_args)), 447 | "planType": "jsCode", 448 | "pluginInstances": [{ 449 | "id": "AGI-EXTENSION-" + plugin_id, 450 | "version": plugin_version 451 | }] 452 | } 453 | 454 | card_data = { 455 | "goal": self.goal, 456 | "processFlag": "true", 457 | "plan": json.dumps(plan) 458 | } 459 | 460 | self.card_instance_id = self.create_and_send_card(self.card_template_id, 461 | {"flowStatus": AICardStatus.PROCESSING}, 462 | recipients=recipients, support_forward=support_forward) 463 | 464 | self.finish(self.card_instance_id, card_data) 465 | -------------------------------------------------------------------------------- /dingtalk_stream/card_replier.py: -------------------------------------------------------------------------------- 1 | # -*- coding:utf-8 -*- 2 | import json 3 | import uuid 4 | 5 | import copy 6 | import hashlib 7 | import platform 8 | import requests 9 | import aiohttp 10 | 11 | from .utils import DINGTALK_OPENAPI_ENDPOINT 12 | from .log import setup_default_logger 13 | from enum import Enum, unique 14 | 15 | from typing import TYPE_CHECKING 16 | 17 | if TYPE_CHECKING: 18 | from .chatbot import ChatbotMessage 19 | from .stream import DingTalkStreamClient 20 | 21 | 22 | class CardReplier(object): 23 | 24 | def __init__( 25 | self, 26 | dingtalk_client: "DingTalkStreamClient", 27 | incoming_message: "ChatbotMessage", 28 | ): 29 | self.dingtalk_client: "DingTalkStreamClient" = dingtalk_client 30 | self.incoming_message: "ChatbotMessage" = incoming_message 31 | self.logger = setup_default_logger("dingtalk_stream.card_replier") 32 | 33 | @staticmethod 34 | def gen_card_id(msg: "ChatbotMessage"): 35 | factor = "%s_%s_%s_%s_%s" % ( 36 | msg.sender_id, 37 | msg.sender_corp_id, 38 | msg.conversation_id, 39 | msg.message_id, 40 | str(uuid.uuid1()), 41 | ) 42 | m = hashlib.sha256() 43 | m.update(factor.encode("utf-8")) 44 | return m.hexdigest() 45 | 46 | @staticmethod 47 | def get_request_header(access_token): 48 | return { 49 | "Content-Type": "application/json", 50 | "Accept": "*/*", 51 | "x-acs-dingtalk-access-token": access_token, 52 | "User-Agent": ( 53 | "DingTalkStream/1.0 SDK/0.1.0 Python/%s " 54 | "(+https://github.com/open-dingtalk/dingtalk-stream-sdk-python)" 55 | ) 56 | % platform.python_version(), 57 | } 58 | 59 | def create_and_send_card( 60 | self, 61 | card_template_id: str, 62 | card_data: dict, 63 | callback_type: str = "STREAM", 64 | callback_route_key: str = "", 65 | at_sender: bool = False, 66 | at_all: bool = False, 67 | recipients: list = None, 68 | support_forward: bool = True, 69 | ) -> str: 70 | """ 71 | 发送卡片,两步骤:创建+投放。 72 | https://open.dingtalk.com/document/orgapp/interface-for-creating-a-card-instance 73 | :param support_forward: 卡片是否支持转发 74 | :param callback_route_key: HTTP 回调时的 route key 75 | :param callback_type: 卡片回调模式 76 | :param recipients: 接收者 77 | :param card_template_id: 卡片模板 ID 78 | :param card_data: 卡片数据 79 | :param at_sender: 是否@发送者 80 | :param at_all: 是否@所有人 81 | :return: 卡片的实例ID 82 | """ 83 | access_token = self.dingtalk_client.get_access_token() 84 | if not access_token: 85 | self.logger.error( 86 | "CardResponder.send_card failed, cannot get dingtalk access token" 87 | ) 88 | return "" 89 | 90 | card_instance_id = self.gen_card_id(self.incoming_message) 91 | body = { 92 | "cardTemplateId": card_template_id, 93 | "outTrackId": card_instance_id, 94 | "cardData": {"cardParamMap": card_data}, 95 | "callbackType": callback_type, 96 | "imGroupOpenSpaceModel": {"supportForward": support_forward}, 97 | "imRobotOpenSpaceModel": {"supportForward": support_forward}, 98 | } 99 | 100 | if callback_type == "HTTP": 101 | body["callbackType"] = "HTTP" 102 | body["callbackRouteKey"] = callback_route_key 103 | 104 | # 创建卡片实例。https://open.dingtalk.com/document/orgapp/interface-for-creating-a-card-instance 105 | url = DINGTALK_OPENAPI_ENDPOINT + "/v1.0/card/instances" 106 | try: 107 | response_text = "" 108 | response = requests.post( 109 | url, headers=self.get_request_header(access_token), json=body 110 | ) 111 | response_text = response.text 112 | 113 | response.raise_for_status() 114 | except Exception as e: 115 | self.logger.error( 116 | f"CardReplier.create_and_send_card failed, create card instance failed, error={e}, response.text={response_text}" 117 | ) 118 | return "" 119 | 120 | body = {"outTrackId": card_instance_id, "userIdType": 1} 121 | 122 | # 2:群聊,1:单聊 123 | if self.incoming_message.conversation_type == "2": 124 | body["openSpaceId"] = "dtv1.card//{spaceType}.{spaceId}".format( 125 | spaceType="IM_GROUP", spaceId=self.incoming_message.conversation_id 126 | ) 127 | body["imGroupOpenDeliverModel"] = { 128 | "robotCode": self.dingtalk_client.credential.client_id, 129 | } 130 | 131 | if at_all: 132 | body["imGroupOpenDeliverModel"]["atUserIds"] = { 133 | "@ALL": "@ALL", 134 | } 135 | elif at_sender: 136 | body["imGroupOpenDeliverModel"]["atUserIds"] = { 137 | self.incoming_message.sender_staff_id: self.incoming_message.sender_nick, 138 | } 139 | 140 | if recipients is not None: 141 | body["imGroupOpenDeliverModel"]["recipients"] = recipients 142 | 143 | # 增加托管extension 144 | if self.incoming_message.hosting_context is not None: 145 | body["imGroupOpenDeliverModel"]["extension"] = { 146 | "hostingRepliedContext": json.dumps( 147 | {"userId": self.incoming_message.hosting_context.user_id} 148 | ) 149 | } 150 | elif self.incoming_message.conversation_type == "1": 151 | body["openSpaceId"] = "dtv1.card//{spaceType}.{spaceId}".format( 152 | spaceType="IM_ROBOT", spaceId=self.incoming_message.sender_staff_id 153 | ) 154 | body["imRobotOpenDeliverModel"] = {"spaceType": "IM_ROBOT"} 155 | 156 | # 增加托管extension 157 | if self.incoming_message.hosting_context is not None: 158 | body["imRobotOpenDeliverModel"]["extension"] = { 159 | "hostingRepliedContext": json.dumps( 160 | {"userId": self.incoming_message.hosting_context.user_id} 161 | ) 162 | } 163 | 164 | # 投放卡片。https://open.dingtalk.com/document/orgapp/delivery-card-interface 165 | url = DINGTALK_OPENAPI_ENDPOINT + "/v1.0/card/instances/deliver" 166 | try: 167 | response_text = "" 168 | response = requests.post( 169 | url, headers=self.get_request_header(access_token), json=body 170 | ) 171 | response_text = response.text 172 | 173 | response.raise_for_status() 174 | 175 | return card_instance_id 176 | except Exception as e: 177 | self.logger.error( 178 | f"CardReplier.create_and_send_card failed, send card failed, error={e}, response.text={response_text}" 179 | ) 180 | return "" 181 | 182 | async def async_create_and_send_card( 183 | self, 184 | card_template_id: str, 185 | card_data: dict, 186 | callback_type: str = "STREAM", 187 | callback_route_key: str = "", 188 | at_sender: bool = False, 189 | at_all: bool = False, 190 | recipients: list = None, 191 | support_forward: bool = True, 192 | ) -> str: 193 | """ 194 | 发送卡片,两步骤:创建+投放。 195 | https://open.dingtalk.com/document/orgapp/interface-for-creating-a-card-instance 196 | :param support_forward: 卡片是否支持转发 197 | :param callback_route_key: HTTP 回调时的 route key 198 | :param callback_type: 卡片回调模式 199 | :param recipients: 接收者 200 | :param card_template_id: 卡片模板 ID 201 | :param card_data: 卡片数据 202 | :param at_sender: 是否@发送者 203 | :param at_all: 是否@所有人 204 | :return: 卡片的实例ID 205 | """ 206 | access_token = self.dingtalk_client.get_access_token() 207 | if not access_token: 208 | self.logger.error( 209 | "CardResponder.send_card failed, cannot get dingtalk access token" 210 | ) 211 | return "" 212 | 213 | card_instance_id = self.gen_card_id(self.incoming_message) 214 | body = { 215 | "cardTemplateId": card_template_id, 216 | "outTrackId": card_instance_id, 217 | "cardData": {"cardParamMap": card_data}, 218 | "callbackType": callback_type, 219 | "imGroupOpenSpaceModel": {"supportForward": support_forward}, 220 | "imRobotOpenSpaceModel": {"supportForward": support_forward}, 221 | } 222 | 223 | if callback_type == "HTTP": 224 | body["callbackType"] = "HTTP" 225 | body["callbackRouteKey"] = callback_route_key 226 | 227 | # 创建卡片实例。https://open.dingtalk.com/document/orgapp/interface-for-creating-a-card-instance 228 | url = DINGTALK_OPENAPI_ENDPOINT + "/v1.0/card/instances" 229 | try: 230 | response_text = "" 231 | async with aiohttp.ClientSession() as session: 232 | async with session.post( 233 | url, headers=self.get_request_header(access_token), json=body 234 | ) as response: 235 | response_text = await response.text() 236 | 237 | response.raise_for_status() 238 | except aiohttp.ClientResponseError as e: 239 | self.logger.error( 240 | f"CardReplier.async_create_and_send_card failed, create card instance failed, HTTP Error: {e.status}, URL: {url}, response.text: {response_text}" 241 | ) 242 | except Exception as e: 243 | self.logger.error( 244 | f"CardReplier.async_create_and_send_card create card instance unexpected error occurred: {e}" 245 | ) 246 | 247 | body = {"outTrackId": card_instance_id, "userIdType": 1} 248 | 249 | # 2:群聊,1:单聊 250 | if self.incoming_message.conversation_type == "2": 251 | body["openSpaceId"] = "dtv1.card//{spaceType}.{spaceId}".format( 252 | spaceType="IM_GROUP", spaceId=self.incoming_message.conversation_id 253 | ) 254 | body["imGroupOpenDeliverModel"] = { 255 | "robotCode": self.dingtalk_client.credential.client_id, 256 | } 257 | 258 | if at_all: 259 | body["imGroupOpenDeliverModel"]["atUserIds"] = { 260 | "@ALL": "@ALL", 261 | } 262 | elif at_sender: 263 | body["imGroupOpenDeliverModel"]["atUserIds"] = { 264 | self.incoming_message.sender_staff_id: self.incoming_message.sender_nick, 265 | } 266 | 267 | if recipients is not None: 268 | body["imGroupOpenDeliverModel"]["recipients"] = recipients 269 | 270 | # 增加托管extension 271 | if self.incoming_message.hosting_context is not None: 272 | body["imGroupOpenDeliverModel"]["extension"] = { 273 | "hostingRepliedContext": json.dumps( 274 | {"userId": self.incoming_message.hosting_context.user_id} 275 | ) 276 | } 277 | elif self.incoming_message.conversation_type == "1": 278 | body["openSpaceId"] = "dtv1.card//{spaceType}.{spaceId}".format( 279 | spaceType="IM_ROBOT", spaceId=self.incoming_message.sender_staff_id 280 | ) 281 | body["imRobotOpenDeliverModel"] = {"spaceType": "IM_ROBOT"} 282 | 283 | # 增加托管extension 284 | if self.incoming_message.hosting_context is not None: 285 | body["imRobotOpenDeliverModel"]["extension"] = { 286 | "hostingRepliedContext": json.dumps( 287 | {"userId": self.incoming_message.hosting_context.user_id} 288 | ) 289 | } 290 | 291 | # 投放卡片。https://open.dingtalk.com/document/orgapp/delivery-card-interface 292 | url = DINGTALK_OPENAPI_ENDPOINT + "/v1.0/card/instances/deliver" 293 | try: 294 | response_text = "" 295 | async with aiohttp.ClientSession() as session: 296 | async with session.post( 297 | url, headers=self.get_request_header(access_token), json=body 298 | ) as response: 299 | response_text = await response.text() 300 | 301 | response.raise_for_status() 302 | 303 | return card_instance_id 304 | except aiohttp.ClientResponseError as e: 305 | self.logger.error( 306 | f"CardReplier.async_create_and_send_card failed, send card failed, HTTP Error: {e.status}, URL: {url}, response.text: {response_text}" 307 | ) 308 | except Exception as e: 309 | self.logger.error( 310 | f"CardReplier.async_create_and_send_card send card unexpected error occurred: {e}" 311 | ) 312 | 313 | def create_and_deliver_card( 314 | self, 315 | card_template_id: str, 316 | card_data: dict, 317 | callback_type: str = "STREAM", 318 | callback_route_key: str = "", 319 | at_sender: bool = False, 320 | at_all: bool = False, 321 | recipients: list = None, 322 | support_forward: bool = True, 323 | **kwargs, 324 | ) -> str: 325 | """ 326 | 创建并发送卡片一步到位,支持传入其他参数以达到投放吊顶场域卡片的效果等等。 327 | https://open.dingtalk.com/document/orgapp/create-and-deliver-cards 328 | :param support_forward: 卡片是否支持转发 329 | :param callback_route_key: HTTP 回调时的 route key 330 | :param callback_type: 卡片回调模式 331 | :param recipients: 接收者 332 | :param card_template_id: 卡片模板 ID 333 | :param card_data: 卡片数据 334 | :param at_sender: 是否@发送者 335 | :param at_all: 是否@所有人 336 | :param kwargs: 其他参数,如覆盖 openSpaceId,配置动态数据源 openDynamicDataConfig,配置吊顶场域 topOpenSpaceModel、topOpenDeliverModel 等等 337 | :return: 卡片的实例ID 338 | """ 339 | access_token = self.dingtalk_client.get_access_token() 340 | if not access_token: 341 | self.logger.error( 342 | "CardResponder.send_card failed, cannot get dingtalk access token" 343 | ) 344 | return "" 345 | 346 | card_instance_id = self.gen_card_id(self.incoming_message) 347 | body = { 348 | "cardTemplateId": card_template_id, 349 | "outTrackId": card_instance_id, 350 | "cardData": {"cardParamMap": card_data}, 351 | "callbackType": callback_type, 352 | "imGroupOpenSpaceModel": {"supportForward": support_forward}, 353 | "imRobotOpenSpaceModel": {"supportForward": support_forward}, 354 | } 355 | 356 | if callback_type == "HTTP": 357 | body["callbackType"] = "HTTP" 358 | body["callbackRouteKey"] = callback_route_key 359 | 360 | # 2:群聊,1:单聊 361 | if self.incoming_message.conversation_type == "2": 362 | body["openSpaceId"] = "dtv1.card//{spaceType}.{spaceId}".format( 363 | spaceType="IM_GROUP", spaceId=self.incoming_message.conversation_id 364 | ) 365 | body["imGroupOpenDeliverModel"] = { 366 | "robotCode": self.dingtalk_client.credential.client_id, 367 | } 368 | 369 | if at_all: 370 | body["imGroupOpenDeliverModel"]["atUserIds"] = { 371 | "@ALL": "@ALL", 372 | } 373 | elif at_sender: 374 | body["imGroupOpenDeliverModel"]["atUserIds"] = { 375 | self.incoming_message.sender_staff_id: self.incoming_message.sender_nick, 376 | } 377 | 378 | if recipients is not None: 379 | body["imGroupOpenDeliverModel"]["recipients"] = recipients 380 | 381 | # 增加托管extension 382 | if self.incoming_message.hosting_context is not None: 383 | body["imGroupOpenDeliverModel"]["extension"] = { 384 | "hostingRepliedContext": json.dumps( 385 | {"userId": self.incoming_message.hosting_context.user_id} 386 | ) 387 | } 388 | elif self.incoming_message.conversation_type == "1": 389 | body["openSpaceId"] = "dtv1.card//{spaceType}.{spaceId}".format( 390 | spaceType="IM_ROBOT", spaceId=self.incoming_message.sender_staff_id 391 | ) 392 | body["imRobotOpenDeliverModel"] = {"spaceType": "IM_ROBOT"} 393 | 394 | # 增加托管extension 395 | if self.incoming_message.hosting_context is not None: 396 | body["imRobotOpenDeliverModel"]["extension"] = { 397 | "hostingRepliedContext": json.dumps( 398 | {"userId": self.incoming_message.hosting_context.user_id} 399 | ) 400 | } 401 | 402 | url = DINGTALK_OPENAPI_ENDPOINT + "/v1.0/card/instances/createAndDeliver" 403 | try: 404 | response_text = "" 405 | body = {**body, **kwargs} 406 | response = requests.post( 407 | url, headers=self.get_request_header(access_token), json=body 408 | ) 409 | response_text = response.text 410 | 411 | response.raise_for_status() 412 | except Exception as e: 413 | self.logger.error( 414 | f"CardReplier.create_and_deliver_card failed, error={e}, response.text={response_text}" 415 | ) 416 | 417 | return card_instance_id 418 | 419 | async def async_create_and_deliver_card( 420 | self, 421 | card_template_id: str, 422 | card_data: dict, 423 | callback_type: str = "STREAM", 424 | callback_route_key: str = "", 425 | at_sender: bool = False, 426 | at_all: bool = False, 427 | recipients: list = None, 428 | support_forward: bool = True, 429 | **kwargs, 430 | ) -> str: 431 | """ 432 | 创建并发送卡片一步到位,支持传入其他参数以达到投放吊顶场域卡片的效果等等。 433 | https://open.dingtalk.com/document/orgapp/create-and-deliver-cards 434 | :param support_forward: 卡片是否支持转发 435 | :param callback_route_key: HTTP 回调时的 route key 436 | :param callback_type: 卡片回调模式 437 | :param recipients: 接收者 438 | :param card_template_id: 卡片模板 ID 439 | :param card_data: 卡片数据 440 | :param at_sender: 是否@发送者 441 | :param at_all: 是否@所有人 442 | :param kwargs: 其他参数,如覆盖 openSpaceId,配置动态数据源 openDynamicDataConfig,配置吊顶场域 topOpenSpaceModel、topOpenDeliverModel 等等 443 | :return: 卡片的实例ID 444 | """ 445 | access_token = self.dingtalk_client.get_access_token() 446 | if not access_token: 447 | self.logger.error( 448 | "CardResponder.send_card failed, cannot get dingtalk access token" 449 | ) 450 | return "" 451 | 452 | card_instance_id = self.gen_card_id(self.incoming_message) 453 | body = { 454 | "cardTemplateId": card_template_id, 455 | "outTrackId": card_instance_id, 456 | "cardData": {"cardParamMap": card_data}, 457 | "callbackType": callback_type, 458 | "imGroupOpenSpaceModel": {"supportForward": support_forward}, 459 | "imRobotOpenSpaceModel": {"supportForward": support_forward}, 460 | } 461 | 462 | if callback_type == "HTTP": 463 | body["callbackType"] = "HTTP" 464 | body["callbackRouteKey"] = callback_route_key 465 | 466 | # 2:群聊,1:单聊 467 | if self.incoming_message.conversation_type == "2": 468 | body["openSpaceId"] = "dtv1.card//{spaceType}.{spaceId}".format( 469 | spaceType="IM_GROUP", spaceId=self.incoming_message.conversation_id 470 | ) 471 | body["imGroupOpenDeliverModel"] = { 472 | "robotCode": self.dingtalk_client.credential.client_id, 473 | } 474 | 475 | if at_all: 476 | body["imGroupOpenDeliverModel"]["atUserIds"] = { 477 | "@ALL": "@ALL", 478 | } 479 | elif at_sender: 480 | body["imGroupOpenDeliverModel"]["atUserIds"] = { 481 | self.incoming_message.sender_staff_id: self.incoming_message.sender_nick, 482 | } 483 | 484 | if recipients is not None: 485 | body["imGroupOpenDeliverModel"]["recipients"] = recipients 486 | 487 | # 增加托管extension 488 | if self.incoming_message.hosting_context is not None: 489 | body["imGroupOpenDeliverModel"]["extension"] = { 490 | "hostingRepliedContext": json.dumps( 491 | {"userId": self.incoming_message.hosting_context.user_id} 492 | ) 493 | } 494 | elif self.incoming_message.conversation_type == "1": 495 | body["openSpaceId"] = "dtv1.card//{spaceType}.{spaceId}".format( 496 | spaceType="IM_ROBOT", spaceId=self.incoming_message.sender_staff_id 497 | ) 498 | body["imRobotOpenDeliverModel"] = {"spaceType": "IM_ROBOT"} 499 | 500 | # 增加托管extension 501 | if self.incoming_message.hosting_context is not None: 502 | body["imRobotOpenDeliverModel"]["extension"] = { 503 | "hostingRepliedContext": json.dumps( 504 | {"userId": self.incoming_message.hosting_context.user_id} 505 | ) 506 | } 507 | 508 | url = DINGTALK_OPENAPI_ENDPOINT + "/v1.0/card/instances/createAndDeliver" 509 | 510 | body = {**body, **kwargs} 511 | async with aiohttp.ClientSession() as session: 512 | try: 513 | response_text = "" 514 | async with session.post( 515 | url, headers=self.get_request_header(access_token), json=body 516 | ) as response: 517 | response_text = await response.text() 518 | 519 | response.raise_for_status() 520 | except aiohttp.ClientResponseError as e: 521 | self.logger.error( 522 | f"CardReplier.async_create_and_deliver_card failed, HTTP Error: {e.status}, URL: {url}, response.text: {response_text}" 523 | ) 524 | except Exception as e: 525 | self.logger.error( 526 | f"CardReplier.async_create_and_deliver_card unexpected error occurred: {e}" 527 | ) 528 | 529 | return card_instance_id 530 | 531 | def put_card_data(self, card_instance_id: str, card_data: dict, **kwargs): 532 | """ 533 | 更新卡片内容 534 | https://open.dingtalk.com/document/orgapp/interactive-card-update-interface 535 | :param card_instance_id: 536 | :param card_data: 537 | :param kwargs: 其他参数,如 privateData、cardUpdateOptions、userIdType 538 | :return: 539 | """ 540 | access_token = self.dingtalk_client.get_access_token() 541 | if not access_token: 542 | self.logger.error( 543 | "CardReplier.put_card_data failed, cannot get dingtalk access token" 544 | ) 545 | return 546 | 547 | body = { 548 | "outTrackId": card_instance_id, 549 | "cardData": {"cardParamMap": card_data}, 550 | **kwargs, 551 | } 552 | 553 | url = DINGTALK_OPENAPI_ENDPOINT + "/v1.0/card/instances" 554 | try: 555 | response_text = "" 556 | response = requests.put( 557 | url, headers=self.get_request_header(access_token), json=body 558 | ) 559 | response_text = response.text 560 | 561 | response.raise_for_status() 562 | except Exception as e: 563 | self.logger.error( 564 | f"CardReplier.put_card_data failed, update card failed, error={e}, response.text={response_text}" 565 | ) 566 | return 567 | 568 | async def async_put_card_data( 569 | self, card_instance_id: str, card_data: dict, **kwargs 570 | ): 571 | """ 572 | 更新卡片内容 573 | https://open.dingtalk.com/document/orgapp/interactive-card-update-interface 574 | :param card_instance_id: 575 | :param card_data: 576 | :param kwargs: 其他参数,如 privateData、cardUpdateOptions、userIdType 577 | :return: 578 | """ 579 | access_token = self.dingtalk_client.get_access_token() 580 | if not access_token: 581 | self.logger.error( 582 | "CardReplier.put_card_data failed, cannot get dingtalk access token" 583 | ) 584 | return 585 | 586 | body = { 587 | "outTrackId": card_instance_id, 588 | "cardData": {"cardParamMap": card_data}, 589 | **kwargs, 590 | } 591 | 592 | url = DINGTALK_OPENAPI_ENDPOINT + "/v1.0/card/instances" 593 | try: 594 | response_text = "" 595 | async with aiohttp.ClientSession() as session: 596 | async with session.put( 597 | url, headers=self.get_request_header(access_token), json=body 598 | ) as response: 599 | response_text = await response.text() 600 | 601 | response.raise_for_status() 602 | except aiohttp.ClientResponseError as e: 603 | self.logger.error( 604 | f"CardReplier.async_put_card_data failed, HTTP Error: {e.status}, URL: {url}, response.text: {response_text}" 605 | ) 606 | except Exception as e: 607 | self.logger.error( 608 | f"CardReplier.async_put_card_data unexpected error occurred: {e}" 609 | ) 610 | 611 | 612 | @unique 613 | class AICardStatus(str, Enum): 614 | PROCESSING: str = 1 # 处理中 615 | INPUTING: str = 2 # 输入中 616 | EXECUTING: str = 4 # 执行中 617 | FINISHED: str = 3 # 执行完成 618 | FAILED: str = 5 # 执行失败 619 | 620 | 621 | class AICardReplier(CardReplier): 622 | 623 | def __init__(self, dingtalk_client, incoming_message): 624 | super(AICardReplier, self).__init__(dingtalk_client, incoming_message) 625 | 626 | def start( 627 | self, 628 | card_template_id: str, 629 | card_data: dict, 630 | recipients: list = None, 631 | support_forward: bool = True, 632 | ) -> str: 633 | """ 634 | AI卡片的创建接口 635 | :param support_forward: 636 | :param recipients: 637 | :param card_template_id: 638 | :param card_data: 639 | :return: 640 | """ 641 | card_data_with_status = copy.deepcopy(card_data) 642 | card_data_with_status["flowStatus"] = AICardStatus.PROCESSING 643 | return self.create_and_send_card( 644 | card_template_id, 645 | card_data_with_status, 646 | at_sender=False, 647 | at_all=False, 648 | recipients=recipients, 649 | support_forward=support_forward, 650 | ) 651 | 652 | async def async_start( 653 | self, 654 | card_template_id: str, 655 | card_data: dict, 656 | recipients: list = None, 657 | support_forward: bool = True, 658 | ) -> str: 659 | """ 660 | AI卡片的创建接口 661 | :param support_forward: 662 | :param recipients: 663 | :param card_template_id: 664 | :param card_data: 665 | :return: 666 | """ 667 | card_data_with_status = copy.deepcopy(card_data) 668 | card_data_with_status["flowStatus"] = AICardStatus.PROCESSING 669 | return await self.async_create_and_send_card( 670 | card_template_id, 671 | card_data_with_status, 672 | at_sender=False, 673 | at_all=False, 674 | recipients=recipients, 675 | support_forward=support_forward, 676 | ) 677 | 678 | def finish(self, card_instance_id: str, card_data: dict): 679 | """ 680 | AI卡片执行完成的接口,整体更新 681 | :param card_instance_id: 682 | :param card_data: 683 | :return: 684 | """ 685 | card_data_with_status = copy.deepcopy(card_data) 686 | card_data_with_status["flowStatus"] = AICardStatus.FINISHED 687 | self.put_card_data(card_instance_id, card_data_with_status) 688 | 689 | async def async_finish(self, card_instance_id: str, card_data: dict): 690 | """ 691 | AI卡片执行完成的接口,整体更新 692 | :param card_instance_id: 693 | :param card_data: 694 | :return: 695 | """ 696 | card_data_with_status = copy.deepcopy(card_data) 697 | card_data_with_status["flowStatus"] = AICardStatus.FINISHED 698 | await self.async_put_card_data(card_instance_id, card_data_with_status) 699 | 700 | def fail(self, card_instance_id: str, card_data: dict): 701 | """ 702 | AI卡片变成失败状态的接口,整体更新,非streaming 703 | :param card_instance_id: 704 | :param card_data: 705 | :return: 706 | """ 707 | card_data_with_status = copy.deepcopy(card_data) 708 | card_data_with_status["flowStatus"] = AICardStatus.FAILED 709 | self.put_card_data(card_instance_id, card_data_with_status) 710 | 711 | async def async_fail(self, card_instance_id: str, card_data: dict): 712 | """ 713 | AI卡片变成失败状态的接口,整体更新,非streaming 714 | :param card_instance_id: 715 | :param card_data: 716 | :return: 717 | """ 718 | card_data_with_status = copy.deepcopy(card_data) 719 | card_data_with_status["flowStatus"] = AICardStatus.FAILED 720 | await self.async_put_card_data(card_instance_id, card_data_with_status) 721 | 722 | def streaming( 723 | self, 724 | card_instance_id: str, 725 | content_key: str, 726 | content_value: str, 727 | append: bool, 728 | finished: bool, 729 | failed: bool, 730 | ): 731 | """ 732 | AI卡片的流式输出 733 | :param card_instance_id: 734 | :param content_key: 735 | :param content_value: 736 | :param append: 737 | :param finished: 738 | :param failed: 739 | :return: 740 | """ 741 | access_token = self.dingtalk_client.get_access_token() 742 | if not access_token: 743 | self.logger.error( 744 | "AICardReplier.streaming failed, cannot get dingtalk access token" 745 | ) 746 | return None 747 | 748 | body = { 749 | "outTrackId": card_instance_id, 750 | "guid": str(uuid.uuid1()), 751 | "key": content_key, 752 | "content": content_value, 753 | "isFull": not append, 754 | "isFinalize": finished, 755 | "isError": failed, 756 | } 757 | 758 | url = DINGTALK_OPENAPI_ENDPOINT + "/v1.0/card/streaming" 759 | try: 760 | response_text = '' 761 | response = requests.put( 762 | url, headers=self.get_request_header(access_token), json=body 763 | ) 764 | response_text = response.text 765 | 766 | response.raise_for_status() 767 | except Exception as e: 768 | self.logger.error( 769 | f"AICardReplier.streaming failed, error={e}, response.text={response_text}" 770 | ) 771 | return 772 | 773 | async def async_streaming( 774 | self, 775 | card_instance_id: str, 776 | content_key: str, 777 | content_value: str, 778 | append: bool, 779 | finished: bool, 780 | failed: bool, 781 | ): 782 | """ 783 | AI卡片的流式输出 784 | :param card_instance_id: 785 | :param content_key: 786 | :param content_value: 787 | :param append: 788 | :param finished: 789 | :param failed: 790 | :return: 791 | """ 792 | access_token = self.dingtalk_client.get_access_token() 793 | if not access_token: 794 | self.logger.error( 795 | "AICardReplier.streaming failed, cannot get dingtalk access token" 796 | ) 797 | return None 798 | 799 | body = { 800 | "outTrackId": card_instance_id, 801 | "guid": str(uuid.uuid1()), 802 | "key": content_key, 803 | "content": content_value, 804 | "isFull": not append, 805 | "isFinalize": finished, 806 | "isError": failed, 807 | } 808 | 809 | url = DINGTALK_OPENAPI_ENDPOINT + "/v1.0/card/streaming" 810 | try: 811 | response_text = '' 812 | async with aiohttp.ClientSession() as session: 813 | async with session.put( 814 | url, headers=self.get_request_header(access_token), json=body 815 | ) as response: 816 | response_text = await response.text() 817 | 818 | response.raise_for_status() 819 | except aiohttp.ClientResponseError as e: 820 | self.logger.error( 821 | f"AICardReplier.async_streaming failed, HTTP Error: {e.status}, URL: {url}, response.text={response_text}" 822 | ) 823 | except Exception as e: 824 | self.logger.error( 825 | f"CardReplier.async_streaming unexpected error occurred: {e}" 826 | ) 827 | -------------------------------------------------------------------------------- /dingtalk_stream/chatbot.py: -------------------------------------------------------------------------------- 1 | # -*- coding:utf-8 -*- 2 | 3 | import json 4 | import requests 5 | import platform 6 | import hashlib 7 | from .stream import CallbackHandler, CallbackMessage 8 | from .frames import AckMessage, Headers 9 | from .interactive_card import generate_multi_text_line_card_data 10 | from .utils import DINGTALK_OPENAPI_ENDPOINT 11 | from concurrent.futures import ThreadPoolExecutor 12 | import uuid 13 | from .card_instance import MarkdownCardInstance, AIMarkdownCardInstance, CarouselCardInstance, \ 14 | MarkdownButtonCardInstance, RPAPluginCardInstance 15 | import traceback 16 | 17 | 18 | class AtUser(object): 19 | def __init__(self): 20 | self.dingtalk_id = None 21 | self.staff_id = None 22 | self.extensions = {} 23 | 24 | @classmethod 25 | def from_dict(cls, d): 26 | user = AtUser() 27 | data = '' 28 | for name, value in d.items(): 29 | if name == 'dingtalkId': 30 | user.dingtalk_id = value 31 | elif name == 'staffId': 32 | user.staff_id = value 33 | else: 34 | user.extensions[name] = value 35 | return user 36 | 37 | def to_dict(self): 38 | result = self.extensions.copy() 39 | if self.dingtalk_id is not None: 40 | result['dingtalkId'] = self.dingtalk_id 41 | if self.staff_id is not None: 42 | result['staffId'] = self.staff_id 43 | return result 44 | 45 | 46 | class TextContent(object): 47 | content: str 48 | 49 | def __init__(self): 50 | self.content = None 51 | self.extensions = {} 52 | 53 | def __str__(self): 54 | return 'TextContent(content=%s)' % self.content 55 | 56 | @classmethod 57 | def from_dict(cls, d): 58 | content = TextContent() 59 | data = '' 60 | for name, value in d.items(): 61 | if name == 'content': 62 | content.content = value 63 | else: 64 | content.extensions[name] = value 65 | return content 66 | 67 | def to_dict(self): 68 | result = self.extensions.copy() 69 | if self.content is not None: 70 | result['content'] = self.content 71 | return result 72 | 73 | 74 | class ImageContent(object): 75 | 76 | def __init__(self): 77 | self.download_code = None 78 | 79 | @classmethod 80 | def from_dict(cls, d): 81 | content = ImageContent() 82 | for name, value in d.items(): 83 | if name == 'downloadCode': 84 | content.download_code = value 85 | return content 86 | 87 | def to_dict(self): 88 | result = {} 89 | if self.download_code is not None: 90 | result['downloadCode'] = self.download_code 91 | return result 92 | 93 | 94 | class RichTextContent(object): 95 | 96 | def __init__(self): 97 | self.rich_text_list = None 98 | 99 | @classmethod 100 | def from_dict(cls, d): 101 | content = RichTextContent() 102 | content.rich_text_list = [] 103 | for name, value in d.items(): 104 | if name == 'richText': 105 | content.rich_text_list = value 106 | return content 107 | 108 | def to_dict(self): 109 | result = {} 110 | if self.rich_text_list is not None: 111 | result['richText'] = self.rich_text_list 112 | return result 113 | 114 | 115 | class HostingContext(object): 116 | """ 117 | 托管人的上下文 118 | """ 119 | 120 | def __init__(self): 121 | self.user_id = "" 122 | self.nick = "" 123 | 124 | def to_dict(self): 125 | result = { 126 | "userId": self.user_id, 127 | "nick": self.nick, 128 | } 129 | return result 130 | 131 | 132 | class ConversationMessage(object): 133 | """ 134 | 历史消息状态 135 | """ 136 | 137 | def __init__(self): 138 | self.read_status = "" 139 | self.sender_user_id = "" 140 | self.send_time = 0 141 | 142 | def read_by_me(self) -> bool: 143 | """ 144 | 消息是否被我已读 145 | :return: 146 | """ 147 | return self.read_status == "2" 148 | 149 | def to_dict(self): 150 | result = { 151 | "readStatus": self.read_status, 152 | "senderUserId": self.sender_user_id, 153 | "sendTime": self.send_time 154 | } 155 | return result 156 | 157 | 158 | class ChatbotMessage(object): 159 | TOPIC = '/v1.0/im/bot/messages/get' 160 | DELEGATE_TOPIC = '/v1.0/im/bot/messages/delegate' 161 | text: TextContent 162 | 163 | def __init__(self): 164 | self.is_in_at_list = None 165 | self.session_webhook = None 166 | self.sender_nick = None 167 | self.robot_code = None 168 | self.session_webhook_expired_time = None 169 | self.message_id = None 170 | self.sender_id = None 171 | self.chatbot_user_id = None 172 | self.conversation_id = None 173 | self.is_admin = None 174 | self.create_at = None 175 | self.text = None 176 | self.conversation_type = None 177 | self.at_users = [] 178 | self.chatbot_corp_id = None 179 | self.sender_corp_id = None 180 | self.conversation_title = None 181 | self.message_type = None 182 | self.image_content = None 183 | self.rich_text_content = None 184 | self.sender_staff_id = None 185 | self.hosting_context: HostingContext = None 186 | self.conversation_msg_context = None 187 | 188 | self.extensions = {} 189 | 190 | @classmethod 191 | def from_dict(cls, d): 192 | msg = ChatbotMessage() 193 | data = '' 194 | for name, value in d.items(): 195 | if name == 'isInAtList': 196 | msg.is_in_at_list = value 197 | elif name == 'sessionWebhook': 198 | msg.session_webhook = value 199 | elif name == 'senderNick': 200 | msg.sender_nick = value 201 | elif name == 'robotCode': 202 | msg.robot_code = value 203 | elif name == 'sessionWebhookExpiredTime': 204 | msg.session_webhook_expired_time = int(value) 205 | elif name == 'msgId': 206 | msg.message_id = value 207 | elif name == 'senderId': 208 | msg.sender_id = value 209 | elif name == 'chatbotUserId': 210 | msg.chatbot_user_id = value 211 | elif name == 'conversationId': 212 | msg.conversation_id = value 213 | elif name == 'isAdmin': 214 | msg.is_admin = value 215 | elif name == 'createAt': 216 | msg.create_at = value 217 | elif name == 'conversationType': 218 | msg.conversation_type = value 219 | elif name == 'atUsers': 220 | msg.at_users = [AtUser.from_dict(i) for i in value] 221 | elif name == 'chatbotCorpId': 222 | msg.chatbot_corp_id = value 223 | elif name == 'senderCorpId': 224 | msg.sender_corp_id = value 225 | elif name == 'conversationTitle': 226 | msg.conversation_title = value 227 | elif name == 'msgtype': 228 | msg.message_type = value 229 | if value == 'text': 230 | msg.text = TextContent.from_dict(d['text']) 231 | elif value == 'picture': 232 | msg.image_content = ImageContent.from_dict(d['content']) 233 | elif value == 'richText': 234 | msg.rich_text_content = RichTextContent.from_dict(d['content']) 235 | elif name == 'senderStaffId': 236 | msg.sender_staff_id = value 237 | elif name == 'hostingContext': 238 | msg.hosting_context = HostingContext() 239 | msg.hosting_context.user_id = value["userId"] 240 | msg.hosting_context.nick = value["nick"] 241 | elif name == 'conversationMsgContext': 242 | msg.conversation_msg_context = [] 243 | for v in value: 244 | conversation_msg = ConversationMessage() 245 | conversation_msg.read_status = v["readStatus"] 246 | conversation_msg.send_time = v["sendTime"] 247 | conversation_msg.sender_user_id = v["senderUserId"] 248 | 249 | msg.conversation_msg_context.append(conversation_msg) 250 | else: 251 | msg.extensions[name] = value 252 | return msg 253 | 254 | def to_dict(self): 255 | result = self.extensions.copy() 256 | if self.is_in_at_list is not None: 257 | result['isInAtList'] = self.is_in_at_list 258 | if self.session_webhook is not None: 259 | result['sessionWebhook'] = self.session_webhook 260 | if self.sender_nick is not None: 261 | result['senderNick'] = self.sender_nick 262 | if self.robot_code is not None: 263 | result['robotCode'] = self.robot_code 264 | if self.session_webhook_expired_time is not None: 265 | result['sessionWebhookExpiredTime'] = self.session_webhook_expired_time 266 | if self.message_id is not None: 267 | result['msgId'] = self.message_id 268 | if self.sender_id is not None: 269 | result['senderId'] = self.sender_id 270 | if self.chatbot_user_id is not None: 271 | result['chatbotUserId'] = self.chatbot_user_id 272 | if self.conversation_id is not None: 273 | result['conversationId'] = self.conversation_id 274 | if self.is_admin is not None: 275 | result['isAdmin'] = self.is_admin 276 | if self.create_at is not None: 277 | result['createAt'] = self.create_at 278 | if self.text is not None: 279 | result['text'] = self.text.to_dict() 280 | if self.image_content is not None: 281 | result['content'] = self.image_content.to_dict() 282 | if self.rich_text_content is not None: 283 | result['content'] = self.rich_text_content.to_dict() 284 | if self.conversation_type is not None: 285 | result['conversationType'] = self.conversation_type 286 | if self.at_users is not None: 287 | result['atUsers'] = [i.to_dict() for i in self.at_users] 288 | if self.chatbot_corp_id is not None: 289 | result['chatbotCorpId'] = self.chatbot_corp_id 290 | if self.sender_corp_id is not None: 291 | result['senderCorpId'] = self.sender_corp_id 292 | if self.conversation_title is not None: 293 | result['conversationTitle'] = self.conversation_title 294 | if self.message_type is not None: 295 | result['msgtype'] = self.message_type 296 | if self.sender_staff_id is not None: 297 | result['senderStaffId'] = self.sender_staff_id 298 | if self.hosting_context is not None: 299 | result['hostingContext'] = self.hosting_context.to_dict() 300 | if self.conversation_msg_context is not None: 301 | result['conversationMsgContext'] = [v.to_dict() for v in self.conversation_msg_context] 302 | return result 303 | 304 | def get_text_list(self): 305 | if self.message_type == 'text': 306 | return [self.text.content] 307 | elif self.message_type == 'richText': 308 | text = [] 309 | for item in self.rich_text_content.rich_text_list: 310 | if 'text' in item: 311 | text.append(item["text"]) 312 | 313 | return text 314 | 315 | def get_image_list(self): 316 | if self.message_type == 'picture': 317 | return [self.image_content.download_code] 318 | elif self.message_type == 'richText': 319 | images = [] 320 | for item in self.rich_text_content.rich_text_list: 321 | if 'downloadCode' in item: 322 | images.append(item['downloadCode']) 323 | 324 | return images 325 | 326 | def __str__(self): 327 | return 'ChatbotMessage(message_type=%s, text=%s, sender_nick=%s, conversation_title=%s)' % ( 328 | self.message_type, 329 | self.text, 330 | self.sender_nick, 331 | self.conversation_title, 332 | ) 333 | 334 | 335 | def reply_specified_single_chat(user_id: str, user_nickname: str = "") -> ChatbotMessage: 336 | d = { 337 | "senderId": user_id, 338 | "senderStaffId": user_id, 339 | "sender": user_nickname, 340 | "conversationType": '1', 341 | "messageId": str(uuid.uuid1()), 342 | } 343 | return ChatbotMessage.from_dict(d) 344 | 345 | 346 | def reply_specified_group_chat(open_conversation_id: str) -> ChatbotMessage: 347 | d = { 348 | "conversationId": open_conversation_id, 349 | "conversationType": '2', 350 | "messageId": str(uuid.uuid1()), 351 | } 352 | return ChatbotMessage.from_dict(d) 353 | 354 | 355 | class ChatbotHandler(CallbackHandler): 356 | 357 | def __init__(self): 358 | super(ChatbotHandler, self).__init__() 359 | 360 | def reply_markdown_card(self, markdown: str, incoming_message: ChatbotMessage, title: str = "", logo: str = "", 361 | at_sender: bool = False, at_all: bool = False) -> MarkdownCardInstance: 362 | """ 363 | 回复一个markdown卡片 364 | :param markdown: 365 | :param incoming_message: 366 | :param title: 367 | :param logo: 368 | :param at_sender: 369 | :param at_all: 370 | :return: 371 | """ 372 | markdown_card_instance = MarkdownCardInstance(self.dingtalk_client, incoming_message) 373 | markdown_card_instance.set_title_and_logo(title, logo) 374 | 375 | markdown_card_instance.reply(markdown, at_sender=at_sender, at_all=at_all) 376 | 377 | return markdown_card_instance 378 | 379 | def reply_rpa_plugin_card(self, incoming_message: ChatbotMessage, 380 | plugin_id: str = "", 381 | plugin_version: str = "", 382 | plugin_name: str = "", 383 | ability_name: str = "", 384 | plugin_args: dict = {}, 385 | goal: str = "", 386 | corp_id: str = "", 387 | recipients: list = None) -> RPAPluginCardInstance: 388 | """ 389 | 回复一个markdown卡片 390 | :param ability_name: 391 | :param incoming_message: 392 | :param recipients: 393 | :param corp_id: 394 | :param goal: 395 | :param plugin_args: 396 | :param plugin_name: 397 | :param plugin_version: 398 | :param plugin_id: 399 | :return: 400 | """ 401 | 402 | rpa_plugin_card_instance = RPAPluginCardInstance(self.dingtalk_client, incoming_message) 403 | rpa_plugin_card_instance.set_goal(goal) 404 | rpa_plugin_card_instance.set_corp_id(corp_id) 405 | 406 | rpa_plugin_card_instance.reply(plugin_id, plugin_version, plugin_name, ability_name, plugin_args, 407 | recipients=recipients) 408 | 409 | return rpa_plugin_card_instance 410 | 411 | def reply_markdown_button(self, incoming_message: ChatbotMessage, markdown: str, button_list: list, tips: str = "", 412 | title: str = "", logo: str = "") -> MarkdownButtonCardInstance: 413 | """ 414 | 回复一个带button的卡片 415 | :param tips: 416 | :param incoming_message: 417 | :param markdown: 418 | :param button_list: 419 | :param title: 420 | :param logo: 421 | :return: 422 | """ 423 | markdown_button_instance = MarkdownButtonCardInstance(self.dingtalk_client, incoming_message) 424 | markdown_button_instance.set_title_and_logo(title, logo) 425 | 426 | markdown_button_instance.reply(markdown, button_list, tips=tips) 427 | 428 | return markdown_button_instance 429 | 430 | def reply_ai_markdown_button(self, 431 | incoming_message: ChatbotMessage, 432 | markdown: str, 433 | button_list: list, 434 | tips: str = "", 435 | title: str = "", 436 | logo: str = "", 437 | recipients: list = None, 438 | support_forward: bool = True) -> AIMarkdownCardInstance: 439 | """ 440 | 回复一个带button的ai卡片 441 | :param support_forward: 442 | :param recipients: 443 | :param tips: 444 | :param incoming_message: 445 | :param markdown: 446 | :param button_list: 447 | :param title: 448 | :param logo: 449 | :return: 450 | """ 451 | markdown_button_instance = AIMarkdownCardInstance(self.dingtalk_client, incoming_message) 452 | markdown_button_instance.set_title_and_logo(title, logo) 453 | 454 | markdown_button_instance.ai_start(recipients=recipients, support_forward=support_forward) 455 | markdown_button_instance.ai_streaming(markdown=markdown, append=True) 456 | markdown_button_instance.ai_finish(markdown=markdown, button_list=button_list, tips=tips) 457 | 458 | return markdown_button_instance 459 | 460 | def reply_carousel_card(self, incoming_message: ChatbotMessage, markdown: str, image_slider, button_text, 461 | title: str = "", 462 | logo: str = "") -> CarouselCardInstance: 463 | """ 464 | 回复一个轮播图卡片 465 | :param markdown: 466 | :param incoming_message: 467 | :param title: 468 | :param logo: 469 | :param image_slider: 470 | :param button_text: 471 | :return: 472 | """ 473 | carousel_card_instance = CarouselCardInstance(self.dingtalk_client, incoming_message) 474 | carousel_card_instance.set_title_and_logo(title, logo) 475 | 476 | carousel_card_instance.reply(markdown, image_slider, button_text) 477 | 478 | return carousel_card_instance 479 | 480 | def ai_markdown_card_start(self, incoming_message: ChatbotMessage, title: str = "", 481 | logo: str = "", recipients: list = None) -> AIMarkdownCardInstance: 482 | """ 483 | 发起一个AI卡片 484 | :param recipients: 485 | :param incoming_message: 486 | :param title: 487 | :param logo: 488 | :return: 489 | """ 490 | ai_markdown_card_instance = AIMarkdownCardInstance(self.dingtalk_client, incoming_message) 491 | ai_markdown_card_instance.set_title_and_logo(title, logo) 492 | 493 | ai_markdown_card_instance.ai_start(recipients=recipients) 494 | return ai_markdown_card_instance 495 | 496 | def extract_text_from_incoming_message(self, incoming_message: ChatbotMessage) -> list: 497 | """ 498 | 获取文本列表 499 | :param incoming_message: 500 | :return: text list。如果是纯文本消息,结果列表中只有一个元素;如果是富文本消息,结果是长列表,按富文本消息的逻辑分割,大致是按换行符分割的。 501 | """ 502 | return incoming_message.get_text_list() 503 | 504 | def extract_image_from_incoming_message(self, incoming_message: ChatbotMessage) -> list: 505 | """ 506 | 获取用户发送的图片,重新上传,获取新的mediaId列表 507 | :param incoming_message: 508 | :return: mediaid list 509 | """ 510 | image_list = incoming_message.get_image_list() 511 | if image_list is None or len(image_list) == 0: 512 | return None 513 | 514 | mediaids = [] 515 | for download_code in image_list: 516 | download_url = self.get_image_download_url(download_code) 517 | 518 | image_content = requests.get(download_url) 519 | mediaid = self.dingtalk_client.upload_to_dingtalk(image_content.content, filetype='image', 520 | filename='image.png', 521 | mimetype='image/png') 522 | 523 | mediaids.append(mediaid) 524 | 525 | return mediaids 526 | 527 | def get_image_download_url(self, download_code: str) -> str: 528 | """ 529 | 根据downloadCode获取下载链接 https://open.dingtalk.com/document/isvapp/download-the-file-content-of-the-robot-receiving-message 530 | :param download_code: 531 | :return: 532 | """ 533 | access_token = self.dingtalk_client.get_access_token() 534 | if not access_token: 535 | self.logger.error('send_off_duty_prompt failed, cannot get dingtalk access token') 536 | return None 537 | 538 | request_headers = { 539 | 'Content-Type': 'application/json', 540 | 'Accept': '*/*', 541 | 'x-acs-dingtalk-access-token': access_token, 542 | 'User-Agent': ('DingTalkStream/1.0 SDK/0.1.0 Python/%s ' 543 | '(+https://github.com/open-dingtalk/dingtalk-stream-sdk-python)' 544 | ) % platform.python_version(), 545 | } 546 | 547 | values = { 548 | 'robotCode': self.dingtalk_client.credential.client_id, 549 | 'downloadCode': download_code, 550 | } 551 | 552 | url = DINGTALK_OPENAPI_ENDPOINT + '/v1.0/robot/messageFiles/download' 553 | 554 | try: 555 | response_text = '' 556 | response = requests.post(url, 557 | headers=request_headers, 558 | data=json.dumps(values)) 559 | response_text = response.text 560 | 561 | response.raise_for_status() 562 | except Exception as e: 563 | self.logger.error(f'get_image_download_url, error={e}, response.text={response_text}') 564 | return "" 565 | return response.json()["downloadUrl"] 566 | 567 | def set_off_duty_prompt(self, text: str, title: str = "", logo: str = ""): 568 | """ 569 | 设置离线提示词,需要使用OpenAPI,当前仅支持自建应用。 570 | :param text: 离线提示词,支持markdown 571 | :param title: 机器人名称,默认:"钉钉Stream机器人" 572 | :param logo: 机器人logo,默认:"@lALPDfJ6V_FPDmvNAfTNAfQ" 573 | :return: 574 | """ 575 | access_token = self.dingtalk_client.get_access_token() 576 | if not access_token: 577 | self.logger.error('send_off_duty_prompt failed, cannot get dingtalk access token') 578 | return None 579 | 580 | if title is None or title == "": 581 | title = "钉钉Stream机器人" 582 | 583 | if logo is None or logo == "": 584 | logo = "@lALPDfJ6V_FPDmvNAfTNAfQ" 585 | 586 | prompt_card_data = generate_multi_text_line_card_data(title=title, logo=logo, texts=[text]) 587 | 588 | request_headers = { 589 | 'Content-Type': 'application/json', 590 | 'Accept': '*/*', 591 | 'x-acs-dingtalk-access-token': access_token, 592 | 'User-Agent': ('DingTalkStream/1.0 SDK/0.1.0 Python/%s ' 593 | '(+https://github.com/open-dingtalk/dingtalk-stream-sdk-python)' 594 | ) % platform.python_version(), 595 | } 596 | 597 | values = { 598 | 'robotCode': self.dingtalk_client.credential.client_id, 599 | 'cardData': json.dumps(prompt_card_data), 600 | 'cardTemplateId': "StandardCard", 601 | } 602 | 603 | url = DINGTALK_OPENAPI_ENDPOINT + '/v1.0/innerApi/robot/stream/away/template/update' 604 | 605 | try: 606 | response_text = '' 607 | response = requests.post(url, 608 | headers=request_headers, 609 | data=json.dumps(values)) 610 | response_text = response.text 611 | 612 | response.raise_for_status() 613 | except Exception as e: 614 | self.logger.error(f'set_off_duty_prompt, error={e}, response.text={response_text}') 615 | return response.status_code 616 | return response.json() 617 | 618 | def reply_text(self, 619 | text: str, 620 | incoming_message: ChatbotMessage): 621 | request_headers = { 622 | 'Content-Type': 'application/json', 623 | 'Accept': '*/*', 624 | } 625 | values = { 626 | 'msgtype': 'text', 627 | 'text': { 628 | 'content': text, 629 | }, 630 | 'at': { 631 | 'atUserIds': [incoming_message.sender_staff_id], 632 | } 633 | } 634 | try: 635 | response_text = '' 636 | response = requests.post(incoming_message.session_webhook, 637 | headers=request_headers, 638 | data=json.dumps(values)) 639 | response_text = response.text 640 | 641 | response.raise_for_status() 642 | except Exception as e: 643 | self.logger.error(f'reply text failed, error={e}, response.text={response_text}') 644 | return None 645 | return response.json() 646 | 647 | def reply_markdown(self, 648 | title: str, 649 | text: str, 650 | incoming_message: ChatbotMessage): 651 | request_headers = { 652 | 'Content-Type': 'application/json', 653 | 'Accept': '*/*', 654 | } 655 | values = { 656 | 'msgtype': 'markdown', 657 | 'markdown': { 658 | 'title': title, 659 | 'text': text, 660 | }, 661 | 'at': { 662 | 'atUserIds': [incoming_message.sender_staff_id], 663 | } 664 | } 665 | try: 666 | response_text = '' 667 | response = requests.post(incoming_message.session_webhook, 668 | headers=request_headers, 669 | data=json.dumps(values)) 670 | response_text = response.text 671 | 672 | response.raise_for_status() 673 | except Exception as e: 674 | self.logger.error(f'reply markdown failed, error={e}, response.text={response_text}') 675 | return None 676 | return response.json() 677 | 678 | def reply_card(self, 679 | card_data: dict, 680 | incoming_message: ChatbotMessage, 681 | at_sender: bool = False, 682 | at_all: bool = False, 683 | **kwargs) -> str: 684 | """ 685 | 机器人回复互动卡片。由于 sessionWebhook 不支持发送互动卡片,所以需要使用 OpenAPI,当前仅支持自建应用。 686 | https://open.dingtalk.com/document/orgapp/robots-send-interactive-cards 687 | :param card_data: 卡片数据内容,interactive_card.py 中有一些简单的样例,高阶需求请至卡片搭建平台:https://card.dingtalk.com/card-builder 688 | :param incoming_message: 回调数据源 689 | :param at_sender: 是否at发送人 690 | :param at_all: 是否at所有人 691 | :param kwargs: 其他参数,具体可参考文档。 692 | :return: 693 | """ 694 | access_token = self.dingtalk_client.get_access_token() 695 | if not access_token: 696 | self.logger.error( 697 | 'simple_reply_interactive_card_only_for_inner_app failed, cannot get dingtalk access token') 698 | return None 699 | 700 | request_headers = { 701 | 'Content-Type': 'application/json', 702 | 'Accept': '*/*', 703 | 'x-acs-dingtalk-access-token': access_token, 704 | 'User-Agent': ('DingTalkStream/1.0 SDK/0.1.0 Python/%s ' 705 | '(+https://github.com/open-dingtalk/dingtalk-stream-sdk-python)' 706 | ) % platform.python_version(), 707 | } 708 | 709 | card_biz_id = self._gen_card_id(incoming_message) 710 | body = { 711 | "cardTemplateId": "StandardCard", 712 | "robotCode": self.dingtalk_client.credential.client_id, 713 | "cardData": json.dumps(card_data), 714 | "sendOptions": { 715 | # "atUserListJson": "String", 716 | # "atAll": at_all, 717 | # "receiverListJson": "String", 718 | # "cardPropertyJson": "String" 719 | }, 720 | "cardBizId": card_biz_id, 721 | } 722 | 723 | if incoming_message.conversation_type == '2': 724 | body["openConversationId"] = incoming_message.conversation_id 725 | elif incoming_message.conversation_type == '1': 726 | single_chat_receiver = { 727 | "userId": incoming_message.sender_staff_id 728 | } 729 | body["singleChatReceiver"] = json.dumps(single_chat_receiver) 730 | 731 | if at_all: 732 | body["sendOptions"]["atAll"] = True 733 | else: 734 | body["sendOptions"]["atAll"] = False 735 | 736 | if at_sender: 737 | user_list_json = [ 738 | { 739 | "nickName": incoming_message.sender_nick, 740 | "userId": incoming_message.sender_staff_id 741 | } 742 | ] 743 | body["sendOptions"]["atUserListJson"] = json.dumps(user_list_json, ensure_ascii=False) 744 | body.update(**kwargs) 745 | 746 | url = DINGTALK_OPENAPI_ENDPOINT + '/v1.0/im/v1.0/robot/interactiveCards/send' 747 | try: 748 | response_text = '' 749 | response = requests.post(url, 750 | headers=request_headers, 751 | json=body) 752 | response_text = response.text 753 | 754 | response.raise_for_status() 755 | 756 | return card_biz_id 757 | except Exception as e: 758 | self.logger.error(f'reply card failed, error={e}, response.text={response_text}') 759 | return "" 760 | 761 | def update_card(self, card_biz_id: str, card_data: dict): 762 | """ 763 | 更新机器人发送互动卡片(普通版)。 764 | https://open.dingtalk.com/document/orgapp/update-the-robot-to-send-interactive-cards 765 | :param card_biz_id: 唯一标识一张卡片的外部ID(卡片幂等ID,可用于更新或重复发送同一卡片到多个群会话)。需与 self.reply_card 接口返回的 card_biz_id 保持一致。 766 | :param card_data: 要更新的卡片数据内容。详情参考卡片搭建平台:https://card.dingtalk.com/card-builder 767 | :return: 768 | """ 769 | access_token = self.dingtalk_client.get_access_token() 770 | if not access_token: 771 | self.logger.error('update_card failed, cannot get dingtalk access token') 772 | return None 773 | 774 | request_headers = { 775 | 'Content-Type': 'application/json', 776 | 'Accept': '*/*', 777 | 'x-acs-dingtalk-access-token': access_token, 778 | 'User-Agent': ('DingTalkStream/1.0 SDK/0.1.0 Python/%s ' 779 | '(+https://github.com/open-dingtalk/dingtalk-stream-sdk-python)' 780 | ) % platform.python_version(), 781 | } 782 | 783 | values = { 784 | 'cardBizId': card_biz_id, 785 | 'cardData': json.dumps(card_data), 786 | } 787 | url = DINGTALK_OPENAPI_ENDPOINT + '/v1.0/im/robots/interactiveCards' 788 | try: 789 | response_text = '' 790 | response = requests.put(url, 791 | headers=request_headers, 792 | data=json.dumps(values)) 793 | response_text = response.text 794 | 795 | response.raise_for_status() 796 | except Exception as e: 797 | self.logger.error(f'update card failed, error={e}, response.text={response_text}') 798 | return response.status_code 799 | return response.json() 800 | 801 | @staticmethod 802 | def _gen_card_id(msg: ChatbotMessage): 803 | factor = '%s_%s_%s_%s_%s' % ( 804 | msg.sender_id, msg.sender_corp_id, msg.conversation_id, msg.message_id, str(uuid.uuid1())) 805 | m = hashlib.sha256() 806 | m.update(factor.encode('utf-8')) 807 | return m.hexdigest() 808 | 809 | 810 | class AsyncChatbotHandler(ChatbotHandler): 811 | """ 812 | 多任务执行handler,注意:process函数重载的时候不要用 async 813 | """ 814 | 815 | async_executor: ThreadPoolExecutor = ThreadPoolExecutor(max_workers=8) 816 | 817 | def __init__(self, max_workers: int = 8): 818 | super(AsyncChatbotHandler, self).__init__() 819 | self.async_executor = ThreadPoolExecutor(max_workers=max_workers) 820 | 821 | def process(self, message): 822 | ''' 823 | 不要用 async 修饰 824 | :param message: 825 | :return: 826 | ''' 827 | return AckMessage.STATUS_NOT_IMPLEMENT, 'not implement' 828 | 829 | async def raw_process(self, callback_message: CallbackMessage): 830 | def func(): 831 | try: 832 | self.process(callback_message) 833 | except Exception as e: 834 | self.logger.error(traceback.format_exc()) 835 | 836 | self.async_executor.submit(func) 837 | 838 | ack_message = AckMessage() 839 | ack_message.code = AckMessage.STATUS_OK 840 | ack_message.headers.message_id = callback_message.headers.message_id 841 | ack_message.headers.content_type = Headers.CONTENT_TYPE_APPLICATION_JSON 842 | ack_message.message = "ok" 843 | ack_message.data = callback_message.data 844 | return ack_message 845 | -------------------------------------------------------------------------------- /dingtalk_stream/credential.py: -------------------------------------------------------------------------------- 1 | class Credential(object): 2 | def __init__(self, client_id, client_secret): 3 | self.client_id = client_id 4 | self.client_secret = client_secret 5 | -------------------------------------------------------------------------------- /dingtalk_stream/frames.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | 4 | class Headers(object): 5 | CONTENT_TYPE_APPLICATION_JSON = 'application/json' 6 | 7 | def __init__(self): 8 | self.app_id = None 9 | self.connection_id = None 10 | self.content_type = None 11 | self.message_id = None 12 | self.time = None 13 | self.topic = None 14 | self.extensions = {} 15 | # event fields start 16 | self.event_born_time = None 17 | self.event_corp_id = None 18 | self.event_id = None 19 | self.event_type = None 20 | self.event_unified_app_id = None 21 | # event fields end 22 | 23 | def __str__(self): 24 | fields = { 25 | 'app_id': self.app_id, 26 | 'connection_id': self.connection_id, 27 | 'content_type': self.content_type, 28 | 'message_id': self.message_id, 29 | 'time': self.time, 30 | 'topic': self.topic, 31 | 'extensions': self.extensions, 32 | } 33 | if self.event_id is not None: 34 | fields['event_born_time'] = self.event_born_time 35 | fields['event_id'] = self.event_id 36 | fields['event_corp_id'] = self.event_corp_id 37 | fields['event_type'] = self.event_type 38 | fields['event_unified_app_id'] = self.event_unified_app_id 39 | return 'Headers(%s)' % ', '.join(['%s=%s' % (k, v) for k, v in fields.items()]) 40 | 41 | def to_dict(self): 42 | result = self.extensions.copy() 43 | if self.app_id is not None: 44 | result['appId'] = self.app_id 45 | if self.connection_id is not None: 46 | result['connectionId'] = self.connection_id 47 | if self.content_type is not None: 48 | result['contentType'] = self.content_type 49 | if self.message_id is not None: 50 | result['messageId'] = self.message_id 51 | if self.topic is not None: 52 | result['topic'] = self.topic 53 | if self.time is not None: 54 | result['time'] = str(self.time) 55 | return result 56 | 57 | @classmethod 58 | def from_dict(cls, d): 59 | headers = Headers() 60 | for name, value in d.items(): 61 | if name == 'appId': 62 | headers.app_id = value 63 | elif name == 'connectionId': 64 | headers.connection_id = value 65 | elif name == 'contentType': 66 | headers.content_type = value 67 | elif name == 'messageId': 68 | headers.message_id = value 69 | elif name == 'topic': 70 | headers.topic = value 71 | elif name == 'time': 72 | headers.time = int(value) 73 | elif name == 'eventBornTime': 74 | headers.event_born_time = int(value) 75 | elif name == 'eventCorpId': 76 | headers.event_corp_id = value 77 | elif name == 'eventId': 78 | headers.event_id = value 79 | elif name == 'eventType': 80 | headers.event_type = value 81 | elif name == 'eventUnifiedAppId': 82 | headers.event_unified_app_id = value 83 | else: 84 | headers.extensions[name] = value 85 | return headers 86 | 87 | 88 | class EventMessage(object): 89 | TYPE = 'EVENT' 90 | 91 | def __init__(self): 92 | self.spec_version = '' 93 | self.type = EventMessage.TYPE 94 | self.headers = Headers() 95 | self.data = {} 96 | self.extensions = {} 97 | 98 | def __str__(self): 99 | return 'EventMessage(spec_version=%s, type=%s, headers=%s, data=%s, extensions=%s)' % ( 100 | self.spec_version, 101 | self.type, 102 | self.headers, 103 | self.data, 104 | self.extensions) 105 | 106 | @classmethod 107 | def from_dict(cls, d): 108 | msg = EventMessage() 109 | data = '' 110 | for name, value in d.items(): 111 | if name == 'specVersion': 112 | msg.spec_version = value 113 | elif name == 'data': 114 | data = value 115 | elif name == 'type': 116 | pass 117 | elif name == 'headers': 118 | msg.headers = Headers.from_dict(value) 119 | else: 120 | msg.extensions[name] = value 121 | if data: 122 | msg.data = json.loads(data) 123 | return msg 124 | 125 | class CallbackMessage(object): 126 | TYPE = 'CALLBACK' 127 | 128 | def __init__(self): 129 | self.spec_version = '' 130 | self.type = CallbackMessage.TYPE 131 | self.headers = Headers() 132 | self.data = {} 133 | self.extensions = {} 134 | 135 | def __str__(self): 136 | return 'CallbackMessage(spec_version=%s, type=%s, headers=%s, data=%s, extensions=%s)' % ( 137 | self.spec_version, 138 | self.type, 139 | self.headers, 140 | self.data, 141 | self.extensions) 142 | 143 | @classmethod 144 | def from_dict(cls, d): 145 | msg = CallbackMessage() 146 | data = '' 147 | for name, value in d.items(): 148 | if name == 'specVersion': 149 | msg.spec_version = value 150 | elif name == 'data': 151 | data = value 152 | elif name == 'type': 153 | pass 154 | elif name == 'headers': 155 | msg.headers = Headers.from_dict(value) 156 | else: 157 | msg.extensions[name] = value 158 | if data: 159 | msg.data = json.loads(data) 160 | return msg 161 | 162 | class SystemMessage(object): 163 | TYPE = 'SYSTEM' 164 | TOPIC_DISCONNECT = 'disconnect' 165 | 166 | def __init__(self): 167 | self.spec_version = '' 168 | self.type = SystemMessage.TYPE 169 | self.headers = Headers() 170 | self.data = {} 171 | self.extensions = {} 172 | 173 | @classmethod 174 | def from_dict(cls, d): 175 | msg = SystemMessage() 176 | data = '' 177 | for name, value in d.items(): 178 | if name == 'specVersion': 179 | msg.spec_version = value 180 | elif name == 'data': 181 | data = value 182 | elif name == 'type': 183 | pass 184 | elif name == 'headers': 185 | msg.headers = Headers.from_dict(value) 186 | else: 187 | msg.extensions[name] = value 188 | if data: 189 | msg.data = json.loads(data) 190 | return msg 191 | 192 | def __str__(self): 193 | return 'SystemMessage(spec_version=%s, type=%s, headers=%s, data=%s, extensions=%s)' % ( 194 | self.spec_version, 195 | self.type, 196 | self.headers, 197 | self.data, 198 | self.extensions, 199 | ) 200 | 201 | @classmethod 202 | def from_dict(cls, d): 203 | msg = SystemMessage() 204 | data = '' 205 | for name, value in d.items(): 206 | if name == 'specVersion': 207 | msg.spec_version = value 208 | elif name == 'data': 209 | data = value 210 | elif name == 'type': 211 | pass 212 | elif name == 'headers': 213 | msg.headers = Headers.from_dict(value) 214 | else: 215 | msg.extensions[name] = value 216 | if data: 217 | msg.data = json.loads(data) 218 | return msg 219 | 220 | 221 | class AckMessage(object): 222 | STATUS_OK = 200 223 | STATUS_BAD_REQUEST = 400 224 | STATUS_NOT_IMPLEMENT = 404 225 | STATUS_SYSTEM_EXCEPTION = 500 226 | 227 | def __init__(self): 228 | self.code = AckMessage.STATUS_OK 229 | self.headers = Headers() 230 | self.message = '' 231 | self.data = {} 232 | 233 | def to_dict(self): 234 | return { 235 | 'code': self.code, 236 | 'headers': self.headers.to_dict(), 237 | 'message': self.message, 238 | 'data': json.dumps(self.data), 239 | } 240 | -------------------------------------------------------------------------------- /dingtalk_stream/graph.py: -------------------------------------------------------------------------------- 1 | # -*- coding:utf-8 -*- 2 | 3 | import json 4 | from .stream import CallbackHandler, CallbackMessage 5 | from .utils import http_post_json 6 | 7 | class GraphMessage(object): 8 | TOPIC = '/v1.0/graph/api/invoke' 9 | 10 | class RequestLine(object): 11 | def __init__(self): 12 | self.method = 'GET' 13 | self.uri = '/' 14 | self.extensions = {} 15 | 16 | @classmethod 17 | def from_dict(cls, d): 18 | msg = RequestLine() 19 | for name, value in d.items(): 20 | if name == 'method': 21 | msg.method = value 22 | elif name == 'uri': 23 | msg.uri = value 24 | else: 25 | msg.extensions[name] = value 26 | return msg 27 | 28 | def to_dict(self): 29 | result = self.extensions.copy() 30 | if self.method is not None: 31 | result['method'] = self.method 32 | if self.uri is not None: 33 | result['uri'] = self.uri 34 | return result 35 | 36 | class StatusLine(object): 37 | def __init__(self): 38 | self.code = 200 39 | self.reason_phrase = 'OK' 40 | self.extensions = {} 41 | 42 | @classmethod 43 | def from_dict(cls, d): 44 | msg = RequestLine() 45 | for name, value in d.items(): 46 | if name == 'code': 47 | msg.code = value 48 | elif name == 'reasonPhrase': 49 | msg.reason_phrase = value 50 | else: 51 | msg.extensions[name] = value 52 | return msg 53 | 54 | def to_dict(self): 55 | result = self.extensions.copy() 56 | if self.code is not None: 57 | result['code'] = self.code 58 | if self.reason_phrase is not None: 59 | result['reasonPhrase'] = self.reason_phrase 60 | return result 61 | 62 | class GraphRequest(object): 63 | def __init__(self): 64 | self.body = None 65 | self.request_line = RequestLine() 66 | self.headers = {} 67 | self.extensions = {} 68 | 69 | @classmethod 70 | def from_dict(cls, d): 71 | msg = GraphRequest() 72 | for name, value in d.items(): 73 | if name == 'body': 74 | msg.body = value 75 | elif name == 'headers': 76 | msg.headers = value 77 | elif name == 'requestLine': 78 | msg.request_line = RequestLine.from_dict(value) 79 | else: 80 | msg.extensions[name] = value 81 | return msg 82 | 83 | def to_dict(self): 84 | result = self.extensions.copy() 85 | if self.body is not None: 86 | result['body'] = self.body 87 | if self.headers is not None: 88 | result['headers'] = self.headers 89 | if self.request_line is not None: 90 | result['requestLine'] = self.request_line.to_dict() 91 | return result 92 | 93 | class GraphResponse(object): 94 | def __init__(self): 95 | self.body = None 96 | self.headers = {} 97 | self.status_line = StatusLine() 98 | self.extensions = {} 99 | 100 | @classmethod 101 | def from_dict(cls, d): 102 | msg = GraphResponse() 103 | for name, value in d.items(): 104 | if name == 'body': 105 | msg.body = value 106 | elif name == 'headers': 107 | msg.headers = value 108 | elif name == 'statusLine': 109 | msg.status_line = StatusLine.from_dict(value) 110 | else: 111 | msg.extensions[name] = value 112 | return msg 113 | 114 | def to_dict(self): 115 | result = self.extensions.copy() 116 | if self.body is not None: 117 | result['body'] = self.body 118 | if self.headers is not None: 119 | result['headers'] = self.headers 120 | if self.status_line is not None: 121 | result['statusLine'] = self.status_line.to_dict() 122 | return result 123 | 124 | 125 | class GraphHandler(CallbackHandler): 126 | MARKDOWN_TEMPLATE_ID = 'd28e2ac5-fb34-4d93-94bc-cf5c580c2d4f.schema' 127 | def __init__(self): 128 | super(GraphHandler, self).__init__() 129 | 130 | async def reply_markdown(self, webhook, content): 131 | payload = { 132 | 'contentType': 'ai_card', 133 | 'content': { 134 | 'templateId': self.MARKDOWN_TEMPLATE_ID, 135 | 'cardData': { 136 | 'content': content, 137 | } 138 | } 139 | } 140 | return await http_post_json(webhook, payload) 141 | 142 | def get_success_response(self, payload=None): 143 | if payload is None: 144 | payload = dict() 145 | response = GraphResponse() 146 | response.status_line.code = 200 147 | response.status_line.reason_phrase = 'OK' 148 | response.headers['Content-Type'] = 'application/json' 149 | response.body = json.dumps(payload, ensure_ascii=False) 150 | return response 151 | 152 | -------------------------------------------------------------------------------- /dingtalk_stream/handlers.py: -------------------------------------------------------------------------------- 1 | from .frames import Headers 2 | from .frames import AckMessage 3 | from .frames import SystemMessage 4 | from .frames import EventMessage 5 | from .frames import CallbackMessage 6 | from .log import setup_default_logger 7 | 8 | from typing import TYPE_CHECKING 9 | 10 | if TYPE_CHECKING: 11 | from .stream import DingTalkStreamClient 12 | 13 | class CallbackHandler(object): 14 | TOPIC_CARD_CALLBACK = '/v1.0/card/instances/callback' 15 | 16 | def __init__(self): 17 | self.dingtalk_client: 'DingTalkStreamClient' = None 18 | self.logger = setup_default_logger('dingtalk_stream.handler') 19 | 20 | def pre_start(self): 21 | return 22 | 23 | async def process(self, message: CallbackMessage): 24 | return AckMessage.STATUS_NOT_IMPLEMENT, 'not implement' 25 | 26 | async def raw_process(self, callback_message: CallbackMessage): 27 | code, message = await self.process(callback_message) 28 | ack_message = AckMessage() 29 | ack_message.code = code 30 | ack_message.headers.message_id = callback_message.headers.message_id 31 | ack_message.headers.content_type = Headers.CONTENT_TYPE_APPLICATION_JSON 32 | ack_message.data = {"response": message} 33 | return ack_message 34 | 35 | 36 | class EventHandler(object): 37 | def __init__(self): 38 | self.dingtalk_client: 'DingTalkStreamClient' = None 39 | self.logger = setup_default_logger('dingtalk_stream.handler') 40 | 41 | def pre_start(self): 42 | return 43 | 44 | async def process(self, event: EventMessage): 45 | return AckMessage.STATUS_NOT_IMPLEMENT, 'not implement' 46 | 47 | async def raw_process(self, event_message: EventMessage): 48 | code, message = await self.process(event_message) 49 | ack_message = AckMessage() 50 | ack_message.code = code 51 | ack_message.headers.message_id = event_message.headers.message_id 52 | ack_message.headers.content_type = Headers.CONTENT_TYPE_APPLICATION_JSON 53 | ack_message.message = message 54 | ack_message.data = event_message.data 55 | return ack_message 56 | 57 | 58 | class SystemHandler(object): 59 | def __init__(self): 60 | self.dingtalk_client: 'DingTalkStreamClient' = None 61 | self.logger = setup_default_logger('dingtalk_stream.handler') 62 | 63 | def pre_start(self): 64 | return 65 | 66 | async def process(self, message: SystemMessage): 67 | return AckMessage.STATUS_OK, 'OK' 68 | 69 | async def raw_process(self, system_message: SystemMessage): 70 | code, message = await self.process(system_message) 71 | ack_message = AckMessage() 72 | ack_message.code = code 73 | ack_message.headers.message_id = system_message.headers.message_id 74 | ack_message.headers.content_type = Headers.CONTENT_TYPE_APPLICATION_JSON 75 | ack_message.message = message 76 | ack_message.data = system_message.data 77 | return ack_message 78 | -------------------------------------------------------------------------------- /dingtalk_stream/interactive_card.py: -------------------------------------------------------------------------------- 1 | # -*- coding:utf-8 -*- 2 | 3 | import copy, uuid 4 | 5 | """ 6 | 这里是卡片模板库,提供一些必要的卡片组件组合。 7 | INTERACTIVE_CARD_JSON_SAMPLE_1 极简卡片组合:title-text-image-button 8 | INTERACTIVE_CARD_JSON_SAMPLE_2 较丰富的组件卡片,title-text-image-section-button 9 | INTERACTIVE_CARD_JSON_SAMPLE_3 较丰富的组件卡片,title-image-markdown-button 10 | 高阶需求请至卡片搭建平台:https://card.dingtalk.com/card-builder 11 | """ 12 | 13 | 14 | ''' 15 | 实用卡片模板:多行文本 16 | ''' 17 | INTERACTIVE_CARD_JSON_SAMPLE_MULTI_TEXT_LINE = { 18 | "config": { 19 | "autoLayout": True, 20 | "enableForward": True 21 | }, 22 | "header": { 23 | "title": { 24 | "type": "text", 25 | "text": "钉钉卡片" 26 | }, 27 | "logo": "@lALPDfJ6V_FPDmvNAfTNAfQ" 28 | }, 29 | "contents": [ 30 | { 31 | "type": "markdown", 32 | "text": "钉钉正在为各行各业提供专业解决方案,沉淀钉钉1900万企业组织核心业务场景,提供专属钉钉、教育、医疗、新零售等多行业多维度的解决方案。", 33 | "id": "markdown_1686281949314" 34 | }, 35 | { 36 | "type": "divider", 37 | "id": "divider_1686281949314" 38 | } 39 | ] 40 | } 41 | 42 | 43 | def generate_multi_text_line_card_data(title: str, logo: str, texts: [str]) -> dict: 44 | card_data = copy.deepcopy(INTERACTIVE_CARD_JSON_SAMPLE_MULTI_TEXT_LINE) 45 | 46 | if title != "": 47 | card_data["header"]["title"]["text"] = title 48 | 49 | if logo != "": 50 | card_data["header"]["logo"] = logo 51 | 52 | card_data["contents"] = [] 53 | for text in texts: 54 | text_line = { 55 | "type": "markdown", 56 | "text": text, 57 | "id": "text_" + str(uuid.uuid1()) 58 | } 59 | divider_line = { 60 | "type": "divider", 61 | "id": "divider_" + str(uuid.uuid1()) 62 | } 63 | card_data["contents"].append(text_line) 64 | card_data["contents"].append(divider_line) 65 | 66 | return card_data 67 | 68 | 69 | ''' 70 | 实用卡片模板,多行文本+多图组合 71 | ''' 72 | INTERACTIVE_CARD_JSON_SAMPLE_MULTI_TEXT_IMAGE = { 73 | "config": { 74 | "autoLayout": True, 75 | "enableForward": True 76 | }, 77 | "header": { 78 | "title": { 79 | "type": "text", 80 | "text": "钉钉卡片" 81 | }, 82 | "logo": "@lALPDfJ6V_FPDmvNAfTNAfQ" 83 | }, 84 | "contents": [ 85 | { 86 | "type": "markdown", 87 | "text": "钉钉正在为各行各业提供专业解决方案,沉淀钉钉1900万企业组织核心业务场景,提供专属钉钉、教育、医疗、新零售等多行业多维度的解决方案。", 88 | "id": "markdown_1686281949314" 89 | }, 90 | { 91 | "type": "divider", 92 | "id": "divider_1686281949314" 93 | }, 94 | { 95 | "type": "imageList", 96 | "images": [ 97 | "@lADPDe7s2ySi18PNA6XNBXg", 98 | "@lADPDf0i1beuNF3NAxTNBXg", 99 | "@lADPDe7s2ySRnIvNA6fNBXg" 100 | ], 101 | "id": "imageList_1686283179480" 102 | } 103 | ] 104 | } 105 | 106 | 107 | def generate_multi_text_image_card_data(title: str, logo: str, texts: [str], images: [str]) -> dict: 108 | card_data = copy.deepcopy(INTERACTIVE_CARD_JSON_SAMPLE_MULTI_TEXT_IMAGE) 109 | 110 | if title != "": 111 | card_data["header"]["title"]["text"] = title 112 | 113 | if logo != "": 114 | card_data["header"]["logo"] = logo 115 | 116 | card_data["contents"] = [] 117 | for text in texts: 118 | text_line = { 119 | "type": "markdown", 120 | "text": text, 121 | "id": "text_" + str(uuid.uuid1()) 122 | } 123 | divider_line = { 124 | "type": "divider", 125 | "id": "divider_" + str(uuid.uuid1()) 126 | } 127 | card_data["contents"].append(text_line) 128 | card_data["contents"].append(divider_line) 129 | 130 | image_list = { 131 | "type": "imageList", 132 | "images": images, 133 | "id": "imageList_" + str(uuid.uuid1()) 134 | } 135 | card_data["contents"].append(image_list) 136 | 137 | return card_data 138 | 139 | 140 | ''' 141 | 极简卡片组合:title-text-image-button 142 | ''' 143 | INTERACTIVE_CARD_JSON_SAMPLE_1 = { 144 | "config": { 145 | "autoLayout": True, 146 | "enableForward": True 147 | }, 148 | "header": { 149 | "title": { 150 | "type": "text", 151 | "text": "钉钉卡片" 152 | }, 153 | "logo": "@lALPDfJ6V_FPDmvNAfTNAfQ" 154 | }, 155 | "contents": [ 156 | { 157 | "type": "markdown", 158 | "text": "钉钉,让进步发生!\n 更新时间:2023-06-06 12:00", 159 | "id": "text_1686025745169" 160 | }, 161 | { 162 | "type": "image", 163 | "image": "@lADPDetfXH_Pn3HNAbrNBDg", 164 | "id": "image_1686025745169" 165 | }, 166 | { 167 | "type": "action", 168 | "actions": [ 169 | { 170 | "type": "button", 171 | "label": { 172 | "type": "text", 173 | "text": "打开链接", 174 | "id": "text_1686025745289" 175 | }, 176 | "actionType": "openLink", 177 | "url": { 178 | "all": "https://www.dingtalk.com" 179 | }, 180 | "status": "primary", 181 | "id": "button_1646816888247" 182 | }, 183 | { 184 | "type": "button", 185 | "label": { 186 | "type": "text", 187 | "text": "回传请求", 188 | "id": "text_1686025745208" 189 | }, 190 | "actionType": "request", 191 | "status": "primary", 192 | "id": "button_1646816888257" 193 | } 194 | ], 195 | "id": "action_1686025745169" 196 | } 197 | ] 198 | } 199 | 200 | ''' 201 | 较丰富的组件卡片,title-text-image-section-button 202 | ''' 203 | INTERACTIVE_CARD_JSON_SAMPLE_2 = { 204 | "config": { 205 | "autoLayout": True, 206 | "enableForward": True 207 | }, 208 | "header": { 209 | "title": { 210 | "type": "text", 211 | "text": "钉钉卡片" 212 | }, 213 | "logo": "@lALPDfJ6V_FPDmvNAfTNAfQ" 214 | }, 215 | "contents": [ 216 | { 217 | "type": "markdown", 218 | "text": "钉钉正在为各行各业提供专业解决方案,沉淀钉钉1900万企业组织核心业务场景,提供专属钉钉、教育、医疗、新零售等多行业多维度的解决方案。", 219 | "id": "text_1686025745169" 220 | }, 221 | { 222 | "type": "image", 223 | "image": "@lADPDetfXH_Pn3HNAbrNBDg", 224 | "id": "image_1686025745169" 225 | }, 226 | { 227 | "type": "divider", 228 | "id": "divider_1686025745169" 229 | }, 230 | { 231 | "type": "section", 232 | "fields": { 233 | "list": [ 234 | { 235 | "type": "text", 236 | "text": "钉钉发起“C10圆桌派”,旨在邀请各行各业的CIO、CTO等,面对面深入交流数字化建设心得,总结行业…", 237 | "id": "text_1686025745205" 238 | }, 239 | { 240 | "type": "text", 241 | "text": "在后疫情时期,数字化跃升为时代命题之一,混合办公及云上创新逐渐普及,数字化已成为企业发展的必答…", 242 | "id": "text_1686025745174" 243 | } 244 | ] 245 | }, 246 | "extra": { 247 | "type": "button", 248 | "label": { 249 | "type": "text", 250 | "text": "查看详情", 251 | "id": "text_1686025745191" 252 | }, 253 | "actionType": "openLink", 254 | "url": { 255 | "all": "https://alidocs.dingtalk.com/i/p/nb9XJlvOKbAyDGyA/docs/nb9XJo9ogo27lmyA?spm=a217n7.14136887.0.0.499d573fCVWe7p" 256 | }, 257 | "status": "primary", 258 | "id": "button_1646816886531" 259 | }, 260 | "id": "section_1686025745169" 261 | }, 262 | { 263 | "type": "action", 264 | "actions": [ 265 | { 266 | "type": "button", 267 | "label": { 268 | "type": "text", 269 | "text": "打开链接", 270 | "id": "text_1686025745289" 271 | }, 272 | "actionType": "openLink", 273 | "url": { 274 | "all": "https://www.dingtalk.com" 275 | }, 276 | "status": "primary", 277 | "id": "button_1646816888247" 278 | }, 279 | { 280 | "type": "button", 281 | "label": { 282 | "type": "text", 283 | "text": "回传请求", 284 | "id": "text_1686025745208" 285 | }, 286 | "actionType": "request", 287 | "status": "primary", 288 | "id": "button_1646816888257" 289 | }, 290 | { 291 | "type": "button", 292 | "label": { 293 | "type": "text", 294 | "text": "次级按钮", 295 | "id": "text_1686025745206" 296 | }, 297 | "actionType": "openLink", 298 | "url": { 299 | "all": "https://www.dingtalk.com" 300 | }, 301 | "status": "normal", 302 | "id": "button_1646816888277" 303 | }, 304 | { 305 | "type": "button", 306 | "label": { 307 | "type": "text", 308 | "text": "警示按钮", 309 | "id": "text_1686025745195" 310 | }, 311 | "actionType": "openLink", 312 | "url": { 313 | "all": "https://www.dingtalk.com" 314 | }, 315 | "status": "warning", 316 | "id": "button_1646816888287" 317 | } 318 | ], 319 | "id": "action_1686025745169" 320 | } 321 | ] 322 | } 323 | 324 | ''' 325 | 较丰富的组件卡片,title-image-markdown-button 326 | ''' 327 | INTERACTIVE_CARD_JSON_SAMPLE_3 = { 328 | "config": { 329 | "autoLayout": True, 330 | "enableForward": True 331 | }, 332 | "header": { 333 | "title": { 334 | "type": "text", 335 | "text": "钉钉小技巧" 336 | }, 337 | "logo": "@lALPDefR3hjhflFAQA" 338 | }, 339 | "contents": [ 340 | { 341 | "type": "image", 342 | "image": "@lALPDsCJC34CVxzNAYTNArA", 343 | "id": "image_1686034081551" 344 | }, 345 | { 346 | "type": "markdown", 347 | "text": "🎉 **四招教你玩转钉钉项目**", 348 | "id": "markdown_1686034081551" 349 | }, 350 | { 351 | "type": "markdown", 352 | "text": "一、创建项目群,重要事项放项目", 353 | "id": "markdown_1686034081584" 354 | }, 355 | { 356 | "type": "markdown", 357 | "text": "😭 群内信息太碎片?任务交办难跟踪?协作边界很模糊?\n👉 试试创建项目群,把重要事项放在项目内跟踪,可以事半功倍!", 358 | "id": "markdown_1686034081625" 359 | }, 360 | { 361 | "type": "markdown", 362 | "text": "更多精彩内容请查看详情…", 363 | "id": "markdown_1686034081660" 364 | }, 365 | { 366 | "type": "action", 367 | "actions": [ 368 | { 369 | "type": "button", 370 | "label": { 371 | "type": "text", 372 | "text": "查看详情", 373 | "id": "text_1686034081551" 374 | }, 375 | "actionType": "openLink", 376 | "url": { 377 | "all": "https://www.dingtalk.com" 378 | }, 379 | "status": "normal", 380 | "id": "button_1647166782413" 381 | } 382 | ], 383 | "id": "action_1686034081551" 384 | } 385 | ] 386 | } 387 | -------------------------------------------------------------------------------- /dingtalk_stream/log.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | 4 | def setup_default_logger(name: str = None): 5 | logger = logging.getLogger(name) 6 | if not logger.hasHandlers(): 7 | handler = logging.StreamHandler() 8 | handler.setFormatter( 9 | logging.Formatter('%(asctime)s %(name)-8s %(levelname)-8s %(message)s [%(filename)s:%(lineno)d]')) 10 | logger.addHandler(handler) 11 | logger.setLevel(logging.INFO) 12 | return logger 13 | -------------------------------------------------------------------------------- /dingtalk_stream/stream.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import asyncio 4 | import asyncio.exceptions 5 | import json 6 | import logging 7 | import platform 8 | import time 9 | import requests 10 | import socket 11 | import websockets 12 | 13 | from urllib.parse import quote_plus 14 | 15 | from .credential import Credential 16 | from .handlers import CallbackHandler 17 | from .handlers import EventHandler 18 | from .handlers import SystemHandler 19 | from .frames import SystemMessage 20 | from .frames import EventMessage 21 | from .frames import CallbackMessage 22 | from .log import setup_default_logger 23 | from .utils import DINGTALK_OPENAPI_ENDPOINT 24 | from .version import VERSION_STRING 25 | 26 | 27 | class DingTalkStreamClient(object): 28 | OPEN_CONNECTION_API = DINGTALK_OPENAPI_ENDPOINT + '/v1.0/gateway/connections/open' 29 | TAG_DISCONNECT = 'disconnect' 30 | 31 | def __init__(self, credential: Credential, logger: logging.Logger = None): 32 | self.credential: Credential = credential 33 | self.event_handler: EventHandler = EventHandler() 34 | self.callback_handler_map = {} 35 | self.system_handler: SystemHandler = SystemHandler() 36 | self.websocket = None # create websocket client after connected 37 | self.logger: logging.Logger = logger if logger else setup_default_logger('dingtalk_stream.client') 38 | self._pre_started = False 39 | self._is_event_required = False 40 | self._access_token = {} 41 | 42 | def register_all_event_handler(self, handler: EventHandler): 43 | handler.dingtalk_client = self 44 | self.event_handler = handler 45 | self._is_event_required = True 46 | 47 | def register_callback_handler(self, topic, handler: CallbackHandler): 48 | handler.dingtalk_client = self 49 | self.callback_handler_map[topic] = handler 50 | 51 | def pre_start(self): 52 | if self._pre_started: 53 | return 54 | self._pre_started = True 55 | self.event_handler.pre_start() 56 | self.system_handler.pre_start() 57 | for handler in self.callback_handler_map.values(): 58 | handler.pre_start() 59 | 60 | async def start(self): 61 | self.pre_start() 62 | 63 | while True: 64 | try: 65 | connection = self.open_connection() 66 | 67 | if not connection: 68 | self.logger.error('open connection failed') 69 | await asyncio.sleep(10) 70 | continue 71 | self.logger.info('endpoint is %s', connection) 72 | 73 | uri = f'{connection["endpoint"]}?ticket={quote_plus(connection["ticket"])}' 74 | async with websockets.connect(uri) as websocket: 75 | self.websocket = websocket 76 | asyncio.create_task(self.keepalive(websocket)) 77 | async for raw_message in websocket: 78 | json_message = json.loads(raw_message) 79 | asyncio.create_task(self.background_task(json_message)) 80 | except KeyboardInterrupt as e: 81 | break 82 | except (asyncio.exceptions.CancelledError, 83 | websockets.exceptions.ConnectionClosedError) as e: 84 | self.logger.error('[start] network exception, error=%s', e) 85 | await asyncio.sleep(10) 86 | continue 87 | except Exception as e: 88 | await asyncio.sleep(3) 89 | self.logger.exception('unknown exception', e) 90 | continue 91 | finally: 92 | pass 93 | 94 | async def keepalive(self, ws, ping_interval=60): 95 | while True: 96 | await asyncio.sleep(ping_interval) 97 | try: 98 | await ws.ping() 99 | except websockets.exceptions.ConnectionClosed: 100 | break 101 | 102 | async def background_task(self, json_message): 103 | try: 104 | route_result = await self.route_message(json_message) 105 | if route_result == DingTalkStreamClient.TAG_DISCONNECT: 106 | await self.websocket.close() 107 | except Exception as e: 108 | self.logger.error(f"error processing message: {e}") 109 | 110 | async def route_message(self, json_message): 111 | result = '' 112 | msg_type = json_message.get('type', '') 113 | ack = None 114 | if msg_type == SystemMessage.TYPE: 115 | msg = SystemMessage.from_dict(json_message) 116 | ack = await self.system_handler.raw_process(msg) 117 | if msg.headers.topic == SystemMessage.TOPIC_DISCONNECT: 118 | result = DingTalkStreamClient.TAG_DISCONNECT 119 | self.logger.info("received disconnect topic=%s, message=%s", msg.headers.topic, json_message) 120 | else: 121 | self.logger.warning("unknown message topic, topic=%s, message=%s", msg.headers.topic, json_message) 122 | elif msg_type == EventMessage.TYPE: 123 | msg = EventMessage.from_dict(json_message) 124 | ack = await self.event_handler.raw_process(msg) 125 | elif msg_type == CallbackMessage.TYPE: 126 | msg = CallbackMessage.from_dict(json_message) 127 | handler = self.callback_handler_map.get(msg.headers.topic) 128 | if handler: 129 | ack = await handler.raw_process(msg) 130 | else: 131 | self.logger.warning("unknown callback message topic, topic=%s, message=%s", msg.headers.topic, 132 | json_message) 133 | else: 134 | self.logger.warning('unknown message, content=%s', json_message) 135 | if ack: 136 | await self.websocket.send(json.dumps(ack.to_dict())) 137 | return result 138 | 139 | def start_forever(self): 140 | while True: 141 | try: 142 | asyncio.run(self.start()) 143 | except KeyboardInterrupt as e: 144 | break 145 | finally: 146 | time.sleep(3) 147 | 148 | def open_connection(self): 149 | self.logger.info('open connection, url=%s' % DingTalkStreamClient.OPEN_CONNECTION_API) 150 | request_headers = { 151 | 'Content-Type': 'application/json', 152 | 'Accept': 'application/json', 153 | 'User-Agent': ('DingTalkStream/1.0 SDK/%s Python/%s ' 154 | '(+https://github.com/open-dingtalk/dingtalk-stream-sdk-python)' 155 | ) % (VERSION_STRING, platform.python_version()), 156 | } 157 | topics = [] 158 | if self._is_event_required: 159 | topics.append({'type': 'EVENT', 'topic': '*'}) 160 | for topic in self.callback_handler_map.keys(): 161 | topics.append({'type': 'CALLBACK', 'topic': topic}) 162 | request_body = json.dumps({ 163 | 'clientId': self.credential.client_id, 164 | 'clientSecret': self.credential.client_secret, 165 | 'subscriptions': topics, 166 | 'ua': 'dingtalk-sdk-python/v%s' % VERSION_STRING, 167 | 'localIp': self.get_host_ip() 168 | }).encode('utf-8') 169 | 170 | try: 171 | response_text = '' 172 | response = requests.post(DingTalkStreamClient.OPEN_CONNECTION_API, 173 | headers=request_headers, 174 | data=request_body) 175 | response_text = response.text 176 | 177 | response.raise_for_status() 178 | except Exception as e: 179 | self.logger.error(f'open connection failed, error={e}, response.text={response_text}') 180 | return None 181 | return response.json() 182 | 183 | def get_host_ip(self): 184 | """ 185 | 查询本机ip地址 186 | :return: ip 187 | """ 188 | ip = "" 189 | try: 190 | s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 191 | s.connect(('8.8.8.8', 80)) 192 | ip = s.getsockname()[0] 193 | finally: 194 | s.close() 195 | return ip 196 | 197 | def reset_access_token(self): 198 | """ reset token if open api return 401 """ 199 | self._access_token = {} 200 | 201 | def get_access_token(self): 202 | now = int(time.time()) 203 | if self._access_token and now < self._access_token['expireTime']: 204 | return self._access_token['accessToken'] 205 | 206 | request_headers = { 207 | 'Content-Type': 'application/json', 208 | 'Accept': 'application/json', 209 | } 210 | values = { 211 | 'appKey': self.credential.client_id, 212 | 'appSecret': self.credential.client_secret, 213 | } 214 | try: 215 | url = DINGTALK_OPENAPI_ENDPOINT + '/v1.0/oauth2/accessToken' 216 | response_text = '' 217 | response = requests.post(url, 218 | headers=request_headers, 219 | data=json.dumps(values)) 220 | response_text = response.text 221 | 222 | response.raise_for_status() 223 | except Exception as e: 224 | self.logger.error(f'get dingtalk access token failed, error={e}, response.text={response_text}') 225 | return None 226 | 227 | result = response.json() 228 | result['expireTime'] = int(time.time()) + result['expireIn'] - (5 * 60) # reserve 5min buffer time 229 | self._access_token = result 230 | return self._access_token['accessToken'] 231 | 232 | def upload_to_dingtalk(self, image_content, filetype='image', filename='image.png', mimetype='image/png'): 233 | access_token = self.get_access_token() 234 | if not access_token: 235 | self.logger.error('upload_to_dingtalk failed, cannot get dingtalk access token') 236 | return None 237 | files = { 238 | 'media': (filename, image_content, mimetype), 239 | } 240 | values = { 241 | 'type': filetype, 242 | } 243 | upload_url = f'https://oapi.dingtalk.com/media/upload?access_token={quote_plus(access_token)}' 244 | try: 245 | response_text = '' 246 | response = requests.post(upload_url, data=values, files=files) 247 | response_text = response.text 248 | if response.status_code == 401: 249 | self.reset_access_token() 250 | 251 | response.raise_for_status() 252 | except Exception as e: 253 | self.logger.error(f'upload to dingtalk failed, error={e}, response.text={response_text}') 254 | return None 255 | if 'media_id' not in response.json(): 256 | self.logger.error('upload to dingtalk failed, error response is %s', response.json()) 257 | raise Exception('upload failed, error=%s' % response.json()) 258 | return response.json()['media_id'] 259 | -------------------------------------------------------------------------------- /dingtalk_stream/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding:utf-8 -*- 2 | 3 | import os 4 | 5 | import http.client 6 | import json 7 | 8 | DINGTALK_OPENAPI_ENDPOINT = os.getenv( 9 | "DINGTALK_OPENAPI_ENDPOINT", "https://api.dingtalk.com" 10 | ) 11 | 12 | async def http_post_json(url, data): 13 | """异步发送 HTTP POST 请求,携带 JSON 数据""" 14 | # 解析 URL 15 | is_https = True 16 | if url.startswith("http://"): 17 | is_https = False 18 | url = url[7:] # 去掉 "http://" 19 | if url.startswith("https://"): url = url[8:] # 去掉 "https://" 20 | host, _, path = url.partition('/') 21 | path = '/' + path # 确保路径以 '/' 开头 22 | 23 | # 将数据转换为 JSON 字符串 24 | json_data = json.dumps(data) 25 | headers = { 26 | "Content-Type": "application/json", 27 | "Content-Length": str(len(json_data)) 28 | } 29 | # 创建 HTTP 连接 30 | conn = http.client.HTTPSConnection(host) if is_https else http.client.HTTPConnection(host) 31 | # 发送 POST 请求 32 | conn.request("POST", path, body=json_data, headers=headers) 33 | # 获取响应 34 | response = conn.getresponse() 35 | response_data = response.read().decode('utf-8') 36 | # 关闭连接 37 | conn.close() 38 | return response.status, response_data -------------------------------------------------------------------------------- /dingtalk_stream/version.py: -------------------------------------------------------------------------------- 1 | VERSION_STRING = '0.24.2' 2 | -------------------------------------------------------------------------------- /examples/agent/hello.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import json 5 | import asyncio 6 | import dingtalk_stream 7 | 8 | def define_options(): 9 | import argparse 10 | parser = argparse.ArgumentParser() 11 | parser.add_argument( 12 | '--client_id', dest='client_id', required=True, 13 | help='app_key or suite_key from https://open-dev.digntalk.com' 14 | ) 15 | parser.add_argument( 16 | '--client_secret', dest='client_secret', required=True, 17 | help='app_secret or suite_secret from https://open-dev.digntalk.com' 18 | ) 19 | options = parser.parse_args() 20 | return options 21 | 22 | 23 | class HelloHandler(dingtalk_stream.GraphHandler): 24 | async def process(self, callback: dingtalk_stream.CallbackMessage): 25 | request = dingtalk_stream.GraphRequest.from_dict(callback.data) 26 | body = json.loads(request.body) 27 | await self.reply_markdown(body['sessionWebhook'], '- 天气:晴\n- temperature: 22') 28 | return dingtalk_stream.AckMessage.STATUS_OK, self.get_success_response({'success': True}).to_dict() 29 | 30 | async def hello(): 31 | options = define_options() 32 | credential = dingtalk_stream.Credential(options.client_id, options.client_secret) 33 | client = dingtalk_stream.DingTalkStreamClient(credential) 34 | client.register_callback_handler(dingtalk_stream.graph.GraphMessage.TOPIC, HelloHandler()) 35 | await client.start() 36 | 37 | if __name__ == '__main__': 38 | asyncio.run(hello()) -------------------------------------------------------------------------------- /examples/agent/stream.yaml: -------------------------------------------------------------------------------- 1 | ## 直通模式可以将AI助理的上下文直接传递给开发者的Action,不再经过AI助理的大模型,可参考文档(https://opensource.dingtalk.com/developerpedia/docs/explore/tutorials/assistant_ability/passthrough_mode/java/intro) 2 | openapi: 3.0.1 3 | info: 4 | title: 天气查询 5 | description: 按地区和日期来查看天气信息,了解气温、湿度、风向等信息。非真实天气数据,仅用于演示,请勿在生产中使用。 6 | version: v1.0.0 7 | ## 推荐使用 钉钉 Stream 协议,无需提供公网域名(https://open.dingtalk.com/document/ai-dev/actions-advanced-settings#dc65a46ae9nis) 8 | x-dingtalk-protocol: stream 9 | paths: 10 | /v1/actions/example/weather/get: 11 | post: 12 | description: 查询特定地区的天气信息 13 | summary: 查看天气 14 | operationId: GetCurrentWeather 15 | x-dingtalk-params-confirm: false 16 | x-dingtalk-display-result: disabled 17 | requestBody: 18 | content: 19 | application/json: 20 | schema: 21 | $ref: '#/components/schemas/GraphRequest' 22 | responses: 23 | '200': 24 | description: OK 25 | content: 26 | application/json: 27 | schema: 28 | $ref: '#/components/schemas/GraphResponse' 29 | components: 30 | schemas: 31 | GraphRequest: 32 | type: object 33 | properties: 34 | day: 35 | type: string 36 | description: 日期,例如今天、明天、下周三、下下周三等 37 | name: 38 | type: string 39 | description: 地区,例如杭州、北京、上海、纽约等 40 | userId: 41 | type: string 42 | description: 操作人的 User ID 43 | x-dingtalk-context: 44 | property: currentUser 45 | format: userId 46 | unionId: 47 | type: string 48 | description: 操作人的 Union ID 49 | x-dingtalk-context: 50 | property: currentUser 51 | format: unionId 52 | jobNum: 53 | type: string 54 | description: jobNum 55 | x-dingtalk-context: 56 | property: currentUser 57 | format: jobNum 58 | corpId: 59 | type: string 60 | description: corpId 61 | x-dingtalk-context: 62 | property: currentOrg 63 | format: corpId 64 | rawInput: 65 | type: string 66 | description: rawInput 67 | x-dingtalk-context: 68 | property: currentInput 69 | format: raw 70 | inputAttribute: 71 | type: string 72 | description: inputAttribute 73 | x-dingtalk-context: 74 | property: currentInput 75 | format: attribute 76 | openConversationId: 77 | type: string 78 | description: openConversationId 79 | x-dingtalk-context: 80 | property: currentConversation 81 | format: openConversationId 82 | conversationToken: 83 | type: string 84 | description: conversationToken 85 | x-dingtalk-context: 86 | property: currentConversation 87 | format: conversationToken 88 | sessionWebhook: 89 | type: string 90 | description: sessionWebhook 91 | x-dingtalk-context: 92 | property: currentConversation 93 | format: sessionWebhook 94 | GraphResponse: 95 | type: object -------------------------------------------------------------------------------- /examples/calcbot/calcbot.py: -------------------------------------------------------------------------------- 1 | # !/usr/bin/env python 2 | 3 | import argparse 4 | import logging 5 | from dingtalk_stream import AckMessage 6 | import dingtalk_stream 7 | 8 | def setup_logger(): 9 | logger = logging.getLogger() 10 | handler = logging.StreamHandler() 11 | handler.setFormatter( 12 | logging.Formatter('%(asctime)s %(name)-8s %(levelname)-8s %(message)s [%(filename)s:%(lineno)d]')) 13 | logger.addHandler(handler) 14 | logger.setLevel(logging.INFO) 15 | return logger 16 | 17 | 18 | def define_options(): 19 | parser = argparse.ArgumentParser() 20 | parser.add_argument( 21 | '--client_id', dest='client_id', required=True, 22 | help='app_key or suite_key from https://open-dev.digntalk.com' 23 | ) 24 | parser.add_argument( 25 | '--client_secret', dest='client_secret', required=True, 26 | help='app_secret or suite_secret from https://open-dev.digntalk.com' 27 | ) 28 | options = parser.parse_args() 29 | return options 30 | 31 | 32 | class CalcBotHandler(dingtalk_stream.ChatbotHandler): 33 | def __init__(self, logger: logging.Logger = None): 34 | super(dingtalk_stream.ChatbotHandler, self).__init__() 35 | if logger: 36 | self.logger = logger 37 | 38 | async def process(self, callback: dingtalk_stream.CallbackMessage): 39 | incoming_message = dingtalk_stream.ChatbotMessage.from_dict(callback.data) 40 | expression = incoming_message.text.content.strip() 41 | try: 42 | result = eval(expression) 43 | except Exception as e: 44 | result = 'Error: %s' % e 45 | self.logger.info('%s = %s' % (expression, result)) 46 | response = 'Q: %s\nA: %s' % (expression, result) 47 | self.reply_text(response, incoming_message) 48 | return AckMessage.STATUS_OK, 'OK' 49 | 50 | def main(): 51 | logger = setup_logger() 52 | options = define_options() 53 | 54 | credential = dingtalk_stream.Credential(options.client_id, options.client_secret) 55 | client = dingtalk_stream.DingTalkStreamClient(credential) 56 | client.register_callback_handler(dingtalk_stream.chatbot.ChatbotMessage.TOPIC, CalcBotHandler(logger)) 57 | client.start_forever() 58 | 59 | 60 | if __name__ == '__main__': 61 | main() 62 | -------------------------------------------------------------------------------- /examples/cardbot/cardbot.py: -------------------------------------------------------------------------------- 1 | # !/usr/bin/env python 2 | 3 | import sys 4 | 5 | sys.path.append("../../") 6 | sys.path.append("../") 7 | sys.path.append(".") 8 | 9 | import argparse 10 | import logging 11 | from dingtalk_stream import AckMessage 12 | import dingtalk_stream 13 | import time 14 | 15 | 16 | def setup_logger(): 17 | logger = logging.getLogger() 18 | handler = logging.StreamHandler() 19 | handler.setFormatter( 20 | logging.Formatter('%(asctime)s %(name)-8s %(levelname)-8s %(message)s [%(filename)s:%(lineno)d]')) 21 | logger.addHandler(handler) 22 | logger.setLevel(logging.INFO) 23 | return logger 24 | 25 | 26 | def define_options(): 27 | parser = argparse.ArgumentParser() 28 | parser.add_argument( 29 | '--client_id', dest='client_id', required=True, 30 | help='app_key or suite_key from https://open-dev.digntalk.com' 31 | ) 32 | parser.add_argument( 33 | '--client_secret', dest='client_secret', required=True, 34 | help='app_secret or suite_secret from https://open-dev.digntalk.com' 35 | ) 36 | options = parser.parse_args() 37 | return options 38 | 39 | 40 | class CardBotHandler(dingtalk_stream.AsyncChatbotHandler): 41 | """ 42 | 接收回调消息。 43 | 回复一个卡片,然后更新卡片的文本和图片。 44 | """ 45 | 46 | def __init__(self, logger: logging.Logger = None, max_workers: int = 8): 47 | super(CardBotHandler, self).__init__(max_workers=max_workers) 48 | if logger: 49 | self.logger = logger 50 | 51 | def process(self, callback: dingtalk_stream.CallbackMessage): 52 | incoming_message = dingtalk_stream.ChatbotMessage.from_dict(callback.data) 53 | 54 | card_instance = self.reply_markdown_card("**这是一个markdown消息,初始状态,将于5s后更新**", incoming_message, 55 | title="钉钉AI卡片", 56 | logo="@lALPDfJ6V_FPDmvNAfTNAfQ") 57 | 58 | # 如果需要更新卡片内容的话,使用这个: 59 | time.sleep(5) 60 | card_instance.update("**这是一个markdown消息,已更新**") 61 | 62 | return AckMessage.STATUS_OK, 'OK' 63 | 64 | 65 | def main(): 66 | logger = setup_logger() 67 | options = define_options() 68 | 69 | credential = dingtalk_stream.Credential(options.client_id, options.client_secret) 70 | client = dingtalk_stream.DingTalkStreamClient(credential) 71 | 72 | card_bot_handler = CardBotHandler(logger) 73 | 74 | client.register_callback_handler(dingtalk_stream.chatbot.ChatbotMessage.TOPIC, card_bot_handler) 75 | 76 | card_bot_handler.set_off_duty_prompt("不好意思,我已下班,请稍后联系我!") 77 | 78 | client.start_forever() 79 | 80 | 81 | if __name__ == '__main__': 82 | main() 83 | -------------------------------------------------------------------------------- /examples/cardbot/img.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-dingtalk/dingtalk-stream-sdk-python/c53842ddfc22ad2d7698d9f05553362638e266fa/examples/cardbot/img.png -------------------------------------------------------------------------------- /examples/cardcallback/cardcallback.py: -------------------------------------------------------------------------------- 1 | # !/usr/bin/env python 2 | 3 | import argparse 4 | import logging 5 | from dingtalk_stream import AckMessage 6 | import dingtalk_stream 7 | 8 | def setup_logger(): 9 | logger = logging.getLogger() 10 | handler = logging.StreamHandler() 11 | handler.setFormatter( 12 | logging.Formatter('%(asctime)s %(name)-8s %(levelname)-8s %(message)s [%(filename)s:%(lineno)d]')) 13 | logger.addHandler(handler) 14 | logger.setLevel(logging.INFO) 15 | return logger 16 | 17 | 18 | def define_options(): 19 | parser = argparse.ArgumentParser() 20 | parser.add_argument( 21 | '--client_id', dest='client_id', required=True, 22 | help='app_key or suite_key from https://open-dev.digntalk.com' 23 | ) 24 | parser.add_argument( 25 | '--client_secret', dest='client_secret', required=True, 26 | help='app_secret or suite_secret from https://open-dev.digntalk.com' 27 | ) 28 | options = parser.parse_args() 29 | return options 30 | 31 | 32 | class CardCallbackHandler(dingtalk_stream.CallbackHandler): 33 | def __init__(self, logger: logging.Logger = None): 34 | super(dingtalk_stream.CallbackHandler, self).__init__() 35 | if logger: 36 | self.logger = logger 37 | 38 | async def process(self, callback: dingtalk_stream.CallbackMessage): 39 | # 卡片回调的数据构造详见文档:https://open.dingtalk.com/document/orgapp/instructions-for-filling-in-api-card-data 40 | response = { 41 | 'cardData': { 42 | 'cardParamMap': { 43 | 'intParam': '1', 44 | 'trueParam': 'true', 45 | }}, 46 | 'privateData': { 47 | 'myUserId': { 48 | 'cardParamMap': { 49 | 'floatParam': '1.23', 50 | 'falseparam': 'false', 51 | }, 52 | } 53 | } 54 | } 55 | return AckMessage.STATUS_OK, response 56 | 57 | 58 | def main(): 59 | logger = setup_logger() 60 | options = define_options() 61 | 62 | credential = dingtalk_stream.Credential(options.client_id, options.client_secret) 63 | client = dingtalk_stream.DingTalkStreamClient(credential) 64 | client.register_callback_handler(dingtalk_stream.CallbackHandler.TOPIC_CARD_CALLBACK, 65 | CardCallbackHandler(logger)) 66 | client.start_forever() 67 | 68 | 69 | if __name__ == '__main__': 70 | main() 71 | -------------------------------------------------------------------------------- /examples/helloworld/helloworld.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import argparse 4 | from dingtalk_stream import AckMessage 5 | import dingtalk_stream 6 | 7 | 8 | def define_options(): 9 | parser = argparse.ArgumentParser() 10 | parser.add_argument( 11 | '--client_id', dest='client_id', required=True, 12 | help='app_key or suite_key from https://open-dev.digntalk.com' 13 | ) 14 | parser.add_argument( 15 | '--client_secret', dest='client_secret', required=True, 16 | help='app_secret or suite_secret from https://open-dev.digntalk.com' 17 | ) 18 | options = parser.parse_args() 19 | return options 20 | 21 | 22 | class MyEventHandler(dingtalk_stream.EventHandler): 23 | async def process(self, event: dingtalk_stream.EventMessage): 24 | print(event.headers.event_type, 25 | event.headers.event_id, 26 | event.headers.event_born_time, 27 | event.data) 28 | return AckMessage.STATUS_OK, 'OK' 29 | 30 | 31 | class MyCallbackHandler(dingtalk_stream.CallbackHandler): 32 | async def process(self, message: dingtalk_stream.CallbackMessage): 33 | print(message.headers.topic, 34 | message.data) 35 | return AckMessage.STATUS_OK, 'OK' 36 | 37 | 38 | def main(): 39 | options = define_options() 40 | 41 | credential = dingtalk_stream.Credential(options.client_id, options.client_secret) 42 | client = dingtalk_stream.DingTalkStreamClient(credential) 43 | client.register_all_event_handler(MyEventHandler()) 44 | client.register_callback_handler(dingtalk_stream.ChatbotMessage.TOPIC, MyCallbackHandler()) 45 | client.start_forever() 46 | 47 | 48 | if __name__ == '__main__': 49 | main() 50 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | websockets==11.0.2 2 | requests==2.30.0 3 | aiohttp==3.10.0 -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | from setuptools import setup 4 | 5 | BASE_PATH = os.path.dirname(os.path.abspath(__file__)) 6 | VERSION_STRING = '' 7 | with open(os.path.join(BASE_PATH, 'dingtalk_stream', 'version.py')) as fp: 8 | content = fp.read() 9 | VERSION_STRING = re.findall(r"VERSION_STRING\s*=\s*\'(.*?)\'", content)[0] 10 | 11 | setup( 12 | name='dingtalk-stream', 13 | version=VERSION_STRING, 14 | description='A Python library for sending messages to DingTalk chatbot', 15 | long_description=open('README.md').read(), 16 | long_description_content_type='text/markdown', 17 | url='https://github.com/open-dingtalk/dingtalk-stream-sdk-python', 18 | author='Ke Jie', 19 | author_email='jinxi.kj@alibaba-inc.com', 20 | license='MIT', 21 | packages=['dingtalk_stream'], 22 | install_requires=[ 23 | 'websockets>=11.0.2', 24 | 'requests>=2.27.1', 25 | 'aiohttp>=3.10.0', 26 | ], 27 | classifiers=[ 28 | 'Development Status :: 3 - Alpha', 29 | 'Intended Audience :: Developers', 30 | 'License :: OSI Approved :: MIT License', 31 | 'Programming Language :: Python :: 3', 32 | 'Programming Language :: Python :: 3.6', 33 | 'Programming Language :: Python :: 3.7', 34 | 'Programming Language :: Python :: 3.8', 35 | 'Programming Language :: Python :: 3.9', 36 | 'Programming Language :: Python :: Implementation :: CPython', 37 | 'Programming Language :: Python :: Implementation :: PyPy' 38 | ], 39 | ) 40 | --------------------------------------------------------------------------------