Пересланный файл из чата, где нас нет (link.chatId=0, источник скрыт),
FILE_DOWNLOAD по источнику отдавал «нет прав» — файл не доезжал, в TG
уходила заглушка [файл: имя]. Теперь при отказе по источнику скачивание
повторяется по чату, куда форвард ПРИЛЕТЕЛ (наш диалог с пересылающим) +
id самого форвард-сообщения. Накрывает FILE_DOWNLOAD и VIDEO_PLAY через
общий withDownloadFallback. Обычный форвард (источник доступен) не
затронут — там проходит первый заход.
Исходящий перехватчик срабатывал на forum_topic_created (Telegram кладёт
его в тему при создании), пытался copyMessage служебное сообщение
репортёру и на ошибке постил «⚠️ Не удалось доставить ответ». Теперь
служебные сообщения форума (created/edited/closed/reopened, pin)
поглощаются без релея.
Личка постороннего = баг-репорт. Приёмник включается только там, где задан
env BUGREPORT_INBOX (бот мейнтейнера); на остальных деплоях флаг пуст →
посторонний получает редирект-заглушку на https://t.me/TelemaxSvv_bot.
- src/store/bugReportStore.ts — persistent map репортёр(chatId) ↔ topicId.
- src/bridge/bugReports.ts — createBugReports: входящее из лички → тема
«🐞 Имя (@user)» в группе (copyMessage + шапка, антиспам 5/5мин);
исходящее (мейнтейнер пишет в теме) → copyMessage в личку репортёра.
- sync.ts: первый middleware роутит приватные чаты в приёмник (группы «не
туда» — прежний отлуп); перехватчик перед MAX-релеем отдаёт ответы из
тем баг-репортов; строка со ссылкой в /help для всех.
Управление мостом остаётся за админ-гейтом — посторонний видит только
ветку баг-репорта.
Два дефекта при создании диалога из панели:
1. При пересоздании удалённой темы бралось имя из старого битого маппинга
(fallback "CHAT -<id>") — теперь пересоздаём с именем контакта.
2. Диалог от createDialog приходит типом CHAT, не DIALOG, из-за чего
sendAutoInfoCard рисовал generic-карточку («MAX chat …», «Тип: CHAT»)
вместо карточки контакта. Теперь 2-участниковый чат без названия
рендерится как диалог (с подтяжкой профиля по контакту).
Плюс карточка теперь шлётся и пинится на любом создании, а не только
в ветке пересоздания. Чинит и обычный путь входящих для таких диалогов.
Проба живости в startDialog зовёт editForumTopic, а он на удалённой теме
отдаёт TOPIC_ID_INVALID (а не "message thread not found", как send). Регэксп
isThreadNotFound его не матчил → тема не пересоздавалась, deep-link вёл на
удалённую тему, Telegram при тапе писал «чат не найден». Добавлен
TOPIC_ID_INVALID в детектор — панель (и релей) теперь лечат такие маппинги.
startDialog переиспользовал сохранённый topicId вслепую: если оператор
удалил тему кнопкой в Telegram, маппинг оставался, и deep-link вёл в никуда,
а тема не пересоздавалась. Релей-путь самолечится на следующем сообщении
(isThreadNotFound в deliverToTopic), но startDialog в тему ничего не пишет.
Добавлена проба живости: no-op editForumTopic по переиспользованной теме;
на thread-not-found — recreateTopicForChat + карточка контакта, как в
restoreDeletedTopic. Ссылка теперь всегда живая.
Пульт управления (/panel), поиск контакта MAX и старт чата, нативные reply
в обе стороны, зеркалирование удаления чата, уведомление о слёте MAX-сессии,
имя группы/диалога и участники в /info. Версия 0.1.0 → 0.2.0.
- deliverToTopic передаёт resolveTopicTitle(chatId) в ensureTopicForMaxChat:
реальное имя из cachedChats при создании темы (иначе "MAX chat <id>", т.к.
CHAT_UPDATE с title приходит раньше, чем создаётся тема, и переименование
промахивалось). Фолбэки ("CHAT <id>"/"MAX ID <id>") не подставляются.
- /info: для группы резолвит имена участников (недостающие — одним batch
getContactInfo) и печатает «Состав: …»; sendContactInfoCard принимает список.
Свежесозданный чат отсутствовал в cachedChats (снимок из CHATS_LIST), поэтому
resolveChatName давал "MAX chat <id>", а /info (через resolveDialogContact)
показывал пусто. Теперь app.ts на CHAT_UPDATE с полным chat-объектом (есть
participants — не реакция) апсертит его в cachedChats: тема при создании
резолвит имя из кэша, /info видит название/тип/число участников.
Выяснено live: удаление чата в MAX приходит НЕ как CHAT_UPDATE status:CLOSED,
а как PUSH_MESSAGE с CONTROL-аттачем {event:"system", message:"Чат закрыт"}.
- handleMaxPush: детект этого → удаляем тему + чистим маппинг.
- handleMaxChatUpdate: не-реакционное обновление с полным chat-объектом
(несёт title/participants) → переименовываем тему по resolveChatName.
Чинит свежесозданные группы (title) и диалоги (собеседник), которые
получали "MAX chat <id>" (чат ещё не в кэше на момент создания темы).
- resolveChatName: чат с 2 участниками и пустым title = диалог.
Отладочные логи убраны.
- resolveChatName: чат с 2 участниками (я+один) и пустым title теперь
считается диалогом → имя собеседника вместо "CHAT <id>" (диалоги от
createDialog приходят с type:"CHAT").
- Временные [dbg]-логи: CHAT_UPDATE (не-реакции) и необработанные опкоды —
чтобы поймать, чем реально приходит удаление чата (CLOSED не сработал).
Входящий reply теперь по link.message.id ищется в MessageLinkStore →
если связь есть, шлём в Telegram с reply_parameters (нативный ответ с
переходом к оригиналу, allow_sending_without_reply:true). Если связи нет
(стор в памяти, до 500, теряется при рестарте) — прежний текстовый префикс.
reply_parameters пробрасывается в первую отправку: текст, либо (для медиа-
онли reply) первый аттач через sendAttachments. Обе стороны теперь нативные.
Отклонение сохранённой сессии (Saved session was rejected) не рвёт сокет,
поэтому max-down алярм (завязан на 'disconnected') не срабатывал, хотя мост
функционально мёртв до переавторизации. Теперь в ветке отклонения шлём в
группу «❌ MAX-сессия отклонена — нужна повторная авторизация» (троттлинг),
а на успешном resume/re-auth — «✅ восстановлена».
Удаление чата/диалога в MAX приходит как CHAT_UPDATE (0x0087) с
chat.status:"CLOSED" (живой = "ACTIVE", owner:0/participants:{}), chatId
внутри chat.id. handleMaxChatUpdate теперь ловит это: удаляет форум-тему и
дропает маппинг. Уточнён комментарий: CONTROL event:"system" — это очистка
истории, НЕ удаление чата.
Удаление чата в MAX присылает CONTROL-аттач event:"system"; без
скачиваемого контента он уходил в Telegram как "[системное событие: system]".
Теперь нераспознанные/системные CONTROL-события пропускаются в relay;
осмысленные (new/join/leave/title) по-прежнему рендерятся.
- startDialog сначала ищет существующий 1:1 диалог с контактом (chat с
participants == {я, контакт}) и переиспользует его вместо createDialog —
MAX иначе плодит дубликаты.
- После создания/открытия отдаёт deep-link на тему (t.me/c/<id>/<topicId>),
панель показывает inline-кнопку «➡️ Открыть чат». Текст различает
«создан» / «уже был — открыл».
Раньше при недоступности Telegram скрипт делал exit 1 — но частая причина
это просто опечатка в адресе прокси, из-за которой глупо выкидывать из
установщика. Теперь:
- MAX недоступен → по-прежнему фатально (там прокси нет, чинить нечего —
это блокировка окружения).
- Telegram недоступен → цикл: вписать прокси заново и перепроверить, либо
пустой Enter (перепроверить напрямую), либо skip (продолжить без проверки,
задать прокси позже в панели). Если прокси не вводили, а напрямую не
достучались — подсказываем, что помогает прокси (частый случай в РФ).
Проверено на живых прокси: верный → OK, битый порт/пароль → перезапрос, skip
не оставляет тупика; под set -euo pipefail без падений.
Раньше очистка поля в панели удаляла override в ./data и молча откатывала на
TELEGRAM_PROXY из .env — то есть прокси можно было включить/сменить, но не
выключить, если он прописан в .env.
- proxy.ts: writePersistedProxy всегда пишет файл (пустое значение = явное
«напрямую», переживает рестарт). resolveTelegramProxy: override из ./data
(если файл есть) выигрывает целиком, включая пустое = direct; .env берётся
только пока панель прокси ни разу не трогали.
- App.tsx: строка «Сейчас: через прокси … / напрямую» (креды скрыты) +
подсказка «Пусто = напрямую». Кнопка «Сохранить и перезапустить» теперь
умеет и выключить.
Фаза 1 — ядро прокси:
- src/telegram/proxy.ts: агент из TELEGRAM_PROXY (socks5/http/https), редакция
кред в логах, override в ./data (перекрывает env), singleton на старте.
- app.ts: агент в Telegraf; upload.ts: то же для скачивания файлов с
api.telegram.org/file (единственная точка мимо Telegraf). MAX всегда напрямую.
- setup.sh: вопрос про прокси до проверки связи; проверка Telegram, getUpdates и
setChatPhoto идут через прокси; TELEGRAM_PROXY пишется в .env.
Фаза 2 — веб-панель:
- Эндпоинты GET/POST /api/proxy, /api/proxy/test, /api/system/restart.
- Карточка «Telegram-прокси» в Configuration: тест вживую + сохранить и
перезапустить (persist в ./data, применение self-restart'ом).
Фаза 3 — ошибки моста в ТГ, без спама:
- src/bridge/errorReporter.ts: троттлинг (10 мин на ключ + 6/час потолок).
- Хуки: потеря/восстановление связи с MAX (дебаунс 60с), сбой доставки в
Telegram, unhandled/uncaught (общий текст, без утечки токенов).
Добавлен быстрый litmus для локализации (видно бота в группе или нет),
отдельный пункт про заблокированный IPv6-маршрут до Telegram на РФ-хостингах
(со свежим IPv4-фиксом и обходным путём через /etc/hosts) и упоминание, что
повторный ./setup.sh теперь сам предлагает доввести номер и код, если MAX
не авторизован.
- app.ts: dns.setDefaultResultOrder('ipv4first'). На dual-stack серверах, где
IPv6 до Telegram заблокирован (частый случай в РФ), Node сначала пробовал
мёртвый v6-адрес и бот зависал на подключении к api.telegram.org —
сообщения молча переставали ходить (диагностировано вживую). Теперь
предпочитается v4; v6-only хосты продолжают работать через фолбэк.
- setup.sh: если .env уже есть, но MAX не авторизован (шаг прерван, или
аккаунт с облачным паролем, и человек бросил на этом), повторный запуск
больше не выходит молча со словами "настройка не нужна" — проверяет
/api/status и предлагает завершить авторизацию прямо из консоли. Логика
авторизации и финального статуса вынесены в run_max_auth/print_final_status.
Раньше показывался только tip-коммит («🆕 Доступна новая: sha — сообщение»).
Если отставание на несколько коммитов, юзер не видел, что именно накатится.
Теперь через GitHub compare-эндпоинт тянем первые строки всех коммитов
между текущей версией и последней (newest-first, до 12, дальше «…и ещё N»)
и показываем блоком «Что нового:». Best-effort: если compare не ответил —
фолбэк на прежний вид с одним коммитом.
На запуске бот проверяет доступ к TARGET_TELEGRAM_GROUP (getChat):
- группа доступна → один раз (маркер .data/welcome-sent) постит в неё
«✅ Telemax подключён» с указанием на /apikey и /help — тот самый
недостающий сигнал «взлетело», из-за которого новички считали мост
сломанным;
- группа недоступна (бот не добавлен / неверный id) → громкая ошибка в
лог с инструкцией (в Telegram владельцу не написать — бот не может
инициировать личку). README-диагностика на этот лог и указывает.
По живому фидбэку с Хабра («не работает», «документации нет»):
- В «Первой авторизации» — предупреждение, что шаг обязателен и идёт
после сборки, его легко пропустить (это и была реальная причина, а не
окружение — проверено на чистой Debian 13 VM с <1GB RAM).
- Новый раздел «Если что-то не работает»: 6 проверок по частоте
(авторизация MAX, контейнер, права бота Manage Topics, где API-ключ,
HTTPS-панель, связь с MAX/Telegram) + куда писать с логами.
Живой фидбэк с Хабра: юзер прошёл установку, но «не работает» — по всем
признакам просто не заметил запрос номера/SMS, который вылезает ПОСЛЕ
многоминутной сборки образа (простыня docker-логов), а «Мост запущен»
выглядел как финал. Установка при этом технически исправна (проверено на
чистой Debian 13 VM с <1GB RAM — сборка и старт проходят без проблем).
Что изменено:
- «Мост запущен» → «Остался ОДИН обязательный шаг ниже».
- Шаг авторизации обрамлён явным баннером «БЕЗ НЕГО МОСТ НЕ ЗАРАБОТАЕТ».
- В самом конце — крупный итоговый статус: ✅ авторизован, либо ⚠️ запущен,
но НЕ авторизован, с чёткой инструкцией и URL/ключом панели.
Юзер с Хабра встал на экране ввода ключа — подсказка «см. API_KEY на
сервере» не говорила ГДЕ. Теперь прямо: печатается в консоли при
установке, лежит в .env (grep API_KEY .env), либо /apikey у бота в группе.
restoreDeletedTopic наполнял пересозданную тему историей, но не слал
закреплённую карточку контакта — в отличие от обычного создания темы.
Теперь шлём её сразу после пересоздания, до истории, чтобы она осталась
закреплённой шапкой сверху, как у темы, созданной с нуля.
recreateTopicForChat вызывал ensureTopicForMaxChat без title, поэтому
восстановленная удалённая тема получала дефолтное имя вместо ника. Теперь
переиспользуем title из старой связки перед её удалением — тема
возвращается ровно с тем названием, что было. Цвет иконки и так
детерминирован по id чата (pickTopicIconColor), отдельно не трогаем.
Удалил тему → следующее сообщение теперь возвращает не пустую тему, а
весь чат: пересоздаём тему и заливаем полную историю MAX (от старых к
новым). Триггерное сообщение уже лежит в этой истории (самым новым), так
что приходит последним, по порядку — отдельно не досылается, дублей нет.
Fire-and-forget + guard-set restoringChats, чтобы пачка сообщений в
удалённую тему вызвала одно пересоздание, а не по одному на сообщение.
Догрузка истории (catch-up после рестарта) тоже пересоздаёт удалённую
тему: если отправка падает с "thread not found", тема пересоздаётся один
раз за прогон, и сообщение переотправляется (курсор не проматывается
мимо него). Раньше при рестарте все catch-up сообщения для чата с
удалённой темой молча терялись — курсор двигался вперёд в finally.
Хелперы isThreadNotFound/recreateTopicForChat вынесены на уровень модуля
и переиспользуются live-путём и бэкафиллом.
Плюс: syncAllChatsToTelegram (полный ресинк, /reboot) пропускает
забаненные чаты — бан переживает пересинхронизацию, тема не всплывает.
1. Удаление темы в Telegram больше не «банит» контакт молча. Если
отправка входящего MAX-сообщения падает с "message thread not found"
(тему удалили вручную), мост вычищает мёртвую связку, пересоздаёт
тему и до-доставляет сообщение (deliverToTopic в sync.ts). Раньше
такое сообщение просто терялось навсегда.
2. /ban — осознанно заглушить чат: выбор кнопкой из списка, входящие от
него перестают зеркалиться, тема удаляется. Флаг banned хранится в
chat-map (переживает рестарт). /unban — вернуть: снимает флаг, тема
восстанавливается на следующем сообщении через тот же
recreate-on-thread-not-found путь. Команды — только для админов
группы (общий admin-гейт).
1. Admin-гейт: обычные участники группы могут читать и писать (участие в
общем обсуждении), но любая команда боту и нажатие инлайн-кнопки
выполняются только если отправитель — администратор группы
(creator/administrator, проверка через getChatMember, fail-closed).
Раньше любой участник группы мог вызвать /kill, /apikey и т.п.
2. setMyDescription публичен (виден в профиле бота кому угодно, кто
открыл его в личке или добавил в свою группу). Описание содержало
активный номер MAX — то есть номер утекал любому такому зрителю.
Убран из описания; владелец по-прежнему видит его через /help внутри
группы.
Пока обновление идёт (сборка+рестарт — несколько минут), свежий /version
всё ещё показывает живую кнопку «Обновить» (контейнер ещё не пересобран,
выглядит устаревшим), и повторное нажатие ставило второй маркер → вотчер
на следующем тике запускал второй update.sh поверх идущего.
- update-watcher.sh: flock -n — параллельный запуск невозможен, поздний тик
тихо выходит (маркер всё равно поглощается).
- update.sh: пишет data/update-in-progress на время работы (trap EXIT
очищает при любом исходе, включая краш).
- Кнопка tlmx_update: проверяет оба маркера (queued / in-progress) и вместо
второго запроса отвечает «обновление уже идёт».
Свайп-удаление теперь подхватывается зондом, поэтому убрано устаревшее
«бот свайп не видит». Добавлены все три пути (/delete, 👎 на своё, свайп)
и честная оговорка: свайп-подхват — только для своих сообщений,
отправленных после последнего запуска моста. Описание бота — 487/512.
MSG_GET_REACTIONS реально отвечает {messagesReactions:{"<id>":{counters:[]}}}
(подтверждено кадром 2026-08-15), а getReactions парсил reactionInfo/reactions
— ни одна ветка не совпадала, функция ВСЕГДА возвращала []. Из-за этого
pollReactionRemovals раз в 60с считал, что реакция в MAX снята, и стирал её в
Telegram — отсюда «реакции со временем пропадают». Добавлен разбор реального
формата (по messageId, с фолбэком на единственную запись), старые формы
оставлены как фолбэки.
MAX отвергал реакцию ❤ (error.message.like.unknown.like), а 👍 принимал:
Telegram Bot API отдаёт часть реакций в text-форме без U+FE0F (❤ = U+2764),
а MAX хранит emoji-форму (❤️ = U+2764 U+FE0F). Это не разные наборы, а разное
представление одного эмодзи — чинится симметричной нормализацией selector'а,
а не таблицей соответствия.
Новый модуль max/reactions.ts: toMaxReaction (TG→MAX, добавляет FE0F нужным
базовым символам, включая внутри ZWJ-последовательностей вроде ❤🔥/🤷♂️) и
toTelegramReaction (MAX→TG, убирает FE0F — setMessageReaction хочет bare-форму).
Применено в addReaction, в релее реакций MAX→TG и в re-affirm зонда. Список
проблемных code points — из набора реакций Telegram (Emoji_Presentation=No).
Покрыто тестами (round-trip TG→MAX→TG).
Живой тест на боевом вскрыл: удалённое сообщение ПОЛЬЗОВАТЕЛЯ Telegram
отвечает на пустой setMessageReaction кодом MESSAGE_ID_INVALID, а не
"message to react not found" (та строка — от удалённого сообщения бота,
на которой строился первый эксперимент). Классификатор её не знал →
возвращал unknown → зонд не удалял. Живое сообщение по-прежнему даёт
REACTION_EMPTY, так что механика верна, не хватало только этого кода.
Безопасно как gone: пингуем только свой сохранённый id, живой даёт
REACTION_EMPTY, rate-limit даёт 429.
Bot API не сообщает боту об удалении сообщения, поэтому свайп-делит в теме
Telegram раньше не доходил до MAX. Два новых пути (в дополнение к /delete):
- 👎 на СВОём сообщении = удалить с обеих сторон (forAll). 🗑/❌ Telegram как
реакции не принимает (REACTION_INVALID, проверено живьём), из тройки реально
ставится только 👎. На чужом входящем 👎 остаётся обычной реакцией.
- Зонд: периодически «прощупывает» наши сообщения невидимым setMessageReaction
и по тексту ошибки отличает живое (REACTION_EMPTY) от удалённого (message to
react not found). Пропавшее зеркалим в MAX forAll. Лесенка затухания
(probeIntervalMs) кладёт усилие туда, где ~90% удалений — сразу после
отправки: 15с первые 2 мин → минута → 5 мин → 30 мин → стоп через 6ч.
Только свои (outgoing) сообщения: MAX→TG удаления и так приходят нативным
REMOVED-пушем, а удалять чужое в MAX для всех не наше дело. Флаг направления
добавлен в MessageLink.
Зонд не конфликтует с пробросом реакций: вместо слепой пустышки он
переподтверждает текущую релеированную реакцию из lastRelayedReaction
(идемпотентно) — иначе пустой setMessageReaction стёр бы MAX-реакцию,
поставленную на наше сообщение.
Классификатор ошибки зонда возвращает "удалено" ТОЛЬКО по точной строке
not-found — 429/сеть/прочее → "не знаю, позже", т.к. ложное срабатывание =
необратимое удаление в MAX. Покрыто тестами (probe.test.ts).
Драйвер json-file по умолчанию копит stdout контейнера без ограничений между
пересозданиями — теперь 10 МБ x 3 файла (~30 МБ максимум). update.log больше
не дописывается на каждом обновлении (полный вывод сборки ~100-200 КБ за
раз), а перезаписывается: для диагностики нужен только последний прогон.
Установлено эмпирически за три живых захода: quoted/RFC 5987 сервер не
парсит (вклеивает в имя буквально), сырые UTF-8-байты читает как Latin-1
(мохибейк ÐÐ_Ñ_...), а вот percent-encoded часть старых испорченных имён
вернулась читаемой кириллицей с пробелами — то есть значение filename=
он URL-декодирует и трактует как UTF-8. Percent-encoding заодно чистый
ASCII, так что ByteString-валидация undici проходит без трюков.
Сервер MAX не парсит Content-Disposition — байты после filename= становятся
именем как есть. Вчерашние кавычки и RFC 5987 filename* он вклеивал в имя
(«"file"_ filename__UTF-8__Доплаты.xlsx»), а литеральная кавычка в таком
имени затем ломала multipart у telegraf при пересылке файла обратно в
Telegram — sendDocument падал с "invalid json response body".
Теперь: имя отдаётся MAX без кавычек (как до 2026-08-14), не-ASCII уходит
сырыми UTF-8-байтами через latin1-перекодировку (undici требует ByteString);
имена файлов из MAX перед sendDocument чистятся от кавычек и переводов строк.
Текст/гео/контакт/опрос выходят из обработчика раньше строки логирования,
добавленной прошлым коммитом — в результате текстовые релеи по-прежнему шли
без следа в логе. Тип сообщения теперь определяется и пишется до всех веток.
Значения HTTP-заголовков обязаны быть Latin-1 (undici проверяет ByteString и
кидает исключение ещё до отправки запроса), а имена файлов из Telegram сплошь
и рядом кириллические — Content-Disposition теперь отдаёт ASCII как есть
(в кавычках), остальное percent-encoded через RFC 5987 filename*.
Путь TG->MAX и /delete до сих пор не логировали ничего, кроме финальных
ошибок — молчаливые ранние выходы (нет темы, нет связки, неподдерживаемый
тип) стоили слепой отладочной сессии 2026-08-14. Теперь: INFO на каждый
релей с типом вложения, INFO на каждый исход /delete, WARN при связке без
MAX messageId (после неё edit/delete бессильны), и явные ответы бота на
/delete вне темы и на потерянную из-за перезапуска связку.
Agent из npm-пакета undici v8 нельзя передавать во встроенный fetch Node 22:
у внутреннего undici другой интерфейс диспетчера, и каждый запрос падал с
"invalid onRequestStart method". Ломались обе стороны вложений: MAX→TG
приходили заглушки вместо файлов, TG→MAX выгрузка падала целиком (сообщение
не создавалось — отсюда и «/delete не находит связку»). Текст ходил, потому
что идёт по TCP-сокету мимо fetch. Поймано на тестовом сервере 2026-08-14,
воспроизведено и подтверждено локально: глобальный fetch + npm-Agent падает
с той же ошибкой, undici-fetch + тот же Agent работает.
FormData в upload.ts теперь тоже импортируется из undici — класс в рантайме
тот же, но типы npm-пакета номинально несовместимы с копией undici-types
из @types/node.