dagster-dingtalk 0.1.8__tar.gz → 0.1.10__tar.gz

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: dagster-dingtalk
3
- Version: 0.1.8
3
+ Version: 0.1.10
4
4
  Summary: A dagster plugin for the DingTalk
5
5
  Author: YiZixuan
6
6
  Author-email: sqkkyzx@qq.com
@@ -20,28 +20,19 @@ Description-Content-Type: text/markdown
20
20
 
21
21
  ## 介绍
22
22
 
23
- 该 Dagster 集成是为了更便捷的调用钉钉(DingTalk)的API,集成提供了两个 Dagster
24
- Resource 和若干 Dagster Op 的封装。
23
+ 该 Dagster 集成是为了更便捷的调用钉钉(DingTalk)的API,集成提供了两个 Dagster Resource。
24
+
25
+
26
+ ## Webhook 资源
25
27
 
26
28
  ### DingTalkWebhookResource
27
29
 
28
- Dagster 资源允许定义一个钉钉自定义机器人的 Webhook 端点,发送文本、Markdown
30
+ 该资源允许定义单个钉钉自定义机器人的 Webhook 端点,以便于发送文本、Markdown
29
31
  、Link、 ActionCard、FeedCard 消息,消息具体样式可参考
30
32
  [钉钉开放平台 | 自定义机器人发送消息的消息类型](https://open.dingtalk.com/document/orgapp/custom-bot-send-message-type) 。
31
33
 
32
34
 
33
- ### DingTalkAPIResource
34
-
35
- 该 Dagster 资源允许定义一个钉钉的 API Client,更加便捷地调用钉钉服务端 API (
36
- 仅企业内部应用)
37
-
38
-
39
- ### DingTalkWebhookOp
40
-
41
- 该类提供一些预定义的 Dagster Op ,以便于快速调用。
35
+ ### DingTalkAppResource
42
36
 
43
- 需要注意的是,这些 Op 都没有静态声明 `required_resource_keys`,而是需要根据
44
- 上下文配置 `context.op_config` 中的 `dingtalk_webhook_key` 键来动态查找
45
- 资源。在使用时,必须在 Job 中提供 `resource_defs` ,或在 `User Code` 全局
46
- 定义会使用到的 `dingtalk_webhook_key`。
37
+ Dagster 资源允许定义一个钉钉的 API Client,更加便捷地调用钉钉服务端企业内部应用 API
47
38
 
@@ -0,0 +1,21 @@
1
+ # 钉钉与 Dagster 集成
2
+
3
+ ---
4
+
5
+ ## 介绍
6
+
7
+ 该 Dagster 集成是为了更便捷的调用钉钉(DingTalk)的API,集成提供了两个 Dagster Resource。
8
+
9
+
10
+ ## Webhook 资源
11
+
12
+ ### DingTalkWebhookResource
13
+
14
+ 该资源允许定义单个钉钉自定义机器人的 Webhook 端点,以便于发送文本、Markdown
15
+ 、Link、 ActionCard、FeedCard 消息,消息具体样式可参考
16
+ [钉钉开放平台 | 自定义机器人发送消息的消息类型](https://open.dingtalk.com/document/orgapp/custom-bot-send-message-type) 。
17
+
18
+
19
+ ### DingTalkAppResource
20
+
21
+ 该 Dagster 资源允许定义一个钉钉的 API Client,更加便捷地调用钉钉服务端企业内部应用 API
@@ -1,8 +1,9 @@
1
1
  # noinspection PyProtectedMember
2
2
  from dagster._core.libraries import DagsterLibraryRegistry
3
3
 
4
- from dagster_dingtalk.resources import DingTalkResource, MultiDingTalkResource
5
- from dagster_dingtalk.resources import DingTalkWebhookResource, MultiDingTalkWebhookResource
4
+ from dagster_dingtalk.resources import DingTalkAppResource
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,154 @@
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
+ # noinspection NonAsciiCharacters
57
+ class 智能人事_API:
58
+ def __init__(self, _client:DingTalkClient):
59
+ self.花名册 = 智能人事_花名册_API(_client)
60
+ self.员工管理 = 智能人事_员工管理_API(_client)
61
+
62
+ # noinspection NonAsciiCharacters
63
+ class 智能人事_花名册_API:
64
+ def __init__(self, _client:DingTalkClient):
65
+ self.__client:DingTalkClient = _client
66
+
67
+ def 获取花名册元数据(self) -> dict:
68
+ response = self.__client.oapi.post(
69
+ url="/topapi/smartwork/hrm/roster/meta/get",
70
+ json={"agentid": self.__client.agent_id},
71
+ )
72
+ return response.json()
73
+
74
+ def 获取员工花名册字段信息(self, user_id_list:List[str], field_filter_list:List[str]|None = None, text_to_select_convert:bool|None = None) -> dict:
75
+ body_dict = {"userIdList": user_id_list, "appAgentId": self.__client.agent_id}
76
+ if field_filter_list is not None:
77
+ body_dict["fieldFilterList"] = field_filter_list
78
+ if text_to_select_convert is not None:
79
+ body_dict["text2SelectConvert"] = text_to_select_convert
80
+
81
+ response = self.__client.api.post(url="/topapi/smartwork/hrm/roster/meta/get", json=body_dict, )
82
+ return response.json()
83
+
84
+ # noinspection NonAsciiCharacters
85
+ class 智能人事_员工管理_API:
86
+
87
+ def __init__(self, _client:DingTalkClient):
88
+ self.__client:DingTalkClient = _client
89
+
90
+ # noinspection NonAsciiCharacters
91
+ class 在职员工状态(Enum):
92
+ 试用期: '2'
93
+ 正式: '3'
94
+ 待离职: '5'
95
+ 无状态: '-1'
96
+
97
+ def 获取待入职员工列表(self, offset:int, size:int) -> dict:
98
+ response = self.__client.oapi.post(
99
+ "/topapi/smartwork/hrm/employee/querypreentry",
100
+ json={"offset": offset, "size": size},
101
+ )
102
+ return response.json()
103
+
104
+ def 获取在职员工列表(self, status_list:List[在职员工状态], offset:int, size:int) -> dict:
105
+ response = self.__client.oapi.post(
106
+ "/topapi/smartwork/hrm/employee/querypreentry",
107
+ json={"status_list": status_list, "offset": offset, "size": size},
108
+ )
109
+ return response.json()
110
+
111
+ def 获取离职员工列表(self, next_token:int, max_results:int) -> dict:
112
+ response = self.__client.api.get(
113
+ "/v1.0/hrm/employees/dismissions",
114
+ params={"nextToken": next_token, "maxResults": max_results},
115
+ )
116
+ return response.json()
117
+
118
+ def 批量获取员工离职信息(self, user_id_list:List[str]) -> dict:
119
+ response = self.__client.api.get(
120
+ "/v1.0/hrm/employees/dimissionInfo",
121
+ params={"userIdList": user_id_list},
122
+ )
123
+ return response.json()
124
+
125
+ # noinspection NonAsciiCharacters
126
+ class 通讯录管理_API:
127
+ def __init__(self, _client:DingTalkClient):
128
+ self.__client = _client
129
+
130
+ def 查询用户详情(self, user_id:str, language:str = "zh_CN") -> dict:
131
+ response = self.__client.oapi.post(url="/topapi/v2/user/get", json={"language": language, "userid": user_id})
132
+ return response.json()
133
+
134
+ def 查询离职记录列表(self, start_time:datetime, end_time:datetime|None, next_token:str, max_results:int) -> dict:
135
+ params = {"startTime": start_time.strftime("%Y-%m-%dT%H:%M:%SZ"), "nextToken": next_token, "maxResults": max_results}
136
+ if end_time is not None:
137
+ params["endTime"] = end_time.strftime("%Y-%m-%dT%H:%M:%SZ")
138
+ response = self.__client.api.get(url="/v1.0/contact/empLeaveRecords", params=params)
139
+ return response.json()
140
+
141
+ # noinspection NonAsciiCharacters
142
+ class 文档文件_API:
143
+ def __init__(self, _client:DingTalkClient):
144
+ self.媒体文件 = 文档文件_媒体文件_API(_client)
145
+
146
+ # noinspection NonAsciiCharacters
147
+ class 文档文件_媒体文件_API:
148
+ def __init__(self, _client:DingTalkClient):
149
+ self.__client = _client
150
+
151
+ def 上传媒体文件(self, file_path:Path|str, media_type:Literal['image', 'voice', 'video', 'file']) -> dict:
152
+ with open(file_path, 'rb') as f:
153
+ response = self.__client.oapi.post(url=f"/media/upload?type={media_type}", files={'media': f})
154
+ return response.json()
@@ -0,0 +1,20 @@
1
+ from dagster import In, OpExecutionContext, op
2
+
3
+ # noinspection PyProtectedMember
4
+ from dagster._annotations import experimental
5
+
6
+
7
+ @experimental
8
+ @op(description="使用钉钉 Webhook 发送文本消息",
9
+ required_resource_keys={'dingtalk_webhook'},
10
+ ins={"text": In(str)})
11
+ def op_send_simple_text(context: OpExecutionContext, text):
12
+ webhook = context.resources.dingtalk_webhook
13
+ webhook.send_text(text)
14
+
15
+ @op(description="使用钉钉 Webhook 发送 Markdown 消息",
16
+ required_resource_keys={'dingtalk_webhook'},
17
+ ins={"text": In(str), "title": In(str, default_value='')})
18
+ def op_simple_markdown(context: OpExecutionContext, text, title):
19
+ webhook = context.resources.dingtalk_webhook
20
+ webhook.send_text(text, title)
@@ -0,0 +1,221 @@
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}&timestamp={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
+ )
@@ -0,0 +1 @@
1
+ __version__ = "0.1.10"
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "dagster-dingtalk"
3
- version = "0.1.8"
3
+ version = "0.1.10"
4
4
  description = "A dagster plugin for the DingTalk"
5
5
  authors = ["YiZixuan <sqkkyzx@qq.com>"]
6
6
  readme = "README.md"
@@ -1,30 +0,0 @@
1
- # 钉钉与 Dagster 集成
2
-
3
- ---
4
-
5
- ## 介绍
6
-
7
- 该 Dagster 集成是为了更便捷的调用钉钉(DingTalk)的API,集成提供了两个 Dagster
8
- Resource 和若干 Dagster Op 的封装。
9
-
10
- ### DingTalkWebhookResource
11
-
12
- 该 Dagster 资源允许定义一个钉钉自定义机器人的 Webhook 端点,发送文本、Markdown
13
- 、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
-
23
- ### DingTalkWebhookOp
24
-
25
- 该类提供一些预定义的 Dagster Op ,以便于快速调用。
26
-
27
- 需要注意的是,这些 Op 都没有静态声明 `required_resource_keys`,而是需要根据
28
- 上下文配置 `context.op_config` 中的 `dingtalk_webhook_key` 键来动态查找
29
- 资源。在使用时,必须在 Job 中提供 `resource_defs` ,或在 `User Code` 全局
30
- 定义会使用到的 `dingtalk_webhook_key`。
@@ -1,21 +0,0 @@
1
- from dagster import In, OpExecutionContext, op
2
-
3
-
4
- class DingTalkWebhookOp:
5
- @staticmethod
6
- @op(description="使用钉钉 Webhook 发送文本消息",
7
- required_resource_keys={'dingtalk_webhook'},
8
- ins={"text": In(str)},
9
- )
10
- def send_simple_text(context: OpExecutionContext, text):
11
- webhook = context.resources.dingtalk_webhook
12
- webhook.send_text(text)
13
-
14
- @staticmethod
15
- @op(description="使用钉钉 Webhook 发送 Markdown 消息",
16
- required_resource_keys={'dingtalk_webhook'},
17
- ins={"text": In(str), "title": In(str, default_value='')},
18
- )
19
- def send_simple_markdown(context: OpExecutionContext, text, title):
20
- webhook = context.resources.dingtalk_webhook
21
- webhook.send_text(text, title)
@@ -1,370 +0,0 @@
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, ResourceDependency,
16
- )
17
- from httpx import Client
18
- from pydantic import Field, PrivateAttr
19
-
20
-
21
- class DingTalkWebhookResource(ConfigurableResource):
22
- access_token: str = Field(description="Webhook地址中的 access_token 部分")
23
- secret: Optional[str] = Field(default=None, description="如使用加签安全配置,需传签名密钥")
24
- alias: Optional[str] = Field(default=None, description="如提供别名,将来可以使用别名进行选择")
25
- base_url: str = Field(default="https://oapi.dingtalk.com/robot/send", description="Webhook的通用地址,无需更改")
26
-
27
- def webhook_url(self):
28
- if self.secret is None:
29
- return f"{self.base_url}?access_token={self.access_token}"
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
- # noinspection NonAsciiCharacters
92
- class MultiDingTalkWebhookResource(ConfigurableResource):
93
- """
94
- 该资源提供了预先定义多个 webhook 资源,并在运行时动态选择的方法。
95
-
96
- 使用示例:
97
-
98
- ```
99
- from dagster_dingtalk import DingTalkResource, MultiDingTalkResource
100
-
101
- app_apple = DingTalkResource(AppID="apple", ClientId="", ClientSecret="")
102
- app_book = DingTalkResource(AppID="book", ClientId="", ClientSecret="")
103
-
104
- @op(required_resource_keys={"dingtalk"}, ins={"app_id":In(str)})
105
- def print_app_id(context:OpExecutionContext, app_id):
106
- dingtalk:DingTalkResource = context.resources.dingtalk
107
- select_app = dingtalk.select_app(app_id)
108
- context.log.info(dingtalk_app.AppName)
109
-
110
- @job
111
- def print_app_id_job():
112
- print_app_id()
113
-
114
- defs = Definitions(
115
- jobs=[print_app_id_job],
116
- resources={
117
- "dingtalk": MultiDingTalkResource(
118
- Apps=[app_apple, app_book]
119
- )
120
- },
121
- )
122
- ```
123
-
124
- """
125
-
126
- Webhooks: ResourceDependency[List[DingTalkWebhookResource]] = Field(description="多个 Webhook 资源的列表")
127
-
128
- _webhooks = PrivateAttr()
129
-
130
- def setup_for_execution(self, context: InitResourceContext) -> None:
131
- _webhooks_token_key = {webhook.access_token:webhook for webhook in self.Webhooks}
132
- _webhooks_alias_key = {webhook.alias:webhook for webhook in self.Webhooks if webhook.alias}
133
- self._webhooks = _webhooks_token_key | _webhooks_alias_key
134
-
135
- def select(self, key:str = "_FIRST_"):
136
- try:
137
- if key == "_FIRST_" or key is None:
138
- webhook = self.Webhooks[0]
139
- else:
140
- webhook = self._webhooks[key]
141
- webhook.init_webhook_url()
142
- return webhook
143
- except KeyError:
144
- raise f"该 AccessToken 或 别名 <{key}> 不存在于提供的 Webhooks 中。请使用 DingTalkWebhookResource 定义单个 Webhook 后,将其加入 Webhooks 。"
145
-
146
-
147
- class DingTalkClient:
148
- def __init__(self, access_token: str, app_id: str, agent_id: int, robot_code: str) -> None:
149
- self.access_token: str = access_token
150
- self.app_id: str = app_id
151
- self.agent_id: int = agent_id
152
- self.robot_code: str = robot_code
153
- self.api: Client = httpx.Client(base_url="https://api.dingtalk.com/", headers={"x-acs-dingtalk-access-token": self.access_token})
154
- self.oapi: Client = httpx.Client(base_url="https://oapi.dingtalk.com/", params={"access_token": self.access_token})
155
-
156
-
157
- # noinspection NonAsciiCharacters
158
- class DingTalkResource(ConfigurableResource):
159
- """
160
- [钉钉服务端 API](https://open.dingtalk.com/document/orgapp/api-overview) 企业内部应用部分的第三方封装。
161
- 通过此资源,可以调用部分钉钉服务端API。
162
-
163
- 注意:不包含全部的API端点。
164
- """
165
-
166
- AppID: str = Field(description="应用应用唯一标识 AppID,作为缓存标识符使用。不传入则不缓存鉴权。")
167
- AgentID: Optional[int] = Field(default=None, description="原企业内部应用AgentId ,部分API会使用到。")
168
- AppName: Optional[str] = Field(default=None, description="应用名。")
169
- ClientId: str = Field(description="应用的 Client ID (原 AppKey 和 SuiteKey)")
170
- ClientSecret: str = Field(description="应用的 Client Secret (原 AppSecret 和 SuiteSecret)")
171
- RobotCode: Optional[str] = Field(default=None, description="应用的机器人 RobotCode,不传时使用 self.ClientId ")
172
-
173
- _client: DingTalkClient = PrivateAttr()
174
-
175
- @classmethod
176
- def _is_dagster_maintained(cls) -> bool:
177
- return False
178
-
179
- def _get_access_token(self) -> str:
180
- access_token_cache = Path("/tmp/.dingtalk_cache")
181
-
182
- try:
183
- with open(access_token_cache, 'rb') as f:
184
- all_access_token: Dict[str, Tuple[str, int]] = pickle.loads(f.read())
185
- access_token, expire_in = all_access_token.get(self.AppID, ('', 0))
186
- except Exception as e:
187
- print(e)
188
- all_access_token = {}
189
- access_token, expire_in = all_access_token.get(self.AppID, ('', 0))
190
-
191
- if access_token and expire_in < int(time.time()):
192
- return access_token
193
- else:
194
- print(f"应用{self.AppName}<{self.AppID}> 鉴权缓存过期或不存在,正在重新获取...")
195
- response = httpx.post(
196
- url="https://api.dingtalk.com/v1.0/oauth2/accessToken",
197
- json={"appKey": self.ClientId, "appSecret": self.ClientSecret},
198
- )
199
- access_token:str = response.json().get("accessToken")
200
- expire_in:int = response.json().get("expireIn") + int(time.time()) - 60
201
- with open(access_token_cache, 'wb') as f:
202
- all_access_token[self.AppID] = (access_token, expire_in)
203
- f.write(pickle.dumps(all_access_token))
204
- return access_token
205
-
206
- def init_client(self):
207
- if not hasattr(self, '_client'):
208
- self._client = DingTalkClient(
209
- self._get_access_token(),
210
- self.AppID,
211
- self.AgentID,
212
- self.RobotCode or self.ClientId
213
- )
214
-
215
- def setup_for_execution(self, context: InitResourceContext) -> None:
216
- self.init_client()
217
-
218
- def teardown_after_execution(self, context: InitResourceContext) -> None:
219
- self._client.api.close()
220
- self._client.oapi.close()
221
-
222
- def 智能人事(self):
223
- return API_智能人事(self._client)
224
-
225
- def 通讯录管理(self):
226
- return API_通讯录管理(self._client)
227
-
228
- def 文档文件(self):
229
- return API_文档文件(self._client)
230
-
231
-
232
- # noinspection NonAsciiCharacters
233
- class MultiDingTalkResource(ConfigurableResource):
234
- """
235
- 该资源提供了预先定义多个应用资源,并在运行时动态选择的方法。
236
-
237
- 使用示例:
238
-
239
- ```
240
- from dagster_dingtalk import DingTalkResource, MultiDingTalkResource
241
-
242
- app_apple = DingTalkResource(AppID="apple", ClientId="", ClientSecret="")
243
- app_book = DingTalkResource(AppID="book", ClientId="", ClientSecret="")
244
-
245
- @op(required_resource_keys={"dingtalk"}, ins={"app_id":In(str)})
246
- def print_app_id(context:OpExecutionContext, app_id):
247
- dingtalk:DingTalkResource = context.resources.dingtalk
248
- select_app = dingtalk.select_app(app_id)
249
- context.log.info(dingtalk_app.AppName)
250
-
251
- @job
252
- def print_app_id_job():
253
- print_app_id()
254
-
255
- defs = Definitions(
256
- jobs=[print_app_id_job],
257
- resources={
258
- "dingtalk": MultiDingTalkResource(
259
- Apps=[app_apple, app_book]
260
- )
261
- },
262
- )
263
- ```
264
-
265
- """
266
-
267
- Apps: ResourceDependency[List[DingTalkResource]] = Field(description="多个单应用资源的列表")
268
-
269
- _apps = PrivateAttr()
270
-
271
- def setup_for_execution(self, context: InitResourceContext) -> None:
272
- self._apps = {app.AppID:app for app in self.Apps}
273
-
274
- def select(self, app_id:str = "_FIRST_"):
275
- try:
276
- if app_id == "_FIRST_" or app_id is None:
277
- app = self.Apps[0]
278
- else:
279
- app = self._apps[app_id]
280
- app.init_client()
281
- return app
282
- except KeyError:
283
- raise f"该 AppID <{app_id}> 不存在于提供的 AppLists 中。请使用 DingTalkResource 定义单个 App 后,将其加入 AppLists 。"
284
-
285
-
286
- # noinspection NonAsciiCharacters
287
- class API_智能人事:
288
- def __init__(self, _client:DingTalkClient):
289
- self._client = _client
290
-
291
- def 花名册_获取花名册元数据(self) -> dict:
292
- response = self._client.oapi.post(
293
- url="/topapi/smartwork/hrm/roster/meta/get",
294
- json={"agentid": self._client.agent_id},
295
- )
296
- return response.json()
297
-
298
- def 花名册_获取员工花名册字段信息(self, user_id_list:List[str], field_filter_list:List[str]|None = None, text_to_select_convert:bool|None = None) -> dict:
299
- body_dict = {"userIdList": user_id_list, "appAgentId": self._client.agent_id}
300
- if field_filter_list is not None:
301
- body_dict["fieldFilterList"] = field_filter_list
302
- if text_to_select_convert is not None:
303
- body_dict["text2SelectConvert"] = text_to_select_convert
304
-
305
- response = self._client.api.post(url="/topapi/smartwork/hrm/roster/meta/get", json=body_dict,)
306
- return response.json()
307
-
308
- # noinspection NonAsciiCharacters
309
- class 在职员工状态(Enum):
310
- 试用期: '2'
311
- 正式: '3'
312
- 待离职: '5'
313
- 无状态: '-1'
314
-
315
- def 员工管理_获取待入职员工列表(self, offset:int, size:int) -> dict:
316
- response = self._client.oapi.post(
317
- "/topapi/smartwork/hrm/employee/querypreentry",
318
- json={"offset": offset, "size": size},
319
- )
320
- return response.json()
321
-
322
- def 员工管理_获取在职员工列表(self, status_list:List[在职员工状态], offset:int, size:int) -> dict:
323
- response = self._client.oapi.post(
324
- "/topapi/smartwork/hrm/employee/querypreentry",
325
- json={"status_list": status_list, "offset": offset, "size": size},
326
- )
327
- return response.json()
328
-
329
- def 员工管理_获取离职员工列表(self, next_token:int, max_results:int) -> dict:
330
- response = self._client.api.get(
331
- "/v1.0/hrm/employees/dismissions",
332
- params={"nextToken": next_token, "maxResults": max_results},
333
- )
334
- return response.json()
335
-
336
- def 员工管理_批量获取员工离职信息(self, user_id_list:List[str]) -> dict:
337
- response = self._client.api.get(
338
- "/v1.0/hrm/employees/dimissionInfo",
339
- params={"userIdList": user_id_list},
340
- )
341
- return response.json()
342
-
343
-
344
- # noinspection NonAsciiCharacters
345
- class API_通讯录管理:
346
- def __init__(self, _client:DingTalkClient):
347
- self._client = _client
348
-
349
- def 查询用户详情(self, user_id:str, language:str = "zh_CN") -> dict:
350
- response = self._client.oapi.post(url="/topapi/v2/user/get", json={"language": language, "userid": user_id})
351
- return response.json()
352
-
353
- def 查询离职记录列表(self, start_time:datetime, end_time:datetime|None, next_token:str, max_results:int) -> dict:
354
- params = {"startTime": start_time.strftime("%Y-%m-%dT%H:%M:%SZ"), "nextToken": next_token, "maxResults": max_results}
355
- if end_time is not None:
356
- params["endTime"] = end_time.strftime("%Y-%m-%dT%H:%M:%SZ")
357
- response = self._client.api.get(url="/v1.0/contact/empLeaveRecords", params=params)
358
- return response.json()
359
-
360
-
361
- # noinspection NonAsciiCharacters
362
- class API_文档文件:
363
- def __init__(self, _client:DingTalkClient):
364
- self._client = _client
365
-
366
- def 媒体文件_上传媒体文件(self, file_path:Path|str, media_type:Literal['image', 'voice', 'video', 'file']) -> dict:
367
- with open(file_path, 'rb') as f:
368
- response = self._client.oapi.post(url=f"/media/upload?type={media_type}", files={'media': f})
369
- return response.json()
370
-
@@ -1 +0,0 @@
1
- __version__ = "0.0.1"