feat: add minutes permission application shortcut

Change-Id: Ice3e0113a55d7fc4817de9978539fc03d81b7a9b
This commit is contained in:
calendar-assistant
2026-07-01 14:44:31 +08:00
parent 075b34f9a3
commit e54f07d284
5 changed files with 340 additions and 1 deletions

View File

@@ -0,0 +1,75 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// MinutesApplyPermission applies for view or edit permission on a minute.
var MinutesApplyPermission = common.Shortcut{
Service: "minutes",
Command: "+apply-permission",
Description: "Apply for view or edit permission on a minute",
Risk: "write",
Scopes: []string{"minutes:permission:apply"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "minute-token", Desc: "minute token", Required: true},
{Name: "perm", Desc: "permission to apply for", Required: true, Enum: []string{"view", "edit"}},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
if minuteToken == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--minute-token is required").WithParam("--minute-token")
}
if err := validate.ResourceName(minuteToken, "--minute-token"); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--minute-token")
}
perm := strings.TrimSpace(runtime.Str("perm"))
if perm == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--perm is required").WithParam("--perm")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
return common.NewDryRunAPI().
POST(minutesApplyPermissionPath(minuteToken)).
Body(minutesApplyPermissionBody(runtime))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
perm := strings.TrimSpace(runtime.Str("perm"))
_, err := runtime.CallAPITyped(http.MethodPost, minutesApplyPermissionPath(minuteToken), nil, map[string]interface{}{"perm": perm})
if err != nil {
return err
}
runtime.OutFormat(map[string]interface{}{
"minute_token": minuteToken,
"perm": perm,
}, nil, nil)
return nil
},
}
func minutesApplyPermissionPath(minuteToken string) string {
return fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/permissions/apply", validate.EncodePathSegment(minuteToken))
}
func minutesApplyPermissionBody(runtime *common.RuntimeContext) map[string]interface{} {
return map[string]interface{}{
"perm": strings.TrimSpace(runtime.Str("perm")),
}
}

View File

@@ -0,0 +1,192 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"encoding/json"
"errors"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/spf13/cobra"
)
const minutesApplyPermissionTestToken = "obcnexampleminute"
func TestMinutesApplyPermission_Validate(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
tests := []struct {
name string
args []string
wantErr string
}{
{
name: "missing minute token",
args: []string{"+apply-permission", "--perm", "view", "--as", "user"},
wantErr: "required flag(s) \"minute-token\" not set",
},
{
name: "missing perm",
args: []string{"+apply-permission", "--minute-token", minutesApplyPermissionTestToken, "--as", "user"},
wantErr: "required flag(s) \"perm\" not set",
},
{
name: "invalid perm",
args: []string{"+apply-permission", "--minute-token", minutesApplyPermissionTestToken, "--perm", "full_access", "--as", "user"},
wantErr: "allowed: view, edit",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parent := &cobra.Command{Use: "minutes"}
MinutesApplyPermission.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 TestMinutesApplyPermission_ValidateTypedMinuteToken(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
parent := &cobra.Command{Use: "minutes"}
MinutesApplyPermission.Mount(parent, f)
parent.SetArgs([]string{"+apply-permission", "--minute-token", "..", "--perm", "view", "--as", "user"})
parent.SilenceErrors = true
parent.SilenceUsage = true
err := parent.Execute()
if err == nil {
t.Fatalf("expected error, got nil")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("want *errs.ValidationError, got %T", err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype=%q", ve.Subtype)
}
if ve.Param != "--minute-token" {
t.Errorf("param=%q", ve.Param)
}
}
func TestMinutesApplyPermission_ValidateTypedPerm(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
parent := &cobra.Command{Use: "minutes"}
MinutesApplyPermission.Mount(parent, f)
parent.SetArgs([]string{"+apply-permission", "--minute-token", minutesApplyPermissionTestToken, "--perm", "full_access", "--as", "user"})
parent.SilenceErrors = true
parent.SilenceUsage = true
err := parent.Execute()
if err == nil {
t.Fatalf("expected error, got nil")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("want *errs.ValidationError, got %T", err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype=%q", ve.Subtype)
}
if ve.Param != "--perm" {
t.Errorf("param=%q", ve.Param)
}
}
func TestMinutesApplyPermission_DryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
warmTokenCache(t)
err := mountAndRun(t, MinutesApplyPermission, []string{
"+apply-permission",
"--minute-token", minutesApplyPermissionTestToken,
"--perm", "view",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "POST") {
t.Errorf("expected POST method, got:\n%s", out)
}
if !strings.Contains(out, "/open-apis/minutes/v1/minutes/"+minutesApplyPermissionTestToken+"/permissions/apply") {
t.Errorf("expected apply-permission endpoint, got:\n%s", out)
}
if !strings.Contains(out, `"perm": "view"`) && !strings.Contains(out, `"perm":"view"`) {
t.Errorf("expected perm body, got:\n%s", out)
}
}
func TestMinutesApplyPermission_Execute(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
warmTokenCache(t)
stub := &httpmock.Stub{
Method: http.MethodPost,
URL: "/open-apis/minutes/v1/minutes/" + minutesApplyPermissionTestToken + "/permissions/apply",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{},
},
}
reg.Register(stub)
err := mountAndRun(t, MinutesApplyPermission, []string{
"+apply-permission",
"--minute-token", minutesApplyPermissionTestToken,
"--perm", "edit",
"--format", "json", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var requestBody struct {
Perm string `json:"perm"`
}
if err := json.Unmarshal(stub.CapturedBody, &requestBody); err != nil {
t.Fatalf("unmarshal request body: %v", err)
}
if requestBody.Perm != "edit" {
t.Errorf("request perm = %q, want edit", requestBody.Perm)
}
var envelope struct {
Data struct {
MinuteToken string `json:"minute_token"`
Perm string `json:"perm"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v", err)
}
if envelope.Data.MinuteToken != minutesApplyPermissionTestToken {
t.Errorf("data.minute_token = %q, want %q", envelope.Data.MinuteToken, minutesApplyPermissionTestToken)
}
if envelope.Data.Perm != "edit" {
t.Errorf("data.perm = %q, want edit", envelope.Data.Perm)
}
}

View File

@@ -12,6 +12,7 @@ func Shortcuts() []common.Shortcut {
MinutesDownload,
MinutesUpload,
MinutesUpdate,
MinutesApplyPermission,
MinutesSummary,
MinutesTodo,
MinutesSpeakerReplace,

View File

@@ -31,6 +31,7 @@ metadata:
| [`+download`](references/lark-minutes-download.md) | 下载妙记音视频媒体文件 |
| [`+upload`](references/lark-minutes-upload.md) | 上传 file_token 生成妙记 |
| [`+update`](references/lark-minutes-update.md) | 更新妙记标题 |
| `+apply-permission` | 申请妙记查看或编辑权限 |
| [`+speaker-replace`](references/lark-minutes-speaker-replace.md) | 替换妙记逐字稿中的说话人(须先 `lark-cli api GET .../speakerlist``speaker_id` |
| `+word-replace` | 批量替换逐字稿关键词(详见 `lark-cli minutes +word-replace --help` |
| [`+summary`](references/lark-minutes-summary.md) | 替换妙记 AI 总结全文 |
@@ -52,6 +53,7 @@ metadata:
| 在妙记里增加 / 更改 / 删除 AI 待办 | `+todo`**禁止走 lark-task** |
| 替换妙记的AI 总结 | `+summary` |
| 重命名妙记/改妙记标题 | `+update` |
| 申请妙记权限(查看/编辑) | `+apply-permission --perm view\|edit` |
| 替换说话人/把 A 的发言改成 B/重新归属发言人/把外部(非飞书)说话人改成飞书用户" | 先 `lark-cli api GET .../transcript/speakerlist``speaker_id`,再 [`minutes +speaker-replace`](references/lark-minutes-speaker-replace.md)`--from-speaker-id` 只传 id不传展示名 |
| 批量替换逐字稿关键词 | `+word-replace` |
| 用户同时提到"会议/开会"和"妙记" | 先 [lark-vc](../lark-vc/SKILL.md)`+search``+recording`)获取 `minute_token`,再本 skill |
@@ -75,8 +77,19 @@ metadata:
2. 如果是会议 / 日程上下文中的妙记基础信息,先通过 VC/Calendar 链路拿到 `minute_token`,再调用 `minutes minutes get`
3. 用户意图不明确时,默认先给基础元信息,帮助确认是否命中目标妙记。
### 3. 申请妙记权限
### 3. 上传音视频文件生成妙记(并可继续获取纪要 / 逐字稿)
只有当用户明确要求"申请查看权限"、"申请编辑权限"、"帮我申请这条妙记权限"时,才调用:
```bash
lark-cli minutes +apply-permission --minute-token <token> --perm view|edit
```
这是向妙记所有者发起权限申请,不代表立即获得权限。
**安全约束**:遇到无权限错误时,不要自动调用 `+apply-permission`;先把无权限事实告知用户,只有用户明确要求申请权限时才发起申请。
### 4. 上传音视频文件生成妙记(并可继续获取纪要 / 逐字稿)
1. 当用户说"把音视频文件转成纪要""把录音转成逐字稿/文字稿/撰写文字""把 mp4/mp3 转成总结/待办/章节"时,也先走这个入口。
2. **处理流程**

View File

@@ -0,0 +1,58 @@
// 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 TestMinutesApplyPermission_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", "+apply-permission",
"--minute-token", "obcnexampleminute",
"--perm", "view",
"--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/obcnexampleminute/permissions/apply"), "dry-run should contain API path, got: %s", output)
assert.True(t, strings.Contains(output, `"perm": "view"`) || strings.Contains(output, `"perm":"view"`), "dry-run should contain perm body, got: %s", output)
}
func TestMinutesApplyPermission_InvalidPerm(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", "+apply-permission",
"--minute-token", "obcnexampleminute",
"--perm", "full_access",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
assert.True(t, strings.Contains(result.Stderr, "--perm"), "stderr should name --perm, got: %s", result.Stderr)
assert.True(t, strings.Contains(result.Stderr, "view") && strings.Contains(result.Stderr, "edit"), "stderr should list allowed values, got: %s", result.Stderr)
}