Skip to content

Latest commit

 

History

23 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Бот расписания КГУ

Бот с расписанием всех групп вуза — в телеграме и во ВКонтакте. Держит локальную копию расписания в SQLite и отвечает из неё за доли миллисекунды, не дёргая сервер вуза на каждое нажатие кнопки.

Два статических бинаря в двух контейнерах, деплой пушем в main: на сервере нужен только докер.

Как это устроено

                    ┌────────────────────────────────────┐
   apeks.tksu.ru ◄──┤  raspd                             │
   2 req/s, ночью   │  apeks · store · syncer · schedule │
                    │  HTTP API @ /run/rasp/api.sock     │
                    └──────┬───────────────────┬─────────┘
                           │                   │
              schedule.db (SQLite WAL)         │
                           │                   │
              ┌────────────▼───────┐   ┌───────▼──────────┐
              │  botd              │   │  admind          │
              │  botcore · tg · vk │   │  admin           │
              └────────────┬───────┘   └───────┬──────────┘
                           │ long polling      │ :8305 за caddy
                api.telegram.org · api.vk.ru   панель /admin

raspd — единственный процесс, который ходит к вузу. Синхронизирует расписание в фоне и отдаёт его боту по unix-сокету.

botd — сценарии и обе площадки. Не знает ни про Апекс, ни про SQLite. Адаптеры тонкие, а сценарии и кэши общие, поэтому разносить платформы по процессам незачем: платформа поднимается, если для неё задан токен.

admind — панель наблюдения. Ходит в тот же сокет, что и бот, и базу расписания не открывает: у неё по-прежнему ровно один писатель.

Почему не прокси поверх upstream: один запрос расписания группы — это 241 КБ и 0.8 с, из которых 78 % занимает каталог групп, приезжающий неизменным в каждом ответе. Зато весь датасет вуза (675 групп × ~10 месяцев) в нормализованном виде — это десятки мегабайт, которые целиком помещаются в page cache. Отсюда и решение: не кэшировать чужие ответы, а держать полную локальную копию.

Побочные выгоды: бот продолжает работать, когда Апекс лежит (с честной пометкой о свежести данных), и открывается доступ к запросам, которых upstream не умеет в принципе — расписание преподавателя, свободные аудитории, уведомления об изменениях.

Пакеты

Пакет Ответственность
apeks HTTP-клиент вуза: токен, троттлинг, ретраи, разбор JSON. Единственное место, знающее формат Апекса
store SQLite: схема, миграции, репозитории
syncer Планировщик фетчей, singleflight, хэши, детект изменений, уборка
schedule Домен: сборка дня и недели, окна, «сейчас». Без единого I/O
api Контракт raspd ↔ боты поверх unix-сокета
botcore Сценарии, тексты, вёрстка сообщений. Платформо-независим
tg Тонкий адаптер телеграма
vk Тонкий адаптер ВКонтакте
admin Панель: вайтлист, метрики машины, история, страница
logbuf Кольцевой буфер последних жалоб демона

Два правила, которые держат это честным: schedule не делает I/O, botcore не знает про платформы. Формат upstream заперт в apeks — когда вуз обновит движок, правится один пакет.

Второе правило окупилось на VK: адаптер получился на четыре файла, а сценарии, тексты и вёрстка не изменились ни на строку.

Что выяснилось про API вуза

Реверс живых данных дал четыре вещи, которых нет в документации виджета.

1. Занятие адресуется одним из четырёх способов, и от этого зависит и фильтрация, и подпись в сообщении:

Режим Признак Пример groupName
Группа group_id заполнен, flow пуст С-ЛД-12
Поток flow заполнен Поток 1 (С-ЛД-11, С-ЛД-12, …)
Подгруппа subgroup_id заполнен, group_id пуст С-ЛД-12/2
Сборный поток superflowGroupsIds непуст перечисление групп

