@clowder-ai/connector-feishu 0.0.0-stage → 0.1.0-alpha.5
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.
- package/README.en.md +54 -0
- package/README.md +25 -2
- package/assets/icon.svg +3 -0
- package/dist/FeishuAdapter.d.ts +250 -0
- package/dist/FeishuAdapter.d.ts.map +1 -0
- package/dist/FeishuAdapter.js +845 -0
- package/dist/FeishuAdapter.js.map +1 -0
- package/dist/FeishuQrBindClient.d.ts +23 -0
- package/dist/FeishuQrBindClient.d.ts.map +1 -0
- package/dist/FeishuQrBindClient.js +97 -0
- package/dist/FeishuQrBindClient.js.map +1 -0
- package/dist/FeishuTokenManager.d.ts +15 -0
- package/dist/FeishuTokenManager.d.ts.map +1 -0
- package/dist/FeishuTokenManager.js +35 -0
- package/dist/FeishuTokenManager.js.map +1 -0
- package/dist/command-ingress.d.ts +10 -0
- package/dist/command-ingress.d.ts.map +1 -0
- package/dist/command-ingress.js +15 -0
- package/dist/command-ingress.js.map +1 -0
- package/dist/execution-status.d.ts +27 -0
- package/dist/execution-status.d.ts.map +1 -0
- package/dist/execution-status.js +48 -0
- package/dist/execution-status.js.map +1 -0
- package/dist/feishu-card-formatter.d.ts +18 -0
- package/dist/feishu-card-formatter.d.ts.map +1 -0
- package/dist/feishu-card-formatter.js +58 -0
- package/dist/feishu-card-formatter.js.map +1 -0
- package/dist/inbound-download.d.ts +13 -0
- package/dist/inbound-download.d.ts.map +1 -0
- package/dist/inbound-download.js +70 -0
- package/dist/inbound-download.js.map +1 -0
- package/dist/inbound-media-source.d.ts +25 -0
- package/dist/inbound-media-source.d.ts.map +1 -0
- package/dist/inbound-media-source.js +111 -0
- package/dist/inbound-media-source.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/lifecycle-action.d.ts +30 -0
- package/dist/lifecycle-action.d.ts.map +1 -0
- package/dist/lifecycle-action.js +457 -0
- package/dist/lifecycle-action.js.map +1 -0
- package/dist/materialize-media.d.ts +7 -0
- package/dist/materialize-media.d.ts.map +1 -0
- package/dist/materialize-media.js +35 -0
- package/dist/materialize-media.js.map +1 -0
- package/dist/media-notice.d.ts +6 -0
- package/dist/media-notice.d.ts.map +1 -0
- package/dist/media-notice.js +50 -0
- package/dist/media-notice.js.map +1 -0
- package/dist/plugin-entrypoint.d.ts +9 -0
- package/dist/plugin-entrypoint.d.ts.map +1 -0
- package/dist/plugin-entrypoint.js +347 -0
- package/dist/plugin-entrypoint.js.map +1 -0
- package/dist/reply-sender-map.d.ts +10 -0
- package/dist/reply-sender-map.d.ts.map +1 -0
- package/dist/reply-sender-map.js +123 -0
- package/dist/reply-sender-map.js.map +1 -0
- package/dist/runtime.d.ts +190 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +580 -0
- package/dist/runtime.js.map +1 -0
- package/dist/settings.d.ts +20 -0
- package/dist/settings.d.ts.map +1 -0
- package/dist/settings.js +35 -0
- package/dist/settings.js.map +1 -0
- package/dist/thread-mappings.d.ts +46 -0
- package/dist/thread-mappings.d.ts.map +1 -0
- package/dist/thread-mappings.js +62 -0
- package/dist/thread-mappings.js.map +1 -0
- package/dist/types.d.ts +54 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +7 -0
- package/dist/types.js.map +1 -0
- package/npm-shrinkwrap.json +979 -0
- package/package.json +58 -4
- package/plugin.yaml +118 -0
|
@@ -0,0 +1,845 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feishu (飞书/Lark) Bot Adapter
|
|
3
|
+
* Inbound: Parse webhook event → extract private text message
|
|
4
|
+
* Outbound: Send reply via Lark API
|
|
5
|
+
*
|
|
6
|
+
* Uses @larksuiteoapi/node-sdk for API calls.
|
|
7
|
+
* MVP: DM-only (p2p), text-only, single-owner.
|
|
8
|
+
*
|
|
9
|
+
* F088 Multi-Platform Chat Gateway
|
|
10
|
+
*/
|
|
11
|
+
import { openAsBlob } from 'node:fs';
|
|
12
|
+
import * as lark from '@larksuiteoapi/node-sdk';
|
|
13
|
+
import { DEFAULT_QUICK_ACTIONS } from './types.js';
|
|
14
|
+
import { formatFeishuCard } from './feishu-card-formatter.js';
|
|
15
|
+
import { fetchBoundedInboundMedia, INBOUND_MEDIA_MAX_BYTES, INBOUND_MEDIA_TIMEOUT_MS, } from './inbound-download.js';
|
|
16
|
+
import { materializeMedia } from './materialize-media.js';
|
|
17
|
+
// Feishu IM upload limits: message images 10 MB; files (including audio) 30 MB.
|
|
18
|
+
// Sources: https://open.feishu.cn/document/server-docs/im-v1/image/create
|
|
19
|
+
// https://open.feishu.cn/document/server-docs/im-v1/file/create
|
|
20
|
+
export const FEISHU_MEDIA_MAX_BYTES = {
|
|
21
|
+
image: 10_000_000,
|
|
22
|
+
file: 30_000_000,
|
|
23
|
+
audio: 30_000_000,
|
|
24
|
+
};
|
|
25
|
+
const NOOP_SDK_LOGGER = Object.freeze({ error() { }, warn() { }, info() { }, debug() { }, trace() { } });
|
|
26
|
+
export class FeishuAdapter {
|
|
27
|
+
connectorId = 'feishu';
|
|
28
|
+
client;
|
|
29
|
+
log;
|
|
30
|
+
verificationToken;
|
|
31
|
+
groupBotMentions;
|
|
32
|
+
tokenManager = null;
|
|
33
|
+
uploadFetchFn = globalThis.fetch;
|
|
34
|
+
inboundFetchFn = globalThis.fetch;
|
|
35
|
+
inboundMediaTimeoutMs = INBOUND_MEDIA_TIMEOUT_MS;
|
|
36
|
+
sendMessageFn = null;
|
|
37
|
+
editMessageFn = null;
|
|
38
|
+
deleteMessageFn = null;
|
|
39
|
+
addReactionFn = null;
|
|
40
|
+
botOpenId = null;
|
|
41
|
+
static CACHE_TTL_MS = 30 * 60 * 1000;
|
|
42
|
+
senderNameCache = new Map();
|
|
43
|
+
chatNameCache = new Map();
|
|
44
|
+
chatTypeCache = new Map();
|
|
45
|
+
constructor(appId, appSecret, log, options) {
|
|
46
|
+
this.client = new lark.Client({
|
|
47
|
+
appId,
|
|
48
|
+
appSecret,
|
|
49
|
+
appType: lark.AppType.SelfBuild,
|
|
50
|
+
// The SDK includes request URLs (and therefore file_key locators) in
|
|
51
|
+
// failure logs. Connector-owned logs retain only safe metadata.
|
|
52
|
+
logger: NOOP_SDK_LOGGER,
|
|
53
|
+
});
|
|
54
|
+
this.log = log;
|
|
55
|
+
this.verificationToken = options?.verificationToken ?? null;
|
|
56
|
+
this.groupBotMentions = options?.groupBotMentions ?? {};
|
|
57
|
+
}
|
|
58
|
+
async downloadInboundMedia(locator) {
|
|
59
|
+
if (this.tokenManager === null)
|
|
60
|
+
throw new Error('Feishu token manager is not configured');
|
|
61
|
+
const controller = new AbortController();
|
|
62
|
+
const timer = setTimeout(() => controller.abort(new Error('Feishu inbound media download timed out')), this.inboundMediaTimeoutMs);
|
|
63
|
+
timer.unref?.();
|
|
64
|
+
try {
|
|
65
|
+
const token = await this.tokenManager.getTenantAccessToken(controller.signal);
|
|
66
|
+
const messageId = encodeURIComponent(locator.sourceEventId);
|
|
67
|
+
const fileKey = encodeURIComponent(locator.platformKey);
|
|
68
|
+
const url = `https://open.feishu.cn/open-apis/im/v1/messages/${messageId}/resources/${fileKey}?type=${locator.type === 'image' ? 'image' : 'file'}`;
|
|
69
|
+
const { response, bytes } = await fetchBoundedInboundMedia(this.inboundFetchFn, url, { headers: { Authorization: `Bearer ${token}` }, signal: controller.signal }, INBOUND_MEDIA_MAX_BYTES, this.inboundMediaTimeoutMs);
|
|
70
|
+
if (!response.ok)
|
|
71
|
+
throw new Error(`Feishu inbound media download HTTP ${response.status}`);
|
|
72
|
+
return bytes;
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Check if the request body is a Feishu URL verification challenge.
|
|
80
|
+
* Returns the challenge token if so, null otherwise.
|
|
81
|
+
*/
|
|
82
|
+
isVerificationChallenge(body) {
|
|
83
|
+
if (!body || typeof body !== 'object')
|
|
84
|
+
return null;
|
|
85
|
+
const b = body;
|
|
86
|
+
if (b.type === 'url_verification' && typeof b.challenge === 'string') {
|
|
87
|
+
return { challenge: b.challenge };
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Verify event callback token.
|
|
93
|
+
* Checks that the event body's header.token matches the configured verificationToken.
|
|
94
|
+
* If no verificationToken is configured, verification is skipped (returns true).
|
|
95
|
+
*/
|
|
96
|
+
verifyEventToken(body) {
|
|
97
|
+
if (!this.verificationToken)
|
|
98
|
+
return true;
|
|
99
|
+
if (!body || typeof body !== 'object')
|
|
100
|
+
return false;
|
|
101
|
+
const b = body;
|
|
102
|
+
const header = b.header;
|
|
103
|
+
if (!header)
|
|
104
|
+
return false;
|
|
105
|
+
return header.token === this.verificationToken;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Parse a Feishu event callback into an inbound message.
|
|
109
|
+
* Supports text, image, file, audio, and post message types.
|
|
110
|
+
* For group chats, only processes messages that @mention the bot.
|
|
111
|
+
* Returns null for unsupported events or group messages not mentioning bot.
|
|
112
|
+
*/
|
|
113
|
+
parseEvent(eventBody) {
|
|
114
|
+
if (!eventBody || typeof eventBody !== 'object')
|
|
115
|
+
return null;
|
|
116
|
+
const body = eventBody;
|
|
117
|
+
const header = body.header;
|
|
118
|
+
if (!header || header.event_type !== 'im.message.receive_v1')
|
|
119
|
+
return null;
|
|
120
|
+
const event = body.event;
|
|
121
|
+
if (!event)
|
|
122
|
+
return null;
|
|
123
|
+
const message = event.message;
|
|
124
|
+
if (!message)
|
|
125
|
+
return null;
|
|
126
|
+
if (typeof message.message_id !== 'string' || message.message_id.trim() === '')
|
|
127
|
+
return null;
|
|
128
|
+
const msgType = message.message_type;
|
|
129
|
+
const chatType = message.chat_type;
|
|
130
|
+
if (chatType !== 'p2p' && chatType !== 'group')
|
|
131
|
+
return null;
|
|
132
|
+
const mentions = message.mentions ?? [];
|
|
133
|
+
if (chatType === 'group') {
|
|
134
|
+
if (!this.botOpenId)
|
|
135
|
+
return null;
|
|
136
|
+
const botMentioned = mentions.some((m) => {
|
|
137
|
+
if (m.key === '@_all')
|
|
138
|
+
return false;
|
|
139
|
+
const mentionId = m.id;
|
|
140
|
+
return mentionId?.open_id === this.botOpenId;
|
|
141
|
+
});
|
|
142
|
+
if (!botMentioned)
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
// Extract sender
|
|
146
|
+
const sender = event.sender;
|
|
147
|
+
const senderId = sender?.sender_id?.open_id;
|
|
148
|
+
const base = {
|
|
149
|
+
chatId: message.chat_id,
|
|
150
|
+
messageId: message.message_id,
|
|
151
|
+
senderId: String(senderId ?? 'unknown'),
|
|
152
|
+
chatType: chatType,
|
|
153
|
+
};
|
|
154
|
+
// Parse content JSON
|
|
155
|
+
let content;
|
|
156
|
+
try {
|
|
157
|
+
content = JSON.parse(message.content);
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
// Strip @bot placeholder from text in group chat (Feishu uses @_user_N tokens)
|
|
163
|
+
const stripBotMention = (text) => {
|
|
164
|
+
if (chatType !== 'group')
|
|
165
|
+
return text;
|
|
166
|
+
for (const m of mentions) {
|
|
167
|
+
const mentionId = m.id;
|
|
168
|
+
if (mentionId?.open_id === this.botOpenId && typeof m.key === 'string') {
|
|
169
|
+
text = text.replace(new RegExp(m.key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '');
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return text.trim();
|
|
173
|
+
};
|
|
174
|
+
switch (msgType) {
|
|
175
|
+
case 'text': {
|
|
176
|
+
const text = content.text;
|
|
177
|
+
if (typeof text !== 'string')
|
|
178
|
+
return null;
|
|
179
|
+
return { ...base, text: stripBotMention(text) };
|
|
180
|
+
}
|
|
181
|
+
case 'image': {
|
|
182
|
+
const imageKey = content.image_key;
|
|
183
|
+
if (!imageKey)
|
|
184
|
+
return null;
|
|
185
|
+
return { ...base, text: '[图片]', attachments: [{ type: 'image', feishuKey: imageKey }] };
|
|
186
|
+
}
|
|
187
|
+
case 'file': {
|
|
188
|
+
const fileKey = content.file_key;
|
|
189
|
+
const fileName = content.file_name;
|
|
190
|
+
if (!fileKey)
|
|
191
|
+
return null;
|
|
192
|
+
return {
|
|
193
|
+
...base,
|
|
194
|
+
text: fileName ? `[文件] ${fileName}` : '[文件]',
|
|
195
|
+
attachments: [{ type: 'file', feishuKey: fileKey, ...(fileName ? { fileName } : {}) }],
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
case 'audio': {
|
|
199
|
+
const audioKey = content.file_key;
|
|
200
|
+
const duration = content.duration;
|
|
201
|
+
if (!audioKey)
|
|
202
|
+
return null;
|
|
203
|
+
return {
|
|
204
|
+
...base,
|
|
205
|
+
text: '[语音]',
|
|
206
|
+
attachments: [{ type: 'audio', feishuKey: audioKey, ...(duration != null ? { duration } : {}) }],
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
case 'post': {
|
|
210
|
+
// Feishu sends post content in two formats:
|
|
211
|
+
// 1. Locale-wrapped: { zh_cn: { title, content }, en_us: ... }
|
|
212
|
+
// 2. Direct (no locale wrapper): { title, content: [[...]] }
|
|
213
|
+
const c = content;
|
|
214
|
+
const locale = c.zh_cn ?? c.en_us ?? c.ja_jp;
|
|
215
|
+
const resolved = locale && typeof locale === 'object'
|
|
216
|
+
? locale
|
|
217
|
+
: Array.isArray(c.content) // direct format — content is array of paragraphs
|
|
218
|
+
? c
|
|
219
|
+
: null;
|
|
220
|
+
if (!resolved || typeof resolved !== 'object')
|
|
221
|
+
return null;
|
|
222
|
+
const loc = resolved;
|
|
223
|
+
const textParts = [];
|
|
224
|
+
const attachments = [];
|
|
225
|
+
if (loc.title)
|
|
226
|
+
textParts.push(loc.title);
|
|
227
|
+
if (Array.isArray(loc.content)) {
|
|
228
|
+
for (const paragraph of loc.content) {
|
|
229
|
+
if (!Array.isArray(paragraph))
|
|
230
|
+
continue;
|
|
231
|
+
const paraTexts = [];
|
|
232
|
+
for (const node of paragraph) {
|
|
233
|
+
const n = node;
|
|
234
|
+
if (n.tag === 'text' || n.tag === 'a') {
|
|
235
|
+
if (typeof n.text === 'string')
|
|
236
|
+
paraTexts.push(n.text);
|
|
237
|
+
}
|
|
238
|
+
else if (n.tag === 'img' && typeof n.image_key === 'string') {
|
|
239
|
+
attachments.push({
|
|
240
|
+
type: 'image',
|
|
241
|
+
feishuKey: n.image_key,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (paraTexts.length > 0)
|
|
246
|
+
textParts.push(paraTexts.join(''));
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const text = textParts.join('\n') || '[富文本]';
|
|
250
|
+
return {
|
|
251
|
+
...base,
|
|
252
|
+
text,
|
|
253
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
default:
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* AC-14: Parse a Feishu card action callback (button click, etc.).
|
|
262
|
+
* Returns null for non-card-action events.
|
|
263
|
+
*/
|
|
264
|
+
parseCardAction(eventBody) {
|
|
265
|
+
if (!eventBody || typeof eventBody !== 'object')
|
|
266
|
+
return null;
|
|
267
|
+
const body = eventBody;
|
|
268
|
+
const header = body.header;
|
|
269
|
+
if (!header || header.event_type !== 'card.action.trigger')
|
|
270
|
+
return null;
|
|
271
|
+
const event = body.event;
|
|
272
|
+
if (!event)
|
|
273
|
+
return null;
|
|
274
|
+
// Webhook callbacks carry header.event_id; the Lark WebSocket SDK passes
|
|
275
|
+
// the same provider ID in its flattened event data. The card message ID
|
|
276
|
+
// is not a click ID and must never be used to deduplicate card actions.
|
|
277
|
+
const headerEventId = header.event_id;
|
|
278
|
+
const flattenedEventId = event.event_id;
|
|
279
|
+
if (headerEventId !== undefined && flattenedEventId !== undefined && headerEventId !== flattenedEventId)
|
|
280
|
+
return null;
|
|
281
|
+
const eventId = headerEventId ?? flattenedEventId;
|
|
282
|
+
if (typeof eventId !== 'string' || eventId.trim() === '')
|
|
283
|
+
return null;
|
|
284
|
+
const operator = event.operator;
|
|
285
|
+
const action = event.action;
|
|
286
|
+
const context = event.context;
|
|
287
|
+
if (!operator || !action || !context)
|
|
288
|
+
return null;
|
|
289
|
+
const actionValue = action.value ?? {};
|
|
290
|
+
const option = typeof action.option === 'string' ? action.option : undefined;
|
|
291
|
+
if (Object.keys(actionValue).length === 0 && !option)
|
|
292
|
+
return null;
|
|
293
|
+
const rawChatType = context.open_chat_type;
|
|
294
|
+
const chatType = rawChatType === 'p2p' || rawChatType === 'group' ? rawChatType : undefined;
|
|
295
|
+
return {
|
|
296
|
+
eventId,
|
|
297
|
+
chatId: context.open_chat_id,
|
|
298
|
+
senderId: operator.open_id,
|
|
299
|
+
actionValue,
|
|
300
|
+
option,
|
|
301
|
+
chatType,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
async sendLarkMessage(externalChatId, msgType, content) {
|
|
305
|
+
const params = { chatId: externalChatId, content, msgType };
|
|
306
|
+
if (this.sendMessageFn)
|
|
307
|
+
return this.sendMessageFn(params);
|
|
308
|
+
const result = await this.client.im.message.create({
|
|
309
|
+
params: { receive_id_type: 'chat_id' },
|
|
310
|
+
data: { receive_id: externalChatId, msg_type: msgType, content },
|
|
311
|
+
});
|
|
312
|
+
const code = result?.code;
|
|
313
|
+
if (code !== undefined && code !== 0) {
|
|
314
|
+
const msg = result?.msg ?? 'unknown';
|
|
315
|
+
throw new Error(`Feishu API error ${code}: ${msg}`);
|
|
316
|
+
}
|
|
317
|
+
return result;
|
|
318
|
+
}
|
|
319
|
+
/** Send a provider key or Host-authorized byte stream to a Feishu chat. */
|
|
320
|
+
async sendMedia(externalChatId, payload) {
|
|
321
|
+
this.log.info({
|
|
322
|
+
type: payload.type,
|
|
323
|
+
hasKey: !!(payload.imageKey || payload.fileKey),
|
|
324
|
+
hasContent: payload.content !== undefined,
|
|
325
|
+
hasTokenMgr: !!this.tokenManager,
|
|
326
|
+
}, '[FeishuAdapter] sendMedia entry');
|
|
327
|
+
if (payload.imageKey || payload.fileKey) {
|
|
328
|
+
await this.sendWithPlatformKey(externalChatId, payload);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (payload.content === undefined)
|
|
332
|
+
throw new TypeError('Feishu media content is required');
|
|
333
|
+
const materialized = await materializeMedia(payload.content, payload.fileName, FEISHU_MEDIA_MAX_BYTES[payload.type]);
|
|
334
|
+
try {
|
|
335
|
+
const deliveryType = this.deliveryTypeFor(materialized.path, payload.type);
|
|
336
|
+
if (deliveryType !== payload.type) {
|
|
337
|
+
this.log.info({ declaredType: payload.type, deliveryType }, '[FeishuAdapter] sendMedia: no transcode capability, delivering audio as original-name file');
|
|
338
|
+
}
|
|
339
|
+
const uploaded = await this.uploadToFeishu(materialized.path, deliveryType, payload.fileName);
|
|
340
|
+
if (!uploaded)
|
|
341
|
+
throw new Error('Feishu media upload returned no provider key');
|
|
342
|
+
await this.sendWithPlatformKey(externalChatId, { type: deliveryType, ...uploaded });
|
|
343
|
+
}
|
|
344
|
+
finally {
|
|
345
|
+
await materialized.cleanup().catch(() => undefined);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
async sendWithPlatformKey(externalChatId, payload) {
|
|
349
|
+
const typeMap = {
|
|
350
|
+
image: () => ({ msgType: 'image', content: JSON.stringify({ image_key: payload.imageKey }) }),
|
|
351
|
+
file: () => ({ msgType: 'file', content: JSON.stringify({ file_key: payload.fileKey }) }),
|
|
352
|
+
audio: () => ({ msgType: 'audio', content: JSON.stringify({ file_key: payload.fileKey }) }),
|
|
353
|
+
};
|
|
354
|
+
const entry = typeMap[payload.type];
|
|
355
|
+
if (!entry)
|
|
356
|
+
return;
|
|
357
|
+
const { msgType, content } = entry();
|
|
358
|
+
await this.sendLarkMessage(externalChatId, msgType, content);
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Feishu accepts only OPUS for `msg_type: audio`. The connector vocabulary
|
|
362
|
+
* grants no process capability, so there is no transcoder/subprocess to
|
|
363
|
+
* call: non-opus audio is delivered as the original file (`msg_type: file`,
|
|
364
|
+
* original name) rather than being converted or silently dropped.
|
|
365
|
+
*/
|
|
366
|
+
deliveryTypeFor(filePath, type) {
|
|
367
|
+
if (type !== 'audio')
|
|
368
|
+
return type;
|
|
369
|
+
const ext = filePath.split('.').pop()?.toLowerCase();
|
|
370
|
+
return ext === 'opus' ? 'audio' : 'file';
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Upload a local file to Feishu and return the platform key.
|
|
374
|
+
* Images → /im/v1/images, files/audio → /im/v1/files.
|
|
375
|
+
*
|
|
376
|
+
* Audio: only true OPUS input keeps `file_type: opus` / `msg_type: audio`;
|
|
377
|
+
* non-opus audio is uploaded as a plain file (see deliveryTypeFor).
|
|
378
|
+
*/
|
|
379
|
+
async uploadToFeishu(filePath, type, displayFileName) {
|
|
380
|
+
const token = await this.tokenManager?.getTenantAccessToken();
|
|
381
|
+
if (!token) {
|
|
382
|
+
this.log.warn({ filePath, type }, '[FeishuAdapter] uploadToFeishu: no tenant access token');
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
const form = new FormData();
|
|
386
|
+
if (type === 'image') {
|
|
387
|
+
form.append('image_type', 'message');
|
|
388
|
+
form.append('image', await openAsBlob(filePath));
|
|
389
|
+
const res = await this.uploadFetchFn('https://open.feishu.cn/open-apis/im/v1/images', {
|
|
390
|
+
method: 'POST',
|
|
391
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
392
|
+
body: form,
|
|
393
|
+
});
|
|
394
|
+
if (!res.ok) {
|
|
395
|
+
const body = await res.text().catch(() => '(unreadable)');
|
|
396
|
+
this.log.warn({ status: res.status, body, filePath }, '[FeishuAdapter] uploadToFeishu image upload failed');
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
const data = (await res.json());
|
|
400
|
+
const imageKey = data.data?.image_key;
|
|
401
|
+
return imageKey ? { imageKey } : null;
|
|
402
|
+
}
|
|
403
|
+
// Audio delivery means deliveryTypeFor verified an .opus source path, but
|
|
404
|
+
// the Host may supply a display name without an extension — Feishu rejects
|
|
405
|
+
// a file_type/file_name mismatch, so the name must also end in .opus.
|
|
406
|
+
let fileName = displayFileName ?? filePath.split('/').pop() ?? 'file';
|
|
407
|
+
if (type === 'audio' && !fileName.toLowerCase().endsWith('.opus')) {
|
|
408
|
+
fileName = `${fileName}.opus`;
|
|
409
|
+
}
|
|
410
|
+
const fileType = type === 'audio' ? 'opus' : inferFeishuFileType(fileName);
|
|
411
|
+
form.append('file_type', fileType);
|
|
412
|
+
form.append('file_name', fileName);
|
|
413
|
+
form.append('file', await openAsBlob(filePath));
|
|
414
|
+
const res = await this.uploadFetchFn('https://open.feishu.cn/open-apis/im/v1/files', {
|
|
415
|
+
method: 'POST',
|
|
416
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
417
|
+
body: form,
|
|
418
|
+
});
|
|
419
|
+
if (!res.ok) {
|
|
420
|
+
const body = await res.text().catch(() => '(unreadable)');
|
|
421
|
+
this.log.warn({ status: res.status, body, filePath, fileType }, '[FeishuAdapter] uploadToFeishu file upload failed');
|
|
422
|
+
return null;
|
|
423
|
+
}
|
|
424
|
+
const data = (await res.json());
|
|
425
|
+
const fileKey = data.data?.file_key;
|
|
426
|
+
return fileKey ? { fileKey } : null;
|
|
427
|
+
}
|
|
428
|
+
setBotOpenId(openId) {
|
|
429
|
+
this.botOpenId = openId;
|
|
430
|
+
}
|
|
431
|
+
getBotOpenId() {
|
|
432
|
+
return this.botOpenId;
|
|
433
|
+
}
|
|
434
|
+
async resolveSenderName(openId) {
|
|
435
|
+
const cached = this.senderNameCache.get(openId);
|
|
436
|
+
if (cached && cached.expiresAt > Date.now())
|
|
437
|
+
return cached.name;
|
|
438
|
+
const token = await this.tokenManager?.getTenantAccessToken();
|
|
439
|
+
if (!token) {
|
|
440
|
+
this.log.warn({ openId }, '[FeishuAdapter] resolveSenderName: no tenant access token');
|
|
441
|
+
return undefined;
|
|
442
|
+
}
|
|
443
|
+
try {
|
|
444
|
+
const res = await (this.uploadFetchFn ?? globalThis.fetch)(`https://open.feishu.cn/open-apis/contact/v3/users/${openId}?user_id_type=open_id`, { headers: { Authorization: `Bearer ${token}` } });
|
|
445
|
+
if (!res.ok) {
|
|
446
|
+
this.log.warn({ openId, status: res.status }, '[FeishuAdapter] resolveSenderName failed — check contact:user.base:readonly scope');
|
|
447
|
+
return undefined;
|
|
448
|
+
}
|
|
449
|
+
const data = (await res.json());
|
|
450
|
+
const name = data?.data?.user?.name;
|
|
451
|
+
if (name) {
|
|
452
|
+
this.senderNameCache.set(openId, { name, expiresAt: Date.now() + FeishuAdapter.CACHE_TTL_MS });
|
|
453
|
+
}
|
|
454
|
+
return name;
|
|
455
|
+
}
|
|
456
|
+
catch (err) {
|
|
457
|
+
this.log.warn({ openId, err }, '[FeishuAdapter] resolveSenderName threw');
|
|
458
|
+
return undefined;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Fallback: resolve sender name from the chat members API.
|
|
463
|
+
* Works even when Contact API returns 41050 (user not in app's contact scope).
|
|
464
|
+
* Uses the same senderNameCache as resolveSenderName().
|
|
465
|
+
*/
|
|
466
|
+
async resolveSenderNameFromChat(openId, chatId) {
|
|
467
|
+
// Check cache first (shared with resolveSenderName)
|
|
468
|
+
const cached = this.senderNameCache.get(openId);
|
|
469
|
+
if (cached && cached.expiresAt > Date.now())
|
|
470
|
+
return cached.name;
|
|
471
|
+
const token = await this.tokenManager?.getTenantAccessToken();
|
|
472
|
+
if (!token)
|
|
473
|
+
return undefined;
|
|
474
|
+
try {
|
|
475
|
+
const fetchFn = this.uploadFetchFn ?? globalThis.fetch;
|
|
476
|
+
let pageToken;
|
|
477
|
+
// Paginate through all members (page_size=50 per request)
|
|
478
|
+
do {
|
|
479
|
+
const url = new URL(`https://open.feishu.cn/open-apis/im/v1/chats/${chatId}/members`);
|
|
480
|
+
url.searchParams.set('member_id_type', 'open_id');
|
|
481
|
+
url.searchParams.set('page_size', '50');
|
|
482
|
+
if (pageToken)
|
|
483
|
+
url.searchParams.set('page_token', pageToken);
|
|
484
|
+
const res = await fetchFn(url.toString(), {
|
|
485
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
486
|
+
});
|
|
487
|
+
if (!res.ok) {
|
|
488
|
+
this.log.warn({ chatId, status: res.status }, '[FeishuAdapter] resolveSenderNameFromChat: chat members API failed');
|
|
489
|
+
return undefined;
|
|
490
|
+
}
|
|
491
|
+
const data = (await res.json());
|
|
492
|
+
const members = data?.data?.items;
|
|
493
|
+
if (!members)
|
|
494
|
+
return undefined;
|
|
495
|
+
// Cache ALL members from this page (amortize the API call)
|
|
496
|
+
for (const m of members) {
|
|
497
|
+
if (m.member_id && m.name) {
|
|
498
|
+
this.senderNameCache.set(m.member_id, {
|
|
499
|
+
name: m.name,
|
|
500
|
+
expiresAt: Date.now() + FeishuAdapter.CACHE_TTL_MS,
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
// If target found in cache, return immediately — skip remaining pages
|
|
505
|
+
const found = this.senderNameCache.get(openId);
|
|
506
|
+
if (found)
|
|
507
|
+
return found.name;
|
|
508
|
+
pageToken = data?.data?.has_more ? data.data.page_token : undefined;
|
|
509
|
+
} while (pageToken);
|
|
510
|
+
// Exhausted all pages without finding target
|
|
511
|
+
return undefined;
|
|
512
|
+
}
|
|
513
|
+
catch (err) {
|
|
514
|
+
this.log.warn({ chatId, openId, err }, '[FeishuAdapter] resolveSenderNameFromChat threw');
|
|
515
|
+
return undefined;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
async resolveChatName(chatId) {
|
|
519
|
+
const cached = this.chatNameCache.get(chatId);
|
|
520
|
+
if (cached && cached.expiresAt > Date.now())
|
|
521
|
+
return cached.name;
|
|
522
|
+
const token = await this.tokenManager?.getTenantAccessToken();
|
|
523
|
+
if (!token)
|
|
524
|
+
return undefined;
|
|
525
|
+
try {
|
|
526
|
+
const res = await (this.uploadFetchFn ?? globalThis.fetch)(`https://open.feishu.cn/open-apis/im/v1/chats/${chatId}`, { headers: { Authorization: `Bearer ${token}` } });
|
|
527
|
+
if (!res.ok)
|
|
528
|
+
return undefined;
|
|
529
|
+
const data = (await res.json());
|
|
530
|
+
const name = data?.data?.name;
|
|
531
|
+
if (name) {
|
|
532
|
+
this.chatNameCache.set(chatId, { name, expiresAt: Date.now() + FeishuAdapter.CACHE_TTL_MS });
|
|
533
|
+
}
|
|
534
|
+
return name;
|
|
535
|
+
}
|
|
536
|
+
catch {
|
|
537
|
+
return undefined;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
async resolveChatType(chatId) {
|
|
541
|
+
const cached = this.chatTypeCache.get(chatId);
|
|
542
|
+
if (cached && cached.expiresAt > Date.now())
|
|
543
|
+
return cached.chatType;
|
|
544
|
+
try {
|
|
545
|
+
const token = await this.tokenManager?.getTenantAccessToken();
|
|
546
|
+
if (!token)
|
|
547
|
+
return undefined;
|
|
548
|
+
const res = await (this.uploadFetchFn ?? globalThis.fetch)(`https://open.feishu.cn/open-apis/im/v1/chats/${chatId}`, { headers: { Authorization: `Bearer ${token}` } });
|
|
549
|
+
if (!res.ok)
|
|
550
|
+
return undefined;
|
|
551
|
+
const data = (await res.json());
|
|
552
|
+
const mode = data?.data?.chat_mode;
|
|
553
|
+
if (mode === 'p2p' || mode === 'group') {
|
|
554
|
+
this.chatTypeCache.set(chatId, { chatType: mode, expiresAt: Date.now() + FeishuAdapter.CACHE_TTL_MS });
|
|
555
|
+
return mode;
|
|
556
|
+
}
|
|
557
|
+
return undefined;
|
|
558
|
+
}
|
|
559
|
+
catch {
|
|
560
|
+
return undefined;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
prependAtMention(text, sender) {
|
|
564
|
+
if (!sender)
|
|
565
|
+
return text;
|
|
566
|
+
return `<at user_id="${sender.id}">${sender.name ?? '用户'}</at> ${text}`;
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* #1035: Resolve `@alias` patterns in outbound body text to Feishu `<at>` tokens.
|
|
570
|
+
*
|
|
571
|
+
* - `text` mode → `<at user_id="ou_xxx">@displayName</at>` (for `msg_type: text`)
|
|
572
|
+
* - `card_markdown` mode → `<at id=ou_xxx></at>` (Feishu auto-renders display name in cards)
|
|
573
|
+
*
|
|
574
|
+
* Only matches `@` preceded by start-of-string or a delimiter (whitespace, brackets,
|
|
575
|
+
* Chinese open-quotes, full-width space), so `user@example.com` is NOT replaced.
|
|
576
|
+
* Unknown aliases pass through unchanged.
|
|
577
|
+
* Aliases are matched on bare unicode letters/digits/underscore (no leading `@` in the key).
|
|
578
|
+
*/
|
|
579
|
+
resolveBodyMentions(text, mode) {
|
|
580
|
+
if (!text)
|
|
581
|
+
return text;
|
|
582
|
+
const aliases = this.groupBotMentions;
|
|
583
|
+
// Fast-path: empty config (most common when feature unused)
|
|
584
|
+
let hasAny = false;
|
|
585
|
+
for (const _ in aliases) {
|
|
586
|
+
hasAny = true;
|
|
587
|
+
break;
|
|
588
|
+
}
|
|
589
|
+
if (!hasAny)
|
|
590
|
+
return text;
|
|
591
|
+
const pattern = /(^|[\s([【「『 ])@([\p{L}\p{N}_]+)/gu;
|
|
592
|
+
return text.replace(pattern, (match, prefix, alias) => {
|
|
593
|
+
// Own-property check guards against Object.prototype keys (constructor / toString /
|
|
594
|
+
// hasOwnProperty / …) that would otherwise resolve to truthy inherited members and
|
|
595
|
+
// emit `<at id=undefined></at>`. parseGroupBotMentionsEnv uses Object.create(null)
|
|
596
|
+
// already, but callers may pass plain-object maps directly via FeishuAdapterOptions.
|
|
597
|
+
// (Reviewer cloud-Codex P2 R3, cat-cafe#2611.)
|
|
598
|
+
if (!Object.hasOwn(aliases, alias))
|
|
599
|
+
return match;
|
|
600
|
+
const entry = aliases[alias];
|
|
601
|
+
if (!entry)
|
|
602
|
+
return match; // unknown alias passes through
|
|
603
|
+
if (mode === 'text') {
|
|
604
|
+
// F134-feishu-group-chat.md line 730 + prependAtMention (line 720) both render the
|
|
605
|
+
// bare display name inside the tag; Feishu adds the visible `@` itself. Adding our
|
|
606
|
+
// own `@` here would break protocol shape and the platform may fail to resolve the
|
|
607
|
+
// mention. (Reviewer P1, cat-cafe#2611.)
|
|
608
|
+
const name = entry.displayName ?? alias;
|
|
609
|
+
return `${prefix}<at user_id="${entry.openId}">${name}</at>`;
|
|
610
|
+
}
|
|
611
|
+
// card_markdown: Feishu renders display name from contact resolution
|
|
612
|
+
return `${prefix}<at id=${entry.openId}></at>`;
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
async sendReply(externalChatId, content, metadata) {
|
|
616
|
+
const sender = metadata?.replyToSender;
|
|
617
|
+
const text = this.resolveBodyMentions(this.prependAtMention(content, sender), 'text');
|
|
618
|
+
await this.sendLarkMessage(externalChatId, 'text', JSON.stringify({ text }));
|
|
619
|
+
}
|
|
620
|
+
async sendRichMessage(externalChatId, textContent, blocks, catDisplayName, metadata) {
|
|
621
|
+
const sender = metadata?.replyToSender;
|
|
622
|
+
const text = this.resolveBodyMentions(this.prependAtMention(textContent, sender), 'card_markdown');
|
|
623
|
+
const card = formatFeishuCard(blocks, catDisplayName, text);
|
|
624
|
+
await this.sendLarkMessage(externalChatId, 'interactive', JSON.stringify(card));
|
|
625
|
+
}
|
|
626
|
+
async sendFormattedReply(externalChatId, envelope, metadata) {
|
|
627
|
+
const isCallback = envelope.origin === 'callback';
|
|
628
|
+
const headerTitle = isCallback ? `📨 ${envelope.header} · 传话` : envelope.header;
|
|
629
|
+
const headerTemplate = isCallback ? 'purple' : 'blue';
|
|
630
|
+
const sender = metadata?.replyToSender;
|
|
631
|
+
const atPrefix = sender ? `<at id=${sender.id}>${sender.name ?? '用户'}</at> ` : '';
|
|
632
|
+
const elements = [];
|
|
633
|
+
if (envelope.subtitle) {
|
|
634
|
+
elements.push({ tag: 'markdown', content: `**${envelope.subtitle}**` });
|
|
635
|
+
}
|
|
636
|
+
elements.push({
|
|
637
|
+
tag: 'markdown',
|
|
638
|
+
content: `${atPrefix}${this.resolveBodyMentions(envelope.body, 'card_markdown')}`,
|
|
639
|
+
});
|
|
640
|
+
if (envelope.footer) {
|
|
641
|
+
elements.push({ tag: 'hr' });
|
|
642
|
+
elements.push({ tag: 'markdown', content: envelope.footer });
|
|
643
|
+
}
|
|
644
|
+
const actions = envelope.cardActions?.length ? envelope.cardActions : DEFAULT_QUICK_ACTIONS;
|
|
645
|
+
if (actions.length) {
|
|
646
|
+
elements.push({ tag: 'hr' });
|
|
647
|
+
const options = actions.map((a) => {
|
|
648
|
+
const v = a.value;
|
|
649
|
+
const optVal = v.cmd ? (v.args ? `${v.cmd} ${v.args}` : v.cmd) : JSON.stringify(a.value);
|
|
650
|
+
return { text: { tag: 'plain_text', content: a.label }, value: optVal };
|
|
651
|
+
});
|
|
652
|
+
elements.push({
|
|
653
|
+
tag: 'action',
|
|
654
|
+
actions: [
|
|
655
|
+
{
|
|
656
|
+
tag: 'select_static',
|
|
657
|
+
placeholder: { tag: 'plain_text', content: '选择操作...' },
|
|
658
|
+
options,
|
|
659
|
+
},
|
|
660
|
+
],
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
const card = {
|
|
664
|
+
header: {
|
|
665
|
+
title: { tag: 'plain_text', content: headerTitle },
|
|
666
|
+
template: headerTemplate,
|
|
667
|
+
},
|
|
668
|
+
elements,
|
|
669
|
+
};
|
|
670
|
+
await this.sendLarkMessage(externalChatId, 'interactive', JSON.stringify(card));
|
|
671
|
+
}
|
|
672
|
+
async sendPlaceholder(externalChatId, text) {
|
|
673
|
+
const card = {
|
|
674
|
+
config: { update_multi: true },
|
|
675
|
+
header: { title: { tag: 'plain_text', content: text }, template: 'grey' },
|
|
676
|
+
elements: [{ tag: 'markdown', content: '...' }],
|
|
677
|
+
};
|
|
678
|
+
const result = await this.sendLarkMessage(externalChatId, 'interactive', JSON.stringify(card));
|
|
679
|
+
const data = result;
|
|
680
|
+
return data?.data?.message_id ?? data?.message_id ?? '';
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* Edit an already-sent message card in place.
|
|
684
|
+
* Uses Lark im.message.patch API — only supports interactive (card) messages.
|
|
685
|
+
* The text is rendered as markdown inside the card body.
|
|
686
|
+
*/
|
|
687
|
+
async editMessage(_externalChatId, platformMessageId, text) {
|
|
688
|
+
if (this.editMessageFn) {
|
|
689
|
+
await this.editMessageFn({ messageId: platformMessageId, content: text });
|
|
690
|
+
return true;
|
|
691
|
+
}
|
|
692
|
+
const card = {
|
|
693
|
+
config: { update_multi: true },
|
|
694
|
+
header: { title: { tag: 'plain_text', content: '🐱 回复中...' }, template: 'blue' },
|
|
695
|
+
elements: [{ tag: 'markdown', content: text }],
|
|
696
|
+
};
|
|
697
|
+
await this.client.im.message.patch({
|
|
698
|
+
path: { message_id: platformMessageId },
|
|
699
|
+
data: {
|
|
700
|
+
content: JSON.stringify(card),
|
|
701
|
+
},
|
|
702
|
+
});
|
|
703
|
+
return true;
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* Delete a message by its platform message ID.
|
|
707
|
+
* Used to clean up streaming placeholder cards after final outbound delivery.
|
|
708
|
+
*/
|
|
709
|
+
async deleteMessage(platformMessageId) {
|
|
710
|
+
if (this.deleteMessageFn) {
|
|
711
|
+
await this.deleteMessageFn({ messageId: platformMessageId });
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
await this.client.im.message.delete({
|
|
715
|
+
path: { message_id: platformMessageId },
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* F157: Add an emoji reaction to a message (e.g. ❤️ on user's inbound message).
|
|
720
|
+
* Fire-and-forget — errors are logged but never thrown.
|
|
721
|
+
*/
|
|
722
|
+
async addReaction(platformMessageId, emojiType) {
|
|
723
|
+
if (this.addReactionFn) {
|
|
724
|
+
await this.addReactionFn({ messageId: platformMessageId, emojiType });
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
try {
|
|
728
|
+
await this.client.im.messageReaction.create({
|
|
729
|
+
path: { message_id: platformMessageId },
|
|
730
|
+
data: { reaction_type: { emoji_type: emojiType } },
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
catch (err) {
|
|
734
|
+
this.log.warn({ err, platformMessageId, emojiType }, '[FeishuAdapter] addReaction failed (non-fatal)');
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* F157: Edit a streaming card to a truthful terminal state.
|
|
739
|
+
* Preferred over deleteMessage to avoid Feishu's "recalled" notification.
|
|
740
|
+
*/
|
|
741
|
+
async finalizeStreamCard(_externalChatId, platformMessageId, catDisplayName, outcome = 'completed') {
|
|
742
|
+
const title = outcome === 'completed'
|
|
743
|
+
? `✅ ${catDisplayName || '猫猫'}已回复`
|
|
744
|
+
: outcome === 'failed'
|
|
745
|
+
? `⚠️ ${catDisplayName || '猫猫'}未能完成回复`
|
|
746
|
+
: `⏹️ ${catDisplayName || '猫猫'}已取消回复`;
|
|
747
|
+
const card = {
|
|
748
|
+
config: { update_multi: true },
|
|
749
|
+
header: {
|
|
750
|
+
title: { tag: 'plain_text', content: title },
|
|
751
|
+
template: (outcome === 'completed' ? 'green' : 'grey'),
|
|
752
|
+
},
|
|
753
|
+
elements: [],
|
|
754
|
+
};
|
|
755
|
+
if (this.editMessageFn) {
|
|
756
|
+
await this.editMessageFn({ messageId: platformMessageId, content: JSON.stringify(card) });
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
await this.client.im.message.patch({
|
|
760
|
+
path: { message_id: platformMessageId },
|
|
761
|
+
data: { content: JSON.stringify(card) },
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
/**
|
|
765
|
+
* Test helper: inject a mock send function.
|
|
766
|
+
* @internal
|
|
767
|
+
*/
|
|
768
|
+
_injectSendMessage(fn) {
|
|
769
|
+
this.sendMessageFn = fn;
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* Test helper: inject a mock edit function.
|
|
773
|
+
* @internal
|
|
774
|
+
*/
|
|
775
|
+
_injectEditMessage(fn) {
|
|
776
|
+
this.editMessageFn = fn;
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* Test helper: inject a mock delete function.
|
|
780
|
+
* @internal
|
|
781
|
+
*/
|
|
782
|
+
_injectDeleteMessage(fn) {
|
|
783
|
+
this.deleteMessageFn = fn;
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* Test helper: inject a mock addReaction function.
|
|
787
|
+
* @internal
|
|
788
|
+
*/
|
|
789
|
+
_injectAddReaction(fn) {
|
|
790
|
+
this.addReactionFn = fn;
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* Test helper: inject a FeishuTokenManager.
|
|
794
|
+
* @internal
|
|
795
|
+
*/
|
|
796
|
+
_injectTokenManager(tm) {
|
|
797
|
+
this.tokenManager = tm;
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* Test helper: inject a mock fetch for upload APIs.
|
|
801
|
+
* @internal
|
|
802
|
+
*/
|
|
803
|
+
_injectUploadFetch(fn) {
|
|
804
|
+
this.uploadFetchFn = fn;
|
|
805
|
+
}
|
|
806
|
+
/** @internal */
|
|
807
|
+
_injectInboundFetch(fn) {
|
|
808
|
+
this.inboundFetchFn = fn;
|
|
809
|
+
}
|
|
810
|
+
/** @internal */
|
|
811
|
+
_injectInboundMediaTimeout(timeoutMs) {
|
|
812
|
+
this.inboundMediaTimeoutMs = timeoutMs;
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* Test helper: clear TTL caches.
|
|
816
|
+
* @internal
|
|
817
|
+
*/
|
|
818
|
+
_clearCaches() {
|
|
819
|
+
this.senderNameCache.clear();
|
|
820
|
+
this.chatNameCache.clear();
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* Phase J: Map file extension to Feishu file_type for native preview.
|
|
825
|
+
* Feishu recognizes: pdf, doc, xls, ppt, mp4, opus, stream (catch-all).
|
|
826
|
+
*/
|
|
827
|
+
const FEISHU_EXT_TO_FILE_TYPE = {
|
|
828
|
+
pdf: 'pdf',
|
|
829
|
+
doc: 'doc',
|
|
830
|
+
docx: 'doc',
|
|
831
|
+
xls: 'xls',
|
|
832
|
+
xlsx: 'xls',
|
|
833
|
+
ppt: 'ppt',
|
|
834
|
+
pptx: 'ppt',
|
|
835
|
+
mp4: 'mp4',
|
|
836
|
+
};
|
|
837
|
+
export function inferFeishuFileType(fileName) {
|
|
838
|
+
const ext = fileName.split('.').pop()?.toLowerCase() ?? '';
|
|
839
|
+
// Object.hasOwn, not a bare subscript: `??` only guards undefined, while a
|
|
840
|
+
// bare lookup walks the prototype chain — ext 'constructor' or '__proto__'
|
|
841
|
+
// (both reachable from a CDN-controlled URL path segment) would resolve to
|
|
842
|
+
// Object members and be sent to Feishu as a garbage file_type.
|
|
843
|
+
return Object.hasOwn(FEISHU_EXT_TO_FILE_TYPE, ext) ? FEISHU_EXT_TO_FILE_TYPE[ext] : 'stream';
|
|
844
|
+
}
|
|
845
|
+
//# sourceMappingURL=FeishuAdapter.js.map
|