mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
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:
committed by
GitHub
parent
d7ee5b5769
commit
0250054a90
72
shortcuts/minutes/minutes_upload.go
Normal file
72
shortcuts/minutes/minutes_upload.go
Normal 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
|
||||
},
|
||||
}
|
||||
119
shortcuts/minutes/minutes_upload_test.go
Normal file
119
shortcuts/minutes/minutes_upload_test.go
Normal 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"])
|
||||
}
|
||||
}
|
||||
@@ -10,5 +10,6 @@ func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
MinutesSearch,
|
||||
MinutesDownload,
|
||||
MinutesUpload,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 管理,勿手动编辑 -->
|
||||
|
||||
|
||||
89
skills/lark-minutes/references/lark-minutes-upload.md
Normal file
89
skills/lark-minutes/references/lark-minutes-upload.md
Normal 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) -- 认证和全局参数
|
||||
44
tests/cli_e2e/minutes/minutes_upload_test.go
Normal file
44
tests/cli_e2e/minutes/minutes_upload_test.go
Normal 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")
|
||||
}
|
||||
Reference in New Issue
Block a user