2. Потоковое занятие приходит с одним и тем же id в ленты всех групп потока, а его собственное group_id указывает на якорную группу, которая может не совпадать с запрошенной. Проверено: все 26 потоковых занятий группы 232 лежат в ленте группы 231 с теми же идентификаторами.

Поэтому занятия нельзя раскладывать по lessons.group_id, как предполагал первоначальный набросок схемы: вторая запись затирала бы первую. Занятие хранится один раз, а принадлежность лентам вынесена в group_lessons (M:N).

3. subgroup_id — глобальный по вузу идентификатор (334, 335), а не порядковый номер внутри группы. Прошлая версия бота вытаскивала подгруппу суффиксом из строки С-ЛД-12/1 и работала ровно для одной зашитой в код группы.

4. В каталоге встречаются одноимённые записи групп. Раньше это было системным правилом: одна запись относилась к нынешнему названию, вторая — к названию следующего учебного года. Вуз перенумеровывает группы ежегодно, и первая цифра номера означает курс: второкурсники «С-ЛД-12» на следующий год станут «С-ЛД-22». В августе 2026 обе записи С-ЛД-22 ещё были активны, имя у них совпадало до буквы, но занятия нового года были ровно у одной:

Запись Курс в дереве Учебный план Занятий за 09.2025 / 02.2026 Занятий за 09.2026
С-ЛД-22 id=39 2 302 94 / 65 0
С-ЛД-22 id=545 1 84 0 / 0 96

Тогда из 675 активных групп были задвоены 209 имён (211 лишних записей). На 1 сентября 2026 КГУ почистил почти весь каталог: живой API отдаёт 412 активных групп с 411 уникальными именами. Задвоенным осталось только Мз-ПППЭ-21 (id=789 и id=349). У С-ЛД-22 теперь одна запись — id=545 на втором курсе; id=39 из дерева удалён.

Поэтому механизм нельзя удалить целиком. Пока обе записи есть, лишняя помечается признаком shadowed и не показывается в поиске и обзоре. Когда upstream удаляет старую запись, она становится неактивной, а привязанные к ней пользователи автоматически переезжают на единственную оставшуюся одноимённую группу; подгруппа переносится по имени. Каталог обновляется сразу при запуске, чтобы после деплоя устаревшая строка «Копия группы» не жила до суточного фонового прохода.

Ловушка в том, что роли записей переворачиваются раз в год. Внутри учебного года настоящая запись — та, чей курс совпадает с первой цифрой имени. Но в августе вуз перенумеровывает имена, не трогая курсы, и правило обращается: с сентября 2026 расписание С-ЛД-22 лежит у записи с курсом 1, а у записи с курсом 2 — той самой, что весь прошлый год была настоящей, — ноль занятий. Проверено на живых данных: из 12 задвоенных имён, у которых расписание на сентябрь 2026 заведено, во всех 12 случаях оно у записи с курсом на единицу меньше первой цифры имени.

Поэтому решение принимается по занятиям, а не по соглашению об именах: занятия начиная с текущего месяца → у кого расписание тянется дальше → и только в последнюю очередь курс, соответствующий имени с поправкой на фазу каталога. Саму поправку тоже считают данные: среди всех групп с актуальными занятиями берётся смещение курса, которое встретилось чаще. Решение пересчитывается после каждого обновления каталога и после каждой записи расписания, так что и следующая августовская перенумерация разрулится сама.

Остаётся случай, когда занятий нет вообще ни у кого — свежая установка, пока не прошёл первый обход. Угадать тут нечего, и раньше код всё же угадывал: голосов за смещение нет, значит ноль, значит прошлогодний расклад. В августе это ровно наоборот, и первый же деплой спрятал настоящую запись у каждого из 209 задвоенных имён — половина каталога исчезла из поиска и обзора. Теперь без свидетельств не прячется никто: видны обе копии, и ни одна группа не пропадает. В обзоре это даже не заметно — копии лежат на разных курсах.

