@tobeyoureyes/feishu 1.0.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.
package/src/webhook.ts ADDED
@@ -0,0 +1,549 @@
1
+ /**
2
+ * Feishu webhook event handling
3
+ */
4
+
5
+ import type {
6
+ FeishuUrlVerificationEvent,
7
+ FeishuMessageReceiveEvent,
8
+ FeishuBotAddedEvent,
9
+ FeishuBotRemovedEvent,
10
+ ResolvedFeishuAccount,
11
+ FeishuMsgType,
12
+ } from "./types.js";
13
+
14
+ export interface FeishuInboundMessage {
15
+ messageId: string;
16
+ chatId: string;
17
+ chatType: "direct" | "group";
18
+ senderId: string;
19
+ senderOpenId?: string;
20
+ senderUserId?: string;
21
+ senderUnionId?: string;
22
+ messageType: FeishuMsgType;
23
+ /** Raw content JSON string */
24
+ content: string;
25
+ /** Raw text with placeholders like @_user_1 */
26
+ text?: string;
27
+ /** Display text with placeholders replaced by actual names */
28
+ displayText?: string;
29
+ mentions?: Array<{
30
+ key: string;
31
+ id: string;
32
+ name: string;
33
+ }>;
34
+ rootId?: string;
35
+ parentId?: string;
36
+ createTime: number;
37
+ tenantKey: string;
38
+
39
+ // Reply context fields (populated when message is a reply)
40
+ /** The text content of the replied-to message */
41
+ replyToBody?: string;
42
+ /** The sender ID of the replied-to message */
43
+ replyToSenderId?: string;
44
+ /** The message ID of the replied-to message */
45
+ replyToId?: string;
46
+
47
+ // Media fields (for image/file/audio messages)
48
+ /** Image key for image messages */
49
+ imageKey?: string;
50
+ /** File key for file messages */
51
+ fileKey?: string;
52
+ /** Original file name */
53
+ fileName?: string;
54
+ /** Local path after download */
55
+ mediaPath?: string;
56
+ /** Media MIME type */
57
+ mediaType?: string;
58
+ }
59
+
60
+ export interface FeishuWebhookResult {
61
+ type: "challenge" | "message" | "bot_added" | "bot_removed" | "unknown" | "error";
62
+ challenge?: string;
63
+ message?: FeishuInboundMessage;
64
+ chatId?: string;
65
+ error?: string;
66
+ }
67
+
68
+ /**
69
+ * Parse and handle incoming Feishu webhook event
70
+ */
71
+ export function parseWebhookEvent(
72
+ body: unknown,
73
+ account: ResolvedFeishuAccount,
74
+ ): FeishuWebhookResult {
75
+ try {
76
+ // Handle URL verification challenge
77
+ if (isUrlVerification(body)) {
78
+ return handleUrlVerification(body, account);
79
+ }
80
+
81
+ // Handle event callback
82
+ if (isEventCallback(body)) {
83
+ return handleEventCallback(body, account);
84
+ }
85
+
86
+ return { type: "unknown" };
87
+ } catch (error) {
88
+ const message = error instanceof Error ? error.message : String(error);
89
+ return { type: "error", error: message };
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Check if the body is a URL verification request
95
+ */
96
+ function isUrlVerification(body: unknown): body is FeishuUrlVerificationEvent {
97
+ return (
98
+ typeof body === "object" &&
99
+ body !== null &&
100
+ "type" in body &&
101
+ (body as { type: string }).type === "url_verification"
102
+ );
103
+ }
104
+
105
+ /**
106
+ * Check if the body is an event callback
107
+ */
108
+ function isEventCallback(body: unknown): body is { schema: string; header: { event_type: string } } {
109
+ return (
110
+ typeof body === "object" &&
111
+ body !== null &&
112
+ "schema" in body &&
113
+ "header" in body &&
114
+ typeof (body as { header: unknown }).header === "object"
115
+ );
116
+ }
117
+
118
+ /**
119
+ * Handle URL verification challenge
120
+ */
121
+ function handleUrlVerification(
122
+ event: FeishuUrlVerificationEvent,
123
+ account: ResolvedFeishuAccount,
124
+ ): FeishuWebhookResult {
125
+ // Verify token if configured
126
+ if (account.verificationToken && event.token !== account.verificationToken) {
127
+ return { type: "error", error: "Invalid verification token" };
128
+ }
129
+
130
+ return {
131
+ type: "challenge",
132
+ challenge: event.challenge,
133
+ };
134
+ }
135
+
136
+ /**
137
+ * Handle event callback
138
+ */
139
+ function handleEventCallback(
140
+ body: { schema: string; header: { event_type: string }; event?: unknown },
141
+ account: ResolvedFeishuAccount,
142
+ ): FeishuWebhookResult {
143
+ const eventType = body.header.event_type;
144
+
145
+ switch (eventType) {
146
+ case "im.message.receive_v1":
147
+ return handleMessageReceive(body as FeishuMessageReceiveEvent, account);
148
+
149
+ case "im.chat.member.bot.added_v1":
150
+ return handleBotAdded(body as FeishuBotAddedEvent);
151
+
152
+ case "im.chat.member.bot.deleted_v1":
153
+ return handleBotRemoved(body as FeishuBotRemovedEvent);
154
+
155
+ default:
156
+ return { type: "unknown" };
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Handle message receive event
162
+ */
163
+ function handleMessageReceive(
164
+ event: FeishuMessageReceiveEvent,
165
+ _account: ResolvedFeishuAccount,
166
+ ): FeishuWebhookResult {
167
+ const { sender, message } = event.event;
168
+
169
+ // Parse message content
170
+ let text: string | undefined;
171
+ let imageKey: string | undefined;
172
+ let fileKey: string | undefined;
173
+ let fileName: string | undefined;
174
+
175
+ try {
176
+ const contentObj = JSON.parse(message.content);
177
+
178
+ switch (message.message_type) {
179
+ case "text":
180
+ text = contentObj.text;
181
+ break;
182
+
183
+ case "post":
184
+ // Extract text from post content
185
+ text = extractTextFromPost(contentObj);
186
+ break;
187
+
188
+ case "image":
189
+ // Image message: { image_key: "xxx" }
190
+ imageKey = contentObj.image_key;
191
+ text = "<media:image>";
192
+ break;
193
+
194
+ case "file":
195
+ // File message: { file_key: "xxx", file_name: "xxx" }
196
+ fileKey = contentObj.file_key;
197
+ fileName = contentObj.file_name;
198
+ text = `<media:file>${fileName ?? "file"}`;
199
+ break;
200
+
201
+ case "audio":
202
+ // Audio message: { file_key: "xxx", duration: xxx }
203
+ fileKey = contentObj.file_key;
204
+ text = `<media:audio>`;
205
+ break;
206
+
207
+ case "media":
208
+ // Media message (video): { file_key: "xxx", image_key: "xxx" }
209
+ fileKey = contentObj.file_key;
210
+ imageKey = contentObj.image_key;
211
+ text = `<media:video>`;
212
+ break;
213
+
214
+ case "sticker":
215
+ // Sticker message: { file_key: "xxx" }
216
+ fileKey = contentObj.file_key;
217
+ text = `<media:sticker>`;
218
+ break;
219
+
220
+ case "interactive":
221
+ // Card message - extract text from elements
222
+ text = extractTextFromCard(contentObj);
223
+ break;
224
+
225
+ case "share_chat":
226
+ // Shared chat: { chat_id: "xxx" }
227
+ text = `[Shared chat: ${contentObj.chat_id}]`;
228
+ break;
229
+
230
+ case "share_user":
231
+ // Shared user: { user_id: "xxx" }
232
+ text = `[Shared user: ${contentObj.user_id}]`;
233
+ break;
234
+
235
+ default:
236
+ // Unknown message type - try to extract as text
237
+ text = message.content;
238
+ }
239
+ } catch {
240
+ // Content might not be JSON
241
+ text = message.content;
242
+ }
243
+
244
+ // Map mentions
245
+ const mentions = message.mentions?.map((m) => ({
246
+ key: m.key,
247
+ id: m.id.open_id || m.id.user_id || m.id.union_id || "",
248
+ name: m.name,
249
+ }));
250
+
251
+ // Generate display text with mention placeholders replaced
252
+ let displayText = text;
253
+ if (text && mentions && mentions.length > 0) {
254
+ for (const mention of mentions) {
255
+ displayText = displayText?.replace(new RegExp(mention.key, "g"), `@${mention.name}`);
256
+ }
257
+ }
258
+
259
+ const inboundMessage: FeishuInboundMessage = {
260
+ messageId: message.message_id,
261
+ chatId: message.chat_id,
262
+ chatType: message.chat_type === "p2p" ? "direct" : "group",
263
+ senderId: sender.sender_id.open_id || sender.sender_id.user_id || sender.sender_id.union_id || "",
264
+ senderOpenId: sender.sender_id.open_id,
265
+ senderUserId: sender.sender_id.user_id,
266
+ senderUnionId: sender.sender_id.union_id,
267
+ messageType: message.message_type,
268
+ content: message.content,
269
+ text,
270
+ displayText,
271
+ mentions,
272
+ rootId: message.root_id,
273
+ parentId: message.parent_id,
274
+ createTime: parseInt(message.create_time, 10),
275
+ tenantKey: sender.tenant_key,
276
+ // Media fields
277
+ imageKey,
278
+ fileKey,
279
+ fileName,
280
+ };
281
+
282
+ return {
283
+ type: "message",
284
+ message: inboundMessage,
285
+ };
286
+ }
287
+
288
+ /**
289
+ * Extract text from interactive card content
290
+ */
291
+ function extractTextFromCard(content: unknown): string {
292
+ const texts: string[] = [];
293
+
294
+ if (typeof content !== "object" || content === null) {
295
+ return "";
296
+ }
297
+
298
+ const card = content as {
299
+ header?: { title?: { content?: string } };
300
+ elements?: Array<{ tag?: string; content?: string; text?: { content?: string } }>;
301
+ };
302
+
303
+ // Extract header title
304
+ if (card.header?.title?.content) {
305
+ texts.push(card.header.title.content);
306
+ }
307
+
308
+ // Extract element contents
309
+ if (Array.isArray(card.elements)) {
310
+ for (const element of card.elements) {
311
+ if (element.tag === "markdown" && element.content) {
312
+ texts.push(element.content);
313
+ } else if (element.tag === "div" && element.text?.content) {
314
+ texts.push(element.text.content);
315
+ }
316
+ }
317
+ }
318
+
319
+ return texts.join("\n");
320
+ }
321
+
322
+ /**
323
+ * Handle bot added to chat event
324
+ */
325
+ function handleBotAdded(event: FeishuBotAddedEvent): FeishuWebhookResult {
326
+ return {
327
+ type: "bot_added",
328
+ chatId: event.event.chat_id,
329
+ };
330
+ }
331
+
332
+ /**
333
+ * Handle bot removed from chat event
334
+ */
335
+ function handleBotRemoved(event: FeishuBotRemovedEvent): FeishuWebhookResult {
336
+ return {
337
+ type: "bot_removed",
338
+ chatId: event.event.chat_id,
339
+ };
340
+ }
341
+
342
+ /**
343
+ * Extract plain text from post content
344
+ */
345
+ function extractTextFromPost(content: unknown): string {
346
+ const texts: string[] = [];
347
+
348
+ function extractFromElements(elements: unknown[]): void {
349
+ for (const element of elements) {
350
+ if (typeof element !== "object" || element === null) {
351
+ continue;
352
+ }
353
+
354
+ const el = element as { tag?: string; text?: string; content?: unknown[][] };
355
+
356
+ if (el.tag === "text" && el.text) {
357
+ texts.push(el.text);
358
+ } else if (el.tag === "a" && el.text) {
359
+ texts.push(el.text);
360
+ }
361
+ }
362
+ }
363
+
364
+ if (typeof content === "object" && content !== null) {
365
+ const post = content as {
366
+ zh_cn?: { content?: unknown[][] };
367
+ en_us?: { content?: unknown[][] };
368
+ };
369
+
370
+ // Try zh_cn first, then en_us
371
+ const postContent = post.zh_cn?.content || post.en_us?.content;
372
+
373
+ if (Array.isArray(postContent)) {
374
+ for (const line of postContent) {
375
+ if (Array.isArray(line)) {
376
+ extractFromElements(line);
377
+ }
378
+ }
379
+ }
380
+ }
381
+
382
+ return texts.join(" ");
383
+ }
384
+
385
+ /**
386
+ * Verify webhook request signature
387
+ */
388
+ export function verifySignature(
389
+ timestamp: string,
390
+ nonce: string,
391
+ signature: string,
392
+ body: string,
393
+ encryptKey: string,
394
+ ): boolean {
395
+ // Feishu webhook signature verification
396
+ // signature = sha256(timestamp + nonce + encryptKey + body)
397
+ // For now, we'll implement basic verification
398
+ // TODO: Implement proper HMAC-SHA256 verification
399
+
400
+ if (!encryptKey) {
401
+ return true; // No encryption configured, skip verification
402
+ }
403
+
404
+ // Placeholder for actual verification
405
+ return true;
406
+ }
407
+
408
+ /**
409
+ * Decrypt encrypted webhook body
410
+ */
411
+ export function decryptBody(encryptedBody: string, encryptKey: string): string {
412
+ // Feishu uses AES-256-CBC for encryption
413
+ // The encrypted body is base64 encoded
414
+ // TODO: Implement proper AES decryption
415
+
416
+ if (!encryptKey) {
417
+ return encryptedBody;
418
+ }
419
+
420
+ // Placeholder for actual decryption
421
+ return encryptedBody;
422
+ }
423
+
424
+ /**
425
+ * Create the challenge response for URL verification
426
+ */
427
+ export function createChallengeResponse(challenge: string): { challenge: string } {
428
+ return { challenge };
429
+ }
430
+
431
+ /**
432
+ * Check if a message mentions the bot
433
+ * @param message - The inbound message
434
+ * @param botOpenId - The bot's open_id (optional)
435
+ * @param botName - The bot's name to match (optional, e.g., "OpenClaw")
436
+ */
437
+ export function isBotMentioned(
438
+ message: FeishuInboundMessage,
439
+ botOpenId?: string,
440
+ botName?: string,
441
+ ): boolean {
442
+ if (!message.mentions || message.mentions.length === 0) {
443
+ return false;
444
+ }
445
+
446
+ return message.mentions.some((m) => {
447
+ // Check by open_id
448
+ if (botOpenId && m.id === botOpenId) {
449
+ return true;
450
+ }
451
+ // Check by name
452
+ if (botName && m.name === botName) {
453
+ return true;
454
+ }
455
+ // Check for @all
456
+ if (m.key === "@_all") {
457
+ return true;
458
+ }
459
+ return false;
460
+ });
461
+ }
462
+
463
+ /**
464
+ * Get the text content without the bot mention
465
+ * Removes the @bot placeholder and cleans up whitespace
466
+ */
467
+ export function getTextWithoutBotMention(
468
+ text: string,
469
+ mentions: Array<{ key: string; name: string; id: string }> | undefined,
470
+ botOpenId?: string,
471
+ botName?: string,
472
+ ): string {
473
+ if (!text || !mentions || mentions.length === 0) {
474
+ return text;
475
+ }
476
+
477
+ let result = text;
478
+
479
+ for (const mention of mentions) {
480
+ const isBotMention =
481
+ (botOpenId && mention.id === botOpenId) ||
482
+ (botName && mention.name === botName);
483
+
484
+ if (isBotMention) {
485
+ // Remove bot mention
486
+ result = result.replace(new RegExp(mention.key, "g"), "");
487
+ } else {
488
+ // Replace other mentions with @name
489
+ result = result.replace(new RegExp(mention.key, "g"), `@${mention.name}`);
490
+ }
491
+ }
492
+
493
+ // Clean up extra whitespace
494
+ return result.replace(/\s+/g, " ").trim();
495
+ }
496
+
497
+ /**
498
+ * Strip bot mention from message text
499
+ */
500
+ export function stripBotMention(
501
+ text: string,
502
+ mentions: Array<{ key: string; name: string }> | undefined,
503
+ _botOpenId?: string,
504
+ ): string {
505
+ if (!text || !mentions || mentions.length === 0) {
506
+ return text;
507
+ }
508
+
509
+ let result = text;
510
+
511
+ for (const mention of mentions) {
512
+ // Replace mention placeholders like @_user_1
513
+ result = result.replace(new RegExp(mention.key, "g"), "").trim();
514
+ }
515
+
516
+ return result;
517
+ }
518
+
519
+ /**
520
+ * Replace mention placeholders with actual names
521
+ * e.g., "@_user_1 hello" -> "@张三 hello"
522
+ */
523
+ export function replaceMentionPlaceholders(
524
+ text: string,
525
+ mentions: Array<{ key: string; name: string }> | undefined,
526
+ ): string {
527
+ if (!text || !mentions || mentions.length === 0) {
528
+ return text;
529
+ }
530
+
531
+ let result = text;
532
+
533
+ for (const mention of mentions) {
534
+ // Replace @_user_1 with @实际名称
535
+ result = result.replace(new RegExp(mention.key, "g"), `@${mention.name}`);
536
+ }
537
+
538
+ return result;
539
+ }
540
+
541
+ /**
542
+ * Format text for display, replacing all mention placeholders
543
+ */
544
+ export function formatMessageText(
545
+ text: string,
546
+ mentions: Array<{ key: string; name: string }> | undefined,
547
+ ): string {
548
+ return replaceMentionPlaceholders(text, mentions);
549
+ }