feat: add dingtalk-log skill for querying work reports

- Add SKILL.md with full API documentation
- Add shell script for quick queries
- Update TOOLS.md with reference to new skill
This commit is contained in:
root
2026-03-29 14:15:03 +08:00
parent f8d9936d56
commit 1a9fdc7274
8 changed files with 1177 additions and 0 deletions

View File

@@ -0,0 +1,88 @@
---
name: dingtalk-document
description: 钉钉知识库和文档管理操作。当用户提到"钉钉文档"、"知识库"、"新建文档"、"查看文档目录"、"读取文档内容"、"写入文档"、"更新文档"、"文档成员"、"dingtalk doc"、"knowledge base"时使用此技能。支持:创建知识库、查询知识库列表、新建文档/文件夹、读取/写入文档正文内容、管理成员权限等全部文档类操作。
---
# 钉钉文档技能
负责钉钉知识库和文档的所有操作。本文件为**策略指南**,仅包含决策逻辑和工作流程。完整 API 请求格式见文末「references/api.md 查阅索引」。
> `dt_helper.sh` 位于本 `SKILL.md` 同级目录的 `scripts/dt_helper.sh`。
## 核心概念
- **知识库Workspace**:文档容器,有 `workspaceId``rootNodeId`
- **节点Node**:文件或文件夹,`type``FILE``FOLDER`
- **文档标识(用于 `/v1.0/doc/suites/documents/{id}`**:可用 `docKey``dentryUuid`
- 创建文档响应会返回:`docKey``dentryUuid``nodeId`
- 其中 `docKey` / `dentryUuid` 可用于读写正文;`nodeId` 用于删除和文档管理类接口
- `wiki/nodes` 返回的 `nodeId` 实际上是 `dentryUuid`,可直接用于正文读写
- **operatorId**:所有接口必须的 unionId 参数,通过 `bash scripts/dt_helper.sh --to-unionid` 自动转换
## 工作流程(每次执行前)
1. **先识别本次任务类型** → 例如:列知识库、读文档、写文档、创建文档、成员管理
2. **按本次任务校验所需配置** → 通过 `bash scripts/dt_helper.sh --get KEY` 读取;仅校验本任务必须项
3. **仅收集缺失配置** → 若缺少某项,**一次性询问用户**所有缺失值,用 `bash scripts/dt_helper.sh --set KEY=VALUE` 写入
4. **获取 Token / operatorId** → 直接调用 `bash scripts/dt_helper.sh`token 获取与缓存细节无需关心
5. **执行操作** → 凡是包含变量替换、管道或多行逻辑的命令,写入 `/tmp/<task>.sh``bash /tmp/<task>.sh` 执行。不要把多行命令直接粘到终端里(终端工具会截断),也不要用 `<<'EOF'` 语法heredoc 在工具中同样会被截断导致变量丢失)
### 按任务校验配置(必须先做)
- **所有任务通用必需**`DINGTALK_APP_KEY``DINGTALK_APP_SECRET``DINGTALK_MY_USER_ID`
- **涉及任何文档/知识库 API 调用**:必须有 `DINGTALK_MY_OPERATOR_ID`(若缺失,先用 `bash scripts/dt_helper.sh --to-unionid` 自动转换并写回)
- **创建/读取/写入/删除/成员管理**:除上述通用项外,无额外固定配置键;`workspaceId`/`nodeId`/`docKey` 属于任务参数,运行时从用户输入或 API 响应中获取
> 规则:未通过“本次任务配置校验”前,不得进入 API 调用步骤。
> 凭证禁止在输出中完整打印,确认时仅显示前 4 位 + `****`
### 所需配置
| 配置键 | 必填 | 说明 | 如何获取 |
|---|---|---|---|
| `DINGTALK_APP_KEY` | ✅ | 应用 AppKey | 钉钉开放平台 → 应用管理 → 凭证信息 |
| `DINGTALK_APP_SECRET` | ✅ | 应用 AppSecret | 同上 |
| `DINGTALK_MY_USER_ID` | ✅ | 当前用户的企业员工 IDuserId | 管理后台 → 通讯录 → 成员管理 → 点击姓名查看 |
| `DINGTALK_MY_OPERATOR_ID` | ✅ | 当前用户的 unionIdoperatorId | 首次由 `bash scripts/dt_helper.sh --to-unionid` 自动转换并写入 |
### 身份标识说明
| 标识 | 说明 |
|---|---|
| `userId`= `staffId` | 企业内部员工 ID可通过管理后台 -> 通讯录 -> 成员管理 -> 点击姓名查看 |
| `unionId` | 跨企业/跨应用唯一标识,可通过 `bash scripts/dt_helper.sh --to-unionid <userid>` 获取 |
### 执行脚本模板
```bash
#!/bin/bash
set -e
HELPER="./scripts/dt_helper.sh"
NEW_TOKEN=$(bash "$HELPER" --token)
OPERATOR_ID=$(bash "$HELPER" --get DINGTALK_MY_OPERATOR_ID)
# 在此追加具体 API 调用,例如查询知识库列表:
WORKSPACES=$(curl -s -X GET "https://api.dingtalk.com/v2.0/wiki/workspaces?operatorId=${OPERATOR_ID}&maxResults=20" \
-H "x-acs-dingtalk-access-token: $NEW_TOKEN")
echo "知识库列表: $WORKSPACES"
```
> **Token 失效处理**dt_helper 仅按时间缓存,无法感知 token 被提前吊销。若 API 返回 401token 无效/过期),用 `--nocache` 跳过缓存强制重新获取:
> ```bash
> NEW_TOKEN=$(bash "$HELPER" --token --nocache)
> ```
## references/api.md 查阅索引
确定好要做什么之后,用以下命令从 `references/api.md` 中提取对应章节的完整 API 细节(请求格式、参数说明、返回值示例):
```bash
grep -A 30 "^## 1. 查询知识库列表" references/api.md
grep -A 10 "^## 2. 查询知识库信息" references/api.md
grep -A 35 "^## 3. 查询节点列表" references/api.md
grep -A 10 "^## 4. 查询单个节点" references/api.md
grep -A 15 "^## 5. 通过 URL 查询节点" references/api.md
grep -A 28 "^## 6. 创建文档" references/api.md
grep -A 10 "^## 7. 删除文档" references/api.md
grep -A 30 "^## 8. 读取文档内容" references/api.md
grep -A 15 "^## 9. 覆盖写入文档内容" references/api.md
grep -A 12 "^## 10. 追加文本到段落" references/api.md
grep -A 18 "^## 11. 添加文档成员" references/api.md
grep -A 12 "^## 12. 更新文档成员权限" references/api.md
grep -A 10 "^## 13. 移除文档成员" references/api.md
grep -A 10 "^## 错误码" references/api.md
grep -A 10 "^## 所需应用权限" references/api.md
```

