Skip to content

Commit afd61a0

Browse files
committed
feat: file attachment support for Feishu (documents + audio)
- Add FileAttachment interface to ChannelMessage (mimeType, data, fileName) - Feishu adapter: handle file and audio message types via downloadFile() - Gateway: allow file-only messages, pass files to assistant.chat() - Assistant: save files to .golem/files/, append file paths to prompt - HTTP /chat endpoint: accept base64-encoded files array
1 parent 0facb14 commit afd61a0

5 files changed

Lines changed: 127 additions & 15 deletions

File tree

src/channel.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,16 @@ export interface ImageAttachment {
88
fileName?: string;
99
}
1010

11+
/** A file (non-image) attached to an incoming IM message. */
12+
export interface FileAttachment {
13+
/** MIME type, e.g. "application/pdf", "audio/ogg". */
14+
mimeType: string;
15+
/** Raw file bytes. */
16+
data: Buffer;
17+
/** Original filename. */
18+
fileName: string;
19+
}
20+
1121
export interface ChannelMessage {
1222
channelType: string;
1323
senderId: string;
@@ -19,6 +29,8 @@ export interface ChannelMessage {
1929
messageId?: string;
2030
/** Images attached to the message (downloaded by the adapter). */
2131
images?: ImageAttachment[];
32+
/** Files (non-image) attached to the message (downloaded by the adapter). */
33+
files?: FileAttachment[];
2234
raw: unknown;
2335
/**
2436
* Indicates whether the sender is a human user or a bot/app.

src/channels/feishu.ts

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import type { ChannelAdapter, ChannelMessage, ImageAttachment, ReadReceipt, ReplyOptions } from '../channel.js';
1+
import type {
2+
ChannelAdapter,
3+
ChannelMessage,
4+
FileAttachment,
5+
ImageAttachment,
6+
ReadReceipt,
7+
ReplyOptions,
8+
} from '../channel.js';
29
import { importPeer } from '../peer-require.js';
310
import type { FeishuChannelConfig } from '../workspace.js';
411
import { hasMarkdown, markdownToCard } from './feishu-format.js';
@@ -73,6 +80,25 @@ export class FeishuAdapter implements ChannelAdapter {
7380
return { mimeType, data, fileName: `${imageKey}.${mimeType === 'image/png' ? 'png' : 'jpg'}` };
7481
}
7582

83+
/**
84+
* Download a file resource (document, audio, etc.) from a Feishu message.
85+
* Uses the same IM v1 message resource API with type=file.
86+
*/
87+
private async downloadFile(messageId: string, fileKey: string, fileName: string): Promise<FileAttachment> {
88+
const token = await this.client.tokenManager.getTenantAccessToken();
89+
const resp = await fetch(
90+
`https://open.feishu.cn/open-apis/im/v1/messages/${messageId}/resources/${fileKey}?type=file`,
91+
{ headers: { Authorization: `Bearer ${token}` } },
92+
);
93+
if (!resp.ok) {
94+
throw new Error(`Feishu file download failed: ${resp.status} ${resp.statusText}`);
95+
}
96+
const data = Buffer.from(await resp.arrayBuffer());
97+
const contentType = resp.headers.get('content-type') || 'application/octet-stream';
98+
const mimeType = contentType.split(';')[0];
99+
return { mimeType, data, fileName };
100+
}
101+
76102
async start(onMessage: (msg: ChannelMessage) => void): Promise<void> {
77103
let lark: any;
78104
try {
@@ -168,7 +194,8 @@ export class FeishuAdapter implements ChannelAdapter {
168194

169195
// Parse message content based on type
170196
const msgType = message.message_type;
171-
if (msgType !== 'text' && msgType !== 'image' && msgType !== 'post') return;
197+
if (msgType !== 'text' && msgType !== 'image' && msgType !== 'post' && msgType !== 'file' && msgType !== 'audio')
198+
return;
172199

173200
let parsedContent: Record<string, any>;
174201
try {
@@ -239,6 +266,36 @@ export class FeishuAdapter implements ChannelAdapter {
239266
if (!text && images.length > 0) text = '(image)';
240267
}
241268

269+
// File attachment handling
270+
const files: FileAttachment[] = [];
271+
272+
if (msgType === 'file') {
273+
const fileKey = parsedContent.file_key;
274+
const fileName = parsedContent.file_name || 'attachment';
275+
if (fileKey) {
276+
try {
277+
const file = await this.downloadFile(message.message_id, fileKey, fileName);
278+
files.push(file);
279+
text = `(file: ${fileName})`;
280+
} catch (e) {
281+
console.error('[feishu] Failed to download file:', (e as Error).message);
282+
return;
283+
}
284+
}
285+
} else if (msgType === 'audio') {
286+
const fileKey = parsedContent.file_key;
287+
if (fileKey) {
288+
try {
289+
const file = await this.downloadFile(message.message_id, fileKey, 'voice.opus');
290+
files.push(file);
291+
text = '(audio)';
292+
} catch (e) {
293+
console.error('[feishu] Failed to download audio:', (e as Error).message);
294+
return;
295+
}
296+
}
297+
}
298+
242299
// Process @mentions in text:
243300
// - Strip the bot's own @mention key entirely
244301
// - Replace other users' @mention keys with readable @Name format
@@ -253,7 +310,7 @@ export class FeishuAdapter implements ChannelAdapter {
253310
}
254311
}
255312

256-
if (!text && images.length === 0) return;
313+
if (!text && images.length === 0 && files.length === 0) return;
257314

258315
const senderId = sender.sender_id?.open_id || sender.sender_id?.user_id || '';
259316
const senderName = await this.resolveUserName(senderId);
@@ -276,6 +333,7 @@ export class FeishuAdapter implements ChannelAdapter {
276333
text,
277334
messageId: msgId,
278335
images: images.length > 0 ? images : undefined,
336+
files: files.length > 0 ? files : undefined,
279337
senderType,
280338
mentioned: chatType === 'group' ? isMentioned : undefined,
281339
mentionedOthers: otherMentionNames.length > 0 ? otherMentionNames : undefined,

src/gateway.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,7 @@ export async function handleMessage(
319319
peers?: PeerBot[],
320320
): Promise<void> {
321321
const userText = msg.chatType === 'group' ? stripMention(msg.text) : msg.text;
322-
if (!userText && (!msg.images || msg.images.length === 0)) return;
322+
if (!userText && (!msg.images || msg.images.length === 0) && (!msg.files || msg.files.length === 0)) return;
323323

324324
// ── Slash command interception ──
325325
const parsed = parseCommand(userText);
@@ -493,7 +493,7 @@ export async function handleMessage(
493493
buffer = '';
494494
};
495495

496-
for await (const event of assistant.chat(fullText, { sessionKey, images: msg.images })) {
496+
for await (const event of assistant.chat(fullText, { sessionKey, images: msg.images, files: msg.files })) {
497497
if (event.type === 'text') {
498498
fullReply += event.content;
499499
buffer += event.content;
@@ -558,7 +558,7 @@ export async function handleMessage(
558558
}
559559
} else {
560560
// ── Buffered mode (default): accumulate all text, send at end ──
561-
for await (const event of assistant.chat(fullText, { sessionKey, images: msg.images })) {
561+
for await (const event of assistant.chat(fullText, { sessionKey, images: msg.images, files: msg.files })) {
562562
if (event.type === 'text') {
563563
fullReply += event.content;
564564
} else if (event.type === 'warning') {

src/index.ts

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { existsSync } from 'node:fs';
22
import { mkdir, rm, writeFile } from 'node:fs/promises';
33
import { join, resolve } from 'node:path';
4-
import type { ImageAttachment } from './channel.js';
4+
import type { FileAttachment, ImageAttachment } from './channel.js';
55
import { type AgentEngine, createEngine, type DiscoveredEngine, discoverEngines, type StreamEvent } from './engine.js';
66
import {
77
appendHistory,
@@ -147,6 +147,8 @@ export interface ChatOpts {
147147
sessionKey?: string;
148148
/** Images attached to the user message. Saved to disk and referenced in the prompt. */
149149
images?: ImageAttachment[];
150+
/** Files (non-image) attached to the user message. Saved to disk and referenced in the prompt. */
151+
files?: FileAttachment[];
150152
}
151153

152154
export interface Assistant {
@@ -221,6 +223,7 @@ export function createAssistant(opts: CreateAssistantOpts): Assistant {
221223
sessionKey: string,
222224
isRetry: boolean,
223225
images?: ImageAttachment[],
226+
files?: FileAttachment[],
224227
): AsyncIterable<StreamEvent> {
225228
const { config, skills } = await ensureReady(dir);
226229

@@ -278,12 +281,30 @@ export function createAssistant(opts: CreateAssistantOpts): Assistant {
278281
}
279282
}
280283

284+
// Save attached files to workspace temp dir so the agent can read them
285+
const filePaths: string[] = [];
286+
const fileDir = join(dir, '.golem', 'files');
287+
if (files && files.length > 0) {
288+
await mkdir(fileDir, { recursive: true });
289+
for (const file of files) {
290+
const filePath = join(fileDir, file.fileName);
291+
await writeFile(filePath, file.data);
292+
filePaths.push(filePath);
293+
}
294+
}
295+
281296
// Append image file paths to the message so the agent can read/view them
282297
if (imagePaths.length > 0) {
283298
const imageRefs = imagePaths.map((p) => p).join('\n');
284299
finalMessage += `\n\n[User attached ${imagePaths.length} image(s). File paths:\n${imageRefs}\nPlease read/view these files to see the images.]`;
285300
}
286301

302+
// Append file paths to the message so the agent can read them
303+
if (filePaths.length > 0) {
304+
const fileRefs = filePaths.map((p) => p).join('\n');
305+
finalMessage += `\n\n[User attached ${filePaths.length} file(s). File paths:\n${fileRefs}\nPlease read these files to see the content.]`;
306+
}
307+
287308
// Prune once per process
288309
if (!pruneDone) {
289310
pruneDone = true;
@@ -339,8 +360,8 @@ export function createAssistant(opts: CreateAssistantOpts): Assistant {
339360
}
340361
} finally {
341362
clearTimeout(timer);
342-
// Clean up temp image files
343-
for (const p of imagePaths) {
363+
// Clean up temp image and file attachments
364+
for (const p of [...imagePaths, ...filePaths]) {
344365
rm(p).catch(() => {});
345366
}
346367
}
@@ -416,7 +437,7 @@ export function createAssistant(opts: CreateAssistantOpts): Assistant {
416437
if (isResumeFail) {
417438
await clearSession(dir, sessionKey);
418439
yield { type: 'warning' as const, message: 'Session could not be resumed. Starting fresh conversation.' };
419-
yield* doChat(message, sessionKey, true, images);
440+
yield* doChat(message, sessionKey, true, images, files);
420441
}
421442
}
422443
}
@@ -425,6 +446,7 @@ export function createAssistant(opts: CreateAssistantOpts): Assistant {
425446
message: string,
426447
sessionKey: string,
427448
images?: ImageAttachment[],
449+
files?: FileAttachment[],
428450
): AsyncIterable<StreamEvent> {
429451
// Rate limits use opts values directly — no file I/O before acquiring the mutex,
430452
// so same-key serialization order is preserved (first caller wins the lock).
@@ -455,7 +477,7 @@ export function createAssistant(opts: CreateAssistantOpts): Assistant {
455477
}
456478

457479
try {
458-
yield* doChat(message, sessionKey, false, images);
480+
yield* doChat(message, sessionKey, false, images, files);
459481
} finally {
460482
activeChatCount--;
461483
mutex.release(sessionKey);
@@ -465,7 +487,7 @@ export function createAssistant(opts: CreateAssistantOpts): Assistant {
465487
return {
466488
chat(message: string, chatOpts?: ChatOpts): AsyncIterable<StreamEvent> {
467489
const key = chatOpts?.sessionKey || DEFAULT_SESSION_KEY;
468-
return chatImpl(message, key, chatOpts?.images);
490+
return chatImpl(message, key, chatOpts?.images, chatOpts?.files);
469491
},
470492

471493
async init(initOpts: { engine: string; name: string; role?: string }): Promise<void> {

src/server.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ export function createGolemServer(
121121
message?: string;
122122
sessionKey?: string;
123123
images?: Array<{ mimeType?: string; data?: string; fileName?: string }>;
124+
files?: Array<{ mimeType?: string; data?: string; fileName?: string }>;
124125
};
125126
try {
126127
body = JSON.parse(await readBody(req));
@@ -129,9 +130,10 @@ export function createGolemServer(
129130
return;
130131
}
131132

132-
// Allow image-only messages (no text required when images are present)
133+
// Allow image/file-only messages (no text required when attachments are present)
133134
const hasImages = Array.isArray(body.images) && body.images.length > 0;
134-
if ((!body.message || typeof body.message !== 'string') && !hasImages) {
135+
const hasFiles = Array.isArray(body.files) && body.files.length > 0;
136+
if ((!body.message || typeof body.message !== 'string') && !hasImages && !hasFiles) {
135137
json(res, 400, { error: 'Missing "message" field' });
136138
return;
137139
}
@@ -153,7 +155,24 @@ export function createGolemServer(
153155
}
154156
}
155157

156-
const chatMessage = body.message || '(image)';
158+
// Convert base64-encoded files to FileAttachment[]
159+
const files: Array<{ mimeType: string; data: Buffer; fileName: string }> = [];
160+
if (hasFiles) {
161+
for (const f of body.files!) {
162+
if (!f.data || !f.fileName) continue;
163+
try {
164+
files.push({
165+
mimeType: f.mimeType || 'application/octet-stream',
166+
data: Buffer.from(f.data, 'base64'),
167+
fileName: f.fileName,
168+
});
169+
} catch {
170+
/* skip malformed entries */
171+
}
172+
}
173+
}
174+
175+
const chatMessage = body.message || (hasImages ? '(image)' : '(file)');
157176

158177
// ── Slash command interception ──
159178
if (dir) {
@@ -197,6 +216,7 @@ export function createGolemServer(
197216
for await (const event of assistant.chat(chatMessage, {
198217
sessionKey: body.sessionKey,
199218
images: images.length > 0 ? images : undefined,
219+
files: files.length > 0 ? files : undefined,
200220
})) {
201221
res.write(`data: ${JSON.stringify(event)}\n\n`);
202222
if (event.type === 'text') replyText += event.content;

0 commit comments

Comments
 (0)