dagster-dingtalk 0.1.10b2__tar.gz → 0.1.12__tar.gz
Sign up to get free protection for your applications and to get access to all the features.
- {dagster_dingtalk-0.1.10b2 → dagster_dingtalk-0.1.12}/PKG-INFO +1 -1
- {dagster_dingtalk-0.1.10b2 → dagster_dingtalk-0.1.12}/dagster_dingtalk/__init__.py +1 -0
- dagster_dingtalk-0.1.12/dagster_dingtalk/app_client.py +327 -0
- dagster_dingtalk-0.1.12/dagster_dingtalk/resources.py +413 -0
- dagster_dingtalk-0.1.12/dagster_dingtalk/version.py +1 -0
- {dagster_dingtalk-0.1.10b2 → dagster_dingtalk-0.1.12}/pyproject.toml +1 -1
- dagster_dingtalk-0.1.10b2/dagster_dingtalk/app_client.py +0 -155
- dagster_dingtalk-0.1.10b2/dagster_dingtalk/resources.py +0 -221
- dagster_dingtalk-0.1.10b2/dagster_dingtalk/version.py +0 -1
- {dagster_dingtalk-0.1.10b2 → dagster_dingtalk-0.1.12}/README.md +0 -0
- {dagster_dingtalk-0.1.10b2 → dagster_dingtalk-0.1.12}/dagster_dingtalk/operations.py +0 -0
@@ -3,6 +3,7 @@ from dagster._core.libraries import DagsterLibraryRegistry
|
|
3
3
|
|
4
4
|
from dagster_dingtalk.resources import DingTalkAppResource
|
5
5
|
from dagster_dingtalk.resources import DingTalkWebhookResource
|
6
|
+
from dagster_dingtalk.app_client import DingTalkClient as DingTalkAppClient
|
6
7
|
# from dagster_dingtalk.operations import DingTalkWebhookOp
|
7
8
|
from dagster_dingtalk.version import __version__
|
8
9
|
|
@@ -0,0 +1,327 @@
|
|
1
|
+
import logging
|
2
|
+
import pickle
|
3
|
+
import time
|
4
|
+
from datetime import datetime
|
5
|
+
from enum import Enum
|
6
|
+
from pathlib import Path
|
7
|
+
from typing import List, Literal, Dict, Tuple
|
8
|
+
from httpx import Client
|
9
|
+
|
10
|
+
|
11
|
+
# noinspection NonAsciiCharacters
|
12
|
+
class DingTalkClient:
|
13
|
+
def __init__(self, app_id: str, client_id: str, client_secret: str, app_name: str|None = None, agent_id: int|None = None):
|
14
|
+
self.app_id: str = app_id
|
15
|
+
self.app_name: str|None = app_name
|
16
|
+
self.agent_id: int|None = agent_id
|
17
|
+
self.client_id: str = client_id
|
18
|
+
self.__client_secret: str = client_secret
|
19
|
+
self.robot_code: str = client_id
|
20
|
+
|
21
|
+
access_token: str = self.__get_access_token()
|
22
|
+
self.api: Client = Client(base_url="https://api.dingtalk.com/", headers={"x-acs-dingtalk-access-token": access_token})
|
23
|
+
self.oapi: Client = Client(base_url="https://oapi.dingtalk.com/", params={"access_token": access_token})
|
24
|
+
|
25
|
+
self.智能人事 = 智能人事_API(self)
|
26
|
+
self.通讯录管理 = 通讯录管理_API(self)
|
27
|
+
self.文档文件 = 文档文件_API(self)
|
28
|
+
self.互动卡片 = 互动卡片_API(self)
|
29
|
+
|
30
|
+
def __get_access_token(self) -> str:
|
31
|
+
access_token_cache = Path("/tmp/.dingtalk_cache")
|
32
|
+
all_access_token: dict = {}
|
33
|
+
access_token: str|None = None
|
34
|
+
expire_in: int = 0
|
35
|
+
renew_reason = None
|
36
|
+
|
37
|
+
# 从缓存中读取
|
38
|
+
if not access_token_cache.exists():
|
39
|
+
renew_reason = f"鉴权缓存不存在"
|
40
|
+
else:
|
41
|
+
try:
|
42
|
+
with open(access_token_cache, 'rb') as f:
|
43
|
+
all_access_token = pickle.loads(f.read())
|
44
|
+
except Exception as e:
|
45
|
+
logging.error(e)
|
46
|
+
renew_reason = "鉴权缓存读取或解析错误"
|
47
|
+
|
48
|
+
if all_access_token:
|
49
|
+
app_access_token = all_access_token.get(self.app_id)
|
50
|
+
access_token = app_access_token.get('access_token')
|
51
|
+
expire_in = app_access_token.get('expire_in')
|
52
|
+
else:
|
53
|
+
renew_reason = f"鉴权缓存不存在该应用 {self.app_name}<{self.app_id}>"
|
54
|
+
|
55
|
+
if not access_token:
|
56
|
+
renew_reason = F"应用 {self.app_name}<{self.app_id}> 的鉴权缓存无效"
|
57
|
+
if expire_in < int(time.time()):
|
58
|
+
renew_reason = F"应用 {self.app_name}<{self.app_id}> 的鉴权缓存过期"
|
59
|
+
|
60
|
+
if renew_reason is None:
|
61
|
+
return access_token
|
62
|
+
else:
|
63
|
+
logging.warning(renew_reason)
|
64
|
+
response = Client().post(
|
65
|
+
url="https://api.dingtalk.com/v1.0/oauth2/accessToken",
|
66
|
+
json={"appKey": self.client_id, "appSecret": self.__client_secret},
|
67
|
+
)
|
68
|
+
access_token:str = response.json().get("accessToken")
|
69
|
+
expire_in:int = response.json().get("expireIn") + int(time.time()) - 60
|
70
|
+
with open(access_token_cache, 'wb') as f:
|
71
|
+
all_access_token[self.app_id] = {
|
72
|
+
"access_token": access_token,
|
73
|
+
"expire_in": expire_in,
|
74
|
+
}
|
75
|
+
f.write(pickle.dumps(all_access_token))
|
76
|
+
return access_token
|
77
|
+
|
78
|
+
# noinspection NonAsciiCharacters
|
79
|
+
class 智能人事_API:
|
80
|
+
def __init__(self, _client:DingTalkClient):
|
81
|
+
self.__client:DingTalkClient = _client
|
82
|
+
self.花名册 = 智能人事_花名册_API(_client)
|
83
|
+
self.员工管理 = 智能人事_员工管理_API(_client)
|
84
|
+
|
85
|
+
# noinspection NonAsciiCharacters
|
86
|
+
class 智能人事_花名册_API:
|
87
|
+
def __init__(self, _client:DingTalkClient):
|
88
|
+
self.__client:DingTalkClient = _client
|
89
|
+
|
90
|
+
def 获取花名册元数据(self) -> dict:
|
91
|
+
response = self.__client.oapi.post(
|
92
|
+
url="/topapi/smartwork/hrm/roster/meta/get",
|
93
|
+
json={"agentid": self.__client.agent_id},
|
94
|
+
)
|
95
|
+
return response.json()
|
96
|
+
|
97
|
+
def 获取员工花名册字段信息(self, user_id_list:List[str], field_filter_list:List[str]|None = None, text_to_select_convert:bool|None = None) -> dict:
|
98
|
+
body_dict = {"userIdList": user_id_list, "appAgentId": self.__client.agent_id}
|
99
|
+
if field_filter_list is not None:
|
100
|
+
body_dict["fieldFilterList"] = field_filter_list
|
101
|
+
if text_to_select_convert is not None:
|
102
|
+
body_dict["text2SelectConvert"] = text_to_select_convert
|
103
|
+
|
104
|
+
response = self.__client.api.post(url="/topapi/smartwork/hrm/roster/meta/get", json=body_dict, )
|
105
|
+
return response.json()
|
106
|
+
|
107
|
+
# noinspection NonAsciiCharacters
|
108
|
+
class 智能人事_员工管理_API:
|
109
|
+
def __init__(self, _client:DingTalkClient):
|
110
|
+
self.__client:DingTalkClient = _client
|
111
|
+
|
112
|
+
# noinspection NonAsciiCharacters
|
113
|
+
class 在职员工状态(Enum):
|
114
|
+
试用期: '2'
|
115
|
+
正式: '3'
|
116
|
+
待离职: '5'
|
117
|
+
无状态: '-1'
|
118
|
+
|
119
|
+
def 获取待入职员工列表(self, offset:int, size:int) -> dict:
|
120
|
+
response = self.__client.oapi.post(
|
121
|
+
"/topapi/smartwork/hrm/employee/querypreentry",
|
122
|
+
json={"offset": offset, "size": size},
|
123
|
+
)
|
124
|
+
return response.json()
|
125
|
+
|
126
|
+
def 获取在职员工列表(self, status_list:List[在职员工状态], offset:int, size:int) -> dict:
|
127
|
+
response = self.__client.oapi.post(
|
128
|
+
"/topapi/smartwork/hrm/employee/querypreentry",
|
129
|
+
json={"status_list": status_list, "offset": offset, "size": size},
|
130
|
+
)
|
131
|
+
return response.json()
|
132
|
+
|
133
|
+
def 获取离职员工列表(self, next_token:int, max_results:int) -> dict:
|
134
|
+
response = self.__client.api.get(
|
135
|
+
"/v1.0/hrm/employees/dismissions",
|
136
|
+
params={"nextToken": next_token, "maxResults": max_results},
|
137
|
+
)
|
138
|
+
return response.json()
|
139
|
+
|
140
|
+
def 批量获取员工离职信息(self, user_id_list:List[str]) -> dict:
|
141
|
+
response = self.__client.api.get(
|
142
|
+
"/v1.0/hrm/employees/dimissionInfo",
|
143
|
+
params={"userIdList": user_id_list},
|
144
|
+
)
|
145
|
+
return response.json()
|
146
|
+
|
147
|
+
# noinspection NonAsciiCharacters
|
148
|
+
class 通讯录管理_API:
|
149
|
+
def __init__(self, _client:DingTalkClient):
|
150
|
+
self.__client:DingTalkClient = _client
|
151
|
+
self.用户管理 = 通讯录管理_用户管理_API(_client)
|
152
|
+
self.部门管理 = 通讯录管理_部门管理_API(_client)
|
153
|
+
|
154
|
+
def 查询用户详情(self, user_id:str, language:str = "zh_CN"):
|
155
|
+
return self.用户管理.查询用户详情(user_id, language)
|
156
|
+
|
157
|
+
# noinspection NonAsciiCharacters
|
158
|
+
class 通讯录管理_用户管理_API:
|
159
|
+
def __init__(self, _client:DingTalkClient):
|
160
|
+
self.__client:DingTalkClient = _client
|
161
|
+
|
162
|
+
def 查询用户详情(self, user_id:str, language:str = "zh_CN") -> dict:
|
163
|
+
response = self.__client.oapi.post(url="/topapi/v2/user/get", json={"language": language, "userid": user_id})
|
164
|
+
return response.json()
|
165
|
+
|
166
|
+
def 查询离职记录列表(self, start_time:datetime, end_time:datetime|None, next_token:str, max_results:int) -> dict:
|
167
|
+
params = {"startTime": start_time.strftime("%Y-%m-%dT%H:%M:%SZ"), "nextToken": next_token, "maxResults": max_results}
|
168
|
+
if end_time is not None:
|
169
|
+
params["endTime"] = end_time.strftime("%Y-%m-%dT%H:%M:%SZ")
|
170
|
+
response = self.__client.api.get(url="/v1.0/contact/empLeaveRecords", params=params)
|
171
|
+
return response.json()
|
172
|
+
|
173
|
+
# noinspection NonAsciiCharacters
|
174
|
+
class 通讯录管理_部门管理_API:
|
175
|
+
def __init__(self, _client:DingTalkClient):
|
176
|
+
self.__client:DingTalkClient = _client
|
177
|
+
|
178
|
+
def 获取部门详情(self, dept_id: int, language:str = "zh_CN") -> dict:
|
179
|
+
"""
|
180
|
+
调用本接口,根据部门ID获取指定部门详情。
|
181
|
+
|
182
|
+
https://open.dingtalk.com/document/orgapp/query-department-details0-v2
|
183
|
+
|
184
|
+
:param dept_id: 部门 ID ,根部门 ID 为 1。
|
185
|
+
:param language: 通讯录语言。zh_CN en_US
|
186
|
+
"""
|
187
|
+
response = self.__client.oapi.post(
|
188
|
+
url="/topapi/v2/department/get",
|
189
|
+
json={"language": language, "dept_id": dept_id}
|
190
|
+
)
|
191
|
+
return response.json()
|
192
|
+
|
193
|
+
def 获取部门列表(self, dept_id: int, language:str = "zh_CN"):
|
194
|
+
"""
|
195
|
+
调用本接口,获取下一级部门基础信息。
|
196
|
+
|
197
|
+
https://open.dingtalk.com/document/orgapp/obtain-the-department-list-v2
|
198
|
+
|
199
|
+
:param dept_id: 部门 ID ,根部门 ID 为 1。
|
200
|
+
:param language: 通讯录语言。zh_CN en_US
|
201
|
+
"""
|
202
|
+
response = self.__client.oapi.post(
|
203
|
+
url="/topapi/v2/department/listsub",
|
204
|
+
json={"language": language, "dept_id": dept_id}
|
205
|
+
)
|
206
|
+
return response.json()
|
207
|
+
|
208
|
+
# noinspection NonAsciiCharacters
|
209
|
+
class 文档文件_API:
|
210
|
+
def __init__(self, _client:DingTalkClient):
|
211
|
+
self.__client:DingTalkClient = _client
|
212
|
+
self.媒体文件 = 文档文件_媒体文件_API(_client)
|
213
|
+
|
214
|
+
# noinspection NonAsciiCharacters
|
215
|
+
class 文档文件_媒体文件_API:
|
216
|
+
def __init__(self, _client:DingTalkClient):
|
217
|
+
self.__client:DingTalkClient = _client
|
218
|
+
|
219
|
+
def 上传媒体文件(self, file_path:Path|str, media_type:Literal['image', 'voice', 'video', 'file']) -> dict:
|
220
|
+
"""
|
221
|
+
调用本接口,上传图片、语音媒体资源文件以及普通文件,接口返回媒体资源标识 media_id。
|
222
|
+
|
223
|
+
https://open.dingtalk.com/document/orgapp/upload-media-files
|
224
|
+
|
225
|
+
:param file_path: 本地文件路径
|
226
|
+
:param media_type: 媒体类型,支持 'image', 'voice', 'video', 'file'
|
227
|
+
|
228
|
+
:return:
|
229
|
+
{
|
230
|
+
"errcode": 0,
|
231
|
+
"errmsg": "ok",
|
232
|
+
"media_id": "$iAEKAqDBgTNAk",
|
233
|
+
"created_at": 1605863153573,
|
234
|
+
"type": "image"
|
235
|
+
}
|
236
|
+
"""
|
237
|
+
with open(file_path, 'rb') as f:
|
238
|
+
response = self.__client.oapi.post(url=f"/media/upload?type={media_type}", files={'media': f})
|
239
|
+
return response.json()
|
240
|
+
|
241
|
+
# noinspection NonAsciiCharacters
|
242
|
+
class 互动卡片_API:
|
243
|
+
def __init__(self, _client:DingTalkClient):
|
244
|
+
self.__client:DingTalkClient = _client
|
245
|
+
|
246
|
+
def 创建并投放卡片(
|
247
|
+
self, search_type_name: str, search_desc: str, card_template_id: str, card_param_map: dict,
|
248
|
+
alert_content: str, open_space_ids: List[str], out_track_id: str, support_forward: bool = True,
|
249
|
+
call_back_type: str = "STREAM", expired_time_millis:int = 0
|
250
|
+
) -> dict:
|
251
|
+
"""
|
252
|
+
创建并投放卡片。当前仅支持 IM群聊, IM机器人单聊, 吊顶 三种场域类型。
|
253
|
+
|
254
|
+
https://open.dingtalk.com/document/orgapp/create-and-deliver-cards
|
255
|
+
|
256
|
+
:param card_template_id: 卡片模板 ID
|
257
|
+
:param open_space_ids: 卡片投放场域 Id
|
258
|
+
:param out_track_id: 卡片唯一标识
|
259
|
+
:param card_param_map: 卡片数据
|
260
|
+
:param search_type_name: 卡片类型名
|
261
|
+
:param search_desc: 卡片消息展示
|
262
|
+
:param alert_content: 通知内容
|
263
|
+
:param call_back_type: 回调模式
|
264
|
+
:param support_forward: 是否支持转发
|
265
|
+
:param expired_time_millis: 吊顶投放过期时间。当投放内容为吊顶时必须传参。
|
266
|
+
"""
|
267
|
+
|
268
|
+
open_space_id = f"dtv1.card//{';'.join(open_space_ids)}"
|
269
|
+
|
270
|
+
payload = {
|
271
|
+
"cardTemplateId": card_template_id,
|
272
|
+
"outTrackId": out_track_id,
|
273
|
+
"openSpaceId": open_space_id,
|
274
|
+
"callbackType": call_back_type,
|
275
|
+
"cardData": {"cardParamMap": card_param_map}
|
276
|
+
}
|
277
|
+
|
278
|
+
open_space_model = {
|
279
|
+
"supportForward": support_forward,
|
280
|
+
"searchSupport": {"searchTypeName": search_type_name, "searchDesc": search_desc},
|
281
|
+
"notification": {"alertContent": alert_content, "notificationOff": False}
|
282
|
+
}
|
283
|
+
|
284
|
+
if 'IM_GROUP' in open_space_id.upper():
|
285
|
+
payload["imGroupOpenSpaceModel"] = open_space_model
|
286
|
+
payload["imGroupOpenDeliverModel"] = {"robotCode": self.__client.robot_code}
|
287
|
+
|
288
|
+
if 'IM_ROBOT' in open_space_id.upper():
|
289
|
+
payload["imRobotOpenSpaceModel"] = open_space_model
|
290
|
+
payload["imRobotOpenDeliverModel"] = {"spaceType": "IM_ROBOT", "robotCode": self.__client.robot_code}
|
291
|
+
|
292
|
+
if 'ONE_BOX' in open_space_id.upper():
|
293
|
+
if expired_time_millis == 0:
|
294
|
+
expired_time_millis = int(time.time()+3600)*1000
|
295
|
+
payload["topOpenSpaceModel"] = {"spaceType": "ONE_BOX"}
|
296
|
+
payload["topOpenDeliverModel"] = {"platforms": ["android","ios","win","mac"], "expiredTimeMillis": expired_time_millis,}
|
297
|
+
|
298
|
+
response = self.__client.api.post(
|
299
|
+
url="/v1.0/card/instances/createAndDeliver",
|
300
|
+
json=payload
|
301
|
+
)
|
302
|
+
|
303
|
+
return response.json()
|
304
|
+
|
305
|
+
def 更新卡片(self, out_track_id: str, card_param_map: dict, update_card_data_by_key:bool=True) -> dict:
|
306
|
+
"""
|
307
|
+
调用本接口,实现主动更新卡片数据。
|
308
|
+
|
309
|
+
https://open.dingtalk.com/document/orgapp/interactive-card-update-interface
|
310
|
+
|
311
|
+
:param out_track_id: 外部卡片实例Id。
|
312
|
+
:param card_param_map: 卡片模板内容。
|
313
|
+
:param update_card_data_by_key: True-按 key 更新 cardData 数据 False-覆盖更新 cardData 数据
|
314
|
+
:return:
|
315
|
+
{success: bool, result: bool}
|
316
|
+
"""
|
317
|
+
|
318
|
+
response = self.__client.api.put(
|
319
|
+
url="/v1.0/card/instances",
|
320
|
+
json={
|
321
|
+
"outTrackId": out_track_id,
|
322
|
+
"cardData": {"cardParamMap": card_param_map},
|
323
|
+
"cardUpdateOptions": {"updateCardDataByKey": update_card_data_by_key}
|
324
|
+
}
|
325
|
+
)
|
326
|
+
|
327
|
+
return response.json()
|
@@ -0,0 +1,413 @@
|
|
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
|
+
如提供别名,可以在使用 `MultiDingTalkWebhookResource` 中使用别名进行 webhook 选择。默认值为 None。
|
52
|
+
- **base_url** (str, optional):
|
53
|
+
通用地址,一般无需更改。默认值为 “https://oapi.dingtalk.com/robot/send”。
|
54
|
+
|
55
|
+
### 用例:
|
56
|
+
|
57
|
+
1. 使用单个资源:
|
58
|
+
|
59
|
+
```python
|
60
|
+
from dagster_dingtalk import DingTalkWebhookResource
|
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
|
+
result = dingtalk.send_text(text)
|
66
|
+
|
67
|
+
@job
|
68
|
+
def job_send_text():
|
69
|
+
op_send_text
|
70
|
+
|
71
|
+
defs = Definitions(
|
72
|
+
jobs=job_user_info,
|
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
|
+
|
82
|
+
@op(required_resource_keys={"dingtalk_webhook"}, ins={"text": In(str)})
|
83
|
+
def op_send_text(context:OpExecutionContext, text:str):
|
84
|
+
dingtalk_webhook:DingTalkWebhookResource = context.resources.dingtalk_webhook
|
85
|
+
result = dingtalk.send_text(text)
|
86
|
+
|
87
|
+
@job
|
88
|
+
def job_send_text():
|
89
|
+
op_send_text
|
90
|
+
|
91
|
+
dingtalk_webhooks = {
|
92
|
+
"Group1" : DingTalkWebhookResource(access_token="<access_token>", secret="<secret>", alias="Group1"),
|
93
|
+
"Group2" : DingTalkWebhookResource(access_token="<access_token>", secret="<secret>", alias="Group2")
|
94
|
+
}
|
95
|
+
|
96
|
+
defs = Definitions(jobs=job_send_text, resources={"dingtalk_webhook": DingTalkWebhookResource.configure_at_launch()})
|
97
|
+
|
98
|
+
@schedule(cron_schedule="20 9 * * *", job=job_send_text)
|
99
|
+
def schedule_user_info():
|
100
|
+
return RunRequest(run_config=RunConfig(
|
101
|
+
ops={"op_send_text": {"inputs": {"text": "This a test text."}}},
|
102
|
+
resources={"dingtalk": dingtalk_webhooks["Group1"]},
|
103
|
+
))
|
104
|
+
```
|
105
|
+
|
106
|
+
### 注意:
|
107
|
+
|
108
|
+
应该永远避免直接将密钥字符串直接配置给资源,这会导致在 dagster 前端用户界面暴露密钥。
|
109
|
+
应当从环境变量中读取密钥。你可以在代码中注册临时的环境变量,或从系统中引入环境变量。
|
110
|
+
|
111
|
+
```python
|
112
|
+
import os
|
113
|
+
from dagster_dingtalk import DingTalkWebhookResource
|
114
|
+
|
115
|
+
# 直接在代码中注册临时的环境变量
|
116
|
+
os.environ.update({'access_token': "<access_token>"})
|
117
|
+
os.environ.update({'secret': "<secret>"})
|
118
|
+
|
119
|
+
webhook = DingTalkWebhookResource(access_token=EnvVar("access_token"), secret=EnvVar("secret"))
|
120
|
+
|
121
|
+
|
122
|
+
"""
|
123
|
+
|
124
|
+
access_token: str = Field(description="Webhook地址中的 access_token 部分")
|
125
|
+
secret: Optional[str] = Field(default=None, description="如使用加签安全配置,需传签名密钥")
|
126
|
+
alias: Optional[str] = Field(default=None, description="别名,标记用,无实际意义")
|
127
|
+
base_url: str = Field(default="https://oapi.dingtalk.com/robot/send", description="Webhook的通用地址,无需更改")
|
128
|
+
|
129
|
+
def webhook_url(self):
|
130
|
+
"""
|
131
|
+
实时生成加签或未加签的 Webhook URL。
|
132
|
+
|
133
|
+
钉钉API文档:
|
134
|
+
https://open.dingtalk.com/document/robots/custom-robot-access
|
135
|
+
|
136
|
+
:return:
|
137
|
+
str: Webhook URL
|
138
|
+
"""
|
139
|
+
if self.secret is None:
|
140
|
+
return f"{self.base_url}?access_token={self.access_token}"
|
141
|
+
else:
|
142
|
+
timestamp = round(time.time() * 1000)
|
143
|
+
hmac_code = hmac.new(
|
144
|
+
self.secret.encode('utf-8'), f'{timestamp}\n{self.secret}'.encode('utf-8'), digestmod=hashlib.sha256
|
145
|
+
).digest()
|
146
|
+
sign = urllib.parse.quote_plus(base64.b64encode(hmac_code))
|
147
|
+
return f"{self.base_url}?access_token={self.access_token}×tamp={timestamp}&sign={sign}"
|
148
|
+
|
149
|
+
@staticmethod
|
150
|
+
def _gen_title(text):
|
151
|
+
"""
|
152
|
+
从文本截取前12个字符作为标题,并清理其中的 Markdown 格式字符。
|
153
|
+
|
154
|
+
:param text: 原文
|
155
|
+
|
156
|
+
:return:
|
157
|
+
str: 标题
|
158
|
+
"""
|
159
|
+
return re.sub(r'[\n#>* ]', '', text[:12])
|
160
|
+
|
161
|
+
@staticmethod
|
162
|
+
def __handle_response(response):
|
163
|
+
"""
|
164
|
+
处理钉钉 Webhook API 响应,根据 errcode 抛出相应的异常
|
165
|
+
|
166
|
+
:param response: 钉钉Webhook API响应的JSON数据
|
167
|
+
"""
|
168
|
+
errcode = response.json().get("errcode")
|
169
|
+
errmsg = response.json().get("errmsg")
|
170
|
+
|
171
|
+
if errcode == "0":
|
172
|
+
return True
|
173
|
+
else:
|
174
|
+
raise DingTalkWebhookException(errcode, errmsg)
|
175
|
+
|
176
|
+
def send_text(self, text: str,
|
177
|
+
at_mobiles:List[str]|None = None, at_user_ids:List[str]|None = None, at_all:bool = False):
|
178
|
+
"""
|
179
|
+
发送文本消息。
|
180
|
+
|
181
|
+
钉钉API文档:
|
182
|
+
https://open.dingtalk.com/document/orgapp/custom-bot-send-message-type
|
183
|
+
|
184
|
+
:param str text: 待发送文本
|
185
|
+
:param List[str],optional at_mobiles: 需要 @ 的用户手机号。默认值为 None
|
186
|
+
:param List[str],optional at_user_ids: 需要 @ 的用户 UserID。默认值为 None
|
187
|
+
:param bool,optional at_all: 是否 @ 所有人。默认值为 False
|
188
|
+
|
189
|
+
:raise DingTalkWebhookException:
|
190
|
+
|
191
|
+
"""
|
192
|
+
at = {"isAtAll": at_all}
|
193
|
+
if at_user_ids:
|
194
|
+
at["atUserIds"] = at_user_ids
|
195
|
+
if at_mobiles:
|
196
|
+
at["atMobiles"] = at_mobiles
|
197
|
+
response = httpx.post(url=self.webhook_url(), json={"msgtype": "text", "text": {"content": text}, "at": at})
|
198
|
+
self.__handle_response(response)
|
199
|
+
|
200
|
+
def send_link(self, text: str, message_url:str, title:str|None = None, pic_url:str = ""):
|
201
|
+
"""
|
202
|
+
发送 Link 消息。
|
203
|
+
|
204
|
+
钉钉API文档:
|
205
|
+
https://open.dingtalk.com/document/orgapp/custom-bot-send-message-type
|
206
|
+
|
207
|
+
:param str text: 待发送文本
|
208
|
+
:param str message_url: 链接的 Url
|
209
|
+
:param str,optional title: 标题,在通知和被引用时显示的简短信息。默认从文本中生成。
|
210
|
+
:param str,optional pic_url: 图片的 Url,默认为 None
|
211
|
+
|
212
|
+
:raise DingTalkWebhookException:
|
213
|
+
"""
|
214
|
+
title = title or self._gen_title(text)
|
215
|
+
response = httpx.post(
|
216
|
+
url=self.webhook_url(),
|
217
|
+
json={"msgtype": "link", "link": {"title": title, "text": text, "picUrl": pic_url, "messageUrl": message_url}}
|
218
|
+
)
|
219
|
+
self.__handle_response(response)
|
220
|
+
|
221
|
+
def send_markdown(self, text: List[str]|str, title:str|None = None,
|
222
|
+
at_mobiles:List[str]|None = None, at_user_ids:List[str]|None = None, at_all:bool = False):
|
223
|
+
"""
|
224
|
+
发送 Markdown 消息。支持的语法有:
|
225
|
+
# 一级标题 ## 二级标题 ### 三级标题 #### 四级标题 ##### 五级标题 ###### 六级标题
|
226
|
+
> 引用 **加粗** *斜体*
|
227
|
+
[链接跳转](https://example.com/doc.html)
|
228
|
+
![图片预览](https://example.com/pic.jpg)
|
229
|
+
- 无序列表
|
230
|
+
1. 有序列表
|
231
|
+
|
232
|
+
钉钉API文档:
|
233
|
+
https://open.dingtalk.com/document/orgapp/custom-bot-send-message-type
|
234
|
+
|
235
|
+
:param str text: 待发送文本
|
236
|
+
:param str,optional title: 标题,在通知和被引用时显示的简短信息。默认从文本中生成。
|
237
|
+
:param List[str],optional at_mobiles: 需要 @ 的用户手机号。默认值为 None
|
238
|
+
:param List[str],optional at_user_ids: 需要 @ 的用户 UserID。默认值为 None
|
239
|
+
:param bool,optional at_all: 是否 @ 所有人。默认值为 False
|
240
|
+
|
241
|
+
:raise DingTalkWebhookException:
|
242
|
+
"""
|
243
|
+
text = text if isinstance(text, str) else "\n\n".join(text)
|
244
|
+
title = title or self._gen_title(text)
|
245
|
+
at = {"isAtAll": at_all}
|
246
|
+
if at_user_ids:
|
247
|
+
at["atUserIds"] = at_user_ids
|
248
|
+
if at_mobiles:
|
249
|
+
at["atMobiles"] = at_mobiles
|
250
|
+
response = httpx.post(url=self.webhook_url(),json={"msgtype": "markdown", "markdown": {"title": title, "text": text}, "at": at})
|
251
|
+
self.__handle_response(response)
|
252
|
+
|
253
|
+
def send_action_card(self, text: List[str]|str, title:str|None = None, btn_orientation:Literal["0","1"] = "0",
|
254
|
+
single_jump:Tuple[str,str]|None = None, btns_jump:List[Tuple[str,str]]|None = None):
|
255
|
+
"""
|
256
|
+
发送跳转 ActionCard 消息。
|
257
|
+
|
258
|
+
**注意:**
|
259
|
+
同时传 `single_jump` 和 `btns_jump`,仅 `single_jump` 生效。
|
260
|
+
|
261
|
+
:param str text: 待发送文本,支持 Markdown 部分语法。
|
262
|
+
:param str,optional title: 标题,在通知和被引用时显示的简短信息。默认从文本中生成。
|
263
|
+
:param str,optional btn_orientation: 按钮排列方式,0-按钮竖直排列,1-按钮横向排列。默认值为 "0"
|
264
|
+
:param Tuple[str,str],optional single_jump: 传此参数为单个按钮,元组内第一项为按钮的标题,第二项为按钮链接。
|
265
|
+
:param Tuple[str,str],optional btns_jump: 传此参数为多个按钮,元组内第一项为按钮的标题,第二项为按钮链接。
|
266
|
+
|
267
|
+
:raise DingTalkWebhookException:
|
268
|
+
"""
|
269
|
+
text = text if isinstance(text, str) else "\n\n".join(text)
|
270
|
+
title = title or self._gen_title(text)
|
271
|
+
action_card = {"title": title, "text": text, "btnOrientation": str(btn_orientation)}
|
272
|
+
|
273
|
+
if single_jump:
|
274
|
+
action_card["singleTitle"], action_card["singleURL"] = single_jump
|
275
|
+
if btns_jump:
|
276
|
+
action_card["btns"] = [{"title": action_title, "actionURL": action_url} for action_title, action_url in btns_jump]
|
277
|
+
|
278
|
+
response = httpx.post(url=self.webhook_url(), json={"msgtype": "actionCard", "actionCard": action_card})
|
279
|
+
self.__handle_response(response)
|
280
|
+
|
281
|
+
def send_feed_card(self, *args:Tuple[str,str,str]):
|
282
|
+
"""
|
283
|
+
发送 FeedCard 消息。
|
284
|
+
|
285
|
+
:param Tuple[str,str,str],optional args: 可以传入任意个具有三个元素的元组,分别为 `(标题, 跳转链接, 缩略图链接)`
|
286
|
+
|
287
|
+
:raise DingTalkWebhookException:
|
288
|
+
"""
|
289
|
+
for a in args:
|
290
|
+
print(a)
|
291
|
+
links_data = [
|
292
|
+
{"title": title, "messageURL": message_url, "picURL": pic_url}
|
293
|
+
for title, message_url, pic_url in args
|
294
|
+
]
|
295
|
+
response = httpx.post(url=self.webhook_url(), json={"msgtype": "feedCard", "feedCard": {"links": links_data}})
|
296
|
+
self.__handle_response(response)
|
297
|
+
|
298
|
+
|
299
|
+
class DingTalkAppResource(ConfigurableResource):
|
300
|
+
"""
|
301
|
+
[钉钉服务端 API](https://open.dingtalk.com/document/orgapp/api-overview) 企业内部应用部分的第三方封装。
|
302
|
+
|
303
|
+
通过此资源,可以调用部分钉钉服务端 API。具体封装的 API 可以在 IDE 中通过引入 `DingTalkAppClient` 类来查看 IDE 提示:
|
304
|
+
|
305
|
+
`from dagster_dingtalk import DingTalkAppClient`
|
306
|
+
|
307
|
+
### 配置项:
|
308
|
+
|
309
|
+
- **AppID** (str):
|
310
|
+
应用应用唯一标识 AppID,作为缓存标识符使用。不传入则不缓存鉴权。
|
311
|
+
- **AgentID** (int, optional):
|
312
|
+
原企业内部应用 AgentId ,部分 API 会使用到。默认值为 None
|
313
|
+
- **AppName** (str, optional):
|
314
|
+
应用名。
|
315
|
+
- **ClientId** (str):
|
316
|
+
应用的 Client ID ,原 AppKey 和 SuiteKey
|
317
|
+
- **ClientSecret** (str):
|
318
|
+
应用的 Client Secret ,原 AppSecret 和 SuiteSecret
|
319
|
+
|
320
|
+
### 用例:
|
321
|
+
|
322
|
+
1. 使用单一的企业内部应用资源。
|
323
|
+
|
324
|
+
```python
|
325
|
+
from dagster_dingtalk import DingTalkAppResource
|
326
|
+
|
327
|
+
@op(required_resource_keys={"dingtalk"}, ins={"user_id": In(str)})
|
328
|
+
def op_user_info(context:OpExecutionContext, user_id:str):
|
329
|
+
dingtalk:DingTalkAppClient = context.resources.dingtalk
|
330
|
+
result = dingtalk.通讯录管理.用户管理.查询用户详情(user_id).get('result')
|
331
|
+
context.log.info(result)
|
332
|
+
|
333
|
+
@job
|
334
|
+
def job_user_info():
|
335
|
+
op_user_info
|
336
|
+
|
337
|
+
defs = Definitions(jobs=job_user_info, resources={
|
338
|
+
"dingtalk": DingTalkAppResource(
|
339
|
+
AppID = "<the-app-id>",
|
340
|
+
ClientId = "<the-client-id>",
|
341
|
+
ClientSecret = EnvVar("<the-client-secret-env-name>"),
|
342
|
+
)
|
343
|
+
})
|
344
|
+
```
|
345
|
+
|
346
|
+
2. 启动时动态构建企业内部应用资源, 可参考 [Dagster文档 | 在启动时配置资源](https://docs.dagster.io/concepts/resources#configuring-resources-at-launch-time)
|
347
|
+
|
348
|
+
```python
|
349
|
+
from dagster_dingtalk import DingTalkAppResource
|
350
|
+
|
351
|
+
@op(required_resource_keys={"dingtalk"}, ins={"user_id": In(str)})
|
352
|
+
def op_user_info(context:OpExecutionContext, user_id:str):
|
353
|
+
dingtalk:DingTalkAppClient = context.resources.dingtalk
|
354
|
+
result = dingtalk.通讯录管理.用户管理.查询用户详情(user_id).get('result')
|
355
|
+
context.log.info(result)
|
356
|
+
|
357
|
+
@job
|
358
|
+
def job_user_info():
|
359
|
+
op_user_info()
|
360
|
+
|
361
|
+
dingtalk_apps = {
|
362
|
+
"App1" : DingTalkAppResource(
|
363
|
+
AppID = "<app-1-app-id>",
|
364
|
+
ClientId = "<app-1-client-id>",
|
365
|
+
ClientSecret = EnvVar("<app-1-client-secret-env-name>"),
|
366
|
+
),
|
367
|
+
"App2" : DingTalkAppResource(
|
368
|
+
AppID = "<app-2-app-id>",
|
369
|
+
ClientId = "<app-2-client-id>",
|
370
|
+
ClientSecret = EnvVar("<app-2-client-secret-env-name>"),
|
371
|
+
)
|
372
|
+
}
|
373
|
+
|
374
|
+
defs = Definitions(jobs=job_user_info, resources={"dingtalk": DingTalkAppResource.configure_at_launch()})
|
375
|
+
|
376
|
+
@schedule(cron_schedule="20 9 * * *", job=job_user_info)
|
377
|
+
def schedule_user_info():
|
378
|
+
return RunRequest(run_config=RunConfig(
|
379
|
+
ops={"op_user_info": {"inputs": {"user_id": "<the-user-id>"}}},
|
380
|
+
resources={"dingtalk": dingtalk_apps["App1"]},
|
381
|
+
))
|
382
|
+
```
|
383
|
+
|
384
|
+
### 注意:
|
385
|
+
|
386
|
+
应该永远避免直接将密钥字符串直接配置给资源,这会导致在 dagster 前端用户界面暴露密钥。
|
387
|
+
应当从环境变量中读取密钥。你可以在代码中注册临时的环境变量,或从系统中引入环境变量。
|
388
|
+
|
389
|
+
"""
|
390
|
+
|
391
|
+
AppID: str = Field(description="应用应用唯一标识 AppID,作为缓存标识符使用。不传入则不缓存鉴权。")
|
392
|
+
AgentID: Optional[int] = Field(default=None, description="原企业内部应用AgentId ,部分API会使用到。")
|
393
|
+
AppName: Optional[str] = Field(default=None, description="应用名。")
|
394
|
+
ClientId: str = Field(description="应用的 Client ID (原 AppKey 和 SuiteKey)")
|
395
|
+
ClientSecret: str = Field(description="应用的 Client Secret (原 AppSecret 和 SuiteSecret)")
|
396
|
+
|
397
|
+
@classmethod
|
398
|
+
def _is_dagster_maintained(cls) -> bool:
|
399
|
+
return False
|
400
|
+
|
401
|
+
def create_resource(self, context: InitResourceContext) -> DingTalkClient:
|
402
|
+
"""
|
403
|
+
返回一个 `DingTalkClient` 实例。
|
404
|
+
:param context:
|
405
|
+
:return:
|
406
|
+
"""
|
407
|
+
return DingTalkClient(
|
408
|
+
app_id=self.AppID,
|
409
|
+
agent_id=self.AgentID,
|
410
|
+
app_name=self.AppName,
|
411
|
+
client_id=self.ClientId,
|
412
|
+
client_secret=self.ClientSecret
|
413
|
+
)
|
@@ -0,0 +1 @@
|
|
1
|
+
__version__ = "0.1.12"
|
@@ -1,155 +0,0 @@
|
|
1
|
-
import logging
|
2
|
-
import pickle
|
3
|
-
import time
|
4
|
-
from datetime import datetime
|
5
|
-
from enum import Enum
|
6
|
-
from pathlib import Path
|
7
|
-
from typing import List, Literal, Dict, Tuple
|
8
|
-
from httpx import Client
|
9
|
-
|
10
|
-
|
11
|
-
# noinspection NonAsciiCharacters
|
12
|
-
class DingTalkClient:
|
13
|
-
def __init__(self, app_id: str, client_id: str, client_secret: str, app_name: str|None = None, agent_id: int|None = None):
|
14
|
-
self.app_id: str = app_id
|
15
|
-
self.app_name: str|None = app_name
|
16
|
-
self.agent_id: int|None = agent_id
|
17
|
-
self.client_id: str = client_id
|
18
|
-
self.client_secret: str = client_secret
|
19
|
-
self.robot_code: str = client_id
|
20
|
-
|
21
|
-
access_token: str = self._get_access_token()
|
22
|
-
self.api: Client = Client(base_url="https://api.dingtalk.com/", headers={"x-acs-dingtalk-access-token": access_token})
|
23
|
-
self.oapi: Client = Client(base_url="https://oapi.dingtalk.com/", params={"access_token": access_token})
|
24
|
-
|
25
|
-
self.智能人事 = 智能人事_API(self)
|
26
|
-
self.通讯录管理 = 通讯录管理_API(self)
|
27
|
-
self.文档文件 = 文档文件_API(self)
|
28
|
-
|
29
|
-
def _get_access_token(self) -> str:
|
30
|
-
access_token_cache = Path("/tmp/.dingtalk_cache")
|
31
|
-
|
32
|
-
try:
|
33
|
-
with open(access_token_cache, 'rb') as f:
|
34
|
-
all_access_token: Dict[str, Tuple[str, int]] = pickle.loads(f.read())
|
35
|
-
access_token, expire_in = all_access_token.get(self.app_id, ('', 0))
|
36
|
-
except Exception as e:
|
37
|
-
print(e)
|
38
|
-
all_access_token = {}
|
39
|
-
access_token, expire_in = all_access_token.get(self.app_id, ('', 0))
|
40
|
-
|
41
|
-
if access_token and expire_in < int(time.time()):
|
42
|
-
return access_token
|
43
|
-
else:
|
44
|
-
logging.warning(f"应用{self.app_name}<{self.app_id}> 鉴权缓存过期或不存在,正在重新获取...")
|
45
|
-
response = Client().post(
|
46
|
-
url="https://api.dingtalk.com/v1.0/oauth2/accessToken",
|
47
|
-
json={"appKey": self.client_id, "appSecret": self.client_secret},
|
48
|
-
)
|
49
|
-
access_token:str = response.json().get("accessToken")
|
50
|
-
expire_in:int = response.json().get("expireIn") + int(time.time()) - 60
|
51
|
-
with open(access_token_cache, 'wb') as f:
|
52
|
-
all_access_token[self.app_id] = (access_token, expire_in)
|
53
|
-
f.write(pickle.dumps(all_access_token))
|
54
|
-
return access_token
|
55
|
-
|
56
|
-
|
57
|
-
# noinspection NonAsciiCharacters
|
58
|
-
class 智能人事_API:
|
59
|
-
def __init__(self, _client:DingTalkClient):
|
60
|
-
self.花名册 = 智能人事_花名册_API(_client)
|
61
|
-
self.员工管理 = 智能人事_员工管理_API(_client)
|
62
|
-
|
63
|
-
# noinspection NonAsciiCharacters
|
64
|
-
class 智能人事_花名册_API:
|
65
|
-
def __init__(self, _client:DingTalkClient):
|
66
|
-
self.__client:DingTalkClient = _client
|
67
|
-
|
68
|
-
def 获取花名册元数据(self) -> dict:
|
69
|
-
response = self.__client.oapi.post(
|
70
|
-
url="/topapi/smartwork/hrm/roster/meta/get",
|
71
|
-
json={"agentid": self.__client.agent_id},
|
72
|
-
)
|
73
|
-
return response.json()
|
74
|
-
|
75
|
-
def 获取员工花名册字段信息(self, user_id_list:List[str], field_filter_list:List[str]|None = None, text_to_select_convert:bool|None = None) -> dict:
|
76
|
-
body_dict = {"userIdList": user_id_list, "appAgentId": self.__client.agent_id}
|
77
|
-
if field_filter_list is not None:
|
78
|
-
body_dict["fieldFilterList"] = field_filter_list
|
79
|
-
if text_to_select_convert is not None:
|
80
|
-
body_dict["text2SelectConvert"] = text_to_select_convert
|
81
|
-
|
82
|
-
response = self.__client.api.post(url="/topapi/smartwork/hrm/roster/meta/get", json=body_dict, )
|
83
|
-
return response.json()
|
84
|
-
|
85
|
-
# noinspection NonAsciiCharacters
|
86
|
-
class 智能人事_员工管理_API:
|
87
|
-
|
88
|
-
def __init__(self, _client:DingTalkClient):
|
89
|
-
self.__client:DingTalkClient = _client
|
90
|
-
|
91
|
-
# noinspection NonAsciiCharacters
|
92
|
-
class 在职员工状态(Enum):
|
93
|
-
试用期: '2'
|
94
|
-
正式: '3'
|
95
|
-
待离职: '5'
|
96
|
-
无状态: '-1'
|
97
|
-
|
98
|
-
def 获取待入职员工列表(self, offset:int, size:int) -> dict:
|
99
|
-
response = self.__client.oapi.post(
|
100
|
-
"/topapi/smartwork/hrm/employee/querypreentry",
|
101
|
-
json={"offset": offset, "size": size},
|
102
|
-
)
|
103
|
-
return response.json()
|
104
|
-
|
105
|
-
def 获取在职员工列表(self, status_list:List[在职员工状态], offset:int, size:int) -> dict:
|
106
|
-
response = self.__client.oapi.post(
|
107
|
-
"/topapi/smartwork/hrm/employee/querypreentry",
|
108
|
-
json={"status_list": status_list, "offset": offset, "size": size},
|
109
|
-
)
|
110
|
-
return response.json()
|
111
|
-
|
112
|
-
def 获取离职员工列表(self, next_token:int, max_results:int) -> dict:
|
113
|
-
response = self.__client.api.get(
|
114
|
-
"/v1.0/hrm/employees/dismissions",
|
115
|
-
params={"nextToken": next_token, "maxResults": max_results},
|
116
|
-
)
|
117
|
-
return response.json()
|
118
|
-
|
119
|
-
def 批量获取员工离职信息(self, user_id_list:List[str]) -> dict:
|
120
|
-
response = self.__client.api.get(
|
121
|
-
"/v1.0/hrm/employees/dimissionInfo",
|
122
|
-
params={"userIdList": user_id_list},
|
123
|
-
)
|
124
|
-
return response.json()
|
125
|
-
|
126
|
-
# noinspection NonAsciiCharacters
|
127
|
-
class 通讯录管理_API:
|
128
|
-
def __init__(self, _client:DingTalkClient):
|
129
|
-
self.__client = _client
|
130
|
-
|
131
|
-
def 查询用户详情(self, user_id:str, language:str = "zh_CN") -> dict:
|
132
|
-
response = self.__client.oapi.post(url="/topapi/v2/user/get", json={"language": language, "userid": user_id})
|
133
|
-
return response.json()
|
134
|
-
|
135
|
-
def 查询离职记录列表(self, start_time:datetime, end_time:datetime|None, next_token:str, max_results:int) -> dict:
|
136
|
-
params = {"startTime": start_time.strftime("%Y-%m-%dT%H:%M:%SZ"), "nextToken": next_token, "maxResults": max_results}
|
137
|
-
if end_time is not None:
|
138
|
-
params["endTime"] = end_time.strftime("%Y-%m-%dT%H:%M:%SZ")
|
139
|
-
response = self.__client.api.get(url="/v1.0/contact/empLeaveRecords", params=params)
|
140
|
-
return response.json()
|
141
|
-
|
142
|
-
# noinspection NonAsciiCharacters
|
143
|
-
class 文档文件_API:
|
144
|
-
def __init__(self, _client:DingTalkClient):
|
145
|
-
self.媒体文件 = 文档文件_媒体文件_API(_client)
|
146
|
-
|
147
|
-
# noinspection NonAsciiCharacters
|
148
|
-
class 文档文件_媒体文件_API:
|
149
|
-
def __init__(self, _client:DingTalkClient):
|
150
|
-
self.__client = _client
|
151
|
-
|
152
|
-
def 上传媒体文件(self, file_path:Path|str, media_type:Literal['image', 'voice', 'video', 'file']) -> dict:
|
153
|
-
with open(file_path, 'rb') as f:
|
154
|
-
response = self.__client.oapi.post(url=f"/media/upload?type={media_type}", files={'media': f})
|
155
|
-
return response.json()
|
@@ -1,221 +0,0 @@
|
|
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
|
-
class DingTalkWebhookResource(ConfigurableResource):
|
15
|
-
"""
|
16
|
-
定义一个钉钉群 Webhook 机器人资源,可以用来发送各类通知。
|
17
|
-
|
18
|
-
钉钉API文档:
|
19
|
-
https://open.dingtalk.com/document/orgapp/custom-bot-send-message-type
|
20
|
-
|
21
|
-
Args:
|
22
|
-
access_token (str): 机器人 Webhook 地址中的 access_token 值。
|
23
|
-
secret (str, optional): 如使用加签安全配置,则需传签名密钥。默认值为 None。
|
24
|
-
alias (str, optional): 如提供别名,可以在使用 `MultiDingTalkWebhookResource` 中使用别名进行 webhook 选择。默认值为 None。
|
25
|
-
base_url (str, optional): 通用地址,一般无需更改。默认值为 “https://oapi.dingtalk.com/robot/send”。
|
26
|
-
|
27
|
-
"""
|
28
|
-
access_token: str = Field(description="Webhook地址中的 access_token 部分")
|
29
|
-
secret: Optional[str] = Field(default=None, description="如使用加签安全配置,需传签名密钥")
|
30
|
-
alias: Optional[str] = Field(default=None, description="如提供别名,将来可以使用别名进行选择")
|
31
|
-
base_url: str = Field(default="https://oapi.dingtalk.com/robot/send", description="Webhook的通用地址,无需更改")
|
32
|
-
|
33
|
-
def webhook_url(self):
|
34
|
-
"""
|
35
|
-
实时生成加签或未加签的 Webhook URL。
|
36
|
-
|
37
|
-
钉钉API文档:
|
38
|
-
https://open.dingtalk.com/document/robots/custom-robot-access
|
39
|
-
|
40
|
-
Returns:
|
41
|
-
str: Webhook URL
|
42
|
-
"""
|
43
|
-
if self.secret is None:
|
44
|
-
return f"{self.base_url}?access_token={self.access_token}"
|
45
|
-
else:
|
46
|
-
timestamp = round(time.time() * 1000)
|
47
|
-
hmac_code = hmac.new(
|
48
|
-
self.secret.encode('utf-8'), f'{timestamp}\n{self.secret}'.encode('utf-8'), digestmod=hashlib.sha256
|
49
|
-
).digest()
|
50
|
-
sign = urllib.parse.quote_plus(base64.b64encode(hmac_code))
|
51
|
-
return f"{self.base_url}?access_token={self.access_token}×tamp={timestamp}&sign={sign}"
|
52
|
-
|
53
|
-
@staticmethod
|
54
|
-
def _gen_title(text):
|
55
|
-
"""
|
56
|
-
从文本截取前12个字符作为标题,并清理其中的 Markdown 格式字符。
|
57
|
-
|
58
|
-
Args:
|
59
|
-
text: 原文
|
60
|
-
|
61
|
-
Returns:
|
62
|
-
str: 标题
|
63
|
-
"""
|
64
|
-
return re.sub(r'[\n#>* ]', '', text[:12])
|
65
|
-
|
66
|
-
def send_text(self, text: str,
|
67
|
-
at_mobiles:List[str]|None = None, at_user_ids:List[str]|None = None, at_all:bool = False):
|
68
|
-
"""
|
69
|
-
发送文本消息。
|
70
|
-
|
71
|
-
钉钉API文档:
|
72
|
-
https://open.dingtalk.com/document/orgapp/custom-bot-send-message-type
|
73
|
-
|
74
|
-
Args:
|
75
|
-
text (str): 待发送文本
|
76
|
-
at_mobiles (List[str], optional): 需要 @ 的用户手机号。默认值为 None
|
77
|
-
at_user_ids (List[str], optional): 需要 @ 的用户 UserID。默认值为 None
|
78
|
-
at_all (bool, optional): 是否 @ 所有人。默认值为 False
|
79
|
-
"""
|
80
|
-
at = {"isAtAll": at_all}
|
81
|
-
if at_user_ids:
|
82
|
-
at["atUserIds"] = at_user_ids
|
83
|
-
if at_mobiles:
|
84
|
-
at["atMobiles"] = at_mobiles
|
85
|
-
httpx.post(url=self.webhook_url(), json={"msgtype": "text", "text": {"content": text}, "at": at})
|
86
|
-
|
87
|
-
def send_link(self, text: str, message_url:str, title:str|None = None, pic_url:str = ""):
|
88
|
-
"""
|
89
|
-
发送 Link 消息。
|
90
|
-
|
91
|
-
钉钉API文档:
|
92
|
-
https://open.dingtalk.com/document/orgapp/custom-bot-send-message-type
|
93
|
-
|
94
|
-
Args:
|
95
|
-
text (str): 待发送文本
|
96
|
-
message_url (str): 链接的 Url
|
97
|
-
title (str, optional): 标题,在通知和被引用时显示的简短信息。默认从文本中生成。
|
98
|
-
pic_url (str, optional): 图片的 Url,默认为 None
|
99
|
-
"""
|
100
|
-
title = title or self._gen_title(text)
|
101
|
-
httpx.post(
|
102
|
-
url=self.webhook_url(),
|
103
|
-
json={"msgtype": "link", "link": {"title": title, "text": text, "picUrl": pic_url, "messageUrl": message_url}}
|
104
|
-
)
|
105
|
-
|
106
|
-
def send_markdown(self, text: List[str]|str, title:str|None = None,
|
107
|
-
at_mobiles:List[str]|None = None, at_user_ids:List[str]|None = None, at_all:bool = False):
|
108
|
-
"""
|
109
|
-
发送 Markdown 消息。支持的语法有:
|
110
|
-
# 一级标题
|
111
|
-
## 二级标题
|
112
|
-
### 三级标题
|
113
|
-
#### 四级标题
|
114
|
-
##### 五级标题
|
115
|
-
###### 六级标题
|
116
|
-
> 引用
|
117
|
-
**加粗**
|
118
|
-
*斜体*
|
119
|
-
[链接跳转](https://example.com/doc.html)
|
120
|
-
![图片预览](https://example.com/pic.jpg)
|
121
|
-
- 无序列表
|
122
|
-
1. 有序列表
|
123
|
-
|
124
|
-
钉钉API文档:
|
125
|
-
https://open.dingtalk.com/document/orgapp/custom-bot-send-message-type
|
126
|
-
|
127
|
-
Args:
|
128
|
-
text (str): 待发送文本
|
129
|
-
title (str, optional): 标题,在通知和被引用时显示的简短信息。默认从文本中生成。
|
130
|
-
at_mobiles (List[str], optional): 需要 @ 的用户手机号。默认值为 None
|
131
|
-
at_user_ids (List[str], optional): 需要 @ 的用户 UserID。默认值为 None
|
132
|
-
at_all (bool, optional): 是否 @ 所有人。默认值为 False
|
133
|
-
"""
|
134
|
-
text = text if isinstance(text, str) else "\n\n".join(text)
|
135
|
-
title = title or self._gen_title(text)
|
136
|
-
at = {"isAtAll": at_all}
|
137
|
-
if at_user_ids:
|
138
|
-
at["atUserIds"] = at_user_ids
|
139
|
-
if at_mobiles:
|
140
|
-
at["atMobiles"] = at_mobiles
|
141
|
-
httpx.post(url=self.webhook_url(),json={"msgtype": "markdown", "markdown": {"title": title, "text": text}, "at": at})
|
142
|
-
|
143
|
-
def send_action_card(self, text: List[str]|str, title:str|None = None, btn_orientation:Literal["0","1"] = "0",
|
144
|
-
single_jump:Tuple[str,str]|None = None, btns_jump:List[Tuple[str,str]]|None = None):
|
145
|
-
"""
|
146
|
-
发送跳转 ActionCard 消息。
|
147
|
-
|
148
|
-
Args:
|
149
|
-
text (str): 待发送文本,支持 Markdown 部分语法。
|
150
|
-
title (str, optional): 标题,在通知和被引用时显示的简短信息。默认从文本中生成。
|
151
|
-
btn_orientation (str, optional): 按钮排列方式,0-按钮竖直排列,1-按钮横向排列。默认值为 "0"
|
152
|
-
single_jump(Tuple[str,str], optional): 传此参数为单个按钮,元组内第一项为按钮的标题,第二项为按钮链接。
|
153
|
-
btns_jump(Tuple[str,str], optional): 传此参数为多个按钮,元组内第一项为按钮的标题,第二项为按钮链接。
|
154
|
-
|
155
|
-
Notes:
|
156
|
-
同时传 single_jump 和 btns_jump,仅 single_jump 生效。
|
157
|
-
|
158
|
-
"""
|
159
|
-
text = text if isinstance(text, str) else "\n\n".join(text)
|
160
|
-
title = title or self._gen_title(text)
|
161
|
-
action_card = {"title": title, "text": text, "btnOrientation": str(btn_orientation)}
|
162
|
-
if single_jump:
|
163
|
-
action_card["singleTitle"], action_card["singleURL"] = single_jump
|
164
|
-
httpx.post(url=self.webhook_url(), json={"msgtype": "actionCard", "actionCard": action_card})
|
165
|
-
elif btns_jump:
|
166
|
-
action_card["btns"] = [{"title": action_title, "actionURL": action_url} for action_title, action_url in btns_jump]
|
167
|
-
httpx.post(url=self.webhook_url(), json={"msgtype": "actionCard", "actionCard": action_card})
|
168
|
-
else:
|
169
|
-
pass
|
170
|
-
|
171
|
-
def send_feed_card(self, *args:Tuple[str,str,str]):
|
172
|
-
"""
|
173
|
-
发送 FeedCard 消息。
|
174
|
-
|
175
|
-
Args:
|
176
|
-
args (Tuple[str,str,str]): 可以传入任意个具有三个元素的元组,分别为 (标题, 跳转链接, 缩略图链接)
|
177
|
-
|
178
|
-
"""
|
179
|
-
for a in args:
|
180
|
-
print(a)
|
181
|
-
links_data = [
|
182
|
-
{"title": title, "messageURL": message_url, "picURL": pic_url}
|
183
|
-
for title, message_url, pic_url in args
|
184
|
-
]
|
185
|
-
httpx.post(url=self.webhook_url(), json={"msgtype": "feedCard", "feedCard": {"links": links_data}})
|
186
|
-
|
187
|
-
|
188
|
-
class DingTalkAppResource(ConfigurableResource):
|
189
|
-
"""
|
190
|
-
[钉钉服务端 API](https://open.dingtalk.com/document/orgapp/api-overview) 企业内部应用部分的第三方封装。
|
191
|
-
通过此资源,可以调用部分钉钉服务端API。
|
192
|
-
|
193
|
-
Notes:
|
194
|
-
不包含全部的API端点。
|
195
|
-
|
196
|
-
Args:
|
197
|
-
AppID (str): 应用应用唯一标识 AppID,作为缓存标识符使用。不传入则不缓存鉴权。
|
198
|
-
AgentID (int, optional): 原企业内部应用 AgentId ,部分 API 会使用到。默认值为 None
|
199
|
-
AppName (str, optional): 应用名。
|
200
|
-
ClientId (str): 应用的 Client ID ,原 AppKey 和 SuiteKey
|
201
|
-
ClientSecret (str): 应用的 Client Secret ,原 AppSecret 和 SuiteSecret
|
202
|
-
"""
|
203
|
-
|
204
|
-
AppID: str = Field(description="应用应用唯一标识 AppID,作为缓存标识符使用。不传入则不缓存鉴权。")
|
205
|
-
AgentID: Optional[int] = Field(default=None, description="原企业内部应用AgentId ,部分API会使用到。")
|
206
|
-
AppName: Optional[str] = Field(default=None, description="应用名。")
|
207
|
-
ClientId: str = Field(description="应用的 Client ID (原 AppKey 和 SuiteKey)")
|
208
|
-
ClientSecret: str = Field(description="应用的 Client Secret (原 AppSecret 和 SuiteSecret)")
|
209
|
-
|
210
|
-
@classmethod
|
211
|
-
def _is_dagster_maintained(cls) -> bool:
|
212
|
-
return False
|
213
|
-
|
214
|
-
def create_resource(self, context: InitResourceContext) -> DingTalkClient:
|
215
|
-
return DingTalkClient(
|
216
|
-
app_id=self.AppID,
|
217
|
-
agent_id=self.AgentID,
|
218
|
-
app_name=self.AppName,
|
219
|
-
client_id=self.ClientId,
|
220
|
-
client_secret=self.ClientSecret
|
221
|
-
)
|
@@ -1 +0,0 @@
|
|
1
|
-
__version__ = "0.1.10b2"
|
File without changes
|
File without changes
|