@gakr-gakr/feishu 0.1.0

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 (133) hide show
  1. package/api.ts +32 -0
  2. package/autobot.plugin.json +180 -0
  3. package/channel-entry.ts +20 -0
  4. package/channel-plugin-api.ts +1 -0
  5. package/contract-api.ts +16 -0
  6. package/index.ts +82 -0
  7. package/package.json +62 -0
  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.ts +13 -0
  14. package/skills/feishu-doc/SKILL.md +211 -0
  15. package/skills/feishu-doc/references/block-types.md +103 -0
  16. package/skills/feishu-drive/SKILL.md +97 -0
  17. package/skills/feishu-perm/SKILL.md +119 -0
  18. package/skills/feishu-wiki/SKILL.md +113 -0
  19. package/src/accounts.ts +333 -0
  20. package/src/agent-config.ts +21 -0
  21. package/src/app-registration.ts +331 -0
  22. package/src/approval-auth.ts +25 -0
  23. package/src/async.ts +104 -0
  24. package/src/audio-preflight.runtime.ts +9 -0
  25. package/src/bitable.ts +762 -0
  26. package/src/bot-content.ts +485 -0
  27. package/src/bot-runtime-api.ts +12 -0
  28. package/src/bot-sender-name.ts +125 -0
  29. package/src/bot.ts +1703 -0
  30. package/src/card-action.ts +447 -0
  31. package/src/card-interaction.ts +159 -0
  32. package/src/card-test-helpers.ts +54 -0
  33. package/src/card-ux-approval.ts +65 -0
  34. package/src/card-ux-launcher.ts +121 -0
  35. package/src/card-ux-shared.ts +33 -0
  36. package/src/channel-runtime-api.ts +16 -0
  37. package/src/channel.runtime.ts +47 -0
  38. package/src/channel.ts +1423 -0
  39. package/src/chat-schema.ts +25 -0
  40. package/src/chat.ts +188 -0
  41. package/src/client-timeout.ts +42 -0
  42. package/src/client.ts +262 -0
  43. package/src/comment-dispatcher-runtime-api.ts +6 -0
  44. package/src/comment-dispatcher.ts +107 -0
  45. package/src/comment-handler-runtime-api.ts +3 -0
  46. package/src/comment-handler.ts +303 -0
  47. package/src/comment-reaction.ts +259 -0
  48. package/src/comment-shared.ts +406 -0
  49. package/src/comment-target.ts +44 -0
  50. package/src/config-schema.ts +335 -0
  51. package/src/conversation-id.ts +199 -0
  52. package/src/dedup-runtime-api.ts +1 -0
  53. package/src/dedup.ts +141 -0
  54. package/src/dedupe-key.ts +72 -0
  55. package/src/directory.static.ts +61 -0
  56. package/src/directory.ts +124 -0
  57. package/src/doc-schema.ts +182 -0
  58. package/src/docx-batch-insert.ts +223 -0
  59. package/src/docx-color-text.ts +154 -0
  60. package/src/docx-table-ops.ts +316 -0
  61. package/src/docx-types.ts +38 -0
  62. package/src/docx.ts +1596 -0
  63. package/src/drive-schema.ts +92 -0
  64. package/src/drive.ts +829 -0
  65. package/src/dynamic-agent.ts +143 -0
  66. package/src/event-types.ts +45 -0
  67. package/src/external-keys.ts +19 -0
  68. package/src/lifecycle.test-support.ts +220 -0
  69. package/src/media.ts +1105 -0
  70. package/src/mention-target.types.ts +5 -0
  71. package/src/mention.ts +114 -0
  72. package/src/message-action-contract.ts +13 -0
  73. package/src/monitor-state-runtime-api.ts +7 -0
  74. package/src/monitor-transport-runtime-api.ts +10 -0
  75. package/src/monitor.account.ts +492 -0
  76. package/src/monitor.acp-init-failure.lifecycle.test-support.ts +219 -0
  77. package/src/monitor.bot-identity.ts +86 -0
  78. package/src/monitor.bot-menu-handler.ts +165 -0
  79. package/src/monitor.bot-menu.lifecycle.test-support.ts +224 -0
  80. package/src/monitor.broadcast.reply-once.lifecycle.test-support.ts +264 -0
  81. package/src/monitor.card-action.lifecycle.test-support.ts +421 -0
  82. package/src/monitor.comment-notice-handler.ts +105 -0
  83. package/src/monitor.comment.ts +1386 -0
  84. package/src/monitor.message-handler.ts +350 -0
  85. package/src/monitor.reaction.lifecycle.test-support.ts +68 -0
  86. package/src/monitor.startup.ts +74 -0
  87. package/src/monitor.state.ts +170 -0
  88. package/src/monitor.synthetic-error.ts +18 -0
  89. package/src/monitor.test-mocks.ts +46 -0
  90. package/src/monitor.transport.ts +451 -0
  91. package/src/monitor.ts +100 -0
  92. package/src/outbound-runtime-api.ts +1 -0
  93. package/src/outbound.ts +785 -0
  94. package/src/perm-schema.ts +52 -0
  95. package/src/perm.ts +170 -0
  96. package/src/pins.ts +108 -0
  97. package/src/policy.ts +321 -0
  98. package/src/post.ts +275 -0
  99. package/src/probe.ts +166 -0
  100. package/src/processing-claims.ts +59 -0
  101. package/src/qr-terminal.ts +1 -0
  102. package/src/reactions.ts +123 -0
  103. package/src/reasoning-preview.ts +28 -0
  104. package/src/reply-dispatcher-runtime-api.ts +7 -0
  105. package/src/reply-dispatcher.ts +748 -0
  106. package/src/runtime.ts +9 -0
  107. package/src/secret-contract.ts +145 -0
  108. package/src/secret-input.ts +1 -0
  109. package/src/security-audit-shared.ts +69 -0
  110. package/src/security-audit.ts +1 -0
  111. package/src/send-result.ts +80 -0
  112. package/src/send-target.ts +35 -0
  113. package/src/send.ts +861 -0
  114. package/src/sequential-key.ts +28 -0
  115. package/src/sequential-queue.ts +86 -0
  116. package/src/session-conversation.ts +42 -0
  117. package/src/session-route.ts +48 -0
  118. package/src/setup-core.ts +51 -0
  119. package/src/setup-surface.ts +618 -0
  120. package/src/streaming-card.ts +571 -0
  121. package/src/subagent-hooks.ts +413 -0
  122. package/src/targets.ts +97 -0
  123. package/src/thread-bindings.ts +331 -0
  124. package/src/tool-account.ts +93 -0
  125. package/src/tool-factory-test-harness.ts +79 -0
  126. package/src/tool-result.ts +16 -0
  127. package/src/tools-config.ts +22 -0
  128. package/src/types.ts +106 -0
  129. package/src/typing.ts +214 -0
  130. package/src/wiki-schema.ts +69 -0
  131. package/src/wiki.ts +270 -0
  132. package/subagent-hooks-api.ts +31 -0
  133. package/tsconfig.json +16 -0
