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 <calendar-assistant@users.noreply.github.com>
This commit is contained in:
calendar-assistant
2026-04-30 11:19:22 +08:00
committed by GitHub
parent d7ee5b5769
commit 0250054a90
6 changed files with 339 additions and 2 deletions

View File

@@ -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
},
}

View File

@@ -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"])
}
}

View File

@@ -10,5 +10,6 @@ func Shortcuts() []common.Shortcut {
return []common.Shortcut{
MinutesSearch,
MinutesDownload,
MinutesUpload,
}
}

View File

@@ -1,7 +1,7 @@
---
name: lark-minutes
version: 1.0.0
description: "飞书妙记妙记相关基本功能。1.查询妙记列表(按关键词/所有者/参与者/时间范围2.获取妙记基础信息(标题、封面、时长 等3.下载妙记音视频文件4.获取妙记相关 AI 产物(总结、待办、章节)。飞书妙记 URL 格式: http(s)://<host>/minutes/<minute-token>"
description: "飞书妙记妙记相关基本功能。1.查询妙记列表(按关键词/所有者/参与者/时间范围2.获取妙记基础信息(标题、封面、时长 等3.下载妙记音视频文件4.获取妙记相关 AI 产物(总结、待办、章节)5.上传音视频生成妙记。飞书妙记 URL 格式: http(s)://<host>/minutes/<minute-token>"
metadata:
requires:
bins: ["lark-cli"]
@@ -59,6 +59,15 @@ lark-cli vc +notes --minute-tokens <minute_token>
> **跨 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 +<verb> [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),了解生成参数和返回值结构。
<!-- AUTO-GENERATED-START — gen-skills.py 管理,勿手动编辑 -->

View File

@@ -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 <path/to/media/file>
```
- 从命令的返回结果中提取生成的 `file_token`。
2. **将 file_token 转换为妙记链接minute_url**
- 调用本 shortcut将获取到的 `file_token` 转换为妙记:
```bash
lark-cli minutes +upload --file-token <file_token>
```
- 命令执行成功后,将返回生成的妙记链接 `minute_url`。
> **异步生成提示**API 会立即返回 `minute_url`,但妙记可能仍在异步生成中,您可以直接通过该妙记链接查看当前的处理状态和转写结果。
## 命令示例
```bash
# 通过已上传到云空间的 file_token 生成妙记
lark-cli minutes +upload --file-token boxcnxxxxxxxxxxxxxxxx
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--file-token <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 <path>` 上传本地音视频文件到云空间
2. 从返回结果中取出 `file_token`
3. 调用 `lark-cli minutes +upload --file-token <file_token>` 生成妙记
## 输出结果示例
```json
{
"minute_url": "http(s)://<host>/minutes/<minute-token>"
}
```
| 字段 | 说明 |
|------|------|
| `minute_url` | 生成的妙记访问链接 |
## 参考
- [lark-minutes](../SKILL.md) -- 妙记相关功能说明
- [drive +upload](../../lark-drive/references/lark-drive-upload.md) -- 上传文件到云空间
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数

View File

@@ -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")
}