@openclaw-cn/feishu 0.1.8

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 (167) hide show
  1. package/README.md +86 -0
  2. package/dist/index.d.ts +37 -0
  3. package/dist/index.d.ts.map +1 -0
  4. package/dist/index.js +49 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/src/accounts.d.ts +33 -0
  7. package/dist/src/accounts.d.ts.map +1 -0
  8. package/dist/src/accounts.js +110 -0
  9. package/dist/src/accounts.js.map +1 -0
  10. package/dist/src/bitable.d.ts +211 -0
  11. package/dist/src/bitable.d.ts.map +1 -0
  12. package/dist/src/bitable.js +721 -0
  13. package/dist/src/bitable.js.map +1 -0
  14. package/dist/src/bot.d.ts +53 -0
  15. package/dist/src/bot.d.ts.map +1 -0
  16. package/dist/src/bot.js +853 -0
  17. package/dist/src/bot.js.map +1 -0
  18. package/dist/src/calendar-schema.d.ts +18 -0
  19. package/dist/src/calendar-schema.d.ts.map +1 -0
  20. package/dist/src/calendar-schema.js +52 -0
  21. package/dist/src/calendar-schema.js.map +1 -0
  22. package/dist/src/calendar.d.ts +441 -0
  23. package/dist/src/calendar.d.ts.map +1 -0
  24. package/dist/src/calendar.js +257 -0
  25. package/dist/src/calendar.js.map +1 -0
  26. package/dist/src/channel.d.ts +4 -0
  27. package/dist/src/channel.d.ts.map +1 -0
  28. package/dist/src/channel.js +331 -0
  29. package/dist/src/channel.js.map +1 -0
  30. package/dist/src/client.d.ts +35 -0
  31. package/dist/src/client.d.ts.map +1 -0
  32. package/dist/src/client.js +86 -0
  33. package/dist/src/client.js.map +1 -0
  34. package/dist/src/config-schema.d.ts +176 -0
  35. package/dist/src/config-schema.d.ts.map +1 -0
  36. package/dist/src/config-schema.js +56 -0
  37. package/dist/src/config-schema.js.map +1 -0
  38. package/dist/src/directory.d.ts +36 -0
  39. package/dist/src/directory.d.ts.map +1 -0
  40. package/dist/src/directory.js +129 -0
  41. package/dist/src/directory.js.map +1 -0
  42. package/dist/src/doc-schema.d.ts +37 -0
  43. package/dist/src/doc-schema.d.ts.map +1 -0
  44. package/dist/src/doc-schema.js +51 -0
  45. package/dist/src/doc-schema.js.map +1 -0
  46. package/dist/src/docx.d.ts +3 -0
  47. package/dist/src/docx.d.ts.map +1 -0
  48. package/dist/src/docx.js +608 -0
  49. package/dist/src/docx.js.map +1 -0
  50. package/dist/src/drive-schema.d.ts +24 -0
  51. package/dist/src/drive-schema.d.ts.map +1 -0
  52. package/dist/src/drive-schema.js +39 -0
  53. package/dist/src/drive-schema.js.map +1 -0
  54. package/dist/src/drive.d.ts +3 -0
  55. package/dist/src/drive.d.ts.map +1 -0
  56. package/dist/src/drive.js +181 -0
  57. package/dist/src/drive.js.map +1 -0
  58. package/dist/src/dynamic-agent.d.ts +19 -0
  59. package/dist/src/dynamic-agent.d.ts.map +1 -0
  60. package/dist/src/dynamic-agent.js +91 -0
  61. package/dist/src/dynamic-agent.js.map +1 -0
  62. package/dist/src/im-schema.d.ts +38 -0
  63. package/dist/src/im-schema.d.ts.map +1 -0
  64. package/dist/src/im-schema.js +73 -0
  65. package/dist/src/im-schema.js.map +1 -0
  66. package/dist/src/im.d.ts +70 -0
  67. package/dist/src/im.d.ts.map +1 -0
  68. package/dist/src/im.js +328 -0
  69. package/dist/src/im.js.map +1 -0
  70. package/dist/src/media.d.ts +99 -0
  71. package/dist/src/media.d.ts.map +1 -0
  72. package/dist/src/media.js +454 -0
  73. package/dist/src/media.js.map +1 -0
  74. package/dist/src/mention.d.ts +49 -0
  75. package/dist/src/mention.d.ts.map +1 -0
  76. package/dist/src/mention.js +99 -0
  77. package/dist/src/mention.js.map +1 -0
  78. package/dist/src/monitor.d.ts +16 -0
  79. package/dist/src/monitor.d.ts.map +1 -0
  80. package/dist/src/monitor.js +288 -0
  81. package/dist/src/monitor.js.map +1 -0
  82. package/dist/src/onboarding.d.ts +3 -0
  83. package/dist/src/onboarding.d.ts.map +1 -0
  84. package/dist/src/onboarding.js +119 -0
  85. package/dist/src/onboarding.js.map +1 -0
  86. package/dist/src/outbound.d.ts +3 -0
  87. package/dist/src/outbound.d.ts.map +1 -0
  88. package/dist/src/outbound.js +53 -0
  89. package/dist/src/outbound.js.map +1 -0
  90. package/dist/src/perm-schema.d.ts +21 -0
  91. package/dist/src/perm-schema.d.ts.map +1 -0
  92. package/dist/src/perm-schema.js +47 -0
  93. package/dist/src/perm-schema.js.map +1 -0
  94. package/dist/src/perm.d.ts +14 -0
  95. package/dist/src/perm.d.ts.map +1 -0
  96. package/dist/src/perm.js +106 -0
  97. package/dist/src/perm.js.map +1 -0
  98. package/dist/src/policy.d.ts +28 -0
  99. package/dist/src/policy.d.ts.map +1 -0
  100. package/dist/src/policy.js +61 -0
  101. package/dist/src/policy.js.map +1 -0
  102. package/dist/src/probe.d.ts +4 -0
  103. package/dist/src/probe.d.ts.map +1 -0
  104. package/dist/src/probe.js +41 -0
  105. package/dist/src/probe.js.map +1 -0
  106. package/dist/src/reactions.d.ts +66 -0
  107. package/dist/src/reactions.d.ts.map +1 -0
  108. package/dist/src/reactions.js +104 -0
  109. package/dist/src/reactions.js.map +1 -0
  110. package/dist/src/reply-dispatcher.d.ts +41 -0
  111. package/dist/src/reply-dispatcher.d.ts.map +1 -0
  112. package/dist/src/reply-dispatcher.js +249 -0
  113. package/dist/src/reply-dispatcher.js.map +1 -0
  114. package/dist/src/runtime.d.ts +4 -0
  115. package/dist/src/runtime.d.ts.map +1 -0
  116. package/dist/src/runtime.js +11 -0
  117. package/dist/src/runtime.js.map +1 -0
  118. package/dist/src/send.d.ts +76 -0
  119. package/dist/src/send.d.ts.map +1 -0
  120. package/dist/src/send.js +250 -0
  121. package/dist/src/send.js.map +1 -0
  122. package/dist/src/sheets-schema.d.ts +12 -0
  123. package/dist/src/sheets-schema.d.ts.map +1 -0
  124. package/dist/src/sheets-schema.js +35 -0
  125. package/dist/src/sheets-schema.js.map +1 -0
  126. package/dist/src/sheets.d.ts +45 -0
  127. package/dist/src/sheets.d.ts.map +1 -0
  128. package/dist/src/sheets.js +197 -0
  129. package/dist/src/sheets.js.map +1 -0
  130. package/dist/src/streaming-card.d.ts +29 -0
  131. package/dist/src/streaming-card.d.ts.map +1 -0
  132. package/dist/src/streaming-card.js +192 -0
  133. package/dist/src/streaming-card.js.map +1 -0
  134. package/dist/src/targets.d.ts +7 -0
  135. package/dist/src/targets.d.ts.map +1 -0
  136. package/dist/src/targets.js +70 -0
  137. package/dist/src/targets.js.map +1 -0
  138. package/dist/src/task-schema.d.ts +19 -0
  139. package/dist/src/task-schema.d.ts.map +1 -0
  140. package/dist/src/task-schema.js +54 -0
  141. package/dist/src/task-schema.js.map +1 -0
  142. package/dist/src/task.d.ts +1193 -0
  143. package/dist/src/task.d.ts.map +1 -0
  144. package/dist/src/task.js +274 -0
  145. package/dist/src/task.js.map +1 -0
  146. package/dist/src/tools-config.d.ts +12 -0
  147. package/dist/src/tools-config.d.ts.map +1 -0
  148. package/dist/src/tools-config.js +24 -0
  149. package/dist/src/tools-config.js.map +1 -0
  150. package/dist/src/types.d.ts +74 -0
  151. package/dist/src/types.d.ts.map +1 -0
  152. package/dist/src/types.js +2 -0
  153. package/dist/src/types.js.map +1 -0
  154. package/dist/src/typing.d.ts +22 -0
  155. package/dist/src/typing.d.ts.map +1 -0
  156. package/dist/src/typing.js +60 -0
  157. package/dist/src/typing.js.map +1 -0
  158. package/dist/src/wiki-schema.d.ts +34 -0
  159. package/dist/src/wiki-schema.d.ts.map +1 -0
  160. package/dist/src/wiki-schema.js +43 -0
  161. package/dist/src/wiki-schema.js.map +1 -0
  162. package/dist/src/wiki.d.ts +3 -0
  163. package/dist/src/wiki.d.ts.map +1 -0
  164. package/dist/src/wiki.js +176 -0
  165. package/dist/src/wiki.js.map +1 -0
  166. package/openclaw.plugin.json +9 -0
  167. package/package.json +79 -0
