dagster-dingtalk 0.1.23__py3-none-any.whl → 0.1.26__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dagster_dingtalk/__init__.py +3 -3
- dagster_dingtalk/app_client.py +399 -148
- dagster_dingtalk/resources.py +422 -422
- dagster_dingtalk/version.py +1 -1
- {dagster_dingtalk-0.1.23.dist-info → dagster_dingtalk-0.1.26.dist-info}/METADATA +10 -15
- dagster_dingtalk-0.1.26.dist-info/RECORD +8 -0
- {dagster_dingtalk-0.1.23.dist-info → dagster_dingtalk-0.1.26.dist-info}/WHEEL +1 -1
- dagster_dingtalk-0.1.23.dist-info/RECORD +0 -8
dagster_dingtalk/resources.py
CHANGED
@@ -1,422 +1,422 @@
|
|
1
|
-
import base64
|
2
|
-
import hashlib
|
3
|
-
import hmac
|
4
|
-
import re
|
5
|
-
import time
|
6
|
-
import urllib.parse
|
7
|
-
from typing import Optional, Tuple, List, Literal
|
8
|
-
import httpx
|
9
|
-
from pydantic import Field
|
10
|
-
from .app_client import DingTalkClient
|
11
|
-
from dagster import ConfigurableResource, InitResourceContext
|
12
|
-
|
13
|
-
|
14
|
-
DINGTALK_WEBHOOK_EXCEPTION_SOLUTIONS = {
|
15
|
-
"-1": "\n系统繁忙,请稍后重试",
|
16
|
-
"40035": "\n缺少参数 json,请补充消息json",
|
17
|
-
"43004": "\n无效的HTTP HEADER Content-Type,请设置具体的消息参数",
|
18
|
-
"400013": "\n群已被解散,请向其他群发消息",
|
19
|
-
"400101": "\naccess_token不存在,请确认access_token拼写是否正确",
|
20
|
-
"400102": "\n机器人已停用,请联系管理员启用机器人",
|
21
|
-
"400105": "\n不支持的消息类型,请使用文档中支持的消息类型",
|
22
|
-
"400106": "\n机器人不存在,请确认机器人是否在群中",
|
23
|
-
"410100": "\n发送速度太快而限流,请降低发送速度",
|
24
|
-
"430101": "\n含有不安全的外链,请确认发送的内容合法",
|
25
|
-
"430102": "\n含有不合适的文本,请确认发送的内容合法",
|
26
|
-
"430103": "\n含有不合适的图片,请确认发送的内容合法",
|
27
|
-
"430104": "\n含有不合适的内容,请确认发送的内容合法",
|
28
|
-
"310000": "\n消息校验未通过,请查看机器人的安全设置",
|
29
|
-
}
|
30
|
-
|
31
|
-
|
32
|
-
class DingTalkWebhookException(Exception):
|
33
|
-
def __init__(self, errcode, errmsg):
|
34
|
-
self.errcode = errcode
|
35
|
-
self.errmsg = errmsg
|
36
|
-
super().__init__(f"DingTalkWebhookError {errcode}: {errmsg} {DINGTALK_WEBHOOK_EXCEPTION_SOLUTIONS.get(errcode)}")
|
37
|
-
|
38
|
-
|
39
|
-
class DingTalkWebhookResource(ConfigurableResource):
|
40
|
-
"""
|
41
|
-
该资源允许定义单个钉钉自定义机器人的 Webhook 端点,以便于发送文本、Markdown、Link、 ActionCard、FeedCard 消息,消息具体样式可参考
|
42
|
-
[钉钉开放平台 | 自定义机器人发送消息的消息类型](https://open.dingtalk.com/document/orgapp/custom-bot-send-message-type)。
|
43
|
-
|
44
|
-
### 配置项:
|
45
|
-
|
46
|
-
- **access_token** (str):
|
47
|
-
机器人 Webhook 地址中的 access_token 值。
|
48
|
-
- **secret** (str, optional):
|
49
|
-
如使用加签安全配置,则需传签名密钥。默认值为 None。
|
50
|
-
- **alias** (str, optional):
|
51
|
-
别名,仅用作标记。默认值为 None。
|
52
|
-
- **base_url** (str, optional):
|
53
|
-
通用地址,一般无需更改。默认值为 “https://oapi.dingtalk.com/robot/send”。
|
54
|
-
|
55
|
-
### 用例:
|
56
|
-
|
57
|
-
##### 1. 使用单个资源:
|
58
|
-
```python
|
59
|
-
from dagster_dingtalk import DingTalkWebhookResource
|
60
|
-
from dagster import op, In, OpExecutionContext, job, Definitions
|
61
|
-
|
62
|
-
@op(required_resource_keys={"dingtalk_webhook"}, ins={"text": In(str)})
|
63
|
-
def op_send_text(context:OpExecutionContext, text:str):
|
64
|
-
dingtalk_webhook:DingTalkWebhookResource = context.resources.dingtalk_webhook
|
65
|
-
dingtalk_webhook.send_text(text)
|
66
|
-
|
67
|
-
@job
|
68
|
-
def job_send_text():
|
69
|
-
op_send_text()
|
70
|
-
|
71
|
-
defs = Definitions(
|
72
|
-
jobs=[job_send_text],
|
73
|
-
resources={"dingtalk_webhook": DingTalkWebhookResource(access_token = "<access_token>", secret = "<secret>")}
|
74
|
-
)
|
75
|
-
```
|
76
|
-
|
77
|
-
##### 2. 启动时动态构建企业内部应用资源, 可参考 [Dagster文档 | 在启动时配置资源](https://docs.dagster.io/concepts/resources#configuring-resources-at-launch-time)
|
78
|
-
|
79
|
-
```python
|
80
|
-
from dagster_dingtalk import DingTalkWebhookResource
|
81
|
-
from dagster import op, In, OpExecutionContext, job, Definitions, schedule, RunRequest, RunConfig
|
82
|
-
|
83
|
-
@op(required_resource_keys={"dingtalk_webhook"}, ins={"text": In(str)})
|
84
|
-
def op_send_text(context:OpExecutionContext, text:str):
|
85
|
-
dingtalk_webhook:DingTalkWebhookResource = context.resources.dingtalk_webhook
|
86
|
-
dingtalk_webhook.send_text(text)
|
87
|
-
|
88
|
-
@job
|
89
|
-
def job_send_text():
|
90
|
-
op_send_text()
|
91
|
-
|
92
|
-
dingtalk_webhooks = {
|
93
|
-
"Group1" : DingTalkWebhookResource(access_token="<access_token>", secret="<secret>", alias="Group1"),
|
94
|
-
"Group2" : DingTalkWebhookResource(access_token="<access_token>", secret="<secret>", alias="Group2")
|
95
|
-
}
|
96
|
-
|
97
|
-
defs = Definitions(
|
98
|
-
jobs=[job_send_text],
|
99
|
-
resources={"dingtalk_webhook": DingTalkWebhookResource.configure_at_launch()}
|
100
|
-
)
|
101
|
-
|
102
|
-
@schedule(cron_schedule="20 9 * * *", job=job_send_text)
|
103
|
-
def schedule_user_info():
|
104
|
-
return RunRequest(run_config=RunConfig(
|
105
|
-
ops={"op_send_text": {"inputs": {"text": "This a test text."}}},
|
106
|
-
resources={"dingtalk": dingtalk_webhooks["Group1"]},
|
107
|
-
))
|
108
|
-
```
|
109
|
-
|
110
|
-
### 注意:
|
111
|
-
|
112
|
-
应该永远避免直接将密钥字符串直接配置给资源,这会导致在 dagster 前端用户界面暴露密钥。
|
113
|
-
应当从环境变量中读取密钥。你可以在代码中注册临时的环境变量,或从系统中引入环境变量。
|
114
|
-
|
115
|
-
```python
|
116
|
-
|
117
|
-
import os
|
118
|
-
from dagster import EnvVar
|
119
|
-
from dagster_dingtalk import DingTalkWebhookResource
|
120
|
-
|
121
|
-
# 直接在代码中注册临时的环境变量
|
122
|
-
os.environ.update({'access_token_name': "<your-access_token>"})
|
123
|
-
os.environ.update({'secret_name': "<your-secret>"})
|
124
|
-
|
125
|
-
webhook = DingTalkWebhookResource(access_token=EnvVar("access_token_name"), secret=EnvVar("secret_name"))
|
126
|
-
```
|
127
|
-
"""
|
128
|
-
|
129
|
-
access_token: str = Field(description="Webhook地址中的 access_token 部分")
|
130
|
-
secret: Optional[str] = Field(default=None, description="如使用加签安全配置,需传签名密钥")
|
131
|
-
alias: Optional[str] = Field(default=None, description="别名,标记用,无实际意义")
|
132
|
-
base_url: str = Field(default="https://oapi.dingtalk.com/robot/send", description="Webhook的通用地址,无需更改")
|
133
|
-
|
134
|
-
def webhook_url(self):
|
135
|
-
"""
|
136
|
-
实时生成加签或未加签的 Webhook URL。
|
137
|
-
|
138
|
-
钉钉API文档:
|
139
|
-
https://open.dingtalk.com/document/robots/custom-robot-access
|
140
|
-
|
141
|
-
:return:
|
142
|
-
str: Webhook URL
|
143
|
-
"""
|
144
|
-
if self.secret is None:
|
145
|
-
return f"{self.base_url}?access_token={self.access_token}"
|
146
|
-
else:
|
147
|
-
timestamp = round(time.time() * 1000)
|
148
|
-
hmac_code = hmac.new(
|
149
|
-
self.secret.encode('utf-8'), f'{timestamp}\n{self.secret}'.encode('utf-8'), digestmod=hashlib.sha256
|
150
|
-
).digest()
|
151
|
-
sign = urllib.parse.quote_plus(base64.b64encode(hmac_code))
|
152
|
-
return f"{self.base_url}?access_token={self.access_token}×tamp={timestamp}&sign={sign}"
|
153
|
-
|
154
|
-
@staticmethod
|
155
|
-
def _gen_title(text):
|
156
|
-
"""
|
157
|
-
从文本截取前12个字符作为标题,并清理其中的 Markdown 格式字符。
|
158
|
-
|
159
|
-
:param text: 原文
|
160
|
-
|
161
|
-
:return:
|
162
|
-
str: 标题
|
163
|
-
"""
|
164
|
-
return re.sub(r'[\n#>* ]', '', text[:12])
|
165
|
-
|
166
|
-
@staticmethod
|
167
|
-
def __handle_response(response):
|
168
|
-
"""
|
169
|
-
处理钉钉 Webhook API 响应,根据 errcode 抛出相应的异常
|
170
|
-
|
171
|
-
:param response: 钉钉Webhook API响应的JSON数据
|
172
|
-
"""
|
173
|
-
errcode = str(response.json().get("errcode"))
|
174
|
-
errmsg = response.json().get("errmsg")
|
175
|
-
|
176
|
-
if errcode == "0":
|
177
|
-
return True
|
178
|
-
else:
|
179
|
-
raise DingTalkWebhookException(errcode, errmsg)
|
180
|
-
|
181
|
-
def send_text(self, text: str,
|
182
|
-
at_mobiles:List[str]|None = None, at_user_ids:List[str]|None = None, at_all:bool = False):
|
183
|
-
"""
|
184
|
-
发送文本消息。
|
185
|
-
|
186
|
-
钉钉API文档:
|
187
|
-
https://open.dingtalk.com/document/orgapp/custom-bot-send-message-type
|
188
|
-
|
189
|
-
:param str text: 待发送文本
|
190
|
-
:param List[str],optional at_mobiles: 需要 @ 的用户手机号。默认值为 None
|
191
|
-
:param List[str],optional at_user_ids: 需要 @ 的用户 UserID。默认值为 None
|
192
|
-
:param bool,optional at_all: 是否 @ 所有人。默认值为 False
|
193
|
-
|
194
|
-
:raise DingTalkWebhookException:
|
195
|
-
|
196
|
-
"""
|
197
|
-
at = {"isAtAll": at_all}
|
198
|
-
if at_user_ids:
|
199
|
-
at["atUserIds"] = at_user_ids
|
200
|
-
if at_mobiles:
|
201
|
-
at["atMobiles"] = at_mobiles
|
202
|
-
response = httpx.post(url=self.webhook_url(), json={"msgtype": "text", "text": {"content": text}, "at": at})
|
203
|
-
self.__handle_response(response)
|
204
|
-
|
205
|
-
def send_link(self, text: str, message_url:str, title:str|None = None, pic_url:str = ""):
|
206
|
-
"""
|
207
|
-
发送 Link 消息。
|
208
|
-
|
209
|
-
钉钉API文档:
|
210
|
-
https://open.dingtalk.com/document/orgapp/custom-bot-send-message-type
|
211
|
-
|
212
|
-
:param str text: 待发送文本
|
213
|
-
:param str message_url: 链接的 Url
|
214
|
-
:param str,optional title: 标题,在通知和被引用时显示的简短信息。默认从文本中生成。
|
215
|
-
:param str,optional pic_url: 图片的 Url,默认为 None
|
216
|
-
|
217
|
-
:raise DingTalkWebhookException:
|
218
|
-
"""
|
219
|
-
title = title or self._gen_title(text)
|
220
|
-
response = httpx.post(
|
221
|
-
url=self.webhook_url(),
|
222
|
-
json={"msgtype": "link", "link": {"title": title, "text": text, "picUrl": pic_url, "messageUrl": message_url}}
|
223
|
-
)
|
224
|
-
self.__handle_response(response)
|
225
|
-
|
226
|
-
def send_markdown(self, text: List[str]|str, title:str|None = None,
|
227
|
-
at_mobiles:List[str]|None = None, at_user_ids:List[str]|None = None, at_all:bool = False):
|
228
|
-
"""
|
229
|
-
发送 Markdown 消息。支持的语法有:
|
230
|
-
# 一级标题 ## 二级标题 ### 三级标题 #### 四级标题 ##### 五级标题 ###### 六级标题
|
231
|
-
> 引用 **加粗** *斜体*
|
232
|
-
[链接跳转](https://example.com/doc.html)
|
233
|
-

|
234
|
-
- 无序列表
|
235
|
-
1. 有序列表
|
236
|
-
|
237
|
-
钉钉API文档:
|
238
|
-
https://open.dingtalk.com/document/orgapp/custom-bot-send-message-type
|
239
|
-
|
240
|
-
:param str text: 待发送文本
|
241
|
-
:param str,optional title: 标题,在通知和被引用时显示的简短信息。默认从文本中生成。
|
242
|
-
:param List[str],optional at_mobiles: 需要 @ 的用户手机号。默认值为 None
|
243
|
-
:param List[str],optional at_user_ids: 需要 @ 的用户 UserID。默认值为 None
|
244
|
-
:param bool,optional at_all: 是否 @ 所有人。默认值为 False
|
245
|
-
|
246
|
-
:raise DingTalkWebhookException:
|
247
|
-
"""
|
248
|
-
text = text if isinstance(text, str) else "\n\n".join(text)
|
249
|
-
title = title or self._gen_title(text)
|
250
|
-
at = {"isAtAll": at_all}
|
251
|
-
if at_user_ids:
|
252
|
-
at["atUserIds"] = at_user_ids
|
253
|
-
if at_mobiles:
|
254
|
-
at["atMobiles"] = at_mobiles
|
255
|
-
response = httpx.post(url=self.webhook_url(),json={"msgtype": "markdown", "markdown": {"title": title, "text": text}, "at": at})
|
256
|
-
self.__handle_response(response)
|
257
|
-
|
258
|
-
def send_action_card(self, text: List[str]|str, title:str|None = None, btn_orientation:Literal["0","1"] = "0",
|
259
|
-
single_jump:Tuple[str,str]|None = None, btns_jump:List[Tuple[str,str]]|None = None):
|
260
|
-
"""
|
261
|
-
发送跳转 ActionCard 消息。
|
262
|
-
|
263
|
-
**注意:**
|
264
|
-
同时传 `single_jump` 和 `btns_jump`,仅 `single_jump` 生效。
|
265
|
-
|
266
|
-
:param str text: 待发送文本,支持 Markdown 部分语法。
|
267
|
-
:param str,optional title: 标题,在通知和被引用时显示的简短信息。默认从文本中生成。
|
268
|
-
:param str,optional btn_orientation: 按钮排列方式,0-按钮竖直排列,1-按钮横向排列。默认值为 "0"
|
269
|
-
:param Tuple[str,str],optional single_jump: 传此参数为单个按钮,元组内第一项为按钮的标题,第二项为按钮链接。
|
270
|
-
:param Tuple[str,str],optional btns_jump: 传此参数为多个按钮,元组内第一项为按钮的标题,第二项为按钮链接。
|
271
|
-
|
272
|
-
:raise DingTalkWebhookException:
|
273
|
-
"""
|
274
|
-
text = text if isinstance(text, str) else "\n\n".join(text)
|
275
|
-
title = title or self._gen_title(text)
|
276
|
-
action_card = {"title": title, "text": text, "btnOrientation": str(btn_orientation)}
|
277
|
-
|
278
|
-
if single_jump:
|
279
|
-
action_card["singleTitle"], action_card["singleURL"] = single_jump
|
280
|
-
if btns_jump:
|
281
|
-
action_card["btns"] = [{"title": action_title, "actionURL": action_url} for action_title, action_url in btns_jump]
|
282
|
-
|
283
|
-
response = httpx.post(url=self.webhook_url(), json={"msgtype": "actionCard", "actionCard": action_card})
|
284
|
-
self.__handle_response(response)
|
285
|
-
|
286
|
-
def send_feed_card(self, *args:Tuple[str,str,str]):
|
287
|
-
"""
|
288
|
-
发送 FeedCard 消息。
|
289
|
-
|
290
|
-
:param Tuple[str,str,str],optional args: 可以传入任意个具有三个元素的元组,分别为 `(标题, 跳转链接, 缩略图链接)`
|
291
|
-
|
292
|
-
:raise DingTalkWebhookException:
|
293
|
-
"""
|
294
|
-
for a in args:
|
295
|
-
print(a)
|
296
|
-
links_data = [
|
297
|
-
{"title": title, "messageURL": message_url, "picURL": pic_url}
|
298
|
-
for title, message_url, pic_url in args
|
299
|
-
]
|
300
|
-
response = httpx.post(url=self.webhook_url(), json={"msgtype": "feedCard", "feedCard": {"links": links_data}})
|
301
|
-
self.__handle_response(response)
|
302
|
-
|
303
|
-
|
304
|
-
class DingTalkAppResource(ConfigurableResource):
|
305
|
-
"""
|
306
|
-
[钉钉服务端 API](https://open.dingtalk.com/document/orgapp/api-overview) 企业内部应用部分的第三方封装。
|
307
|
-
|
308
|
-
通过此资源,可以调用部分钉钉服务端 API。具体封装的 API 可以在 IDE 中通过引入 `DingTalkAppClient` 类来查看 IDE 提示:
|
309
|
-
|
310
|
-
```python
|
311
|
-
from dagster_dingtalk import DingTalkAppClient
|
312
|
-
|
313
|
-
dingtalk: DingTalkAppClient
|
314
|
-
```
|
315
|
-
|
316
|
-
### 配置项:
|
317
|
-
|
318
|
-
- **AppID** (str):
|
319
|
-
应用应用唯一标识 AppID,作为缓存标识符使用。不传入则不缓存鉴权。
|
320
|
-
- **AgentID** (int, optional):
|
321
|
-
原企业内部应用 AgentId ,部分 API 会使用到。默认值为 None
|
322
|
-
- **AppName** (str, optional):
|
323
|
-
应用名。
|
324
|
-
- **ClientId** (str):
|
325
|
-
应用的 Client ID ,原 AppKey 和 SuiteKey
|
326
|
-
- **ClientSecret** (str):
|
327
|
-
应用的 Client Secret ,原 AppSecret 和 SuiteSecret
|
328
|
-
|
329
|
-
### 用例
|
330
|
-
|
331
|
-
##### 1. 使用单一的企业内部应用资源。
|
332
|
-
|
333
|
-
```python
|
334
|
-
from dagster_dingtalk import DingTalkAppResource, DingTalkAppClient
|
335
|
-
from dagster import op, In, OpExecutionContext, job, Definitions, EnvVar
|
336
|
-
|
337
|
-
@op(required_resource_keys={"dingtalk"}, ins={"user_id": In(str)})
|
338
|
-
def op_user_info(context:OpExecutionContext, user_id:str):
|
339
|
-
dingtalk:DingTalkAppClient = context.resources.dingtalk
|
340
|
-
result = dingtalk.通讯录管理.用户管理.查询用户详情(user_id).get('result')
|
341
|
-
context.log.info(result)
|
342
|
-
|
343
|
-
@job
|
344
|
-
def job_user_info():
|
345
|
-
op_user_info()
|
346
|
-
|
347
|
-
defs = Definitions(
|
348
|
-
jobs=[job_user_info],
|
349
|
-
resources={"dingtalk": DingTalkAppResource(
|
350
|
-
AppID = "<the-app-id>",
|
351
|
-
ClientId = "<the-client-id>",
|
352
|
-
ClientSecret = EnvVar("<the-client-secret-env-name>"),
|
353
|
-
)})
|
354
|
-
```
|
355
|
-
|
356
|
-
##### 2. 启动时动态构建企业内部应用资源, 可参考 [Dagster文档 | 在启动时配置资源](https://docs.dagster.io/concepts/resources#configuring-resources-at-launch-time)
|
357
|
-
|
358
|
-
```python
|
359
|
-
from dagster_dingtalk import DingTalkAppResource, DingTalkAppClient
|
360
|
-
from dagster import op, In, OpExecutionContext, job, Definitions, schedule, RunRequest, RunConfig, EnvVar
|
361
|
-
|
362
|
-
@op(required_resource_keys={"dingtalk"}, ins={"user_id": In(str)})
|
363
|
-
def op_user_info(context:OpExecutionContext, user_id:str):
|
364
|
-
dingtalk:DingTalkAppClient = context.resources.dingtalk
|
365
|
-
result = dingtalk.通讯录管理.用户管理.查询用户详情(user_id).get('result')
|
366
|
-
context.log.info(result)
|
367
|
-
|
368
|
-
@job
|
369
|
-
def job_user_info():
|
370
|
-
op_user_info()
|
371
|
-
|
372
|
-
dingtalk_apps = {
|
373
|
-
"App1" : DingTalkAppResource(
|
374
|
-
AppID = "<app-1-app-id>",
|
375
|
-
ClientId = "<app-1-client-id>",
|
376
|
-
ClientSecret = EnvVar("<app-1-client-secret-env-name>"),
|
377
|
-
),
|
378
|
-
"App2" : DingTalkAppResource(
|
379
|
-
AppID = "<app-2-app-id>",
|
380
|
-
ClientId = "<app-2-client-id>",
|
381
|
-
ClientSecret = EnvVar("<app-2-client-secret-env-name>"),
|
382
|
-
)
|
383
|
-
}
|
384
|
-
|
385
|
-
defs = Definitions(jobs=[job_user_info], resources={"dingtalk": DingTalkAppResource.configure_at_launch()})
|
386
|
-
|
387
|
-
@schedule(cron_schedule="20 9 * * *", job=job_user_info)
|
388
|
-
def schedule_user_info():
|
389
|
-
return RunRequest(run_config=RunConfig(
|
390
|
-
ops={"op_user_info": {"inputs": {"user_id": "<the-user-id>"}}},
|
391
|
-
resources={"dingtalk": dingtalk_apps["App1"]},
|
392
|
-
))
|
393
|
-
```
|
394
|
-
|
395
|
-
### 注意:
|
396
|
-
|
397
|
-
应该永远避免直接将密钥字符串直接配置给资源,这会导致在 dagster 前端用户界面暴露密钥。你可以在代码中注册临时的环境变量,或从系统中引入环境变量。
|
398
|
-
"""
|
399
|
-
|
400
|
-
AppID: str = Field(description="应用应用唯一标识 AppID,作为缓存标识符使用。不传入则不缓存鉴权。")
|
401
|
-
AgentID: Optional[int] = Field(default=None, description="原企业内部应用AgentId ,部分API会使用到。")
|
402
|
-
AppName: Optional[str] = Field(default=None, description="应用名。")
|
403
|
-
ClientId: str = Field(description="应用的 Client ID (原 AppKey 和 SuiteKey)")
|
404
|
-
ClientSecret: str = Field(description="应用的 Client Secret (原 AppSecret 和 SuiteSecret)")
|
405
|
-
|
406
|
-
@classmethod
|
407
|
-
def _is_dagster_maintained(cls) -> bool:
|
408
|
-
return False
|
409
|
-
|
410
|
-
def create_resource(self, context: InitResourceContext) -> DingTalkClient:
|
411
|
-
"""
|
412
|
-
返回一个 `DingTalkClient` 实例。
|
413
|
-
:param context:
|
414
|
-
:return:
|
415
|
-
"""
|
416
|
-
return DingTalkClient(
|
417
|
-
app_id=self.AppID,
|
418
|
-
agent_id=self.AgentID,
|
419
|
-
app_name=self.AppName,
|
420
|
-
client_id=self.ClientId,
|
421
|
-
client_secret=self.ClientSecret
|
422
|
-
)
|
1
|
+
import base64
|
2
|
+
import hashlib
|
3
|
+
import hmac
|
4
|
+
import re
|
5
|
+
import time
|
6
|
+
import urllib.parse
|
7
|
+
from typing import Optional, Tuple, List, Literal
|
8
|
+
import httpx
|
9
|
+
from pydantic import Field
|
10
|
+
from .app_client import DingTalkClient
|
11
|
+
from dagster import ConfigurableResource, InitResourceContext
|
12
|
+
|
13
|
+
|
14
|
+
DINGTALK_WEBHOOK_EXCEPTION_SOLUTIONS = {
|
15
|
+
"-1": "\n系统繁忙,请稍后重试",
|
16
|
+
"40035": "\n缺少参数 json,请补充消息json",
|
17
|
+
"43004": "\n无效的HTTP HEADER Content-Type,请设置具体的消息参数",
|
18
|
+
"400013": "\n群已被解散,请向其他群发消息",
|
19
|
+
"400101": "\naccess_token不存在,请确认access_token拼写是否正确",
|
20
|
+
"400102": "\n机器人已停用,请联系管理员启用机器人",
|
21
|
+
"400105": "\n不支持的消息类型,请使用文档中支持的消息类型",
|
22
|
+
"400106": "\n机器人不存在,请确认机器人是否在群中",
|
23
|
+
"410100": "\n发送速度太快而限流,请降低发送速度",
|
24
|
+
"430101": "\n含有不安全的外链,请确认发送的内容合法",
|
25
|
+
"430102": "\n含有不合适的文本,请确认发送的内容合法",
|
26
|
+
"430103": "\n含有不合适的图片,请确认发送的内容合法",
|
27
|
+
"430104": "\n含有不合适的内容,请确认发送的内容合法",
|
28
|
+
"310000": "\n消息校验未通过,请查看机器人的安全设置",
|
29
|
+
}
|
30
|
+
|
31
|
+
|
32
|
+
class DingTalkWebhookException(Exception):
|
33
|
+
def __init__(self, errcode, errmsg):
|
34
|
+
self.errcode = errcode
|
35
|
+
self.errmsg = errmsg
|
36
|
+
super().__init__(f"DingTalkWebhookError {errcode}: {errmsg} {DINGTALK_WEBHOOK_EXCEPTION_SOLUTIONS.get(errcode)}")
|
37
|
+
|
38
|
+
|
39
|
+
class DingTalkWebhookResource(ConfigurableResource):
|
40
|
+
"""
|
41
|
+
该资源允许定义单个钉钉自定义机器人的 Webhook 端点,以便于发送文本、Markdown、Link、 ActionCard、FeedCard 消息,消息具体样式可参考
|
42
|
+
[钉钉开放平台 | 自定义机器人发送消息的消息类型](https://open.dingtalk.com/document/orgapp/custom-bot-send-message-type)。
|
43
|
+
|
44
|
+
### 配置项:
|
45
|
+
|
46
|
+
- **access_token** (str):
|
47
|
+
机器人 Webhook 地址中的 access_token 值。
|
48
|
+
- **secret** (str, optional):
|
49
|
+
如使用加签安全配置,则需传签名密钥。默认值为 None。
|
50
|
+
- **alias** (str, optional):
|
51
|
+
别名,仅用作标记。默认值为 None。
|
52
|
+
- **base_url** (str, optional):
|
53
|
+
通用地址,一般无需更改。默认值为 “https://oapi.dingtalk.com/robot/send”。
|
54
|
+
|
55
|
+
### 用例:
|
56
|
+
|
57
|
+
##### 1. 使用单个资源:
|
58
|
+
```python
|
59
|
+
from dagster_dingtalk import DingTalkWebhookResource
|
60
|
+
from dagster import op, In, OpExecutionContext, job, Definitions
|
61
|
+
|
62
|
+
@op(required_resource_keys={"dingtalk_webhook"}, ins={"text": In(str)})
|
63
|
+
def op_send_text(context:OpExecutionContext, text:str):
|
64
|
+
dingtalk_webhook:DingTalkWebhookResource = context.resources.dingtalk_webhook
|
65
|
+
dingtalk_webhook.send_text(text)
|
66
|
+
|
67
|
+
@job
|
68
|
+
def job_send_text():
|
69
|
+
op_send_text()
|
70
|
+
|
71
|
+
defs = Definitions(
|
72
|
+
jobs=[job_send_text],
|
73
|
+
resources={"dingtalk_webhook": DingTalkWebhookResource(access_token = "<access_token>", secret = "<secret>")}
|
74
|
+
)
|
75
|
+
```
|
76
|
+
|
77
|
+
##### 2. 启动时动态构建企业内部应用资源, 可参考 [Dagster文档 | 在启动时配置资源](https://docs.dagster.io/concepts/resources#configuring-resources-at-launch-time)
|
78
|
+
|
79
|
+
```python
|
80
|
+
from dagster_dingtalk import DingTalkWebhookResource
|
81
|
+
from dagster import op, In, OpExecutionContext, job, Definitions, schedule, RunRequest, RunConfig
|
82
|
+
|
83
|
+
@op(required_resource_keys={"dingtalk_webhook"}, ins={"text": In(str)})
|
84
|
+
def op_send_text(context:OpExecutionContext, text:str):
|
85
|
+
dingtalk_webhook:DingTalkWebhookResource = context.resources.dingtalk_webhook
|
86
|
+
dingtalk_webhook.send_text(text)
|
87
|
+
|
88
|
+
@job
|
89
|
+
def job_send_text():
|
90
|
+
op_send_text()
|
91
|
+
|
92
|
+
dingtalk_webhooks = {
|
93
|
+
"Group1" : DingTalkWebhookResource(access_token="<access_token>", secret="<secret>", alias="Group1"),
|
94
|
+
"Group2" : DingTalkWebhookResource(access_token="<access_token>", secret="<secret>", alias="Group2")
|
95
|
+
}
|
96
|
+
|
97
|
+
defs = Definitions(
|
98
|
+
jobs=[job_send_text],
|
99
|
+
resources={"dingtalk_webhook": DingTalkWebhookResource.configure_at_launch()}
|
100
|
+
)
|
101
|
+
|
102
|
+
@schedule(cron_schedule="20 9 * * *", job=job_send_text)
|
103
|
+
def schedule_user_info():
|
104
|
+
return RunRequest(run_config=RunConfig(
|
105
|
+
ops={"op_send_text": {"inputs": {"text": "This a test text."}}},
|
106
|
+
resources={"dingtalk": dingtalk_webhooks["Group1"]},
|
107
|
+
))
|
108
|
+
```
|
109
|
+
|
110
|
+
### 注意:
|
111
|
+
|
112
|
+
应该永远避免直接将密钥字符串直接配置给资源,这会导致在 dagster 前端用户界面暴露密钥。
|
113
|
+
应当从环境变量中读取密钥。你可以在代码中注册临时的环境变量,或从系统中引入环境变量。
|
114
|
+
|
115
|
+
```python
|
116
|
+
|
117
|
+
import os
|
118
|
+
from dagster import EnvVar
|
119
|
+
from dagster_dingtalk import DingTalkWebhookResource
|
120
|
+
|
121
|
+
# 直接在代码中注册临时的环境变量
|
122
|
+
os.environ.update({'access_token_name': "<your-access_token>"})
|
123
|
+
os.environ.update({'secret_name': "<your-secret>"})
|
124
|
+
|
125
|
+
webhook = DingTalkWebhookResource(access_token=EnvVar("access_token_name"), secret=EnvVar("secret_name"))
|
126
|
+
```
|
127
|
+
"""
|
128
|
+
|
129
|
+
access_token: str = Field(description="Webhook地址中的 access_token 部分")
|
130
|
+
secret: Optional[str] = Field(default=None, description="如使用加签安全配置,需传签名密钥")
|
131
|
+
alias: Optional[str] = Field(default=None, description="别名,标记用,无实际意义")
|
132
|
+
base_url: str = Field(default="https://oapi.dingtalk.com/robot/send", description="Webhook的通用地址,无需更改")
|
133
|
+
|
134
|
+
def webhook_url(self):
|
135
|
+
"""
|
136
|
+
实时生成加签或未加签的 Webhook URL。
|
137
|
+
|
138
|
+
钉钉API文档:
|
139
|
+
https://open.dingtalk.com/document/robots/custom-robot-access
|
140
|
+
|
141
|
+
:return:
|
142
|
+
str: Webhook URL
|
143
|
+
"""
|
144
|
+
if self.secret is None:
|
145
|
+
return f"{self.base_url}?access_token={self.access_token}"
|
146
|
+
else:
|
147
|
+
timestamp = round(time.time() * 1000)
|
148
|
+
hmac_code = hmac.new(
|
149
|
+
self.secret.encode('utf-8'), f'{timestamp}\n{self.secret}'.encode('utf-8'), digestmod=hashlib.sha256
|
150
|
+
).digest()
|
151
|
+
sign = urllib.parse.quote_plus(base64.b64encode(hmac_code))
|
152
|
+
return f"{self.base_url}?access_token={self.access_token}×tamp={timestamp}&sign={sign}"
|
153
|
+
|
154
|
+
@staticmethod
|
155
|
+
def _gen_title(text):
|
156
|
+
"""
|
157
|
+
从文本截取前12个字符作为标题,并清理其中的 Markdown 格式字符。
|
158
|
+
|
159
|
+
:param text: 原文
|
160
|
+
|
161
|
+
:return:
|
162
|
+
str: 标题
|
163
|
+
"""
|
164
|
+
return re.sub(r'[\n#>* ]', '', text[:12])
|
165
|
+
|
166
|
+
@staticmethod
|
167
|
+
def __handle_response(response):
|
168
|
+
"""
|
169
|
+
处理钉钉 Webhook API 响应,根据 errcode 抛出相应的异常
|
170
|
+
|
171
|
+
:param response: 钉钉Webhook API响应的JSON数据
|
172
|
+
"""
|
173
|
+
errcode = str(response.json().get("errcode"))
|
174
|
+
errmsg = response.json().get("errmsg")
|
175
|
+
|
176
|
+
if errcode == "0":
|
177
|
+
return True
|
178
|
+
else:
|
179
|
+
raise DingTalkWebhookException(errcode, errmsg)
|
180
|
+
|
181
|
+
def send_text(self, text: str,
|
182
|
+
at_mobiles:List[str]|None = None, at_user_ids:List[str]|None = None, at_all:bool = False):
|
183
|
+
"""
|
184
|
+
发送文本消息。
|
185
|
+
|
186
|
+
钉钉API文档:
|
187
|
+
https://open.dingtalk.com/document/orgapp/custom-bot-send-message-type
|
188
|
+
|
189
|
+
:param str text: 待发送文本
|
190
|
+
:param List[str],optional at_mobiles: 需要 @ 的用户手机号。默认值为 None
|
191
|
+
:param List[str],optional at_user_ids: 需要 @ 的用户 UserID。默认值为 None
|
192
|
+
:param bool,optional at_all: 是否 @ 所有人。默认值为 False
|
193
|
+
|
194
|
+
:raise DingTalkWebhookException:
|
195
|
+
|
196
|
+
"""
|
197
|
+
at = {"isAtAll": at_all}
|
198
|
+
if at_user_ids:
|
199
|
+
at["atUserIds"] = at_user_ids
|
200
|
+
if at_mobiles:
|
201
|
+
at["atMobiles"] = at_mobiles
|
202
|
+
response = httpx.post(url=self.webhook_url(), json={"msgtype": "text", "text": {"content": text}, "at": at})
|
203
|
+
self.__handle_response(response)
|
204
|
+
|
205
|
+
def send_link(self, text: str, message_url:str, title:str|None = None, pic_url:str = ""):
|
206
|
+
"""
|
207
|
+
发送 Link 消息。
|
208
|
+
|
209
|
+
钉钉API文档:
|
210
|
+
https://open.dingtalk.com/document/orgapp/custom-bot-send-message-type
|
211
|
+
|
212
|
+
:param str text: 待发送文本
|
213
|
+
:param str message_url: 链接的 Url
|
214
|
+
:param str,optional title: 标题,在通知和被引用时显示的简短信息。默认从文本中生成。
|
215
|
+
:param str,optional pic_url: 图片的 Url,默认为 None
|
216
|
+
|
217
|
+
:raise DingTalkWebhookException:
|
218
|
+
"""
|
219
|
+
title = title or self._gen_title(text)
|
220
|
+
response = httpx.post(
|
221
|
+
url=self.webhook_url(),
|
222
|
+
json={"msgtype": "link", "link": {"title": title, "text": text, "picUrl": pic_url, "messageUrl": message_url}}
|
223
|
+
)
|
224
|
+
self.__handle_response(response)
|
225
|
+
|
226
|
+
def send_markdown(self, text: List[str]|str, title:str|None = None,
|
227
|
+
at_mobiles:List[str]|None = None, at_user_ids:List[str]|None = None, at_all:bool = False):
|
228
|
+
"""
|
229
|
+
发送 Markdown 消息。支持的语法有:
|
230
|
+
# 一级标题 ## 二级标题 ### 三级标题 #### 四级标题 ##### 五级标题 ###### 六级标题
|
231
|
+
> 引用 **加粗** *斜体*
|
232
|
+
[链接跳转](https://example.com/doc.html)
|
233
|
+

|
234
|
+
- 无序列表
|
235
|
+
1. 有序列表
|
236
|
+
|
237
|
+
钉钉API文档:
|
238
|
+
https://open.dingtalk.com/document/orgapp/custom-bot-send-message-type
|
239
|
+
|
240
|
+
:param str text: 待发送文本
|
241
|
+
:param str,optional title: 标题,在通知和被引用时显示的简短信息。默认从文本中生成。
|
242
|
+
:param List[str],optional at_mobiles: 需要 @ 的用户手机号。默认值为 None
|
243
|
+
:param List[str],optional at_user_ids: 需要 @ 的用户 UserID。默认值为 None
|
244
|
+
:param bool,optional at_all: 是否 @ 所有人。默认值为 False
|
245
|
+
|
246
|
+
:raise DingTalkWebhookException:
|
247
|
+
"""
|
248
|
+
text = text if isinstance(text, str) else "\n\n".join(text)
|
249
|
+
title = title or self._gen_title(text)
|
250
|
+
at = {"isAtAll": at_all}
|
251
|
+
if at_user_ids:
|
252
|
+
at["atUserIds"] = at_user_ids
|
253
|
+
if at_mobiles:
|
254
|
+
at["atMobiles"] = at_mobiles
|
255
|
+
response = httpx.post(url=self.webhook_url(),json={"msgtype": "markdown", "markdown": {"title": title, "text": text}, "at": at})
|
256
|
+
self.__handle_response(response)
|
257
|
+
|
258
|
+
def send_action_card(self, text: List[str]|str, title:str|None = None, btn_orientation:Literal["0","1"] = "0",
|
259
|
+
single_jump:Tuple[str,str]|None = None, btns_jump:List[Tuple[str,str]]|None = None):
|
260
|
+
"""
|
261
|
+
发送跳转 ActionCard 消息。
|
262
|
+
|
263
|
+
**注意:**
|
264
|
+
同时传 `single_jump` 和 `btns_jump`,仅 `single_jump` 生效。
|
265
|
+
|
266
|
+
:param str text: 待发送文本,支持 Markdown 部分语法。
|
267
|
+
:param str,optional title: 标题,在通知和被引用时显示的简短信息。默认从文本中生成。
|
268
|
+
:param str,optional btn_orientation: 按钮排列方式,0-按钮竖直排列,1-按钮横向排列。默认值为 "0"
|
269
|
+
:param Tuple[str,str],optional single_jump: 传此参数为单个按钮,元组内第一项为按钮的标题,第二项为按钮链接。
|
270
|
+
:param Tuple[str,str],optional btns_jump: 传此参数为多个按钮,元组内第一项为按钮的标题,第二项为按钮链接。
|
271
|
+
|
272
|
+
:raise DingTalkWebhookException:
|
273
|
+
"""
|
274
|
+
text = text if isinstance(text, str) else "\n\n".join(text)
|
275
|
+
title = title or self._gen_title(text)
|
276
|
+
action_card = {"title": title, "text": text, "btnOrientation": str(btn_orientation)}
|
277
|
+
|
278
|
+
if single_jump:
|
279
|
+
action_card["singleTitle"], action_card["singleURL"] = single_jump
|
280
|
+
if btns_jump:
|
281
|
+
action_card["btns"] = [{"title": action_title, "actionURL": action_url} for action_title, action_url in btns_jump]
|
282
|
+
|
283
|
+
response = httpx.post(url=self.webhook_url(), json={"msgtype": "actionCard", "actionCard": action_card})
|
284
|
+
self.__handle_response(response)
|
285
|
+
|
286
|
+
def send_feed_card(self, *args:Tuple[str,str,str]):
|
287
|
+
"""
|
288
|
+
发送 FeedCard 消息。
|
289
|
+
|
290
|
+
:param Tuple[str,str,str],optional args: 可以传入任意个具有三个元素的元组,分别为 `(标题, 跳转链接, 缩略图链接)`
|
291
|
+
|
292
|
+
:raise DingTalkWebhookException:
|
293
|
+
"""
|
294
|
+
for a in args:
|
295
|
+
print(a)
|
296
|
+
links_data = [
|
297
|
+
{"title": title, "messageURL": message_url, "picURL": pic_url}
|
298
|
+
for title, message_url, pic_url in args
|
299
|
+
]
|
300
|
+
response = httpx.post(url=self.webhook_url(), json={"msgtype": "feedCard", "feedCard": {"links": links_data}})
|
301
|
+
self.__handle_response(response)
|
302
|
+
|
303
|
+
|
304
|
+
class DingTalkAppResource(ConfigurableResource):
|
305
|
+
"""
|
306
|
+
[钉钉服务端 API](https://open.dingtalk.com/document/orgapp/api-overview) 企业内部应用部分的第三方封装。
|
307
|
+
|
308
|
+
通过此资源,可以调用部分钉钉服务端 API。具体封装的 API 可以在 IDE 中通过引入 `DingTalkAppClient` 类来查看 IDE 提示:
|
309
|
+
|
310
|
+
```python
|
311
|
+
from dagster_dingtalk import DingTalkAppClient
|
312
|
+
|
313
|
+
dingtalk: DingTalkAppClient
|
314
|
+
```
|
315
|
+
|
316
|
+
### 配置项:
|
317
|
+
|
318
|
+
- **AppID** (str):
|
319
|
+
应用应用唯一标识 AppID,作为缓存标识符使用。不传入则不缓存鉴权。
|
320
|
+
- **AgentID** (int, optional):
|
321
|
+
原企业内部应用 AgentId ,部分 API 会使用到。默认值为 None
|
322
|
+
- **AppName** (str, optional):
|
323
|
+
应用名。
|
324
|
+
- **ClientId** (str):
|
325
|
+
应用的 Client ID ,原 AppKey 和 SuiteKey
|
326
|
+
- **ClientSecret** (str):
|
327
|
+
应用的 Client Secret ,原 AppSecret 和 SuiteSecret
|
328
|
+
|
329
|
+
### 用例
|
330
|
+
|
331
|
+
##### 1. 使用单一的企业内部应用资源。
|
332
|
+
|
333
|
+
```python
|
334
|
+
from dagster_dingtalk import DingTalkAppResource, DingTalkAppClient
|
335
|
+
from dagster import op, In, OpExecutionContext, job, Definitions, EnvVar
|
336
|
+
|
337
|
+
@op(required_resource_keys={"dingtalk"}, ins={"user_id": In(str)})
|
338
|
+
def op_user_info(context:OpExecutionContext, user_id:str):
|
339
|
+
dingtalk:DingTalkAppClient = context.resources.dingtalk
|
340
|
+
result = dingtalk.通讯录管理.用户管理.查询用户详情(user_id).get('result')
|
341
|
+
context.log.info(result)
|
342
|
+
|
343
|
+
@job
|
344
|
+
def job_user_info():
|
345
|
+
op_user_info()
|
346
|
+
|
347
|
+
defs = Definitions(
|
348
|
+
jobs=[job_user_info],
|
349
|
+
resources={"dingtalk": DingTalkAppResource(
|
350
|
+
AppID = "<the-app-id>",
|
351
|
+
ClientId = "<the-client-id>",
|
352
|
+
ClientSecret = EnvVar("<the-client-secret-env-name>"),
|
353
|
+
)})
|
354
|
+
```
|
355
|
+
|
356
|
+
##### 2. 启动时动态构建企业内部应用资源, 可参考 [Dagster文档 | 在启动时配置资源](https://docs.dagster.io/concepts/resources#configuring-resources-at-launch-time)
|
357
|
+
|
358
|
+
```python
|
359
|
+
from dagster_dingtalk import DingTalkAppResource, DingTalkAppClient
|
360
|
+
from dagster import op, In, OpExecutionContext, job, Definitions, schedule, RunRequest, RunConfig, EnvVar
|
361
|
+
|
362
|
+
@op(required_resource_keys={"dingtalk"}, ins={"user_id": In(str)})
|
363
|
+
def op_user_info(context:OpExecutionContext, user_id:str):
|
364
|
+
dingtalk:DingTalkAppClient = context.resources.dingtalk
|
365
|
+
result = dingtalk.通讯录管理.用户管理.查询用户详情(user_id).get('result')
|
366
|
+
context.log.info(result)
|
367
|
+
|
368
|
+
@job
|
369
|
+
def job_user_info():
|
370
|
+
op_user_info()
|
371
|
+
|
372
|
+
dingtalk_apps = {
|
373
|
+
"App1" : DingTalkAppResource(
|
374
|
+
AppID = "<app-1-app-id>",
|
375
|
+
ClientId = "<app-1-client-id>",
|
376
|
+
ClientSecret = EnvVar("<app-1-client-secret-env-name>"),
|
377
|
+
),
|
378
|
+
"App2" : DingTalkAppResource(
|
379
|
+
AppID = "<app-2-app-id>",
|
380
|
+
ClientId = "<app-2-client-id>",
|
381
|
+
ClientSecret = EnvVar("<app-2-client-secret-env-name>"),
|
382
|
+
)
|
383
|
+
}
|
384
|
+
|
385
|
+
defs = Definitions(jobs=[job_user_info], resources={"dingtalk": DingTalkAppResource.configure_at_launch()})
|
386
|
+
|
387
|
+
@schedule(cron_schedule="20 9 * * *", job=job_user_info)
|
388
|
+
def schedule_user_info():
|
389
|
+
return RunRequest(run_config=RunConfig(
|
390
|
+
ops={"op_user_info": {"inputs": {"user_id": "<the-user-id>"}}},
|
391
|
+
resources={"dingtalk": dingtalk_apps["App1"]},
|
392
|
+
))
|
393
|
+
```
|
394
|
+
|
395
|
+
### 注意:
|
396
|
+
|
397
|
+
应该永远避免直接将密钥字符串直接配置给资源,这会导致在 dagster 前端用户界面暴露密钥。你可以在代码中注册临时的环境变量,或从系统中引入环境变量。
|
398
|
+
"""
|
399
|
+
|
400
|
+
AppID: str = Field(description="应用应用唯一标识 AppID,作为缓存标识符使用。不传入则不缓存鉴权。")
|
401
|
+
AgentID: Optional[int] = Field(default=None, description="原企业内部应用AgentId ,部分API会使用到。")
|
402
|
+
AppName: Optional[str] = Field(default=None, description="应用名。")
|
403
|
+
ClientId: str = Field(description="应用的 Client ID (原 AppKey 和 SuiteKey)")
|
404
|
+
ClientSecret: str = Field(description="应用的 Client Secret (原 AppSecret 和 SuiteSecret)")
|
405
|
+
|
406
|
+
@classmethod
|
407
|
+
def _is_dagster_maintained(cls) -> bool:
|
408
|
+
return False
|
409
|
+
|
410
|
+
def create_resource(self, context: InitResourceContext) -> DingTalkClient:
|
411
|
+
"""
|
412
|
+
返回一个 `DingTalkClient` 实例。
|
413
|
+
:param context:
|
414
|
+
:return:
|
415
|
+
"""
|
416
|
+
return DingTalkClient(
|
417
|
+
app_id=self.AppID,
|
418
|
+
agent_id=self.AgentID,
|
419
|
+
app_name=self.AppName,
|
420
|
+
client_id=self.ClientId,
|
421
|
+
client_secret=self.ClientSecret
|
422
|
+
)
|