@openclaw/feishu 2026.3.13 → 2026.5.2-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/api.ts +31 -0
  2. package/channel-entry.ts +20 -0
  3. package/channel-plugin-api.ts +1 -0
  4. package/contract-api.ts +16 -0
  5. package/index.ts +70 -53
  6. package/openclaw.plugin.json +1827 -4
  7. package/package.json +32 -7
  8. package/runtime-api.ts +55 -0
  9. package/secret-contract-api.ts +5 -0
  10. package/security-contract-api.ts +1 -0
  11. package/session-key-api.ts +1 -0
  12. package/setup-api.ts +3 -0
  13. package/setup-entry.test.ts +14 -0
  14. package/setup-entry.ts +13 -0
  15. package/src/accounts.test.ts +95 -7
  16. package/src/accounts.ts +199 -117
  17. package/src/app-registration.ts +331 -0
  18. package/src/approval-auth.test.ts +24 -0
  19. package/src/approval-auth.ts +25 -0
  20. package/src/async.test.ts +35 -0
  21. package/src/async.ts +43 -1
  22. package/src/audio-preflight.runtime.ts +9 -0
  23. package/src/bitable.test.ts +131 -0
  24. package/src/bitable.ts +59 -22
  25. package/src/bot-content.ts +474 -0
  26. package/src/bot-group-name.test.ts +108 -0
  27. package/src/bot-runtime-api.ts +12 -0
  28. package/src/bot-sender-name.ts +125 -0
  29. package/src/bot.broadcast.test.ts +463 -0
  30. package/src/bot.card-action.test.ts +519 -5
  31. package/src/bot.checkBotMentioned.test.ts +92 -20
  32. package/src/bot.helpers.test.ts +118 -0
  33. package/src/bot.stripBotMention.test.ts +13 -21
  34. package/src/bot.test.ts +1334 -401
  35. package/src/bot.ts +778 -775
  36. package/src/card-action.ts +408 -40
  37. package/src/card-interaction.test.ts +129 -0
  38. package/src/card-interaction.ts +159 -0
  39. package/src/card-test-helpers.ts +47 -0
  40. package/src/card-ux-approval.ts +65 -0
  41. package/src/card-ux-launcher.test.ts +99 -0
  42. package/src/card-ux-launcher.ts +121 -0
  43. package/src/card-ux-shared.ts +33 -0
  44. package/src/channel-runtime-api.ts +16 -0
  45. package/src/channel.runtime.ts +47 -0
  46. package/src/channel.test.ts +914 -3
  47. package/src/channel.ts +1253 -309
  48. package/src/chat-schema.ts +5 -4
  49. package/src/chat.test.ts +135 -28
  50. package/src/chat.ts +68 -10
  51. package/src/client.test.ts +212 -103
  52. package/src/client.ts +115 -21
  53. package/src/comment-dispatcher-runtime-api.ts +6 -0
  54. package/src/comment-dispatcher.test.ts +169 -0
  55. package/src/comment-dispatcher.ts +107 -0
  56. package/src/comment-handler-runtime-api.ts +3 -0
  57. package/src/comment-handler.test.ts +486 -0
  58. package/src/comment-handler.ts +309 -0
  59. package/src/comment-reaction.test.ts +166 -0
  60. package/src/comment-reaction.ts +259 -0
  61. package/src/comment-shared.test.ts +182 -0
  62. package/src/comment-shared.ts +406 -0
  63. package/src/comment-target.ts +44 -0
  64. package/src/config-schema.test.ts +63 -1
  65. package/src/config-schema.ts +31 -4
  66. package/src/conversation-id.test.ts +18 -0
  67. package/src/conversation-id.ts +199 -0
  68. package/src/dedup-runtime-api.ts +1 -0
  69. package/src/dedup.ts +33 -95
  70. package/src/directory.static.ts +61 -0
  71. package/src/directory.test.ts +116 -20
  72. package/src/directory.ts +60 -92
  73. package/src/doc-schema.ts +1 -1
  74. package/src/docx-batch-insert.test.ts +39 -38
  75. package/src/docx-batch-insert.ts +55 -19
  76. package/src/docx-color-text.ts +9 -4
  77. package/src/docx-table-ops.test.ts +53 -0
  78. package/src/docx-table-ops.ts +52 -34
  79. package/src/docx-types.ts +38 -0
  80. package/src/docx.account-selection.test.ts +12 -3
  81. package/src/docx.test.ts +314 -74
  82. package/src/docx.ts +278 -122
  83. package/src/drive-schema.ts +47 -1
  84. package/src/drive.test.ts +1219 -0
  85. package/src/drive.ts +614 -13
  86. package/src/dynamic-agent.ts +10 -4
  87. package/src/event-types.ts +45 -0
  88. package/src/external-keys.ts +1 -1
  89. package/src/lifecycle.test-support.ts +220 -0
  90. package/src/media.test.ts +403 -26
  91. package/src/media.ts +509 -132
  92. package/src/mention-target.types.ts +5 -0
  93. package/src/mention.ts +32 -51
  94. package/src/message-action-contract.ts +13 -0
  95. package/src/monitor-state-runtime-api.ts +7 -0
  96. package/src/monitor-transport-runtime-api.ts +7 -0
  97. package/src/monitor.account.ts +218 -312
  98. package/src/monitor.acp-init-failure.lifecycle.test-support.ts +219 -0
  99. package/src/monitor.bot-identity.ts +86 -0
  100. package/src/monitor.bot-menu-handler.ts +165 -0
  101. package/src/monitor.bot-menu.lifecycle.test-support.ts +224 -0
  102. package/src/monitor.bot-menu.test.ts +178 -0
  103. package/src/monitor.broadcast.reply-once.lifecycle.test-support.ts +264 -0
  104. package/src/monitor.card-action.lifecycle.test-support.ts +373 -0
  105. package/src/monitor.cleanup.test.ts +376 -0
  106. package/src/monitor.comment-notice-handler.ts +105 -0
  107. package/src/monitor.comment.test.ts +937 -0
  108. package/src/monitor.comment.ts +1386 -0
  109. package/src/monitor.lifecycle.test.ts +4 -0
  110. package/src/monitor.message-handler.ts +339 -0
  111. package/src/monitor.reaction.lifecycle.test-support.ts +68 -0
  112. package/src/monitor.reaction.test.ts +108 -48
  113. package/src/monitor.startup.test.ts +11 -9
  114. package/src/monitor.startup.ts +26 -16
  115. package/src/monitor.state.ts +20 -5
  116. package/src/monitor.synthetic-error.ts +18 -0
  117. package/src/monitor.test-mocks.ts +2 -2
  118. package/src/monitor.transport.ts +220 -60
  119. package/src/monitor.ts +15 -10
  120. package/src/monitor.webhook-e2e.test.ts +65 -7
  121. package/src/monitor.webhook-security.test.ts +122 -0
  122. package/src/monitor.webhook.test-helpers.ts +44 -26
  123. package/src/outbound-runtime-api.ts +1 -0
  124. package/src/outbound.test.ts +616 -37
  125. package/src/outbound.ts +623 -81
  126. package/src/perm-schema.ts +1 -1
  127. package/src/perm.ts +1 -7
  128. package/src/pins.ts +108 -0
  129. package/src/policy.test.ts +297 -117
  130. package/src/policy.ts +142 -29
  131. package/src/post.ts +7 -6
  132. package/src/probe.test.ts +14 -9
  133. package/src/probe.ts +26 -16
  134. package/src/processing-claims.ts +59 -0
  135. package/src/qr-terminal.ts +1 -0
  136. package/src/reactions.ts +4 -34
  137. package/src/reasoning-preview.test.ts +59 -0
  138. package/src/reasoning-preview.ts +20 -0
  139. package/src/reply-dispatcher-runtime-api.ts +7 -0
  140. package/src/reply-dispatcher.test.ts +660 -29
  141. package/src/reply-dispatcher.ts +407 -154
  142. package/src/runtime.ts +6 -3
  143. package/src/secret-contract.ts +145 -0
  144. package/src/secret-input.ts +1 -13
  145. package/src/security-audit-shared.ts +69 -0
  146. package/src/security-audit.test.ts +61 -0
  147. package/src/security-audit.ts +1 -0
  148. package/src/send-result.ts +1 -1
  149. package/src/send-target.test.ts +9 -3
  150. package/src/send-target.ts +10 -4
  151. package/src/send.reply-fallback.test.ts +105 -2
  152. package/src/send.test.ts +386 -4
  153. package/src/send.ts +414 -95
  154. package/src/sequential-key.test.ts +72 -0
  155. package/src/sequential-key.ts +28 -0
  156. package/src/sequential-queue.test.ts +92 -0
  157. package/src/sequential-queue.ts +16 -0
  158. package/src/session-conversation.ts +42 -0
  159. package/src/session-route.ts +48 -0
  160. package/src/setup-core.ts +51 -0
  161. package/src/{onboarding.test.ts → setup-surface.test.ts} +52 -21
  162. package/src/setup-surface.ts +581 -0
  163. package/src/streaming-card.test.ts +138 -2
  164. package/src/streaming-card.ts +134 -18
  165. package/src/subagent-hooks.test.ts +603 -0
  166. package/src/subagent-hooks.ts +397 -0
  167. package/src/targets.ts +3 -13
  168. package/src/test-support/lifecycle-test-support.ts +453 -0
  169. package/src/thread-bindings.test.ts +143 -0
  170. package/src/thread-bindings.ts +330 -0
  171. package/src/tool-account-routing.test.ts +66 -8
  172. package/src/tool-account.test.ts +44 -0
  173. package/src/tool-account.ts +40 -17
  174. package/src/tool-factory-test-harness.ts +11 -8
  175. package/src/tool-result.ts +3 -1
  176. package/src/tools-config.ts +1 -1
  177. package/src/types.ts +16 -15
  178. package/src/typing.ts +10 -6
  179. package/src/wiki-schema.ts +1 -1
  180. package/src/wiki.ts +1 -7
  181. package/subagent-hooks-api.ts +31 -0
  182. package/tsconfig.json +16 -0
  183. package/src/feishu-command-handler.ts +0 -59
  184. package/src/onboarding.status.test.ts +0 -25
  185. package/src/onboarding.ts +0 -489
  186. package/src/send-message.ts +0 -71
  187. package/src/targets.test.ts +0 -70
