feat: support card action trigger (#1528)

Add support for card.action.trigger, the event fired when a user interacts with an
interactive card (button click, form submit, dropdown, checkbox, input, date picker, etc.).
The handler flattens the V2 envelope into a structured output and auto-fetches the original
card content (card_content) at consume time, enabling a complete read-then-update workflow
without extra API calls.
This commit is contained in:
91-enjoy
2026-06-24 22:00:08 +08:00
committed by GitHub
parent b3514e5519
commit 3f9ace8af5
8 changed files with 763 additions and 4 deletions

132
events/im/card_action.go Normal file
View File

@@ -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)
}

View File

@@ -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])
}

View File

@@ -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 {

View File

@@ -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) |

View File

@@ -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`)

View File

@@ -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`.

View File

@@ -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": ["<operator_id>"]` 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":"<token>","card":<new_card_json>}'
```
`--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.

View File

@@ -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