@@ -0,0 +1,853 @@
1
+ import { buildPendingHistoryContextFromMap, recordPendingHistoryEntryIfEnabled, clearHistoryEntriesIfEnabled, DEFAULT_GROUP_HISTORY_LIMIT, } from "openclaw/plugin-sdk";
2
+ import { resolveFeishuAccount } from "./accounts.js";
3
+ import { createFeishuClient } from "./client.js";
4
+ import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
5
+ import { downloadMessageResourceFeishu } from "./media.js";
6
+ import { extractMentionTargets, extractMessageBody, isMentionForwardRequest } from "./mention.js";
7
+ import { resolveFeishuGroupConfig, resolveFeishuReplyPolicy, resolveFeishuAllowlistMatch, isFeishuGroupAllowed, } from "./policy.js";
8
+ import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
9
+ import { getFeishuRuntime } from "./runtime.js";
10
+ import { getMessageFeishu, sendMessageFeishu } from "./send.js";
11
+ import { addTypingIndicator, removeTypingIndicator } from "./typing.js";
12
+ // --- Message deduplication ---
13
+ // Prevent duplicate processing when WebSocket reconnects or Feishu redelivers messages.
14
+ // Uses SQLite for persistence across gateway restarts (falls back to in-memory if unavailable).
15
+ const DEDUP_TTL_MS = 30 * 60 * 1000; // 30 minutes
16
+ const DEDUP_MAX_SIZE = 1_000;
17
+ const DEDUP_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // cleanup every 5 minutes
18
+ // --- SQLite persistent dedup backend ---
19
+ let _dedupDb = null;
20
+ let _dedupDbInitFailed = false;
21
+ let _lastDbCleanupTime = Date.now();
22
+ function getDedupDb() {
23
+ if (_dedupDbInitFailed)
24
+ return null;
25
+ if (_dedupDb)
26
+ return _dedupDb;
27
+ try {
28
+ const Database = require("better-sqlite3");
29
+ const os = require("os");
30
+ const path = require("path");
31
+ const fs = require("fs");
32
+ const dbDir = path.join(os.homedir(), ".openclaw");
33
+ fs.mkdirSync(dbDir, { recursive: true });
34
+ const db = new Database(path.join(dbDir, "feishu-dedup.db"));
35
+ db.pragma("journal_mode = WAL");
36
+ db.exec(`
37
+ CREATE TABLE IF NOT EXISTS processed_messages (
38
+ message_id TEXT PRIMARY KEY,
39
+ processed_at INTEGER NOT NULL
40
+ );
41
+ CREATE INDEX IF NOT EXISTS idx_processed_at ON processed_messages(processed_at);
42
+ `);
43
+ _dedupDb = db;
44
+ return db;
45
+ }
46
+ catch {
47
+ _dedupDbInitFailed = true;
48
+ return null;
49
+ }
50
+ }
51
+ // --- In-memory fallback ---
52
+ const processedMessageIds = new Map(); // messageId -> timestamp
53
+ let lastCleanupTime = Date.now();
54
+ function tryRecordMessage(messageId) {
55
+ const now = Date.now();
56
+ const db = getDedupDb();
57
+ if (db) {
58
+ // SQLite path: persistent across restarts
59
+ try {
60
+ // Throttled cleanup of expired rows
61
+ if (now - _lastDbCleanupTime > DEDUP_CLEANUP_INTERVAL_MS) {
62
+ db.prepare("DELETE FROM processed_messages WHERE processed_at < ?").run(now - DEDUP_TTL_MS);
63
+ // Keep table size bounded
64
+ const count = db.prepare("SELECT COUNT(*) as c FROM processed_messages").get().c;
65
+ if (count > DEDUP_MAX_SIZE) {
66
+ db.prepare("DELETE FROM processed_messages WHERE message_id IN (SELECT message_id FROM processed_messages ORDER BY processed_at ASC LIMIT ?)").run(count - DEDUP_MAX_SIZE);
67
+ }
68
+ _lastDbCleanupTime = now;
69
+ }
70
+ // Check if already processed (within TTL)
71
+ const existing = db
72
+ .prepare("SELECT processed_at FROM processed_messages WHERE message_id = ?")
73
+ .get(messageId);
74
+ if (existing && now - existing.processed_at < DEDUP_TTL_MS)
75
+ return false;
76
+ // Insert or replace (handles expired entries)
77
+ db.prepare("INSERT OR REPLACE INTO processed_messages (message_id, processed_at) VALUES (?, ?)").run(messageId, now);
78
+ return true;
79
+ }
80
+ catch {
81
+ // Fall through to in-memory on error
82
+ }
83
+ }
84
+ // In-memory fallback path
85
+ if (now - lastCleanupTime > DEDUP_CLEANUP_INTERVAL_MS) {
86
+ for (const [id, ts] of processedMessageIds) {
87
+ if (now - ts > DEDUP_TTL_MS)
88
+ processedMessageIds.delete(id);
89
+ }
90
+ lastCleanupTime = now;
91
+ }
92
+ if (processedMessageIds.has(messageId))
93
+ return false;
94
+ if (processedMessageIds.size >= DEDUP_MAX_SIZE) {
95
+ const first = processedMessageIds.keys().next().value;
96
+ processedMessageIds.delete(first);
97
+ }
98
+ processedMessageIds.set(messageId, now);
99
+ return true;
100
+ }
101
+ function extractPermissionError(err) {
102
+ if (!err || typeof err !== "object")
103
+ return null;
104
+ // Axios error structure: err.response.data contains the Feishu error
105
+ const axiosErr = err;
106
+ const data = axiosErr.response?.data;
107
+ if (!data || typeof data !== "object")
108
+ return null;
109
+ const feishuErr = data;
110
+ // Feishu permission error code: 99991672
111
+ if (feishuErr.code !== 99991672)
112
+ return null;
113
+ // Extract the grant URL from the error message (contains the direct link)
114
+ const msg = feishuErr.msg ?? "";
115
+ const urlMatch = msg.match(/https:\/\/[^\s,]+\/app\/[^\s,]+/);
116
+ const grantUrl = urlMatch?.[0];
117
+ return {
118
+ code: feishuErr.code,
119
+ message: msg,
120
+ grantUrl,
121
+ };
122
+ }
123
+ // --- Sender name resolution (so the agent can distinguish who is speaking in group chats) ---
124
+ // Cache display names by open_id to avoid an API call on every message.
125
+ const SENDER_NAME_TTL_MS = 10 * 60 * 1000;
126
+ const senderNameCache = new Map();
127
+ // Cache permission errors to avoid spamming the user with repeated notifications.
128
+ // Key: appId or "default", Value: timestamp of last notification
129
+ const permissionErrorNotifiedAt = new Map();
130
+ const PERMISSION_ERROR_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes
131
+ async function resolveFeishuSenderName(params) {
132
+ const { account, senderOpenId, log } = params;
133
+ if (!account.configured)
134
+ return {};
135
+ if (!senderOpenId)
136
+ return {};
137
+ const cached = senderNameCache.get(senderOpenId);
138
+ const now = Date.now();
139
+ if (cached && cached.expireAt > now)
140
+ return { name: cached.name };
141
+ try {
142
+ const client = createFeishuClient(account);
143
+ // contact/v3/users/:user_id?user_id_type=open_id
144
+ const res = await client.contact.user.get({
145
+ path: { user_id: senderOpenId },
146
+ params: { user_id_type: "open_id" },
147
+ });
148
+ const name = res?.data?.user?.name ||
149
+ res?.data?.user?.display_name ||
150
+ res?.data?.user?.nickname ||
151
+ res?.data?.user?.en_name;
152
+ if (name && typeof name === "string") {
153
+ senderNameCache.set(senderOpenId, { name, expireAt: now + SENDER_NAME_TTL_MS });
154
+ return { name };
155
+ }
156
+ return {};
157
+ }
158
+ catch (err) {
159
+ // Check if this is a permission error
160
+ const permErr = extractPermissionError(err);
161
+ if (permErr) {
162
+ log(`feishu: permission error resolving sender name: code=${permErr.code}`);
163
+ return { permissionError: permErr };
164
+ }
165
+ // Best-effort. Don't fail message handling if name lookup fails.
166
+ log(`feishu: failed to resolve sender name for ${senderOpenId}: ${String(err)}`);
167
+ return {};
168
+ }
169
+ }
170
+ function parseMessageContent(content, messageType) {
171
+ try {
172
+ const parsed = JSON.parse(content);
173
+ if (messageType === "text") {
174
+ return parsed.text || "";
175
+ }
176
+ if (messageType === "post") {
177
+ // Extract text content from rich text post
178
+ const { textContent } = parsePostContent(content);
179
+ return textContent;
180
+ }
181
+ return content;
182
+ }
183
+ catch {
184
+ return content;
185
+ }
186
+ }
187
+ function checkBotMentioned(event, botOpenId) {
188
+ const mentions = event.message.mentions ?? [];
189
+ if (mentions.length === 0)
190
+ return false;
191
+ if (!botOpenId)
192
+ return false;
193
+ return mentions.some((m) => m.id.open_id === botOpenId);
194
+ }
195
+ function stripBotMention(text, mentions) {
196
+ if (!mentions || mentions.length === 0)
197
+ return text;
198
+ let result = text;
199
+ for (const mention of mentions) {
200
+ result = result.replace(new RegExp(`@${mention.name}\\s*`, "g"), "").trim();
201
+ result = result.replace(new RegExp(mention.key, "g"), "").trim();
202
+ }
203
+ return result;
204
+ }
205
+ /**
206
+ * Parse media keys from message content based on message type.
207
+ */
208
+ function parseMediaKeys(content, messageType) {
209
+ try {
210
+ const parsed = JSON.parse(content);
211
+ switch (messageType) {
212
+ case "image":
213
+ return { imageKey: parsed.image_key };
214
+ case "file":
215
+ return { fileKey: parsed.file_key, fileName: parsed.file_name };
216
+ case "audio":
217
+ return { fileKey: parsed.file_key };
218
+ case "video":
219
+ // Video has both file_key (video) and image_key (thumbnail)
220
+ return { fileKey: parsed.file_key, imageKey: parsed.image_key };
221
+ case "sticker":
222
+ return { fileKey: parsed.file_key };
223
+ default:
224
+ return {};
225
+ }
226
+ }
227
+ catch {
228
+ return {};
229
+ }
230
+ }
231
+ /**
232
+ * Parse post (rich text) content and extract embedded image keys.
233
+ * Post structure: { title?: string, content: [[{ tag, text?, image_key?, ... }]] }
234
+ */
235
+ function parsePostContent(content) {
236
+ try {
237
+ const parsed = JSON.parse(content);
238
+ const title = parsed.title || "";
239
+ const contentBlocks = parsed.content || [];
240
+ let textContent = title ? `${title}\n\n` : "";
241
+ const imageKeys = [];
242
+ for (const paragraph of contentBlocks) {
243
+ if (Array.isArray(paragraph)) {
244
+ for (const element of paragraph) {
245
+ if (element.tag === "text") {
246
+ textContent += element.text || "";
247
+ }
248
+ else if (element.tag === "a") {
249
+ // Link: show text or href
250
+ textContent += element.text || element.href || "";
251
+ }
252
+ else if (element.tag === "at") {
253
+ // Mention: @username
254
+ textContent += `@${element.user_name || element.user_id || ""}`;
255
+ }
256
+ else if (element.tag === "img" && element.image_key) {
257
+ // Embedded image
258
+ imageKeys.push(element.image_key);
259
+ }
260
+ }
261
+ textContent += "\n";
262
+ }
263
+ }
264
+ return {
265
+ textContent: textContent.trim() || "[富文本消息]",
266
+ imageKeys,
267
+ };
268
+ }
269
+ catch {
270
+ return { textContent: "[富文本消息]", imageKeys: [] };
271
+ }
272
+ }
273
+ /**
274
+ * Infer placeholder text based on message type.
275
+ */
276
+ function inferPlaceholder(messageType) {
277
+ switch (messageType) {
278
+ case "image":
279
+ return "<media:image>";
280
+ case "file":
281
+ return "<media:document>";
282
+ case "audio":
283
+ return "<media:audio>";
284
+ case "video":
285
+ return "<media:video>";
286
+ case "sticker":
287
+ return "<media:sticker>";
288
+ default:
289
+ return "<media:document>";
290
+ }
291
+ }
292
+ /**
293
+ * Resolve media from a Feishu message, downloading and saving to disk.
294
+ * Similar to Discord's resolveMediaList().
295
+ */
296
+ async function resolveFeishuMediaList(params) {
297
+ const { cfg, messageId, messageType, content, maxBytes, log, accountId } = params;
298
+ // Only process media message types (including post for embedded images)
299
+ const mediaTypes = ["image", "file", "audio", "video", "sticker", "post"];
300
+ if (!mediaTypes.includes(messageType)) {
301
+ return [];
302
+ }
303
+ const out = [];
304
+ const core = getFeishuRuntime();
305
+ // Handle post (rich text) messages with embedded images
306
+ if (messageType === "post") {
307
+ const { imageKeys } = parsePostContent(content);
308
+ if (imageKeys.length === 0) {
309
+ return [];
310
+ }
311
+ log?.(`feishu: post message contains ${imageKeys.length} embedded image(s)`);
312
+ for (const imageKey of imageKeys) {
313
+ try {
314
+ // Embedded images in post use messageResource API with image_key as file_key
315
+ const result = await downloadMessageResourceFeishu({
316
+ cfg,
317
+ messageId,
318
+ fileKey: imageKey,
319
+ type: "image",
320
+ accountId,
321
+ });
322
+ let contentType = result.contentType;
323
+ if (!contentType) {
324
+ contentType = await core.media.detectMime({ buffer: result.buffer });
325
+ }
326
+ const saved = await core.channel.media.saveMediaBuffer(result.buffer, contentType, "inbound", maxBytes);
327
+ out.push({
328
+ path: saved.path,
329
+ contentType: saved.contentType,
330
+ placeholder: "<media:image>",
331
+ });
332
+ log?.(`feishu: downloaded embedded image ${imageKey}, saved to ${saved.path}`);
333
+ }
334
+ catch (err) {
335
+ log?.(`feishu: failed to download embedded image ${imageKey}: ${String(err)}`);
336
+ }
337
+ }
338
+ return out;
339
+ }
340
+ // Handle other media types
341
+ const mediaKeys = parseMediaKeys(content, messageType);
342
+ if (!mediaKeys.imageKey && !mediaKeys.fileKey) {
343
+ return [];
344
+ }
345
+ try {
346
+ let buffer;
347
+ let contentType;
348
+ let fileName;
349
+ // For message media, always use messageResource API
350
+ // The image.get API is only for images uploaded via im/v1/images, not for message attachments
351
+ const fileKey = mediaKeys.imageKey || mediaKeys.fileKey;
352
+ if (!fileKey) {
353
+ return [];
354
+ }
355
+ const resourceType = messageType === "image" ? "image" : "file";
356
+ const result = await downloadMessageResourceFeishu({
357
+ cfg,
358
+ messageId,
359
+ fileKey,
360
+ type: resourceType,
361
+ accountId,
362
+ });
363
+ buffer = result.buffer;
364
+ contentType = result.contentType;
365
+ fileName = result.fileName || mediaKeys.fileName;
366
+ // Detect mime type if not provided
367
+ if (!contentType) {
368
+ contentType = await core.media.detectMime({ buffer });
369
+ }
370
+ // Save to disk using core's saveMediaBuffer
371
+ const saved = await core.channel.media.saveMediaBuffer(buffer, contentType, "inbound", maxBytes, fileName);
372
+ out.push({
373
+ path: saved.path,
374
+ contentType: saved.contentType,
375
+ placeholder: inferPlaceholder(messageType),
376
+ });
377
+ log?.(`feishu: downloaded ${messageType} media, saved to ${saved.path}`);
378
+ }
379
+ catch (err) {
380
+ log?.(`feishu: failed to download ${messageType} media: ${String(err)}`);
381
+ }
382
+ return out;
383
+ }
384
+ /**
385
+ * Build media payload for inbound context.
386
+ * Similar to Discord's buildDiscordMediaPayload().
387
+ */
388
+ function buildFeishuMediaPayload(mediaList) {
389
+ const first = mediaList[0];
390
+ const mediaPaths = mediaList.map((media) => media.path);
391
+ const mediaTypes = mediaList.map((media) => media.contentType).filter(Boolean);
392
+ return {
393
+ MediaPath: first?.path,
394
+ MediaType: first?.contentType,
395
+ MediaUrl: first?.path,
396
+ MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
397
+ MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
398
+ MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
399
+ };
400
+ }
401
+ export function parseFeishuMessageEvent(event, botOpenId) {
402
+ const rawContent = parseMessageContent(event.message.content, event.message.message_type);
403
+ const mentionedBot = checkBotMentioned(event, botOpenId);
404
+ const content = stripBotMention(rawContent, event.message.mentions);
405
+ const ctx = {
406
+ chatId: event.message.chat_id,
407
+ messageId: event.message.message_id,
408
+ senderId: event.sender.sender_id.user_id || event.sender.sender_id.open_id || "",
409
+ senderOpenId: event.sender.sender_id.open_id || "",
410
+ chatType: event.message.chat_type,
411
+ mentionedBot,
412
+ rootId: event.message.root_id || undefined,
413
+ parentId: event.message.parent_id || undefined,
414
+ content,
415
+ contentType: event.message.message_type,
416
+ };
417
+ // Detect mention forward request: message mentions bot + at least one other user
418
+ if (isMentionForwardRequest(event, botOpenId)) {
419
+ const mentionTargets = extractMentionTargets(event, botOpenId);
420
+ if (mentionTargets.length > 0) {
421
+ ctx.mentionTargets = mentionTargets;
422
+ // Extract message body (remove all @ placeholders)
423
+ const allMentionKeys = (event.message.mentions ?? []).map((m) => m.key);
424
+ ctx.mentionMessageBody = extractMessageBody(content, allMentionKeys);
425
+ }
426
+ }
427
+ return ctx;
428
+ }
429
+ export async function handleFeishuMessage(params) {
430
+ const { cfg, event, botOpenId, runtime, chatHistories, accountId } = params;
431
+ // Resolve account with merged config
432
+ const account = resolveFeishuAccount({ cfg, accountId });
433
+ const feishuCfg = account.config;
434
+ const log = runtime?.log ?? console.log;
435
+ const error = runtime?.error ?? console.error;
436
+ // Dedup check: skip if this message was already processed
437
+ const messageId = event.message.message_id;
438
+ if (!tryRecordMessage(messageId)) {
439
+ log(`feishu: skipping duplicate message ${messageId}`);
440
+ return;
441
+ }
442
+ let ctx = parseFeishuMessageEvent(event, botOpenId);
443
+ const isGroup = ctx.chatType === "group";
444
+ // Resolve sender display name (best-effort) so the agent can attribute messages correctly.
445
+ const senderResult = await resolveFeishuSenderName({
446
+ account,
447
+ senderOpenId: ctx.senderOpenId,
448
+ log,
449
+ });
450
+ if (senderResult.name)
451
+ ctx = { ...ctx, senderName: senderResult.name };
452
+ // Track permission error to inform agent later (with cooldown to avoid repetition)
453
+ let permissionErrorForAgent;
454
+ if (senderResult.permissionError) {
455
+ const appKey = account.appId ?? "default";
456
+ const now = Date.now();
457
+ const lastNotified = permissionErrorNotifiedAt.get(appKey) ?? 0;
458
+ if (now - lastNotified > PERMISSION_ERROR_COOLDOWN_MS) {
459
+ permissionErrorNotifiedAt.set(appKey, now);
460
+ permissionErrorForAgent = senderResult.permissionError;
461
+ }
462
+ }
463
+ log(`feishu[${account.accountId}]: received message from ${ctx.senderOpenId} in ${ctx.chatId} (${ctx.chatType})`);
464
+ // Log mention targets if detected
465
+ if (ctx.mentionTargets && ctx.mentionTargets.length > 0) {
466
+ const names = ctx.mentionTargets.map((t) => t.name).join(", ");
467
+ log(`feishu[${account.accountId}]: detected @ forward request, targets: [${names}]`);
468
+ }
469
+ const historyLimit = Math.max(0, feishuCfg?.historyLimit ?? cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT);
470
+ const dmPolicy = feishuCfg?.dmPolicy ?? "pairing";
471
+ const configAllowFrom = feishuCfg?.allowFrom ?? [];
472
+ const useAccessGroups = cfg.commands?.useAccessGroups !== false;
473
+ const groupConfig = isGroup
474
+ ? resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId })
475
+ : undefined;
476
+ if (isGroup) {
477
+ const groupPolicy = feishuCfg?.groupPolicy ?? "open";
478
+ const groupAllowFrom = feishuCfg?.groupAllowFrom ?? [];
479
+ // DEBUG: log(`feishu[${account.accountId}]: groupPolicy=${groupPolicy}`);
480
+ const groupConfig = resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId });
481
+ // Check if this GROUP is allowed (groupAllowFrom contains group IDs like oc_xxx, not user IDs)
482
+ const groupAllowed = isFeishuGroupAllowed({
483
+ groupPolicy,
484
+ allowFrom: groupAllowFrom,
485
+ senderId: ctx.chatId, // Check group ID, not sender ID
486
+ senderName: undefined,
487
+ });
488
+ if (!groupAllowed) {
489
+ log(`feishu[${account.accountId}]: sender ${ctx.senderOpenId} not in group allowlist`);
490
+ return;
491
+ }
492
+ // Additional sender-level allowlist check if group has specific allowFrom config
493
+ const senderAllowFrom = groupConfig?.allowFrom ?? [];
494
+ if (senderAllowFrom.length > 0) {
495
+ const senderAllowed = isFeishuGroupAllowed({
496
+ groupPolicy: "allowlist",
497
+ allowFrom: senderAllowFrom,
498
+ senderId: ctx.senderOpenId,
499
+ senderName: ctx.senderName,
500
+ });
501
+ if (!senderAllowed) {
502
+ log(`feishu: sender ${ctx.senderOpenId} not in group ${ctx.chatId} sender allowlist`);
503
+ return;
504
+ }
505
+ }
506
+ const { requireMention } = resolveFeishuReplyPolicy({
507
+ isDirectMessage: false,
508
+ globalConfig: feishuCfg,
509
+ groupConfig,
510
+ });
511
+ if (requireMention && !ctx.mentionedBot) {
512
+ log(`feishu[${account.accountId}]: message in group ${ctx.chatId} did not mention bot, recording to history`);
513
+ if (chatHistories) {
514
+ recordPendingHistoryEntryIfEnabled({
515
+ historyMap: chatHistories,
516
+ historyKey: ctx.chatId,
517
+ limit: historyLimit,
518
+ entry: {
519
+ sender: ctx.senderOpenId,
520
+ body: `${ctx.senderName ?? ctx.senderOpenId}: ${ctx.content}`,
521
+ timestamp: Date.now(),
522
+ messageId: ctx.messageId,
523
+ },
524
+ });
525
+ }
526
+ return;
527
+ }
528
+ }
529
+ else {
530
+ }
531
+ try {
532
+ const core = getFeishuRuntime();
533
+ // DM access control: enforce dmPolicy (pairing / allowlist / open)
534
+ const shouldComputeCommandAuthorized = core.channel.commands.shouldComputeCommandAuthorized(ctx.content, cfg);
535
+ const storeAllowFrom = !isGroup && (dmPolicy !== "open" || shouldComputeCommandAuthorized)
536
+ ? await core.channel.pairing.readAllowFromStore("feishu").catch(() => [])
537
+ : [];
538
+ const effectiveDmAllowFrom = [...configAllowFrom, ...storeAllowFrom];
539
+ const dmAllowed = resolveFeishuAllowlistMatch({
540
+ allowFrom: effectiveDmAllowFrom,
541
+ senderId: ctx.senderOpenId,
542
+ senderName: ctx.senderName,
543
+ }).allowed;
544
+ if (!isGroup && dmPolicy !== "open" && !dmAllowed) {
545
+ if (dmPolicy === "pairing") {
546
+ const { code, created } = await core.channel.pairing.upsertPairingRequest({
547
+ channel: "feishu",
548
+ id: ctx.senderOpenId,
549
+ meta: { name: ctx.senderName },
550
+ });
551
+ if (created) {
552
+ log(`feishu[${account.accountId}]: pairing request sender=${ctx.senderOpenId}`);
553
+ try {
554
+ await sendMessageFeishu({
555
+ cfg,
556
+ to: `user:${ctx.senderOpenId}`,
557
+ text: core.channel.pairing.buildPairingReply({
558
+ channel: "feishu",
559
+ idLine: `Your Feishu user id: ${ctx.senderOpenId}`,
560
+ code,
561
+ }),
562
+ accountId: account.accountId,
563
+ });
564
+ }
565
+ catch (err) {
566
+ log(`feishu[${account.accountId}]: pairing reply failed for ${ctx.senderOpenId}: ${String(err)}`);
567
+ }
568
+ }
569
+ }
570
+ else {
571
+ log(`feishu[${account.accountId}]: blocked unauthorized sender ${ctx.senderOpenId} (dmPolicy=${dmPolicy})`);
572
+ }
573
+ return;
574
+ }
575
+ const commandAllowFrom = isGroup ? (groupConfig?.allowFrom ?? []) : effectiveDmAllowFrom;
576
+ const senderAllowedForCommands = resolveFeishuAllowlistMatch({
577
+ allowFrom: commandAllowFrom,
578
+ senderId: ctx.senderOpenId,
579
+ senderName: ctx.senderName,
580
+ }).allowed;
581
+ const commandAuthorized = shouldComputeCommandAuthorized
582
+ ? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
583
+ useAccessGroups,
584
+ authorizers: [
585
+ { configured: commandAllowFrom.length > 0, allowed: senderAllowedForCommands },
586
+ ],
587
+ })
588
+ : undefined;
589
+ // In group chats, the session is scoped to the group, but the *speaker* is the sender.
590
+ // Using a group-scoped From causes the agent to treat different users as the same person.
591
+ const feishuFrom = `feishu:${ctx.senderOpenId}`;
592
+ const feishuTo = isGroup ? `chat:${ctx.chatId}` : `user:${ctx.senderOpenId}`;
593
+ // Resolve peer ID for session routing
594
+ // When topicSessionMode is enabled, messages within a topic (identified by root_id)
595
+ // get a separate session from the main group chat.
596
+ let peerId = isGroup ? ctx.chatId : ctx.senderOpenId;
597
+ if (isGroup && ctx.rootId) {
598
+ const groupConfig = resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId });
599
+ const topicSessionMode = groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled";
600
+ if (topicSessionMode === "enabled") {
601
+ // Use chatId:topic:rootId as peer ID for topic-scoped sessions
602
+ peerId = `${ctx.chatId}:topic:${ctx.rootId}`;
603
+ log(`feishu[${account.accountId}]: topic session isolation enabled, peer=${peerId}`);
604
+ }
605
+ }
606
+ let route = core.channel.routing.resolveAgentRoute({
607
+ cfg,
608
+ channel: "feishu",
609
+ accountId: account.accountId,
610
+ peer: {
611
+ kind: isGroup ? "group" : "dm",
612
+ id: peerId,
613
+ },
614
+ });
615
+ // Dynamic agent creation for DM users
616
+ // When enabled, creates a unique agent instance with its own workspace for each DM user.
617
+ let _effectiveCfg = cfg;
618
+ if (!isGroup && route.matchedBy === "default") {
619
+ const dynamicCfg = feishuCfg?.dynamicAgentCreation;
620
+ if (dynamicCfg?.enabled) {
621
+ const runtime = getFeishuRuntime();
622
+ const result = await maybeCreateDynamicAgent({
623
+ cfg,
624
+ runtime,
625
+ senderOpenId: ctx.senderOpenId,
626
+ dynamicCfg,
627
+ log: (msg) => log(msg),
628
+ });
629
+ if (result.created) {
630
+ _effectiveCfg = result.updatedCfg;
631
+ // Re-resolve route with updated config
632
+ route = core.channel.routing.resolveAgentRoute({
633
+ cfg: result.updatedCfg,
634
+ channel: "feishu",
635
+ accountId: account.accountId,
636
+ peer: { kind: "dm", id: ctx.senderOpenId },
637
+ });
638
+ log(`feishu[${account.accountId}]: dynamic agent created, new route: ${route.sessionKey}`);
639
+ }
640
+ }
641
+ }
642
+ const preview = ctx.content.replace(/\s+/g, " ").slice(0, 160);
643
+ const inboundLabel = isGroup
644
+ ? `Feishu[${account.accountId}] message in group ${ctx.chatId}`
645
+ : `Feishu[${account.accountId}] DM from ${ctx.senderOpenId}`;
646
+ core.system.enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
647
+ sessionKey: route.sessionKey,
648
+ contextKey: `feishu:message:${ctx.chatId}:${ctx.messageId}`,
649
+ });
650
+ // Resolve media from message
651
+ const mediaMaxBytes = (feishuCfg?.mediaMaxMb ?? 30) * 1024 * 1024; // 30MB default
652
+ const mediaList = await resolveFeishuMediaList({
653
+ cfg,
654
+ messageId: ctx.messageId,
655
+ messageType: event.message.message_type,
656
+ content: event.message.content,
657
+ maxBytes: mediaMaxBytes,
658
+ log,
659
+ accountId: account.accountId,
660
+ });
661
+ const mediaPayload = buildFeishuMediaPayload(mediaList);
662
+ // Fetch quoted/replied message content if parentId exists
663
+ let quotedContent;
664
+ if (ctx.parentId) {
665
+ try {
666
+ const quotedMsg = await getMessageFeishu({
667
+ cfg,
668
+ messageId: ctx.parentId,
669
+ accountId: account.accountId,
670
+ });
671
+ if (quotedMsg) {
672
+ quotedContent = quotedMsg.content;
673
+ log(`feishu[${account.accountId}]: fetched quoted message: ${quotedContent?.slice(0, 100)}`);
674
+ }
675
+ }
676
+ catch (err) {
677
+ log(`feishu[${account.accountId}]: failed to fetch quoted message: ${String(err)}`);
678
+ }
679
+ }
680
+ const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
681
+ // Build message body with quoted content if available
682
+ let messageBody = ctx.content;
683
+ if (quotedContent) {
684
+ messageBody = `[Replying to: "${quotedContent}"]\n\n${ctx.content}`;
685
+ }
686
+ // Include a readable speaker label so the model can attribute instructions.
687
+ // (DMs already have per-sender sessions, but the prefix is still useful for clarity.)
688
+ const speaker = ctx.senderName ?? ctx.senderOpenId;
689
+ messageBody = `${speaker}: ${messageBody}`;
690
+ // If there are mention targets, inform the agent that replies will auto-mention them
691
+ if (ctx.mentionTargets && ctx.mentionTargets.length > 0) {
692
+ const targetNames = ctx.mentionTargets.map((t) => t.name).join(", ");
693
+ messageBody += `\n\n[System: Your reply will automatically @mention: ${targetNames}. Do not write @xxx yourself.]`;
694
+ }
695
+ const envelopeFrom = isGroup ? `${ctx.chatId}:${ctx.senderOpenId}` : ctx.senderOpenId;
696
+ // If there's a permission error, dispatch a separate notification first
697
+ if (permissionErrorForAgent) {
698
+ const grantUrl = permissionErrorForAgent.grantUrl ?? "";
699
+ const permissionNotifyBody = `[System: The bot encountered a Feishu API permission error. Please inform the user about this issue and provide the permission grant URL for the admin to authorize. Permission grant URL: ${grantUrl}]`;
700
+ const permissionBody = core.channel.reply.formatAgentEnvelope({
701
+ channel: "Feishu",
702
+ from: envelopeFrom,
703
+ timestamp: new Date(),
704
+ envelope: envelopeOptions,
705
+ body: permissionNotifyBody,
706
+ });
707
+ const permissionCtx = core.channel.reply.finalizeInboundContext({
708
+ Body: permissionBody,
709
+ BodyForAgent: permissionNotifyBody,
710
+ RawBody: permissionNotifyBody,
711
+ CommandBody: permissionNotifyBody,
712
+ From: feishuFrom,
713
+ To: feishuTo,
714
+ SessionKey: route.sessionKey,
715
+ AccountId: route.accountId,
716
+ ChatType: isGroup ? "group" : "direct",
717
+ GroupSubject: isGroup ? ctx.chatId : undefined,
718
+ SenderName: "system",
719
+ SenderId: "system",
720
+ Provider: "feishu",
721
+ Surface: "feishu",
722
+ MessageSid: `${ctx.messageId}:permission-error`,
723
+ Timestamp: Date.now(),
724
+ WasMentioned: false,
725
+ CommandAuthorized: commandAuthorized,
726
+ OriginatingChannel: "feishu",
727
+ OriginatingTo: feishuTo,
728
+ });
729
+ const { dispatcher: permDispatcher, replyOptions: permReplyOptions, markDispatchIdle: markPermIdle, } = createFeishuReplyDispatcher({
730
+ cfg,
731
+ agentId: route.agentId,
732
+ runtime: runtime,
733
+ chatId: ctx.chatId,
734
+ replyToMessageId: ctx.messageId,
735
+ accountId: account.accountId,
736
+ });
737
+ log(`feishu[${account.accountId}]: dispatching permission error notification to agent`);
738
+ await core.channel.reply.dispatchReplyFromConfig({
739
+ ctx: permissionCtx,
740
+ cfg,
741
+ dispatcher: permDispatcher,
742
+ replyOptions: permReplyOptions,
743
+ });
744
+ markPermIdle();
745
+ }
746
+ const body = core.channel.reply.formatAgentEnvelope({
747
+ channel: "Feishu",
748
+ from: envelopeFrom,
749
+ timestamp: new Date(),
750
+ envelope: envelopeOptions,
751
+ body: messageBody,
752
+ });
753
+ let combinedBody = body;
754
+ const historyKey = isGroup ? ctx.chatId : undefined;
755
+ if (isGroup && historyKey && chatHistories) {
756
+ combinedBody = buildPendingHistoryContextFromMap({
757
+ historyMap: chatHistories,
758
+ historyKey,
759
+ limit: historyLimit,
760
+ currentMessage: combinedBody,
761
+ formatEntry: (entry) => core.channel.reply.formatAgentEnvelope({
762
+ channel: "Feishu",
763
+ // Preserve speaker identity in group history as well.
764
+ from: `${ctx.chatId}:${entry.sender}`,
765
+ timestamp: entry.timestamp,
766
+ body: entry.body,
767
+ envelope: envelopeOptions,
768
+ }),
769
+ });
770
+ }
771
+ const inboundHistory = isGroup && historyKey && historyLimit > 0 && chatHistories
772
+ ? (chatHistories.get(historyKey) ?? []).map((entry) => ({
773
+ sender: entry.sender,
774
+ body: entry.body,
775
+ timestamp: entry.timestamp,
776
+ }))
777
+ : undefined;
778
+ const ctxPayload = core.channel.reply.finalizeInboundContext({
779
+ Body: combinedBody,
780
+ BodyForAgent: ctx.content,
781
+ InboundHistory: inboundHistory,
782
+ RawBody: ctx.content,
783
+ CommandBody: ctx.content,
784
+ From: feishuFrom,
785
+ To: feishuTo,
786
+ SessionKey: route.sessionKey,
787
+ AccountId: route.accountId,
788
+ ChatType: isGroup ? "group" : "direct",
789
+ GroupSubject: isGroup ? ctx.chatId : undefined,
790
+ SenderName: ctx.senderName ?? ctx.senderOpenId,
791
+ SenderId: ctx.senderOpenId,
792
+ Provider: "feishu",
793
+ Surface: "feishu",
794
+ MessageSid: ctx.messageId,
795
+ ReplyToBody: quotedContent ?? undefined,
796
+ Timestamp: Date.now(),
797
+ WasMentioned: ctx.mentionedBot,
798
+ CommandAuthorized: commandAuthorized,
799
+ OriginatingChannel: "feishu",
800
+ OriginatingTo: feishuTo,
801
+ ...mediaPayload,
802
+ });
803
+ const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
804
+ cfg,
805
+ agentId: route.agentId,
806
+ runtime: runtime,
807
+ chatId: ctx.chatId,
808
+ replyToMessageId: ctx.messageId,
809
+ mentionTargets: ctx.mentionTargets,
810
+ accountId: account.accountId,
811
+ });
812
+ // Provide immediate feedback by adding a typing indicator reaction
813
+ let typingState;
814
+ try {
815
+ typingState = await addTypingIndicator({
816
+ cfg,
817
+ messageId: ctx.messageId,
818
+ accountId: account.accountId,
819
+ });
820
+ }
821
+ catch (err) {
822
+ log(`feishu[${account.accountId}]: failed to add typing indicator: ${String(err)}`);
823
+ }
824
+ log(`feishu[${account.accountId}]: dispatching to agent (session=${route.sessionKey})`);
825
+ const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
826
+ ctx: ctxPayload,
827
+ cfg,
828
+ dispatcher,
829
+ replyOptions,
830
+ });
831
+ markDispatchIdle();
832
+ if (isGroup && historyKey && chatHistories) {
833
+ clearHistoryEntriesIfEnabled({
834
+ historyMap: chatHistories,
835
+ historyKey,
836
+ limit: historyLimit,
837
+ });
838
+ }
839
+ log(`feishu[${account.accountId}]: dispatch complete (queuedFinal=${queuedFinal}, replies=${counts.final})`);
840
+ // If typing state was recorded, remove the reaction since agent will reply now/later
841
+ if (typingState) {
842
+ removeTypingIndicator({
843
+ cfg,
844
+ state: typingState,
845
+ accountId: account.accountId,
846
+ }).catch((err) => log(`feishu[${account.accountId}]: failed to remove typing indicator: ${String(err)}`));
847
+ }
848
+ }
849
+ catch (err) {
850
+ error(`feishu[${account.accountId}]: failed to dispatch message: ${String(err)}`);
851
+ }
852
+ }
853
+ //# sourceMappingURL=bot.js.map