Compare commits

..

3 Commits

Author SHA1 Message Date
胡港
4cffbd7229 fix: optimize skill 2026-06-15 16:33:41 +08:00
胡港
1ff071556d fix: optimize calendar,vc,minute skills 2026-06-13 12:22:08 +08:00
胡港
e0490b73f3 fix: optimize calendar,vc,minutes,note shortcut 2026-06-13 11:57:06 +08:00
50 changed files with 2977 additions and 582 deletions

View File

@@ -0,0 +1,235 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//
// calendar +meeting — get meeting info for calendar events via mget_instance_relation_info
package calendar
import (
"context"
"fmt"
"io"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const meetingLogPrefix = "[calendar +meeting]"
var scopesCalendarMeeting = []string{
"calendar:calendar:read",
"calendar:calendar.event:read",
"vc:meeting.meetingevent:read",
}
// mgetInstanceRelationRequestBody is the request body for mget_instance_relation_info API.
type mgetInstanceRelationRequestBody struct {
InstanceIDs []string `json:"instance_ids"`
NeedMeetingInstanceIDs bool `json:"need_meeting_instance_ids"`
NeedMeetingNotes bool `json:"need_meeting_notes"`
NeedAIMeetingNotes bool `json:"need_ai_meeting_notes"`
}
// meetingInfoItem represents a single event's meeting info in the output.
type meetingInfoItem struct {
EventID string `json:"event_id"`
MeetingID string `json:"meeting_id,omitempty"`
MeetingNote string `json:"meeting_note,omitempty"`
Error string `json:"error,omitempty"`
Hint string `json:"hint,omitempty"`
}
// translateFailMsg converts API fail_msg to a user-friendly error message.
func translateFailMsg(failMsg string) string {
switch failMsg {
case "No Permission":
return "no read permission for this calendar event (not a participant of the event)"
case "Not Found":
return "event not found on the specified calendar (event ID may be incorrect or does not belong to this calendar)"
default:
return failMsg
}
}
// fetchEventMeetingInfo queries mget_instance_relation_info for a single event instance.
func fetchEventMeetingInfo(ctx context.Context, runtime *common.RuntimeContext, instanceID, calendarID string) *meetingInfoItem {
body := &mgetInstanceRelationRequestBody{
InstanceIDs: []string{instanceID},
NeedMeetingInstanceIDs: true,
NeedMeetingNotes: true,
NeedAIMeetingNotes: true,
}
data, err := runtime.CallAPITyped("POST",
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/mget_instance_relation_info", validate.EncodePathSegment(calendarID)),
nil, body)
if err != nil {
return &meetingInfoItem{EventID: instanceID, Error: err.Error()}
}
// Check for failed instance IDs first
if failedIDs, _ := data["failed_instance_ids"].([]any); len(failedIDs) > 0 {
for _, raw := range failedIDs {
if failInfo, ok := raw.(map[string]any); ok {
if failID, _ := failInfo["instance_id"].(string); failID == instanceID {
failMsg, _ := failInfo["fail_msg"].(string)
return &meetingInfoItem{EventID: instanceID, Error: translateFailMsg(failMsg)}
}
}
}
}
infos, _ := data["instance_relation_infos"].([]any)
if len(infos) == 0 {
return &meetingInfoItem{EventID: instanceID, Error: "no event relation info found"}
}
info, _ := infos[0].(map[string]any)
result := &meetingInfoItem{EventID: instanceID}
// Extract meeting_id (return first if multiple) — API returns string
if rawIDs, _ := info["meeting_instance_ids"].([]any); len(rawIDs) > 0 {
if id, ok := rawIDs[0].(string); ok && id != "" {
result.MeetingID = id
}
}
// Extract meeting_note (return first if multiple)
if notes, _ := info["meeting_notes"].([]any); len(notes) > 0 {
if note, ok := notes[0].(string); ok && note != "" {
result.MeetingNote = note
}
}
// Add hints for empty resources (independent checks)
var emptyFields []string
if result.MeetingID == "" {
emptyFields = append(emptyFields, "meeting_id")
}
if result.MeetingNote == "" {
emptyFields = append(emptyFields, "meeting_note")
}
if len(emptyFields) > 0 {
result.Hint = fmt.Sprintf("%s not found for this event", strings.Join(emptyFields, ", "))
}
return result
}
// CalendarMeeting gets meeting info for calendar events.
var CalendarMeeting = common.Shortcut{
Service: "calendar",
Command: "+meeting",
Description: "Get meeting info for calendar events (meeting_id, meeting_note)",
Risk: "read",
Scopes: []string{"calendar:calendar.event:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "event-ids", Desc: "calendar event instance IDs, comma-separated for batch", Required: true},
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := rejectCalendarAutoBotFallback(runtime); err != nil {
return err
}
ids := common.SplitCSV(runtime.Str("event-ids"))
const maxBatchSize = 50
if len(ids) > maxBatchSize {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--event-ids: too many IDs (%d), maximum is %d", len(ids), maxBatchSize).WithParam("--event-ids")
}
// dynamic scope check
result, err := runtime.Factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(runtime.As(), runtime.Config.AppID))
if err == nil && result != nil && result.Scopes != "" {
if missing := auth.MissingScopes(result.Scopes, scopesCalendarMeeting); len(missing) > 0 {
return errs.NewPermissionError(errs.SubtypeMissingScope,
"missing required scope(s): %s", strings.Join(missing, ", ")).
WithHint("run `lark-cli auth login --scope %q` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " ")).
WithMissingScopes(missing...).
WithIdentity(string(runtime.As()))
}
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
calendarID := runtime.Str("calendar-id")
if calendarID == "" {
calendarID = "<primary>"
}
return common.NewDryRunAPI().
POST(fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/mget_instance_relation_info", calendarID)).
Set("event_ids", common.SplitCSV(runtime.Str("event-ids"))).
Set("calendar_id", calendarID)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
errOut := runtime.IO().ErrOut
instanceIDs := common.SplitCSV(runtime.Str("event-ids"))
calendarID := strings.TrimSpace(runtime.Str("calendar-id"))
if calendarID == "" {
calendarID = PrimaryCalendarIDStr
}
results := make([]*meetingInfoItem, 0, len(instanceIDs))
const batchDelay = 100 * time.Millisecond
fmt.Fprintf(errOut, "%s querying %d event_id(s)\n", meetingLogPrefix, len(instanceIDs))
for i, id := range instanceIDs {
if err := ctx.Err(); err != nil {
return err
}
if i > 0 {
time.Sleep(batchDelay)
}
fmt.Fprintf(errOut, "%s querying event_id=%s ...\n", meetingLogPrefix, id)
results = append(results, fetchEventMeetingInfo(ctx, runtime, id, calendarID))
}
successCount := 0
for _, r := range results {
if r.Error == "" {
successCount++
}
}
fmt.Fprintf(errOut, "%s done: %d total, %d succeeded, %d failed\n", meetingLogPrefix, len(results), successCount, len(results)-successCount)
if successCount == 0 && len(results) > 0 {
return runtime.OutPartialFailure(map[string]any{"meetings": results}, &output.Meta{Count: len(results)})
}
outData := map[string]any{"meetings": results}
runtime.OutFormat(outData, &output.Meta{Count: len(results)}, func(w io.Writer) {
if len(results) == 0 {
fmt.Fprintln(w, "No events.")
return
}
var rows []map[string]interface{}
for _, r := range results {
row := map[string]interface{}{"event_id": r.EventID}
if r.Error != "" {
row["status"] = "FAIL"
row["error"] = r.Error
} else {
row["status"] = "OK"
if r.MeetingID != "" {
row["meeting_id"] = r.MeetingID
}
if r.MeetingNote != "" {
row["meeting_note"] = r.MeetingNote
}
if r.Hint != "" {
row["hint"] = r.Hint
}
}
rows = append(rows, row)
}
output.PrintTable(w, rows)
fmt.Fprintf(w, "\n%d event(s), %d succeeded, %d failed\n", len(results), successCount, len(results)-successCount)
})
return nil
},
}

View File

@@ -0,0 +1,484 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package calendar
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------
var calWarmOnce sync.Once
func calWarmTokenCache(t *testing.T) {
t.Helper()
calWarmOnce.Do(func() {
f, _, _, reg := cmdutil.TestFactory(t, calDefaultConfig())
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/warm",
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
})
s := common.Shortcut{
Service: "test",
Command: "+warm",
AuthTypes: []string{"bot"},
Execute: func(_ context.Context, rctx *common.RuntimeContext) error {
_, err := rctx.CallAPI("GET", "/open-apis/test/v1/warm", nil, nil)
return err
},
}
parent := &cobra.Command{Use: "test"}
s.Mount(parent, f)
parent.SetArgs([]string{"+warm"})
parent.SilenceErrors = true
parent.SilenceUsage = true
parent.Execute()
})
}
func calDefaultConfig() *core.CliConfig {
return &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
UserOpenId: "ou_testuser",
}
}
func calMountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
t.Helper()
calWarmTokenCache(t)
parent := &cobra.Command{Use: "calendar"}
s.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
}
// ---------------------------------------------------------------------------
// calendar +meeting tests
// ---------------------------------------------------------------------------
func mgetInstanceRelationStub(calendarID, instanceID string, meetingIDs []string, meetingNotes []string, aiMeetingNotes []string) *httpmock.Stub {
infos := map[string]interface{}{
"instance_id": instanceID,
}
mIDs := make([]interface{}, len(meetingIDs))
for i, id := range meetingIDs {
mIDs[i] = id
}
infos["meeting_instance_ids"] = mIDs
if len(meetingNotes) > 0 {
notes := make([]interface{}, len(meetingNotes))
for i, n := range meetingNotes {
notes[i] = n
}
infos["meeting_notes"] = notes
}
if len(aiMeetingNotes) > 0 {
notes := make([]interface{}, len(aiMeetingNotes))
for i, n := range aiMeetingNotes {
notes[i] = n
}
infos["ai_meeting_notes"] = notes
}
return &httpmock.Stub{
Method: "POST",
URL: fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/mget_instance_relation_info", calendarID),
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"instance_relation_infos": []interface{}{infos},
},
},
}
}
func mgetInstanceRelationFailedStub(calendarID, instanceID, failMsg string) *httpmock.Stub {
return &httpmock.Stub{
Method: "POST",
URL: fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/mget_instance_relation_info", calendarID),
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"instance_relation_infos": []interface{}{},
"failed_instance_ids": []interface{}{
map[string]interface{}{
"instance_id": instanceID,
"fail_msg": failMsg,
},
},
},
},
}
}
func TestMeeting_Validation_MissingEventIDs(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, calDefaultConfig())
err := calMountAndRun(t, CalendarMeeting, []string{"+meeting", "--as", "user"}, f, nil)
if err == nil {
t.Fatal("expected validation error for missing --event-ids")
}
}
func TestMeeting_Validation_BatchLimit(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, calDefaultConfig())
ids := make([]string, 51)
for i := range ids {
ids[i] = fmt.Sprintf("evt%d", i)
}
err := calMountAndRun(t, CalendarMeeting, []string{"+meeting", "--event-ids", strings.Join(ids, ","), "--as", "user"}, f, nil)
if err == nil {
t.Fatal("expected batch limit error")
}
if !strings.Contains(err.Error(), "too many IDs") {
t.Errorf("expected 'too many IDs' error, got: %v", err)
}
}
func TestMeeting_DryRun(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, calDefaultConfig())
err := calMountAndRun(t, CalendarMeeting, []string{"+meeting", "--event-ids", "evt001", "--dry-run", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "mget_instance_relation_info") {
t.Errorf("dry-run should show mget API path, got: %s", stdout.String())
}
}
func TestMeeting_Execute_Success(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, calDefaultConfig())
reg.Register(mgetInstanceRelationStub("primary", "evt_m1", []string{"123456"}, []string{"doc_note1"}, []string{"doc_ai1"}))
err := calMountAndRun(t, CalendarMeeting, []string{"+meeting", "--event-ids", "evt_m1", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var resp map[string]any
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse output: %v", err)
}
data, _ := resp["data"].(map[string]any)
meetings, _ := data["meetings"].([]any)
if len(meetings) != 1 {
t.Fatalf("expected 1 meeting, got %d", len(meetings))
}
m, _ := meetings[0].(map[string]any)
if m["meeting_id"] != "123456" {
t.Errorf("meeting_id = %v, want 123456", m["meeting_id"])
}
if m["meeting_note"] != "doc_note1" {
t.Errorf("meeting_note = %v, want doc_note1", m["meeting_note"])
}
if _, hasAI := m["ai_meeting_note"]; hasAI {
t.Error("ai_meeting_note should not be present in output")
}
}
func TestMeeting_Execute_FailedInstance(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, calDefaultConfig())
reg.Register(mgetInstanceRelationFailedStub("primary", "evt_fail", "No Permission"))
err := calMountAndRun(t, CalendarMeeting, []string{"+meeting", "--event-ids", "evt_fail", "--as", "user"}, f, stdout)
if err == nil {
t.Fatal("expected partial failure error")
}
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
}
// Verify translated fail_msg appears in output
var resp map[string]any
if err := json.Unmarshal(stdout.Bytes(), &resp); err == nil {
data, _ := resp["data"].(map[string]any)
meetings, _ := data["meetings"].([]any)
if len(meetings) > 0 {
m, _ := meetings[0].(map[string]any)
if errMsg, _ := m["error"].(string); !strings.Contains(errMsg, "no read permission") {
t.Errorf("expected translated fail_msg, got: %v", errMsg)
}
}
}
}
func TestMeeting_Execute_NoMeeting(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, calDefaultConfig())
reg.Register(mgetInstanceRelationStub("primary", "evt_nomeet", []string{}, nil, nil))
err := calMountAndRun(t, CalendarMeeting, []string{"+meeting", "--event-ids", "evt_nomeet", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var resp map[string]any
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse output: %v", err)
}
data, _ := resp["data"].(map[string]any)
meetings, _ := data["meetings"].([]any)
if len(meetings) != 1 {
t.Fatalf("expected 1 meeting, got %d", len(meetings))
}
m, _ := meetings[0].(map[string]any)
if hint, _ := m["hint"].(string); !strings.Contains(hint, "meeting_id") {
t.Errorf("expected hint about meeting_id, got: %v", hint)
}
}
// ---------------------------------------------------------------------------
// calendar +search-event tests
// ---------------------------------------------------------------------------
func TestSearchEvent_Validation_InvalidTimeRange(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, calDefaultConfig())
err := calMountAndRun(t, CalendarSearchEvent, []string{"+search-event", "--start", "bad-format", "--end", "2026-04-27", "--as", "user"}, f, nil)
if err == nil {
t.Fatal("expected validation error for invalid --start")
}
if !strings.Contains(err.Error(), "--start") {
t.Errorf("unexpected error: %v", err)
}
}
func TestSearchEvent_Validation_TimeRangeStartAfterEnd(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, calDefaultConfig())
err := calMountAndRun(t, CalendarSearchEvent, []string{"+search-event", "--start", "2026-04-27", "--end", "2026-04-20", "--as", "user"}, f, nil)
if err == nil {
t.Fatal("expected validation error for start after end")
}
}
func TestSearchEvent_DryRun(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, calDefaultConfig())
err := calMountAndRun(t, CalendarSearchEvent, []string{"+search-event", "--query", "周会", "--dry-run", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "search_event") {
t.Errorf("dry-run should show search_event API path, got: %s", stdout.String())
}
}
func TestSearchEvent_Execute_Success(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, calDefaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/calendars/primary/events/search_event",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"display_info": "Q2 周会\n2026-04-23 15:00-16:00",
"meta_data": map[string]interface{}{
"event_id": "evt_search1",
"summary": "Q2 周会",
"start": map[string]interface{}{
"date_time": "2026-04-23T15:00:00+08:00",
"timezone": "Asia/Shanghai",
},
"end": map[string]interface{}{
"date_time": "2026-04-23T16:00:00+08:00",
"timezone": "Asia/Shanghai",
},
"is_all_day": false,
"app_link": "https://applink.feishu.cn/...",
},
},
},
"has_more": false,
"page_token": "",
},
},
})
err := calMountAndRun(t, CalendarSearchEvent, []string{"+search-event", "--query", "周会", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var resp map[string]any
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse output: %v", err)
}
data, _ := resp["data"].(map[string]any)
if data["calendar_id"] != "primary" {
t.Errorf("calendar_id = %v, want primary", data["calendar_id"])
}
items, _ := data["items"].([]any)
if len(items) != 1 {
t.Fatalf("expected 1 item, got %d", len(items))
}
item, _ := items[0].(map[string]any)
if item["event_id"] != "evt_search1" {
t.Errorf("event_id = %v, want evt_search1", item["event_id"])
}
if item["summary"] != "Q2 周会" {
t.Errorf("summary = %v, want 'Q2 周会'", item["summary"])
}
}
func TestSearchEvent_Execute_Empty(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, calDefaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/calendars/primary/events/search_event",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{},
"has_more": false,
},
},
})
err := calMountAndRun(t, CalendarSearchEvent, []string{"+search-event", "--query", "nonexistent", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// ---------------------------------------------------------------------------
// Pure function tests
// ---------------------------------------------------------------------------
func TestParseSearchEventTimeRange(t *testing.T) {
tests := []struct {
name string
start string
end string
wantErr bool
}{
{"empty", "", "", false},
{"valid", "2026-04-20", "2026-04-27", false},
{"start only defaults end", "2026-04-20", "", false},
{"end only defaults start", "", "2026-04-27", false},
{"invalid start format", "not-a-date", "2026-04-27", true},
{"start after end", "2026-04-27", "2026-04-20", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("start", "", "")
cmd.Flags().String("end", "", "")
if tt.start != "" {
_ = cmd.Flags().Set("start", tt.start)
}
if tt.end != "" {
_ = cmd.Flags().Set("end", tt.end)
}
runtime := common.TestNewRuntimeContext(cmd, calDefaultConfig())
_, _, err := parseSearchEventTimeRange(runtime)
if (err != nil) != tt.wantErr {
t.Errorf("parseSearchEventTimeRange() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
t.Run("start only fills end with end-of-day", func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("start", "", "")
cmd.Flags().String("end", "", "")
_ = cmd.Flags().Set("start", "2026-04-20")
runtime := common.TestNewRuntimeContext(cmd, calDefaultConfig())
startRFC, endRFC, err := parseSearchEventTimeRange(runtime)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.HasPrefix(startRFC, "2026-04-20T00:00:00") {
t.Errorf("start = %s, want 2026-04-20T00:00:00...", startRFC)
}
if !strings.HasPrefix(endRFC, "2026-04-20T23:59:59") {
t.Errorf("end = %s, want 2026-04-20T23:59:59...", endRFC)
}
})
t.Run("end only fills start with start-of-day", func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("start", "", "")
cmd.Flags().String("end", "", "")
_ = cmd.Flags().Set("end", "2026-04-27")
runtime := common.TestNewRuntimeContext(cmd, calDefaultConfig())
startRFC, endRFC, err := parseSearchEventTimeRange(runtime)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.HasPrefix(startRFC, "2026-04-27T00:00:00") {
t.Errorf("start = %s, want 2026-04-27T00:00:00...", startRFC)
}
if !strings.HasPrefix(endRFC, "2026-04-27T23:59:59") {
t.Errorf("end = %s, want 2026-04-27T23:59:59...", endRFC)
}
})
}
func TestBuildSearchEventFilter(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("attendee-ids", "", "")
_ = cmd.Flags().Set("attendee-ids", "ou_user1,oc_chat1,omm_room1")
runtime := common.TestNewRuntimeContext(cmd, calDefaultConfig())
filter := buildSearchEventFilter(runtime, "", "")
if filter == nil {
t.Fatal("expected filter to be non-nil")
}
if len(filter.AttendeeUserIDs) != 1 || filter.AttendeeUserIDs[0] != "ou_user1" {
t.Errorf("attendee_user_ids = %v, want [ou_user1]", filter.AttendeeUserIDs)
}
if len(filter.AttendeeChatIDs) != 1 || filter.AttendeeChatIDs[0] != "oc_chat1" {
t.Errorf("attendee_chat_ids = %v, want [oc_chat1]", filter.AttendeeChatIDs)
}
if len(filter.MeetingRoomIDs) != 1 || filter.MeetingRoomIDs[0] != "omm_room1" {
t.Errorf("meeting_room_ids = %v, want [omm_room1]", filter.MeetingRoomIDs)
}
}
func TestBuildSearchEventFilter_Empty(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("attendee-ids", "", "")
runtime := common.TestNewRuntimeContext(cmd, calDefaultConfig())
filter := buildSearchEventFilter(runtime, "", "")
if filter != nil {
t.Errorf("expected nil for empty filter, got %v", filter)
}
}
func TestBuildSearchEventFilter_TimeRange(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("attendee-ids", "", "")
runtime := common.TestNewRuntimeContext(cmd, calDefaultConfig())
filter := buildSearchEventFilter(runtime, "2026-04-20T00:00:00+08:00", "2026-04-27T23:59:59+08:00")
if filter == nil {
t.Fatal("expected filter to be non-nil")
}
if filter.TimeRange == nil {
t.Fatal("expected time_range in filter")
}
if filter.TimeRange.StartTime != "2026-04-20T00:00:00+08:00" {
t.Errorf("start_time = %v, want 2026-04-20T00:00:00+08:00", filter.TimeRange.StartTime)
}
}

View File

@@ -66,7 +66,8 @@ type roomFindSlot struct {
type roomFindTimeSlot struct {
Start string `json:"start,omitempty"`
End string `json:"end,omitempty"`
MeetingRooms []*roomFindSuggestion `json:"meeting_rooms,omitempty"`
MeetingRooms []*roomFindSuggestion `json:"meeting_rooms"`
Hint string `json:"hint,omitempty"`
}
type roomFindOutput struct {
@@ -103,11 +104,18 @@ func collectRoomFindResults(slots []roomFindSlot, limit int, fetch func(roomFind
}
return
}
out.TimeSlots = append(out.TimeSlots, &roomFindTimeSlot{
if suggestions == nil {
suggestions = []*roomFindSuggestion{}
}
ts := &roomFindTimeSlot{
Start: slot.Start,
End: slot.End,
MeetingRooms: suggestions,
})
}
if len(suggestions) == 0 {
ts.Hint = "no meeting room matches the current filters for this slot"
}
out.TimeSlots = append(out.TimeSlots, ts)
}(slot)
}
wg.Wait()
@@ -374,6 +382,10 @@ var CalendarRoomFind = common.Shortcut{
}
for _, slot := range out.TimeSlots {
fmt.Fprintf(w, "%s - %s\n", slot.Start, slot.End)
if len(slot.MeetingRooms) == 0 {
fmt.Fprintf(w, "0 meeting room(s) found: %s\n", slot.Hint)
continue
}
var rows []map[string]interface{}
for _, room := range slot.MeetingRooms {
rows = append(rows, map[string]interface{}{
@@ -384,6 +396,7 @@ var CalendarRoomFind = common.Shortcut{
})
}
output.PrintTable(w, rows)
fmt.Fprintf(w, "%d meeting room(s) found\n", len(slot.MeetingRooms))
fmt.Fprintln(w)
}
})

View File

@@ -4,6 +4,8 @@
package calendar
import (
"encoding/json"
"strings"
"testing"
"time"
)
@@ -82,3 +84,60 @@ func TestCollectRoomFindResults_LimitsConcurrency(t *testing.T) {
t.Fatalf("expected %d time slots, got %d", len(slots), len(out.TimeSlots))
}
}
func TestCollectRoomFindResults_EmptySlotEmitsHintAndArray(t *testing.T) {
slots := []roomFindSlot{
{Start: "2026-03-27T14:00:00+08:00", End: "2026-03-27T15:00:00+08:00"},
{Start: "2026-03-27T15:00:00+08:00", End: "2026-03-27T16:00:00+08:00"},
}
out, err := collectRoomFindResults(slots, 2, func(slot roomFindSlot) ([]*roomFindSuggestion, error) {
if strings.HasPrefix(slot.Start, "2026-03-27T14") {
return []*roomFindSuggestion{{RoomID: "rm_1", RoomName: "Room A"}}, nil
}
return nil, nil
})
if err != nil {
t.Fatalf("collectRoomFindResults returned error: %v", err)
}
if len(out.TimeSlots) != 2 {
t.Fatalf("expected 2 time slots, got %d", len(out.TimeSlots))
}
for _, ts := range out.TimeSlots {
if ts.MeetingRooms == nil {
t.Fatalf("meeting_rooms should be non-nil for slot %s", ts.Start)
}
switch {
case strings.HasPrefix(ts.Start, "2026-03-27T14"):
if len(ts.MeetingRooms) != 1 {
t.Fatalf("expected 1 room for first slot, got %d", len(ts.MeetingRooms))
}
if ts.Hint != "" {
t.Fatalf("non-empty slot should not carry hint, got %q", ts.Hint)
}
case strings.HasPrefix(ts.Start, "2026-03-27T15"):
if len(ts.MeetingRooms) != 0 {
t.Fatalf("expected 0 rooms for empty slot, got %d", len(ts.MeetingRooms))
}
if ts.Hint == "" {
t.Fatal("empty slot should carry a hint explaining the filters")
}
}
}
emptySlot := out.TimeSlots[0]
if !strings.HasPrefix(emptySlot.Start, "2026-03-27T15") {
emptySlot = out.TimeSlots[1]
}
raw, err := json.Marshal(emptySlot)
if err != nil {
t.Fatalf("marshal empty slot: %v", err)
}
if !strings.Contains(string(raw), `"meeting_rooms":[]`) {
t.Fatalf("expected meeting_rooms:[] in JSON, got %s", raw)
}
if !strings.Contains(string(raw), `"hint"`) {
t.Fatalf("expected hint field in JSON, got %s", raw)
}
}

View File

@@ -0,0 +1,351 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//
// calendar +search-event — search calendar events by keyword, time range, and attendees
package calendar
import (
"context"
"fmt"
"io"
"strconv"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const searchEventLogPrefix = "[calendar +search-event]"
const (
defaultSearchEventPageSize = 20
maxSearchEventPageSize = 30
)
var scopesSearchEvent = []string{
"calendar:calendar:read",
"calendar:calendar.event:read",
}
// searchEventTimeRange represents the time range filter for search_event API.
type searchEventTimeRange struct {
StartTime string `json:"start_time,omitempty"`
EndTime string `json:"end_time,omitempty"`
}
// searchEventFilter represents the filter object for the search_event API request.
type searchEventFilter struct {
AttendeeUserIDs []string `json:"attendee_user_ids,omitempty"`
AttendeeChatIDs []string `json:"attendee_chat_ids,omitempty"`
MeetingRoomIDs []string `json:"meeting_room_ids,omitempty"`
TimeRange *searchEventTimeRange `json:"time_range,omitempty"`
}
// searchEventRequestBody is the request body for the search_event API.
type searchEventRequestBody struct {
Query string `json:"query"`
Filter *searchEventFilter `json:"filter,omitempty"`
}
// searchEventTimeInfo represents start/end time info in the search result.
type searchEventTimeInfo struct {
Date string `json:"date,omitempty"`
DateTime string `json:"date_time,omitempty"`
Timezone string `json:"timezone,omitempty"`
}
// searchEventItem represents a single event in the search result output.
type searchEventItem struct {
EventID string `json:"event_id"`
Summary string `json:"summary"`
Start *searchEventTimeInfo `json:"start,omitempty"`
End *searchEventTimeInfo `json:"end,omitempty"`
IsAllDay bool `json:"is_all_day,omitempty"`
AppLink string `json:"app_link,omitempty"`
}
// searchEventOutput is the structured output for +search-event.
type searchEventOutput struct {
CalendarID string `json:"calendar_id"`
Items []searchEventItem `json:"items"`
HasMore bool `json:"has_more"`
PageToken string `json:"page_token"`
}
// parseSearchEventTimeRange parses --start / --end into RFC3339 strings.
// When only one side is provided, the other defaults to the same day's
// boundary (start → end-of-day, end → start-of-day).
func parseSearchEventTimeRange(runtime *common.RuntimeContext) (string, string, error) {
startInput := strings.TrimSpace(runtime.Str("start"))
endInput := strings.TrimSpace(runtime.Str("end"))
if startInput == "" && endInput == "" {
return "", "", nil
}
var startSec, endSec int64
if startInput != "" {
ts, err := common.ParseTime(startInput)
if err != nil {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start")
}
startSec, _ = strconv.ParseInt(ts, 10, 64)
}
if endInput != "" {
ts, err := common.ParseTime(endInput, "end")
if err != nil {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
}
endSec, _ = strconv.ParseInt(ts, 10, 64)
}
if startInput == "" {
t := time.Unix(endSec, 0).In(time.Local)
startSec = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()).Unix()
}
if endInput == "" {
t := time.Unix(startSec, 0).In(time.Local)
endSec = time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 0, t.Location()).Unix()
}
if startSec > endSec {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--start must be before --end").WithParam("--start")
}
return time.Unix(startSec, 0).Format(time.RFC3339), time.Unix(endSec, 0).Format(time.RFC3339), nil
}
// buildSearchEventFilter builds the filter object for the search_event API.
func buildSearchEventFilter(runtime *common.RuntimeContext, startTime, endTime string) *searchEventFilter {
attendeeIDs := common.SplitCSV(runtime.Str("attendee-ids"))
var userIDs, chatIDs, roomIDs []string
for _, id := range attendeeIDs {
switch {
case strings.HasPrefix(id, "ou_"):
userIDs = append(userIDs, id)
case strings.HasPrefix(id, "oc_"):
chatIDs = append(chatIDs, id)
case strings.HasPrefix(id, "omm_"):
roomIDs = append(roomIDs, id)
default:
userIDs = append(userIDs, id)
}
}
var tr *searchEventTimeRange
if startTime != "" || endTime != "" {
tr = &searchEventTimeRange{StartTime: startTime, EndTime: endTime}
}
if len(userIDs) == 0 && len(chatIDs) == 0 && len(roomIDs) == 0 && tr == nil {
return nil
}
return &searchEventFilter{
AttendeeUserIDs: userIDs,
AttendeeChatIDs: chatIDs,
MeetingRoomIDs: roomIDs,
TimeRange: tr,
}
}
// extractTimeInfo extracts time info from a meta_data start/end map.
func extractTimeInfo(m map[string]any) *searchEventTimeInfo {
if m == nil {
return nil
}
info := &searchEventTimeInfo{}
if v, ok := m["date"].(string); ok && v != "" {
info.Date = v
}
if v, ok := m["date_time"].(string); ok && v != "" {
info.DateTime = v
}
if v, ok := m["timezone"].(string); ok && v != "" {
info.Timezone = v
}
if info.Date == "" && info.DateTime == "" {
return nil
}
return info
}
// CalendarSearchEvent searches calendar events by keyword, time range, and attendees.
var CalendarSearchEvent = common.Shortcut{
Service: "calendar",
Command: "+search-event",
Description: "Search calendar events by keyword, time range, and attendees",
Risk: "read",
Scopes: []string{"calendar:calendar.event:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
{Name: "query", Desc: "search keyword"},
{Name: "attendee-ids", Desc: "attendee IDs, comma-separated (supports user ou_, chat oc_, room omm_)"},
{Name: "start", Desc: "search time range start (ISO 8601 or YYYY-MM-DD)"},
{Name: "end", Desc: "search time range end (ISO 8601 or YYYY-MM-DD)"},
{Name: "page-token", Desc: "page token for next page"},
{Name: "page-size", Default: "20", Desc: "page size, 1-30 (default 20)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := rejectCalendarAutoBotFallback(runtime); err != nil {
return err
}
if _, _, err := parseSearchEventTimeRange(runtime); err != nil {
return err
}
if _, err := common.ValidatePageSizeTyped(runtime, "page-size", defaultSearchEventPageSize, 1, maxSearchEventPageSize); err != nil {
return err
}
// dynamic scope check
result, err := runtime.Factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(runtime.As(), runtime.Config.AppID))
if err == nil && result != nil && result.Scopes != "" {
if missing := auth.MissingScopes(result.Scopes, scopesSearchEvent); len(missing) > 0 {
return errs.NewPermissionError(errs.SubtypeMissingScope,
"missing required scope(s): %s", strings.Join(missing, ", ")).
WithHint("run `lark-cli auth login --scope %q` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " ")).
WithMissingScopes(missing...).
WithIdentity(string(runtime.As()))
}
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
calendarID := runtime.Str("calendar-id")
if calendarID == "" {
calendarID = "<primary>"
}
return common.NewDryRunAPI().
POST(fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/search_event", calendarID)).
Set("calendar_id", calendarID)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
calendarID := strings.TrimSpace(runtime.Str("calendar-id"))
if calendarID == "" {
calendarID = PrimaryCalendarIDStr
}
startTime, endTime, err := parseSearchEventTimeRange(runtime)
if err != nil {
return err
}
// Build request body — always send query (even if empty)
body := &searchEventRequestBody{
Query: strings.TrimSpace(runtime.Str("query")),
}
if filter := buildSearchEventFilter(runtime, startTime, endTime); filter != nil {
body.Filter = filter
}
// Build query params
params := map[string]any{}
pageSize, _ := strconv.Atoi(strings.TrimSpace(runtime.Str("page-size")))
if pageSize <= 0 {
pageSize = defaultSearchEventPageSize
}
params["page_size"] = strconv.Itoa(pageSize)
if pt := strings.TrimSpace(runtime.Str("page-token")); pt != "" {
params["page_token"] = pt
}
data, err := runtime.CallAPITyped("POST",
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/search_event", validate.EncodePathSegment(calendarID)),
params, body)
if err != nil {
return err
}
if data == nil {
data = map[string]any{}
}
items := common.GetSlice(data, "items")
hasMore, _ := data["has_more"].(bool)
pageToken, _ := data["page_token"].(string)
// Transform items to structured output
outItems := make([]searchEventItem, 0, len(items))
for _, raw := range items {
item, _ := raw.(map[string]any)
if item == nil {
continue
}
meta, _ := item["meta_data"].(map[string]any)
out := searchEventItem{}
if meta != nil {
if v, ok := meta["event_id"].(string); ok {
out.EventID = v
}
if v, ok := meta["summary"].(string); ok {
out.Summary = v
}
if v, ok := meta["is_all_day"].(bool); ok {
out.IsAllDay = v
}
if v, ok := meta["app_link"].(string); ok {
out.AppLink = v
}
if start, ok := meta["start"].(map[string]any); ok {
out.Start = extractTimeInfo(start)
}
if end, ok := meta["end"].(map[string]any); ok {
out.End = extractTimeInfo(end)
}
}
outItems = append(outItems, out)
}
outData := searchEventOutput{
CalendarID: calendarID,
Items: outItems,
HasMore: hasMore,
PageToken: pageToken,
}
runtime.OutFormat(outData, &output.Meta{Count: len(outItems)}, func(w io.Writer) {
if len(outItems) == 0 {
fmt.Fprintln(w, "No events found.")
return
}
var rows []map[string]interface{}
for _, item := range outItems {
row := map[string]interface{}{
"event_id": item.EventID,
"summary": common.TruncateStr(item.Summary, 40),
}
if item.Start != nil {
if item.Start.DateTime != "" {
row["start"] = item.Start.DateTime
} else if item.Start.Date != "" {
row["start"] = item.Start.Date
}
}
if item.End != nil {
if item.End.DateTime != "" {
row["end"] = item.End.DateTime
} else if item.End.Date != "" {
row["end"] = item.End.Date
}
}
if item.IsAllDay {
row["is_all_day"] = true
}
rows = append(rows, row)
}
output.PrintTable(w, rows)
fmt.Fprintf(w, "\n%d event(s) found\n", len(outItems))
})
if hasMore && runtime.Format != "json" && runtime.Format != "" {
fmt.Fprintf(runtime.IO().Out, "\n(more available, page_token: %s)\n", pageToken)
}
return nil
},
}

View File

@@ -2234,10 +2234,10 @@ func TestResolveStartEnd_ExplicitValues(t *testing.T) {
// Shortcuts() registration test
// ---------------------------------------------------------------------------
func TestShortcuts_Returns7(t *testing.T) {
func TestShortcuts_Returns9(t *testing.T) {
shortcuts := Shortcuts()
if len(shortcuts) != 7 {
t.Fatalf("expected 7 shortcuts, got %d", len(shortcuts))
if len(shortcuts) != 9 {
t.Fatalf("expected 9 shortcuts, got %d", len(shortcuts))
}
names := map[string]bool{}

View File

@@ -15,5 +15,7 @@ func Shortcuts() []common.Shortcut {
CalendarRoomFind,
CalendarRsvp,
CalendarSuggestion,
CalendarMeeting,
CalendarSearchEvent,
}
}

View File

@@ -0,0 +1,285 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//
// minutes +detail — query minute details with selective artifact flags
package minutes
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const minutesDetailLogPrefix = "[minutes +detail]"
// Error codes from the minutes API.
const minutesDetailNoReadPermissionCode = 2091005
var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)
var scopesDetailMinuteTokens = []string{
"minutes:minutes.basic:read",
"minutes:minutes.artifacts:read",
}
// minuteDetailItem represents a single minute detail result.
type minuteDetailItem struct {
MinuteToken string `json:"minute_token"`
Title string `json:"title"`
NoteID string `json:"note_id"`
Artifacts map[string]any `json:"artifacts,omitempty"`
Error string `json:"error,omitempty"`
}
// fetchMinuteDetail queries a single minute's metadata and selected artifacts.
func fetchMinuteDetail(ctx context.Context, runtime *common.RuntimeContext, minuteToken string) *minuteDetailItem {
data, err := runtime.CallAPITyped(http.MethodGet,
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment(minuteToken)), nil, nil)
if err != nil {
result := &minuteDetailItem{MinuteToken: minuteToken}
if p, ok := errs.ProblemOf(err); ok && p.Code == minutesDetailNoReadPermissionCode {
result.Error = fmt.Sprintf("No read permission for minute %s. Ask the minute owner for minute file read permission", minuteToken)
} else {
result.Error = fmt.Sprintf("failed to query minute: %v", err)
}
return result
}
minute, _ := data["minute"].(map[string]any)
if minute == nil {
return &minuteDetailItem{MinuteToken: minuteToken, Error: "minute not found"}
}
result := &minuteDetailItem{MinuteToken: minuteToken}
if v, ok := minute["title"].(string); ok && v != "" {
result.Title = v
}
if v, ok := minute["note_id"].(string); ok && v != "" {
result.NoteID = v
}
// Fetch artifacts selectively based on flags
needSummary := runtime.Bool("summary")
needTodo := runtime.Bool("todo")
needChapter := runtime.Bool("chapter")
needTranscript := runtime.Bool("transcript")
needKeyword := runtime.Bool("keyword")
if needSummary || needTodo || needChapter || needTranscript || needKeyword {
artData, err := runtime.CallAPITyped(http.MethodGet,
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/artifacts", validate.EncodePathSegment(minuteToken)), nil, nil)
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "%s failed to fetch artifacts for %s: %v\n", minutesDetailLogPrefix, minuteToken, err)
} else {
artifacts := make(map[string]any)
if needSummary {
if v, ok := artData["summary"].(string); ok && v != "" {
artifacts["summary"] = v
} else {
artifacts["summary"] = ""
}
}
if needTodo {
if v, ok := artData["minute_todos"].([]any); ok && len(v) > 0 {
artifacts["todos"] = v
} else {
artifacts["todos"] = []any{}
}
}
if needChapter {
if v, ok := artData["minute_chapters"].([]any); ok && len(v) > 0 {
artifacts["chapters"] = v
} else {
artifacts["chapters"] = []any{}
}
}
if needKeyword {
if v, ok := artData["keywords"].([]any); ok && len(v) > 0 {
artifacts["keywords"] = v
} else {
artifacts["keywords"] = []any{}
}
}
if needTranscript {
if v, ok := artData["transcript"].(string); ok && v != "" {
if path := saveDetailTranscript(runtime, minuteToken, result.Title, []byte(v)); path != "" {
artifacts["transcript_file"] = path
} else {
artifacts["transcript_file"] = ""
}
} else {
artifacts["transcript_file"] = ""
}
}
result.Artifacts = artifacts
}
}
return result
}
// saveDetailTranscript persists transcript bytes to the canonical artifact path.
func saveDetailTranscript(runtime *common.RuntimeContext, minuteToken, title string, content []byte) string {
errOut := runtime.IO().ErrOut
dirName := common.DefaultMinuteArtifactDir(minuteToken)
transcriptPath := filepath.Join(dirName, common.DefaultTranscriptFileName)
if !runtime.Bool("overwrite") {
if _, statErr := runtime.FileIO().Stat(transcriptPath); statErr == nil {
fmt.Fprintf(errOut, "%s transcript already exists: %s (use --overwrite to replace)\n", minutesDetailLogPrefix, transcriptPath)
return transcriptPath
}
}
fmt.Fprintf(errOut, "%s writing transcript: %s\n", minutesDetailLogPrefix, transcriptPath)
if _, err := runtime.FileIO().Save(transcriptPath, fileio.SaveOptions{}, bytes.NewReader(content)); err != nil {
fmt.Fprintf(errOut, "%s failed to write transcript: %v\n", minutesDetailLogPrefix, err)
return ""
}
return transcriptPath
}
// MinutesDetail queries minute details with selective artifact flags.
var MinutesDetail = common.Shortcut{
Service: "minutes",
Command: "+detail",
Description: "Query minute details with selective artifact flags (summary, todo, chapter, transcript, keyword)",
Risk: "read",
Scopes: []string{"minutes:minutes.basic:read", "minutes:minutes.artifacts:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "minute-tokens", Desc: "minute tokens, comma-separated for batch", Required: true},
{Name: "summary", Type: "bool", Desc: "include summary"},
{Name: "todo", Type: "bool", Desc: "include todos"},
{Name: "chapter", Type: "bool", Desc: "include chapters"},
{Name: "transcript", Type: "bool", Desc: "include transcript (saved to file)"},
{Name: "keyword", Type: "bool", Desc: "include keywords"},
{Name: "overwrite", Type: "bool", Desc: "overwrite existing transcript files"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
tokens := common.SplitCSV(runtime.Str("minute-tokens"))
const maxBatchSize = 50
if len(tokens) > maxBatchSize {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--minute-tokens: too many tokens (%d), maximum is %d", len(tokens), maxBatchSize).WithParam("--minute-tokens")
}
for _, token := range tokens {
if !validMinuteTokenDetail.MatchString(token) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid minute token %q: must contain only lowercase alphanumeric characters", token).WithParam("--minute-tokens")
}
}
// dynamic scope check
result, err := runtime.Factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(runtime.As(), runtime.Config.AppID))
if err == nil && result != nil && result.Scopes != "" {
if missing := auth.MissingScopes(result.Scopes, scopesDetailMinuteTokens); len(missing) > 0 {
return errs.NewPermissionError(errs.SubtypeMissingScope,
"missing required scope(s): %s", strings.Join(missing, ", ")).
WithHint("run `lark-cli auth login --scope %q` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " ")).
WithMissingScopes(missing...).
WithIdentity(string(runtime.As()))
}
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
tokens := runtime.Str("minute-tokens")
d := common.NewDryRunAPI().
GET("/open-apis/minutes/v1/minutes/{minute_token}").
Set("minute_tokens", common.SplitCSV(tokens))
if runtime.Bool("summary") || runtime.Bool("todo") || runtime.Bool("chapter") || runtime.Bool("transcript") || runtime.Bool("keyword") {
d.GET("/open-apis/minutes/v1/minutes/{minute_token}/artifacts")
}
return d
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
errOut := runtime.IO().ErrOut
minuteTokens := common.SplitCSV(runtime.Str("minute-tokens"))
results := make([]*minuteDetailItem, 0, len(minuteTokens))
const batchDelay = 100 * time.Millisecond
fmt.Fprintf(errOut, "%s querying %d minute_token(s)\n", minutesDetailLogPrefix, len(minuteTokens))
for i, token := range minuteTokens {
if err := ctx.Err(); err != nil {
return err
}
if i > 0 {
time.Sleep(batchDelay)
}
fmt.Fprintf(errOut, "%s querying minute_token=%s ...\n", minutesDetailLogPrefix, token)
results = append(results, fetchMinuteDetail(ctx, runtime, token))
}
successCount := 0
for _, r := range results {
if r.Error == "" {
successCount++
}
}
fmt.Fprintf(errOut, "%s done: %d total, %d succeeded, %d failed\n", minutesDetailLogPrefix, len(results), successCount, len(results)-successCount)
if successCount == 0 && len(results) > 0 {
return runtime.OutPartialFailure(map[string]any{"minutes": results}, &output.Meta{Count: len(results)})
}
outData := map[string]any{"minutes": results}
runtime.OutFormat(outData, &output.Meta{Count: len(results)}, func(w io.Writer) {
if len(results) == 0 {
fmt.Fprintln(w, "No minutes.")
return
}
var rows []map[string]interface{}
for _, r := range results {
row := map[string]interface{}{"minute_token": r.MinuteToken}
if r.Error != "" {
row["status"] = "FAIL"
row["error"] = r.Error
} else {
row["status"] = "OK"
row["title"] = r.Title
row["note_id"] = r.NoteID
if len(r.Artifacts) > 0 {
var parts []string
if _, ok := r.Artifacts["summary"]; ok {
parts = append(parts, "summary")
}
if _, ok := r.Artifacts["todos"]; ok {
parts = append(parts, "todo")
}
if _, ok := r.Artifacts["chapters"]; ok {
parts = append(parts, "chapter")
}
if _, ok := r.Artifacts["keywords"]; ok {
parts = append(parts, "keyword")
}
if _, ok := r.Artifacts["transcript_file"]; ok {
parts = append(parts, "transcript")
}
if len(parts) > 0 {
row["artifacts"] = strings.Join(parts, ", ")
}
}
}
rows = append(rows, row)
}
output.PrintTable(w, rows)
fmt.Fprintf(w, "\n%d minute(s), %d succeeded, %d failed\n", len(results), successCount, len(results)-successCount)
})
return nil
},
}

View File

@@ -0,0 +1,372 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"sync"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------
var detailWarmOnce sync.Once
func detailWarmTokenCache(t *testing.T) {
t.Helper()
detailWarmOnce.Do(func() {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/warm",
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
})
s := common.Shortcut{
Service: "test",
Command: "+warm",
AuthTypes: []string{"bot"},
Execute: func(_ context.Context, rctx *common.RuntimeContext) error {
_, err := rctx.CallAPI("GET", "/open-apis/test/v1/warm", nil, nil)
return err
},
}
parent := &cobra.Command{Use: "test"}
s.Mount(parent, f)
parent.SetArgs([]string{"+warm"})
parent.SilenceErrors = true
parent.SilenceUsage = true
parent.Execute()
})
}
func detailMountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
t.Helper()
detailWarmTokenCache(t)
parent := &cobra.Command{Use: "minutes"}
s.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
}
func detailBotExec(t *testing.T, name string, f *cmdutil.Factory, fn func(context.Context, *common.RuntimeContext) error) error {
t.Helper()
detailWarmTokenCache(t)
s := common.Shortcut{
Service: "test",
Command: "+" + name,
AuthTypes: []string{"bot"},
HasFormat: true,
Execute: fn,
}
parent := &cobra.Command{Use: "minutes"}
s.Mount(parent, f)
parent.SetArgs([]string{"+" + name, "--format", "json"})
parent.SilenceErrors = true
parent.SilenceUsage = true
return parent.Execute()
}
// ---------------------------------------------------------------------------
// Validation tests
// ---------------------------------------------------------------------------
func detailMinuteGetStub(token, noteID, title string) *httpmock.Stub {
minute := map[string]interface{}{"title": title}
if noteID != "" {
minute["note_id"] = noteID
}
return &httpmock.Stub{
Method: "GET",
URL: "/open-apis/minutes/v1/minutes/" + token,
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"minute": minute},
},
}
}
func detailArtifactsStub(token, transcript string) *httpmock.Stub {
data := map[string]interface{}{
"summary": "Test summary content",
"minute_todos": []interface{}{map[string]interface{}{"content": "Buy milk"}},
"minute_chapters": []interface{}{map[string]interface{}{"title": "Intro", "summary_content": "Opening"}},
"keywords": []interface{}{"budget", "roadmap"},
}
if transcript != "" {
data["transcript"] = transcript
}
return &httpmock.Stub{
Method: "GET",
URL: "/open-apis/minutes/v1/minutes/" + token + "/artifacts",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": data,
},
}
}
func TestDetail_Validation_MissingMinuteTokens(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--as", "user"}, f, nil)
if err == nil {
t.Fatal("expected validation error for missing --minute-tokens")
}
}
func TestDetail_Validation_InvalidToken(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "INVALID!", "--as", "user"}, f, nil)
if err == nil {
t.Fatal("expected validation error for invalid token")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Param != "--minute-tokens" {
t.Errorf("Param = %q, want --minute-tokens", ve.Param)
}
}
func TestDetail_Validation_BatchLimit(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
tokens := make([]string, 51)
for i := range tokens {
tokens[i] = fmt.Sprintf("tok%d", i)
}
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", strings.Join(tokens, ","), "--as", "user"}, f, nil)
if err == nil {
t.Fatal("expected batch limit error")
}
if !strings.Contains(err.Error(), "too many tokens") {
t.Errorf("expected 'too many tokens' error, got: %v", err)
}
}
// ---------------------------------------------------------------------------
// DryRun tests
// ---------------------------------------------------------------------------
func TestDetail_DryRun(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tok001", "--dry-run", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "/open-apis/minutes/v1/minutes/") {
t.Errorf("dry-run should show minutes API path, got: %s", stdout.String())
}
}
func TestDetail_DryRun_WithArtifactFlags(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tok001", "--summary", "--todo", "--dry-run", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "artifacts") {
t.Errorf("dry-run should show artifacts API path when artifact flags are set, got: %s", stdout.String())
}
}
// ---------------------------------------------------------------------------
// Execute tests with mocked HTTP
// ---------------------------------------------------------------------------
func TestDetail_Execute_BasicInfo(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(detailMinuteGetStub("tokbasic", "", "Test Meeting"))
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tokbasic", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var resp map[string]any
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse output: %v", err)
}
data, _ := resp["data"].(map[string]any)
minutes, _ := data["minutes"].([]any)
if len(minutes) != 1 {
t.Fatalf("expected 1 minute, got %d", len(minutes))
}
m, _ := minutes[0].(map[string]any)
if m["minute_token"] != "tokbasic" {
t.Errorf("minute_token = %v, want tokbasic", m["minute_token"])
}
if m["title"] != "Test Meeting" {
t.Errorf("title = %v, want Test Meeting", m["title"])
}
if _, ok := m["note_id"]; ok {
t.Errorf("note_id should be omitted when minute has no note_id, got %v", m["note_id"])
}
}
func TestDetail_Execute_WithSummaryAndTodo(t *testing.T) {
chdirForDetailTest(t)
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(detailMinuteGetStub("tokart", "note_art", "Artifact Meeting"))
reg.Register(detailArtifactsStub("tokart", ""))
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tokart", "--summary", "--todo", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var resp map[string]any
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse output: %v", err)
}
data, _ := resp["data"].(map[string]any)
minutes, _ := data["minutes"].([]any)
if len(minutes) != 1 {
t.Fatalf("expected 1 minute, got %d", len(minutes))
}
m, _ := minutes[0].(map[string]any)
if m["note_id"] != "note_art" {
t.Errorf("note_id = %v, want note_art", m["note_id"])
}
arts, _ := m["artifacts"].(map[string]any)
if arts == nil {
t.Fatal("expected artifacts to be present")
}
if _, ok := arts["summary"]; !ok {
t.Error("expected summary in artifacts")
}
if _, ok := arts["todos"]; !ok {
t.Error("expected todos in artifacts")
}
// chapter and keywords should NOT be present since flags not set
if _, ok := arts["chapters"]; ok {
t.Error("chapters should not be present when --chapter not set")
}
if _, ok := arts["keywords"]; ok {
t.Error("keywords should not be present when --keyword not set")
}
}
func TestDetail_Execute_NoArtifactFlags(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(detailMinuteGetStub("toknoart", "", "No Artifacts"))
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "toknoart", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var resp map[string]any
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse output: %v", err)
}
data, _ := resp["data"].(map[string]any)
minutes, _ := data["minutes"].([]any)
if len(minutes) != 1 {
t.Fatalf("expected 1 minute, got %d", len(minutes))
}
m, _ := minutes[0].(map[string]any)
if _, ok := m["artifacts"]; ok {
t.Error("artifacts should not be present when no artifact flags set")
}
}
func TestDetail_Execute_Transcript(t *testing.T) {
chdirForDetailTest(t)
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(detailMinuteGetStub("toktrans", "", "Transcript Meeting"))
reg.Register(detailArtifactsStub("toktrans", "speaker1: hello world\n"))
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "toktrans", "--transcript", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Check transcript file was saved
wantPath := "minutes/toktrans/transcript.txt"
data, err := os.ReadFile(wantPath)
if err != nil {
t.Fatalf("expected file at %s: %v", wantPath, err)
}
if string(data) != "speaker1: hello world\n" {
t.Errorf("content mismatch: %q", string(data))
}
}
func TestDetail_Execute_MinuteNotFound(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/minutes/v1/minutes/tokbad",
Body: map[string]interface{}{"code": 2091004, "msg": "not found"},
})
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tokbad", "--as", "user"}, f, stdout)
if err == nil {
t.Fatal("expected partial failure error")
}
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
}
}
// ---------------------------------------------------------------------------
// Pure function tests
// ---------------------------------------------------------------------------
func TestValidMinuteTokenDetail(t *testing.T) {
tests := []struct {
token string
valid bool
}{
{"abc123", true},
{"obcnmgn1429t5xt9j82i1p3h", true},
{"INVALID!", false},
{"has-space", false},
{"", false},
}
for _, tt := range tests {
got := validMinuteTokenDetail.MatchString(tt.token)
if got != tt.valid {
t.Errorf("validMinuteTokenDetail(%q) = %v, want %v", tt.token, got, tt.valid)
}
}
}
// chdirForDetailTest switches cwd to a temp dir for the test.
func chdirForDetailTest(t *testing.T) string {
t.Helper()
orig, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
dir := t.TempDir()
if err := os.Chdir(dir); err != nil {
t.Fatalf("chdir: %v", err)
}
t.Cleanup(func() { os.Chdir(orig) })
return dir
}

