mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
feat(vc): support bot meeting activity events
This commit is contained in:
156
events/vc/bot_events.go
Normal file
156
events/vc/bot_events.go
Normal 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 ""
|
||||
}
|
||||
277
events/vc/bot_events_test.go
Normal file
277
events/vc/bot_events_test.go
Normal 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
|
||||
}
|
||||
@@ -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},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
如果只是回答当前登录用户所在会议发生了什么,使用用户身份一路查:
|
||||
|
||||
@@ -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,或排查后端问题 |
|
||||
|
||||
@@ -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 / 灰度 |
|
||||
|
||||
Reference in New Issue
Block a user