@openclaw/feishu 2026.3.12 → 2026.5.1-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 (188) 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 +1653 -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 +115 -22
  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 +798 -786
  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 +1252 -309
  48. package/src/chat-schema.ts +5 -4
  49. package/src/chat.test.ts +84 -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 +365 -0
  63. package/src/comment-target.ts +44 -0
  64. package/src/config-schema.test.ts +77 -25
  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 +76 -35
  70. package/src/directory.static.ts +61 -0
  71. package/src/directory.test.ts +119 -20
  72. package/src/directory.ts +61 -91
  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 +413 -87
  91. package/src/media.ts +488 -154
  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 +220 -313
  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 +194 -92
  113. package/src/monitor.reply-once.lifecycle.test-support.ts +190 -0
  114. package/src/monitor.startup.test.ts +24 -36
  115. package/src/monitor.startup.ts +26 -16
  116. package/src/monitor.state.ts +20 -5
  117. package/src/monitor.synthetic-error.ts +18 -0
  118. package/src/monitor.test-mocks.ts +2 -2
  119. package/src/monitor.transport.ts +297 -39
  120. package/src/monitor.ts +15 -10
  121. package/src/monitor.webhook-e2e.test.ts +272 -0
  122. package/src/monitor.webhook-security.test.ts +125 -91
  123. package/src/monitor.webhook.test-helpers.ts +116 -0
  124. package/src/outbound-runtime-api.ts +1 -0
  125. package/src/outbound.test.ts +627 -53
  126. package/src/outbound.ts +623 -81
  127. package/src/perm-schema.ts +1 -1
  128. package/src/perm.ts +1 -7
  129. package/src/pins.ts +108 -0
  130. package/src/policy.test.ts +297 -117
  131. package/src/policy.ts +142 -29
  132. package/src/post.ts +7 -6
  133. package/src/probe.test.ts +122 -118
  134. package/src/probe.ts +26 -16
  135. package/src/processing-claims.ts +59 -0
  136. package/src/qr-terminal.ts +1 -0
  137. package/src/reactions.ts +23 -60
  138. package/src/reasoning-preview.test.ts +59 -0
  139. package/src/reasoning-preview.ts +20 -0
  140. package/src/reply-dispatcher-runtime-api.ts +7 -0
  141. package/src/reply-dispatcher.test.ts +721 -168
  142. package/src/reply-dispatcher.ts +422 -172
  143. package/src/runtime.ts +6 -3
  144. package/src/secret-contract.ts +145 -0
  145. package/src/secret-input.ts +1 -13
  146. package/src/security-audit-shared.ts +69 -0
  147. package/src/security-audit.test.ts +61 -0
  148. package/src/security-audit.ts +1 -0
  149. package/src/send-result.ts +1 -1
  150. package/src/send-target.test.ts +9 -3
  151. package/src/send-target.ts +10 -4
  152. package/src/send.reply-fallback.test.ts +127 -42
  153. package/src/send.test.ts +386 -4
  154. package/src/send.ts +486 -164
  155. package/src/sequential-key.test.ts +72 -0
  156. package/src/sequential-key.ts +28 -0
  157. package/src/sequential-queue.test.ts +92 -0
  158. package/src/sequential-queue.ts +16 -0
  159. package/src/session-conversation.ts +42 -0
  160. package/src/session-route.ts +48 -0
  161. package/src/setup-core.ts +51 -0
  162. package/src/{onboarding.test.ts → setup-surface.test.ts} +52 -21
  163. package/src/setup-surface.ts +581 -0
  164. package/src/streaming-card.test.ts +138 -2
  165. package/src/streaming-card.ts +134 -18
  166. package/src/subagent-hooks.test.ts +603 -0
  167. package/src/subagent-hooks.ts +397 -0
  168. package/src/targets.ts +3 -13
  169. package/src/test-support/lifecycle-test-support.ts +479 -0
  170. package/src/thread-bindings.test.ts +143 -0
  171. package/src/thread-bindings.ts +330 -0
  172. package/src/tool-account-routing.test.ts +66 -8
  173. package/src/tool-account.test.ts +44 -0
  174. package/src/tool-account.ts +40 -17
  175. package/src/tool-factory-test-harness.ts +11 -8
  176. package/src/tool-result.ts +3 -1
  177. package/src/tools-config.ts +1 -1
  178. package/src/types.ts +16 -15
  179. package/src/typing.ts +10 -6
  180. package/src/wiki-schema.ts +1 -1
  181. package/src/wiki.ts +1 -7
  182. package/subagent-hooks-api.ts +31 -0
  183. package/tsconfig.json +16 -0
  184. package/src/feishu-command-handler.ts +0 -59
  185. package/src/onboarding.status.test.ts +0 -25
  186. package/src/onboarding.ts +0 -489
  187. package/src/send-message.ts +0 -71
  188. package/src/targets.test.ts +0 -70
