1.3.11-beta.1: GitHub #3 — в группе MAX (type CHAT) имя автора «👤 Имя:» всегда: в списке чатов участники большой группы бывают неполными, и при двух видимых группа считалась личкой — имена пропадали (AlexHighTower, 1.3.7); GitHub #2 — сообщение, доставленное позже 5 минут (заливка истории, догрузка после простоя), получает в конце «🕓 10.09.2026, 11:27» — время отправки в MAX, по TZ (МСК по умолчанию); BACKFILL_TIME=off выключает (Mackey-way)

This commit is contained in:
Folist 2026-10-16 15:02:51 +03:00
parent df6c4afa4e
commit c25d43115d
6 changed files with 79 additions and 7 deletions

View file

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

View file

@ -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`); все остальные команды — в группе моста. Если администратор напишет в личку что-то другое, бот подскажет, где пульт.

4
package-lock.json generated
View file

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

View file

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

View file

@ -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<string, unknown> } | 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<string, unknown> } | 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[] = [];

View file

@ -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);