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;