package/src/send.ts CHANGED
@@ -1,21 +1,43 @@
1
- import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
2
- import { resolveFeishuAccount } from "./accounts.js";
1
+ import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
2
+ import {
3
+ convertMarkdownTables,
4
+ normalizeLowercaseStringOrEmpty,
5
+ normalizeOptionalLowercaseString,
6
+ } from "openclaw/plugin-sdk/text-runtime";
7
+ import type { ClawdbotConfig } from "../runtime-api.js";
8
+ import { resolveFeishuRuntimeAccount } from "./accounts.js";
3
9
  import { createFeishuClient } from "./client.js";
4
- import type { MentionTarget } from "./mention.js";
5
- import { buildMentionedMessage, buildMentionedCardContent } from "./mention.js";
10
+ import type { MentionTarget } from "./mention-target.types.js";
11
+ import { buildMentionedCardContent, buildMentionedMessage } from "./mention.js";
6
12
  import { parsePostContent } from "./post.js";
7
- import { getFeishuRuntime } from "./runtime.js";
8
13
  import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js";
9
14
  import { resolveFeishuSendTarget } from "./send-target.js";
10
15
  import type { FeishuChatType, FeishuMessageInfo, FeishuSendResult } from "./types.js";
11
16
 
12
17
  const WITHDRAWN_REPLY_ERROR_CODES = new Set([230011, 231003]);
18
+ const INTERACTIVE_CARD_FALLBACK_TEXT = "[Interactive Card]";
19
+ const POST_FALLBACK_TEXT = "[Rich text message]";
20
+ const FEISHU_CARD_TEMPLATES = new Set([
21
+ "blue",
22
+ "green",
23
+ "red",
24
+ "orange",
25
+ "purple",
26
+ "indigo",
27
+ "wathet",
28
+ "turquoise",
29
+ "yellow",
30
+ "grey",
31
+ "carmine",
32
+ "violet",
33
+ "lime",
34
+ ]);
13
35
 
14
36
  function shouldFallbackFromReplyTarget(response: { code?: number; msg?: string }): boolean {
15
37
  if (response.code !== undefined && WITHDRAWN_REPLY_ERROR_CODES.has(response.code)) {
16
38
  return true;
17
39
  }
18
- const msg = response.msg?.toLowerCase() ?? "";
40
+ const msg = normalizeLowercaseStringOrEmpty(response.msg);
19
41
  return msg.includes("withdrawn") || msg.includes("not found");
20
42
  }
21
43
 
@@ -40,9 +62,17 @@ function isWithdrawnReplyError(err: unknown): boolean {
40
62
  return false;
41
63
  }
42
64
 