@@ -1,53 +1,116 @@
1
- import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk/feishu";
2
- import { resolveFeishuAccount } from "./accounts.js";
1
+ import type { ClawdbotConfig, RuntimeEnv } from "../runtime-api.js";
2
+ import { resolveFeishuRuntimeAccount } from "./accounts.js";
3
3
  import { handleFeishuMessage, type FeishuMessageEvent } from "./bot.js";
4
+ import { decodeFeishuCardAction, buildFeishuCardActionTextFallback } from "./card-interaction.js";
5
+ import {
6
+ createApprovalCard,
7
+ FEISHU_APPROVAL_CANCEL_ACTION,
8
+ FEISHU_APPROVAL_CONFIRM_ACTION,
9
+ FEISHU_APPROVAL_REQUEST_ACTION,
10
+ } from "./card-ux-approval.js";
11
+ import { createFeishuClient } from "./client.js";
12
+ import { sendCardFeishu, sendMessageFeishu } from "./send.js";
4
13
 
5
14
  export type FeishuCardActionEvent = {
6
15
  operator: {
7
16
  open_id: string;
8
- user_id: string;
9
- union_id: string;
17
+ user_id?: string;
18
+ union_id?: string;
10
19
  };
11
20
  token: string;
12
21
  action: {
13
22
  value: Record<string, unknown>;
14
23
  tag: string;
15
24
  };
25
+ open_message_id?: string;
16
26
  context: {
17
- open_id: string;
18
- user_id: string;
19
- chat_id: string;
27
+ open_message_id?: string;
28
+ open_id?: string;
29
+ user_id?: string;
30
+ chat_id?: string;
20
31
  };
21
32
  };
