moltbot-channel-feishu 0.0.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.
@@ -0,0 +1,335 @@
1
+ /**
2
+ * Media upload and sending operations.
3
+ */
4
+
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { Readable } from "node:stream";
8
+ import type { Config } from "../config/schema.js";
9
+ import type {
10
+ UploadImageParams,
11
+ UploadFileParams,
12
+ SendMediaParams,
13
+ SendResult,
14
+ ImageUploadResult,
15
+ FileUploadResult,
16
+ FileType,
17
+ SendImageParams,
18
+ SendFileParams,
19
+ } from "../types/index.js";
20
+ import { getApiClient } from "./client.js";
21
+ import { normalizeTarget, resolveReceiveIdType } from "./messages.js";
22
+
23
+ // ============================================================================
24
+ // File Type Detection
25
+ // ============================================================================
26
+
27
+ /**
28
+ * Detect file type from extension for upload.
29
+ */
30
+ export function detectFileType(fileName: string): FileType {
31
+ const ext = path.extname(fileName).toLowerCase();
32
+ switch (ext) {
33
+ case ".opus":
34
+ case ".ogg":
35
+ return "opus";
36
+ case ".mp4":
37
+ case ".mov":
38
+ case ".avi":
39
+ return "mp4";
40
+ case ".pdf":
41
+ return "pdf";
42
+ case ".doc":
43
+ case ".docx":
44
+ return "doc";
45
+ case ".xls":
46
+ case ".xlsx":
47
+ return "xls";
48
+ case ".ppt":
49
+ case ".pptx":
50
+ return "ppt";
51
+ default:
52
+ return "stream";
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Check if extension indicates an image.
58
+ */
59
+ function isImageExtension(fileName: string): boolean {
60
+ const ext = path.extname(fileName).toLowerCase();
61
+ return [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".ico", ".tiff"].includes(ext);
62
+ }
63
+
64
+ /**
65
+ * Check if a string is a local file path (not a URL).
66
+ */
67
+ function isLocalPath(urlOrPath: string): boolean {
68
+ if (urlOrPath.startsWith("/") || urlOrPath.startsWith("~")) {
69
+ return true;
70
+ }
71
+ if (/^[a-zA-Z]:/.test(urlOrPath)) {
72
+ return true; // Windows drive letter
73
+ }
74
+ try {
75
+ const url = new URL(urlOrPath);
76
+ return url.protocol === "file:";
77
+ } catch {
78
+ return true; // Not a valid URL, treat as local path
79
+ }
80
+ }
81
+
82
+ // ============================================================================
83
+ // Upload Operations
84
+ // ============================================================================
85
+
86
+ interface UploadImageResponse {
87
+ code?: number;
88
+ msg?: string;
89
+ image_key?: string;
90
+ data?: { image_key?: string };
91
+ }
92
+
93
+ /**
94
+ * Upload an image to Feishu.
95
+ * Supports JPEG, PNG, WEBP, GIF, TIFF, BMP, ICO.
96
+ *
97
+ * @throws Error if upload fails
98
+ */
99
+ export async function uploadImage(
100
+ config: Config,
101
+ params: UploadImageParams
102
+ ): Promise<ImageUploadResult> {
103
+ const client = getApiClient(config);
104
+ const imageType = params.imageType ?? "message";
105
+
106
+ // Create readable stream from input
107
+ const imageStream =
108
+ typeof params.image === "string"
109
+ ? fs.createReadStream(params.image)
110
+ : Readable.from(params.image);
111
+
112
+ const response = (await client.im.image.create({
113
+ data: {
114
+ image_type: imageType,
115
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
116
+ image: imageStream as any,
117
+ },
118
+ })) as UploadImageResponse;
119
+
120
+ if (response.code !== undefined && response.code !== 0) {
121
+ throw new Error(`Image upload failed: ${response.msg ?? `code ${response.code}`}`);
122
+ }
123
+
124
+ const imageKey = response.image_key ?? response.data?.image_key;
125
+ if (!imageKey) {
126
+ throw new Error("Image upload failed: no image_key returned");
127
+ }
128
+
129
+ return { imageKey };
130
+ }
131
+
132
+ interface UploadFileResponse {
133
+ code?: number;
134
+ msg?: string;
135
+ file_key?: string;
136
+ data?: { file_key?: string };
137
+ }
138
+
139
+ /**
140
+ * Upload a file to Feishu.
141
+ * Max file size: 30MB.
142
+ *
143
+ * @throws Error if upload fails
144
+ */
145
+ export async function uploadFile(
146
+ config: Config,
147
+ params: UploadFileParams
148
+ ): Promise<FileUploadResult> {
149
+ const client = getApiClient(config);
150
+
151
+ // Create readable stream from input
152
+ const fileStream =
153
+ typeof params.file === "string" ? fs.createReadStream(params.file) : Readable.from(params.file);
154
+
155
+ const response = (await client.im.file.create({
156
+ data: {
157
+ file_type: params.fileType,
158
+ file_name: params.fileName,
159
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
160
+ file: fileStream as any,
161
+ ...(params.duration !== undefined ? { duration: params.duration } : {}),
162
+ },
163
+ })) as UploadFileResponse;
164
+
165
+ if (response.code !== undefined && response.code !== 0) {
166
+ throw new Error(`File upload failed: ${response.msg ?? `code ${response.code}`}`);
167
+ }
168
+
169
+ const fileKey = response.file_key ?? response.data?.file_key;
170
+ if (!fileKey) {
171
+ throw new Error("File upload failed: no file_key returned");
172
+ }
173
+
174
+ return { fileKey };
175
+ }
176
+
177
+ // ============================================================================
178
+ // Media Sending
179
+ // ============================================================================
180
+
181
+ interface SendMediaResponse {
182
+ code?: number;
183
+ msg?: string;
184
+ data?: { message_id?: string };
185
+ }
186
+
187
+ /**
188
+ * Send an image message using an image_key.
189
+ *
190
+ * @throws Error if target is invalid or send fails
191
+ */
192
+ export async function sendImage(config: Config, params: SendImageParams): Promise<SendResult> {
193
+ const client = getApiClient(config);
194
+ const receiveId = normalizeTarget(params.to);
195
+
196
+ if (!receiveId) {
197
+ throw new Error(`Invalid target: ${params.to}`);
198
+ }
199
+
200
+ const receiveIdType = resolveReceiveIdType(receiveId);
201
+ const content = JSON.stringify({ image_key: params.imageKey });
202
+
203
+ if (params.replyToMessageId) {
204
+ const response = (await client.im.message.reply({
205
+ path: { message_id: params.replyToMessageId },
206
+ data: { content, msg_type: "image" },
207
+ })) as SendMediaResponse;
208
+
209
+ if (response.code !== 0) {
210
+ throw new Error(`Image reply failed: ${response.msg ?? `code ${response.code}`}`);
211
+ }
212
+
213
+ return {
214
+ messageId: response.data?.message_id ?? "unknown",
215
+ chatId: receiveId,
216
+ };
217
+ }
218
+
219
+ const response = (await client.im.message.create({
220
+ params: { receive_id_type: receiveIdType },
221
+ data: { receive_id: receiveId, content, msg_type: "image" },
222
+ })) as SendMediaResponse;
223
+
224
+ if (response.code !== 0) {
225
+ throw new Error(`Image send failed: ${response.msg ?? `code ${response.code}`}`);
226
+ }
227
+
228
+ return {
229
+ messageId: response.data?.message_id ?? "unknown",
230
+ chatId: receiveId,
231
+ };
232
+ }
233
+
234
+ /**
235
+ * Send a file message using a file_key.
236
+ *
237
+ * @throws Error if target is invalid or send fails
238
+ */
239
+ export async function sendFile(config: Config, params: SendFileParams): Promise<SendResult> {
240
+ const client = getApiClient(config);
241
+ const receiveId = normalizeTarget(params.to);
242
+
243
+ if (!receiveId) {
244
+ throw new Error(`Invalid target: ${params.to}`);
245
+ }
246
+
247
+ const receiveIdType = resolveReceiveIdType(receiveId);
248
+ const content = JSON.stringify({ file_key: params.fileKey });
249
+
250
+ if (params.replyToMessageId) {
251
+ const response = (await client.im.message.reply({
252
+ path: { message_id: params.replyToMessageId },
253
+ data: { content, msg_type: "file" },
254
+ })) as SendMediaResponse;
255
+
256
+ if (response.code !== 0) {
257
+ throw new Error(`File reply failed: ${response.msg ?? `code ${response.code}`}`);
258
+ }
259
+
260
+ return {
261
+ messageId: response.data?.message_id ?? "unknown",
262
+ chatId: receiveId,
263
+ };
264
+ }
265
+
266
+ const response = (await client.im.message.create({
267
+ params: { receive_id_type: receiveIdType },
268
+ data: { receive_id: receiveId, content, msg_type: "file" },
269
+ })) as SendMediaResponse;
270
+
271
+ if (response.code !== 0) {
272
+ throw new Error(`File send failed: ${response.msg ?? `code ${response.code}`}`);
273
+ }
274
+
275
+ return {
276
+ messageId: response.data?.message_id ?? "unknown",
277
+ chatId: receiveId,
278
+ };
279
+ }
280
+
281
+ /**
282
+ * Upload and send media (image or file) from URL, local path, or buffer.
283
+ *
284
+ * @throws Error if no media source provided or upload/send fails
285
+ */
286
+ export async function sendMedia(config: Config, params: SendMediaParams): Promise<SendResult> {
287
+ let buffer: Buffer;
288
+ let name: string;
289
+
290
+ if (params.mediaBuffer) {
291
+ buffer = params.mediaBuffer;
292
+ name = params.fileName ?? "file";
293
+ } else if (params.mediaUrl) {
294
+ if (isLocalPath(params.mediaUrl)) {
295
+ // Local file path
296
+ const filePath = params.mediaUrl.startsWith("~")
297
+ ? params.mediaUrl.replace("~", process.env["HOME"] ?? "")
298
+ : params.mediaUrl.replace("file://", "");
299
+
300
+ if (!fs.existsSync(filePath)) {
301
+ throw new Error(`Local file not found: ${filePath}`);
302
+ }
303
+ buffer = fs.readFileSync(filePath);
304
+ name = params.fileName ?? path.basename(filePath);
305
+ } else {
306
+ // Remote URL
307
+ const response = await fetch(params.mediaUrl);
308
+ if (!response.ok) {
309
+ throw new Error(`Failed to fetch media from URL: ${response.status}`);
310
+ }
311
+ buffer = Buffer.from(await response.arrayBuffer());
312
+ name = params.fileName ?? (path.basename(new URL(params.mediaUrl).pathname) || "file");
313
+ }
314
+ } else {
315
+ throw new Error("Either mediaUrl or mediaBuffer must be provided");
316
+ }
317
+
318
+ // Determine if it's an image and upload accordingly
319
+ if (isImageExtension(name)) {
320
+ const { imageKey } = await uploadImage(config, { image: buffer });
321
+ return sendImage(config, {
322
+ to: params.to,
323
+ imageKey,
324
+ replyToMessageId: params.replyToMessageId,
325
+ });
326
+ } else {
327
+ const fileType = detectFileType(name);
328
+ const { fileKey } = await uploadFile(config, {
329
+ file: buffer,
330
+ fileName: name,
331
+ fileType,
332
+ });
333
+ return sendFile(config, { to: params.to, fileKey, replyToMessageId: params.replyToMessageId });
334
+ }
335
+ }
@@ -0,0 +1,290 @@
1
+ /**
2
+ * Message sending and retrieval operations.
3
+ */
4
+
5
+ import type { Config } from "../config/schema.js";
6
+ import type {
7
+ SendTextParams,
8
+ SendCardParams,
9
+ EditMessageParams,
10
+ SendResult,
11
+ MessageInfo,
12
+ ReceiveIdType,
13
+ } from "../types/index.js";
14
+ import { getApiClient } from "./client.js";
15
+
16
+ // ============================================================================
17
+ // Target Resolution
18
+ // ============================================================================
19
+
20
+ /**
21
+ * Normalize a target string to a receive_id.
22
+ * Handles prefixed formats like "user:xxx" or "chat:xxx".
23
+ */
24
+ export function normalizeTarget(target: string): string | null {
25
+ const trimmed = target.trim();
26
+ if (!trimmed) return null;
27
+
28
+ // Handle prefixed formats
29
+ if (trimmed.startsWith("user:")) {
30
+ return trimmed.slice(5).trim() || null;
31
+ }
32
+ if (trimmed.startsWith("chat:")) {
33
+ return trimmed.slice(5).trim() || null;
34
+ }
35
+
36
+ // Return as-is if no prefix
37
+ return trimmed;
38
+ }
39
+
40
+ /**
41
+ * Determine the receive_id_type based on ID format.
42
+ */
43
+ export function resolveReceiveIdType(receiveId: string): ReceiveIdType {
44
+ if (receiveId.startsWith("oc_")) return "chat_id";
45
+ if (receiveId.startsWith("ou_")) return "open_id";
46
+ if (receiveId.startsWith("on_")) return "union_id";
47
+ // Default to open_id for DMs
48
+ return "open_id";
49
+ }
50
+
51
+ /**
52
+ * Check if a string looks like a Feishu ID.
53
+ */
54
+ export function isValidId(id: string): boolean {
55
+ const trimmed = id.trim();
56
+ return (
57
+ trimmed.startsWith("oc_") ||
58
+ trimmed.startsWith("ou_") ||
59
+ trimmed.startsWith("on_") ||
60
+ trimmed.startsWith("u_") ||
61
+ trimmed.length > 10
62
+ );
63
+ }
64
+
65
+ // ============================================================================
66
+ // Message Retrieval
67
+ // ============================================================================
68
+
69
+ interface GetMessageResponse {
70
+ code?: number;
71
+ msg?: string;
72
+ data?: {
73
+ items?: {
74
+ message_id?: string;
75
+ chat_id?: string;
76
+ msg_type?: string;
77
+ body?: { content?: string };
78
+ sender?: {
79
+ id?: string;
80
+ id_type?: string;
81
+ sender_type?: string;
82
+ };
83
+ create_time?: string;
84
+ }[];
85
+ };
86
+ }
87
+
88
+ /**
89
+ * Get a message by ID.
90
+ * Returns null if message not found or access denied.
91
+ */
92
+ export async function getMessage(config: Config, messageId: string): Promise<MessageInfo | null> {
93
+ const client = getApiClient(config);
94
+
95
+ try {
96
+ const response = (await client.im.message.get({
97
+ path: { message_id: messageId },
98
+ })) as GetMessageResponse;
99
+
100
+ if (response.code !== 0) {
101
+ return null;
102
+ }
103
+
104
+ const item = response.data?.items?.[0];
105
+ if (!item) {
106
+ return null;
107
+ }
108
+
109
+ // Parse content based on message type
110
+ let content = item.body?.content ?? "";
111
+ try {
112
+ const parsed: unknown = JSON.parse(content);
113
+ if (
114
+ item.msg_type === "text" &&
115
+ typeof parsed === "object" &&
116
+ parsed !== null &&
117
+ "text" in parsed
118
+ ) {
119
+ content = String((parsed as { text: unknown }).text);
120
+ }
121
+ } catch {
122
+ // Keep raw content if parsing fails
123
+ }
124
+
125
+ return {
126
+ messageId: item.message_id ?? messageId,
127
+ chatId: item.chat_id ?? "",
128
+ senderId: item.sender?.id,
129
+ senderOpenId: item.sender?.id_type === "open_id" ? item.sender?.id : undefined,
130
+ content,
131
+ contentType: item.msg_type ?? "text",
132
+ createTime: item.create_time ? parseInt(item.create_time, 10) : undefined,
133
+ };
134
+ } catch {
135
+ return null;
136
+ }
137
+ }
138
+
139
+ // ============================================================================
140
+ // Message Sending
141
+ // ============================================================================
142
+
143
+ interface SendMessageResponse {
144
+ code?: number;
145
+ msg?: string;
146
+ data?: {
147
+ message_id?: string;
148
+ };
149
+ }
150
+
151
+ /**
152
+ * Send a text message.
153
+ *
154
+ * @throws Error if target is invalid or send fails
155
+ */
156
+ export async function sendTextMessage(config: Config, params: SendTextParams): Promise<SendResult> {
157
+ const client = getApiClient(config);
158
+ const receiveId = normalizeTarget(params.to);
159
+
160
+ if (!receiveId) {
161
+ throw new Error(`Invalid target: ${params.to}`);
162
+ }
163
+
164
+ const receiveIdType = resolveReceiveIdType(receiveId);
165
+ const content = JSON.stringify({ text: params.text });
166
+
167
+ // Reply to existing message
168
+ if (params.replyToMessageId) {
169
+ const response = (await client.im.message.reply({
170
+ path: { message_id: params.replyToMessageId },
171
+ data: { content, msg_type: "text" },
172
+ })) as SendMessageResponse;
173
+
174
+ if (response.code !== 0) {
175
+ throw new Error(`Reply failed: ${response.msg ?? `code ${response.code}`}`);
176
+ }
177
+
178
+ return {
179
+ messageId: response.data?.message_id ?? "unknown",
180
+ chatId: receiveId,
181
+ };
182
+ }
183
+
184
+ // Create new message
185
+ const response = (await client.im.message.create({
186
+ params: { receive_id_type: receiveIdType },
187
+ data: { receive_id: receiveId, content, msg_type: "text" },
188
+ })) as SendMessageResponse;
189
+
190
+ if (response.code !== 0) {
191
+ throw new Error(`Send failed: ${response.msg ?? `code ${response.code}`}`);
192
+ }
193
+
194
+ return {
195
+ messageId: response.data?.message_id ?? "unknown",
196
+ chatId: receiveId,
197
+ };
198
+ }
199
+
200
+ /**
201
+ * Send an interactive card message.
202
+ *
203
+ * @throws Error if target is invalid or send fails
204
+ */
205
+ export async function sendCardMessage(config: Config, params: SendCardParams): Promise<SendResult> {
206
+ const client = getApiClient(config);
207
+ const receiveId = normalizeTarget(params.to);
208
+
209
+ if (!receiveId) {
210
+ throw new Error(`Invalid target: ${params.to}`);
211
+ }
212
+
213
+ const receiveIdType = resolveReceiveIdType(receiveId);
214
+ const content = JSON.stringify(params.card);
215
+
216
+ // Reply with card
217
+ if (params.replyToMessageId) {
218
+ const response = (await client.im.message.reply({
219
+ path: { message_id: params.replyToMessageId },
220
+ data: { content, msg_type: "interactive" },
221
+ })) as SendMessageResponse;
222
+
223
+ if (response.code !== 0) {
224
+ throw new Error(`Card reply failed: ${response.msg ?? `code ${response.code}`}`);
225
+ }
226
+
227
+ return {
228
+ messageId: response.data?.message_id ?? "unknown",
229
+ chatId: receiveId,
230
+ };
231
+ }
232
+
233
+ // Create card message
234
+ const response = (await client.im.message.create({
235
+ params: { receive_id_type: receiveIdType },
236
+ data: { receive_id: receiveId, content, msg_type: "interactive" },
237
+ })) as SendMessageResponse;
238
+
239
+ if (response.code !== 0) {
240
+ throw new Error(`Card send failed: ${response.msg ?? `code ${response.code}`}`);
241
+ }
242
+
243
+ return {
244
+ messageId: response.data?.message_id ?? "unknown",
245
+ chatId: receiveId,
246
+ };
247
+ }
248
+
249
+ /**
250
+ * Update an interactive card message.
251
+ *
252
+ * @throws Error if update fails
253
+ */
254
+ export async function updateCard(
255
+ config: Config,
256
+ messageId: string,
257
+ card: Record<string, unknown>
258
+ ): Promise<void> {
259
+ const client = getApiClient(config);
260
+ const content = JSON.stringify(card);
261
+
262
+ const response = (await client.im.message.patch({
263
+ path: { message_id: messageId },
264
+ data: { content },
265
+ })) as SendMessageResponse;
266
+
267
+ if (response.code !== 0) {
268
+ throw new Error(`Card update failed: ${response.msg ?? `code ${response.code}`}`);
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Edit an existing text message.
274
+ * Note: Feishu only allows editing messages within 24 hours.
275
+ *
276
+ * @throws Error if edit fails
277
+ */
278
+ export async function editMessage(config: Config, params: EditMessageParams): Promise<void> {
279
+ const client = getApiClient(config);
280
+ const content = JSON.stringify({ text: params.text });
281
+
282
+ const response = (await client.im.message.update({
283
+ path: { message_id: params.messageId },
284
+ data: { msg_type: "text", content },
285
+ })) as SendMessageResponse;
286
+
287
+ if (response.code !== 0) {
288
+ throw new Error(`Edit failed: ${response.msg ?? `code ${response.code}`}`);
289
+ }
290
+ }