Перейти к содержимому

Go и Python SDK

Официальные SDK закрывают ту часть API, которую программы дёргают чаще всего: отправку сообщений, вопросы с ожиданием ответа, heartbeat, мониторы и приём метрик. Методы типизированы, ошибки разобраны, повторы с Retry-After уже внутри — собирать HTTP-запросы к REST API руками не нужно.

  • Go — модуль github.com/truboter/notifly.ru/sdk/go, единственная зависимость — github.com/coder/websocket (сама без зависимостей): по сокету SDK получает сигнал о готовом ответе на вопрос.
  • Python — пакет, который импортируется как notifly, Python 3.9+, единственная зависимость — requests.

С исходниками на руках Go-модуль подключается директивой replace в go.mod, а Python-пакет ставится из каталога с его pyproject.toml (pip install <каталог>). Импорты в коде такие:

import notifly "github.com/truboter/notifly.ru/sdk/go"
from notifly import Notifly

Клиент создаётся с токеном, и именно вид токена решает, что клиенту можно. Не тот вид — 401; верный вид без нужного права (например, отправка шарой «только просмотр») — 403. Подробно про выпуск и скоуп — на странице API-токены.

ТокенПрефиксКуда передаётсяЧто открывает
API-токенMWithAPIToken(…) / Notifly("M…")всё на уровне аккаунта: каналы, мониторы, heartbeat-ы, сканы, история, сообщения
Токен канала или шарыA / SWithChannelToken(…) / Notifly("A…")один канал: отправка и ask-цикл (нужно право записи, иначе 403), чтение сообщений и подписка по сокету (нужно право чтения)
Токен устройстваCтуда же, куда API-токенте же маршруты, но это сессия устройства, а не ключ для интеграций
Ping-токен heartbeatHаргументом вызоваHeartbeatPing / heartbeat_ping
Токен источника метрикMаргументом вызоваPushMetric / push_metric — несмотря на префикс, это не API-токен
Токен роутераRаргументом вызоваTriggerRouter / trigger_router — вызов ingest-URL роутера вебхуков
client := notifly.New(notifly.WithChannelToken("A<channel-token>")) // defaults to https://api.notifly.ru
msg, err := client.Send(ctx, notifly.Message{
Title: "Deploy finished",
Body: "v1.4.2 is live",
Priority: 7, // 0 = use the channel default
Extras: map[string]any{
"client::display": map[string]any{"contentType": "text/markdown"},
},
})
nf = Notifly("A<channel-token>") # defaults to https://api.notifly.ru
nf.send("Deploy finished", "v1.4.2 is live", priority=7)
# Markdown and other extras:
nf.send("Report", "**bold** text", extras_dict={
"client::display": {"contentType": "text/markdown"},
})

Приоритет, заголовок и дополнительные поля — как в Отправке сообщений и Доп. полях.

Скрипт задаёт вопрос и ждёт, пока человек нажмёт кнопку в приложении — получается gate перед опасным шагом (подробности — Вопросы).

q, err := client.Ask(ctx, notifly.AskRequest{
Question: "Deploy v1.4.2 to production?",
Options: []string{"deploy", "abort"},
DefaultOption: "abort",
TimeoutSec: 600, // auto-expire after 10 minutes
DefaultAnswer: "abort", // submitted automatically on timeout
})
waitCtx, cancel := context.WithTimeout(ctx, 15*time.Minute)
defer cancel()
ans, err := client.WaitAskAnswer(waitCtx, q.ID, 3*time.Second)
if ans.Status == notifly.AskAnswered && ans.Answer == "deploy" {
// approved
}
q = nf.ask(
"Deploy v1.4.2 to production?",
options=["deploy", "abort"],
default_option="abort",
timeout_sec=600,
default_answer="abort",
)
ans = nf.wait_ask_answer(q["id"], poll=3, timeout=900)
if ans["status"] == "answered" and ans["answer"] == "deploy":
... # approved

В Go WaitAskAnswer подписывается на вопрос по WebSocket и возвращается примерно через секунду после того, как человек нажал кнопку. Опрашивать раз в pollInterval он начинает только там, где сокет открыть не удалось: свой сервер без /ws, прокси без upgrade, токен канала без права чтения. Отключить сокет совсем — notifly.WithPollingOnly(); эта опция выключает и Subscribe (он сразу вернёт ErrWebSocketUnavailable). Python-SDK опрашивает всегда, а wait_ask_answer, не дождавшись ответа за timeout, бросает NotiflyError — сам вопрос при этом остаётся открытым.

