mirror of
https://github.com/larksuite/cli.git
synced 2026-07-05 07:31:22 +08:00
831 lines
30 KiB
Go
831 lines
30 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
//
|
|
// vc +notes — query meeting notes
|
|
//
|
|
// Three mutually exclusive input modes (only one allowed per invocation):
|
|
// meeting-ids: meeting.get → note_id → note detail API
|
|
// minute-tokens: minutes API → note detail + AI artifacts (transcript inlined)
|
|
// calendar-event-ids: primary calendar → mget_instance_relation_info → meeting_id → meeting.get → note_id
|
|
|
|
package vc
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
|
|
|
"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"
|
|
)
|
|
|
|
// per-flag additional scope requirements for +notes (vc:note:read is checked by framework)
|
|
var (
|
|
scopesMeetingIDs = []string{
|
|
"vc:meeting.meetingevent:read",
|
|
"vc:note:read",
|
|
"vc:record:readonly",
|
|
}
|
|
scopesMinuteTokens = []string{
|
|
"minutes:minutes:readonly",
|
|
"minutes:minutes.artifacts:read",
|
|
}
|
|
scopesCalendarEventIDs = []string{
|
|
"calendar:calendar:read",
|
|
"calendar:calendar.event:read",
|
|
"vc:meeting.meetingevent:read",
|
|
"vc:record:readonly",
|
|
}
|
|
)
|
|
|
|
// artifact type enum from note detail API
|
|
const (
|
|
artifactTypeMainDoc = 1 // main note document
|
|
artifactTypeVerbatim = 2 // verbatim transcript
|
|
)
|
|
|
|
const logPrefix = "[vc +notes]"
|
|
|
|
const (
|
|
minutesNoReadPermissionCode = 2091005
|
|
|
|
// recording API specific error codes (used to surface meeting minute_token state).
|
|
recordingNotFoundCode = 121004 // 该会议没有妙记文件
|
|
recordingNoPermissionCode = 121005 // 非会议参与者无权查看
|
|
recordingGeneratingCode = 124002 // 录制/妙记文件仍在生成中
|
|
|
|
// note detail API specific error code.
|
|
noteNoPermissionCode = 121005 // 调用者没有该纪要的阅读权限
|
|
)
|
|
|
|
func minutesReadError(err error, minuteToken string) error {
|
|
var exitErr *output.ExitError
|
|
if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Code != minutesNoReadPermissionCode {
|
|
return err
|
|
}
|
|
|
|
return &output.ExitError{
|
|
Code: output.ExitAPI,
|
|
Detail: &output.ErrDetail{
|
|
Type: "no_read_permission",
|
|
Code: minutesNoReadPermissionCode,
|
|
Message: fmt.Sprintf("No read permission for minute %s: cannot query the minute.", minuteToken),
|
|
Hint: "Ask the minute owner for minute file read permission",
|
|
Detail: exitErr.Detail.Detail,
|
|
},
|
|
Err: err,
|
|
}
|
|
}
|
|
|
|
// validMinuteToken matches the server's minute-token format and blocks any
|
|
// user-supplied token from reaching filesystem paths unsanitized.
|
|
var validMinuteToken = regexp.MustCompile(`^[a-z0-9]+$`)
|
|
|
|
// sanitizeLogValue strips newlines and ANSI escape sequences from user input for safe logging.
|
|
func sanitizeLogValue(s string) string {
|
|
s = strings.ReplaceAll(s, "\n", " ")
|
|
s = strings.ReplaceAll(s, "\r", " ")
|
|
// strip ANSI escape sequences (ESC[...)
|
|
for i := strings.Index(s, "\x1b["); i >= 0; i = strings.Index(s, "\x1b[") {
|
|
end := strings.IndexByte(s[i+2:], 'm')
|
|
if end < 0 {
|
|
s = s[:i]
|
|
break
|
|
}
|
|
s = s[:i] + s[i+2+end+1:]
|
|
}
|
|
return s
|
|
}
|
|
|
|
// getPrimaryCalendarID retrieves the current user's primary calendar ID.
|
|
func getPrimaryCalendarID(runtime *common.RuntimeContext) (string, error) {
|
|
data, err := runtime.DoAPIJSON(http.MethodPost, "/open-apis/calendar/v4/calendars/primary", nil, nil)
|
|
if err != nil {
|
|
return "", err // preserve original API error (with lark error code)
|
|
}
|
|
calendars, _ := data["calendars"].([]any)
|
|
if len(calendars) == 0 {
|
|
return "", output.ErrValidation("primary calendar not found")
|
|
}
|
|
first, _ := calendars[0].(map[string]any)
|
|
cal, _ := first["calendar"].(map[string]any)
|
|
calID, _ := cal["calendar_id"].(string)
|
|
if calID == "" {
|
|
return "", output.ErrValidation("primary calendar ID is empty")
|
|
}
|
|
return calID, nil
|
|
}
|
|
|
|
// eventRelationInfo holds the resolved relation info from mget_instance_relation_info API.
|
|
type eventRelationInfo struct {
|
|
MeetingIDs []string // meeting IDs (one event may spawn multiple meetings)
|
|
MeetingNotes []string // user-bound meeting note doc tokens
|
|
}
|
|
|
|
// resolveMeetingIDsFromCalendarEvent resolves a calendar event instance to its
|
|
// associated meeting IDs and optionally note doc tokens via the mget_instance_relation_info API.
|
|
// When needNotes is true, meeting_notes are also requested.
|
|
// Shared by +notes and +recording for the --calendar-event-ids path.
|
|
func resolveMeetingIDsFromCalendarEvent(runtime *common.RuntimeContext, instanceID string, calendarID string, needNotes bool) (*eventRelationInfo, error) {
|
|
body := map[string]any{
|
|
"instance_ids": []string{instanceID},
|
|
"need_meeting_instance_ids": true,
|
|
}
|
|
if needNotes {
|
|
body["need_meeting_notes"] = true
|
|
}
|
|
data, err := runtime.DoAPIJSON(http.MethodPost,
|
|
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/mget_instance_relation_info", validate.EncodePathSegment(calendarID)),
|
|
nil,
|
|
body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to query event relation info: %w", err)
|
|
}
|
|
|
|
infos, _ := data["instance_relation_infos"].([]any)
|
|
if len(infos) == 0 {
|
|
return nil, fmt.Errorf("no event relation info found")
|
|
}
|
|
info, _ := infos[0].(map[string]any)
|
|
|
|
rawIDs, _ := info["meeting_instance_ids"].([]any)
|
|
if len(rawIDs) == 0 {
|
|
return nil, fmt.Errorf("no associated video meeting for this event")
|
|
}
|
|
|
|
result := &eventRelationInfo{}
|
|
for _, mid := range rawIDs {
|
|
if mid == nil {
|
|
continue
|
|
}
|
|
var meetingID string
|
|
switch v := mid.(type) {
|
|
case float64:
|
|
meetingID = fmt.Sprintf("%.0f", v)
|
|
case string:
|
|
meetingID = v
|
|
default:
|
|
meetingID = fmt.Sprintf("%v", v)
|
|
}
|
|
result.MeetingIDs = append(result.MeetingIDs, meetingID)
|
|
}
|
|
|
|
result.MeetingNotes = extractStringSlice(info, "meeting_notes")
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// extractStringSlice extracts a []string from a JSON array field in a map.
|
|
func extractStringSlice(m map[string]any, key string) []string {
|
|
raw, _ := m[key].([]any)
|
|
var out []string
|
|
for _, v := range raw {
|
|
if s, ok := v.(string); ok && s != "" {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// fetchNoteByCalendarEventID queries notes via calendar event instance ID.
|
|
// Two sources of doc tokens are collected and deduplicated:
|
|
// - mget_instance_relation_info: meeting_notes (user-bound note doc tokens)
|
|
// - meeting_id chain: meeting.get → note detail (note_doc_token, verbatim_doc_token, shared_doc_tokens)
|
|
func fetchNoteByCalendarEventID(ctx context.Context, runtime *common.RuntimeContext, instanceID string, calendarID string) map[string]any {
|
|
errOut := runtime.IO().ErrOut
|
|
|
|
relInfo, err := resolveMeetingIDsFromCalendarEvent(runtime, instanceID, calendarID, true)
|
|
if err != nil {
|
|
return map[string]any{"calendar_event_id": instanceID, "error": err.Error()}
|
|
}
|
|
|
|
result := map[string]any{"calendar_event_id": instanceID}
|
|
|
|
// source 1: user-bound meeting note doc tokens from mget_instance_relation_info
|
|
if len(relInfo.MeetingNotes) > 0 {
|
|
result["meeting_notes"] = relInfo.MeetingNotes
|
|
}
|
|
|
|
// source 2: meeting_id → meeting.get → note detail (for shared_doc_tokens etc.)
|
|
if len(relInfo.MeetingIDs) > 1 {
|
|
fmt.Fprintf(errOut, "%s event %s has %d meetings, trying each\n", logPrefix, sanitizeLogValue(instanceID), len(relInfo.MeetingIDs))
|
|
}
|
|
|
|
for _, meetingID := range relInfo.MeetingIDs {
|
|
fmt.Fprintf(errOut, "%s event %s → meeting_id=%s\n", logPrefix, sanitizeLogValue(instanceID), sanitizeLogValue(meetingID))
|
|
noteResult := fetchNoteByMeetingID(ctx, runtime, meetingID)
|
|
// success means note detail was retrieved, regardless of whether the
|
|
// recording API (minute_token) call succeeded — minute_token failures
|
|
// surface as part of the merged `error` string for downstream visibility.
|
|
if _, ok := noteResult["note_doc_token"].(string); ok {
|
|
for k, v := range noteResult {
|
|
result[k] = v
|
|
}
|
|
deduplicateDocTokens(result)
|
|
return result
|
|
}
|
|
fmt.Fprintf(errOut, "%s meeting_id=%s: %s, trying next\n", logPrefix, sanitizeLogValue(meetingID), noteResult["error"])
|
|
}
|
|
|
|
// meeting chain failed, but still succeed if relation info returned note tokens
|
|
if len(relInfo.MeetingNotes) > 0 {
|
|
return result
|
|
}
|
|
result["error"] = "no notes found in any associated meeting"
|
|
return result
|
|
}
|
|
|
|
// deduplicateDocTokens removes meeting_notes entries that duplicate note detail fields.
|
|
func deduplicateDocTokens(result map[string]any) {
|
|
seen := map[string]bool{}
|
|
if v, _ := result["note_doc_token"].(string); v != "" {
|
|
seen[v] = true
|
|
}
|
|
if v, _ := result["verbatim_doc_token"].(string); v != "" {
|
|
seen[v] = true
|
|
}
|
|
for _, tok := range asStringSlice(result["shared_doc_tokens"]) {
|
|
seen[tok] = true
|
|
}
|
|
|
|
var filtered []string
|
|
for _, tok := range asStringSlice(result["meeting_notes"]) {
|
|
if !seen[tok] {
|
|
filtered = append(filtered, tok)
|
|
}
|
|
}
|
|
if len(filtered) > 0 {
|
|
result["meeting_notes"] = filtered
|
|
} else {
|
|
delete(result, "meeting_notes")
|
|
}
|
|
}
|
|
|
|
// asStringSlice casts v to []string; returns nil for non-[]string or nil values.
|
|
func asStringSlice(v any) []string {
|
|
ss, _ := v.([]string)
|
|
return ss
|
|
}
|
|
|
|
// fetchMeetingMinuteToken queries the recording API of a meeting and returns
|
|
// the associated minute_token (parsed from the recording URL) and an
|
|
// optional human-friendly error message. On success token is non-empty and
|
|
// errMsg is empty; on failure token is empty and errMsg describes the cause:
|
|
// - 121004: meeting has no minute file
|
|
// - 121005: caller has no permission for the meeting recording
|
|
// - 124002: recording / minute file is still being generated
|
|
//
|
|
// Other failures fall back to the raw API error description so Agents can
|
|
// still parse the underlying cause.
|
|
func fetchMeetingMinuteToken(runtime *common.RuntimeContext, meetingID string) (token, errMsg string) {
|
|
data, err := runtime.DoAPIJSON(http.MethodGet,
|
|
fmt.Sprintf("/open-apis/vc/v1/meetings/%s/recording", validate.EncodePathSegment(meetingID)),
|
|
nil, nil)
|
|
if err != nil {
|
|
var exitErr *output.ExitError
|
|
if errors.As(err, &exitErr) && exitErr.Detail != nil {
|
|
switch exitErr.Detail.Code {
|
|
case recordingNotFoundCode:
|
|
return "", "no minute file for this meeting"
|
|
case recordingNoPermissionCode:
|
|
return "", "no permission to access this meeting's minute; ask the meeting owner to share the minute"
|
|
case recordingGeneratingCode:
|
|
return "", "minute file is still being generated; please retry later"
|
|
}
|
|
}
|
|
return "", fmt.Sprintf("failed to query recording: %v", err)
|
|
}
|
|
|
|
recording, _ := data["recording"].(map[string]any)
|
|
if recording == nil {
|
|
return "", "no recording available for this meeting"
|
|
}
|
|
recordingURL, _ := recording["url"].(string)
|
|
if t := extractMinuteToken(recordingURL); t != "" {
|
|
return t, ""
|
|
}
|
|
return "", "no minute_token found in recording URL"
|
|
}
|
|
|
|
// fetchNoteByMeetingID queries notes via meeting_id and additionally fetches
|
|
// the meeting's minute_token via the recording API. The two paths are queried
|
|
// independently; their failures are merged into a single `error` field
|
|
// (semicolon-separated) so Agents always see all causes at once. The
|
|
// `minute_token` field is only populated on success.
|
|
func fetchNoteByMeetingID(ctx context.Context, runtime *common.RuntimeContext, meetingID string) map[string]any {
|
|
data, err := runtime.DoAPIJSON(http.MethodGet, fmt.Sprintf("/open-apis/vc/v1/meetings/%s", validate.EncodePathSegment(meetingID)),
|
|
larkcore.QueryParams{"with_participants": []string{"false"}, "query_mode": []string{"0"}}, nil)
|
|
if err != nil {
|
|
return map[string]any{"meeting_id": meetingID, "error": fmt.Sprintf("failed to query meeting: %v", err)}
|
|
}
|
|
|
|
meeting, _ := data["meeting"].(map[string]any)
|
|
if meeting == nil {
|
|
return map[string]any{"meeting_id": meetingID, "error": "meeting not found"}
|
|
}
|
|
|
|
// Always attempt to query the meeting's minute_token via the recording API,
|
|
// regardless of whether the meeting has a note_id, so callers always see
|
|
// minute state for follow-up calls (e.g. `vc +notes --minute-tokens=...`).
|
|
minuteToken, minuteErr := fetchMeetingMinuteToken(runtime, meetingID)
|
|
|
|
var result map[string]any
|
|
var noteErr string
|
|
if noteID, _ := meeting["note_id"].(string); noteID != "" {
|
|
result = fetchNoteDetail(ctx, runtime, noteID)
|
|
if msg, _ := result["error"].(string); msg != "" {
|
|
noteErr = msg
|
|
delete(result, "error")
|
|
}
|
|
} else {
|
|
result = map[string]any{}
|
|
noteErr = "no notes available for this meeting"
|
|
}
|
|
|
|
result["meeting_id"] = meetingID
|
|
if minuteToken != "" {
|
|
result["minute_token"] = minuteToken
|
|
}
|
|
if combined := joinErrors(noteErr, minuteErr); combined != "" {
|
|
result["error"] = combined
|
|
}
|
|
return result
|
|
}
|
|
|
|
// joinErrors merges multiple non-empty error messages with "; " so Agents can
|
|
// see all causes at once when both note and minute paths fail.
|
|
func joinErrors(msgs ...string) string {
|
|
parts := make([]string, 0, len(msgs))
|
|
for _, m := range msgs {
|
|
if m != "" {
|
|
parts = append(parts, m)
|
|
}
|
|
}
|
|
return strings.Join(parts, "; ")
|
|
}
|
|
|
|
// hasNotesPayload reports whether a result map carries any usable note or
|
|
// minute payload, irrespective of partial failures surfaced via `error`.
|
|
func hasNotesPayload(m map[string]any) bool {
|
|
if m == nil {
|
|
return false
|
|
}
|
|
for _, k := range []string{"note_doc_token", "verbatim_doc_token", "minute_token", "meeting_notes", "shared_doc_tokens", "artifacts"} {
|
|
if v, ok := m[k]; ok && v != nil && v != "" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// fetchNoteByMinuteToken queries notes via minute_token.
|
|
// Fetches both note detail (doc tokens) and AI artifacts (summary/todos/chapters inline +
|
|
// transcript to file) independently, merging into a single result map for Agent consumption.
|
|
func fetchNoteByMinuteToken(ctx context.Context, runtime *common.RuntimeContext, minuteToken string) map[string]any {
|
|
errOut := runtime.IO().ErrOut
|
|
|
|
data, err := runtime.DoAPIJSON(http.MethodGet, fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment(minuteToken)), nil, nil)
|
|
if err != nil {
|
|
err = minutesReadError(err, minuteToken)
|
|
result := map[string]any{"minute_token": minuteToken, "error": err.Error()}
|
|
var exitErr *output.ExitError
|
|
if errors.As(err, &exitErr) && exitErr.Detail != nil && exitErr.Detail.Hint != "" {
|
|
result["hint"] = exitErr.Detail.Hint
|
|
}
|
|
return result
|
|
}
|
|
|
|
minute, _ := data["minute"].(map[string]any)
|
|
if minute == nil {
|
|
return map[string]any{"minute_token": minuteToken, "error": "minutes not found"}
|
|
}
|
|
|
|
result := map[string]any{"minute_token": minuteToken}
|
|
title, _ := minute["title"].(string)
|
|
if title != "" {
|
|
result["title"] = title
|
|
}
|
|
|
|
// path 1: note detail (doc tokens) — fetch when note_id exists
|
|
noteID, _ := minute["note_id"].(string)
|
|
if noteID != "" {
|
|
noteResult := fetchNoteDetail(ctx, runtime, noteID)
|
|
if errMsg, _ := noteResult["error"].(string); errMsg != "" {
|
|
fmt.Fprintf(errOut, "%s note detail failed: %s\n", logPrefix, errMsg)
|
|
} else {
|
|
// merge note detail fields into result
|
|
for k, v := range noteResult {
|
|
result[k] = v
|
|
}
|
|
}
|
|
}
|
|
|
|
// AI artifacts + transcript come from the same /artifacts endpoint.
|
|
artifacts := map[string]any{}
|
|
fetchInlineArtifacts(runtime, minuteToken, title, artifacts)
|
|
if len(artifacts) > 0 {
|
|
result["artifacts"] = artifacts
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// sanitizeDirName generates a safe directory name using title and minuteToken for uniqueness.
|
|
func sanitizeDirName(title, minuteToken string) string {
|
|
const maxLen = 200
|
|
replacer := strings.NewReplacer(
|
|
"/", "_", "\\", "_", ":", "_", "*", "_", "?", "_",
|
|
"\"", "_", "<", "_", ">", "_", "|", "_",
|
|
"\n", "_", "\r", "_", "\t", "_", "\x00", "_",
|
|
)
|
|
safe := replacer.Replace(strings.TrimSpace(title))
|
|
safe = strings.Trim(safe, ".") // remove leading/trailing dots
|
|
if len(safe) > maxLen {
|
|
safe = safe[:maxLen]
|
|
}
|
|
if safe == "" {
|
|
return fmt.Sprintf("artifact-%s", minuteToken)
|
|
}
|
|
return fmt.Sprintf("artifact-%s-%s", safe, minuteToken)
|
|
}
|
|
|
|
// fetchInlineArtifacts fetches summary/todos/chapters/keywords and transcript from the
|
|
// /artifacts API, persists transcript to disk, and exposes the path as transcript_file.
|
|
func fetchInlineArtifacts(runtime *common.RuntimeContext, minuteToken string, title string, result map[string]any) {
|
|
errOut := runtime.IO().ErrOut
|
|
fmt.Fprintf(errOut, "%s fetching AI artifacts...\n", logPrefix)
|
|
data, err := runtime.DoAPIJSON(http.MethodGet, fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/artifacts", validate.EncodePathSegment(minuteToken)), nil, nil)
|
|
if err != nil {
|
|
fmt.Fprintf(errOut, "%s failed to fetch AI artifacts: %v\n", logPrefix, err)
|
|
return
|
|
}
|
|
if summary, ok := data["summary"].(string); ok && summary != "" {
|
|
result["summary"] = summary
|
|
}
|
|
if todos, ok := data["minute_todos"].([]any); ok && len(todos) > 0 {
|
|
result["todos"] = todos
|
|
}
|
|
if chapters, ok := data["minute_chapters"].([]any); ok && len(chapters) > 0 {
|
|
result["chapters"] = chapters
|
|
}
|
|
if keywords, ok := data["keywords"].([]any); ok && len(keywords) > 0 {
|
|
result["keywords"] = keywords
|
|
}
|
|
if transcript, ok := data["transcript"].(string); ok && transcript != "" {
|
|
if path := saveTranscriptToFile(runtime, minuteToken, title, []byte(transcript)); path != "" {
|
|
result["transcript_file"] = path
|
|
}
|
|
}
|
|
}
|
|
|
|
// saveTranscriptToFile persists transcript bytes to the canonical artifact path
|
|
// for the given minute_token. Returns the file path on success (or when the
|
|
// file already exists and --overwrite is not set), empty string on any failure.
|
|
func saveTranscriptToFile(runtime *common.RuntimeContext, minuteToken, title string, content []byte) string {
|
|
errOut := runtime.IO().ErrOut
|
|
|
|
// With no --output-dir the default layout shares the directory with
|
|
// `minutes +download`. Legacy layout is preserved when the flag is set.
|
|
var dirName string
|
|
if outDir := runtime.Str("output-dir"); outDir != "" {
|
|
dirName = filepath.Join(outDir, sanitizeDirName(title, minuteToken))
|
|
} else {
|
|
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", logPrefix, transcriptPath)
|
|
return transcriptPath
|
|
}
|
|
}
|
|
|
|
fmt.Fprintf(errOut, "%s writing transcript: %s\n", logPrefix, transcriptPath)
|
|
if _, err := runtime.FileIO().Save(transcriptPath, fileio.SaveOptions{}, bytes.NewReader(content)); err != nil {
|
|
var me *fileio.MkdirError
|
|
switch {
|
|
case errors.Is(err, fileio.ErrPathValidation):
|
|
fmt.Fprintf(errOut, "%s invalid transcript path: %v\n", logPrefix, err)
|
|
case errors.As(err, &me):
|
|
fmt.Fprintf(errOut, "%s failed to create directory: %v\n", logPrefix, err)
|
|
default:
|
|
fmt.Fprintf(errOut, "%s failed to write transcript: %v\n", logPrefix, err)
|
|
}
|
|
return ""
|
|
}
|
|
return transcriptPath
|
|
}
|
|
|
|
// parseArtifactType extracts artifact_type as int from varying JSON number representations.
|
|
func parseArtifactType(v any) int {
|
|
switch n := v.(type) {
|
|
case json.Number:
|
|
i, _ := n.Int64()
|
|
return int(i)
|
|
case float64:
|
|
return int(n)
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
// extractArtifactTokens picks main-doc and verbatim-doc tokens from the artifacts list.
|
|
func extractArtifactTokens(artifacts []any) (noteDoc, verbatimDoc string) {
|
|
for _, a := range artifacts {
|
|
artifact, _ := a.(map[string]any)
|
|
if artifact == nil {
|
|
continue
|
|
}
|
|
docToken, _ := artifact["doc_token"].(string)
|
|
switch parseArtifactType(artifact["artifact_type"]) {
|
|
case artifactTypeMainDoc:
|
|
noteDoc = docToken
|
|
case artifactTypeVerbatim:
|
|
verbatimDoc = docToken
|
|
default:
|
|
// ignore unknown artifact types
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// extractDocTokens collects doc_token values from a list of reference objects.
|
|
func extractDocTokens(refs []any) []string {
|
|
var tokens []string
|
|
for _, s := range refs {
|
|
source, _ := s.(map[string]any)
|
|
if source == nil {
|
|
continue
|
|
}
|
|
if docToken, _ := source["doc_token"].(string); docToken != "" {
|
|
tokens = append(tokens, docToken)
|
|
}
|
|
}
|
|
return tokens
|
|
}
|
|
|
|
// fetchNoteDetail retrieves note document tokens via note_id.
|
|
func fetchNoteDetail(_ context.Context, runtime *common.RuntimeContext, noteID string) map[string]any {
|
|
data, err := runtime.DoAPIJSON(http.MethodGet, fmt.Sprintf("/open-apis/vc/v1/notes/%s", validate.EncodePathSegment(noteID)), nil, nil)
|
|
if err != nil {
|
|
var exitErr *output.ExitError
|
|
if errors.As(err, &exitErr) && exitErr.Detail != nil && exitErr.Detail.Code == noteNoPermissionCode {
|
|
return map[string]any{"error": fmt.Sprintf("[%v]: no read permission for this meeting note", exitErr.Detail.Code)}
|
|
}
|
|
return map[string]any{"error": fmt.Sprintf("failed to query note detail: %v", err)}
|
|
}
|
|
|
|
note, _ := data["note"].(map[string]any)
|
|
if note == nil {
|
|
return map[string]any{"error": "note detail is empty"}
|
|
}
|
|
|
|
creatorID, _ := note["creator_id"].(string)
|
|
createTime := common.FormatTime(note["create_time"])
|
|
noteDocToken, verbatimDocToken := extractArtifactTokens(common.GetSlice(note, "artifacts"))
|
|
sharedDocTokens := extractDocTokens(common.GetSlice(note, "references"))
|
|
|
|
result := map[string]any{
|
|
"creator_id": creatorID,
|
|
"create_time": createTime,
|
|
"note_doc_token": noteDocToken,
|
|
"verbatim_doc_token": verbatimDocToken,
|
|
}
|
|
if len(sharedDocTokens) > 0 {
|
|
result["shared_doc_tokens"] = sharedDocTokens
|
|
}
|
|
return result
|
|
}
|
|
|
|
// VCNotes queries meeting notes via meeting-ids, minute-tokens, or calendar-event-ids.
|
|
var VCNotes = common.Shortcut{
|
|
Service: "vc",
|
|
Command: "+notes",
|
|
Description: "Query meeting notes (via meeting-ids, minute-tokens, or calendar-event-ids)",
|
|
Risk: "read",
|
|
Scopes: []string{"vc:note:read"}, // minimum scope; additional per-flag scopes checked in Validate
|
|
AuthTypes: []string{"user"},
|
|
HasFormat: true,
|
|
Flags: []common.Flag{
|
|
{Name: "meeting-ids", Desc: "meeting IDs, comma-separated for batch"},
|
|
{Name: "minute-tokens", Desc: "minute tokens, comma-separated for batch"},
|
|
{Name: "calendar-event-ids", Desc: "calendar event instance IDs, comma-separated for batch"},
|
|
{Name: "output-dir", Desc: "output directory for artifact files (default: ./minutes/{minute_token}/)"},
|
|
{Name: "overwrite", Type: "bool", Desc: "overwrite existing artifact files"},
|
|
},
|
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
|
if err := common.ExactlyOne(runtime, "meeting-ids", "minute-tokens", "calendar-event-ids"); err != nil {
|
|
return err
|
|
}
|
|
// batch input size limit
|
|
const maxBatchSize = 50
|
|
for _, flag := range []string{"meeting-ids", "minute-tokens", "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)
|
|
}
|
|
}
|
|
}
|
|
if outDir := runtime.Str("output-dir"); outDir != "" {
|
|
if err := common.ValidateSafePath(runtime.FileIO(), outDir); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
// Reject malformed minute tokens before they flow into filesystem paths.
|
|
if v := runtime.Str("minute-tokens"); v != "" {
|
|
for _, token := range common.SplitCSV(v) {
|
|
if !validMinuteToken.MatchString(token) {
|
|
return output.ErrValidation("invalid minute token %q: must contain only lowercase alphanumeric characters", token)
|
|
}
|
|
}
|
|
}
|
|
// dynamic scope check based on which flag is provided
|
|
var required []string
|
|
switch {
|
|
case runtime.Str("meeting-ids") != "":
|
|
required = scopesMeetingIDs
|
|
case runtime.Str("minute-tokens") != "":
|
|
required = scopesMinuteTokens
|
|
case runtime.Str("calendar-event-ids") != "":
|
|
required = scopesCalendarEventIDs
|
|
default:
|
|
// unreachable: ExactlyOne already ensures one flag is set
|
|
}
|
|
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, required); 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 {
|
|
if ids := runtime.Str("meeting-ids"); ids != "" {
|
|
return common.NewDryRunAPI().
|
|
GET("/open-apis/vc/v1/meetings/{meeting_id}").
|
|
GET("/open-apis/vc/v1/notes/{note_id}").
|
|
GET("/open-apis/vc/v1/meetings/{meeting_id}/recording").
|
|
Set("meeting_ids", common.SplitCSV(ids)).
|
|
Set("steps", "meeting.get → note_id → note detail API + recording API → minute_token")
|
|
}
|
|
if tokens := runtime.Str("minute-tokens"); tokens != "" {
|
|
return common.NewDryRunAPI().
|
|
GET("/open-apis/minutes/v1/minutes/{minute_token}").
|
|
GET("/open-apis/vc/v1/notes/{note_id}").
|
|
GET("/open-apis/minutes/v1/minutes/{minute_token}/artifacts").
|
|
Set("minute_tokens", common.SplitCSV(tokens)).
|
|
Set("steps", "minutes API → note detail + AI artifacts (incl. transcript)")
|
|
}
|
|
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}").
|
|
GET("/open-apis/vc/v1/notes/{note_id}").
|
|
GET("/open-apis/vc/v1/meetings/{meeting_id}/recording").
|
|
Set("calendar_event_ids", common.SplitCSV(ids)).
|
|
Set("steps", "primary calendar → mget_instance_relation_info → meeting_id → meeting.get → note detail API + recording API → 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", logPrefix, 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", logPrefix, sanitizeLogValue(id))
|
|
results = append(results, fetchNoteByMeetingID(ctx, runtime, id))
|
|
}
|
|
} else if tokens := runtime.Str("minute-tokens"); tokens != "" {
|
|
minuteTokens := common.SplitCSV(tokens)
|
|
fmt.Fprintf(errOut, "%s querying %d minute_token(s)\n", logPrefix, 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", logPrefix, sanitizeLogValue(token))
|
|
results = append(results, fetchNoteByMinuteToken(ctx, runtime, token))
|
|
}
|
|
} else {
|
|
instanceIDs := common.SplitCSV(runtime.Str("calendar-event-ids"))
|
|
fmt.Fprintf(errOut, "%s querying %d calendar_event_id(s)\n", logPrefix, len(instanceIDs))
|
|
calendarID, err := getPrimaryCalendarID(runtime)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintf(errOut, "%s primary calendar: %s\n", logPrefix, calendarID)
|
|
for i, id := range instanceIDs {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
if i > 0 {
|
|
time.Sleep(batchDelay)
|
|
}
|
|
fmt.Fprintf(errOut, "%s querying calendar_event_id=%s ...\n", logPrefix, sanitizeLogValue(id))
|
|
results = append(results, fetchNoteByCalendarEventID(ctx, runtime, id, calendarID))
|
|
}
|
|
}
|
|
|
|
// count results: a result counts as "successful" when it carries any
|
|
// note/minute payload, even if the merged `error` field surfaces a
|
|
// partial failure (e.g. note ok but minute_token lookup failed).
|
|
successCount := 0
|
|
for _, r := range results {
|
|
m, _ := r.(map[string]any)
|
|
if hasNotesPayload(m) {
|
|
successCount++
|
|
}
|
|
}
|
|
fmt.Fprintf(errOut, "%s done: %d total, %d succeeded, %d failed\n", logPrefix, len(results), successCount, len(results)-successCount)
|
|
|
|
// all failed → return structured error
|
|
if successCount == 0 && len(results) > 0 {
|
|
outData := map[string]any{"notes": results}
|
|
runtime.OutFormat(outData, &output.Meta{Count: len(results)}, nil)
|
|
return output.ErrAPI(0, fmt.Sprintf("all %d queries failed", len(results)), nil)
|
|
}
|
|
|
|
// output
|
|
outData := map[string]any{"notes": 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)
|
|
id, _ := m["meeting_id"].(string)
|
|
if id == "" {
|
|
id, _ = m["minute_token"].(string)
|
|
}
|
|
if id == "" {
|
|
id, _ = m["calendar_event_id"].(string)
|
|
}
|
|
row := map[string]interface{}{"id": id}
|
|
if errMsg, _ := m["error"].(string); errMsg != "" {
|
|
row["status"] = "FAIL"
|
|
row["error"] = errMsg
|
|
} else {
|
|
row["status"] = "OK"
|
|
if v, _ := m["note_doc_token"].(string); v != "" {
|
|
row["note_doc"] = v
|
|
}
|
|
if v, _ := m["verbatim_doc_token"].(string); v != "" {
|
|
row["verbatim_doc"] = v
|
|
}
|
|
if v, _ := m["shared_doc_tokens"].([]string); len(v) > 0 {
|
|
row["shared_docs"] = strings.Join(v, ", ")
|
|
}
|
|
if v := asStringSlice(m["meeting_notes"]); len(v) > 0 {
|
|
row["meeting_notes"] = strings.Join(v, ", ")
|
|
}
|
|
if v, _ := m["source"].(string); v != "" {
|
|
row["source"] = v
|
|
}
|
|
if v, _ := m["create_time"].(string); v != "" {
|
|
row["create_time"] = v
|
|
}
|
|
if arts, _ := m["artifacts"].(map[string]any); arts != nil {
|
|
if v, _ := arts["transcript_file"].(string); v != "" {
|
|
row["transcript"] = v
|
|
}
|
|
}
|
|
}
|
|
rows = append(rows, row)
|
|
}
|
|
output.PrintTable(w, rows)
|
|
fmt.Fprintf(w, "\n%d note(s), %d succeeded, %d failed\n", len(results), successCount, len(results)-successCount)
|
|
})
|
|
return nil
|
|
},
|
|
}
|