1.3.6-beta.1: фото из MAX, которое Telegram не принимает как фото (HEIC/TIFF/AVIF, больше 10 000 px по сумме сторон или 20:1 — MAX пускает до 7680×7680), приходит файлом, а не «[фото]»; расширение по содержимому, а не всегда .jpg; формат и причина отказа в логе; источник пересылки — имя чата как у темы (личный диалог — собеседник), без «MAX chat N» и без повтора отправителя

This commit is contained in:
Folist 2026-10-13 11:34:06 +03:00
parent d151900db3
commit 6f89576896
6 changed files with 88 additions and 14 deletions

4
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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<DownloadedAttachment | null> {
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

View file

@ -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<number, ContactProfile>,
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;

View file

@ -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<string | number[]>) =>
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');
});
});

View file

@ -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<number, ContactProfile>([[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<number, ContactProfile>();