mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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 <noreply@anthropic.com>
This commit is contained in:
parent
ac89ec618c
commit
766e1e63ab
120
adapters/feishu/__tests__/media.test.ts
Normal file
120
adapters/feishu/__tests__/media.test.ts
Normal file
@ -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')
|
||||
})
|
||||
})
|
||||
178
adapters/feishu/media.ts
Normal file
178
adapters/feishu/media.ts
Normal file
@ -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<typeof Lark.Client>
|
||||
|
||||
/** 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<string, string>)[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<string, string>)[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<LocalAttachment> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 }),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user