Вопросы в каналах (Ask)
Ask — это вопрос, который ваше приложение отправляет в канал и ждёт на него ответ от человека. Обычное сообщение летит в одну сторону; вопрос — в обе: приложение публикует «Деплоить в прод?», человек в админке или Android-приложении жмёт кнопку (или печатает текст), а приложение получает ответ обратно по тому же токену.
Удобно для:
- ручного подтверждения опасных операций (деплой, удаление, миграция) из CI/скрипта;
- HITL-сценариев (human-in-the-loop): агент/бот спрашивает человека и продолжает по ответу;
- простых опросов и сбора короткого ввода (выбрать окружение, ввести причину).
Вопрос — это обычное сообщение канала с доп-полем notifly::ask в
extras. Поэтому он отправляется App-токеном (A…)
того же канала, что и любые push-уведомления, и виден в ленте канала наравне
с ними.
Открытый и закрытый вопрос
Заголовок раздела «Открытый и закрытый вопрос»Тип вопроса определяется наличием поля answer_options:
| Тип | Когда | Как отвечают |
|---|---|---|
open | answer_options не задан | произвольный текст |
closed | answer_options — массив строк | выбор одного (или нескольких) вариантов-кнопок |
Для закрытого вопроса каждый элемент answer_options превращается в вариант
ответа {id, label} (id = label = строка варианта). Сервер при получении ответа
проверяет, что он совпадает с одним из вариантов (по id или label), иначе —
400.
Параметры вопроса
Заголовок раздела «Параметры вопроса»Все параметры передаются в теле POST /ask (настроек на уровне канала нет).
| Поле | Тип | Обяз. | Описание |
|---|---|---|---|
question | string | да | текст вопроса |
title | string | нет | заголовок сообщения (по умолчанию ❓) |
answer_options | string[] | нет | варианты ответа → делает вопрос закрытым |
default_option | string | нет | какой из answer_options выделить как вариант по умолчанию |
allow_multi | boolean | нет | разрешить выбор нескольких вариантов (только для закрытого вопроса) |
timeout_sec | number | нет | через сколько секунд вопрос авто-истечёт (0/отсутствует — бессрочно) |
default_answer | string | нет | ответ, подставляемый по таймауту |
json_format | boolean | нет | ответ обязан быть валидным JSON (для открытого вопроса) |
client_message_id | string | нет | ключ идемпотентности |
priority | number | нет | приоритет сообщения (по умолчанию — приоритет канала или 5) |
callback_url | string | нет | http(s)-URL: webhook при answered/expired/cancelled (см. ниже) |
callback_secret | string | нет | секрет для HMAC-подписи тела callback’а (X-Notifly-Signature) |
Несколько важных деталей:
allow_multiработает только для закрытого вопроса. Если человек прислал более одного ответа на открытый вопрос или на закрытый безallow_multi— сервер вернёт400. Несколько ответов склеиваются в строку через,.json_formatвалидируется на стороне сервера: ответ, не являющийся корректным JSON, отклоняется с400.client_message_idдаёт идемпотентность: повторныйPOST /askс тем жеclient_message_idв рамках канала не создаёт новый вопрос, а возвращает{"id": …}уже существующего. Удобно для ретраев из CI.
Ответ на POST /ask — это id вопроса:
{ "id": 12345678 }Статусы вопроса
Заголовок раздела «Статусы вопроса»Сам вопрос живёт в одном из четырёх статусов:
| Статус | Что значит |
|---|---|
pending | вопрос отправлен, ждём ответа |
answered | человек ответил; ответ лежит в поле answer |
expired | истёк timeout_sec до ответа |
cancelled | вопрос отменён администратором (DELETE /ask-question/:id) до ответа |
Отвечать (POST …/answer) и отменять (DELETE …) можно только пока вопрос в
статусе pending; иначе — 409 Conflict.
Отдельно фиксируется доставка: когда приложение фактически забрало ответ,
у вопроса проставляется отметка доставки, а в карточке сообщения (в админке)
статус меняется на delivered. Сам статус вопроса при этом остаётся
answered — GET /ask-question/:id и после доставки возвращает
"status":"answered".
Доставка ответа: WebSocket-сигнал + GET
Заголовок раздела «Доставка ответа: WebSocket-сигнал + GET»Ответ доставляется по модели notify-then-fetch: по WebSocket уходит лишь сигнал «ответ готов», а сам ответ забирается отдельным GET-запросом. Так сделано ради serverless-среды и больших ответов.
-
Откройте WebSocket с тем же токеном и параметром
?id=<id вопроса>:wss://notifly.ru/ws?token=A<appToken>&id=12345678 -
Когда появится ответ, на это соединение придёт сигнал (тело ответа по сокету не едет):
{"id":12345678,"status":"answered","fetch":true} -
По сигналу заберите ответ через
GET /ask-question/:id:Код Значение 200ответ готов: {"id":…,"answer":"…","status":"answered"}204ещё ждём (вопрос всё ещё pending)410вопрос expiredилиcancelledЭтот GET одновременно фиксирует доставку (карточка сообщения в админке переходит в
delivered).
Подробности протокола, подписка ?id= и действие get-pending —
на странице WebSocket-протокол.
Webhook-callback
Заголовок раздела «Webhook-callback»Третий способ узнать об ответе — попросить Notifly самому постучаться к вам:
передайте в POST /ask поле callback_url. Когда вопрос перейдёт в терминальный
статус (answered, expired или cancelled), на этот URL уйдёт POST c JSON:
{ "id": 12345678, "question": "Деплоить в прод?", "status": "answered", "answer": "Yes", "answeredAt": 1750684800, "ts": 1750684801}Если задан callback_secret, тело подписывается HMAC-SHA256, подпись — в
заголовке X-Notifly-Signature: sha256=<hex> (тот же формат, что у
generic-webhook каналов). Проверяйте её на своей стороне.
Важные ограничения:
- Доставка best-effort. Сетевые ошибки и
5xxретраятся несколько раз сразу же, но durable-очереди нет: если ваш приёмник был недоступен — повторной попытки позже не будет. Источник истины — всегдаGET /ask-question/:id; callback это ускоритель, а не гарантия. Дедуплицируйте по паре (id,status). - URL должен быть публичным
http(s): адреса приватных сетей (RFC1918, loopback, link-local) блокируются SSRF-защитой на момент доставки. - Медленный приёмник не задержит ответ пользователю дольше ~8 секунд — дальше доставка callback’а обрывается.
Approval gate в CI
Заголовок раздела «Approval gate в CI»Классический сценарий: пайплайн доходит до опасного шага и ждёт «да» от человека
в пуше/Telegram. Всё нужное уже есть: закрытый вопрос + timeout_sec +
default_answer (что считать, если никто не ответил) + короткий поллинг.
# notifly_gate "Deploy to prod?" Yes No# → печатает ответ; код возврата 0 — ответ получен, 1 — истёк/ошибка.# default_answer = второй вариант ($2 после shift), т.е. fail-closed.notifly_gate() { local question="$1"; shift local options; options=$(printf '"%s",' "$@"); options="[${options%,}]" local id id=$(curl -sf -X POST "$NOTIFLY_URL/ask" \ -H "X-Notifly-Key: $NOTIFLY_APP_TOKEN" \ -H "Content-Type: application/json" \ -d "{ \"title\": \"CI gate\", \"question\": \"$question\", \"answer_options\": $options, \"timeout_sec\": 600, \"default_answer\": \"$2\", \"client_message_id\": \"gate-$CI_PIPELINE_ID\" }" | jq -r .id) || return 1
while true; do local code code=$(curl -s -o /tmp/gate.json -w '%{http_code}' \ "$NOTIFLY_URL/ask-question/$id" \ -H "X-Notifly-Key: $NOTIFLY_APP_TOKEN") case "$code" in 200) jq -r .answer /tmp/gate.json; return 0 ;; 410) echo "expired/cancelled"; return 1 ;; 204) sleep 5 ;; *) echo "HTTP $code"; return 1 ;; esac done}
answer=$(notifly_gate "Deploy to prod?" Yes No) || exit 1[ "$answer" = "Yes" ] || { echo "деплой отклонён: $answer"; exit 1; }Разбор решений в этом рецепте:
timeout_sec+default_answer: "No"— если никто не ответил за 10 минут, гейт безопасно закрывается сам (fail-closed). Хотите fail-open — поставьтеdefault_answerравным подтверждающему варианту.client_message_idпривязан к id пайплайна: ретрай упавшего CI-шага не создаст второй вопрос, а продолжит ждать тот же.- Поллинг раз в 5 секунд — сервер отвечает мгновенно (
204— пусто), держать соединение не нужно. Человеческое «да» занимает минуты, так что этого более чем достаточно. - Вместо поллинга можно указать
callback_url(см. выше) — например, эндпоинтrepository_dispatchGitHub Actions — и продолжать пайплайн событием.
Тот же паттерн доступен AI-агентам через MCP-сервер: инструменты
ask_question / get_ask_answer(wait_sec) — human-in-the-loop без единой строчки
HTTP-кода.
Примеры (curl)
Заголовок раздела «Примеры (curl)»Закрытый вопрос с кнопками
Заголовок раздела «Закрытый вопрос с кнопками»# 1. Отправить вопросcurl -X POST "$NOTIFLY_URL/ask" \ -H "X-Notifly-Key: A<appToken>" \ -H "Content-Type: application/json" \ -d '{ "title": "Deploy decision", "question": "Деплоить в прод?", "answer_options": ["Yes", "No"], "default_option": "No", "allow_multi": false, "timeout_sec": 300, "default_answer": "No" }'# → { "id": 12345678 }
# 2. Когда по WS придёт {"id":12345678,"fetch":true} — забрать ответ:# 200 — ответ готов, 204 — ещё ждём, 410 — истёк/отменёнcurl "$NOTIFLY_URL/ask-question/12345678" \ -H "X-Notifly-Key: A<appToken>"# → { "id": 12345678, "answer": "Yes", "status": "answered" }Открытый вопрос с ответом в JSON
Заголовок раздела «Открытый вопрос с ответом в JSON»curl -X POST "$NOTIFLY_URL/ask" \ -H "X-Notifly-Key: A<appToken>" \ -H "Content-Type: application/json" \ -d '{ "title": "Config parameters", "question": "Введите параметры деплоя", "json_format": true }'Polling вместо WebSocket
Заголовок раздела «Polling вместо WebSocket»# Какие вопросы канала уже получили ответ, но ещё не забраныcurl "$NOTIFLY_URL/ask/pending-answers" \ -H "X-Notifly-Key: A<appToken>"# → [ { "id": 12345678, "fetch": true } ]
# Подтвердить доставку пачкой (проставит delivered)curl -X POST "$NOTIFLY_URL/ask/ack" \ -H "X-Notifly-Key: A<appToken>" \ -H "Content-Type: application/json" \ -d '{ "ids": [12345678] }'# → { "acknowledged": 1 }Ответ на вопрос (со стороны клиента)
Заголовок раздела «Ответ на вопрос (со стороны клиента)»Обычно на вопрос отвечают кнопкой в админке или в Android-приложении. Программно
ответ шлётся Client-токеном (C…) или Basic Auth. Поддерживается одиночный
ответ (answer) и мультивыбор (answers):
# Одиночный ответ по id вопросаcurl -X POST "$NOTIFLY_URL/ask-question/12345678/answer" \ -H "X-Notifly-Key: C<clientToken>" \ -H "Content-Type: application/json" \ -d '{ "answer": "Yes" }'# → { "status": "answered", "answer": "Yes" }
# Мультивыбор (только для закрытого вопроса с allow_multi)curl -X POST "$NOTIFLY_URL/ask-question/12345678/answer" \ -H "X-Notifly-Key: C<clientToken>" \ -H "Content-Type: application/json" \ -d '{ "answers": ["Yes", "No"] }'
# Ответ по id сообщения (если знаете message id, а не id вопроса)curl -X POST "$NOTIFLY_URL/message/9876543/answer" \ -H "X-Notifly-Key: C<clientToken>" \ -H "Content-Type: application/json" \ -d '{ "answer": "Yes" }'Extras notifly::ask
Заголовок раздела «Extras notifly::ask»Вопрос — это сообщение, у которого в extras лежит объект notifly::ask.
Именно по нему клиенты (админка, Android) понимают, что сообщение интерактивное,
и рисуют кнопки/поле ввода:
{ "notifly::ask": { "askQuestionId": 12345678, "askType": "closed", "status": "pending", "options": [ {"id": "Yes", "label": "Yes"}, {"id": "No", "label": "No", "default": true} ], "allowMulti": false, "expiresAt": 1750684800000 }}Поля allowMulti, options и expiresAt присутствуют только когда применимы
(options/allowMulti — для закрытого вопроса, expiresAt — при заданном
timeout_sec); для открытого вопроса с json_format добавляется jsonFormat: true.
Про работу с extras в целом — Дополнения сообщений.
REST API
Заголовок раздела «REST API»| Метод и путь | Авторизация | Назначение |
|---|---|---|
POST /ask | app-token | отправить вопрос в канал |
GET /ask/pending-answers | app-token | список вопросов с готовым, но не забранным ответом |
POST /ask/ack | app-token | подтвердить доставку ответов ({"ids":[…]}) |
GET /ask-question/:id | app-token | забрать ответ (200 / 204 / 410) |
POST /ask-question/:id/answer | client-token | ответить на вопрос |
DELETE /ask-question/:id | client-token | отменить ожидающий вопрос |
POST /message/:id/answer | client-token | ответить на вопрос по id сообщения |