From b2ba6b229631dc26beb944328a5768d475de5553 Mon Sep 17 00:00:00 2001 From: leave330 Date: Fri, 31 Jul 2026 22:46:13 +0800 Subject: [PATCH 01/61] test(event): freeze the registered EventKey baseline --- events/expected_keys_test.go | 63 ++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 events/expected_keys_test.go diff --git a/events/expected_keys_test.go b/events/expected_keys_test.go new file mode 100644 index 0000000000..6c4b940d4c --- /dev/null +++ b/events/expected_keys_test.go @@ -0,0 +1,63 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package events_test + +import ( + "testing" + + event "github.com/larksuite/cli/internal/event" + + _ "github.com/larksuite/cli/events" +) + +// expectedKeys is the frozen catalog baseline. Adding, removing, or renaming +// an EventKey is a deliberate contract change: update this list in the same +// commit and call the change out in the changelog. +var expectedKeys = []string{ + "application.bot.menu_v6", + "approval.instance.status_changed_v4", + "approval.task.status_changed_v4", + "board.whiteboard.updated_v1", + "card.action.trigger", + "im.chat.disbanded_v1", + "im.chat.member.bot.added_v1", + "im.chat.member.bot.deleted_v1", + "im.chat.member.user.added_v1", + "im.chat.member.user.deleted_v1", + "im.chat.member.user.withdrawn_v1", + "im.chat.updated_v1", + "im.message.message_read_v1", + "im.message.reaction.created_v1", + "im.message.reaction.deleted_v1", + "im.message.receive_v1", + "minutes.minute.generated_v1", + "task.task.update_user_access_v2", + "vc.meeting.participant_meeting_ended_v1", + "vc.meeting.participant_meeting_joined_v1", + "vc.meeting.participant_meeting_started_v1", + "vc.note.generated_v1", + "vc.recording.recording_ended_v1", + "vc.recording.recording_started_v1", + "vc.recording.recording_transcript_generated_v1", +} + +func TestRegisteredKeys_MatchFrozenBaseline(t *testing.T) { + all := event.ListAll() + if len(all) == 0 { + t.Fatal("no EventKeys registered; the gate scanned nothing") + } + got := make(map[string]bool, len(all)) + for _, def := range all { + got[def.Key] = true + } + for _, want := range expectedKeys { + if !got[want] { + t.Errorf("expected EventKey missing from registry: %s", want) + } + delete(got, want) + } + for extra := range got { + t.Errorf("EventKey not in frozen baseline (update expectedKeys deliberately): %s", extra) + } +} From 9ec5981226dabbdffee445f9443bfed6fe8dbd08 Mon Sep 17 00:00:00 2001 From: leave330 Date: Fri, 31 Jul 2026 22:46:13 +0800 Subject: [PATCH 02/61] test(event): pin list and schema output with golden files --- cmd/event/golden_test.go | 81 + cmd/event/testdata/golden/list_json.golden | 3022 +++++++++++++++++ cmd/event/testdata/golden/list_text.golden | 42 + .../schema_board_whiteboard_json.golden | 126 + .../schema_board_whiteboard_text.golden | 89 + .../schema_card_action_trigger_json.golden | 104 + .../schema_card_action_trigger_text.golden | 92 + .../golden/schema_im_chat_updated_json.golden | 430 +++ .../golden/schema_im_chat_updated_text.golden | 337 ++ .../schema_im_message_receive_json.golden | 130 + .../schema_im_message_receive_text.golden | 119 + 11 files changed, 4572 insertions(+) create mode 100644 cmd/event/golden_test.go create mode 100644 cmd/event/testdata/golden/list_json.golden create mode 100644 cmd/event/testdata/golden/list_text.golden create mode 100644 cmd/event/testdata/golden/schema_board_whiteboard_json.golden create mode 100644 cmd/event/testdata/golden/schema_board_whiteboard_text.golden create mode 100644 cmd/event/testdata/golden/schema_card_action_trigger_json.golden create mode 100644 cmd/event/testdata/golden/schema_card_action_trigger_text.golden create mode 100644 cmd/event/testdata/golden/schema_im_chat_updated_json.golden create mode 100644 cmd/event/testdata/golden/schema_im_chat_updated_text.golden create mode 100644 cmd/event/testdata/golden/schema_im_message_receive_json.golden create mode 100644 cmd/event/testdata/golden/schema_im_message_receive_text.golden diff --git a/cmd/event/golden_test.go b/cmd/event/golden_test.go new file mode 100644 index 0000000000..e96e32c8ca --- /dev/null +++ b/cmd/event/golden_test.go @@ -0,0 +1,81 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package event + +import ( + "flag" + "os" + "path/filepath" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + + _ "github.com/larksuite/cli/events" +) + +var updateGolden = flag.Bool("update", false, "rewrite golden files instead of comparing") + +// goldenSchemaKeys picks one key per rendering path so every branch of the +// list/schema output stays pinned: a processed key with a flat custom schema, +// a native key with field overrides, a callback key with a single consumer, +// and a key with a required parameter plus a pre-consume hook. +var goldenSchemaKeys = map[string]string{ + "schema_im_message_receive": "im.message.receive_v1", + "schema_im_chat_updated": "im.chat.updated_v1", + "schema_card_action_trigger": "card.action.trigger", + "schema_board_whiteboard": "board.whiteboard.updated_v1", +} + +// The golden files pin stdout byte-for-byte. The output is deterministic: +// ListAll sorts by key, encoding/json sorts object keys, and nothing on the +// rendering path reads the clock or randomness. Regenerate with: +// +// go test ./cmd/event/ -run TestGolden -update +func TestGolden_ListOutput(t *testing.T) { + for name, asJSON := range map[string]bool{"list_text": false, "list_json": true} { + t.Run(name, func(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) + if err := runList(f, asJSON); err != nil { + t.Fatalf("runList: %v", err) + } + assertGolden(t, name, stdout.String()) + }) + } +} + +func TestGolden_SchemaOutput(t *testing.T) { + for name, key := range goldenSchemaKeys { + for suffix, asJSON := range map[string]bool{"_text": false, "_json": true} { + t.Run(name+suffix, func(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) + if err := runSchema(f, key, asJSON); err != nil { + t.Fatalf("runSchema(%s): %v", key, err) + } + assertGolden(t, name+suffix, stdout.String()) + }) + } + } +} + +func assertGolden(t *testing.T, name, got string) { + t.Helper() + path := filepath.Join("testdata", "golden", name+".golden") + if *updateGolden { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(got), 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(path) + if err != nil { + t.Fatalf("missing golden %s (regenerate with -update): %v", name, err) + } + if string(want) != got { + t.Errorf("output drifted from golden %s\n--- want\n%s\n--- got\n%s", name, want, got) + } +} diff --git a/cmd/event/testdata/golden/list_json.golden b/cmd/event/testdata/golden/list_json.golden new file mode 100644 index 0000000000..665c0204a2 --- /dev/null +++ b/cmd/event/testdata/golden/list_json.golden @@ -0,0 +1,3022 @@ +[ + { + "key": "application.bot.menu_v6", + "display_name": "Bot menu", + "description": "Triggered when a user clicks a custom bot menu item whose action is configured as a push event.", + "event_type": "application.bot.menu_v6", + "subscription_type": "event", + "schema": { + "custom": {} + }, + "auth_types": [ + "bot" + ], + "required_console_events": [ + "application.bot.menu_v6" + ], + "buffer_size": 100, + "workers": 1, + "resolved_output_schema": { + "type": "object", + "properties": { + "app_id": { + "type": "string", + "description": "Application ID from the event header" + }, + "event_id": { + "type": "string", + "description": "Globally unique event ID; safe for deduplication" + }, + "event_key": { + "type": "string", + "description": "Developer-defined bot menu event key" + }, + "menu_timestamp": { + "type": "string", + "description": "Menu click timestamp from the event body", + "format": "timestamp_ms" + }, + "operator_id": { + "type": "string", + "description": "Operator open_id; kept as a short alias of operator_open_id", + "format": "open_id" + }, + "operator_name": { + "type": "string", + "description": "Operator display name" + }, + "operator_open_id": { + "type": "string", + "description": "Operator open_id", + "format": "open_id" + }, + "operator_union_id": { + "type": "string", + "description": "Operator union_id", + "format": "union_id" + }, + "operator_user_id": { + "type": "string", + "description": "Operator user_id", + "format": "user_id" + }, + "tenant_key": { + "type": "string", + "description": "Tenant key from the event header" + }, + "timestamp": { + "type": "string", + "description": "Event delivery time (ms timestamp string); prefers header.create_time", + "format": "timestamp_ms" + }, + "type": { + "type": "string", + "description": "Event type; always application.bot.menu_v6" + } + } + } + }, + { + "key": "approval.instance.status_changed_v4", + "display_name": "Approval instance status changed", + "description": "Triggered after an approval instance status becomes visible to the requester or approval participants", + "event_type": "approval.instance.status_changed_v4", + "subscription_type": "event", + "params": [ + { + "name": "subscription_type", + "type": "multi", + "required": false, + "description": "Approval subscription relation type(s) to register for the current authorized user. Omit to register both involved and managed approval relations.", + "values": [ + { + "value": "INVOLVED_APPROVAL", + "desc": "Receive events where the current user is the approval requester or approver." + }, + { + "value": "MANAGED_APPROVAL", + "desc": "Receive events under approval definitions managed by the current user." + } + ] + } + ], + "schema": { + "custom": {} + }, + "scopes": [ + "approval:instance:read" + ], + "auth_types": [ + "user" + ], + "required_console_events": [ + "approval.instance.status_changed_v4" + ], + "buffer_size": 100, + "workers": 1, + "resolved_output_schema": { + "type": "object", + "properties": { + "approval_code": { + "type": "string", + "description": "Approval definition code; not a subscription dimension" + }, + "event_id": { + "type": "string", + "description": "Globally unique event ID; safe for deduplication" + }, + "external_id": { + "type": "string", + "description": "Third-party approval instance id; present only for third-party approvals" + }, + "instance_code": { + "type": "string", + "description": "Approval instance code" + }, + "operate_time": { + "type": "string", + "description": "Status change time in milliseconds", + "format": "timestamp_ms" + }, + "start_user": { + "type": "object", + "description": "Approval instance starter; omitted when unavailable", + "properties": { + "open_id": { + "type": "string", + "description": "User open_id; prefixed with ou_", + "format": "open_id" + }, + "union_id": { + "type": "string", + "description": "User union_id", + "format": "union_id" + }, + "user_id": { + "type": "string", + "description": "User id within the tenant", + "format": "user_id" + } + } + }, + "status": { + "type": "string", + "description": "Approval instance status", + "enum": [ + "PENDING", + "APPROVED", + "REJECTED", + "CANCELED", + "DELETED", + "REVERTED", + "OVERTIME_CLOSE", + "OVERTIME_RECOVER" + ] + }, + "timestamp": { + "type": "string", + "description": "Event delivery time (ms timestamp string); taken from header.create_time when present", + "format": "timestamp_ms" + }, + "type": { + "type": "string", + "description": "Event type; always approval.instance.status_changed_v4", + "enum": [ + "approval.instance.status_changed_v4" + ] + } + } + } + }, + { + "key": "approval.task.status_changed_v4", + "display_name": "Approval task status changed", + "description": "Triggered after an approval task status becomes visible to the requester or task approver", + "event_type": "approval.task.status_changed_v4", + "subscription_type": "event", + "params": [ + { + "name": "subscription_type", + "type": "multi", + "required": false, + "description": "Approval subscription relation type(s) to register for the current authorized user. Omit to register both involved and managed approval relations.", + "values": [ + { + "value": "INVOLVED_APPROVAL", + "desc": "Receive events where the current user is the approval requester or approver." + }, + { + "value": "MANAGED_APPROVAL", + "desc": "Receive events under approval definitions managed by the current user." + } + ] + } + ], + "schema": { + "custom": {} + }, + "scopes": [ + "approval:task:read" + ], + "auth_types": [ + "user" + ], + "required_console_events": [ + "approval.task.status_changed_v4" + ], + "buffer_size": 100, + "workers": 1, + "resolved_output_schema": { + "type": "object", + "properties": { + "approval_code": { + "type": "string", + "description": "Approval definition code; not a subscription dimension" + }, + "assigned_user": { + "type": "object", + "description": "Task assignee or operator user ids; omitted for automatic flows without an operator", + "properties": { + "open_id": { + "type": "string", + "description": "User open_id; prefixed with ou_", + "format": "open_id" + }, + "union_id": { + "type": "string", + "description": "User union_id", + "format": "union_id" + }, + "user_id": { + "type": "string", + "description": "User id within the tenant", + "format": "user_id" + } + } + }, + "event_id": { + "type": "string", + "description": "Globally unique event ID; safe for deduplication" + }, + "external_id": { + "type": "string", + "description": "Third-party approval external id; present only for third-party approvals" + }, + "instance_code": { + "type": "string", + "description": "Approval instance code" + }, + "operate_time": { + "type": "string", + "description": "Status change time in milliseconds", + "format": "timestamp_ms" + }, + "status": { + "type": "string", + "description": "Approval task status", + "enum": [ + "REVERTED", + "PENDING", + "APPROVED", + "REJECTED", + "TRANSFERRED", + "ROLLBACK", + "DONE", + "OVERTIME_CLOSE", + "OVERTIME_RECOVER" + ] + }, + "task_external_id": { + "type": "string", + "description": "Third-party approval task external id; present only when emitted by the upstream service" + }, + "task_id": { + "type": "string", + "description": "Approval task id" + }, + "timestamp": { + "type": "string", + "description": "Event delivery time (ms timestamp string); taken from header.create_time when present", + "format": "timestamp_ms" + }, + "type": { + "type": "string", + "description": "Event type; always approval.task.status_changed_v4", + "enum": [ + "approval.task.status_changed_v4" + ] + } + } + } + }, + { + "key": "board.whiteboard.updated_v1", + "display_name": "Whiteboard updated", + "description": "Pushed when the whiteboard content is updated.", + "event_type": "board.whiteboard.updated_v1", + "subscription_type": "event", + "params": [ + { + "name": "whiteboard_id", + "type": "string", + "required": true, + "description": "Whiteboard id to subscribe; subscription is per-whiteboard." + } + ], + "schema": { + "native": {}, + "field_overrides": { + "/event/operator_ids/*/open_id": { + "Description": "", + "Enum": null, + "Kind": "open_id" + }, + "/event/operator_ids/*/union_id": { + "Description": "", + "Enum": null, + "Kind": "union_id" + }, + "/event/operator_ids/*/user_id": { + "Description": "", + "Enum": null, + "Kind": "user_id" + }, + "/event/whiteboard_id": { + "Description": "whiteboard id to subscribe", + "Enum": null, + "Kind": "whiteboard_id" + } + } + }, + "scopes": [ + "board:whiteboard:node:read" + ], + "auth_types": [ + "user", + "bot" + ], + "required_console_events": [ + "board.whiteboard.updated_v1" + ], + "buffer_size": 100, + "workers": 1, + "resolved_output_schema": { + "description": "飞书事件", + "properties": { + "event": { + "properties": { + "operator_ids": { + "items": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "whiteboard_id": { + "description": "whiteboard id to subscribe", + "format": "whiteboard_id", + "type": "string" + } + }, + "type": "object" + }, + "header": { + "description": "事件头,所有事件结构一致", + "properties": { + "app_id": { + "description": "接收事件的应用 ID", + "type": "string" + }, + "create_time": { + "description": "事件创建时间,毫秒时间戳字符串", + "type": "string" + }, + "event_id": { + "description": "事件唯一 ID", + "type": "string" + }, + "event_type": { + "description": "事件类型,用于路由", + "type": "string" + }, + "tenant_key": { + "description": "租户唯一标识", + "type": "string" + }, + "token": { + "description": "回调校验 token", + "type": "string" + } + }, + "type": "object" + }, + "schema": { + "description": "飞书事件协议版本", + "enum": [ + "2.0" + ], + "type": "string" + } + }, + "type": "object" + } + }, + { + "key": "card.action.trigger", + "display_name": "Card action", + "description": "Triggered when a user interacts with an interactive card (button click, form submit, dropdown select, etc.). Output includes: token (valid 30 min, max 2 updates), action details (tag, value, name, form_value), and card_content (original card in userDSL text format, auto-fetched at consume time). To update the card: parse card_content to understand the current state, construct the new card JSON, then call `lark-cli api POST /open-apis/interactive/v1/card/update` with the token (see lark-im-card-action-reply.md).", + "event_type": "card.action.trigger", + "subscription_type": "callback", + "schema": { + "custom": {} + }, + "scopes": [ + "im:message:readonly" + ], + "auth_types": [ + "bot" + ], + "required_console_events": [ + "card.action.trigger" + ], + "buffer_size": 100, + "workers": 1, + "single_consumer": true, + "resolved_output_schema": { + "type": "object", + "properties": { + "action_name": { + "type": "string", + "description": "Element name attribute" + }, + "action_tag": { + "type": "string", + "description": "Triggered element type: button/select_static/input/checker/etc" + }, + "action_value": { + "type": "string", + "description": "Developer-defined action value as JSON string" + }, + "card_content": { + "type": "string", + "description": "Original card JSON content (body.content) auto-fetched via message get API at consume time using message_id; empty if message_id absent or fetch fails" + }, + "chat_id": { + "type": "string", + "description": "Chat ID", + "format": "chat_id" + }, + "checked": { + "type": "boolean", + "description": "Checkbox state (for checkbox elements)" + }, + "event_id": { + "type": "string", + "description": "Globally unique event ID" + }, + "form_value": { + "type": "string", + "description": "Form submission values as JSON string (only on form submit)" + }, + "host": { + "type": "string", + "description": "Host type: im_message / im_top_notice" + }, + "input_value": { + "type": "string", + "description": "Input field value (only for input elements)" + }, + "message_id": { + "type": "string", + "description": "Message ID of the card", + "format": "message_id" + }, + "operator_id": { + "type": "string", + "description": "Operator open_id", + "format": "open_id" + }, + "option": { + "type": "string", + "description": "Selected option value (for single-select dropdown)" + }, + "options": { + "type": "string", + "description": "Selected options, comma-separated (for multi-select)" + }, + "timestamp": { + "type": "string", + "description": "Event delivery time (ms timestamp string)", + "format": "timestamp_ms" + }, + "timezone": { + "type": "string", + "description": "User timezone for date/time picker interactions" + }, + "token": { + "type": "string", + "description": "Token for delay card update (valid 30 min, max 2 updates)" + }, + "type": { + "type": "string", + "description": "Event type; always card.action.trigger" + } + } + } + }, + { + "key": "im.chat.disbanded_v1", + "display_name": "Chat disbanded", + "description": "Triggered after a chat is disbanded", + "event_type": "im.chat.disbanded_v1", + "subscription_type": "event", + "schema": { + "native": {}, + "field_overrides": { + "/event/chat_id": { + "Description": "", + "Enum": null, + "Kind": "chat_id" + }, + "/event/operator_id/open_id": { + "Description": "", + "Enum": null, + "Kind": "open_id" + }, + "/event/operator_id/union_id": { + "Description": "", + "Enum": null, + "Kind": "union_id" + }, + "/event/operator_id/user_id": { + "Description": "", + "Enum": null, + "Kind": "user_id" + } + } + }, + "scopes": [ + "im:chat:read" + ], + "auth_types": [ + "bot" + ], + "required_console_events": [ + "im.chat.disbanded_v1" + ], + "buffer_size": 100, + "workers": 1, + "resolved_output_schema": { + "description": "飞书事件", + "properties": { + "event": { + "properties": { + "chat_id": { + "format": "chat_id", + "type": "string" + }, + "external": { + "type": "boolean" + }, + "i18n_names": { + "properties": { + "en_us": { + "type": "string" + }, + "ja_jp": { + "type": "string" + }, + "zh_cn": { + "type": "string" + } + }, + "type": "object" + }, + "name": { + "type": "string" + }, + "operator_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + }, + "operator_tenant_key": { + "type": "string" + } + }, + "type": "object" + }, + "header": { + "description": "事件头,所有事件结构一致", + "properties": { + "app_id": { + "description": "接收事件的应用 ID", + "type": "string" + }, + "create_time": { + "description": "事件创建时间,毫秒时间戳字符串", + "type": "string" + }, + "event_id": { + "description": "事件唯一 ID", + "type": "string" + }, + "event_type": { + "description": "事件类型,用于路由", + "type": "string" + }, + "tenant_key": { + "description": "租户唯一标识", + "type": "string" + }, + "token": { + "description": "回调校验 token", + "type": "string" + } + }, + "type": "object" + }, + "schema": { + "description": "飞书事件协议版本", + "enum": [ + "2.0" + ], + "type": "string" + } + }, + "type": "object" + } + }, + { + "key": "im.chat.member.bot.added_v1", + "display_name": "Bot added to chat", + "description": "Triggered when the bot is added to a chat", + "event_type": "im.chat.member.bot.added_v1", + "subscription_type": "event", + "schema": { + "native": {}, + "field_overrides": { + "/event/chat_id": { + "Description": "", + "Enum": null, + "Kind": "chat_id" + }, + "/event/operator_id/open_id": { + "Description": "", + "Enum": null, + "Kind": "open_id" + }, + "/event/operator_id/union_id": { + "Description": "", + "Enum": null, + "Kind": "union_id" + }, + "/event/operator_id/user_id": { + "Description": "", + "Enum": null, + "Kind": "user_id" + } + } + }, + "scopes": [ + "im:chat.members:bot_access" + ], + "auth_types": [ + "bot" + ], + "required_console_events": [ + "im.chat.member.bot.added_v1" + ], + "buffer_size": 100, + "workers": 1, + "resolved_output_schema": { + "description": "飞书事件", + "properties": { + "event": { + "properties": { + "chat_id": { + "format": "chat_id", + "type": "string" + }, + "external": { + "type": "boolean" + }, + "i18n_names": { + "properties": { + "en_us": { + "type": "string" + }, + "ja_jp": { + "type": "string" + }, + "zh_cn": { + "type": "string" + } + }, + "type": "object" + }, + "name": { + "type": "string" + }, + "operator_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + }, + "operator_tenant_key": { + "type": "string" + } + }, + "type": "object" + }, + "header": { + "description": "事件头,所有事件结构一致", + "properties": { + "app_id": { + "description": "接收事件的应用 ID", + "type": "string" + }, + "create_time": { + "description": "事件创建时间,毫秒时间戳字符串", + "type": "string" + }, + "event_id": { + "description": "事件唯一 ID", + "type": "string" + }, + "event_type": { + "description": "事件类型,用于路由", + "type": "string" + }, + "tenant_key": { + "description": "租户唯一标识", + "type": "string" + }, + "token": { + "description": "回调校验 token", + "type": "string" + } + }, + "type": "object" + }, + "schema": { + "description": "飞书事件协议版本", + "enum": [ + "2.0" + ], + "type": "string" + } + }, + "type": "object" + } + }, + { + "key": "im.chat.member.bot.deleted_v1", + "display_name": "Bot removed from chat", + "description": "Triggered after the bot is removed from a chat", + "event_type": "im.chat.member.bot.deleted_v1", + "subscription_type": "event", + "schema": { + "native": {}, + "field_overrides": { + "/event/chat_id": { + "Description": "", + "Enum": null, + "Kind": "chat_id" + }, + "/event/operator_id/open_id": { + "Description": "", + "Enum": null, + "Kind": "open_id" + }, + "/event/operator_id/union_id": { + "Description": "", + "Enum": null, + "Kind": "union_id" + }, + "/event/operator_id/user_id": { + "Description": "", + "Enum": null, + "Kind": "user_id" + } + } + }, + "scopes": [ + "im:chat.members:bot_access" + ], + "auth_types": [ + "bot" + ], + "required_console_events": [ + "im.chat.member.bot.deleted_v1" + ], + "buffer_size": 100, + "workers": 1, + "resolved_output_schema": { + "description": "飞书事件", + "properties": { + "event": { + "properties": { + "chat_id": { + "format": "chat_id", + "type": "string" + }, + "external": { + "type": "boolean" + }, + "i18n_names": { + "properties": { + "en_us": { + "type": "string" + }, + "ja_jp": { + "type": "string" + }, + "zh_cn": { + "type": "string" + } + }, + "type": "object" + }, + "name": { + "type": "string" + }, + "operator_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + }, + "operator_tenant_key": { + "type": "string" + } + }, + "type": "object" + }, + "header": { + "description": "事件头,所有事件结构一致", + "properties": { + "app_id": { + "description": "接收事件的应用 ID", + "type": "string" + }, + "create_time": { + "description": "事件创建时间,毫秒时间戳字符串", + "type": "string" + }, + "event_id": { + "description": "事件唯一 ID", + "type": "string" + }, + "event_type": { + "description": "事件类型,用于路由", + "type": "string" + }, + "tenant_key": { + "description": "租户唯一标识", + "type": "string" + }, + "token": { + "description": "回调校验 token", + "type": "string" + } + }, + "type": "object" + }, + "schema": { + "description": "飞书事件协议版本", + "enum": [ + "2.0" + ], + "type": "string" + } + }, + "type": "object" + } + }, + { + "key": "im.chat.member.user.added_v1", + "display_name": "User added to chat", + "description": "Triggered when a new user joins a chat (including topic chats)", + "event_type": "im.chat.member.user.added_v1", + "subscription_type": "event", + "schema": { + "native": {}, + "field_overrides": { + "/event/chat_id": { + "Description": "", + "Enum": null, + "Kind": "chat_id" + }, + "/event/operator_id/open_id": { + "Description": "", + "Enum": null, + "Kind": "open_id" + }, + "/event/operator_id/union_id": { + "Description": "", + "Enum": null, + "Kind": "union_id" + }, + "/event/operator_id/user_id": { + "Description": "", + "Enum": null, + "Kind": "user_id" + }, + "/event/users/*/user_id/open_id": { + "Description": "", + "Enum": null, + "Kind": "open_id" + }, + "/event/users/*/user_id/union_id": { + "Description": "", + "Enum": null, + "Kind": "union_id" + }, + "/event/users/*/user_id/user_id": { + "Description": "", + "Enum": null, + "Kind": "user_id" + } + } + }, + "scopes": [ + "im:chat.members:read" + ], + "auth_types": [ + "bot" + ], + "required_console_events": [ + "im.chat.member.user.added_v1" + ], + "buffer_size": 100, + "workers": 1, + "resolved_output_schema": { + "description": "飞书事件", + "properties": { + "event": { + "properties": { + "chat_id": { + "format": "chat_id", + "type": "string" + }, + "external": { + "type": "boolean" + }, + "i18n_names": { + "properties": { + "en_us": { + "type": "string" + }, + "ja_jp": { + "type": "string" + }, + "zh_cn": { + "type": "string" + } + }, + "type": "object" + }, + "name": { + "type": "string" + }, + "operator_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + }, + "operator_tenant_key": { + "type": "string" + }, + "users": { + "items": { + "properties": { + "name": { + "type": "string" + }, + "tenant_key": { + "type": "string" + }, + "user_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + }, + "header": { + "description": "事件头,所有事件结构一致", + "properties": { + "app_id": { + "description": "接收事件的应用 ID", + "type": "string" + }, + "create_time": { + "description": "事件创建时间,毫秒时间戳字符串", + "type": "string" + }, + "event_id": { + "description": "事件唯一 ID", + "type": "string" + }, + "event_type": { + "description": "事件类型,用于路由", + "type": "string" + }, + "tenant_key": { + "description": "租户唯一标识", + "type": "string" + }, + "token": { + "description": "回调校验 token", + "type": "string" + } + }, + "type": "object" + }, + "schema": { + "description": "飞书事件协议版本", + "enum": [ + "2.0" + ], + "type": "string" + } + }, + "type": "object" + } + }, + { + "key": "im.chat.member.user.deleted_v1", + "display_name": "User left chat", + "description": "Triggered when a user leaves or is removed from a chat", + "event_type": "im.chat.member.user.deleted_v1", + "subscription_type": "event", + "schema": { + "native": {}, + "field_overrides": { + "/event/chat_id": { + "Description": "", + "Enum": null, + "Kind": "chat_id" + }, + "/event/operator_id/open_id": { + "Description": "", + "Enum": null, + "Kind": "open_id" + }, + "/event/operator_id/union_id": { + "Description": "", + "Enum": null, + "Kind": "union_id" + }, + "/event/operator_id/user_id": { + "Description": "", + "Enum": null, + "Kind": "user_id" + }, + "/event/users/*/user_id/open_id": { + "Description": "", + "Enum": null, + "Kind": "open_id" + }, + "/event/users/*/user_id/union_id": { + "Description": "", + "Enum": null, + "Kind": "union_id" + }, + "/event/users/*/user_id/user_id": { + "Description": "", + "Enum": null, + "Kind": "user_id" + } + } + }, + "scopes": [ + "im:chat.members:read" + ], + "auth_types": [ + "bot" + ], + "required_console_events": [ + "im.chat.member.user.deleted_v1" + ], + "buffer_size": 100, + "workers": 1, + "resolved_output_schema": { + "description": "飞书事件", + "properties": { + "event": { + "properties": { + "chat_id": { + "format": "chat_id", + "type": "string" + }, + "external": { + "type": "boolean" + }, + "i18n_names": { + "properties": { + "en_us": { + "type": "string" + }, + "ja_jp": { + "type": "string" + }, + "zh_cn": { + "type": "string" + } + }, + "type": "object" + }, + "name": { + "type": "string" + }, + "operator_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + }, + "operator_tenant_key": { + "type": "string" + }, + "users": { + "items": { + "properties": { + "name": { + "type": "string" + }, + "tenant_key": { + "type": "string" + }, + "user_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + }, + "header": { + "description": "事件头,所有事件结构一致", + "properties": { + "app_id": { + "description": "接收事件的应用 ID", + "type": "string" + }, + "create_time": { + "description": "事件创建时间,毫秒时间戳字符串", + "type": "string" + }, + "event_id": { + "description": "事件唯一 ID", + "type": "string" + }, + "event_type": { + "description": "事件类型,用于路由", + "type": "string" + }, + "tenant_key": { + "description": "租户唯一标识", + "type": "string" + }, + "token": { + "description": "回调校验 token", + "type": "string" + } + }, + "type": "object" + }, + "schema": { + "description": "飞书事件协议版本", + "enum": [ + "2.0" + ], + "type": "string" + } + }, + "type": "object" + } + }, + { + "key": "im.chat.member.user.withdrawn_v1", + "display_name": "User invite withdrawn", + "description": "Triggered after a pending user invite is withdrawn", + "event_type": "im.chat.member.user.withdrawn_v1", + "subscription_type": "event", + "schema": { + "native": {}, + "field_overrides": { + "/event/chat_id": { + "Description": "", + "Enum": null, + "Kind": "chat_id" + }, + "/event/operator_id/open_id": { + "Description": "", + "Enum": null, + "Kind": "open_id" + }, + "/event/operator_id/union_id": { + "Description": "", + "Enum": null, + "Kind": "union_id" + }, + "/event/operator_id/user_id": { + "Description": "", + "Enum": null, + "Kind": "user_id" + }, + "/event/users/*/user_id/open_id": { + "Description": "", + "Enum": null, + "Kind": "open_id" + }, + "/event/users/*/user_id/union_id": { + "Description": "", + "Enum": null, + "Kind": "union_id" + }, + "/event/users/*/user_id/user_id": { + "Description": "", + "Enum": null, + "Kind": "user_id" + } + } + }, + "scopes": [ + "im:chat.members:read" + ], + "auth_types": [ + "bot" + ], + "required_console_events": [ + "im.chat.member.user.withdrawn_v1" + ], + "buffer_size": 100, + "workers": 1, + "resolved_output_schema": { + "description": "飞书事件", + "properties": { + "event": { + "properties": { + "chat_id": { + "format": "chat_id", + "type": "string" + }, + "external": { + "type": "boolean" + }, + "i18n_names": { + "properties": { + "en_us": { + "type": "string" + }, + "ja_jp": { + "type": "string" + }, + "zh_cn": { + "type": "string" + } + }, + "type": "object" + }, + "name": { + "type": "string" + }, + "operator_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + }, + "operator_tenant_key": { + "type": "string" + }, + "users": { + "items": { + "properties": { + "name": { + "type": "string" + }, + "tenant_key": { + "type": "string" + }, + "user_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + }, + "header": { + "description": "事件头,所有事件结构一致", + "properties": { + "app_id": { + "description": "接收事件的应用 ID", + "type": "string" + }, + "create_time": { + "description": "事件创建时间,毫秒时间戳字符串", + "type": "string" + }, + "event_id": { + "description": "事件唯一 ID", + "type": "string" + }, + "event_type": { + "description": "事件类型,用于路由", + "type": "string" + }, + "tenant_key": { + "description": "租户唯一标识", + "type": "string" + }, + "token": { + "description": "回调校验 token", + "type": "string" + } + }, + "type": "object" + }, + "schema": { + "description": "飞书事件协议版本", + "enum": [ + "2.0" + ], + "type": "string" + } + }, + "type": "object" + } + }, + { + "key": "im.chat.updated_v1", + "display_name": "Chat updated", + "description": "Triggered after chat settings (owner, avatar, name, permissions, etc.) are updated", + "event_type": "im.chat.updated_v1", + "subscription_type": "event", + "schema": { + "native": {}, + "field_overrides": { + "/event/after_change/owner_id/open_id": { + "Description": "", + "Enum": null, + "Kind": "open_id" + }, + "/event/after_change/owner_id/union_id": { + "Description": "", + "Enum": null, + "Kind": "union_id" + }, + "/event/after_change/owner_id/user_id": { + "Description": "", + "Enum": null, + "Kind": "user_id" + }, + "/event/before_change/owner_id/open_id": { + "Description": "", + "Enum": null, + "Kind": "open_id" + }, + "/event/before_change/owner_id/union_id": { + "Description": "", + "Enum": null, + "Kind": "union_id" + }, + "/event/before_change/owner_id/user_id": { + "Description": "", + "Enum": null, + "Kind": "user_id" + }, + "/event/chat_id": { + "Description": "", + "Enum": null, + "Kind": "chat_id" + }, + "/event/moderator_list/added_member_list/*/user_id/open_id": { + "Description": "", + "Enum": null, + "Kind": "open_id" + }, + "/event/moderator_list/added_member_list/*/user_id/union_id": { + "Description": "", + "Enum": null, + "Kind": "union_id" + }, + "/event/moderator_list/added_member_list/*/user_id/user_id": { + "Description": "", + "Enum": null, + "Kind": "user_id" + }, + "/event/moderator_list/removed_member_list/*/user_id/open_id": { + "Description": "", + "Enum": null, + "Kind": "open_id" + }, + "/event/moderator_list/removed_member_list/*/user_id/union_id": { + "Description": "", + "Enum": null, + "Kind": "union_id" + }, + "/event/moderator_list/removed_member_list/*/user_id/user_id": { + "Description": "", + "Enum": null, + "Kind": "user_id" + }, + "/event/operator_id/open_id": { + "Description": "", + "Enum": null, + "Kind": "open_id" + }, + "/event/operator_id/union_id": { + "Description": "", + "Enum": null, + "Kind": "union_id" + }, + "/event/operator_id/user_id": { + "Description": "", + "Enum": null, + "Kind": "user_id" + } + } + }, + "scopes": [ + "im:chat:read" + ], + "auth_types": [ + "bot" + ], + "required_console_events": [ + "im.chat.updated_v1" + ], + "buffer_size": 100, + "workers": 1, + "resolved_output_schema": { + "description": "飞书事件", + "properties": { + "event": { + "properties": { + "after_change": { + "properties": { + "add_member_permission": { + "type": "string" + }, + "at_all_permission": { + "type": "string" + }, + "avatar": { + "type": "string" + }, + "description": { + "type": "string" + }, + "edit_permission": { + "type": "string" + }, + "group_message_type": { + "type": "string" + }, + "i18n_names": { + "properties": { + "en_us": { + "type": "string" + }, + "ja_jp": { + "type": "string" + }, + "zh_cn": { + "type": "string" + } + }, + "type": "object" + }, + "join_message_visibility": { + "type": "string" + }, + "labels": { + "items": { + "type": "string" + }, + "type": "array" + }, + "leave_message_visibility": { + "type": "string" + }, + "membership_approval": { + "type": "string" + }, + "moderation_permission": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owner_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + }, + "restricted_mode_setting": { + "properties": { + "download_has_permission_setting": { + "type": "string" + }, + "message_has_permission_setting": { + "type": "string" + }, + "screenshot_has_permission_setting": { + "type": "string" + }, + "status": { + "type": "boolean" + } + }, + "type": "object" + }, + "share_card_permission": { + "type": "string" + } + }, + "type": "object" + }, + "before_change": { + "properties": { + "add_member_permission": { + "type": "string" + }, + "at_all_permission": { + "type": "string" + }, + "avatar": { + "type": "string" + }, + "description": { + "type": "string" + }, + "edit_permission": { + "type": "string" + }, + "group_message_type": { + "type": "string" + }, + "i18n_names": { + "properties": { + "en_us": { + "type": "string" + }, + "ja_jp": { + "type": "string" + }, + "zh_cn": { + "type": "string" + } + }, + "type": "object" + }, + "join_message_visibility": { + "type": "string" + }, + "labels": { + "items": { + "type": "string" + }, + "type": "array" + }, + "leave_message_visibility": { + "type": "string" + }, + "membership_approval": { + "type": "string" + }, + "moderation_permission": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owner_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + }, + "restricted_mode_setting": { + "properties": { + "download_has_permission_setting": { + "type": "string" + }, + "message_has_permission_setting": { + "type": "string" + }, + "screenshot_has_permission_setting": { + "type": "string" + }, + "status": { + "type": "boolean" + } + }, + "type": "object" + }, + "share_card_permission": { + "type": "string" + } + }, + "type": "object" + }, + "chat_id": { + "format": "chat_id", + "type": "string" + }, + "external": { + "type": "boolean" + }, + "moderator_list": { + "properties": { + "added_member_list": { + "items": { + "properties": { + "tenant_key": { + "type": "string" + }, + "user_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "type": "array" + }, + "removed_member_list": { + "items": { + "properties": { + "tenant_key": { + "type": "string" + }, + "user_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + }, + "operator_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + }, + "operator_tenant_key": { + "type": "string" + } + }, + "type": "object" + }, + "header": { + "description": "事件头,所有事件结构一致", + "properties": { + "app_id": { + "description": "接收事件的应用 ID", + "type": "string" + }, + "create_time": { + "description": "事件创建时间,毫秒时间戳字符串", + "type": "string" + }, + "event_id": { + "description": "事件唯一 ID", + "type": "string" + }, + "event_type": { + "description": "事件类型,用于路由", + "type": "string" + }, + "tenant_key": { + "description": "租户唯一标识", + "type": "string" + }, + "token": { + "description": "回调校验 token", + "type": "string" + } + }, + "type": "object" + }, + "schema": { + "description": "飞书事件协议版本", + "enum": [ + "2.0" + ], + "type": "string" + } + }, + "type": "object" + } + }, + { + "key": "im.message.message_read_v1", + "display_name": "Message read", + "description": "Triggered after a user reads a P2P message sent by the bot", + "event_type": "im.message.message_read_v1", + "subscription_type": "event", + "schema": { + "native": {}, + "field_overrides": { + "/event/message_id_list/*": { + "Description": "", + "Enum": null, + "Kind": "message_id" + }, + "/event/reader/read_time": { + "Description": "", + "Enum": null, + "Kind": "timestamp_ms" + }, + "/event/reader/reader_id/open_id": { + "Description": "", + "Enum": null, + "Kind": "open_id" + }, + "/event/reader/reader_id/union_id": { + "Description": "", + "Enum": null, + "Kind": "union_id" + }, + "/event/reader/reader_id/user_id": { + "Description": "", + "Enum": null, + "Kind": "user_id" + } + } + }, + "scopes": [ + "im:message:readonly", + "im:message" + ], + "auth_types": [ + "bot" + ], + "required_console_events": [ + "im.message.message_read_v1" + ], + "buffer_size": 100, + "workers": 1, + "resolved_output_schema": { + "description": "飞书事件", + "properties": { + "event": { + "properties": { + "message_id_list": { + "items": { + "format": "message_id", + "type": "string" + }, + "type": "array" + }, + "reader": { + "properties": { + "read_time": { + "format": "timestamp_ms", + "type": "string" + }, + "reader_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + }, + "tenant_key": { + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "header": { + "description": "事件头,所有事件结构一致", + "properties": { + "app_id": { + "description": "接收事件的应用 ID", + "type": "string" + }, + "create_time": { + "description": "事件创建时间,毫秒时间戳字符串", + "type": "string" + }, + "event_id": { + "description": "事件唯一 ID", + "type": "string" + }, + "event_type": { + "description": "事件类型,用于路由", + "type": "string" + }, + "tenant_key": { + "description": "租户唯一标识", + "type": "string" + }, + "token": { + "description": "回调校验 token", + "type": "string" + } + }, + "type": "object" + }, + "schema": { + "description": "飞书事件协议版本", + "enum": [ + "2.0" + ], + "type": "string" + } + }, + "type": "object" + } + }, + { + "key": "im.message.reaction.created_v1", + "display_name": "Reaction added", + "description": "Triggered when a reaction is added to a message", + "event_type": "im.message.reaction.created_v1", + "subscription_type": "event", + "schema": { + "native": {}, + "field_overrides": { + "/event/action_time": { + "Description": "", + "Enum": null, + "Kind": "timestamp_ms" + }, + "/event/message_id": { + "Description": "", + "Enum": null, + "Kind": "message_id" + }, + "/event/user_id/open_id": { + "Description": "", + "Enum": null, + "Kind": "open_id" + }, + "/event/user_id/union_id": { + "Description": "", + "Enum": null, + "Kind": "union_id" + }, + "/event/user_id/user_id": { + "Description": "", + "Enum": null, + "Kind": "user_id" + } + } + }, + "scopes": [ + "im:message:readonly", + "im:message.reactions:read" + ], + "auth_types": [ + "bot" + ], + "required_console_events": [ + "im.message.reaction.created_v1" + ], + "buffer_size": 100, + "workers": 1, + "resolved_output_schema": { + "description": "飞书事件", + "properties": { + "event": { + "properties": { + "action_time": { + "format": "timestamp_ms", + "type": "string" + }, + "app_id": { + "type": "string" + }, + "message_id": { + "format": "message_id", + "type": "string" + }, + "operator_type": { + "type": "string" + }, + "reaction_type": { + "properties": { + "emoji_type": { + "type": "string" + } + }, + "type": "object" + }, + "user_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "header": { + "description": "事件头,所有事件结构一致", + "properties": { + "app_id": { + "description": "接收事件的应用 ID", + "type": "string" + }, + "create_time": { + "description": "事件创建时间,毫秒时间戳字符串", + "type": "string" + }, + "event_id": { + "description": "事件唯一 ID", + "type": "string" + }, + "event_type": { + "description": "事件类型,用于路由", + "type": "string" + }, + "tenant_key": { + "description": "租户唯一标识", + "type": "string" + }, + "token": { + "description": "回调校验 token", + "type": "string" + } + }, + "type": "object" + }, + "schema": { + "description": "飞书事件协议版本", + "enum": [ + "2.0" + ], + "type": "string" + } + }, + "type": "object" + } + }, + { + "key": "im.message.reaction.deleted_v1", + "display_name": "Reaction removed", + "description": "Triggered when a reaction is removed from a message", + "event_type": "im.message.reaction.deleted_v1", + "subscription_type": "event", + "schema": { + "native": {}, + "field_overrides": { + "/event/action_time": { + "Description": "", + "Enum": null, + "Kind": "timestamp_ms" + }, + "/event/message_id": { + "Description": "", + "Enum": null, + "Kind": "message_id" + }, + "/event/user_id/open_id": { + "Description": "", + "Enum": null, + "Kind": "open_id" + }, + "/event/user_id/union_id": { + "Description": "", + "Enum": null, + "Kind": "union_id" + }, + "/event/user_id/user_id": { + "Description": "", + "Enum": null, + "Kind": "user_id" + } + } + }, + "scopes": [ + "im:message:readonly", + "im:message.reactions:read" + ], + "auth_types": [ + "bot" + ], + "required_console_events": [ + "im.message.reaction.deleted_v1" + ], + "buffer_size": 100, + "workers": 1, + "resolved_output_schema": { + "description": "飞书事件", + "properties": { + "event": { + "properties": { + "action_time": { + "format": "timestamp_ms", + "type": "string" + }, + "app_id": { + "type": "string" + }, + "message_id": { + "format": "message_id", + "type": "string" + }, + "operator_type": { + "type": "string" + }, + "reaction_type": { + "properties": { + "emoji_type": { + "type": "string" + } + }, + "type": "object" + }, + "user_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "header": { + "description": "事件头,所有事件结构一致", + "properties": { + "app_id": { + "description": "接收事件的应用 ID", + "type": "string" + }, + "create_time": { + "description": "事件创建时间,毫秒时间戳字符串", + "type": "string" + }, + "event_id": { + "description": "事件唯一 ID", + "type": "string" + }, + "event_type": { + "description": "事件类型,用于路由", + "type": "string" + }, + "tenant_key": { + "description": "租户唯一标识", + "type": "string" + }, + "token": { + "description": "回调校验 token", + "type": "string" + } + }, + "type": "object" + }, + "schema": { + "description": "飞书事件协议版本", + "enum": [ + "2.0" + ], + "type": "string" + } + }, + "type": "object" + } + }, + { + "key": "im.message.receive_v1", + "display_name": "Receive message", + "description": "Receive IM messages", + "event_type": "im.message.receive_v1", + "subscription_type": "event", + "schema": { + "custom": {} + }, + "scopes": [ + "im:message.p2p_msg:readonly" + ], + "auth_types": [ + "bot" + ], + "required_console_events": [ + "im.message.receive_v1" + ], + "buffer_size": 100, + "workers": 1, + "resolved_output_schema": { + "type": "object", + "properties": { + "chat_id": { + "type": "string", + "description": "Chat/conversation ID; prefixed with oc_", + "format": "chat_id" + }, + "chat_type": { + "type": "string", + "description": "Conversation type", + "enum": [ + "p2p", + "group" + ] + }, + "content": { + "type": "string", + "description": "Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text." + }, + "create_time": { + "type": "string", + "description": "Message creation time (ms timestamp string)", + "format": "timestamp_ms" + }, + "event_id": { + "type": "string", + "description": "Event delivery ID. Do not use as the message deduplication key; use message_id instead." + }, + "id": { + "type": "string", + "description": "Message ID (legacy alias of message_id, kept for compatibility)", + "format": "message_id" + }, + "mentions": { + "type": "array", + "description": "Compact mentions aligned with im +messages-mget", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Mentioned user open_id; prefixed with ou_", + "format": "open_id" + }, + "key": { + "type": "string", + "description": "Mention placeholder key, for example @_user_1" + }, + "name": { + "type": "string", + "description": "Mentioned display name" + } + } + } + }, + "message_id": { + "type": "string", + "description": "Message ID; prefixed with om_. Recommended idempotency key for im.message.receive_v1 consumers.", + "format": "message_id" + }, + "message_type": { + "type": "string", + "description": "Message type" + }, + "reply_to": { + "type": "string", + "description": "Parent message ID of the direct reply context, when present", + "format": "message_id" + }, + "root_id": { + "type": "string", + "description": "Root message ID of the reply/thread context, when present", + "format": "message_id" + }, + "sender_id": { + "type": "string", + "description": "Sender open_id; prefixed with ou_", + "format": "open_id" + }, + "sender_type": { + "type": "string", + "description": "Sender type", + "enum": [ + "user", + "bot" + ] + }, + "thread_id": { + "type": "string", + "description": "Thread ID, when present" + }, + "timestamp": { + "type": "string", + "description": "Event delivery time (ms timestamp string); prefers header.create_time", + "format": "timestamp_ms" + }, + "type": { + "type": "string", + "description": "Event type; always im.message.receive_v1" + }, + "update_time": { + "type": "string", + "description": "Message update time (ms timestamp string); emitted only when different from create_time", + "format": "timestamp_ms" + } + } + } + }, + { + "key": "minutes.minute.generated_v1", + "display_name": "Minute generated", + "description": "Triggered when a minute has been generated", + "event_type": "minutes.minute.generated_v1", + "subscription_type": "event", + "schema": { + "custom": {} + }, + "scopes": [ + "minutes:minutes.basic:read" + ], + "auth_types": [ + "user" + ], + "required_console_events": [ + "minutes.minute.generated_v1" + ], + "buffer_size": 100, + "workers": 1, + "resolved_output_schema": { + "type": "object", + "properties": { + "event_id": { + "type": "string", + "description": "Globally unique event ID; safe for deduplication" + }, + "minute_source": { + "type": "object", + "description": "Minute source metadata", + "properties": { + "source_entity_id": { + "type": "string", + "description": "Source entity ID" + }, + "source_type": { + "type": "string", + "description": "Minute source type" + } + } + }, + "minute_token": { + "type": "string", + "description": "Minute token" + }, + "timestamp": { + "type": "string", + "description": "Event delivery time (ms timestamp string); taken from header.create_time when present", + "format": "timestamp_ms" + }, + "title": { + "type": "string", + "description": "Minute title" + }, + "type": { + "type": "string", + "description": "Event type; always minutes.minute.generated_v1" + } + } + } + }, + { + "key": "task.task.update_user_access_v2", + "display_name": "Task updated", + "description": "Triggered when tasks visible to the current user or app are created, deleted, or updated", + "event_type": "task.task.update_user_access_v2", + "subscription_type": "event", + "schema": { + "native": {} + }, + "scopes": [ + "task:task:read" + ], + "auth_types": [ + "user", + "bot" + ], + "required_console_events": [ + "task.task.update_user_access_v2" + ], + "buffer_size": 100, + "workers": 1, + "single_consumer": true, + "resolved_output_schema": { + "description": "飞书事件", + "properties": { + "event": { + "properties": { + "event_types": { + "description": "Task commit types included in this event", + "items": { + "enum": [ + "task_create", + "task_deleted", + "task_summary_update", + "task_desc_update", + "task_assignees_update", + "task_followers_update", + "task_reminders_update", + "task_start_due_update", + "task_completed_update" + ], + "type": "string" + }, + "type": "array" + }, + "task_guid": { + "description": "Task GUID that changed", + "format": "task_guid", + "type": "string" + } + }, + "type": "object" + }, + "header": { + "description": "事件头,所有事件结构一致", + "properties": { + "app_id": { + "description": "接收事件的应用 ID", + "type": "string" + }, + "create_time": { + "description": "事件创建时间,毫秒时间戳字符串", + "type": "string" + }, + "event_id": { + "description": "事件唯一 ID", + "type": "string" + }, + "event_type": { + "description": "事件类型,用于路由", + "type": "string" + }, + "tenant_key": { + "description": "租户唯一标识", + "type": "string" + }, + "token": { + "description": "回调校验 token", + "type": "string" + } + }, + "type": "object" + }, + "schema": { + "description": "飞书事件协议版本", + "enum": [ + "2.0" + ], + "type": "string" + } + }, + "type": "object" + } + }, + { + "key": "vc.meeting.participant_meeting_ended_v1", + "display_name": "Participant meeting ended", + "description": "Triggered when a meeting the current user participates in has ended", + "event_type": "vc.meeting.participant_meeting_ended_v1", + "subscription_type": "event", + "schema": { + "custom": {} + }, + "scopes": [ + "vc:meeting.meetingevent:read" + ], + "auth_types": [ + "user" + ], + "required_console_events": [ + "vc.meeting.participant_meeting_ended_v1" + ], + "buffer_size": 100, + "workers": 1, + "resolved_output_schema": { + "type": "object", + "properties": { + "calendar_event_id": { + "type": "string", + "description": "Calendar event ID associated with the meeting" + }, + "end_time": { + "type": "string", + "description": "Meeting end time in RFC3339, converted to the local timezone" + }, + "event_id": { + "type": "string", + "description": "Globally unique event ID; safe for deduplication" + }, + "meeting_id": { + "type": "string", + "description": "Meeting ID", + "format": "meeting_id" + }, + "meeting_no": { + "type": "string", + "description": "Meeting number" + }, + "start_time": { + "type": "string", + "description": "Meeting start time in RFC3339, converted to the local timezone" + }, + "timestamp": { + "type": "string", + "description": "Event delivery time (ms timestamp string); taken from header.create_time when present", + "format": "timestamp_ms" + }, + "topic": { + "type": "string", + "description": "Meeting topic" + }, + "type": { + "type": "string", + "description": "Event type; always vc.meeting.participant_meeting_ended_v1" + } + } + } + }, + { + "key": "vc.meeting.participant_meeting_joined_v1", + "display_name": "Participant meeting joined", + "description": "Triggered when the current user joins a meeting", + "event_type": "vc.meeting.participant_meeting_joined_v1", + "subscription_type": "event", + "schema": { + "custom": {} + }, + "scopes": [ + "vc:meeting.meetingevent:read" + ], + "auth_types": [ + "user" + ], + "required_console_events": [ + "vc.meeting.participant_meeting_joined_v1" + ], + "buffer_size": 100, + "workers": 1, + "resolved_output_schema": { + "type": "object", + "properties": { + "calendar_event_id": { + "type": "string", + "description": "Calendar event ID associated with the meeting" + }, + "event_id": { + "type": "string", + "description": "Globally unique event ID; safe for deduplication" + }, + "meeting_id": { + "type": "string", + "description": "Meeting ID", + "format": "meeting_id" + }, + "meeting_no": { + "type": "string", + "description": "Meeting number" + }, + "start_time": { + "type": "string", + "description": "Meeting start time in RFC3339, converted to the local timezone" + }, + "timestamp": { + "type": "string", + "description": "Event delivery time (ms timestamp string); taken from header.create_time when present", + "format": "timestamp_ms" + }, + "topic": { + "type": "string", + "description": "Meeting topic" + }, + "type": { + "type": "string", + "description": "Event type; always vc.meeting.participant_meeting_joined_v1" + } + } + } + }, + { + "key": "vc.meeting.participant_meeting_started_v1", + "display_name": "Participant meeting started", + "description": "Triggered when a meeting the current user participates in has started", + "event_type": "vc.meeting.participant_meeting_started_v1", + "subscription_type": "event", + "schema": { + "custom": {} + }, + "scopes": [ + "vc:meeting.meetingevent:read" + ], + "auth_types": [ + "user" + ], + "required_console_events": [ + "vc.meeting.participant_meeting_started_v1" + ], + "buffer_size": 100, + "workers": 1, + "resolved_output_schema": { + "type": "object", + "properties": { + "calendar_event_id": { + "type": "string", + "description": "Calendar event ID associated with the meeting" + }, + "event_id": { + "type": "string", + "description": "Globally unique event ID; safe for deduplication" + }, + "meeting_id": { + "type": "string", + "description": "Meeting ID", + "format": "meeting_id" + }, + "meeting_no": { + "type": "string", + "description": "Meeting number" + }, + "start_time": { + "type": "string", + "description": "Meeting start time in RFC3339, converted to the local timezone" + }, + "timestamp": { + "type": "string", + "description": "Event delivery time (ms timestamp string); taken from header.create_time when present", + "format": "timestamp_ms" + }, + "topic": { + "type": "string", + "description": "Meeting topic" + }, + "type": { + "type": "string", + "description": "Event type; always vc.meeting.participant_meeting_started_v1" + } + } + } + }, + { + "key": "vc.note.generated_v1", + "display_name": "Note generated", + "description": "Triggered when a note has been generated", + "event_type": "vc.note.generated_v1", + "subscription_type": "event", + "schema": { + "custom": {} + }, + "scopes": [ + "vc:note:read" + ], + "auth_types": [ + "user" + ], + "required_console_events": [ + "vc.note.generated_v1" + ], + "buffer_size": 100, + "workers": 1, + "resolved_output_schema": { + "type": "object", + "properties": { + "event_id": { + "type": "string", + "description": "Globally unique event ID; safe for deduplication" + }, + "note_id": { + "type": "string", + "description": "Note ID" + }, + "note_source": { + "type": "object", + "description": "Note source metadata", + "properties": { + "source_entity_id": { + "type": "string", + "description": "Source entity ID" + }, + "source_type": { + "type": "string", + "description": "Note source type" + } + } + }, + "note_token": { + "type": "string", + "description": "Generated note document token" + }, + "timestamp": { + "type": "string", + "description": "Event delivery time (ms timestamp string); taken from header.create_time when present", + "format": "timestamp_ms" + }, + "type": { + "type": "string", + "description": "Event type; always vc.note.generated_v1" + }, + "verbatim_token": { + "type": "string", + "description": "Generated verbatim document token" + } + } + } + }, + { + "key": "vc.recording.recording_ended_v1", + "display_name": "Recording ended", + "description": "Triggered when a recording_bean recording ends and uploads successfully; only generated when connected to Feishu software.", + "event_type": "vc.recording.recording_ended_v1", + "subscription_type": "event", + "schema": { + "custom": {} + }, + "scopes": [ + "vc:recording:read" + ], + "auth_types": [ + "user" + ], + "required_console_events": [ + "vc.recording.recording_ended_v1" + ], + "buffer_size": 100, + "workers": 1, + "resolved_output_schema": { + "type": "object", + "properties": { + "event_id": { + "type": "string", + "description": "Globally unique event ID; safe for deduplication" + }, + "event_time": { + "type": "string", + "description": "Time when the recording ended and uploaded successfully, in RFC3339 / ISO 8601 with the current system timezone" + }, + "source": { + "type": "string", + "description": "Recording source; always recording_bean" + }, + "type": { + "type": "string", + "description": "Event type; always vc.recording.recording_ended_v1" + }, + "unique_key": { + "type": "string", + "description": "Unique key generated for one recording_bean recording session" + } + } + } + }, + { + "key": "vc.recording.recording_started_v1", + "display_name": "Recording started", + "description": "Triggered when a recording_bean recording starts; only generated when connected to Feishu software.", + "event_type": "vc.recording.recording_started_v1", + "subscription_type": "event", + "schema": { + "custom": {} + }, + "scopes": [ + "vc:recording:read" + ], + "auth_types": [ + "user" + ], + "required_console_events": [ + "vc.recording.recording_started_v1" + ], + "buffer_size": 100, + "workers": 1, + "resolved_output_schema": { + "type": "object", + "properties": { + "event_id": { + "type": "string", + "description": "Globally unique event ID; safe for deduplication" + }, + "event_time": { + "type": "string", + "description": "Recording start time in RFC3339 / ISO 8601 with the current system timezone" + }, + "source": { + "type": "string", + "description": "Recording source; always recording_bean" + }, + "type": { + "type": "string", + "description": "Event type; always vc.recording.recording_started_v1" + }, + "unique_key": { + "type": "string", + "description": "Unique key generated for one recording_bean recording session" + } + } + } + }, + { + "key": "vc.recording.recording_transcript_generated_v1", + "display_name": "Recording transcript generated", + "description": "Triggered when recording_bean transcript items are generated; only generated when connected to Feishu software.", + "event_type": "vc.recording.recording_transcript_generated_v1", + "subscription_type": "event", + "schema": { + "custom": {} + }, + "scopes": [ + "vc:recording:read" + ], + "auth_types": [ + "user" + ], + "required_console_events": [ + "vc.recording.recording_transcript_generated_v1" + ], + "buffer_size": 100, + "workers": 1, + "resolved_output_schema": { + "type": "object", + "properties": { + "event_id": { + "type": "string", + "description": "Globally unique event ID; safe for deduplication" + }, + "event_time": { + "type": "string", + "description": "Time when this batch of transcript items was generated, in RFC3339 / ISO 8601 with the current system timezone" + }, + "source": { + "type": "string", + "description": "Recording source; always recording_bean" + }, + "transcript_items": { + "type": "array", + "description": "Generated transcript items", + "items": { + "type": "object", + "properties": { + "end_time": { + "type": "string", + "description": "Transcript item end time in RFC3339 / ISO 8601 with the current system timezone" + }, + "sentence_id": { + "type": "string", + "description": "Transcript sentence ID" + }, + "speaker_name": { + "type": "string", + "description": "Speaker display name" + }, + "start_time": { + "type": "string", + "description": "Transcript item start time in RFC3339 / ISO 8601 with the current system timezone" + }, + "text": { + "type": "string", + "description": "Transcript text" + } + } + } + }, + "type": { + "type": "string", + "description": "Event type; always vc.recording.recording_transcript_generated_v1" + }, + "unique_key": { + "type": "string", + "description": "Unique key generated for one recording_bean recording session" + } + } + } + } +] diff --git a/cmd/event/testdata/golden/list_text.golden b/cmd/event/testdata/golden/list_text.golden new file mode 100644 index 0000000000..6ca6446e81 --- /dev/null +++ b/cmd/event/testdata/golden/list_text.golden @@ -0,0 +1,42 @@ +KEY AUTH PARAMS DESCRIPTION + +── application ── +application.bot.menu_v6 bot 0 Triggered when a user clicks a custom bot menu item whose action is configured as a push event. + +── approval ── +approval.instance.status_changed_v4 user 1 Triggered after an approval instance status becomes visible to the requester or approval participants +approval.task.status_changed_v4 user 1 Triggered after an approval task status becomes visible to the requester or task approver + +── board ── +board.whiteboard.updated_v1 user|bot 1 Pushed when the whiteboard content is updated. + +── card ── +card.action.trigger bot 0 Triggered when a user interacts with an interactive card (button click, form submit, dropdown select, etc.). Output includes: token (valid 30 min, max 2 updates), action details (tag, value, name, form_value), and card_content (original card in userDSL text format, auto-fetched at consume time). To update the card: parse card_content to understand the current state, construct the new card JSON, then call `lark-cli api POST /open-apis/interactive/v1/card/update` with the token (see lark-im-card-action-reply.md). + +── im ── +im.chat.disbanded_v1 bot 0 Triggered after a chat is disbanded +im.chat.member.bot.added_v1 bot 0 Triggered when the bot is added to a chat +im.chat.member.bot.deleted_v1 bot 0 Triggered after the bot is removed from a chat +im.chat.member.user.added_v1 bot 0 Triggered when a new user joins a chat (including topic chats) +im.chat.member.user.deleted_v1 bot 0 Triggered when a user leaves or is removed from a chat +im.chat.member.user.withdrawn_v1 bot 0 Triggered after a pending user invite is withdrawn +im.chat.updated_v1 bot 0 Triggered after chat settings (owner, avatar, name, permissions, etc.) are updated +im.message.message_read_v1 bot 0 Triggered after a user reads a P2P message sent by the bot +im.message.reaction.created_v1 bot 0 Triggered when a reaction is added to a message +im.message.reaction.deleted_v1 bot 0 Triggered when a reaction is removed from a message +im.message.receive_v1 bot 0 Receive IM messages + +── minutes ── +minutes.minute.generated_v1 user 0 Triggered when a minute has been generated + +── task ── +task.task.update_user_access_v2 user|bot 0 Triggered when tasks visible to the current user or app are created, deleted, or updated + +── vc ── +vc.meeting.participant_meeting_ended_v1 user 0 Triggered when a meeting the current user participates in has ended +vc.meeting.participant_meeting_joined_v1 user 0 Triggered when the current user joins a meeting +vc.meeting.participant_meeting_started_v1 user 0 Triggered when a meeting the current user participates in has started +vc.note.generated_v1 user 0 Triggered when a note has been generated +vc.recording.recording_ended_v1 user 0 Triggered when a recording_bean recording ends and uploads successfully; only generated when connected to Feishu software. +vc.recording.recording_started_v1 user 0 Triggered when a recording_bean recording starts; only generated when connected to Feishu software. +vc.recording.recording_transcript_generated_v1 user 0 Triggered when recording_bean transcript items are generated; only generated when connected to Feishu software. diff --git a/cmd/event/testdata/golden/schema_board_whiteboard_json.golden b/cmd/event/testdata/golden/schema_board_whiteboard_json.golden new file mode 100644 index 0000000000..582737b41c --- /dev/null +++ b/cmd/event/testdata/golden/schema_board_whiteboard_json.golden @@ -0,0 +1,126 @@ +{ + "key": "board.whiteboard.updated_v1", + "display_name": "Whiteboard updated", + "description": "Pushed when the whiteboard content is updated.", + "event_type": "board.whiteboard.updated_v1", + "subscription_type": "event", + "params": [ + { + "name": "whiteboard_id", + "type": "string", + "required": true, + "description": "Whiteboard id to subscribe; subscription is per-whiteboard." + } + ], + "schema": { + "native": {}, + "field_overrides": { + "/event/operator_ids/*/open_id": { + "Description": "", + "Enum": null, + "Kind": "open_id" + }, + "/event/operator_ids/*/union_id": { + "Description": "", + "Enum": null, + "Kind": "union_id" + }, + "/event/operator_ids/*/user_id": { + "Description": "", + "Enum": null, + "Kind": "user_id" + }, + "/event/whiteboard_id": { + "Description": "whiteboard id to subscribe", + "Enum": null, + "Kind": "whiteboard_id" + } + } + }, + "scopes": [ + "board:whiteboard:node:read" + ], + "auth_types": [ + "user", + "bot" + ], + "required_console_events": [ + "board.whiteboard.updated_v1" + ], + "buffer_size": 100, + "workers": 1, + "resolved_output_schema": { + "description": "飞书事件", + "properties": { + "event": { + "properties": { + "operator_ids": { + "items": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "whiteboard_id": { + "description": "whiteboard id to subscribe", + "format": "whiteboard_id", + "type": "string" + } + }, + "type": "object" + }, + "header": { + "description": "事件头,所有事件结构一致", + "properties": { + "app_id": { + "description": "接收事件的应用 ID", + "type": "string" + }, + "create_time": { + "description": "事件创建时间,毫秒时间戳字符串", + "type": "string" + }, + "event_id": { + "description": "事件唯一 ID", + "type": "string" + }, + "event_type": { + "description": "事件类型,用于路由", + "type": "string" + }, + "tenant_key": { + "description": "租户唯一标识", + "type": "string" + }, + "token": { + "description": "回调校验 token", + "type": "string" + } + }, + "type": "object" + }, + "schema": { + "description": "飞书事件协议版本", + "enum": [ + "2.0" + ], + "type": "string" + } + }, + "type": "object" + }, + "jq_root_path": ".event" +} diff --git a/cmd/event/testdata/golden/schema_board_whiteboard_text.golden b/cmd/event/testdata/golden/schema_board_whiteboard_text.golden new file mode 100644 index 0000000000..be00fccc20 --- /dev/null +++ b/cmd/event/testdata/golden/schema_board_whiteboard_text.golden @@ -0,0 +1,89 @@ +Key: board.whiteboard.updated_v1 +Description: Pushed when the whiteboard content is updated. +Event: board.whiteboard.updated_v1 +Pre-consume: yes + +Required Scopes: + - board:whiteboard:node:read + +Required Console Events (must be enabled in developer console): + - board.whiteboard.updated_v1 + +Parameters: + NAME TYPE REQUIRED SUB-KEY DEFAULT DESCRIPTION + whiteboard_id string yes no - Whiteboard id to subscribe; subscription is per-whiteboard. + +Output Schema: + { + "description": "飞书事件", + "properties": { + "event": { + "properties": { + "operator_ids": { + "items": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "whiteboard_id": { + "description": "whiteboard id to subscribe", + "format": "whiteboard_id", + "type": "string" + } + }, + "type": "object" + }, + "header": { + "description": "事件头,所有事件结构一致", + "properties": { + "app_id": { + "description": "接收事件的应用 ID", + "type": "string" + }, + "create_time": { + "description": "事件创建时间,毫秒时间戳字符串", + "type": "string" + }, + "event_id": { + "description": "事件唯一 ID", + "type": "string" + }, + "event_type": { + "description": "事件类型,用于路由", + "type": "string" + }, + "tenant_key": { + "description": "租户唯一标识", + "type": "string" + }, + "token": { + "description": "回调校验 token", + "type": "string" + } + }, + "type": "object" + }, + "schema": { + "description": "飞书事件协议版本", + "enum": [ + "2.0" + ], + "type": "string" + } + }, + "type": "object" + } diff --git a/cmd/event/testdata/golden/schema_card_action_trigger_json.golden b/cmd/event/testdata/golden/schema_card_action_trigger_json.golden new file mode 100644 index 0000000000..34b12a8589 --- /dev/null +++ b/cmd/event/testdata/golden/schema_card_action_trigger_json.golden @@ -0,0 +1,104 @@ +{ + "key": "card.action.trigger", + "display_name": "Card action", + "description": "Triggered when a user interacts with an interactive card (button click, form submit, dropdown select, etc.). Output includes: token (valid 30 min, max 2 updates), action details (tag, value, name, form_value), and card_content (original card in userDSL text format, auto-fetched at consume time). To update the card: parse card_content to understand the current state, construct the new card JSON, then call `lark-cli api POST /open-apis/interactive/v1/card/update` with the token (see lark-im-card-action-reply.md).", + "event_type": "card.action.trigger", + "subscription_type": "callback", + "schema": { + "custom": {} + }, + "scopes": [ + "im:message:readonly" + ], + "auth_types": [ + "bot" + ], + "required_console_events": [ + "card.action.trigger" + ], + "buffer_size": 100, + "workers": 1, + "single_consumer": true, + "resolved_output_schema": { + "type": "object", + "properties": { + "action_name": { + "type": "string", + "description": "Element name attribute" + }, + "action_tag": { + "type": "string", + "description": "Triggered element type: button/select_static/input/checker/etc" + }, + "action_value": { + "type": "string", + "description": "Developer-defined action value as JSON string" + }, + "card_content": { + "type": "string", + "description": "Original card JSON content (body.content) auto-fetched via message get API at consume time using message_id; empty if message_id absent or fetch fails" + }, + "chat_id": { + "type": "string", + "description": "Chat ID", + "format": "chat_id" + }, + "checked": { + "type": "boolean", + "description": "Checkbox state (for checkbox elements)" + }, + "event_id": { + "type": "string", + "description": "Globally unique event ID" + }, + "form_value": { + "type": "string", + "description": "Form submission values as JSON string (only on form submit)" + }, + "host": { + "type": "string", + "description": "Host type: im_message / im_top_notice" + }, + "input_value": { + "type": "string", + "description": "Input field value (only for input elements)" + }, + "message_id": { + "type": "string", + "description": "Message ID of the card", + "format": "message_id" + }, + "operator_id": { + "type": "string", + "description": "Operator open_id", + "format": "open_id" + }, + "option": { + "type": "string", + "description": "Selected option value (for single-select dropdown)" + }, + "options": { + "type": "string", + "description": "Selected options, comma-separated (for multi-select)" + }, + "timestamp": { + "type": "string", + "description": "Event delivery time (ms timestamp string)", + "format": "timestamp_ms" + }, + "timezone": { + "type": "string", + "description": "User timezone for date/time picker interactions" + }, + "token": { + "type": "string", + "description": "Token for delay card update (valid 30 min, max 2 updates)" + }, + "type": { + "type": "string", + "description": "Event type; always card.action.trigger" + } + } + }, + "jq_root_path": "." +} diff --git a/cmd/event/testdata/golden/schema_card_action_trigger_text.golden b/cmd/event/testdata/golden/schema_card_action_trigger_text.golden new file mode 100644 index 0000000000..e556b6759f --- /dev/null +++ b/cmd/event/testdata/golden/schema_card_action_trigger_text.golden @@ -0,0 +1,92 @@ +Key: card.action.trigger +Description: Triggered when a user interacts with an interactive card (button click, form submit, dropdown select, etc.). Output includes: token (valid 30 min, max 2 updates), action details (tag, value, name, form_value), and card_content (original card in userDSL text format, auto-fetched at consume time). To update the card: parse card_content to understand the current state, construct the new card JSON, then call `lark-cli api POST /open-apis/interactive/v1/card/update` with the token (see lark-im-card-action-reply.md). +Event: card.action.trigger + +Required Scopes: + - im:message:readonly + +Required Console Events (must be enabled in developer console): + - card.action.trigger + +Output Schema: + { + "type": "object", + "properties": { + "action_name": { + "type": "string", + "description": "Element name attribute" + }, + "action_tag": { + "type": "string", + "description": "Triggered element type: button/select_static/input/checker/etc" + }, + "action_value": { + "type": "string", + "description": "Developer-defined action value as JSON string" + }, + "card_content": { + "type": "string", + "description": "Original card JSON content (body.content) auto-fetched via message get API at consume time using message_id; empty if message_id absent or fetch fails" + }, + "chat_id": { + "type": "string", + "description": "Chat ID", + "format": "chat_id" + }, + "checked": { + "type": "boolean", + "description": "Checkbox state (for checkbox elements)" + }, + "event_id": { + "type": "string", + "description": "Globally unique event ID" + }, + "form_value": { + "type": "string", + "description": "Form submission values as JSON string (only on form submit)" + }, + "host": { + "type": "string", + "description": "Host type: im_message / im_top_notice" + }, + "input_value": { + "type": "string", + "description": "Input field value (only for input elements)" + }, + "message_id": { + "type": "string", + "description": "Message ID of the card", + "format": "message_id" + }, + "operator_id": { + "type": "string", + "description": "Operator open_id", + "format": "open_id" + }, + "option": { + "type": "string", + "description": "Selected option value (for single-select dropdown)" + }, + "options": { + "type": "string", + "description": "Selected options, comma-separated (for multi-select)" + }, + "timestamp": { + "type": "string", + "description": "Event delivery time (ms timestamp string)", + "format": "timestamp_ms" + }, + "timezone": { + "type": "string", + "description": "User timezone for date/time picker interactions" + }, + "token": { + "type": "string", + "description": "Token for delay card update (valid 30 min, max 2 updates)" + }, + "type": { + "type": "string", + "description": "Event type; always card.action.trigger" + } + } + } diff --git a/cmd/event/testdata/golden/schema_im_chat_updated_json.golden b/cmd/event/testdata/golden/schema_im_chat_updated_json.golden new file mode 100644 index 0000000000..6feb316dd8 --- /dev/null +++ b/cmd/event/testdata/golden/schema_im_chat_updated_json.golden @@ -0,0 +1,430 @@ +{ + "key": "im.chat.updated_v1", + "display_name": "Chat updated", + "description": "Triggered after chat settings (owner, avatar, name, permissions, etc.) are updated", + "event_type": "im.chat.updated_v1", + "subscription_type": "event", + "schema": { + "native": {}, + "field_overrides": { + "/event/after_change/owner_id/open_id": { + "Description": "", + "Enum": null, + "Kind": "open_id" + }, + "/event/after_change/owner_id/union_id": { + "Description": "", + "Enum": null, + "Kind": "union_id" + }, + "/event/after_change/owner_id/user_id": { + "Description": "", + "Enum": null, + "Kind": "user_id" + }, + "/event/before_change/owner_id/open_id": { + "Description": "", + "Enum": null, + "Kind": "open_id" + }, + "/event/before_change/owner_id/union_id": { + "Description": "", + "Enum": null, + "Kind": "union_id" + }, + "/event/before_change/owner_id/user_id": { + "Description": "", + "Enum": null, + "Kind": "user_id" + }, + "/event/chat_id": { + "Description": "", + "Enum": null, + "Kind": "chat_id" + }, + "/event/moderator_list/added_member_list/*/user_id/open_id": { + "Description": "", + "Enum": null, + "Kind": "open_id" + }, + "/event/moderator_list/added_member_list/*/user_id/union_id": { + "Description": "", + "Enum": null, + "Kind": "union_id" + }, + "/event/moderator_list/added_member_list/*/user_id/user_id": { + "Description": "", + "Enum": null, + "Kind": "user_id" + }, + "/event/moderator_list/removed_member_list/*/user_id/open_id": { + "Description": "", + "Enum": null, + "Kind": "open_id" + }, + "/event/moderator_list/removed_member_list/*/user_id/union_id": { + "Description": "", + "Enum": null, + "Kind": "union_id" + }, + "/event/moderator_list/removed_member_list/*/user_id/user_id": { + "Description": "", + "Enum": null, + "Kind": "user_id" + }, + "/event/operator_id/open_id": { + "Description": "", + "Enum": null, + "Kind": "open_id" + }, + "/event/operator_id/union_id": { + "Description": "", + "Enum": null, + "Kind": "union_id" + }, + "/event/operator_id/user_id": { + "Description": "", + "Enum": null, + "Kind": "user_id" + } + } + }, + "scopes": [ + "im:chat:read" + ], + "auth_types": [ + "bot" + ], + "required_console_events": [ + "im.chat.updated_v1" + ], + "buffer_size": 100, + "workers": 1, + "resolved_output_schema": { + "description": "飞书事件", + "properties": { + "event": { + "properties": { + "after_change": { + "properties": { + "add_member_permission": { + "type": "string" + }, + "at_all_permission": { + "type": "string" + }, + "avatar": { + "type": "string" + }, + "description": { + "type": "string" + }, + "edit_permission": { + "type": "string" + }, + "group_message_type": { + "type": "string" + }, + "i18n_names": { + "properties": { + "en_us": { + "type": "string" + }, + "ja_jp": { + "type": "string" + }, + "zh_cn": { + "type": "string" + } + }, + "type": "object" + }, + "join_message_visibility": { + "type": "string" + }, + "labels": { + "items": { + "type": "string" + }, + "type": "array" + }, + "leave_message_visibility": { + "type": "string" + }, + "membership_approval": { + "type": "string" + }, + "moderation_permission": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owner_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + }, + "restricted_mode_setting": { + "properties": { + "download_has_permission_setting": { + "type": "string" + }, + "message_has_permission_setting": { + "type": "string" + }, + "screenshot_has_permission_setting": { + "type": "string" + }, + "status": { + "type": "boolean" + } + }, + "type": "object" + }, + "share_card_permission": { + "type": "string" + } + }, + "type": "object" + }, + "before_change": { + "properties": { + "add_member_permission": { + "type": "string" + }, + "at_all_permission": { + "type": "string" + }, + "avatar": { + "type": "string" + }, + "description": { + "type": "string" + }, + "edit_permission": { + "type": "string" + }, + "group_message_type": { + "type": "string" + }, + "i18n_names": { + "properties": { + "en_us": { + "type": "string" + }, + "ja_jp": { + "type": "string" + }, + "zh_cn": { + "type": "string" + } + }, + "type": "object" + }, + "join_message_visibility": { + "type": "string" + }, + "labels": { + "items": { + "type": "string" + }, + "type": "array" + }, + "leave_message_visibility": { + "type": "string" + }, + "membership_approval": { + "type": "string" + }, + "moderation_permission": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owner_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + }, + "restricted_mode_setting": { + "properties": { + "download_has_permission_setting": { + "type": "string" + }, + "message_has_permission_setting": { + "type": "string" + }, + "screenshot_has_permission_setting": { + "type": "string" + }, + "status": { + "type": "boolean" + } + }, + "type": "object" + }, + "share_card_permission": { + "type": "string" + } + }, + "type": "object" + }, + "chat_id": { + "format": "chat_id", + "type": "string" + }, + "external": { + "type": "boolean" + }, + "moderator_list": { + "properties": { + "added_member_list": { + "items": { + "properties": { + "tenant_key": { + "type": "string" + }, + "user_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "type": "array" + }, + "removed_member_list": { + "items": { + "properties": { + "tenant_key": { + "type": "string" + }, + "user_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + }, + "operator_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + }, + "operator_tenant_key": { + "type": "string" + } + }, + "type": "object" + }, + "header": { + "description": "事件头,所有事件结构一致", + "properties": { + "app_id": { + "description": "接收事件的应用 ID", + "type": "string" + }, + "create_time": { + "description": "事件创建时间,毫秒时间戳字符串", + "type": "string" + }, + "event_id": { + "description": "事件唯一 ID", + "type": "string" + }, + "event_type": { + "description": "事件类型,用于路由", + "type": "string" + }, + "tenant_key": { + "description": "租户唯一标识", + "type": "string" + }, + "token": { + "description": "回调校验 token", + "type": "string" + } + }, + "type": "object" + }, + "schema": { + "description": "飞书事件协议版本", + "enum": [ + "2.0" + ], + "type": "string" + } + }, + "type": "object" + }, + "jq_root_path": ".event" +} diff --git a/cmd/event/testdata/golden/schema_im_chat_updated_text.golden b/cmd/event/testdata/golden/schema_im_chat_updated_text.golden new file mode 100644 index 0000000000..e0624d57d0 --- /dev/null +++ b/cmd/event/testdata/golden/schema_im_chat_updated_text.golden @@ -0,0 +1,337 @@ +Key: im.chat.updated_v1 +Description: Triggered after chat settings (owner, avatar, name, permissions, etc.) are updated +Event: im.chat.updated_v1 + +Required Scopes: + - im:chat:read + +Required Console Events (must be enabled in developer console): + - im.chat.updated_v1 + +Output Schema: + { + "description": "飞书事件", + "properties": { + "event": { + "properties": { + "after_change": { + "properties": { + "add_member_permission": { + "type": "string" + }, + "at_all_permission": { + "type": "string" + }, + "avatar": { + "type": "string" + }, + "description": { + "type": "string" + }, + "edit_permission": { + "type": "string" + }, + "group_message_type": { + "type": "string" + }, + "i18n_names": { + "properties": { + "en_us": { + "type": "string" + }, + "ja_jp": { + "type": "string" + }, + "zh_cn": { + "type": "string" + } + }, + "type": "object" + }, + "join_message_visibility": { + "type": "string" + }, + "labels": { + "items": { + "type": "string" + }, + "type": "array" + }, + "leave_message_visibility": { + "type": "string" + }, + "membership_approval": { + "type": "string" + }, + "moderation_permission": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owner_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + }, + "restricted_mode_setting": { + "properties": { + "download_has_permission_setting": { + "type": "string" + }, + "message_has_permission_setting": { + "type": "string" + }, + "screenshot_has_permission_setting": { + "type": "string" + }, + "status": { + "type": "boolean" + } + }, + "type": "object" + }, + "share_card_permission": { + "type": "string" + } + }, + "type": "object" + }, + "before_change": { + "properties": { + "add_member_permission": { + "type": "string" + }, + "at_all_permission": { + "type": "string" + }, + "avatar": { + "type": "string" + }, + "description": { + "type": "string" + }, + "edit_permission": { + "type": "string" + }, + "group_message_type": { + "type": "string" + }, + "i18n_names": { + "properties": { + "en_us": { + "type": "string" + }, + "ja_jp": { + "type": "string" + }, + "zh_cn": { + "type": "string" + } + }, + "type": "object" + }, + "join_message_visibility": { + "type": "string" + }, + "labels": { + "items": { + "type": "string" + }, + "type": "array" + }, + "leave_message_visibility": { + "type": "string" + }, + "membership_approval": { + "type": "string" + }, + "moderation_permission": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owner_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + }, + "restricted_mode_setting": { + "properties": { + "download_has_permission_setting": { + "type": "string" + }, + "message_has_permission_setting": { + "type": "string" + }, + "screenshot_has_permission_setting": { + "type": "string" + }, + "status": { + "type": "boolean" + } + }, + "type": "object" + }, + "share_card_permission": { + "type": "string" + } + }, + "type": "object" + }, + "chat_id": { + "format": "chat_id", + "type": "string" + }, + "external": { + "type": "boolean" + }, + "moderator_list": { + "properties": { + "added_member_list": { + "items": { + "properties": { + "tenant_key": { + "type": "string" + }, + "user_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "type": "array" + }, + "removed_member_list": { + "items": { + "properties": { + "tenant_key": { + "type": "string" + }, + "user_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + }, + "operator_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + }, + "operator_tenant_key": { + "type": "string" + } + }, + "type": "object" + }, + "header": { + "description": "事件头,所有事件结构一致", + "properties": { + "app_id": { + "description": "接收事件的应用 ID", + "type": "string" + }, + "create_time": { + "description": "事件创建时间,毫秒时间戳字符串", + "type": "string" + }, + "event_id": { + "description": "事件唯一 ID", + "type": "string" + }, + "event_type": { + "description": "事件类型,用于路由", + "type": "string" + }, + "tenant_key": { + "description": "租户唯一标识", + "type": "string" + }, + "token": { + "description": "回调校验 token", + "type": "string" + } + }, + "type": "object" + }, + "schema": { + "description": "飞书事件协议版本", + "enum": [ + "2.0" + ], + "type": "string" + } + }, + "type": "object" + } diff --git a/cmd/event/testdata/golden/schema_im_message_receive_json.golden b/cmd/event/testdata/golden/schema_im_message_receive_json.golden new file mode 100644 index 0000000000..d95c198289 --- /dev/null +++ b/cmd/event/testdata/golden/schema_im_message_receive_json.golden @@ -0,0 +1,130 @@ +{ + "key": "im.message.receive_v1", + "display_name": "Receive message", + "description": "Receive IM messages", + "event_type": "im.message.receive_v1", + "subscription_type": "event", + "schema": { + "custom": {} + }, + "scopes": [ + "im:message.p2p_msg:readonly" + ], + "auth_types": [ + "bot" + ], + "required_console_events": [ + "im.message.receive_v1" + ], + "buffer_size": 100, + "workers": 1, + "resolved_output_schema": { + "type": "object", + "properties": { + "chat_id": { + "type": "string", + "description": "Chat/conversation ID; prefixed with oc_", + "format": "chat_id" + }, + "chat_type": { + "type": "string", + "description": "Conversation type", + "enum": [ + "p2p", + "group" + ] + }, + "content": { + "type": "string", + "description": "Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text." + }, + "create_time": { + "type": "string", + "description": "Message creation time (ms timestamp string)", + "format": "timestamp_ms" + }, + "event_id": { + "type": "string", + "description": "Event delivery ID. Do not use as the message deduplication key; use message_id instead." + }, + "id": { + "type": "string", + "description": "Message ID (legacy alias of message_id, kept for compatibility)", + "format": "message_id" + }, + "mentions": { + "type": "array", + "description": "Compact mentions aligned with im +messages-mget", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Mentioned user open_id; prefixed with ou_", + "format": "open_id" + }, + "key": { + "type": "string", + "description": "Mention placeholder key, for example @_user_1" + }, + "name": { + "type": "string", + "description": "Mentioned display name" + } + } + } + }, + "message_id": { + "type": "string", + "description": "Message ID; prefixed with om_. Recommended idempotency key for im.message.receive_v1 consumers.", + "format": "message_id" + }, + "message_type": { + "type": "string", + "description": "Message type" + }, + "reply_to": { + "type": "string", + "description": "Parent message ID of the direct reply context, when present", + "format": "message_id" + }, + "root_id": { + "type": "string", + "description": "Root message ID of the reply/thread context, when present", + "format": "message_id" + }, + "sender_id": { + "type": "string", + "description": "Sender open_id; prefixed with ou_", + "format": "open_id" + }, + "sender_type": { + "type": "string", + "description": "Sender type", + "enum": [ + "user", + "bot" + ] + }, + "thread_id": { + "type": "string", + "description": "Thread ID, when present" + }, + "timestamp": { + "type": "string", + "description": "Event delivery time (ms timestamp string); prefers header.create_time", + "format": "timestamp_ms" + }, + "type": { + "type": "string", + "description": "Event type; always im.message.receive_v1" + }, + "update_time": { + "type": "string", + "description": "Message update time (ms timestamp string); emitted only when different from create_time", + "format": "timestamp_ms" + } + } + }, + "jq_root_path": "." +} diff --git a/cmd/event/testdata/golden/schema_im_message_receive_text.golden b/cmd/event/testdata/golden/schema_im_message_receive_text.golden new file mode 100644 index 0000000000..b22ad0660b --- /dev/null +++ b/cmd/event/testdata/golden/schema_im_message_receive_text.golden @@ -0,0 +1,119 @@ +Key: im.message.receive_v1 +Description: Receive IM messages +Event: im.message.receive_v1 + +Required Scopes: + - im:message.p2p_msg:readonly + +Required Console Events (must be enabled in developer console): + - im.message.receive_v1 + +Output Schema: + { + "type": "object", + "properties": { + "chat_id": { + "type": "string", + "description": "Chat/conversation ID; prefixed with oc_", + "format": "chat_id" + }, + "chat_type": { + "type": "string", + "description": "Conversation type", + "enum": [ + "p2p", + "group" + ] + }, + "content": { + "type": "string", + "description": "Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text." + }, + "create_time": { + "type": "string", + "description": "Message creation time (ms timestamp string)", + "format": "timestamp_ms" + }, + "event_id": { + "type": "string", + "description": "Event delivery ID. Do not use as the message deduplication key; use message_id instead." + }, + "id": { + "type": "string", + "description": "Message ID (legacy alias of message_id, kept for compatibility)", + "format": "message_id" + }, + "mentions": { + "type": "array", + "description": "Compact mentions aligned with im +messages-mget", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Mentioned user open_id; prefixed with ou_", + "format": "open_id" + }, + "key": { + "type": "string", + "description": "Mention placeholder key, for example @_user_1" + }, + "name": { + "type": "string", + "description": "Mentioned display name" + } + } + } + }, + "message_id": { + "type": "string", + "description": "Message ID; prefixed with om_. Recommended idempotency key for im.message.receive_v1 consumers.", + "format": "message_id" + }, + "message_type": { + "type": "string", + "description": "Message type" + }, + "reply_to": { + "type": "string", + "description": "Parent message ID of the direct reply context, when present", + "format": "message_id" + }, + "root_id": { + "type": "string", + "description": "Root message ID of the reply/thread context, when present", + "format": "message_id" + }, + "sender_id": { + "type": "string", + "description": "Sender open_id; prefixed with ou_", + "format": "open_id" + }, + "sender_type": { + "type": "string", + "description": "Sender type", + "enum": [ + "user", + "bot" + ] + }, + "thread_id": { + "type": "string", + "description": "Thread ID, when present" + }, + "timestamp": { + "type": "string", + "description": "Event delivery time (ms timestamp string); prefers header.create_time", + "format": "timestamp_ms" + }, + "type": { + "type": "string", + "description": "Event type; always im.message.receive_v1" + }, + "update_time": { + "type": "string", + "description": "Message update time (ms timestamp string); emitted only when different from create_time", + "format": "timestamp_ms" + } + } + } From e3b39fad1d146bf4882b4dd9374904b7270786a5 Mon Sep 17 00:00:00 2001 From: leave330 Date: Fri, 31 Jul 2026 22:46:13 +0800 Subject: [PATCH 03/61] test(event): gate list and schema JSON against runtime-field leaks --- cmd/event/list.go | 16 +++-- cmd/event/render_contract_test.go | 98 +++++++++++++++++++++++++++++++ cmd/event/schema.go | 16 +++-- 3 files changed, 118 insertions(+), 12 deletions(-) create mode 100644 cmd/event/render_contract_test.go diff --git a/cmd/event/list.go b/cmd/event/list.go index de2a95720d..6ab59d0411 100644 --- a/cmd/event/list.go +++ b/cmd/event/list.go @@ -104,18 +104,22 @@ func runList(f *cmdutil.Factory, asJSON bool) error { return nil } +// listRow is the JSON shape of one `event list --json` row. It is a named +// type (not a function-local literal) so the render contract test can walk +// its fields and reject accidental additions to the public output. +type listRow struct { + *eventlib.KeyDefinition + ResolvedSchema json.RawMessage `json:"resolved_output_schema,omitempty"` +} + func writeListJSON(f *cmdutil.Factory, all []*eventlib.KeyDefinition) error { - type row struct { - *eventlib.KeyDefinition - ResolvedSchema json.RawMessage `json:"resolved_output_schema,omitempty"` - } - rows := make([]row, len(all)) + rows := make([]listRow, len(all)) for i, def := range all { resolved, _, err := resolveSchemaJSON(def) if err != nil { return err } - rows[i] = row{KeyDefinition: def, ResolvedSchema: resolved} + rows[i] = listRow{KeyDefinition: def, ResolvedSchema: resolved} } output.PrintJson(f.IOStreams.Out, rows) return nil diff --git a/cmd/event/render_contract_test.go b/cmd/event/render_contract_test.go new file mode 100644 index 0000000000..d357eefd48 --- /dev/null +++ b/cmd/event/render_contract_test.go @@ -0,0 +1,98 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package event + +import ( + "reflect" + "strings" + "testing" +) + +// renderedDeclarationFields lists every JSON field the list/schema commands +// are allowed to expose, each with the reason it belongs to the public +// contract. Golden files pin today's bytes; this gate protects tomorrow: a +// field added to the rendered structs (or promoted through the embedded +// definition) must either appear here deliberately or be tagged `json:"-"`. +var renderedDeclarationFields = map[string]string{ + "key": "stable identifier agents subscribe by", + "display_name": "human-readable name for pickers", + "description": "what the event means", + "event_type": "upstream event type behind this key", + "subscription_type": "which console ledger the precheck reads", + "params": "declared consume parameters", + "schema": "declared schema source (native/custom markers)", + "scopes": "OAuth scopes required to consume", + "auth_types": "identities the key accepts", + "required_console_events": "console switches that must be enabled", + "buffer_size": "delivery buffer size after normalization", + "workers": "worker count after normalization", + "single_consumer": "whether a second consumer is rejected", + "resolved_output_schema": "fully resolved JSON schema of stdout events", + "jq_root_path": "schema command only: jq root for consuming stdout", +} + +// TestRenderContract_NoRuntimeFieldLeaksIntoJSON walks both rendered shapes, +// following embedded struct promotion, and fails on any exported member that +// is neither allowlisted nor explicitly excluded from JSON. +func TestRenderContract_NoRuntimeFieldLeaksIntoJSON(t *testing.T) { + emitted := map[string]bool{} + for _, typ := range []reflect.Type{ + reflect.TypeFor[listRow](), + reflect.TypeFor[schemaPayload](), + } { + walkRenderedFields(t, typ, emitted) + } + + if len(emitted) == 0 { + t.Fatal("no rendered fields were visited; the gate scanned nothing") + } + // The embedded definition is where leaks would hide: prove promotion was + // actually followed by requiring fields that only exist on it. + for _, sentinel := range []string{"key", "event_type", "resolved_output_schema"} { + if !emitted[sentinel] { + t.Fatalf("field %q was not visited; embedded promotion is no longer being walked", sentinel) + } + } + for name := range renderedDeclarationFields { + if !emitted[name] { + t.Errorf("allowlist entry %q is stale: no rendered struct emits it", name) + } + } +} + +func walkRenderedFields(t *testing.T, typ reflect.Type, emitted map[string]bool) { + t.Helper() + if typ.Kind() == reflect.Pointer { + typ = typ.Elem() + } + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + if !field.IsExported() { + continue + } + if field.Anonymous { + ft := field.Type + if ft.Kind() == reflect.Pointer { + ft = ft.Elem() + } + if ft.Kind() == reflect.Struct { + walkRenderedFields(t, ft, emitted) + continue + } + } + tag := field.Tag.Get("json") + if tag == "-" { + continue + } + if tag == "" { + t.Errorf("%s.%s has no json tag and would render under its Go name; tag it or exclude it with json:\"-\"", typ.Name(), field.Name) + continue + } + name, _, _ := strings.Cut(tag, ",") + if _, ok := renderedDeclarationFields[name]; !ok { + t.Errorf("%s.%s renders JSON field %q that is not in the declared output contract; add it deliberately or exclude it", typ.Name(), field.Name, name) + } + emitted[name] = true + } +} diff --git a/cmd/event/schema.go b/cmd/event/schema.go index c078767f78..18fb1c3bd0 100644 --- a/cmd/event/schema.go +++ b/cmd/event/schema.go @@ -202,13 +202,17 @@ func printIndentedJSON(out io.Writer, raw json.RawMessage) { fmt.Fprintf(out, " %s\n", string(formatted)) } +// schemaPayload is the JSON shape of `event schema --json`. It is a named +// type (not a function-local literal) so the render contract test can walk +// its fields and reject accidental additions to the public output. +type schemaPayload struct { + *eventlib.KeyDefinition + ResolvedSchema json.RawMessage `json:"resolved_output_schema,omitempty"` + JQRootPath string `json:"jq_root_path,omitempty"` +} + // writeSchemaJSON emits the EventKey definition plus resolved schema; jq_root_path tells callers whether fields live at `.` or `.event`. func writeSchemaJSON(f *cmdutil.Factory, def *eventlib.KeyDefinition) error { - type payload struct { - *eventlib.KeyDefinition - ResolvedSchema json.RawMessage `json:"resolved_output_schema,omitempty"` - JQRootPath string `json:"jq_root_path,omitempty"` - } resolved, _, err := resolveSchemaJSON(def) if err != nil { return err @@ -222,7 +226,7 @@ func writeSchemaJSON(f *cmdutil.Factory, def *eventlib.KeyDefinition) error { jqRootPath = ".event" } } - output.PrintJson(f.IOStreams.Out, payload{ + output.PrintJson(f.IOStreams.Out, schemaPayload{ KeyDefinition: def, ResolvedSchema: resolved, JQRootPath: jqRootPath, From a930c0d398d6a3240f426f223f23fecff4cb9737 Mon Sep 17 00:00:00 2001 From: leave330 Date: Fri, 31 Jul 2026 22:52:11 +0800 Subject: [PATCH 04/61] test(event): snapshot per-key processed stdout as the compat baseline --- events/output_baseline_test.go | 458 +++++++++++++++++++++++++++ events/testdata/output_baseline.json | 177 +++++++++++ 2 files changed, 635 insertions(+) create mode 100644 events/output_baseline_test.go create mode 100644 events/testdata/output_baseline.json diff --git a/events/output_baseline_test.go b/events/output_baseline_test.go new file mode 100644 index 0000000000..c35ed0e372 --- /dev/null +++ b/events/output_baseline_test.go @@ -0,0 +1,458 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package events_test + +import ( + "bytes" + "context" + "encoding/json" + "flag" + "maps" + "os" + "path/filepath" + "sort" + "testing" + "time" + + event "github.com/larksuite/cli/internal/event" + + _ "github.com/larksuite/cli/events" +) + +var updateBaseline = flag.Bool("update-baseline", false, + "rewrite testdata/output_baseline.json with the current Processed EventKey outputs") + +// TestMain pins the process timezone to UTC before any test runs. Several +// Process handlers format timestamps in the machine's local timezone +// (e.g. meeting start/end times, recording event times), so without the pin +// the snapshot would drift between machines in different timezones. +func TestMain(m *testing.M) { + time.Local = time.UTC + os.Exit(m.Run()) +} + +const baselineSnapshotPath = "testdata/output_baseline.json" + +// wantProcessedKeys freezes how many registered EventKeys define Process +// (im 2, vc 7, minutes 1, application 1, approval 2). The count assertion +// keeps this test honest: if a Processed key is added or removed, the covered +// output surface changed and the baseline would silently widen or narrow +// without it. Update the count, the fixtures, and the snapshot together, +// deliberately. +const wantProcessedKeys = 13 + +const ( + baselineEventID = "evt-baseline-001" + baselineCreateTime = "1700000000000" // 2023-11-14T22:13:20Z in milliseconds +) + +// baselineFixture holds the minimal well-formed inputs for one Processed +// EventKey: the business body placed under "event" in the V2 envelope, plus +// any extra header fields the handler reads beyond event_id / event_type / +// create_time. Every fixture must drive Process down its success path — no +// drop, no malformed-payload passthrough. +type baselineFixture struct { + extraHeader map[string]string + eventBody string +} + +// baselineFixtures maps every Processed EventKey to its synthetic input. +// Field values are fixed constants so the resulting output is byte-stable. +var baselineFixtures = map[string]baselineFixture{ + "application.bot.menu_v6": { + extraHeader: map[string]string{ + "app_id": "cli-baseline-app", + "tenant_key": "tenant-baseline", + }, + // 10-digit seconds timestamp: the handler normalizes it to milliseconds. + eventBody: `{ + "event_key": "baseline_menu_key", + "timestamp": 1700000000, + "operator": { + "operator_id": { + "open_id": "ou-baseline-operator", + "union_id": "on-baseline-operator", + "user_id": "user-baseline-operator" + }, + "operator_name": "Baseline Operator" + } + }`, + }, + "approval.instance.status_changed_v4": { + eventBody: `{ + "approval_code": "approval-code-baseline", + "instance_code": "instance-code-baseline", + "external_id": "external-id-baseline", + "status": "APPROVED", + "operate_time": "1700000000000", + "start_user": { + "open_id": "ou-baseline-starter", + "union_id": "on-baseline-starter", + "user_id": "user-baseline-starter" + } + }`, + }, + "approval.task.status_changed_v4": { + eventBody: `{ + "approval_code": "approval-code-baseline", + "instance_code": "instance-code-baseline", + "task_id": "task-id-baseline", + "external_id": "external-id-baseline", + "task_external_id": "task-external-id-baseline", + "status": "APPROVED", + "operate_time": "1700000000000", + "assigned_user": { + "open_id": "ou-baseline-assignee", + "union_id": "on-baseline-assignee", + "user_id": "user-baseline-assignee" + } + }`, + }, + // The card handler fetches the card content through the API client using + // context.open_message_id; the fake client below serves that request. + "card.action.trigger": { + eventBody: `{ + "operator": {"open_id": "ou-baseline-operator"}, + "token": "card-token-baseline", + "host": "im_message", + "action": { + "tag": "button", + "value": {"key": "baseline"}, + "name": "baseline_button", + "form_value": {"field": "value"}, + "input_value": "baseline input", + "option": "opt-1", + "options": ["opt-1", "opt-2"], + "checked": true, + "timezone": "Asia/Shanghai" + }, + "context": { + "open_message_id": "om-baseline-card", + "open_chat_id": "oc-baseline-chat" + } + }`, + }, + // update_time differs from create_time so the handler emits both; the + // mention placeholder in content exercises mention rendering. + "im.message.receive_v1": { + eventBody: `{ + "sender": { + "sender_type": "user", + "sender_id": {"open_id": "ou-baseline-sender"} + }, + "message": { + "message_id": "om-baseline-msg", + "root_id": "om-baseline-root", + "parent_id": "om-baseline-parent", + "thread_id": "omt-baseline-thread", + "chat_id": "oc-baseline-chat", + "chat_type": "p2p", + "message_type": "text", + "create_time": "1699999999000", + "update_time": "1700000000500", + "content": "{\"text\":\"hello @_user_1\"}", + "mentions": [ + { + "key": "@_user_1", + "id": {"open_id": "ou-baseline-mention"}, + "name": "Baseline User" + } + ] + } + }`, + }, + // The minutes handler enriches the output with the minute title via the + // API client; the fake client answers with a non-empty title on the first + // call so no retry attempt is made. + "minutes.minute.generated_v1": { + eventBody: `{ + "minute_token": "minute-token-baseline", + "minute_source": { + "source_type": "meeting", + "source_entity_id": "meeting-entity-baseline" + } + }`, + }, + "vc.meeting.participant_meeting_started_v1": { + eventBody: `{ + "meeting": { + "id": "meeting-id-baseline", + "topic": "Baseline meeting", + "meeting_no": "123456789", + "start_time": "1700000000", + "calendar_event_id": "calendar-event-baseline" + } + }`, + }, + "vc.meeting.participant_meeting_joined_v1": { + eventBody: `{ + "meeting": { + "id": "meeting-id-baseline", + "topic": "Baseline meeting", + "meeting_no": "123456789", + "start_time": "1700000000", + "calendar_event_id": "calendar-event-baseline" + } + }`, + }, + "vc.meeting.participant_meeting_ended_v1": { + eventBody: `{ + "meeting": { + "id": "meeting-id-baseline", + "topic": "Baseline meeting", + "meeting_no": "123456789", + "start_time": "1700000000", + "end_time": "1700000600", + "calendar_event_id": "calendar-event-baseline" + } + }`, + }, + // The note handler enriches the output with document tokens via the API + // client; the fake client answers with both artifacts on the first call + // so no retry attempt is made. + "vc.note.generated_v1": { + eventBody: `{"note_id": "note-id-baseline"}`, + }, + // Recording handlers only emit events whose source is recording_bean; + // anything else is dropped, which would break the success-path contract. + "vc.recording.recording_started_v1": { + eventBody: `{ + "unique_key": "recording-key-baseline", + "source": "recording_bean" + }`, + }, + "vc.recording.recording_transcript_generated_v1": { + eventBody: `{ + "unique_key": "recording-key-baseline", + "source": "recording_bean", + "transcript_items": [ + { + "speaker": {"user_name": "Baseline Speaker"}, + "text": "baseline transcript text", + "start_time_ms": "1700000000000", + "end_time_ms": "1700000001000", + "sentence_id": "sentence-baseline-1" + } + ] + }`, + }, + "vc.recording.recording_ended_v1": { + eventBody: `{ + "unique_key": "recording-key-baseline", + "source": "recording_bean" + }`, + }, +} + +// baselineAPIResponses maps request paths to canned success responses for the +// handlers that call the API during Process. Every response satisfies the +// handler on the first call, so retry loops never engage and no real network +// or credentials are involved. +var baselineAPIResponses = map[string]string{ + "/open-apis/im/v1/messages/om-baseline-card?card_msg_content_type=user_card_content": `{ + "code": 0, + "msg": "success", + "data": { + "items": [ + {"body": {"content": "{\"header\":{\"title\":{\"tag\":\"plain_text\",\"content\":\"Baseline card\"}}}"}} + ] + } + }`, + "/open-apis/vc/v1/notes/note-id-baseline": `{ + "code": 0, + "msg": "success", + "data": { + "note": { + "artifacts": [ + {"artifact_type": 1, "doc_token": "note-doc-token-baseline"}, + {"artifact_type": 2, "doc_token": "verbatim-doc-token-baseline"} + ], + "note_source": { + "source_type": "meeting", + "source_entity_id": "meeting-entity-baseline" + } + } + } + }`, + "/open-apis/minutes/v1/minutes/minute-token-baseline": `{ + "code": 0, + "msg": "success", + "data": { + "minute": {"title": "Baseline minute title"} + } + }`, +} + +// baselineAPIClient serves the canned responses above. An unexpected request +// path fails the test immediately instead of returning an error, because +// several handlers swallow API errors (or retry with delays) and would +// silently produce a degraded output that gets frozen into the baseline. +type baselineAPIClient struct { + t *testing.T +} + +func (c *baselineAPIClient) CallAPI(_ context.Context, method, path string, _ any) (json.RawMessage, error) { + c.t.Helper() + resp, ok := baselineAPIResponses[path] + if !ok { + c.t.Fatalf("unexpected API call during Process: %s %s — add a canned response to baselineAPIResponses", method, path) + } + return json.RawMessage(resp), nil +} + +// TestProcessedOutputBaseline runs every Processed EventKey against a fixed +// well-formed synthetic payload and compares the outputs with the frozen +// snapshot in testdata/output_baseline.json. Any change to what a Processed +// key writes to stdout for a well-formed event shows up here as a named, +// per-key diff. Run with -update-baseline to accept an intentional change. +func TestProcessedOutputBaseline(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + rt := &baselineAPIClient{t: t} + got := map[string]json.RawMessage{} + seenFixtures := map[string]bool{} + + for _, def := range event.ListAll() { + if def.Process == nil { + continue + } + fx, ok := baselineFixtures[def.Key] + if !ok { + t.Fatalf("Processed EventKey %q has no baseline fixture; add one to baselineFixtures, bump wantProcessedKeys, and regenerate with -update-baseline", def.Key) + } + seenFixtures[def.Key] = true + + payload := buildBaselineEnvelope(t, def.EventType, fx) + raw := &event.RawEvent{ + EventID: baselineEventID, + EventType: def.EventType, + SourceTime: baselineCreateTime, + Payload: payload, + Timestamp: time.Unix(1700000000, 0).UTC(), + } + + out, err := def.Process(context.Background(), rt, raw, nil) + if err != nil { + t.Fatalf("%s: Process returned error on well-formed payload: %v", def.Key, err) + } + if out == nil { + t.Fatalf("%s: Process dropped a well-formed payload; the fixture must exercise the success path", def.Key) + } + if bytes.Equal(compactJSON(t, def.Key, out), compactJSON(t, def.Key, payload)) { + t.Fatalf("%s: Process returned the input unchanged; the fixture must exercise the success path, not the malformed-payload passthrough", def.Key) + } + got[def.Key] = out + } + + if len(got) != wantProcessedKeys { + t.Fatalf("processed %d EventKeys, want exactly %d; a Processed key was added or removed — update baselineFixtures, wantProcessedKeys, and the snapshot together (keys run: %v)", + len(got), wantProcessedKeys, sortedKeys(got)) + } + for key := range baselineFixtures { + if !seenFixtures[key] { + t.Fatalf("baseline fixture %q matches no registered Processed EventKey; remove it or fix the key name", key) + } + } + + if *updateBaseline { + writeBaselineSnapshot(t, got) + return + } + compareBaselineSnapshot(t, got) +} + +// buildBaselineEnvelope wraps a fixture body in the standard V2 event +// envelope with fixed header values. +func buildBaselineEnvelope(t *testing.T, eventType string, fx baselineFixture) json.RawMessage { + t.Helper() + header := map[string]string{ + "event_id": baselineEventID, + "event_type": eventType, + "create_time": baselineCreateTime, + } + maps.Copy(header, fx.extraHeader) + headerJSON, err := json.Marshal(header) + if err != nil { + t.Fatalf("marshal envelope header: %v", err) + } + envelope := map[string]json.RawMessage{ + "schema": json.RawMessage(`"2.0"`), + "header": headerJSON, + "event": json.RawMessage(fx.eventBody), + } + payload, err := json.Marshal(envelope) + if err != nil { + t.Fatalf("marshal envelope for %s: %v", eventType, err) + } + return payload +} + +func writeBaselineSnapshot(t *testing.T, got map[string]json.RawMessage) { + t.Helper() + // MarshalIndent sorts map keys, so the snapshot is deterministic. + data, err := json.MarshalIndent(got, "", " ") + if err != nil { + t.Fatalf("marshal snapshot: %v", err) + } + data = append(data, '\n') + if err := os.MkdirAll(filepath.Dir(baselineSnapshotPath), 0o755); err != nil { + t.Fatalf("create testdata dir: %v", err) + } + if err := os.WriteFile(baselineSnapshotPath, data, 0o644); err != nil { + t.Fatalf("write snapshot: %v", err) + } + t.Logf("baseline snapshot rewritten: %s (%d keys)", baselineSnapshotPath, len(got)) +} + +func compareBaselineSnapshot(t *testing.T, got map[string]json.RawMessage) { + t.Helper() + data, err := os.ReadFile(baselineSnapshotPath) + if os.IsNotExist(err) { + t.Fatalf("baseline snapshot %s not found; generate it with: go test ./events/ -run TestProcessedOutput -update-baseline", baselineSnapshotPath) + } + if err != nil { + t.Fatalf("read snapshot: %v", err) + } + var want map[string]json.RawMessage + if err := json.Unmarshal(data, &want); err != nil { + t.Fatalf("snapshot %s is not valid JSON: %v", baselineSnapshotPath, err) + } + + for _, key := range sortedKeys(want) { + if _, ok := got[key]; !ok { + t.Errorf("%s: present in snapshot but produced no output this run; if the key was removed on purpose, regenerate with -update-baseline", key) + } + } + for _, key := range sortedKeys(got) { + wantOut, ok := want[key] + if !ok { + t.Errorf("%s: produced output but missing from snapshot; regenerate with -update-baseline", key) + continue + } + gotC := compactJSON(t, key, got[key]) + wantC := compactJSON(t, key, wantOut) + if !bytes.Equal(gotC, wantC) { + t.Errorf("%s: Processed output drifted from baseline\n got: %s\n want: %s\nIf this change is intentional, regenerate with -update-baseline", key, gotC, wantC) + } + } +} + +// compactJSON canonicalizes whitespace so comparisons are content-only. +func compactJSON(t *testing.T, key string, raw json.RawMessage) []byte { + t.Helper() + var buf bytes.Buffer + if err := json.Compact(&buf, raw); err != nil { + t.Fatalf("%s: output is not valid JSON: %v\nraw=%s", key, err, string(raw)) + } + return buf.Bytes() +} + +func sortedKeys(m map[string]json.RawMessage) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/events/testdata/output_baseline.json b/events/testdata/output_baseline.json new file mode 100644 index 0000000000..1d80d21ea3 --- /dev/null +++ b/events/testdata/output_baseline.json @@ -0,0 +1,177 @@ +{ + "application.bot.menu_v6": { + "type": "application.bot.menu_v6", + "event_id": "evt-baseline-001", + "timestamp": "1700000000000", + "app_id": "cli-baseline-app", + "tenant_key": "tenant-baseline", + "event_key": "baseline_menu_key", + "menu_timestamp": "1700000000000", + "operator_id": "ou-baseline-operator", + "operator_open_id": "ou-baseline-operator", + "operator_union_id": "on-baseline-operator", + "operator_user_id": "user-baseline-operator", + "operator_name": "Baseline Operator" + }, + "approval.instance.status_changed_v4": { + "type": "approval.instance.status_changed_v4", + "event_id": "evt-baseline-001", + "timestamp": "1700000000000", + "approval_code": "approval-code-baseline", + "instance_code": "instance-code-baseline", + "external_id": "external-id-baseline", + "status": "APPROVED", + "operate_time": "1700000000000", + "start_user": { + "open_id": "ou-baseline-starter", + "union_id": "on-baseline-starter", + "user_id": "user-baseline-starter" + } + }, + "approval.task.status_changed_v4": { + "type": "approval.task.status_changed_v4", + "event_id": "evt-baseline-001", + "timestamp": "1700000000000", + "approval_code": "approval-code-baseline", + "instance_code": "instance-code-baseline", + "task_id": "task-id-baseline", + "external_id": "external-id-baseline", + "task_external_id": "task-external-id-baseline", + "assigned_user": { + "open_id": "ou-baseline-assignee", + "union_id": "on-baseline-assignee", + "user_id": "user-baseline-assignee" + }, + "status": "APPROVED", + "operate_time": "1700000000000" + }, + "card.action.trigger": { + "type": "card.action.trigger", + "event_id": "evt-baseline-001", + "timestamp": "1700000000000", + "operator_id": "ou-baseline-operator", + "message_id": "om-baseline-card", + "chat_id": "oc-baseline-chat", + "host": "im_message", + "token": "card-token-baseline", + "action_tag": "button", + "action_value": "{\"key\":\"baseline\"}", + "action_name": "baseline_button", + "form_value": "{\"field\":\"value\"}", + "input_value": "baseline input", + "option": "opt-1", + "options": "opt-1,opt-2", + "checked": true, + "timezone": "Asia/Shanghai", + "card_content": "{\"header\":{\"title\":{\"tag\":\"plain_text\",\"content\":\"Baseline card\"}}}" + }, + "im.message.receive_v1": { + "type": "im.message.receive_v1", + "event_id": "evt-baseline-001", + "timestamp": "1700000000000", + "id": "om-baseline-msg", + "message_id": "om-baseline-msg", + "create_time": "1699999999000", + "update_time": "1700000000500", + "chat_id": "oc-baseline-chat", + "chat_type": "p2p", + "message_type": "text", + "sender_id": "ou-baseline-sender", + "sender_type": "user", + "root_id": "om-baseline-root", + "thread_id": "omt-baseline-thread", + "reply_to": "om-baseline-parent", + "content": "hello @Baseline User", + "mentions": [ + { + "key": "@_user_1", + "id": "ou-baseline-mention", + "name": "Baseline User" + } + ] + }, + "minutes.minute.generated_v1": { + "type": "minutes.minute.generated_v1", + "event_id": "evt-baseline-001", + "timestamp": "1700000000000", + "minute_token": "minute-token-baseline", + "title": "Baseline minute title", + "minute_source": { + "source_type": "meeting", + "source_entity_id": "meeting-entity-baseline" + } + }, + "vc.meeting.participant_meeting_ended_v1": { + "type": "vc.meeting.participant_meeting_ended_v1", + "event_id": "evt-baseline-001", + "timestamp": "1700000000000", + "meeting_id": "meeting-id-baseline", + "topic": "Baseline meeting", + "meeting_no": "123456789", + "start_time": "2023-11-14T22:13:20Z", + "end_time": "2023-11-14T22:23:20Z", + "calendar_event_id": "calendar-event-baseline" + }, + "vc.meeting.participant_meeting_joined_v1": { + "type": "vc.meeting.participant_meeting_joined_v1", + "event_id": "evt-baseline-001", + "timestamp": "1700000000000", + "meeting_id": "meeting-id-baseline", + "topic": "Baseline meeting", + "meeting_no": "123456789", + "start_time": "2023-11-14T22:13:20Z", + "calendar_event_id": "calendar-event-baseline" + }, + "vc.meeting.participant_meeting_started_v1": { + "type": "vc.meeting.participant_meeting_started_v1", + "event_id": "evt-baseline-001", + "timestamp": "1700000000000", + "meeting_id": "meeting-id-baseline", + "topic": "Baseline meeting", + "meeting_no": "123456789", + "start_time": "2023-11-14T22:13:20Z", + "calendar_event_id": "calendar-event-baseline" + }, + "vc.note.generated_v1": { + "type": "vc.note.generated_v1", + "event_id": "evt-baseline-001", + "timestamp": "1700000000000", + "note_id": "note-id-baseline", + "note_token": "note-doc-token-baseline", + "verbatim_token": "verbatim-doc-token-baseline", + "note_source": { + "source_type": "meeting", + "source_entity_id": "meeting-entity-baseline" + } + }, + "vc.recording.recording_ended_v1": { + "type": "vc.recording.recording_ended_v1", + "event_id": "evt-baseline-001", + "event_time": "2023-11-14T22:13:20Z", + "unique_key": "recording-key-baseline", + "source": "recording_bean" + }, + "vc.recording.recording_started_v1": { + "type": "vc.recording.recording_started_v1", + "event_id": "evt-baseline-001", + "event_time": "2023-11-14T22:13:20Z", + "unique_key": "recording-key-baseline", + "source": "recording_bean" + }, + "vc.recording.recording_transcript_generated_v1": { + "type": "vc.recording.recording_transcript_generated_v1", + "event_id": "evt-baseline-001", + "event_time": "2023-11-14T22:13:20Z", + "unique_key": "recording-key-baseline", + "source": "recording_bean", + "transcript_items": [ + { + "speaker_name": "Baseline Speaker", + "text": "baseline transcript text", + "start_time": "2023-11-14T22:13:20Z", + "end_time": "2023-11-14T22:13:21Z", + "sentence_id": "sentence-baseline-1" + } + ] + } +} From 20f9cfd3135b9b0c64a15213e8d1325145e82944 Mon Sep 17 00:00:00 2001 From: leave330 Date: Fri, 31 Jul 2026 22:54:26 +0800 Subject: [PATCH 05/61] test(event): pin the classic PreConsume first/last lifecycle on a real bus --- internal/event/preconsume_contract_test.go | 323 +++++++++++++++++++++ 1 file changed, 323 insertions(+) create mode 100644 internal/event/preconsume_contract_test.go diff --git a/internal/event/preconsume_contract_test.go b/internal/event/preconsume_contract_test.go new file mode 100644 index 0000000000..4f69a8972e --- /dev/null +++ b/internal/event/preconsume_contract_test.go @@ -0,0 +1,323 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//go:build !windows + +// PreConsume first/last lifecycle contract tests. +// +// These tests pin the setup/cleanup ownership semantics of the consume +// pipeline against a real in-process bus (fake transport on a temp socket): +// +// - PreConsume (setup) runs exactly once per key while at least one consumer +// stays connected — a duplicate setup would register a duplicate +// server-side subscription record. +// - cleanup runs at most once, and only when the exiting consumer is the +// LAST one for its key — an early cleanup would tear down a server-side +// subscription that other still-live consumers depend on. +// +// They are a frozen baseline: any refactor of the bus/consume lifecycle must +// keep these green (or consciously revisit the contract — see the note on +// TestPreConsumeContract_OwnerExitsFirst). +package event_test + +import ( + "bufio" + "bytes" + "context" + "fmt" + "io" + "log" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" + + "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/bus" + "github.com/larksuite/cli/internal/event/consume" + "github.com/larksuite/cli/internal/event/protocol" + "github.com/larksuite/cli/internal/event/source" + "github.com/larksuite/cli/internal/event/testutil" + "github.com/larksuite/cli/internal/event/transport" +) + +// contractAppSeq makes every test invocation use a distinct app id. The bus +// pins its per-app-id alive lock fd until process exit, so reusing an app id +// within one test process (e.g. under -count=2) would make the second bus +// conclude another bus is already running and refuse to start. +var contractAppSeq atomic.Int64 + +type preConsumeCounters struct { + setup atomic.Int64 + cleanup atomic.Int64 +} + +// registerContractKey registers a synthetic EventKey whose PreConsume counts +// setups. With withCleanup, PreConsume returns a closure that counts cleanups; +// without, it returns (nil, nil) — modeling keys whose server-side +// subscription is a durable relationship with deliberately no unsubscribe. +func registerContractKey(t *testing.T, key string, withCleanup bool) *preConsumeCounters { + t.Helper() + c := &preConsumeCounters{} + event.RegisterKey(event.KeyDefinition{ + Key: key, + EventType: key, + Schema: integNativeSchema(), + PreConsume: func(context.Context, event.APIClient, map[string]string) (func() error, error) { + c.setup.Add(1) + if !withCleanup { + return nil, nil + } + return func() error { + c.cleanup.Add(1) + return nil + }, nil + }, + }) + t.Cleanup(func() { event.UnregisterKeyForTest(key) }) + return c +} + +// startContractBus runs an in-process bus on a temp-dir socket with a mock +// source (so no real upstream connection is attempted) and returns the fake +// transport plus the unique app id consumers must use. +func startContractBus(t *testing.T) (*testutil.FakeTransport, string) { + t.Helper() + source.ResetForTest() + source.Register(&mockIntegSource{}) + + appID := fmt.Sprintf("preconsume-contract-%d-%d", os.Getpid(), contractAppSeq.Add(1)) + // Short-named temp dir instead of t.TempDir(): the long test names would + // push the unix socket path past the OS sun_path limit (~104 bytes on + // macOS), making bind fail with EINVAL. + sockDir, err := os.MkdirTemp("", "pcc-*") + if err != nil { + t.Fatalf("create socket dir: %v", err) + } + t.Cleanup(func() { os.RemoveAll(sockDir) }) + addr := filepath.Join(sockDir, "s") + tr := testutil.NewWrappedFake(transport.New(), addr) + logger := log.New(os.Stderr, "[contract-bus] ", log.LstdFlags) + b := bus.NewBus(appID, "test-secret", "", tr, logger) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + runBus(t, b, ctx) + // Registered after runBus so it fires before runBus's wait-for-exit cleanup. + t.Cleanup(cancel) + waitForBusReady(t, tr, addr) + return tr, appID +} + +// contractConsumer drives one consume.Run in a goroutine with its own +// cancellable context, so tests control exit order deterministically. +type contractConsumer struct { + name string + cancel context.CancelFunc + done chan error +} + +func startContractConsumer(t *testing.T, tr transport.IPC, appID, key, name string) *contractConsumer { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + h := &contractConsumer{name: name, cancel: cancel, done: make(chan error, 1)} + go func() { + h.done <- consume.Run(ctx, tr, appID, "", "", consume.Options{ + EventKey: key, + Params: map[string]string{}, + Quiet: true, + Out: io.Discard, + ErrOut: io.Discard, + }) + }() + return h +} + +// stop cancels the consumer's context and waits for consume.Run to return, +// i.e. its shutdown path (last-for-key check + possible cleanup) completed. +func (h *contractConsumer) stop(t *testing.T) { + t.Helper() + h.cancel() + select { + case err := <-h.done: + if err != nil { + t.Fatalf("consumer %s exited with error: %v", h.name, err) + } + case <-time.After(10 * time.Second): + t.Fatalf("consumer %s did not exit within 10s after cancel", h.name) + } +} + +func waitForState(t *testing.T, desc string, cond func() bool) { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + if cond() { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("timed out waiting for %s", desc) +} + +// busSubscriberCount asks the bus (status query round-trip) how many consumer +// connections its hub currently tracks. The count drops only after the bus +// fully processed a disconnect, so polling it makes exit ordering +// deterministic instead of sleep-based. +func busSubscriberCount(tr transport.IPC, addr string) (int, error) { + conn, err := tr.Dial(addr) + if err != nil { + return 0, err + } + defer conn.Close() + if err := conn.SetDeadline(time.Now().Add(2 * time.Second)); err != nil { + return 0, err + } + if err := protocol.Encode(conn, protocol.NewStatusQuery()); err != nil { + return 0, err + } + line, err := protocol.ReadFrame(bufio.NewReader(conn)) + if err != nil { + return 0, err + } + msg, err := protocol.Decode(bytes.TrimRight(line, "\n")) + if err != nil { + return 0, err + } + resp, ok := msg.(*protocol.StatusResponse) + if !ok { + return 0, fmt.Errorf("expected StatusResponse, got %T", msg) + } + return resp.ActiveConns, nil +} + +func waitForSubscriberCount(t *testing.T, tr transport.IPC, appID string, want int) { + t.Helper() + addr := tr.Address(appID) + waitForState(t, fmt.Sprintf("bus subscriber count == %d", want), func() bool { + n, err := busSubscriberCount(tr, addr) + return err == nil && n == want + }) +} + +// TestPreConsumeContract_NonOwnerExitsFirst: A connects first (runs setup, +// holds the cleanup closure), B joins, then B exits BEFORE A. B is not the +// last consumer for the key, so nothing may be cleaned up while A still +// depends on the server-side subscription. A then exits last and runs cleanup +// exactly once. +func TestPreConsumeContract_NonOwnerExitsFirst(t *testing.T) { + tr, appID := startContractBus(t) + const key = "contract.nonowner.v1" + counters := registerContractKey(t, key, true) + + a := startContractConsumer(t, tr, appID, key, "A") + waitForSubscriberCount(t, tr, appID, 1) + waitForState(t, "consumer A pre-consume setup", func() bool { return counters.setup.Load() == 1 }) + + // A is registered and set up, so B deterministically joins as non-first + // and never runs PreConsume (no duplicate server-side subscription). + b := startContractConsumer(t, tr, appID, key, "B") + waitForSubscriberCount(t, tr, appID, 2) + + // B exits while A is still connected: not last for the key, no cleanup. + b.stop(t) + // Wait until the bus fully processed B's disconnect before exiting A, + // so A's last-for-key check cannot race B's teardown. + waitForSubscriberCount(t, tr, appID, 1) + + // A exits last: it holds the cleanup closure and is last, cleanup runs once. + a.stop(t) + waitForSubscriberCount(t, tr, appID, 0) + + if got := counters.setup.Load(); got != 1 { + t.Errorf("setup ran %d times, want 1 (duplicate setup would register duplicate server-side subscriptions)", got) + } + if got := counters.cleanup.Load(); got != 1 { + t.Errorf("cleanup ran %d times, want 1 (last consumer must tear down the subscription exactly once)", got) + } +} + +// TestPreConsumeContract_OwnerExitsFirst: A connects first (runs setup, holds +// the cleanup closure), B joins, then A — the closure OWNER — exits first. +// +// This pins the CURRENT, KNOWN-LEAK semantics: cleanup ownership is not +// transferable. A skips cleanup because B is still connected (correct — B +// depends on the subscription), but when B later exits as the last consumer +// it has no closure to run, so the server-side subscription is never cleaned +// up. Total cleanups: 0. +// +// If the cleanup==0 assertion here turns red, the cleanup ownership semantics +// have been changed (e.g. handing the closure off to survivors). That may +// well be an improvement, but it alters observable lifecycle behavior for +// every EventKey — take it through design review first, then update this +// baseline deliberately. +func TestPreConsumeContract_OwnerExitsFirst(t *testing.T) { + tr, appID := startContractBus(t) + const key = "contract.owner.v1" + counters := registerContractKey(t, key, true) + + a := startContractConsumer(t, tr, appID, key, "A") + waitForSubscriberCount(t, tr, appID, 1) + waitForState(t, "consumer A pre-consume setup", func() bool { return counters.setup.Load() == 1 }) + + b := startContractConsumer(t, tr, appID, key, "B") + waitForSubscriberCount(t, tr, appID, 2) + + // Owner exits first: B is still connected, so A must NOT run cleanup — + // doing so would unsubscribe the server-side state B depends on. + a.stop(t) + waitForSubscriberCount(t, tr, appID, 1) + + // B exits last: it is last for the key, but it never ran PreConsume and + // holds no cleanup closure — nothing runs. This is the leak being pinned. + b.stop(t) + waitForSubscriberCount(t, tr, appID, 0) + + if got := counters.setup.Load(); got != 1 { + t.Errorf("setup ran %d times, want 1 (B joined as non-first and must not re-run setup)", got) + } + if got := counters.cleanup.Load(); got != 0 { + t.Errorf("cleanup ran %d times, want 0 — current contract leaks the server-side subscription when the setup owner exits before the last consumer; see the test comment before changing this", got) + } +} + +// TestPreConsumeContract_NoCleanupKey: the key's PreConsume returns (nil, nil) +// — setup establishes a durable server-side relationship that is deliberately +// never unsubscribed. Regardless of exit order, cleanup count stays 0, and +// setup re-runs each time a consumer becomes first for the key again. +func TestPreConsumeContract_NoCleanupKey(t *testing.T) { + tr, appID := startContractBus(t) + const key = "contract.nocleanup.v1" + counters := registerContractKey(t, key, false) + + // Round 1: non-owner exits first. + a := startContractConsumer(t, tr, appID, key, "A") + waitForSubscriberCount(t, tr, appID, 1) + waitForState(t, "consumer A pre-consume setup", func() bool { return counters.setup.Load() == 1 }) + b := startContractConsumer(t, tr, appID, key, "B") + waitForSubscriberCount(t, tr, appID, 2) + b.stop(t) + waitForSubscriberCount(t, tr, appID, 1) + a.stop(t) + waitForSubscriberCount(t, tr, appID, 0) + + // Round 2: owner exits first. The key's consumer count dropped to zero + // above, so C is first for the key again and setup runs a second time. + c := startContractConsumer(t, tr, appID, key, "C") + waitForSubscriberCount(t, tr, appID, 1) + waitForState(t, "consumer C pre-consume setup", func() bool { return counters.setup.Load() == 2 }) + d := startContractConsumer(t, tr, appID, key, "D") + waitForSubscriberCount(t, tr, appID, 2) + c.stop(t) + waitForSubscriberCount(t, tr, appID, 1) + d.stop(t) + waitForSubscriberCount(t, tr, appID, 0) + + if got := counters.setup.Load(); got != 2 { + t.Errorf("setup ran %d times, want 2 (once per first-for-key consumer)", got) + } + if got := counters.cleanup.Load(); got != 0 { + t.Errorf("cleanup ran %d times, want 0 (key declares no cleanup: the server-side subscription is a durable relationship)", got) + } +} From 57835186f50c0979aee2f74498759402ed4437ea Mon Sep 17 00:00:00 2001 From: leave330 Date: Fri, 31 Jul 2026 22:56:55 +0800 Subject: [PATCH 06/61] refactor(event): extract the canonical event value type into a model package --- internal/event/model/event.go | 32 ++++++++++++++++++++++++++++++++ internal/event/types.go | 13 ++++--------- 2 files changed, 36 insertions(+), 9 deletions(-) create mode 100644 internal/event/model/event.go diff --git a/internal/event/model/event.go b/internal/event/model/event.go new file mode 100644 index 0000000000..5a949f31c4 --- /dev/null +++ b/internal/event/model/event.go @@ -0,0 +1,32 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package model holds the pure value types of the event kernel. It may import +// the standard library only; every other event package builds on top of it. +package model + +import ( + "encoding/json" + "time" +) + +// Event is the single canonical representation of one upstream event. +// The ingress adapter parses the envelope header exactly once and fills these +// fields; everything downstream propagates and validates them but never +// re-derives them from the payload. +type Event struct { + // EventID is the upstream unique id — the dedup and diagnostics anchor. + EventID string `json:"event_id"` + EventType string `json:"event_type"` + // SourceTime is the upstream create_time. When the upstream omits it, it + // stays visibly empty — it is never backfilled from a local clock. + SourceTime string `json:"source_time,omitempty"` + // AppID and TenantKey identify the tenant the event was delivered for, + // as parsed from the envelope header at ingress. + AppID string `json:"app_id,omitempty"` + TenantKey string `json:"tenant_key,omitempty"` + Payload json.RawMessage `json:"payload"` + // Timestamp is the local observation clock at ingress. SourceTime and + // Timestamp are two different facts and never substitute for each other. + Timestamp time.Time `json:"timestamp"` +} diff --git a/internal/event/types.go b/internal/event/types.go index 28592c2749..a777e2dd11 100644 --- a/internal/event/types.go +++ b/internal/event/types.go @@ -8,8 +8,8 @@ import ( "context" "encoding/json" "reflect" - "time" + "github.com/larksuite/cli/internal/event/model" "github.com/larksuite/cli/internal/event/schemas" ) @@ -18,14 +18,9 @@ const ( MaxBufferSize = 1000 ) -// RawEvent: SourceTime is upstream create_time; Timestamp is local source observation time. -type RawEvent struct { - EventID string `json:"event_id"` - EventType string `json:"event_type"` - SourceTime string `json:"source_time,omitempty"` - Payload json.RawMessage `json:"payload"` - Timestamp time.Time `json:"timestamp"` -} +// RawEvent is the canonical event fact carrier; see model.Event for the field +// contracts. The alias keeps every existing reference compiling unchanged. +type RawEvent = model.Event // APIClient: identity is opaque so business code can't bypass pre-flight checks. type APIClient interface { From 4987cc3273168758b69a6a60c192665110e587e1 Mon Sep 17 00:00:00 2001 From: leave330 Date: Fri, 31 Jul 2026 22:58:49 +0800 Subject: [PATCH 07/61] feat(event): parse tenant identity once at the websocket ingress --- internal/event/source/feishu.go | 4 ++ internal/event/source/feishu_ingress_test.go | 62 ++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 internal/event/source/feishu_ingress_test.go diff --git a/internal/event/source/feishu.go b/internal/event/source/feishu.go index db0c8a1c60..b8e2f1d9a2 100644 --- a/internal/event/source/feishu.go +++ b/internal/event/source/feishu.go @@ -83,6 +83,8 @@ func (s *FeishuSource) buildRawHandler(emit func(*event.RawEvent)) func(context. EventID string `json:"event_id"` EventType string `json:"event_type"` CreateTime string `json:"create_time"` + AppID string `json:"app_id"` + TenantKey string `json:"tenant_key"` } `json:"header"` } if err := json.Unmarshal(e.Body, &envelope); err != nil { @@ -106,6 +108,8 @@ func (s *FeishuSource) buildRawHandler(emit func(*event.RawEvent)) func(context. EventID: envelope.Header.EventID, EventType: envelope.Header.EventType, SourceTime: envelope.Header.CreateTime, + AppID: envelope.Header.AppID, + TenantKey: envelope.Header.TenantKey, Payload: json.RawMessage(e.Body), Timestamp: time.Now(), }) diff --git a/internal/event/source/feishu_ingress_test.go b/internal/event/source/feishu_ingress_test.go new file mode 100644 index 0000000000..76591a0d62 --- /dev/null +++ b/internal/event/source/feishu_ingress_test.go @@ -0,0 +1,62 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package source + +import ( + "context" + "testing" + + larkevent "github.com/larksuite/oapi-sdk-go/v3/event" + + event "github.com/larksuite/cli/internal/event" +) + +// The websocket ingress is the only place that parses the envelope header; +// every canonical fact consumers rely on must be captured here, once. +func TestBuildRawHandler_ParsesCanonicalHeaderOnce(t *testing.T) { + s := &FeishuSource{} + var got *event.RawEvent + handler := s.buildRawHandler(func(ev *event.RawEvent) { got = ev }) + + body := []byte(`{"schema":"2.0","header":{"event_id":"evt-1","event_type":"im.message.receive_v1",` + + `"create_time":"1700000000000","app_id":"cli_test_app","tenant_key":"tenant_test"},"event":{}}`) + if err := handler(context.Background(), &larkevent.EventReq{Body: body}); err != nil { + t.Fatalf("handler: %v", err) + } + if got == nil { + t.Fatal("event was not emitted") + } + if got.EventID != "evt-1" || got.EventType != "im.message.receive_v1" { + t.Errorf("identity facts wrong: id=%q type=%q", got.EventID, got.EventType) + } + if got.SourceTime != "1700000000000" { + t.Errorf("SourceTime = %q, want upstream create_time", got.SourceTime) + } + if got.AppID != "cli_test_app" || got.TenantKey != "tenant_test" { + t.Errorf("tenant identity not captured: app_id=%q tenant_key=%q", got.AppID, got.TenantKey) + } + if got.Timestamp.IsZero() { + t.Error("local observation Timestamp must be set at ingress") + } +} + +// A header that omits optional facts leaves them visibly empty — the ingress +// never substitutes local configuration for missing upstream facts. +func TestBuildRawHandler_MissingOptionalFactsStayEmpty(t *testing.T) { + s := &FeishuSource{} + var got *event.RawEvent + handler := s.buildRawHandler(func(ev *event.RawEvent) { got = ev }) + + body := []byte(`{"schema":"2.0","header":{"event_id":"evt-2","event_type":"im.message.receive_v1"},"event":{}}`) + if err := handler(context.Background(), &larkevent.EventReq{Body: body}); err != nil { + t.Fatalf("handler: %v", err) + } + if got == nil { + t.Fatal("event was not emitted") + } + if got.SourceTime != "" || got.AppID != "" || got.TenantKey != "" { + t.Errorf("missing facts must stay empty: source_time=%q app_id=%q tenant_key=%q", + got.SourceTime, got.AppID, got.TenantKey) + } +} From 1166b273f7d56232cb8c33fdcdd701989cef8e17 Mon Sep 17 00:00:00 2001 From: leave330 Date: Fri, 31 Jul 2026 23:04:36 +0800 Subject: [PATCH 08/61] feat(event): carry full canonical metadata across the local bus frame --- internal/event/consume/loop_test.go | 13 +-- internal/event/consume/shutdown_test.go | 3 +- .../event/protocol/canonical_fields_test.go | 86 +++++++++++++++++++ internal/event/protocol/messages.go | 43 +++++++--- internal/event/protocol/messages_test.go | 3 +- 5 files changed, 130 insertions(+), 18 deletions(-) create mode 100644 internal/event/protocol/canonical_fields_test.go diff --git a/internal/event/consume/loop_test.go b/internal/event/consume/loop_test.go index ace554fd73..19583d492a 100644 --- a/internal/event/consume/loop_test.go +++ b/internal/event/consume/loop_test.go @@ -19,6 +19,7 @@ import ( "time" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/model" "github.com/larksuite/cli/internal/event/protocol" ) @@ -70,8 +71,8 @@ func TestConsumeLoop_DeliversEventsAndExitsOnMaxEvents(t *testing.T) { defer server.Close() events := []*protocol.Event{ - protocol.NewEvent("test.evt", "e1", "", 1, json.RawMessage(`{"n":1}`)), - protocol.NewEvent("test.evt", "e2", "", 2, json.RawMessage(`{"n":2}`)), + protocol.NewEvent(&model.Event{EventType: "test.evt", EventID: "e1", Payload: json.RawMessage(`{"n":1}`)}, 1), + protocol.NewEvent(&model.Event{EventType: "test.evt", EventID: "e2", Payload: json.RawMessage(`{"n":2}`)}, 2), } go busSide(t, server, events, true) @@ -113,8 +114,8 @@ func TestConsumeLoop_SeqGapEmitsWarning(t *testing.T) { defer server.Close() events := []*protocol.Event{ - protocol.NewEvent("test.evt", "e1", "", 1, json.RawMessage(`{"n":1}`)), - protocol.NewEvent("test.evt", "e5", "", 5, json.RawMessage(`{"n":5}`)), + protocol.NewEvent(&model.Event{EventType: "test.evt", EventID: "e1", Payload: json.RawMessage(`{"n":1}`)}, 1), + protocol.NewEvent(&model.Event{EventType: "test.evt", EventID: "e5", Payload: json.RawMessage(`{"n":5}`)}, 5), } go busSide(t, server, events, true) @@ -149,8 +150,8 @@ func TestConsumeLoop_JQFilterAppliedPerEvent(t *testing.T) { defer server.Close() events := []*protocol.Event{ - protocol.NewEvent("test.evt", "e1", "", 1, json.RawMessage(`{"keep":true,"n":1}`)), - protocol.NewEvent("test.evt", "e2", "", 2, json.RawMessage(`{"keep":false,"n":2}`)), + protocol.NewEvent(&model.Event{EventType: "test.evt", EventID: "e1", Payload: json.RawMessage(`{"keep":true,"n":1}`)}, 1), + protocol.NewEvent(&model.Event{EventType: "test.evt", EventID: "e2", Payload: json.RawMessage(`{"keep":false,"n":2}`)}, 2), } go busSide(t, server, events, true) diff --git a/internal/event/consume/shutdown_test.go b/internal/event/consume/shutdown_test.go index 76e28ad668..362105ce3e 100644 --- a/internal/event/consume/shutdown_test.go +++ b/internal/event/consume/shutdown_test.go @@ -12,6 +12,7 @@ import ( "testing" "time" + "github.com/larksuite/cli/internal/event/model" "github.com/larksuite/cli/internal/event/protocol" ) @@ -28,7 +29,7 @@ func TestCheckLastForKey_IgnoresNonAckFrames(t *testing.T) { errs <- err return } - evt := protocol.NewEvent("im.msg", "evt_1", "", 1, json.RawMessage(`{}`)) + evt := protocol.NewEvent(&model.Event{EventType: "im.msg", EventID: "evt_1", Payload: json.RawMessage(`{}`)}, 1) if err := protocol.Encode(server, evt); err != nil { errs <- err return diff --git a/internal/event/protocol/canonical_fields_test.go b/internal/event/protocol/canonical_fields_test.go new file mode 100644 index 0000000000..dff1c5a696 --- /dev/null +++ b/internal/event/protocol/canonical_fields_test.go @@ -0,0 +1,86 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package protocol + +import ( + "bufio" + "bytes" + "encoding/json" + "testing" + "time" + + "github.com/larksuite/cli/internal/event/model" +) + +// Every canonical fact the ingress parsed must survive the wire round trip +// verbatim — the consumer restores the event from this frame instead of +// re-deriving anything from the payload. +func TestEventFrame_CarriesCanonicalFactsVerbatim(t *testing.T) { + observed := time.Date(2023, 11, 14, 22, 13, 20, 123456789, time.UTC) + ev := &model.Event{ + EventID: "evt-42", + EventType: "im.message.receive_v1", + SourceTime: "1700000000000", + AppID: "cli_test_app", + TenantKey: "tenant_test", + Payload: json.RawMessage(`{"schema":"2.0"}`), + Timestamp: observed, + } + + var buf bytes.Buffer + if err := Encode(&buf, NewEvent(ev, 7)); err != nil { + t.Fatalf("encode: %v", err) + } + line, err := ReadFrame(bufio.NewReader(&buf)) + if err != nil { + t.Fatalf("read frame: %v", err) + } + decoded, err := Decode(line) + if err != nil { + t.Fatalf("decode: %v", err) + } + frame, ok := decoded.(*Event) + if !ok { + t.Fatalf("decoded %T, want *Event", decoded) + } + + if frame.EventID != ev.EventID || frame.EventType != ev.EventType || + frame.SourceTime != ev.SourceTime || frame.AppID != ev.AppID || + frame.TenantKey != ev.TenantKey || frame.Seq != 7 { + t.Errorf("canonical facts drifted across the wire: %+v", frame) + } + // observed_at is a fixed RFC3339Nano string contract, not an incidental + // time.Time marshal shape. + parsed, err := time.Parse(time.RFC3339Nano, frame.ObservedAt) + if err != nil { + t.Fatalf("observed_at %q is not RFC3339Nano: %v", frame.ObservedAt, err) + } + if !parsed.Equal(observed) { + t.Errorf("observed_at: got %v, want %v", parsed, observed) + } +} + +// Facts the upstream omitted stay omitted on the wire: the frame never invents +// values, and absent facts must not even appear as empty strings. +func TestEventFrame_MissingFactsStayAbsent(t *testing.T) { + ev := &model.Event{ + EventType: "im.message.receive_v1", + EventID: "evt-1", + Payload: json.RawMessage(`{}`), + } + + raw, err := json.Marshal(NewEvent(ev, 1)) + if err != nil { + t.Fatal(err) + } + var asMap map[string]json.RawMessage + if err := json.Unmarshal(raw, &asMap); err != nil { + t.Fatal(err) + } + for _, absent := range []string{"source_time", "app_id", "tenant_key", "observed_at"} { + if _, present := asMap[absent]; present { + t.Errorf("field %q must be omitted when the fact is missing, frame: %s", absent, raw) + } + } +} diff --git a/internal/event/protocol/messages.go b/internal/event/protocol/messages.go index afab7bbe91..d076141148 100644 --- a/internal/event/protocol/messages.go +++ b/internal/event/protocol/messages.go @@ -3,7 +3,12 @@ package protocol -import "encoding/json" +import ( + "encoding/json" + "time" + + "github.com/larksuite/cli/internal/event/model" +) const ( MsgTypeHello = "hello" @@ -51,11 +56,19 @@ type HelloAck struct { } // Event: Seq is per-conn monotonic; gaps signal bus drop-oldest backpressure loss. +// The frame carries every canonical fact the ingress parsed — consumers restore +// them verbatim instead of re-deriving anything from the payload. All fields +// beyond the original set are additive so older peers ignore them. type Event struct { - Type string `json:"type"` - EventType string `json:"event_type"` - EventID string `json:"event_id,omitempty"` - SourceTime string `json:"source_time,omitempty"` // ms-precision unix timestamp, stringified + Type string `json:"type"` + EventType string `json:"event_type"` + EventID string `json:"event_id,omitempty"` + SourceTime string `json:"source_time,omitempty"` // upstream create_time verbatim; empty when the upstream omitted it + AppID string `json:"app_id,omitempty"` + TenantKey string `json:"tenant_key,omitempty"` + // ObservedAt is the ingress observation clock in RFC3339Nano — a fixed + // string contract on the wire, not whatever time.Time happens to marshal to. + ObservedAt string `json:"observed_at,omitempty"` Seq uint64 `json:"seq,omitempty"` Payload json.RawMessage `json:"payload"` } @@ -130,14 +143,24 @@ func NewHelloAckRejected(busVersion, reason string) *HelloAck { } } -func NewEvent(eventType, eventID, sourceTime string, seq uint64, payload json.RawMessage) *Event { +// NewEvent projects the canonical event onto the wire frame verbatim. It is +// the only Event constructor on purpose: every fact travels or is visibly +// absent — nothing is defaulted, substituted, or dropped here. +func NewEvent(ev *model.Event, seq uint64) *Event { + observedAt := "" + if !ev.Timestamp.IsZero() { + observedAt = ev.Timestamp.Format(time.RFC3339Nano) + } return &Event{ Type: MsgTypeEvent, - EventType: eventType, - EventID: eventID, - SourceTime: sourceTime, + EventType: ev.EventType, + EventID: ev.EventID, + SourceTime: ev.SourceTime, + AppID: ev.AppID, + TenantKey: ev.TenantKey, + ObservedAt: observedAt, Seq: seq, - Payload: payload, + Payload: ev.Payload, } } diff --git a/internal/event/protocol/messages_test.go b/internal/event/protocol/messages_test.go index 29d00d02a1..052a053d4d 100644 --- a/internal/event/protocol/messages_test.go +++ b/internal/event/protocol/messages_test.go @@ -9,6 +9,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/larksuite/cli/internal/event/model" "io" "net" "testing" @@ -23,7 +24,7 @@ func TestConstructors_PinTypeField(t *testing.T) { if got := NewHelloAck("v1", true); got.Type != MsgTypeHelloAck || !got.FirstForKey { t.Errorf("NewHelloAck mismatch: %+v", got) } - if got := NewEvent("im.msg", "e1", "", 7, json.RawMessage(`{}`)); got.Type != MsgTypeEvent || got.Seq != 7 { + if got := NewEvent(&model.Event{EventType: "im.msg", EventID: "e1", Payload: json.RawMessage(`{}`)}, 7); got.Type != MsgTypeEvent || got.Seq != 7 { t.Errorf("NewEvent mismatch: %+v", got) } if got := NewPreShutdownCheck("k", ""); got.Type != MsgTypePreShutdownCheck || got.EventKey != "k" { From 6e760b921c747fe7d6d7c0dc37715970bfe7e253 Mon Sep 17 00:00:00 2001 From: leave330 Date: Fri, 31 Jul 2026 23:04:36 +0800 Subject: [PATCH 09/61] fix(event): stop backfilling upstream event time from the local clock --- internal/event/bus/hub.go | 22 +++++--------------- internal/event/bus/hub_observability_test.go | 20 ++++++++++++------ 2 files changed, 19 insertions(+), 23 deletions(-) diff --git a/internal/event/bus/hub.go b/internal/event/bus/hub.go index a53fc1554b..fd7e538636 100644 --- a/internal/event/bus/hub.go +++ b/internal/event/bus/hub.go @@ -222,24 +222,12 @@ func (h *Hub) Publish(raw *event.RawEvent) { } h.mu.RUnlock() - // Resolve source time once per Publish (not per subscriber) — same value - // across the fan-out. Prefer the upstream header create_time - // (raw.SourceTime) over the local arrival timestamp so consumers see - // original publisher intent; fall back to Timestamp when SourceTime - // wasn't populated (e.g. test-only sources, pre-4.4 RawEvent producers). - sourceTime := raw.SourceTime - if sourceTime == "" && !raw.Timestamp.IsZero() { - sourceTime = fmt.Sprintf("%d", raw.Timestamp.UnixMilli()) - } - + // SourceTime travels verbatim: when the upstream omitted create_time it + // stays empty on the wire. Substituting the local arrival clock here would + // disguise a local observation as an upstream fact — consumers that need + // the arrival time have the frame's observed_at. for _, s := range matches { - msg := protocol.NewEvent( - raw.EventType, - raw.EventID, - sourceTime, - s.NextSeq(), - raw.Payload, - ) + msg := protocol.NewEvent(raw, s.NextSeq()) enqueued, dropped := s.PushDropOldest(msg) if dropped { diff --git a/internal/event/bus/hub_observability_test.go b/internal/event/bus/hub_observability_test.go index 0134fe2a58..bc4be84420 100644 --- a/internal/event/bus/hub_observability_test.go +++ b/internal/event/bus/hub_observability_test.go @@ -65,10 +65,11 @@ func TestPublishPopulatesEventIDAndSourceTime(t *testing.T) { h.RegisterAndIsFirst(c) const eid = "test-event-id-123" + observed := time.UnixMilli(1234567890123) h.Publish(&event.RawEvent{ EventID: eid, EventType: "t", - Timestamp: time.UnixMilli(1234567890123), + Timestamp: observed, }) msg := <-c.SendCh() @@ -76,8 +77,13 @@ func TestPublishPopulatesEventIDAndSourceTime(t *testing.T) { if ev.EventID != eid { t.Errorf("expected EventID %q, got %q", eid, ev.EventID) } - if ev.SourceTime != "1234567890123" { - t.Errorf("expected SourceTime \"1234567890123\", got %q", ev.SourceTime) + // The upstream never sent create_time, so source_time must stay empty on + // the wire — the local arrival clock travels separately as observed_at. + if ev.SourceTime != "" { + t.Errorf("SourceTime must stay empty without upstream create_time, got %q", ev.SourceTime) + } + if want := observed.Format(time.RFC3339Nano); ev.ObservedAt != want { + t.Errorf("ObservedAt: got %q, want %q", ev.ObservedAt, want) } } @@ -106,7 +112,9 @@ func TestPublishSourceTimeTakesPrecedence(t *testing.T) { } } -func TestPublishSourceTimeFallback(t *testing.T) { +// A missing upstream create_time is a fact worth preserving: substituting the +// local clock would let a local observation masquerade as upstream intent. +func TestPublishMissingSourceTimeStaysEmpty(t *testing.T) { h := NewHub() server, client := testNetPipe(t) defer server.Close() @@ -123,8 +131,8 @@ func TestPublishSourceTimeFallback(t *testing.T) { msg := <-c.SendCh() ev := msg.(*protocol.Event) - if ev.SourceTime != "42" { - t.Errorf("SourceTime fallback: got %q, want %q", ev.SourceTime, "42") + if ev.SourceTime != "" { + t.Errorf("SourceTime: got %q, want empty when upstream omitted create_time", ev.SourceTime) } } From dbca48892aa649822062fcb1e3921a4214cbd9f3 Mon Sep 17 00:00:00 2001 From: leave330 Date: Fri, 31 Jul 2026 23:26:56 +0800 Subject: [PATCH 10/61] fix(event): fail closed when a payload header conflicts with canonical metadata --- internal/event/consume/canonical_conflict.go | 84 +++++++++ .../event/consume/canonical_conflict_test.go | 167 ++++++++++++++++++ internal/event/consume/loop.go | 45 ++++- 3 files changed, 292 insertions(+), 4 deletions(-) create mode 100644 internal/event/consume/canonical_conflict.go create mode 100644 internal/event/consume/canonical_conflict_test.go diff --git a/internal/event/consume/canonical_conflict.go b/internal/event/consume/canonical_conflict.go new file mode 100644 index 0000000000..29fcfb55b2 --- /dev/null +++ b/internal/event/consume/canonical_conflict.go @@ -0,0 +1,84 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package consume + +import ( + "encoding/json" + + "github.com/larksuite/cli/internal/event/model" +) + +// payloadHeaderClaims is the subset of the payload's header block that can +// claim canonical facts. It exists only for validation at the consume +// boundary — canonical metadata itself always comes from the ingress. +type payloadHeaderClaims struct { + Header struct { + EventID string `json:"event_id"` + EventType string `json:"event_type"` + CreateTime string `json:"create_time"` + AppID string `json:"app_id"` + TenantKey string `json:"tenant_key"` + } `json:"header"` +} + +// factComparison pairs one canonical fact with the payload-header field that +// can claim it. The comparisons live in a table, not an if-chain: adding a +// fact to model.Event means adding a row here (a reflection gate enforces it). +type factComparison struct { + name string + header func(c *payloadHeaderClaims) string + canonical func(ev *model.Event) string +} + +var canonicalFactComparisons = []factComparison{ + { + name: "event_id", + header: func(c *payloadHeaderClaims) string { return c.Header.EventID }, + canonical: func(ev *model.Event) string { return ev.EventID }, + }, + { + name: "event_type", + header: func(c *payloadHeaderClaims) string { return c.Header.EventType }, + canonical: func(ev *model.Event) string { return ev.EventType }, + }, + { + name: "create_time", + header: func(c *payloadHeaderClaims) string { return c.Header.CreateTime }, + canonical: func(ev *model.Event) string { return ev.SourceTime }, + }, + { + name: "app_id", + header: func(c *payloadHeaderClaims) string { return c.Header.AppID }, + canonical: func(ev *model.Event) string { return ev.AppID }, + }, + { + name: "tenant_key", + header: func(c *payloadHeaderClaims) string { return c.Header.TenantKey }, + canonical: func(ev *model.Event) string { return ev.TenantKey }, + }, +} + +// checkCanonicalConflict returns the name of the first canonical fact the +// payload header contradicts, or "" when the event may be delivered. +// +// Arbitration is deliberately one-sided: a silent header claims nothing, but +// once the header asserts a fact it must match the canonical value — +// including when the canonical side is empty. An asserted header fact facing +// an empty canonical value means the fact was lost between the ingress and +// this consumer; that is a delivery defect, not "nothing to compare". +// +// A payload that is not a JSON object is not re-classified here — malformed +// handling belongs to the processing layer. +func checkCanonicalConflict(ev *model.Event) string { + var claims payloadHeaderClaims + if err := json.Unmarshal(ev.Payload, &claims); err != nil { + return "" + } + for _, c := range canonicalFactComparisons { + if claimed := c.header(&claims); claimed != "" && claimed != c.canonical(ev) { + return c.name + } + } + return "" +} diff --git a/internal/event/consume/canonical_conflict_test.go b/internal/event/consume/canonical_conflict_test.go new file mode 100644 index 0000000000..c13e7f7773 --- /dev/null +++ b/internal/event/consume/canonical_conflict_test.go @@ -0,0 +1,167 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package consume + +import ( + "encoding/json" + "fmt" + "reflect" + "testing" + "time" + + "github.com/larksuite/cli/internal/event/model" +) + +// rawEventFieldsNotComparable lists the model.Event fields that legitimately +// have no payload-header counterpart, each with the reason. Every other field +// must appear as a comparison row — the reflection walk below enforces it. +var rawEventFieldsNotComparable = map[string]string{ + "Payload": "the value under validation cannot vouch for itself", + "Timestamp": "local observation clock; the upstream envelope deliberately has no counterpart", +} + +// fieldToComparison maps model.Event struct fields to their comparison rows. +var fieldToComparison = map[string]string{ + "EventID": "event_id", + "EventType": "event_type", + "SourceTime": "create_time", + "AppID": "app_id", + "TenantKey": "tenant_key", +} + +// Every canonical fact must be either compared or declared not-comparable +// with a reason. This is the structural check that keeps the comparison table +// honest when model.Event grows a field. +func TestCanonicalConflict_CoversEveryCanonicalField(t *testing.T) { + typ := reflect.TypeFor[model.Event]() + if typ.NumField() == 0 { + t.Fatal("model.Event has no fields; the gate scanned nothing") + } + rows := map[string]bool{} + for _, c := range canonicalFactComparisons { + rows[c.name] = true + } + for i := 0; i < typ.NumField(); i++ { + name := typ.Field(i).Name + if _, exempt := rawEventFieldsNotComparable[name]; exempt { + if _, alsoCompared := fieldToComparison[name]; alsoCompared { + t.Errorf("field %s is both exempt and compared; pick one", name) + } + continue + } + rowName, ok := fieldToComparison[name] + if !ok { + t.Errorf("model.Event.%s is neither compared nor declared not-comparable; add a comparison row or an exemption with a reason", name) + continue + } + if !rows[rowName] { + t.Errorf("comparison row %q for model.Event.%s is missing from canonicalFactComparisons", rowName, name) + } + } +} + +func conflictBaseEvent() *model.Event { + return &model.Event{ + EventID: "evt-1", + EventType: "im.message.receive_v1", + SourceTime: "1700000000000", + AppID: "cli_app", + TenantKey: "tenant_a", + Timestamp: time.Unix(0, 0), + } +} + +func headerPayload(overrides map[string]string) json.RawMessage { + header := map[string]string{ + "event_id": "evt-1", + "event_type": "im.message.receive_v1", + "create_time": "1700000000000", + "app_id": "cli_app", + "tenant_key": "tenant_a", + } + for k, v := range overrides { + header[k] = v + } + raw, _ := json.Marshal(map[string]any{"schema": "2.0", "header": header, "event": map[string]any{}}) + return raw +} + +// Each row must detect a real mismatch — a row that exists but compares a +// value to itself would pass this suite's structural check yet catch nothing. +func TestCanonicalConflict_DetectsEveryFactMismatch(t *testing.T) { + for _, c := range canonicalFactComparisons { + t.Run(c.name, func(t *testing.T) { + ev := conflictBaseEvent() + ev.Payload = headerPayload(map[string]string{c.name: "tampered-value"}) + if got := checkCanonicalConflict(ev); got != c.name { + t.Errorf("header claiming a different %s must conflict, got %q", c.name, got) + } + }) + } +} + +// The reverse direction: the header keeps its claim but the canonical side +// lost the fact. An asserted claim facing an empty canonical value means the +// fact was dropped between ingress and this consumer — that is a conflict, +// not "nothing to compare". +func TestCanonicalConflict_CatchesLostFacts(t *testing.T) { + blank := map[string]func(*model.Event){ + "event_id": func(ev *model.Event) { ev.EventID = "" }, + "event_type": func(ev *model.Event) { ev.EventType = "" }, + "create_time": func(ev *model.Event) { ev.SourceTime = "" }, + "app_id": func(ev *model.Event) { ev.AppID = "" }, + "tenant_key": func(ev *model.Event) { ev.TenantKey = "" }, + } + if len(blank) != len(canonicalFactComparisons) { + t.Fatalf("blanking table covers %d facts, comparisons have %d; keep them in lockstep", len(blank), len(canonicalFactComparisons)) + } + for _, c := range canonicalFactComparisons { + t.Run(c.name, func(t *testing.T) { + ev := conflictBaseEvent() + ev.Payload = headerPayload(nil) + blank[c.name](ev) + if got := checkCanonicalConflict(ev); got != c.name { + t.Errorf("a lost canonical %s facing an asserted header claim must conflict, got %q", c.name, got) + } + }) + } +} + +// Well-formed control cases: agreement and silence both deliver. +func TestCanonicalConflict_AgreementAndSilenceDeliver(t *testing.T) { + agree := conflictBaseEvent() + agree.Payload = headerPayload(nil) + if got := checkCanonicalConflict(agree); got != "" { + t.Errorf("matching header must deliver, got conflict on %q", got) + } + + silent := conflictBaseEvent() + silent.Payload = json.RawMessage(`{"schema":"2.0","event":{"text":"no header block"}}`) + if got := checkCanonicalConflict(silent); got != "" { + t.Errorf("a silent header claims nothing and must deliver, got conflict on %q", got) + } + + // Non-JSON payloads are the processing layer's business, not arbitration's. + malformed := conflictBaseEvent() + malformed.Payload = json.RawMessage(`this is definitely not valid json {{{`) + if got := checkCanonicalConflict(malformed); got != "" { + t.Errorf("non-JSON payloads are not re-classified here, got conflict on %q", got) + } +} + +// The pipeline drops conflicting events with a diagnostic naming identity +// facts only — never payload content. +func TestCanonicalConflict_PipelineDropsWithRedactedDiagnostic(t *testing.T) { + const sentinel = "SECRET-PAYLOAD-CONTENT-XYZ" + ev := conflictBaseEvent() + ev.Payload = headerPayload(map[string]string{"app_id": "attacker-" + sentinel}) + + field := checkCanonicalConflict(ev) + if field != "app_id" { + t.Fatalf("expected app_id conflict, got %q", field) + } + diag := fmt.Sprintf("WARN: event %s (%s) dropped: payload header conflicts with canonical metadata (field=%s)\n", + ev.EventID, ev.EventType, field) + assertNoPayloadBytes(t, diag, sentinel) +} diff --git a/internal/event/consume/loop.go b/internal/event/consume/loop.go index 849caddd8a..e2cdcb4239 100644 --- a/internal/event/consume/loop.go +++ b/internal/event/consume/loop.go @@ -18,6 +18,7 @@ import ( "github.com/itchyny/gojq" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/processing" "github.com/larksuite/cli/internal/event/protocol" ) @@ -199,9 +200,18 @@ func consumeLoop(ctx context.Context, conn net.Conn, br *bufio.Reader, keyDef *e // processAndOutput returns (wrote, err); err non-nil only for sink.Write failures. func processAndOutput(ctx context.Context, keyDef *event.KeyDefinition, evt *protocol.Event, opts Options, sink Sink, jqCode *gojq.Code) (bool, error) { - raw := &event.RawEvent{ - EventType: evt.EventType, - Payload: evt.Payload, + raw := restoreCanonicalEvent(evt) + + // Validate before any domain work: a payload header that contradicts the + // canonical metadata means the two sources of truth diverged somewhere on + // the delivery path — deliver neither. The diagnostic names identity + // facts only; payload content never reaches stderr. + if field := checkCanonicalConflict(raw); field != "" { + if !opts.Quiet { + fmt.Fprintf(opts.ErrOut, "WARN: event %s (%s) dropped: payload header conflicts with canonical metadata (field=%s)\n", + raw.EventID, raw.EventType, field) + } + return false, nil } // Synchronous Match filter runs before any work (Process / sink write). @@ -216,7 +226,12 @@ func processAndOutput(ctx context.Context, keyDef *event.KeyDefinition, evt *pro result, err = keyDef.Process(ctx, opts.Runtime, raw, opts.Params) if err != nil { if !opts.Quiet { - fmt.Fprintf(opts.ErrOut, "WARN: Process error: %v\n", err) + if processing.IsDropMalformed(err) { + fmt.Fprintf(opts.ErrOut, "WARN: event %s (%s) dropped: malformed payload\n", + raw.EventID, raw.EventType) + } else { + fmt.Fprintf(opts.ErrOut, "WARN: Process error: %v\n", err) + } } return false, nil } @@ -247,6 +262,28 @@ func processAndOutput(ctx context.Context, keyDef *event.KeyDefinition, evt *pro return true, nil } +// restoreCanonicalEvent rebuilds the canonical event from the wire frame in +// full. Every fact the ingress parsed must survive into the domain hooks — +// restoring only a subset is how processors historically ended up re-parsing +// the payload header as a second source of truth. +func restoreCanonicalEvent(evt *protocol.Event) *event.RawEvent { + var observed time.Time + if evt.ObservedAt != "" { + if parsed, err := time.Parse(time.RFC3339Nano, evt.ObservedAt); err == nil { + observed = parsed + } + } + return &event.RawEvent{ + EventID: evt.EventID, + EventType: evt.EventType, + SourceTime: evt.SourceTime, + AppID: evt.AppID, + TenantKey: evt.TenantKey, + Payload: evt.Payload, + Timestamp: observed, + } +} + // isTerminalSinkError reports if the output channel is permanently broken (EPIPE/ErrClosed). func isTerminalSinkError(err error) bool { if err == nil { From 42897d3c5c98e424182cf260b224bed604202b10 Mon Sep 17 00:00:00 2001 From: leave330 Date: Fri, 31 Jul 2026 23:26:56 +0800 Subject: [PATCH 11/61] feat(event): negotiate bus capabilities before any consume side effect --- internal/event/bus/bus.go | 2 +- .../event/consume/capability_gate_test.go | 133 ++++++++++++++++++ internal/event/consume/consume.go | 21 +++ internal/event/protocol/messages.go | 19 ++- 4 files changed, 170 insertions(+), 5 deletions(-) create mode 100644 internal/event/consume/capability_gate_test.go diff --git a/internal/event/bus/bus.go b/internal/event/bus/bus.go index 849285694d..e15e2904be 100644 --- a/internal/event/bus/bus.go +++ b/internal/event/bus/bus.go @@ -325,7 +325,7 @@ func (b *Bus) handleHello(conn net.Conn, reader *bufio.Reader, hello *protocol.H } b.mu.Unlock() - ack := protocol.NewHelloAck("v1", firstForKey) + ack := protocol.NewHelloAck("v1", firstForKey, protocol.CapabilityCanonicalMetadataV1) // writeFrame shares writeMu with every other write; bc.Close on failure unwinds hub+bus registration via onClose. if err := bc.writeFrame(ack); err != nil { b.logger.Printf("WARN: hello_ack write to pid=%d key=%q failed: %v (rejecting connection)", diff --git a/internal/event/consume/capability_gate_test.go b/internal/event/consume/capability_gate_test.go new file mode 100644 index 0000000000..31a28e6622 --- /dev/null +++ b/internal/event/consume/capability_gate_test.go @@ -0,0 +1,133 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package consume + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "net" + "os" + "strings" + "sync/atomic" + "testing" + + "github.com/larksuite/cli/errs" + event "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/protocol" + "github.com/larksuite/cli/internal/event/testutil" + "github.com/larksuite/cli/internal/event/transport" +) + +// legacyBusAcks replays, byte for byte, the hello_ack an older bus would send: +// the capability list either absent, empty, or missing the canonical-metadata +// entry. Each one must be refused — and refused before any side effect runs. +var legacyBusAcks = map[string]string{ + "no capabilities field": `{"type":"hello_ack","bus_version":"v1","first_for_key":true}`, + "empty capability list": `{"type":"hello_ack","bus_version":"v1","first_for_key":true,"capabilities":[]}`, + "unrelated capability": `{"type":"hello_ack","bus_version":"v1","first_for_key":true,"capabilities":["some_other_capability"]}`, +} + +func TestCapabilityGate_RefusesLegacyBusBeforeAnySideEffect(t *testing.T) { + for name, rawAck := range legacyBusAcks { + t.Run(name, func(t *testing.T) { + const key = "test.evt_capability_gate" + var setupCalls atomic.Int64 + event.RegisterKey(event.KeyDefinition{ + Key: key, + EventType: key, + Schema: event.SchemaDef{Custom: &event.SchemaSpec{Raw: json.RawMessage(`{"type":"object"}`)}}, + PreConsume: func(_ context.Context, _ event.APIClient, _ map[string]string) (func() error, error) { + setupCalls.Add(1) + return nil, nil + }, + }) + defer event.UnregisterKeyForTest(key) + + tr := startLegacyBusStub(t, rawAck) + + err := Run(context.Background(), tr, "cap-gate-app", "", "", Options{ + EventKey: key, + Runtime: &fakeRT{}, + Quiet: true, + }) + if err == nil { + t.Fatal("consume must refuse a bus without canonical metadata support") + } + problem, ok := errs.ProblemOf(err) + if !ok || problem.Subtype != errs.SubtypeFailedPrecondition { + t.Errorf("want a failed_precondition problem, got %v", err) + } + if !strings.Contains(err.Error(), protocol.CapabilityCanonicalMetadataV1) { + t.Errorf("error must name the missing capability, got: %v", err) + } + if got := setupCalls.Load(); got != 0 { + t.Errorf("pre-consume ran %d time(s) before the capability check; the gate must come first", got) + } + }) + } +} + +// The current build's own ack must pass — a gate that rejects everything +// would be just as wrong as one that accepts everything. +func TestCapabilityGate_CurrentAckPasses(t *testing.T) { + ack := protocol.NewHelloAck("v1", true, protocol.CapabilityCanonicalMetadataV1) + if err := capabilityError(ack, "any.key"); err != nil { + t.Fatalf("current bus ack must satisfy the capability gate, got: %v", err) + } +} + +// startLegacyBusStub listens on a fake transport and speaks just enough of the +// wire protocol to let a consumer attach: it answers the status probe, then +// replies to the hello with the provided raw legacy ack line. +func startLegacyBusStub(t *testing.T, rawAck string) transport.IPC { + t.Helper() + dir, err := os.MkdirTemp("", "capgate-*") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(dir) }) + + tr := testutil.NewWrappedFake(transport.New(), dir+"/bus.sock") + ln, err := tr.Listen("cap-gate-app") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { ln.Close() }) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go serveLegacyConn(conn, rawAck) + } + }() + return tr +} + +func serveLegacyConn(conn net.Conn, rawAck string) { + defer conn.Close() + br := bufio.NewReader(conn) + line, err := protocol.ReadFrame(br) + if err != nil { + return + } + msg, err := protocol.Decode(bytes.TrimRight(line, "\n")) + if err != nil { + return + } + switch msg.(type) { + case *protocol.StatusQuery: + _ = protocol.Encode(conn, protocol.NewStatusResponse(1, 1, 0, nil)) + case *protocol.Hello: + _, _ = fmt.Fprintf(conn, "%s\n", rawAck) + // Hold the conn open like a real bus would; the consumer is expected + // to walk away after inspecting the ack. + _, _ = protocol.ReadFrame(br) + } +} diff --git a/internal/event/consume/consume.go b/internal/event/consume/consume.go index ec8aa7f66c..fa09d8b3d2 100644 --- a/internal/event/consume/consume.go +++ b/internal/event/consume/consume.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "os" + "slices" "sort" "strings" "sync/atomic" @@ -106,6 +107,13 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin if rejErr := rejectionError(ack, opts.EventKey); rejErr != nil { return rejErr } + // The capability check must run before any side effect (pre-consume setup, + // worker start): attaching to a bus that cannot deliver full canonical + // metadata would silently emit events with missing ids, times, and tenant + // identity instead of failing visibly. + if capErr := capabilityError(ack, opts.EventKey); capErr != nil { + return capErr + } var cleanup func() error if ack.FirstForKey && keyDef.PreConsume != nil { @@ -186,6 +194,19 @@ func rejectionError(ack *protocol.HelloAck, eventKey string) error { WithHint("EventKey %s allows only one consumer; run `lark-cli event status` to find the running one, then stop it before retrying", eventKey) } +// capabilityError refuses to attach to a bus that does not advertise full +// canonical event metadata. The check happens on the real delivery +// connection's ack — not on a separate probe — so the bus that answered is +// exactly the bus that would deliver. +func capabilityError(ack *protocol.HelloAck, eventKey string) error { + if slices.Contains(ack.Capabilities, protocol.CapabilityCanonicalMetadataV1) { + return nil + } + return errs.NewValidationError(errs.SubtypeFailedPrecondition, + "the running local event bus does not support %s; it was started by an older CLI version", protocol.CapabilityCanonicalMetadataV1). + WithHint("stop the consumers still attached to the old bus, run `lark-cli event stop` (add --force to override active consumers at the cost of dropping them), then retry `lark-cli event consume %s`", eventKey) +} + func truncateDuration(d time.Duration) time.Duration { return d.Truncate(time.Second) } diff --git a/internal/event/protocol/messages.go b/internal/event/protocol/messages.go index d076141148..c8a2ff5857 100644 --- a/internal/event/protocol/messages.go +++ b/internal/event/protocol/messages.go @@ -47,12 +47,22 @@ type Hello struct { SubscriptionID string `json:"subscription_id,omitempty"` // empty = fallback to EventKey on bus side } +// CapabilityCanonicalMetadataV1 declares that every event frame this bus +// publishes carries the full canonical metadata set (event id, source time, +// tenant identity, observation time). Consumers that depend on those facts +// verify the capability on the delivery connection's ack and refuse to attach +// to a bus that cannot provide them. +const CapabilityCanonicalMetadataV1 = "canonical_metadata_v1" + type HelloAck struct { Type string `json:"type"` BusVersion string `json:"bus_version"` FirstForKey bool `json:"first_for_key"` Rejected bool `json:"rejected,omitempty"` RejectReason string `json:"reject_reason,omitempty"` + // Capabilities is additive: an older bus simply never sends it, which is + // exactly the signal consumers use to reject the attach. + Capabilities []string `json:"capabilities,omitempty"` } // Event: Seq is per-conn monotonic; gaps signal bus drop-oldest backpressure loss. @@ -124,11 +134,12 @@ func NewHello(pid int, eventKey string, eventTypes []string, version string, sub } } -func NewHelloAck(busVersion string, firstForKey bool) *HelloAck { +func NewHelloAck(busVersion string, firstForKey bool, capabilities ...string) *HelloAck { return &HelloAck{ - Type: MsgTypeHelloAck, - BusVersion: busVersion, - FirstForKey: firstForKey, + Type: MsgTypeHelloAck, + BusVersion: busVersion, + FirstForKey: firstForKey, + Capabilities: capabilities, } } From 059950168007416563219ef15433191f221d6a3b Mon Sep 17 00:00:00 2001 From: leave330 Date: Fri, 31 Jul 2026 23:26:56 +0800 Subject: [PATCH 12/61] fix(event): drop malformed payloads instead of leaking raw envelopes to stdout --- events/application/menu.go | 3 +- events/application/menu_test.go | 9 +- events/approval/register.go | 5 +- events/approval/register_test.go | 11 +- events/im/card_action.go | 3 +- events/im/card_action_test.go | 9 +- events/im/message_receive.go | 3 +- events/im/message_receive_test.go | 9 +- events/minutes/minute_generated.go | 3 +- events/minutes/minute_generated_test.go | 9 +- events/schema_closure_test.go | 77 ++++++++++++ events/vc/note_generated.go | 3 +- events/vc/note_generated_test.go | 9 +- events/vc/participant_meeting_ended.go | 3 +- events/vc/participant_meeting_ended_test.go | 9 +- events/vc/participant_meeting_joined.go | 3 +- .../vc/participant_meeting_lifecycle_test.go | 9 +- events/vc/participant_meeting_started.go | 3 +- events/vc/recording_ended.go | 3 +- events/vc/recording_started.go | 3 +- events/vc/recording_test.go | 11 +- events/vc/recording_transcript_generated.go | 3 +- .../consume/diagnostics_redaction_test.go | 113 ++++++++++++++++++ internal/event/processing/result.go | 40 +++++++ internal/event/types.go | 9 +- 25 files changed, 307 insertions(+), 55 deletions(-) create mode 100644 events/schema_closure_test.go create mode 100644 internal/event/consume/diagnostics_redaction_test.go create mode 100644 internal/event/processing/result.go diff --git a/events/application/menu.go b/events/application/menu.go index f77dec6d80..ed410e981b 100644 --- a/events/application/menu.go +++ b/events/application/menu.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/processing" ) // BotMenuOutput is the flattened shape for application.bot.menu_v6. @@ -50,7 +51,7 @@ func processBotMenu(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ } `json:"event"` } if err := json.Unmarshal(raw.Payload, &envelope); err != nil { - return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event + return nil, processing.DropMalformed(raw.EventType) } menuTimestamp := timestampMillisString(envelope.Event.Timestamp) diff --git a/events/application/menu_test.go b/events/application/menu_test.go index 1a9c5be1fe..2ff187c1f6 100644 --- a/events/application/menu_test.go +++ b/events/application/menu_test.go @@ -11,6 +11,7 @@ import ( "time" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/processing" ) func TestKeysBotMenuMetadata(t *testing.T) { @@ -199,11 +200,11 @@ func TestProcessBotMenuMalformedPayload(t *testing.T) { Timestamp: time.Now(), } got, err := processBotMenu(context.Background(), nil, raw, nil) - if err != nil { - t.Fatalf("Process should swallow parse errors, got %v", err) + if !processing.IsDropMalformed(err) { + t.Fatalf("malformed payload must be dropped with a malformed marker, got err=%v", err) } - if string(got) != "not json" { - t.Errorf("malformed fallback output = %q, want original bytes", string(got)) + if got != nil { + t.Errorf("malformed payload must be dropped without output, got %q", string(got)) } } diff --git a/events/approval/register.go b/events/approval/register.go index 55e37db05e..a31be1a671 100644 --- a/events/approval/register.go +++ b/events/approval/register.go @@ -10,6 +10,7 @@ import ( "reflect" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/processing" ) const ( @@ -114,7 +115,7 @@ func processApprovalInstanceStatusChanged(_ context.Context, _ event.APIClient, } `json:"event"` } if err := json.Unmarshal(raw.Payload, &envelope); err != nil { - return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event + return nil, processing.DropMalformed(raw.EventType) } out := &ApprovalInstanceStatusChangedV4Output{ @@ -156,7 +157,7 @@ func processApprovalTaskStatusChanged(_ context.Context, _ event.APIClient, raw } `json:"event"` } if err := json.Unmarshal(raw.Payload, &envelope); err != nil { - return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event + return nil, processing.DropMalformed(raw.EventType) } out := &ApprovalTaskStatusChangedV4Output{ diff --git a/events/approval/register_test.go b/events/approval/register_test.go index c1017bb614..a303ee0783 100644 --- a/events/approval/register_test.go +++ b/events/approval/register_test.go @@ -14,6 +14,7 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/processing" "github.com/larksuite/cli/internal/event/schemas" ) @@ -553,7 +554,7 @@ func TestProcessApprovalStatusChangedUsesRawEventTypeFallback(t *testing.T) { } } -func TestProcessApprovalStatusChangedMalformedPayloadPassthrough(t *testing.T) { +func TestProcessApprovalStatusChangedMalformedPayloadDrop(t *testing.T) { for _, tc := range []struct { name string eventType string @@ -569,11 +570,11 @@ func TestProcessApprovalStatusChangedMalformedPayloadPassthrough(t *testing.T) { 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 !processing.IsDropMalformed(err) { + t.Fatalf("malformed payload must be dropped with a malformed marker, got err=%v", err) } - if string(got) != "not json" { - t.Errorf("malformed fallback output = %q, want original bytes", string(got)) + if got != nil { + t.Errorf("malformed payload must be dropped without output, got %q", string(got)) } }) } diff --git a/events/im/card_action.go b/events/im/card_action.go index 43b8b6cdbf..d125f8eafa 100644 --- a/events/im/card_action.go +++ b/events/im/card_action.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/processing" ) // CardActionTriggerOutput is the flattened shape for card.action.trigger. @@ -64,7 +65,7 @@ func processCardAction(ctx context.Context, rt event.APIClient, raw *event.RawEv } `json:"event"` } if err := json.Unmarshal(raw.Payload, &envelope); err != nil { - return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload + return nil, processing.DropMalformed(raw.EventType) } actionValue := marshalToString(envelope.Event.Action.Value) diff --git a/events/im/card_action_test.go b/events/im/card_action_test.go index df0c1fe36c..c6f10dbc25 100644 --- a/events/im/card_action_test.go +++ b/events/im/card_action_test.go @@ -10,6 +10,7 @@ import ( "time" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/processing" ) func TestCardActionTriggerRegistered(t *testing.T) { @@ -243,11 +244,11 @@ func TestProcessCardAction_MalformedPayload(t *testing.T) { Timestamp: time.Now(), } got, err := processCardAction(context.Background(), nil, raw, nil) - if err != nil { - t.Fatalf("Process should swallow parse errors, got %v", err) + if !processing.IsDropMalformed(err) { + t.Fatalf("malformed payload must be dropped with a malformed marker, got err=%v", err) } - if string(got) != "not json" { - t.Errorf("malformed fallback output = %q, want original bytes", string(got)) + if got != nil { + t.Errorf("malformed payload must be dropped without output, got %q", string(got)) } } diff --git a/events/im/message_receive.go b/events/im/message_receive.go index ae73021232..b1d9091e10 100644 --- a/events/im/message_receive.go +++ b/events/im/message_receive.go @@ -8,6 +8,7 @@ import ( "encoding/json" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/processing" convertlib "github.com/larksuite/cli/shortcuts/im/convert_lib" ) @@ -68,7 +69,7 @@ func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.Ra } `json:"event"` } if err := json.Unmarshal(raw.Payload, &envelope); err != nil { - return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event + return nil, processing.DropMalformed(raw.EventType) } msg := envelope.Event.Message diff --git a/events/im/message_receive_test.go b/events/im/message_receive_test.go index 40acb0bae0..e5414c73b9 100644 --- a/events/im/message_receive_test.go +++ b/events/im/message_receive_test.go @@ -11,6 +11,7 @@ import ( "time" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/processing" ) func TestMain(m *testing.M) { @@ -232,11 +233,11 @@ func TestProcessImMessageReceive_MalformedPayload(t *testing.T) { Timestamp: time.Now(), } got, err := processImMessageReceive(context.Background(), nil, raw, nil) - if err != nil { - t.Fatalf("Process should swallow parse errors, got %v", err) + if !processing.IsDropMalformed(err) { + t.Fatalf("malformed payload must be dropped with a malformed marker, got err=%v", err) } - if string(got) != "not json" { - t.Errorf("malformed fallback output = %q, want original bytes", string(got)) + if got != nil { + t.Errorf("malformed payload must be dropped without output, got %q", string(got)) } } diff --git a/events/minutes/minute_generated.go b/events/minutes/minute_generated.go index f4e4ec9dad..8c4260a05c 100644 --- a/events/minutes/minute_generated.go +++ b/events/minutes/minute_generated.go @@ -10,6 +10,7 @@ import ( "time" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/processing" "github.com/larksuite/cli/internal/validate" ) @@ -50,7 +51,7 @@ func processMinutesMinuteGenerated(ctx context.Context, rt event.APIClient, raw } `json:"event"` } if err := json.Unmarshal(raw.Payload, &envelope); err != nil { - return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event + return nil, processing.DropMalformed(raw.EventType) } out := &MinutesMinuteGeneratedOutput{ diff --git a/events/minutes/minute_generated_test.go b/events/minutes/minute_generated_test.go index 9a0a5b13ea..33c2de1ce8 100644 --- a/events/minutes/minute_generated_test.go +++ b/events/minutes/minute_generated_test.go @@ -13,6 +13,7 @@ import ( "time" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/processing" "github.com/larksuite/cli/internal/validate" ) @@ -326,11 +327,11 @@ func TestProcessMinutesMinuteGenerated_MalformedPayload(t *testing.T) { Timestamp: time.Now(), } got, err := processMinutesMinuteGenerated(context.Background(), nil, raw, nil) - if err != nil { - t.Fatalf("Process should swallow parse errors, got %v", err) + if !processing.IsDropMalformed(err) { + t.Fatalf("malformed payload must be dropped with a malformed marker, got err=%v", err) } - if string(got) != "not json" { - t.Errorf("malformed fallback output = %q, want original bytes", string(got)) + if got != nil { + t.Errorf("malformed payload must be dropped without output, got %q", string(got)) } } diff --git a/events/schema_closure_test.go b/events/schema_closure_test.go new file mode 100644 index 0000000000..edbd0ba9ef --- /dev/null +++ b/events/schema_closure_test.go @@ -0,0 +1,77 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package events_test + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + event "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/processing" + + _ "github.com/larksuite/cli/events" +) + +// closureAPIClient answers any API call with a benign error: a handler facing +// a malformed payload must decide to drop before it ever needs the API. +type closureAPIClient struct{} + +func (closureAPIClient) CallAPI(context.Context, string, string, any) (json.RawMessage, error) { + return nil, errors.New("no API access for malformed input") +} + +// Every Processed EventKey declares an output schema; its stdout must stay +// inside that schema. A payload that cannot be decoded therefore has exactly +// one legal outcome: a malformed drop. Passing the raw envelope through would +// hand consumers a shape the schema never described. +// +// Native keys (Process == nil) are exempt by contract: their declared output +// is the raw envelope itself. +func TestAllKeys_MalformedPayloadStaysSchemaClosed(t *testing.T) { + const wantProcessedKeys = 13 + + processed := 0 + for _, def := range event.ListAll() { + if def.Process == nil { + continue + } + processed++ + out, err := safeProcess(t, def, json.RawMessage(`this is definitely not valid json {{{`)) + if out != nil { + t.Errorf("%s: malformed payload produced stdout output; it must be dropped", def.Key) + } + if !processing.IsDropMalformed(err) { + t.Errorf("%s: malformed payload must be dropped with a malformed marker, got err=%v", def.Key, err) + } + } + if processed == 0 { + t.Fatal("no processed keys were exercised; the gate scanned nothing") + } + if processed != wantProcessedKeys { + t.Fatalf("exercised %d processed keys, want exactly %d; update the count when keys are deliberately added or removed", processed, wantProcessedKeys) + } +} + +// safeProcess isolates a panicking handler to a per-key finding instead of +// aborting the whole gate: a handler that dereferences before decoding is a +// bug in that key, not a reason to stop scanning the rest. +func safeProcess(t *testing.T, def *event.KeyDefinition, payload json.RawMessage) (out json.RawMessage, err error) { + t.Helper() + defer func() { + if r := recover(); r != nil { + t.Errorf("%s: Process panicked on malformed payload: %v", def.Key, r) + out, err = nil, nil + } + }() + raw := &event.RawEvent{ + EventID: "evt-closure-1", + EventType: def.EventType, + Payload: payload, + Timestamp: time.Unix(0, 0), + } + return def.Process(context.Background(), closureAPIClient{}, raw, map[string]string{}) +} diff --git a/events/vc/note_generated.go b/events/vc/note_generated.go index ac5e45760d..ce79888409 100644 --- a/events/vc/note_generated.go +++ b/events/vc/note_generated.go @@ -11,6 +11,7 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/processing" "github.com/larksuite/cli/internal/validate" ) @@ -52,7 +53,7 @@ func processVCNoteGenerated(ctx context.Context, rt event.APIClient, raw *event. } `json:"event"` } if err := json.Unmarshal(raw.Payload, &envelope); err != nil { - return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event + return nil, processing.DropMalformed(raw.EventType) } out := &VCNoteGeneratedOutput{ diff --git a/events/vc/note_generated_test.go b/events/vc/note_generated_test.go index c45186236d..72ab924a78 100644 --- a/events/vc/note_generated_test.go +++ b/events/vc/note_generated_test.go @@ -10,6 +10,7 @@ import ( "time" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/processing" ) func TestVCKeys_ProcessedNoteGeneratedRegistered(t *testing.T) { @@ -301,11 +302,11 @@ func TestProcessVCNoteGenerated_MalformedPayload(t *testing.T) { Timestamp: time.Now(), } got, err := processVCNoteGenerated(context.Background(), nil, raw, nil) - if err != nil { - t.Fatalf("Process should swallow parse errors, got %v", err) + if !processing.IsDropMalformed(err) { + t.Fatalf("malformed payload must be dropped with a malformed marker, got err=%v", err) } - if string(got) != "not json" { - t.Errorf("malformed fallback output = %q, want original bytes", string(got)) + if got != nil { + t.Errorf("malformed payload must be dropped without output, got %q", string(got)) } } diff --git a/events/vc/participant_meeting_ended.go b/events/vc/participant_meeting_ended.go index 4941b3b753..a26b2ace11 100644 --- a/events/vc/participant_meeting_ended.go +++ b/events/vc/participant_meeting_ended.go @@ -10,6 +10,7 @@ import ( "time" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/processing" ) // VCParticipantMeetingEndedOutput is the flattened shape for vc.meeting.participant_meeting_ended_v1. @@ -44,7 +45,7 @@ func processVCParticipantMeetingEnded(_ context.Context, _ event.APIClient, raw } `json:"event"` } if err := json.Unmarshal(raw.Payload, &envelope); err != nil { - return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event + return nil, processing.DropMalformed(raw.EventType) } meeting := envelope.Event.Meeting diff --git a/events/vc/participant_meeting_ended_test.go b/events/vc/participant_meeting_ended_test.go index 0989f484c3..a334d7a128 100644 --- a/events/vc/participant_meeting_ended_test.go +++ b/events/vc/participant_meeting_ended_test.go @@ -11,6 +11,7 @@ import ( "time" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/processing" ) func TestMain(m *testing.M) { @@ -130,11 +131,11 @@ func TestProcessVCParticipantMeetingEnded_MalformedPayload(t *testing.T) { Timestamp: time.Now(), } got, err := processVCParticipantMeetingEnded(context.Background(), nil, raw, nil) - if err != nil { - t.Fatalf("Process should swallow parse errors, got %v", err) + if !processing.IsDropMalformed(err) { + t.Fatalf("malformed payload must be dropped with a malformed marker, got err=%v", err) } - if string(got) != "not json" { - t.Errorf("malformed fallback output = %q, want original bytes", string(got)) + if got != nil { + t.Errorf("malformed payload must be dropped without output, got %q", string(got)) } } diff --git a/events/vc/participant_meeting_joined.go b/events/vc/participant_meeting_joined.go index 99ac9e76d3..804b5ce040 100644 --- a/events/vc/participant_meeting_joined.go +++ b/events/vc/participant_meeting_joined.go @@ -8,6 +8,7 @@ import ( "encoding/json" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/processing" ) // VCParticipantMeetingJoinedOutput is the flattened shape for vc.meeting.participant_meeting_joined_v1. @@ -41,7 +42,7 @@ func processVCParticipantMeetingJoined(_ context.Context, _ event.APIClient, raw } `json:"event"` } if err := json.Unmarshal(raw.Payload, &envelope); err != nil { - return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event + return nil, processing.DropMalformed(raw.EventType) } meeting := envelope.Event.Meeting diff --git a/events/vc/participant_meeting_lifecycle_test.go b/events/vc/participant_meeting_lifecycle_test.go index c67b9654fe..976d5ef50f 100644 --- a/events/vc/participant_meeting_lifecycle_test.go +++ b/events/vc/participant_meeting_lifecycle_test.go @@ -11,6 +11,7 @@ import ( "time" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/processing" ) func TestVCKeys_ProcessedMeetingLifecycleRegistered(t *testing.T) { @@ -193,11 +194,11 @@ func TestProcessVCParticipantMeetingLifecycle_MalformedPayload(t *testing.T) { 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 !processing.IsDropMalformed(err) { + t.Fatalf("malformed payload must be dropped with a malformed marker, got err=%v", err) } - if string(got) != "not json" { - t.Errorf("malformed fallback output = %q, want original bytes", string(got)) + if got != nil { + t.Errorf("malformed payload must be dropped without output, got %q", string(got)) } }) } diff --git a/events/vc/participant_meeting_started.go b/events/vc/participant_meeting_started.go index d91aa3ebe7..5e2a2aaf46 100644 --- a/events/vc/participant_meeting_started.go +++ b/events/vc/participant_meeting_started.go @@ -8,6 +8,7 @@ import ( "encoding/json" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/processing" ) // VCParticipantMeetingStartedOutput is the flattened shape for vc.meeting.participant_meeting_started_v1. @@ -40,7 +41,7 @@ func processVCParticipantMeetingStarted(_ context.Context, _ event.APIClient, ra } `json:"event"` } if err := json.Unmarshal(raw.Payload, &envelope); err != nil { - return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event + return nil, processing.DropMalformed(raw.EventType) } meeting := envelope.Event.Meeting diff --git a/events/vc/recording_ended.go b/events/vc/recording_ended.go index bc0a4e3c71..0ec5987a38 100644 --- a/events/vc/recording_ended.go +++ b/events/vc/recording_ended.go @@ -10,6 +10,7 @@ import ( "time" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/processing" ) // VCRecordingEndedOutput is the flattened shape for vc.recording.recording_ended_v1. @@ -38,7 +39,7 @@ type recordingEndedEvent struct { 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 + return nil, processing.DropMalformed(raw.EventType) } if !isRecordingEndedBeanEvent(envelope) { return nil, nil diff --git a/events/vc/recording_started.go b/events/vc/recording_started.go index 00d51caf03..33d2eea125 100644 --- a/events/vc/recording_started.go +++ b/events/vc/recording_started.go @@ -10,6 +10,7 @@ import ( "time" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/processing" ) // VCRecordingStartedOutput is the flattened shape for vc.recording.recording_started_v1. @@ -38,7 +39,7 @@ type recordingStartedEvent struct { 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 + return nil, processing.DropMalformed(raw.EventType) } if !isRecordingStartedBeanEvent(envelope) { return nil, nil diff --git a/events/vc/recording_test.go b/events/vc/recording_test.go index 89ba244870..fa82dd961b 100644 --- a/events/vc/recording_test.go +++ b/events/vc/recording_test.go @@ -12,6 +12,7 @@ import ( "time" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/processing" ) func TestVCKeys_RecordingEventsRegistered(t *testing.T) { @@ -351,7 +352,7 @@ func TestProcessVCRecording_NonRecordingBeanFiltered(t *testing.T) { } } -func TestProcessVCRecording_MalformedPayloadPassthrough(t *testing.T) { +func TestProcessVCRecording_MalformedPayloadDrop(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) for _, tc := range []struct { @@ -370,11 +371,11 @@ func TestProcessVCRecording_MalformedPayloadPassthrough(t *testing.T) { 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 !processing.IsDropMalformed(err) { + t.Fatalf("malformed payload must be dropped with a malformed marker, got err=%v", err) } - if string(got) != "not json" { - t.Errorf("malformed fallback output = %q, want original bytes", string(got)) + if got != nil { + t.Errorf("malformed payload must be dropped without output, got %q", string(got)) } }) } diff --git a/events/vc/recording_transcript_generated.go b/events/vc/recording_transcript_generated.go index fe609bbff0..2540c84689 100644 --- a/events/vc/recording_transcript_generated.go +++ b/events/vc/recording_transcript_generated.go @@ -10,6 +10,7 @@ import ( "time" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/processing" ) // VCRecordingTranscriptItemOutput is one flattened transcript item for recording events. @@ -63,7 +64,7 @@ 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 + return nil, processing.DropMalformed(raw.EventType) } if !isRecordingTranscriptGeneratedBeanEvent(envelope) { return nil, nil diff --git a/internal/event/consume/diagnostics_redaction_test.go b/internal/event/consume/diagnostics_redaction_test.go new file mode 100644 index 0000000000..a6897fb893 --- /dev/null +++ b/internal/event/consume/diagnostics_redaction_test.go @@ -0,0 +1,113 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package consume + +import ( + "bytes" + "context" + "encoding/json" + "strings" + "testing" + + event "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/model" + "github.com/larksuite/cli/internal/event/processing" + "github.com/larksuite/cli/internal/event/protocol" +) + +// assertNoPayloadBytes fails when a diagnostic line carries payload content. +// Diagnostics may name identity facts (event id, type, field names) but the +// payload itself must never reach stderr — it can hold user content, tokens, +// or anything else the upstream put there. +func assertNoPayloadBytes(t *testing.T, diagnostic, sentinel string) { + t.Helper() + if strings.Contains(diagnostic, sentinel) { + t.Errorf("diagnostic leaks payload content: %s", diagnostic) + } +} + +// The detector itself must bite: a deliberately leaky diagnostic has to be +// caught, otherwise a green redaction suite proves nothing. +func TestRedactionDetector_CatchesALeak(t *testing.T) { + const sentinel = "SENSITIVE-VALUE-123" + leaky := "WARN: dropped event, payload was: {\"token\":\"" + sentinel + "\"}" + if !strings.Contains(leaky, sentinel) { + t.Fatal("control diagnostic lost its sentinel; the detector cannot be trusted") + } +} + +// A malformed payload is dropped through the real pipeline with a diagnostic +// that names the event, not its content. +func TestMalformedDrop_DiagnosticNamesIdentityOnly(t *testing.T) { + const sentinel = "API-KEY-LIKE-CONTENT-abcdef123456" + keyDef := &event.KeyDefinition{ + Key: "test.evt_redaction", + EventType: "test.evt_redaction", + Process: func(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) { + return nil, processing.DropMalformed(raw.EventType) + }, + } + var stderr bytes.Buffer + var stdout bytes.Buffer + opts := Options{ErrOut: &stderr, Params: map[string]string{}} + sink := &WriterSink{W: &stdout} + + evt := protocol.NewEvent(&model.Event{ + EventID: "evt-redact-1", + EventType: "test.evt_redaction", + Payload: json.RawMessage(`{"garbage": "` + sentinel + `"`), + }, 1) + + wrote, err := processAndOutput(context.Background(), keyDef, evt, opts, sink, nil) + if wrote || err != nil { + t.Fatalf("malformed event must be dropped silently from stdout: wrote=%v err=%v", wrote, err) + } + if stdout.Len() != 0 { + t.Errorf("nothing may reach stdout for a dropped event, got: %s", stdout.String()) + } + diag := stderr.String() + if !strings.Contains(diag, "dropped: malformed payload") { + t.Errorf("expected a malformed-drop diagnostic, got: %q", diag) + } + if !strings.Contains(diag, "evt-redact-1") { + t.Errorf("diagnostic must anchor on the event id, got: %q", diag) + } + assertNoPayloadBytes(t, diag, sentinel) +} + +// A metadata conflict is dropped through the real pipeline the same way. +func TestConflictDrop_DiagnosticNamesIdentityOnly(t *testing.T) { + const sentinel = "PRIVATE-MESSAGE-TEXT-qwerty" + keyDef := &event.KeyDefinition{ + Key: "test.evt_conflict_redaction", + EventType: "test.evt_conflict_redaction", + } + var stderr bytes.Buffer + var stdout bytes.Buffer + opts := Options{ErrOut: &stderr, Params: map[string]string{}} + sink := &WriterSink{W: &stdout} + + payload, _ := json.Marshal(map[string]any{ + "header": map[string]string{"event_id": "evt-forged"}, + "event": map[string]string{"text": sentinel}, + }) + evt := protocol.NewEvent(&model.Event{ + EventID: "evt-real", + EventType: "test.evt_conflict_redaction", + Payload: payload, + }, 1) + + wrote, err := processAndOutput(context.Background(), keyDef, evt, opts, sink, nil) + if wrote || err != nil { + t.Fatalf("conflicting event must be dropped: wrote=%v err=%v", wrote, err) + } + if stdout.Len() != 0 { + t.Errorf("nothing may reach stdout for a dropped event, got: %s", stdout.String()) + } + diag := stderr.String() + if !strings.Contains(diag, "conflicts with canonical metadata (field=event_id)") { + t.Errorf("expected a conflict diagnostic naming the field, got: %q", diag) + } + assertNoPayloadBytes(t, diag, sentinel) +} diff --git a/internal/event/processing/result.go b/internal/event/processing/result.go new file mode 100644 index 0000000000..8456fdd08f --- /dev/null +++ b/internal/event/processing/result.go @@ -0,0 +1,40 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package processing defines how a domain processor's outcome is interpreted +// by the consume pipeline: emit exactly what the key's schema declares, or +// drop with a stated reason — never leak an undeclared shape to stdout. +package processing + +import ( + "context" + "encoding/json" + "errors" +) + +// APIClient is the narrow API surface handed to domain hooks. Identity stays +// opaque on purpose so business code cannot bypass the pre-flight checks. +type APIClient interface { + CallAPI(ctx context.Context, method, path string, body any) (json.RawMessage, error) +} + +type dropMalformedError struct{ eventType string } + +func (d *dropMalformedError) Error() string { + return "malformed payload for " + d.eventType +} + +// DropMalformed signals that the payload could not be decoded into the shape +// this EventKey declares. The event is dropped instead of passing the raw +// envelope through to stdout, which would violate the declared output schema. +func DropMalformed(eventType string) error { + return &dropMalformedError{eventType: eventType} +} + +// IsDropMalformed reports whether err marks an event dropped for a malformed +// payload, letting the pipeline pick the right diagnostic without parsing +// error strings. +func IsDropMalformed(err error) bool { + var d *dropMalformedError + return errors.As(err, &d) +} diff --git a/internal/event/types.go b/internal/event/types.go index a777e2dd11..fcd82d1931 100644 --- a/internal/event/types.go +++ b/internal/event/types.go @@ -10,6 +10,7 @@ import ( "reflect" "github.com/larksuite/cli/internal/event/model" + "github.com/larksuite/cli/internal/event/processing" "github.com/larksuite/cli/internal/event/schemas" ) @@ -22,10 +23,10 @@ const ( // contracts. The alias keeps every existing reference compiling unchanged. type RawEvent = model.Event -// APIClient: identity is opaque so business code can't bypass pre-flight checks. -type APIClient interface { - CallAPI(ctx context.Context, method, path string, body interface{}) (json.RawMessage, error) -} +// APIClient is the narrow API surface handed to domain hooks; see +// processing.APIClient for the contract. The alias keeps existing references +// compiling unchanged. +type APIClient = processing.APIClient type ParamType string From a5d30fe7dc43b2c735674b67a99b32195f7f0a40 Mon Sep 17 00:00:00 2001 From: leave330 Date: Fri, 31 Jul 2026 23:26:56 +0800 Subject: [PATCH 13/61] fix(event): scope whiteboard subscriptions by whiteboard id The whiteboard schema golden gains the subscription-key marker on whiteboard_id; that visible diff is the intended outcome of this fix. --- cmd/event/testdata/golden/list_json.golden | 3 +- .../schema_board_whiteboard_json.golden | 3 +- .../schema_board_whiteboard_text.golden | 2 +- events/whiteboard/register.go | 13 ++++-- events/whiteboard/subscription_scope_test.go | 36 +++++++++++++++++ .../event/consume/fingerprint_scope_test.go | 40 +++++++++++++++++++ 6 files changed, 90 insertions(+), 7 deletions(-) create mode 100644 events/whiteboard/subscription_scope_test.go create mode 100644 internal/event/consume/fingerprint_scope_test.go diff --git a/cmd/event/testdata/golden/list_json.golden b/cmd/event/testdata/golden/list_json.golden index 665c0204a2..433177a88f 100644 --- a/cmd/event/testdata/golden/list_json.golden +++ b/cmd/event/testdata/golden/list_json.golden @@ -320,7 +320,8 @@ "name": "whiteboard_id", "type": "string", "required": true, - "description": "Whiteboard id to subscribe; subscription is per-whiteboard." + "description": "Whiteboard id to subscribe; subscription is per-whiteboard.", + "subscription_key": true } ], "schema": { diff --git a/cmd/event/testdata/golden/schema_board_whiteboard_json.golden b/cmd/event/testdata/golden/schema_board_whiteboard_json.golden index 582737b41c..91fec1a3fb 100644 --- a/cmd/event/testdata/golden/schema_board_whiteboard_json.golden +++ b/cmd/event/testdata/golden/schema_board_whiteboard_json.golden @@ -9,7 +9,8 @@ "name": "whiteboard_id", "type": "string", "required": true, - "description": "Whiteboard id to subscribe; subscription is per-whiteboard." + "description": "Whiteboard id to subscribe; subscription is per-whiteboard.", + "subscription_key": true } ], "schema": { diff --git a/cmd/event/testdata/golden/schema_board_whiteboard_text.golden b/cmd/event/testdata/golden/schema_board_whiteboard_text.golden index be00fccc20..fb172c4b8b 100644 --- a/cmd/event/testdata/golden/schema_board_whiteboard_text.golden +++ b/cmd/event/testdata/golden/schema_board_whiteboard_text.golden @@ -11,7 +11,7 @@ Required Console Events (must be enabled in developer console): Parameters: NAME TYPE REQUIRED SUB-KEY DEFAULT DESCRIPTION - whiteboard_id string yes no - Whiteboard id to subscribe; subscription is per-whiteboard. + whiteboard_id string yes yes - Whiteboard id to subscribe; subscription is per-whiteboard. Output Schema: { diff --git a/events/whiteboard/register.go b/events/whiteboard/register.go index 7e81ec053b..5c8d01c3a5 100644 --- a/events/whiteboard/register.go +++ b/events/whiteboard/register.go @@ -24,10 +24,15 @@ func Keys() []event.KeyDefinition { EventType: eventTypeWhiteboardUpdated, Params: []event.ParamDef{ { - Name: "whiteboard_id", - Type: event.ParamString, - Required: true, - Description: "Whiteboard id to subscribe; subscription is per-whiteboard.", + Name: "whiteboard_id", + Type: event.ParamString, + Required: true, + // The server-side subscription is keyed per whiteboard, so + // the id must be part of the consumer's subscription + // identity: consumers of different whiteboards get their + // own setup/cleanup lifecycle instead of sharing one. + SubscriptionKey: true, + Description: "Whiteboard id to subscribe; subscription is per-whiteboard.", }, }, Schema: event.SchemaDef{ diff --git a/events/whiteboard/subscription_scope_test.go b/events/whiteboard/subscription_scope_test.go new file mode 100644 index 0000000000..40c6f39772 --- /dev/null +++ b/events/whiteboard/subscription_scope_test.go @@ -0,0 +1,36 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package whiteboard + +import ( + "testing" + + event "github.com/larksuite/cli/internal/event" +) + +// The whiteboard subscription is registered per whiteboard on the server, so +// the whiteboard id must take part in the consumer's subscription identity. +// Without it, two consumers of different whiteboards share one scope: the +// second consumer's setup never runs (its whiteboard is never subscribed) and +// whichever exits last unsubscribes the other one's still-active board. +func TestWhiteboardID_IsPartOfSubscriptionIdentity(t *testing.T) { + defs := Keys() + if len(defs) != 1 { + t.Fatalf("expected exactly one whiteboard key, got %d", len(defs)) + } + def := defs[0] + + var found *event.ParamDef + for i := range def.Params { + if def.Params[i].Name == "whiteboard_id" { + found = &def.Params[i] + } + } + if found == nil { + t.Fatal("whiteboard_id param is missing") + } + if !found.SubscriptionKey { + t.Error("whiteboard_id must be a subscription key: the server-side subscription is per-whiteboard") + } +} diff --git a/internal/event/consume/fingerprint_scope_test.go b/internal/event/consume/fingerprint_scope_test.go new file mode 100644 index 0000000000..025dd29147 --- /dev/null +++ b/internal/event/consume/fingerprint_scope_test.go @@ -0,0 +1,40 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package consume + +import ( + "encoding/json" + "testing" + + event "github.com/larksuite/cli/internal/event" +) + +// A key whose server-side subscription is parameter-scoped must derive +// distinct subscription identities per parameter value: consumers of +// different resources own independent setup/cleanup lifecycles, while +// consumers of the same resource share one. +func TestSubscriptionKeyParam_SplitsScopes(t *testing.T) { + def := &event.KeyDefinition{ + Key: "test.evt_scoped", + EventType: "test.evt_scoped", + Schema: event.SchemaDef{Custom: &event.SchemaSpec{Raw: json.RawMessage(`{"type":"object"}`)}}, + Params: []event.ParamDef{ + {Name: "resource_id", Type: event.ParamString, Required: true, SubscriptionKey: true}, + }, + } + + a1 := ComputeSubscriptionID(def, map[string]string{"resource_id": "board-A"}) + a2 := ComputeSubscriptionID(def, map[string]string{"resource_id": "board-A"}) + b := ComputeSubscriptionID(def, map[string]string{"resource_id": "board-B"}) + + if a1 != a2 { + t.Errorf("same resource must share one scope: %q vs %q", a1, a2) + } + if a1 == b { + t.Error("different resources must not share a subscription scope") + } + if a1 == def.Key || b == def.Key { + t.Error("scoped identities must not degenerate to the bare key") + } +} From 5a3b112fa733ba18bbbdb9e71dee0d82685eeb3c Mon Sep 17 00:00:00 2001 From: leave330 Date: Sat, 1 Aug 2026 00:03:13 +0800 Subject: [PATCH 14/61] refactor(event): compile event declarations into a validated immutable snapshot --- events/register.go | 34 --- internal/event/catalog/canonicalize.go | 71 ++++++ internal/event/catalog/compile.go | 275 ++++++++++++++++++++++++ internal/event/catalog/compile_test.go | 238 ++++++++++++++++++++ internal/event/catalog/definition.go | 167 ++++++++++++++ internal/event/catalog/snapshot.go | 184 ++++++++++++++++ internal/event/catalog/snapshot_test.go | 184 ++++++++++++++++ internal/event/catalog/strategy.go | 35 +++ internal/event/registry.go | 144 ------------- internal/event/types.go | 171 +++------------ 10 files changed, 1181 insertions(+), 322 deletions(-) delete mode 100644 events/register.go create mode 100644 internal/event/catalog/canonicalize.go create mode 100644 internal/event/catalog/compile.go create mode 100644 internal/event/catalog/compile_test.go create mode 100644 internal/event/catalog/definition.go create mode 100644 internal/event/catalog/snapshot.go create mode 100644 internal/event/catalog/snapshot_test.go create mode 100644 internal/event/catalog/strategy.go delete mode 100644 internal/event/registry.go diff --git a/events/register.go b/events/register.go deleted file mode 100644 index b45406bfc0..0000000000 --- a/events/register.go +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -// Package events wires domain EventKey definitions into the global registry. Blank-import to populate. -package events - -import ( - "github.com/larksuite/cli/events/application" - "github.com/larksuite/cli/events/approval" - "github.com/larksuite/cli/events/im" - "github.com/larksuite/cli/events/minutes" - "github.com/larksuite/cli/events/task" - "github.com/larksuite/cli/events/vc" - "github.com/larksuite/cli/events/whiteboard" - "github.com/larksuite/cli/internal/event" -) - -// Mail is intentionally omitted in this phase. -func init() { - all := [][]event.KeyDefinition{ - application.Keys(), - approval.Keys(), - im.Keys(), - minutes.Keys(), - task.Keys(), - vc.Keys(), - whiteboard.Keys(), - } - for _, keys := range all { - for _, k := range keys { - event.RegisterKey(k) - } - } -} diff --git a/internal/event/catalog/canonicalize.go b/internal/event/catalog/canonicalize.go new file mode 100644 index 0000000000..b6fddbcc54 --- /dev/null +++ b/internal/event/catalog/canonicalize.go @@ -0,0 +1,71 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package catalog + +import ( + "maps" + "slices" + "strings" +) + +// Canonicalize returns the normalized copy of a declaration: the defaults +// that used to be applied at registration time, made explicit. Projections +// and the compatibility view are both built from the canonical form, so +// round-trip checks compare against Canonicalize(input), never the raw input. +func Canonicalize(def KeyDefinition) KeyDefinition { + out := deepCopyDefinition(def) + if out.SubscriptionType == "" { + out.SubscriptionType = SubTypeEvent + } + if out.BufferSize > MaxBufferSize { + out.BufferSize = MaxBufferSize + } + if out.BufferSize <= 0 { + out.BufferSize = DefaultBufferSize + } + if out.Workers <= 0 { + out.Workers = 1 + } + return out +} + +// DerivedDomain returns the declaration's explicit Domain, or the key's first +// dot segment. The derived value feeds the Descriptor only — it is never +// written back into the compatibility view, so a declaration that said +// nothing keeps saying nothing in legacy JSON output. +func DerivedDomain(def *KeyDefinition) string { + if def.Domain != "" { + return def.Domain + } + domain, _, found := strings.Cut(def.Key, ".") + if !found || domain == "" { + return def.Key + } + return domain +} + +// deepCopyDefinition clones every mutable member so neither the caller's +// declaration nor a compiled entry can be changed through the other. +func deepCopyDefinition(def KeyDefinition) KeyDefinition { + out := def + out.Params = slices.Clone(def.Params) + for i := range out.Params { + out.Params[i].Values = slices.Clone(def.Params[i].Values) + } + out.Scopes = slices.Clone(def.Scopes) + out.AuthTypes = slices.Clone(def.AuthTypes) + out.RequiredConsoleEvents = slices.Clone(def.RequiredConsoleEvents) + if def.Schema.Native != nil { + spec := *def.Schema.Native + spec.Raw = slices.Clone(def.Schema.Native.Raw) + out.Schema.Native = &spec + } + if def.Schema.Custom != nil { + spec := *def.Schema.Custom + spec.Raw = slices.Clone(def.Schema.Custom.Raw) + out.Schema.Custom = &spec + } + out.Schema.FieldOverrides = maps.Clone(def.Schema.FieldOverrides) + return out +} diff --git a/internal/event/catalog/compile.go b/internal/event/catalog/compile.go new file mode 100644 index 0000000000..7fd100edd8 --- /dev/null +++ b/internal/event/catalog/compile.go @@ -0,0 +1,275 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package catalog + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + + "github.com/larksuite/cli/internal/event/schemas" +) + +// Compile canonicalizes and validates every declaration, resolves each key's +// output schema, and projects the result into an immutable snapshot. It is +// the only way to obtain a Snapshot; invalid declarations produce an error +// and never a live snapshot. +func Compile(defs []KeyDefinition, strategies StrategySet) (*Snapshot, error) { + var problems []string + fail := func(format string, args ...any) { + problems = append(problems, fmt.Sprintf(format, args...)) + } + + entries := make(map[string]*Entry, len(defs)) + keys := make([]string, 0, len(defs)) + + for i := range defs { + def := Canonicalize(defs[i]) + + if def.Key == "" { + fail("declaration %d: Key must not be empty", i) + continue + } + if _, dup := entries[def.Key]; dup { + fail("duplicate EventKey: %s", def.Key) + continue + } + if errs := validateDefinition(&def); len(errs) > 0 { + problems = append(problems, errs...) + continue + } + + schemaJSON, orphans, err := resolveSchemaJSON(&def) + if err != nil { + fail("EventKey %s: resolve output schema: %v", def.Key, err) + continue + } + if len(orphans) > 0 { + fail("EventKey %s: field overrides point at paths the schema does not have: %s", + def.Key, strings.Join(orphans, ", ")) + continue + } + if isPlaceholderSchema(schemaJSON) { + fail("EventKey %s: declared schema resolves to an empty placeholder; declare the real output shape", def.Key) + continue + } + + preparation := StrategyNone + if def.PreConsume != nil { + preparation = StrategyLegacyPreConsume + } + if strategies == nil || !strategies.Has(preparation) { + fail("EventKey %s: preparation strategy %q is not provided by the strategy set", def.Key, preparation) + continue + } + + mode := OutputProcessed + if def.Schema.Native != nil { + mode = OutputNative + } + entries[def.Key] = &Entry{ + descriptor: Descriptor{ + Key: def.Key, + Domain: DerivedDomain(&def), + DisplayName: def.DisplayName, + Description: def.Description, + EventType: def.EventType, + SubscriptionType: def.SubscriptionType, + Params: cloneParams(def.Params), + Scopes: append([]string(nil), def.Scopes...), + AuthTypes: append([]string(nil), def.AuthTypes...), + RequiredConsoleEvents: append([]string(nil), def.RequiredConsoleEvents...), + }, + output: OutputContract{ + Mode: mode, + SchemaJSON: schemaJSON, + JQRootPath: jqRootPath(mode), + }, + capability: Capability{ + Preparation: preparation, + BufferSize: def.BufferSize, + Workers: def.Workers, + SingleConsumer: def.SingleConsumer, + }, + binding: RuntimeBinding{ + NormalizeParams: def.NormalizeParams, + Match: def.Match, + Process: def.Process, + PreConsume: def.PreConsume, + }, + canonical: def, + } + keys = append(keys, def.Key) + } + + if len(problems) > 0 { + return nil, errors.New("event catalog rejected:\n " + strings.Join(problems, "\n ")) + } + + sort.Strings(keys) + return &Snapshot{entries: entries, keys: keys}, nil +} + +// jqRootPath states where consumers address output fields from: native keys +// deliver the V2 envelope (fields under .event), processed keys deliver the +// processor's flat shape (fields at the root). +func jqRootPath(mode OutputMode) string { + if mode == OutputNative { + return ".event" + } + return "." +} + +// validateDefinition runs the per-declaration contract checks. It reports +// every violation instead of stopping at the first. +func validateDefinition(def *KeyDefinition) []string { + var out []string + fail := func(format string, args ...any) { + out = append(out, fmt.Sprintf(format, args...)) + } + + if def.EventType == "" { + fail("EventKey %s: EventType must not be empty", def.Key) + } + if def.SubscriptionType != SubTypeEvent && def.SubscriptionType != SubTypeCallback { + fail("EventKey %s: SubscriptionType must be %q or %q; got %q", + def.Key, SubTypeEvent, SubTypeCallback, def.SubscriptionType) + } + if def.Domain != "" && def.Domain != DerivedDomain(&KeyDefinition{Key: def.Key}) { + fail("EventKey %s: explicit Domain %q does not match the key's first segment", def.Key, def.Domain) + } + + nativeSet := def.Schema.Native != nil + customSet := def.Schema.Custom != nil + switch { + case nativeSet && customSet: + fail("EventKey %s: Schema.Native and Schema.Custom are mutually exclusive", def.Key) + case !nativeSet && !customSet: + fail("EventKey %s: Schema requires either Native or Custom", def.Key) + } + if nativeSet && def.Process != nil { + fail("EventKey %s: Schema.Native forbids Process (Process produces a complete shape — use Schema.Custom)", def.Key) + } + if spec := def.Schema.Native; spec != nil { + out = append(out, validateSpec(def.Key, "Schema.Native", spec)...) + } + if spec := def.Schema.Custom; spec != nil { + out = append(out, validateSpec(def.Key, "Schema.Custom", spec)...) + } + + for _, p := range def.Params { + switch p.Type { + case "", ParamString, ParamBool, ParamInt: + case ParamEnum, ParamMulti: + if len(p.Values) == 0 { + fail("EventKey %s: param %q type %q requires Values", def.Key, p.Name, p.Type) + } + for _, v := range p.Values { + if v.Desc == "" { + fail("EventKey %s: param %q value %q requires non-empty Desc", def.Key, p.Name, v.Value) + } + } + default: + fail("EventKey %s: param %q has unknown type %q", def.Key, p.Name, p.Type) + } + } + + for _, t := range def.AuthTypes { + if t != "user" && t != "bot" { + fail("EventKey %s: AuthTypes elements must be \"user\" or \"bot\"; got %q", def.Key, t) + } + } + return out +} + +func validateSpec(key, field string, s *SchemaSpec) []string { + typeSet := s.Type != nil + rawSet := len(s.Raw) > 0 + if typeSet == rawSet { + return []string{fmt.Sprintf("EventKey %s: %s requires exactly one of Type or Raw", key, field)} + } + return nil +} + +// resolveSchemaJSON returns the final JSON Schema for a declaration +// (reflected base, V2-wrapped for native keys, field overlay applied); +// orphans lists override pointers that resolved to nothing. +func resolveSchemaJSON(def *KeyDefinition) (json.RawMessage, []string, error) { + spec, isNative := pickSpec(def.Schema) + if spec == nil { + return nil, nil, nil + } + + base, err := renderSpec(spec) + if err != nil { + return nil, nil, err + } + if base == nil { + return nil, nil, nil + } + + if isNative { + base = schemas.WrapV2Envelope(base) + } + + if len(def.Schema.FieldOverrides) > 0 { + var parsed map[string]any + if err := json.Unmarshal(base, &parsed); err != nil { + return nil, nil, fmt.Errorf("parse base schema for field overrides: %w", err) + } + orphans := schemas.ApplyFieldOverrides(parsed, def.Schema.FieldOverrides) + out, err := json.Marshal(parsed) + if err != nil { + return nil, nil, fmt.Errorf("serialize schema with field overrides: %w", err) + } + return out, orphans, nil + } + + return base, nil, nil +} + +// pickSpec returns the non-nil spec and whether it is native (V2-wrapped). +func pickSpec(s SchemaDef) (*SchemaSpec, bool) { + if s.Native != nil { + return s.Native, true + } + if s.Custom != nil { + return s.Custom, false + } + return nil, false +} + +// renderSpec produces a JSON Schema from Type (reflected) or Raw (copied). +func renderSpec(s *SchemaSpec) (json.RawMessage, error) { + if s.Type != nil { + return schemas.FromType(s.Type), nil + } + if len(s.Raw) > 0 { + buf := make(json.RawMessage, len(s.Raw)) + copy(buf, s.Raw) + return buf, nil + } + return nil, errors.New("schema spec has neither Type nor Raw") +} + +// isPlaceholderSchema rejects declarations whose schema decodes but describes +// nothing: an empty document, empty object, or null. Per-declaration checks +// only see "raw bytes are non-empty" — this closes that gap. +func isPlaceholderSchema(schema json.RawMessage) bool { + trimmed := bytes.TrimSpace(schema) + if len(trimmed) == 0 { + return true + } + if bytes.Equal(trimmed, []byte("null")) || bytes.Equal(trimmed, []byte("{}")) { + return true + } + var asMap map[string]json.RawMessage + if err := json.Unmarshal(trimmed, &asMap); err == nil && len(asMap) == 0 { + return true + } + return false +} diff --git a/internal/event/catalog/compile_test.go b/internal/event/catalog/compile_test.go new file mode 100644 index 0000000000..c94fc518e0 --- /dev/null +++ b/internal/event/catalog/compile_test.go @@ -0,0 +1,238 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package catalog + +import ( + "context" + "encoding/json" + "reflect" + "strings" + "testing" + + "github.com/larksuite/cli/internal/event/model" + "github.com/larksuite/cli/internal/event/processing" + "github.com/larksuite/cli/internal/event/schemas" +) + +var testStrategies = StrategyRefs{StrategyNone, StrategyLegacyPreConsume} + +func validDef() KeyDefinition { + return KeyDefinition{ + Key: "demo.thing.updated_v1", + EventType: "demo.thing.updated_v1", + Schema: SchemaDef{Custom: &SchemaSpec{Raw: json.RawMessage(`{"type":"object","properties":{"id":{"type":"string"}}}`)}}, + } +} + +// The rejection side: every contract violation must fail the compile, and a +// failed compile must never hand back a live snapshot. +func TestCompile_RejectsContractViolations(t *testing.T) { + cases := map[string]struct { + mutate func(*KeyDefinition) + wantMsg string + }{ + "empty event type": { + func(d *KeyDefinition) { d.EventType = "" }, + "EventType must not be empty", + }, + "bad subscription type": { + func(d *KeyDefinition) { d.SubscriptionType = "webhook" }, + "SubscriptionType must be", + }, + "native and custom together": { + func(d *KeyDefinition) { + d.Schema.Native = &SchemaSpec{Raw: json.RawMessage(`{"type":"object","properties":{"x":{}}}`)} + }, + "mutually exclusive", + }, + "neither native nor custom": { + func(d *KeyDefinition) { d.Schema = SchemaDef{} }, + "requires either Native or Custom", + }, + "native with process": { + func(d *KeyDefinition) { + d.Schema = SchemaDef{Native: &SchemaSpec{Raw: json.RawMessage(`{"type":"object","properties":{"x":{}}}`)}} + d.Process = func(context.Context, processing.APIClient, *model.Event, map[string]string) (json.RawMessage, error) { + return nil, nil + } + }, + "forbids Process", + }, + "spec with both type and raw": { + func(d *KeyDefinition) { + d.Schema.Custom.Type = jsonObjectType() + }, + "exactly one of Type or Raw", + }, + "enum param without values": { + func(d *KeyDefinition) { + d.Params = []ParamDef{{Name: "mode", Type: ParamEnum, Description: "mode"}} + }, + "requires Values", + }, + "enum value without desc": { + func(d *KeyDefinition) { + d.Params = []ParamDef{{Name: "mode", Type: ParamEnum, Description: "mode", Values: []ParamValue{{Value: "a"}}}} + }, + "requires non-empty Desc", + }, + "unknown param type": { + func(d *KeyDefinition) { + d.Params = []ParamDef{{Name: "x", Type: "float", Description: "x"}} + }, + "unknown type", + }, + "bad auth type": { + func(d *KeyDefinition) { d.AuthTypes = []string{"tenant"} }, + `must be "user" or "bot"`, + }, + "explicit domain mismatch": { + func(d *KeyDefinition) { d.Domain = "gadget" }, + "does not match the key's first segment", + }, + "placeholder object schema": { + func(d *KeyDefinition) { d.Schema.Custom.Raw = json.RawMessage(`{}`) }, + "empty placeholder", + }, + "placeholder null schema": { + func(d *KeyDefinition) { d.Schema.Custom.Raw = json.RawMessage(`null`) }, + "empty placeholder", + }, + "orphan field override": { + func(d *KeyDefinition) { + d.Schema.FieldOverrides = map[string]schemas.FieldMeta{ + "/no/such/path": {Description: "dangling"}, + } + }, + "paths the schema does not have", + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + def := validDef() + tc.mutate(&def) + snap, err := Compile([]KeyDefinition{def}, testStrategies) + if err == nil { + t.Fatalf("compile must reject this declaration") + } + if !strings.Contains(err.Error(), tc.wantMsg) { + t.Errorf("error should mention %q, got: %v", tc.wantMsg, err) + } + if snap != nil { + t.Error("a failed compile must never produce a live snapshot") + } + }) + } +} + +func TestCompile_RejectsDuplicateKeys(t *testing.T) { + snap, err := Compile([]KeyDefinition{validDef(), validDef()}, testStrategies) + if err == nil || !strings.Contains(err.Error(), "duplicate EventKey") { + t.Fatalf("want duplicate-key rejection, got err=%v", err) + } + if snap != nil { + t.Error("a failed compile must never produce a live snapshot") + } +} + +func TestCompile_RejectsUnknownStrategy(t *testing.T) { + def := validDef() + def.PreConsume = func(context.Context, processing.APIClient, map[string]string) (func() error, error) { + return nil, nil + } + // A strategy set without legacy_preconsume cannot host a PreConsume key. + snap, err := Compile([]KeyDefinition{def}, StrategyRefs{StrategyNone}) + if err == nil || !strings.Contains(err.Error(), "strategy") { + t.Fatalf("want strategy rejection, got err=%v", err) + } + if snap != nil { + t.Error("a failed compile must never produce a live snapshot") + } +} + +// The acceptance side: a compile that rejects everything would be just as +// broken as one that accepts everything. +func TestCompile_AcceptsWellFormedDeclarations(t *testing.T) { + withPrep := validDef() + withPrep.Key = "demo.other.created_v1" + withPrep.EventType = withPrep.Key + withPrep.PreConsume = func(context.Context, processing.APIClient, map[string]string) (func() error, error) { + return nil, nil + } + + snap, err := Compile([]KeyDefinition{validDef(), withPrep}, testStrategies) + if err != nil { + t.Fatalf("well-formed declarations must compile: %v", err) + } + if snap.Len() != 2 { + t.Fatalf("compiled %d keys, want 2", snap.Len()) + } + + plain, _ := snap.Resolve("demo.thing.updated_v1") + if got := plain.Capability().Preparation; got != StrategyNone { + t.Errorf("key without PreConsume must project strategy %q, got %q", StrategyNone, got) + } + prepared, _ := snap.Resolve("demo.other.created_v1") + if got := prepared.Capability().Preparation; got != StrategyLegacyPreConsume { + t.Errorf("key with PreConsume must project strategy %q, got %q", StrategyLegacyPreConsume, got) + } +} + +func TestCompile_CanonicalizesDefaults(t *testing.T) { + def := validDef() + def.BufferSize = 5000 // above the cap + snap, err := Compile([]KeyDefinition{def}, testStrategies) + if err != nil { + t.Fatal(err) + } + entry, _ := snap.Resolve(def.Key) + got := entry.Definition() + if got.SubscriptionType != SubTypeEvent { + t.Errorf("empty SubscriptionType must canonicalize to %q, got %q", SubTypeEvent, got.SubscriptionType) + } + if got.BufferSize != MaxBufferSize { + t.Errorf("BufferSize must clamp to %d, got %d", MaxBufferSize, got.BufferSize) + } + if got.Workers != 1 { + t.Errorf("Workers must default to 1, got %d", got.Workers) + } + cap := entry.Capability() + if cap.BufferSize != MaxBufferSize || cap.Workers != 1 { + t.Errorf("capability must carry canonicalized delivery values, got %+v", cap) + } +} + +func TestCompile_ProjectsOutputContract(t *testing.T) { + custom := validDef() + native := KeyDefinition{ + Key: "demo.native.updated_v1", + EventType: "demo.native.updated_v1", + Schema: SchemaDef{Native: &SchemaSpec{Raw: json.RawMessage(`{"type":"object","properties":{"id":{"type":"string"}}}`)}}, + } + snap, err := Compile([]KeyDefinition{custom, native}, testStrategies) + if err != nil { + t.Fatal(err) + } + + c, _ := snap.Resolve(custom.Key) + if out := c.Output(); out.Mode != OutputProcessed || out.JQRootPath != "." || len(out.SchemaJSON) == 0 { + t.Errorf("custom key contract wrong: %+v", out) + } + n, _ := snap.Resolve(native.Key) + if out := n.Output(); out.Mode != OutputNative || out.JQRootPath != ".event" || len(out.SchemaJSON) == 0 { + t.Errorf("native key contract wrong: %+v", out) + } + // Native schemas are delivered inside the V2 envelope; the resolved + // schema must describe the envelope, not the bare body. + if !strings.Contains(string(n.Output().SchemaJSON), `"header"`) { + t.Error("native schema must be wrapped in the V2 envelope shape") + } +} + +func jsonObjectType() reflect.Type { + return reflect.TypeOf(struct { + ID string `json:"id"` + }{}) +} diff --git a/internal/event/catalog/definition.go b/internal/event/catalog/definition.go new file mode 100644 index 0000000000..58661b68dc --- /dev/null +++ b/internal/event/catalog/definition.go @@ -0,0 +1,167 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package catalog owns the EventKey declaration types and compiles them into +// a validated, immutable snapshot. Declarations are compile-time inputs: they +// are aggregated explicitly (see events.All), checked as a whole, and +// projected into read-only entries — there is no runtime registration. +package catalog + +import ( + "context" + "encoding/json" + "reflect" + + "github.com/larksuite/cli/internal/event/model" + "github.com/larksuite/cli/internal/event/processing" + "github.com/larksuite/cli/internal/event/schemas" +) + +const ( + DefaultBufferSize = 100 + MaxBufferSize = 1000 +) + +type ParamType string + +const ( + ParamString ParamType = "string" + ParamEnum ParamType = "enum" + ParamMulti ParamType = "multi" + ParamBool ParamType = "bool" + ParamInt ParamType = "int" +) + +// SubscriptionType marks whether an EventKey is delivered via Lark event +// subscription or interactive callback subscription. It is a sibling of +// EventType (which holds the concrete Lark event_type string). +type SubscriptionType string + +const ( + // SubTypeEvent: checked against the published app_versions event_infos. + SubTypeEvent SubscriptionType = "event" + // SubTypeCallback: checked against application/get subscribed_callbacks. + SubTypeCallback SubscriptionType = "callback" +) + +// ParamValue.Desc is mandatory so AI consumers can decide which value to pick. +type ParamValue struct { + Value string `json:"value"` + Desc string `json:"desc"` +} + +type ParamDef struct { + Name string `json:"name"` + Type ParamType `json:"type"` + Required bool `json:"required"` + Default string `json:"default,omitempty"` + Description string `json:"description"` + Values []ParamValue `json:"values,omitempty"` + + // SubscriptionKey marks this param as part of the subscription identity. + // Two consumers of the same EventKey but different values for any + // SubscriptionKey-marked param are treated as DISTINCT subscriptions: + // PreConsume runs once per (EventKey, SubscriptionID), cleanup runs once per + // (EventKey, SubscriptionID). + // + // CONTRACT: only mark a param SubscriptionKey if the EventKey's server-side + // subscribe/unsubscribe API is itself scoped to that resource. Lark keys the + // subscription record by (app, user, event_type) and overwrites it rather + // than reference-counting, so for a non-per-resource API the cleanup of one + // resource's last consumer unsubscribes the shared record and silently cuts + // off every other resource sharing that event_type. + // + // Default false = the param is a filter / formatting / metadata param + // and does not affect subscription identity. + SubscriptionKey bool `json:"subscription_key,omitempty"` +} + +type ProcessFunc = func(ctx context.Context, rt processing.APIClient, raw *model.Event, params map[string]string) (json.RawMessage, error) + +// SchemaDef: exactly one of Native or Custom must be set. +// Native auto-wraps the SDK type in the V2 envelope; Custom passes through verbatim. +type SchemaDef struct { + Native *SchemaSpec `json:"native,omitempty"` + Custom *SchemaSpec `json:"custom,omitempty"` + FieldOverrides map[string]schemas.FieldMeta `json:"field_overrides,omitempty"` +} + +// SchemaSpec: exactly one of Type or Raw. +type SchemaSpec struct { + Type reflect.Type `json:"-"` + Raw json.RawMessage `json:"raw,omitempty"` +} + +type KeyDefinition struct { + Key string `json:"key"` + DisplayName string `json:"display_name,omitempty"` + Description string `json:"description,omitempty"` + EventType string `json:"event_type"` + + // Domain is optional in declarations: when empty it is derived from the + // key's first dot segment. When set it must match that segment — the + // compiler rejects a mismatch. The derived value lives on the Descriptor + // only; this field keeps whatever the declaration said (usually nothing), + // so legacy JSON output is unchanged. + Domain string `json:"domain,omitempty"` + + // SubscriptionType selects which console "底账" the precheck reads. + // Empty is normalized to SubTypeEvent by Canonicalize. + SubscriptionType SubscriptionType `json:"subscription_type,omitempty"` + + Params []ParamDef `json:"params,omitempty"` + + Schema SchemaDef `json:"schema"` + + // NormalizeParams canonicalizes param values BEFORE fingerprint compute, + // PreConsume, Match, and Process. Mutates the params map in place. + // May call OAPI; runs once per consumer at startup. + // + // Use cases: resolve aliases ("me" -> real email, a name -> an ID), + // trim whitespace. On error, consume fails (no retry); caller gets the + // wrapped error. + // + // Default nil = no normalization, params pass through unchanged. + NormalizeParams func(ctx context.Context, rt processing.APIClient, params map[string]string) error `json:"-"` + + // Process required when Schema.Custom is Processed output; must be nil when Native is used. + // + // Outcome convention: a non-nil result is emitted; (nil, nil) drops the + // event silently; processing.DropMalformed drops it with a malformed + // diagnostic; any other error drops it with a process-error diagnostic. + // Nothing a Process returns may fall outside the declared schema. + Process ProcessFunc `json:"-"` + + // Match is a synchronous payload filter run on every received event + // BEFORE Process. Return false to drop the event without further work. + // + // Signature deliberately omits ctx/rt to physically enforce "no OAPI + // calls in Match". For filters that need a metadata fetch first, use + // Process and return nil to drop. + // + // Default nil = accept all events. + Match func(raw *model.Event, params map[string]string) bool `json:"-"` + + // PreConsume runs once per (EventKey, SubscriptionID) when this consumer + // is first for that scope. Returns a cleanup function that the framework + // invokes when this consumer is the last for its scope. + // + // The cleanup's error return is honored: on nil the framework prints + // "[event] cleanup done."; on non-nil it prints a WARN with an + // idempotency note. + PreConsume func(ctx context.Context, rt processing.APIClient, params map[string]string) (cleanup func() error, err error) `json:"-"` + + Scopes []string `json:"scopes,omitempty"` + + // AuthTypes: whitelist of identities the EventKey accepts. Empty = no identity required. + AuthTypes []string `json:"auth_types,omitempty"` + + RequiredConsoleEvents []string `json:"required_console_events,omitempty"` + + BufferSize int `json:"buffer_size,omitempty"` + Workers int `json:"workers,omitempty"` + + // SingleConsumer rejects a second consumer for the same SubscriptionID at + // the bus handshake. Default false = unlimited consumers (fan-out). + SingleConsumer bool `json:"single_consumer,omitempty"` +} diff --git a/internal/event/catalog/snapshot.go b/internal/event/catalog/snapshot.go new file mode 100644 index 0000000000..850c118fdf --- /dev/null +++ b/internal/event/catalog/snapshot.go @@ -0,0 +1,184 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package catalog + +import ( + "context" + "encoding/json" + "slices" + "sort" + + "github.com/larksuite/cli/internal/event/model" + "github.com/larksuite/cli/internal/event/processing" +) + +// Descriptor holds the declaration's display facts: everything list/schema +// render, nothing that executes. Domain is always resolved here even when the +// declaration left it empty. +type Descriptor struct { + Key string + Domain string + DisplayName string + Description string + EventType string + SubscriptionType SubscriptionType + Params []ParamDef + Scopes []string + AuthTypes []string + RequiredConsoleEvents []string +} + +// OutputMode states which side of the output contract a key lives on. +type OutputMode string + +const ( + // OutputNative delivers the raw V2 envelope verbatim. + OutputNative OutputMode = "native" + // OutputProcessed delivers what the key's processor emits — and only that. + OutputProcessed OutputMode = "processed" +) + +// OutputContract is the compiled promise about a key's stdout: the fully +// resolved schema and the jq root consumers address fields from. Resolving at +// compile time means an unresolvable schema or a dangling field override is a +// startup failure, not a silently degraded rendering. +type OutputContract struct { + Mode OutputMode + SchemaJSON json.RawMessage + JQRootPath string +} + +// Capability describes how a key's delivery is provisioned and bounded, in +// serializable form: which preparation strategy readies it, how deliveries +// are buffered, and whether a second consumer is rejected. +type Capability struct { + Preparation StrategyRef + BufferSize int + Workers int + SingleConsumer bool +} + +// RuntimeBinding carries the declaration's executable hooks. It has no JSON +// tags on purpose: behavior never travels through a rendering path. +type RuntimeBinding struct { + NormalizeParams func(ctx context.Context, rt processing.APIClient, params map[string]string) error + Match func(raw *model.Event, params map[string]string) bool + Process ProcessFunc + PreConsume func(ctx context.Context, rt processing.APIClient, params map[string]string) (cleanup func() error, err error) +} + +// Entry is one compiled key: four projections composed read-only. Definition +// reassembles the canonical compatibility view from them, which doubles as +// the proof that the projection lost nothing. +type Entry struct { + descriptor Descriptor + output OutputContract + capability Capability + binding RuntimeBinding + // canonical is the Canonicalize'd declaration the entry was compiled + // from; Definition returns deep copies of it. + canonical KeyDefinition +} + +func (e *Entry) Descriptor() Descriptor { + d := e.descriptor + d.Params = cloneParams(e.descriptor.Params) + d.Scopes = slices.Clone(e.descriptor.Scopes) + d.AuthTypes = slices.Clone(e.descriptor.AuthTypes) + d.RequiredConsoleEvents = slices.Clone(e.descriptor.RequiredConsoleEvents) + return d +} + +func (e *Entry) Output() OutputContract { + o := e.output + o.SchemaJSON = slices.Clone(e.output.SchemaJSON) + return o +} + +func (e *Entry) Capability() Capability { return e.capability } + +func (e *Entry) Binding() RuntimeBinding { return e.binding } + +// Definition returns the canonical compatibility view of the declaration this +// entry was compiled from. Callers may mutate the returned value freely. +func (e *Entry) Definition() *KeyDefinition { + def := deepCopyDefinition(e.canonical) + return &def +} + +func cloneParams(params []ParamDef) []ParamDef { + out := slices.Clone(params) + for i := range out { + out[i].Values = slices.Clone(params[i].Values) + } + return out +} + +// Snapshot is the compiled, immutable catalog. Accessors return values or +// fresh copies — never pointers into the snapshot's own state. +type Snapshot struct { + entries map[string]*Entry + keys []string // pre-sorted +} + +// Keys returns every compiled key in stable sorted order. +func (s *Snapshot) Keys() []string { return slices.Clone(s.keys) } + +// Len reports how many keys were compiled. +func (s *Snapshot) Len() int { return len(s.keys) } + +// Resolve returns the entry for a key. The two return values come from the +// same entry, so a definition and its capability can never be mismatched. +func (s *Snapshot) Resolve(key string) (*Entry, bool) { + e, ok := s.entries[key] + return e, ok +} + +// Entries returns all compiled entries in key order. +func (s *Snapshot) Entries() []*Entry { + out := make([]*Entry, 0, len(s.keys)) + for _, k := range s.keys { + out = append(out, s.entries[k]) + } + return out +} + +// Definitions returns the canonical compatibility view of every entry in key +// order — the drop-in replacement for the old registry listing. +func (s *Snapshot) Definitions() []*KeyDefinition { + out := make([]*KeyDefinition, 0, len(s.keys)) + for _, k := range s.keys { + out = append(out, s.entries[k].Definition()) + } + return out +} + +// Domains returns the sorted, deduplicated domain set across all entries. +func (s *Snapshot) Domains() []string { + seen := map[string]bool{} + for _, e := range s.entries { + seen[e.descriptor.Domain] = true + } + out := make([]string, 0, len(seen)) + for d := range seen { + out = append(out, d) + } + sort.Strings(out) + return out +} + +// EventTypes returns the sorted, deduplicated set of upstream event types — +// what a bus subscribes to the platform with. +func (s *Snapshot) EventTypes() []string { + seen := map[string]bool{} + for _, e := range s.entries { + seen[e.descriptor.EventType] = true + } + out := make([]string, 0, len(seen)) + for t := range seen { + out = append(out, t) + } + sort.Strings(out) + return out +} diff --git a/internal/event/catalog/snapshot_test.go b/internal/event/catalog/snapshot_test.go new file mode 100644 index 0000000000..b92741eb62 --- /dev/null +++ b/internal/event/catalog/snapshot_test.go @@ -0,0 +1,184 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package catalog + +import ( + "context" + "encoding/json" + "reflect" + "testing" + + "github.com/larksuite/cli/internal/event/model" + "github.com/larksuite/cli/internal/event/processing" +) + +func compiledFixture(t *testing.T) *Snapshot { + t.Helper() + def := validDef() + def.Params = []ParamDef{{Name: "mode", Type: ParamEnum, Description: "mode", + Values: []ParamValue{{Value: "a", Desc: "first"}}}} + def.Scopes = []string{"demo:read"} + def.AuthTypes = []string{"user"} + def.RequiredConsoleEvents = []string{"demo.thing.updated_v1"} + snap, err := Compile([]KeyDefinition{def}, testStrategies) + if err != nil { + t.Fatal(err) + } + return snap +} + +// Mutating anything an accessor returns must not affect what the snapshot +// hands out next — otherwise one caller can silently rewrite the catalog for +// every other caller. +func TestSnapshot_IsImmutableFromOutside(t *testing.T) { + snap := compiledFixture(t) + entry, _ := snap.Resolve("demo.thing.updated_v1") + + d := entry.Descriptor() + d.Params[0].Name = "tampered" + d.Params[0].Values[0].Value = "tampered" + d.Scopes[0] = "tampered" + d.AuthTypes[0] = "tampered" + d.RequiredConsoleEvents[0] = "tampered" + if fresh := entry.Descriptor(); fresh.Params[0].Name == "tampered" || + fresh.Params[0].Values[0].Value == "tampered" || + fresh.Scopes[0] == "tampered" || + fresh.AuthTypes[0] == "tampered" || + fresh.RequiredConsoleEvents[0] == "tampered" { + t.Error("mutating a returned Descriptor leaked into the snapshot") + } + + out := entry.Output() + if len(out.SchemaJSON) > 0 { + out.SchemaJSON[0] = '!' + if fresh := entry.Output(); fresh.SchemaJSON[0] == '!' { + t.Error("mutating a returned schema leaked into the snapshot") + } + } + + def := entry.Definition() + def.Params[0].Name = "tampered" + def.Scopes[0] = "tampered" + def.Schema.Custom.Raw[0] = '!' + if fresh := entry.Definition(); fresh.Params[0].Name == "tampered" || + fresh.Scopes[0] == "tampered" || + fresh.Schema.Custom.Raw[0] == '!' { + t.Error("mutating a returned Definition leaked into the snapshot") + } + + keys := snap.Keys() + keys[0] = "tampered" + if snap.Keys()[0] == "tampered" { + t.Error("mutating the returned key list leaked into the snapshot") + } +} + +// The compiler deep-copies its input: mutating the declaration after Compile +// must not reach the snapshot either. +func TestSnapshot_DoesNotAliasCompileInput(t *testing.T) { + def := validDef() + def.Scopes = []string{"demo:read"} + snap, err := Compile([]KeyDefinition{def}, testStrategies) + if err != nil { + t.Fatal(err) + } + def.Scopes[0] = "tampered" + entry, _ := snap.Resolve(def.Key) + if entry.Definition().Scopes[0] == "tampered" { + t.Error("the snapshot aliases the caller's declaration") + } +} + +// keyDefinitionRouting states, for every KeyDefinition field, which projection +// carries it. A new field must be routed here (and actually projected) before +// it ships — this is the structural check the round-trip test cannot do, +// because an unprojected field is zero on both sides of a round trip. +var keyDefinitionRouting = map[string]string{ + "Key": "Descriptor", + "DisplayName": "Descriptor", + "Description": "Descriptor", + "EventType": "Descriptor", + "Domain": "Descriptor (derived; compat view keeps the raw declaration)", + "SubscriptionType": "Descriptor", + "Params": "Descriptor", + "Schema": "OutputContract (resolved schema + mode)", + "NormalizeParams": "RuntimeBinding", + "Process": "RuntimeBinding", + "Match": "RuntimeBinding", + "PreConsume": "RuntimeBinding + Capability.Preparation", + "Scopes": "Descriptor", + "AuthTypes": "Descriptor", + "RequiredConsoleEvents": "Descriptor", + "BufferSize": "Capability", + "Workers": "Capability", + "SingleConsumer": "Capability", +} + +func TestProjection_EveryKeyDefinitionFieldIsRouted(t *testing.T) { + typ := reflect.TypeFor[KeyDefinition]() + if typ.NumField() == 0 { + t.Fatal("KeyDefinition has no fields; the gate scanned nothing") + } + seen := map[string]bool{} + for i := 0; i < typ.NumField(); i++ { + name := typ.Field(i).Name + seen[name] = true + if _, ok := keyDefinitionRouting[name]; !ok { + t.Errorf("KeyDefinition.%s is not routed to any projection; route it in the compiler and record it here", name) + } + } + for name := range keyDefinitionRouting { + if !seen[name] { + t.Errorf("routing entry %q is stale: KeyDefinition no longer has that field", name) + } + } +} + +// The round-trip half of the projection proof: a compiled entry's +// compatibility view equals the canonicalized input, hooks included. +func TestProjection_DefinitionRoundTripsCanonicalInput(t *testing.T) { + var normalizeCalls, processCalls int + def := validDef() + def.DisplayName = "Demo thing updated" + def.Description = "fires when the demo thing changes" + def.Params = []ParamDef{{Name: "mode", Type: ParamEnum, Description: "m", + Values: []ParamValue{{Value: "a", Desc: "first"}}, SubscriptionKey: true}} + def.Scopes = []string{"demo:read"} + def.AuthTypes = []string{"user", "bot"} + def.RequiredConsoleEvents = []string{"demo.thing.updated_v1"} + def.SingleConsumer = true + def.NormalizeParams = func(context.Context, processing.APIClient, map[string]string) error { + normalizeCalls++ + return nil + } + def.Process = func(context.Context, processing.APIClient, *model.Event, map[string]string) (json.RawMessage, error) { + processCalls++ + return json.RawMessage(`{}`), nil + } + + snap, err := Compile([]KeyDefinition{def}, testStrategies) + if err != nil { + t.Fatal(err) + } + entry, _ := snap.Resolve(def.Key) + got := entry.Definition() + want := Canonicalize(def) + + // Function values cannot be compared with DeepEqual; prove identity by + // invocation, then blank them for the value comparison. + if got.NormalizeParams == nil || got.Process == nil { + t.Fatal("hooks were dropped by the projection") + } + _ = got.NormalizeParams(context.Background(), nil, nil) + _, _ = got.Process(context.Background(), nil, nil, nil) + if normalizeCalls != 1 || processCalls != 1 { + t.Errorf("projected hooks are not the declared functions: normalize=%d process=%d", normalizeCalls, processCalls) + } + got.NormalizeParams, want.NormalizeParams = nil, nil + got.Process, want.Process = nil, nil + + if !reflect.DeepEqual(*got, want) { + t.Errorf("Definition() != Canonicalize(input)\n got: %+v\nwant: %+v", *got, want) + } +} diff --git a/internal/event/catalog/strategy.go b/internal/event/catalog/strategy.go new file mode 100644 index 0000000000..89b531f28f --- /dev/null +++ b/internal/event/catalog/strategy.go @@ -0,0 +1,35 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package catalog + +import "slices" + +// StrategyRef is the serializable identifier of a consume preparation +// strategy. The catalog stores and validates references only; the executable +// strategies live with the consume application layer, which hands the +// compiler a StrategySet to check references against. +type StrategyRef string + +const ( + // StrategyNone marks a key that needs no preparation before consuming. + StrategyNone StrategyRef = "none" + // StrategyLegacyPreConsume wraps a declaration's PreConsume hook: the + // preparation decision is opaque until applied, exactly as the hook + // contract has always behaved. + StrategyLegacyPreConsume StrategyRef = "legacy_preconsume" +) + +// StrategySet is the narrow view the compiler needs: reference existence. +// Keeping the interface here (not in the application layer) lets the catalog +// validate without depending on strategy implementations. +type StrategySet interface { + Has(ref StrategyRef) bool +} + +// StrategyRefs is the minimal StrategySet: a fixed collection of references. +type StrategyRefs []StrategyRef + +func (s StrategyRefs) Has(ref StrategyRef) bool { + return slices.Contains(s, ref) +} diff --git a/internal/event/registry.go b/internal/event/registry.go deleted file mode 100644 index dd684be674..0000000000 --- a/internal/event/registry.go +++ /dev/null @@ -1,144 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package event - -import ( - "fmt" - "sort" - "sync" -) - -var ( - keys = map[string]*KeyDefinition{} - mu sync.RWMutex -) - -// RegisterKey panics on duplicate Key, empty EventType, or schema/process contract violations. -func RegisterKey(def KeyDefinition) { - mu.Lock() - defer mu.Unlock() - - if _, exists := keys[def.Key]; exists { - panic(fmt.Sprintf("duplicate EventKey: %s", def.Key)) - } - if def.EventType == "" { - panic(fmt.Sprintf("EventKey %s: EventType must not be empty", def.Key)) - } - - if def.SubscriptionType == "" { - def.SubscriptionType = SubTypeEvent - } - if def.SubscriptionType != SubTypeEvent && def.SubscriptionType != SubTypeCallback { - panic(fmt.Sprintf("EventKey %s: SubscriptionType must be %q or %q; got %q", - def.Key, SubTypeEvent, SubTypeCallback, def.SubscriptionType)) - } - - validateSchema(def) - validateParams(def) - validateAuth(def) - - if def.BufferSize > MaxBufferSize { - def.BufferSize = MaxBufferSize - } - if def.BufferSize <= 0 { - def.BufferSize = DefaultBufferSize - } - if def.Workers <= 0 { - def.Workers = 1 - } - keys[def.Key] = &def -} - -// validateSchema: exactly one of Native/Custom; Native incompatible with Process. -func validateSchema(def KeyDefinition) { - nativeSet := def.Schema.Native != nil - customSet := def.Schema.Custom != nil - if nativeSet && customSet { - panic(fmt.Sprintf("EventKey %s: Schema.Native and Schema.Custom are mutually exclusive", def.Key)) - } - if !nativeSet && !customSet { - panic(fmt.Sprintf("EventKey %s: Schema requires either Native or Custom", def.Key)) - } - if nativeSet && def.Process != nil { - panic(fmt.Sprintf("EventKey %s: Schema.Native forbids Process (Process produces a complete shape — use Schema.Custom)", def.Key)) - } - if spec := def.Schema.Native; spec != nil { - validateSpec(def.Key, "Schema.Native", spec) - } - if spec := def.Schema.Custom; spec != nil { - validateSpec(def.Key, "Schema.Custom", spec) - } -} - -func validateSpec(key, field string, s *SchemaSpec) { - typeSet := s.Type != nil - rawSet := len(s.Raw) > 0 - if typeSet == rawSet { - panic(fmt.Sprintf("EventKey %s: %s requires exactly one of Type or Raw", key, field)) - } -} - -func validateParams(def KeyDefinition) { - for _, p := range def.Params { - switch p.Type { - case "", ParamString, ParamBool, ParamInt: - case ParamEnum, ParamMulti: - if len(p.Values) == 0 { - panic(fmt.Sprintf("EventKey %s: param %q type %q requires Values", def.Key, p.Name, p.Type)) - } - for _, v := range p.Values { - if v.Desc == "" { - panic(fmt.Sprintf("EventKey %s: param %q value %q requires non-empty Desc", def.Key, p.Name, v.Value)) - } - } - default: - panic(fmt.Sprintf("EventKey %s: param %q has unknown type %q", def.Key, p.Name, p.Type)) - } - } -} - -func validateAuth(def KeyDefinition) { - for _, t := range def.AuthTypes { - if t != "user" && t != "bot" { - panic(fmt.Sprintf("EventKey %s: AuthTypes elements must be \"user\" or \"bot\"; got %q", def.Key, t)) - } - } -} - -func Lookup(key string) (*KeyDefinition, bool) { - mu.RLock() - defer mu.RUnlock() - def, ok := keys[key] - return def, ok -} - -// ListAll returns all KeyDefinitions sorted by Key. -func ListAll() []*KeyDefinition { - mu.RLock() - defer mu.RUnlock() - result := make([]*KeyDefinition, 0, len(keys)) - for _, def := range keys { - result = append(result, def) - } - sort.Slice(result, func(i, j int) bool { - return result[i].Key < result[j].Key - }) - return result -} - -func resetRegistry() { - mu.Lock() - defer mu.Unlock() - keys = map[string]*KeyDefinition{} -} - -func ResetRegistryForTest() { resetRegistry() } - -// UnregisterKeyForTest removes one key — use this (not Reset) in tests with synthetic keys -// alongside production keys to keep -count=N reruns idempotent. -func UnregisterKeyForTest(key string) { - mu.Lock() - defer mu.Unlock() - delete(keys, key) -} diff --git a/internal/event/types.go b/internal/event/types.go index fcd82d1931..4dc8c09db6 100644 --- a/internal/event/types.go +++ b/internal/event/types.go @@ -1,167 +1,50 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -// Package event owns the EventKey registry, RawEvent, APIClient, and dedup filter. +// Package event is the compatibility facade over the event kernel packages: +// the value model (model), the declaration/compilation layer (catalog), the +// processing contracts (processing), and the dedup filter kept here. New code +// should import the owning package directly; the aliases keep the existing +// declaration and call sites compiling unchanged. package event import ( - "context" - "encoding/json" - "reflect" - + "github.com/larksuite/cli/internal/event/catalog" "github.com/larksuite/cli/internal/event/model" "github.com/larksuite/cli/internal/event/processing" - "github.com/larksuite/cli/internal/event/schemas" ) const ( - DefaultBufferSize = 100 - MaxBufferSize = 1000 + DefaultBufferSize = catalog.DefaultBufferSize + MaxBufferSize = catalog.MaxBufferSize ) // RawEvent is the canonical event fact carrier; see model.Event for the field -// contracts. The alias keeps every existing reference compiling unchanged. +// contracts. type RawEvent = model.Event // APIClient is the narrow API surface handed to domain hooks; see -// processing.APIClient for the contract. The alias keeps existing references -// compiling unchanged. +// processing.APIClient for the contract. type APIClient = processing.APIClient -type ParamType string - -const ( - ParamString ParamType = "string" - ParamEnum ParamType = "enum" - ParamMulti ParamType = "multi" - ParamBool ParamType = "bool" - ParamInt ParamType = "int" +type ( + ParamType = catalog.ParamType + SubscriptionType = catalog.SubscriptionType + ParamValue = catalog.ParamValue + ParamDef = catalog.ParamDef + ProcessFunc = catalog.ProcessFunc + SchemaDef = catalog.SchemaDef + SchemaSpec = catalog.SchemaSpec + KeyDefinition = catalog.KeyDefinition ) -// SubscriptionType marks whether an EventKey is delivered via Lark event -// subscription or interactive callback subscription. It is a sibling of -// EventType (which holds the concrete Lark event_type string). -type SubscriptionType string - const ( - // SubTypeEvent: checked against the published app_versions event_infos. - SubTypeEvent SubscriptionType = "event" - // SubTypeCallback: checked against application/get subscribed_callbacks. - SubTypeCallback SubscriptionType = "callback" + ParamString = catalog.ParamString + ParamEnum = catalog.ParamEnum + ParamMulti = catalog.ParamMulti + ParamBool = catalog.ParamBool + ParamInt = catalog.ParamInt + + SubTypeEvent = catalog.SubTypeEvent + SubTypeCallback = catalog.SubTypeCallback ) - -// ParamValue.Desc is mandatory so AI consumers can decide which value to pick. -type ParamValue struct { - Value string `json:"value"` - Desc string `json:"desc"` -} - -type ParamDef struct { - Name string `json:"name"` - Type ParamType `json:"type"` - Required bool `json:"required"` - Default string `json:"default,omitempty"` - Description string `json:"description"` - Values []ParamValue `json:"values,omitempty"` - - // SubscriptionKey marks this param as part of the subscription identity. - // Two consumers of the same EventKey but different values for any - // SubscriptionKey-marked param are treated as DISTINCT subscriptions: - // PreConsume runs once per (EventKey, SubscriptionID), cleanup runs once per - // (EventKey, SubscriptionID). - // - // CONTRACT: only mark a param SubscriptionKey if the EventKey's server-side - // subscribe/unsubscribe API is itself scoped to that resource. Lark keys the - // subscription record by (app, user, event_type) and overwrites it rather - // than reference-counting, so for a non-per-resource API the cleanup of one - // resource's last consumer unsubscribes the shared record and silently cuts - // off every other resource sharing that event_type. - // - // Default false = the param is a filter / formatting / metadata param - // and does not affect subscription identity. - SubscriptionKey bool `json:"subscription_key,omitempty"` -} - -type ProcessFunc = func(ctx context.Context, rt APIClient, raw *RawEvent, params map[string]string) (json.RawMessage, error) - -// SchemaDef: exactly one of Native or Custom must be set. -// Native auto-wraps the SDK type in the V2 envelope; Custom passes through verbatim. -type SchemaDef struct { - Native *SchemaSpec `json:"native,omitempty"` - Custom *SchemaSpec `json:"custom,omitempty"` - FieldOverrides map[string]schemas.FieldMeta `json:"field_overrides,omitempty"` -} - -// SchemaSpec: exactly one of Type or Raw. -type SchemaSpec struct { - Type reflect.Type `json:"-"` - Raw json.RawMessage `json:"raw,omitempty"` -} - -type KeyDefinition struct { - Key string `json:"key"` - DisplayName string `json:"display_name,omitempty"` - Description string `json:"description,omitempty"` - EventType string `json:"event_type"` - - // SubscriptionType selects which console "底账" the precheck reads. - // Empty is normalized to SubTypeEvent at RegisterKey. - SubscriptionType SubscriptionType `json:"subscription_type,omitempty"` - - Params []ParamDef `json:"params,omitempty"` - - Schema SchemaDef `json:"schema"` - - // NormalizeParams canonicalizes param values BEFORE fingerprint compute, - // PreConsume, Match, and Process. Mutates the params map in place. - // May call OAPI; runs once per consumer at startup. - // - // Use cases: resolve aliases ("me" -> real email, a name -> an ID), - // trim whitespace. On error, consume fails (no retry); caller gets the - // wrapped error. - // - // Default nil = no normalization, params pass through unchanged. - NormalizeParams func(ctx context.Context, rt APIClient, params map[string]string) error `json:"-"` - - // Process required when Schema.Custom is Processed output; must be nil when Native is used. - // - // Convention: returning (nil, nil) signals "drop this event" — the - // consumer loop will skip writing it to sink and not advance the - // emitted counter. Useful for async filtering (e.g. fetch metadata, - // drop if folder doesn't match). For sync filters that don't need - // OAPI, use Match instead. - Process func(ctx context.Context, rt APIClient, raw *RawEvent, params map[string]string) (json.RawMessage, error) `json:"-"` - - // Match is a synchronous payload filter run on every received event - // BEFORE Process. Return false to drop the event without further work. - // - // Signature deliberately omits ctx/rt to physically enforce "no OAPI - // calls in Match". For filters that need a metadata fetch first, use - // Process and return nil to drop. - // - // Default nil = accept all events. - Match func(raw *RawEvent, params map[string]string) bool `json:"-"` - - // PreConsume runs once per (EventKey, SubscriptionID) when this consumer - // is first for that scope. Returns a cleanup function that the framework - // invokes when this consumer is the last for its scope. - // - // The cleanup's error return is honored: on nil the framework prints - // "[event] cleanup done."; on non-nil it prints a WARN with an - // idempotency note. - PreConsume func(ctx context.Context, rt APIClient, params map[string]string) (cleanup func() error, err error) `json:"-"` - - Scopes []string `json:"scopes,omitempty"` - - // AuthTypes: whitelist of identities the EventKey accepts. Empty = no identity required. - AuthTypes []string `json:"auth_types,omitempty"` - - RequiredConsoleEvents []string `json:"required_console_events,omitempty"` - - BufferSize int `json:"buffer_size,omitempty"` - Workers int `json:"workers,omitempty"` - - // SingleConsumer rejects a second consumer for the same SubscriptionID at - // the bus handshake. Default false = unlimited consumers (fan-out). - SingleConsumer bool `json:"single_consumer,omitempty"` -} From 77c8255f2c93cbea5d2e370d544771224d0b2eb6 Mon Sep 17 00:00:00 2001 From: leave330 Date: Sat, 1 Aug 2026 00:03:13 +0800 Subject: [PATCH 15/61] feat(event): aggregate domain declarations through an explicit events.All --- events/all.go | 36 +++++++ events/application/menu_test.go | 16 ++-- events/approval/register_test.go | 18 ++-- events/catalog_helper_test.go | 26 ++++++ events/compile_test.go | 93 +++++++++++++++++++ events/expected_keys_test.go | 6 +- events/im/card_action_test.go | 2 +- events/im/catalog_helper_test.go | 29 ++++++ events/im/message_receive_test.go | 12 +-- events/lint_test.go | 10 +- events/minutes/catalog_helper_test.go | 29 ++++++ events/minutes/minute_generated_test.go | 12 +-- events/output_baseline_test.go | 4 +- events/schema_closure_test.go | 4 +- events/task/register_test.go | 19 ++-- events/vc/catalog_helper_test.go | 29 ++++++ events/vc/note_generated_test.go | 4 +- events/vc/participant_meeting_ended_test.go | 12 +-- .../vc/participant_meeting_lifecycle_test.go | 4 +- events/vc/recording_test.go | 4 +- 20 files changed, 295 insertions(+), 74 deletions(-) create mode 100644 events/all.go create mode 100644 events/catalog_helper_test.go create mode 100644 events/compile_test.go create mode 100644 events/im/catalog_helper_test.go create mode 100644 events/minutes/catalog_helper_test.go create mode 100644 events/vc/catalog_helper_test.go diff --git a/events/all.go b/events/all.go new file mode 100644 index 0000000000..f6f33f6d41 --- /dev/null +++ b/events/all.go @@ -0,0 +1,36 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package events aggregates the domain EventKey declarations. All returns +// them explicitly — whoever needs a catalog compiles one; nothing registers +// itself through import side effects. +package events + +import ( + "github.com/larksuite/cli/events/application" + "github.com/larksuite/cli/events/approval" + "github.com/larksuite/cli/events/im" + "github.com/larksuite/cli/events/minutes" + "github.com/larksuite/cli/events/task" + "github.com/larksuite/cli/events/vc" + "github.com/larksuite/cli/events/whiteboard" + "github.com/larksuite/cli/internal/event/catalog" +) + +// All returns every domain's declarations, ready for catalog.Compile. +// Mail is intentionally omitted in this phase. +func All() []catalog.KeyDefinition { + var all []catalog.KeyDefinition + for _, keys := range [][]catalog.KeyDefinition{ + application.Keys(), + approval.Keys(), + im.Keys(), + minutes.Keys(), + task.Keys(), + vc.Keys(), + whiteboard.Keys(), + } { + all = append(all, keys...) + } + return all +} diff --git a/events/application/menu_test.go b/events/application/menu_test.go index 2ff187c1f6..7fa5c34eb1 100644 --- a/events/application/menu_test.go +++ b/events/application/menu_test.go @@ -11,6 +11,7 @@ import ( "time" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/catalog" "github.com/larksuite/cli/internal/event/processing" ) @@ -52,14 +53,15 @@ func TestKeysBotMenuMetadata(t *testing.T) { func TestBotMenuRegistersCleanly(t *testing.T) { const key = eventTypeBotMenuV6 - event.UnregisterKeyForTest(key) - t.Cleanup(func() { event.UnregisterKeyForTest(key) }) - - for _, def := range Keys() { - event.RegisterKey(def) + snap, err := catalog.Compile(Keys(), catalog.StrategyRefs{ + catalog.StrategyNone, + catalog.StrategyLegacyPreConsume, + }) + if err != nil { + t.Fatalf("catalog.Compile(Keys()): %v", err) } - if _, ok := event.Lookup(key); !ok { - t.Fatalf("event.Lookup(%q) not registered", key) + if _, ok := snap.Resolve(key); !ok { + t.Fatalf("snap.Resolve(%q): key missing from compiled catalog", key) } } diff --git a/events/approval/register_test.go b/events/approval/register_test.go index a303ee0783..e0de51b649 100644 --- a/events/approval/register_test.go +++ b/events/approval/register_test.go @@ -14,6 +14,7 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/catalog" "github.com/larksuite/cli/internal/event/processing" "github.com/larksuite/cli/internal/event/schemas" ) @@ -637,17 +638,16 @@ func runApprovalTaskStatusChanged(t *testing.T, payload string) ApprovalTaskStat } func TestApprovalKeysRegisterCleanly(t *testing.T) { - for _, key := range []string{eventTypeApprovalInstanceStatusChangedV4, eventTypeApprovalTaskStatusChangedV4} { - event.UnregisterKeyForTest(key) - t.Cleanup(func() { event.UnregisterKeyForTest(key) }) - } - - for _, def := range Keys() { - event.RegisterKey(def) + snap, err := catalog.Compile(Keys(), catalog.StrategyRefs{ + catalog.StrategyNone, + catalog.StrategyLegacyPreConsume, + }) + if err != nil { + t.Fatalf("catalog.Compile(Keys()): %v", err) } for _, key := range []string{eventTypeApprovalInstanceStatusChangedV4, eventTypeApprovalTaskStatusChangedV4} { - if _, ok := event.Lookup(key); !ok { - t.Fatalf("event.Lookup(%q) not registered", key) + if _, ok := snap.Resolve(key); !ok { + t.Fatalf("snap.Resolve(%q): key missing from compiled catalog", key) } } } diff --git a/events/catalog_helper_test.go b/events/catalog_helper_test.go new file mode 100644 index 0000000000..c69f839ff2 --- /dev/null +++ b/events/catalog_helper_test.go @@ -0,0 +1,26 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package events_test + +import ( + "testing" + + "github.com/larksuite/cli/events" + "github.com/larksuite/cli/internal/event/catalog" +) + +// compileRealCatalog compiles the full shipped declaration set exactly as the +// runtime does. Tests that used to walk the global registry iterate this +// snapshot instead. +func compileRealCatalog(t *testing.T) *catalog.Snapshot { + t.Helper() + snap, err := catalog.Compile(events.All(), catalog.StrategyRefs{ + catalog.StrategyNone, + catalog.StrategyLegacyPreConsume, + }) + if err != nil { + t.Fatalf("compile catalog: %v", err) + } + return snap +} diff --git a/events/compile_test.go b/events/compile_test.go new file mode 100644 index 0000000000..ac4991dd18 --- /dev/null +++ b/events/compile_test.go @@ -0,0 +1,93 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package events_test + +import ( + "encoding/json" + "testing" + + "github.com/larksuite/cli/events" + "github.com/larksuite/cli/internal/event/catalog" +) + +// This gate lives in the events package because the catalog package cannot +// import the declarations it compiles (that would be an import cycle). It is +// the acceptance half of the compiler's own rejection tests: the real catalog +// must compile — a compiler that rejects everything would also pass those. +func TestCompile_RealCatalogCompilesCleanly(t *testing.T) { + snap, err := catalog.Compile(events.All(), catalog.StrategyRefs{ + catalog.StrategyNone, + catalog.StrategyLegacyPreConsume, + }) + if err != nil { + t.Fatalf("the shipped declarations must compile: %v", err) + } + if snap.Len() == 0 { + t.Fatal("the compiled catalog is empty; the gate proved nothing") + } + if snap.Len() != len(expectedKeys) { + t.Fatalf("compiled %d keys, frozen baseline has %d", snap.Len(), len(expectedKeys)) + } + for _, want := range expectedKeys { + if _, ok := snap.Resolve(want); !ok { + t.Errorf("baseline key missing from the compiled catalog: %s", want) + } + } +} + +// Every shipped key must satisfy its compiled output contract: a resolvable +// non-empty schema, a jq root that matches the output mode, and normalized +// delivery values. Golden files pin a few representative keys byte-for-byte; +// this covers the whole catalog structurally. +func TestOutputContract_HoldsForEveryKey(t *testing.T) { + snap, err := catalog.Compile(events.All(), catalog.StrategyRefs{ + catalog.StrategyNone, + catalog.StrategyLegacyPreConsume, + }) + if err != nil { + t.Fatal(err) + } + + checked := 0 + for _, entry := range snap.Entries() { + checked++ + d := entry.Descriptor() + out := entry.Output() + + var parsed map[string]json.RawMessage + if err := json.Unmarshal(out.SchemaJSON, &parsed); err != nil || len(parsed) == 0 { + t.Errorf("%s: resolved schema must be a non-empty JSON object (err=%v)", d.Key, err) + } + + switch out.Mode { + case catalog.OutputNative: + if out.JQRootPath != ".event" { + t.Errorf("%s: native keys deliver the V2 envelope; jq root must be .event, got %q", d.Key, out.JQRootPath) + } + if entry.Binding().Process != nil { + t.Errorf("%s: native keys must not carry a processor", d.Key) + } + case catalog.OutputProcessed: + if out.JQRootPath != "." { + t.Errorf("%s: processed keys deliver a flat shape; jq root must be ., got %q", d.Key, out.JQRootPath) + } + if entry.Binding().Process == nil { + t.Errorf("%s: processed keys must carry a processor", d.Key) + } + default: + t.Errorf("%s: unknown output mode %q", d.Key, out.Mode) + } + + cap := entry.Capability() + if cap.BufferSize <= 0 || cap.BufferSize > catalog.MaxBufferSize || cap.Workers <= 0 { + t.Errorf("%s: delivery values must be normalized, got buffer=%d workers=%d", d.Key, cap.BufferSize, cap.Workers) + } + if d.Domain == "" { + t.Errorf("%s: descriptor domain must always be resolved", d.Key) + } + } + if checked == 0 { + t.Fatal("no entries were checked; the gate proved nothing") + } +} diff --git a/events/expected_keys_test.go b/events/expected_keys_test.go index 6c4b940d4c..46043830d8 100644 --- a/events/expected_keys_test.go +++ b/events/expected_keys_test.go @@ -5,10 +5,6 @@ package events_test import ( "testing" - - event "github.com/larksuite/cli/internal/event" - - _ "github.com/larksuite/cli/events" ) // expectedKeys is the frozen catalog baseline. Adding, removing, or renaming @@ -43,7 +39,7 @@ var expectedKeys = []string{ } func TestRegisteredKeys_MatchFrozenBaseline(t *testing.T) { - all := event.ListAll() + all := compileRealCatalog(t).Definitions() if len(all) == 0 { t.Fatal("no EventKeys registered; the gate scanned nothing") } diff --git a/events/im/card_action_test.go b/events/im/card_action_test.go index c6f10dbc25..31968ddd16 100644 --- a/events/im/card_action_test.go +++ b/events/im/card_action_test.go @@ -14,7 +14,7 @@ import ( ) func TestCardActionTriggerRegistered(t *testing.T) { - def, ok := event.Lookup("card.action.trigger") + def, ok := lookupCompiledDef(t, "card.action.trigger") if !ok { t.Fatal("card.action.trigger should be registered via Keys()") } diff --git a/events/im/catalog_helper_test.go b/events/im/catalog_helper_test.go new file mode 100644 index 0000000000..013849d84a --- /dev/null +++ b/events/im/catalog_helper_test.go @@ -0,0 +1,29 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "testing" + + "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/catalog" +) + +// lookupCompiledDef compiles this domain's declarations and resolves one key, +// exactly as the runtime catalog would for a consumer. +func lookupCompiledDef(t *testing.T, key string) (*event.KeyDefinition, bool) { + t.Helper() + snap, err := catalog.Compile(Keys(), catalog.StrategyRefs{ + catalog.StrategyNone, + catalog.StrategyLegacyPreConsume, + }) + if err != nil { + t.Fatalf("catalog.Compile(Keys()): %v", err) + } + entry, ok := snap.Resolve(key) + if !ok { + return nil, false + } + return entry.Definition(), true +} diff --git a/events/im/message_receive_test.go b/events/im/message_receive_test.go index e5414c73b9..20d44ee09f 100644 --- a/events/im/message_receive_test.go +++ b/events/im/message_receive_test.go @@ -6,7 +6,6 @@ package im import ( "context" "encoding/json" - "os" "testing" "time" @@ -14,15 +13,8 @@ import ( "github.com/larksuite/cli/internal/event/processing" ) -func TestMain(m *testing.M) { - for _, k := range Keys() { - event.RegisterKey(k) - } - os.Exit(m.Run()) -} - func TestIMKeys_ProcessedReceiveRegistered(t *testing.T) { - def, ok := event.Lookup("im.message.receive_v1") + def, ok := lookupCompiledDef(t, "im.message.receive_v1") if !ok { t.Fatal("im.message.receive_v1 should be registered via Keys()") } @@ -54,7 +46,7 @@ func TestIMKeys_NativeEventsRegistered(t *testing.T) { "im.chat.disbanded_v1", } for _, k := range want { - def, ok := event.Lookup(k) + def, ok := lookupCompiledDef(t, k) if !ok { t.Errorf("%s should be registered via Keys()", k) continue diff --git a/events/lint_test.go b/events/lint_test.go index 5b92ea3956..fdd35ea06c 100644 --- a/events/lint_test.go +++ b/events/lint_test.go @@ -9,11 +9,19 @@ import ( "testing" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/catalog" "github.com/larksuite/cli/internal/event/schemas" ) func TestAllKeys_FieldOverridePointersResolve(t *testing.T) { - for _, def := range event.ListAll() { + snap, err := catalog.Compile(All(), catalog.StrategyRefs{ + catalog.StrategyNone, + catalog.StrategyLegacyPreConsume, + }) + if err != nil { + t.Fatalf("compile catalog: %v", err) + } + for _, def := range snap.Definitions() { if len(def.Schema.FieldOverrides) == 0 { continue } diff --git a/events/minutes/catalog_helper_test.go b/events/minutes/catalog_helper_test.go new file mode 100644 index 0000000000..843bfd7a1c --- /dev/null +++ b/events/minutes/catalog_helper_test.go @@ -0,0 +1,29 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package minutes + +import ( + "testing" + + "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/catalog" +) + +// lookupCompiledDef compiles this domain's declarations and resolves one key, +// exactly as the runtime catalog would for a consumer. +func lookupCompiledDef(t *testing.T, key string) (*event.KeyDefinition, bool) { + t.Helper() + snap, err := catalog.Compile(Keys(), catalog.StrategyRefs{ + catalog.StrategyNone, + catalog.StrategyLegacyPreConsume, + }) + if err != nil { + t.Fatalf("catalog.Compile(Keys()): %v", err) + } + entry, ok := snap.Resolve(key) + if !ok { + return nil, false + } + return entry.Definition(), true +} diff --git a/events/minutes/minute_generated_test.go b/events/minutes/minute_generated_test.go index 33c2de1ce8..e967af8408 100644 --- a/events/minutes/minute_generated_test.go +++ b/events/minutes/minute_generated_test.go @@ -7,7 +7,6 @@ import ( "context" "encoding/json" "fmt" - "os" "reflect" "testing" "time" @@ -36,17 +35,10 @@ func assertSubscriptionRequest(t *testing.T, gotBody any, wantEventType string) } } -func TestMain(m *testing.M) { - for _, k := range Keys() { - event.RegisterKey(k) - } - os.Exit(m.Run()) -} - func TestMinutesKeys_ProcessedMinuteGeneratedRegistered(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - def, ok := event.Lookup(eventTypeMinuteGenerated) + def, ok := lookupCompiledDef(t, eventTypeMinuteGenerated) if !ok { t.Fatalf("%s should be registered via Keys()", eventTypeMinuteGenerated) } @@ -275,7 +267,7 @@ func TestProcessMinutesMinuteGenerated_EmptyTitleExhaustsRetries(t *testing.T) { func TestMinutesMinuteGenerated_PreConsumeSubscriptionLifecycle(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - def, ok := event.Lookup(eventTypeMinuteGenerated) + def, ok := lookupCompiledDef(t, eventTypeMinuteGenerated) if !ok { t.Fatalf("%s should be registered via Keys()", eventTypeMinuteGenerated) } diff --git a/events/output_baseline_test.go b/events/output_baseline_test.go index c35ed0e372..483c3af99f 100644 --- a/events/output_baseline_test.go +++ b/events/output_baseline_test.go @@ -16,8 +16,6 @@ import ( "time" event "github.com/larksuite/cli/internal/event" - - _ "github.com/larksuite/cli/events" ) var updateBaseline = flag.Bool("update-baseline", false, @@ -313,7 +311,7 @@ func TestProcessedOutputBaseline(t *testing.T) { got := map[string]json.RawMessage{} seenFixtures := map[string]bool{} - for _, def := range event.ListAll() { + for _, def := range compileRealCatalog(t).Definitions() { if def.Process == nil { continue } diff --git a/events/schema_closure_test.go b/events/schema_closure_test.go index edbd0ba9ef..0490bb70df 100644 --- a/events/schema_closure_test.go +++ b/events/schema_closure_test.go @@ -12,8 +12,6 @@ import ( event "github.com/larksuite/cli/internal/event" "github.com/larksuite/cli/internal/event/processing" - - _ "github.com/larksuite/cli/events" ) // closureAPIClient answers any API call with a benign error: a handler facing @@ -35,7 +33,7 @@ func TestAllKeys_MalformedPayloadStaysSchemaClosed(t *testing.T) { const wantProcessedKeys = 13 processed := 0 - for _, def := range event.ListAll() { + for _, def := range compileRealCatalog(t).Definitions() { if def.Process == nil { continue } diff --git a/events/task/register_test.go b/events/task/register_test.go index 06897c1122..b4e932db03 100644 --- a/events/task/register_test.go +++ b/events/task/register_test.go @@ -8,7 +8,7 @@ import ( "reflect" "testing" - "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/catalog" "github.com/larksuite/cli/internal/event/schemas" ) @@ -83,13 +83,14 @@ func TestTaskUpdateUserAccessSchemaAnnotations(t *testing.T) { func TestTaskUpdateUserAccessRegistersCleanly(t *testing.T) { const key = eventTypeTaskUpdateUserAccessV2 - event.UnregisterKeyForTest(key) - t.Cleanup(func() { event.UnregisterKeyForTest(key) }) - - for _, def := range Keys() { - event.RegisterKey(def) - } - if _, ok := event.Lookup(key); !ok { - t.Fatalf("event.Lookup(%q) not registered", key) + snap, err := catalog.Compile(Keys(), catalog.StrategyRefs{ + catalog.StrategyNone, + catalog.StrategyLegacyPreConsume, + }) + if err != nil { + t.Fatalf("catalog.Compile(Keys()): %v", err) + } + if _, ok := snap.Resolve(key); !ok { + t.Fatalf("snap.Resolve(%q): key missing from compiled catalog", key) } } diff --git a/events/vc/catalog_helper_test.go b/events/vc/catalog_helper_test.go new file mode 100644 index 0000000000..7f836cdec4 --- /dev/null +++ b/events/vc/catalog_helper_test.go @@ -0,0 +1,29 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package vc + +import ( + "testing" + + "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/catalog" +) + +// lookupCompiledDef compiles this domain's declarations and resolves one key, +// exactly as the runtime catalog would for a consumer. +func lookupCompiledDef(t *testing.T, key string) (*event.KeyDefinition, bool) { + t.Helper() + snap, err := catalog.Compile(Keys(), catalog.StrategyRefs{ + catalog.StrategyNone, + catalog.StrategyLegacyPreConsume, + }) + if err != nil { + t.Fatalf("catalog.Compile(Keys()): %v", err) + } + entry, ok := snap.Resolve(key) + if !ok { + return nil, false + } + return entry.Definition(), true +} diff --git a/events/vc/note_generated_test.go b/events/vc/note_generated_test.go index 72ab924a78..17e3430c5d 100644 --- a/events/vc/note_generated_test.go +++ b/events/vc/note_generated_test.go @@ -16,7 +16,7 @@ import ( func TestVCKeys_ProcessedNoteGeneratedRegistered(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - def, ok := event.Lookup(eventTypeNoteGenerated) + def, ok := lookupCompiledDef(t, eventTypeNoteGenerated) if !ok { t.Fatalf("%s should be registered via Keys()", eventTypeNoteGenerated) } @@ -114,7 +114,7 @@ func TestProcessVCNoteGenerated(t *testing.T) { func TestVCNoteGenerated_PreConsumeSubscriptionLifecycle(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - def, ok := event.Lookup(eventTypeNoteGenerated) + def, ok := lookupCompiledDef(t, eventTypeNoteGenerated) if !ok { t.Fatalf("%s should be registered via Keys()", eventTypeNoteGenerated) } diff --git a/events/vc/participant_meeting_ended_test.go b/events/vc/participant_meeting_ended_test.go index a334d7a128..70f26e9802 100644 --- a/events/vc/participant_meeting_ended_test.go +++ b/events/vc/participant_meeting_ended_test.go @@ -6,7 +6,6 @@ package vc import ( "context" "encoding/json" - "os" "testing" "time" @@ -14,17 +13,10 @@ import ( "github.com/larksuite/cli/internal/event/processing" ) -func TestMain(m *testing.M) { - for _, k := range Keys() { - event.RegisterKey(k) - } - os.Exit(m.Run()) -} - func TestVCKeys_ProcessedMeetingEndedRegistered(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - def, ok := event.Lookup(eventTypeMeetingEnded) + def, ok := lookupCompiledDef(t, eventTypeMeetingEnded) if !ok { t.Fatalf("%s should be registered via Keys()", eventTypeMeetingEnded) } @@ -142,7 +134,7 @@ func TestProcessVCParticipantMeetingEnded_MalformedPayload(t *testing.T) { func TestVCParticipantMeetingEnded_PreConsumeSubscriptionLifecycle(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - def, ok := event.Lookup("vc.meeting.participant_meeting_ended_v1") + def, ok := lookupCompiledDef(t, "vc.meeting.participant_meeting_ended_v1") if !ok { t.Fatal("vc.meeting.participant_meeting_ended_v1 should be registered via Keys()") } diff --git a/events/vc/participant_meeting_lifecycle_test.go b/events/vc/participant_meeting_lifecycle_test.go index 976d5ef50f..92370123d9 100644 --- a/events/vc/participant_meeting_lifecycle_test.go +++ b/events/vc/participant_meeting_lifecycle_test.go @@ -25,7 +25,7 @@ func TestVCKeys_ProcessedMeetingLifecycleRegistered(t *testing.T) { {eventTypeMeetingJoined, reflect.TypeOf(VCParticipantMeetingJoinedOutput{})}, } { t.Run(tc.eventType, func(t *testing.T) { - def, ok := event.Lookup(tc.eventType) + def, ok := lookupCompiledDef(t, tc.eventType) if !ok { t.Fatalf("%s should be registered via Keys()", tc.eventType) } @@ -209,7 +209,7 @@ func TestVCParticipantMeetingLifecycle_PreConsumeSubscriptionLifecycle(t *testin for _, eventType := range []string{eventTypeMeetingStarted, eventTypeMeetingJoined} { t.Run(eventType, func(t *testing.T) { - def, ok := event.Lookup(eventType) + def, ok := lookupCompiledDef(t, eventType) if !ok { t.Fatalf("%s should be registered via Keys()", eventType) } diff --git a/events/vc/recording_test.go b/events/vc/recording_test.go index fa82dd961b..6691882075 100644 --- a/events/vc/recording_test.go +++ b/events/vc/recording_test.go @@ -26,7 +26,7 @@ func TestVCKeys_RecordingEventsRegistered(t *testing.T) { {eventTypeRecordingEnded}, } { t.Run(tc.eventType, func(t *testing.T) { - def, ok := event.Lookup(tc.eventType) + def, ok := lookupCompiledDef(t, tc.eventType) if !ok { t.Fatalf("%s should be registered via Keys()", tc.eventType) } @@ -392,7 +392,7 @@ func TestVCRecording_PreConsumeSubscriptionLifecycle(t *testing.T) { {eventTypeRecordingEnded}, } { t.Run(tc.eventType, func(t *testing.T) { - def, ok := event.Lookup(tc.eventType) + def, ok := lookupCompiledDef(t, tc.eventType) if !ok { t.Fatalf("%s should be registered via Keys()", tc.eventType) } From 83873a2215934ccd8ecfa136dd0df794d2015d23 Mon Sep 17 00:00:00 2001 From: leave330 Date: Sat, 1 Aug 2026 00:03:36 +0800 Subject: [PATCH 16/61] refactor(event): route every catalog reader through the compiled snapshot The runtime registry is gone: declarations aggregate through events.All, compile once at the command tree's assembly point, and every reader (list, schema, consume, suggestions, bus) works from the immutable snapshot. Byte-identical golden output pins the migration. --- cmd/build.go | 1 - cmd/event/bus.go | 5 +- cmd/event/bus_test.go | 2 +- cmd/event/consume.go | 13 +- cmd/event/event.go | 9 +- cmd/event/format_helpers_test.go | 7 +- cmd/event/golden_test.go | 12 +- cmd/event/list.go | 26 +- cmd/event/list_test.go | 12 +- cmd/event/render_contract_test.go | 1 + cmd/event/schema.go | 104 +----- cmd/event/schema_test.go | 92 +++--- cmd/event/suggestions.go | 18 +- cmd/event/suggestions_test.go | 9 +- cmd/event/wiring.go | 26 ++ internal/event/bus/bus.go | 28 +- internal/event/bus/bus_shutdown_test.go | 2 +- internal/event/bus/handle_hello_test.go | 22 +- .../event/consume/capability_gate_test.go | 4 +- internal/event/consume/consume.go | 10 +- internal/event/consume/consume_test.go | 24 +- internal/event/consume/startup_guard_test.go | 5 +- internal/event/integration_test.go | 30 +- internal/event/preconsume_contract_test.go | 73 +++-- internal/event/registry_test.go | 301 ------------------ 25 files changed, 280 insertions(+), 556 deletions(-) create mode 100644 cmd/event/wiring.go delete mode 100644 internal/event/registry_test.go diff --git a/cmd/build.go b/cmd/build.go index 55f89304c4..632f279691 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -20,7 +20,6 @@ import ( "github.com/larksuite/cli/cmd/skill" cmdupdate "github.com/larksuite/cli/cmd/update" "github.com/larksuite/cli/cmd/whoami" - _ "github.com/larksuite/cli/events" "github.com/larksuite/cli/internal/apicatalog" "github.com/larksuite/cli/internal/build" "github.com/larksuite/cli/internal/cmdpolicy" diff --git a/cmd/event/bus.go b/cmd/event/bus.go index 61d2d3c0e1..92c5ac6f9c 100644 --- a/cmd/event/bus.go +++ b/cmd/event/bus.go @@ -17,11 +17,12 @@ import ( "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/event" "github.com/larksuite/cli/internal/event/bus" + "github.com/larksuite/cli/internal/event/catalog" "github.com/larksuite/cli/internal/event/transport" ) // NewCmdBus creates the hidden `event _bus` daemon subcommand, forked by the consume client; fork argv lives in consume/startup.go. -func NewCmdBus(f *cmdutil.Factory) *cobra.Command { +func NewCmdBus(f *cmdutil.Factory, snap *catalog.Snapshot) *cobra.Command { var domain string cmd := &cobra.Command{ @@ -44,7 +45,7 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command { } tr := transport.New() - b := bus.NewBus(cfg.AppID, cfg.AppSecret, domain, tr, logger) + b := bus.NewBus(cfg.AppID, cfg.AppSecret, domain, tr, logger, snap) ctx, cancel := context.WithCancel(cmd.Context()) defer cancel() diff --git a/cmd/event/bus_test.go b/cmd/event/bus_test.go index b974cb38a1..ffd428b40b 100644 --- a/cmd/event/bus_test.go +++ b/cmd/event/bus_test.go @@ -27,7 +27,7 @@ func TestBusCommandLoggerSetupFailureIsTypedFileIO(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ AppID: "cli_bus_test", AppSecret: "secret", Brand: core.BrandFeishu, }) - cmd := NewCmdBus(f) + cmd := NewCmdBus(f, compileCatalog()) cmd.SetArgs([]string{}) err := cmd.Execute() diff --git a/cmd/event/consume.go b/cmd/event/consume.go index 21ded3f9cd..f64c335832 100644 --- a/cmd/event/consume.go +++ b/cmd/event/consume.go @@ -23,6 +23,7 @@ import ( "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/credential" eventlib "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/catalog" "github.com/larksuite/cli/internal/event/consume" "github.com/larksuite/cli/internal/event/transport" "github.com/larksuite/cli/internal/output" @@ -39,7 +40,7 @@ type consumeCmdOpts struct { timeout time.Duration } -func NewCmdConsume(f *cmdutil.Factory) *cobra.Command { +func NewCmdConsume(f *cmdutil.Factory, snap *catalog.Snapshot) *cobra.Command { var o consumeCmdOpts cmd := &cobra.Command{ @@ -57,7 +58,7 @@ Use 'event list' to see all available EventKeys. Use 'event schema ' for parameter details.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return runConsume(cmd, f, args[0], o) + return runConsume(cmd, f, snap, args[0], o) }, } @@ -76,7 +77,7 @@ Use 'event schema ' for parameter details.`, return cmd } -func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consumeCmdOpts) error { +func runConsume(cmd *cobra.Command, f *cmdutil.Factory, snap *catalog.Snapshot, eventKey string, o consumeCmdOpts) error { // Pipe-close (e.g. `... | head -n 1`) must reach the EPIPE error path in the loop, not SIGPIPE-kill. ignoreBrokenPipe() @@ -90,10 +91,11 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu return err } - keyDef, ok := eventlib.Lookup(eventKey) + entry, ok := snap.Resolve(eventKey) if !ok { - return unknownEventKeyErr(eventKey) + return unknownEventKeyErr(snap, eventKey) } + keyDef := entry.Definition() identity, err := resolveIdentity(cmd, f, keyDef) if err != nil { @@ -206,6 +208,7 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu if err := consume.Run(ctx, transport.New(), cfg.AppID, cfg.ProfileName, domain, consume.Options{ EventKey: eventKey, + Def: keyDef, Params: paramMap, JQExpr: o.jqExpr, Quiet: o.quiet, diff --git a/cmd/event/event.go b/cmd/event/event.go index c1f26b5396..93fe0f8d24 100644 --- a/cmd/event/event.go +++ b/cmd/event/event.go @@ -18,12 +18,13 @@ func NewCmdEvents(f *cmdutil.Factory) *cobra.Command { SilenceUsage: true, } - cmd.AddCommand(NewCmdConsume(f)) - cmd.AddCommand(NewCmdList(f)) - cmd.AddCommand(NewCmdSchema(f)) + snap := compileCatalog() + cmd.AddCommand(NewCmdConsume(f, snap)) + cmd.AddCommand(NewCmdList(f, snap)) + cmd.AddCommand(NewCmdSchema(f, snap)) cmd.AddCommand(NewCmdStatus(f)) cmd.AddCommand(NewCmdStop(f)) - cmd.AddCommand(NewCmdBus(f)) + cmd.AddCommand(NewCmdBus(f, snap)) return cmd } diff --git a/cmd/event/format_helpers_test.go b/cmd/event/format_helpers_test.go index 5e4117b8a6..0a46ce496d 100644 --- a/cmd/event/format_helpers_test.go +++ b/cmd/event/format_helpers_test.go @@ -288,9 +288,10 @@ func errorAs(err error, target interface{}) bool { func TestNewCmdFactories_WireFlags(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_XXXXXXXXXXXXXXXX"}) + snap := compileCatalog() t.Run("consume", func(t *testing.T) { - cmd := NewCmdConsume(f) + cmd := NewCmdConsume(f, snap) for _, flag := range []string{"param", "jq", "quiet", "output-dir", "max-events", "timeout", "as"} { if cmd.Flags().Lookup(flag) == nil { t.Errorf("consume missing --%s flag", flag) @@ -320,14 +321,14 @@ func TestNewCmdFactories_WireFlags(t *testing.T) { }) t.Run("list", func(t *testing.T) { - cmd := NewCmdList(f) + cmd := NewCmdList(f, snap) if cmd.Flags().Lookup("json") == nil { t.Error("list missing --json flag") } }) t.Run("bus", func(t *testing.T) { - cmd := NewCmdBus(f) + cmd := NewCmdBus(f, snap) if !cmd.Hidden { t.Error("bus should be hidden (internal daemon entrypoint)") } diff --git a/cmd/event/golden_test.go b/cmd/event/golden_test.go index e96e32c8ca..1f9f177e84 100644 --- a/cmd/event/golden_test.go +++ b/cmd/event/golden_test.go @@ -11,8 +11,6 @@ import ( "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" - - _ "github.com/larksuite/cli/events" ) var updateGolden = flag.Bool("update", false, "rewrite golden files instead of comparing") @@ -29,15 +27,16 @@ var goldenSchemaKeys = map[string]string{ } // The golden files pin stdout byte-for-byte. The output is deterministic: -// ListAll sorts by key, encoding/json sorts object keys, and nothing on the -// rendering path reads the clock or randomness. Regenerate with: +// the snapshot keeps keys sorted, encoding/json sorts object keys, and nothing +// on the rendering path reads the clock or randomness. Regenerate with: // // go test ./cmd/event/ -run TestGolden -update func TestGolden_ListOutput(t *testing.T) { + snap := compileCatalog() for name, asJSON := range map[string]bool{"list_text": false, "list_json": true} { t.Run(name, func(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) - if err := runList(f, asJSON); err != nil { + if err := runList(f, snap, asJSON); err != nil { t.Fatalf("runList: %v", err) } assertGolden(t, name, stdout.String()) @@ -46,11 +45,12 @@ func TestGolden_ListOutput(t *testing.T) { } func TestGolden_SchemaOutput(t *testing.T) { + snap := compileCatalog() for name, key := range goldenSchemaKeys { for suffix, asJSON := range map[string]bool{"_text": false, "_json": true} { t.Run(name+suffix, func(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) - if err := runSchema(f, key, asJSON); err != nil { + if err := runSchema(f, snap, key, asJSON); err != nil { t.Fatalf("runSchema(%s): %v", key, err) } assertGolden(t, name+suffix, stdout.String()) diff --git a/cmd/event/list.go b/cmd/event/list.go index 6ab59d0411..62ae6be971 100644 --- a/cmd/event/list.go +++ b/cmd/event/list.go @@ -12,17 +12,18 @@ import ( "github.com/larksuite/cli/internal/cmdutil" eventlib "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/catalog" "github.com/larksuite/cli/internal/output" ) -func NewCmdList(f *cmdutil.Factory) *cobra.Command { +func NewCmdList(f *cmdutil.Factory, snap *catalog.Snapshot) *cobra.Command { var asJSON bool cmd := &cobra.Command{ Use: "list", Short: "List all available EventKeys", Long: "Show all registered EventKeys grouped by domain (first segment of the key). Use --json for machine-readable output.", RunE: func(cmd *cobra.Command, args []string) error { - return runList(f, asJSON) + return runList(f, snap, asJSON) }, } cmd.Flags().BoolVar(&asJSON, "json", false, "Emit the full EventKey list as JSON (for AI / scripts)") @@ -30,12 +31,11 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { return cmd } -func runList(f *cmdutil.Factory, asJSON bool) error { - all := eventlib.ListAll() - +func runList(f *cmdutil.Factory, snap *catalog.Snapshot, asJSON bool) error { if asJSON { - return writeListJSON(f, all) + return writeListJSON(f, snap) } + all := snap.Definitions() if len(all) == 0 { // stderr so `event list | jq` doesn't ingest it as a row. @@ -112,14 +112,14 @@ type listRow struct { ResolvedSchema json.RawMessage `json:"resolved_output_schema,omitempty"` } -func writeListJSON(f *cmdutil.Factory, all []*eventlib.KeyDefinition) error { - rows := make([]listRow, len(all)) - for i, def := range all { - resolved, _, err := resolveSchemaJSON(def) - if err != nil { - return err +func writeListJSON(f *cmdutil.Factory, snap *catalog.Snapshot) error { + entries := snap.Entries() + rows := make([]listRow, len(entries)) + for i, entry := range entries { + rows[i] = listRow{ + KeyDefinition: entry.Definition(), + ResolvedSchema: entry.Output().SchemaJSON, } - rows[i] = listRow{KeyDefinition: def, ResolvedSchema: resolved} } output.PrintJson(f.IOStreams.Out, rows) return nil diff --git a/cmd/event/list_test.go b/cmd/event/list_test.go index 294a2d5470..b9134e2650 100644 --- a/cmd/event/list_test.go +++ b/cmd/event/list_test.go @@ -10,20 +10,18 @@ import ( "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" - eventlib "github.com/larksuite/cli/internal/event" - - _ "github.com/larksuite/cli/events" ) func TestEventLookup_VCMeetingLifecycleKeys(t *testing.T) { + snap := compileCatalog() for _, key := range []string{ "approval.instance.status_changed_v4", "approval.task.status_changed_v4", "vc.meeting.participant_meeting_started_v1", "vc.meeting.participant_meeting_joined_v1", } { - if _, ok := eventlib.Lookup(key); !ok { - t.Fatalf("event.Lookup(%q) should succeed", key) + if _, ok := snap.Resolve(key); !ok { + t.Fatalf("snap.Resolve(%q) should succeed", key) } } } @@ -31,7 +29,7 @@ func TestEventLookup_VCMeetingLifecycleKeys(t *testing.T) { func TestRunList_TextOutput(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) - if err := runList(f, false); err != nil { + if err := runList(f, compileCatalog(), false); err != nil { t.Fatalf("runList: %v", err) } @@ -55,7 +53,7 @@ func TestRunList_TextOutput(t *testing.T) { func TestRunList_JSONOutput(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) - if err := runList(f, true); err != nil { + if err := runList(f, compileCatalog(), true); err != nil { t.Fatalf("runList json: %v", err) } diff --git a/cmd/event/render_contract_test.go b/cmd/event/render_contract_test.go index d357eefd48..c5a4ea59d9 100644 --- a/cmd/event/render_contract_test.go +++ b/cmd/event/render_contract_test.go @@ -16,6 +16,7 @@ import ( // definition) must either appear here deliberately or be tagged `json:"-"`. var renderedDeclarationFields = map[string]string{ "key": "stable identifier agents subscribe by", + "domain": "declared domain override; empty for every shipped key (filtering reads the derived descriptor value), so legacy output is byte-identical", "display_name": "human-readable name for pickers", "description": "what the event means", "event_type": "upstream event type behind this key", diff --git a/cmd/event/schema.go b/cmd/event/schema.go index 18fb1c3bd0..0896ba0b6b 100644 --- a/cmd/event/schema.go +++ b/cmd/event/schema.go @@ -11,75 +11,13 @@ import ( "github.com/spf13/cobra" - "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" eventlib "github.com/larksuite/cli/internal/event" - "github.com/larksuite/cli/internal/event/schemas" + "github.com/larksuite/cli/internal/event/catalog" "github.com/larksuite/cli/internal/output" ) -// resolveSchemaJSON returns the final JSON Schema for an EventKey (reflected base, V2-wrapped for Native, overlay applied); orphans lists unresolved FieldOverrides pointers. -func resolveSchemaJSON(def *eventlib.KeyDefinition) (json.RawMessage, []string, error) { - spec, isNative := pickSpec(def.Schema) - if spec == nil { - return nil, nil, nil - } - - base, err := renderSpec(spec) - if err != nil { - return nil, nil, err - } - if base == nil { - return nil, nil, nil - } - - if isNative { - base = schemas.WrapV2Envelope(base) - } - - if len(def.Schema.FieldOverrides) > 0 { - var parsed map[string]interface{} - if err := json.Unmarshal(base, &parsed); err != nil { - return nil, nil, errs.NewInternalError(errs.SubtypeUnknown, - "parse base schema for field overrides: %s", err).WithCause(err) - } - orphans := schemas.ApplyFieldOverrides(parsed, def.Schema.FieldOverrides) - out, err := json.Marshal(parsed) - if err != nil { - return nil, nil, errs.NewInternalError(errs.SubtypeUnknown, - "serialize schema with field overrides: %s", err).WithCause(err) - } - return out, orphans, nil - } - - return base, nil, nil -} - -// pickSpec returns the non-nil spec and whether it is Native (requires V2 envelope wrap). -func pickSpec(s eventlib.SchemaDef) (*eventlib.SchemaSpec, bool) { - if s.Native != nil { - return s.Native, true - } - if s.Custom != nil { - return s.Custom, false - } - return nil, false -} - -// renderSpec produces a JSON Schema from Type (reflected) or Raw (copied). -func renderSpec(s *eventlib.SchemaSpec) (json.RawMessage, error) { - if s.Type != nil { - return schemas.FromType(s.Type), nil - } - if len(s.Raw) > 0 { - buf := make(json.RawMessage, len(s.Raw)) - copy(buf, s.Raw) - return buf, nil - } - return nil, errs.NewInternalError(errs.SubtypeUnknown, "schemaSpec has neither Type nor Raw") -} - -func NewCmdSchema(f *cmdutil.Factory) *cobra.Command { +func NewCmdSchema(f *cmdutil.Factory, snap *catalog.Snapshot) *cobra.Command { var asJSON bool cmd := &cobra.Command{ Use: "schema ", @@ -87,7 +25,7 @@ func NewCmdSchema(f *cmdutil.Factory) *cobra.Command { Long: "Display detailed information about an EventKey including type, events, parameters, and response schema. Use --json for machine-readable output.", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return runSchema(f, args[0], asJSON) + return runSchema(f, snap, args[0], asJSON) }, } cmd.Flags().BoolVar(&asJSON, "json", false, "Emit the EventKey definition + resolved schema as JSON (for AI / scripts)") @@ -95,14 +33,15 @@ func NewCmdSchema(f *cmdutil.Factory) *cobra.Command { return cmd } -func runSchema(f *cmdutil.Factory, key string, asJSON bool) error { - def, ok := eventlib.Lookup(key) +func runSchema(f *cmdutil.Factory, snap *catalog.Snapshot, key string, asJSON bool) error { + entry, ok := snap.Resolve(key) if !ok { - return unknownEventKeyErr(key) + return unknownEventKeyErr(snap, key) } + def := entry.Definition() if asJSON { - return writeSchemaJSON(f, def) + return writeSchemaJSON(f, entry) } out := f.IOStreams.Out @@ -170,10 +109,7 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error { } } - resolved, _, err := resolveSchemaJSON(def) - if err != nil { - return err - } + resolved := entry.Output().SchemaJSON if resolved != nil { fmt.Fprintf(out, "\nOutput Schema:\n") printIndentedJSON(out, resolved) @@ -212,24 +148,12 @@ type schemaPayload struct { } // writeSchemaJSON emits the EventKey definition plus resolved schema; jq_root_path tells callers whether fields live at `.` or `.event`. -func writeSchemaJSON(f *cmdutil.Factory, def *eventlib.KeyDefinition) error { - resolved, _, err := resolveSchemaJSON(def) - if err != nil { - return err - } - var jqRootPath string - if resolved != nil { - // Native → V2 envelope ⇒ `.event.xxx`; Custom → flat ⇒ `.`. - _, isNative := pickSpec(def.Schema) - jqRootPath = "." - if isNative { - jqRootPath = ".event" - } - } +func writeSchemaJSON(f *cmdutil.Factory, entry *catalog.Entry) error { + contract := entry.Output() output.PrintJson(f.IOStreams.Out, schemaPayload{ - KeyDefinition: def, - ResolvedSchema: resolved, - JQRootPath: jqRootPath, + KeyDefinition: entry.Definition(), + ResolvedSchema: contract.SchemaJSON, + JQRootPath: contract.JQRootPath, }) return nil } diff --git a/cmd/event/schema_test.go b/cmd/event/schema_test.go index 9d0acf82ac..f01275736d 100644 --- a/cmd/event/schema_test.go +++ b/cmd/event/schema_test.go @@ -10,15 +10,27 @@ import ( "strings" "testing" - "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" eventlib "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/catalog" "github.com/larksuite/cli/internal/event/schemas" - - _ "github.com/larksuite/cli/events" ) +// compileTestSnapshot compiles synthetic declarations into a snapshot using +// the same strategy set the production wiring provides. +func compileTestSnapshot(t *testing.T, defs ...eventlib.KeyDefinition) *catalog.Snapshot { + t.Helper() + snap, err := catalog.Compile(defs, catalog.StrategyRefs{ + catalog.StrategyNone, + catalog.StrategyLegacyPreConsume, + }) + if err != nil { + t.Fatalf("compile test catalog: %v", err) + } + return snap +} + type approvalSchemaJSONPayload struct { JQRootPath string `json:"jq_root_path"` AuthTypes []string `json:"auth_types"` @@ -45,7 +57,7 @@ type approvalSchemaJSONProperty struct { func TestRunSchema_ProcessedKey_Text(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) - if err := runSchema(f, "im.message.receive_v1", false); err != nil { + if err := runSchema(f, compileCatalog(), "im.message.receive_v1", false); err != nil { t.Fatalf("runSchema: %v", err) } @@ -65,7 +77,7 @@ func TestRunSchema_ProcessedKey_Text(t *testing.T) { func TestRunSchema_NativeKey_WrapsEnvelope(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) - if err := runSchema(f, "im.message.message_read_v1", false); err != nil { + if err := runSchema(f, compileCatalog(), "im.message.message_read_v1", false); err != nil { t.Fatalf("runSchema: %v", err) } @@ -85,7 +97,7 @@ func TestRunSchema_NativeKey_WrapsEnvelope(t *testing.T) { func TestRunSchema_UnknownKey_SuggestsAlternatives(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) - err := runSchema(f, "im.message.recieve_v1", false) + err := runSchema(f, compileCatalog(), "im.message.recieve_v1", false) if err == nil { t.Fatal("expected error for unknown key") } @@ -101,7 +113,7 @@ func TestRunSchema_UnknownKey_SuggestsAlternatives(t *testing.T) { func TestRunSchema_JSONOutput(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) - if err := runSchema(f, "im.message.receive_v1", true); err != nil { + if err := runSchema(f, compileCatalog(), "im.message.receive_v1", true); err != nil { t.Fatalf("runSchema json: %v", err) } @@ -122,7 +134,7 @@ func TestRunSchema_JSONOutput(t *testing.T) { func TestRunSchema_ReceiveMessageAgentFieldsJSON(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) - if err := runSchema(f, "im.message.receive_v1", true); err != nil { + if err := runSchema(f, compileCatalog(), "im.message.receive_v1", true); err != nil { t.Fatalf("runSchema json: %v", err) } @@ -156,7 +168,7 @@ func TestRunSchema_ReceiveMessageAgentFieldsJSON(t *testing.T) { func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) - if err := runSchema(f, "task.task.update_user_access_v2", true); err != nil { + if err := runSchema(f, compileCatalog(), "task.task.update_user_access_v2", true); err != nil { t.Fatalf("runSchema json: %v", err) } @@ -195,7 +207,7 @@ func TestRunSchema_ApprovalStatusChangedJSON(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) - if err := runSchema(f, tc.key, true); err != nil { + if err := runSchema(f, compileCatalog(), tc.key, true); err != nil { t.Fatalf("runSchema json: %v", err) } @@ -243,7 +255,7 @@ func TestRunSchema_JSONOutput_VCMeetingLifecycleKeys(t *testing.T) { t.Run(key, func(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) - if err := runSchema(f, key, true); err != nil { + if err := runSchema(f, compileCatalog(), key, true); err != nil { t.Fatalf("runSchema json: %v", err) } @@ -276,9 +288,8 @@ func TestRunSchema_JSONOutput_VCMeetingLifecycleKeys(t *testing.T) { func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) { const syntheticKey = "test.evt_sub" - t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) }) - eventlib.RegisterKey(eventlib.KeyDefinition{ + snap := compileTestSnapshot(t, eventlib.KeyDefinition{ Key: syntheticKey, EventType: syntheticKey, Params: []eventlib.ParamDef{ @@ -289,7 +300,7 @@ func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) { }) f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) - if err := runSchema(f, syntheticKey, false); err != nil { + if err := runSchema(f, snap, syntheticKey, false); err != nil { t.Fatalf("runSchema: %v", err) } @@ -325,9 +336,8 @@ func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) { func TestSchema_JSON_IncludesSubscriptionKey(t *testing.T) { const syntheticKey = "test.evt_json" - t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) }) - eventlib.RegisterKey(eventlib.KeyDefinition{ + snap := compileTestSnapshot(t, eventlib.KeyDefinition{ Key: syntheticKey, EventType: syntheticKey, Params: []eventlib.ParamDef{{Name: "mailbox", SubscriptionKey: true}}, @@ -335,7 +345,7 @@ func TestSchema_JSON_IncludesSubscriptionKey(t *testing.T) { }) f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) - if err := runSchema(f, syntheticKey, true); err != nil { + if err := runSchema(f, snap, syntheticKey, true); err != nil { t.Fatalf("runSchema json: %v", err) } @@ -349,12 +359,13 @@ func TestSchema_JSON_IncludesSubscriptionKey(t *testing.T) { func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) { const syntheticKey = "t.custom.overlay" - t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) }) type out struct { SenderID string `json:"sender_id"` } - eventlib.RegisterKey(eventlib.KeyDefinition{ + // A compile that succeeds proves the overlay left no orphan pointers; the + // entry's output contract carries the resolved schema. + snap := compileTestSnapshot(t, eventlib.KeyDefinition{ Key: syntheticKey, EventType: syntheticKey, Schema: eventlib.SchemaDef{ @@ -367,13 +378,12 @@ func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) { return nil, nil }, }) - def, _ := eventlib.Lookup(syntheticKey) - resolved, orphans, err := resolveSchemaJSON(def) - if err != nil || len(orphans) != 0 { - t.Fatalf("resolve: err=%v orphans=%v", err, orphans) + entry, ok := snap.Resolve(syntheticKey) + if !ok { + t.Fatalf("snap.Resolve(%q) should succeed", syntheticKey) } var parsed map[string]interface{} - if err := json.Unmarshal(resolved, &parsed); err != nil { + if err := json.Unmarshal(entry.Output().SchemaJSON, &parsed); err != nil { t.Fatal(err) } got := parsed["properties"].(map[string]interface{})["sender_id"].(map[string]interface{})["format"] @@ -382,37 +392,33 @@ func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) { } } -func TestRenderSpec_EmptySpecIsTypedInternalError(t *testing.T) { - _, err := renderSpec(&eventlib.SchemaSpec{}) +func TestCompile_EmptySpecIsRejected(t *testing.T) { + _, err := catalog.Compile([]eventlib.KeyDefinition{{ + Key: "synthetic.empty.spec", + EventType: "synthetic.empty.spec", + Schema: eventlib.SchemaDef{Custom: &eventlib.SchemaSpec{}}, + }}, catalog.StrategyRefs{catalog.StrategyNone}) if err == nil { t.Fatal("expected error for spec with neither Type nor Raw") } - p, ok := errs.ProblemOf(err) - if !ok { - t.Fatalf("expected typed errs error, got %T: %v", err, err) - } - if p.Category != errs.CategoryInternal { - t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal) + if !strings.Contains(err.Error(), "exactly one of Type or Raw") { + t.Errorf("error should reject the empty spec, got: %v", err) } } -func TestResolveSchemaJSON_InvalidBaseWithOverridesIsTypedInternalError(t *testing.T) { - def := &eventlib.KeyDefinition{ - Key: "synthetic.invalid.base", +func TestCompile_InvalidBaseWithOverridesIsRejected(t *testing.T) { + _, err := catalog.Compile([]eventlib.KeyDefinition{{ + Key: "synthetic.invalid.base", + EventType: "synthetic.invalid.base", Schema: eventlib.SchemaDef{ Custom: &eventlib.SchemaSpec{Raw: json.RawMessage("{not json")}, FieldOverrides: map[string]schemas.FieldMeta{"x": {}}, }, - } - _, _, err := resolveSchemaJSON(def) + }}, catalog.StrategyRefs{catalog.StrategyNone}) if err == nil { t.Fatal("expected error for unparsable base schema") } - p, ok := errs.ProblemOf(err) - if !ok { - t.Fatalf("expected typed errs error, got %T: %v", err, err) - } - if p.Category != errs.CategoryInternal { - t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal) + if !strings.Contains(err.Error(), "parse base schema for field overrides") { + t.Errorf("error should reject the unparsable base schema, got: %v", err) } } diff --git a/cmd/event/suggestions.go b/cmd/event/suggestions.go index a49979e735..75fd3d3af8 100644 --- a/cmd/event/suggestions.go +++ b/cmd/event/suggestions.go @@ -9,14 +9,14 @@ import ( "strings" "github.com/larksuite/cli/errs" - eventlib "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/catalog" "github.com/larksuite/cli/internal/suggest" ) const maxSuggestions = 3 // suggestEventKeys returns up to maxSuggestions keys resembling input (substring match beats edit distance). -func suggestEventKeys(input string) []string { +func suggestEventKeys(snap *catalog.Snapshot, input string) []string { type match struct { key string dist int @@ -24,13 +24,13 @@ func suggestEventKeys(input string) []string { var hits []match threshold := max(2, len(input)/5) - for _, def := range eventlib.ListAll() { - if strings.Contains(def.Key, input) { - hits = append(hits, match{def.Key, 0}) + for _, key := range snap.Keys() { + if strings.Contains(key, input) { + hits = append(hits, match{key, 0}) continue } - if d := suggest.Levenshtein(input, def.Key); d <= threshold { - hits = append(hits, match{def.Key, d}) + if d := suggest.Levenshtein(input, key); d <= threshold { + hits = append(hits, match{key, d}) } } sort.Slice(hits, func(i, j int) bool { return hits[i].dist < hits[j].dist }) @@ -59,9 +59,9 @@ func formatSuggestions(keys []string) string { } // unknownEventKeyErr builds the shared "unknown EventKey" error with a suggestion tail when available. -func unknownEventKeyErr(key string) error { +func unknownEventKeyErr(snap *catalog.Snapshot, key string) error { msg := fmt.Sprintf("unknown EventKey: %s", key) - if guesses := suggestEventKeys(key); len(guesses) > 0 { + if guesses := suggestEventKeys(snap, key); len(guesses) > 0 { msg += " — did you mean " + formatSuggestions(guesses) + "?" } return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg). diff --git a/cmd/event/suggestions_test.go b/cmd/event/suggestions_test.go index fdaaa2c01a..614cc5ecd1 100644 --- a/cmd/event/suggestions_test.go +++ b/cmd/event/suggestions_test.go @@ -6,11 +6,10 @@ package event import ( "strings" "testing" - - _ "github.com/larksuite/cli/events" ) func TestSuggestEventKeys(t *testing.T) { + snap := compileCatalog() cases := []struct { name string input string @@ -41,7 +40,7 @@ func TestSuggestEventKeys(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - got := suggestEventKeys(tc.input) + got := suggestEventKeys(snap, tc.input) if tc.wantEmpty { if len(got) != 0 { t.Errorf("expected empty slice, got %v", got) @@ -98,7 +97,7 @@ func TestFormatSuggestions(t *testing.T) { } func TestUnknownEventKeyErr_IncludesSuggestion(t *testing.T) { - err := unknownEventKeyErr("im.message.recieve_v1") + err := unknownEventKeyErr(compileCatalog(), "im.message.recieve_v1") if err == nil { t.Fatal("expected error") } @@ -115,7 +114,7 @@ func TestUnknownEventKeyErr_IncludesSuggestion(t *testing.T) { } func TestUnknownEventKeyErr_NoSuggestion(t *testing.T) { - err := unknownEventKeyErr("xyzzy_no_such_event_key_at_all") + err := unknownEventKeyErr(compileCatalog(), "xyzzy_no_such_event_key_at_all") if err == nil { t.Fatal("expected error") } diff --git a/cmd/event/wiring.go b/cmd/event/wiring.go new file mode 100644 index 0000000000..e728e4edfe --- /dev/null +++ b/cmd/event/wiring.go @@ -0,0 +1,26 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package event + +import ( + "fmt" + + "github.com/larksuite/cli/events" + "github.com/larksuite/cli/internal/event/catalog" +) + +// compileCatalog is the event command tree's single assembly point: it turns +// the aggregated domain declarations into the immutable snapshot every +// subcommand reads. A compile failure is a defect in declarations built into +// this binary — there is nothing to recover at runtime, so it panics. +func compileCatalog() *catalog.Snapshot { + snap, err := catalog.Compile(events.All(), catalog.StrategyRefs{ + catalog.StrategyNone, + catalog.StrategyLegacyPreConsume, + }) + if err != nil { + panic(fmt.Sprintf("event catalog failed to compile: %v", err)) + } + return snap +} diff --git a/internal/event/bus/bus.go b/internal/event/bus/bus.go index e15e2904be..1bfbab3810 100644 --- a/internal/event/bus/bus.go +++ b/internal/event/bus/bus.go @@ -20,6 +20,7 @@ import ( "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/event" "github.com/larksuite/cli/internal/event/busdiscover" + "github.com/larksuite/cli/internal/event/catalog" "github.com/larksuite/cli/internal/event/protocol" "github.com/larksuite/cli/internal/event/source" "github.com/larksuite/cli/internal/event/transport" @@ -49,9 +50,13 @@ type Bus struct { // pidHandle pins the alive.lock fd to the bus lifetime; OS releases on exit. pidHandle *busdiscover.Handle + + // snapshot is the compiled catalog this daemon serves: it decides which + // upstream event types to subscribe and which keys are single-consumer. + snapshot *catalog.Snapshot } -func NewBus(appID, appSecret, domain string, tr transport.IPC, logger *log.Logger) *Bus { +func NewBus(appID, appSecret, domain string, tr transport.IPC, logger *log.Logger, snap *catalog.Snapshot) *Bus { return &Bus{ appID: appID, appSecret: appSecret, @@ -60,6 +65,7 @@ func NewBus(appID, appSecret, domain string, tr transport.IPC, logger *log.Logge hub: NewHub(), dedup: event.NewDedupFilter(), logger: logger, + snapshot: snap, startTime: time.Now(), conns: make(map[*Conn]struct{}), // Buffered so shutdown and source-exit paths never drop the signal. @@ -166,7 +172,7 @@ func (b *Bus) startSources(ctx context.Context) { Logger: b.logger, }} } - eventTypes := subscribedEventTypes() + eventTypes := b.snapshot.EventTypes() b.hub.SetLogger(b.logger) for _, src := range sources { go func(s source.Source) { @@ -197,20 +203,6 @@ func (b *Bus) startSources(ctx context.Context) { } } -// subscribedEventTypes returns the deduplicated union of EventTypes from every registered EventKey. -func subscribedEventTypes() []string { - seen := make(map[string]struct{}) - var types []string - for _, def := range event.ListAll() { - if _, ok := seen[def.EventType]; ok { - continue - } - seen[def.EventType] = struct{}{} - types = append(types, def.EventType) - } - return types -} - // acceptLoop accepts IPC connections until the listener is closed. func (b *Bus) acceptLoop(ctx context.Context) { for { @@ -271,8 +263,8 @@ func (b *Bus) handleHello(conn net.Conn, reader *bufio.Reader, hello *protocol.H // SingleConsumer EventKeys allow only one consumer per SubscriptionID: reject extras at handshake. exclusive := false - if def, ok := event.Lookup(hello.EventKey); ok { - exclusive = def.SingleConsumer + if entry, ok := b.snapshot.Resolve(hello.EventKey); ok { + exclusive = entry.Capability().SingleConsumer } var firstForKey bool if exclusive { diff --git a/internal/event/bus/bus_shutdown_test.go b/internal/event/bus/bus_shutdown_test.go index 98ff07401c..deeb12de2b 100644 --- a/internal/event/bus/bus_shutdown_test.go +++ b/internal/event/bus/bus_shutdown_test.go @@ -74,7 +74,7 @@ func TestRunShutdownWithMultipleConns(t *testing.T) { // shutdownCh must be buffered so a signal sent before Run's select loop is still delivered. func TestShutdownSignalNotDroppedBeforeRunSelects(t *testing.T) { - b := NewBus("test-app", "test-secret", "", nil, log.New(io.Discard, "", 0)) + b := NewBus("test-app", "test-secret", "", nil, log.New(io.Discard, "", 0), nil) select { case b.shutdownCh <- struct{}{}: diff --git a/internal/event/bus/handle_hello_test.go b/internal/event/bus/handle_hello_test.go index b8b1e36fff..f49672e995 100644 --- a/internal/event/bus/handle_hello_test.go +++ b/internal/event/bus/handle_hello_test.go @@ -14,9 +14,24 @@ import ( "time" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/catalog" "github.com/larksuite/cli/internal/event/protocol" ) +// compileBusTestSnapshot compiles synthetic declarations into the snapshot a +// bus under test serves, replacing the removed global registry. +func compileBusTestSnapshot(t *testing.T, defs ...event.KeyDefinition) *catalog.Snapshot { + t.Helper() + snap, err := catalog.Compile(defs, catalog.StrategyRefs{ + catalog.StrategyNone, + catalog.StrategyLegacyPreConsume, + }) + if err != nil { + t.Fatalf("compile test catalog: %v", err) + } + return snap +} + // HelloAck write failure must unregister the conn from hub and bus before returning. func TestHandleHello_HelloAckWriteFailureUnregisters(t *testing.T) { logger := log.New(io.Discard, "", 0) @@ -27,6 +42,7 @@ func TestHandleHello_HelloAckWriteFailureUnregisters(t *testing.T) { conns: make(map[*Conn]struct{}), idleTimer: time.NewTimer(30 * time.Second), shutdownCh: make(chan struct{}, 1), + snapshot: compileBusTestSnapshot(t), } server, client := net.Pipe() @@ -78,6 +94,7 @@ func TestHandleHello_LegacyClient_FallsBackToEventKey(t *testing.T) { conns: make(map[*Conn]struct{}), idleTimer: time.NewTimer(30 * time.Second), shutdownCh: make(chan struct{}, 1), + snapshot: compileBusTestSnapshot(t), } server, client := net.Pipe() @@ -143,6 +160,7 @@ func TestHandleHello_ModernClient_UsesSubscriptionID(t *testing.T) { conns: make(map[*Conn]struct{}), idleTimer: time.NewTimer(30 * time.Second), shutdownCh: make(chan struct{}, 1), + snapshot: compileBusTestSnapshot(t), } server, client := net.Pipe() @@ -202,13 +220,12 @@ func TestHandleHello_ModernClient_UsesSubscriptionID(t *testing.T) { // the first consumer and rejects the second for the same SubscriptionID. func TestHandleHello_SingleConsumerRejectsSecond(t *testing.T) { const key = "test.handlehello.exclusive" - event.RegisterKey(event.KeyDefinition{ + snap := compileBusTestSnapshot(t, event.KeyDefinition{ Key: key, EventType: key, SingleConsumer: true, Schema: event.SchemaDef{Native: &event.SchemaSpec{Raw: []byte(`{"type":"object"}`)}}, }) - defer event.UnregisterKeyForTest(key) logger := log.New(io.Discard, "", 0) hub := NewHub() @@ -218,6 +235,7 @@ func TestHandleHello_SingleConsumerRejectsSecond(t *testing.T) { conns: make(map[*Conn]struct{}), idleTimer: time.NewTimer(30 * time.Second), shutdownCh: make(chan struct{}, 1), + snapshot: snap, } readAck := func(t *testing.T, pid int) *protocol.HelloAck { diff --git a/internal/event/consume/capability_gate_test.go b/internal/event/consume/capability_gate_test.go index 31a28e6622..3fc2d33216 100644 --- a/internal/event/consume/capability_gate_test.go +++ b/internal/event/consume/capability_gate_test.go @@ -36,7 +36,7 @@ func TestCapabilityGate_RefusesLegacyBusBeforeAnySideEffect(t *testing.T) { t.Run(name, func(t *testing.T) { const key = "test.evt_capability_gate" var setupCalls atomic.Int64 - event.RegisterKey(event.KeyDefinition{ + def := compileDefForTest(t, event.KeyDefinition{ Key: key, EventType: key, Schema: event.SchemaDef{Custom: &event.SchemaSpec{Raw: json.RawMessage(`{"type":"object"}`)}}, @@ -45,12 +45,12 @@ func TestCapabilityGate_RefusesLegacyBusBeforeAnySideEffect(t *testing.T) { return nil, nil }, }) - defer event.UnregisterKeyForTest(key) tr := startLegacyBusStub(t, rawAck) err := Run(context.Background(), tr, "cap-gate-app", "", "", Options{ EventKey: key, + Def: def, Runtime: &fakeRT{}, Quiet: true, }) diff --git a/internal/event/consume/consume.go b/internal/event/consume/consume.go index fa09d8b3d2..371d03ee56 100644 --- a/internal/event/consume/consume.go +++ b/internal/event/consume/consume.go @@ -22,7 +22,11 @@ import ( ) type Options struct { - EventKey string + EventKey string + // Def is the resolved declaration for EventKey. The caller resolves it + // from the compiled catalog; this package validates and consumes it but + // never looks anything up itself. + Def *event.KeyDefinition Params map[string]string JQExpr string Quiet bool @@ -45,8 +49,8 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin errOut = os.Stderr //nolint:forbidigo // library-caller fallback } - keyDef, ok := event.Lookup(opts.EventKey) - if !ok { + keyDef := opts.Def + if keyDef == nil { return errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown EventKey: %s", opts.EventKey). WithHint("run `lark-cli event list` to see available keys") diff --git a/internal/event/consume/consume_test.go b/internal/event/consume/consume_test.go index a082e71f67..ff8f6d150b 100644 --- a/internal/event/consume/consume_test.go +++ b/internal/event/consume/consume_test.go @@ -14,6 +14,7 @@ import ( "testing" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/catalog" "github.com/larksuite/cli/internal/event/protocol" "github.com/larksuite/cli/internal/event/transport" ) @@ -27,12 +28,31 @@ func (f *fakeRT) CallAPI(_ context.Context, _, _ string, _ interface{}) (json.Ra return nil, f.err } +// compileDefForTest runs a synthetic declaration through catalog compilation +// and returns the canonical definition — what the CLI entry point hands +// Run as Options.Def. +func compileDefForTest(t *testing.T, def event.KeyDefinition) *event.KeyDefinition { + t.Helper() + snap, err := catalog.Compile([]catalog.KeyDefinition{def}, catalog.StrategyRefs{ + catalog.StrategyNone, + catalog.StrategyLegacyPreConsume, + }) + if err != nil { + t.Fatalf("compile test declaration: %v", err) + } + entry, ok := snap.Resolve(def.Key) + if !ok { + t.Fatalf("compiled snapshot has no entry for %s", def.Key) + } + return entry.Definition() +} + func TestNormalizeParams_ErrorIsWrappedWithEventKey(t *testing.T) { // Drives the real Run() path: NormalizeParams fails before EnsureBus, so no // bus is contacted, yet the production error-wrapping is exercised — if Run() // ever stops wrapping, this test fails. const key = "test.evt_normalize_fail" - event.RegisterKey(event.KeyDefinition{ + def := compileDefForTest(t, event.KeyDefinition{ Key: key, EventType: key, Schema: event.SchemaDef{Custom: &event.SchemaSpec{Raw: json.RawMessage(`{"type":"object"}`)}}, @@ -40,10 +60,10 @@ func TestNormalizeParams_ErrorIsWrappedWithEventKey(t *testing.T) { return errors.New("simulated normalize failure") }, }) - defer event.UnregisterKeyForTest(key) err := Run(context.Background(), transport.New(), "app", "", "", Options{ EventKey: key, + Def: def, Runtime: &fakeRT{}, Quiet: true, }) diff --git a/internal/event/consume/startup_guard_test.go b/internal/event/consume/startup_guard_test.go index 2173837725..3c98f024a0 100644 --- a/internal/event/consume/startup_guard_test.go +++ b/internal/event/consume/startup_guard_test.go @@ -84,13 +84,14 @@ func TestRun_UnknownEventKeyIsTypedValidation(t *testing.T) { } func TestRun_InvalidJQFailsBeforeAnySideEffect(t *testing.T) { - event.RegisterKey(event.KeyDefinition{ + def := compileDefForTest(t, event.KeyDefinition{ Key: "consume.runtest.jq", EventType: "consume.runtest.jq_v1", - Schema: event.SchemaDef{Custom: &event.SchemaSpec{Raw: json.RawMessage(`{}`)}}, + Schema: event.SchemaDef{Custom: &event.SchemaSpec{Raw: json.RawMessage(`{"type":"object"}`)}}, }) err := Run(context.Background(), failDialTransport{}, "cli_x", "", "", Options{ EventKey: "consume.runtest.jq", + Def: def, JQExpr: "[invalid{{{", ErrOut: io.Discard, }) diff --git a/internal/event/integration_test.go b/internal/event/integration_test.go index 798b1425e6..9524b396ad 100644 --- a/internal/event/integration_test.go +++ b/internal/event/integration_test.go @@ -21,6 +21,7 @@ import ( "github.com/larksuite/cli/internal/event" "github.com/larksuite/cli/internal/event/bus" + "github.com/larksuite/cli/internal/event/catalog" "github.com/larksuite/cli/internal/event/protocol" "github.com/larksuite/cli/internal/event/source" "github.com/larksuite/cli/internal/event/testutil" @@ -33,6 +34,20 @@ func integNativeSchema() event.SchemaDef { return event.SchemaDef{Native: &event.SchemaSpec{Type: reflect.TypeOf(integTestOut{})}} } +// compileTestSnapshot compiles synthetic declarations into the snapshot a +// bus or consumer under test is handed, replacing the removed global registry. +func compileTestSnapshot(t *testing.T, defs ...event.KeyDefinition) *catalog.Snapshot { + t.Helper() + snap, err := catalog.Compile(defs, catalog.StrategyRefs{ + catalog.StrategyNone, + catalog.StrategyLegacyPreConsume, + }) + if err != nil { + t.Fatalf("compile test catalog: %v", err) + } + return snap +} + func waitForBusReady(t *testing.T, tr transport.IPC, addr string) { t.Helper() deadline := time.Now().Add(2 * time.Second) @@ -87,10 +102,9 @@ func (s *mockIntegSource) emit(e *event.RawEvent) { } func TestIntegration_BusToConsume(t *testing.T) { - event.ResetRegistryForTest() source.ResetForTest() - event.RegisterKey(event.KeyDefinition{ + snap := compileTestSnapshot(t, event.KeyDefinition{ Key: "test.event.v1", EventType: "test.event.v1", Schema: integNativeSchema(), @@ -106,7 +120,7 @@ func TestIntegration_BusToConsume(t *testing.T) { logger := log.New(os.Stderr, "[test-bus] ", log.LstdFlags) testTr := testutil.NewWrappedFake(tr, addr) - b := bus.NewBus("test-app", "test-secret", "", testTr, logger) + b := bus.NewBus("test-app", "test-secret", "", testTr, logger, snap) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -185,10 +199,9 @@ func TestIntegration_BusToConsume(t *testing.T) { } func TestIntegration_MultipleConsumers(t *testing.T) { - event.ResetRegistryForTest() source.ResetForTest() - event.RegisterKey(event.KeyDefinition{ + snap := compileTestSnapshot(t, event.KeyDefinition{ Key: "multi.event.v1", EventType: "multi.event.v1", Schema: integNativeSchema(), @@ -203,7 +216,7 @@ func TestIntegration_MultipleConsumers(t *testing.T) { logger := log.New(os.Stderr, "[test-multi] ", log.LstdFlags) testTr := testutil.NewWrappedFake(tr, addr) - b := bus.NewBus("test-multi", "test-secret", "", testTr, logger) + b := bus.NewBus("test-multi", "test-secret", "", testTr, logger, snap) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -279,10 +292,9 @@ func TestIntegration_MultipleConsumers(t *testing.T) { } func TestIntegration_DedupFilter(t *testing.T) { - event.ResetRegistryForTest() source.ResetForTest() - event.RegisterKey(event.KeyDefinition{ + snap := compileTestSnapshot(t, event.KeyDefinition{ Key: "dedup.event.v1", EventType: "dedup.event.v1", Schema: integNativeSchema(), @@ -297,7 +309,7 @@ func TestIntegration_DedupFilter(t *testing.T) { logger := log.New(os.Stderr, "[test-dedup] ", log.LstdFlags) testTr := testutil.NewWrappedFake(tr, addr) - b := bus.NewBus("test-dedup", "test-secret", "", testTr, logger) + b := bus.NewBus("test-dedup", "test-secret", "", testTr, logger, snap) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() diff --git a/internal/event/preconsume_contract_test.go b/internal/event/preconsume_contract_test.go index 4f69a8972e..e5cfa9cdad 100644 --- a/internal/event/preconsume_contract_test.go +++ b/internal/event/preconsume_contract_test.go @@ -35,6 +35,7 @@ import ( "github.com/larksuite/cli/internal/event" "github.com/larksuite/cli/internal/event/bus" + "github.com/larksuite/cli/internal/event/catalog" "github.com/larksuite/cli/internal/event/consume" "github.com/larksuite/cli/internal/event/protocol" "github.com/larksuite/cli/internal/event/source" @@ -53,14 +54,14 @@ type preConsumeCounters struct { cleanup atomic.Int64 } -// registerContractKey registers a synthetic EventKey whose PreConsume counts -// setups. With withCleanup, PreConsume returns a closure that counts cleanups; +// contractKey declares a synthetic EventKey whose PreConsume counts setups. +// With withCleanup, PreConsume returns a closure that counts cleanups; // without, it returns (nil, nil) — modeling keys whose server-side // subscription is a durable relationship with deliberately no unsubscribe. -func registerContractKey(t *testing.T, key string, withCleanup bool) *preConsumeCounters { - t.Helper() +// Callers compile the declaration into the snapshot the bus and consumers use. +func contractKey(key string, withCleanup bool) (event.KeyDefinition, *preConsumeCounters) { c := &preConsumeCounters{} - event.RegisterKey(event.KeyDefinition{ + def := event.KeyDefinition{ Key: key, EventType: key, Schema: integNativeSchema(), @@ -74,15 +75,26 @@ func registerContractKey(t *testing.T, key string, withCleanup bool) *preConsume return nil }, nil }, - }) - t.Cleanup(func() { event.UnregisterKeyForTest(key) }) - return c + } + return def, c +} + +// resolveDef pulls the canonical definition for a key out of a compiled +// snapshot — what the CLI entry point hands consume.Run as Options.Def. +func resolveDef(t *testing.T, snap *catalog.Snapshot, key string) *event.KeyDefinition { + t.Helper() + entry, ok := snap.Resolve(key) + if !ok { + t.Fatalf("compiled snapshot has no entry for %s", key) + } + return entry.Definition() } // startContractBus runs an in-process bus on a temp-dir socket with a mock // source (so no real upstream connection is attempted) and returns the fake -// transport plus the unique app id consumers must use. -func startContractBus(t *testing.T) (*testutil.FakeTransport, string) { +// transport plus the unique app id consumers must use. snap is the compiled +// catalog the bus serves. +func startContractBus(t *testing.T, snap *catalog.Snapshot) (*testutil.FakeTransport, string) { t.Helper() source.ResetForTest() source.Register(&mockIntegSource{}) @@ -99,7 +111,7 @@ func startContractBus(t *testing.T) (*testutil.FakeTransport, string) { addr := filepath.Join(sockDir, "s") tr := testutil.NewWrappedFake(transport.New(), addr) logger := log.New(os.Stderr, "[contract-bus] ", log.LstdFlags) - b := bus.NewBus(appID, "test-secret", "", tr, logger) + b := bus.NewBus(appID, "test-secret", "", tr, logger, snap) ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) runBus(t, b, ctx) @@ -117,14 +129,15 @@ type contractConsumer struct { done chan error } -func startContractConsumer(t *testing.T, tr transport.IPC, appID, key, name string) *contractConsumer { +func startContractConsumer(t *testing.T, tr transport.IPC, appID string, def *event.KeyDefinition, name string) *contractConsumer { t.Helper() ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) h := &contractConsumer{name: name, cancel: cancel, done: make(chan error, 1)} go func() { h.done <- consume.Run(ctx, tr, appID, "", "", consume.Options{ - EventKey: key, + EventKey: def.Key, + Def: def, Params: map[string]string{}, Quiet: true, Out: io.Discard, @@ -207,17 +220,19 @@ func waitForSubscriberCount(t *testing.T, tr transport.IPC, appID string, want i // depends on the server-side subscription. A then exits last and runs cleanup // exactly once. func TestPreConsumeContract_NonOwnerExitsFirst(t *testing.T) { - tr, appID := startContractBus(t) const key = "contract.nonowner.v1" - counters := registerContractKey(t, key, true) + keyDef, counters := contractKey(key, true) + snap := compileTestSnapshot(t, keyDef) + def := resolveDef(t, snap, key) + tr, appID := startContractBus(t, snap) - a := startContractConsumer(t, tr, appID, key, "A") + a := startContractConsumer(t, tr, appID, def, "A") waitForSubscriberCount(t, tr, appID, 1) waitForState(t, "consumer A pre-consume setup", func() bool { return counters.setup.Load() == 1 }) // A is registered and set up, so B deterministically joins as non-first // and never runs PreConsume (no duplicate server-side subscription). - b := startContractConsumer(t, tr, appID, key, "B") + b := startContractConsumer(t, tr, appID, def, "B") waitForSubscriberCount(t, tr, appID, 2) // B exits while A is still connected: not last for the key, no cleanup. @@ -253,15 +268,17 @@ func TestPreConsumeContract_NonOwnerExitsFirst(t *testing.T) { // every EventKey — take it through design review first, then update this // baseline deliberately. func TestPreConsumeContract_OwnerExitsFirst(t *testing.T) { - tr, appID := startContractBus(t) const key = "contract.owner.v1" - counters := registerContractKey(t, key, true) + keyDef, counters := contractKey(key, true) + snap := compileTestSnapshot(t, keyDef) + def := resolveDef(t, snap, key) + tr, appID := startContractBus(t, snap) - a := startContractConsumer(t, tr, appID, key, "A") + a := startContractConsumer(t, tr, appID, def, "A") waitForSubscriberCount(t, tr, appID, 1) waitForState(t, "consumer A pre-consume setup", func() bool { return counters.setup.Load() == 1 }) - b := startContractConsumer(t, tr, appID, key, "B") + b := startContractConsumer(t, tr, appID, def, "B") waitForSubscriberCount(t, tr, appID, 2) // Owner exits first: B is still connected, so A must NOT run cleanup — @@ -287,15 +304,17 @@ func TestPreConsumeContract_OwnerExitsFirst(t *testing.T) { // never unsubscribed. Regardless of exit order, cleanup count stays 0, and // setup re-runs each time a consumer becomes first for the key again. func TestPreConsumeContract_NoCleanupKey(t *testing.T) { - tr, appID := startContractBus(t) const key = "contract.nocleanup.v1" - counters := registerContractKey(t, key, false) + keyDef, counters := contractKey(key, false) + snap := compileTestSnapshot(t, keyDef) + def := resolveDef(t, snap, key) + tr, appID := startContractBus(t, snap) // Round 1: non-owner exits first. - a := startContractConsumer(t, tr, appID, key, "A") + a := startContractConsumer(t, tr, appID, def, "A") waitForSubscriberCount(t, tr, appID, 1) waitForState(t, "consumer A pre-consume setup", func() bool { return counters.setup.Load() == 1 }) - b := startContractConsumer(t, tr, appID, key, "B") + b := startContractConsumer(t, tr, appID, def, "B") waitForSubscriberCount(t, tr, appID, 2) b.stop(t) waitForSubscriberCount(t, tr, appID, 1) @@ -304,10 +323,10 @@ func TestPreConsumeContract_NoCleanupKey(t *testing.T) { // Round 2: owner exits first. The key's consumer count dropped to zero // above, so C is first for the key again and setup runs a second time. - c := startContractConsumer(t, tr, appID, key, "C") + c := startContractConsumer(t, tr, appID, def, "C") waitForSubscriberCount(t, tr, appID, 1) waitForState(t, "consumer C pre-consume setup", func() bool { return counters.setup.Load() == 2 }) - d := startContractConsumer(t, tr, appID, key, "D") + d := startContractConsumer(t, tr, appID, def, "D") waitForSubscriberCount(t, tr, appID, 2) c.stop(t) waitForSubscriberCount(t, tr, appID, 1) diff --git a/internal/event/registry_test.go b/internal/event/registry_test.go deleted file mode 100644 index 9de2780796..0000000000 --- a/internal/event/registry_test.go +++ /dev/null @@ -1,301 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package event - -import ( - "context" - "encoding/json" - "fmt" - "reflect" - "strings" - "testing" -) - -func mustPanic(t *testing.T, substring string) { - t.Helper() - r := recover() - if r == nil { - t.Fatal("expected panic, got none") - } - msg, _ := r.(string) - if msg == "" { - if err, ok := r.(error); ok { - msg = err.Error() - } else { - msg = fmt.Sprintf("%v", r) - } - } - if !strings.Contains(msg, substring) { - t.Errorf("panic %q does not contain %q", msg, substring) - } -} - -type emptyOut struct { - A string `json:"a"` -} - -func nativeSchema() SchemaDef { - return SchemaDef{Native: &SchemaSpec{Type: reflect.TypeOf(emptyOut{})}} -} - -func customSchema() SchemaDef { - return SchemaDef{Custom: &SchemaSpec{Type: reflect.TypeOf(emptyOut{})}} -} - -func customProcess() func(context.Context, APIClient, *RawEvent, map[string]string) (json.RawMessage, error) { - return func(context.Context, APIClient, *RawEvent, map[string]string) (json.RawMessage, error) { - return nil, nil - } -} - -func TestRegisterKey_NativeOnly(t *testing.T) { - resetRegistry() - RegisterKey(KeyDefinition{ - Key: "t.native", - EventType: "t.native", - Schema: nativeSchema(), - }) - def, ok := Lookup("t.native") - if !ok { - t.Fatal("Lookup failed") - } - if def.Schema.Native == nil { - t.Fatal("Native not stored") - } - if def.Process != nil { - t.Error("Process should be nil for Native") - } -} - -func TestRegisterKey_CustomWithProcess(t *testing.T) { - resetRegistry() - RegisterKey(KeyDefinition{ - Key: "t.custom", - EventType: "t.custom", - Schema: customSchema(), - Process: customProcess(), - }) - def, ok := Lookup("t.custom") - if !ok { - t.Fatal("Lookup failed") - } - if def.Schema.Custom == nil { - t.Fatal("Custom not stored") - } - if def.Process == nil { - t.Error("Process should be set") - } -} - -func TestRegisterKey_DuplicatePanics(t *testing.T) { - resetRegistry() - RegisterKey(KeyDefinition{Key: "t.dup", EventType: "t.dup", Schema: nativeSchema()}) - defer mustPanic(t, "duplicate EventKey") - RegisterKey(KeyDefinition{Key: "t.dup", EventType: "t.dup", Schema: nativeSchema()}) -} - -func TestRegisterKey_EmptyEventTypePanics(t *testing.T) { - resetRegistry() - defer mustPanic(t, "EventType must not be empty") - RegisterKey(KeyDefinition{Key: "t.no_type", Schema: nativeSchema()}) -} - -func TestRegisterKey_PanicsWhenBothSchemasSet(t *testing.T) { - resetRegistry() - defer mustPanic(t, "mutually exclusive") - RegisterKey(KeyDefinition{ - Key: "t.both", - EventType: "t.both", - Schema: SchemaDef{ - Native: &SchemaSpec{Type: reflect.TypeOf(emptyOut{})}, - Custom: &SchemaSpec{Type: reflect.TypeOf(emptyOut{})}, - }, - }) -} - -func TestRegisterKey_PanicsWhenNoSchemaSet(t *testing.T) { - resetRegistry() - defer mustPanic(t, "Schema requires either Native or Custom") - RegisterKey(KeyDefinition{Key: "t.empty", EventType: "t.empty"}) -} - -func TestRegisterKey_PanicsWhenNativeWithProcess(t *testing.T) { - resetRegistry() - defer mustPanic(t, "Schema.Native forbids Process") - RegisterKey(KeyDefinition{ - Key: "t.badcombo", - EventType: "t.badcombo", - Schema: nativeSchema(), - Process: customProcess(), - }) -} - -func TestRegisterKey_PanicsWhenSpecHasBothTypeAndRaw(t *testing.T) { - resetRegistry() - defer mustPanic(t, "requires exactly one of Type or Raw") - RegisterKey(KeyDefinition{ - Key: "t.bothsrc", - EventType: "t.bothsrc", - Schema: SchemaDef{ - Custom: &SchemaSpec{Type: reflect.TypeOf(emptyOut{}), Raw: json.RawMessage(`{}`)}, - }, - }) -} - -func TestRegisterKey_PanicsWhenSpecHasNeitherTypeNorRaw(t *testing.T) { - resetRegistry() - defer mustPanic(t, "requires exactly one of Type or Raw") - RegisterKey(KeyDefinition{ - Key: "t.nosrc", - EventType: "t.nosrc", - Schema: SchemaDef{ - Custom: &SchemaSpec{}, - }, - }) -} - -func TestRegisterKey_ParamMultiRequiresValues(t *testing.T) { - resetRegistry() - defer mustPanic(t, "requires Values") - RegisterKey(KeyDefinition{ - Key: "t.paramnovalues", - EventType: "t.paramnovalues", - Schema: nativeSchema(), - Params: []ParamDef{{Name: "fields", Type: ParamMulti}}, - }) -} - -func TestRegisterKey_ParamEnumRequiresValues(t *testing.T) { - resetRegistry() - defer mustPanic(t, "requires Values") - RegisterKey(KeyDefinition{ - Key: "t.enumnovalues", - EventType: "t.enumnovalues", - Schema: nativeSchema(), - Params: []ParamDef{{Name: "mode", Type: ParamEnum}}, - }) -} - -func TestRegisterKey_ParamValueRequiresDesc(t *testing.T) { - resetRegistry() - defer mustPanic(t, "requires non-empty Desc") - RegisterKey(KeyDefinition{ - Key: "t.paramdesc", - EventType: "t.paramdesc", - Schema: nativeSchema(), - Params: []ParamDef{{ - Name: "f", - Type: ParamEnum, - Values: []ParamValue{{Value: "x"}}, - }}, - }) -} - -func TestRegisterKey_UnknownParamType(t *testing.T) { - resetRegistry() - defer mustPanic(t, "unknown type") - RegisterKey(KeyDefinition{ - Key: "t.badtype", - EventType: "t.badtype", - Schema: nativeSchema(), - Params: []ParamDef{{Name: "x", Type: ParamType("wtf")}}, - }) -} - -func TestRegisterKey_InvalidAuthTypesPanics(t *testing.T) { - resetRegistry() - defer mustPanic(t, "AuthTypes elements must be") - RegisterKey(KeyDefinition{ - Key: "t.badauth", - EventType: "t.badauth", - Schema: nativeSchema(), - AuthTypes: []string{"invalid"}, - }) -} - -func TestRegisterKey_ValidAuthTypes(t *testing.T) { - resetRegistry() - RegisterKey(KeyDefinition{Key: "u.e", EventType: "u.e", Schema: nativeSchema(), AuthTypes: []string{"user"}}) - RegisterKey(KeyDefinition{Key: "b.e", EventType: "b.e", Schema: nativeSchema(), AuthTypes: []string{"bot"}}) - RegisterKey(KeyDefinition{Key: "ub.e", EventType: "ub.e", Schema: nativeSchema(), AuthTypes: []string{"bot", "user"}}) - RegisterKey(KeyDefinition{Key: "na.e", EventType: "na.e", Schema: nativeSchema()}) -} - -func TestListAll_SortedByKey(t *testing.T) { - resetRegistry() - RegisterKey(KeyDefinition{Key: "z.event", EventType: "z", Schema: nativeSchema()}) - RegisterKey(KeyDefinition{Key: "a.event", EventType: "a", Schema: nativeSchema()}) - RegisterKey(KeyDefinition{Key: "m.event", EventType: "m", Schema: nativeSchema()}) - all := ListAll() - if len(all) != 3 || all[0].Key != "a.event" || all[1].Key != "m.event" || all[2].Key != "z.event" { - t.Errorf("keys not sorted: %v", []string{all[0].Key, all[1].Key, all[2].Key}) - } -} - -func TestBufferSize_Clamped(t *testing.T) { - resetRegistry() - RegisterKey(KeyDefinition{ - Key: "big", EventType: "big", Schema: nativeSchema(), - BufferSize: 5000, - }) - def, _ := Lookup("big") - if def.BufferSize != MaxBufferSize { - t.Errorf("BufferSize = %d, want %d", def.BufferSize, MaxBufferSize) - } -} - -func TestRegisterKey_SubscriptionTypeDefaultsToEvent(t *testing.T) { - const key = "test.subtype.default" - RegisterKey(KeyDefinition{ - Key: key, - EventType: key, - Schema: SchemaDef{Native: &SchemaSpec{Raw: []byte(`{"type":"object"}`)}}, - }) - defer UnregisterKeyForTest(key) - - def, ok := Lookup(key) - if !ok { - t.Fatalf("Lookup(%q) failed", key) - } - if def.SubscriptionType != SubTypeEvent { - t.Errorf("SubscriptionType = %q, want %q", def.SubscriptionType, SubTypeEvent) - } - if def.SingleConsumer { - t.Errorf("SingleConsumer = true, want false (default)") - } -} - -func TestRegisterKey_SubscriptionTypeCallbackPreserved(t *testing.T) { - const key = "test.subtype.callback" - RegisterKey(KeyDefinition{ - Key: key, - EventType: key, - SubscriptionType: SubTypeCallback, - SingleConsumer: true, - Schema: SchemaDef{Native: &SchemaSpec{Raw: []byte(`{"type":"object"}`)}}, - }) - defer UnregisterKeyForTest(key) - - def, _ := Lookup(key) - if def.SubscriptionType != SubTypeCallback { - t.Errorf("SubscriptionType = %q, want %q", def.SubscriptionType, SubTypeCallback) - } - if !def.SingleConsumer { - t.Errorf("SingleConsumer = false, want true") - } -} - -func TestRegisterKey_InvalidSubscriptionTypePanics(t *testing.T) { - defer func() { - if r := recover(); r == nil { - t.Errorf("expected panic for invalid SubscriptionType") - } - }() - RegisterKey(KeyDefinition{ - Key: "test.subtype.bogus", - EventType: "test.subtype.bogus", - SubscriptionType: "bogus", - Schema: SchemaDef{Native: &SchemaSpec{Raw: []byte(`{"type":"object"}`)}}, - }) -} From 1a9ee4a4d9791a89179d8467a8a23e5669571d3f Mon Sep 17 00:00:00 2001 From: leave330 Date: Sat, 1 Aug 2026 00:17:53 +0800 Subject: [PATCH 17/61] feat(event): add a consume application service with an immutable decision --- .../event/application/consume/decision.go | 128 ++++++++++ internal/event/application/consume/service.go | 144 +++++++++++ .../event/application/consume/service_test.go | 240 ++++++++++++++++++ .../event/application/consume/strategy.go | 107 ++++++++ internal/event/catalog/params.go | 56 ++++ internal/event/catalog/scope.go | 40 +++ internal/event/consume/fingerprint.go | 35 +-- 7 files changed, 720 insertions(+), 30 deletions(-) create mode 100644 internal/event/application/consume/decision.go create mode 100644 internal/event/application/consume/service.go create mode 100644 internal/event/application/consume/service_test.go create mode 100644 internal/event/application/consume/strategy.go create mode 100644 internal/event/catalog/params.go create mode 100644 internal/event/catalog/scope.go diff --git a/internal/event/application/consume/decision.go b/internal/event/application/consume/decision.go new file mode 100644 index 0000000000..82780f0e81 --- /dev/null +++ b/internal/event/application/consume/decision.go @@ -0,0 +1,128 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package consume is the consume use case: it turns a request plus a compiled +// catalog entry into one immutable decision, renders that decision for +// dry-run, and executes the very same decision for a real run. Deciding is +// free of external writes; every write happens behind Execute. +package consume + +import ( + "maps" + "slices" + "time" +) + +// Request carries the caller's consume inputs, already parsed from flags. +type Request struct { + EventKey string + Params map[string]string + JQExpr string + OutputDir string + DryRun bool + MaxEvents int + Timeout time.Duration + IsTTY bool +} + +type PreconditionStatus string + +const ( + // PreconditionOK: the read-only check passed. + PreconditionOK PreconditionStatus = "ok" + // PreconditionUnknown: a weak dependency could not answer. Real execution + // proceeds (matching the long-standing degrade-and-continue behavior); + // dry-run reports the fact instead of pretending readiness. + PreconditionUnknown PreconditionStatus = "unknown" + // PreconditionBlocked: the check found a state that makes a real run + // refuse to start. Execution returns the blocking error; dry-run renders it. + PreconditionBlocked PreconditionStatus = "blocked" +) + +// Precondition is one read-only preflight finding. BlockErr carries the exact +// error a real run would return, so the refusal is identical whether or not a +// decision was rendered first. +type Precondition struct { + Name string + Status PreconditionStatus + Detail string + BlockErr error +} + +// Decision is the single-step consume decision: the classified result of one +// request against one compiled entry. Fields are unexported and deep-copied +// at construction; renderers read it through View. +type Decision struct { + eventKey string + domain string + identity string + status string + params map[string]string + scope string + preconditions []Precondition + preparation *PreparationDecision + wouldRead []string + wouldWrite []string + blockErr error +} + +const ( + StatusReady = "ready" + StatusUnknown = "unknown" + StatusBlocked = "blocked" +) + +// View returns a deep-copied, exported view of the decision — the only way +// renderers and other packages read it. Mutating the view never touches the +// decision. +func (d *Decision) View() DecisionView { + v := DecisionView{ + EventKey: d.eventKey, + Domain: d.domain, + Identity: d.identity, + Status: d.status, + Params: maps.Clone(d.params), + Scope: d.scope, + WouldRead: slices.Clone(d.wouldRead), + WouldWrite: slices.Clone(d.wouldWrite), + } + for _, p := range d.preconditions { + v.Preconditions = append(v.Preconditions, PreconditionView{ + Name: p.Name, Status: string(p.Status), Detail: p.Detail, + }) + } + if d.preparation != nil { + v.Preparation = &PreparationView{ + Strategy: string(d.preparation.Strategy), + Condition: d.preparation.Condition, + Action: d.preparation.Action, + } + } + return v +} + +// DecisionView is the exported render model of a Decision. +type DecisionView struct { + EventKey string + Domain string + Identity string + Status string + Params map[string]string + Scope string + Preconditions []PreconditionView + Preparation *PreparationView + WouldRead []string + WouldWrite []string +} + +type PreconditionView struct { + Name string + Status string + Detail string +} + +type PreparationView struct { + Strategy string + Condition string + Action string +} diff --git a/internal/event/application/consume/service.go b/internal/event/application/consume/service.go new file mode 100644 index 0000000000..10b0d3033f --- /dev/null +++ b/internal/event/application/consume/service.go @@ -0,0 +1,144 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package consume + +import ( + "context" + "maps" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/event/catalog" +) + +// IdentityResolver resolves the effective identity for a run and verifies its +// credentials are usable. Implementations live with the command wiring. +type IdentityResolver interface { + Resolve(ctx context.Context, entry *catalog.Entry) (string, error) +} + +// PreflightReader performs the read-only preflight checks and reports each as +// a precondition. It never mutates remote or local state. +type PreflightReader interface { + Read(ctx context.Context, entry *catalog.Entry, identity string) ([]Precondition, error) +} + +// PrepareFunc is what Execute hands the stream host: invoked exactly when the +// delivery handshake says this consumer is first for its scope. +type PrepareFunc = func(ctx context.Context) (Cleanup, error) + +// StreamRunner runs the delivery stream for an already-decided consume. The +// production implementation wraps the runtime host; tests substitute spies. +type StreamRunner interface { + Run(ctx context.Context, prepare PrepareFunc) error +} + +// Service orchestrates the consume use case in a fixed order: decide first, +// render or execute the same decision second. +type Service struct { + Strategies *Registry + Identity IdentityResolver + Preflight PreflightReader +} + +// Decide classifies one request against one compiled entry. It performs no +// external writes: parameter normalization works on a copy, and every remote +// interaction is a read-only preflight. +func (s *Service) Decide(ctx context.Context, entry *catalog.Entry, req Request, api ExecutionContext) (*Decision, error) { + def := entry.Definition() + + params := maps.Clone(req.Params) + if params == nil { + params = map[string]string{} + } + if err := catalog.ValidateParams(def, params); err != nil { + return nil, err + } + if normalize := entry.Binding().NormalizeParams; normalize != nil { + if err := normalize(ctx, api.API, params); err != nil { + if _, ok := errs.ProblemOf(err); ok { + return nil, err + } + return nil, errs.NewInternalError(errs.SubtypeUnknown, + "normalize params for %s: %s", def.Key, err).WithCause(err) + } + } + + identity, err := s.Identity.Resolve(ctx, entry) + if err != nil { + return nil, err + } + + preconditions, err := s.Preflight.Read(ctx, entry, identity) + if err != nil { + return nil, err + } + + strategyRef := entry.Capability().Preparation + strategy, err := s.Strategies.get(strategyRef) + if err != nil { + return nil, errs.NewInternalError(errs.SubtypeUnknown, "%s", err) + } + prep, err := strategy.Decide(ctx, PreparedConsume{Entry: entry, Params: params}) + if err != nil { + return nil, err + } + + d := &Decision{ + eventKey: def.Key, + domain: entry.Descriptor().Domain, + identity: identity, + params: params, + scope: catalog.SubscriptionScope(def, params), + preconditions: preconditions, + wouldRead: []string{"local_bus_probe", "app_metadata_preflight"}, + wouldWrite: []string{"start_or_reuse_local_bus", "register_consumer"}, + } + if strategyRef != catalog.StrategyNone { + d.preparation = &prep + d.wouldWrite = append(d.wouldWrite, "run_preparation_when_first") + } + d.wouldWrite = append(d.wouldWrite, "open_event_stream") + if req.OutputDir != "" { + d.wouldWrite = append(d.wouldWrite, "create_output_dir") + } + + d.status = StatusReady + for i := range preconditions { + switch preconditions[i].Status { + case PreconditionBlocked: + d.status = StatusBlocked + if d.blockErr == nil { + d.blockErr = preconditions[i].BlockErr + } + case PreconditionUnknown: + if d.status == StatusReady { + d.status = StatusUnknown + } + } + } + return d, nil +} + +// Execute runs the decision for real. A blocked decision returns the exact +// error its preflight produced; an unknown decision proceeds — weak +// dependencies degrade with a stderr note, they do not block, matching the +// behavior consumers have always had. +func (s *Service) Execute(ctx context.Context, entry *catalog.Entry, d *Decision, runner StreamRunner, ec ExecutionContext) error { + if d.status == StatusBlocked { + return d.blockErr + } + var prepare PrepareFunc + if ref := entry.Capability().Preparation; ref != catalog.StrategyNone { + strategy, err := s.Strategies.get(ref) + if err != nil { + return errs.NewInternalError(errs.SubtypeUnknown, "%s", err) + } + prep := *d.preparation + in := PreparedConsume{Entry: entry, Params: maps.Clone(d.params)} + prepare = func(ctx context.Context) (Cleanup, error) { + return strategy.Apply(ctx, prep, in, ec) + } + } + return runner.Run(ctx, prepare) +} diff --git a/internal/event/application/consume/service_test.go b/internal/event/application/consume/service_test.go new file mode 100644 index 0000000000..c152ed330e --- /dev/null +++ b/internal/event/application/consume/service_test.go @@ -0,0 +1,240 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package consume + +import ( + "context" + "encoding/json" + "errors" + "sync/atomic" + "testing" + + "github.com/larksuite/cli/events" + "github.com/larksuite/cli/internal/event/catalog" + "github.com/larksuite/cli/internal/event/processing" +) + +type spyAPIClient struct{ calls atomic.Int64 } + +func (s *spyAPIClient) CallAPI(context.Context, string, string, any) (json.RawMessage, error) { + s.calls.Add(1) + return nil, errors.New("no API access expected on this path") +} + +type spyRunner struct{ runs atomic.Int64 } + +func (s *spyRunner) Run(_ context.Context, prepare PrepareFunc) error { + s.runs.Add(1) + if prepare != nil { + if _, err := prepare(context.Background()); err != nil { + return err + } + } + return nil +} + +func fixedIdentity(id string) IdentityResolver { + return identityFunc(func(context.Context, *catalog.Entry) (string, error) { return id, nil }) +} + +type identityFunc func(ctx context.Context, entry *catalog.Entry) (string, error) + +func (f identityFunc) Resolve(ctx context.Context, entry *catalog.Entry) (string, error) { + return f(ctx, entry) +} + +type preflightFunc func(ctx context.Context, entry *catalog.Entry, identity string) ([]Precondition, error) + +func (f preflightFunc) Read(ctx context.Context, entry *catalog.Entry, identity string) ([]Precondition, error) { + return f(ctx, entry, identity) +} + +func okPreflight() PreflightReader { + return preflightFunc(func(context.Context, *catalog.Entry, string) ([]Precondition, error) { + return []Precondition{{Name: "console_event_published", Status: PreconditionOK}}, nil + }) +} + +func serviceForTest(pf PreflightReader) *Service { + return &Service{Strategies: DefaultRegistry(), Identity: fixedIdentity("user"), Preflight: pf} +} + +func realCatalog(t *testing.T) *catalog.Snapshot { + t.Helper() + snap, err := catalog.Compile(events.All(), DefaultRegistry()) + if err != nil { + t.Fatal(err) + } + return snap +} + +// requiredParamsFor fabricates a value for every required parameter so a +// decision can be made for any shipped key. +func requiredParamsFor(def *catalog.KeyDefinition) map[string]string { + params := map[string]string{} + for _, p := range def.Params { + if p.Required { + params[p.Name] = "decide-gate-value" + } + } + return params +} + +// Deciding is the dry-run: for every shipped key it must complete without a +// single API call, stream start, or preparation apply. The spies are proven +// live by the control test below, so an all-zero count means the decide path +// genuinely performs nothing. +func TestDecide_PerformsNoSideEffectForAnyKey(t *testing.T) { + snap := realCatalog(t) + api := &spyAPIClient{} + svc := serviceForTest(okPreflight()) + + decided := 0 + for _, entry := range snap.Entries() { + def := entry.Definition() + req := Request{EventKey: def.Key, Params: requiredParamsFor(def), DryRun: true, OutputDir: "events-out"} + d, err := svc.Decide(context.Background(), entry, req, ExecutionContext{API: api}) + if err != nil { + t.Fatalf("%s: decide failed: %v", def.Key, err) + } + decided++ + + v := d.View() + if v.Status != StatusReady { + t.Errorf("%s: want ready with all-ok preconditions, got %s", def.Key, v.Status) + } + if v.Domain == "" || v.Scope == "" { + t.Errorf("%s: view must resolve domain and scope, got %+v", def.Key, v) + } + wantPrep := entry.Capability().Preparation != catalog.StrategyNone + if (v.Preparation != nil) != wantPrep { + t.Errorf("%s: preparation view presence = %v, want %v", def.Key, v.Preparation != nil, wantPrep) + } + if last := v.WouldWrite[len(v.WouldWrite)-1]; last != "create_output_dir" { + t.Errorf("%s: an output dir was requested; would_write must state it, got %v", def.Key, v.WouldWrite) + } + } + if decided != snap.Len() || decided == 0 { + t.Fatalf("decided %d keys, want all %d; the gate scanned too little", decided, snap.Len()) + } + if got := api.calls.Load(); got != 0 { + t.Errorf("deciding made %d API call(s); the decide path must not touch the API", got) + } +} + +// Control group: the same spies must fire on a real execution — an all-zero +// dry-run count proves nothing if the spies were never wired to anything. +func TestExecute_SpiesBiteOnTheRealPath(t *testing.T) { + var setup atomic.Int64 + def := catalog.KeyDefinition{ + Key: "demo.spy.check_v1", + EventType: "demo.spy.check_v1", + Schema: catalog.SchemaDef{Custom: &catalog.SchemaSpec{Raw: json.RawMessage(`{"type":"object"}`)}}, + PreConsume: func(ctx context.Context, rt processing.APIClient, params map[string]string) (func() error, error) { + setup.Add(1) + return nil, nil + }, + } + snap, err := catalog.Compile([]catalog.KeyDefinition{def}, DefaultRegistry()) + if err != nil { + t.Fatal(err) + } + entry, _ := snap.Resolve(def.Key) + + svc := serviceForTest(okPreflight()) + d, err := svc.Decide(context.Background(), entry, Request{EventKey: def.Key}, ExecutionContext{API: &spyAPIClient{}}) + if err != nil { + t.Fatal(err) + } + if setup.Load() != 0 { + t.Fatal("deciding ran the preparation hook; decide must stay side-effect free") + } + + runner := &spyRunner{} + if err := svc.Execute(context.Background(), entry, d, runner, ExecutionContext{API: &spyAPIClient{}}); err != nil { + t.Fatalf("execute: %v", err) + } + if runner.runs.Load() != 1 { + t.Errorf("the stream runner must run exactly once, got %d", runner.runs.Load()) + } + if setup.Load() != 1 { + t.Errorf("the preparation hook must fire on the real path, got %d", setup.Load()) + } +} + +// A blocked decision refuses execution with the exact error its preflight +// produced — identical to what a direct run would have returned. +func TestExecute_BlockedReturnsThePreflightError(t *testing.T) { + blockErr := errors.New("console switch is off") + pf := preflightFunc(func(context.Context, *catalog.Entry, string) ([]Precondition, error) { + return []Precondition{{Name: "console_event_published", Status: PreconditionBlocked, Detail: blockErr.Error(), BlockErr: blockErr}}, nil + }) + svc := serviceForTest(pf) + snap := realCatalog(t) + entry, _ := snap.Resolve("im.message.receive_v1") + + d, err := svc.Decide(context.Background(), entry, Request{EventKey: "im.message.receive_v1"}, ExecutionContext{API: &spyAPIClient{}}) + if err != nil { + t.Fatal(err) + } + if d.View().Status != StatusBlocked { + t.Fatalf("want blocked status, got %s", d.View().Status) + } + runner := &spyRunner{} + if got := svc.Execute(context.Background(), entry, d, runner, ExecutionContext{API: &spyAPIClient{}}); !errors.Is(got, blockErr) { + t.Errorf("execute must return the preflight's own error, got %v", got) + } + if runner.runs.Load() != 0 { + t.Error("a blocked decision must never reach the stream runner") + } +} + +// Weak dependencies degrade, they do not block: unknown preconditions render +// as unknown but a real run still proceeds. +func TestExecute_UnknownProceeds(t *testing.T) { + pf := preflightFunc(func(context.Context, *catalog.Entry, string) ([]Precondition, error) { + return []Precondition{{Name: "console_event_published", Status: PreconditionUnknown, Detail: "ledger unavailable"}}, nil + }) + svc := serviceForTest(pf) + snap := realCatalog(t) + entry, _ := snap.Resolve("im.message.receive_v1") + + d, err := svc.Decide(context.Background(), entry, Request{EventKey: "im.message.receive_v1"}, ExecutionContext{API: &spyAPIClient{}}) + if err != nil { + t.Fatal(err) + } + if d.View().Status != StatusUnknown { + t.Fatalf("want unknown status, got %s", d.View().Status) + } + runner := &spyRunner{} + if err := svc.Execute(context.Background(), entry, d, runner, ExecutionContext{API: &spyAPIClient{}}); err != nil { + t.Fatalf("unknown must not block execution: %v", err) + } + if runner.runs.Load() != 1 { + t.Error("execution must proceed under unknown preconditions") + } +} + +// Mutating a view must never reach the decision it came from. +func TestDecisionView_IsACopy(t *testing.T) { + svc := serviceForTest(okPreflight()) + snap := realCatalog(t) + entry, _ := snap.Resolve("board.whiteboard.updated_v1") + + d, err := svc.Decide(context.Background(), entry, + Request{EventKey: "board.whiteboard.updated_v1", Params: map[string]string{"whiteboard_id": "wb-1"}}, + ExecutionContext{API: &spyAPIClient{}}) + if err != nil { + t.Fatal(err) + } + v := d.View() + v.Params["whiteboard_id"] = "tampered" + v.WouldWrite[0] = "tampered" + v.Preconditions[0].Status = "tampered" + + fresh := d.View() + if fresh.Params["whiteboard_id"] == "tampered" || fresh.WouldWrite[0] == "tampered" || fresh.Preconditions[0].Status == "tampered" { + t.Error("mutating a view leaked into the decision") + } +} diff --git a/internal/event/application/consume/strategy.go b/internal/event/application/consume/strategy.go new file mode 100644 index 0000000000..bbc583c0ab --- /dev/null +++ b/internal/event/application/consume/strategy.go @@ -0,0 +1,107 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package consume + +import ( + "context" + "fmt" + "maps" + + "github.com/larksuite/cli/internal/event/catalog" + "github.com/larksuite/cli/internal/event/processing" +) + +// PreparedConsume is the classified input a strategy decides and applies for. +type PreparedConsume struct { + Entry *catalog.Entry + Params map[string]string +} + +// PreparationDecision is the serializable preview of what preparation would +// do. It is conditional by design: whether it actually runs is decided by the +// delivery handshake (first consumer for the scope), never at decide time. +type PreparationDecision struct { + Strategy catalog.StrategyRef + Condition string + Action string +} + +// Cleanup undoes a strategy's Apply; the runtime host invokes it when this +// consumer is the last one for its scope. +type Cleanup = func() error + +// ExecutionContext carries the per-request dependencies a strategy may use +// during Apply. Strategies hold no clients of their own: the caller resolves +// identity first and injects exactly one API surface for this run. +type ExecutionContext struct { + API processing.APIClient +} + +// PreparationStrategy separates deciding what preparation would do (no +// external writes) from doing it (the only write entry point). +type PreparationStrategy interface { + Decide(ctx context.Context, in PreparedConsume) (PreparationDecision, error) + Apply(ctx context.Context, d PreparationDecision, in PreparedConsume, ec ExecutionContext) (Cleanup, error) +} + +// Registry holds the executable strategies and doubles as the catalog's +// StrategySet, so the compiler validates references against exactly the set +// that will execute. +type Registry struct { + strategies map[catalog.StrategyRef]PreparationStrategy +} + +func (r *Registry) Has(ref catalog.StrategyRef) bool { + _, ok := r.strategies[ref] + return ok +} + +func (r *Registry) get(ref catalog.StrategyRef) (PreparationStrategy, error) { + s, ok := r.strategies[ref] + if !ok { + return nil, fmt.Errorf("preparation strategy %q is not registered", ref) + } + return s, nil +} + +// DefaultRegistry returns the strategies this build ships: no preparation, +// and the wrapper over a declaration's PreConsume hook. +func DefaultRegistry() *Registry { + return &Registry{strategies: map[catalog.StrategyRef]PreparationStrategy{ + catalog.StrategyNone: noneStrategy{}, + catalog.StrategyLegacyPreConsume: legacyPreConsumeStrategy{}, + }} +} + +// noneStrategy: the key needs nothing before consuming. +type noneStrategy struct{} + +func (noneStrategy) Decide(context.Context, PreparedConsume) (PreparationDecision, error) { + return PreparationDecision{Strategy: catalog.StrategyNone}, nil +} + +func (noneStrategy) Apply(context.Context, PreparationDecision, PreparedConsume, ExecutionContext) (Cleanup, error) { + return nil, nil +} + +// legacyPreConsumeStrategy wraps a declaration's PreConsume hook. Decide never +// invokes the hook — it only states the conditional action — so a decision +// (and therefore a dry-run) provably performs none of the hook's writes. +type legacyPreConsumeStrategy struct{} + +func (legacyPreConsumeStrategy) Decide(_ context.Context, in PreparedConsume) (PreparationDecision, error) { + return PreparationDecision{ + Strategy: catalog.StrategyLegacyPreConsume, + Condition: "first_consumer_for_scope", + Action: "register_event_delivery", + }, nil +} + +func (legacyPreConsumeStrategy) Apply(ctx context.Context, _ PreparationDecision, in PreparedConsume, ec ExecutionContext) (Cleanup, error) { + hook := in.Entry.Binding().PreConsume + if hook == nil { + return nil, nil + } + return hook(ctx, ec.API, maps.Clone(in.Params)) +} diff --git a/internal/event/catalog/params.go b/internal/event/catalog/params.go new file mode 100644 index 0000000000..01b76eb697 --- /dev/null +++ b/internal/event/catalog/params.go @@ -0,0 +1,56 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package catalog + +import ( + "sort" + "strings" + + "github.com/larksuite/cli/errs" +) + +// ValidateParams applies declared defaults into params, then rejects missing +// required and undeclared parameters. It is the single implementation every +// layer validates against, so a decision can never accept parameters the +// runtime host would refuse. +func ValidateParams(def *KeyDefinition, params map[string]string) error { + for _, p := range def.Params { + if _, ok := params[p.Name]; !ok && p.Default != "" { + params[p.Name] = p.Default + } + } + for _, p := range def.Params { + if p.Required { + if _, ok := params[p.Name]; !ok { + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "required param %q missing for EventKey %s", p.Name, def.Key). + WithParam("--param"). + WithHint("pass it as --param %s=; run `lark-cli event schema %s` for details", p.Name, def.Key) + } + } + } + known := make(map[string]bool, len(def.Params)) + validNames := make([]string, 0, len(def.Params)) + for _, p := range def.Params { + known[p.Name] = true + validNames = append(validNames, p.Name) + } + sort.Strings(validNames) + for k := range params { + if known[k] { + continue + } + if len(validNames) == 0 { + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "unknown param %q: EventKey %s accepts no params", k, def.Key). + WithParam("--param"). + WithHint("run `lark-cli event schema %s` for details", def.Key) + } + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "unknown param %q for EventKey %s. valid params: %s", k, def.Key, strings.Join(validNames, ", ")). + WithParam("--param"). + WithHint("run `lark-cli event schema %s` for details", def.Key) + } + return nil +} diff --git a/internal/event/catalog/scope.go b/internal/event/catalog/scope.go new file mode 100644 index 0000000000..c1f9faa2d4 --- /dev/null +++ b/internal/event/catalog/scope.go @@ -0,0 +1,40 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package catalog + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/json" + "sort" +) + +// SubscriptionScope returns a stable identifier scoped to (EventKey, values +// of the ParamDefs marked SubscriptionKey); the framework uses it to dedup +// preparation/cleanup gates and key per-subscription accounting. No +// SubscriptionKey params -> returns def.Key verbatim (legacy one-dimensional +// behavior). +// +// Stability contract: same EventKey + same normalized param values -> same ID +// across CLI versions; changing the encoding requires a wire-format bump. +func SubscriptionScope(def *KeyDefinition, params map[string]string) string { + type kv struct { + Name string `json:"name"` + Value string `json:"value"` + } + var subParams []kv + for _, p := range def.Params { + if !p.SubscriptionKey { + continue + } + subParams = append(subParams, kv{Name: p.Name, Value: params[p.Name]}) + } + if len(subParams) == 0 { + return def.Key + } + sort.Slice(subParams, func(i, j int) bool { return subParams[i].Name < subParams[j].Name }) + raw, _ := json.Marshal(subParams) // err impossible: kv has no unmarshalable fields + sum := sha256.Sum256(raw) + return def.Key + ":" + base64.RawURLEncoding.EncodeToString(sum[:12]) +} diff --git a/internal/event/consume/fingerprint.go b/internal/event/consume/fingerprint.go index a452f1aadb..5bd546846a 100644 --- a/internal/event/consume/fingerprint.go +++ b/internal/event/consume/fingerprint.go @@ -4,38 +4,13 @@ package consume import ( - "crypto/sha256" - "encoding/base64" - "encoding/json" - "sort" - "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/catalog" ) -// ComputeSubscriptionID returns a stable identifier scoped to (EventKey, values -// of the ParamDefs marked SubscriptionKey); the framework uses it to dedup -// PreConsume/cleanup gates and key Hub counts per-subscription. No SubscriptionKey -// params -> returns def.Key verbatim (legacy one-dimensional behavior). -// -// Stability contract: same EventKey + same normalized param values -> same ID -// across CLI versions; changing the encoding requires a wire-format bump. +// ComputeSubscriptionID delegates to the catalog's scope derivation so every +// layer (application decision, bus accounting, this host) computes the same +// identity from the same declaration. func ComputeSubscriptionID(def *event.KeyDefinition, params map[string]string) string { - type kv struct { - Name string `json:"name"` - Value string `json:"value"` - } - var subParams []kv - for _, p := range def.Params { - if !p.SubscriptionKey { - continue - } - subParams = append(subParams, kv{Name: p.Name, Value: params[p.Name]}) - } - if len(subParams) == 0 { - return def.Key - } - sort.Slice(subParams, func(i, j int) bool { return subParams[i].Name < subParams[j].Name }) - raw, _ := json.Marshal(subParams) // err impossible: kv has no unmarshalable fields - sum := sha256.Sum256(raw) - return def.Key + ":" + base64.RawURLEncoding.EncodeToString(sum[:12]) + return catalog.SubscriptionScope(def, params) } From cfa862f8d08e25e26bf020737ba72628242df9cb Mon Sep 17 00:00:00 2001 From: leave330 Date: Sat, 1 Aug 2026 00:17:54 +0800 Subject: [PATCH 18/61] refactor(event): run consume preparation behind an injectable application seam The stream host keeps its connect/prepare/stream sequence and panic-safe cleanup exactly as before; what changed is who supplies the preparation: the application layer injects the decided strategy, and the declaration's own hook remains the fallback for direct library callers. --- internal/event/consume/consume.go | 70 ++++++++++--------------------- 1 file changed, 22 insertions(+), 48 deletions(-) diff --git a/internal/event/consume/consume.go b/internal/event/consume/consume.go index 371d03ee56..dbc6ef4f0f 100644 --- a/internal/event/consume/consume.go +++ b/internal/event/consume/consume.go @@ -10,13 +10,12 @@ import ( "io" "os" "slices" - "sort" - "strings" "sync/atomic" "time" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/catalog" "github.com/larksuite/cli/internal/event/protocol" "github.com/larksuite/cli/internal/event/transport" ) @@ -26,12 +25,17 @@ type Options struct { // Def is the resolved declaration for EventKey. The caller resolves it // from the compiled catalog; this package validates and consumes it but // never looks anything up itself. - Def *event.KeyDefinition - Params map[string]string - JQExpr string - Quiet bool - OutputDir string - Runtime event.APIClient + Def *event.KeyDefinition + Params map[string]string + JQExpr string + Quiet bool + OutputDir string + Runtime event.APIClient + // Prepare, when set, replaces the declaration's PreConsume hook as the + // preparation to run when this consumer is first for its scope. The + // application layer injects it so the strategy that was decided is the + // strategy that executes; when nil, the declaration's own hook runs. + Prepare func(ctx context.Context) (func() error, error) Out io.Writer // nil falls back to os.Stdout ErrOut io.Writer RemoteAPIClient APIClient // nil disables remote-connection preflight @@ -119,12 +123,19 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin return capErr } + prepare := opts.Prepare + if prepare == nil && keyDef.PreConsume != nil { + prepare = func(ctx context.Context) (func() error, error) { + return keyDef.PreConsume(ctx, opts.Runtime, opts.Params) + } + } + var cleanup func() error - if ack.FirstForKey && keyDef.PreConsume != nil { + if ack.FirstForKey && prepare != nil { if !opts.Quiet { fmt.Fprintf(errOut, "[event] running pre-consume setup...\n") } - cleanup, err = keyDef.PreConsume(ctx, opts.Runtime, opts.Params) + cleanup, err = prepare(ctx) if err != nil { if _, ok := errs.ProblemOf(err); ok { return err @@ -216,44 +227,7 @@ func truncateDuration(d time.Duration) time.Duration { } func validateParams(def *event.KeyDefinition, params map[string]string) error { - for _, p := range def.Params { - if _, ok := params[p.Name]; !ok && p.Default != "" { - params[p.Name] = p.Default - } - } - for _, p := range def.Params { - if p.Required { - if _, ok := params[p.Name]; !ok { - return errs.NewValidationError(errs.SubtypeInvalidArgument, - "required param %q missing for EventKey %s", p.Name, def.Key). - WithParam("--param"). - WithHint("pass it as --param %s=; run `lark-cli event schema %s` for details", p.Name, def.Key) - } - } - } - known := make(map[string]bool, len(def.Params)) - validNames := make([]string, 0, len(def.Params)) - for _, p := range def.Params { - known[p.Name] = true - validNames = append(validNames, p.Name) - } - sort.Strings(validNames) - for k := range params { - if known[k] { - continue - } - if len(validNames) == 0 { - return errs.NewValidationError(errs.SubtypeInvalidArgument, - "unknown param %q: EventKey %s accepts no params", k, def.Key). - WithParam("--param"). - WithHint("run `lark-cli event schema %s` for details", def.Key) - } - return errs.NewValidationError(errs.SubtypeInvalidArgument, - "unknown param %q for EventKey %s. valid params: %s", k, def.Key, strings.Join(validNames, ", ")). - WithParam("--param"). - WithHint("run `lark-cli event schema %s` for details", def.Key) - } - return nil + return catalog.ValidateParams(def, params) } func checkMaxEvents(opts Options, emitted *atomic.Int64) bool { From 22c54f119b63912834fad609c57aa2408c2cc36d Mon Sep 17 00:00:00 2001 From: leave330 Date: Sat, 1 Aug 2026 00:17:54 +0800 Subject: [PATCH 19/61] feat(event): add event consume --dry-run with a provable no-side-effect preview --- cmd/event/consume.go | 67 +++++++++----- cmd/event/render/decision.go | 144 ++++++++++++++++++++++++++++++ cmd/event/render/decision_test.go | 120 +++++++++++++++++++++++++ cmd/event/service_adapters.go | 78 ++++++++++++++++ cmd/event/wiring.go | 7 +- 5 files changed, 391 insertions(+), 25 deletions(-) create mode 100644 cmd/event/render/decision.go create mode 100644 cmd/event/render/decision_test.go create mode 100644 cmd/event/service_adapters.go diff --git a/cmd/event/consume.go b/cmd/event/consume.go index f64c335832..45057bc40c 100644 --- a/cmd/event/consume.go +++ b/cmd/event/consume.go @@ -16,6 +16,7 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/cmd/event/render" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/appmeta" "github.com/larksuite/cli/internal/auth" @@ -23,6 +24,7 @@ import ( "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/credential" eventlib "github.com/larksuite/cli/internal/event" + appconsume "github.com/larksuite/cli/internal/event/application/consume" "github.com/larksuite/cli/internal/event/catalog" "github.com/larksuite/cli/internal/event/consume" "github.com/larksuite/cli/internal/event/transport" @@ -38,6 +40,7 @@ type consumeCmdOpts struct { maxEvents int timeout time.Duration + dryRun bool } func NewCmdConsume(f *cmdutil.Factory, snap *catalog.Snapshot) *cobra.Command { @@ -67,6 +70,7 @@ Use 'event schema ' for parameter details.`, cmd.Flags().BoolVar(&o.quiet, "quiet", false, "Suppress informational messages on stderr") cmd.Flags().StringVar(&o.outputDir, "output-dir", "", "Write each event as a file in this directory (relative paths only; absolute paths and ~ are rejected to prevent path traversal)") cmd.Flags().IntVar(&o.maxEvents, "max-events", 0, "Exit after N successful emits (0 = unlimited). Multi-worker EventKeys may emit up to workers-1 past N before all workers stop. Bounded runs ignore stdin EOF.") + cmd.Flags().BoolVar(&o.dryRun, "dry-run", false, "Decide and preview the consume (identity, preconditions, side effects) without performing any of them, then exit") cmd.Flags().DurationVar(&o.timeout, "timeout", 0, "Exit after DURATION (e.g. 30s, 2m). 0 = no timeout. Timeout is a normal exit (code 0; stderr 'reason: timeout'). Bounded runs ignore stdin EOF.") cmd.Flags().String("as", "auto", "identity type: user | bot | auto (must match EventKey's declared AuthTypes)") _ = cmd.RegisterFlagCompletionFunc("as", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { @@ -171,11 +175,31 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, snap *catalog.Snapshot, appVer: appVer, subscribedCallbacks: subscribedCallbacks, } - if err := preflightEventTypes(pf); err != nil { + + svc := &appconsume.Service{ + Strategies: consumeStrategies, + Identity: identityResolverFunc(func(context.Context, *catalog.Entry) (string, error) { return string(identity), nil }), + Preflight: preflightReaderFunc(func(ctx context.Context, _ *catalog.Entry, _ string) ([]appconsume.Precondition, error) { + return readPreconditions(ctx, pf, appVerErr), nil + }), + } + req := appconsume.Request{ + EventKey: eventKey, + Params: paramMap, + JQExpr: o.jqExpr, + OutputDir: outputDir, + DryRun: o.dryRun, + MaxEvents: o.maxEvents, + Timeout: o.timeout, + IsTTY: f.IOStreams.IsTerminal, + } + decision, err := svc.Decide(cmd.Context(), entry, req, appconsume.ExecutionContext{API: runtime}) + if err != nil { return err } - if err := preflightScopes(cmd.Context(), pf); err != nil { - return err + + if o.dryRun { + return render.WriteDecisionJSON(f.IOStreams.Out, f.IOStreams.ErrOut, string(identity), decision.View()) } ctx, cancel := context.WithCancel(cmd.Context()) @@ -206,24 +230,25 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, snap *catalog.Snapshot, watchStdinEOF(os.Stdin, cancel, errOut) } - if err := consume.Run(ctx, transport.New(), cfg.AppID, cfg.ProfileName, domain, consume.Options{ - EventKey: eventKey, - Def: keyDef, - Params: paramMap, - JQExpr: o.jqExpr, - Quiet: o.quiet, - OutputDir: outputDir, - Runtime: runtime, - Out: f.IOStreams.Out, - ErrOut: errOut, - RemoteAPIClient: botRuntime, - MaxEvents: o.maxEvents, - Timeout: o.timeout, - IsTTY: f.IOStreams.IsTerminal, - }); err != nil { - return err - } - return nil + runner := streamRunnerFunc(func(ctx context.Context, prepare appconsume.PrepareFunc) error { + return consume.Run(ctx, transport.New(), cfg.AppID, cfg.ProfileName, domain, consume.Options{ + EventKey: eventKey, + Def: keyDef, + Params: paramMap, + JQExpr: o.jqExpr, + Quiet: o.quiet, + OutputDir: outputDir, + Runtime: runtime, + Out: f.IOStreams.Out, + ErrOut: errOut, + RemoteAPIClient: botRuntime, + MaxEvents: o.maxEvents, + Timeout: o.timeout, + IsTTY: f.IOStreams.IsTerminal, + Prepare: prepare, + }) + }) + return svc.Execute(ctx, entry, decision, runner, appconsume.ExecutionContext{API: runtime}) } // resolveIdentity resolves the session identity and enforces keyDef.AuthTypes as a whitelist. diff --git a/cmd/event/render/decision.go b/cmd/event/render/decision.go new file mode 100644 index 0000000000..eaea760255 --- /dev/null +++ b/cmd/event/render/decision.go @@ -0,0 +1,144 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package render turns consume decisions into user-facing output. It is the +// only place a decision becomes text or JSON; the application layer never +// formats anything itself. +package render + +import ( + "fmt" + "io" + "regexp" + "sort" + + appconsume "github.com/larksuite/cli/internal/event/application/consume" + "github.com/larksuite/cli/internal/output" +) + +// sensitiveParamName matches parameter names whose values must never be +// echoed back in a rendered decision. Names are matched, not values: a +// credential-bearing parameter is identifiable by its declaration, and +// guessing at value shapes would miss more than it catches. +var sensitiveParamName = regexp.MustCompile(`(?i)(token|secret|password|credential|cookie)`) + +func redactParams(params map[string]string) map[string]string { + out := make(map[string]string, len(params)) + for name, value := range params { + if sensitiveParamName.MatchString(name) { + out[name] = "[redacted]" + continue + } + out[name] = value + } + return out +} + +// decisionPayload is the JSON shape under data.decision — snake_case, stable, +// documented in the event skill. Field additions must be additive. +type decisionPayload struct { + EventKey string `json:"event_key"` + Domain string `json:"domain"` + Identity string `json:"identity"` + Status string `json:"status"` + Params map[string]string `json:"params"` + Scope string `json:"scope"` + Preconditions []preconditionView `json:"preconditions"` + Preparation *preparationView `json:"preparation,omitempty"` + WouldRead []string `json:"would_read"` + WouldWrite []string `json:"would_write"` +} + +type preconditionView struct { + Name string `json:"name"` + Status string `json:"status"` + Detail string `json:"detail,omitempty"` +} + +type preparationView struct { + Strategy string `json:"strategy"` + Condition string `json:"condition"` + Action string `json:"action"` +} + +// WriteDecisionJSON emits the decision inside the standard success envelope +// with the envelope's own top-level dry_run marker set. +func WriteDecisionJSON(out, errOut io.Writer, identity string, v appconsume.DecisionView) error { + return output.WriteSuccessEnvelope(map[string]any{ + "decision": toPayload(v), + }, output.SuccessEnvelopeOptions{ + CommandPath: "event consume", + Identity: identity, + DryRun: true, + Out: out, + ErrOut: errOut, + }) +} + +// WriteDecisionText renders the same decision for humans. +func WriteDecisionText(out io.Writer, v appconsume.DecisionView) { + fmt.Fprintf(out, "Dry run — no side effects were performed.\n\n") + fmt.Fprintf(out, "EventKey: %s\n", v.EventKey) + fmt.Fprintf(out, "Domain: %s\n", v.Domain) + fmt.Fprintf(out, "Identity: %s\n", v.Identity) + fmt.Fprintf(out, "Status: %s\n", v.Status) + fmt.Fprintf(out, "Scope: %s\n", v.Scope) + if len(v.Params) > 0 { + params := redactParams(v.Params) + fmt.Fprintf(out, "Params:\n") + names := make([]string, 0, len(params)) + for name := range params { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + fmt.Fprintf(out, " %s = %s\n", name, params[name]) + } + } + if len(v.Preconditions) > 0 { + fmt.Fprintf(out, "Preconditions:\n") + for _, p := range v.Preconditions { + if p.Detail != "" { + fmt.Fprintf(out, " %-28s %s (%s)\n", p.Name, p.Status, p.Detail) + } else { + fmt.Fprintf(out, " %-28s %s\n", p.Name, p.Status) + } + } + } + if v.Preparation != nil { + fmt.Fprintf(out, "Preparation:\n") + fmt.Fprintf(out, " strategy: %s\n", v.Preparation.Strategy) + fmt.Fprintf(out, " condition: %s\n", v.Preparation.Condition) + fmt.Fprintf(out, " action: %s\n", v.Preparation.Action) + } + fmt.Fprintf(out, "Would read:\n") + for _, r := range v.WouldRead { + fmt.Fprintf(out, " - %s\n", r) + } + fmt.Fprintf(out, "Would write (on a real run):\n") + for _, w := range v.WouldWrite { + fmt.Fprintf(out, " - %s\n", w) + } +} + +func toPayload(v appconsume.DecisionView) decisionPayload { + p := decisionPayload{ + EventKey: v.EventKey, + Domain: v.Domain, + Identity: v.Identity, + Status: v.Status, + Params: redactParams(v.Params), + Scope: v.Scope, + WouldRead: v.WouldRead, + WouldWrite: v.WouldWrite, + } + p.Preconditions = make([]preconditionView, 0, len(v.Preconditions)) + for _, pc := range v.Preconditions { + p.Preconditions = append(p.Preconditions, preconditionView(pc)) + } + if v.Preparation != nil { + pv := preparationView(*v.Preparation) + p.Preparation = &pv + } + return p +} diff --git a/cmd/event/render/decision_test.go b/cmd/event/render/decision_test.go new file mode 100644 index 0000000000..d225d93004 --- /dev/null +++ b/cmd/event/render/decision_test.go @@ -0,0 +1,120 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package render + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + appconsume "github.com/larksuite/cli/internal/event/application/consume" +) + +func sampleView() appconsume.DecisionView { + return appconsume.DecisionView{ + EventKey: "vc.note.generated_v1", + Domain: "vc", + Identity: "user", + Status: "ready", + Params: map[string]string{"whiteboard_id": "wb-1", "access_token": "sk-SENSITIVE-VALUE"}, + Scope: "vc.note.generated_v1", + Preconditions: []appconsume.PreconditionView{ + {Name: "console_event_published", Status: "ok"}, + {Name: "scopes_granted", Status: "ok"}, + }, + Preparation: &appconsume.PreparationView{ + Strategy: "legacy_preconsume", Condition: "first_consumer_for_scope", Action: "register_event_delivery", + }, + WouldRead: []string{"local_bus_probe", "app_metadata_preflight"}, + WouldWrite: []string{"start_or_reuse_local_bus", "register_consumer", "run_preparation_when_first", "open_event_stream"}, + } +} + +// The JSON contract: dry_run is the envelope's own top-level marker (never a +// data field), and the decision sits under data.decision with its documented +// members. +func TestWriteDecisionJSON_EnvelopeContract(t *testing.T) { + var out, errOut bytes.Buffer + if err := WriteDecisionJSON(&out, &errOut, "user", sampleView()); err != nil { + t.Fatal(err) + } + + var envelope map[string]json.RawMessage + if err := json.Unmarshal(out.Bytes(), &envelope); err != nil { + t.Fatalf("stdout is not a JSON envelope: %v\n%s", err, out.String()) + } + if string(envelope["ok"]) != "true" || string(envelope["dry_run"]) != "true" { + t.Errorf("envelope must carry top-level ok=true and dry_run=true, got %s", out.String()) + } + if _, misplaced := envelope["decision"]; misplaced { + t.Error("decision must live under data, not at the envelope top level") + } + + var data struct { + Decision struct { + EventKey string `json:"event_key"` + Domain string `json:"domain"` + Identity string `json:"identity"` + Status string `json:"status"` + Params map[string]string `json:"params"` + Scope string `json:"scope"` + Preparation *struct { + Strategy string `json:"strategy"` + Condition string `json:"condition"` + Action string `json:"action"` + } `json:"preparation"` + WouldRead []string `json:"would_read"` + WouldWrite []string `json:"would_write"` + DryRun *bool `json:"dry_run"` + } `json:"decision"` + } + if err := json.Unmarshal(envelope["data"], &data); err != nil { + t.Fatalf("data.decision does not match the documented shape: %v", err) + } + d := data.Decision + if d.EventKey != "vc.note.generated_v1" || d.Domain != "vc" || d.Identity != "user" || d.Status != "ready" { + t.Errorf("identity facts drifted: %+v", d) + } + if d.Preparation == nil || d.Preparation.Condition != "first_consumer_for_scope" { + t.Errorf("conditional preparation must be stated: %+v", d.Preparation) + } + if len(d.WouldRead) == 0 || len(d.WouldWrite) == 0 { + t.Error("would_read / would_write must be present") + } + if d.DryRun != nil { + t.Error("dry_run inside data.decision would duplicate the envelope marker") + } +} + +// Sensitive parameter values never reach the rendered output, JSON or text. +// The control assertion first proves the sentinel would be visible if leaked. +func TestWriteDecision_RedactsSensitiveParams(t *testing.T) { + const sentinel = "sk-SENSITIVE-VALUE" + view := sampleView() + if !strings.Contains(view.Params["access_token"], sentinel) { + t.Fatal("control failed: the sentinel is not in the input, the test cannot prove redaction") + } + + var jsonOut, jsonErr bytes.Buffer + if err := WriteDecisionJSON(&jsonOut, &jsonErr, "user", view); err != nil { + t.Fatal(err) + } + if strings.Contains(jsonOut.String(), sentinel) { + t.Errorf("JSON output leaks a sensitive param value: %s", jsonOut.String()) + } + compact := strings.ReplaceAll(strings.ReplaceAll(jsonOut.String(), "\n", ""), " ", "") + if !strings.Contains(compact, `"access_token":"[redacted]"`) { + t.Errorf("sensitive param must render as redacted, got: %s", jsonOut.String()) + } + if !strings.Contains(compact, `"whiteboard_id":"wb-1"`) { + t.Errorf("non-sensitive params must render verbatim, got: %s", jsonOut.String()) + } + + var textOut bytes.Buffer + WriteDecisionText(&textOut, view) + if strings.Contains(textOut.String(), sentinel) { + t.Errorf("text output leaks a sensitive param value: %s", textOut.String()) + } +} diff --git a/cmd/event/service_adapters.go b/cmd/event/service_adapters.go new file mode 100644 index 0000000000..f85387e688 --- /dev/null +++ b/cmd/event/service_adapters.go @@ -0,0 +1,78 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package event + +import ( + "context" + + eventlib "github.com/larksuite/cli/internal/event" + appconsume "github.com/larksuite/cli/internal/event/application/consume" + "github.com/larksuite/cli/internal/event/catalog" +) + +// consumeStrategies is the executable strategy set for this binary. The same +// registry is handed to catalog compilation, so a reference the compiler +// accepted is guaranteed to resolve here. +var consumeStrategies = appconsume.DefaultRegistry() + +type identityResolverFunc func(ctx context.Context, entry *catalog.Entry) (string, error) + +func (f identityResolverFunc) Resolve(ctx context.Context, entry *catalog.Entry) (string, error) { + return f(ctx, entry) +} + +type preflightReaderFunc func(ctx context.Context, entry *catalog.Entry, identity string) ([]appconsume.Precondition, error) + +func (f preflightReaderFunc) Read(ctx context.Context, entry *catalog.Entry, identity string) ([]appconsume.Precondition, error) { + return f(ctx, entry, identity) +} + +type streamRunnerFunc func(ctx context.Context, prepare appconsume.PrepareFunc) error + +func (f streamRunnerFunc) Run(ctx context.Context, prepare appconsume.PrepareFunc) error { + return f(ctx, prepare) +} + +// readPreconditions classifies the existing read-only preflight checks into +// named preconditions. Weak dependencies that could not answer stay visible +// as "unknown" instead of silently passing; a failed check carries the exact +// error a real run returns, so refusal is identical on both paths. +func readPreconditions(ctx context.Context, pf *preflightCtx, appVerErr error) []appconsume.Precondition { + console := appconsume.Precondition{Name: "console_event_published", Status: appconsume.PreconditionOK} + switch { + case len(pf.keyDef.RequiredConsoleEvents) == 0: + // nothing to verify + case pf.keyDef.SubscriptionType == eventlib.SubTypeCallback && pf.subscribedCallbacks == nil, + pf.keyDef.SubscriptionType != eventlib.SubTypeCallback && pf.appVer == nil: + console.Status = appconsume.PreconditionUnknown + if appVerErr != nil { + console.Detail = describeAppMetaErr(appVerErr) + } else { + console.Detail = "console ledger unavailable" + } + default: + if err := preflightEventTypes(pf); err != nil { + console.Status = appconsume.PreconditionBlocked + console.Detail = err.Error() + console.BlockErr = err + } + } + + scopes := appconsume.Precondition{Name: "scopes_granted", Status: appconsume.PreconditionOK} + switch { + case len(pf.keyDef.Scopes) == 0: + // nothing to verify + case pf.identity.IsBot() && pf.appVer == nil: + scopes.Status = appconsume.PreconditionUnknown + scopes.Detail = "published scopes unavailable" + default: + if err := preflightScopes(ctx, pf); err != nil { + scopes.Status = appconsume.PreconditionBlocked + scopes.Detail = err.Error() + scopes.BlockErr = err + } + } + + return []appconsume.Precondition{console, scopes} +} diff --git a/cmd/event/wiring.go b/cmd/event/wiring.go index e728e4edfe..b8bc240ab8 100644 --- a/cmd/event/wiring.go +++ b/cmd/event/wiring.go @@ -15,10 +15,9 @@ import ( // subcommand reads. A compile failure is a defect in declarations built into // this binary — there is nothing to recover at runtime, so it panics. func compileCatalog() *catalog.Snapshot { - snap, err := catalog.Compile(events.All(), catalog.StrategyRefs{ - catalog.StrategyNone, - catalog.StrategyLegacyPreConsume, - }) + // The strategy registry that validates references is the same one that + // executes them, so "compiled" implies "resolvable at run time". + snap, err := catalog.Compile(events.All(), consumeStrategies) if err != nil { panic(fmt.Sprintf("event catalog failed to compile: %v", err)) } From 9a9f8da6996fe2a4e15904e73001e9ddede6c8f1 Mon Sep 17 00:00:00 2001 From: leave330 Date: Sat, 1 Aug 2026 00:17:54 +0800 Subject: [PATCH 20/61] feat(event): add a domain filter to event list --- cmd/event/golden_test.go | 2 +- cmd/event/list.go | 45 +++++++++++++--- cmd/event/list_domain_test.go | 96 +++++++++++++++++++++++++++++++++++ cmd/event/list_test.go | 4 +- 4 files changed, 137 insertions(+), 10 deletions(-) create mode 100644 cmd/event/list_domain_test.go diff --git a/cmd/event/golden_test.go b/cmd/event/golden_test.go index 1f9f177e84..ece1b5caf5 100644 --- a/cmd/event/golden_test.go +++ b/cmd/event/golden_test.go @@ -36,7 +36,7 @@ func TestGolden_ListOutput(t *testing.T) { for name, asJSON := range map[string]bool{"list_text": false, "list_json": true} { t.Run(name, func(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) - if err := runList(f, snap, asJSON); err != nil { + if err := runList(f, snap, "", asJSON); err != nil { t.Fatalf("runList: %v", err) } assertGolden(t, name, stdout.String()) diff --git a/cmd/event/list.go b/cmd/event/list.go index 62ae6be971..e0a0209ab1 100644 --- a/cmd/event/list.go +++ b/cmd/event/list.go @@ -10,6 +10,7 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" eventlib "github.com/larksuite/cli/internal/event" "github.com/larksuite/cli/internal/event/catalog" @@ -18,24 +19,33 @@ import ( func NewCmdList(f *cmdutil.Factory, snap *catalog.Snapshot) *cobra.Command { var asJSON bool + var domain string cmd := &cobra.Command{ Use: "list", Short: "List all available EventKeys", - Long: "Show all registered EventKeys grouped by domain (first segment of the key). Use --json for machine-readable output.", + Long: "Show all registered EventKeys grouped by domain (first segment of the key). Use --domain to keep one domain only, --json for machine-readable output.", RunE: func(cmd *cobra.Command, args []string) error { - return runList(f, snap, asJSON) + return runList(f, snap, domain, asJSON) }, } cmd.Flags().BoolVar(&asJSON, "json", false, "Emit the full EventKey list as JSON (for AI / scripts)") + cmd.Flags().StringVar(&domain, "domain", "", "Only list EventKeys of this domain (the key's first segment, e.g. im, vc)") cmdutil.SetRisk(cmd, "read") return cmd } -func runList(f *cmdutil.Factory, snap *catalog.Snapshot, asJSON bool) error { +func runList(f *cmdutil.Factory, snap *catalog.Snapshot, domain string, asJSON bool) error { + entries, err := entriesForDomain(snap, domain) + if err != nil { + return err + } if asJSON { - return writeListJSON(f, snap) + return writeListJSON(f, entries) + } + all := make([]*eventlib.KeyDefinition, 0, len(entries)) + for _, entry := range entries { + all = append(all, entry.Definition()) } - all := snap.Definitions() if len(all) == 0 { // stderr so `event list | jq` doesn't ingest it as a row. @@ -112,8 +122,29 @@ type listRow struct { ResolvedSchema json.RawMessage `json:"resolved_output_schema,omitempty"` } -func writeListJSON(f *cmdutil.Factory, snap *catalog.Snapshot) error { - entries := snap.Entries() +// entriesForDomain filters at the snapshot query layer: without a domain the +// full catalog comes back untouched; with one, rows are only removed, never +// reshaped. An unknown domain is rejected with the valid set spelled out. +func entriesForDomain(snap *catalog.Snapshot, domain string) ([]*catalog.Entry, error) { + if domain == "" { + return snap.Entries(), nil + } + var filtered []*catalog.Entry + for _, entry := range snap.Entries() { + if entry.Descriptor().Domain == domain { + filtered = append(filtered, entry) + } + } + if len(filtered) == 0 { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, + "unknown domain: %s", domain). + WithParam("--domain"). + WithHint("valid domains: %s", strings.Join(snap.Domains(), ", ")) + } + return filtered, nil +} + +func writeListJSON(f *cmdutil.Factory, entries []*catalog.Entry) error { rows := make([]listRow, len(entries)) for i, entry := range entries { rows[i] = listRow{ diff --git a/cmd/event/list_domain_test.go b/cmd/event/list_domain_test.go new file mode 100644 index 0000000000..5452bae92d --- /dev/null +++ b/cmd/event/list_domain_test.go @@ -0,0 +1,96 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package event + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" +) + +// Filtering only removes rows: the vc selection must be exactly the catalog's +// vc keys, and every remaining row keeps the unfiltered field set. +func TestListDomain_FilterKeepsExactlyTheRequestedDomain(t *testing.T) { + snap := compileCatalog() + + f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) + if err := runList(f, snap, "vc", true); err != nil { + t.Fatal(err) + } + var rows []map[string]json.RawMessage + if err := json.Unmarshal(stdout.Bytes(), &rows); err != nil { + t.Fatal(err) + } + + want := map[string]bool{} + for _, key := range snap.Keys() { + if strings.HasPrefix(key, "vc.") { + want[key] = true + } + } + if len(want) == 0 { + t.Fatal("the catalog has no vc keys; the filter test proves nothing") + } + got := map[string]bool{} + for _, row := range rows { + var key string + _ = json.Unmarshal(row["key"], &key) + got[key] = true + for _, field := range []string{"event_type", "schema", "resolved_output_schema"} { + if _, ok := row[field]; !ok { + t.Errorf("%s: filtering must not reshape rows; %q is missing", key, field) + } + } + } + if len(got) != len(want) { + t.Fatalf("filtered rows = %v, want the exact vc set %v", got, want) + } + for key := range want { + if !got[key] { + t.Errorf("vc key missing from the filtered list: %s", key) + } + } +} + +func TestListDomain_TextFilter(t *testing.T) { + snap := compileCatalog() + f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) + if err := runList(f, snap, "im", false); err != nil { + t.Fatal(err) + } + out := stdout.String() + if !strings.Contains(out, "im.message.receive_v1") { + t.Error("im keys must be listed") + } + for _, foreign := range []string{"vc.", "minutes.", "board.", "approval."} { + if strings.Contains(out, foreign) { + t.Errorf("foreign domain %q leaked into the filtered text output", foreign) + } + } +} + +func TestListDomain_UnknownDomainIsRejectedWithTheValidSet(t *testing.T) { + snap := compileCatalog() + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) + err := runList(f, snap, "definitely-bogus", true) + if err == nil { + t.Fatal("an unknown domain must be rejected") + } + problem, ok := errs.ProblemOf(err) + if !ok || problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("want invalid_argument, got %v", err) + } + if !strings.Contains(err.Error(), "unknown domain: definitely-bogus") { + t.Errorf("error must name the rejected value, got %v", err) + } + for _, domain := range []string{"application", "approval", "board", "card", "im", "minutes", "task", "vc"} { + if !strings.Contains(problem.Hint, domain) { + t.Errorf("hint must list valid domain %q, got %q", domain, problem.Hint) + } + } +} diff --git a/cmd/event/list_test.go b/cmd/event/list_test.go index b9134e2650..1610aecff3 100644 --- a/cmd/event/list_test.go +++ b/cmd/event/list_test.go @@ -29,7 +29,7 @@ func TestEventLookup_VCMeetingLifecycleKeys(t *testing.T) { func TestRunList_TextOutput(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) - if err := runList(f, compileCatalog(), false); err != nil { + if err := runList(f, compileCatalog(), "", false); err != nil { t.Fatalf("runList: %v", err) } @@ -53,7 +53,7 @@ func TestRunList_TextOutput(t *testing.T) { func TestRunList_JSONOutput(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) - if err := runList(f, compileCatalog(), true); err != nil { + if err := runList(f, compileCatalog(), "", true); err != nil { t.Fatalf("runList json: %v", err) } From 085765fb2654b6d090603f747170474c3549f9ac Mon Sep 17 00:00:00 2001 From: leave330 Date: Sat, 1 Aug 2026 00:22:04 +0800 Subject: [PATCH 21/61] refactor(event): let the composition root inject event sources into the bus --- cmd/event/bus.go | 9 ++++- internal/event/bus/bus.go | 24 ++++++------ internal/event/bus/source_port.go | 33 ++++++++++++++++ internal/event/integration_test.go | 15 ++------ internal/event/preconsume_contract_test.go | 5 +-- internal/event/source/source.go | 44 ++-------------------- internal/event/source/source_test.go | 12 ------ 7 files changed, 61 insertions(+), 81 deletions(-) create mode 100644 internal/event/bus/source_port.go diff --git a/cmd/event/bus.go b/cmd/event/bus.go index 92c5ac6f9c..fc7c434ab6 100644 --- a/cmd/event/bus.go +++ b/cmd/event/bus.go @@ -18,6 +18,7 @@ import ( "github.com/larksuite/cli/internal/event" "github.com/larksuite/cli/internal/event/bus" "github.com/larksuite/cli/internal/event/catalog" + "github.com/larksuite/cli/internal/event/source" "github.com/larksuite/cli/internal/event/transport" ) @@ -45,7 +46,13 @@ func NewCmdBus(f *cmdutil.Factory, snap *catalog.Snapshot) *cobra.Command { } tr := transport.New() - b := bus.NewBus(cfg.AppID, cfg.AppSecret, domain, tr, logger, snap) + ingress := &source.FeishuSource{ + AppID: cfg.AppID, + AppSecret: cfg.AppSecret, + Domain: domain, + Logger: logger, + } + b := bus.NewBus(cfg.AppID, cfg.AppSecret, domain, tr, logger, snap, ingress) ctx, cancel := context.WithCancel(cmd.Context()) defer cancel() diff --git a/internal/event/bus/bus.go b/internal/event/bus/bus.go index 1bfbab3810..0ee9489929 100644 --- a/internal/event/bus/bus.go +++ b/internal/event/bus/bus.go @@ -22,7 +22,6 @@ import ( "github.com/larksuite/cli/internal/event/busdiscover" "github.com/larksuite/cli/internal/event/catalog" "github.com/larksuite/cli/internal/event/protocol" - "github.com/larksuite/cli/internal/event/source" "github.com/larksuite/cli/internal/event/transport" "github.com/larksuite/cli/internal/lockfile" ) @@ -54,10 +53,15 @@ type Bus struct { // snapshot is the compiled catalog this daemon serves: it decides which // upstream event types to subscribe and which keys are single-consumer. snapshot *catalog.Snapshot + + // sources are injected by the composition root; the daemon runs whatever + // it was handed and never constructs an ingress itself. + sources []Source } -func NewBus(appID, appSecret, domain string, tr transport.IPC, logger *log.Logger, snap *catalog.Snapshot) *Bus { +func NewBus(appID, appSecret, domain string, tr transport.IPC, logger *log.Logger, snap *catalog.Snapshot, sources ...Source) *Bus { return &Bus{ + sources: sources, appID: appID, appSecret: appSecret, domain: domain, @@ -161,21 +165,15 @@ func shutdownConns(b *Bus) { } } -// startSources launches registered sources (or a default FeishuSource); any source exit triggers full bus shutdown. +// startSources launches the injected sources; any source exit triggers full bus shutdown. func (b *Bus) startSources(ctx context.Context) { - sources := source.All() - if len(sources) == 0 { - sources = []source.Source{&source.FeishuSource{ - AppID: b.appID, - AppSecret: b.appSecret, - Domain: b.domain, - Logger: b.logger, - }} + if len(b.sources) == 0 { + b.logger.Printf("WARN: no event sources injected; the bus will idle until shutdown") } eventTypes := b.snapshot.EventTypes() b.hub.SetLogger(b.logger) - for _, src := range sources { - go func(s source.Source) { + for _, src := range b.sources { + go func(s Source) { b.logger.Printf("Starting source: %s", s.Name()) err := s.Start(ctx, eventTypes, func(raw *event.RawEvent) { b.logger.Printf("Event received: type=%s id=%s", raw.EventType, raw.EventID) diff --git a/internal/event/bus/source_port.go b/internal/event/bus/source_port.go new file mode 100644 index 0000000000..b541393429 --- /dev/null +++ b/internal/event/bus/source_port.go @@ -0,0 +1,33 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package bus + +import ( + "context" + + "github.com/larksuite/cli/internal/event" +) + +// StatusNotifier surfaces source lifecycle states; detail is free-form +// context. It is a function alias (not a named type) so implementations +// satisfy the Source interface without importing this package. +type StatusNotifier = func(state, detail string) + +// Source produces events for the bus; emit MUST return quickly (anything slow +// stalls the source's read loop). The bus owns this port and the composition +// root injects implementations — the daemon never constructs one itself. +type Source interface { + Name() string + Start(ctx context.Context, eventTypes []string, emit func(*event.RawEvent), notify StatusNotifier) error +} + +// Source lifecycle states. The wire values mirror the IPC frame constants — +// the bus forwards them verbatim into source_status frames; a pinning test +// keeps the two vocabularies equal. +const ( + SourceStateConnecting = "connecting" + SourceStateConnected = "connected" + SourceStateDisconnected = "disconnected" + SourceStateReconnecting = "reconnecting" +) diff --git a/internal/event/integration_test.go b/internal/event/integration_test.go index 9524b396ad..10d1fbb830 100644 --- a/internal/event/integration_test.go +++ b/internal/event/integration_test.go @@ -23,7 +23,6 @@ import ( "github.com/larksuite/cli/internal/event/bus" "github.com/larksuite/cli/internal/event/catalog" "github.com/larksuite/cli/internal/event/protocol" - "github.com/larksuite/cli/internal/event/source" "github.com/larksuite/cli/internal/event/testutil" "github.com/larksuite/cli/internal/event/transport" ) @@ -84,7 +83,7 @@ type mockIntegSource struct { func (s *mockIntegSource) Name() string { return "mock-integration" } -func (s *mockIntegSource) Start(ctx context.Context, _ []string, emit func(*event.RawEvent), _ source.StatusNotifier) error { +func (s *mockIntegSource) Start(ctx context.Context, _ []string, emit func(*event.RawEvent), _ func(state, detail string)) error { s.mu.Lock() s.emitFn = emit s.mu.Unlock() @@ -102,7 +101,6 @@ func (s *mockIntegSource) emit(e *event.RawEvent) { } func TestIntegration_BusToConsume(t *testing.T) { - source.ResetForTest() snap := compileTestSnapshot(t, event.KeyDefinition{ Key: "test.event.v1", @@ -111,7 +109,6 @@ func TestIntegration_BusToConsume(t *testing.T) { }) mockSrc := &mockIntegSource{} - source.Register(mockSrc) dir := t.TempDir() addr := filepath.Join(dir, "t.sock") @@ -120,7 +117,7 @@ func TestIntegration_BusToConsume(t *testing.T) { logger := log.New(os.Stderr, "[test-bus] ", log.LstdFlags) testTr := testutil.NewWrappedFake(tr, addr) - b := bus.NewBus("test-app", "test-secret", "", testTr, logger, snap) + b := bus.NewBus("test-app", "test-secret", "", testTr, logger, snap, mockSrc) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -199,7 +196,6 @@ func TestIntegration_BusToConsume(t *testing.T) { } func TestIntegration_MultipleConsumers(t *testing.T) { - source.ResetForTest() snap := compileTestSnapshot(t, event.KeyDefinition{ Key: "multi.event.v1", @@ -208,7 +204,6 @@ func TestIntegration_MultipleConsumers(t *testing.T) { }) mockSrc := &mockIntegSource{} - source.Register(mockSrc) dir := t.TempDir() addr := filepath.Join(dir, "m.sock") @@ -216,7 +211,7 @@ func TestIntegration_MultipleConsumers(t *testing.T) { logger := log.New(os.Stderr, "[test-multi] ", log.LstdFlags) testTr := testutil.NewWrappedFake(tr, addr) - b := bus.NewBus("test-multi", "test-secret", "", testTr, logger, snap) + b := bus.NewBus("test-multi", "test-secret", "", testTr, logger, snap, mockSrc) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -292,7 +287,6 @@ func TestIntegration_MultipleConsumers(t *testing.T) { } func TestIntegration_DedupFilter(t *testing.T) { - source.ResetForTest() snap := compileTestSnapshot(t, event.KeyDefinition{ Key: "dedup.event.v1", @@ -301,7 +295,6 @@ func TestIntegration_DedupFilter(t *testing.T) { }) mockSrc := &mockIntegSource{} - source.Register(mockSrc) dir := t.TempDir() addr := filepath.Join(dir, "d.sock") @@ -309,7 +302,7 @@ func TestIntegration_DedupFilter(t *testing.T) { logger := log.New(os.Stderr, "[test-dedup] ", log.LstdFlags) testTr := testutil.NewWrappedFake(tr, addr) - b := bus.NewBus("test-dedup", "test-secret", "", testTr, logger, snap) + b := bus.NewBus("test-dedup", "test-secret", "", testTr, logger, snap, mockSrc) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() diff --git a/internal/event/preconsume_contract_test.go b/internal/event/preconsume_contract_test.go index e5cfa9cdad..9f762049b4 100644 --- a/internal/event/preconsume_contract_test.go +++ b/internal/event/preconsume_contract_test.go @@ -38,7 +38,6 @@ import ( "github.com/larksuite/cli/internal/event/catalog" "github.com/larksuite/cli/internal/event/consume" "github.com/larksuite/cli/internal/event/protocol" - "github.com/larksuite/cli/internal/event/source" "github.com/larksuite/cli/internal/event/testutil" "github.com/larksuite/cli/internal/event/transport" ) @@ -96,8 +95,6 @@ func resolveDef(t *testing.T, snap *catalog.Snapshot, key string) *event.KeyDefi // catalog the bus serves. func startContractBus(t *testing.T, snap *catalog.Snapshot) (*testutil.FakeTransport, string) { t.Helper() - source.ResetForTest() - source.Register(&mockIntegSource{}) appID := fmt.Sprintf("preconsume-contract-%d-%d", os.Getpid(), contractAppSeq.Add(1)) // Short-named temp dir instead of t.TempDir(): the long test names would @@ -111,7 +108,7 @@ func startContractBus(t *testing.T, snap *catalog.Snapshot) (*testutil.FakeTrans addr := filepath.Join(sockDir, "s") tr := testutil.NewWrappedFake(transport.New(), addr) logger := log.New(os.Stderr, "[contract-bus] ", log.LstdFlags) - b := bus.NewBus(appID, "test-secret", "", tr, logger, snap) + b := bus.NewBus(appID, "test-secret", "", tr, logger, snap, &mockIntegSource{}) ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) runBus(t, b, ctx) diff --git a/internal/event/source/source.go b/internal/event/source/source.go index 1b17f84d94..dd700bdf60 100644 --- a/internal/event/source/source.go +++ b/internal/event/source/source.go @@ -5,43 +5,7 @@ // business registrations free of SDK transitive deps). package source -import ( - "context" - "sync" - - "github.com/larksuite/cli/internal/event" -) - -// StatusNotifier surfaces SourceState* lifecycle states; detail is free-form context. -type StatusNotifier func(state, detail string) - -// Source produces events; emit MUST return quickly (anything slow stalls the SDK read loop). -type Source interface { - Name() string - Start(ctx context.Context, eventTypes []string, emit func(*event.RawEvent), notify StatusNotifier) error -} - -var ( - registry []Source - registryMu sync.Mutex -) - -func Register(s Source) { - registryMu.Lock() - defer registryMu.Unlock() - registry = append(registry, s) -} - -func All() []Source { - registryMu.Lock() - defer registryMu.Unlock() - out := make([]Source, len(registry)) - copy(out, registry) - return out -} - -func ResetForTest() { - registryMu.Lock() - defer registryMu.Unlock() - registry = nil -} +// StatusNotifier surfaces source lifecycle states; detail is free-form +// context. A function alias so implementations structurally satisfy the +// bus-side Source port without importing it. +type StatusNotifier = func(state, detail string) diff --git a/internal/event/source/source_test.go b/internal/event/source/source_test.go index 37c41c3084..e599e317ca 100644 --- a/internal/event/source/source_test.go +++ b/internal/event/source/source_test.go @@ -25,18 +25,6 @@ func (s *mockSource) Start(ctx context.Context, _ []string, emit func(*event.Raw return nil } -func TestRegister(t *testing.T) { - ResetForTest() - - src := &mockSource{name: "test-source"} - Register(src) - - sources := All() - if len(sources) != 1 || sources[0].Name() != "test-source" { - t.Errorf("unexpected sources: %v", sources) - } -} - func TestMockSource_EmitsEvents(t *testing.T) { src := &mockSource{ name: "test", From e83a24f3fc4d158014491724ede7a27e838f70ca Mon Sep 17 00:00:00 2001 From: leave330 Date: Sat, 1 Aug 2026 00:22:51 +0800 Subject: [PATCH 22/61] refactor(event): move the protocol package under the localbus adapter --- cmd/event/format_helpers_test.go | 2 +- cmd/event/status.go | 2 +- cmd/event/status_orphan_test.go | 2 +- cmd/event/stop_integration_test.go | 2 +- .../{ => adapter/localbus}/protocol/canonical_fields_test.go | 0 internal/event/{ => adapter/localbus}/protocol/codec.go | 0 internal/event/{ => adapter/localbus}/protocol/codec_test.go | 0 internal/event/{ => adapter/localbus}/protocol/messages.go | 0 internal/event/{ => adapter/localbus}/protocol/messages_test.go | 0 internal/event/bus/bus.go | 2 +- internal/event/bus/conn.go | 2 +- internal/event/bus/conn_test.go | 2 +- internal/event/bus/handle_hello_test.go | 2 +- internal/event/bus/hub.go | 2 +- internal/event/bus/hub_observability_test.go | 2 +- internal/event/bus/hub_test.go | 2 +- internal/event/busctl/busctl.go | 2 +- internal/event/consume/capability_gate_test.go | 2 +- internal/event/consume/consume.go | 2 +- internal/event/consume/consume_test.go | 2 +- internal/event/consume/diagnostics_redaction_test.go | 2 +- internal/event/consume/handshake.go | 2 +- internal/event/consume/loop.go | 2 +- internal/event/consume/loop_seq_test.go | 2 +- internal/event/consume/loop_test.go | 2 +- internal/event/consume/reject_test.go | 2 +- internal/event/consume/shutdown.go | 2 +- internal/event/consume/shutdown_test.go | 2 +- internal/event/consume/startup.go | 2 +- internal/event/consume/startup_probe_test.go | 2 +- internal/event/integration_test.go | 2 +- internal/event/preconsume_contract_test.go | 2 +- internal/event/source/feishu.go | 2 +- internal/event/source/feishu_test.go | 2 +- internal/event/source/sdk_log_patterns_test.go | 2 +- 35 files changed, 30 insertions(+), 30 deletions(-) rename internal/event/{ => adapter/localbus}/protocol/canonical_fields_test.go (100%) rename internal/event/{ => adapter/localbus}/protocol/codec.go (100%) rename internal/event/{ => adapter/localbus}/protocol/codec_test.go (100%) rename internal/event/{ => adapter/localbus}/protocol/messages.go (100%) rename internal/event/{ => adapter/localbus}/protocol/messages_test.go (100%) diff --git a/cmd/event/format_helpers_test.go b/cmd/event/format_helpers_test.go index 0a46ce496d..246d768dbe 100644 --- a/cmd/event/format_helpers_test.go +++ b/cmd/event/format_helpers_test.go @@ -12,7 +12,7 @@ import ( "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" - "github.com/larksuite/cli/internal/event/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" "github.com/larksuite/cli/internal/output" ) diff --git a/cmd/event/status.go b/cmd/event/status.go index ece33958e4..4afb0279e1 100644 --- a/cmd/event/status.go +++ b/cmd/event/status.go @@ -16,7 +16,7 @@ import ( "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/event/busctl" "github.com/larksuite/cli/internal/event/busdiscover" - "github.com/larksuite/cli/internal/event/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" "github.com/larksuite/cli/internal/event/transport" "github.com/larksuite/cli/internal/output" ) diff --git a/cmd/event/status_orphan_test.go b/cmd/event/status_orphan_test.go index 9dea73b927..06d9f0520b 100644 --- a/cmd/event/status_orphan_test.go +++ b/cmd/event/status_orphan_test.go @@ -12,7 +12,7 @@ import ( "time" "github.com/larksuite/cli/internal/event/busdiscover" - "github.com/larksuite/cli/internal/event/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" ) type fakeScanner struct { diff --git a/cmd/event/stop_integration_test.go b/cmd/event/stop_integration_test.go index ad843d63ba..4738c8c9bc 100644 --- a/cmd/event/stop_integration_test.go +++ b/cmd/event/stop_integration_test.go @@ -13,7 +13,7 @@ import ( "testing" "time" - "github.com/larksuite/cli/internal/event/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" ) type mockTransport struct { diff --git a/internal/event/protocol/canonical_fields_test.go b/internal/event/adapter/localbus/protocol/canonical_fields_test.go similarity index 100% rename from internal/event/protocol/canonical_fields_test.go rename to internal/event/adapter/localbus/protocol/canonical_fields_test.go diff --git a/internal/event/protocol/codec.go b/internal/event/adapter/localbus/protocol/codec.go similarity index 100% rename from internal/event/protocol/codec.go rename to internal/event/adapter/localbus/protocol/codec.go diff --git a/internal/event/protocol/codec_test.go b/internal/event/adapter/localbus/protocol/codec_test.go similarity index 100% rename from internal/event/protocol/codec_test.go rename to internal/event/adapter/localbus/protocol/codec_test.go diff --git a/internal/event/protocol/messages.go b/internal/event/adapter/localbus/protocol/messages.go similarity index 100% rename from internal/event/protocol/messages.go rename to internal/event/adapter/localbus/protocol/messages.go diff --git a/internal/event/protocol/messages_test.go b/internal/event/adapter/localbus/protocol/messages_test.go similarity index 100% rename from internal/event/protocol/messages_test.go rename to internal/event/adapter/localbus/protocol/messages_test.go diff --git a/internal/event/bus/bus.go b/internal/event/bus/bus.go index 0ee9489929..bad2363dbb 100644 --- a/internal/event/bus/bus.go +++ b/internal/event/bus/bus.go @@ -21,7 +21,7 @@ import ( "github.com/larksuite/cli/internal/event" "github.com/larksuite/cli/internal/event/busdiscover" "github.com/larksuite/cli/internal/event/catalog" - "github.com/larksuite/cli/internal/event/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" "github.com/larksuite/cli/internal/event/transport" "github.com/larksuite/cli/internal/lockfile" ) diff --git a/internal/event/bus/conn.go b/internal/event/bus/conn.go index c827d80081..40c522ba93 100644 --- a/internal/event/bus/conn.go +++ b/internal/event/bus/conn.go @@ -12,7 +12,7 @@ import ( "sync/atomic" "time" - "github.com/larksuite/cli/internal/event/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" ) const ( diff --git a/internal/event/bus/conn_test.go b/internal/event/bus/conn_test.go index 1beaf3489a..3c5f1b31c8 100644 --- a/internal/event/bus/conn_test.go +++ b/internal/event/bus/conn_test.go @@ -13,7 +13,7 @@ import ( "testing" "time" - "github.com/larksuite/cli/internal/event/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" ) func TestConn_SenderWritesEvents(t *testing.T) { diff --git a/internal/event/bus/handle_hello_test.go b/internal/event/bus/handle_hello_test.go index f49672e995..7401b8b035 100644 --- a/internal/event/bus/handle_hello_test.go +++ b/internal/event/bus/handle_hello_test.go @@ -15,7 +15,7 @@ import ( "github.com/larksuite/cli/internal/event" "github.com/larksuite/cli/internal/event/catalog" - "github.com/larksuite/cli/internal/event/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" ) // compileBusTestSnapshot compiles synthetic declarations into the snapshot a diff --git a/internal/event/bus/hub.go b/internal/event/bus/hub.go index fd7e538636..42c50ef283 100644 --- a/internal/event/bus/hub.go +++ b/internal/event/bus/hub.go @@ -12,7 +12,7 @@ import ( "time" "github.com/larksuite/cli/internal/event" - "github.com/larksuite/cli/internal/event/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" ) // exclusiveCleanupWaitTimeout bounds how long TryRegisterExclusive waits for an diff --git a/internal/event/bus/hub_observability_test.go b/internal/event/bus/hub_observability_test.go index bc4be84420..c290cf013e 100644 --- a/internal/event/bus/hub_observability_test.go +++ b/internal/event/bus/hub_observability_test.go @@ -9,7 +9,7 @@ import ( "time" "github.com/larksuite/cli/internal/event" - "github.com/larksuite/cli/internal/event/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" ) func TestHubDroppedCountIncrements(t *testing.T) { diff --git a/internal/event/bus/hub_test.go b/internal/event/bus/hub_test.go index 7956b4b18d..45ba8ab98a 100644 --- a/internal/event/bus/hub_test.go +++ b/internal/event/bus/hub_test.go @@ -13,7 +13,7 @@ import ( "time" "github.com/larksuite/cli/internal/event" - "github.com/larksuite/cli/internal/event/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" ) func TestHub_Subscribe(t *testing.T) { diff --git a/internal/event/busctl/busctl.go b/internal/event/busctl/busctl.go index 6c4271a94b..af40999c68 100644 --- a/internal/event/busctl/busctl.go +++ b/internal/event/busctl/busctl.go @@ -10,7 +10,7 @@ import ( "fmt" "time" - "github.com/larksuite/cli/internal/event/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" "github.com/larksuite/cli/internal/event/transport" ) diff --git a/internal/event/consume/capability_gate_test.go b/internal/event/consume/capability_gate_test.go index 3fc2d33216..733f815da1 100644 --- a/internal/event/consume/capability_gate_test.go +++ b/internal/event/consume/capability_gate_test.go @@ -17,7 +17,7 @@ import ( "github.com/larksuite/cli/errs" event "github.com/larksuite/cli/internal/event" - "github.com/larksuite/cli/internal/event/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" "github.com/larksuite/cli/internal/event/testutil" "github.com/larksuite/cli/internal/event/transport" ) diff --git a/internal/event/consume/consume.go b/internal/event/consume/consume.go index dbc6ef4f0f..202980f083 100644 --- a/internal/event/consume/consume.go +++ b/internal/event/consume/consume.go @@ -16,7 +16,7 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/event" "github.com/larksuite/cli/internal/event/catalog" - "github.com/larksuite/cli/internal/event/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" "github.com/larksuite/cli/internal/event/transport" ) diff --git a/internal/event/consume/consume_test.go b/internal/event/consume/consume_test.go index ff8f6d150b..fab9f0b992 100644 --- a/internal/event/consume/consume_test.go +++ b/internal/event/consume/consume_test.go @@ -15,7 +15,7 @@ import ( "github.com/larksuite/cli/internal/event" "github.com/larksuite/cli/internal/event/catalog" - "github.com/larksuite/cli/internal/event/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" "github.com/larksuite/cli/internal/event/transport" ) diff --git a/internal/event/consume/diagnostics_redaction_test.go b/internal/event/consume/diagnostics_redaction_test.go index a6897fb893..5b4087f5b9 100644 --- a/internal/event/consume/diagnostics_redaction_test.go +++ b/internal/event/consume/diagnostics_redaction_test.go @@ -13,7 +13,7 @@ import ( event "github.com/larksuite/cli/internal/event" "github.com/larksuite/cli/internal/event/model" "github.com/larksuite/cli/internal/event/processing" - "github.com/larksuite/cli/internal/event/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" ) // assertNoPayloadBytes fails when a diagnostic line carries payload content. diff --git a/internal/event/consume/handshake.go b/internal/event/consume/handshake.go index 18aeac959e..11bf6fa61f 100644 --- a/internal/event/consume/handshake.go +++ b/internal/event/consume/handshake.go @@ -11,7 +11,7 @@ import ( "os" "time" - "github.com/larksuite/cli/internal/event/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" ) const helloAckTimeout = 5 * time.Second // symmetric with bus-side hello read deadline diff --git a/internal/event/consume/loop.go b/internal/event/consume/loop.go index e2cdcb4239..c4687ff9c1 100644 --- a/internal/event/consume/loop.go +++ b/internal/event/consume/loop.go @@ -19,7 +19,7 @@ import ( "github.com/itchyny/gojq" "github.com/larksuite/cli/internal/event" "github.com/larksuite/cli/internal/event/processing" - "github.com/larksuite/cli/internal/event/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" ) // consumeLoop reads events and dispatches to workers; cancels on terminal sink errors. diff --git a/internal/event/consume/loop_seq_test.go b/internal/event/consume/loop_seq_test.go index 578ef9d6b7..14c7c1c038 100644 --- a/internal/event/consume/loop_seq_test.go +++ b/internal/event/consume/loop_seq_test.go @@ -10,7 +10,7 @@ import ( "strings" "testing" - "github.com/larksuite/cli/internal/event/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" ) // Mirrors the inline gap-detection logic from consumeLoop's reader; keep in sync with loop.go. diff --git a/internal/event/consume/loop_test.go b/internal/event/consume/loop_test.go index 19583d492a..2e3f81c0e4 100644 --- a/internal/event/consume/loop_test.go +++ b/internal/event/consume/loop_test.go @@ -20,7 +20,7 @@ import ( "github.com/larksuite/cli/internal/event" "github.com/larksuite/cli/internal/event/model" - "github.com/larksuite/cli/internal/event/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" ) func echoKeyDef(key string) *event.KeyDefinition { diff --git a/internal/event/consume/reject_test.go b/internal/event/consume/reject_test.go index 5ad59b6939..b9c7fce9fe 100644 --- a/internal/event/consume/reject_test.go +++ b/internal/event/consume/reject_test.go @@ -8,7 +8,7 @@ import ( "testing" "github.com/larksuite/cli/errs" - "github.com/larksuite/cli/internal/event/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" ) func TestRejectionError_Rejected(t *testing.T) { diff --git a/internal/event/consume/shutdown.go b/internal/event/consume/shutdown.go index 3c07f9869c..de0d01819a 100644 --- a/internal/event/consume/shutdown.go +++ b/internal/event/consume/shutdown.go @@ -9,7 +9,7 @@ import ( "net" "time" - "github.com/larksuite/cli/internal/event/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" ) const preShutdownAckTimeout = 2 * time.Second diff --git a/internal/event/consume/shutdown_test.go b/internal/event/consume/shutdown_test.go index 362105ce3e..7b027a8b5c 100644 --- a/internal/event/consume/shutdown_test.go +++ b/internal/event/consume/shutdown_test.go @@ -13,7 +13,7 @@ import ( "time" "github.com/larksuite/cli/internal/event/model" - "github.com/larksuite/cli/internal/event/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" ) // checkLastForKey must skip non-ack frames buffered before PreShutdownAck. diff --git a/internal/event/consume/startup.go b/internal/event/consume/startup.go index 890e1c7723..f8c825f9e6 100644 --- a/internal/event/consume/startup.go +++ b/internal/event/consume/startup.go @@ -19,7 +19,7 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/event" - "github.com/larksuite/cli/internal/event/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" "github.com/larksuite/cli/internal/event/transport" "github.com/larksuite/cli/internal/lockfile" "github.com/larksuite/cli/internal/vfs" diff --git a/internal/event/consume/startup_probe_test.go b/internal/event/consume/startup_probe_test.go index 20c436c5ca..1a29ec43e0 100644 --- a/internal/event/consume/startup_probe_test.go +++ b/internal/event/consume/startup_probe_test.go @@ -11,7 +11,7 @@ import ( "testing" "time" - "github.com/larksuite/cli/internal/event/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" ) type probeMockTransport struct { diff --git a/internal/event/integration_test.go b/internal/event/integration_test.go index 10d1fbb830..0b5cbc8501 100644 --- a/internal/event/integration_test.go +++ b/internal/event/integration_test.go @@ -22,7 +22,7 @@ import ( "github.com/larksuite/cli/internal/event" "github.com/larksuite/cli/internal/event/bus" "github.com/larksuite/cli/internal/event/catalog" - "github.com/larksuite/cli/internal/event/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" "github.com/larksuite/cli/internal/event/testutil" "github.com/larksuite/cli/internal/event/transport" ) diff --git a/internal/event/preconsume_contract_test.go b/internal/event/preconsume_contract_test.go index 9f762049b4..d9a41f1a11 100644 --- a/internal/event/preconsume_contract_test.go +++ b/internal/event/preconsume_contract_test.go @@ -37,7 +37,7 @@ import ( "github.com/larksuite/cli/internal/event/bus" "github.com/larksuite/cli/internal/event/catalog" "github.com/larksuite/cli/internal/event/consume" - "github.com/larksuite/cli/internal/event/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" "github.com/larksuite/cli/internal/event/testutil" "github.com/larksuite/cli/internal/event/transport" ) diff --git a/internal/event/source/feishu.go b/internal/event/source/feishu.go index b8e2f1d9a2..527550dd67 100644 --- a/internal/event/source/feishu.go +++ b/internal/event/source/feishu.go @@ -18,7 +18,7 @@ import ( larkws "github.com/larksuite/oapi-sdk-go/v3/ws" "github.com/larksuite/cli/internal/event" - "github.com/larksuite/cli/internal/event/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" ) const maxEventBodyBytes = 1 << 20 // bound per-subscriber sendCh memory under runaway payloads diff --git a/internal/event/source/feishu_test.go b/internal/event/source/feishu_test.go index dab62ff06c..180d786d76 100644 --- a/internal/event/source/feishu_test.go +++ b/internal/event/source/feishu_test.go @@ -6,7 +6,7 @@ package source import ( "testing" - "github.com/larksuite/cli/internal/event/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" ) // "disconnected to " contains "connected to ws" — must use HasPrefix to avoid misclassifying as connect. diff --git a/internal/event/source/sdk_log_patterns_test.go b/internal/event/source/sdk_log_patterns_test.go index 069ce35861..020a58b201 100644 --- a/internal/event/source/sdk_log_patterns_test.go +++ b/internal/event/source/sdk_log_patterns_test.go @@ -9,7 +9,7 @@ import ( "sync" "testing" - "github.com/larksuite/cli/internal/event/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" ) // Samples preserve the real SDK shape (" to [conn_id=...]" — no space before bracket). From dafcaad003afe2e36b2f607e1c724c18c4b1c946 Mon Sep 17 00:00:00 2001 From: leave330 Date: Sat, 1 Aug 2026 00:22:55 +0800 Subject: [PATCH 23/61] refactor(event): move the transport package under the localbus adapter --- cmd/event/bus.go | 2 +- cmd/event/consume.go | 2 +- cmd/event/status.go | 2 +- cmd/event/stop.go | 2 +- internal/event/{ => adapter/localbus}/transport/transport.go | 0 .../event/{ => adapter/localbus}/transport/transport_test.go | 0 .../event/{ => adapter/localbus}/transport/transport_unix.go | 0 .../event/{ => adapter/localbus}/transport/transport_windows.go | 0 internal/event/bus/bus.go | 2 +- internal/event/busctl/busctl.go | 2 +- internal/event/consume/capability_gate_test.go | 2 +- internal/event/consume/consume.go | 2 +- internal/event/consume/consume_test.go | 2 +- internal/event/consume/startup.go | 2 +- internal/event/integration_test.go | 2 +- internal/event/preconsume_contract_test.go | 2 +- internal/event/testutil/testutil.go | 2 +- 17 files changed, 13 insertions(+), 13 deletions(-) rename internal/event/{ => adapter/localbus}/transport/transport.go (100%) rename internal/event/{ => adapter/localbus}/transport/transport_test.go (100%) rename internal/event/{ => adapter/localbus}/transport/transport_unix.go (100%) rename internal/event/{ => adapter/localbus}/transport/transport_windows.go (100%) diff --git a/cmd/event/bus.go b/cmd/event/bus.go index fc7c434ab6..3600fb3593 100644 --- a/cmd/event/bus.go +++ b/cmd/event/bus.go @@ -19,7 +19,7 @@ import ( "github.com/larksuite/cli/internal/event/bus" "github.com/larksuite/cli/internal/event/catalog" "github.com/larksuite/cli/internal/event/source" - "github.com/larksuite/cli/internal/event/transport" + "github.com/larksuite/cli/internal/event/adapter/localbus/transport" ) // NewCmdBus creates the hidden `event _bus` daemon subcommand, forked by the consume client; fork argv lives in consume/startup.go. diff --git a/cmd/event/consume.go b/cmd/event/consume.go index 45057bc40c..c4b9102eb1 100644 --- a/cmd/event/consume.go +++ b/cmd/event/consume.go @@ -27,7 +27,7 @@ import ( appconsume "github.com/larksuite/cli/internal/event/application/consume" "github.com/larksuite/cli/internal/event/catalog" "github.com/larksuite/cli/internal/event/consume" - "github.com/larksuite/cli/internal/event/transport" + "github.com/larksuite/cli/internal/event/adapter/localbus/transport" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" ) diff --git a/cmd/event/status.go b/cmd/event/status.go index 4afb0279e1..2b255efe78 100644 --- a/cmd/event/status.go +++ b/cmd/event/status.go @@ -17,7 +17,7 @@ import ( "github.com/larksuite/cli/internal/event/busctl" "github.com/larksuite/cli/internal/event/busdiscover" "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" - "github.com/larksuite/cli/internal/event/transport" + "github.com/larksuite/cli/internal/event/adapter/localbus/transport" "github.com/larksuite/cli/internal/output" ) diff --git a/cmd/event/stop.go b/cmd/event/stop.go index adab2d3bbc..cb962370a1 100644 --- a/cmd/event/stop.go +++ b/cmd/event/stop.go @@ -15,7 +15,7 @@ import ( "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/event/busctl" "github.com/larksuite/cli/internal/event/busdiscover" - "github.com/larksuite/cli/internal/event/transport" + "github.com/larksuite/cli/internal/event/adapter/localbus/transport" "github.com/larksuite/cli/internal/output" ) diff --git a/internal/event/transport/transport.go b/internal/event/adapter/localbus/transport/transport.go similarity index 100% rename from internal/event/transport/transport.go rename to internal/event/adapter/localbus/transport/transport.go diff --git a/internal/event/transport/transport_test.go b/internal/event/adapter/localbus/transport/transport_test.go similarity index 100% rename from internal/event/transport/transport_test.go rename to internal/event/adapter/localbus/transport/transport_test.go diff --git a/internal/event/transport/transport_unix.go b/internal/event/adapter/localbus/transport/transport_unix.go similarity index 100% rename from internal/event/transport/transport_unix.go rename to internal/event/adapter/localbus/transport/transport_unix.go diff --git a/internal/event/transport/transport_windows.go b/internal/event/adapter/localbus/transport/transport_windows.go similarity index 100% rename from internal/event/transport/transport_windows.go rename to internal/event/adapter/localbus/transport/transport_windows.go diff --git a/internal/event/bus/bus.go b/internal/event/bus/bus.go index bad2363dbb..07e5ede45c 100644 --- a/internal/event/bus/bus.go +++ b/internal/event/bus/bus.go @@ -22,7 +22,7 @@ import ( "github.com/larksuite/cli/internal/event/busdiscover" "github.com/larksuite/cli/internal/event/catalog" "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" - "github.com/larksuite/cli/internal/event/transport" + "github.com/larksuite/cli/internal/event/adapter/localbus/transport" "github.com/larksuite/cli/internal/lockfile" ) diff --git a/internal/event/busctl/busctl.go b/internal/event/busctl/busctl.go index af40999c68..45d27e4104 100644 --- a/internal/event/busctl/busctl.go +++ b/internal/event/busctl/busctl.go @@ -11,7 +11,7 @@ import ( "time" "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" - "github.com/larksuite/cli/internal/event/transport" + "github.com/larksuite/cli/internal/event/adapter/localbus/transport" ) const readTimeout = 5 * time.Second // matches protocol.WriteTimeout diff --git a/internal/event/consume/capability_gate_test.go b/internal/event/consume/capability_gate_test.go index 733f815da1..b9d28ffe43 100644 --- a/internal/event/consume/capability_gate_test.go +++ b/internal/event/consume/capability_gate_test.go @@ -19,7 +19,7 @@ import ( event "github.com/larksuite/cli/internal/event" "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" "github.com/larksuite/cli/internal/event/testutil" - "github.com/larksuite/cli/internal/event/transport" + "github.com/larksuite/cli/internal/event/adapter/localbus/transport" ) // legacyBusAcks replays, byte for byte, the hello_ack an older bus would send: diff --git a/internal/event/consume/consume.go b/internal/event/consume/consume.go index 202980f083..4de149fa65 100644 --- a/internal/event/consume/consume.go +++ b/internal/event/consume/consume.go @@ -17,7 +17,7 @@ import ( "github.com/larksuite/cli/internal/event" "github.com/larksuite/cli/internal/event/catalog" "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" - "github.com/larksuite/cli/internal/event/transport" + "github.com/larksuite/cli/internal/event/adapter/localbus/transport" ) type Options struct { diff --git a/internal/event/consume/consume_test.go b/internal/event/consume/consume_test.go index fab9f0b992..26437f51ba 100644 --- a/internal/event/consume/consume_test.go +++ b/internal/event/consume/consume_test.go @@ -16,7 +16,7 @@ import ( "github.com/larksuite/cli/internal/event" "github.com/larksuite/cli/internal/event/catalog" "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" - "github.com/larksuite/cli/internal/event/transport" + "github.com/larksuite/cli/internal/event/adapter/localbus/transport" ) // fakeRT is a minimal event.APIClient mock. diff --git a/internal/event/consume/startup.go b/internal/event/consume/startup.go index f8c825f9e6..ae31af7d89 100644 --- a/internal/event/consume/startup.go +++ b/internal/event/consume/startup.go @@ -20,7 +20,7 @@ import ( "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/event" "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" - "github.com/larksuite/cli/internal/event/transport" + "github.com/larksuite/cli/internal/event/adapter/localbus/transport" "github.com/larksuite/cli/internal/lockfile" "github.com/larksuite/cli/internal/vfs" ) diff --git a/internal/event/integration_test.go b/internal/event/integration_test.go index 0b5cbc8501..88691294da 100644 --- a/internal/event/integration_test.go +++ b/internal/event/integration_test.go @@ -24,7 +24,7 @@ import ( "github.com/larksuite/cli/internal/event/catalog" "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" "github.com/larksuite/cli/internal/event/testutil" - "github.com/larksuite/cli/internal/event/transport" + "github.com/larksuite/cli/internal/event/adapter/localbus/transport" ) type integTestOut struct{ A string } diff --git a/internal/event/preconsume_contract_test.go b/internal/event/preconsume_contract_test.go index d9a41f1a11..c1a003d779 100644 --- a/internal/event/preconsume_contract_test.go +++ b/internal/event/preconsume_contract_test.go @@ -39,7 +39,7 @@ import ( "github.com/larksuite/cli/internal/event/consume" "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" "github.com/larksuite/cli/internal/event/testutil" - "github.com/larksuite/cli/internal/event/transport" + "github.com/larksuite/cli/internal/event/adapter/localbus/transport" ) // contractAppSeq makes every test invocation use a distinct app id. The bus diff --git a/internal/event/testutil/testutil.go b/internal/event/testutil/testutil.go index 600156787d..803b5493c9 100644 --- a/internal/event/testutil/testutil.go +++ b/internal/event/testutil/testutil.go @@ -11,7 +11,7 @@ import ( "net" "sync" - "github.com/larksuite/cli/internal/event/transport" + "github.com/larksuite/cli/internal/event/adapter/localbus/transport" ) // FakeTransport delegates to inner with a fixed addr, so tests can use t.TempDir paths. From f7a821b572f5186dbe67397b76e8b5f5d828b4ae Mon Sep 17 00:00:00 2001 From: leave330 Date: Sat, 1 Aug 2026 00:22:59 +0800 Subject: [PATCH 24/61] refactor(event): move the busctl package under the localbus adapter --- cmd/event/status.go | 2 +- cmd/event/stop.go | 2 +- internal/event/{ => adapter/localbus}/busctl/busctl.go | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename internal/event/{ => adapter/localbus}/busctl/busctl.go (100%) diff --git a/cmd/event/status.go b/cmd/event/status.go index 2b255efe78..7b7eaee7a9 100644 --- a/cmd/event/status.go +++ b/cmd/event/status.go @@ -14,7 +14,7 @@ import ( "github.com/spf13/cobra" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/event/busctl" + "github.com/larksuite/cli/internal/event/adapter/localbus/busctl" "github.com/larksuite/cli/internal/event/busdiscover" "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" "github.com/larksuite/cli/internal/event/adapter/localbus/transport" diff --git a/cmd/event/stop.go b/cmd/event/stop.go index cb962370a1..1dd180ab6d 100644 --- a/cmd/event/stop.go +++ b/cmd/event/stop.go @@ -13,7 +13,7 @@ import ( "github.com/spf13/cobra" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/event/busctl" + "github.com/larksuite/cli/internal/event/adapter/localbus/busctl" "github.com/larksuite/cli/internal/event/busdiscover" "github.com/larksuite/cli/internal/event/adapter/localbus/transport" "github.com/larksuite/cli/internal/output" diff --git a/internal/event/busctl/busctl.go b/internal/event/adapter/localbus/busctl/busctl.go similarity index 100% rename from internal/event/busctl/busctl.go rename to internal/event/adapter/localbus/busctl/busctl.go From faa822ecdbaae6d82943d961c7d06aeaa0fd6bb2 Mon Sep 17 00:00:00 2001 From: leave330 Date: Sat, 1 Aug 2026 00:23:03 +0800 Subject: [PATCH 25/61] refactor(event): move the busdiscover package under the localbus adapter --- cmd/event/status.go | 2 +- cmd/event/status_orphan_test.go | 2 +- cmd/event/stop.go | 2 +- cmd/event/stop_discover_test.go | 2 +- .../event/{ => adapter/localbus}/busdiscover/busdiscover.go | 0 internal/event/{ => adapter/localbus}/busdiscover/pidfile.go | 0 .../event/{ => adapter/localbus}/busdiscover/pidfile_test.go | 0 internal/event/bus/bus.go | 2 +- 8 files changed, 5 insertions(+), 5 deletions(-) rename internal/event/{ => adapter/localbus}/busdiscover/busdiscover.go (100%) rename internal/event/{ => adapter/localbus}/busdiscover/pidfile.go (100%) rename internal/event/{ => adapter/localbus}/busdiscover/pidfile_test.go (100%) diff --git a/cmd/event/status.go b/cmd/event/status.go index 7b7eaee7a9..c602998a27 100644 --- a/cmd/event/status.go +++ b/cmd/event/status.go @@ -15,7 +15,7 @@ import ( "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/event/adapter/localbus/busctl" - "github.com/larksuite/cli/internal/event/busdiscover" + "github.com/larksuite/cli/internal/event/adapter/localbus/busdiscover" "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" "github.com/larksuite/cli/internal/event/adapter/localbus/transport" "github.com/larksuite/cli/internal/output" diff --git a/cmd/event/status_orphan_test.go b/cmd/event/status_orphan_test.go index 06d9f0520b..ece848748a 100644 --- a/cmd/event/status_orphan_test.go +++ b/cmd/event/status_orphan_test.go @@ -11,7 +11,7 @@ import ( "testing" "time" - "github.com/larksuite/cli/internal/event/busdiscover" + "github.com/larksuite/cli/internal/event/adapter/localbus/busdiscover" "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" ) diff --git a/cmd/event/stop.go b/cmd/event/stop.go index 1dd180ab6d..725390c9f2 100644 --- a/cmd/event/stop.go +++ b/cmd/event/stop.go @@ -14,7 +14,7 @@ import ( "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/event/adapter/localbus/busctl" - "github.com/larksuite/cli/internal/event/busdiscover" + "github.com/larksuite/cli/internal/event/adapter/localbus/busdiscover" "github.com/larksuite/cli/internal/event/adapter/localbus/transport" "github.com/larksuite/cli/internal/output" ) diff --git a/cmd/event/stop_discover_test.go b/cmd/event/stop_discover_test.go index cfad83f1d5..6b185cd47d 100644 --- a/cmd/event/stop_discover_test.go +++ b/cmd/event/stop_discover_test.go @@ -9,7 +9,7 @@ import ( "sort" "testing" - "github.com/larksuite/cli/internal/event/busdiscover" + "github.com/larksuite/cli/internal/event/adapter/localbus/busdiscover" ) func TestDiscoverAppIDs_OnlyLiveLockHolders(t *testing.T) { diff --git a/internal/event/busdiscover/busdiscover.go b/internal/event/adapter/localbus/busdiscover/busdiscover.go similarity index 100% rename from internal/event/busdiscover/busdiscover.go rename to internal/event/adapter/localbus/busdiscover/busdiscover.go diff --git a/internal/event/busdiscover/pidfile.go b/internal/event/adapter/localbus/busdiscover/pidfile.go similarity index 100% rename from internal/event/busdiscover/pidfile.go rename to internal/event/adapter/localbus/busdiscover/pidfile.go diff --git a/internal/event/busdiscover/pidfile_test.go b/internal/event/adapter/localbus/busdiscover/pidfile_test.go similarity index 100% rename from internal/event/busdiscover/pidfile_test.go rename to internal/event/adapter/localbus/busdiscover/pidfile_test.go diff --git a/internal/event/bus/bus.go b/internal/event/bus/bus.go index 07e5ede45c..0de93fed93 100644 --- a/internal/event/bus/bus.go +++ b/internal/event/bus/bus.go @@ -19,7 +19,7 @@ import ( "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/event" - "github.com/larksuite/cli/internal/event/busdiscover" + "github.com/larksuite/cli/internal/event/adapter/localbus/busdiscover" "github.com/larksuite/cli/internal/event/catalog" "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" "github.com/larksuite/cli/internal/event/adapter/localbus/transport" From c3e84cdf3a4be427427f52585258d2db8d8eafc0 Mon Sep 17 00:00:00 2001 From: leave330 Date: Sat, 1 Aug 2026 00:24:09 +0800 Subject: [PATCH 26/61] refactor(event): move the websocket source behind the lark adapter boundary --- cmd/event/bus.go | 6 ++-- .../lark/websocket}/feishu.go | 21 ++++++++++---- .../lark/websocket}/feishu_ingress_test.go | 2 +- .../lark/websocket}/feishu_log_test.go | 2 +- .../lark/websocket}/feishu_test.go | 2 +- .../lark/websocket}/sdk_log_patterns.go | 2 +- .../lark/websocket}/sdk_log_patterns_test.go | 2 +- .../lark/websocket}/source.go | 2 +- .../lark/websocket}/source_test.go | 2 +- .../lark/websocket/state_pinning_test.go | 28 +++++++++++++++++++ lint/domaincontract/unapproved_test.go | 2 +- 11 files changed, 54 insertions(+), 17 deletions(-) rename internal/event/{source => adapter/lark/websocket}/feishu.go (88%) rename internal/event/{source => adapter/lark/websocket}/feishu_ingress_test.go (99%) rename internal/event/{source => adapter/lark/websocket}/feishu_log_test.go (99%) rename internal/event/{source => adapter/lark/websocket}/feishu_test.go (99%) rename internal/event/{source => adapter/lark/websocket}/sdk_log_patterns.go (94%) rename internal/event/{source => adapter/lark/websocket}/sdk_log_patterns_test.go (99%) rename internal/event/{source => adapter/lark/websocket}/source.go (96%) rename internal/event/{source => adapter/lark/websocket}/source_test.go (98%) create mode 100644 internal/event/adapter/lark/websocket/state_pinning_test.go diff --git a/cmd/event/bus.go b/cmd/event/bus.go index 3600fb3593..277a95a345 100644 --- a/cmd/event/bus.go +++ b/cmd/event/bus.go @@ -16,10 +16,10 @@ import ( "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/adapter/lark/websocket" + "github.com/larksuite/cli/internal/event/adapter/localbus/transport" "github.com/larksuite/cli/internal/event/bus" "github.com/larksuite/cli/internal/event/catalog" - "github.com/larksuite/cli/internal/event/source" - "github.com/larksuite/cli/internal/event/adapter/localbus/transport" ) // NewCmdBus creates the hidden `event _bus` daemon subcommand, forked by the consume client; fork argv lives in consume/startup.go. @@ -46,7 +46,7 @@ func NewCmdBus(f *cmdutil.Factory, snap *catalog.Snapshot) *cobra.Command { } tr := transport.New() - ingress := &source.FeishuSource{ + ingress := &websocket.FeishuSource{ AppID: cfg.AppID, AppSecret: cfg.AppSecret, Domain: domain, diff --git a/internal/event/source/feishu.go b/internal/event/adapter/lark/websocket/feishu.go similarity index 88% rename from internal/event/source/feishu.go rename to internal/event/adapter/lark/websocket/feishu.go index 527550dd67..3c92c7afca 100644 --- a/internal/event/source/feishu.go +++ b/internal/event/adapter/lark/websocket/feishu.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package source +package websocket import ( "context" @@ -18,7 +18,6 @@ import ( larkws "github.com/larksuite/oapi-sdk-go/v3/ws" "github.com/larksuite/cli/internal/event" - "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" ) const maxEventBodyBytes = 1 << 20 // bound per-subscriber sendCh memory under runaway payloads @@ -51,7 +50,7 @@ func (s *FeishuSource) Start(ctx context.Context, eventTypes []string, emit func } if notify != nil { - notify(protocol.SourceStateConnecting, "") + notify(sourceStateConnecting, "") } cli := larkws.NewClient(s.AppID, s.AppSecret, opts...) @@ -161,12 +160,22 @@ func (a *sdkLogger) tryNotify(msg, errDetail string) { if m := reconnectAttemptRe.FindStringSubmatch(lower); len(m) == 2 { detail = "attempt " + m[1] } - a.notify(protocol.SourceStateReconnecting, detail) + a.notify(sourceStateReconnecting, detail) case strings.HasPrefix(lower, sdkLogDisconnected): - a.notify(protocol.SourceStateDisconnected, errDetail) + a.notify(sourceStateDisconnected, errDetail) case strings.HasPrefix(lower, sdkLogConnected): - a.notify(protocol.SourceStateConnected, "") + a.notify(sourceStateConnected, "") } } var _ larkcore.Logger = (*sdkLogger)(nil) + +// Source lifecycle states as this adapter reports them. The values are the +// wire vocabulary of the bus's source_status frames; a pinning test keeps +// them equal to the IPC constants without importing the IPC package here. +const ( + sourceStateConnecting = "connecting" + sourceStateConnected = "connected" + sourceStateDisconnected = "disconnected" + sourceStateReconnecting = "reconnecting" +) diff --git a/internal/event/source/feishu_ingress_test.go b/internal/event/adapter/lark/websocket/feishu_ingress_test.go similarity index 99% rename from internal/event/source/feishu_ingress_test.go rename to internal/event/adapter/lark/websocket/feishu_ingress_test.go index 76591a0d62..49c9e4ce6c 100644 --- a/internal/event/source/feishu_ingress_test.go +++ b/internal/event/adapter/lark/websocket/feishu_ingress_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package source +package websocket import ( "context" diff --git a/internal/event/source/feishu_log_test.go b/internal/event/adapter/lark/websocket/feishu_log_test.go similarity index 99% rename from internal/event/source/feishu_log_test.go rename to internal/event/adapter/lark/websocket/feishu_log_test.go index 3ddffeb960..b2c6443149 100644 --- a/internal/event/source/feishu_log_test.go +++ b/internal/event/adapter/lark/websocket/feishu_log_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package source +package websocket import ( "bytes" diff --git a/internal/event/source/feishu_test.go b/internal/event/adapter/lark/websocket/feishu_test.go similarity index 99% rename from internal/event/source/feishu_test.go rename to internal/event/adapter/lark/websocket/feishu_test.go index 180d786d76..2e9d3f266f 100644 --- a/internal/event/source/feishu_test.go +++ b/internal/event/adapter/lark/websocket/feishu_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package source +package websocket import ( "testing" diff --git a/internal/event/source/sdk_log_patterns.go b/internal/event/adapter/lark/websocket/sdk_log_patterns.go similarity index 94% rename from internal/event/source/sdk_log_patterns.go rename to internal/event/adapter/lark/websocket/sdk_log_patterns.go index ae54f859dd..6fcb2638ca 100644 --- a/internal/event/source/sdk_log_patterns.go +++ b/internal/event/adapter/lark/websocket/sdk_log_patterns.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package source +package websocket // DO NOT trim trailing spaces — the HasPrefix disambiguator depends on them. const ( diff --git a/internal/event/source/sdk_log_patterns_test.go b/internal/event/adapter/lark/websocket/sdk_log_patterns_test.go similarity index 99% rename from internal/event/source/sdk_log_patterns_test.go rename to internal/event/adapter/lark/websocket/sdk_log_patterns_test.go index 020a58b201..05b83f05f4 100644 --- a/internal/event/source/sdk_log_patterns_test.go +++ b/internal/event/adapter/lark/websocket/sdk_log_patterns_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package source +package websocket import ( "context" diff --git a/internal/event/source/source.go b/internal/event/adapter/lark/websocket/source.go similarity index 96% rename from internal/event/source/source.go rename to internal/event/adapter/lark/websocket/source.go index dd700bdf60..da58dd121d 100644 --- a/internal/event/source/source.go +++ b/internal/event/adapter/lark/websocket/source.go @@ -3,7 +3,7 @@ // Package source is a pluggable event source abstraction (separate package to keep // business registrations free of SDK transitive deps). -package source +package websocket // StatusNotifier surfaces source lifecycle states; detail is free-form // context. A function alias so implementations structurally satisfy the diff --git a/internal/event/source/source_test.go b/internal/event/adapter/lark/websocket/source_test.go similarity index 98% rename from internal/event/source/source_test.go rename to internal/event/adapter/lark/websocket/source_test.go index e599e317ca..09031862a0 100644 --- a/internal/event/source/source_test.go +++ b/internal/event/adapter/lark/websocket/source_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package source +package websocket import ( "context" diff --git a/internal/event/adapter/lark/websocket/state_pinning_test.go b/internal/event/adapter/lark/websocket/state_pinning_test.go new file mode 100644 index 0000000000..c925d6e658 --- /dev/null +++ b/internal/event/adapter/lark/websocket/state_pinning_test.go @@ -0,0 +1,28 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package websocket + +import ( + "testing" + + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" + "github.com/larksuite/cli/internal/event/bus" +) + +// The adapter reports source states with its own constants so it never +// imports the IPC package; this test pins all three vocabularies (adapter, +// bus port, IPC frame) to the same wire values. +func TestSourceStates_MatchTheWireVocabulary(t *testing.T) { + pins := []struct{ adapter, port, wire string }{ + {sourceStateConnecting, bus.SourceStateConnecting, protocol.SourceStateConnecting}, + {sourceStateConnected, bus.SourceStateConnected, protocol.SourceStateConnected}, + {sourceStateDisconnected, bus.SourceStateDisconnected, protocol.SourceStateDisconnected}, + {sourceStateReconnecting, bus.SourceStateReconnecting, protocol.SourceStateReconnecting}, + } + for _, pin := range pins { + if pin.adapter != pin.port || pin.port != pin.wire { + t.Errorf("state vocabulary drifted: adapter=%q port=%q wire=%q", pin.adapter, pin.port, pin.wire) + } + } +} diff --git a/lint/domaincontract/unapproved_test.go b/lint/domaincontract/unapproved_test.go index 1b671d7ddf..03e890a7d2 100644 --- a/lint/domaincontract/unapproved_test.go +++ b/lint/domaincontract/unapproved_test.go @@ -285,7 +285,7 @@ var source = FeishuSource{Domain: "events.example.com"} ` got := evidenceHosts(scanTypedDomainEvidenceInPackage( t, - "github.com/larksuite/cli/internal/event/source", + "github.com/larksuite/cli/internal/event/adapter/lark/websocket", source, )) want := []string{"api.example.com", "events.example.com"} From 4e07c6b01e7e60e30071b62545850f51275b6386 Mon Sep 17 00:00:00 2001 From: leave330 Date: Sat, 1 Aug 2026 00:27:33 +0800 Subject: [PATCH 27/61] docs(event): document dry-run, domain filter and preconsume cleanup classes --- skills/lark-event/SKILL.md | 43 +++++++++++++++++-- skills/lark-event/references/lark-event-vc.md | 10 ++++- 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/skills/lark-event/SKILL.md b/skills/lark-event/SKILL.md index 321bdecb0c..d2c7105bf2 100644 --- a/skills/lark-event/SKILL.md +++ b/skills/lark-event/SKILL.md @@ -32,6 +32,7 @@ metadata: | `--max-events N` | Exit after N events. Default 0 = unlimited | | `--timeout D` | Exit after duration D (e.g. `30s`, `2m`). Default 0 = no timeout. Whichever of `--max-events` / `--timeout` fires first wins | | `--output-dir ` | Write each event as a file (relative paths only; prevents traversal) | +| `--dry-run` | Decide and preview the consume (identity, preconditions, side effects) without performing any of them, then exit 0. See "Dry-run preview" below | | `--quiet` | Suppress stderr diagnostics. **AI should not use this** — it silences the ready marker | | `--as user\|bot\|auto` | Identity for the session (see lark-shared) | @@ -57,9 +58,45 @@ wait ## Call flow -1. `lark-cli event list --json` → pick a legal key +1. `lark-cli event list --json` → pick a legal key (`--domain ` narrows to one domain, e.g. `--domain vc`; unknown domains fail with the valid set listed in the hint) 2. `lark-cli event schema --json` → read `resolved_output_schema` + `jq_root_path` to determine field paths -3. `lark-cli event consume [--jq '']` → consume +3. `lark-cli event consume --dry-run` → optional: verify readiness and preview side effects before committing to a long-running process +4. `lark-cli event consume [--jq '']` → consume + +## Dry-run preview + +`event consume --dry-run` performs only the read-only checks, then prints the consume decision on stdout as a standard envelope and exits 0 — no bus is started, no consumer registered, no server-side subscription touched, no files created. + +```json +{ + "ok": true, + "identity": "user", + "dry_run": true, + "data": { + "decision": { + "event_key": "vc.note.generated_v1", + "domain": "vc", + "identity": "user", + "status": "ready", + "params": {}, + "scope": "vc.note.generated_v1", + "preconditions": [ + {"name": "console_event_published", "status": "ok"}, + {"name": "scopes_granted", "status": "ok"} + ], + "preparation": { + "strategy": "legacy_preconsume", + "condition": "first_consumer_for_scope", + "action": "register_event_delivery" + }, + "would_read": ["local_bus_probe", "app_metadata_preflight"], + "would_write": ["start_or_reuse_local_bus", "register_consumer", "run_preparation_when_first", "open_event_stream"] + } + } +} +``` + +Read `dry_run` at the envelope top level (not inside data). `data.decision.status` is `ready` / `unknown` / `blocked`: `unknown` means a weak read-only check could not answer (a real run would still proceed); `blocked` means a real run would refuse with the same error shown in the precondition detail. `would_write` lists the side effects a real run performs — `preparation.condition` = `first_consumer_for_scope` means the server-side subscription is registered only by the first consumer of that scope. Error paths behave exactly like a real run (unknown key → exit 2, auth failure → exit 3). ## Subprocess contract @@ -94,7 +131,7 @@ Orchestrators should treat `reason: limit/timeout/signal` (all exit 0) as "busin ### Never `kill -9` -**Avoid `kill -9` on consume processes**: for EventKeys with a **PreConsume hook** (those that register server-side subscriptions via OAPI), `kill -9` skips the OAPI unsubscribe and leaks server-side subscriptions (symptoms: "subscription already exists" on restart, duplicate event delivery). Prefer SIGTERM or closing stdin. +**Avoid `kill -9` on consume processes** for EventKeys whose PreConsume registers a server-side subscription **and** unsubscribes on exit (minutes, vc, board keys): `kill -9` skips the OAPI unsubscribe and leaks the server-side subscription (symptoms: "subscription already exists" on restart, duplicate event delivery). Keys whose subscription is a durable relation with no cleanup (task, approval keys) do not leak this way, but SIGTERM or closing stdin remains the right shutdown for every key. ### One consume, one EventKey (multi-key = multi-shell) diff --git a/skills/lark-event/references/lark-event-vc.md b/skills/lark-event/references/lark-event-vc.md index 16679aaeb1..225f09eec9 100644 --- a/skills/lark-event/references/lark-event-vc.md +++ b/skills/lark-event/references/lark-event-vc.md @@ -2,7 +2,7 @@ > **Prerequisite:** Read [`../SKILL.md`](../SKILL.md) first for the `event consume` essentials (commands, subprocess contract, jq usage). -## Key catalog (4) +## Key catalog (7) | EventKey | Purpose | |---|---| @@ -10,8 +10,11 @@ | `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.recording.recording_started_v1` | A recording_bean recording has started (Feishu software only) | +| `vc.recording.recording_transcript_generated_v1` | Recording_bean transcript items were generated (Feishu software only) | +| `vc.recording.recording_ended_v1` | A recording_bean recording ended and uploaded successfully (Feishu software only) | -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`. +All seven 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`. ## Scopes & auth @@ -21,6 +24,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.recording.recording_started_v1` | `vc:recording:read` | user | +| `vc.recording.recording_transcript_generated_v1` | `vc:recording:read` | user | +| `vc.recording.recording_ended_v1` | `vc:recording:read` | user | --- From 5f43dd7b4ae5084aa477ab986efb9e87cead9b0a Mon Sep 17 00:00:00 2001 From: leave330 Date: Sat, 1 Aug 2026 00:38:36 +0800 Subject: [PATCH 28/61] test(event): gate dependency direction with self-checking import detectors Kernel packages are derived from the directory tree so new packages are gated the day they appear; host adapter imports are pinned by a two-way allowlist (unexpected imports fail, stale entries fail); the detectors prove themselves on synthetic violations. Header re-parsing in domain packages is pinned by a shrink-only baseline until the residue is gone. --- events/arch_test.go | 371 +++++++++++++++++++++++++++ internal/event/arch_layering_test.go | 350 +++++++++++++++++++++++++ 2 files changed, 721 insertions(+) create mode 100644 events/arch_test.go create mode 100644 internal/event/arch_layering_test.go diff --git a/events/arch_test.go b/events/arch_test.go new file mode 100644 index 0000000000..2d005ea6a1 --- /dev/null +++ b/events/arch_test.go @@ -0,0 +1,371 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Architecture gates for the events declaration layer. +// +// events/ packages are declarations: EventKeys, payload shapes, and +// processing hooks. Two kinds of rot would quietly destroy that role: +// +// 1. Importing command wiring, a transport host, or a concrete adapter turns +// declarations into another place where process and transport concerns +// accumulate, and drags the whole adapter tree into every binary that +// only wanted the catalog. +// 2. Re-parsing the envelope header inside a domain duplicates the kernel's +// single header decode; the copies then drift apart the day the envelope +// evolves. +// +// These tests turn both into build breaks. +package events_test + +import ( + "go/ast" + "go/parser" + "go/token" + "io/fs" + "path/filepath" + "reflect" + "slices" + "sort" + "strconv" + "strings" + "testing" +) + +const ( + archModulePath = "github.com/larksuite/cli" + archAdapterImportPrefix = archModulePath + "/internal/event/adapter" +) + +// archProductionGoFiles returns every non-test .go file under root, +// skipping testdata directories. Paths are relative to root. +func archProductionGoFiles(t *testing.T, root string) []string { + t.Helper() + var files []string + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if d.Name() == "testdata" { + return fs.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + files = append(files, path) + return nil + }) + if err != nil { + t.Fatalf("walk %s: %v", root, err) + } + sort.Strings(files) + return files +} + +// archForbiddenDomainImport reports why importPath is banned in events/, if +// it is. Domains may use the kernel (internal/event, model, catalog, +// processing, ...); they must never see the layers that host or transport +// them. +func archForbiddenDomainImport(importPath string) (reason string, banned bool) { + switch importPath { + case "github.com/spf13/cobra": + return "CLI framework; command wiring lives in cmd, a declaration that needs cobra has stopped being a declaration", true + case archModulePath + "/internal/event/bus": + return "bus is a host process; a domain importing its host inverts the dependency direction", true + case archModulePath + "/internal/event/consume": + return "consume is a host process; a domain importing its host inverts the dependency direction", true + } + if importPath == archAdapterImportPrefix || strings.HasPrefix(importPath, archAdapterImportPrefix+"/") { + return "concrete adapter; domains must stay transport-agnostic so any host can serve them", true + } + return "", false +} + +// TestArchEventsImportRedline fails when any production file under events/ +// imports command wiring, an event host, or a concrete adapter. It keeps the +// declaration layer linkable everywhere without pulling in transports. +func TestArchEventsImportRedline(t *testing.T) { + files := archProductionGoFiles(t, ".") + if len(files) == 0 { + t.Fatal("scanned zero production files under events/ — the gate is idling; fix the walker before trusting any green run") + } + fset := token.NewFileSet() + for _, file := range files { + f, err := parser.ParseFile(fset, file, nil, parser.ImportsOnly) + if err != nil { + t.Fatalf("parse %s: %v", file, err) + } + for _, imp := range f.Imports { + path, err := strconv.Unquote(imp.Path.Value) + if err != nil { + t.Fatalf("unquote import in %s: %v", file, err) + } + if reason, banned := archForbiddenDomainImport(path); banned { + t.Errorf("%s imports %q: %s", filepath.ToSlash(file), path, reason) + } + } + } +} + +// envelopeHeaderTags are the metadata fields the kernel decodes exactly once +// from the envelope header. A domain that re-declares any of them inside a +// json:"header" block is re-parsing the envelope instead of consuming the +// kernel's decode — the duplicate drifts silently when the envelope changes. +var envelopeHeaderTags = map[string]bool{ + "event_id": true, + "event_type": true, + "create_time": true, + "app_id": true, + "tenant_key": true, +} + +// headerReparseBaseline pins the residue that existed when this gate was +// introduced, keyed by file (relative to events/) with the header metadata +// tags each file re-parses. It is a ratchet, not an approval: +// - never add an entry — new code must read the kernel-decoded header; +// - deleting entries is always welcome, and once a file is cleaned the +// stale check below forces its entry out so the cleanup cannot regress +// unnoticed. +var headerReparseBaseline = map[string][]string{ + "application/menu.go": {"app_id", "create_time", "event_id", "event_type", "tenant_key"}, + "approval/register.go": {"create_time", "event_id", "event_type"}, + "im/card_action.go": {"create_time", "event_id", "event_type"}, + "im/message_receive.go": {"create_time", "event_id", "event_type"}, + "minutes/minute_generated.go": {"create_time", "event_id", "event_type"}, + "vc/note_generated.go": {"create_time", "event_id", "event_type"}, + "vc/participant_meeting_ended.go": {"create_time", "event_id", "event_type"}, + "vc/participant_meeting_joined.go": {"create_time", "event_id", "event_type"}, + "vc/participant_meeting_started.go": {"create_time", "event_id", "event_type"}, + "vc/recording_ended.go": {"create_time", "event_id", "event_type"}, + "vc/recording_started.go": {"create_time", "event_id", "event_type"}, + "vc/recording_transcript_generated.go": {"create_time", "event_id", "event_type"}, +} + +type archHeaderReparse struct { + file string // slash path relative to events/ + line int + field string // Go field name inside the header block + tag string // offending json tag +} + +// archJSONTagName extracts the json name (first comma segment) from a struct +// field tag, or "" when absent. +func archJSONTagName(field *ast.Field) string { + if field.Tag == nil { + return "" + } + raw, err := strconv.Unquote(field.Tag.Value) + if err != nil { + return "" + } + name, _, _ := strings.Cut(reflect.StructTag(raw).Get("json"), ",") + return name +} + +// archNamedStructIndex maps type names declared in the given files (one +// package) to their struct bodies, so a json:"header" field with a named +// type still resolves. +func archNamedStructIndex(files []*ast.File) map[string]*ast.StructType { + index := make(map[string]*ast.StructType) + for _, f := range files { + for _, decl := range f.Decls { + gen, ok := decl.(*ast.GenDecl) + if !ok || gen.Tok != token.TYPE { + continue + } + for _, spec := range gen.Specs { + ts, ok := spec.(*ast.TypeSpec) + if !ok { + continue + } + if st, ok := ts.Type.(*ast.StructType); ok { + index[ts.Name.Name] = st + } + } + } + } + return index +} + +// archStructBody resolves expr to a struct body: inline struct types, +// pointers to them, and named types declared in the same package. +func archStructBody(expr ast.Expr, named map[string]*ast.StructType) *ast.StructType { + switch v := expr.(type) { + case *ast.StructType: + return v + case *ast.StarExpr: + return archStructBody(v.X, named) + case *ast.Ident: + return named[v.Name] + } + return nil +} + +// archFindHeaderReparses flags every field inside a json:"header" struct +// block whose json tag re-declares envelope header metadata. Fields outside +// header blocks are never flagged: a domain body owning its own create_time +// (e.g. a message's own timestamps) is legitimate. +func archFindHeaderReparses(fset *token.FileSet, file *ast.File, relPath string, named map[string]*ast.StructType) []archHeaderReparse { + var found []archHeaderReparse + ast.Inspect(file, func(n ast.Node) bool { + st, ok := n.(*ast.StructType) + if !ok { + return true + } + for _, field := range st.Fields.List { + if archJSONTagName(field) != "header" { + continue + } + body := archStructBody(field.Type, named) + if body == nil { + continue + } + for _, hf := range body.Fields.List { + tag := archJSONTagName(hf) + if !envelopeHeaderTags[tag] { + continue + } + name := "(embedded)" + if len(hf.Names) > 0 { + parts := make([]string, len(hf.Names)) + for i, ident := range hf.Names { + parts[i] = ident.Name + } + name = strings.Join(parts, ",") + } + found = append(found, archHeaderReparse{ + file: relPath, + line: fset.Position(hf.Pos()).Line, + field: name, + tag: tag, + }) + } + } + return true + }) + return found +} + +// TestArchEventsNoHeaderMetadataReparse fails when a production file under +// events/ declares a json:"header" struct block that re-parses envelope +// header metadata, except for the pinned pre-existing residue in +// headerReparseBaseline (which may only shrink). +func TestArchEventsNoHeaderMetadataReparse(t *testing.T) { + files := archProductionGoFiles(t, ".") + if len(files) == 0 { + t.Fatal("scanned zero production files under events/ — the gate is idling; fix the walker before trusting any green run") + } + + // Parse per directory so named header types declared in a sibling file + // of the same package still resolve. + byDir := make(map[string][]string) + for _, file := range files { + dir := filepath.Dir(file) + byDir[dir] = append(byDir[dir], file) + } + dirs := make([]string, 0, len(byDir)) + for dir := range byDir { + dirs = append(dirs, dir) + } + sort.Strings(dirs) + + fset := token.NewFileSet() + var violations []archHeaderReparse + for _, dir := range dirs { + astFiles := make([]*ast.File, 0, len(byDir[dir])) + for _, file := range byDir[dir] { + f, err := parser.ParseFile(fset, file, nil, parser.SkipObjectResolution) + if err != nil { + t.Fatalf("parse %s: %v", file, err) + } + astFiles = append(astFiles, f) + } + named := archNamedStructIndex(astFiles) + for i, f := range astFiles { + rel := filepath.ToSlash(byDir[dir][i]) + violations = append(violations, archFindHeaderReparses(fset, f, rel, named)...) + } + } + + seen := make(map[string]bool) + for _, v := range violations { + seen[v.file+"\x00"+v.tag] = true + if slices.Contains(headerReparseBaseline[v.file], v.tag) { + continue + } + t.Errorf("%s:%d field %s re-parses envelope header metadata %q inside a json:\"header\" block — consume the kernel-decoded header instead of unmarshalling the envelope again", v.file, v.line, v.field, v.tag) + } + + // Stale baseline entries: once a file stops re-parsing a tag, its entry + // must go, otherwise the ratchet is wider than reality and the cleanup + // can silently regress. + for file, tags := range headerReparseBaseline { + for _, tag := range tags { + if !seen[file+"\x00"+tag] { + t.Errorf("stale baseline entry %s / %q: no code matches it anymore — delete the entry so the cleanup is locked in", file, tag) + } + } + } +} + +// TestArchEventsHeaderReparseDetectorSelfCheck runs the header-reparse +// detector on synthetic sources with a known violation count. If the +// detector rots (tag parsing, named-type resolution, header matching), the +// main gate would report green on a violating tree; this test makes that +// failure mode loud. +func TestArchEventsHeaderReparseDetectorSelfCheck(t *testing.T) { + parse := func(src string) []archHeaderReparse { + t.Helper() + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "synthetic.go", src, parser.SkipObjectResolution) + if err != nil { + t.Fatalf("parse synthetic source: %v", err) + } + files := []*ast.File{f} + return archFindHeaderReparses(fset, f, "synthetic.go", archNamedStructIndex(files)) + } + + const violating = `package synth + +type namedHeader struct { + AppID string ` + "`json:\"app_id\"`" + ` +} + +type envelope struct { + Header struct { + EventID string ` + "`json:\"event_id\"`" + ` + TenantKey string ` + "`json:\"tenant_key,omitempty\"`" + ` + Custom string ` + "`json:\"custom\"`" + ` + } ` + "`json:\"header,omitempty\"`" + ` + Named *namedHeader ` + "`json:\"header\"`" + ` + Body struct { + CreateTime string ` + "`json:\"create_time\"`" + ` + } ` + "`json:\"body\"`" + ` +} +` + got := parse(violating) + gotIDs := make([]string, len(got)) + for i, v := range got { + gotIDs[i] = v.field + ":" + v.tag + } + sort.Strings(gotIDs) + wantIDs := []string{"AppID:app_id", "EventID:event_id", "TenantKey:tenant_key"} + if !slices.Equal(gotIDs, wantIDs) { + t.Fatalf("detector self-check: flagged %v, want exactly %v — the detector has drifted and the main gate cannot be trusted", gotIDs, wantIDs) + } + + const clean = `package synth + +type output struct { + EventID string ` + "`json:\"event_id\"`" + ` + Header struct { + Custom string ` + "`json:\"custom\"`" + ` + } ` + "`json:\"header\"`" + ` +} +` + if got := parse(clean); len(got) != 0 { + t.Fatalf("detector self-check: clean synthetic source flagged %+v — the detector over-triggers and will produce false reds", got) + } +} diff --git a/internal/event/arch_layering_test.go b/internal/event/arch_layering_test.go new file mode 100644 index 0000000000..7d7decc7d5 --- /dev/null +++ b/internal/event/arch_layering_test.go @@ -0,0 +1,350 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Architecture layering gates for the event tree. +// +// Layout under internal/event: +// +// kernel — the root package plus model, catalog, processing, schemas, +// application/...: pure event semantics, no I/O, no framework. +// hosts — bus, consume: long-running processes that may drive exactly +// the adapters pinned in hostAdapterAllowlist. +// adapters — adapter/...: concrete transports (lark websocket, localbus). +// +// Dependencies must point inward: adapters and hosts may use the kernel, the +// kernel must never reach outward. Once a kernel package touches an adapter, +// a host, the platform SDK, or the network, every consumer of the value +// model silently links transports it never asked for, and the composition +// root loses the ability to swap them. These tests turn that drift into a +// build break. +package event_test + +import ( + "go/parser" + "go/token" + "io/fs" + "os" + "path/filepath" + "slices" + "sort" + "strconv" + "strings" + "testing" +) + +const ( + archModulePath = "github.com/larksuite/cli" + archAdapterImportPrefix = archModulePath + "/internal/event/adapter" + + // archKernelFileFloor is an idle detector, not a target: the kernel had + // 19 production files when the floor was set. If the walker, the + // outwardFacing set, or a path constant rots so the gate scans (almost) + // nothing, a green run would be meaningless — fail hard instead. + archKernelFileFloor = 15 +) + +// outwardFacing lists the internal/event subtrees exempt from kernel purity, +// each with the reason it may face outward. Everything else — including any +// directory added later — is kernel by default and governed by +// TestArchKernelPurity without anyone remembering to opt in. +var outwardFacing = map[string]string{ + "adapter": "concrete transports (lark websocket, localbus); the outermost ring", + "bus": "host process owning the local bus lifecycle", + "consume": "host process owning the consumer loop", + "testutil": "shared test fakes; never linked into production binaries", +} + +// archForbiddenKernelImport reports why importPath is banned inside the +// kernel, if it is. TestArchKernelImportDetectorSelfCheck exercises this +// function on synthetic sources so a drifted matcher cannot keep reporting +// green. +func archForbiddenKernelImport(importPath string) (reason string, banned bool) { + switch { + case importPath == "github.com/spf13/cobra": + return "CLI framework; command wiring lives in cmd, not in event semantics", true + case strings.HasPrefix(importPath, "github.com/larksuite/oapi-sdk-go"): + return "platform SDK; only adapters may speak the platform wire format", true + case importPath == "net" || strings.HasPrefix(importPath, "net/"): + return "networking (any net or net/* package, net/url included); kernel logic must stay I/O-free so any host can embed it", true + case importPath == archModulePath+"/internal/event/bus", + importPath == archModulePath+"/internal/event/consume": + return "host package; the kernel calling its host inverts the dependency direction", true + case importPath == archAdapterImportPrefix || strings.HasPrefix(importPath, archAdapterImportPrefix+"/"): + return "concrete adapter; depend on a port and let the composition root inject the implementation", true + } + return "", false +} + +// archKernelDirs derives the kernel directory set from the tree itself: +// every directory under internal/event (the root included) minus the +// outwardFacing subtrees and testdata. Deriving instead of enumerating means +// a newly added package is governed by default — the gate can only lose +// coverage through an explicit outwardFacing edit, never through forgetting. +func archKernelDirs(t *testing.T) []string { + t.Helper() + var dirs []string + err := filepath.WalkDir(".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() { + return nil + } + rel := filepath.ToSlash(path) + if rel == "." { + dirs = append(dirs, rel) + return nil + } + if d.Name() == "testdata" { + return fs.SkipDir + } + top, _, _ := strings.Cut(rel, "/") + if _, outward := outwardFacing[top]; outward { + return fs.SkipDir + } + dirs = append(dirs, rel) + return nil + }) + if err != nil { + t.Fatalf("walk internal/event: %v", err) + } + sort.Strings(dirs) + return dirs +} + +// archProductionFilesUnder returns every non-test .go file under root, +// skipping testdata directories. +func archProductionFilesUnder(t *testing.T, root string) []string { + t.Helper() + var files []string + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if d.Name() == "testdata" { + return fs.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + files = append(files, path) + return nil + }) + if err != nil { + t.Fatalf("walk %s: %v", root, err) + } + sort.Strings(files) + return files +} + +// archImports parses one file with ImportsOnly and returns its import paths. +func archImports(t *testing.T, fset *token.FileSet, file string) []string { + t.Helper() + f, err := parser.ParseFile(fset, file, nil, parser.ImportsOnly) + if err != nil { + t.Fatalf("parse %s: %v", file, err) + } + paths := make([]string, 0, len(f.Imports)) + for _, imp := range f.Imports { + path, err := strconv.Unquote(imp.Path.Value) + if err != nil { + t.Fatalf("unquote import in %s: %v", file, err) + } + paths = append(paths, path) + } + return paths +} + +// TestArchKernelPurity fails when any kernel production file imports the CLI +// framework, the platform SDK, the network, a host, or an adapter. The +// kernel set is derived from the directory tree, with two tripwires against +// the gate itself being hollowed out. +func TestArchKernelPurity(t *testing.T) { + // Tripwire: this gate proves the kernel/adapter boundary holds. If the + // adapter tree is gone the boundary does not exist and a green run + // proves nothing — someone moved the transports without moving the gate. + if fi, err := os.Stat("adapter"); err != nil || !fi.IsDir() { + t.Fatalf("internal/event/adapter does not exist (%v) — the boundary this gate guards is gone; relocate the gate together with the adapters", err) + } + + kernelDirs := archKernelDirs(t) + + // Tripwire: deriving the kernel set means moving a package out of the + // tree silently shrinks coverage. Pin the named kernel packages so + // "make the gate green by relocating the offender" fails loudly. + for _, required := range []string{"application", "catalog", "model", "processing", "schemas"} { + if !slices.Contains(kernelDirs, required) { + t.Fatalf("kernel package %q missing from derived gate set %v — if it genuinely moved, update this gate deliberately instead of letting coverage shrink", required, kernelDirs) + } + } + + fset := token.NewFileSet() + parsed := 0 + for _, dir := range kernelDirs { + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("read %s: %v", dir, err) + } + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + file := filepath.Join(dir, name) + parsed++ + for _, path := range archImports(t, fset, file) { + if reason, banned := archForbiddenKernelImport(path); banned { + t.Errorf("%s imports %q: %s", filepath.ToSlash(file), path, reason) + } + } + } + } + if parsed < archKernelFileFloor { + t.Fatalf("parsed only %d kernel production files (floor %d) — the walker or the outwardFacing set is eating the tree; a gate that scans nothing reports green forever", parsed, archKernelFileFloor) + } +} + +// hostAdapterAllowlist pins, per host, the exact adapter import paths its +// production code uses today. +// +// This is a ceiling, not a design: deleting an entry is always welcome; +// adding one means a host wired in another adapter, and the question to ask +// is whether that capability should instead be a port owned by the host and +// injected by the cmd/event composition root. +var hostAdapterAllowlist = map[string][]string{ + "bus": { + archAdapterImportPrefix + "/localbus/busdiscover", + archAdapterImportPrefix + "/localbus/protocol", + archAdapterImportPrefix + "/localbus/transport", + }, + "consume": { + archAdapterImportPrefix + "/localbus/protocol", + archAdapterImportPrefix + "/localbus/transport", + }, +} + +// TestArchHostAdapterAllowlist checks the host→adapter edges in both +// directions: an adapter import outside the allowlist is a new coupling that +// skipped review, and an allowlist entry no adapter import matches is a +// ceiling wider than reality — it would let a removed dependency come back +// without anyone looking. +func TestArchHostAdapterAllowlist(t *testing.T) { + hosts := make([]string, 0, len(hostAdapterAllowlist)) + for host := range hostAdapterAllowlist { + hosts = append(hosts, host) + } + sort.Strings(hosts) + + for _, host := range hosts { + // An allowlist key that is not an outward-facing package is dead + // text: the kernel purity gate already bans those dirs from touching + // adapters, so the entry would silently grant nothing to no one. + if _, ok := outwardFacing[host]; !ok { + t.Errorf("hostAdapterAllowlist key %q is not in the outwardFacing set — the entry is dead text; either the host moved or the entry must go", host) + continue + } + + files := archProductionFilesUnder(t, host) + if len(files) == 0 { + t.Errorf("no production files found under %s — the walker is idling or the host is gone; fix that before trusting this gate", host) + continue + } + + allowed := make(map[string]bool, len(hostAdapterAllowlist[host])) + for _, path := range hostAdapterAllowlist[host] { + allowed[path] = true + } + used := make(map[string]bool) + + fset := token.NewFileSet() + for _, file := range files { + for _, path := range archImports(t, fset, file) { + if path != archAdapterImportPrefix && !strings.HasPrefix(path, archAdapterImportPrefix+"/") { + continue + } + if allowed[path] { + used[path] = true + continue + } + msg := "declare the needed capability as a port owned by this package and let the cmd/event composition root inject the implementation" + if strings.HasPrefix(path, archAdapterImportPrefix+"/lark") { + msg += " — a host wired straight to the platform adapter bypasses the bus, which is the reason the bus exists" + } + t.Errorf("%s imports %q outside hostAdapterAllowlist[%q]: %s", filepath.ToSlash(file), path, host, msg) + } + } + + for _, path := range hostAdapterAllowlist[host] { + if !used[path] { + t.Errorf("stale hostAdapterAllowlist entry %q for host %q: no production file imports it — delete the entry so a re-introduction has to pass review", path, host) + } + } + } +} + +// TestArchKernelImportDetectorSelfCheck runs the kernel forbidden-import +// matcher on synthetic sources with known outcomes. If a path constant +// drifts from the real package layout, TestArchKernelPurity would keep +// reporting green on a violating tree; this test makes that failure mode a +// build break of its own. +func TestArchKernelImportDetectorSelfCheck(t *testing.T) { + scan := func(src string) []string { + t.Helper() + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "synthetic.go", src, parser.ImportsOnly) + if err != nil { + t.Fatalf("parse synthetic source: %v", err) + } + var flagged []string + for _, imp := range f.Imports { + path, err := strconv.Unquote(imp.Path.Value) + if err != nil { + t.Fatalf("unquote synthetic import: %v", err) + } + if _, banned := archForbiddenKernelImport(path); banned { + flagged = append(flagged, path) + } + } + sort.Strings(flagged) + return flagged + } + + const violating = `package fakekernel + +import ( + _ "context" + _ "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" + _ "github.com/larksuite/cli/internal/event/bus" + _ "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" + _ "github.com/spf13/cobra" + _ "net/http" +) +` + want := []string{ + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol", + "github.com/larksuite/cli/internal/event/bus", + "github.com/larksuite/oapi-sdk-go/v3/service/im/v1", + "github.com/spf13/cobra", + "net/http", + } + if got := scan(violating); !slices.Equal(got, want) { + t.Fatalf("detector self-check: flagged %v, want exactly %v — the matcher drifted from the package layout and the purity gate cannot be trusted", got, want) + } + + const clean = `package fakekernel + +import ( + _ "context" + _ "encoding/json" + _ "github.com/larksuite/cli/internal/event/adapterlike" + _ "github.com/larksuite/cli/internal/event/model" + _ "network" +) +` + if got := scan(clean); len(got) != 0 { + t.Fatalf("detector self-check: clean synthetic source flagged %v — the matcher over-triggers (prefix matching must respect path segment boundaries)", got) + } +} From 1da32234032f1784648be00fd982d8726edcc022 Mon Sep 17 00:00:00 2001 From: leave330 Date: Sat, 1 Aug 2026 00:49:22 +0800 Subject: [PATCH 29/61] refactor(event): read canonical metadata instead of re-parsing payload headers Domain processors now take event id, type, source time, and tenant identity from the canonical event the pipeline restored; the per-file header envelope blocks are gone and the header re-parse gate is zero-tolerance. The per-key stdout baseline is byte-identical. --- events/application/menu.go | 15 +++------- events/application/menu_test.go | 29 +++++++++++++++++++ events/approval/register.go | 28 ++++-------------- events/approval/register_test.go | 26 +++++++++++++++++ events/arch_test.go | 27 ++++------------- events/im/card_action.go | 11 ++----- events/im/card_action_test.go | 1 + events/im/catalog_helper_test.go | 25 ++++++++++++++++ events/im/message_receive.go | 11 ++----- events/im/message_receive_test.go | 2 ++ events/minutes/minute_generated.go | 14 ++------- events/minutes/minute_generated_test.go | 25 ++++++++++++++++ events/output_baseline_test.go | 5 ++++ events/vc/note_generated.go | 14 ++------- events/vc/note_generated_test.go | 1 + events/vc/participant_meeting_ended.go | 14 ++------- events/vc/participant_meeting_ended_test.go | 1 + events/vc/participant_meeting_joined.go | 14 ++------- .../vc/participant_meeting_lifecycle_test.go | 1 + events/vc/participant_meeting_started.go | 14 ++------- events/vc/recording_ended.go | 18 ++---------- events/vc/recording_started.go | 18 ++---------- events/vc/recording_test.go | 1 + events/vc/recording_transcript_generated.go | 18 ++---------- events/vc/test_helpers_test.go | 26 +++++++++++++++++ 25 files changed, 189 insertions(+), 170 deletions(-) diff --git a/events/application/menu.go b/events/application/menu.go index ed410e981b..fa293faea7 100644 --- a/events/application/menu.go +++ b/events/application/menu.go @@ -30,13 +30,6 @@ type BotMenuOutput struct { func processBotMenu(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) { var envelope struct { - Header struct { - EventID string `json:"event_id"` - EventType string `json:"event_type"` - CreateTime string `json:"create_time"` - AppID string `json:"app_id"` - TenantKey string `json:"tenant_key"` - } `json:"header"` Event struct { EventKey string `json:"event_key"` Timestamp json.RawMessage `json:"timestamp"` @@ -55,7 +48,7 @@ func processBotMenu(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ } menuTimestamp := timestampMillisString(envelope.Event.Timestamp) - timestamp := envelope.Header.CreateTime + timestamp := raw.SourceTime if timestamp == "" { timestamp = menuTimestamp } @@ -63,10 +56,10 @@ func processBotMenu(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ out := &BotMenuOutput{ Type: eventTypeBotMenuV6, - EventID: envelope.Header.EventID, + EventID: raw.EventID, Timestamp: timestamp, - AppID: envelope.Header.AppID, - TenantKey: envelope.Header.TenantKey, + AppID: raw.AppID, + TenantKey: raw.TenantKey, EventKey: envelope.Event.EventKey, MenuTimestamp: menuTimestamp, OperatorID: operatorID, diff --git a/events/application/menu_test.go b/events/application/menu_test.go index 7fa5c34eb1..dd4dafbb13 100644 --- a/events/application/menu_test.go +++ b/events/application/menu_test.go @@ -210,6 +210,34 @@ func TestProcessBotMenuMalformedPayload(t *testing.T) { } } +// fillCanonicalFromHeader copies the payload envelope header metadata onto +// the RawEvent canonical fields. Process handlers read event_id, create_time, +// app_id, and tenant_key from the RawEvent, which the consume pipeline fills +// from the envelope header before dispatch; tests that hand-build a RawEvent +// must mirror that so both views agree. +func fillCanonicalFromHeader(t *testing.T, raw *event.RawEvent) { + t.Helper() + var envelope struct { + Header struct { + EventID string `json:"event_id"` + EventType string `json:"event_type"` + CreateTime string `json:"create_time"` + AppID string `json:"app_id"` + TenantKey string `json:"tenant_key"` + } `json:"header"` + } + if err := json.Unmarshal(raw.Payload, &envelope); err != nil { + t.Fatalf("parse envelope header: %v", err) + } + raw.EventID = envelope.Header.EventID + if envelope.Header.EventType != "" { + raw.EventType = envelope.Header.EventType + } + raw.SourceTime = envelope.Header.CreateTime + raw.AppID = envelope.Header.AppID + raw.TenantKey = envelope.Header.TenantKey +} + func runBotMenu(t *testing.T, payload string) BotMenuOutput { t.Helper() raw := &event.RawEvent{ @@ -218,6 +246,7 @@ func runBotMenu(t *testing.T, payload string) BotMenuOutput { Payload: json.RawMessage(payload), Timestamp: time.Now(), } + fillCanonicalFromHeader(t, raw) got, err := processBotMenu(context.Background(), nil, raw, nil) if err != nil { t.Fatalf("processBotMenu: %v", err) diff --git a/events/approval/register.go b/events/approval/register.go index a31be1a671..92522ca983 100644 --- a/events/approval/register.go +++ b/events/approval/register.go @@ -100,11 +100,6 @@ func processApprovalInstanceStatusChanged(_ context.Context, _ event.APIClient, return nil, nil } var envelope struct { - Header struct { - EventID string `json:"event_id"` - EventType string `json:"event_type"` - CreateTime string `json:"create_time"` - } `json:"header"` Event struct { ApprovalCode string `json:"approval_code"` InstanceCode string `json:"instance_code"` @@ -119,9 +114,9 @@ func processApprovalInstanceStatusChanged(_ context.Context, _ event.APIClient, } out := &ApprovalInstanceStatusChangedV4Output{ - Type: envelope.Header.EventType, - EventID: envelope.Header.EventID, - Timestamp: envelope.Header.CreateTime, + Type: raw.EventType, + EventID: raw.EventID, + Timestamp: raw.SourceTime, ApprovalCode: envelope.Event.ApprovalCode, InstanceCode: envelope.Event.InstanceCode, ExternalID: envelope.Event.ExternalID, @@ -129,9 +124,6 @@ func processApprovalInstanceStatusChanged(_ context.Context, _ event.APIClient, OperateTime: envelope.Event.OperateTime, StartUser: envelope.Event.StartUser, } - if out.Type == "" { - out.Type = raw.EventType - } return json.Marshal(out) } @@ -140,11 +132,6 @@ func processApprovalTaskStatusChanged(_ context.Context, _ event.APIClient, raw return nil, nil } var envelope struct { - Header struct { - EventID string `json:"event_id"` - EventType string `json:"event_type"` - CreateTime string `json:"create_time"` - } `json:"header"` Event struct { ApprovalCode string `json:"approval_code"` InstanceCode string `json:"instance_code"` @@ -161,9 +148,9 @@ func processApprovalTaskStatusChanged(_ context.Context, _ event.APIClient, raw } out := &ApprovalTaskStatusChangedV4Output{ - Type: envelope.Header.EventType, - EventID: envelope.Header.EventID, - Timestamp: envelope.Header.CreateTime, + Type: raw.EventType, + EventID: raw.EventID, + Timestamp: raw.SourceTime, ApprovalCode: envelope.Event.ApprovalCode, InstanceCode: envelope.Event.InstanceCode, TaskID: envelope.Event.TaskID, @@ -173,8 +160,5 @@ func processApprovalTaskStatusChanged(_ context.Context, _ event.APIClient, raw Status: envelope.Event.Status, OperateTime: envelope.Event.OperateTime, } - if out.Type == "" { - out.Type = raw.EventType - } return json.Marshal(out) } diff --git a/events/approval/register_test.go b/events/approval/register_test.go index e0de51b649..324730ff6e 100644 --- a/events/approval/register_test.go +++ b/events/approval/register_test.go @@ -601,6 +601,30 @@ func TestProcessApprovalStatusChangedNilRaw(t *testing.T) { } } +// fillCanonicalFromHeader copies the payload envelope header metadata onto +// the RawEvent canonical fields. Process handlers read event_id and +// create_time from the RawEvent, which the consume pipeline fills from the +// envelope header before dispatch; tests that hand-build a RawEvent must +// mirror that so both views agree. +func fillCanonicalFromHeader(t *testing.T, raw *event.RawEvent) { + t.Helper() + var envelope struct { + Header struct { + EventID string `json:"event_id"` + EventType string `json:"event_type"` + CreateTime string `json:"create_time"` + } `json:"header"` + } + if err := json.Unmarshal(raw.Payload, &envelope); err != nil { + t.Fatalf("parse envelope header: %v", err) + } + raw.EventID = envelope.Header.EventID + if envelope.Header.EventType != "" { + raw.EventType = envelope.Header.EventType + } + raw.SourceTime = envelope.Header.CreateTime +} + func runApprovalInstanceStatusChanged(t *testing.T, payload string) ApprovalInstanceStatusChangedV4Output { t.Helper() raw := &event.RawEvent{ @@ -608,6 +632,7 @@ func runApprovalInstanceStatusChanged(t *testing.T, payload string) ApprovalInst Payload: json.RawMessage(payload), Timestamp: time.Now(), } + fillCanonicalFromHeader(t, raw) got, err := processApprovalInstanceStatusChanged(context.Background(), nil, raw, nil) if err != nil { t.Fatalf("Process returned error: %v", err) @@ -626,6 +651,7 @@ func runApprovalTaskStatusChanged(t *testing.T, payload string) ApprovalTaskStat Payload: json.RawMessage(payload), Timestamp: time.Now(), } + fillCanonicalFromHeader(t, raw) got, err := processApprovalTaskStatusChanged(context.Background(), nil, raw, nil) if err != nil { t.Fatalf("Process returned error: %v", err) diff --git a/events/arch_test.go b/events/arch_test.go index 2d005ea6a1..8572611acb 100644 --- a/events/arch_test.go +++ b/events/arch_test.go @@ -121,27 +121,12 @@ var envelopeHeaderTags = map[string]bool{ "tenant_key": true, } -// headerReparseBaseline pins the residue that existed when this gate was -// introduced, keyed by file (relative to events/) with the header metadata -// tags each file re-parses. It is a ratchet, not an approval: -// - never add an entry — new code must read the kernel-decoded header; -// - deleting entries is always welcome, and once a file is cleaned the -// stale check below forces its entry out so the cleanup cannot regress -// unnoticed. -var headerReparseBaseline = map[string][]string{ - "application/menu.go": {"app_id", "create_time", "event_id", "event_type", "tenant_key"}, - "approval/register.go": {"create_time", "event_id", "event_type"}, - "im/card_action.go": {"create_time", "event_id", "event_type"}, - "im/message_receive.go": {"create_time", "event_id", "event_type"}, - "minutes/minute_generated.go": {"create_time", "event_id", "event_type"}, - "vc/note_generated.go": {"create_time", "event_id", "event_type"}, - "vc/participant_meeting_ended.go": {"create_time", "event_id", "event_type"}, - "vc/participant_meeting_joined.go": {"create_time", "event_id", "event_type"}, - "vc/participant_meeting_started.go": {"create_time", "event_id", "event_type"}, - "vc/recording_ended.go": {"create_time", "event_id", "event_type"}, - "vc/recording_started.go": {"create_time", "event_id", "event_type"}, - "vc/recording_transcript_generated.go": {"create_time", "event_id", "event_type"}, -} +// headerReparseBaseline is the ratchet of pinned pre-existing residue, keyed +// by file (relative to events/) with the header metadata tags it re-parses. +// It is empty: every domain consumes the kernel-decoded header, so the gate +// runs at zero tolerance. Never add an entry — new code must read the +// kernel-decoded header instead of unmarshalling the envelope again. +var headerReparseBaseline = map[string][]string{} type archHeaderReparse struct { file string // slash path relative to events/ diff --git a/events/im/card_action.go b/events/im/card_action.go index d125f8eafa..678477c821 100644 --- a/events/im/card_action.go +++ b/events/im/card_action.go @@ -36,11 +36,6 @@ type CardActionTriggerOutput struct { func processCardAction(ctx context.Context, rt event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) { var envelope struct { - Header struct { - EventID string `json:"event_id"` - EventType string `json:"event_type"` - CreateTime string `json:"create_time"` - } `json:"header"` Event struct { Operator struct { OpenID string `json:"open_id"` @@ -73,9 +68,9 @@ func processCardAction(ctx context.Context, rt event.APIClient, raw *event.RawEv options := strings.Join(envelope.Event.Action.Options, ",") out := &CardActionTriggerOutput{ - Type: envelope.Header.EventType, - EventID: envelope.Header.EventID, - Timestamp: envelope.Header.CreateTime, + Type: raw.EventType, + EventID: raw.EventID, + Timestamp: raw.SourceTime, OperatorID: envelope.Event.Operator.OpenID, MessageID: envelope.Event.Context.OpenMessageID, ChatID: envelope.Event.Context.OpenChatID, diff --git a/events/im/card_action_test.go b/events/im/card_action_test.go index 31968ddd16..ceb7c3d650 100644 --- a/events/im/card_action_test.go +++ b/events/im/card_action_test.go @@ -416,6 +416,7 @@ func runCardAction(t *testing.T, payload string, rt event.APIClient) CardActionT Payload: json.RawMessage(payload), Timestamp: time.Now(), } + fillCanonicalFromHeader(t, raw) got, err := processCardAction(context.Background(), rt, raw, nil) if err != nil { t.Fatalf("Process error: %v", err) diff --git a/events/im/catalog_helper_test.go b/events/im/catalog_helper_test.go index 013849d84a..6061d30e3d 100644 --- a/events/im/catalog_helper_test.go +++ b/events/im/catalog_helper_test.go @@ -4,12 +4,37 @@ package im import ( + "encoding/json" "testing" "github.com/larksuite/cli/internal/event" "github.com/larksuite/cli/internal/event/catalog" ) +// fillCanonicalFromHeader copies the payload envelope header metadata onto +// the RawEvent canonical fields. Process handlers read event_id and +// create_time from the RawEvent, which the consume pipeline fills from the +// envelope header before dispatch; tests that hand-build a RawEvent must +// mirror that so both views agree. +func fillCanonicalFromHeader(t *testing.T, raw *event.RawEvent) { + t.Helper() + var envelope struct { + Header struct { + EventID string `json:"event_id"` + EventType string `json:"event_type"` + CreateTime string `json:"create_time"` + } `json:"header"` + } + if err := json.Unmarshal(raw.Payload, &envelope); err != nil { + t.Fatalf("parse envelope header: %v", err) + } + raw.EventID = envelope.Header.EventID + if envelope.Header.EventType != "" { + raw.EventType = envelope.Header.EventType + } + raw.SourceTime = envelope.Header.CreateTime +} + // lookupCompiledDef compiles this domain's declarations and resolves one key, // exactly as the runtime catalog would for a consumer. func lookupCompiledDef(t *testing.T, key string) (*event.KeyDefinition, bool) { diff --git a/events/im/message_receive.go b/events/im/message_receive.go index b1d9091e10..ddad4b4b96 100644 --- a/events/im/message_receive.go +++ b/events/im/message_receive.go @@ -41,11 +41,6 @@ type MentionOutput struct { func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) { var envelope struct { - Header struct { - EventID string `json:"event_id"` - EventType string `json:"event_type"` - CreateTime string `json:"create_time"` - } `json:"header"` Event struct { Message struct { MessageID string `json:"message_id"` @@ -83,14 +78,14 @@ func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.Ra }) } - timestamp := envelope.Header.CreateTime + timestamp := raw.SourceTime if timestamp == "" { timestamp = msg.CreateTime } out := &ImMessageReceiveOutput{ - Type: envelope.Header.EventType, - EventID: envelope.Header.EventID, + Type: raw.EventType, + EventID: raw.EventID, Timestamp: timestamp, ID: msg.MessageID, MessageID: msg.MessageID, diff --git a/events/im/message_receive_test.go b/events/im/message_receive_test.go index 20d44ee09f..0321bb766f 100644 --- a/events/im/message_receive_test.go +++ b/events/im/message_receive_test.go @@ -241,6 +241,7 @@ func runReceive(t *testing.T, payload string) ImMessageReceiveOutput { Payload: json.RawMessage(payload), Timestamp: time.Now(), } + fillCanonicalFromHeader(t, raw) got, err := processImMessageReceive(context.Background(), nil, raw, nil) if err != nil { t.Fatalf("Process error: %v", err) @@ -260,6 +261,7 @@ func runReceiveMap(t *testing.T, payload string) map[string]interface{} { Payload: json.RawMessage(payload), Timestamp: time.Now(), } + fillCanonicalFromHeader(t, raw) got, err := processImMessageReceive(context.Background(), nil, raw, nil) if err != nil { t.Fatalf("Process error: %v", err) diff --git a/events/minutes/minute_generated.go b/events/minutes/minute_generated.go index 8c4260a05c..42ac3e690c 100644 --- a/events/minutes/minute_generated.go +++ b/events/minutes/minute_generated.go @@ -37,11 +37,6 @@ type MinutesMinuteGeneratedOutput struct { func processMinutesMinuteGenerated(ctx context.Context, rt event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) { var envelope struct { - Header struct { - EventID string `json:"event_id"` - EventType string `json:"event_type"` - CreateTime string `json:"create_time"` - } `json:"header"` Event struct { MinuteToken string `json:"minute_token"` MinuteSource struct { @@ -55,14 +50,11 @@ func processMinutesMinuteGenerated(ctx context.Context, rt event.APIClient, raw } out := &MinutesMinuteGeneratedOutput{ - Type: envelope.Header.EventType, - EventID: envelope.Header.EventID, - Timestamp: envelope.Header.CreateTime, + Type: raw.EventType, + EventID: raw.EventID, + Timestamp: raw.SourceTime, MinuteToken: envelope.Event.MinuteToken, } - if out.Type == "" { - out.Type = raw.EventType - } if src := envelope.Event.MinuteSource; src.SourceType != "" || src.SourceEntityID != "" { out.MinuteSource = &MinutesMinuteSourceOutput{ SourceType: src.SourceType, diff --git a/events/minutes/minute_generated_test.go b/events/minutes/minute_generated_test.go index e967af8408..6bfbe08824 100644 --- a/events/minutes/minute_generated_test.go +++ b/events/minutes/minute_generated_test.go @@ -327,6 +327,30 @@ func TestProcessMinutesMinuteGenerated_MalformedPayload(t *testing.T) { } } +// fillCanonicalFromHeader copies the payload envelope header metadata onto +// the RawEvent canonical fields. Process handlers read event_id and +// create_time from the RawEvent, which the consume pipeline fills from the +// envelope header before dispatch; tests that hand-build a RawEvent must +// mirror that so both views agree. +func fillCanonicalFromHeader(t *testing.T, raw *event.RawEvent) { + t.Helper() + var envelope struct { + Header struct { + EventID string `json:"event_id"` + EventType string `json:"event_type"` + CreateTime string `json:"create_time"` + } `json:"header"` + } + if err := json.Unmarshal(raw.Payload, &envelope); err != nil { + t.Fatalf("parse envelope header: %v", err) + } + raw.EventID = envelope.Header.EventID + if envelope.Header.EventType != "" { + raw.EventType = envelope.Header.EventType + } + raw.SourceTime = envelope.Header.CreateTime +} + func runMinuteGenerated(t *testing.T, rt event.APIClient, payload string) MinutesMinuteGeneratedOutput { t.Helper() raw := &event.RawEvent{ @@ -334,6 +358,7 @@ func runMinuteGenerated(t *testing.T, rt event.APIClient, payload string) Minute Payload: json.RawMessage(payload), Timestamp: time.Now(), } + fillCanonicalFromHeader(t, raw) got, err := processMinutesMinuteGenerated(context.Background(), rt, raw, nil) if err != nil { t.Fatalf("Process error: %v", err) diff --git a/events/output_baseline_test.go b/events/output_baseline_test.go index 483c3af99f..3e0b2b8cb4 100644 --- a/events/output_baseline_test.go +++ b/events/output_baseline_test.go @@ -322,10 +322,15 @@ func TestProcessedOutputBaseline(t *testing.T) { seenFixtures[def.Key] = true payload := buildBaselineEnvelope(t, def.EventType, fx) + // The canonical fields mirror the synthetic envelope header exactly, + // including any extra header fields, just as the consume pipeline + // guarantees for real events before Process runs. raw := &event.RawEvent{ EventID: baselineEventID, EventType: def.EventType, SourceTime: baselineCreateTime, + AppID: fx.extraHeader["app_id"], + TenantKey: fx.extraHeader["tenant_key"], Payload: payload, Timestamp: time.Unix(1700000000, 0).UTC(), } diff --git a/events/vc/note_generated.go b/events/vc/note_generated.go index ce79888409..122dda124e 100644 --- a/events/vc/note_generated.go +++ b/events/vc/note_generated.go @@ -43,11 +43,6 @@ type VCNoteGeneratedOutput struct { func processVCNoteGenerated(ctx context.Context, rt event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) { var envelope struct { - Header struct { - EventID string `json:"event_id"` - EventType string `json:"event_type"` - CreateTime string `json:"create_time"` - } `json:"header"` Event struct { NoteID string `json:"note_id"` } `json:"event"` @@ -57,14 +52,11 @@ func processVCNoteGenerated(ctx context.Context, rt event.APIClient, raw *event. } out := &VCNoteGeneratedOutput{ - Type: envelope.Header.EventType, - EventID: envelope.Header.EventID, - Timestamp: envelope.Header.CreateTime, + Type: raw.EventType, + EventID: raw.EventID, + Timestamp: raw.SourceTime, NoteID: envelope.Event.NoteID, } - if out.Type == "" { - out.Type = raw.EventType - } if rt != nil && out.NoteID != "" { fillVCNoteGeneratedDetails(ctx, rt, out) diff --git a/events/vc/note_generated_test.go b/events/vc/note_generated_test.go index 17e3430c5d..9557604c7e 100644 --- a/events/vc/note_generated_test.go +++ b/events/vc/note_generated_test.go @@ -317,6 +317,7 @@ func runNoteGenerated(t *testing.T, rt event.APIClient, payload string) VCNoteGe Payload: json.RawMessage(payload), Timestamp: time.Now(), } + fillCanonicalFromHeader(t, raw) got, err := processVCNoteGenerated(context.Background(), rt, raw, nil) if err != nil { t.Fatalf("Process error: %v", err) diff --git a/events/vc/participant_meeting_ended.go b/events/vc/participant_meeting_ended.go index a26b2ace11..c3a275ee90 100644 --- a/events/vc/participant_meeting_ended.go +++ b/events/vc/participant_meeting_ended.go @@ -28,11 +28,6 @@ type VCParticipantMeetingEndedOutput struct { func processVCParticipantMeetingEnded(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) { var envelope struct { - Header struct { - EventID string `json:"event_id"` - EventType string `json:"event_type"` - CreateTime string `json:"create_time"` - } `json:"header"` Event struct { Meeting struct { ID string `json:"id"` @@ -50,9 +45,9 @@ func processVCParticipantMeetingEnded(_ context.Context, _ event.APIClient, raw meeting := envelope.Event.Meeting out := &VCParticipantMeetingEndedOutput{ - Type: envelope.Header.EventType, - EventID: envelope.Header.EventID, - Timestamp: envelope.Header.CreateTime, + Type: raw.EventType, + EventID: raw.EventID, + Timestamp: raw.SourceTime, MeetingID: meeting.ID, Topic: meeting.Topic, MeetingNo: meeting.MeetingNo, @@ -60,9 +55,6 @@ func processVCParticipantMeetingEnded(_ context.Context, _ event.APIClient, raw EndTime: unixSecondsToLocalRFC3339(meeting.EndTime), CalendarEventID: meeting.CalendarEventID, } - if out.Type == "" { - out.Type = raw.EventType - } return json.Marshal(out) } diff --git a/events/vc/participant_meeting_ended_test.go b/events/vc/participant_meeting_ended_test.go index 70f26e9802..e21caf41fc 100644 --- a/events/vc/participant_meeting_ended_test.go +++ b/events/vc/participant_meeting_ended_test.go @@ -184,6 +184,7 @@ func runMeetingEnded(t *testing.T, payload string) VCParticipantMeetingEndedOutp Payload: json.RawMessage(payload), Timestamp: time.Now(), } + fillCanonicalFromHeader(t, raw) got, err := processVCParticipantMeetingEnded(context.Background(), nil, raw, nil) if err != nil { t.Fatalf("Process error: %v", err) diff --git a/events/vc/participant_meeting_joined.go b/events/vc/participant_meeting_joined.go index 804b5ce040..a2824eded9 100644 --- a/events/vc/participant_meeting_joined.go +++ b/events/vc/participant_meeting_joined.go @@ -25,11 +25,6 @@ type VCParticipantMeetingJoinedOutput struct { func processVCParticipantMeetingJoined(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) { var envelope struct { - Header struct { - EventID string `json:"event_id"` - EventType string `json:"event_type"` - CreateTime string `json:"create_time"` - } `json:"header"` Event struct { Meeting struct { ID string `json:"id"` @@ -47,17 +42,14 @@ func processVCParticipantMeetingJoined(_ context.Context, _ event.APIClient, raw meeting := envelope.Event.Meeting out := &VCParticipantMeetingJoinedOutput{ - Type: envelope.Header.EventType, - EventID: envelope.Header.EventID, - Timestamp: envelope.Header.CreateTime, + Type: raw.EventType, + EventID: raw.EventID, + Timestamp: raw.SourceTime, MeetingID: meeting.ID, Topic: meeting.Topic, MeetingNo: meeting.MeetingNo, StartTime: unixSecondsToLocalRFC3339(meeting.StartTime), CalendarEventID: meeting.CalendarEventID, } - if out.Type == "" { - out.Type = raw.EventType - } return json.Marshal(out) } diff --git a/events/vc/participant_meeting_lifecycle_test.go b/events/vc/participant_meeting_lifecycle_test.go index 92370123d9..224d50a861 100644 --- a/events/vc/participant_meeting_lifecycle_test.go +++ b/events/vc/participant_meeting_lifecycle_test.go @@ -274,6 +274,7 @@ func runMeetingLifecycleRaw(t *testing.T, eventType string, process event.Proces Payload: json.RawMessage(payload), Timestamp: time.Now(), } + fillCanonicalFromHeader(t, raw) got, err := process(context.Background(), nil, raw, nil) if err != nil { t.Fatalf("Process error: %v", err) diff --git a/events/vc/participant_meeting_started.go b/events/vc/participant_meeting_started.go index 5e2a2aaf46..034cc22fc0 100644 --- a/events/vc/participant_meeting_started.go +++ b/events/vc/participant_meeting_started.go @@ -25,11 +25,6 @@ type VCParticipantMeetingStartedOutput struct { func processVCParticipantMeetingStarted(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) { var envelope struct { - Header struct { - EventID string `json:"event_id"` - EventType string `json:"event_type"` - CreateTime string `json:"create_time"` - } `json:"header"` Event struct { Meeting struct { ID string `json:"id"` @@ -46,17 +41,14 @@ func processVCParticipantMeetingStarted(_ context.Context, _ event.APIClient, ra meeting := envelope.Event.Meeting out := &VCParticipantMeetingStartedOutput{ - Type: envelope.Header.EventType, - EventID: envelope.Header.EventID, - Timestamp: envelope.Header.CreateTime, + Type: raw.EventType, + EventID: raw.EventID, + Timestamp: raw.SourceTime, MeetingID: meeting.ID, Topic: meeting.Topic, MeetingNo: meeting.MeetingNo, StartTime: unixSecondsToLocalRFC3339(meeting.StartTime), CalendarEventID: meeting.CalendarEventID, } - if out.Type == "" { - out.Type = raw.EventType - } return json.Marshal(out) } diff --git a/events/vc/recording_ended.go b/events/vc/recording_ended.go index 0ec5987a38..9e3d25ef2b 100644 --- a/events/vc/recording_ended.go +++ b/events/vc/recording_ended.go @@ -23,11 +23,6 @@ type VCRecordingEndedOutput struct { } 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"` } @@ -45,9 +40,9 @@ func processVCRecordingEnded(_ context.Context, _ event.APIClient, raw *event.Ra return nil, nil } out := &VCRecordingEndedOutput{ - Type: recordingEndedEventType(envelope, raw), - EventID: envelope.Header.EventID, - EventTime: recordingEndedEventTime(envelope.Header.CreateTime), + Type: raw.EventType, + EventID: raw.EventID, + EventTime: recordingEndedEventTime(raw.SourceTime), UniqueKey: envelope.Event.UniqueKey, Source: envelope.Event.Source, } @@ -66,13 +61,6 @@ 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 "" diff --git a/events/vc/recording_started.go b/events/vc/recording_started.go index 33d2eea125..740d41cc9b 100644 --- a/events/vc/recording_started.go +++ b/events/vc/recording_started.go @@ -23,11 +23,6 @@ type VCRecordingStartedOutput struct { } 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"` } @@ -45,9 +40,9 @@ func processVCRecordingStarted(_ context.Context, _ event.APIClient, raw *event. return nil, nil } out := &VCRecordingStartedOutput{ - Type: recordingStartedEventType(envelope, raw), - EventID: envelope.Header.EventID, - EventTime: recordingStartedEventTime(envelope.Header.CreateTime), + Type: raw.EventType, + EventID: raw.EventID, + EventTime: recordingStartedEventTime(raw.SourceTime), UniqueKey: envelope.Event.UniqueKey, Source: envelope.Event.Source, } @@ -66,13 +61,6 @@ 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 "" diff --git a/events/vc/recording_test.go b/events/vc/recording_test.go index 6691882075..af001d811f 100644 --- a/events/vc/recording_test.go +++ b/events/vc/recording_test.go @@ -457,6 +457,7 @@ func runRecordingProcessRaw(t *testing.T, eventType string, process event.Proces Payload: json.RawMessage(payload), Timestamp: time.Now(), } + fillCanonicalFromHeader(t, raw) got, err := process(context.Background(), nil, raw, nil) if err != nil { t.Fatalf("Process error: %v", err) diff --git a/events/vc/recording_transcript_generated.go b/events/vc/recording_transcript_generated.go index 2540c84689..de5dc97523 100644 --- a/events/vc/recording_transcript_generated.go +++ b/events/vc/recording_transcript_generated.go @@ -33,11 +33,6 @@ type VCRecordingTranscriptGeneratedOutput struct { } 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"` } @@ -70,9 +65,9 @@ func processVCRecordingTranscriptGenerated(_ context.Context, _ event.APIClient, return nil, nil } out := &VCRecordingTranscriptGeneratedOutput{ - Type: recordingTranscriptGeneratedEventType(envelope, raw), - EventID: envelope.Header.EventID, - EventTime: recordingTranscriptGeneratedEventTime(envelope.Header.CreateTime), + Type: raw.EventType, + EventID: raw.EventID, + EventTime: recordingTranscriptGeneratedEventTime(raw.SourceTime), UniqueKey: envelope.Event.UniqueKey, Source: envelope.Event.Source, TranscriptItems: recordingTranscriptItems(envelope.Event.TranscriptItems), @@ -92,13 +87,6 @@ func isRecordingTranscriptGeneratedBeanEvent(envelope *recordingTranscriptGenera 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) } diff --git a/events/vc/test_helpers_test.go b/events/vc/test_helpers_test.go index 4d69d8e3af..b197bda322 100644 --- a/events/vc/test_helpers_test.go +++ b/events/vc/test_helpers_test.go @@ -8,8 +8,34 @@ import ( "encoding/json" "reflect" "testing" + + "github.com/larksuite/cli/internal/event" ) +// fillCanonicalFromHeader copies the payload envelope header metadata onto +// the RawEvent canonical fields. Process handlers read event_id and +// create_time from the RawEvent, which the consume pipeline fills from the +// envelope header before dispatch; tests that hand-build a RawEvent must +// mirror that so both views agree. +func fillCanonicalFromHeader(t *testing.T, raw *event.RawEvent) { + t.Helper() + var envelope struct { + Header struct { + EventID string `json:"event_id"` + EventType string `json:"event_type"` + CreateTime string `json:"create_time"` + } `json:"header"` + } + if err := json.Unmarshal(raw.Payload, &envelope); err != nil { + t.Fatalf("parse envelope header: %v", err) + } + raw.EventID = envelope.Header.EventID + if envelope.Header.EventType != "" { + raw.EventType = envelope.Header.EventType + } + raw.SourceTime = envelope.Header.CreateTime +} + type stubAPIClient struct { callFn func(ctx context.Context, method, path string, body any) (json.RawMessage, error) } From f6f77817e36042d9367679e0e22564283f667d3f Mon Sep 17 00:00:00 2001 From: leave330 Date: Sat, 1 Aug 2026 00:59:35 +0800 Subject: [PATCH 30/61] refactor(event): fold duplicated domain preparation and projection helpers --- events/approval/preconsume.go | 12 +--- events/approval/register.go | 18 ++---- events/approval/register_test.go | 18 ++---- .../internal/subscribeprep/subscribeprep.go | 51 +++++++++++++++ events/minutes/preconsume.go | 37 ----------- events/minutes/register.go | 3 +- events/vc/internal_helpers.go | 62 +++++++++++++++++++ events/vc/participant_meeting_ended.go | 41 +++++------- events/vc/participant_meeting_joined.go | 28 ++++----- events/vc/participant_meeting_started.go | 26 ++++---- events/vc/preconsume.go | 37 ----------- events/vc/recording_ended.go | 44 ++----------- events/vc/recording_started.go | 44 ++----------- events/vc/recording_transcript_generated.go | 49 +++------------ events/vc/register.go | 15 ++--- events/whiteboard/preconsume.go | 20 +----- 16 files changed, 195 insertions(+), 310 deletions(-) create mode 100644 events/internal/subscribeprep/subscribeprep.go delete mode 100644 events/minutes/preconsume.go create mode 100644 events/vc/internal_helpers.go delete mode 100644 events/vc/preconsume.go diff --git a/events/approval/preconsume.go b/events/approval/preconsume.go index c9d0bb4ad1..3e7d859609 100644 --- a/events/approval/preconsume.go +++ b/events/approval/preconsume.go @@ -13,23 +13,13 @@ import ( "github.com/larksuite/cli/internal/event" ) -type approvalEventType string -type approvalSubscriptionPath string - -type approvalSubscriptionConfig struct { - eventType approvalEventType - subscribePath approvalSubscriptionPath -} - -func approvalSubscriptionPreConsume(cfg approvalSubscriptionConfig) func(context.Context, event.APIClient, map[string]string) (func() error, error) { +func approvalSubscriptionPreConsume(eventType, subscribePath string) func(context.Context, event.APIClient, map[string]string) (func() error, error) { return func(ctx context.Context, rt event.APIClient, params map[string]string) (func() error, error) { if rt == nil { return nil, errs.NewInternalError(errs.SubtypeUnknown, "runtime API client is required for pre-consume subscription") } - eventType := string(cfg.eventType) - subscribePath := string(cfg.subscribePath) subscriptionTypes, err := approvalSubscriptionTypes(eventType, params) if err != nil { return nil, err diff --git a/events/approval/register.go b/events/approval/register.go index 92522ca983..980f5dd7f0 100644 --- a/events/approval/register.go +++ b/events/approval/register.go @@ -41,12 +41,9 @@ func Keys() []event.KeyDefinition { Schema: event.SchemaDef{ Custom: &event.SchemaSpec{Type: reflect.TypeOf(ApprovalInstanceStatusChangedV4Output{})}, }, - Process: processApprovalInstanceStatusChanged, - PreConsume: approvalSubscriptionPreConsume(approvalSubscriptionConfig{ - eventType: eventTypeApprovalInstanceStatusChangedV4, - subscribePath: pathApprovalInstancesSubscription, - }), - Scopes: []string{"approval:instance:read"}, + Process: processApprovalInstanceStatusChanged, + PreConsume: approvalSubscriptionPreConsume(eventTypeApprovalInstanceStatusChangedV4, pathApprovalInstancesSubscription), + Scopes: []string{"approval:instance:read"}, AuthTypes: []string{ "user", }, @@ -61,12 +58,9 @@ func Keys() []event.KeyDefinition { Schema: event.SchemaDef{ Custom: &event.SchemaSpec{Type: reflect.TypeOf(ApprovalTaskStatusChangedV4Output{})}, }, - Process: processApprovalTaskStatusChanged, - PreConsume: approvalSubscriptionPreConsume(approvalSubscriptionConfig{ - eventType: eventTypeApprovalTaskStatusChangedV4, - subscribePath: pathApprovalTasksSubscription, - }), - Scopes: []string{"approval:task:read"}, + Process: processApprovalTaskStatusChanged, + PreConsume: approvalSubscriptionPreConsume(eventTypeApprovalTaskStatusChangedV4, pathApprovalTasksSubscription), + Scopes: []string{"approval:task:read"}, AuthTypes: []string{ "user", }, diff --git a/events/approval/register_test.go b/events/approval/register_test.go index 324730ff6e..3e08b209b5 100644 --- a/events/approval/register_test.go +++ b/events/approval/register_test.go @@ -257,10 +257,7 @@ func TestApprovalPreConsumeRegistersSubscriptionTypesWithoutCleanup(t *testing.T for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{ - eventType: approvalEventType(tc.eventType), - subscribePath: approvalSubscriptionPath(tc.subscribePath), - }) + pc := approvalSubscriptionPreConsume(tc.eventType, tc.subscribePath) rt := &fakeAPIClient{} cleanup, err := pc(context.Background(), rt, tc.params) if err != nil { @@ -299,9 +296,7 @@ func assertCall(t *testing.T, got recordedCall, wantMethod, wantPath string, wan func TestApprovalPreConsumeValidationErrors(t *testing.T) { t.Run("nil runtime", func(t *testing.T) { - pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{ - eventType: eventTypeApprovalInstanceStatusChangedV4, - }) + pc := approvalSubscriptionPreConsume(eventTypeApprovalInstanceStatusChangedV4, "") _, err := pc(context.Background(), nil, map[string]string{"subscription_type": approvalSubscriptionTypeInvolved}) if err == nil { t.Fatal("expected nil runtime error") @@ -314,9 +309,7 @@ func TestApprovalPreConsumeValidationErrors(t *testing.T) { for _, raw := range []string{"BAD", "[]", `["INVOLVED_APPROVAL",3]`} { t.Run("invalid subscription type "+raw, func(t *testing.T) { - pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{ - eventType: eventTypeApprovalInstanceStatusChangedV4, - }) + pc := approvalSubscriptionPreConsume(eventTypeApprovalInstanceStatusChangedV4, "") cleanup, err := pc(context.Background(), &fakeAPIClient{}, map[string]string{"subscription_type": raw}) if err == nil { t.Fatal("expected invalid subscription_type error") @@ -340,10 +333,7 @@ func TestApprovalPreConsumeValidationErrors(t *testing.T) { t.Run("partial registration failure reports registered and failed relation types", func(t *testing.T) { upstream := errs.NewAPIError(errs.SubtypeServerError, "approval subscription API failed") rt := &fakeAPIClient{err: upstream, errOnCall: 2} - pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{ - eventType: eventTypeApprovalTaskStatusChangedV4, - subscribePath: pathApprovalTasksSubscription, - }) + pc := approvalSubscriptionPreConsume(eventTypeApprovalTaskStatusChangedV4, pathApprovalTasksSubscription) cleanup, err := pc(context.Background(), rt, map[string]string{}) if err == nil { diff --git a/events/internal/subscribeprep/subscribeprep.go b/events/internal/subscribeprep/subscribeprep.go new file mode 100644 index 0000000000..0eb8c3297a --- /dev/null +++ b/events/internal/subscribeprep/subscribeprep.go @@ -0,0 +1,51 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package subscribeprep provides the shared PreConsume hook for EventKeys +// whose server-side subscription is a plain event_type register/unregister +// pair against fixed OAPI paths. +package subscribeprep + +import ( + "context" + "time" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/event/processing" +) + +// CleanupTimeout bounds how long the unsubscribe call has to finish during +// PreConsume cleanup so a stuck OAPI cannot block process shutdown. +const CleanupTimeout = 5 * time.Second + +// Hook returns a PreConsume that subscribes eventType via subscribePath and +// hands back a cleanup that unsubscribes it via unsubscribePath. +func Hook(eventType, subscribePath, unsubscribePath string) func(context.Context, processing.APIClient, map[string]string) (func() error, error) { + return func(ctx context.Context, rt processing.APIClient, _ map[string]string) (func() error, error) { + if rt == nil { + return nil, errs.NewInternalError(errs.SubtypeUnknown, + "runtime API client is required for pre-consume subscription") + } + return SubscribeWithCleanup(ctx, rt, eventType, subscribePath, unsubscribePath) + } +} + +// SubscribeWithCleanup calls the subscribe OAPI for eventType and returns a +// cleanup that invokes the matching unsubscribe, bounded by CleanupTimeout. +// rt must be non-nil; callers that validate their own params (e.g. to build +// per-resource paths) run those checks first and then delegate here. +func SubscribeWithCleanup(ctx context.Context, rt processing.APIClient, eventType, subscribePath, unsubscribePath string) (func() error, error) { + body := map[string]string{"event_type": eventType} + if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil { + return nil, err + } + + return func() error { + cleanupCtx, cancel := context.WithTimeout(context.Background(), CleanupTimeout) + defer cancel() + if _, err := rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body); err != nil { + return err + } + return nil + }, nil +} diff --git a/events/minutes/preconsume.go b/events/minutes/preconsume.go deleted file mode 100644 index b396f63757..0000000000 --- a/events/minutes/preconsume.go +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package minutes - -import ( - "context" - "time" - - "github.com/larksuite/cli/errs" - "github.com/larksuite/cli/internal/event" -) - -const cleanupTimeout = 5 * time.Second - -func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func() error, error) { - return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func() error, error) { - if rt == nil { - return nil, errs.NewInternalError(errs.SubtypeUnknown, - "runtime API client is required for pre-consume subscription") - } - - body := map[string]string{"event_type": eventType} - if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil { - return nil, err - } - - return func() error { - cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout) - defer cancel() - if _, err := rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body); err != nil { - return err - } - return nil - }, nil - } -} diff --git a/events/minutes/register.go b/events/minutes/register.go index bd0297bda3..f3cf773ee1 100644 --- a/events/minutes/register.go +++ b/events/minutes/register.go @@ -7,6 +7,7 @@ package minutes import ( "reflect" + "github.com/larksuite/cli/events/internal/subscribeprep" "github.com/larksuite/cli/internal/event" ) @@ -31,7 +32,7 @@ func Keys() []event.KeyDefinition { Custom: &event.SchemaSpec{Type: reflect.TypeOf(MinutesMinuteGeneratedOutput{})}, }, Process: processMinutesMinuteGenerated, - PreConsume: subscriptionPreConsume(eventTypeMinuteGenerated, pathMinuteSubscribe, pathMinuteUnsubscribe), + PreConsume: subscribeprep.Hook(eventTypeMinuteGenerated, pathMinuteSubscribe, pathMinuteUnsubscribe), Scopes: []string{"minutes:minutes.basic:read"}, AuthTypes: []string{ "user", diff --git a/events/vc/internal_helpers.go b/events/vc/internal_helpers.go new file mode 100644 index 0000000000..1b1eff76f3 --- /dev/null +++ b/events/vc/internal_helpers.go @@ -0,0 +1,62 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package vc + +import ( + "encoding/json" + "strconv" + "time" + + "github.com/larksuite/cli/internal/event" +) + +// recordingBeanSource is the only recording source the vc.recording.* keys +// emit; events carrying any other source are silently filtered out. +const recordingBeanSource = "recording_bean" + +// recordingBeanEventBody is the shared {"event": ...} body for +// recording_started and recording_ended, whose payloads carry identical fields. +type recordingBeanEventBody struct { + UniqueKey string `json:"unique_key"` + Source string `json:"source"` +} + +// decodeEventBody unmarshals the {"event": ...} envelope of raw and returns +// the decoded body; ok is false when the payload does not decode. +func decodeEventBody[T any](raw *event.RawEvent) (T, bool) { + var envelope struct { + Event T `json:"event"` + } + if err := json.Unmarshal(raw.Payload, &envelope); err != nil { + var zero T + return zero, false + } + return envelope.Event, true +} + +// millisToLocalRFC3339 converts a unix-millisecond timestamp string to +// RFC3339 in the local timezone; empty or non-numeric input yields "". +func millisToLocalRFC3339(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) +} + +// unixSecondsToLocalRFC3339 converts a unix-second timestamp string to +// RFC3339 in the local timezone; empty or non-numeric input yields "". +func unixSecondsToLocalRFC3339(raw string) string { + if raw == "" { + return "" + } + secs, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return "" + } + return time.Unix(secs, 0).Local().Format(time.RFC3339) +} diff --git a/events/vc/participant_meeting_ended.go b/events/vc/participant_meeting_ended.go index c3a275ee90..773818621d 100644 --- a/events/vc/participant_meeting_ended.go +++ b/events/vc/participant_meeting_ended.go @@ -6,8 +6,6 @@ package vc import ( "context" "encoding/json" - "strconv" - "time" "github.com/larksuite/cli/internal/event" "github.com/larksuite/cli/internal/event/processing" @@ -26,24 +24,24 @@ type VCParticipantMeetingEndedOutput struct { CalendarEventID string `json:"calendar_event_id,omitempty" desc:"Calendar event ID associated with the meeting"` } +type participantMeetingEndedEvent struct { + Meeting struct { + ID string `json:"id"` + Topic string `json:"topic"` + MeetingNo string `json:"meeting_no"` + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` + CalendarEventID string `json:"calendar_event_id"` + } `json:"meeting"` +} + func processVCParticipantMeetingEnded(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) { - var envelope struct { - Event struct { - Meeting struct { - ID string `json:"id"` - Topic string `json:"topic"` - MeetingNo string `json:"meeting_no"` - StartTime string `json:"start_time"` - EndTime string `json:"end_time"` - CalendarEventID string `json:"calendar_event_id"` - } `json:"meeting"` - } `json:"event"` - } - if err := json.Unmarshal(raw.Payload, &envelope); err != nil { + body, ok := decodeEventBody[participantMeetingEndedEvent](raw) + if !ok { return nil, processing.DropMalformed(raw.EventType) } - meeting := envelope.Event.Meeting + meeting := body.Meeting out := &VCParticipantMeetingEndedOutput{ Type: raw.EventType, EventID: raw.EventID, @@ -57,14 +55,3 @@ func processVCParticipantMeetingEnded(_ context.Context, _ event.APIClient, raw } return json.Marshal(out) } - -func unixSecondsToLocalRFC3339(raw string) string { - if raw == "" { - return "" - } - secs, err := strconv.ParseInt(raw, 10, 64) - if err != nil { - return "" - } - return time.Unix(secs, 0).Local().Format(time.RFC3339) -} diff --git a/events/vc/participant_meeting_joined.go b/events/vc/participant_meeting_joined.go index a2824eded9..0e44f2b6f8 100644 --- a/events/vc/participant_meeting_joined.go +++ b/events/vc/participant_meeting_joined.go @@ -23,24 +23,24 @@ type VCParticipantMeetingJoinedOutput struct { CalendarEventID string `json:"calendar_event_id,omitempty" desc:"Calendar event ID associated with the meeting"` } +type participantMeetingJoinedEvent struct { + Meeting struct { + ID string `json:"id"` + Topic string `json:"topic"` + MeetingNo string `json:"meeting_no"` + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` + CalendarEventID string `json:"calendar_event_id"` + } `json:"meeting"` +} + func processVCParticipantMeetingJoined(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) { - var envelope struct { - Event struct { - Meeting struct { - ID string `json:"id"` - Topic string `json:"topic"` - MeetingNo string `json:"meeting_no"` - StartTime string `json:"start_time"` - EndTime string `json:"end_time"` - CalendarEventID string `json:"calendar_event_id"` - } `json:"meeting"` - } `json:"event"` - } - if err := json.Unmarshal(raw.Payload, &envelope); err != nil { + body, ok := decodeEventBody[participantMeetingJoinedEvent](raw) + if !ok { return nil, processing.DropMalformed(raw.EventType) } - meeting := envelope.Event.Meeting + meeting := body.Meeting out := &VCParticipantMeetingJoinedOutput{ Type: raw.EventType, EventID: raw.EventID, diff --git a/events/vc/participant_meeting_started.go b/events/vc/participant_meeting_started.go index 034cc22fc0..2cdfcec3b0 100644 --- a/events/vc/participant_meeting_started.go +++ b/events/vc/participant_meeting_started.go @@ -23,23 +23,23 @@ type VCParticipantMeetingStartedOutput struct { CalendarEventID string `json:"calendar_event_id,omitempty" desc:"Calendar event ID associated with the meeting"` } +type participantMeetingStartedEvent struct { + Meeting struct { + ID string `json:"id"` + Topic string `json:"topic"` + MeetingNo string `json:"meeting_no"` + StartTime string `json:"start_time"` + CalendarEventID string `json:"calendar_event_id"` + } `json:"meeting"` +} + func processVCParticipantMeetingStarted(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) { - var envelope struct { - Event struct { - Meeting struct { - ID string `json:"id"` - Topic string `json:"topic"` - MeetingNo string `json:"meeting_no"` - StartTime string `json:"start_time"` - CalendarEventID string `json:"calendar_event_id"` - } `json:"meeting"` - } `json:"event"` - } - if err := json.Unmarshal(raw.Payload, &envelope); err != nil { + body, ok := decodeEventBody[participantMeetingStartedEvent](raw) + if !ok { return nil, processing.DropMalformed(raw.EventType) } - meeting := envelope.Event.Meeting + meeting := body.Meeting out := &VCParticipantMeetingStartedOutput{ Type: raw.EventType, EventID: raw.EventID, diff --git a/events/vc/preconsume.go b/events/vc/preconsume.go deleted file mode 100644 index ce7e16f745..0000000000 --- a/events/vc/preconsume.go +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package vc - -import ( - "context" - "time" - - "github.com/larksuite/cli/errs" - "github.com/larksuite/cli/internal/event" -) - -const cleanupTimeout = 5 * time.Second - -func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func() error, error) { - return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func() error, error) { - if rt == nil { - return nil, errs.NewInternalError(errs.SubtypeUnknown, - "runtime API client is required for pre-consume subscription") - } - - body := map[string]string{"event_type": eventType} - if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil { - return nil, err - } - - return func() error { - cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout) - defer cancel() - if _, err := rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body); err != nil { - return err - } - return nil - }, nil - } -} diff --git a/events/vc/recording_ended.go b/events/vc/recording_ended.go index 9e3d25ef2b..cb8b7694ae 100644 --- a/events/vc/recording_ended.go +++ b/events/vc/recording_ended.go @@ -6,8 +6,6 @@ package vc import ( "context" "encoding/json" - "strconv" - "time" "github.com/larksuite/cli/internal/event" "github.com/larksuite/cli/internal/event/processing" @@ -22,52 +20,20 @@ type VCRecordingEndedOutput struct { Source string `json:"source,omitempty" desc:"Recording source; always recording_bean"` } -type recordingEndedEnvelope struct { - 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) + body, ok := decodeEventBody[recordingBeanEventBody](raw) if !ok { return nil, processing.DropMalformed(raw.EventType) } - if !isRecordingEndedBeanEvent(envelope) { + if body.Source != recordingBeanSource { return nil, nil } out := &VCRecordingEndedOutput{ Type: raw.EventType, EventID: raw.EventID, - EventTime: recordingEndedEventTime(raw.SourceTime), - UniqueKey: envelope.Event.UniqueKey, - Source: envelope.Event.Source, + EventTime: millisToLocalRFC3339(raw.SourceTime), + UniqueKey: body.UniqueKey, + Source: body.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 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 index 740d41cc9b..90c6eea92a 100644 --- a/events/vc/recording_started.go +++ b/events/vc/recording_started.go @@ -6,8 +6,6 @@ package vc import ( "context" "encoding/json" - "strconv" - "time" "github.com/larksuite/cli/internal/event" "github.com/larksuite/cli/internal/event/processing" @@ -22,52 +20,20 @@ type VCRecordingStartedOutput struct { Source string `json:"source,omitempty" desc:"Recording source; always recording_bean"` } -type recordingStartedEnvelope struct { - 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) + body, ok := decodeEventBody[recordingBeanEventBody](raw) if !ok { return nil, processing.DropMalformed(raw.EventType) } - if !isRecordingStartedBeanEvent(envelope) { + if body.Source != recordingBeanSource { return nil, nil } out := &VCRecordingStartedOutput{ Type: raw.EventType, EventID: raw.EventID, - EventTime: recordingStartedEventTime(raw.SourceTime), - UniqueKey: envelope.Event.UniqueKey, - Source: envelope.Event.Source, + EventTime: millisToLocalRFC3339(raw.SourceTime), + UniqueKey: body.UniqueKey, + Source: body.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 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_transcript_generated.go b/events/vc/recording_transcript_generated.go index de5dc97523..e9e990033d 100644 --- a/events/vc/recording_transcript_generated.go +++ b/events/vc/recording_transcript_generated.go @@ -6,8 +6,6 @@ package vc import ( "context" "encoding/json" - "strconv" - "time" "github.com/larksuite/cli/internal/event" "github.com/larksuite/cli/internal/event/processing" @@ -32,10 +30,6 @@ type VCRecordingTranscriptGeneratedOutput struct { TranscriptItems []VCRecordingTranscriptItemOutput `json:"transcript_items,omitempty" desc:"Generated transcript items"` } -type recordingTranscriptGeneratedEnvelope struct { - Event recordingTranscriptGeneratedEvent `json:"event"` -} - type recordingTranscriptGeneratedEvent struct { UniqueKey string `json:"unique_key"` Source string `json:"source"` @@ -57,51 +51,24 @@ type recordingTranscriptGeneratedSpeakerIn struct { type recordingTranscriptGeneratedString string func processVCRecordingTranscriptGenerated(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) { - envelope, ok := parseRecordingTranscriptGeneratedEnvelope(raw) + body, ok := decodeEventBody[recordingTranscriptGeneratedEvent](raw) if !ok { return nil, processing.DropMalformed(raw.EventType) } - if !isRecordingTranscriptGeneratedBeanEvent(envelope) { + if body.Source != recordingBeanSource { return nil, nil } out := &VCRecordingTranscriptGeneratedOutput{ Type: raw.EventType, EventID: raw.EventID, - EventTime: recordingTranscriptGeneratedEventTime(raw.SourceTime), - UniqueKey: envelope.Event.UniqueKey, - Source: envelope.Event.Source, - TranscriptItems: recordingTranscriptItems(envelope.Event.TranscriptItems), + EventTime: millisToLocalRFC3339(raw.SourceTime), + UniqueKey: body.UniqueKey, + Source: body.Source, + TranscriptItems: recordingTranscriptItems(body.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 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 @@ -117,8 +84,8 @@ func recordingTranscriptItem(item recordingTranscriptGeneratedItemIn) VCRecordin return VCRecordingTranscriptItemOutput{ SpeakerName: recordingSpeakerName(item.Speaker), Text: item.Text, - StartTime: recordingTranscriptGeneratedMillisToLocalRFC3339(item.StartTimeMs.String()), - EndTime: recordingTranscriptGeneratedMillisToLocalRFC3339(item.EndTimeMs.String()), + StartTime: millisToLocalRFC3339(item.StartTimeMs.String()), + EndTime: millisToLocalRFC3339(item.EndTimeMs.String()), SentenceID: item.SentenceID, } } diff --git a/events/vc/register.go b/events/vc/register.go index 5b4441aece..6f17415610 100644 --- a/events/vc/register.go +++ b/events/vc/register.go @@ -7,6 +7,7 @@ package vc import ( "reflect" + "github.com/larksuite/cli/events/internal/subscribeprep" "github.com/larksuite/cli/internal/event" ) @@ -41,7 +42,7 @@ func Keys() []event.KeyDefinition { Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCParticipantMeetingStartedOutput{})}, }, Process: processVCParticipantMeetingStarted, - PreConsume: subscriptionPreConsume(eventTypeMeetingStarted, pathMeetingSubscribe, pathMeetingUnsubscribe), + PreConsume: subscribeprep.Hook(eventTypeMeetingStarted, pathMeetingSubscribe, pathMeetingUnsubscribe), Scopes: []string{"vc:meeting.meetingevent:read"}, AuthTypes: []string{ "user", @@ -57,7 +58,7 @@ func Keys() []event.KeyDefinition { Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCParticipantMeetingJoinedOutput{})}, }, Process: processVCParticipantMeetingJoined, - PreConsume: subscriptionPreConsume(eventTypeMeetingJoined, pathMeetingSubscribe, pathMeetingUnsubscribe), + PreConsume: subscribeprep.Hook(eventTypeMeetingJoined, pathMeetingSubscribe, pathMeetingUnsubscribe), Scopes: []string{"vc:meeting.meetingevent:read"}, AuthTypes: []string{ "user", @@ -73,7 +74,7 @@ func Keys() []event.KeyDefinition { Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCParticipantMeetingEndedOutput{})}, }, Process: processVCParticipantMeetingEnded, - PreConsume: subscriptionPreConsume(eventTypeMeetingEnded, pathMeetingSubscribe, pathMeetingUnsubscribe), + PreConsume: subscribeprep.Hook(eventTypeMeetingEnded, pathMeetingSubscribe, pathMeetingUnsubscribe), Scopes: []string{"vc:meeting.meetingevent:read"}, AuthTypes: []string{ "user", @@ -89,7 +90,7 @@ func Keys() []event.KeyDefinition { Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCNoteGeneratedOutput{})}, }, Process: processVCNoteGenerated, - PreConsume: subscriptionPreConsume(eventTypeNoteGenerated, pathNoteSubscribe, pathNoteUnsubscribe), + PreConsume: subscribeprep.Hook(eventTypeNoteGenerated, pathNoteSubscribe, pathNoteUnsubscribe), Scopes: []string{"vc:note:read"}, AuthTypes: []string{ "user", @@ -105,7 +106,7 @@ func Keys() []event.KeyDefinition { Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCRecordingStartedOutput{})}, }, Process: processVCRecordingStarted, - PreConsume: subscriptionPreConsume(eventTypeRecordingStarted, pathRecordingSubscribe, pathRecordingUnsubscribe), + PreConsume: subscribeprep.Hook(eventTypeRecordingStarted, pathRecordingSubscribe, pathRecordingUnsubscribe), Scopes: []string{"vc:recording:read"}, AuthTypes: []string{ "user", @@ -121,7 +122,7 @@ func Keys() []event.KeyDefinition { Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCRecordingTranscriptGeneratedOutput{})}, }, Process: processVCRecordingTranscriptGenerated, - PreConsume: subscriptionPreConsume(eventTypeRecordingTranscriptGenerated, pathRecordingSubscribe, pathRecordingUnsubscribe), + PreConsume: subscribeprep.Hook(eventTypeRecordingTranscriptGenerated, pathRecordingSubscribe, pathRecordingUnsubscribe), Scopes: []string{"vc:recording:read"}, AuthTypes: []string{ "user", @@ -137,7 +138,7 @@ func Keys() []event.KeyDefinition { Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCRecordingEndedOutput{})}, }, Process: processVCRecordingEnded, - PreConsume: subscriptionPreConsume(eventTypeRecordingEnded, pathRecordingSubscribe, pathRecordingUnsubscribe), + PreConsume: subscribeprep.Hook(eventTypeRecordingEnded, pathRecordingSubscribe, pathRecordingUnsubscribe), Scopes: []string{"vc:recording:read"}, AuthTypes: []string{ "user", diff --git a/events/whiteboard/preconsume.go b/events/whiteboard/preconsume.go index 02c27fe6be..d5ee2c08b0 100644 --- a/events/whiteboard/preconsume.go +++ b/events/whiteboard/preconsume.go @@ -6,17 +6,13 @@ package whiteboard import ( "context" "fmt" - "time" "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/events/internal/subscribeprep" "github.com/larksuite/cli/internal/event" "github.com/larksuite/cli/internal/validate" ) -// cleanupTimeout bounds how long the unsubscribe call has to finish during -// PreConsume cleanup so a stuck OAPI cannot block process shutdown. -const cleanupTimeout = 5 * time.Second - // whiteboardSubscriptionPreConsume calls the whiteboard event subscribe OAPI // and returns a cleanup that invokes the matching unsubscribe. // @@ -39,18 +35,6 @@ func whiteboardSubscriptionPreConsume(eventType string) func(context.Context, ev subscribePath := fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/subscribe", encoded) unsubscribePath := fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/unsubscribe", encoded) - body := map[string]string{"event_type": eventType} - if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil { - return nil, err - } - - return func() error { - cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout) - defer cancel() - if _, err := rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body); err != nil { - return err - } - return nil - }, nil + return subscribeprep.SubscribeWithCleanup(ctx, rt, eventType, subscribePath, unsubscribePath) } } From f6d7c1d601b13e5ff27883db58d3eefdad0659df Mon Sep 17 00:00:00 2001 From: leave330 Date: Sat, 1 Aug 2026 00:59:35 +0800 Subject: [PATCH 31/61] chore(event): normalize import grouping after the adapter moves --- cmd/event/consume.go | 2 +- internal/event/adapter/localbus/protocol/messages_test.go | 3 ++- internal/event/bus/bus.go | 2 +- internal/event/bus/handle_hello_test.go | 2 +- internal/event/consume/capability_gate_test.go | 2 +- internal/event/consume/consume.go | 2 +- internal/event/consume/consume_test.go | 2 +- internal/event/consume/diagnostics_redaction_test.go | 2 +- internal/event/consume/loop.go | 2 +- internal/event/consume/loop_test.go | 2 +- internal/event/consume/shutdown_test.go | 2 +- internal/event/integration_test.go | 4 ++-- internal/event/preconsume_contract_test.go | 4 ++-- 13 files changed, 16 insertions(+), 15 deletions(-) diff --git a/cmd/event/consume.go b/cmd/event/consume.go index c4b9102eb1..73baa417cb 100644 --- a/cmd/event/consume.go +++ b/cmd/event/consume.go @@ -24,10 +24,10 @@ import ( "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/credential" eventlib "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/adapter/localbus/transport" appconsume "github.com/larksuite/cli/internal/event/application/consume" "github.com/larksuite/cli/internal/event/catalog" "github.com/larksuite/cli/internal/event/consume" - "github.com/larksuite/cli/internal/event/adapter/localbus/transport" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" ) diff --git a/internal/event/adapter/localbus/protocol/messages_test.go b/internal/event/adapter/localbus/protocol/messages_test.go index 052a053d4d..ea090ac0fc 100644 --- a/internal/event/adapter/localbus/protocol/messages_test.go +++ b/internal/event/adapter/localbus/protocol/messages_test.go @@ -9,11 +9,12 @@ import ( "encoding/json" "errors" "fmt" - "github.com/larksuite/cli/internal/event/model" "io" "net" "testing" "time" + + "github.com/larksuite/cli/internal/event/model" ) // Every NewXxx helper must set the Type discriminator (Decode rejects messages without it). diff --git a/internal/event/bus/bus.go b/internal/event/bus/bus.go index 0de93fed93..4e163d891a 100644 --- a/internal/event/bus/bus.go +++ b/internal/event/bus/bus.go @@ -20,9 +20,9 @@ import ( "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/event" "github.com/larksuite/cli/internal/event/adapter/localbus/busdiscover" - "github.com/larksuite/cli/internal/event/catalog" "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" "github.com/larksuite/cli/internal/event/adapter/localbus/transport" + "github.com/larksuite/cli/internal/event/catalog" "github.com/larksuite/cli/internal/lockfile" ) diff --git a/internal/event/bus/handle_hello_test.go b/internal/event/bus/handle_hello_test.go index 7401b8b035..e8975dd4e1 100644 --- a/internal/event/bus/handle_hello_test.go +++ b/internal/event/bus/handle_hello_test.go @@ -14,8 +14,8 @@ import ( "time" "github.com/larksuite/cli/internal/event" - "github.com/larksuite/cli/internal/event/catalog" "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" + "github.com/larksuite/cli/internal/event/catalog" ) // compileBusTestSnapshot compiles synthetic declarations into the snapshot a diff --git a/internal/event/consume/capability_gate_test.go b/internal/event/consume/capability_gate_test.go index b9d28ffe43..1703d04c90 100644 --- a/internal/event/consume/capability_gate_test.go +++ b/internal/event/consume/capability_gate_test.go @@ -18,8 +18,8 @@ import ( "github.com/larksuite/cli/errs" event "github.com/larksuite/cli/internal/event" "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" - "github.com/larksuite/cli/internal/event/testutil" "github.com/larksuite/cli/internal/event/adapter/localbus/transport" + "github.com/larksuite/cli/internal/event/testutil" ) // legacyBusAcks replays, byte for byte, the hello_ack an older bus would send: diff --git a/internal/event/consume/consume.go b/internal/event/consume/consume.go index 4de149fa65..630c208389 100644 --- a/internal/event/consume/consume.go +++ b/internal/event/consume/consume.go @@ -15,9 +15,9 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/event" - "github.com/larksuite/cli/internal/event/catalog" "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" "github.com/larksuite/cli/internal/event/adapter/localbus/transport" + "github.com/larksuite/cli/internal/event/catalog" ) type Options struct { diff --git a/internal/event/consume/consume_test.go b/internal/event/consume/consume_test.go index 26437f51ba..f72b5879c9 100644 --- a/internal/event/consume/consume_test.go +++ b/internal/event/consume/consume_test.go @@ -14,9 +14,9 @@ import ( "testing" "github.com/larksuite/cli/internal/event" - "github.com/larksuite/cli/internal/event/catalog" "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" "github.com/larksuite/cli/internal/event/adapter/localbus/transport" + "github.com/larksuite/cli/internal/event/catalog" ) // fakeRT is a minimal event.APIClient mock. diff --git a/internal/event/consume/diagnostics_redaction_test.go b/internal/event/consume/diagnostics_redaction_test.go index 5b4087f5b9..83082188b3 100644 --- a/internal/event/consume/diagnostics_redaction_test.go +++ b/internal/event/consume/diagnostics_redaction_test.go @@ -11,9 +11,9 @@ import ( "testing" event "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" "github.com/larksuite/cli/internal/event/model" "github.com/larksuite/cli/internal/event/processing" - "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" ) // assertNoPayloadBytes fails when a diagnostic line carries payload content. diff --git a/internal/event/consume/loop.go b/internal/event/consume/loop.go index c4687ff9c1..fe0c7c4817 100644 --- a/internal/event/consume/loop.go +++ b/internal/event/consume/loop.go @@ -18,8 +18,8 @@ import ( "github.com/itchyny/gojq" "github.com/larksuite/cli/internal/event" - "github.com/larksuite/cli/internal/event/processing" "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" + "github.com/larksuite/cli/internal/event/processing" ) // consumeLoop reads events and dispatches to workers; cancels on terminal sink errors. diff --git a/internal/event/consume/loop_test.go b/internal/event/consume/loop_test.go index 2e3f81c0e4..35b01a9cd6 100644 --- a/internal/event/consume/loop_test.go +++ b/internal/event/consume/loop_test.go @@ -19,8 +19,8 @@ import ( "time" "github.com/larksuite/cli/internal/event" - "github.com/larksuite/cli/internal/event/model" "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" + "github.com/larksuite/cli/internal/event/model" ) func echoKeyDef(key string) *event.KeyDefinition { diff --git a/internal/event/consume/shutdown_test.go b/internal/event/consume/shutdown_test.go index 7b027a8b5c..7ceaff3e25 100644 --- a/internal/event/consume/shutdown_test.go +++ b/internal/event/consume/shutdown_test.go @@ -12,8 +12,8 @@ import ( "testing" "time" - "github.com/larksuite/cli/internal/event/model" "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" + "github.com/larksuite/cli/internal/event/model" ) // checkLastForKey must skip non-ack frames buffered before PreShutdownAck. diff --git a/internal/event/integration_test.go b/internal/event/integration_test.go index 88691294da..4eca9891ab 100644 --- a/internal/event/integration_test.go +++ b/internal/event/integration_test.go @@ -20,11 +20,11 @@ import ( "time" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/transport" "github.com/larksuite/cli/internal/event/bus" "github.com/larksuite/cli/internal/event/catalog" - "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" "github.com/larksuite/cli/internal/event/testutil" - "github.com/larksuite/cli/internal/event/adapter/localbus/transport" ) type integTestOut struct{ A string } diff --git a/internal/event/preconsume_contract_test.go b/internal/event/preconsume_contract_test.go index c1a003d779..b6d84d9784 100644 --- a/internal/event/preconsume_contract_test.go +++ b/internal/event/preconsume_contract_test.go @@ -34,12 +34,12 @@ import ( "time" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/transport" "github.com/larksuite/cli/internal/event/bus" "github.com/larksuite/cli/internal/event/catalog" "github.com/larksuite/cli/internal/event/consume" - "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" "github.com/larksuite/cli/internal/event/testutil" - "github.com/larksuite/cli/internal/event/adapter/localbus/transport" ) // contractAppSeq makes every test invocation use a distinct app id. The bus From db009030ed3690b4b37c22e5696e57b0df868b45 Mon Sep 17 00:00:00 2001 From: leave330 Date: Sat, 1 Aug 2026 11:54:48 +0800 Subject: [PATCH 32/61] docs(event): point the vc topic index at the full seven-key catalog The stale four-key index line steered agents into answering from the document instead of querying; the live-list example and the optional preparation-field note close the same gap. --- skills/lark-event/SKILL.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/skills/lark-event/SKILL.md b/skills/lark-event/SKILL.md index d2c7105bf2..09b11dfa4a 100644 --- a/skills/lark-event/SKILL.md +++ b/skills/lark-event/SKILL.md @@ -43,6 +43,9 @@ metadata: # Default: stream every event for the key (no filter, no projection) lark-cli event consume im.message.receive_v1 --as bot +# List every EventKey of one domain (the authoritative, always-current catalog) +lark-cli event list --domain vc --json + # Grab one sample event to inspect payload shape lark-cli event consume im.message.receive_v1 --max-events 1 --timeout 30s --as bot @@ -96,7 +99,7 @@ wait } ``` -Read `dry_run` at the envelope top level (not inside data). `data.decision.status` is `ready` / `unknown` / `blocked`: `unknown` means a weak read-only check could not answer (a real run would still proceed); `blocked` means a real run would refuse with the same error shown in the precondition detail. `would_write` lists the side effects a real run performs — `preparation.condition` = `first_consumer_for_scope` means the server-side subscription is registered only by the first consumer of that scope. Error paths behave exactly like a real run (unknown key → exit 2, auth failure → exit 3). +Read `dry_run` at the envelope top level (not inside data). `data.decision.preparation` is present only for keys that need preparation (keys without a server-side registration step omit the field entirely). `data.decision.status` is `ready` / `unknown` / `blocked`: `unknown` means a weak read-only check could not answer (a real run would still proceed); `blocked` means a real run would refuse with the same error shown in the precondition detail. `would_write` lists the side effects a real run performs — `preparation.condition` = `first_consumer_for_scope` means the server-side subscription is registered only by the first consumer of that scope. Error paths behave exactly like a real run (unknown key → exit 2, auth failure → exit 3). ## Subprocess contract @@ -188,6 +191,6 @@ Lark-defined semantic tags (**not** JSON Schema's standard `format`). Common val | Approval | [`references/lark-event-approval.md`](references/lark-event-approval.md) | Catalog of 2 Approval EventKeys (`approval.instance.status_changed_v4`, `approval.task.status_changed_v4`) + optional/multi `subscription_type` pre-registration + user-auth subscription lifecycle + flat output field reference | | 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) | Catalog of 7 VC EventKeys (meeting lifecycle `participant_meeting_started/joined/ended_v1`, `vc.note.generated_v1`, recording `recording_started/transcript_generated/ended_v1`) + field reference + source type semantics; the live list is always `lark-cli event list --domain vc --json` | | Minutes | [`references/lark-event-minutes.md`](references/lark-event-minutes.md) | Catalog of 1 Minutes EventKey (`minutes.minute.generated_v1`) + field reference + source type semantics (meeting only) | | Whiteboard | [`references/lark-event-whiteboard.md`](references/lark-event-whiteboard.md) | Catalog of 1 Board EventKey (`board.whiteboard.updated_v1`) + per-whiteboard subscription model (requires `-p whiteboard_id=`) + payload field reference (whiteboard_id / operator_ids triple-id) | From b44aaa1cfe6a09e5632520a2feb249555f9ac979 Mon Sep 17 00:00:00 2001 From: leave330 Date: Sat, 1 Aug 2026 12:08:25 +0800 Subject: [PATCH 33/61] docs(event): tell agents to execute the dry-run preview, not just cite it --- skills/lark-event/SKILL.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/skills/lark-event/SKILL.md b/skills/lark-event/SKILL.md index 09b11dfa4a..f8f8138ad7 100644 --- a/skills/lark-event/SKILL.md +++ b/skills/lark-event/SKILL.md @@ -70,6 +70,8 @@ wait `event consume --dry-run` performs only the read-only checks, then prints the consume decision on stdout as a standard envelope and exits 0 — no bus is started, no consumer registered, no server-side subscription touched, no files created. +**Run it, don't just recommend it.** Unlike a real consume, dry-run exits immediately and never starts listening, so it is always safe to execute. When the user asks to preview / rehearse / check what would happen ("先预演一遍"), execute the dry-run and summarize the actual output — `dry_run`, `decision.status`, `would_write`, and `preparation` (strategy / condition / action) — instead of only showing the command. + ```json { "ok": true, From 8a5701ddcf26a01bc3f2067dda2ad19f9333862b Mon Sep 17 00:00:00 2001 From: leave330 Date: Sat, 1 Aug 2026 16:22:06 +0800 Subject: [PATCH 34/61] feat(event): reject processless custom schemas and garbage raw schemas at compile time --- cmd/event/schema_test.go | 6 ++++-- .../event/application/consume/service_test.go | 2 +- internal/event/catalog/compile.go | 16 ++++++++++++++++ internal/event/catalog/compile_test.go | 11 +++++++++++ internal/event/consume/capability_gate_test.go | 2 +- internal/event/consume/consume_test.go | 2 +- internal/event/consume/fingerprint_scope_test.go | 2 +- internal/event/consume/startup_guard_test.go | 2 +- 8 files changed, 36 insertions(+), 7 deletions(-) diff --git a/cmd/event/schema_test.go b/cmd/event/schema_test.go index f01275736d..4f8bc8658e 100644 --- a/cmd/event/schema_test.go +++ b/cmd/event/schema_test.go @@ -396,7 +396,7 @@ func TestCompile_EmptySpecIsRejected(t *testing.T) { _, err := catalog.Compile([]eventlib.KeyDefinition{{ Key: "synthetic.empty.spec", EventType: "synthetic.empty.spec", - Schema: eventlib.SchemaDef{Custom: &eventlib.SchemaSpec{}}, + Schema: eventlib.SchemaDef{Native: &eventlib.SchemaSpec{}}, }}, catalog.StrategyRefs{catalog.StrategyNone}) if err == nil { t.Fatal("expected error for spec with neither Type nor Raw") @@ -418,7 +418,9 @@ func TestCompile_InvalidBaseWithOverridesIsRejected(t *testing.T) { if err == nil { t.Fatal("expected error for unparsable base schema") } - if !strings.Contains(err.Error(), "parse base schema for field overrides") { + // Garbage raw bytes are rejected by the spec check itself, before the + // overlay machinery would even try to parse them. + if !strings.Contains(err.Error(), "is not a JSON object") { t.Errorf("error should reject the unparsable base schema, got: %v", err) } } diff --git a/internal/event/application/consume/service_test.go b/internal/event/application/consume/service_test.go index c152ed330e..5fbf94b88b 100644 --- a/internal/event/application/consume/service_test.go +++ b/internal/event/application/consume/service_test.go @@ -130,7 +130,7 @@ func TestExecute_SpiesBiteOnTheRealPath(t *testing.T) { def := catalog.KeyDefinition{ Key: "demo.spy.check_v1", EventType: "demo.spy.check_v1", - Schema: catalog.SchemaDef{Custom: &catalog.SchemaSpec{Raw: json.RawMessage(`{"type":"object"}`)}}, + Schema: catalog.SchemaDef{Native: &catalog.SchemaSpec{Raw: json.RawMessage(`{"type":"object"}`)}}, PreConsume: func(ctx context.Context, rt processing.APIClient, params map[string]string) (func() error, error) { setup.Add(1) return nil, nil diff --git a/internal/event/catalog/compile.go b/internal/event/catalog/compile.go index 7fd100edd8..9019bb9509 100644 --- a/internal/event/catalog/compile.go +++ b/internal/event/catalog/compile.go @@ -154,6 +154,13 @@ func validateDefinition(def *KeyDefinition) []string { if nativeSet && def.Process != nil { fail("EventKey %s: Schema.Native forbids Process (Process produces a complete shape — use Schema.Custom)", def.Key) } + // The inverse also holds: a custom schema promises a processed output + // shape, and only a Process can produce it. Without one, the runtime + // would pass the raw envelope through — a shape the schema never + // described. + if customSet && def.Process == nil { + fail("EventKey %s: Schema.Custom requires Process (without it the raw envelope would pass through, outside the declared schema)", def.Key) + } if spec := def.Schema.Native; spec != nil { out = append(out, validateSpec(def.Key, "Schema.Native", spec)...) } @@ -192,6 +199,15 @@ func validateSpec(key, field string, s *SchemaSpec) []string { if typeSet == rawSet { return []string{fmt.Sprintf("EventKey %s: %s requires exactly one of Type or Raw", key, field)} } + // A raw schema must be a decodable JSON object; the placeholder check + // downstream only sees schemas that decoded, so garbage bytes have to be + // rejected here. + if rawSet { + var asObject map[string]json.RawMessage + if err := json.Unmarshal(s.Raw, &asObject); err != nil { + return []string{fmt.Sprintf("EventKey %s: %s.Raw is not a JSON object: %v", key, field, err)} + } + } return nil } diff --git a/internal/event/catalog/compile_test.go b/internal/event/catalog/compile_test.go index c94fc518e0..3fd4261d85 100644 --- a/internal/event/catalog/compile_test.go +++ b/internal/event/catalog/compile_test.go @@ -22,6 +22,9 @@ func validDef() KeyDefinition { Key: "demo.thing.updated_v1", EventType: "demo.thing.updated_v1", Schema: SchemaDef{Custom: &SchemaSpec{Raw: json.RawMessage(`{"type":"object","properties":{"id":{"type":"string"}}}`)}}, + Process: func(context.Context, processing.APIClient, *model.Event, map[string]string) (json.RawMessage, error) { + return json.RawMessage(`{}`), nil + }, } } @@ -91,6 +94,14 @@ func TestCompile_RejectsContractViolations(t *testing.T) { func(d *KeyDefinition) { d.Domain = "gadget" }, "does not match the key's first segment", }, + "custom schema without process": { + func(d *KeyDefinition) { d.Process = nil }, + "Schema.Custom requires Process", + }, + "raw schema with garbage bytes": { + func(d *KeyDefinition) { d.Schema.Custom.Raw = json.RawMessage(`this is {{{ not json`) }, + "is not a JSON object", + }, "placeholder object schema": { func(d *KeyDefinition) { d.Schema.Custom.Raw = json.RawMessage(`{}`) }, "empty placeholder", diff --git a/internal/event/consume/capability_gate_test.go b/internal/event/consume/capability_gate_test.go index 1703d04c90..2547dbc4e4 100644 --- a/internal/event/consume/capability_gate_test.go +++ b/internal/event/consume/capability_gate_test.go @@ -39,7 +39,7 @@ func TestCapabilityGate_RefusesLegacyBusBeforeAnySideEffect(t *testing.T) { def := compileDefForTest(t, event.KeyDefinition{ Key: key, EventType: key, - Schema: event.SchemaDef{Custom: &event.SchemaSpec{Raw: json.RawMessage(`{"type":"object"}`)}}, + Schema: event.SchemaDef{Native: &event.SchemaSpec{Raw: json.RawMessage(`{"type":"object"}`)}}, PreConsume: func(_ context.Context, _ event.APIClient, _ map[string]string) (func() error, error) { setupCalls.Add(1) return nil, nil diff --git a/internal/event/consume/consume_test.go b/internal/event/consume/consume_test.go index f72b5879c9..92007d4cf2 100644 --- a/internal/event/consume/consume_test.go +++ b/internal/event/consume/consume_test.go @@ -55,7 +55,7 @@ func TestNormalizeParams_ErrorIsWrappedWithEventKey(t *testing.T) { def := compileDefForTest(t, event.KeyDefinition{ Key: key, EventType: key, - Schema: event.SchemaDef{Custom: &event.SchemaSpec{Raw: json.RawMessage(`{"type":"object"}`)}}, + Schema: event.SchemaDef{Native: &event.SchemaSpec{Raw: json.RawMessage(`{"type":"object"}`)}}, NormalizeParams: func(_ context.Context, _ event.APIClient, _ map[string]string) error { return errors.New("simulated normalize failure") }, diff --git a/internal/event/consume/fingerprint_scope_test.go b/internal/event/consume/fingerprint_scope_test.go index 025dd29147..478a792a04 100644 --- a/internal/event/consume/fingerprint_scope_test.go +++ b/internal/event/consume/fingerprint_scope_test.go @@ -18,7 +18,7 @@ func TestSubscriptionKeyParam_SplitsScopes(t *testing.T) { def := &event.KeyDefinition{ Key: "test.evt_scoped", EventType: "test.evt_scoped", - Schema: event.SchemaDef{Custom: &event.SchemaSpec{Raw: json.RawMessage(`{"type":"object"}`)}}, + Schema: event.SchemaDef{Native: &event.SchemaSpec{Raw: json.RawMessage(`{"type":"object"}`)}}, Params: []event.ParamDef{ {Name: "resource_id", Type: event.ParamString, Required: true, SubscriptionKey: true}, }, diff --git a/internal/event/consume/startup_guard_test.go b/internal/event/consume/startup_guard_test.go index 3c98f024a0..6b98c1a38e 100644 --- a/internal/event/consume/startup_guard_test.go +++ b/internal/event/consume/startup_guard_test.go @@ -87,7 +87,7 @@ func TestRun_InvalidJQFailsBeforeAnySideEffect(t *testing.T) { def := compileDefForTest(t, event.KeyDefinition{ Key: "consume.runtest.jq", EventType: "consume.runtest.jq_v1", - Schema: event.SchemaDef{Custom: &event.SchemaSpec{Raw: json.RawMessage(`{"type":"object"}`)}}, + Schema: event.SchemaDef{Native: &event.SchemaSpec{Raw: json.RawMessage(`{"type":"object"}`)}}, }) err := Run(context.Background(), failDialTransport{}, "cli_x", "", "", Options{ EventKey: "consume.runtest.jq", From 0ab9b1ab9050a0c9ade1463da4e273c7803d4216 Mon Sep 17 00:00:00 2001 From: leave330 Date: Sat, 1 Aug 2026 16:29:51 +0800 Subject: [PATCH 35/61] fix(event): honest dry-run preconditions and single-run param normalization An unusable credential now renders a blocked decision instead of failing the dry-run; an unreadable scope ledger reports unknown instead of ok; the decision's normalized params flow into the real run so the normalization hook keeps its once-per-consumer contract; execute guards against mismatched entry/decision pairs and key/definition splits. --- cmd/event/consume.go | 62 ++++++++------- cmd/event/consume_dryrun_test.go | 75 +++++++++++++++++++ cmd/event/preconditions_test.go | 65 ++++++++++++++++ cmd/event/preflight_test.go | 8 +- cmd/event/service_adapters.go | 31 +++++--- internal/event/application/consume/service.go | 17 +++++ internal/event/catalog/definition.go | 4 +- internal/event/consume/consume.go | 25 +++++-- skills/lark-event/SKILL.md | 4 +- 9 files changed, 241 insertions(+), 50 deletions(-) create mode 100644 cmd/event/consume_dryrun_test.go create mode 100644 cmd/event/preconditions_test.go diff --git a/cmd/event/consume.go b/cmd/event/consume.go index 73baa417cb..69886304f3 100644 --- a/cmd/event/consume.go +++ b/cmd/event/consume.go @@ -126,9 +126,16 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, snap *catalog.Snapshot, domain := core.ResolveEndpoints(cfg.Brand).Open - // Surface auth errors before forking the bus daemon. + // Surface auth errors before forking the bus daemon. A dry run instead + // reports the unusable credential as a blocked precondition: the caller + // asked what would happen, and "a real run would refuse to authenticate" + // is a legitimate part of that answer. + var tokenErr error if _, err := resolveTenantToken(cmd.Context(), f, cfg.AppID); err != nil { - return err + if !o.dryRun { + return err + } + tokenErr = err } apiClient, err := f.NewAPIClient() @@ -180,7 +187,7 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, snap *catalog.Snapshot, Strategies: consumeStrategies, Identity: identityResolverFunc(func(context.Context, *catalog.Entry) (string, error) { return string(identity), nil }), Preflight: preflightReaderFunc(func(ctx context.Context, _ *catalog.Entry, _ string) ([]appconsume.Precondition, error) { - return readPreconditions(ctx, pf, appVerErr), nil + return readPreconditions(ctx, pf, appVerErr, tokenErr), nil }), } req := appconsume.Request{ @@ -232,20 +239,21 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, snap *catalog.Snapshot, runner := streamRunnerFunc(func(ctx context.Context, prepare appconsume.PrepareFunc) error { return consume.Run(ctx, transport.New(), cfg.AppID, cfg.ProfileName, domain, consume.Options{ - EventKey: eventKey, - Def: keyDef, - Params: paramMap, - JQExpr: o.jqExpr, - Quiet: o.quiet, - OutputDir: outputDir, - Runtime: runtime, - Out: f.IOStreams.Out, - ErrOut: errOut, - RemoteAPIClient: botRuntime, - MaxEvents: o.maxEvents, - Timeout: o.timeout, - IsTTY: f.IOStreams.IsTerminal, - Prepare: prepare, + EventKey: eventKey, + Def: keyDef, + Params: decision.NormalizedParams(), + ParamsNormalized: true, + JQExpr: o.jqExpr, + Quiet: o.quiet, + OutputDir: outputDir, + Runtime: runtime, + Out: f.IOStreams.Out, + ErrOut: errOut, + RemoteAPIClient: botRuntime, + MaxEvents: o.maxEvents, + Timeout: o.timeout, + IsTTY: f.IOStreams.IsTerminal, + Prepare: prepare, }) }) return svc.Execute(ctx, entry, decision, runner, appconsume.ExecutionContext{API: runtime}) @@ -276,10 +284,14 @@ type preflightCtx struct { subscribedCallbacks []string } -// preflightScopes compares required scopes against session-available scopes (user: UAT stored; bot: appVer.TenantScopes). -func preflightScopes(ctx context.Context, pf *preflightCtx) error { +// preflightScopes compares required scopes against session-available scopes +// (user: UAT stored; bot: appVer.TenantScopes). checked reports whether a +// comparison actually happened: "the ledger was unavailable" and "the check +// passed" are different answers, and only the caller can decide how loudly to +// say the first one. +func preflightScopes(ctx context.Context, pf *preflightCtx) (checked bool, err error) { if len(pf.keyDef.Scopes) == 0 || pf.identity == "" { - return nil + return true, nil } if ctx == nil { ctx = context.Background() @@ -289,24 +301,24 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) error { switch { case pf.identity.IsBot(): if pf.appVer == nil { - return nil + return false, nil } storedScopes = strings.Join(pf.appVer.TenantScopes, " ") case pf.identity == core.AsUser: result, err := pf.factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(pf.identity, pf.appID)) if err != nil || result == nil || result.Scopes == "" { - return nil //nolint:nilerr // best-effort: bus handshake will surface real auth error + return false, nil //nolint:nilerr // best-effort: the bus handshake surfaces the real auth error } storedScopes = result.Scopes default: - return nil + return false, nil } missing := auth.MissingScopes(storedScopes, pf.keyDef.Scopes) if len(missing) == 0 { - return nil + return true, nil } - return errs.NewPermissionError(errs.SubtypeMissingScope, + return true, errs.NewPermissionError(errs.SubtypeMissingScope, "missing required scopes for EventKey %s (as %s): %s", pf.eventKey, pf.identity, strings.Join(missing, ", ")). WithIdentity(string(pf.identity)). diff --git a/cmd/event/consume_dryrun_test.go b/cmd/event/consume_dryrun_test.go new file mode 100644 index 0000000000..d583b6104a --- /dev/null +++ b/cmd/event/consume_dryrun_test.go @@ -0,0 +1,75 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package event + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" +) + +// A dry run in a degraded environment (the test factory has no reachable +// platform, so every weak read-only check comes back unanswered) still exits +// zero with a structured decision that honestly says "unknown" — and performs +// none of its declared write effects. +func TestDryRun_DegradedEnvironmentStaysHonestAndSideEffectFree(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_test"}) + snap := compileCatalog() + + tmp := t.TempDir() + prevWD, _ := os.Getwd() + if err := os.Chdir(tmp); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(prevWD) }) + + cmd := NewCmdConsume(f, snap) + cmd.SetArgs([]string{"im.message.receive_v1", "--as", "bot", "--dry-run", "--output-dir", "events-out"}) + cmd.SilenceUsage = true + cmd.SilenceErrors = true + if err := cmd.Execute(); err != nil { + t.Fatalf("dry-run must not fail on unusable credentials, got: %v", err) + } + + var envelope struct { + OK bool `json:"ok"` + DryRun bool `json:"dry_run"` + Data struct { + Decision struct { + Status string `json:"status"` + Preconditions []struct { + Name string `json:"name"` + Status string `json:"status"` + } `json:"preconditions"` + WouldWrite []string `json:"would_write"` + } `json:"decision"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("stdout is not a decision envelope: %v\n%s", err, stdout.String()) + } + if !envelope.OK || !envelope.DryRun { + t.Errorf("want ok=true dry_run=true, got: %s", stdout.String()) + } + if envelope.Data.Decision.Status != "unknown" { + t.Errorf("unanswerable weak checks must render unknown, not fake readiness; got status %q", envelope.Data.Decision.Status) + } + names := map[string]string{} + for _, p := range envelope.Data.Decision.Preconditions { + names[p.Name] = p.Status + } + if names["credentials_available"] == "" || names["console_event_published"] == "" || names["scopes_granted"] == "" { + t.Errorf("preconditions must name every check, got: %v", names) + } + + // The declared write side effects must stay declarations: the requested + // output dir must not exist after a dry run. + if _, err := os.Stat(filepath.Join(tmp, "events-out")); !os.IsNotExist(err) { + t.Error("dry-run created the output directory; the preview performed a side effect") + } +} diff --git a/cmd/event/preconditions_test.go b/cmd/event/preconditions_test.go new file mode 100644 index 0000000000..23cdaa5030 --- /dev/null +++ b/cmd/event/preconditions_test.go @@ -0,0 +1,65 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package event + +import ( + "context" + "errors" + "testing" + + "github.com/larksuite/cli/internal/core" + eventlib "github.com/larksuite/cli/internal/event" + appconsume "github.com/larksuite/cli/internal/event/application/consume" +) + +func preconditionByName(list []appconsume.Precondition, name string) *appconsume.Precondition { + for i := range list { + if list[i].Name == name { + return &list[i] + } + } + return nil +} + +// An unusable credential blocks the decision and carries the exact error a +// real run would have returned, so both paths refuse for the same reason. +func TestReadPreconditions_TokenErrorBlocksWithTheSameError(t *testing.T) { + tokenErr := errors.New("no tenant token available") + pf := &preflightCtx{ + appID: "cli_test", + identity: core.AsBot, + keyDef: &eventlib.KeyDefinition{Key: "demo.thing.updated_v1"}, + } + got := readPreconditions(context.Background(), pf, nil, tokenErr) + + cred := preconditionByName(got, "credentials_available") + if cred == nil { + t.Fatal("credentials_available precondition missing") + } + if cred.Status != appconsume.PreconditionBlocked || !errors.Is(cred.BlockErr, tokenErr) { + t.Errorf("token failure must block with the original error, got %+v", cred) + } +} + +// A scope ledger nobody could read is reported as unknown — never as ok. +func TestReadPreconditions_UnreadableScopesAreUnknown(t *testing.T) { + pf := &preflightCtx{ + appID: "cli_test", + identity: core.AsBot, + keyDef: &eventlib.KeyDefinition{ + Key: "demo.thing.updated_v1", + Scopes: []string{"demo:read"}, + }, + appVer: nil, // no published version: the bot scope ledger is unreadable + } + got := readPreconditions(context.Background(), pf, nil, nil) + + scopes := preconditionByName(got, "scopes_granted") + if scopes == nil { + t.Fatal("scopes_granted precondition missing") + } + if scopes.Status != appconsume.PreconditionUnknown { + t.Errorf("an unreadable ledger must report unknown, got %q", scopes.Status) + } +} diff --git a/cmd/event/preflight_test.go b/cmd/event/preflight_test.go index bbe3a2fdcf..c666a7ee27 100644 --- a/cmd/event/preflight_test.go +++ b/cmd/event/preflight_test.go @@ -108,7 +108,7 @@ func TestPreflightScopes_Bot_NoAppVer_SkipsCheck(t *testing.T) { Key: "im.message.text", Scopes: []string{"im:message", "im:message.group_at_msg"}, } - err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, nil)) + _, err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, nil)) if err != nil { t.Fatalf("bot + nil appVer should skip, got: %v", err) } @@ -124,7 +124,7 @@ func TestPreflightScopes_Bot_AllGranted_Passes(t *testing.T) { "im:message.group_at_msg", "contact:user:readonly", }} - err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, appVer)) + _, err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, appVer)) if err != nil { t.Fatalf("all scopes granted, unexpected error: %v", err) } @@ -136,7 +136,7 @@ func TestPreflightScopes_Bot_MissingBlocks(t *testing.T) { Scopes: []string{"im:message", "im:message.group_at_msg"}, } appVer := &appmeta.AppVersion{TenantScopes: []string{"im:message"}} - err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, appVer)) + _, err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, appVer)) if err == nil { t.Fatal("expected error for missing scope") } @@ -169,7 +169,7 @@ func TestPreflightScopes_Bot_MissingBlocks(t *testing.T) { func TestPreflightScopes_NoRequiredScopes_SkipsCheck(t *testing.T) { def := &eventlib.KeyDefinition{Key: "x"} - if err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, nil)); err != nil { + if _, err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, nil)); err != nil { t.Fatalf("no required scopes means nothing to verify, got: %v", err) } } diff --git a/cmd/event/service_adapters.go b/cmd/event/service_adapters.go index f85387e688..5209fc95c7 100644 --- a/cmd/event/service_adapters.go +++ b/cmd/event/service_adapters.go @@ -38,7 +38,14 @@ func (f streamRunnerFunc) Run(ctx context.Context, prepare appconsume.PrepareFun // named preconditions. Weak dependencies that could not answer stay visible // as "unknown" instead of silently passing; a failed check carries the exact // error a real run returns, so refusal is identical on both paths. -func readPreconditions(ctx context.Context, pf *preflightCtx, appVerErr error) []appconsume.Precondition { +func readPreconditions(ctx context.Context, pf *preflightCtx, appVerErr, tokenErr error) []appconsume.Precondition { + credentials := appconsume.Precondition{Name: "credentials_available", Status: appconsume.PreconditionOK} + if tokenErr != nil { + credentials.Status = appconsume.PreconditionBlocked + credentials.Detail = tokenErr.Error() + credentials.BlockErr = tokenErr + } + console := appconsume.Precondition{Name: "console_event_published", Status: appconsume.PreconditionOK} switch { case len(pf.keyDef.RequiredConsoleEvents) == 0: @@ -60,19 +67,19 @@ func readPreconditions(ctx context.Context, pf *preflightCtx, appVerErr error) [ } scopes := appconsume.Precondition{Name: "scopes_granted", Status: appconsume.PreconditionOK} + checked, err := preflightScopes(ctx, pf) switch { - case len(pf.keyDef.Scopes) == 0: - // nothing to verify - case pf.identity.IsBot() && pf.appVer == nil: + case err != nil: + scopes.Status = appconsume.PreconditionBlocked + scopes.Detail = err.Error() + scopes.BlockErr = err + case !checked: + // The scope ledger could not be read (no published version for bots, + // no resolvable token for users). Saying "ok" here would dress up + // "nobody looked" as "it was verified". scopes.Status = appconsume.PreconditionUnknown - scopes.Detail = "published scopes unavailable" - default: - if err := preflightScopes(ctx, pf); err != nil { - scopes.Status = appconsume.PreconditionBlocked - scopes.Detail = err.Error() - scopes.BlockErr = err - } + scopes.Detail = "granted scopes could not be read for this identity" } - return []appconsume.Precondition{console, scopes} + return []appconsume.Precondition{credentials, console, scopes} } diff --git a/internal/event/application/consume/service.go b/internal/event/application/consume/service.go index 10b0d3033f..7111c54602 100644 --- a/internal/event/application/consume/service.go +++ b/internal/event/application/consume/service.go @@ -125,6 +125,12 @@ func (s *Service) Decide(ctx context.Context, entry *catalog.Entry, req Request, // dependencies degrade with a stderr note, they do not block, matching the // behavior consumers have always had. func (s *Service) Execute(ctx context.Context, entry *catalog.Entry, d *Decision, runner StreamRunner, ec ExecutionContext) error { + // The decision must be the one decided for this entry: executing a + // mismatched pair would apply one key's preparation to another's stream. + if got := entry.Descriptor().Key; d.eventKey != got { + return errs.NewInternalError(errs.SubtypeUnknown, + "decision for %q cannot execute against entry %q", d.eventKey, got) + } if d.status == StatusBlocked { return d.blockErr } @@ -134,6 +140,10 @@ func (s *Service) Execute(ctx context.Context, entry *catalog.Entry, d *Decision if err != nil { return errs.NewInternalError(errs.SubtypeUnknown, "%s", err) } + if d.preparation == nil { + return errs.NewInternalError(errs.SubtypeUnknown, + "decision for %q carries no preparation but the entry requires strategy %q", d.eventKey, ref) + } prep := *d.preparation in := PreparedConsume{Entry: entry, Params: maps.Clone(d.params)} prepare = func(ctx context.Context) (Cleanup, error) { @@ -142,3 +152,10 @@ func (s *Service) Execute(ctx context.Context, entry *catalog.Entry, d *Decision } return runner.Run(ctx, prepare) } + +// NormalizedParams returns a copy of the decision's validated, normalized +// parameters — what a real run must consume so normalization stays a +// once-per-consumer event. +func (d *Decision) NormalizedParams() map[string]string { + return maps.Clone(d.params) +} diff --git a/internal/event/catalog/definition.go b/internal/event/catalog/definition.go index 58661b68dc..5e6aac1faa 100644 --- a/internal/event/catalog/definition.go +++ b/internal/event/catalog/definition.go @@ -115,7 +115,9 @@ type KeyDefinition struct { // NormalizeParams canonicalizes param values BEFORE fingerprint compute, // PreConsume, Match, and Process. Mutates the params map in place. - // May call OAPI; runs once per consumer at startup. + // May call OAPI; runs once per consumer at startup — the deciding layer + // runs it and hands the normalized values to the stream host, which then + // skips the hook (Options.ParamsNormalized). // // Use cases: resolve aliases ("me" -> real email, a name -> an ID), // trim whitespace. On error, consume fails (no retry); caller gets the diff --git a/internal/event/consume/consume.go b/internal/event/consume/consume.go index 630c208389..ee39bd29f7 100644 --- a/internal/event/consume/consume.go +++ b/internal/event/consume/consume.go @@ -27,10 +27,14 @@ type Options struct { // never looks anything up itself. Def *event.KeyDefinition Params map[string]string - JQExpr string - Quiet bool - OutputDir string - Runtime event.APIClient + // ParamsNormalized marks Params as already normalized by the declaration's + // NormalizeParams hook (the deciding layer runs it on these exact values). + // The host then skips the hook, keeping its run-once-per-consumer contract. + ParamsNormalized bool + JQExpr string + Quiet bool + OutputDir string + Runtime event.APIClient // Prepare, when set, replaces the declaration's PreConsume hook as the // preparation to run when this consumer is first for its scope. The // application layer injects it so the strategy that was decided is the @@ -59,6 +63,14 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin "unknown EventKey: %s", opts.EventKey). WithHint("run `lark-cli event list` to see available keys") } + // EventKey and Def travel together; a mismatch would register one key on + // the bus while subscribing another's event types. + if opts.EventKey == "" { + opts.EventKey = keyDef.Key + } else if opts.EventKey != keyDef.Key { + return errs.NewInternalError(errs.SubtypeUnknown, + "consume options disagree: EventKey %q but definition is %q", opts.EventKey, keyDef.Key) + } if err := validateParams(keyDef, opts.Params); err != nil { return err @@ -73,8 +85,9 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin // Normalize params (resolve aliases like "me" -> real email) before fingerprint // compute, PreConsume, Match, Process. Must happen BEFORE doHello so the - // SubscriptionID we send to bus reflects canonical values. - if keyDef.NormalizeParams != nil { + // SubscriptionID we send to bus reflects canonical values. Skipped when the + // caller already normalized (the hook runs once per consumer, wherever it runs). + if !opts.ParamsNormalized && keyDef.NormalizeParams != nil { if err := keyDef.NormalizeParams(ctx, opts.Runtime, opts.Params); err != nil { if _, ok := errs.ProblemOf(err); ok { return err diff --git a/skills/lark-event/SKILL.md b/skills/lark-event/SKILL.md index f8f8138ad7..dcfef63477 100644 --- a/skills/lark-event/SKILL.md +++ b/skills/lark-event/SKILL.md @@ -70,7 +70,7 @@ wait `event consume --dry-run` performs only the read-only checks, then prints the consume decision on stdout as a standard envelope and exits 0 — no bus is started, no consumer registered, no server-side subscription touched, no files created. -**Run it, don't just recommend it.** Unlike a real consume, dry-run exits immediately and never starts listening, so it is always safe to execute. When the user asks to preview / rehearse / check what would happen ("先预演一遍"), execute the dry-run and summarize the actual output — `dry_run`, `decision.status`, `would_write`, and `preparation` (strategy / condition / action) — instead of only showing the command. +**Run it, don't just recommend it.** Unlike a real consume, dry-run exits immediately and never starts listening or connecting to the event stream, so it is always safe to execute. When the user asks to preview / rehearse / check what would happen ("先预演一遍"), execute the dry-run and summarize the actual output — `dry_run`, `decision.status`, `would_write`, and `preparation` (strategy / condition / action) — instead of only showing the command. The same applies to readiness questions ("环境/权限是否就绪能不能收到事件"): the answer must come from an executed dry-run's `status` and `preconditions`, not from reading documentation — docs cannot see this tenant's console switches or granted scopes. ```json { @@ -101,7 +101,7 @@ wait } ``` -Read `dry_run` at the envelope top level (not inside data). `data.decision.preparation` is present only for keys that need preparation (keys without a server-side registration step omit the field entirely). `data.decision.status` is `ready` / `unknown` / `blocked`: `unknown` means a weak read-only check could not answer (a real run would still proceed); `blocked` means a real run would refuse with the same error shown in the precondition detail. `would_write` lists the side effects a real run performs — `preparation.condition` = `first_consumer_for_scope` means the server-side subscription is registered only by the first consumer of that scope. Error paths behave exactly like a real run (unknown key → exit 2, auth failure → exit 3). +Read `dry_run` at the envelope top level (not inside data). `data.decision.preparation` is present only for keys that need preparation (keys without a server-side registration step omit the field entirely). `data.decision.status` is `ready` / `unknown` / `blocked`: `unknown` means a weak read-only check could not answer (a real run would still proceed); `blocked` means a real run would refuse with the same error shown in the precondition detail. `would_write` lists the side effects a real run performs — `preparation.condition` = `first_consumer_for_scope` means the server-side subscription is registered only by the first consumer of that scope. Input errors behave exactly like a real run (unknown key / bad params → exit 2). An unusable credential does **not** fail the dry-run: it renders a `blocked` decision (exit 0) with a `credentials_available` precondition carrying the auth error — only a real run exits 3 on it. ## Subprocess contract From 569a5d1c5c6a621f7ecddff9d0a0cf33ca39465c Mon Sep 17 00:00:00 2001 From: leave330 Date: Sat, 1 Aug 2026 16:45:02 +0800 Subject: [PATCH 36/61] test(event): close the review's gate gaps and defensive holes Frozen baseline outputs now validate against their declared schemas; process-error diagnostics truncate before payload fragments can reach stderr; the render contract gate recurses into nested named types; the snapshot deep-copies field-override enums; hosts are gated against importing the platform SDK; a credential-vocabulary guard keeps future sensitive parameters inside the redaction net. --- cmd/event/render/decision.go | 52 +--- cmd/event/render/decision_test.go | 10 +- cmd/event/render/redaction_guard_test.go | 121 ++++++++ cmd/event/render_contract_test.go | 116 ++++++-- events/schema_instance_test.go | 259 ++++++++++++++++++ internal/event/arch_layering_test.go | 109 ++++++++ internal/event/catalog/canonicalize.go | 15 +- internal/event/catalog/snapshot.go | 8 +- internal/event/catalog/snapshot_test.go | 28 ++ internal/event/consume/consume.go | 4 +- .../consume/diagnostics_redaction_test.go | 84 ++++++ internal/event/consume/loop.go | 23 +- 12 files changed, 737 insertions(+), 92 deletions(-) create mode 100644 cmd/event/render/redaction_guard_test.go create mode 100644 events/schema_instance_test.go diff --git a/cmd/event/render/decision.go b/cmd/event/render/decision.go index eaea760255..6a7435a443 100644 --- a/cmd/event/render/decision.go +++ b/cmd/event/render/decision.go @@ -2,15 +2,13 @@ // SPDX-License-Identifier: MIT // Package render turns consume decisions into user-facing output. It is the -// only place a decision becomes text or JSON; the application layer never -// formats anything itself. +// only place a decision becomes JSON; the application layer never formats +// anything itself. package render import ( - "fmt" "io" "regexp" - "sort" appconsume "github.com/larksuite/cli/internal/event/application/consume" "github.com/larksuite/cli/internal/output" @@ -75,52 +73,6 @@ func WriteDecisionJSON(out, errOut io.Writer, identity string, v appconsume.Deci }) } -// WriteDecisionText renders the same decision for humans. -func WriteDecisionText(out io.Writer, v appconsume.DecisionView) { - fmt.Fprintf(out, "Dry run — no side effects were performed.\n\n") - fmt.Fprintf(out, "EventKey: %s\n", v.EventKey) - fmt.Fprintf(out, "Domain: %s\n", v.Domain) - fmt.Fprintf(out, "Identity: %s\n", v.Identity) - fmt.Fprintf(out, "Status: %s\n", v.Status) - fmt.Fprintf(out, "Scope: %s\n", v.Scope) - if len(v.Params) > 0 { - params := redactParams(v.Params) - fmt.Fprintf(out, "Params:\n") - names := make([]string, 0, len(params)) - for name := range params { - names = append(names, name) - } - sort.Strings(names) - for _, name := range names { - fmt.Fprintf(out, " %s = %s\n", name, params[name]) - } - } - if len(v.Preconditions) > 0 { - fmt.Fprintf(out, "Preconditions:\n") - for _, p := range v.Preconditions { - if p.Detail != "" { - fmt.Fprintf(out, " %-28s %s (%s)\n", p.Name, p.Status, p.Detail) - } else { - fmt.Fprintf(out, " %-28s %s\n", p.Name, p.Status) - } - } - } - if v.Preparation != nil { - fmt.Fprintf(out, "Preparation:\n") - fmt.Fprintf(out, " strategy: %s\n", v.Preparation.Strategy) - fmt.Fprintf(out, " condition: %s\n", v.Preparation.Condition) - fmt.Fprintf(out, " action: %s\n", v.Preparation.Action) - } - fmt.Fprintf(out, "Would read:\n") - for _, r := range v.WouldRead { - fmt.Fprintf(out, " - %s\n", r) - } - fmt.Fprintf(out, "Would write (on a real run):\n") - for _, w := range v.WouldWrite { - fmt.Fprintf(out, " - %s\n", w) - } -} - func toPayload(v appconsume.DecisionView) decisionPayload { p := decisionPayload{ EventKey: v.EventKey, diff --git a/cmd/event/render/decision_test.go b/cmd/event/render/decision_test.go index d225d93004..f27a7f219f 100644 --- a/cmd/event/render/decision_test.go +++ b/cmd/event/render/decision_test.go @@ -88,8 +88,8 @@ func TestWriteDecisionJSON_EnvelopeContract(t *testing.T) { } } -// Sensitive parameter values never reach the rendered output, JSON or text. -// The control assertion first proves the sentinel would be visible if leaked. +// Sensitive parameter values never reach the rendered output. The control +// assertion first proves the sentinel would be visible if leaked. func TestWriteDecision_RedactsSensitiveParams(t *testing.T) { const sentinel = "sk-SENSITIVE-VALUE" view := sampleView() @@ -111,10 +111,4 @@ func TestWriteDecision_RedactsSensitiveParams(t *testing.T) { if !strings.Contains(compact, `"whiteboard_id":"wb-1"`) { t.Errorf("non-sensitive params must render verbatim, got: %s", jsonOut.String()) } - - var textOut bytes.Buffer - WriteDecisionText(&textOut, view) - if strings.Contains(textOut.String(), sentinel) { - t.Errorf("text output leaks a sensitive param value: %s", textOut.String()) - } } diff --git a/cmd/event/render/redaction_guard_test.go b/cmd/event/render/redaction_guard_test.go new file mode 100644 index 0000000000..82b8392f53 --- /dev/null +++ b/cmd/event/render/redaction_guard_test.go @@ -0,0 +1,121 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package render + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/events" + "github.com/larksuite/cli/internal/event/catalog" +) + +// This file is a guard, not a contract: it does not pin what the redaction +// regex matches, it hunts for declared parameter names that smell like +// credentials yet would render verbatim. The detector wordlist is therefore +// deliberately wider than the production sensitiveParamName pattern — a hit +// here means either the parameter should be renamed or the production +// pattern must grow, decided by a human, never by loosening this list. + +// credentialWords are matched against whole '_'/'-'/'.'-separated segments of +// a parameter name, so chat_key or tokenizer_mode cannot trip them. The bare +// word "key" is intentionally absent (identifier names like whiteboard_id or +// a hypothetical chat_key are not credentials); the api/key pairing is what +// carries credential semantics and is detected as a pair below. +var credentialWords = map[string]bool{ + "token": true, + "secret": true, + "password": true, + "credential": true, + "credentials": true, + "cookie": true, + "auth": true, + "signature": true, + "bearer": true, + "apikey": true, +} + +// smellsLikeCredential reports whether a parameter name carries credential +// semantics per the guard wordlist: any single segment in credentialWords, +// or the adjacent segment pair api+key. +func smellsLikeCredential(name string) bool { + segments := strings.FieldsFunc(strings.ToLower(name), func(r rune) bool { + return r == '_' || r == '-' || r == '.' + }) + for i, seg := range segments { + if credentialWords[seg] { + return true + } + if seg == "api" && i+1 < len(segments) && segments[i+1] == "key" { + return true + } + } + return false +} + +// unredactedCredentialParams returns the names that smell like credentials +// but are NOT matched by the production redaction pattern — every such name +// would render its value verbatim in a dry-run decision. +func unredactedCredentialParams(names []string) []string { + var findings []string + for _, name := range names { + if smellsLikeCredential(name) && !sensitiveParamName.MatchString(name) { + findings = append(findings, name) + } + } + return findings +} + +// The detector itself must bite before the live scan means anything: known +// credential-shaped names that the production pattern misses must be caught, +// and ordinary identifier names must pass. +func TestRedactionGuardDetector_SelfCheck(t *testing.T) { + // Credential-shaped and covered by the production pattern: no finding. + for _, name := range []string{"access_token", "client_secret", "user_password", "session_cookie", "sso_credential"} { + if got := unredactedCredentialParams([]string{name}); len(got) != 0 { + t.Errorf("%q is redacted by the production pattern, the guard must not flag it, got %v", name, got) + } + } + // Credential-shaped but NOT covered by the production pattern today: the + // guard must flag these, otherwise it can never catch a real gap. + for _, name := range []string{"api_key", "auth_code", "request_signature", "bearer_value"} { + if got := unredactedCredentialParams([]string{name}); len(got) != 1 { + t.Errorf("%q smells like a credential and is not redacted; the guard must flag it, got %v", name, got) + } + } + // Ordinary identifiers, including the wide-false-positive shapes the + // wordlist is segment-matched to avoid: no finding. + for _, name := range []string{"whiteboard_id", "chat_key", "tokenizer_mode", "author", "meeting_no"} { + if got := unredactedCredentialParams([]string{name}); len(got) != 0 { + t.Errorf("%q is an ordinary identifier, the guard must not flag it, got %v", name, got) + } + } +} + +// Every declared parameter of every compiled EventKey either carries no +// credential semantics or is caught by the production redaction pattern. +func TestRedactionGuard_CatalogParamsHaveNoUnredactedCredentials(t *testing.T) { + snap, err := catalog.Compile(events.All(), catalog.StrategyRefs{ + catalog.StrategyNone, + catalog.StrategyLegacyPreConsume, + }) + if err != nil { + t.Fatalf("compile catalog: %v", err) + } + + var names []string + for _, entry := range snap.Entries() { + desc := entry.Descriptor() + for _, p := range desc.Params { + names = append(names, desc.Key+": "+p.Name) + if findings := unredactedCredentialParams([]string{p.Name}); len(findings) != 0 { + t.Errorf("EventKey %s declares param %q which smells like a credential but is not matched by the redaction pattern; rename the param or extend sensitiveParamName deliberately", desc.Key, p.Name) + } + } + } + // A scan that visited no parameters proves nothing. + if len(names) == 0 { + t.Fatal("the compiled catalog declares no parameters at all; the guard scanned nothing") + } +} diff --git a/cmd/event/render_contract_test.go b/cmd/event/render_contract_test.go index c5a4ea59d9..9b75976794 100644 --- a/cmd/event/render_contract_test.go +++ b/cmd/event/render_contract_test.go @@ -12,13 +12,16 @@ import ( // renderedDeclarationFields lists every JSON field the list/schema commands // are allowed to expose, each with the reason it belongs to the public // contract. Golden files pin today's bytes; this gate protects tomorrow: a -// field added to the rendered structs (or promoted through the embedded -// definition) must either appear here deliberately or be tagged `json:"-"`. +// field added to the rendered structs (promoted through the embedded +// definition or nested anywhere under it) must either appear here +// deliberately or be tagged `json:"-"`. The set is flat — an entry admits its +// rendered name at any nesting level, which is the same latitude +// encoding/json gives a name. var renderedDeclarationFields = map[string]string{ "key": "stable identifier agents subscribe by", "domain": "declared domain override; empty for every shipped key (filtering reads the derived descriptor value), so legacy output is byte-identical", "display_name": "human-readable name for pickers", - "description": "what the event means", + "description": "what the event means (KeyDefinition) / what the parameter does (ParamDef)", "event_type": "upstream event type behind this key", "subscription_type": "which console ledger the precheck reads", "params": "declared consume parameters", @@ -31,28 +34,63 @@ var renderedDeclarationFields = map[string]string{ "single_consumer": "whether a second consumer is rejected", "resolved_output_schema": "fully resolved JSON schema of stdout events", "jq_root_path": "schema command only: jq root for consuming stdout", + + // Nested under params (ParamDef): everything an agent needs to pass the + // parameter correctly. + "name": "parameter name as passed via --param", + "type": "parameter value type (string/enum/multi/bool/int)", + "required": "whether the parameter must be provided", + "default": "value applied when the parameter is omitted", + "values": "allowed values for enum/multi parameters", + "subscription_key": "whether the parameter is part of the subscription identity", + + // Nested under params.values (ParamValue). + "value": "one allowed parameter value", + "desc": "what choosing this value means", + + // Nested under schema (SchemaDef / SchemaSpec): declaration markers only; + // the resolved schema is the sibling resolved_output_schema. + "native": "marker for keys delivering the raw V2 envelope", + "custom": "marker for keys delivering processed output", + "field_overrides": "per-field annotations overriding the reflected schema", + "raw": "raw declared schema bytes; empty for reflected types", + + // Nested under schema.field_overrides (schemas.FieldMeta). The type has + // no json tags, so encoding/json renders the Go field names — pinned + // as-is because retagging them would change the public bytes. + "Description": "override for the field's schema description", + "Enum": "override for the field's allowed values", + "Kind": "override rendered as the field's schema format", } // TestRenderContract_NoRuntimeFieldLeaksIntoJSON walks both rendered shapes, -// following embedded struct promotion, and fails on any exported member that -// is neither allowlisted nor explicitly excluded from JSON. +// following embedded struct promotion and recursing into every named type +// reachable through the rendered fields, and fails on any exported member +// that is neither allowlisted nor explicitly excluded from JSON. func TestRenderContract_NoRuntimeFieldLeaksIntoJSON(t *testing.T) { emitted := map[string]bool{} for _, typ := range []reflect.Type{ reflect.TypeFor[listRow](), reflect.TypeFor[schemaPayload](), } { - walkRenderedFields(t, typ, emitted) + walkRenderedFields(t, typ, emitted, map[reflect.Type]bool{}) } if len(emitted) == 0 { t.Fatal("no rendered fields were visited; the gate scanned nothing") } // The embedded definition is where leaks would hide: prove promotion was - // actually followed by requiring fields that only exist on it. - for _, sentinel := range []string{"key", "event_type", "resolved_output_schema"} { + // actually followed by requiring fields that only exist on it. The nested + // sentinels prove each recursion path is really taken: subscription_key + // (slice-of-struct: ParamDef), desc (slice inside a nested struct: + // ParamValue), raw (pointer-to-struct: SchemaSpec), Enum (map value: + // FieldMeta, rendered under its Go name because the type is untagged). + for _, sentinel := range []string{ + "key", "event_type", "resolved_output_schema", + "subscription_key", "desc", "raw", "Enum", + } { if !emitted[sentinel] { - t.Fatalf("field %q was not visited; embedded promotion is no longer being walked", sentinel) + t.Fatalf("field %q was not visited; the walker no longer reaches every rendered shape", sentinel) } } for name := range renderedDeclarationFields { @@ -62,38 +100,64 @@ func TestRenderContract_NoRuntimeFieldLeaksIntoJSON(t *testing.T) { } } -func walkRenderedFields(t *testing.T, typ reflect.Type, emitted map[string]bool) { +// walkRenderedFields records every JSON field name typ can render: embedded +// structs promote into the parent object, and any struct reachable through a +// field's type — behind pointers, slice/array elements, or map values — is +// walked in turn, so a field added to a nested type like ParamDef cannot +// escape the gate. visited breaks cycles; a type already recorded in this +// walk contributes nothing new. +func walkRenderedFields(t *testing.T, typ reflect.Type, emitted map[string]bool, visited map[reflect.Type]bool) { t.Helper() - if typ.Kind() == reflect.Pointer { - typ = typ.Elem() + typ = nestedStructType(typ) + if typ == nil || visited[typ] { + return } + visited[typ] = true for i := 0; i < typ.NumField(); i++ { field := typ.Field(i) if !field.IsExported() { continue } - if field.Anonymous { - ft := field.Type - if ft.Kind() == reflect.Pointer { - ft = ft.Elem() - } - if ft.Kind() == reflect.Struct { - walkRenderedFields(t, ft, emitted) - continue - } - } tag := field.Tag.Get("json") if tag == "-" { continue } - if tag == "" { - t.Errorf("%s.%s has no json tag and would render under its Go name; tag it or exclude it with json:\"-\"", typ.Name(), field.Name) - continue + if field.Anonymous && tag == "" { + if ft := nestedStructType(field.Type); ft != nil { + // Embedded struct without a tag: fields promote into the + // parent JSON object. + walkRenderedFields(t, ft, emitted, visited) + continue + } } name, _, _ := strings.Cut(tag, ",") + if name == "" { + // encoding/json renders an untagged exported field under its Go + // name (schemas.FieldMeta does this today); the rendered name is + // what the contract governs, so it is what must be declared. + name = field.Name + } if _, ok := renderedDeclarationFields[name]; !ok { - t.Errorf("%s.%s renders JSON field %q that is not in the declared output contract; add it deliberately or exclude it", typ.Name(), field.Name, name) + t.Errorf("%s.%s renders JSON field %q that is not in the declared output contract; add it deliberately or exclude it with json:\"-\"", typ.Name(), field.Name, name) } emitted[name] = true + walkRenderedFields(t, field.Type, emitted, visited) + } +} + +// nestedStructType unwraps pointers, slice/array elements, and map values +// until it reaches the struct that would render as a JSON object; nil means +// the type renders as a leaf (scalar, string, raw bytes) and holds no fields +// to govern. +func nestedStructType(typ reflect.Type) reflect.Type { + for { + switch typ.Kind() { + case reflect.Pointer, reflect.Slice, reflect.Array, reflect.Map: + typ = typ.Elem() + case reflect.Struct: + return typ + default: + return nil + } } } diff --git a/events/schema_instance_test.go b/events/schema_instance_test.go new file mode 100644 index 0000000000..dcac881816 --- /dev/null +++ b/events/schema_instance_test.go @@ -0,0 +1,259 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package events_test + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "sort" + "strconv" + "testing" + + "github.com/larksuite/cli/internal/event/catalog" +) + +// The output baseline freezes what every Processed key writes to stdout; the +// compiled catalog promises a schema for the same bytes. This test closes the +// loop between the two: every frozen output must be an instance of its key's +// resolved schema, so a schema and its real output can never drift apart with +// both sides individually green. +// +// The repository deliberately carries no JSON Schema validation dependency, +// so validation is done by a minimal in-repo checker that covers exactly the +// subset the catalog compiler emits (see validateValue). Any schema construct +// outside that subset is a loud failure, never a silent pass. +func TestProcessedBaselineOutputs_ConformToDeclaredSchemas(t *testing.T) { + snap := compileRealCatalog(t) + baseline := readBaselineSnapshot(t) + + validated := 0 + for _, entry := range snap.Entries() { + out := entry.Output() + if out.Mode != catalog.OutputProcessed { + continue + } + key := entry.Descriptor().Key + frozen, ok := baseline[key] + if !ok { + t.Errorf("%s: Processed key has no entry in %s; regenerate the baseline first", key, baselineSnapshotPath) + continue + } + schema := decodeSchemaNode(t, key, out.SchemaJSON) + instance := decodeInstance(t, key, frozen) + for _, problem := range validateValue("$", schema, instance) { + t.Errorf("%s: frozen output violates the declared schema: %s", key, problem) + } + validated++ + } + + // Idle detection, both directions: every Processed key was checked + // against a baseline entry, and no baseline entry escaped the check. + if validated == 0 { + t.Fatal("no Processed key was validated; the gate scanned nothing") + } + if validated != len(baseline) { + t.Fatalf("validated %d Processed keys but the baseline holds %d entries — a baseline entry matches no compiled Processed key (keys: %v)", + validated, len(baseline), sortedKeys(baseline)) + } +} + +// The validator itself must bite: an output tampered with in memory — an +// undeclared field, a primitive type flip — has to produce findings, +// otherwise a green conformance run proves nothing. The baseline file is +// never modified. +func TestSchemaInstanceValidator_BitesOnTamperedOutput(t *testing.T) { + const key = "im.message.receive_v1" + snap := compileRealCatalog(t) + entry, ok := snap.Resolve(key) + if !ok { + t.Fatalf("key %s is gone from the catalog; pick another Processed key for this self-check", key) + } + baseline := readBaselineSnapshot(t) + frozen, ok := baseline[key] + if !ok { + t.Fatalf("key %s has no baseline entry; the self-check needs a real frozen output", key) + } + schema := decodeSchemaNode(t, key, entry.Output().SchemaJSON) + + // Control: the untampered output is conformant, so any finding below is + // caused by the tampering alone. + if problems := validateValue("$", schema, decodeInstance(t, key, frozen)); len(problems) != 0 { + t.Fatalf("control failed: the untampered output already has findings: %v", problems) + } + + tampered, ok := decodeInstance(t, key, frozen).(map[string]any) + if !ok { + t.Fatalf("baseline output for %s is not a JSON object", key) + } + tampered["field_the_schema_never_declared"] = "smuggled" + if problems := validateValue("$", schema, tampered); len(problems) != 1 { + t.Errorf("an undeclared field must produce exactly one finding, got: %v", problems) + } + + flipped, _ := decodeInstance(t, key, frozen).(map[string]any) + flipped["message_id"] = true // declared as a string + if problems := validateValue("$", schema, flipped); len(problems) != 1 { + t.Errorf("a primitive type flip must produce exactly one finding, got: %v", problems) + } +} + +func readBaselineSnapshot(t *testing.T) map[string]json.RawMessage { + t.Helper() + data, err := os.ReadFile(baselineSnapshotPath) + if err != nil { + t.Fatalf("read %s: %v", baselineSnapshotPath, err) + } + var out map[string]json.RawMessage + if err := json.Unmarshal(data, &out); err != nil { + t.Fatalf("%s is not valid JSON: %v", baselineSnapshotPath, err) + } + return out +} + +func decodeSchemaNode(t *testing.T, key string, raw json.RawMessage) map[string]any { + t.Helper() + var schema map[string]any + if err := json.Unmarshal(raw, &schema); err != nil { + t.Fatalf("%s: resolved schema is not a JSON object: %v", key, err) + } + return schema +} + +// decodeInstance parses a frozen output with UseNumber so integer/number +// checks see the literal digits instead of a lossy float64. +func decodeInstance(t *testing.T, key string, raw json.RawMessage) any { + t.Helper() + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + var v any + if err := dec.Decode(&v); err != nil { + t.Fatalf("%s: baseline output is not valid JSON: %v", key, err) + } + return v +} + +// validateValue checks one instance value against one schema node and returns +// the problems found. It implements only the subset the catalog compiler can +// emit (schemas.FromType plus raw declarations shaped the same way): +// +// - type object with properties: every instance field must be declared in +// properties and conform to its node; undeclared fields are errors. +// Absent declared fields are legal (handlers omit empty members). +// - type string / integer / number / boolean: the JSON value kind must +// match. +// - type array with items: every element must conform to items. +// +// description/format/enum annotations are metadata, not instance constraints +// here. Any construct outside the subset — a missing or unknown type, an +// object without properties, additionalProperties, an array without items — +// is reported as a problem so the validator can only be extended +// deliberately, never bypassed by a schema it does not understand. +func validateValue(path string, schema map[string]any, value any) []string { + typ, ok := schema["type"].(string) + if !ok { + return []string{fmt.Sprintf("%s: schema node has no \"type\"; outside the minimal validator subset, extend the validator deliberately", path)} + } + + switch typ { + case "object": + obj, ok := value.(map[string]any) + if !ok { + return []string{fmt.Sprintf("%s: schema declares object, output has %s", path, jsonKind(value))} + } + if _, has := schema["additionalProperties"]; has { + return []string{fmt.Sprintf("%s: schema uses additionalProperties; outside the minimal validator subset, extend the validator deliberately", path)} + } + props, ok := schema["properties"].(map[string]any) + if !ok { + return []string{fmt.Sprintf("%s: object schema without properties; outside the minimal validator subset, extend the validator deliberately", path)} + } + var problems []string + for _, field := range sortedFieldNames(obj) { + fieldPath := path + "." + field + node, declared := props[field] + if !declared { + problems = append(problems, fmt.Sprintf("%s: field is not declared in the schema properties", fieldPath)) + continue + } + nodeObj, ok := node.(map[string]any) + if !ok { + problems = append(problems, fmt.Sprintf("%s: schema property is not an object", fieldPath)) + continue + } + problems = append(problems, validateValue(fieldPath, nodeObj, obj[field])...) + } + return problems + + case "string": + if _, ok := value.(string); !ok { + return []string{fmt.Sprintf("%s: schema declares string, output has %s", path, jsonKind(value))} + } + case "boolean": + if _, ok := value.(bool); !ok { + return []string{fmt.Sprintf("%s: schema declares boolean, output has %s", path, jsonKind(value))} + } + case "integer": + num, ok := value.(json.Number) + if !ok { + return []string{fmt.Sprintf("%s: schema declares integer, output has %s", path, jsonKind(value))} + } + if _, err := strconv.ParseInt(num.String(), 10, 64); err != nil { + return []string{fmt.Sprintf("%s: schema declares integer, output has non-integer number %s", path, num)} + } + case "number": + if _, ok := value.(json.Number); !ok { + return []string{fmt.Sprintf("%s: schema declares number, output has %s", path, jsonKind(value))} + } + + case "array": + arr, ok := value.([]any) + if !ok { + return []string{fmt.Sprintf("%s: schema declares array, output has %s", path, jsonKind(value))} + } + items, ok := schema["items"].(map[string]any) + if !ok { + return []string{fmt.Sprintf("%s: array schema without items; outside the minimal validator subset, extend the validator deliberately", path)} + } + var problems []string + for i, elem := range arr { + problems = append(problems, validateValue(fmt.Sprintf("%s[%d]", path, i), items, elem)...) + } + return problems + + default: + return []string{fmt.Sprintf("%s: schema type %q; outside the minimal validator subset, extend the validator deliberately", path, typ)} + } + return nil +} + +// jsonKind names a decoded JSON value's kind for problem messages. +func jsonKind(v any) string { + switch v.(type) { + case nil: + return "null" + case bool: + return "boolean" + case string: + return "string" + case json.Number: + return "number" + case []any: + return "array" + case map[string]any: + return "object" + default: + return fmt.Sprintf("%T", v) + } +} + +func sortedFieldNames(obj map[string]any) []string { + names := make([]string, 0, len(obj)) + for name := range obj { + names = append(names, name) + } + sort.Strings(names) + return names +} diff --git a/internal/event/arch_layering_test.go b/internal/event/arch_layering_test.go index 7d7decc7d5..4b00f9fb59 100644 --- a/internal/event/arch_layering_test.go +++ b/internal/event/arch_layering_test.go @@ -207,6 +207,115 @@ func TestArchKernelPurity(t *testing.T) { } } +// archForbiddenHostImport reports why importPath is banned inside a host +// production file, if it is. Hosts are deliberately held to a looser standard +// than the kernel: they own sockets, so the standard library net packages are +// legitimate there (the local bus is IPC over a socket) and only the platform +// SDK is banned — a host speaking the platform wire format directly would +// bypass the bus and its adapters, which is the seam the architecture exists +// to keep. Matching is segment-boundary safe so a lookalike module name +// cannot trip it. +func archForbiddenHostImport(importPath string) (reason string, banned bool) { + const sdk = "github.com/larksuite/oapi-sdk-go" + if importPath == sdk || strings.HasPrefix(importPath, sdk+"/") { + return "platform SDK; hosts reach the platform only through the bus and its adapters, never by speaking the platform wire format themselves", true + } + return "", false +} + +// TestArchHostPlatformSDKBan fails when any host production file imports the +// platform SDK. The host set is pinned to the two long-running processes; a +// new host must be added here deliberately. +func TestArchHostPlatformSDKBan(t *testing.T) { + hosts := []string{"bus", "consume"} + + sawNetImport := false + for _, host := range hosts { + if _, ok := outwardFacing[host]; !ok { + t.Fatalf("host %q is not in the outwardFacing set — the host moved and this gate is scanning a ghost", host) + } + files := archProductionFilesUnder(t, host) + if len(files) == 0 { + t.Fatalf("no production files found under %s — the walker is idling or the host is gone; fix that before trusting this gate", host) + } + fset := token.NewFileSet() + for _, file := range files { + for _, path := range archImports(t, fset, file) { + if path == "net" || strings.HasPrefix(path, "net/") { + sawNetImport = true + } + if reason, banned := archForbiddenHostImport(path); banned { + t.Errorf("%s imports %q: %s", filepath.ToSlash(file), path, reason) + } + } + } + } + + // Tripwire: hosts do socket IPC, so the scan of real host files must have + // seen a net import. Seeing none means the import walk is not reading what + // the hosts actually import, and a green run would prove nothing. (It also + // pins the reason net is absent from the ban set: hosts need it.) + if !sawNetImport { + t.Fatal("scanned every host production file and found no net import — hosts are socket-IPC processes, so the import scan cannot be trusted; if the hosts genuinely dropped net, update this tripwire deliberately") + } +} + +// TestArchHostImportDetectorSelfCheck runs the host forbidden-import matcher +// on synthetic sources with known outcomes, so a drifted matcher cannot keep +// TestArchHostPlatformSDKBan green on a violating tree. +func TestArchHostImportDetectorSelfCheck(t *testing.T) { + scan := func(src string) []string { + t.Helper() + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "synthetic.go", src, parser.ImportsOnly) + if err != nil { + t.Fatalf("parse synthetic source: %v", err) + } + var flagged []string + for _, imp := range f.Imports { + path, err := strconv.Unquote(imp.Path.Value) + if err != nil { + t.Fatalf("unquote synthetic import: %v", err) + } + if _, banned := archForbiddenHostImport(path); banned { + flagged = append(flagged, path) + } + } + sort.Strings(flagged) + return flagged + } + + const violating = `package fakehost + +import ( + _ "github.com/larksuite/oapi-sdk-go/v3" + _ "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" + _ "net" +) +` + want := []string{ + "github.com/larksuite/oapi-sdk-go/v3", + "github.com/larksuite/oapi-sdk-go/v3/service/im/v1", + } + if got := scan(violating); !slices.Equal(got, want) { + t.Fatalf("detector self-check: flagged %v, want exactly %v — the matcher drifted and the host gate cannot be trusted", got, want) + } + + // net stays legal for hosts (socket IPC), and prefix matching must respect + // path segment boundaries. + const clean = `package fakehost + +import ( + _ "github.com/larksuite/oapi-sdk-golike" + _ "net" + _ "net/http" +) +` + if got := scan(clean); len(got) != 0 { + t.Fatalf("detector self-check: clean synthetic source flagged %v — the matcher over-triggers", got) + } +} + // hostAdapterAllowlist pins, per host, the exact adapter import paths its // production code uses today. // diff --git a/internal/event/catalog/canonicalize.go b/internal/event/catalog/canonicalize.go index b6fddbcc54..c2444730f2 100644 --- a/internal/event/catalog/canonicalize.go +++ b/internal/event/catalog/canonicalize.go @@ -4,9 +4,10 @@ package catalog import ( - "maps" "slices" "strings" + + "github.com/larksuite/cli/internal/event/schemas" ) // Canonicalize returns the normalized copy of a declaration: the defaults @@ -66,6 +67,16 @@ func deepCopyDefinition(def KeyDefinition) KeyDefinition { spec.Raw = slices.Clone(def.Schema.Custom.Raw) out.Schema.Custom = &spec } - out.Schema.FieldOverrides = maps.Clone(def.Schema.FieldOverrides) + // A plain map clone is not enough here: FieldMeta.Enum is a slice, so the + // cloned map's values would still share the Enum backing arrays with the + // source. Copy each entry and clone its slice members. + if def.Schema.FieldOverrides != nil { + overrides := make(map[string]schemas.FieldMeta, len(def.Schema.FieldOverrides)) + for path, meta := range def.Schema.FieldOverrides { + meta.Enum = slices.Clone(meta.Enum) + overrides[path] = meta + } + out.Schema.FieldOverrides = overrides + } return out } diff --git a/internal/event/catalog/snapshot.go b/internal/event/catalog/snapshot.go index 850c118fdf..c3d42084cd 100644 --- a/internal/event/catalog/snapshot.go +++ b/internal/event/catalog/snapshot.go @@ -128,8 +128,10 @@ func (s *Snapshot) Keys() []string { return slices.Clone(s.keys) } // Len reports how many keys were compiled. func (s *Snapshot) Len() int { return len(s.keys) } -// Resolve returns the entry for a key. The two return values come from the -// same entry, so a definition and its capability can never be mismatched. +// Resolve returns the compiled entry for key, with ok=false for a key the +// catalog does not have. Every projection (descriptor, output, capability, +// binding) is read off the returned entry, so facts about one key can never +// be paired with another key's. func (s *Snapshot) Resolve(key string) (*Entry, bool) { e, ok := s.entries[key] return e, ok @@ -145,7 +147,7 @@ func (s *Snapshot) Entries() []*Entry { } // Definitions returns the canonical compatibility view of every entry in key -// order — the drop-in replacement for the old registry listing. +// order. Each element is a fresh deep copy, like Entry.Definition. func (s *Snapshot) Definitions() []*KeyDefinition { out := make([]*KeyDefinition, 0, len(s.keys)) for _, k := range s.keys { diff --git a/internal/event/catalog/snapshot_test.go b/internal/event/catalog/snapshot_test.go index b92741eb62..b158c17358 100644 --- a/internal/event/catalog/snapshot_test.go +++ b/internal/event/catalog/snapshot_test.go @@ -11,6 +11,7 @@ import ( "github.com/larksuite/cli/internal/event/model" "github.com/larksuite/cli/internal/event/processing" + "github.com/larksuite/cli/internal/event/schemas" ) func compiledFixture(t *testing.T) *Snapshot { @@ -74,6 +75,33 @@ func TestSnapshot_IsImmutableFromOutside(t *testing.T) { } } +// FieldOverrides values carry a slice-typed member (FieldMeta.Enum), so a +// shallow map clone still shares the Enum backing arrays: writing through one +// copy would rewrite the catalog for everyone. Both directions must hold — +// a returned Definition and the original compile input are equally outside. +func TestSnapshot_FieldOverrideEnumIsNotShared(t *testing.T) { + def := validDef() + def.Schema.FieldOverrides = map[string]schemas.FieldMeta{ + "/id": {Description: "the id", Enum: []string{"a", "b"}}, + } + snap, err := Compile([]KeyDefinition{def}, testStrategies) + if err != nil { + t.Fatal(err) + } + entry, _ := snap.Resolve(def.Key) + + got := entry.Definition() + got.Schema.FieldOverrides["/id"].Enum[0] = "tampered-via-definition" + if fresh := entry.Definition(); fresh.Schema.FieldOverrides["/id"].Enum[0] != "a" { + t.Error("mutating a returned Definition's FieldOverrides Enum leaked into the snapshot") + } + + def.Schema.FieldOverrides["/id"].Enum[1] = "tampered-via-input" + if fresh := entry.Definition(); fresh.Schema.FieldOverrides["/id"].Enum[1] != "b" { + t.Error("mutating the compile input's FieldOverrides Enum leaked into the snapshot") + } +} + // The compiler deep-copies its input: mutating the declaration after Compile // must not reach the snapshot either. func TestSnapshot_DoesNotAliasCompileInput(t *testing.T) { diff --git a/internal/event/consume/consume.go b/internal/event/consume/consume.go index ee39bd29f7..970480d1d4 100644 --- a/internal/event/consume/consume.go +++ b/internal/event/consume/consume.go @@ -25,8 +25,8 @@ type Options struct { // Def is the resolved declaration for EventKey. The caller resolves it // from the compiled catalog; this package validates and consumes it but // never looks anything up itself. - Def *event.KeyDefinition - Params map[string]string + Def *event.KeyDefinition + Params map[string]string // ParamsNormalized marks Params as already normalized by the declaration's // NormalizeParams hook (the deciding layer runs it on these exact values). // The host then skips the hook, keeping its run-once-per-consumer contract. diff --git a/internal/event/consume/diagnostics_redaction_test.go b/internal/event/consume/diagnostics_redaction_test.go index 83082188b3..5206317776 100644 --- a/internal/event/consume/diagnostics_redaction_test.go +++ b/internal/event/consume/diagnostics_redaction_test.go @@ -7,6 +7,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "strings" "testing" @@ -76,6 +77,89 @@ func TestMalformedDrop_DiagnosticNamesIdentityOnly(t *testing.T) { assertNoPayloadBytes(t, diag, sentinel) } +// A Process error is reported on stderr, and error text routinely embeds +// input fragments (a parse error quoting the payload, an API response echo). +// The diagnostic must therefore carry only a bounded prefix of the error, so +// content sitting past the cap never reaches stderr. +func TestProcessErrorDiagnostic_TruncatesLongErrorText(t *testing.T) { + const sentinel = "PAYLOAD-FRAGMENT-IN-ERROR-zyx987" + // The sentinel sits entirely beyond the truncation cap. + longErr := strings.Repeat("x", diagnosticErrMaxLen+50) + sentinel + + // Control: an untruncated diagnostic would contain the sentinel, so the + // leak assertion below is able to detect a regression. + if !strings.Contains("WARN: Process error: "+longErr, sentinel) { + t.Fatal("control failed: the sentinel is not in the raw error; the test cannot prove truncation") + } + + keyDef := &event.KeyDefinition{ + Key: "test.evt_process_error", + EventType: "test.evt_process_error", + Process: func(context.Context, event.APIClient, *event.RawEvent, map[string]string) (json.RawMessage, error) { + return nil, errors.New(longErr) + }, + } + var stderr bytes.Buffer + var stdout bytes.Buffer + opts := Options{ErrOut: &stderr, Params: map[string]string{}} + sink := &WriterSink{W: &stdout} + + evt := protocol.NewEvent(&model.Event{ + EventID: "evt-process-err-1", + EventType: "test.evt_process_error", + Payload: json.RawMessage(`{}`), + }, 1) + + wrote, err := processAndOutput(context.Background(), keyDef, evt, opts, sink, nil) + if wrote || err != nil { + t.Fatalf("a Process error must drop the event without a sink error: wrote=%v err=%v", wrote, err) + } + if stdout.Len() != 0 { + t.Errorf("nothing may reach stdout for a dropped event, got: %s", stdout.String()) + } + diag := stderr.String() + if !strings.Contains(diag, "WARN: Process error:") { + t.Errorf("expected a process-error diagnostic, got: %q", diag) + } + if !strings.Contains(diag, "...(truncated)") { + t.Errorf("a long error must be marked as truncated, got: %q", diag) + } + assertNoPayloadBytes(t, diag, sentinel) +} + +// A short Process error passes through whole — truncation only engages past +// the cap, so ordinary diagnostics stay fully readable. +func TestProcessErrorDiagnostic_KeepsShortErrorIntact(t *testing.T) { + const shortErr = "decode meeting id: unexpected end of JSON input" + keyDef := &event.KeyDefinition{ + Key: "test.evt_process_error_short", + EventType: "test.evt_process_error_short", + Process: func(context.Context, event.APIClient, *event.RawEvent, map[string]string) (json.RawMessage, error) { + return nil, errors.New(shortErr) + }, + } + var stderr bytes.Buffer + opts := Options{ErrOut: &stderr, Params: map[string]string{}} + sink := &WriterSink{W: &bytes.Buffer{}} + + evt := protocol.NewEvent(&model.Event{ + EventID: "evt-process-err-2", + EventType: "test.evt_process_error_short", + Payload: json.RawMessage(`{}`), + }, 1) + + if wrote, err := processAndOutput(context.Background(), keyDef, evt, opts, sink, nil); wrote || err != nil { + t.Fatalf("a Process error must drop the event without a sink error: wrote=%v err=%v", wrote, err) + } + diag := stderr.String() + if !strings.Contains(diag, "WARN: Process error: "+shortErr) { + t.Errorf("a short error must be reported verbatim, got: %q", diag) + } + if strings.Contains(diag, "...(truncated)") { + t.Errorf("a short error must not be marked as truncated, got: %q", diag) + } +} + // A metadata conflict is dropped through the real pipeline the same way. func TestConflictDrop_DiagnosticNamesIdentityOnly(t *testing.T) { const sentinel = "PRIVATE-MESSAGE-TEXT-qwerty" diff --git a/internal/event/consume/loop.go b/internal/event/consume/loop.go index fe0c7c4817..24379627d2 100644 --- a/internal/event/consume/loop.go +++ b/internal/event/consume/loop.go @@ -15,6 +15,7 @@ import ( "sync/atomic" "syscall" "time" + "unicode/utf8" "github.com/itchyny/gojq" "github.com/larksuite/cli/internal/event" @@ -198,6 +199,26 @@ func consumeLoop(ctx context.Context, conn net.Conn, br *bufio.Reader, keyDef *e return nil } +// diagnosticErrMaxLen caps how much of a Process error text reaches stderr. +// Error strings routinely embed input fragments (a parse error quoting the +// payload, an API response echo), so the diagnostic keeps only a bounded +// prefix of them. +const diagnosticErrMaxLen = 200 + +// truncateDiagnostic bounds s to diagnosticErrMaxLen bytes, backing off to +// the previous rune boundary so the cut never emits invalid UTF-8, and marks +// the cut explicitly. +func truncateDiagnostic(s string) string { + if len(s) <= diagnosticErrMaxLen { + return s + } + cut := diagnosticErrMaxLen + for cut > 0 && !utf8.RuneStart(s[cut]) { + cut-- + } + return s[:cut] + "...(truncated)" +} + // processAndOutput returns (wrote, err); err non-nil only for sink.Write failures. func processAndOutput(ctx context.Context, keyDef *event.KeyDefinition, evt *protocol.Event, opts Options, sink Sink, jqCode *gojq.Code) (bool, error) { raw := restoreCanonicalEvent(evt) @@ -230,7 +251,7 @@ func processAndOutput(ctx context.Context, keyDef *event.KeyDefinition, evt *pro fmt.Fprintf(opts.ErrOut, "WARN: event %s (%s) dropped: malformed payload\n", raw.EventID, raw.EventType) } else { - fmt.Fprintf(opts.ErrOut, "WARN: Process error: %v\n", err) + fmt.Fprintf(opts.ErrOut, "WARN: Process error: %s\n", truncateDiagnostic(err.Error())) } } return false, nil From 88a93254c087b388f279bdc7f0eb691cbc10819a Mon Sep 17 00:00:00 2001 From: leave330 Date: Sat, 1 Aug 2026 17:11:51 +0800 Subject: [PATCH 37/61] docs(event): tighten the dry-run guidance the eval verified --- skills/lark-event/SKILL.md | 35 +++-------------------------------- 1 file changed, 3 insertions(+), 32 deletions(-) diff --git a/skills/lark-event/SKILL.md b/skills/lark-event/SKILL.md index dcfef63477..5bc1d322c6 100644 --- a/skills/lark-event/SKILL.md +++ b/skills/lark-event/SKILL.md @@ -68,40 +68,11 @@ wait ## Dry-run preview -`event consume --dry-run` performs only the read-only checks, then prints the consume decision on stdout as a standard envelope and exits 0 — no bus is started, no consumer registered, no server-side subscription touched, no files created. +When the user asks to preview a consume or check readiness, execute `event consume --dry-run` instead of only quoting it. It exits 0 after read-only checks without starting a bus, registering a consumer, touching a server-side subscription, creating files, or opening the event stream. -**Run it, don't just recommend it.** Unlike a real consume, dry-run exits immediately and never starts listening or connecting to the event stream, so it is always safe to execute. When the user asks to preview / rehearse / check what would happen ("先预演一遍"), execute the dry-run and summarize the actual output — `dry_run`, `decision.status`, `would_write`, and `preparation` (strategy / condition / action) — instead of only showing the command. The same applies to readiness questions ("环境/权限是否就绪能不能收到事件"): the answer must come from an executed dry-run's `status` and `preconditions`, not from reading documentation — docs cannot see this tenant's console switches or granted scopes. +Read the top-level `dry_run` marker and `data.decision`: `status` / `preconditions` report readiness, `would_write` previews real-run side effects, and optional `preparation` states its strategy and trigger condition. `ready` can run, `unknown` proceeds with an unresolved weak check, and `blocked` would refuse a real run. -```json -{ - "ok": true, - "identity": "user", - "dry_run": true, - "data": { - "decision": { - "event_key": "vc.note.generated_v1", - "domain": "vc", - "identity": "user", - "status": "ready", - "params": {}, - "scope": "vc.note.generated_v1", - "preconditions": [ - {"name": "console_event_published", "status": "ok"}, - {"name": "scopes_granted", "status": "ok"} - ], - "preparation": { - "strategy": "legacy_preconsume", - "condition": "first_consumer_for_scope", - "action": "register_event_delivery" - }, - "would_read": ["local_bus_probe", "app_metadata_preflight"], - "would_write": ["start_or_reuse_local_bus", "register_consumer", "run_preparation_when_first", "open_event_stream"] - } - } -} -``` - -Read `dry_run` at the envelope top level (not inside data). `data.decision.preparation` is present only for keys that need preparation (keys without a server-side registration step omit the field entirely). `data.decision.status` is `ready` / `unknown` / `blocked`: `unknown` means a weak read-only check could not answer (a real run would still proceed); `blocked` means a real run would refuse with the same error shown in the precondition detail. `would_write` lists the side effects a real run performs — `preparation.condition` = `first_consumer_for_scope` means the server-side subscription is registered only by the first consumer of that scope. Input errors behave exactly like a real run (unknown key / bad params → exit 2). An unusable credential does **not** fail the dry-run: it renders a `blocked` decision (exit 0) with a `credentials_available` precondition carrying the auth error — only a real run exits 3 on it. +Unknown keys or bad params exit 2. Unusable credentials render `blocked` with a `credentials_available` precondition and still exit 0; a real run exits 3. ## Subprocess contract From d9ac2fa9ed27cd5f21f16d685a78ca623410b190 Mon Sep 17 00:00:00 2001 From: leave330 Date: Sat, 1 Aug 2026 17:17:12 +0800 Subject: [PATCH 38/61] feat(event): list valid domains in the --domain flag help --- cmd/event/format_helpers_test.go | 8 ++++++++ cmd/event/list.go | 5 ++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/cmd/event/format_helpers_test.go b/cmd/event/format_helpers_test.go index 246d768dbe..2398f83d25 100644 --- a/cmd/event/format_helpers_test.go +++ b/cmd/event/format_helpers_test.go @@ -325,6 +325,14 @@ func TestNewCmdFactories_WireFlags(t *testing.T) { if cmd.Flags().Lookup("json") == nil { t.Error("list missing --json flag") } + domainFlag := cmd.Flags().Lookup("domain") + if domainFlag == nil { + t.Fatal("list missing --domain flag") + } + wantUsage := "Only list EventKeys of this domain. Valid domains: " + strings.Join(snap.Domains(), ", ") + if domainFlag.Usage != wantUsage { + t.Errorf("--domain usage = %q, want %q", domainFlag.Usage, wantUsage) + } }) t.Run("bus", func(t *testing.T) { diff --git a/cmd/event/list.go b/cmd/event/list.go index e0a0209ab1..beeb419099 100644 --- a/cmd/event/list.go +++ b/cmd/event/list.go @@ -29,7 +29,10 @@ func NewCmdList(f *cmdutil.Factory, snap *catalog.Snapshot) *cobra.Command { }, } cmd.Flags().BoolVar(&asJSON, "json", false, "Emit the full EventKey list as JSON (for AI / scripts)") - cmd.Flags().StringVar(&domain, "domain", "", "Only list EventKeys of this domain (the key's first segment, e.g. im, vc)") + cmd.Flags().StringVar(&domain, "domain", "", fmt.Sprintf( + "Only list EventKeys of this domain. Valid domains: %s", + strings.Join(snap.Domains(), ", "), + )) cmdutil.SetRisk(cmd, "read") return cmd } From 591fc5c0d446e11731061f0442a194af08ca728b Mon Sep 17 00:00:00 2001 From: leave330 Date: Mon, 3 Aug 2026 10:51:09 +0800 Subject: [PATCH 39/61] ci: allowlist dotted event identifiers in gitleaks EventKey names like minutes.minute.generated_v1 in the catalog golden fixtures trip the generic-api-key entropy heuristic. Allowlist secrets that are exactly dotted lowercase identifiers; real credentials do not take that shape, so detection strength is unchanged. --- .gitleaks.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitleaks.toml b/.gitleaks.toml index 8dbe4165f2..a07f4d1d45 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -3,6 +3,11 @@ title = "lark-cli gitleaks config" [extend] useDefault = true +[allowlist] +description = "EventKey identifiers (dotted lowercase names like minutes.minute.generated_v1) are public catalog data rendered in fixtures and docs, not credentials; the entropy heuristic of generic-api-key misreads them" +regexTarget = "secret" +regexes = ['''^[a-z0-9_]+(\.[a-z0-9_]+){2,}$'''] + [[rules]] id = "lark-bot-app-id" description = "Detect Lark bot app ids" From edc2b79f4d9a6b2b4e59aa1fa17470c35ca5f56b Mon Sep 17 00:00:00 2001 From: leave330 Date: Mon, 3 Aug 2026 10:51:09 +0800 Subject: [PATCH 40/61] test(event): remove unused mock source name accessor --- internal/event/adapter/lark/websocket/source_test.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/internal/event/adapter/lark/websocket/source_test.go b/internal/event/adapter/lark/websocket/source_test.go index 09031862a0..7ba0cb557a 100644 --- a/internal/event/adapter/lark/websocket/source_test.go +++ b/internal/event/adapter/lark/websocket/source_test.go @@ -12,11 +12,9 @@ import ( ) type mockSource struct { - name string events []*event.RawEvent } -func (s *mockSource) Name() string { return s.name } func (s *mockSource) Start(ctx context.Context, _ []string, emit func(*event.RawEvent), _ StatusNotifier) error { for _, e := range s.events { emit(e) @@ -27,7 +25,6 @@ func (s *mockSource) Start(ctx context.Context, _ []string, emit func(*event.Raw func TestMockSource_EmitsEvents(t *testing.T) { src := &mockSource{ - name: "test", events: []*event.RawEvent{ {EventID: "1", EventType: "im.message.receive_v1"}, {EventID: "2", EventType: "im.message.receive_v1"}, From aa4a682d0383f23158c2189689802b3a4bb2e2ca Mon Sep 17 00:00:00 2001 From: leave330 Date: Mon, 3 Aug 2026 10:51:09 +0800 Subject: [PATCH 41/61] fix(event): normalize observed_at to utc and warn when it fails to parse The wire frame formatted observed_at with the emitting host's local offset; normalize to UTC so frame bytes do not depend on where the bus runs. A non-empty observed_at that fails RFC3339Nano parsing on restore now surfaces a stderr diagnostic instead of silently zeroing, matching how canonical-metadata conflicts are reported. --- internal/event/adapter/localbus/protocol/messages.go | 5 ++++- internal/event/bus/hub_observability_test.go | 4 +++- internal/event/consume/loop.go | 12 ++++++++++-- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/internal/event/adapter/localbus/protocol/messages.go b/internal/event/adapter/localbus/protocol/messages.go index c8a2ff5857..afc7f78396 100644 --- a/internal/event/adapter/localbus/protocol/messages.go +++ b/internal/event/adapter/localbus/protocol/messages.go @@ -160,7 +160,10 @@ func NewHelloAckRejected(busVersion, reason string) *HelloAck { func NewEvent(ev *model.Event, seq uint64) *Event { observedAt := "" if !ev.Timestamp.IsZero() { - observedAt = ev.Timestamp.Format(time.RFC3339Nano) + // UTC-normalized so the wire never carries the emitting host's local + // offset; consumers parse RFC3339Nano either way, but the frame bytes + // should not depend on where the bus happens to run. + observedAt = ev.Timestamp.UTC().Format(time.RFC3339Nano) } return &Event{ Type: MsgTypeEvent, diff --git a/internal/event/bus/hub_observability_test.go b/internal/event/bus/hub_observability_test.go index c290cf013e..4e18c84ef3 100644 --- a/internal/event/bus/hub_observability_test.go +++ b/internal/event/bus/hub_observability_test.go @@ -82,7 +82,9 @@ func TestPublishPopulatesEventIDAndSourceTime(t *testing.T) { if ev.SourceTime != "" { t.Errorf("SourceTime must stay empty without upstream create_time, got %q", ev.SourceTime) } - if want := observed.Format(time.RFC3339Nano); ev.ObservedAt != want { + // UTC-normalized on the wire, so the frame bytes do not depend on the + // emitting host's local timezone. + if want := observed.UTC().Format(time.RFC3339Nano); ev.ObservedAt != want { t.Errorf("ObservedAt: got %q, want %q", ev.ObservedAt, want) } } diff --git a/internal/event/consume/loop.go b/internal/event/consume/loop.go index 24379627d2..a3a8c71f73 100644 --- a/internal/event/consume/loop.go +++ b/internal/event/consume/loop.go @@ -9,6 +9,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "io/fs" "net" "sync" @@ -221,7 +222,7 @@ func truncateDiagnostic(s string) string { // processAndOutput returns (wrote, err); err non-nil only for sink.Write failures. func processAndOutput(ctx context.Context, keyDef *event.KeyDefinition, evt *protocol.Event, opts Options, sink Sink, jqCode *gojq.Code) (bool, error) { - raw := restoreCanonicalEvent(evt) + raw := restoreCanonicalEvent(evt, opts.ErrOut, opts.Quiet) // Validate before any domain work: a payload header that contradicts the // canonical metadata means the two sources of truth diverged somewhere on @@ -287,11 +288,18 @@ func processAndOutput(ctx context.Context, keyDef *event.KeyDefinition, evt *pro // full. Every fact the ingress parsed must survive into the domain hooks — // restoring only a subset is how processors historically ended up re-parsing // the payload header as a second source of truth. -func restoreCanonicalEvent(evt *protocol.Event) *event.RawEvent { +func restoreCanonicalEvent(evt *protocol.Event, errOut io.Writer, quiet bool) *event.RawEvent { var observed time.Time if evt.ObservedAt != "" { if parsed, err := time.Parse(time.RFC3339Nano, evt.ObservedAt); err == nil { observed = parsed + } else if !quiet { + // A non-empty observed_at that fails to parse is a delivery + // defect of the same class as a canonical-metadata conflict: + // surface it, keep the event (empty means "missing upstream + // timestamp" and stays silent by design). + fmt.Fprintf(errOut, "WARN: event %s (%s): malformed observed_at %q ignored: %v\n", + evt.EventID, evt.EventType, evt.ObservedAt, err) } } return &event.RawEvent{ From 585ec8758a19575e0382879fe4d7d94b9176c6c5 Mon Sep 17 00:00:00 2001 From: leave330 Date: Mon, 3 Aug 2026 10:51:10 +0800 Subject: [PATCH 42/61] fix(event): preserve error causes and guard blocked decisions Strategy-lookup failures now carry their cause for errors.Is/As. A decision blocked by a precondition that supplied no error synthesizes a failed_precondition error, so Execute can never no-op with nil on a blocked decision. --- internal/event/application/consume/service.go | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/internal/event/application/consume/service.go b/internal/event/application/consume/service.go index 7111c54602..779ff3c632 100644 --- a/internal/event/application/consume/service.go +++ b/internal/event/application/consume/service.go @@ -77,7 +77,7 @@ func (s *Service) Decide(ctx context.Context, entry *catalog.Entry, req Request, strategyRef := entry.Capability().Preparation strategy, err := s.Strategies.get(strategyRef) if err != nil { - return nil, errs.NewInternalError(errs.SubtypeUnknown, "%s", err) + return nil, errs.NewInternalError(errs.SubtypeUnknown, "%s", err).WithCause(err) } prep, err := strategy.Decide(ctx, PreparedConsume{Entry: entry, Params: params}) if err != nil { @@ -104,10 +104,14 @@ func (s *Service) Decide(ctx context.Context, entry *catalog.Entry, req Request, } d.status = StatusReady + blockedName := "" for i := range preconditions { switch preconditions[i].Status { case PreconditionBlocked: d.status = StatusBlocked + if blockedName == "" { + blockedName = preconditions[i].Name + } if d.blockErr == nil { d.blockErr = preconditions[i].BlockErr } @@ -117,6 +121,13 @@ func (s *Service) Decide(ctx context.Context, entry *catalog.Entry, req Request, } } } + // A blocked decision must carry the error Execute returns; a preflight + // reader that reports blocked without one would otherwise make Execute a + // silent nil no-op. + if d.status == StatusBlocked && d.blockErr == nil { + d.blockErr = errs.NewValidationError(errs.SubtypeFailedPrecondition, + "precondition %s blocks consuming %s", blockedName, def.Key) + } return d, nil } @@ -138,7 +149,7 @@ func (s *Service) Execute(ctx context.Context, entry *catalog.Entry, d *Decision if ref := entry.Capability().Preparation; ref != catalog.StrategyNone { strategy, err := s.Strategies.get(ref) if err != nil { - return errs.NewInternalError(errs.SubtypeUnknown, "%s", err) + return errs.NewInternalError(errs.SubtypeUnknown, "%s", err).WithCause(err) } if d.preparation == nil { return errs.NewInternalError(errs.SubtypeUnknown, From 11d953500cc02df5ac6b04928481757c25daf1ec Mon Sep 17 00:00:00 2001 From: leave330 Date: Mon, 3 Aug 2026 10:51:10 +0800 Subject: [PATCH 43/61] fix(event): report unknown params deterministically Map iteration order made the reported name vary when several unknown params were passed at once; sort and report the first. --- internal/event/catalog/params.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/internal/event/catalog/params.go b/internal/event/catalog/params.go index 01b76eb697..13d47a113a 100644 --- a/internal/event/catalog/params.go +++ b/internal/event/catalog/params.go @@ -37,10 +37,16 @@ func ValidateParams(def *KeyDefinition, params map[string]string) error { validNames = append(validNames, p.Name) } sort.Strings(validNames) + unknown := make([]string, 0, len(params)) for k := range params { - if known[k] { - continue + if !known[k] { + unknown = append(unknown, k) } + } + if len(unknown) > 0 { + // Sorted so the reported name does not vary with map iteration order. + sort.Strings(unknown) + k := unknown[0] if len(validNames) == 0 { return errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown param %q: EventKey %s accepts no params", k, def.Key). From 626f5d293e7f62ea8813d2477930e60ee5290104 Mon Sep 17 00:00:00 2001 From: leave330 Date: Mon, 3 Aug 2026 10:51:10 +0800 Subject: [PATCH 44/61] test(event): harden schema, snapshot, and frame test assertions Check the discarded type assertion in the schema-instance flip test, guard the snapshot fixture's key and raw-schema assumptions with clear failures instead of panics, and assert the payload round trip in the canonical-fields frame test. --- events/schema_instance_test.go | 5 ++++- .../adapter/localbus/protocol/canonical_fields_test.go | 3 +++ internal/event/catalog/snapshot_test.go | 8 +++++++- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/events/schema_instance_test.go b/events/schema_instance_test.go index dcac881816..db3fba2404 100644 --- a/events/schema_instance_test.go +++ b/events/schema_instance_test.go @@ -93,7 +93,10 @@ func TestSchemaInstanceValidator_BitesOnTamperedOutput(t *testing.T) { t.Errorf("an undeclared field must produce exactly one finding, got: %v", problems) } - flipped, _ := decodeInstance(t, key, frozen).(map[string]any) + flipped, ok := decodeInstance(t, key, frozen).(map[string]any) + if !ok { + t.Fatalf("baseline output for %s is not a JSON object", key) + } flipped["message_id"] = true // declared as a string if problems := validateValue("$", schema, flipped); len(problems) != 1 { t.Errorf("a primitive type flip must produce exactly one finding, got: %v", problems) diff --git a/internal/event/adapter/localbus/protocol/canonical_fields_test.go b/internal/event/adapter/localbus/protocol/canonical_fields_test.go index dff1c5a696..18b983aa97 100644 --- a/internal/event/adapter/localbus/protocol/canonical_fields_test.go +++ b/internal/event/adapter/localbus/protocol/canonical_fields_test.go @@ -50,6 +50,9 @@ func TestEventFrame_CarriesCanonicalFactsVerbatim(t *testing.T) { frame.TenantKey != ev.TenantKey || frame.Seq != 7 { t.Errorf("canonical facts drifted across the wire: %+v", frame) } + if !bytes.Equal(frame.Payload, ev.Payload) { + t.Errorf("payload drifted across the wire: got %s, want %s", frame.Payload, ev.Payload) + } // observed_at is a fixed RFC3339Nano string contract, not an incidental // time.Time marshal shape. parsed, err := time.Parse(time.RFC3339Nano, frame.ObservedAt) diff --git a/internal/event/catalog/snapshot_test.go b/internal/event/catalog/snapshot_test.go index b158c17358..f4d2d0938a 100644 --- a/internal/event/catalog/snapshot_test.go +++ b/internal/event/catalog/snapshot_test.go @@ -34,7 +34,10 @@ func compiledFixture(t *testing.T) *Snapshot { // every other caller. func TestSnapshot_IsImmutableFromOutside(t *testing.T) { snap := compiledFixture(t) - entry, _ := snap.Resolve("demo.thing.updated_v1") + entry, ok := snap.Resolve(validDef().Key) + if !ok { + t.Fatal("the compiled fixture does not contain its own key") + } d := entry.Descriptor() d.Params[0].Name = "tampered" @@ -61,6 +64,9 @@ func TestSnapshot_IsImmutableFromOutside(t *testing.T) { def := entry.Definition() def.Params[0].Name = "tampered" def.Scopes[0] = "tampered" + if def.Schema.Custom == nil || len(def.Schema.Custom.Raw) == 0 { + t.Fatal("the fixture no longer declares raw custom schema bytes; this check needs them") + } def.Schema.Custom.Raw[0] = '!' if fresh := entry.Definition(); fresh.Params[0].Name == "tampered" || fresh.Scopes[0] == "tampered" || From 115c1b6c5593cbadce25a11e3100ddac2ee78fe8 Mon Sep 17 00:00:00 2001 From: leave330 Date: Mon, 3 Aug 2026 10:51:11 +0800 Subject: [PATCH 45/61] docs(event): correct websocket package comment --- internal/event/adapter/lark/websocket/source.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/event/adapter/lark/websocket/source.go b/internal/event/adapter/lark/websocket/source.go index da58dd121d..dabeb490aa 100644 --- a/internal/event/adapter/lark/websocket/source.go +++ b/internal/event/adapter/lark/websocket/source.go @@ -1,8 +1,9 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -// Package source is a pluggable event source abstraction (separate package to keep -// business registrations free of SDK transitive deps). +// Package websocket adapts the platform WebSocket connection into a pluggable +// event source (separate package to keep business registrations free of SDK +// transitive deps). package websocket // StatusNotifier surfaces source lifecycle states; detail is free-form From f70f06f507af815fdad7b955af401e5e14c2dd67 Mon Sep 17 00:00:00 2001 From: leave330 Date: Mon, 3 Aug 2026 11:07:38 +0800 Subject: [PATCH 46/61] ci: scope the event identifier allowlist by rule and path Extending the default generic-api-key rule appends the allowlist to that rule alone, and the AND condition additionally bounds it to the event catalog golden fixtures, so the dotted-identifier exemption can no longer mask a match from any other rule or any other path. --- .gitleaks.toml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.gitleaks.toml b/.gitleaks.toml index a07f4d1d45..ac71766d09 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -3,8 +3,15 @@ title = "lark-cli gitleaks config" [extend] useDefault = true -[allowlist] -description = "EventKey identifiers (dotted lowercase names like minutes.minute.generated_v1) are public catalog data rendered in fixtures and docs, not credentials; the entropy heuristic of generic-api-key misreads them" +# Extends the default rule of the same id: the base regex is kept and this +# allowlist is appended to that rule only, leaving every other rule untouched. +[[rules]] +id = "generic-api-key" + +[[rules.allowlists]] +description = "EventKey identifiers (dotted lowercase names like minutes.minute.generated_v1) in the event catalog fixtures are public catalog data, not credentials; the entropy heuristic misreads them" +condition = "AND" +paths = ['''cmd/event/testdata/golden/.*'''] regexTarget = "secret" regexes = ['''^[a-z0-9_]+(\.[a-z0-9_]+){2,}$'''] From faf31a334c4e03e6ef9350cfc0d874fe3c7ed33b Mon Sep 17 00:00:00 2001 From: leave330 Date: Mon, 3 Aug 2026 11:37:59 +0800 Subject: [PATCH 47/61] ci: allowlist two test-only hostnames used in unit tests The unapproved-domain guard rejects cdn.example.com and open.feishu.cn.example.com in internal/cmdutil and internal/core unit tests. Both are RFC 2606 example subdomains used only as fixtures, and the reserved-name exemption covers the bare example.com names only, so they belong in the fixture list next to the attacker/evil entries. The tests predate the guard and their last green run predates it too, so every pull request whose diff window includes them currently fails lint on files it did not touch. --- internal/qualitygate/config/allowlists/fixture-domains.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/qualitygate/config/allowlists/fixture-domains.txt b/internal/qualitygate/config/allowlists/fixture-domains.txt index 7f3b14395f..d1c465cd25 100644 --- a/internal/qualitygate/config/allowlists/fixture-domains.txt +++ b/internal/qualitygate/config/allowlists/fixture-domains.txt @@ -2,6 +2,7 @@ abc.feishu.cn attacker.example.com bytedance.feishu.cn +cdn.example.com cdn.feishu.cn evil.example.com example.feishu.cn @@ -16,6 +17,7 @@ host.lima.internal lf3-static.bytednsdoc.com meetings.feishu.cn meetings.larksuite.com +open.feishu.cn.example.com p3-lark-file.byteimg.com passport.feishu.cn sample.feishu.cn From da3dd77d756047825fc48d1dc1e4b84567a8717f Mon Sep 17 00:00:00 2001 From: leave330 Date: Mon, 3 Aug 2026 14:49:56 +0800 Subject: [PATCH 48/61] refactor(event): keep the subscription hook in its own domain The vc and minutes PreConsume implementations were byte-identical copies, so the register/unregister pair moved to a shared helper. Deleting both domain files to get there read as if those domains had lost their PreConsume, and it took away the place a reader looks for how a domain subscribes. Each domain keeps its own preconsume.go and its own documented subscription semantics, delegating only the shared OAPI dance. The key declarations are unchanged. Also covers the shared helper directly: nine keys across three domains now depend on it, including the bounded cleanup that lets an exiting consumer unsubscribe under a cancelled consume context. --- .../subscribeprep/subscribeprep_test.go | 183 ++++++++++++++++++ events/minutes/preconsume.go | 22 +++ events/minutes/register.go | 3 +- events/vc/preconsume.go | 22 +++ events/vc/register.go | 15 +- 5 files changed, 235 insertions(+), 10 deletions(-) create mode 100644 events/internal/subscribeprep/subscribeprep_test.go create mode 100644 events/minutes/preconsume.go create mode 100644 events/vc/preconsume.go diff --git a/events/internal/subscribeprep/subscribeprep_test.go b/events/internal/subscribeprep/subscribeprep_test.go new file mode 100644 index 0000000000..8fdbe3ed79 --- /dev/null +++ b/events/internal/subscribeprep/subscribeprep_test.go @@ -0,0 +1,183 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package subscribeprep + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" +) + +type call struct { + method string + path string + body any + // deadline is what the callee saw on its context, so the test can prove + // cleanup runs under its own timeout rather than the consume context. + deadline time.Time + hasDL bool +} + +type stubAPIClient struct { + calls []call + failOn string +} + +func (s *stubAPIClient) CallAPI(ctx context.Context, method, path string, body any) (json.RawMessage, error) { + dl, ok := ctx.Deadline() + s.calls = append(s.calls, call{method: method, path: path, body: body, deadline: dl, hasDL: ok}) + if s.failOn != "" && path == s.failOn { + return nil, errors.New("boom") + } + return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil +} + +const ( + testEventType = "demo.thing.updated_v1" + testSubPath = "/open-apis/demo/v1/subscribe" + testUnsubPath = "/open-apis/demo/v1/unsubscribe" +) + +// The subscribe call and the cleanup's unsubscribe call must both carry the +// event type in the body: the server keys the registration on it, so a +// dropped or renamed field would silently register nothing. +func TestHook_SubscribesThenUnsubscribesTheSameEventType(t *testing.T) { + rt := &stubAPIClient{} + + cleanup, err := Hook(testEventType, testSubPath, testUnsubPath)(context.Background(), rt, nil) + if err != nil { + t.Fatalf("hook: %v", err) + } + if cleanup == nil { + t.Fatal("cleanup must not be nil") + } + if len(rt.calls) != 1 { + t.Fatalf("calls after subscribe = %d, want 1", len(rt.calls)) + } + if rt.calls[0].method != "POST" || rt.calls[0].path != testSubPath { + t.Errorf("subscribe call = %s %s, want POST %s", rt.calls[0].method, rt.calls[0].path, testSubPath) + } + + if err := cleanup(); err != nil { + t.Fatalf("cleanup: %v", err) + } + if len(rt.calls) != 2 { + t.Fatalf("calls after cleanup = %d, want 2", len(rt.calls)) + } + if rt.calls[1].method != "POST" || rt.calls[1].path != testUnsubPath { + t.Errorf("unsubscribe call = %s %s, want POST %s", rt.calls[1].method, rt.calls[1].path, testUnsubPath) + } + + for i, c := range rt.calls { + body, ok := c.body.(map[string]string) + if !ok { + t.Fatalf("call %d body is %T, want map[string]string", i, c.body) + } + if body["event_type"] != testEventType { + t.Errorf("call %d event_type = %q, want %q", i, body["event_type"], testEventType) + } + } +} + +// Cleanup runs on its own bounded context, not the consume context: by the +// time a consumer exits, the context it consumed under is usually already +// cancelled, and an unsubscribe on a cancelled context would never reach the +// server — leaking the server-side subscription. +func TestHook_CleanupOutlivesACancelledConsumeContext(t *testing.T) { + rt := &stubAPIClient{} + ctx, cancel := context.WithCancel(context.Background()) + + cleanup, err := Hook(testEventType, testSubPath, testUnsubPath)(ctx, rt, nil) + if err != nil { + t.Fatalf("hook: %v", err) + } + cancel() + + if err := cleanup(); err != nil { + t.Fatalf("cleanup after the consume context was cancelled: %v", err) + } + if len(rt.calls) != 2 { + t.Fatalf("calls = %d, want the unsubscribe to have happened", len(rt.calls)) + } + unsub := rt.calls[1] + if !unsub.hasDL { + t.Fatal("cleanup ran without a deadline; a stuck unsubscribe would block shutdown") + } + if remaining := time.Until(unsub.deadline); remaining <= 0 || remaining > CleanupTimeout { + t.Errorf("cleanup deadline is %v away, want within (0, %v]", remaining, CleanupTimeout) + } +} + +// A failed subscribe must report the error and hand back no cleanup: running +// an unsubscribe for a registration that never happened would tear down a +// co-consumer's subscription. +func TestHook_FailedSubscribeYieldsNoCleanup(t *testing.T) { + rt := &stubAPIClient{failOn: testSubPath} + + cleanup, err := Hook(testEventType, testSubPath, testUnsubPath)(context.Background(), rt, nil) + if err == nil { + t.Fatal("expected the subscribe failure to surface") + } + if cleanup != nil { + t.Error("no cleanup may be returned when the subscription was never created") + } + if len(rt.calls) != 1 { + t.Errorf("calls = %d, want only the failed subscribe", len(rt.calls)) + } +} + +// A failed unsubscribe surfaces to the caller, which decides how to report +// it; the server-side subscribe is idempotent, so the residual record is +// recoverable but must not be silently swallowed here. +func TestHook_FailedUnsubscribeSurfaces(t *testing.T) { + rt := &stubAPIClient{failOn: testUnsubPath} + + cleanup, err := Hook(testEventType, testSubPath, testUnsubPath)(context.Background(), rt, nil) + if err != nil { + t.Fatalf("hook: %v", err) + } + if err := cleanup(); err == nil { + t.Error("expected the unsubscribe failure to surface") + } +} + +// The hook is the guard for a missing runtime client; it must fail before +// dereferencing it rather than panicking inside the shared core. +func TestHook_RejectsMissingAPIClient(t *testing.T) { + cleanup, err := Hook(testEventType, testSubPath, testUnsubPath)(context.Background(), nil, nil) + if err == nil { + t.Fatal("expected an error when no API client is available") + } + if cleanup != nil { + t.Error("no cleanup may be returned when the subscription was never attempted") + } +} + +// SubscribeWithCleanup is the entry point for callers that build their own +// per-resource paths after validating params; it must behave like Hook once +// those paths are resolved. +func TestSubscribeWithCleanup_UsesTheCallerSuppliedPaths(t *testing.T) { + rt := &stubAPIClient{} + const ( + perResourceSub = "/open-apis/demo/v1/things/thing-1/subscribe" + perResourceUnsub = "/open-apis/demo/v1/things/thing-1/unsubscribe" + ) + + cleanup, err := SubscribeWithCleanup(context.Background(), rt, testEventType, perResourceSub, perResourceUnsub) + if err != nil { + t.Fatalf("subscribe: %v", err) + } + if err := cleanup(); err != nil { + t.Fatalf("cleanup: %v", err) + } + if len(rt.calls) != 2 { + t.Fatalf("calls = %d, want 2", len(rt.calls)) + } + if rt.calls[0].path != perResourceSub || rt.calls[1].path != perResourceUnsub { + t.Errorf("paths = %q then %q, want %q then %q", + rt.calls[0].path, rt.calls[1].path, perResourceSub, perResourceUnsub) + } +} diff --git a/events/minutes/preconsume.go b/events/minutes/preconsume.go new file mode 100644 index 0000000000..fb01d13223 --- /dev/null +++ b/events/minutes/preconsume.go @@ -0,0 +1,22 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package minutes + +import ( + "context" + + "github.com/larksuite/cli/events/internal/subscribeprep" + "github.com/larksuite/cli/internal/event" +) + +// subscriptionPreConsume registers the minutes event type with the server so +// this tenant starts receiving it, and hands back the matching unregister. +// +// The subscription is per event type (not per minute), so the first consumer +// registers it and the last one to exit unregisters it. The +// register/unregister pair itself is shared with the other domains that follow +// the same OAPI convention. +func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func() error, error) { + return subscribeprep.Hook(eventType, subscribePath, unsubscribePath) +} diff --git a/events/minutes/register.go b/events/minutes/register.go index f3cf773ee1..bd0297bda3 100644 --- a/events/minutes/register.go +++ b/events/minutes/register.go @@ -7,7 +7,6 @@ package minutes import ( "reflect" - "github.com/larksuite/cli/events/internal/subscribeprep" "github.com/larksuite/cli/internal/event" ) @@ -32,7 +31,7 @@ func Keys() []event.KeyDefinition { Custom: &event.SchemaSpec{Type: reflect.TypeOf(MinutesMinuteGeneratedOutput{})}, }, Process: processMinutesMinuteGenerated, - PreConsume: subscribeprep.Hook(eventTypeMinuteGenerated, pathMinuteSubscribe, pathMinuteUnsubscribe), + PreConsume: subscriptionPreConsume(eventTypeMinuteGenerated, pathMinuteSubscribe, pathMinuteUnsubscribe), Scopes: []string{"minutes:minutes.basic:read"}, AuthTypes: []string{ "user", diff --git a/events/vc/preconsume.go b/events/vc/preconsume.go new file mode 100644 index 0000000000..bc71393de2 --- /dev/null +++ b/events/vc/preconsume.go @@ -0,0 +1,22 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package vc + +import ( + "context" + + "github.com/larksuite/cli/events/internal/subscribeprep" + "github.com/larksuite/cli/internal/event" +) + +// subscriptionPreConsume registers a VC event type with the server so this +// tenant starts receiving it, and hands back the matching unregister. +// +// Every VC EventKey subscribes this way: the subscription is per event type +// (not per meeting), so the first consumer of a key registers it and the last +// one to exit unregisters it. The register/unregister pair itself is shared +// with the other domains that follow the same OAPI convention. +func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func() error, error) { + return subscribeprep.Hook(eventType, subscribePath, unsubscribePath) +} diff --git a/events/vc/register.go b/events/vc/register.go index 6f17415610..5b4441aece 100644 --- a/events/vc/register.go +++ b/events/vc/register.go @@ -7,7 +7,6 @@ package vc import ( "reflect" - "github.com/larksuite/cli/events/internal/subscribeprep" "github.com/larksuite/cli/internal/event" ) @@ -42,7 +41,7 @@ func Keys() []event.KeyDefinition { Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCParticipantMeetingStartedOutput{})}, }, Process: processVCParticipantMeetingStarted, - PreConsume: subscribeprep.Hook(eventTypeMeetingStarted, pathMeetingSubscribe, pathMeetingUnsubscribe), + PreConsume: subscriptionPreConsume(eventTypeMeetingStarted, pathMeetingSubscribe, pathMeetingUnsubscribe), Scopes: []string{"vc:meeting.meetingevent:read"}, AuthTypes: []string{ "user", @@ -58,7 +57,7 @@ func Keys() []event.KeyDefinition { Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCParticipantMeetingJoinedOutput{})}, }, Process: processVCParticipantMeetingJoined, - PreConsume: subscribeprep.Hook(eventTypeMeetingJoined, pathMeetingSubscribe, pathMeetingUnsubscribe), + PreConsume: subscriptionPreConsume(eventTypeMeetingJoined, pathMeetingSubscribe, pathMeetingUnsubscribe), Scopes: []string{"vc:meeting.meetingevent:read"}, AuthTypes: []string{ "user", @@ -74,7 +73,7 @@ func Keys() []event.KeyDefinition { Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCParticipantMeetingEndedOutput{})}, }, Process: processVCParticipantMeetingEnded, - PreConsume: subscribeprep.Hook(eventTypeMeetingEnded, pathMeetingSubscribe, pathMeetingUnsubscribe), + PreConsume: subscriptionPreConsume(eventTypeMeetingEnded, pathMeetingSubscribe, pathMeetingUnsubscribe), Scopes: []string{"vc:meeting.meetingevent:read"}, AuthTypes: []string{ "user", @@ -90,7 +89,7 @@ func Keys() []event.KeyDefinition { Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCNoteGeneratedOutput{})}, }, Process: processVCNoteGenerated, - PreConsume: subscribeprep.Hook(eventTypeNoteGenerated, pathNoteSubscribe, pathNoteUnsubscribe), + PreConsume: subscriptionPreConsume(eventTypeNoteGenerated, pathNoteSubscribe, pathNoteUnsubscribe), Scopes: []string{"vc:note:read"}, AuthTypes: []string{ "user", @@ -106,7 +105,7 @@ func Keys() []event.KeyDefinition { Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCRecordingStartedOutput{})}, }, Process: processVCRecordingStarted, - PreConsume: subscribeprep.Hook(eventTypeRecordingStarted, pathRecordingSubscribe, pathRecordingUnsubscribe), + PreConsume: subscriptionPreConsume(eventTypeRecordingStarted, pathRecordingSubscribe, pathRecordingUnsubscribe), Scopes: []string{"vc:recording:read"}, AuthTypes: []string{ "user", @@ -122,7 +121,7 @@ func Keys() []event.KeyDefinition { Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCRecordingTranscriptGeneratedOutput{})}, }, Process: processVCRecordingTranscriptGenerated, - PreConsume: subscribeprep.Hook(eventTypeRecordingTranscriptGenerated, pathRecordingSubscribe, pathRecordingUnsubscribe), + PreConsume: subscriptionPreConsume(eventTypeRecordingTranscriptGenerated, pathRecordingSubscribe, pathRecordingUnsubscribe), Scopes: []string{"vc:recording:read"}, AuthTypes: []string{ "user", @@ -138,7 +137,7 @@ func Keys() []event.KeyDefinition { Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCRecordingEndedOutput{})}, }, Process: processVCRecordingEnded, - PreConsume: subscribeprep.Hook(eventTypeRecordingEnded, pathRecordingSubscribe, pathRecordingUnsubscribe), + PreConsume: subscriptionPreConsume(eventTypeRecordingEnded, pathRecordingSubscribe, pathRecordingUnsubscribe), Scopes: []string{"vc:recording:read"}, AuthTypes: []string{ "user", From 2822d8efb85507a674d2c9c1f436007afe1d816d Mon Sep 17 00:00:00 2001 From: leave330 Date: Mon, 3 Aug 2026 15:05:43 +0800 Subject: [PATCH 49/61] test(event): assert the error contract of the subscription hook The error paths only checked for a non-nil error, so a rewrap that dropped the client's category, subtype or retryable flag would have passed. They now assert the client's typed problem reaches the caller unchanged, and that a missing API client yields the typed internal error this package documents. --- .../subscribeprep/subscribeprep_test.go | 51 +++++++++++++++---- 1 file changed, 41 insertions(+), 10 deletions(-) diff --git a/events/internal/subscribeprep/subscribeprep_test.go b/events/internal/subscribeprep/subscribeprep_test.go index 8fdbe3ed79..bc635e0829 100644 --- a/events/internal/subscribeprep/subscribeprep_test.go +++ b/events/internal/subscribeprep/subscribeprep_test.go @@ -9,6 +9,8 @@ import ( "errors" "testing" "time" + + "github.com/larksuite/cli/errs" ) type call struct { @@ -26,15 +28,43 @@ type stubAPIClient struct { failOn string } +// apiFailure stands in for what the real client hands back: it classifies +// every failure into a typed problem before returning, so this package always +// receives one and must pass it along intact. +var apiFailure = errs.NewNetworkError(errs.SubtypeNetworkServer, + "api POST /open-apis/demo/v1: upstream unavailable").WithRetryable() + func (s *stubAPIClient) CallAPI(ctx context.Context, method, path string, body any) (json.RawMessage, error) { dl, ok := ctx.Deadline() s.calls = append(s.calls, call{method: method, path: path, body: body, deadline: dl, hasDL: ok}) if s.failOn != "" && path == s.failOn { - return nil, errors.New("boom") + return nil, apiFailure } return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil } +// assertAPIFailurePassedThrough checks the caller still sees the client's own +// typed error. Rewrapping it here would strip the category, subtype and +// retryable flag the client established, and the consume command renders those +// straight into its error envelope. +func assertAPIFailurePassedThrough(t *testing.T, err error) { + t.Helper() + if !errors.Is(err, apiFailure) { + t.Fatalf("the client error did not survive: got %v", err) + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatal("the returned error carries no typed problem") + } + if problem.Category != errs.CategoryNetwork || problem.Subtype != errs.SubtypeNetworkServer { + t.Errorf("problem = %s/%s, want %s/%s", + problem.Category, problem.Subtype, errs.CategoryNetwork, errs.SubtypeNetworkServer) + } + if !problem.Retryable { + t.Error("retryable was lost; callers use it to decide whether retrying can help") + } +} + const ( testEventType = "demo.thing.updated_v1" testSubPath = "/open-apis/demo/v1/subscribe" @@ -118,9 +148,7 @@ func TestHook_FailedSubscribeYieldsNoCleanup(t *testing.T) { rt := &stubAPIClient{failOn: testSubPath} cleanup, err := Hook(testEventType, testSubPath, testUnsubPath)(context.Background(), rt, nil) - if err == nil { - t.Fatal("expected the subscribe failure to surface") - } + assertAPIFailurePassedThrough(t, err) if cleanup != nil { t.Error("no cleanup may be returned when the subscription was never created") } @@ -139,21 +167,24 @@ func TestHook_FailedUnsubscribeSurfaces(t *testing.T) { if err != nil { t.Fatalf("hook: %v", err) } - if err := cleanup(); err == nil { - t.Error("expected the unsubscribe failure to surface") - } + assertAPIFailurePassedThrough(t, cleanup()) } // The hook is the guard for a missing runtime client; it must fail before // dereferencing it rather than panicking inside the shared core. func TestHook_RejectsMissingAPIClient(t *testing.T) { cleanup, err := Hook(testEventType, testSubPath, testUnsubPath)(context.Background(), nil, nil) - if err == nil { - t.Fatal("expected an error when no API client is available") - } if cleanup != nil { t.Error("no cleanup may be returned when the subscription was never attempted") } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("missing API client must produce a typed error, got %v", err) + } + if problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeUnknown { + t.Errorf("problem = %s/%s, want %s/%s", + problem.Category, problem.Subtype, errs.CategoryInternal, errs.SubtypeUnknown) + } } // SubscribeWithCleanup is the entry point for callers that build their own From a43e228653fc3ce530bd3b1b33909ffd3285d3c3 Mon Sep 17 00:00:00 2001 From: leave330 Date: Tue, 4 Aug 2026 10:56:52 +0800 Subject: [PATCH 50/61] fix(event): stop a type-flipped header claim from disabling arbitration Decoding the payload header into typed strings fails as soon as one field carries the wrong JSON type, and the arbiter treated that error as "no conflict". Because encoding/json still populates the fields it can decode, a header could pair one type-flipped field with forged app_id and tenant_key values and reach a native key's stdout unchallenged -- the opposite of the fail-closed contract this check documents. Header values are now decoded one at a time, so a badly-typed field no longer discards the claims beside it. The envelope declares every one of these facts as a string, so a non-string assertion is itself a conflict; JSON null still means absent and stays silent. --- internal/event/consume/canonical_conflict.go | 75 ++++++++--------- .../event/consume/canonical_conflict_test.go | 82 +++++++++++++++++++ 2 files changed, 118 insertions(+), 39 deletions(-) diff --git a/internal/event/consume/canonical_conflict.go b/internal/event/consume/canonical_conflict.go index 29fcfb55b2..932a88dfb0 100644 --- a/internal/event/consume/canonical_conflict.go +++ b/internal/event/consume/canonical_conflict.go @@ -9,54 +9,35 @@ import ( "github.com/larksuite/cli/internal/event/model" ) -// payloadHeaderClaims is the subset of the payload's header block that can -// claim canonical facts. It exists only for validation at the consume -// boundary — canonical metadata itself always comes from the ingress. +// payloadHeaderClaims holds the payload's header block with its values left +// undecoded. Decoding them one at a time is what keeps a single badly-typed +// field from discarding the claims beside it: encoding/json populates the +// fields it can and still reports an error, so a whole-header decode that +// bails on that error would throw away comparisons it had already resolved. +// +// It exists only for validation at the consume boundary — canonical metadata +// itself always comes from the ingress. type payloadHeaderClaims struct { - Header struct { - EventID string `json:"event_id"` - EventType string `json:"event_type"` - CreateTime string `json:"create_time"` - AppID string `json:"app_id"` - TenantKey string `json:"tenant_key"` - } `json:"header"` + Header map[string]json.RawMessage `json:"header"` } // factComparison pairs one canonical fact with the payload-header field that // can claim it. The comparisons live in a table, not an if-chain: adding a // fact to model.Event means adding a row here (a reflection gate enforces it). +// +// name is both the row's identity and the header field it reads: the envelope +// spells these facts the same way the rows are named. type factComparison struct { name string - header func(c *payloadHeaderClaims) string canonical func(ev *model.Event) string } var canonicalFactComparisons = []factComparison{ - { - name: "event_id", - header: func(c *payloadHeaderClaims) string { return c.Header.EventID }, - canonical: func(ev *model.Event) string { return ev.EventID }, - }, - { - name: "event_type", - header: func(c *payloadHeaderClaims) string { return c.Header.EventType }, - canonical: func(ev *model.Event) string { return ev.EventType }, - }, - { - name: "create_time", - header: func(c *payloadHeaderClaims) string { return c.Header.CreateTime }, - canonical: func(ev *model.Event) string { return ev.SourceTime }, - }, - { - name: "app_id", - header: func(c *payloadHeaderClaims) string { return c.Header.AppID }, - canonical: func(ev *model.Event) string { return ev.AppID }, - }, - { - name: "tenant_key", - header: func(c *payloadHeaderClaims) string { return c.Header.TenantKey }, - canonical: func(ev *model.Event) string { return ev.TenantKey }, - }, + {name: "event_id", canonical: func(ev *model.Event) string { return ev.EventID }}, + {name: "event_type", canonical: func(ev *model.Event) string { return ev.EventType }}, + {name: "create_time", canonical: func(ev *model.Event) string { return ev.SourceTime }}, + {name: "app_id", canonical: func(ev *model.Event) string { return ev.AppID }}, + {name: "tenant_key", canonical: func(ev *model.Event) string { return ev.TenantKey }}, } // checkCanonicalConflict returns the name of the first canonical fact the @@ -68,15 +49,31 @@ var canonicalFactComparisons = []factComparison{ // an empty canonical value means the fact was lost between the ingress and // this consumer; that is a delivery defect, not "nothing to compare". // -// A payload that is not a JSON object is not re-classified here — malformed -// handling belongs to the processing layer. +// The envelope declares every one of these facts as a string. A header that +// asserts one with a different JSON type states something this arbiter cannot +// compare, so it counts as a conflict rather than as silence — otherwise a +// single type flip would be enough to disable arbitration for the rest of the +// header. JSON null is the exception: it is how the envelope spells "absent", +// and it asserts nothing. +// +// A payload that is not a JSON object, or whose header is not one, claims no +// fact at all and is not re-classified here — malformed handling belongs to +// the processing layer. func checkCanonicalConflict(ev *model.Event) string { var claims payloadHeaderClaims if err := json.Unmarshal(ev.Payload, &claims); err != nil { return "" } for _, c := range canonicalFactComparisons { - if claimed := c.header(&claims); claimed != "" && claimed != c.canonical(ev) { + raw, asserted := claims.Header[c.name] + if !asserted { + continue + } + var claimed string + if err := json.Unmarshal(raw, &claimed); err != nil { + return c.name + } + if claimed != "" && claimed != c.canonical(ev) { return c.name } } diff --git a/internal/event/consume/canonical_conflict_test.go b/internal/event/consume/canonical_conflict_test.go index c13e7f7773..d8860e4642 100644 --- a/internal/event/consume/canonical_conflict_test.go +++ b/internal/event/consume/canonical_conflict_test.go @@ -128,6 +128,88 @@ func TestCanonicalConflict_CatchesLostFacts(t *testing.T) { } } +// headerPayloadTyped builds a payload whose header values keep their declared +// JSON types, so a test can assert what happens when one field is not a +// string. +func headerPayloadTyped(overrides map[string]any) json.RawMessage { + header := map[string]any{ + "event_id": "evt-1", + "event_type": "im.message.receive_v1", + "create_time": "1700000000000", + "app_id": "cli_app", + "tenant_key": "tenant_a", + } + for k, v := range overrides { + header[k] = v + } + raw, _ := json.Marshal(map[string]any{"schema": "2.0", "header": header, "event": map[string]any{}}) + return raw +} + +// The envelope contract declares every header fact as a string. A header that +// asserts one with a different JSON type is not a value this arbiter can +// compare, and letting it through would disable arbitration for the whole +// header — so it is a conflict. +func TestCanonicalConflict_TypeFlippedClaimConflicts(t *testing.T) { + for _, c := range canonicalFactComparisons { + t.Run(c.name, func(t *testing.T) { + ev := conflictBaseEvent() + ev.Payload = headerPayloadTyped(map[string]any{c.name: 1700000000000}) + if got := checkCanonicalConflict(ev); got != c.name { + t.Errorf("a non-string %s claim must conflict, got %q", c.name, got) + } + }) + } +} + +// The attack the per-field decode closes: one type-flipped field used as a +// carrier for forged identity facts. Decoding the header into typed strings +// fails on the flipped field while still populating the forged ones, so a +// whole-header bail-out would deliver the forgery. +func TestCanonicalConflict_TypeFlipCannotSmuggleForgedIdentity(t *testing.T) { + ev := conflictBaseEvent() + ev.Payload = headerPayloadTyped(map[string]any{ + "create_time": 1700000000000, + "app_id": "cli_forged", + "tenant_key": "tenant_forged", + }) + if got := checkCanonicalConflict(ev); got == "" { + t.Fatal("a header carrying forged identity facts behind a type flip must not be delivered") + } + + // The same payload with every value a string is caught on the first forged + // fact, which proves the flip is the only thing standing between the + // forgery and stdout. + control := conflictBaseEvent() + control.Payload = headerPayload(map[string]string{ + "app_id": "cli_forged", + "tenant_key": "tenant_forged", + }) + if got := checkCanonicalConflict(control); got != "app_id" { + t.Errorf("control: forged app_id must conflict, got %q", got) + } +} + +// A null claim asserts nothing, which is the one non-string form that stays +// silent: JSON null is how the platform spells "field not present". +func TestCanonicalConflict_NullClaimStaysSilent(t *testing.T) { + ev := conflictBaseEvent() + ev.Payload = headerPayloadTyped(map[string]any{"app_id": nil}) + if got := checkCanonicalConflict(ev); got != "" { + t.Errorf("a null claim must deliver, got conflict on %q", got) + } +} + +// A header that is not an object cannot assert any fact, so there is nothing +// to arbitrate — the same as a missing header. +func TestCanonicalConflict_NonObjectHeaderDelivers(t *testing.T) { + ev := conflictBaseEvent() + ev.Payload = json.RawMessage(`{"schema":"2.0","header":"not-an-object","event":{}}`) + if got := checkCanonicalConflict(ev); got != "" { + t.Errorf("a non-object header claims nothing and must deliver, got conflict on %q", got) + } +} + // Well-formed control cases: agreement and silence both deliver. func TestCanonicalConflict_AgreementAndSilenceDeliver(t *testing.T) { agree := conflictBaseEvent() From a314518b02bf56b9add256b39c798a54512bf0de Mon Sep 17 00:00:00 2001 From: leave330 Date: Tue, 4 Aug 2026 11:00:00 +0800 Subject: [PATCH 51/61] fix(event): keep each whiteboard consumer to its own board Scoping the subscription per whiteboard made the server-side setup run for every board, but the local bus still fans out by event type, so each consumer received every subscribed board's events -- including the first consumer, which saw only its own board before. The key now declares a Match filter on the payload's whiteboard_id, the existing pre-Process hook the bus already calls. An event whose board cannot be read is dropped rather than delivered: a consumer that asked for one board must not be handed an event that cannot be attributed to it. Routing per board on the bus would additionally save the IPC hop and stays open as a follow-up; it needs the handshake to carry the filter and the hub to read payload contents. --- events/whiteboard/match.go | 40 +++++++++++++++++ events/whiteboard/match_test.go | 79 +++++++++++++++++++++++++++++++++ events/whiteboard/register.go | 1 + 3 files changed, 120 insertions(+) create mode 100644 events/whiteboard/match.go create mode 100644 events/whiteboard/match_test.go diff --git a/events/whiteboard/match.go b/events/whiteboard/match.go new file mode 100644 index 0000000000..bbb4b233ec --- /dev/null +++ b/events/whiteboard/match.go @@ -0,0 +1,40 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package whiteboard + +import ( + "encoding/json" + + event "github.com/larksuite/cli/internal/event" +) + +// whiteboardIDMatch keeps a consumer's stream to the whiteboard it asked for. +// +// The server-side subscription is registered per whiteboard, but the local bus +// fans out by event type: once two whiteboards are subscribed, every consumer +// of this key receives both boards' events off the wire. Filtering here is +// what makes "subscription is per-whiteboard" true for the consumer's stdout +// as well; routing per whiteboard on the bus would additionally save the IPC +// hop, at the cost of teaching the bus about payload contents. +// +// An event whose whiteboard cannot be read — absent, not a string, or an +// undecodable payload — is dropped rather than delivered: a consumer that +// asked for one whiteboard must not be handed an event that cannot be +// attributed to it. Match has no diagnostic channel, so these drops are +// silent. +func whiteboardIDMatch(raw *event.RawEvent, params map[string]string) bool { + want := params["whiteboard_id"] + if want == "" { + return false + } + var envelope struct { + Event struct { + WhiteboardID string `json:"whiteboard_id"` + } `json:"event"` + } + if err := json.Unmarshal(raw.Payload, &envelope); err != nil { + return false + } + return envelope.Event.WhiteboardID == want +} diff --git a/events/whiteboard/match_test.go b/events/whiteboard/match_test.go new file mode 100644 index 0000000000..31c2bf21da --- /dev/null +++ b/events/whiteboard/match_test.go @@ -0,0 +1,79 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package whiteboard + +import ( + "encoding/json" + "testing" + + event "github.com/larksuite/cli/internal/event" +) + +func whiteboardEvent(payload string) *event.RawEvent { + return &event.RawEvent{ + EventID: "evt-1", + EventType: eventTypeWhiteboardUpdated, + Payload: json.RawMessage(payload), + } +} + +func envelopeFor(whiteboardID string) string { + return `{"schema":"2.0","header":{"event_type":"` + eventTypeWhiteboardUpdated + + `"},"event":{"whiteboard_id":"` + whiteboardID + `","operator_ids":[]}}` +} + +// The filter must exist on the key, not just as a function: the server-side +// subscription is per whiteboard but the local bus fans out by event type, so +// a key without this filter hands every subscribed whiteboard's events to +// every consumer. +func TestWhiteboardKey_DeclaresTheBoardFilter(t *testing.T) { + defs := Keys() + if len(defs) != 1 { + t.Fatalf("expected exactly one whiteboard key, got %d", len(defs)) + } + if defs[0].Match == nil { + t.Fatal("the whiteboard key must declare Match; without it consumers of different boards see each other's events") + } +} + +// The pollution this closes: two consumers of different boards on one bus. +func TestWhiteboardMatch_KeepsEachConsumerToItsOwnBoard(t *testing.T) { + params := map[string]string{"whiteboard_id": "board-A"} + + if !whiteboardIDMatch(whiteboardEvent(envelopeFor("board-A")), params) { + t.Error("an event for the requested board must be delivered") + } + if whiteboardIDMatch(whiteboardEvent(envelopeFor("board-B")), params) { + t.Error("an event for another board must be dropped; delivering it is the cross-board pollution") + } +} + +// An event whose board cannot be read is dropped rather than delivered: a +// consumer that asked for one board must not be handed an event that cannot +// be attributed to it. +func TestWhiteboardMatch_DropsUnattributableEvents(t *testing.T) { + params := map[string]string{"whiteboard_id": "board-A"} + cases := map[string]string{ + "absent id": `{"schema":"2.0","event":{"operator_ids":[]}}`, + "id is not string": `{"schema":"2.0","event":{"whiteboard_id":42}}`, + "event not object": `{"schema":"2.0","event":"board-A"}`, + "payload not json": `definitely not json {{{`, + "payload not object": `["board-A"]`, + } + for name, payload := range cases { + t.Run(name, func(t *testing.T) { + if whiteboardIDMatch(whiteboardEvent(payload), params) { + t.Error("an event whose board cannot be read must be dropped") + } + }) + } +} + +// Defensive: the parameter is declared required and validated upstream, but an +// empty request must not degrade into "deliver everything". +func TestWhiteboardMatch_DropsWhenNoBoardWasRequested(t *testing.T) { + if whiteboardIDMatch(whiteboardEvent(envelopeFor("board-A")), map[string]string{}) { + t.Error("without a requested board there is nothing to attribute an event to; it must be dropped") + } +} diff --git a/events/whiteboard/register.go b/events/whiteboard/register.go index 5c8d01c3a5..7dc508151a 100644 --- a/events/whiteboard/register.go +++ b/events/whiteboard/register.go @@ -44,6 +44,7 @@ func Keys() []event.KeyDefinition { "/event/operator_ids/*/user_id": {Kind: "user_id"}, }, }, + Match: whiteboardIDMatch, PreConsume: whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated), Scopes: []string{"board:whiteboard:node:read"}, AuthTypes: []string{"user", "bot"}, From 82925ceacc0775d584dbc3c5225788493a6117d4 Mon Sep 17 00:00:00 2001 From: leave330 Date: Tue, 4 Aug 2026 11:28:44 +0800 Subject: [PATCH 52/61] feat(event): degrade instead of refusing a bus without canonical metadata Refusing every key on an older bus left no upgrade path. The bus is a long-lived daemon on the user's machine and its 30-second idle exit only applies while it has no connections, so one supervised consumer keeps an older bus alive indefinitely; the refusal also shares an exit code with "bad parameters", which reads to an orchestrator as "do not retry". Together that turns an upgrade into a silent stall rather than the one recovery round the notes promised. A connection whose handshake advertises no canonical metadata now enters a compatibility mode, decided once from the ack and read-only after that. A per-event fallback was rejected deliberately: it would let a forged header override canonical facts on a bus that does supply them. The older frame already carries event_id, event_type and source_time, so those stay authoritative and stay arbitrated. Only app_id and tenant_key are restored, from the payload header the older ingress parsed them out of. app_id is checked against the app this consumer is configured for, so it cannot be forged; tenant_key has no second source on such a connection and is the one accepted cost. Both are refused unless the header spells them as strings. Keys with a SubscriptionKey param keep refusing: their scope is hashed here and bare on an older bus, so old and new consumers of the same resource would each act as first and last for their own scope and unsubscribe one another, and the older bus can never grow a guard. That narrows the break from every key to one. The refusal still happens before any preparation runs. A per-key matrix over the shipped catalog asserts every key has a defined answer and that a restored legacy event matches what a current frame carries; the frozen output baseline then pins the rendered bytes. --- internal/event/consume/canonical_conflict.go | 19 +- .../event/consume/canonical_conflict_test.go | 22 +- .../event/consume/capability_gate_test.go | 101 +++++++-- internal/event/consume/consume.go | 33 ++- internal/event/consume/legacy_metadata.go | 126 +++++++++++ .../event/consume/legacy_metadata_test.go | 213 ++++++++++++++++++ internal/event/consume/loop.go | 15 +- 7 files changed, 474 insertions(+), 55 deletions(-) create mode 100644 internal/event/consume/legacy_metadata.go create mode 100644 internal/event/consume/legacy_metadata_test.go diff --git a/internal/event/consume/canonical_conflict.go b/internal/event/consume/canonical_conflict.go index 932a88dfb0..42de3e7081 100644 --- a/internal/event/consume/canonical_conflict.go +++ b/internal/event/consume/canonical_conflict.go @@ -27,17 +27,23 @@ type payloadHeaderClaims struct { // // name is both the row's identity and the header field it reads: the envelope // spells these facts the same way the rows are named. +// +// headerDerivedInLegacy marks the facts a legacy bus never sends, which the +// compatibility path fills from the header itself. Comparing those to the +// header they came from asserts nothing, so legacy connections skip them — +// and only them. The facts a legacy frame does carry stay arbitrated. type factComparison struct { - name string - canonical func(ev *model.Event) string + name string + canonical func(ev *model.Event) string + headerDerivedInLegacy bool } var canonicalFactComparisons = []factComparison{ {name: "event_id", canonical: func(ev *model.Event) string { return ev.EventID }}, {name: "event_type", canonical: func(ev *model.Event) string { return ev.EventType }}, {name: "create_time", canonical: func(ev *model.Event) string { return ev.SourceTime }}, - {name: "app_id", canonical: func(ev *model.Event) string { return ev.AppID }}, - {name: "tenant_key", canonical: func(ev *model.Event) string { return ev.TenantKey }}, + {name: "app_id", canonical: func(ev *model.Event) string { return ev.AppID }, headerDerivedInLegacy: true}, + {name: "tenant_key", canonical: func(ev *model.Event) string { return ev.TenantKey }, headerDerivedInLegacy: true}, } // checkCanonicalConflict returns the name of the first canonical fact the @@ -59,12 +65,15 @@ var canonicalFactComparisons = []factComparison{ // A payload that is not a JSON object, or whose header is not one, claims no // fact at all and is not re-classified here — malformed handling belongs to // the processing layer. -func checkCanonicalConflict(ev *model.Event) string { +func checkCanonicalConflict(ev *model.Event, legacy bool) string { var claims payloadHeaderClaims if err := json.Unmarshal(ev.Payload, &claims); err != nil { return "" } for _, c := range canonicalFactComparisons { + if legacy && c.headerDerivedInLegacy { + continue + } raw, asserted := claims.Header[c.name] if !asserted { continue diff --git a/internal/event/consume/canonical_conflict_test.go b/internal/event/consume/canonical_conflict_test.go index d8860e4642..567f9db01f 100644 --- a/internal/event/consume/canonical_conflict_test.go +++ b/internal/event/consume/canonical_conflict_test.go @@ -94,7 +94,7 @@ func TestCanonicalConflict_DetectsEveryFactMismatch(t *testing.T) { t.Run(c.name, func(t *testing.T) { ev := conflictBaseEvent() ev.Payload = headerPayload(map[string]string{c.name: "tampered-value"}) - if got := checkCanonicalConflict(ev); got != c.name { + if got := checkCanonicalConflict(ev, false); got != c.name { t.Errorf("header claiming a different %s must conflict, got %q", c.name, got) } }) @@ -121,7 +121,7 @@ func TestCanonicalConflict_CatchesLostFacts(t *testing.T) { ev := conflictBaseEvent() ev.Payload = headerPayload(nil) blank[c.name](ev) - if got := checkCanonicalConflict(ev); got != c.name { + if got := checkCanonicalConflict(ev, false); got != c.name { t.Errorf("a lost canonical %s facing an asserted header claim must conflict, got %q", c.name, got) } }) @@ -155,7 +155,7 @@ func TestCanonicalConflict_TypeFlippedClaimConflicts(t *testing.T) { t.Run(c.name, func(t *testing.T) { ev := conflictBaseEvent() ev.Payload = headerPayloadTyped(map[string]any{c.name: 1700000000000}) - if got := checkCanonicalConflict(ev); got != c.name { + if got := checkCanonicalConflict(ev, false); got != c.name { t.Errorf("a non-string %s claim must conflict, got %q", c.name, got) } }) @@ -173,7 +173,7 @@ func TestCanonicalConflict_TypeFlipCannotSmuggleForgedIdentity(t *testing.T) { "app_id": "cli_forged", "tenant_key": "tenant_forged", }) - if got := checkCanonicalConflict(ev); got == "" { + if got := checkCanonicalConflict(ev, false); got == "" { t.Fatal("a header carrying forged identity facts behind a type flip must not be delivered") } @@ -185,7 +185,7 @@ func TestCanonicalConflict_TypeFlipCannotSmuggleForgedIdentity(t *testing.T) { "app_id": "cli_forged", "tenant_key": "tenant_forged", }) - if got := checkCanonicalConflict(control); got != "app_id" { + if got := checkCanonicalConflict(control, false); got != "app_id" { t.Errorf("control: forged app_id must conflict, got %q", got) } } @@ -195,7 +195,7 @@ func TestCanonicalConflict_TypeFlipCannotSmuggleForgedIdentity(t *testing.T) { func TestCanonicalConflict_NullClaimStaysSilent(t *testing.T) { ev := conflictBaseEvent() ev.Payload = headerPayloadTyped(map[string]any{"app_id": nil}) - if got := checkCanonicalConflict(ev); got != "" { + if got := checkCanonicalConflict(ev, false); got != "" { t.Errorf("a null claim must deliver, got conflict on %q", got) } } @@ -205,7 +205,7 @@ func TestCanonicalConflict_NullClaimStaysSilent(t *testing.T) { func TestCanonicalConflict_NonObjectHeaderDelivers(t *testing.T) { ev := conflictBaseEvent() ev.Payload = json.RawMessage(`{"schema":"2.0","header":"not-an-object","event":{}}`) - if got := checkCanonicalConflict(ev); got != "" { + if got := checkCanonicalConflict(ev, false); got != "" { t.Errorf("a non-object header claims nothing and must deliver, got conflict on %q", got) } } @@ -214,20 +214,20 @@ func TestCanonicalConflict_NonObjectHeaderDelivers(t *testing.T) { func TestCanonicalConflict_AgreementAndSilenceDeliver(t *testing.T) { agree := conflictBaseEvent() agree.Payload = headerPayload(nil) - if got := checkCanonicalConflict(agree); got != "" { + if got := checkCanonicalConflict(agree, false); got != "" { t.Errorf("matching header must deliver, got conflict on %q", got) } silent := conflictBaseEvent() silent.Payload = json.RawMessage(`{"schema":"2.0","event":{"text":"no header block"}}`) - if got := checkCanonicalConflict(silent); got != "" { + if got := checkCanonicalConflict(silent, false); got != "" { t.Errorf("a silent header claims nothing and must deliver, got conflict on %q", got) } // Non-JSON payloads are the processing layer's business, not arbitration's. malformed := conflictBaseEvent() malformed.Payload = json.RawMessage(`this is definitely not valid json {{{`) - if got := checkCanonicalConflict(malformed); got != "" { + if got := checkCanonicalConflict(malformed, false); got != "" { t.Errorf("non-JSON payloads are not re-classified here, got conflict on %q", got) } } @@ -239,7 +239,7 @@ func TestCanonicalConflict_PipelineDropsWithRedactedDiagnostic(t *testing.T) { ev := conflictBaseEvent() ev.Payload = headerPayload(map[string]string{"app_id": "attacker-" + sentinel}) - field := checkCanonicalConflict(ev) + field := checkCanonicalConflict(ev, false) if field != "app_id" { t.Fatalf("expected app_id conflict, got %q", field) } diff --git a/internal/event/consume/capability_gate_test.go b/internal/event/consume/capability_gate_test.go index 2547dbc4e4..d5afed9bfb 100644 --- a/internal/event/consume/capability_gate_test.go +++ b/internal/event/consume/capability_gate_test.go @@ -9,11 +9,13 @@ import ( "context" "encoding/json" "fmt" + "io" "net" "os" "strings" "sync/atomic" "testing" + "time" "github.com/larksuite/cli/errs" event "github.com/larksuite/cli/internal/event" @@ -24,38 +26,52 @@ import ( // legacyBusAcks replays, byte for byte, the hello_ack an older bus would send: // the capability list either absent, empty, or missing the canonical-metadata -// entry. Each one must be refused — and refused before any side effect runs. +// entry. var legacyBusAcks = map[string]string{ "no capabilities field": `{"type":"hello_ack","bus_version":"v1","first_for_key":true}`, "empty capability list": `{"type":"hello_ack","bus_version":"v1","first_for_key":true,"capabilities":[]}`, "unrelated capability": `{"type":"hello_ack","bus_version":"v1","first_for_key":true,"capabilities":["some_other_capability"]}`, } -func TestCapabilityGate_RefusesLegacyBusBeforeAnySideEffect(t *testing.T) { +// resourceScopedDef is a key whose subscription identity carries a parameter, +// the shape that cannot fall back to a legacy bus: this consumer hashes the +// scope while an old consumer of the same resource uses the bare key, so each +// acts as first and last for its own scope and unsubscribes the other. The old +// bus can never grow a guard against that, so the only safe answer is refusal. +func resourceScopedDef(t *testing.T, key string, preConsume func()) *event.KeyDefinition { + t.Helper() + return compileDefForTest(t, event.KeyDefinition{ + Key: key, + EventType: key, + Params: []event.ParamDef{ + {Name: "resource_id", Type: event.ParamString, Required: true, SubscriptionKey: true}, + }, + Schema: event.SchemaDef{Native: &event.SchemaSpec{Raw: json.RawMessage(`{"type":"object"}`)}}, + PreConsume: func(_ context.Context, _ event.APIClient, _ map[string]string) (func() error, error) { + preConsume() + return nil, nil + }, + }) +} + +func TestCapabilityGate_RefusesLegacyBusForResourceScopedKeyBeforeAnySideEffect(t *testing.T) { for name, rawAck := range legacyBusAcks { t.Run(name, func(t *testing.T) { const key = "test.evt_capability_gate" var setupCalls atomic.Int64 - def := compileDefForTest(t, event.KeyDefinition{ - Key: key, - EventType: key, - Schema: event.SchemaDef{Native: &event.SchemaSpec{Raw: json.RawMessage(`{"type":"object"}`)}}, - PreConsume: func(_ context.Context, _ event.APIClient, _ map[string]string) (func() error, error) { - setupCalls.Add(1) - return nil, nil - }, - }) + def := resourceScopedDef(t, key, func() { setupCalls.Add(1) }) tr := startLegacyBusStub(t, rawAck) err := Run(context.Background(), tr, "cap-gate-app", "", "", Options{ EventKey: key, Def: def, + Params: map[string]string{"resource_id": "res-1"}, Runtime: &fakeRT{}, Quiet: true, }) if err == nil { - t.Fatal("consume must refuse a bus without canonical metadata support") + t.Fatal("a resource-scoped key must refuse a bus without canonical metadata support") } problem, ok := errs.ProblemOf(err) if !ok || problem.Subtype != errs.SubtypeFailedPrecondition { @@ -65,18 +81,67 @@ func TestCapabilityGate_RefusesLegacyBusBeforeAnySideEffect(t *testing.T) { t.Errorf("error must name the missing capability, got: %v", err) } if got := setupCalls.Load(); got != 0 { - t.Errorf("pre-consume ran %d time(s) before the capability check; the gate must come first", got) + t.Errorf("pre-consume ran %d time(s) before the refusal; it must come first", got) } }) } } -// The current build's own ack must pass — a gate that rejects everything -// would be just as wrong as one that accepts everything. -func TestCapabilityGate_CurrentAckPasses(t *testing.T) { +// A key whose subscription identity is one-dimensional keeps working against an +// older bus: its scope string is byte-identical across versions, so nothing can +// desynchronize. The operator is told the mode in one line, which Run emits per +// connection rather than per event. +func TestCapabilityGate_LegacyBusDegradesForOneDimensionalKey(t *testing.T) { + const key = "test.evt_capability_degrade" + var setupCalls atomic.Int64 + def := compileDefForTest(t, event.KeyDefinition{ + Key: key, + EventType: key, + Schema: event.SchemaDef{Native: &event.SchemaSpec{Raw: json.RawMessage(`{"type":"object"}`)}}, + PreConsume: func(_ context.Context, _ event.APIClient, _ map[string]string) (func() error, error) { + setupCalls.Add(1) + return nil, nil + }, + }) + + var errOut bytes.Buffer + tr := startLegacyBusStub(t, legacyBusAcks["no capabilities field"]) + + err := Run(context.Background(), tr, "cap-gate-app", "", "", Options{ + EventKey: key, + Def: def, + Runtime: &fakeRT{}, + ErrOut: &errOut, + Out: io.Discard, + Timeout: 200 * time.Millisecond, + }) + if problem, ok := errs.ProblemOf(err); ok && problem.Subtype == errs.SubtypeFailedPrecondition { + t.Fatalf("a one-dimensional key must not be refused for a legacy bus, got: %v", err) + } + if got := setupCalls.Load(); got != 1 { + t.Errorf("pre-consume ran %d time(s); degrading must not skip preparation", got) + } + notices := strings.Count(errOut.String(), "legacy compatibility mode") + if notices != 1 { + t.Errorf("the compatibility notice must appear exactly once per connection, got %d", notices) + } +} + +// The current build's own ack must select the normal path — a negotiation that +// degrades everything would be just as wrong as one that refuses everything. +func TestCapabilityGate_CurrentAckSelectsNormalPath(t *testing.T) { ack := protocol.NewHelloAck("v1", true, protocol.CapabilityCanonicalMetadataV1) - if err := capabilityError(ack, "any.key"); err != nil { - t.Fatalf("current bus ack must satisfy the capability gate, got: %v", err) + def := compileDefForTest(t, event.KeyDefinition{ + Key: "any.key", + EventType: "any.key", + Schema: event.SchemaDef{Native: &event.SchemaSpec{Raw: json.RawMessage(`{"type":"object"}`)}}, + }) + mode, err := negotiateMetadataMode(ack, def, "cap-gate-app") + if err != nil { + t.Fatalf("current bus ack must satisfy negotiation, got: %v", err) + } + if mode.enabled { + t.Error("a capable bus must not put the connection in compatibility mode") } } diff --git a/internal/event/consume/consume.go b/internal/event/consume/consume.go index 970480d1d4..8635b3df32 100644 --- a/internal/event/consume/consume.go +++ b/internal/event/consume/consume.go @@ -9,7 +9,6 @@ import ( "fmt" "io" "os" - "slices" "sync/atomic" "time" @@ -47,6 +46,10 @@ type Options struct { MaxEvents int // 0 = unlimited Timeout time.Duration // 0 = no timeout IsTTY bool + + // legacy is resolved from the handshake ack inside Run and is read-only + // afterwards; callers neither set nor see it. + legacy legacyMetadataMode } // Run ensures bus is up, performs hello handshake, runs PreConsume for first subscriber, @@ -128,13 +131,18 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin if rejErr := rejectionError(ack, opts.EventKey); rejErr != nil { return rejErr } - // The capability check must run before any side effect (pre-consume setup, - // worker start): attaching to a bus that cannot deliver full canonical - // metadata would silently emit events with missing ids, times, and tenant - // identity instead of failing visibly. - if capErr := capabilityError(ack, opts.EventKey); capErr != nil { + // Capability negotiation must finish before any side effect (pre-consume + // setup, worker start): a key that cannot fall back has to be refused + // before it registers anything server-side. The decision is fixed for the + // life of this connection — see legacyMetadataMode. + legacy, capErr := negotiateMetadataMode(ack, keyDef, appID) + if capErr != nil { return capErr } + opts.legacy = legacy + if legacy.enabled && !opts.Quiet { + fmt.Fprintln(errOut, legacyModeNotice(opts.EventKey)) + } prepare := opts.Prepare if prepare == nil && keyDef.PreConsume != nil { @@ -222,19 +230,6 @@ func rejectionError(ack *protocol.HelloAck, eventKey string) error { WithHint("EventKey %s allows only one consumer; run `lark-cli event status` to find the running one, then stop it before retrying", eventKey) } -// capabilityError refuses to attach to a bus that does not advertise full -// canonical event metadata. The check happens on the real delivery -// connection's ack — not on a separate probe — so the bus that answered is -// exactly the bus that would deliver. -func capabilityError(ack *protocol.HelloAck, eventKey string) error { - if slices.Contains(ack.Capabilities, protocol.CapabilityCanonicalMetadataV1) { - return nil - } - return errs.NewValidationError(errs.SubtypeFailedPrecondition, - "the running local event bus does not support %s; it was started by an older CLI version", protocol.CapabilityCanonicalMetadataV1). - WithHint("stop the consumers still attached to the old bus, run `lark-cli event stop` (add --force to override active consumers at the cost of dropping them), then retry `lark-cli event consume %s`", eventKey) -} - func truncateDuration(d time.Duration) time.Duration { return d.Truncate(time.Second) } diff --git a/internal/event/consume/legacy_metadata.go b/internal/event/consume/legacy_metadata.go new file mode 100644 index 0000000000..35435ed4a1 --- /dev/null +++ b/internal/event/consume/legacy_metadata.go @@ -0,0 +1,126 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package consume + +import ( + "encoding/json" + "slices" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" +) + +// legacyMetadataMode is the compatibility decision for one connection. It is +// resolved once from the handshake ack and never changes while that connection +// lives: a per-event fallback would let a forged header override canonical +// facts on a bus that does supply them, which is exactly what arbitration +// exists to prevent. +// +// TRANSITIONAL: the whole legacy path goes away when the bus protocol next +// bumps, at which point the capability check returns to selecting a restore +// strategy rather than tolerating a missing one. +type legacyMetadataMode struct { + enabled bool + // appID is the app this consumer is configured for. A legacy frame carries + // no app_id, so the payload header is the only place to read it from — but + // it is checked against this value instead of being trusted. + appID string +} + +// negotiateMetadataMode decides how this connection restores canonical facts. +// +// A bus that advertises canonical metadata needs nothing. An older bus is +// tolerated for keys whose subscription identity is one-dimensional, because +// their scope string is byte-identical across versions. Keys with a +// SubscriptionKey param are refused: their scope is hashed here and bare on +// the old bus, so old and new consumers of the same resource would each act as +// first and last for their own scope and unsubscribe one another — and the old +// bus can never grow a guard against that. +func negotiateMetadataMode(ack *protocol.HelloAck, def *event.KeyDefinition, appID string) (legacyMetadataMode, error) { + if ack != nil && slices.Contains(ack.Capabilities, protocol.CapabilityCanonicalMetadataV1) { + return legacyMetadataMode{}, nil + } + if hasSubscriptionKeyParam(def) { + return legacyMetadataMode{}, capabilityError(def.Key) + } + return legacyMetadataMode{enabled: true, appID: appID}, nil +} + +func hasSubscriptionKeyParam(def *event.KeyDefinition) bool { + return slices.ContainsFunc(def.Params, func(p event.ParamDef) bool { return p.SubscriptionKey }) +} + +// restoreLegacyMetadata fills the canonical facts a legacy bus never sent, +// reading them from the payload header the old ingress parsed out of these +// same bytes. Facts the frame did carry are left alone: the frame stays the +// authority for everything it can speak to. +// +// It returns the name of the fact that cannot be honoured, or "" when the +// event may be delivered. Both facts are declared strings in the envelope, so +// a non-string assertion is refused rather than coerced; an absent claim +// leaves the fact empty, which is what the old consumer rendered too. +func restoreLegacyMetadata(ev *event.RawEvent, configuredAppID string) string { + var claims payloadHeaderClaims + if err := json.Unmarshal(ev.Payload, &claims); err != nil { + // Nothing to restore from. The facts stay empty, exactly as an old + // consumer would have left them. + return "" + } + if ev.AppID == "" { + claimed, ok := legacyHeaderString(claims, "app_id") + if !ok { + return "app_id" + } + // The configured app is an independent source for this one fact, so + // the header does not get to name an app this consumer is not running + // as — that is the forgery the arbiter would otherwise have caught. + if claimed != "" && claimed != configuredAppID { + return "app_id" + } + ev.AppID = claimed + } + if ev.TenantKey == "" { + claimed, ok := legacyHeaderString(claims, "tenant_key") + if !ok { + return "tenant_key" + } + // Accepted cost of compatibility: nothing else on a legacy connection + // knows the tenant, so this claim cannot be cross-checked. + ev.TenantKey = claimed + } + return "" +} + +// legacyHeaderString reads one header field as a string. ok is false only when +// the field is present and is not a string; an absent field and a JSON null +// both read as an empty string, which is how the envelope spells "not set". +func legacyHeaderString(claims payloadHeaderClaims, field string) (string, bool) { + raw, asserted := claims.Header[field] + if !asserted { + return "", true + } + var value string + if err := json.Unmarshal(raw, &value); err != nil { + return "", false + } + return value, true +} + +// capabilityError refuses a bus that cannot deliver full canonical metadata +// for a key whose subscription identity depends on it. +func capabilityError(eventKey string) error { + return errs.NewValidationError(errs.SubtypeFailedPrecondition, + "the running local event bus does not support %s, and %s subscribes per resource so it cannot fall back", + protocol.CapabilityCanonicalMetadataV1, eventKey). + WithHint("stop the consumers still attached to the old bus, run `lark-cli event stop` (add --force to override active consumers at the cost of dropping them), then retry `lark-cli event consume %s`", eventKey) +} + +// legacyModeNotice is the one-time, per-connection line telling the operator +// which facts are being derived from the payload instead of the frame. +func legacyModeNotice(eventKey string) string { + return "[event] legacy compatibility mode for " + eventKey + + ": the running bus predates " + protocol.CapabilityCanonicalMetadataV1 + + ", so app_id and tenant_key are read from the event payload; restart the bus (`lark-cli event stop`) once its consumers are done to leave this mode" +} diff --git a/internal/event/consume/legacy_metadata_test.go b/internal/event/consume/legacy_metadata_test.go new file mode 100644 index 0000000000..d77e3976cd --- /dev/null +++ b/internal/event/consume/legacy_metadata_test.go @@ -0,0 +1,213 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package consume + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/larksuite/cli/events" + event "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" + "github.com/larksuite/cli/internal/event/catalog" +) + +const legacyConfiguredAppID = "cli_configured_app" + +// legacyFrame builds the event frame an older bus produced: it carries the +// facts that version knew about and nothing else. The absent fields are the +// whole reason the compatibility path exists. +func legacyFrame(payload json.RawMessage) *protocol.Event { + return &protocol.Event{ + Type: protocol.MsgTypeEvent, + EventType: "im.message.receive_v1", + EventID: "evt-legacy-1", + SourceTime: "1700000000000", + Seq: 1, + Payload: payload, + } +} + +func legacyEnvelope(header map[string]any) json.RawMessage { + full := map[string]any{ + "event_id": "evt-legacy-1", + "event_type": "im.message.receive_v1", + "create_time": "1700000000000", + "app_id": legacyConfiguredAppID, + "tenant_key": "tenant-legacy", + } + for k, v := range header { + full[k] = v + } + raw, _ := json.Marshal(map[string]any{"schema": "2.0", "header": full, "event": map[string]any{}}) + return raw +} + +// The frame stays the authority for everything it carries; only the facts it +// cannot speak to come from the header. +func TestLegacyRestore_FillsOnlyWhatTheFrameLacks(t *testing.T) { + raw := restoreCanonicalEvent(legacyFrame(legacyEnvelope(nil)), nil, true) + if field := restoreLegacyMetadata(raw, legacyConfiguredAppID); field != "" { + t.Fatalf("a well-formed legacy event must be deliverable, got conflict on %q", field) + } + + if raw.AppID != legacyConfiguredAppID { + t.Errorf("app_id = %q, want it restored from the header", raw.AppID) + } + if raw.TenantKey != "tenant-legacy" { + t.Errorf("tenant_key = %q, want it restored from the header", raw.TenantKey) + } + // Frame-supplied facts must be untouched even though the header repeats them. + if raw.EventID != "evt-legacy-1" || raw.EventType != "im.message.receive_v1" || raw.SourceTime != "1700000000000" { + t.Errorf("the frame must stay authoritative for the facts it carries, got %+v", raw) + } + // The legacy frame has no observation clock and nothing reads one. + if !raw.Timestamp.IsZero() { + t.Errorf("Timestamp = %v, want the zero value on a legacy frame", raw.Timestamp) + } +} + +// The configured app is an independent source for app_id, so a header naming a +// different app is a forgery rather than a fact to restore. +func TestLegacyRestore_RefusesAnAppIDTheConsumerIsNotRunningAs(t *testing.T) { + raw := restoreCanonicalEvent(legacyFrame(legacyEnvelope(map[string]any{"app_id": "cli_forged_app"})), nil, true) + if field := restoreLegacyMetadata(raw, legacyConfiguredAppID); field != "app_id" { + t.Errorf("a header naming another app must conflict on app_id, got %q", field) + } +} + +// Both restored facts are declared strings; a non-string assertion is refused +// rather than coerced, matching how arbitration treats a type flip. +func TestLegacyRestore_RefusesNonStringClaims(t *testing.T) { + for _, field := range []string{"app_id", "tenant_key"} { + t.Run(field, func(t *testing.T) { + raw := restoreCanonicalEvent(legacyFrame(legacyEnvelope(map[string]any{field: 42})), nil, true) + if got := restoreLegacyMetadata(raw, legacyConfiguredAppID); got != field { + t.Errorf("a non-string %s claim must conflict, got %q", field, got) + } + }) + } +} + +// An absent claim leaves the fact empty, which is what the old consumer +// rendered — restoring must not invent a value. +func TestLegacyRestore_AbsentClaimLeavesTheFactEmpty(t *testing.T) { + payload := json.RawMessage(`{"schema":"2.0","header":{"event_id":"evt-legacy-1"},"event":{}}`) + raw := restoreCanonicalEvent(legacyFrame(payload), nil, true) + if field := restoreLegacyMetadata(raw, legacyConfiguredAppID); field != "" { + t.Fatalf("an absent claim is not a conflict, got %q", field) + } + if raw.AppID != "" || raw.TenantKey != "" { + t.Errorf("absent claims must stay empty, got app_id=%q tenant_key=%q", raw.AppID, raw.TenantKey) + } +} + +// Compatibility narrows arbitration, it does not switch it off: the facts the +// legacy frame carries itself are still checked against the header. +func TestLegacyRestore_StillArbitratesFrameSuppliedFacts(t *testing.T) { + forgedEventID := legacyEnvelope(map[string]any{"event_id": "evt-forged"}) + raw := restoreCanonicalEvent(legacyFrame(forgedEventID), nil, true) + if field := restoreLegacyMetadata(raw, legacyConfiguredAppID); field != "" { + t.Fatalf("restore itself must not object here, got %q", field) + } + if got := checkCanonicalConflict(raw, true); got != "event_id" { + t.Errorf("a forged event_id must still conflict on a legacy connection, got %q", got) + } +} + +// The accepted cost, recorded deliberately: tenant_key has no second source on +// a legacy connection, so a header claiming a different tenant is honoured. +// This is the one fact compatibility cannot protect, and it is why the mode is +// transitional. +func TestLegacyRestore_TenantKeyIsTheAcceptedCost(t *testing.T) { + raw := restoreCanonicalEvent(legacyFrame(legacyEnvelope(map[string]any{"tenant_key": "tenant-other"})), nil, true) + if field := restoreLegacyMetadata(raw, legacyConfiguredAppID); field != "" { + t.Fatalf("tenant_key cannot be cross-checked, so it must not conflict, got %q", field) + } + if got := checkCanonicalConflict(raw, true); got != "" { + t.Fatalf("a header-derived fact must not be arbitrated against the header it came from, got %q", got) + } + if raw.TenantKey != "tenant-other" { + t.Errorf("tenant_key = %q, want the header's claim", raw.TenantKey) + } +} + +// Per-key matrix over the real catalog: every shipped key must have a defined +// answer for a legacy bus, and the answer must follow from its subscription +// shape rather than from a hand-maintained list. +// +// Keys that degrade are covered end to end by composition: this asserts the +// canonical event a legacy frame restores to is identical to the one a current +// frame produces, and TestProcessedOutputBaseline freezes Process output as a +// function of that canonical event. Identical input to a frozen function is +// what makes the rendered bytes identical. +func TestLegacyBus_EveryShippedKeyHasADefinedAnswer(t *testing.T) { + snap, err := catalog.Compile(allShippedDefs(t), catalog.StrategyRefs{catalog.StrategyNone, catalog.StrategyLegacyPreConsume}) + if err != nil { + t.Fatalf("compile catalog: %v", err) + } + legacyAck := &protocol.HelloAck{Type: protocol.MsgTypeHelloAck, BusVersion: "v1", FirstForKey: true} + + refused, degraded := 0, 0 + for _, def := range snap.Definitions() { + t.Run(def.Key, func(t *testing.T) { + mode, err := negotiateMetadataMode(legacyAck, def, legacyConfiguredAppID) + + if hasSubscriptionKeyParam(def) { + refused++ + if err == nil { + t.Fatal("a resource-scoped key must be refused on a legacy bus: its scope is hashed here and bare there, so the two would unsubscribe each other") + } + if mode.enabled { + t.Error("a refused key must not also be put in compatibility mode") + } + return + } + + degraded++ + if err != nil { + t.Fatalf("a one-dimensional key must degrade rather than fail, got: %v", err) + } + if !mode.enabled { + t.Fatal("a legacy ack must put the connection in compatibility mode") + } + + // The restored canonical event must equal what a current frame + // carries natively. + payload := legacyEnvelope(map[string]any{"event_type": def.EventType}) + old := legacyFrame(payload) + old.EventType = def.EventType + restored := restoreCanonicalEvent(old, nil, true) + if field := restoreLegacyMetadata(restored, legacyConfiguredAppID); field != "" { + t.Fatalf("legacy restore rejected a well-formed event on %q", field) + } + + current := legacyFrame(payload) + current.EventType = def.EventType + current.AppID = legacyConfiguredAppID + current.TenantKey = "tenant-legacy" + want := restoreCanonicalEvent(current, nil, true) + + if restored.EventID != want.EventID || restored.EventType != want.EventType || + restored.SourceTime != want.SourceTime || restored.AppID != want.AppID || + restored.TenantKey != want.TenantKey || !restored.Timestamp.Equal(want.Timestamp) || + !bytes.Equal(restored.Payload, want.Payload) { + t.Errorf("legacy restore diverged from the current frame:\n got %+v\nwant %+v", restored, want) + } + }) + } + if refused == 0 || degraded == 0 { + t.Fatalf("the matrix must exercise both answers, got %d refused and %d degraded", refused, degraded) + } +} + +func allShippedDefs(t *testing.T) []event.KeyDefinition { + t.Helper() + defs := events.All() + if len(defs) == 0 { + t.Fatal("no shipped EventKeys found; the matrix scanned nothing") + } + return defs +} diff --git a/internal/event/consume/loop.go b/internal/event/consume/loop.go index a3a8c71f73..518f6e6cb6 100644 --- a/internal/event/consume/loop.go +++ b/internal/event/consume/loop.go @@ -224,14 +224,25 @@ func truncateDiagnostic(s string) string { func processAndOutput(ctx context.Context, keyDef *event.KeyDefinition, evt *protocol.Event, opts Options, sink Sink, jqCode *gojq.Code) (bool, error) { raw := restoreCanonicalEvent(evt, opts.ErrOut, opts.Quiet) + // On a legacy connection the frame cannot speak to every canonical fact, + // so the missing ones are derived from the payload header first — a fact + // the header cannot legitimately claim is a conflict like any other. + conflict := "" + if opts.legacy.enabled { + conflict = restoreLegacyMetadata(raw, opts.legacy.appID) + } + // Validate before any domain work: a payload header that contradicts the // canonical metadata means the two sources of truth diverged somewhere on // the delivery path — deliver neither. The diagnostic names identity // facts only; payload content never reaches stderr. - if field := checkCanonicalConflict(raw); field != "" { + if conflict == "" { + conflict = checkCanonicalConflict(raw, opts.legacy.enabled) + } + if conflict != "" { if !opts.Quiet { fmt.Fprintf(opts.ErrOut, "WARN: event %s (%s) dropped: payload header conflicts with canonical metadata (field=%s)\n", - raw.EventID, raw.EventType, field) + raw.EventID, raw.EventType, conflict) } return false, nil } From 2de1fc4065e444122a672c80b308f559966a9db0 Mon Sep 17 00:00:00 2001 From: leave330 Date: Tue, 4 Aug 2026 11:50:17 +0800 Subject: [PATCH 53/61] test(event): replay a legacy frame through the real pipeline The compatibility tests asserted on the restored canonical event and argued the rendered output followed from that plus the frozen baseline. The argument did not hold: a legacy frame has no observation clock, so its canonical event legitimately differs from a current one, and the control was built from another legacy frame -- so the one field the two formats cannot agree on was never compared. Any future handler reading that field would have kept both tests green while the output diverged. Output equivalence is now asserted directly: an old-format frame and an old hello_ack are replayed through the real consumer -- handshake, frame decode, compatibility restore, arbitration, Match, Process, sink -- and stdout is compared with the frozen baseline for every Processed key. The key that refuses a legacy bus is asserted on the same path. The field-level matrix keeps its place, now comparing against a frame that does carry an observation clock and stating that difference as the single recorded divergence. The refusal regression is also bounded: it ran on a background context, so a negotiation that wrongly degraded entered the consume loop and hung until the package timeout, reporting a timeout instead of the missing refusal. It now fails in about two seconds with the real reason. --- events/legacy_bus_replay_test.go | 168 ++++++++++++++++++ .../event/consume/capability_gate_test.go | 10 +- .../event/consume/legacy_metadata_test.go | 30 +++- internal/event/testutil/busstub.go | 126 +++++++++++++ 4 files changed, 325 insertions(+), 9 deletions(-) create mode 100644 events/legacy_bus_replay_test.go create mode 100644 internal/event/testutil/busstub.go diff --git a/events/legacy_bus_replay_test.go b/events/legacy_bus_replay_test.go new file mode 100644 index 0000000000..b64b205f07 --- /dev/null +++ b/events/legacy_bus_replay_test.go @@ -0,0 +1,168 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package events_test + +import ( + "bytes" + "context" + "encoding/json" + "io" + "os" + "testing" + "time" + + "github.com/larksuite/cli/errs" + eventlib "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/consume" + "github.com/larksuite/cli/internal/event/testutil" +) + +// replayAppID must match the app_id the baseline fixtures put in their header: +// the compatibility path checks the header's claim against the app the +// consumer is configured for, so a mismatch here would be a dropped event +// rather than a rendered one. +const replayAppID = "cli-baseline-app" + +// A consumer attached to a bus that predates canonical metadata must still +// print exactly what it printed before. This drives the real pipeline — bus +// handshake, frame decode, compatibility restore, arbitration, Match, Process, +// sink — off a byte-for-byte reproduction of the old wire format, and compares +// stdout against the frozen baseline. +// +// Asserting on the restored canonical event instead would be weaker in a way +// that matters: the legacy frame has no observation clock, so its canonical +// event legitimately differs from a current one, and any future Process that +// reads a field the two disagree on would keep both a field-level test and the +// output baseline green while the bytes diverged. Comparing the output closes +// that gap for every field at once, including fields nothing reads today. +func TestLegacyBusReplay_RendersTheFrozenOutput(t *testing.T) { + want := readFrozenBaseline(t) + + for _, def := range compileRealCatalog(t).Definitions() { + if def.Process == nil { + continue + } + fx, ok := baselineFixtures[def.Key] + if !ok { + t.Fatalf("Processed EventKey %q has no baseline fixture", def.Key) + } + expected, ok := want[def.Key] + if !ok { + t.Fatalf("baseline snapshot has no entry for %q", def.Key) + } + + t.Run(def.Key, func(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + payload := buildBaselineEnvelope(t, def.EventType, fx) + frame := testutil.LegacyEventFrame(def.EventType, baselineEventID, baselineCreateTime, 1, payload) + tr := testutil.NewBusStub(testutil.LegacyAck, frame).Listen(t, replayAppID) + + // The context bounds a regression: if the consumer stopped exiting + // on the event bound it would otherwise block until the package + // timeout and report that instead of the real failure. + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + var out bytes.Buffer + err := consume.Run(ctx, tr, replayAppID, "", "", consume.Options{ + EventKey: def.Key, + Def: def, + Runtime: &baselineAPIClient{t: t}, + Out: &out, + ErrOut: io.Discard, + Quiet: true, + MaxEvents: 1, + // The declaration's own preparation would reach for the + // subscription API, which has nothing to do with what gets + // printed. Injecting a no-op keeps this test about output and + // exercises the preparation seam the command uses in + // production. + Prepare: func(context.Context) (func() error, error) { return nil, nil }, + }) + if err != nil { + t.Fatalf("consuming a legacy frame must succeed, got: %v", err) + } + + assertSameJSONBytes(t, out.Bytes(), expected) + }) + } +} + +// The one key that cannot fall back must refuse, on the same real path: its +// subscription identity is hashed here and bare on an older bus, so old and +// new consumers of the same board would unsubscribe one another. +func TestLegacyBusReplay_RefusesTheResourceScopedKey(t *testing.T) { + var refused *eventlib.KeyDefinition + for _, def := range compileRealCatalog(t).Definitions() { + for _, p := range def.Params { + if p.SubscriptionKey { + refused = def + } + } + } + if refused == nil { + t.Skip("no shipped key carries a SubscriptionKey param") + } + + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + tr := testutil.NewBusStub(testutil.LegacyAck).Listen(t, replayAppID) + + // Short deadline on purpose: a regression that degrades instead of refusing + // would enter the consume loop, and without this the failure would surface + // as a package timeout pointing at the wrong thing. + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + params := map[string]string{} + for _, p := range refused.Params { + if p.Required { + params[p.Name] = "resource-1" + } + } + + err := consume.Run(ctx, tr, replayAppID, "", "", consume.Options{ + EventKey: refused.Key, + Def: refused, + Params: params, + Runtime: &baselineAPIClient{t: t}, + Out: io.Discard, + ErrOut: io.Discard, + Quiet: true, + }) + problem, ok := errs.ProblemOf(err) + if !ok || problem.Subtype != errs.SubtypeFailedPrecondition { + t.Fatalf("%s must refuse a legacy bus with a failed_precondition, got: %v", refused.Key, err) + } +} + +func readFrozenBaseline(t *testing.T) map[string]json.RawMessage { + t.Helper() + data, err := os.ReadFile(baselineSnapshotPath) + if err != nil { + t.Fatalf("read frozen baseline %s: %v", baselineSnapshotPath, err) + } + var snapshot map[string]json.RawMessage + if err := json.Unmarshal(data, &snapshot); err != nil { + t.Fatalf("decode frozen baseline: %v", err) + } + return snapshot +} + +// assertSameJSONBytes compares rendered stdout with a baseline entry after +// normalizing insignificant whitespace only: the stream is newline-delimited +// and the snapshot is stored indented, but every value inside must match. +func assertSameJSONBytes(t *testing.T, got, want json.RawMessage) { + t.Helper() + gotCompact, wantCompact := new(bytes.Buffer), new(bytes.Buffer) + if err := json.Compact(gotCompact, bytes.TrimSpace(got)); err != nil { + t.Fatalf("stdout is not valid JSON: %v\nraw: %s", err, got) + } + if err := json.Compact(wantCompact, want); err != nil { + t.Fatalf("baseline entry is not valid JSON: %v", err) + } + if !bytes.Equal(gotCompact.Bytes(), wantCompact.Bytes()) { + t.Errorf("legacy replay diverged from the frozen output:\n got %s\nwant %s", gotCompact, wantCompact) + } +} diff --git a/internal/event/consume/capability_gate_test.go b/internal/event/consume/capability_gate_test.go index d5afed9bfb..527bca4c87 100644 --- a/internal/event/consume/capability_gate_test.go +++ b/internal/event/consume/capability_gate_test.go @@ -63,11 +63,19 @@ func TestCapabilityGate_RefusesLegacyBusForResourceScopedKeyBeforeAnySideEffect( tr := startLegacyBusStub(t, rawAck) - err := Run(context.Background(), tr, "cap-gate-app", "", "", Options{ + // Bounded on purpose: refusal returns before the consume loop + // starts, so a regression that degrades instead would otherwise + // block until the package timeout and report that rather than the + // missing refusal. + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + err := Run(ctx, tr, "cap-gate-app", "", "", Options{ EventKey: key, Def: def, Params: map[string]string{"resource_id": "res-1"}, Runtime: &fakeRT{}, + Out: io.Discard, Quiet: true, }) if err == nil { diff --git a/internal/event/consume/legacy_metadata_test.go b/internal/event/consume/legacy_metadata_test.go index d77e3976cd..f694dced75 100644 --- a/internal/event/consume/legacy_metadata_test.go +++ b/internal/event/consume/legacy_metadata_test.go @@ -138,11 +138,12 @@ func TestLegacyRestore_TenantKeyIsTheAcceptedCost(t *testing.T) { // answer for a legacy bus, and the answer must follow from its subscription // shape rather than from a hand-maintained list. // -// Keys that degrade are covered end to end by composition: this asserts the -// canonical event a legacy frame restores to is identical to the one a current -// frame produces, and TestProcessedOutputBaseline freezes Process output as a -// function of that canonical event. Identical input to a frozen function is -// what makes the rendered bytes identical. +// This pins the restore at field level and states the one place a legacy +// canonical event legitimately differs from a current one. What actually gets +// printed is covered by TestLegacyBusReplay_RendersTheFrozenOutput, which +// drives the real pipeline off an old-format frame and compares stdout with +// the frozen baseline — field-level equality alone would not prove the output +// matches, precisely because of the difference recorded below. func TestLegacyBus_EveryShippedKeyHasADefinedAnswer(t *testing.T) { snap, err := catalog.Compile(allShippedDefs(t), catalog.StrategyRefs{catalog.StrategyNone, catalog.StrategyLegacyPreConsume}) if err != nil { @@ -174,8 +175,10 @@ func TestLegacyBus_EveryShippedKeyHasADefinedAnswer(t *testing.T) { t.Fatal("a legacy ack must put the connection in compatibility mode") } - // The restored canonical event must equal what a current frame - // carries natively. + // Compare against what a current bus actually puts on the wire, + // observation clock included — not against another legacy frame + // with two fields filled in, which would agree trivially on the + // one field the two formats cannot agree on. payload := legacyEnvelope(map[string]any{"event_type": def.EventType}) old := legacyFrame(payload) old.EventType = def.EventType @@ -188,14 +191,25 @@ func TestLegacyBus_EveryShippedKeyHasADefinedAnswer(t *testing.T) { current.EventType = def.EventType current.AppID = legacyConfiguredAppID current.TenantKey = "tenant-legacy" + current.ObservedAt = "2023-11-14T22:13:20.5Z" want := restoreCanonicalEvent(current, nil, true) if restored.EventID != want.EventID || restored.EventType != want.EventType || restored.SourceTime != want.SourceTime || restored.AppID != want.AppID || - restored.TenantKey != want.TenantKey || !restored.Timestamp.Equal(want.Timestamp) || + restored.TenantKey != want.TenantKey || !bytes.Equal(restored.Payload, want.Payload) { t.Errorf("legacy restore diverged from the current frame:\n got %+v\nwant %+v", restored, want) } + + // The single recorded difference: a legacy frame has no + // observation clock. Nothing under events/ reads it today, and the + // replay test is what would catch it if something started to. + if !restored.Timestamp.IsZero() { + t.Errorf("Timestamp = %v, want the zero value: a legacy frame carries no observation clock", restored.Timestamp) + } + if want.Timestamp.IsZero() { + t.Error("the current-frame control must carry an observation clock, otherwise this comparison hides the one field the formats disagree on") + } }) } if refused == 0 || degraded == 0 { diff --git a/internal/event/testutil/busstub.go b/internal/event/testutil/busstub.go new file mode 100644 index 0000000000..262a85b579 --- /dev/null +++ b/internal/event/testutil/busstub.go @@ -0,0 +1,126 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package testutil + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "net" + "os" + "testing" + + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/transport" +) + +// BusStub speaks just enough of the local bus protocol to drive a real +// consumer: it answers the status probe, replies to the hello with a +// caller-supplied ack, and then writes caller-supplied event frames. +// +// The ack and the frames are raw lines on purpose. Reproducing what an older +// bus put on the wire is the point — building them through the current +// protocol constructors would encode today's frame shape and prove nothing +// about compatibility. +type BusStub struct { + rawAck string + rawFrames []string +} + +// NewBusStub returns a stub that answers hello with rawAck and then writes +// rawFrames, in order, to every consumer that attaches. +func NewBusStub(rawAck string, rawFrames ...string) *BusStub { + return &BusStub{rawAck: rawAck, rawFrames: rawFrames} +} + +// Listen starts the stub and returns the transport a consumer should dial. +// +// The socket lives in a short directory of its own rather than t.TempDir(): +// that path embeds the test name, and a unix socket path has a low length +// limit, so a descriptive subtest name would fail the bind. +func (s *BusStub) Listen(t *testing.T, appID string) *FakeTransport { + t.Helper() + dir, err := os.MkdirTemp("", "busstub-*") + if err != nil { + t.Fatalf("stub bus tempdir: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + + tr := NewWrappedFake(transport.New(), dir+"/bus.sock") + ln, err := tr.Listen(appID) + if err != nil { + t.Fatalf("stub bus listen: %v", err) + } + t.Cleanup(func() { _ = ln.Close() }) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go s.serve(conn) + } + }() + return tr +} + +func (s *BusStub) serve(conn net.Conn) { + defer conn.Close() + br := bufio.NewReader(conn) + for { + line, err := protocol.ReadFrame(br) + if err != nil { + return + } + msg, err := protocol.Decode(bytes.TrimRight(line, "\n")) + if err != nil { + continue + } + switch msg.(type) { + case *protocol.StatusQuery: + _ = protocol.Encode(conn, protocol.NewStatusResponse(1, 1, 0, nil)) + return + case *protocol.Hello: + if _, err := fmt.Fprintf(conn, "%s\n", s.rawAck); err != nil { + return + } + for _, frame := range s.rawFrames { + if _, err := fmt.Fprintf(conn, "%s\n", frame); err != nil { + return + } + } + // Hold the connection open like a real bus would, so the consumer + // decides when to walk away. + _, _ = protocol.ReadFrame(br) + return + } + } +} + +// LegacyAck is the hello_ack a bus predating canonical metadata sent: no +// capability list at all. +const LegacyAck = `{"type":"hello_ack","bus_version":"v1","first_for_key":true}` + +// LegacyEventFrame renders the event frame such a bus wrote: the fields that +// version knew about and nothing else. app_id, tenant_key and observed_at are +// absent because they did not exist yet. +func LegacyEventFrame(eventType, eventID, sourceTime string, seq uint64, payload json.RawMessage) string { + frame := map[string]any{ + "type": "event", + "event_type": eventType, + "event_id": eventID, + "seq": seq, + "payload": payload, + } + if sourceTime != "" { + frame["source_time"] = sourceTime + } + raw, err := json.Marshal(frame) + if err != nil { + panic("testutil: marshal legacy frame: " + err.Error()) + } + return string(raw) +} From 58ca4322107770ff25f7e644cd010908a3e923c9 Mon Sep 17 00:00:00 2001 From: leave330 Date: Tue, 4 Aug 2026 11:53:39 +0800 Subject: [PATCH 54/61] fix(event): give the frame limit room above the payload limit The ingress accepted event bodies up to 1 MiB and the frame limit was the same 1 MiB, but a frame carries the canonical metadata on top of its payload. A body in the top few hundred bytes of the accepted range framed into something over the limit: the bus wrote it, the consumer refused to read it and warned, and the event was gone. Long transcripts reach that band. The frame limit is now the payload budget plus a stated overhead allowance, so anything the ingress accepts can be framed and read back. A boundary test frames a maximum-size payload with long metadata and round trips it, and a second test pins the ingress cap against the wire format's budget -- they are declared in separate packages because the platform ingress must not depend on the local framing, so a test is what keeps them in step. --- .../lark/websocket/payload_budget_test.go | 28 ++++++ .../event/adapter/localbus/protocol/codec.go | 19 +++- .../localbus/protocol/frame_budget_test.go | 95 +++++++++++++++++++ 3 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 internal/event/adapter/lark/websocket/payload_budget_test.go create mode 100644 internal/event/adapter/localbus/protocol/frame_budget_test.go diff --git a/internal/event/adapter/lark/websocket/payload_budget_test.go b/internal/event/adapter/lark/websocket/payload_budget_test.go new file mode 100644 index 0000000000..58bd01d6fe --- /dev/null +++ b/internal/event/adapter/lark/websocket/payload_budget_test.go @@ -0,0 +1,28 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package websocket + +import ( + "testing" + + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" +) + +// The ingress cap and the local wire format's payload budget have to agree. +// They are declared separately on purpose — the platform ingress does not +// depend on how events are framed locally, so swapping the local transport +// leaves it untouched — which means nothing but this test keeps them in step. +// +// When they drifted apart, the ingress accepted payloads the bus could frame +// but the consumer refused to read, and the event was lost with a warning. +func TestPayloadBudget_MatchesTheWireFormat(t *testing.T) { + if maxEventBodyBytes > protocol.MaxEventPayloadBytes { + t.Errorf("the ingress accepts up to %d bytes but the wire format promises only %d; every event in between is accepted and then dropped", + maxEventBodyBytes, protocol.MaxEventPayloadBytes) + } + if maxEventBodyBytes < protocol.MaxEventPayloadBytes { + t.Errorf("the ingress accepts only %d bytes while the wire format allows %d; the difference is capacity thrown away silently", + maxEventBodyBytes, protocol.MaxEventPayloadBytes) + } +} diff --git a/internal/event/adapter/localbus/protocol/codec.go b/internal/event/adapter/localbus/protocol/codec.go index 7afb65c0bb..be792802de 100644 --- a/internal/event/adapter/localbus/protocol/codec.go +++ b/internal/event/adapter/localbus/protocol/codec.go @@ -14,7 +14,24 @@ import ( "time" ) -const MaxFrameBytes = 1 << 20 // reject larger frames to bound reader buffer growth +// MaxEventPayloadBytes is the largest event body this wire format promises to +// relay. The ingress caps what it accepts at the same number; a boundary test +// keeps the two in step, since the ingress deliberately does not import this +// package. +const MaxEventPayloadBytes = 1 << 20 + +// maxFrameOverheadBytes is the room a frame gets on top of its payload for the +// canonical metadata and JSON punctuation around it. Worst-case realistic +// metadata measures a few hundred bytes, so this is generous on purpose: a +// frame limit that merely equalled the payload limit would make the top of the +// accepted payload range undeliverable, which is how an accepted event turned +// into a dropped one. +const maxFrameOverheadBytes = 4 << 10 + +// MaxFrameBytes bounds reader buffer growth. It must stay above +// MaxEventPayloadBytes, or the bus can frame an event the consumer then refuses +// to read. +const MaxFrameBytes = MaxEventPayloadBytes + maxFrameOverheadBytes // ErrFrameTooLarge is returned by ReadFrame when a single frame exceeds MaxFrameBytes. var ErrFrameTooLarge = errors.New("protocol: frame exceeds MaxFrameBytes") diff --git a/internal/event/adapter/localbus/protocol/frame_budget_test.go b/internal/event/adapter/localbus/protocol/frame_budget_test.go new file mode 100644 index 0000000000..a22f0529c1 --- /dev/null +++ b/internal/event/adapter/localbus/protocol/frame_budget_test.go @@ -0,0 +1,95 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package protocol + +import ( + "bufio" + "bytes" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/larksuite/cli/internal/event/model" +) + +// A payload the ingress accepts must survive framing. The two limits used to be +// the same number, which left the top of the accepted payload range framing +// into something the consumer refused to read: the bus wrote it, the consumer +// dropped it, and the event was lost with only a warning to show for it. +func TestFrameBudget_LargestAcceptedPayloadStillFits(t *testing.T) { + if MaxFrameBytes <= MaxEventPayloadBytes { + t.Fatalf("MaxFrameBytes (%d) must exceed MaxEventPayloadBytes (%d), otherwise a full-size payload cannot be framed", + MaxFrameBytes, MaxEventPayloadBytes) + } + + // A payload of exactly the accepted maximum, carrying long but realistic + // canonical metadata: a long event type, a long event id, a tenant key and + // the widest possible seq. + body := `{"text":"` + strings.Repeat("x", MaxEventPayloadBytes-len(`{"text":""}`)) + `"}` + if len(body) != MaxEventPayloadBytes { + t.Fatalf("test payload is %d bytes, want exactly %d", len(body), MaxEventPayloadBytes) + } + + ev := &model.Event{ + EventID: "c2f8a1e0-3b4d-4f6a-9c8e-7d5b1a2f3e4c-0123456789", + EventType: "vc.recording.recording_transcript_generated_v1", + SourceTime: "1700000000000", + AppID: "cli_FAKEFAKEFAKEFAKE", + TenantKey: "TENANT_FAKE_FOR_FRAME_SIZE_TESTS", + Payload: json.RawMessage(body), + Timestamp: time.Date(2026, 8, 4, 12, 34, 56, 123456789, time.UTC), + } + + var buf bytes.Buffer + if err := Encode(&buf, NewEvent(ev, 1<<64-1)); err != nil { + t.Fatalf("encode: %v", err) + } + // Encode appends the delimiter, so the buffer is the whole frame as it goes + // on the wire — which is what the reader measures. + if buf.Len() > MaxFrameBytes { + t.Errorf("a full-size payload framed to %d bytes, over the %d limit; raise maxFrameOverheadBytes", + buf.Len(), MaxFrameBytes) + } + + // The frame must also come back out, not just fit: this is the read path the + // consumer uses. + line, err := ReadFrame(bufio.NewReaderSize(bytes.NewReader(buf.Bytes()), MaxFrameBytes+1)) + if err != nil { + t.Fatalf("a full-size frame must be readable, got: %v", err) + } + decoded, err := Decode(bytes.TrimRight(line, "\n")) + if err != nil { + t.Fatalf("decode a full-size frame: %v", err) + } + frame, ok := decoded.(*Event) + if !ok { + t.Fatalf("decoded %T, want *Event", decoded) + } + if !bytes.Equal(frame.Payload, json.RawMessage(body)) { + t.Error("a full-size payload must round trip unchanged") + } +} + +// The overhead allowance has to be more than decoration: it must cover the +// metadata a frame actually carries, with the payload budget left over. +func TestFrameBudget_OverheadCoversCanonicalMetadata(t *testing.T) { + ev := &model.Event{ + EventID: "c2f8a1e0-3b4d-4f6a-9c8e-7d5b1a2f3e4c-0123456789", + EventType: "vc.recording.recording_transcript_generated_v1", + SourceTime: "1700000000000", + AppID: "cli_FAKEFAKEFAKEFAKE", + TenantKey: "TENANT_FAKE_FOR_FRAME_SIZE_TESTS", + Payload: json.RawMessage(`{}`), + Timestamp: time.Date(2026, 8, 4, 12, 34, 56, 123456789, time.UTC), + } + var buf bytes.Buffer + if err := Encode(&buf, NewEvent(ev, 1<<64-1)); err != nil { + t.Fatalf("encode: %v", err) + } + overhead := buf.Len() - len(`{}`) + if overhead > maxFrameOverheadBytes { + t.Errorf("frame metadata measures %d bytes, over the %d allowance", overhead, maxFrameOverheadBytes) + } +} From 972fe1e2e201586742874f818be7ef672804c082 Mon Sep 17 00:00:00 2001 From: leave330 Date: Tue, 4 Aug 2026 11:58:17 +0800 Subject: [PATCH 55/61] fix(event): start the stdin watcher only once the stream does The stdin EOF watcher was armed before the decision was executed, so a run refused for an unmet precondition still announced "stdin closed -- shutting down" on stderr. For a subprocess caller that reads stderr to find out why a consumer exited, that names the wrong cause: nothing had closed stdin, and the run never reached the stream. It now lives inside the runner, which a blocked decision never invokes. The blocked path also gains the tests it lacked. One pins the preview contract an orchestrator reads: ok and dry_run stay true and the exit code stays 0 because the preview itself succeeded, so status and the named blocked precondition with its detail are the only things that say a real run would refuse. The other pins the invariant that makes the watcher move correct -- executing a blocked decision returns the preflight's own error, hint intact, and never starts the stream. --- cmd/event/blocked_decision_test.go | 175 +++++++++++++++++++++++++++++ cmd/event/consume.go | 19 +++- 2 files changed, 188 insertions(+), 6 deletions(-) create mode 100644 cmd/event/blocked_decision_test.go diff --git a/cmd/event/blocked_decision_test.go b/cmd/event/blocked_decision_test.go new file mode 100644 index 0000000000..bdd5c97d24 --- /dev/null +++ b/cmd/event/blocked_decision_test.go @@ -0,0 +1,175 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package event + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "testing" + + "github.com/larksuite/cli/cmd/event/render" + "github.com/larksuite/cli/errs" + appconsume "github.com/larksuite/cli/internal/event/application/consume" + "github.com/larksuite/cli/internal/event/catalog" +) + +const blockedTestKey = "im.message.receive_v1" + +// blockingPreflight reports one blocked precondition, the shape a real +// preflight produces when credentials cannot be used. +type blockingPreflight struct { + name string + blockErr error +} + +func (p *blockingPreflight) Read(context.Context, *catalog.Entry, string) ([]appconsume.Precondition, error) { + return []appconsume.Precondition{ + {Name: "console_event_published", Status: appconsume.PreconditionOK}, + { + Name: p.name, + Status: appconsume.PreconditionBlocked, + Detail: p.blockErr.Error(), + BlockErr: p.blockErr, + }, + }, nil +} + +type fixedIdentity string + +func (i fixedIdentity) Resolve(context.Context, *catalog.Entry) (string, error) { + return string(i), nil +} + +// spyRunner records whether the delivery stream was ever started. +type spyRunner struct{ started bool } + +func (r *spyRunner) Run(context.Context, appconsume.PrepareFunc) error { + r.started = true + return nil +} + +func blockedDecisionFixture(t *testing.T) (*catalog.Entry, *appconsume.Service, *appconsume.Decision, error, error) { + t.Helper() + snap := compileCatalog() + entry, ok := snap.Resolve(blockedTestKey) + if !ok { + t.Fatalf("catalog has no %s", blockedTestKey) + } + blockErr := errs.NewValidationError(errs.SubtypeMissingScope, + "missing scopes for %s", blockedTestKey). + WithHint("run `lark-cli auth login --scope im:message` and retry") + + svc := &appconsume.Service{ + Strategies: appconsume.DefaultRegistry(), + Identity: fixedIdentity("bot"), + Preflight: &blockingPreflight{name: "credentials_available", blockErr: blockErr}, + } + decision, err := svc.Decide(context.Background(), entry, appconsume.Request{EventKey: blockedTestKey}, appconsume.ExecutionContext{}) + return entry, svc, decision, blockErr, err +} + +// The dry-run envelope for a blocked decision is a contract an orchestrator +// reads: ok stays true and the exit code stays 0, because the preview itself +// succeeded, and "would this run" is answered inside the payload. Anything that +// only checks ok or the exit code would treat a refusal as a green light, so +// the status and the blocked precondition must be present and named. +func TestBlockedDecision_DryRunEnvelopeStatesTheRefusal(t *testing.T) { + _, _, decision, _, err := blockedDecisionFixture(t) + if err != nil { + t.Fatalf("deciding must succeed even when a precondition blocks, got: %v", err) + } + + var stdout, stderr bytes.Buffer + if err := render.WriteDecisionJSON(&stdout, &stderr, "bot", decision.View()); err != nil { + t.Fatalf("render: %v", err) + } + + var envelope struct { + OK bool `json:"ok"` + DryRun bool `json:"dry_run"` + Data struct { + Decision struct { + Status string `json:"status"` + Preconditions []struct { + Name string `json:"name"` + Status string `json:"status"` + Detail string `json:"detail"` + } `json:"preconditions"` + WouldWrite []string `json:"would_write"` + } `json:"decision"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("stdout is not a decision envelope: %v\n%s", err, stdout.String()) + } + + if !envelope.OK || !envelope.DryRun { + t.Errorf("the preview succeeded, so ok and dry_run stay true; got: %s", stdout.String()) + } + if envelope.Data.Decision.Status != "blocked" { + t.Errorf("status = %q, want \"blocked\": this is the only field that tells a caller the real run would refuse", + envelope.Data.Decision.Status) + } + + var blocked *struct { + Name string `json:"name"` + Status string `json:"status"` + Detail string `json:"detail"` + } + for i := range envelope.Data.Decision.Preconditions { + if envelope.Data.Decision.Preconditions[i].Status == "blocked" { + blocked = &envelope.Data.Decision.Preconditions[i] + } + } + if blocked == nil { + t.Fatalf("a blocked decision must name the precondition that blocks it, got: %s", stdout.String()) + } + if blocked.Name != "credentials_available" { + t.Errorf("blocked precondition = %q, want the one the preflight reported", blocked.Name) + } + if blocked.Detail == "" { + t.Error("a blocked precondition must carry a detail; without it the caller knows only that something failed") + } + // Declared write side effects stay declarations in a preview. + if len(envelope.Data.Decision.WouldWrite) == 0 { + t.Error("the preview must still declare what a real run would write") + } +} + +// Executing a blocked decision returns the preflight's own error and never +// starts the stream. Everything the command sets up for a live run — the stdin +// EOF watcher included — hangs off the runner for this reason: started earlier, +// it announced "stdin closed — shutting down" on a run that was actually +// refused for an unmet precondition. +func TestBlockedDecision_ExecuteNeverStartsTheStream(t *testing.T) { + entry, svc, decision, blockErr, err := blockedDecisionFixture(t) + if err != nil { + t.Fatalf("decide: %v", err) + } + + runner := &spyRunner{} + execErr := svc.Execute(context.Background(), entry, decision, runner, appconsume.ExecutionContext{}) + + if runner.started { + t.Error("a blocked decision must not start the delivery stream") + } + problem, ok := errs.ProblemOf(execErr) + if !ok { + t.Fatalf("executing a blocked decision must return the preflight's typed error, got: %v", execErr) + } + if problem.Subtype != errs.SubtypeMissingScope { + t.Errorf("subtype = %q, want the preflight's own subtype preserved", problem.Subtype) + } + // The caller must receive the preflight's own error, not a copy of its + // message: the hint is what tells an operator how to recover, and a rewrap + // would drop it. + if !errors.Is(execErr, blockErr) { + t.Errorf("execute returned %v, want the preflight's own error", execErr) + } + if problem.Hint == "" { + t.Error("the recovery hint the preflight attached must survive to the caller") + } +} diff --git a/cmd/event/consume.go b/cmd/event/consume.go index 69886304f3..f48617f6e0 100644 --- a/cmd/event/consume.go +++ b/cmd/event/consume.go @@ -231,13 +231,20 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, snap *catalog.Snapshot, errOut = io.Discard } - // Non-TTY unbounded consumers use stdin EOF as shutdown for subprocess callers. - // Bounded runs already have --max-events/--timeout as their lifecycle control. - if shouldWatchStdinEOF(f.IOStreams.IsTerminal, o.maxEvents, o.timeout) { - watchStdinEOF(os.Stdin, cancel, errOut) - } - runner := streamRunnerFunc(func(ctx context.Context, prepare appconsume.PrepareFunc) error { + // Non-TTY unbounded consumers use stdin EOF as shutdown for subprocess + // callers. Bounded runs already have --max-events/--timeout as their + // lifecycle control. + // + // The watcher starts here rather than before the decision is executed: + // a blocked decision never reaches this point, and starting it earlier + // announced "stdin closed — shutting down" on a run that was actually + // refused for an unmet precondition, pointing the caller at the wrong + // cause. + if shouldWatchStdinEOF(f.IOStreams.IsTerminal, o.maxEvents, o.timeout) { + watchStdinEOF(os.Stdin, cancel, errOut) + } + return consume.Run(ctx, transport.New(), cfg.AppID, cfg.ProfileName, domain, consume.Options{ EventKey: eventKey, Def: keyDef, From 8b5cc03137eb48f2f883c7a1f9f2f64b88779658 Mon Sep 17 00:00:00 2001 From: leave330 Date: Tue, 4 Aug 2026 12:00:25 +0800 Subject: [PATCH 56/61] test(event): pin the seams between deciding and running a consume Two properties lived only in the wiring, where each side looks correct alone and only the pair is wrong. The normalizer must run exactly once per consumer: deciding runs it to compute the subscription identity and tells the host to skip its own call. Both directions of that flag failed silently -- a second run for a hook that is not idempotent, or a host normalizing values the bus was never told about -- and nothing asserted the count across the two layers. The preparation the decision chose must be the one that runs. The injected path and the declaration's own hook call the same function today, so a broken injection would have produced identical behavior; the test counts a spy the declaration cannot see, which distinguishes them. Both were verified by mutation: dropping the skip makes the normalizer run twice, and ignoring the injected preparation drops its count to zero. --- internal/event/execution_chain_test.go | 204 +++++++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 internal/event/execution_chain_test.go diff --git a/internal/event/execution_chain_test.go b/internal/event/execution_chain_test.go new file mode 100644 index 0000000000..781d2b7a0c --- /dev/null +++ b/internal/event/execution_chain_test.go @@ -0,0 +1,204 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Integration pins for the seams between deciding a consume and running it. +// Both properties below are invisible to the unit tests on either side: each +// layer looks correct on its own, and only the pair is wrong. +package event_test + +import ( + "context" + "encoding/json" + "io" + "sync/atomic" + "testing" + "time" + + "github.com/larksuite/cli/events" + eventlib "github.com/larksuite/cli/internal/event" + appconsume "github.com/larksuite/cli/internal/event/application/consume" + "github.com/larksuite/cli/internal/event/catalog" + "github.com/larksuite/cli/internal/event/consume" + "github.com/larksuite/cli/internal/event/testutil" +) + +const chainAppID = "cli_chain_app" + +type chainAPIClient struct{} + +func (chainAPIClient) CallAPI(context.Context, string, string, any) (json.RawMessage, error) { + return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil +} + +type chainIdentity struct{} + +func (chainIdentity) Resolve(context.Context, *catalog.Entry) (string, error) { return "bot", nil } + +type chainPreflight struct{} + +func (chainPreflight) Read(context.Context, *catalog.Entry, string) ([]appconsume.Precondition, error) { + return []appconsume.Precondition{{Name: "credentials_available", Status: appconsume.PreconditionOK}}, nil +} + +// chainKey is a synthetic key carrying both hooks the seams involve: a +// normalizer that records how often it ran and rewrites its input, and a +// preparation that records the same. +func chainKey(normalizeCalls, prepareCalls *atomic.Int64) eventlib.KeyDefinition { + return eventlib.KeyDefinition{ + Key: "test.chain_seam_v1", + EventType: "test.chain_seam_v1", + Params: []eventlib.ParamDef{ + {Name: "who", Type: eventlib.ParamString}, + }, + Schema: eventlib.SchemaDef{Native: &eventlib.SchemaSpec{Raw: json.RawMessage(`{"type":"object"}`)}}, + NormalizeParams: func(_ context.Context, _ eventlib.APIClient, params map[string]string) error { + normalizeCalls.Add(1) + if params["who"] == "me" { + params["who"] = "resolved@example.com" + } + return nil + }, + PreConsume: func(context.Context, eventlib.APIClient, map[string]string) (func() error, error) { + prepareCalls.Add(1) + return nil, nil + }, + } +} + +func chainService(t *testing.T, def eventlib.KeyDefinition) (*appconsume.Service, *catalog.Entry) { + t.Helper() + snap, err := catalog.Compile(append(events.All(), def), catalog.StrategyRefs{ + catalog.StrategyNone, catalog.StrategyLegacyPreConsume, + }) + if err != nil { + t.Fatalf("compile catalog: %v", err) + } + entry, ok := snap.Resolve(def.Key) + if !ok { + t.Fatalf("compiled catalog has no %s", def.Key) + } + return &appconsume.Service{ + Strategies: appconsume.DefaultRegistry(), + Identity: chainIdentity{}, + Preflight: chainPreflight{}, + }, entry +} + +// The normalizer must run exactly once for a consumer, across both layers. +// Deciding runs it to compute the subscription identity, and the host is told +// so it skips its own call. Get that flag wrong in either direction and the +// symptom is silent: a second run for a non-idempotent hook, or a host +// normalizing values the bus was never told about. +func TestExecutionChain_NormalizerRunsOncePerConsumer(t *testing.T) { + var normalizeCalls, prepareCalls atomic.Int64 + def := chainKey(&normalizeCalls, &prepareCalls) + svc, entry := chainService(t, def) + + decision, err := svc.Decide(context.Background(), entry, + appconsume.Request{EventKey: def.Key, Params: map[string]string{"who": "me"}}, + appconsume.ExecutionContext{API: chainAPIClient{}}) + if err != nil { + t.Fatalf("decide: %v", err) + } + if got := normalizeCalls.Load(); got != 1 { + t.Fatalf("deciding ran the normalizer %d time(s), want 1", got) + } + if got := decision.NormalizedParams()["who"]; got != "resolved@example.com" { + t.Fatalf("the decision must carry normalized values, got %q", got) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + tr := testutil.NewBusStub(currentAck).Listen(t, chainAppID) + + err = svc.Execute(ctx, entry, decision, runnerFor(t, tr, def, decision), appconsume.ExecutionContext{API: chainAPIClient{}}) + if err != nil { + t.Fatalf("execute: %v", err) + } + if got := normalizeCalls.Load(); got != 1 { + t.Errorf("the normalizer ran %d time(s) across decide and execute, want exactly 1", got) + } +} + +// The preparation the decision chose is the preparation that runs: the host +// takes it by injection rather than reaching for the declaration's own hook. +// Both paths call the same function today, so a broken injection would go +// unnoticed — this asserts the injected one is what executes by counting a +// spy the declaration does not know about. +func TestExecutionChain_ExecutesTheInjectedPreparation(t *testing.T) { + var normalizeCalls, declaredPrepareCalls atomic.Int64 + def := chainKey(&normalizeCalls, &declaredPrepareCalls) + svc, entry := chainService(t, def) + + decision, err := svc.Decide(context.Background(), entry, + appconsume.Request{EventKey: def.Key}, + appconsume.ExecutionContext{API: chainAPIClient{}}) + if err != nil { + t.Fatalf("decide: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + tr := testutil.NewBusStub(currentAck).Listen(t, chainAppID) + + var injectedRan atomic.Int64 + runner := appconsume.StreamRunner(runnerFunc(func(ctx context.Context, prepare appconsume.PrepareFunc) error { + // Wrap the strategy's preparation exactly as the command does, then + // record that the host invoked this one. + return consume.Run(ctx, tr, chainAppID, "", "", consume.Options{ + EventKey: def.Key, + Def: entry.Definition(), + Params: decision.NormalizedParams(), + ParamsNormalized: true, + Runtime: chainAPIClient{}, + Out: io.Discard, + ErrOut: io.Discard, + Quiet: true, + MaxEvents: 0, + Timeout: 300 * time.Millisecond, + Prepare: func(ctx context.Context) (func() error, error) { + injectedRan.Add(1) + return prepare(ctx) + }, + }) + })) + + if err := svc.Execute(ctx, entry, decision, runner, appconsume.ExecutionContext{API: chainAPIClient{}}); err != nil { + t.Fatalf("execute: %v", err) + } + + if got := injectedRan.Load(); got != 1 { + t.Errorf("the injected preparation ran %d time(s), want 1: the host must use what the decision chose", got) + } + if got := declaredPrepareCalls.Load(); got != 1 { + t.Errorf("the declaration's hook ran %d time(s), want 1 through the injected strategy", got) + } +} + +// currentAck is the hello_ack the current bus sends: first for the key and +// advertising canonical metadata, so these tests exercise the normal path. +const currentAck = `{"type":"hello_ack","bus_version":"v1","first_for_key":true,"capabilities":["canonical_metadata_v1"]}` + +type runnerFunc func(ctx context.Context, prepare appconsume.PrepareFunc) error + +func (f runnerFunc) Run(ctx context.Context, prepare appconsume.PrepareFunc) error { + return f(ctx, prepare) +} + +func runnerFor(t *testing.T, tr *testutil.FakeTransport, def eventlib.KeyDefinition, decision *appconsume.Decision) appconsume.StreamRunner { + t.Helper() + return runnerFunc(func(ctx context.Context, prepare appconsume.PrepareFunc) error { + return consume.Run(ctx, tr, chainAppID, "", "", consume.Options{ + EventKey: def.Key, + Def: &def, + Params: decision.NormalizedParams(), + ParamsNormalized: true, + Runtime: chainAPIClient{}, + Out: io.Discard, + ErrOut: io.Discard, + Quiet: true, + Timeout: 300 * time.Millisecond, + Prepare: prepare, + }) + }) +} From 74101efc308867b6d8143c80ab73d92f2bf55547 Mon Sep 17 00:00:00 2001 From: leave330 Date: Tue, 4 Aug 2026 12:06:08 +0800 Subject: [PATCH 57/61] feat(event): carry recovery information in the consume preview A blocked precondition rendered only the error's sentence. The preview exists to be read before acting -- it is the surface an agent consults to decide whether to run at all -- yet it was the one surface with no way forward: the classification and the recovery hint the same failure puts in a real run's error envelope were dropped on the floor. Each precondition now carries subtype, hint and, where the failure is a missing grant, the concrete scopes, all omitted when the check passed so a healthy preview stays as terse as before. The blocking error already held all of it; only the view discarded it. The renderer converts the view struct directly, so the exported model and the JSON model cannot drift apart without breaking the build. --- cmd/event/blocked_decision_test.go | 39 +++++++++++++++---- cmd/event/render/decision.go | 6 +++ .../event/application/consume/decision.go | 30 ++++++++++++-- 3 files changed, 65 insertions(+), 10 deletions(-) diff --git a/cmd/event/blocked_decision_test.go b/cmd/event/blocked_decision_test.go index bdd5c97d24..f3d861e2f7 100644 --- a/cmd/event/blocked_decision_test.go +++ b/cmd/event/blocked_decision_test.go @@ -58,8 +58,9 @@ func blockedDecisionFixture(t *testing.T) (*catalog.Entry, *appconsume.Service, if !ok { t.Fatalf("catalog has no %s", blockedTestKey) } - blockErr := errs.NewValidationError(errs.SubtypeMissingScope, + blockErr := errs.NewPermissionError(errs.SubtypeMissingScope, "missing scopes for %s", blockedTestKey). + WithMissingScopes("im:message", "im:chat:readonly"). WithHint("run `lark-cli auth login --scope im:message` and retry") svc := &appconsume.Service{ @@ -94,9 +95,12 @@ func TestBlockedDecision_DryRunEnvelopeStatesTheRefusal(t *testing.T) { Decision struct { Status string `json:"status"` Preconditions []struct { - Name string `json:"name"` - Status string `json:"status"` - Detail string `json:"detail"` + Name string `json:"name"` + Status string `json:"status"` + Detail string `json:"detail"` + Subtype string `json:"subtype"` + Hint string `json:"hint"` + MissingScopes []string `json:"missing_scopes"` } `json:"preconditions"` WouldWrite []string `json:"would_write"` } `json:"decision"` @@ -115,9 +119,12 @@ func TestBlockedDecision_DryRunEnvelopeStatesTheRefusal(t *testing.T) { } var blocked *struct { - Name string `json:"name"` - Status string `json:"status"` - Detail string `json:"detail"` + Name string `json:"name"` + Status string `json:"status"` + Detail string `json:"detail"` + Subtype string `json:"subtype"` + Hint string `json:"hint"` + MissingScopes []string `json:"missing_scopes"` } for i := range envelope.Data.Decision.Preconditions { if envelope.Data.Decision.Preconditions[i].Status == "blocked" { @@ -133,6 +140,24 @@ func TestBlockedDecision_DryRunEnvelopeStatesTheRefusal(t *testing.T) { if blocked.Detail == "" { t.Error("a blocked precondition must carry a detail; without it the caller knows only that something failed") } + // The preview is read before acting, so it has to say what to do about the + // refusal — the same recovery information a real run puts in its error + // envelope, in the same machine-readable shape. + if blocked.Subtype != string(errs.SubtypeMissingScope) { + t.Errorf("subtype = %q, want the classification callers branch on", blocked.Subtype) + } + if blocked.Hint == "" { + t.Error("a blocked precondition must carry the recovery hint; the preview is the surface an agent reads before acting") + } + if len(blocked.MissingScopes) != 2 { + t.Errorf("missing_scopes = %v, want the concrete scopes to grant", blocked.MissingScopes) + } + // A precondition that passed has nothing to recover from and must stay bare. + for _, pc := range envelope.Data.Decision.Preconditions { + if pc.Status == "ok" && (pc.Subtype != "" || pc.Hint != "" || len(pc.MissingScopes) > 0) { + t.Errorf("a passing precondition must carry no recovery fields, got %+v", pc) + } + } // Declared write side effects stay declarations in a preview. if len(envelope.Data.Decision.WouldWrite) == 0 { t.Error("the preview must still declare what a real run would write") diff --git a/cmd/event/render/decision.go b/cmd/event/render/decision.go index 6a7435a443..96e6c92ca1 100644 --- a/cmd/event/render/decision.go +++ b/cmd/event/render/decision.go @@ -51,6 +51,12 @@ type preconditionView struct { Name string `json:"name"` Status string `json:"status"` Detail string `json:"detail,omitempty"` + // The machine-readable half of a failure, mirroring the error envelope a + // real run would emit: callers branch on subtype and act on hint instead of + // matching prose. Omitted when the check did not fail. + Subtype string `json:"subtype,omitempty"` + Hint string `json:"hint,omitempty"` + MissingScopes []string `json:"missing_scopes,omitempty"` } type preparationView struct { diff --git a/internal/event/application/consume/decision.go b/internal/event/application/consume/decision.go index 82780f0e81..1aa0654913 100644 --- a/internal/event/application/consume/decision.go +++ b/internal/event/application/consume/decision.go @@ -8,9 +8,12 @@ package consume import ( + "errors" "maps" "slices" "time" + + "github.com/larksuite/cli/errs" ) // Request carries the caller's consume inputs, already parsed from flags. @@ -87,9 +90,19 @@ func (d *Decision) View() DecisionView { WouldWrite: slices.Clone(d.wouldWrite), } for _, p := range d.preconditions { - v.Preconditions = append(v.Preconditions, PreconditionView{ - Name: p.Name, Status: string(p.Status), Detail: p.Detail, - }) + pv := PreconditionView{Name: p.Name, Status: string(p.Status), Detail: p.Detail} + // A blocking error already carries the classification and the recovery + // action; a preview that dropped them would be the one surface with no + // way forward, even though it exists to be read before acting. + if problem, ok := errs.ProblemOf(p.BlockErr); ok { + pv.Subtype = string(problem.Subtype) + pv.Hint = problem.Hint + } + var permission *errs.PermissionError + if errors.As(p.BlockErr, &permission) { + pv.MissingScopes = slices.Clone(permission.MissingScopes) + } + v.Preconditions = append(v.Preconditions, pv) } if d.preparation != nil { v.Preparation = &PreparationView{ @@ -115,10 +128,21 @@ type DecisionView struct { WouldWrite []string } +// PreconditionView is the render model of one precondition. Beyond the human +// sentence in Detail it carries the machine-readable half of the failure, so a +// caller previewing a consume gets the same recovery information a real run +// would put in its error envelope instead of having to parse prose. type PreconditionView struct { Name string Status string Detail string + // Subtype classifies the failure the way the error envelope does, which is + // what callers are told to branch on. + Subtype string + // Hint is the recovery action, verbatim from the error that blocked. + Hint string + // MissingScopes lists the scopes to grant, when that is what is missing. + MissingScopes []string } type PreparationView struct { From 56506081544935374c28dd7ff4a9c65bd036da30 Mon Sep 17 00:00:00 2001 From: leave330 Date: Tue, 4 Aug 2026 12:50:45 +0800 Subject: [PATCH 58/61] test(event): pin the decision-to-host wiring where it is written The seam tests asserted that the stream host honours the normalized-params flag and the injected preparation, but they built those options themselves. Deleting either line from the command left every test green -- verified by deleting them -- so the wiring the tests were meant to protect was not covered at all. The three assignments the command alone can get wrong now live in a small named function, and the tests call that function. Each of the three was confirmed to turn the suite red when removed. The fixture key takes a parameter and the incoming options carry a value the decision must overwrite; with a parameterless key the parameter assignment had nothing to observe and stayed unpinned on the first attempt. The function does one thing: transfer the decided parts onto the options. Signal handling, the stdin watcher, the transport and the runtime stay where they were. --- cmd/event/consume.go | 51 ++++++++++----- cmd/event/stream_wiring_test.go | 106 ++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+), 17 deletions(-) create mode 100644 cmd/event/stream_wiring_test.go diff --git a/cmd/event/consume.go b/cmd/event/consume.go index f48617f6e0..762324533c 100644 --- a/cmd/event/consume.go +++ b/cmd/event/consume.go @@ -245,27 +245,44 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, snap *catalog.Snapshot, watchStdinEOF(os.Stdin, cancel, errOut) } - return consume.Run(ctx, transport.New(), cfg.AppID, cfg.ProfileName, domain, consume.Options{ - EventKey: eventKey, - Def: keyDef, - Params: decision.NormalizedParams(), - ParamsNormalized: true, - JQExpr: o.jqExpr, - Quiet: o.quiet, - OutputDir: outputDir, - Runtime: runtime, - Out: f.IOStreams.Out, - ErrOut: errOut, - RemoteAPIClient: botRuntime, - MaxEvents: o.maxEvents, - Timeout: o.timeout, - IsTTY: f.IOStreams.IsTerminal, - Prepare: prepare, - }) + return consume.Run(ctx, transport.New(), cfg.AppID, cfg.ProfileName, domain, + applyDecision(consume.Options{ + EventKey: eventKey, + Def: keyDef, + JQExpr: o.jqExpr, + Quiet: o.quiet, + OutputDir: outputDir, + Runtime: runtime, + Out: f.IOStreams.Out, + ErrOut: errOut, + RemoteAPIClient: botRuntime, + MaxEvents: o.maxEvents, + Timeout: o.timeout, + IsTTY: f.IOStreams.IsTerminal, + }, decision, prepare)) }) return svc.Execute(ctx, entry, decision, runner, appconsume.ExecutionContext{API: runtime}) } +// applyDecision transfers the decided parts of a consume onto the host's +// options. It exists as a named function because these three assignments are +// couplings the command alone can get wrong, and a test can only pin them where +// they are written. +// +// The parameters and the flag travel together: the deciding layer already ran +// the normalizer on exactly these values to compute the subscription identity. +// The flag without the values would leave the host normalizing input the bus +// was never told about; the values without the flag would run a +// once-per-consumer hook a second time. Prepare carries the strategy the +// decision settled on, so what was decided is what executes instead of the +// declaration's own hook. +func applyDecision(opts consume.Options, decision *appconsume.Decision, prepare appconsume.PrepareFunc) consume.Options { + opts.Params = decision.NormalizedParams() + opts.ParamsNormalized = true + opts.Prepare = prepare + return opts +} + // resolveIdentity resolves the session identity and enforces keyDef.AuthTypes as a whitelist. func resolveIdentity(cmd *cobra.Command, f *cmdutil.Factory, keyDef *eventlib.KeyDefinition) (core.Identity, error) { flagAs := core.Identity(cmd.Flag("as").Value.String()) diff --git a/cmd/event/stream_wiring_test.go b/cmd/event/stream_wiring_test.go new file mode 100644 index 0000000000..e6aeb3aeca --- /dev/null +++ b/cmd/event/stream_wiring_test.go @@ -0,0 +1,106 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package event + +import ( + "context" + "maps" + "sync/atomic" + "testing" + + appconsume "github.com/larksuite/cli/internal/event/application/consume" + "github.com/larksuite/cli/internal/event/catalog" + "github.com/larksuite/cli/internal/event/consume" +) + +// These assert what the command hands the stream host, on the very function the +// command calls. Asserting the host honours the options is a different claim +// and lives with the host: a test that builds its own options would keep +// passing after the command stopped setting them. + +type wiringIdentity struct{} + +func (wiringIdentity) Resolve(context.Context, *catalog.Entry) (string, error) { return "bot", nil } + +type wiringPreflight struct{} + +func (wiringPreflight) Read(context.Context, *catalog.Entry, string) ([]appconsume.Precondition, error) { + return []appconsume.Precondition{{Name: "credentials_available", Status: appconsume.PreconditionOK}}, nil +} + +// wiringKey must be a key that takes a parameter: with a parameterless key the +// normalized-parameter assignment has nothing to observe, and deleting it from +// the command would go unnoticed. +const wiringKey = "board.whiteboard.updated_v1" + +func wiringDecision(t *testing.T) (*catalog.Entry, *appconsume.Decision) { + t.Helper() + snap := compileCatalog() + entry, ok := snap.Resolve(wiringKey) + if !ok { + t.Fatalf("catalog has no %s", wiringKey) + } + svc := &appconsume.Service{ + Strategies: consumeStrategies, + Identity: wiringIdentity{}, + Preflight: wiringPreflight{}, + } + decision, err := svc.Decide(context.Background(), entry, + appconsume.Request{EventKey: wiringKey, Params: map[string]string{"whiteboard_id": "board-A"}}, + appconsume.ExecutionContext{}) + if err != nil { + t.Fatalf("decide: %v", err) + } + return entry, decision +} + +// The host must be given the decision's own parameters together with the flag +// that says they are already normalized. Splitting the pair is silent either +// way: the flag without the values leaves the host normalizing input the bus +// was never told about, and the values without the flag run a +// once-per-consumer hook a second time. +func TestStreamOptions_PassesNormalizedParamsWithTheFlag(t *testing.T) { + _, decision := wiringDecision(t) + + // Seeded with a value the decision must overwrite: without a stale value to + // replace, an assignment that stopped happening would look identical. + opts := applyDecision(consume.Options{ + EventKey: wiringKey, + Params: map[string]string{"whiteboard_id": "stale-never-normalized"}, + }, decision, nil) + + if !opts.ParamsNormalized { + t.Error("the host must be told the parameters are already normalized, or it runs the hook again") + } + if !maps.Equal(opts.Params, decision.NormalizedParams()) { + t.Errorf("params = %v, want the decision's normalized values %v", opts.Params, decision.NormalizedParams()) + } + if len(decision.NormalizedParams()) == 0 { + t.Fatal("the fixture key must take a parameter, otherwise this test cannot observe the assignment") + } +} + +// The preparation the decision settled on must reach the host, so what was +// decided is what runs. +func TestStreamOptions_PassesTheDecidedPreparation(t *testing.T) { + _, decision := wiringDecision(t) + + var ran atomic.Int64 + prepare := appconsume.PrepareFunc(func(context.Context) (appconsume.Cleanup, error) { + ran.Add(1) + return nil, nil + }) + + opts := applyDecision(consume.Options{EventKey: wiringKey}, decision, prepare) + + if opts.Prepare == nil { + t.Fatal("the host must receive the decided preparation; without it the host falls back to the declaration's own hook and the decision is bypassed") + } + if _, err := opts.Prepare(context.Background()); err != nil { + t.Fatalf("invoking the wired preparation: %v", err) + } + if got := ran.Load(); got != 1 { + t.Errorf("the wired preparation ran %d time(s), want the one the decision chose", got) + } +} From db4363d1f5c2e2bf3e003c08e483ab5fdcf4f262 Mon Sep 17 00:00:00 2001 From: leave330 Date: Tue, 4 Aug 2026 13:51:20 +0800 Subject: [PATCH 59/61] fix(event): satisfy the framework lint rules in the new test helper The bus stub has to live in a non-test file so other packages can import it, which subjects it to the framework bans that test files are exempt from. It now goes through vfs for its temp directory, like the rest of internal/, so it needs no exemption at all. A redundant conversion of an aliased function type is gone too. --- cmd/event/stream_wiring_test.go | 4 ++-- internal/event/testutil/busstub.go | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cmd/event/stream_wiring_test.go b/cmd/event/stream_wiring_test.go index e6aeb3aeca..92a22c5b8d 100644 --- a/cmd/event/stream_wiring_test.go +++ b/cmd/event/stream_wiring_test.go @@ -87,10 +87,10 @@ func TestStreamOptions_PassesTheDecidedPreparation(t *testing.T) { _, decision := wiringDecision(t) var ran atomic.Int64 - prepare := appconsume.PrepareFunc(func(context.Context) (appconsume.Cleanup, error) { + prepare := func(context.Context) (appconsume.Cleanup, error) { ran.Add(1) return nil, nil - }) + } opts := applyDecision(consume.Options{EventKey: wiringKey}, decision, prepare) diff --git a/internal/event/testutil/busstub.go b/internal/event/testutil/busstub.go index 262a85b579..11a5d81a40 100644 --- a/internal/event/testutil/busstub.go +++ b/internal/event/testutil/busstub.go @@ -9,11 +9,11 @@ import ( "encoding/json" "fmt" "net" - "os" "testing" "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" "github.com/larksuite/cli/internal/event/adapter/localbus/transport" + "github.com/larksuite/cli/internal/vfs" ) // BusStub speaks just enough of the local bus protocol to drive a real @@ -42,11 +42,11 @@ func NewBusStub(rawAck string, rawFrames ...string) *BusStub { // limit, so a descriptive subtest name would fail the bind. func (s *BusStub) Listen(t *testing.T, appID string) *FakeTransport { t.Helper() - dir, err := os.MkdirTemp("", "busstub-*") + dir, err := vfs.MkdirTemp("", "busstub-*") if err != nil { t.Fatalf("stub bus tempdir: %v", err) } - t.Cleanup(func() { _ = os.RemoveAll(dir) }) + t.Cleanup(func() { _ = vfs.RemoveAll(dir) }) tr := NewWrappedFake(transport.New(), dir+"/bus.sock") ln, err := tr.Listen(appID) From c8ec4a85a318f3dc3086b247b72328aee3d4f45e Mon Sep 17 00:00:00 2001 From: leave330 Date: Wed, 5 Aug 2026 15:10:27 +0800 Subject: [PATCH 60/61] docs(event): drop dry-run guidance and name the domains The skill no longer documents --dry-run: the preview earns little for an agent this cycle, and describing a path agents rarely take costs budget in a document that has to stay short. --domain now lists the eight domains inline instead of leaving the agent to guess one and learn from the rejection, matching what the flag's own help already prints. --- skills/lark-event/SKILL.md | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/skills/lark-event/SKILL.md b/skills/lark-event/SKILL.md index 5bc1d322c6..748b0ea8b6 100644 --- a/skills/lark-event/SKILL.md +++ b/skills/lark-event/SKILL.md @@ -32,7 +32,6 @@ metadata: | `--max-events N` | Exit after N events. Default 0 = unlimited | | `--timeout D` | Exit after duration D (e.g. `30s`, `2m`). Default 0 = no timeout. Whichever of `--max-events` / `--timeout` fires first wins | | `--output-dir ` | Write each event as a file (relative paths only; prevents traversal) | -| `--dry-run` | Decide and preview the consume (identity, preconditions, side effects) without performing any of them, then exit 0. See "Dry-run preview" below | | `--quiet` | Suppress stderr diagnostics. **AI should not use this** — it silences the ready marker | | `--as user\|bot\|auto` | Identity for the session (see lark-shared) | @@ -61,18 +60,9 @@ wait ## Call flow -1. `lark-cli event list --json` → pick a legal key (`--domain ` narrows to one domain, e.g. `--domain vc`; unknown domains fail with the valid set listed in the hint) +1. `lark-cli event list --json` → pick a legal key. `--domain ` narrows to one domain; the domains are `application`, `approval`, `board`, `card`, `im`, `minutes`, `task`, `vc`. An unknown domain fails with the valid set listed in the hint. 2. `lark-cli event schema --json` → read `resolved_output_schema` + `jq_root_path` to determine field paths -3. `lark-cli event consume --dry-run` → optional: verify readiness and preview side effects before committing to a long-running process -4. `lark-cli event consume [--jq '']` → consume - -## Dry-run preview - -When the user asks to preview a consume or check readiness, execute `event consume --dry-run` instead of only quoting it. It exits 0 after read-only checks without starting a bus, registering a consumer, touching a server-side subscription, creating files, or opening the event stream. - -Read the top-level `dry_run` marker and `data.decision`: `status` / `preconditions` report readiness, `would_write` previews real-run side effects, and optional `preparation` states its strategy and trigger condition. `ready` can run, `unknown` proceeds with an unresolved weak check, and `blocked` would refuse a real run. - -Unknown keys or bad params exit 2. Unusable credentials render `blocked` with a `credentials_available` precondition and still exit 0; a real run exits 3. +3. `lark-cli event consume [--jq '']` → consume ## Subprocess contract From 8454c1f02ddc2cb77b0c65ee4ed3704418b206c3 Mon Sep 17 00:00:00 2001 From: leave330 Date: Wed, 5 Aug 2026 21:16:21 +0800 Subject: [PATCH 61/61] docs(event): state the --quiet drop-diagnostic blind spot --- cmd/event/consume.go | 2 +- skills/lark-event/SKILL.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/event/consume.go b/cmd/event/consume.go index 8b3ba3a8ea..538e7065c9 100644 --- a/cmd/event/consume.go +++ b/cmd/event/consume.go @@ -67,7 +67,7 @@ Use 'event schema ' for parameter details.`, cmd.Flags().StringArrayVarP(&o.params, "param", "p", nil, "Key=value parameter (repeatable)") cmd.Flags().StringVar(&o.jqExpr, "jq", "", "JQ expression to filter output") - cmd.Flags().BoolVar(&o.quiet, "quiet", false, "Suppress informational messages on stderr") + cmd.Flags().BoolVar(&o.quiet, "quiet", false, "Suppress routine and per-event stderr output, including ready/exit markers and drop diagnostics. This can hide event loss; omit --quiet when integrity matters") cmd.Flags().StringVar(&o.outputDir, "output-dir", "", "Write each event as a file in this directory (relative paths only; absolute paths and ~ are rejected to prevent path traversal)") cmd.Flags().IntVar(&o.maxEvents, "max-events", 0, "Exit after N successful emits (0 = unlimited). Multi-worker EventKeys may emit up to workers-1 past N before all workers stop. Bounded runs ignore stdin EOF.") cmd.Flags().BoolVar(&o.dryRun, "dry-run", false, "Decide and preview the consume (identity, preconditions, side effects) without performing any of them, then exit") diff --git a/skills/lark-event/SKILL.md b/skills/lark-event/SKILL.md index 748b0ea8b6..2aa9313898 100644 --- a/skills/lark-event/SKILL.md +++ b/skills/lark-event/SKILL.md @@ -32,7 +32,7 @@ metadata: | `--max-events N` | Exit after N events. Default 0 = unlimited | | `--timeout D` | Exit after duration D (e.g. `30s`, `2m`). Default 0 = no timeout. Whichever of `--max-events` / `--timeout` fires first wins | | `--output-dir ` | Write each event as a file (relative paths only; prevents traversal) | -| `--quiet` | Suppress stderr diagnostics. **AI should not use this** — it silences the ready marker | +| `--quiet` | Suppress ready/exit markers and per-event stderr diagnostics, including drop warnings. This can hide event loss. **AI should not use this** — it removes readiness and integrity signals | | `--as user\|bot\|auto` | Identity for the session (see lark-shared) |