View File

@@ -0,0 +1,341 @@
# dingtalk-document API 参考
> 所有接口均已验证可用。
> `NEW_TOKEN` = 新版 token`api.dingtalk.com` 用),获取方式 `bash scripts/dt_helper.sh --token`
> `OPERATOR_ID` = 用户 unionId获取方式 `bash scripts/dt_helper.sh --get DINGTALK_MY_OPERATOR_ID`
> ⚠️ **重要**:所有接口均需传 `operatorId`unionId缺少则返回 `MissingoperatorId` 错误。
---
## 1. 查询知识库列表
```
GET https://api.dingtalk.com/v2.0/wiki/workspaces?operatorId={OPERATOR_ID}&maxResults=20&nextToken=
Header: x-acs-dingtalk-access-token: {NEW_TOKEN}
```
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `operatorId` | string | ✅ | 用户 unionId |
| `maxResults` | int | ❌ | 每页数量,默认 20 |
| `nextToken` | string | ❌ | 分页令牌,首次为空 |
响应:
```json
{
"workspaces": [
{
"workspaceId": "QXvd5SLN2AxOQz0Z",
"name": "团队知识库",
"description": "...",
"rootNodeId": "P0MALyR8kl3qpB7qTkM1xn3mW3bzYmDO",
"type": "TEAM",
"url": "https://alidocs.dingtalk.com/i/spaces/.../overview",
"createTime": "2024-01-01T00:00Z",
"modifiedTime": "2024-06-01T00:00Z"
}
],
"nextToken": "..."
}
```
> 翻页:`nextToken` 非空时传入下次请求继续获取。
---
## 2. 查询知识库信息
```
GET https://api.dingtalk.com/v2.0/wiki/workspaces/{workspaceId}?operatorId={OPERATOR_ID}
Header: x-acs-dingtalk-access-token: {NEW_TOKEN}
```
响应:单个 workspace 对象(结构同列表项)
---
## 3. 查询节点列表
```
GET https://api.dingtalk.com/v2.0/wiki/nodes?parentNodeId={nodeId}&operatorId={OPERATOR_ID}&maxResults=50&nextToken=
Header: x-acs-dingtalk-access-token: {NEW_TOKEN}
```
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `parentNodeId` | string | ✅ | 父节点 ID传知识库的 `rootNodeId` 可列出顶层内容 |
| `operatorId` | string | ✅ | 用户 unionId |
| `maxResults` | int | ❌ | 每页数量,默认 20 |
| `nextToken` | string | ❌ | 分页令牌 |
响应:
```json
{
"nodes": [
{
"nodeId": "LeBq413JAw31yaz1fB0BBdLGWDOnGvpb",
"name": "使用文档.adoc",
"type": "FILE",
"category": "ALIDOC",
"extension": "adoc",
"workspaceId": "QXvd5SnBnzmZdZ0Z",
"url": "https://alidocs.dingtalk.com/i/nodes/...",
"createTime": "2026-03-04T16:58Z",
"modifiedTime": "2026-03-04T17:51Z"
}
],
"nextToken": "..."
}
```
> `type``FILE`(文档/文件)| `FOLDER`(文件夹)
---
## 4. 查询单个节点
```
GET https://api.dingtalk.com/v2.0/wiki/nodes/{nodeId}?operatorId={OPERATOR_ID}
Header: x-acs-dingtalk-access-token: {NEW_TOKEN}
```
响应:`{ "node": { nodeId, name, type, category, workspaceId, url, ... } }`
---
## 5. 通过 URL 查询节点
```
POST https://api.dingtalk.com/v2.0/wiki/nodes/queryByUrl?operatorId={OPERATOR_ID}
Header: x-acs-dingtalk-access-token: {NEW_TOKEN}
```
请求体:
```json
{
"url": "https://alidocs.dingtalk.com/i/nodes/<nodeId>",
"operatorId": "{OPERATOR_ID}"
}
```
响应:与 GET 单个节点相同的 node 结构。
---
## 6. 创建文档
```
POST https://api.dingtalk.com/v1.0/doc/workspaces/{workspaceId}/docs
Header: x-acs-dingtalk-access-token: {NEW_TOKEN}
```
请求体:
```json
{
"operatorId": "{OPERATOR_ID}",
"docType": "DOC",
"name": "文档标题"
}
```
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `operatorId` | string | ✅ | 用户 unionId |
| `docType` | string | ✅ | 固定填 `"DOC"`ALIDOC 富文本格式) |
| `name` | string | ✅ | 文档标题 |
响应:
```json
{
"dentryUuid": "aaa",
"nodeId": "xxx",
"docKey": "yyy",
"workspaceId": "zzz",
"url": "https://..."
}
```
> **重要**
> - `docKey` / `dentryUuid`:用于 `/v1.0/doc/suites/documents/{id}` 内容读写
> - `nodeId`:用于 `/v1.0/doc/workspaces/{workspaceId}/docs/{nodeId}` 删除/管理
---
## 7. 删除文档
```
DELETE https://api.dingtalk.com/v1.0/doc/workspaces/{workspaceId}/docs/{nodeId}?operatorId={OPERATOR_ID}
Header: x-acs-dingtalk-access-token: {NEW_TOKEN}
```
> `workspaceId` 和 `nodeId` 均使用创建文档响应中的值。成功返回 `200 {}`。
---
## 8. 读取文档内容Block 结构)
```
GET https://api.dingtalk.com/v1.0/doc/suites/documents/{docKey}/blocks?operatorId={OPERATOR_ID}
Header: x-acs-dingtalk-access-token: {NEW_TOKEN}
```
> **docKey 的来源**
> - 通过 wiki nodes 接口查到的 `nodeId` 本质是 `dentryUuid`,可直接用于正文读写
> - 通过创建文档接口新建的文档:可使用响应中的 `docKey` 或 `dentryUuid`
> - 创建响应中的 `nodeId`(通常出现在 `/docs/<nodeId>` 链接中)不能直接用于正文读写
响应:
```json
{
"result": {
"data": [
{ "blockType": "heading", "heading": { "level": "heading-2", "text": "快速开始" }, "index": 0, "id": "xxx" },
{ "blockType": "paragraph", "paragraph": { "text": "正文内容..." }, "index": 1, "id": "yyy" },
{ "blockType": "table", "table": { "colSize": 2, "rowSize": 10 }, "index": 2, "id": "zzz" },
{ "blockType": "unknown", "index": 3, "id": "aaa", "unknown": {} }
]
},
"success": true
}
```
`blockType` 枚举:`heading``paragraph``unorderedList``orderedList``table``blockquote``unknown`(代码块/图片等)
---
## 9. 覆盖写入文档内容
```
POST https://api.dingtalk.com/v1.0/doc/suites/documents/{docKey}/overwriteContent?operatorId={OPERATOR_ID}
Header: x-acs-dingtalk-access-token: {NEW_TOKEN}
```
> ⚠️ `operatorId` 必须**同时**作为 query param 和请求体字段传入,缺一会报 `Missingcontent` 错误。
请求体:
```json
{
"operatorId": "{OPERATOR_ID}",
"content": "# 新标题\n\n新的正文内容支持 Markdown 格式。",
"contentType": "markdown"
}
```
> ⚠️ 此操作**全量覆盖**文档内容,不可撤销。
---
## 10. 追加文本到段落
```
POST https://api.dingtalk.com/v1.0/doc/suites/documents/{docKey}/blocks/{blockId}/paragraph/appendText
Header: x-acs-dingtalk-access-token: {NEW_TOKEN}
```
请求体:
```json
{ "operatorId": "{OPERATOR_ID}", "text": "追加的文字" }
```
---
## 11. 添加文档成员
```
POST https://api.dingtalk.com/v1.0/doc/workspaces/{workspaceId}/docs/{nodeId}/members
Header: x-acs-dingtalk-access-token: {NEW_TOKEN}
```
请求体:
```json
{
"operatorId": "{OPERATOR_ID}",
"members": [
{ "id": "<userId>", "roleType": "viewer" }
]
}
```
| 参数 | 说明 |
|---|---|
| `id` | 用户 userId**注意**:这里用 userId不是 unionId|
| `roleType` | `viewer`(只读)| `editor`(可编辑)|
---
## 12. 更新文档成员权限
```
PUT https://api.dingtalk.com/v1.0/doc/workspaces/{workspaceId}/docs/{nodeId}/members/{memberId}
Header: x-acs-dingtalk-access-token: {NEW_TOKEN}
```
请求体:
```json
{ "operatorId": "{OPERATOR_ID}", "roleType": "viewer" }
```
---
## 13. 移除文档成员
```
DELETE https://api.dingtalk.com/v1.0/doc/workspaces/{workspaceId}/docs/{nodeId}/members/{memberId}?operatorId={OPERATOR_ID}
Header: x-acs-dingtalk-access-token: {NEW_TOKEN}
```
---
## 14. 搜索文档(用于找回可读写 ID
```
GET https://api.dingtalk.com/v1.0/doc/docs?operatorId={OPERATOR_ID}&workspaceId={workspaceId}&keyword={keyword}&maxResults=20&nextToken=
Header: x-acs-dingtalk-access-token: {NEW_TOKEN}
```
说明:
- 需要权限:`Document.WorkspaceDocument.Read`
- 返回结果中的 `docs[].nodeBO.nodeId` 即可用于 `/v1.0/doc/suites/documents/{id}` 的读写(该值是 `dentryUuid` 风格 ID
示例响应(节选):
```json
{
"docs": [
{
"nodeBO": {
"name": "测试创建.adoc",
"nodeId": "np9zOoBVBYwB2OZBIn4y0G1vW1DK0g6l",
"url": "https://alidocs.dingtalk.com/i/nodes/np9zOoBVBYwB2OZBIn4y0G1vW1DK0g6l"
},
"workspaceBO": {
"workspaceId": "QXvd5SLN2AxOQz0Z"
}
}
],
"hasMore": false
}
```
---
## 错误码
| HTTP状态码 | 错误码 | 说明 | 处理建议 |
|---|---|---|---|
| 400 | `MissingoperatorId` | operatorId 未传 | 补充 operatorIdunionId|
| 400 | `paramError` | 参数类型错误 | operatorId 必须是 unionId不是 userId |
| 403 | `Forbidden.AccessDenied.AccessTokenPermissionDenied` | 应用缺少权限 | 错误中有 `requiredScopes`,开通对应权限 |
| 404 | `InvalidAction.NotFound` | 接口路径不存在 | 检查版本号v1.0/v2.0)和路径 |
| 429 | — | QPS 限流 | 1 秒后重试 |
---
## 所需应用权限
| 功能 | 权限 scope |
|---|---|
| 查询知识库/节点 | `Wiki.Node.Read` |
| 读取文档正文 | `Storage.File.Read` |
| 写入文档正文 | `Storage.File.Write` |
| 创建/删除文档 | `Storage.File.Write` |
| 查询用户 unionId | `Contact.User.Read` |

