ForcomeBot 2.2.4__py3-none-any.whl

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.
src/auth/dingtalk.py ADDED
@@ -0,0 +1,373 @@
1
+ """钉钉API客户端"""
2
+ import logging
3
+ import time
4
+ from typing import Optional, Dict, Any
5
+
6
+ import httpx
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class DingTalkClient:
12
+ """钉钉开放平台API客户端"""
13
+
14
+ # API基础地址
15
+ BASE_URL = "https://oapi.dingtalk.com"
16
+ NEW_API_URL = "https://api.dingtalk.com"
17
+
18
+ def __init__(
19
+ self,
20
+ app_key: str,
21
+ app_secret: str,
22
+ corp_id: str = "",
23
+ agent_id: str = ""
24
+ ):
25
+ """
26
+ 初始化钉钉客户端
27
+
28
+ Args:
29
+ app_key: 应用AppKey
30
+ app_secret: 应用AppSecret
31
+ corp_id: 企业CorpId(工作台免登需要)
32
+ agent_id: 应用AgentId(工作台免登需要)
33
+ """
34
+ self.app_key = app_key
35
+ self.app_secret = app_secret
36
+ self.corp_id = corp_id
37
+ self.agent_id = agent_id
38
+
39
+ # Access Token缓存
40
+ self._access_token: Optional[str] = None
41
+ self._token_expire_time: float = 0
42
+
43
+ # HTTP客户端
44
+ self._client = httpx.AsyncClient(timeout=30.0)
45
+
46
+ async def close(self):
47
+ """关闭HTTP客户端"""
48
+ await self._client.aclose()
49
+
50
+ async def get_access_token(self) -> str:
51
+ """
52
+ 获取企业内部应用的Access Token
53
+
54
+ Returns:
55
+ Access Token字符串
56
+ """
57
+ # 检查缓存
58
+ if self._access_token and time.time() < self._token_expire_time - 60:
59
+ return self._access_token
60
+
61
+ # 请求新Token
62
+ url = f"{self.BASE_URL}/gettoken"
63
+ params = {
64
+ "appkey": self.app_key,
65
+ "appsecret": self.app_secret
66
+ }
67
+
68
+ try:
69
+ response = await self._client.get(url, params=params)
70
+ data = response.json()
71
+
72
+ if data.get("errcode") == 0:
73
+ self._access_token = data["access_token"]
74
+ # Token有效期7200秒
75
+ self._token_expire_time = time.time() + data.get("expires_in", 7200)
76
+ logger.info("获取钉钉Access Token成功")
77
+ return self._access_token
78
+ else:
79
+ logger.error(f"获取钉钉Access Token失败: {data}")
80
+ raise Exception(f"获取Access Token失败: {data.get('errmsg')}")
81
+ except Exception as e:
82
+ logger.error(f"请求钉钉API异常: {e}")
83
+ raise
84
+
85
+ async def get_user_info_by_code(self, auth_code: str) -> Dict[str, Any]:
86
+ """
87
+ 通过授权码获取用户信息(新版OAuth2扫码登录)
88
+
89
+ Args:
90
+ auth_code: 授权码
91
+
92
+ Returns:
93
+ 用户信息字典
94
+ """
95
+ try:
96
+ # 1. 通过授权码获取用户access_token(新版OAuth2 API)
97
+ url = f"{self.NEW_API_URL}/v1.0/oauth2/userAccessToken"
98
+ body = {
99
+ "clientId": self.app_key,
100
+ "clientSecret": self.app_secret,
101
+ "code": auth_code,
102
+ "grantType": "authorization_code"
103
+ }
104
+
105
+ response = await self._client.post(url, json=body)
106
+ data = response.json()
107
+
108
+ if "accessToken" not in data:
109
+ logger.error(f"获取用户Token失败: {data}")
110
+ raise Exception(f"获取用户Token失败: {data.get('message', data)}")
111
+
112
+ user_access_token = data["accessToken"]
113
+ logger.info("获取用户access_token成功")
114
+
115
+ # 2. 使用用户access_token获取用户信息
116
+ user_info_url = f"{self.NEW_API_URL}/v1.0/contact/users/me"
117
+ headers = {"x-acs-dingtalk-access-token": user_access_token}
118
+
119
+ user_response = await self._client.get(user_info_url, headers=headers)
120
+ user_data = user_response.json()
121
+
122
+ if "unionId" not in user_data:
123
+ logger.error(f"获取用户信息失败: {user_data}")
124
+ raise Exception(f"获取用户信息失败: {user_data.get('message', user_data)}")
125
+
126
+ unionid = user_data.get("unionId")
127
+ logger.info(f"获取用户信息成功: unionId={unionid}")
128
+
129
+ # 3. 通过unionId获取企业内用户userid
130
+ userid = await self.get_userid_by_unionid(unionid)
131
+
132
+ if userid:
133
+ # 获取企业内用户详细信息
134
+ user_detail = await self.get_user_detail(userid)
135
+ return {
136
+ "userid": userid,
137
+ "unionid": unionid,
138
+ "name": user_detail.get("name", user_data.get("nick", "")),
139
+ "avatar": user_detail.get("avatar", user_data.get("avatarUrl", "")),
140
+ "mobile": user_detail.get("mobile", user_data.get("mobile", "")),
141
+ "email": user_detail.get("email", user_data.get("email", "")),
142
+ "department": self._format_department(user_detail.get("dept_id_list", [])),
143
+ "title": user_detail.get("title", ""),
144
+ }
145
+ else:
146
+ # 非企业内用户,使用OAuth返回的基本信息
147
+ return {
148
+ "userid": user_data.get("openId", unionid),
149
+ "unionid": unionid,
150
+ "name": user_data.get("nick", ""),
151
+ "avatar": user_data.get("avatarUrl", ""),
152
+ "mobile": user_data.get("mobile", ""),
153
+ "email": user_data.get("email", ""),
154
+ "department": "",
155
+ "title": "",
156
+ }
157
+
158
+ except Exception as e:
159
+ logger.error(f"通过授权码获取用户信息异常: {e}")
160
+ raise
161
+
162
+ async def get_user_info_by_code_internal(self, auth_code: str) -> Dict[str, Any]:
163
+ """
164
+ 通过免登授权码获取用户信息(企业内部应用/H5免登)
165
+
166
+ Args:
167
+ auth_code: 免登授权码(通过钉钉JSAPI获取)
168
+
169
+ Returns:
170
+ 用户信息字典
171
+ """
172
+ access_token = await self.get_access_token()
173
+
174
+ # 通过免登授权码获取用户userid
175
+ url = f"{self.BASE_URL}/topapi/v2/user/getuserinfo"
176
+ params = {"access_token": access_token}
177
+ body = {"code": auth_code}
178
+
179
+ try:
180
+ response = await self._client.post(url, params=params, json=body)
181
+ data = response.json()
182
+
183
+ if data.get("errcode") != 0:
184
+ logger.error(f"获取用户信息失败: {data}")
185
+ raise Exception(f"获取用户信息失败: {data.get('errmsg')}")
186
+
187
+ result = data.get("result", {})
188
+ userid = result.get("userid")
189
+ unionid = result.get("unionid")
190
+
191
+ if not userid:
192
+ raise Exception("未获取到用户userid")
193
+
194
+ # 获取用户详细信息
195
+ user_detail = await self.get_user_detail(userid)
196
+
197
+ return {
198
+ "userid": userid,
199
+ "unionid": unionid,
200
+ "name": user_detail.get("name", ""),
201
+ "avatar": user_detail.get("avatar", ""),
202
+ "mobile": user_detail.get("mobile", ""),
203
+ "email": user_detail.get("email", ""),
204
+ "department": self._format_department(user_detail.get("dept_id_list", [])),
205
+ "title": user_detail.get("title", ""),
206
+ }
207
+
208
+ except Exception as e:
209
+ logger.error(f"通过免登授权码获取用户信息异常: {e}")
210
+ raise
211
+
212
+ async def get_user_detail(self, userid: str) -> Dict[str, Any]:
213
+ """
214
+ 获取用户详细信息
215
+
216
+ Args:
217
+ userid: 用户ID
218
+
219
+ Returns:
220
+ 用户详细信息
221
+ """
222
+ access_token = await self.get_access_token()
223
+
224
+ url = f"{self.BASE_URL}/topapi/v2/user/get"
225
+ params = {"access_token": access_token}
226
+ body = {"userid": userid}
227
+
228
+ try:
229
+ response = await self._client.post(url, params=params, json=body)
230
+ data = response.json()
231
+
232
+ if data.get("errcode") != 0:
233
+ logger.error(f"获取用户详情失败: {data}")
234
+ raise Exception(f"获取用户详情失败: {data.get('errmsg')}")
235
+
236
+ return data.get("result", {})
237
+
238
+ except Exception as e:
239
+ logger.error(f"获取用户详情异常: {e}")
240
+ raise
241
+
242
+ async def get_userid_by_unionid(self, unionid: str) -> Optional[str]:
243
+ """
244
+ 通过UnionId获取UserId
245
+
246
+ Args:
247
+ unionid: 用户UnionId
248
+
249
+ Returns:
250
+ 用户UserId
251
+ """
252
+ access_token = await self.get_access_token()
253
+
254
+ url = f"{self.BASE_URL}/topapi/user/getbyunionid"
255
+ params = {"access_token": access_token}
256
+ body = {"unionid": unionid}
257
+
258
+ try:
259
+ response = await self._client.post(url, params=params, json=body)
260
+ data = response.json()
261
+
262
+ if data.get("errcode") != 0:
263
+ logger.warning(f"通过UnionId获取UserId失败: {data}")
264
+ return None
265
+
266
+ return data.get("result", {}).get("userid")
267
+
268
+ except Exception as e:
269
+ logger.error(f"通过UnionId获取UserId异常: {e}")
270
+ return None
271
+
272
+ async def get_jsapi_ticket(self) -> str:
273
+ """
274
+ 获取JSAPI Ticket(H5页面调用钉钉JS API需要)
275
+
276
+ Returns:
277
+ JSAPI Ticket
278
+ """
279
+ access_token = await self.get_access_token()
280
+
281
+ url = f"{self.BASE_URL}/get_jsapi_ticket"
282
+ params = {"access_token": access_token}
283
+
284
+ try:
285
+ response = await self._client.get(url, params=params)
286
+ data = response.json()
287
+
288
+ if data.get("errcode") != 0:
289
+ logger.error(f"获取JSAPI Ticket失败: {data}")
290
+ raise Exception(f"获取JSAPI Ticket失败: {data.get('errmsg')}")
291
+
292
+ return data.get("ticket", "")
293
+
294
+ except Exception as e:
295
+ logger.error(f"获取JSAPI Ticket异常: {e}")
296
+ raise
297
+
298
+ def _format_department(self, dept_ids: list) -> str:
299
+ """格式化部门信息"""
300
+ if not dept_ids:
301
+ return ""
302
+ # 简单返回部门ID列表,实际可以查询部门名称
303
+ return ",".join(str(d) for d in dept_ids)
304
+
305
+ def generate_qrcode_url(self, redirect_uri: str, state: str = "") -> str:
306
+ """
307
+ 生成钉钉扫码登录URL(新版OAuth2)
308
+
309
+ Args:
310
+ redirect_uri: 回调地址
311
+ state: 状态参数
312
+
313
+ Returns:
314
+ 扫码登录URL
315
+ """
316
+ import urllib.parse
317
+
318
+ params = {
319
+ "client_id": self.app_key,
320
+ "response_type": "code",
321
+ "scope": "openid",
322
+ "redirect_uri": redirect_uri,
323
+ "state": state or "dingtalk_login",
324
+ "prompt": "consent"
325
+ }
326
+
327
+ query = urllib.parse.urlencode(params)
328
+ return f"https://login.dingtalk.com/oauth2/auth?{query}"
329
+
330
+ def generate_h5_auth_url(self, redirect_uri: str, state: str = "") -> str:
331
+ """
332
+ 生成H5页面授权URL(用于钉钉内H5应用)
333
+
334
+ Args:
335
+ redirect_uri: 回调地址
336
+ state: 状态参数
337
+
338
+ Returns:
339
+ H5授权URL
340
+ """
341
+ import urllib.parse
342
+
343
+ params = {
344
+ "appid": self.app_key,
345
+ "response_type": "code",
346
+ "scope": "snsapi_auth",
347
+ "redirect_uri": redirect_uri,
348
+ "state": state or "dingtalk_h5"
349
+ }
350
+
351
+ query = urllib.parse.urlencode(params)
352
+ return f"https://login.dingtalk.com/oauth2/auth?{query}"
353
+
354
+
355
+ # 全局客户端实例
356
+ _dingtalk_client: Optional[DingTalkClient] = None
357
+
358
+
359
+ def init_dingtalk_client(
360
+ app_key: str,
361
+ app_secret: str,
362
+ corp_id: str = "",
363
+ agent_id: str = ""
364
+ ) -> DingTalkClient:
365
+ """初始化全局钉钉客户端"""
366
+ global _dingtalk_client
367
+ _dingtalk_client = DingTalkClient(app_key, app_secret, corp_id, agent_id)
368
+ return _dingtalk_client
369
+
370
+
371
+ def get_dingtalk_client() -> Optional[DingTalkClient]:
372
+ """获取全局钉钉客户端"""
373
+ return _dingtalk_client
@@ -0,0 +1,129 @@
1
+ """JWT Token 处理"""
2
+ import logging
3
+ from datetime import datetime, timedelta
4
+ from typing import Optional, Dict, Any
5
+
6
+ from jose import jwt, JWTError
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class JWTHandler:
12
+ """JWT Token 处理器"""
13
+
14
+ def __init__(
15
+ self,
16
+ secret_key: str,
17
+ algorithm: str = "HS256",
18
+ expire_hours: int = 24
19
+ ):
20
+ """
21
+ 初始化JWT处理器
22
+
23
+ Args:
24
+ secret_key: 密钥
25
+ algorithm: 加密算法
26
+ expire_hours: Token过期时间(小时)
27
+ """
28
+ self.secret_key = secret_key
29
+ self.algorithm = algorithm
30
+ self.expire_hours = expire_hours
31
+
32
+ def create_token(
33
+ self,
34
+ user_id: int,
35
+ dingtalk_userid: str,
36
+ name: str,
37
+ extra_data: Optional[Dict[str, Any]] = None
38
+ ) -> str:
39
+ """
40
+ 创建JWT Token
41
+
42
+ Args:
43
+ user_id: 用户ID
44
+ dingtalk_userid: 钉钉用户ID
45
+ name: 用户名称
46
+ extra_data: 额外数据
47
+
48
+ Returns:
49
+ JWT Token字符串
50
+ """
51
+ expire = datetime.utcnow() + timedelta(hours=self.expire_hours)
52
+
53
+ payload = {
54
+ "sub": str(user_id),
55
+ "dingtalk_userid": dingtalk_userid,
56
+ "name": name,
57
+ "exp": expire,
58
+ "iat": datetime.utcnow(),
59
+ }
60
+
61
+ if extra_data:
62
+ payload.update(extra_data)
63
+
64
+ token = jwt.encode(payload, self.secret_key, algorithm=self.algorithm)
65
+ return token
66
+
67
+ def verify_token(self, token: str) -> Optional[Dict[str, Any]]:
68
+ """
69
+ 验证JWT Token
70
+
71
+ Args:
72
+ token: JWT Token字符串
73
+
74
+ Returns:
75
+ 解码后的payload,验证失败返回None
76
+ """
77
+ try:
78
+ payload = jwt.decode(
79
+ token,
80
+ self.secret_key,
81
+ algorithms=[self.algorithm]
82
+ )
83
+ return payload
84
+ except JWTError as e:
85
+ logger.warning(f"JWT验证失败: {e}")
86
+ return None
87
+
88
+ def get_user_id(self, token: str) -> Optional[int]:
89
+ """
90
+ 从Token中获取用户ID
91
+
92
+ Args:
93
+ token: JWT Token字符串
94
+
95
+ Returns:
96
+ 用户ID,验证失败返回None
97
+ """
98
+ payload = self.verify_token(token)
99
+ if payload and "sub" in payload:
100
+ try:
101
+ return int(payload["sub"])
102
+ except (ValueError, TypeError):
103
+ return None
104
+ return None
105
+
106
+ def refresh_token(self, token: str) -> Optional[str]:
107
+ """
108
+ 刷新Token(延长过期时间)
109
+
110
+ Args:
111
+ token: 原JWT Token
112
+
113
+ Returns:
114
+ 新的JWT Token,验证失败返回None
115
+ """
116
+ payload = self.verify_token(token)
117
+ if not payload:
118
+ return None
119
+
120
+ # 创建新Token
121
+ return self.create_token(
122
+ user_id=int(payload["sub"]),
123
+ dingtalk_userid=payload.get("dingtalk_userid", ""),
124
+ name=payload.get("name", ""),
125
+ extra_data={
126
+ k: v for k, v in payload.items()
127
+ if k not in ("sub", "dingtalk_userid", "name", "exp", "iat")
128
+ }
129
+ )