From aaa2237235338daa84a30d2ac9d80d4ba39add3e Mon Sep 17 00:00:00 2001 From: peng Date: Tue, 10 Mar 2026 19:53:01 +0800 Subject: [PATCH] fix: correct feishu bridge image MIME detection bridge/adapters: - detect the real MIME type and file extension for Feishu-downloaded images from magic bytes and response headers - stop forcing every Feishu image attachment to image/png and .png before sending it to Claude tests: - add regression coverage for JPEG, PNG, and WebP Feishu media detection root cause: - the Feishu bridge always labeled downloaded images as image/png regardless of the actual bytes - sending screenshots/photos through the Feishu bridge could make Claude Code exit with code 1 and show a misleading provider/image support error even though the provider supported multimodal input --- src/__tests__/unit/feishu-media.test.ts | 51 ++++++++++ src/lib/bridge/adapters/feishu-adapter.ts | 22 ++-- src/lib/bridge/adapters/feishu-media.ts | 116 ++++++++++++++++++++++ 3 files changed, 174 insertions(+), 15 deletions(-) create mode 100644 src/__tests__/unit/feishu-media.test.ts create mode 100644 src/lib/bridge/adapters/feishu-media.ts diff --git a/src/__tests__/unit/feishu-media.test.ts b/src/__tests__/unit/feishu-media.test.ts new file mode 100644 index 00000000..0c085c2f --- /dev/null +++ b/src/__tests__/unit/feishu-media.test.ts @@ -0,0 +1,51 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + extensionForMimeType, + resolveFeishuResourceMimeType, + sniffImageMimeType, +} from '../../lib/bridge/adapters/feishu-media'; + +describe('sniffImageMimeType', () => { + it('detects jpeg from magic bytes', () => { + const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]); + assert.strictEqual(sniffImageMimeType(jpeg), 'image/jpeg'); + }); + + it('detects png from magic bytes', () => { + const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + assert.strictEqual(sniffImageMimeType(png), 'image/png'); + }); + + it('detects webp from riff container', () => { + const webp = Buffer.from([ + 0x52, 0x49, 0x46, 0x46, + 0x24, 0x00, 0x00, 0x00, + 0x57, 0x45, 0x42, 0x50, + ]); + assert.strictEqual(sniffImageMimeType(webp), 'image/webp'); + }); +}); + +describe('resolveFeishuResourceMimeType', () => { + it('prefers sniffed image type over hardcoded fallback', () => { + const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe1, 0x00, 0x18]); + assert.strictEqual(resolveFeishuResourceMimeType('image', jpeg), 'image/jpeg'); + }); + + it('falls back to response headers when bytes are unknown', () => { + const unknown = Buffer.from([0x00, 0x01, 0x02, 0x03]); + const headers = { 'content-type': 'image/webp; charset=binary' }; + assert.strictEqual(resolveFeishuResourceMimeType('image', unknown, headers), 'image/webp'); + }); +}); + +describe('extensionForMimeType', () => { + it('maps jpeg to jpg extension', () => { + assert.strictEqual(extensionForMimeType('image/jpeg', 'image'), 'jpg'); + }); + + it('maps webp to webp extension', () => { + assert.strictEqual(extensionForMimeType('image/webp', 'image'), 'webp'); + }); +}); diff --git a/src/lib/bridge/adapters/feishu-adapter.ts b/src/lib/bridge/adapters/feishu-adapter.ts index 9f9fe6e7..8513b802 100644 --- a/src/lib/bridge/adapters/feishu-adapter.ts +++ b/src/lib/bridge/adapters/feishu-adapter.ts @@ -27,6 +27,7 @@ import type { FileAttachment } from '@/types'; import { BaseChannelAdapter, registerAdapterFactory } from '../channel-adapter'; import { insertAuditLog } from '../../db'; import { getSetting } from '../../db'; +import { extensionForMimeType, resolveFeishuResourceMimeType } from './feishu-media'; import { htmlToFeishuMarkdown, preprocessFeishuMarkdown, @@ -70,16 +71,6 @@ type FeishuMessageEventData = { }; }; - -/** MIME type guesses by message_type. */ -const MIME_BY_TYPE: Record = { - image: 'image/png', - file: 'application/octet-stream', - audio: 'audio/ogg', - video: 'video/mp4', - media: 'application/octet-stream', -}; - export class FeishuAdapter extends BaseChannelAdapter { readonly channelType: ChannelType = 'feishu'; @@ -905,11 +896,12 @@ export class FeishuAdapter extends BaseChannelAdapter { const base64 = buffer.toString('base64'); const id = crypto.randomUUID(); - const mimeType = MIME_BY_TYPE[resourceType] || 'application/octet-stream'; - const ext = resourceType === 'image' ? 'png' - : resourceType === 'audio' ? 'ogg' - : resourceType === 'video' ? 'mp4' - : 'bin'; + const mimeType = resolveFeishuResourceMimeType( + resourceType, + buffer, + (res as { headers?: Headers | Record }).headers, + ); + const ext = extensionForMimeType(mimeType, resourceType); console.log(`[feishu-adapter] Resource downloaded: ${buffer.length} bytes, key=${fileKey}`); diff --git a/src/lib/bridge/adapters/feishu-media.ts b/src/lib/bridge/adapters/feishu-media.ts new file mode 100644 index 00000000..61b145f9 --- /dev/null +++ b/src/lib/bridge/adapters/feishu-media.ts @@ -0,0 +1,116 @@ +type HeaderBag = + | Headers + | Record + | undefined + | null; + +function normalizeMimeType(value: string | undefined): string | undefined { + if (!value) return undefined; + const mime = value.split(';', 1)[0]?.trim().toLowerCase(); + return mime || undefined; +} + +function getHeaderValue(headers: HeaderBag, name: string): string | undefined { + if (!headers) return undefined; + + if (typeof (headers as Headers).get === 'function') { + return (headers as Headers).get(name) || undefined; + } + + const lookup = name.toLowerCase(); + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() !== lookup) continue; + if (Array.isArray(value)) return value[0]; + return value; + } + + return undefined; +} + +export function sniffImageMimeType(buffer: Uint8Array): string | undefined { + if (buffer.length >= 3 + && buffer[0] === 0xff + && buffer[1] === 0xd8 + && buffer[2] === 0xff) { + return 'image/jpeg'; + } + + if (buffer.length >= 8 + && buffer[0] === 0x89 + && buffer[1] === 0x50 + && buffer[2] === 0x4e + && buffer[3] === 0x47 + && buffer[4] === 0x0d + && buffer[5] === 0x0a + && buffer[6] === 0x1a + && buffer[7] === 0x0a) { + return 'image/png'; + } + + if (buffer.length >= 6) { + const signature = Buffer.from(buffer.subarray(0, 6)).toString('ascii'); + if (signature === 'GIF87a' || signature === 'GIF89a') { + return 'image/gif'; + } + } + + if (buffer.length >= 12) { + const riff = Buffer.from(buffer.subarray(0, 4)).toString('ascii'); + const webp = Buffer.from(buffer.subarray(8, 12)).toString('ascii'); + if (riff === 'RIFF' && webp === 'WEBP') { + return 'image/webp'; + } + } + + if (buffer.length >= 2 + && buffer[0] === 0x42 + && buffer[1] === 0x4d) { + return 'image/bmp'; + } + + return undefined; +} + +export function resolveFeishuResourceMimeType( + resourceType: string, + buffer: Uint8Array, + headers?: HeaderBag, +): string { + const headerMime = normalizeMimeType(getHeaderValue(headers, 'content-type')); + + if (resourceType === 'image') { + return sniffImageMimeType(buffer) || headerMime || 'image/png'; + } + + if (resourceType === 'audio') { + return headerMime || 'audio/ogg'; + } + + if (resourceType === 'video') { + return headerMime || 'video/mp4'; + } + + return headerMime || 'application/octet-stream'; +} + +export function extensionForMimeType(mimeType: string, resourceType: string): string { + switch (mimeType.toLowerCase()) { + case 'image/jpeg': + case 'image/jpg': + return 'jpg'; + case 'image/png': + return 'png'; + case 'image/gif': + return 'gif'; + case 'image/webp': + return 'webp'; + case 'image/bmp': + return 'bmp'; + case 'audio/ogg': + return 'ogg'; + case 'video/mp4': + return 'mp4'; + default: + return resourceType === 'image' ? 'png' : 'bin'; + } +}