Свести окно к минутам помогает второе изменение: первый полный обход идёт сразу после установки, а не ждёт трёх ночи. Каталог без расписания всё равно бесполезен.

Когда свидетельства есть, но решение всё же промахнулось, в настройках бота есть строка «🔀 Копия группы» (появляется только у тех, у кого две активные одноимённые записи действительно остались) с состоянием расписания каждой: «расписание идёт» или «закончилось 10.06.2026». Переключение переносит подгруппу по имени: у копий названия подгрупп совпадают, а id различаются.

Плюс мелочи, которые ломали бы вывод: lessonTimesEnabled задаёт учебные дни данными (воскресенья в ответе просто нет — хардкодить его не нужно), а поле classroom иногда приезжает с кусками вёрстки внутри (<i class="fa fa-times"></i> без ауд.), которые обязаны быть срезаны до того, как попадут в сообщение с parse_mode=HTML.

UX

Что изменилось по сравнению с прошлой версией:

  • Выбор любой группы вуза — поиском по названию (слд12, с лд 12, С-ЛД-12 находят одно и то же) или обзором по институтам и курсам. Раньше группа была зашита в код.

  • Настоящие подгруппы с их именами из API вместо жёстких «Подгруппа 1/2».

  • Навигация по абсолютной дате. В callback_data кнопок лежит дата, а не смещение от «сегодня». Это убирает целый класс багов: прошлая версия считала смещения от текущего дня и отдельно подпихивала пропуск воскресенья, из-за чего стрелка «вперёд» срабатывала со второго раза.

  • Чужая группа — просмотр, а не переезд. Написал название любой группы — показывается её расписание с пометкой и кнопкой «✅ Сделать моей», а собственная привязка, подгруппа и адресат рассылки остаются нетронутыми. Группа едет в callback_data вместе с датой, поэтому чужую неделю можно листать сколько угодно, не заводя состояния диалога. Раньше любой выбор группы молча переписывал настройки — при том что /help прямо приглашал «просто напиши название другой группы».

  • Компактная неделя — одна строка на пару вместо шести полноразмерных дней, склеенных в простыню. Пустой учебный день остаётся в выдаче строкой «занятий нет»: молча пропущенная среда неотличима от среды без пар, а спрашивают как раз об этом.

  • «Окна» между парами считаются по пропущенным слотам сетки звонков, а не по минутам: перемена в 20 минут окном не считается.

  • Обрезанный поиск называет себя обрезанным. Восемь результатов из двадцати, показанные молча, читаются как «моей группы бот не знает».

  • Длинный курс листается, а не обрезается. На первом курсе медицинского института 42 группы — больше, чем помещается на экран мессенджера. Раньше хвост списка отбрасывался с подписью «не поместилось»: выбрать такую группу кнопкой было нельзя вовсе. Теперь список идёт страницами со стрелками, и номер страницы виден в заголовке.

  • «Сейчас / следующая пара» — с учётом часового пояса пользователя.

  • Уведомления — отдельная вкладка настроек вместо одной кнопки «утренняя рассылка». Главный тумблер, утреннее сообщение (расписание на сегодня, с 00:00 до 08:30), вечернее (следующий учебный день, с 09:00 до 23:30), правки расписания — каждое включается порознь. Время пишется текстом: 7:15, 7.15, 715 и 7 понимаются одинаково, а рядом лежат кнопки с привычными значениями. Ради этого одного ввода заведено единственное во всей системе состояние диалога — и живёт оно в записи пользователя, а не в памяти botd, так что перезапуск не роняет человека на середине настройки.

  • Новость о правке — только тем, у кого правили, и только про будущее. Отпечаток содержимого считается по ленте каждой группы отдельно, поэтому перестановка пары у С-ЛД-22 не разбудит С-ЛД-23; потоковую лекцию видят все группы потока — и все они узнают, потому что у каждой изменилась своя лента. Второй отпечаток считается только по дням, которые ещё не прошли: вуз постоянно дописывает темы и меняет аудитории задним числом, и новость про пару двухнедельной давности — чистый шум. Граница окна хранится вместе с отпечатком: сравнивать их можно только на одной и той же дате, иначе первый же синк после полуночи разослал бы «расписание изменилось» всей базе.

  • Новость называет дни и умеет показать «до/после». «Расписание изменилось» без единой подробности заставляет открывать месяц и искать разницу глазами. Поэтому в момент правки raspd сравнивает старый месяц с новым день за днём, кладёт задетые числа прямо в сообщение и откладывает снимок каждого такого дня в change_days. По кнопке «подробнее» бот показывает «было / стало» одного дня: пропавшие пары помечены , появившиеся — , совпавшие идут точкой.

    Дни в тексте — потому что «меня это касается?» самый частый вопрос и он должен решаться без нажатий. Сравнение за кнопкой — потому что его открывают единицы, и платить за них запросом на каждого адресата рассылки нельзя. Снимок хранится только «до»: «после» и так лежит в базе, а его копия устарела бы первой. Живут снимки трое суток — дольше, чем сообщение в очереди, чтобы кнопка работала и назавтра; когда снимок истёк, бот честно говорит, что уже не помнит, вместо того чтобы выдавать нынешний день за неизменённый. Если правка задела чужую подгруппу, сравнение так и скажет: два одинаковых списка человек сличать не должен.

  • Отписка от правок — в самой новости. Эта рассылка включена по умолчанию и приходит без спроса, поэтому под новостью стоит «🔕 Не сообщать о правках»: за ней человек не пойдёт в «Настройки → Уведомления», он либо стерпит, либо заблокирует бота — и утреннее расписание уедет вместе с новостями. Кнопка не переключатель, а именно «выключить»: сообщение живёт в переписке вечно, и нажатие спустя месяц не должно молча вернуть то, от чего человек отписался (нужное состояние едет в самой кнопке — ncs:off и ncs:on). Подтверждение приходит отдельным сообщением, а не правкой новости: иначе вместе с ней пропала бы кнопка «что изменилось» — ровно та, ради которой переписку и открыли. В подтверждении сказано, что остальные рассылки на месте, и лежит кнопка возврата; она заодно поднимает главный тумблер, если тот успел выключиться.

  • Пустые дни: молчание, объяснённое один раз. Раньше бот писал и в дни без пар. Теперь такие сообщения выключены по умолчанию — «занятий нет» никто не просил, — но в первый же пустой день приходит разовое объяснение с кнопкой «присылать и в пустые дни». Без него молчание бота неотличимо от поломки, а с ним человек узнаёт о настройке ровно тогда, когда она ему понадобилась.

  • Пометка о свежести вместо молчания, когда сервер вуза недоступен.

  • Нет кнопки «Обновить данные» — она была заплаткой над кэшем. Данные обновляются сами, а пользователю не нужно знать про существование кэша.

  • В поиске нет пустых двойников групп — см. находку 4 выше, а если автоматика всё же ошиблась, копия переключается руками из настроек.

