zy-dingtalk-chat-bot 1.0.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,3 @@
1
+ include README.md
2
+ include LICENSE
3
+ recursive-include dingtalk_chat_bot *.py
@@ -0,0 +1,296 @@
1
+ Metadata-Version: 2.4
2
+ Name: zy-dingtalk-chat-bot
3
+ Version: 1.0.0
4
+ Summary: 钉钉机器人工具包,封装 HTTP 回调签名校验、回复体构造、单聊、群聊及固定 Webhook 主动推送。
5
+ Author: ZY
6
+ License: ISC
7
+ Project-URL: Homepage, https://github.com/
8
+ Keywords: dingtalk,robot,bot,chatbot
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: ISC License (ISCL)
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Topic :: Communications :: Chat
13
+ Requires-Python: >=3.8
14
+ Description-Content-Type: text/markdown
15
+ Requires-Dist: requests>=2.28
16
+ Provides-Extra: server
17
+ Requires-Dist: flask>=2.3; extra == "server"
18
+ Requires-Dist: python-dotenv>=1.0; extra == "server"
19
+
20
+ # dingtalk-chat-bot (Python)
21
+
22
+ 钉钉机器人工具包,封装 HTTP 回调签名校验、同步回复消息体、单聊发送、群聊发送,以及固定群机器人 Webhook 主动推送。
23
+
24
+ 这是 [npm 包 dingtalk-chat-bot](https://www.npmjs.com/package/dingtalk-chat-bot) 的 Python 版本,API 风格保持一致。
25
+
26
+ ## 支持范围
27
+
28
+ 这个包当前支持 **HTTP 回调方式**,不支持 Stream 长连接方式。
29
+
30
+ 钉钉机器人常见有两类接入:
31
+
32
+ - HTTP 回调:钉钉把用户消息 POST 到你的公网服务。
33
+ - Stream 长连接:你的程序主动连接钉钉并保持长连接。
34
+
35
+ 本包适合这些场景:
36
+
37
+ - 用户私聊机器人后,自动回复或异步发送单聊消息。
38
+ - 群里 @ 机器人后,回复当前群聊。
39
+ - 监控告警、定时日报等任务,通过固定群机器人 Webhook 主动推送到群。
40
+
41
+ 本包暂不包含:
42
+
43
+ - Stream 长连接收消息。
44
+ - 业务指令解析。
45
+ - 数据库、任务队列、权限系统。
46
+
47
+ ## 安装
48
+
49
+ ```bash
50
+ pip install dingtalk-chat-bot
51
+ ```
52
+
53
+ 如果你需要本地运行示例 Flask 服务:
54
+
55
+ ```bash
56
+ pip install "dingtalk-chat-bot[server]"
57
+ ```
58
+
59
+ ## 创建实例
60
+
61
+ ```python
62
+ import os
63
+ from dingtalk_chat_bot import create_dingtalk_bot
64
+
65
+ bot = create_dingtalk_bot(
66
+ app_key=os.environ["DINGTALK_APP_KEY"],
67
+ app_secret=os.environ["DINGTALK_APP_SECRET"],
68
+ robot_code=os.environ.get("DINGTALK_ROBOT_CODE"),
69
+ )
70
+ ```
71
+
72
+ 配置说明:
73
+
74
+ - `app_key`:钉钉应用的 AppKey。发送单聊消息时需要。
75
+ - `app_secret`:钉钉应用的 AppSecret。校验 HTTP 回调签名、获取 accessToken 时需要。
76
+ - `robot_code`:机器人编码。通常可以填 AppKey;如果钉钉后台单独展示 robotCode,就填 robotCode。
77
+ - `http_client`:可选,自定义 `requests.Session`。
78
+ - `signature_expires_in`:可选,HTTP 回调签名时间窗口,单位毫秒,默认 1 小时。
79
+ - `request_timeout`:可选,HTTP 请求超时时间,单位秒,默认 10。
80
+
81
+ 如果你只使用固定群机器人 Webhook 推送,可以不传 `app_key`、`app_secret`:
82
+
83
+ ```python
84
+ bot = create_dingtalk_bot()
85
+
86
+ bot.send_webhook_message(os.environ["DINGTALK_WEBHOOK_URL"], "服务正常")
87
+ ```
88
+
89
+ ## 环境变量
90
+
91
+ 仓库提供了 `.env.example` 作为模板。本地运行示例服务时,可以自己创建 `.env`:
92
+
93
+ ```bash
94
+ cp .env.example .env
95
+ ```
96
+
97
+ 示例:
98
+
99
+ ```env
100
+ PORT=3000
101
+ DINGTALK_APP_KEY=
102
+ DINGTALK_APP_SECRET=
103
+ DINGTALK_ROBOT_CODE=
104
+ DINGTALK_WEBHOOK_URL=
105
+ ```
106
+
107
+ `.env` 不应该提交到 Git,也不会进入发布包。
108
+
109
+ ## HTTP 回调签名校验
110
+
111
+ 当钉钉把消息 POST 到你的服务时,可以这样校验签名(以 Flask 为例):
112
+
113
+ ```python
114
+ from flask import Flask, jsonify, request
115
+
116
+ app = Flask(__name__)
117
+
118
+ @app.post("/dingtalk/robot")
119
+ def dingtalk_robot():
120
+ ok = bot.verify_signature(
121
+ request.headers.get("timestamp"),
122
+ request.headers.get("sign"),
123
+ )
124
+ if not ok:
125
+ return jsonify({"error": "invalid dingtalk signature"}), 401
126
+
127
+ return jsonify(bot.build_reply_payload("收到", request.get_json()))
128
+ ```
129
+
130
+ ## 同步回复
131
+
132
+ `build_reply_payload` 用来构造可直接返回给钉钉 HTTP 回调的消息体。
133
+
134
+ 普通文本:
135
+
136
+ ```python
137
+ return jsonify(bot.build_reply_payload("你好,我收到了", request.get_json()))
138
+ ```
139
+
140
+ Markdown:
141
+
142
+ ```python
143
+ return jsonify(bot.build_reply_payload({
144
+ "msgtype": "markdown",
145
+ "title": "处理结果",
146
+ "content": "## 处理结果\n\n- 状态:成功\n- 耗时:120ms",
147
+ }, request.get_json()))
148
+ ```
149
+
150
+ 如果是群聊回调,并且消息体里有 `senderStaffId`,回复会默认在第一行真实 @ 提问人。
151
+
152
+ ## 自动发送单聊或群聊
153
+
154
+ `send_message` 适合在收到钉钉回调后使用。它会根据回调消息体里的 `conversationType` 自动判断发送方式:
155
+
156
+ - `conversationType == "1"`:通过 OpenAPI 发送单聊消息。
157
+ - `conversationType == "2"`:通过回调里的 `sessionWebhook` 发送群聊消息。
158
+
159
+ ```python
160
+ bot.send_message(body, "这条消息会自动发到当前单聊或群聊")
161
+ ```
162
+
163
+ 群聊发送时默认会 @ 本次提问人。如果不想 @:
164
+
165
+ ```python
166
+ bot.send_message(body, "这条群消息不艾特任何人", {"atSender": False})
167
+ ```
168
+
169
+ 发送 Markdown:
170
+
171
+ ```python
172
+ bot.send_message(body, {
173
+ "msgtype": "markdown",
174
+ "title": "日报",
175
+ "content": "## 今日日报\n\n- 完成机器人回复\n- 支持单聊和群聊",
176
+ })
177
+ ```
178
+
179
+ ## 主动发送单聊
180
+
181
+ ```python
182
+ bot.send_private_message("用户 staffId", "你好")
183
+ ```
184
+
185
+ 多个用户:
186
+
187
+ ```python
188
+ bot.send_private_message(["staffId1", "staffId2"], "批量单聊消息")
189
+ ```
190
+
191
+ Markdown 单聊:
192
+
193
+ ```python
194
+ bot.send_private_message("用户 staffId", {
195
+ "msgtype": "markdown",
196
+ "title": "通知",
197
+ "content": "## 通知\n\n请查看最新处理结果。",
198
+ })
199
+ ```
200
+
201
+ ## 通过 sessionWebhook 发群聊
202
+
203
+ `sessionWebhook` 来自钉钉 HTTP 回调消息体,适合在用户 @ 机器人之后回复当前群聊。
204
+
205
+ ```python
206
+ bot.send_group_message(body["sessionWebhook"], "群聊回复")
207
+ ```
208
+
209
+ 指定 @ 用户:
210
+
211
+ ```python
212
+ bot.send_group_message(body["sessionWebhook"], "请关注这条消息", {
213
+ "atUserIds": ["staffId1"],
214
+ })
215
+ ```
216
+
217
+ Markdown 群聊:
218
+
219
+ ```python
220
+ bot.send_group_message(body["sessionWebhook"], {
221
+ "msgtype": "markdown",
222
+ "title": "群聊通知",
223
+ "content": "## 群聊通知\n\n- 已处理完成",
224
+ })
225
+ ```
226
+
227
+ ## 固定群机器人 Webhook 推送
228
+
229
+ 如果你在群机器人设置里复制到了固定 Webhook:
230
+
231
+ ```text
232
+ https://oapi.dingtalk.com/robot/send?access_token=xxx
233
+ ```
234
+
235
+ 可以使用 `send_webhook_message` 主动推送消息,适合监控告警、定时日报、定时巡检等场景,不需要等用户先私聊或 @ 机器人。
236
+
237
+ 普通文本:
238
+
239
+ ```python
240
+ bot.send_webhook_message(
241
+ os.environ["DINGTALK_WEBHOOK_URL"],
242
+ "监控告警:订单 API 响应超时",
243
+ )
244
+ ```
245
+
246
+ Markdown:
247
+
248
+ ```python
249
+ bot.send_webhook_message(os.environ["DINGTALK_WEBHOOK_URL"], {
250
+ "msgtype": "markdown",
251
+ "title": "监控告警",
252
+ "content": "## 监控告警\n\n- 服务:订单 API\n- 状态:响应超时\n- 请及时处理",
253
+ })
254
+ ```
255
+
256
+ @ 指定用户:
257
+
258
+ ```python
259
+ bot.send_webhook_message(
260
+ os.environ["DINGTALK_WEBHOOK_URL"],
261
+ "请关注这条告警",
262
+ {"atUserIds": ["staffId1"]},
263
+ )
264
+ ```
265
+
266
+ @ 所有人:
267
+
268
+ ```python
269
+ bot.send_webhook_message(
270
+ os.environ["DINGTALK_WEBHOOK_URL"],
271
+ "重要告警,请所有人关注",
272
+ {"isAtAll": True},
273
+ )
274
+ ```
275
+
276
+ 注意:如果固定群机器人开启了「加签」安全设置,需要把签名参数拼到 Webhook URL 上;如果开启了关键词安全设置,消息内容必须包含对应关键词。
277
+
278
+ ## 示例服务
279
+
280
+ 仓库里的 `server.py` 是一个 Flask 示例,不会进入发布包。你可以本地运行它测试 HTTP 回调:
281
+
282
+ ```bash
283
+ pip install "dingtalk-chat-bot[server]"
284
+ cp .env.example .env
285
+ python server.py
286
+ ```
287
+
288
+ 默认接口:
289
+
290
+ - `POST /dingtalk/robot`:同步回复示例。
291
+ - `POST /dingtalk/robot-async`:先同步确认,再异步发送消息示例。
292
+ - `GET /health`:健康检查。
293
+
294
+ ## 发布说明
295
+
296
+ `pyproject.toml` 的 `[tool.setuptools]` 字段只显式包含 `dingtalk_chat_bot` 包目录。因此 `.env`、`server.py`、`requirements-dev.txt`、`.venv` 等都不会进入发布包。
@@ -0,0 +1,277 @@
1
+ # dingtalk-chat-bot (Python)
2
+
3
+ 钉钉机器人工具包,封装 HTTP 回调签名校验、同步回复消息体、单聊发送、群聊发送,以及固定群机器人 Webhook 主动推送。
4
+
5
+ 这是 [npm 包 dingtalk-chat-bot](https://www.npmjs.com/package/dingtalk-chat-bot) 的 Python 版本,API 风格保持一致。
6
+
7
+ ## 支持范围
8
+
9
+ 这个包当前支持 **HTTP 回调方式**,不支持 Stream 长连接方式。
10
+
11
+ 钉钉机器人常见有两类接入:
12
+
13
+ - HTTP 回调:钉钉把用户消息 POST 到你的公网服务。
14
+ - Stream 长连接:你的程序主动连接钉钉并保持长连接。
15
+
16
+ 本包适合这些场景:
17
+
18
+ - 用户私聊机器人后,自动回复或异步发送单聊消息。
19
+ - 群里 @ 机器人后,回复当前群聊。
20
+ - 监控告警、定时日报等任务,通过固定群机器人 Webhook 主动推送到群。
21
+
22
+ 本包暂不包含:
23
+
24
+ - Stream 长连接收消息。
25
+ - 业务指令解析。
26
+ - 数据库、任务队列、权限系统。
27
+
28
+ ## 安装
29
+
30
+ ```bash
31
+ pip install dingtalk-chat-bot
32
+ ```
33
+
34
+ 如果你需要本地运行示例 Flask 服务:
35
+
36
+ ```bash
37
+ pip install "dingtalk-chat-bot[server]"
38
+ ```
39
+
40
+ ## 创建实例
41
+
42
+ ```python
43
+ import os
44
+ from dingtalk_chat_bot import create_dingtalk_bot
45
+
46
+ bot = create_dingtalk_bot(
47
+ app_key=os.environ["DINGTALK_APP_KEY"],
48
+ app_secret=os.environ["DINGTALK_APP_SECRET"],
49
+ robot_code=os.environ.get("DINGTALK_ROBOT_CODE"),
50
+ )
51
+ ```
52
+
53
+ 配置说明:
54
+
55
+ - `app_key`:钉钉应用的 AppKey。发送单聊消息时需要。
56
+ - `app_secret`:钉钉应用的 AppSecret。校验 HTTP 回调签名、获取 accessToken 时需要。
57
+ - `robot_code`:机器人编码。通常可以填 AppKey;如果钉钉后台单独展示 robotCode,就填 robotCode。
58
+ - `http_client`:可选,自定义 `requests.Session`。
59
+ - `signature_expires_in`:可选,HTTP 回调签名时间窗口,单位毫秒,默认 1 小时。
60
+ - `request_timeout`:可选,HTTP 请求超时时间,单位秒,默认 10。
61
+
62
+ 如果你只使用固定群机器人 Webhook 推送,可以不传 `app_key`、`app_secret`:
63
+
64
+ ```python
65
+ bot = create_dingtalk_bot()
66
+
67
+ bot.send_webhook_message(os.environ["DINGTALK_WEBHOOK_URL"], "服务正常")
68
+ ```
69
+
70
+ ## 环境变量
71
+
72
+ 仓库提供了 `.env.example` 作为模板。本地运行示例服务时,可以自己创建 `.env`:
73
+
74
+ ```bash
75
+ cp .env.example .env
76
+ ```
77
+
78
+ 示例:
79
+
80
+ ```env
81
+ PORT=3000
82
+ DINGTALK_APP_KEY=
83
+ DINGTALK_APP_SECRET=
84
+ DINGTALK_ROBOT_CODE=
85
+ DINGTALK_WEBHOOK_URL=
86
+ ```
87
+
88
+ `.env` 不应该提交到 Git,也不会进入发布包。
89
+
90
+ ## HTTP 回调签名校验
91
+
92
+ 当钉钉把消息 POST 到你的服务时,可以这样校验签名(以 Flask 为例):
93
+
94
+ ```python
95
+ from flask import Flask, jsonify, request
96
+
97
+ app = Flask(__name__)
98
+
99
+ @app.post("/dingtalk/robot")
100
+ def dingtalk_robot():
101
+ ok = bot.verify_signature(
102
+ request.headers.get("timestamp"),
103
+ request.headers.get("sign"),
104
+ )
105
+ if not ok:
106
+ return jsonify({"error": "invalid dingtalk signature"}), 401
107
+
108
+ return jsonify(bot.build_reply_payload("收到", request.get_json()))
109
+ ```
110
+
111
+ ## 同步回复
112
+
113
+ `build_reply_payload` 用来构造可直接返回给钉钉 HTTP 回调的消息体。
114
+
115
+ 普通文本:
116
+
117
+ ```python
118
+ return jsonify(bot.build_reply_payload("你好,我收到了", request.get_json()))
119
+ ```
120
+
121
+ Markdown:
122
+
123
+ ```python
124
+ return jsonify(bot.build_reply_payload({
125
+ "msgtype": "markdown",
126
+ "title": "处理结果",
127
+ "content": "## 处理结果\n\n- 状态:成功\n- 耗时:120ms",
128
+ }, request.get_json()))
129
+ ```
130
+
131
+ 如果是群聊回调,并且消息体里有 `senderStaffId`,回复会默认在第一行真实 @ 提问人。
132
+
133
+ ## 自动发送单聊或群聊
134
+
135
+ `send_message` 适合在收到钉钉回调后使用。它会根据回调消息体里的 `conversationType` 自动判断发送方式:
136
+
137
+ - `conversationType == "1"`:通过 OpenAPI 发送单聊消息。
138
+ - `conversationType == "2"`:通过回调里的 `sessionWebhook` 发送群聊消息。
139
+
140
+ ```python
141
+ bot.send_message(body, "这条消息会自动发到当前单聊或群聊")
142
+ ```
143
+
144
+ 群聊发送时默认会 @ 本次提问人。如果不想 @:
145
+
146
+ ```python
147
+ bot.send_message(body, "这条群消息不艾特任何人", {"atSender": False})
148
+ ```
149
+
150
+ 发送 Markdown:
151
+
152
+ ```python
153
+ bot.send_message(body, {
154
+ "msgtype": "markdown",
155
+ "title": "日报",
156
+ "content": "## 今日日报\n\n- 完成机器人回复\n- 支持单聊和群聊",
157
+ })
158
+ ```
159
+
160
+ ## 主动发送单聊
161
+
162
+ ```python
163
+ bot.send_private_message("用户 staffId", "你好")
164
+ ```
165
+
166
+ 多个用户:
167
+
168
+ ```python
169
+ bot.send_private_message(["staffId1", "staffId2"], "批量单聊消息")
170
+ ```
171
+
172
+ Markdown 单聊:
173
+
174
+ ```python
175
+ bot.send_private_message("用户 staffId", {
176
+ "msgtype": "markdown",
177
+ "title": "通知",
178
+ "content": "## 通知\n\n请查看最新处理结果。",
179
+ })
180
+ ```
181
+
182
+ ## 通过 sessionWebhook 发群聊
183
+
184
+ `sessionWebhook` 来自钉钉 HTTP 回调消息体,适合在用户 @ 机器人之后回复当前群聊。
185
+
186
+ ```python
187
+ bot.send_group_message(body["sessionWebhook"], "群聊回复")
188
+ ```
189
+
190
+ 指定 @ 用户:
191
+
192
+ ```python
193
+ bot.send_group_message(body["sessionWebhook"], "请关注这条消息", {
194
+ "atUserIds": ["staffId1"],
195
+ })
196
+ ```
197
+
198
+ Markdown 群聊:
199
+
200
+ ```python
201
+ bot.send_group_message(body["sessionWebhook"], {
202
+ "msgtype": "markdown",
203
+ "title": "群聊通知",
204
+ "content": "## 群聊通知\n\n- 已处理完成",
205
+ })
206
+ ```
207
+
208
+ ## 固定群机器人 Webhook 推送
209
+
210
+ 如果你在群机器人设置里复制到了固定 Webhook:
211
+
212
+ ```text
213
+ https://oapi.dingtalk.com/robot/send?access_token=xxx
214
+ ```
215
+
216
+ 可以使用 `send_webhook_message` 主动推送消息,适合监控告警、定时日报、定时巡检等场景,不需要等用户先私聊或 @ 机器人。
217
+
218
+ 普通文本:
219
+
220
+ ```python
221
+ bot.send_webhook_message(
222
+ os.environ["DINGTALK_WEBHOOK_URL"],
223
+ "监控告警:订单 API 响应超时",
224
+ )
225
+ ```
226
+
227
+ Markdown:
228
+
229
+ ```python
230
+ bot.send_webhook_message(os.environ["DINGTALK_WEBHOOK_URL"], {
231
+ "msgtype": "markdown",
232
+ "title": "监控告警",
233
+ "content": "## 监控告警\n\n- 服务:订单 API\n- 状态:响应超时\n- 请及时处理",
234
+ })
235
+ ```
236
+
237
+ @ 指定用户:
238
+
239
+ ```python
240
+ bot.send_webhook_message(
241
+ os.environ["DINGTALK_WEBHOOK_URL"],
242
+ "请关注这条告警",
243
+ {"atUserIds": ["staffId1"]},
244
+ )
245
+ ```
246
+
247
+ @ 所有人:
248
+
249
+ ```python
250
+ bot.send_webhook_message(
251
+ os.environ["DINGTALK_WEBHOOK_URL"],
252
+ "重要告警,请所有人关注",
253
+ {"isAtAll": True},
254
+ )
255
+ ```
256
+
257
+ 注意:如果固定群机器人开启了「加签」安全设置,需要把签名参数拼到 Webhook URL 上;如果开启了关键词安全设置,消息内容必须包含对应关键词。
258
+
259
+ ## 示例服务
260
+
261
+ 仓库里的 `server.py` 是一个 Flask 示例,不会进入发布包。你可以本地运行它测试 HTTP 回调:
262
+
263
+ ```bash
264
+ pip install "dingtalk-chat-bot[server]"
265
+ cp .env.example .env
266
+ python server.py
267
+ ```
268
+
269
+ 默认接口:
270
+
271
+ - `POST /dingtalk/robot`:同步回复示例。
272
+ - `POST /dingtalk/robot-async`:先同步确认,再异步发送消息示例。
273
+ - `GET /health`:健康检查。
274
+
275
+ ## 发布说明
276
+
277
+ `pyproject.toml` 的 `[tool.setuptools]` 字段只显式包含 `dingtalk_chat_bot` 包目录。因此 `.env`、`server.py`、`requirements-dev.txt`、`.venv` 等都不会进入发布包。
@@ -0,0 +1,4 @@
1
+ from .bot import DingTalkBot, create_dingtalk_bot
2
+
3
+ __all__ = ["DingTalkBot", "create_dingtalk_bot"]
4
+ __version__ = "1.0.0"
@@ -0,0 +1,285 @@
1
+ import base64
2
+ import hashlib
3
+ import hmac
4
+ import json
5
+ import time
6
+ from typing import Any, Dict, Iterable, List, Optional, Union
7
+
8
+ import requests
9
+
10
+
11
+ MessageInput = Union[str, Dict[str, Any]]
12
+
13
+
14
+ class DingTalkBot:
15
+ """钉钉机器人工具类,封装 HTTP 回调签名校验、回复体构造、单聊、群聊及固定 Webhook 主动推送。"""
16
+
17
+ def __init__(
18
+ self,
19
+ app_key: Optional[str] = None,
20
+ app_secret: Optional[str] = None,
21
+ robot_code: Optional[str] = None,
22
+ http_client: Optional[requests.Session] = None,
23
+ signature_expires_in: int = 60 * 60 * 1000,
24
+ request_timeout: float = 10.0,
25
+ ) -> None:
26
+ self.app_key = app_key
27
+ self.app_secret = app_secret
28
+ self.robot_code = robot_code or app_key
29
+ self.http_client = http_client or requests.Session()
30
+ self.signature_expires_in = signature_expires_in
31
+ self.request_timeout = request_timeout
32
+
33
+ self._token_cache = {"access_token": "", "expire_at": 0}
34
+
35
+ # ------------------------------------------------------------------ utils
36
+
37
+ @staticmethod
38
+ def _now_ms() -> int:
39
+ return int(time.time() * 1000)
40
+
41
+ @staticmethod
42
+ def _normalize_message(message: MessageInput) -> Dict[str, Any]:
43
+ if isinstance(message, str):
44
+ return {"msgtype": "text", "content": message}
45
+
46
+ normalized = dict(message)
47
+ normalized.setdefault("msgtype", "text")
48
+ return normalized
49
+
50
+ @staticmethod
51
+ def _normalize_user_ids(user_ids: Union[None, str, Iterable[str]]) -> List[str]:
52
+ if not user_ids:
53
+ return []
54
+ if isinstance(user_ids, str):
55
+ return [user_ids]
56
+ return [u for u in user_ids if u]
57
+
58
+ # ----------------------------------------------------------- verify sign
59
+
60
+ def verify_signature(self, timestamp: Optional[str], sign: Optional[str]) -> bool:
61
+ if not self.app_secret:
62
+ raise ValueError(
63
+ "app_secret is required to verify DingTalk callback signatures"
64
+ )
65
+
66
+ if not timestamp or not sign:
67
+ return False
68
+
69
+ try:
70
+ ts = int(timestamp)
71
+ except (TypeError, ValueError):
72
+ return False
73
+
74
+ if abs(self._now_ms() - ts) > self.signature_expires_in:
75
+ return False
76
+
77
+ string_to_sign = f"{timestamp}\n{self.app_secret}".encode("utf-8")
78
+ digest = hmac.new(
79
+ self.app_secret.encode("utf-8"), string_to_sign, hashlib.sha256
80
+ ).digest()
81
+ expected_sign = base64.b64encode(digest).decode("utf-8")
82
+
83
+ return hmac.compare_digest(expected_sign, sign)
84
+
85
+ # ------------------------------------------------------------ accessToken
86
+
87
+ def get_access_token(self) -> str:
88
+ if not self.app_key or not self.app_secret:
89
+ raise ValueError(
90
+ "app_key and app_secret are required to get DingTalk accessToken"
91
+ )
92
+
93
+ now = self._now_ms()
94
+ if self._token_cache["access_token"] and now < self._token_cache["expire_at"]:
95
+ return self._token_cache["access_token"]
96
+
97
+ resp = self.http_client.post(
98
+ "https://api.dingtalk.com/v1.0/oauth2/accessToken",
99
+ json={"appKey": self.app_key, "appSecret": self.app_secret},
100
+ headers={"Content-Type": "application/json"},
101
+ timeout=self.request_timeout,
102
+ )
103
+ data = resp.json()
104
+ access_token = data.get("accessToken")
105
+ expire_in = data.get("expireIn") or 7200
106
+
107
+ if not access_token:
108
+ raise RuntimeError(f"Failed to get DingTalk accessToken: {json.dumps(data)}")
109
+
110
+ self._token_cache = {
111
+ "access_token": access_token,
112
+ "expire_at": now + (int(expire_in) - 60) * 1000,
113
+ }
114
+ return access_token
115
+
116
+ # ----------------------------------------------------------- payloads
117
+
118
+ def build_webhook_payload(
119
+ self, message: MessageInput, target: Optional[Dict[str, Any]] = None
120
+ ) -> Dict[str, Any]:
121
+ target = target or {}
122
+ normalized = self._normalize_message(message)
123
+ at_user_ids = self._normalize_user_ids(
124
+ normalized.get("atUserIds") or target.get("atUserIds")
125
+ )
126
+ is_at_all = bool(normalized.get("isAtAll") or target.get("isAtAll"))
127
+ at_prefix = (
128
+ " ".join(f"@{u}" for u in at_user_ids) + "\n\n" if at_user_ids else ""
129
+ )
130
+ content = normalized.get("content") or normalized.get("text") or ""
131
+
132
+ if normalized["msgtype"] == "markdown":
133
+ return {
134
+ "msgtype": "markdown",
135
+ "markdown": {
136
+ "title": normalized.get("title") or "Markdown message",
137
+ "text": f"{at_prefix}{content}",
138
+ },
139
+ "at": {"atUserIds": at_user_ids, "isAtAll": is_at_all},
140
+ }
141
+
142
+ return {
143
+ "msgtype": "text",
144
+ "text": {"content": f"{at_prefix}{content}"},
145
+ "at": {"atUserIds": at_user_ids, "isAtAll": is_at_all},
146
+ }
147
+
148
+ def build_reply_payload(
149
+ self, message: MessageInput, body: Optional[Dict[str, Any]] = None
150
+ ) -> Dict[str, Any]:
151
+ body = body or {}
152
+ normalized = self._normalize_message(message)
153
+ sender_staff_id = body.get("senderStaffId")
154
+ is_group_chat = body.get("conversationType") == "2"
155
+ at_user_ids = [sender_staff_id] if is_group_chat and sender_staff_id else []
156
+ content = normalized.get("content") or normalized.get("text") or ""
157
+
158
+ if normalized["msgtype"] == "markdown":
159
+ text = f"@{sender_staff_id}\n\n{content}" if at_user_ids else content
160
+ return {
161
+ "msgtype": "markdown",
162
+ "markdown": {
163
+ "title": normalized.get("title") or "Markdown message",
164
+ "text": text,
165
+ },
166
+ "at": {"atUserIds": at_user_ids, "isAtAll": False},
167
+ }
168
+
169
+ text = f"@{sender_staff_id}\n\n{content}" if at_user_ids else content
170
+ return {
171
+ "msgtype": "text",
172
+ "text": {"content": text},
173
+ "at": {"atUserIds": at_user_ids, "isAtAll": False},
174
+ }
175
+
176
+ # ---------------------------------------------------------- send actions
177
+
178
+ def send_private_message(
179
+ self,
180
+ user_ids: Union[str, Iterable[str]],
181
+ message: MessageInput,
182
+ ) -> Dict[str, Any]:
183
+ normalized_user_ids = self._normalize_user_ids(user_ids)
184
+ if not normalized_user_ids:
185
+ raise ValueError("user_ids are required")
186
+
187
+ normalized = self._normalize_message(message)
188
+ access_token = self.get_access_token()
189
+
190
+ if normalized["msgtype"] == "markdown":
191
+ msg_param = {
192
+ "title": normalized.get("title") or "Markdown message",
193
+ "text": normalized.get("content") or normalized.get("text") or "",
194
+ }
195
+ default_msg_key = "sampleMarkdown"
196
+ else:
197
+ msg_param = {
198
+ "content": normalized.get("content") or normalized.get("text") or ""
199
+ }
200
+ default_msg_key = "sampleText"
201
+
202
+ resp = self.http_client.post(
203
+ "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend",
204
+ json={
205
+ "robotCode": self.robot_code,
206
+ "userIds": normalized_user_ids,
207
+ "msgKey": normalized.get("msgKey") or default_msg_key,
208
+ "msgParam": json.dumps(msg_param, ensure_ascii=False),
209
+ },
210
+ headers={
211
+ "Content-Type": "application/json",
212
+ "x-acs-dingtalk-access-token": access_token,
213
+ },
214
+ timeout=self.request_timeout,
215
+ )
216
+ return resp.json()
217
+
218
+ def send_group_message(
219
+ self,
220
+ session_webhook: str,
221
+ message: MessageInput,
222
+ options: Optional[Dict[str, Any]] = None,
223
+ ) -> Dict[str, Any]:
224
+ if not session_webhook:
225
+ raise ValueError("session_webhook is required for group messages")
226
+
227
+ resp = self.http_client.post(
228
+ session_webhook,
229
+ json=self.build_webhook_payload(message, options or {}),
230
+ headers={"Content-Type": "application/json"},
231
+ timeout=self.request_timeout,
232
+ )
233
+ return resp.json()
234
+
235
+ def send_webhook_message(
236
+ self,
237
+ webhook_url: str,
238
+ message: MessageInput,
239
+ options: Optional[Dict[str, Any]] = None,
240
+ ) -> Dict[str, Any]:
241
+ if not webhook_url:
242
+ raise ValueError("webhook_url is required")
243
+
244
+ resp = self.http_client.post(
245
+ webhook_url,
246
+ json=self.build_webhook_payload(message, options or {}),
247
+ headers={"Content-Type": "application/json"},
248
+ timeout=self.request_timeout,
249
+ )
250
+ return resp.json()
251
+
252
+ def send_message(
253
+ self,
254
+ target: Dict[str, Any],
255
+ message: MessageInput,
256
+ options: Optional[Dict[str, Any]] = None,
257
+ ) -> Dict[str, Any]:
258
+ options = dict(options or {})
259
+ conversation_type = target.get("conversationType")
260
+
261
+ if conversation_type == "2":
262
+ at_sender = options.pop("atSender", True) is not False
263
+ sender_staff_id = target.get("senderStaffId")
264
+ at_user_ids = (
265
+ [sender_staff_id]
266
+ if at_sender and sender_staff_id
267
+ else options.get("atUserIds")
268
+ )
269
+ options["atUserIds"] = at_user_ids
270
+
271
+ return self.send_group_message(
272
+ target.get("sessionWebhook"), message, options
273
+ )
274
+
275
+ user_ids = (
276
+ target.get("userIds")
277
+ or target.get("userId")
278
+ or target.get("senderStaffId")
279
+ )
280
+ return self.send_private_message(user_ids, message)
281
+
282
+
283
+ def create_dingtalk_bot(**kwargs: Any) -> DingTalkBot:
284
+ """与 npm 包风格一致的工厂函数。"""
285
+ return DingTalkBot(**kwargs)
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "zy-dingtalk-chat-bot"
7
+ version = "1.0.0"
8
+ description = "钉钉机器人工具包,封装 HTTP 回调签名校验、回复体构造、单聊、群聊及固定 Webhook 主动推送。"
9
+ readme = "README.md"
10
+ license = { text = "ISC" }
11
+ authors = [{ name = "ZY" }]
12
+ requires-python = ">=3.8"
13
+ keywords = ["dingtalk", "robot", "bot", "chatbot"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: ISC License (ISCL)",
17
+ "Operating System :: OS Independent",
18
+ "Topic :: Communications :: Chat",
19
+ ]
20
+ dependencies = [
21
+ "requests>=2.28",
22
+ ]
23
+
24
+ [project.optional-dependencies]
25
+ server = [
26
+ "flask>=2.3",
27
+ "python-dotenv>=1.0",
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/"
32
+
33
+ [tool.setuptools]
34
+ packages = ["dingtalk_chat_bot"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,296 @@
1
+ Metadata-Version: 2.4
2
+ Name: zy-dingtalk-chat-bot
3
+ Version: 1.0.0
4
+ Summary: 钉钉机器人工具包,封装 HTTP 回调签名校验、回复体构造、单聊、群聊及固定 Webhook 主动推送。
5
+ Author: ZY
6
+ License: ISC
7
+ Project-URL: Homepage, https://github.com/
8
+ Keywords: dingtalk,robot,bot,chatbot
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: ISC License (ISCL)
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Topic :: Communications :: Chat
13
+ Requires-Python: >=3.8
14
+ Description-Content-Type: text/markdown
15
+ Requires-Dist: requests>=2.28
16
+ Provides-Extra: server
17
+ Requires-Dist: flask>=2.3; extra == "server"
18
+ Requires-Dist: python-dotenv>=1.0; extra == "server"
19
+
20
+ # dingtalk-chat-bot (Python)
21
+
22
+ 钉钉机器人工具包,封装 HTTP 回调签名校验、同步回复消息体、单聊发送、群聊发送,以及固定群机器人 Webhook 主动推送。
23
+
24
+ 这是 [npm 包 dingtalk-chat-bot](https://www.npmjs.com/package/dingtalk-chat-bot) 的 Python 版本,API 风格保持一致。
25
+
26
+ ## 支持范围
27
+
28
+ 这个包当前支持 **HTTP 回调方式**,不支持 Stream 长连接方式。
29
+
30
+ 钉钉机器人常见有两类接入:
31
+
32
+ - HTTP 回调:钉钉把用户消息 POST 到你的公网服务。
33
+ - Stream 长连接:你的程序主动连接钉钉并保持长连接。
34
+
35
+ 本包适合这些场景:
36
+
37
+ - 用户私聊机器人后,自动回复或异步发送单聊消息。
38
+ - 群里 @ 机器人后,回复当前群聊。
39
+ - 监控告警、定时日报等任务,通过固定群机器人 Webhook 主动推送到群。
40
+
41
+ 本包暂不包含:
42
+
43
+ - Stream 长连接收消息。
44
+ - 业务指令解析。
45
+ - 数据库、任务队列、权限系统。
46
+
47
+ ## 安装
48
+
49
+ ```bash
50
+ pip install dingtalk-chat-bot
51
+ ```
52
+
53
+ 如果你需要本地运行示例 Flask 服务:
54
+
55
+ ```bash
56
+ pip install "dingtalk-chat-bot[server]"
57
+ ```
58
+
59
+ ## 创建实例
60
+
61
+ ```python
62
+ import os
63
+ from dingtalk_chat_bot import create_dingtalk_bot
64
+
65
+ bot = create_dingtalk_bot(
66
+ app_key=os.environ["DINGTALK_APP_KEY"],
67
+ app_secret=os.environ["DINGTALK_APP_SECRET"],
68
+ robot_code=os.environ.get("DINGTALK_ROBOT_CODE"),
69
+ )
70
+ ```
71
+
72
+ 配置说明:
73
+
74
+ - `app_key`:钉钉应用的 AppKey。发送单聊消息时需要。
75
+ - `app_secret`:钉钉应用的 AppSecret。校验 HTTP 回调签名、获取 accessToken 时需要。
76
+ - `robot_code`:机器人编码。通常可以填 AppKey;如果钉钉后台单独展示 robotCode,就填 robotCode。
77
+ - `http_client`:可选,自定义 `requests.Session`。
78
+ - `signature_expires_in`:可选,HTTP 回调签名时间窗口,单位毫秒,默认 1 小时。
79
+ - `request_timeout`:可选,HTTP 请求超时时间,单位秒,默认 10。
80
+
81
+ 如果你只使用固定群机器人 Webhook 推送,可以不传 `app_key`、`app_secret`:
82
+
83
+ ```python
84
+ bot = create_dingtalk_bot()
85
+
86
+ bot.send_webhook_message(os.environ["DINGTALK_WEBHOOK_URL"], "服务正常")
87
+ ```
88
+
89
+ ## 环境变量
90
+
91
+ 仓库提供了 `.env.example` 作为模板。本地运行示例服务时,可以自己创建 `.env`:
92
+
93
+ ```bash
94
+ cp .env.example .env
95
+ ```
96
+
97
+ 示例:
98
+
99
+ ```env
100
+ PORT=3000
101
+ DINGTALK_APP_KEY=
102
+ DINGTALK_APP_SECRET=
103
+ DINGTALK_ROBOT_CODE=
104
+ DINGTALK_WEBHOOK_URL=
105
+ ```
106
+
107
+ `.env` 不应该提交到 Git,也不会进入发布包。
108
+
109
+ ## HTTP 回调签名校验
110
+
111
+ 当钉钉把消息 POST 到你的服务时,可以这样校验签名(以 Flask 为例):
112
+
113
+ ```python
114
+ from flask import Flask, jsonify, request
115
+
116
+ app = Flask(__name__)
117
+
118
+ @app.post("/dingtalk/robot")
119
+ def dingtalk_robot():
120
+ ok = bot.verify_signature(
121
+ request.headers.get("timestamp"),
122
+ request.headers.get("sign"),
123
+ )
124
+ if not ok:
125
+ return jsonify({"error": "invalid dingtalk signature"}), 401
126
+
127
+ return jsonify(bot.build_reply_payload("收到", request.get_json()))
128
+ ```
129
+
130
+ ## 同步回复
131
+
132
+ `build_reply_payload` 用来构造可直接返回给钉钉 HTTP 回调的消息体。
133
+
134
+ 普通文本:
135
+
136
+ ```python
137
+ return jsonify(bot.build_reply_payload("你好,我收到了", request.get_json()))
138
+ ```
139
+
140
+ Markdown:
141
+
142
+ ```python
143
+ return jsonify(bot.build_reply_payload({
144
+ "msgtype": "markdown",
145
+ "title": "处理结果",
146
+ "content": "## 处理结果\n\n- 状态:成功\n- 耗时:120ms",
147
+ }, request.get_json()))
148
+ ```
149
+
150
+ 如果是群聊回调,并且消息体里有 `senderStaffId`,回复会默认在第一行真实 @ 提问人。
151
+
152
+ ## 自动发送单聊或群聊
153
+
154
+ `send_message` 适合在收到钉钉回调后使用。它会根据回调消息体里的 `conversationType` 自动判断发送方式:
155
+
156
+ - `conversationType == "1"`:通过 OpenAPI 发送单聊消息。
157
+ - `conversationType == "2"`:通过回调里的 `sessionWebhook` 发送群聊消息。
158
+
159
+ ```python
160
+ bot.send_message(body, "这条消息会自动发到当前单聊或群聊")
161
+ ```
162
+
163
+ 群聊发送时默认会 @ 本次提问人。如果不想 @:
164
+
165
+ ```python
166
+ bot.send_message(body, "这条群消息不艾特任何人", {"atSender": False})
167
+ ```
168
+
169
+ 发送 Markdown:
170
+
171
+ ```python
172
+ bot.send_message(body, {
173
+ "msgtype": "markdown",
174
+ "title": "日报",
175
+ "content": "## 今日日报\n\n- 完成机器人回复\n- 支持单聊和群聊",
176
+ })
177
+ ```
178
+
179
+ ## 主动发送单聊
180
+
181
+ ```python
182
+ bot.send_private_message("用户 staffId", "你好")
183
+ ```
184
+
185
+ 多个用户:
186
+
187
+ ```python
188
+ bot.send_private_message(["staffId1", "staffId2"], "批量单聊消息")
189
+ ```
190
+
191
+ Markdown 单聊:
192
+
193
+ ```python
194
+ bot.send_private_message("用户 staffId", {
195
+ "msgtype": "markdown",
196
+ "title": "通知",
197
+ "content": "## 通知\n\n请查看最新处理结果。",
198
+ })
199
+ ```
200
+
201
+ ## 通过 sessionWebhook 发群聊
202
+
203
+ `sessionWebhook` 来自钉钉 HTTP 回调消息体,适合在用户 @ 机器人之后回复当前群聊。
204
+
205
+ ```python
206
+ bot.send_group_message(body["sessionWebhook"], "群聊回复")
207
+ ```
208
+
209
+ 指定 @ 用户:
210
+
211
+ ```python
212
+ bot.send_group_message(body["sessionWebhook"], "请关注这条消息", {
213
+ "atUserIds": ["staffId1"],
214
+ })
215
+ ```
216
+
217
+ Markdown 群聊:
218
+
219
+ ```python
220
+ bot.send_group_message(body["sessionWebhook"], {
221
+ "msgtype": "markdown",
222
+ "title": "群聊通知",
223
+ "content": "## 群聊通知\n\n- 已处理完成",
224
+ })
225
+ ```
226
+
227
+ ## 固定群机器人 Webhook 推送
228
+
229
+ 如果你在群机器人设置里复制到了固定 Webhook:
230
+
231
+ ```text
232
+ https://oapi.dingtalk.com/robot/send?access_token=xxx
233
+ ```
234
+
235
+ 可以使用 `send_webhook_message` 主动推送消息,适合监控告警、定时日报、定时巡检等场景,不需要等用户先私聊或 @ 机器人。
236
+
237
+ 普通文本:
238
+
239
+ ```python
240
+ bot.send_webhook_message(
241
+ os.environ["DINGTALK_WEBHOOK_URL"],
242
+ "监控告警:订单 API 响应超时",
243
+ )
244
+ ```
245
+
246
+ Markdown:
247
+
248
+ ```python
249
+ bot.send_webhook_message(os.environ["DINGTALK_WEBHOOK_URL"], {
250
+ "msgtype": "markdown",
251
+ "title": "监控告警",
252
+ "content": "## 监控告警\n\n- 服务:订单 API\n- 状态:响应超时\n- 请及时处理",
253
+ })
254
+ ```
255
+
256
+ @ 指定用户:
257
+
258
+ ```python
259
+ bot.send_webhook_message(
260
+ os.environ["DINGTALK_WEBHOOK_URL"],
261
+ "请关注这条告警",
262
+ {"atUserIds": ["staffId1"]},
263
+ )
264
+ ```
265
+
266
+ @ 所有人:
267
+
268
+ ```python
269
+ bot.send_webhook_message(
270
+ os.environ["DINGTALK_WEBHOOK_URL"],
271
+ "重要告警,请所有人关注",
272
+ {"isAtAll": True},
273
+ )
274
+ ```
275
+
276
+ 注意:如果固定群机器人开启了「加签」安全设置,需要把签名参数拼到 Webhook URL 上;如果开启了关键词安全设置,消息内容必须包含对应关键词。
277
+
278
+ ## 示例服务
279
+
280
+ 仓库里的 `server.py` 是一个 Flask 示例,不会进入发布包。你可以本地运行它测试 HTTP 回调:
281
+
282
+ ```bash
283
+ pip install "dingtalk-chat-bot[server]"
284
+ cp .env.example .env
285
+ python server.py
286
+ ```
287
+
288
+ 默认接口:
289
+
290
+ - `POST /dingtalk/robot`:同步回复示例。
291
+ - `POST /dingtalk/robot-async`:先同步确认,再异步发送消息示例。
292
+ - `GET /health`:健康检查。
293
+
294
+ ## 发布说明
295
+
296
+ `pyproject.toml` 的 `[tool.setuptools]` 字段只显式包含 `dingtalk_chat_bot` 包目录。因此 `.env`、`server.py`、`requirements-dev.txt`、`.venv` 等都不会进入发布包。
@@ -0,0 +1,10 @@
1
+ MANIFEST.in
2
+ README.md
3
+ pyproject.toml
4
+ dingtalk_chat_bot/__init__.py
5
+ dingtalk_chat_bot/bot.py
6
+ zy_dingtalk_chat_bot.egg-info/PKG-INFO
7
+ zy_dingtalk_chat_bot.egg-info/SOURCES.txt
8
+ zy_dingtalk_chat_bot.egg-info/dependency_links.txt
9
+ zy_dingtalk_chat_bot.egg-info/requires.txt
10
+ zy_dingtalk_chat_bot.egg-info/top_level.txt
@@ -0,0 +1,5 @@
1
+ requests>=2.28
2
+
3
+ [server]
4
+ flask>=2.3
5
+ python-dotenv>=1.0