@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/api.ts ADDED
@@ -0,0 +1,1160 @@
1
+ /**
2
+ * Feishu API wrapper for messaging
3
+ */
4
+
5
+ import type {
6
+ FeishuApiResponse,
7
+ FeishuSendMessageRequest,
8
+ FeishuSendMessageResponse,
9
+ FeishuReceiveIdType,
10
+ FeishuMsgType,
11
+ FeishuTextContent,
12
+ FeishuPostContent,
13
+ FeishuInteractiveContent,
14
+ ResolvedFeishuAccount,
15
+ FeishuSendResult,
16
+ FeishuSendOptions,
17
+ FeishuRenderMode,
18
+ } from "./types.js";
19
+ import { getTenantAccessToken, invalidateToken } from "./auth.js";
20
+
21
+ /** Get API base URL for an account */
22
+ function getApiBase(account: ResolvedFeishuAccount): string {
23
+ return account.apiBase || "https://open.feishu.cn/open-apis";
24
+ }
25
+
26
+ /**
27
+ * Make an authenticated API request to Feishu
28
+ */
29
+ async function feishuRequest<T>(
30
+ account: ResolvedFeishuAccount,
31
+ method: string,
32
+ endpoint: string,
33
+ body?: unknown,
34
+ queryParams?: Record<string, string>,
35
+ retryOnAuthError = true,
36
+ ): Promise<FeishuApiResponse<T>> {
37
+ const token = await getTenantAccessToken(account);
38
+ const apiBase = getApiBase(account);
39
+
40
+ let url = `${apiBase}${endpoint}`;
41
+ if (queryParams) {
42
+ const params = new URLSearchParams(queryParams);
43
+ url += `?${params.toString()}`;
44
+ }
45
+
46
+ const response = await fetch(url, {
47
+ method,
48
+ headers: {
49
+ Authorization: `Bearer ${token}`,
50
+ "Content-Type": "application/json; charset=utf-8",
51
+ },
52
+ body: body ? JSON.stringify(body) : undefined,
53
+ });
54
+
55
+ const data = (await response.json()) as FeishuApiResponse<T>;
56
+
57
+ // Handle token expiration
58
+ if (data.code === 99991663 || data.code === 99991664) {
59
+ if (retryOnAuthError) {
60
+ invalidateToken(account.accountId);
61
+ return feishuRequest(account, method, endpoint, body, queryParams, false);
62
+ }
63
+ }
64
+
65
+ return data;
66
+ }
67
+
68
+ /**
69
+ * Send a message to Feishu
70
+ */
71
+ async function sendMessage(
72
+ account: ResolvedFeishuAccount,
73
+ receiveId: string,
74
+ msgType: FeishuMsgType,
75
+ content: string,
76
+ receiveIdType: FeishuReceiveIdType = "chat_id",
77
+ replyToId?: string,
78
+ ): Promise<FeishuSendResult> {
79
+ const body: FeishuSendMessageRequest = {
80
+ receive_id: receiveId,
81
+ msg_type: msgType,
82
+ content,
83
+ uuid: generateUuid(),
84
+ };
85
+
86
+ const endpoint = replyToId
87
+ ? `/im/v1/messages/${replyToId}/reply`
88
+ : "/im/v1/messages";
89
+
90
+ const queryParams = replyToId ? undefined : { receive_id_type: receiveIdType };
91
+
92
+ const response = await feishuRequest<FeishuSendMessageResponse>(
93
+ account,
94
+ "POST",
95
+ endpoint,
96
+ body,
97
+ queryParams,
98
+ );
99
+
100
+ if (response.code !== 0) {
101
+ return {
102
+ ok: false,
103
+ error: `Feishu API error: ${response.code} - ${response.msg}`,
104
+ };
105
+ }
106
+
107
+ return {
108
+ ok: true,
109
+ messageId: response.data?.message_id,
110
+ };
111
+ }
112
+
113
+ /**
114
+ * Send a text message
115
+ */
116
+ export async function sendText(
117
+ account: ResolvedFeishuAccount,
118
+ receiveId: string,
119
+ text: string,
120
+ options: FeishuSendOptions = {},
121
+ ): Promise<FeishuSendResult> {
122
+ const content: FeishuTextContent = { text };
123
+ return sendMessage(
124
+ account,
125
+ receiveId,
126
+ "text",
127
+ JSON.stringify(content),
128
+ options.receiveIdType ?? "chat_id",
129
+ options.replyToId,
130
+ );
131
+ }
132
+
133
+ /**
134
+ * Send a rich text (post) message
135
+ */
136
+ export async function sendPost(
137
+ account: ResolvedFeishuAccount,
138
+ receiveId: string,
139
+ postContent: FeishuPostContent,
140
+ options: FeishuSendOptions = {},
141
+ ): Promise<FeishuSendResult> {
142
+ return sendMessage(
143
+ account,
144
+ receiveId,
145
+ "post",
146
+ JSON.stringify(postContent),
147
+ options.receiveIdType ?? "chat_id",
148
+ options.replyToId,
149
+ );
150
+ }
151
+
152
+ /**
153
+ * Send an interactive card message
154
+ */
155
+ export async function sendCard(
156
+ account: ResolvedFeishuAccount,
157
+ receiveId: string,
158
+ cardContent: FeishuInteractiveContent,
159
+ options: FeishuSendOptions = {},
160
+ ): Promise<FeishuSendResult> {
161
+ return sendMessage(
162
+ account,
163
+ receiveId,
164
+ "interactive",
165
+ JSON.stringify(cardContent),
166
+ options.receiveIdType ?? "chat_id",
167
+ options.replyToId,
168
+ );
169
+ }
170
+
171
+ /**
172
+ * Send an image message
173
+ */
174
+ export async function sendImage(
175
+ account: ResolvedFeishuAccount,
176
+ receiveId: string,
177
+ imageKey: string,
178
+ options: FeishuSendOptions = {},
179
+ ): Promise<FeishuSendResult> {
180
+ const content = { image_key: imageKey };
181
+ return sendMessage(
182
+ account,
183
+ receiveId,
184
+ "image",
185
+ JSON.stringify(content),
186
+ options.receiveIdType ?? "chat_id",
187
+ options.replyToId,
188
+ );
189
+ }
190
+
191
+ /**
192
+ * Send a file message
193
+ */
194
+ export async function sendFile(
195
+ account: ResolvedFeishuAccount,
196
+ receiveId: string,
197
+ fileKey: string,
198
+ options: FeishuSendOptions = {},
199
+ ): Promise<FeishuSendResult> {
200
+ const content = { file_key: fileKey };
201
+ return sendMessage(
202
+ account,
203
+ receiveId,
204
+ "file",
205
+ JSON.stringify(content),
206
+ options.receiveIdType ?? "chat_id",
207
+ options.replyToId,
208
+ );
209
+ }
210
+
211
+ /**
212
+ * Upload an image to Feishu and get the image_key
213
+ */
214
+ export async function uploadImage(
215
+ account: ResolvedFeishuAccount,
216
+ imageBuffer: ArrayBuffer,
217
+ imageName: string,
218
+ ): Promise<{ ok: boolean; imageKey?: string; error?: string }> {
219
+ const token = await getTenantAccessToken(account);
220
+ const apiBase = getApiBase(account);
221
+
222
+ const formData = new FormData();
223
+ formData.append("image_type", "message");
224
+ formData.append("image", new Blob([imageBuffer]), imageName);
225
+
226
+ const response = await fetch(`${apiBase}/im/v1/images`, {
227
+ method: "POST",
228
+ headers: {
229
+ Authorization: `Bearer ${token}`,
230
+ },
231
+ body: formData,
232
+ });
233
+
234
+ const data = (await response.json()) as FeishuApiResponse<{ image_key: string }>;
235
+
236
+ if (data.code !== 0) {
237
+ return {
238
+ ok: false,
239
+ error: `Failed to upload image: ${data.code} - ${data.msg}`,
240
+ };
241
+ }
242
+
243
+ return {
244
+ ok: true,
245
+ imageKey: data.data?.image_key,
246
+ };
247
+ }
248
+
249
+ /**
250
+ * Upload a file to Feishu and get the file_key
251
+ */
252
+ export async function uploadFile(
253
+ account: ResolvedFeishuAccount,
254
+ fileBuffer: ArrayBuffer,
255
+ fileName: string,
256
+ fileType: "opus" | "mp4" | "pdf" | "doc" | "xls" | "ppt" | "stream",
257
+ ): Promise<{ ok: boolean; fileKey?: string; error?: string }> {
258
+ const token = await getTenantAccessToken(account);
259
+ const apiBase = getApiBase(account);
260
+
261
+ const formData = new FormData();
262
+ formData.append("file_type", fileType);
263
+ formData.append("file_name", fileName);
264
+ formData.append("file", new Blob([fileBuffer]), fileName);
265
+
266
+ const response = await fetch(`${apiBase}/im/v1/files`, {
267
+ method: "POST",
268
+ headers: {
269
+ Authorization: `Bearer ${token}`,
270
+ },
271
+ body: formData,
272
+ });
273
+
274
+ const data = (await response.json()) as FeishuApiResponse<{ file_key: string }>;
275
+
276
+ if (data.code !== 0) {
277
+ return {
278
+ ok: false,
279
+ error: `Failed to upload file: ${data.code} - ${data.msg}`,
280
+ };
281
+ }
282
+
283
+ return {
284
+ ok: true,
285
+ fileKey: data.data?.file_key,
286
+ };
287
+ }
288
+
289
+ /**
290
+ * Get chat information
291
+ */
292
+ export async function getChatInfo(
293
+ account: ResolvedFeishuAccount,
294
+ chatId: string,
295
+ ): Promise<FeishuApiResponse<{
296
+ chat_id: string;
297
+ name: string;
298
+ description: string;
299
+ owner_id: string;
300
+ owner_id_type: string;
301
+ chat_mode: string;
302
+ chat_type: string;
303
+ external: boolean;
304
+ }>> {
305
+ return feishuRequest(account, "GET", `/im/v1/chats/${chatId}`);
306
+ }
307
+
308
+ /**
309
+ * Get user info by user ID
310
+ */
311
+ export async function getUserInfo(
312
+ account: ResolvedFeishuAccount,
313
+ userId: string,
314
+ userIdType: "open_id" | "union_id" | "user_id" = "open_id",
315
+ ): Promise<FeishuApiResponse<{
316
+ user: {
317
+ open_id: string;
318
+ union_id: string;
319
+ user_id: string;
320
+ name: string;
321
+ en_name: string;
322
+ nickname: string;
323
+ email: string;
324
+ mobile: string;
325
+ avatar: {
326
+ avatar_72: string;
327
+ avatar_240: string;
328
+ avatar_640: string;
329
+ avatar_origin: string;
330
+ };
331
+ };
332
+ }>> {
333
+ return feishuRequest(account, "GET", `/contact/v3/users/${userId}`, undefined, {
334
+ user_id_type: userIdType,
335
+ });
336
+ }
337
+
338
+ /**
339
+ * Test API connectivity (probe)
340
+ */
341
+ export async function probeFeishu(
342
+ account: ResolvedFeishuAccount,
343
+ timeoutMs = 10000,
344
+ ): Promise<{ ok: boolean; error?: string }> {
345
+ try {
346
+ const controller = new AbortController();
347
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
348
+ const apiBase = getApiBase(account);
349
+
350
+ // Validate credentials by fetching a token
351
+ await getTenantAccessToken(account);
352
+
353
+ const response = await fetch(`${apiBase}/auth/v3/tenant_access_token/internal`, {
354
+ method: "POST",
355
+ headers: {
356
+ "Content-Type": "application/json; charset=utf-8",
357
+ },
358
+ body: JSON.stringify({
359
+ app_id: account.appId,
360
+ app_secret: account.appSecret,
361
+ }),
362
+ signal: controller.signal,
363
+ });
364
+
365
+ clearTimeout(timeoutId);
366
+
367
+ if (!response.ok) {
368
+ return { ok: false, error: `HTTP ${response.status}` };
369
+ }
370
+
371
+ const data = (await response.json()) as FeishuApiResponse;
372
+
373
+ if (data.code !== 0) {
374
+ return { ok: false, error: data.msg };
375
+ }
376
+
377
+ return { ok: true };
378
+ } catch (error) {
379
+ const message = error instanceof Error ? error.message : String(error);
380
+ return { ok: false, error: message };
381
+ }
382
+ }
383
+
384
+ /**
385
+ * Generate a UUID for message deduplication
386
+ */
387
+ function generateUuid(): string {
388
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
389
+ const r = (Math.random() * 16) | 0;
390
+ const v = c === "x" ? r : (r & 0x3) | 0x8;
391
+ return v.toString(16);
392
+ });
393
+ }
394
+
395
+ /**
396
+ * Convert markdown to Feishu post format
397
+ */
398
+ export function markdownToPost(markdown: string, title?: string): FeishuPostContent {
399
+ const lines = markdown.split("\n");
400
+ const content: Array<Array<{ tag: string; text?: string; href?: string }>> = [];
401
+
402
+ for (const line of lines) {
403
+ if (!line.trim()) {
404
+ continue;
405
+ }
406
+
407
+ const elements: Array<{ tag: string; text?: string; href?: string }> = [];
408
+ const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
409
+ let lastIndex = 0;
410
+ let match;
411
+
412
+ while ((match = linkRegex.exec(line)) !== null) {
413
+ // Add text before the link
414
+ if (match.index > lastIndex) {
415
+ elements.push({
416
+ tag: "text",
417
+ text: line.slice(lastIndex, match.index),
418
+ });
419
+ }
420
+
421
+ // Add the link
422
+ elements.push({
423
+ tag: "a",
424
+ text: match[1],
425
+ href: match[2],
426
+ });
427
+
428
+ lastIndex = match.index + match[0].length;
429
+ }
430
+
431
+ // Add remaining text
432
+ if (lastIndex < line.length) {
433
+ elements.push({
434
+ tag: "text",
435
+ text: line.slice(lastIndex),
436
+ });
437
+ }
438
+
439
+ if (elements.length > 0) {
440
+ content.push(elements);
441
+ }
442
+ }
443
+
444
+ return {
445
+ zh_cn: {
446
+ title,
447
+ content,
448
+ },
449
+ };
450
+ }
451
+
452
+ // ============ Card Building Utilities ============
453
+
454
+ /** Supported card header colors */
455
+ export type CardColor =
456
+ | "blue"
457
+ | "wathet"
458
+ | "turquoise"
459
+ | "green"
460
+ | "yellow"
461
+ | "orange"
462
+ | "red"
463
+ | "carmine"
464
+ | "violet"
465
+ | "purple"
466
+ | "indigo"
467
+ | "grey";
468
+
469
+ /** Options for building a card base */
470
+ export interface CardBaseOptions {
471
+ /** Card title (optional) */
472
+ title?: string;
473
+ /** Header color (default: "blue") */
474
+ color?: CardColor;
475
+ /** Enable wide screen mode (default: true) */
476
+ wideScreen?: boolean;
477
+ /** Enable forward (default: undefined) */
478
+ enableForward?: boolean;
479
+ }
480
+
481
+ /**
482
+ * Build a base card structure with common configuration
483
+ * Internal helper to reduce duplication in card creation functions
484
+ */
485
+ function buildCardBase(options: CardBaseOptions = {}): FeishuInteractiveContent {
486
+ const { title, color = "blue", wideScreen = true, enableForward } = options;
487
+
488
+ const card: FeishuInteractiveContent = {
489
+ config: {
490
+ wide_screen_mode: wideScreen,
491
+ ...(enableForward !== undefined && { enable_forward: enableForward }),
492
+ },
493
+ elements: [],
494
+ };
495
+
496
+ if (title) {
497
+ card.header = {
498
+ title: {
499
+ tag: "plain_text",
500
+ content: title,
501
+ },
502
+ template: color,
503
+ };
504
+ }
505
+
506
+ return card;
507
+ }
508
+
509
+ /**
510
+ * Create a simple card message
511
+ */
512
+ export function createSimpleCard(
513
+ title: string,
514
+ content: string,
515
+ color: CardColor = "blue",
516
+ ): FeishuInteractiveContent {
517
+ const card = buildCardBase({ title, color });
518
+ card.elements = [{ tag: "markdown", content }];
519
+ return card;
520
+ }
521
+
522
+ /**
523
+ * Create a markdown card without header (cleaner look)
524
+ */
525
+ export function createMarkdownCard(content: string): FeishuInteractiveContent {
526
+ const card = buildCardBase();
527
+ card.elements = [{ tag: "markdown", content }];
528
+ return card;
529
+ }
530
+
531
+ /**
532
+ * Create a card with syntax-highlighted code block
533
+ * Feishu supports language-specific code highlighting in markdown cards
534
+ */
535
+ export function createCodeCard(
536
+ code: string,
537
+ language: string = "plaintext",
538
+ title?: string,
539
+ ): FeishuInteractiveContent {
540
+ const card = buildCardBase({ title });
541
+ card.elements = [{ tag: "markdown", content: `\`\`\`${language}\n${code}\n\`\`\`` }];
542
+ return card;
543
+ }
544
+
545
+ /**
546
+ * Create a card with a table
547
+ * Feishu cards support markdown tables natively
548
+ */
549
+ export function createTableCard(
550
+ headers: string[],
551
+ rows: string[][],
552
+ title?: string,
553
+ ): FeishuInteractiveContent {
554
+ // Build markdown table
555
+ const headerRow = `| ${headers.join(" | ")} |`;
556
+ const separator = `| ${headers.map(() => "---").join(" | ")} |`;
557
+ const dataRows = rows.map((row) => `| ${row.join(" | ")} |`).join("\n");
558
+ const tableMarkdown = `${headerRow}\n${separator}\n${dataRows}`;
559
+
560
+ const card = buildCardBase({ title });
561
+ card.elements = [{ tag: "markdown", content: tableMarkdown }];
562
+ return card;
563
+ }
564
+
565
+ /**
566
+ * Button configuration for interactive cards
567
+ */
568
+ export interface CardButton {
569
+ /** Button display text */
570
+ text: string;
571
+ /** Button action value */
572
+ value: string;
573
+ /** Button style */
574
+ type?: "primary" | "default" | "danger";
575
+ /** Optional URL for link buttons */
576
+ url?: string;
577
+ }
578
+
579
+ /**
580
+ * Create a card with interactive buttons
581
+ */
582
+ export function createCardWithButtons(
583
+ content: string,
584
+ buttons: CardButton[],
585
+ title?: string,
586
+ ): FeishuInteractiveContent {
587
+ const card = buildCardBase({ title });
588
+ card.elements = [
589
+ { tag: "markdown", content },
590
+ {
591
+ tag: "action",
592
+ actions: buttons.map((btn) => {
593
+ if (btn.url) {
594
+ return {
595
+ tag: "button",
596
+ text: { tag: "plain_text", content: btn.text },
597
+ type: btn.type ?? "default",
598
+ url: btn.url,
599
+ };
600
+ }
601
+ return {
602
+ tag: "button",
603
+ text: { tag: "plain_text", content: btn.text },
604
+ type: btn.type ?? "default",
605
+ value: { action: btn.value },
606
+ };
607
+ }),
608
+ },
609
+ ];
610
+ return card;
611
+ }
612
+
613
+ /**
614
+ * Section configuration for multi-section cards
615
+ */
616
+ export interface CardSection {
617
+ /** Section type */
618
+ type: "markdown" | "divider" | "note";
619
+ /** Content for markdown sections */
620
+ content?: string;
621
+ /** Elements for note sections */
622
+ elements?: Array<{ tag: string; content?: string }>;
623
+ }
624
+
625
+ /**
626
+ * Create a multi-section card with dividers
627
+ */
628
+ export function createMultiSectionCard(
629
+ sections: CardSection[],
630
+ title?: string,
631
+ color?: CardColor,
632
+ ): FeishuInteractiveContent {
633
+ const card = buildCardBase({ title, color });
634
+ card.elements = sections.map((section) => {
635
+ if (section.type === "markdown" && section.content) {
636
+ return { tag: "markdown", content: section.content };
637
+ }
638
+ if (section.type === "divider") {
639
+ return { tag: "hr" };
640
+ }
641
+ if (section.type === "note" && section.elements) {
642
+ return { tag: "note", elements: section.elements };
643
+ }
644
+ return { tag: "markdown", content: section.content ?? "" };
645
+ });
646
+ return card;
647
+ }
648
+
649
+ /**
650
+ * Check if text contains code blocks, tables, or other rich content (should use card rendering)
651
+ */
652
+ export function shouldUseCardRendering(text: string): boolean {
653
+ // Check for code blocks (```code```)
654
+ if (/```[\s\S]*?```/.test(text)) {
655
+ return true;
656
+ }
657
+ // Check for tables (markdown table syntax)
658
+ if (/\|.+\|[\r\n]+\|[-:| ]+\|/.test(text)) {
659
+ return true;
660
+ }
661
+ // Check for markdown links [text](url)
662
+ if (/\[.+\]\(.+\)/.test(text)) {
663
+ return true;
664
+ }
665
+ // Check for long text (>500 chars) - better display in card
666
+ if (text.length > 500) {
667
+ return true;
668
+ }
669
+ // Check for multiple paragraphs
670
+ if ((text.match(/\n\n/g) || []).length >= 3) {
671
+ return true;
672
+ }
673
+ return false;
674
+ }
675
+
676
+ /**
677
+ * Convert markdown table to ASCII art table (for raw mode)
678
+ */
679
+ export function markdownTableToAscii(markdown: string): string {
680
+ // Simple conversion - just clean up the markdown table syntax
681
+ return markdown
682
+ .replace(/\|/g, " | ")
683
+ .replace(/[-:]+\|[-:| ]+/g, (match) => match.replace(/[:|]/g, "-"));
684
+ }
685
+
686
+ /**
687
+ * Smart send - automatically choose between text and card based on content and renderMode
688
+ */
689
+ export async function sendSmart(
690
+ account: ResolvedFeishuAccount,
691
+ receiveId: string,
692
+ text: string,
693
+ options: FeishuSendOptions & { renderMode?: FeishuRenderMode } = {},
694
+ ): Promise<FeishuSendResult> {
695
+ const renderMode = options.renderMode ?? account.renderMode ?? "auto";
696
+
697
+ // Determine whether to use card rendering
698
+ let useCard = false;
699
+ let processedText = text;
700
+
701
+ switch (renderMode) {
702
+ case "card":
703
+ useCard = true;
704
+ break;
705
+ case "raw":
706
+ useCard = false;
707
+ // Convert tables to ASCII for raw mode
708
+ processedText = markdownTableToAscii(text);
709
+ break;
710
+ case "auto":
711
+ default:
712
+ useCard = shouldUseCardRendering(text);
713
+ break;
714
+ }
715
+
716
+ if (useCard) {
717
+ // Send as card message
718
+ const card = createMarkdownCard(text);
719
+ return sendCard(account, receiveId, card, options);
720
+ } else {
721
+ // Send as plain text
722
+ return sendText(account, receiveId, processedText, options);
723
+ }
724
+ }
725
+
726
+ /**
727
+ * Get chat list that the bot is in
728
+ */
729
+ export async function getChatList(
730
+ account: ResolvedFeishuAccount,
731
+ pageSize = 50,
732
+ pageToken?: string,
733
+ ): Promise<FeishuApiResponse<{
734
+ items: Array<{
735
+ chat_id: string;
736
+ name: string;
737
+ description?: string;
738
+ chat_mode: string;
739
+ chat_type: string;
740
+ external: boolean;
741
+ owner_id?: string;
742
+ }>;
743
+ page_token?: string;
744
+ has_more: boolean;
745
+ }>> {
746
+ const params: Record<string, string> = { page_size: String(pageSize) };
747
+ if (pageToken) {
748
+ params.page_token = pageToken;
749
+ }
750
+ return feishuRequest(account, "GET", "/im/v1/chats", undefined, params);
751
+ }
752
+
753
+ /**
754
+ * Get chat members
755
+ */
756
+ export async function getChatMembers(
757
+ account: ResolvedFeishuAccount,
758
+ chatId: string,
759
+ pageSize = 50,
760
+ pageToken?: string,
761
+ ): Promise<FeishuApiResponse<{
762
+ items: Array<{
763
+ member_id: string;
764
+ member_id_type: string;
765
+ name?: string;
766
+ }>;
767
+ page_token?: string;
768
+ has_more: boolean;
769
+ }>> {
770
+ const params: Record<string, string> = { page_size: String(pageSize) };
771
+ if (pageToken) {
772
+ params.page_token = pageToken;
773
+ }
774
+ return feishuRequest(account, "GET", `/im/v1/chats/${chatId}/members`, undefined, params);
775
+ }
776
+
777
+ /**
778
+ * Get message history from a chat
779
+ */
780
+ export async function getMessageHistory(
781
+ account: ResolvedFeishuAccount,
782
+ chatId: string,
783
+ pageSize = 20,
784
+ startTime?: number,
785
+ endTime?: number,
786
+ pageToken?: string,
787
+ ): Promise<FeishuApiResponse<{
788
+ items: Array<{
789
+ message_id: string;
790
+ root_id?: string;
791
+ parent_id?: string;
792
+ msg_type: string;
793
+ create_time: string;
794
+ update_time?: string;
795
+ deleted: boolean;
796
+ chat_id: string;
797
+ sender: {
798
+ id: string;
799
+ id_type: string;
800
+ sender_type: string;
801
+ };
802
+ body: {
803
+ content: string;
804
+ };
805
+ }>;
806
+ page_token?: string;
807
+ has_more: boolean;
808
+ }>> {
809
+ const params: Record<string, string> = {
810
+ container_id_type: "chat",
811
+ container_id: chatId,
812
+ page_size: String(pageSize),
813
+ };
814
+ if (startTime) {
815
+ params.start_time = String(startTime);
816
+ }
817
+ if (endTime) {
818
+ params.end_time = String(endTime);
819
+ }
820
+ if (pageToken) {
821
+ params.page_token = pageToken;
822
+ }
823
+ return feishuRequest(account, "GET", "/im/v1/messages", undefined, params);
824
+ }
825
+
826
+ /**
827
+ * Download image from Feishu
828
+ */
829
+ export async function downloadImage(
830
+ account: ResolvedFeishuAccount,
831
+ imageKey: string,
832
+ ): Promise<{ ok: boolean; data?: ArrayBuffer; error?: string }> {
833
+ try {
834
+ const token = await getTenantAccessToken(account);
835
+ const apiBase = getApiBase(account);
836
+
837
+ const response = await fetch(`${apiBase}/im/v1/images/${imageKey}`, {
838
+ headers: {
839
+ Authorization: `Bearer ${token}`,
840
+ },
841
+ });
842
+
843
+ if (!response.ok) {
844
+ return { ok: false, error: `HTTP ${response.status}` };
845
+ }
846
+
847
+ const data = await response.arrayBuffer();
848
+ return { ok: true, data };
849
+ } catch (error) {
850
+ const message = error instanceof Error ? error.message : String(error);
851
+ return { ok: false, error: message };
852
+ }
853
+ }
854
+
855
+ /**
856
+ * Download file from Feishu
857
+ */
858
+ export async function downloadFile(
859
+ account: ResolvedFeishuAccount,
860
+ fileKey: string,
861
+ ): Promise<{ ok: boolean; data?: ArrayBuffer; error?: string }> {
862
+ try {
863
+ const token = await getTenantAccessToken(account);
864
+ const apiBase = getApiBase(account);
865
+
866
+ const response = await fetch(`${apiBase}/im/v1/files/${fileKey}`, {
867
+ headers: {
868
+ Authorization: `Bearer ${token}`,
869
+ },
870
+ });
871
+
872
+ if (!response.ok) {
873
+ return { ok: false, error: `HTTP ${response.status}` };
874
+ }
875
+
876
+ const data = await response.arrayBuffer();
877
+ return { ok: true, data };
878
+ } catch (error) {
879
+ const message = error instanceof Error ? error.message : String(error);
880
+ return { ok: false, error: message };
881
+ }
882
+ }
883
+
884
+ /**
885
+ * Add reaction (emoji) to a message - used as typing indicator
886
+ */
887
+ export async function addReaction(
888
+ account: ResolvedFeishuAccount,
889
+ messageId: string,
890
+ emojiType: string,
891
+ ): Promise<FeishuApiResponse<{ reaction_id: string }>> {
892
+ return feishuRequest(
893
+ account,
894
+ "POST",
895
+ `/im/v1/messages/${messageId}/reactions`,
896
+ { reaction_type: { emoji_type: emojiType } },
897
+ );
898
+ }
899
+
900
+ /**
901
+ * Remove reaction from a message
902
+ */
903
+ export async function removeReaction(
904
+ account: ResolvedFeishuAccount,
905
+ messageId: string,
906
+ reactionId: string,
907
+ ): Promise<FeishuApiResponse> {
908
+ return feishuRequest(
909
+ account,
910
+ "DELETE",
911
+ `/im/v1/messages/${messageId}/reactions/${reactionId}`,
912
+ );
913
+ }
914
+
915
+ /**
916
+ * Reply to a specific message
917
+ */
918
+ export async function replyMessage(
919
+ account: ResolvedFeishuAccount,
920
+ messageId: string,
921
+ text: string,
922
+ options: { renderMode?: FeishuRenderMode } = {},
923
+ ): Promise<FeishuSendResult> {
924
+ const renderMode = options.renderMode ?? account.renderMode ?? "auto";
925
+ const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCardRendering(text));
926
+
927
+ const msgType = useCard ? "interactive" : "text";
928
+ const content = useCard
929
+ ? JSON.stringify(createMarkdownCard(text))
930
+ : JSON.stringify({ text });
931
+
932
+ const body = {
933
+ msg_type: msgType,
934
+ content,
935
+ uuid: generateUuid(),
936
+ };
937
+
938
+ const response = await feishuRequest<FeishuSendMessageResponse>(
939
+ account,
940
+ "POST",
941
+ `/im/v1/messages/${messageId}/reply`,
942
+ body,
943
+ );
944
+
945
+ if (response.code !== 0) {
946
+ return { ok: false, error: `${response.code}: ${response.msg}` };
947
+ }
948
+
949
+ return { ok: true, messageId: response.data?.message_id };
950
+ }
951
+
952
+ /**
953
+ * Send a rich text (post) message with @ mentions that are highlighted
954
+ * This is the recommended way to @ someone in Feishu
955
+ */
956
+ export async function sendPostWithMentions(
957
+ account: ResolvedFeishuAccount,
958
+ receiveId: string,
959
+ text: string,
960
+ mentions: Array<{ openId: string; name?: string }>,
961
+ options: FeishuSendOptions = {},
962
+ ): Promise<FeishuSendResult> {
963
+ const receiveIdType = options.receiveIdType ?? "chat_id";
964
+
965
+ // Build content elements
966
+ const elements: Array<{ tag: string; user_id?: string; text?: string }> = [];
967
+
968
+ // Add @ mentions at the beginning
969
+ for (const mention of mentions) {
970
+ elements.push({
971
+ tag: "at",
972
+ user_id: mention.openId,
973
+ });
974
+ elements.push({
975
+ tag: "text",
976
+ text: " ",
977
+ });
978
+ }
979
+
980
+ // Add the message text
981
+ elements.push({
982
+ tag: "text",
983
+ text: text,
984
+ });
985
+
986
+ const postContent = {
987
+ zh_cn: {
988
+ content: [elements],
989
+ },
990
+ };
991
+
992
+ const body: FeishuSendMessageRequest = {
993
+ receive_id: receiveId,
994
+ msg_type: "post",
995
+ content: JSON.stringify(postContent),
996
+ uuid: generateUuid(),
997
+ };
998
+
999
+ const response = await feishuRequest<FeishuSendMessageResponse>(
1000
+ account,
1001
+ "POST",
1002
+ "/im/v1/messages",
1003
+ body,
1004
+ { receive_id_type: receiveIdType },
1005
+ );
1006
+
1007
+ if (response.code !== 0) {
1008
+ return { ok: false, error: `${response.code}: ${response.msg}` };
1009
+ }
1010
+
1011
+ return { ok: true, messageId: response.data?.message_id };
1012
+ }
1013
+
1014
+ /**
1015
+ * Send a reply that @mentions the sender
1016
+ * Automatically @ the person who sent the original message
1017
+ */
1018
+ export async function sendReplyWithMention(
1019
+ account: ResolvedFeishuAccount,
1020
+ chatId: string,
1021
+ text: string,
1022
+ senderOpenId: string,
1023
+ options: FeishuSendOptions = {},
1024
+ ): Promise<FeishuSendResult> {
1025
+ return sendPostWithMentions(
1026
+ account,
1027
+ chatId,
1028
+ text,
1029
+ [{ openId: senderOpenId }],
1030
+ options,
1031
+ );
1032
+ }
1033
+
1034
+ // ============ Message Retrieval ============
1035
+
1036
+ /**
1037
+ * Get a single message by ID
1038
+ * Used for fetching reply context (parent message content)
1039
+ */
1040
+ export async function getMessage(
1041
+ account: ResolvedFeishuAccount,
1042
+ messageId: string,
1043
+ ): Promise<{
1044
+ ok: boolean;
1045
+ message?: {
1046
+ messageId: string;
1047
+ msgType: string;
1048
+ content: string;
1049
+ senderId: string;
1050
+ senderType: string;
1051
+ chatId: string;
1052
+ createTime: string;
1053
+ body?: string;
1054
+ };
1055
+ error?: string;
1056
+ }> {
1057
+ const response = await feishuRequest<{
1058
+ items: Array<{
1059
+ message_id: string;
1060
+ msg_type: string;
1061
+ create_time: string;
1062
+ chat_id: string;
1063
+ sender: {
1064
+ id: string;
1065
+ id_type: string;
1066
+ sender_type: string;
1067
+ };
1068
+ body: {
1069
+ content: string;
1070
+ };
1071
+ }>;
1072
+ }>(account, "GET", `/im/v1/messages/${messageId}`);
1073
+
1074
+ if (response.code !== 0) {
1075
+ return {
1076
+ ok: false,
1077
+ error: `Failed to get message: ${response.code} - ${response.msg}`,
1078
+ };
1079
+ }
1080
+
1081
+ const msg = response.data?.items?.[0];
1082
+ if (!msg) {
1083
+ return {
1084
+ ok: false,
1085
+ error: "Message not found",
1086
+ };
1087
+ }
1088
+
1089
+ // Parse the content to extract text
1090
+ let textContent: string | undefined;
1091
+ try {
1092
+ const contentObj = JSON.parse(msg.body.content);
1093
+ if (msg.msg_type === "text") {
1094
+ textContent = contentObj.text;
1095
+ } else if (msg.msg_type === "post") {
1096
+ // Extract text from post content
1097
+ textContent = extractTextFromPostContent(contentObj);
1098
+ } else {
1099
+ textContent = msg.body.content;
1100
+ }
1101
+ } catch {
1102
+ textContent = msg.body.content;
1103
+ }
1104
+
1105
+ return {
1106
+ ok: true,
1107
+ message: {
1108
+ messageId: msg.message_id,
1109
+ msgType: msg.msg_type,
1110
+ content: msg.body.content,
1111
+ senderId: msg.sender.id,
1112
+ senderType: msg.sender.sender_type,
1113
+ chatId: msg.chat_id,
1114
+ createTime: msg.create_time,
1115
+ body: textContent,
1116
+ },
1117
+ };
1118
+ }
1119
+
1120
+ /**
1121
+ * Extract text from post content structure
1122
+ */
1123
+ function extractTextFromPostContent(content: unknown): string {
1124
+ const texts: string[] = [];
1125
+
1126
+ function extractFromElements(elements: unknown[]): void {
1127
+ for (const element of elements) {
1128
+ if (typeof element !== "object" || element === null) {
1129
+ continue;
1130
+ }
1131
+
1132
+ const el = element as { tag?: string; text?: string };
1133
+
1134
+ if (el.tag === "text" && el.text) {
1135
+ texts.push(el.text);
1136
+ } else if (el.tag === "a" && el.text) {
1137
+ texts.push(el.text);
1138
+ }
1139
+ }
1140
+ }
1141
+
1142
+ if (typeof content === "object" && content !== null) {
1143
+ const post = content as {
1144
+ zh_cn?: { content?: unknown[][] };
1145
+ en_us?: { content?: unknown[][] };
1146
+ };
1147
+
1148
+ const postContent = post.zh_cn?.content || post.en_us?.content;
1149
+
1150
+ if (Array.isArray(postContent)) {
1151
+ for (const line of postContent) {
1152
+ if (Array.isArray(line)) {
1153
+ extractFromElements(line);
1154
+ }
1155
+ }
1156
+ }
1157
+ }
1158
+
1159
+ return texts.join(" ");
1160
+ }