View File

@@ -184,12 +184,6 @@ func minuteSearchAppLink(item map[string]interface{}) string {
return common.GetString(meta, "app_link")
}
// minuteSearchAvatar extracts the avatar URL from a search result item.
func minuteSearchAvatar(item map[string]interface{}) string {
meta := common.GetMap(item, "meta_data")
return common.GetString(meta, "avatar")
}
// buildMinuteSearchRows converts API items into pretty output rows.
func buildMinuteSearchRows(items []interface{}) []map[string]interface{} {
rows := make([]map[string]interface{}, 0, len(items))
@@ -203,12 +197,27 @@ func buildMinuteSearchRows(items []interface{}) []map[string]interface{} {
"display_info": common.TruncateStr(minuteSearchDisplayInfo(item), 40),
"description": common.TruncateStr(minuteSearchDescription(item), 40),
"app_link": common.TruncateStr(minuteSearchAppLink(item), 80),
"avatar": common.TruncateStr(minuteSearchAvatar(item), 80),
})
}
return rows
}
// stripAvatarFromItems removes meta_data.avatar from each search item in place
// so the structured output does not surface avatars to AI agents.
func stripAvatarFromItems(items []interface{}) {
for _, raw := range items {
item, _ := raw.(map[string]interface{})
if item == nil {
continue
}
meta, _ := item["meta_data"].(map[string]interface{})
if meta == nil {
continue
}
delete(meta, "avatar")
}
}
// MinutesSearch searches minutes by keyword, owners, participants, and time range.
var MinutesSearch = common.Shortcut{
Service: "minutes",
@@ -298,13 +307,13 @@ var MinutesSearch = common.Shortcut{
}
items := minuteSearchItems(data)
stripAvatarFromItems(items)
hasMore, _ := data["has_more"].(bool)
pageToken, _ := data["page_token"].(string)
rows := buildMinuteSearchRows(items)
outData := map[string]interface{}{
"items": items,
"total": data["total"],
"has_more": data["has_more"],
"page_token": data["page_token"],
}

View File

@@ -526,7 +526,7 @@ func TestMinutesSearchExecuteRendersRowsAndMoreHint(t *testing.T) {
}
out := stdout.String()
for _, want := range []string{"minute_1", "周会摘要", "周会纪要", "https://meetings.feishu.cn/minutes/obcn123", "https://p3-lark-file.byteimg.com/img/xxxx.jpg", "next_token", "more available"} {
for _, want := range []string{"minute_1", "周会摘要", "周会纪要", "https://meetings.feishu.cn/minutes/obcn123", "next_token", "more available"} {
if !strings.Contains(out, want) {
t.Fatalf("output missing %q, got: %s", want, out)
}
@@ -663,7 +663,6 @@ func TestMinuteSearchFieldExtractors(t *testing.T) {
"meta_data": map[string]interface{}{
"description": "周会纪要",
"app_link": "https://meetings.feishu.cn/minutes/obcn123",
"avatar": "https://p3-lark-file.byteimg.com/img/xxxx.jpg",
},
}
@@ -679,9 +678,6 @@ func TestMinuteSearchFieldExtractors(t *testing.T) {
if got := minuteSearchAppLink(item); got != "https://meetings.feishu.cn/minutes/obcn123" {
t.Fatalf("minuteSearchAppLink() = %q", got)
}
if got := minuteSearchAvatar(item); got != "https://p3-lark-file.byteimg.com/img/xxxx.jpg" {
t.Fatalf("minuteSearchAvatar() = %q", got)
}
}
// TestMinuteSearchFieldExtractorsFallbacks verifies extractors keep working for alternate sample data.
@@ -694,7 +690,6 @@ func TestMinuteSearchFieldExtractorsFallbacks(t *testing.T) {
"meta_data": map[string]interface{}{
"description": "回退纪要",
"app_link": "https://meetings.feishu.cn/minutes/fallback",
"avatar": "https://p3-lark-file.byteimg.com/img/fallback.jpg",
},
}
@@ -707,9 +702,6 @@ func TestMinuteSearchFieldExtractorsFallbacks(t *testing.T) {
if got := minuteSearchAppLink(item); got != "https://meetings.feishu.cn/minutes/fallback" {
t.Fatalf("minuteSearchAppLink() = %q", got)
}
if got := minuteSearchAvatar(item); got != "https://p3-lark-file.byteimg.com/img/fallback.jpg" {
t.Fatalf("minuteSearchAvatar() = %q", got)
}
}
// TestMinuteSearchFieldExtractorsMissingMetaData verifies extractors fall back to empty values without metadata.
@@ -730,7 +722,32 @@ func TestMinuteSearchFieldExtractorsMissingMetaData(t *testing.T) {
if got := minuteSearchAppLink(item); got != "" {
t.Fatalf("minuteSearchAppLink() = %q, want empty", got)
}
if got := minuteSearchAvatar(item); got != "" {
t.Fatalf("minuteSearchAvatar() = %q, want empty", got)
}
// TestStripAvatarFromItems verifies the avatar field is removed from items in place.
func TestStripAvatarFromItems(t *testing.T) {
t.Parallel()
items := []interface{}{
map[string]interface{}{
"token": "minute_1",
"meta_data": map[string]interface{}{
"description": "周会纪要",
"avatar": "https://p3-lark-file.byteimg.com/img/xxxx.jpg",
},
},
nil,
map[string]interface{}{"token": "minute_no_meta"},
}
stripAvatarFromItems(items)
first, _ := items[0].(map[string]interface{})
meta, _ := first["meta_data"].(map[string]interface{})
if _, ok := meta["avatar"]; ok {
t.Fatalf("avatar should be stripped, got meta = %v", meta)
}
if meta["description"] != "周会纪要" {
t.Fatalf("description should be preserved, got %v", meta["description"])
}
}

View File

@@ -16,5 +16,6 @@ func Shortcuts() []common.Shortcut {
MinutesTodo,
MinutesSpeakerReplace,
MinutesWordReplace,
MinutesDetail,
}
}

View File

@@ -78,6 +78,7 @@ func init() {
allShortcuts = append(allShortcuts, markdown.Shortcuts()...)
allShortcuts = append(allShortcuts, slides.Shortcuts()...)
allShortcuts = append(allShortcuts, minutes.Shortcuts()...)
allShortcuts = append(allShortcuts, note.Shortcuts()...)
allShortcuts = append(allShortcuts, task.Shortcuts()...)
allShortcuts = append(allShortcuts, vc.Shortcuts()...)
allShortcuts = append(allShortcuts, note.Shortcuts()...)

View File

@@ -11,6 +11,7 @@ func Shortcuts() []common.Shortcut {
VCSearch,
VCNotes,
VCRecording,
VCDetail,
VCMeetingJoin,
VCMeetingLeave,
VCMeetingEvents,

216
shortcuts/vc/vc_detail.go Normal file
View File

@@ -0,0 +1,216 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//
// vc +detail — get meeting details including note_id and minute_token
package vc
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const detailLogPrefix = "[vc +detail]"
var scopesDetailMeetingIDs = []string{
"vc:meeting.meetingevent:read",
"vc:record:readonly",
}
// meetingDetailItem represents a single meeting detail result.
type meetingDetailItem struct {
MeetingID string `json:"meeting_id"`
MeetingNo string `json:"meeting_no,omitempty"`
Topic string `json:"topic"`
StartTime string `json:"start_time,omitempty"`
EndTime string `json:"end_time,omitempty"`
NoteID string `json:"note_id,omitempty"`
MinuteToken string `json:"minute_token,omitempty"`
Error string `json:"error,omitempty"`
Hint string `json:"hint,omitempty"`
}
// fetchMeetingDetail queries meeting.get and recording API to return a
// consolidated view of meeting metadata, note_id, and minute_token.
// Error is only set when an API call actually fails; note_id and minute_token
// are always present (empty string when not available).
func fetchMeetingDetail(ctx context.Context, runtime *common.RuntimeContext, meetingID string) *meetingDetailItem {
result := &meetingDetailItem{MeetingID: meetingID}
// Step 1: query meeting detail
data, err := runtime.CallAPITyped(http.MethodGet,
fmt.Sprintf("/open-apis/vc/v1/meetings/%s", validate.EncodePathSegment(meetingID)),
map[string]interface{}{"with_participants": "false", "query_mode": "0"}, nil)
if err != nil {
result.Error = fmt.Sprintf("failed to query meeting detail: %v", err)
return result
}
meeting, _ := data["meeting"].(map[string]any)
if meeting == nil {
result.Error = "meeting not found in response"
return result
}
if v, ok := meeting["meeting_no"].(string); ok {
result.MeetingNo = v
}
if v, ok := meeting["topic"].(string); ok {
result.Topic = v
}
if v := common.FormatTime(meeting["start_time"]); v != "" {
result.StartTime = v
}
if v := common.FormatTime(meeting["end_time"]); v != "" {
result.EndTime = v
}
if v, ok := meeting["note_id"].(string); ok && v != "" {
result.NoteID = v
}
// Step 2: query minute_token via recording API
minuteToken, minuteHint, minuteErr := fetchMeetingMinuteToken(runtime, meetingID)
if minuteErr != nil {
// Recording API failed — surface the error but keep data from step 1
result.Error = fmt.Sprintf("failed to query minutes: %v", minuteErr)
minuteHint = ""
}
if minuteToken != "" {
result.MinuteToken = minuteToken
}
// Add hints for empty resources (not errors, just informational)
var emptyFields []string
if result.NoteID == "" {
emptyFields = append(emptyFields, "note_id")
}
if result.MinuteToken == "" && minuteErr == nil && minuteHint == "" {
emptyFields = append(emptyFields, "minute_token")
}
if len(emptyFields) > 0 {
result.Hint = fmt.Sprintf("%s not found for this meeting", strings.Join(emptyFields, ", "))
}
if minuteHint != "" {
if result.Hint != "" {
result.Hint += "; " + minuteHint
} else {
result.Hint = minuteHint
}
}
return result
}
// VCDetail gets meeting details including note_id and minute_token.
var VCDetail = common.Shortcut{
Service: "vc",
Command: "+detail",
Description: "Get meeting details including note_id and minute_token by meeting IDs",
Risk: "read",
Scopes: []string{"vc:meeting.meetingevent:read", "vc:record:readonly"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "meeting-ids", Desc: "meeting IDs, comma-separated for batch", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
ids := common.SplitCSV(runtime.Str("meeting-ids"))
const maxBatchSize = 50
if len(ids) > maxBatchSize {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--meeting-ids: too many IDs (%d), maximum is %d", len(ids), maxBatchSize).WithParam("--meeting-ids")
}
// dynamic scope check
result, err := runtime.Factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(runtime.As(), runtime.Config.AppID))
if err == nil && result != nil && result.Scopes != "" {
if missing := auth.MissingScopes(result.Scopes, scopesDetailMeetingIDs); len(missing) > 0 {
return errs.NewPermissionError(errs.SubtypeMissingScope,
"missing required scope(s): %s", strings.Join(missing, ", ")).
WithHint("run `lark-cli auth login --scope %q` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " ")).
WithMissingScopes(missing...).
WithIdentity(string(runtime.As()))
}
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ids := runtime.Str("meeting-ids")
return common.NewDryRunAPI().
GET("/open-apis/vc/v1/meetings/{meeting_id}").
GET("/open-apis/vc/v1/meetings/{meeting_id}/recording").
Set("meeting_ids", common.SplitCSV(ids)).
Set("steps", "meeting.get → note_id + recording API → minute_token")
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
errOut := runtime.IO().ErrOut
meetingIDs := common.SplitCSV(runtime.Str("meeting-ids"))
results := make([]*meetingDetailItem, 0, len(meetingIDs))
const batchDelay = 100 * time.Millisecond
fmt.Fprintf(errOut, "%s querying %d meeting_id(s)\n", detailLogPrefix, len(meetingIDs))
for i, id := range meetingIDs {
if err := ctx.Err(); err != nil {
return err
}
if i > 0 {
time.Sleep(batchDelay)
}
fmt.Fprintf(errOut, "%s querying meeting_id=%s ...\n", detailLogPrefix, sanitizeLogValue(id))
results = append(results, fetchMeetingDetail(ctx, runtime, id))
}
successCount := 0
for _, r := range results {
if r.Error == "" {
successCount++
}
}
fmt.Fprintf(errOut, "%s done: %d total, %d succeeded, %d failed\n", detailLogPrefix, len(results), successCount, len(results)-successCount)
if successCount == 0 && len(results) > 0 {
return runtime.OutPartialFailure(map[string]any{"meetings": results}, &output.Meta{Count: len(results)})
}
outData := map[string]any{"meetings": results}
runtime.OutFormat(outData, &output.Meta{Count: len(results)}, func(w io.Writer) {
if len(results) == 0 {
fmt.Fprintln(w, "No meetings.")
return
}
var rows []map[string]interface{}
for _, r := range results {
row := map[string]interface{}{"meeting_id": r.MeetingID}
if r.Error != "" {
row["status"] = "FAIL"
row["error"] = r.Error
} else {
row["status"] = "OK"
}
if r.NoteID != "" {
row["note_id"] = r.NoteID
}
if r.MinuteToken != "" {
row["minute_token"] = r.MinuteToken
}
row["topic"] = r.Topic
if r.Hint != "" {
row["hint"] = r.Hint
}
rows = append(rows, row)
}
output.PrintTable(w, rows)
fmt.Fprintf(w, "\n%d meeting(s), %d succeeded, %d failed\n", len(results), successCount, len(results)-successCount)
})
return nil
},
}

View File

@@ -0,0 +1,282 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
// ---------------------------------------------------------------------------
// Validation tests
// ---------------------------------------------------------------------------
func TestDetail_Validation_MissingMeetingIDs(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, VCDetail, []string{"+detail", "--as", "user"}, f, nil)
if err == nil {
t.Fatal("expected validation error for missing --meeting-ids")
}
if !strings.Contains(err.Error(), "meeting-ids") {
t.Errorf("unexpected error message: %v", err)
}
}
func TestDetail_Validation_BatchLimit(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
ids := make([]string, 51)
for i := range ids {
ids[i] = fmt.Sprintf("m%d", i)
}
err := mountAndRun(t, VCDetail, []string{"+detail", "--meeting-ids", strings.Join(ids, ","), "--as", "user"}, f, nil)
if err == nil {
t.Fatal("expected batch limit error")
}
if !strings.Contains(err.Error(), "too many IDs") {
t.Errorf("expected 'too many IDs' error, got: %v", err)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("Subtype = %q, want SubtypeInvalidArgument", ve.Subtype)
}
}
// ---------------------------------------------------------------------------
// DryRun tests
// ---------------------------------------------------------------------------
func TestDetail_DryRun(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, VCDetail, []string{"+detail", "--meeting-ids", "m001", "--dry-run", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "/open-apis/vc/v1/meetings/") {
t.Errorf("dry-run should show meeting API path, got: %s", stdout.String())
}
if !strings.Contains(stdout.String(), "recording") {
t.Errorf("dry-run should show recording API path, got: %s", stdout.String())
}
}
// ---------------------------------------------------------------------------
// Execute tests with mocked HTTP
// ---------------------------------------------------------------------------
func TestDetail_Execute_Success(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingGetStub("m_detail1", "note_001"))
reg.Register(recordingOKStub("m_detail1", "https://meetings.feishu.cn/minutes/obc_detail1"))
err := mountAndRun(t, VCDetail, []string{"+detail", "--meeting-ids", "m_detail1", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var resp map[string]any
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse output: %v", err)
}
data, _ := resp["data"].(map[string]any)
meetings, _ := data["meetings"].([]any)
if len(meetings) != 1 {
t.Fatalf("expected 1 meeting, got %d", len(meetings))
}
m, _ := meetings[0].(map[string]any)
if m["meeting_id"] != "m_detail1" {
t.Errorf("meeting_id = %v, want m_detail1", m["meeting_id"])
}
if m["note_id"] != "note_001" {
t.Errorf("note_id = %v, want note_001", m["note_id"])
}
if m["minute_token"] != "obc_detail1" {
t.Errorf("minute_token = %v, want obc_detail1", m["minute_token"])
}
}
func TestDetail_Execute_NoNoteNoMinute(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingGetStub("m_nonote", ""))
reg.Register(recordingErrStub("m_nonote", 121004, "not found"))
err := mountAndRun(t, VCDetail, []string{"+detail", "--meeting-ids", "m_nonote", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify hint is present for empty note_id and missing recording
var resp map[string]any
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse output: %v", err)
}
data, _ := resp["data"].(map[string]any)
meetings, _ := data["meetings"].([]any)
m, _ := meetings[0].(map[string]any)
if hint, _ := m["hint"].(string); !strings.Contains(hint, "note_id") || !strings.Contains(hint, "no minute file for this meeting") {
t.Errorf("hint should mention note_id and minute file missing, got: %v", hint)
}
if errMsg, _ := m["error"].(string); errMsg != "" {
t.Errorf("error should be empty, got: %v", errMsg)
}
}
func TestDetail_Execute_MeetingNotFound(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/meetings/m_bad",
Body: map[string]interface{}{"code": 121004, "msg": "data not found"},
})
err := mountAndRun(t, VCDetail, []string{"+detail", "--meeting-ids", "m_bad", "--as", "user"}, f, stdout)
if err == nil {
t.Fatal("expected partial failure error")
}
}
func TestDetail_Execute_Batch(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
// m1 succeeds with note and minute
reg.Register(meetingGetStub("m_batch1", "note_b1"))
reg.Register(recordingOKStub("m_batch1", "https://meetings.feishu.cn/minutes/obc_b1"))
// m2 has no note_id but has minute
reg.Register(meetingGetStub("m_batch2", ""))
reg.Register(recordingOKStub("m_batch2", "https://meetings.feishu.cn/minutes/obc_b2"))
err := mountAndRun(t, VCDetail, []string{"+detail", "--meeting-ids", "m_batch1,m_batch2", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var resp map[string]any
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse output: %v", err)
}
data, _ := resp["data"].(map[string]any)
meetings, _ := data["meetings"].([]any)
if len(meetings) != 2 {
t.Fatalf("expected 2 meetings, got %d", len(meetings))
}
}
// ---------------------------------------------------------------------------
// Pure function tests
// ---------------------------------------------------------------------------
func TestFetchMeetingDetail_MeetingWithNoteAndMinute(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingGetStub("m_fn", "note_fn"))
reg.Register(recordingOKStub("m_fn", "https://meetings.feishu.cn/minutes/obc_fn"))
if err := botExec(t, "detail-fn", f, func(_ context.Context, rctx *common.RuntimeContext) error {
result := fetchMeetingDetail(context.Background(), rctx, "m_fn")
if result.MeetingID != "m_fn" {
t.Errorf("meeting_id = %v, want m_fn", result.MeetingID)
}
if result.NoteID != "note_fn" {
t.Errorf("note_id = %v, want note_fn", result.NoteID)
}
if result.MinuteToken != "obc_fn" {
t.Errorf("minute_token = %v, want obc_fn", result.MinuteToken)
}
if result.Error != "" {
t.Errorf("unexpected error: %v", result.Error)
}
return nil
}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestFetchMeetingDetail_MeetingNotFound(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/meetings/m_nf",
Body: map[string]interface{}{"code": 121004, "msg": "data not found"},
})
if err := botExec(t, "detail-nf", f, func(_ context.Context, rctx *common.RuntimeContext) error {
result := fetchMeetingDetail(context.Background(), rctx, "m_nf")
if result.Error == "" {
t.Error("expected error for meeting not found")
}
// note_id and minute_token should still be present (empty)
if result.NoteID != "" {
t.Errorf("note_id = %q, want empty", result.NoteID)
}
if result.MinuteToken != "" {
t.Errorf("minute_token = %q, want empty", result.MinuteToken)
}
return nil
}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestFetchMeetingDetail_RecordingFailsButNoteOK(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingGetStub("m_partial", "note_partial"))
reg.Register(recordingErrStub("m_partial", 121004, "not found"))
if err := botExec(t, "detail-partial", f, func(_ context.Context, rctx *common.RuntimeContext) error {
result := fetchMeetingDetail(context.Background(), rctx, "m_partial")
if result.NoteID != "note_partial" {
t.Errorf("note_id = %v, want note_partial", result.NoteID)
}
if result.MinuteToken != "" {
t.Errorf("minute_token = %q, want empty", result.MinuteToken)
}
if result.Error != "" {
t.Errorf("error = %q, want empty", result.Error)
}
if !strings.Contains(result.Hint, "no minute file for this meeting") {
t.Errorf("hint = %q, want contains 'no minute file for this meeting'", result.Hint)
}
return nil
}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestFetchMeetingDetail_RecordingAPIErrorButNoteOK(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingGetStub("m_api_err", "note_apierr"))
reg.Register(recordingErrStub("m_api_err", 99999, "weird API error"))
if err := botExec(t, "detail-apierr", f, func(_ context.Context, rctx *common.RuntimeContext) error {
result := fetchMeetingDetail(context.Background(), rctx, "m_api_err")
if result.NoteID != "note_apierr" {
t.Errorf("note_id = %v, want note_apierr", result.NoteID)
}
if result.MinuteToken != "" {
t.Errorf("minute_token = %q, want empty", result.MinuteToken)
}
if !strings.Contains(result.Error, "failed to query minutes") || !strings.Contains(result.Error, "weird API error") {
t.Errorf("error = %q, want contains 'failed to query minutes' and 'weird API error'", result.Error)
}
if strings.Contains(result.Hint, "minute_token") {
t.Errorf("hint = %q, should not mention minute_token when there is an error", result.Hint)
}
return nil
}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}

View File

@@ -818,7 +818,7 @@ func TestVCShortcuts_RegistersMeetingAgentCommands(t *testing.T) {
for _, shortcut := range got {
commands = append(commands, shortcut.Command)
}
want := []string{"+search", "+notes", "+recording", "+meeting-join", "+meeting-leave", "+meeting-events"}
want := []string{"+search", "+notes", "+recording", "+detail", "+meeting-join", "+meeting-leave", "+meeting-events"}
if !reflect.DeepEqual(commands, want) {
t.Fatalf("shortcut commands = %#v, want %#v", commands, want)
}

View File

@@ -263,42 +263,35 @@ func asStringSlice(v any) []string {
}
// 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.CallAPITyped(http.MethodGet,
// the associated minute_token (parsed from the recording URL), an optional
// hint for expected missing states, and an error for unexpected failures.
func fetchMeetingMinuteToken(runtime *common.RuntimeContext, meetingID string) (token, hint string, err error) {
data, apiErr := runtime.CallAPITyped(http.MethodGet,
fmt.Sprintf("/open-apis/vc/v1/meetings/%s/recording", validate.EncodePathSegment(meetingID)),
nil, nil)
if err != nil {
if p, ok := errs.ProblemOf(err); ok {
if apiErr != nil {
if p, ok := errs.ProblemOf(apiErr); ok {
switch p.Code {
case recordingNotFoundCode:
return "", "no minute file for this meeting"
return "", "no minute file for this meeting", nil
case recordingNoPermissionCode:
return "", "no permission to access this meeting's minute; ask the meeting owner to share the minute"
return "", "no permission to access this meeting's minute; ask the meeting owner to share the minute", nil
case recordingGeneratingCode:
return "", "minute file is still being generated; please retry later"
return "", "minute file is still being generated; please retry later", nil
}
}
return "", fmt.Sprintf("failed to query recording: %v", err)
return "", "", apiErr
}
recording, _ := data["recording"].(map[string]any)
if recording == nil {
return "", "no recording available for this meeting"
return "", "no recording available for this meeting", nil
}
recordingURL, _ := recording["url"].(string)
if t := extractMinuteToken(recordingURL); t != "" {
return t, ""
return t, "", nil
}
return "", "no minute_token found in recording URL"
return "", "no minute_token found in recording URL", nil
}
// fetchNoteByMeetingID queries notes via meeting_id and additionally fetches
@@ -321,7 +314,7 @@ func fetchNoteByMeetingID(ctx context.Context, runtime *common.RuntimeContext, m
// 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)
minuteToken, minuteHint, minuteErr := fetchMeetingMinuteToken(runtime, meetingID)
var result map[string]any
var noteErr string
@@ -340,7 +333,13 @@ func fetchNoteByMeetingID(ctx context.Context, runtime *common.RuntimeContext, m
if minuteToken != "" {
result["minute_token"] = minuteToken
}
if combined := joinErrors(noteErr, minuteErr); combined != "" {
var minuteErrMsg string
if minuteHint != "" {
minuteErrMsg = minuteHint
} else if minuteErr != nil {
minuteErrMsg = minuteErr.Error()
}
if combined := joinErrors(noteErr, minuteErrMsg); combined != "" {
result["error"] = combined
}
return result
@@ -538,6 +537,7 @@ var VCNotes = common.Shortcut{
Risk: "read",
Scopes: []string{"vc:note:read"}, // minimum scope; additional per-flag scopes checked in Validate
AuthTypes: []string{"user"},
Hidden: true, // hidden from --help; prefer vc +detail, minutes +detail, or note +detail
HasFormat: true,
Flags: []common.Flag{
{Name: "meeting-ids", Desc: "meeting IDs, comma-separated for batch"},

View File

@@ -792,12 +792,15 @@ func TestFetchMeetingMinuteToken_Success(t *testing.T) {
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")
token, hint, err := 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)
if hint != "" {
t.Errorf("hint = %q, want empty", hint)
}
if err != nil {
t.Errorf("err = %v, want nil", err)
}
return nil
}); err != nil {
@@ -823,12 +826,15 @@ func TestFetchMeetingMinuteToken_KnownErrorCodes(t *testing.T) {
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)
token, hint, err := 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)
if !strings.Contains(hint, tt.wantMsg) {
t.Errorf("hint = %q, want contains %q", hint, tt.wantMsg)
}
if err != nil {
t.Errorf("err = %v, want nil", err)
}
return nil
}); err != nil {
@@ -844,12 +850,15 @@ func TestFetchMeetingMinuteToken_GenericAPIError(t *testing.T) {
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")
token, hint, err := 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)
if hint != "" {
t.Errorf("hint = %q, want empty", hint)
}
if err == nil || !strings.Contains(err.Error(), "weird") {
t.Errorf("err = %v, want contains 'weird'", err)
}
return nil
}); err != nil {
@@ -866,12 +875,15 @@ func TestFetchMeetingMinuteToken_NoRecording(t *testing.T) {
}))
if err := botExec(t, "fmmt-norec", f, func(_ context.Context, rctx *common.RuntimeContext) error {
token, msg := fetchMeetingMinuteToken(rctx, "m_norec")
token, hint, err := 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)
if err != nil {
t.Errorf("err = %v, want nil", err)
}
if !strings.Contains(hint, "no recording available") {
t.Errorf("hint = %q, want contains 'no recording available'", hint)
}
return nil
}); err != nil {
@@ -885,12 +897,15 @@ func TestFetchMeetingMinuteToken_URLWithoutToken(t *testing.T) {
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")
token, hint, err := 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)
if err != nil {
t.Errorf("err = %v, want nil", err)
}
if !strings.Contains(hint, "no minute_token found") {
t.Errorf("hint = %q, want contains 'no minute_token found'", hint)
}
return nil
}); err != nil {
@@ -983,7 +998,7 @@ func TestNotes_MeetingPath_OnlyMinuteFails_PartialSuccess(t *testing.T) {
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")
assertNoteError(t, note, "no permission to access this meeting's minute; ask the meeting owner to share the minute")
}
func TestNotes_MeetingPath_NoNote_ButMinuteOK(t *testing.T) {
@@ -1068,6 +1083,7 @@ func TestNotes_MeetingPath_NoteNoPermission_FriendlyHint(t *testing.T) {
assertNoteError(t, note,
"[121005]",
"no read permission for this meeting note",
"no permission to access this meeting's minute",
"; ", // note + minute causes joined with semicolon
)
}

View File

@@ -230,9 +230,16 @@ var VCSearch = common.Shortcut{
data = map[string]interface{}{}
}
items := common.GetSlice(data, "items")
// Strip avatar from meta_data — not useful for AI agents.
for _, raw := range items {
if m, ok := raw.(map[string]interface{}); ok {
if meta, ok := m["meta_data"].(map[string]interface{}); ok {
delete(meta, "avatar")
}
}
}
outData := map[string]interface{}{
"items": items,
"total": data["total"],
"has_more": data["has_more"],
"page_token": data["page_token"],
}

View File

@@ -31,6 +31,8 @@ lark-cli calendar +agenda --as user
| Shortcut | 说明 |
|----------|------|
| [`+agenda`](references/lark-calendar-agenda.md) | 查看日程安排(默认今天) |
| [`+search-event`](references/lark-calendar-search-event.md) | 按关键词、时间范围和参会人搜索日程 |
| [`+meeting`](references/lark-calendar-meeting.md) | 通过日程事件 ID 获取关联的视频会议信息meeting_id、meeting_note |
| [`+create`](references/lark-calendar-create.md) | 创建日程并邀请参会人ISO 8601 时间) |
| [`+update`](references/lark-calendar-update.md) | 更新既有日程字段,或独立增量添加/移除参会人和会议室 |
| [`+freebusy`](references/lark-calendar-freebusy.md) | 查询用户主日历的忙闲信息和 RSVP 状态 |
@@ -64,6 +66,9 @@ lark-cli calendar +agenda --as user
|----------|--------|
| 查询过去的会议("昨天的会议""上周的会" | [`../lark-vc/SKILL.md`](../lark-vc/SKILL.md)(会议数据含即时会议,仅查日程会遗漏) |
| 查询日历/日程或未来时间的会议 | 本 skill |
| 按关键词搜索日程 | 本 skill`+search-event` |
| 从日程获取关联的视频会议 ID 或用户绑定的会议纪要文档 | 本 skill`+meeting` |
| 从日程进一步拿 AI 智能纪要 / 逐字稿 / 妙记产物 | 先 `+meeting``meeting_id`,再 [`vc +detail`](../lark-vc/references/lark-vc-detail.md) → [`note +detail`](../lark-note/references/lark-note-detail.md) / [`minutes +detail`](../lark-minutes/references/lark-minutes-detail.md) |
| 预约/改约日程、添加/移除参会人、添加/更换会议室、调整时间 | 先判断新建 vs 编辑,再进入 [schedule-meeting 工作流](references/lark-calendar-schedule-meeting.md) |
## 任务类型分流

View File

@@ -0,0 +1,40 @@
# calendar +meeting
通过日程 ID`event_id` 获取关联的视频会议信息(`meeting_id``meeting_note`)。只读。
## 命令
```bash
# 单个 / 批量(逗号分隔,最多 50 个)
lark-cli calendar +meeting --event-ids <event_id1>,<event_id2>
# 默认使用主日历,需要时显式传 --calendar-id
lark-cli calendar +meeting --event-ids <event_id> --calendar-id <calendar_id>
```
## 输出字段
| 字段 | 说明 |
|------|------|
| `event_id` | 日程 ID |
| `meeting_id` | 关联的视频会议 ID |
| `meeting_note` | 用户主动绑定到日程的纪要文档 Token`MeetingNotes`,由用户在日程页手动添加;)。**与会中产生的 AI 智能纪要 `note_doc_token` 是两份不同文档**,要拿 AI 纪要请继续走 `vc +detail``note +detail`。 |
## 下游链路
`calendar +meeting` 只把日程 ID 翻译为 `meeting_id` / `meeting_note`要拿会中产生的产物AI 智能纪要、逐字稿、妙记)需继续调用:
```bash
# 1. meeting_id → note_id + minute_token同一会议两份产物可能各自为空
lark-cli vc +detail --meeting-ids <meeting_id>
# 2a. note_id → 纪要文档 tokennote_doc_token / verbatim_doc_token / shared_doc_tokens
lark-cli note +detail --note-id <note_id>
# 2b. minute_token → 妙记 AI 产物(按需获取,不传不返回任何 AI 内容)
lark-cli minutes +detail --minute-tokens <minute_token> --summary --todo --chapter --keyword --transcript
# 3. 任意文档 tokenmeeting_note / note_doc_token / verbatim_doc_token / shared_doc_token→ 正文
lark-cli docs +fetch --api-version v2 --doc <doc_token> --doc-format markdown
```

View File

@@ -0,0 +1,29 @@
# calendar +search-event
按关键词、时间范围和参会人搜索日历日程。只读。
## 命令
```bash
# 按关键词
lark-cli calendar +search-event --query "周会"
# 按时间范围ISO 8601 或 YYYY-MM-DD
lark-cli calendar +search-event --start "2026-04-20T00:00:00+08:00" --end "2026-04-27T23:59:59+08:00"
# 按参会人(自动识别 ou_ 用户 / oc_ 群聊 / omm_ 会议室前缀)
lark-cli calendar +search-event --attendee-ids "ou_user1,oc_chat1,omm_room1"
# 组合
lark-cli calendar +search-event --query "周会" --start 2026-04-20 --end 2026-04-27 --attendee-ids "ou_user1"
```
## 输出字段
`items` 列表每条返回 `event_id` / `summary` / `start` / `end` / `is_all_day` / `app_link`;外层有 `has_more``page_token`。**仅返回基础字段,要拿日程详情用 `calendar events get`。**
## 注意事项
- 分页:`has_more=true` 时持续用 `page_token` 翻页直到 false不要遗漏`page-size` 最大 30。
- 已结束的会议优先用 `vc +search`——日历不收录"即时会议",只查日程会漏。

View File

@@ -1,7 +1,7 @@
---
name: lark-minutes
version: 1.0.0
description: "飞书妙记:搜索妙记列表、查看妙记基础信息、下载妙记音视频文件、上传音视频生成妙记、更新妙记标题、替换说话人。当需要获取、操作或者生成妙记时使用。也支持将本地音视频文件转成纪要逐字稿优先使用本 skill不要用 ffmpeg/whisper 本地转写。不负责:获取会议关联妙记,或仅按自然语言标题定位纪要"
description: "飞书妙记:搜索妙记、查看妙记基础信息、下载/上传音视频、读取或编辑妙记的产物内容、改标题、替换说话人/关键词。当给出minute_token、本地音视频文件要查/改/转妙记产物时使用;本地音视频纪要/逐字稿优先本 skill不要用 ffmpeg/whisper 本地转写。不负责:获取会议关联妙记,或仅按自然语言标题定位纪要"
metadata:
requires:
bins: ["lark-cli"]
@@ -27,27 +27,34 @@ metadata:
| Shortcut | 说明 |
|----------|------|
| [`+search`](references/lark-minutes-search.md) | 按关键词、所有者、参与者、时间范围搜索妙记 |
| [`+detail`](references/lark-minutes-detail.md) | 查询妙记详情(标题和关联的纪要note_id),按需获取 AI 产物(总结、待办、章节、逐字稿、关键词) |
| [`+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不支持姓名 |
| `+word-replace` | 批量替换逐字稿关键词(详见 `lark-cli minutes +word-replace --help` |
| [`+summary`](references/lark-minutes-summary.md) | 替换妙记 AI 总结全文 |
| [`+todo`](references/lark-minutes-todo.md) | 新建/更新/删除妙记 AI 待办(单条或 `--todos` 批量;不是 lark-task |
- 使用任何 Shortcut 前,必须先读其对应 reference 文档。
## 意图路由
| 用户意图 | 路由到 |
|----------|--------|
| "我的妙记""搜索妙记""妙记列表" | 本 skill`+search` |
| "这个妙记的标题/时长/封面/链接" | 本 skill`minutes get` |
| "下载妙记视频/音频" | 本 skill`+download` |
| "把音视频转妙记/上传文件生成妙记" | 本 skill`+upload` |
| "重命名妙记/改妙记标题" | 本 skill`+update` |
| "替换说话人/把 A 的发言改成 B" | 本 skill`+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` |
| 用户同时提到"会议/开会"和"妙记" | 先 [lark-vc](../lark-vc/SKILL.md)`+search``+recording`),再本 skill |
| 用户意图 | 命令 |
|---------|------|
| 我的妙记 / 搜索妙记 / 某段时间的妙记 | `+search` |
| 妙记基础信息:标题 / 时长 / 封面 / 链接 | `minutes get` |
| 下载妙记视频文件、获取媒体下载链接 | `+download`(仅媒体;要妙记内容用 `+detail` |
| 妙记总结 / 章节 / 待办 / 关键词 / 逐字稿 | `+detail --minute-tokens <token>` + 显式产物 flag |
| 基于妙记**提炼/总结/分析/回顾**会议 | `+detail --minute-tokens <token> --transcript`,再独立分析(**禁止照搬 AI 总结** |
| 拿这条妙记关联的纪要文档(`note_doc_token` / `verbatim_doc_token` / `shared_doc_tokens` | `+detail` 取顶层 `note_id` → [`note +detail --note-id`](../lark-note/SKILL.md) |
| 把本地音视频转纪要 / 逐字稿 / 文字稿 | `drive +upload``file_token``+upload` 生成 `minute_url``+detail` 拿产物 |
| 在妙记里增加 / 更改 / 删除 AI 待办 | `+todo`**禁止走 lark-task** |
| 替换妙记的AI 总结 | `+summary` |
| 重命名妙记/改妙记标题 | `+update` |
| 替换说话人/把 A 的发言改成 B/重新归属发言人 | `+speaker-replace` |
| 批量替换逐字稿关键词 | `+word-replace` |
| 用户同时提到"会议/开会"和"妙记" | 先 [lark-vc](../lark-vc/SKILL.md)`+search``+recording`)获取 `minute_token`,再本 skill |
## 核心概念
@@ -58,60 +65,30 @@ metadata:
### 1. 搜索妙记
1. 当用户描述的是"我的妙记""包含某个关键词的妙记""某段时间内的妙记",优先使用 `minutes +search`
2. 仅支持使用关键词、时间段、参与者、所有者等筛选条件搜索妙记记录,对于不支持的筛选条件,需要提示用户
3. 搜索结果存在多条数据时,务必注意分页数据获取,不要遗漏任何妙记记录。
4. 如果是会议的妙记,应优先通过 [lark-vc](../lark-vc/SKILL.md) 定位会议并获取 `minute_token`
5. 会议场景的妙记路由,以及"参与的妙记"如何解释,统一以 [minutes +search](references/lark-minutes-search.md) 为准。
1. 如果是会议的妙记,应优先通过 [lark-vc](../lark-vc/SKILL.md) 定位会议并获取 `minute_token`
2. 会议场景的妙记路由,以及"参与的妙记"如何解释,统一以 [minutes +search](references/lark-minutes-search.md) 为准
### 2. 查看妙记基础信息
1. 当用户只需要确认某条妙记的标题、封面、时长、所有者、URL 等基础信息时,使用 `minutes minutes get`
2. 如果用户给的是妙记 URL应先从 URL 末尾提取 `minute_token`,再调用 `minutes minutes get`
3. 如果是会议 / 日程上下文中的妙记基础信息,先通过 VC 链路拿到 `minute_token`,再调用 `minutes minutes get`
4. 用户意图不明确时,默认先给基础元信息,帮助确认是否命中目标妙记。
2. 如果是会议 / 日程上下文中的妙记基础信息,先通过 VC/Calendar 链路拿到 `minute_token`,再调用 `minutes minutes get`
3. 用户意图不明确时,默认先给基础信息,帮助确认是否命中目标妙记
> 使用 `lark-cli schema minutes.minutes.get` 可查看完整返回值结构。核心字段包含:`title`(标题)、`cover`(封面 URL、`duration`(时长,毫秒)、`owner_id`(所有者 ID、`url`(妙记链接)。
### 3. 下载妙记音视频文件
### 3. 上传音视频文件生成妙记(并可继续获取纪要 / 逐字稿)
1. 下载妙记音视频文件到本地,或获取有效期 1 天的下载链接。详见 [minutes +download](references/lark-minutes-download.md)
2. `+download` 只负责音视频媒体文件。用户需要逐字稿、总结、待办、章节等纪要内容时,请使用 [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md)。
3. 用户只想拿可分享的下载地址时,使用 `--url-only`;用户要落地到本地文件时,直接下载。
4. 未显式指定路径时,文件默认落到 `./minutes/{minute_token}/<server-filename>`,与 `vc +notes` 的逐字稿共享同一目录便于聚合。
> **注意**`+download` 只负责音视频媒体文件。如果用户需要的是逐字稿、总结、待办、章节等纪要内容,请使用 [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md)。
### 4. 读取妙记的逐字稿、总结、待办、章节(只读)
1. 当用户要**查看 / 读取**"这个妙记的逐字稿""总结""待办""章节"时,使用 [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md)。
2. 如果当前上下文中已有 `minute_token`,可直接传给 `vc +notes`;如果只有妙记 URL先提取 `minute_token`
3. 如果用户给的是**本地音视频文件**,但目标是"转成纪要""转成逐字稿""转成文字稿""转成撰写文字",应先按下文第 5 节上传文件生成妙记,再把返回的 `minute_url` 提取成 `minute_token`,继续调用 `vc +notes --minute-tokens`
4. 用户如果直接给出本地文件名或路径,并要求"转逐字稿""转文字稿""整理成撰写文字",这也是本 skill 的明确触发信号。
```bash
# 通过 minute_token 获取纪要产物(逐字稿、总结、待办、章节)
lark-cli vc +notes --minute-tokens <minute_token>
```
> **跨 skill 路由**逐字稿、AI 总结、待办、章节等纪要内容由 [lark-vc](../lark-vc/SKILL.md) 的 `+notes` 命令提供
> **读 vs 写**`vc +notes` 只负责**读取** AI 产物。用户要**新建 / 修改 / 删除**妙记内的 AI 待办或替换 AI 总结,见下文第 6 节,**不要**走 [lark-task](../lark-task/SKILL.md)。
### 5. 上传音视频文件生成妙记(并可继续获取纪要 / 逐字稿)
1. 当用户需要通过上传本地音视频文件来生成妙记时使用。
2. 当用户说"把音视频文件转成纪要""把录音转成逐字稿/文字稿/撰写文字""把 mp4/mp3 转成总结/待办/章节"时,也先走这个入口。
3. **处理流程**
1. 当用户说"把音视频文件转成纪要""把录音转成逐字稿/文字稿/撰写文字""把 mp4/mp3 转成总结/待办/章节"时,也先走这个入口
2. **处理流程**
- **上传音视频获取 `file_token`**:使用 [`lark-cli drive +upload`](../lark-drive/references/lark-drive-upload.md) 上传本地文件到云空间(云盘/云存储)并获取 `file_token`
- **生成妙记**:获取到 `file_token` 后,调用 [`lark-cli minutes +upload`](references/lark-minutes-upload.md) 将文件转换为妙记并获取 `minute_url` 链接。
- **继续获取纪要 / 逐字稿(按需)**:如果用户目标不是只要妙记链接,而是要纪要、逐字稿、总结、待办或章节,则从 `minute_url` 中提取 `minute_token`,再调用 [`lark-cli vc +notes --minute-tokens`](../lark-vc/references/lark-vc-notes.md) 获取对应产物。
- **继续获取纪要 / 逐字稿(按需)**:如果用户目标不是只要妙记链接,而是要纪要、逐字稿、总结、待办或章节,则从 `minute_url` 中提取 `minute_token`,再调用 [`lark-cli minutes +detail --minute-tokens`](references/lark-minutes-detail.md) 获取对应产物。
> **注意**:必须先获取飞书云空间(云盘/云存储)的 `file_token` 才能进行转换。
>
> **不要误走本地转写工具**:当用户目标是把本地音视频文件转成纪要、逐字稿、文字稿、撰写文字时,不要改用 `ffmpeg`、`whisper` 或其他本地 ASR/转码命令;标准路径就是 `drive +upload -> minutes +upload -> vc +notes --minute-tokens`。
> **不要误走本地转写工具**:当用户目标是把本地音视频文件转成纪要、逐字稿、文字稿、撰写文字时,不要改用 `ffmpeg`、`whisper` 或其他本地 ASR/转码命令;标准路径就是 `drive +upload -> minutes +upload -> minutes +detail --minute-tokens`。
### 6. 编辑妙记的 AI 待办与 AI 总结(写入)
### 5. 编辑妙记的 AI 待办与 AI 总结(写入)
当用户要在**某条妙记内**操作 AI 待办或 AI 总结时使用本节。**不是**飞书任务Task清单里的待办。
@@ -141,73 +118,44 @@ lark-cli minutes +todo --minute-token <token> --as user --todos '[
]'
```
**更新 / 删除前**:先用 `vc +notes --minute-tokens <token>` 读取 `todos[].todo_id`(按 `content` 匹配目标条目;列表顺序不保证稳定,**不要**用"第 2 条"代替 `todo_id`)。
**更新 / 删除前**:先用 `minutes +detail --minute-tokens <token> --todo` 读取 `todos[].todo_id`(按 `content` 匹配目标条目;列表顺序不保证稳定,**不要**用"第 2 条"代替 `todo_id`)。
**无编辑权限**:若 CLI 返回 `error.type=no_edit_permission`,表示对**这条妙记**没有编辑权,应请所有者授权;**不要**误走 `auth login --scope`
**逐字稿关键词替换无命中**`minutes +word-replace` 时,若 CLI 返回 `error.type=words_not_found`,表示传入的 `source_word` 在该妙记逐字稿中**一个都没匹配到**,未做任何替换。这是**参数问题不是权限问题**:先用 `vc +notes --minute-tokens <token>` 读取当前逐字稿,核对 `source_word` 的精确写法与大小写后重试。
**逐字稿关键词替换无命中**`minutes +word-replace` 时,若 CLI 返回 `error.type=words_not_found`,表示传入的 `source_word` 在该妙记逐字稿中**一个都没匹配到**,未做任何替换。这是**参数问题不是权限问题**:先用 `minutes +detail --minute-tokens <token> --transcript` 读取当前逐字稿,核对 `source_word` 的精确写法与大小写后重试。
**替换 AI 总结全文**:见 [minutes +summary](references/lark-minutes-summary.md)。
> 使用 `+todo` 前必须阅读 [references/lark-minutes-todo.md](references/lark-minutes-todo.md);使用 `+summary` 前必须阅读 [references/lark-minutes-summary.md](references/lark-minutes-summary.md)。
## 资源关系
## 行为规则
```text
Minutes (妙记) ← minute_token 标识
├── Metadata (标题、封面、时长、owner、url) → minutes minutes get
└── MediaFile (音频/视频文件) → minutes +download
### 1. `+detail` 必须显式声明产物 flag
不传 `--summary` / `--todo` / `--chapter` / `--keyword` / `--transcript` 时只返回基础信息(含顶层 `note_id`AI 产物字段一律不返回。即使产物为空也会返回空值字段,便于程序化处理。
```bash
# 拿全产物
lark-cli minutes +detail --minute-tokens <token> --summary --todo --chapter --keyword --transcript
```
> **能力边界**`minutes` 负责 **搜索妙记、查看基础元信息、下载/上传音视频、编辑妙记 AI 待办与 AI 总结、重命名、逐字稿说话人/关键词替换**。
>
> **路由规则**
>
> - 用户说"妙记列表 / 搜索妙记 / 某个关键词的妙记" → `minutes +search`
> - 用户只是想看"我的妙记 / 某段时间内的妙记 / 妙记列表",不要先走 [lark-vc](../lark-vc/SKILL.md),而应直接使用本 skill
> - 用户如果同时提到"会议 / 会 / 开会 / 某场会",即使也提到了"妙记",也应优先走 [lark-vc](../lark-vc/SKILL.md) 先定位会议,再通过 [vc +recording](../lark-vc/references/lark-vc-recording.md) 获取 `minute_token`
> - 用户如果要的是妙记基础信息,拿到 `minute_token` 后用 `minutes minutes get`;用户如果要**读取**逐字稿、文字稿、撰写文字、总结、待办、章节,再走 `vc +notes --minute-tokens`
> - “我的妙记”“参与的妙记”等自然语言映射细则,以 [minutes +search](references/lark-minutes-search.md) 为准
> - 结果有多页时,使用 `page_token` 持续翻页,直到确认没有更多结果
> - `minutes +search` 单次最多返回 `200` 条;结果总数没有固定上限
> - 用户说"这个妙记的标题 / 时长 / 封面 / 链接" → `minutes minutes get`
> - 用户说"下载这个妙记的视频 / 音频 / 媒体文件" → `minutes +download`
> - 用户要**读取**"这个妙记的逐字稿 / 文字稿 / 撰写文字 / 总结 / 待办 / 章节" → [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md)
> - 用户要在**妙记内新建 / 修改 / 删除 AI 待办**含「妙记里加待办」「任务1 已完成」等)→ [`minutes +todo`](references/lark-minutes-todo.md)**禁止**走 lark-task
> - 用户要**替换妙记 AI 总结全文** → [`minutes +summary`](references/lark-minutes-summary.md)
> - 用户说"通过文件生成妙记 / 把音视频转妙记" → 先上传获取 `file_token`,然后使用 `minutes +upload`
> - 用户说"把音视频文件转成纪要 / 逐字稿 / 文字稿 / 撰写文字 / 总结 / 待办 / 章节" → 先上传获取 `file_token`,调用 `minutes +upload` 生成 `minute_url`,再提取 `minute_token` 走 `vc +notes --minute-tokens`
> - 用户说"重命名妙记 / 改妙记标题 / 修改妙记名字" → `minutes +update`
> - 用户说"替换说话人 / 把 A 的发言改成 B / 重新归属发言人" → `minutes +speaker-replace`
> - 用户说"批量替换逐字稿关键词" → `minutes +word-replace`
>
> **Note 域边界(禁止规则)**`minute_token` 是妙记文件标识,**不是** `note_id`。
> - 不要把 `minute_token` 传给 `note +detail` 或 `note +transcript`。
> - 已有 `minute_token` 且要读取纪要产物时,先走 [lark-vc](../lark-vc/SKILL.md);只有自然语言纪要标题时不要从 Minutes 反查。
### 2. "提炼 / 总结"必须基于 Transcript不要照搬 AI 总结
## Shortcuts推荐优先使用
AI 总结是模型对会议的二次压缩,可能遗漏争论过程和隐含决策。用户要求"提炼"或"重新总结"时,期望基于原始发言独立分析,而非搬运 AI 产物。**优先 `--transcript`,再独立写结论**。
Shortcut 是对常用操作的高级封装(`lark-cli minutes +<verb> [flags]`)。有 Shortcut 的操作优先使用。
### 3. 从妙记反查纪要:不绕 lark-vc
| Shortcut | 说明 |
| -------------------------------------------------- | --------------------------------------------------------------- |
| [`+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) |
| [`+summary`](references/lark-minutes-summary.md) | Replace the full AI summary text of a minute |
| [`+todo`](references/lark-minutes-todo.md) | Add, update, or delete **AI todo(s) inside a minute** (single or batch via `--todos`; not Feishu Task) |
`minutes +detail` 顶层直接返回 `note_id`(仅在该妙记关联纪要时存在)。不需要绕回 [lark-vc](../lark-vc/SKILL.md),直接:
- 使用 `+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不支持姓名
- 使用 `+summary` 命令时,必须阅读 [references/lark-minutes-summary.md](references/lark-minutes-summary.md),了解全文替换参数。
- 使用 `+todo` 命令时,必须阅读 [references/lark-minutes-todo.md](references/lark-minutes-todo.md),了解单条与 `--todos` 批量模式;**不要**用 lark-task。
```bash
# 1) 取 note_id顶层 .minutes[0].note_id
lark-cli minutes +detail --minute-tokens <minute_token> --format json
# 2) 用上一步拿到的 note_id 读纪要 token
lark-cli note +detail --note-id <note_id> # 拿 note_doc_token / verbatim_doc_token / shared_doc_tokens
```
顶层无 `note_id` 字段即代表无关联纪要,到此为止——不要继续尝试用 `minute_token``note_id`
<!-- AUTO-GENERATED-START — gen-skills.py 管理,勿手动编辑 -->
## API Resources
@@ -223,7 +171,8 @@ lark-cli minutes <resource> <method> [flags]
## 不在本 skill 范围
- 已有 `minute_token` 的纪要/逐字稿/总结/待办/章节内容获取 → [lark-vc](../lark-vc/SKILL.md)`vc +notes --minute-tokens`
- 只有自然语言纪要标题的逐字稿查询 → 文档搜索 / Docx 正文读取;有显式 `vc-node-id` 才进入 [lark-note](../lark-note/SKILL.md)
- 搜索历史会议记录 → [lark-vc](../lark-vc/SKILL.md)
- 查询未来的会议日程 → [lark-calendar](../lark-calendar/SKILL.md)
- 搜索历史会议记录、查参会人快照 → [lark-vc](../lark-vc/SKILL.md)
- 未来日程 / 日历查询 → [lark-calendar](../lark-calendar/SKILL.md)
- 已知 `note_id` 直接读纪要详情 → [lark-note](../lark-note/SKILL.md)
- 飞书任务清单(个人 Todo / 共享清单) → [lark-task](../lark-task/SKILL.md)
- 只有自然语言纪要标题、没有 `minute_token` / 妙记 URL / 本地音视频时定位逐字稿 → 文档搜索([lark-drive](../lark-drive/SKILL.md) / [lark-doc](../lark-doc/SKILL.md)

View File

@@ -0,0 +1,62 @@
# minutes +detail
通过 `minute_token` 查询妙记详情,按需获取 AI 产物(总结/待办/章节/逐字稿/关键词)。只读。
> `--summary` / `--todo` / `--chapter` / `--keyword` / `--transcript` 至少一个;不传任何产物 flag 时只返回基础信息(如 `title`AI 产物字段都不会出现。一次性获取所有产物:`--summary --todo --chapter --keyword --transcript`。
## 命令
```bash
# 仅基础信息
lark-cli minutes +detail --minute-tokens obcxxxxxxxxxx
# 批量(逗号分隔,最多 50 个)
lark-cli minutes +detail --minute-tokens obcxxx,obcyyy --summary --todo
# 全产物
lark-cli minutes +detail --minute-tokens obcxxx --summary --todo --chapter --keyword --transcript
# 仅逐字稿,覆盖已有文件
lark-cli minutes +detail --minute-tokens obcxxx --transcript --overwrite
```
## 输出
`minutes` 数组每条含 `minute_token``title``note_id``artifacts``note_id` 仅在该妙记关联了会议纪要时返回,可直接传给 [`note +detail`](../../lark-note/references/lark-note-detail.md) 拿纪要文档 token无需再绕回 `vc +detail``artifacts` 中**只包含本次请求的产物**
| 字段 | 类型 | 说明 |
|------|------|------|
| `artifacts.summary` | string | AI 总结。 |
| `artifacts.todos` | array | 待办事项列表。 |
| `artifacts.chapters` | array | 章节列表。 |
| `artifacts.keywords` | array | 关键词列表。 |
| `artifacts.transcript_file` | string | 逐字稿本地文件路径。 |
逐字稿默认落地 `./minutes/{minute_token}/transcript.txt`,与 `minutes +download` 同目录便于聚合。
## minute_token 来源
| 来源 | 取值字段 |
|------|---------|
| 妙记 URL `https://*.feishu.cn/minutes/obcxxx` | 截路径最后一段 `obcxxx` |
| `vc +detail --meeting-ids` | `minute_token` |
| `vc +recording --meeting-ids` | `minute_token` |
| `minutes +search` | `minute_token` |
## 典型链路:从 minute_token 拿纪要文档 token
只持有 `minute_token`(如妙记 URL 入口),又想拿 AI 智能纪要 / 逐字稿文档时:
```bash
# 1. 取妙记关联的 note_id没有关联会议纪要则为空
lark-cli minutes +detail --minute-tokens <minute_token>
# 2. 用 note_id 拿 note_doc_token / verbatim_doc_token / shared_doc_tokens
lark-cli note +detail --note-id <note_id>
# 3. 读纪要 / 逐字稿正文
lark-cli docs +fetch --api-version v2 --doc <note_doc_token> --doc-format markdown
```
> `minute_token` 不要直接传给 `note +detail`:必须先用本命令拿到 `note_id` 再调用 `note +detail`。

View File

@@ -43,7 +43,7 @@ lark-cli minutes +download --minute-tokens obcnxxxxxxxxxxxxxxxxxxxx --dry-run
| `--url-only` | 否 | 仅返回下载链接,不下载文件 |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
> **默认落点**:未指定 `--output` / `--output-dir` 时,文件落到 `./minutes/{minute_token}/<server-filename>`。文件名沿用服务端 Content-Disposition / Content-Type 推断Agent 可从 `saved_path` 字段读取实际路径。同一 minute_token 的录像和 `vc +notes` 的逐字稿默认会落在**同一目录**下,方便聚合。
> **默认落点**:未指定 `--output` / `--output-dir` 时,文件落到 `./minutes/{minute_token}/<server-filename>`。文件名沿用服务端 Content-Disposition / Content-Type 推断Agent 可从 `saved_path` 字段读取实际路径。同一 minute_token 的录像和 `minutes +detail` 的逐字稿默认会落在**同一目录**下,方便聚合。
## 核心约束
@@ -85,7 +85,7 @@ API 限流 5 次/秒,批量下载时需注意控制频率。
| 字段 | 说明 |
|------|------|
| `minute_token` | 妙记 Token用于 Agent 索引) |
| `artifact_type` | 固定为 `"recording"`(与 `vc +notes``"transcript"` 区分) |
| `artifact_type` | 固定为 `"recording"`(与 `minutes +detail``"transcript"` 区分) |
| `saved_path` | 文件保存的本地路径(绝对路径) |
| `size_bytes` | 文件大小(字节) |
@@ -125,13 +125,13 @@ API 限流 5 次/秒,批量下载时需注意控制频率。
## 提示
- 音视频文件可能较大,下载无固定超时限制(由用户 Ctrl+C 控制取消)。
- 默认落点 `./minutes/{minute_token}/``vc +notes` 的逐字稿共享同一目录,方便 Agent 聚合同一会议的所有产物。
- 默认落点 `./minutes/{minute_token}/``minutes +detail` 的逐字稿共享同一目录,方便 Agent 聚合同一会议的所有产物。
- 单 token 模式下 `--output` 若传入已存在目录(如 `--output ./existing-dir`),等价于 `--output-dir`文件落入该目录cp 语义)。
- 批量模式下 `--output` 不接受已存在的文件路径(会报错),应改用 `--output-dir`
- 如需获取妙记的纪要内容逐字稿、AI 总结等),请使用 [vc +notes](../../lark-vc/references/lark-vc-notes.md)。
- 如需获取妙记的纪要内容逐字稿、AI 总结等),请使用 [minutes +detail](lark-minutes-detail.md)。
## 参考
- [lark-minutes](../SKILL.md) — 妙记全部命令
- [lark-vc-notes](../../lark-vc/references/lark-vc-notes.md) — 会议纪要查询
- [lark-minutes-detail](lark-minutes-detail.md) — 妙记详情与 AI 产物查询
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -129,8 +129,6 @@ CLI 会先按输入的本地日历日语义解析,再标准化为 RFC3339 时
2. `vc +recording` 获取 `minute_token`
3. `minutes minutes get` 查询妙记基础信息
不要为了查"妙记信息"直接走 `vc +notes --meeting-ids``vc +notes` 只适用于逐字稿、总结、待办、章节等纪要内容。
<br />
## 时间格式
@@ -173,8 +171,8 @@ lark-cli minutes +search --query "预算复盘" --page-size 20 --page-token '<PA
# 首先查询妙记元信息(标题、时长、封面) → 用本 skill
lark-cli minutes minutes get --params '{"minute_token": "obcn***************"}'
# 查妙记关联的纪要产物:逐字稿、总结、待办、章节等 → 用 lark-cli vc +notes
lark-cli vc +notes --minute-tokens obcn_EXAMPLE_TOKEN
# 查妙记关联的纪要产物:逐字稿、总结、待办、章节等 → 用 lark-cli minutes +detail
lark-cli minutes +detail --minute-tokens obcn_EXAMPLE_TOKEN
```
## 常见错误与排查
@@ -192,7 +190,7 @@ lark-cli vc +notes --minute-tokens obcn_EXAMPLE_TOKEN
- 当用户说“我的妙记”时,优先理解为 `--owner-ids me`
- 当用户说“我参与的妙记”“我参加过的妙记”时,默认理解为 `--owner-ids me``--participant-ids me` 两次查询后的并集。
- 当用户明确说“仅我参与但不是我拥有”时,才优先理解为 `--participant-ids me`
- 当用户同时提到“会议 / 会 / 开会 / 某场会”和“妙记”时,优先先定位会议;如果要的是妙记信息,走 `vc +recording``minutes minutes get`,只有要纪要内容时才走 `vc +notes --minute-tokens`
- 当用户同时提到“会议 / 会 / 开会 / 某场会”和“妙记”时,优先先定位会议;如果要的是妙记信息,走 `vc +recording` 获取 `minute_token``minutes minutes get`,只有要妙记产物内容时才走 `minutes +detail --minute-tokens`
- 必须使用 `--format json` 输出,你更加擅长解析 JSON 数据。
- 排查参数与请求结构时优先使用 `--dry-run`
- 搜索的时间范围最大为 1 个月,如果需要搜索更长时间范围的妙记,需要拆分为多次时间范围为一个月查询。
@@ -200,7 +198,7 @@ lark-cli vc +notes --minute-tokens obcn_EXAMPLE_TOKEN
## 参考
- [lark-minutes](../SKILL.md) -- 妙记相关命令
- [lark-vc-notes](../../lark-vc/references/lark-vc-notes.md) -- 基于 `minute_token` 获取逐字稿、总结、待办、章节等产物
- [lark-minutes-detail](lark-minutes-detail.md) -- 基于 `minute_token` 获取逐字稿、总结、待办、章节等产物
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
- [lark-vc](../../lark-vc/SKILL.md) -- 视频会议全部命令

View File

@@ -40,7 +40,7 @@ lark-cli minutes +summary --minute-token obcnxxxxxxxxxxxxxxxxxxxx --summary @sum
### 1. 先读后写
替换前建议先用 `lark-cli vc +notes --minute-tokens <token>` 读取当前总结,确认 `minute_token` 与待替换内容无误。
替换前建议先用 `lark-cli minutes +detail --minute-tokens <token> --summary` 读取当前总结,确认 `minute_token` 与待替换内容无误。
### 2. Markdown 展示说明
@@ -104,7 +104,7 @@ lark-cli minutes +summary --minute-token obcnxxxxxxxxxxxxxxxxxxxx --summary @sum
|------|---------|
| 妙记 URL | 从 URL 末尾提取,如 `https://sample.feishu.cn/minutes/obcnxxxxxxxxxxxxxxxxxxxx` |
| 妙记搜索 | `lark-cli minutes +search --query "关键词"` |
| 会议产物查询 | `lark-cli vc +notes --minute-tokens <token>` |
| 会议产物查询 | `lark-cli vc +detail --meeting-ids <id>` 拿到 `minute_token`,或 `vc +recording` |
## 常见错误与排查
@@ -118,5 +118,5 @@ lark-cli minutes +summary --minute-token obcnxxxxxxxxxxxxxxxxxxxx --summary @sum
- [lark-minutes](../SKILL.md) — 妙记全部命令
- [minutes +todo](lark-minutes-todo.md) — 替换待办项
- [lark-vc-notes](../../lark-vc/references/lark-vc-notes.md) — 读取总结、待办等 AI 产物
- [minutes +detail](lark-minutes-detail.md) — 读取总结、待办等 AI 产物
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -94,7 +94,7 @@ lark-cli minutes +todo --minute-token obcnxxxxxxxxxxxxxxxxxxxx --operation add -
### 1. 先读后写,待办 id 如何获取
更新 / 删除前先用 `lark-cli vc +notes --minute-tokens <token>` 读取当前待办。返回的每条待办带 `todo_id` 字段。
更新 / 删除前先用 `lark-cli minutes +detail --minute-tokens <token> --todo` 读取当前待办。返回的每条待办带 `todo_id` 字段。
> 待办 id 仅用于程序内部定位,不必展示给用户。
@@ -134,5 +134,5 @@ lark-cli minutes +todo --minute-token obcnxxxxxxxxxxxxxxxxxxxx --operation add -
- [lark-minutes](../SKILL.md)
- [minutes +summary](lark-minutes-summary.md)
- [lark-vc-notes](../../lark-vc/references/lark-vc-notes.md)
- [minutes +detail](lark-minutes-detail.md)
- [lark-shared](../../lark-shared/SKILL.md)

View File

@@ -31,13 +31,13 @@
```
- 命令执行成功后,将返回生成的妙记链接 `minute_url`。
3. **如需纪要 / 逐字稿 / 文字稿 / 撰写文字,继续提取 `minute_token` 调用 `vc +notes`**
3. **如需纪要 / 逐字稿 / 文字稿 / 撰写文字,继续提取 `minute_token` 调用 `minutes +detail`**
- 从返回的 `minute_url` 中提取路径最后一段,得到 `minute_token`。
- 如果用户要的是纪要、逐字稿、文字稿、撰写文字、总结、待办或章节,继续调用:
```bash
lark-cli vc +notes --minute-tokens <minute_token>
lark-cli minutes +detail --minute-tokens <minute_token> --transcript
```
- `vc +notes --minute-tokens` 会返回纪要文档、逐字稿文档,以及 AI 内置产物(总结、待办、章节);必要时还会把逐字稿落地到本地文件。
- `minutes +detail --minute-tokens` 会返回纪要文档、逐字稿文档,以及 AI 内置产物(总结、待办、章节);必要时还会把逐字稿落地到本地文件。
> **异步生成提示**API 会立即返回 `minute_url`,但妙记可能仍在异步生成中,您可以直接通过该妙记链接查看当前的处理状态和转写结果。
@@ -48,7 +48,7 @@
lark-cli minutes +upload --file-token boxcnxxxxxxxxxxxxxxxx
# 通过 minute_token 继续获取纪要 / 逐字稿 / 文字稿 / AI 产物
lark-cli vc +notes --minute-tokens obcnxxxxxxxxxxxxxxxx
lark-cli minutes +detail --minute-tokens obcnxxxxxxxxxxxxxxxx
```
## 参数
@@ -81,9 +81,9 @@ lark-cli vc +notes --minute-tokens obcnxxxxxxxxxxxxxxxx
1. 使用 `lark-cli drive +upload --file <path>` 上传本地音视频文件到云空间(云盘/云存储)
2. 从返回结果中取出 `file_token`
3. 调用 `lark-cli minutes +upload --file-token <file_token>` 生成妙记
4. 如果目标是纪要、逐字稿、文字稿、撰写文字、总结、待办或章节,再从 `minute_url` 提取 `minute_token`,继续调用 `lark-cli vc +notes --minute-tokens <minute_token>`
4. 如果目标是纪要、逐字稿、文字稿、撰写文字、总结、待办或章节,再从 `minute_url` 提取 `minute_token`,继续调用 `lark-cli minutes +detail --minute-tokens <minute_token>`
> **边界说明**`minutes +upload` 本身只负责把文件转成妙记并返回 `minute_url`。纪要内容、逐字稿、文字稿、撰写文字、总结、待办、章节属于后续产物获取,应由 [vc +notes](../../lark-vc/references/lark-vc-notes.md) 承接。
> **边界说明**`minutes +upload` 本身只负责把文件转成妙记并返回 `minute_url`。纪要内容、逐字稿、文字稿、撰写文字、总结、待办、章节属于后续产物获取,应由 [minutes +detail](lark-minutes-detail.md) 承接。
## 输出结果示例

View File

@@ -12,6 +12,11 @@ metadata:
身份:仅使用 `--as user`。使用前阅读 [`../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. 了解不同会议产物的组成部分,以便根据需求决策使用哪种产物的数据
Note 域只接受显式 `note_id`:用户直接提供,或 `docs +fetch --api-version v2` 返回的 `<vc-transcribe-tab vc-node-id="...">` 中的 `vc-node-id`。不要从 `doc_token`、标题、正文或 backlink 反推 `note_id`
## 命令路由
@@ -20,6 +25,9 @@ Note 域只接受显式 `note_id`:用户直接提供,或 `docs +fetch --api-
|---------|------|
| 已知 `note_id`,查纪要类型 / 文档 token | `note +detail --note-id NOTE_ID` |
| `docs +fetch --api-version v2` 返回 `<vc-transcribe-tab vc-node-id="...">` | 取 `vc-node-id` 作为 `NOTE_ID`,先 `note +detail --note-id NOTE_ID` |
| 只持有 `meeting_id` | 先 `vc +detail --meeting-ids <id>``note_id`,再 `note +detail --note-id NOTE_ID` |
| 只持有 `minute_token`(妙记 URL | 先 `minutes +detail --minute-tokens <token>` 顶层取 `note_id`,再 `note +detail --note-id NOTE_ID`(不要把 `minute_token``note_id` |
| 只持有日程 `event_id` | 先 `calendar +meeting --event-ids <id>``meeting_id`,再按上一行继续 |
| 已知 `note_id`,读纪要正文 | `note +detail``docs +fetch --api-version v2 --doc <note_doc_token>` |
| 已知 `note_id`,查 unified 原始记录 / 逐字稿 | `note +transcript --note-id NOTE_ID` |
| 只有自然语言纪要标题,用户要逐字稿 / 原始记录 / 谁说了什么 | 不进本 skill先走文档搜索与 `docs +fetch`,拿到 `vc-node-id` 后再回来 |
@@ -44,7 +52,9 @@ Note 域只接受显式 `note_id`:用户直接提供,或 `docs +fetch --api-
## 不在本 Skill 范围
- 通过 `meeting_id` / `calendar_event_id` / `minute_token` 定位纪要 → [lark-vc](../lark-vc/SKILL.md)。
- 通过 `meeting_id` 定位纪要(`note_id`→ [lark-vc](../lark-vc/SKILL.md)`vc +detail`
- 通过 `minute_token` 定位纪要(`note_id`)→ [lark-minutes](../lark-minutes/SKILL.md)`minutes +detail` 顶层返回 `note_id`)。
- 通过日程 `event_id` 定位会议(`meeting_id`) / 用户绑定纪要(`meeting_note`) → [lark-calendar](../lark-calendar/SKILL.md)`calendar +meeting`)。
- 自然语言纪要标题搜索 → [lark-drive](../lark-drive/SKILL.md) / [lark-doc](../lark-doc/SKILL.md)。
- Docx 正文读取 → [lark-doc](../lark-doc/SKILL.md)。
- 妙记基础信息与媒体文件 → [lark-minutes](../lark-minutes/SKILL.md)。
@@ -55,3 +65,30 @@ Note 域只接受显式 `note_id`:用户直接提供,或 `docs +fetch --api-
|----------|------|
| [`+detail`](references/lark-note-detail.md) | 需要解释输出字段或根据展示类型继续路由 |
| [`+transcript`](references/lark-note-transcript.md) | 需要拉取 unified 原始记录或处理本地输出文件 |
## 核心概念
- **会议纪要Note**:视频会议结束后生成的结构化文档,通过 `note_id` 标识。一个 Note 包含 AI 智能纪要文档、逐字稿文档和会中共享文档。
- **note_id**:纪要的唯一标识符,可通过 `vc +detail --meeting-ids` 获取。
- **AI 智能纪要MainDoc**AI 生成的会议总结与待办,对应 `note_doc_token`
- **逐字稿VerbatimDoc**:会议的逐句发言记录,含说话人和时间戳,对应 `verbatim_doc_token`
- **共享文档SharedDoc**:会中投屏共享的文档,对应 `shared_doc_tokens`
## 核心场景
### 1. 通过 note_id 获取纪要文档 Token
1. 当用户已有 `note_id`,需要获取对应的 `note_doc_token``verbatim_doc_token``shared_doc_tokens` 时,使用 `note +detail`
2. `note_id` 通常来自 `vc +detail` 的返回结果。
3. 获取到文档 Token 后,可使用 `docs +fetch --api-version v2` 读取文档内容,或使用 `drive metas batch_query` 获取文档元信息。
```bash
# 1. 从会议获取 note_id
lark-cli vc +detail --meeting-ids <meeting_id>
# 2. 用 note_id 拿文档 Token
lark-cli note +detail --note-id <note_id>
# 3. 读取纪要文档内容
lark-cli docs +fetch --api-version v2 --doc <note_doc_token> --doc-format markdown
```

View File

@@ -1,9 +1,11 @@
# note +detail
`note +detail` 只做一件事:按显式 `note_id` 返回纪要展示类型和相关文档 token。
通过 `note_id` 查询会议纪要详情,获取下挂文档 TokenAI 智能纪要、逐字稿、会中共享文档)。只读,仅支持 `--as user`
## 命令
```bash
lark-cli note +detail --note-id NOTE_ID --format json
lark-cli note +detail --note-id <note_id>
```
## `note_id` 来源

View File

@@ -1,6 +1,6 @@
# note +transcript
只在 `note +detail``vc +notes` 已确认 `note_display_type=unified` 时使用。普通纪要逐字稿是独立 Docx 文档,应回到 [lark-doc](../../lark-doc/SKILL.md) 读取 `verbatim_doc_token`
只在 `note +detail` 已确认 `note_display_type=unified` 时使用。普通纪要逐字稿是独立 Docx 文档,应回到 [lark-doc](../../lark-doc/SKILL.md) 读取 `verbatim_doc_token`
```bash
lark-cli note +transcript --note-id NOTE_ID

View File

@@ -1,31 +1,168 @@
---
name: lark-shared
version: 1.1.0
description: "lark-cli 通用规则user/bot 身份、认证授权、安全与高风险确认门禁。当首次配置 lark-cli、需要 auth login、遇到权限或 scope 错误、命令以退出码 10 要求确认、或输出包含 _notice 升级提示时使用。"
version: 1.0.0
description: "Use when first setting up lark-cli, running auth login, switching user/bot identity (--as), handling permission denied or scope errors, needing to update lark-cli, or seeing _notice in JSON output."
---
# lark-cli 共享规则
所有 lark-* skill 共享的底座lark-cli 的身份、认证、安全与高风险操作通用规则
本技能指导你如何通过lark-cli操作飞书资源, 以及有哪些注意事项
## 通用准则
## 配置初始化
1. **调用前先懂用法**:执行 shortcut 前先读对应 reference 或跑 `-h` 弄懂用法,别猜 flag 盲调
首次使用需运行 `lark-cli config init` 完成应用配置
2. **身份决定你代表谁操作**`--as user` 代表用户本人(能看到、也能操作其日历 / 云空间 / 邮箱等个人资源),`--as bot` 代表应用自己(只涉及 bot 的资源,发消息、建文档都归 bot。用 `--as bot` 碰用户资源**可能静默返空**而非报错,别误判成"没有数据"。身份模型与权限恢复 → [`references/lark-shared-identity-and-permissions.md`](references/lark-shared-identity-and-permissions.md)
当你帮用户初始化配置时使用background方式使用下面的命令发起配置应用流程启动后读取输出从中提取授权链接并发给用户
3. **代表用户发起 `auth login` 授权时绝不阻塞**:走 split-flow发起后交还控制权、下一轮再完成别在同一轮阻塞等授权。完整步骤 **执行前必读** → [`references/lark-shared-auth-split-flow.md`](references/lark-shared-auth-split-flow.md)
**URL 转发规则**:当命令输出 `verification_url``verification_uri_complete``console_url` 等 URL 字段时:**必须生成二维码**:你必须调用 `lark-cli auth qrcode` 将 URL 转为二维码并展示给用户,这是必须步骤,不要跳过。优先生成 PNG 二维码(--output仅当用户明确要求时才使用 ASCII--ascii。**URL 输出规则**:将 URL 视为不可修改的 opaque string不要做任何修改包括 URL 编码/解码、添加空格或标点、重新拼接 query二维码和链接请一起展示给用户
4. **授权 / 配置类 URL 必须配二维码**:用 `lark-cli auth qrcode` 生成、URL 在前二维码在后URL 原样不改写。
```bash
# 发起配置(该命令会阻塞直到用户打开链接并完成操作或过期)
lark-cli config init --new
```
5. **退出码 10 是高风险确认门禁,不是错误**:停下、取得用户**显式同意**后才按 `hint` 重试,**绝不**静默加确认 flag 绕过。机制 → [`references/lark-shared-high-risk-approval.md`](references/lark-shared-high-risk-approval.md)。
## 认证
6. **路径参数只接受 cwd 相对路径**:绝对路径会被拒(`unsafe file path`),规划时就用相对路径。
### 身份类型
7. **不输出密钥明文**appSecret、accessToken
两种身份类型,通过 `--as` 切换:
## 其他场景
| 身份 | 标识 | 获取方式 | 适用场景 |
|------|------|---------|---------|
| user 用户身份 | `--as user` | `lark-cli auth login` 等 | 访问用户自己的资源(日历、云空间/云盘/云存储等) |
| bot 应用身份 | `--as bot` | 自动,只需 appId + appSecret | 应用级操作,访问bot自己的资源 |
- 首次配置 lark-cli`config init`)→ [`references/lark-shared-config-init.md`](references/lark-shared-config-init.md)
- 拿到 `/wiki/` 链接或 wiki token → [`references/lark-wiki-token-routing.md`](references/lark-wiki-token-routing.md)
- 输出 `_notice`(升级 / skills 落后 / 废弃命令提示)→ [`references/lark-shared-update-notice.md`](references/lark-shared-update-notice.md)
### 身份选择原则
输出 `[identity: bot/user]` 代表当前身份。bot 与 user 表现差异很大,需确认身份符合目标需求:
- **Bot 看不到用户资源**:无法访问用户的日历、云空间(云盘/云存储)文档、邮箱等个人资源。例如 `--as bot` 查日程返回 bot 自己的(空)日历
- **Bot 无法代表用户操作**:发消息以应用名义发送,创建文档归属 bot
- **Bot 权限**:只需在飞书开发者后台开通 scope无需 `auth login`
- **User 权限**:后台开通 scope + 用户通过 `auth login` 授权,两层都要满足
### 权限不足处理
遇到权限相关错误时,**根据当前身份类型采取不同解决方案**。
错误响应中包含关键信息:
- `permission_violations`:列出缺失的 scope (N选1)
- `console_url`:飞书开发者后台的权限配置链接
- `hint`:建议的修复命令
#### Bot 身份(`--as bot`
将错误中的 `console_url` 原样提供给用户,引导去后台开通 scope。**禁止**对 bot 执行 `auth login`
#### User 身份(`--as user`
```bash
lark-cli auth login --domain <domain> # 按业务域授权
lark-cli auth login --scope "<missing_scope>" # 按具体 scope 授权(推荐,符合最小权限原则)
```
**规则**auth login 必须指定范围(`--domain``--scope`)。多次 login 的 scope 会累积(增量授权)。
#### Agent 代理发起认证(推荐)
当你作为 AI agent 需要帮用户完成认证时,优先使用 split-flow避免在同一轮对话中阻塞等待用户授权
```bash
# 发起授权(立即返回 device_code 和 verification_url
lark-cli auth login --scope "calendar:calendar:readonly" --no-wait --json
```
拿到 `verification_url` 后,将它原样作为本轮最终消息发给用户,并结束本轮/交还控制权。不要在同一轮中展示 URL 后立刻执行 `--device-code` 阻塞轮询;在不透传中间输出的 agent harness 里,这会导致用户永远看不到 URL。
用户回复已完成授权后,再在后续步骤执行:
```bash
lark-cli auth login --device-code <device_code>
```
**Split-Flow 完整步骤**
**第一步:发起授权(当前轮)**
1. 执行 `lark-cli auth login --scope "xxx" --no-wait --json`(必须加 `--no-wait --json`
2. 从 JSON 输出中提取 `verification_url``device_code`
3. 生成二维码:`lark-cli auth qrcode <verification_url> --output "xxx"`
4. 将 URL 和二维码展示给用户(先 URL后二维码
5. **结束本轮对话前,必须明确告知用户**"请完成授权后,回来告诉我已授权完成,我会帮你完成后续步骤"
**第二步:完成授权(后续轮)**
1. 等待用户回复"已完成授权"
2. **由你AI agent亲自执行**`lark-cli auth login --device-code <device_code>`
3. 此命令会轮询授权状态并完成登录
4. 如果返回授权成功,流程结束
**关键规则**
- **你必须亲自执行 `--device-code` 命令**,不要指示用户自行执行
- **不要在同一轮中展示 URL 后立刻执行 `--device-code`**,这会导致用户看不到 URL
- **禁止缓存 `verification_url``device_code`**:每次需要授权时,必须重新执行 `lark-cli auth login --no-wait --json` 生成新的链接。不要将授权链接和 device code 存入上下文供后续复用
## 更新检查
lark-cli 命令执行后如果检测到新版本JSON 输出中会包含 `_notice.update` 字段(含 `message``command` 等)。
**当你在输出中看到 `_notice.update` 时,完成用户当前请求后,主动提议帮用户更新**
1. 告知用户当前版本和最新版本号
2. 提议执行更新(同时更新 CLI 和 Skills
```bash
lark-cli update
```
3. 更新完成后提醒用户:**退出并重新打开 AI Agent** 以加载最新 Skills
**重要**:始终使用 `lark-cli update` 更新,它会同时更新 CLI 和 AI Skills。
**规则**:不要静默忽略更新提示。即使当前任务与更新无关,也应在完成用户请求后补充告知。
## 安全规则
- **禁止输出密钥**appSecret、accessToken到终端明文。
- **写入/删除操作前必须确认用户意图**。
- 用 `--dry-run` 预览危险请求。
- **文件路径只接受相对路径**`--file`、`--output`、`--output-dir`、`@file` 等路径参数只接受 cwd 下的相对路径,传绝对路径会报 `unsafe file path`。数据输入(`@file`、大 JSON优先用 stdin 传入,避免路径和转义问题。
## 高风险操作的审批协议exit 10
lark-cli 对高风险写操作(`risk: "high-risk-write"`)有强制确认门禁。当你不带 `--yes` 调用这类命令时CLI 会退出码 `10`、并在 stderr 返回如下结构化 envelope
```json
{
"ok": false,
"error": {
"type": "confirmation_required",
"message": "drive +delete requires confirmation",
"hint": "add --yes to confirm",
"risk": {
"level": "high-risk-write",
"action": "drive +delete"
}
}
}
```
**遇到这种情况,不要当普通错误放弃。** 按以下流程处理:
1. **识别**:看到子进程 exit code = `10` 且 stderr JSON 里 `error.type == "confirmation_required"`
2. **向用户确认**:把 `error.risk.action` 和关键参数展示给用户,明确告知"这是高风险操作",等待用户显式同意
3. **用户同意** → 在你**原始 argv 的末尾追加 `--yes`** 后重试
4. **用户拒绝** → 终止流程,不要擅自改写参数或跳过门禁
**绝对不允许**
- 看到 exit 10 就默认加 `--yes` 静默重试(这等于禁用门禁)
- 把 `confirmation_required` 当网络错误/权限错误处理
- 在用户没明确同意的前提下追加 `--yes` 重试
- 用 `sh -c` 等 shell 方式拼接命令重试——用 `exec.Command(argv...)` 参数数组形式,避免 shell 解析把用户参数当作语法
提前预判:想先让用户 review 危险操作的具体请求,调用时加 `--dry-run`——它不触发门禁会打印完整请求详情URL / body / params你可以把这个预览给用户看过再去真正执行。
### 如何识别一条命令是高风险
- shortcut`lark-cli <service> +<cmd> --help` 顶部会显示 `Risk: high-risk-write`
- service 命令:`lark-cli schema <service>.<resource>.<method> --format json` 的返回值里 `"risk": "high-risk-write"`

View File

@@ -1,18 +0,0 @@
# Agent 代理发起授权split-flow
帮用户完成 user 身份授权。背景:如果运行环境只把最终消息发给用户、不显示中间命令输出,阻塞式 `auth login` 会让用户永远看不到授权链接,所以把"发起"和"完成"拆到两轮。
## 第一步:发起(当前轮)
1. 执行 `lark-cli auth login --scope "<scope>" --no-wait --json`,从输出提取 `verification_url``device_code`
2.`verification_url` 按正文准则配二维码展示给用户生成二维码、URL 在前、原样不改写)。
3. 明确告知用户"完成授权后回来告诉我",然后交还控制权。**不要**在同一轮接着执行 `--device-code` 阻塞轮询——否则用户看不到链接。
## 第二步:完成(后续轮)
等用户回复已授权,**由你agent亲自执行** `lark-cli auth login --device-code <device_code>`(别让用户自己跑)。该命令轮询授权状态并完成登录,成功即结束。
## 规则
- **禁止缓存 `verification_url` / `device_code`**:每次授权都重新 `--no-wait` 发起拿新值,不要存旧值复用。
- **范围必须显式指定**`--scope`(推荐,最小权限)或 `--domain`;多次 login 的 scope 累积(增量授权)。`--exclude` 排除特定 scope`--recommend` 只请求可自动批准的 scope。

View File

@@ -1,11 +0,0 @@
# 首次配置 lark-cli
首次使用需运行 `lark-cli config init --new` 完成应用配置。
**注意:`config init` 是阻塞命令,没有 `--no-wait`,不要套用 `auth login` 的 split-flow。** 它会一直阻塞到用户在浏览器完成配置或过期。帮用户初始化时,用 background 方式执行命令,启动后读取输出,从中提取授权链接发给用户:
```bash
lark-cli config init --new
```
输出里的授权 URL 按正文准则处理生成二维码、URL 原样不改写)。

View File

@@ -1,58 +0,0 @@
# 确认门禁 envelope 参考exit 10
处理协议见 SKILL.md 正文准则。本文讲报错 JSON 的两种形态、字段位置,以及重试 / 预览的两个坑。
## 可靠信号是退出码 10不是 type 字符串
仓库正从扁平式迁往 typed 式,过渡期两种并存——扁平式仍是 shortcut / service 命令的当前形态多数高风险命令typed 式是已迁移命令(如 `config bind`)的新形态。**别认 `type` 字符串(迁移中会变),认退出码 10**
**扁平式:**
```json
{
"ok": false,
"error": {
"type": "confirmation_required",
"message": "drive +delete requires confirmation",
"hint": "add --yes to confirm",
"risk": { "level": "high-risk-write", "action": "drive +delete" }
}
}
```
**typed 式:**
```json
{
"ok": false,
"error": {
"type": "confirmation",
"subtype": "confirmation_required",
"risk": "high-risk-write",
"action": "config bind --force",
"hint": "若用户确认切换,附加 --force 重新运行:`lark-cli config bind --identity user-default --force`"
}
}
```
识别条件exit code = 10`error` 命中任一形态——`type == "confirmation_required"`(扁平),或 `type == "confirmation" && subtype == "confirmation_required"`typed。只判 `type == "confirmation_required"` 会漏掉 typed 式。
## 字段位置速查
| 信息 | 扁平式 | typed 式 |
|------|--------|----------|
| 操作名 | `error.risk.action` | `error.action` |
| 风险级别 | `error.risk.level``risk` 是对象) | `error.risk`(字符串) |
| 确认 flag | `error.hint` | `error.hint` |
取操作名typed 式看 `error.action`,没有再看扁平式的 `error.risk.action`(哪个有用哪个)。`hint` 是给你看的自然语言提示,里面写明了该加哪个确认 flag扁平式如 "add --yes to confirm" → `--yes``config bind` 的 hint 提示 `--force`)。**提取那个 flag 加到你自己的原始命令上**,别照抄 hint 里的完整示例命令——示例不含用户的原始参数,照抄会丢参数。
## 先预览再执行(可选,不触发门禁)
想让用户先 review 危险请求,调用时加 `--dry-run`它不触发确认门禁会打印完整请求URL / body / params可把预览给用户看过再真正执行。
## 如何预判一条命令是高风险
- shortcut`lark-cli <service> +<cmd> --help` 顶部显示 `Risk: high-risk-write`
- service 命令:`lark-cli schema <service> <resource> <method> --format json` 返回值里 `"risk": "high-risk-write"`schema 同时注入 `yes` 布尔字段标记需确认)。
- 注意:标注 `high-risk-write` ≠ 一定走 exit-10 门禁(如 `lark-cli update` 有 risk 标注但没有 `--yes` flag、不走该门禁。以**实际 exit 10 + envelope** 为准,不要臆造 `--yes`

View File

@@ -1,27 +0,0 @@
# 身份与权限
基本心智模型——`--as` 代表谁操作、`--as bot` 碰用户资源可能静默返空——见 SKILL.md 正文准则。本文补充:身份怎么获得、授权分几层、权限不足时怎么恢复。
## 获取方式与授权层级
- **user 身份**`--as user`):用户通过 `lark-cli auth login` 授权获得。要能访问,需**两层都满足**——后台开通对应 scope + 用户 auth login 授权。
- **bot 身份**`--as bot`):自动,只需 appId + appSecret只需后台开通 scope无需 auth login。
输出里的 `[identity: bot/user]` 是当前身份。
## bot 碰用户资源的失败形态
因命令而异:有的静默返回空结果(如查日程落到 bot 自己的空日历),有的明确报"未登录 / 越权"。**无论哪种,都别把 bot 的结果当成用户的真实数据。**
## 权限 / scope 不足恢复
错误响应中的关键字段:
- 缺失的 scope`permission_violations`(原始 API 错误块,元素形如 `{subject: "<scope>"}`)或 `missing_scopes`CLI 结构化错误,已抽好的 scope 字符串数组)。
- `console_url`:飞书开发者后台的权限配置链接。
- `hint`:建议的修复命令。
按身份分流:
- **Bot 身份**:把 `console_url` 提供给用户(按正文准则配二维码转发),引导去后台开通 scope。**禁止**对 bot 执行 `auth login`,也不要因为 user 报错就降级到 bot 重试。
- **User 身份**:补授权用 `lark-cli auth login --scope "<missing_scope>"`(推荐,最小权限)或 `--domain <domain>`;必须指定其一,多次 login 的 scope 会累积(增量授权)。作为 agent 代发起时走 split-flow见 [`lark-shared-auth-split-flow.md`](lark-shared-auth-split-flow.md)。

View File

@@ -1,9 +0,0 @@
# 升级提示_notice
命令执行后 JSON 输出可能包含 `_notice`,其下三种通知的处置都是升级:
- `update`CLI 有新版本(字段 `current` / `latest` / `message` / `command`)。
- `skills`:内置 AI Skills 落后于 CLI字段 `current` / `target`)。
- `deprecated_command`:本次用了已废弃的命令别名(`replacement` 为新命令名)。
看到任一通知都**不要静默忽略**,即使与当前任务无关:完成用户当前请求后告知情况,主动提议执行 `lark-cli update`(同时更新 CLI 和 AI Skills`--check` 可只检查不安装)。更新完成后提醒用户**退出并重新打开 AI Agent** 以加载最新 Skills。

View File

@@ -55,8 +55,8 @@ metadata:
2. 输入是 **`meeting_id`**(长数字 ID不是 9 位会议号。
3. Bot 必须**真实参会过**(先 `+meeting-join`),否则事件流通常不可见。具体的状态边界、结束后宽限窗口与错误码(如 `10005 / 20001 / 20002`)请查看 `+meeting-events` reference。
4. **不能做会后复盘****不能替代参会人快照查询**。如果会议已结束:
- 拿纪要文档或逐字稿文档 token `lark-cli vc +notes --meeting-ids <meeting.id>`
- 想拿 AI 产物summary / todos / chapters或导出逐字稿文件先用 `lark-cli vc +recording --meeting-ids <meeting.id>` `minute_token`,再用 `lark-cli vc +notes --minute-tokens <minute_token>`
- **优先(拿纪要文档:智能纪要 / 逐字稿 / 共享文档)**:先 `lark-cli vc +detail --meeting-ids <meeting.id>``note_id``minute_token`;再 `lark-cli note +detail --note-id <note_id>``note_doc_token`(智能纪要)/ `verbatim_doc_token`(逐字稿)。
- **备选(拿妙记 AI 产物)**:用上一步的 `minute_token` `lark-cli minutes +detail --minute-tokens <minute_token> --summary --todo --chapter --keyword --transcript`**必须显式指定产物 flag不传则不返回任何产物内容**)。
- 想看参会人快照:用 `vc meeting get --with-participants`(见 [`lark-vc`](../lark-vc/SKILL.md)
5. **默认必须使用** **`--page-all`**,除非用户明确要求“只查一页”,或确实需要控制返回体大小。
6. 输出格式默认优先 `--format pretty`(时间线更易读);只有在需要完整保留原始消息流与结构化字段时,才使用 `--format json`
@@ -84,7 +84,7 @@ MID=$(echo "$JOIN" | jq -r '.data.meeting.id')
lark-cli vc +meeting-events --meeting-id "$MID" --page-all --format pretty
# 3. 会后可选:取纪要 / 逐字稿(跨到 lark-vc
lark-cli vc +notes --meeting-ids "$MID"
lark-cli vc +detail --meeting-ids "$MID"
```
如果用户随后明确要求退出 / 离开 / 结束参会,再单独调用 `lark-cli vc +meeting-leave --meeting-id "$MID"`
@@ -114,7 +114,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli vc +<verb> [flags]`)。
## 延伸
- 查已结束会议、参会人快照、搜索历史会议 → [`lark-vc`](../lark-vc/SKILL.md)
- 会议纪要逐字稿 → [`lark-vc`](../lark-vc/SKILL.md)`+notes`
- 妙记产物AI 总结 / 转写 / 章节)→ [`lark-minutes`](../lark-minutes/SKILL.md)
- 已知 `note_id` 直查会议纪要 / 逐字稿 → [`lark-note`](../lark-note/SKILL.md)(先 `vc +detail``note_id`,再 `note +detail` / `note +transcript`
- 妙记产物AI 总结 / 转写 / 章节 / 待办)→ [`lark-minutes`](../lark-minutes/SKILL.md)(先 `vc +detail``minute_token`,再 `minutes +detail`
- 会后把产物发到群 / 私聊 → [`lark-im`](../lark-im/SKILL.md)
- 认证、身份切换、scope 管理 → [`lark-shared`](../lark-shared/SKILL.md)

View File

@@ -222,7 +222,7 @@ lark-cli vc +meeting-events \
|---------|---------|---------|
| `--meeting-id is required` | 未传入 `--meeting-id` | 传入长数字 `meeting.id` |
| `10005 bot is not in meeting` | bot 从未真实入会该会议;或会议已结束但 bot 从未在会中出现过 | 先 `+meeting-join --meeting-number <9位号>` 真实入会再查;如果会议已经结束且当时 bot 没进过会,本接口也拉不到数据。**如果只是想看参会人快照,改用 `lark-cli vc meeting get --params '{"meeting_id":"<meeting.id>"}' --with-participants`**(不依赖 bot 身份参会) |
| `20001 meeting_status_MEETING_END` | 会议已结束且已超出后端允许的 5 分钟宽限窗口 | 本接口不再适合继续拉取事件。若要拿纪要文档或逐字稿 token `lark-cli vc +notes --meeting-ids <meeting.id>`;若要拿 AI 产物summary / todos / chapters或导出逐字稿文件先用 `lark-cli vc +recording --meeting-ids <meeting.id>` `minute_token`,再用 `lark-cli vc +notes --minute-tokens <minute_token>`;参会人请用 `lark-cli vc meeting get --params '{"meeting_id":"<meeting.id>"}' --with-participants` |
| `20001 meeting_status_MEETING_END` | 会议已结束且已超出后端允许的 5 分钟宽限窗口 | 本接口不再适合继续拉取事件。**会后拉产物分两步走**:① `lark-cli vc +detail --meeting-ids <meeting.id>``note_id``minute_token`;② 优先 `lark-cli note +detail --note-id <note_id>``note_doc_token` / `verbatim_doc_token` / `shared_doc_tokens` 后用 `docs +fetch` 读正文;若用户要妙记 AI 产物summary / todos / chapters / transcript`lark-cli minutes +detail --minute-tokens <minute_token> --summary --todo --chapter --transcript`**必须显式指定 flag不传则不返回产物**;参会人请用 `lark-cli vc meeting get --params '{"meeting_id":"<meeting.id>"}' --with-participants` |
| `20002 meeting not exist` | `meeting_id` 错误,或会议实例当前已不可获取(常见于把 9 位会议号当 meeting_id 传) | 确认传入的是长数字 `meeting_id`,不是 9 位会议号 |
| `HTTP 404` / `HTTP 500` | 服务端当前无法找到或处理该会议实例 | 换一个正在进行且 bot 可见的 meeting_id或排查后端问题 |
@@ -230,8 +230,8 @@ lark-cli vc +meeting-events \
- 这是**会中事件流**查询,不适合拿来搜历史会议记录;搜历史会议请用 `+search`
- 如果会议已经结束,不要卡在 `+meeting-events`
- 想拿纪要文档或逐字稿 token`lark-cli vc +notes --meeting-ids <meeting.id>`
- 想拿 AI 产物summary / todos / chapters或导出逐字稿文件先用 `lark-cli vc +recording --meeting-ids <meeting.id>``minute_token`,再用 `lark-cli vc +notes --minute-tokens <minute_token>`
- 想拿纪要文档或逐字稿 token`lark-cli vc +detail --meeting-ids <meeting.id>`
- 想拿 AI 产物summary / todos / chapters或导出逐字稿文件先用 `lark-cli vc +detail --meeting-ids <meeting.id>``minute_token`,再用 `lark-cli minutes +detail --minute-tokens <minute_token>`
- 事件列表是否完整,取决于 bot 何时入会、何时离会,以及后端当前可见的会中事件范围。对于已结束会议,通常只在**结束后 5 分钟内**、且 bot **曾经在会中**时还能继续拉到事件。
- 查询"谁参加过某会议"请用 `vc meeting get --params '{"meeting_id":"<id>","with_participants":true}'`——这是参会人**快照** API不依赖 bot 是否参会,对已结束会议也可查;**不要** 用 `+meeting-events` 做参会人查询。
@@ -241,7 +241,7 @@ lark-cli vc +meeting-events \
- [lark-vc-agent-meeting-leave](lark-vc-agent-meeting-leave.md) — 用户明确要求时离会
- [lark-vc-search](../../lark-vc/references/lark-vc-search.md) — 搜索历史会议(获取 meeting_id
- [lark-vc-recording](../../lark-vc/references/lark-vc-recording.md) — 查询 minute_token
- [lark-vc-notes](../../lark-vc/references/lark-vc-notes.md) — 获取会议纪要
- [lark-vc-detail](../../lark-vc/references/lark-vc-detail.md) — 获取会议详情
- [lark-vc-agent](../SKILL.md) — Agent 参会能力(本 skill
- [lark-vc](../../lark-vc/SKILL.md) — 视频会议原子域Meeting / Note 等核心概念)
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -103,8 +103,14 @@ lark-cli vc +meeting-join --meeting-number 123456789
# 第 2 步:会议结束后,查询录制(拿到 minute_token
lark-cli vc +recording --meeting-ids <meeting.id>
# 第 3 步:查询会议纪要(总结 / 待办 / 章节 / 逐字稿)
lark-cli vc +notes --meeting-ids <meeting.id>
# 第 3 步:查询会议详情,拿到 note_id 和 minute_token
lark-cli vc +detail --meeting-ids <meeting.id>
# 第 4 步:根据用户诉求拉取实际产物
# 优先:通过 note_id 获取纪要文档 token再用 docs +fetch 读取正文
lark-cli note +detail --note-id <note_id>
# 备选:通过 minute_token 获取妙记产物(必须显式指定要哪些产物)
lark-cli minutes +detail --minute-tokens <minute_token> --summary --todo --chapter --transcript
```
## 常见错误与排查
@@ -129,7 +135,7 @@ lark-cli vc +notes --meeting-ids <meeting.id>
- [lark-vc-agent-meeting-events](lark-vc-agent-meeting-events.md) — 会中事件流
- [lark-vc-search](../../lark-vc/references/lark-vc-search.md) — 搜索历史会议记录
- [lark-vc-recording](../../lark-vc/references/lark-vc-recording.md) — 查询 minute_token
- [lark-vc-notes](../../lark-vc/references/lark-vc-notes.md) — 获取会议纪要
- [lark-vc-detail](../../lark-vc/references/lark-vc-detail.md) — 获取会议详情
- [lark-vc-agent](../SKILL.md) — Agent 参会能力(本 skill
- [lark-vc](../../lark-vc/SKILL.md) — 视频会议原子域Meeting / Note 等核心概念)
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -77,11 +77,17 @@ lark-cli vc +meeting-leave --meeting-id <meeting.id>
如果用户只是要求会议结束后拉录制、纪要或逐字稿,不要先调用 `+meeting-leave`;直接跨到 `lark-vc` 查询会后产物。
```bash
# 第 1 步:会议结束后查询录制
# 第 1 步:会议结束后查询录制minute_token
lark-cli vc +recording --meeting-ids <meeting.id>
# 第 2 步:查询会议纪要
lark-cli vc +notes --meeting-ids <meeting.id>
# 第 2 步:查询会议详情,拿到 note_id 和 minute_token
lark-cli vc +detail --meeting-ids <meeting.id>
# 第 3 步:根据用户诉求继续拉产物
# 优先:通过 note_id 获取纪要文档 token再用 docs +fetch 读正文
lark-cli note +detail --note-id <note_id>
# 备选:通过 minute_token 获取妙记产物(必须显式指定 flag
lark-cli minutes +detail --minute-tokens <minute_token> --summary --todo --chapter --transcript
```
## 常见错误与排查
@@ -104,7 +110,7 @@ lark-cli vc +notes --meeting-ids <meeting.id>
- [lark-vc-agent-meeting-events](lark-vc-agent-meeting-events.md) — 会中事件流
- [lark-vc-search](../../lark-vc/references/lark-vc-search.md) — 搜索历史会议(获取 meeting_id
- [lark-vc-recording](../../lark-vc/references/lark-vc-recording.md) — 查询 minute_token
- [lark-vc-notes](../../lark-vc/references/lark-vc-notes.md) — 获取会议纪要
- [lark-vc-detail](../../lark-vc/references/lark-vc-detail.md) — 获取会议详情
- [lark-vc-agent](../SKILL.md) — Agent 参会能力(本 skill
- [lark-vc](../../lark-vc/SKILL.md) — 视频会议原子域Meeting / Note 等核心概念)
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -24,18 +24,18 @@ metadata:
```bash
# BAD — 查昨天的会议用 calendar会漏掉即时会议
lark-cli calendar events search_event --query "站会" --start-time ...
lark-cli calendar +search-event --query "站会" --start <start_time> --end <end_time>
# GOOD — 查已结束的会议用 vc +search
lark-cli vc +search --query "站会" --start-time ...
lark-cli vc +search --query "站会" --start <start_time> --end <end_time>
```
## Shortcuts (推荐优先使用)
| Shortcut | 说明 |
|----------|------|
| [`+search`](references/lark-vc-search.md) | 搜索历史会议记录(需至少一个筛选条件) |
| [`+notes`](references/lark-vc-notes.md) | 查询会议纪要和妙记产物(通过 meeting-idsminute-tokens 或 calendar-event-ids |
| [`+search`](references/lark-vc-search.md) | 搜索历史会议记录(需至关键词、时间范围、组织者、参与者、会议室少一个筛选条件) |
| [`+detail`](references/lark-vc-detail.md) | 通过 meeting-ids 获取会议详情,包括 note_id 和 minute_token |
| [`+recording`](references/lark-vc-recording.md) | 通过 meeting-ids 或 calendar-event-ids 查询 minute_token |
- 使用任何 Shortcut 前,必须先读其对应 reference 文档。
@@ -49,7 +49,8 @@ lark-cli vc +search --query "站会" --start-time ...
| 查"今天有哪些会议" | `vc +search`(已结束)+ lark-calendar未开始合并展示 |
| 只按自然语言标题查"xx 纪要的逐字稿 / 原始记录 / 谁说了什么" | 先到 [lark-drive](../lark-drive/SKILL.md) / [lark-doc](../lark-doc/SKILL.md);仅在已拿到 `note_id` / `vc-node-id` 后再到 [lark-note](../lark-note/SKILL.md) |
| Agent 真实入会/离会、会中实时事件 | [lark-vc-agent](../lark-vc-agent/SKILL.md) |
| 本地音视频文件转纪要/逐字稿 | 先走 [lark-minutes](../lark-minutes/SKILL.md) 上传,再回 `vc +notes --minute-tokens` |
| 妙记信息/时长/封面/链接 | 先走 `vc +detail` 获取 `minute_token`,再用 [lark-minutes](../lark-minutes/SKILL.md) `minutes get` |
| 本地音视频文件转纪要/逐字稿 | 先走 [lark-minutes](../lark-minutes/SKILL.md) 上传,再用 `minutes +detail --minute-tokens` |
## 核心概念
@@ -57,7 +58,7 @@ lark-cli vc +search --query "站会" --start-time ...
- **会议纪要Note**:视频会议结束后生成的结构化文档,通过 `note_id` 标识,包含纪要文档(总结、待办)和逐字稿文档。`note_display_type` 区分**普通纪要(`normal`**和 **unified 纪要**;已知 `note_id` 的直查与 unified 原始记录请用 [lark-note](../lark-note/SKILL.md)。
- **妙记Minutes**:来源于飞书视频会议的录制产物或用户上传的音视频文件,支持视频/音频的转写,包含总结、待办、章节和文字记录,通过 minute_token 标识。
- **纪要文档MainDoc**AI 智能纪要的主文档,包含 AI 生成的总结和待办,对应 `note_doc_token`
- **用户会议纪要MeetingNotes**:用户主动绑定到会议的纪要文档,对应 `meeting_notes`通过 `--calendar-event-ids` 路径返回
- **用户会议纪要MeetingNotes**:用户主动绑定到日程的纪要文档,对应 `meeting_note`需先通过 [`calendar +meeting`](../lark-calendar/references/lark-calendar-meeting.md) 由 `event_id` 获取
- **逐字稿VerbatimDoc**:会议的逐句文字记录,包含说话人和时间戳。
## 产物选择决策
@@ -70,7 +71,7 @@ lark-cli vc +search --query "站会" --start-time ...
| 直接看 AI 总结结果 | AI 纪要(`note_doc_token` | — |
| 谁说了什么/完整发言记录 | 原始对话记录(按下方逐字稿路由取得) | — |
> **逐字稿路由**:先 `vc +notes` 返回的 `note_display_type`,不要只看 `verbatim_doc_token` 是否为空。具体路由以 [`+notes`](references/lark-vc-notes.md) 和 [lark-note](../lark-note/SKILL.md) 为准。
> **逐字稿路由**:先 `vc +detail` 拿到 `note_id`,再 [`note +detail`](../lark-note/SKILL.md) 看 `note_display_type`**不要只看 `verbatim_doc_token` 是否为空**。具体路由以 [lark-note](../lark-note/SKILL.md) 的 `note_display_type` 规则为准。
>
> **为什么"提炼/总结"必须从原始对话记录出发?** AI 纪要是模型对会议的二次压缩,可能遗漏讨论细节、争论过程和隐含决策。用户要求"提炼"或"重新总结"时,期望的是基于原始对话的独立分析,而非对 AI 产物的重新排版。
@@ -98,15 +99,15 @@ lark-cli docs +fetch --api-version v2 --doc <note_doc_token> --doc-format markdo
# 并非所有纪要都有封面画板,没有 <whiteboard> 标签时跳过即可
lark-cli docs +media-download --type whiteboard --token <whiteboard_token> --output ./minutes/<minute_token>/cover
```
> **产物目录规范**:同一会议的所有下载产物(录像、逐字稿、封面图等)统一放到 `./minutes/{minute_token}/` 目录下。这与 `minutes +download` 和 `vc +notes --minute-tokens` 的默认落点保持一致,便于 Agent 聚合。显式路径(如封面图)需手动对齐到同一目录。
> **产物目录规范**:同一会议的所有下载产物(录像、逐字稿、封面图等)统一放到 `./minutes/{minute_token}/` 目录下。这与 `minutes +download` 和 `minutes +detail --minute-tokens` 的默认落点保持一致,便于 Agent 聚合。显式路径(如封面图)需手动对齐到同一目录。
> **纪要相关文档 — 根据用户意图选择:**
> - `note_doc_token` → **AI 智能纪要**AI 总结 + 待办)
> - `meeting_notes` → **用户绑定的会议纪要**(用户主动关联到会议的文档,仅 `--calendar-event-ids` 路径返回
> - 用户说"逐字稿""完整记录""谁说了什么"时 → 按 `note_display_type` 路由,详见 [`+notes`](references/lark-vc-notes.md)
> - 用户说"纪要""总结""纪要内容"时,应同时返回 `note_doc_token` 和 `meeting_notes`(如有)
> - `note_doc_token` → **AI 智能纪要**AI 总结 + 待办),由 `note +detail --note-id <note_id>` 返回
> - `meeting_note` → **用户绑定到日程的会议纪要**,由 [`calendar +meeting --event-ids <event_id>`](../lark-calendar/references/lark-calendar-meeting.md) 返回
> - 用户说"逐字稿""完整记录""谁说了什么"时 → 按 `note_display_type` 路由,详见 [lark-note](../lark-note/SKILL.md)
> - 用户说"纪要""总结""纪要内容"时,应同时返回 `note_doc_token` 和 `meeting_note`(如有)
> - 用户意图不明确时,应展示所有文档链接让用户选择,而不是替用户决定
> - 如果用户提供的是**本地音视频文件**并说"转纪要""转逐字稿",不要直接从 `vc +notes` 开始;应先用 [minutes +upload](../lark-minutes/references/lark-minutes-upload.md) 生成 `minute_url`,再提取 `minute_token` 调用 `vc +notes --minute-tokens`
> - 如果用户提供的是**本地音视频文件**并说"转纪要""转逐字稿",不要直接从 `vc +detail` 开始;应先用 [minutes +upload](../lark-minutes/references/lark-minutes-upload.md) 生成 `minute_url`,再提取 `minute_token` 调用 `minutes +detail --minute-tokens`
### 3. 纪要文档与逐字稿链接
1. 纪要文档、逐字稿文档与关联的共享文档默认使用文档 Token 返回。
@@ -137,7 +138,7 @@ lark-cli vc meeting get --params '{"meeting_id":"<meeting_id>","with_participant
| 用户意图 | 推荐命令 | 所在 skill |
|---------|---------|--------|
| 参会人快照(谁参加过、何时入/离会,任意时点)| `vc meeting get --with-participants` | 本 skill |
| 已结束会议的发言内容 | `vc +notes`,再按 `note_display_type` 路由 | 本 skill / [`lark-note`](../lark-note/SKILL.md) |
| 已结束会议的发言内容 | 优先:`vc +detail``note_id``note +detail``verbatim_doc_token``docs +fetch`;备选:`vc +detail``minute_token``minutes +detail --transcript` | [lark-note](../lark-note/SKILL.md) / [lark-minutes](../lark-minutes/SKILL.md) |
| **进行中会议**的实时事件流(转写、聊天、共享、会中加入/离开)| `vc +meeting-events` | [`lark-vc-agent`](../lark-vc-agent/SKILL.md) |
| **Agent 真实入会 / 离会** | `vc +meeting-join` / `vc +meeting-leave` | [`lark-vc-agent`](../lark-vc-agent/SKILL.md) |
@@ -151,7 +152,7 @@ Meeting (视频会议)
│ ├── VerbatimDoc (逐字稿, verbatim_doc_token) ← normal 路径
│ ├── UnifiedTranscript (unified 原始记录) ← unified 路径note +transcriptlark-note
│ └── SharedDoc (会中共享文档)
└── Minutes (妙记) ← minute_token 标识,+recording 从 meeting_id 获取
└── Minutes (妙记) ← minute_token 标识,由 `vc +detail` 或 `vc +recording` 桥接获取,产物详情走 [lark-minutes](../lark-minutes/SKILL.md)
├── Transcript (文字记录)
├── Summary (总结)
├── Todos (待办)
@@ -159,12 +160,16 @@ Meeting (视频会议)
└── Keywords (推荐关键词)
```
> **妙记边界**`+notes` 负责纪要内容、逐字稿和 AI 产物;妙记基础信息请优先看 [`+recording`](references/lark-vc-recording.md) 与 [lark-minutes](../lark-minutes/SKILL.md)。
> **MeetingNotes 边界**:用户绑定到日程的会议纪要文档(`meeting_note`)属于日程域,不在 VC 资源关系内;从 `event_id` 用 [`calendar +meeting`](../lark-calendar/references/lark-calendar-meeting.md) 获取
>
> **Note 域边界**`vc +notes` 是从**会议线索**`meeting_id` / `calendar_event_id` / `minute_token`)定位纪要的入口,返回 `note_id` 和 `note_display_type`
> - 已有 `note_id` → [lark-note](../lark-note/SKILL.md)。
> **妙记边界**`+recording` 仅负责把 `meeting_id` / `calendar_event_id` 桥接到 `minute_token`;妙记的总结/待办/章节/逐字稿等产物归 [lark-minutes](../lark-minutes/SKILL.md)`minutes +detail`
>
> **Note 域边界**VC 域只负责把 `meeting_id` 转成 `note_id` / `minute_token`,纪要详情归 [lark-note](../lark-note/SKILL.md)。
> - 入口选择:从 `meeting_id` 出发用 `vc +detail` 拿 `note_id` 和 `minute_token`;从 `minute_token` 出发用 [`minutes +detail`](../lark-minutes/references/lark-minutes-detail.md) 也会返回关联的 `note_id`,可继续走 `note +detail` 拿纪要文档 token。
> - 已有 `note_id` → 直接走 [`note +detail`](../lark-note/SKILL.md) / [`note +transcript`](../lark-note/SKILL.md),不要绕回 VC。
> - 已有 `doc_token` 且目标是读正文 → [lark-doc](../lark-doc/SKILL.md)。
> - 只有自然语言纪要标题 → 文档搜索 / Docx 正文读取;有显式 `vc-node-id` 才进入 [lark-note](../lark-note/SKILL.md)。
> - 从日程出发(只有 `event_id`)→ 先走 [`calendar +meeting`](../lark-calendar/references/lark-calendar-meeting.md) 拿到 `meeting_id` 或 `meeting_note`,再按上述路径继续。
## API Resources
@@ -186,12 +191,12 @@ lark-cli vc meeting get --params '{"meeting_id": "<meeting_id>", "with_participa
### minutes跨域详见 [lark-minutes](../lark-minutes/SKILL.md)
- `get` — 获取妙记基础信息(标题、时长、封面);查询妙记**内容**请用 `+notes --minute-tokens <minute-token>`
- `get` — 获取妙记基础信息(标题、时长、封面);查询妙记**内容**(总结/待办/章节/逐字稿)请用 [`minutes +detail`](../lark-minutes/references/lark-minutes-detail.md)
## 不在本 skill 范围
- 查询未来的会议日程 → [lark-calendar](../lark-calendar/SKILL.md)
- Agent 真实入会/离会、会中实时事件 → [lark-vc-agent](../lark-vc-agent/SKILL.md)
- 只有纪要文档标题的逐字稿查询 → 文档搜索 / Docx 正文读取;有显式 `vc-node-id` 才进入 [lark-note](../lark-note/SKILL.md)
- 本地音视频文件转纪要/逐字稿 → [lark-minutes](../lark-minutes/SKILL.md)(上传后回 `vc +notes`
- 妙记搜索/下载/上传/重命名/替换说话人 → [lark-minutes](../lark-minutes/SKILL.md)
- 本地音视频文件转纪要/逐字稿、妙记搜索/下载/上传/重命名/替换说话人 → [lark-minutes](../lark-minutes/SKILL.md)
- 通过 `note_id` 取纪要文档 Token → [lark-note](../lark-note/SKILL.md)

View File

@@ -0,0 +1,44 @@
# vc +detail
通过会议 ID 获取会议详情,包括基本信息、关联的纪要 ID`note_id`)和妙记 Token`minute_token`)。只读。
## 命令
```bash
# 单个 / 批量(逗号分隔,最多 50 个)
lark-cli vc +detail --meeting-ids <meeting_id1>,<meeting_id2>
```
## 输出字段
| 字段 | 说明 |
|------|------|
| `meeting_id` | 会议 ID |
| `meeting_no` | 会议 9 位号码 |
| `topic` | 会议主题 |
| `start_time` | 开始时间 |
| `end_time` | 结束时间 |
| `note_id` | 关联的纪要 ID。 |
| `minute_token` | 关联的妙记 Token。 |
## 典型场景
### 场景 1获取会议的纪要和妙记关联
`vc +detail` 只能拿到 `note_id``minute_token`,不直接返回纪要文档 token 与妙记产物内容。要获取实际产物,需根据用户诉求继续调用 `note +detail``minutes +detail`
```bash
# 1. 获取会议详情,拿到 note_id 和 minute_token
lark-cli vc +detail --meeting-ids <meeting_id>
# 2. 用 note_id 获取纪要文档 Tokennote_doc_token / verbatim_doc_token / shared_doc_tokens
lark-cli note +detail --note-id <note_id>
# 3. 用 minute_token 获取妙记产物
# ⚠️ 必须显式指定 --summary / --todo / --chapter / --keyword / --transcript 中至少一个 flag
# 不传任何 flag 则不会返回任何产物内容。
lark-cli minutes +detail --minute-tokens <minute_token> --todo --transcript
```
> **路由建议**:当用户未明确指定使用妙记时,**优先**走 `note +detail` 链路(纪要文档信息更完整、含逐字稿原文),仅在 `note_id` 为空或用户要求妙记产物时才走 `minutes +detail`。

View File

@@ -1,148 +0,0 @@
# vc +notes
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
查询会议纪要,支持通过会议 ID、妙记 Token 或日程事件 ID 获取纪要文档、逐字稿、AI 总结、待办和章节。只读操作。
本 skill 对应 shortcut`lark-cli vc +notes`
## 命令
```bash
# 通过会议 ID 查询(逗号分隔支持批量,最多 50 个)
lark-cli vc +notes --meeting-ids 69xxxxxxxxxxxxx28
lark-cli vc +notes --meeting-ids 69xxxxxxxxxxxxx28,69xxxxxxxxxxxxx29
# 通过妙记 Token 查询(从妙记 URL 中提取)
lark-cli vc +notes --minute-tokens obbxxxxxxxxxxxxxxxxxx
lark-cli vc +notes --minute-tokens obbxxxxxxxxxxxxxxxxxx,obbyyyyyyyyyyyyyyyyyy
# 指定逐字稿输出目录(仅 --minute-tokens 路径有效)
lark-cli vc +notes --minute-tokens obbxxxxxxxxxxxxxxxxxx --output-dir ./output
lark-cli vc +notes --minute-tokens obbxxxxxxxxxxxxxxxxxx --overwrite
# 通过日程事件 ID 查询(从 calendar +agenda 获取 event_id
lark-cli vc +notes --calendar-event-ids xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx_0
# 输出格式
lark-cli vc +notes --meeting-ids 69xxxxxxxxxxxxx28 --format json
# 预览 API 调用
lark-cli vc +notes --meeting-ids 69xxxxxxxxxxxxx28 --dry-run
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--meeting-ids <ids>` | 三选一 | 会议 ID逗号分隔支持批量 |
| `--minute-tokens <tokens>` | 三选一 | 妙记 Token逗号分隔支持批量 |
| `--calendar-event-ids <ids>` | 三选一 | 日程事件 ID逗号分隔支持批量 |
| `--output-dir <dir>` | 否 | 逐字稿输出目录。未指定时默认落到 `./minutes/{minute_token}/transcript.txt`(与 `minutes +download` 共享目录);显式指定时沿用旧布局 `./{output-dir}/artifact-{title}-{token}/transcript.txt`。仅 `--minute-tokens` 路径有效 |
| `--overwrite` | 否 | 覆盖已存在的逐字稿文件,仅 `--minute-tokens` 路径有效 |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
## 核心约束
### 1. 三种参数互斥
每次只能指定一种输入方式。同时传入多种会报错。
### 2. 仅支持 user 身份
该命令仅支持 `user` 身份,使用前需完成 `lark-cli auth login`
### 3. 批量上限
每次最多传入 50 个 ID/Token。
### 4. 按路径检查权限
不同输入方式需要不同权限,命令会自动检查对应路径所需的 scope
| 输入 | 所需权限 |
|------|---------|
| `--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``vc:record:readonly` |
## 输出结果
### 有纪要文档时
返回 `notes` 数组,每条记录包含:
| 字段 | 说明 |
|------|------|
| `meeting_id` | 会议 ID`--meeting-ids` / `--calendar-event-ids` 路径) |
| `minute_token` | **会议对应的妙记 Token**`--meeting-ids` / `--calendar-event-ids` 路径自动通过录制 API 反查并附加)|
| `note_id` | **纪要 ID** — 用于继续进入 Note 域(`note +detail` / `note +transcript` |
| `note_display_type` | **纪要展示类型**`unknown` / `normal` / `unified`,区分普通纪要和 unified 纪要 |
| `note_doc_token` | **AI 智能纪要**文档 Token — AI 生成的总结、待办、章节 |
| `meeting_notes` | **用户绑定的会议纪要**文档 Token 列表 — 用户主动关联到会议的文档(仅 `--calendar-event-ids` 路径返回) |
| `verbatim_doc_token` | **逐字稿**文档 Token — 完整的逐句文字记录含说话人和时间戳unified 纪要的逐字稿请改用 `note +transcript` |
| `shared_doc_tokens` | 会中共享文档 Token 列表 |
| `creator_id` | 创建者 ID |
| `create_time` | 创建时间(格式化) |
> **选择哪个 token** 用户说"会议纪要""总结""待办""纪要内容" → 返回 `note_doc_token` 和 `meeting_notes`(如有)。用户说"逐字稿""完整记录""谁说了什么" → 见下方「按 `note_display_type` 路由逐字稿」。意图不明确时,展示所有文档链接让用户选择。
>
> 📌 不确定该返回哪个 token参见 [`vc-domain-boundaries.md`](vc-domain-boundaries.md) 的产物链路对比表,了解 AI 总结链路 vs 录制链路的区别。
### 按 `note_display_type` 路由逐字稿 / 原始记录
逐字稿走哪条路由由 `note_display_type` 决定,**不要只看 `verbatim_doc_token` 是否为空**
| 字段 / 条件 | Agent 动作 |
|------------|-----------|
| 用户要纪要正文 / 总结 / 待办 / 章节 | `docs +fetch --api-version v2 --doc <note_doc_token>` |
| `note_display_type=normal` + 用户要逐字稿 | `docs +fetch --api-version v2 --doc <verbatim_doc_token>` |
| `note_display_type=unknown` + `verbatim_doc_token` 非空 + 用户要逐字稿 | `docs +fetch --api-version v2 --doc <verbatim_doc_token>`;不要猜成 unified |
| `note_display_type=unknown` + 无可用逐字稿 token | 先 `note +detail --note-id <note_id>` 复核,再按返回的展示类型路由 |
| `note_display_type=unified` + 用户要逐字稿 / 原始记录 | `note +transcript --note-id <note_id>` → 切到 [lark-note](../../lark-note/SKILL.md) |
| `minute_token` 存在 + 用户要音视频媒体 | `minutes +download --minute-tokens <minute_token>` |
> **`unified` 纪要的逐字稿不是独立文档**,必须用 `note +transcript` 按 `note_id` 拉取,输出更结构化。即使 unified 也返回了非空 `verbatim_doc_token`,仍以 `note_display_type` 为准。
### minute-tokens 路径的 AI 产物
通过 `--minute-tokens` 查询时,返回的 `artifacts` 字段包含 AI 内置产物:
| 字段 | 说明 |
|------|------|
| `artifacts.summary` | AI 总结JSON 内联) |
| `artifacts.todos` | 待办事项JSON 内联,**只读**);每条含 `content``is_done``todo_id``todo_id` 仅供 [`minutes +todo`](../../lark-minutes/references/lark-minutes-todo.md) 更新/删除待办时使用,不必展示给用户。**新建**妙记内待办请用 `minutes +todo`,不要用 lark-task |
| `artifacts.chapters` | 章节纪要JSON 内联) |
| `artifacts.keywords` | 妙记推荐关键词JSON 内联) |
| `artifacts.transcript_file` | 逐字稿本地文件路径。默认落到 `./minutes/{minute_token}/transcript.txt`(与 `minutes +download` 聚合);显式 `--output-dir` 时走旧布局 `./{output-dir}/artifact-{title}-{token}/transcript.txt` |
## 如何获取输入参数
| 输入参数 | 获取方式 |
|---------|---------|
| `meeting_id` | `vc +search` 搜索历史会议 → 结果中的 `id` 字段 |
| `minute_token` | 从妙记 URL 中提取,如 `https://sample.feishu.cn/minutes/obbyyyyyyyyyyyyyyyyyy``obbyyyyyyyyyyyyyyyyyy` |
| `calendar_event_id` | `calendar +agenda` 查看日程 → 结果中的 `event_id` 字段 |
## 常见错误与排查
| 错误现象 | 根本原因 | 解决方案 |
|---------|---------|---------|
| `exactly one of ... is required` | 未传入参数或同时传了多种 | 只指定一种输入方式 |
| `no notes available for this meeting` | 该会议未生成纪要 | 尝试用 `--minute-tokens` 路径 |
| `121005 no permission` | 非会议参与者无权查看 | 使用 `--minute-tokens` 降级到内置产物 |
| `missing required scope(s)` | 权限不足 | 按提示运行 `auth login --scope` |
| `too many IDs` | 超过批量上限 | 分批查询,每批最多 50 个 |
## 提示
- 默认使用 `--format json` 输出,你更佳擅长解析 JSON 数据。
- 排查参数与请求结构时优先使用 `--dry-run`
- `--meeting-ids``--calendar-event-ids` 路径最终都走纪要详情 API需要 `vc:note:read` 权限。
- `--minute-tokens` 路径无纪要权限时会自动降级,**不会报错**,而是下载内置产物到本地。
## 参考
- [lark-vc](../SKILL.md) — 视频会议全部命令
- [lark-vc-search](lark-vc-search.md) — 搜索历史会议(获取 meeting_id
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -5,7 +5,7 @@
通过 meeting_id 或 calendar_event_id 查询对应的 minute_token。这是 VC 域和 Minutes 域之间的桥梁命令。只读操作。
> **边界提醒:** 如果用户明确要的是"妙记信息""妙记详情""妙记链接""minute_token""标题""时长""owner"这类妙记元信息,先用本命令拿到 `minute_token`,再调用 `minutes minutes get`。不要直接切到 `vc +notes``vc +notes` 只用于纪要内容和逐字稿。
> **边界提醒:** 如果用户明确要的是"妙记信息""妙记详情""妙记链接""minute_token""标题""时长""owner"这类妙记元信息,先用本命令拿到 `minute_token`,再调用 `minutes minutes get`。不要直接切到 `minutes +detail``minutes +detail` 只用于纪要内容和逐字稿。
本 skill 对应 shortcut`lark-cli vc +recording`
@@ -102,7 +102,8 @@ lark-cli minutes minutes get --params '{"minute_token":"<minute_token>"}'
lark-cli vc +recording --meeting-ids xxx
# 第 2 步:使用上一步返回的 minute_token 获取完整纪要
lark-cli vc +notes --minute-tokens <minute_token>
# ⚠️ 必须显式指定要获取的产物 flag不传则不会返回任何产物内容
lark-cli minutes +detail --minute-tokens <minute_token> --summary --todo --chapter --transcript
```
### 场景 4先搜索会议再获取录制并下载
@@ -143,11 +144,11 @@ lark-cli minutes +download --minute-tokens <minute_token>
- 默认使用 `--format json` 输出Agent 更擅长解析 JSON 数据。
- 排查参数与请求结构时优先使用 `--dry-run`
- `minute_token` 从录制 URL 尾段解析(`https://meetings.feishu.cn/minutes/{minute_token}`)。
- 拿到 `minute_token` 后,如果要妙记基础信息,优先传给 `minutes minutes get`;如果要下载媒体文件,传给 `minutes +download`;如果要逐字稿、总结、待办、章节,再传给 `vc +notes --minute-tokens`
- 拿到 `minute_token` 后,如果要妙记基础信息,优先传给 `minutes minutes get`;如果要下载媒体文件,传给 `minutes +download`;如果要逐字稿、总结、待办、章节,再传给 `minutes +detail --minute-tokens`
## 参考
- [lark-vc](../SKILL.md) — 视频会议全部命令
- [lark-vc-search](lark-vc-search.md) — 搜索历史会议(获取 meeting_id
- [lark-vc-notes](lark-vc-notes.md) — 获取会议纪要
- [lark-minutes-detail](../../lark-minutes/references/lark-minutes-detail.md) — 获取会议纪要
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -1,17 +1,13 @@
# vc +search
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则
搜索已结束的历史会议记录,支持关键词、时间范围、组织者、参与者以及会议室等多条件过滤。只读操作,不修改任何会议数据。
本 skill 对应 shortcut`lark-cli vc +search`(调用 `POST /open-apis/vc/v1/meetings/search`)。
搜索已结束的历史会议记录,支持关键词、时间范围、组织者、参与者、会议室多条件过滤。只读,仅 `--as user`
## 关键词使用边界
`--query` 只用于真实会议关键词,例如会议主题、项目名、评审名、客户名。用户只是说"我这月参加的所有视频会议"、"最近两周我组织的所有视频会议"、"总结主要议题 / 看看参会情况"时,本质是历史会议列表和后续总结,不要把"回顾"、"所有视频会议"、"总结主要议题"等动作词放进 `--query`。这类请求应先用时间范围 + `--participant-ids` / `--organizer-ids` 搜全量候选,再按结果继续取纪要或录制信息。
列表阶段只负责找会议记录;总结阶段必须继续取证。若用户要求"主要议题"、"主要决策"、"参会情况",先确认搜索结果的 `meeting_id`、时间、组织者/参与者符合过滤条件,然后用 `vc +notes``vc +recording` / `minutes` 读取纪要、妙记或录制信息。没有纪要或妙记时,如实说明只能基于会议标题/参会数据汇总,不要编造议题。
列表阶段只负责找会议记录;总结阶段必须继续取证。若用户要求"主要议题"、"主要决策"、"参会情况",先确认搜索结果的 `meeting_id`、时间、组织者/参与者符合过滤条件,然后用 `vc +detail` `minutes` 读取纪要、妙记或录制信息。没有纪要或妙记时,如实说明只能基于会议标题/参会数据汇总,不要编造议题。
## 典型触发表达
@@ -35,37 +31,17 @@ lark-cli vc +search --start 2026-03-10 --end 2026-03-10
# 按时间范围搜索
lark-cli vc +search --start "2026-03-10T00:00+08:00" --end "2026-03-17T00:00+08:00"
lark-cli vc +search --start 2026-03-10 --end 2026-03-17
# 关键词 + 时间范围
lark-cli vc +search --query "周会" --start "2026-03-10T00:00+08:00" --end "2026-03-17T00:00+08:00"
lark-cli vc +search --query "周会" --start "2026-03-10T00:00+08:00"
lark-cli vc +search --query "周会" --end "2026-03-17T00:00+08:00"
# 按组织者过滤open_id逗号分隔
lark-cli vc +search --organizer-ids "ou_a,ou_b"
# 按参与者过滤open_id逗号分隔
lark-cli vc +search --participant-ids "ou_x,ou_y"
# 查询我这个月参加过的历史会议,不带关键词
lark-cli vc +search --start "<YYYY-MM-DD>" --end "<YYYY-MM-DD>" --participant-ids "ou_me"
# 查询最近两周我组织的历史会议,不带关键词
lark-cli vc +search --start "<YYYY-MM-DD>" --end "<YYYY-MM-DD>" --organizer-ids "ou_me"
# 按会议室过滤
# 按组织者 / 参与者 / 会议室(逗号分隔)
lark-cli vc +search --organizer-ids "ou_user1,ou_user2"
lark-cli vc +search --participant-ids "ou_user1,ou_user2"
lark-cli vc +search --room-ids "123,456"
# 多条件组合查询
lark-cli vc +search --organizer-ids "ou_a" --room-ids "123" --start "2026-03-10T00:00+08:00"
# 多条件组合
lark-cli vc +search --organizer-ids "ou_user1" --room-ids "123" --start "2026-03-10T00:00+08:00"
# 分页查询
lark-cli vc +search --query "周会" --page-size 15
lark-cli vc +search --query "周会" --page-token "next_page_token"
# 输出为表格/可读格式
lark-cli vc +search --query "周会" --format json
# 翻页
lark-cli vc +search --query "周会" --page-token "<PAGE_TOKEN>"
```
## 参数
@@ -161,7 +137,7 @@ lark-cli vc +search --query "周会" --page-size 15 --page-token "<PAGE_TOKEN>"
```bash
# 如果要会议纪要 / 逐字稿 / AI 总结 / 待办 / 章节
lark-cli vc +notes --meeting-ids <MEETING_ID>
lark-cli vc +detail --meeting-ids <MEETING_ID>
# 如果要会议对应的妙记信息 / minute_token / 妙记链接
lark-cli vc +recording --meeting-ids <MEETING_ID>
@@ -183,11 +159,5 @@ lark-cli minutes minutes get --params '{"minute_token":"<MINUTE_TOKEN>"}'
- 排查参数与请求结构时优先使用 `--dry-run`
- 搜索的时间范围最大为 1 个月,如果需要搜索更长时间范围的会议,需要拆分为多次时间范围为一个月查询。
- 不要使用 `yesterday``today` 这类相对时间字面量;请先转换成明确日期,例如 `2026-03-10`
- 用户如果明确问的是“妙记信息”而不是“纪要内容”,不要默认走 `vc +notes`;应先用 `vc +recording`
- 用户如果明确问的是“妙记信息”而不是“纪要内容”,不要默认走 `vc +detail`;应先用 `vc +recording`
## 参考
- [lark-vc](../SKILL.md) -- 视频会议全部命令
- [lark-vc-recording](lark-vc-recording.md) -- 查询会议对应的 minute_token
- [lark-vc-notes](lark-vc-notes.md) -- 获取会议纪要
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数

View File

@@ -30,7 +30,7 @@
| 逐字稿 | `verbatim_doc_token` | 飞书文档 | 完整的逐句发言记录(含说话人、时间戳)— **仅 `note_display_type=normal` 时是可读的独立文档**`unified` 纪要的逐字稿用 `note +transcript --note-id <note_id>` 拉取(见下方 [Note 域](#note-域) |
| 共享文档 | `shared_doc_token` | 飞书文档 | 会中投屏共享的文档信息 |
此外,还存在**用户会议纪要MeetingNotes**,对应 `meeting_notes` 字段。这是用户主动绑定到会议的纪要文档,通常用于会前记录会议相关内容,与智能纪要文档相互独立。仅通过 `+notes --calendar-event-ids` 路径返回。
此外,还存在**用户会议纪要MeetingNotes**,对应 `meeting_note` 字段。这是用户主动绑定到日程的纪要文档,通常用于会前记录会议相关内容,与智能纪要文档相互独立。仅通过 [`calendar +meeting --event-ids`](../../lark-calendar/references/lark-calendar-meeting.md) 路径返回。
#### 链路二:开启「录制」
@@ -93,8 +93,20 @@ lark-cli vc +search --start "<YYYY-MM-DD>" --end "<YYYY-MM-DD>" --format json
##### 获取会议产物
当用户提供 `meeting_id` 并需要会议产物时,先用 `vc +detail` 拿到 `note_id``minute_token`
```bash
lark-cli vc +notes --meeting-ids '<meeting_id1>,<meeting_id2>'
lark-cli vc +detail --meeting-ids '<meeting_id1>,<meeting_id2>'
```
详细用法请阅读 [`lark-vc-detail.md`](lark-vc-detail.md)。
**优先路径:通过 `note_id` 获取纪要产物**
如果用户未明确要求使用妙记,且返回了 `note_id`**优先**使用 `note +detail` 获取纪要文档的 token 信息:
```bash
lark-cli note +detail --note-id <note_id>
```
可获取会议的所有产物信息,包括:
@@ -102,17 +114,20 @@ lark-cli vc +notes --meeting-ids '<meeting_id1>,<meeting_id2>'
- 智能纪要(`note_doc_token`)— AI 生成的总结和待办信息
- 逐字稿(`verbatim_doc_token`)— 完整的会中发言记录(仅 `normal` 纪要可直接读取该文档)
- 共享文档(`shared_doc_token`)— 会中投屏共享的文档
- 妙记 Token`minute_token`)— 如存在录制产物则返回
详细用法请阅读 [`lark-vc-notes.md`](lark-vc-notes.md)。
拿到文档 token 后,再通过 Doc 域 `docs +fetch` 拉取文档正文内容(见 Step 3详细用法请阅读 [`lark-note-detail.md`](../../lark-note/references/lark-note-detail.md)。
如果返回了 `minute_token`,可通过以下命令获取妙记的详细信息(总结、待办、章节、文字记录):
**备选路径:通过 `minute_token` 获取妙记产物**
如果 `note_id` 为空,或用户明确要求使用妙记产物,则使用 `minutes +detail` 获取妙记的具体产物:
```bash
lark-cli vc +notes --minute-tokens '<minute_token1>,<minute_token2>'
# 必须显式指定要获取的产物 flag至少传一个不传则不会返回任何产物内容
lark-cli minutes +detail --minute-tokens '<minute_token1>,<minute_token2>' \
--summary --todo --chapter --keyword --transcript
```
可获取妙记的总结、待办、章节、文字记录等信息。详细用法请阅读 [`lark-vc-notes.md`](lark-vc-notes.md)。
> **注意**`minutes +detail` 需要**手动指定**要获取的产物 flag可选 `--summary`(总结)、`--todo`(待办)、`--chapter`(章节)、`--keyword`(关键词)、`--transcript`(文字记录)。**未传任何产物 flag 时不会返回产物内容**,请按用户诉求按需指定。详细用法请阅读 [`lark-minutes-detail.md`](../../lark-minutes/references/lark-minutes-detail.md)。
#### Step 3: 按 `note_display_type` 拉取正文 / 逐字稿
@@ -138,8 +153,10 @@ lark-cli note +transcript --note-id <note_id>
## Note 域
- VC 只负责从 `meeting_id` / `calendar_event_id` / `minute_token` 定位会议产物和 `note_id`
- VC 只负责从 `meeting_id` 定位会议产物和 `note_id` / `minute_token`[`vc +detail`](lark-vc-detail.md)
- 已知 `note_id` 后切到 [lark-note](../../lark-note/SKILL.md);逐字稿路由以 `lark-note``note_display_type` 规则为准。
- 已知 `minute_token` 时,[`minutes +detail`](../../lark-minutes/references/lark-minutes-detail.md) 顶层会一并返回该妙记关联的 `note_id`(如有);可直接传给 `note +detail` 取纪要文档 token无需绕回 VC。
- 仅有日程 `event_id` 时,先走 [`calendar +meeting`](../../lark-calendar/references/lark-calendar-meeting.md) 拿到 `meeting_id` 或用户绑定的 `meeting_note`,再按上述路径继续。
- 只有自然语言纪要标题时,先走文档搜索与 `docs +fetch --api-version v2`;只有 `<vc-transcribe-tab vc-node-id="...">``vc-node-id` 可以进入 Note 域。
- `doc_token` / Docx URL 不是 `note_id`。没有 `vc-node-id` 时不要反推 Note继续按 Doc 域读取正文或正文中明确给出的逐字稿文档。

View File

@@ -37,7 +37,10 @@ lark-cli auth login --domain vc,drive # 含读取纪要文档正文、生成
{时间范围} ─► vc +search ──► 会议列表 (meeting_ids)
vc +notes ──► 纪要文档 tokens
vc +detail ──► 获取 note_id
note +detail ──► 纪要文档 tokens
drive metas batch_query 纪要元数据
@@ -69,12 +72,16 @@ lark-cli vc +search --start "<YYYY-MM-DD>" --end "<YYYY-MM-DD>" --format json --
1. 查询会议关联的纪要信息
```bash
lark-cli vc +notes --meeting-ids "id1,id2,...,idN"
# 首先获取 note_id 和 minute_token
lark-cli vc +detail --meeting-ids "id1,id2,...,idN"
# 然后用 note_id 获取文档 tokens如有多个需分别获取
lark-cli note +detail --note-id "note_id"
```
- 根据上一步搜集到的 `meeting-id` 查询会议纪要
- 单次最多查询 50 个纪要信息,超过 50 个需分批调用。
- 部分会议返回 `no notes available`,在最终输出中标注"无纪要"
- 记录每个会议`note_id`(纪要 ID`note_display_type`(展示类型:`unknown` / `normal` / `unified`)、`note_doc_token`(纪要文档 Token`verbatim_doc_token`(逐字稿文档 Token
- 根据上一步搜集到的 `meeting-id` 查询。
- 单次最多查询 50 个,超过 50 个需分批调用。
- 部分会议没有 `note_id` 或报错 `no notes available`,在最终输出中标注"无纪要"
- 记录每个纪要`note_id`(纪要 ID`note_display_type`(展示类型:`unknown` / `normal` / `unified`)、`note_doc_token`(纪要文档 Token`verbatim_doc_token`(逐字稿文档 Token
> **逐字稿路由按 `note_display_type` 决定**(详见 [vc-domain-boundaries.md](../lark-vc/references/vc-domain-boundaries.md) 的 Note 域):
> - `normal`:逐字稿是独立文档,链接/正文走 `verbatim_doc_token`。
@@ -110,6 +117,6 @@ lark-cli docs +update --api-version v2 --doc "<url_or_token>" --command append -
## 参考
- [lark-shared](../lark-shared/SKILL.md) — 认证、权限(必读)
- [lark-vc](../lark-vc/SKILL.md) — `+search``+notes` 详细用法
- [lark-vc](../lark-vc/SKILL.md) — `+search``+detail` 详细用法
- [lark-note](../lark-note/SKILL.md) — `note +detail``note +transcript`unified 纪要逐字稿)
- [lark-doc](../lark-doc/SKILL.md) — `+fetch``+create``+update` 详细用法