Metadata-Version: 2.4
Name: wpsbot
Version: 0.3.2
Summary: WPS 365 开放平台 Python SDK —— OAuth2 登录 + 应用机器人消息发送
Author: wpsbot contributors
License-Expression: Apache-2.0
Keywords: wps,wps365,oauth2,bot,messaging,sdk
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: Communications :: Chat
Requires-Python: >=3.8
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: requests>=2.20
Dynamic: license-file

# wpsbot

WPS 365 开放平台 Python SDK —— OAuth2 单点登录 + 应用机器人消息发送 + 通讯录查询，仅依赖 `requests`。

[![PyPI version](https://img.shields.io/pypi/v/wpsbot.svg)](https://pypi.org/project/wpsbot/)
[![Python](https://img.shields.io/pypi/pyversions/wpsbot.svg)](https://pypi.org/project/wpsbot/)
[![License](https://img.shields.io/pypi/l/wpsbot.svg)](https://opensource.org/licenses/Apache-2.0)

## 目录

- [一、使用前必做：开放平台权限与配置](#一使用前必做开放平台权限与配置) ← **先读这一节，再看代码**
  - [两类权限：用户授权 vs 应用授权](#两类权限用户授权-vs-应用授权)
  - [前置步骤清单（缺一步就会报错）](#前置步骤清单缺一步就会报错)
  - [1. SSO 登录要开什么权限](#1-sso-登录要开什么权限用户授权)
  - [2. 机器人发消息 / 通讯录要开什么权限](#2-机器人发消息--通讯录要开什么权限应用授权)
  - [3. 申请权限时的 5 个坑](#3-申请权限时的-5-个坑)
- [二、快速开始](#二快速开始)
- [三、完整功能](#三完整功能)
  - [机器人发消息（WpsMsgSender）](#机器人发消息wpsmsgsender)
  - [SSO 登录（WpsBot）](#sso-登录wpsbot)
- [四、API 参考](#四api-参考)
- [五、权限 / 接口对照表](#五权限--接口对照表)
- [六、错误排查](#六错误排查)
- [七、ID 类型说明](#七id-类型说明)
- [八、许可证与链接](#八许可证与链接)

---

## 一、使用前必做：开放平台权限与配置

**这个库能不能跑通，绝大部分取决于你在 [WPS 开放平台](https://open.wps.cn/) 有没有把下面的权限和配置开好，而不取决于代码。**
`pip install wpsbot` 之后直接跑示例，最常见的失败是：`403 invalid sign`、`redirect_uri` 不匹配、拿不到部门岗位、机器人发不出消息、通讯录搜不到人 —— 原因全在这一节。

### 两类权限：用户授权 vs 应用授权

WPS 开放平台的权限分两类，**名称可能相同，但要在后台分别申请**：

| | 用户授权（user token） | 应用授权（app token） |
|---|---|---|
| 谁的身份 | 登录用户本人 | 应用（机器人）自己 |
| 怎么拿 token | OAuth2 授权码流程：`build_authorize_url()` → 用户登录 → `exchange_code(code)` | 直接用 client_id/secret：`get_app_token()`（client_credentials） |
| 本库谁在用 | `WpsBot`（SSO 登录、`fetch_complete_user()`） | `WpsMsgSender`（发消息、查通讯录、查群、上传资源） |
| 后台申请时选 | 「用户授权」 | 「应用授权」 |

> 所以：**只做 SSO 登录**就申请用户授权那几个；**只用机器人发消息**就申请应用授权那几个；两个都用就都申请。

### 前置步骤清单（缺一步就会报错）

在 [WPS 开放平台](https://open.wps.cn/) 开发者后台创建一个**企业自建应用**，然后按顺序完成：

| # | 位置 | 要做的事 | 不做的后果 |
|---|---|---|---|
| ① | 应用详情 | 记下 `Client ID`（AppID，AK 开头）和 `Client Secret`（AppKey） | 无法初始化 SDK |
| ② | **安全设置 → 回调地址** | 添加 OAuth 回调地址，**必须与代码里的 `redirect_uri` 完全一致**（协议、域名、端口、路径、大小写全一致） | 授权页报 `redirect_uri` 不匹配，SSO 登录进不去 |
| ③ | **安全设置 → 接口签名** | 开启 **KSO-1 接口签名**。SDK 默认 `sign_enabled=True`，会自动加 `X-Kso-Date` / `X-Kso-Authorization` | 所有 `/v7/*` 接口报 `403 invalid sign` / `sign error` |
| ④ | **权限管理** | 申请下面两张表列出的 scope，注意**应用授权**和**用户授权**要分别申请 | `403`、`permission denied`、`access denied` |
| ⑤ | **权限管理 → 数据权限** | 配置**通讯录数据权限范围**（哪些部门/成员对应用可见）并提交审核 | 通讯录接口能通但查不到人，或直接报权限错误 |
| ⑥ | **应用发布 → 版本管理** | 创建应用版本并**发布**；在版本详情的**可用范围**里设置应用可见成员 | 机器人发不出消息（接收者不在范围内）；`get_app_users()` 返回空 |

> 部分权限（尤其通讯录、消息类）需要**企业管理员在 WPS 365 管理后台审批**后才生效；开发者后台提交申请 ≠ 立即可用。刚审批通过时若有延迟，等几分钟再试。

### 1. SSO 登录要开什么权限（用户授权）

SSO 登录走 **OAuth2 授权码流程**，用**用户授权**。`WpsBot` 默认申请的 scope 是：

```python
scopes = ["kso.user_base.read", "kso.contact.read"]   # WpsBot 默认值
```

| scope | 是否必需 | 拿到什么 | 对应接口 | 缺了会怎样 |
|---|---|---|---|---|
| `kso.user_base.read` | **必需** | 用户 `id`、`name`、**头像** | `GET /v7/users/current` | `fetch_user_info()` / `fetch_complete_user()` 抛 `WpsOAuthError`（HTTP 403），**登录拿不到身份** |
| `kso.contact.read` | 可选但强烈建议 | **部门**、**岗位**、邮箱、手机号 | `GET /v7/users/{user_id}?with_dept=true` | 登录能成功，但 `debug["detail"]["ok"]` 为 `False`，**部门/岗位/邮箱/手机号全为空** |

结论：

- **只要"能登录 + 知道是谁 + 头像"** → 至少申请 `kso.user_base.read`。
- **要部门、岗位、邮箱、手机号**（`fetch_complete_user()` 的完整字段） → 两个都申请，并保持默认 `scopes`：

```python
bot = WpsBot(
    client_id="...",
    client_secret="...",
    redirect_uri="...",
    scopes=["kso.user_base.read", "kso.contact.read"],   # 这就是默认值，可不传
)
```

- **不需要自动拉部门/岗位**时，可以关掉第二次请求：`WpsBot(..., user_detail_enabled=False)`。

> ⚠️ `scope` 只能填「权限管理」里真实存在且已审批通过的权限。填了未申请的 scope，授权页会提示"应用尚未开通此权限"，或授权后接口返回 403。
>
> 除 scope 外，SSO 登录还依赖步骤 ②（回调地址）和步骤 ③（接口签名）。这两项没配好，scope 再全也登录不了。

### 2. 机器人发消息 / 通讯录要开什么权限（应用授权）

下面这些用**应用授权**（app token，`get_app_token()` 直接用 client_id/secret 换取，不需要用户登录）。`WpsMsgSender` 内部全部走应用授权。

| 功能 | scope | 对应接口 | 代码入口 |
|---|---|---|---|
| **发消息**（个人 / 群聊 / 部门 / 企业） | `kso.chat_message.readwrite`（查询和管理会话消息）<br>MCP 协作消息另需 `kso.mcp_message.readwrite` | `POST /v7/messages/create`<br>`POST /v7/messages/batch_create` | `s.user.*` / `s.chat.*`、`bot.send_message()`、`bot.send_to_chat()` |
| 上传资源（图片/文件，拿 `storage_key`） | 随消息能力 `kso.chat_message.readwrite` | `POST /v7/chats/resources/upload` | `s.upload_file()`、`send_local_image/file` |
| 查群聊列表 / 群成员 | `kso.chat.read` | `GET /v7/chats`、`GET /v7/chats/{chat_id}/members` | `s.get_chats()`、`s.get_chat_members()`、`bot.get_chat_list()` |
| 通讯录搜索 / 详情 / 列表 | `kso.contact.read`（只读够用；写操作才需要 `kso.contact.readwrite`） | `GET /v7/users/search`、`GET /v7/users/{user_id}`、`GET /v7/users` | `s.search_users()`、`s.get_user_by_id()`、`s.list_users()` |
| 应用可见范围用户 | `kso.app.read` | `GET /v7/developer/applications/current/subjects` | `s.get_app_users()` |

> **最小可用集合**
> - 只做 SSO 登录 → 第 1 节两个用户授权 scope（+ 回调地址、接口签名）
> - 只做机器人发消息 → `kso.chat_message.readwrite`（应用授权）+ 步骤 ⑥ 的可见范围
> - 还要按姓名找人 → 加 `kso.contact.read`（应用授权）+ 步骤 ⑤ 的数据权限范围
> - 全都要 → 上面全部

### 3. 申请权限时的 5 个坑

1. **同名 scope 的"应用授权"和"用户授权"是两条独立记录。** 例如 `kso.contact.read`：SSO 登录（用户 token）用它拿部门岗位，`WpsMsgSender` 搜人（app token）也用它。只开一边会出现"登录能拿到部门、搜人却 403"这种割裂现象。
2. **通讯录必须配数据权限范围（步骤 ⑤）。** 只申请 scope 不配范围，接口可能返回 200 但 `items` 为空，或直接报权限错误。
3. **机器人只能给"应用可见范围"内的人发消息（步骤 ⑥）。** 给范围外用户发，接口可能返回成功但对方收不到；发群聊时机器人必须**已经被拉进该群**，否则 `get_chats()` 里根本看不到这个群。
4. **发消息权限名是 `kso.chat_message.readwrite`，不是 `kso.message.send`。** 本库早期文档写错过这个名字，`kso.message.send` 在开放平台权限列表里并不存在，按它去申请会找不到权限。
5. **scope 名称最终以你自己后台「权限管理」页显示为准。** WPS 会调整权限命名，本 README 记录的是 SDK 实测可用的名称；若后台名称不同，以后台为准，并同步修改 `WpsBot(scopes=...)`。

---

## 二、快速开始

### 安装

```bash
pip install wpsbot
```

### 给个人 / 群聊发消息（最常用）

```python
from wpsbot.msg import WpsMsgSender

s = WpsMsgSender(
    client_id="YOUR_CLIENT_ID",          # 必填
    client_secret="YOUR_CLIENT_SECRET",  # 必填
)

# 发个人（receiver_id = 用户字母 ID，形如 AbCdEf）
s.user.send_text("用户ID", "你好")
s.user.send_markdown("用户ID", "## 标题\n- 列表项")

# 发群聊（receiver_id = 群聊 ID，可为纯数字）
s.chat.send_text("群聊ID", "你好")

# 发本地图片 / 文件（自动上传 + 发送）
s.user.send_local_image("用户ID", "./截图.png")
s.chat.send_local_file("群聊ID", "./报表.xlsx")

# 不知道群聊 ID？先列出来
print(s.get_chats())          # [{'id': '12345678', 'name': '项目群'}, ...]
```

> 前提：应用授权 `kso.chat_message.readwrite` + 步骤 ⑥ 的可见范围，见 [一、2](#2-机器人发消息--通讯录要开什么权限应用授权)。

### SSO 登录拿用户信息

```python
from wpsbot import WpsBot

bot = WpsBot(
    client_id="YOUR_CLIENT_ID",
    client_secret="YOUR_CLIENT_SECRET",
    redirect_uri="https://your-domain.com/callback",   # 必须与后台回调地址完全一致
)

# 1. 生成授权链接，让用户去登录
print(bot.build_authorize_url(state="random123"))

# 2. 用户登录后，从回调 URL 里取出 code，换 token
token = bot.exchange_code(code)
# token["access_token"] / token["refresh_token"] / token["expires_at"]

# 3. 一次拿到完整用户信息（头像 + 部门 + 岗位 + 邮箱）
user, debug = bot.fetch_complete_user(token["access_token"])
print(user["name"], user["avatar"], user["dept_name"], user["title"])
print(debug)   # 出错时看这个：每一步的状态码和签名信息
```

> 前提：用户授权 `kso.user_base.read`（必需）+ `kso.contact.read`（部门/岗位），以及回调地址、接口签名配置，见 [一、1](#1-sso-登录要开什么权限用户授权)。

---

## 三、完整功能

### 机器人发消息（WpsMsgSender）

```python
from wpsbot.msg import WpsMsgSender

s = WpsMsgSender(
    client_id="YOUR_CLIENT_ID",
    client_secret="YOUR_CLIENT_SECRET",
    # base_url="https://openapi.wps.cn",  # 私有化部署时改这里
    # timeout=15,
)
```

`s.user` / `s.chat` 是两个绑定好 `receiver_type` 的子发送器，不用记参数：

```python
# ---- 文本 / Markdown ----
s.user.send_text("用户ID", "你好")
s.user.send_markdown("用户ID", "## 标题\n- 列表项")
s.chat.send_text("群聊ID", "你好")
s.chat.send_markdown("群聊ID", "## 标题\n- 列表项")

# ---- 图片 / 文件（一键：自动上传 + 发送）----
s.user.send_local_image("用户ID", "./截图.png")     # 自动读宽高和 MIME
s.chat.send_local_image("群聊ID", "./截图.png")
s.user.send_local_file("用户ID", "./报表.xlsx")
s.chat.send_local_file("群聊ID", "./报表.pdf")

# ---- 复用已上传的资源（同一份文件发多个会话时用）----
key = s.upload_file("./报表.pdf")                  # 返回 storage_key 字符串
s.chat.send_file("群聊ID", key, name="报表.pdf", size=os.path.getsize("./报表.pdf"))
s.chat.send_image("群聊ID", key, mime_type="image/png", width=1920, height=1080)
```

所有 `send_*` 方法返回 `(status_code, body)` 元组：

```python
status, body = s.user.send_text("用户ID", "你好")
# (200, {'code': 0, 'data': {'id': '消息ID'}, 'msg': 'ok'})
```

失败时抛 `RuntimeError`（消息里带完整响应体），HTTP 层错误看 `status`。

#### 群聊与通讯录查询

```python
# 机器人所在群聊（机器人必须已被拉进群才看得到）
chats = s.get_chats()
# [{'id': '12345678', 'name': '项目群'}, ...]

# 群成员（letter_id 可直接用于 s.user.send_text）
members = s.get_chat_members("群聊ID")
# [{'id': 'AbCdEf', 'type': 'user', 'company_id': '...', 'app_id': '...'}, ...]

# 群成员姓名对照表（优先用通讯录 API 解析，失败自动回退本机 wps365 CLI）
rows = s.get_chat_members_with_names("群聊ID")
# [{'name': '张三', 'letter_id': 'AbCdEf', 'num_id': '1600000001'}, ...]

# 按姓名搜索用户（默认只搜在职，search_field=user_name）
users = s.search_users("张三")
# [{'id': 'AbCdEf', 'user_name': '张三', 'def_dept_name': '技术部'}, ...]

# 带岗位/完整部门路径/工号（内部逐人再查一次详情，较慢）
users = s.search_users("张三", with_detail=True)

# 单个用户详情
info = s.get_user_by_id("AbCdEf", with_dept=True)
# {'user_name': '张三', 'title': '高级工程师', 'email': '...', 'depts': [...], ...}

# 应用可见范围内的全部用户（自动翻页；需应用已发布）
users = s.get_app_users()
users = s.get_app_users(with_detail=True)

# 通讯录用户列表（第一页）
users = s.list_users(page_size=20)
```

#### 兼容旧接口

顶层也保留了一组带 `receiver_type` 的方法（**默认是 `"chat"`，发个人必须显式传 `receiver_type="user"`**）：

```python
s.send_text("群聊ID", "你好")                                  # 默认发群
s.send_text("用户ID", "你好", receiver_type="user")            # 发个人
s.send_markdown(...); s.send_image(...); s.send_file(...)
s.send_local_image(...); s.send_local_file(...)
```

新代码建议统一用 `s.user.*` / `s.chat.*`，不容易发错对象。

### SSO 登录（WpsBot）

```python
from wpsbot import WpsBot     # 或 from wpsbot.client import WpsBot

bot = WpsBot(
    client_id="YOUR_CLIENT_ID",
    client_secret="YOUR_CLIENT_SECRET",
    redirect_uri="https://your-domain.com/callback",
    # scopes=["kso.user_base.read", "kso.contact.read"],  # 默认值
    # base_url="https://openapi.wps.cn",
    # sign_enabled=True,          # 必须 True，否则 403 invalid sign
    # timeout=15,
    # user_detail_enabled=True,   # False 则不拉部门/岗位
)

# 1. 授权链接（scope 由 scopes 拼成逗号分隔）
url = bot.build_authorize_url(state="random123")

# 2. code 换 token
token = bot.exchange_code(code)

# 3. 刷新 token（结构与 exchange_code 相同）
new_token = bot.refresh(token["refresh_token"])

# 4. 当前用户基础信息（需 kso.user_base.read）
user, raw_body, attempts = bot.fetch_user_info(token["access_token"])

# 5. 通讯录详情（需 kso.contact.read），失败不抛异常，返回 (None, record)
detail, record = bot.fetch_user_detail(token["access_token"], user["id"])

# 6. 合并版：基础信息 + 详情（推荐）
user, debug = bot.fetch_complete_user(token["access_token"])

# 7. 应用授权 token（发消息等企业级接口用，与用户 token 不是一回事）
app_token = bot.get_app_token()
print(app_token["access_token"], app_token["expires_at"])
```

#### token 字典结构

`exchange_code()` / `refresh()` / `get_app_token()` 都返回同样结构的字典（**不是**接口原始响应，原始响应在 `raw` 里）：

```python
{
    "access_token": "...",
    "token_type": "bearer",
    "expires_in": 7200,
    "expires_at": 1730000000,   # 绝对过期时间戳，用这个判断要不要刷新
    "refresh_token": "...",     # get_app_token() 时为 ""
    "issued_at": 1729992800,
    "raw": {...},               # 接口原始 JSON
}
```

> ⚠️ **`refresh_token` 是一次性的**：每次 `refresh()` 后必须用返回的新 `refresh_token` 覆盖保存，否则下次刷新失败。
>
> `WpsBot` **不缓存、不自动刷新用户 token**，也不维护 `self.access_token`；持久化和刷新调度由调用方负责（`get_app_token()` 同样每次都会发请求，`WpsMsgSender` 内部才对 app token 做了缓存）。

#### 用户信息字段（normalize_user 输出）

`fetch_user_info()` / `fetch_user_detail()` / `fetch_complete_user()` 返回的用户字典，字段是固定的（取不到时为空字符串，不会缺 key）：

| 字段 | 说明 | 主要来自 | 所需权限 |
|---|---|---|---|
| `id` | 用户 ID（字母 ID） | `/v7/users/current` | `kso.user_base.read` |
| `name` | 姓名，取不到时为 `(未返回姓名)` | `/v7/users/current` | `kso.user_base.read` |
| `avatar` | 头像 URL | `/v7/users/current` | `kso.user_base.read` |
| `email` | 企业邮箱 | `/v7/users/{id}` | `kso.contact.read` |
| `mobile` | 手机号 | `/v7/users/{id}` | `kso.contact.read` |
| `title` | 岗位/职务 | `/v7/users/{id}` | `kso.contact.read` |
| `dept_name` | 主部门名称 | `/v7/users/{id}` | `kso.contact.read` |
| `dept_ids` | 部门 ID（多个用逗号连接） | `/v7/users/{id}` | `kso.contact.read` |
| `company_id` | 企业 ID | `/v7/users/{id}` | `kso.contact.read` |

`fetch_complete_user()` 的合并规则：以 `/v7/users/current` 的结果为底，`/v7/users/{id}` 的字段**只填补空值、不覆盖**（例如头像以 current 接口为准）。

#### debug 结构（排查 403 就看它）

```python
user, debug = bot.fetch_complete_user(access_token)
# debug = {
#     "current": [                      # fetch_user_info 的尝试记录列表
#         {"path": "/v7/users/current", "ok": True, "status": 200,
#          "debug": {"request_uri": "...", "data_to_sign": "KSO-1GET...", "signed": True}},
#     ],
#     "detail": {                        # fetch_user_detail 的记录
#         "path": "/v7/users/AbCdEf", "ok": False, "status": 403,
#         "error": "HTTP 403 | ...", "debug": {...},
#     },
# }
```

- `ok=False` + `status=403` → 权限没开或没生效（回到 [一、1](#1-sso-登录要开什么权限用户授权)）
- `signed=False` → `sign_enabled` 被关掉了，KSO 接口必然 403
- `data_to_sign` → 参与签名的原文，和后台校验规则对不上时用来比对

#### 用 WpsBot 直接发消息（不引入 WpsMsgSender）

`WpsBot` 自带两个发消息方法，走应用授权，返回 `(status_code, body, debug)` 三元组：

```python
# 批量发（/v7/messages/batch_create），receiver_type 支持 user / dept / company
status, body, dbg = bot.send_message(
    receiver_ids=["AbCdEf", "GhIjKl"],
    content="你好",
    receiver_type="user",
    msg_type="text",
    content_type="plain",       # 改成 "markdown" 即 Markdown
)

# 发到群聊（/v7/messages/create），机器人必须已在群里
status, body, dbg = bot.send_to_chat("群聊ID", "大家好", content_type="markdown")

# 拿 chat_id
status, body, dbg = bot.get_chat_list()
```

> `WpsMsgSender` 与这两个方法的区别：前者封装了图片/文件的**上传 + 组装 + 发送**全流程和通讯录查询，返回二元组；后者是裸接口，复杂消息类型需要自己拼 `content` dict（此时把 dict 传给 `content` 即可原样发出）。

---

## 四、API 参考

### `WpsBot`（`wpsbot.client`）

#### 构造参数

| 参数 | 必填 | 默认值 | 说明 |
|---|---|---|---|
| `client_id` | ✅ | — | 应用 APPID（AK 开头），开放平台「应用详情」页获取 |
| `client_secret` | ✅ | — | 应用 APPKEY；同时用作 KSO-1 签名的 HMAC 密钥 |
| `redirect_uri` | ✅ | — | OAuth 回调地址，**必须**与后台「安全设置 → 回调地址」完全一致 |
| `scopes` | | `["kso.user_base.read", "kso.contact.read"]` | 授权范围，必须是后台已开通的权限 |
| `base_url` | | `https://openapi.wps.cn` | 开放平台地址，私有化部署时改这里 |
| `sign_enabled` | | `True` | KSO-1 签名开关，**必须为 True**，否则 403 `invalid sign` |
| `timeout` | | `15` | 请求超时（秒） |
| `user_detail_enabled` | | `True` | `fetch_complete_user()` 是否自动拉通讯录详情 |
| `userinfo_paths` | | `["/v7/users/current"]` | 用户信息接口候选路径，按顺序尝试 |

> 三个必填参数缺任何一个，构造时直接抛 `WpsOAuthError`。`WpsBot` **不读 `.env`、不读环境变量**，配置由调用方传入。

#### 方法

| 方法 | 返回 | 说明 |
|---|---|---|
| `build_authorize_url(state="")` | `str` | 生成 OAuth2 授权链接（`/oauth2/auth`） |
| `exchange_code(code)` | token dict | 授权码换 token（`/oauth2/token`） |
| `refresh(refresh_token)` | token dict | 刷新 token；`refresh_token` 一次性 |
| `get_app_token()` | token dict | 应用授权 token（`client_credentials`），**不缓存** |
| `kso1_headers(method, request_uri, content_type, body=b"")` | dict | 生成 KSO-1 签名头，自定义请求时用 |
| `fetch_user_info(access_token)` | `(user, raw_body, attempts)` | 当前用户基础信息，需 `kso.user_base.read`；全部路径失败时抛 `WpsOAuthError` |
| `fetch_user_detail(access_token, user_id)` | `(user 或 None, record)` | 通讯录详情，需 `kso.contact.read`；**失败不抛异常** |
| `fetch_complete_user(access_token, user_id=None)` | `(merged_user, debug)` | 基础 + 详情合并；`user_id` 缺省用基础信息里的 `id` |
| `send_message(receiver_ids, content, receiver_type="user", msg_type="text", content_type="plain", app_token=None)` | `(status, body, debug)` | 批量发消息，`POST /v7/messages/batch_create` |
| `send_to_chat(chat_id, content, msg_type="text", content_type="plain", mentions=None, app_token=None)` | `(status, body, debug)` | 发群聊，`POST /v7/messages/create` |
| `get_chat_list(app_token=None)` | `(status, body, debug)` | 机器人所在会话列表，`GET /v7/chats` |

模块级工具：`normalize_user(data)` —— 把 WPS 原始 `data` 转成上表那套固定字段；`WpsOAuthError(message, detail=None, status=None)` —— 携带 `.message` / `.detail` / `.status`。

### `WpsMsgSender`（`wpsbot.msg`）

#### 构造参数

| 参数 | 必填 | 默认值 | 说明 |
|---|---|---|---|
| `client_id` | ✅ | — | 应用 APPID |
| `client_secret` | ✅ | — | 应用 APPKEY |
| `base_url` | | `https://openapi.wps.cn` | 开放平台地址 |
| `timeout` | | `15` | 请求超时（秒），上传 PUT 阶段自动放宽到 4 倍且不低于 60s |

> 内部自建一个 `WpsBot(sign_enabled=True)` 复用签名与 token 逻辑（`redirect_uri` 用占位值，发消息不涉及 OAuth）。app token 自带缓存，提前 300 秒续期。**不接受 `bot=` 参数**。

#### `s.user` / `s.chat` 子发送器方法

| 方法 | 说明 |
|---|---|
| `send_text(receiver_id, content)` | 纯文本消息（`type=text`，`content.type=plain`） |
| `send_markdown(receiver_id, content)` | Markdown 消息（`type=text`，`content.type=markdown`） |
| `send_image(receiver_id, storage_key, mime_type="image/png", width=None, height=None, size=None, name="")` | 发图片，`storage_key` 需先 `upload_file()` |
| `send_file(receiver_id, storage_key, name="", size=None)` | 发文件，`storage_key` 需先 `upload_file()` |
| `send_local_image(receiver_id, file_path, width=None, height=None, mime_type=None, name=None)` | 一键发本地图片：自动读宽高/MIME + 上传 + 发送 |
| `send_local_file(receiver_id, file_path, name=None)` | 一键发本地文件：自动上传 + 发送 |

#### `WpsMsgSender` 自身方法

| 方法 | 返回 | 说明 / 所需权限 |
|---|---|---|
| `get_app_token()` | `str` | 应用 token（带缓存，提前 5 分钟续期） |
| `upload_file(file_path, name=None, is_avatar=False)` | `str`（storage_key） | 上传资源：申请凭证 → PUT 上传。`kso.chat_message.readwrite` |
| `send_text/send_markdown/send_image/send_file/send_local_image/send_local_file(..., receiver_type="chat")` | `(status, body)` | 兼容旧接口，**默认发群聊** |
| `get_chats(page_size=20)` | `[{id, name}]` | 机器人所在群聊。`kso.chat.read` |
| `get_chat_members(chat_id, page_size=100)` | `[{id, type, company_id, app_id}]` | 群成员，`id` 即字母 ID。`kso.chat.read` |
| `get_chat_members_with_names(chat_id, page_size=100, wps365_cli=None)` | `[{name, letter_id, num_id}]` | 姓名对照表；API 解析失败时回退本机 `wps365` CLI |
| `search_users(keyword, page_size=20, status="active", with_detail=False)` | `[user...]` | 按姓名搜索，`GET /v7/users/search`。`kso.contact.read` |
| `get_user_by_id(user_id, with_dept=False)` | `dict` | 用户详情，`GET /v7/users/{id}`。`kso.contact.read` |
| `list_users(page_size=20, status="active")` | `[user...]` | 通讯录列表第一页，`GET /v7/users`。`kso.contact.read` |
| `get_app_users(page_size=50, with_detail=False, auto_paging=True)` | `[{id, name, user_name, type}]` | 应用可见范围用户（自动翻页）。`kso.app.read` |

> 查询类方法失败一律抛 `RuntimeError`，消息里带状态码和响应体（`403` = 权限未生效，`400` = ID 无效）。

---

## 五、权限 / 接口对照表

| 功能 | scope | 授权类型 | 接口 | 后台位置 |
|---|---|---|---|---|
| SSO 登录 / 基础用户信息 / 头像 | `kso.user_base.read` | 用户授权 | `GET /v7/users/current` | 权限管理 |
| 部门 / 岗位 / 邮箱 / 手机号 | `kso.contact.read` | 用户授权 | `GET /v7/users/{id}?with_dept=true` | 权限管理 |
| 发消息（个人/群/部门/企业） | `kso.chat_message.readwrite`<br>（MCP 消息：`kso.mcp_message.readwrite`） | 应用授权 | `POST /v7/messages/create`<br>`POST /v7/messages/batch_create` | 权限管理 |
| 上传资源 | `kso.chat_message.readwrite` | 应用授权 | `POST /v7/chats/resources/upload` | 权限管理 |
| 群聊列表 / 群成员 | `kso.chat.read` | 应用授权 | `GET /v7/chats`、`GET /v7/chats/{id}/members` | 权限管理 |
| 通讯录搜索 / 详情 / 列表 | `kso.contact.read`（写操作需 `kso.contact.readwrite`） | 应用授权 | `GET /v7/users/search`、`GET /v7/users/{id}`、`GET /v7/users` | 权限管理 + **数据权限范围** |
| 应用可见范围用户 | `kso.app.read` | 应用授权 | `GET /v7/developer/applications/current/subjects` | 权限管理（需应用已发布） |
| 换 token / 刷新 token | 无需 scope | — | `POST /oauth2/token` | — |
| 接口签名 | 开启 KSO-1 | — | 所有 `/v7/*` | 安全设置 → 接口签名 |
| OAuth 回调 | 回调地址白名单 | — | `GET /oauth2/auth` | 安全设置 → 回调地址 |
| 可发消息的人群 | 应用可用范围 | — | — | 应用发布 → 版本管理 → 可用范围 |

---

## 六、错误排查

| 现象 | 原因 | 解决 |
|---|---|---|
| `403 invalid sign` / `sign error` | 后台没开接口签名，或 `sign_enabled=False` | 后台「安全设置 → 接口签名」开启 **KSO-1**；SDK 保持 `sign_enabled=True`（默认） |
| 授权页提示 `redirect_uri` 不匹配 | 回调地址未配置或不完全一致 | 「安全设置 → 回调地址」添加与代码**逐字符一致**的地址 |
| 授权页提示"应用尚未开通此权限" | `scopes` 里有未申请的权限 | 去「权限管理」申请，或从 `scopes` 里删掉 |
| `fetch_complete_user` 部门/岗位为空，`debug["detail"]["status"] == 403` | 缺 `kso.contact.read`（用户授权） | 申请该权限（选**用户授权**）并确认在 `scopes` 里 |
| `fetch_user_info` 抛 `WpsOAuthError`，`attempts[0]["status"] == 403` | 缺 `kso.user_base.read` | 申请该权限并加入 `scopes` |
| 构造 `WpsBot` 就抛 `WpsOAuthError` | 三个必填参数有空值 | `client_id` / `client_secret` / `redirect_uri` 都必须传 |
| 通讯录接口 200 但 `items` 为空 | 未配置通讯录数据权限范围，或 `status="active"` 过滤掉了 | 「权限管理 → 数据权限」配置可见部门/成员并审核；必要时调整 `status` |
| `search_users()` 搜不到人 | 关键字不匹配（按 `user_name` 搜） | 换更短的关键字，如只传姓 |
| 发消息 403 / `permission denied` | 缺应用授权 `kso.chat_message.readwrite` | 「权限管理」申请（注意选**应用授权**） |
| 发消息返回成功但对方收不到 | 接收者不在应用可用范围内 | 「应用发布 → 版本管理 → 可用范围」加入对方并重新发布 |
| 发群聊失败 / `get_chats()` 里没有目标群 | 机器人不在该群 | 先把机器人拉进群 |
| `get_app_users()` 返回空 | 应用未发布，或可用范围为空 | 先发布应用版本并设置可用范围 |
| `upload_file()` 抛"获取上传凭证失败" | 缺消息权限，或文件名/大小/checksum 校验不过 | 检查 `kso.chat_message.readwrite`；确认文件可读、未被占用 |
| `RuntimeError: 查询用户失败(400)` | `user_id` 传了数字 ID 或金山文档账号 ID | 用字母 ID（见 [七、ID 类型说明](#七id-类型说明)） |
| 刷新 token 失败 | 用了旧的 `refresh_token` | 一次性令牌，每次刷新后必须保存新值 |
| `ImportError: WpsBot 需要 requests 库` | 未装依赖 | `pip install requests` |

---

## 七、ID 类型说明

WPS 的用户 ID 有多种形态，用错了会 400 或"发送成功但收不到"：

| 形态 | 示例 | 用途 |
|---|---|---|
| **字母 ID**（应用域，`identity.id`） | `AbCdEf` | **发消息**时 `s.user.send_*` 的 `receiver_id`；通讯录接口 `get_user_by_id()` 也用它 |
| 群聊 ID | `12345678`（可为纯数字） | `s.chat.send_*` 的 `receiver_id`，由 `get_chats()` 获取 |
| 数字 ID（通讯录内部编码 / CLI 用户域） | `1600000001` | 仅在 `get_chat_members_with_names()` 的 `num_id` 里出现，**不能直接用于发消息** |
| 金山文档个人中心账号 ID | `1602884839` | ⚠️ **不是有效 user_id**，传进去会报错 |

SDK 通过 `X-Kso-Id-Type` 头切换 ID 域：`search_users()` / `list_users()` / `get_app_users()` 用 `internal`，`get_user_by_id()` 用 `external`。

拿不准某人 ID 时，推荐路径：`search_users("姓名")` 拿字母 ID → 用该 ID 发消息；或 `get_chat_members_with_names(群ID)` 直接从群里取对照表。

---

## 八、许可证与链接

Apache License 2.0，见 [LICENSE](LICENSE)。欢迎提交 Issue 和 Pull Request。

- [PyPI · wpsbot](https://pypi.org/project/wpsbot/)
- [WPS 开放平台](https://open.wps.cn/)
- [WPS 开放平台文档中心](https://open.wps.cn/document/)
- [接入调用流程](https://open.wps.cn/documents/app-integration-dev/wps365/server/api-description/flow)
- [认证与授权概述](https://open.wps.cn/documents/app-integration-dev/wps365/server/certification-authorization/summary)
- [应用机器人](https://open.wps.cn/documents/app-integration-dev/guide/robot/app)
