@actagent/feishu 2026.6.2

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