From c25d43115dac931ea4d2e09c7fabd39061c1bf3e Mon Sep 17 00:00:00 2001 From: Folist Date: Fri, 16 Oct 2026 15:02:51 +0300 Subject: [PATCH] =?UTF-8?q?1.3.11-beta.1:=20GitHub=20#3=20=E2=80=94=20?= =?UTF-8?q?=D0=B2=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF=D0=B5=20MAX=20(type=20C?= =?UTF-8?q?HAT)=20=D0=B8=D0=BC=D1=8F=20=D0=B0=D0=B2=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D0=B0=20=C2=AB=F0=9F=91=A4=20=D0=98=D0=BC=D1=8F:=C2=BB=20?= =?UTF-8?q?=D0=B2=D1=81=D0=B5=D0=B3=D0=B4=D0=B0:=20=D0=B2=20=D1=81=D0=BF?= =?UTF-8?q?=D0=B8=D1=81=D0=BA=D0=B5=20=D1=87=D0=B0=D1=82=D0=BE=D0=B2=20?= =?UTF-8?q?=D1=83=D1=87=D0=B0=D1=81=D1=82=D0=BD=D0=B8=D0=BA=D0=B8=20=D0=B1?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=D1=88=D0=BE=D0=B9=20=D0=B3=D1=80=D1=83=D0=BF?= =?UTF-8?q?=D0=BF=D1=8B=20=D0=B1=D1=8B=D0=B2=D0=B0=D1=8E=D1=82=20=D0=BD?= =?UTF-8?q?=D0=B5=D0=BF=D0=BE=D0=BB=D0=BD=D1=8B=D0=BC=D0=B8,=20=D0=B8=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=20=D0=B4=D0=B2=D1=83=D1=85=20=D0=B2=D0=B8?= =?UTF-8?q?=D0=B4=D0=B8=D0=BC=D1=8B=D1=85=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF?= =?UTF-8?q?=D0=B0=20=D1=81=D1=87=D0=B8=D1=82=D0=B0=D0=BB=D0=B0=D1=81=D1=8C?= =?UTF-8?q?=20=D0=BB=D0=B8=D1=87=D0=BA=D0=BE=D0=B9=20=E2=80=94=20=D0=B8?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=B0=20=D0=BF=D1=80=D0=BE=D0=BF=D0=B0=D0=B4?= =?UTF-8?q?=D0=B0=D0=BB=D0=B8=20(AlexHighTower,=201.3.7);=20GitHub=20#2=20?= =?UTF-8?q?=E2=80=94=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5,=20=D0=B4=D0=BE=D1=81=D1=82=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=BD=D0=BE=D0=B5=20=D0=BF=D0=BE=D0=B7=D0=B6=D0=B5=205?= =?UTF-8?q?=20=D0=BC=D0=B8=D0=BD=D1=83=D1=82=20(=D0=B7=D0=B0=D0=BB=D0=B8?= =?UTF-8?q?=D0=B2=D0=BA=D0=B0=20=D0=B8=D1=81=D1=82=D0=BE=D1=80=D0=B8=D0=B8?= =?UTF-8?q?,=20=D0=B4=D0=BE=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B0=20=D0=BF?= =?UTF-8?q?=D0=BE=D1=81=D0=BB=D0=B5=20=D0=BF=D1=80=D0=BE=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D1=8F),=20=D0=BF=D0=BE=D0=BB=D1=83=D1=87=D0=B0=D0=B5=D1=82=20?= =?UTF-8?q?=D0=B2=20=D0=BA=D0=BE=D0=BD=D1=86=D0=B5=20=C2=AB=F0=9F=95=93=20?= =?UTF-8?q?10.09.2026,=2011:27=C2=BB=20=E2=80=94=20=D0=B2=D1=80=D0=B5?= =?UTF-8?q?=D0=BC=D1=8F=20=D0=BE=D1=82=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8?= =?UTF-8?q?=20=D0=B2=20MAX,=20=D0=BF=D0=BE=20TZ=20(=D0=9C=D0=A1=D0=9A=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D1=83=D0=BC=D0=BE=D0=BB=D1=87=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=8E);=20BACKFILL=5FTIME=3Doff=20=D0=B2=D1=8B=D0=BA=D0=BB?= =?UTF-8?q?=D1=8E=D1=87=D0=B0=D0=B5=D1=82=20(Mackey-way)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 5 +++++ README.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- src/bridge/sync.ts | 37 +++++++++++++++++++++++++++++++++++-- tests/chatCatchUp.test.ts | 36 +++++++++++++++++++++++++++++++++++- 6 files changed, 79 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index b8aa14a..12da539 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,11 @@ MAX_SESSION_KEY= # Telegram пускает ~20 сообщений в минуту: чат на 5 000 сообщений иначе заливается около 4 часов. # HISTORY_LIMIT=300 +# Сообщение, доставленное с опозданием больше 5 минут (заливка истории, догрузка после простоя), +# получает в конце строку «🕓 10.09.2026, 11:27» — когда его на самом деле отправили в MAX. +# Время — по TZ (по умолчанию московское). Выключить: BACKFILL_TIME=off +# BACKFILL_TIME=off + # Опционально: версия десктопного MAX, которой представляется мост. Нужна, только если при /login # MAX отвечает «Приложение устарело» (см. README, «Если что-то не работает»). Пусто — встроенная. # MAX_APP_VERSION=26.35.2 diff --git a/README.md b/README.md index 6141b5f..0bd25ef 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ curl -fsSL https://raw.githubusercontent.com/Trollobot/Telemax/main/install.sh | После успешной авторизации сессия сохраняется зашифрованной в `./data` — повторной авторизации при перезапуске контейнера не требуется. Повторный вход тем же путём (`/login`) нужен только после `/kill`, смены номера или если MAX отклонил сессию (бот сам напишет об этом в группу). -После входа мост переносит историю чатов в темы. Telegram разрешает боту писать в группу примерно 20 сообщений в минуту, поэтому большая история заливается долго: чат на 5 000 сообщений — около 4 часов (новые сообщения при этом приходят сразу). Установщик спрашивает, сколько последних сообщений каждого чата переносить; то же — `HISTORY_LIMIT=300` в `.env` (затем `docker compose up -d --force-recreate`). Тема, куда попала не вся история, начинается с пометки об этом. +После входа мост переносит историю чатов в темы. Telegram разрешает боту писать в группу примерно 20 сообщений в минуту, поэтому большая история заливается долго: чат на 5 000 сообщений — около 4 часов (новые сообщения при этом приходят сразу). Установщик спрашивает, сколько последних сообщений каждого чата переносить; то же — `HISTORY_LIMIT=300` в `.env` (затем `docker compose up -d --force-recreate`). Тема, куда попала не вся история, начинается с пометки об этом. У сообщений из истории и у всех, что пришли с опозданием больше 5 минут, в конце стоит время отправки в MAX: «🕓 10.09.2026, 11:27» (выключить — `BACKFILL_TIME=off` в `.env`). В личке бот работает только для входа в MAX (`/login`); все остальные команды — в группе моста. Если администратор напишет в личку что-то другое, бот подскажет, где пульт. diff --git a/package-lock.json b/package-lock.json index 78d5e52..56e223b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "telemax", - "version": "1.3.10", + "version": "1.3.11-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "telemax", - "version": "1.3.10", + "version": "1.3.11-beta.1", "license": "AGPL-3.0-only", "dependencies": { "dotenv": "^17.2.3", diff --git a/package.json b/package.json index 8701b9a..2173ebe 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "telemax", "private": true, "license": "AGPL-3.0-only", - "version": "1.3.10", + "version": "1.3.11-beta.1", "type": "module", "scripts": { "dev": "tsx watch src/server/app.ts", diff --git a/src/bridge/sync.ts b/src/bridge/sync.ts index 081ff51..036aaf3 100644 --- a/src/bridge/sync.ts +++ b/src/bridge/sync.ts @@ -919,6 +919,36 @@ export function historyLimitNotice(limit: number, channel = false): string { return `📜 Здесь ${phrase} этого чата — так задано при установке (HISTORY_LIMIT в .env). Более ранняя переписка осталась в MAX.`; } +/** A catch-up also delivers the live message that set it off: only a message at least this old at delivery is «late». */ +const LATE_MIN_AGE_MS = 5 * 60_000; + +/** + * «🕓 10.09.2026, 11:27» — the MAX time of a message delivered late (a history fill, a catch-up + * after downtime), in TZ (МСК by default); undefined for a fresh one or with BACKFILL_TIME=off. + * Otherwise a filled history reads as all sent at the fill (GitHub #2, Mackey-way, 2026-10-13). + * Pure + exported for unit testing. + */ +export function lateTimeStamp(time: unknown, env: NodeJS.ProcessEnv = process.env, now: number = Date.now()): string | undefined { + if ((env.BACKFILL_TIME ?? '').trim().toLowerCase() === 'off') return undefined; + const ms = Number(time); + if (!Number.isFinite(ms) || ms <= 0 || now - ms < LATE_MIN_AGE_MS) return undefined; + const when = new Date(ms).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: env.TZ || 'Europe/Moscow' }); + return `🕓 ${when}`; +} + +/** + * Whether a chat's messages carry «👤 Имя:». A group always, by MAX's own type: a big group's + * `participants` in the chat list can be partial, and two in it hid every author (GitHub #3, + * AlexHighTower, on 1.3.7). A 1:1 never — the topic IS the contact. Anything else (a channel, a + * chat without a type) by its participants, as before. Pure + exported for unit testing. + */ +export function showsAuthor(chat: unknown): boolean { + const c = chat as { type?: unknown; participants?: Record } | null | undefined; + if (c?.type === 'CHAT') return true; + if (c?.type === 'DIALOG') return false; + return !!c?.participants && Object.keys(c.participants).length > 2; +} + /** HISTORY_LIMIT from .env: how many of a chat's newest messages a topic filled from scratch gets (0 = all). */ export function historyLimitFromEnv(env: NodeJS.ProcessEnv = process.env): number { const n = Number((env.HISTORY_LIMIT ?? '').trim()); @@ -1038,8 +1068,7 @@ async function resolveAuthorPrefix( const id = typeof senderId === 'number' ? senderId : Number(senderId); if (Number.isNaN(id)) return ''; if (myAccountId != null && id === myAccountId) return '🧑 Вы:\n'; - const participants = (chat as { participants?: Record } | null)?.participants; - if (!participants || Object.keys(participants).length <= 2) return ''; // 1:1 (or unknown) — contact's line stays plain + if (!showsAuthor(chat)) return ''; // 1:1 (or unknown) — contact's line stays plain const profile = await resolveProfile(max, id, 'group sender', profiles); return `👤 ${resolveContactDisplayName(id, profile)}:\n`; } @@ -1325,6 +1354,10 @@ export class ChatCatchUp { const authorPrefix = await resolveAuthorPrefix(chat, (msg as { sender?: unknown }).sender, myAccountId, max, profiles); if (authorPrefix) text = text ? `${authorPrefix}${text}` : authorPrefix; } + // Delivered late: its MAX time at the end (lateTimeStamp). A bare photo of a 1:1 gets none — a + // text message of its own for just the time would be noise. + const stamp = lateTimeStamp(msg.time); + if (stamp && text) text = `${text}\n${stamp}`; const abortKey = `${String(chatId)}:${String(msg.id ?? msg.time)}`; const lastTry = this.strikes.isLastTry(abortKey); const sent: number[] = []; diff --git a/tests/chatCatchUp.test.ts b/tests/chatCatchUp.test.ts index 6631a7a..32a8177 100644 --- a/tests/chatCatchUp.test.ts +++ b/tests/chatCatchUp.test.ts @@ -6,7 +6,7 @@ import type { Telegraf } from 'telegraf'; import type { MaxClient, MaxHistoryMessage } from '../src/max/client.js'; import { ChatMapStore } from '../src/store/chatMapStore.js'; import { ChatBannedError, StrikeCounter } from '../src/bridge/catchUp.js'; -import { ChatCatchUp, MessageLinkStore, historyLimitFromEnv, historyLimitNotice, isLeftChat, syncAllChatsToTelegram } from '../src/bridge/sync.js'; +import { ChatCatchUp, MessageLinkStore, historyLimitFromEnv, historyLimitNotice, isLeftChat, lateTimeStamp, showsAuthor, syncAllChatsToTelegram } from '../src/bridge/sync.js'; // One writer per chat, catch-up on first touch, and the cursor gate — see ChatCatchUp in sync.ts. @@ -16,9 +16,12 @@ let store: ChatMapStore; beforeEach(async () => { dir = await mkdtemp(path.join(tmpdir(), 'telemax-catchup-')); store = new ChatMapStore(path.join(dir, 'chat-map.json')); + // The fixtures' MAX times are of 1970: every message would be «late» and get its time line (lateTimeStamp). + process.env.BACKFILL_TIME = 'off'; }); afterEach(async () => { + delete process.env.BACKFILL_TIME; await rm(dir, { recursive: true, force: true }); }); @@ -407,6 +410,37 @@ describe('syncAllChatsToTelegram', () => { }); }); +describe('late messages and authors', () => { + it('a message delivered late gets its MAX time; a fresh one and BACKFILL_TIME=off get none', () => { + const sent = Date.parse('2026-09-10T08:27:00Z'); + expect(lateTimeStamp(sent, { TZ: 'Europe/Moscow' }, sent + 6 * 60_000)).toBe('🕓 10.09.2026, 11:27'); + expect(lateTimeStamp(sent, {}, sent + 6 * 60_000)).toBe('🕓 10.09.2026, 11:27'); // МСК by default + expect(lateTimeStamp(sent, {}, sent + 4 * 60_000)).toBeUndefined(); // the live message a catch-up delivers + expect(lateTimeStamp(sent, { BACKFILL_TIME: 'off' }, sent + 60 * 60_000)).toBeUndefined(); + expect(lateTimeStamp('мусор', {}, sent)).toBeUndefined(); + }); + + it('a group shows its authors by its type, whatever part of its members the chat list carries', () => { + expect(showsAuthor({ type: 'CHAT', participants: { '1': 0, '2': 0 } })).toBe(true); // GitHub #3: a big group listed with two + expect(showsAuthor({ type: 'CHAT' })).toBe(true); + expect(showsAuthor({ type: 'DIALOG', participants: { '1': 0, '2': 0 } })).toBe(false); + expect(showsAuthor({ participants: { '1': 0, '2': 0, '3': 0 } })).toBe(true); // no type: as before + expect(showsAuthor({ type: 'CHANNEL', participants: { '1': 0 } })).toBe(false); + expect(showsAuthor(undefined)).toBe(false); + }); + + it('a history fill writes the author of a group message and its time', async () => { + delete process.env.BACKFILL_TIME; + await store.upsert({ maxChatId: '7', telegramTopicId: 100, title: 'Дом', createdAt: 'x' }); + const old = Date.now() - 3 * 86_400_000; + const w = fakeWorld([{ id: 1, time: old, text: 'лифт сломан', sender: 42 } as unknown as MaxHistoryMessage]); + w.sync.deps.getChats = () => [{ id: 7, type: 'CHAT', participants: { '5': 0, '42': 0 } }]; + await w.sync.ensureCaughtUp(7); + expect(w.sends).toHaveLength(1); + expect(w.sends[0]!.text).toMatch(/^👤 [^\n]+:\nлифт сломан\n🕓 \d\d\.\d\d\.\d{4}, \d\d:\d\d$/); + }); +}); + describe('isLeftChat', () => { it('is MAX saying the account is out of a group or channel — never a dialog, never an unclear answer', () => { expect(isLeftChat({ type: 'CHANNEL', status: 'LEFT' })).toBe(true);