From 6398a9ea9d88a546334fd835285f4e5a9e73994e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E6=B8=AF?= Date: Fri, 29 May 2026 16:19:34 +0800 Subject: [PATCH] fix: add vc-domain-boundaries and enrich vc +notes --- shortcuts/vc/vc_notes.go | 161 +++++++- shortcuts/vc/vc_notes_test.go | 349 ++++++++++++++++++ shortcuts/vc/vc_search.go | 2 +- skills/lark-minutes/SKILL.md | 8 + skills/lark-vc/SKILL.md | 27 +- skills/lark-vc/references/lark-vc-notes.md | 8 +- .../references/vc-domain-boundaries.md | 159 ++++++++ skills/lark-workflow-meeting-summary/SKILL.md | 6 + 8 files changed, 697 insertions(+), 23 deletions(-) create mode 100644 skills/lark-vc/references/vc-domain-boundaries.md diff --git a/shortcuts/vc/vc_notes.go b/shortcuts/vc/vc_notes.go index 4b03a7e7d..9f799e445 100644 --- a/shortcuts/vc/vc_notes.go +++ b/shortcuts/vc/vc_notes.go @@ -37,6 +37,8 @@ import ( var ( scopesMeetingIDs = []string{ "vc:meeting.meetingevent:read", + "vc:note:read", + "vc:record:readonly", } scopesMinuteTokens = []string{ "minutes:minutes:readonly", @@ -47,6 +49,7 @@ var ( "calendar:calendar:read", "calendar:calendar.event:read", "vc:meeting.meetingevent:read", + "vc:record:readonly", } ) @@ -58,6 +61,37 @@ const ( const logPrefix = "[vc +notes]" +const ( + minutesNoReadPermissionCode = 2091005 + + // recording API specific error codes (used to surface meeting minute_token state). + recordingNotFoundCode = 121004 // 该会议没有妙记文件 + recordingNoPermissionCode = 121005 // 非会议参与者无权查看 + recordingGeneratingCode = 124002 // 录制/妙记文件仍在生成中 + + // note detail API specific error code. + noteNoPermissionCode = 121005 // 调用者没有该纪要的阅读权限 +) + +func minutesReadError(err error, minuteToken string) error { + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Code != minutesNoReadPermissionCode { + return err + } + + return &output.ExitError{ + Code: output.ExitAPI, + Detail: &output.ErrDetail{ + Type: "no_read_permission", + Code: minutesNoReadPermissionCode, + Message: fmt.Sprintf("No read permission for minute %s: cannot query the minute.", minuteToken), + Hint: "Ask the minute owner for minute file read permission", + Detail: exitErr.Detail.Detail, + }, + Err: err, + } +} + // validMinuteToken matches the server's minute-token format and blocks any // user-supplied token from reaching filesystem paths unsanitized. var validMinuteToken = regexp.MustCompile(`^[a-z0-9]+$`) @@ -195,7 +229,10 @@ func fetchNoteByCalendarEventID(ctx context.Context, runtime *common.RuntimeCont for _, meetingID := range relInfo.MeetingIDs { fmt.Fprintf(errOut, "%s event %s → meeting_id=%s\n", logPrefix, sanitizeLogValue(instanceID), sanitizeLogValue(meetingID)) noteResult := fetchNoteByMeetingID(ctx, runtime, meetingID) - if noteResult["error"] == nil { + // success means note detail was retrieved, regardless of whether the + // recording API (minute_token) call succeeded — minute_token failures + // surface as part of the merged `error` string for downstream visibility. + if _, ok := noteResult["note_doc_token"].(string); ok { for k, v := range noteResult { result[k] = v } @@ -245,7 +282,51 @@ func asStringSlice(v any) []string { return ss } -// fetchNoteByMeetingID queries notes via meeting_id. +// fetchMeetingMinuteToken queries the recording API of a meeting and returns +// the associated minute_token (parsed from the recording URL) and an +// optional human-friendly error message. On success token is non-empty and +// errMsg is empty; on failure token is empty and errMsg describes the cause: +// - 121004: meeting has no minute file +// - 121005: caller has no permission for the meeting recording +// - 124002: recording / minute file is still being generated +// +// Other failures fall back to the raw API error description so Agents can +// still parse the underlying cause. +func fetchMeetingMinuteToken(runtime *common.RuntimeContext, meetingID string) (token, errMsg string) { + data, err := runtime.DoAPIJSON(http.MethodGet, + fmt.Sprintf("/open-apis/vc/v1/meetings/%s/recording", validate.EncodePathSegment(meetingID)), + nil, nil) + if err != nil { + var exitErr *output.ExitError + if errors.As(err, &exitErr) && exitErr.Detail != nil { + switch exitErr.Detail.Code { + case recordingNotFoundCode: + return "", "no minute file for this meeting" + case recordingNoPermissionCode: + return "", "no permission to access this meeting's minute; ask the meeting owner to share the minute" + case recordingGeneratingCode: + return "", "minute file is still being generated; please retry later" + } + } + return "", fmt.Sprintf("failed to query recording: %v", err) + } + + recording, _ := data["recording"].(map[string]any) + if recording == nil { + return "", "no recording available for this meeting" + } + recordingURL, _ := recording["url"].(string) + if t := extractMinuteToken(recordingURL); t != "" { + return t, "" + } + return "", "no minute_token found in recording URL" +} + +// fetchNoteByMeetingID queries notes via meeting_id and additionally fetches +// the meeting's minute_token via the recording API. The two paths are queried +// independently; their failures are merged into a single `error` field +// (semicolon-separated) so Agents always see all causes at once. The +// `minute_token` field is only populated on success. func fetchNoteByMeetingID(ctx context.Context, runtime *common.RuntimeContext, meetingID string) map[string]any { data, err := runtime.DoAPIJSON(http.MethodGet, fmt.Sprintf("/open-apis/vc/v1/meetings/%s", validate.EncodePathSegment(meetingID)), larkcore.QueryParams{"with_participants": []string{"false"}, "query_mode": []string{"0"}}, nil) @@ -258,16 +339,60 @@ func fetchNoteByMeetingID(ctx context.Context, runtime *common.RuntimeContext, m return map[string]any{"meeting_id": meetingID, "error": "meeting not found"} } - noteID, _ := meeting["note_id"].(string) - if noteID == "" { - return map[string]any{"meeting_id": meetingID, "error": "no notes available for this meeting"} + // Always attempt to query the meeting's minute_token via the recording API, + // regardless of whether the meeting has a note_id, so callers always see + // minute state for follow-up calls (e.g. `vc +notes --minute-tokens=...`). + minuteToken, minuteErr := fetchMeetingMinuteToken(runtime, meetingID) + + var result map[string]any + var noteErr string + if noteID, _ := meeting["note_id"].(string); noteID != "" { + result = fetchNoteDetail(ctx, runtime, noteID) + if msg, _ := result["error"].(string); msg != "" { + noteErr = msg + delete(result, "error") + } + } else { + result = map[string]any{} + noteErr = "no notes available for this meeting" } - result := fetchNoteDetail(ctx, runtime, noteID) result["meeting_id"] = meetingID + if minuteToken != "" { + result["minute_token"] = minuteToken + } + if combined := joinErrors(noteErr, minuteErr); combined != "" { + result["error"] = combined + } return result } +// joinErrors merges multiple non-empty error messages with "; " so Agents can +// see all causes at once when both note and minute paths fail. +func joinErrors(msgs ...string) string { + parts := make([]string, 0, len(msgs)) + for _, m := range msgs { + if m != "" { + parts = append(parts, m) + } + } + return strings.Join(parts, "; ") +} + +// hasNotesPayload reports whether a result map carries any usable note or +// minute payload, irrespective of partial failures surfaced via `error`. +func hasNotesPayload(m map[string]any) bool { + if m == nil { + return false + } + for _, k := range []string{"note_doc_token", "verbatim_doc_token", "minute_token", "meeting_notes", "shared_doc_tokens", "artifacts"} { + if v, ok := m[k]; ok && v != nil && v != "" { + return true + } + } + return false +} + // fetchNoteByMinuteToken queries notes via minute_token. // Fetches both note detail (doc tokens) and AI artifacts (summary/todos/chapters inline + // transcript to file) independently, merging into a single result map for Agent consumption. @@ -276,7 +401,13 @@ func fetchNoteByMinuteToken(ctx context.Context, runtime *common.RuntimeContext, data, err := runtime.DoAPIJSON(http.MethodGet, fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment(minuteToken)), nil, nil) if err != nil { - return map[string]any{"minute_token": minuteToken, "error": fmt.Sprintf("failed to query minutes: %v", err)} + err = minutesReadError(err, minuteToken) + result := map[string]any{"minute_token": minuteToken, "error": err.Error()} + var exitErr *output.ExitError + if errors.As(err, &exitErr) && exitErr.Detail != nil && exitErr.Detail.Hint != "" { + result["hint"] = exitErr.Detail.Hint + } + return result } minute, _ := data["minute"].(map[string]any) @@ -471,6 +602,10 @@ func extractDocTokens(refs []any) []string { func fetchNoteDetail(_ context.Context, runtime *common.RuntimeContext, noteID string) map[string]any { data, err := runtime.DoAPIJSON(http.MethodGet, fmt.Sprintf("/open-apis/vc/v1/notes/%s", validate.EncodePathSegment(noteID)), nil, nil) if err != nil { + var exitErr *output.ExitError + if errors.As(err, &exitErr) && exitErr.Detail != nil && exitErr.Detail.Code == noteNoPermissionCode { + return map[string]any{"error": fmt.Sprintf("[%v]: no read permission for this meeting note", exitErr.Detail.Code)} + } return map[string]any{"error": fmt.Sprintf("failed to query note detail: %v", err)} } @@ -565,8 +700,9 @@ var VCNotes = common.Shortcut{ return common.NewDryRunAPI(). GET("/open-apis/vc/v1/meetings/{meeting_id}"). GET("/open-apis/vc/v1/notes/{note_id}"). + GET("/open-apis/vc/v1/meetings/{meeting_id}/recording"). Set("meeting_ids", common.SplitCSV(ids)). - Set("steps", "meeting.get → note_id → note detail API") + Set("steps", "meeting.get → note_id → note detail API + recording API → minute_token") } if tokens := runtime.Str("minute-tokens"); tokens != "" { return common.NewDryRunAPI(). @@ -583,8 +719,9 @@ var VCNotes = common.Shortcut{ POST("/open-apis/calendar/v4/calendars/{calendar_id}/events/mget_instance_relation_info"). GET("/open-apis/vc/v1/meetings/{meeting_id}"). GET("/open-apis/vc/v1/notes/{note_id}"). + GET("/open-apis/vc/v1/meetings/{meeting_id}/recording"). Set("calendar_event_ids", common.SplitCSV(ids)). - Set("steps", "primary calendar → mget_instance_relation_info → meeting_id → meeting.get → note detail API") + Set("steps", "primary calendar → mget_instance_relation_info → meeting_id → meeting.get → note detail API + recording API → minute_token") }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { errOut := runtime.IO().ErrOut @@ -638,11 +775,13 @@ var VCNotes = common.Shortcut{ } } - // count results + // count results: a result counts as "successful" when it carries any + // note/minute payload, even if the merged `error` field surfaces a + // partial failure (e.g. note ok but minute_token lookup failed). successCount := 0 for _, r := range results { m, _ := r.(map[string]any) - if m["error"] == nil { + if hasNotesPayload(m) { successCount++ } } diff --git a/shortcuts/vc/vc_notes_test.go b/shortcuts/vc/vc_notes_test.go index 5757c515e..fdebf437e 100644 --- a/shortcuts/vc/vc_notes_test.go +++ b/shortcuts/vc/vc_notes_test.go @@ -728,3 +728,352 @@ func TestNotes_TranscriptExplicitOutputDir_PreservesLegacyLayout(t *testing.T) { t.Errorf("minutes/ should not be created when --output-dir is explicit") } } + +// --------------------------------------------------------------------------- +// Tests for joinErrors / hasNotesPayload (pure helpers) +// --------------------------------------------------------------------------- + +func TestJoinErrors(t *testing.T) { + tests := []struct { + name string + in []string + want string + }{ + {"all empty", []string{"", "", ""}, ""}, + {"single", []string{"only"}, "only"}, + {"two non-empty", []string{"a", "b"}, "a; b"}, + {"skip empties", []string{"", "a", "", "b", ""}, "a; b"}, + {"three", []string{"x", "y", "z"}, "x; y; z"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := joinErrors(tt.in...); got != tt.want { + t.Errorf("joinErrors(%v) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +func TestHasNotesPayload(t *testing.T) { + tests := []struct { + name string + in map[string]any + want bool + }{ + {"nil", nil, false}, + {"empty", map[string]any{}, false}, + {"only meta", map[string]any{"meeting_id": "m1", "error": "fail"}, false}, + {"empty values", map[string]any{"note_doc_token": "", "minute_token": ""}, false}, + {"has note_doc_token", map[string]any{"note_doc_token": "doc1"}, true}, + {"has verbatim_doc_token", map[string]any{"verbatim_doc_token": "v1"}, true}, + {"has minute_token", map[string]any{"minute_token": "obc"}, true}, + {"has meeting_notes", map[string]any{"meeting_notes": []string{"d1"}}, true}, + {"has shared_doc_tokens", map[string]any{"shared_doc_tokens": []string{"s1"}}, true}, + {"has artifacts", map[string]any{"artifacts": map[string]any{"summary": "s"}}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := hasNotesPayload(tt.in); got != tt.want { + t.Errorf("hasNotesPayload(%v) = %v, want %v", tt.in, got, tt.want) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Tests for fetchMeetingMinuteToken — recording API → minute_token mapping +// --------------------------------------------------------------------------- + +// recordingStub is a small helper for shaping `/v1/meetings/{id}/recording` responses. +func recordingStub(meetingID string, body map[string]any) *httpmock.Stub { + return &httpmock.Stub{ + Method: "GET", + URL: "/open-apis/vc/v1/meetings/" + meetingID + "/recording", + Body: body, + } +} + +func recordingErrStub(meetingID string, code int, msg string) *httpmock.Stub { + return recordingStub(meetingID, map[string]any{"code": code, "msg": msg}) +} + +func recordingOKStub(meetingID, url string) *httpmock.Stub { + return recordingStub(meetingID, map[string]any{ + "code": 0, "msg": "ok", + "data": map[string]any{ + "recording": map[string]any{"url": url}, + }, + }) +} + +func TestFetchMeetingMinuteToken_Success(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(recordingOKStub("m_ok", "https://meetings.feishu.cn/minutes/obctoken_ok")) + + if err := botExec(t, "fmmt-ok", f, func(_ context.Context, rctx *common.RuntimeContext) error { + token, msg := fetchMeetingMinuteToken(rctx, "m_ok") + if token != "obctoken_ok" { + t.Errorf("token = %q, want obctoken_ok", token) + } + if msg != "" { + t.Errorf("errMsg = %q, want empty", msg) + } + return nil + }); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestFetchMeetingMinuteToken_KnownErrorCodes(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + cases := []struct { + name string + meetingID string + code int + wantMsg string + }{ + {"121004 not found", "m_121004", 121004, "no minute file for this meeting"}, + {"121005 no permission", "m_121005", 121005, "no permission to access this meeting's minute"}, + {"124002 generating", "m_124002", 124002, "minute file is still being generated"}, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(recordingErrStub(tt.meetingID, tt.code, "err")) + + if err := botExec(t, "fmmt-"+tt.meetingID, f, func(_ context.Context, rctx *common.RuntimeContext) error { + token, msg := fetchMeetingMinuteToken(rctx, tt.meetingID) + if token != "" { + t.Errorf("token = %q, want empty on error", token) + } + if !strings.Contains(msg, tt.wantMsg) { + t.Errorf("errMsg = %q, want contains %q", msg, tt.wantMsg) + } + return nil + }); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func TestFetchMeetingMinuteToken_GenericAPIError(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(recordingErrStub("m_other", 99999, "weird")) + + if err := botExec(t, "fmmt-generic", f, func(_ context.Context, rctx *common.RuntimeContext) error { + token, msg := fetchMeetingMinuteToken(rctx, "m_other") + if token != "" { + t.Errorf("token = %q, want empty", token) + } + if !strings.Contains(msg, "failed to query recording") { + t.Errorf("errMsg = %q, want contains 'failed to query recording'", msg) + } + return nil + }); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestFetchMeetingMinuteToken_NoRecording(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(recordingStub("m_norec", map[string]any{ + "code": 0, "msg": "ok", + "data": map[string]any{}, + })) + + if err := botExec(t, "fmmt-norec", f, func(_ context.Context, rctx *common.RuntimeContext) error { + token, msg := fetchMeetingMinuteToken(rctx, "m_norec") + if token != "" { + t.Errorf("token = %q, want empty", token) + } + if !strings.Contains(msg, "no recording available") { + t.Errorf("errMsg = %q, want contains 'no recording available'", msg) + } + return nil + }); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestFetchMeetingMinuteToken_URLWithoutToken(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(recordingOKStub("m_notok", "https://example.com/no/minute/path")) + + if err := botExec(t, "fmmt-notok", f, func(_ context.Context, rctx *common.RuntimeContext) error { + token, msg := fetchMeetingMinuteToken(rctx, "m_notok") + if token != "" { + t.Errorf("token = %q, want empty", token) + } + if !strings.Contains(msg, "no minute_token found") { + t.Errorf("errMsg = %q, want contains 'no minute_token found'", msg) + } + return nil + }); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// --------------------------------------------------------------------------- +// Integration: fetchNoteByMeetingID — note + minute_token combined behavior +// --------------------------------------------------------------------------- + +// extractFirstNote runs +notes via --meeting-ids and returns the single result map. +func extractFirstNote(t *testing.T, stdout *bytes.Buffer) map[string]any { + t.Helper() + var resp map[string]any + if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse output: %v\n%s", err, stdout.String()) + } + data, _ := resp["data"].(map[string]any) + notes, _ := data["notes"].([]any) + if len(notes) != 1 { + t.Fatalf("expected 1 note, got %d (%v)", len(notes), notes) + } + note, _ := notes[0].(map[string]any) + return note +} + +// assertNoteError verifies the result map's `error` field contains every +// substring in wantSubstrs (order-independent). Pass an empty slice to assert +// the field is absent. Centralized here so tests don't have to repeat the same +// "for each substring, Contains + Errorf" pattern. +func assertNoteError(t *testing.T, note map[string]any, wantSubstrs ...string) { + t.Helper() + errMsg, _ := note["error"].(string) + if len(wantSubstrs) == 0 { + if e, has := note["error"]; has { + t.Errorf("error should be absent, got %v", e) + } + return + } + for _, sub := range wantSubstrs { + if !strings.Contains(errMsg, sub) { + t.Errorf("error %q missing substring %q", errMsg, sub) + } + } +} + +// assertNoteFieldAbsent fails the test if any of the named fields is present. +func assertNoteFieldAbsent(t *testing.T, note map[string]any, fields ...string) { + t.Helper() + for _, f := range fields { + if v, has := note[f]; has { + t.Errorf("%s should be absent, got %v", f, v) + } + } +} + +func TestNotes_MeetingPath_NoteAndMinuteBothOK(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(meetingGetStub("m_both", "note_both")) + reg.Register(noteDetailStub("note_both")) + reg.Register(recordingOKStub("m_both", "https://meetings.feishu.cn/minutes/obc_both")) + + if err := mountAndRun(t, VCNotes, []string{"+notes", "--meeting-ids", "m_both", "--as", "user"}, f, stdout); err != nil { + t.Fatalf("unexpected error: %v", err) + } + note := extractFirstNote(t, stdout) + if got := note["note_doc_token"]; got != "doc_main" { + t.Errorf("note_doc_token = %v, want doc_main", got) + } + if got := note["minute_token"]; got != "obc_both" { + t.Errorf("minute_token = %v, want obc_both", got) + } + assertNoteError(t, note) +} + +func TestNotes_MeetingPath_OnlyMinuteFails_PartialSuccess(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(meetingGetStub("m_minfail", "note_minfail")) + reg.Register(noteDetailStub("note_minfail")) + reg.Register(recordingErrStub("m_minfail", 121005, "no permission")) + + if err := mountAndRun(t, VCNotes, []string{"+notes", "--meeting-ids", "m_minfail", "--as", "user"}, f, stdout); err != nil { + t.Fatalf("unexpected error: %v", err) + } + note := extractFirstNote(t, stdout) + if got := note["note_doc_token"]; got != "doc_main" { + t.Errorf("note_doc_token = %v, want doc_main", got) + } + assertNoteFieldAbsent(t, note, "minute_token") + assertNoteError(t, note, "no permission to access this meeting's minute") +} + +func TestNotes_MeetingPath_NoNote_ButMinuteOK(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + // note_id missing on the meeting object → no notes, but minute_token present + reg.Register(meetingGetStub("m_nonote", "")) + reg.Register(recordingOKStub("m_nonote", "https://meetings.feishu.cn/minutes/obc_nonote")) + + if err := mountAndRun(t, VCNotes, []string{"+notes", "--meeting-ids", "m_nonote", "--as", "user"}, f, stdout); err != nil { + t.Fatalf("unexpected error: %v", err) + } + note := extractFirstNote(t, stdout) + if got := note["minute_token"]; got != "obc_nonote" { + t.Errorf("minute_token = %v, want obc_nonote", got) + } + assertNoteError(t, note, "no notes available for this meeting") +} + +func TestNotes_MeetingPath_BothFail_ErrorJoinedWithSemicolon(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + // no note_id → "no notes available..."; recording 121004 → "no minute file..." + reg.Register(meetingGetStub("m_bothfail", "")) + reg.Register(recordingErrStub("m_bothfail", 121004, "data not found")) + + // Two-path failure with no payload should make the batch return ErrAPI. + err := mountAndRun(t, VCNotes, []string{"+notes", "--meeting-ids", "m_bothfail", "--as", "user"}, f, stdout) + if err == nil { + t.Fatalf("expected batch failure error, got nil") + } + + note := extractFirstNote(t, stdout) + assertNoteFieldAbsent(t, note, "minute_token") + assertNoteError(t, note, + "no notes available for this meeting", + "no minute file for this meeting", + "; ", // causes joined with semicolon + ) +} + +// noteDetailErrStub returns a stub that emits an error response from +// /open-apis/vc/v1/notes/{note_id}. +func noteDetailErrStub(noteID string, code int, msg string) *httpmock.Stub { + return &httpmock.Stub{ + Method: "GET", + URL: "/open-apis/vc/v1/notes/" + noteID, + Body: map[string]any{"code": code, "msg": msg}, + } +} + +func TestNotes_MeetingPath_NoteNoPermission_FriendlyHint(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + // note 接口返回 121005 → 阅读权限不足;同时 recording 也返回 121005, + // 用以验证两路错误都会被合并到顶层 error 字段(用 "; " 拼接)。 + reg.Register(meetingGetStub("m_noteperm", "note_noperm")) + reg.Register(noteDetailErrStub("note_noperm", 121005, "no permission")) + reg.Register(recordingErrStub("m_noteperm", 121005, "no permission")) + + err := mountAndRun(t, VCNotes, []string{"+notes", "--meeting-ids", "m_noteperm", "--as", "user"}, f, stdout) + if err == nil { + t.Fatalf("expected batch failure error, got nil") + } + + note := extractFirstNote(t, stdout) + assertNoteFieldAbsent(t, note, "note_doc_token", "minute_token") + assertNoteError(t, note, + "[121005]", + "no read permission for this meeting note", + "; ", // note + minute causes joined with semicolon + ) +} diff --git a/shortcuts/vc/vc_search.go b/shortcuts/vc/vc_search.go index adf434639..3c5d5579b 100644 --- a/shortcuts/vc/vc_search.go +++ b/shortcuts/vc/vc_search.go @@ -172,7 +172,7 @@ func meetingSearchDescription(item map[string]interface{}) string { var VCSearch = common.Shortcut{ Service: "vc", Command: "+search", - Description: "Search meeting records (requires at least one filter)", + Description: "Search meeting records by keyword, time range, participant, organizer, or meeting room (requires at least one filter)", Risk: "read", Scopes: []string{"vc:meeting.search:read"}, AuthTypes: []string{"user"}, diff --git a/skills/lark-minutes/SKILL.md b/skills/lark-minutes/SKILL.md index c10cbe47b..f8dbe40ea 100644 --- a/skills/lark-minutes/SKILL.md +++ b/skills/lark-minutes/SKILL.md @@ -12,6 +12,12 @@ metadata: **CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理** +**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-vc/references/vc-domain-boundaries.md`](../lark-vc/references/vc-domain-boundaries.md)**,不读将导致命令使用、会议产物决策、领域边界职责判断错误: +> 1. 了解日历 & VC、会议产物 & 文档的关联关系和职责划分 +> 2. 了解会议产物(妙记和纪要)之间的关联关系,例如:**妙记和纪要产生条件相互独立** +> 3. 了解不同会议产物的组成部分,以便根据需求决策使用哪种产物的数据 +> 4. 了解会议总结、分析和信息提取的标准流程 + ## 核心概念 - **妙记(Minutes)**:来源于飞书视频会议的录制产物或用户上传的音视频文件,通过 `minute_token` 标识。 @@ -134,6 +140,8 @@ lark-cli minutes [flags] # 调用 API - `get` — 获取妙记信息 +> **权限错误**:如果返回 `[2091005] permission deny`,表示用户没有对应妙记文件的阅读权限,需提示用户联系妙记 owner 申请权限。 + ## 权限表 | 方法 | 所需 scope | diff --git a/skills/lark-vc/SKILL.md b/skills/lark-vc/SKILL.md index e471b16bc..4318cf418 100644 --- a/skills/lark-vc/SKILL.md +++ b/skills/lark-vc/SKILL.md @@ -12,11 +12,17 @@ metadata: **CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理** +**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`references/vc-domain-boundaries.md`](references/vc-domain-boundaries.md)**,不读将导致命令使用、会议产物决策、领域边界职责判断错误: +> 1. 了解日历 & VC、会议产物 & 文档的关联关系和职责划分 +> 2. 了解会议产物(妙记和纪要)之间的关联关系,例如:**妙记和纪要产生条件相互独立** +> 3. 了解不同会议产物的组成部分,以便根据需求决策使用哪种产物的数据 +> 4. 了解会议总结、分析和信息提取的标准流程 + ## 核心概念 -- **视频会议(Meeting)**:飞书视频会议实例,通过 meeting\_id 标识。已结束的会议支持通过关键词、时间段、参会人、组织者、会议室等条件搜索(见 `+search`)。 -- **会议纪要(Note)**:视频会议结束后生成的结构化文档,包含纪要文档(包含总结、待办、章节)和逐字稿文档。 -- **妙记(Minutes)**:来源于飞书视频会议的录制产物或用户上传的音视频文件,支持视频/音频的转写和会议纪要,通过 minute\_token 标识。 +- **视频会议(Meeting)**:飞书视频会议实例,通过 meeting_id 标识。已结束的会议支持通过关键词、时间段、参会人、组织者、会议室等条件搜索(见 `+search`)。 +- **会议纪要(Note)**:视频会议结束后生成的结构化文档,包含纪要文档(包含总结、待办)和逐字稿文档。 +- **妙记(Minutes)**:来源于飞书视频会议的录制产物或用户上传的音视频文件,支持视频/音频的转写,包含总结、待办、章节和文字记录,通过 minute_token 标识。 - **纪要文档(MainDoc)**:AI 智能纪要的主文档,包含 AI 生成的总结和待办,对应 `note_doc_token`。 - **用户会议纪要(MeetingNotes)**:用户主动绑定到会议的纪要文档,对应 `meeting_notes`。仅通过 `--calendar-event-ids` 路径返回。 - **逐字稿(VerbatimDoc)**:会议的逐句文字记录,包含说话人和时间戳。 @@ -29,8 +35,11 @@ metadata: 3. 搜索结果存在多条数据时,务必注意分页数据获取,不要遗漏任何会议记录。 ### 2. 整理会议纪要 -1. 整理纪要文档时默认给出纪要文档和逐字稿链接即可,无需读取纪要文档或逐字稿内容。 -2. 用户明确需要获取纪要文档中的总结、待办、章节产物时,再读取文档获取具体内容。 + +> ⚠️ 在选择读取哪个产物前,请先确认你理解 AI 总结链路 vs 录制链路的区别。如不确定,先读 [`references/vc-domain-boundaries.md`](references/vc-domain-boundaries.md) 的「两条链路的独立性」章节。 + +1. 整理纪要文档时默认给出纪要文档、逐字稿、妙记链接即可,无需读取纪要文档或逐字稿内容。 +2. 用户明确需要获取总结、待办、章节产物时,再读取文档获取具体内容。 3. 读取智能纪要(`note_doc_token`)内容时,纪要文档的**第一个 ``** 标签是封面图(AI 生成的总结可视化),应同时下载展示给用户: ```bash # 1. 读取纪要内容 @@ -43,7 +52,7 @@ lark-cli docs +media-download --type whiteboard --token --out > **产物目录规范**:同一会议的所有下载产物(录像、逐字稿、封面图等)统一放到 `./minutes/{minute_token}/` 目录下。这与 `minutes +download` 和 `vc +notes --minute-tokens` 的默认落点保持一致,便于 Agent 聚合。显式路径(如封面图)需手动对齐到同一目录。 > **纪要相关文档 — 根据用户意图选择:** -> - `note_doc_token` → **AI 智能纪要**(AI 总结 + 待办 + 章节) +> - `note_doc_token` → **AI 智能纪要**(AI 总结 + 待办) > - `meeting_notes` → **用户绑定的会议纪要**(用户主动关联到会议的文档,仅 `--calendar-event-ids` 路径返回) > - `verbatim_doc_token` → **逐字稿**(完整的逐句文字记录,含说话人和时间戳)— 用户说"逐字稿""完整记录""谁说了什么"时用这个 > - 用户说"纪要""总结""纪要内容"时,应同时返回 `note_doc_token` 和 `meeting_notes`(如有) @@ -119,7 +128,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli vc + [flags]`)。 | Shortcut | 说明 | |----------|------| | [`+search`](references/lark-vc-search.md) | Search meeting records (requires at least one filter) | -| [`+notes`](references/lark-vc-notes.md) | Query meeting notes (via meeting-ids, minute-tokens, or calendar-event-ids) | +| [`+notes`](references/lark-vc-notes.md) | Query meeting notes and minutes (via meeting-ids, minute-tokens, or calendar-event-ids) | | [`+recording`](references/lark-vc-recording.md) | Query minute_token from meeting-ids or calendar-event-ids | - 使用 `+search` 命令时,必须阅读 [references/lark-vc-search.md](references/lark-vc-search.md),了解搜索参数和返回值结构。 @@ -158,9 +167,9 @@ lark-cli vc meeting get --params '{"meeting_id": "", "with_participa | 方法 | 所需 scope | |------|-----------| -| `+notes --meeting-ids` | `vc:meeting.meetingevent:read`、`vc:note:read` | +| `+notes --meeting-ids` | `vc:meeting.meetingevent:read`、`vc:note:read`、 `vc:record:readonly` | | `+notes --minute-tokens` | `vc:note:read`、`minutes:minutes:readonly`、`minutes:minutes.artifacts:read`、`minutes:minutes.transcript:export` | -| `+notes --calendar-event-ids` | `calendar:calendar:read`、`calendar:calendar.event:read`、`vc:meeting.meetingevent:read`、`vc:note:read` | +| `+notes --calendar-event-ids` | `calendar:calendar:read`、`calendar:calendar.event:read`、`vc:meeting.meetingevent:read`、`vc:note:read`、 `vc:record:readonly` | | `+recording --meeting-ids` | `vc:record:readonly` | | `+recording --calendar-event-ids` | `vc:record:readonly`、`calendar:calendar:read`、`calendar:calendar.event:read` | | `+search` | `vc:meeting.search:read` | diff --git a/skills/lark-vc/references/lark-vc-notes.md b/skills/lark-vc/references/lark-vc-notes.md index 00b5fd7e1..a819c87cd 100644 --- a/skills/lark-vc/references/lark-vc-notes.md +++ b/skills/lark-vc/references/lark-vc-notes.md @@ -63,9 +63,9 @@ lark-cli vc +notes --meeting-ids 69xxxxxxxxxxxxx28 --dry-run | 输入 | 所需权限 | |------|---------| -| `--meeting-ids` | `vc:meeting.meetingevent:read`、`vc:note:read` | +| `--meeting-ids` | `vc:meeting.meetingevent:read`、`vc:note:read`、`vc:record:readonly` | | `--minute-tokens` | `vc:note:read`、`minutes:minutes:readonly`、`minutes:minutes.artifacts:read`、`minutes:minutes.transcript:export` | -| `--calendar-event-ids` | `calendar:calendar:read`、`calendar:calendar.event:read`、`vc:meeting.meetingevent:read`、`vc:note:read` | +| `--calendar-event-ids` | `calendar:calendar:read`、`calendar:calendar.event:read`、`vc:meeting.meetingevent:read`、`vc:note:read`、`vc:record:readonly` | ## 输出结果 @@ -75,6 +75,8 @@ lark-cli vc +notes --meeting-ids 69xxxxxxxxxxxxx28 --dry-run | 字段 | 说明 | |------|------| +| `meeting_id` | 会议 ID(`--meeting-ids` / `--calendar-event-ids` 路径) | +| `minute_token` | **会议对应的妙记 Token**(`--meeting-ids` / `--calendar-event-ids` 路径自动通过录制 API 反查并附加)| | `note_doc_token` | **AI 智能纪要**文档 Token — AI 生成的总结、待办、章节 | | `meeting_notes` | **用户绑定的会议纪要**文档 Token 列表 — 用户主动关联到会议的文档(仅 `--calendar-event-ids` 路径返回) | | `verbatim_doc_token` | **逐字稿**文档 Token — 完整的逐句文字记录,含说话人和时间戳 | @@ -83,6 +85,8 @@ lark-cli vc +notes --meeting-ids 69xxxxxxxxxxxxx28 --dry-run | `create_time` | 创建时间(格式化) | > **选择哪个 token?** 用户说"会议纪要""总结""待办""纪要内容" → 返回 `note_doc_token` 和 `meeting_notes`(如有)。用户说"逐字稿""完整记录""谁说了什么" → 用 `verbatim_doc_token`。意图不明确时,展示所有文档链接让用户选择。 +> +> 📌 不确定该返回哪个 token?参见 [`vc-domain-boundaries.md`](vc-domain-boundaries.md) 的产物链路对比表,了解 AI 总结链路 vs 录制链路的区别。 ### minute-tokens 路径的 AI 产物 diff --git a/skills/lark-vc/references/vc-domain-boundaries.md b/skills/lark-vc/references/vc-domain-boundaries.md new file mode 100644 index 000000000..c987e1459 --- /dev/null +++ b/skills/lark-vc/references/vc-domain-boundaries.md @@ -0,0 +1,159 @@ +# Calendar/VC/Doc 跨领域关联关系、领域知识和职责边界说明 + +本文档说明飞书日历(Calendar)、视频会议(VC)、云文档(Doc)三个域之间的关联关系,帮助理解跨域数据流转和产物依赖。 + +## Calendar 域 + +- **lark-calendar skill** 负责日历与日程管理,包括创建、查询、修改、删除日程等操作。 +- **日程与会议的关系**:日程可以用于提前预约会议,确定会议时间、参与人、会议室、会议主题等信息。日程上可以关联飞书/Lark 视频会议。 +- **并非所有会议都通过日程发起**:即时会议不经过日程预约,直接创建。因此,仅查询日程数据无法覆盖所有会议,搜索历史会议应优先使用 `vc +search`。 +- **日程上的用户会议纪要**:用户可以在日程上绑定自己的会议纪要文档(MeetingNotes),用于手动记录会议相关信息。该文档与 AI 生成的智能纪要(`note_doc_token`)是不同的文档,相互独立。 + +> **路由规则**:查询过去已结束的会议 → `lark-vc`;查询未来日程/待开的会 → `lark-calendar`;查询"今天有哪些会议" → 两者结合(`vc +search` 查已结束 + `calendar` 查未开始)。 + +## VC 域 + +- **lark-vc skill** 负责视频会议管理,包括搜索历史会议、查询会议产物(智能纪要、逐字稿、妙记等)、查询参会人快照等操作。 +- **会议类型**:会议可以是日程会议(由日程发起,有对应的 `calendar_event_id`),也可以是即时会议等其他类型。 + +### 会议产物 + +会议产物取决于会中开启的功能,分为两条独立链路: + +#### 链路一:开启「AI 总结」 + +会中开启「AI 总结」功能后,产生以下产物: + +| 产物 | Token 字段 | 本质 | 说明 | +|------|-----------|------|------| +| 智能纪要 | `note_doc_token` | 飞书文档 | AI 生成的会议总结与待办 | +| 逐字稿 | `verbatim_doc_token` | 飞书文档 | 完整的逐句发言记录(含说话人、时间戳) | +| 共享文档 | `shared_doc_token` | 飞书文档 | 会中投屏共享的文档信息 | + +此外,还存在**用户会议纪要(MeetingNotes)**,对应 `meeting_notes` 字段。这是用户主动绑定到会议的纪要文档,通常用于会前记录会议相关内容,与智能纪要文档相互独立。仅通过 `+notes --calendar-event-ids` 路径返回。 + +#### 链路二:开启「录制」 + +会中开启「录制」功能后,产生**妙记产物**(`minute_token`),妙记本身包含以下子产物: + +| 子产物 | 说明 | +|--------|------| +| Summary(总结) | 对整场会议的智能总结 | +| Todo(待办) | 会议中识别出的待处理任务列表 | +| Chapter(章节) | 按讨论话题划分的核心内容摘要 | +| Transcript(文字记录) | 整场会议最原始的逐人发言记录 | + +#### 两条链路的独立性 + +- 智能纪要(AI 总结链路)和妙记(录制链路)**相互独立、互不影响**。 +- 一场会议可能同时拥有两类产物,也可能只有其中一类,也可能都没有。 +- 当两者都存在时,Summary/Todo 内容可能重叠,应根据用户意图选择优先读取哪个。 + +> **产物选择决策**:智能总结、待办、章节都属于 AI 分析产物,可能只包含最终结论和关键信息,完整的会议信息仍需从逐字稿/文字记录中获取。可根据用户诉求判断使用哪一种类型的产物。如果用户没有明确偏好,对于重复的内容(如智能总结、待办),**优先查询智能纪要(Note),不存在时再降级到妙记(Minutes)**。 + +#### 逐字稿与文字记录的格式 + +智能纪要的逐字稿(`verbatim_doc_token`)和妙记的文字记录(Transcript)都记录了用户原始对话内容,格式一致: + +``` +发言人名称 相对时间戳 +<发言内容> +``` + +示例: + +``` +张三 00:00:00.195 +我们接下来讨论一下项目进度。 +``` + +- 第一行为发言人信息,包含用户名称和发言的相对时间(从会议开始计算的偏移量)。 +- 后续行为该发言人的发言内容,直到下一个发言人标记出现。 + +### 会议总结和分析流程 + +#### Step 1: 定位会议 + +根据关键字、组织者、参与人、会议室等条件搜索会议,获取会议列表。 + +```bash +lark-cli vc +search --start "" --end "" --format json +``` + +详细用法请阅读 [`lark-vc-search.md`](lark-vc-search.md)。 + +#### Step 2: 根据 meeting_id 查询产物 + +##### 获取会议纪要产物 + +```bash +lark-cli vc +notes --meeting-ids ',' +``` + +可获取智能纪要(`note_doc_token`)、逐字稿(`verbatim_doc_token`)、共享文档(`shared_doc_token`)等文档 Token。详细用法请阅读 [`lark-vc-notes.md`](lark-vc-notes.md)。 + +其中: +- **智能纪要**包含 AI 生成的总结和待办信息 +- **逐字稿**包含完整的会中发言记录 + +文档正文内容需通过 Doc 域读取(见 Step 3)。 + +##### 获取妙记产物(录制链路) + +1. 查询妙记基本信息,获取 `minute_token`: + +```bash +lark-cli vc +recording --meeting-ids ',' +``` + +详细用法请阅读 [`lark-vc-recording.md`](lark-vc-recording.md)。 + +2. 通过 `minute_token` 获取妙记产物内容: + +```bash +lark-cli vc +notes --minute-tokens ',' +``` + +可获取妙记的总结、待办、章节、文字记录等信息。详细用法请阅读 [`lark-vc-notes.md`](lark-vc-notes.md)。 + +#### Step 3: Doc 域拉取文档内容 + +智能纪要和逐字稿都是飞书文档,需使用 `docs +fetch` 读取正文内容: + +```bash +lark-cli docs +fetch --api-version v2 --doc --doc-format markdown +``` + +详细用法请参考 [lark-doc](../../lark-doc/SKILL.md) skill。 + +#### Step 4: 判断用户需要的产物内容 + +- 根据用户诉求(总结/待办/章节/完整发言记录等),选择合适的产物进行分析和信息提取 +- 如果两种产物都不存在或没有权限,需如实告知用户 + +## Doc 域 + +- **lark-doc skill** 负责飞书云文档管理,包括获取文档元信息、读取文档内容、创建和编辑文档等操作。 +- **会议产物的文档本质**:智能纪要(`note_doc_token`)、逐字稿(`verbatim_doc_token`)都是飞书文档,需要通过 `lark-doc` 的 API(如 `docs +fetch`)查询其内容和元信息。 +- **文档元信息查询**:获取文档名称、URL 等基本信息时,使用 `drive metas batch_query`;获取文档正文内容时,使用 `docs +fetch --api-version v2`。 + +## 三域关联总览 + +``` +Calendar (日程) ──── 发起预约 ────► VC (会议) + │ + ┌──────────────────┤ + │ │ + AI 总结链路 录制链路 + │ │ + ▼ ▼ + 智能纪要 (Doc) 妙记 (Minutes) + 逐字稿 (Doc) ├── Summary + 共享文档 (Doc) ├── Todo + 用户纪要 (Doc) ├── Chapter + └── Transcript +``` + +- Calendar 提供会议预约入口,但并非所有会议都来自日程。 +- VC 是会议数据的中心,管理会议记录和产物关联。 +- Doc 是会议产物的载体,智能纪要和逐字稿都以飞书文档形式沉淀,需通过 Doc 域 API 读取。 diff --git a/skills/lark-workflow-meeting-summary/SKILL.md b/skills/lark-workflow-meeting-summary/SKILL.md index c04129271..838a985cd 100644 --- a/skills/lark-workflow-meeting-summary/SKILL.md +++ b/skills/lark-workflow-meeting-summary/SKILL.md @@ -11,6 +11,12 @@ metadata: **CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**。然后阅读 [`../lark-vc/SKILL.md`](../lark-vc/SKILL.md),了解会议纪要相关操作。 +**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-vc/references/vc-domain-boundaries.md`](../lark-vc/references/vc-domain-boundaries.md)**,不读将导致命令使用、会议产物决策、领域边界职责判断错误: +> 1. 了解日历 & VC、会议产物 & 文档的关联关系和职责划分 +> 2. 了解会议产物(妙记和纪要)之间的关联关系,例如:**妙记和纪要产生条件相互独立** +> 3. 了解不同会议产物的组成部分,以便根据需求决策使用哪种产物的数据 +> 4. 了解会议总结、分析和信息提取的标准流程 + ## 适用场景 - "帮我整理这周的会议纪要" / "总结最近的会议" / "生成会议周报"