From 766e1e63ab7e8e09e2f6325bf3651f10ac756331 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sat, 11 Apr 2026 15:44:56 +0800 Subject: [PATCH] feat(feishu): add FeishuMediaService for download/upload/send Implements FeishuMediaService wrapping @larksuiteoapi/node-sdk im.messageResource, im.image, im.file, and im.message APIs. SDK call signatures verified against OpenClaw lark plugin reference implementation. 5 new tests, 172 total passing. Co-Authored-By: Claude Sonnet 4.6 --- adapters/feishu/__tests__/media.test.ts | 120 ++++++++++++++++ adapters/feishu/media.ts | 178 ++++++++++++++++++++++++ 2 files changed, 298 insertions(+) create mode 100644 adapters/feishu/__tests__/media.test.ts create mode 100644 adapters/feishu/media.ts diff --git a/adapters/feishu/__tests__/media.test.ts b/adapters/feishu/__tests__/media.test.ts new file mode 100644 index 00000000..5b350a99 --- /dev/null +++ b/adapters/feishu/__tests__/media.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test' +import * as fs from 'node:fs/promises' +import * as path from 'node:path' +import * as os from 'node:os' +import { FeishuMediaService } from '../media.js' +import { AttachmentStore } from '../../common/attachment/attachment-store.js' + +function makeMockClient() { + return { + im: { + messageResource: { + get: mock(async () => ({ + // node-sdk returns an object with a `.writeFile(path)` helper + // that dumps the underlying stream. We fake that here. + writeFile: async (target: string) => { + await fs.writeFile(target, Buffer.from('DOWNLOADED')) + }, + })), + }, + image: { + create: mock(async (_req: any) => ({ + data: { image_key: 'img_fake_123' }, + })), + }, + file: { + create: mock(async (_req: any) => ({ + data: { file_key: 'file_fake_456' }, + })), + }, + message: { + create: mock(async (_req: any) => ({ + data: { message_id: 'om_fake' }, + })), + }, + }, + } +} + +let tmpRoot: string + +beforeEach(async () => { + tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'feishu-media-test-')) +}) + +afterEach(async () => { + await fs.rm(tmpRoot, { recursive: true, force: true }) +}) + +describe('FeishuMediaService', () => { + it('downloadResource writes a local file and returns LocalAttachment', async () => { + const client = makeMockClient() + const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 }) + const svc = new FeishuMediaService(client as any, store) + const local = await svc.downloadResource({ + messageId: 'om_msg_1', + fileKey: 'img_key_1', + kind: 'image', + fileName: 'cat.png', + sessionId: 'sess-1', + }) + expect(local.kind).toBe('image') + expect(local.name).toBe('cat.png') + expect(local.size).toBe('DOWNLOADED'.length) + expect(local.path).toContain(path.join('feishu', 'sess-1')) + const onDisk = await fs.readFile(local.path) + expect(onDisk.toString()).toBe('DOWNLOADED') + expect(client.im.messageResource.get).toHaveBeenCalledTimes(1) + const call = (client.im.messageResource.get as any).mock.calls[0][0] + expect(call.path.message_id).toBe('om_msg_1') + expect(call.path.file_key).toBe('img_key_1') + expect(call.params.type).toBe('image') + }) + + it('uploadImage returns an image_key and sends the buffer through', async () => { + const client = makeMockClient() + const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 }) + const svc = new FeishuMediaService(client as any, store) + const key = await svc.uploadImage(Buffer.from('PNGDATA'), 'image/png') + expect(key).toBe('img_fake_123') + expect(client.im.image.create).toHaveBeenCalledTimes(1) + const call = (client.im.image.create as any).mock.calls[0][0] + expect(call.data.image_type).toBe('message') + expect(call.data.image).toBeDefined() + }) + + it('uploadFile returns a file_key and uses stream file_type mapping', async () => { + const client = makeMockClient() + const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 }) + const svc = new FeishuMediaService(client as any, store) + const key = await svc.uploadFile(Buffer.from('PDFDATA'), 'report.pdf') + expect(key).toBe('file_fake_456') + const call = (client.im.file.create as any).mock.calls[0][0] + expect(call.data.file_name).toBe('report.pdf') + expect(call.data.file_type).toBe('pdf') + }) + + it('sendImageMessage posts msg_type=image', async () => { + const client = makeMockClient() + const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 }) + const svc = new FeishuMediaService(client as any, store) + await svc.sendImageMessage('oc_chat_1', 'img_fake_123') + const call = (client.im.message.create as any).mock.calls[0][0] + expect(call.params.receive_id_type).toBe('chat_id') + expect(call.data.receive_id).toBe('oc_chat_1') + expect(call.data.msg_type).toBe('image') + const content = JSON.parse(call.data.content) + expect(content.image_key).toBe('img_fake_123') + }) + + it('sendFileMessage posts msg_type=file', async () => { + const client = makeMockClient() + const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 }) + const svc = new FeishuMediaService(client as any, store) + await svc.sendFileMessage('oc_chat_1', 'file_fake_456') + const call = (client.im.message.create as any).mock.calls[0][0] + expect(call.data.msg_type).toBe('file') + const content = JSON.parse(call.data.content) + expect(content.file_key).toBe('file_fake_456') + }) +}) diff --git a/adapters/feishu/media.ts b/adapters/feishu/media.ts new file mode 100644 index 00000000..aa5fff23 --- /dev/null +++ b/adapters/feishu/media.ts @@ -0,0 +1,178 @@ +/** + * Feishu media service — wraps im.messageResource / im.image / im.file + * so adapters/feishu/index.ts stays focused on flow control. + * + * References: + * - Feishu OpenAPI: POST /open-apis/im/v1/images + * POST /open-apis/im/v1/files + * GET /open-apis/im/v1/messages/{message_id}/resources/{file_key} + * - OpenClaw impl: openclaw-lark/src/messaging/outbound/media.ts:226,281,323,423,454 + */ + +import * as Lark from '@larksuiteoapi/node-sdk' +import * as fs from 'node:fs/promises' +import * as path from 'node:path' +import { Readable } from 'node:stream' +import { AttachmentStore } from '../common/attachment/attachment-store.js' +import type { LocalAttachment } from '../common/attachment/attachment-types.js' + +type LarkClient = InstanceType + +/** Map a filename extension to Feishu's file_type enum. */ +function detectFeishuFileType( + fileName: string, +): 'opus' | 'mp4' | 'pdf' | 'doc' | 'xls' | 'ppt' | 'stream' { + const ext = path.extname(fileName).toLowerCase().replace(/^\./, '') + switch (ext) { + case 'opus': + return 'opus' + case 'mp4': + return 'mp4' + case 'pdf': + return 'pdf' + case 'doc': + case 'docx': + return 'doc' + case 'xls': + case 'xlsx': + return 'xls' + case 'ppt': + case 'pptx': + return 'ppt' + default: + return 'stream' + } +} + +function guessMime(fileName: string, kind: 'image' | 'file'): string { + const ext = path.extname(fileName).toLowerCase().replace(/^\./, '') + if (kind === 'image') { + return ( + ({ + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + webp: 'image/webp', + heic: 'image/heic', + } as Record)[ext] || 'image/png' + ) + } + return ( + ({ + pdf: 'application/pdf', + doc: 'application/msword', + docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + xls: 'application/vnd.ms-excel', + xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ppt: 'application/vnd.ms-powerpoint', + pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + txt: 'text/plain', + json: 'application/json', + } as Record)[ext] || 'application/octet-stream' + ) +} + +export interface DownloadParams { + messageId: string + fileKey: string + kind: 'image' | 'file' + fileName?: string + sessionId: string +} + +export class FeishuMediaService { + constructor( + private readonly client: LarkClient, + private readonly store: AttachmentStore, + ) {} + + /** Download an image or file the user sent in Feishu into the local stage. */ + async downloadResource(params: DownloadParams): Promise { + const { messageId, fileKey, kind, sessionId } = params + const fallbackName = `${fileKey}${kind === 'image' ? '.png' : ''}` + const name = params.fileName || fallbackName + const target = this.store.resolvePath('feishu', sessionId, name) + + // node-sdk returns an object with a `.writeFile(target)` helper + // that dumps the underlying stream. See OpenClaw media.ts:147 and :237. + const resp: any = await (this.client.im as any).messageResource.get({ + path: { message_id: messageId, file_key: fileKey }, + params: { type: kind }, + }) + + if (typeof resp?.writeFile === 'function') { + await resp.writeFile(target) + } else if (resp?.data instanceof Buffer) { + await this.store.write(target, resp.data) + } else if (resp instanceof Buffer) { + await this.store.write(target, resp) + } else { + throw new Error('[FeishuMedia] Unknown downloadResource response shape') + } + + const buffer = await fs.readFile(target) + return { + kind, + name, + path: target, + size: buffer.length, + mimeType: guessMime(name, kind), + buffer, + } + } + + /** Upload an image buffer, returns image_key. + * node-sdk expects `image` to be a readable stream (see OpenClaw media.ts:290). + * We use Readable.from(buffer) to avoid spilling to disk for pure in-memory buffers. */ + async uploadImage(buffer: Buffer, _mime: string): Promise { + const resp: any = await this.client.im.image.create({ + data: { + image_type: 'message', + image: Readable.from(buffer), + }, + }) + const key = resp?.data?.image_key + if (!key) throw new Error('[FeishuMedia] uploadImage: missing image_key') + return key + } + + /** Upload a non-image file, returns file_key. + * See OpenClaw media.ts:334 for stream-based upload pattern. */ + async uploadFile(buffer: Buffer, fileName: string): Promise { + const resp: any = await this.client.im.file.create({ + data: { + file_type: detectFeishuFileType(fileName), + file_name: fileName, + file: Readable.from(buffer), + }, + }) + const key = resp?.data?.file_key + if (!key) throw new Error('[FeishuMedia] uploadFile: missing file_key') + return key + } + + /** Send an image message to a chat. See OpenClaw media.ts:435. */ + async sendImageMessage(chatId: string, imageKey: string): Promise { + await this.client.im.message.create({ + params: { receive_id_type: 'chat_id' }, + data: { + receive_id: chatId, + msg_type: 'image', + content: JSON.stringify({ image_key: imageKey }), + }, + }) + } + + /** Send a file message to a chat. See OpenClaw media.ts:466. */ + async sendFileMessage(chatId: string, fileKey: string): Promise { + await this.client.im.message.create({ + params: { receive_id_type: 'chat_id' }, + data: { + receive_id: chatId, + msg_type: 'file', + content: JSON.stringify({ file_key: fileKey }), + }, + }) + } +}