View File

@@ -0,0 +1,381 @@
#!/bin/bash
# =============================================================================
# dt_helper.sh — 钉钉开放平台辅助工具
# 路径: scripts/common/dt_helper.sh
# 用法: bash scripts/common/dt_helper.sh <命令> [参数]
# =============================================================================
set -e
CONFIG="${DINGTALK_CONFIG:-$HOME/.dingtalk-skills/config}"
# ─────────────────────────────────────────────────────────────────────────────
# 帮助信息
# ─────────────────────────────────────────────────────────────────────────────
show_help() {
cat <<'EOF'
钉钉开放平台辅助工具 (dt_helper.sh)
用法: bash scripts/common/dt_helper.sh <命令> [参数]
Token 管理(两种 token 互不兼容,按域名区分):
--token [--nocache] 获取新版 accessToken用于 api.dingtalk.com 域名的所有接口)
适用待办、文档、AI 表格等 api.dingtalk.com 域名下所有版本的接口
请求头x-acs-dingtalk-access-token: <token>
有缓存且未过期则直接返回,否则自动刷新并缓存
--nocache跳过缓存强制重新获取token 被提前吊销时使用)
--token-info 查看新版 token 缓存状态(是否有效、剩余有效秒数)
--clear-token 清除缓存的新版 token下次 --token 时强制重新获取)
--old-token [--nocache]
获取旧版 access_token用于 oapi.dingtalk.com 域名的所有接口)
适用:群消息/工作通知/userId↔unionId 转换等 oapi.dingtalk.com 接口
不适用api.dingtalk.com 接口如待办、文档、AI表格
⚠️ 新旧两种 token 互不兼容,混用会导致 401/403
--nocache跳过缓存强制重新获取token 被提前吊销时使用)
身份转换:
--to-unionid [userId] 将 userId 转换为 unionId
不传参数:转换配置中的 DINGTALK_MY_USER_ID操作者自身
结果首次自动写入 DINGTALK_MY_OPERATOR_ID
传入参数:动态转换指定 userId仅返回结果不写入配置
--to-userid [unionId] 将 unionId 反向转换为 userId需传入参数
配置管理:
--config 查看 ~/.dingtalk-skills/config 中的所有配置项(敏感项脱敏显示)
--get KEY [KEY...] 获取一个或多个配置项的值(敏感项脱敏显示)
--set KEY=VALUE 将配置项持久化写入配置文件(已存在则更新,不存在则追加,目录自动创建)
帮助:
--help, -h 显示此帮助信息
环境变量:
DINGTALK_CONFIG 覆盖默认配置文件路径(默认 ~/.dingtalk-skills/config
配置文件:
~/.dingtalk-skills/config key=value 格式,存储以下键:
DINGTALK_APP_KEY 应用 Client IDAppKey
DINGTALK_APP_SECRET 应用 Client SecretAppSecret
DINGTALK_MY_USER_ID 企业员工 IDuserId管理后台通讯录可查
DINGTALK_MY_OPERATOR_ID 操作者 unionId由 --to-unionid 自动生成)
DINGTALK_ACCESS_TOKEN 新版 token 缓存
DINGTALK_TOKEN_EXPIRY 新版 token 过期时间戳Unix 秒)
DINGTALK_OLD_TOKEN 旧版 token 缓存
DINGTALK_OLD_TOKEN_EXPIRY 旧版 token 过期时间戳Unix 秒)
EOF
}
# ─────────────────────────────────────────────────────────────────────────────
# 工具函数
# ─────────────────────────────────────────────────────────────────────────────
# 从配置文件读取指定键的值
cfg_get() {
local key="$1"
grep "^${key}=" "$CONFIG" 2>/dev/null | head -1 | cut -d= -f2-
}
# 写入或更新配置文件中的键值
cfg_set() {
local key="$1"
local value="$2"
mkdir -p "$(dirname "$CONFIG")"
touch "$CONFIG"
if grep -q "^${key}=" "$CONFIG" 2>/dev/null; then
sed -i "s|^${key}=.*|${key}=${value}|" "$CONFIG"
else
echo "${key}=${value}" >> "$CONFIG"
fi
}
# 从配置文件删除指定键
cfg_del() {
local key="$1"
sed -i "/^${key}=/d" "$CONFIG" 2>/dev/null || true
}
# 确保必须的配置项存在,否则报错退出
require_cfg() {
local key="$1"
local val
val=$(cfg_get "$key")
if [ -z "$val" ]; then
echo "❌ 缺少配置项 ${key},请先运行: bash scripts/common/dt_helper.sh --set ${key}=<值>" >&2
exit 1
fi
echo "$val"
}
# ─────────────────────────────────────────────────────────────────────────────
# Token 管理
# ─────────────────────────────────────────────────────────────────────────────
cmd_token() {
local force="${1:-}" app_key app_secret cached expiry now resp token expire_in
app_key=$(require_cfg DINGTALK_APP_KEY)
app_secret=$(require_cfg DINGTALK_APP_SECRET)
now=$(date +%s)
if [ "$force" != "--nocache" ]; then
cached=$(cfg_get DINGTALK_ACCESS_TOKEN)
expiry=$(cfg_get DINGTALK_TOKEN_EXPIRY)
if [ -n "$cached" ] && [ -n "$expiry" ] && [ "$now" -lt "$expiry" ]; then
echo "$cached"
return 0
fi
fi
# 过期或无缓存,重新获取
resp=$(curl -s -X POST "https://api.dingtalk.com/v1.0/oauth2/accessToken" \
-H "Content-Type: application/json" \
-d "{\"appKey\":\"${app_key}\",\"appSecret\":\"${app_secret}\"}")
token=$(echo "$resp" | grep -o '"accessToken":"[^"]*"' | cut -d'"' -f4)
expire_in=$(echo "$resp" | grep -o '"expireIn":[0-9]*' | cut -d: -f2)
if [ -z "$token" ]; then
echo "❌ 获取 token 失败: $resp" >&2
exit 1
fi
cfg_set DINGTALK_ACCESS_TOKEN "$token"
cfg_set DINGTALK_TOKEN_EXPIRY "$((now + expire_in - 200))"
echo "$token"
}
cmd_token_info() {
local cached expiry now remaining
cached=$(cfg_get DINGTALK_ACCESS_TOKEN)
expiry=$(cfg_get DINGTALK_TOKEN_EXPIRY)
now=$(date +%s)
if [ -z "$cached" ]; then
echo "状态: 无缓存(从未获取或已清除)"
return 0
fi
if [ -z "$expiry" ] || [ "$now" -ge "$expiry" ]; then
echo "状态: 已过期"
echo "Token: ${cached:0:20}..."
else
remaining=$((expiry - now))
echo "状态: 有效"
echo "Token: ${cached:0:20}..."
echo "剩余: ${remaining} 秒(约 $((remaining / 60)) 分钟)"
fi
}
cmd_clear_token() {
cfg_del DINGTALK_ACCESS_TOKEN
cfg_del DINGTALK_TOKEN_EXPIRY
echo "✅ 新版 Token 缓存已清除"
}
cmd_old_token() {
# 旧版 access_token用于所有 oapi.dingtalk.com 接口:
# - 群消息、工作通知、互动卡片dingtalk-message
# - userId ↔ unionId 转换
# ⚠️ 不可用于 api.dingtalk.com 接口待办、文档、AI表格等
local force="${1:-}" app_key app_secret resp token cached expiry now
app_key=$(require_cfg DINGTALK_APP_KEY)
app_secret=$(require_cfg DINGTALK_APP_SECRET)
now=$(date +%s)
if [ "$force" != "--nocache" ]; then
cached=$(cfg_get DINGTALK_OLD_TOKEN)
expiry=$(cfg_get DINGTALK_OLD_TOKEN_EXPIRY)
if [ -n "$cached" ] && [ -n "$expiry" ] && [ "$now" -lt "$expiry" ]; then
echo "$cached"
return 0
fi
fi
resp=$(curl -s "https://oapi.dingtalk.com/gettoken?appkey=${app_key}&appsecret=${app_secret}")
token=$(echo "$resp" | grep -o '"access_token":"[^"]*"' | cut -d'"' -f4)
expires_in=$(echo "$resp" | grep -o '"expires_in":[0-9]*' | cut -d: -f2)
if [ -z "$token" ]; then
echo "❌ 获取旧版 token 失败: $resp" >&2
exit 1
fi
cfg_set DINGTALK_OLD_TOKEN "$token"
cfg_set DINGTALK_OLD_TOKEN_EXPIRY "$((now + expires_in - 200))"
echo "$token"
}
# ─────────────────────────────────────────────────────────────────────────────
# 身份转换
# ─────────────────────────────────────────────────────────────────────────────
cmd_to_unionid() {
local user_id="$1"
local is_self=false
local old_token resp union_id
# 未传参 → 使用配置中的操作者自身 userId转换结果写入配置
if [ -z "$user_id" ]; then
user_id=$(require_cfg DINGTALK_MY_USER_ID)
is_self=true
fi
old_token=$(cmd_old_token)
resp=$(curl -s -X POST \
"https://oapi.dingtalk.com/topapi/v2/user/get?access_token=${old_token}" \
-H "Content-Type: application/json" \
-d "{\"userid\":\"${user_id}\"}")
# 注意:使用无下划线的 unionid 字段(有下划线的 union_id 可能为空)
union_id=$(echo "$resp" | grep -o '"unionid":"[^"]*"' | head -1 | cut -d'"' -f4)
if [ -z "$union_id" ]; then
echo "❌ userId→unionId 转换失败: $resp" >&2
exit 1
fi
# 仅当转换的是操作者自身时,才写入配置(动态转换他人 userId 不写入)
if "$is_self" && [ -z "$(cfg_get DINGTALK_MY_OPERATOR_ID)" ]; then
cfg_set DINGTALK_MY_OPERATOR_ID "$union_id"
echo "✅ 自身 unionId 已写入配置 DINGTALK_MY_OPERATOR_ID" >&2
fi
echo "$union_id"
}
cmd_to_userid() {
local union_id="$1"
local old_token resp user_id
if [ -z "$union_id" ]; then
echo "❌ 请提供 unionId 参数" >&2
exit 1
fi
old_token=$(cmd_old_token)
resp=$(curl -s -X POST \
"https://oapi.dingtalk.com/topapi/user/getbyunionid?access_token=${old_token}" \
-H "Content-Type: application/json" \
-d "{\"unionid\":\"${union_id}\"}")
user_id=$(echo "$resp" | grep -o '"userid":"[^"]*"' | head -1 | cut -d'"' -f4)
if [ -z "$user_id" ]; then
echo "❌ unionId→userId 转换失败: $resp" >&2
exit 1
fi
echo "$user_id"
}
# ─────────────────────────────────────────────────────────────────────────────
# 配置管理
# ─────────────────────────────────────────────────────────────────────────────
cmd_config() {
if [ ! -f "$CONFIG" ]; then
echo "配置文件不存在: $CONFIG"
echo "使用 --set KEY=VALUE 写入配置项"
return 0
fi
echo "配置文件: $CONFIG"
echo "─────────────────────────────────"
# 脱敏显示 SECRET 和 TOKEN
while IFS= read -r line; do
key="${line%%=*}"
val="${line#*=}"
case "$key" in
DINGTALK_APP_SECRET|DINGTALK_ACCESS_TOKEN|DINGTALK_OLD_TOKEN)
echo "${key}=${val:0:6}***(已脱敏)"
;;
*)
echo "$line"
;;
esac
done < "$CONFIG"
}
cmd_get() {
if [ $# -eq 0 ]; then
echo "❌ 请提供至少一个键名,用法: --get KEY [KEY2 ...]" >&2
exit 1
fi
for key in "$@"; do
val=$(cfg_get "$key")
if [ -z "$val" ]; then
echo "${key}=(未设置)"
else
case "$key" in
DINGTALK_APP_SECRET|DINGTALK_ACCESS_TOKEN|DINGTALK_OLD_TOKEN)
echo "${key}=${val:0:6}***(脱敏)"
;;
*)
echo "${key}=${val}"
;;
esac
fi
done
}
cmd_set() {
local kv="$1"
if [ -z "$kv" ] || [[ "$kv" != *"="* ]]; then
echo "❌ 格式错误,用法: --set KEY=VALUE" >&2
exit 1
fi
local key="${kv%%=*}"
local value="${kv#*=}"
cfg_set "$key" "$value"
echo "✅ 已设置 ${key}"
}
# ─────────────────────────────────────────────────────────────────────────────
# 入口:解析命令
# ─────────────────────────────────────────────────────────────────────────────
CMD="${1:-}"
case "$CMD" in
--help|-h|"")
show_help
;;
--token)
cmd_token "${2:-}"
;;
--token-info)
cmd_token_info
;;
--clear-token)
cmd_clear_token
;;
--old-token)
cmd_old_token "${2:-}"
;;
--to-unionid)
cmd_to_unionid "${2:-}"
;;
--to-userid)
cmd_to_userid "${2:-}"
;;
--config)
cmd_config
;;
--get)
shift
cmd_get "$@"
;;
--set)
cmd_set "${2:-}"
;;
*)
echo "❌ 未知命令: $CMD" >&2
echo "运行 --help 查看用法" >&2
exit 1
;;
esac