Что во ВКонтакте иначе

Три отличия, из-за которых адаптер VK длиннее телеграмного, — и все они спрятаны в нём, а не протекли в сценарии:

  • Разметки в сообщениях нет. Ни HTML, ни markdown, ни parse_mode, а юникодного жирного для кириллицы не существует — передать структуру можно только самими символами. Поэтому адаптер теги не выбрасывает, а отрисовывает: жирный и курсив уходят, а <blockquote> становится полоской . Без неё заголовок дня ничем не отличался от строки с парой, и неделя читалась как ровная простыня.
  • Клавиатур две, и они разные. Инлайн-клавиатура живёт внутри сообщения и позволяет его редактировать — но вмещает всего 10 кнопок в 6 строках. Нижняя панель вмещает 40, зато она одна на диалог и к сообщению не привязана. Поэтому кнопки едут инлайном, пока помещаются, а списки институтов и групп — на панель; заняв её, адаптер держит там и следующие экраны, пока первый ответ с меню не вернёт панель на место. Список, который не влезает и туда, — 42 группы первого курса медицинского института — листается страницами: botcore режет длинные списки по 16 групп и 9 институтов, оставляя строку под «◀️ Стр. N / Стр. M ▶️», а номер страницы едет в callback-данных меткой «pN». Размер страницы посчитан по панели ВКонтакте — самому тесному из двух экранов; телеграму те же страницы не жмут, а расходиться раскладкам ради него значило бы завести в сценариях знание о платформе. Обрезка с подписью «не поместилось» осталась в адаптере последней сеткой: лимиты считаются в двух местах, и разъехаться они могут молча.
  • Подпись кнопки — не больше 40 символов, и на длинной ВКонтакте отвергает клавиатуру целиком, а не одну кнопку. «Институт искусств и социокультурного проектирования» — это 51, поэтому адаптер режет подписи по границе слова.
  • Про свой retry_after ВКонтакте молчит. Паузы для рассылки назначаются адаптером, а «недоставляемых» адресатов он узнаёт по кодам 900/901/902.
  • Расписание — для подписчиков сообщества (VK_REQUIRE_SUBSCRIPTION, по умолчанию включено). Незнакомец получает просьбу подписаться и кнопку «Я подписался»; сценарии открываются после проверки. Дословная реализация — groups.isMember перед каждым ответом — стоила бы лишнего запроса на каждое нажатие: это и десятки миллисекунд к задержке, и треть от лимита в 20 запросов в секунду, одного на все методы ключа сообщества. Поэтому бот держит список подписчиков целиком и отвечает по нему из памяти: на пути человека сети нет вовсе. Список приезжает полным обходом (groups.getMembers, тысяча id за запрос) раз в сутки — несколько запросов в день на весь бот, сколько бы человек им ни пользовалось, — а между обходами его правят события group_join и group_leave. Единственный запрос про одного человека делается по кнопке «Я подписался», где его и ждут.
  • События — ускорение, а не источник истины. Полагаться на group_leave нельзя: длинный опрос после любого обрыва поднимается с новым ts, то есть события за время обрыва не доезжают; сервер и сам просит начать заново, отвечая failed 1 и 3; а рестарт при деплое обнуляет память процесса. Ровно поэтому свежесть держит полный обход, и расхождение «в кэше подписчик, а на деле нет» не живёт дольше суток независимо от событий. Точнее и не нужно: отписка сразу после подписки — редкость, а тот, кто отписался ради того, чтобы через день упереться в ту же просьбу, скорее перестанет пробовать, чем найдёт в этом лазейку. Включать типы событий в настройках сообщества руками не нужно — их включает сам botd по зарегистрированным обработчикам, для чего и требуется право «Управление сообществом».