GetAskAnswer / get_ask_answer — один опрос, если цикл ожидания вы пишете сами; статусы pending, answered, expired, cancelled. Воркеру, который разбирает много вопросов сразу, подойдут PendingAnswers + AckAnswers (pending_answers + ack_answers). Продлить дедлайн — ExtendAsk (extend_ask_question), он ставит новый срок «сейчас + N секунд», а не добавляет к остатку.

У AskRequest есть и другие поля: Fields — форма вместо кнопок (ответ приходит JSON-объектом), Next — ветвление: какой вопрос задать следом при выборе варианта (его id вернётся в NextQuestionID), AllowMulti, JSONFormat, ClientMessageID — ключ идемпотентности, CallbackURL и CallbackSecret — вебхук на итог вопроса. В Python это одноимённые аргументы ask(): fields, next, allow_multi, json_format, client_message_id, callback_url, callback_secret.

Ответить на вопрос самому — AnswerAsk / AnswerAskMulti (answer_ask_question): так автоматизация снимает собственный gate, когда её проверки прошли. Отозвать ещё открытый вопрос — CancelAsk (cancel_ask_question). Оба действуют от имени аккаунта, поэтому берут не токен канала, а API-токен.

Управление аккаунтом: каналы, мониторы, heartbeat

Заголовок раздела «Управление аккаунтом: каналы, мониторы, heartbeat»

Это уже API-токен.

admin := notifly.New(notifly.WithAPIToken("M<api-token>"))
ch, err := admin.CreateChannel(ctx, notifly.ChannelParams{
Name: "deploy-bot",
Description: "CI notifications",
})
fmt.Println("channel token:", ch.Token) // shown only once
_, err = admin.CreateMonitor(ctx, notifly.MonitorParams{
ChannelID: ch.ID,
Name: "example.com",
Kind: notifly.KindHTTP, // 27 kinds, see the Kind* constants
Target: "https://example.com",
IntervalSec: 60,
AlertMessage: "example.com is down",
})
hb, err := admin.CreateHeartbeat(ctx, notifly.HeartbeatParams{
ChannelID: ch.ID,
Name: "nightly-backup",
IntervalSec: 24 * 3600,
GraceSec: 3600, // tolerate an hour of lateness
AlertMessage: "nightly backup did not report",
})
// hb.PingToken is a capability for this heartbeat alone — all the job needs:
err = admin.HeartbeatPing(ctx, hb.PingToken)
admin = Notifly("M<api-token>")
ch = admin.create_channel("deploy-bot", description="CI notifications")
print("channel token:", ch["token"]) # shown only once
admin.create_monitor(
channel_id=ch["id"],
name="example.com",
kind="http", # 27 kinds — see MONITOR_KINDS
target="https://example.com",
interval_sec=60,
alert_message="example.com is down",
)
hb = admin.create_heartbeat(
channel_id=ch["id"],
name="nightly-backup",
interval_sec=24 * 3600,
alert_message="nightly backup did not report",
grace_sec=3600,
)
admin.heartbeat_ping(hb["pingToken"])

Шесть семейств мониторов устроены одинаково — List/Create/Update/Delete/Pause/Resume: обычный (Monitor), HTTP-запрос (HTTPMonitor), TCP-порты (PortMonitor), контент страницы (ContentMonitor), многошаговый HTTP (WorkflowMonitor) и сценарий в браузере (BrowserWorkflow). У всех, кроме браузерного, есть Test* — проверить цель один раз, ничего не создавая; MonitorHistory и MonitorLog показывают, почему монитор «мигает».

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

Любой не-2xx ответ становится ошибкой с разобранным телом. Проверять статус числом не нужно — есть предикаты.

_, err := client.Send(ctx, notifly.Message{Body: "hi"})
switch {
case err == nil:
case notifly.IsRateLimited(err): // 429
var apiErr *notifly.APIError
errors.As(err, &apiErr)
time.Sleep(apiErr.RetryAfter)
case notifly.IsAuth(err): // 401/403 — wrong, revoked or read-only token
log.Fatal("check the token kind and its scope")
case notifly.IsQuotaExceeded(err): // 402 — the plan does not allow this
log.Fatal(err)
}
import time
from notifly import NotiflyError
try:
nf.send("t", "m")
except NotiflyError as exc:
if exc.is_rate_limited: # 429
time.sleep(exc.retry_after or 5)
elif exc.is_auth: # 401/403
raise SystemExit("check the token kind and its scope")
elif exc.is_quota_exceeded: # 402
raise SystemExit("the plan does not allow this")
else:
raise

