Commit graph

61 commits

Author SHA1 Message Date
Folist
87367a95e7 Форвард файла из недоступного чата: фолбэк-скачивание по чату-получателю
Пересланный файл из чата, где нас нет (link.chatId=0, источник скрыт),
FILE_DOWNLOAD по источнику отдавал «нет прав» — файл не доезжал, в TG
уходила заглушка [файл: имя]. Теперь при отказе по источнику скачивание
повторяется по чату, куда форвард ПРИЛЕТЕЛ (наш диалог с пересылающим) +
id самого форвард-сообщения. Накрывает FILE_DOWNLOAD и VIDEO_PLAY через
общий withDownloadFallback. Обычный форвард (источник доступен) не
затронут — там проходит первый заход.
2026-08-18 15:44:49 +03:00
Folist
4c66f71070 Мелкие улучшения службы обратной связи 2026-08-18 13:42:26 +03:00
Folist
b23c4fb3a1 Баг-репорты: не слать warning на служебное «тема создана»
Исходящий перехватчик срабатывал на forum_topic_created (Telegram кладёт
его в тему при создании), пытался copyMessage служебное сообщение
репортёру и на ошибке постил «⚠️ Не удалось доставить ответ». Теперь
служебные сообщения форума (created/edited/closed/reopened, pin)
поглощаются без релея.
2026-08-18 12:25:17 +03:00
Folist
ba5b4f6542 Баг-репорты: двусторонний приёмник через штатный бот (флаг BUGREPORT_INBOX)
Личка постороннего = баг-репорт. Приёмник включается только там, где задан
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 для всех.

Управление мостом остаётся за админ-гейтом — посторонний видит только
ветку баг-репорта.
2026-08-18 12:01:43 +03:00
Folist
070a07c354 Панель «Начать чат»: имя контакта на теме + карточка контакта
Два дефекта при создании диалога из панели:
1. При пересоздании удалённой темы бралось имя из старого битого маппинга
   (fallback "CHAT -<id>") — теперь пересоздаём с именем контакта.
2. Диалог от createDialog приходит типом CHAT, не DIALOG, из-за чего
   sendAutoInfoCard рисовал generic-карточку («MAX chat …», «Тип: CHAT»)
   вместо карточки контакта. Теперь 2-участниковый чат без названия
   рендерится как диалог (с подтяжкой профиля по контакту).
Плюс карточка теперь шлётся и пинится на любом создании, а не только
в ветке пересоздания. Чинит и обычный путь входящих для таких диалогов.
2026-08-18 11:41:34 +03:00
Folist
97c693ac03 Фикс: детектор удалённой темы не ловил TOPIC_ID_INVALID
Проба живости в startDialog зовёт editForumTopic, а он на удалённой теме
отдаёт TOPIC_ID_INVALID (а не "message thread not found", как send). Регэксп
isThreadNotFound его не матчил → тема не пересоздавалась, deep-link вёл на
удалённую тему, Telegram при тапе писал «чат не найден». Добавлен
TOPIC_ID_INVALID в детектор — панель (и релей) теперь лечат такие маппинги.
2026-08-18 11:16:21 +03:00
Folist
f8e1d627fe Фикс: «Открыть чат» из панели вёл на удалённую тему
startDialog переиспользовал сохранённый topicId вслепую: если оператор
удалил тему кнопкой в Telegram, маппинг оставался, и deep-link вёл в никуда,
а тема не пересоздавалась. Релей-путь самолечится на следующем сообщении
(isThreadNotFound в deliverToTopic), но startDialog в тему ничего не пишет.

