diff --git a/src/lib/telegram/chat/__tests__/bufferStartsWith.test.ts b/src/lib/telegram/chat/__tests__/bufferStartsWith.test.ts new file mode 100644 index 00000000..30bb6419 --- /dev/null +++ b/src/lib/telegram/chat/__tests__/bufferStartsWith.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest'; +import bufferStartsWith from '../bufferStartsWith'; + +describe('bufferStartsWith', () => { + it('returns true when the buffer starts with the magic bytes', () => { + expect( + bufferStartsWith( + Buffer.from([0xff, 0xd8, 0xff, 0x00]), + [0xff, 0xd8, 0xff] + ) + ).toBe(true); + }); + + it('returns false when the prefix does not match', () => { + expect( + bufferStartsWith(Buffer.from([0x00, 0xd8, 0xff]), [0xff, 0xd8, 0xff]) + ).toBe(false); + }); +}); diff --git a/src/lib/telegram/chat/__tests__/detectMimeTypeFromBuffer.test.ts b/src/lib/telegram/chat/__tests__/detectMimeTypeFromBuffer.test.ts new file mode 100644 index 00000000..bbbe8ea6 --- /dev/null +++ b/src/lib/telegram/chat/__tests__/detectMimeTypeFromBuffer.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import detectMimeTypeFromBuffer from '../detectMimeTypeFromBuffer'; + +const readFixture = (name: string): Buffer => + readFileSync(join(__dirname, 'fixtures', name)); + +describe('detectMimeTypeFromBuffer', () => { + it('detects HEIC from magic bytes when the file path has no extension', () => { + const buffer = readFixture('apple-styled-photo.heic'); + expect(detectMimeTypeFromBuffer(buffer)).toBe('image/heic'); + }); + + it('detects HEIC for the shelf fixture', () => { + const buffer = readFixture('shelf-christmas-decoration.heic'); + expect(detectMimeTypeFromBuffer(buffer)).toBe('image/heic'); + }); + + it('returns undefined for unknown bytes', () => { + expect( + detectMimeTypeFromBuffer(Buffer.from('not-an-image')) + ).toBeUndefined(); + }); +}); diff --git a/src/lib/telegram/chat/__tests__/extractExifCaptureDate.heic.test.ts b/src/lib/telegram/chat/__tests__/extractExifCaptureDate.heic.test.ts new file mode 100644 index 00000000..652aa2e9 --- /dev/null +++ b/src/lib/telegram/chat/__tests__/extractExifCaptureDate.heic.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import extractExifCaptureDate from '../extractExifCaptureDate'; + +const readFixture = (name: string): Buffer => + readFileSync(join(__dirname, 'fixtures', name)); + +describe('extractExifCaptureDate HEIC integration', () => { + it('extracts the correct UTC instant from the shelf HEIC fixture', async () => { + const result = await extractExifCaptureDate( + readFixture('shelf-christmas-decoration.heic') + ); + + expect(result).toBe( + Math.floor(new Date('2023-12-13T12:03:39.000Z').getTime() / 1000) + ); + }); + + it('extracts the correct UTC instant from an Apple multi-brand HEIC fixture', async () => { + const result = await extractExifCaptureDate( + readFixture('apple-styled-photo.heic') + ); + + expect(result).toBe( + Math.floor(new Date('2026-07-05T16:02:17.000Z').getTime() / 1000) + ); + }); +}); diff --git a/src/lib/telegram/chat/__tests__/extractExifCaptureDate.test.ts b/src/lib/telegram/chat/__tests__/extractExifCaptureDate.test.ts index f77c7f07..1ab3ae59 100644 --- a/src/lib/telegram/chat/__tests__/extractExifCaptureDate.test.ts +++ b/src/lib/telegram/chat/__tests__/extractExifCaptureDate.test.ts @@ -14,7 +14,7 @@ beforeEach(() => { }); describe('extractExifCaptureDate', () => { - it('requests raw (non-revived) values including OffsetTimeOriginal', async () => { + it('requests raw (non-revived) values including offset tags', async () => { mockParse.mockResolvedValue({ DateTimeOriginal: '2026:07:09 23:32:10', OffsetTimeOriginal: '+09:00', @@ -23,7 +23,7 @@ describe('extractExifCaptureDate', () => { await extractExifCaptureDate(Buffer.from('jpeg-bytes')); expect(mockParse).toHaveBeenCalledWith(expect.any(Buffer), { - pick: ['DateTimeOriginal', 'OffsetTimeOriginal'], + pick: ['DateTimeOriginal', 'OffsetTimeOriginal', 'OffsetTime'], reviveValues: false, }); }); @@ -41,6 +41,19 @@ describe('extractExifCaptureDate', () => { ); }); + it('falls back to OffsetTime when OffsetTimeOriginal is absent', async () => { + mockParse.mockResolvedValue({ + DateTimeOriginal: '2023:12:13 13:03:39', + OffsetTime: '+01:00', + }); + + const result = await extractExifCaptureDate(Buffer.from('heic-bytes')); + + expect(result).toBe( + (Date.UTC(2023, 11, 13, 13, 3, 39) - 60 * 60_000) / 1000 + ); + }); + it('subtracts a negative OffsetTimeOriginal correctly', async () => { mockParse.mockResolvedValue({ DateTimeOriginal: '2026:07:09 23:32:10', @@ -54,7 +67,7 @@ describe('extractExifCaptureDate', () => { ); }); - it('skips EXIF entirely when OffsetTimeOriginal is absent, even if DateTimeOriginal is present', async () => { + it('skips EXIF entirely when no offset tag is present, even if DateTimeOriginal is present', async () => { mockParse.mockResolvedValue({ DateTimeOriginal: '2026:07:09 23:32:10' }); const result = await extractExifCaptureDate(Buffer.from('jpeg-bytes')); @@ -62,10 +75,11 @@ describe('extractExifCaptureDate', () => { expect(result).toBeUndefined(); }); - it('skips EXIF when OffsetTimeOriginal is present but malformed', async () => { + it('skips EXIF when offset tags are present but malformed', async () => { mockParse.mockResolvedValue({ DateTimeOriginal: '2026:07:09 23:32:10', OffsetTimeOriginal: 'garbage', + OffsetTime: 'also-garbage', }); const result = await extractExifCaptureDate(Buffer.from('jpeg-bytes')); diff --git a/src/lib/telegram/chat/__tests__/fetchTelegramFile.test.ts b/src/lib/telegram/chat/__tests__/fetchTelegramFile.test.ts index a41ddeb9..c926595f 100644 --- a/src/lib/telegram/chat/__tests__/fetchTelegramFile.test.ts +++ b/src/lib/telegram/chat/__tests__/fetchTelegramFile.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; import fetchTelegramFile from '../fetchTelegramFile'; vi.mock('../getTelegramFilePath', () => ({ default: vi.fn() })); @@ -41,6 +43,27 @@ describe('fetchTelegramFile', () => { expect(result.mimeType).toBe('video/mp4'); }); + it('sniffs HEIC mimeType from magic bytes when the Telegram path has no extension', async () => { + const buffer = readFileSync( + join(__dirname, 'fixtures', 'apple-styled-photo.heic') + ); + vi.mocked(getTelegramFilePath).mockResolvedValue('documents/111'); + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + arrayBuffer: () => + Promise.resolve( + buffer.buffer.slice( + buffer.byteOffset, + buffer.byteOffset + buffer.byteLength + ) + ), + } as never); + + const result = await fetchTelegramFile('file-id'); + + expect(result.mimeType).toBe('image/heic'); + }); + it('throws when the Telegram file response is not ok', async () => { vi.mocked(global.fetch).mockResolvedValue({ ok: false, diff --git a/src/lib/telegram/chat/__tests__/fixtures/apple-styled-photo.heic b/src/lib/telegram/chat/__tests__/fixtures/apple-styled-photo.heic new file mode 100644 index 00000000..ff090707 Binary files /dev/null and b/src/lib/telegram/chat/__tests__/fixtures/apple-styled-photo.heic differ diff --git a/src/lib/telegram/chat/__tests__/fixtures/shelf-christmas-decoration.heic b/src/lib/telegram/chat/__tests__/fixtures/shelf-christmas-decoration.heic new file mode 100644 index 00000000..25429284 Binary files /dev/null and b/src/lib/telegram/chat/__tests__/fixtures/shelf-christmas-decoration.heic differ diff --git a/src/lib/telegram/chat/__tests__/getMimeTypeFromFilePath.test.ts b/src/lib/telegram/chat/__tests__/getMimeTypeFromFilePath.test.ts index b063a300..b1edd185 100644 --- a/src/lib/telegram/chat/__tests__/getMimeTypeFromFilePath.test.ts +++ b/src/lib/telegram/chat/__tests__/getMimeTypeFromFilePath.test.ts @@ -8,6 +8,8 @@ describe('getMimeTypeFromFilePath', () => { ['image.png', 'image/png'], ['animation.gif', 'image/gif'], ['photo.webp', 'image/webp'], + ['photo.heic', 'image/heic'], + ['photo.heif', 'image/heif'], ['video.mp4', 'video/mp4'], ['video.mov', 'video/quicktime'], ['video.avi', 'video/x-msvideo'], diff --git a/src/lib/telegram/chat/__tests__/needsMimeSniff.test.ts b/src/lib/telegram/chat/__tests__/needsMimeSniff.test.ts new file mode 100644 index 00000000..8ccc9b3c --- /dev/null +++ b/src/lib/telegram/chat/__tests__/needsMimeSniff.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from 'vitest'; +import needsMimeSniff from '../needsMimeSniff'; + +describe('needsMimeSniff', () => { + it('returns true for file attachments without a useful mimeType', () => { + expect(needsMimeSniff({ type: 'file' } as never)).toBe(true); + expect( + needsMimeSniff({ + type: 'file', + mimeType: 'application/octet-stream', + } as never) + ).toBe(true); + }); + + it('returns false when the mimeType is already known', () => { + expect( + needsMimeSniff({ type: 'file', mimeType: 'image/jpeg' } as never) + ).toBe(false); + }); +}); diff --git a/src/lib/telegram/chat/__tests__/parseExifOffsetMs.test.ts b/src/lib/telegram/chat/__tests__/parseExifOffsetMs.test.ts index 521e56e3..e61c038f 100644 --- a/src/lib/telegram/chat/__tests__/parseExifOffsetMs.test.ts +++ b/src/lib/telegram/chat/__tests__/parseExifOffsetMs.test.ts @@ -10,19 +10,19 @@ describe('parseExifOffsetMs', () => { expect(parseExifOffsetMs('-05:00')).toBe(-5 * 60 * 60_000); }); - it('parses a non-zero minutes offset', () => { + it('parses a half-hour offset', () => { expect(parseExifOffsetMs('+05:30')).toBe((5 * 60 + 30) * 60_000); }); - it('returns undefined for undefined input', () => { + it('returns undefined for missing values', () => { expect(parseExifOffsetMs(undefined)).toBeUndefined(); }); - it('returns undefined for a malformed string', () => { + it('returns undefined for malformed strings', () => { expect(parseExifOffsetMs('not-an-offset')).toBeUndefined(); }); - it('returns undefined for a non-string value', () => { + it('returns undefined for non-string types', () => { expect(parseExifOffsetMs(540)).toBeUndefined(); }); }); diff --git a/src/lib/telegram/chat/__tests__/readFtypBrands.test.ts b/src/lib/telegram/chat/__tests__/readFtypBrands.test.ts new file mode 100644 index 00000000..2109d758 --- /dev/null +++ b/src/lib/telegram/chat/__tests__/readFtypBrands.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import readFtypBrands from '../readFtypBrands'; + +describe('readFtypBrands', () => { + it('reads compatible brands from a HEIC fixture', () => { + const buffer = readFileSync( + join(__dirname, 'fixtures', 'apple-styled-photo.heic') + ); + + expect(readFtypBrands(buffer)).toContain('heic'); + }); + + it('returns an empty array for non-ftyp bytes', () => { + expect(readFtypBrands(Buffer.from('not-ftyp'))).toEqual([]); + }); +}); diff --git a/src/lib/telegram/chat/__tests__/resolveExifOffsetMs.test.ts b/src/lib/telegram/chat/__tests__/resolveExifOffsetMs.test.ts new file mode 100644 index 00000000..b600be9d --- /dev/null +++ b/src/lib/telegram/chat/__tests__/resolveExifOffsetMs.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from 'vitest'; +import resolveExifOffsetMs from '../resolveExifOffsetMs'; + +describe('resolveExifOffsetMs', () => { + it('prefers OffsetTimeOriginal over OffsetTime', () => { + expect( + resolveExifOffsetMs({ + OffsetTimeOriginal: '+09:00', + OffsetTime: '+01:00', + }) + ).toBe(9 * 60 * 60_000); + }); + + it('falls back to OffsetTime when OffsetTimeOriginal is absent', () => { + expect(resolveExifOffsetMs({ OffsetTime: '+01:00' })).toBe(60 * 60_000); + }); + + it('returns undefined when both offset tags are absent', () => { + expect(resolveExifOffsetMs({})).toBeUndefined(); + }); +}); diff --git a/src/lib/telegram/chat/__tests__/resolveTelegramFileMimeType.test.ts b/src/lib/telegram/chat/__tests__/resolveTelegramFileMimeType.test.ts new file mode 100644 index 00000000..e8966805 --- /dev/null +++ b/src/lib/telegram/chat/__tests__/resolveTelegramFileMimeType.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import resolveTelegramFileMimeType from '../resolveTelegramFileMimeType'; + +const readFixture = (name: string): Buffer => + readFileSync(join(__dirname, 'fixtures', name)); + +describe('resolveTelegramFileMimeType', () => { + it('prefers the file path extension when present', () => { + const buffer = readFixture('shelf-christmas-decoration.heic'); + expect(resolveTelegramFileMimeType('photos/file.jpg', buffer)).toBe( + 'image/jpeg' + ); + }); + + it('sniffs HEIC from magic bytes when the Telegram path has no extension', () => { + const buffer = readFixture('apple-styled-photo.heic'); + expect(resolveTelegramFileMimeType('documents/111', buffer)).toBe( + 'image/heic' + ); + }); +}); diff --git a/src/lib/telegram/chat/__tests__/resolveTelegramMediaAttachment.test.ts b/src/lib/telegram/chat/__tests__/resolveTelegramMediaAttachment.test.ts new file mode 100644 index 00000000..d3709ceb --- /dev/null +++ b/src/lib/telegram/chat/__tests__/resolveTelegramMediaAttachment.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../resolveMediaAttachmentType', () => ({ default: vi.fn() })); +vi.mock('../extractTelegramFileIds', () => ({ default: vi.fn() })); +vi.mock('../fetchTelegramFile', () => ({ default: vi.fn() })); + +import resolveMediaAttachmentType from '../resolveMediaAttachmentType'; +import extractTelegramFileIds from '../extractTelegramFileIds'; +import fetchTelegramFile from '../fetchTelegramFile'; +import resolveTelegramMediaAttachment from '../resolveTelegramMediaAttachment'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('resolveTelegramMediaAttachment', () => { + it('returns the attachment with a resolved type when mime is already known', async () => { + vi.mocked(resolveMediaAttachmentType).mockReturnValue('image'); + const attachment = { type: 'file', mimeType: 'image/jpeg' } as never; + + const result = await resolveTelegramMediaAttachment( + { attachments: [attachment] } as never, + attachment + ); + + expect(result).toEqual({ + type: 'image', + mimeType: 'image/jpeg', + }); + expect(fetchTelegramFile).not.toHaveBeenCalled(); + }); + + it('sniffs mime from Telegram file bytes when the document has no useful mimeType', async () => { + vi.mocked(resolveMediaAttachmentType) + .mockReturnValueOnce(undefined) + .mockReturnValueOnce('image'); + vi.mocked(extractTelegramFileIds).mockReturnValue({ + fileId: 'file-1', + thumbFileId: undefined, + }); + vi.mocked(fetchTelegramFile).mockResolvedValue({ + buffer: Buffer.from('bytes'), + mimeType: 'image/heic', + }); + + const attachment = { + type: 'file', + mimeType: 'application/octet-stream', + } as never; + + const result = await resolveTelegramMediaAttachment( + { attachments: [attachment] } as never, + attachment + ); + + expect(fetchTelegramFile).toHaveBeenCalledWith('file-1'); + expect(result).toEqual({ + type: 'image', + mimeType: 'image/heic', + }); + }); + + it('returns undefined when sniffing still does not resolve to media', async () => { + vi.mocked(resolveMediaAttachmentType).mockReturnValue(undefined); + vi.mocked(extractTelegramFileIds).mockReturnValue({ + fileId: 'file-1', + thumbFileId: undefined, + }); + vi.mocked(fetchTelegramFile).mockResolvedValue({ + buffer: Buffer.from('bytes'), + mimeType: 'application/octet-stream', + }); + + const attachment = { type: 'file' } as never; + + const result = await resolveTelegramMediaAttachment( + { attachments: [attachment] } as never, + attachment + ); + + expect(result).toBeUndefined(); + }); +}); diff --git a/src/lib/telegram/chat/bufferStartsWith.ts b/src/lib/telegram/chat/bufferStartsWith.ts new file mode 100644 index 00000000..72d0787c --- /dev/null +++ b/src/lib/telegram/chat/bufferStartsWith.ts @@ -0,0 +1,4 @@ +const bufferStartsWith = (buffer: Buffer, magic: number[]): boolean => + magic.every((byte, index) => buffer[index] === byte); + +export default bufferStartsWith; diff --git a/src/lib/telegram/chat/detectMimeTypeFromBuffer.ts b/src/lib/telegram/chat/detectMimeTypeFromBuffer.ts new file mode 100644 index 00000000..90e348d4 --- /dev/null +++ b/src/lib/telegram/chat/detectMimeTypeFromBuffer.ts @@ -0,0 +1,68 @@ +import bufferStartsWith from './bufferStartsWith'; +import readFtypBrands from './readFtypBrands'; + +const JPEG_MAGIC = [0xff, 0xd8, 0xff]; +const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47]; +const GIF87_MAGIC = [0x47, 0x49, 0x46, 0x38, 0x37, 0x61]; +const GIF89_MAGIC = [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]; +const RIFF_MAGIC = [0x52, 0x49, 0x46, 0x46]; +const WEBP_MAGIC = [0x57, 0x45, 0x42, 0x50]; + +const FTYP_IMAGE_BRANDS = new Set(['heic', 'heix', 'heim', 'heis', 'mif1']); +const FTYP_HEIF_BRANDS = new Set([ + 'heif', + 'hevc', + 'hevx', + 'hevm', + 'hevs', + 'msf1', +]); +const FTYP_VIDEO_BRANDS = new Set([ + 'mp41', + 'mp42', + 'isom', + 'iso2', + 'avc1', + 'qt ', + 'M4V ', + 'M4A ', + 'f4v ', + 'dash', +]); + +/** + * Sniffs image/video MIME from magic bytes when the Telegram file path + * has no useful extension (e.g. a document named "111"). + */ +const detectMimeTypeFromBuffer = (buffer: Buffer): string | undefined => { + if (buffer.length < 12) return undefined; + + if (bufferStartsWith(buffer, JPEG_MAGIC)) return 'image/jpeg'; + if (bufferStartsWith(buffer, PNG_MAGIC)) return 'image/png'; + if ( + bufferStartsWith(buffer, GIF87_MAGIC) || + bufferStartsWith(buffer, GIF89_MAGIC) + ) { + return 'image/gif'; + } + if ( + bufferStartsWith(buffer, RIFF_MAGIC) && + bufferStartsWith(buffer.subarray(8), WEBP_MAGIC) + ) { + return 'image/webp'; + } + + const brands = readFtypBrands(buffer); + if (brands.length > 0) { + if (brands.some((brand) => FTYP_HEIF_BRANDS.has(brand))) + return 'image/heif'; + if (brands.some((brand) => FTYP_IMAGE_BRANDS.has(brand))) + return 'image/heic'; + if (brands.some((brand) => FTYP_VIDEO_BRANDS.has(brand))) + return 'video/mp4'; + } + + return undefined; +}; + +export default detectMimeTypeFromBuffer; diff --git a/src/lib/telegram/chat/extractExifCaptureDate.ts b/src/lib/telegram/chat/extractExifCaptureDate.ts index 16da0249..46d6413b 100644 --- a/src/lib/telegram/chat/extractExifCaptureDate.ts +++ b/src/lib/telegram/chat/extractExifCaptureDate.ts @@ -1,5 +1,8 @@ import exifr from 'exifr'; -import parseExifOffsetMs from './parseExifOffsetMs'; +import patchExifrHeicDetection from './patchExifrHeicDetection'; +import resolveExifOffsetMs from './resolveExifOffsetMs'; + +patchExifrHeicDetection(); const EXIF_DATE_PATTERN = /^(\d{4}):(\d{2}):(\d{2}) (\d{2}):(\d{2}):(\d{2})$/; @@ -8,15 +11,15 @@ const extractExifCaptureDate = async ( ): Promise => { try { const result = await exifr.parse(buffer, { - pick: ['DateTimeOriginal', 'OffsetTimeOriginal'], + pick: ['DateTimeOriginal', 'OffsetTimeOriginal', 'OffsetTime'], reviveValues: false, }); // DateTimeOriginal alone carries no timezone, and saleStart is written - // on-chain — so without a real OffsetTimeOriginal (EXIF 2.31+) to compute - // the true UTC instant, we don't guess. Skip EXIF entirely and let the - // caller fall back to upload time. - const offsetMs = parseExifOffsetMs(result?.OffsetTimeOriginal); + // on-chain — so without OffsetTimeOriginal or OffsetTime (EXIF 2.31+) to + // compute the true UTC instant, we don't guess. Skip EXIF entirely and + // let the caller fall back to upload time. + const offsetMs = resolveExifOffsetMs(result); if (offsetMs === undefined) return undefined; const raw = result?.DateTimeOriginal; diff --git a/src/lib/telegram/chat/fetchTelegramFile.ts b/src/lib/telegram/chat/fetchTelegramFile.ts index 66f670c3..08537c01 100644 --- a/src/lib/telegram/chat/fetchTelegramFile.ts +++ b/src/lib/telegram/chat/fetchTelegramFile.ts @@ -1,19 +1,20 @@ import getTelegramFilePath from './getTelegramFilePath'; -import getMimeTypeFromFilePath from './getMimeTypeFromFilePath'; +import resolveTelegramFileMimeType from './resolveTelegramFileMimeType'; const fetchTelegramFile = async ( fileId: string ): Promise<{ buffer: Buffer; mimeType: string }> => { const token = process.env.TELEGRAM_CHAT_BOT_TOKEN; const filePath = await getTelegramFilePath(fileId); - const mimeType = getMimeTypeFromFilePath(filePath); const res = await fetch( `https://api.telegram.org/file/bot${token}/${filePath}` ); if (!res.ok) throw new Error(`Failed to fetch Telegram file: ${res.statusText}`); const arrayBuffer = await res.arrayBuffer(); - return { buffer: Buffer.from(arrayBuffer), mimeType }; + const buffer = Buffer.from(arrayBuffer); + const mimeType = resolveTelegramFileMimeType(filePath, buffer); + return { buffer, mimeType }; }; export default fetchTelegramFile; diff --git a/src/lib/telegram/chat/getMimeTypeFromFilePath.ts b/src/lib/telegram/chat/getMimeTypeFromFilePath.ts index daa78105..543f174c 100644 --- a/src/lib/telegram/chat/getMimeTypeFromFilePath.ts +++ b/src/lib/telegram/chat/getMimeTypeFromFilePath.ts @@ -4,6 +4,8 @@ const mimeTypeMap: Record = { png: 'image/png', gif: 'image/gif', webp: 'image/webp', + heic: 'image/heic', + heif: 'image/heif', mp4: 'video/mp4', mov: 'video/quicktime', avi: 'video/x-msvideo', diff --git a/src/lib/telegram/chat/handlers/__tests__/onNewMention.test.ts b/src/lib/telegram/chat/handlers/__tests__/onNewMention.test.ts index 4cbecf58..ee19f24f 100644 --- a/src/lib/telegram/chat/handlers/__tests__/onNewMention.test.ts +++ b/src/lib/telegram/chat/handlers/__tests__/onNewMention.test.ts @@ -10,6 +10,7 @@ vi.mock( vi.mock('@/lib/telegram/parseTelegramChatId', () => ({ default: vi.fn() })); vi.mock('../../commands/commandsHandler', () => ({ default: vi.fn() })); vi.mock('../../processMediaThread', () => ({ default: vi.fn() })); +vi.mock('../../resolveTelegramMediaAttachment', () => ({ default: vi.fn() })); vi.mock('../../processYoutubeLink', () => ({ default: vi.fn() })); vi.mock('@/lib/link/youtubeParser', () => ({ default: vi.fn() })); vi.mock('../../commands/connectHandler', () => ({ default: vi.fn() })); @@ -19,6 +20,7 @@ import upsertAccountNotification from '@/lib/supabase/account_notifications/upse import parseTelegramChatId from '@/lib/telegram/parseTelegramChatId'; import commandsHandler from '../../commands/commandsHandler'; import processMediaThread from '../../processMediaThread'; +import resolveTelegramMediaAttachment from '../../resolveTelegramMediaAttachment'; import processYoutubeLink from '../../processYoutubeLink'; import youtubeParser from '@/lib/link/youtubeParser'; import connectHandler from '../../commands/connectHandler'; @@ -72,6 +74,7 @@ beforeEach(() => { error: null, } as never); vi.mocked(commandsHandler).mockResolvedValue(false); + vi.mocked(resolveTelegramMediaAttachment).mockResolvedValue(undefined); vi.mocked(processMediaThread).mockResolvedValue(undefined); vi.mocked(processYoutubeLink).mockResolvedValue(undefined); vi.mocked(youtubeParser).mockReturnValue(false); @@ -133,6 +136,10 @@ describe('onNewMention', () => { it('processes image attachment when present', async () => { const attachment = { type: 'image', mimeType: 'image/jpeg' }; const message = makeMessage({ attachments: [attachment] }); + vi.mocked(resolveTelegramMediaAttachment).mockResolvedValue({ + type: 'image', + mimeType: 'image/jpeg', + } as never); await capturedHandler!(makeThread(), message); @@ -142,6 +149,10 @@ describe('onNewMention', () => { it('processes an image sent as a Telegram document', async () => { const attachment = { type: 'file', mimeType: 'image/jpeg' }; const message = makeMessage({ attachments: [attachment] }); + vi.mocked(resolveTelegramMediaAttachment).mockResolvedValue({ + type: 'image', + mimeType: 'image/jpeg', + } as never); await capturedHandler!(makeThread(), message); @@ -157,6 +168,10 @@ describe('onNewMention', () => { it('processes a video sent as a Telegram document', async () => { const attachment = { type: 'file', mimeType: 'video/mp4' }; const message = makeMessage({ attachments: [attachment] }); + vi.mocked(resolveTelegramMediaAttachment).mockResolvedValue({ + type: 'video', + mimeType: 'video/mp4', + } as never); await capturedHandler!(makeThread(), message); @@ -169,6 +184,32 @@ describe('onNewMention', () => { ); }); + it('sniffs extensionless HEIC documents via resolveTelegramMediaAttachment', async () => { + const attachment = { + type: 'file', + mimeType: 'application/octet-stream', + }; + const message = makeMessage({ attachments: [attachment] }); + vi.mocked(resolveTelegramMediaAttachment).mockResolvedValue({ + type: 'image', + mimeType: 'image/heic', + } as never); + + await capturedHandler!(makeThread(), message); + + expect(resolveTelegramMediaAttachment).toHaveBeenCalledWith( + message, + attachment + ); + expect(processMediaThread).toHaveBeenCalledWith( + expect.anything(), + message, + expect.objectContaining({ type: 'image', mimeType: 'image/heic' }), + '', + ARTIST + ); + }); + it('rejects a non-media document (e.g. PDF) with the fallback message', async () => { const attachment = { type: 'file', mimeType: 'application/pdf' }; const thread = makeThread(); diff --git a/src/lib/telegram/chat/handlers/onNewMention.ts b/src/lib/telegram/chat/handlers/onNewMention.ts index fdc4890e..2b75c220 100644 --- a/src/lib/telegram/chat/handlers/onNewMention.ts +++ b/src/lib/telegram/chat/handlers/onNewMention.ts @@ -5,7 +5,7 @@ import upsertAccountNotification from '@/lib/supabase/account_notifications/upse import parseTelegramChatId from '@/lib/telegram/parseTelegramChatId'; import commandsHandler from '../commands/commandsHandler'; import processMediaThread from '../processMediaThread'; -import resolveMediaAttachmentType from '../resolveMediaAttachmentType'; +import resolveTelegramMediaAttachment from '../resolveTelegramMediaAttachment'; import youtubeParser from '@/lib/link/youtubeParser'; import processYoutubeLink from '../processYoutubeLink'; import connectHandler from '../commands/connectHandler'; @@ -44,16 +44,21 @@ export function registerOnNewMention(bot: TelegramChatBot) { if (handled) return; const attachment = message.attachments?.[0]; - const resolvedType = attachment && resolveMediaAttachmentType(attachment); - if (attachment && resolvedType) { - await processMediaThread( - thread, + if (attachment) { + const resolvedAttachment = await resolveTelegramMediaAttachment( message, - { ...attachment, type: resolvedType }, - text, - artist + attachment ); - return; + if (resolvedAttachment) { + await processMediaThread( + thread, + message, + resolvedAttachment, + text, + artist + ); + return; + } } const youtubeUrl = text.match(YOUTUBE_URL_REGEX)?.[0]; diff --git a/src/lib/telegram/chat/needsMimeSniff.ts b/src/lib/telegram/chat/needsMimeSniff.ts new file mode 100644 index 00000000..2f62cce7 --- /dev/null +++ b/src/lib/telegram/chat/needsMimeSniff.ts @@ -0,0 +1,7 @@ +import type { Attachment } from 'chat'; + +const needsMimeSniff = (attachment: Attachment): boolean => + attachment.type === 'file' && + (!attachment.mimeType || attachment.mimeType === 'application/octet-stream'); + +export default needsMimeSniff; diff --git a/src/lib/telegram/chat/parseExifOffsetMs.ts b/src/lib/telegram/chat/parseExifOffsetMs.ts index b914eafe..0e8e5eb2 100644 --- a/src/lib/telegram/chat/parseExifOffsetMs.ts +++ b/src/lib/telegram/chat/parseExifOffsetMs.ts @@ -1,6 +1,6 @@ const EXIF_OFFSET_PATTERN = /^([+-])(\d{2}):(\d{2})$/; -/** Parses an EXIF OffsetTimeOriginal string (e.g. "+09:00") into milliseconds. */ +/** Parses an EXIF offset string (e.g. "+09:00") into milliseconds. */ const parseExifOffsetMs = (raw: unknown): number | undefined => { if (typeof raw !== 'string') return undefined; const match = EXIF_OFFSET_PATTERN.exec(raw); diff --git a/src/lib/telegram/chat/patchExifrHeicDetection.ts b/src/lib/telegram/chat/patchExifrHeicDetection.ts new file mode 100644 index 00000000..926d15a0 --- /dev/null +++ b/src/lib/telegram/chat/patchExifrHeicDetection.ts @@ -0,0 +1,46 @@ +import exifr from 'exifr'; + +let patched = false; + +/** + * exifr@7.1.3 rejects HEIC when the ftyp box exceeds 50 bytes, which happens + * on Apple multi-brand files (e.g. styled photos). Patch detection to use the + * full ftyp box size and compatible brands instead. + */ +const patchExifrHeicDetection = (): void => { + if (patched) return; + + const heicParser = exifr.fileParsers?.get?.('heic') as + | { + canHandle: ( + file: { + getString: (o: number, l: number) => string; + getUint32: (o: number) => number; + }, + marker: number + ) => boolean; + type: string; + } + | undefined; + + if (!heicParser) return; + + const original = heicParser.canHandle.bind(heicParser); + heicParser.canHandle = function (file, firstTwoBytes) { + if (firstTwoBytes !== 0) return false; + if (file.getString(4, 4) !== 'ftyp') return false; + + const ftypLength = file.getUint32(0); + if (ftypLength < 16 || ftypLength > 256) return false; + + for (let offset = 16; offset + 4 <= ftypLength; offset += 4) { + if (file.getString(offset, 4) === this.type) return true; + } + + return original(file, firstTwoBytes); + }; + + patched = true; +}; + +export default patchExifrHeicDetection; diff --git a/src/lib/telegram/chat/readFtypBrands.ts b/src/lib/telegram/chat/readFtypBrands.ts new file mode 100644 index 00000000..cea075b1 --- /dev/null +++ b/src/lib/telegram/chat/readFtypBrands.ts @@ -0,0 +1,16 @@ +const readFtypBrands = (buffer: Buffer): string[] => { + if (buffer.length < 16 || buffer.toString('ascii', 4, 8) !== 'ftyp') { + return []; + } + + const ftypLength = buffer.readUInt32BE(0); + if (ftypLength < 16 || ftypLength > buffer.length) return []; + + const brands: string[] = []; + for (let offset = 16; offset + 4 <= ftypLength; offset += 4) { + brands.push(buffer.toString('ascii', offset, offset + 4)); + } + return brands; +}; + +export default readFtypBrands; diff --git a/src/lib/telegram/chat/resolveExifOffsetMs.ts b/src/lib/telegram/chat/resolveExifOffsetMs.ts new file mode 100644 index 00000000..53ecbd28 --- /dev/null +++ b/src/lib/telegram/chat/resolveExifOffsetMs.ts @@ -0,0 +1,19 @@ +import parseExifOffsetMs from './parseExifOffsetMs'; + +type ExifOffsetFields = { + OffsetTimeOriginal?: unknown; + OffsetTime?: unknown; +}; + +/** + * Resolves the capture-time offset from EXIF fields. + * OffsetTimeOriginal is preferred; OffsetTime is the HEIC/JPEG fallback + * when only the generic offset tag is present. + */ +const resolveExifOffsetMs = ( + fields: ExifOffsetFields | undefined +): number | undefined => + parseExifOffsetMs(fields?.OffsetTimeOriginal) ?? + parseExifOffsetMs(fields?.OffsetTime); + +export default resolveExifOffsetMs; diff --git a/src/lib/telegram/chat/resolveTelegramFileMimeType.ts b/src/lib/telegram/chat/resolveTelegramFileMimeType.ts new file mode 100644 index 00000000..089fd89d --- /dev/null +++ b/src/lib/telegram/chat/resolveTelegramFileMimeType.ts @@ -0,0 +1,13 @@ +import getMimeTypeFromFilePath from './getMimeTypeFromFilePath'; +import detectMimeTypeFromBuffer from './detectMimeTypeFromBuffer'; + +const resolveTelegramFileMimeType = ( + filePath: string, + buffer: Buffer +): string => { + const fromPath = getMimeTypeFromFilePath(filePath); + if (fromPath !== 'application/octet-stream') return fromPath; + return detectMimeTypeFromBuffer(buffer) ?? fromPath; +}; + +export default resolveTelegramFileMimeType; diff --git a/src/lib/telegram/chat/resolveTelegramMediaAttachment.ts b/src/lib/telegram/chat/resolveTelegramMediaAttachment.ts new file mode 100644 index 00000000..fe1d22c0 --- /dev/null +++ b/src/lib/telegram/chat/resolveTelegramMediaAttachment.ts @@ -0,0 +1,25 @@ +import type { Attachment, Message } from 'chat'; +import resolveMediaAttachmentType from './resolveMediaAttachmentType'; +import extractTelegramFileIds from './extractTelegramFileIds'; +import fetchTelegramFile from './fetchTelegramFile'; +import needsMimeSniff from './needsMimeSniff'; + +const resolveTelegramMediaAttachment = async ( + message: Message, + attachment: Attachment +): Promise => { + const directType = resolveMediaAttachmentType(attachment); + if (directType) return { ...attachment, type: directType }; + + if (!needsMimeSniff(attachment)) return undefined; + + const { fileId } = extractTelegramFileIds(message, attachment); + const { mimeType } = await fetchTelegramFile(fileId); + const sniffed = { ...attachment, mimeType }; + const sniffedType = resolveMediaAttachmentType(sniffed); + if (!sniffedType) return undefined; + + return { ...sniffed, type: sniffedType }; +}; + +export default resolveTelegramMediaAttachment;