From ca1747db1dada893f061c847db946594a0023c97 Mon Sep 17 00:00:00 2001 From: "zhangjun.1" Date: Thu, 2 Jul 2026 22:56:34 +0800 Subject: [PATCH] bugfix 0702 about speaker replace --- shortcuts/minutes/minutes_speaker_replace.go | 31 ++---- .../minutes/minutes_speaker_replace_test.go | 73 +++--------- shortcuts/minutes/minutes_speakers.go | 104 ------------------ shortcuts/minutes/minutes_speakers_test.go | 45 -------- .../lark-minutes-speaker-replace.md | 2 + .../minutes/minutes_speaker_replace_test.go | 26 +---- 6 files changed, 27 insertions(+), 254 deletions(-) delete mode 100644 shortcuts/minutes/minutes_speakers.go delete 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 716230d3e..a92676f10 100644 --- a/shortcuts/minutes/minutes_speaker_replace.go +++ b/shortcuts/minutes/minutes_speaker_replace.go @@ -66,31 +66,24 @@ var MinutesSpeakerReplace = common.Shortcut{ }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { minuteToken := strings.TrimSpace(runtime.Str("minute-token")) - 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))). + return common.NewDryRunAPI(). + 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")) - fromSpeakerInput := strings.TrimSpace(runtime.Str("from-speaker-id")) + fromSpeakerID := strings.TrimSpace(runtime.Str("from-speaker-id")) + fromUserID := strings.TrimSpace(runtime.Str("from-user-id")) toUserID := strings.TrimSpace(runtime.Str("to-user-id")) - fromSpeakerID, fromUserID, err := resolveSpeakerReplaceFrom(runtime, minuteToken) - if err != nil { - return err - } - - _, err = runtime.CallAPITyped(http.MethodPut, + _, 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)) + return minutesSpeakerReplaceError(err, minuteToken, speakerReplaceSourceLabel(fromSpeakerID, fromUserID)) } - runtime.OutFormat(buildSpeakerReplaceOutputData(fromSpeakerInput, minuteToken, fromSpeakerID, fromUserID, toUserID), nil, nil) + runtime.OutFormat(buildSpeakerReplaceOutputData(minuteToken, fromSpeakerID, fromUserID, toUserID), nil, nil) return nil }, } @@ -114,26 +107,20 @@ func buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID return body } -func buildSpeakerReplaceOutputData(fromSpeakerInput, minuteToken, fromSpeakerID, fromUserID, toUserID string) map[string]interface{} { +func buildSpeakerReplaceOutputData(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 - } +func speakerReplaceSourceLabel(fromSpeakerID, fromUserID string) string { if fromSpeakerID != "" { return fromSpeakerID } diff --git a/shortcuts/minutes/minutes_speaker_replace_test.go b/shortcuts/minutes/minutes_speaker_replace_test.go index 5b028a249..193331f55 100644 --- a/shortcuts/minutes/minutes_speaker_replace_test.go +++ b/shortcuts/minutes/minutes_speaker_replace_test.go @@ -153,58 +153,14 @@ 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) { +func TestMinutesSpeakerReplace_Execute_OpaqueSpeakerIDNoPrefetch(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", - }, - }, - }, - }, - }) + // Only the PUT is registered on purpose: an opaque speaker_id must be passed + // straight through without a second speakerlist call. If the code still + // prefetched speakerlist, the unregistered GET would fail the request. reg.Register(&httpmock.Stub{ Method: http.MethodPut, URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speaker", @@ -218,7 +174,7 @@ func TestMinutesSpeakerReplace_Execute_ResolveFromSpeakerID(t *testing.T) { err := mountAndRun(t, MinutesSpeakerReplace, []string{ "+speaker-replace", "--minute-token", minutesSpeakerReplaceTestToken, - "--from-speaker-id", "说话人1", + "--from-speaker-id", "ENCRYPTED_TOKEN_ABC", "--to-user-id", "ou_new_speaker", "--format", "json", "--as", "user", }, f, stdout) @@ -228,21 +184,19 @@ func TestMinutesSpeakerReplace_Execute_ResolveFromSpeakerID(t *testing.T) { 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"` + 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) } + if envelope.Data.ToUserID != "ou_new_speaker" { + t.Errorf("data.to_user_id = %q, want ou_new_speaker", envelope.Data.ToUserID) + } } func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) { @@ -262,8 +216,11 @@ func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) { } 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("opaque speaker_id should not prefetch speakerlist, got:\n%s", out) + } + if !strings.Contains(out, "PUT") { + t.Errorf("expected PUT for speaker replace, 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) diff --git a/shortcuts/minutes/minutes_speakers.go b/shortcuts/minutes/minutes_speakers.go deleted file mode 100644 index b9649f6d1..000000000 --- a/shortcuts/minutes/minutes_speakers.go +++ /dev/null @@ -1,104 +0,0 @@ -// 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 deleted file mode 100644 index 015a362c1..000000000 --- a/shortcuts/minutes/minutes_speakers_test.go +++ /dev/null @@ -1,45 +0,0 @@ -// 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/references/lark-minutes-speaker-replace.md b/skills/lark-minutes/references/lark-minutes-speaker-replace.md index a3e94ec78..9cf982dc0 100644 --- a/skills/lark-minutes/references/lark-minutes-speaker-replace.md +++ b/skills/lark-minutes/references/lark-minutes-speaker-replace.md @@ -81,6 +81,8 @@ lark-cli minutes +speaker-replace \ Agent 必须先 `lark-cli api GET .../speakerlist`,再 `+speaker-replace`;`--from-speaker-id` 只接受 `speaker_id`。 +`+speaker-replace` **不会**自己请求 speakerlist:`--from-speaker-id` 的值会原样发给替换接口。整条链路只在 Agent 一开始查一次 speakerlist,务必传入上一步拿到的 `speaker_id`(不要传展示名,否则替换接口会返回 speaker-not-found)。 + ### 2. 新说话人必须是 open_id `--to-user-id` 仅支持 `ou_` 开头的 open_id,**不支持直接传姓名**;如果用户只给了姓名,请先用 [lark-contact](../../lark-contact/SKILL.md) 把姓名解析成 `open_id`。 diff --git a/tests/cli_e2e/minutes/minutes_speaker_replace_test.go b/tests/cli_e2e/minutes/minutes_speaker_replace_test.go index 959446804..6df57658e 100644 --- a/tests/cli_e2e/minutes/minutes_speaker_replace_test.go +++ b/tests/cli_e2e/minutes/minutes_speaker_replace_test.go @@ -63,29 +63,5 @@ func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) { 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) + assert.False(t, strings.Contains(output, "/transcript/speakerlist"), "+speaker-replace should never fetch speakerlist, got: %s", output) }