mirror of
https://github.com/Trollobot/Telemax.git
synced 2026-10-19 10:23:24 +00:00
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:
parent
d151900db3
commit
6f89576896
6 changed files with 88 additions and 14 deletions
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue