feat(minutes): add minutes edit shortcuts (#1036)

This commit is contained in:
calendar-assistant
2026-05-26 18:41:50 +08:00
committed by GitHub
parent b9e5b50251
commit cf40945bbc
11 changed files with 821 additions and 11 deletions

View File

@@ -0,0 +1,139 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
minutesSpeakerReplaceSpeakerNotFoundCode = 2091001
minutesSpeakerReplaceNoEditPermission = 2091005
)
// MinutesSpeakerReplace replaces a speaker in a minute's transcript.
var MinutesSpeakerReplace = common.Shortcut{
Service: "minutes",
Command: "+speaker-replace",
Description: "Replace a speaker in a minute's transcript (rebind from one user to another)",
Risk: "write",
Scopes: []string{"minutes:minutes:update"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "minute-token", Desc: "minute token", Required: true},
{Name: "from-user-id", Desc: "speaker to replace, must be an open_id starting with 'ou_'", Required: true},
{Name: "to-user-id", Desc: "new speaker, must be an open_id starting with 'ou_'", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
if minuteToken == "" {
return output.ErrValidation("--minute-token is required")
}
if err := validate.ResourceName(minuteToken, "--minute-token"); err != nil {
return output.ErrValidation("%s", err)
}
fromUserID := strings.TrimSpace(runtime.Str("from-user-id"))
if fromUserID == "" {
return output.ErrValidation("--from-user-id is required")
}
if _, err := common.ValidateUserID(fromUserID); err != nil {
return output.ErrValidation("--from-user-id: %s", err)
}
toUserID := strings.TrimSpace(runtime.Str("to-user-id"))
if toUserID == "" {
return output.ErrValidation("--to-user-id is required")
}
if _, err := common.ValidateUserID(toUserID); err != nil {
return output.ErrValidation("--to-user-id: %s", err)
}
if fromUserID == toUserID {
return output.ErrValidation("--from-user-id and --to-user-id must be different")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
fromUserID := strings.TrimSpace(runtime.Str("from-user-id"))
toUserID := strings.TrimSpace(runtime.Str("to-user-id"))
return common.NewDryRunAPI().
PUT(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken))).
Body(map[string]interface{}{
"minute_token": minuteToken,
"from_user_id": fromUserID,
"to_user_id": toUserID,
})
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
fromUserID := strings.TrimSpace(runtime.Str("from-user-id"))
toUserID := strings.TrimSpace(runtime.Str("to-user-id"))
body := map[string]interface{}{
"minute_token": minuteToken,
"from_user_id": fromUserID,
"to_user_id": toUserID,
}
_, err := runtime.CallAPI(http.MethodPut,
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken)),
nil, body)
if err != nil {
return minutesSpeakerReplaceError(err, minuteToken, fromUserID)
}
outData := map[string]interface{}{
"minute_token": minuteToken,
"from_user_id": fromUserID,
"to_user_id": toUserID,
}
runtime.OutFormat(outData, nil, nil)
return nil
},
}
func minutesSpeakerReplaceError(err error, minuteToken, fromUserID string) error {
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
return err
}
switch exitErr.Detail.Code {
case minutesSpeakerReplaceNoEditPermission:
return &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "no_edit_permission",
Code: minutesSpeakerReplaceNoEditPermission,
Message: fmt.Sprintf("No edit permission for minute %q: cannot replace the transcript speaker.", minuteToken),
Hint: "Ask the minute owner for minute edit permission",
Detail: exitErr.Detail.Detail,
},
Err: err,
}
case minutesSpeakerReplaceSpeakerNotFoundCode:
return &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "speaker_not_found",
Code: minutesSpeakerReplaceSpeakerNotFoundCode,
Message: fmt.Sprintf("Speaker not found in minute %q: --from-user-id %q does not match an existing speaker in the transcript.", minuteToken, fromUserID),
Hint: "Check --minute-token and --from-user-id. Use an open_id for a speaker that appears in the minute transcript, then retry.",
Detail: exitErr.Detail.Detail,
},
Err: err,
}
}
return err
}

View File

