Пересланный файл из чата, где нас нет (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. Ссылка теперь всегда живая.
- 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-кнопку «➡️ Открыть чат». Текст различает
«создан» / «уже был — открыл».
Раньше очистка поля в панели удаляла 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 (общий текст, без утечки токенов).
- 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-диагностика на этот лог и указывает.
Юзер с Хабра встал на экране ввода ключа — подсказка «см. 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).
Установлено эмпирически за три живых захода: 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.
vite — devDependency и в рантайм-образе отсутствует, но app.ts импортировал
его статически на верхнем уровне. Раньше это маскировалось тем, что
@tailwindcss/vite числился в dependencies и притягивал vite в прод-образ как
peer-зависимость; после переноса фронтенд-пакетов в devDependencies контейнер
стал падать на старте с ERR_MODULE_NOT_FOUND (поймано на тестовом сервере
2026-08-14). Теперь vite подгружается динамически только в dev-ветке.
Проверено boot-smoke'ом собранного бандла: старт в production-режиме, health
по https, 301-редирект с http, генерация self-signed сертификата — всё живо;
все статические внешние импорты бандла сверены со списком dependencies.
- redactSecrets: маскируются password/trackId/phone и любые строки от 512
символов — пароль MAX больше не попадает в лог веб-панели открытым текстом
- Веб-панель по умолчанию работает по HTTPS с самоподписанным сертификатом
(генерируется при первом старте в .data/tls); старые http-ссылки получают
301-редирект на том же порту (polyglot по первому байту; resume строго в
process.nextTick — иначе TLS-хендшейк зависает); /apikey даёт https-ссылку;
PANEL_TLS=off для установки за своим reverse proxy
- Сертификаты Минцифры больше не доверяются всему контейнеру через
NODE_EXTRA_CA_CERTS — src/max/ca.ts скоупит их только на соединения с MAX
(ca в tls.connect + undici-агент для CDN-загрузок); Telegram и GitHub
проверяются только по стандартным корням Mozilla
- Запросы одного опкода к MAX сериализуются (request() в client.ts) — два
конкурентных MSG_SEND больше не перепутают messageId-связки редактирования
и удаления
- Починена гонка первого чтения в ChatMapStore: конкурентные обращения
форкали кэш, и часть upsert'ов молча терялась на диске
- Graceful shutdown по SIGTERM/SIGINT; в панели — реконнект WebSocket и
периодическое обновление метрик; сравнение id в patchCachedChatLastMessage
переведено на String() (BigInt-чаты не совпадали)
- Удалён мёртвый код: эндпоинты /api/debug/*, getPollVoters,
createTelegramBot, цепочка historySynced, getChats(marker)
- React/Tailwind/lucide перенесены в devDependencies — рантайм-образ легче;
vitest 4, npm audit: 0 уязвимостей; chmod 600 для .env в setup.sh/update.sh
- Новые тесты: redactSecrets, сторы (эвикшн/нормализация/конкурентность),
сериализация запросов — всего 41
ensureTopicForMaxChat now catches a createForumTopic failure and, for
a recognized cause (starting with "not enough rights to create a
topic" — Manage Topics isn't part of Telegram's default admin preset,
confirmed live, and previously just failed identically for every
single chat with nothing but a server log to explain why), posts a
one-time actionable message to the group instead of leaving the admin
to guess. Extensible list for future patterns.
Web panel: a "log out / change number" button once authenticated,
reusing killMaxSession's MAX-side teardown (disconnect + delete the
session) via a new /api/auth/logout endpoint — scoped to just that,
unlike /kill, which also wipes Telegram topics.
/help and the bot's Telegram profile description both had the actual
production MAX number (+79112954314) baked in as a literal string —
leaked into every other install of this project regardless of which
number they authorize. Both now read the number from the live session
(getActivePhone/activePhone), falling back to a generic label before
first auth. Description also refreshes after login/logout instead of
only being set once at Telegram launch.
Also: setup.sh and README now call out that the bot's admin rights
need "Manage Topics" specifically enabled — it's not part of the
default admin preset, and its absence fails topic creation for every
single chat with no visible explanation (confirmed live 2026-08-14).
Full flow reverse-engineered and confirmed live: CHECK_CODE responds
with a passwordChallenge (trackId + optional hint) instead of a login
token when the account has a password set. A new opcode, CHECK_PASSWORD
(0x0073), exchanges {trackId, password} for the same 663-char login
token CHECK_CODE would have returned directly — from there it's an
ordinary LOGIN.
verifyCode() now returns a discriminated union (ok / password_required)
instead of throwing either way, so callers can actually ask for a
password instead of just failing. Wired through everywhere MAX auth
happens: the web panel gets a new password step (with hint), setup.sh's
console flow prompts for it too, and the dev auth-cli script handles it
via the same file-polling pattern it already used for the SMS code.
A wrong password doesn't burn the trackId (confirmed live) — both the
web UI and setup.sh let you retry it freely without a fresh SMS. It's
only consumed on success, matching MAX's own behavior.
Bot API forum topics only support a fixed palette (icon_color) or a
custom emoji — no arbitrary photo, so a contact's actual avatar isn't
an option here. Hashing the chat id into one of the six allowed colors
at least gives each conversation a stable, distinct visual marker that
survives topic recreation (e.g. after /reboot).
Both just returned the error to the browser with nothing printed to
the server's own logs — meant a failed MAX login was undiagnosable
from docker logs alone, confirmed live debugging a test-server report
of SMS verification silently not going through.