Ещё есть IsNotFound / is_not_found (404) и StatusCode(err) в Go — сырой код, 0 для транспортной ошибки. Тело больше 1 МиБ в Go не режется, а возвращается как ErrResponseTooLarge: запросите страницу поменьше.

По умолчанию — до двух повторов. 429 повторяется всегда (запрос отклонён, а не выполнен), транспортные ошибки и ответы 502, 503, 504 — только для идемпотентных GET, поэтому отправка сообщения никогда не задваивается молча. 500 не повторяется. Паузы берутся из Retry-After, иначе — экспоненциальный backoff (0,5 с, 1 с, 2 с… не больше 5 с). Python не ждёт на одной паузе дольше 30 секунд (MAX_RETRY_WAIT), даже если Retry-After просит больше.

client := notifly.New(
notifly.WithChannelToken("A<channel-token>"),
notifly.WithAPIToken("M<api-token>"),
notifly.WithBaseURL("https://my-notifly.example.com"),
notifly.WithHTTPClient(&http.Client{Timeout: 5 * time.Second}),
notifly.WithUserAgent("my-deploy-bot/1.0"),
notifly.WithRetry(0), // disable retries
notifly.FromEnv(), // fill what is still empty from the environment
)
nf = Notifly(
"A<channel-token>",
base_url="https://my-notifly.example.com",
timeout=5.0,
retries=0,
user_agent="my-deploy-bot/1.0",
)

FromEnv() в Go читает NOTIFLY_API_TOKEN, NOTIFLY_CHANNEL_TOKEN и NOTIFLY_DEFAULT_BASE_URL, но только для полей, которые ещё пусты, — явная опция всегда сильнее. Чтение окружения намеренно не встроено в New: клиент не должен подхватывать чужой токен из шелла сам по себе. В Python переменных окружения нет — токен передаётся аргументом.

Ещё несколько настроек Go-клиента. WithLogger(*slog.Logger) включает диагностику, которая по умолчанию выброшена: на уровне Debug — строка на каждый запрос и жизненный цикл сокета, на Warn — повторы; токены маскируются, тела запросов и ответов не пишутся. WithWSURL задаёт адрес сокета, когда он живёт не на /ws хоста API (например, URL стейджа AWS API Gateway); без неё адрес выводится из базового: https → wss, путь /ws. Python-клиенту можно передать свой requests.Session аргументом session= — ради прокси, адаптеров или общего пула соединений.

Токен, который становится известен в рантайме (например, канал только что создан), подставляйте через Clone:

ch, _ := admin.CreateChannel(ctx, notifly.ChannelParams{Name: "deploys"})
admin.Clone(notifly.WithChannelToken(ch.Token)).Send(ctx, msg)

Настройки Go-клиента неэкспортируемые, поэтому Clone — единственный способ: присваивание client.ChannelToken = …, которое гналось бы с запросами в полёте и меняло учётку всем, кто делит этот клиент, просто не компилируется. Клон переиспользует пул соединений. Печать клиента маскирует оба токена (apiToken:M...bGh4), так что залогировать его целиком безопасно.

Для эндпоинта, у которого нет своего метода, в Go есть Client.Do(ctx, method, path, body, out, opts...) — тот же транспорт с повторами и разбором ошибок. По умолчанию он авторизуется API-токеном; AsChannel() переключает на токен канала, AsEither() берёт любой из двух (API-токен, если заданы оба).

Кроме отправки, вопросов, heartbeat’ов и мониторов оба SDK покрывают роутер вебхуков (правила, история событий и вызов ingest-URL, чтобы проверить правило, не дожидаясь настоящей интеграции), машинные ключи каналов и шары, настройки доставки с проверкой адаптера, источники метрик и пороговые алерты, статус-страницы (включая публичное чтение вообще без токена), почтовые ящики, трекинг ошибок сайта с загрузкой source maps из CI, а также текущего пользователя и разбор дневной квоты.

