Нет повести печальнее на свете, чем повесть о лежачем алерте.

Pusk — self‑hosted сервер алертов на 16 МБ. Один бинарник, без внешних сервисов, частично совместим с Telegram Bot API (13 методов из 80+).
Типичная ситуация: несколько серверов, Zabbix собирает метрики, Python‑боты шлют алерты в Telegram. У кого‑то это веб‑проект, у кого‑то видеонаблюдение, у кого‑то живые эфиры, где 2 минуты без алерта = зрители видят чёрный экран. Работало годами.
А потом канал до API отвалился. Причина неважна — лимиты, блокировки, авария на стороне провайдера. Алерты встали. Нужен был свой канал доставки, который не зависит от внешних сервисов.

Почему не "просто переехать"?
Первая мысль — поднять self‑hosted мессенджер. Посмотрели на варианты — один другого краше:
Mattermost | Matrix | Telegram | |
Доп. сервисы | PostgreSQL, SMTP | Synapse, PostgreSQL | Cloud |
Миграция ботов | Переписывать | Переписывать | — |
ACK алертов | Плагин | Нет | Нет |
Mattermost/Rocket.Chat: Это полноценные платформы со своими схемами, миграциями, обновлениями. Да, Postgres у нас есть — Zabbix на нём живёт. Но накатывать на него ещё одну базу с миграциями и отдельными бэкапами ради доставки алертов — перебор. У многих «сервер» — это виртуалка на 4 ГБ, где Zabbix уже съел свои 2 ГБ.
Matrix: Synapse требует тюнинга, Element избыточен для алертов. Нам нужен браузер и одна кнопка, а не мессенджер с федерацией.
Главное: все эти варианты — полноценные мессенджеры. Команды, пространства, плагины, база данных. Нам не нужен мессенджер. Нужен алертинг с ламповым чатиком: принял webhook, показал дежурному, дал нажать ACK, дал написать коллегам «я взял». Тащить PostgreSQL и 500 МБ RAM ради этого — как поднимать кубер ради пары контейнеров.
Плюс все существующие боты написаны под Telegram Bot API. Можно скормить их AI и портировать за вечер. Но зачем переписывать, если можно не переписывать? И главное под что переписывать?
А VK Teams / MAX / Signal / Яндекс.Мессенджер? VK Teams (ex‑Myteam) — self‑hosted только в корпоративном тарифе, Bot API несовместим с Telegram, боты переписывать. MAX — Bot API есть, но публикация только через верифицированные юрлица РФ, API несовместим с Telegram, self‑hosted нет. Signal — нет Bot API, нет вебхуков. Сервер на GitHub, но Java, минимум 4 CPU / 8 GB и выбор без выбора: или облако AWS/GCP, или поднимать у себя MinIO с обвязкой. Отдельный проект, а не «поставил и забыл». Яндекс.Мессенджер — часть Яндекс 360, облачный, self‑hosted нет.
А Gotify? ntfy? Gotify — push в одну сторону, нет чата, нет ACK. ntfy — аналогично. Нам нужны были ACK, командный чат и совместимость с существующими Telegram‑ботами. Это не про push‑трубу.
А может VPN и оставить Telegram? Туннели, меш‑сети и прочие пляски с бубном — и вот бот снова видит api.telegram.org. Можно, а зачем? Сегодня VPN работает, завтра endpoint заблокирован, послезавтра нужен новый выходной узел. Плюс туннель — это ещё одна точка отказа, которую тоже надо мониторить. Мониторинг мониторинга — мы не за этим. Проще поднять своё за те же 30 минут.
Нам шашечки или ехать? Нам ехать. Написали свой сервер, который прикидывается Telegram Bot API. Поменял base_url в боте и он работает с твоей инфрой и даже не знает об этом.
Архитектура: один бинарник, без внешних сервисов
Почему Go, а не Rust? На Rust тоже пишу, но для этой задачи выбрали Go: net/http из коробки, сборка за 7 секунд с нуля, полсекунды повторная. При десятках запросов в минуту разницы между Go и Rust не увидишь.
Выбор БД был коротким: Postgres — жирная зависимость ради алертов. BoltDB/BadgerDB — key‑value, а нам нужны JOIN'ы и нормальные запросы. SQLite — файл, SQL, ноль настройки. Взяли modernc.org/sqlite — это SQLite, транслированный в чистый Go. Никакого CGO, gcc в Docker не нужен. Платим ~18 МБ в весе бинарника. На старте жрёт ~4 МБ RSS. Логи через slog — structured JSON или text, как нравится.

