feat(vc): support bot meeting activity events

This commit is contained in:
renaocheng
2026-06-30 16:34:16 +08:00
parent bdffffb368
commit e72bb64be9
10 changed files with 1456 additions and 121 deletions

156
events/vc/bot_events.go Normal file
View File

@@ -0,0 +1,156 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"bytes"
"context"
"encoding/json"
"strings"
"github.com/larksuite/cli/internal/event"
)
// VCBotEventOutput is the raw-preserving shape for bot-observed VC events.
type VCBotEventOutput struct {
Type string `json:"type" desc:"Event type; one of the supported vc.bot.* keys"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"`
CallID string `json:"call_id,omitempty" desc:"Bot invitation call ID; pass through to vc agent join when present"`
MeetingNo string `json:"meeting_no,omitempty" desc:"Meeting number from the bot event's declared meeting field"`
ActivityEventType string `json:"activity_event_type,omitempty" desc:"First event.meeting_activity_items[].activity_event_type value"`
RawEvent json.RawMessage `json:"raw_event,omitempty" desc:"Original VC bot event payload; authoritative for fields not exposed as stable top-level fields"`
}
func processVCBotMeetingInvited(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
return processVCBotEvent(raw)
}
func processVCBotMeetingEvent(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
return processVCBotEvent(raw)
}
func processVCBotMeetingEnded(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
return processVCBotEvent(raw)
}
type vcBotEventEnvelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event json.RawMessage `json:"event"`
}
type vcBotMeetingInvitedEvent struct {
CallID string `json:"call_id"`
Meeting struct {
MeetingNo string `json:"meeting_no"`
} `json:"meeting"`
}
type vcBotMeetingActivityEvent struct {
MeetingActivityItems []vcBotMeetingActivityItem `json:"meeting_activity_items"`
}
type vcBotMeetingActivityItem struct {
ActivityEventType string `json:"activity_event_type"`
Meeting struct {
MeetingNo string `json:"meeting_no"`
} `json:"meeting"`
}
type vcBotMeetingEndedEvent struct {
MeetingNo string `json:"meeting_no"`
}
func processVCBotEvent(raw *event.RawEvent) (json.RawMessage, error) {
var envelope vcBotEventEnvelope
decoder := json.NewDecoder(bytes.NewReader(raw.Payload))
decoder.UseNumber()
if err := decoder.Decode(&envelope); err != nil {
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
}
eventType := envelope.Header.EventType
if eventType == "" {
eventType = raw.EventType
}
out := &VCBotEventOutput{
Type: eventType,
EventID: envelope.Header.EventID,
Timestamp: envelope.Header.CreateTime,
RawEvent: append(json.RawMessage(nil), raw.Payload...),
}
fillBotEventOutput(eventType, envelope.Event, out)
return json.Marshal(out)
}
func fillBotEventOutput(eventType string, data json.RawMessage, out *VCBotEventOutput) {
switch eventType {
case eventTypeBotMeetingInvited:
payload, err := decodeBotMeetingInvitedEvent(data)
if err != nil {
return
}
out.CallID = strings.TrimSpace(payload.CallID)
out.MeetingNo = strings.TrimSpace(payload.Meeting.MeetingNo)
case eventTypeBotMeetingEvent:
payload, err := decodeBotMeetingActivityEvent(data)
if err != nil {
return
}
out.MeetingNo = botActivityMeetingNo(payload.MeetingActivityItems)
out.ActivityEventType = botActivityEventType(payload.MeetingActivityItems)
case eventTypeBotMeetingEnded:
payload, err := decodeBotMeetingEndedEvent(data)
if err != nil {
return
}
out.MeetingNo = strings.TrimSpace(payload.MeetingNo)
}
}
func decodeBotMeetingInvitedEvent(data json.RawMessage) (vcBotMeetingInvitedEvent, error) {
var payload vcBotMeetingInvitedEvent
if err := json.Unmarshal(data, &payload); err != nil {
return vcBotMeetingInvitedEvent{}, err
}
return payload, nil
}
func decodeBotMeetingActivityEvent(data json.RawMessage) (vcBotMeetingActivityEvent, error) {
var payload vcBotMeetingActivityEvent
if err := json.Unmarshal(data, &payload); err != nil {
return vcBotMeetingActivityEvent{}, err
}
return payload, nil
}
func decodeBotMeetingEndedEvent(data json.RawMessage) (vcBotMeetingEndedEvent, error) {
var payload vcBotMeetingEndedEvent
if err := json.Unmarshal(data, &payload); err != nil {
return vcBotMeetingEndedEvent{}, err
}
return payload, nil
}
func botActivityMeetingNo(items []vcBotMeetingActivityItem) string {
for _, item := range items {
if meetingNo := strings.TrimSpace(item.Meeting.MeetingNo); meetingNo != "" {
return meetingNo
}
}
return ""
}
func botActivityEventType(items []vcBotMeetingActivityItem) string {
for _, item := range items {
if eventType := strings.TrimSpace(item.ActivityEventType); eventType != "" {
return eventType
}
}
return ""
}

View File

@@ -0,0 +1,277 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"encoding/json"
"reflect"
"testing"
"time"
"github.com/larksuite/cli/internal/event"
)
func TestVCKeys_BotEventsRegistered(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
for _, eventType := range []string{
eventTypeBotMeetingInvited,
eventTypeBotMeetingEvent,
eventTypeBotMeetingEnded,
} {
t.Run(eventType, func(t *testing.T) {
def, ok := event.Lookup(eventType)
if !ok {
t.Fatalf("%s should be registered via Keys()", eventType)
}
if def.Schema.Custom == nil {
t.Error("bot event must set Schema.Custom")
}
if def.Schema.Native != nil {
t.Error("bot event must not set Schema.Native")
}
if def.Process == nil {
t.Error("bot event Process must not be nil")
}
if def.PreConsume != nil {
t.Fatal("bot event must not reuse user-side VC PreConsume subscription")
}
if !reflect.DeepEqual(def.AuthTypes, []string{"bot"}) {
t.Errorf("AuthTypes = %v, want [bot]", def.AuthTypes)
}
if !reflect.DeepEqual(def.RequiredConsoleEvents, []string{eventType}) {
t.Errorf("RequiredConsoleEvents = %v, want [%s]", def.RequiredConsoleEvents, eventType)
}
})
}
}
func TestProcessVCBotEvents_StableFieldsAndRawEvent(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cases := []struct {
name string
eventType string
process event.ProcessFunc
payload string
want VCBotEventOutput
}{
{
name: "invited",
eventType: eventTypeBotMeetingInvited,
process: processVCBotMeetingInvited,
payload: `{
"schema": "2.0",
"header": {
"event_id": "ev_invited",
"event_type": "vc.bot.meeting_invited_v1",
"create_time": "1776409469273"
},
"event": {
"call_id": "call_123",
"meeting": {"meeting_no": "123456789"}
}
}`,
want: VCBotEventOutput{
Type: eventTypeBotMeetingInvited,
EventID: "ev_invited",
Timestamp: "1776409469273",
CallID: "call_123",
MeetingNo: "123456789",
},
},
{
name: "meeting activity",
eventType: eventTypeBotMeetingEvent,
process: processVCBotMeetingEvent,
payload: `{
"schema": "2.0",
"header": {
"event_id": "ev_activity",
"event_type": "vc.bot.meeting_activity_v1",
"create_time": "1776409469274"
},
"event": {
"meeting_no": "should_not_use",
"activity_event_type": "should_not_use",
"chat_messages": [
{"message_type": 3, "content": "SHOULD_NOT_COLLECT"}
],
"meeting_activity_items": [{
"activity_event_type": "chat_received",
"meeting": {"meeting_no": "987654321"},
"chat_received_items": [
{"message_type": 1, "content": "hello"},
{"message_type": 3, "content": "JIAYI", "reaction_type": {"emoji_type": "SHOULD_NOT_COLLECT"}},
{"message_type": 3, "content": "OK"},
{"message_type": 3, "content": "JIAYI"}
]
}, {
"activity_event_type": "chat_received",
"meeting": {"meeting_no": "should_not_use"},
"chat_received_items": [
{"message_type": 3, "content": "THUMBSUP"}
]
}, {
"activity_event_type": "participant_joined",
"meeting": {"meeting_no": "should_not_use"},
"chat_received_items": [
{"message_type": 3, "content": "SHOULD_NOT_COLLECT"}
]
}]
}
}`,
want: VCBotEventOutput{
Type: eventTypeBotMeetingEvent,
EventID: "ev_activity",
Timestamp: "1776409469274",
MeetingNo: "987654321",
ActivityEventType: "chat_received",
},
},
{
name: "meeting activity preserves reaction content in raw event",
eventType: eventTypeBotMeetingEvent,
process: processVCBotMeetingEvent,
payload: `{
"schema": "2.0",
"header": {
"event_id": "ev_activity_content",
"event_type": "vc.bot.meeting_activity_v1",
"create_time": "1776409469276"
},
"event": {
"meeting_activity_items": [{
"activity_event_type": "chat_received",
"chat_received_items": [
{"message_type": 1, "content": "ws test"},
{"message_type": 3, "content": "OK"}
],
"meeting": {"meeting_no": "427607561"}
}]
}
}`,
want: VCBotEventOutput{
Type: eventTypeBotMeetingEvent,
EventID: "ev_activity_content",
Timestamp: "1776409469276",
MeetingNo: "427607561",
ActivityEventType: "chat_received",
},
},
{
name: "ended",
eventType: eventTypeBotMeetingEnded,
process: processVCBotMeetingEnded,
payload: `{
"schema": "2.0",
"header": {
"event_id": "ev_ended",
"event_type": "vc.bot.meeting_ended_v1",
"create_time": "1776409469275"
},
"event": {
"meeting_no": "246801357"
}
}`,
want: VCBotEventOutput{
Type: eventTypeBotMeetingEnded,
EventID: "ev_ended",
Timestamp: "1776409469275",
MeetingNo: "246801357",
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
out := runBotEventProcess(t, tc.eventType, tc.process, tc.payload)
if out.Type != tc.want.Type || out.EventID != tc.want.EventID || out.Timestamp != tc.want.Timestamp {
t.Errorf("type/event_id/timestamp = %q/%q/%q", out.Type, out.EventID, out.Timestamp)
}
if out.CallID != tc.want.CallID {
t.Errorf("CallID = %q, want %q", out.CallID, tc.want.CallID)
}
if out.MeetingNo != tc.want.MeetingNo {
t.Errorf("MeetingNo = %q, want %q", out.MeetingNo, tc.want.MeetingNo)
}
if out.ActivityEventType != tc.want.ActivityEventType {
t.Errorf("ActivityEventType = %q, want %q", out.ActivityEventType, tc.want.ActivityEventType)
}
if len(out.RawEvent) == 0 {
t.Fatal("RawEvent must be preserved")
}
var raw map[string]any
if err := json.Unmarshal(out.RawEvent, &raw); err != nil {
t.Fatalf("RawEvent is not valid JSON: %v", err)
}
if raw["schema"] != "2.0" {
t.Errorf("RawEvent schema = %v, want 2.0", raw["schema"])
}
})
}
}
func TestProcessVCBotMeetingEvent_MalformedPassthrough(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
raw := &event.RawEvent{
EventID: "ev_bad",
EventType: eventTypeBotMeetingEvent,
Payload: json.RawMessage(`not json`),
Timestamp: time.Now(),
}
got, err := processVCBotMeetingEvent(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("process error: %v", err)
}
if string(got) != "not json" {
t.Fatalf("malformed payload passthrough = %s, want raw payload", string(got))
}
}
func TestProcessVCBotMeetingEvent_MalformedActivityPayloadKeepsRawEvent(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
out := runBotEventProcess(t, eventTypeBotMeetingEvent, processVCBotMeetingEvent, `{
"schema": "2.0",
"header": {
"event_id": "ev_bad_activity",
"event_type": "vc.bot.meeting_activity_v1",
"create_time": "1776409469277"
},
"event": {
"meeting_activity_items": ["not an activity item"]
}
}`)
if out.Type != eventTypeBotMeetingEvent {
t.Fatalf("Type = %q, want %q", out.Type, eventTypeBotMeetingEvent)
}
if out.MeetingNo != "" || out.ActivityEventType != "" {
t.Fatalf("stable fields = meeting_no:%q activity_event_type:%q, want empty", out.MeetingNo, out.ActivityEventType)
}
if len(out.RawEvent) == 0 {
t.Fatal("RawEvent must be preserved")
}
}
func runBotEventProcess(t *testing.T, eventType string, process event.ProcessFunc, payload string) VCBotEventOutput {
t.Helper()
raw := &event.RawEvent{
EventID: "raw_" + eventType,
EventType: eventType,
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
got, err := process(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("process %s: %v", eventType, err)
}
var out VCBotEventOutput
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("unmarshal output: %v\n%s", err, string(got))
}
return out
}

View File

@@ -18,6 +18,9 @@ const (
eventTypeRecordingStarted = "vc.recording.recording_started_v1"
eventTypeRecordingTranscriptGenerated = "vc.recording.recording_transcript_generated_v1"
eventTypeRecordingEnded = "vc.recording.recording_ended_v1"
eventTypeBotMeetingInvited = "vc.bot.meeting_invited_v1"
eventTypeBotMeetingEvent = "vc.bot.meeting_activity_v1"
eventTypeBotMeetingEnded = "vc.bot.meeting_ended_v1"
pathMeetingSubscribe = "/open-apis/vc/v1/meetings/subscription"
pathMeetingUnsubscribe = "/open-apis/vc/v1/meetings/unsubscription"
@@ -144,5 +147,41 @@ func Keys() []event.KeyDefinition {
},
RequiredConsoleEvents: []string{eventTypeRecordingEnded},
},
{
Key: eventTypeBotMeetingInvited,
DisplayName: "Bot meeting invited",
Description: "Triggered when the bot is invited to a meeting; bot-observed event that does not create a user-side VC subscription",
EventType: eventTypeBotMeetingInvited,
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCBotEventOutput{})},
},
Process: processVCBotMeetingInvited,
AuthTypes: []string{"bot"},
RequiredConsoleEvents: []string{eventTypeBotMeetingInvited},
},
{
Key: eventTypeBotMeetingEvent,
DisplayName: "Bot meeting activity",
Description: "Triggered when the bot observes activity in a meeting; keeps the raw bot payload and extracts stable activity fields",
EventType: eventTypeBotMeetingEvent,
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCBotEventOutput{})},
},
Process: processVCBotMeetingEvent,
AuthTypes: []string{"bot"},
RequiredConsoleEvents: []string{eventTypeBotMeetingEvent},
},
{
Key: eventTypeBotMeetingEnded,
DisplayName: "Bot meeting ended",
Description: "Triggered when a meeting observed by the bot has ended; distinct from user participant or open meeting resource events",
EventType: eventTypeBotMeetingEnded,
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCBotEventOutput{})},
},
Process: processVCBotMeetingEnded,
AuthTypes: []string{"bot"},
RequiredConsoleEvents: []string{eventTypeBotMeetingEnded},
},
}
}

View File