Приём вебхуков — тоже часть SDK: Go отдаёт готовые http.Handler в подпакете webhook, Python — функцию verify_signature. Подробности и подводные камни (реплей, ретраи, разные единицы времени в двух payload’ах) — на странице доставки.

Go-клиент дополнительно умеет Subscribe — поток сообщений и служебных событий канала по WebSocket, с keepalive и переподключением:

err := client.Subscribe(ctx, notifly.Handler{
OnMessage: func(ctx context.Context, m *notifly.SentMessage) {
fmt.Println(m.Title, m.Message)
},
OnEvent: func(ctx context.Context, e notifly.Event) {
// e.Kind: heartbeat:ping, heartbeat:alert, channel:status_changed, …
},
}, nil) // nil = default *SubscribeOptions; returns when ctx is done

Ему нужен токен канала или шары с правом чтения — API-токен сокет не принимает. Запасного опроса у Subscribe нет: если сокет недоступен, он возвращает ErrWebSocketUnavailable. В Python realtime нет, там только опрос.

Управление аккаунтом и админкой (пользователи, устройства, 2FA, sudo, смена тарифа, пополнение), сами API-токены, окна обслуживания и тихие часы, AI-ассистент, AI-подбор селектора для монитора контента и AI-сборка шагов многошагового HTTP и браузерного сценария, нативные приёмники POST /integrations/alertmanager и POST /integrations/zabbix.

AI-подсказки, которые SDK всё-таки оборачивают: правило роутера по записанному событию (SuggestRouterRule / suggest_router_rule), правило почтового ящика по письму (SuggestEmailInboxRule / suggest_email_inbox_rule) и разбор ошибки сайта (AnalyzeWebErrorIssue и GetWebErrorIssueAnalysis / analyze_web_error_issue и get_web_error_issue_analysis).

Два публичных эндпоинта не обёрнуты намеренно: форма анонимной подписки на статус-страницу (у неё нет админской стороны — управлять созданным нечем) и приёмник браузерного сниппета POST /script/{token}, который ждёт ровно ту форму батча, которую строит сам сниппет. Всё это доступно через REST API напрямую или через MCP-сервер, у которого поверхность шире.

В CI SDK не обязателен: сообщение и heartbeat-пинг — это по одному HTTP-запросу. Токен канала и ping-токен храните в секретах репозитория.

- name: Notify about failure
if: failure()
env:
NOTIFLY_TOKEN: ${{ secrets.NOTIFLY_APP_TOKEN }}
run: |
jq -n --arg t "CI failed: $GITHUB_REPOSITORY" \
--arg m "$GITHUB_WORKFLOW failed on $GITHUB_REF_NAME: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
'{title: $t, message: $m, priority: 8}' |
curl -sS --fail-with-body -X POST https://api.notifly.ru/message \
-H "X-Notifly-Key: $NOTIFLY_TOKEN" \
-H "Content-Type: application/json" -d @-
- name: Heartbeat "deploys are alive"
env:
NOTIFLY_HB_TOKEN: ${{ secrets.NOTIFLY_HEARTBEAT_TOKEN }}
run: curl -fsS -X POST "https://api.notifly.ru/heartbeat/ping/$NOTIFLY_HB_TOKEN"

В репозитории Notifly есть и composite-action github-action/ — bash + curl, без Docker. Входы: token (A… для режима message, H… для heartbeat), mode (message или heartbeat), message, title, priority (по умолчанию "5" и отправляется всегда, то есть перекрывает приоритет канала по умолчанию) и url (по умолчанию https://api.notifly.ru). Сетевые ошибки, 429 и 5xx повторяются, всего до трёх попыток; вопросов ask action не умеет. Сообщение он шлёт формой (application/x-www-form-urlencoded), а не JSON: если такой шаг падает с 400, отправляйте JSON, как в примере выше. И главное — репозиторий закрыт, поэтому uses: truboter/notifly.ru/github-action@main из чужого репозитория не разрешится; пользуйтесь шагами выше.

  • API-токены — выпуск ключа, права, скоуп и срок действия.
  • REST API — полный справочник эндпоинтов.
  • CLI-клиент — то же самое из терминала, без кода.
  • MCP-сервер — если вместо кода нужен AI-агент с инструментами.
  • Вопросы, Heartbeat, Мониторы — что именно автоматизируют эти вызовы.