diff --git a/package-lock.json b/package-lock.json index 8520b00..1c4754b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "telemax", - "version": "1.3.5", + "version": "1.3.6-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "telemax", - "version": "1.3.5", + "version": "1.3.6-beta.1", "license": "AGPL-3.0-only", "dependencies": { "dotenv": "^17.2.3", diff --git a/package.json b/package.json index c4d40db..e57b63f 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "telemax", "private": true, "license": "AGPL-3.0-only", - "version": "1.3.5", + "version": "1.3.6-beta.1", "type": "module", "scripts": { "dev": "tsx watch src/server/app.ts", diff --git a/src/bridge/attachments.ts b/src/bridge/attachments.ts index 64cce4a..b9f820b 100644 --- a/src/bridge/attachments.ts +++ b/src/bridge/attachments.ts @@ -216,11 +216,34 @@ export async function downloadMaxAttachment(att: MaxAttachment, ctx: DownloadCon } } +/** + * An image's real format from its first bytes — MAX takes JPG, PNG, GIF, TIFF, BMP and HEIC (its + * upload docs), Telegram's sendPhoto refuses some of them, and a HEIC named `.jpg` opens nowhere. + * 'jpg' when nothing matches. Pure + exported for unit testing. + */ +export function imageExtension(buf: Uint8Array): string { + const ascii = (from: number, to: number) => String.fromCharCode(...buf.subarray(from, to)); + if (buf[0] === 0x89 && ascii(1, 4) === 'PNG') return 'png'; + if (ascii(0, 4) === 'GIF8') return 'gif'; + if (ascii(0, 4) === 'RIFF' && ascii(8, 12) === 'WEBP') return 'webp'; + if (ascii(0, 2) === 'BM') return 'bmp'; + if (ascii(0, 4) === 'II*\0' || ascii(0, 4) === 'MM\0*') return 'tiff'; + if (ascii(4, 8) === 'ftyp') { + const brand = ascii(8, 12); + if (brand === 'avif' || brand === 'avis') return 'avif'; + if (['heic', 'heix', 'hevc', 'hevx', 'heim', 'heis', 'mif1', 'msf1'].includes(brand)) return 'heic'; + } + return 'jpg'; +} + +/** Image formats Telegram doesn't take as a photo — they go as a file straight away instead of a doomed sendPhoto. */ +export const PHOTO_AS_FILE = new Set(['heic', 'tiff', 'avif']); + async function downloadAttachmentOrThrow(att: MaxAttachment, ctx: DownloadContext): Promise { if (att._type === 'PHOTO' && att.baseUrl && att.photoToken) { const buffer = await downloadUrl(att.baseUrl + att.photoToken); if (!buffer) return null; - return { buffer, filename: `photo_${String(att.photoId ?? Date.now())}.jpg`, kind: 'photo' }; + return { buffer, filename: `photo_${String(att.photoId ?? Date.now())}.${imageExtension(buffer)}`, kind: 'photo' }; } // Animated stickers carry a `lottieUrl`: gzip-compressed Lottie JSON that is already a valid diff --git a/src/bridge/sync.ts b/src/bridge/sync.ts index 3214ec1..8e22b0d 100644 --- a/src/bridge/sync.ts +++ b/src/bridge/sync.ts @@ -19,6 +19,7 @@ import { KEYBOARD_GEO, KEYBOARD_CONTACT, pressableButton, + PHOTO_AS_FILE, type MaxAttachment, type DownloadContext, } from './attachments.js'; @@ -697,7 +698,21 @@ async function sendOneAttachment( return withFloodRetry(() => bot.telegram.sendMessage(groupId, describeAttachment(att), opts)); } const source = { source: downloaded.buffer, filename: downloaded.filename }; - if (kind === 'photo') return withFloodRetry(() => bot.telegram.sendPhoto(groupId, source, opts)); + if (kind === 'photo') { + // MAX takes images Telegram won't show as a photo: HEIC/TIFF (format), or past sendPhoto's + // 10 000 px (width + height) or 20:1 — MAX allows 7680×7680. Such a picture goes as a file + // instead of a «[фото]» placeholder (seen live 2026-10-13 on a user's bridge). + const ext = downloaded.filename.split('.').pop() ?? 'jpg'; + if (ext !== 'jpg') logger.info(`MAX photo arrived as ${ext} (${downloaded.buffer.byteLength} bytes)`); + if (PHOTO_AS_FILE.has(ext)) return withFloodRetry(() => bot.telegram.sendDocument(groupId, source, opts)); + try { + return await withFloodRetry(() => bot.telegram.sendPhoto(groupId, source, opts)); + } catch (err) { + if (!isPermanentTelegramRefusal(err)) throw err; + logger.error(`Telegram refused a MAX photo (${ext}, ${downloaded.buffer.byteLength} bytes: ${telegramErrorText(err)}) — sending it as a file`); + return withFloodRetry(() => bot.telegram.sendDocument(groupId, source, opts)); + } + } if (kind === 'video') return withFloodRetry(() => bot.telegram.sendVideo(groupId, source, opts)); if (kind === 'video_note') { try { @@ -835,6 +850,7 @@ export async function resolveForwardContent( wrapperChatId: unknown, wrapperMessageId: unknown, profiles?: Map, + myAccountId: number | null = null, ): Promise<{ text: string; attaches: MaxAttachment[]; download: ForwardDownloadIds } | null> { if (link?.type !== 'FORWARD') return null; const original = link.message; @@ -843,11 +859,13 @@ export async function resolveForwardContent( const senderName = Number.isNaN(senderId) ? 'неизвестно' : resolveContactDisplayName(senderId, await resolveProfile(max, senderId, 'forward sender', profiles)); - const sourceChat = chats.find((c) => c && typeof c === 'object' && String((c as { id?: unknown }).id) === String(link.chatId)) as - | { title?: string } - | undefined; - const sourceLabel = sourceChat?.title || `MAX chat ${String(link.chatId)}`; - const prefix = `↩️ Переслано из «${sourceLabel}» (от ${senderName}):`; + // The source chat is named the way its topic is: a 1:1 dialog has no title of its own — it is the + // other person (seen live 2026-10-13 as «MAX chat 381718116»). No real name, or just the sender + // again (a forward out of a dialog with them) — the «из «…»» part is left out. + const sourceChat = chats.find((c) => c && typeof c === 'object' && String((c as { id?: unknown }).id) === String(link.chatId)); + const sourceName = sourceChat ? resolveChatName(sourceChat, myAccountId, profiles ?? new Map()) : undefined; + const sourceLabel = sourceName && !isFallbackTitle(sourceName) && sourceName !== senderName ? sourceName : undefined; + const prefix = sourceLabel ? `↩️ Переслано из «${sourceLabel}» (от ${senderName}):` : `↩️ Переслано (от ${senderName}):`; return { text: original?.text ? `${prefix}\n${original.text}` : prefix, attaches, @@ -1146,7 +1164,7 @@ export class ChatCatchUp { await chatMapStore.advanceHistoryCursor(chatId, msg.time); continue; } - const forwarded = await resolveForwardContent(max, chats, msg.link, chatId, msg.id, profiles); + const forwarded = await resolveForwardContent(max, chats, msg.link, chatId, msg.id, profiles, myAccountId); let text = forwarded ? forwarded.text : msg.text; const taken = takeKeyboard(forwarded ? forwarded.attaches : Array.isArray(msg.attaches) ? (msg.attaches as MaxAttachment[]) : [], chatId); let attaches = taken.attaches; @@ -2257,7 +2275,7 @@ export function wireBridge({ if (!opened || opened.deferred) return; const topicId = opened.topicId; - const forwarded = await resolveForwardContent(max, getChats(), message.link, chatId, message.id, getContactProfiles()); + const forwarded = await resolveForwardContent(max, getChats(), message.link, chatId, message.id, getContactProfiles(), getMyAccountId()); if (forwarded) { text = forwarded.text; attaches = forwarded.attaches; diff --git a/tests/delivery.test.ts b/tests/delivery.test.ts index d428e34..a0f715c 100644 --- a/tests/delivery.test.ts +++ b/tests/delivery.test.ts @@ -3,7 +3,7 @@ import type { Telegraf } from 'telegraf'; import { splitTelegramText, truncateCodePoints, truncateUtf16, MAX_TEXT_LIMIT, TELEGRAM_TEXT_LIMIT } from '../src/bridge/text.js'; import { dialogParticipantIds, isFallbackTitle, resolveChatName, resolveContactDisplayName } from '../src/max/names.js'; import { clampTopicTitle, ensureTopicForMaxChat, withTopicLock } from '../src/telegram/bot.js'; -import { telegramSendKind, TELEGRAM_PHOTO_LIMIT_BYTES, TELEGRAM_UPLOAD_LIMIT_BYTES } from '../src/bridge/attachments.js'; +import { imageExtension, telegramSendKind, TELEGRAM_PHOTO_LIMIT_BYTES, TELEGRAM_UPLOAD_LIMIT_BYTES } from '../src/bridge/attachments.js'; import { buildLinkIds, describeUnrelayableTelegramMessage, @@ -496,3 +496,22 @@ describe('dialogParticipantIds', () => { expect(dialogParticipantIds(chats, me).sort()).toEqual([2, 3]); }); }); + +describe('imageExtension (a MAX picture named by what it really is)', () => { + const bytes = (...parts: Array) => + Uint8Array.from(parts.flatMap((p) => (typeof p === 'string' ? [...p].map((c) => c.charCodeAt(0)) : p))); + it('tells the formats MAX takes apart, JPEG by default', () => { + expect(imageExtension(bytes([0xff, 0xd8, 0xff, 0xe0]))).toBe('jpg'); + expect(imageExtension(bytes([0x89], 'PNG', [0x0d, 0x0a]))).toBe('png'); + expect(imageExtension(bytes('GIF89a'))).toBe('gif'); + expect(imageExtension(bytes('RIFF', [0, 0, 0, 0], 'WEBPVP8 '))).toBe('webp'); + expect(imageExtension(bytes('BM', [0, 0, 0, 0]))).toBe('bmp'); + expect(imageExtension(bytes('II*', [0]))).toBe('tiff'); + expect(imageExtension(bytes('MM', [0], '*'))).toBe('tiff'); + expect(imageExtension(bytes([0, 0, 0, 0x18], 'ftypheic'))).toBe('heic'); + expect(imageExtension(bytes([0, 0, 0, 0x18], 'ftypmif1'))).toBe('heic'); + expect(imageExtension(bytes([0, 0, 0, 0x1c], 'ftypavif'))).toBe('avif'); + expect(imageExtension(bytes([0, 0, 0, 0x18], 'ftypisom'))).toBe('jpg'); + expect(imageExtension(new Uint8Array(0))).toBe('jpg'); + }); +}); diff --git a/tests/forward.test.ts b/tests/forward.test.ts index 16ed9d4..8cad72d 100644 --- a/tests/forward.test.ts +++ b/tests/forward.test.ts @@ -88,11 +88,25 @@ describe('resolveForwardContent (shared by the live path and the backfill)', () it('a forward from a hidden chat (the backfill used to drop its file) still gets the recipient-chat fallback', async () => { const { max } = fakeMax(async () => []); const out = await resolveForwardContent(max, chats, { type: 'FORWARD', chatId: 0, message: { id: 'orig-1', sender: 7, attaches: [{ _type: 'FILE' }] } }, 100, 'wrap-1'); - expect(out?.text).toBe('↩️ Переслано из «MAX chat 0» (от MAX ID 7):'); + // No name for the source — no «MAX chat 0» stand-in either (seen live as «MAX chat 381718116»). + expect(out?.text).toBe('↩️ Переслано (от MAX ID 7):'); expect(out?.download.fallbackChatId).toBe(100); expect(out?.download.fallbackMessageId).toBe('wrap-1'); }); + it('names a 1:1 dialog by the other person, and skips it when that is the sender again', async () => { + const { max } = fakeMax(async () => [VLADIMIR]); + const profiles = new Map([[9, { id: 9, names: [{ name: 'Анна', type: 'ONEME' }] }]]); + const dialogs = [ + { id: 381718116, type: 'DIALOG', participants: { 1: 0, 9: 0 } }, + { id: 381718117, type: 'DIALOG', participants: { 1: 0, 7: 0 } }, + ]; + const fromAnna = await resolveForwardContent(max, dialogs, { type: 'FORWARD', chatId: 381718116, message: { text: 'x', sender: 7 } }, 100, 'w', profiles, 1); + expect(fromAnna?.text).toBe('↩️ Переслано из «Анна» (от Владимир П.):\nx'); + const fromVladimir = await resolveForwardContent(max, dialogs, { type: 'FORWARD', chatId: 381718117, message: { text: 'y', sender: 7 } }, 100, 'w', profiles, 1); + expect(fromVladimir?.text).toBe('↩️ Переслано (от Владимир П.):\ny'); + }); + it('uses and fills the profile cache when one is passed', async () => { const { max, getContactInfo } = fakeMax(async () => [VLADIMIR]); const cache = new Map();