@@ -0,0 +1,247 @@
// 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/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
const minutesSpeakerReplaceTestToken = "obcnexampleminute"
func TestMinutesSpeakerReplace_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{"+speaker-replace", "--from-user-id", "ou_a", "--to-user-id", "ou_b", "--as", "user"},
wantErr: "required flag(s) \"minute-token\" not set",
},
{
name: "missing from",
args: []string{"+speaker-replace", "--minute-token", "obcn123456", "--to-user-id", "ou_b", "--as", "user"},
wantErr: "required flag(s) \"from-user-id\" not set",
},
{
name: "missing to",
args: []string{"+speaker-replace", "--minute-token", "obcn123456", "--from-user-id", "ou_a", "--as", "user"},
wantErr: "required flag(s) \"to-user-id\" not set",
},
{
name: "invalid from prefix",
args: []string{"+speaker-replace", "--minute-token", "obcn123456", "--from-user-id", "u_a", "--to-user-id", "ou_b", "--as", "user"},
wantErr: "--from-user-id",
},
{
name: "invalid to prefix",
args: []string{"+speaker-replace", "--minute-token", "obcn123456", "--from-user-id", "ou_a", "--to-user-id", "u_b", "--as", "user"},
wantErr: "--to-user-id",
},
{
name: "from equals to",
args: []string{"+speaker-replace", "--minute-token", "obcn123456", "--from-user-id", "ou_same", "--to-user-id", "ou_same", "--as", "user"},
wantErr: "must be different",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parent := &cobra.Command{Use: "minutes"}
MinutesSpeakerReplace.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 TestMinutesSpeakerReplace_DryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
warmTokenCache(t)
err := mountAndRun(t, MinutesSpeakerReplace, []string{
"+speaker-replace",
"--minute-token", minutesSpeakerReplaceTestToken,
"--from-user-id", "ou_old_speaker",
"--to-user-id", "ou_new_speaker",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "PUT") {
t.Errorf("expected PUT method, got:\n%s", out)
}
if !strings.Contains(out, "/open-apis/minutes/v1/minutes/"+minutesSpeakerReplaceTestToken+"/transcript/speaker") {
t.Errorf("expected speaker endpoint, got:\n%s", out)
}
if !strings.Contains(out, "ou_old_speaker") {
t.Errorf("expected from_user_id in body, got:\n%s", out)
}
if !strings.Contains(out, "ou_new_speaker") {
t.Errorf("expected to_user_id in body, got:\n%s", out)
}
}
func TestMinutesSpeakerReplace_Execute(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
warmTokenCache(t)
reg.Register(&httpmock.Stub{
Method: http.MethodPut,
URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speaker",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{},
},
})
err := mountAndRun(t, MinutesSpeakerReplace, []string{
"+speaker-replace",
"--minute-token", minutesSpeakerReplaceTestToken,
"--from-user-id", "ou_old_speaker",
"--to-user-id", "ou_new_speaker",
"--format", "json", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var envelope struct {
Data struct {
MinuteToken string `json:"minute_token"`
FromUserID string `json:"from_user_id"`
ToUserID string `json:"to_user_id"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v", err)
}
if envelope.Data.MinuteToken != minutesSpeakerReplaceTestToken {
t.Errorf("data.minute_token = %q, want %q", envelope.Data.MinuteToken, minutesSpeakerReplaceTestToken)
}
if envelope.Data.FromUserID != "ou_old_speaker" {
t.Errorf("data.from_user_id = %q, want ou_old_speaker", envelope.Data.FromUserID)
}
if envelope.Data.ToUserID != "ou_new_speaker" {
t.Errorf("data.to_user_id = %q, want ou_new_speaker", envelope.Data.ToUserID)
}
}
func TestMinutesSpeakerReplace_SpeakerNotFound(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
warmTokenCache(t)
reg.Register(&httpmock.Stub{
Method: http.MethodPut,
URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speaker",
Body: map[string]interface{}{
"code": 2091001,
"msg": "speaker not exist",
},
})
err := mountAndRun(t, MinutesSpeakerReplace, []string{
"+speaker-replace",
"--minute-token", minutesSpeakerReplaceTestToken,
"--from-user-id", "ou_missing_speaker",
"--to-user-id", "ou_new_speaker",
"--format", "json", "--as", "user",
}, f, stdout)
if err == nil {
t.Fatal("expected speaker-not-found error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Detail == nil {
t.Fatalf("expected structured error detail, got nil")
}
if exitErr.Detail.Type != "speaker_not_found" {
t.Errorf("error type = %q, want speaker_not_found", exitErr.Detail.Type)
}
if !strings.Contains(exitErr.Detail.Message, "Speaker not found") {
t.Errorf("message should be friendly, got: %s", exitErr.Detail.Message)
}
if !strings.Contains(exitErr.Detail.Message, "ou_missing_speaker") {
t.Errorf("message should include missing speaker id, got: %s", exitErr.Detail.Message)
}
if !strings.Contains(exitErr.Detail.Hint, "--from-user-id") {
t.Errorf("hint should mention --from-user-id, got: %s", exitErr.Detail.Hint)
}
}
func TestMinutesSpeakerReplace_NoEditPermission(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
warmTokenCache(t)
reg.Register(&httpmock.Stub{
Method: http.MethodPut,
URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speaker",
Body: map[string]interface{}{
"code": 2091005,
"msg": "no edit permission",
},
})
err := mountAndRun(t, MinutesSpeakerReplace, []string{
"+speaker-replace",
"--minute-token", minutesSpeakerReplaceTestToken,
"--from-user-id", "ou_old_speaker",
"--to-user-id", "ou_new_speaker",
"--format", "json", "--as", "user",
}, f, stdout)
if err == nil {
t.Fatal("expected no-edit-permission error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Detail == nil {
t.Fatalf("expected structured error detail, got nil")
}
if exitErr.Detail.Type != "no_edit_permission" {
t.Errorf("error type = %q, want no_edit_permission", exitErr.Detail.Type)
}
if !strings.Contains(exitErr.Detail.Message, "No edit permission") {
t.Errorf("message should be friendly, got: %s", exitErr.Detail.Message)
}
if !strings.Contains(exitErr.Detail.Message, minutesSpeakerReplaceTestToken) {
t.Errorf("message should include minute token, got: %s", exitErr.Detail.Message)
}
if !strings.Contains(exitErr.Detail.Hint, "edit permission") {
t.Errorf("hint should mention edit permission, got: %s", exitErr.Detail.Hint)
}
}

View File

@@ -0,0 +1,94 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const minutesUpdateNoEditPermissionCode = 2091005
// MinutesUpdate updates the title (topic) of a minute.
var MinutesUpdate = common.Shortcut{
Service: "minutes",
Command: "+update",
Description: "Update a minute's title",
Risk: "write",
Scopes: []string{"minutes:minutes:update"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "minute-token", Desc: "minute token", Required: true},
{Name: "topic", Desc: "new minute title", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
if minuteToken == "" {
return output.ErrValidation("--minute-token is required")
}
if err := validate.ResourceName(minuteToken, "--minute-token"); err != nil {
return output.ErrValidation("%s", err)
}
if strings.TrimSpace(runtime.Str("topic")) == "" {
return output.ErrValidation("--topic is required")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
return common.NewDryRunAPI().
PATCH(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment(minuteToken))).
Body(map[string]interface{}{"topic": runtime.Str("topic")})
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
topic := runtime.Str("topic")
body := map[string]interface{}{
"topic": topic,
}
_, err := runtime.CallAPI(http.MethodPatch,
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment(minuteToken)),
nil, body)
if err != nil {
return minutesUpdateError(err, minuteToken)
}
outData := map[string]interface{}{
"minute_token": minuteToken,
"topic": topic,
}
runtime.OutFormat(outData, nil, nil)
return nil
},
}
func minutesUpdateError(err error, minuteToken string) error {
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Code != minutesUpdateNoEditPermissionCode {
return err
}
return &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "no_edit_permission",
Code: minutesUpdateNoEditPermissionCode,
Message: fmt.Sprintf("No edit permission for minute %q: cannot update the title.", minuteToken),
Hint: "Ask the minute owner for minute edit permission",
Detail: exitErr.Detail.Detail,
},
Err: err,
}
}

View File

@@ -0,0 +1,154 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"errors"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
const minutesUpdateTestToken = "obcnexampleminute"
func TestMinutesUpdate_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{"+update", "--topic", "new title", "--as", "user"},
wantErr: "required flag(s) \"minute-token\" not set",
},
{
name: "missing topic",
args: []string{"+update", "--minute-token", "obcn123456", "--as", "user"},
wantErr: "required flag(s) \"topic\" not set",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parent := &cobra.Command{Use: "minutes"}
MinutesUpdate.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 TestMinutesUpdate_DryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
warmTokenCache(t)
err := mountAndRun(t, MinutesUpdate, []string{
"+update",
"--minute-token", minutesUpdateTestToken,
"--topic", "周会纪要",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "PATCH") {
t.Errorf("expected PATCH method, got:\n%s", out)
}
if !strings.Contains(out, "/open-apis/minutes/v1/minutes/"+minutesUpdateTestToken) {
t.Errorf("expected PATCH /open-apis/minutes/v1/minutes/<token>, got:\n%s", out)
}
if !strings.Contains(out, "周会纪要") {
t.Errorf("expected topic in body, got:\n%s", out)
}
}
func TestMinutesUpdate_Execute(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
warmTokenCache(t)
reg.Register(&httpmock.Stub{
Method: http.MethodPatch,
URL: "/open-apis/minutes/v1/minutes/" + minutesUpdateTestToken,
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{},
},
})
err := mountAndRun(t, MinutesUpdate, []string{
"+update",
"--minute-token", minutesUpdateTestToken,
"--topic", "新标题",
"--format", "json", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestMinutesUpdate_NoEditPermission(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
warmTokenCache(t)
reg.Register(&httpmock.Stub{
Method: http.MethodPatch,
URL: "/open-apis/minutes/v1/minutes/" + minutesUpdateTestToken,
Body: map[string]interface{}{
"code": 2091005,
"msg": "no edit permission",
},
})
err := mountAndRun(t, MinutesUpdate, []string{
"+update",
"--minute-token", minutesUpdateTestToken,
"--topic", "新标题",
"--format", "json", "--as", "user",
}, f, stdout)
if err == nil {
t.Fatal("expected no-edit-permission error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Detail == nil {
t.Fatalf("expected structured error detail, got nil")
}
if exitErr.Detail.Type != "no_edit_permission" {
t.Errorf("error type = %q, want no_edit_permission", exitErr.Detail.Type)
}
if !strings.Contains(exitErr.Detail.Message, "No edit permission") {
t.Errorf("message should be friendly, got: %s", exitErr.Detail.Message)
}
if !strings.Contains(exitErr.Detail.Message, minutesUpdateTestToken) {
t.Errorf("message should include minute token, got: %s", exitErr.Detail.Message)
}
if !strings.Contains(exitErr.Detail.Hint, "edit permission") {
t.Errorf("hint should mention edit permission, got: %s", exitErr.Detail.Hint)
}
}

View File

@@ -11,5 +11,7 @@ func Shortcuts() []common.Shortcut {
MinutesSearch,
MinutesDownload,
MinutesUpload,
MinutesUpdate,
MinutesSpeakerReplace,
}
}

View File

@@ -59,14 +59,12 @@ lark-cli calendar +create --summary "..." --start "..." --end "..." \
## 查看完整参数定义
lark-cli schema calendar.events.create
## 创建日程
lark-cli calendar events create --calendar-id primary --data '{
"summary": "产品评审",
"description": "本周分享主题CLI 架构设计",
lark-cli calendar events create \
--params '{"calendar_id":"<CALENDAR_ID>"}' \
--data '{
"summary": "技术分享CLI 架构设计",
"start_time": { "timestamp": "1741586400" },
"end_time": { "timestamp": "1741593600" },
"location": { "name": "5F-大会议室" },
"attendee_ability": "can_modify_event",
"reminders": [{ "minutes": 15 }]
"end_time": { "timestamp": "1741593600" }
}'
# 第二步:添加参会人(使用第一步返回的 calendar_id 和 event_id
@@ -74,7 +72,7 @@ lark-cli calendar events create --calendar-id primary --data '{
lark-cli schema calendar.event.attendees.create
## 添加参会人
lark-cli calendar event.attendees create \
--calendar-id <CALENDAR_ID> --event-id <EVENT_ID> \
--params '{"calendar_id":"<CALENDAR_ID>","event_id":"<EVENT_ID>"}' \
--data '{"attendees": [{"type": "user", "user_id": "ou_xxx"}]}'
# 可选第三步(推荐):若第二步失败,回滚删除空日程
@@ -82,8 +80,7 @@ lark-cli calendar event.attendees create \
lark-cli schema calendar.events.delete
## 删除空日程
lark-cli calendar events delete \
--calendar-id <CALENDAR_ID> --event-id <EVENT_ID> \
--params '{"need_notification":false}'
--params '{"calendar_id":"<CALENDAR_ID>","event_id":"<EVENT_ID>","need_notification":false}'
```

View File

@@ -1,7 +1,7 @@
---
name: lark-minutes
version: 1.0.0
description: "飞书妙记妙记相关基本功能。1.查询妙记列表(按关键词/所有者/参与者/时间范围2.获取妙记基础信息(标题、封面、时长 等3.下载妙记音视频文件4.获取妙记相关 AI 产物总结、待办、章节5.上传音视频生成妙记,也支持将本地音视频文件转成纪要、逐字稿、文字稿、撰写文字等产物。遇到这类请求时,应优先使用本 skill,而不是尝试 `ffmpeg``whisper` 等本地转写命令。飞书妙记 URL 格式: http(s)://<host>/minutes/<minute-token>"
description: "飞书妙记妙记相关基本功能。1.查询妙记列表(按关键词/所有者/参与者/时间范围2.获取妙记基础信息(标题、封面、时长 等3.下载妙记音视频文件4.获取妙记相关 AI 产物总结、待办、章节5.上传音视频生成妙记,也支持将本地音视频文件转成纪要、逐字稿、文字稿、撰写文字等产物6.更新妙记标题重命名妙记7.替换妙记逐字稿中的说话人。遇到这类请求时,应优先使用本 skill。飞书妙记 URL 格式: http(s)://<host>/minutes/<minute-token>"
metadata:
requires:
bins: ["lark-cli"]
@@ -98,6 +98,8 @@ Minutes (妙记) ← minute_token 标识
> - 用户说"这个妙记的逐字稿 / 文字稿 / 撰写文字 / 总结 / 待办 / 章节" → 使用 [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md)
> - 用户说"通过文件生成妙记 / 把音视频转妙记" → 先上传获取 `file_token`,然后使用 `minutes +upload`
> - 用户说"把音视频文件转成纪要 / 逐字稿 / 文字稿 / 撰写文字 / 总结 / 待办 / 章节" → 先上传获取 `file_token`,调用 `minutes +upload` 生成 `minute_url`,再提取 `minute_token` 走 `vc +notes --minute-tokens`
> - 用户说"重命名妙记 / 改妙记标题 / 修改妙记名字" → `minutes +update`
> - 用户说"替换说话人 / 把 A 的发言改成 B / 重新归属发言人" → `minutes +speaker-replace`
## Shortcuts推荐优先使用
@@ -108,10 +110,14 @@ 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 |
| [`+update`](references/lark-minutes-update.md) | Update a minute's title |
| [`+speaker-replace`](references/lark-minutes-speaker-replace.md) | Replace a speaker in a minute's transcript (rebind from one user to another) |
- 使用 `+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),了解生成参数和返回值结构。
- 使用 `+update` 命令时,必须阅读 [references/lark-minutes-update.md](references/lark-minutes-update.md),了解修改参数和返回值结构。
- 使用 `+speaker-replace` 命令时,必须阅读 [references/lark-minutes-speaker-replace.md](references/lark-minutes-speaker-replace.md),了解参数和限制(仅支持用户 ID不支持姓名
<!-- AUTO-GENERATED-START — gen-skills.py 管理,勿手动编辑 -->
@@ -135,5 +141,7 @@ lark-cli minutes <resource> <method> [flags] # 调用 API
| `+search` | `minutes:minutes.search:read` |
| `minutes.get` | `minutes:minutes:readonly` |
| `+download` | `minutes:minutes.media:export` |
| `+update` | `minutes:minutes:update` |
| `+speaker-replace` | `minutes:minutes:update` |
<!-- AUTO-GENERATED-END -->

View File

@@ -0,0 +1,50 @@
# minutes +speaker-replace
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
替换妙记逐字稿中的说话人身份:把妙记逐字稿里"原说话人"对应的所有发言段,重新归属到"新说话人"。常用于解决妙记自动识别错说话人,或需要手工把某段语音绑定到正确用户的场景。
本 skill 对应 shortcut`lark-cli minutes +speaker-replace`
## 典型触发表达
- "把这条妙记里 A 的发言改成 B"
- "妙记说话人识别错了,帮我把张三的部分换成李四"
- "妙记说话人修改 / 替换 / 重新归属"
- "改一下妙记的说话人"
## 命令示例
```bash
lark-cli minutes +speaker-replace \
--minute-token obcnxxxxxxxxxxxxxxxxxxxx \
--from-user-id ou_old_speaker_open_id \
--to-user-id ou_new_speaker_open_id
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--minute-token <token>` | 是 | 妙记的唯一标识,可从妙记 URL 末尾路径提取 |
| `--from-user-id <ou_xxx>` | 是 | 被替换的原说话人,**必须是 `ou_` 开头的 open_id**,不支持用户名 |
| `--to-user-id <ou_xxx>` | 是 | 新的说话人,**必须是 `ou_` 开头的 open_id**,不支持用户名 |
> **重要**`--from-user-id` 和 `--to-user-id` 仅支持 `ou_` 开头的用户 ID**不支持直接传姓名**。如果用户只给了姓名,请先用 [lark-contact](../../lark-contact/SKILL.md) 把姓名解析成 `open_id`,再调用本命令。
## 认证与权限
- 所需 scope`minutes:minutes:update`
## 输出结果
| 字段 | 说明 |
|------|------|
| `minute_token` | 被修改的妙记 Token与输入的 `--minute-token` 一致 |
| `from_user_id` | 被替换的原说话人 open_id与输入的 `--from-user-id` 一致;必须是妙记逐字稿中已存在的说话人 |
| `to_user_id` | 替换后的新说话人 open_id与输入的 `--to-user-id` 一致 |
## 参考
- [lark-minutes](../SKILL.md) -- 妙记相关功能说明
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数

View File

@@ -0,0 +1,41 @@
# minutes +update
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
修改飞书妙记的标题topic
本 skill 对应 shortcut`lark-cli minutes +update`
## 典型触发表达
- "把这个妙记的标题改成 xxx"
- "重命名这条妙记"
- "修改妙记标题"
## 命令示例
```bash
lark-cli minutes +update --minute-token xxx --topic "周会纪要 2026-05-18"
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--minute-token <token>` | 是 | 妙记的唯一标识,可从妙记 URL 末尾路径提取 |
| `--topic <string>` | 是 | 新的妙记标题 |
## 认证与权限
- 所需 scope`minutes:minutes:update`
## 输出结果
| 字段 | 说明 |
|------|------|
| `minute_token` | 被修改的妙记 Token与输入的 `--minute-token` 一致,可继续用于查询妙记信息、下载媒体或获取纪要产物 |
| `topic` | 修改后的妙记标题,与输入的 `--topic` 一致 |
## 参考
- [lark-minutes](../SKILL.md) -- 妙记相关功能说明
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数

View File

@@ -0,0 +1,40 @@
// 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 TestMinutesSpeakerReplace_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", "+speaker-replace",
"--minute-token", "obcnexampleminute",
"--from-user-id", "ou_old_speaker",
"--to-user-id", "ou_new_speaker",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := result.Stdout
assert.True(t, strings.Contains(output, "PUT"), "dry-run should contain PUT method, got: %s", output)
assert.True(t, strings.Contains(output, "/open-apis/minutes/v1/minutes/obcnexampleminute/transcript/speaker"), "dry-run should contain API path, got: %s", output)
assert.True(t, strings.Contains(output, "ou_old_speaker"), "dry-run should contain from_user_id, got: %s", output)
assert.True(t, strings.Contains(output, "ou_new_speaker"), "dry-run should contain to_user_id, got: %s", output)
}

View File

@@ -0,0 +1,38 @@
// 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 TestMinutesUpdate_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", "+update",
"--minute-token", "obcnexampleminute",
"--topic", "新的妙记标题",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := result.Stdout
assert.True(t, strings.Contains(output, "PATCH"), "dry-run should contain PATCH method, got: %s", output)
assert.True(t, strings.Contains(output, "/open-apis/minutes/v1/minutes/obcnexampleminute"), "dry-run should contain API path, got: %s", output)
assert.True(t, strings.Contains(output, "新的妙记标题"), "dry-run should contain topic, got: %s", output)
}