Добавлена проба живости: no-op editForumTopic по переиспользованной теме;
на thread-not-found — recreateTopicForChat + карточка контакта, как в
restoreDeletedTopic. Ссылка теперь всегда живая.
2026-08-18 10:52:40 +03:00
Folist
0b4ffeaea1 Имя темы из кэша при создании + состав группы по именам в /info
- deliverToTopic передаёт resolveTopicTitle(chatId) в ensureTopicForMaxChat:
  реальное имя из cachedChats при создании темы (иначе "MAX chat <id>", т.к.
  CHAT_UPDATE с title приходит раньше, чем создаётся тема, и переименование
  промахивалось). Фолбэки ("CHAT <id>"/"MAX ID <id>") не подставляются.
- /info: для группы резолвит имена участников (недостающие — одним batch
  getContactInfo) и печатает «Состав: …»; sendContactInfoCard принимает список.
2026-08-17 21:15:37 +03:00
Folist
6008c5847c cachedChats: апсерт чата на полном CHAT_UPDATE → имя и /info для свежих чатов
Свежесозданный чат отсутствовал в cachedChats (снимок из CHATS_LIST), поэтому
resolveChatName давал "MAX chat <id>", а /info (через resolveDialogContact)
показывал пусто. Теперь app.ts на CHAT_UPDATE с полным chat-объектом (есть
participants — не реакция) апсертит его в cachedChats: тема при создании
резолвит имя из кэша, /info видит название/тип/число участников.
2026-08-17 20:57:25 +03:00
Folist
65aeee8db2 Удаление чата = CONTROL system «Чат закрыт» → удаляем тему; имя группы/диалога через CHAT_UPDATE
Выяснено 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 = диалог.
Отладочные логи убраны.
2026-08-17 20:40:27 +03:00
Folist
ade8ae4281 dbg: полный CHAT_UPDATE + CONTROL-аттачи в PUSH + unhandled без PING 2026-08-17 20:12:31 +03:00
Folist
c71bf7021c Фикс имени диалога (2 участника = диалог) + диагностика события удаления
- resolveChatName: чат с 2 участниками (я+один) и пустым title теперь
  считается диалогом → имя собеседника вместо "CHAT <id>" (диалоги от
  createDialog приходят с type:"CHAT").
- Временные [dbg]-логи: CHAT_UPDATE (не-реакции) и необработанные опкоды —
  чтобы поймать, чем реально приходит удаление чата (CLOSED не сработал).
2026-08-17 17:52:40 +03:00
Folist
8c93724d4b Reply MAX→TG: нативный (reply_parameters) с фолбэком на префикс
Входящий reply теперь по link.message.id ищется в MessageLinkStore →
если связь есть, шлём в Telegram с reply_parameters (нативный ответ с
переходом к оригиналу, allow_sending_without_reply:true). Если связи нет
(стор в памяти, до 500, теряется при рестарте) — прежний текстовый префикс.
reply_parameters пробрасывается в первую отправку: текст, либо (для медиа-
онли reply) первый аттач через sendAttachments. Обе стороны теперь нативные.
2026-08-17 17:30:19 +03:00
Folist
4a0ebab3da Уведомлять в ТГ о слёте MAX-сессии (не только об обрыве сокета)
Отклонение сохранённой сессии (Saved session was rejected) не рвёт сокет,
поэтому max-down алярм (завязан на 'disconnected') не срабатывал, хотя мост
функционально мёртв до переавторизации. Теперь в ветке отклонения шлём в
группу « MAX-сессия отклонена — нужна повторная авторизация» (троттлинг),
а на успешном resume/re-auth — « восстановлена».
2026-08-17 16:53:57 +03:00
Folist
64dabf1ec9 Зеркалить удаление чата MAX → удалять тему в Telegram
Удаление чата/диалога в MAX приходит как CHAT_UPDATE (0x0087) с
chat.status:"CLOSED" (живой = "ACTIVE", owner:0/participants:{}), chatId
внутри chat.id. handleMaxChatUpdate теперь ловит это: удаляет форум-тему и
дропает маппинг. Уточнён комментарий: CONTROL event:"system" — это очистка
истории, НЕ удаление чата.
2026-08-17 15:59:47 +03:00
Folist
945dbd64a0 Не релеить шумные CONTROL-события (system) как мусорный текст
Удаление чата в MAX присылает CONTROL-аттач event:"system"; без
скачиваемого контента он уходил в Telegram как "[системное событие: system]".
Теперь нераспознанные/системные CONTROL-события пропускаются в relay;
осмысленные (new/join/leave/title) по-прежнему рендерятся.
2026-08-17 15:27:31 +03:00
Folist
499241e7fa Пульт: защита от задвоения диалога + кнопка «Открыть чат»
- startDialog сначала ищет существующий 1:1 диалог с контактом (chat с
  participants == {я, контакт}) и переиспользует его вместо createDialog —
  MAX иначе плодит дубликаты.
