Compare commits

...

1 Commits

Author SHA1 Message Date
renaocheng
8581b1cd4c feat: support VC bot event consume
Source-Branch: features/F-vc-meeting-contract
Source-Commit: 40a09c8957
Source-Subject: chore: release v1.0.58 (#1586)
Repo: larksuite-cli
Synced-By: bytedance
Timestamp: 20260625_190731Z
2026-06-26 03:07:31 +08:00
5 changed files with 487 additions and 3 deletions

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

@@ -0,0 +1,174 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"bytes"
"context"
"encoding/json"
"sort"
"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 when present in the bot event payload"`
ActivityEventType string `json:"activity_event_type,omitempty" desc:"Meeting activity event subtype when present"`
ChatEmojiTypes []string `json:"chat_emoji_types,omitempty" desc:"Feishu post emotion emoji_type values extracted from vc.bot.meeting_event_v1 payloads"`
RawEvent json.RawMessage `json:"raw_event,omitempty" desc:"Original VC bot event payload; authoritative for fields not normalized by lark-cli"`
}
func processVCBotMeetingInvited(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
return processVCBotEvent(raw, false)
}
func processVCBotMeetingEvent(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
return processVCBotEvent(raw, true)
}
func processVCBotMeetingEnded(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
return processVCBotEvent(raw, false)
}
func processVCBotEvent(raw *event.RawEvent, includeEmojiTypes bool) (json.RawMessage, error) {
var payload any
decoder := json.NewDecoder(bytes.NewReader(raw.Payload))
decoder.UseNumber()
if err := decoder.Decode(&payload); err != nil {
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
}
out := &VCBotEventOutput{
Type: firstString(payload, "event_type"),
EventID: firstString(payload, "event_id"),
Timestamp: firstString(payload, "create_time"),
CallID: firstString(payload, "call_id"),
MeetingNo: firstString(payload, "meeting_no"),
ActivityEventType: firstString(payload, "activity_event_type"),
RawEvent: append(json.RawMessage(nil), raw.Payload...),
}
if out.Type == "" {
out.Type = raw.EventType
}
if includeEmojiTypes {
out.ChatEmojiTypes = botEmojiTypes(payload)
}
return json.Marshal(out)
}
func firstString(value any, key string) string {
switch v := value.(type) {
case map[string]any:
if raw, ok := v[key]; ok {
if s := jsonString(raw); s != "" {
return s
}
}
for _, child := range orderedChildren(v) {
if s := firstString(child, key); s != "" {
return s
}
}
case []any:
for _, child := range v {
if s := firstString(child, key); s != "" {
return s
}
}
}
return ""
}
func orderedChildren(v map[string]any) []any {
priority := []string{"header", "event", "meeting", "meeting_info", "activity", "message", "reaction_type"}
out := make([]any, 0, len(v))
used := make(map[string]bool, len(priority))
for _, key := range priority {
if child, ok := v[key]; ok {
out = append(out, child)
used[key] = true
}
}
rest := make([]string, 0, len(v))
for key := range v {
if !used[key] {
rest = append(rest, key)
}
}
sort.Strings(rest)
for _, key := range rest {
out = append(out, v[key])
}
return out
}
func botEmojiTypes(value any) []string {
seen := map[string]bool{}
var out []string
collectEmojiTypes(value, seen, &out)
return out
}
func collectEmojiTypes(value any, seen map[string]bool, out *[]string) {
switch v := value.(type) {
case map[string]any:
for _, key := range []string{"emoji_type", "chat_emoji_type"} {
if s := jsonString(v[key]); s != "" && !seen[s] {
seen[s] = true
*out = append(*out, s)
}
}
if raw, ok := v["chat_emoji_types"]; ok {
for _, s := range jsonStringSlice(raw) {
if !seen[s] {
seen[s] = true
*out = append(*out, s)
}
}
}
for _, child := range v {
collectEmojiTypes(child, seen, out)
}
case []any:
for _, child := range v {
collectEmojiTypes(child, seen, out)
}
}
}
func jsonString(value any) string {
switch v := value.(type) {
case string:
return v
case json.Number:
return v.String()
}
return ""
}
func jsonStringSlice(value any) []string {
switch v := value.(type) {
case []any:
out := make([]string, 0, len(v))
for _, item := range v {
if s := jsonString(item); s != "" {
out = append(out, s)
}
}
return out
case []string:
return append([]string(nil), v...)
case string:
if v == "" {
return nil
}
return []string{v}
}
return nil
}

View File

@@ -0,0 +1,206 @@
// 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
wantEmojis []string
}{
{
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 event",
eventType: eventTypeBotMeetingEvent,
process: processVCBotMeetingEvent,
payload: `{
"schema": "2.0",
"header": {
"event_id": "ev_activity",
"event_type": "vc.bot.meeting_event_v1",
"create_time": "1776409469274"
},
"event": {
"meeting_no": "987654321",
"activity_event_type": "chat_message",
"chat_messages": [
{"message_type": 3, "reaction_type": {"emoji_type": "JIAYI"}},
{"message_type": 3, "chat_emoji_types": ["OK", "JIAYI"]}
]
}
}`,
want: VCBotEventOutput{
Type: eventTypeBotMeetingEvent,
EventID: "ev_activity",
Timestamp: "1776409469274",
MeetingNo: "987654321",
ActivityEventType: "chat_message",
},
wantEmojis: []string{"JIAYI", "OK"},
},
{
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 !reflect.DeepEqual(out.ChatEmojiTypes, tc.wantEmojis) {
t.Errorf("ChatEmojiTypes = %v, want %v", out.ChatEmojiTypes, tc.wantEmojis)
}
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 runBotEventProcess(t *testing.T, eventType string, process event.ProcessFunc, payload string) VCBotEventOutput {
t.Helper()
raw := &event.RawEvent{
EventID: "raw_" + eventType,
EventType: eventType,
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
got, err := process(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("process %s: %v", eventType, err)
}
var out VCBotEventOutput
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("unmarshal output: %v\n%s", err, string(got))
}
return out
}

View File

@@ -16,6 +16,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_event_v1"
eventTypeBotMeetingEnded = "vc.bot.meeting_ended_v1"
pathMeetingSubscribe = "/open-apis/vc/v1/meetings/subscription"
pathMeetingUnsubscribe = "/open-apis/vc/v1/meetings/unsubscription"
@@ -110,5 +113,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 event",
Description: "Triggered when the bot observes activity in a meeting; keeps the raw bot payload and extracts stable activity fields",
EventType: eventTypeBotMeetingEvent,
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCBotEventOutput{})},
},
Process: processVCBotMeetingEvent,
AuthTypes: []string{"bot"},
RequiredConsoleEvents: []string{eventTypeBotMeetingEvent},
},
{
Key: eventTypeBotMeetingEnded,
DisplayName: "Bot meeting ended",
Description: "Triggered when a meeting observed by the bot has ended; distinct from user participant or open meeting resource events",
EventType: eventTypeBotMeetingEnded,
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCBotEventOutput{})},
},
Process: processVCBotMeetingEnded,
AuthTypes: []string{"bot"},
RequiredConsoleEvents: []string{eventTypeBotMeetingEnded},
},
}
}

View File

@@ -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 2 VC EventKeys (`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_event_v1`, `vc.bot.meeting_ended_v1`) + raw_event/stable field reference + post emotion guidance |
| Minutes | [`references/lark-event-minutes.md`](references/lark-event-minutes.md) | Catalog of 1 Minutes EventKey (`minutes.minute.generated_v1`) + field reference + source type semantics (meeting only) |
| Whiteboard | [`references/lark-event-whiteboard.md`](references/lark-event-whiteboard.md) | Catalog of 1 Board EventKey (`board.whiteboard.updated_v1`) + per-whiteboard subscription model (requires `-p whiteboard_id=<token>`) + payload field reference (whiteboard_id / operator_ids triple-id) |

View File

@@ -2,14 +2,19 @@
> **Prerequisite:** Read [`../SKILL.md`](../SKILL.md) first for the `event consume` essentials (commands, subprocess contract, jq usage).
## Key catalog (2)
## Key catalog
| EventKey | Purpose |
|---|---|
| `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_event_v1` | The bot observes meeting activity |
| `vc.bot.meeting_ended_v1` | A meeting observed by the bot has ended |
Both keys use a **Custom schema** (flat output) and carry a **PreConsume hook** that auto-subscribes / unsubscribes via OAPI on first / last consumer. Both 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
@@ -17,6 +22,9 @@ Both keys use a **Custom schema** (flat output) and carry a **PreConsume hook**
|---|---|---|
| `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_event_v1` | App event subscription in the Developer Console | bot |
| `vc.bot.meeting_ended_v1` | App event subscription in the Developer Console | bot |
---
@@ -92,3 +100,60 @@ 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_event_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_event_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 when present in the payload |
| `activity_event_type` | string | Meeting activity subtype when present |
| `chat_emoji_types` | string[] | Feishu post emotion `emoji_type` values extracted from `vc.bot.meeting_event_v1` payloads |
| `raw_event` | object | Original bot event payload; authoritative for fields not normalized by `lark-cli` |
Malformed or evolving payloads are not over-normalized. If a payload cannot be parsed, `event consume` passes the raw payload through; if a field is not recognized, read `raw_event`.
### Post emotion forwarding
`lark-cli event consume` does not send IM messages automatically. When `vc.bot.meeting_event_v1` exposes `chat_emoji_types`, an agent can explicitly forward the emotion by sending a Feishu `post` message whose content uses `tag: "emotion"` and `emoji_type` from `chat_emoji_types`.
```bash
lark-cli im +messages-send \
--chat-id <chat_id> \
--msg-type post \
--content '{
"zh_cn": {
"title": "Meeting reaction",
"content": [[
{"tag": "text", "text": "Reaction: "},
{"tag": "emotion", "emoji_type": "JIAYI"}
]]
}
}' \
--as bot
```
Use the exact key from `.chat_emoji_types[]` as `emoji_type`; do not convert it to a system emoji or synthesize another value.