mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
Compare commits
3 Commits
feat/docs_
...
feat/vc_ev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cffbd7229 | ||
|
|
1ff071556d | ||
|
|
e0490b73f3 |
235
shortcuts/calendar/calendar_meeting.go
Normal file
235
shortcuts/calendar/calendar_meeting.go
Normal 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
|
||||
},
|
||||
}
|
||||
484
shortcuts/calendar/calendar_meeting_test.go
Normal file
484
shortcuts/calendar/calendar_meeting_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
351
shortcuts/calendar/calendar_search_event.go
Normal file
351
shortcuts/calendar/calendar_search_event.go
Normal 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
|
||||
},
|
||||
}
|
||||
@@ -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{}
|
||||
|
||||
@@ -15,5 +15,7 @@ func Shortcuts() []common.Shortcut {
|
||||
CalendarRoomFind,
|
||||
CalendarRsvp,
|
||||
CalendarSuggestion,
|
||||
CalendarMeeting,
|
||||
CalendarSearchEvent,
|
||||
}
|
||||
}
|
||||
|
||||
285
shortcuts/minutes/minutes_detail.go
Normal file
285
shortcuts/minutes/minutes_detail.go
Normal 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
|
||||
},
|
||||
}
|
||||
372
shortcuts/minutes/minutes_detail_test.go
Normal file
372
shortcuts/minutes/minutes_detail_test.go
Normal 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
|
||||
}
|
||||
@@ -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"],
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,6 @@ func Shortcuts() []common.Shortcut {
|
||||
MinutesTodo,
|
||||
MinutesSpeakerReplace,
|
||||
MinutesWordReplace,
|
||||
MinutesDetail,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()...)
|
||||
|
||||
@@ -11,6 +11,7 @@ func Shortcuts() []common.Shortcut {
|
||||
VCSearch,
|
||||
VCNotes,
|
||||
VCRecording,
|
||||
VCDetail,
|
||||
VCMeetingJoin,
|
||||
VCMeetingLeave,
|
||||
VCMeetingEvents,
|
||||
|
||||
216
shortcuts/vc/vc_detail.go
Normal file
216
shortcuts/vc/vc_detail.go
Normal 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
|
||||
},
|
||||
}
|
||||
282
shortcuts/vc/vc_detail_test.go
Normal file
282
shortcuts/vc/vc_detail_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
}
|
||||
|
||||
@@ -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) |
|
||||
|
||||
## 任务类型分流
|
||||
|
||||
40
skills/lark-calendar/references/lark-calendar-meeting.md
Normal file
40
skills/lark-calendar/references/lark-calendar-meeting.md
Normal 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 → 纪要文档 token(note_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. 任意文档 token(meeting_note / note_doc_token / verbatim_doc_token / shared_doc_token)→ 正文
|
||||
lark-cli docs +fetch --api-version v2 --doc <doc_token> --doc-format markdown
|
||||
```
|
||||
@@ -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`——日历不收录"即时会议",只查日程会漏。
|
||||
@@ -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))
|
||||
|
||||
62
skills/lark-minutes/references/lark-minutes-detail.md
Normal file
62
skills/lark-minutes/references/lark-minutes-detail.md
Normal 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`。
|
||||
@@ -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) — 认证和全局参数
|
||||
|
||||
@@ -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) -- 视频会议全部命令
|
||||
|
||||
|
||||
@@ -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) — 认证和全局参数
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) 承接。
|
||||
|
||||
## 输出结果示例
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -1,9 +1,11 @@
|
||||
# note +detail
|
||||
|
||||
`note +detail` 只做一件事:按显式 `note_id` 返回纪要展示类型和相关文档 token。
|
||||
通过 `note_id` 查询会议纪要详情,获取下挂文档 Token(AI 智能纪要、逐字稿、会中共享文档)。只读,仅支持 `--as user`。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
lark-cli note +detail --note-id NOTE_ID --format json
|
||||
lark-cli note +detail --note-id <note_id>
|
||||
```
|
||||
|
||||
## `note_id` 来源
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) — 认证和全局参数
|
||||
|
||||
@@ -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) — 认证和全局参数
|
||||
|
||||
@@ -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) — 认证和全局参数
|
||||
|
||||
@@ -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-ids、minute-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 +transcript(lark-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)
|
||||
|
||||
44
skills/lark-vc/references/lark-vc-detail.md
Normal file
44
skills/lark-vc/references/lark-vc-detail.md
Normal 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 获取纪要文档 Token(note_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`。
|
||||
@@ -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) — 认证和全局参数
|
||||
@@ -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) — 认证和全局参数
|
||||
|
||||
@@ -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) -- 认证和全局参数
|
||||
|
||||
@@ -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 域读取正文或正文中明确给出的逐字稿文档。
|
||||
|
||||
|
||||
@@ -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` 详细用法
|
||||
Reference in New Issue
Block a user