Что внутри бинарника
pusk (16 МБ, ~6600 строк Go, 110 тестов) ├── Bot API — /bot/<token>/<method> (13 из 80+ методов Telegram Bot API) ├── Client API — /api/* (бэкенд PWA) ├── WebSocket — /api/ws (real-time статусы, typing) ├── Web Push — FCM / Mozilla (уведомления без polling) ├── Файлы — /file/<id> (медиа) ├── PWA — / (веб-клиент) └── SQLite — data/orgs/*/pusk.db (отдельная БД на каждую организацию)
Совместимость «в одну строку»
Telegram Bot API — это не только ценный протокол миграции, но и 13 методов легкоусвояемого API.
Взяли самое необходимое: sendMessage, editMessageText, deleteMessage, answerCallbackQuery, sendPhoto, sendDocument, sendVoice, sendVideo, setWebhook, deleteWebhook, getWebhookInfo, getMe, getUpdates. Этого хватает для типичного мониторингового бота: отправить сообщение, показать кнопки, принять callback, отправить фото/файл. Стикеры и inline‑mode (@бот запрос) пока не тащим, inline‑кнопки под сообщениями — есть. Нереализованные методы возвращают 400 unknown method, а не молча глотают.
Нюанс: Telegram‑клиенты шлют запросы на /botTOKEN/method (слитно), а наш роут POST /bot/ ловит только /bot/.... Мидлварь на 5 строк переписывает путь на лету:
func TelegramCompat(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { p := r.URL.Path if strings.HasPrefix(p, "/bot") && !strings.HasPrefix(p, "/bot/") { r.URL.Path = "/bot/" + p[4:] } next.ServeHTTP(w, r) }) }
Бот на python‑telegram‑bot переезжает так:
# Было: app = ApplicationBuilder().token(TOKEN).build() # Стало: app = ApplicationBuilder().token(TOKEN).base_url("https://pusk.internal/bot").build()
Длинный опрос (Long Polling)
Python‑библиотеки (python-telegram-bot, aiogram) ожидают от /getUpdates конкретного поведения: учитывай offset, держи соединение timeout секунд, отдавай только новые. Реализовали через очередь в памяти (100 апдейтов на бота). Poll() ждёт первое сообщение или таймаут, потом сливает всё что накопилось. Апдейты с UpdateID <= offset пропускаются. Персистенс не нужен, если бот перезапустился, он просто получит следующие алерты.
SQLite на каждую организацию
Каждая организация — отдельный файл SQLite. Честно: взяли SQLite потому что не хотели тащить Postgres как зависимость. Оказалось, что для алертов (десятки сообщений в минуту) этого хватает с запасом. Бонусом получили изоляцию: бэкап организации = cp файла, удалить = rm файла. WAL‑режим — SQLite не блокирует читателей при записи. Горячий бэкап: sqlite3 pusk.db ".backup backup.db" прямо на живом сервере.
WebSocket‑релей
Webhook работает, когда Pusk может достучаться до бота по HTTP. Если бот за NAT, тогда он сам подключается к Pusk по WebSocket и получает апдейты через обратный канал:
import websockets, json, asyncio async def relay(): async with websockets.connect("wss://pusk.internal/bot/TOKEN/relay") as ws: async for msg in ws: update = json.loads(msg) print(update["message"]["text"]) asyncio.run(relay())
Pusk сам определяет способ доставки: webhook, relay или очередь getUpdates. Webhook URL проверяется на подмену адреса (SSRF) — нельзя заставить Pusk стучаться во внутренние сервисы. Relay требует валидный bot token. Ограничения: 10 auth/мин, 30 msg/мин, 10 upload/мин.
Как это выглядит изнутри
Мы сейчас активно проводим тесты с командой. Вот как выглядит рабочий процесс.
Шаг 1. Админ поднимает сервер. docker run или просто закинул бинарник на сервер и запустил. Создаёт организацию — становится первым админом. Автоматически создаётся #general и системный бот.

