diff --git a/CLAUDE.md b/CLAUDE.md index ec4c9ed..685465c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ ## 项目概述 -`feishu-cli` 是一个功能完整的飞书开放平台命令行工具,**核心功能是 Markdown ↔ 飞书文档双向转换**,支持文档操作、消息发送、权限管理、知识库操作、文件管理、评论管理等功能。 +`feishu-cli` 是一个功能完整的飞书开放平台命令行工具,**核心功能是 Markdown ↔ 飞书文档双向转换**,支持文档操作、消息发送、权限管理、审批查询、知识库操作、文件管理、评论管理等功能。 ## 技术栈 @@ -26,6 +26,10 @@ feishu-cli/ │ ├── auth_login.go # OAuth 登录 │ ├── auth_status.go # 查看授权状态 │ ├── auth_logout.go # 退出登录 +│ ├── approval.go # 审批命令组 +│ ├── approval_get.go # 审批定义详情查询 +│ ├── approval_task.go # 审批任务命令组 +│ ├── approval_task_query.go # 审批任务查询 │ ├── search_docs.go # 文档搜索 │ ├── wiki.go # 知识库命令组 │ ├── msg.go # 消息命令组 @@ -39,11 +43,13 @@ feishu-cli/ │ │ ├── oauth.go # OAuth 流程(Login, ExchangeToken, Refresh) │ │ ├── resolve.go # Token 优先级链(ResolveUserAccessToken) │ │ ├── token.go # Token 持久化(Load/Save/Delete) +│ │ ├── user_cache.go # 当前登录用户缓存(user_profile.json) │ │ └── browser.go # 浏览器打开、环境检测 │ ├── client/ # 飞书 API 封装 │ │ ├── client.go # 客户端初始化、Context() │ │ ├── helpers.go # 工具函数(StringVal/BoolVal/IsRateLimitError 等) │ │ ├── docx.go # 文档 API(含 FillTableCells) +│ │ ├── approval.go # 审批 API(定义详情、任务查询) │ │ ├── board.go # 画板 API(Mermaid/PlantUML 导入) │ │ ├── sheets.go # 电子表格 API │ │ ├── bitable.go # 多维表格 API(Base) @@ -107,7 +113,7 @@ app_secret: "xxx" ### OAuth 认证 -通过 OAuth 2.0 Authorization Code Flow 获取 User Access Token,用于搜索等需要用户授权的功能。 +通过 OAuth 2.0 Authorization Code Flow 获取 User Access Token,用于搜索、审批任务查询等需要用户授权的功能。 **流程**:`auth login` → 浏览器授权 → 回调获取 code → 换取 Token → 保存到 `~/.feishu-cli/token.json` @@ -119,6 +125,12 @@ app_secret: "xxx" 2. `FEISHU_USER_ACCESS_TOKEN` 环境变量 3. `~/.feishu-cli/token.json`(access_token 有效直接返回;过期则用 refresh_token 自动刷新) 4. `config.yaml` 中的 `user_access_token` 静态配置 +- **审批任务查询**:`approval task query` 会先通过 `resolveRequiredUserToken` 获取当前用户 Token,再调用 `/authen/v1/user_info` 推断当前登录用户的 `open_id`;用户资料会缓存到 `~/.feishu-cli/user_profile.json`,登录态变化或 `auth logout` 时自动清理 + +**审批相关输出模式**: +- 不传 `--output`:输出便于阅读的文本摘要 +- `--output json`:输出 CLI 归一化后的 JSON,部分字段会做拍平和字符串化处理 +- `--output raw-json`:输出飞书 API 原始响应,便于排查字段差异 **前置条件**:在飞书开放平台 → 应用详情 → 安全设置 → 重定向 URL 中添加 `http://127.0.0.1:9768/callback` @@ -190,6 +202,14 @@ feishu-cli auth status # 查看授权状态 feishu-cli auth status -o json # JSON 格式输出授权状态 feishu-cli auth logout # 退出登录 +# === 审批 === +feishu-cli approval get # 查询审批模板/流程定义 +feishu-cli approval get --output json +feishu-cli approval get --output raw-json +feishu-cli approval task query --topic todo +feishu-cli approval task query --topic started --output json +feishu-cli approval task query --topic started --output raw-json + # === 文档操作 === feishu-cli doc create --title "测试" feishu-cli doc get @@ -533,6 +553,8 @@ feishu-cli search docs "产品需求" --user-access-token | 日历 | `calendar:calendar` | 需单独申请 | | 任务 | `task:task:read`, `task:task:write` | 需单独申请 | | 任务列表 | `task:tasklist:read`, `task:tasklist:write` | 任务列表管理 | +| 审批定义查询 | `approval:approval:readonly` | 用于 `approval get` | +| 审批任务查询 | `approval:task` | 用于 `approval task query`,并需完成用户授权 | | 搜索消息/应用 | 需要 User Access Token | 通过 `auth login` 或手动获取 | | 搜索文档 | 需要 User Access Token | 通过 `auth login` 或手动获取,支持 `search:read` 权限 | diff --git a/README.md b/README.md index d5edbc3..9e9c846 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,7 @@ feishu-cli doc import large-doc.md --title "大文档" \ | **群聊** | 创建、获取、更新、删除、分享链接、成员管理 | | **日历** | 日历列表、主日历、日程增删改查、搜索、回复邀请、参与者管理、忙闲查询 | | **任务** | 创建、查看、完成、删除、子任务、成员管理、提醒管理、任务列表 | +| **审批** | 审批定义详情查询、当前登录用户审批任务查询(待办 / 已办 / 已发起 / 抄送) | | **权限** | 添加 / 更新 / 删除协作者、批量添加、公开权限管理、分享密码、权限检查、转移所有权 | | **文件** | 云空间文件列表、创建、移动、复制、删除、上传、下载、版本管理、元数据、统计 | | **素材** | 上传 / 下载(图片、文件、音视频) | @@ -183,7 +184,7 @@ export FEISHU_APP_SECRET="xxx" feishu-cli config init ``` -4. (可选)如果需要使用搜索功能,还需完成 OAuth 用户授权: +4. (可选)如果需要使用搜索、审批任务查询等需要用户身份的功能,还需完成 OAuth 用户授权: ```bash # 前置条件:在飞书开放平台 → 应用详情 → 安全设置 → 重定向 URL 中添加: @@ -222,6 +223,7 @@ Commands: dept 部门操作(详情、子部门列表) board 画板操作(导入图表、下载图片) comment 评论操作(列出、添加、解决/恢复、回复管理) + approval 审批操作(定义详情、当前登录用户任务查询) search 搜索操作(消息、应用、文档) auth 身份认证(OAuth 登录、状态、退出) config 配置管理 @@ -461,10 +463,51 @@ feishu-cli search docs "产品需求" +
+审批操作 + +`approval get` 使用应用权限查询审批定义(审批模板/流程定义),支持 `--output raw-json` 查看飞书 API 原始响应,需要开通 `approval:approval:readonly`;`approval task query` 会优先根据当前 `auth login` 登录态自动识别当前用户,需要开通 `approval:task` 并完成用户授权。 + +输出说明: +- 不传 `--output`:输出便于阅读的文本摘要 +- `--output json`:输出 CLI 归一化后的 JSON,部分字段会做拍平和字符串化处理 +- `--output raw-json`:输出飞书 API 原始响应,便于排查字段差异 + +```bash +# 查询审批定义详情(审批模板/流程定义) +feishu-cli approval get + +# 输出完整 JSON +feishu-cli approval get --output json + +# 输出飞书 API 原始响应 +feishu-cli approval get --output raw-json + +# 查询当前登录用户的待我审批 +feishu-cli approval task query --topic todo + +# 查询我已审批的任务 +feishu-cli approval task query --topic done + +# 查询我发起的审批 +feishu-cli approval task query --topic started --output json + +# 输出飞书 API 原始响应 +feishu-cli approval task query --topic started --output raw-json + +# 翻页查询 +feishu-cli approval task query --topic todo --page-size 20 --page-token + +# 显式指定 User Access Token +feishu-cli approval task query --topic cc-unread --user-access-token +``` + +
+
身份认证 -通过 OAuth 2.0 获取 User Access Token,用于搜索等需要用户授权的功能。 +通过 OAuth 2.0 获取 User Access Token,用于搜索、审批待办查询等需要用户授权的功能。 **前置条件**:在飞书开放平台 → 应用详情 → 安全设置 → 重定向 URL 中添加 `http://127.0.0.1:9768/callback` @@ -498,6 +541,7 @@ feishu-cli auth logout - Access Token 过期时自动使用 Refresh Token 刷新(Refresh Token 有效期 30 天) - **重要**:登录时需包含 `offline_access` scope 才会返回 Refresh Token,否则 2 小时后需重新登录 - Token 优先级:`--user-access-token` 参数 > `FEISHU_USER_ACCESS_TOKEN` 环境变量 > `token.json` > `config.yaml` +- 审批任务查询会缓存当前登录用户资料到 `~/.feishu-cli/user_profile.json`,登录态变化或执行 `auth logout` 时会自动清理
@@ -648,6 +692,8 @@ npx skills add riba2534/feishu-cli --global --yes --agent claude-code --copy | 日历 | `calendar:calendar` | 需单独申请 | | 任务 | `task:task:read`, `task:task:write` | 需单独申请 | | 任务列表 | `task:tasklist:read`, `task:tasklist:write` | 任务列表管理 | +| 审批定义查询 | `approval:approval:readonly` | 用于 `approval get` | +| 审批任务查询 | `approval:task` | 用于 `approval task query`,并需完成用户授权 | | 搜索消息/应用 | 需要 User Access Token | 通过 `auth login` 或手动获取 | | 搜索文档 | 需要 User Access Token | 通过 `auth login` 或手动获取,支持 `search:read` 权限 | | 读取他人文档(User 身份) | `docx:document:readonly`(User scope) | 通过 `feishu-cli auth login --scopes "docx:document:readonly offline_access"` 授权 | @@ -659,6 +705,8 @@ npx skills add riba2534/feishu-cli --global --yes --agent claude-code --copy { "scopes": { "tenant": [ + "approval:approval:readonly", + "approval:task", "board:whiteboard:node:create", "board:whiteboard:node:read", "board:whiteboard:node:update", @@ -722,6 +770,8 @@ npx skills add riba2534/feishu-cli --global --yes --agent claude-code --copy { "scopes": { "tenant": [ + "approval:approval:readonly", + "approval:task", "board:whiteboard:node:create", "board:whiteboard:node:delete", "board:whiteboard:node:read", diff --git a/cmd/approval.go b/cmd/approval.go new file mode 100644 index 0000000..3697ca8 --- /dev/null +++ b/cmd/approval.go @@ -0,0 +1,24 @@ +package cmd + +import "github.com/spf13/cobra" + +var approvalCmd = &cobra.Command{ + Use: "approval", + Short: "审批相关命令", + Long: `审批相关命令,用于查看审批定义、实例和任务。 + +当前已提供: + - 审批定义查询(approval get) + - 审批任务查询(approval task query) + +示例: + # 查看审批定义详情 + feishu-cli approval get + + # 查看当前登录用户的待我审批任务 + feishu-cli approval task query --topic todo`, +} + +func init() { + rootCmd.AddCommand(approvalCmd) +} diff --git a/cmd/approval_get.go b/cmd/approval_get.go new file mode 100644 index 0000000..e6d64c7 --- /dev/null +++ b/cmd/approval_get.go @@ -0,0 +1,108 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/riba2534/feishu-cli/internal/client" + "github.com/riba2534/feishu-cli/internal/config" + "github.com/spf13/cobra" +) + +var approvalGetCmd = &cobra.Command{ + Use: "get ", + Short: "获取审批定义详情", + Long: `获取指定审批定义的详细信息,包括名称、状态、表单结构和审批节点。 + +参数: + approval_code 审批定义 Code(必填) + --output, -o 输出格式,可选:json、raw-json + +示例: + # 获取审批定义详情 + feishu-cli approval get + + # 获取完整 JSON 输出 + feishu-cli approval get --output json + + # 获取原始 API 响应 + feishu-cli approval get --output raw-json + + # 指定语言 + feishu-cli approval get --locale zh-CN`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := config.Validate(); err != nil { + return err + } + + approvalCode := args[0] + if err := validateApprovalCode(approvalCode); err != nil { + return err + } + + locale, _ := cmd.Flags().GetString("locale") + withAdminID, _ := cmd.Flags().GetBool("with-admin-id") + withOption, _ := cmd.Flags().GetBool("with-option") + output, _ := cmd.Flags().GetString("output") + + opts := client.GetApprovalOptions{ + Locale: locale, + WithAdminID: withAdminID, + WithOption: withOption, + } + + if output == "raw-json" { + raw, err := client.GetApprovalDefinitionRaw(approvalCode, opts) + if err != nil { + return err + } + fmt.Println(string(raw)) + return nil + } + + approval, err := client.GetApprovalDefinition(approvalCode, opts) + if err != nil { + return err + } + + if output == "json" { + return printJSON(approval) + } + + fmt.Printf("审批定义详情:\n") + fmt.Printf(" Code: %s\n", approval.ApprovalCode) + fmt.Printf(" 名称: %s\n", approval.ApprovalName) + fmt.Printf(" 状态: %s\n", approval.Status) + fmt.Printf(" 节点数: %d\n", len(approval.NodeList)) + fmt.Printf(" 可见人数量: %d\n", len(approval.Viewers)) + if len(approval.ApprovalAdminIDs) > 0 { + fmt.Printf(" 管理员 ID: %s\n", strings.Join(approval.ApprovalAdminIDs, ", ")) + } + if len(approval.NodeList) > 0 { + fmt.Printf(" 节点列表:\n") + for idx, node := range approval.NodeList { + fmt.Printf(" %d. %s [%s]\n", idx+1, node.Name, node.NodeType) + } + } + fmt.Printf(" 使用 --output json 查看归一化结果,或 --output raw-json 查看飞书 API 原始响应\n") + + return nil + }, +} + +func validateApprovalCode(approvalCode string) error { + if !isValidToken(approvalCode) { + return fmt.Errorf("无效的审批定义 code: %s", approvalCode) + } + return nil +} + +func init() { + approvalCmd.AddCommand(approvalGetCmd) + + approvalGetCmd.Flags().StringP("output", "o", "", "输出格式(json/raw-json)") + approvalGetCmd.Flags().String("locale", "zh-CN", "返回结果语言,如 zh-CN、en-US") + approvalGetCmd.Flags().Bool("with-admin-id", false, "返回有数据权限的审批流程管理员 ID") + approvalGetCmd.Flags().Bool("with-option", false, "返回外部数据源和假勤控件选项") +} diff --git a/cmd/approval_get_test.go b/cmd/approval_get_test.go new file mode 100644 index 0000000..a32cbf7 --- /dev/null +++ b/cmd/approval_get_test.go @@ -0,0 +1,63 @@ +package cmd + +import ( + "strings" + "testing" +) + +func TestValidateApprovalCode(t *testing.T) { + tests := []struct { + name string + code string + wantErr bool + }{ + { + name: "valid uuid-like code", + code: "7C468A54-8745-2245-9675-08B7C63E7A85", + wantErr: false, + }, + { + name: "valid short code", + code: "approval_123", + wantErr: false, + }, + { + name: "invalid blank code", + code: "", + wantErr: true, + }, + { + name: "invalid code with slash", + code: "approval/123", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateApprovalCode(tt.code) + if (err != nil) != tt.wantErr { + t.Fatalf("validateApprovalCode(%q) error = %v, wantErr %v", tt.code, err, tt.wantErr) + } + }) + } +} + +func TestApprovalGetOutputFlagSupportsRawJSON(t *testing.T) { + flag := approvalGetCmd.Flags().Lookup("output") + if flag == nil { + t.Fatal("approvalGetCmd should register --output") + } + if got := flag.Usage; got != "输出格式(json/raw-json)" { + t.Fatalf("--output usage = %q, want %q", got, "输出格式(json/raw-json)") + } +} + +func TestApprovalGetLongHelpMentionsRawJSONAndAlignedExamples(t *testing.T) { + if !strings.Contains(approvalGetCmd.Long, "--output raw-json") { + t.Fatalf("approvalGetCmd.Long should mention raw-json, got %q", approvalGetCmd.Long) + } + if !strings.Contains(approvalGetCmd.Long, "\n示例:\n") { + t.Fatalf("approvalGetCmd.Long should align 示例 header with other commands, got %q", approvalGetCmd.Long) + } +} diff --git a/cmd/approval_task.go b/cmd/approval_task.go new file mode 100644 index 0000000..86435a1 --- /dev/null +++ b/cmd/approval_task.go @@ -0,0 +1,20 @@ +package cmd + +import "github.com/spf13/cobra" + +var approvalTaskCmd = &cobra.Command{ + Use: "task", + Short: "审批任务相关命令", + Long: `审批任务相关命令,用于查询当前 auth 登录用户待处理、已处理、已发起或抄送的审批任务。 + +示例: + # 查询待我审批的任务 + feishu-cli approval task query --topic todo + + # 查询我发起的审批 + feishu-cli approval task query --topic started --output json`, +} + +func init() { + approvalCmd.AddCommand(approvalTaskCmd) +} diff --git a/cmd/approval_task_query.go b/cmd/approval_task_query.go new file mode 100644 index 0000000..30eb6d1 --- /dev/null +++ b/cmd/approval_task_query.go @@ -0,0 +1,168 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/riba2534/feishu-cli/internal/client" + "github.com/riba2534/feishu-cli/internal/config" + "github.com/spf13/cobra" +) + +var approvalTaskQueryCmd = &cobra.Command{ + Use: "query", + Short: "查询审批任务列表", + Long: `查询当前 auth 登录用户的审批任务列表,可用于查看待办审批、已办审批、已发起审批和抄送通知。 + +参数: + --topic 任务主题,可选:todo、done、started、cc-unread、cc-read + --output, -o 输出格式,可选:json、raw-json + +示例: + # 查询当前登录用户的待我审批(默认使用 Tenant Token) + feishu-cli approval task query --topic todo + + # 查询我已审批的任务 + feishu-cli approval task query --topic done + + # 显式使用 User Token + feishu-cli approval task query --topic todo --user-access-token u-xxx + + # JSON 输出 + feishu-cli approval task query --topic started --output json + + # 原始 API 响应 + feishu-cli approval task query --topic started --output raw-json`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := config.Validate(); err != nil { + return err + } + + topic, _ := cmd.Flags().GetString("topic") + topicValue, err := normalizeApprovalTaskTopic(topic) + if err != nil { + return err + } + + userID, err := resolveCurrentAuthedUserID(cmd, "open_id") + if err != nil { + return fmt.Errorf("无法从当前登录态自动获取用户身份,请先执行 feishu-cli auth login: %w", err) + } + + pageSize, _ := cmd.Flags().GetInt("page-size") + pageToken, _ := cmd.Flags().GetString("page-token") + output, _ := cmd.Flags().GetString("output") + + token := resolveFlagUserToken(cmd) + queryOpts := client.ApprovalTaskQueryOptions{ + PageSize: pageSize, + PageToken: pageToken, + UserID: userID, + Topic: topicValue, + UserIDType: "open_id", + } + + if output == "raw-json" { + raw, err := client.QueryApprovalTasksRaw(queryOpts, token) + if err != nil { + return err + } + fmt.Println(string(raw)) + return nil + } + + result, err := client.QueryApprovalTasks(queryOpts, token) + if err != nil { + return err + } + + if output == "json" { + return printJSON(result) + } + + if len(result.Tasks) == 0 { + fmt.Printf("没有找到审批任务(topic: %s)\n", approvalTaskTopicLabel(topicValue)) + return nil + } + + if result.Count != nil { + fmt.Printf("审批任务(%s),总数约 %d\n\n", approvalTaskTopicLabel(topicValue), result.Count.Total) + } else { + fmt.Printf("审批任务(%s),当前页 %d 条\n\n", approvalTaskTopicLabel(topicValue), len(result.Tasks)) + } + + for idx, task := range result.Tasks { + fmt.Printf("[%d] %s\n", idx+1, task.Title) + fmt.Printf(" 任务 ID: %s\n", task.TaskID) + if task.DefinitionName != "" { + fmt.Printf(" 审批流: %s\n", task.DefinitionName) + } + if len(task.InitiatorNames) > 0 { + fmt.Printf(" 发起人: %s\n", strings.Join(task.InitiatorNames, ", ")) + } + if task.Status != "" { + fmt.Printf(" 任务状态: %s\n", task.Status) + } + if task.ProcessStatus != "" { + fmt.Printf(" 流程状态: %s\n", task.ProcessStatus) + } + if task.PCURL != "" { + fmt.Printf(" PC 链接: %s\n", task.PCURL) + } else if task.MobileURL != "" { + fmt.Printf(" 移动端链接: %s\n", task.MobileURL) + } + fmt.Println() + } + + if result.HasMore { + fmt.Printf("还有更多任务,使用 --page-token %s 获取下一页\n", result.PageToken) + } + + return nil + }, +} + +func normalizeApprovalTaskTopic(topic string) (string, error) { + switch strings.ToLower(strings.TrimSpace(topic)) { + case "1", "todo": + return "1", nil + case "2", "done": + return "2", nil + case "3", "started", "initiated": + return "3", nil + case "17", "cc-unread", "unread-cc": + return "17", nil + case "18", "cc-read", "read-cc": + return "18", nil + default: + return "", fmt.Errorf("不支持的 topic: %s(可选值: todo, done, started, cc-unread, cc-read)", topic) + } +} + +func approvalTaskTopicLabel(topic string) string { + switch topic { + case "1": + return "待我审批" + case "2": + return "我已审批" + case "3": + return "我发起的审批" + case "17": + return "未读抄送" + case "18": + return "已读抄送" + default: + return topic + } +} + +func init() { + approvalTaskCmd.AddCommand(approvalTaskQueryCmd) + + approvalTaskQueryCmd.Flags().String("topic", "", "任务主题:todo、done、started、cc-unread、cc-read") + approvalTaskQueryCmd.Flags().Int("page-size", 50, "每页数量") + approvalTaskQueryCmd.Flags().String("page-token", "", "分页标记") + approvalTaskQueryCmd.Flags().StringP("output", "o", "", "输出格式(json/raw-json)") + approvalTaskQueryCmd.Flags().String("user-access-token", "", "User Access Token(用户授权令牌)") + mustMarkFlagRequired(approvalTaskQueryCmd, "topic") +} diff --git a/cmd/approval_task_query_test.go b/cmd/approval_task_query_test.go new file mode 100644 index 0000000..8a17618 --- /dev/null +++ b/cmd/approval_task_query_test.go @@ -0,0 +1,126 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/riba2534/feishu-cli/internal/client" + "github.com/spf13/cobra" +) + +func TestNormalizeApprovalTaskTopic(t *testing.T) { + tests := []struct { + input string + want string + wantErr bool + }{ + {input: "todo", want: "1"}, + {input: "1", want: "1"}, + {input: "done", want: "2"}, + {input: "started", want: "3"}, + {input: "cc-unread", want: "17"}, + {input: "cc-read", want: "18"}, + {input: "unknown", wantErr: true}, + } + + for _, tt := range tests { + got, err := normalizeApprovalTaskTopic(tt.input) + if (err != nil) != tt.wantErr { + t.Fatalf("normalizeApprovalTaskTopic(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) + } + if err == nil && got != tt.want { + t.Fatalf("normalizeApprovalTaskTopic(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestApprovalTaskTopicLabel(t *testing.T) { + tests := map[string]string{ + "1": "待我审批", + "2": "我已审批", + "3": "我发起的审批", + "17": "未读抄送", + "18": "已读抄送", + "x": "x", + } + + for input, want := range tests { + if got := approvalTaskTopicLabel(input); got != want { + t.Fatalf("approvalTaskTopicLabel(%q) = %q, want %q", input, got, want) + } + } +} + +func TestResolveFlagUserTokenIgnoresEnvironment(t *testing.T) { + t.Setenv("FEISHU_USER_ACCESS_TOKEN", "env-token") + + cmd := &cobra.Command{} + cmd.Flags().String("user-access-token", "", "") + + if got := resolveFlagUserToken(cmd); got != "" { + t.Fatalf("resolveFlagUserToken() = %q, want empty string", got) + } + + if err := cmd.Flags().Set("user-access-token", "flag-token"); err != nil { + t.Fatalf("Set flag: %v", err) + } + + if got := resolveFlagUserToken(cmd); got != "flag-token" { + t.Fatalf("resolveFlagUserToken() = %q, want %q", got, "flag-token") + } +} + +func TestApprovalTaskQueryDoesNotRegisterUserIDFlag(t *testing.T) { + if flag := approvalTaskQueryCmd.Flags().Lookup("user-id"); flag != nil { + t.Fatalf("approvalTaskQueryCmd should not register --user-id, got %q", flag.Name) + } +} + +func TestApprovalTaskQueryRequiresTopicFlag(t *testing.T) { + flag := approvalTaskQueryCmd.Flags().Lookup("topic") + if flag == nil { + t.Fatal("approvalTaskQueryCmd should register --topic") + } + if got := flag.Annotations[cobra.BashCompOneRequiredFlag]; len(got) != 1 || got[0] != "true" { + t.Fatalf("--topic should be marked required, got annotations %#v", flag.Annotations) + } +} + +func TestCurrentUserIDFromInfo(t *testing.T) { + info := &client.UserInfo{ + OpenID: "ou_test", + UserID: "u_test", + UnionID: "on_test", + } + + tests := []struct { + userIDType string + want string + wantErr bool + }{ + {userIDType: "open_id", want: "ou_test"}, + {userIDType: "user_id", want: "u_test"}, + {userIDType: "union_id", want: "on_test"}, + {userIDType: "employee_id", wantErr: true}, + } + + for _, tt := range tests { + got, err := currentUserIDFromInfo(info, tt.userIDType) + if (err != nil) != tt.wantErr { + t.Fatalf("currentUserIDFromInfo(..., %q) error = %v, wantErr %v", tt.userIDType, err, tt.wantErr) + } + if err == nil && got != tt.want { + t.Fatalf("currentUserIDFromInfo(..., %q) = %q, want %q", tt.userIDType, got, tt.want) + } + } +} + +func TestCurrentUserIDFromInfoMissingIDDoesNotMentionRemovedUserIDFlag(t *testing.T) { + _, err := currentUserIDFromInfo(&client.UserInfo{}, "open_id") + if err == nil { + t.Fatal("currentUserIDFromInfo(..., open_id) error = nil, want non-nil") + } + if strings.Contains(err.Error(), "--user-id") { + t.Fatalf("error %q should not mention removed --user-id flag", err.Error()) + } +} diff --git a/cmd/root.go b/cmd/root.go index 4360190..f72fdb2 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -26,7 +26,7 @@ func SetVersionInfo(v, bt string) { var rootCmd = &cobra.Command{ Use: "feishu-cli", Short: "飞书开放平台命令行工具", - Long: `飞书开放平台命令行工具,支持文档操作、Markdown 双向转换、消息发送、权限管理、日历管理、搜索等功能。 + Long: `飞书开放平台命令行工具,支持文档操作、Markdown 双向转换、消息发送、权限管理、审批查询、日历管理、搜索等功能。 命令模块: doc 文档操作(创建、获取、编辑、导入导出、添加高亮块/画板) @@ -40,6 +40,7 @@ var rootCmd = &cobra.Command{ msg 消息操作(发送消息、搜索群聊、会话历史) bitable 多维表格操作(数据表、字段、记录、视图管理) task 任务操作(创建、查看、更新、完成) + approval 审批操作(定义详情、当前登录用户任务查询) calendar 日历操作(日历、日程管理) search 搜索操作(消息、应用搜索,需要用户授权) config 配置管理(初始化配置) @@ -67,6 +68,9 @@ var rootCmd = &cobra.Command{ # 发送消息 feishu-cli msg send --receive-id-type email --receive-id user@example.com --text "你好" + # 查询当前登录用户的审批待办(需先 auth login) + feishu-cli approval task query --topic todo + 更多信息请访问: https://github.com/riba2534/feishu-cli`, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { // Skip config initialization for group commands (those with subcommands but no own RunE) diff --git a/cmd/utils.go b/cmd/utils.go index 9720d5c..5644b03 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/riba2534/feishu-cli/internal/auth" + "github.com/riba2534/feishu-cli/internal/client" "github.com/riba2534/feishu-cli/internal/config" "github.com/spf13/cobra" ) @@ -27,6 +28,13 @@ func resolveOptionalUserToken(cmd *cobra.Command) string { return "" } +// resolveFlagUserToken 仅解析命令行显式传入的 user_access_token。 +// 适用于默认应使用 App/Tenant Token,仅在用户明确指定时才切换到 User Token 的命令。 +func resolveFlagUserToken(cmd *cobra.Command) string { + flagToken, _ := cmd.Flags().GetString("user-access-token") + return flagToken +} + // resolveOptionalUserTokenWithFallback 尝试完整优先级链解析 User Token(可选) // 与 resolveOptionalUserToken 不同,会额外尝试从 token.json 和 config 中读取 // 找不到时返回空字符串(回退到 App Token),而非报错 @@ -61,6 +69,97 @@ func resolveRequiredUserToken(cmd *cobra.Command) (string, error) { return auth.ResolveUserAccessToken(flagToken, cfg.UserAccessToken, cfg.AppID, cfg.AppSecret, cfg.BaseURL) } +// resolveCurrentAuthedUserID returns the current logged-in user's ID for the requested type. +func resolveCurrentAuthedUserID(cmd *cobra.Command, userIDType string) (string, error) { + token, err := resolveRequiredUserToken(cmd) + if err != nil { + return "", err + } + + cfg := config.Get() + cachePath, _ := auth.UserCachePath() + cached, cacheErr := auth.LoadCurrentUserCache() + switch { + case cacheErr != nil: + if cfg.Debug { + fmt.Fprintf(os.Stderr, "[Debug] [cache:%s] 读取当前登录用户缓存失败,回源 user_info: %v\n", cachePath, cacheErr) + } + case cached != nil && cached.MatchesToken(token): + if cfg.Debug { + fmt.Fprintf(os.Stderr, "[Debug] [cache:%s] 命中当前登录用户缓存\n", cachePath) + } + return currentUserIDFromInfo(currentUserIDCacheToInfo(cached), userIDType) + case cached != nil: + if cfg.Debug { + fmt.Fprintf(os.Stderr, "[Debug] [cache:%s] 当前登录 token 已变化,忽略旧缓存并回源 user_info\n", cachePath) + } + default: + if cfg.Debug { + fmt.Fprintf(os.Stderr, "[Debug] [cache:%s] 未命中当前登录用户缓存,回源 user_info\n", cachePath) + } + } + + info, err := client.GetCurrentUserInfo(token) + if err != nil { + return "", err + } + + cache := &auth.CurrentUserCache{ + OpenID: info.OpenID, + UserID: info.UserID, + UnionID: info.UnionID, + Name: info.Name, + TokenFingerprint: auth.UserTokenFingerprint(token), + } + if err := auth.SaveCurrentUserCache(cache); err != nil { + if cfg.Debug { + fmt.Fprintf(os.Stderr, "[Debug] [cache:%s] 写入当前登录用户缓存失败: %v\n", cachePath, err) + } + } else if cfg.Debug { + fmt.Fprintf(os.Stderr, "[Debug] [cache:%s] 已更新当前登录用户缓存\n", cachePath) + } + + return currentUserIDFromInfo(info, userIDType) +} + +func currentUserIDCacheToInfo(cache *auth.CurrentUserCache) *client.UserInfo { + if cache == nil { + return &client.UserInfo{} + } + + return &client.UserInfo{ + OpenID: cache.OpenID, + UserID: cache.UserID, + UnionID: cache.UnionID, + Name: cache.Name, + } +} + +func currentUserIDFromInfo(info *client.UserInfo, userIDType string) (string, error) { + if info == nil { + return "", fmt.Errorf("当前登录用户信息为空") + } + + switch userIDType { + case "open_id": + if info.OpenID != "" { + return info.OpenID, nil + } + case "user_id": + if info.UserID != "" { + return info.UserID, nil + } + case "union_id": + if info.UnionID != "" { + return info.UnionID, nil + } + default: + return "", fmt.Errorf("不支持的 user-id-type: %s", userIDType) + } + + return "", fmt.Errorf("当前登录用户缺少 %s,无法自动推断当前登录用户身份", userIDType) +} + // mustMarkFlagRequired 标记 flag 为必填,如果失败则 panic // 用于 init() 函数中,确保配置错误在启动时被发现 func mustMarkFlagRequired(cmd *cobra.Command, flags ...string) { diff --git a/internal/auth/token.go b/internal/auth/token.go index 656f76e..30baeaf 100644 --- a/internal/auth/token.go +++ b/internal/auth/token.go @@ -78,6 +78,7 @@ func SaveToken(t *TokenStore) error { return fmt.Errorf("写入 token 文件失败: %w", err) } + clearCurrentUserCacheBestEffort() return nil } @@ -89,15 +90,21 @@ func DeleteToken() error { } if err := os.Remove(path); err != nil { - if os.IsNotExist(err) { - return nil + if !os.IsNotExist(err) { + return fmt.Errorf("删除 token 文件失败: %w", err) } - return fmt.Errorf("删除 token 文件失败: %w", err) } + clearCurrentUserCacheBestEffort() return nil } +// clearCurrentUserCacheBestEffort clears derived user cache without failing the +// primary token persistence flow. +func clearCurrentUserCacheBestEffort() { + _ = DeleteCurrentUserCache() +} + // IsAccessTokenValid 检查 access_token 是否有效(预留 60s 缓冲) func (t *TokenStore) IsAccessTokenValid() bool { return t.AccessToken != "" && time.Now().Add(60*time.Second).Before(t.ExpiresAt) diff --git a/internal/auth/user_cache.go b/internal/auth/user_cache.go new file mode 100644 index 0000000..401e29e --- /dev/null +++ b/internal/auth/user_cache.go @@ -0,0 +1,130 @@ +package auth + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" +) + +// CurrentUserCache stores the current logged-in user's profile metadata. +// It intentionally avoids storing raw tokens and only keeps a token fingerprint +// to determine whether the cache still matches the current OAuth session. +type CurrentUserCache struct { + OpenID string `json:"open_id,omitempty"` + UserID string `json:"user_id,omitempty"` + UnionID string `json:"union_id,omitempty"` + Name string `json:"name,omitempty"` + CachedAt time.Time `json:"cached_at"` + TokenFingerprint string `json:"token_fingerprint,omitempty"` +} + +// userCachePathFunc can be overridden in tests. +var userCachePathFunc = originalUserCachePath + +func originalUserCachePath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("获取用户目录失败: %w", err) + } + return filepath.Join(home, ".feishu-cli", "user_profile.json"), nil +} + +// UserCachePath returns the current user cache path (~/.feishu-cli/user_profile.json). +func UserCachePath() (string, error) { + return userCachePathFunc() +} + +// LoadCurrentUserCache loads the current user cache from disk. +// Returns nil, nil when the cache file does not exist. +func LoadCurrentUserCache() (*CurrentUserCache, error) { + path, err := UserCachePath() + if err != nil { + return nil, err + } + + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("读取当前用户缓存失败: %w", err) + } + + var cache CurrentUserCache + if err := json.Unmarshal(data, &cache); err != nil { + return nil, fmt.Errorf("解析当前用户缓存失败: %w", err) + } + + return &cache, nil +} + +// SaveCurrentUserCache persists the current user cache to disk (0600). +func SaveCurrentUserCache(cache *CurrentUserCache) error { + if cache == nil { + return fmt.Errorf("当前用户缓存为空") + } + + path, err := UserCachePath() + if err != nil { + return err + } + + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0700); err != nil { + return fmt.Errorf("创建目录失败: %w", err) + } + + if cache.CachedAt.IsZero() { + cache.CachedAt = time.Now() + } + + data, err := json.MarshalIndent(cache, "", " ") + if err != nil { + return fmt.Errorf("序列化当前用户缓存失败: %w", err) + } + + if err := os.WriteFile(path, data, 0600); err != nil { + return fmt.Errorf("写入当前用户缓存失败: %w", err) + } + + return nil +} + +// DeleteCurrentUserCache removes the persisted current user cache. +func DeleteCurrentUserCache() error { + path, err := UserCachePath() + if err != nil { + return err + } + + if err := os.Remove(path); err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("删除当前用户缓存失败: %w", err) + } + + return nil +} + +// UserTokenFingerprint returns a stable fingerprint for the user access token +// without storing the raw token in the cache file. +func UserTokenFingerprint(token string) string { + if token == "" { + return "" + } + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:16]) +} + +// MatchesToken reports whether this cache entry belongs to the given token. +func (c *CurrentUserCache) MatchesToken(token string) bool { + if c == nil || c.TokenFingerprint == "" { + return false + } + return c.TokenFingerprint == UserTokenFingerprint(token) +} diff --git a/internal/auth/user_cache_test.go b/internal/auth/user_cache_test.go new file mode 100644 index 0000000..d00148c --- /dev/null +++ b/internal/auth/user_cache_test.go @@ -0,0 +1,191 @@ +package auth + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSaveLoadAndDeleteCurrentUserCache(t *testing.T) { + tmpDir := t.TempDir() + cacheFile := filepath.Join(tmpDir, "user_profile.json") + + original := userCachePathFunc + userCachePathFunc = func() (string, error) { return cacheFile, nil } + defer func() { userCachePathFunc = original }() + + cache := &CurrentUserCache{ + OpenID: "ou_test", + UserID: "u_test", + UnionID: "on_test", + Name: "Tester", + TokenFingerprint: UserTokenFingerprint("access-token"), + } + + if err := SaveCurrentUserCache(cache); err != nil { + t.Fatalf("SaveCurrentUserCache() error: %v", err) + } + + info, err := os.Stat(cacheFile) + if err != nil { + t.Fatalf("Stat() error: %v", err) + } + if perm := info.Mode().Perm(); perm != 0600 { + t.Fatalf("cache file permissions = %o, want 0600", perm) + } + + loaded, err := LoadCurrentUserCache() + if err != nil { + t.Fatalf("LoadCurrentUserCache() error: %v", err) + } + if loaded.OpenID != cache.OpenID { + t.Fatalf("OpenID = %q, want %q", loaded.OpenID, cache.OpenID) + } + if !loaded.MatchesToken("access-token") { + t.Fatalf("MatchesToken(access-token) = false, want true") + } + if loaded.MatchesToken("another-token") { + t.Fatalf("MatchesToken(another-token) = true, want false") + } + + if err := DeleteCurrentUserCache(); err != nil { + t.Fatalf("DeleteCurrentUserCache() error: %v", err) + } + + loaded, err = LoadCurrentUserCache() + if err != nil { + t.Fatalf("LoadCurrentUserCache() after delete error: %v", err) + } + if loaded != nil { + t.Fatalf("expected nil cache after delete") + } +} + +func TestSaveTokenDeletesCurrentUserCache(t *testing.T) { + tmpDir := t.TempDir() + tokenFile := filepath.Join(tmpDir, "token.json") + cacheFile := filepath.Join(tmpDir, "user_profile.json") + + originalTokenPath := tokenPathFunc + originalUserCachePath := userCachePathFunc + tokenPathFunc = func() (string, error) { return tokenFile, nil } + userCachePathFunc = func() (string, error) { return cacheFile, nil } + defer func() { + tokenPathFunc = originalTokenPath + userCachePathFunc = originalUserCachePath + }() + + if err := SaveCurrentUserCache(&CurrentUserCache{OpenID: "ou_test"}); err != nil { + t.Fatalf("SaveCurrentUserCache() setup error: %v", err) + } + + if err := SaveToken(&TokenStore{AccessToken: "access"}); err != nil { + t.Fatalf("SaveToken() error: %v", err) + } + + cache, err := LoadCurrentUserCache() + if err != nil { + t.Fatalf("LoadCurrentUserCache() error: %v", err) + } + if cache != nil { + t.Fatalf("expected current user cache to be cleared after SaveToken") + } +} + +func TestDeleteTokenDeletesCurrentUserCache(t *testing.T) { + tmpDir := t.TempDir() + tokenFile := filepath.Join(tmpDir, "token.json") + cacheFile := filepath.Join(tmpDir, "user_profile.json") + + originalTokenPath := tokenPathFunc + originalUserCachePath := userCachePathFunc + tokenPathFunc = func() (string, error) { return tokenFile, nil } + userCachePathFunc = func() (string, error) { return cacheFile, nil } + defer func() { + tokenPathFunc = originalTokenPath + userCachePathFunc = originalUserCachePath + }() + + if err := os.WriteFile(tokenFile, []byte(`{"access_token":"access"}`), 0600); err != nil { + t.Fatalf("write token file: %v", err) + } + if err := SaveCurrentUserCache(&CurrentUserCache{OpenID: "ou_test"}); err != nil { + t.Fatalf("SaveCurrentUserCache() setup error: %v", err) + } + + if err := DeleteToken(); err != nil { + t.Fatalf("DeleteToken() error: %v", err) + } + + cache, err := LoadCurrentUserCache() + if err != nil { + t.Fatalf("LoadCurrentUserCache() error: %v", err) + } + if cache != nil { + t.Fatalf("expected current user cache to be cleared after DeleteToken") + } +} + +func TestSaveTokenIgnoresCurrentUserCacheDeleteError(t *testing.T) { + tmpDir := t.TempDir() + tokenFile := filepath.Join(tmpDir, "token.json") + cachePath := nonRemovableCachePath(t) + + originalTokenPath := tokenPathFunc + originalUserCachePath := userCachePathFunc + tokenPathFunc = func() (string, error) { return tokenFile, nil } + userCachePathFunc = func() (string, error) { return cachePath, nil } + defer func() { + tokenPathFunc = originalTokenPath + userCachePathFunc = originalUserCachePath + }() + + if err := SaveToken(&TokenStore{AccessToken: "access"}); err != nil { + t.Fatalf("SaveToken() error = %v, want nil when cache cleanup fails", err) + } + + if _, err := os.Stat(tokenFile); err != nil { + t.Fatalf("token file not written: %v", err) + } +} + +func TestDeleteTokenIgnoresCurrentUserCacheDeleteError(t *testing.T) { + tmpDir := t.TempDir() + tokenFile := filepath.Join(tmpDir, "token.json") + cachePath := nonRemovableCachePath(t) + + originalTokenPath := tokenPathFunc + originalUserCachePath := userCachePathFunc + tokenPathFunc = func() (string, error) { return tokenFile, nil } + userCachePathFunc = func() (string, error) { return cachePath, nil } + defer func() { + tokenPathFunc = originalTokenPath + userCachePathFunc = originalUserCachePath + }() + + if err := os.WriteFile(tokenFile, []byte(`{"access_token":"access"}`), 0600); err != nil { + t.Fatalf("write token file: %v", err) + } + + if err := DeleteToken(); err != nil { + t.Fatalf("DeleteToken() error = %v, want nil when cache cleanup fails", err) + } + + if _, err := os.Stat(tokenFile); !os.IsNotExist(err) { + t.Fatalf("token file should be removed, got err = %v", err) + } +} + +func nonRemovableCachePath(t *testing.T) string { + t.Helper() + + cacheDir := filepath.Join(t.TempDir(), "user_profile.json") + if err := os.Mkdir(cacheDir, 0700); err != nil { + t.Fatalf("mkdir cache dir: %v", err) + } + if err := os.WriteFile(filepath.Join(cacheDir, "child"), []byte("busy"), 0600); err != nil { + t.Fatalf("write cache dir child: %v", err) + } + + return cacheDir +} diff --git a/internal/client/approval.go b/internal/client/approval.go new file mode 100644 index 0000000..0581482 --- /dev/null +++ b/internal/client/approval.go @@ -0,0 +1,428 @@ +package client + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + larkapproval "github.com/larksuite/oapi-sdk-go/v3/service/approval/v4" +) + +// GetApprovalOptions represents optional filters for fetching an approval definition. +type GetApprovalOptions struct { + Locale string + WithAdminID bool + UserIDType string + WithOption bool + UserID string +} + +// ApprovalDefinition represents a simplified approval definition. +type ApprovalDefinition struct { + ApprovalCode string `json:"approval_code"` + ApprovalName string `json:"approval_name"` + Status string `json:"status"` + Form any `json:"form,omitempty"` + NodeList []*ApprovalNode `json:"node_list,omitempty"` + Viewers []*ApprovalViewer `json:"viewers,omitempty"` + ApprovalAdminIDs []string `json:"approval_admin_ids,omitempty"` + FormWidgetRelation any `json:"form_widget_relation,omitempty"` +} + +// ApprovalNode represents a simplified approval node. +type ApprovalNode struct { + Name string `json:"name,omitempty"` + NodeID string `json:"node_id,omitempty"` + CustomNodeID string `json:"custom_node_id,omitempty"` + NodeType string `json:"node_type,omitempty"` + NeedApprover bool `json:"need_approver,omitempty"` + ApproverChosenMulti bool `json:"approver_chosen_multi,omitempty"` + RequireSignature bool `json:"require_signature,omitempty"` +} + +// ApprovalViewer represents a simplified approval viewer. +type ApprovalViewer struct { + Type string `json:"type,omitempty"` + ID string `json:"id,omitempty"` + UserID string `json:"user_id,omitempty"` +} + +// ApprovalTaskQueryOptions represents options for querying approval tasks. +type ApprovalTaskQueryOptions struct { + PageSize int + PageToken string + UserID string + Topic string + UserIDType string +} + +// ApprovalTaskQueryResult represents a simplified approval task query result. +type ApprovalTaskQueryResult struct { + Tasks []*ApprovalTaskInfo `json:"tasks"` + PageToken string `json:"page_token,omitempty"` + HasMore bool `json:"has_more"` + Count *ApprovalTaskCount `json:"count,omitempty"` +} + +// ApprovalTaskCount represents summary count information returned on the first page. +type ApprovalTaskCount struct { + Total int `json:"total"` + HasMore bool `json:"has_more"` +} + +// ApprovalTaskInfo represents a simplified approval task. +type ApprovalTaskInfo struct { + Topic string `json:"topic,omitempty"` + UserID string `json:"user_id,omitempty"` + Title string `json:"title,omitempty"` + HelpdeskURL string `json:"helpdesk_url,omitempty"` + MobileURL string `json:"mobile_url,omitempty"` + PCURL string `json:"pc_url,omitempty"` + ProcessExternalID string `json:"process_external_id,omitempty"` + TaskExternalID string `json:"task_external_id,omitempty"` + Status string `json:"status,omitempty"` + ProcessStatus string `json:"process_status,omitempty"` + DefinitionCode string `json:"definition_code,omitempty"` + DefinitionName string `json:"definition_name,omitempty"` + DefinitionID string `json:"definition_id,omitempty"` + DefinitionGroupID string `json:"definition_group_id,omitempty"` + DefinitionGroupName string `json:"definition_group_name,omitempty"` + Initiators []string `json:"initiators,omitempty"` + InitiatorNames []string `json:"initiator_names,omitempty"` + TaskID string `json:"task_id,omitempty"` + ProcessID string `json:"process_id,omitempty"` + ProcessCode string `json:"process_code,omitempty"` +} + +type approvalTaskString string + +func (s *approvalTaskString) UnmarshalJSON(data []byte) error { + data = bytes.TrimSpace(data) + if bytes.Equal(data, []byte("null")) { + *s = "" + return nil + } + + var str string + if err := json.Unmarshal(data, &str); err == nil { + *s = approvalTaskString(str) + return nil + } + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + + var number json.Number + if err := decoder.Decode(&number); err == nil { + *s = approvalTaskString(number.String()) + return nil + } + + var boolean bool + if err := json.Unmarshal(data, &boolean); err == nil { + *s = approvalTaskString(strconv.FormatBool(boolean)) + return nil + } + + return fmt.Errorf("不支持的审批任务字段类型: %s", string(data)) +} + +func (s approvalTaskString) String() string { + return string(s) +} + +type approvalTaskQueryAPIResp struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data *approvalTaskQueryAPIData `json:"data"` +} + +type approvalTaskQueryAPIData struct { + Tasks []*approvalTaskAPIInfo `json:"tasks,omitempty"` + PageToken string `json:"page_token,omitempty"` + HasMore bool `json:"has_more,omitempty"` + Count *ApprovalTaskCount `json:"count,omitempty"` +} + +type approvalTaskAPIInfo struct { + Topic approvalTaskString `json:"topic,omitempty"` + UserID approvalTaskString `json:"user_id,omitempty"` + Title approvalTaskString `json:"title,omitempty"` + Urls *approvalTaskURLs `json:"urls,omitempty"` + ProcessExternalID approvalTaskString `json:"process_external_id,omitempty"` + TaskExternalID approvalTaskString `json:"task_external_id,omitempty"` + Status approvalTaskString `json:"status,omitempty"` + ProcessStatus approvalTaskString `json:"process_status,omitempty"` + DefinitionCode approvalTaskString `json:"definition_code,omitempty"` + DefinitionName approvalTaskString `json:"definition_name,omitempty"` + DefinitionID approvalTaskString `json:"definition_id,omitempty"` + DefinitionGroupID approvalTaskString `json:"definition_group_id,omitempty"` + DefinitionGroupName approvalTaskString `json:"definition_group_name,omitempty"` + Initiators []string `json:"initiators,omitempty"` + InitiatorNames []string `json:"initiator_names,omitempty"` + TaskID approvalTaskString `json:"task_id,omitempty"` + ProcessID approvalTaskString `json:"process_id,omitempty"` + ProcessCode approvalTaskString `json:"process_code,omitempty"` +} + +type approvalTaskURLs struct { + Helpdesk approvalTaskString `json:"helpdesk,omitempty"` + Mobile approvalTaskString `json:"mobile,omitempty"` + Pc approvalTaskString `json:"pc,omitempty"` +} + +type approvalGetAPIResp struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data *larkapproval.GetApprovalRespData `json:"data"` +} + +func getApprovalDefinitionRawBody(approvalCode string, opts GetApprovalOptions) ([]byte, error) { + client, err := GetClient() + if err != nil { + return nil, err + } + + req := &larkcore.ApiReq{ + HttpMethod: http.MethodGet, + ApiPath: "/open-apis/approval/v4/approvals/:approval_code", + PathParams: larkcore.PathParams{}, + QueryParams: larkcore.QueryParams{}, + SupportedAccessTokenTypes: []larkcore.AccessTokenType{larkcore.AccessTokenTypeTenant}, + } + req.PathParams.Set("approval_code", approvalCode) + if opts.Locale != "" { + req.QueryParams.Set("locale", opts.Locale) + } + if opts.WithAdminID { + req.QueryParams.Set("with_admin_id", "true") + } + if opts.UserIDType != "" { + req.QueryParams.Set("user_id_type", opts.UserIDType) + } + if opts.WithOption { + req.QueryParams.Set("with_option", "true") + } + if opts.UserID != "" { + req.QueryParams.Set("user_id", opts.UserID) + } + + resp, err := client.Do(Context(), req) + if err != nil { + return nil, fmt.Errorf("获取审批定义失败: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("获取审批定义失败: HTTP %d, body: %s", resp.StatusCode, string(resp.RawBody)) + } + + return resp.RawBody, nil +} + +// GetApprovalDefinitionRaw retrieves the raw approval definition response body from the API. +func GetApprovalDefinitionRaw(approvalCode string, opts GetApprovalOptions) ([]byte, error) { + return getApprovalDefinitionRawBody(approvalCode, opts) +} + +// GetApprovalDefinition retrieves approval definition details by approval code. +func GetApprovalDefinition(approvalCode string, opts GetApprovalOptions) (*ApprovalDefinition, error) { + body, err := getApprovalDefinitionRawBody(approvalCode, opts) + if err != nil { + return nil, err + } + + return parseApprovalDefinitionResponse(body, approvalCode) +} + +func parseApprovalDefinitionResponse(body []byte, approvalCode string) (*ApprovalDefinition, error) { + var apiResp approvalGetAPIResp + if err := json.Unmarshal(body, &apiResp); err != nil { + return nil, fmt.Errorf("解析审批定义响应失败: %w", err) + } + + if apiResp.Code != 0 { + return nil, fmt.Errorf("获取审批定义失败: code=%d, msg=%s", apiResp.Code, apiResp.Msg) + } + + if apiResp.Data == nil { + return nil, fmt.Errorf("获取审批定义返回数据为空") + } + + result := &ApprovalDefinition{ + ApprovalCode: approvalCode, + ApprovalName: StringVal(apiResp.Data.ApprovalName), + Status: StringVal(apiResp.Data.Status), + Form: parseEmbeddedJSON(StringVal(apiResp.Data.Form)), + ApprovalAdminIDs: apiResp.Data.ApprovalAdminIds, + FormWidgetRelation: parseEmbeddedJSON(StringVal(apiResp.Data.FormWidgetRelation)), + } + + if len(apiResp.Data.NodeList) > 0 { + result.NodeList = make([]*ApprovalNode, 0, len(apiResp.Data.NodeList)) + for _, node := range apiResp.Data.NodeList { + if node == nil { + continue + } + result.NodeList = append(result.NodeList, &ApprovalNode{ + Name: StringVal(node.Name), + NodeID: StringVal(node.NodeId), + CustomNodeID: StringVal(node.CustomNodeId), + NodeType: StringVal(node.NodeType), + NeedApprover: BoolVal(node.NeedApprover), + ApproverChosenMulti: BoolVal(node.ApproverChosenMulti), + RequireSignature: BoolVal(node.RequireSignature), + }) + } + } + + if len(apiResp.Data.Viewers) > 0 { + result.Viewers = make([]*ApprovalViewer, 0, len(apiResp.Data.Viewers)) + for _, viewer := range apiResp.Data.Viewers { + if viewer == nil { + continue + } + result.Viewers = append(result.Viewers, &ApprovalViewer{ + Type: StringVal(viewer.Type), + ID: StringVal(viewer.Id), + UserID: StringVal(viewer.UserId), + }) + } + } + + return result, nil +} + +func queryApprovalTasksRawBody(opts ApprovalTaskQueryOptions, userAccessToken string) ([]byte, error) { + client, err := GetClient() + if err != nil { + return nil, err + } + + req := &larkcore.ApiReq{ + HttpMethod: http.MethodGet, + ApiPath: "/open-apis/approval/v4/tasks/query", + QueryParams: larkcore.QueryParams{}, + SupportedAccessTokenTypes: []larkcore.AccessTokenType{larkcore.AccessTokenTypeTenant, larkcore.AccessTokenTypeUser}, + } + req.QueryParams.Set("user_id", opts.UserID) + req.QueryParams.Set("topic", opts.Topic) + + if opts.PageSize > 0 { + req.QueryParams.Set("page_size", strconv.Itoa(opts.PageSize)) + } + if opts.PageToken != "" { + req.QueryParams.Set("page_token", opts.PageToken) + } + if opts.UserIDType != "" { + req.QueryParams.Set("user_id_type", opts.UserIDType) + } + + resp, err := client.Do(Context(), req, UserTokenOption(userAccessToken)...) + if err != nil { + return nil, fmt.Errorf("查询审批任务失败: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("查询审批任务失败: HTTP %d, body: %s", resp.StatusCode, string(resp.RawBody)) + } + + return resp.RawBody, nil +} + +// QueryApprovalTasksRaw retrieves the raw approval task response body from the API. +func QueryApprovalTasksRaw(opts ApprovalTaskQueryOptions, userAccessToken string) ([]byte, error) { + return queryApprovalTasksRawBody(opts, userAccessToken) +} + +// QueryApprovalTasks retrieves approval tasks for a user. +func QueryApprovalTasks(opts ApprovalTaskQueryOptions, userAccessToken string) (*ApprovalTaskQueryResult, error) { + body, err := queryApprovalTasksRawBody(opts, userAccessToken) + if err != nil { + return nil, err + } + + return parseApprovalTaskQueryResponse(body) +} + +func parseEmbeddedJSON(raw string) any { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + + var value any + if err := json.Unmarshal([]byte(raw), &value); err == nil { + return value + } + + return raw +} + +func parseApprovalTaskQueryResponse(body []byte) (*ApprovalTaskQueryResult, error) { + var apiResp approvalTaskQueryAPIResp + if err := json.Unmarshal(body, &apiResp); err != nil { + return nil, fmt.Errorf("解析审批任务响应失败: %w", err) + } + + if apiResp.Code != 0 { + return nil, fmt.Errorf("查询审批任务失败: code=%d, msg=%s", apiResp.Code, apiResp.Msg) + } + + result := &ApprovalTaskQueryResult{ + Tasks: make([]*ApprovalTaskInfo, 0), + } + + if apiResp.Data == nil { + return result, nil + } + + result.PageToken = apiResp.Data.PageToken + result.HasMore = apiResp.Data.HasMore + result.Count = apiResp.Data.Count + + if len(apiResp.Data.Tasks) > 0 { + result.Tasks = make([]*ApprovalTaskInfo, 0, len(apiResp.Data.Tasks)) + for _, task := range apiResp.Data.Tasks { + if task == nil { + continue + } + result.Tasks = append(result.Tasks, approvalTaskAPIToInfo(task)) + } + } + + return result, nil +} + +func approvalTaskAPIToInfo(task *approvalTaskAPIInfo) *ApprovalTaskInfo { + info := &ApprovalTaskInfo{ + Topic: task.Topic.String(), + UserID: task.UserID.String(), + Title: task.Title.String(), + ProcessExternalID: task.ProcessExternalID.String(), + TaskExternalID: task.TaskExternalID.String(), + Status: task.Status.String(), + ProcessStatus: task.ProcessStatus.String(), + DefinitionCode: task.DefinitionCode.String(), + DefinitionName: task.DefinitionName.String(), + DefinitionID: task.DefinitionID.String(), + DefinitionGroupID: task.DefinitionGroupID.String(), + DefinitionGroupName: task.DefinitionGroupName.String(), + Initiators: task.Initiators, + InitiatorNames: task.InitiatorNames, + TaskID: task.TaskID.String(), + ProcessID: task.ProcessID.String(), + ProcessCode: task.ProcessCode.String(), + } + + if task.Urls != nil { + info.HelpdeskURL = task.Urls.Helpdesk.String() + info.MobileURL = task.Urls.Mobile.String() + info.PCURL = task.Urls.Pc.String() + } + + return info +} diff --git a/internal/client/approval_test.go b/internal/client/approval_test.go new file mode 100644 index 0000000..06badf3 --- /dev/null +++ b/internal/client/approval_test.go @@ -0,0 +1,229 @@ +package client + +import ( + "encoding/json" + "reflect" + "testing" +) + +func TestParseEmbeddedJSON(t *testing.T) { + tests := []struct { + name string + input string + want any + }{ + { + name: "json object", + input: `{"name":"leave"}`, + want: map[string]any{"name": "leave"}, + }, + { + name: "json array", + input: `[{"id":"widget_1"}]`, + want: []any{map[string]any{"id": "widget_1"}}, + }, + { + name: "plain string fallback", + input: `not-json`, + want: "not-json", + }, + { + name: "empty string", + input: ` `, + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseEmbeddedJSON(tt.input) + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("parseEmbeddedJSON(%q) = %#v, want %#v", tt.input, got, tt.want) + } + }) + } +} + +func TestApprovalTaskStringUnmarshal(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "string", + input: `"RUNNING"`, + want: "RUNNING", + }, + { + name: "number", + input: `42`, + want: "42", + }, + { + name: "boolean", + input: `true`, + want: "true", + }, + { + name: "null", + input: `null`, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got approvalTaskString + if err := json.Unmarshal([]byte(tt.input), &got); err != nil { + t.Fatalf("json.Unmarshal(%s) error = %v", tt.input, err) + } + if got.String() != tt.want { + t.Fatalf("json.Unmarshal(%s) = %q, want %q", tt.input, got.String(), tt.want) + } + }) + } +} + +func TestParseApprovalDefinitionResponse(t *testing.T) { + body := []byte(`{ + "code": 0, + "msg": "success", + "data": { + "approval_name": "请假", + "status": "ACTIVE", + "form": "{\"widgets\":[{\"id\":\"widget_1\"}]}", + "node_list": [ + { + "name": "直属上级", + "node_id": "node_1", + "custom_node_id": "custom_1", + "node_type": "AND", + "need_approver": true, + "approver_chosen_multi": false, + "require_signature": true + } + ], + "viewers": [ + { + "type": "USER", + "id": "ou_viewer", + "user_id": "ou_viewer" + } + ], + "approval_admin_ids": ["ou_admin"], + "form_widget_relation": "{\"widget_1\":[\"widget_2\"]}" + } + }`) + + result, err := parseApprovalDefinitionResponse(body, "approval_123") + if err != nil { + t.Fatalf("parseApprovalDefinitionResponse() error = %v", err) + } + + if result.ApprovalCode != "approval_123" { + t.Fatalf("ApprovalCode = %q, want %q", result.ApprovalCode, "approval_123") + } + if result.ApprovalName != "请假" { + t.Fatalf("ApprovalName = %q, want %q", result.ApprovalName, "请假") + } + if result.Status != "ACTIVE" { + t.Fatalf("Status = %q, want %q", result.Status, "ACTIVE") + } + + wantForm := map[string]any{ + "widgets": []any{ + map[string]any{"id": "widget_1"}, + }, + } + if !reflect.DeepEqual(result.Form, wantForm) { + t.Fatalf("Form = %#v, want %#v", result.Form, wantForm) + } + + if len(result.NodeList) != 1 || result.NodeList[0].NodeID != "node_1" || !result.NodeList[0].NeedApprover || !result.NodeList[0].RequireSignature { + t.Fatalf("NodeList = %#v, want one populated node", result.NodeList) + } + if len(result.Viewers) != 1 || result.Viewers[0].UserID != "ou_viewer" { + t.Fatalf("Viewers = %#v, want one viewer", result.Viewers) + } + if !reflect.DeepEqual(result.ApprovalAdminIDs, []string{"ou_admin"}) { + t.Fatalf("ApprovalAdminIDs = %#v, want %#v", result.ApprovalAdminIDs, []string{"ou_admin"}) + } + + wantRelation := map[string]any{ + "widget_1": []any{"widget_2"}, + } + if !reflect.DeepEqual(result.FormWidgetRelation, wantRelation) { + t.Fatalf("FormWidgetRelation = %#v, want %#v", result.FormWidgetRelation, wantRelation) + } +} + +func TestParseApprovalDefinitionResponseError(t *testing.T) { + body := []byte(`{"code": 99991663, "msg": "invalid approval code"}`) + + _, err := parseApprovalDefinitionResponse(body, "approval_123") + if err == nil { + t.Fatal("parseApprovalDefinitionResponse() error = nil, want non-nil") + } + if got := err.Error(); got != "获取审批定义失败: code=99991663, msg=invalid approval code" { + t.Fatalf("parseApprovalDefinitionResponse() error = %q, want %q", got, "获取审批定义失败: code=99991663, msg=invalid approval code") + } +} + +func TestParseApprovalTaskQueryResponseHandlesNumericProcessStatus(t *testing.T) { + body := []byte(`{ + "code": 0, + "msg": "success", + "data": { + "page_token": "next_page", + "has_more": true, + "count": { + "total": 1, + "has_more": false + }, + "tasks": [ + { + "topic": 1, + "user_id": "ou_user", + "title": "审批标题", + "status": "PENDING", + "process_status": 12, + "definition_name": "文档权限申请", + "task_id": "task_id", + "process_id": "process_id", + "initiator_names": ["A"], + "urls": { + "pc": "https://pc" + } + } + ] + } + }`) + + result, err := parseApprovalTaskQueryResponse(body) + if err != nil { + t.Fatalf("parseApprovalTaskQueryResponse() error = %v", err) + } + + if !result.HasMore { + t.Fatalf("HasMore = %v, want true", result.HasMore) + } + if result.PageToken != "next_page" { + t.Fatalf("PageToken = %q, want %q", result.PageToken, "next_page") + } + if result.Count == nil || result.Count.Total != 1 { + t.Fatalf("Count = %#v, want total 1", result.Count) + } + if len(result.Tasks) != 1 { + t.Fatalf("len(Tasks) = %d, want 1", len(result.Tasks)) + } + if result.Tasks[0].Topic != "1" { + t.Fatalf("Topic = %q, want %q", result.Tasks[0].Topic, "1") + } + if result.Tasks[0].ProcessStatus != "12" { + t.Fatalf("ProcessStatus = %q, want %q", result.Tasks[0].ProcessStatus, "12") + } + if result.Tasks[0].PCURL != "https://pc" { + t.Fatalf("PCURL = %q, want %q", result.Tasks[0].PCURL, "https://pc") + } +} diff --git a/internal/client/user.go b/internal/client/user.go index 7b0fbe7..6d9adf2 100644 --- a/internal/client/user.go +++ b/internal/client/user.go @@ -3,6 +3,7 @@ package client import ( "fmt" + larkauthen "github.com/larksuite/oapi-sdk-go/v3/service/authen/v1" larkcontact "github.com/larksuite/oapi-sdk-go/v3/service/contact/v3" ) @@ -102,6 +103,47 @@ func GetUserInfo(userID string, opts GetUserInfoOptions) (*UserInfo, error) { return info, nil } +// GetCurrentUserInfo retrieves the currently authorized user's profile. +func GetCurrentUserInfo(userAccessToken string) (*UserInfo, error) { + client, err := GetClient() + if err != nil { + return nil, err + } + + resp, err := client.Authen.V1.UserInfo.Get(Context(), UserTokenOption(userAccessToken)...) + if err != nil { + return nil, fmt.Errorf("获取当前登录用户信息失败: %w", err) + } + + if !resp.Success() { + return nil, fmt.Errorf("获取当前登录用户信息失败: code=%d, msg=%s", resp.Code, resp.Msg) + } + + if resp.Data == nil { + return nil, fmt.Errorf("当前登录用户信息为空") + } + + return currentUserInfoFromAuthen(resp.Data), nil +} + +func currentUserInfoFromAuthen(data *larkauthen.GetUserInfoRespData) *UserInfo { + if data == nil { + return &UserInfo{} + } + + return &UserInfo{ + UserID: StringVal(data.UserId), + OpenID: StringVal(data.OpenId), + UnionID: StringVal(data.UnionId), + Name: StringVal(data.Name), + EnName: StringVal(data.EnName), + Email: StringVal(data.Email), + Mobile: StringVal(data.Mobile), + Avatar: StringVal(data.AvatarUrl), + EmployeeNo: StringVal(data.EmployeeNo), + } +} + // batchUserLimit 飞书 Batch API 单次最多查询 50 个用户 const batchUserLimit = 50 diff --git a/skills/feishu-cli-toolkit/SKILL.md b/skills/feishu-cli-toolkit/SKILL.md index 57676c3..59be796 100644 --- a/skills/feishu-cli-toolkit/SKILL.md +++ b/skills/feishu-cli-toolkit/SKILL.md @@ -2,13 +2,13 @@ name: feishu-cli-toolkit description: >- 飞书综合工具箱:电子表格(含导出 XLSX/CSV)、日历日程(含日程列表/agenda)、 - 任务管理(含我的任务/重新打开/评论)、任务清单(含任务关联/成员管理)、 + 任务管理、审批查询(含我的任务/重新打开/评论)、任务清单(含任务关联/成员管理)、 画板操作、PlantUML 图表、文件管理、素材上传下载、文档评论、知识库、 用户通讯录、文档附件下载。 当用户请求操作飞书表格、导出表格、查看日历、查看日程列表、创建任务、 - 查看我的任务、重新打开任务、任务评论、任务清单成员、操作画板、生成 PlantUML、 - 管理文件、上传素材、查看评论、查看知识库、查询用户信息、查询部门、 - 下载文档附件时使用。 + 查看我的任务、重新打开任务、任务评论、任务清单成员、查询审批定义或当前登录用户审批任务、 + 操作画板、生成 PlantUML、管理文件、上传素材、查看评论、查看知识库、查询用户信息、 + 查询部门、下载文档附件时使用。 注意:群聊浏览/管理请使用 feishu-cli-chat,搜索请使用 feishu-cli-search, 发送消息请使用 feishu-cli-msg。 argument-hint: [args] @@ -18,7 +18,7 @@ allowed-tools: Bash, Read, Write # 飞书综合工具箱 -覆盖 feishu-cli 的 13 个功能模块,提供命令速查和核心用法。复杂模块的详细参考文档在 `references/` 目录中。 +覆盖 feishu-cli 的 14 个功能模块,提供命令速查和核心用法。复杂模块的详细参考文档在 `references/` 目录中。 > **feishu-cli**:如尚未安装,请前往 [riba2534/feishu-cli](https://github.com/riba2534/feishu-cli) 获取安装方式。 @@ -36,9 +36,10 @@ allowed-tools: Bash, Read, Write | 8 | 素材管理 | `media upload/download` | — | | 9 | 评论管理 | `comment list/add/delete/resolve/unresolve` + `comment reply` | — | | 10 | 知识库 | `wiki get/export/spaces/nodes/space-get` + `wiki member` | — | -| 11 | 搜索 | 请使用 **feishu-cli-search**(文档/应用)或 **feishu-cli-chat**(消息/群聊) | `references/search-commands.md` | -| 12 | 用户和部门 | `user info/search/list` + `dept get/children` | — | -| 13 | 附件下载 | `doc export` + `media download` 批量下载文档附件 | — | +| 11 | 审批 | `approval get` + `approval task query` | — | +| 12 | 搜索 | 请使用 **feishu-cli-search**(文档/应用)或 **feishu-cli-chat**(消息/群聊) | `references/search-commands.md` | +| 13 | 用户和部门 | `user info/search/list` + `dept get/children` | — | +| 14 | 附件下载 | `doc export` + `media download` 批量下载文档附件 | — | --- @@ -343,7 +344,30 @@ feishu-cli chat link [--validity-period week|year|permanently] --- -## 5. 画板操作 +## 5. 审批查询 + +查询审批定义详情(审批模板/流程定义),以及当前登录用户的审批待办、已办、已发起或抄送任务。`approval get` 和 `approval task query` 都支持 `--output raw-json` 查看飞书 API 原始响应;其中 `approval task query` 依赖 `auth login` 的当前登录态。 + +### 常用命令 + +```bash +# 查询审批定义详情(审批模板/流程定义) +feishu-cli approval get +feishu-cli approval get --output json +feishu-cli approval get --output raw-json + +# 查询当前登录用户审批任务 +feishu-cli approval task query --topic todo +feishu-cli approval task query --topic done +feishu-cli approval task query --topic started --output json +feishu-cli approval task query --topic started --output raw-json +``` + +**权限要求**:`approval:approval:readonly`、`approval:task` + +--- + +## 6. 画板操作 下载画板图片、导入 Mermaid/PlantUML 图表到画板。