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

Pusk — self‑hosted сервер алертов на 16 МБ. Один бинарник, без внешних сервисов, частично совместим с Telegram Bot API (13 методов из 80+).

Типичная ситуация: несколько серверов, Zabbix собирает метрики, Python‑боты шлют алерты в Telegram. У кого‑то это веб‑проект, у кого‑то видеонаблюдение, у кого‑то живые эфиры, где 2 минуты без алерта = зрители видят чёрный экран. Работало годами.

А потом канал до API отвалился. Причина неважна — лимиты, блокировки, авария на стороне провайдера. Алерты встали. Нужен был свой канал доставки, который не зависит от внешних сервисов.

Лендинг Pusk с демо-ботом
Лендинг Pusk с демо-ботом

Почему не "просто переехать"?

Первая мысль — поднять 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. Все участники автоматически подписываются.

Канал алертов: firing (красный), resolved (зелёный), кнопки ACK/Mute/Resolved
Канал алертов: firing (красный), resolved (зелёный), кнопки ACK/Mute/Resolved

Шаг 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 создаётся, повторные алерты глохнут.

Alertmanager UI: silence создан автоматически при ACK в Pusk
Alertmanager UI: silence создан автоматически при ACK в Pusk

Цветовые полоски. Видно на скриншоте выше — красная полоска слева = горит, зелёная = решено. Статус инцидента считывается за секунду, не читая текст.

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)

Нагрузка
(k6, Xeon E5 2012 г.)

~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. От кого мы там ушли — в следующей серии.