dagster-dingtalk 0.1.0__tar.gz
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-0.1.0/PKG-INFO +45 -0
- dagster_dingtalk-0.1.0/README.md +28 -0
- dagster_dingtalk-0.1.0/dagster_dingtalk/__init__.py +8 -0
- dagster_dingtalk-0.1.0/dagster_dingtalk/dynamic_ops.py +20 -0
- dagster_dingtalk-0.1.0/dagster_dingtalk/resources.py +255 -0
- dagster_dingtalk-0.1.0/dagster_dingtalk/version.py +1 -0
- dagster_dingtalk-0.1.0/pyproject.toml +17 -0
@@ -0,0 +1,45 @@
|
|
1
|
+
Metadata-Version: 2.1
|
2
|
+
Name: dagster-dingtalk
|
3
|
+
Version: 0.1.0
|
4
|
+
Summary: A dagster plugin for the DingTalk
|
5
|
+
Author: YiZixuan
|
6
|
+
Author-email: sqkkyzx@qq.com
|
7
|
+
Requires-Python: >=3.10,<3.13
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
9
|
+
Classifier: Programming Language :: Python :: 3.10
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
12
|
+
Requires-Dist: dagster (>=1.8.10,<2.0.0)
|
13
|
+
Requires-Dist: httpx (>=0.27.2,<0.28.0)
|
14
|
+
Requires-Dist: pydantic (>=2.9.2,<3.0.0)
|
15
|
+
Description-Content-Type: text/markdown
|
16
|
+
|
17
|
+
# 钉钉与 Dagster 集成
|
18
|
+
|
19
|
+
---
|
20
|
+
|
21
|
+
## 介绍
|
22
|
+
|
23
|
+
该 Dagster 集成是为了更便捷的调用钉钉(DingTalk)的API,
|
24
|
+
集成提供了两个 Dagster Resource 和若干 Dagster Op 的封装。
|
25
|
+
|
26
|
+
### DingTalkWebhookResource
|
27
|
+
|
28
|
+
该 Dagster 资源允许定义一个钉钉自定义机器人的 Webhook 端点,
|
29
|
+
发送文本、Markdown、Link、 ActionCard、FeedCard 消息,
|
30
|
+
消息具体样式可参考 [钉钉开放平台 | 自定义机器人发送消息的消息类型](https://open.dingtalk.com/document/orgapp/custom-bot-send-message-type) 。
|
31
|
+
|
32
|
+
|
33
|
+
### DingTalkAPIResource
|
34
|
+
|
35
|
+
该 Dagster 资源允许定义一个钉钉的 API Client,便捷的调用钉钉服务端 API (仅企业内部应用)
|
36
|
+
|
37
|
+
|
38
|
+
### DingTalkWebhookOp
|
39
|
+
|
40
|
+
该类提供一些预定义的 Dagster Op ,以便于快速调用。
|
41
|
+
|
42
|
+
需要注意的是,这些 Op 都没有静态地声明 `required_resource_keys`,
|
43
|
+
而是需要根据上下文配置 `context.op_config` 中的 `dingtalk_webhook_key` 键来动态查找资源。
|
44
|
+
在使用时,必须在 Job 中提供 `resource_defs` ,或在 `User Code` 全局定义会使用到的 `dingtalk_webhook_key`。
|
45
|
+
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# 钉钉与 Dagster 集成
|
2
|
+
|
3
|
+
---
|
4
|
+
|
5
|
+
## 介绍
|
6
|
+
|
7
|
+
该 Dagster 集成是为了更便捷的调用钉钉(DingTalk)的API,
|
8
|
+
集成提供了两个 Dagster Resource 和若干 Dagster Op 的封装。
|
9
|
+
|
10
|
+
### DingTalkWebhookResource
|
11
|
+
|
12
|
+
该 Dagster 资源允许定义一个钉钉自定义机器人的 Webhook 端点,
|
13
|
+
发送文本、Markdown、Link、 ActionCard、FeedCard 消息,
|
14
|
+
消息具体样式可参考 [钉钉开放平台 | 自定义机器人发送消息的消息类型](https://open.dingtalk.com/document/orgapp/custom-bot-send-message-type) 。
|
15
|
+
|
16
|
+
|
17
|
+
### DingTalkAPIResource
|
18
|
+
|
19
|
+
该 Dagster 资源允许定义一个钉钉的 API Client,便捷的调用钉钉服务端 API (仅企业内部应用)
|
20
|
+
|
21
|
+
|
22
|
+
### DingTalkWebhookOp
|
23
|
+
|
24
|
+
该类提供一些预定义的 Dagster Op ,以便于快速调用。
|
25
|
+
|
26
|
+
需要注意的是,这些 Op 都没有静态地声明 `required_resource_keys`,
|
27
|
+
而是需要根据上下文配置 `context.op_config` 中的 `dingtalk_webhook_key` 键来动态查找资源。
|
28
|
+
在使用时,必须在 Job 中提供 `resource_defs` ,或在 `User Code` 全局定义会使用到的 `dingtalk_webhook_key`。
|
@@ -0,0 +1,8 @@
|
|
1
|
+
# noinspection PyProtectedMember
|
2
|
+
from dagster._core.libraries import DagsterLibraryRegistry
|
3
|
+
|
4
|
+
from dagster_dingtalk.resources import DingTalkAPIResource, DingTalkWebhookResource
|
5
|
+
from dagster_dingtalk.dynamic_ops import DingTalkWebhookOp
|
6
|
+
from dagster_dingtalk.version import __version__
|
7
|
+
|
8
|
+
DagsterLibraryRegistry.register("dagster-dingtalk", __version__)
|
@@ -0,0 +1,20 @@
|
|
1
|
+
from dagster import In, OpExecutionContext
|
2
|
+
from pydantic import Field
|
3
|
+
|
4
|
+
|
5
|
+
class DingTalkWebhookOp:
|
6
|
+
@op(description="使用钉钉 Webhook 发送文本消息",
|
7
|
+
config_schema={"dingtalk_webhook_key": Field(str)},
|
8
|
+
ins={"text": In(str)},
|
9
|
+
)
|
10
|
+
def send_simple_text(self, context: OpExecutionContext, text):
|
11
|
+
webhook = getattr(context.resources, context.op_config["dingtalk_webhook_key"])
|
12
|
+
webhook.send_text(text)
|
13
|
+
|
14
|
+
@op(description="使用钉钉 Webhook 发送 Markdown 消息",
|
15
|
+
config_schema={"dingtalk_webhook_key": Field(str)},
|
16
|
+
ins={"text": In(str), "title": In(str, default_value='')},
|
17
|
+
)
|
18
|
+
def send_simple_markdown(self, context: OpExecutionContext, text, title):
|
19
|
+
webhook = getattr(context.resources, context.op_config["dingtalk_webhook_key"])
|
20
|
+
webhook.send_text(text, title)
|
@@ -0,0 +1,255 @@
|
|
1
|
+
import base64
|
2
|
+
import hashlib
|
3
|
+
import hmac
|
4
|
+
import time
|
5
|
+
import urllib.parse
|
6
|
+
from datetime import datetime
|
7
|
+
from enum import Enum
|
8
|
+
from pathlib import Path
|
9
|
+
from typing import Optional, Dict, Tuple, List, Literal
|
10
|
+
import pickle
|
11
|
+
import httpx
|
12
|
+
|
13
|
+
from dagster import (
|
14
|
+
ConfigurableResource,
|
15
|
+
InitResourceContext,
|
16
|
+
)
|
17
|
+
from httpx import Client
|
18
|
+
from pydantic import Field, PrivateAttr
|
19
|
+
|
20
|
+
|
21
|
+
class DingTalkWebhookResource(ConfigurableResource):
|
22
|
+
base_url: str = Field(default="https://oapi.dingtalk.com/robot/send", description="Webhook的通用地址,无需更改")
|
23
|
+
access_token: str = Field(description="Webhook地址中的 access_token 部分")
|
24
|
+
secret: Optional[str] = Field(default=None, description="如使用加签安全配置,需传签名密钥")
|
25
|
+
|
26
|
+
def _sign_webhook_url(self):
|
27
|
+
|
28
|
+
if self.secret is None:
|
29
|
+
return self.webhook_url
|
30
|
+
else:
|
31
|
+
timestamp = round(time.time() * 1000)
|
32
|
+
hmac_code = hmac.new(
|
33
|
+
self.secret.encode('utf-8'), f'{timestamp}\n{self.secret}'.encode('utf-8'), digestmod=hashlib.sha256
|
34
|
+
).digest()
|
35
|
+
sign = urllib.parse.quote_plus(base64.b64encode(hmac_code))
|
36
|
+
return f"{self.base_url}?access_token={self.access_token}×tamp={timestamp}&sign={sign}"
|
37
|
+
|
38
|
+
@staticmethod
|
39
|
+
def _gen_title(text):
|
40
|
+
return text[:12].replace("\n", "").replace("#", "").replace(">", "").replace("*", "")
|
41
|
+
|
42
|
+
def send_text(self, text: str,
|
43
|
+
at_mobiles:List[str]|None = None, at_user_ids:List[str]|None = None, at_all:bool = False):
|
44
|
+
at = {"isAtAll": at_all}
|
45
|
+
if at_user_ids:
|
46
|
+
at["atUserIds"] = at_user_ids
|
47
|
+
if at_mobiles:
|
48
|
+
at["atMobiles"] = at_mobiles
|
49
|
+
httpx.post(url=self.webhook_url, json={"msgtype": "text", "text": {"content": text}, "at": at})
|
50
|
+
|
51
|
+
def send_link(self, text: str, message_url:str, title:str|None = None, pic_url:str = ""):
|
52
|
+
title = title or self._gen_title(text)
|
53
|
+
httpx.post(
|
54
|
+
url=self.webhook_url,
|
55
|
+
json={"msgtype": "link", "link": {"title": title, "text": text, "picUrl": pic_url, "messageUrl": message_url}}
|
56
|
+
)
|
57
|
+
|
58
|
+
def send_markdown(self, text: List[str]|str, title:str|None = None,
|
59
|
+
at_mobiles:List[str]|None = None, at_user_ids:List[str]|None = None, at_all:bool = False):
|
60
|
+
text = text if isinstance(text, str) else "\n\n".join(text)
|
61
|
+
title = title or self._gen_title(text)
|
62
|
+
at = {"isAtAll": at_all}
|
63
|
+
if at_user_ids:
|
64
|
+
at["atUserIds"] = at_user_ids
|
65
|
+
if at_mobiles:
|
66
|
+
at["atMobiles"] = at_mobiles
|
67
|
+
httpx.post(url=self.webhook_url,json={"msgtype": "markdown", "markdown": {"title": title, "text": text}, "at": at})
|
68
|
+
|
69
|
+
def send_action_card(self, text: List[str]|str, title:str|None = None, btn_orientation:Literal["0","1"] = "0",
|
70
|
+
single_jump:Tuple[str,str]|None = None, btns_jump:List[Tuple[str,str]]|None = None):
|
71
|
+
text = text if isinstance(text, str) else "\n\n".join(text)
|
72
|
+
title = title or self._gen_title(text)
|
73
|
+
action_card = {"title": title, "text": text, "btnOrientation": btn_orientation}
|
74
|
+
if btns_jump:
|
75
|
+
action_card["btns"] = [{"title": action_title, "actionURL": action_url} for action_title, action_url in btns_jump]
|
76
|
+
httpx.post(url=self.webhook_url, json={"msgtype": "actionCard", "actionCard": action_card})
|
77
|
+
elif single_jump:
|
78
|
+
action_card["singleTitle"], action_card["singleURL"] = single_jump
|
79
|
+
httpx.post(url=self.webhook_url, json={"msgtype": "actionCard", "actionCard": action_card})
|
80
|
+
else:
|
81
|
+
pass
|
82
|
+
|
83
|
+
def send_feed_card(self, links: List[Tuple[str,str,str]]):
|
84
|
+
links_data = [
|
85
|
+
{"title": title, "messageURL": message_url, "picURL": pic_url}
|
86
|
+
for title, message_url, pic_url in links
|
87
|
+
]
|
88
|
+
httpx.post(url=self.webhook_url, json={"msgtype": "feedCard", "feedCard": {"links": links_data}})
|
89
|
+
|
90
|
+
|
91
|
+
class DingTalkMultiClient:
|
92
|
+
def __init__(self, access_token: str, app_id: str, agent_id: int, robot_code: str) -> None:
|
93
|
+
self.access_token: str = access_token
|
94
|
+
self.app_id: str = app_id
|
95
|
+
self.agent_id: int = agent_id
|
96
|
+
self.robot_code: str = robot_code
|
97
|
+
self.api: Client = httpx.Client(base_url="https://api.dingtalk.com/", headers={"x-acs-dingtalk-access-token": self.access_token})
|
98
|
+
self.oapi: Client = httpx.Client(base_url="https://oapi.dingtalk.com/", params={"access_token": self.access_token})
|
99
|
+
|
100
|
+
|
101
|
+
# noinspection NonAsciiCharacters
|
102
|
+
class DingTalkAPIResource(ConfigurableResource):
|
103
|
+
"""
|
104
|
+
[钉钉服务端 API](https://open.dingtalk.com/document/orgapp/api-overview) 企业内部应用部分的第三方封装。
|
105
|
+
通过此资源,可以调用部分钉钉服务端API。
|
106
|
+
|
107
|
+
注意:不包含全部的API端点。
|
108
|
+
"""
|
109
|
+
|
110
|
+
AppID: Optional[str] = Field(default=None, description="应用应用唯一标识 AppID,作为缓存标识符使用。不传入则不缓存鉴权。")
|
111
|
+
AgentID: Optional[int] = Field(default=None, description="原企业内部应用AgentId ,部分API会使用到。")
|
112
|
+
AppName: Optional[str] = Field(default=None, description="应用名。")
|
113
|
+
ClientId: str = Field(description="应用的 Client ID (原 AppKey 和 SuiteKey)")
|
114
|
+
ClientSecret: str = Field(description="应用的 Client Secret (原 AppSecret 和 SuiteSecret)")
|
115
|
+
RobotCode: str = Field(description="应用的机器人 RobotCode")
|
116
|
+
|
117
|
+
_client: DingTalkMultiClient = PrivateAttr()
|
118
|
+
|
119
|
+
@classmethod
|
120
|
+
def _is_dagster_maintained(cls) -> bool:
|
121
|
+
return False
|
122
|
+
|
123
|
+
def _get_access_token(self, context: InitResourceContext) -> str:
|
124
|
+
access_token_cache = Path("~/.dingtalk_cache")
|
125
|
+
|
126
|
+
if access_token_cache.exists():
|
127
|
+
with open(access_token_cache, 'rb') as f:
|
128
|
+
all_access_token: Dict[str, Tuple[str, int]] = pickle.loads(f.read())
|
129
|
+
else:
|
130
|
+
all_access_token = {}
|
131
|
+
|
132
|
+
access_token, expire_in = all_access_token.get(self.AppID, ('', 0))
|
133
|
+
|
134
|
+
if access_token and expire_in < int(time.time()):
|
135
|
+
return access_token
|
136
|
+
else:
|
137
|
+
context.log.info(f"应用{self.AppName}<{self.AppID}> 鉴权缓存过期或不存在,正在重新获取...")
|
138
|
+
response = httpx.post(
|
139
|
+
url="https://api.dingtalk.com/v1.0/oauth2/accessToken",
|
140
|
+
json={"appKey": self.ClientId, "appSecret": self.ClientSecret},
|
141
|
+
)
|
142
|
+
access_token:str = response.json().get("accessToken")
|
143
|
+
expire_in:int = response.json().get("expireIn") + int(time.time()) - 60
|
144
|
+
with open(access_token_cache, 'wb') as f:
|
145
|
+
all_access_token[self.AppID] = (access_token, expire_in)
|
146
|
+
f.write(pickle.dumps(access_token_cache))
|
147
|
+
return access_token
|
148
|
+
|
149
|
+
def setup_for_execution(self, context: InitResourceContext) -> None:
|
150
|
+
self._client = DingTalkMultiClient(
|
151
|
+
self._get_access_token(context),
|
152
|
+
self.AppID,
|
153
|
+
self.AgentID,
|
154
|
+
self.RobotCode,
|
155
|
+
)
|
156
|
+
|
157
|
+
def teardown_after_execution(self, context: InitResourceContext) -> None:
|
158
|
+
self._client.api.close()
|
159
|
+
self._client.oapi.close()
|
160
|
+
|
161
|
+
def 智能人事(self):
|
162
|
+
return API_智能人事(self._client)
|
163
|
+
|
164
|
+
def 通讯录管理(self):
|
165
|
+
return API_通讯录管理(self._client)
|
166
|
+
|
167
|
+
def 文档文件(self):
|
168
|
+
return API_文档文件(self._client)
|
169
|
+
|
170
|
+
|
171
|
+
# noinspection NonAsciiCharacters
|
172
|
+
class API_智能人事:
|
173
|
+
def __init__(self, _client:DingTalkMultiClient):
|
174
|
+
self._client = _client
|
175
|
+
|
176
|
+
def 花名册_获取花名册元数据(self):
|
177
|
+
response = self._client.oapi.post(
|
178
|
+
url="/topapi/smartwork/hrm/roster/meta/get",
|
179
|
+
json={"agentid": self._client.agent_id},
|
180
|
+
)
|
181
|
+
return response.json()
|
182
|
+
|
183
|
+
def 花名册_获取员工花名册字段信息(self, user_id_list:List[str], field_filter_list:List[str]|None = None, text_to_select_convert:bool|None = None):
|
184
|
+
body_dict = {"userIdList": user_id_list, "appAgentId": self._client.agent_id}
|
185
|
+
if field_filter_list is not None:
|
186
|
+
body_dict["fieldFilterList"] = field_filter_list
|
187
|
+
if text_to_select_convert is not None:
|
188
|
+
body_dict["text2SelectConvert"] = text_to_select_convert
|
189
|
+
|
190
|
+
response = self._client.api.post(url="/topapi/smartwork/hrm/roster/meta/get", json=body_dict,)
|
191
|
+
return response.json()
|
192
|
+
|
193
|
+
# noinspection NonAsciiCharacters
|
194
|
+
class 在职员工状态(Enum):
|
195
|
+
试用期: '2'
|
196
|
+
正式: '3'
|
197
|
+
待离职: '5'
|
198
|
+
无状态: '-1'
|
199
|
+
|
200
|
+
def 员工管理_获取待入职员工列表(self, offset:int, size:int):
|
201
|
+
response = self._client.oapi.post(
|
202
|
+
"/topapi/smartwork/hrm/employee/querypreentry",
|
203
|
+
json={"offset": offset, "size": size},
|
204
|
+
)
|
205
|
+
return response.json()
|
206
|
+
|
207
|
+
def 员工管理_获取在职员工列表(self, status_list:List[在职员工状态], offset:int, size:int):
|
208
|
+
response = self._client.oapi.post(
|
209
|
+
"/topapi/smartwork/hrm/employee/querypreentry",
|
210
|
+
json={"status_list": status_list, "offset": offset, "size": size},
|
211
|
+
)
|
212
|
+
return response.json()
|
213
|
+
|
214
|
+
def 员工管理_获取离职员工列表(self, next_token:int, max_results:int):
|
215
|
+
response = self._client.api.get(
|
216
|
+
"/v1.0/hrm/employees/dismissions",
|
217
|
+
params={"nextToken": next_token, "maxResults": max_results},
|
218
|
+
)
|
219
|
+
return response.json()
|
220
|
+
|
221
|
+
def 员工管理_批量获取员工离职信息(self, user_id_list:List[str]):
|
222
|
+
response = self._client.api.get(
|
223
|
+
"/v1.0/hrm/employees/dimissionInfo",
|
224
|
+
params={"userIdList": user_id_list},
|
225
|
+
)
|
226
|
+
return response.json()
|
227
|
+
|
228
|
+
|
229
|
+
# noinspection NonAsciiCharacters
|
230
|
+
class API_通讯录管理:
|
231
|
+
def __init__(self, _client:DingTalkMultiClient):
|
232
|
+
self._client = _client
|
233
|
+
|
234
|
+
def 查询用户详情(self, user_id:str, language:str = "zh_CN"):
|
235
|
+
response = self._client.oapi.post(url="/topapi/v2/user/get", json={"language": language, "userid": user_id})
|
236
|
+
return response.json()
|
237
|
+
|
238
|
+
def 查询离职记录列表(self, start_time:datetime, end_time:datetime|None, next_token:str, max_results:int):
|
239
|
+
params = {"startTime": start_time.strftime("%Y-%m-%dT%H:%M:%SZ"), "nextToken": next_token, "maxResults": max_results}
|
240
|
+
if end_time is not None:
|
241
|
+
params["endTime"] = end_time.strftime("%Y-%m-%dT%H:%M:%SZ")
|
242
|
+
response = self._client.api.get(url="/v1.0/contact/empLeaveRecords", params=params)
|
243
|
+
return response.json()
|
244
|
+
|
245
|
+
|
246
|
+
# noinspection NonAsciiCharacters
|
247
|
+
class API_文档文件:
|
248
|
+
def __init__(self, _client:DingTalkMultiClient):
|
249
|
+
self._client = _client
|
250
|
+
|
251
|
+
def 媒体文件_上传媒体文件(self, file_path:Path|str, media_type:Literal['image', 'voice', 'video', 'file']):
|
252
|
+
with open(file_path, 'rb') as f:
|
253
|
+
response = self._client.oapi.post(url=f"/media/upload?type={media_type}", files={'media': f})
|
254
|
+
return response.json()
|
255
|
+
|
@@ -0,0 +1 @@
|
|
1
|
+
__version__ = "0.0.1"
|
@@ -0,0 +1,17 @@
|
|
1
|
+
[tool.poetry]
|
2
|
+
name = "dagster-dingtalk"
|
3
|
+
version = "0.1.0"
|
4
|
+
description = "A dagster plugin for the DingTalk"
|
5
|
+
authors = ["YiZixuan <sqkkyzx@qq.com>"]
|
6
|
+
readme = "README.md"
|
7
|
+
|
8
|
+
[tool.poetry.dependencies]
|
9
|
+
python = ">=3.10,<3.13"
|
10
|
+
httpx = "^0.27.2"
|
11
|
+
pydantic = "^2.9.2"
|
12
|
+
dagster = "^1.8.10"
|
13
|
+
|
14
|
+
|
15
|
+
[build-system]
|
16
|
+
requires = ["poetry-core"]
|
17
|
+
build-backend = "poetry.core.masonry.api"
|