Long polling здесь тоже свой: библиотека выходит из опроса на первой же ошибке, включая обычный обрыв связи, поэтому цикл поднимается заново сам.

Запуск

Локально:

APEKS_TOKEN=<токен> make run-rasp
TELEGRAM_TOKEN=<токен> VK_TOKEN=<ключ сообщества> make run-bot

Токены независимы: с одним поднимется одна площадка, без обоих botd откажется стартовать. Требование подписки на сообщество на время отладки снимается VK_REQUIRE_SUBSCRIPTION=false.

Сокет отлаживается обычным curl:

curl -s --unix-socket run/api.sock "http://rasp/schedule/day?group=232&date=2025-09-19"

Деплой: пуш в main → прод обновился сам

CI в .github/workflows/deploy.yml: на каждый пуш в main Actions гоняет go vet и тесты, собирает docker-образ (ghcr.io/uwuocha/dairy-303) и по SSH говорит серверу стянуть его и перезапустить контейнеры. VPS ничего не компилирует — на гигабайте это существенно: пиковые сотни мегабайт сборки modernc.org/sqlite остаются на раннерах GitHub.

Вместе с образом уезжает и compose.yaml, поэтому правка лимитов или тома доезжает до прода тем же пушем. Секреты этим путём не ездят вовсе: .env живут только на сервере.