65
+ function isRecord(value: unknown): value is Record<string, unknown> {
66
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
67
+ }
68
+
43
69
  type FeishuCreateMessageClient = {
44
70
  im: {
45
71
  message: {
72
+ reply: (opts: {
73
+ path: { message_id: string };
74
+ data: { content: string; msg_type: string; reply_in_thread?: true };
75
+ }) => Promise<{ code?: number; msg?: string; data?: { message_id?: string } }>;
46
76
  create: (opts: {
47
77
  params: { receive_id_type: "chat_id" | "email" | "open_id" | "union_id" | "user_id" };
48
78
  data: { receive_id: string; content: string; msg_type: string };
@@ -51,6 +81,31 @@ type FeishuCreateMessageClient = {
51
81
  };
52
82
  };
53
83
 
84
+ type FeishuMessageSender = {
85
+ id?: string;
86
+ id_type?: string;
87
+ sender_type?: string;
88
+ };
89
+
90
+ type FeishuMessageGetItem = {
91
+ message_id?: string;
92
+ chat_id?: string;
93
+ chat_type?: FeishuChatType;
94
+ thread_id?: string;
95
+ msg_type?: string;
96
+ body?: { content?: string };
97
+ sender?: FeishuMessageSender;
98
+ create_time?: string;
99
+ };
100
+
101
+ type FeishuGetMessageResponse = {
102
+ code?: number;
103
+ msg?: string;
104
+ data?: FeishuMessageGetItem & {
105
+ items?: FeishuMessageGetItem[];
106
+ };
107
+ };
108
+
54
109
  /** Send a direct message as a fallback when a reply target is unavailable. */
55
110
  async function sendFallbackDirect(
56
111
  client: FeishuCreateMessageClient,
@@ -74,38 +129,180 @@ async function sendFallbackDirect(
74
129
  return toFeishuSendResult(response, params.receiveId);
75
130
  }
76
131
 
77
- function parseInteractiveCardContent(parsed: unknown): string {
78
- if (!parsed || typeof parsed !== "object") {
79
- return "[Interactive Card]";
132
+ async function sendReplyOrFallbackDirect(
133
+ client: FeishuCreateMessageClient,
134
+ params: {
135
+ replyToMessageId?: string;
136
+ replyInThread?: boolean;
137
+ content: string;
138
+ msgType: string;
139
+ directParams: {
140
+ receiveId: string;
141
+ receiveIdType: "chat_id" | "email" | "open_id" | "union_id" | "user_id";
142
+ content: string;
143
+ msgType: string;
144
+ };
145
+ directErrorPrefix: string;
146
+ replyErrorPrefix: string;
147
+ },
148
+ ): Promise<FeishuSendResult> {
149
+ if (!params.replyToMessageId) {
150
+ return sendFallbackDirect(client, params.directParams, params.directErrorPrefix);
80
151
  }
81
152
 
82
- const candidate = parsed as { elements?: unknown };
83
- if (!Array.isArray(candidate.elements)) {
84
- return "[Interactive Card]";
153
+ const threadReplyFallbackError = params.replyInThread
154
+ ? new Error(
155
+ "Feishu thread reply failed: reply target is unavailable and cannot safely fall back to a top-level send.",
156
+ )
157
+ : null;
158
+
159
+ let response: { code?: number; msg?: string; data?: { message_id?: string } };
160
+ try {
161
+ response = await client.im.message.reply({
162
+ path: { message_id: params.replyToMessageId },
163
+ data: {
164
+ content: params.content,
165
+ msg_type: params.msgType,
166
+ ...(params.replyInThread ? { reply_in_thread: true } : {}),
167
+ },
168
+ });
169
+ } catch (err) {
170
+ if (!isWithdrawnReplyError(err)) {
171
+ throw err;
172
+ }
173
+ if (threadReplyFallbackError) {
174
+ throw threadReplyFallbackError;
175
+ }
176
+ return sendFallbackDirect(client, params.directParams, params.directErrorPrefix);
177
+ }
178
+ if (shouldFallbackFromReplyTarget(response)) {
179
+ if (threadReplyFallbackError) {
180
+ throw threadReplyFallbackError;
181
+ }
182
+ return sendFallbackDirect(client, params.directParams, params.directErrorPrefix);
85
183
  }
184
+ assertFeishuMessageApiSuccess(response, params.replyErrorPrefix);
185
+ return toFeishuSendResult(response, params.directParams.receiveId);
186
+ }
86
187
 
87
- const texts: string[] = [];
88
- for (const element of candidate.elements) {
89
- if (!element || typeof element !== "object") {
188
+ function normalizeCardTemplateVariable(value: unknown): string | undefined {
189
+ if (typeof value === "string") {
190
+ return value;
191
+ }
192
+ if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
193
+ return String(value);
194
+ }
195
+ return undefined;
196
+ }
197
+
198
+ function readCardTemplateVariables(parsed: Record<string, unknown>): Map<string, string> {
199
+ const variables = new Map<string, string>();
200
+ for (const source of [parsed.template_variable, parsed.template_variables]) {
201
+ if (!isRecord(source)) {
90
202
  continue;
91
203
  }
92
- const item = element as {
93
- tag?: string;
94
- content?: string;
95
- text?: { content?: string };
96
- };
97
- if (item.tag === "div" && typeof item.text?.content === "string") {
98
- texts.push(item.text.content);
204
+ for (const [key, value] of Object.entries(source)) {
205
+ const normalized = normalizeCardTemplateVariable(value);
206
+ if (normalized !== undefined) {
207
+ variables.set(key, normalized);
208
+ }
209
+ }
210
+ }
211
+ return variables;
212
+ }
213
+
214
+ function applyCardTemplateVariables(text: string, variables: Map<string, string>): string {
215
+ if (variables.size === 0) {
216
+ return text;
217
+ }
218
+ return text.replace(/\$\{([A-Za-z0-9_.-]+)\}|\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/g, (match, a, b) => {
219
+ const variableName = typeof a === "string" ? a : b;
220
+ return variables.get(variableName) ?? match;
221
+ });
222
+ }
223
+
224
+ function extractInteractiveElementText(
225
+ element: unknown,
226
+ variables: Map<string, string>,
227
+ ): string | undefined {
228
+ if (!isRecord(element)) {
229
+ return undefined;
230
+ }
231
+ const tag = typeof element.tag === "string" ? element.tag : "";
232
+ const text = isRecord(element.text) ? element.text : undefined;
233
+
234
+ if (tag === "div" && typeof text?.content === "string") {
235
+ return applyCardTemplateVariables(text.content, variables);
236
+ }
237
+ if ((tag === "markdown" || tag === "lark_md") && typeof element.content === "string") {
238
+ return applyCardTemplateVariables(element.content, variables);
239
+ }
240
+ if (tag === "plain_text" && typeof element.content === "string") {
241
+ return applyCardTemplateVariables(element.content, variables);
242
+ }
243
+ return undefined;
244
+ }
245
+
246
+ function extractInteractiveElementsText(
247
+ elements: unknown[],
248
+ variables: Map<string, string>,
249
+ ): string {
250
+ const texts: string[] = [];
251
+ for (const element of elements) {
252
+ const text = extractInteractiveElementText(element, variables);
253
+ if (text !== undefined) {
254
+ texts.push(text);
255
+ }
256
+ }
257
+ return texts.join("\n").trim();
258
+ }
259
+
260
+ function readInteractiveElementArrays(parsed: Record<string, unknown>): unknown[][] {
261
+ const body = isRecord(parsed.body) ? parsed.body : undefined;
262
+ const elementArrays: unknown[][] = [];
263
+
264
+ for (const candidate of [parsed.elements, body?.elements]) {
265
+ if (Array.isArray(candidate)) {
266
+ elementArrays.push(candidate);
267
+ }
268
+ }
269
+
270
+ for (const candidate of [parsed.i18n_elements, body?.i18n_elements]) {
271
+ if (!isRecord(candidate)) {
99
272
  continue;
100
273
  }
101
- if (item.tag === "markdown" && typeof item.content === "string") {
102
- texts.push(item.content);
274
+ for (const localeElements of Object.values(candidate)) {
275
+ if (Array.isArray(localeElements)) {
276
+ elementArrays.push(localeElements);
277
+ }
103
278
  }
104
279
  }
105
- return texts.join("\n").trim() || "[Interactive Card]";
280
+
281
+ return elementArrays;
282
+ }
283
+
284
+ function parseInteractivePostFallback(parsed: unknown): string | undefined {
285
+ const textContent = parsePostContent(JSON.stringify(parsed)).textContent.trim();
286
+ return textContent && textContent !== POST_FALLBACK_TEXT ? textContent : undefined;
106
287
  }
107
288
 
108
- function parseQuotedMessageContent(rawContent: string, msgType: string): string {
289
+ function parseInteractiveCardContent(parsed: unknown): string {
290
+ if (!isRecord(parsed)) {
291
+ return INTERACTIVE_CARD_FALLBACK_TEXT;
292
+ }
293
+
294
+ const variables = readCardTemplateVariables(parsed);
295
+ for (const elements of readInteractiveElementArrays(parsed)) {
296
+ const text = extractInteractiveElementsText(elements, variables);
297
+ if (text) {
298
+ return text;
299
+ }
300
+ }
301
+
302
+ return parseInteractivePostFallback(parsed) ?? INTERACTIVE_CARD_FALLBACK_TEXT;
303
+ }
304
+
305
+ function parseFeishuMessageContent(rawContent: string, msgType: string): string {
109
306
  if (!rawContent) {
110
307
  return "";
111
308
  }
@@ -146,6 +343,33 @@ function parseQuotedMessageContent(rawContent: string, msgType: string): string
146
343
  return `[${msgType || "unknown"} message]`;
147
344
  }
148
345
 
346
+ function parseFeishuMessageItem(
347
+ item: FeishuMessageGetItem,
348
+ fallbackMessageId?: string,
349
+ ): FeishuMessageInfo {
350
+ const msgType = item.msg_type ?? "text";
351
+ const rawContent = item.body?.content ?? "";
352
+
353
+ return {
354
+ messageId: item.message_id ?? fallbackMessageId ?? "",
355
+ chatId: item.chat_id ?? "",
356
+ chatType:
357
+ item.chat_type === "group" ||
358
+ item.chat_type === "topic_group" ||
359
+ item.chat_type === "private" ||
360
+ item.chat_type === "p2p"
361
+ ? item.chat_type
362
+ : undefined,
363
+ senderId: item.sender?.id,
364
+ senderOpenId: item.sender?.id_type === "open_id" ? item.sender?.id : undefined,
365
+ senderType: item.sender?.sender_type,
366
+ content: parseFeishuMessageContent(rawContent, msgType),
367
+ contentType: msgType,
368
+ createTime: item.create_time ? Number.parseInt(item.create_time, 10) : undefined,
369
+ threadId: item.thread_id || undefined,
370
+ };
371
+ }
372
+
149
373
  /**
150
374
  * Get a message by its ID.
151
375
  * Useful for fetching quoted/replied message content.
@@ -156,7 +380,7 @@ export async function getMessageFeishu(params: {
156
380
  accountId?: string;
157
381
  }): Promise<FeishuMessageInfo | null> {
158
382
  const { cfg, messageId, accountId } = params;
159
- const account = resolveFeishuAccount({ cfg, accountId });
383
+ const account = resolveFeishuRuntimeAccount({ cfg, accountId });
160
384
  if (!account.configured) {
161
385
  throw new Error(`Feishu account "${account.accountId}" not configured`);
162
386
  }
@@ -166,36 +390,7 @@ export async function getMessageFeishu(params: {
166
390
  try {
167
391
  const response = (await client.im.message.get({
168
392
  path: { message_id: messageId },
169
- })) as {
170
- code?: number;
171
- msg?: string;
172
- data?: {
173
- items?: Array<{
174
- message_id?: string;
175
- chat_id?: string;
176
- chat_type?: FeishuChatType;
177
- msg_type?: string;
178
- body?: { content?: string };
179
- sender?: {
180
- id?: string;
181
- id_type?: string;
182
- sender_type?: string;
183
- };
184
- create_time?: string;
185
- }>;
186
- message_id?: string;
187
- chat_id?: string;
188
- chat_type?: FeishuChatType;
189
- msg_type?: string;
190
- body?: { content?: string };
191
- sender?: {
192
- id?: string;
193
- id_type?: string;
194
- sender_type?: string;
195
- };
196
- create_time?: string;
197
- };
198
- };
393
+ })) as FeishuGetMessageResponse;
199
394
 
200
395
  if (response.code !== 0) {
201
396
  return null;
@@ -212,29 +407,104 @@ export async function getMessageFeishu(params: {
212
407
  return null;
213
408
  }
214
409
 
215
- const msgType = item.msg_type ?? "text";
216
- const rawContent = item.body?.content ?? "";
217
- const content = parseQuotedMessageContent(rawContent, msgType);
218
-
219
- return {
220
- messageId: item.message_id ?? messageId,
221
- chatId: item.chat_id ?? "",
222
- chatType:
223
- item.chat_type === "group" || item.chat_type === "private" || item.chat_type === "p2p"
224
- ? item.chat_type
225
- : undefined,
226
- senderId: item.sender?.id,
227
- senderOpenId: item.sender?.id_type === "open_id" ? item.sender?.id : undefined,
228
- senderType: item.sender?.sender_type,
229
- content,
230
- contentType: msgType,
231
- createTime: item.create_time ? parseInt(String(item.create_time), 10) : undefined,
232
- };
410
+ return parseFeishuMessageItem(item, messageId);
233
411
  } catch {
234
412
  return null;
235
413
  }
236
414
  }
237
415
 
416
+ export type FeishuThreadMessageInfo = {
417
+ messageId: string;
418
+ senderId?: string;
419
+ senderType?: string;
420
+ content: string;
421
+ contentType: string;
422
+ createTime?: number;
423
+ };
424
+
425
+ /**
426
+ * List messages in a Feishu thread (topic).
427
+ * Uses container_id_type=thread to directly query thread messages,
428
+ * which includes both the root message and all replies (including bot replies).
429
+ */
430
+ export async function listFeishuThreadMessages(params: {
431
+ cfg: ClawdbotConfig;
432
+ threadId: string;
433
+ currentMessageId?: string;
434
+ /** Exclude the root message (already provided separately as ThreadStarterBody). */
435
+ rootMessageId?: string;
436
+ limit?: number;
437
+ accountId?: string;
438
+ }): Promise<FeishuThreadMessageInfo[]> {
439
+ const { cfg, threadId, currentMessageId, rootMessageId, limit = 20, accountId } = params;
440
+ const account = resolveFeishuRuntimeAccount({ cfg, accountId });
441
+ if (!account.configured) {
442
+ throw new Error(`Feishu account "${account.accountId}" not configured`);
443
+ }
444
+
445
+ const client = createFeishuClient(account);
446
+
447
+ const response = (await client.im.message.list({
448
+ params: {
449
+ container_id_type: "thread",
450
+ container_id: threadId,
451
+ // Fetch newest messages first so long threads keep the most recent turns.
452
+ // Results are reversed below to restore chronological order.
453
+ sort_type: "ByCreateTimeDesc",
454
+ page_size: Math.min(limit + 1, 50),
455
+ },
456
+ })) as {
457
+ code?: number;
458
+ msg?: string;
459
+ data?: {
460
+ items?: Array<
461
+ {
462
+ message_id?: string;
463
+ root_id?: string;
464
+ parent_id?: string;
465
+ } & FeishuMessageGetItem
466
+ >;
467
+ };
468
+ };
469
+
470
+ if (response.code !== 0) {
471
+ throw new Error(
472
+ `Feishu thread list failed: code=${response.code} msg=${response.msg ?? "unknown"}`,
473
+ );
474
+ }
475
+
476
+ const items = response.data?.items ?? [];
477
+ const results: FeishuThreadMessageInfo[] = [];
478
+
479
+ for (const item of items) {
480
+ if (currentMessageId && item.message_id === currentMessageId) {
481
+ continue;
482
+ }
483
+ if (rootMessageId && item.message_id === rootMessageId) {
484
+ continue;
485
+ }
486
+
487
+ const parsed = parseFeishuMessageItem(item);
488
+
489
+ results.push({
490
+ messageId: parsed.messageId,
491
+ senderId: parsed.senderId,
492
+ senderType: parsed.senderType,
493
+ content: parsed.content,
494
+ contentType: parsed.contentType,
495
+ createTime: parsed.createTime,
496
+ });
497
+
498
+ if (results.length >= limit) {
499
+ break;
500
+ }
501
+ }
502
+
503
+ // Restore chronological order (oldest first) since we fetched newest-first.
504
+ results.reverse();
505
+ return results;
506
+ }
507
+
238
508
  export type SendFeishuMessageParams = {
239
509
  cfg: ClawdbotConfig;
240
510
  to: string;
@@ -248,7 +518,7 @@ export type SendFeishuMessageParams = {
248
518
  accountId?: string;
249
519
  };
250
520
 
251
- function buildFeishuPostMessagePayload(params: { messageText: string }): {
521
+ export function buildFeishuPostMessagePayload(params: { messageText: string }): {
252
522
  content: string;
253
523
  msgType: string;
254
524
  } {
@@ -275,7 +545,7 @@ export async function sendMessageFeishu(
275
545
  ): Promise<FeishuSendResult> {
276
546
  const { cfg, to, text, replyToMessageId, replyInThread, mentions, accountId } = params;
277
547
  const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({ cfg, to, accountId });
278
- const tableMode = getFeishuRuntime().channel.text.resolveMarkdownTableMode({
548
+ const tableMode = resolveMarkdownTableMode({
279
549
  cfg,
280
550
  channel: "feishu",
281
551
  });
@@ -285,37 +555,20 @@ export async function sendMessageFeishu(
285
555
  if (mentions && mentions.length > 0) {
286
556
  rawText = buildMentionedMessage(mentions, rawText);
287
557
  }
288
- const messageText = getFeishuRuntime().channel.text.convertMarkdownTables(rawText, tableMode);
558
+ const messageText = convertMarkdownTables(rawText, tableMode);
289
559
 
290
560
  const { content, msgType } = buildFeishuPostMessagePayload({ messageText });
291
561
 
292
562
  const directParams = { receiveId, receiveIdType, content, msgType };
293
-
294
- if (replyToMessageId) {
295
- let response: { code?: number; msg?: string; data?: { message_id?: string } };
296
- try {
297
- response = await client.im.message.reply({
298
- path: { message_id: replyToMessageId },
299
- data: {
300
- content,
301
- msg_type: msgType,
302
- ...(replyInThread ? { reply_in_thread: true } : {}),
303
- },
304
- });
305
- } catch (err) {
306
- if (!isWithdrawnReplyError(err)) {
307
- throw err;
308
- }
309
- return sendFallbackDirect(client, directParams, "Feishu send failed");
310
- }
311
- if (shouldFallbackFromReplyTarget(response)) {
312
- return sendFallbackDirect(client, directParams, "Feishu send failed");
313
- }
314
- assertFeishuMessageApiSuccess(response, "Feishu reply failed");
315
- return toFeishuSendResult(response, receiveId);
316
- }
317
-
318
- return sendFallbackDirect(client, directParams, "Feishu send failed");
563
+ return sendReplyOrFallbackDirect(client, {
564
+ replyToMessageId,
565
+ replyInThread,
566
+ content,
567
+ msgType,
568
+ directParams,
569
+ directErrorPrefix: "Feishu send failed",
570
+ replyErrorPrefix: "Feishu reply failed",
571
+ });
319
572
  }
320
573
 
321
574
  export type SendFeishuCardParams = {
@@ -334,32 +587,68 @@ export async function sendCardFeishu(params: SendFeishuCardParams): Promise<Feis
334
587
  const content = JSON.stringify(card);
335
588
 
336
589
  const directParams = { receiveId, receiveIdType, content, msgType: "interactive" };
590
+ return sendReplyOrFallbackDirect(client, {
591
+ replyToMessageId,
592
+ replyInThread,
593
+ content,
594
+ msgType: "interactive",
595
+ directParams,
596
+ directErrorPrefix: "Feishu card send failed",
597
+ replyErrorPrefix: "Feishu card reply failed",
598
+ });
599
+ }
337
600
 
338
- if (replyToMessageId) {
339
- let response: { code?: number; msg?: string; data?: { message_id?: string } };
340
- try {
341
- response = await client.im.message.reply({
342
- path: { message_id: replyToMessageId },
343
- data: {
344
- content,
345
- msg_type: "interactive",
346
- ...(replyInThread ? { reply_in_thread: true } : {}),
347
- },
348
- });
349
- } catch (err) {
350
- if (!isWithdrawnReplyError(err)) {
351
- throw err;
352
- }
353
- return sendFallbackDirect(client, directParams, "Feishu card send failed");
354
- }
355
- if (shouldFallbackFromReplyTarget(response)) {
356
- return sendFallbackDirect(client, directParams, "Feishu card send failed");
601
+ export async function editMessageFeishu(params: {
602
+ cfg: ClawdbotConfig;
603
+ messageId: string;
604
+ text?: string;
605
+ card?: Record<string, unknown>;
606
+ accountId?: string;
607
+ }): Promise<{ messageId: string; contentType: "post" | "interactive" }> {
608
+ const { cfg, messageId, text, card, accountId } = params;
609
+ const account = resolveFeishuRuntimeAccount({ cfg, accountId });
610
+ if (!account.configured) {
611
+ throw new Error(`Feishu account "${account.accountId}" not configured`);
612
+ }
613
+
614
+ const hasText = typeof text === "string" && text.trim().length > 0;
615
+ const hasCard = Boolean(card);
616
+ if (hasText === hasCard) {
617
+ throw new Error("Feishu edit requires exactly one of text or card.");
618
+ }
619
+
620
+ const client = createFeishuClient(account);
621
+
622
+ if (card) {
623
+ const content = JSON.stringify(card);
624
+ const response = await client.im.message.patch({
625
+ path: { message_id: messageId },
626
+ data: { content },
627
+ });
628
+
629
+ if (response.code !== 0) {
630
+ throw new Error(`Feishu message edit failed: ${response.msg || `code ${response.code}`}`);
357
631
  }
358
- assertFeishuMessageApiSuccess(response, "Feishu card reply failed");
359
- return toFeishuSendResult(response, receiveId);
632
+
633
+ return { messageId, contentType: "interactive" };
634
+ }
635
+
636
+ const tableMode = resolveMarkdownTableMode({
637
+ cfg,
638
+ channel: "feishu",
639
+ });
640
+ const messageText = convertMarkdownTables(text!, tableMode);
641
+ const payload = buildFeishuPostMessagePayload({ messageText });
642
+ const response = await client.im.message.patch({
643
+ path: { message_id: messageId },
644
+ data: { content: payload.content },
645
+ });
646
+
647
+ if (response.code !== 0) {
648
+ throw new Error(`Feishu message edit failed: ${response.msg || `code ${response.code}`}`);
360
649
  }
361
650
 
362
- return sendFallbackDirect(client, directParams, "Feishu card send failed");
651
+ return { messageId, contentType: "post" };
363
652
  }
364
653
 
365
654
  export async function updateCardFeishu(params: {
@@ -369,7 +658,7 @@ export async function updateCardFeishu(params: {
369
658
  accountId?: string;
370
659
  }): Promise<void> {
371
660
  const { cfg, messageId, card, accountId } = params;
372
- const account = resolveFeishuAccount({ cfg, accountId });
661
+ const account = resolveFeishuRuntimeAccount({ cfg, accountId });
373
662
  if (!account.configured) {
374
663
  throw new Error(`Feishu account "${account.accountId}" not configured`);
375
664
  }
@@ -396,7 +685,7 @@ export function buildMarkdownCard(text: string): Record<string, unknown> {
396
685
  return {
397
686
  schema: "2.0",
398
687
  config: {
399
- wide_screen_mode: true,
688
+ width_mode: "fill",
400
689
  },
401
690
  body: {
402
691
  elements: [
@@ -409,64 +698,97 @@ export function buildMarkdownCard(text: string): Record<string, unknown> {
409
698
  };
410
699
  }
411
700
 
701
+ /** Header configuration for structured Feishu cards. */
702
+ export type CardHeaderConfig = {
703
+ /** Header title text, e.g. "💻 Coder" */
704
+ title: string;
705
+ /** Feishu header color template (blue, green, red, orange, purple, grey, etc.). Defaults to "blue". */
706
+ template?: string;
707
+ };
708
+
709
+ export function resolveFeishuCardTemplate(template?: string): string | undefined {
710
+ const normalized = normalizeOptionalLowercaseString(template);
711
+ if (!normalized || !FEISHU_CARD_TEMPLATES.has(normalized)) {
712
+ return undefined;
713
+ }
714
+ return normalized;
715
+ }
716
+
412
717
  /**
413
- * Send a message as a markdown card (interactive message).
414
- * This renders markdown properly in Feishu (code blocks, tables, bold/italic, etc.)
718
+ * Build a Feishu interactive card with optional header and note footer.
719
+ * When header/note are omitted, behaves identically to buildMarkdownCard.
415
720
  */
416
- export async function sendMarkdownCardFeishu(params: {
721
+ export function buildStructuredCard(
722
+ text: string,
723
+ options?: {
724
+ header?: CardHeaderConfig;
725
+ note?: string;
726
+ },
727
+ ): Record<string, unknown> {
728
+ const elements: Record<string, unknown>[] = [{ tag: "markdown", content: text }];
729
+ if (options?.note) {
730
+ elements.push({ tag: "hr" });
731
+ elements.push({ tag: "markdown", content: `<font color='grey'>${options.note}</font>` });
732
+ }
733
+ const card: Record<string, unknown> = {
734
+ schema: "2.0",
735
+ config: { width_mode: "fill" },
736
+ body: { elements },
737
+ };
738
+ if (options?.header) {
739
+ card.header = {
740
+ title: { tag: "plain_text", content: options.header.title },
741
+ template: resolveFeishuCardTemplate(options.header.template) ?? "blue",
742
+ };
743
+ }
744
+ return card;
745
+ }
746
+
747
+ /**
748
+ * Send a message as a structured card with optional header and note.
749
+ */
750
+ export async function sendStructuredCardFeishu(params: {
417
751
  cfg: ClawdbotConfig;
418
752
  to: string;
419
753
  text: string;
420
754
  replyToMessageId?: string;
421
755
  /** When true, reply creates a Feishu topic thread instead of an inline reply */
422
756
  replyInThread?: boolean;
423
- /** Mention target users */
424
757
  mentions?: MentionTarget[];
425
758
  accountId?: string;
759
+ header?: CardHeaderConfig;
760
+ note?: string;
426
761
  }): Promise<FeishuSendResult> {
427
- const { cfg, to, text, replyToMessageId, replyInThread, mentions, accountId } = params;
762
+ const { cfg, to, text, replyToMessageId, replyInThread, mentions, accountId, header, note } =
763
+ params;
428
764
  let cardText = text;
429
765
  if (mentions && mentions.length > 0) {
430
766
  cardText = buildMentionedCardContent(mentions, text);
431
767
  }
432
- const card = buildMarkdownCard(cardText);
768
+ const card = buildStructuredCard(cardText, { header, note });
433
769
  return sendCardFeishu({ cfg, to, card, replyToMessageId, replyInThread, accountId });
434
770
  }
435
771
 
436
772
  /**
437
- * Edit an existing text message.
438
- * Note: Feishu only allows editing messages within 24 hours.
773
+ * Send a message as a markdown card (interactive message).
774
+ * This renders markdown properly in Feishu (code blocks, tables, bold/italic, etc.)
439
775
  */
440
- export async function editMessageFeishu(params: {
776
+ export async function sendMarkdownCardFeishu(params: {
441
777
  cfg: ClawdbotConfig;
442
- messageId: string;
778
+ to: string;
443
779
  text: string;
780
+ replyToMessageId?: string;
781
+ /** When true, reply creates a Feishu topic thread instead of an inline reply */
782
+ replyInThread?: boolean;
783
+ /** Mention target users */
784
+ mentions?: MentionTarget[];
444
785
  accountId?: string;
445
- }): Promise<void> {
446
- const { cfg, messageId, text, accountId } = params;
447
- const account = resolveFeishuAccount({ cfg, accountId });
448
- if (!account.configured) {
449
- throw new Error(`Feishu account "${account.accountId}" not configured`);
450
- }
451
-
452
- const client = createFeishuClient(account);
453
- const tableMode = getFeishuRuntime().channel.text.resolveMarkdownTableMode({
454
- cfg,
455
- channel: "feishu",
456
- });
457
- const messageText = getFeishuRuntime().channel.text.convertMarkdownTables(text ?? "", tableMode);
458
-
459
- const { content, msgType } = buildFeishuPostMessagePayload({ messageText });
460
-
461
- const response = await client.im.message.update({
462
- path: { message_id: messageId },
463
- data: {
464
- msg_type: msgType,
465
- content,
466
- },
467
- });
468
-
469
- if (response.code !== 0) {
470
- throw new Error(`Feishu message edit failed: ${response.msg || `code ${response.code}`}`);
786
+ }): Promise<FeishuSendResult> {
787
+ const { cfg, to, text, replyToMessageId, replyInThread, mentions, accountId } = params;
788
+ let cardText = text;
789
+ if (mentions && mentions.length > 0) {
790
+ cardText = buildMentionedCardContent(mentions, text);
471
791
  }
792
+ const card = buildMarkdownCard(cardText);
793
+ return sendCardFeishu({ cfg, to, card, replyToMessageId, replyInThread, accountId });
472
794
  }