@openclaw/feishu 2026.3.13 → 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 +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 +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 +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 +32 -94
  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 +375 -26
  91. package/src/media.ts +434 -88
  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.reply-once.lifecycle.test-support.ts +190 -0
  114. package/src/monitor.startup.test.ts +11 -9
  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 +220 -60
  120. package/src/monitor.ts +15 -10
  121. package/src/monitor.webhook-e2e.test.ts +65 -7
  122. package/src/monitor.webhook-security.test.ts +122 -0
  123. package/src/monitor.webhook.test-helpers.ts +44 -26
  124. package/src/outbound-runtime-api.ts +1 -0
  125. package/src/outbound.test.ts +616 -37
  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 +14 -9
  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 +4 -34
  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 +660 -29
  142. package/src/reply-dispatcher.ts +407 -154
  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 +77 -2
  153. package/src/send.test.ts +386 -4
  154. package/src/send.ts +399 -86
  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/outbound.ts CHANGED
@@ -1,36 +1,81 @@
1
- import fs from "fs";
2
- import path from "path";
3
- import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/feishu";
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import {
4
+ attachChannelToResult,
5
+ createAttachedChannelResultAdapter,
6
+ } from "openclaw/plugin-sdk/channel-send-result";
7
+ import {
8
+ interactiveReplyToPresentation,
9
+ normalizeInteractiveReply,
10
+ normalizeMessagePresentation,
11
+ renderMessagePresentationFallbackText,
12
+ resolveInteractiveTextFallback,
13
+ type MessagePresentationBlock,
14
+ type MessagePresentationButton,
15
+ } from "openclaw/plugin-sdk/interactive-runtime";
16
+ import {
17
+ resolvePayloadMediaUrls,
18
+ sendPayloadMediaSequenceAndFinalize,
19
+ sendTextMediaPayload,
20
+ } from "openclaw/plugin-sdk/reply-payload";
21
+ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
4
22
  import { resolveFeishuAccount } from "./accounts.js";
23
+ import { createFeishuCardInteractionEnvelope } from "./card-interaction.js";
24
+ import { createFeishuClient } from "./client.js";
25
+ import { cleanupAmbientCommentTypingReaction } from "./comment-reaction.js";
26
+ import { parseFeishuCommentTarget } from "./comment-target.js";
27
+ import { deliverCommentThreadText } from "./drive.js";
5
28
  import { sendMediaFeishu } from "./media.js";
6
- import { getFeishuRuntime } from "./runtime.js";
7
- import { sendMarkdownCardFeishu, sendMessageFeishu } from "./send.js";
29
+ import { chunkTextForOutbound, type ChannelOutboundAdapter } from "./outbound-runtime-api.js";
30
+ import {
31
+ resolveFeishuCardTemplate,
32
+ sendCardFeishu,
33
+ sendMarkdownCardFeishu,
34
+ sendMessageFeishu,
35
+ sendStructuredCardFeishu,
36
+ } from "./send.js";
37
+
38
+ const RENDERED_FEISHU_CARD = Symbol("openclaw.renderedFeishuCard");
8
39
 
9
40
  function normalizePossibleLocalImagePath(text: string | undefined): string | null {
10
41
  const raw = text?.trim();
11
- if (!raw) return null;
42
+ if (!raw) {
43
+ return null;
44
+ }
12
45
 
13
46
  // Only auto-convert when the message is a pure path-like payload.
14
47
  // Avoid converting regular sentences that merely contain a path.
15
48
  const hasWhitespace = /\s/.test(raw);
16
- if (hasWhitespace) return null;
49
+ if (hasWhitespace) {
50
+ return null;
51
+ }
17
52
 
18
53
  // Ignore links/data URLs; those should stay in normal mediaUrl/text paths.
19
- if (/^(https?:\/\/|data:|file:\/\/)/i.test(raw)) return null;
54
+ if (/^(https?:\/\/|data:|file:\/\/)/i.test(raw)) {
55
+ return null;
56
+ }
20
57
 
21
- const ext = path.extname(raw).toLowerCase();
58
+ const ext = normalizeLowercaseStringOrEmpty(path.extname(raw));
22
59
  const isImageExt = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".ico", ".tiff"].includes(
23
60
  ext,
24
61
  );