Разовая настройка сервера:

sudo mkdir -p /opt/rasp/deploy && sudo chown -R $USER /opt/rasp
# положить сюда из репо: compose.yaml и deploy/*.env по образцу *.env.example
scp compose.yaml            user@vps:/opt/rasp/
scp deploy/rasp.env.example  user@vps:/opt/rasp/deploy/rasp.env
scp deploy/bot.env.example   user@vps:/opt/rasp/deploy/bot.env
scp deploy/admin.env.example user@vps:/opt/rasp/deploy/admin.env
ssh user@vps 'chmod 600 /opt/rasp/deploy/*.env'

Заполнить на сервере APEKS_TOKEN в rasp.env, токены площадок в bot.env (TELEGRAM_TOKEN, VK_TOKEN — что именно нужно каждому, написано в bot.env.example) и ADMIN_ALLOW_IPS в admin.env — без него панель не поднимется. Дальше первый запуск:

cd /opt/rasp
# GHCR приватный. Постоянный PAT на сервере не нужен: при автодеплое workflow
# сам логинится одноразовым GITHUB_TOKEN прогона. Руками, для первого раза, —
# PAT (classic, scope read:packages):
#   docker login ghcr.io -u UwUOcha
docker compose pull && docker compose up -d

Разовая настройка репозитория — три секрета в Settings → Secrets and variables → Actions:

Секрет Что это
DEPLOY_HOST адрес VPS
DEPLOY_USER SSH-пользователь
DEPLOY_PORT порт SSH, если он не 22 — иначе секрет можно не заводить
DEPLOY_SSH_KEY приватный deploy-ключ (сгенерировать отдельный: ssh-keygen -t ed25519 -f deploy_key, публичную часть — в ~/.ssh/authorized_keys на сервере)

Пока секреты не заданы, workflow просто публикует образ, шаг деплоя пропускается. Выкатить main без нового коммита — кнопка Run workflow.

Портов наружу нет ни у одного контейнера: обе площадки работают длинным опросом, так что ни обратного прокси, ни сертификатов, ни дырок в файрволе этому деплою не нужно.

Проверка и логи:

ssh user@vps 'cd /opt/rasp && docker compose ps'
ssh user@vps 'cd /opt/rasp && docker compose logs -f'

raspd показывает healthy, когда база открыта и запросы обслуживаются. Пробу выполняет сам бинарь — raspd -health, — потому что в образе нет ни шелла, ни curl. Состояние фонового синка в пробу намеренно не входит: под него есть отдельное поле sync_error, и алертить стоит по нему. Разделение не косметическое: недоступный сервер вуза не делает бота неработоспособным — локальная копия ровно для того и держится, — а вот перезапуск живого демона из-за ночи без связи с вузом был бы чистым вредом.

Сокет отлаживается изнутри контейнера:

docker compose exec raspd /usr/local/bin/raspd -health

Бэкап

make backup HOST=user@vps

Архив едет потоком в файл на ноутбуке, на сервере не остаётся ничего: база лежит в томе докера, а tar запускает одноразовый контейнер с alpine — в самом образе нет ни шелла, ни tar.

Забирать базу стоит не ради расписания — оно за ночь скачается заново, — а ради таблицы пользователей: привязки, подгруппы и подписки восстановить неоткуда. Снимок берётся с живой базы: WAL едет в архиве вместе с ней, поэтому копия открывается и доигрывает журнал сама.

Что даёт контейнер сверх удобства