- После создания/открытия отдаёт deep-link на тему (t.me/c/<id>/<topicId>),
  панель показывает inline-кнопку «➡️ Открыть чат». Текст различает
  «создан» / «уже был — открыл».
2026-08-17 15:18:48 +03:00
Folist
d7c695c8be Пульт управления (inline-кнопки) + поиск контакта + начать чат + reply
Фаза A–E плана «фичи 1,2,3»:
- src/bridge/panel.ts: закреплённый в General пульт с деревом кнопок
  (Найти контакт / Управление чатами / Веб-панель / Система), навигация
  редактированием сообщения, автовосстановление закрепа, /panel.
- Найти контакт (фича 1): По номеру → CONTACT_INFO_BY_PHONE (0x002E,
  not.found→нет), По нику → PUBLIC_SEARCH (0x003C, глобально); карточка с
  «Начать чат», гейт по options ONEME.
- Начать чат: createDialog (MSG_SEND control-аттач chatType DIALOG + userIds,
  отрицательный chatId) → ensureTopicForMaxChat с именем контакта.
- Пауза MAX (фича 3): 10м/1ч/1сут/навсегда, авто-возобновление по таймеру,
  без ложного алярма (disconnect не эмитит disconnected).
- Веб-панель: ссылка для входа (ifconfig.me) + API-ключ; листья ban/unban/
  version/help переиспользуют существующую логику.
- Reply (фича 2): TG→MAX нативно (link {type:REPLY, messageId, chatId} через
  MessageLinkStore), MAX→TG префиксом (link.type==='REPLY', цитата из
  link.message) — нативный reply_parameters MAX→TG отложен.
- Опкоды CONTACT_SEARCH/CONTACT_INFO_BY_PHONE/PUBLIC_SEARCH; методы клиента
  searchContactByPhone/publicSearch/searchContactByName/createDialog;
  sendMessage получил опциональный reply-link. /panel в help и меню команд.
2026-08-17 14:29:03 +03:00
Folist
7a1a694911 Прокси: из панели можно и выключить (пустое = напрямую), + строка «Сейчас»
Раньше очистка поля в панели удаляла override в ./data и молча откатывала на
TELEGRAM_PROXY из .env — то есть прокси можно было включить/сменить, но не
выключить, если он прописан в .env.

- proxy.ts: writePersistedProxy всегда пишет файл (пустое значение = явное
  «напрямую», переживает рестарт). resolveTelegramProxy: override из ./data
  (если файл есть) выигрывает целиком, включая пустое = direct; .env берётся
  только пока панель прокси ни разу не трогали.
- App.tsx: строка «Сейчас: через прокси … / напрямую» (креды скрыты) +
  подсказка «Пусто = напрямую». Кнопка «Сохранить и перезапустить» теперь
  умеет и выключить.
