From 0250054a903d28a75ee3c7d9dc6fa69b1fdf2feb Mon Sep 17 00:00:00 2001 From: calendar-assistant Date: Thu, 30 Apr 2026 11:19:22 +0800 Subject: [PATCH] feat(minutes): add media upload shortcut (#725) Support minutes +upload to generate a minute from an uploaded media file token. Change-Id: I59c0719a39541134e395a23262aea7f387105715 Co-authored-by: calendar-assistant --- shortcuts/minutes/minutes_upload.go | 72 +++++++++++ shortcuts/minutes/minutes_upload_test.go | 119 ++++++++++++++++++ shortcuts/minutes/shortcuts.go | 1 + skills/lark-minutes/SKILL.md | 16 ++- .../references/lark-minutes-upload.md | 89 +++++++++++++ tests/cli_e2e/minutes/minutes_upload_test.go | 44 +++++++ 6 files changed, 339 insertions(+), 2 deletions(-) create mode 100644 shortcuts/minutes/minutes_upload.go create mode 100644 shortcuts/minutes/minutes_upload_test.go create mode 100644 skills/lark-minutes/references/lark-minutes-upload.md create mode 100644 tests/cli_e2e/minutes/minutes_upload_test.go diff --git a/shortcuts/minutes/minutes_upload.go b/shortcuts/minutes/minutes_upload.go new file mode 100644 index 00000000..06fee39e --- /dev/null +++ b/shortcuts/minutes/minutes_upload.go @@ -0,0 +1,72 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package minutes + +import ( + "context" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + minutesUploadSupportedFormatsTip = "Supported audio formats: wav, mp3, m4a, aac, ogg, wma, amr; supported video formats: avi, wmv, mov, mp4, m4v, mpeg, ogg, flv." + minutesUploadLimitsTip = "The original uploaded media must be no larger than 6GB and no longer than 6 hours." +) + +// MinutesUpload uploads a media file token to generate a minute. +var MinutesUpload = common.Shortcut{ + Service: "minutes", + Command: "+upload", + Description: "Upload a media file token to generate a minute", + Risk: "write", + Scopes: []string{"minutes:minutes.upload:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "file-token", Desc: "file_token of a supported audio/video file already uploaded to Drive", Required: true}, + }, + Tips: []string{ + "This shortcut only accepts --file-token. Upload the local media file to Drive first with `lark-cli drive +upload`.", + minutesUploadSupportedFormatsTip, + minutesUploadLimitsTip, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + fileToken := runtime.Str("file-token") + if fileToken == "" { + return output.ErrValidation("--file-token is required") + } + if err := validate.ResourceName(fileToken, "--file-token"); err != nil { + return output.ErrValidation("%s", err) + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + POST("/open-apis/minutes/v1/minutes/upload"). + Body(map[string]interface{}{"file_token": runtime.Str("file-token")}) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + fileToken := runtime.Str("file-token") + + body := map[string]interface{}{ + "file_token": fileToken, + } + + data, err := runtime.CallAPI("POST", "/open-apis/minutes/v1/minutes/upload", nil, body) + if err != nil { + return err + } + + minuteURL := common.GetString(data, "minute_url") + + outData := map[string]interface{}{ + "minute_url": minuteURL, + } + + runtime.OutFormat(outData, nil, nil) + return nil + }, +} diff --git a/shortcuts/minutes/minutes_upload_test.go b/shortcuts/minutes/minutes_upload_test.go new file mode 100644 index 00000000..cc6fb93b --- /dev/null +++ b/shortcuts/minutes/minutes_upload_test.go @@ -0,0 +1,119 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package minutes + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" + "github.com/spf13/cobra" +) + +func TestMinutesUpload_Validate(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, defaultConfig()) + tests := []struct { + name string + args []string + wantErr string + }{ + { + name: "missing file token", + args: []string{"+upload", "--as", "user"}, + wantErr: "required flag(s) \"file-token\" not set", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parent := &cobra.Command{Use: "minutes"} + MinutesUpload.Mount(parent, f) + parent.SetArgs(tt.args) + parent.SilenceErrors = true + parent.SilenceUsage = true + err := parent.Execute() + if err == nil { + t.Fatalf("expected error, got nil") + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("error should contain %q, got: %s", tt.wantErr, err.Error()) + } + }) + } +} + +func TestMinutesUpload_HelpMetadata(t *testing.T) { + if len(MinutesUpload.Flags) == 0 { + t.Fatal("expected file-token flag metadata") + } + if got := MinutesUpload.Flags[0].Desc; !strings.Contains(got, "supported audio/video file") { + t.Fatalf("file-token description = %q, want supported media guidance", got) + } + + joinedTips := strings.Join(MinutesUpload.Tips, "\n") + for _, want := range []string{ + "drive +upload", + "wav, mp3, m4a, aac, ogg, wma, amr", + "avi, wmv, mov, mp4, m4v, mpeg, ogg, flv", + "6GB", + "6 hours", + } { + if !strings.Contains(joinedTips, want) { + t.Fatalf("tips should contain %q, got:\n%s", want, joinedTips) + } + } +} + +func TestMinutesUpload_DryRun(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + + err := mountAndRun(t, MinutesUpload, []string{"+upload", "--file-token", "boxcn123456", "--dry-run", "--as", "user"}, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := stdout.String() + if !strings.Contains(out, "POST") || !strings.Contains(out, "/open-apis/minutes/v1/minutes/upload") { + t.Errorf("expected POST /open-apis/minutes/v1/minutes/upload, got:\n%s", out) + } + if !strings.Contains(out, "boxcn123456") { + t.Errorf("expected file token in body, got:\n%s", out) + } +} + +func TestMinutesUpload_Execute(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + + reg.Register(&httpmock.Stub{ + Method: http.MethodPost, + URL: "/open-apis/minutes/v1/minutes/upload", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "minute_url": "https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c", + }, + }, + }) + + err := mountAndRun(t, MinutesUpload, []string{"+upload", "--file-token", "boxcn123456", "--format", "json", "--as", "user"}, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var res map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &res); err != nil { + t.Fatalf("failed to parse output: %v", err) + } + + dataMap, _ := res["data"].(map[string]interface{}) + if dataMap["minute_url"] != "https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c" { + t.Errorf("expected minute_url https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c, got %v", dataMap["minute_url"]) + } +} diff --git a/shortcuts/minutes/shortcuts.go b/shortcuts/minutes/shortcuts.go index 9c0651e7..8aef2b05 100644 --- a/shortcuts/minutes/shortcuts.go +++ b/shortcuts/minutes/shortcuts.go @@ -10,5 +10,6 @@ func Shortcuts() []common.Shortcut { return []common.Shortcut{ MinutesSearch, MinutesDownload, + MinutesUpload, } } diff --git a/skills/lark-minutes/SKILL.md b/skills/lark-minutes/SKILL.md index d6958da6..9f3a0763 100644 --- a/skills/lark-minutes/SKILL.md +++ b/skills/lark-minutes/SKILL.md @@ -1,7 +1,7 @@ --- name: lark-minutes version: 1.0.0 -description: "飞书妙记:妙记相关基本功能。1.查询妙记列表(按关键词/所有者/参与者/时间范围);2.获取妙记基础信息(标题、封面、时长 等);3.下载妙记音视频文件;4.获取妙记相关 AI 产物(总结、待办、章节)。飞书妙记 URL 格式: http(s):///minutes/" +description: "飞书妙记:妙记相关基本功能。1.查询妙记列表(按关键词/所有者/参与者/时间范围);2.获取妙记基础信息(标题、封面、时长 等);3.下载妙记音视频文件;4.获取妙记相关 AI 产物(总结、待办、章节);5.上传音视频生成妙记。飞书妙记 URL 格式: http(s):///minutes/" metadata: requires: bins: ["lark-cli"] @@ -59,6 +59,15 @@ lark-cli vc +notes --minute-tokens > **跨 skill 路由**:逐字稿、AI 总结、待办、章节等纪要内容由 [lark-vc](../lark-vc/SKILL.md) 的 `+notes` 命令提供 +### 5. 上传音视频文件生成妙记 + +1. 当用户需要通过上传本地音视频文件来生成妙记时使用。 +2. **处理流程**: + - **上传音视频获取 `file_token`**:使用 [`lark-cli drive +upload`](../lark-drive/references/lark-drive-upload.md) 上传本地文件到云空间并获取 `file_token`。 + - **生成妙记**:获取到 `file_token` 后,调用 [`lark-cli minutes +upload`](references/lark-minutes-upload.md) 将文件转换为妙记并获取 `minute_url` 链接。 + +> **注意**:必须先获取飞书云空间的 `file_token` 才能进行转换。 + ## 资源关系 ```text @@ -67,7 +76,7 @@ Minutes (妙记) ← minute_token 标识 └── MediaFile (音频/视频文件) → minutes +download ``` -> **能力边界**:`minutes` 负责 **搜索妙记、查看基础元信息、下载音视频文件**。 +> **能力边界**:`minutes` 负责 **搜索妙记、查看基础元信息、下载音视频文件、上传音视频生成妙记**。 > > **路由规则**: > @@ -81,6 +90,7 @@ Minutes (妙记) ← minute_token 标识 > - 用户说"这个妙记的标题 / 时长 / 封面 / 链接" → `minutes minutes get` > - 用户说"下载这个妙记的视频 / 音频 / 媒体文件" → `minutes +download` > - 用户说"这个妙记的逐字稿 / 总结 / 待办 / 章节" → 使用 [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md) +> - 用户说"通过文件生成妙记 / 把音视频转妙记" → 先上传获取 `file_token`,然后使用 `minutes +upload` ## Shortcuts(推荐优先使用) @@ -90,9 +100,11 @@ Shortcut 是对常用操作的高级封装(`lark-cli minutes + [flags]` | -------------------------------------------------- | --------------------------------------------------------------- | | [`+search`](references/lark-minutes-search.md) | Search minutes by keyword, owners, participants, and time range | | [`+download`](references/lark-minutes-download.md) | Download audio/video media file of a minute | +| [`+upload`](references/lark-minutes-upload.md) | Upload a media file token to generate a minute | - 使用 `+search` 命令时,必须阅读 [references/lark-minutes-search.md](references/lark-minutes-search.md),了解搜索参数和返回值结构。 - 使用 `+download` 命令时,必须阅读 [references/lark-minutes-download.md](references/lark-minutes-download.md),了解下载参数和返回值结构。 +- 使用 `+upload` 命令时,必须阅读 [references/lark-minutes-upload.md](references/lark-minutes-upload.md),了解生成参数和返回值结构。 diff --git a/skills/lark-minutes/references/lark-minutes-upload.md b/skills/lark-minutes/references/lark-minutes-upload.md new file mode 100644 index 00000000..89735840 --- /dev/null +++ b/skills/lark-minutes/references/lark-minutes-upload.md @@ -0,0 +1,89 @@ +# minutes +upload + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +上传音视频文件到飞书妙记并生成妙记(Minute)。 + +本 skill 对应 shortcut:`lark-cli minutes +upload`。 + +## 典型触发表达 + +- "把这个音视频文件转成妙记" +- "帮我把电脑里的录音上传到妙记" +- "将这个 mp4/mp3 文件生成妙记" + +## 完整工作流 + +当用户要求将音视频文件转换为妙记时,必须按照以下步骤执行: + +1. **上传文件至云空间获取 file_token** + - 使用 `lark-cli drive +upload` 命令上传本地文件到云空间(Drive): + ```bash + lark-cli drive +upload --file + ``` + - 从命令的返回结果中提取生成的 `file_token`。 + +2. **将 file_token 转换为妙记链接(minute_url)** + - 调用本 shortcut,将获取到的 `file_token` 转换为妙记: + ```bash + lark-cli minutes +upload --file-token + ``` + - 命令执行成功后,将返回生成的妙记链接 `minute_url`。 + +> **异步生成提示**:API 会立即返回 `minute_url`,但妙记可能仍在异步生成中,您可以直接通过该妙记链接查看当前的处理状态和转写结果。 + +## 命令示例 + +```bash +# 通过已上传到云空间的 file_token 生成妙记 +lark-cli minutes +upload --file-token boxcnxxxxxxxxxxxxxxxx +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--file-token ` | 是 | 已经上传到飞书云空间的音视频文件的 file_token | + +## 支持的格式与限制 + +待上传到妙记的原始音视频文件必须满足以下要求: + +- 支持音频格式:`wav`、`mp3`、`m4a`、`aac`、`ogg`、`wma`、`amr` +- 支持视频格式:`avi`、`wmv`、`mov`、`mp4`、`m4v`、`mpeg`、`ogg`、`flv` +- 音视频时长不能超过 `6` 小时 +- 文件大小不能超过 `6 GB` + +> 说明:本 shortcut 只接收 `file_token`,不会直接读取本地文件内容,因此这些格式、时长和大小限制对应的是**原始上传文件**本身。若妙记生成失败,请先回查源文件是否满足上述要求。 + +## 核心约束 + +### 1. 必须提供 file_token + +本接口不直接处理本地文件的上传,必须先使用 `drive +upload` 将文件上传到云空间获取 `file_token`,然后再调用本接口。 + +### 2. 先上传,再生成妙记 + +推荐流程如下: + +1. 使用 `lark-cli drive +upload --file ` 上传本地音视频文件到云空间 +2. 从返回结果中取出 `file_token` +3. 调用 `lark-cli minutes +upload --file-token ` 生成妙记 + +## 输出结果示例 + +```json +{ + "minute_url": "http(s):///minutes/" +} +``` + +| 字段 | 说明 | +|------|------| +| `minute_url` | 生成的妙记访问链接 | + +## 参考 + +- [lark-minutes](../SKILL.md) -- 妙记相关功能说明 +- [drive +upload](../../lark-drive/references/lark-drive-upload.md) -- 上传文件到云空间 +- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数 diff --git a/tests/cli_e2e/minutes/minutes_upload_test.go b/tests/cli_e2e/minutes/minutes_upload_test.go new file mode 100644 index 00000000..b43142b9 --- /dev/null +++ b/tests/cli_e2e/minutes/minutes_upload_test.go @@ -0,0 +1,44 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package minutes + +import ( + "context" + "strings" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMinutesUpload_DryRun(t *testing.T) { + setDryRunConfigEnv(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "minutes", "+upload", + "--file-token", "boxcn123456", + "--dry-run", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + output := result.Stdout + assert.True(t, strings.Contains(output, "POST"), "dry-run should contain POST method, got: %s", output) + assert.True(t, strings.Contains(output, "/open-apis/minutes/v1/minutes/upload"), "dry-run should contain API path, got: %s", output) + assert.True(t, strings.Contains(output, "boxcn123456"), "dry-run should contain file_token, got: %s", output) +} + +func setDryRunConfigEnv(t *testing.T) { + t.Helper() + t.Setenv("LARKSUITE_CLI_APP_ID", "cli_dryrun_test") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "dryrun_secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") +}