Периметр каждого процесса сузился до того, что ему правда нужно:

  • У ботов нет ни базы, ни токена вуза. Токены разнесены по двум env-файлам, том с базой примонтирован только к raspd. Скомпрометированный адаптер площадки не даёт ни ключа Апекса, ни файла с пользователями. Под systemd оба демона ходили одним пользователем и читали оба env-файла.
  • Сокет у botd смонтирован только на чтение. Подключиться это позволяет (для сокета проверяются права инода, а не режим монтирования), а создать рядом файл — уже нет. Сети между контейнерами не заведено вообще.
  • Внутри образа нечего эксплуатировать: distroless без шелла и пакетного менеджера, процесс не root (uid 65532), корневая ФС только на чтение, все capability сброшены, no-new-privileges. Единственное записываемое место — /tmp на tmpfs с noexec.
  • Потолки памяти те же, что были у systemd: 256 МБ на raspd, 128 МБ на botd, swap запрещён.
  • На сервер не попадает ни исходников, ни ключей от реестра: образ приходит готовым, а логин в GHCR живёт до конца прогона Actions.

Ручные пути

Оба на месте — на случай, когда Actions недоступен или образ не должен уезжать в реестр:

make deploy-manual HOST=user@vps   # собрать образ локально и отдать по ssh
make deploy-binary HOST=user@vps   # вообще без докера: два бинаря под systemd

deploy-manual собирает образ на ноутбуке и гонит его потоком (docker save | gzip | ssh docker load) — реестр в этом пути не участвует. Порт ssh берётся из ~/.ssh/config; задать явно — SSH_PORT=2222.

