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 +9 -0
- wpsbot/client.py +471 -0
- wpsbot/msg.py +557 -0
- wpsbot-0.1.0.dist-info/METADATA +350 -0
- wpsbot-0.1.0.dist-info/RECORD +8 -0
- wpsbot-0.1.0.dist-info/WHEEL +5 -0
- wpsbot-0.1.0.dist-info/licenses/LICENSE +201 -0
- wpsbot-0.1.0.dist-info/top_level.txt +1 -0
wpsbot/__init__.py
ADDED
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
|
+
}
|