2026-08-16 22:46:21 +03:00
Folist
f28739d860 Прокси для Telegram (SOCKS5/HTTP/HTTPS) + уведомления об ошибках в ТГ
Фаза 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 (общий текст, без утечки токенов).
2026-08-16 20:06:05 +03:00
Folist
f8b5b692cb IPv4-first для Telegram + консольная переавторизация в setup.sh
- 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.
2026-08-16 13:34:32 +03:00
Folist
741921bd1d В предложении обновиться — список всех изменений, а не только последний коммит
Раньше показывался только tip-коммит («🆕 Доступна новая: sha — сообщение»).
Если отставание на несколько коммитов, юзер не видел, что именно накатится.
Теперь через GitHub compare-эндпоинт тянем первые строки всех коммитов
между текущей версией и последней (newest-first, до 12, дальше «…и ещё N»)
и показываем блоком «Что нового:». Best-effort: если compare не ответил —
фолбэк на прежний вид с одним коммитом.
2026-08-16 10:35:02 +03:00
Folist
c45e0572f7 Стартовое подтверждение подключения к группе + проверка доступа
На запуске бот проверяет доступ к TARGET_TELEGRAM_GROUP (getChat):
- группа доступна → один раз (маркер .data/welcome-sent) постит в неё
  « Telemax подключён» с указанием на /apikey и /help — тот самый
  недостающий сигнал «взлетело», из-за которого новички считали мост
  сломанным;
- группа недоступна (бот не добавлен / неверный id) → громкая ошибка в
  лог с инструкцией (в Telegram владельцу не написать — бот не может
  инициировать личку). README-диагностика на этот лог и указывает.
2026-08-16 10:25:45 +03:00
Folist
d238e5d362 Веб-панель: явно указать, где взять API-ключ
Юзер с Хабра встал на экране ввода ключа — подсказка «см. API_KEY на
сервере» не говорила ГДЕ. Теперь прямо: печатается в консоли при
установке, лежит в .env (grep API_KEY .env), либо /apikey у бота в группе.
2026-08-16 09:35:04 +03:00
Folist
e5ba2cdacf Восстановленная тема получает закреплённую инфо-карточку, как при создании
restoreDeletedTopic наполнял пересозданную тему историей, но не слал
закреплённую карточку контакта — в отличие от обычного создания темы.
Теперь шлём её сразу после пересоздания, до истории, чтобы она осталась
закреплённой шапкой сверху, как у темы, созданной с нуля.
2026-08-15 22:25:53 +03:00
Folist
84639659b5 Пересозданная тема сохраняет имя контакта, а не "MAX chat <id>"
recreateTopicForChat вызывал ensureTopicForMaxChat без title, поэтому
восстановленная удалённая тема получала дефолтное имя вместо ника. Теперь
переиспользуем title из старой связки перед её удалением — тема
возвращается ровно с тем названием, что было. Цвет иконки и так
детерминирован по id чата (pickTopicIconColor), отдельно не трогаем.
2026-08-15 22:04:26 +03:00
Folist
9fe29bedcd Восстановление истории при пересоздании удалённой темы
Удалил тему → следующее сообщение теперь возвращает не пустую тему, а
весь чат: пересоздаём тему и заливаем полную историю MAX (от старых к
новым). Триггерное сообщение уже лежит в этой истории (самым новым), так
что приходит последним, по порядку — отдельно не досылается, дублей нет.
Fire-and-forget + guard-set restoringChats, чтобы пачка сообщений в
удалённую тему вызвала одно пересоздание, а не по одному на сообщение.
2026-08-15 21:55:18 +03:00
Folist
a9752fba1b Автопересоздание темы и в бэкафилле + бан учитывается при ресинке
Догрузка истории (catch-up после рестарта) тоже пересоздаёт удалённую
тему: если отправка падает с "thread not found", тема пересоздаётся один
раз за прогон, и сообщение переотправляется (курсор не проматывается
мимо него). Раньше при рестарте все catch-up сообщения для чата с
удалённой темой молча терялись — курсор двигался вперёд в finally.
Хелперы isThreadNotFound/recreateTopicForChat вынесены на уровень модуля
и переиспользуются live-путём и бэкафиллом.