deploy-binary — прежняя схема с systemd, юниты лежат в deploy/*.service. Первичная установка:

sudo useradd --system --home /var/lib/rasp --shell /usr/sbin/nologin rasp
sudo mkdir -p /etc/rasp && sudo chown rasp:rasp /etc/rasp
sudo cp deploy/rasp.env.example  /etc/rasp/rasp.env
sudo cp deploy/bot.env.example   /etc/rasp/bot.env
sudo cp deploy/admin.env.example /etc/rasp/admin.env
sudo chmod 640 /etc/rasp/*.env && sudo chown rasp:rasp /etc/rasp/*.env
sudo cp deploy/*.service /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl enable --now raspd botd admind

Логи — journalctl -u raspd -f, живость — sudo -u rasp /usr/local/bin/raspd -health.

Панель наблюдения

Панель живёт по пути /admin — витрина цифр: аптайм, пользователи, популярные группы, ресурсы машины, здоровье синка, очередь и последние жалобы демонов. Графики строятся из собственной истории admind — точка раз в пять минут, тридцать дней хранения, около мегабайта на диске.

Панель ничего не меняет. Ни одной кнопки, ни одного POST, ни одного пути, который бы что-то записал в расписание или в пользователей, — поэтому вместо авторизации у неё список адресов в deploy/admin.env:

ADMIN_ALLOW_IPS=203.0.113.10, 198.51.100.0/24

Список намеренно не в репозитории: домашний адрес админа — личные данные, а публичный git для них плохое место. Пустой список — не «пускать всех», а отказ стартовать.

Граница доверия проходит по прокси. Контейнер admind не публикует наружу ни одного порта: до него дотягивается только caddy по сети докера. Поэтому X-Forwarded-For читается только у соединений из приватной сети, и подделать свой адрес заголовком снаружи нельзя — снаружи до панели просто нет пути. Посторонний получает 404, а не 403: ему незачем знать, что по этому адресу что-то есть.

Три источника данных сходятся в одном ответе:

  • база — агрегаты собирает raspd (store.Stats), потому что владелец базы один и лезть в файл вторым процессом ради красивых цифр незачем;
  • машина/proc хоста, смонтированный в контейнер только на чтение: изнутри собственный /proc показывает контейнер, а знать надо про машину;
  • ботbotd раз в полминуты присылает raspd свои счётчики и последние жалобы. Направление именно такое: у бота нет ни одного входящего порта, и заводить его ради статистики значило бы разменять это свойство на удобство.

Когда raspd молчит, панель показывает прошлый снимок с честной пометкой, сколько ему минут, — тем же приёмом, которым бот переживает падение Апекса.

Страница целиком лежит внутри бинаря (embed) и не тянет ни одного внешнего файла: ни шрифтов, ни библиотек графиков. Панель нужна ровно в тот момент, когда с сервером что-то не так, и зависеть в этот момент от чужого CDN — значит остаться без единственного прибора. Графики нарисованы руками в SVG.

Настройка caddy (он живёт в соседнем compose-проекте); домен подставьте свой:

<ваш-домен> {
	encode zstd gzip
	handle /admin* {
		reverse_proxy admind:8305 {
			# Перезаписываем, а не дополняем: иначе клиент прислал бы свой
			# X-Forwarded-For и объявил себя кем угодно.
			header_up X-Forwarded-For {remote_host}
			header_up -X-Real-IP
		}
	}
}

admind подключается к сети проекта caddy как к внешней (dev303_default в compose.yaml). На сервере она уже есть; на ноутбуке её создаёт make docker-up.

Бюджет ресурсов

Компонент RSS
raspd 40–80 МБ
botd 30–50 МБ
admind 15–30 МБ
SQLite page cache 100–200 МБ

Снимки изменённых дней (change_days) в этот бюджет заметного вклада не вносят: снимок дня — это полтора-два килобайта, откладывается только на настоящей правке будущего дня, не больше 20 дней на месяц группы, и вычищается через трое суток тем же уборщиком, что и очередь исходящих. Лишний запрос к базе на правку тоже один — и приходится он на месяц, который в этот момент и так переписывается целиком.

Панель к этому бюджету добавляет немного: она спит почти всё время — страница собирается за миллисекунды, точка истории пишется раз в пять минут, и опрос браузера раз в десять секунд упирается не в сервер, а в то, с какой скоростью человек вообще способен замечать изменения.

Помещается на VPS с 1 ГБ с запасом около половины. Ночной полный обход — 675 групп × 2 месяца = 1350 запросов, при 2 req/s это примерно 11 минут и 325 МБ трафика раз в сутки. Днём работает только контур «горячих» групп — тех, за которыми стоят живые пользователи; их из 675 набирается несколько десятков.

Вежливость к вузу

Это сервер колледжа, а не CDN, и единственный реальный риск проекта — что нас сочтут нагрузкой и закроют доступ. Поэтому: 2 запроса в секунду, максимум 2 одновременных, тяжёлый обход ночью, User-Agent с контактом, singleflight против дублей. Менять эти значения в сторону увеличения — плохая идея.

Токен взят из публичного виджета вуза и в код не зашит: он живёт ровно до тех пор, пока админ Апекса не решит его сменить.

Что дальше

Порядок из плана архитектуры, пункты 1–3 сделаны:

  • apeks + store + syncer — синк наполняет базу
  • schedule — домен с табличными тестами
  • users + botcore + tg — телеграм-бот с текстовым выводом
  • vk — адаптер поверх готового botcore
  • Уведомления об изменениях расписания — content_hash ловит правку, raspd ставит её подписчикам группы в outbox, botd разгребает очередь
  • «Что именно изменилось» — список задетых дней в самом сообщении и «до/после» по кнопке, из снимков в change_days
  • Вкладка уведомлений — утро, вечер, пустые дни и правки по отдельности, время рассылки текстом
  • renderd + media_cache — картинки: SVG-шаблон → resvg, кэш file_id
  • Расписание преподавателя и свободные аудитории — индексы под них в схеме уже есть, запросов пока нет
  • Экспорт в iCal — подписка в календарь телефона

About

Телеграм- и ВК-бот расписания: локальная копия расписания вуза в SQLite, два демона на Go и панель наблюдения

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages