diff --git a/events/vc/bot_events.go b/events/vc/bot_events.go new file mode 100644 index 000000000..c2c9cc086 --- /dev/null +++ b/events/vc/bot_events.go @@ -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 "" +} diff --git a/events/vc/bot_events_test.go b/events/vc/bot_events_test.go new file mode 100644 index 000000000..e847dfc19 --- /dev/null +++ b/events/vc/bot_events_test.go @@ -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 +} diff --git a/events/vc/register.go b/events/vc/register.go index 5b4441aec..ec965f58d 100644 --- a/events/vc/register.go +++ b/events/vc/register.go @@ -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}, + }, } } diff --git a/shortcuts/vc/vc_meeting_events.go b/shortcuts/vc/vc_meeting_events.go index 48df42ac5..76c4a3517 100644 --- a/shortcuts/vc/vc_meeting_events.go +++ b/shortcuts/vc/vc_meeting_events.go @@ -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 { diff --git a/shortcuts/vc/vc_meeting_events_test.go b/shortcuts/vc/vc_meeting_events_test.go index 198498731..a02659e01 100644 --- a/shortcuts/vc/vc_meeting_events_test.go +++ b/shortcuts/vc/vc_meeting_events_test.go @@ -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 diff --git a/skills/lark-event/SKILL.md b/skills/lark-event/SKILL.md index 885446e6b..5fe167854 100644 --- a/skills/lark-event/SKILL.md +++ b/skills/lark-event/SKILL.md @@ -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=`) + payload field reference (whiteboard_id / operator_ids triple-id) | diff --git a/skills/lark-event/references/lark-event-vc.md b/skills/lark-event/references/lark-event-vc.md index 16679aaeb..9843b4af8 100644 --- a/skills/lark-event/references/lark-event-vc.md +++ b/skills/lark-event/references/lark-event-vc.md @@ -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`. diff --git a/skills/lark-vc-agent/SKILL.md b/skills/lark-vc-agent/SKILL.md index 657c40959..162e631b8 100644 --- a/skills/lark-vc-agent/SKILL.md +++ b/skills/lark-vc-agent/SKILL.md @@ -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 --format json -lark-cli vc +meeting-events --as bot --meeting-id --page-all --format pretty +lark-cli vc +meeting-events --as bot --meeting-id --page-all --format pretty ``` 如果只是回答当前登录用户所在会议发生了什么,使用用户身份一路查: diff --git a/skills/lark-vc-agent/references/lark-vc-agent-meeting-events.md b/skills/lark-vc-agent/references/lark-vc-agent-meeting-events.md index a01e56008..8717d2658 100644 --- a/skills/lark-vc-agent/references/lark-vc-agent-meeting-events.md +++ b/skills/lark-vc-agent/references/lark-vc-agent-meeting-events.md @@ -14,17 +14,14 @@ ## 命令 ```bash -# 默认用法:全量拉取当前可见事件 -lark-cli vc +meeting-events --as --meeting-id 69xxxxxxxxxxxxx28 --page-all --format pretty +# 默认用法:全量拉取当前身份可见事件;输出结构化事件契约 +lark-cli vc +meeting-events --as --meeting-id --page-all --format pretty # 指定时间范围,并拉全该时间窗内当前可见事件 -lark-cli vc +meeting-events --as --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 --meeting-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 --meeting-id 69xxxxxxxxxxxxx28 --page-token --page-all --format pretty - -# 调试或控制返回体大小时,显式只查一页 -lark-cli vc +meeting-events --as --meeting-id 69xxxxxxxxxxxxx28 --page-size 20 --format json +lark-cli vc +meeting-events --as --meeting-id --page-token --page-all --format pretty ``` ## 参数 @@ -54,9 +51,10 @@ lark-cli vc +meeting-events --as --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 --meeting-id 69xxxxxxxxxxxxx28 lark-cli vc +meeting-join --as bot --meeting-number 123456789 # 再查询事件 -lark-cli vc +meeting-events --as bot --meeting-id +lark-cli vc +meeting-events --as bot --meeting-id ``` 如果应用机器人已经在会中,也可以先通过 active meeting 找会: ```bash lark-cli vc +meeting-list-active --as bot --user-id --format json -lark-cli vc +meeting-events --as bot --meeting-id --page-all --format pretty +lark-cli vc +meeting-events --as bot --meeting-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 --page-all --format pretty +lark-cli vc +meeting-events --as user --meeting-id --page-all --format pretty ``` 若应用机器人已离会、未入会、或会议已经无法再判断身份,后端通常会报: @@ -104,18 +102,19 @@ lark-cli vc +meeting-events --as user --meeting-id --page-all --for 执行准则: -- **默认命令模板**:`lark-cli vc +meeting-events --as --meeting-id --page-all --format pretty` +- **默认命令模板**:`lark-cli vc +meeting-events --as --meeting-id --page-all --format pretty` - 如果你发现自己执行成了不带 `--page-all` 的单页查询,而响应里又出现 `has_more=true` / `more available` / 非空 `page_token`,应立刻意识到这只是部分结果。 -- 遇到上述情况,默认补救方式是继续使用返回的 `page_token` 续拉,例如:`lark-cli vc +meeting-events --as --meeting-id --page-token --page-all --format pretty` +- 遇到上述情况,默认补救方式是继续使用返回的 `page_token` 续拉,例如:`lark-cli vc +meeting-events --as --meeting-id --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 --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 --format pretty`。返回多个会议时先让用户选择。 - 如果上下文只有 9 位会议号,先按当前身份执行 `+meeting-list-active` 并按 `meeting_no` 匹配;匹配到唯一会议后再查事件。不要为了总结会议而自动调用 `+meeting-join`。 -- 这类问题拿到 `meeting_id` 后,用 `lark-cli vc +meeting-events --as --meeting-id --page-all --format json` 拉取最新事件流。 +- 这类问题拿到 `meeting_id` 后,用同一身份执行 `lark-cli vc +meeting-events --as --meeting-id --page-all --format json` 拉取最新事件流。 - 如果事件中出现共享文档线索,例如: - `magic_share_started` - `share_doc.title` @@ -159,7 +158,11 @@ lark-cli vc +meeting-events --as user --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 --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":""}`。 +- 其他聊天消息写成文本节点:`{"tag":"text","text":""}`。 +- 最终调用 `im +messages-send --msg-type post --content ''`,其中 `` 必须保留上面构造出的 `emotion` 节点;不要用 `--markdown` 承载会中 reaction。 +- 如果用户原始请求已经明确“发给我 / 推送给我 / 发到我的聊天框 / 发到我的单聊”,这已经覆盖本次收件人、内容和发送动作,直接发送给当前用户,不要再二次询问“是否发送”。 +- 默认用应用身份 `--as bot` 发送;只有用户明确要求“用本人身份 / 用户身份发送”时才切到 `--as user`。 +- 如果用户要求发给某个群或其他人但收件人不可唯一确定,只询问缺失的收件人信息。 + +```bash +lark-cli vc +meeting-events \ + --as \ + --meeting-id \ + --page-all \ + --format json +``` + +如果用户已经要求“发给我”,`` 使用当前用户的 open_id;需要解析时先用用户查询能力获取当前用户信息。构造 IM post 时只发送用户请求范围内的会中内容,不要把前一条自然语言预览当作发送内容。 + ## pretty 输出示例 ```text @@ -197,28 +223,29 @@ lark-cli vc +meeting-events --as user --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 --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 --format json -lark-cli vc +meeting-events --as bot --meeting-id --page-all --format pretty +lark-cli vc +meeting-events --as bot --meeting-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 --page-all --format pretty +lark-cli vc +meeting-events --as user --meeting-id --page-all --format pretty ``` ### 场景 2:过滤某段时间内的事件 @@ -226,7 +253,7 @@ lark-cli vc +meeting-events --as user --meeting-id --page-all --for ```bash lark-cli vc +meeting-events \ --as \ - --meeting-id \ + --meeting-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 \ - --meeting-id \ + --meeting-id \ --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":""}' --with-participants`** | -| 用户身份不支持 | 当前事件读取接口不支持用用户身份访问 | 不要反复执行 `auth login`。改用应用身份流程:先通过 `+meeting-list-active --as bot --user-id ` 获取应用身份可读的 `meeting_id`,或在用户明确同意后让应用机器人入会,再用 `+meeting-events --as bot` 读取 | -| `20001 meeting_status_MEETING_END` | 会议已结束且已超出后端允许的 5 分钟宽限窗口 | 本接口不再适合继续拉取事件。先用 `lark-cli vc +detail --meeting-ids ` 获取会议产物信息,再根据 `note_id` / `minute_token` 和用户意图选择纪要正文、逐字稿或妙记;参会人请用 `lark-cli vc meeting get --params '{"meeting_id":""}' --with-participants` | +| `10005 bot is not in meeting` | 使用应用身份读取,但应用机器人从未真实入会该会议;或会议已结束但应用机器人从未在会中出现过 | 如果 `meeting_id` 来自用户身份发现,改回 `--as user`;如果确实要应用身份读取,先让应用机器人入会或确认它曾参会后再用 `--as bot`。**如果只是想看参会人快照,改用 `lark-cli vc meeting get --params '{"meeting_id":""}' --with-participants`** | +| 用户身份无权限 / 不可见 | 当前用户不是该会议的可见参与者,或 `meeting_id` 不是从用户身份路径获得 | 不要反复执行 `auth login`。先确认 `meeting_id` 是否来自 `+meeting-list-active --as user`;如果用户明确要切到应用身份,再通过 `+meeting-list-active --as bot --user-id ` 获取应用身份可读的 `meeting_id`,或在用户明确同意后让应用机器人入会,再用 `+meeting-events --as bot` 读取 | +| `20001 meeting_status_MEETING_END` | 会议已结束且已超出后端允许的 5 分钟宽限窗口 | 本接口不再适合继续拉取事件。先用 `lark-cli vc +notes --meeting-ids ` 获取会议产物信息,再根据 `note_display_type` / `note_id` / `minute_token` 和用户意图选择纪要正文、逐字稿或妙记;参会人请用 `lark-cli vc meeting get --params '{"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,或排查后端问题 | diff --git a/skills/lark-vc-agent/references/lark-vc-agent-meeting-list-active.md b/skills/lark-vc-agent/references/lark-vc-agent-meeting-list-active.md index 825dee3f3..f0cd9be08 100644 --- a/skills/lark-vc-agent/references/lark-vc-agent-meeting-list-active.md +++ b/skills/lark-vc-agent/references/lark-vc-agent-meeting-list-active.md @@ -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 ` | 目标用户正在参加、且应用机器人也在会中的会议 | 继续 `+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 --page-all --format pretty +lark-cli vc +meeting-events --as bot --meeting-id --page-all --format pretty # 方式 2:应用机器人已经在会中时,用应用身份发现 meeting_id lark-cli vc +meeting-list-active --as bot --user-id --format json -lark-cli vc +meeting-events --as bot --meeting-id --page-all --format pretty +lark-cli vc +meeting-events --as bot --meeting-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 --page-all --format pretty +lark-cli vc +meeting-events --as user --meeting-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 --format json |---------|---------|---------| | `--user-id is required when --as bot` | 应用身份未传目标用户 | 传入目标用户 open_id | | 用户身份返回空列表 | 当前登录用户没有可见的进行中会议 | 确认用户是否在会中,或是否切错身份 | -| 用户身份不支持 | 当前接口不支持用用户身份访问 | 不要反复执行 `auth login`。改用应用身份流程:先拿目标用户 open_id,再执行 `+meeting-list-active --as bot --user-id `;同时按应用身份权限配置检查应用权限、安装、数据范围和灰度 | +| 用户身份无权限 / 不可见 | 当前登录用户没有可见的进行中会议,或当前身份无法读取该会议 | 不要反复执行 `auth login`。先确认当前登录用户是否在会中、是否切错 profile;如果用户明确要查询应用机器人可见的会议,再拿目标用户 open_id 执行 `+meeting-list-active --as bot --user-id `,并按应用身份权限配置检查应用权限、安装、数据范围和灰度 | | 应用身份返回空列表 | 没有满足“目标用户在会中且应用机器人也在会中”的当前会 | 先让应用机器人入会,或确认 `user_id` 和会议状态 | | `--user-id` 格式错误 | 传入了 internal user_id 或其他非 `ou_...` 值 | 改传目标用户 open_id | | 应用身份权限不足 | 应用权限、租户安装、权限可访问的数据范围或 VC Agent privilege 未配置完整 | 不要执行 `auth login`。以 CLI 返回的 metadata / error envelope 为准确认缺失权限;检查应用发布/安装,以及开放平台“权限可访问的数据范围”:选择“按条件筛选”,条件为“会议的归属者 包含 与应用的可用范围一致”;仍失败再排查内测 privilege / 灰度 |