Плюс: syncAllChatsToTelegram (полный ресинк, /reboot) пропускает
забаненные чаты — бан переживает пересинхронизацию, тема не всплывает.
2026-08-15 21:40:48 +03:00
Folist
0ce74427b2 Автопересоздание удалённой темы + команды /ban и /unban
1. Удаление темы в Telegram больше не «банит» контакт молча. Если
   отправка входящего MAX-сообщения падает с "message thread not found"
   (тему удалили вручную), мост вычищает мёртвую связку, пересоздаёт
   тему и до-доставляет сообщение (deliverToTopic в sync.ts). Раньше
   такое сообщение просто терялось навсегда.

2. /ban — осознанно заглушить чат: выбор кнопкой из списка, входящие от
   него перестают зеркалиться, тема удаляется. Флаг banned хранится в
   chat-map (переживает рестарт). /unban — вернуть: снимает флаг, тема
   восстанавливается на следующем сообщении через тот же
   recreate-on-thread-not-found путь. Команды — только для админов
   группы (общий admin-гейт).
2026-08-15 21:29:11 +03:00
Folist
8e14099e3d Ограничение команд бота админами группы + убран номер MAX из публичного описания
1. Admin-гейт: обычные участники группы могут читать и писать (участие в
   общем обсуждении), но любая команда боту и нажатие инлайн-кнопки
   выполняются только если отправитель — администратор группы
   (creator/administrator, проверка через getChatMember, fail-closed).
   Раньше любой участник группы мог вызвать /kill, /apikey и т.п.

2. setMyDescription публичен (виден в профиле бота кому угодно, кто
   открыл его в личке или добавил в свою группу). Описание содержало
   активный номер MAX — то есть номер утекал любому такому зрителю.
   Убран из описания; владелец по-прежнему видит его через /help внутри
   группы.
2026-08-15 19:22:34 +03:00
Folist
06921ca69b Защита от двойного запуска обновления по кнопке «Обновить»
Пока обновление идёт (сборка+рестарт — несколько минут), свежий /version
всё ещё показывает живую кнопку «Обновить» (контейнер ещё не пересобран,
выглядит устаревшим), и повторное нажатие ставило второй маркер → вотчер
на следующем тике запускал второй update.sh поверх идущего.

- update-watcher.sh: flock -n — параллельный запуск невозможен, поздний тик
  тихо выходит (маркер всё равно поглощается).
- update.sh: пишет data/update-in-progress на время работы (trap EXIT
  очищает при любом исходе, включая краш).
- Кнопка tlmx_update: проверяет оба маркера (queued / in-progress) и вместо
  второго запроса отвечает «обновление уже идёт».
