From db1a3fc0a63d2bfe55c2a486107f576658fc7175 Mon Sep 17 00:00:00 2001 From: zhicong666-bytedance Date: Mon, 11 May 2026 21:32:06 +0800 Subject: [PATCH] feat(vc): add agent meeting join, leave, and events shortcuts (#824) * feat(vc): agent join meeting basic shortcuts structure Change-Id: Ic5d64067eb48670fa6636841cd00cbfa9b0bf3e7 * docs: add skill references for vc +meeting-join and +meeting-leave * feat(vc): add meeting events shortcut Add vc +meeting-events for bot meeting activity queries with page-all pagination support and tested pretty/json output. * feat(vc): refine meeting events pagination and output * test: add unit tests for vc +meeting-join and +meeting-leave shortcuts * feat(vc): improve meeting events pretty timeline * feat(vc): refine meeting events pretty output * docs(skill): add vc meeting events shortcut guide * docs(skill): clarify vc meeting events output guidance * docs: clarify participant-snapshot vs meeting-events routing * refactor: split lark-vc-agent from lark-vc * docs: drop nonexistent workflow skill reference and fix identity * docs: fix cross-links in lark-vc-agent references after split * fix(vc): send meeting join password at top level * docs: rewrite lark-vc-agent description in user-facing language * docs: tighten lark-vc-agent description to descriptive neutral tone * fix: use Chinese quotes in vc/vc-agent description YAML frontmatter * docs: downgrade dry-run from mandatory to optional for vc-agent writes * docs: clarify pretty vs json format choice by processing depth * docs: systematic review of lark-vc-agent SKILL for clarity and precision * feat(vc): print meeting event page token in pretty output * docs(skill): refine vc agent meeting guidance * revert: restore CRITICAL banner in lark-vc-agent to match repo convention * docs: replace inaccurate no-replay warning with real social-cost risk * docs: tighten meeting-join risk warning to single sentence * docs: tighten vc-agent references - remove redundancy and fix vague wording * Revert "docs: tighten vc-agent references - remove redundancy and fix vague wording" This reverts commit 9845fc40622c65b0811da1c9ae4902434377f33e. * docs(skill): refine vc meeting events paging guidance * fix(vc): keep meeting event count aligned with events list * docs(skill): tighten vc agent meeting events workflow * refactor(vc): simplify meeting events pagination * docs(skill): tighten vc agent meeting guidance * docs(skill): require reading shared docs for meeting summaries * chore(env): switch default feishu endpoints to pre * fix(env): use feishu accounts host * docs(vc): use explicit date in recording example * revert(env): remove default ppe request header * chore(env): switch default feishu endpoints to pre * docs(skill): guide users to early-bird group on agent meeting gray miss Teach the lark-vc-agent skill to recognize OAPI's new gray-miss signal for the three agent meeting commands (`+meeting-join`, `+meeting-leave`, `+meeting-events`) and route the user to the early-bird group instead of treating it as a permission error. When CLI stderr JSON returns `error.code=20017 / ErrNotInGray`, the agent renders the fixed early-bird invite link `https://go.larkoffice.com/join-chat/2f4nb0e1-fe00-4f67-bed7-25beaf533fbd`. The user manual is intentionally not surfaced yet. Scope-related errors still follow the existing `auth login --scope` flow with no early-bird copy mixed in. lark-shared and other skills are not touched, so the guidance stays scoped to the agent meeting commands only. * chore(env): switch endpoints to boe for agent meeting gray testing * chore(vc-agent): update gray guide and boe endpoints * docs(vc-agent): refine gray guidance flow * docs(vc-agent): centralize gray guidance * fix(ci): stabilize vc output and skill frontmatter * fix(vc): address review feedback --------- Co-authored-by: zhaolei.vc Co-authored-by: renaocheng --- shortcuts/vc/shortcuts.go | 3 + shortcuts/vc/vc_meeting_events.go | 984 ++++++++++++++++++ shortcuts/vc/vc_meeting_events_test.go | 931 +++++++++++++++++ shortcuts/vc/vc_meeting_join.go | 94 ++ shortcuts/vc/vc_meeting_leave.go | 57 + shortcuts/vc/vc_meeting_test.go | 536 ++++++++++ skills/lark-vc-agent/SKILL.md | 121 +++ .../lark-vc-agent-meeting-events.md | 247 +++++ .../references/lark-vc-agent-meeting-join.md | 133 +++ .../references/lark-vc-agent-meeting-leave.md | 111 ++ skills/lark-vc/SKILL.md | 26 +- .../lark-vc/references/lark-vc-recording.md | 2 +- skills/lark-vc/references/lark-vc-search.md | 12 +- 13 files changed, 3252 insertions(+), 5 deletions(-) create mode 100644 shortcuts/vc/vc_meeting_events.go create mode 100644 shortcuts/vc/vc_meeting_events_test.go create mode 100644 shortcuts/vc/vc_meeting_join.go create mode 100644 shortcuts/vc/vc_meeting_leave.go create mode 100644 shortcuts/vc/vc_meeting_test.go create mode 100644 skills/lark-vc-agent/SKILL.md create mode 100644 skills/lark-vc-agent/references/lark-vc-agent-meeting-events.md create mode 100644 skills/lark-vc-agent/references/lark-vc-agent-meeting-join.md create mode 100644 skills/lark-vc-agent/references/lark-vc-agent-meeting-leave.md diff --git a/shortcuts/vc/shortcuts.go b/shortcuts/vc/shortcuts.go index 96b788913..34b9ec246 100644 --- a/shortcuts/vc/shortcuts.go +++ b/shortcuts/vc/shortcuts.go @@ -11,5 +11,8 @@ func Shortcuts() []common.Shortcut { VCSearch, VCNotes, VCRecording, + VCMeetingJoin, + VCMeetingLeave, + VCMeetingEvents, } } diff --git a/shortcuts/vc/vc_meeting_events.go b/shortcuts/vc/vc_meeting_events.go new file mode 100644 index 000000000..dd25af93c --- /dev/null +++ b/shortcuts/vc/vc_meeting_events.go @@ -0,0 +1,984 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package vc + +import ( + "context" + "fmt" + "io" + "net/http" + "sort" + "strconv" + "strings" + "time" + "unicode" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + vcMeetingEventsAPIPath = "/open-apis/vc/v1/bots/events" + defaultVCMeetingEventsSize = 20 + minVCMeetingEventsPageSize = 20 + maxVCMeetingEventsPageSize = 100 + maxVCMeetingEventsPages = 200 +) + +var meetingDisplayLocation = time.FixedZone("UTC+8", 8*60*60) + +// toUnixSeconds converts a supported CLI time input into a Unix seconds string. +func toUnixSeconds(input string, hint ...string) (string, error) { + ts, err := common.ParseTime(input, hint...) + if err != nil { + return "", err + } + if _, err := strconv.ParseInt(ts, 10, 64); err != nil { + return "", fmt.Errorf("invalid timestamp %q: %w", ts, err) + } + return ts, nil +} + +// VCMeetingEvents lists bot meeting events for a meeting. +var VCMeetingEvents = common.Shortcut{ + Service: "vc", + Command: "+meeting-events", + Description: "List bot meeting events by meeting ID", + Risk: "read", + Scopes: []string{"vc:meeting.meetingevent:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "meeting-id", Required: true, Desc: "meeting ID to query"}, + {Name: "start", Desc: "time lower bound (ISO 8601, YYYY-MM-DD, or Unix seconds)"}, + {Name: "end", Desc: "time upper bound (ISO 8601, YYYY-MM-DD, or Unix seconds)"}, + {Name: "page-token", Desc: "page token for the next page"}, + {Name: "page-size", Default: "20", Desc: "page size, 20-100 (default 20)"}, + {Name: "page-all", Type: "bool", Desc: "automatically paginate through all available pages"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if err := validateMeetingEventsMeetingID(runtime.Str("meeting-id")); err != nil { + return err + } + if _, err := meetingEventsPageSize(runtime); err != nil { + return err + } + if _, _, err := parseMeetingEventsTimeRange(runtime); err != nil { + return err + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + startTime, endTime, err := parseMeetingEventsTimeRange(runtime) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + params, err := buildMeetingEventsParams(runtime, startTime, endTime) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + dryRun := common.NewDryRunAPI() + if runtime.Bool("page-all") { + dryRun = dryRun.Desc("Auto-paginates through all available pages") + } + dryRun = dryRun.GET(vcMeetingEventsAPIPath) + if flat := flattenQueryParams(params); len(flat) > 0 { + dryRun.Params(flat) + } + return dryRun + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + startTime, endTime, err := parseMeetingEventsTimeRange(runtime) + if err != nil { + return err + } + data, events, hasMore, pageToken, err := fetchMeetingEvents(ctx, runtime, startTime, endTime) + if err != nil { + return err + } + events = compactMeetingEvents(events) + outData := map[string]interface{}{ + "events": events, + "has_more": data["has_more"], + "page_token": data["page_token"], + } + + timeline := buildMeetingEventTimeline(events) + runtime.OutFormat(outData, &output.Meta{Count: len(events)}, func(w io.Writer) { + if len(timeline.entries) == 0 { + fmt.Fprintln(w, "No meeting events.") + return + } + io.WriteString(w, renderMeetingEventsPretty(timeline)) + }) + if runtime.Format == "pretty" && pageToken != "" { + fmt.Fprintf(runtime.IO().Out, "\npage_token: %s\n", pageToken) + if hasMore { + fmt.Fprintln(runtime.IO().Out, "more available") + } + } + return nil + }, +} + +func meetingEventsPageSize(runtime *common.RuntimeContext) (int, error) { + if runtime.Bool("page-all") { + return maxVCMeetingEventsPageSize, nil + } + pageSizeStr := strings.TrimSpace(runtime.Str("page-size")) + if pageSizeStr == "" { + return defaultVCMeetingEventsSize, nil + } + pageSize, err := strconv.Atoi(pageSizeStr) + if err != nil { + return 0, common.FlagErrorf("invalid --page-size %q: must be an integer", pageSizeStr) + } + if pageSize < minVCMeetingEventsPageSize { + return minVCMeetingEventsPageSize, nil + } + if pageSize > maxVCMeetingEventsPageSize { + return maxVCMeetingEventsPageSize, nil + } + return pageSize, nil +} + +func meetingEventsPaginationConfig(runtime *common.RuntimeContext) (bool, int) { + if !runtime.Bool("page-all") { + return false, 0 + } + return true, maxVCMeetingEventsPages +} + +func validateMeetingEventsMeetingID(meetingID string) error { + meetingID = strings.TrimSpace(meetingID) + if meetingID == "" { + return common.FlagErrorf("--meeting-id is required") + } + value, err := strconv.ParseInt(meetingID, 10, 64) + if err != nil || value <= 0 { + return common.FlagErrorf("--meeting-id must be a positive integer, got %q", meetingID) + } + return nil +} + +// parseMeetingEventsTimeRange validates --start/--end and returns Unix seconds strings. +func parseMeetingEventsTimeRange(runtime *common.RuntimeContext) (string, string, error) { + start := strings.TrimSpace(runtime.Str("start")) + end := strings.TrimSpace(runtime.Str("end")) + if start == "" && end == "" { + return "", "", nil + } + + var startTime, endTime string + if start != "" { + parsed, err := toUnixSeconds(start) + if err != nil { + return "", "", output.ErrValidation("--start: %v", err) + } + startTime = parsed + } + if end != "" { + parsed, err := toUnixSeconds(end, "end") + if err != nil { + return "", "", output.ErrValidation("--end: %v", err) + } + endTime = parsed + } + if startTime != "" && endTime != "" { + startValue, _ := strconv.ParseInt(startTime, 10, 64) + endValue, _ := strconv.ParseInt(endTime, 10, 64) + if startValue > endValue { + return "", "", output.ErrValidation("--start (%s) is after --end (%s)", start, end) + } + } + return startTime, endTime, nil +} + +func buildMeetingEventsParams(runtime *common.RuntimeContext, startTime, endTime string) (larkcore.QueryParams, error) { + pageSize, err := meetingEventsPageSize(runtime) + if err != nil { + return nil, err + } + + params := make(larkcore.QueryParams) + params.Set("meeting_id", strings.TrimSpace(runtime.Str("meeting-id"))) + params.Set("page_size", strconv.Itoa(pageSize)) + if pageToken := strings.TrimSpace(runtime.Str("page-token")); pageToken != "" { + params.Set("page_token", pageToken) + } + if startTime != "" { + params.Set("start_time", startTime) + } + if endTime != "" { + params.Set("end_time", endTime) + } + return params, nil +} + +func fetchMeetingEvents(ctx context.Context, runtime *common.RuntimeContext, startTime, endTime string) (map[string]interface{}, []interface{}, bool, string, error) { + params, err := buildMeetingEventsParams(runtime, startTime, endTime) + if err != nil { + return nil, nil, false, "", err + } + autoPaginate, pageLimit := meetingEventsPaginationConfig(runtime) + if !autoPaginate { + data, err := runtime.DoAPIJSON(http.MethodGet, vcMeetingEventsAPIPath, params, nil) + if err != nil { + return nil, nil, false, "", err + } + if data == nil { + data = map[string]interface{}{} + } + events := common.GetSlice(data, "events") + hasMore, _ := data["has_more"].(bool) + pageToken, _ := data["page_token"].(string) + return data, events, hasMore, pageToken, nil + } + + var ( + allEvents []interface{} + lastData map[string]interface{} + lastPageToken string + lastHasMore bool + ) + for page := 0; page < pageLimit; page++ { + data, err := runtime.DoAPIJSON(http.MethodGet, vcMeetingEventsAPIPath, params, nil) + if err != nil { + return nil, nil, false, "", err + } + if data == nil { + data = map[string]interface{}{} + } + lastData = data + events := common.GetSlice(data, "events") + allEvents = append(allEvents, events...) + lastHasMore, _ = data["has_more"].(bool) + lastPageToken, _ = data["page_token"].(string) + if !lastHasMore || lastPageToken == "" { + break + } + params.Set("page_token", lastPageToken) + } + if lastData == nil { + lastData = map[string]interface{}{} + } + lastData["events"] = allEvents + lastData["has_more"] = lastHasMore + lastData["page_token"] = lastPageToken + return lastData, allEvents, lastHasMore, lastPageToken, nil +} + +func flattenQueryParams(params larkcore.QueryParams) map[string]interface{} { + if len(params) == 0 { + return nil + } + flat := make(map[string]interface{}, len(params)) + for key, values := range params { + switch len(values) { + case 0: + continue + case 1: + flat[key] = values[0] + default: + copied := make([]string, len(values)) + copy(copied, values) + flat[key] = copied + } + } + return flat +} + +func compactMeetingEvents(events []interface{}) []interface{} { + compacted := make([]interface{}, 0, len(events)) + for _, raw := range events { + event, _ := raw.(map[string]interface{}) + if event == nil { + continue + } + if payload := common.GetMap(event, "payload"); payload != nil { + event["payload"] = compactMeetingPayload(payload) + } + compacted = append(compacted, event) + } + return compacted +} + +func compactMeetingPayload(payload map[string]interface{}) map[string]interface{} { + if payload == nil { + return nil + } + compacted := make(map[string]interface{}, len(payload)) + for key, value := range payload { + if items, ok := value.([]interface{}); ok && len(items) == 0 { + continue + } + compacted[key] = value + } + return compacted +} + +type meetingTimeline struct { + topic string + startTime time.Time + hasStart bool + endTime time.Time + hasEnd bool + entries []meetingTimelineEntry +} + +type meetingTimelineEntry struct { + when time.Time + hasWhen bool + sequence int + group int + subject string + description string + details []string +} + +func buildMeetingEventTimeline(events []interface{}) meetingTimeline { + timeline := meetingTimeline{} + var sequence int + var group int + for _, raw := range events { + event, _ := raw.(map[string]interface{}) + if event == nil { + continue + } + payload := common.GetMap(event, "payload") + if payload == nil { + continue + } + if timeline.topic == "" || !timeline.hasStart || !timeline.hasEnd { + populateMeetingHeader(&timeline, common.GetMap(payload, "meeting")) + } + for _, entry := range buildTimelineEntriesForEvent(event, &sequence, group) { + timeline.entries = append(timeline.entries, entry) + } + group++ + } + sort.SliceStable(timeline.entries, func(i, j int) bool { + left := timeline.entries[i] + right := timeline.entries[j] + switch { + case left.hasWhen && right.hasWhen: + if left.when.Equal(right.when) { + return left.sequence < right.sequence + } + return left.when.Before(right.when) + case left.hasWhen: + return true + case right.hasWhen: + return false + default: + return left.sequence < right.sequence + } + }) + return timeline +} + +func populateMeetingHeader(timeline *meetingTimeline, meeting map[string]interface{}) { + if timeline == nil || meeting == nil { + return + } + if timeline.topic == "" { + timeline.topic = common.GetString(meeting, "topic") + } + if !timeline.hasStart { + if parsed, ok := parseFlexibleTime(common.GetString(meeting, "start_time")); ok { + timeline.startTime = parsed + timeline.hasStart = true + } + } + if !timeline.hasEnd { + if parsed, ok := parseFlexibleTime(common.GetString(meeting, "end_time")); ok { + timeline.endTime = parsed + timeline.hasEnd = true + } + } +} + +func buildTimelineEntriesForEvent(event map[string]interface{}, sequence *int, group int) []meetingTimelineEntry { + payload := common.GetMap(event, "payload") + if payload == nil { + return nil + } + eventType := meetingEventType(event) + eventTime, eventTimeOK := parseFlexibleTime(common.GetString(event, "event_time")) + switch eventType { + case "participant_joined": + return participantJoinedEntries(payload, eventTime, eventTimeOK, sequence, group) + case "participant_left": + return participantLeftEntries(payload, eventTime, eventTimeOK, sequence, group) + case "transcript_received": + return transcriptEntries(payload, eventTime, eventTimeOK, sequence, group) + case "chat_received": + return chatEntries(payload, eventTime, eventTimeOK, sequence, group) + case "magic_share_started": + return magicShareStartedEntries(payload, eventTime, eventTimeOK, sequence, group) + case "magic_share_ended": + return magicShareEndedEntries(payload, eventTime, eventTimeOK, sequence, group) + default: + return []meetingTimelineEntry{newTimelineEntry(eventTime, eventTimeOK, sequence, group, meetingEventUserDisplayName(nil), meetingEventSummary(event), nil)} + } +} + +func participantJoinedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry { + items := common.GetSlice(payload, "participant_joined_items") + if len(items) == 0 { + return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "加入了会议", nil)} + } + entries := make([]meetingTimelineEntry, 0, len(items)) + for _, raw := range items { + item, _ := raw.(map[string]interface{}) + when, ok := parseFlexibleTime(common.GetString(item, "join_time")) + if !ok { + when, ok = fallbackTime, fallbackOK + } + subject := meetingEventUserWithID(common.GetMap(item, "participant")) + if subject == "" { + subject = "未知参会人" + } + entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, "加入了会议", nil)) + } + return entries +} + +func participantLeftEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry { + items := common.GetSlice(payload, "participant_left_items") + if len(items) == 0 { + return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "离开了会议", nil)} + } + entries := make([]meetingTimelineEntry, 0, len(items)) + for _, raw := range items { + item, _ := raw.(map[string]interface{}) + when, ok := parseFlexibleTime(common.GetString(item, "leave_time")) + if !ok { + when, ok = fallbackTime, fallbackOK + } + subject := meetingEventUserWithID(common.GetMap(item, "participant")) + if subject == "" { + subject = "未知参会人" + } + entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, leaveAction(item), nil)) + } + return entries +} + +func transcriptEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry { + items := common.GetSlice(payload, "transcript_received_items") + if len(items) == 0 { + return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "产生了转写", nil)} + } + entries := make([]meetingTimelineEntry, 0, len(items)) + for _, raw := range items { + item, _ := raw.(map[string]interface{}) + when, ok := parseFlexibleTime(common.GetString(item, "start_time_ms")) + if !ok { + when, ok = fallbackTime, fallbackOK + } + subject := meetingEventUserWithID(common.GetMap(item, "speaker")) + if subject == "" { + subject = "未知发言人" + } + text := strings.TrimSpace(common.GetString(item, "text")) + description := "产生了转写" + if text != "" { + description = text + } + entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, description, nil)) + } + return entries +} + +func chatEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry { + items := common.GetSlice(payload, "chat_received_items") + if len(items) == 0 { + return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "发送了消息", nil)} + } + entries := make([]meetingTimelineEntry, 0, len(items)) + for _, raw := range items { + item, _ := raw.(map[string]interface{}) + when, ok := parseFlexibleTime(common.GetString(item, "send_time")) + if !ok { + when, ok = fallbackTime, fallbackOK + } + subject := meetingEventUserWithID(common.GetMap(item, "operator")) + if subject == "" { + subject = "未知发送者" + } + typeLabel := chatMessageTypeLabel(item) + description := strings.TrimSpace(common.GetString(item, "content")) + if description == "" { + description = fmt.Sprintf("[%s] 发送了消息", typeLabel) + } else { + description = fmt.Sprintf("[%s] %s", typeLabel, description) + } + entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, description, nil)) + } + return entries +} + +func magicShareStartedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry { + items := common.GetSlice(payload, "magic_share_started_items") + if len(items) == 0 { + return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "开始共享内容", nil)} + } + entries := make([]meetingTimelineEntry, 0, len(items)) + for _, raw := range items { + item, _ := raw.(map[string]interface{}) + when, ok := parseFlexibleTime(common.GetString(item, "time")) + if !ok { + when, ok = fallbackTime, fallbackOK + } + subject := meetingEventUserWithID(common.GetMap(item, "operator")) + if subject == "" { + subject = "未知用户" + } + title := strings.TrimSpace(common.GetString(common.GetMap(item, "share_doc"), "title")) + url := strings.TrimSpace(common.GetString(common.GetMap(item, "share_doc"), "url")) + description := "开始共享内容" + if title != "" { + description = fmt.Sprintf("开始共享「%s」", title) + } + var details []string + if url != "" { + details = append(details, "URL: "+url) + } + entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, description, details)) + } + return entries +} + +func magicShareEndedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry { + items := common.GetSlice(payload, "magic_share_ended_items") + if len(items) == 0 { + return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "结束共享", nil)} + } + entries := make([]meetingTimelineEntry, 0, len(items)) + for _, raw := range items { + item, _ := raw.(map[string]interface{}) + when, ok := parseFlexibleTime(common.GetString(item, "time")) + if !ok { + when, ok = fallbackTime, fallbackOK + } + subject := meetingEventUserWithID(common.GetMap(item, "operator")) + if subject == "" { + subject = "未知用户" + } + entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, "结束共享", nil)) + } + return entries +} + +func newTimelineEntry(when time.Time, hasWhen bool, sequence *int, group int, subject, description string, details []string) meetingTimelineEntry { + entry := meetingTimelineEntry{ + when: when, + hasWhen: hasWhen, + sequence: *sequence, + group: group, + subject: subject, + description: description, + details: details, + } + *sequence = *sequence + 1 + return entry +} + +func parseFlexibleTime(raw string) (time.Time, bool) { + raw = strings.TrimSpace(raw) + if raw == "" { + return time.Time{}, false + } + if ts, err := strconv.ParseInt(raw, 10, 64); err == nil { + switch { + case ts > 1_000_000_000_000: + return time.UnixMilli(ts), true + case ts > 0: + return time.Unix(ts, 0), true + } + } + if parsed, err := time.Parse(time.RFC3339, raw); err == nil { + return parsed, true + } + return time.Time{}, false +} + +func renderMeetingEventsPretty(timeline meetingTimeline) string { + var b strings.Builder + if timeline.topic != "" { + fmt.Fprintf(&b, "会议主题:%s\n", escapePrettyText(timeline.topic)) + } + if timeline.hasStart || timeline.hasEnd { + fmt.Fprintf(&b, "会议时间:%s\n", escapePrettyText(formatMeetingWindow(timeline.startTime, timeline.hasStart, timeline.endTime, timeline.hasEnd))) + } + if b.Len() > 0 { + b.WriteString("\n") + } + for _, entry := range timeline.entries { + fmt.Fprintf(&b, "[%s] ", formatTimelineOffset(entry.when, entry.hasWhen, timeline.startTime, timeline.hasStart)) + if entry.subject != "" { + if entry.description == "" { + fmt.Fprintln(&b, escapePrettyText(entry.subject)) + for _, detail := range entry.details { + fmt.Fprintf(&b, " %s\n", escapePrettyText(detail)) + } + continue + } + if needsColon(entry.description) { + fmt.Fprintf(&b, "%s: %s\n", escapePrettyText(entry.subject), escapePrettyText(entry.description)) + } else { + fmt.Fprintf(&b, "%s %s\n", escapePrettyText(entry.subject), escapePrettyText(entry.description)) + } + for _, detail := range entry.details { + fmt.Fprintf(&b, " %s\n", escapePrettyText(detail)) + } + continue + } + fmt.Fprintln(&b, escapePrettyText(entry.description)) + for _, detail := range entry.details { + fmt.Fprintf(&b, " %s\n", escapePrettyText(detail)) + } + } + if b.Len() == 0 { + return "" + } + return b.String() +} + +func escapePrettyText(s string) string { + if s == "" { + return "" + } + var b strings.Builder + for _, r := range s { + switch r { + case '\n': + b.WriteString(`\n`) + case '\r': + b.WriteString(`\r`) + case '\t': + b.WriteString(`\t`) + default: + if unicode.IsControl(r) { + fmt.Fprintf(&b, "\\u%04X", r) + continue + } + b.WriteRune(r) + } + } + return b.String() +} + +func formatMeetingWindow(start time.Time, hasStart bool, end time.Time, hasEnd bool) string { + switch { + case hasStart && hasEnd: + if !end.After(start) { + return fmt.Sprintf("%s(进行中)", start.In(meetingDisplayLocation).Format("2006-01-02 15:04:05")) + } + return fmt.Sprintf("%s - %s", start.In(meetingDisplayLocation).Format("2006-01-02 15:04:05"), end.In(meetingDisplayLocation).Format("2006-01-02 15:04:05")) + case hasStart: + return start.In(meetingDisplayLocation).Format("2006-01-02 15:04:05") + case hasEnd: + return end.In(meetingDisplayLocation).Format("2006-01-02 15:04:05") + default: + return "" + } +} + +func formatTimelineOffset(when time.Time, hasWhen bool, meetingStart time.Time, hasMeetingStart bool) string { + if hasWhen && hasMeetingStart { + diff := when.Sub(meetingStart) + if diff < 0 { + diff = 0 + } + totalSeconds := int(diff.Seconds()) + hours := totalSeconds / 3600 + minutes := (totalSeconds % 3600) / 60 + seconds := totalSeconds % 60 + return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, seconds) + } + if hasWhen { + return when.In(meetingDisplayLocation).Format("15:04:05") + } + return "??:??:??" +} + +func needsColon(description string) bool { + switch description { + case "发送了消息", "产生了转写": + return false + default: + return !strings.HasPrefix(description, "加入了") && + !strings.HasPrefix(description, "离开了") && + !strings.HasPrefix(description, "被移出") && + !strings.HasPrefix(description, "会议结束") && + !strings.HasPrefix(description, "开始共享") && + !strings.HasPrefix(description, "结束共享") + } +} + +func leaveAction(item map[string]interface{}) string { + switch int(common.GetFloat(item, "leave_reason")) { + case 2: + return "因会议结束离开了会议" + case 3: + return "被移出了会议" + default: + return "离开了会议" + } +} + +func meetingEventUserWithID(user map[string]interface{}) string { + if user == nil { + return "" + } + userID := common.GetString(user, "id") + userName := common.GetString(user, "user_name") + switch { + case userName != "" && userID != "": + return fmt.Sprintf("%s(%s)", userName, userID) + case userName != "": + return userName + case userID != "": + return userID + default: + return "" + } +} + +func meetingEventType(event map[string]interface{}) string { + if eventType := common.GetString(event, "event_type"); eventType != "" { + return eventType + } + return common.GetString(common.GetMap(event, "payload"), "activity_event_type") +} + +func meetingEventSummary(event map[string]interface{}) string { + payload := common.GetMap(event, "payload") + eventType := meetingEventType(event) + switch eventType { + case "participant_joined": + return participantJoinedSummary(payload) + case "participant_left": + return participantLeftSummary(payload) + case "transcript_received": + return transcriptReceivedSummary(payload) + case "chat_received": + return chatReceivedSummary(payload) + case "magic_share_started": + return magicShareStartedSummary(payload) + case "magic_share_ended": + return magicShareEndedSummary(payload) + default: + return fallbackMeetingEventSummary(payload, eventType) + } +} + +func participantJoinedSummary(payload map[string]interface{}) string { + items := common.GetSlice(payload, "participant_joined_items") + switch len(items) { + case 0: + return "participant joined" + case 1: + user := common.GetMap(firstSliceMap(payload, "participant_joined_items"), "participant") + if label := meetingEventUserLabel(user); label != "" { + return fmt.Sprintf("participant %s joined", label) + } + return "participant joined" + default: + return fmt.Sprintf("%d participants joined", len(items)) + } +} + +func participantLeftSummary(payload map[string]interface{}) string { + items := common.GetSlice(payload, "participant_left_items") + switch len(items) { + case 0: + return "participant left" + case 1: + user := common.GetMap(firstSliceMap(payload, "participant_left_items"), "participant") + if label := meetingEventUserLabel(user); label != "" { + return fmt.Sprintf("participant %s left", label) + } + return "participant left" + default: + return fmt.Sprintf("%d participants left", len(items)) + } +} + +func transcriptReceivedSummary(payload map[string]interface{}) string { + items := common.GetSlice(payload, "transcript_received_items") + if len(items) > 1 { + return fmt.Sprintf("%d transcript items", len(items)) + } + item := firstSliceMap(payload, "transcript_received_items") + text := common.GetString(item, "text") + speaker := meetingEventUserLabel(common.GetMap(item, "speaker")) + switch { + case speaker != "" && text != "": + return fmt.Sprintf("speaker %s: %s", speaker, text) + case speaker != "": + return fmt.Sprintf("speaker %s transcript received", speaker) + case text != "": + return fmt.Sprintf("transcript: %s", text) + default: + return "transcript received" + } +} + +func chatReceivedSummary(payload map[string]interface{}) string { + items := common.GetSlice(payload, "chat_received_items") + switch len(items) { + case 0: + return "chat received" + case 1: + item := firstSliceMap(payload, "chat_received_items") + content := common.GetString(item, "content") + operator := meetingEventUserDisplayName(common.GetMap(item, "operator")) + switch { + case operator != "" && content != "": + return fmt.Sprintf("%s: %s", operator, content) + case operator != "": + return fmt.Sprintf("message by %s", operator) + case content != "": + return fmt.Sprintf("message: %s", content) + default: + return "chat received" + } + default: + count, operator := summarizeChatOperators(items) + switch { + case count == 1 && operator != "": + return fmt.Sprintf("%d messages by %s", len(items), operator) + case count > 1: + return fmt.Sprintf("%d messages by %d users", len(items), count) + default: + return fmt.Sprintf("%d messages", len(items)) + } + } +} + +func magicShareStartedSummary(payload map[string]interface{}) string { + items := common.GetSlice(payload, "magic_share_started_items") + if len(items) > 1 { + return fmt.Sprintf("%d share start events", len(items)) + } + item := firstSliceMap(payload, "magic_share_started_items") + shareID := common.GetString(item, "share_id") + title := common.GetString(common.GetMap(item, "share_doc"), "title") + switch { + case shareID != "" && title != "": + return fmt.Sprintf("share %s started: %s", shareID, title) + case shareID != "": + return fmt.Sprintf("share %s started", shareID) + case title != "": + return fmt.Sprintf("share started: %s", title) + default: + return "share started" + } +} + +func magicShareEndedSummary(payload map[string]interface{}) string { + items := common.GetSlice(payload, "magic_share_ended_items") + if len(items) > 1 { + return fmt.Sprintf("%d share end events", len(items)) + } + item := firstSliceMap(payload, "magic_share_ended_items") + if shareID := common.GetString(item, "share_id"); shareID != "" { + return fmt.Sprintf("share %s ended", shareID) + } + return "share ended" +} + +func fallbackMeetingEventSummary(payload map[string]interface{}, eventType string) string { + meeting := common.GetMap(payload, "meeting") + if topic := common.GetString(meeting, "topic"); topic != "" { + if eventType != "" { + return fmt.Sprintf("%s: %s", eventType, topic) + } + return topic + } + if eventType != "" { + return eventType + } + return "meeting event" +} + +func firstSliceMap(payload map[string]interface{}, key string) map[string]interface{} { + items := common.GetSlice(payload, key) + if len(items) == 0 { + return nil + } + first, _ := items[0].(map[string]interface{}) + return first +} + +func meetingEventUserLabel(user map[string]interface{}) string { + if user == nil { + return "" + } + userID := common.GetString(user, "id") + userName := common.GetString(user, "user_name") + switch { + case userID != "" && userName != "": + return fmt.Sprintf("%s (%s)", userID, userName) + case userID != "": + return userID + case userName != "": + return userName + default: + return "" + } +} + +func meetingEventUserDisplayName(user map[string]interface{}) string { + if user == nil { + return "" + } + if userName := common.GetString(user, "user_name"); userName != "" { + return userName + } + return common.GetString(user, "id") +} + +func chatMessageTypeLabel(item map[string]interface{}) string { + code := int(common.GetFloat(item, "message_type")) + switch code { + case 1: + return "text" + case 2: + return "system" + case 3: + return "reaction" + case 4: + return "encrypted" + default: + return "unknown" + } +} + +func summarizeChatOperators(items []interface{}) (int, string) { + seen := make(map[string]struct{}, len(items)) + for _, raw := range items { + item, _ := raw.(map[string]interface{}) + if item == nil { + continue + } + operator := meetingEventUserDisplayName(common.GetMap(item, "operator")) + if operator == "" { + continue + } + seen[operator] = struct{}{} + } + if len(seen) != 1 { + return len(seen), "" + } + for operator := range seen { + return 1, operator + } + return 0, "" +} diff --git a/shortcuts/vc/vc_meeting_events_test.go b/shortcuts/vc/vc_meeting_events_test.go new file mode 100644 index 000000000..b998fb01b --- /dev/null +++ b/shortcuts/vc/vc_meeting_events_test.go @@ -0,0 +1,931 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package vc + +import ( + "context" + "reflect" + "strings" + "testing" + "time" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" +) + +func newMeetingEventsRuntime() *common.RuntimeContext { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("meeting-id", "", "") + cmd.Flags().String("start", "", "") + cmd.Flags().String("end", "", "") + cmd.Flags().String("page-token", "", "") + cmd.Flags().String("page-size", "", "") + cmd.Flags().Bool("page-all", false, "") + return common.TestNewRuntimeContext(cmd, defaultConfig()) +} + +func mustSetMeetingEventsFlag(t *testing.T, runtime *common.RuntimeContext, name, value string) { + t.Helper() + if err := runtime.Cmd.Flags().Set(name, value); err != nil { + t.Fatalf("Flags().Set(%q, %q) error = %v", name, value, err) + } +} + +func meetingEventsStub(events []interface{}, hasMore bool, pageToken string) *httpmock.Stub { + return &httpmock.Stub{ + Method: "GET", + URL: vcMeetingEventsAPIPath, + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "total": len(events), + "has_more": hasMore, + "page_token": pageToken, + "events": events, + }, + }, + } +} + +func participantJoinedEvent() map[string]interface{} { + return map[string]interface{}{ + "event_id": "event-1", + "event_type": "participant_joined", + "event_time": "2026-04-17T08:00:00Z", + "payload": map[string]interface{}{ + "activity_event_type": "participant_joined", + "meeting": map[string]interface{}{ + "id": "7628568141510692381", + "topic": "项目例会", + "meeting_no": "724939760", + "start_time": "1776407700", + "end_time": "1776411300", + }, + "participant_joined_items": []interface{}{ + map[string]interface{}{ + "participant": map[string]interface{}{ + "id": "bot_001", + "user_name": "Demo Bot", + }, + "join_time": "2026-04-17T08:00:00Z", + }, + }, + }, + } +} + +func participantJoinedEventOngoing() map[string]interface{} { + event := participantJoinedEvent() + payload := common.GetMap(event, "payload") + meeting := common.GetMap(payload, "meeting") + meeting["start_time"] = "1776410100" + meeting["end_time"] = "1776410100" + return event +} + +func chatReceivedEvent() map[string]interface{} { + return map[string]interface{}{ + "event_id": "event-2", + "event_type": "chat_received", + "event_time": "2026-04-17T08:05:00Z", + "payload": map[string]interface{}{ + "activity_event_type": "chat_received", + "meeting": map[string]interface{}{ + "id": "7628568141510692381", + "topic": "项目例会", + "meeting_no": "724939760", + "start_time": "1776407700", + "end_time": "1776411300", + }, + "participant_joined_items": []interface{}{}, + "participant_left_items": []interface{}{}, + "transcript_received_items": []interface{}{}, + "magic_share_started_items": []interface{}{}, + "magic_share_ended_items": []interface{}{}, + "chat_received_items": []interface{}{ + map[string]interface{}{ + "content": "hello", + "message_type": 3, + "operator": map[string]interface{}{ + "id": "u1", + "user_name": "Alice", + }, + }, + }, + }, + } +} + +func multiChatReceivedEvent() map[string]interface{} { + return map[string]interface{}{ + "event_id": "event-3", + "event_type": "chat_received", + "event_time": "2026-04-17T08:06:00Z", + "payload": map[string]interface{}{ + "activity_event_type": "chat_received", + "meeting": map[string]interface{}{ + "id": "7628568141510692381", + "topic": "项目例会", + "meeting_no": "724939760", + "start_time": "1776407700", + "end_time": "1776411300", + }, + "chat_received_items": []interface{}{ + map[string]interface{}{ + "content": "第一条\n第二行", + "message_type": 3, + "send_time": "1776408061000", + "operator": map[string]interface{}{ + "id": "u1", + "user_name": "Alice", + }, + }, + map[string]interface{}{ + "content": "第二条", + "message_type": 3, + "send_time": "1776408062000", + "operator": map[string]interface{}{ + "id": "u1", + "user_name": "Alice", + }, + }, + }, + }, + } +} + +func magicShareStartedEvent() map[string]interface{} { + return map[string]interface{}{ + "event_id": "event-4", + "event_type": "magic_share_started", + "event_time": "2026-04-17T08:07:00Z", + "payload": map[string]interface{}{ + "activity_event_type": "magic_share_started", + "meeting": map[string]interface{}{ + "id": "7628568141510692381", + "topic": "项目例会", + "meeting_no": "724939760", + "start_time": "1776407700", + "end_time": "1776411300", + }, + "magic_share_started_items": []interface{}{ + map[string]interface{}{ + "time": "1776408123000", + "operator": map[string]interface{}{ + "id": "u2", + "user_name": "Bob", + }, + "share_doc": map[string]interface{}{ + "title": "共享文档", + "url": "https://example.com/doc", + }, + }, + }, + }, + } +} + +func TestChatReceivedSummary_MultipleItems(t *testing.T) { + payload := map[string]interface{}{ + "chat_received_items": []interface{}{ + map[string]interface{}{"content": "hello"}, + map[string]interface{}{"content": "world"}, + }, + } + + got := chatReceivedSummary(payload) + if got != "2 messages" { + t.Fatalf("chatReceivedSummary() = %q, want %q", got, "2 messages") + } +} + +func TestChatReceivedSummary_MultipleItemsSameOperator(t *testing.T) { + payload := map[string]interface{}{ + "chat_received_items": []interface{}{ + map[string]interface{}{"content": "hello", "operator": map[string]interface{}{"id": "u1", "user_name": "Alice"}}, + map[string]interface{}{"content": "world", "operator": map[string]interface{}{"id": "u1", "user_name": "Alice"}}, + }, + } + + got := chatReceivedSummary(payload) + if got != "2 messages by Alice" { + t.Fatalf("chatReceivedSummary() = %q, want %q", got, "2 messages by Alice") + } +} + +func TestChatReceivedSummary_MultipleItemsMultipleOperators(t *testing.T) { + payload := map[string]interface{}{ + "chat_received_items": []interface{}{ + map[string]interface{}{"content": "hello", "operator": map[string]interface{}{"id": "u1", "user_name": "Alice"}}, + map[string]interface{}{"content": "world", "operator": map[string]interface{}{"id": "u2", "user_name": "Bob"}}, + map[string]interface{}{"content": "again", "operator": map[string]interface{}{"id": "u3", "user_name": "Carol"}}, + }, + } + + got := chatReceivedSummary(payload) + if got != "3 messages by 3 users" { + t.Fatalf("chatReceivedSummary() = %q, want %q", got, "3 messages by 3 users") + } +} + +func TestParticipantJoinedSummary_MultipleItems(t *testing.T) { + payload := map[string]interface{}{ + "participant_joined_items": []interface{}{ + map[string]interface{}{"participant": map[string]interface{}{"id": "u1", "user_name": "User 1"}}, + map[string]interface{}{"participant": map[string]interface{}{"id": "u2", "user_name": "User 2"}}, + }, + } + + got := participantJoinedSummary(payload) + if got != "2 participants joined" { + t.Fatalf("participantJoinedSummary() = %q, want %q", got, "2 participants joined") + } +} + +func TestMeetingEvents_Validation_InvalidMeetingID(t *testing.T) { + runtime := newMeetingEventsRuntime() + mustSetMeetingEventsFlag(t, runtime, "meeting-id", "not-a-number") + + err := VCMeetingEvents.Validate(context.Background(), runtime) + if err == nil { + t.Fatal("expected validation error for invalid meeting ID") + } + if !strings.Contains(err.Error(), "positive integer") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestMeetingEvents_Validation_InvalidTimeRange(t *testing.T) { + runtime := newMeetingEventsRuntime() + mustSetMeetingEventsFlag(t, runtime, "meeting-id", "7628568141510692381") + mustSetMeetingEventsFlag(t, runtime, "start", "200") + mustSetMeetingEventsFlag(t, runtime, "end", "100") + + err := VCMeetingEvents.Validate(context.Background(), runtime) + if err == nil { + t.Fatal("expected validation error for invalid time range") + } + if !strings.Contains(err.Error(), "after --end") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestMeetingEvents_Validation_PageSizeBelowMinDoesNotError(t *testing.T) { + runtime := newMeetingEventsRuntime() + mustSetMeetingEventsFlag(t, runtime, "meeting-id", "7628568141510692381") + mustSetMeetingEventsFlag(t, runtime, "page-size", "10") + + err := VCMeetingEvents.Validate(context.Background(), runtime) + if err != nil { + t.Fatalf("expected no validation error for page-size clamp, got: %v", err) + } +} + +func TestMeetingEvents_Validation_PageAllIgnoresInvalidPageSize(t *testing.T) { + runtime := newMeetingEventsRuntime() + mustSetMeetingEventsFlag(t, runtime, "meeting-id", "7628568141510692381") + mustSetMeetingEventsFlag(t, runtime, "page-all", "true") + mustSetMeetingEventsFlag(t, runtime, "page-size", "10") + + err := VCMeetingEvents.Validate(context.Background(), runtime) + if err != nil { + t.Fatalf("expected no validation error when page-all ignores page-size, got: %v", err) + } +} + +func TestMeetingEvents_Validation_InvalidPageSizeReturnsFlagError(t *testing.T) { + runtime := newMeetingEventsRuntime() + mustSetMeetingEventsFlag(t, runtime, "meeting-id", "7628568141510692381") + mustSetMeetingEventsFlag(t, runtime, "page-size", "foo") + + err := VCMeetingEvents.Validate(context.Background(), runtime) + if err == nil { + t.Fatal("expected validation error for non-integer page-size") + } + if !strings.Contains(err.Error(), "invalid --page-size") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestBuildMeetingEventsParams(t *testing.T) { + runtime := newMeetingEventsRuntime() + mustSetMeetingEventsFlag(t, runtime, "meeting-id", "7628568141510692381") + mustSetMeetingEventsFlag(t, runtime, "page-size", "40") + mustSetMeetingEventsFlag(t, runtime, "page-token", "1710000000000000000") + + params, err := buildMeetingEventsParams(runtime, "1710000000", "1710003600") + if err != nil { + t.Fatalf("buildMeetingEventsParams() error = %v", err) + } + if got := params["meeting_id"][0]; got != "7628568141510692381" { + t.Fatalf("meeting_id = %q, want %q", got, "7628568141510692381") + } + if got := params["page_size"][0]; got != "40" { + t.Fatalf("page_size = %q, want %q", got, "40") + } + if got := params["page_token"][0]; got != "1710000000000000000" { + t.Fatalf("page_token = %q, want %q", got, "1710000000000000000") + } + if got := params["start_time"][0]; got != "1710000000" { + t.Fatalf("start_time = %q, want %q", got, "1710000000") + } + if got := params["end_time"][0]; got != "1710003600" { + t.Fatalf("end_time = %q, want %q", got, "1710003600") + } +} + +func TestBuildMeetingEventsParams_PageSizeBelowMinClampsToMin(t *testing.T) { + runtime := newMeetingEventsRuntime() + mustSetMeetingEventsFlag(t, runtime, "meeting-id", "7628568141510692381") + mustSetMeetingEventsFlag(t, runtime, "page-size", "10") + + params, err := buildMeetingEventsParams(runtime, "", "") + if err != nil { + t.Fatalf("buildMeetingEventsParams() error = %v", err) + } + if got := params["page_size"][0]; got != "20" { + t.Fatalf("page_size = %q, want %q when below min", got, "20") + } +} + +func TestBuildMeetingEventsParams_PageSizeAboveMaxClampsToMax(t *testing.T) { + runtime := newMeetingEventsRuntime() + mustSetMeetingEventsFlag(t, runtime, "meeting-id", "7628568141510692381") + mustSetMeetingEventsFlag(t, runtime, "page-size", "999") + + params, err := buildMeetingEventsParams(runtime, "", "") + if err != nil { + t.Fatalf("buildMeetingEventsParams() error = %v", err) + } + if got := params["page_size"][0]; got != "100" { + t.Fatalf("page_size = %q, want %q when above max", got, "100") + } +} + +func TestBuildMeetingEventsParams_PageAllUsesMaxPageSize(t *testing.T) { + runtime := newMeetingEventsRuntime() + mustSetMeetingEventsFlag(t, runtime, "meeting-id", "7628568141510692381") + mustSetMeetingEventsFlag(t, runtime, "page-all", "true") + mustSetMeetingEventsFlag(t, runtime, "page-size", "50") + + params, err := buildMeetingEventsParams(runtime, "", "") + if err != nil { + t.Fatalf("buildMeetingEventsParams() error = %v", err) + } + if got := params["page_size"][0]; got != "100" { + t.Fatalf("page_size = %q, want %q when page-all is set", got, "100") + } +} + +func TestMeetingEvents_DryRun(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig()) + err := mountAndRun(t, VCMeetingEvents, []string{ + "+meeting-events", + "--meeting-id", "7628568141510692381", + "--page-token", "1710000000000000000", + "--page-size", "40", + "--start", "1710000000", + "--end", "1710003600", + "--dry-run", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := stdout.String() + for _, want := range []string{ + vcMeetingEventsAPIPath, + `"meeting_id": "7628568141510692381"`, + `"page_token": "1710000000000000000"`, + `"page_size": "40"`, + `"start_time": "1710000000"`, + `"end_time": "1710003600"`, + } { + if !strings.Contains(out, want) { + t.Fatalf("dry-run output missing %q: %s", want, out) + } + } +} + +func TestMeetingEvents_DryRun_PageAllUsesMaxLimit(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig()) + err := mountAndRun(t, VCMeetingEvents, []string{ + "+meeting-events", + "--meeting-id", "7628568141510692381", + "--page-all", + "--dry-run", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(stdout.String(), "Auto-paginates through all available pages") { + t.Fatalf("dry-run output missing auto-pagination description: %s", stdout.String()) + } +} + +func TestMeetingEvents_ExecuteJSON_PageAll(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, true, "pt_2")) + reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, false, "")) + + err := mountAndRun(t, VCMeetingEvents, []string{ + "+meeting-events", + "--meeting-id", "7628568141510692381", + "--format", "json", + "--page-all", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + reg.Verify(t) + + out := strings.ReplaceAll(stdout.String(), " ", "") + out = strings.ReplaceAll(out, "\n", "") + if count := strings.Count(out, `"event_type":"participant_joined"`); count != 2 { + t.Fatalf("expected 2 aggregated events, got %d: %s", count, stdout.String()) + } + if !strings.Contains(out, `"has_more":false`) { + t.Fatalf("expected final has_more=false: %s", stdout.String()) + } +} + +func TestMeetingEvents_ExecuteJSON(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, true, "1710000000000000000")) + + err := mountAndRun(t, VCMeetingEvents, []string{ + "+meeting-events", + "--meeting-id", "7628568141510692381", + "--format", "json", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + reg.Verify(t) + + out := strings.ReplaceAll(stdout.String(), " ", "") + out = strings.ReplaceAll(out, "\n", "") + for _, want := range []string{ + `"event_type":"participant_joined"`, + `"has_more":true`, + `"page_token":"1710000000000000000"`, + `"events":[`, + } { + if !strings.Contains(out, want) { + t.Fatalf("json output missing %q: %s", want, stdout.String()) + } + } +} + +func TestMeetingEvents_ExecuteJSON_PrunesEmptySlices(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(meetingEventsStub([]interface{}{chatReceivedEvent()}, false, "")) + + err := mountAndRun(t, VCMeetingEvents, []string{ + "+meeting-events", + "--meeting-id", "7628568141510692381", + "--format", "json", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + reg.Verify(t) + + out := stdout.String() + for _, unwanted := range []string{ + `"participant_joined_items": []`, + `"participant_left_items": []`, + `"transcript_received_items": []`, + `"magic_share_started_items": []`, + `"magic_share_ended_items": []`, + } { + if strings.Contains(out, unwanted) { + t.Fatalf("json output should not contain %q: %s", unwanted, out) + } + } + if !strings.Contains(out, `"message_type": 3`) { + t.Fatalf("json output should keep numeric fields: %s", out) + } +} + +func TestMeetingEvents_ExecutePretty(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(meetingEventsStub([]interface{}{participantJoinedEventOngoing(), multiChatReceivedEvent(), magicShareStartedEvent()}, true, "1710000000000000000")) + + err := mountAndRun(t, VCMeetingEvents, []string{ + "+meeting-events", + "--meeting-id", "7628568141510692381", + "--format", "pretty", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + reg.Verify(t) + + out := stdout.String() + for _, want := range []string{ + "会议主题:项目例会", + "会议时间:2026-04-17 15:15:00(进行中)", + "Demo Bot(bot_001) 加入了会议", + "Alice(u1): [reaction] 第一条\\n第二行", + "Alice(u1): [reaction] 第二条", + "Bob(u2) 开始共享「共享文档」", + "URL: https://example.com/doc", + "page_token: 1710000000000000000", + } { + if !strings.Contains(out, want) { + t.Fatalf("pretty output missing %q: %s", want, out) + } + } + if strings.Contains(out, "第二条\n\n[") { + t.Fatalf("pretty output should not insert blank lines between event entries: %s", out) + } + if !strings.Contains(out, "第二条\n[") { + t.Fatalf("pretty output should keep event entries contiguous: %s", out) + } +} + +func TestMeetingEvents_ExecutePretty_PrintsPageTokenWithoutHasMore(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(meetingEventsStub([]interface{}{participantJoinedEventOngoing()}, false, "pt_last")) + + err := mountAndRun(t, VCMeetingEvents, []string{ + "+meeting-events", + "--meeting-id", "7628568141510692381", + "--format", "pretty", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + reg.Verify(t) + + out := stdout.String() + if !strings.Contains(out, "page_token: pt_last") { + t.Fatalf("pretty output should print page_token even when has_more is false: %s", out) + } + if strings.Contains(out, "more available") { + t.Fatalf("pretty output should not print more-available hint when has_more is false: %s", out) + } +} + +func TestMeetingEvents_ExecuteEmpty(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(meetingEventsStub(nil, false, "")) + + err := mountAndRun(t, VCMeetingEvents, []string{ + "+meeting-events", + "--meeting-id", "7628568141510692381", + "--format", "pretty", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + reg.Verify(t) + + if !strings.Contains(stdout.String(), "No meeting events.") { + t.Fatalf("unexpected output: %s", stdout.String()) + } +} + +func TestParseFlexibleTime(t *testing.T) { + t.Run("unix seconds", func(t *testing.T) { + got, ok := parseFlexibleTime("1776410100") + if !ok { + t.Fatal("parseFlexibleTime() ok = false, want true") + } + if want := time.Unix(1776410100, 0); !got.Equal(want) { + t.Fatalf("parseFlexibleTime() = %v, want %v", got, want) + } + }) + + t.Run("unix millis", func(t *testing.T) { + got, ok := parseFlexibleTime("1776408061000") + if !ok { + t.Fatal("parseFlexibleTime() ok = false, want true") + } + if want := time.UnixMilli(1776408061000); !got.Equal(want) { + t.Fatalf("parseFlexibleTime() = %v, want %v", got, want) + } + }) + + t.Run("rfc3339", func(t *testing.T) { + got, ok := parseFlexibleTime("2026-04-17T08:00:00Z") + if !ok { + t.Fatal("parseFlexibleTime() ok = false, want true") + } + if want, _ := time.Parse(time.RFC3339, "2026-04-17T08:00:00Z"); !got.Equal(want) { + t.Fatalf("parseFlexibleTime() = %v, want %v", got, want) + } + }) + + t.Run("invalid", func(t *testing.T) { + if _, ok := parseFlexibleTime("not-a-time"); ok { + t.Fatal("parseFlexibleTime() ok = true, want false") + } + }) +} + +func TestFormatMeetingWindow(t *testing.T) { + start := time.Unix(1776410100, 0) + end := time.Unix(1776413700, 0) + + tests := []struct { + name string + start time.Time + hasStart bool + end time.Time + hasEnd bool + want string + }{ + { + name: "ongoing", + start: start, + hasStart: true, + end: start, + hasEnd: true, + want: "2026-04-17 15:15:00(进行中)", + }, + { + name: "finished range", + start: start, + hasStart: true, + end: end, + hasEnd: true, + want: "2026-04-17 15:15:00 - 2026-04-17 16:15:00", + }, + { + name: "only start", + start: start, + hasStart: true, + want: "2026-04-17 15:15:00", + }, + { + name: "only end", + end: end, + hasEnd: true, + want: "2026-04-17 16:15:00", + }, + { + name: "empty", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := formatMeetingWindow(tt.start, tt.hasStart, tt.end, tt.hasEnd); got != tt.want { + t.Fatalf("formatMeetingWindow() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestFormatTimelineOffset(t *testing.T) { + start := time.Unix(1776410100, 0) + later := start.Add(90 * time.Second) + earlier := start.Add(-5 * time.Minute) + + tests := []struct { + name string + when time.Time + hasWhen bool + meetingStart time.Time + hasMeetingStart bool + want string + }{ + { + name: "with meeting start", + when: later, + hasWhen: true, + meetingStart: start, + hasMeetingStart: true, + want: "00:01:30", + }, + { + name: "negative diff clamps to zero", + when: earlier, + hasWhen: true, + meetingStart: start, + hasMeetingStart: true, + want: "00:00:00", + }, + { + name: "without meeting start uses wall clock", + when: later, + hasWhen: true, + want: "15:16:30", + }, + { + name: "missing when", + want: "??:??:??", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := formatTimelineOffset(tt.when, tt.hasWhen, tt.meetingStart, tt.hasMeetingStart); got != tt.want { + t.Fatalf("formatTimelineOffset() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestFlattenQueryParams(t *testing.T) { + params := larkcore.QueryParams{ + "one": []string{"1"}, + "many": []string{"2", "3"}, + "empty": []string{}, + } + + got := flattenQueryParams(params) + want := map[string]interface{}{ + "one": "1", + "many": []string{"2", "3"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("flattenQueryParams() = %#v, want %#v", got, want) + } +} + +func TestCompactMeetingPayload_DropsOnlyEmptySlices(t *testing.T) { + got := compactMeetingPayload(map[string]interface{}{ + "empty_items": []interface{}{}, + "items": []interface{}{"x"}, + "zero": 0, + "text": "ok", + }) + + if _, ok := got["empty_items"]; ok { + t.Fatalf("compactMeetingPayload() should drop empty_items: %#v", got) + } + if !reflect.DeepEqual(got["items"], []interface{}{"x"}) { + t.Fatalf("compactMeetingPayload() items = %#v, want %#v", got["items"], []interface{}{"x"}) + } + if got["zero"] != 0 || got["text"] != "ok" { + t.Fatalf("compactMeetingPayload() preserved fields mismatch: %#v", got) + } +} + +func TestCompactMeetingEvents_IgnoresNonMapsAndCompactsPayload(t *testing.T) { + got := compactMeetingEvents([]interface{}{ + "skip-me", + map[string]interface{}{ + "event_type": "chat_received", + "payload": map[string]interface{}{ + "chat_received_items": []interface{}{"x"}, + "empty_items": []interface{}{}, + }, + }, + }) + + if len(got) != 1 { + t.Fatalf("len(compactMeetingEvents()) = %d, want 1", len(got)) + } + event, _ := got[0].(map[string]interface{}) + payload := common.GetMap(event, "payload") + if _, ok := payload["empty_items"]; ok { + t.Fatalf("compactMeetingEvents() should prune empty payload slices: %#v", payload) + } +} + +func TestVCShortcuts_RegistersMeetingAgentCommands(t *testing.T) { + got := Shortcuts() + var commands []string + for _, shortcut := range got { + commands = append(commands, shortcut.Command) + } + want := []string{"+search", "+notes", "+recording", "+meeting-join", "+meeting-leave", "+meeting-events"} + if !reflect.DeepEqual(commands, want) { + t.Fatalf("shortcut commands = %#v, want %#v", commands, want) + } +} + +func TestLeaveAction(t *testing.T) { + tests := []struct { + name string + item map[string]interface{} + want string + }{ + {name: "meeting ended", item: map[string]interface{}{"leave_reason": 2}, want: "因会议结束离开了会议"}, + {name: "kicked", item: map[string]interface{}{"leave_reason": 3}, want: "被移出了会议"}, + {name: "default", item: map[string]interface{}{"leave_reason": 1}, want: "离开了会议"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := leaveAction(tt.item); got != tt.want { + t.Fatalf("leaveAction() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestMeetingEventUserWithID(t *testing.T) { + tests := []struct { + name string + user map[string]interface{} + want string + }{ + {name: "nil", want: ""}, + {name: "name and id", user: map[string]interface{}{"user_name": "Alice", "id": "u1"}, want: "Alice(u1)"}, + {name: "name only", user: map[string]interface{}{"user_name": "Alice"}, want: "Alice"}, + {name: "id only", user: map[string]interface{}{"id": "u1"}, want: "u1"}, + {name: "empty", user: map[string]interface{}{}, want: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := meetingEventUserWithID(tt.user); got != tt.want { + t.Fatalf("meetingEventUserWithID() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestMeetingEventSummary(t *testing.T) { + tests := []struct { + name string + event map[string]interface{} + want string + }{ + { + name: "participant joined count", + event: map[string]interface{}{ + "event_type": "participant_joined", + "payload": map[string]interface{}{ + "participant_joined_items": []interface{}{ + map[string]interface{}{}, + map[string]interface{}{}, + }, + }, + }, + want: "2 participants joined", + }, + { + name: "participant left with label", + event: map[string]interface{}{ + "event_type": "participant_left", + "payload": map[string]interface{}{ + "participant_left_items": []interface{}{ + map[string]interface{}{"participant": map[string]interface{}{"user_name": "Bob", "id": "u2"}}, + }, + }, + }, + want: "participant u2 (Bob) left", + }, + { + name: "fallback unknown event", + event: map[string]interface{}{ + "event_type": "mystery_event", + "payload": map[string]interface{}{}, + }, + want: "mystery_event", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := meetingEventSummary(tt.event); got != tt.want { + t.Fatalf("meetingEventSummary() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestEscapePrettyText(t *testing.T) { + got := escapePrettyText("line1\nline2\t\r" + string(rune(0x07))) + want := `line1\nline2\t\r\u0007` + if got != want { + t.Fatalf("escapePrettyText() = %q, want %q", got, want) + } +} + +func TestNeedsColon(t *testing.T) { + tests := []struct { + description string + want bool + }{ + {description: "发送了消息", want: false}, + {description: "加入了会议", want: false}, + {description: "离开了会议", want: false}, + {description: "开始共享「文档」", want: false}, + {description: "[text] hello", want: true}, + } + for _, tt := range tests { + if got := needsColon(tt.description); got != tt.want { + t.Fatalf("needsColon(%q) = %v, want %v", tt.description, got, tt.want) + } + } +} diff --git a/shortcuts/vc/vc_meeting_join.go b/shortcuts/vc/vc_meeting_join.go new file mode 100644 index 000000000..88db36500 --- /dev/null +++ b/shortcuts/vc/vc_meeting_join.go @@ -0,0 +1,94 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package vc + +import ( + "context" + "fmt" + "io" + "regexp" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +var meetingNumberRe = regexp.MustCompile(`^\d{9}$`) + +// validMeetingNumber checks whether s is a valid 9-digit meeting number. +func validMeetingNumber(s string) bool { + return meetingNumberRe.MatchString(s) +} + +// VCMeetingJoin joins a meeting by meeting number via /vc/v1/bots/join. +var VCMeetingJoin = common.Shortcut{ + Service: "vc", + Command: "+meeting-join", + Description: "Join a meeting by meeting number (bot join)", + Risk: "write", + Scopes: []string{"vc:meeting.bot.join:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "meeting-number", Required: true, Desc: "meeting number to join"}, + {Name: "password", Desc: "meeting password (if required)"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + mn := strings.TrimSpace(runtime.Str("meeting-number")) + if !validMeetingNumber(mn) { + return common.FlagErrorf("--meeting-number must be exactly 9 digits, got %q", mn) + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + body := buildMeetingJoinBody(runtime) + return common.NewDryRunAPI(). + POST("/open-apis/vc/v1/bots/join"). + Body(body) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + body := buildMeetingJoinBody(runtime) + data, err := runtime.DoAPIJSON("POST", "/open-apis/vc/v1/bots/join", nil, body) + if err != nil { + return err + } + if data == nil { + data = map[string]interface{}{} + } + runtime.OutFormat(data, nil, func(w io.Writer) { + meeting, _ := data["meeting"].(map[string]interface{}) + if meeting == nil { + fmt.Fprintln(w, "Joined meeting (no meeting info returned).") + return + } + fmt.Fprintf(w, "Joined meeting successfully.\n") + if id := common.GetString(meeting, "id"); id != "" { + fmt.Fprintf(w, " Meeting ID: %s\n", id) + } + if no := common.GetString(meeting, "meeting_no"); no != "" { + fmt.Fprintf(w, " Meeting No: %s\n", no) + } + if topic := common.GetString(meeting, "topic"); topic != "" { + fmt.Fprintf(w, " Topic: %s\n", topic) + } + if startTime := common.GetString(meeting, "start_time"); startTime != "" { + fmt.Fprintf(w, " Start Time: %s\n", startTime) + } + }) + return nil + }, +} + +func buildMeetingJoinBody(runtime *common.RuntimeContext) map[string]interface{} { + meetingNo := strings.TrimSpace(runtime.Str("meeting-number")) + body := map[string]interface{}{ + "join_type": 1, + "join_identify": map[string]interface{}{ + "meeting_no": meetingNo, + }, + } + if pw := strings.TrimSpace(runtime.Str("password")); pw != "" { + body["password"] = pw + } + return body +} diff --git a/shortcuts/vc/vc_meeting_leave.go b/shortcuts/vc/vc_meeting_leave.go new file mode 100644 index 000000000..011abb348 --- /dev/null +++ b/shortcuts/vc/vc_meeting_leave.go @@ -0,0 +1,57 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package vc + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +// VCMeetingLeave leaves a meeting via /vc/v1/bots/leave. +var VCMeetingLeave = common.Shortcut{ + Service: "vc", + Command: "+meeting-leave", + Description: "Leave a meeting by meeting ID", + Risk: "write", + Scopes: []string{"vc:meeting.bot.join:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "meeting-id", Required: true, Desc: "meeting ID to leave"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if strings.TrimSpace(runtime.Str("meeting-id")) == "" { + return common.FlagErrorf("--meeting-id is required") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + POST("/open-apis/vc/v1/bots/leave"). + Body(map[string]interface{}{ + "meeting_id": strings.TrimSpace(runtime.Str("meeting-id")), + }) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + meetingID := strings.TrimSpace(runtime.Str("meeting-id")) + body := map[string]interface{}{ + "meeting_id": meetingID, + } + data, err := runtime.DoAPIJSON("POST", "/open-apis/vc/v1/bots/leave", nil, body) + if err != nil { + return err + } + if data == nil { + data = map[string]interface{}{} + } + runtime.OutFormat(data, nil, func(w io.Writer) { + fmt.Fprintf(w, "Left meeting %s successfully.\n", meetingID) + }) + return nil + }, +} diff --git a/shortcuts/vc/vc_meeting_test.go b/shortcuts/vc/vc_meeting_test.go new file mode 100644 index 000000000..24ee95909 --- /dev/null +++ b/shortcuts/vc/vc_meeting_test.go @@ -0,0 +1,536 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package vc + +import ( + "bytes" + "context" + "encoding/json" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +// --------------------------------------------------------------------------- +// Unit tests: pure functions +// --------------------------------------------------------------------------- + +func TestValidMeetingNumber(t *testing.T) { + tests := []struct { + name string + in string + want bool + }{ + {"9 digits", "123456789", true}, + {"9 digits leading zero", "012345678", true}, + {"empty", "", false}, + {"8 digits", "12345678", false}, + {"10 digits", "1234567890", false}, + {"with space", "12345 678", false}, + {"letters mixed", "12345678a", false}, + {"pure letters", "abcdefghi", false}, + {"with dash", "123-456-789", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := validMeetingNumber(tt.in); got != tt.want { + t.Errorf("validMeetingNumber(%q) = %v, want %v", tt.in, got, tt.want) + } + }) + } +} + +func TestBuildMeetingJoinBody_WithoutPassword(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("meeting-number", "", "") + cmd.Flags().String("password", "", "") + _ = cmd.Flags().Set("meeting-number", "123456789") + + runtime := common.TestNewRuntimeContext(cmd, defaultConfig()) + body := buildMeetingJoinBody(runtime) + + if body["join_type"] != 1 { + t.Errorf("join_type = %v, want 1", body["join_type"]) + } + ji, ok := body["join_identify"].(map[string]interface{}) + if !ok { + t.Fatalf("join_identify missing or wrong type: %v", body["join_identify"]) + } + if ji["meeting_no"] != "123456789" { + t.Errorf("meeting_no = %v, want 123456789", ji["meeting_no"]) + } + if _, exists := body["password"]; exists { + t.Errorf("password should be omitted when empty, got %v", body["password"]) + } +} + +func TestBuildMeetingJoinBody_WithPassword(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("meeting-number", "", "") + cmd.Flags().String("password", "", "") + _ = cmd.Flags().Set("meeting-number", "123456789") + _ = cmd.Flags().Set("password", "secret") + + runtime := common.TestNewRuntimeContext(cmd, defaultConfig()) + body := buildMeetingJoinBody(runtime) + + if body["password"] != "secret" { + t.Errorf("password = %v, want secret", body["password"]) + } +} + +func TestBuildMeetingJoinBody_TrimsWhitespace(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("meeting-number", "", "") + cmd.Flags().String("password", "", "") + _ = cmd.Flags().Set("meeting-number", " 123456789 ") + _ = cmd.Flags().Set("password", " pw ") + + runtime := common.TestNewRuntimeContext(cmd, defaultConfig()) + body := buildMeetingJoinBody(runtime) + + ji, _ := body["join_identify"].(map[string]interface{}) + if ji["meeting_no"] != "123456789" { + t.Errorf("meeting_no should be trimmed, got %q", ji["meeting_no"]) + } + if body["password"] != "pw" { + t.Errorf("password should be trimmed, got %q", body["password"]) + } +} + +// --------------------------------------------------------------------------- +// Validate tests: VCMeetingJoin +// --------------------------------------------------------------------------- + +func TestMeetingJoin_Validate_MissingNumber(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, defaultConfig()) + // cobra MarkFlagRequired should reject missing --meeting-number + err := mountAndRun(t, VCMeetingJoin, []string{"+meeting-join", "--as", "user"}, f, nil) + if err == nil { + t.Fatal("expected error when --meeting-number is missing") + } + if !strings.Contains(err.Error(), "meeting-number") { + t.Errorf("error should mention meeting-number, got: %v", err) + } +} + +func TestMeetingJoin_Validate_InvalidFormat(t *testing.T) { + tests := []struct { + name string + num string + }{ + {"too short", "12345678"}, + {"too long", "1234567890"}, + {"with letters", "12345abcd"}, + {"empty after trim", " "}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("meeting-number", "", "") + cmd.Flags().String("password", "", "") + _ = cmd.Flags().Set("meeting-number", tt.num) + + runtime := common.TestNewRuntimeContext(cmd, defaultConfig()) + err := VCMeetingJoin.Validate(context.Background(), runtime) + if err == nil { + t.Fatalf("expected validation error for %q", tt.num) + } + if !strings.Contains(err.Error(), "9 digits") { + t.Errorf("error should mention '9 digits', got: %v", err) + } + }) + } +} + +func TestMeetingJoin_Validate_Valid(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("meeting-number", "", "") + cmd.Flags().String("password", "", "") + _ = cmd.Flags().Set("meeting-number", "123456789") + + runtime := common.TestNewRuntimeContext(cmd, defaultConfig()) + if err := VCMeetingJoin.Validate(context.Background(), runtime); err != nil { + t.Errorf("unexpected validation error: %v", err) + } +} + +// --------------------------------------------------------------------------- +// DryRun tests: VCMeetingJoin +// --------------------------------------------------------------------------- + +func TestMeetingJoin_DryRun(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig()) + err := mountAndRun(t, VCMeetingJoin, []string{ + "+meeting-join", "--meeting-number", "123456789", "--password", "pw123", + "--dry-run", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "/open-apis/vc/v1/bots/join") { + t.Errorf("dry-run should include API path, got: %s", out) + } + if !strings.Contains(out, "123456789") { + t.Errorf("dry-run should include meeting number, got: %s", out) + } + if !strings.Contains(out, "pw123") { + t.Errorf("dry-run should include password, got: %s", out) + } +} + +// --------------------------------------------------------------------------- +// Execute tests: VCMeetingJoin +// --------------------------------------------------------------------------- + +func TestMeetingJoin_Execute_Success(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/vc/v1/bots/join", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "meeting": map[string]interface{}{ + "id": "69999999", + "meeting_no": "123456789", + "topic": "Weekly Sync", + "start_time": "1700000000", + }, + }, + }, + } + reg.Register(stub) + + err := mountAndRun(t, VCMeetingJoin, []string{ + "+meeting-join", "--meeting-number", "123456789", + "--format", "json", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // verify captured request body + if len(stub.CapturedBody) == 0 { + t.Fatal("expected request body to be captured") + } + var req map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &req); err != nil { + t.Fatalf("failed to parse request body: %v", err) + } + if req["join_type"].(float64) != 1 { + t.Errorf("join_type = %v, want 1", req["join_type"]) + } + ji, _ := req["join_identify"].(map[string]interface{}) + if ji["meeting_no"] != "123456789" { + t.Errorf("meeting_no = %v, want 123456789", ji["meeting_no"]) + } + if _, exists := ji["password"]; exists { + t.Errorf("password should be omitted when not provided, got %v", ji["password"]) + } + + // verify response envelope carries meeting info under data.meeting + var resp map[string]any + if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse stdout: %v", err) + } + data, _ := resp["data"].(map[string]any) + meeting, _ := data["meeting"].(map[string]any) + if meeting["id"] != "69999999" { + t.Errorf("meeting.id = %v, want 69999999 (envelope: %s)", meeting["id"], stdout.String()) + } +} + +func TestMeetingJoin_Execute_WithPassword_CapturesBody(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/vc/v1/bots/join", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{}, + }, + } + reg.Register(stub) + + err := mountAndRun(t, VCMeetingJoin, []string{ + "+meeting-join", "--meeting-number", "987654321", "--password", "s3cret", + "--format", "json", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var req map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &req); err != nil { + t.Fatalf("failed to parse request body: %v", err) + } + ji, _ := req["join_identify"].(map[string]interface{}) + if req["password"] != "s3cret" { + t.Errorf("password = %v, want s3cret", req["password"]) + } + if ji["meeting_no"] != "987654321" { + t.Errorf("meeting_no = %v, want 987654321", ji["meeting_no"]) + } +} + +func TestMeetingJoin_Execute_PrettyOutput(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/vc/v1/bots/join", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "meeting": map[string]interface{}{ + "id": "69999999", + "meeting_no": "123456789", + "topic": "Weekly Sync", + "start_time": "1700000000", + }, + }, + }, + }) + + err := mountAndRun(t, VCMeetingJoin, []string{ + "+meeting-join", "--meeting-number", "123456789", + "--format", "pretty", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + for _, want := range []string{"Joined meeting successfully", "69999999", "123456789", "Weekly Sync", "1700000000"} { + if !strings.Contains(out, want) { + t.Errorf("pretty output missing %q, got: %s", want, out) + } + } +} + +func TestMeetingJoin_Execute_PrettyOutput_NoMeetingInfo(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/vc/v1/bots/join", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{}, + }, + }) + + err := mountAndRun(t, VCMeetingJoin, []string{ + "+meeting-join", "--meeting-number", "123456789", + "--format", "pretty", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), "no meeting info returned") { + t.Errorf("pretty output should fall back to 'no meeting info' notice, got: %s", stdout.String()) + } +} + +func TestMeetingLeave_Execute_PrettyOutput(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/vc/v1/bots/leave", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{}, + }, + }) + + err := mountAndRun(t, VCMeetingLeave, []string{ + "+meeting-leave", "--meeting-id", "69999999", + "--format", "pretty", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "Left meeting 69999999 successfully") { + t.Errorf("pretty output should confirm leave, got: %s", out) + } +} + +func TestMeetingJoin_Execute_APIError(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/vc/v1/bots/join", + Body: map[string]interface{}{"code": 190001, "msg": "invalid meeting number"}, + }) + + err := mountAndRun(t, VCMeetingJoin, []string{ + "+meeting-join", "--meeting-number", "123456789", + "--as", "user", + }, f, &bytes.Buffer{}) + if err == nil { + t.Fatal("expected error for API failure") + } + if !strings.Contains(err.Error(), "invalid meeting number") { + t.Errorf("error should surface API message, got: %v", err) + } +} + +// --------------------------------------------------------------------------- +// Validate tests: VCMeetingLeave +// --------------------------------------------------------------------------- + +func TestMeetingLeave_Validate_MissingID(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, defaultConfig()) + err := mountAndRun(t, VCMeetingLeave, []string{"+meeting-leave", "--as", "user"}, f, nil) + if err == nil { + t.Fatal("expected error when --meeting-id is missing") + } + if !strings.Contains(err.Error(), "meeting-id") { + t.Errorf("error should mention meeting-id, got: %v", err) + } +} + +func TestMeetingLeave_Validate_WhitespaceOnly(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("meeting-id", "", "") + _ = cmd.Flags().Set("meeting-id", " ") + + runtime := common.TestNewRuntimeContext(cmd, defaultConfig()) + err := VCMeetingLeave.Validate(context.Background(), runtime) + if err == nil { + t.Fatal("expected error for whitespace-only meeting-id") + } + if !strings.Contains(err.Error(), "meeting-id") { + t.Errorf("error should mention meeting-id, got: %v", err) + } +} + +func TestMeetingLeave_Validate_Valid(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("meeting-id", "", "") + _ = cmd.Flags().Set("meeting-id", "69999999") + + runtime := common.TestNewRuntimeContext(cmd, defaultConfig()) + if err := VCMeetingLeave.Validate(context.Background(), runtime); err != nil { + t.Errorf("unexpected validation error: %v", err) + } +} + +// --------------------------------------------------------------------------- +// DryRun tests: VCMeetingLeave +// --------------------------------------------------------------------------- + +func TestMeetingLeave_DryRun(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig()) + err := mountAndRun(t, VCMeetingLeave, []string{ + "+meeting-leave", "--meeting-id", "69999999", + "--dry-run", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "/open-apis/vc/v1/bots/leave") { + t.Errorf("dry-run should include API path, got: %s", out) + } + if !strings.Contains(out, "69999999") { + t.Errorf("dry-run should include meeting-id, got: %s", out) + } +} + +// --------------------------------------------------------------------------- +// Execute tests: VCMeetingLeave +// --------------------------------------------------------------------------- + +func TestMeetingLeave_Execute_Success(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/vc/v1/bots/leave", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{}, + }, + } + reg.Register(stub) + + err := mountAndRun(t, VCMeetingLeave, []string{ + "+meeting-leave", "--meeting-id", "69999999", + "--format", "json", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // verify captured request body + var req map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &req); err != nil { + t.Fatalf("failed to parse request body: %v", err) + } + if req["meeting_id"] != "69999999" { + t.Errorf("meeting_id = %v, want 69999999", req["meeting_id"]) + } +} + +func TestMeetingLeave_Execute_TrimsMeetingID(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/vc/v1/bots/leave", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{}, + }, + } + reg.Register(stub) + + err := mountAndRun(t, VCMeetingLeave, []string{ + "+meeting-leave", "--meeting-id", " 69999999 ", + "--format", "json", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var req map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &req); err != nil { + t.Fatalf("failed to parse request body: %v", err) + } + if req["meeting_id"] != "69999999" { + t.Errorf("meeting_id should be trimmed, got %q", req["meeting_id"]) + } +} + +func TestMeetingLeave_Execute_APIError(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/vc/v1/bots/leave", + Body: map[string]interface{}{"code": 121005, "msg": "no permission"}, + }) + + err := mountAndRun(t, VCMeetingLeave, []string{ + "+meeting-leave", "--meeting-id", "69999999", "--as", "user", + }, f, &bytes.Buffer{}) + if err == nil { + t.Fatal("expected error for API failure") + } + if !strings.Contains(err.Error(), "no permission") { + t.Errorf("error should surface API message, got: %v", err) + } +} diff --git a/skills/lark-vc-agent/SKILL.md b/skills/lark-vc-agent/SKILL.md new file mode 100644 index 000000000..29115c0fe --- /dev/null +++ b/skills/lark-vc-agent/SKILL.md @@ -0,0 +1,121 @@ +--- +name: lark-vc-agent +version: 1.0.0 +description: "飞书视频会议:让机器人代当前用户加入/离开正在进行的会议,并读取会议期间的实时事件(参会人加入与离开、发言、聊天、屏幕共享等)。1. 用户提供 9 位会议号、要求代为入会或离会时使用 +meeting-join / +meeting-leave——会真实产生入会/离会记录。2. 会议进行中用户想知道“谁加入了”“谁离开了”“谁在发言”“有人共享屏幕吗”等会中动态时,机器人入会后用 +meeting-events 读取事件时间线。3. 典型场景:参会机器人、会中助手、代为旁听、代为参会。前提:机器人只能读到它自己参会过且仍在进行中的会议的事件;查询已结束会议的参会名单、纪要或逐字稿请使用 lark-vc 技能。" +metadata: + requires: + bins: ["lark-cli"] + cliHelp: "lark-cli vc --help" +--- + +# vc-agent (v1) + +**CRITICAL — 开始前 MUST 先用 Read 工具读取以下两份 skill 文档:** + +- [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) — 认证、身份切换、权限处理 +- [`../lark-vc/SKILL.md`](../lark-vc/SKILL.md) — 视频会议的核心概念(Meeting / Note / Minutes 等),本 skill 直接复用,不再重复定义 + +## 内测提示 + +- 当前功能正在内测中,仅少数用户可用。忽略 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) 中的权限申请流程,以下方的指引为准。 +- 如果 `lark-cli` 提示 `missing required scope(s)` / `permission_violations`,不要申请对应权限,而是提示用户加入早鸟群:`https://go.larkoffice.com/join-chat/2f4nb0e1-fe00-4f67-bed7-25beaf533fbd`。 +- 如果 `lark-cli` 返回 `error.code=20017` / `ErrNotInGray`,提示用户加入早鸟群:`https://go.larkoffice.com/join-chat/2f4nb0e1-fe00-4f67-bed7-25beaf533fbd`。 + +## 定位 + +本 skill 与 [`lark-vc`](../lark-vc/SKILL.md) 并列: + +- **`lark-vc`** **负责"会后查询"**:搜索历史会议、参会人快照、纪要/逐字稿/录制 +- **`lark-vc-agent`** **负责"会中动作"**:机器人入会 / 读取进行中会议的实时事件 / 机器人离会 + +按此分工路由,避免两个 skill 语义混淆。 + +| 用户意图示例 | 应路由到 | +| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| "帮我入会 123456789"、"代我参会"、"让机器人进会旁听" | **本 skill** `+meeting-join` | +| "会议现在还开着,谁刚加入了"、"会议里谁在发言"、"有人共享屏幕吗"(**进行中会议**,且**机器人已入会**) | **本 skill** `+meeting-events` | +| "退出会议"、"让机器人离开" | **本 skill** `+meeting-leave` | +| "昨天那场会有谁参加过"、"搜昨天的会"、"查纪要/逐字稿/录制" | [`lark-vc`](../lark-vc/SKILL.md) | +| "帮我参会,结束后把纪要发到群" 等跨阶段场景 | 按序编排:本 skill(入会 → 读事件 → 离会)→ [`lark-vc`](../lark-vc/SKILL.md) / [`lark-minutes`](../lark-minutes/SKILL.md)(拉纪要)→ [`lark-im`](../lark-im/SKILL.md)(发群) | + +## 核心场景 + +### 1. 加入正在进行的会议(写操作) + +1. 只有用户明确表达"让 Agent **真实入会**"(参会机器人、会中助手、代为旁听、代参会)时才用 `+meeting-join`。只是查数据不要入会。 +2. `+meeting-join --meeting-number` 只接受 **9 位纯数字**会议号,不是会议链接整串、也不是 `meeting_id`。 +3. 返回体中的 `meeting.id` **必须立刻记录**——后续 `+meeting-events` / `+meeting-leave` 都靠它,**不能用 9 位会议号替代**。 +4. 入会对所有参会人可见,执行前核实 9 位会议号来源,避免误入错会。 +5. 仅支持 `user` 身份,需提前 `lark-cli auth login`。 +6. 若入会失败,优先查看 `+meeting-join` reference 的错误排查段落,重点确认会议号、密码、会议状态、等候室 / 审批以及会议是否禁止当前身份加入。 + +### 2. 感知会中事件(读操作) + +1. 用户要看"会议里正在发生什么"(参会人加入/离开、聊天、转写、屏幕共享)时,用 `+meeting-events`。 +2. 输入是 **`meeting_id`**(长数字 ID),不是 9 位会议号。 +3. Bot 必须**真实参会过**(先 `+meeting-join`),否则事件流通常不可见。具体的状态边界、结束后宽限窗口与错误码(如 `10005 / 20001 / 20002`)请查看 `+meeting-events` reference。 +4. **不能做会后复盘**,**不能替代参会人快照查询**。如果会议已结束: + - 想拿纪要文档或逐字稿文档 token:用 `lark-cli vc +notes --meeting-ids ` + - 想拿 AI 产物(summary / todos / chapters)或导出逐字稿文件:先用 `lark-cli vc +recording --meeting-ids ` 拿 `minute_token`,再用 `lark-cli vc +notes --minute-tokens ` + - 想看参会人快照:用 `vc meeting get --with-participants`(见 [`lark-vc`](../lark-vc/SKILL.md)) +5. **默认必须使用** **`--page-all`**,除非用户明确要求“只查一页”,或确实需要控制返回体大小。 +6. 输出格式默认优先 `--format pretty`(时间线更易读);只有在需要完整保留原始消息流与结构化字段时,才使用 `--format json`。 +7. **必须识别分页信号**:只要响应里出现 `has_more=true`、pretty 里的 `more available`,或返回了非空 `page_token`,就不能把当前结果当作完整事件流;默认应继续分页,或明确告诉用户当前只是部分结果。 +8. 保留响应里的 `page_token`,下次增量拉取直接续,不要从头再拉。 +9. **只要你是基于** **`+meeting-events`** **来回答一场正在进行中的会议内容,就不能直接复用旧结果。** 无论用户是在问“现在/刚刚/最新”的状态,还是让你“总结一下这个会议讲什么”,都必须先重新拉一次当前事件流,确认拿到的是最新信息,再基于最新结果回答。只有在用户明确要求基于某次历史快照继续分析时,才可以复用旧结果。 + +### 3. 离开会议(写操作) + +1. 任务完成、或用户要求结束时,用 `+meeting-leave --meeting-id <从 +meeting-join 拿到的 meeting.id>`。 +2. `--meeting-id` **必须**是 `+meeting-join` 返回的长数字 `meeting.id`,**不接受 9 位会议号**。 +3. 离会**立即生效**,机器人从会议的参会人列表中消失,对其他参会人可见;若需要重新入会,再跑一次 `+meeting-join` 即可(非真正"不可逆")。 +4. 仅支持 `user` 身份。 + +### 4. Agent 参会最小闭环示范 + +```bash +# 1. 入会,捕获 meeting.id +JOIN=$(lark-cli vc +meeting-join --meeting-number 123456789 --format json) +MID=$(echo "$JOIN" | jq -r '.data.meeting.id') + +# 2. 会中轮询事件 +# 默认用 --page-all 拉全当前可见事件;下次增量优先复用 page_token +# 典型间隔 10-30 秒 +lark-cli vc +meeting-events --meeting-id "$MID" --page-all --format pretty + +# 3. 任务完成或用户要求结束时离会 +lark-cli vc +meeting-leave --meeting-id "$MID" + +# 4. 会后可选:取纪要 / 逐字稿(跨到 lark-vc) +lark-cli vc +notes --meeting-ids "$MID" +``` + +## Shortcuts + +Shortcut 是对常用操作的高级封装(`lark-cli vc + [flags]`)。 + +| Shortcut | 类型 | 说明 | +| --------------------------------------------------------------- | -- | -------------------------------------------------------------------------- | +| [`+meeting-join`](references/lark-vc-agent-meeting-join.md) | 写 | Join an in-progress meeting by 9-digit meeting number | +| [`+meeting-events`](references/lark-vc-agent-meeting-events.md) | 读 | List bot meeting events (participant joined/left, transcript, chat, share) | +| [`+meeting-leave`](references/lark-vc-agent-meeting-leave.md) | 写 | Leave a meeting by meeting\_id | + +- 使用 `+meeting-join` 前**必须**阅读 [references/lark-vc-agent-meeting-join.md](references/lark-vc-agent-meeting-join.md),了解入参格式与写操作可见性风险。 +- 使用 `+meeting-events` 前**必须**阅读 [references/lark-vc-agent-meeting-events.md](references/lark-vc-agent-meeting-events.md),了解 `meeting_id` 来源、分页、错误码(10005 / 20001 / 20002)与 "bot 仍在会中" 硬约束。 +- 使用 `+meeting-leave` 前**必须**阅读 [references/lark-vc-agent-meeting-leave.md](references/lark-vc-agent-meeting-leave.md),了解 `meeting_id` 的来源与写操作可见性。 + +## 权限表 + +| Shortcut | 所需 scope | +| ----------------- | ------------------------------ | +| `+meeting-join` | `vc:meeting.bot.join:write` | +| `+meeting-events` | `vc:meeting.meetingevent:read` | +| `+meeting-leave` | `vc:meeting.bot.join:write` | + +## 延伸 + +- 查已结束会议、参会人快照、搜索历史会议 → [`lark-vc`](../lark-vc/SKILL.md) +- 会议纪要、逐字稿 → [`lark-vc`](../lark-vc/SKILL.md) 的 `+notes` +- 妙记产物(AI 总结 / 转写 / 章节)→ [`lark-minutes`](../lark-minutes/SKILL.md) +- 会后把产物发到群 / 私聊 → [`lark-im`](../lark-im/SKILL.md) +- 认证、身份切换、scope 管理 → [`lark-shared`](../lark-shared/SKILL.md) diff --git a/skills/lark-vc-agent/references/lark-vc-agent-meeting-events.md b/skills/lark-vc-agent/references/lark-vc-agent-meeting-events.md new file mode 100644 index 000000000..5a5629b1a --- /dev/null +++ b/skills/lark-vc-agent/references/lark-vc-agent-meeting-events.md @@ -0,0 +1,247 @@ + +# vc +meeting-events + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +查询当前 bot 在一场正在进行的视频会议中收到的会中事件列表。该命令是**读操作**。对进行中会议,要求 bot 当前仍在会中;对已结束会议,存在一个**结束后 5 分钟内的宽限窗口**,只要 bot 曾经在这场会里出现过,仍可继续拉取事件。 + +本 skill 对应 shortcut:`lark-cli vc +meeting-events`(调用 `GET /open-apis/vc/v1/bots/events`)。 + +## 命令 + +```bash +# 默认用法:全量拉取当前可见事件 +lark-cli vc +meeting-events --meeting-id 69xxxxxxxxxxxxx28 --page-all --format pretty + +# 指定时间范围,并拉全该时间窗内当前可见事件 +lark-cli vc +meeting-events --meeting-id 69xxxxxxxxxxxxx28 --start 2026-04-17T15:00:00+08:00 --end 2026-04-17T16:00:00+08:00 --page-all --format pretty + +# 基于上一次保存的 page_token 继续查新增事件 +lark-cli vc +meeting-events --meeting-id 69xxxxxxxxxxxxx28 --page-token --page-all --format pretty + +# 调试或控制返回体大小时,显式只查一页 +lark-cli vc +meeting-events --meeting-id 69xxxxxxxxxxxxx28 --page-size 20 --format json + +# 预览 API 调用(不实际请求) +lark-cli vc +meeting-events --meeting-id 69xxxxxxxxxxxxx28 --dry-run +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--meeting-id ` | 是 | 会议 ID(长数字 ID,不是 9 位会议号) | +| `--start