@@ -0,0 +1,485 @@
1
+ import type { ClawdbotConfig } from "../runtime-api.js";
2
+ import { buildFeishuConversationId } from "./conversation-id.js";
3
+ import { normalizeFeishuExternalKey } from "./external-keys.js";
4
+ import { saveMessageResourceFeishu } from "./media.js";
5
+ import { isFeishuBroadcastMention } from "./mention.js";
6
+ import { parsePostContent } from "./post.js";
7
+ import { getFeishuRuntime } from "./runtime.js";
8
+ import type { FeishuChatType, FeishuMediaInfo } from "./types.js";
9
+
10
+ type FeishuMention = {
11
+ key: string;
12
+ id: {
13
+ open_id?: string;
14
+ user_id?: string;
15
+ union_id?: string;
16
+ };
17
+ name: string;
18
+ tenant_key?: string;
19
+ };
20
+
21
+ type FeishuMessageLike = {
22
+ message: {
23
+ content: string;
24
+ message_type: string;
25
+ mentions?: FeishuMention[];
26
+ chat_id: string;
27
+ root_id?: string;
28
+ parent_id?: string;
29
+ thread_id?: string;
30
+ message_id: string;
31
+ };
32
+ sender: {
33
+ sender_id: {
34
+ open_id?: string;
35
+ user_id?: string;
36
+ };
37
+ };
38
+ };
39
+
40
+ type GroupSessionScope = "group" | "group_sender" | "group_topic" | "group_topic_sender";
41
+
42
+ type FeishuLogger = (...args: unknown[]) => void;
43
+
44
+ type ResolvedFeishuGroupSession = {
45
+ peerId: string;
46
+ parentPeer: { kind: "group"; id: string } | null;
47
+ groupSessionScope: GroupSessionScope;
48
+ replyInThread: boolean;
49
+ threadReply: boolean;
50
+ };
51
+
52
+ export function resolveFeishuGroupSession(params: {
53
+ chatId: string;
54
+ senderOpenId: string;
55
+ messageId: string;
56
+ rootId?: string;
57
+ threadId?: string;
58
+ chatType?: FeishuChatType;
59
+ groupConfig?: {
60
+ groupSessionScope?: GroupSessionScope;
61
+ topicSessionMode?: "enabled" | "disabled";
62
+ replyInThread?: "enabled" | "disabled";
63
+ };
64
+ feishuCfg?: {
65
+ groupSessionScope?: GroupSessionScope;
66
+ topicSessionMode?: "enabled" | "disabled";
67
+ replyInThread?: "enabled" | "disabled";
68
+ };
69
+ }): ResolvedFeishuGroupSession {
70
+ const { chatId, senderOpenId, messageId, rootId, threadId, chatType, groupConfig, feishuCfg } =
71
+ params;
72
+ const normalizedThreadId = threadId?.trim();
73
+ const normalizedRootId = rootId?.trim();
74
+ const threadReply = Boolean(normalizedThreadId || normalizedRootId);
75
+ const replyInThread =
76
+ (groupConfig?.replyInThread ?? feishuCfg?.replyInThread ?? "disabled") === "enabled" ||
77
+ threadReply;
78
+ const legacyTopicSessionMode =
79
+ groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled";
80
+ const groupSessionScope: GroupSessionScope =
81
+ groupConfig?.groupSessionScope ??
82
+ feishuCfg?.groupSessionScope ??
83
+ (legacyTopicSessionMode === "enabled" ? "group_topic" : "group");
84
+ const normalizedTopicGroupThreadId =
85
+ chatType === "topic_group" ? (normalizedThreadId ?? normalizedRootId) : undefined;
86
+ const topicScope =
87
+ groupSessionScope === "group_topic" || groupSessionScope === "group_topic_sender"
88
+ ? (normalizedTopicGroupThreadId ??
89
+ normalizedRootId ??
90
+ normalizedThreadId ??
91
+ (replyInThread ? messageId : null))
92
+ : null;
93
+
94
+ let peerId = chatId;
95
+ switch (groupSessionScope) {
96
+ case "group_sender":
97
+ peerId = buildFeishuConversationId({ chatId, scope: "group_sender", senderOpenId });
98
+ break;
99
+ case "group_topic":
100
+ peerId = topicScope
101
+ ? buildFeishuConversationId({ chatId, scope: "group_topic", topicId: topicScope })
102
+ : chatId;
103
+ break;
104
+ case "group_topic_sender":
105
+ peerId = topicScope
106
+ ? buildFeishuConversationId({
107
+ chatId,
108
+ scope: "group_topic_sender",
109
+ topicId: topicScope,
110
+ senderOpenId,
111
+ })
112
+ : buildFeishuConversationId({ chatId, scope: "group_sender", senderOpenId });
113
+ break;
114
+ case "group":
115
+ default:
116
+ peerId = chatId;
117
+ break;
118
+ }
119
+
120
+ return {
121
+ peerId,
122
+ parentPeer:
123
+ topicScope &&
124
+ (groupSessionScope === "group_topic" || groupSessionScope === "group_topic_sender")
125
+ ? { kind: "group", id: chatId }
126
+ : null,
127
+ groupSessionScope,
128
+ replyInThread,
129
+ threadReply,
130
+ };
131
+ }
132
+
133
+ export function parseMessageContent(content: string, messageType: string): string {
134
+ if (messageType === "post") {
135
+ return parsePostContent(content).textContent;
136
+ }
137
+
138
+ try {
139
+ const parsed = JSON.parse(content);
140
+ if (messageType === "text") {
141
+ return parsed.text || "";
142
+ }
143
+ if (["image", "file", "audio", "video", "media", "sticker"].includes(messageType)) {
144
+ if (messageType === "audio") {
145
+ const speechToText =
146
+ typeof parsed.speech_to_text === "string" ? parsed.speech_to_text.trim() : "";
147
+ if (speechToText) {
148
+ return speechToText;
149
+ }
150
+ }
151
+ const placeholder = inferPlaceholder(messageType);
152
+ const fileName = typeof parsed.file_name === "string" ? parsed.file_name.trim() : "";
153
+ return fileName ? `${placeholder} (${fileName})` : placeholder;
154
+ }
155
+ if (messageType === "share_chat") {
156
+ if (parsed && typeof parsed === "object") {
157
+ const share = parsed as { body?: unknown; summary?: unknown; share_chat_id?: unknown };
158
+ if (typeof share.body === "string" && share.body.trim()) {
159
+ return share.body.trim();
160
+ }
161
+ if (typeof share.summary === "string" && share.summary.trim()) {
162
+ return share.summary.trim();
163
+ }
164
+ if (typeof share.share_chat_id === "string" && share.share_chat_id.trim()) {
165
+ return `[Forwarded message: ${share.share_chat_id.trim()}]`;
166
+ }
167
+ }
168
+ return "[Forwarded message]";
169
+ }
170
+ if (messageType === "merge_forward") {
171
+ return "[Merged and Forwarded Message - loading...]";
172
+ }
173
+ return content;
174
+ } catch {
175
+ return content;
176
+ }
177
+ }
178
+
179
+ function formatSubMessageContent(content: string, contentType: string): string {
180
+ try {
181
+ const parsed = JSON.parse(content);
182
+ switch (contentType) {
183
+ case "text":
184
+ return parsed.text || content;
185
+ case "post":
186
+ return parsePostContent(content).textContent;
187
+ case "image":
188
+ return "[Image]";
189
+ case "file":
190
+ return `[File: ${parsed.file_name || "unknown"}]`;
191
+ case "audio":
192
+ return "[Audio]";
193
+ case "video":
194
+ return "[Video]";
195
+ case "sticker":
196
+ return "[Sticker]";
197
+ case "merge_forward":
198
+ return "[Nested Merged Forward]";
199
+ default:
200
+ return `[${contentType}]`;
201
+ }
202
+ } catch {
203
+ return content;
204
+ }
205
+ }
206
+
207
+ export function parseMergeForwardContent(params: { content: string; log?: FeishuLogger }): string {
208
+ const { content, log } = params;
209
+ const maxMessages = 50;
210
+ log?.("feishu: parsing merge_forward sub-messages from API response");
211
+
212
+ let items: Array<{
213
+ message_id?: string;
214
+ msg_type?: string;
215
+ body?: { content?: string };
216
+ sender?: { id?: string };
217
+ upper_message_id?: string;
218
+ create_time?: string;
219
+ }>;
220
+ try {
221
+ items = JSON.parse(content);
222
+ } catch {
223
+ log?.("feishu: merge_forward items parse failed");
224
+ return "[Merged and Forwarded Message - parse error]";
225
+ }
226
+ if (!Array.isArray(items) || items.length === 0) {
227
+ return "[Merged and Forwarded Message - no sub-messages]";
228
+ }
229
+ const subMessages = items.filter((item) => item.upper_message_id);
230
+ if (subMessages.length === 0) {
231
+ return "[Merged and Forwarded Message - no sub-messages found]";
232
+ }
233
+
234
+ log?.(`feishu: merge_forward contains ${subMessages.length} sub-messages`);
235
+ subMessages.sort(
236
+ (a, b) => Number.parseInt(a.create_time || "0", 10) - Number.parseInt(b.create_time || "0", 10),
237
+ );
238
+
239
+ const lines = ["[Merged and Forwarded Messages]"];
240
+ for (const item of subMessages.slice(0, maxMessages)) {
241
+ lines.push(`- ${formatSubMessageContent(item.body?.content || "", item.msg_type || "text")}`);
242
+ }
243
+ if (subMessages.length > maxMessages) {
244
+ lines.push(`... and ${subMessages.length - maxMessages} more messages`);
245
+ }
246
+ return lines.join("\n");
247
+ }
248
+
249
+ export function checkBotMentioned(event: FeishuMessageLike, botOpenId?: string): boolean {
250
+ if (!botOpenId) {
251
+ return false;
252
+ }
253
+ const mentions = event.message.mentions ?? [];
254
+ if (mentions.length > 0) {
255
+ return mentions.some(
256
+ (mention) => !isFeishuBroadcastMention(mention) && mention.id.open_id === botOpenId,
257
+ );
258
+ }
259
+ if (event.message.message_type === "post") {
260
+ return parsePostContent(event.message.content).mentionedOpenIds.some(
261
+ (id) => id.trim().toLowerCase() !== "all" && id === botOpenId,
262
+ );
263
+ }
264
+ return false;
265
+ }
266
+
267
+ export function normalizeMentions(
268
+ text: string,
269
+ mentions?: FeishuMention[],
270
+ botStripId?: string,
271
+ ): string {
272
+ if (!mentions || mentions.length === 0) {
273
+ return text;
274
+ }
275
+ const escaped = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
276
+ const escapeName = (value: string) => value.replace(/</g, "&lt;").replace(/>/g, "&gt;");
277
+ let result = text;
278
+ for (const mention of mentions) {
279
+ const mentionId = mention.id.open_id;
280
+ const replacement =
281
+ botStripId && mentionId === botStripId
282
+ ? ""
283
+ : mentionId
284
+ ? `<at user_id="${mentionId}">${escapeName(mention.name)}</at>`
285
+ : `@${mention.name}`;
286
+ result = result.replace(new RegExp(escaped(mention.key), "g"), () => replacement).trim();
287
+ }
288
+ return result;
289
+ }
290
+
291
+ export function normalizeFeishuCommandProbeBody(text: string): string {
292
+ if (!text) {
293
+ return "";
294
+ }
295
+ return text
296
+ .replace(/<at\b[^>]*>[^<]*<\/at>/giu, " ")
297
+ .replace(/(^|\s)@[^/\s]+(?=\s|$|\/)/gu, "$1")
298
+ .replace(/\s+/g, " ")
299
+ .trim();
300
+ }
301
+
302
+ function parseMediaKeys(
303
+ content: string,
304
+ messageType: string,
305
+ ): { imageKey?: string; fileKey?: string; fileName?: string } {
306
+ try {
307
+ const parsed = JSON.parse(content);
308
+ const imageKey = normalizeFeishuExternalKey(parsed.image_key);
309
+ const fileKey = normalizeFeishuExternalKey(parsed.file_key);
310
+ switch (messageType) {
311
+ case "image":
312
+ return { imageKey, fileName: parsed.file_name };
313
+ case "file":
314
+ case "audio":
315
+ case "sticker":
316
+ return { fileKey, fileName: parsed.file_name };
317
+ case "video":
318
+ case "media":
319
+ return { fileKey, imageKey, fileName: parsed.file_name };
320
+ default:
321
+ return {};
322
+ }
323
+ } catch {
324
+ return {};
325
+ }
326
+ }
327
+
328
+ export function toMessageResourceType(messageType: string): "image" | "file" {
329
+ return messageType === "image" ? "image" : "file";
330
+ }
331
+
332
+ async function resolveSavedFeishuMedia(params: {
333
+ result:
334
+ | Awaited<ReturnType<typeof saveMessageResourceFeishu>>
335
+ | { buffer: Buffer; contentType?: string; fileName?: string };
336
+ maxBytes: number;
337
+ originalFilename?: string;
338
+ }) {
339
+ if ("saved" in params.result) {
340
+ return params.result.saved;
341
+ }
342
+ const core = getFeishuRuntime();
343
+ const contentType =
344
+ params.result.contentType ?? (await core.media.detectMime({ buffer: params.result.buffer }));
345
+ return await core.channel.media.saveMediaBuffer(
346
+ params.result.buffer,
347
+ contentType,
348
+ "inbound",
349
+ params.maxBytes,
350
+ params.result.fileName ?? params.originalFilename,
351
+ );
352
+ }
353
+
354
+ function inferPlaceholder(messageType: string): string {
355
+ switch (messageType) {
356
+ case "image":
357
+ return "<media:image>";
358
+ case "file":
359
+ return "<media:document>";
360
+ case "audio":
361
+ return "<media:audio>";
362
+ case "video":
363
+ case "media":
364
+ return "<media:video>";
365
+ case "sticker":
366
+ return "<media:sticker>";
367
+ default:
368
+ return "<media:document>";
369
+ }
370
+ }
371
+
372
+ export async function resolveFeishuMediaList(params: {
373
+ cfg: ClawdbotConfig;
374
+ messageId: string;
375
+ messageType: string;
376
+ content: string;
377
+ maxBytes: number;
378
+ log?: (msg: string) => void;
379
+ accountId?: string;
380
+ }): Promise<FeishuMediaInfo[]> {
381
+ const { cfg, messageId, messageType, content, maxBytes, log, accountId } = params;
382
+ const mediaTypes = ["image", "file", "audio", "video", "media", "sticker", "post"];
383
+ if (!mediaTypes.includes(messageType)) {
384
+ return [];
385
+ }
386
+
387
+ const out: FeishuMediaInfo[] = [];
388
+ if (messageType === "post") {
389
+ const { imageKeys, mediaKeys } = parsePostContent(content);
390
+ if (imageKeys.length === 0 && mediaKeys.length === 0) {
391
+ return [];
392
+ }
393
+ if (imageKeys.length > 0) {
394
+ log?.(`feishu: post message contains ${imageKeys.length} embedded image(s)`);
395
+ }
396
+ if (mediaKeys.length > 0) {
397
+ log?.(`feishu: post message contains ${mediaKeys.length} embedded media file(s)`);
398
+ }
399
+
400
+ for (const imageKey of imageKeys) {
401
+ try {
402
+ const result = await saveMessageResourceFeishu({
403
+ cfg,
404
+ messageId,
405
+ fileKey: imageKey,
406
+ type: "image",
407
+ accountId,
408
+ maxBytes,
409
+ });
410
+ const saved = await resolveSavedFeishuMedia({ result, maxBytes });
411
+ out.push({
412
+ path: saved.path,
413
+ contentType: saved.contentType,
414
+ placeholder: "<media:image>",
415
+ });
416
+ log?.(`feishu: downloaded embedded image ${imageKey}, saved to ${saved.path}`);
417
+ } catch (err) {
418
+ log?.(`feishu: failed to download embedded image ${imageKey}: ${String(err)}`);
419
+ }
420
+ }
421
+
422
+ for (const media of mediaKeys) {
423
+ try {
424
+ const result = await saveMessageResourceFeishu({
425
+ cfg,
426
+ messageId,
427
+ fileKey: media.fileKey,
428
+ type: "file",
429
+ accountId,
430
+ maxBytes,
431
+ originalFilename: media.fileName,
432
+ });
433
+ const saved = await resolveSavedFeishuMedia({
434
+ result,
435
+ maxBytes,
436
+ originalFilename: media.fileName,
437
+ });
438
+ out.push({
439
+ path: saved.path,
440
+ contentType: saved.contentType,
441
+ placeholder: "<media:video>",
442
+ });
443
+ log?.(`feishu: downloaded embedded media ${media.fileKey}, saved to ${saved.path}`);
444
+ } catch (err) {
445
+ log?.(`feishu: failed to download embedded media ${media.fileKey}: ${String(err)}`);
446
+ }
447
+ }
448
+ return out;
449
+ }
450
+
451
+ const mediaKeys = parseMediaKeys(content, messageType);
452
+ if (!mediaKeys.imageKey && !mediaKeys.fileKey) {
453
+ return [];
454
+ }
455
+
456
+ try {
457
+ const fileKey = mediaKeys.fileKey || mediaKeys.imageKey;
458
+ if (!fileKey) {
459
+ return [];
460
+ }
461
+ const result = await saveMessageResourceFeishu({
462
+ cfg,
463
+ messageId,
464
+ fileKey,
465
+ type: toMessageResourceType(messageType),
466
+ accountId,
467
+ maxBytes,
468
+ originalFilename: mediaKeys.fileName,
469
+ });
470
+ const saved = await resolveSavedFeishuMedia({
471
+ result,
472
+ maxBytes,
473
+ originalFilename: mediaKeys.fileName,
474
+ });
475
+ out.push({
476
+ path: saved.path,
477
+ contentType: saved.contentType,
478
+ placeholder: inferPlaceholder(messageType),
479
+ });
480
+ log?.(`feishu: downloaded ${messageType} media, saved to ${saved.path}`);
481
+ } catch (err) {
482
+ log?.(`feishu: failed to download ${messageType} media: ${String(err)}`);
483
+ }
484
+ return out;
485
+ }
@@ -0,0 +1,12 @@
1
+ export {
2
+ buildAgentMediaPayload,
3
+ resolveChannelContextVisibilityMode,
4
+ type ClawdbotConfig,
5
+ type RuntimeEnv,
6
+ } from "../runtime-api.js";
7
+ export {
8
+ evaluateSupplementalContextVisibility,
9
+ filterSupplementalContextItems,
10
+ normalizeAgentId,
11
+ } from "../runtime-api.js";
12
+ export { loadSessionStore, resolveSessionStoreEntry } from "../runtime-api.js";
@@ -0,0 +1,125 @@
1
+ import { normalizeLowercaseStringOrEmpty } from "autobot/plugin-sdk/string-coerce-runtime";
2
+ import { createFeishuClient } from "./client.js";
3
+ import type { ResolvedFeishuAccount } from "./types.js";
4
+
5
+ export type FeishuPermissionError = {
6
+ code: number;
7
+ message: string;
8
+ grantUrl?: string;
9
+ };
10
+
11
+ type SenderNameResult = {
12
+ name?: string;
13
+ permissionError?: FeishuPermissionError;
14
+ };
15
+
16
+ type FeishuContactUserGetResponse = Awaited<
17
+ ReturnType<ReturnType<typeof createFeishuClient>["contact"]["user"]["get"]>
18
+ >;
19
+
20
+ type FeishuLogger = (...args: unknown[]) => void;
21
+
22
+ const IGNORED_PERMISSION_SCOPE_TOKENS = ["contact:contact.base:readonly"];
23
+ const FEISHU_SCOPE_CORRECTIONS: Record<string, string> = {
24
+ "contact:contact.base:readonly": "contact:user.base:readonly",
25
+ };
26
+ const SENDER_NAME_TTL_MS = 10 * 60 * 1000;
27
+ const senderNameCache = new Map<string, { name: string; expireAt: number }>();
28
+
29
+ function correctFeishuScopeInUrl(url: string): string {
30
+ let corrected = url;
31
+ for (const [wrong, right] of Object.entries(FEISHU_SCOPE_CORRECTIONS)) {
32
+ corrected = corrected.replaceAll(encodeURIComponent(wrong), encodeURIComponent(right));
33
+ corrected = corrected.replaceAll(wrong, right);
34
+ }
35
+ return corrected;
36
+ }
37
+
38
+ function shouldSuppressPermissionErrorNotice(permissionError: FeishuPermissionError): boolean {
39
+ const message = normalizeLowercaseStringOrEmpty(permissionError.message);
40
+ return IGNORED_PERMISSION_SCOPE_TOKENS.some((token) => message.includes(token));
41
+ }
42
+
43
+ function extractPermissionError(err: unknown): FeishuPermissionError | null {
44
+ if (!err || typeof err !== "object") {
45
+ return null;
46
+ }
47
+ const axiosErr = err as { response?: { data?: unknown } };
48
+ const data = axiosErr.response?.data;
49
+ if (!data || typeof data !== "object") {
50
+ return null;
51
+ }
52
+ const feishuErr = data as { code?: number; msg?: string };
53
+ if (feishuErr.code !== 99991672) {
54
+ return null;
55
+ }
56
+ const msg = feishuErr.msg ?? "";
57
+ const urlMatch = msg.match(/https:\/\/[^\s,]+\/app\/[^\s,]+/);
58
+ return {
59
+ code: feishuErr.code,
60
+ message: msg,
61
+ grantUrl: urlMatch?.[0] ? correctFeishuScopeInUrl(urlMatch[0]) : undefined,
62
+ };
63
+ }
64
+
65
+ function resolveSenderLookupIdType(senderId: string): "open_id" | "user_id" | "union_id" {
66
+ const trimmed = senderId.trim();
67
+ if (trimmed.startsWith("ou_")) {
68
+ return "open_id";
69
+ }
70
+ if (trimmed.startsWith("on_")) {
71
+ return "union_id";
72
+ }
73
+ return "user_id";
74
+ }
75
+
76
+ export async function resolveFeishuSenderName(params: {
77
+ account: ResolvedFeishuAccount;
78
+ senderId: string;
79
+ log: FeishuLogger;
80
+ }): Promise<SenderNameResult> {
81
+ const { account, senderId, log } = params;
82
+ if (!account.configured) {
83
+ return {};
84
+ }
85
+
86
+ const normalizedSenderId = senderId.trim();
87
+ if (!normalizedSenderId) {
88
+ return {};
89
+ }
90
+
91
+ const cached = senderNameCache.get(normalizedSenderId);
92
+ const now = Date.now();
93
+ if (cached && cached.expireAt > now) {
94
+ return { name: cached.name };
95
+ }
96
+
97
+ try {
98
+ const client = createFeishuClient(account);
99
+ const userIdType = resolveSenderLookupIdType(normalizedSenderId);
100
+ const res: FeishuContactUserGetResponse = await client.contact.user.get({
101
+ path: { user_id: normalizedSenderId },
102
+ params: { user_id_type: userIdType },
103
+ });
104
+ const user = res.data?.user;
105
+ const name = user?.name ?? user?.nickname ?? user?.en_name;
106
+
107
+ if (name) {
108
+ senderNameCache.set(normalizedSenderId, { name, expireAt: now + SENDER_NAME_TTL_MS });
109
+ return { name };
110
+ }
111
+ return {};
112
+ } catch (err) {
113
+ const permErr = extractPermissionError(err);
114
+ if (permErr) {
115
+ if (shouldSuppressPermissionErrorNotice(permErr)) {
116
+ log(`feishu: ignoring stale permission scope error: ${permErr.message}`);
117
+ return {};
118
+ }
119
+ log(`feishu: permission error resolving sender name: code=${permErr.code}`);
120
+ return { permissionError: permErr };
121
+ }
122
+ log(`feishu: failed to resolve sender name for ${normalizedSenderId}: ${String(err)}`);
123
+ return {};
124
+ }
125
+ }