wpsbot 0.1.0__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.
wpsbot/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ # -*- coding: utf-8 -*-
2
+ """WpsBot —— WPS 365 开放平台 Python SDK。
3
+
4
+ 提供 OAuth2 单点登录与应用机器人消息发送能力,仅依赖 requests。
5
+ """
6
+ from .client import WpsBot, WpsOAuthError, normalize_user
7
+
8
+ __all__ = ["WpsBot", "WpsOAuthError", "normalize_user"]
9
+ __version__ = "0.1.0"
wpsbot/client.py ADDED
@@ -0,0 +1,471 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ WpsBot —— WPS 开放平台单点登录客户端(独立可复用模块,仅依赖 requests)。
4
+
5
+ 特点:
6
+ - 配置通过构造参数显式传入,不读取 .env / 环境变量
7
+ - 完整 OAuth2 流程:授权链接 -> code 换 token -> 刷新 token
8
+ - KSO-1 签名(HMAC-SHA256)
9
+ - 用户信息获取(/v7/users/current 基础信息 + /v7/users/{id} 通讯录详情)
10
+ - 自动合并两接口:头像(current)+ 部门/岗位/邮箱(detail)
11
+
12
+ 用法:
13
+ from wpsbot import WpsBot
14
+ bot = WpsBot(
15
+ client_id="你的APPID",
16
+ client_secret="你的APPKEY",
17
+ redirect_uri="http://your.domain/auth/callback",
18
+ )
19
+
20
+ # 1) 生成授权链接
21
+ auth_url = bot.build_authorize_url(state='random123')
22
+
23
+ # 2) 用回调 code 换 token
24
+ token = bot.exchange_code(code)
25
+
26
+ # 3) 获取当前用户完整信息(基础+详情自动合并)
27
+ user = bot.fetch_complete_user(token['access_token'], user_id='JV2KlR3')
28
+
29
+ 可配置项(均有默认值,一般只需传 client_id / client_secret / redirect_uri):
30
+ scopes=['kso.user_base.read', 'kso.contact.read'] 授权 scope(部门/岗位需 contact.read)
31
+ base_url='https://openapi.wps.cn' 开放平台地址
32
+ sign_enabled=True KSO-1 签名(KSO 接口必须签名,否则 403 invalid sign)
33
+ timeout=15 请求超时(秒)
34
+ user_detail_enabled=True 是否自动拉取通讯录详情(部门/岗位)
35
+ userinfo_paths=['/v7/users/current'] 用户信息接口候选路径
36
+ """
37
+ import hashlib
38
+ import hmac
39
+ import json
40
+ import time
41
+ from email.utils import formatdate
42
+ from urllib.parse import urlencode
43
+
44
+ try:
45
+ import requests as _requests
46
+ except ImportError:
47
+ raise ImportError("WpsBot 需要 requests 库: pip install requests")
48
+
49
+
50
+ class WpsOAuthError(Exception):
51
+ """携带可读错误信息与原始响应。"""
52
+
53
+ def __init__(self, message, detail=None, status=None):
54
+ super().__init__(message)
55
+ self.message = message
56
+ self.detail = detail
57
+ self.status = status
58
+
59
+
60
+ class WpsBot:
61
+ """
62
+ WPS 开放平台 OAuth2 单点登录客户端。
63
+
64
+ 构造参数:
65
+ client_id 必填,WPS 应用 APPID
66
+ client_secret 必填,WPS 应用 APPKEY
67
+ redirect_uri 必填,授权回调地址(须与控制台配置精确一致)
68
+ scopes 可选,默认 ['kso.user_base.read']
69
+ base_url 可选,默认 'https://openapi.wps.cn'
70
+ sign_enabled 可选,默认 False
71
+ timeout 可选,默认 15
72
+ user_detail_enabled 可选,默认 True(自动拉取部门/岗位)
73
+ userinfo_paths 可选,用户信息接口候选路径
74
+ """
75
+
76
+ def __init__(self, client_id=None, client_secret=None, redirect_uri=None,
77
+ scopes=None, base_url=None, sign_enabled=True,
78
+ timeout=15, user_detail_enabled=True, userinfo_paths=None):
79
+ if not client_id or not client_secret or not redirect_uri:
80
+ raise WpsOAuthError(
81
+ "client_id / client_secret / redirect_uri 均必填,请通过构造参数传入")
82
+ self.client_id = str(client_id)
83
+ self.client_secret = str(client_secret)
84
+ self.redirect_uri = str(redirect_uri)
85
+ # 默认含 kso.contact.read:部门/岗位需要通讯录权限
86
+ self.scopes = list(scopes) if scopes else ["kso.user_base.read", "kso.contact.read"]
87
+ self.base_url = (base_url or "https://openapi.wps.cn").rstrip("/")
88
+ self.sign_enabled = bool(sign_enabled)
89
+ self.timeout = int(timeout)
90
+ self.user_detail_enabled = bool(user_detail_enabled)
91
+ self._userinfo_paths = list(userinfo_paths) if userinfo_paths else ["/v7/users/current"]
92
+
93
+ # ------------------------------------------------------------------ #
94
+ # 1. 授权链接
95
+ # ------------------------------------------------------------------ #
96
+ def build_authorize_url(self, state=""):
97
+ params = {
98
+ "client_id": self.client_id,
99
+ "response_type": "code",
100
+ "redirect_uri": self.redirect_uri,
101
+ "scope": ",".join(self.scopes),
102
+ "state": state,
103
+ }
104
+ return self.base_url + "/oauth2/auth?" + urlencode(params)
105
+
106
+ # ------------------------------------------------------------------ #
107
+ # 2. code 换 token / 刷新
108
+ # ------------------------------------------------------------------ #
109
+ def exchange_code(self, code):
110
+ return self._token_request({
111
+ "grant_type": "authorization_code",
112
+ "client_id": self.client_id,
113
+ "client_secret": self.client_secret,
114
+ "code": code,
115
+ "redirect_uri": self.redirect_uri,
116
+ })
117
+
118
+ def refresh(self, refresh_token):
119
+ return self._token_request({
120
+ "grant_type": "refresh_token",
121
+ "client_id": self.client_id,
122
+ "client_secret": self.client_secret,
123
+ "refresh_token": refresh_token,
124
+ })
125
+
126
+ def _token_request(self, payload):
127
+ url = self.base_url + "/oauth2/token"
128
+ try:
129
+ resp = _requests.post(url, data=payload,
130
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
131
+ timeout=self.timeout)
132
+ except _requests.RequestException as exc:
133
+ raise WpsOAuthError("请求 token 接口失败: " + str(exc))
134
+
135
+ body = _safe_json(resp)
136
+ if resp.status_code != 200 or "access_token" not in body:
137
+ raise WpsOAuthError(
138
+ "换取 access_token 失败 (HTTP {0})".format(resp.status_code),
139
+ detail=body if body else resp.text[:2000], status=resp.status_code)
140
+
141
+ issued_at = int(time.time())
142
+ expires_in = int(body.get("expires_in") or 7200)
143
+ return {
144
+ "access_token": body["access_token"],
145
+ "token_type": body.get("token_type", "bearer"),
146
+ "expires_in": expires_in,
147
+ "expires_at": issued_at + expires_in,
148
+ "refresh_token": body.get("refresh_token", ""),
149
+ "issued_at": issued_at,
150
+ "raw": body,
151
+ }
152
+
153
+ # ------------------------------------------------------------------ #
154
+ # 3. KSO-1 签名
155
+ # ------------------------------------------------------------------ #
156
+ def kso1_headers(self, method, request_uri, content_type, body=b""):
157
+ kso_date = formatdate(timeval=None, localtime=False, usegmt=True)
158
+ # 严格按官方 Python 示例:请求体为空时 sha256Hex 为空字符串(不拼接)
159
+ sha256_hex = ""
160
+ if body is not None and len(body) > 0:
161
+ sha256_hex = hashlib.sha256(body).hexdigest()
162
+ data_to_sign = "KSO-1" + method.upper() + request_uri + content_type + kso_date + sha256_hex
163
+ signature = hmac.new(
164
+ self.client_secret.encode("utf-8"),
165
+ data_to_sign.encode("utf-8"),
166
+ hashlib.sha256,
167
+ ).hexdigest()
168
+ return {
169
+ "X-Kso-Date": kso_date,
170
+ "X-Kso-Authorization": "KSO-1 " + self.client_id + ":" + signature,
171
+ "_data_to_sign": data_to_sign, # 仅用于调试展示,发送前会被剔除
172
+ }
173
+
174
+ # ------------------------------------------------------------------ #
175
+ # 4. 获取用户信息
176
+ # ------------------------------------------------------------------ #
177
+ def fetch_user_info(self, access_token):
178
+ """按候选路径顺序尝试,返回 (normalized_user, raw_body, attempts)。
179
+
180
+ /v7/users/current 返回基础信息,含头像。
181
+ """
182
+ attempts = []
183
+ last_error = None
184
+ for path in self._userinfo_paths:
185
+ try:
186
+ status, body, debug = self._api_get(path, access_token)
187
+ except WpsOAuthError as exc:
188
+ attempts.append({"path": path, "ok": False, "error": exc.message})
189
+ last_error = exc
190
+ continue
191
+ code = body.get("code") if isinstance(body, dict) else None
192
+ data = body.get("data") if isinstance(body, dict) else None
193
+ record = {"path": path, "status": status, "debug": debug}
194
+ if status == 200 and (code in (0, None)) and data:
195
+ record["ok"] = True
196
+ attempts.append(record)
197
+ return normalize_user(data), body, attempts
198
+ record["ok"] = False
199
+ record["error"] = _extract_error(body, status)
200
+ attempts.append(record)
201
+ last_error = WpsOAuthError(record["error"], detail=body, status=status)
202
+
203
+ raise last_error or WpsOAuthError("获取用户信息失败:没有可用的接口路径")
204
+
205
+ def fetch_user_detail(self, access_token, user_id):
206
+ """用 user_id 查通讯录详情(部门、岗位、邮箱等)。需要 kso.contact.read。"""
207
+ path = "/v7/users/" + str(user_id)
208
+ try:
209
+ status, body, debug = self._api_get(path, access_token,
210
+ params={"with_dept": "true"})
211
+ except WpsOAuthError as exc:
212
+ return None, {"path": path, "ok": False, "error": exc.message}
213
+ code = body.get("code") if isinstance(body, dict) else None
214
+ data = body.get("data") if isinstance(body, dict) else None
215
+ record = {"path": path, "status": status, "debug": debug}
216
+ if status == 200 and (code in (0, None)) and data:
217
+ record["ok"] = True
218
+ return normalize_user(data), record
219
+ record["ok"] = False
220
+ record["error"] = _extract_error(body, status)
221
+ return None, record
222
+
223
+ def fetch_complete_user(self, access_token, user_id=None):
224
+ """获取用户完整信息:基础(头像)+ 详情(部门/岗位/邮箱)自动合并。
225
+
226
+ 返回 (merged_user, debug_info)。
227
+ - 基础信息来自 /v7/users/current(必有头像)
228
+ - 详情来自 /v7/users/{id}(部门/岗位/邮箱),user_id 缺省用基础信息里的 id
229
+ """
230
+ base_user, _, attempts = self.fetch_user_info(access_token)
231
+ merged = dict(base_user)
232
+ debug = {"current": attempts, "detail": None}
233
+
234
+ uid = user_id or base_user.get("id")
235
+ if uid and self.user_detail_enabled:
236
+ detail, detail_debug = self.fetch_user_detail(access_token, uid)
237
+ debug["detail"] = detail_debug
238
+ if detail:
239
+ # 详情补全:不覆盖基础信息里已有的头像
240
+ for k, v in detail.items():
241
+ if v and not merged.get(k):
242
+ merged[k] = v
243
+ return merged, debug
244
+
245
+ def _api_get(self, path, access_token, params=None):
246
+ query = ("?" + urlencode(params)) if params else ""
247
+ request_uri = path + query
248
+ content_type = "application/json"
249
+ headers = {
250
+ "Authorization": "Bearer " + access_token,
251
+ "Content-Type": content_type,
252
+ "Accept": "application/json",
253
+ }
254
+ debug = {"request_uri": request_uri}
255
+ if self.sign_enabled:
256
+ sign = self.kso1_headers("GET", request_uri, content_type, b"")
257
+ debug["data_to_sign"] = sign.pop("_data_to_sign")
258
+ headers.update(sign)
259
+ debug["signed"] = True
260
+ else:
261
+ debug["signed"] = False
262
+ url = self.base_url + request_uri
263
+ try:
264
+ resp = _requests.get(url, headers=headers, timeout=self.timeout)
265
+ except _requests.RequestException as exc:
266
+ raise WpsOAuthError("请求 " + path + " 失败: " + str(exc))
267
+ return resp.status_code, _safe_json(resp), debug
268
+
269
+ # ------------------------------------------------------------------ #
270
+ # 5. 应用授权 token(发消息等应用身份接口用)
271
+ # ------------------------------------------------------------------ #
272
+ def get_app_token(self):
273
+ """获取应用授权 access_token(client_credentials 模式)。
274
+
275
+ 用于调用应用身份接口(如发消息、通讯录管理等),与用户授权 token 不同。
276
+ 有效期 2 小时,建议缓存复用。
277
+ """
278
+ payload = {
279
+ "grant_type": "client_credentials",
280
+ "client_id": self.client_id,
281
+ "client_secret": self.client_secret,
282
+ }
283
+ return self._token_request(payload)
284
+
285
+ # ------------------------------------------------------------------ #
286
+ # 6. 发送消息(应用机器人)
287
+ # ------------------------------------------------------------------ #
288
+ def _api_post(self, path, access_token, json_body=None):
289
+ """POST 请求(带 KSO-1 签名 + Bearer token)"""
290
+ content_type = "application/json"
291
+ body_bytes = json.dumps(json_body, ensure_ascii=False).encode("utf-8") if json_body else b""
292
+ headers = {
293
+ "Authorization": "Bearer " + access_token,
294
+ "Content-Type": content_type,
295
+ "Accept": "application/json",
296
+ }
297
+ debug = {"request_uri": path}
298
+ if self.sign_enabled:
299
+ sign = self.kso1_headers("POST", path, content_type, body_bytes)
300
+ debug["data_to_sign"] = sign.pop("_data_to_sign")
301
+ headers.update(sign)
302
+ debug["signed"] = True
303
+ else:
304
+ debug["signed"] = False
305
+ url = self.base_url + path
306
+ try:
307
+ resp = _requests.post(url, headers=headers, data=body_bytes, timeout=self.timeout)
308
+ except _requests.RequestException as exc:
309
+ raise WpsOAuthError("请求 " + path + " 失败: " + str(exc))
310
+ return resp.status_code, _safe_json(resp), debug
311
+
312
+ def send_message(self, receiver_ids, content, receiver_type="user", msg_type="text",
313
+ content_type="plain", app_token=None):
314
+ """通过应用机器人发送消息。
315
+
316
+ 前提:
317
+ 1. 开发者后台已申请权限 app:kso.chat_message.readwrite
318
+ 2. 已设置应用可见范围(版本管理 → 可用范围),接收者必须在范围内
319
+ 3. 应用版本已发布
320
+
321
+ Args:
322
+ receiver_ids: 接收者 ID(字符串或列表),用户 ID / 部门 ID / 企业 ID
323
+ content: 消息内容(文本字符串,或 dict 用于卡片/图片等复杂类型)
324
+ receiver_type: 接收者类型 "user" / "dept" / "company"
325
+ msg_type: 消息类型 "text" / "rich_text" / "image" / "file" / "card"
326
+ content_type: 文本格式 "plain" / "markdown"(仅 msg_type="text" 时有效)
327
+ app_token: 应用授权 token(可选,不传则自动获取)
328
+
329
+ Returns:
330
+ (status_code, response_body, debug_info)
331
+ """
332
+ if app_token is None:
333
+ app_token = self.get_app_token()["access_token"]
334
+
335
+ # 构造消息内容
336
+ if msg_type == "text" and isinstance(content, str):
337
+ msg_content = {"text": {"content": content, "type": content_type}}
338
+ elif isinstance(content, dict):
339
+ msg_content = content
340
+ else:
341
+ msg_content = {"text": {"content": str(content), "type": content_type}}
342
+
343
+ # 构造接收者
344
+ ids = receiver_ids if isinstance(receiver_ids, list) else [receiver_ids]
345
+ body = {
346
+ "receivers": [{
347
+ "type": receiver_type,
348
+ "receiver_ids": ids,
349
+ }],
350
+ "type": msg_type,
351
+ "content": msg_content,
352
+ }
353
+
354
+ return self._api_post("/v7/messages/batch_create", app_token, body)
355
+
356
+ def send_to_chat(self, chat_id, content, msg_type="text", content_type="plain",
357
+ mentions=None, app_token=None):
358
+ """向群聊会话发送消息(⚠️ 机器人必须已在该群中)。
359
+
360
+ 使用 /v7/messages/create 接口,receiver.type="chat"。
361
+ 支持向指定用户(user)或会话(chat)发送即时消息。
362
+
363
+ Args:
364
+ chat_id: 群聊会话 ID
365
+ content: 消息内容(文本字符串,或 dict 用于卡片/图片等)
366
+ msg_type: 消息类型 text/rich_text/image/file/audio/video/card
367
+ content_type: 文本格式 plain/markdown(仅 msg_type="text" 时有效)
368
+ mentions: 被@的人员列表(可选,格式见文档)
369
+ app_token: 应用授权 token(可选,不传则自动获取)
370
+
371
+ Returns:
372
+ (status_code, response_body, debug_info)
373
+ """
374
+ if app_token is None:
375
+ app_token = self.get_app_token()["access_token"]
376
+
377
+ # 构造消息内容
378
+ if msg_type == "text" and isinstance(content, str):
379
+ msg_content = {"text": {"content": content, "type": content_type}}
380
+ elif isinstance(content, dict):
381
+ msg_content = content
382
+ else:
383
+ msg_content = {"text": {"content": str(content), "type": content_type}}
384
+
385
+ body = {
386
+ "type": msg_type,
387
+ "receiver": {
388
+ "type": "chat",
389
+ "receiver_id": chat_id,
390
+ },
391
+ "content": msg_content,
392
+ }
393
+ if mentions:
394
+ body["mentions"] = mentions
395
+
396
+ return self._api_post("/v7/messages/create", app_token, body)
397
+
398
+ def get_chat_list(self, app_token=None):
399
+ """获取机器人所在的会话列表(用于获取 chat_id)。
400
+
401
+ ⚠️ 机器人只能看到自己已被拉入的群聊。
402
+ 若端点不对,可根据报错调整 path。
403
+ """
404
+ if app_token is None:
405
+ app_token = self.get_app_token()["access_token"]
406
+ return self._api_get("/v7/chats", app_token)
407
+
408
+
409
+ # ============ 工具函数 ============ #
410
+ def _safe_json(resp):
411
+ try:
412
+ return resp.json()
413
+ except (ValueError, json.JSONDecodeError):
414
+ return {"_raw_text": resp.text[:2000]}
415
+
416
+
417
+ def _extract_error(body, status):
418
+ if isinstance(body, dict):
419
+ for key in ("msg", "message"):
420
+ if body.get(key):
421
+ return "HTTP " + str(status) + " | " + str(body[key])
422
+ if body.get("code") not in (None, 0):
423
+ return "HTTP " + str(status) + " | code=" + str(body["code"])
424
+ return "HTTP " + str(status) + " | 无有效数据"
425
+
426
+
427
+ _NAME_KEYS = ("user_name", "name", "alias_name", "nick_name", "nickname", "real_name")
428
+ _ID_KEYS = ("id", "user_id", "uid", "userid")
429
+ _AVATAR_KEYS = ("avatar_url", "avatar", "avatar_big", "head_img_url", "photo")
430
+ _EMAIL_KEYS = ("email", "mail", "business_email")
431
+ _MOBILE_KEYS = ("mobile", "phone", "phone_number", "telephone")
432
+ _DEPT_KEYS = ("def_dept_name", "dept_name", "department_name")
433
+ _TITLE_KEYS = ("title", "position", "job_title", "post", "role_name")
434
+
435
+
436
+ def _pick(d, keys):
437
+ if not isinstance(d, dict):
438
+ return ""
439
+ for k in keys:
440
+ v = d.get(k)
441
+ if v not in (None, "", [],):
442
+ return v
443
+ return ""
444
+
445
+
446
+ def normalize_user(data):
447
+ """WPS 原始 data -> 页面友好结构。"""
448
+ avatar = _pick(data, _AVATAR_KEYS)
449
+ if isinstance(avatar, dict):
450
+ avatar = avatar.get("url") or avatar.get("avatar_url") or ""
451
+ dept_name = _pick(data, _DEPT_KEYS)
452
+ if not dept_name and isinstance(data.get("depts"), list) and data["depts"]:
453
+ first = data["depts"][0]
454
+ if isinstance(first, dict):
455
+ dept_name = first.get("dept_name") or first.get("name") or ""
456
+ dept_ids = data.get("dept_ids")
457
+ if isinstance(dept_ids, list):
458
+ dept_ids = ",".join(str(x) for x in dept_ids)
459
+ elif not dept_ids and isinstance(data.get("def_dept_id"), str):
460
+ dept_ids = data["def_dept_id"]
461
+ return {
462
+ "id": str(_pick(data, _ID_KEYS)),
463
+ "name": _pick(data, _NAME_KEYS) or "(未返回姓名)",
464
+ "avatar": avatar or "",
465
+ "email": _pick(data, _EMAIL_KEYS),
466
+ "mobile": _pick(data, _MOBILE_KEYS),
467
+ "title": _pick(data, _TITLE_KEYS),
468
+ "dept_name": dept_name,
469
+ "dept_ids": dept_ids or "",
470
+ "company_id": str(_pick(data, ("company_id", "corp_id", "ent_id"))),
471
+ }