mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56ed529c1b | ||
|
|
f67f569e76 | ||
|
|
f930d9c52f | ||
|
|
7c3d5b31d5 | ||
|
|
bf537f8d9c | ||
|
|
10caeb5788 | ||
|
|
6a4dd8dc1b | ||
|
|
1f3d9e0420 | ||
|
|
6692300468 | ||
|
|
7baba213bc | ||
|
|
725a62879b | ||
|
|
112dd5f6b2 | ||
|
|
0f96bdf5e8 | ||
|
|
102ee51914 | ||
|
|
79f43dc337 | ||
|
|
f231031041 | ||
|
|
f68a41163e |
28
.github/workflows/gitleaks.yml
vendored
Normal file
28
.github/workflows/gitleaks.yml
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
name: Gitleaks
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
gitleaks:
|
||||
# Forked pull_request runs do not receive repository/org secrets except GITHUB_TOKEN.
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2.3.9
|
||||
env:
|
||||
# GITHUB_TOKEN is provided automatically by GitHub Actions.
|
||||
# GITLEAKS_KEY must be configured as a repository secret.
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_KEY }}
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -30,3 +30,7 @@ test_scripts/
|
||||
tests/mail/reports/
|
||||
|
||||
/log/
|
||||
|
||||
# Generated / test artifacts
|
||||
internal/registry/meta_data.json
|
||||
cmd/api/download.bin
|
||||
|
||||
16
.gitleaks.toml
Normal file
16
.gitleaks.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
title = "lark-cli gitleaks config"
|
||||
|
||||
[extend]
|
||||
useDefault = true
|
||||
|
||||
[[rules]]
|
||||
id = "lark-bot-app-id"
|
||||
description = "Detect Lark bot app ids"
|
||||
regex = '''\bcli_[a-z0-9]{16}\b'''
|
||||
keywords = ["cli_"]
|
||||
|
||||
[[rules]]
|
||||
id = "lark-session-token"
|
||||
description = "Detect Lark session tokens"
|
||||
regex = '''\bXN0YXJ0-[A-Za-z0-9_-]+-WVuZA\b'''
|
||||
keywords = ["XN0YXJ0-", "-WVuZA"]
|
||||
27
CHANGELOG.md
27
CHANGELOG.md
@@ -2,6 +2,32 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.3] - 2026-04-02
|
||||
|
||||
### Features
|
||||
|
||||
- Add `--jq` flag for filtering JSON output (#211)
|
||||
- Add `+download` shortcut for minutes media download (#101)
|
||||
- Add drive import, export, move, and task result shortcuts (#194)
|
||||
- Support im message send/reply with uat (#180)
|
||||
- Add approve domain (#217)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **mail**: Use in-memory keyring in mail scope tests to avoid macOS keychain popups (#212)
|
||||
- **mail**: On-demand scope checks and watch event filtering (#198)
|
||||
- Use curl for binary download to support proxy and add npmmirror fallback (#226)
|
||||
- Normalize escaped sheet range separators (#207)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **mail**: Clarify JSON output is directly usable without extra encoding (#228)
|
||||
- Clarify docs search query usage (#221)
|
||||
|
||||
### CI
|
||||
|
||||
- Add gitleaks scanning workflow and custom rules (#142)
|
||||
|
||||
## [v1.0.2] - 2026-04-01
|
||||
|
||||
### Features
|
||||
@@ -110,6 +136,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.3]: https://github.com/larksuite/cli/releases/tag/v1.0.3
|
||||
[v1.0.2]: https://github.com/larksuite/cli/releases/tag/v1.0.2
|
||||
[v1.0.1]: https://github.com/larksuite/cli/releases/tag/v1.0.1
|
||||
[v1.0.0]: https://github.com/larksuite/cli/releases/tag/v1.0.0
|
||||
|
||||
72
README.md
72
README.md
@@ -6,14 +6,14 @@
|
||||
|
||||
[中文版](./README.zh.md) | [English](./README.md)
|
||||
|
||||
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Calendar, Mail, Tasks, Meetings, and more, with 200+ commands and 19 AI Agent [Skills](./skills/).
|
||||
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Calendar, Mail, Tasks, Meetings, and more, with 200+ commands and 20 AI Agent [Skills](./skills/).
|
||||
|
||||
[Install](#installation--quick-start) · [AI Agent Skills](#agent-skills) · [Auth](#authentication) · [Commands](#three-layer-command-system) · [Advanced](#advanced-usage) · [Security](#security--risk-warnings-read-before-use) · [Contributing](#contributing)
|
||||
|
||||
## Why lark-cli?
|
||||
|
||||
- **Agent-Native Design** — 19 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
|
||||
- **Wide Coverage** — 11 business domains, 200+ curated commands, 19 AI Agent [Skills](./skills/)
|
||||
- **Agent-Native Design** — 20 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
|
||||
- **Wide Coverage** — 12 business domains, 200+ curated commands, 20 AI Agent [Skills](./skills/)
|
||||
- **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output to maximize Agent call success rates
|
||||
- **Open Source, Zero Barriers** — MIT license, ready to use, just `npm install`
|
||||
- **Up and Running in 3 Minutes** — One-click app creation, interactive login, from install to first API call in just 3 steps
|
||||
@@ -22,19 +22,20 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
|
||||
|
||||
## Features
|
||||
|
||||
| Category | Capabilities |
|
||||
| ------------- | ----------------------------------------------------------------------------------- |
|
||||
| 📅 Calendar | View agenda, create events, invite attendees, check free/busy status, time suggestions |
|
||||
| 💬 Messenger | Send/reply messages, create and manage group chats, view chat history & threads, search messages, download media |
|
||||
| 📄 Docs | Create, read, update, and search documents, read/write media & whiteboards |
|
||||
| 📁 Drive | Upload and download files, search docs & wiki, manage comments |
|
||||
| Category | Capabilities |
|
||||
| ------------- |-----------------------------------------------------------------------------------------------------------------------------------|
|
||||
| 📅 Calendar | View agenda, create events, invite attendees, check free/busy status, time suggestions |
|
||||
| 💬 Messenger | Send/reply messages, create and manage group chats, view chat history & threads, search messages, download media |
|
||||
| 📄 Docs | Create, read, update, and search documents, read/write media & whiteboards |
|
||||
| 📁 Drive | Upload and download files, search docs & wiki, manage comments |
|
||||
| 📊 Base | Create and manage tables, fields, records, views, dashboards, workflows, forms, roles & permissions, data aggregation & analytics |
|
||||
| 📈 Sheets | Create, read, write, append, find, and export spreadsheet data |
|
||||
| ✅ Tasks | Create, query, update, and complete tasks; manage task lists, subtasks, comments & reminders |
|
||||
| 📚 Wiki | Create and manage knowledge spaces, nodes, and documents |
|
||||
| 👤 Contact | Search users by name/email/phone, get user profiles |
|
||||
| 📧 Mail | Browse, search, read emails, send, reply, forward, manage drafts, watch new mail |
|
||||
| 🎥 Meetings | Search meeting records, query meeting minutes & recordings |
|
||||
| 📈 Sheets | Create, read, write, append, find, and export spreadsheet data |
|
||||
| ✅ Tasks | Create, query, update, and complete tasks; manage task lists, subtasks, comments & reminders |
|
||||
| 📚 Wiki | Create and manage knowledge spaces, nodes, and documents |
|
||||
| 👤 Contact | Search users by name/email/phone, get user profiles |
|
||||
| 📧 Mail | Browse, search, read emails, send, reply, forward, manage drafts, watch new mail |
|
||||
| 🎥 Meetings | Search meeting records, query meeting minutes & recordings |
|
||||
| ✍️ Approval | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
|
||||
|
||||
## Installation & Quick Start
|
||||
|
||||
@@ -127,27 +128,28 @@ lark-cli auth status
|
||||
|
||||
## Agent Skills
|
||||
|
||||
| Skill | Description |
|
||||
| ------------------------------- | ------------------------------------------------------------------------------------- |
|
||||
| Skill | Description |
|
||||
| ------------------------------- |----------------------------------------------------------------------------------------------------------------|
|
||||
| `lark-shared` | App config, auth login, identity switching, scope management, security rules (auto-loaded by all other skills) |
|
||||
| `lark-calendar` | Calendar events, agenda view, free/busy queries, time suggestions |
|
||||
| `lark-im` | Send/reply messages, group chat management, message search, upload/download images & files, reactions |
|
||||
| `lark-doc` | Create, read, update, search documents (Markdown-based) |
|
||||
| `lark-drive` | Upload, download files, manage permissions & comments |
|
||||
| `lark-sheets` | Create, read, write, append, find, export spreadsheets |
|
||||
| `lark-base` | Tables, fields, records, views, dashboards, data aggregation & analytics |
|
||||
| `lark-task` | Tasks, task lists, subtasks, reminders, member assignment |
|
||||
| `lark-mail` | Browse, search, read emails, send, reply, forward, draft management, watch new mail |
|
||||
| `lark-contact` | Search users by name/email/phone, get user profiles |
|
||||
| `lark-wiki` | Knowledge spaces, nodes, documents |
|
||||
| `lark-event` | Real-time event subscriptions (WebSocket), regex routing & agent-friendly format |
|
||||
| `lark-vc` | Search meeting records, query meeting minutes (summary, todos, transcript) |
|
||||
| `lark-whiteboard` | Whiteboard/chart DSL rendering |
|
||||
| `lark-minutes` | Minutes metadata & AI artifacts (summary, todos, chapters) |
|
||||
| `lark-openapi-explorer` | Explore underlying APIs from official docs |
|
||||
| `lark-skill-maker` | Custom skill creation framework |
|
||||
| `lark-workflow-meeting-summary` | Workflow: meeting minutes aggregation & structured report |
|
||||
| `lark-workflow-standup-report` | Workflow: agenda & todo summary |
|
||||
| `lark-calendar` | Calendar events, agenda view, free/busy queries, time suggestions |
|
||||
| `lark-im` | Send/reply messages, group chat management, message search, upload/download images & files, reactions |
|
||||
| `lark-doc` | Create, read, update, search documents (Markdown-based) |
|
||||
| `lark-drive` | Upload, download files, manage permissions & comments |
|
||||
| `lark-sheets` | Create, read, write, append, find, export spreadsheets |
|
||||
| `lark-base` | Tables, fields, records, views, dashboards, data aggregation & analytics |
|
||||
| `lark-task` | Tasks, task lists, subtasks, reminders, member assignment |
|
||||
| `lark-mail` | Browse, search, read emails, send, reply, forward, draft management, watch new mail |
|
||||
| `lark-contact` | Search users by name/email/phone, get user profiles |
|
||||
| `lark-wiki` | Knowledge spaces, nodes, documents |
|
||||
| `lark-event` | Real-time event subscriptions (WebSocket), regex routing & agent-friendly format |
|
||||
| `lark-vc` | Search meeting records, query meeting minutes (summary, todos, transcript) |
|
||||
| `lark-whiteboard` | Whiteboard/chart DSL rendering |
|
||||
| `lark-minutes` | Minutes metadata & AI artifacts (summary, todos, chapters) |
|
||||
| `lark-openapi-explorer` | Explore underlying APIs from official docs |
|
||||
| `lark-skill-maker` | Custom skill creation framework |
|
||||
| `lark-approval` | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
|
||||
| `lark-workflow-meeting-summary` | Workflow: meeting minutes aggregation & structured report |
|
||||
| `lark-workflow-standup-report` | Workflow: agenda & todo summary |
|
||||
|
||||
## Authentication
|
||||
|
||||
|
||||
70
README.zh.md
70
README.zh.md
@@ -6,14 +6,14 @@
|
||||
|
||||
[中文版](./README.zh.md) | [English](./README.md)
|
||||
|
||||
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、日历、邮箱、任务、会议等核心业务域,提供 200+ 命令及 19 个 AI Agent [Skills](./skills/)。
|
||||
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、日历、邮箱、任务、会议等核心业务域,提供 200+ 命令及 20 个 AI Agent [Skills](./skills/)。
|
||||
|
||||
[安装](#安装与快速开始) · [AI Agent Skills](#agent-skills) · [认证](#认证) · [命令](#三层命令调用) · [进阶用法](#进阶用法) · [安全](#安全与风险提示使用前必读) · [贡献](#贡献)
|
||||
|
||||
## 为什么选 lark-cli?
|
||||
|
||||
- **为 Agent 原生设计** — [Skills](./skills/) 开箱即用,适配主流 AI 工具,Agent 无需额外适配即可操作飞书
|
||||
- **覆盖面广** — 11 大业务域、200+ 精选命令、 19 个 AI Agent [Skills](./skills/)
|
||||
- **覆盖面广** — 12 大业务域、200+ 精选命令、 20 个 AI Agent [Skills](./skills/)
|
||||
- **AI 友好调优** — 每条命令经过 Agent 实测验证,提供更友好的参数、智能默认值和结构化输出,大幅提升 Agent 调用成功率
|
||||
- **开源零门槛** — MIT 协议,开箱即用,`npm install` 即可使用
|
||||
- **三分钟上手** — 一键创建应用、交互式登录授权,从安装到第一次 API 调用只需三步
|
||||
@@ -22,19 +22,20 @@
|
||||
|
||||
## 功能
|
||||
|
||||
| 类别 | 能力 |
|
||||
| ------------- | --------------------------------------------------------------------------- |
|
||||
| 📅 日历 | 查看日程、创建日程、邀请参会人、查询忙闲状态、时间建议 |
|
||||
| 💬 即时通讯 | 发送/回复消息、创建和管理群聊、查看聊天记录与话题、搜索消息、下载媒体文件 |
|
||||
| 📄 云文档 | 创建、读取、更新文档、搜索文档、读写素材与画板 |
|
||||
| 📁 云空间 | 上传和下载文件、搜索文档与知识库、管理评论 |
|
||||
| 类别 | 能力 |
|
||||
| ------------- |--------------------------------------------|
|
||||
| 📅 日历 | 查看日程、创建日程、邀请参会人、查询忙闲状态、时间建议 |
|
||||
| 💬 即时通讯 | 发送/回复消息、创建和管理群聊、查看聊天记录与话题、搜索消息、下载媒体文件 |
|
||||
| 📄 云文档 | 创建、读取、更新文档、搜索文档、读写素材与画板 |
|
||||
| 📁 云空间 | 上传和下载文件、搜索文档与知识库、管理评论 |
|
||||
| 📊 多维表格 | 创建和管理数据表、字段、记录、视图、仪表盘、自动化流程、表单、角色权限,数据聚合分析 |
|
||||
| 📈 电子表格 | 创建、读取、写入、追加、查找和导出表格数据 |
|
||||
| ✅ 任务 | 创建、查询、更新和完成任务;管理任务清单、子任务、评论与提醒 |
|
||||
| 📚 知识库 | 创建和管理知识空间、节点和文档 |
|
||||
| 👤 通讯录 | 按姓名/邮箱/手机号搜索用户、获取用户信息 |
|
||||
| 📧 邮箱 | 浏览、搜索、阅读邮件,发送、回复、转发邮件,管理草稿,监听新邮件 |
|
||||
| 🎥 视频会议 | 搜索会议记录、查询会议纪要与录制 |
|
||||
| 📈 电子表格 | 创建、读取、写入、追加、查找和导出表格数据 |
|
||||
| ✅ 任务 | 创建、查询、更新和完成任务;管理任务清单、子任务、评论与提醒 |
|
||||
| 📚 知识库 | 创建和管理知识空间、节点和文档 |
|
||||
| 👤 通讯录 | 按姓名/邮箱/手机号搜索用户、获取用户信息 |
|
||||
| 📧 邮箱 | 浏览、搜索、阅读邮件,发送、回复、转发邮件,管理草稿,监听新邮件 |
|
||||
| 🎥 视频会议 | 搜索会议记录、查询会议纪要与录制 |
|
||||
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
|
||||
|
||||
## 安装与快速开始
|
||||
|
||||
@@ -128,27 +129,28 @@ lark-cli auth status
|
||||
|
||||
## Agent Skills
|
||||
|
||||
| Skill | 说明 |
|
||||
| --------------------------------- | ----------------------------------------------------------------------------- |
|
||||
| Skill | 说明 |
|
||||
| --------------------------------- |-------------------------------------------|
|
||||
| `lark-shared` | 应用配置、认证登录、身份切换、权限管理、安全规则(所有其他 skill 自动加载) |
|
||||
| `lark-calendar` | 日历日程、议程查看、忙闲查询、时间建议 |
|
||||
| `lark-im` | 发送/回复消息、群聊管理、消息搜索、上传下载图片与文件、表情回复 |
|
||||
| `lark-doc` | 创建、读取、更新、搜索文档(基于 Markdown) |
|
||||
| `lark-drive` | 上传、下载文件,管理权限与评论 |
|
||||
| `lark-sheets` | 创建、读取、写入、追加、查找、导出电子表格 |
|
||||
| `lark-base` | 多维表格、字段、记录、视图、仪表盘、数据聚合分析 |
|
||||
| `lark-task` | 任务、任务清单、子任务、提醒、成员分配 |
|
||||
| `lark-mail` | 浏览、搜索、阅读邮件,发送、回复、转发,草稿管理,监听新邮件 |
|
||||
| `lark-contact` | 按姓名/邮箱/手机号搜索用户,获取用户信息 |
|
||||
| `lark-wiki` | 知识空间、节点、文档 |
|
||||
| `lark-event` | 实时事件订阅(WebSocket),支持正则路由与 Agent 友好格式 |
|
||||
| `lark-vc` | 搜索会议记录、查询会议纪要产物(总结、待办、逐字稿) |
|
||||
| `lark-whiteboard` | 画板/图表 DSL 渲染 |
|
||||
| `lark-minutes` | 妙记元数据与 AI 产物(总结、待办、章节) |
|
||||
| `lark-openapi-explorer` | 从官方文档探索底层 API |
|
||||
| `lark-skill-maker` | 自定义 skill 创建框架 |
|
||||
| `lark-workflow-meeting-summary` | 工作流:会议纪要汇总与结构化报告 |
|
||||
| `lark-workflow-standup-report` | 工作流:日程待办摘要 |
|
||||
| `lark-calendar` | 日历日程、议程查看、忙闲查询、时间建议 |
|
||||
| `lark-im` | 发送/回复消息、群聊管理、消息搜索、上传下载图片与文件、表情回复 |
|
||||
| `lark-doc` | 创建、读取、更新、搜索文档(基于 Markdown) |
|
||||
| `lark-drive` | 上传、下载文件,管理权限与评论 |
|
||||
| `lark-sheets` | 创建、读取、写入、追加、查找、导出电子表格 |
|
||||
| `lark-base` | 多维表格、字段、记录、视图、仪表盘、数据聚合分析 |
|
||||
| `lark-task` | 任务、任务清单、子任务、提醒、成员分配 |
|
||||
| `lark-mail` | 浏览、搜索、阅读邮件,发送、回复、转发,草稿管理,监听新邮件 |
|
||||
| `lark-contact` | 按姓名/邮箱/手机号搜索用户,获取用户信息 |
|
||||
| `lark-wiki` | 知识空间、节点、文档 |
|
||||
| `lark-event` | 实时事件订阅(WebSocket),支持正则路由与 Agent 友好格式 |
|
||||
| `lark-vc` | 搜索会议记录、查询会议纪要产物(总结、待办、逐字稿) |
|
||||
| `lark-whiteboard` | 画板/图表 DSL 渲染 |
|
||||
| `lark-minutes` | 妙记元数据与 AI 产物(总结、待办、章节) |
|
||||
| `lark-openapi-explorer` | 从官方文档探索底层 API |
|
||||
| `lark-skill-maker` | 自定义 skill 创建框架 |
|
||||
| `lark-approval` | 审批任务查询、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
|
||||
| `lark-workflow-meeting-summary` | 工作流:会议纪要汇总与结构化报告 |
|
||||
| `lark-workflow-standup-report` | 工作流:日程待办摘要 |
|
||||
|
||||
## 认证
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ type APIOptions struct {
|
||||
PageLimit int
|
||||
PageDelay int
|
||||
Format string
|
||||
JqExpr string
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
@@ -96,6 +97,7 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command
|
||||
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
|
||||
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
|
||||
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
|
||||
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
|
||||
|
||||
cmd.ValidArgsFunction = func(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
@@ -155,6 +157,9 @@ func apiRun(opts *APIOptions) error {
|
||||
if opts.PageAll && opts.Output != "" {
|
||||
return output.ErrValidation("--output and --page-all are mutually exclusive")
|
||||
}
|
||||
if err := output.ValidateJqFlags(opts.JqExpr, opts.Output, opts.Format); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
request, err := buildAPIRequest(opts)
|
||||
if err != nil {
|
||||
@@ -184,7 +189,7 @@ func apiRun(opts *APIOptions) error {
|
||||
}
|
||||
|
||||
if opts.PageAll {
|
||||
return apiPaginate(opts.Ctx, ac, request, format, out, f.IOStreams.ErrOut,
|
||||
return apiPaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut,
|
||||
client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay})
|
||||
}
|
||||
|
||||
@@ -195,6 +200,7 @@ func apiRun(opts *APIOptions) error {
|
||||
err = client.HandleResponse(resp, client.ResponseOptions{
|
||||
OutputPath: opts.Output,
|
||||
Format: format,
|
||||
JqExpr: opts.JqExpr,
|
||||
Out: out,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
})
|
||||
@@ -210,7 +216,15 @@ func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.Cl
|
||||
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
|
||||
}
|
||||
|
||||
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, out, errOut io.Writer, pagOpts client.PaginationOptions) error {
|
||||
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions) error {
|
||||
// When jq is set, always aggregate all pages then filter.
|
||||
if jqExpr != "" {
|
||||
if err := client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, client.CheckLarkResponse); err != nil {
|
||||
return output.MarkRaw(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
switch format {
|
||||
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
|
||||
pf := output.NewPaginatedFormatter(out, format)
|
||||
|
||||
@@ -536,6 +536,179 @@ func TestApiCmd_PageAll_APIError_IsRaw(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_JqFlag_Parsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--jq", ".data"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts.JqExpr != ".data" {
|
||||
t.Errorf("expected JqExpr=.data, got %s", gotOpts.JqExpr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_JqFlag_ShortForm(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "-q", ".data"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts.JqExpr != ".data" {
|
||||
t.Errorf("expected JqExpr=.data, got %s", gotOpts.JqExpr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_JqAndOutputConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--output", "file.bin"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --jq + --output conflict")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Errorf("expected 'mutually exclusive' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_JqFilter_AppliesExpression(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-jq", AppSecret: "test-secret-jq", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/auth/v3/tenant_access_token/internal",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-test-token-jq", "expire": 7200,
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/jq",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"name": "Alice"},
|
||||
map[string]interface{}{"name": "Bob"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test/jq", "--as", "bot", "--jq", ".data.items[].name"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "Alice") || !strings.Contains(out, "Bob") {
|
||||
t.Errorf("expected jq-filtered names, got: %s", out)
|
||||
}
|
||||
// Should NOT contain the full envelope structure
|
||||
if strings.Contains(out, `"code"`) {
|
||||
t.Errorf("expected jq to filter out envelope, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_JqAndFormatConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--format", "ndjson"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --jq + --format ndjson conflict")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Errorf("expected 'mutually exclusive' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_JqInvalidExpression(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", "invalid["})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid jq expression")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid jq expression") {
|
||||
t.Errorf("expected 'invalid jq expression' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_PageAll_WithJq(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-pjq", AppSecret: "test-secret-pjq", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/auth/v3/tenant_access_token/internal",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-test-token-pjq", "expire": 7200,
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/contact/v3/users",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "u1"}, map[string]interface{}{"id": "u2"}},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--jq", ".data.items[].id"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "u1") || !strings.Contains(out, "u2") {
|
||||
t.Errorf("expected jq-filtered ids, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, `"code"`) {
|
||||
t.Errorf("expected jq to filter out envelope, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_MethodUppercase(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
|
||||
@@ -61,6 +61,8 @@ FLAGS:
|
||||
--page-limit <N> max pages to fetch with --page-all (default: 10, 0 for unlimited)
|
||||
--page-delay <MS> delay in ms between pages (default: 200, only with --page-all)
|
||||
-o, --output <path> output file path for binary responses
|
||||
--jq <expr> jq expression to filter JSON output
|
||||
-q <expr> shorthand for --jq
|
||||
--dry-run print request without executing
|
||||
|
||||
AI AGENT SKILLS:
|
||||
|
||||
@@ -109,6 +109,7 @@ type ServiceMethodOptions struct {
|
||||
PageLimit int
|
||||
PageDelay int
|
||||
Format string
|
||||
JqExpr string
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
@@ -157,6 +158,7 @@ func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{}
|
||||
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
|
||||
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
|
||||
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
|
||||
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
|
||||
|
||||
_ = cmd.RegisterFlagCompletionFunc("as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
@@ -185,6 +187,9 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
if opts.PageAll && opts.Output != "" {
|
||||
return output.ErrValidation("--output and --page-all are mutually exclusive")
|
||||
}
|
||||
if err := output.ValidateJqFlags(opts.JqExpr, opts.Output, opts.Format); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
config, err := f.ResolveConfig(opts.As)
|
||||
if err != nil {
|
||||
@@ -223,7 +228,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
checkErr := scopeAwareChecker(scopes, opts.As.IsBot())
|
||||
|
||||
if opts.PageAll {
|
||||
return servicePaginate(opts.Ctx, ac, request, format, out, f.IOStreams.ErrOut,
|
||||
return servicePaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut,
|
||||
client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay}, checkErr)
|
||||
}
|
||||
|
||||
@@ -234,6 +239,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
return client.HandleResponse(resp, client.ResponseOptions{
|
||||
OutputPath: opts.Output,
|
||||
Format: format,
|
||||
JqExpr: opts.JqExpr,
|
||||
Out: out,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
CheckError: checkErr,
|
||||
@@ -400,7 +406,12 @@ func scopeAwareChecker(scopes []interface{}, isBotMode bool) func(interface{}) e
|
||||
}
|
||||
}
|
||||
|
||||
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, out, errOut io.Writer, pagOpts client.PaginationOptions, checkErr func(interface{}) error) error {
|
||||
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions, checkErr func(interface{}) error) error {
|
||||
// When jq is set, always aggregate all pages then filter.
|
||||
if jqExpr != "" {
|
||||
return client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, checkErr)
|
||||
}
|
||||
|
||||
switch format {
|
||||
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
|
||||
pf := output.NewPaginatedFormatter(out, format)
|
||||
|
||||
@@ -474,6 +474,173 @@ func TestServiceMethod_UnknownFormat_Warning(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── jq flag ──
|
||||
|
||||
func TestNewCmdServiceMethod_JqFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
|
||||
var captured *ServiceMethodOptions
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(),
|
||||
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files",
|
||||
func(opts *ServiceMethodOptions) error {
|
||||
captured = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--jq", ".data"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if captured == nil {
|
||||
t.Fatal("runF was not called")
|
||||
}
|
||||
if captured.JqExpr != ".data" {
|
||||
t.Errorf("expected JqExpr=.data, got %s", captured.JqExpr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCmdServiceMethod_JqShortForm(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
|
||||
var captured *ServiceMethodOptions
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(),
|
||||
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files",
|
||||
func(opts *ServiceMethodOptions) error {
|
||||
captured = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"-q", ".data"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if captured.JqExpr != ".data" {
|
||||
t.Errorf("expected JqExpr=.data, got %s", captured.JqExpr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_JqAndOutputConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
spec := map[string]interface{}{
|
||||
"name": "svc", "servicePath": "/open-apis/svc/v1",
|
||||
}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--jq", ".data", "--output", "file.bin", "--as", "bot"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --jq + --output conflict")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Errorf("expected 'mutually exclusive' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_JqFilter_AppliesExpression(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-jq", AppSecret: "test-secret-jq", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(tokenStub())
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/svc/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"name": "Alice"},
|
||||
map[string]interface{}{"name": "Bob"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--as", "bot", "--jq", ".data.items[].name"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "Alice") || !strings.Contains(out, "Bob") {
|
||||
t.Errorf("expected jq-filtered names, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, `"code"`) {
|
||||
t.Errorf("expected jq to filter out envelope, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_JqAndFormatConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
spec := map[string]interface{}{
|
||||
"name": "svc", "servicePath": "/open-apis/svc/v1",
|
||||
}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--jq", ".data", "--format", "ndjson", "--as", "bot"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --jq + --format ndjson conflict")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Errorf("expected 'mutually exclusive' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_JqInvalidExpression(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
spec := map[string]interface{}{
|
||||
"name": "svc", "servicePath": "/open-apis/svc/v1",
|
||||
}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--jq", "invalid[", "--as", "bot"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid jq expression")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid jq expression") {
|
||||
t.Errorf("expected 'invalid jq expression' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_PageAll_WithJq(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-spjq", AppSecret: "test-secret-spjq", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(tokenStub())
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/svc/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "s1"}, map[string]interface{}{"id": "s2"}},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--as", "bot", "--page-all", "--jq", ".data.items[].id"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "s1") || !strings.Contains(out, "s2") {
|
||||
t.Errorf("expected jq-filtered ids, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, `"code"`) {
|
||||
t.Errorf("expected jq to filter out envelope, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// ── scopeAwareChecker ──
|
||||
|
||||
func TestScopeAwareChecker_Success(t *testing.T) {
|
||||
|
||||
2
go.mod
2
go.mod
@@ -7,6 +7,7 @@ require (
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/gofrs/flock v0.8.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/itchyny/gojq v0.12.17
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
github.com/smartystreets/goconvey v1.8.1
|
||||
@@ -37,6 +38,7 @@ require (
|
||||
github.com/gopherjs/gopherjs v1.17.2 // indirect
|
||||
github.com/gorilla/websocket v1.5.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/itchyny/timefmt-go v0.1.6 // indirect
|
||||
github.com/jtolds/gls v4.20.0+incompatible // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
|
||||
4
go.sum
4
go.sum
@@ -61,6 +61,10 @@ github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWm
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/itchyny/gojq v0.12.17 h1:8av8eGduDb5+rvEdaOO+zQUjA04MS0m3Ps8HiD+fceg=
|
||||
github.com/itchyny/gojq v0.12.17/go.mod h1:WBrEMkgAfAGO1LUcGOckBl5O726KPp+OlkKug0I/FEY=
|
||||
github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/myJ5Q=
|
||||
github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg=
|
||||
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
@@ -16,6 +17,22 @@ type PaginationOptions struct {
|
||||
PageDelay int // ms, default 200
|
||||
}
|
||||
|
||||
// PaginateWithJq aggregates all pages, checks for API errors, then applies a jq filter.
|
||||
// If checkErr detects an error, the raw result is printed as JSON before returning the error.
|
||||
func PaginateWithJq(ctx context.Context, ac *APIClient, request RawApiRequest,
|
||||
jqExpr string, out io.Writer, pagOpts PaginationOptions,
|
||||
checkErr func(interface{}) error) error {
|
||||
result, err := ac.PaginateAll(ctx, request, pagOpts)
|
||||
if err != nil {
|
||||
return output.ErrNetwork("API call failed: %v", err)
|
||||
}
|
||||
if apiErr := checkErr(result); apiErr != nil {
|
||||
output.FormatValue(out, result, output.FormatJSON)
|
||||
return apiErr
|
||||
}
|
||||
return output.JqFilter(out, result, jqExpr)
|
||||
}
|
||||
|
||||
func mergePagedResults(w io.Writer, results []interface{}) interface{} {
|
||||
if len(results) == 0 {
|
||||
return map[string]interface{}{}
|
||||
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
type ResponseOptions struct {
|
||||
OutputPath string // --output flag; "" = auto-detect
|
||||
Format output.Format // output format for JSON responses
|
||||
JqExpr string // if set, apply jq filter instead of Format
|
||||
Out io.Writer // stdout
|
||||
ErrOut io.Writer // stderr
|
||||
// CheckError is called on parsed JSON results. Nil defaults to CheckLarkResponse.
|
||||
@@ -62,11 +63,17 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
|
||||
if opts.OutputPath != "" {
|
||||
return saveAndPrint(resp, opts.OutputPath, opts.Out)
|
||||
}
|
||||
if opts.JqExpr != "" {
|
||||
return output.JqFilter(opts.Out, result, opts.JqExpr)
|
||||
}
|
||||
output.FormatValue(opts.Out, result, opts.Format)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Non-JSON (binary) responses.
|
||||
if opts.JqExpr != "" {
|
||||
return output.ErrValidation("--jq requires a JSON response (got Content-Type: %s)", ct)
|
||||
}
|
||||
if opts.OutputPath != "" {
|
||||
return saveAndPrint(resp, opts.OutputPath, opts.Out)
|
||||
}
|
||||
|
||||
@@ -319,6 +319,23 @@ func TestHandleResponse_200TextPlain_SavesFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResponse_BinaryWithJq_RejectsNonJSON(t *testing.T) {
|
||||
resp := newApiResp([]byte("PNG DATA"), map[string]string{"Content-Type": "image/png"})
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
err := HandleResponse(resp, ResponseOptions{
|
||||
JqExpr: ".data",
|
||||
Out: &out,
|
||||
ErrOut: &errOut,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when --jq is used with non-JSON response")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--jq requires a JSON response") {
|
||||
t.Errorf("expected '--jq requires a JSON response' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResponse_403JSON_CheckLarkResponse(t *testing.T) {
|
||||
body := []byte(`{"code":99991400,"msg":"invalid token"}`)
|
||||
resp := newApiRespWithStatus(403, body, map[string]string{"Content-Type": "application/json"})
|
||||
|
||||
132
internal/output/jq.go
Normal file
132
internal/output/jq.go
Normal file
@@ -0,0 +1,132 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
|
||||
"github.com/itchyny/gojq"
|
||||
)
|
||||
|
||||
// JqFilter applies a jq expression to data and writes the results to w.
|
||||
// Scalar values are printed raw (no quotes for strings), matching jq -r behavior.
|
||||
// Complex values (maps, arrays) are printed as indented JSON.
|
||||
func JqFilter(w io.Writer, data interface{}, expr string) error {
|
||||
query, err := gojq.Parse(expr)
|
||||
if err != nil {
|
||||
return ErrValidation("invalid jq expression: %s", err)
|
||||
}
|
||||
code, err := gojq.Compile(query)
|
||||
if err != nil {
|
||||
return ErrValidation("invalid jq expression: %s", err)
|
||||
}
|
||||
|
||||
// Normalize data through toGeneric so typed structs become map[string]any.
|
||||
normalized := toGeneric(data)
|
||||
// Convert json.Number values to gojq-compatible types.
|
||||
normalized = convertNumbers(normalized)
|
||||
|
||||
iter := code.Run(normalized)
|
||||
for {
|
||||
v, ok := iter.Next()
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if err, isErr := v.(error); isErr {
|
||||
return Errorf(ExitAPI, "jq_error", "jq error: %s", err)
|
||||
}
|
||||
if err := writeJqValue(w, v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateJqFlags checks --jq flag compatibility with --output and --format flags,
|
||||
// and validates the jq expression syntax. Returns nil if jqExpr is empty.
|
||||
func ValidateJqFlags(jqExpr, outputFlag, format string) error {
|
||||
if jqExpr == "" {
|
||||
return nil
|
||||
}
|
||||
if outputFlag != "" {
|
||||
return ErrValidation("--jq and --output are mutually exclusive")
|
||||
}
|
||||
if format != "" && format != "json" {
|
||||
return ErrValidation("--jq and --format %s are mutually exclusive", format)
|
||||
}
|
||||
return ValidateJqExpression(jqExpr)
|
||||
}
|
||||
|
||||
// ValidateJqExpression checks whether a jq expression is syntactically valid.
|
||||
func ValidateJqExpression(expr string) error {
|
||||
query, err := gojq.Parse(expr)
|
||||
if err != nil {
|
||||
return ErrValidation("invalid jq expression: %s", err)
|
||||
}
|
||||
_, err = gojq.Compile(query)
|
||||
if err != nil {
|
||||
return ErrValidation("invalid jq expression: %s", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeJqValue writes a single jq result value to w.
|
||||
// Scalars are printed raw; complex values as indented JSON.
|
||||
func writeJqValue(w io.Writer, v interface{}) error {
|
||||
switch val := v.(type) {
|
||||
case nil:
|
||||
fmt.Fprintln(w, "null")
|
||||
case bool:
|
||||
fmt.Fprintln(w, val)
|
||||
case int:
|
||||
fmt.Fprintln(w, val)
|
||||
case float64:
|
||||
// Use %g to avoid trailing zeros, matching jq behavior.
|
||||
fmt.Fprintf(w, "%g\n", val)
|
||||
case *big.Int:
|
||||
fmt.Fprintln(w, val.String())
|
||||
case string:
|
||||
// Raw output for strings (no quotes), matching jq -r.
|
||||
fmt.Fprintln(w, val)
|
||||
default:
|
||||
// Complex value (map, array): indented JSON.
|
||||
b, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return Errorf(ExitInternal, "jq_error", "failed to marshal jq result: %s", err)
|
||||
}
|
||||
fmt.Fprintln(w, string(b))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// convertNumbers recursively converts json.Number values to int or float64
|
||||
// so that gojq can process them correctly.
|
||||
func convertNumbers(v interface{}) interface{} {
|
||||
switch val := v.(type) {
|
||||
case json.Number:
|
||||
if i, err := val.Int64(); err == nil {
|
||||
return int(i)
|
||||
}
|
||||
if f, err := val.Float64(); err == nil {
|
||||
return f
|
||||
}
|
||||
// Fallback: return as string (shouldn't happen for valid JSON numbers).
|
||||
return val.String()
|
||||
case map[string]interface{}:
|
||||
for k, elem := range val {
|
||||
val[k] = convertNumbers(elem)
|
||||
}
|
||||
return val
|
||||
case []interface{}:
|
||||
for i, elem := range val {
|
||||
val[i] = convertNumbers(elem)
|
||||
}
|
||||
return val
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
215
internal/output/jq_test.go
Normal file
215
internal/output/jq_test.go
Normal file
@@ -0,0 +1,215 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestJqFilter(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"ok": true,
|
||||
"identity": "user",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"name": "Alice", "age": 30},
|
||||
map[string]interface{}{"name": "Bob", "age": 25},
|
||||
map[string]interface{}{"name": "Charlie", "age": 35},
|
||||
},
|
||||
"total": 3,
|
||||
},
|
||||
"meta": map[string]interface{}{
|
||||
"count": 3,
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "identity expression",
|
||||
expr: ".",
|
||||
want: `"ok"`,
|
||||
},
|
||||
{
|
||||
name: "field access .ok",
|
||||
expr: ".ok",
|
||||
want: "true\n",
|
||||
},
|
||||
{
|
||||
name: "string field raw output",
|
||||
expr: ".identity",
|
||||
want: "user\n",
|
||||
},
|
||||
{
|
||||
name: "nested field access",
|
||||
expr: ".data.total",
|
||||
want: "3\n",
|
||||
},
|
||||
{
|
||||
name: "meta count",
|
||||
expr: ".meta.count",
|
||||
want: "3\n",
|
||||
},
|
||||
{
|
||||
name: "array iteration",
|
||||
expr: ".data.items[].name",
|
||||
want: "Alice\nBob\nCharlie\n",
|
||||
},
|
||||
{
|
||||
name: "pipe and select",
|
||||
expr: `.data.items[] | select(.age > 28) | .name`,
|
||||
want: "Alice\nCharlie\n",
|
||||
},
|
||||
{
|
||||
name: "length builtin",
|
||||
expr: ".data.items | length",
|
||||
want: "3\n",
|
||||
},
|
||||
{
|
||||
name: "keys builtin",
|
||||
expr: ".data | keys",
|
||||
want: "[\n \"items\",\n \"total\"\n]\n",
|
||||
},
|
||||
{
|
||||
name: "null for missing field",
|
||||
expr: ".nonexistent",
|
||||
want: "null\n",
|
||||
},
|
||||
{
|
||||
name: "complex value output",
|
||||
expr: ".data.items[0]",
|
||||
want: "{\n \"age\": 30,\n \"name\": \"Alice\"\n}\n",
|
||||
},
|
||||
{
|
||||
name: "invalid expression",
|
||||
expr: "invalid[",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "multiple outputs",
|
||||
expr: ".ok, .identity",
|
||||
want: "true\nuser\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
err := JqFilter(&buf, data, tt.expr)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if tt.name == "identity expression" {
|
||||
// For identity, just verify it contains the key fields
|
||||
if !strings.Contains(buf.String(), `"ok"`) {
|
||||
t.Errorf("identity output missing 'ok' key")
|
||||
}
|
||||
return
|
||||
}
|
||||
if buf.String() != tt.want {
|
||||
t.Errorf("got %q, want %q", buf.String(), tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJqFilter_WithStruct(t *testing.T) {
|
||||
// Test that toGeneric normalizes structs properly
|
||||
type inner struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
data := struct {
|
||||
OK bool `json:"ok"`
|
||||
Item *inner `json:"item"`
|
||||
}{
|
||||
OK: true,
|
||||
Item: &inner{Name: "test"},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := JqFilter(&buf, data, ".item.name")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(buf.String()); got != "test" {
|
||||
t.Errorf("got %q, want %q", got, "test")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateJqFlags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
jqExpr string
|
||||
outputFlag string
|
||||
format string
|
||||
wantErr string
|
||||
}{
|
||||
{name: "empty jq is noop", jqExpr: "", outputFlag: "file.json", format: "csv", wantErr: ""},
|
||||
{name: "jq only", jqExpr: ".data", outputFlag: "", format: "", wantErr: ""},
|
||||
{name: "jq with json format", jqExpr: ".data", outputFlag: "", format: "json", wantErr: ""},
|
||||
{name: "jq and output conflict", jqExpr: ".data", outputFlag: "out.json", format: "", wantErr: "--jq and --output are mutually exclusive"},
|
||||
{name: "jq and csv conflict", jqExpr: ".data", outputFlag: "", format: "csv", wantErr: "--jq and --format csv are mutually exclusive"},
|
||||
{name: "jq and ndjson conflict", jqExpr: ".data", outputFlag: "", format: "ndjson", wantErr: "--jq and --format ndjson are mutually exclusive"},
|
||||
{name: "invalid expression", jqExpr: "invalid[", outputFlag: "", format: "", wantErr: "invalid jq expression"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateJqFlags(tt.jqExpr, tt.outputFlag, tt.format)
|
||||
if tt.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
t.Errorf("expected error containing %q, got nil", tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Errorf("error %q does not contain %q", err.Error(), tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateJqExpression(t *testing.T) {
|
||||
tests := []struct {
|
||||
expr string
|
||||
wantErr bool
|
||||
}{
|
||||
{".", false},
|
||||
{".data", false},
|
||||
{".data.items[].name", false},
|
||||
{`.data.items[] | select(.name == "Alice")`, false},
|
||||
{"length", false},
|
||||
{"keys", false},
|
||||
{"invalid[", true},
|
||||
{".foo | invalid_func", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.expr, func(t *testing.T) {
|
||||
err := ValidateJqExpression(tt.expr)
|
||||
if tt.wantErr && err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
}
|
||||
if !tt.wantErr && err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"approval": {
|
||||
"en": { "title": "Approval", "description": "Approval instance, and task management" },
|
||||
"zh": { "title": "审批", "description": "审批实例、审批任务管理" }
|
||||
},
|
||||
"base": {
|
||||
"en": { "title": "Base", "description": "Table, field, record, view, dashboard, workflow, form, role & permission management" },
|
||||
"zh": { "title": "多维表格", "description": "数据表、字段、记录、视图、仪表盘、自动化流程、表单、角色权限管理" }
|
||||
|
||||
@@ -181,6 +181,25 @@ func cloneDownloadTransport(base http.RoundTripper) *http.Transport {
|
||||
return cloned
|
||||
}
|
||||
|
||||
// DialContextFunc is the signature for DialContext / DialTLSContext.
|
||||
type DialContextFunc func(ctx context.Context, network, addr string) (net.Conn, error)
|
||||
|
||||
// WrapDialContextWithIPCheck wraps a DialContext function to validate the
|
||||
// remote IP after connection, rejecting local/internal addresses (SSRF protection).
|
||||
func WrapDialContextWithIPCheck(origDial DialContextFunc) DialContextFunc {
|
||||
return func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
conn, err := dialConn(ctx, origDial, network, addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateConnRemoteIP(conn); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
}
|
||||
|
||||
func dialConn(ctx context.Context, dialFn func(context.Context, string, string) (net.Conn, error), network, addr string) (net.Conn, error) {
|
||||
if dialFn != nil {
|
||||
return dialFn(ctx, network, addr)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const https = require("https");
|
||||
const { execSync } = require("child_process");
|
||||
const os = require("os");
|
||||
|
||||
@@ -32,45 +31,34 @@ if (!platform || !arch) {
|
||||
const isWindows = process.platform === "win32";
|
||||
const ext = isWindows ? ".zip" : ".tar.gz";
|
||||
const archiveName = `${NAME}-${VERSION}-${platform}-${arch}${ext}`;
|
||||
const url = `https://github.com/${REPO}/releases/download/v${VERSION}/${archiveName}`;
|
||||
const GITHUB_URL = `https://github.com/${REPO}/releases/download/v${VERSION}/${archiveName}`;
|
||||
const MIRROR_URL = `https://registry.npmmirror.com/-/binary/lark-cli/v${VERSION}/${archiveName}`;
|
||||
|
||||
const binDir = path.join(__dirname, "..", "bin");
|
||||
const dest = path.join(binDir, NAME + (isWindows ? ".exe" : ""));
|
||||
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
|
||||
function download(url, destPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = url.startsWith("https") ? https : require("http");
|
||||
client
|
||||
.get(url, (res) => {
|
||||
if (res.statusCode === 302 || res.statusCode === 301) {
|
||||
return download(res.headers.location, destPath).then(
|
||||
resolve,
|
||||
reject
|
||||
);
|
||||
}
|
||||
if (res.statusCode !== 200) {
|
||||
return reject(
|
||||
new Error(`Download failed with status ${res.statusCode}: ${url}`)
|
||||
);
|
||||
}
|
||||
const file = fs.createWriteStream(destPath);
|
||||
res.pipe(file);
|
||||
file.on("finish", () => {
|
||||
file.close();
|
||||
resolve();
|
||||
});
|
||||
})
|
||||
.on("error", reject);
|
||||
});
|
||||
// --ssl-revoke-best-effort: on Windows (Schannel), avoid CRYPT_E_REVOCATION_OFFLINE
|
||||
// errors when the certificate revocation list server is unreachable
|
||||
const sslFlag = isWindows ? "--ssl-revoke-best-effort " : "";
|
||||
execSync(
|
||||
`curl ${sslFlag}--fail --location --silent --show-error --connect-timeout 10 --max-time 120 --output "${destPath}" "${url}"`,
|
||||
{ stdio: ["ignore", "ignore", "pipe"] }
|
||||
);
|
||||
}
|
||||
|
||||
async function install() {
|
||||
function install() {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "lark-cli-"));
|
||||
const archivePath = path.join(tmpDir, archiveName);
|
||||
|
||||
try {
|
||||
await download(url, archivePath);
|
||||
try {
|
||||
download(GITHUB_URL, archivePath);
|
||||
} catch (err) {
|
||||
download(MIRROR_URL, archivePath);
|
||||
}
|
||||
|
||||
if (isWindows) {
|
||||
execSync(
|
||||
@@ -94,7 +82,14 @@ async function install() {
|
||||
}
|
||||
}
|
||||
|
||||
install().catch((err) => {
|
||||
try {
|
||||
install();
|
||||
} catch (err) {
|
||||
console.error(`Failed to install ${NAME}:`, err.message);
|
||||
console.error(
|
||||
`\nIf you are behind a firewall or in a restricted network, try setting a proxy:\n` +
|
||||
` export https_proxy=http://your-proxy:port\n` +
|
||||
` npm install -g @larksuite/cli`
|
||||
);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@ type RuntimeContext struct {
|
||||
Config *core.CliConfig
|
||||
Cmd *cobra.Command
|
||||
Format string
|
||||
JqExpr string // --jq expression; empty = no filter
|
||||
outputErr error // deferred error from Out()/OutFormat() jq filtering
|
||||
botOnly bool // set by framework for bot-only shortcuts
|
||||
resolvedAs core.Identity // effective identity resolved by framework
|
||||
Factory *cmdutil.Factory // injected by framework
|
||||
@@ -225,6 +227,20 @@ func (ctx *RuntimeContext) DoAPI(req *larkcore.ApiReq, opts ...larkcore.RequestO
|
||||
return ac.DoSDKRequest(ctx.ctx, req, ctx.As(), opts...)
|
||||
}
|
||||
|
||||
// DoAPIAsBot executes a raw Lark SDK request using bot identity (tenant access token),
|
||||
// regardless of the current --as flag. Use this for bot-only APIs (e.g. image/file upload)
|
||||
// that must be called with TAT even when the surrounding shortcut runs as user.
|
||||
func (ctx *RuntimeContext) DoAPIAsBot(req *larkcore.ApiReq, opts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) {
|
||||
ac, err := ctx.getAPIClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if optFn := cmdutil.ShortcutHeaderOpts(ctx.ctx); optFn != nil {
|
||||
opts = append(opts, optFn)
|
||||
}
|
||||
return ac.DoSDKRequest(ctx.ctx, req, core.AsBot, opts...)
|
||||
}
|
||||
|
||||
type cancelOnCloseReadCloser struct {
|
||||
io.ReadCloser
|
||||
cancel context.CancelFunc
|
||||
@@ -419,13 +435,27 @@ func (ctx *RuntimeContext) IO() *cmdutil.IOStreams {
|
||||
// Out prints a success JSON envelope to stdout.
|
||||
func (ctx *RuntimeContext) Out(data interface{}, meta *output.Meta) {
|
||||
env := output.Envelope{OK: true, Identity: string(ctx.As()), Data: data, Meta: meta, Notice: output.GetNotice()}
|
||||
if ctx.JqExpr != "" {
|
||||
if err := output.JqFilter(ctx.IO().Out, env, ctx.JqExpr); err != nil {
|
||||
fmt.Fprintf(ctx.IO().ErrOut, "error: %v\n", err)
|
||||
if ctx.outputErr == nil {
|
||||
ctx.outputErr = err
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
b, _ := json.MarshalIndent(env, "", " ")
|
||||
fmt.Fprintln(ctx.IO().Out, string(b))
|
||||
}
|
||||
|
||||
// OutFormat prints output based on --format flag.
|
||||
// "json" (default) outputs JSON envelope; "pretty" calls prettyFn; others delegate to FormatValue.
|
||||
// When JqExpr is set, routes through Out() regardless of format.
|
||||
func (ctx *RuntimeContext) OutFormat(data interface{}, meta *output.Meta, prettyFn func(w io.Writer)) {
|
||||
if ctx.JqExpr != "" {
|
||||
ctx.Out(data, meta)
|
||||
return
|
||||
}
|
||||
switch ctx.Format {
|
||||
case "pretty":
|
||||
if prettyFn != nil {
|
||||
@@ -546,6 +576,9 @@ func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bo
|
||||
if err := validateEnumFlags(rctx, s.Flags); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := output.ValidateJqFlags(rctx.JqExpr, "", rctx.Format); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.Validate != nil {
|
||||
if err := s.Validate(rctx.ctx, rctx); err != nil {
|
||||
return err
|
||||
@@ -562,7 +595,10 @@ func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bo
|
||||
}
|
||||
}
|
||||
|
||||
return s.Execute(rctx.ctx, rctx)
|
||||
if err := s.Execute(rctx.ctx, rctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return rctx.outputErr
|
||||
}
|
||||
|
||||
func resolveShortcutIdentity(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut) (core.Identity, error) {
|
||||
@@ -604,6 +640,7 @@ func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, conf
|
||||
if s.HasFormat {
|
||||
rctx.Format = rctx.Str("format")
|
||||
}
|
||||
rctx.JqExpr, _ = cmd.Flags().GetString("jq")
|
||||
return rctx, nil
|
||||
}
|
||||
|
||||
@@ -684,6 +721,7 @@ func registerShortcutFlags(cmd *cobra.Command, s *Shortcut) {
|
||||
if s.Risk == "high-risk-write" {
|
||||
cmd.Flags().Bool("yes", false, "confirm high-risk operation")
|
||||
}
|
||||
cmd.Flags().StringP("jq", "q", "", "jq expression to filter JSON output")
|
||||
cmd.Flags().String("as", s.AuthTypes[0], "identity type: user | bot")
|
||||
|
||||
_ = cmd.RegisterFlagCompletionFunc("as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
|
||||
201
shortcuts/common/runner_jq_test.go
Normal file
201
shortcuts/common/runner_jq_test.go
Normal file
@@ -0,0 +1,201 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// newJqTestContext creates a RuntimeContext wired for jq testing.
|
||||
func newJqTestContext(jqExpr, format string) (*RuntimeContext, *bytes.Buffer, *bytes.Buffer) {
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("jq", "", "")
|
||||
cmd.Flags().String("format", "json", "")
|
||||
cmd.Flags().String("as", "bot", "")
|
||||
cmd.ParseFlags(nil)
|
||||
if jqExpr != "" {
|
||||
cmd.Flags().Set("jq", jqExpr)
|
||||
}
|
||||
if format != "" {
|
||||
cmd.Flags().Set("format", format)
|
||||
}
|
||||
|
||||
rctx := &RuntimeContext{
|
||||
ctx: context.Background(),
|
||||
Config: &core.CliConfig{Brand: core.BrandFeishu},
|
||||
Cmd: cmd,
|
||||
Format: format,
|
||||
JqExpr: jqExpr,
|
||||
resolvedAs: core.AsBot,
|
||||
Factory: &cmdutil.Factory{
|
||||
IOStreams: &cmdutil.IOStreams{Out: stdout, ErrOut: stderr},
|
||||
},
|
||||
}
|
||||
return rctx, stdout, stderr
|
||||
}
|
||||
|
||||
func TestRuntimeContext_Out_WithJq(t *testing.T) {
|
||||
rctx, stdout, _ := newJqTestContext(".data.name", "")
|
||||
|
||||
rctx.Out(map[string]interface{}{
|
||||
"name": "Alice",
|
||||
"age": 30,
|
||||
}, nil)
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "Alice") {
|
||||
t.Errorf("expected jq-filtered 'Alice', got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "age") {
|
||||
t.Errorf("expected jq to filter out 'age', got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeContext_Out_WithJq_Identity(t *testing.T) {
|
||||
rctx, stdout, _ := newJqTestContext(".ok", "")
|
||||
|
||||
rctx.Out(map[string]interface{}{"key": "value"}, nil)
|
||||
|
||||
out := strings.TrimSpace(stdout.String())
|
||||
if out != "true" {
|
||||
t.Errorf("expected 'true' for .ok, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeContext_OutFormat_WithJq_OverridesFormat(t *testing.T) {
|
||||
rctx, stdout, _ := newJqTestContext(".data.items", "pretty")
|
||||
|
||||
items := []interface{}{"a", "b", "c"}
|
||||
rctx.OutFormat(map[string]interface{}{
|
||||
"items": items,
|
||||
}, nil, func(w io.Writer) {
|
||||
t.Error("prettyFn should not be called when jq is set")
|
||||
})
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "a") || !strings.Contains(out, "b") {
|
||||
t.Errorf("expected jq-filtered items, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeContext_Out_WithJq_InvalidExpr_WritesStderr(t *testing.T) {
|
||||
rctx, _, stderr := newJqTestContext(".foo | invalid_func_xyz", "")
|
||||
|
||||
rctx.Out(map[string]interface{}{"foo": "bar"}, nil)
|
||||
|
||||
if !strings.Contains(stderr.String(), "error") {
|
||||
t.Errorf("expected error on stderr for runtime jq error, got: %s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func newTestShortcutCmd(s *Shortcut) *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "test-shortcut"}
|
||||
cmd.SetContext(context.Background())
|
||||
registerShortcutFlags(cmd, s)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newTestFactory() *cmdutil.Factory {
|
||||
return &cmdutil.Factory{
|
||||
Config: func() (*core.CliConfig, error) {
|
||||
return &core.CliConfig{
|
||||
AppID: "test", AppSecret: "test", Brand: core.BrandFeishu,
|
||||
}, nil
|
||||
},
|
||||
LarkClient: func() (*lark.Client, error) {
|
||||
return lark.NewClient("test", "test"), nil
|
||||
},
|
||||
IOStreams: &cmdutil.IOStreams{Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunShortcut_JqAndFormatConflict(t *testing.T) {
|
||||
s := &Shortcut{
|
||||
Service: "test",
|
||||
Command: "test-shortcut",
|
||||
AuthTypes: []string{"bot"},
|
||||
HasFormat: true,
|
||||
Execute: func(ctx context.Context, rctx *RuntimeContext) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd := newTestShortcutCmd(s)
|
||||
cmd.Flags().Set("jq", ".data")
|
||||
cmd.Flags().Set("format", "table")
|
||||
cmd.Flags().Set("as", "bot")
|
||||
|
||||
err := runShortcut(cmd, newTestFactory(), s, true)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --jq + --format table conflict")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Errorf("expected 'mutually exclusive' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunShortcut_JqInvalidExpression(t *testing.T) {
|
||||
s := &Shortcut{
|
||||
Service: "test",
|
||||
Command: "test-shortcut",
|
||||
AuthTypes: []string{"bot"},
|
||||
Execute: func(ctx context.Context, rctx *RuntimeContext) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd := newTestShortcutCmd(s)
|
||||
cmd.Flags().Set("jq", "invalid[")
|
||||
cmd.Flags().Set("as", "bot")
|
||||
|
||||
err := runShortcut(cmd, newTestFactory(), s, true)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid jq expression")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid jq expression") {
|
||||
t.Errorf("expected 'invalid jq expression' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunShortcut_JqRuntimeError_PropagatesError(t *testing.T) {
|
||||
s := &Shortcut{
|
||||
Service: "test",
|
||||
Command: "test-shortcut",
|
||||
AuthTypes: []string{"bot"},
|
||||
Execute: func(ctx context.Context, rctx *RuntimeContext) error {
|
||||
rctx.Out(map[string]interface{}{"foo": "bar"}, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd := newTestShortcutCmd(s)
|
||||
cmd.Flags().Set("jq", ".foo | invalid_func_xyz")
|
||||
cmd.Flags().Set("as", "bot")
|
||||
|
||||
err := runShortcut(cmd, newTestFactory(), s, true)
|
||||
if err == nil {
|
||||
t.Fatal("expected error from jq runtime failure to propagate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeContext_Out_WithoutJq_NormalOutput(t *testing.T) {
|
||||
rctx, stdout, _ := newJqTestContext("", "")
|
||||
|
||||
rctx.Out(map[string]interface{}{"key": "value"}, &output.Meta{Count: 1})
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"ok"`) || !strings.Contains(out, `"key"`) {
|
||||
t.Errorf("expected normal JSON envelope, got: %s", out)
|
||||
}
|
||||
}
|
||||
245
shortcuts/drive/drive_export.go
Normal file
245
shortcuts/drive/drive_export.go
Normal file
@@ -0,0 +1,245 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// DriveExport exports Drive-native documents to local files and falls back to
|
||||
// a follow-up command when the async export task does not finish in time.
|
||||
var DriveExport = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+export",
|
||||
Description: "Export a doc/docx/sheet/bitable to a local file with limited polling",
|
||||
Risk: "read",
|
||||
Scopes: []string{
|
||||
"docs:document.content:read",
|
||||
"docs:document:export",
|
||||
"drive:drive.metadata:readonly",
|
||||
},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "token", Desc: "source document token", Required: true},
|
||||
{Name: "doc-type", Desc: "source document type: doc | docx | sheet | bitable", Required: true, Enum: []string{"doc", "docx", "sheet", "bitable"}},
|
||||
{Name: "file-extension", Desc: "export format: docx | pdf | xlsx | csv | markdown", Required: true, Enum: []string{"docx", "pdf", "xlsx", "csv", "markdown"}},
|
||||
{Name: "sub-id", Desc: "sub-table/sheet ID, required when exporting sheet/bitable as csv"},
|
||||
{Name: "output-dir", Default: ".", Desc: "local output directory (default: current directory)"},
|
||||
{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateDriveExportSpec(driveExportSpec{
|
||||
Token: runtime.Str("token"),
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
SubID: runtime.Str("sub-id"),
|
||||
})
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec := driveExportSpec{
|
||||
Token: runtime.Str("token"),
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
SubID: runtime.Str("sub-id"),
|
||||
}
|
||||
// Markdown export is a special case: docx markdown comes from docs content
|
||||
// directly instead of the Drive export task API.
|
||||
if spec.FileExtension == "markdown" {
|
||||
return common.NewDryRunAPI().
|
||||
Desc("2-step orchestration: fetch docx markdown -> write local file").
|
||||
GET("/open-apis/docs/v1/content").
|
||||
Params(map[string]interface{}{
|
||||
"doc_token": spec.Token,
|
||||
"doc_type": "docx",
|
||||
"content_type": "markdown",
|
||||
})
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
}
|
||||
if strings.TrimSpace(spec.SubID) != "" {
|
||||
body["sub_id"] = spec.SubID
|
||||
}
|
||||
|
||||
return common.NewDryRunAPI().
|
||||
Desc("3-step orchestration: create export task -> limited polling -> download file").
|
||||
POST("/open-apis/drive/v1/export_tasks").
|
||||
Body(body)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := driveExportSpec{
|
||||
Token: runtime.Str("token"),
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
SubID: runtime.Str("sub-id"),
|
||||
}
|
||||
outputDir := runtime.Str("output-dir")
|
||||
overwrite := runtime.Bool("overwrite")
|
||||
|
||||
// Markdown export bypasses the async export task and writes the fetched
|
||||
// markdown content directly to disk.
|
||||
if spec.FileExtension == "markdown" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token))
|
||||
data, err := runtime.CallAPI(
|
||||
"GET",
|
||||
"/open-apis/docs/v1/content",
|
||||
map[string]interface{}{
|
||||
"doc_token": spec.Token,
|
||||
"doc_type": "docx",
|
||||
"content_type": "markdown",
|
||||
},
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Prefer the remote title for the exported file name, but still fall
|
||||
// back to the token if metadata is empty.
|
||||
title, err := fetchDriveMetaTitle(runtime, spec.Token, spec.DocType)
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Title lookup failed, using token as filename: %v\n", err)
|
||||
title = spec.Token
|
||||
}
|
||||
fileName := ensureExportFileExtension(sanitizeExportFileName(title, spec.Token), spec.FileExtension)
|
||||
savedPath, err := saveContentToOutputDir(outputDir, fileName, []byte(common.GetString(data, "content")), overwrite)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.Out(map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"file_name": filepath.Base(savedPath),
|
||||
"saved_path": savedPath,
|
||||
"size_bytes": len([]byte(common.GetString(data, "content"))),
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
ticket, err := createDriveExportTask(runtime, spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Created export task: %s\n", ticket)
|
||||
|
||||
var lastStatus driveExportStatus
|
||||
var lastPollErr error
|
||||
hasObservedStatus := false
|
||||
// Keep the command responsive by polling for a bounded window. If the task
|
||||
// is still running after that, return a resume command instead of blocking.
|
||||
for attempt := 1; attempt <= driveExportPollAttempts; attempt++ {
|
||||
if attempt > 1 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(driveExportPollInterval):
|
||||
}
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
status, err := getDriveExportStatus(runtime, spec.Token, ticket)
|
||||
if err != nil {
|
||||
// Treat polling failures as transient so short-lived backend hiccups
|
||||
// do not immediately fail an otherwise healthy export task.
|
||||
lastPollErr = err
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export status attempt %d/%d failed: %v\n", attempt, driveExportPollAttempts, err)
|
||||
continue
|
||||
}
|
||||
lastStatus = status
|
||||
hasObservedStatus = true
|
||||
|
||||
if status.Ready() {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export task completed: %s\n", common.MaskToken(status.FileToken))
|
||||
fileName := ensureExportFileExtension(sanitizeExportFileName(status.FileName, spec.Token), spec.FileExtension)
|
||||
out, err := downloadDriveExportFile(ctx, runtime, status.FileToken, outputDir, fileName, overwrite)
|
||||
if err != nil {
|
||||
recoveryCommand := driveExportDownloadCommand(status.FileToken, fileName, outputDir, overwrite)
|
||||
hint := fmt.Sprintf(
|
||||
"the export artifact is already ready (ticket=%s, file_token=%s)\nretry download with: %s",
|
||||
ticket,
|
||||
status.FileToken,
|
||||
recoveryCommand,
|
||||
)
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) && exitErr.Detail != nil {
|
||||
return output.ErrWithHint(exitErr.Code, exitErr.Detail.Type, exitErr.Detail.Message, hint)
|
||||
}
|
||||
return output.ErrWithHint(output.ExitAPI, "api_error", err.Error(), hint)
|
||||
}
|
||||
out["ticket"] = ticket
|
||||
out["doc_type"] = spec.DocType
|
||||
out["file_extension"] = spec.FileExtension
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
if status.Failed() {
|
||||
msg := strings.TrimSpace(status.JobErrorMsg)
|
||||
if msg == "" {
|
||||
msg = status.StatusLabel()
|
||||
}
|
||||
return output.Errorf(output.ExitAPI, "api_error", "export task failed: %s (ticket=%s)", msg, ticket)
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export status %d/%d: %s\n", attempt, driveExportPollAttempts, status.StatusLabel())
|
||||
}
|
||||
|
||||
nextCommand := driveExportTaskResultCommand(ticket, spec.Token)
|
||||
if !hasObservedStatus && lastPollErr != nil {
|
||||
hint := fmt.Sprintf(
|
||||
"the export task was created but every status poll failed (ticket=%s)\nretry status lookup with: %s",
|
||||
ticket,
|
||||
nextCommand,
|
||||
)
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(lastPollErr, &exitErr) && exitErr.Detail != nil {
|
||||
if strings.TrimSpace(exitErr.Detail.Hint) != "" {
|
||||
hint = exitErr.Detail.Hint + "\n" + hint
|
||||
}
|
||||
return output.ErrWithHint(exitErr.Code, exitErr.Detail.Type, exitErr.Detail.Message, hint)
|
||||
}
|
||||
return output.ErrWithHint(output.ExitAPI, "api_error", lastPollErr.Error(), hint)
|
||||
}
|
||||
|
||||
failed := false
|
||||
var jobStatus interface{}
|
||||
jobStatusLabel := "unknown"
|
||||
if hasObservedStatus {
|
||||
failed = lastStatus.Failed()
|
||||
jobStatus = lastStatus.JobStatus
|
||||
jobStatusLabel = lastStatus.StatusLabel()
|
||||
}
|
||||
// Return the last observed status so callers can resume from a known task
|
||||
// state instead of losing all progress information on timeout.
|
||||
runtime.Out(map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"ready": false,
|
||||
"failed": failed,
|
||||
"job_status": jobStatus,
|
||||
"job_status_label": jobStatusLabel,
|
||||
"timed_out": true,
|
||||
"next_command": nextCommand,
|
||||
}, nil)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export task is still in progress. Continue with: %s\n", nextCommand)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
371
shortcuts/drive/drive_export_common.go
Normal file
371
shortcuts/drive/drive_export_common.go
Normal file
@@ -0,0 +1,371 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var (
|
||||
driveExportPollAttempts = 10
|
||||
driveExportPollInterval = 5 * time.Second
|
||||
)
|
||||
|
||||
// driveExportSpec contains the normalized export request understood by the
|
||||
// shortcut and the underlying export task APIs.
|
||||
type driveExportSpec struct {
|
||||
Token string
|
||||
DocType string
|
||||
FileExtension string
|
||||
SubID string
|
||||
}
|
||||
|
||||
// driveExportTaskResultCommand prints the resume command shown when bounded
|
||||
// export polling times out locally.
|
||||
func driveExportTaskResultCommand(ticket, docToken string) string {
|
||||
return fmt.Sprintf("lark-cli drive +task_result --scenario export --ticket %s --file-token %s", ticket, docToken)
|
||||
}
|
||||
|
||||
// driveExportDownloadCommand prints a copy-pasteable follow-up command for
|
||||
// downloading an already-generated export artifact by file token.
|
||||
func driveExportDownloadCommand(fileToken, fileName, outputDir string, overwrite bool) string {
|
||||
parts := []string{
|
||||
"lark-cli", "drive", "+export-download",
|
||||
"--file-token", strconv.Quote(fileToken),
|
||||
}
|
||||
if strings.TrimSpace(fileName) != "" {
|
||||
parts = append(parts, "--file-name", strconv.Quote(fileName))
|
||||
}
|
||||
if strings.TrimSpace(outputDir) != "" && outputDir != "." {
|
||||
parts = append(parts, "--output-dir", strconv.Quote(outputDir))
|
||||
}
|
||||
if overwrite {
|
||||
parts = append(parts, "--overwrite")
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// driveExportStatus captures the fields needed to decide whether the export is
|
||||
// ready for download, still pending, or terminally failed.
|
||||
type driveExportStatus struct {
|
||||
Ticket string
|
||||
FileExtension string
|
||||
DocType string
|
||||
FileName string
|
||||
FileToken string
|
||||
JobErrorMsg string
|
||||
FileSize int64
|
||||
JobStatus int
|
||||
}
|
||||
|
||||
func (s driveExportStatus) Ready() bool {
|
||||
return s.FileToken != "" && s.JobStatus == 0
|
||||
}
|
||||
|
||||
func (s driveExportStatus) Pending() bool {
|
||||
// A zero status without a file token is still in progress because there is
|
||||
// nothing downloadable yet.
|
||||
return s.JobStatus == 1 || s.JobStatus == 2 || s.JobStatus == 0 && s.FileToken == ""
|
||||
}
|
||||
|
||||
func (s driveExportStatus) Failed() bool {
|
||||
return !s.Ready() && !s.Pending() && s.JobStatus != 0
|
||||
}
|
||||
|
||||
func (s driveExportStatus) StatusLabel() string {
|
||||
switch s.JobStatus {
|
||||
case 0:
|
||||
// Success is a special case where the file token is set.
|
||||
if s.FileToken != "" {
|
||||
return "success"
|
||||
}
|
||||
return "pending"
|
||||
case 1:
|
||||
return "new"
|
||||
case 2:
|
||||
return "processing"
|
||||
case 3:
|
||||
return "internal_error"
|
||||
case 107:
|
||||
return "export_size_limit"
|
||||
case 108:
|
||||
return "timeout"
|
||||
case 109:
|
||||
return "export_block_not_permitted"
|
||||
case 110:
|
||||
return "no_permission"
|
||||
case 111:
|
||||
return "docs_deleted"
|
||||
case 122:
|
||||
return "export_denied_on_copying"
|
||||
case 123:
|
||||
return "docs_not_exist"
|
||||
case 6000:
|
||||
return "export_images_exceed_limit"
|
||||
default:
|
||||
return fmt.Sprintf("status_%d", s.JobStatus)
|
||||
}
|
||||
}
|
||||
|
||||
// validateDriveExportSpec enforces shortcut-level export constraints before any
|
||||
// backend request is sent.
|
||||
func validateDriveExportSpec(spec driveExportSpec) error {
|
||||
if err := validate.ResourceName(spec.Token, "--token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
|
||||
switch spec.DocType {
|
||||
case "doc", "docx", "sheet", "bitable":
|
||||
default:
|
||||
return output.ErrValidation("invalid --doc-type %q: allowed values are doc, docx, sheet, bitable", spec.DocType)
|
||||
}
|
||||
|
||||
switch spec.FileExtension {
|
||||
case "docx", "pdf", "xlsx", "csv", "markdown":
|
||||
default:
|
||||
return output.ErrValidation("invalid --file-extension %q: allowed values are docx, pdf, xlsx, csv, markdown", spec.FileExtension)
|
||||
}
|
||||
|
||||
if spec.FileExtension == "markdown" && spec.DocType != "docx" {
|
||||
return output.ErrValidation("--file-extension markdown only supports --doc-type docx")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(spec.SubID) != "" {
|
||||
if spec.FileExtension != "csv" || (spec.DocType != "sheet" && spec.DocType != "bitable") {
|
||||
return output.ErrValidation("--sub-id is only used when exporting sheet/bitable as csv")
|
||||
}
|
||||
if err := validate.ResourceName(spec.SubID, "--sub-id"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
}
|
||||
|
||||
if spec.FileExtension == "csv" && (spec.DocType == "sheet" || spec.DocType == "bitable") && strings.TrimSpace(spec.SubID) == "" {
|
||||
return output.ErrValidation("--sub-id is required when exporting sheet/bitable as csv")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createDriveExportTask starts the asynchronous export job and returns its
|
||||
// ticket for subsequent polling.
|
||||
func createDriveExportTask(runtime *common.RuntimeContext, spec driveExportSpec) (string, error) {
|
||||
body := map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
}
|
||||
if strings.TrimSpace(spec.SubID) != "" {
|
||||
body["sub_id"] = spec.SubID
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("POST", "/open-apis/drive/v1/export_tasks", nil, body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
ticket := common.GetString(data, "ticket")
|
||||
if ticket == "" {
|
||||
return "", output.Errorf(output.ExitAPI, "api_error", "export task created but ticket is missing")
|
||||
}
|
||||
return ticket, nil
|
||||
}
|
||||
|
||||
// getDriveExportStatus fetches the current backend state for a previously
|
||||
// created export task.
|
||||
func getDriveExportStatus(runtime *common.RuntimeContext, token, ticket string) (driveExportStatus, error) {
|
||||
data, err := runtime.CallAPI(
|
||||
"GET",
|
||||
fmt.Sprintf("/open-apis/drive/v1/export_tasks/%s", validate.EncodePathSegment(ticket)),
|
||||
map[string]interface{}{"token": token},
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return driveExportStatus{}, err
|
||||
}
|
||||
return parseDriveExportStatus(ticket, data), nil
|
||||
}
|
||||
|
||||
// parseDriveExportStatus accepts the wrapped export result and normalizes the
|
||||
// subset of fields used by the shortcut.
|
||||
func parseDriveExportStatus(ticket string, data map[string]interface{}) driveExportStatus {
|
||||
result := common.GetMap(data, "result")
|
||||
status := driveExportStatus{
|
||||
Ticket: ticket,
|
||||
}
|
||||
if result == nil {
|
||||
// Keep the ticket even when the result body is missing so callers can
|
||||
// still show a resumable task reference.
|
||||
return status
|
||||
}
|
||||
|
||||
status.FileExtension = common.GetString(result, "file_extension")
|
||||
status.DocType = common.GetString(result, "type")
|
||||
status.FileName = common.GetString(result, "file_name")
|
||||
status.FileToken = common.GetString(result, "file_token")
|
||||
status.JobErrorMsg = common.GetString(result, "job_error_msg")
|
||||
status.FileSize = int64(common.GetFloat(result, "file_size"))
|
||||
status.JobStatus = int(common.GetFloat(result, "job_status"))
|
||||
return status
|
||||
}
|
||||
|
||||
// fetchDriveMetaTitle looks up the document title so exported files can use a
|
||||
// human-readable default name when possible.
|
||||
func fetchDriveMetaTitle(runtime *common.RuntimeContext, token, docType string) (string, error) {
|
||||
data, err := runtime.CallAPI(
|
||||
"POST",
|
||||
"/open-apis/drive/v1/metas/batch_query",
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"request_docs": []map[string]interface{}{
|
||||
{
|
||||
"doc_token": token,
|
||||
"doc_type": docType,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
metas := common.GetSlice(data, "metas")
|
||||
if len(metas) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
meta, _ := metas[0].(map[string]interface{})
|
||||
return common.GetString(meta, "title"), nil
|
||||
}
|
||||
|
||||
// saveContentToOutputDir validates the target path, enforces overwrite policy,
|
||||
// and writes the payload atomically to disk.
|
||||
func saveContentToOutputDir(outputDir, fileName string, payload []byte, overwrite bool) (string, error) {
|
||||
if outputDir == "" {
|
||||
outputDir = "."
|
||||
}
|
||||
|
||||
// Sanitize both the filename and the combined output path so caller-provided
|
||||
// names cannot escape the requested output directory.
|
||||
safeName := sanitizeExportFileName(fileName, "export.bin")
|
||||
target := filepath.Join(outputDir, safeName)
|
||||
safePath, err := validate.SafeOutputPath(target)
|
||||
if err != nil {
|
||||
return "", output.ErrValidation("unsafe output path: %s", err)
|
||||
}
|
||||
if err := common.EnsureWritableFile(safePath, overwrite); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(safePath), 0755); err != nil {
|
||||
return "", output.Errorf(output.ExitInternal, "io", "cannot create output directory: %s", err)
|
||||
}
|
||||
if err := validate.AtomicWrite(safePath, payload, 0644); err != nil {
|
||||
return "", output.Errorf(output.ExitInternal, "io", "cannot write file: %s", err)
|
||||
}
|
||||
return safePath, nil
|
||||
}
|
||||
|
||||
// downloadDriveExportFile downloads the exported artifact, derives a safe local
|
||||
// file name, and returns metadata about the saved file.
|
||||
func downloadDriveExportFile(ctx context.Context, runtime *common.RuntimeContext, fileToken, outputDir, preferredName string, overwrite bool) (map[string]interface{}, error) {
|
||||
if err := validate.ResourceName(fileToken, "--file-token"); err != nil {
|
||||
return nil, output.ErrValidation("%s", err)
|
||||
}
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/export_tasks/file/%s/download", validate.EncodePathSegment(fileToken)),
|
||||
}, larkcore.WithFileDownload())
|
||||
if err != nil {
|
||||
return nil, output.ErrNetwork("download failed: %s", err)
|
||||
}
|
||||
if apiResp.StatusCode >= 400 {
|
||||
return nil, output.ErrNetwork("download failed: HTTP %d: %s", apiResp.StatusCode, string(apiResp.RawBody))
|
||||
}
|
||||
|
||||
fileName := strings.TrimSpace(preferredName)
|
||||
if fileName == "" {
|
||||
// Fall back to the server-provided download name when the caller did not
|
||||
// request an explicit local file name.
|
||||
fileName = client.ResolveFilename(apiResp)
|
||||
}
|
||||
savedPath, err := saveContentToOutputDir(outputDir, fileName, apiResp.RawBody, overwrite)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"file_token": fileToken,
|
||||
"file_name": filepath.Base(savedPath),
|
||||
"saved_path": savedPath,
|
||||
"size_bytes": len(apiResp.RawBody),
|
||||
"content_type": apiResp.Header.Get("Content-Type"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// sanitizeExportFileName strips path traversal and unsupported characters while
|
||||
// preserving a readable file name when possible.
|
||||
func sanitizeExportFileName(name, fallback string) string {
|
||||
name = strings.TrimSpace(filepath.Base(name))
|
||||
if name == "" || name == "." || name == string(filepath.Separator) {
|
||||
name = fallback
|
||||
}
|
||||
|
||||
replacer := strings.NewReplacer(
|
||||
"/", "_", "\\", "_", ":", "_", "*", "_", "?", "_",
|
||||
"\"", "_", "<", "_", ">", "_", "|", "_",
|
||||
"\n", "_", "\r", "_", "\t", "_", "\x00", "_",
|
||||
)
|
||||
name = replacer.Replace(name)
|
||||
name = strings.Trim(name, ". ")
|
||||
if name == "" {
|
||||
return fallback
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// ensureExportFileExtension appends the expected local suffix when the chosen
|
||||
// file name does not already end with the export format's extension.
|
||||
func ensureExportFileExtension(name, fileExtension string) string {
|
||||
expected := exportFileSuffix(fileExtension)
|
||||
if expected == "" {
|
||||
return name
|
||||
}
|
||||
if strings.EqualFold(filepath.Ext(name), expected) {
|
||||
return name
|
||||
}
|
||||
return name + expected
|
||||
}
|
||||
|
||||
// exportFileSuffix maps shortcut-level export formats to the local filename
|
||||
// suffix written to disk.
|
||||
func exportFileSuffix(fileExtension string) string {
|
||||
switch fileExtension {
|
||||
case "markdown":
|
||||
return ".md"
|
||||
case "docx":
|
||||
return ".docx"
|
||||
case "pdf":
|
||||
return ".pdf"
|
||||
case "xlsx":
|
||||
return ".xlsx"
|
||||
case "csv":
|
||||
return ".csv"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
67
shortcuts/drive/drive_export_common_test.go
Normal file
67
shortcuts/drive/drive_export_common_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDriveExportStatusLabelCoversKnownAndUnknownCodes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
status driveExportStatus
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "size limit",
|
||||
status: driveExportStatus{JobStatus: 107},
|
||||
want: "export_size_limit",
|
||||
},
|
||||
{
|
||||
name: "not exist",
|
||||
status: driveExportStatus{JobStatus: 123},
|
||||
want: "docs_not_exist",
|
||||
},
|
||||
{
|
||||
name: "unknown status",
|
||||
status: driveExportStatus{JobStatus: 999},
|
||||
want: "status_999",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := tt.status.StatusLabel(); got != tt.want {
|
||||
t.Fatalf("StatusLabel() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDriveExportStatusWithoutResultKeepsTicket(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
status := parseDriveExportStatus("ticket_export_test", map[string]interface{}{})
|
||||
if status.Ticket != "ticket_export_test" {
|
||||
t.Fatalf("ticket = %q, want %q", status.Ticket, "ticket_export_test")
|
||||
}
|
||||
if status.FileToken != "" {
|
||||
t.Fatalf("file token = %q, want empty", status.FileToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeExportFileNameAndEnsureExtension(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := sanitizeExportFileName("../quarterly:report?.pdf", "fallback.bin"); got != "quarterly_report_.pdf" {
|
||||
t.Fatalf("sanitizeExportFileName() = %q, want %q", got, "quarterly_report_.pdf")
|
||||
}
|
||||
if got := ensureExportFileExtension("meeting-notes", "markdown"); got != "meeting-notes.md" {
|
||||
t.Fatalf("ensureExportFileExtension() = %q, want %q", got, "meeting-notes.md")
|
||||
}
|
||||
if got := ensureExportFileExtension("report.pdf", "pdf"); got != "report.pdf" {
|
||||
t.Fatalf("ensureExportFileExtension() should preserve suffix, got %q", got)
|
||||
}
|
||||
}
|
||||
60
shortcuts/drive/drive_export_download.go
Normal file
60
shortcuts/drive/drive_export_download.go
Normal file
@@ -0,0 +1,60 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// DriveExportDownload downloads an already-generated export artifact when the
|
||||
// caller has a file token from a previous export task.
|
||||
var DriveExportDownload = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+export-download",
|
||||
Description: "Download an exported file by file_token",
|
||||
Risk: "read",
|
||||
Scopes: []string{
|
||||
"docs:document:export",
|
||||
},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "file-token", Desc: "exported file token", Required: true},
|
||||
{Name: "file-name", Desc: "preferred output filename (optional)"},
|
||||
{Name: "output-dir", Default: ".", Desc: "local output directory (default: current directory)"},
|
||||
{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validate.ResourceName(runtime.Str("file-token"), "--file-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/drive/v1/export_tasks/file/:file_token/download").
|
||||
Set("file_token", runtime.Str("file-token")).
|
||||
Set("output_dir", runtime.Str("output-dir"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
// Reuse the shared export download helper so overwrite checks, filename
|
||||
// resolution, and output metadata stay consistent with drive +export.
|
||||
out, err := downloadDriveExportFile(
|
||||
ctx,
|
||||
runtime,
|
||||
runtime.Str("file-token"),
|
||||
runtime.Str("output-dir"),
|
||||
runtime.Str("file-name"),
|
||||
runtime.Bool("overwrite"),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
516
shortcuts/drive/drive_export_test.go
Normal file
516
shortcuts/drive/drive_export_test.go
Normal file
@@ -0,0 +1,516 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestValidateDriveExportSpec(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
spec driveExportSpec
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "markdown docx ok",
|
||||
spec: driveExportSpec{Token: "docx123", DocType: "docx", FileExtension: "markdown"},
|
||||
},
|
||||
{
|
||||
name: "markdown non docx rejected",
|
||||
spec: driveExportSpec{Token: "doc123", DocType: "doc", FileExtension: "markdown"},
|
||||
wantErr: "only supports --doc-type docx",
|
||||
},
|
||||
{
|
||||
name: "csv without sub id rejected",
|
||||
spec: driveExportSpec{Token: "sheet123", DocType: "sheet", FileExtension: "csv"},
|
||||
wantErr: "--sub-id is required",
|
||||
},
|
||||
{
|
||||
name: "sub id on non csv rejected",
|
||||
spec: driveExportSpec{Token: "docx123", DocType: "docx", FileExtension: "pdf", SubID: "tbl_1"},
|
||||
wantErr: "--sub-id is only used",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := validateDriveExportSpec(tt.spec)
|
||||
if tt.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportMarkdownWritesFile(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/docs/v1/content",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"content": "# hello\n",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"metas": []map[string]interface{}{
|
||||
{"title": "Weekly Notes"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "docx123",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "markdown",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "Weekly Notes.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error: %v", err)
|
||||
}
|
||||
if string(data) != "# hello\n" {
|
||||
t.Fatalf("markdown content = %q", string(data))
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "Weekly Notes.md") {
|
||||
t.Fatalf("stdout missing file name: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportAsyncSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"ticket": "tk_123"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_123",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"job_status": 0,
|
||||
"file_token": "box_123",
|
||||
"file_name": "report",
|
||||
"file_extension": "pdf",
|
||||
"type": "docx",
|
||||
"file_size": 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/file/box_123/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("pdf"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/pdf"},
|
||||
"Content-Disposition": []string{`attachment; filename="report.pdf"`},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
|
||||
driveExportPollAttempts, driveExportPollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "docx123",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "pdf",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "report.pdf"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error: %v", err)
|
||||
}
|
||||
if string(data) != "pdf" {
|
||||
t.Fatalf("downloaded content = %q", string(data))
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"ticket": "tk_123"`) {
|
||||
t.Fatalf("stdout missing ticket: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportReadyDownloadFailureIncludesRecoveryHint(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"ticket": "tk_ready"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_ready",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"job_status": 0,
|
||||
"file_token": "box_ready",
|
||||
"file_name": "report",
|
||||
"file_extension": "pdf",
|
||||
"type": "docx",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/file/box_ready/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("pdf"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/pdf"},
|
||||
"Content-Disposition": []string{`attachment; filename="report.pdf"`},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "report.pdf"), []byte("old"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
|
||||
driveExportPollAttempts, driveExportPollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "docx123",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "pdf",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected download recovery error, got nil")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured exit error, got %v", err)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "already exists") {
|
||||
t.Fatalf("message missing overwrite guidance: %q", exitErr.Detail.Message)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "ticket=tk_ready") {
|
||||
t.Fatalf("hint missing ticket: %q", exitErr.Detail.Hint)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "file_token=box_ready") {
|
||||
t.Fatalf("hint missing file token: %q", exitErr.Detail.Hint)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, `lark-cli drive +export-download --file-token "box_ready" --file-name "report.pdf"`) {
|
||||
t.Fatalf("hint missing recovery command: %q", exitErr.Detail.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportTimeoutReturnsFollowUpCommand(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"ticket": "tk_456"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_456",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"job_status": 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
|
||||
driveExportPollAttempts, driveExportPollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "docx123",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "pdf",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"ticket": "tk_456"`) {
|
||||
t.Fatalf("stdout missing ticket: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"timed_out": true`) {
|
||||
t.Fatalf("stdout missing timed_out=true: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"failed": false`) {
|
||||
t.Fatalf("stdout missing failed=false: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"job_status": 2`) {
|
||||
t.Fatalf("stdout missing numeric job_status: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"job_status_label": "processing"`) {
|
||||
t.Fatalf("stdout missing processing job_status_label: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"next_command": "lark-cli drive +task_result --scenario export --ticket tk_456 --file-token docx123"`) {
|
||||
t.Fatalf("stdout missing follow-up command: %s", stdout.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "report.pdf")); !os.IsNotExist(err) {
|
||||
t.Fatalf("unexpected downloaded file, err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportPollErrorsReturnLastErrorWithRecoveryHint(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"ticket": "tk_poll_fail"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_poll_fail",
|
||||
Status: 500,
|
||||
Body: map[string]interface{}{
|
||||
"code": 999,
|
||||
"msg": "temporary backend failure",
|
||||
},
|
||||
})
|
||||
|
||||
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
|
||||
driveExportPollAttempts, driveExportPollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "docx123",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "pdf",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected persistent poll error, got nil")
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout should stay empty on persistent poll error: %s", stdout.String())
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured exit error, got %v", err)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "temporary backend failure") {
|
||||
t.Fatalf("message missing last poll error: %q", exitErr.Detail.Message)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "ticket=tk_poll_fail") {
|
||||
t.Fatalf("hint missing ticket: %q", exitErr.Detail.Hint)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "lark-cli drive +task_result --scenario export --ticket tk_poll_fail --file-token docx123") {
|
||||
t.Fatalf("hint missing recovery command: %q", exitErr.Detail.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportDownloadUsesProvidedFileName(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/file/box_789/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("csv"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"text/csv"},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDrive(t, DriveExportDownload, []string{
|
||||
"+export-download",
|
||||
"--file-token", "box_789",
|
||||
"--file-name", "custom.csv",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "custom.csv"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error: %v", err)
|
||||
}
|
||||
if string(data) != "csv" {
|
||||
t.Fatalf("downloaded content = %q", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportDownloadRejectsOverwriteWithoutFlag(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/file/box_dup/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("new"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/pdf"},
|
||||
"Content-Disposition": []string{`attachment; filename="dup.pdf"`},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.WriteFile("dup.pdf", []byte("old"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
err := mountAndRunDrive(t, DriveExportDownload, []string{
|
||||
"+export-download",
|
||||
"--file-token", "box_dup",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected overwrite protection error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "already exists") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveContentToOutputDirRejectsOverwriteWithoutFlag(t *testing.T) {
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
target := filepath.Join(tmpDir, "exists.txt")
|
||||
if err := os.WriteFile(target, []byte("old"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("Getwd() error: %v", err)
|
||||
}
|
||||
if err := os.Chdir(tmpDir); err != nil {
|
||||
t.Fatalf("Chdir() error: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(cwd) })
|
||||
|
||||
_, err = saveContentToOutputDir(".", "exists.txt", []byte("new"), false)
|
||||
if err == nil || !strings.Contains(err.Error(), "already exists") {
|
||||
t.Fatalf("expected overwrite error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveTaskResultExportIncludesReadyFlags(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_export",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"job_status": 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveTaskResult, []string{
|
||||
"+task_result",
|
||||
"--scenario", "export",
|
||||
"--ticket", "tk_export",
|
||||
"--file-token", "docx123",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"ready": false`)) {
|
||||
t.Fatalf("stdout missing ready=false: %s", stdout.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"failed": false`)) {
|
||||
t.Fatalf("stdout missing failed=false: %s", stdout.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"job_status_label": "processing"`)) {
|
||||
t.Fatalf("stdout missing job_status_label: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
229
shortcuts/drive/drive_import.go
Normal file
229
shortcuts/drive/drive_import.go
Normal file
@@ -0,0 +1,229 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// DriveImport uploads a local file, creates an import task, and polls until
|
||||
// the imported cloud document is ready or the local polling window expires.
|
||||
var DriveImport = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+import",
|
||||
Description: "Import a local file to Drive as a cloud document (docx, sheet, bitable)",
|
||||
Risk: "write",
|
||||
Scopes: []string{
|
||||
"docs:document.media:upload",
|
||||
"docs:document:import",
|
||||
},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "file", Desc: "local file path (e.g. .docx, .xlsx, .md; large files auto use multipart upload)", Required: true},
|
||||
{Name: "type", Desc: "target document type (docx, sheet, bitable)", Required: true},
|
||||
{Name: "folder-token", Desc: "target folder token (omit for root folder; API accepts empty mount_key as root)"},
|
||||
{Name: "name", Desc: "imported file name (default: local file name without extension)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateDriveImportSpec(driveImportSpec{
|
||||
FilePath: runtime.Str("file"),
|
||||
DocType: strings.ToLower(runtime.Str("type")),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
})
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec := driveImportSpec{
|
||||
FilePath: runtime.Str("file"),
|
||||
DocType: strings.ToLower(runtime.Str("type")),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
}
|
||||
fileSize, err := preflightDriveImportFile(&spec)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
|
||||
dry := common.NewDryRunAPI()
|
||||
dry.Desc("Upload file (single-part or multipart) -> create import task -> poll status")
|
||||
|
||||
appendDriveImportUploadDryRun(dry, spec, fileSize)
|
||||
|
||||
dry.POST("/open-apis/drive/v1/import_tasks").
|
||||
Desc("[2] Create import task").
|
||||
Body(spec.CreateTaskBody("<file_token>"))
|
||||
|
||||
dry.GET("/open-apis/drive/v1/import_tasks/:ticket").
|
||||
Desc("[3] Poll import task result").
|
||||
Set("ticket", "<ticket>")
|
||||
|
||||
return dry
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := driveImportSpec{
|
||||
FilePath: runtime.Str("file"),
|
||||
DocType: strings.ToLower(runtime.Str("type")),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
}
|
||||
if _, err := preflightDriveImportFile(&spec); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 1: Upload file as media
|
||||
fileToken, uploadErr := uploadMediaForImport(ctx, runtime, spec.FilePath, spec.SourceFileName(), spec.DocType)
|
||||
if uploadErr != nil {
|
||||
return uploadErr
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Creating import task for %s as %s...\n", spec.TargetFileName(), spec.DocType)
|
||||
|
||||
// Step 2: Create import task
|
||||
ticket, err := createDriveImportTask(runtime, spec, fileToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 3: Poll task
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Polling import task %s...\n", ticket)
|
||||
|
||||
status, ready, err := pollDriveImportTask(runtime, ticket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Some intermediate responses omit the final type, so fall back to the
|
||||
// requested type to keep the output shape stable.
|
||||
resultType := status.DocType
|
||||
if resultType == "" {
|
||||
resultType = spec.DocType
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"type": resultType,
|
||||
"ready": ready,
|
||||
"job_status": status.JobStatus,
|
||||
"job_status_label": status.StatusLabel(),
|
||||
}
|
||||
if status.Token != "" {
|
||||
out["token"] = status.Token
|
||||
}
|
||||
if status.URL != "" {
|
||||
out["url"] = status.URL
|
||||
}
|
||||
if status.JobErrorMsg != "" {
|
||||
out["job_error_msg"] = status.JobErrorMsg
|
||||
}
|
||||
if status.Extra != nil {
|
||||
out["extra"] = status.Extra
|
||||
}
|
||||
if !ready {
|
||||
nextCommand := driveImportTaskResultCommand(ticket)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Import task is still in progress. Continue with: %s\n", nextCommand)
|
||||
out["timed_out"] = true
|
||||
out["next_command"] = nextCommand
|
||||
}
|
||||
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func preflightDriveImportFile(spec *driveImportSpec) (int64, error) {
|
||||
// Keep dry-run and execution aligned on path normalization, file existence,
|
||||
// and format-specific size limits before planning the upload path.
|
||||
safeFilePath, err := validate.SafeInputPath(spec.FilePath)
|
||||
if err != nil {
|
||||
return 0, output.ErrValidation("unsafe file path: %s", err)
|
||||
}
|
||||
spec.FilePath = safeFilePath
|
||||
|
||||
info, err := os.Stat(spec.FilePath)
|
||||
if err != nil {
|
||||
return 0, output.ErrValidation("cannot read file: %s", err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return 0, output.ErrValidation("file must be a regular file: %s", spec.FilePath)
|
||||
}
|
||||
if err = validateDriveImportFileSize(spec.FilePath, spec.DocType, info.Size()); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return info.Size(), nil
|
||||
}
|
||||
|
||||
func appendDriveImportUploadDryRun(dry *common.DryRunAPI, spec driveImportSpec, fileSize int64) {
|
||||
extra, err := buildImportMediaExtra(spec.FilePath, spec.DocType)
|
||||
if err != nil {
|
||||
extra = fmt.Sprintf(`{"obj_type":"%s","file_extension":"%s"}`, spec.DocType, spec.FileExtension())
|
||||
}
|
||||
|
||||
if fileSize > maxDriveUploadFileSize {
|
||||
dry.POST("/open-apis/drive/v1/medias/upload_prepare").
|
||||
Desc("[1a] Initialize multipart upload").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": spec.SourceFileName(),
|
||||
"parent_type": "ccm_import_open",
|
||||
"parent_node": "",
|
||||
"size": "<file_size>",
|
||||
"extra": extra,
|
||||
})
|
||||
dry.POST("/open-apis/drive/v1/medias/upload_part").
|
||||
Desc("[1b] Upload file parts (repeated)").
|
||||
Body(map[string]interface{}{
|
||||
"upload_id": "<upload_id>",
|
||||
"seq": "<chunk_index>",
|
||||
"size": "<chunk_size>",
|
||||
"file": "<chunk_binary>",
|
||||
})
|
||||
dry.POST("/open-apis/drive/v1/medias/upload_finish").
|
||||
Desc("[1c] Finalize multipart upload and get file_token").
|
||||
Body(map[string]interface{}{
|
||||
"upload_id": "<upload_id>",
|
||||
"block_num": "<block_num>",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
dry.POST("/open-apis/drive/v1/medias/upload_all").
|
||||
Desc("[1] Upload file to get file_token").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": spec.SourceFileName(),
|
||||
"parent_type": "ccm_import_open",
|
||||
"size": "<file_size>",
|
||||
"extra": extra,
|
||||
"file": "@" + spec.FilePath,
|
||||
})
|
||||
}
|
||||
|
||||
// importTargetFileName returns the explicit import name when present, otherwise
|
||||
// derives one from the local file name.
|
||||
func importTargetFileName(filePath, explicitName string) string {
|
||||
if explicitName != "" {
|
||||
return explicitName
|
||||
}
|
||||
return importDefaultFileName(filePath)
|
||||
}
|
||||
|
||||
// importDefaultFileName strips only the last extension so names like
|
||||
// "report.final.csv" become "report.final".
|
||||
func importDefaultFileName(filePath string) string {
|
||||
base := filepath.Base(filePath)
|
||||
ext := filepath.Ext(base)
|
||||
if ext == "" {
|
||||
return base
|
||||
}
|
||||
name := strings.TrimSuffix(base, ext)
|
||||
if name == "" {
|
||||
return base
|
||||
}
|
||||
return name
|
||||
}
|
||||
551
shortcuts/drive/drive_import_common.go
Normal file
551
shortcuts/drive/drive_import_common.go
Normal file
@@ -0,0 +1,551 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var (
|
||||
driveImportPollAttempts = 30
|
||||
driveImportPollInterval = 2 * time.Second
|
||||
)
|
||||
|
||||
const (
|
||||
// These limits follow the current product-side import constraints per format.
|
||||
driveImport20MBFileSizeLimit int64 = 20 * 1024 * 1024
|
||||
driveImport100MBFileSizeLimit int64 = 100 * 1024 * 1024
|
||||
driveImport600MBFileSizeLimit int64 = 600 * 1024 * 1024
|
||||
driveImport800MBFileSizeLimit int64 = 800 * 1024 * 1024
|
||||
)
|
||||
|
||||
type driveMultipartUploadSession struct {
|
||||
UploadID string
|
||||
BlockSize int
|
||||
BlockNum int
|
||||
}
|
||||
|
||||
// driveImportExtToDocTypes defines which source file extensions can be imported
|
||||
// into which Drive-native document types.
|
||||
var driveImportExtToDocTypes = map[string][]string{
|
||||
"docx": {"docx"},
|
||||
"doc": {"docx"},
|
||||
"txt": {"docx"},
|
||||
"md": {"docx"},
|
||||
"mark": {"docx"},
|
||||
"markdown": {"docx"},
|
||||
"html": {"docx"},
|
||||
"xlsx": {"sheet", "bitable"},
|
||||
"xls": {"sheet"},
|
||||
"csv": {"sheet", "bitable"},
|
||||
}
|
||||
|
||||
// driveImportSpec contains the user-facing import inputs after normalization.
|
||||
type driveImportSpec struct {
|
||||
FilePath string
|
||||
DocType string
|
||||
FolderToken string
|
||||
Name string
|
||||
}
|
||||
|
||||
func (s driveImportSpec) FileExtension() string {
|
||||
return strings.TrimPrefix(strings.ToLower(filepath.Ext(s.FilePath)), ".")
|
||||
}
|
||||
|
||||
func (s driveImportSpec) SourceFileName() string {
|
||||
return filepath.Base(s.FilePath)
|
||||
}
|
||||
|
||||
func (s driveImportSpec) TargetFileName() string {
|
||||
return importTargetFileName(s.FilePath, s.Name)
|
||||
}
|
||||
|
||||
// CreateTaskBody builds the request body expected by /drive/v1/import_tasks.
|
||||
func (s driveImportSpec) CreateTaskBody(fileToken string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"file_extension": s.FileExtension(),
|
||||
"file_token": fileToken,
|
||||
"type": s.DocType,
|
||||
"file_name": s.TargetFileName(),
|
||||
"point": map[string]interface{}{
|
||||
"mount_type": 1,
|
||||
// The import API treats an empty mount_key as "use the caller's root
|
||||
// folder", so preserve the zero value when --folder-token is omitted.
|
||||
"mount_key": s.FolderToken,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// uploadMediaForImport uploads the source file to the temporary import media
|
||||
// endpoint and returns the file token consumed by import_tasks.
|
||||
func uploadMediaForImport(ctx context.Context, runtime *common.RuntimeContext, filePath, fileName, docType string) (string, error) {
|
||||
importInfo, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
return "", output.ErrValidation("cannot read file: %s", err)
|
||||
}
|
||||
|
||||
fileSize := importInfo.Size()
|
||||
if err = validateDriveImportFileSize(filePath, docType, fileSize); err != nil {
|
||||
return "", err
|
||||
}
|
||||
fileSizeValue, err := driveUploadSizeValue(fileSize)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
extra, err := buildImportMediaExtra(filePath, docType)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if fileSize <= maxDriveUploadFileSize {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Uploading media for import: %s (%s)\n", fileName, common.FormatSize(fileSize))
|
||||
return uploadMediaForImportAll(runtime, filePath, fileName, fileSizeValue, extra)
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Uploading media for import via multipart upload: %s (%s)\n", fileName, common.FormatSize(fileSize))
|
||||
return uploadMediaForImportMultipart(runtime, filePath, fileName, fileSizeValue, extra)
|
||||
}
|
||||
|
||||
func uploadMediaForImportAll(runtime *common.RuntimeContext, filePath, fileName string, fileSize int, extra string) (string, error) {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return "", output.ErrValidation("cannot read file: %s", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddField("file_name", fileName)
|
||||
fd.AddField("parent_type", "ccm_import_open")
|
||||
fd.AddField("size", fmt.Sprintf("%d", fileSize))
|
||||
fd.AddField("extra", extra)
|
||||
fd.AddFile("file", f)
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/drive/v1/medias/upload_all",
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if err != nil {
|
||||
return "", wrapDriveUploadRequestError(err, "upload media failed")
|
||||
}
|
||||
|
||||
data, err := parseDriveUploadResponse(apiResp, "upload media failed")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return extractDriveUploadFileToken(data, "upload media failed")
|
||||
}
|
||||
|
||||
func uploadMediaForImportMultipart(runtime *common.RuntimeContext, filePath, fileName string, fileSize int, extra string) (string, error) {
|
||||
session, err := prepareMediaImportUpload(runtime, fileName, fileSize, extra)
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload prepare failed: %s\n", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
totalBlocks := session.BlockNum
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload initialized: %d chunks x %s\n", totalBlocks, common.FormatSize(int64(session.BlockSize)))
|
||||
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return "", output.ErrValidation("cannot read file: %s", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
buffer := make([]byte, session.BlockSize)
|
||||
remaining := fileSize
|
||||
uploadedBlocks := 0
|
||||
for remaining > 0 {
|
||||
chunkSize := session.BlockSize
|
||||
if chunkSize > remaining {
|
||||
chunkSize = remaining
|
||||
}
|
||||
|
||||
n, readErr := io.ReadFull(f, buffer[:chunkSize])
|
||||
if readErr != nil {
|
||||
return "", output.ErrValidation("cannot read file: %s", readErr)
|
||||
}
|
||||
|
||||
if err = uploadMediaImportPart(runtime, session.UploadID, uploadedBlocks, buffer[:n]); err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload part failed: %s\n", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
remaining -= n
|
||||
uploadedBlocks++
|
||||
}
|
||||
|
||||
if session.BlockNum > 0 && session.BlockNum != uploadedBlocks {
|
||||
return "", output.Errorf(output.ExitAPI, "api_error", "upload prepare mismatch: expected %d blocks, uploaded %d", session.BlockNum, uploadedBlocks)
|
||||
}
|
||||
|
||||
return finishMediaImportUpload(runtime, session.UploadID, uploadedBlocks)
|
||||
}
|
||||
|
||||
func prepareMediaImportUpload(runtime *common.RuntimeContext, fileName string, fileSize int, extra string) (driveMultipartUploadSession, error) {
|
||||
data, err := runtime.CallAPI("POST", "/open-apis/drive/v1/medias/upload_prepare", nil, map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": "ccm_import_open", // For media import uploads, parent_type must be ccm_import_open.
|
||||
"size": fileSize,
|
||||
"extra": extra,
|
||||
"parent_node": "", // For media import uploads, parent_node must be an explicit empty string; unlike medias/upload_all, this field cannot be omitted.
|
||||
})
|
||||
if err != nil {
|
||||
return driveMultipartUploadSession{}, err
|
||||
}
|
||||
|
||||
session := driveMultipartUploadSession{
|
||||
UploadID: common.GetString(data, "upload_id"),
|
||||
BlockSize: int(common.GetFloat(data, "block_size")),
|
||||
BlockNum: int(common.GetFloat(data, "block_num")),
|
||||
}
|
||||
if session.UploadID == "" {
|
||||
return driveMultipartUploadSession{}, output.Errorf(output.ExitAPI, "api_error", "upload prepare failed: no upload_id returned")
|
||||
}
|
||||
if session.BlockSize <= 0 {
|
||||
return driveMultipartUploadSession{}, output.Errorf(output.ExitAPI, "api_error", "upload prepare failed: invalid block_size returned")
|
||||
}
|
||||
if session.BlockNum <= 0 {
|
||||
return driveMultipartUploadSession{}, output.Errorf(output.ExitAPI, "api_error", "upload prepare failed: invalid block_num returned")
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func uploadMediaImportPart(runtime *common.RuntimeContext, uploadID string, seq int, chunk []byte) error {
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddField("upload_id", uploadID)
|
||||
fd.AddField("seq", seq)
|
||||
fd.AddField("size", len(chunk))
|
||||
fd.AddFile("file", bytes.NewReader(chunk))
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/drive/v1/medias/upload_part",
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if err != nil {
|
||||
return wrapDriveUploadRequestError(err, "upload media part failed")
|
||||
}
|
||||
|
||||
_, err = parseDriveUploadResponse(apiResp, "upload media part failed")
|
||||
return err
|
||||
}
|
||||
|
||||
func finishMediaImportUpload(runtime *common.RuntimeContext, uploadID string, blockNum int) (string, error) {
|
||||
data, err := runtime.CallAPI("POST", "/open-apis/drive/v1/medias/upload_finish", nil, map[string]interface{}{
|
||||
"upload_id": uploadID,
|
||||
"block_num": blockNum,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload finish failed: %s\n", err)
|
||||
return "", err
|
||||
}
|
||||
return extractDriveUploadFileToken(data, "upload media finish failed")
|
||||
}
|
||||
|
||||
func buildImportMediaExtra(filePath, docType string) (string, error) {
|
||||
extraBytes, err := json.Marshal(map[string]string{
|
||||
"obj_type": docType,
|
||||
"file_extension": strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), "."),
|
||||
})
|
||||
if err != nil {
|
||||
return "", output.Errorf(output.ExitInternal, "json_error", "build upload extra failed: %v", err)
|
||||
}
|
||||
return string(extraBytes), nil
|
||||
}
|
||||
|
||||
func driveImportFileSizeLimit(filePath, docType string) (int64, bool) {
|
||||
// Keep the limit mapping local to import flows so we do not widen behavior
|
||||
// changes beyond drive +import.
|
||||
switch strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".") {
|
||||
case "docx", "doc":
|
||||
return driveImport600MBFileSizeLimit, true
|
||||
case "txt", "md", "mark", "markdown", "html", "xls":
|
||||
return driveImport20MBFileSizeLimit, true
|
||||
case "xlsx":
|
||||
return driveImport800MBFileSizeLimit, true
|
||||
case "csv":
|
||||
if docType == "bitable" {
|
||||
return driveImport100MBFileSizeLimit, true
|
||||
}
|
||||
return driveImport20MBFileSizeLimit, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func validateDriveImportFileSize(filePath, docType string, fileSize int64) error {
|
||||
limit, ok := driveImportFileSizeLimit(filePath, docType)
|
||||
if !ok || fileSize <= limit {
|
||||
return nil
|
||||
}
|
||||
|
||||
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".")
|
||||
if ext == "csv" {
|
||||
// CSV is the only source format whose limit depends on the target type.
|
||||
return output.ErrValidation(
|
||||
"file %s exceeds %s import limit for .csv when importing as %s",
|
||||
common.FormatSize(fileSize),
|
||||
common.FormatSize(limit),
|
||||
docType,
|
||||
)
|
||||
}
|
||||
|
||||
return output.ErrValidation(
|
||||
"file %s exceeds %s import limit for .%s",
|
||||
common.FormatSize(fileSize),
|
||||
common.FormatSize(limit),
|
||||
ext,
|
||||
)
|
||||
}
|
||||
|
||||
func driveUploadSizeValue(fileSize int64) (int, error) {
|
||||
maxInt := int64(^uint(0) >> 1)
|
||||
if fileSize > maxInt {
|
||||
return 0, output.ErrValidation("file %s is too large to upload", common.FormatSize(fileSize))
|
||||
}
|
||||
return int(fileSize), nil
|
||||
}
|
||||
|
||||
func wrapDriveUploadRequestError(err error, action string) error {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return err
|
||||
}
|
||||
return output.ErrNetwork("%s: %v", action, err)
|
||||
}
|
||||
|
||||
func parseDriveUploadResponse(apiResp *larkcore.ApiResp, action string) (map[string]interface{}, error) {
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "%s: invalid response JSON: %v", action, err)
|
||||
}
|
||||
|
||||
if larkCode := int(common.GetFloat(result, "code")); larkCode != 0 {
|
||||
msg, _ := result["msg"].(string)
|
||||
return nil, output.ErrAPI(larkCode, fmt.Sprintf("%s: [%d] %s", action, larkCode, msg), result["error"])
|
||||
}
|
||||
|
||||
data, _ := result["data"].(map[string]interface{})
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func extractDriveUploadFileToken(data map[string]interface{}, action string) (string, error) {
|
||||
fileToken := common.GetString(data, "file_token")
|
||||
if fileToken == "" {
|
||||
return "", output.Errorf(output.ExitAPI, "api_error", "%s: no file_token returned", action)
|
||||
}
|
||||
return fileToken, nil
|
||||
}
|
||||
|
||||
// validateDriveImportSpec enforces the CLI-level compatibility rules before any
|
||||
// upload or import request is sent to the backend.
|
||||
func validateDriveImportSpec(spec driveImportSpec) error {
|
||||
ext := spec.FileExtension()
|
||||
if ext == "" {
|
||||
return output.ErrValidation("file must have an extension (e.g. .md, .docx, .xlsx)")
|
||||
}
|
||||
|
||||
switch spec.DocType {
|
||||
case "docx", "sheet", "bitable":
|
||||
default:
|
||||
return output.ErrValidation("unsupported target document type: %s. Supported types are: docx, sheet, bitable", spec.DocType)
|
||||
}
|
||||
|
||||
supportedTypes, ok := driveImportExtToDocTypes[ext]
|
||||
if !ok {
|
||||
return output.ErrValidation("unsupported file extension: %s. Supported extensions are: docx, doc, txt, md, mark, markdown, html, xlsx, xls, csv", ext)
|
||||
}
|
||||
|
||||
typeAllowed := false
|
||||
// Validate the extension/type pair locally so users get a precise error
|
||||
// before the file upload step.
|
||||
for _, allowedType := range supportedTypes {
|
||||
if allowedType == spec.DocType {
|
||||
typeAllowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !typeAllowed {
|
||||
var hint string
|
||||
switch ext {
|
||||
case "xlsx", "csv":
|
||||
hint = fmt.Sprintf(".%s files can only be imported as 'sheet' or 'bitable', not '%s'", ext, spec.DocType)
|
||||
case "xls":
|
||||
hint = fmt.Sprintf(".xls files can only be imported as 'sheet', not '%s'", spec.DocType)
|
||||
default:
|
||||
hint = fmt.Sprintf(".%s files can only be imported as 'docx', not '%s'", ext, spec.DocType)
|
||||
}
|
||||
return output.ErrValidation("file type mismatch: %s", hint)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(spec.FolderToken) != "" {
|
||||
if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// driveImportStatus captures the backend fields needed to decide whether the
|
||||
// import can be surfaced immediately or requires a follow-up poll.
|
||||
type driveImportStatus struct {
|
||||
Ticket string
|
||||
DocType string
|
||||
Token string
|
||||
URL string
|
||||
JobErrorMsg string
|
||||
Extra interface{}
|
||||
JobStatus int
|
||||
}
|
||||
|
||||
func (s driveImportStatus) Ready() bool {
|
||||
return s.Token != "" && s.JobStatus == 0
|
||||
}
|
||||
|
||||
func (s driveImportStatus) Pending() bool {
|
||||
return s.JobStatus == 1 || s.JobStatus == 2 || (s.JobStatus == 0 && s.Token == "")
|
||||
}
|
||||
|
||||
func (s driveImportStatus) Failed() bool {
|
||||
return !s.Ready() && !s.Pending() && s.JobStatus != 0
|
||||
}
|
||||
|
||||
func (s driveImportStatus) StatusLabel() string {
|
||||
switch s.JobStatus {
|
||||
case 0:
|
||||
// Some responses report status=0 before the imported token is materialized.
|
||||
// Treat that intermediate state as pending rather than completed.
|
||||
if s.Token == "" {
|
||||
return "pending"
|
||||
}
|
||||
return "success"
|
||||
case 1:
|
||||
return "new"
|
||||
case 2:
|
||||
return "processing"
|
||||
default:
|
||||
return fmt.Sprintf("status_%d", s.JobStatus)
|
||||
}
|
||||
}
|
||||
|
||||
// driveImportTaskResultCommand prints the resume command returned after bounded
|
||||
// polling times out locally.
|
||||
func driveImportTaskResultCommand(ticket string) string {
|
||||
return fmt.Sprintf("lark-cli drive +task_result --scenario import --ticket %s", ticket)
|
||||
}
|
||||
|
||||
// createDriveImportTask creates the server-side import task after the media
|
||||
// upload has produced a reusable file token.
|
||||
func createDriveImportTask(runtime *common.RuntimeContext, spec driveImportSpec, fileToken string) (string, error) {
|
||||
data, err := runtime.CallAPI("POST", "/open-apis/drive/v1/import_tasks", nil, spec.CreateTaskBody(fileToken))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
ticket := common.GetString(data, "ticket")
|
||||
if ticket == "" {
|
||||
return "", output.Errorf(output.ExitAPI, "api_error", "no ticket returned from import_tasks")
|
||||
}
|
||||
return ticket, nil
|
||||
}
|
||||
|
||||
// getDriveImportStatus fetches the current state of an import task by ticket.
|
||||
func getDriveImportStatus(runtime *common.RuntimeContext, ticket string) (driveImportStatus, error) {
|
||||
if err := validate.ResourceName(ticket, "--ticket"); err != nil {
|
||||
return driveImportStatus{}, output.ErrValidation("%s", err)
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI(
|
||||
"GET",
|
||||
fmt.Sprintf("/open-apis/drive/v1/import_tasks/%s", validate.EncodePathSegment(ticket)),
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return driveImportStatus{}, err
|
||||
}
|
||||
|
||||
return parseDriveImportStatus(ticket, data), nil
|
||||
}
|
||||
|
||||
// parseDriveImportStatus accepts either the wrapped API response or an already
|
||||
// extracted result object to keep the helper easy to test.
|
||||
func parseDriveImportStatus(ticket string, data map[string]interface{}) driveImportStatus {
|
||||
result := common.GetMap(data, "result")
|
||||
if result == nil {
|
||||
// Some tests and helper call sites already pass the unwrapped result body.
|
||||
result = data
|
||||
}
|
||||
|
||||
return driveImportStatus{
|
||||
Ticket: ticket,
|
||||
DocType: common.GetString(result, "type"),
|
||||
Token: common.GetString(result, "token"),
|
||||
URL: common.GetString(result, "url"),
|
||||
JobErrorMsg: common.GetString(result, "job_error_msg"),
|
||||
Extra: result["extra"],
|
||||
JobStatus: int(common.GetFloat(result, "job_status")),
|
||||
}
|
||||
}
|
||||
|
||||
// pollDriveImportTask waits for the import to finish within a bounded window
|
||||
// and returns the last observed status for resume-on-timeout flows.
|
||||
func pollDriveImportTask(runtime *common.RuntimeContext, ticket string) (driveImportStatus, bool, error) {
|
||||
lastStatus := driveImportStatus{Ticket: ticket}
|
||||
var lastErr error
|
||||
hadSuccessfulPoll := false
|
||||
for attempt := 1; attempt <= driveImportPollAttempts; attempt++ {
|
||||
if attempt > 1 {
|
||||
time.Sleep(driveImportPollInterval)
|
||||
}
|
||||
|
||||
status, err := getDriveImportStatus(runtime, ticket)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
// Log the error but continue polling.
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Import status attempt %d/%d failed: %v\n", attempt, driveImportPollAttempts, err)
|
||||
continue
|
||||
}
|
||||
lastStatus = status
|
||||
hadSuccessfulPoll = true
|
||||
|
||||
// Stop immediately on terminal states and otherwise return the last known
|
||||
// status so the caller can expose a follow-up command on timeout.
|
||||
if status.Ready() {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Import completed successfully.\n")
|
||||
return status, true, nil
|
||||
}
|
||||
if status.Failed() {
|
||||
msg := strings.TrimSpace(status.JobErrorMsg)
|
||||
if msg == "" {
|
||||
msg = status.StatusLabel()
|
||||
}
|
||||
return status, false, output.Errorf(output.ExitAPI, "api_error", "import failed with status %d: %s", status.JobStatus, msg)
|
||||
}
|
||||
}
|
||||
if !hadSuccessfulPoll && lastErr != nil {
|
||||
return lastStatus, false, lastErr
|
||||
}
|
||||
|
||||
return lastStatus, false, nil
|
||||
}
|
||||
639
shortcuts/drive/drive_import_common_test.go
Normal file
639
shortcuts/drive/drive_import_common_test.go
Normal file
@@ -0,0 +1,639 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestValidateDriveImportSpecRejectsMismatchedType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := validateDriveImportSpec(driveImportSpec{
|
||||
FilePath: "./data.xlsx",
|
||||
DocType: "docx",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "file type mismatch") {
|
||||
t.Fatalf("expected file type mismatch error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDriveImportSpecRejectsXlsBitable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := validateDriveImportSpec(driveImportSpec{
|
||||
FilePath: "./data.xls",
|
||||
DocType: "bitable",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), ".xls files can only be imported as 'sheet'") {
|
||||
t.Fatalf("expected xls-only-sheet validation error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDriveImportFileSize(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
filePath string
|
||||
docType string
|
||||
fileSize int64
|
||||
wantText string
|
||||
}{
|
||||
{
|
||||
name: "docx exceeds 600mb limit",
|
||||
filePath: "./report.docx",
|
||||
docType: "docx",
|
||||
fileSize: driveImport600MBFileSizeLimit + 1,
|
||||
wantText: "exceeds 600.0 MB import limit for .docx",
|
||||
},
|
||||
{
|
||||
name: "csv sheet exceeds 20mb limit",
|
||||
filePath: "./data.csv",
|
||||
docType: "sheet",
|
||||
fileSize: driveImport20MBFileSizeLimit + 1,
|
||||
wantText: "exceeds 20.0 MB import limit for .csv when importing as sheet",
|
||||
},
|
||||
{
|
||||
name: "csv bitable exceeds 100mb limit",
|
||||
filePath: "./data.csv",
|
||||
docType: "bitable",
|
||||
fileSize: driveImport100MBFileSizeLimit + 1,
|
||||
wantText: "exceeds 100.0 MB import limit for .csv when importing as bitable",
|
||||
},
|
||||
{
|
||||
name: "xlsx within 800mb limit",
|
||||
filePath: "./data.xlsx",
|
||||
docType: "sheet",
|
||||
fileSize: driveImport800MBFileSizeLimit,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := validateDriveImportFileSize(tt.filePath, tt.docType, tt.fileSize)
|
||||
if tt.wantText == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantText) {
|
||||
t.Fatalf("error = %v, want substring %q", err, tt.wantText)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDriveImportStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
status := parseDriveImportStatus("tk_123", map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"type": "sheet",
|
||||
"job_status": 0,
|
||||
"job_error_msg": "",
|
||||
"token": "sheet_123",
|
||||
"url": "https://example.com/sheets/sheet_123",
|
||||
"extra": []interface{}{"2000"},
|
||||
},
|
||||
})
|
||||
|
||||
if !status.Ready() {
|
||||
t.Fatal("expected import status to be ready")
|
||||
}
|
||||
if status.StatusLabel() != "success" {
|
||||
t.Fatalf("status label = %q, want %q", status.StatusLabel(), "success")
|
||||
}
|
||||
if status.Token != "sheet_123" {
|
||||
t.Fatalf("token = %q, want %q", status.Token, "sheet_123")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportStatusPendingWithoutToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
status := driveImportStatus{JobStatus: 0}
|
||||
if status.Ready() {
|
||||
t.Fatal("expected status without token to be not ready")
|
||||
}
|
||||
if !status.Pending() {
|
||||
t.Fatal("expected status without token to be pending")
|
||||
}
|
||||
if got := status.StatusLabel(); got != "pending" {
|
||||
t.Fatalf("StatusLabel() = %q, want %q", got, "pending")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportTimeoutReturnsFollowUpCommand(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"file_token": "file_123"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/import_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"ticket": "tk_import"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/import_tasks/tk_import",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"type": "sheet",
|
||||
"job_status": 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.WriteFile("data.xlsx", []byte("fake-xlsx"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
prevAttempts, prevInterval := driveImportPollAttempts, driveImportPollInterval
|
||||
driveImportPollAttempts, driveImportPollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveImportPollAttempts, driveImportPollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveImport, []string{
|
||||
"+import",
|
||||
"--file", "data.xlsx",
|
||||
"--type", "sheet",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"ready": false`)) {
|
||||
t.Fatalf("stdout missing ready=false: %s", stdout.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"timed_out": true`)) {
|
||||
t.Fatalf("stdout missing timed_out=true: %s", stdout.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"next_command": "lark-cli drive +task_result --scenario import --ticket tk_import"`)) {
|
||||
t.Fatalf("stdout missing follow-up command: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportUsesMultipartUploadForLargeFile(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
|
||||
prepareStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_prepare",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"upload_id": "upload_123",
|
||||
"block_size": 4 * 1024 * 1024,
|
||||
"block_num": 6,
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(prepareStub)
|
||||
|
||||
partStubs := make([]*httpmock.Stub, 0, 6)
|
||||
for i := 0; i < 6; i++ {
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_part",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
},
|
||||
}
|
||||
partStubs = append(partStubs, stub)
|
||||
reg.Register(stub)
|
||||
}
|
||||
|
||||
finishStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_finish",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"file_token": "file_123",
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(finishStub)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/import_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"ticket": "tk_import"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/import_tasks/tk_import",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"type": "sheet",
|
||||
"job_status": 0,
|
||||
"token": "sheet_123",
|
||||
"url": "https://example.com/sheets/sheet_123",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
writeSizedDriveImportFile(t, "large.xlsx", int64(maxDriveUploadFileSize)+1)
|
||||
|
||||
err := mountAndRunDrive(t, DriveImport, []string{
|
||||
"+import",
|
||||
"--file", "large.xlsx",
|
||||
"--type", "sheet",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"token": "sheet_123"`)) {
|
||||
t.Fatalf("stdout missing imported token: %s", stdout.String())
|
||||
}
|
||||
|
||||
prepareBody := decodeCapturedJSONBody(t, prepareStub)
|
||||
if got, _ := prepareBody["parent_type"].(string); got != "ccm_import_open" {
|
||||
t.Fatalf("prepare parent_type = %q, want %q", got, "ccm_import_open")
|
||||
}
|
||||
if got, _ := prepareBody["file_name"].(string); got != "large.xlsx" {
|
||||
t.Fatalf("prepare file_name = %q, want %q", got, "large.xlsx")
|
||||
}
|
||||
if got, _ := prepareBody["size"].(float64); got != float64(maxDriveUploadFileSize+1) {
|
||||
t.Fatalf("prepare size = %v, want %d", got, maxDriveUploadFileSize+1)
|
||||
}
|
||||
|
||||
firstPart := decodeCapturedMultipartBody(t, partStubs[0])
|
||||
if got := firstPart.Fields["upload_id"]; got != "upload_123" {
|
||||
t.Fatalf("first part upload_id = %q, want %q", got, "upload_123")
|
||||
}
|
||||
if got := firstPart.Fields["seq"]; got != "0" {
|
||||
t.Fatalf("first part seq = %q, want %q", got, "0")
|
||||
}
|
||||
if got := firstPart.Fields["size"]; got != "4194304" {
|
||||
t.Fatalf("first part size = %q, want %q", got, "4194304")
|
||||
}
|
||||
if got := len(firstPart.Files["file"]); got != 4*1024*1024 {
|
||||
t.Fatalf("first part file size = %d, want %d", got, 4*1024*1024)
|
||||
}
|
||||
|
||||
lastPart := decodeCapturedMultipartBody(t, partStubs[len(partStubs)-1])
|
||||
if got := lastPart.Fields["seq"]; got != "5" {
|
||||
t.Fatalf("last part seq = %q, want %q", got, "5")
|
||||
}
|
||||
if got := lastPart.Fields["size"]; got != "1" {
|
||||
t.Fatalf("last part size = %q, want %q", got, "1")
|
||||
}
|
||||
if got := len(lastPart.Files["file"]); got != 1 {
|
||||
t.Fatalf("last part file size = %d, want %d", got, 1)
|
||||
}
|
||||
|
||||
finishBody := decodeCapturedJSONBody(t, finishStub)
|
||||
if got, _ := finishBody["upload_id"].(string); got != "upload_123" {
|
||||
t.Fatalf("finish upload_id = %q, want %q", got, "upload_123")
|
||||
}
|
||||
if got, _ := finishBody["block_num"].(float64); got != 6 {
|
||||
t.Fatalf("finish block_num = %v, want %d", got, 6)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportMultipartPrepareValidatesResponseFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data map[string]interface{}
|
||||
wantText string
|
||||
}{
|
||||
{
|
||||
name: "missing upload id",
|
||||
data: map[string]interface{}{
|
||||
"block_size": 4 * 1024 * 1024,
|
||||
"block_num": 6,
|
||||
},
|
||||
wantText: "upload prepare failed: no upload_id returned",
|
||||
},
|
||||
{
|
||||
name: "missing block size",
|
||||
data: map[string]interface{}{
|
||||
"upload_id": "upload_123",
|
||||
"block_num": 6,
|
||||
},
|
||||
wantText: "upload prepare failed: invalid block_size returned",
|
||||
},
|
||||
{
|
||||
name: "missing block num",
|
||||
data: map[string]interface{}{
|
||||
"upload_id": "upload_123",
|
||||
"block_size": 4 * 1024 * 1024,
|
||||
},
|
||||
wantText: "upload prepare failed: invalid block_num returned",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_prepare",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": tt.data,
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
writeSizedDriveImportFile(t, "large.xlsx", int64(maxDriveUploadFileSize)+1)
|
||||
|
||||
err := mountAndRunDrive(t, DriveImport, []string{
|
||||
"+import",
|
||||
"--file", "large.xlsx",
|
||||
"--type", "sheet",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantText) {
|
||||
t.Fatalf("error = %v, want substring %q", err, tt.wantText)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportMultipartUploadPartAPIFailure(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_prepare",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"upload_id": "upload_123",
|
||||
"block_size": 4 * 1024 * 1024,
|
||||
"block_num": 6,
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_part",
|
||||
Body: map[string]interface{}{
|
||||
"code": 999,
|
||||
"msg": "chunk rejected",
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
writeSizedDriveImportFile(t, "large.xlsx", int64(maxDriveUploadFileSize)+1)
|
||||
|
||||
err := mountAndRunDrive(t, DriveImport, []string{
|
||||
"+import",
|
||||
"--file", "large.xlsx",
|
||||
"--type", "sheet",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "upload media part failed: [999] chunk rejected") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportMultipartFinishRequiresFileToken(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_prepare",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"upload_id": "upload_123",
|
||||
"block_size": 4 * 1024 * 1024,
|
||||
"block_num": 6,
|
||||
},
|
||||
},
|
||||
})
|
||||
for i := 0; i < 6; i++ {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_part",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
},
|
||||
})
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_finish",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
writeSizedDriveImportFile(t, "large.xlsx", int64(maxDriveUploadFileSize)+1)
|
||||
|
||||
err := mountAndRunDrive(t, DriveImport, []string{
|
||||
"+import",
|
||||
"--file", "large.xlsx",
|
||||
"--type", "sheet",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "upload media finish failed: no file_token returned") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportRejectsOversizedFileByImportLimit(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
writeSizedDriveImportFile(t, "too-large.csv", driveImport100MBFileSizeLimit+1)
|
||||
|
||||
err := mountAndRunDrive(t, DriveImport, []string{
|
||||
"+import",
|
||||
"--file", "too-large.csv",
|
||||
"--type", "bitable",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected size limit error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "exceeds 100.0 MB import limit for .csv when importing as bitable") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDriveUploadResponseErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("invalid json", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseDriveUploadResponse(&larkcore.ApiResp{RawBody: []byte("{")}, "upload media failed")
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid response JSON") {
|
||||
t.Fatalf("expected invalid JSON error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("api code error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseDriveUploadResponse(&larkcore.ApiResp{RawBody: []byte(`{"code":999,"msg":"boom","error":{"detail":"x"}}`)}, "upload media failed")
|
||||
if err == nil || !strings.Contains(err.Error(), "upload media failed: [999] boom") {
|
||||
t.Fatalf("expected API error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWrapDriveUploadRequestError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("preserves exit error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
original := output.ErrValidation("bad input")
|
||||
got := wrapDriveUploadRequestError(original, "upload media failed")
|
||||
if got != original {
|
||||
t.Fatalf("expected same exit error pointer, got %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wraps generic error as network", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := wrapDriveUploadRequestError(io.EOF, "upload media failed")
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(got, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %T", got)
|
||||
}
|
||||
if exitErr.Code != output.ExitNetwork {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitNetwork)
|
||||
}
|
||||
if !strings.Contains(got.Error(), "upload media failed") {
|
||||
t.Fatalf("unexpected error: %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type capturedMultipartBody struct {
|
||||
Fields map[string]string
|
||||
Files map[string][]byte
|
||||
}
|
||||
|
||||
func decodeCapturedJSONBody(t *testing.T, stub *httpmock.Stub) map[string]interface{} {
|
||||
t.Helper()
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("decode captured JSON body: %v", err)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func writeSizedDriveImportFile(t *testing.T, name string, size int64) {
|
||||
t.Helper()
|
||||
|
||||
fh, err := os.Create(name)
|
||||
if err != nil {
|
||||
t.Fatalf("Create(%q) error: %v", name, err)
|
||||
}
|
||||
if err := fh.Truncate(size); err != nil {
|
||||
t.Fatalf("Truncate(%q) error: %v", name, err)
|
||||
}
|
||||
if err := fh.Close(); err != nil {
|
||||
t.Fatalf("Close(%q) error: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeCapturedMultipartBody(t *testing.T, stub *httpmock.Stub) capturedMultipartBody {
|
||||
t.Helper()
|
||||
|
||||
contentType := stub.CapturedHeaders.Get("Content-Type")
|
||||
mediaType, params, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
t.Fatalf("parse multipart content type: %v", err)
|
||||
}
|
||||
if mediaType != "multipart/form-data" {
|
||||
t.Fatalf("content type = %q, want multipart/form-data", mediaType)
|
||||
}
|
||||
|
||||
reader := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
|
||||
body := capturedMultipartBody{
|
||||
Fields: map[string]string{},
|
||||
Files: map[string][]byte{},
|
||||
}
|
||||
for {
|
||||
part, err := reader.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("read multipart part: %v", err)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(part)
|
||||
if err != nil {
|
||||
t.Fatalf("read multipart data: %v", err)
|
||||
}
|
||||
if part.FileName() != "" {
|
||||
body.Files[part.FormName()] = data
|
||||
continue
|
||||
}
|
||||
body.Fields[part.FormName()] = string(data)
|
||||
}
|
||||
return body
|
||||
}
|
||||
363
shortcuts/drive/drive_import_test.go
Normal file
363
shortcuts/drive/drive_import_test.go
Normal file
@@ -0,0 +1,363 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestImportDefaultFileName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
filePath string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "strip xlsx extension",
|
||||
filePath: "/tmp/base-import.xlsx",
|
||||
want: "base-import",
|
||||
},
|
||||
{
|
||||
name: "strip last extension only",
|
||||
filePath: "/tmp/report.final.csv",
|
||||
want: "report.final",
|
||||
},
|
||||
{
|
||||
name: "keep name without extension",
|
||||
filePath: "/tmp/README",
|
||||
want: "README",
|
||||
},
|
||||
{
|
||||
name: "keep hidden file name when trim would be empty",
|
||||
filePath: "/tmp/.env",
|
||||
want: ".env",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := importDefaultFileName(tt.filePath); got != tt.want {
|
||||
t.Fatalf("importDefaultFileName(%q) = %q, want %q", tt.filePath, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportTargetFileName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := importTargetFileName("/tmp/base-import.xlsx", "custom-name.xlsx"); got != "custom-name.xlsx" {
|
||||
t.Fatalf("explicit name should win, got %q", got)
|
||||
}
|
||||
if got := importTargetFileName("/tmp/base-import.xlsx", ""); got != "base-import" {
|
||||
t.Fatalf("default import name = %q, want %q", got, "base-import")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportDryRunUsesExtensionlessDefaultName(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
if err := os.WriteFile("base-import.xlsx", []byte("fake-xlsx"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +import"}
|
||||
cmd.Flags().String("file", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("folder-token", "", "")
|
||||
cmd.Flags().String("name", "", "")
|
||||
if err := cmd.Flags().Set("file", "./base-import.xlsx"); err != nil {
|
||||
t.Fatalf("set --file: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("type", "bitable"); err != nil {
|
||||
t.Fatalf("set --type: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("folder-token", "fld_test"); err != nil {
|
||||
t.Fatalf("set --folder-token: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
|
||||
dry := DriveImport.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run json: %v", err)
|
||||
}
|
||||
if len(got.API) != 3 {
|
||||
t.Fatalf("expected 3 API calls, got %d", len(got.API))
|
||||
}
|
||||
|
||||
uploadName, _ := got.API[0].Body["file_name"].(string)
|
||||
if uploadName != "base-import.xlsx" {
|
||||
t.Fatalf("upload file_name = %q, want %q", uploadName, "base-import.xlsx")
|
||||
}
|
||||
|
||||
importName, _ := got.API[1].Body["file_name"].(string)
|
||||
if importName != "base-import" {
|
||||
t.Fatalf("import task file_name = %q, want %q", importName, "base-import")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportDryRunShowsMultipartUploadForLargeFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
fh, err := os.Create("large.xlsx")
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error: %v", err)
|
||||
}
|
||||
if err := fh.Truncate(int64(maxDriveUploadFileSize) + 1); err != nil {
|
||||
t.Fatalf("Truncate() error: %v", err)
|
||||
}
|
||||
if err := fh.Close(); err != nil {
|
||||
t.Fatalf("Close() error: %v", err)
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +import"}
|
||||
cmd.Flags().String("file", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("folder-token", "", "")
|
||||
cmd.Flags().String("name", "", "")
|
||||
if err := cmd.Flags().Set("file", "./large.xlsx"); err != nil {
|
||||
t.Fatalf("set --file: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("type", "sheet"); err != nil {
|
||||
t.Fatalf("set --type: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
|
||||
dry := DriveImport.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run json: %v", err)
|
||||
}
|
||||
if len(got.API) != 5 {
|
||||
t.Fatalf("expected 5 API calls, got %d", len(got.API))
|
||||
}
|
||||
if got.API[0].URL != "/open-apis/drive/v1/medias/upload_prepare" {
|
||||
t.Fatalf("dry-run first URL = %q, want upload_prepare", got.API[0].URL)
|
||||
}
|
||||
if got.API[1].URL != "/open-apis/drive/v1/medias/upload_part" {
|
||||
t.Fatalf("dry-run second URL = %q, want upload_part", got.API[1].URL)
|
||||
}
|
||||
if got.API[2].URL != "/open-apis/drive/v1/medias/upload_finish" {
|
||||
t.Fatalf("dry-run third URL = %q, want upload_finish", got.API[2].URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportDryRunReturnsErrorForUnsafePath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +import"}
|
||||
cmd.Flags().String("file", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("folder-token", "", "")
|
||||
cmd.Flags().String("name", "", "")
|
||||
if err := cmd.Flags().Set("file", "../outside.md"); err != nil {
|
||||
t.Fatalf("set --file: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("type", "docx"); err != nil {
|
||||
t.Fatalf("set --type: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, nil)
|
||||
dry := DriveImport.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct{} `json:"api"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run json: %v", err)
|
||||
}
|
||||
if got.Error == "" || !strings.Contains(got.Error, "unsafe file path") {
|
||||
t.Fatalf("dry-run error = %q, want unsafe file path error", got.Error)
|
||||
}
|
||||
if len(got.API) != 0 {
|
||||
t.Fatalf("expected no API calls when preflight fails, got %d", len(got.API))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportDryRunReturnsErrorForOversizedMarkdown(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
fh, err := os.Create("large.md")
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error: %v", err)
|
||||
}
|
||||
if err := fh.Truncate(driveImport20MBFileSizeLimit + 5*1024*1024); err != nil {
|
||||
t.Fatalf("Truncate() error: %v", err)
|
||||
}
|
||||
if err := fh.Close(); err != nil {
|
||||
t.Fatalf("Close() error: %v", err)
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +import"}
|
||||
cmd.Flags().String("file", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("folder-token", "", "")
|
||||
cmd.Flags().String("name", "", "")
|
||||
if err := cmd.Flags().Set("file", "./large.md"); err != nil {
|
||||
t.Fatalf("set --file: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("type", "docx"); err != nil {
|
||||
t.Fatalf("set --type: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
|
||||
dry := DriveImport.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct{} `json:"api"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run json: %v", err)
|
||||
}
|
||||
if got.Error == "" || !strings.Contains(got.Error, "exceeds 20.0 MB import limit for .md") {
|
||||
t.Fatalf("dry-run error = %q, want oversized markdown error", got.Error)
|
||||
}
|
||||
if len(got.API) != 0 {
|
||||
t.Fatalf("expected no API calls when size preflight fails, got %d", len(got.API))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportDryRunReturnsErrorForDirectoryInput(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
if err := os.Mkdir("folder-input", 0755); err != nil {
|
||||
t.Fatalf("Mkdir() error: %v", err)
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +import"}
|
||||
cmd.Flags().String("file", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("folder-token", "", "")
|
||||
cmd.Flags().String("name", "", "")
|
||||
if err := cmd.Flags().Set("file", "./folder-input"); err != nil {
|
||||
t.Fatalf("set --file: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("type", "docx"); err != nil {
|
||||
t.Fatalf("set --type: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
|
||||
dry := DriveImport.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct{} `json:"api"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run json: %v", err)
|
||||
}
|
||||
if got.Error == "" || !strings.Contains(got.Error, "file must be a regular file") {
|
||||
t.Fatalf("dry-run error = %q, want regular file error", got.Error)
|
||||
}
|
||||
if len(got.API) != 0 {
|
||||
t.Fatalf("expected no API calls when file type preflight fails, got %d", len(got.API))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportCreateTaskBodyKeepsEmptyMountKeyForRoot(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
spec := driveImportSpec{
|
||||
FilePath: "/tmp/README.md",
|
||||
DocType: "docx",
|
||||
}
|
||||
|
||||
body := spec.CreateTaskBody("file_token_test")
|
||||
point, ok := body["point"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("point = %#v, want map", body["point"])
|
||||
}
|
||||
|
||||
raw, exists := point["mount_key"]
|
||||
if !exists {
|
||||
t.Fatal("mount_key missing; want empty string for root import")
|
||||
}
|
||||
got, ok := raw.(string)
|
||||
if !ok {
|
||||
t.Fatalf("mount_key type = %T, want string", raw)
|
||||
}
|
||||
if got != "" {
|
||||
t.Fatalf("mount_key = %q, want empty string for root import", got)
|
||||
}
|
||||
|
||||
spec.FolderToken = "fld_test"
|
||||
body = spec.CreateTaskBody("file_token_test")
|
||||
point, ok = body["point"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("point = %#v, want map", body["point"])
|
||||
}
|
||||
if got, _ := point["mount_key"].(string); got != "fld_test" {
|
||||
t.Fatalf("mount_key = %q, want %q", got, "fld_test")
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,11 @@ package drive
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -18,9 +20,11 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var driveTestConfigSeq atomic.Int64
|
||||
|
||||
func driveTestConfig() *core.CliConfig {
|
||||
return &core.CliConfig{
|
||||
AppID: "drive-test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
AppID: fmt.Sprintf("drive-test-app-%d", driveTestConfigSeq.Add(1)), AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
153
shortcuts/drive/drive_move.go
Normal file
153
shortcuts/drive/drive_move.go
Normal file
@@ -0,0 +1,153 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// DriveMove moves a Drive file or folder and handles the async task polling
|
||||
// required by folder moves.
|
||||
var DriveMove = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+move",
|
||||
Description: "Move a file or folder to another location in Drive",
|
||||
Risk: "write",
|
||||
Scopes: []string{"space:document:move"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "file-token", Desc: "file or folder token to move", Required: true},
|
||||
{Name: "type", Desc: "file type (file, docx, bitable, doc, sheet, mindnote, folder, slides)", Required: true},
|
||||
{Name: "folder-token", Desc: "target folder token (default: root folder)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateDriveMoveSpec(driveMoveSpec{
|
||||
FileToken: runtime.Str("file-token"),
|
||||
FileType: strings.ToLower(runtime.Str("type")),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
})
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec := driveMoveSpec{
|
||||
FileToken: runtime.Str("file-token"),
|
||||
FileType: strings.ToLower(runtime.Str("type")),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
}
|
||||
|
||||
dry := common.NewDryRunAPI().
|
||||
Desc("Move file or folder in Drive")
|
||||
|
||||
dry.POST("/open-apis/drive/v1/files/:file_token/move").
|
||||
Desc("[1] Move file/folder").
|
||||
Set("file_token", spec.FileToken).
|
||||
Body(spec.RequestBody())
|
||||
|
||||
// If moving a folder, show the async task check step
|
||||
if spec.FileType == "folder" {
|
||||
dry.GET("/open-apis/drive/v1/files/task_check").
|
||||
Desc("[2] Poll async task status (for folder move)").
|
||||
Params(driveTaskCheckParams("<task_id>"))
|
||||
}
|
||||
|
||||
return dry
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := driveMoveSpec{
|
||||
FileToken: runtime.Str("file-token"),
|
||||
FileType: strings.ToLower(runtime.Str("type")),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
}
|
||||
|
||||
// Default to the caller's root folder so the command can move items
|
||||
// without requiring an explicit destination in common cases.
|
||||
if spec.FolderToken == "" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "No target folder specified, getting root folder...\n")
|
||||
rootToken, err := getRootFolderToken(ctx, runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rootToken == "" {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "get root folder token failed, root folder is empty")
|
||||
}
|
||||
spec.FolderToken = rootToken
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Moving %s %s to folder %s...\n", spec.FileType, common.MaskToken(spec.FileToken), common.MaskToken(spec.FolderToken))
|
||||
|
||||
data, err := runtime.CallAPI(
|
||||
"POST",
|
||||
fmt.Sprintf("/open-apis/drive/v1/files/%s/move", validate.EncodePathSegment(spec.FileToken)),
|
||||
nil,
|
||||
spec.RequestBody(),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Folder moves are asynchronous; file moves complete in the initial call.
|
||||
if spec.FileType == "folder" {
|
||||
taskID := common.GetString(data, "task_id")
|
||||
if taskID == "" {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "move folder returned no task_id")
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Folder move is async, polling task %s...\n", taskID)
|
||||
|
||||
status, ready, err := pollDriveTaskCheck(runtime, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Include both the source and destination identifiers so a timed-out
|
||||
// folder move can be resumed or inspected without reconstructing inputs.
|
||||
out := map[string]interface{}{
|
||||
"task_id": taskID,
|
||||
"status": status.StatusLabel(),
|
||||
"file_token": spec.FileToken,
|
||||
"folder_token": spec.FolderToken,
|
||||
"ready": ready,
|
||||
}
|
||||
if !ready {
|
||||
nextCommand := driveTaskCheckResultCommand(taskID)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Folder move task is still in progress. Continue with: %s\n", nextCommand)
|
||||
out["timed_out"] = true
|
||||
out["next_command"] = nextCommand
|
||||
}
|
||||
|
||||
runtime.Out(out, nil)
|
||||
} else {
|
||||
// Non-folder moves are synchronous, so the initial request is the final
|
||||
// outcome and no follow-up task metadata is needed.
|
||||
runtime.Out(map[string]interface{}{
|
||||
"file_token": spec.FileToken,
|
||||
"folder_token": spec.FolderToken,
|
||||
"type": spec.FileType,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// getRootFolderToken resolves the caller's Drive root folder token so other
|
||||
// commands can safely use it as a default destination.
|
||||
func getRootFolderToken(ctx context.Context, runtime *common.RuntimeContext) (string, error) {
|
||||
data, err := runtime.CallAPI("GET", "/open-apis/drive/explorer/v2/root_folder/meta", nil, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
token := common.GetString(data, "token")
|
||||
if token == "" {
|
||||
return "", output.Errorf(output.ExitAPI, "api_error", "root_folder/meta returned no token")
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
160
shortcuts/drive/drive_move_common.go
Normal file
160
shortcuts/drive/drive_move_common.go
Normal file
@@ -0,0 +1,160 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var (
|
||||
driveMovePollAttempts = 30
|
||||
driveMovePollInterval = 2 * time.Second
|
||||
)
|
||||
|
||||
// driveMoveAllowedTypes mirrors the document kinds accepted by the Drive move
|
||||
// endpoint that this shortcut wraps.
|
||||
var driveMoveAllowedTypes = map[string]bool{
|
||||
"file": true,
|
||||
"docx": true,
|
||||
"bitable": true,
|
||||
"doc": true,
|
||||
"sheet": true,
|
||||
"mindnote": true,
|
||||
"folder": true,
|
||||
"slides": true,
|
||||
}
|
||||
|
||||
// driveMoveSpec contains the normalized input needed to issue a move request.
|
||||
type driveMoveSpec struct {
|
||||
FileToken string
|
||||
FileType string
|
||||
FolderToken string
|
||||
}
|
||||
|
||||
func (s driveMoveSpec) RequestBody() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"type": s.FileType,
|
||||
"folder_token": s.FolderToken,
|
||||
}
|
||||
}
|
||||
|
||||
func validateDriveMoveSpec(spec driveMoveSpec) error {
|
||||
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
if strings.TrimSpace(spec.FolderToken) != "" {
|
||||
if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
}
|
||||
if !driveMoveAllowedTypes[spec.FileType] {
|
||||
return output.ErrValidation("unsupported file type: %s. Supported types: file, docx, bitable, doc, sheet, mindnote, folder, slides", spec.FileType)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// driveTaskCheckStatus represents the status payload returned by
|
||||
// /drive/v1/files/task_check for async folder operations.
|
||||
type driveTaskCheckStatus struct {
|
||||
TaskID string
|
||||
Status string
|
||||
}
|
||||
|
||||
func (s driveTaskCheckStatus) Ready() bool {
|
||||
return strings.EqualFold(strings.TrimSpace(s.Status), "success")
|
||||
}
|
||||
|
||||
func (s driveTaskCheckStatus) Failed() bool {
|
||||
return strings.EqualFold(strings.TrimSpace(s.Status), "failed")
|
||||
}
|
||||
|
||||
func (s driveTaskCheckStatus) Pending() bool {
|
||||
return !s.Ready() && !s.Failed()
|
||||
}
|
||||
|
||||
func (s driveTaskCheckStatus) StatusLabel() string {
|
||||
status := strings.TrimSpace(s.Status)
|
||||
if status == "" {
|
||||
// Empty status is treated as unknown so callers can still render a
|
||||
// meaningful label instead of an empty string.
|
||||
return "unknown"
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
// driveTaskCheckResultCommand prints the resume command shown when bounded
|
||||
// polling ends before the backend task completes.
|
||||
func driveTaskCheckResultCommand(taskID string) string {
|
||||
return fmt.Sprintf("lark-cli drive +task_result --scenario task_check --task-id %s", taskID)
|
||||
}
|
||||
|
||||
// driveTaskCheckParams keeps the task_check query parameter shape in one place
|
||||
// for both dry-run and execution paths.
|
||||
func driveTaskCheckParams(taskID string) map[string]interface{} {
|
||||
return map[string]interface{}{"task_id": taskID}
|
||||
}
|
||||
|
||||
// getDriveTaskCheckStatus fetches and validates the current state of an async
|
||||
// folder move or delete task.
|
||||
func getDriveTaskCheckStatus(runtime *common.RuntimeContext, taskID string) (driveTaskCheckStatus, error) {
|
||||
if err := validate.ResourceName(taskID, "--task-id"); err != nil {
|
||||
return driveTaskCheckStatus{}, output.ErrValidation("%s", err)
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("GET", "/open-apis/drive/v1/files/task_check", driveTaskCheckParams(taskID), nil)
|
||||
if err != nil {
|
||||
return driveTaskCheckStatus{}, err
|
||||
}
|
||||
|
||||
return parseDriveTaskCheckStatus(taskID, data), nil
|
||||
}
|
||||
|
||||
// parseDriveTaskCheckStatus tolerates both wrapped and already-unwrapped
|
||||
// response shapes used in tests and helpers.
|
||||
func parseDriveTaskCheckStatus(taskID string, data map[string]interface{}) driveTaskCheckStatus {
|
||||
result := common.GetMap(data, "result")
|
||||
if result == nil {
|
||||
result = data
|
||||
}
|
||||
|
||||
return driveTaskCheckStatus{
|
||||
TaskID: taskID,
|
||||
Status: common.GetString(result, "status"),
|
||||
}
|
||||
}
|
||||
|
||||
// pollDriveTaskCheck polls the backend for a bounded period and returns the
|
||||
// last seen status so callers can emit a follow-up command when needed.
|
||||
func pollDriveTaskCheck(runtime *common.RuntimeContext, taskID string) (driveTaskCheckStatus, bool, error) {
|
||||
lastStatus := driveTaskCheckStatus{TaskID: taskID}
|
||||
for attempt := 1; attempt <= driveMovePollAttempts; attempt++ {
|
||||
if attempt > 1 {
|
||||
time.Sleep(driveMovePollInterval)
|
||||
}
|
||||
|
||||
status, err := getDriveTaskCheckStatus(runtime, taskID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Error polling task %s: %s\n", taskID, err)
|
||||
continue
|
||||
}
|
||||
lastStatus = status
|
||||
// Success and failure are terminal backend states. Any other value is kept
|
||||
// as pending so the caller can decide whether to continue or resume later.
|
||||
if status.Ready() {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Folder move completed successfully.\n")
|
||||
return status, true, nil
|
||||
}
|
||||
if status.Failed() {
|
||||
return status, false, output.Errorf(output.ExitAPI, "api_error", "folder move task failed")
|
||||
}
|
||||
}
|
||||
|
||||
return lastStatus, false, nil
|
||||
}
|
||||
194
shortcuts/drive/drive_move_common_test.go
Normal file
194
shortcuts/drive/drive_move_common_test.go
Normal file
@@ -0,0 +1,194 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestParseDriveTaskCheckStatusFallback(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
status := parseDriveTaskCheckStatus("task_123", map[string]interface{}{
|
||||
"status": "success",
|
||||
})
|
||||
|
||||
if !status.Ready() {
|
||||
t.Fatal("expected task check status to be ready")
|
||||
}
|
||||
if status.StatusLabel() != "success" {
|
||||
t.Fatalf("status label = %q, want %q", status.StatusLabel(), "success")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveTaskCheckStatusPendingAndUnknownLabel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
status := driveTaskCheckStatus{}
|
||||
if !status.Pending() {
|
||||
t.Fatal("expected empty status to be treated as pending")
|
||||
}
|
||||
if got := status.StatusLabel(); got != "unknown" {
|
||||
t.Fatalf("StatusLabel() = %q, want %q", got, "unknown")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDriveMoveSpecRejectsUnsupportedType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := validateDriveMoveSpec(driveMoveSpec{
|
||||
FileToken: "file_token_test",
|
||||
FileType: "unsupported_type",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected unsupported type error, got nil")
|
||||
}
|
||||
if got := err.Error(); !bytes.Contains([]byte(got), []byte("unsupported file type")) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMoveDryRunFolderIncludesTaskCheckParams(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +move"}
|
||||
cmd.Flags().String("file-token", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("folder-token", "", "")
|
||||
if err := cmd.Flags().Set("file-token", "fld_src"); err != nil {
|
||||
t.Fatalf("set --file-token: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("type", "folder"); err != nil {
|
||||
t.Fatalf("set --type: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("folder-token", "fld_dst"); err != nil {
|
||||
t.Fatalf("set --folder-token: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, nil)
|
||||
dry := DriveMove.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run json: %v", err)
|
||||
}
|
||||
if len(got.API) != 2 {
|
||||
t.Fatalf("expected 2 API calls, got %d", len(got.API))
|
||||
}
|
||||
if got.API[1].Params["task_id"] != "<task_id>" {
|
||||
t.Fatalf("task check params = %#v", got.API[1].Params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMoveFolderSuccessUsesTaskCheckHelper(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/fld_src/move",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"task_id": "task_123"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/task_check",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"status": "success"},
|
||||
},
|
||||
})
|
||||
|
||||
prevAttempts, prevInterval := driveMovePollAttempts, driveMovePollInterval
|
||||
driveMovePollAttempts, driveMovePollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveMovePollAttempts, driveMovePollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveMove, []string{
|
||||
"+move",
|
||||
"--file-token", "fld_src",
|
||||
"--type", "folder",
|
||||
"--folder-token", "fld_dst",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"task_id": "task_123"`)) {
|
||||
t.Fatalf("stdout missing task id: %s", stdout.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"ready": true`)) {
|
||||
t.Fatalf("stdout missing ready=true: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMoveFolderTimeoutReturnsFollowUpCommand(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/fld_src/move",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"task_id": "task_123"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/task_check",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"status": "pending"},
|
||||
},
|
||||
})
|
||||
|
||||
prevAttempts, prevInterval := driveMovePollAttempts, driveMovePollInterval
|
||||
driveMovePollAttempts, driveMovePollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveMovePollAttempts, driveMovePollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveMove, []string{
|
||||
"+move",
|
||||
"--file-token", "fld_src",
|
||||
"--type", "folder",
|
||||
"--folder-token", "fld_dst",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"ready": false`)) {
|
||||
t.Fatalf("stdout missing ready=false: %s", stdout.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"timed_out": true`)) {
|
||||
t.Fatalf("stdout missing timed_out=true: %s", stdout.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"next_command": "lark-cli drive +task_result --scenario task_check --task-id task_123"`)) {
|
||||
t.Fatalf("stdout missing follow-up command: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
77
shortcuts/drive/drive_move_test.go
Normal file
77
shortcuts/drive/drive_move_test.go
Normal file
@@ -0,0 +1,77 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestDriveMoveUsesRootFolderWhenFolderTokenMissing(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/explorer/v2/root_folder/meta",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"token": "folder_root_token_test",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/file_token_test/move",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveMove, []string{
|
||||
"+move",
|
||||
"--file-token", "file_token_test",
|
||||
"--type", "file",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"folder_token": "folder_root_token_test"`) {
|
||||
t.Fatalf("stdout missing resolved root folder token: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"file_token": "file_token_test"`) {
|
||||
t.Fatalf("stdout missing file token: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMoveRootFolderLookupRequiresToken(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/explorer/v2/root_folder/meta",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveMove, []string{
|
||||
"+move",
|
||||
"--file-token", "file_token_test",
|
||||
"--type", "file",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected missing root folder token error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "root_folder/meta returned no token") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
190
shortcuts/drive/drive_task_result.go
Normal file
190
shortcuts/drive/drive_task_result.go
Normal file
@@ -0,0 +1,190 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// DriveTaskResult exposes a unified read path for the async task types produced
|
||||
// by Drive import, export, and folder move flows.
|
||||
var DriveTaskResult = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+task_result",
|
||||
Description: "Poll async task result for import, export, move, or delete operations",
|
||||
Risk: "read",
|
||||
Scopes: []string{"drive:drive.metadata:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "ticket", Desc: "async task ticket (for import/export tasks)", Required: false},
|
||||
{Name: "task-id", Desc: "async task ID (for move/delete folder tasks)", Required: false},
|
||||
{Name: "scenario", Desc: "task scenario: import, export, or task_check", Required: true},
|
||||
{Name: "file-token", Desc: "source document token used for export task status lookup", Required: false},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
scenario := strings.ToLower(runtime.Str("scenario"))
|
||||
validScenarios := map[string]bool{
|
||||
"import": true,
|
||||
"export": true,
|
||||
"task_check": true,
|
||||
}
|
||||
if !validScenarios[scenario] {
|
||||
return output.ErrValidation("unsupported scenario: %s. Supported scenarios: import, export, task_check", scenario)
|
||||
}
|
||||
|
||||
// Validate required params based on scenario
|
||||
switch scenario {
|
||||
case "import", "export":
|
||||
if runtime.Str("ticket") == "" {
|
||||
return output.ErrValidation("--ticket is required for %s scenario", scenario)
|
||||
}
|
||||
if err := validate.ResourceName(runtime.Str("ticket"), "--ticket"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
case "task_check":
|
||||
if runtime.Str("task-id") == "" {
|
||||
return output.ErrValidation("--task-id is required for task_check scenario")
|
||||
}
|
||||
if err := validate.ResourceName(runtime.Str("task-id"), "--task-id"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// For export scenario, file-token is required
|
||||
if scenario == "export" && runtime.Str("file-token") == "" {
|
||||
return output.ErrValidation("--file-token is required for export scenario")
|
||||
}
|
||||
if scenario == "export" {
|
||||
if err := validate.ResourceName(runtime.Str("file-token"), "--file-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
scenario := strings.ToLower(runtime.Str("scenario"))
|
||||
ticket := runtime.Str("ticket")
|
||||
taskID := runtime.Str("task-id")
|
||||
fileToken := runtime.Str("file-token")
|
||||
|
||||
dry := common.NewDryRunAPI()
|
||||
dry.Desc(fmt.Sprintf("Poll async task result for %s scenario", scenario))
|
||||
|
||||
switch scenario {
|
||||
case "import":
|
||||
dry.GET("/open-apis/drive/v1/import_tasks/:ticket").
|
||||
Desc("[1] Query import task result").
|
||||
Set("ticket", ticket)
|
||||
case "export":
|
||||
dry.GET("/open-apis/drive/v1/export_tasks/:ticket").
|
||||
Desc("[1] Query export task result").
|
||||
Set("ticket", ticket).
|
||||
Params(map[string]interface{}{"token": fileToken})
|
||||
case "task_check":
|
||||
dry.GET("/open-apis/drive/v1/files/task_check").
|
||||
Desc("[1] Query move/delete folder task status").
|
||||
Params(driveTaskCheckParams(taskID))
|
||||
}
|
||||
|
||||
return dry
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
scenario := strings.ToLower(runtime.Str("scenario"))
|
||||
ticket := runtime.Str("ticket")
|
||||
taskID := runtime.Str("task-id")
|
||||
fileToken := runtime.Str("file-token")
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Querying %s task result...\n", scenario)
|
||||
|
||||
var result map[string]interface{}
|
||||
var err error
|
||||
|
||||
// Each scenario maps to a different backend API, but this shortcut keeps
|
||||
// the CLI surface uniform for resume-on-timeout workflows.
|
||||
switch scenario {
|
||||
case "import":
|
||||
result, err = queryImportTask(runtime, ticket)
|
||||
case "export":
|
||||
result, err = queryExportTask(runtime, ticket, fileToken)
|
||||
case "task_check":
|
||||
result, err = queryTaskCheck(runtime, taskID)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.Out(result, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// queryImportTask returns a stable, shortcut-friendly view of the import task.
|
||||
func queryImportTask(runtime *common.RuntimeContext, ticket string) (map[string]interface{}, error) {
|
||||
status, err := getDriveImportStatus(runtime, ticket)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"scenario": "import",
|
||||
"ticket": status.Ticket,
|
||||
"type": status.DocType,
|
||||
"ready": status.Ready(),
|
||||
"failed": status.Failed(),
|
||||
"job_status": status.JobStatus,
|
||||
"job_status_label": status.StatusLabel(),
|
||||
"job_error_msg": status.JobErrorMsg,
|
||||
"token": status.Token,
|
||||
"url": status.URL,
|
||||
"extra": status.Extra,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// queryExportTask returns the export task status together with download metadata
|
||||
// once the backend has produced the exported file.
|
||||
func queryExportTask(runtime *common.RuntimeContext, ticket, fileToken string) (map[string]interface{}, error) {
|
||||
status, err := getDriveExportStatus(runtime, fileToken, ticket)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"scenario": "export",
|
||||
"ticket": status.Ticket,
|
||||
"ready": status.Ready(),
|
||||
"failed": status.Failed(),
|
||||
"file_extension": status.FileExtension,
|
||||
"type": status.DocType,
|
||||
"file_name": status.FileName,
|
||||
"file_token": status.FileToken,
|
||||
"file_size": status.FileSize,
|
||||
"job_error_msg": status.JobErrorMsg,
|
||||
"job_status": status.JobStatus,
|
||||
"job_status_label": status.StatusLabel(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// queryTaskCheck returns the normalized status of a folder move/delete task.
|
||||
func queryTaskCheck(runtime *common.RuntimeContext, taskID string) (map[string]interface{}, error) {
|
||||
status, err := getDriveTaskCheckStatus(runtime, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"scenario": "task_check",
|
||||
"task_id": status.TaskID,
|
||||
"status": status.StatusLabel(),
|
||||
"ready": status.Ready(),
|
||||
"failed": status.Failed(),
|
||||
}, nil
|
||||
}
|
||||
192
shortcuts/drive/drive_task_result_test.go
Normal file
192
shortcuts/drive/drive_task_result_test.go
Normal file
@@ -0,0 +1,192 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestDriveTaskResultValidateErrorsByScenario(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
flags map[string]string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "unsupported scenario",
|
||||
flags: map[string]string{
|
||||
"scenario": "unknown",
|
||||
},
|
||||
wantErr: "unsupported scenario",
|
||||
},
|
||||
{
|
||||
name: "import missing ticket",
|
||||
flags: map[string]string{
|
||||
"scenario": "import",
|
||||
},
|
||||
wantErr: "--ticket is required",
|
||||
},
|
||||
{
|
||||
name: "export missing file token",
|
||||
flags: map[string]string{
|
||||
"scenario": "export",
|
||||
"ticket": "ticket_export_test",
|
||||
},
|
||||
wantErr: "--file-token is required",
|
||||
},
|
||||
{
|
||||
name: "task check missing task id",
|
||||
flags: map[string]string{
|
||||
"scenario": "task_check",
|
||||
},
|
||||
wantErr: "--task-id is required",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +task_result"}
|
||||
cmd.Flags().String("scenario", "", "")
|
||||
cmd.Flags().String("ticket", "", "")
|
||||
cmd.Flags().String("task-id", "", "")
|
||||
cmd.Flags().String("file-token", "", "")
|
||||
for key, value := range tt.flags {
|
||||
if err := cmd.Flags().Set(key, value); err != nil {
|
||||
t.Fatalf("set --%s: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, nil)
|
||||
err := DriveTaskResult.Validate(context.Background(), runtime)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveTaskResultDryRunExportIncludesTokenParam(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +task_result"}
|
||||
cmd.Flags().String("scenario", "", "")
|
||||
cmd.Flags().String("ticket", "", "")
|
||||
cmd.Flags().String("task-id", "", "")
|
||||
cmd.Flags().String("file-token", "", "")
|
||||
if err := cmd.Flags().Set("scenario", "export"); err != nil {
|
||||
t.Fatalf("set --scenario: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("ticket", "tk_export"); err != nil {
|
||||
t.Fatalf("set --ticket: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("file-token", "doc_123"); err != nil {
|
||||
t.Fatalf("set --file-token: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, nil)
|
||||
dry := DriveTaskResult.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run json: %v", err)
|
||||
}
|
||||
if len(got.API) != 1 {
|
||||
t.Fatalf("expected 1 API call, got %d", len(got.API))
|
||||
}
|
||||
if got.API[0].Params["token"] != "doc_123" {
|
||||
t.Fatalf("export status params = %#v", got.API[0].Params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveTaskResultImportIncludesReadyFlags(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/import_tasks/tk_import",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"type": "sheet",
|
||||
"job_status": 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveTaskResult, []string{
|
||||
"+task_result",
|
||||
"--scenario", "import",
|
||||
"--ticket", "tk_import",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"ready": false`)) {
|
||||
t.Fatalf("stdout missing ready=false: %s", stdout.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"job_status_label": "processing"`)) {
|
||||
t.Fatalf("stdout missing job_status_label: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveTaskResultTaskCheckIncludesReadyFlags(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/task_check",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"status": "pending"},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveTaskResult, []string{
|
||||
"+task_result",
|
||||
"--scenario", "task_check",
|
||||
"--task-id", "task_123",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"status": "pending"`)) {
|
||||
t.Fatalf("stdout missing pending status: %s", stdout.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"ready": false`)) {
|
||||
t.Fatalf("stdout missing ready=false: %s", stdout.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"failed": false`)) {
|
||||
t.Fatalf("stdout missing failed=false: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
@@ -11,5 +11,10 @@ func Shortcuts() []common.Shortcut {
|
||||
DriveUpload,
|
||||
DriveDownload,
|
||||
DriveAddComment,
|
||||
DriveExport,
|
||||
DriveExportDownload,
|
||||
DriveImport,
|
||||
DriveMove,
|
||||
DriveTaskResult,
|
||||
}
|
||||
}
|
||||
|
||||
40
shortcuts/drive/shortcuts_test.go
Normal file
40
shortcuts/drive/shortcuts_test.go
Normal file
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestShortcutsIncludesExpectedCommands(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := Shortcuts()
|
||||
want := []string{
|
||||
"+upload",
|
||||
"+download",
|
||||
"+add-comment",
|
||||
"+export",
|
||||
"+export-download",
|
||||
"+import",
|
||||
"+move",
|
||||
"+task_result",
|
||||
}
|
||||
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("len(Shortcuts()) = %d, want %d", len(got), len(want))
|
||||
}
|
||||
|
||||
seen := make(map[string]bool, len(got))
|
||||
for _, shortcut := range got {
|
||||
if seen[shortcut.Command] {
|
||||
t.Fatalf("duplicate shortcut command: %s", shortcut.Command)
|
||||
}
|
||||
seen[shortcut.Command] = true
|
||||
}
|
||||
|
||||
for _, command := range want {
|
||||
if !seen[command] {
|
||||
t.Fatalf("missing shortcut command %q in Shortcuts()", command)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -876,7 +876,7 @@ func uploadImageToIM(ctx context.Context, runtime *common.RuntimeContext, filePa
|
||||
fd.AddField("image_type", imageType)
|
||||
fd.AddFile("image", f)
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
apiResp, err := runtime.DoAPIAsBot(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/im/v1/images",
|
||||
Body: fd,
|
||||
@@ -922,7 +922,7 @@ func uploadFileToIM(ctx context.Context, runtime *common.RuntimeContext, filePat
|
||||
}
|
||||
fd.AddFile("file", f)
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
apiResp, err := runtime.DoAPIAsBot(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/im/v1/files",
|
||||
Body: fd,
|
||||
|
||||
@@ -17,10 +17,12 @@ import (
|
||||
var ImMessagesReply = common.Shortcut{
|
||||
Service: "im",
|
||||
Command: "+messages-reply",
|
||||
Description: "Reply to a message (supports thread replies) with bot identity; bot-only; supports text/markdown/post/media replies, reply-in-thread, idempotency key",
|
||||
Description: "Reply to a message (supports thread replies); user/bot; supports text/markdown/post/media replies, reply-in-thread, idempotency key",
|
||||
Risk: "write",
|
||||
Scopes: []string{"im:message:send_as_bot"},
|
||||
AuthTypes: []string{"bot"},
|
||||
UserScopes: []string{"im:message.send_as_user", "im:message"},
|
||||
BotScopes: []string{"im:message:send_as_bot"},
|
||||
AuthTypes: []string{"bot", "user"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "message-id", Desc: "message ID (om_xxx)", Required: true},
|
||||
{Name: "msg-type", Default: "text", Desc: "message type for --content JSON; when using --text/--markdown/--image/--file/--video/--audio, the effective type is inferred automatically", Enum: []string{"text", "post", "image", "file", "audio", "media", "interactive", "share_chat", "share_user"}},
|
||||
|
||||
@@ -18,10 +18,12 @@ import (
|
||||
var ImMessagesSend = common.Shortcut{
|
||||
Service: "im",
|
||||
Command: "+messages-send",
|
||||
Description: "Send a message to a chat or direct message with bot identity; bot-only; sends to chat-id or user-id with text/markdown/post/media, supports idempotency key",
|
||||
Description: "Send a message to a chat or direct message; user/bot; sends to chat-id or user-id with text/markdown/post/media, supports idempotency key",
|
||||
Risk: "write",
|
||||
Scopes: []string{"im:message:send_as_bot"},
|
||||
AuthTypes: []string{"bot"},
|
||||
UserScopes: []string{"im:message.send_as_user", "im:message"},
|
||||
BotScopes: []string{"im:message:send_as_bot"},
|
||||
AuthTypes: []string{"bot", "user"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "chat-id", Desc: "(required, mutually exclusive with --user-id) chat ID (oc_xxx)"},
|
||||
{Name: "user-id", Desc: "(required, mutually exclusive with --chat-id) user open_id (ou_xxx)"},
|
||||
|
||||
@@ -218,24 +218,24 @@ func mailboxPath(mailboxID string, segments ...string) string {
|
||||
}
|
||||
|
||||
// fetchMailboxPrimaryEmail retrieves mailbox primary_email_address from
|
||||
// user_mailboxes.profile. Returns empty string on failure (non-fatal).
|
||||
func fetchMailboxPrimaryEmail(runtime *common.RuntimeContext, mailboxID string) string {
|
||||
// user_mailboxes.profile. Returns the email address or an error.
|
||||
func fetchMailboxPrimaryEmail(runtime *common.RuntimeContext, mailboxID string) (string, error) {
|
||||
if mailboxID == "" {
|
||||
mailboxID = "me"
|
||||
}
|
||||
data, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "profile"), nil, nil)
|
||||
if err != nil {
|
||||
return ""
|
||||
return "", err
|
||||
}
|
||||
if email := extractPrimaryEmail(data); email != "" {
|
||||
return email
|
||||
return email, nil
|
||||
}
|
||||
if nested, ok := data["data"].(map[string]interface{}); ok {
|
||||
if email := extractPrimaryEmail(nested); email != "" {
|
||||
return email
|
||||
return email, nil
|
||||
}
|
||||
}
|
||||
return ""
|
||||
return "", fmt.Errorf("profile API returned no primary_email_address")
|
||||
}
|
||||
|
||||
func extractPrimaryEmail(data map[string]interface{}) string {
|
||||
@@ -252,7 +252,8 @@ func extractPrimaryEmail(data map[string]interface{}) string {
|
||||
|
||||
// fetchCurrentUserEmail retrieves the current mailbox primary email.
|
||||
func fetchCurrentUserEmail(runtime *common.RuntimeContext) string {
|
||||
return fetchMailboxPrimaryEmail(runtime, "me")
|
||||
email, _ := fetchMailboxPrimaryEmail(runtime, "me")
|
||||
return email
|
||||
}
|
||||
|
||||
// fetchSelfEmailSet returns a set containing the primary email of the given
|
||||
@@ -264,7 +265,7 @@ func fetchSelfEmailSet(runtime *common.RuntimeContext, mailboxID string) map[str
|
||||
mailboxID = "me"
|
||||
}
|
||||
set := make(map[string]bool)
|
||||
if email := fetchMailboxPrimaryEmail(runtime, mailboxID); email != "" {
|
||||
if email, _ := fetchMailboxPrimaryEmail(runtime, mailboxID); email != "" {
|
||||
set[strings.ToLower(email)] = true
|
||||
}
|
||||
return set
|
||||
@@ -680,6 +681,9 @@ func addUniqueID(dst *[]string, seen map[string]bool, id string) {
|
||||
}
|
||||
|
||||
func listMailboxFolders(runtime *common.RuntimeContext, mailboxID string) ([]folderInfo, error) {
|
||||
if err := validateFolderReadScope(runtime); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "folders"), nil, nil)
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("unable to resolve --folder: failed to list folders (%v). %s", err, resolveLookupHint("folder", mailboxID))
|
||||
@@ -701,6 +705,9 @@ func listMailboxFolders(runtime *common.RuntimeContext, mailboxID string) ([]fol
|
||||
}
|
||||
|
||||
func listMailboxLabels(runtime *common.RuntimeContext, mailboxID string) ([]labelInfo, error) {
|
||||
if err := validateLabelReadScope(runtime); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "labels"), nil, nil)
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("unable to resolve --label: failed to list labels (%v). %s", err, resolveLookupHint("label", mailboxID))
|
||||
@@ -1882,6 +1889,52 @@ func validateConfirmSendScope(runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateFolderReadScope checks that the user's token includes the
|
||||
// mail:user_mailbox.folder:read scope. Called on-demand by listMailboxFolders
|
||||
// before hitting the folders API. System folders are resolved locally and
|
||||
// never reach this check.
|
||||
func validateFolderReadScope(runtime *common.RuntimeContext) error {
|
||||
appID := runtime.Config.AppID
|
||||
userOpenId := runtime.UserOpenId()
|
||||
if appID == "" || userOpenId == "" {
|
||||
return nil
|
||||
}
|
||||
stored := auth.GetStoredToken(appID, userOpenId)
|
||||
if stored == nil {
|
||||
return nil
|
||||
}
|
||||
required := []string{"mail:user_mailbox.folder:read"}
|
||||
if missing := auth.MissingScopes(stored.Scope, required); len(missing) > 0 {
|
||||
return output.ErrWithHint(output.ExitAuth, "missing_scope",
|
||||
fmt.Sprintf("folder resolution requires scope: %s", strings.Join(missing, ", ")),
|
||||
fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` to grant folder read permission", strings.Join(missing, " ")))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateLabelReadScope checks that the user's token includes the
|
||||
// mail:user_mailbox.message:modify scope. Called on-demand by listMailboxLabels
|
||||
// before hitting the labels API. System labels are resolved locally and
|
||||
// never reach this check.
|
||||
func validateLabelReadScope(runtime *common.RuntimeContext) error {
|
||||
appID := runtime.Config.AppID
|
||||
userOpenId := runtime.UserOpenId()
|
||||
if appID == "" || userOpenId == "" {
|
||||
return nil
|
||||
}
|
||||
stored := auth.GetStoredToken(appID, userOpenId)
|
||||
if stored == nil {
|
||||
return nil
|
||||
}
|
||||
required := []string{"mail:user_mailbox.message:modify"}
|
||||
if missing := auth.MissingScopes(stored.Scope, required); len(missing) > 0 {
|
||||
return output.ErrWithHint(output.ExitAuth, "missing_scope",
|
||||
fmt.Sprintf("label resolution requires scope: %s", strings.Join(missing, ", ")),
|
||||
fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` to grant label access permission", strings.Join(missing, " ")))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateComposeHasAtLeastOneRecipient(to, cc, bcc string) error {
|
||||
if strings.TrimSpace(to) == "" && strings.TrimSpace(cc) == "" && strings.TrimSpace(bcc) == "" {
|
||||
return fmt.Errorf("at least one recipient (--to, --cc, or --bcc) is required")
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/zalando/go-keyring"
|
||||
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
@@ -32,6 +33,7 @@ func mailTestConfig() *core.CliConfig {
|
||||
|
||||
func mailShortcutTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
keyring.MockInit() // use in-memory keyring to avoid macOS keychain popups
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
cfg := mailTestConfig()
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
@@ -79,8 +81,8 @@ var MailWatch = common.Shortcut{
|
||||
Command: "+watch",
|
||||
Description: "Watch for incoming mail events via WebSocket (requires scope mail:event and bot event mail.user_mailbox.event.message_received_v1 added). Run with --print-output-schema to see per-format field reference before parsing output.",
|
||||
Risk: "read",
|
||||
Scopes: []string{"mail:event", "mail:user_mailbox.message:readonly", "mail:user_mailbox.folder:read", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Scopes: []string{"mail:event", "mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "format", Default: "data", Desc: "json: NDJSON stream with ok/data envelope; data: bare NDJSON stream"},
|
||||
{Name: "msg-format", Default: "metadata", Desc: "message payload mode: metadata(headers + meta, for triage/notification) | minimal(IDs and state only, no headers, for tracking read/folder changes) | plain_text_full(all metadata fields + full plain-text body) | event(raw WebSocket event, no API call, for debug) | full(full message including HTML body and attachments)"},
|
||||
@@ -138,6 +140,11 @@ var MailWatch = common.Shortcut{
|
||||
Desc(fmt.Sprintf("Subscribe mailbox events (effective_folder_ids=%s, effective_label_ids=%s)", effectiveFolderDisplay, effectiveLabelDisplay)).
|
||||
Body(map[string]interface{}{"event_type": 1})
|
||||
|
||||
if mailbox == "me" {
|
||||
d.GET(mailboxPath("me", "profile")).
|
||||
Desc("Resolve mailbox address for event filtering (requires scope mail:user_mailbox:readonly)")
|
||||
}
|
||||
|
||||
if len(resolvedLabelIDs) > 0 {
|
||||
d.Set("filter_label_ids", strings.Join(resolvedLabelIDs, ","))
|
||||
}
|
||||
@@ -244,11 +251,24 @@ var MailWatch = common.Shortcut{
|
||||
}
|
||||
info("Mailbox subscribed.")
|
||||
|
||||
// mailboxFilter: only apply event-level filtering when an explicit email address is given
|
||||
// "me" is a server-side alias and cannot be matched against event.mail_address
|
||||
mailboxFilter := ""
|
||||
if mailbox != "me" {
|
||||
mailboxFilter = mailbox
|
||||
var unsubOnce sync.Once
|
||||
var unsubErr error
|
||||
unsubscribe := func() error {
|
||||
unsubOnce.Do(func() {
|
||||
_, unsubErr = runtime.CallAPI("POST", mailboxPath(mailbox, "event", "unsubscribe"), nil, map[string]interface{}{"event_type": 1})
|
||||
})
|
||||
return unsubErr
|
||||
}
|
||||
|
||||
// Resolve "me" to the actual email address so we can filter events.
|
||||
mailboxFilter := mailbox
|
||||
if mailbox == "me" {
|
||||
resolved, profileErr := fetchMailboxPrimaryEmail(runtime, "me")
|
||||
if profileErr != nil {
|
||||
unsubscribe() //nolint:errcheck // best-effort cleanup; primary error is profileErr
|
||||
return enhanceProfileError(profileErr)
|
||||
}
|
||||
mailboxFilter = resolved
|
||||
}
|
||||
|
||||
eventCount := 0
|
||||
@@ -257,10 +277,10 @@ var MailWatch = common.Shortcut{
|
||||
// Extract event body
|
||||
eventBody := extractMailEventBody(data)
|
||||
|
||||
// Filter by --mailbox (only when an explicit email address was provided)
|
||||
// Filter by --mailbox
|
||||
if mailboxFilter != "" {
|
||||
mailAddr, _ := eventBody["mail_address"].(string)
|
||||
if mailAddr != mailboxFilter {
|
||||
if !strings.EqualFold(mailAddr, mailboxFilter) {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -414,12 +434,19 @@ var MailWatch = common.Shortcut{
|
||||
}()
|
||||
<-sigCh
|
||||
info(fmt.Sprintf("\nShutting down... (received %d events)", eventCount))
|
||||
info("Unsubscribing mailbox events...")
|
||||
if unsubErr := unsubscribe(); unsubErr != nil {
|
||||
fmt.Fprintf(errOut, "Warning: unsubscribe failed: %v\n", unsubErr)
|
||||
} else {
|
||||
info("Mailbox unsubscribed.")
|
||||
}
|
||||
signal.Stop(sigCh)
|
||||
os.Exit(0)
|
||||
}()
|
||||
|
||||
info("Connected. Waiting for mail events... (Ctrl+C to stop)")
|
||||
if err := cli.Start(ctx); err != nil {
|
||||
unsubscribe() //nolint:errcheck // best-effort cleanup
|
||||
return output.ErrNetwork("WebSocket connection failed: %v", err)
|
||||
}
|
||||
return nil
|
||||
@@ -692,6 +719,25 @@ func wrapWatchSubscribeError(err error) error {
|
||||
return output.ErrWithHint(output.ExitAPI, "api_error", fmt.Sprintf("subscribe mailbox events failed: %v", err), hint)
|
||||
}
|
||||
|
||||
// enhanceProfileError wraps a profile API error with actionable hints.
|
||||
// Permission errors get a scope-specific hint; other errors (network, 5xx)
|
||||
// are reported as-is so diagnostics aren't misleading.
|
||||
func enhanceProfileError(err error) error {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) && exitErr.Detail != nil {
|
||||
errType := exitErr.Detail.Type
|
||||
lower := strings.ToLower(exitErr.Detail.Message)
|
||||
if errType == "permission" || errType == "missing_scope" ||
|
||||
strings.Contains(lower, "permission") || strings.Contains(lower, "scope") {
|
||||
return output.ErrWithHint(output.ExitAuth, "missing_scope",
|
||||
"unable to resolve mailbox address: "+exitErr.Detail.Message,
|
||||
"run `lark-cli auth login --scope \"mail:user_mailbox:readonly\"` to grant mailbox profile access")
|
||||
}
|
||||
}
|
||||
// Preserve original error (and its exit code) for non-permission failures.
|
||||
return err
|
||||
}
|
||||
|
||||
// decodeBodyFieldsForFile returns a shallow copy of outputData with body_html and
|
||||
// body_plain_text decoded from base64url, so that files saved via --output-dir contain
|
||||
// human-readable content instead of raw base64 strings.
|
||||
|
||||
@@ -87,8 +87,8 @@ func TestMailWatchDryRunDefaultMetadataFetchesMessage(t *testing.T) {
|
||||
runtime := runtimeForMailWatchTest(t, map[string]string{})
|
||||
|
||||
apis := dryRunAPIsForMailWatchTest(t, MailWatch.DryRun(context.Background(), runtime))
|
||||
if len(apis) != 2 {
|
||||
t.Fatalf("expected 2 dry-run apis, got %d", len(apis))
|
||||
if len(apis) != 3 {
|
||||
t.Fatalf("expected 3 dry-run apis, got %d", len(apis))
|
||||
}
|
||||
if apis[0].Method != "POST" {
|
||||
t.Fatalf("unexpected method: %s", apis[0].Method)
|
||||
@@ -96,10 +96,13 @@ func TestMailWatchDryRunDefaultMetadataFetchesMessage(t *testing.T) {
|
||||
if apis[0].URL != mailboxPath("me", "event", "subscribe") {
|
||||
t.Fatalf("unexpected url: %s", apis[0].URL)
|
||||
}
|
||||
if apis[1].URL != mailboxPath("me", "messages", "{message_id}") {
|
||||
t.Fatalf("unexpected fetch url: %s", apis[1].URL)
|
||||
if apis[1].Method != "GET" || apis[1].URL != mailboxPath("me", "profile") {
|
||||
t.Fatalf("unexpected profile api: %s %s", apis[1].Method, apis[1].URL)
|
||||
}
|
||||
if got := apis[1].Params["format"]; got != "metadata" {
|
||||
if apis[2].URL != mailboxPath("me", "messages", "{message_id}") {
|
||||
t.Fatalf("unexpected fetch url: %s", apis[2].URL)
|
||||
}
|
||||
if got := apis[2].Params["format"]; got != "metadata" {
|
||||
t.Fatalf("unexpected fetch format: %#v", got)
|
||||
}
|
||||
}
|
||||
@@ -110,16 +113,16 @@ func TestMailWatchDryRunMetadataFormatFetchesMessage(t *testing.T) {
|
||||
})
|
||||
|
||||
apis := dryRunAPIsForMailWatchTest(t, MailWatch.DryRun(context.Background(), runtime))
|
||||
if len(apis) != 2 {
|
||||
t.Fatalf("expected 2 dry-run apis, got %d", len(apis))
|
||||
if len(apis) != 3 {
|
||||
t.Fatalf("expected 3 dry-run apis, got %d", len(apis))
|
||||
}
|
||||
if apis[1].Method != "GET" {
|
||||
t.Fatalf("unexpected fetch method: %s", apis[1].Method)
|
||||
if apis[2].Method != "GET" {
|
||||
t.Fatalf("unexpected fetch method: %s", apis[2].Method)
|
||||
}
|
||||
if apis[1].URL != mailboxPath("me", "messages", "{message_id}") {
|
||||
t.Fatalf("unexpected fetch url: %s", apis[1].URL)
|
||||
if apis[2].URL != mailboxPath("me", "messages", "{message_id}") {
|
||||
t.Fatalf("unexpected fetch url: %s", apis[2].URL)
|
||||
}
|
||||
if got := apis[1].Params["format"]; got != "metadata" {
|
||||
if got := apis[2].Params["format"]; got != "metadata" {
|
||||
t.Fatalf("unexpected fetch format: %#v", got)
|
||||
}
|
||||
}
|
||||
@@ -130,10 +133,10 @@ func TestMailWatchDryRunMinimalFormatFetchesMessage(t *testing.T) {
|
||||
})
|
||||
|
||||
apis := dryRunAPIsForMailWatchTest(t, MailWatch.DryRun(context.Background(), runtime))
|
||||
if len(apis) != 2 {
|
||||
t.Fatalf("expected 2 dry-run apis, got %d", len(apis))
|
||||
if len(apis) != 3 {
|
||||
t.Fatalf("expected 3 dry-run apis, got %d", len(apis))
|
||||
}
|
||||
if got := apis[1].Params["format"]; got != "metadata" {
|
||||
if got := apis[2].Params["format"]; got != "metadata" {
|
||||
t.Fatalf("unexpected fetch format: %#v", got)
|
||||
}
|
||||
}
|
||||
@@ -173,10 +176,10 @@ func TestMailWatchDryRunPlainTextFullFormatFetchesMessage(t *testing.T) {
|
||||
})
|
||||
|
||||
apis := dryRunAPIsForMailWatchTest(t, MailWatch.DryRun(context.Background(), runtime))
|
||||
if len(apis) != 2 {
|
||||
t.Fatalf("expected 2 dry-run apis, got %d", len(apis))
|
||||
if len(apis) != 3 {
|
||||
t.Fatalf("expected 3 dry-run apis, got %d", len(apis))
|
||||
}
|
||||
if got := apis[1].Params["format"]; got != "plain_text_full" {
|
||||
if got := apis[2].Params["format"]; got != "plain_text_full" {
|
||||
t.Fatalf("unexpected fetch format: %#v", got)
|
||||
}
|
||||
}
|
||||
@@ -187,10 +190,10 @@ func TestMailWatchDryRunFullFormatUsesFull(t *testing.T) {
|
||||
})
|
||||
|
||||
apis := dryRunAPIsForMailWatchTest(t, MailWatch.DryRun(context.Background(), runtime))
|
||||
if len(apis) != 2 {
|
||||
t.Fatalf("expected 2 dry-run apis, got %d", len(apis))
|
||||
if len(apis) != 3 {
|
||||
t.Fatalf("expected 3 dry-run apis, got %d", len(apis))
|
||||
}
|
||||
if got := apis[1].Params["format"]; got != "full" {
|
||||
if got := apis[2].Params["format"]; got != "full" {
|
||||
t.Fatalf("unexpected fetch format: %#v", got)
|
||||
}
|
||||
}
|
||||
@@ -202,13 +205,13 @@ func TestMailWatchDryRunEventFormatWithLabelFilterFetchesMessage(t *testing.T) {
|
||||
})
|
||||
|
||||
apis := dryRunAPIsForMailWatchTest(t, MailWatch.DryRun(context.Background(), runtime))
|
||||
if len(apis) != 2 {
|
||||
t.Fatalf("expected 2 dry-run apis, got %d", len(apis))
|
||||
if len(apis) != 3 {
|
||||
t.Fatalf("expected 3 dry-run apis, got %d", len(apis))
|
||||
}
|
||||
if apis[1].URL != mailboxPath("me", "messages", "{message_id}") {
|
||||
t.Fatalf("unexpected fetch url: %s", apis[1].URL)
|
||||
if apis[2].URL != mailboxPath("me", "messages", "{message_id}") {
|
||||
t.Fatalf("unexpected fetch url: %s", apis[2].URL)
|
||||
}
|
||||
if got := apis[1].Params["format"]; got != "metadata" {
|
||||
if got := apis[2].Params["format"]; got != "metadata" {
|
||||
t.Fatalf("unexpected fetch format: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
339
shortcuts/minutes/minutes_download.go
Normal file
339
shortcuts/minutes/minutes_download.go
Normal file
@@ -0,0 +1,339 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
// disableClientTimeout removes the global 30s client timeout for large media downloads.
|
||||
// The download is bounded by the caller's context (e.g. Ctrl+C). A fixed timeout
|
||||
// would cut off legitimate large file transfers.
|
||||
disableClientTimeout = 0
|
||||
|
||||
maxBatchSize = 50
|
||||
maxDownloadRedirects = 5
|
||||
)
|
||||
|
||||
// validMinuteToken matches minute tokens: lowercase alphanumeric characters only.
|
||||
var validMinuteToken = regexp.MustCompile(`^[a-z0-9]+$`)
|
||||
|
||||
var MinutesDownload = common.Shortcut{
|
||||
Service: "minutes",
|
||||
Command: "+download",
|
||||
Description: "Download audio/video media file of a minute",
|
||||
Risk: "read",
|
||||
Scopes: []string{"minutes:minutes.media:export"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "minute-tokens", Desc: "minute tokens, comma-separated for batch download (max 50)", Required: true},
|
||||
{Name: "output", Desc: "output path: file path for single token, directory for batch (default: current dir)"},
|
||||
{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
|
||||
{Name: "url-only", Type: "bool", Desc: "only print the download URL(s) without downloading"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
tokens := common.SplitCSV(runtime.Str("minute-tokens"))
|
||||
if len(tokens) == 0 {
|
||||
return output.ErrValidation("--minute-tokens is required")
|
||||
}
|
||||
if len(tokens) > maxBatchSize {
|
||||
return output.ErrValidation("--minute-tokens: too many tokens (%d), maximum is %d", len(tokens), maxBatchSize)
|
||||
}
|
||||
for _, token := range tokens {
|
||||
if !validMinuteToken.MatchString(token) {
|
||||
return output.ErrValidation("invalid minute token %q: must contain only lowercase alphanumeric characters (e.g. obcnq3b9jl72l83w4f149w9c)", token)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
tokens := common.SplitCSV(runtime.Str("minute-tokens"))
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/minutes/v1/minutes/:minute_token/media").
|
||||
Set("minute_tokens", tokens)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
tokens := common.SplitCSV(runtime.Str("minute-tokens"))
|
||||
outputPath := runtime.Str("output")
|
||||
overwrite := runtime.Bool("overwrite")
|
||||
urlOnly := runtime.Bool("url-only")
|
||||
errOut := runtime.IO().ErrOut
|
||||
single := len(tokens) == 1
|
||||
|
||||
// Batch mode: --output must be a directory, not an existing file.
|
||||
if !single && outputPath != "" {
|
||||
if fi, err := os.Stat(outputPath); err == nil && !fi.IsDir() {
|
||||
return output.ErrValidation("--output %q is a file; batch mode expects a directory path", outputPath)
|
||||
}
|
||||
}
|
||||
|
||||
if !single {
|
||||
fmt.Fprintf(errOut, "[minutes +download] batch: %d token(s)\n", len(tokens))
|
||||
}
|
||||
|
||||
type result struct {
|
||||
MinuteToken string `json:"minute_token"`
|
||||
SavedPath string `json:"saved_path,omitempty"`
|
||||
SizeBytes int64 `json:"size_bytes,omitempty"`
|
||||
DownloadURL string `json:"download_url,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
results := make([]result, len(tokens))
|
||||
seen := make(map[string]int)
|
||||
usedNames := make(map[string]bool)
|
||||
|
||||
// Clone the factory client for download use. We clone the struct (not the
|
||||
// pointer) to avoid mutating the shared singleton's Timeout. The original
|
||||
// transport chain is preserved so security headers and test mocks still work.
|
||||
// SSRF protection: ValidateDownloadSourceURL (URL-level) + CheckRedirect
|
||||
// (redirect-level). Transport-level IP check is intentionally omitted because
|
||||
// download URLs originate from the trusted Lark API, not user input.
|
||||
baseClient, err := runtime.Factory.HttpClient()
|
||||
if err != nil {
|
||||
return output.ErrNetwork("failed to get HTTP client: %s", err)
|
||||
}
|
||||
clonedClient := *baseClient
|
||||
clonedClient.Timeout = disableClientTimeout
|
||||
clonedClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= maxDownloadRedirects {
|
||||
return fmt.Errorf("too many redirects")
|
||||
}
|
||||
if len(via) > 0 {
|
||||
prev := via[len(via)-1]
|
||||
if strings.EqualFold(prev.URL.Scheme, "https") && strings.EqualFold(req.URL.Scheme, "http") {
|
||||
return fmt.Errorf("redirect from https to http is not allowed")
|
||||
}
|
||||
}
|
||||
return validate.ValidateDownloadSourceURL(req.Context(), req.URL.String())
|
||||
}
|
||||
dlClient := &clonedClient
|
||||
|
||||
ticker := time.NewTicker(time.Second / 5) // rate-limit to 5 req/s
|
||||
defer ticker.Stop()
|
||||
|
||||
for i, token := range tokens {
|
||||
if i > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
|
||||
if err := validate.ResourceName(token, "--minute-tokens"); err != nil {
|
||||
results[i] = result{MinuteToken: token, Error: err.Error()}
|
||||
continue
|
||||
}
|
||||
if firstIdx, dup := seen[token]; dup {
|
||||
results[i] = result{MinuteToken: token, Error: fmt.Sprintf("duplicate token, same as index %d", firstIdx)}
|
||||
continue
|
||||
}
|
||||
seen[token] = i
|
||||
|
||||
downloadURL, err := fetchDownloadURL(ctx, runtime, token)
|
||||
if err != nil {
|
||||
results[i] = result{MinuteToken: token, Error: err.Error()}
|
||||
continue
|
||||
}
|
||||
|
||||
if urlOnly {
|
||||
results[i] = result{MinuteToken: token, DownloadURL: downloadURL}
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Fprintf(errOut, "Downloading media: %s\n", common.MaskToken(token))
|
||||
|
||||
// single token: --output is a file path; batch: --output is a directory
|
||||
opts := downloadOpts{overwrite: overwrite, usedNames: usedNames}
|
||||
if single {
|
||||
opts.outputPath = outputPath
|
||||
} else {
|
||||
opts.outputDir = outputPath
|
||||
}
|
||||
|
||||
dl, err := downloadMediaFile(ctx, dlClient, downloadURL, token, opts)
|
||||
if err != nil {
|
||||
results[i] = result{MinuteToken: token, Error: err.Error()}
|
||||
continue
|
||||
}
|
||||
results[i] = result{MinuteToken: token, SavedPath: dl.savedPath, SizeBytes: dl.sizeBytes}
|
||||
}
|
||||
|
||||
// output
|
||||
if single {
|
||||
r := results[0]
|
||||
if r.Error != "" {
|
||||
return output.ErrAPI(0, r.Error, nil)
|
||||
}
|
||||
if urlOnly {
|
||||
runtime.Out(map[string]interface{}{"download_url": r.DownloadURL}, nil)
|
||||
} else {
|
||||
runtime.Out(map[string]interface{}{"saved_path": r.SavedPath, "size_bytes": r.SizeBytes}, nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// batch output
|
||||
successCount := 0
|
||||
for _, r := range results {
|
||||
if r.Error == "" {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(errOut, "[minutes +download] done: %d total, %d succeeded, %d failed\n", len(results), successCount, len(results)-successCount)
|
||||
|
||||
runtime.OutFormat(map[string]interface{}{"downloads": results}, &output.Meta{Count: len(results)}, nil)
|
||||
if successCount == 0 && len(results) > 0 {
|
||||
return output.ErrAPI(0, fmt.Sprintf("all %d downloads failed", len(results)), nil)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// fetchDownloadURL retrieves the pre-signed download URL for a minute token.
|
||||
func fetchDownloadURL(ctx context.Context, runtime *common.RuntimeContext, minuteToken string) (string, error) {
|
||||
data, err := runtime.DoAPIJSON(http.MethodGet,
|
||||
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/media", validate.EncodePathSegment(minuteToken)),
|
||||
nil, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
downloadURL := common.GetString(data, "download_url")
|
||||
if downloadURL == "" {
|
||||
return "", output.Errorf(output.ExitAPI, "api_error", "API returned empty download_url for %s", minuteToken)
|
||||
}
|
||||
return downloadURL, nil
|
||||
}
|
||||
|
||||
type downloadResult struct {
|
||||
savedPath string
|
||||
sizeBytes int64
|
||||
}
|
||||
|
||||
type downloadOpts struct {
|
||||
outputPath string // explicit output file path (single mode only)
|
||||
outputDir string // output directory (batch mode)
|
||||
overwrite bool
|
||||
usedNames map[string]bool // tracks used filenames to deduplicate in batch mode
|
||||
}
|
||||
|
||||
// downloadMediaFile streams a media file from a pre-signed URL to disk.
|
||||
// Filename resolution: opts.outputPath > Content-Disposition filename > Content-Type ext > <token>.media.
|
||||
func downloadMediaFile(ctx context.Context, client *http.Client, downloadURL, minuteToken string, opts downloadOpts) (*downloadResult, error) {
|
||||
if err := validate.ValidateDownloadSourceURL(ctx, downloadURL); err != nil {
|
||||
return nil, output.ErrValidation("blocked download URL: %s", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
|
||||
if err != nil {
|
||||
return nil, output.ErrNetwork("invalid download URL: %s", err)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, output.ErrNetwork("download failed: %s", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
if len(body) > 0 {
|
||||
return nil, output.ErrNetwork("download failed: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return nil, output.ErrNetwork("download failed: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// resolve output path
|
||||
outputPath := opts.outputPath
|
||||
if outputPath == "" {
|
||||
filename := resolveFilenameFromResponse(resp, minuteToken)
|
||||
// Deduplicate filenames in batch mode: prefix with token on collision.
|
||||
if opts.usedNames != nil {
|
||||
if opts.usedNames[filename] {
|
||||
filename = minuteToken + "-" + filename
|
||||
}
|
||||
opts.usedNames[filename] = true
|
||||
}
|
||||
outputPath = filepath.Join(opts.outputDir, filename)
|
||||
}
|
||||
|
||||
safePath, err := validate.SafeOutputPath(outputPath)
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("unsafe output path: %s", err)
|
||||
}
|
||||
if err := common.EnsureWritableFile(safePath, opts.overwrite); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(safePath), 0700); err != nil {
|
||||
return nil, output.Errorf(output.ExitInternal, "api_error", "cannot create parent directory: %s", err)
|
||||
}
|
||||
|
||||
sizeBytes, err := validate.AtomicWriteFromReader(safePath, resp.Body, 0600)
|
||||
if err != nil {
|
||||
return nil, output.Errorf(output.ExitInternal, "api_error", "cannot create file: %s", err)
|
||||
}
|
||||
return &downloadResult{savedPath: safePath, sizeBytes: sizeBytes}, nil
|
||||
}
|
||||
|
||||
// resolveFilenameFromResponse derives the filename from HTTP response headers.
|
||||
// Priority: Content-Disposition filename > Content-Type extension > <token>.media.
|
||||
func resolveFilenameFromResponse(resp *http.Response, minuteToken string) string {
|
||||
if cd := resp.Header.Get("Content-Disposition"); cd != "" {
|
||||
if _, params, err := mime.ParseMediaType(cd); err == nil {
|
||||
if filename := params["filename"]; filename != "" {
|
||||
return filename
|
||||
}
|
||||
}
|
||||
}
|
||||
if ext := extFromContentType(resp.Header.Get("Content-Type")); ext != "" {
|
||||
return minuteToken + ext
|
||||
}
|
||||
return minuteToken + ".media"
|
||||
}
|
||||
|
||||
// preferredExt overrides Go's mime.ExtensionsByType which returns alphabetically sorted
|
||||
// results (e.g. .m4v before .mp4 for video/mp4).
|
||||
var preferredExt = map[string]string{
|
||||
"video/mp4": ".mp4",
|
||||
"audio/mp4": ".m4a",
|
||||
"audio/mpeg": ".mp3",
|
||||
}
|
||||
|
||||
// newDownloadClient wraps the base HTTP client with SSRF protection
|
||||
// (redirect safety + transport-level IP validation). When the base transport
|
||||
// is not *http.Transport (e.g. test mocks), it falls back to cloning
|
||||
// http.DefaultTransport via NewDownloadHTTPClient.
|
||||
// extFromContentType returns a file extension for the given Content-Type, or "" if unknown.
|
||||
func extFromContentType(contentType string) string {
|
||||
if contentType == "" {
|
||||
return ""
|
||||
}
|
||||
mediaType, _, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if ext, ok := preferredExt[mediaType]; ok {
|
||||
return ext
|
||||
}
|
||||
if exts, err := mime.ExtensionsByType(mediaType); err == nil && len(exts) > 0 {
|
||||
return exts[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
439
shortcuts/minutes/minutes_download_test.go
Normal file
439
shortcuts/minutes/minutes_download_test.go
Normal file
@@ -0,0 +1,439 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var warmOnce sync.Once
|
||||
|
||||
func warmTokenCache(t *testing.T) {
|
||||
t.Helper()
|
||||
warmOnce.Do(func() {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/auth/v3/tenant_access_token/internal",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-test-token", "expire": 7200,
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/warm",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
|
||||
})
|
||||
s := common.Shortcut{
|
||||
Service: "test",
|
||||
Command: "+warm",
|
||||
AuthTypes: []string{"bot"},
|
||||
Execute: func(_ context.Context, rctx *common.RuntimeContext) error {
|
||||
_, err := rctx.CallAPI("GET", "/open-apis/test/v1/warm", nil, nil)
|
||||
return err
|
||||
},
|
||||
}
|
||||
parent := &cobra.Command{Use: "test"}
|
||||
s.Mount(parent, f)
|
||||
parent.SetArgs([]string{"+warm"})
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
parent.Execute()
|
||||
})
|
||||
}
|
||||
|
||||
func mountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
|
||||
t.Helper()
|
||||
warmTokenCache(t)
|
||||
parent := &cobra.Command{Use: "minutes"}
|
||||
s.Mount(parent, f)
|
||||
parent.SetArgs(args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if stdout != nil {
|
||||
stdout.Reset()
|
||||
}
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
func defaultConfig() *core.CliConfig {
|
||||
return &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_testuser",
|
||||
}
|
||||
}
|
||||
|
||||
func mediaStub(token, downloadURL string) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/minutes/v1/minutes/" + token + "/media",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"download_url": downloadURL},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func downloadStub(url string, body []byte, contentType string) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
URL: url,
|
||||
RawBody: body,
|
||||
Headers: http.Header{"Content-Type": []string{contentType}},
|
||||
}
|
||||
}
|
||||
|
||||
// chdir changes the working directory and restores it when the test finishes.
|
||||
func chdir(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
orig, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get cwd: %v", err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatalf("failed to chdir to %s: %v", dir, err)
|
||||
}
|
||||
t.Cleanup(func() { os.Chdir(orig) })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit tests: resolveOutputFromResponse
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestResolveFilenameFromResponse_ContentDisposition(t *testing.T) {
|
||||
resp := &http.Response{
|
||||
Header: http.Header{
|
||||
"Content-Disposition": []string{`attachment; filename="meeting_recording.mp4"`},
|
||||
"Content-Type": []string{"video/mp4"},
|
||||
},
|
||||
}
|
||||
got := resolveFilenameFromResponse(resp, "tok001")
|
||||
if got != "meeting_recording.mp4" {
|
||||
t.Errorf("expected Content-Disposition filename, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFilenameFromResponse_ContentType(t *testing.T) {
|
||||
resp := &http.Response{
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"video/mp4"},
|
||||
},
|
||||
}
|
||||
got := resolveFilenameFromResponse(resp, "tok001")
|
||||
if !strings.HasPrefix(got, "tok001") {
|
||||
t.Errorf("expected token prefix, got %q", got)
|
||||
}
|
||||
if ext := got[len("tok001"):]; ext == "" {
|
||||
t.Errorf("expected extension after token, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFilenameFromResponse_Fallback(t *testing.T) {
|
||||
resp := &http.Response{Header: http.Header{}}
|
||||
got := resolveFilenameFromResponse(resp, "tok001")
|
||||
if got != "tok001.media" {
|
||||
t.Errorf("expected fallback %q, got %q", "tok001.media", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFilenameFromResponse_InvalidContentDisposition(t *testing.T) {
|
||||
resp := &http.Response{
|
||||
Header: http.Header{
|
||||
"Content-Disposition": []string{"invalid;;;"},
|
||||
"Content-Type": []string{"audio/mpeg"},
|
||||
},
|
||||
}
|
||||
got := resolveFilenameFromResponse(resp, "tok001")
|
||||
if !strings.HasPrefix(got, "tok001") {
|
||||
t.Errorf("expected token prefix from Content-Type fallback, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFilenameFromResponse_EmptyDispositionFilename(t *testing.T) {
|
||||
resp := &http.Response{
|
||||
Header: http.Header{
|
||||
"Content-Disposition": []string{"attachment"},
|
||||
"Content-Type": []string{"video/mp4"},
|
||||
},
|
||||
}
|
||||
got := resolveFilenameFromResponse(resp, "tok001")
|
||||
if got == "" {
|
||||
t.Error("expected non-empty filename")
|
||||
}
|
||||
if !strings.HasPrefix(got, "tok001") {
|
||||
t.Errorf("expected token prefix, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestDownload_Validation_NoFlags(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, MinutesDownload, []string{"+download", "--as", "user"}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for no flags")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownload_Validation_InvalidToken(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, MinutesDownload, []string{
|
||||
"+download", "--minute-tokens", "obcn***invalid", "--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for invalid token")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid minute token") {
|
||||
t.Errorf("expected 'invalid minute token' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownload_Validation_OutputWithBatch(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, MinutesDownload, []string{
|
||||
"+download", "--minute-tokens", "t1,t2", "--output", "file.mp4", "--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for --output with --minute-tokens")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integration tests: single mode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestDownload_DryRun(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, MinutesDownload, []string{
|
||||
"+download", "--minute-tokens", "tok001", "--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "media") {
|
||||
t.Errorf("dry-run should show media API path, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "tok001") {
|
||||
t.Errorf("dry-run should show minute_token, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownload_UrlOnly(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(mediaStub("tok001", "https://example.com/presigned/download"))
|
||||
|
||||
err := mountAndRun(t, MinutesDownload, []string{
|
||||
"+download", "--minute-tokens", "tok001", "--url-only", "--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "https://example.com/presigned/download") {
|
||||
t.Errorf("url-only should output download URL, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownload_FullDownload(t *testing.T) {
|
||||
chdir(t, t.TempDir())
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(mediaStub("tok001", "https://example.com/presigned/download"))
|
||||
reg.Register(downloadStub("example.com/presigned/download", []byte("fake-video-content"), "video/mp4"))
|
||||
|
||||
err := mountAndRun(t, MinutesDownload, []string{
|
||||
"+download", "--minute-tokens", "tok001", "--output", "output.mp4", "--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile("output.mp4")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read output file: %v", err)
|
||||
}
|
||||
if string(data) != "fake-video-content" {
|
||||
t.Errorf("file content = %q, want %q", string(data), "fake-video-content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownload_OverwriteProtection(t *testing.T) {
|
||||
chdir(t, t.TempDir())
|
||||
if err := os.WriteFile("existing.mp4", []byte("old"), 0644); err != nil {
|
||||
t.Fatalf("setup failed: %v", err)
|
||||
}
|
||||
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(mediaStub("tok001", "https://example.com/presigned/download"))
|
||||
reg.Register(downloadStub("example.com/presigned/download", []byte("new-content"), "video/mp4"))
|
||||
|
||||
err := mountAndRun(t, MinutesDownload, []string{
|
||||
"+download", "--minute-tokens", "tok001", "--output", "existing.mp4", "--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for existing file without --overwrite")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "exists") {
|
||||
t.Errorf("error should mention file exists, got: %v", err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile("existing.mp4")
|
||||
if string(data) != "old" {
|
||||
t.Errorf("original file should be preserved, got %q", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownload_HttpError(t *testing.T) {
|
||||
chdir(t, t.TempDir())
|
||||
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(mediaStub("tok001", "https://example.com/presigned/download"))
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "example.com/presigned/download",
|
||||
Status: 403,
|
||||
RawBody: []byte("Forbidden"),
|
||||
})
|
||||
|
||||
err := mountAndRun(t, MinutesDownload, []string{
|
||||
"+download", "--minute-tokens", "tok001", "--output", "output.mp4", "--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for HTTP 403")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "403") {
|
||||
t.Errorf("error should contain status code, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integration tests: batch mode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestDownload_Batch_UrlOnly(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(mediaStub("tok001", "https://example.com/download/1"))
|
||||
reg.Register(mediaStub("tok002", "https://example.com/download/2"))
|
||||
|
||||
err := mountAndRun(t, MinutesDownload, []string{
|
||||
"+download", "--minute-tokens", "tok001,tok002", "--url-only", "--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "download/1") || !strings.Contains(out, "download/2") {
|
||||
t.Errorf("batch url-only should show both URLs, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownload_Batch_Download(t *testing.T) {
|
||||
chdir(t, t.TempDir())
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(mediaStub("tok001", "https://example.com/download/1"))
|
||||
reg.Register(mediaStub("tok002", "https://example.com/download/2"))
|
||||
reg.Register(downloadStub("example.com/download/1", []byte("content-1"), "video/mp4"))
|
||||
reg.Register(downloadStub("example.com/download/2", []byte("content-2"), "video/mp4"))
|
||||
|
||||
err := mountAndRun(t, MinutesDownload, []string{
|
||||
"+download", "--minute-tokens", "tok001,tok002", "--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// verify output structure
|
||||
var result struct {
|
||||
Data struct {
|
||||
Downloads []struct {
|
||||
MinuteToken string `json:"minute_token"`
|
||||
SavedPath string `json:"saved_path"`
|
||||
} `json:"downloads"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
|
||||
t.Fatalf("failed to parse output: %v\nraw: %s", err, stdout.String())
|
||||
}
|
||||
if len(result.Data.Downloads) != 2 {
|
||||
t.Fatalf("expected 2 downloads, got %d", len(result.Data.Downloads))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownload_Batch_PartialFailure(t *testing.T) {
|
||||
chdir(t, t.TempDir())
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(mediaStub("tok001", "https://example.com/download/1"))
|
||||
reg.Register(downloadStub("example.com/download/1", []byte("content-1"), "video/mp4"))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/minutes/v1/minutes/tok002/media",
|
||||
Status: 200,
|
||||
Body: map[string]interface{}{
|
||||
"code": 99999, "msg": "permission denied",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, MinutesDownload, []string{
|
||||
"+download", "--minute-tokens", "tok001,tok002", "--as", "bot",
|
||||
}, f, stdout)
|
||||
// partial failure should not cause an overall error
|
||||
if err != nil {
|
||||
t.Fatalf("partial failure should not return error, got: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "tok001") || !strings.Contains(out, "tok002") {
|
||||
t.Errorf("output should contain both tokens, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownload_Batch_DuplicateToken(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
// register media stub only once — dedup means only one API call
|
||||
reg.Register(mediaStub("tok001", "https://example.com/download/1"))
|
||||
|
||||
err := mountAndRun(t, MinutesDownload, []string{
|
||||
"+download", "--minute-tokens", "tok001,tok001", "--url-only", "--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "duplicate") {
|
||||
t.Errorf("second token should report duplicate, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownload_Batch_DryRun(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, MinutesDownload, []string{
|
||||
"+download", "--minute-tokens", "tok001,tok002", "--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "tok001") || !strings.Contains(out, "tok002") {
|
||||
t.Errorf("dry-run should show tokens, got: %s", out)
|
||||
}
|
||||
}
|
||||
13
shortcuts/minutes/shortcuts.go
Normal file
13
shortcuts/minutes/shortcuts.go
Normal file
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package minutes
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
// Shortcuts returns all minutes shortcuts.
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
MinutesDownload,
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/event"
|
||||
"github.com/larksuite/cli/shortcuts/im"
|
||||
"github.com/larksuite/cli/shortcuts/mail"
|
||||
"github.com/larksuite/cli/shortcuts/minutes"
|
||||
"github.com/larksuite/cli/shortcuts/sheets"
|
||||
"github.com/larksuite/cli/shortcuts/task"
|
||||
"github.com/larksuite/cli/shortcuts/vc"
|
||||
@@ -36,6 +37,7 @@ func init() {
|
||||
allShortcuts = append(allShortcuts, base.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, event.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, mail.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, minutes.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, task.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, vc.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, whiteboard.Shortcuts()...)
|
||||
|
||||
@@ -23,6 +23,8 @@ var (
|
||||
cellRefPattern = regexp.MustCompile(`^([A-Za-z]+)([1-9][0-9]*)$`)
|
||||
)
|
||||
|
||||
var sheetRangeSeparatorReplacer = strings.NewReplacer(`\!`, "!", `\!`, "!", "!", "!")
|
||||
|
||||
// getFirstSheetID queries the spreadsheet and returns the first sheet's ID.
|
||||
func getFirstSheetID(runtime *common.RuntimeContext, spreadsheetToken string) (string, error) {
|
||||
data, err := runtime.CallAPI("GET", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/query", validate.EncodePathSegment(spreadsheetToken)), nil, nil)
|
||||
@@ -56,7 +58,7 @@ func extractSpreadsheetToken(input string) string {
|
||||
}
|
||||
|
||||
func normalizeSheetRange(sheetID, input string) string {
|
||||
input = strings.TrimSpace(input)
|
||||
input = normalizeSheetRangeSeparators(input)
|
||||
if input == "" || strings.Contains(input, "!") || sheetID == "" {
|
||||
return input
|
||||
}
|
||||
@@ -80,7 +82,7 @@ func normalizePointRange(sheetID, input string) string {
|
||||
|
||||
func normalizeWriteRange(sheetID, input string, values interface{}) string {
|
||||
rows, cols := matrixDimensions(values)
|
||||
input = strings.TrimSpace(input)
|
||||
input = normalizeSheetRangeSeparators(input)
|
||||
if input == "" {
|
||||
return buildRectRange(sheetID, "A1", rows, cols)
|
||||
}
|
||||
@@ -97,7 +99,7 @@ func normalizeWriteRange(sheetID, input string, values interface{}) string {
|
||||
}
|
||||
|
||||
func validateSheetRangeInput(sheetID, input string) error {
|
||||
input = strings.TrimSpace(input)
|
||||
input = normalizeSheetRangeSeparators(input)
|
||||
if input == "" || strings.Contains(input, "!") || sheetID != "" {
|
||||
return nil
|
||||
}
|
||||
@@ -108,7 +110,7 @@ func validateSheetRangeInput(sheetID, input string) error {
|
||||
}
|
||||
|
||||
func looksLikeRelativeRange(input string) bool {
|
||||
input = strings.TrimSpace(input)
|
||||
input = normalizeSheetRangeSeparators(input)
|
||||
if input == "" {
|
||||
return false
|
||||
}
|
||||
@@ -120,13 +122,21 @@ func looksLikeRelativeRange(input string) bool {
|
||||
}
|
||||
|
||||
func splitSheetRange(input string) (sheetID, subRange string, ok bool) {
|
||||
parts := strings.SplitN(strings.TrimSpace(input), "!", 2)
|
||||
parts := strings.SplitN(normalizeSheetRangeSeparators(input), "!", 2)
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return parts[0], parts[1], true
|
||||
}
|
||||
|
||||
func normalizeSheetRangeSeparators(input string) string {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return input
|
||||
}
|
||||
return sheetRangeSeparatorReplacer.Replace(input)
|
||||
}
|
||||
|
||||
func buildRectRange(sheetID, anchor string, rows, cols int) string {
|
||||
if sheetID == "" {
|
||||
return ""
|
||||
|
||||
148
shortcuts/sheets/sheet_ranges_test.go
Normal file
148
shortcuts/sheets/sheet_ranges_test.go
Normal file
@@ -0,0 +1,148 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func mustMarshalSheetsDryRun(t *testing.T, v interface{}) string {
|
||||
t.Helper()
|
||||
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal() error = %v", err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func newSheetsTestRuntime(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
for name := range stringFlags {
|
||||
cmd.Flags().String(name, "", "")
|
||||
}
|
||||
for name := range boolFlags {
|
||||
cmd.Flags().Bool(name, false, "")
|
||||
}
|
||||
if err := cmd.ParseFlags(nil); err != nil {
|
||||
t.Fatalf("ParseFlags() error = %v", err)
|
||||
}
|
||||
for name, value := range stringFlags {
|
||||
if err := cmd.Flags().Set(name, value); err != nil {
|
||||
t.Fatalf("Flags().Set(%q) error = %v", name, err)
|
||||
}
|
||||
}
|
||||
for name, value := range boolFlags {
|
||||
if err := cmd.Flags().Set(name, map[bool]string{true: "true", false: "false"}[value]); err != nil {
|
||||
t.Fatalf("Flags().Set(%q) error = %v", name, err)
|
||||
}
|
||||
}
|
||||
return &common.RuntimeContext{Cmd: cmd}
|
||||
}
|
||||
|
||||
func TestNormalizeSheetRangeSeparators(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{name: "standard", input: "sheet_123!A1:B2", want: "sheet_123!A1:B2"},
|
||||
{name: "escaped ascii", input: `sheet_123\!A1:B2`, want: "sheet_123!A1:B2"},
|
||||
{name: "fullwidth", input: "sheet_123!A1:B2", want: "sheet_123!A1:B2"},
|
||||
{name: "escaped fullwidth", input: `sheet_123\!A1:B2`, want: "sheet_123!A1:B2"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := normalizeSheetRangeSeparators(tt.input); got != tt.want {
|
||||
t.Fatalf("normalizeSheetRangeSeparators(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSheetRangeInputAcceptsEscapedSeparator(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if err := validateSheetRangeInput("", `sheet_123\!A1:B2`); err != nil {
|
||||
t.Fatalf("validateSheetRangeInput() error = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetReadDryRunNormalizesEscapedSeparator(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newSheetsTestRuntime(t, map[string]string{
|
||||
"spreadsheet-token": "sht_test",
|
||||
"range": `sheet_123\!A1`,
|
||||
"sheet-id": "",
|
||||
}, nil)
|
||||
|
||||
got := mustMarshalSheetsDryRun(t, SheetRead.DryRun(context.Background(), runtime))
|
||||
if !strings.Contains(got, `"range":"sheet_123!A1:A1"`) {
|
||||
t.Fatalf("SheetRead.DryRun() = %s, want normalized escaped separator", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetWriteDryRunNormalizesEscapedSeparator(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newSheetsTestRuntime(t, map[string]string{
|
||||
"spreadsheet-token": "sht_test",
|
||||
"range": `sheet_123\!A1:B2`,
|
||||
"values": `[[1,2],[3,4]]`,
|
||||
}, nil)
|
||||
|
||||
got := mustMarshalSheetsDryRun(t, SheetWrite.DryRun(context.Background(), runtime))
|
||||
if !strings.Contains(got, `"range":"sheet_123!A1:B2"`) {
|
||||
t.Fatalf("SheetWrite.DryRun() = %s, want normalized escaped separator", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetAppendDryRunNormalizesEscapedSeparator(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newSheetsTestRuntime(t, map[string]string{
|
||||
"spreadsheet-token": "sht_test",
|
||||
"range": `sheet_123\!A1:B2`,
|
||||
"values": `[["foo","bar"]]`,
|
||||
}, nil)
|
||||
|
||||
got := mustMarshalSheetsDryRun(t, SheetAppend.DryRun(context.Background(), runtime))
|
||||
if !strings.Contains(got, `"range":"sheet_123!A1:B2"`) {
|
||||
t.Fatalf("SheetAppend.DryRun() = %s, want normalized escaped separator", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetFindDryRunNormalizesEscapedSeparator(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newSheetsTestRuntime(t, map[string]string{
|
||||
"spreadsheet-token": "sht_test",
|
||||
"sheet-id": "sheet_123",
|
||||
"find": "target",
|
||||
"range": `sheet_123\!A1:B2`,
|
||||
}, map[string]bool{
|
||||
"ignore-case": false,
|
||||
"match-entire-cell": false,
|
||||
"search-by-regex": false,
|
||||
"include-formulas": false,
|
||||
})
|
||||
|
||||
got := mustMarshalSheetsDryRun(t, SheetFind.DryRun(context.Background(), runtime))
|
||||
if !strings.Contains(got, `"range":"sheet_123!A1:B2"`) {
|
||||
t.Fatalf("SheetFind.DryRun() = %s, want normalized escaped separator", got)
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{"code":0,"data":{},"msg":"ok"}
|
||||
@@ -1 +0,0 @@
|
||||
{"code":0,"data":{},"msg":"ok"}
|
||||
@@ -1 +0,0 @@
|
||||
{"code":0,"data":{},"msg":"ok"}
|
||||
@@ -92,6 +92,8 @@ lark-cli mail +reply --message-id <id> --body '收到,谢谢'
|
||||
|
||||
`+message`、`+messages`、`+thread` 默认返回 HTML 正文(`--html=true`)。仅需确认操作结果(如验证标记已读、移动文件夹是否成功)时,用 `--html=false` 跳过 HTML 正文,只返回纯文本,显著减少 token 消耗。
|
||||
|
||||
输出默认为结构化 JSON,可直接读取,无需额外编码转换。
|
||||
|
||||
```bash
|
||||
# ✅ 验证操作结果:不需要 HTML
|
||||
lark-cli mail +message --message-id <id> --html=false
|
||||
|
||||
48
skills/lark-approval/SKILL.md
Normal file
48
skills/lark-approval/SKILL.md
Normal file
@@ -0,0 +1,48 @@
|
||||
---
|
||||
name: lark-approval
|
||||
version: 1.0.0
|
||||
description: "飞书审批 API:审批实例、审批任务管理。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
cliHelp: "lark-cli approval --help"
|
||||
---
|
||||
|
||||
# approval (v4)
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
|
||||
|
||||
## API Resources
|
||||
|
||||
```bash
|
||||
lark-cli schema approval.<resource>.<method> # 调用 API 前必须先查看参数结构
|
||||
lark-cli approval <resource> <method> [flags] # 调用 API
|
||||
```
|
||||
|
||||
> **重要**:使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜测字段格式。
|
||||
|
||||
### instances
|
||||
|
||||
- `get` — 获取单个审批实例详情
|
||||
- `cancel` — 撤回审批实例
|
||||
- `cc` — 抄送审批实例
|
||||
|
||||
### tasks
|
||||
|
||||
- `approve` — 同意审批任务
|
||||
- `reject` — 拒绝审批任务
|
||||
- `transfer` — 转交审批任务
|
||||
- `query` — 查询用户的任务列表
|
||||
|
||||
## 权限表
|
||||
|
||||
| 方法 | 所需 scope |
|
||||
|------|-----------|
|
||||
| `instances.get` | `approval:instance:read` |
|
||||
| `instances.cancel` | `approval:instance:write` |
|
||||
| `instances.cc` | `approval:instance:write` |
|
||||
| `tasks.approve` | `approval:task:write` |
|
||||
| `tasks.reject` | `approval:task:write` |
|
||||
| `tasks.transfer` | `approval:task:write` |
|
||||
| `tasks.query` | `approval:task:read` |
|
||||
|
||||
@@ -16,6 +16,11 @@
|
||||
|
||||
## 命令
|
||||
|
||||
> **关键约束:搜索关键词必须通过 `--query` 传递。**
|
||||
> 正确:`lark-cli docs +search --query "方案"`
|
||||
> 错误:`lark-cli docs +search 方案`
|
||||
> `+search` 不接受“搜索词位置参数”这种写法;如果把关键词直接跟在命令后面,不会进入 `query`,会变成空搜或返回不符合预期的结果。
|
||||
|
||||
```bash
|
||||
# 关键词搜索
|
||||
lark-cli docs +search --query "季度总结"
|
||||
@@ -46,7 +51,7 @@ lark-cli docs +search --query "方案" --format json --page-token '<PAGE_TOKEN>'
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--query <text>` | 否 | 搜索关键词。默认是关键词检索,不是精确标题匹配;不传/空字符串表示空搜 |
|
||||
| `--query <text>` | 否 | 搜索关键词。默认是关键词检索,不是精确标题匹配;不传/空字符串表示空搜。**凡是有关键词,都要显式通过 `--query` 传递,不要写成位置参数。** |
|
||||
| `--filter <json>` | 否 | JSON 对象,会同时应用到 `doc_filter` 与 `wiki_filter` |
|
||||
| `--page-size <n>` | 否 | 每页数量(默认 15,最大 20) |
|
||||
| `--page-token <token>` | 否 | 翻页标记(配合 `has_more` 使用) |
|
||||
@@ -59,6 +64,7 @@ lark-cli docs +search --query "方案" --format json --page-token '<PAGE_TOKEN>'
|
||||
|
||||
## 决策规则
|
||||
|
||||
- 参数传递:只要用户给了搜索关键词,就必须显式使用 `--query "<关键词>"`。不要生成 `lark-cli docs +search 方案`、`lark-cli docs +search xxx(搜索关键词)` 这种位置参数写法。
|
||||
- 查询语义:默认按关键词搜索理解。用户说“标题为 `X`”“标题里有 `X`”“搜索 `X` 文档”时,先直接返回命中的 OpenAPI 结果;只有用户明确要求“标题精确等于 `X`”时,才做客户端二次筛选。做精确匹配前,先去掉 `title_highlighted` 里的高亮标签。
|
||||
- 入口选择:用户说“找表格标题”“找名为 `X` 的电子表格”“搜某个报表”时,也默认走 `docs +search`。不要误用 `sheets +find` 做跨文件搜索。
|
||||
- 分页策略:默认只返回**第一页**,并说明 `has_more` / `page_token`。只有当用户明确要求“全部结果”“继续翻页”“全量扫描”“所有结果”“完整列表”时,才继续翻页。
|
||||
|
||||
@@ -164,6 +164,11 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`)
|
||||
| [`+upload`](references/lark-drive-upload.md) | Upload a local file to Drive |
|
||||
| [`+download`](references/lark-drive-download.md) | Download a file from Drive to local |
|
||||
| [`+add-comment`](references/lark-drive-add-comment.md) | Add a full-document comment, or a local comment to selected docx text (also supports wiki URL resolving to doc/docx) |
|
||||
| [`+export`](references/lark-drive-export.md) | Export a doc/docx/sheet/bitable to a local file with limited polling |
|
||||
| [`+export-download`](references/lark-drive-export-download.md) | Download an exported file by file_token |
|
||||
| [`+import`](references/lark-drive-import.md) | Import a local file to Drive as a cloud document (docx, sheet, bitable) |
|
||||
| [`+move`](references/lark-drive-move.md) | Move a file or folder to another location in Drive |
|
||||
| [`+task_result`](references/lark-drive-task-result.md) | Poll async task result for import, export, move, or delete operations |
|
||||
|
||||
## API Resources
|
||||
|
||||
@@ -177,6 +182,8 @@ lark-cli drive <resource> <method> [flags] # 调用 API
|
||||
### files
|
||||
|
||||
- `copy` — 复制文件
|
||||
- `create_folder` — 新建文件夹
|
||||
- `list` — 获取文件夹下的清单
|
||||
|
||||
### file.comments
|
||||
|
||||
@@ -208,11 +215,21 @@ lark-cli drive <resource> <method> [flags] # 调用 API
|
||||
- `subscription` — 订阅用户、应用维度事件(本次开放评论添加事件)
|
||||
- `subscription_status` — 查询用户、应用对指定事件的订阅状态
|
||||
|
||||
### file.statistics
|
||||
|
||||
- `get` — 获取文件统计信息
|
||||
|
||||
### file.view_records
|
||||
|
||||
- `list` — 获取文档的访问者记录
|
||||
|
||||
## 权限表
|
||||
|
||||
| 方法 | 所需 scope |
|
||||
|------|-----------|
|
||||
| `files.copy` | `docs:document:copy` |
|
||||
| `files.create_folder` | `space:folder:create` |
|
||||
| `files.list` | `space:document:retrieve` |
|
||||
| `file.comments.batch_query` | `docs:document.comment:read` |
|
||||
| `file.comments.create_v2` | `docs:document.comment:create` |
|
||||
| `file.comments.list` | `docs:document.comment:read` |
|
||||
@@ -228,4 +245,5 @@ lark-cli drive <resource> <method> [flags] # 调用 API
|
||||
| `user.remove_subscription` | `docs:event:subscribe` |
|
||||
| `user.subscription` | `docs:event:subscribe` |
|
||||
| `user.subscription_status` | `docs:event:subscribe` |
|
||||
|
||||
| `file.statistics.get` | `drive:drive.metadata:readonly` |
|
||||
| `file.view_records.list` | `drive:file:view_record:readonly` |
|
||||
|
||||
50
skills/lark-drive/references/lark-drive-export-download.md
Normal file
50
skills/lark-drive/references/lark-drive-export-download.md
Normal file
@@ -0,0 +1,50 @@
|
||||
|
||||
# drive +export-download
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
根据导出任务产物的 `file_token` 下载本地文件。通常与 `drive +task_result --scenario export` 配合使用。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 使用服务端返回的文件名下载到当前目录
|
||||
lark-cli drive +export-download \
|
||||
--file-token "<EXPORTED_FILE_TOKEN>"
|
||||
|
||||
# 下载到指定目录
|
||||
lark-cli drive +export-download \
|
||||
--file-token "<EXPORTED_FILE_TOKEN>" \
|
||||
--output-dir ./exports
|
||||
|
||||
# 指定本地文件名
|
||||
lark-cli drive +export-download \
|
||||
--file-token "<EXPORTED_FILE_TOKEN>" \
|
||||
--file-name "weekly-report.pdf" \
|
||||
--output-dir ./exports
|
||||
|
||||
# 允许覆盖
|
||||
lark-cli drive +export-download \
|
||||
--file-token "<EXPORTED_FILE_TOKEN>" \
|
||||
--overwrite
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--file-token` | 是 | 导出完成后的产物 token |
|
||||
| `--file-name` | 否 | 覆盖默认文件名 |
|
||||
| `--output-dir` | 否 | 本地输出目录,默认当前目录 |
|
||||
| `--overwrite` | 否 | 覆盖已存在文件 |
|
||||
|
||||
## 使用顺序
|
||||
|
||||
1. 用 `drive +export` 发起导出
|
||||
2. 如果返回 `ticket` / `next_command`,用 `drive +task_result --scenario export --ticket <ticket> --file-token <source_token>` 继续查
|
||||
3. 查到 `file_token` 后,用 `drive +export-download` 下载
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-drive](../SKILL.md) -- 云空间全部命令
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
100
skills/lark-drive/references/lark-drive-export.md
Normal file
100
skills/lark-drive/references/lark-drive-export.md
Normal file
@@ -0,0 +1,100 @@
|
||||
|
||||
# drive +export
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
把 `doc` / `docx` / `sheet` / `bitable` 导出到本地文件。这个 shortcut 内置有限轮询:
|
||||
|
||||
- 如果导出任务在轮询窗口内完成,会直接下载到本地目录
|
||||
- 如果轮询结束仍未完成,会返回 `ticket`、`ready=false`、`timed_out=true` 和 `next_command`
|
||||
- 后续继续查结果时,改用 `drive +task_result --scenario export`
|
||||
- 拿到 `file_token` 后,改用 `drive +export-download`
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 导出新版文档为 pdf,默认保存到当前目录
|
||||
lark-cli drive +export \
|
||||
--token "<DOCX_TOKEN>" \
|
||||
--doc-type docx \
|
||||
--file-extension pdf
|
||||
|
||||
# 导出旧版文档为 docx
|
||||
lark-cli drive +export \
|
||||
--token "<DOC_TOKEN>" \
|
||||
--doc-type doc \
|
||||
--file-extension docx
|
||||
|
||||
# 导出 docx 为 markdown
|
||||
# 注意:markdown 只支持 docx,底层走 /open-apis/docs/v1/content
|
||||
lark-cli drive +export \
|
||||
--token "<DOCX_TOKEN>" \
|
||||
--doc-type docx \
|
||||
--file-extension markdown
|
||||
|
||||
# 导出电子表格为 xlsx
|
||||
lark-cli drive +export \
|
||||
--token "<SHEET_TOKEN>" \
|
||||
--doc-type sheet \
|
||||
--file-extension xlsx \
|
||||
--output-dir ./exports
|
||||
|
||||
# 导出电子表格或多维表格为 csv 时,必须传 sub_id
|
||||
lark-cli drive +export \
|
||||
--token "<SHEET_OR_BITABLE_TOKEN>" \
|
||||
--doc-type "<sheet|bitable>" \
|
||||
--file-extension csv \
|
||||
--sub-id "<SUB_ID>" \
|
||||
--output-dir ./exports
|
||||
|
||||
# 允许覆盖已存在文件
|
||||
lark-cli drive +export \
|
||||
--token "<DOCX_TOKEN>" \
|
||||
--doc-type docx \
|
||||
--file-extension pdf \
|
||||
--overwrite
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--token` | 是 | 源文档 token |
|
||||
| `--doc-type` | 是 | 源文档类型:`doc` / `docx` / `sheet` / `bitable` |
|
||||
| `--file-extension` | 是 | 导出格式:`docx` / `pdf` / `xlsx` / `csv` / `markdown` |
|
||||
| `--sub-id` | 条件必填 | 当 `sheet` / `bitable` 导出为 `csv` 时必填 |
|
||||
| `--output-dir` | 否 | 本地输出目录,默认当前目录 |
|
||||
| `--overwrite` | 否 | 覆盖已存在文件 |
|
||||
|
||||
## 关键约束
|
||||
|
||||
- `markdown` 只支持 `docx`
|
||||
- `sheet` / `bitable` 导出为 `csv` 时必须带 `--sub-id`
|
||||
- shortcut 内部固定有限轮询:最多 10 次,每次间隔 5 秒
|
||||
- 轮询超时不是失败;会返回 `ticket`、`timed_out=true` 和 `next_command`,供后续继续查询
|
||||
|
||||
## 推荐续跑方式
|
||||
|
||||
```bash
|
||||
# 第一步:先尝试直接导出
|
||||
lark-cli drive +export \
|
||||
--token "<DOCX_TOKEN>" \
|
||||
--doc-type docx \
|
||||
--file-extension pdf
|
||||
|
||||
# 如果返回 ready=false / timed_out=true,再继续查
|
||||
lark-cli drive +task_result \
|
||||
--scenario export \
|
||||
--ticket "<TICKET>" \
|
||||
--file-token "<DOCX_TOKEN>"
|
||||
|
||||
# 查到 file_token 后下载
|
||||
lark-cli drive +export-download \
|
||||
--file-token "<EXPORTED_FILE_TOKEN>" \
|
||||
--output-dir ./exports
|
||||
```
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-drive](../SKILL.md) -- 云空间全部命令
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
102
skills/lark-drive/references/lark-drive-import.md
Normal file
102
skills/lark-drive/references/lark-drive-import.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# drive +import
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
将本地文件(如 Word、TXT、Markdown、Excel 等)导入并转换为飞书在线云文档(docx、sheet、bitable)。底层统一通过 `POST /open-apis/drive/v1/import_tasks` 接口创建导入任务,并在 shortcut 内做有限次数轮询 `GET /open-apis/drive/v1/import_tasks/:ticket`。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 导入 Markdown 为新版文档 (docx)
|
||||
lark-cli drive +import --file ./README.md --type docx
|
||||
|
||||
# 导入 Excel 为电子表格 (sheet)
|
||||
lark-cli drive +import --file ./data.xlsx --type sheet
|
||||
|
||||
# 导入到指定文件夹,并指定导入后的文件名
|
||||
lark-cli drive +import --file ./data.csv --type bitable --folder-token <FOLDER_TOKEN> --name "导入数据表"
|
||||
|
||||
# 预览底层调用链(上传 -> 创建任务 -> 轮询)
|
||||
lark-cli drive +import --file ./README.md --type docx --dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--file` | 是 | 本地文件路径,根据文件后缀名自动推断 `file_extension`;文件需满足对应格式的导入大小限制,超过 20MB 且仍在允许范围内时会自动切换分片上传 |
|
||||
| `--type` | 是 | 导入目标云文档格式。可选值:`docx` (新版文档)、`sheet` (电子表格)、`bitable` (多维表格) |
|
||||
| `--folder-token` | 否 | 目标文件夹 token,不传则请求中的 `point.mount_key` 为空字符串,Import API 会将其解释为导入到云空间根目录 |
|
||||
| `--name` | 否 | 导入后的在线云文档名称,不传默认使用本地文件名去掉扩展名后的结果 |
|
||||
|
||||
## 行为说明
|
||||
|
||||
- **完整执行流程**:此 shortcut 内部封装了完整流程:
|
||||
1. 自动上传源文件获取 `file_token`:
|
||||
- 20MB 及以下:调用素材上传接口 `POST /open-apis/drive/v1/medias/upload_all`
|
||||
- 超过 20MB:自动切换为分片上传 `upload_prepare -> upload_part -> upload_finish`
|
||||
2. 调用 `import_tasks` 接口发起导入任务,自动根据本地文件提取扩展名并构造挂载点(`mount_point`)参数
|
||||
3. 自动轮询查询导入任务状态;如果在内置轮询窗口内完成,则直接返回导入结果;如果仍未完成,则返回 `ticket`、当前状态和后续查询命令
|
||||
- **默认根目录行为**:不传 `--folder-token` 时,shortcut 会保留空的 `point.mount_key`,Lark Import API 会将其视为“导入到调用者根目录”。
|
||||
|
||||
### 支持的文件类型转换
|
||||
|
||||
本地文件扩展名与目标云文档类型的对应关系如下:
|
||||
|
||||
| 本地文件扩展名 | 可导入为 | 说明 |
|
||||
|--------------|---------|------|
|
||||
| `.docx`, `.doc` | `docx` | Microsoft Word 文档 |
|
||||
| `.txt` | `docx` | 纯文本文件 |
|
||||
| `.md`, `.markdown`, `.mark` | `docx` | Markdown 文档 |
|
||||
| `.html` | `docx` | HTML 文档 |
|
||||
| `.xlsx` | `sheet`, `bitable` | Microsoft Excel 表格 |
|
||||
| `.xls` | `sheet` | Microsoft Excel 97-2003 表格 |
|
||||
| `.csv` | `sheet`, `bitable` | CSV 数据文件 |
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 文件扩展名与目标文档类型必须匹配,否则会返回验证错误:
|
||||
> - 文档类文件(.docx, .doc, .txt, .md, .html)**只能**导入为 `docx`
|
||||
> - `.xlsx` / `.csv` 文件**只能**导入为 `sheet` 或 `bitable`
|
||||
> - `.xls` 文件**只能**导入为 `sheet`
|
||||
> - 例如:`.csv` 文件不能导入为 `docx`,`.md` 文件不能导入为 `sheet`
|
||||
|
||||
### 文件大小限制
|
||||
|
||||
除扩展名与目标类型匹配外,`drive +import` 还会在本地上传前校验格式级大小限制:
|
||||
|
||||
| 本地文件扩展名 | 导入目标 | 大小上限 |
|
||||
|--------------|---------|---------|
|
||||
| `.docx`, `.doc` | `docx` | 600MB |
|
||||
| `.txt` | `docx` | 20MB |
|
||||
| `.md`, `.mark`, `.markdown` | `docx` | 20MB |
|
||||
| `.html` | `docx` | 20MB |
|
||||
| `.xlsx` | `sheet`, `bitable` | 800MB |
|
||||
| `.csv` | `sheet` | 20MB |
|
||||
| `.csv` | `bitable` | 100MB |
|
||||
| `.xls` | `sheet` | 20MB |
|
||||
|
||||
- 如果文件超出对应上限,shortcut 会在真正上传前直接返回验证错误。
|
||||
- “超过 20MB 自动切换分片上传”只表示上传链路会切到 multipart,不代表所有格式都允许导入超过 20MB 的文件。
|
||||
|
||||
- 若导入任务执行失败,会返回失败时的 `job_status` 及错误信息。
|
||||
- 若内置轮询超时但任务仍在处理中,shortcut 会成功返回,并带上:
|
||||
- `ready=false`
|
||||
- `timed_out=true`
|
||||
- `next_command`:可直接复制执行的后续查询命令,例如 `lark-cli drive +task_result --scenario import --ticket <TICKET>`
|
||||
- 如果文件扩展名不被支持,执行时将抛出验证错误。
|
||||
|
||||
### 超时后的继续查询
|
||||
|
||||
当 `+import` 的内置轮询窗口结束但任务尚未完成时,使用返回结果中的 `ticket` 继续查询:
|
||||
|
||||
```bash
|
||||
lark-cli drive +task_result --scenario import --ticket <TICKET>
|
||||
```
|
||||
|
||||
> [!CAUTION]
|
||||
> `drive +import` 是**写入操作** —— 执行前必须确认用户意图。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-drive](../SKILL.md) -- 云空间全部命令
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
92
skills/lark-drive/references/lark-drive-move.md
Normal file
92
skills/lark-drive/references/lark-drive-move.md
Normal file
@@ -0,0 +1,92 @@
|
||||
|
||||
# drive +move
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
将文件或文件夹移动到用户云空间的其他位置。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 移动文件到指定文件夹
|
||||
lark-cli drive +move \
|
||||
--file-token <FILE_TOKEN> \
|
||||
--type file \
|
||||
--folder-token <TARGET_FOLDER_TOKEN>
|
||||
|
||||
# 移动文档到指定文件夹
|
||||
lark-cli drive +move \
|
||||
--file-token <DOCX_TOKEN> \
|
||||
--type docx \
|
||||
--folder-token <TARGET_FOLDER_TOKEN>
|
||||
|
||||
# 移动文件夹(异步操作,会自动有限轮询任务状态)
|
||||
lark-cli drive +move \
|
||||
--file-token <FOLDER_TOKEN> \
|
||||
--type folder \
|
||||
--folder-token <TARGET_FOLDER_TOKEN>
|
||||
|
||||
# 移动到根文件夹(不指定 --folder-token)
|
||||
lark-cli drive +move \
|
||||
--file-token <FILE_TOKEN> \
|
||||
--type file
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--file-token` | 是 | 需要移动的文件或文件夹 token |
|
||||
| `--type` | 是 | 文件类型,可选值:`file` (普通文件)、`docx` (新版文档)、`bitable` (多维表格)、`doc` (旧版文档)、`sheet` (电子表格)、`mindnote` (思维笔记)、`folder` (文件夹)、`slides` (幻灯片) |
|
||||
| `--folder-token` | 否 | 目标文件夹 token,不指定则移动到根文件夹 |
|
||||
|
||||
## 文件类型说明
|
||||
|
||||
| 类型 | 说明 |
|
||||
|------|------|
|
||||
| `file` | 普通文件 |
|
||||
| `docx` | 新版云文档 |
|
||||
| `doc` | 旧版云文档 |
|
||||
| `sheet` | 电子表格 |
|
||||
| `bitable` | 多维表格 |
|
||||
| `mindnote` | 思维笔记 |
|
||||
| `slides` | 幻灯片 |
|
||||
| `folder` | 文件夹(移动文件夹是异步操作) |
|
||||
|
||||
## 行为说明
|
||||
|
||||
- **普通文件移动**:同步操作,立即完成
|
||||
- **文件夹移动**:异步操作,接口返回 `task_id`,shortcut 会先做有限轮询;如果在轮询窗口内完成,则直接返回成功结果
|
||||
- **轮询超时不是失败**:文件夹移动内置最多轮询 30 次、每次间隔 2 秒;如果轮询结束任务仍未完成,会返回 `task_id`、`status`、`ready=false`、`timed_out=true` 和 `next_command`
|
||||
- **继续查询**:当看到 `next_command` 时,改用 `lark-cli drive +task_result --scenario task_check --task-id <TASK_ID>` 继续查询
|
||||
- **目标文件夹**:如果不指定 `--folder-token`,文件将被移动到用户的根文件夹("我的空间")
|
||||
- **权限要求**:需要被移动文件的可管理权限、被移动文件所在位置的编辑权限、目标位置的编辑权限
|
||||
|
||||
## 推荐续跑方式
|
||||
|
||||
```bash
|
||||
# 第一步:先直接移动文件夹
|
||||
lark-cli drive +move \
|
||||
--file-token <FOLDER_TOKEN> \
|
||||
--type folder \
|
||||
--folder-token <TARGET_FOLDER_TOKEN>
|
||||
|
||||
# 如果返回 ready=false / timed_out=true,再继续查
|
||||
lark-cli drive +task_result \
|
||||
--scenario task_check \
|
||||
--task-id <TASK_ID>
|
||||
```
|
||||
|
||||
## 限制
|
||||
|
||||
- 被移动的文件不支持 wiki 文档
|
||||
- 该接口不支持并发调用
|
||||
- 调用频率上限为 5 QPS 且 10000 次/天
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是**写入操作** —— 执行前必须确认用户意图。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-drive](../SKILL.md) -- 云空间全部命令
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
170
skills/lark-drive/references/lark-drive-task-result.md
Normal file
170
skills/lark-drive/references/lark-drive-task-result.md
Normal file
@@ -0,0 +1,170 @@
|
||||
|
||||
# drive +task_result
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
查询异步任务结果。该 shortcut 聚合了导入、导出、移动/删除文件夹等多种异步任务的结果查询,统一接口方便调用。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 查询导入任务结果
|
||||
lark-cli drive +task_result \
|
||||
--scenario import \
|
||||
--ticket <IMPORT_TICKET>
|
||||
|
||||
# 查询导出任务结果
|
||||
lark-cli drive +task_result \
|
||||
--scenario export \
|
||||
--ticket <EXPORT_TICKET> \
|
||||
--file-token <SOURCE_DOC_TOKEN>
|
||||
|
||||
# 查询移动/删除文件夹任务状态
|
||||
lark-cli drive +task_result \
|
||||
--scenario task_check \
|
||||
--task-id <TASK_ID>
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--scenario` | 是 | 任务场景,可选值:`import` (导入任务)、`export` (导出任务)、`task_check` (移动/删除文件夹任务) |
|
||||
| `--ticket` | 条件必填 | 异步任务 ticket,**import/export 场景必填** |
|
||||
| `--task-id` | 条件必填 | 异步任务 ID,**task_check 场景必填** |
|
||||
| `--file-token` | 条件必填 | 导出任务对应的源文档 token,**export 场景必填** |
|
||||
|
||||
## 场景说明
|
||||
|
||||
| 场景 | 说明 | 所需参数 |
|
||||
|------|------|----------|
|
||||
| `import` | 文档导入任务(如将本地文件导入为云文档) | `--ticket` |
|
||||
| `export` | 文档导出任务(如云文档导出为 PDF/Word) | `--ticket`、`--file-token` |
|
||||
| `task_check` | 文件夹移动/删除任务 | `--task-id` |
|
||||
|
||||
## 返回结果
|
||||
|
||||
### Import 场景返回
|
||||
|
||||
```json
|
||||
{
|
||||
"scenario": "import",
|
||||
"ticket": "<IMPORT_TICKET>",
|
||||
"type": "sheet",
|
||||
"ready": true,
|
||||
"failed": false,
|
||||
"job_status": 0,
|
||||
"job_status_label": "success",
|
||||
"job_error_msg": "success",
|
||||
"token": "<IMPORTED_DOC_TOKEN>",
|
||||
"url": "https://example.feishu.cn/sheets/<IMPORTED_DOC_TOKEN>",
|
||||
"extra": ["2000"]
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明:**
|
||||
- `ready`: 是否已经导入完成,可直接使用 `token` / `url`
|
||||
- `failed`: 是否已经失败
|
||||
- `job_status`: 服务端返回的原始状态码
|
||||
- `job_status_label`: 便于阅读的状态标签,例如 `success` / `processing`
|
||||
- `token`: 导入后的文档 token
|
||||
- `url`: 导入后的文档链接
|
||||
|
||||
### Export 场景返回
|
||||
|
||||
```json
|
||||
{
|
||||
"scenario": "export",
|
||||
"ticket": "<EXPORT_TICKET>",
|
||||
"ready": true,
|
||||
"failed": false,
|
||||
"file_extension": "pdf",
|
||||
"type": "doc",
|
||||
"file_name": "docName",
|
||||
"file_token": "<EXPORTED_FILE_TOKEN>",
|
||||
"file_size": 34356,
|
||||
"job_error_msg": "success",
|
||||
"job_status": 0,
|
||||
"job_status_label": "success"
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明:**
|
||||
- `ready`: 是否已经完成导出,可直接使用 `file_token`
|
||||
- `failed`: 是否已经失败
|
||||
- `job_status`: 服务端返回的原始状态码
|
||||
- `job_status_label`: 便于阅读的状态标签,例如 `success` / `processing`
|
||||
- `file_token`: 导出文件的 token,用于下载
|
||||
- `file_extension`: 导出文件扩展名
|
||||
- `file_size`: 导出文件大小(字节)
|
||||
|
||||
### Task_check 场景返回
|
||||
|
||||
```json
|
||||
{
|
||||
"scenario": "task_check",
|
||||
"task_id": "<TASK_ID>",
|
||||
"status": "success",
|
||||
"ready": true,
|
||||
"failed": false
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明:**
|
||||
- `status`: 任务状态,`success`=成功,`failed`=失败,`pending`=处理中
|
||||
- `ready`: 是否已经完成
|
||||
- `failed`: 是否已经失败
|
||||
|
||||
## 使用场景
|
||||
|
||||
### 配合 +import 使用
|
||||
|
||||
```bash
|
||||
# 1. 创建导入任务
|
||||
lark-cli drive +import --file ./data.xlsx --type sheet
|
||||
# 若任务很快完成:直接返回 token / url
|
||||
# 若内置轮询超时:返回 ready=false、ticket 和 next_command
|
||||
|
||||
# 2. 轮询导入结果
|
||||
lark-cli drive +task_result --scenario import --ticket <IMPORT_TICKET>
|
||||
```
|
||||
|
||||
### 配合 +move 使用
|
||||
|
||||
```bash
|
||||
# 1. 移动文件夹(异步操作)
|
||||
lark-cli drive +move --file-token <FOLDER_TOKEN> --type folder --folder-token <TARGET_FOLDER_TOKEN>
|
||||
# 若轮询窗口内完成:直接返回 ready=true
|
||||
# 若内置轮询结束仍未完成:返回 ready=false、task_id 和 next_command
|
||||
|
||||
# 2. 轮询移动结果
|
||||
lark-cli drive +task_result --scenario task_check --task-id <TASK_ID>
|
||||
```
|
||||
|
||||
### 配合 +export 使用
|
||||
|
||||
```bash
|
||||
# 1. 发起导出
|
||||
lark-cli drive +export --token <SOURCE_DOC_TOKEN> --doc-type docx --file-extension pdf
|
||||
# 若轮询窗口内完成:直接下载本地文件
|
||||
# 若内置轮询结束仍未完成:返回 ready=false、ticket 和 next_command
|
||||
|
||||
# 2. 继续查询导出结果
|
||||
lark-cli drive +task_result --scenario export --ticket <EXPORT_TICKET> --file-token <SOURCE_DOC_TOKEN>
|
||||
|
||||
# 3. 拿到 file_token 后下载
|
||||
lark-cli drive +export-download --file-token <EXPORTED_FILE_TOKEN>
|
||||
```
|
||||
|
||||
## 权限要求
|
||||
|
||||
| 场景 | 所需 scope |
|
||||
|------|-----------|
|
||||
| import | `drive:drive.metadata:readonly` |
|
||||
| export | `drive:drive.metadata:readonly` |
|
||||
| task_check | `drive:drive.metadata:readonly` |
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-drive](../SKILL.md) -- 云空间全部命令
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
@@ -61,10 +61,10 @@ Shortcut 是对常用操作的高级封装(`lark-cli im +<verb> [flags]`)。
|
||||
| [`+chat-search`](references/lark-im-chat-search.md) | Search visible group chats by keyword and/or member open_ids (e.g. look up chat_id by group name); user/bot; supports member/type filters, sorting, and pagination |
|
||||
| [`+chat-update`](references/lark-im-chat-update.md) | Update group chat name or description; user/bot; updates a chat's name or description |
|
||||
| [`+messages-mget`](references/lark-im-messages-mget.md) | Batch get messages by IDs; user/bot; fetches up to 50 om_ message IDs, formats sender names, expands thread replies |
|
||||
| [`+messages-reply`](references/lark-im-messages-reply.md) | Reply to a message (supports thread replies) with bot identity; bot-only; supports text/markdown/post/media replies, reply-in-thread, idempotency key |
|
||||
| [`+messages-reply`](references/lark-im-messages-reply.md) | Reply to a message (supports thread replies); user/bot; supports text/markdown/post/media replies, reply-in-thread, idempotency key |
|
||||
| [`+messages-resources-download`](references/lark-im-messages-resources-download.md) | Download images/files from a message; user/bot; downloads image/file resources by message-id and file-key to a safe relative output path |
|
||||
| [`+messages-search`](references/lark-im-messages-search.md) | Search messages across chats (supports keyword, sender, time range filters) with user identity; user-only; filters by chat/sender/attachment/time, supports auto-pagination via `--page-all` / `--page-limit`, enriches results via batched mget and chats batch_query |
|
||||
| [`+messages-send`](references/lark-im-messages-send.md) | Send a message to a chat or direct message with bot identity; bot-only; sends to chat-id or user-id with text/markdown/post/media, supports idempotency key |
|
||||
| [`+messages-send`](references/lark-im-messages-send.md) | Send a message to a chat or direct message; user/bot; sends to chat-id or user-id with text/markdown/post/media, supports idempotency key |
|
||||
| [`+threads-messages-list`](references/lark-im-threads-messages-list.md) | List messages in a thread; user/bot; accepts om_/omt_ input, resolves message IDs to thread_id, supports sort/pagination |
|
||||
|
||||
## API Resources
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules.
|
||||
|
||||
Reply to a specific message. Only supports bot identity. Also supports thread replies.
|
||||
Reply to a specific message. Supports both user identity (`--as user`) and bot identity (`--as bot`). Also supports thread replies.
|
||||
|
||||
This skill maps to the shortcut: `lark-cli im +messages-reply` (internally calls `POST /open-apis/im/v1/messages/:message_id/reply`).
|
||||
|
||||
@@ -12,12 +12,14 @@ Replies sent by this tool are visible to other people. Before calling it, you **
|
||||
|
||||
1. Which message to reply to
|
||||
2. The reply content
|
||||
3. Which identity to use (bot only)
|
||||
3. Which identity to use (user or bot)
|
||||
|
||||
**Do not** send a reply without explicit user approval.
|
||||
|
||||
When using `--as bot`, the reply is sent in the app's name, so make sure the app has already been added to the target chat.
|
||||
|
||||
When using `--as user`, the reply is sent as the authorized end user and requires the `im:message.send_as_user` and `im:message` scopes.
|
||||
|
||||
## Choose The Right Content Flag
|
||||
|
||||
| Need | Recommended flag | Why |
|
||||
@@ -152,7 +154,7 @@ lark-cli im +messages-reply --message-id om_xxx --markdown $'## Test\n\nhello' -
|
||||
| `--audio <path\|key>` | One content option | Local audio path or `file_key` |
|
||||
| `--reply-in-thread` | No | Reply inside the thread. The reply appears in the target message's thread instead of the main chat stream |
|
||||
| `--idempotency-key <key>` | No | Idempotency key; the same key sends only one reply within 1 hour |
|
||||
| `--as <identity>` | No | Identity type: `bot` only |
|
||||
| `--as <identity>` | No | Identity type: `bot` or `user` (default `bot`) |
|
||||
| `--dry-run` | No | Print the request only, do not execute it |
|
||||
|
||||
> **Mutual exclusivity rule:** `--text`, `--markdown`, `--content`, and `--image`/`--file`/`--video`/`--audio` cannot be used together. Media flags are also mutually exclusive with each other.
|
||||
@@ -209,11 +211,12 @@ The reply appears in the target message's thread and does not show up in the mai
|
||||
- When using `--content`, you are responsible for making the JSON structure match the effective `msg_type`
|
||||
- `--reply-in-thread` adds `reply_in_thread=true` to the API request
|
||||
- `--reply-in-thread` is mainly meaningful in chats that support thread replies
|
||||
- `--image`/`--file`/`--video`/`--audio`/`--video-cover` support local file paths; the shortcut uploads first and then sends the reply
|
||||
- `--image`/`--file`/`--video`/`--audio`/`--video-cover` support local file paths; the shortcut uploads first and then sends the reply; file/image upload is bot-only, so when using `--as user`, the upload step is automatically performed with bot identity, and only the final send uses user identity
|
||||
- If the provided media value starts with `img_` or `file_`, it is treated as an existing key and used directly
|
||||
- `--markdown` always sends `msg_type=post`
|
||||
- If you explicitly set `--msg-type` and it conflicts with the chosen content flag, validation fails
|
||||
- When using `--video`, `--video-cover` is required as the video cover
|
||||
- `--dry-run` uses placeholder image keys for remote Markdown images and placeholder media keys for local uploads
|
||||
- Failures return error codes and messages
|
||||
- `--as bot` uses a tenant access token (TAT), and requires the `im:message:send_as_bot` scope
|
||||
- `--as user` uses a user access token (UAT) and requires the `im:message.send_as_user` and `im:message` scopes; the reply is sent as the authorized end user
|
||||
- `--as bot` uses a tenant access token (TAT), and requires the `im:message:send_as_bot` scope
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules.
|
||||
|
||||
Send a message to a group chat or a direct message conversation. Only supports bot identity.
|
||||
Send a message to a group chat or a direct message conversation. Supports both user identity (`--as user`) and bot identity (`--as bot`).
|
||||
|
||||
This skill maps to the shortcut: `lark-cli im +messages-send` (internally calls `POST /open-apis/im/v1/messages`).
|
||||
|
||||
@@ -12,12 +12,14 @@ Messages sent by this tool are visible to other people. Before calling it, you *
|
||||
|
||||
1. The recipient (which person or which group)
|
||||
2. The message content
|
||||
3. The sending identity (bot only)
|
||||
3. The sending identity (user or bot)
|
||||
|
||||
**Do not** send messages without explicit user approval.
|
||||
|
||||
When using `--as bot`, the message is sent in the app's name, so make sure the app has already been added to the target chat.
|
||||
|
||||
When using `--as user`, the message is sent as the authorized end user and requires the `im:message.send_as_user` and `im:message` scopes.
|
||||
|
||||
## Choose The Right Content Flag
|
||||
|
||||
| Need | Recommended flag | Why |
|
||||
@@ -158,7 +160,7 @@ lark-cli im +messages-send --chat-id oc_xxx --markdown $'## Test\n\nhello' --dry
|
||||
| `--audio <path\|key>` | One content option | Local audio path or `file_key`. Local paths are uploaded automatically |
|
||||
| `--msg-type <type>` | No | Message type (default `text`). If you use `--text` / `--markdown` / media flags, the effective type is inferred automatically. Explicitly setting a conflicting `--msg-type` fails validation |
|
||||
| `--idempotency-key <key>` | No | Idempotency key; the same key sends only one message within 1 hour |
|
||||
| `--as <identity>` | No | Identity type: `bot` only |
|
||||
| `--as <identity>` | No | Identity type: `bot` or `user` (default `bot`) |
|
||||
| `--dry-run` | No | Print the request only, do not execute it |
|
||||
|
||||
> **Mutual exclusivity rule:** `--text`, `--markdown`, `--content`, and `--image`/`--file`/`--video`/`--audio` cannot be used together. Media flags are also mutually exclusive with each other.
|
||||
@@ -209,12 +211,13 @@ lark-cli im +messages-send --chat-id oc_xxx --markdown $'## Test\n\nhello' --dry
|
||||
- `--chat-id` and `--user-id` are mutually exclusive; you must provide exactly one
|
||||
- `--content` must be valid JSON
|
||||
- When using `--content`, you are responsible for making the JSON structure match the effective `msg_type`
|
||||
- `--image`/`--file`/`--video`/`--audio` support local file paths; the shortcut uploads first and then sends the message
|
||||
- `--image`/`--file`/`--video`/`--audio` support local file paths; the shortcut uploads first and then sends the message; file/image upload is bot-only, so when using `--as user`, the upload step is automatically performed with bot identity, and only the final send uses user identity
|
||||
- If the provided media value starts with `img_` or `file_`, it is treated as an existing key and used directly
|
||||
- `--markdown` always sends `msg_type=post`, even if you do not explicitly set `--msg-type post`
|
||||
- If you explicitly set `--msg-type` and it conflicts with the chosen content flag, validation fails
|
||||
- When using `--video`, `--video-cover` is required as the video cover
|
||||
- `--dry-run` uses placeholder image keys for remote Markdown images and placeholder media keys for local uploads
|
||||
- Failures return an error code and message
|
||||
- `--as user` uses a user access token (UAT) and requires the `im:message.send_as_user` and `im:message` scopes; the message is sent as the authorized end user
|
||||
- `--as bot` uses a tenant access token (TAT) and requires the `im:message:send_as_bot` scope
|
||||
- When sending as a bot, the app must already be in the target group or already have a direct-message relationship with the target user
|
||||
- When sending as a bot, the app must already be in the target group or already have a direct-message relationship with the target user
|
||||
|
||||
@@ -106,6 +106,8 @@ lark-cli mail +reply --message-id <id> --body '收到,谢谢'
|
||||
|
||||
`+message`、`+messages`、`+thread` 默认返回 HTML 正文(`--html=true`)。仅需确认操作结果(如验证标记已读、移动文件夹是否成功)时,用 `--html=false` 跳过 HTML 正文,只返回纯文本,显著减少 token 消耗。
|
||||
|
||||
输出默认为结构化 JSON,可直接读取,无需额外编码转换。
|
||||
|
||||
```bash
|
||||
# ✅ 验证操作结果:不需要 HTML
|
||||
lark-cli mail +message --message-id <id> --html=false
|
||||
|
||||
@@ -154,11 +154,13 @@ lark-cli mail +message --message-id <message-id> --dry-run
|
||||
|
||||
## 注意事项
|
||||
|
||||
- JSON 输出中 `body_html` 里的 `<` / `>` 可能显示为 `\u003c` / `\u003e`(JSON 安全转义,内容不变)。
|
||||
- **JSON 输出可直接使用** — 默认输出合法 UTF-8 JSON,可直接读取,无需额外编码转换。
|
||||
- JSON 输出中 `body_html` 里的 `<` / `>` 可能显示为 `\u003c` / `\u003e`(JSON 安全转义,内容不变,`jq -r` 可还原)。
|
||||
- `mail +message` 默认不再获取附件/图片下载 URL。这样可以保持邮件详情读取更轻量,调用方可按需单独请求 URL。
|
||||
- 查看原始 HTML:
|
||||
|
||||
```bash
|
||||
# jq -r 自动处理 JSON 转义,输出原始 HTML
|
||||
lark-cli mail +message --message-id <id> --format json | jq -r '.data.body_html'
|
||||
```
|
||||
|
||||
|
||||
@@ -72,9 +72,10 @@ lark-cli mail +messages --message-ids <id1>,<id2> --dry-run
|
||||
|
||||
## 注意事项
|
||||
|
||||
- **JSON 输出可直接使用**,可直接读取,无需额外编码转换。
|
||||
- 只需读取一封邮件时请使用 `+message`。
|
||||
- `--message-ids` 无硬性上限;shortcut 内部会自动将大列表拆分为多次批量 API 调用。
|
||||
- JSON 输出中 `messages[].body_html` 里的 `<` / `>` 可能显示为 `\u003c` / `\u003e`(JSON 安全转义,内容不变)。
|
||||
- JSON 输出中 `messages[].body_html` 里的 `<` / `>` 可能显示为 `\u003c` / `\u003e`(JSON 安全转义,内容不变,`jq -r` 可还原)。
|
||||
- `mail +messages` 仅返回附件元数据。如后续步骤需要下载 URL,请针对特定的 `message_id` 和 `attachment_ids` 调用原生附件 URL API。
|
||||
- 与 `+message` 一样,普通附件和内嵌图片都出现在 `messages[].attachments[]` 中,使用同一个 `user_mailbox.message.attachments download_url` API。
|
||||
|
||||
|
||||
@@ -67,7 +67,8 @@ lark-cli mail +thread --thread-id <thread-id> --dry-run
|
||||
|
||||
## 注意事项
|
||||
|
||||
- JSON 输出中 `messages[].body_html` 里的 `<` / `>` 可能显示为 `\u003c` / `\u003e`(JSON 安全转义,内容不变)。
|
||||
- **JSON 输出可直接使用**,可直接读取,无需额外编码转换。
|
||||
- JSON 输出中 `messages[].body_html` 里的 `<` / `>` 可能显示为 `\u003c` / `\u003e`(JSON 安全转义,内容不变,`jq -r` 可还原)。
|
||||
- `mail +thread` 不再在读取会话时获取附件/图片下载 URL。如后续步骤需要 URL,请针对特定的 `message_id` 和 `attachment_ids` 调用原生附件 URL API。
|
||||
- 与 `+message` 一样,普通附件和内嵌图片都出现在 `messages[].attachments[]` 中,使用同一个 `user_mailbox.message.attachments download_url` API。
|
||||
- 查看某条邮件的原始 HTML:
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
实时监听新邮件事件(`mail.user_mailbox.event.message_received_v1`)。
|
||||
|
||||
**权限要求:** 应用需要 `mail:event`、`mail:user_mailbox.message:readonly`、`mail:user_mailbox.folder:read` 权限,以及字段权限 `mail:user_mailbox.message.address:read`、`mail:user_mailbox.message.subject:read`、`mail:user_mailbox.message.body:read`,且机器人需订阅事件 `mail.user_mailbox.event.message_received_v1`。
|
||||
**权限要求:** 应用需要 `mail:event`、`mail:user_mailbox.message:readonly` 权限,以及字段权限 `mail:user_mailbox.message.address:read`、`mail:user_mailbox.message.subject:read`、`mail:user_mailbox.message.body:read`,且机器人需订阅事件 `mail.user_mailbox.event.message_received_v1`。按需权限(缺失时会提示申请):使用 `--folders` / `--folder-ids` 筛选自定义文件夹时需要 `mail:user_mailbox.folder:read`;使用 `--labels` / `--label-ids` 筛选自定义标签时需要 `mail:user_mailbox.message:modify`。
|
||||
|
||||
## 命令
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-minutes
|
||||
version: 1.0.0
|
||||
description: "飞书妙记:获取妙记基础信息(标题、封面、时长)和相关的 AI 产物(总结、待办、章节)。飞书妙记的 URL 格式为: http(s)://<host>/minutes/<minute-token>"
|
||||
description: "飞书妙记:获取妙记基础信息(标题、封面、时长)和相关的 AI 产物(总结、待办、章节),下载妙记音视频文件。飞书妙记的 URL 格式为: http(s)://<host>/minutes/<minute-token>"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -10,7 +10,7 @@ metadata:
|
||||
|
||||
# minutes (v1)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
|
||||
|
||||
## 核心概念
|
||||
|
||||
@@ -48,6 +48,29 @@ lark-cli vc +notes --minute-tokens obcnhijv43vq6bcsl5xasfb2
|
||||
- 用户未指定需要查询妙记的哪些内容时,默认查询基础元信息和相关联的纪要产物信息。
|
||||
- 用户未明确指定查看纪要产物(逐字稿、总结、待办、章节)时,向用户展示对应产物的链接即可,不需要直接读取产物内容。
|
||||
|
||||
## Shortcuts(推荐优先使用)
|
||||
|
||||
Shortcut 是对常用操作的高级封装(`lark-cli minutes +<verb> [flags]`)。有 Shortcut 的操作优先使用。
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+download`](references/lark-minutes-download.md) | Download audio/video media file of a minute |
|
||||
|
||||
### 妙记音视频下载
|
||||
|
||||
下载妙记音视频文件到本地,或获取有效期 1 天的下载链接。详见 [minutes +download](references/lark-minutes-download.md)。
|
||||
|
||||
```bash
|
||||
# 下载音视频文件到本地
|
||||
lark-cli minutes +download --minute-tokens obcnq3b9jl72l83w4f149w9c --output ./meeting.mp4
|
||||
|
||||
# 仅获取下载链接
|
||||
lark-cli minutes +download --minute-tokens obcnq3b9jl72l83w4f149w9c --url-only
|
||||
|
||||
# 批量下载
|
||||
lark-cli minutes +download --minute-tokens obcnq3b9jl72l83w4f149w9c,obcnexa7814k4t41c446fzwj
|
||||
```
|
||||
|
||||
<!-- AUTO-GENERATED-START — gen-skills.py 管理,勿手动编辑 -->
|
||||
## API Resources
|
||||
|
||||
@@ -67,6 +90,7 @@ lark-cli minutes <resource> <method> [flags] # 调用 API
|
||||
| 方法 | 所需 scope |
|
||||
|------|-----------|
|
||||
| `minutes.get` | `minutes:minutes:readonly` |
|
||||
| `+download` | `minutes:minutes.media:export` |
|
||||
|
||||
|
||||
<!-- AUTO-GENERATED-END -->
|
||||
|
||||
119
skills/lark-minutes/references/lark-minutes-download.md
Normal file
119
skills/lark-minutes/references/lark-minutes-download.md
Normal file
@@ -0,0 +1,119 @@
|
||||
|
||||
# minutes +download
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
下载妙记的音视频媒体文件到本地,或获取有效期 1 天的下载链接。只读操作。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli minutes +download`。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 下载单个妙记的音视频文件
|
||||
lark-cli minutes +download --minute-tokens obcnq3b9jl72l83w4f149w9c
|
||||
|
||||
# 指定输出路径
|
||||
lark-cli minutes +download --minute-tokens obcnq3b9jl72l83w4f149w9c --output ./meeting.mp4
|
||||
|
||||
# 仅获取下载链接(有效期 1 天),不下载文件
|
||||
lark-cli minutes +download --minute-tokens obcnq3b9jl72l83w4f149w9c --url-only
|
||||
|
||||
# 批量下载多个妙记
|
||||
lark-cli minutes +download --minute-tokens obcnq3b9jl72l83w4f149w9c,obcnexa7814k4t41c446fzwj
|
||||
|
||||
# 批量下载到指定目录
|
||||
lark-cli minutes +download --minute-tokens obcnq3b9jl72l83w4f149w9c,obcnexa7814k4t41c446fzwj --output ./downloads
|
||||
|
||||
# 预览 API 调用
|
||||
lark-cli minutes +download --minute-tokens obcnq3b9jl72l83w4f149w9c --dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--minute-tokens <tokens>` | 是 | 妙记 Token,逗号分隔支持批量(最多 50 个) |
|
||||
| `--output <path>` | 否 | 输出路径:单个 token 时为文件路径,批量时为目录(默认当前目录) |
|
||||
| `--overwrite` | 否 | 覆盖已存在的输出文件 |
|
||||
| `--url-only` | 否 | 仅返回下载链接,不下载文件 |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 核心约束
|
||||
|
||||
### 1. 妙记必须已完成转写
|
||||
|
||||
音视频文件仅在妙记转写完成后可下载。如果妙记尚未准备好,API 会返回 `2091003` 错误。
|
||||
|
||||
### 2. 下载链接有效期 1 天
|
||||
|
||||
`--url-only` 返回的链接有效期为 1 天,过期后需重新获取。
|
||||
|
||||
### 3. 频率限制
|
||||
|
||||
API 限流 5 次/秒,批量下载时需注意控制频率。
|
||||
|
||||
### 4. 所需权限
|
||||
|
||||
| 身份 | 所需权限 |
|
||||
|------|---------|
|
||||
| user / bot | `minutes:minutes.media:export` |
|
||||
|
||||
## 输出结果
|
||||
|
||||
### 下载模式(默认)
|
||||
|
||||
```json
|
||||
{
|
||||
"saved_path": "访谈一则 & 澳成.mp4",
|
||||
"size_bytes": 52428800
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `saved_path` | 文件保存的本地路径 |
|
||||
| `size_bytes` | 文件大小(字节) |
|
||||
|
||||
### URL 模式(--url-only)
|
||||
|
||||
```json
|
||||
{
|
||||
"download_url": "https://..."
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `download_url` | 媒体文件下载链接(有效期 1 天) |
|
||||
|
||||
## 如何获取 minute_token
|
||||
|
||||
| 来源 | 获取方式 |
|
||||
|------|---------|
|
||||
| 妙记 URL | 从 URL 末尾提取,如 `https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c` → `obcnq3b9jl72l83w4f149w9c` |
|
||||
| 妙记元信息查询 | `lark-cli minutes minutes get --params '{"minute_token": "obcn..."}'` |
|
||||
| 会议纪要查询 | `lark-cli vc +notes --meeting-ids <id>` 返回结果中关联的妙记 token |
|
||||
|
||||
## 常见错误与排查
|
||||
|
||||
| 错误现象 | 错误码 | 根本原因 | 解决方案 |
|
||||
|---------|--------|---------|---------|
|
||||
| 参数无效 | 2091001 | minute_token 格式不正确 | 检查 token 是否完整(24 位) |
|
||||
| 资源不存在 | 2091002 | token 不存在 | 确认 minute_token 正确 |
|
||||
| 妙记尚未准备好 | 2091003 | 转写未完成 | 等待转写完成后重试 |
|
||||
| 资源已删除 | 2091004 | 妙记已被删除 | 确认妙记文件仍然存在 |
|
||||
| 权限不足 | 2091005 | 无阅读权限 | 检查是否有该妙记的访问权限 |
|
||||
| `missing required scope(s)` | — | 应用缺少权限 | 运行 `auth login --scope "minutes:minutes.media:export"` |
|
||||
|
||||
## 提示
|
||||
|
||||
- 音视频文件可能较大,下载无固定超时限制(由用户 Ctrl+C 控制取消)。
|
||||
- 未指定 `--output` 时,默认使用妙记原始标题作为文件名(如 `Office Oncall流程2.0宣讲.mp4`)。
|
||||
- 如需获取妙记的纪要内容(逐字稿、AI 总结等),请使用 [vc +notes](../../lark-vc/references/lark-vc-notes.md)。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-minutes](../SKILL.md) — 妙记全部命令
|
||||
- [lark-vc-notes](../../lark-vc/references/lark-vc-notes.md) — 会议纪要查询
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
@@ -31,6 +31,21 @@ metadata:
|
||||
### 2. 整理会议纪要
|
||||
1. 整理纪要文档时默认给出纪要文档和逐字稿链接即可,无需读取纪要文档或逐字稿内容。
|
||||
2. 用户明确需要获取纪要文档中的总结、待办、章节产物时,再读取文档获取具体内容。
|
||||
3. 读取智能纪要(`note_doc_token`)内容时,纪要文档的**第一个 `<whiteboard>`** 标签是封面图(AI 生成的总结可视化),应同时下载展示给用户:
|
||||
```bash
|
||||
# 1. 读取纪要内容
|
||||
lark-cli docs +fetch --doc <note_doc_token>
|
||||
# 2. 从返回的 markdown 中提取第一个 <whiteboard token="xxx"/> 的 token
|
||||
# 3. 下载封面图到 artifact 目录(和逐字稿同目录,保持产物归拢)
|
||||
# 并非所有纪要都有封面画板,没有 <whiteboard> 标签时跳过即可
|
||||
lark-cli docs +media-download --type whiteboard --token <whiteboard_token> --output ./artifact-<title>/cover
|
||||
```
|
||||
> **产物目录规范**:同一会议的所有下载产物(封面图、逐字稿等)统一放到 `artifact-<title>/` 目录下,不要散落在当前工作目录。
|
||||
|
||||
> **`note_doc_token` vs `verbatim_doc_token` — 两份不同的文档,根据用户意图选择:**
|
||||
> - `note_doc_token` → **智能纪要**(AI 总结 + 待办 + 章节)— 用户说"纪要""总结""待办""纪要内容"时用这个
|
||||
> - `verbatim_doc_token` → **逐字稿**(完整的逐句文字记录,含说话人和时间戳)— 用户说"逐字稿""完整记录""谁说了什么"时用这个
|
||||
> - 用户意图不明确时,应展示两个文档链接让用户选择,而不是替用户决定
|
||||
|
||||
### 3. 纪要文档与逐字稿链接
|
||||
1. 纪要文档、逐字稿文档与关联的共享文档默认使用文档 Token 返回。
|
||||
|
||||
@@ -75,12 +75,14 @@ lark-cli vc +notes --meeting-ids 69xxxxxxxxxxxxx28 --dry-run
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `note_doc_token` | 主纪要文档 Token |
|
||||
| `verbatim_doc_token` | 逐字稿文档 Token |
|
||||
| `note_doc_token` | **智能纪要**文档 Token — 包含 AI 总结、待办、章节(用户说"纪要"时用这个) |
|
||||
| `verbatim_doc_token` | **逐字稿**文档 Token — 完整的逐句文字记录,含说话人和时间戳(用户说"逐字稿"时才用这个) |
|
||||
| `shared_doc_tokens` | 会中共享文档 Token 列表 |
|
||||
| `creator_id` | 创建者 ID |
|
||||
| `create_time` | 创建时间(格式化) |
|
||||
|
||||
> **选择哪个 token?** 用户说"会议纪要""总结""待办""纪要内容" → 用 `note_doc_token`。用户说"逐字稿""完整记录""谁说了什么" → 用 `verbatim_doc_token`。意图不明确时,展示两个文档链接让用户选择。
|
||||
|
||||
### minute-tokens 路径的 AI 产物
|
||||
|
||||
通过 `--minute-tokens` 查询时,返回的 `artifacts` 字段包含 AI 内置产物:
|
||||
|
||||
@@ -23,11 +23,23 @@ lark-cli wiki <resource> <method> [flags] # 调用 API
|
||||
|
||||
### spaces
|
||||
|
||||
- `get_node` — 获取知识空间节点信息
|
||||
- `get` — 获取知识空间信息
|
||||
- `get_node` — 获取知识空间节点信息
|
||||
- `list` — 获取知识空间列表
|
||||
|
||||
### nodes
|
||||
|
||||
- `copy` — 创建知识空间节点副本
|
||||
- `create` — 创建知识空间节点
|
||||
- `list` — 获取知识空间子节点列表
|
||||
|
||||
## 权限表
|
||||
|
||||
| 方法 | 所需 scope |
|
||||
|------|-----------|
|
||||
| `spaces.get` | `wiki:space:read` |
|
||||
| `spaces.get_node` | `wiki:node:read` |
|
||||
|
||||
| `spaces.list` | `wiki:space:retrieve` |
|
||||
| `nodes.copy` | `wiki:node:copy` |
|
||||
| `nodes.create` | `wiki:node:create` |
|
||||
| `nodes.list` | `wiki:node:retrieve` |
|
||||
|
||||
Reference in New Issue
Block a user