Skip to content
1 change: 1 addition & 0 deletions shortcuts/vc/shortcuts.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ func Shortcuts() []common.Shortcut {
return []common.Shortcut{
VCSearch,
VCNotes,
VCRecording,
}
}
54 changes: 32 additions & 22 deletions shortcuts/vc/vc_notes.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,44 +89,34 @@ func getPrimaryCalendarID(runtime *common.RuntimeContext) (string, error) {
return calID, nil
}

// fetchNoteByCalendarEventID queries notes via calendar event instance ID.
// Chain: primary calendar → mget_instance_relation_info → meeting_id → meeting.get → note_id
func fetchNoteByCalendarEventID(ctx context.Context, runtime *common.RuntimeContext, instanceID string, calendarID string) map[string]any {
errOut := runtime.IO().ErrOut

// call mget_instance_relation_info to get meeting_id
// resolveMeetingIDsFromCalendarEvent resolves a calendar event instance to its
// associated meeting IDs via the mget_instance_relation_info API.
// Shared by +notes and +recording for the --calendar-event-ids path.
func resolveMeetingIDsFromCalendarEvent(runtime *common.RuntimeContext, instanceID string, calendarID string) ([]string, error) {
data, err := runtime.DoAPIJSON(http.MethodPost,
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/mget_instance_relation_info", validate.EncodePathSegment(calendarID)),
nil,
map[string]any{
"instance_ids": []string{instanceID},
"need_meeting_instance_ids": true,
"need_meeting_notes": true,
"need_ai_meeting_notes": true,
})
if err != nil {
return map[string]any{"calendar_event_id": instanceID, "error": fmt.Sprintf("failed to query event relation info: %v", err)}
return nil, fmt.Errorf("failed to query event relation info: %w", err)
}

// parse instance_relation_infos
infos, _ := data["instance_relation_infos"].([]any)
if len(infos) == 0 {
return map[string]any{"calendar_event_id": instanceID, "error": "no event relation info found"}
return nil, fmt.Errorf("no event relation info found")
}
info, _ := infos[0].(map[string]any)

// get meeting_instance_ids
meetingIDs, _ := info["meeting_instance_ids"].([]any)
if len(meetingIDs) == 0 {
return map[string]any{"calendar_event_id": instanceID, "error": "no associated video meeting for this event"}
}

if len(meetingIDs) > 1 {
fmt.Fprintf(errOut, "%s event %s has %d meetings, trying each\n", logPrefix, sanitizeLogValue(instanceID), len(meetingIDs))
rawIDs, _ := info["meeting_instance_ids"].([]any)
if len(rawIDs) == 0 {
return nil, fmt.Errorf("no associated video meeting for this event")
}

// try each meeting_instance_id until one has notes
for _, mid := range meetingIDs {
var ids []string
for _, mid := range rawIDs {
if mid == nil {
continue
}
Expand All @@ -139,12 +129,32 @@ func fetchNoteByCalendarEventID(ctx context.Context, runtime *common.RuntimeCont
default:
meetingID = fmt.Sprintf("%v", v)
}
ids = append(ids, meetingID)
}
return ids, nil
}

// fetchNoteByCalendarEventID queries notes via calendar event instance ID.
// Chain: primary calendar → mget_instance_relation_info → meeting_id → meeting.get → note_id
func fetchNoteByCalendarEventID(ctx context.Context, runtime *common.RuntimeContext, instanceID string, calendarID string) map[string]any {
errOut := runtime.IO().ErrOut

meetingIDs, err := resolveMeetingIDsFromCalendarEvent(runtime, instanceID, calendarID)
if err != nil {
return map[string]any{"calendar_event_id": instanceID, "error": err.Error()}
}

if len(meetingIDs) > 1 {
fmt.Fprintf(errOut, "%s event %s has %d meetings, trying each\n", logPrefix, sanitizeLogValue(instanceID), len(meetingIDs))
}

// try each associated meeting until one has notes
for _, meetingID := range meetingIDs {
fmt.Fprintf(errOut, "%s event %s → meeting_id=%s\n", logPrefix, sanitizeLogValue(instanceID), sanitizeLogValue(meetingID))
result := fetchNoteByMeetingID(ctx, runtime, meetingID)
if result["error"] == nil {
return result
}
// if this meeting has no notes, try next
fmt.Fprintf(errOut, "%s meeting_id=%s: %s, trying next\n", logPrefix, sanitizeLogValue(meetingID), result["error"])
}
return map[string]any{"calendar_event_id": instanceID, "error": "no notes found in any associated meeting"}
Expand Down
252 changes: 252 additions & 0 deletions shortcuts/vc/vc_recording.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//
// vc +recording — query minute_token from meeting-ids or calendar-event-ids
//
// Two mutually exclusive input modes:
// meeting-ids: recording API → extract minute_token from URL
// calendar-event-ids: primary calendar → mget_instance_relation_info → meeting_id → recording API

package vc

import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"

"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)

const recordingLogPrefix = "[vc +recording]"

var (
scopesRecordingMeetingIDs = []string{
"vc:record:readonly",
}
scopesRecordingCalendarEventIDs = []string{
"vc:record:readonly",
"calendar:calendar:read",
"calendar:calendar.event:read",
}
)

// extractMinuteToken parses minute_token from a recording URL.
// URL format: https://meetings.feishu.cn/minutes/{minute_token}
func extractMinuteToken(recordingURL string) string {
u, err := url.Parse(recordingURL)
if err != nil {
return ""
}
parts := strings.Split(strings.TrimRight(u.Path, "/"), "/")
for i, p := range parts {
if p == "minutes" && i+1 < len(parts) {
return parts[i+1]
}
}
return ""
}

// fetchRecordingByMeetingID queries recording info for a single meeting.
func fetchRecordingByMeetingID(_ context.Context, runtime *common.RuntimeContext, meetingID string) map[string]any {
data, err := runtime.DoAPIJSON(http.MethodGet,
fmt.Sprintf("/open-apis/vc/v1/meetings/%s/recording", validate.EncodePathSegment(meetingID)),
nil, nil)
if err != nil {
return map[string]any{"meeting_id": meetingID, "error": fmt.Sprintf("failed to query recording: %v", err)}
}

recording, _ := data["recording"].(map[string]any)
if recording == nil {
return map[string]any{"meeting_id": meetingID, "error": "no recording available for this meeting"}
}

recordingURL, _ := recording["url"].(string)
duration, _ := recording["duration"].(string)

result := map[string]any{"meeting_id": meetingID}
if recordingURL != "" {
result["recording_url"] = recordingURL
}
if duration != "" {
result["duration"] = duration
}
if token := extractMinuteToken(recordingURL); token != "" {
result["minute_token"] = token
}
return result
}

// VCRecording gets meeting recording info and extracts minute_token.
var VCRecording = common.Shortcut{
Service: "vc",
Command: "+recording",
Description: "Query minute_token from meeting-ids or calendar-event-ids",
Risk: "read",
Scopes: []string{"vc:record:readonly"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "meeting-ids", Desc: "meeting IDs, comma-separated for batch"},
{Name: "calendar-event-ids", Desc: "calendar event instance IDs, comma-separated for batch"},
},
Validate: func(_ context.Context, runtime *common.RuntimeContext) error {
if err := common.ExactlyOne(runtime, "meeting-ids", "calendar-event-ids"); err != nil {
return err
}
const maxBatchSize = 50
for _, flag := range []string{"meeting-ids", "calendar-event-ids"} {
if v := runtime.Str(flag); v != "" {
if ids := common.SplitCSV(v); len(ids) > maxBatchSize {
return output.ErrValidation("--%s: too many IDs (%d), maximum is %d", flag, len(ids), maxBatchSize)
}
}
}
var required []string
switch {
case runtime.Str("meeting-ids") != "":
required = scopesRecordingMeetingIDs
case runtime.Str("calendar-event-ids") != "":
required = scopesRecordingCalendarEventIDs
}
appID := runtime.Config.AppID
userOpenID := runtime.UserOpenId()
if appID != "" && userOpenID != "" {
stored := auth.GetStoredToken(appID, userOpenID)
if stored != nil {
if missing := auth.MissingScopes(stored.Scope, required); len(missing) > 0 {
return output.ErrWithHint(output.ExitAuth, "missing_scope",
fmt.Sprintf("missing required scope(s): %s", strings.Join(missing, ", ")),
fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` 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, " ")))
}
}
}
return nil
},
DryRun: func(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
if ids := runtime.Str("meeting-ids"); ids != "" {
return common.NewDryRunAPI().
GET("/open-apis/vc/v1/meetings/{meeting_id}/recording").
Set("meeting_ids", common.SplitCSV(ids)).
Set("steps", "meeting recording API → extract minute_token from URL")
}
ids := runtime.Str("calendar-event-ids")
return common.NewDryRunAPI().
POST("/open-apis/calendar/v4/calendars/primary").
POST("/open-apis/calendar/v4/calendars/{calendar_id}/events/mget_instance_relation_info").
GET("/open-apis/vc/v1/meetings/{meeting_id}/recording").
Set("calendar_event_ids", common.SplitCSV(ids)).
Set("steps", "primary calendar → mget_instance_relation_info → meeting_id → recording API → extract minute_token")
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
errOut := runtime.IO().ErrOut
var results []any

const batchDelay = 100 * time.Millisecond

if ids := runtime.Str("meeting-ids"); ids != "" {
meetingIDs := common.SplitCSV(ids)
fmt.Fprintf(errOut, "%s querying %d meeting_id(s)\n", recordingLogPrefix, 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", recordingLogPrefix, sanitizeLogValue(id))
results = append(results, fetchRecordingByMeetingID(ctx, runtime, id))
}
} else {
instanceIDs := common.SplitCSV(runtime.Str("calendar-event-ids"))
fmt.Fprintf(errOut, "%s querying %d calendar_event_id(s)\n", recordingLogPrefix, len(instanceIDs))
calendarID, err := getPrimaryCalendarID(runtime)
if err != nil {
return err
}
fmt.Fprintf(errOut, "%s primary calendar: %s\n", recordingLogPrefix, calendarID)
for i, instanceID := range instanceIDs {
if err := ctx.Err(); err != nil {
return err
}
if i > 0 {
time.Sleep(batchDelay)
}
fmt.Fprintf(errOut, "%s resolving calendar_event_id=%s ...\n", recordingLogPrefix, sanitizeLogValue(instanceID))
meetingIDs, resolveErr := resolveMeetingIDsFromCalendarEvent(runtime, instanceID, calendarID)
if resolveErr != nil {
results = append(results, map[string]any{"calendar_event_id": instanceID, "error": resolveErr.Error()})
continue
}
found := false
for _, meetingID := range meetingIDs {
fmt.Fprintf(errOut, "%s event %s → meeting_id=%s\n", recordingLogPrefix, sanitizeLogValue(instanceID), sanitizeLogValue(meetingID))
result := fetchRecordingByMeetingID(ctx, runtime, meetingID)
if result["error"] == nil {
result["calendar_event_id"] = instanceID
results = append(results, result)
found = true
break
}
fmt.Fprintf(errOut, "%s meeting_id=%s: %s, trying next\n", recordingLogPrefix, sanitizeLogValue(meetingID), result["error"])
}
if !found {
results = append(results, map[string]any{"calendar_event_id": instanceID, "error": "no recording found in any associated meeting"})
}
}
}

successCount := 0
for _, r := range results {
m, _ := r.(map[string]any)
if m["error"] == nil {
successCount++
}
}
fmt.Fprintf(errOut, "%s done: %d total, %d succeeded, %d failed\n", recordingLogPrefix, len(results), successCount, len(results)-successCount)

if successCount == 0 && len(results) > 0 {
outData := map[string]any{"recordings": results}
runtime.OutFormat(outData, &output.Meta{Count: len(results)}, nil)
return output.ErrAPI(0, fmt.Sprintf("all %d queries failed", len(results)), nil)
}

outData := map[string]any{"recordings": results}
runtime.OutFormat(outData, &output.Meta{Count: len(results)}, func(w io.Writer) {
var rows []map[string]interface{}
for _, r := range results {
m, _ := r.(map[string]any)
meetingID, _ := m["meeting_id"].(string)
row := map[string]interface{}{}
if meetingID != "" {
row["meeting_id"] = meetingID
}
if calEventID, _ := m["calendar_event_id"].(string); calEventID != "" {
row["calendar_event_id"] = calEventID
}
if errMsg, _ := m["error"].(string); errMsg != "" {
row["status"] = "FAIL"
row["error"] = errMsg
} else {
row["status"] = "OK"
if v, _ := m["minute_token"].(string); v != "" {
row["minute_token"] = v
}
if v, _ := m["duration"].(string); v != "" {
row["duration"] = v
}
}
rows = append(rows, row)
}
output.PrintTable(w, rows)
fmt.Fprintf(w, "\n%d recording(s), %d succeeded, %d failed\n", len(results), successCount, len(results)-successCount)
})
return nil
},
}
Loading
Loading