25
- if (!isImageExt) return null;
62
+ if (!isImageExt) {
63
+ return null;
64
+ }
26
65
 
27
- if (!path.isAbsolute(raw)) return null;
28
- if (!fs.existsSync(raw)) return null;
66
+ if (!path.isAbsolute(raw)) {
67
+ return null;
68
+ }
69
+ if (!fs.existsSync(raw)) {
70
+ return null;
71
+ }
29
72
 
30
73
  // Fix race condition: wrap statSync in try-catch to handle file deletion
31
74
  // between existsSync and statSync
32
75
  try {
33
- if (!fs.statSync(raw).isFile()) return null;
76
+ if (!fs.statSync(raw).isFile()) {
77
+ return null;
78
+ }
34
79
  } catch {
35
80
  // File may have been deleted or became inaccessible between checks
36
81
  return null;
@@ -43,6 +88,313 @@ function shouldUseCard(text: string): boolean {
43
88
  return /```[\s\S]*?```/.test(text) || /\|.+\|[\r\n]+\|[-:| ]+\|/.test(text);
44
89
  }
45
90
 
91
+ function isRecord(value: unknown): value is Record<string, unknown> {
92
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
93
+ }
94
+
95
+ function escapeFeishuCardMarkdownText(text: string): string {
96
+ return text.replace(/[&<>]/g, (char) => {
97
+ switch (char) {
98
+ case "&":
99
+ return "&amp;";
100
+ case "<":
101
+ return "&lt;";
102
+ case ">":
103
+ return "&gt;";
104
+ default:
105
+ return char;
106
+ }
107
+ });
108
+ }
109
+
110
+ function resolveSafeFeishuButtonUrl(url: string | undefined): string | undefined {
111
+ const trimmed = url?.trim();
112
+ if (!trimmed) {
113
+ return undefined;
114
+ }
115
+ try {
116
+ const parsed = new URL(trimmed);
117
+ return parsed.protocol === "https:" || parsed.protocol === "http:" ? trimmed : undefined;
118
+ } catch {
119
+ return undefined;
120
+ }
121
+ }
122
+
123
+ function markRenderedFeishuCard(card: Record<string, unknown>): Record<string, unknown> {
124
+ Object.defineProperty(card, RENDERED_FEISHU_CARD, {
125
+ value: true,
126
+ enumerable: false,
127
+ });
128
+ return card;
129
+ }
130
+
131
+ function sanitizeNativeFeishuCardButton(button: unknown): Record<string, unknown> | undefined {
132
+ if (!isRecord(button)) {
133
+ return undefined;
134
+ }
135
+ const text = isRecord(button.text) && typeof button.text.content === "string"
136
+ ? button.text.content
137
+ : undefined;
138
+ if (!text?.trim()) {
139
+ return undefined;
140
+ }
141
+ const style =
142
+ button.type === "danger" ? "danger" : button.type === "primary" ? "primary" : undefined;
143
+ const rendered: Record<string, unknown> = {
144
+ tag: "button",
145
+ text: { tag: "plain_text", content: text },
146
+ type: mapFeishuButtonType(style),
147
+ };
148
+ const safeUrl = resolveSafeFeishuButtonUrl(
149
+ typeof button.url === "string" ? button.url : undefined,
150
+ );
151
+ if (safeUrl) {
152
+ rendered.url = safeUrl;
153
+ }
154
+ if (isRecord(button.value) && button.value.oc === "ocf1") {
155
+ rendered.value = button.value;
156
+ }
157
+ return rendered.url || rendered.value ? rendered : undefined;
158
+ }
159
+
160
+ function sanitizeNativeFeishuCardElement(element: unknown): Record<string, unknown> | undefined {
161
+ if (!isRecord(element) || typeof element.tag !== "string") {
162
+ return undefined;
163
+ }
164
+ if (element.tag === "hr") {
165
+ return { tag: "hr" };
166
+ }
167
+ if (element.tag === "markdown" && typeof element.content === "string") {
168
+ return { tag: "markdown", content: escapeFeishuCardMarkdownText(element.content) };
169
+ }
170
+ if (element.tag === "action" && Array.isArray(element.actions)) {
171
+ const actions = element.actions
172
+ .map((action) => sanitizeNativeFeishuCardButton(action))
173
+ .filter((action): action is Record<string, unknown> => Boolean(action));
174
+ return actions.length > 0 ? { tag: "action", actions } : undefined;
175
+ }
176
+ return undefined;
177
+ }
178
+
179
+ function sanitizeNativeFeishuCard(card: Record<string, unknown>): Record<string, unknown> | undefined {
180
+ const body = isRecord(card.body) ? card.body : undefined;
181
+ const rawElements = Array.isArray(body?.elements) ? body.elements : [];
182
+ const elements = rawElements
183
+ .map((element) => sanitizeNativeFeishuCardElement(element))
184
+ .filter((element): element is Record<string, unknown> => Boolean(element));
185
+ if (elements.length === 0) {
186
+ return undefined;
187
+ }
188
+
189
+ const header = isRecord(card.header) ? card.header : undefined;
190
+ const title = isRecord(header?.title) && typeof header.title.content === "string"
191
+ ? header.title.content
192
+ : undefined;
193
+ return markRenderedFeishuCard({
194
+ schema: "2.0",
195
+ config: { width_mode: "fill" },
196
+ ...(title?.trim()
197
+ ? {
198
+ header: {
199
+ title: { tag: "plain_text", content: title },
200
+ template:
201
+ resolveFeishuCardTemplate(
202
+ typeof header?.template === "string" ? header.template : undefined,
203
+ ) ?? "blue",
204
+ },
205
+ }
206
+ : {}),
207
+ body: { elements },
208
+ });
209
+ }
210
+
211
+ function readNativeFeishuCard(payload: { channelData?: Record<string, unknown> }) {
212
+ const feishuData = payload.channelData?.feishu;
213
+ if (!isRecord(feishuData)) {
214
+ return undefined;
215
+ }
216
+ const card = feishuData.card ?? feishuData.interactiveCard;
217
+ if (!isRecord(card)) {
218
+ return undefined;
219
+ }
220
+ if ((card as { [RENDERED_FEISHU_CARD]?: true })[RENDERED_FEISHU_CARD] === true) {
221
+ return card;
222
+ }
223
+ return sanitizeNativeFeishuCard(card);
224
+ }
225
+
226
+ function mapFeishuButtonType(style: MessagePresentationButton["style"]) {
227
+ if (style === "primary" || style === "success") {
228
+ return "primary";
229
+ }
230
+ if (style === "danger") {
231
+ return "danger";
232
+ }
233
+ return "default";
234
+ }
235
+
236
+ function buildFeishuPayloadButton(
237
+ button: MessagePresentationButton,
238
+ ): Record<string, unknown> | undefined {
239
+ const rendered: Record<string, unknown> = {
240
+ tag: "button",
241
+ text: {
242
+ tag: "plain_text",
243
+ content: button.label,
244
+ },
245
+ type: mapFeishuButtonType(button.style),
246
+ };
247
+ if (button.url) {
248
+ const safeUrl = resolveSafeFeishuButtonUrl(button.url);
249
+ if (safeUrl) {
250
+ rendered.url = safeUrl;
251
+ }
252
+ }
253
+ if (button.value) {
254
+ rendered.value = createFeishuCardInteractionEnvelope({
255
+ k: "quick",
256
+ a: "feishu.payload.button",
257
+ q: button.value,
258
+ });
259
+ }
260
+ return rendered.url || rendered.value ? rendered : undefined;
261
+ }
262
+
263
+ function buildFeishuCardElementForBlock(
264
+ block: MessagePresentationBlock,
265
+ ): Record<string, unknown> | undefined {
266
+ if (block.type === "text") {
267
+ return { tag: "markdown", content: escapeFeishuCardMarkdownText(block.text) };
268
+ }
269
+ if (block.type === "context") {
270
+ return {
271
+ tag: "markdown",
272
+ content: `<font color='grey'>${escapeFeishuCardMarkdownText(block.text)}</font>`,
273
+ };
274
+ }
275
+ if (block.type === "divider") {
276
+ return { tag: "hr" };
277
+ }
278
+ if (block.type === "buttons") {
279
+ const actions = block.buttons
280
+ .map((button) => buildFeishuPayloadButton(button))
281
+ .filter((button): button is Record<string, unknown> => Boolean(button));
282
+ if (actions.length === 0) {
283
+ return undefined;
284
+ }
285
+ return {
286
+ tag: "action",
287
+ actions,
288
+ };
289
+ }
290
+ const labels = block.options.map((option) => `- ${option.label}`).join("\n");
291
+ return {
292
+ tag: "markdown",
293
+ content: `${escapeFeishuCardMarkdownText(
294
+ block.placeholder?.trim() || "Options",
295
+ )}:\n${escapeFeishuCardMarkdownText(labels)}`,
296
+ };
297
+ }
298
+
299
+ function buildFeishuPayloadCard(params: {
300
+ payload: Parameters<NonNullable<ChannelOutboundAdapter["sendPayload"]>>[0]["payload"];
301
+ text?: string;
302
+ identity?: Parameters<NonNullable<ChannelOutboundAdapter["sendPayload"]>>[0]["identity"];
303
+ }): Record<string, unknown> | undefined {
304
+ const nativeCard = readNativeFeishuCard(params.payload);
305
+ if (nativeCard) {
306
+ return nativeCard;
307
+ }
308
+
309
+ const interactive = normalizeInteractiveReply(params.payload.interactive);
310
+ const presentation =
311
+ normalizeMessagePresentation(params.payload.presentation) ??
312
+ (interactive ? interactiveReplyToPresentation(interactive) : undefined);
313
+ if (!presentation && !interactive) {
314
+ return undefined;
315
+ }
316
+
317
+ const text = resolveInteractiveTextFallback({
318
+ text: params.text ?? params.payload.text,
319
+ interactive,
320
+ });
321
+ const elements: Record<string, unknown>[] = [];
322
+ if (text?.trim()) {
323
+ elements.push({ tag: "markdown", content: escapeFeishuCardMarkdownText(text) });
324
+ }
325
+ for (const block of presentation?.blocks ?? []) {
326
+ const element = buildFeishuCardElementForBlock(block);
327
+ if (element) {
328
+ elements.push(element);
329
+ }
330
+ }
331
+ if (elements.length === 0) {
332
+ elements.push({
333
+ tag: "markdown",
334
+ content: renderMessagePresentationFallbackText({ text, presentation }),
335
+ });
336
+ }
337
+
338
+ const identityTitle = params.identity
339
+ ? params.identity.emoji
340
+ ? `${params.identity.emoji} ${params.identity.name ?? ""}`.trim()
341
+ : (params.identity.name ?? "")
342
+ : "";
343
+ const title = presentation?.title ?? identityTitle;
344
+ const template = resolveFeishuCardTemplate(
345
+ presentation?.tone === "danger"
346
+ ? "red"
347
+ : presentation?.tone === "warning"
348
+ ? "orange"
349
+ : presentation?.tone === "success"
350
+ ? "green"
351
+ : "blue",
352
+ );
353
+
354
+ return markRenderedFeishuCard({
355
+ schema: "2.0",
356
+ config: { width_mode: "fill" },
357
+ ...(title
358
+ ? {
359
+ header: {
360
+ title: { tag: "plain_text", content: title },
361
+ template: template ?? "blue",
362
+ },
363
+ }
364
+ : {}),
365
+ body: { elements },
366
+ });
367
+ }
368
+
369
+ function renderFeishuPresentationPayload({
370
+ payload,
371
+ presentation,
372
+ ctx,
373
+ }: Parameters<NonNullable<ChannelOutboundAdapter["renderPresentation"]>>[0]) {
374
+ const card = buildFeishuPayloadCard({
375
+ payload,
376
+ text: payload.text,
377
+ identity: ctx.identity,
378
+ });
379
+ if (!card) {
380
+ return null;
381
+ }
382
+ const existingFeishuData = isRecord(payload.channelData?.feishu)
383
+ ? payload.channelData.feishu
384
+ : undefined;
385
+ return {
386
+ ...payload,
387
+ text: renderMessagePresentationFallbackText({ text: payload.text, presentation }),
388
+ channelData: {
389
+ ...payload.channelData,
390
+ feishu: {
391
+ ...existingFeishuData,
392
+ card,
393
+ },
394
+ },
395
+ };
396
+ }
397
+
46
398
  function resolveReplyToMessageId(params: {
47
399
  replyToId?: string | null;
48
400
  threadId?: string | number | null;
@@ -58,6 +410,49 @@ function resolveReplyToMessageId(params: {
58
410
  return trimmed || undefined;
59
411
  }
60
412
 
413
+ async function sendCommentThreadReply(params: {
414
+ cfg: Parameters<typeof sendMessageFeishu>[0]["cfg"];
415
+ to: string;
416
+ text: string;
417
+ replyId?: string;
418
+ accountId?: string;
419
+ }) {
420
+ const target = parseFeishuCommentTarget(params.to);
421
+ if (!target) {
422
+ return null;
423
+ }
424
+ const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
425
+ const client = createFeishuClient(account);
426
+ const replyId = params.replyId?.trim();
427
+ try {
428
+ const result = await deliverCommentThreadText(client, {
429
+ file_token: target.fileToken,
430
+ file_type: target.fileType,
431
+ comment_id: target.commentId,
432
+ content: params.text,
433
+ });
434
+ return {
435
+ messageId:
436
+ (typeof result.reply_id === "string" && result.reply_id) ||
437
+ (typeof result.comment_id === "string" && result.comment_id) ||
438
+ "",
439
+ chatId: target.commentId,
440
+ result,
441
+ };
442
+ } finally {
443
+ if (replyId) {
444
+ void cleanupAmbientCommentTypingReaction({
445
+ client,
446
+ deliveryContext: {
447
+ channel: "feishu",
448
+ to: params.to,
449
+ threadId: replyId,
450
+ },
451
+ });
452
+ }
453
+ }
454
+ }
455
+
61
456
  async function sendOutboundText(params: {
62
457
  cfg: Parameters<typeof sendMessageFeishu>[0]["cfg"];
63
458
  to: string;
@@ -66,6 +461,17 @@ async function sendOutboundText(params: {
66
461
  accountId?: string;
67
462
  }) {
68
463
  const { cfg, to, text, accountId, replyToMessageId } = params;
464
+ const commentResult = await sendCommentThreadReply({
465
+ cfg,
466
+ to,
467
+ text,
468
+ replyId: replyToMessageId,
469
+ accountId,
470
+ });
471
+ if (commentResult) {
472
+ return commentResult;
473
+ }
474
+
69
475
  const account = resolveFeishuAccount({ cfg, accountId });
70
476
  const renderMode = account.config?.renderMode ?? "auto";
71
477
 
@@ -78,99 +484,235 @@ async function sendOutboundText(params: {
78
484
 
79
485
  export const feishuOutbound: ChannelOutboundAdapter = {
80
486
  deliveryMode: "direct",
81
- chunker: (text, limit) => getFeishuRuntime().channel.text.chunkMarkdownText(text, limit),
487
+ chunker: chunkTextForOutbound,
82
488
  chunkerMode: "markdown",
83
489
  textChunkLimit: 4000,
84
- sendText: async ({ cfg, to, text, accountId, replyToId, threadId, mediaLocalRoots }) => {
85
- const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId });
86
- // Scheme A compatibility shim:
87
- // when upstream accidentally returns a local image path as plain text,
88
- // auto-upload and send as Feishu image message instead of leaking path text.
89
- const localImagePath = normalizePossibleLocalImagePath(text);
90
- if (localImagePath) {
91
- try {
92
- const result = await sendMediaFeishu({
490
+ presentationCapabilities: {
491
+ supported: true,
492
+ buttons: true,
493
+ selects: false,
494
+ context: true,
495
+ divider: true,
496
+ },
497
+ renderPresentation: renderFeishuPresentationPayload,
498
+ sendPayload: async (ctx) => {
499
+ const card = buildFeishuPayloadCard({
500
+ payload: ctx.payload,
501
+ text: ctx.text,
502
+ identity: ctx.identity,
503
+ });
504
+ if (!card) {
505
+ return await sendTextMediaPayload({
506
+ channel: "feishu",
507
+ ctx,
508
+ adapter: feishuOutbound,
509
+ });
510
+ }
511
+
512
+ const replyToMessageId = resolveReplyToMessageId({
513
+ replyToId: ctx.replyToId,
514
+ threadId: ctx.threadId,
515
+ });
516
+ const commentTarget = parseFeishuCommentTarget(ctx.to);
517
+ if (commentTarget) {
518
+ return await sendTextMediaPayload({
519
+ channel: "feishu",
520
+ ctx: {
521
+ ...ctx,
522
+ payload: {
523
+ ...ctx.payload,
524
+ text: renderMessagePresentationFallbackText({
525
+ text: ctx.payload.text,
526
+ presentation:
527
+ normalizeMessagePresentation(ctx.payload.presentation) ??
528
+ (() => {
529
+ const interactive = normalizeInteractiveReply(ctx.payload.interactive);
530
+ return interactive ? interactiveReplyToPresentation(interactive) : undefined;
531
+ })(),
532
+ }),
533
+ interactive: undefined,
534
+ presentation: undefined,
535
+ channelData: undefined,
536
+ },
537
+ },
538
+ adapter: feishuOutbound,
539
+ });
540
+ }
541
+
542
+ const mediaUrls = resolvePayloadMediaUrls(ctx.payload)
543
+ .map((entry) => entry.trim())
544
+ .filter(Boolean);
545
+ return attachChannelToResult(
546
+ "feishu",
547
+ await sendPayloadMediaSequenceAndFinalize({
548
+ text: ctx.payload.text ?? "",
549
+ mediaUrls,
550
+ send: async ({ mediaUrl }) =>
551
+ await sendMediaFeishu({
552
+ cfg: ctx.cfg,
553
+ to: ctx.to,
554
+ mediaUrl,
555
+ accountId: ctx.accountId ?? undefined,
556
+ mediaLocalRoots: ctx.mediaLocalRoots,
557
+ replyToMessageId,
558
+ ...(ctx.payload.audioAsVoice === true || ctx.audioAsVoice === true
559
+ ? { audioAsVoice: true }
560
+ : {}),
561
+ }),
562
+ finalize: async () =>
563
+ await sendCardFeishu({
564
+ cfg: ctx.cfg,
565
+ to: ctx.to,
566
+ card,
567
+ replyToMessageId,
568
+ replyInThread: ctx.threadId != null && !ctx.replyToId,
569
+ accountId: ctx.accountId ?? undefined,
570
+ }),
571
+ }),
572
+ );
573
+ },
574
+ ...createAttachedChannelResultAdapter({
575
+ channel: "feishu",
576
+ sendText: async ({
577
+ cfg,
578
+ to,
579
+ text,
580
+ accountId,
581
+ replyToId,
582
+ threadId,
583
+ mediaLocalRoots,
584
+ identity,
585
+ }) => {
586
+ const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId });
587
+ // Scheme A compatibility shim:
588
+ // when upstream accidentally returns a local image path as plain text,
589
+ // auto-upload and send as Feishu image message instead of leaking path text.
590
+ const localImagePath = normalizePossibleLocalImagePath(text);
591
+ if (localImagePath) {
592
+ try {
593
+ return await sendMediaFeishu({
594
+ cfg,
595
+ to,
596
+ mediaUrl: localImagePath,
597
+ accountId: accountId ?? undefined,
598
+ replyToMessageId,
599
+ mediaLocalRoots,
600
+ });
601
+ } catch (err) {
602
+ console.error(`[feishu] local image path auto-send failed:`, err);
603
+ // fall through to plain text as last resort
604
+ }
605
+ }
606
+
607
+ if (parseFeishuCommentTarget(to)) {
608
+ return await sendOutboundText({
93
609
  cfg,
94
610
  to,
95
- mediaUrl: localImagePath,
611
+ text,
96
612
  accountId: accountId ?? undefined,
97
613
  replyToMessageId,
98
- mediaLocalRoots,
99
614
  });
100
- return { channel: "feishu", ...result };
101
- } catch (err) {
102
- console.error(`[feishu] local image path auto-send failed:`, err);
103
- // fall through to plain text as last resort
104
615
  }
105
- }
106
616
 
107
- const result = await sendOutboundText({
108
- cfg,
109
- to,
110
- text,
111
- accountId: accountId ?? undefined,
112
- replyToMessageId,
113
- });
114
- return { channel: "feishu", ...result };
115
- },
116
- sendMedia: async ({
117
- cfg,
118
- to,
119
- text,
120
- mediaUrl,
121
- accountId,
122
- mediaLocalRoots,
123
- replyToId,
124
- threadId,
125
- }) => {
126
- const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId });
127
- // Send text first if provided
128
- if (text?.trim()) {
129
- await sendOutboundText({
617
+ const account = resolveFeishuAccount({ cfg, accountId: accountId ?? undefined });
618
+ const renderMode = account.config?.renderMode ?? "auto";
619
+ const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
620
+ if (useCard) {
621
+ const header = identity
622
+ ? {
623
+ title: identity.emoji
624
+ ? `${identity.emoji} ${identity.name ?? ""}`.trim()
625
+ : (identity.name ?? ""),
626
+ template: "blue" as const,
627
+ }
628
+ : undefined;
629
+ return await sendStructuredCardFeishu({
630
+ cfg,
631
+ to,
632
+ text,
633
+ replyToMessageId,
634
+ replyInThread: threadId != null && !replyToId,
635
+ accountId: accountId ?? undefined,
636
+ header: header?.title ? header : undefined,
637
+ });
638
+ }
639
+ return await sendOutboundText({
130
640
  cfg,
131
641
  to,
132
642
  text,
133
643
  accountId: accountId ?? undefined,
134
644
  replyToMessageId,
135
645
  });
136
- }
137
-
138
- // Upload and send media if URL or local path provided
139
- if (mediaUrl) {
140
- try {
141
- const result = await sendMediaFeishu({
646
+ },
647
+ sendMedia: async ({
648
+ cfg,
649
+ to,
650
+ text,
651
+ mediaUrl,
652
+ audioAsVoice,
653
+ accountId,
654
+ mediaLocalRoots,
655
+ replyToId,
656
+ threadId,
657
+ }) => {
658
+ const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId });
659
+ const commentTarget = parseFeishuCommentTarget(to);
660
+ if (commentTarget) {
661
+ const commentText = [text?.trim(), mediaUrl?.trim()].filter(Boolean).join("\n\n");
662
+ return await sendOutboundText({
142
663
  cfg,
143
664
  to,
144
- mediaUrl,
665
+ text: commentText || mediaUrl || text || "",
145
666
  accountId: accountId ?? undefined,
146
- mediaLocalRoots,
147
667
  replyToMessageId,
148
668
  });
149
- return { channel: "feishu", ...result };
150
- } catch (err) {
151
- // Log the error for debugging
152
- console.error(`[feishu] sendMediaFeishu failed:`, err);
153
- // Fallback to URL link if upload fails
154
- const fallbackText = `📎 ${mediaUrl}`;
155
- const result = await sendOutboundText({
669
+ }
670
+
671
+ // Send text first if provided
672
+ if (text?.trim()) {
673
+ await sendOutboundText({
156
674
  cfg,
157
675
  to,
158
- text: fallbackText,
676
+ text,
159
677
  accountId: accountId ?? undefined,
160
678
  replyToMessageId,
161
679
  });
162
- return { channel: "feishu", ...result };
163
680
  }
164
- }
165
681
 
166
- // No media URL, just return text result
167
- const result = await sendOutboundText({
168
- cfg,
169
- to,
170
- text: text ?? "",
171
- accountId: accountId ?? undefined,
172
- replyToMessageId,
173
- });
174
- return { channel: "feishu", ...result };
175
- },
682
+ // Upload and send media if URL or local path provided
683
+ if (mediaUrl) {
684
+ try {
685
+ return await sendMediaFeishu({
686
+ cfg,
687
+ to,
688
+ mediaUrl,
689
+ accountId: accountId ?? undefined,
690
+ mediaLocalRoots,
691
+ replyToMessageId,
692
+ ...(audioAsVoice === true ? { audioAsVoice: true } : {}),
693
+ });
694
+ } catch (err) {
695
+ // Log the error for debugging
696
+ console.error(`[feishu] sendMediaFeishu failed:`, err);
697
+ // Fallback to URL link if upload fails
698
+ return await sendOutboundText({
699
+ cfg,
700
+ to,
701
+ text: `📎 ${mediaUrl}`,
702
+ accountId: accountId ?? undefined,
703
+ replyToMessageId,
704
+ });
705
+ }
706
+ }
707
+
708
+ // No media URL, just return text result
709
+ return await sendOutboundText({
710
+ cfg,
711
+ to,
712
+ text: text ?? "",
713
+ accountId: accountId ?? undefined,
714
+ replyToMessageId,
715
+ });
716
+ },
717
+ }),
176
718
  };