mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
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:
132
events/im/card_action.go
Normal file
132
events/im/card_action.go
Normal 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)
|
||||
}
|
||||
432
events/im/card_action_test.go
Normal file
432
events/im/card_action_test.go
Normal 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])
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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`)
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
175
skills/lark-im/references/lark-im-card-action-reply.md
Normal file
175
skills/lark-im/references/lark-im-card-action-reply.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user