Шаг 2. Приглашаем команду. Одна ссылка на 7 дней, до 50 человек. Кинули в рабочий чат — люди регаются сами. При регистрации появляется сообщение «→ username joined the team» в #general. Утекла ссылка — отозвал в настройках.
Шаг 3. Админ создаёт канал #alerts. Подключает webhook из Grafana/Zabbix/Alertmanager. Все участники автоматически подписываются.

Шаг 4. Дежурный (member) видит алерт. Push на телефон, открыл в браузере, нажал ACK. Вся команда видит, кто взял. Member может писать в каналы, @mention'ить коллег, отправлять файлы. Но не может создавать каналы, ботов, приглашать.

Разделение ролей:
Admin | Member | |
Писать в каналы | ✓ | ✓ |
ACK алертов | ✓ | ✓ |
@mention + push | ✓ | ✓ |
Файлы, фото | ✓ | ✓ |
Создать канал/бот | ✓ | ✘ |
Пригласить | ✓ | ✘ |
Переименовать канал | ✓ | ✘ |
Удалить юзера | ✓ | ✘ |
Выдать админа | ✓ | ✘ |
Алерты, алертики, алертушечки
Совместимость с Bot API нужна, чтобы не переписывать ботов. Но раз уж свой сервер — добавили то, чего в Telegram нет:
ACK одной кнопкой. Под алертом кнопка «Подтвердить». Нажал и вся команда видит, кто взял инцидент. Никаких «я смотрю, в процессе, кто смотрит?» в чате.
Кляп для Alertmanager. Нажал ACK — Pusk шлёт POST /api/v2/silences в Alertmanager и глушит повторные алерты. Настройка: одна переменная PUSK_ALERTMANAGER_URL. Проверено на живом Alertmanager — silence создаётся, повторные алерты глохнут.

