From 12154f347e07a41ccda6ac2298ccddcc62152b1c Mon Sep 17 00:00:00 2001 From: ziad Date: Fri, 10 Jul 2026 00:29:26 +1000 Subject: [PATCH] Position Telegram bulk-upload moments by EXIF capture date Telegram bulk uploads currently position moments on the Timeline by upload time instead of when the photo was actually taken. Extract the EXIF DateTimeOriginal from the image buffer and use it as saleStart, falling back to upload time when EXIF is absent (Telegram strips it from compressed "photo" sends). Also fixes document-mode uploads: images/videos sent as a Telegram document (which preserves EXIF, unlike compressed photo sends) were rejected outright, then would have been misrouted into the video/Mux upload path, and their file_id couldn't be resolved. Attachment type is now normalized by mimeType at the entry point so document-sent images/videos flow through the same path as their compressed counterparts. Co-Authored-By: Claude Sonnet 5 --- package.json | 1 + pnpm-lock.yaml | 8 ++ .../__tests__/buildCreateBatchInput.test.ts | 73 +++++++++++++++++++ .../__tests__/extractExifCaptureDate.test.ts | 67 +++++++++++++++++ .../__tests__/extractTelegramFileIds.test.ts | 71 +++++++++++------- .../resolveMediaAttachmentType.test.ts | 59 +++++++++++++++ .../telegram/chat/buildCreateBatchInput.ts | 9 ++- .../telegram/chat/createMomentsFromGroup.ts | 25 ++++--- .../telegram/chat/extractExifCaptureDate.ts | 36 +++++++++ .../telegram/chat/extractTelegramFileIds.ts | 17 +++-- .../handlers/__tests__/onNewMention.test.ts | 43 +++++++++++ .../telegram/chat/handlers/onNewMention.ts | 15 ++-- src/lib/telegram/chat/processMediaThread.ts | 2 +- .../chat/resolveMediaAttachmentType.ts | 18 +++++ 14 files changed, 390 insertions(+), 54 deletions(-) create mode 100644 src/lib/telegram/chat/__tests__/buildCreateBatchInput.test.ts create mode 100644 src/lib/telegram/chat/__tests__/extractExifCaptureDate.test.ts create mode 100644 src/lib/telegram/chat/__tests__/resolveMediaAttachmentType.test.ts create mode 100644 src/lib/telegram/chat/extractExifCaptureDate.ts create mode 100644 src/lib/telegram/chat/resolveMediaAttachmentType.ts diff --git a/package.json b/package.json index ca8310d8..f1efb89d 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@zoralabs/protocol-deployments": "^0.6.5", "arweave": "^1.15.7", "chat": "^4.20.2", + "exifr": "^7.1.3", "image-meta": "^0.2.2", "link-preview-js": "^4.0.0", "multiformats": "^13.4.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e8ddfc13..94b1f025 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,6 +77,9 @@ importers: chat: specifier: ^4.20.2 version: 4.27.0 + exifr: + specifier: ^7.1.3 + version: 7.1.3 image-meta: specifier: ^0.2.2 version: 0.2.2 @@ -4240,6 +4243,9 @@ packages: resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} engines: {node: ^18.19.0 || >=20.5.0} + exifr@7.1.3: + resolution: {integrity: sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -13359,6 +13365,8 @@ snapshots: strip-final-newline: 4.0.0 yoctocolors: 2.1.2 + exifr@7.1.3: {} + expect-type@1.3.0: {} exponential-backoff@3.1.3: diff --git a/src/lib/telegram/chat/__tests__/buildCreateBatchInput.test.ts b/src/lib/telegram/chat/__tests__/buildCreateBatchInput.test.ts new file mode 100644 index 00000000..1a075485 --- /dev/null +++ b/src/lib/telegram/chat/__tests__/buildCreateBatchInput.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { Address } from 'viem'; +import buildCreateBatchInput from '../buildCreateBatchInput'; +import type { PendingMediaGroupAsset } from '@/types/telegram'; + +const artistAddress = '0x1234567890123456789012345678901234567890' as Address; +const collectionAddress = + '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd' as Address; + +const asset = (overrides: Partial = {}) => + ({ + fileId: 'file-1', + name: 'moment.jpg', + mimeType: 'image/jpeg', + attachmentType: 'image', + ...overrides, + }) satisfies PendingMediaGroupAsset; + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-09T00:00:00.000Z')); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('buildCreateBatchInput', () => { + it('uses the EXIF capture date as saleStart when present', () => { + const captureDate = Math.floor( + new Date('2026-07-08T13:32:10.000Z').getTime() / 1000 + ); + + const result = buildCreateBatchInput( + [asset()], + [{ uri: 'ar://token-uri', captureDate }], + collectionAddress, + artistAddress + ); + + expect(result.tokens[0].salesConfig.saleStart).toBe(BigInt(captureDate)); + }); + + it('falls back to upload time when there is no EXIF capture date', () => { + const uploadTime = Math.floor(Date.now() / 1000); + + const result = buildCreateBatchInput( + [asset()], + [{ uri: 'ar://token-uri' }], + collectionAddress, + artistAddress + ); + + expect(result.tokens[0].salesConfig.saleStart).toBe(BigInt(uploadTime)); + }); + + it('resolves saleStart independently per token in a mixed batch', () => { + const captureDate = Math.floor( + new Date('2026-07-08T13:32:10.000Z').getTime() / 1000 + ); + const uploadTime = Math.floor(Date.now() / 1000); + + const result = buildCreateBatchInput( + [asset({ fileId: 'file-1' }), asset({ fileId: 'file-2' })], + [{ uri: 'ar://token-uri-1', captureDate }, { uri: 'ar://token-uri-2' }], + collectionAddress, + artistAddress + ); + + expect(result.tokens[0].salesConfig.saleStart).toBe(BigInt(captureDate)); + expect(result.tokens[1].salesConfig.saleStart).toBe(BigInt(uploadTime)); + }); +}); diff --git a/src/lib/telegram/chat/__tests__/extractExifCaptureDate.test.ts b/src/lib/telegram/chat/__tests__/extractExifCaptureDate.test.ts new file mode 100644 index 00000000..8b25a10d --- /dev/null +++ b/src/lib/telegram/chat/__tests__/extractExifCaptureDate.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('exifr', () => ({ + default: { parse: vi.fn() }, +})); + +import exifr from 'exifr'; +import extractExifCaptureDate from '../extractExifCaptureDate'; + +const mockParse = vi.mocked(exifr.parse); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('extractExifCaptureDate', () => { + it('parses a raw EXIF DateTimeOriginal string as UTC', async () => { + mockParse.mockResolvedValue({ DateTimeOriginal: '2026:07:09 23:32:10' }); + + const result = await extractExifCaptureDate(Buffer.from('jpeg-bytes')); + + expect(result).toBe(Date.UTC(2026, 6, 9, 23, 32, 10) / 1000); + }); + + it('requests raw (non-revived) values so the result is TZ-independent', async () => { + mockParse.mockResolvedValue({ DateTimeOriginal: '2026:07:09 23:32:10' }); + + await extractExifCaptureDate(Buffer.from('jpeg-bytes')); + + expect(mockParse).toHaveBeenCalledWith(expect.any(Buffer), { + pick: ['DateTimeOriginal'], + reviveValues: false, + }); + }); + + it('returns undefined when there is no DateTimeOriginal tag', async () => { + mockParse.mockResolvedValue(undefined); + + const result = await extractExifCaptureDate(Buffer.from('no-exif')); + + expect(result).toBeUndefined(); + }); + + it('returns undefined when DateTimeOriginal is not a string', async () => { + mockParse.mockResolvedValue({ DateTimeOriginal: 12345 }); + + const result = await extractExifCaptureDate(Buffer.from('weird-exif')); + + expect(result).toBeUndefined(); + }); + + it('returns undefined when DateTimeOriginal does not match the expected format', async () => { + mockParse.mockResolvedValue({ DateTimeOriginal: 'not-a-date' }); + + const result = await extractExifCaptureDate(Buffer.from('malformed')); + + expect(result).toBeUndefined(); + }); + + it('returns undefined when exifr throws (e.g. unsupported format)', async () => { + mockParse.mockRejectedValue(new Error('Unknown file format')); + + const result = await extractExifCaptureDate(Buffer.from('image.webp')); + + expect(result).toBeUndefined(); + }); +}); diff --git a/src/lib/telegram/chat/__tests__/extractTelegramFileIds.test.ts b/src/lib/telegram/chat/__tests__/extractTelegramFileIds.test.ts index 08d02ba9..305b85a9 100644 --- a/src/lib/telegram/chat/__tests__/extractTelegramFileIds.test.ts +++ b/src/lib/telegram/chat/__tests__/extractTelegramFileIds.test.ts @@ -1,40 +1,37 @@ import { describe, it, expect } from 'vitest'; import extractTelegramFileIds from '../extractTelegramFileIds'; +const attachment = (fileId?: string) => + ({ fetchMetadata: fileId ? { fileId } : undefined }) as never; + describe('extractTelegramFileIds', () => { describe('photo messages', () => { - it('extracts fileId from the last (largest) photo in the array', () => { - const message = { - raw: { - photo: [ - { file_id: 'small_id' }, - { file_id: 'medium_id' }, - { file_id: 'large_id' }, - ], - }, - }; - const { fileId } = extractTelegramFileIds(message as never); + it('extracts fileId from the attachment metadata', () => { + const message = { raw: {} }; + const { fileId } = extractTelegramFileIds( + message as never, + attachment('large_id') + ); expect(fileId).toBe('large_id'); }); it('returns no thumbFileId for photo messages', () => { - const message = { raw: { photo: [{ file_id: 'photo_id' }] } }; - const { thumbFileId } = extractTelegramFileIds(message as never); - expect(thumbFileId).toBeUndefined(); - }); - - it('throws when photo array is empty', () => { - const message = { raw: { photo: [] } }; - expect(() => extractTelegramFileIds(message as never)).toThrow( - 'No Telegram media file_id found' + const message = { raw: {} }; + const { thumbFileId } = extractTelegramFileIds( + message as never, + attachment('photo_id') ); + expect(thumbFileId).toBeUndefined(); }); }); describe('video messages', () => { - it('extracts fileId from video', () => { + it('extracts fileId from the attachment metadata', () => { const message = { raw: { video: { file_id: 'video_id' } } }; - const { fileId } = extractTelegramFileIds(message as never); + const { fileId } = extractTelegramFileIds( + message as never, + attachment('video_id') + ); expect(fileId).toBe('video_id'); }); @@ -42,24 +39,42 @@ describe('extractTelegramFileIds', () => { const message = { raw: { video: { file_id: 'video_id', thumb: { file_id: 'thumb_id' } } }, }; - const { fileId, thumbFileId } = extractTelegramFileIds(message as never); + const { fileId, thumbFileId } = extractTelegramFileIds( + message as never, + attachment('video_id') + ); expect(fileId).toBe('video_id'); expect(thumbFileId).toBe('thumb_id'); }); it('returns undefined thumbFileId when video has no thumb', () => { const message = { raw: { video: { file_id: 'video_id' } } }; - const { thumbFileId } = extractTelegramFileIds(message as never); + const { thumbFileId } = extractTelegramFileIds( + message as never, + attachment('video_id') + ); expect(thumbFileId).toBeUndefined(); }); }); - describe('missing media', () => { - it('throws when neither photo nor video is present', () => { + describe('document messages', () => { + it('extracts fileId for an image sent as a document', () => { const message = { raw: {} }; - expect(() => extractTelegramFileIds(message as never)).toThrow( - 'No Telegram media file_id found' + const { fileId, thumbFileId } = extractTelegramFileIds( + message as never, + attachment('document_id') ); + expect(fileId).toBe('document_id'); + expect(thumbFileId).toBeUndefined(); + }); + }); + + describe('missing media', () => { + it('throws when the attachment has no fileId', () => { + const message = { raw: {} }; + expect(() => + extractTelegramFileIds(message as never, attachment(undefined)) + ).toThrow('No Telegram media file_id found'); }); }); }); diff --git a/src/lib/telegram/chat/__tests__/resolveMediaAttachmentType.test.ts b/src/lib/telegram/chat/__tests__/resolveMediaAttachmentType.test.ts new file mode 100644 index 00000000..2b49f2cc --- /dev/null +++ b/src/lib/telegram/chat/__tests__/resolveMediaAttachmentType.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; +import resolveMediaAttachmentType from '../resolveMediaAttachmentType'; + +describe('resolveMediaAttachmentType', () => { + it('passes through an image attachment sent as a compressed photo', () => { + const result = resolveMediaAttachmentType({ + type: 'image', + mimeType: 'image/jpeg', + } as never); + expect(result).toBe('image'); + }); + + it('passes through a video attachment', () => { + const result = resolveMediaAttachmentType({ + type: 'video', + mimeType: 'video/mp4', + } as never); + expect(result).toBe('video'); + }); + + it('resolves a document with an image mimeType to image', () => { + const result = resolveMediaAttachmentType({ + type: 'file', + mimeType: 'image/jpeg', + } as never); + expect(result).toBe('image'); + }); + + it('resolves a document with a video mimeType to video', () => { + const result = resolveMediaAttachmentType({ + type: 'file', + mimeType: 'video/mp4', + } as never); + expect(result).toBe('video'); + }); + + it('returns undefined for a document with a non-media mimeType', () => { + const result = resolveMediaAttachmentType({ + type: 'file', + mimeType: 'application/pdf', + } as never); + expect(result).toBeUndefined(); + }); + + it('returns undefined for a document with no mimeType', () => { + const result = resolveMediaAttachmentType({ + type: 'file', + } as never); + expect(result).toBeUndefined(); + }); + + it('returns undefined for audio attachments', () => { + const result = resolveMediaAttachmentType({ + type: 'audio', + mimeType: 'audio/mpeg', + } as never); + expect(result).toBeUndefined(); + }); +}); diff --git a/src/lib/telegram/chat/buildCreateBatchInput.ts b/src/lib/telegram/chat/buildCreateBatchInput.ts index fa45fffc..7b0cad2c 100644 --- a/src/lib/telegram/chat/buildCreateBatchInput.ts +++ b/src/lib/telegram/chat/buildCreateBatchInput.ts @@ -5,7 +5,7 @@ import { MomentType } from '@/types/moment'; import { createMomentBatchSchema } from '@/lib/schema/createMomentSchema'; import type { PendingMediaGroupAsset } from '@/types/telegram'; -type UploadedMediaMeta = { uri: string }; +type UploadedMediaMeta = { uri: string; captureDate?: number }; const buildCreateBatchInput = ( pending: PendingMediaGroupAsset[], @@ -13,14 +13,17 @@ const buildCreateBatchInput = ( selectedCollection: Address | null, artistAddress: Address ): CreateMomentBatchInput => { - const saleStart = Math.floor(Date.now() / 1000); + const uploadTime = Math.floor(Date.now() / 1000); const tokens = uploaded.map((u) => ({ tokenMetadataURI: u.uri, createReferral: REFERRAL_RECIPIENT, salesConfig: { type: MomentType.Erc20Mint, pricePerToken: parseUnits('1', 6).toString(), - saleStart, + // Position the moment on the Timeline by when the photo was actually + // taken (EXIF) when available; Telegram strips EXIF from compressed + // "photo" sends, so fall back to upload time in that case. + saleStart: u.captureDate ?? uploadTime, saleEnd: maxUint64.toString(), currency: USDC_ADDRESS[CHAIN_ID], }, diff --git a/src/lib/telegram/chat/createMomentsFromGroup.ts b/src/lib/telegram/chat/createMomentsFromGroup.ts index 876c3e81..f8db8eb5 100644 --- a/src/lib/telegram/chat/createMomentsFromGroup.ts +++ b/src/lib/telegram/chat/createMomentsFromGroup.ts @@ -3,6 +3,7 @@ import type { TelegramThreadState } from './telegramThreadState'; import type { ArtistContext } from '@/types/artist'; import fetchTelegramFile from './fetchTelegramFile'; import processAttachmentUpload from './processAttachmentUpload'; +import extractExifCaptureDate from './extractExifCaptureDate'; import createMomentBatch from '@/lib/moment/createMomentBatch'; import sendReadyMessage from './sendReadyMessage'; import sendArtistCollage from './sendArtistCollage'; @@ -34,19 +35,25 @@ const createMomentsFromGroup = async ( const typingInterval = setInterval(() => void thread.startTyping(), 4000); try { const uploaded = await Promise.all( - pending.map((asset) => { + pending.map(async (asset) => { + const { buffer } = await fetchTelegramFile(asset.fileId); const attachment: Attachment = { type: asset.attachmentType, mimeType: asset.mimeType, - fetchData: () => - fetchTelegramFile(asset.fileId).then((r) => r.buffer), + fetchData: () => Promise.resolve(buffer), }; - return processAttachmentUpload( - attachment, - asset.fileId, - asset.name, - asset.thumbFileId - ); + const [uploadResult, captureDate] = await Promise.all([ + processAttachmentUpload( + attachment, + asset.fileId, + asset.name, + asset.thumbFileId + ), + asset.attachmentType === 'image' + ? extractExifCaptureDate(buffer) + : Promise.resolve(undefined), + ]); + return { ...uploadResult, captureDate }; }) ); diff --git a/src/lib/telegram/chat/extractExifCaptureDate.ts b/src/lib/telegram/chat/extractExifCaptureDate.ts new file mode 100644 index 00000000..02df4e41 --- /dev/null +++ b/src/lib/telegram/chat/extractExifCaptureDate.ts @@ -0,0 +1,36 @@ +import exifr from 'exifr'; + +const EXIF_DATE_PATTERN = /^(\d{4}):(\d{2}):(\d{2}) (\d{2}):(\d{2}):(\d{2})$/; + +const extractExifCaptureDate = async ( + buffer: Buffer +): Promise => { + try { + const result = await exifr.parse(buffer, { + pick: ['DateTimeOriginal'], + reviveValues: false, + }); + const raw = result?.DateTimeOriginal; + if (typeof raw !== 'string') return undefined; + + const match = EXIF_DATE_PATTERN.exec(raw); + if (!match) return undefined; + const [, year, month, day, hour, minute, second] = match; + + // EXIF DateTimeOriginal carries no timezone; treat the wall-clock value as + // UTC so the result stays deterministic regardless of the host's runtime TZ. + const epochMs = Date.UTC( + Number(year), + Number(month) - 1, + Number(day), + Number(hour), + Number(minute), + Number(second) + ); + return Math.floor(epochMs / 1000); + } catch { + return undefined; + } +}; + +export default extractExifCaptureDate; diff --git a/src/lib/telegram/chat/extractTelegramFileIds.ts b/src/lib/telegram/chat/extractTelegramFileIds.ts index 133cdc04..dd4c2b0b 100644 --- a/src/lib/telegram/chat/extractTelegramFileIds.ts +++ b/src/lib/telegram/chat/extractTelegramFileIds.ts @@ -1,16 +1,17 @@ -import type { Message } from 'chat'; +import type { Message, Attachment } from 'chat'; const extractTelegramFileIds = ( - message: Message + message: Message, + attachment: Attachment ): { fileId: string; thumbFileId?: string } => { + // fetchMetadata.fileId is set uniformly by the adapter for photo, video, + // and document messages, so this covers images/videos sent either way. + const fileId = attachment.fetchMetadata?.fileId; + if (!fileId) throw new Error('No Telegram media file_id found'); + const raw = message.raw as { - photo?: Array<{ file_id: string }>; - video?: { file_id: string; thumb?: { file_id: string } }; + video?: { thumb?: { file_id: string } }; }; - - const fileId = - raw.photo?.[raw.photo.length - 1]?.file_id ?? raw.video?.file_id; - if (!fileId) throw new Error('No Telegram media file_id found'); const thumbFileId = raw.video?.thumb?.file_id; return { fileId, thumbFileId }; diff --git a/src/lib/telegram/chat/handlers/__tests__/onNewMention.test.ts b/src/lib/telegram/chat/handlers/__tests__/onNewMention.test.ts index 6afb1e38..4cbecf58 100644 --- a/src/lib/telegram/chat/handlers/__tests__/onNewMention.test.ts +++ b/src/lib/telegram/chat/handlers/__tests__/onNewMention.test.ts @@ -139,6 +139,49 @@ describe('onNewMention', () => { expect(processMediaThread).toHaveBeenCalled(); }); + it('processes an image sent as a Telegram document', async () => { + const attachment = { type: 'file', mimeType: 'image/jpeg' }; + const message = makeMessage({ attachments: [attachment] }); + + await capturedHandler!(makeThread(), message); + + expect(processMediaThread).toHaveBeenCalledWith( + expect.anything(), + message, + expect.objectContaining({ type: 'image', mimeType: 'image/jpeg' }), + '', + ARTIST + ); + }); + + it('processes a video sent as a Telegram document', async () => { + const attachment = { type: 'file', mimeType: 'video/mp4' }; + const message = makeMessage({ attachments: [attachment] }); + + await capturedHandler!(makeThread(), message); + + expect(processMediaThread).toHaveBeenCalledWith( + expect.anything(), + message, + expect.objectContaining({ type: 'video', mimeType: 'video/mp4' }), + '', + 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(); + const message = makeMessage({ attachments: [attachment] }); + + await capturedHandler!(thread, message); + + expect(processMediaThread).not.toHaveBeenCalled(); + expect(thread.post).toHaveBeenCalledWith( + 'To post moments, please send a photo or video along with a caption, or share a YouTube link.' + ); + }); + it('posts fallback message for unrecognised text without attachment', async () => { const thread = makeThread(); diff --git a/src/lib/telegram/chat/handlers/onNewMention.ts b/src/lib/telegram/chat/handlers/onNewMention.ts index 9954b97f..fdc4890e 100644 --- a/src/lib/telegram/chat/handlers/onNewMention.ts +++ b/src/lib/telegram/chat/handlers/onNewMention.ts @@ -5,6 +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 youtubeParser from '@/lib/link/youtubeParser'; import processYoutubeLink from '../processYoutubeLink'; import connectHandler from '../commands/connectHandler'; @@ -43,11 +44,15 @@ export function registerOnNewMention(bot: TelegramChatBot) { if (handled) return; const attachment = message.attachments?.[0]; - if ( - attachment && - (attachment.type === 'image' || attachment.type === 'video') - ) { - await processMediaThread(thread, message, attachment, text, artist); + const resolvedType = attachment && resolveMediaAttachmentType(attachment); + if (attachment && resolvedType) { + await processMediaThread( + thread, + message, + { ...attachment, type: resolvedType }, + text, + artist + ); return; } diff --git a/src/lib/telegram/chat/processMediaThread.ts b/src/lib/telegram/chat/processMediaThread.ts index ce141add..67734f02 100644 --- a/src/lib/telegram/chat/processMediaThread.ts +++ b/src/lib/telegram/chat/processMediaThread.ts @@ -19,7 +19,7 @@ const processMediaThread = async ( return; } - const { fileId, thumbFileId } = extractTelegramFileIds(message); + const { fileId, thumbFileId } = extractTelegramFileIds(message, attachment); const title = text || ''; const raw = message.raw as { media_group_id?: string }; const groupId = diff --git a/src/lib/telegram/chat/resolveMediaAttachmentType.ts b/src/lib/telegram/chat/resolveMediaAttachmentType.ts new file mode 100644 index 00000000..9e6c16d6 --- /dev/null +++ b/src/lib/telegram/chat/resolveMediaAttachmentType.ts @@ -0,0 +1,18 @@ +import type { Attachment } from 'chat'; + +const resolveMediaAttachmentType = ( + attachment: Attachment +): 'image' | 'video' | undefined => { + if (attachment.type === 'image' || attachment.type === 'video') { + return attachment.type; + } + if (attachment.type === 'file' && attachment.mimeType?.startsWith('image/')) { + return 'image'; + } + if (attachment.type === 'file' && attachment.mimeType?.startsWith('video/')) { + return 'video'; + } + return undefined; +}; + +export default resolveMediaAttachmentType;