dagster-dingtalk 0.1.0__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
@@ -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}&timestamp={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,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,7 @@
1
+ dagster_dingtalk/__init__.py,sha256=DAsRJtx6BEimn5VSm4FKHsz2GgluX_9hZlopaUhqaYw,351
2
+ dagster_dingtalk/dynamic_ops.py,sha256=Lft3JvF6mAWs2Cvh08hkrZh87v1wtTBKnU9kxX-WMMs,870
3
+ dagster_dingtalk/resources.py,sha256=6nl9BIryHniKYbSYcGmfJsY5vE5LxtrPPkHIbjf9GZ4,11089
4
+ dagster_dingtalk/version.py,sha256=sXLh7g3KC4QCFxcZGBTpG2scR7hmmBsMjq6LqRptkRg,22
5
+ dagster_dingtalk-0.1.0.dist-info/METADATA,sha256=cJP0h8M8x401QAwDtBbARO04OstHMRuYOi23XgkEKTE,1655
6
+ dagster_dingtalk-0.1.0.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
7
+ dagster_dingtalk-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 1.9.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any