dagster-dingtalk 0.1.0__tar.gz
Sign up to get free protection for your applications and to get access to all the features.
- 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"
|