From 31bc87a2cc4a52602f236942cabfe93755c9ec7f Mon Sep 17 00:00:00 2001 From: calendar-assistant Date: Wed, 10 Jun 2026 11:42:12 +0800 Subject: [PATCH] feat(vc): add recording event support (#1369) --- events/vc/recording_ended.go | 84 ++++ events/vc/recording_started.go | 84 ++++ events/vc/recording_test.go | 468 ++++++++++++++++++++ events/vc/recording_transcript_generated.go | 163 +++++++ events/vc/register.go | 65 ++- 5 files changed, 858 insertions(+), 6 deletions(-) create mode 100644 events/vc/recording_ended.go create mode 100644 events/vc/recording_started.go create mode 100644 events/vc/recording_test.go create mode 100644 events/vc/recording_transcript_generated.go diff --git a/events/vc/recording_ended.go b/events/vc/recording_ended.go new file mode 100644 index 00000000..bc0a4e3c --- /dev/null +++ b/events/vc/recording_ended.go @@ -0,0 +1,84 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package vc + +import ( + "context" + "encoding/json" + "strconv" + "time" + + "github.com/larksuite/cli/internal/event" +) + +// VCRecordingEndedOutput is the flattened shape for vc.recording.recording_ended_v1. +type VCRecordingEndedOutput struct { + Type string `json:"type" desc:"Event type; always vc.recording.recording_ended_v1"` + EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"` + EventTime string `json:"event_time,omitempty" desc:"Time when the recording ended and uploaded successfully, in RFC3339 / ISO 8601 with the current system timezone"` + UniqueKey string `json:"unique_key,omitempty" desc:"Unique key generated for one recording_bean recording session"` + Source string `json:"source,omitempty" desc:"Recording source; always recording_bean"` +} + +type recordingEndedEnvelope struct { + Header struct { + EventID string `json:"event_id"` + EventType string `json:"event_type"` + CreateTime string `json:"create_time"` + } `json:"header"` + Event recordingEndedEvent `json:"event"` +} + +type recordingEndedEvent struct { + UniqueKey string `json:"unique_key"` + Source string `json:"source"` +} + +func processVCRecordingEnded(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) { + envelope, ok := parseRecordingEndedEnvelope(raw) + if !ok { + return raw.Payload, nil + } + if !isRecordingEndedBeanEvent(envelope) { + return nil, nil + } + out := &VCRecordingEndedOutput{ + Type: recordingEndedEventType(envelope, raw), + EventID: envelope.Header.EventID, + EventTime: recordingEndedEventTime(envelope.Header.CreateTime), + UniqueKey: envelope.Event.UniqueKey, + Source: envelope.Event.Source, + } + return json.Marshal(out) +} + +func parseRecordingEndedEnvelope(raw *event.RawEvent) (*recordingEndedEnvelope, bool) { + var envelope recordingEndedEnvelope + if err := json.Unmarshal(raw.Payload, &envelope); err != nil { + return nil, false + } + return &envelope, true +} + +func isRecordingEndedBeanEvent(envelope *recordingEndedEnvelope) bool { + return envelope != nil && envelope.Event.Source == "recording_bean" +} + +func recordingEndedEventType(envelope *recordingEndedEnvelope, raw *event.RawEvent) string { + if envelope != nil && envelope.Header.EventType != "" { + return envelope.Header.EventType + } + return raw.EventType +} + +func recordingEndedEventTime(raw string) string { + if raw == "" { + return "" + } + millis, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return "" + } + return time.UnixMilli(millis).Local().Format(time.RFC3339) +} diff --git a/events/vc/recording_started.go b/events/vc/recording_started.go new file mode 100644 index 00000000..00d51caf --- /dev/null +++ b/events/vc/recording_started.go @@ -0,0 +1,84 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package vc + +import ( + "context" + "encoding/json" + "strconv" + "time" + + "github.com/larksuite/cli/internal/event" +) + +// VCRecordingStartedOutput is the flattened shape for vc.recording.recording_started_v1. +type VCRecordingStartedOutput struct { + Type string `json:"type" desc:"Event type; always vc.recording.recording_started_v1"` + EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"` + EventTime string `json:"event_time,omitempty" desc:"Recording start time in RFC3339 / ISO 8601 with the current system timezone"` + UniqueKey string `json:"unique_key,omitempty" desc:"Unique key generated for one recording_bean recording session"` + Source string `json:"source,omitempty" desc:"Recording source; always recording_bean"` +} + +type recordingStartedEnvelope struct { + Header struct { + EventID string `json:"event_id"` + EventType string `json:"event_type"` + CreateTime string `json:"create_time"` + } `json:"header"` + Event recordingStartedEvent `json:"event"` +} + +type recordingStartedEvent struct { + UniqueKey string `json:"unique_key"` + Source string `json:"source"` +} + +func processVCRecordingStarted(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) { + envelope, ok := parseRecordingStartedEnvelope(raw) + if !ok { + return raw.Payload, nil + } + if !isRecordingStartedBeanEvent(envelope) { + return nil, nil + } + out := &VCRecordingStartedOutput{ + Type: recordingStartedEventType(envelope, raw), + EventID: envelope.Header.EventID, + EventTime: recordingStartedEventTime(envelope.Header.CreateTime), + UniqueKey: envelope.Event.UniqueKey, + Source: envelope.Event.Source, + } + return json.Marshal(out) +} + +func parseRecordingStartedEnvelope(raw *event.RawEvent) (*recordingStartedEnvelope, bool) { + var envelope recordingStartedEnvelope + if err := json.Unmarshal(raw.Payload, &envelope); err != nil { + return nil, false + } + return &envelope, true +} + +func isRecordingStartedBeanEvent(envelope *recordingStartedEnvelope) bool { + return envelope != nil && envelope.Event.Source == "recording_bean" +} + +func recordingStartedEventType(envelope *recordingStartedEnvelope, raw *event.RawEvent) string { + if envelope != nil && envelope.Header.EventType != "" { + return envelope.Header.EventType + } + return raw.EventType +} + +func recordingStartedEventTime(raw string) string { + if raw == "" { + return "" + } + millis, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return "" + } + return time.UnixMilli(millis).Local().Format(time.RFC3339) +} diff --git a/events/vc/recording_test.go b/events/vc/recording_test.go new file mode 100644 index 00000000..89ba2448 --- /dev/null +++ b/events/vc/recording_test.go @@ -0,0 +1,468 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package vc + +import ( + "context" + "encoding/json" + "reflect" + "strings" + "testing" + "time" + + "github.com/larksuite/cli/internal/event" +) + +func TestVCKeys_RecordingEventsRegistered(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + for _, tc := range []struct { + eventType string + }{ + {eventTypeRecordingStarted}, + {eventTypeRecordingTranscriptGenerated}, + {eventTypeRecordingEnded}, + } { + t.Run(tc.eventType, func(t *testing.T) { + def, ok := event.Lookup(tc.eventType) + if !ok { + t.Fatalf("%s should be registered via Keys()", tc.eventType) + } + if def.Schema.Custom == nil { + t.Error("Processed key must set Schema.Custom") + } + if def.Schema.Native != nil { + t.Error("Processed key must not set Schema.Native") + } + if def.Process == nil { + t.Error("Process must not be nil for processed key") + } + if def.PreConsume == nil { + t.Error("PreConsume must not be nil for processed key") + } + if len(def.Scopes) != 1 || def.Scopes[0] != "vc:recording:read" { + t.Errorf("Scopes = %v", def.Scopes) + } + if len(def.AuthTypes) != 1 || def.AuthTypes[0] != "user" { + t.Errorf("AuthTypes = %v", def.AuthTypes) + } + if len(def.RequiredConsoleEvents) != 1 || def.RequiredConsoleEvents[0] != tc.eventType { + t.Errorf("RequiredConsoleEvents = %v", def.RequiredConsoleEvents) + } + if !strings.Contains(def.Description, "recording_bean") { + t.Errorf("Description should document recording_bean source, got %q", def.Description) + } + if !strings.Contains(def.Description, "connected to Feishu software") { + t.Errorf("Description should document Feishu software connection requirement, got %q", def.Description) + } + if strings.Contains(def.Description, "future") || strings.Contains(def.Description, "software_recording") { + t.Errorf("Description should not mention future sources, got %q", def.Description) + } + if tc.eventType == eventTypeRecordingEnded && (strings.Contains(def.Description, "object_type") || strings.Contains(def.Description, "object_id")) { + t.Errorf("ended Description should not document object metadata, got %q", def.Description) + } + wantSchemaType := reflect.TypeOf(VCRecordingStartedOutput{}) + switch tc.eventType { + case eventTypeRecordingTranscriptGenerated: + wantSchemaType = reflect.TypeOf(VCRecordingTranscriptGeneratedOutput{}) + case eventTypeRecordingEnded: + wantSchemaType = reflect.TypeOf(VCRecordingEndedOutput{}) + } + if def.Schema.Custom.Type != wantSchemaType { + t.Errorf("Custom schema Type = %v, want %v", def.Schema.Custom.Type, wantSchemaType) + } + }) + } +} + +func TestProcessVCRecordingStarted(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + out := runRecordingProcess[VCRecordingStartedOutput](t, eventTypeRecordingStarted, processVCRecordingStarted, `{ + "schema": "2.0", + "header": { + "event_id": "ev_rec_start_001", + "event_type": "vc.recording.recording_started_v1", + "create_time": "1761782400000" + }, + "event": { + "unique_key": "recording_001", + "source": "recording_bean" + } + }`) + + if out.Type != eventTypeRecordingStarted { + t.Errorf("Type = %q", out.Type) + } + if out.EventID != "ev_rec_start_001" || out.EventTime != recordingTestEventTime(1761782400000) { + t.Errorf("EventID/EventTime = %q/%q", out.EventID, out.EventTime) + } + if out.UniqueKey != "recording_001" || out.Source != "recording_bean" { + t.Errorf("UniqueKey/Source = %q/%q", out.UniqueKey, out.Source) + } +} + +func TestProcessVCRecordingTranscriptGenerated(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + got := runRecordingProcessRaw(t, eventTypeRecordingTranscriptGenerated, processVCRecordingTranscriptGenerated, `{ + "schema": "2.0", + "header": { + "event_id": "ev_rec_transcript_001", + "event_type": "vc.recording.recording_transcript_generated_v1", + "create_time": "1761782400100" + }, + "event": { + "unique_key": "recording_001", + "source": "recording_bean", + "transcript_items": [ + { + "speaker": { + "id": { + "open_id": "ou_0f8bf7acdf2ae69553ecbdbfbbd10a53", + "union_id": "on_bc03f16d781bff4178a5d11e48eb1867", + "user_id": null + }, + "user_type": 100, + "user_role": 1, + "user_name": "Alice" + }, + "text": "hello world", + "language": "en_us", + "start_time_ms": "1761782399000", + "end_time_ms": "1761782400000", + "sentence_id": "987654321" + }, + { + "speaker": { + "user_name": "Bob" + }, + "text": "second sentence", + "language": "en_us", + "start_time_ms": "1761782401000", + "end_time_ms": "1761782402000", + "sentence_id": "987654322" + } + ] + } + }`) + if got == nil { + t.Fatal("Process output is nil") + } + var out VCRecordingTranscriptGeneratedOutput + if err := json.Unmarshal(got, &out); err != nil { + t.Fatalf("Process output is not valid JSON: %v\nraw=%s", err, string(got)) + } + + if out.Type != eventTypeRecordingTranscriptGenerated { + t.Errorf("Type = %q", out.Type) + } + if out.UniqueKey != "recording_001" || out.Source != "recording_bean" { + t.Errorf("UniqueKey/Source = %q/%q", out.UniqueKey, out.Source) + } + if out.EventTime != recordingTestEventTime(1761782400100) { + t.Errorf("EventTime = %q", out.EventTime) + } + if len(out.TranscriptItems) != 2 { + t.Fatalf("TranscriptItems len = %d, want 2", len(out.TranscriptItems)) + } + item := out.TranscriptItems[0] + if item.SpeakerName != "Alice" || item.Text != "hello world" { + t.Errorf("Transcript speaker/text = %q/%q", item.SpeakerName, item.Text) + } + if item.StartTime != recordingTestEventTime(1761782399000) || item.EndTime != recordingTestEventTime(1761782400000) { + t.Errorf("Transcript timing = %q/%q", item.StartTime, item.EndTime) + } + if item.SentenceID != "987654321" { + t.Errorf("SentenceID = %q, want 987654321", item.SentenceID) + } + if out.TranscriptItems[1].SpeakerName != "Bob" || out.TranscriptItems[1].SentenceID != "987654322" { + t.Errorf("second transcript item = %+v", out.TranscriptItems[1]) + } + itemJSON, err := json.Marshal(item) + if err != nil { + t.Fatalf("marshal transcript item: %v", err) + } + var itemFields map[string]any + if err := json.Unmarshal(itemJSON, &itemFields); err != nil { + t.Fatalf("unmarshal transcript item JSON: %v", err) + } + wantItemFields := map[string]bool{ + "speaker_name": true, + "text": true, + "start_time": true, + "end_time": true, + "sentence_id": true, + } + for gotField := range itemFields { + if !wantItemFields[gotField] { + t.Errorf("Transcript item should not contain field %q, got %s", gotField, string(itemJSON)) + } + } + for wantField := range wantItemFields { + if _, ok := itemFields[wantField]; !ok { + t.Errorf("Transcript item missing field %q, got %s", wantField, string(itemJSON)) + } + } + for _, unexpected := range []string{ + `"seq_id"`, + `"speaker"`, + `"user_open_id"`, + `"user_type"`, + `"user_role"`, + `"language"`, + `"start_time_ms"`, + `"end_time_ms"`, + `"sequence_id"`, + `"transcript_item"`, + } { + if strings.Contains(string(got), unexpected) { + t.Errorf("Transcript output should not contain %s, got %s", unexpected, string(got)) + } + } + if !strings.Contains(string(got), `"sentence_id":"987654321"`) { + t.Errorf("Transcript output should contain sentence_id, got %s", string(got)) + } +} + +func TestProcessVCRecordingEnded(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + out := runRecordingProcess[VCRecordingEndedOutput](t, eventTypeRecordingEnded, processVCRecordingEnded, `{ + "schema": "2.0", + "header": { + "event_id": "ev_rec_end_001", + "event_type": "vc.recording.recording_ended_v1", + "create_time": "1761782400200" + }, + "event": { + "unique_key": "recording_001", + "source": "recording_bean", + "object_type": "minutes", + "object_id": "minute_token_001" + } + }`) + + if out.Type != eventTypeRecordingEnded { + t.Errorf("Type = %q", out.Type) + } + if out.UniqueKey != "recording_001" || out.Source != "recording_bean" { + t.Errorf("UniqueKey/Source = %q/%q", out.UniqueKey, out.Source) + } + if out.EventTime != recordingTestEventTime(1761782400200) { + t.Errorf("EventTime = %q", out.EventTime) + } +} + +func TestProcessVCRecordingEnded_DropsObjectMetadata(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + got := runRecordingProcessRaw(t, eventTypeRecordingEnded, processVCRecordingEnded, `{ + "schema": "2.0", + "header": { + "event_id": "ev_rec_end_001", + "event_type": "vc.recording.recording_ended_v1", + "create_time": "1761782400200" + }, + "event": { + "unique_key": "recording_001", + "source": "recording_bean", + "object_type": "minutes", + "object_id": "minute_token_001" + } + }`) + + if strings.Contains(string(got), "object_type") || strings.Contains(string(got), "object_id") { + t.Fatalf("ended output should drop object metadata, got %s", string(got)) + } +} + +func TestProcessVCRecording_DropsTimestampField(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + got := runRecordingProcessRaw(t, eventTypeRecordingStarted, processVCRecordingStarted, `{ + "schema": "2.0", + "header": { + "event_id": "ev_rec_start_001", + "event_type": "vc.recording.recording_started_v1", + "create_time": "1761782400000" + }, + "event": { + "unique_key": "recording_001", + "source": "recording_bean" + } + }`) + + if strings.Contains(string(got), `"timestamp"`) { + t.Fatalf("recording output should use event_time instead of timestamp, got %s", string(got)) + } + if !strings.Contains(string(got), `"event_time":"`+recordingTestEventTime(1761782400000)+`"`) { + t.Fatalf("recording output should include ISO 8601 event_time, got %s", string(got)) + } +} + +func TestProcessVCRecording_NonRecordingBeanFiltered(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + for _, tc := range []struct { + name string + eventType string + process event.ProcessFunc + payload string + }{ + { + name: "started", + eventType: eventTypeRecordingStarted, + process: processVCRecordingStarted, + payload: `{ + "schema": "2.0", + "header": {"event_id": "ev_rec_start_001", "event_type": "vc.recording.recording_started_v1"}, + "event": {"unique_key": "recording_001", "source": "software_recording"} + }`, + }, + { + name: "transcript", + eventType: eventTypeRecordingTranscriptGenerated, + process: processVCRecordingTranscriptGenerated, + payload: `{ + "schema": "2.0", + "header": {"event_id": "ev_rec_transcript_001", "event_type": "vc.recording.recording_transcript_generated_v1"}, + "event": {"unique_key": "recording_001", "source": "software_recording", "transcript_items": []} + }`, + }, + { + name: "ended", + eventType: eventTypeRecordingEnded, + process: processVCRecordingEnded, + payload: `{ + "schema": "2.0", + "header": {"event_id": "ev_rec_end_001", "event_type": "vc.recording.recording_ended_v1"}, + "event": {"unique_key": "recording_001", "source": "software_recording"} + }`, + }, + } { + t.Run(tc.name, func(t *testing.T) { + got := runRecordingProcessRaw(t, tc.eventType, tc.process, tc.payload) + if got != nil { + t.Fatalf("non-recording_bean event should be filtered, got %s", string(got)) + } + }) + } +} + +func TestProcessVCRecording_MalformedPayloadPassthrough(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + for _, tc := range []struct { + name string + eventType string + process event.ProcessFunc + }{ + {name: "started", eventType: eventTypeRecordingStarted, process: processVCRecordingStarted}, + {name: "transcript", eventType: eventTypeRecordingTranscriptGenerated, process: processVCRecordingTranscriptGenerated}, + {name: "ended", eventType: eventTypeRecordingEnded, process: processVCRecordingEnded}, + } { + t.Run(tc.name, func(t *testing.T) { + raw := &event.RawEvent{ + EventType: tc.eventType, + Payload: json.RawMessage(`not json`), + Timestamp: time.Now(), + } + got, err := tc.process(context.Background(), nil, raw, nil) + if err != nil { + t.Fatalf("Process should swallow parse errors, got %v", err) + } + if string(got) != "not json" { + t.Errorf("malformed fallback output = %q, want original bytes", string(got)) + } + }) + } +} + +func TestVCRecording_PreConsumeSubscriptionLifecycle(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + for _, tc := range []struct { + eventType string + }{ + {eventTypeRecordingStarted}, + {eventTypeRecordingTranscriptGenerated}, + {eventTypeRecordingEnded}, + } { + t.Run(tc.eventType, func(t *testing.T) { + def, ok := event.Lookup(tc.eventType) + if !ok { + t.Fatalf("%s should be registered via Keys()", tc.eventType) + } + + type call struct { + method string + path string + body any + } + var calls []call + rt := &stubAPIClient{ + callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) { + calls = append(calls, call{method: method, path: path, body: body}) + return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil + }, + } + + cleanup, err := def.PreConsume(context.Background(), rt, nil) + if err != nil { + t.Fatalf("PreConsume error: %v", err) + } + if cleanup == nil { + t.Fatal("cleanup must not be nil") + } + if len(calls) != 1 { + t.Fatalf("calls after subscribe = %d, want 1", len(calls)) + } + if calls[0].method != "POST" || calls[0].path != pathRecordingSubscribe { + t.Fatalf("subscribe call = %+v", calls[0]) + } + assertSubscriptionRequest(t, calls[0].body, tc.eventType) + + cleanup() + if len(calls) != 2 { + t.Fatalf("calls after cleanup = %d, want 2", len(calls)) + } + if calls[1].method != "POST" || calls[1].path != pathRecordingUnsubscribe { + t.Fatalf("unsubscribe call = %+v", calls[1]) + } + assertSubscriptionRequest(t, calls[1].body, tc.eventType) + }) + } +} + +func runRecordingProcess[T any](t *testing.T, eventType string, process event.ProcessFunc, payload string) T { + t.Helper() + got := runRecordingProcessRaw(t, eventType, process, payload) + if got == nil { + t.Fatal("Process output is nil") + } + var out T + if err := json.Unmarshal(got, &out); err != nil { + t.Fatalf("Process output is not valid JSON: %v\nraw=%s", err, string(got)) + } + return out +} + +func runRecordingProcessRaw(t *testing.T, eventType string, process event.ProcessFunc, payload string) json.RawMessage { + t.Helper() + raw := &event.RawEvent{ + EventType: eventType, + Payload: json.RawMessage(payload), + Timestamp: time.Now(), + } + got, err := process(context.Background(), nil, raw, nil) + if err != nil { + t.Fatalf("Process error: %v", err) + } + return got +} + +func recordingTestEventTime(millis int64) string { + return time.UnixMilli(millis).Local().Format(time.RFC3339) +} diff --git a/events/vc/recording_transcript_generated.go b/events/vc/recording_transcript_generated.go new file mode 100644 index 00000000..fe609bbf --- /dev/null +++ b/events/vc/recording_transcript_generated.go @@ -0,0 +1,163 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package vc + +import ( + "context" + "encoding/json" + "strconv" + "time" + + "github.com/larksuite/cli/internal/event" +) + +// VCRecordingTranscriptItemOutput is one flattened transcript item for recording events. +type VCRecordingTranscriptItemOutput struct { + SpeakerName string `json:"speaker_name,omitempty" desc:"Speaker display name"` + Text string `json:"text,omitempty" desc:"Transcript text"` + StartTime string `json:"start_time,omitempty" desc:"Transcript item start time in RFC3339 / ISO 8601 with the current system timezone"` + EndTime string `json:"end_time,omitempty" desc:"Transcript item end time in RFC3339 / ISO 8601 with the current system timezone"` + SentenceID string `json:"sentence_id,omitempty" desc:"Transcript sentence ID"` +} + +// VCRecordingTranscriptGeneratedOutput is the flattened shape for vc.recording.recording_transcript_generated_v1. +type VCRecordingTranscriptGeneratedOutput struct { + Type string `json:"type" desc:"Event type; always vc.recording.recording_transcript_generated_v1"` + EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"` + EventTime string `json:"event_time,omitempty" desc:"Time when this batch of transcript items was generated, in RFC3339 / ISO 8601 with the current system timezone"` + UniqueKey string `json:"unique_key,omitempty" desc:"Unique key generated for one recording_bean recording session"` + Source string `json:"source,omitempty" desc:"Recording source; always recording_bean"` + TranscriptItems []VCRecordingTranscriptItemOutput `json:"transcript_items,omitempty" desc:"Generated transcript items"` +} + +type recordingTranscriptGeneratedEnvelope struct { + Header struct { + EventID string `json:"event_id"` + EventType string `json:"event_type"` + CreateTime string `json:"create_time"` + } `json:"header"` + Event recordingTranscriptGeneratedEvent `json:"event"` +} + +type recordingTranscriptGeneratedEvent struct { + UniqueKey string `json:"unique_key"` + Source string `json:"source"` + TranscriptItems []recordingTranscriptGeneratedItemIn `json:"transcript_items"` +} + +type recordingTranscriptGeneratedItemIn struct { + Speaker *recordingTranscriptGeneratedSpeakerIn `json:"speaker"` + Text string `json:"text"` + StartTimeMs recordingTranscriptGeneratedString `json:"start_time_ms"` + EndTimeMs recordingTranscriptGeneratedString `json:"end_time_ms"` + SentenceID string `json:"sentence_id"` +} + +type recordingTranscriptGeneratedSpeakerIn struct { + UserName string `json:"user_name"` +} + +type recordingTranscriptGeneratedString string + +func processVCRecordingTranscriptGenerated(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) { + envelope, ok := parseRecordingTranscriptGeneratedEnvelope(raw) + if !ok { + return raw.Payload, nil + } + if !isRecordingTranscriptGeneratedBeanEvent(envelope) { + return nil, nil + } + out := &VCRecordingTranscriptGeneratedOutput{ + Type: recordingTranscriptGeneratedEventType(envelope, raw), + EventID: envelope.Header.EventID, + EventTime: recordingTranscriptGeneratedEventTime(envelope.Header.CreateTime), + UniqueKey: envelope.Event.UniqueKey, + Source: envelope.Event.Source, + TranscriptItems: recordingTranscriptItems(envelope.Event.TranscriptItems), + } + return json.Marshal(out) +} + +func parseRecordingTranscriptGeneratedEnvelope(raw *event.RawEvent) (*recordingTranscriptGeneratedEnvelope, bool) { + var envelope recordingTranscriptGeneratedEnvelope + if err := json.Unmarshal(raw.Payload, &envelope); err != nil { + return nil, false + } + return &envelope, true +} + +func isRecordingTranscriptGeneratedBeanEvent(envelope *recordingTranscriptGeneratedEnvelope) bool { + return envelope != nil && envelope.Event.Source == "recording_bean" +} + +func recordingTranscriptGeneratedEventType(envelope *recordingTranscriptGeneratedEnvelope, raw *event.RawEvent) string { + if envelope != nil && envelope.Header.EventType != "" { + return envelope.Header.EventType + } + return raw.EventType +} + +func recordingTranscriptGeneratedEventTime(raw string) string { + return recordingTranscriptGeneratedMillisToLocalRFC3339(raw) +} + +func recordingTranscriptGeneratedMillisToLocalRFC3339(raw string) string { + if raw == "" { + return "" + } + millis, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return "" + } + return time.UnixMilli(millis).Local().Format(time.RFC3339) +} + +func recordingTranscriptItems(items []recordingTranscriptGeneratedItemIn) []VCRecordingTranscriptItemOutput { + if len(items) == 0 { + return nil + } + out := make([]VCRecordingTranscriptItemOutput, 0, len(items)) + for _, item := range items { + out = append(out, recordingTranscriptItem(item)) + } + return out +} + +func recordingTranscriptItem(item recordingTranscriptGeneratedItemIn) VCRecordingTranscriptItemOutput { + return VCRecordingTranscriptItemOutput{ + SpeakerName: recordingSpeakerName(item.Speaker), + Text: item.Text, + StartTime: recordingTranscriptGeneratedMillisToLocalRFC3339(item.StartTimeMs.String()), + EndTime: recordingTranscriptGeneratedMillisToLocalRFC3339(item.EndTimeMs.String()), + SentenceID: item.SentenceID, + } +} + +func recordingSpeakerName(speaker *recordingTranscriptGeneratedSpeakerIn) string { + if speaker == nil { + return "" + } + return speaker.UserName +} + +func (s *recordingTranscriptGeneratedString) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + return nil + } + var str string + if err := json.Unmarshal(data, &str); err == nil { + *s = recordingTranscriptGeneratedString(str) + return nil + } + var num json.Number + if err := json.Unmarshal(data, &num); err != nil { + return err + } + *s = recordingTranscriptGeneratedString(num.String()) + return nil +} + +func (s recordingTranscriptGeneratedString) String() string { + return string(s) +} diff --git a/events/vc/register.go b/events/vc/register.go index d392daf2..bfee1241 100644 --- a/events/vc/register.go +++ b/events/vc/register.go @@ -11,13 +11,18 @@ import ( ) const ( - eventTypeMeetingEnded = "vc.meeting.participant_meeting_ended_v1" - eventTypeNoteGenerated = "vc.note.generated_v1" + eventTypeMeetingEnded = "vc.meeting.participant_meeting_ended_v1" + eventTypeNoteGenerated = "vc.note.generated_v1" + eventTypeRecordingStarted = "vc.recording.recording_started_v1" + eventTypeRecordingTranscriptGenerated = "vc.recording.recording_transcript_generated_v1" + eventTypeRecordingEnded = "vc.recording.recording_ended_v1" - pathMeetingSubscribe = "/open-apis/vc/v1/meetings/subscription" - pathMeetingUnsubscribe = "/open-apis/vc/v1/meetings/unsubscription" - pathNoteSubscribe = "/open-apis/vc/v1/notes/subscription" - pathNoteUnsubscribe = "/open-apis/vc/v1/notes/unsubscription" + pathMeetingSubscribe = "/open-apis/vc/v1/meetings/subscription" + pathMeetingUnsubscribe = "/open-apis/vc/v1/meetings/unsubscription" + pathNoteSubscribe = "/open-apis/vc/v1/notes/subscription" + pathNoteUnsubscribe = "/open-apis/vc/v1/notes/unsubscription" + pathRecordingSubscribe = "/open-apis/vc/v1/recordings/subscription" + pathRecordingUnsubscribe = "/open-apis/vc/v1/recordings/unsubscription" pathNoteDetailFmt = "/open-apis/vc/v1/notes/%s" ) @@ -57,5 +62,53 @@ func Keys() []event.KeyDefinition { }, RequiredConsoleEvents: []string{eventTypeNoteGenerated}, }, + { + Key: eventTypeRecordingStarted, + DisplayName: "Recording started", + Description: "Triggered when a recording_bean recording starts; only generated when connected to Feishu software.", + EventType: eventTypeRecordingStarted, + Schema: event.SchemaDef{ + Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCRecordingStartedOutput{})}, + }, + Process: processVCRecordingStarted, + PreConsume: subscriptionPreConsume(eventTypeRecordingStarted, pathRecordingSubscribe, pathRecordingUnsubscribe), + Scopes: []string{"vc:recording:read"}, + AuthTypes: []string{ + "user", + }, + RequiredConsoleEvents: []string{eventTypeRecordingStarted}, + }, + { + Key: eventTypeRecordingTranscriptGenerated, + DisplayName: "Recording transcript generated", + Description: "Triggered when recording_bean transcript items are generated; only generated when connected to Feishu software.", + EventType: eventTypeRecordingTranscriptGenerated, + Schema: event.SchemaDef{ + Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCRecordingTranscriptGeneratedOutput{})}, + }, + Process: processVCRecordingTranscriptGenerated, + PreConsume: subscriptionPreConsume(eventTypeRecordingTranscriptGenerated, pathRecordingSubscribe, pathRecordingUnsubscribe), + Scopes: []string{"vc:recording:read"}, + AuthTypes: []string{ + "user", + }, + RequiredConsoleEvents: []string{eventTypeRecordingTranscriptGenerated}, + }, + { + Key: eventTypeRecordingEnded, + DisplayName: "Recording ended", + Description: "Triggered when a recording_bean recording ends and uploads successfully; only generated when connected to Feishu software.", + EventType: eventTypeRecordingEnded, + Schema: event.SchemaDef{ + Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCRecordingEndedOutput{})}, + }, + Process: processVCRecordingEnded, + PreConsume: subscriptionPreConsume(eventTypeRecordingEnded, pathRecordingSubscribe, pathRecordingUnsubscribe), + Scopes: []string{"vc:recording:read"}, + AuthTypes: []string{ + "user", + }, + RequiredConsoleEvents: []string{eventTypeRecordingEnded}, + }, } }