Цветовые полоски. Видно на скриншоте выше — красная полоска слева = горит, зелёная = решено. Статус инцидента считывается за секунду, не читая текст.
Webhook из любого мониторинга. Alertmanager, Grafana (да, кто‑то шлёт алерты прямо из неё — не осуждаем), Zabbix, raw JSON — один URL, формат в query‑параметре: ?format=alertmanager. Подключение — один URL в настройках мониторинга.
Web Push без батарейки. Pusk не опрашивает сервер — push идёт через стандартные push‑сервисы Google (FCM) и Mozilla — те же, что используют Telegram и Discord.
WebSocket + Push: зачем оба
На десктопе алерт прилетает по WebSocket — мгновенно. На мобилке PWA в фоне — Android убьёт сокет через полминуты. Тут работает Web Push через стандартные push‑сервисы браузеров.
Чтобы не дублировать: перед отправкой push проверяем, подключён ли юзер по WS и смотрит ли он этот канал прямо сейчас. Если да — push не шлём, он и так видит. Если нет — шлём.
А что если закрытый контур?
Pusk работает и без внешнего интернета. Фронтенд вшит в бинарник, никаких CDN с подгрузкой шрифтов и прочего. Единственное исключение — Web Push (нужны серверы FCM/Mozilla). В полной изоляции push на мобилку не работает — FCM/Mozilla за периметром ведь. Если дежурный один и сидит перед монитором — хватит и Grafana. Pusk нужен когда дежурных несколько и важно видеть кто взял инцидент. В изоляции он работает через браузер по WebSocket. Push на телефон внутри контура — через ntfy.sh (self‑hosted, без Google), интеграция в планах.
Командная координация
Плюс тот самый ламповый чатик: каналы, @mention, файлы. Не Telegram, но чтобы написать коллеге «я взял, перезагружаю» — хватает.
Онлайн‑статус. Зелёная точка — онлайн. Жёлтая — отошёл. Сразу видно кто на связи — важно в целом для дежурств.
Защита владельца. Создатель организации не может быть удалён или понижен. #general нельзя удалить или переименовать.
Prometheus /metrics. Подключайте к своей Grafana.
Docker‑образы для российских ОС. Alpine, RED OS, Astra Linux SE — три образа в GHCR. Cosign‑подпись (проверка, что образ не подменён), SBOM (список всех компонентов для аудита).
Грабли и как мы на них плясали
VAPID-ключи при каждом рестарте
Сначала генерировали ключи при старте. Рестарт и ключи новые, подписки сдохли, push молчит. Без ошибок в логах — просто тишина. Классическое «а в логах всё чисто». Вынесли ключи в env.
Service Worker против Android
И опять двадцать пять. SW кешировал всё намертво. Юзер неделю сидел на бажной версии фронта. Перешли на network‑first для JS/CSS. За один день тестирования прошли от v13 до v52.
Rate limit заблокировал своих
Три бота и Zabbix на одном IP. Один бот с кривым токеном задолбил API и заблокировал всех. Теперь лимиты считаются по IP:token.
Vanilla JS без сборщиков
Фронтенд — 10 ES‑модулей, без Webpack/Vite. Меньше движущихся частей при сборке, проще дебажить. Обратная сторона — вложенные бэктики (\${... \...\ ...}) молча убивают весь модуль. Белый экран, ноль ошибок в консоли. Добавили проверку в pre‑commit hook.
Цифры
Go-код | ~6600 строк |
Фронтенд | ~1600 строк (JS 1300 + CSS 320) |
Бинарник | 16 МБ |
RAM | ~4 МБ RSS на старте |
Тесты | 110 unit (Go) + E2E (Playwright) |
Нагрузка | ~500 req/sec, p95 < 20 ms |
Миграция бота | 1 строка, 30 секунд |
Чего пока нет
Безопасность:
Сообщения в SQLite открытым текстом, шифрования нет. Для алертов приемлемо, для переписки, конечно же, нет
Токены ботов в БД хранятся как есть. Пароли юзеров — bcrypt
Функционал:
Нет автоэскалации. ACK есть, но если никто не нажал — алерт просто висит. Расписание дежурств в планах (скорее всего внутри SQLite, без внешних календарей)
Стикеры, групповые чаты, опросы — пока не планируются. Каждый новый метод — это новые баги
Эксплуатация:
Один процесс, один сервер. При обновлении ~2 сек простоя (клиент покажет «Переподключение...» и сам вернётся). Для команды до 50 человек хватает
Код открыт, PR принимаются
Итого: зачем это всё
Алерты доходят. Даже когда внешние сервисы недоступны. SLA не страдает.
Не нужен штат админов. Один файл, ноль настройки, живёт рядом с Zabbix.
Миграция без боли. Существующие боты продолжают работать — меняется одна строка в коде.
Где мы сейчас
Работает в проде. Алерты доходят, ACK работает, Alertmanager молчит, когда надо.
Нагрузочный тест (k6, Xeon E5 2012 года): ~500 req/sec, p95 < 20 мс. Для алертов более чем с запасом.
Попробовать:
- Демо (без смс и регистрации): getpusk.ru
- Исходники: github.com/getpusk/pusk
- Docker: docker run -d -p 8443:8443 ghcr.io/getpusk/pusk:latest
Ставьте, ломайте, пишите в Issues, что отвалилось.
Это первая статья из цикла «Колобок-стек: я от бабушки ушёл». Следующий колобок — свой реестр артефактов на Rust. От кого мы там ушли — в следующей серии.
