Skip to content
Merged
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

73 changes: 73 additions & 0 deletions src/lib/telegram/chat/__tests__/buildCreateBatchInput.test.ts
Original file line number Diff line number Diff line change
@@ -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<PendingMediaGroupAsset> = {}) =>
({
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));
});
});
67 changes: 67 additions & 0 deletions src/lib/telegram/chat/__tests__/extractExifCaptureDate.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
71 changes: 43 additions & 28 deletions src/lib/telegram/chat/__tests__/extractTelegramFileIds.test.ts
Original file line number Diff line number Diff line change
@@ -1,65 +1,80 @@
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');
});

it('extracts thumbFileId when video has a thumb', () => {
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');
});
});
});
59 changes: 59 additions & 0 deletions src/lib/telegram/chat/__tests__/resolveMediaAttachmentType.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
9 changes: 6 additions & 3 deletions src/lib/telegram/chat/buildCreateBatchInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,25 @@ 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[],
uploaded: UploadedMediaMeta[],
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],
},
Expand Down
Loading
Loading