diff --git a/events/im/card_action.go b/events/im/card_action.go new file mode 100644 index 0000000000..43b8b6cdbf --- /dev/null +++ b/events/im/card_action.go @@ -0,0 +1,132 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "context" + "encoding/json" + "strings" + + "github.com/larksuite/cli/internal/event" +) + +// CardActionTriggerOutput is the flattened shape for card.action.trigger. +type CardActionTriggerOutput struct { + Type string `json:"type" desc:"Event type; always card.action.trigger"` + EventID string `json:"event_id,omitempty" desc:"Globally unique event ID"` + Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string)" kind:"timestamp_ms"` + OperatorID string `json:"operator_id,omitempty" desc:"Operator open_id" kind:"open_id"` + MessageID string `json:"message_id,omitempty" desc:"Message ID of the card" kind:"message_id"` + ChatID string `json:"chat_id,omitempty" desc:"Chat ID" kind:"chat_id"` + Host string `json:"host,omitempty" desc:"Host type: im_message / im_top_notice"` + Token string `json:"token,omitempty" desc:"Token for delay card update (valid 30 min, max 2 updates)"` + ActionTag string `json:"action_tag,omitempty" desc:"Triggered element type: button/select_static/input/checker/etc"` + ActionValue string `json:"action_value,omitempty" desc:"Developer-defined action value as JSON string"` + ActionName string `json:"action_name,omitempty" desc:"Element name attribute"` + FormValue string `json:"form_value,omitempty" desc:"Form submission values as JSON string (only on form submit)"` + InputValue string `json:"input_value,omitempty" desc:"Input field value (only for input elements)"` + Option string `json:"option,omitempty" desc:"Selected option value (for single-select dropdown)"` + Options string `json:"options,omitempty" desc:"Selected options, comma-separated (for multi-select)"` + Checked bool `json:"checked" desc:"Checkbox state (for checkbox elements)"` + Timezone string `json:"timezone,omitempty" desc:"User timezone for date/time picker interactions"` + CardContent string `json:"card_content,omitempty" desc:"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"` +} + +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"` + } `json:"operator"` + Token string `json:"token"` + Host string `json:"host"` + Action struct { + Tag string `json:"tag"` + Value map[string]interface{} `json:"value"` + Name string `json:"name"` + FormValue map[string]interface{} `json:"form_value"` + InputValue string `json:"input_value"` + Option string `json:"option"` + Options []string `json:"options"` + Checked bool `json:"checked"` + Timezone string `json:"timezone"` + } `json:"action"` + Context struct { + OpenMessageID string `json:"open_message_id"` + OpenChatID string `json:"open_chat_id"` + } `json:"context"` + } `json:"event"` + } + if err := json.Unmarshal(raw.Payload, &envelope); err != nil { + return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload + } + + actionValue := marshalToString(envelope.Event.Action.Value) + formValue := marshalToString(envelope.Event.Action.FormValue) + options := strings.Join(envelope.Event.Action.Options, ",") + + out := &CardActionTriggerOutput{ + Type: envelope.Header.EventType, + EventID: envelope.Header.EventID, + Timestamp: envelope.Header.CreateTime, + OperatorID: envelope.Event.Operator.OpenID, + MessageID: envelope.Event.Context.OpenMessageID, + ChatID: envelope.Event.Context.OpenChatID, + Host: envelope.Event.Host, + Token: envelope.Event.Token, + ActionTag: envelope.Event.Action.Tag, + ActionValue: actionValue, + ActionName: envelope.Event.Action.Name, + FormValue: formValue, + InputValue: envelope.Event.Action.InputValue, + Option: envelope.Event.Action.Option, + Options: options, + Checked: envelope.Event.Action.Checked, + Timezone: envelope.Event.Action.Timezone, + } + + if out.MessageID != "" && rt != nil { + out.CardContent = fetchCardUserDSL(ctx, rt, out.MessageID) + } + + return json.Marshal(out) +} + +// fetchCardUserDSL gets the card message content via message get API. +// Returns empty string on any failure — never blocks event consumption. +func fetchCardUserDSL(ctx context.Context, rt event.APIClient, messageID string) string { + path := "/open-apis/im/v1/messages/" + messageID + "?card_msg_content_type=user_card_content" + resp, err := rt.CallAPI(ctx, "GET", path, nil) + if err != nil { + return "" + } + var result struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + Items []struct { + Body struct { + Content string `json:"content"` + } `json:"body"` + } `json:"items"` + } `json:"data"` + } + if json.Unmarshal(resp, &result) != nil || result.Code != 0 || len(result.Data.Items) == 0 { + return "" + } + return result.Data.Items[0].Body.Content +} + +func marshalToString(m map[string]interface{}) string { + if len(m) == 0 { + return "" + } + b, _ := json.Marshal(m) + return string(b) +} diff --git a/events/im/card_action_test.go b/events/im/card_action_test.go new file mode 100644 index 0000000000..df0c1fe36c --- /dev/null +++ b/events/im/card_action_test.go @@ -0,0 +1,432 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/larksuite/cli/internal/event" +) + +func TestCardActionTriggerRegistered(t *testing.T) { + def, ok := event.Lookup("card.action.trigger") + if !ok { + t.Fatal("card.action.trigger should be registered via Keys()") + } + if def.Schema.Custom == nil { + t.Error("card.action.trigger must set Schema.Custom") + } + if def.Process == nil { + t.Error("card.action.trigger must set Process") + } + if len(def.Scopes) == 0 { + t.Error("Scopes must not be empty") + } +} + +func TestProcessCardAction_Button(t *testing.T) { + payload := `{ + "schema": "2.0", + "header": { + "event_id": "ev_btn_001", + "event_type": "card.action.trigger", + "create_time": "1776409469273" + }, + "event": { + "operator": {"open_id": "ou_operator"}, + "token": "c-token-btn", + "host": "im_message", + "action": { + "tag": "button", + "value": {"key": "approve"}, + "name": "approve_btn", + "form_value": {}, + "options": [], + "checked": false + }, + "context": { + "open_message_id": "om_msg_001", + "open_chat_id": "oc_chat_001" + } + } + }` + out := runCardAction(t, payload, nil) + + if out.Type != "card.action.trigger" { + t.Errorf("Type = %q, want card.action.trigger", out.Type) + } + if out.EventID != "ev_btn_001" { + t.Errorf("EventID = %q", out.EventID) + } + if out.OperatorID != "ou_operator" { + t.Errorf("OperatorID = %q", out.OperatorID) + } + if out.ActionTag != "button" { + t.Errorf("ActionTag = %q, want button", out.ActionTag) + } + if out.ActionValue != `{"key":"approve"}` { + t.Errorf("ActionValue = %q", out.ActionValue) + } + if out.ActionName != "approve_btn" { + t.Errorf("ActionName = %q", out.ActionName) + } + if out.Token != "c-token-btn" { + t.Errorf("Token = %q", out.Token) + } + if out.MessageID != "om_msg_001" { + t.Errorf("MessageID = %q", out.MessageID) + } + if out.ChatID != "oc_chat_001" { + t.Errorf("ChatID = %q", out.ChatID) + } + if out.Host != "im_message" { + t.Errorf("Host = %q", out.Host) + } + if out.Timestamp != "1776409469273" { + t.Errorf("Timestamp = %q", out.Timestamp) + } +} + +func TestProcessCardAction_FormSubmit(t *testing.T) { + payload := `{ + "schema": "2.0", + "header": { + "event_id": "ev_form_001", + "event_type": "card.action.trigger", + "create_time": "1776409469274" + }, + "event": { + "operator": {"open_id": "ou_form_user"}, + "token": "c-token-form", + "host": "im_message", + "action": { + "tag": "button", + "value": {}, + "name": "submit_btn", + "form_value": {"name": "test-user", "reason": "testing"}, + "options": [], + "checked": false + }, + "context": { + "open_message_id": "om_form_001", + "open_chat_id": "oc_chat_002" + } + } + }` + out := runCardAction(t, payload, nil) + + if out.FormValue != `{"name":"test-user","reason":"testing"}` { + t.Errorf("FormValue = %q", out.FormValue) + } + if out.ActionTag != "button" { + t.Errorf("ActionTag = %q, want button", out.ActionTag) + } +} + +func TestProcessCardAction_MultiSelect(t *testing.T) { + payload := `{ + "schema": "2.0", + "header": { + "event_id": "ev_ms_001", + "event_type": "card.action.trigger", + "create_time": "1776409469275" + }, + "event": { + "operator": {"open_id": "ou_ms_user"}, + "token": "c-token-ms", + "host": "im_message", + "action": { + "tag": "multi_select_static", + "value": {}, + "name": "multi_select", + "options": ["opt_1", "opt_3"], + "checked": false + }, + "context": { + "open_message_id": "om_ms_001", + "open_chat_id": "oc_chat_003" + } + } + }` + out := runCardAction(t, payload, nil) + + if out.Options != "opt_1,opt_3" { + t.Errorf("Options = %q, want opt_1,opt_3", out.Options) + } + if out.ActionTag != "multi_select_static" { + t.Errorf("ActionTag = %q", out.ActionTag) + } +} + +func TestProcessCardAction_Input(t *testing.T) { + payload := `{ + "schema": "2.0", + "header": { + "event_id": "ev_input_001", + "event_type": "card.action.trigger", + "create_time": "1776409469276" + }, + "event": { + "operator": {"open_id": "ou_input_user"}, + "token": "c-token-input", + "host": "im_message", + "action": { + "tag": "input", + "value": {}, + "name": "text_input", + "input_value": "hello world", + "options": [], + "checked": false + }, + "context": { + "open_message_id": "om_input_001", + "open_chat_id": "oc_chat_004" + } + } + }` + out := runCardAction(t, payload, nil) + + if out.InputValue != "hello world" { + t.Errorf("InputValue = %q", out.InputValue) + } + if out.ActionTag != "input" { + t.Errorf("ActionTag = %q", out.ActionTag) + } +} + +func TestProcessCardAction_DatePicker(t *testing.T) { + payload := `{ + "schema": "2.0", + "header": { + "event_id": "ev_date_001", + "event_type": "card.action.trigger", + "create_time": "1776409469277" + }, + "event": { + "operator": {"open_id": "ou_date_user"}, + "token": "c-token-date", + "host": "im_message", + "action": { + "tag": "date_picker", + "value": {}, + "name": "date_selector", + "option": "2024-04-01 +0800", + "timezone": "Asia/Shanghai", + "options": [], + "checked": false + }, + "context": { + "open_message_id": "om_date_001", + "open_chat_id": "oc_chat_005" + } + } + }` + out := runCardAction(t, payload, nil) + + if out.Option != "2024-04-01 +0800" { + t.Errorf("Option = %q", out.Option) + } + if out.Timezone != "Asia/Shanghai" { + t.Errorf("Timezone = %q", out.Timezone) + } +} + +func TestProcessCardAction_MalformedPayload(t *testing.T) { + raw := &event.RawEvent{ + EventID: "ev_bad", + EventType: "card.action.trigger", + Payload: json.RawMessage(`not json`), + 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 string(got) != "not json" { + t.Errorf("malformed fallback output = %q, want original bytes", string(got)) + } +} + +func TestProcessCardAction_MessageGetSuccess(t *testing.T) { + payload := `{ + "schema": "2.0", + "header": { + "event_id": "ev_mg_ok", + "event_type": "card.action.trigger", + "create_time": "1776409469278" + }, + "event": { + "operator": {"open_id": "ou_mg_user"}, + "token": "c-token-mg", + "host": "im_message", + "action": { + "tag": "button", + "value": {"key": "click"}, + "name": "btn", + "form_value": {}, + "options": [], + "checked": false + }, + "context": { + "open_message_id": "om_mg_001", + "open_chat_id": "oc_chat_mg" + } + } + }` + cardContent := `{"header":{"title":{"tag":"plain_text","content":"A card"}}}` + mock := &mockAPIClient{resp: `{ + "code": 0, + "msg": "success", + "data": { + "items": [{ + "body": {"content": "` + escapeJSON(cardContent) + `"} + }] + } + }`} + out := runCardAction(t, payload, mock) + + if out.CardContent == "" { + t.Error("CardContent should not be empty when message get succeeds") + } +} + +func TestProcessCardAction_MessageGetErrorCode(t *testing.T) { + payload := `{ + "schema": "2.0", + "header": { + "event_id": "ev_mg_ec", + "event_type": "card.action.trigger", + "create_time": "1776409469279" + }, + "event": { + "operator": {"open_id": "ou_mg_user2"}, + "token": "c-token-mg2", + "host": "im_message", + "action": { + "tag": "button", + "value": {}, + "name": "btn", + "form_value": {}, + "options": [], + "checked": false + }, + "context": { + "open_message_id": "om_mg_002", + "open_chat_id": "oc_chat_mg2" + } + } + }` + mock := &mockAPIClient{resp: `{"code": 1, "msg": "error", "data": {"items": []}}`} + out := runCardAction(t, payload, mock) + + if out.CardContent != "" { + t.Errorf("CardContent should be empty when code != 0, got %q", out.CardContent) + } +} + +func TestProcessCardAction_MessageGetFailure(t *testing.T) { + payload := `{ + "schema": "2.0", + "header": { + "event_id": "ev_mg_fail", + "event_type": "card.action.trigger", + "create_time": "1776409469280" + }, + "event": { + "operator": {"open_id": "ou_mg_user3"}, + "token": "c-token-mg3", + "host": "im_message", + "action": { + "tag": "button", + "value": {}, + "name": "btn", + "form_value": {}, + "options": [], + "checked": false + }, + "context": { + "open_message_id": "om_mg_003", + "open_chat_id": "oc_chat_mg3" + } + } + }` + mock := &mockAPIClient{errResp: true} + out := runCardAction(t, payload, mock) + + if out.CardContent != "" { + t.Errorf("CardContent should be empty when message get fails, got %q", out.CardContent) + } +} + +func TestProcessCardAction_EmptyMessageID(t *testing.T) { + payload := `{ + "schema": "2.0", + "header": { + "event_id": "ev_no_msg", + "event_type": "card.action.trigger", + "create_time": "1776409469281" + }, + "event": { + "operator": {"open_id": "ou_no_msg"}, + "token": "c-token-nm", + "host": "im_message", + "action": { + "tag": "button", + "value": {}, + "name": "btn", + "form_value": {}, + "options": [], + "checked": false + }, + "context": { + "open_message_id": "", + "open_chat_id": "oc_chat_nm" + } + } + }` + out := runCardAction(t, payload, nil) + + if out.CardContent != "" { + t.Errorf("CardContent should be empty when message_id is absent, got %q", out.CardContent) + } +} + +type mockAPIClient struct { + resp string + errResp bool +} + +func (m *mockAPIClient) CallAPI(_ context.Context, _, _ string, _ interface{}) (json.RawMessage, error) { + if m.errResp { + return nil, context.DeadlineExceeded + } + return json.RawMessage(m.resp), nil +} + +func runCardAction(t *testing.T, payload string, rt event.APIClient) CardActionTriggerOutput { + t.Helper() + raw := &event.RawEvent{ + EventID: "ev_test", + EventType: "card.action.trigger", + Payload: json.RawMessage(payload), + Timestamp: time.Now(), + } + got, err := processCardAction(context.Background(), rt, raw, nil) + if err != nil { + t.Fatalf("Process error: %v", err) + } + var out CardActionTriggerOutput + if err := json.Unmarshal(got, &out); err != nil { + t.Fatalf("Process output is not valid CardActionTriggerOutput JSON: %v\nraw=%s", err, string(got)) + } + return out +} + +func escapeJSON(s string) string { + b, _ := json.Marshal(s) + return string(b[1 : len(b)-1]) +} diff --git a/events/im/register.go b/events/im/register.go index 8ad514ec4d..288de5a3ab 100644 --- a/events/im/register.go +++ b/events/im/register.go @@ -27,6 +27,21 @@ func Keys() []event.KeyDefinition { AuthTypes: []string{"bot"}, RequiredConsoleEvents: []string{"im.message.receive_v1"}, }, + { + Key: "card.action.trigger", + DisplayName: "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).", + EventType: "card.action.trigger", + SubscriptionType: event.SubTypeCallback, + Schema: event.SchemaDef{ + Custom: &event.SchemaSpec{Type: reflect.TypeOf(CardActionTriggerOutput{})}, + }, + Process: processCardAction, + Scopes: []string{"im:message:readonly"}, + AuthTypes: []string{"bot"}, + SingleConsumer: true, + RequiredConsoleEvents: []string{"card.action.trigger"}, + }, } for _, rk := range nativeIMKeys { diff --git a/skills/lark-event/SKILL.md b/skills/lark-event/SKILL.md index 434d5478e9..217b5055da 100644 --- a/skills/lark-event/SKILL.md +++ b/skills/lark-event/SKILL.md @@ -147,7 +147,7 @@ Lark-defined semantic tags (**not** JSON Schema's standard `format`). Common val | Topic | Reference | Coverage | |------------|------------------------------------------------------------------------------|---| -| IM | [`references/lark-event-im.md`](references/lark-event-im.md) | Catalog of 11 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) | +| IM | [`references/lark-event-im.md`](references/lark-event-im.md) | Catalog of 12 IM EventKeys + shape notes (flat vs V2 envelope) + `im.message.receive_v1` field gotchas (`sender_id` is open_id only; `.content` is plain text except for `interactive` cards) + common jq recipes (filter by chat_type / message_type / sender); for `card.action.trigger` see also [`../lark-im/references/lark-im-card-action-reply.md`](../lark-im/references/lark-im-card-action-reply.md) | | Task | [`references/lark-event-task.md`](references/lark-event-task.md) | Catalog of 1 Task EventKey (`task.task.update_user_access_v2`) + Native V2 envelope shape + task commit types + user/bot subscription notes | | VC | [`references/lark-event-vc.md`](references/lark-event-vc.md) | Catalog of 2 VC EventKeys (`vc.meeting.participant_meeting_ended_v1`, `vc.note.generated_v1`) + field reference + source type semantics (meeting only) | | 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) | diff --git a/skills/lark-event/references/lark-event-im.md b/skills/lark-event/references/lark-event-im.md index 113a87780f..4272c001ba 100644 --- a/skills/lark-event/references/lark-event-im.md +++ b/skills/lark-event/references/lark-event-im.md @@ -4,7 +4,7 @@ > > **Heads-up for AI agents**: this key's `.content` is **NOT** the raw OAPI payload shape your training data may suggest. `lark-cli` runs a Process hook (`convertlib`) that flattens the V2 envelope and **pre-renders** `.content` to human-readable text for `text` / `post` / `image` / `file` / `audio` / etc. Only `interactive` (cards) keeps the raw JSON string. Don't blindly `fromjson`. -## Key catalog (11) +## Key catalog (12) | EventKey | Purpose | |---|---| @@ -19,8 +19,9 @@ | `im.chat.member.user.added_v1` | User joined a chat (including topic chats) | | `im.chat.member.user.deleted_v1` | User left voluntarily **or** was removed | | `im.chat.member.user.withdrawn_v1` | Pending chat invite withdrawn (inviter canceled; user never actually joined) | +| `card.action.trigger` | Interactive card callback — button click, form submit, dropdown, etc. → see [`lark-im-card-action-reply.md`](../../lark-im/references/lark-im-card-action-reply.md) | -> **Shape**: `im.message.receive_v1` is the only flat key (fields at `.xxx`); the other 10 are V2-enveloped (fields at `.event.xxx`). +> **Shape**: All 12 events have a V2-enveloped raw payload. `lark-cli` flattens two of them — `im.message.receive_v1` and `card.action.trigger` — so their consumed output is flat (fields at `.xxx`). The other 10 are passed through as-is; use `.event.xxx` to access their fields. ## Gotchas (`im.message.receive_v1`) diff --git a/skills/lark-im/SKILL.md b/skills/lark-im/SKILL.md index 9a7950ac57..983c66d2d8 100644 --- a/skills/lark-im/SKILL.md +++ b/skills/lark-im/SKILL.md @@ -1,7 +1,7 @@ --- name: lark-im version: 1.0.0 -description: "飞书即时通讯:收发消息和管理群聊。发送和回复消息、搜索聊天记录、管理群聊成员、上传下载图片和文件(支持大文件分片下载)、管理表情回复、发送应用内/短信/电话加急。当用户需要发消息、查看或搜索聊天记录、下载聊天中的文件、查看群成员、搜索群、创建群聊或话题群、管理标记数据、管理 Feed 置顶(添加/移除/查询置顶会话)、管理标签数据时使用。" +description: "飞书即时通讯:收发消息和管理群聊。发送和回复消息、搜索聊天记录、管理群聊成员、上传下载图片和文件(支持大文件分片下载)、管理表情回复、发送应用内/短信/电话加急、发送和处理交互卡片(Interactive Card)、监听卡片按钮回调(card.action.trigger)。当用户需要发消息、查看或搜索聊天记录、下载聊天中的文件、查看群成员、搜索群、创建群聊或话题群、管理标记数据、管理 Feed 置顶(添加/移除/查询置顶会话)、管理标签数据、处理卡片回调时使用。" metadata: requires: bins: ["lark-cli"] @@ -61,6 +61,8 @@ The four message-pulling shortcuts (`+messages-mget`, `+chat-messages-list`, `+m Card messages (`interactive` type) are not yet supported for compact conversion in event subscriptions. The raw event data will be returned instead, with a hint printed to stderr. +`interactive` cards support callback events (`card.action.trigger`) — see [`references/lark-im-card-action-reply.md`](references/lark-im-card-action-reply.md). + ### Audio Messages `--audio` sends a voice message and supports only Opus audio files, for example `.opus` files or Ogg Opus (`.ogg`) files. For `mp3`, `wav`, or other non-Opus audio, either convert to `.opus` first and keep using `--audio`, or send the original file as an attachment with `--file`. diff --git a/skills/lark-im/references/lark-im-card-action-reply.md b/skills/lark-im/references/lark-im-card-action-reply.md new file mode 100644 index 0000000000..7995cdf0bd --- /dev/null +++ b/skills/lark-im/references/lark-im-card-action-reply.md @@ -0,0 +1,175 @@ +# card.action.trigger + +> **Prerequisite:** Read [`../../lark-event/SKILL.md`](../../lark-event/SKILL.md) first for `event consume` essentials. + +Fires when a user interacts with an interactive card — button click, form submit, dropdown select, +checkbox toggle, date/time pick, etc. + +## Setup (required) + +> **Console configuration required**: In the Feishu Developer Console, go to +> **App → Events & Callbacks → Callback Configuration** (应用--事件与回调--回调配置) and enable it. +> The consumer starts without errors even when not configured, but **no events will be received**. +> There is no preflight check for this setting. + +After enabling, events are delivered over the existing WebSocket long connection — no additional +URL configuration needed. + +## Scopes & auth + +| Scope | Required for | +|---|---| +| `im:message:readonly` | Auto-fetch `card_content` via message get API (covers both p2p and group messages) | + +Auth: `bot` only. + +## Output fields + +| Field | Type | Description | +|---|---|---| +| `type` | string | Always `card.action.trigger` | +| `event_id` | string | Unique event ID; safe for deduplication | +| `timestamp` | string (timestamp_ms) | Event delivery time (ms since epoch) | +| `operator_id` | string (open_id) | Open ID of the user who interacted | +| `message_id` | string (message_id) | Message ID of the card (`om_xxx`) | +| `chat_id` | string (chat_id) | Chat ID (`oc_xxx`) | +| `host` | string | `im_message` (chat card) or `im_top_notice` (top banner) | +| `token` | string | Delayed-update token; valid 30 min, max 2 uses | +| `action_tag` | string | Component type that was triggered (see decision table) | +| `action_value` | string | Developer-defined value on the component; serialized to JSON string | +| `action_name` | string | `name` attribute of the component | +| `timezone` | string | User timezone, e.g. `Asia/Shanghai`; only populated for date/time picker interactions | +| `form_value` | string (JSON) | All form field values as JSON string, keyed by component `name`; only present when a button inside a form container is clicked | +| `input_value` | string | Input text; only for standalone `input` components (not inside a form) | +| `option` | string | Selected value for standalone single-select: `select_static`, `select_person`, `overflow`, `date_picker`, `picker_time`, `picker_datetime` | +| `options` | string | Comma-separated selected values for standalone multi-select: `multi_select_static`, `multi_select_person` | +| `checked` | bool | Checkbox state for standalone `checker` elements | +| `card_content` | string | Original card content (userDSL text format) from when the card was sent; auto-fetched via message get API at consume time; empty if `message_id` absent or fetch fails — skip if empty | + +## `card_content` — what it is and how to use it + +`card_content` is the `user_dsl` field extracted from the card message content, auto-fetched +at event consume time. It represents the card's original definition — use it as the starting +point to understand the current card structure and construct the updated card JSON. + +No extra API call is needed — the consumer fetches it automatically. If empty, skip — no fallback required. + +## action_tag decision table + +> **Form container rule**: when a component is inside a `form` container, its value appears in +> `form_value[name]` instead of the standalone fields (`option`, `options`, `input_value`, +> `checked`). There is no `form_submit` tag — form submission comes through as `button` with +> `form_value` populated. + +| `action_tag` | Read field(s) | Notes | +|---|---|---| +| `button` | `action_value` (fromjson if object); `form_value` if inside a form | Most common; `form_value` non-empty = form submit | +| `overflow` | `option` | Collapsible button group selection | +| `select_static` | `option` (standalone) or `form_value[name]` (in form) | Single-select dropdown | +| `multi_select_static` | `options` (standalone) or `form_value[name]` (in form) | Multi-select dropdown | +| `select_person` | `option` — open_id of selected user | Single-select person | +| `multi_select_person` | `options` — comma-separated open_ids | Multi-select person | +| `input` | `input_value` (standalone) or `form_value[name]` (in form) | Text input | +| `checker` | `checked` (standalone) or `form_value[name]` (in form) | Checkbox | +| `date_picker` | `option` (date string) + `timezone` | e.g. `"2024-04-01 +0800"` | +| `picker_time` | `option` (time string) + `timezone` | e.g. `"08:30 +0800"` | +| `picker_datetime` | `option` (datetime string) + `timezone` | e.g. `"2024-04-29 07:07 +0800"` | +| `select_img` | `option` (single) or `options` (multi) | Image picker | + +## Key constraints + +1. Token **valid 30 minutes**, **max 2 uses** — if update fails after exhaustion, inform the user +2. Delayed-update API requires **complete new card JSON** — partial updates are not supported +3. SDK auto-responds `{"code":200}` within 3 s — your update call can be sent any time within 30 min +4. `card_content` is auto-populated — no extra API call needed; if empty, skip it + +## After starting the listener + +Once the listener is running, check whether your agent runtime supports background event +monitoring (i.e. can receive and process stdout lines from a running subprocess while +continuing to respond to the user). If it does, prompt the user: + +> "Card callback listener is now active. Do you want me to automatically handle card +> interactions and update the card based on user actions?" + +Only enter the auto-update workflow below if the user confirms. If your runtime does not +support background monitoring, inform the user that automatic card updates are not available +and they will need to handle interactions manually. + +## Agent workflow + +When a `card.action.trigger` event arrives (**each stdout JSON line is one event — process it immediately**): + +``` +1. Read action fields to understand what the user did: + - action_tag: which component was triggered + - action_value / option / options / checked / input_value / form_value: what value was set + +2. Decide: does this interaction require a card update? + - e.g. button click with a business action → yes + - e.g. navigation / pagination → no (just record, no update needed) + - Not every callback requires a card update — decide based on business semantics + - Before updating, explicitly state what visual change the action requires. If you cannot articulate one, skip the update. + +3. If update is needed: + a. If card_content is empty: inform the user that the original card could not be fetched, + so it is not possible to determine whether an update is needed — do not guess + b. Determine the new card state based on the action + c. Use card_content as the structural basis to construct the updated card JSON + d. Detect card version: if card_content contains `"schema":"2.0"` or `"schema": "2.0"` it is Card 2.0; otherwise assume Card 1.0 + e. For Card 1.0: include `"open_ids": [""]` inside the `card` object, or the API returns code 300090 + f. Call the delayed update API with the token and new card JSON + +4. If no update: end (the SDK has already acknowledged the callback) +``` + +## Updating the card + +```bash +lark-cli api POST /open-apis/interactive/v1/card/update --as bot \ + --data '{"token":"","card":}' +``` + +`--data` parameters: + +| Field | Required | Description | +|---|---|---| +| `token` | Yes | Delayed-update token from the event | +| `card` | Yes | Complete new card JSON — construct based on `card_content` from the event, modified to reflect the new state | +| `card.open_ids` | No | **Card 1.0 only.** Array of `open_id`s defining which users see the updated card. Must contain at least one open_id (e.g. the operator's); passing `[]` or omitting the key both cause "openid empty" (code 300090). | + +## Examples + +```bash +# Stream all card interactions +lark-cli event consume card.action.trigger --as bot + +# Grab one callback to inspect shape (debugging only — do not use in production workflows) +lark-cli event consume card.action.trigger --as bot --max-events 1 --timeout 60s + +# Button clicks only (not form submit), with action value +lark-cli event consume card.action.trigger --as bot \ + --jq 'select(.action_tag == "button" and .form_value == "") | {op: .operator_id, val: (.action_value | fromjson?), token: .token}' + +# Form submits (button with form_value present) +lark-cli event consume card.action.trigger --as bot \ + --jq 'select(.action_tag == "button" and .form_value != "") | {op: .operator_id, form: (.form_value | fromjson), token: .token}' + +# Date picker interactions +lark-cli event consume card.action.trigger --as bot \ + --jq 'select(.action_tag == "date_picker") | {op: .operator_id, date: .option, tz: .timezone}' + +# Filter to one chat +lark-cli event consume card.action.trigger --as bot \ + --jq 'select(.chat_id == "oc_xxx")' +``` + +## Gotchas + +- **No `form_submit` tag**: form submission comes as `action_tag = "button"` with `form_value` + populated. Check `form_value != ""` to distinguish from a standalone button click. +- **`action_value` type is developer-defined**: the original may be an object or a plain string. + Use `fromjson?` (with `?` to swallow errors) or check before parsing. +- **Standalone vs form fields**: `input_value`, `option`, `options`, `checked` are only populated + for components **not** inside a form container. Inside a form, all values appear in `form_value`. +- **WebSocket delivery**: no separate callback URL needed; uses the existing WS connection. diff --git a/skills/lark-im/references/lark-im-messages-send.md b/skills/lark-im/references/lark-im-messages-send.md index 007b729679..03aca1665d 100644 --- a/skills/lark-im/references/lark-im-messages-send.md +++ b/skills/lark-im/references/lark-im-messages-send.md @@ -215,6 +215,8 @@ lark-cli im +messages-send --chat-id oc_xxx --markdown $'## Test\n\nhello' --dry | `share_user` | `{"user_id":"ou_xxx"}` | | `interactive` | Card JSON (see Feishu interactive card documentation) | +`interactive` cards support callback events (`card.action.trigger`) — see [`lark-im-card-action-reply.md`](lark-im-card-action-reply.md). + ## Return Value ```json