Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions src/__tests__/unit/feishu-media.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
22 changes: 7 additions & 15 deletions src/lib/bridge/adapters/feishu-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -70,16 +71,6 @@ type FeishuMessageEventData = {
};
};


/** MIME type guesses by message_type. */
const MIME_BY_TYPE: Record<string, string> = {
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';

Expand Down Expand Up @@ -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<string, string | string[] | undefined> }).headers,
);
const ext = extensionForMimeType(mimeType, resourceType);

console.log(`[feishu-adapter] Resource downloaded: ${buffer.length} bytes, key=${fileKey}`);

Expand Down
116 changes: 116 additions & 0 deletions src/lib/bridge/adapters/feishu-media.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
type HeaderBag =
| Headers
| Record<string, string | string[] | undefined>
| 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';
}
}