diff --git a/shortcuts/vc/vc_meeting_events.go b/shortcuts/vc/vc_meeting_events.go index 1b9257e7f..a1d4c4828 100644 --- a/shortcuts/vc/vc_meeting_events.go +++ b/shortcuts/vc/vc_meeting_events.go @@ -8,8 +8,10 @@ import ( "fmt" "io" "net/http" + "sort" "strconv" "strings" + "time" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" @@ -102,7 +104,6 @@ var VCMeetingEvents = common.Shortcut{ return err } events = compactMeetingEvents(events) - rows := buildMeetingEventRows(events) outData := map[string]interface{}{ "events": events, "total": data["total"], @@ -110,12 +111,13 @@ var VCMeetingEvents = common.Shortcut{ "page_token": data["page_token"], } - runtime.OutFormat(outData, &output.Meta{Count: len(rows)}, func(w io.Writer) { - if len(rows) == 0 { + timeline := buildMeetingEventTimeline(events) + runtime.OutFormat(outData, &output.Meta{Count: len(timeline.entries)}, func(w io.Writer) { + if len(timeline.entries) == 0 { fmt.Fprintln(w, "No meeting events.") return } - output.PrintTable(w, rows) + io.WriteString(w, renderMeetingEventsPretty(timeline)) }) if hasMore && runtime.Format != "json" && runtime.Format != "" { fmt.Fprintf(runtime.IO().Out, "\n(more available, page_token: %s)\n", pageToken) @@ -360,6 +362,386 @@ func buildMeetingEventRows(events []interface{}) []map[string]interface{} { return rows } +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 + subject string + description string +} + +func buildMeetingEventTimeline(events []interface{}) meetingTimeline { + timeline := meetingTimeline{} + var sequence 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) { + timeline.entries = append(timeline.entries, entry) + } + } + 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) []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) + case "participant_left": + return participantLeftEntries(payload, eventTime, eventTimeOK, sequence) + case "transcript_received": + return transcriptEntries(payload, eventTime, eventTimeOK, sequence) + case "chat_received": + return chatEntries(payload, eventTime, eventTimeOK, sequence) + case "magic_share_started": + return magicShareStartedEntries(payload, eventTime, eventTimeOK, sequence) + case "magic_share_ended": + return magicShareEndedEntries(payload, eventTime, eventTimeOK, sequence) + default: + return []meetingTimelineEntry{newTimelineEntry(eventTime, eventTimeOK, sequence, meetingEventUserDisplayName(nil), meetingEventSummary(event))} + } +} + +func participantJoinedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int) []meetingTimelineEntry { + items := common.GetSlice(payload, "participant_joined_items") + if len(items) == 0 { + return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, "", "加入了会议")} + } + 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, subject, "加入了会议")) + } + return entries +} + +func participantLeftEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int) []meetingTimelineEntry { + items := common.GetSlice(payload, "participant_left_items") + if len(items) == 0 { + return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, "", "离开了会议")} + } + 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, subject, leaveAction(item))) + } + return entries +} + +func transcriptEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int) []meetingTimelineEntry { + items := common.GetSlice(payload, "transcript_received_items") + if len(items) == 0 { + return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, "", "产生了转写")} + } + 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, subject, description)) + } + return entries +} + +func chatEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int) []meetingTimelineEntry { + items := common.GetSlice(payload, "chat_received_items") + if len(items) == 0 { + return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, "", "发送了消息")} + } + 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 = "未知发送者" + } + description := strings.TrimSpace(common.GetString(item, "content")) + if description == "" { + description = "发送了消息" + } + entries = append(entries, newTimelineEntry(when, ok, sequence, subject, description)) + } + return entries +} + +func magicShareStartedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int) []meetingTimelineEntry { + items := common.GetSlice(payload, "magic_share_started_items") + if len(items) == 0 { + return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, "", "开始共享内容")} + } + 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")) + description := "开始共享内容" + if title != "" { + description = fmt.Sprintf("开始共享「%s」", title) + } + entries = append(entries, newTimelineEntry(when, ok, sequence, subject, description)) + } + return entries +} + +func magicShareEndedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int) []meetingTimelineEntry { + items := common.GetSlice(payload, "magic_share_ended_items") + if len(items) == 0 { + return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, "", "结束共享")} + } + 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, subject, "结束共享")) + } + return entries +} + +func newTimelineEntry(when time.Time, hasWhen bool, sequence *int, subject, description string) meetingTimelineEntry { + entry := meetingTimelineEntry{ + when: when, + hasWhen: hasWhen, + sequence: *sequence, + subject: subject, + description: description, + } + *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", timeline.topic) + } + if timeline.hasStart || timeline.hasEnd { + fmt.Fprintf(&b, "会议时间:%s\n", 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, entry.subject) + continue + } + if needsColon(entry.description) { + fmt.Fprintf(&b, "%s: %s\n", entry.subject, entry.description) + } else { + fmt.Fprintf(&b, "%s %s\n", entry.subject, entry.description) + } + continue + } + fmt.Fprintln(&b, entry.description) + } + if b.Len() == 0 { + return "" + } + return b.String() +} + +func formatMeetingWindow(start time.Time, hasStart bool, end time.Time, hasEnd bool) string { + switch { + case hasStart && hasEnd: + return fmt.Sprintf("%s - %s", start.Local().Format("2006-01-02 15:04:05"), end.Local().Format("2006-01-02 15:04:05")) + case hasStart: + return start.Local().Format("2006-01-02 15:04:05") + case hasEnd: + return end.Local().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.Local().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 diff --git a/shortcuts/vc/vc_meeting_events_test.go b/shortcuts/vc/vc_meeting_events_test.go index b21a72c93..c2aada507 100644 --- a/shortcuts/vc/vc_meeting_events_test.go +++ b/shortcuts/vc/vc_meeting_events_test.go @@ -51,6 +51,13 @@ func participantJoinedEvent() map[string]interface{} { "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{}{ @@ -70,7 +77,14 @@ func chatReceivedEvent() map[string]interface{} { "event_type": "chat_received", "event_time": "2026-04-17T08:05:00Z", "payload": map[string]interface{}{ - "activity_event_type": "chat_received", + "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{}{}, @@ -90,6 +104,44 @@ func chatReceivedEvent() map[string]interface{} { } } +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": "第一条", + "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 TestChatReceivedSummary_MultipleItems(t *testing.T) { payload := map[string]interface{}{ "chat_received_items": []interface{}{ @@ -446,7 +498,7 @@ func TestMeetingEvents_ExecuteJSON_PrunesEmptySlices(t *testing.T) { func TestMeetingEvents_ExecutePretty(t *testing.T) { f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) - reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, true, "1710000000000000000")) + reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent(), multiChatReceivedEvent()}, true, "1710000000000000000")) err := mountAndRun(t, VCMeetingEvents, []string{ "+meeting-events", @@ -461,8 +513,11 @@ func TestMeetingEvents_ExecutePretty(t *testing.T) { out := stdout.String() for _, want := range []string{ - "participant_joined", - "participant bot_001 (Demo Bot) joined", + "会议主题:项目例会", + "会议时间:", + "Demo Bot(bot_001) 加入了会议", + "Alice(u1): 第一条", + "Alice(u1): 第二条", "page_token: 1710000000000000000", } { if !strings.Contains(out, want) {