2026-08-15 16:16:17 +03:00
Folist
ec73145580 Обновлён /help и описание бота: три способа удаления
Свайп-удаление теперь подхватывается зондом, поэтому убрано устаревшее
«бот свайп не видит». Добавлены все три пути (/delete, 👎 на своё, свайп)
и честная оговорка: свайп-подхват — только для своих сообщений,
отправленных после последнего запуска моста. Описание бота — 487/512.
2026-08-15 16:06:06 +03:00
Folist
505833a1fc Реакции больше не пропадают: getReactions читал неверный формат ответа
MSG_GET_REACTIONS реально отвечает {messagesReactions:{"<id>":{counters:[]}}}
(подтверждено кадром 2026-08-15), а getReactions парсил reactionInfo/reactions
— ни одна ветка не совпадала, функция ВСЕГДА возвращала []. Из-за этого
pollReactionRemovals раз в 60с считал, что реакция в MAX снята, и стирал её в
Telegram — отсюда «реакции со временем пропадают». Добавлен разбор реального
формата (по messageId, с фолбэком на единственную запись), старые формы
оставлены как фолбэки.
2026-08-15 14:57:37 +03:00
Folist
7341c226d1 Нормализация variation selector у реакций TG↔MAX (сердечко и др.)
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).
2026-08-15 12:11:00 +03:00
Folist
e469012c37 Зонд: удалённое юзер-сообщение отвечает MESSAGE_ID_INVALID, не not-found
Живой тест на боевом вскрыл: удалённое сообщение ПОЛЬЗОВАТЕЛЯ Telegram
отвечает на пустой setMessageReaction кодом MESSAGE_ID_INVALID, а не
"message to react not found" (та строка — от удалённого сообщения бота,
на которой строился первый эксперимент). Классификатор её не знал →
возвращал unknown → зонд не удалял. Живое сообщение по-прежнему даёт
REACTION_EMPTY, так что механика верна, не хватало только этого кода.
Безопасно как gone: пингуем только свой сохранённый id, живой даёт
REACTION_EMPTY, rate-limit даёт 429.
2026-08-15 11:31:00 +03:00
Folist
63c275dc22 Удаление TG→MAX: 👎-реакция и зонд существования
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).
2026-08-15 11:15:11 +03:00
Folist
c67aced7a1 Имена файлов для MAX — percent-encoding: сервер URL-декодирует filename=
Установлено эмпирически за три живых захода: quoted/RFC 5987 сервер не
парсит (вклеивает в имя буквально), сырые UTF-8-байты читает как Latin-1
(мохибейк ÐÐ_Ñ_...), а вот percent-encoded часть старых испорченных имён
вернулась читаемой кириллицей с пробелами — то есть значение filename=
он URL-декодирует и трактует как UTF-8. Percent-encoding заодно чистый
ASCII, так что ByteString-валидация undici проходит без трюков.
2026-08-15 07:23:07 +03:00
Folist
b9c927024f Имена файлов: MAX читает filename= буквально; кавычки ломали отдачу в Telegram
Сервер 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 чистятся от кавычек и переводов строк.
2026-08-15 07:13:07 +03:00
Folist
20a85cf7e3 Трассировка TG->MAX: лог в начале обработчика, а не только в ветке вложений
Текст/гео/контакт/опрос выходят из обработчика раньше строки логирования,
добавленной прошлым коммитом — в результате текстовые релеи по-прежнему шли
без следа в логе. Тип сообщения теперь определяется и пишется до всех веток.
2026-08-15 07:00:01 +03:00
Folist
97f6fe416d Кириллические имена файлов в загрузке + трассировка релея TG->MAX и /delete
Значения 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 вне темы и на потерянную из-за перезапуска связку.
2026-08-14 22:47:51 +03:00
Folist
6142463914 maxFetch: undici-fetch вместо глобального — чинит все загрузки MAX-CDN
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.
2026-08-14 22:06:50 +03:00
Folist
96457377b7 Ленивый импорт vite: статический ронял прод-контейнер при старте
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.
2026-08-14 21:29:19 +03:00
Folist
f6ee386a61 Аудит безопасности: HTTPS-панель, скоуп доверия CA, сериализация MAX-запросов
- 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
2026-08-14 21:13:26 +03:00
Folist
b414bdee94 Surface known Telegram permission errors, add web-panel logout
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.
2026-08-14 13:18:29 +03:00
Folist
11ad666ada Stop hardcoding the developer's own phone number in bot-facing text
/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).
2026-08-14 13:03:50 +03:00
Folist
8afc421b0a Support password-protected MAX accounts (2FA on top of SMS)
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.
2026-08-14 11:58:47 +03:00
Folist
8c5e3f6f63 Give each forum topic a deterministic icon color per MAX chat
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).
2026-08-14 11:43:54 +03:00
Folist
b42e0cb1df Temporary: log raw CHECK_CODE failure payload for diagnosis 2026-08-14 11:15:08 +03:00
Folist
a1e17e8276 Log auth/phone and auth/verify failures server-side
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.
2026-08-14 11:03:29 +03:00
Folist
15a18e440c Shorten the update-requested confirmation message 2026-08-14 11:00:51 +03:00