22
33
 
23
- export async function handleFeishuCardAction(params: {
24
- cfg: ClawdbotConfig;
25
- event: FeishuCardActionEvent;
26
- botOpenId?: string;
27
- runtime?: RuntimeEnv;
28
- accountId?: string;
29
- }): Promise<void> {
30
- const { cfg, event, runtime, accountId } = params;
31
- const account = resolveFeishuAccount({ cfg, accountId });
32
- const log = runtime?.log ?? console.log;
34
+ const FEISHU_APPROVAL_CARD_TTL_MS = 5 * 60_000;
35
+ const FEISHU_CARD_ACTION_TOKEN_TTL_MS = 15 * 60_000;
36
+ const processedCardActionTokens = new Map<
37
+ string,
38
+ { status: "inflight" | "completed"; expiresAt: number }
39
+ >();
33
40
 
34
- // Extract action value
35
- const actionValue = event.action.value;
36
- let content = "";
37
- if (typeof actionValue === "object" && actionValue !== null) {
38
- if ("text" in actionValue && typeof actionValue.text === "string") {
39
- content = actionValue.text;
40
- } else if ("command" in actionValue && typeof actionValue.command === "string") {
41
- content = actionValue.command;
42
- } else {
43
- content = JSON.stringify(actionValue);
41
+ export class FeishuRetryableCardActionError extends Error {
42
+ constructor(message: string, options?: ErrorOptions) {
43
+ super(message, options);
44
+ this.name = "FeishuRetryableCardActionError";
45
+ }
46
+ }
47
+
48
+ export function resetProcessedFeishuCardActionTokensForTests(): void {
49
+ processedCardActionTokens.clear();
50
+ }
51
+
52
+ function pruneProcessedCardActionTokens(now: number): void {
53
+ for (const [key, entry] of processedCardActionTokens.entries()) {
54
+ if (entry.expiresAt <= now) {
55
+ processedCardActionTokens.delete(key);
44
56
  }
45
- } else {
46
- content = String(actionValue);
47
57
  }
58
+ }
59
+
60
+ function beginFeishuCardActionToken(params: {
61
+ token: string;
62
+ accountId: string;
63
+ now?: number;
64
+ }): boolean {
65
+ const now = params.now ?? Date.now();
66
+ pruneProcessedCardActionTokens(now);
67
+ const normalizedToken = params.token.trim();
68
+ if (!normalizedToken) {
69
+ return false;
70
+ }
71
+ const key = `${params.accountId}:${normalizedToken}`;
72
+ const existing = processedCardActionTokens.get(key);
73
+ if (existing && existing.expiresAt > now) {
74
+ return false;
75
+ }
76
+ processedCardActionTokens.set(key, {
77
+ status: "inflight",
78
+ expiresAt: now + FEISHU_CARD_ACTION_TOKEN_TTL_MS,
79
+ });
80
+ return true;
81
+ }
82
+
83
+ function completeFeishuCardActionToken(params: {
84
+ token: string;
85
+ accountId: string;
86
+ now?: number;
87
+ }): void {
88
+ const now = params.now ?? Date.now();
89
+ const normalizedToken = params.token.trim();
90
+ if (!normalizedToken) {
91
+ return;
92
+ }
93
+ processedCardActionTokens.set(`${params.accountId}:${normalizedToken}`, {
94
+ status: "completed",
95
+ expiresAt: now + FEISHU_CARD_ACTION_TOKEN_TTL_MS,
96
+ });
97
+ }
48
98
 
49
- // Construct a synthetic message event
50
- const messageEvent: FeishuMessageEvent = {
99
+ function releaseFeishuCardActionToken(params: { token: string; accountId: string }): void {
100
+ const normalizedToken = params.token.trim();
101
+ if (!normalizedToken) {
102
+ return;
103
+ }
104
+ processedCardActionTokens.delete(`${params.accountId}:${normalizedToken}`);
105
+ }
106
+
107
+ function buildSyntheticMessageEvent(
108
+ event: FeishuCardActionEvent,
109
+ content: string,
110
+ chatType: "p2p" | "group",
111
+ ): FeishuMessageEvent {
112
+ const replyTargetMessageId = event.context.open_message_id ?? event.open_message_id;
113
+ return {
51
114
  sender: {
52
115
  sender_id: {
53
116
  open_id: event.operator.open_id,
@@ -57,23 +120,328 @@ export async function handleFeishuCardAction(params: {
57
120
  },
58
121
  message: {
59
122
  message_id: `card-action-${event.token}`,
123
+ ...(replyTargetMessageId ? { reply_target_message_id: replyTargetMessageId } : {}),
124
+ ...(!replyTargetMessageId ? { suppress_reply_target: true } : {}),
60
125
  chat_id: event.context.chat_id || event.operator.open_id,
61
- chat_type: event.context.chat_id ? "group" : "p2p",
126
+ chat_type: chatType,
62
127
  message_type: "text",
63
128
  content: JSON.stringify({ text: content }),
64
129
  },
65
130
  };
131
+ }
66
132
 
67
- log(
68
- `feishu[${account.accountId}]: handling card action from ${event.operator.open_id}: ${content}`,
69
- );
133
+ function resolveCallbackTarget(event: FeishuCardActionEvent): string {
134
+ const chatId = event.context.chat_id?.trim();
135
+ if (chatId) {
136
+ return `chat:${chatId}`;
137
+ }
138
+ return `user:${event.operator.open_id}`;
139
+ }
70
140
 
71
- // Dispatch as normal message
141
+ async function dispatchSyntheticCommand(params: {
142
+ cfg: ClawdbotConfig;
143
+ event: FeishuCardActionEvent;
144
+ command: string;
145
+ account: ReturnType<typeof resolveFeishuRuntimeAccount>;
146
+ botOpenId?: string;
147
+ runtime?: RuntimeEnv;
148
+ accountId?: string;
149
+ chatType?: "p2p" | "group";
150
+ }): Promise<void> {
151
+ const resolvedChatType = await resolveCardActionChatType({
152
+ event: params.event,
153
+ account: params.account,
154
+ chatType: params.chatType,
155
+ log: params.runtime?.log ?? console.log,
156
+ });
72
157
  await handleFeishuMessage({
73
- cfg,
74
- event: messageEvent,
158
+ cfg: params.cfg,
159
+ event: buildSyntheticMessageEvent(params.event, params.command, resolvedChatType),
75
160
  botOpenId: params.botOpenId,
76
- runtime,
77
- accountId,
161
+ runtime: params.runtime,
162
+ accountId: params.accountId,
78
163
  });
79
164
  }
165
+
166
+ // Feishu's im.chat.get returns two fields:
167
+ // chat_mode: conversation type — "p2p" | "group" | "topic"
168
+ // chat_type: privacy classification — "private" | "public"
169
+ // We check chat_mode first because it directly indicates conversation type.
170
+ // "private" maps to "p2p" as the safe-failure direction (restrictive DM
171
+ // policy) — a private group chat misclassified as p2p is safer than the
172
+ // reverse. "topic" and "public" are treated as group semantics.
173
+ function normalizeResolvedCardActionChatType(value: unknown): "p2p" | "group" | undefined {
174
+ if (value === "group" || value === "topic" || value === "public") {
175
+ return "group";
176
+ }
177
+ if (value === "p2p" || value === "private") {
178
+ return "p2p";
179
+ }
180
+ return undefined;
181
+ }
182
+
183
+ const resolvedChatTypeCache = new Map<string, { value: "p2p" | "group"; expiresAt: number }>();
184
+ const CHAT_TYPE_CACHE_TTL_MS = 30 * 60_000;
185
+ const CHAT_TYPE_CACHE_MAX_SIZE = 5_000;
186
+
187
+ function pruneChatTypeCache(now: number): void {
188
+ for (const [key, entry] of resolvedChatTypeCache.entries()) {
189
+ if (entry.expiresAt <= now) {
190
+ resolvedChatTypeCache.delete(key);
191
+ }
192
+ }
193
+ if (resolvedChatTypeCache.size > CHAT_TYPE_CACHE_MAX_SIZE) {
194
+ const excess = resolvedChatTypeCache.size - CHAT_TYPE_CACHE_MAX_SIZE;
195
+ const iter = resolvedChatTypeCache.keys();
196
+ for (let i = 0; i < excess; i++) {
197
+ const key = iter.next().value;
198
+ if (key !== undefined) {
199
+ resolvedChatTypeCache.delete(key);
200
+ }
201
+ }
202
+ }
203
+ }
204
+
205
+ function sanitizeLogValue(v: string): string {
206
+ return v.replace(/[\r\n]/g, " ").slice(0, 500);
207
+ }
208
+
209
+ async function resolveCardActionChatType(params: {
210
+ event: FeishuCardActionEvent;
211
+ account: ReturnType<typeof resolveFeishuRuntimeAccount>;
212
+ chatType?: "p2p" | "group";
213
+ log: (message: string) => void;
214
+ }): Promise<"p2p" | "group"> {
215
+ const explicitChatType = normalizeResolvedCardActionChatType(params.chatType);
216
+ if (explicitChatType) {
217
+ return explicitChatType;
218
+ }
219
+
220
+ const chatId = params.event.context.chat_id?.trim();
221
+ if (!chatId) {
222
+ return "p2p";
223
+ }
224
+
225
+ const cacheKey = `${params.account.accountId}:${chatId}`;
226
+ const now = Date.now();
227
+ pruneChatTypeCache(now);
228
+ const cached = resolvedChatTypeCache.get(cacheKey);
229
+ if (cached) {
230
+ return cached.value;
231
+ }
232
+
233
+ try {
234
+ const response = (await createFeishuClient(params.account).im.chat.get({
235
+ path: { chat_id: chatId },
236
+ })) as { code?: number; msg?: string; data?: { chat_type?: unknown; chat_mode?: unknown } };
237
+ if (response.code === 0) {
238
+ const resolvedChatType =
239
+ normalizeResolvedCardActionChatType(response.data?.chat_mode) ??
240
+ normalizeResolvedCardActionChatType(response.data?.chat_type);
241
+ if (resolvedChatType) {
242
+ resolvedChatTypeCache.set(cacheKey, {
243
+ value: resolvedChatType,
244
+ expiresAt: now + CHAT_TYPE_CACHE_TTL_MS,
245
+ });
246
+ return resolvedChatType;
247
+ }
248
+ params.log(
249
+ `feishu[${params.account.accountId}]: card action missing chat type for chat; defaulting to p2p`,
250
+ );
251
+ } else {
252
+ params.log(
253
+ `feishu[${params.account.accountId}]: failed to resolve chat type: ${sanitizeLogValue(response.msg ?? "unknown error")}; defaulting to p2p`,
254
+ );
255
+ }
256
+ } catch (err) {
257
+ const message = err instanceof Error ? err.message : "unknown";
258
+ params.log(
259
+ `feishu[${params.account.accountId}]: failed to resolve chat type: ${sanitizeLogValue(message)}; defaulting to p2p`,
260
+ );
261
+ }
262
+
263
+ return "p2p";
264
+ }
265
+
266
+ async function sendInvalidInteractionNotice(params: {
267
+ cfg: ClawdbotConfig;
268
+ event: FeishuCardActionEvent;
269
+ reason: "malformed" | "stale" | "wrong_user" | "wrong_conversation";
270
+ accountId?: string;
271
+ }): Promise<void> {
272
+ const reasonText =
273
+ params.reason === "stale"
274
+ ? "This card action has expired. Open a fresh launcher card and try again."
275
+ : params.reason === "wrong_user"
276
+ ? "This card action belongs to a different user."
277
+ : params.reason === "wrong_conversation"
278
+ ? "This card action belongs to a different conversation."
279
+ : "This card action payload is invalid.";
280
+
281
+ await sendMessageFeishu({
282
+ cfg: params.cfg,
283
+ to: resolveCallbackTarget(params.event),
284
+ text: `⚠️ ${reasonText}`,
285
+ accountId: params.accountId,
286
+ });
287
+ }
288
+
289
+ export async function handleFeishuCardAction(params: {
290
+ cfg: ClawdbotConfig;
291
+ event: FeishuCardActionEvent;
292
+ botOpenId?: string;
293
+ runtime?: RuntimeEnv;
294
+ accountId?: string;
295
+ }): Promise<void> {
296
+ const { cfg, event, runtime, accountId } = params;
297
+ const account = resolveFeishuRuntimeAccount({ cfg, accountId });
298
+ const log = runtime?.log ?? console.log;
299
+ if (!event.token.trim()) {
300
+ log(
301
+ `feishu[${account.accountId}]: rejected card action from ${event.operator.open_id}: missing token`,
302
+ );
303
+ return;
304
+ }
305
+ const decoded = decodeFeishuCardAction({ event });
306
+ const claimedToken = beginFeishuCardActionToken({
307
+ token: event.token,
308
+ accountId: account.accountId,
309
+ });
310
+ if (!claimedToken) {
311
+ log(`feishu[${account.accountId}]: skipping duplicate card action token ${event.token}`);
312
+ return;
313
+ }
314
+
315
+ try {
316
+ if (decoded.kind === "invalid") {
317
+ log(
318
+ `feishu[${account.accountId}]: rejected card action from ${event.operator.open_id}: ${decoded.reason}`,
319
+ );
320
+ await sendInvalidInteractionNotice({
321
+ cfg,
322
+ event,
323
+ reason: decoded.reason,
324
+ accountId,
325
+ });
326
+ completeFeishuCardActionToken({ token: event.token, accountId: account.accountId });
327
+ return;
328
+ }
329
+
330
+ if (decoded.kind === "structured") {
331
+ const { envelope } = decoded;
332
+ log(
333
+ `feishu[${account.accountId}]: handling structured card action ${envelope.a} from ${event.operator.open_id}`,
334
+ );
335
+
336
+ if (envelope.a === FEISHU_APPROVAL_REQUEST_ACTION) {
337
+ const command = typeof envelope.m?.command === "string" ? envelope.m.command.trim() : "";
338
+ if (!command) {
339
+ await sendInvalidInteractionNotice({
340
+ cfg,
341
+ event,
342
+ reason: "malformed",
343
+ accountId,
344
+ });
345
+ completeFeishuCardActionToken({ token: event.token, accountId: account.accountId });
346
+ return;
347
+ }
348
+ const prompt =
349
+ typeof envelope.m?.prompt === "string" && envelope.m.prompt.trim()
350
+ ? envelope.m.prompt
351
+ : `Run \`${command}\` in this Feishu conversation?`;
352
+ await sendCardFeishu({
353
+ cfg,
354
+ to: resolveCallbackTarget(event),
355
+ card: createApprovalCard({
356
+ operatorOpenId: event.operator.open_id,
357
+ chatId: event.context.chat_id || undefined,
358
+ command,
359
+ prompt,
360
+ sessionKey: envelope.c?.s,
361
+ expiresAt: Date.now() + FEISHU_APPROVAL_CARD_TTL_MS,
362
+ chatType: await resolveCardActionChatType({
363
+ event,
364
+ account,
365
+ chatType: envelope.c?.t,
366
+ log,
367
+ }),
368
+ confirmLabel: command === "/reset" ? "Reset" : "Confirm",
369
+ }),
370
+ accountId,
371
+ });
372
+ completeFeishuCardActionToken({ token: event.token, accountId: account.accountId });
373
+ return;
374
+ }
375
+
376
+ if (envelope.a === FEISHU_APPROVAL_CANCEL_ACTION) {
377
+ await sendMessageFeishu({
378
+ cfg,
379
+ to: resolveCallbackTarget(event),
380
+ text: "Cancelled.",
381
+ accountId,
382
+ });
383
+ completeFeishuCardActionToken({ token: event.token, accountId: account.accountId });
384
+ return;
385
+ }
386
+
387
+ if (envelope.a === FEISHU_APPROVAL_CONFIRM_ACTION || envelope.k === "quick") {
388
+ const command = envelope.q?.trim();
389
+ if (!command) {
390
+ await sendInvalidInteractionNotice({
391
+ cfg,
392
+ event,
393
+ reason: "malformed",
394
+ accountId,
395
+ });
396
+ completeFeishuCardActionToken({ token: event.token, accountId: account.accountId });
397
+ return;
398
+ }
399
+ await dispatchSyntheticCommand({
400
+ cfg,
401
+ event,
402
+ command,
403
+ account,
404
+ botOpenId: params.botOpenId,
405
+ runtime,
406
+ accountId,
407
+ chatType: envelope.c?.t,
408
+ });
409
+ completeFeishuCardActionToken({ token: event.token, accountId: account.accountId });
410
+ return;
411
+ }
412
+
413
+ await sendInvalidInteractionNotice({
414
+ cfg,
415
+ event,
416
+ reason: "malformed",
417
+ accountId,
418
+ });
419
+ completeFeishuCardActionToken({ token: event.token, accountId: account.accountId });
420
+ return;
421
+ }
422
+
423
+ const content = buildFeishuCardActionTextFallback(event);
424
+
425
+ log(
426
+ `feishu[${account.accountId}]: handling card action from ${event.operator.open_id}: ${content}`,
427
+ );
428
+
429
+ await dispatchSyntheticCommand({
430
+ cfg,
431
+ event,
432
+ command: content,
433
+ account,
434
+ botOpenId: params.botOpenId,
435
+ runtime,
436
+ accountId,
437
+ });
438
+ completeFeishuCardActionToken({ token: event.token, accountId: account.accountId });
439
+ } catch (err) {
440
+ if (err instanceof FeishuRetryableCardActionError) {
441
+ releaseFeishuCardActionToken({ token: event.token, accountId: account.accountId });
442
+ } else {
443
+ completeFeishuCardActionToken({ token: event.token, accountId: account.accountId });
444
+ }
445
+ throw err;
446
+ }
447
+ }
@@ -0,0 +1,129 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ buildFeishuCardActionTextFallback,
4
+ createFeishuCardInteractionEnvelope,
5
+ decodeFeishuCardAction,
6
+ } from "./card-interaction.js";
7
+
8
+ describe("feishu card interaction decoder", () => {
9
+ it("decodes valid structured payloads", () => {
10
+ const result = decodeFeishuCardAction({
11
+ now: 1_700_000_000_000,
12
+ event: {
13
+ operator: { open_id: "u123" },
14
+ context: { chat_id: "chat1" },
15
+ action: {
16
+ value: createFeishuCardInteractionEnvelope({
17
+ k: "quick",
18
+ a: "feishu.quick_actions.help",
19
+ q: "/help",
20
+ c: { u: "u123", h: "chat1", t: "group", e: 1_700_000_060_000 },
21
+ }),
22
+ },
23
+ },
24
+ });
25
+
26
+ expect(result).toEqual(
27
+ expect.objectContaining({
28
+ kind: "structured",
29
+ envelope: expect.objectContaining({
30
+ q: "/help",
31
+ }),
32
+ }),
33
+ );
34
+ });
35
+
36
+ it("falls back for legacy text-like payloads", () => {
37
+ const result = decodeFeishuCardAction({
38
+ event: {
39
+ operator: { open_id: "u123" },
40
+ context: { chat_id: "chat1" },
41
+ action: { value: { text: "/ping" } },
42
+ },
43
+ });
44
+
45
+ expect(result).toEqual({ kind: "legacy", text: "/ping" });
46
+ expect(
47
+ buildFeishuCardActionTextFallback({
48
+ operator: { open_id: "u123" },
49
+ context: { chat_id: "chat1" },
50
+ action: { value: { command: "/new" } },
51
+ }),
52
+ ).toBe("/new");
53
+ });
54
+
55
+ it("rejects malformed structured payloads", () => {
56
+ const result = decodeFeishuCardAction({
57
+ event: {
58
+ operator: { open_id: "u123" },
59
+ context: { chat_id: "chat1" },
60
+ action: {
61
+ value: {
62
+ oc: "ocf1",
63
+ k: "quick",
64
+ a: "broken",
65
+ m: { bad: { nested: true } },
66
+ },
67
+ },
68
+ },
69
+ });
70
+
71
+ expect(result).toEqual({ kind: "invalid", reason: "malformed" });
72
+ });
73
+
74
+ it("rejects stale payloads", () => {
75
+ const result = decodeFeishuCardAction({
76
+ now: 100,
77
+ event: {
78
+ operator: { open_id: "u123" },
79
+ context: { chat_id: "chat1" },
80
+ action: {
81
+ value: createFeishuCardInteractionEnvelope({
82
+ k: "button",
83
+ a: "stale",
84
+ c: { e: 99, t: "group" },
85
+ }),
86
+ },
87
+ },
88
+ });
89
+
90
+ expect(result).toEqual({ kind: "invalid", reason: "stale" });
91
+ });
92
+
93
+ it("rejects wrong-conversation payloads when chat context is enforced", () => {
94
+ const result = decodeFeishuCardAction({
95
+ event: {
96
+ operator: { open_id: "u123" },
97
+ context: { chat_id: "chat2" },
98
+ action: {
99
+ value: createFeishuCardInteractionEnvelope({
100
+ k: "button",
101
+ a: "scoped",
102
+ c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 },
103
+ }),
104
+ },
105
+ },
106
+ });
107
+
108
+ expect(result).toEqual({ kind: "invalid", reason: "wrong_conversation" });
109
+ });
110
+
111
+ it("rejects malformed chat-type context", () => {
112
+ const result = decodeFeishuCardAction({
113
+ event: {
114
+ operator: { open_id: "u123" },
115
+ context: { chat_id: "chat1" },
116
+ action: {
117
+ value: {
118
+ oc: "ocf1",
119
+ k: "button",
120
+ a: "bad",
121
+ c: { t: "private" },
122
+ },
123
+ },
124
+ },
125
+ });
126
+
127
+ expect(result).toEqual({ kind: "invalid", reason: "malformed" });
128
+ });
129
+ });