From ba51d4874e5e12d8154002303a8680ea12bf4f8c Mon Sep 17 00:00:00 2001 From: zhangjun-bytedance Date: Fri, 26 Jun 2026 11:41:32 +0800 Subject: [PATCH] feat: support speaker list and nolark speaker replace (#1594) --- shortcuts/minutes/minutes_speaker_replace.go | 113 ++++++++++----- .../minutes/minutes_speaker_replace_test.go | 129 +++++++++++++++++- shortcuts/minutes/minutes_speakers.go | 104 ++++++++++++++ shortcuts/minutes/minutes_speakers_test.go | 45 ++++++ skills/lark-minutes/SKILL.md | 20 ++- .../lark-minutes-speaker-replace.md | 71 +++++++++- .../minutes/minutes_speaker_replace_test.go | 51 +++++++ .../sheets_table_put_typed_workflow_test.go | 16 ++- 8 files changed, 496 insertions(+), 53 deletions(-) create mode 100644 shortcuts/minutes/minutes_speakers.go create mode 100644 shortcuts/minutes/minutes_speakers_test.go diff --git a/shortcuts/minutes/minutes_speaker_replace.go b/shortcuts/minutes/minutes_speaker_replace.go index f0fa649f..716230d3 100644 --- a/shortcuts/minutes/minutes_speaker_replace.go +++ b/shortcuts/minutes/minutes_speaker_replace.go @@ -25,12 +25,13 @@ var MinutesSpeakerReplace = common.Shortcut{ 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"}, + Scopes: []string{"minutes:minutes:readonly", "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: "from-speaker-id", Desc: "speaker to replace: opaque speaker_id from transcript speakerlist API (do not pass display names)"}, + {Name: "from-user-id", Desc: "deprecated: open_id of the speaker to replace; prefer --from-speaker-id", Hidden: 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 { @@ -41,12 +42,10 @@ var MinutesSpeakerReplace = common.Shortcut{ if err := validate.ResourceName(minuteToken, "--minute-token"); err != nil { return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--minute-token") } + fromSpeakerID := strings.TrimSpace(runtime.Str("from-speaker-id")) fromUserID := strings.TrimSpace(runtime.Str("from-user-id")) - if fromUserID == "" { - return errs.NewValidationError(errs.SubtypeInvalidArgument, "--from-user-id is required").WithParam("--from-user-id") - } - if _, err := common.ValidateUserIDTyped("--from-user-id", fromUserID); err != nil { - return err + if fromSpeakerID == "" && fromUserID == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--from-speaker-id is required").WithParam("--from-speaker-id") } toUserID := strings.TrimSpace(runtime.Str("to-user-id")) if toUserID == "" { @@ -55,53 +54,93 @@ var MinutesSpeakerReplace = common.Shortcut{ if _, err := common.ValidateUserIDTyped("--to-user-id", toUserID); err != nil { return err } - if fromUserID == toUserID { - return errs.NewValidationError(errs.SubtypeInvalidArgument, "--from-user-id and --to-user-id must be different").WithParam("--to-user-id") + if fromSpeakerID == "" { + if _, err := common.ValidateUserIDTyped("--from-user-id", fromUserID); err != nil { + return err + } + if fromUserID == toUserID { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--from-user-id and --to-user-id must be different").WithParam("--to-user-id") + } } 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, - }) + dr := common.NewDryRunAPI() + if strings.TrimSpace(runtime.Str("from-speaker-id")) != "" && strings.TrimSpace(runtime.Str("from-user-id")) == "" { + dr.GET(minuteTranscriptSpeakerlistPath(minuteToken)).Desc("Resolve --from-speaker-id when it is a display name") + } + return dr.PUT(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken))). + Body(buildSpeakerReplaceRequestBody(runtime)) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { minuteToken := strings.TrimSpace(runtime.Str("minute-token")) - fromUserID := strings.TrimSpace(runtime.Str("from-user-id")) + fromSpeakerInput := strings.TrimSpace(runtime.Str("from-speaker-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.CallAPITyped(http.MethodPut, - fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken)), - nil, body) + fromSpeakerID, fromUserID, err := resolveSpeakerReplaceFrom(runtime, minuteToken) if err != nil { - return minutesSpeakerReplaceError(err, minuteToken, fromUserID) + return err } - outData := map[string]interface{}{ - "minute_token": minuteToken, - "from_user_id": fromUserID, - "to_user_id": toUserID, + _, err = runtime.CallAPITyped(http.MethodPut, + fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken)), + map[string]interface{}{"user_id_type": "open_id"}, buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID)) + if err != nil { + return minutesSpeakerReplaceError(err, minuteToken, speakerReplaceSourceLabel(fromSpeakerInput, fromSpeakerID, fromUserID)) } - runtime.OutFormat(outData, nil, nil) + runtime.OutFormat(buildSpeakerReplaceOutputData(fromSpeakerInput, minuteToken, fromSpeakerID, fromUserID, toUserID), nil, nil) return nil }, } -func minutesSpeakerReplaceError(err error, minuteToken, fromUserID string) error { +func buildSpeakerReplaceRequestBody(runtime *common.RuntimeContext) map[string]interface{} { + fromSpeakerID := strings.TrimSpace(runtime.Str("from-speaker-id")) + fromUserID := strings.TrimSpace(runtime.Str("from-user-id")) + toUserID := strings.TrimSpace(runtime.Str("to-user-id")) + return buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID) +} + +func buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID string) map[string]interface{} { + body := map[string]interface{}{ + "to_user_id": toUserID, + } + if fromSpeakerID != "" { + body["from_speaker_id"] = fromSpeakerID + } else { + body["from_user_id"] = fromUserID + } + return body +} + +func buildSpeakerReplaceOutputData(fromSpeakerInput, minuteToken, fromSpeakerID, fromUserID, toUserID string) map[string]interface{} { + out := map[string]interface{}{ + "minute_token": minuteToken, + "to_user_id": toUserID, + } + if fromSpeakerID != "" { + out["from_speaker_id"] = fromSpeakerID + if fromSpeakerInput != "" && fromSpeakerInput != fromSpeakerID { + out["from_speaker_input"] = fromSpeakerInput + } + } else { + out["from_user_id"] = fromUserID + } + return out +} + +func speakerReplaceSourceLabel(fromSpeakerInput, fromSpeakerID, fromUserID string) string { + if fromSpeakerInput != "" { + return fromSpeakerInput + } + if fromSpeakerID != "" { + return fromSpeakerID + } + return fromUserID +} + +func minutesSpeakerReplaceError(err error, minuteToken, sourceSpeaker string) error { p, ok := errs.ProblemOf(err) if !ok { return err @@ -112,8 +151,8 @@ func minutesSpeakerReplaceError(err error, minuteToken, fromUserID string) error p.Hint = "Ask the minute owner for minute edit permission" case minutesSpeakerReplaceSpeakerNotFoundCode: p.Subtype = errs.SubtypeNotFound - p.Message = fmt.Sprintf("Speaker not found in minute %q: --from-user-id %q does not match an existing speaker in the transcript.", minuteToken, fromUserID) - p.Hint = "Check --minute-token and --from-user-id. Use an open_id for a speaker that appears in the minute transcript, then retry." + p.Message = fmt.Sprintf("Speaker not found in minute %q: source speaker %q does not match an existing speaker in the transcript.", minuteToken, sourceSpeaker) + p.Hint = "Verify --from-speaker-id is a valid speaker_id or display name from the transcript; if multiple speakers share the same name, pass the exact speaker_id after reviewing their utterances." } return err } diff --git a/shortcuts/minutes/minutes_speaker_replace_test.go b/shortcuts/minutes/minutes_speaker_replace_test.go index 78944b2d..5b028a24 100644 --- a/shortcuts/minutes/minutes_speaker_replace_test.go +++ b/shortcuts/minutes/minutes_speaker_replace_test.go @@ -34,7 +34,7 @@ func TestMinutesSpeakerReplace_Validate(t *testing.T) { { name: "missing from", args: []string{"+speaker-replace", "--minute-token", minutesSpeakerReplaceTestToken, "--to-user-id", "ou_b", "--as", "user"}, - wantErr: "required flag(s) \"from-user-id\" not set", + wantErr: "--from-speaker-id is required", }, { name: "missing to", @@ -153,6 +153,129 @@ func TestMinutesSpeakerReplace_DryRun(t *testing.T) { } } +func TestMinutesSpeakerReplace_DryRun_ResolveFromSpeakerID(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-speaker-id", "说话人1", + "--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, "GET") { + t.Errorf("expected GET for internal speaker list, got:\n%s", out) + } + if !strings.Contains(out, "/transcript/speakerlist") { + t.Errorf("expected speakerlist path, got:\n%s", out) + } + if !strings.Contains(out, "PUT") { + t.Errorf("expected PUT for speaker replace, 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_ResolveFromSpeakerID(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.MethodGet, + URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speakerlist", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "speakers": []interface{}{ + map[string]interface{}{ + "speaker_id": "ENCRYPTED_TOKEN_ABC", + "name": "说话人1", + }, + }, + }, + }, + }) + 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-speaker-id", "说话人1", + "--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"` + FromSpeakerInput string `json:"from_speaker_input"` + FromSpeakerID string `json:"from_speaker_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.FromSpeakerInput != "说话人1" { + t.Errorf("data.from_speaker_input = %q, want 说话人1", envelope.Data.FromSpeakerInput) + } + if envelope.Data.FromSpeakerID != "ENCRYPTED_TOKEN_ABC" { + t.Errorf("data.from_speaker_id = %q, want ENCRYPTED_TOKEN_ABC", envelope.Data.FromSpeakerID) + } +} + +func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(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-speaker-id", "ENCRYPTED_TOKEN_ABC", + "--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, "GET") { + t.Errorf("expected GET for internal speaker list, got:\n%s", out) + } + if !strings.Contains(out, "from_speaker_id") || !strings.Contains(out, "ENCRYPTED_TOKEN_ABC") { + t.Errorf("expected from_speaker_id in body, got:\n%s", out) + } + if strings.Contains(out, "from_user_id") { + t.Errorf("from_speaker_id path should not send from_user_id, 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()) @@ -238,8 +361,8 @@ func TestMinutesSpeakerReplace_SpeakerNotFound(t *testing.T) { if !strings.Contains(p.Message, "ou_missing_speaker") { t.Errorf("message should include missing speaker id, got: %s", p.Message) } - if !strings.Contains(p.Hint, "--from-user-id") { - t.Errorf("hint should mention --from-user-id, got: %s", p.Hint) + if !strings.Contains(p.Hint, "--from-speaker-id") { + t.Errorf("hint should mention --from-speaker-id, got: %s", p.Hint) } } diff --git a/shortcuts/minutes/minutes_speakers.go b/shortcuts/minutes/minutes_speakers.go new file mode 100644 index 00000000..b9649f6d --- /dev/null +++ b/shortcuts/minutes/minutes_speakers.go @@ -0,0 +1,104 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package minutes + +import ( + "fmt" + "net/http" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +type minuteSpeaker struct { + SpeakerID string + Name string +} + +func minuteTranscriptSpeakerlistPath(minuteToken string) string { + return fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speakerlist", validate.EncodePathSegment(minuteToken)) +} + +func fetchMinuteSpeakers(runtime *common.RuntimeContext, minuteToken string) ([]minuteSpeaker, error) { + data, err := runtime.CallAPITyped(http.MethodGet, minuteTranscriptSpeakerlistPath(minuteToken), nil, nil) + if err != nil { + return nil, err + } + if data == nil { + return nil, nil + } + + items := common.GetSlice(data, "speakers") + speakers := make([]minuteSpeaker, 0, len(items)) + for _, raw := range items { + item, _ := raw.(map[string]interface{}) + if item == nil { + continue + } + id := strings.TrimSpace(common.GetString(item, "speaker_id")) + name := strings.TrimSpace(common.GetString(item, "name")) + if id == "" { + continue + } + speakers = append(speakers, minuteSpeaker{SpeakerID: id, Name: name}) + } + return speakers, nil +} + +func resolveSpeakerIDByName(speakers []minuteSpeaker, name string) (string, error) { + name = strings.TrimSpace(name) + var matches []minuteSpeaker + for _, s := range speakers { + if s.Name == name { + matches = append(matches, s) + } + } + switch len(matches) { + case 0: + return "", errs.NewValidationError(errs.SubtypeNotFound, + "no speaker named %q in minute transcript", name). + WithParam("--from-speaker-id"). + WithHint("Check the speaker name spelling or open the minute to see transcript speaker labels") + case 1: + return matches[0].SpeakerID, nil + default: + ids := make([]string, len(matches)) + for i, m := range matches { + ids[i] = m.SpeakerID + } + return "", errs.NewValidationError(errs.SubtypeFailedPrecondition, + "multiple speakers named %q (%d matches); pass the exact --from-speaker-id", name, len(matches)). + WithParam("--from-speaker-id"). + WithHint(fmt.Sprintf("Matching speaker_ids: %s. Review each speaker's utterances in the minute, then retry with the exact speaker_id", strings.Join(ids, ", "))) + } +} + +// resolveFromSpeakerID resolves --from-speaker-id to an API speaker_id. +// The input may already be an opaque speaker_id, or a display name that requires +// an internal speaker-list fetch. +func resolveFromSpeakerID(runtime *common.RuntimeContext, minuteToken, input string) (string, error) { + input = strings.TrimSpace(input) + speakers, err := fetchMinuteSpeakers(runtime, minuteToken) + if err != nil { + return "", err + } + for _, s := range speakers { + if s.SpeakerID == input { + return input, nil + } + } + return resolveSpeakerIDByName(speakers, input) +} + +func resolveSpeakerReplaceFrom(runtime *common.RuntimeContext, minuteToken string) (fromSpeakerID, fromUserID string, err error) { + fromUserID = strings.TrimSpace(runtime.Str("from-user-id")) + if fromUserID != "" { + return "", fromUserID, nil + } + + fromSpeakerID, err = resolveFromSpeakerID(runtime, minuteToken, runtime.Str("from-speaker-id")) + return fromSpeakerID, "", err +} diff --git a/shortcuts/minutes/minutes_speakers_test.go b/shortcuts/minutes/minutes_speakers_test.go new file mode 100644 index 00000000..015a362c --- /dev/null +++ b/shortcuts/minutes/minutes_speakers_test.go @@ -0,0 +1,45 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package minutes + +import ( + "errors" + "strings" + "testing" + + "github.com/larksuite/cli/errs" +) + +func TestResolveSpeakerIDByName(t *testing.T) { + speakers := []minuteSpeaker{ + {SpeakerID: "id_a", Name: "Alice"}, + {SpeakerID: "id_b", Name: "Bob"}, + {SpeakerID: "id_c", Name: "Alice"}, + } + + id, err := resolveSpeakerIDByName(speakers, "Bob") + if err != nil || id != "id_b" { + t.Fatalf("resolve Bob: id=%q err=%v", id, err) + } + + _, err = resolveSpeakerIDByName(speakers, "Carol") + if err == nil { + t.Fatal("expected not found error") + } + var ve *errs.ValidationError + if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeNotFound { + t.Fatalf("want not-found validation error, got %T: %v", err, err) + } + + _, err = resolveSpeakerIDByName(speakers, "Alice") + if err == nil { + t.Fatal("expected duplicate name error") + } + if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeFailedPrecondition { + t.Fatalf("want failed-precondition validation error, got %T: %v", err, err) + } + if !strings.Contains(ve.Hint, "id_a") || !strings.Contains(ve.Hint, "id_c") { + t.Errorf("hint should list matching speaker_ids, got: %s", ve.Hint) + } +} diff --git a/skills/lark-minutes/SKILL.md b/skills/lark-minutes/SKILL.md index ab877980..941c0958 100644 --- a/skills/lark-minutes/SKILL.md +++ b/skills/lark-minutes/SKILL.md @@ -30,7 +30,7 @@ metadata: | [`+download`](references/lark-minutes-download.md) | 下载妙记音视频媒体文件 | | [`+upload`](references/lark-minutes-upload.md) | 上传 file_token 生成妙记 | | [`+update`](references/lark-minutes-update.md) | 更新妙记标题 | -| [`+speaker-replace`](references/lark-minutes-speaker-replace.md) | 替换妙记逐字稿中的说话人(仅支持用户 ID,不支持姓名) | +| [`+speaker-replace`](references/lark-minutes-speaker-replace.md) | 替换妙记逐字稿中的说话人(须先 `lark-cli api GET .../speakerlist` 取 `speaker_id`) | - 使用任何 Shortcut 前,必须先读其对应 reference 文档。 @@ -43,7 +43,7 @@ metadata: | "下载妙记的视频/音频" | 本 skill(`+download`) | | "把音视频转妙记/上传文件生成妙记" | 本 skill(`+upload`) | | "重命名妙记/改妙记标题" | 本 skill(`+update`) | -| "替换说话人/把 A 的发言改成 B" | 本 skill(`+speaker-replace`) | +| "替换说话人/把 A 的发言改成 B/把外部说话人改成飞书用户" | 本 skill(先 `lark-cli api GET .../speakerlist`,再 `+speaker-replace`) | | "这个妙记的逐字稿/总结/待办/章节" | [lark-vc](../lark-vc/SKILL.md)(`vc +notes --minute-tokens`) | | "xx 纪要的逐字稿/原始记录/谁说了什么" 且没有 `minute_token` / 妙记 URL / 本地音视频文件 | 不走本 skill;路由到 [lark-drive](../lark-drive/SKILL.md) / [lark-doc](../lark-doc/SKILL.md),必要时再到 [lark-note](../lark-note/SKILL.md) | | "把音视频文件转成纪要/逐字稿/文字稿" | 先本 skill(`+upload`),再 [lark-vc](../lark-vc/SKILL.md)(`vc +notes --minute-tokens`) | @@ -151,6 +151,20 @@ lark-cli minutes +todo --minute-token --as user --todos '[ > 使用 `+todo` 前必须阅读 [references/lark-minutes-todo.md](references/lark-minutes-todo.md);使用 `+summary` 前必须阅读 [references/lark-minutes-summary.md](references/lark-minutes-summary.md)。 +### 7. 替换妙记逐字稿说话人 + +当用户要把妙记里某说话人的发言改绑到另一位飞书用户时使用。 + +**触发信号**:「替换说话人」「把 A 的发言改成 B」「说话人识别错了」「把外部说话人改成飞书用户」等。 + +**Agent 必读流程**(详见 [minutes +speaker-replace](references/lark-minutes-speaker-replace.md)): + +1. 确认 `minute_token`。 +2. **先**用 `lark-cli api GET "/open-apis/minutes/v1/minutes//transcript/speakerlist"` 查说话人列表(内部 HTTP,无 shortcut、无公开 OpenAPI 文档页)。 +3. 根据用户描述的原说话人展示名,在返回的 `data.speakers[]` 中匹配 `name` → 得到 `speaker_id`;同名多人时结合 `vc +notes` 逐字稿请用户确认,**不要擅自挑选**。 +4. 新说话人姓名用 [lark-contact](../lark-contact/SKILL.md) 解析为 `ou_` open_id。 +5. 调用 `minutes +speaker-replace`,**`--from-speaker-id` 只传步骤 3 的 `speaker_id`,禁止传展示名**。 + ## 资源关系 ```text @@ -178,7 +192,7 @@ Minutes (妙记) ← minute_token 标识 > - 用户说"通过文件生成妙记 / 把音视频转妙记" → 先上传获取 `file_token`,然后使用 `minutes +upload` > - 用户说"把音视频文件转成纪要 / 逐字稿 / 文字稿 / 撰写文字 / 总结 / 待办 / 章节" → 先上传获取 `file_token`,调用 `minutes +upload` 生成 `minute_url`,再提取 `minute_token` 走 `vc +notes --minute-tokens` > - 用户说"重命名妙记 / 改妙记标题 / 修改妙记名字" → `minutes +update` -> - 用户说"替换说话人 / 把 A 的发言改成 B / 重新归属发言人" → `minutes +speaker-replace` +> - 用户说"替换说话人 / 把 A 的发言改成 B / 重新归属发言人 / 把外部(非飞书)说话人改成飞书用户" → 先 `lark-cli api GET .../transcript/speakerlist` 取 `speaker_id`,再 [`minutes +speaker-replace`](references/lark-minutes-speaker-replace.md);`--from-speaker-id` 只传 id,不传展示名 > - 用户说"批量替换逐字稿关键词" → `minutes +word-replace` > > **Note 域边界(禁止规则)**:`minute_token` 是妙记文件标识,**不是** `note_id`。 diff --git a/skills/lark-minutes/references/lark-minutes-speaker-replace.md b/skills/lark-minutes/references/lark-minutes-speaker-replace.md index 12b82c63..a3e94ec7 100644 --- a/skills/lark-minutes/references/lark-minutes-speaker-replace.md +++ b/skills/lark-minutes/references/lark-minutes-speaker-replace.md @@ -2,7 +2,7 @@ > **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 -替换妙记逐字稿中的说话人身份:把妙记逐字稿里"原说话人"对应的所有发言段,重新归属到"新说话人"。常用于解决妙记自动识别错说话人,或需要手工把某段语音绑定到正确用户的场景。 +替换妙记逐字稿中的说话人身份:把妙记逐字稿里"原说话人"对应的所有发言段,重新归属到"新说话人"。常用于解决妙记自动识别错说话人,或需要把外部/非飞书说话人改绑到正确飞书用户的场景。 本 skill 对应 shortcut:`lark-cli minutes +speaker-replace`。 @@ -10,15 +10,60 @@ - "把这条妙记里 A 的发言改成 B" - "妙记说话人识别错了,帮我把张三的部分换成李四" +- "把妙记里外部说话人 / 非飞书说话人的发言改成某个飞书用户" - "妙记说话人修改 / 替换 / 重新归属" -- "改一下妙记的说话人" + +## 完整工作流 + +识别到「修改妙记说话人」需求后,**必须**按以下顺序执行;**禁止**把展示名直接传给 `--from-speaker-id`。 + +1. **确认 `minute_token`** + - 从妙记 URL、搜索或 VC 链路取得 `minute_token`。 + +2. **查说话人列表(必须先做)** + - 用 **`lark-cli api`** 直接调用内部 HTTP 接口: + ```bash + lark-cli api GET "/open-apis/minutes/v1/minutes//transcript/speakerlist" --as user + ``` + - 返回 `data.speakers[]`,每项含 `speaker_id`(不透明 id)与 `name`(逐字稿展示名)。示例: + ```json + { + "data": { + "speakers": [ + {"speaker_id": "ENCRYPTED_TOKEN_ABC", "name": "说话人1"}, + {"speaker_id": "ENCRYPTED_TOKEN_DEF", "name": "说话人2"} + ] + } + } + ``` + +3. **解析 `--from-speaker-id`** + - 根据用户描述的原说话人(展示名,如「说话人1」「张三」),在 `speakers[]` 里按 `name` **精确匹配**,取对应的 **`speaker_id`** 作为 `--from-speaker-id` 的值。 + - **`--from-speaker-id` 只传 `speaker_id`,不传展示名。** + - 若同名有多条(`name` 相同、`speaker_id` 不同):**不要擅自挑选**。可结合 [`vc +notes --minute-tokens`](../../lark-vc/references/lark-vc-notes.md) 对照各人发言内容,请用户确认后再用精确的 `speaker_id`。 + - 若列表中无匹配展示名:告知用户并核对拼写,或请用户在妙记页面确认标签。 + +4. **解析 `--to-user-id`** + - 新说话人必须是 `ou_` 开头的 open_id。用户只给姓名时,先用 [lark-contact](../../lark-contact/SKILL.md) 解析。 + +5. **执行替换** + ```bash + lark-cli minutes +speaker-replace \ + --minute-token obcnxxxxxxxxxxxxxxxxxxxx \ + --from-speaker-id ENCRYPTED_TOKEN_ABC \ + --to-user-id ou_new_speaker_open_id + ``` ## 命令示例 ```bash +# 1. 先查列表(裸调 HTTP) +lark-cli api GET "/open-apis/minutes/v1/minutes/obcnxxxxxxxxxxxxxxxxxxxx/transcript/speakerlist" --as user + +# 2. 再替换(from-speaker-id 来自上一步的 speaker_id) lark-cli minutes +speaker-replace \ --minute-token obcnxxxxxxxxxxxxxxxxxxxx \ - --from-user-id ou_old_speaker_open_id \ + --from-speaker-id ENCRYPTED_TOKEN_ABC \ --to-user-id ou_new_speaker_open_id ``` @@ -27,21 +72,33 @@ lark-cli minutes +speaker-replace \ | 参数 | 必填 | 说明 | |------|------|------| | `--minute-token ` | 是 | 妙记的唯一标识,可从妙记 URL 末尾路径提取 | -| `--from-user-id ` | 是 | 被替换的原说话人,**必须是 `ou_` 开头的 open_id**,不支持用户名 | +| `--from-speaker-id ` | 是 | 被替换的原说话人 **`speaker_id`**(来自 speakerlist API 的 `data.speakers[].speaker_id`) | | `--to-user-id ` | 是 | 新的说话人,**必须是 `ou_` 开头的 open_id**,不支持用户名 | -> **重要**:`--from-user-id` 和 `--to-user-id` 仅支持 `ou_` 开头的用户 ID,**不支持直接传姓名**。如果用户只给了姓名,请先用 [lark-contact](../../lark-contact/SKILL.md) 把姓名解析成 `open_id`,再调用本命令。 +## 核心约束 + +### 1. 必须先查 speakerlist,再替换 + +Agent 必须先 `lark-cli api GET .../speakerlist`,再 `+speaker-replace`;`--from-speaker-id` 只接受 `speaker_id`。 + +### 2. 新说话人必须是 open_id + +`--to-user-id` 仅支持 `ou_` 开头的 open_id,**不支持直接传姓名**;如果用户只给了姓名,请先用 [lark-contact](../../lark-contact/SKILL.md) 把姓名解析成 `open_id`。 + +### 3. 历史参数 + +存在一个隐藏的历史参数 `--from-user-id`(飞书说话人的 open_id),仅为向后兼容保留;新流程请一律使用 `--from-speaker-id` + `speaker_id`。 ## 认证与权限 -- 所需 scope:`minutes:minutes:update`。 +- 所需 scope:`minutes:minutes:readonly`(内部解析说话人)、`minutes:minutes:update`(执行替换)。 ## 输出结果 | 字段 | 说明 | |------|------| | `minute_token` | 被修改的妙记 Token,与输入的 `--minute-token` 一致 | -| `from_user_id` | 被替换的原说话人 open_id,与输入的 `--from-user-id` 一致;必须是妙记逐字稿中已存在的说话人 | +| `from_speaker_id` | 实际用于替换的不透明说话人标识 | | `to_user_id` | 替换后的新说话人 open_id,与输入的 `--to-user-id` 一致 | ## 参考 diff --git a/tests/cli_e2e/minutes/minutes_speaker_replace_test.go b/tests/cli_e2e/minutes/minutes_speaker_replace_test.go index 70cf3cce..95944680 100644 --- a/tests/cli_e2e/minutes/minutes_speaker_replace_test.go +++ b/tests/cli_e2e/minutes/minutes_speaker_replace_test.go @@ -38,3 +38,54 @@ func TestMinutesSpeakerReplace_DryRun(t *testing.T) { 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) } + +func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(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-speaker-id", "ENCRYPTED_TOKEN_ABC", + "--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, "from_speaker_id"), "dry-run should contain from_speaker_id, got: %s", output) + assert.True(t, strings.Contains(output, "ENCRYPTED_TOKEN_ABC"), "dry-run should contain the encrypted speaker id, got: %s", output) + assert.False(t, strings.Contains(output, "from_user_id"), "dry-run should not contain from_user_id when from-speaker-id is set, got: %s", output) +} + +func TestMinutesSpeakerReplace_DryRun_ResolveFromSpeakerID(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-speaker-id", "说话人1", + "--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, "GET"), "dry-run should contain GET for internal speaker list, got: %s", output) + assert.True(t, strings.Contains(output, "/open-apis/minutes/v1/minutes/obcnexampleminute/transcript/speakerlist"), "dry-run should contain speakerlist API path, got: %s", output) + 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 speaker replace path, got: %s", output) +} diff --git a/tests/cli_e2e/sheets/sheets_table_put_typed_workflow_test.go b/tests/cli_e2e/sheets/sheets_table_put_typed_workflow_test.go index 9bf23583..9ea6c42b 100644 --- a/tests/cli_e2e/sheets/sheets_table_put_typed_workflow_test.go +++ b/tests/cli_e2e/sheets/sheets_table_put_typed_workflow_test.go @@ -5,6 +5,7 @@ package sheets import ( "context" + "strings" "testing" "time" @@ -93,19 +94,28 @@ func TestSheets_WorkbookCreateTypedWorkflow(t *testing.T) { t.Cleanup(cancel) suffix := clie2e.GenerateSuffix() - folderToken := drive.CreateDriveFolder(t, parentT, ctx, "lark-cli-e2e-wb-create-typed-"+suffix+"-folder", "bot", "") + title := "lark-cli-e2e-wb-create-typed-" + suffix // One-shot: create workbook + write typed payload (date + int + string). + // --folder-token is optional; omit it so the test does not depend on drive:drive + // (CreateDriveFolder) when validating the typed --sheets path. createRes, err := clie2e.RunCmd(ctx, clie2e.Request{ Args: []string{ "sheets", "+workbook-create", - "--title", "lark-cli-e2e-wb-create-typed-" + suffix, - "--folder-token", folderToken, + "--title", title, "--sheets", `{"sheets":[{"name":"销售","columns":["日期","金额","渠道"],"dtypes":{"日期":"datetime64[ns]","金额":"float64","渠道":"object"},"formats":{"金额":"$#,##0.00","日期":"yyyy-mm-dd"},"data":[["2024-01-15",1500.5,"门店"],["2024-02-02",2300.75,"线上"]]}]}`, }, DefaultAs: "bot", }) require.NoError(t, err) + if createRes.ExitCode != 0 { + combined := strings.ToLower(createRes.Stdout + "\n" + createRes.Stderr) + if strings.Contains(combined, "app_scope_not_applied") || + strings.Contains(combined, "missing_scopes") || + strings.Contains(combined, "99991672") { + t.Skipf("skip workbook-create typed workflow due to missing bot scope: %s", strings.TrimSpace(createRes.Stdout+"\n"+createRes.Stderr)) + } + } createRes.AssertExitCode(t, 0) createRes.AssertStdoutStatus(t, true)