@@ -5,6 +5,7 @@ package vc
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
@@ -15,7 +16,9 @@ import (
"unicode"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -41,11 +44,11 @@ func toUnixSeconds(input string, hint ...string) (string, error) {
return ts, nil
}
// VCMeetingEvents lists bot meeting events for a meeting.
// VCMeetingEvents lists meeting events for a meeting.
var VCMeetingEvents = common.Shortcut{
Service: "vc",
Command: "+meeting-events",
Description: "List bot meeting events by meeting ID",
Description: "List meeting events by meeting ID",
Risk: "read",
Scopes: []string{"vc:meeting.meetingevent:read"},
AuthTypes: []string{"user", "bot"},
@@ -99,20 +102,30 @@ var VCMeetingEvents = common.Shortcut{
return err
}
events = compactMeetingEvents(events)
outData := map[string]interface{}{
"events": events,
"has_more": data["has_more"],
"page_token": data["page_token"],
identity, identityWarning := meetingEventsCurrentIdentity(runtime)
currentRoster, rosterWarning := fetchMeetingEventsCurrentRoster(runtime)
outData := buildMeetingEventsOutput(data, events, currentRoster, identity, identityWarning, rosterWarning)
metadata := map[string]interface{}{
"row_type": "metadata",
"meeting": outData.Meeting,
"identity": outData.Identity,
"current_roster": outData.CurrentRoster,
"has_more": outData.HasMore,
"page_token": outData.PageToken,
}
if len(outData.Warnings) > 0 {
metadata["warnings"] = outData.Warnings
}
ndjsonData := meetingEventsEventRows(outData.Events, metadata)
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 == "ndjson" {
runtime.OutFormat(ndjsonData, &output.Meta{Count: len(events)}, func(w io.Writer) {})
} else {
runtime.OutFormat(outData, &output.Meta{Count: len(events)}, func(w io.Writer) {
renderMeetingEventsCompactPretty(w, outData, timeline)
})
}
if runtime.Format == "pretty" && pageToken != "" {
fmt.Fprintf(runtime.IO().Out, "\npage_token: %s\n", pageToken)
if hasMore {
@@ -123,6 +136,411 @@ var VCMeetingEvents = common.Shortcut{
},
}
type meetingEventsOutput struct {
Meeting meetingEventsMeeting `json:"meeting"`
Identity meetingEventsIdentity `json:"identity"`
CurrentRoster []meetingEventsIdentity `json:"current_roster"`
Events []meetingEventsEvent `json:"events"`
Warnings []string `json:"warnings,omitempty"`
HasMore bool `json:"has_more"`
PageToken string `json:"page_token,omitempty"`
}
type meetingEventsMeeting struct {
ID string `json:"id,omitempty"`
Topic string `json:"topic,omitempty"`
MeetingNo string `json:"meeting_no,omitempty"`
StartTime string `json:"start_time,omitempty"`
EndTime string `json:"end_time,omitempty"`
Status string `json:"status"`
}
type meetingEventsIdentity struct {
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
ParticipantType string `json:"participant_type,omitempty"`
Role string `json:"role,omitempty"`
IsSelf bool `json:"is_self"`
Label string `json:"label,omitempty"`
}
type meetingEventsEvent struct {
EventID string `json:"event_id,omitempty"`
EventType string `json:"event_type,omitempty"`
EventTime string `json:"event_time,omitempty"`
Summary string `json:"summary,omitempty"`
Actors []meetingEventsIdentity `json:"actors,omitempty"`
Payload map[string]interface{} `json:"payload,omitempty"`
Raw map[string]interface{} `json:"raw,omitempty"`
}
func buildMeetingEventsOutput(data map[string]interface{}, events []interface{}, currentRoster []interface{}, identity meetingEventsIdentity, warnings ...string) meetingEventsOutput {
output := meetingEventsOutput{
Identity: identity,
HasMore: common.GetBool(data, "has_more"),
PageToken: common.GetString(data, "page_token"),
}
for _, warning := range warnings {
if warning = strings.TrimSpace(warning); warning != "" {
output.Warnings = append(output.Warnings, warning)
}
}
for _, raw := range events {
event, _ := raw.(map[string]interface{})
if event == nil {
continue
}
payload := common.GetMap(event, "payload")
if output.Meeting.ID == "" {
output.Meeting = meetingEventsMeetingFromPayload(common.GetMap(payload, "meeting"))
}
output.Events = append(output.Events, meetingEventsEventFromPayload(event, output.Identity))
}
output.CurrentRoster = meetingEventsCurrentRoster(currentRoster, output.Identity)
return output
}
func meetingEventsCurrentIdentity(runtime *common.RuntimeContext) (meetingEventsIdentity, string) {
if runtime.As() == core.AsBot {
botInfo, err := runtime.BotInfo()
if err != nil {
return meetingEventsBotIdentity(nil), fmt.Sprintf("identity unavailable: %v", err)
}
return meetingEventsBotIdentity(botInfo), ""
}
userOpenID := strings.TrimSpace(runtime.UserOpenId())
identity := meetingEventsIdentity{
ID: userOpenID,
Name: strings.TrimSpace(runtime.Config.UserName),
ParticipantType: "human",
Role: "user",
IsSelf: true,
}
identity.Label = identityLabel(identity)
if userOpenID == "" {
return identity, "identity unavailable: current user open_id is unavailable"
}
return identity, ""
}
func fetchMeetingEventsCurrentRoster(runtime *common.RuntimeContext) ([]interface{}, string) {
meetingID := strings.TrimSpace(runtime.Str("meeting-id"))
data, err := runtime.CallAPITyped(http.MethodGet, fmt.Sprintf("/open-apis/vc/v1/meetings/%s", validate.EncodePathSegment(meetingID)),
map[string]interface{}{"with_participants": "true", "query_mode": "0"}, nil)
if err != nil {
return nil, fmt.Sprintf("current_roster unavailable: %v", err)
}
if meeting := common.GetMap(data, "meeting"); meeting != nil {
return common.GetSlice(meeting, "participants"), ""
}
return nil, ""
}
func meetingEventsBotIdentity(botInfo *common.BotInfo) meetingEventsIdentity {
if botInfo == nil {
return meetingEventsIdentity{ParticipantType: "bot", Role: "bot", IsSelf: true, Label: "bot"}
}
identity := meetingEventsIdentity{
ID: botInfo.OpenID,
Name: botInfo.AppName,
ParticipantType: "bot",
Role: "bot",
IsSelf: true,
}
identity.Label = identityLabel(identity)
return identity
}
func meetingEventsMeetingFromPayload(meeting map[string]interface{}) meetingEventsMeeting {
out := meetingEventsMeeting{
ID: common.GetString(meeting, "id"),
Topic: common.GetString(meeting, "topic"),
MeetingNo: common.GetString(meeting, "meeting_no"),
StartTime: meetingEventsTimeString(common.GetString(meeting, "start_time")),
EndTime: meetingEventsTimeString(common.GetString(meeting, "end_time")),
Status: "unknown",
}
start, hasStart := parseFlexibleTime(out.StartTime)
end, hasEnd := parseFlexibleTime(out.EndTime)
if hasStart && hasEnd {
if end.After(start) {
out.Status = "ended"
} else {
out.Status = "ongoing"
out.EndTime = ""
}
}
return out
}
func meetingEventsEventFromPayload(event map[string]interface{}, selfIdentity meetingEventsIdentity) meetingEventsEvent {
payload := common.GetMap(event, "payload")
rawCopy := cloneStringMap(event)
out := meetingEventsEvent{
EventID: common.GetString(event, "event_id"),
EventType: meetingEventType(event),
EventTime: meetingEventsTimeString(common.GetString(event, "event_time")),
Summary: meetingEventSummary(event),
Payload: payload,
Raw: rawCopy,
}
out.Actors = eventActors(out.EventType, payload, selfIdentity)
return out
}
func meetingEventsCurrentRoster(rawRoster []interface{}, selfIdentity meetingEventsIdentity) []meetingEventsIdentity {
roster := make([]meetingEventsIdentity, 0, len(rawRoster))
for _, raw := range rawRoster {
item, _ := raw.(map[string]interface{})
if item == nil {
continue
}
participant := item
if nested := common.GetMap(item, "participant"); nested != nil {
participant = nested
}
roster = append(roster, meetingEventsIdentityFromParticipant(participant, selfIdentity))
}
return roster
}
func eventActors(eventType string, payload map[string]interface{}, selfIdentity meetingEventsIdentity) []meetingEventsIdentity {
var actors []meetingEventsIdentity
addFromItems := func(key, participantKey string) {
for _, raw := range common.GetSlice(payload, key) {
item, _ := raw.(map[string]interface{})
if item == nil {
continue
}
if participant := common.GetMap(item, participantKey); participant != nil {
actors = append(actors, meetingEventsIdentityFromParticipant(participant, selfIdentity))
}
}
}
switch eventType {
case "participant_joined":
addFromItems("participant_joined_items", "participant")
case "participant_left":
addFromItems("participant_left_items", "participant")
case "transcript_received":
addFromItems("transcript_received_items", "speaker")
case "chat_received":
addFromItems("chat_received_items", "operator")
case "magic_share_started":
addFromItems("magic_share_started_items", "operator")
case "magic_share_ended":
addFromItems("magic_share_ended_items", "operator")
}
return actors
}
func meetingEventsIdentityFromParticipant(participant map[string]interface{}, selfIdentity meetingEventsIdentity) meetingEventsIdentity {
identity := meetingEventsIdentity{
ID: common.GetString(participant, "id"),
Name: common.GetString(participant, "user_name"),
ParticipantType: meetingEventsParticipantType(participant),
Role: meetingEventsParticipantRole(participant),
}
if identity.ID != "" && selfIdentity.ID != "" && identity.ID == selfIdentity.ID {
identity.IsSelf = true
if selfIdentity.ParticipantType == "bot" && (identity.ParticipantType == "" || identity.ParticipantType == "human") {
identity.ParticipantType = "bot"
}
if selfIdentity.Role == "bot" && (identity.Role == "" || identity.Role == "participant") {
identity.Role = "bot"
}
}
if identity.ParticipantType == "" {
identity.ParticipantType = "human"
}
if identity.Role == "" {
identity.Role = "participant"
}
identity.Label = identityLabel(identity)
return identity
}
func meetingEventsParticipantType(participant map[string]interface{}) string {
if raw := meetingEventsParticipantTypeFromParticipantType(fieldValueString(participant, "participant_type")); raw != "" {
return raw
}
return meetingEventsParticipantTypeFromUserType(fieldValueString(participant, "user_type"))
}
func meetingEventsParticipantTypeFromParticipantType(raw string) string {
raw = strings.ToLower(strings.TrimSpace(raw))
switch raw {
case "1", "user", "human":
return "human"
case "2", "bot", "app":
return "bot"
case "":
return ""
default:
return raw
}
}
func meetingEventsParticipantRole(participant map[string]interface{}) string {
if raw := meetingEventsRoleFromRosterRole(fieldValueString(participant, "role")); raw != "" {
return raw
}
return meetingEventsRoleFromEventUserRole(fieldValueString(participant, "user_role"))
}
func meetingEventsParticipantTypeFromUserType(raw string) string {
raw = strings.ToLower(strings.TrimSpace(raw))
switch raw {
case "1", "user", "human":
return "human"
case "2", "10", "bot", "app":
return "bot"
case "":
return ""
default:
return raw
}
}
func meetingEventsRoleFromRosterRole(raw string) string {
raw = strings.ToLower(strings.TrimSpace(raw))
switch raw {
case "1", "host":
return "host"
case "2", "co_host", "cohost":
return "co_host"
case "3", "participant", "attendee":
return "participant"
case "4", "bot", "app":
return "bot"
case "":
return ""
default:
return raw
}
}
func meetingEventsRoleFromEventUserRole(raw string) string {
raw = strings.ToLower(strings.TrimSpace(raw))
switch raw {
case "1", "participant", "attendee":
return "participant"
case "2", "host":
return "host"
case "4", "bot", "app":
return "bot"
case "", "0":
return ""
default:
return raw
}
}
func fieldValueString(values map[string]interface{}, key string) string {
if values == nil {
return ""
}
switch value := values[key].(type) {
case string:
return value
case int:
return strconv.Itoa(value)
case int64:
return strconv.FormatInt(value, 10)
case float64:
return strconv.FormatInt(int64(value), 10)
case json.Number:
return value.String()
default:
return ""
}
}
func identityLabel(identity meetingEventsIdentity) string {
name := identity.Name
if name == "" {
name = identity.ID
}
if name == "" {
name = "unknown"
}
var tags []string
if identity.ParticipantType != "" {
tags = append(tags, identity.ParticipantType)
}
if identity.Role != "" && identity.Role != identity.ParticipantType {
tags = append(tags, identity.Role)
}
if identity.IsSelf {
tags = append(tags, "self")
}
if len(tags) == 0 {
return name
}
return fmt.Sprintf("%s [%s]", name, strings.Join(tags, ","))
}
func meetingEventsTimeString(raw string) string {
if parsed, ok := parseFlexibleTime(raw); ok {
return parsed.UTC().Format(time.RFC3339)
}
return strings.TrimSpace(raw)
}
func cloneStringMap(in map[string]interface{}) map[string]interface{} {
if in == nil {
return nil
}
raw, err := json.Marshal(in)
if err != nil {
return in
}
var out map[string]interface{}
if err := json.Unmarshal(raw, &out); err != nil {
return in
}
return out
}
func meetingEventsEventRows(events []meetingEventsEvent, metadata map[string]interface{}) []interface{} {
rows := make([]interface{}, 0, len(events)+1)
for _, event := range events {
row := map[string]interface{}{
"row_type": "event",
"event_id": event.EventID,
"event_type": event.EventType,
"event_time": event.EventTime,
"summary": event.Summary,
"actors": event.Actors,
"payload": event.Payload,
"raw": event.Raw,
}
rows = append(rows, row)
}
if metadata != nil {
rows = append(rows, metadata)
}
return rows
}
func renderMeetingEventsCompactPretty(w io.Writer, data meetingEventsOutput, timeline meetingTimeline) {
if data.Identity.Label != "" {
fmt.Fprintf(w, "当前身份:%s\n", escapePrettyText(data.Identity.Label))
}
if len(data.CurrentRoster) > 0 {
fmt.Fprintln(w, "当前名单:")
for _, participant := range data.CurrentRoster {
fmt.Fprintf(w, "- %s\n", escapePrettyText(participant.Label))
}
fmt.Fprintln(w)
}
if len(timeline.entries) == 0 {
fmt.Fprintln(w, "No meeting events.")
return
}
io.WriteString(w, renderMeetingEventsPretty(timeline))
}
func meetingEventsPageSize(runtime *common.RuntimeContext) (int, error) {
if runtime.Bool("page-all") {
return maxVCMeetingEventsPageSize, nil
@@ -323,7 +741,6 @@ type meetingTimelineEntry struct {
when time.Time
hasWhen bool
sequence int
group int
subject string
description string
details []string
@@ -332,7 +749,6 @@ type meetingTimelineEntry struct {
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 {
@@ -345,10 +761,9 @@ func buildMeetingEventTimeline(events []interface{}) meetingTimeline {
if timeline.topic == "" || !timeline.hasStart || !timeline.hasEnd {
populateMeetingHeader(&timeline, common.GetMap(payload, "meeting"))
}
for _, entry := range buildTimelineEntriesForEvent(event, &sequence, group) {
for _, entry := range buildTimelineEntriesForEvent(event, &sequence) {
timeline.entries = append(timeline.entries, entry)
}
group++
}
sort.SliceStable(timeline.entries, func(i, j int) bool {
left := timeline.entries[i]
@@ -391,7 +806,7 @@ func populateMeetingHeader(timeline *meetingTimeline, meeting map[string]interfa
}
}
func buildTimelineEntriesForEvent(event map[string]interface{}, sequence *int, group int) []meetingTimelineEntry {
func buildTimelineEntriesForEvent(event map[string]interface{}, sequence *int) []meetingTimelineEntry {
payload := common.GetMap(event, "payload")
if payload == nil {
return nil
@@ -400,26 +815,26 @@ func buildTimelineEntriesForEvent(event map[string]interface{}, sequence *int, g
eventTime, eventTimeOK := parseFlexibleTime(common.GetString(event, "event_time"))
switch eventType {
case "participant_joined":
return participantJoinedEntries(payload, eventTime, eventTimeOK, sequence, group)
return participantJoinedEntries(payload, eventTime, eventTimeOK, sequence)
case "participant_left":
return participantLeftEntries(payload, eventTime, eventTimeOK, sequence, group)
return participantLeftEntries(payload, eventTime, eventTimeOK, sequence)
case "transcript_received":
return transcriptEntries(payload, eventTime, eventTimeOK, sequence, group)
return transcriptEntries(payload, eventTime, eventTimeOK, sequence)
case "chat_received":
return chatEntries(payload, eventTime, eventTimeOK, sequence, group)
return chatEntries(payload, eventTime, eventTimeOK, sequence)
case "magic_share_started":
return magicShareStartedEntries(payload, eventTime, eventTimeOK, sequence, group)
return magicShareStartedEntries(payload, eventTime, eventTimeOK, sequence)
case "magic_share_ended":
return magicShareEndedEntries(payload, eventTime, eventTimeOK, sequence, group)
return magicShareEndedEntries(payload, eventTime, eventTimeOK, sequence)
default:
return []meetingTimelineEntry{newTimelineEntry(eventTime, eventTimeOK, sequence, group, meetingEventUserDisplayName(nil), meetingEventSummary(event), nil)}
return []meetingTimelineEntry{newTimelineEntry(eventTime, eventTimeOK, sequence, meetingEventUserDisplayName(nil), meetingEventSummary(event), nil)}
}
}
func participantJoinedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
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, group, "", "加入了会议", nil)}
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, "", "加入了会议", nil)}
}
entries := make([]meetingTimelineEntry, 0, len(items))
for _, raw := range items {
@@ -432,15 +847,15 @@ func participantJoinedEntries(payload map[string]interface{}, fallbackTime time.
if subject == "" {
subject = "未知参会人"
}
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, "加入了会议", nil))
entries = append(entries, newTimelineEntry(when, ok, sequence, subject, "加入了会议", nil))
}
return entries
}
func participantLeftEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
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, group, "", "离开了会议", nil)}
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, "", "离开了会议", nil)}
}
entries := make([]meetingTimelineEntry, 0, len(items))
for _, raw := range items {
@@ -453,15 +868,15 @@ func participantLeftEntries(payload map[string]interface{}, fallbackTime time.Ti
if subject == "" {
subject = "未知参会人"
}
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, leaveAction(item), nil))
entries = append(entries, newTimelineEntry(when, ok, sequence, subject, leaveAction(item), nil))
}
return entries
}
func transcriptEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
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, group, "", "产生了转写", nil)}
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, "", "产生了转写", nil)}
}
entries := make([]meetingTimelineEntry, 0, len(items))
for _, raw := range items {
@@ -479,15 +894,15 @@ func transcriptEntries(payload map[string]interface{}, fallbackTime time.Time, f
if text != "" {
description = text
}
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, description, nil))
entries = append(entries, newTimelineEntry(when, ok, sequence, subject, description, nil))
}
return entries
}
func chatEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
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, group, "", "发送了消息", nil)}
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, "", "发送了消息", nil)}
}
entries := make([]meetingTimelineEntry, 0, len(items))
for _, raw := range items {
@@ -507,15 +922,15 @@ func chatEntries(payload map[string]interface{}, fallbackTime time.Time, fallbac
} else {
description = fmt.Sprintf("[%s] %s", typeLabel, description)
}
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, description, nil))
entries = append(entries, newTimelineEntry(when, ok, sequence, subject, description, nil))
}
return entries
}
func magicShareStartedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
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, group, "", "开始共享内容", nil)}
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, "", "开始共享内容", nil)}
}
entries := make([]meetingTimelineEntry, 0, len(items))
for _, raw := range items {
@@ -538,15 +953,15 @@ func magicShareStartedEntries(payload map[string]interface{}, fallbackTime time.
if url != "" {
details = append(details, "URL: "+url)
}
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, description, details))
entries = append(entries, newTimelineEntry(when, ok, sequence, subject, description, details))
}
return entries
}
func magicShareEndedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
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, group, "", "结束共享", nil)}
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, "", "结束共享", nil)}
}
entries := make([]meetingTimelineEntry, 0, len(items))
for _, raw := range items {
@@ -559,17 +974,16 @@ func magicShareEndedEntries(payload map[string]interface{}, fallbackTime time.Ti
if subject == "" {
subject = "未知用户"
}
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, "结束共享", nil))
entries = append(entries, newTimelineEntry(when, ok, sequence, subject, "结束共享", nil))
}
return entries
}
func newTimelineEntry(when time.Time, hasWhen bool, sequence *int, group int, subject, description string, details []string) meetingTimelineEntry {
func newTimelineEntry(when time.Time, hasWhen bool, sequence *int, subject, description string, details []string) meetingTimelineEntry {
entry := meetingTimelineEntry{
when: when,
hasWhen: hasWhen,
sequence: *sequence,
group: group,
subject: subject,
description: description,
details: details,
@@ -741,10 +1155,7 @@ func meetingEventUserWithID(user map[string]interface{}) string {
}
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")
return common.GetString(event, "event_type")
}
func meetingEventSummary(event map[string]interface{}) string {

View File

@@ -5,6 +5,7 @@ package vc
import (
"context"
"encoding/json"
"errors"
"reflect"
"strings"
@@ -54,6 +55,62 @@ func meetingEventsStub(events []interface{}, hasMore bool, pageToken string) *ht
}
}
func botInfoStub() *httpmock.Stub {
return &httpmock.Stub{
Method: "GET",
URL: "/open-apis/bot/v3/info",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"bot": map[string]interface{}{
"open_id": "bot_001",
"app_name": "Demo Bot",
},
},
}
}
func botInfoErrorStub() *httpmock.Stub {
return &httpmock.Stub{
Method: "GET",
URL: "/open-apis/bot/v3/info",
Status: 500,
Body: map[string]interface{}{
"code": 99991663,
"msg": "bot info unavailable",
},
}
}
func meetingDetailRosterStub(roster []interface{}) *httpmock.Stub {
return &httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/meetings/7628568141510692381",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"meeting": map[string]interface{}{
"id": "7628568141510692381",
"participants": roster,
},
},
},
}
}
func meetingDetailRosterErrorStub() *httpmock.Stub {
return &httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/meetings/7628568141510692381",
Status: 500,
Body: map[string]interface{}{
"code": 99991663,
"msg": "meeting detail unavailable",
},
}
}
func participantJoinedEvent() map[string]interface{} {
return map[string]interface{}{
"event_id": "event-1",
@@ -73,6 +130,8 @@ func participantJoinedEvent() map[string]interface{} {
"participant": map[string]interface{}{
"id": "bot_001",
"user_name": "Demo Bot",
"user_type": 2,
"user_role": 4,
},
"join_time": "2026-04-17T08:00:00Z",
},
@@ -112,7 +171,7 @@ func chatReceivedEvent() map[string]interface{} {
"chat_received_items": []interface{}{
map[string]interface{}{
"content": "hello",
"message_type": 3,
"message_type": 1,
"operator": map[string]interface{}{
"id": "u1",
"user_name": "Alice",
@@ -140,7 +199,7 @@ func multiChatReceivedEvent() map[string]interface{} {
"chat_received_items": []interface{}{
map[string]interface{}{
"content": "第一条\n第二行",
"message_type": 3,
"message_type": 1,
"send_time": "1776408061000",
"operator": map[string]interface{}{
"id": "u1",
@@ -149,6 +208,44 @@ func multiChatReceivedEvent() map[string]interface{} {
},
map[string]interface{}{
"content": "第二条",
"message_type": 1,
"send_time": "1776408062000",
"operator": map[string]interface{}{
"id": "u1",
"user_name": "Alice",
},
},
},
},
}
}
func mixedChatAndReactionEvent() map[string]interface{} {
return map[string]interface{}{
"event_id": "event-reaction",
"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",
},
"chat_received_items": []interface{}{
map[string]interface{}{
"content": "hello",
"message_type": 1,
"send_time": "1776408061000",
"operator": map[string]interface{}{
"id": "u1",
"user_name": "Alice",
},
},
map[string]interface{}{
"content": "OK",
"message_type": 3,
"send_time": "1776408062000",
"operator": map[string]interface{}{
@@ -414,7 +511,7 @@ func TestMeetingEvents_DryRun(t *testing.T) {
"--start", "1710000000",
"--end", "1710003600",
"--dry-run",
"--as", "user",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -442,7 +539,7 @@ func TestMeetingEvents_DryRun_PageAllUsesMaxLimit(t *testing.T) {
"--meeting-id", "7628568141510692381",
"--page-all",
"--dry-run",
"--as", "user",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -457,13 +554,15 @@ 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, ""))
reg.Register(botInfoStub())
reg.Register(meetingDetailRosterStub(nil))
err := mountAndRun(t, VCMeetingEvents, []string{
"+meeting-events",
"--meeting-id", "7628568141510692381",
"--format", "json",
"--page-all",
"--as", "user",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -472,7 +571,7 @@ func TestMeetingEvents_ExecuteJSON_PageAll(t *testing.T) {
out := strings.ReplaceAll(stdout.String(), " ", "")
out = strings.ReplaceAll(out, "\n", "")
if count := strings.Count(out, `"event_type":"participant_joined"`); count != 2 {
if count := strings.Count(out, `"summary":"participantbot_001(DemoBot)joined"`); count != 2 {
t.Fatalf("expected 2 aggregated events, got %d: %s", count, stdout.String())
}
if !strings.Contains(out, `"has_more":false`) {
@@ -483,6 +582,112 @@ func TestMeetingEvents_ExecuteJSON_PageAll(t *testing.T) {
func TestMeetingEvents_ExecuteJSON(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, true, "1710000000000000000"))
reg.Register(botInfoStub())
reg.Register(meetingDetailRosterStub([]interface{}{
map[string]interface{}{"id": "bot_001", "user_name": "Demo Bot", "participant_type": "2", "role": "4"},
map[string]interface{}{"id": "u1", "user_name": "Alice", "participant_type": "1", "role": "1"},
}))
err := mountAndRun(t, VCMeetingEvents, []string{
"+meeting-events",
"--meeting-id", "7628568141510692381",
"--format", "json",
"--as", "bot",
}, 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{
`"identity":{"id":"bot_001","name":"DemoBot","participant_type":"bot","role":"bot","is_self":true,"label":"DemoBot[bot,self]"}`,
`"current_roster":[`,
`"role":"host"`,
`"is_self":true`,
`"event_type":"participant_joined"`,
`"actors":[`,
`"start_time":"2026-04-17T06:35:00Z"`,
`"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_RosterErrorDoesNotBlockEvents(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, false, ""))
reg.Register(botInfoStub())
reg.Register(meetingDetailRosterErrorStub())
err := mountAndRun(t, VCMeetingEvents, []string{
"+meeting-events",
"--meeting-id", "7628568141510692381",
"--format", "json",
"--as", "bot",
}, 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"`,
`"current_roster":[]`,
`"warnings":[`,
`current_rosterunavailable`,
} {
if !strings.Contains(out, want) {
t.Fatalf("json output missing %q: %s", want, stdout.String())
}
}
}
func TestMeetingEvents_ExecuteJSON_BotIdentityErrorDoesNotBlockEvents(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, false, ""))
reg.Register(botInfoErrorStub())
reg.Register(meetingDetailRosterStub(nil))
err := mountAndRun(t, VCMeetingEvents, []string{
"+meeting-events",
"--meeting-id", "7628568141510692381",
"--format", "json",
"--as", "bot",
}, 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"`,
`"identity":{"participant_type":"bot","role":"bot","is_self":true,"label":"bot"}`,
`"warnings":[`,
`identityunavailable`,
} {
if !strings.Contains(out, want) {
t.Fatalf("json output missing %q: %s", want, stdout.String())
}
}
}
func TestMeetingEvents_ExecuteJSON_UserIdentitySkipsBotInfo(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, false, ""))
reg.Register(meetingDetailRosterStub([]interface{}{
map[string]interface{}{"id": "ou_testuser", "user_name": "Current User", "participant_type": "1", "role": "1"},
map[string]interface{}{"id": "u1", "user_name": "Alice", "participant_type": "1", "role": "1"},
}))
err := mountAndRun(t, VCMeetingEvents, []string{
"+meeting-events",
@@ -498,13 +703,85 @@ func TestMeetingEvents_ExecuteJSON(t *testing.T) {
out := strings.ReplaceAll(stdout.String(), " ", "")
out = strings.ReplaceAll(out, "\n", "")
for _, want := range []string{
`"identity":{"id":"ou_testuser","participant_type":"human","role":"user","is_self":true,"label":"ou_testuser[human,user,self]"}`,
`"current_roster":[`,
`"id":"ou_testuser"`,
`"is_self":true`,
`"event_type":"participant_joined"`,
`"has_more":true`,
`"page_token":"1710000000000000000"`,
`"events":[`,
`"has_more":false`,
} {
if !strings.Contains(out, want) {
t.Fatalf("json output missing %q: %s", want, stdout.String())
t.Fatalf("user json output missing %q: %s", want, stdout.String())
}
}
}
func TestMeetingEvents_ExecuteJSON_OngoingMeetingOmitsEndTime(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingEventsStub([]interface{}{participantJoinedEventOngoing()}, false, ""))
reg.Register(botInfoStub())
reg.Register(meetingDetailRosterStub(nil))
err := mountAndRun(t, VCMeetingEvents, []string{
"+meeting-events",
"--meeting-id", "7628568141510692381",
"--format", "json",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
reg.Verify(t)
var envelope map[string]interface{}
if err := json.Unmarshal([]byte(stdout.String()), &envelope); err != nil {
t.Fatalf("invalid json output: %v\n%s", err, stdout.String())
}
data := common.GetMap(envelope, "data")
meeting := common.GetMap(data, "meeting")
if got := common.GetString(meeting, "status"); got != "ongoing" {
t.Fatalf("meeting status = %q, want ongoing: %s", got, stdout.String())
}
if _, ok := meeting["end_time"]; ok {
t.Fatalf("ongoing meeting should not expose dirty top-level end_time: %s", stdout.String())
}
}
func TestMeetingEvents_ExecuteNDJSONIncludesMetadataRow(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, true, "1710000000000000000"))
reg.Register(botInfoStub())
reg.Register(meetingDetailRosterStub([]interface{}{
map[string]interface{}{"id": "bot_001", "user_name": "Demo Bot", "participant_type": "2", "role": "4"},
}))
err := mountAndRun(t, VCMeetingEvents, []string{
"+meeting-events",
"--meeting-id", "7628568141510692381",
"--format", "ndjson",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
reg.Verify(t)
lines := strings.Split(strings.TrimSpace(stdout.String()), "\n")
if len(lines) != 2 {
t.Fatalf("ndjson lines = %d, want 2: %s", len(lines), stdout.String())
}
if !strings.Contains(lines[0], `"row_type":"event"`) || !strings.Contains(lines[0], `"event_type":"participant_joined"`) {
t.Fatalf("first ndjson row should be event: %s", lines[0])
}
for _, want := range []string{
`"row_type":"metadata"`,
`"has_more":true`,
`"page_token":"1710000000000000000"`,
`"identity":`,
`"current_roster":[`,
} {
if !strings.Contains(lines[1], want) {
t.Fatalf("metadata ndjson row missing %q: %s", want, lines[1])
}
}
}
@@ -512,12 +789,14 @@ func TestMeetingEvents_ExecuteJSON(t *testing.T) {
func TestMeetingEvents_ExecuteJSON_PrunesEmptySlices(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingEventsStub([]interface{}{chatReceivedEvent()}, false, ""))
reg.Register(botInfoStub())
reg.Register(meetingDetailRosterStub(nil))
err := mountAndRun(t, VCMeetingEvents, []string{
"+meeting-events",
"--meeting-id", "7628568141510692381",
"--format", "json",
"--as", "user",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -536,20 +815,59 @@ func TestMeetingEvents_ExecuteJSON_PrunesEmptySlices(t *testing.T) {
t.Fatalf("json output should not contain %q: %s", unwanted, out)
}
}
if !strings.Contains(out, `"message_type": 3`) {
if !strings.Contains(out, `"message_type": 1`) {
t.Fatalf("json output should keep numeric fields: %s", out)
}
}
func TestMeetingEvents_ExecuteJSON_PreservesReactionItems(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingEventsStub([]interface{}{mixedChatAndReactionEvent()}, false, ""))
reg.Register(botInfoStub())
reg.Register(meetingDetailRosterStub(nil))
err := mountAndRun(t, VCMeetingEvents, []string{
"+meeting-events",
"--meeting-id", "7628568141510692381",
"--format", "json",
"--as", "bot",
}, 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":"chat_received"`,
`"chat_received_items":[`,
`"content":"OK"`,
`"message_type":3`,
} {
if !strings.Contains(out, want) {
t.Fatalf("json output missing %q: %s", want, stdout.String())
}
}
if strings.Contains(out, `"im_post"`) {
t.Fatalf("json output should not include IM post payload: %s", stdout.String())
}
}
func TestMeetingEvents_ExecutePretty(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingEventsStub([]interface{}{participantJoinedEventOngoing(), multiChatReceivedEvent(), magicShareStartedEvent()}, true, "1710000000000000000"))
reg.Register(botInfoStub())
reg.Register(meetingDetailRosterStub([]interface{}{
map[string]interface{}{"id": "bot_001", "user_name": "Demo Bot", "participant_type": "2", "role": "4"},
map[string]interface{}{"id": "u1", "user_name": "Alice", "participant_type": "1", "role": "1"},
}))
err := mountAndRun(t, VCMeetingEvents, []string{
"+meeting-events",
"--meeting-id", "7628568141510692381",
"--format", "pretty",
"--as", "user",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -558,11 +876,15 @@ func TestMeetingEvents_ExecutePretty(t *testing.T) {
out := stdout.String()
for _, want := range []string{
"当前身份Demo Bot [bot,self]",
"当前名单:",
"- Demo Bot [bot,self]",
"- Alice [human,host]",
"会议主题:项目例会",
"会议时间2026-04-17 15:15:00进行中",
"Demo Bot(bot_001) 加入了会议",
"Alice(u1): [reaction] 第一条\\n第二行",
"Alice(u1): [reaction] 第二条",
"Alice(u1): [text] 第一条\\n第二行",
"Alice(u1): [text] 第二条",
"Bob(u2) 开始共享「共享文档」",
"URL: https://example.com/doc",
"page_token: 1710000000000000000",
@@ -582,12 +904,14 @@ func TestMeetingEvents_ExecutePretty(t *testing.T) {
func TestMeetingEvents_ExecutePretty_PrintsPageTokenWithoutHasMore(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingEventsStub([]interface{}{participantJoinedEventOngoing()}, false, "pt_last"))
reg.Register(botInfoStub())
reg.Register(meetingDetailRosterStub(nil))
err := mountAndRun(t, VCMeetingEvents, []string{
"+meeting-events",
"--meeting-id", "7628568141510692381",
"--format", "pretty",
"--as", "user",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -606,12 +930,14 @@ func TestMeetingEvents_ExecutePretty_PrintsPageTokenWithoutHasMore(t *testing.T)
func TestMeetingEvents_ExecuteEmpty(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingEventsStub(nil, false, ""))
reg.Register(botInfoStub())
reg.Register(meetingDetailRosterStub(nil))
err := mountAndRun(t, VCMeetingEvents, []string{
"+meeting-events",
"--meeting-id", "7628568141510692381",
"--format", "pretty",
"--as", "user",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -884,6 +1210,57 @@ func TestMeetingEventUserWithID(t *testing.T) {
}
}
func TestMeetingEventsIdentityFromParticipant_UsesContractFields(t *testing.T) {
got := meetingEventsIdentityFromParticipant(map[string]interface{}{
"id": "u1",
"user_name": "Alice",
"user_type": 1,
"user_role": 2,
}, meetingEventsIdentity{})
if got.ParticipantType != "human" || got.Role != "host" {
t.Fatalf("identity = %#v, want participant_type=human role=host", got)
}
}
func TestMeetingEventsIdentityFromParticipant_UserRoleParticipant(t *testing.T) {
got := meetingEventsIdentityFromParticipant(map[string]interface{}{
"id": "u1",
"user_name": "Alice",
"user_type": 1,
"user_role": 1,
}, meetingEventsIdentity{})
if got.Role != "participant" {
t.Fatalf("identity = %#v, want role=participant", got)
}
}
func TestMeetingEventsIdentityFromParticipant_UserTypeApp(t *testing.T) {
got := meetingEventsIdentityFromParticipant(map[string]interface{}{
"id": "ou_app",
"user_name": "Demo Bot",
"user_type": 10,
"user_role": 1,
}, meetingEventsIdentity{})
if got.ParticipantType != "bot" {
t.Fatalf("identity = %#v, want participant_type=bot", got)
}
}
func TestMeetingEventsIdentityFromParticipant_IgnoresGenericTypeField(t *testing.T) {
got := meetingEventsIdentityFromParticipant(map[string]interface{}{
"id": "u1",
"user_name": "Alice",
"type": "bot",
}, meetingEventsIdentity{})
if got.ParticipantType != "human" {
t.Fatalf("identity = %#v, generic type field should not drive participant_type", got)
}
}
func TestMeetingEventSummary(t *testing.T) {
tests := []struct {
name string

View File

@@ -149,6 +149,6 @@ Lark-defined semantic tags (**not** JSON Schema's standard `format`). Common val
|------------|------------------------------------------------------------------------------|---|
| IM | [`references/lark-event-im.md`](references/lark-event-im.md) | Catalog of 12 IM EventKeys + shape notes (flat vs V2 envelope) + `im.message.receive_v1` field gotchas (`sender_id` is open_id only; `.content` is plain text except for `interactive` cards) + common jq recipes (filter by chat_type / message_type / sender); for `card.action.trigger` see also [`../lark-im/references/lark-im-card-action-reply.md`](../lark-im/references/lark-im-card-action-reply.md) |
| Task | [`references/lark-event-task.md`](references/lark-event-task.md) | Catalog of 1 Task EventKey (`task.task.update_user_access_v2`) + Native V2 envelope shape + task commit types + user/bot subscription notes |
| VC | [`references/lark-event-vc.md`](references/lark-event-vc.md) | Catalog of 4 VC EventKeys (`vc.meeting.participant_meeting_started_v1`, `vc.meeting.participant_meeting_joined_v1`, `vc.meeting.participant_meeting_ended_v1`, `vc.note.generated_v1`) + field reference + source type semantics (meeting only) |
| VC | [`references/lark-event-vc.md`](references/lark-event-vc.md) | VC user events plus bot-observed EventKeys (`vc.bot.meeting_invited_v1`, `vc.bot.meeting_activity_v1`, `vc.bot.meeting_ended_v1`) + raw_event/stable field reference + meeting-events forwarding guidance |
| Minutes | [`references/lark-event-minutes.md`](references/lark-event-minutes.md) | Catalog of 1 Minutes EventKey (`minutes.minute.generated_v1`) + field reference + source type semantics (meeting only) |
| Whiteboard | [`references/lark-event-whiteboard.md`](references/lark-event-whiteboard.md) | Catalog of 1 Board EventKey (`board.whiteboard.updated_v1`) + per-whiteboard subscription model (requires `-p whiteboard_id=<token>`) + payload field reference (whiteboard_id / operator_ids triple-id) |

View File

@@ -2,7 +2,7 @@
> **Prerequisite:** Read [`../SKILL.md`](../SKILL.md) first for the `event consume` essentials (commands, subprocess contract, jq usage).
## Key catalog (4)
## Key catalog
| EventKey | Purpose |
|---|---|
@@ -10,8 +10,13 @@
| `vc.meeting.participant_meeting_joined_v1` | The current user has joined a meeting |
| `vc.meeting.participant_meeting_ended_v1` | A meeting the current user participates in has ended |
| `vc.note.generated_v1` | A note has been generated (meeting, recording, upload, etc.) |
| `vc.bot.meeting_invited_v1` | The bot is invited to a meeting |
| `vc.bot.meeting_activity_v1` | The bot observes meeting activity |
| `vc.bot.meeting_ended_v1` | A meeting observed by the bot has ended |
All four keys use a **Custom schema** (flat output) and carry a **PreConsume hook** that auto-subscribes / unsubscribes via OAPI on first / last consumer. All require `--as user`.
The user VC keys use a **Custom schema** (flat output) and carry a **PreConsume hook** that auto-subscribes / unsubscribes via OAPI on first / last consumer. They require `--as user`.
The `vc.bot.*` keys are bot-observed events. They require `--as bot`, keep the original payload in `raw_event`, and do not call the user-side VC meeting subscription / unsubscription APIs.
## Scopes & auth
@@ -21,6 +26,9 @@ All four keys use a **Custom schema** (flat output) and carry a **PreConsume hoo
| `vc.meeting.participant_meeting_joined_v1` | `vc:meeting.meetingevent:read` | user |
| `vc.meeting.participant_meeting_ended_v1` | `vc:meeting.meetingevent:read` | user |
| `vc.note.generated_v1` | `vc:note:read` | user |
| `vc.bot.meeting_invited_v1` | App event subscription in the Developer Console | bot |
| `vc.bot.meeting_activity_v1` | App event subscription in the Developer Console | bot |
| `vc.bot.meeting_ended_v1` | App event subscription in the Developer Console | bot |
---
@@ -104,3 +112,41 @@ lark-cli event consume vc.note.generated_v1 --as user \
lark-cli event consume vc.note.generated_v1 --as user \
--jq 'select(.note_source.source_type == "meeting") | {note_id, meeting_id: .note_source.source_entity_id}'
```
---
## Bot-observed VC events
Use bot identity for all `vc.bot.*` keys:
```bash
lark-cli event consume vc.bot.meeting_invited_v1 --as bot
lark-cli event consume vc.bot.meeting_activity_v1 --as bot
lark-cli event consume vc.bot.meeting_ended_v1 --as bot
```
These keys model what the bot observes. Do not treat them as aliases for:
| Bot event | Not the same as |
|---|---|
| `vc.bot.meeting_invited_v1` | Meeting start events, participant join events, or IM meeting cards |
| `vc.bot.meeting_activity_v1` | User-side `vc +meeting-events` open meeting activity queries |
| `vc.bot.meeting_ended_v1` | `vc.meeting.participant_meeting_ended_v1` or open meeting resource ended events |
### Output fields
| Field | Type | Description |
|---|---|---|
| `type` | string | Event type; one of the supported `vc.bot.*` keys |
| `event_id` | string | Globally unique event ID; safe for deduplication |
| `timestamp` | string (timestamp_ms) | Event delivery time from `header.create_time` when present |
| `call_id` | string | Invitation call ID; pass through to VC agent join when present |
| `meeting_no` | string | Meeting number from the bot event's declared meeting field |
| `activity_event_type` | string | First `event.meeting_activity_items[].activity_event_type` value |
| `raw_event` | object | Original bot event payload; authoritative for fields not exposed as stable top-level fields |
Malformed or evolving payloads are not forced into fixed fields. If a payload cannot be parsed, `event consume` passes the raw payload through; if a field is not part of the documented event contract above, read `raw_event` instead of expecting it to be guessed into a stable top-level field.
### Forwarding meeting activity to IM
`lark-cli event consume` does not send IM messages automatically and does not expose IM-ready post payloads. If the user wants meeting activity forwarded into IM, use `vc +meeting-events --format json` to read structured events; for `chat_received_items[].message_type == 3`, construct the Feishu `post` node with `tag:"emotion"` and `emoji_type` from the item's `content`.

View File

@@ -72,12 +72,14 @@ metadata:
- 再根据 `note_id``minute_token` 和用户意图,按 [`lark-vc`](../lark-vc/SKILL.md) 的产物决策读取正文、逐字稿或妙记。
- 想看参会人快照:用 `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`** **来回答一场正在进行中的会议内容,就不能直接复用旧结果。** 无论用户是在问“现在/刚刚/最新”的状态,还是让你“总结一下这个会议讲什么”,都必须先重新拉一次当前事件流,确认拿到的是最新信息,再基于最新结果回答。只有在用户明确要求基于某次历史快照继续分析时,才可以复用旧结果
10. 用户直接问“这个会议讲了什么 / 现在讲到哪了”且上下文没有明确 `meeting_id` 时,先用用户身份发现当前会议;如果用户明确要求应用机器人视角,或上下文已经是应用机器人参会流程,再用应用身份发现。若返回多个会议,展示候选并让用户选择
11. 用户直接提供 **9 位会议号** 并询问会中事件/会议内容时,默认把它当作 active meeting 的筛选条件:先按当前身份查 active meetings并在返回里匹配 `meeting_no == <9位会议号>`;匹配到唯一会议后取长数字 `meeting_id`,再用同一身份查事件。只有用户明确要求“入会 / 让应用机器人旁听 / 代我参会”时才改用 `+meeting-join`
6. 命令默认输出结构化事件契约:`meeting``identity``current_roster``events``warnings``has_more``page_token`,参会人含 `participant_type``role``is_self` 和可读 `label`;事件中的原始细节保留在 `payload/raw`
7. 输出格式默认优先 `--format pretty`(时间线更易读,并带当前身份与当前名单标签);需要结构化处理时用 `--format json`;需要流式消费事件时用 `--format ndjson`
8. **必须识别分页信号**:只要响应里出现 `has_more=true`、pretty 里的 `more available`,或返回了非空 `page_token`,就不能把当前结果当作完整事件流;默认应继续分页,或明确告诉用户当前只是部分结果
9. 保留响应里的 `page_token`,下次增量拉取直接续,不要从头再拉
10. **只要你是基于** **`+meeting-events`** **来回答一场正在进行中的会议内容,就不能直接复用旧结果。** 无论用户是在问“现在/刚刚/最新”的状态,还是让你“总结一下这个会议讲什么”,都必须先重新拉一次当前事件流,确认拿到的是最新信息,再基于最新结果回答。只有在用户明确要求基于某次历史快照继续分析时,才可以复用旧结果
11. **会中聊天 / 互动转发到 IM 时基于 JSON 事件构造 IM post。** `chat_received_items[].message_type == 3` 表示会中 reaction必须把同一 item 的 `content` 作为 Feishu post `emotion.emoji_type`;普通聊天按文本发送。不要从 pretty/Markdown 重新拼消息,也不要把 reaction 改写成普通文本。用户已说“发给我 / 推送给我 / 发到我的单聊”时,默认用 bot 身份直接发当前用户;收件人不明确时只补问收件人
12. 用户直接问“这个会议讲了什么 / 现在讲到哪了”且上下文没有明确 `meeting_id` 时,先用用户身份发现当前会议;如果用户明确要求应用机器人视角,或上下文已经是应用机器人参会流程,再用应用身份发现。若返回多个会议,展示候选并让用户选择。
13. 用户直接提供 **9 位会议号** 并询问会中事件/会议内容时,默认把它当作 active meeting 的筛选条件:先按当前身份查 active meetings并在返回里匹配 `meeting_no == <9位会议号>`;匹配到唯一会议后取长数字 `meeting_id`,再用同一身份查事件。只有用户明确要求“入会 / 让应用机器人旁听 / 代我参会”时才改用 `+meeting-join`
### 3. 离开会议(写操作)
@@ -99,13 +101,14 @@ metadata:
```bash
# 1. 入会,捕获 meeting.id
JOIN=$(lark-cli vc +meeting-join --as bot --meeting-number 123456789 --format json)
AS=bot
JOIN=$(lark-cli vc +meeting-join --as "$AS" --meeting-number 123456789 --format json)
MID=$(echo "$JOIN" | jq -r '.data.meeting.id')
# 2. 会中轮询事件
# 默认用 --page-all 拉全当前可见事件;下次增量优先复用 page_token
# 沿用入会身份;默认用 --page-all 拉全当前可见事件;下次增量优先复用 page_token
# 典型间隔 10-30 秒
lark-cli vc +meeting-events --as bot --meeting-id "$MID" --page-all --format pretty
lark-cli vc +meeting-events --as "$AS" --meeting-id "$MID" --page-all --format pretty
# 3. 会后可选:进入 lark-vc 获取会议产物信息,再按 note_id / minute_token 决策读取
lark-cli vc +detail --meeting-ids "$MID"
@@ -117,7 +120,7 @@ lark-cli vc +detail --meeting-ids "$MID"
```bash
lark-cli vc +meeting-list-active --as bot --user-id <user_open_id> --format json
lark-cli vc +meeting-events --as bot --meeting-id <meeting_id> --page-all --format pretty
lark-cli vc +meeting-events --as bot --meeting-id <id> --page-all --format pretty
```
如果只是回答当前登录用户所在会议发生了什么,使用用户身份一路查:

View File

@@ -14,17 +14,14 @@
## 命令
```bash
# 默认用法:全量拉取当前可见事件
lark-cli vc +meeting-events --as <same_identity> --meeting-id 69xxxxxxxxxxxxx28 --page-all --format pretty
# 默认用法:全量拉取当前身份可见事件;输出结构化事件契约
lark-cli vc +meeting-events --as <same_identity> --meeting-id <id> --page-all --format pretty
# 指定时间范围,并拉全该时间窗内当前可见事件
lark-cli vc +meeting-events --as <same_identity> --meeting-id 69xxxxxxxxxxxxx28 --start 2026-04-17T15:00:00+08:00 --end 2026-04-17T16:00:00+08:00 --page-all --format pretty
lark-cli vc +meeting-events --as <same_identity> --meeting-id <id> --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 --as <same_identity> --meeting-id 69xxxxxxxxxxxxx28 --page-token <last_page_token> --page-all --format pretty
# 调试或控制返回体大小时,显式只查一页
lark-cli vc +meeting-events --as <same_identity> --meeting-id 69xxxxxxxxxxxxx28 --page-size 20 --format json
lark-cli vc +meeting-events --as <same_identity> --meeting-id <id> --page-token <last_page_token> --page-all --format pretty
```
## 参数
@@ -54,9 +51,10 @@ lark-cli vc +meeting-events --as <same_identity> --meeting-id 69xxxxxxxxxxxxx28
### 2. 身份来源是读取事件的权限锚点
- 用户身份路径:先用 `+meeting-list-active --as user` 发现当前登录用户的会议,再用 `+meeting-events --as user` 读取该 `meeting_id`
- 用身份路径:应用机器人必须在会中或参会过;不要拿任意 `meeting_id` 直接用 `--as bot`
- 不要混用身份。身份不一致时,常见结果是空列表、`no permission``bot is not in meeting`
- `+meeting-events` 支持 `--as user``--as bot`
-身份路径:用户身份发现的会议继续用用户身份读取
- 应用身份路径:应用机器人必须在会中或参会过;不要拿任意 `meeting_id` 直接查
- 不要在拿到 `meeting_id` 后随意切换身份。身份不一致时,常见结果是空列表、`no permission``bot is not in meeting`
### 3. 读取事件前必须先拿到可见的 meeting_id
@@ -67,21 +65,21 @@ lark-cli vc +meeting-events --as <same_identity> --meeting-id 69xxxxxxxxxxxxx28
lark-cli vc +meeting-join --as bot --meeting-number 123456789
# 再查询事件
lark-cli vc +meeting-events --as bot --meeting-id <meeting.id>
lark-cli vc +meeting-events --as bot --meeting-id <id>
```
如果应用机器人已经在会中,也可以先通过 active meeting 找会:
```bash
lark-cli vc +meeting-list-active --as bot --user-id <user_open_id> --format json
lark-cli vc +meeting-events --as bot --meeting-id <meeting_id> --page-all --format pretty
lark-cli vc +meeting-events --as bot --meeting-id <id> --page-all --format pretty
```
如果只是查询当前登录用户所在会议:
如果查询当前登录用户所在会议:
```bash
lark-cli vc +meeting-list-active --as user --format json
lark-cli vc +meeting-events --as user --meeting-id <meeting_id> --page-all --format pretty
lark-cli vc +meeting-events --as user --meeting-id <id> --page-all --format pretty
```
若应用机器人已离会、未入会、或会议已经无法再判断身份,后端通常会报:
@@ -104,18 +102,19 @@ lark-cli vc +meeting-events --as user --meeting-id <meeting_id> --page-all --for
执行准则:
- **默认命令模板**`lark-cli vc +meeting-events --as <same_identity> --meeting-id <meeting.id> --page-all --format pretty`
- **默认命令模板**`lark-cli vc +meeting-events --as <same_identity> --meeting-id <id> --page-all --format pretty`
- 如果你发现自己执行成了不带 `--page-all` 的单页查询,而响应里又出现 `has_more=true` / `more available` / 非空 `page_token`,应立刻意识到这只是部分结果。
- 遇到上述情况,默认补救方式是继续使用返回的 `page_token` 续拉,例如:`lark-cli vc +meeting-events --as <same_identity> --meeting-id <meeting.id> --page-token <returned_page_token> --page-all --format pretty`
- 遇到上述情况,默认补救方式是继续使用返回的 `page_token` 续拉,例如:`lark-cli vc +meeting-events --as <same_identity> --meeting-id <id> --page-token <returned_page_token> --page-all --format pretty`
- 只有在用户明确要求“就看第一页”“先不要翻页”时,才不要默认带 `--page-all`
- 只要你是基于 `+meeting-events` 来回答一场**正在进行中的会议内容**,就不能直接复用上一次查询结果。无论用户是在问“现在是谁在说话”“刚刚发生了什么”“最新事件有哪些”,还是让你“总结一下这个会议讲什么”,都必须先重新执行一次 `+meeting-events`,确认拿到的是最新事件流,再回答用户。只有在用户明确要求基于某次历史快照继续分析时,才可以复用旧结果。
### 5. pretty / json 输出差异
### 5. 输出格式差异
- `--format pretty`:输出会议主题、会议时间和逐条时间线,适合快速理解“发生了什么”,也是本 skill 的默认推荐格式
- `--format json`:保留完整原始 `events[]` 结构——参会人 open_id、聊天原文、share_doc、分页字段都在原始响应里适合提取字段、联动其他命令或做进一步程序处理
- `--format json`:结构化契约,顶层包含 `meeting``identity``current_roster``events``has_more``page_token`。参会人身份统一含 `participant_type``role``is_self``label`;每条事件保留 `payload/raw` 便于追溯原始细节
- `--format pretty`:输出当前身份、当前名单和逐条时间线,适合快速理解“发生了什么”
- `--format ndjson`:输出事件行,并带 metadata 行,适合流式消费。
**选型原则**:只目标是告诉用户“发生了什么”,默认就`--page-all --format pretty`只有在需要完整原始消息流和结构化字段时,才改用 `json`
**选型原则**:只`pretty``json``ndjson` 之间选择。目标是告诉用户“发生了什么”,用 `--page-all --format pretty`需要稳定字段给 agent 消费时用 `--format json`;需要流式消费时用 `--format ndjson`
> **注意**pretty 输出中的正文文本会做单行转义,真实换行会显示为 `\n`,避免打乱时间线布局。
@@ -132,10 +131,10 @@ lark-cli vc +meeting-events --as user --meeting-id <meeting_id> --page-all --for
执行准则:
- 如果上下文已有明确 `meeting_id` 和来源身份,直接用同一身份执行 `+meeting-events --page-all --format json`
- 如果上下文已有明确 `meeting_id`,沿用该 `meeting_id` 的来源身份执行 `+meeting-events --page-all --format json`
- 如果上下文没有明确 `meeting_id`,先按用户当前意图选择身份:问“我/当前用户所在会议”用 `lark-cli vc +meeting-list-active --as user --format pretty`;问“应用机器人可见的目标用户会议”用 `lark-cli vc +meeting-list-active --as bot --user-id <user_open_id> --format pretty`。返回多个会议时先让用户选择。
- 如果上下文只有 9 位会议号,先按当前身份执行 `+meeting-list-active` 并按 `meeting_no` 匹配;匹配到唯一会议后再查事件。不要为了总结会议而自动调用 `+meeting-join`
- 这类问题拿到 `meeting_id` 后,用 `lark-cli vc +meeting-events --as <same_identity> --meeting-id <meeting.id> --page-all --format json` 拉取最新事件流。
- 这类问题拿到 `meeting_id` 后,用同一身份执行 `lark-cli vc +meeting-events --as <same_identity> --meeting-id <id> --page-all --format json` 拉取最新事件流。
- 如果事件中出现共享文档线索,例如:
- `magic_share_started`
- `share_doc.title`
@@ -159,7 +158,11 @@ lark-cli vc +meeting-events --as user --meeting-id <meeting_id> --page-all --for
| 字段 | 说明 |
|------|------|
| `events` | 事件列表 |
| `meeting` | 会议身份与时间状态,包含 `id/topic/meeting_no/start_time/end_time/status` |
| `identity` | 当前读取身份,包含 `id/name/participant_type/role/is_self/label` |
| `current_roster` | 服务端当前名单,不从历史事件 replay 推导 |
| `events` | 结构化事件列表;每条事件含 `actors` 和原始 `payload/raw` |
| `warnings` | 非阻断告警列表,例如当前名单补充信息不可用;事件列表本身仍可使用 |
| `has_more` | 是否还有下一页 |
| `page_token` | 下一页游标 |
@@ -174,6 +177,29 @@ lark-cli vc +meeting-events --as user --meeting-id <meeting_id> --page-all --for
| `magic_share_started` | 开始共享内容 / 文档 |
| `magic_share_ended` | 结束共享 |
### Forwarding meeting chat and reactions to IM
转发到 IM 时Agent 必须先用 `+meeting-events --format json` 的结构化事件构造完整 Feishu `post` 内容,再调用 IM 发送 shortcut。不要解析 pretty/Markdown 输出,也不要先生成纯文本或 Markdown 后再期望 IM 侧二次识别 reaction。
`event_type == "chat_received"` 的事件逐项处理 `payload.chat_received_items`
- `message_type == 3` 是会中 reaction构造 IM `post` 内容时,把同一 item 的 `content` 直接写成 `{"tag":"emotion","emoji_type":"<content>"}`
- 其他聊天消息写成文本节点:`{"tag":"text","text":"<content>"}`
- 最终调用 `im +messages-send --msg-type post --content '<post-json>'`,其中 `<post-json>` 必须保留上面构造出的 `emotion` 节点;不要用 `--markdown` 承载会中 reaction。
- 如果用户原始请求已经明确“发给我 / 推送给我 / 发到我的聊天框 / 发到我的单聊”,这已经覆盖本次收件人、内容和发送动作,直接发送给当前用户,不要再二次询问“是否发送”。
- 默认用应用身份 `--as bot` 发送;只有用户明确要求“用本人身份 / 用户身份发送”时才切到 `--as user`
- 如果用户要求发给某个群或其他人但收件人不可唯一确定,只询问缺失的收件人信息。
```bash
lark-cli vc +meeting-events \
--as <same_identity> \
--meeting-id <id> \
--page-all \
--format json
```
如果用户已经要求“发给我”,`<open_id>` 使用当前用户的 open_id需要解析时先用用户查询能力获取当前用户信息。构造 IM post 时只发送用户请求范围内的会中内容,不要把前一条自然语言预览当作发送内容。
## pretty 输出示例
```text
@@ -197,28 +223,29 @@ lark-cli vc +meeting-events --as user --meeting-id <meeting_id> --page-all --for
## Agent 组合场景
### 场景 1入会后查看会中发生了什么
### 场景 1入会后读取会中发生了什么
```bash
# 第 1 步:加入会议,记录返回的 meeting.id
lark-cli vc +meeting-join --as bot --meeting-number 123456789
JOIN=$(lark-cli vc +meeting-join --as bot --meeting-number 123456789 --format json)
MID=$(echo "$JOIN" | jq -r '.data.meeting.id')
# 第 2 步:查询事件
lark-cli vc +meeting-events --as bot --meeting-id <meeting.id> --page-all --format pretty
# 第 2 步:用 meeting.id 读取当前可见事件
lark-cli vc +meeting-events --as bot --meeting-id "$MID" --page-all --format pretty
```
### 场景 1b应用机器人已在会中先发现 meeting_id 再读事件
```bash
lark-cli vc +meeting-list-active --as bot --user-id <user_open_id> --format json
lark-cli vc +meeting-events --as bot --meeting-id <meeting_id> --page-all --format pretty
lark-cli vc +meeting-events --as bot --meeting-id <id> --page-all --format pretty
```
### 场景 1c当前登录用户正在会中先发现 meeting_id 再读事件
```bash
lark-cli vc +meeting-list-active --as user --format json
lark-cli vc +meeting-events --as user --meeting-id <meeting_id> --page-all --format pretty
lark-cli vc +meeting-events --as user --meeting-id <id> --page-all --format pretty
```
### 场景 2过滤某段时间内的事件
@@ -226,7 +253,7 @@ lark-cli vc +meeting-events --as user --meeting-id <meeting_id> --page-all --for
```bash
lark-cli vc +meeting-events \
--as <same_identity> \
--meeting-id <meeting.id> \
--meeting-id <id> \
--start 2026-04-17T15:00:00+08:00 \
--end 2026-04-17T16:00:00+08:00 \
--page-all \
@@ -240,7 +267,7 @@ lark-cli vc +meeting-events \
# 这次直接从该游标继续拉新增事件
lark-cli vc +meeting-events \
--as <same_identity> \
--meeting-id <meeting.id> \
--meeting-id <id> \
--page-token <last_page_token> \
--page-all \
--format pretty
@@ -257,10 +284,9 @@ lark-cli vc +meeting-events \
| 错误现象 | 根本原因 | 解决方案 |
|---------|---------|---------|
| `--meeting-id is required` | 未传入 `--meeting-id` | 传入长数字 `meeting.id` |
| `not a 9-digit meeting number` | 把 9 位会议号误传给 `--meeting-id` | 如果只是查询会中内容,先用 `+meeting-list-active``meeting_no` 匹配拿长数字 `meeting_id`;只有用户明确要求入会时才用 `+meeting-join --as bot --meeting-number <9位号>` |
| `10005 bot is not in meeting` | 使用应用身份读取,但应用机器人从未真实入会该会议;或会议已结束但应用机器人从未在会中出现过 | 如果本来是用户身份发现的 `meeting_id`,改回 `--as user`;如果确实要应用身份读取,先 `+meeting-join --as bot --meeting-number <9位号>` 真实入会再查。**如果只是想看参会人快照,改用 `lark-cli vc meeting get --params '{"meeting_id":"<meeting.id>"}' --with-participants`** |
| 用户身份不支持 | 当前事件读取接口不支持用用户身份访问 | 不要反复执行 `auth login`。改用应用身份流程:先通过 `+meeting-list-active --as bot --user-id <user_open_id>` 获取应用身份可读的 `meeting_id`,或在用户明确同意后让应用机器人入会,再用 `+meeting-events --as bot` 读取 |
| `20001 meeting_status_MEETING_END` | 会议已结束且已超出后端允许的 5 分钟宽限窗口 | 本接口不再适合继续拉取事件。先用 `lark-cli vc +detail --meeting-ids <meeting.id>` 获取会议产物信息,再根据 `note_id` / `minute_token` 和用户意图选择纪要正文、逐字稿或妙记;参会人请用 `lark-cli vc meeting get --params '{"meeting_id":"<meeting.id>"}' --with-participants` |
| `10005 bot is not in meeting` | 使用应用身份读取,但应用机器人从未真实入会该会议;或会议已结束但应用机器人从未在会中出现过 | 如果 `meeting_id` 来自用户身份发现,改回 `--as user`;如果确实要应用身份读取,先让应用机器人入会或确认它曾参会后再用 `--as bot`。**如果只是想看参会人快照,改用 `lark-cli vc meeting get --params '{"meeting_id":"<meeting.id>"}' --with-participants`** |
| 用户身份无权限 / 不可见 | 当前用户不是该会议的可见参与者,或 `meeting_id` 不是从用户身份路径获得 | 不要反复执行 `auth login`。先确认 `meeting_id` 是否来自 `+meeting-list-active --as user`;如果用户明确要切到应用身份,再通过 `+meeting-list-active --as bot --user-id <user_open_id>` 获取应用身份可读的 `meeting_id`,或在用户明确同意后让应用机器人入会,再用 `+meeting-events --as bot` 读取 |
| `20001 meeting_status_MEETING_END` | 会议已结束且已超出后端允许的 5 分钟宽限窗口 | 本接口不再适合继续拉取事件。先用 `lark-cli vc +notes --meeting-ids <meeting.id>` 获取会议产物信息,再根据 `note_display_type` / `note_id` / `minute_token` 和用户意图选择纪要正文、逐字稿或妙记;参会人请用 `lark-cli vc meeting get --params '{"meeting_id":"<meeting.id>"}' --with-participants` |
| `20002 meeting not exist` | `meeting_id` 错误,或会议实例当前已不可获取(常见于把 9 位会议号当 meeting_id 传) | 确认传入的是长数字 `meeting_id`,不是 9 位会议号 |
| 应用身份权限不足 | 应用权限、租户安装、权限可访问的数据范围或 VC Agent privilege 未配置完整 | 不要执行 `auth login`。以 CLI 返回的 metadata / error envelope 为准确认缺失权限;检查应用发布/安装,以及开放平台“权限可访问的数据范围”:选择“按条件筛选”,条件为“会议的归属者 包含 与应用的可用范围一致”;仍失败再排查内测 privilege / 灰度 |
| `HTTP 404` / `HTTP 500` | 服务端当前无法找到或处理该会议实例 | 换一个正在进行且 bot 可见的 meeting_id或排查后端问题 |

View File

@@ -29,7 +29,7 @@ lark-cli vc +meeting-list-active --as bot --user-id ou_xxx --format json
| 用户身份 | `--as user` | 当前登录用户正在参加的会议 | 继续 `+meeting-events --as user` |
| 应用身份 | `--as bot --user-id <user_open_id>` | 目标用户正在参加、且应用机器人也在会中的会议 | 继续 `+meeting-events --as bot` |
硬规则:`meeting_id` 从哪种身份路径拿到,后续 `+meeting-events` 就沿用哪种身份。不要把用身份拿到的 `meeting_id` 改用应用身份查,也不要把用身份拿到的 `meeting_id` 改用用户身份查,除非用户明确要求切换场景
硬规则:`meeting_id` 从哪种身份路径拿到,后续 `+meeting-events` 就沿用哪种身份。不要把用身份拿到的 `meeting_id` 改用用户身份读事件,也不要把用身份拿到的 `meeting_id` 强制切到应用身份
应用身份返回空,不代表目标用户不在任何会议中,只能说明没有找到“目标用户在会中且应用机器人也在会中”的当前会。
@@ -38,22 +38,22 @@ lark-cli vc +meeting-list-active --as bot --user-id ou_xxx --format json
```bash
# 方式 1先让应用机器人入会直接从 join 响应拿 meeting.id
lark-cli vc +meeting-join --as bot --meeting-number 123456789 --format json
lark-cli vc +meeting-events --as bot --meeting-id <meeting.id> --page-all --format pretty
lark-cli vc +meeting-events --as bot --meeting-id <id> --page-all --format pretty
# 方式 2应用机器人已经在会中时用应用身份发现 meeting_id
lark-cli vc +meeting-list-active --as bot --user-id <user_open_id> --format json
lark-cli vc +meeting-events --as bot --meeting-id <meeting_id> --page-all --format pretty
lark-cli vc +meeting-events --as bot --meeting-id <id> --page-all --format pretty
# 方式 3只回答当前登录用户所在会议发生了什么
# 方式 3查询当前登录用户所在会议发生了什么
lark-cli vc +meeting-list-active --as user --format json
lark-cli vc +meeting-events --as user --meeting-id <meeting_id> --page-all --format pretty
lark-cli vc +meeting-events --as user --meeting-id <id> --page-all --format pretty
```
## 多会议选择
- 如果返回多个会议,不要自动挑第一个。
- 向用户展示每个候选的 `meeting_title` / `meeting_no` / `meeting_id`,等待用户选择。
- 选择后继续使用发现该会议时的同一身份调用 `+meeting-events`
- 选择后同一身份执行 `+meeting-events` 读取事件
## 9 位会议号匹配
@@ -80,7 +80,7 @@ lark-cli vc +meeting-list-active --as bot --user-id <user_open_id> --format json
|---------|---------|---------|
| `--user-id is required when --as bot` | 应用身份未传目标用户 | 传入目标用户 open_id |
| 用户身份返回空列表 | 当前登录用户没有可见的进行中会议 | 确认用户是否在会中,或是否切错身份 |
| 用户身份不支持 | 当前接口不支持用用户身份访问 | 不要反复执行 `auth login`。改用应用身份流程:先拿目标用户 open_id,再执行 `+meeting-list-active --as bot --user-id <user_open_id>`;同时按应用身份权限配置检查应用权限、安装、数据范围和灰度 |
| 用户身份无权限 / 不可见 | 当前登录用户没有可见的进行中会议,或当前身份无法读取该会议 | 不要反复执行 `auth login`。先确认当前登录用户是否在会中、是否切错 profile如果用户明确要查询应用机器人可见的会议拿目标用户 open_id 执行 `+meeting-list-active --as bot --user-id <user_open_id>`,并按应用身份权限配置检查应用权限、安装、数据范围和灰度 |
| 应用身份返回空列表 | 没有满足“目标用户在会中且应用机器人也在会中”的当前会 | 先让应用机器人入会,或确认 `user_id` 和会议状态 |
| `--user-id` 格式错误 | 传入了 internal user_id 或其他非 `ou_...` 值 | 改传目标用户 open_id |
| 应用身份权限不足 | 应用权限、租户安装、权限可访问的数据范围或 VC Agent privilege 未配置完整 | 不要执行 `auth login`。以 CLI 返回的 metadata / error envelope 为准确认缺失权限;检查应用发布/安装,以及开放平台“权限可访问的数据范围”:选择“按条件筛选”,条件为“会议的归属者 包含 与应用的可用范围一致”;仍失败再排查内测 privilege / 灰度 |