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.
@@ -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}&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,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"