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

Вопросы в каналах (Ask)

Ask — это вопрос, который ваше приложение отправляет в канал и ждёт на него ответ от человека. Обычное сообщение летит в одну сторону; вопрос — в обе: приложение публикует «Деплоить в прод?», человек в админке или Android-приложении жмёт кнопку (или печатает текст), а приложение получает ответ обратно по тому же токену.

Удобно для:

  • ручного подтверждения опасных операций (деплой, удаление, миграция) из CI/скрипта;
  • HITL-сценариев (human-in-the-loop): агент/бот спрашивает человека и продолжает по ответу;
  • простых опросов и сбора короткого ввода (выбрать окружение, ввести причину).

Вопрос — это обычное сообщение канала с доп-полем notifly::ask в extras. Поэтому он отправляется App-токеном (A…) того же канала, что и любые push-уведомления, и виден в ленте канала наравне с ними.

Тип вопроса определяется наличием поля answer_options:

ТипКогдаКак отвечают
openanswer_options не заданпроизвольный текст
closedanswer_options — массив строквыбор одного (или нескольких) вариантов-кнопок

Для закрытого вопроса каждый элемент answer_options превращается в вариант ответа {id, label} (id = label = строка варианта). Сервер при получении ответа проверяет, что он совпадает с одним из вариантов (по id или label), иначе — 400.

Все параметры передаются в теле POST /ask (настроек на уровне канала нет).

ПолеТипОбяз.Описание
questionstringдатекст вопроса
titlestringнетзаголовок сообщения (по умолчанию )
answer_optionsstring[]нетварианты ответа → делает вопрос закрытым
default_optionstringнеткакой из answer_options выделить как вариант по умолчанию
allow_multibooleanнетразрешить выбор нескольких вариантов (только для закрытого вопроса)
timeout_secnumberнетчерез сколько секунд вопрос авто-истечёт (0/отсутствует — бессрочно)
default_answerstringнетответ, подставляемый по таймауту
json_formatbooleanнетответ обязан быть валидным JSON (для открытого вопроса)
client_message_idstringнетключ идемпотентности
prioritynumberнетприоритет сообщения (по умолчанию — приоритет канала или 5)
callback_urlstringнетhttp(s)-URL: webhook при answered/expired/cancelled (см. ниже)
callback_secretstringнетсекрет для 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. Сам статус вопроса при этом остаётся answeredGET /ask-question/:id и после доставки возвращает "status":"answered".

Ответ доставляется по модели notify-then-fetch: по WebSocket уходит лишь сигнал «ответ готов», а сам ответ забирается отдельным GET-запросом. Так сделано ради serverless-среды и больших ответов.

  1. Откройте WebSocket с тем же токеном и параметром ?id=<id вопроса>:

    wss://notifly.ru/ws?token=A<appToken>&id=12345678
  2. Когда появится ответ, на это соединение придёт сигнал (тело ответа по сокету не едет):

    {"id":12345678,"status":"answered","fetch":true}
  3. По сигналу заберите ответ через GET /ask-question/:id:

    КодЗначение
    200ответ готов: {"id":…,"answer":"…","status":"answered"}
    204ещё ждём (вопрос всё ещё pending)
    410вопрос expired или cancelled

    Этот GET одновременно фиксирует доставку (карточка сообщения в админке переходит в delivered).

Подробности протокола, подписка ?id= и действие get-pending — на странице WebSocket-протокол.

Третий способ узнать об ответе — попросить 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’а обрывается.

Классический сценарий: пайплайн доходит до опасного шага и ждёт «да» от человека в пуше/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_dispatch GitHub Actions — и продолжать пайплайн событием.

Тот же паттерн доступен AI-агентам через MCP-сервер: инструменты ask_question / get_ask_answer(wait_sec) — human-in-the-loop без единой строчки HTTP-кода.

Окно терминала
# 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" }
Окно терминала
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
}'
Окно терминала
# Какие вопросы канала уже получили ответ, но ещё не забраны
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. Именно по нему клиенты (админка, 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 в целом — Дополнения сообщений.

Метод и путьАвторизацияНазначение
POST /askapp-tokenотправить вопрос в канал
GET /ask/pending-answersapp-tokenсписок вопросов с готовым, но не забранным ответом
POST /ask/ackapp-tokenподтвердить доставку ответов ({"ids":[…]})
GET /ask-question/:idapp-tokenзабрать ответ (200 / 204 / 410)
POST /ask-question/:id/answerclient-tokenответить на вопрос
DELETE /ask-question/:idclient-tokenотменить ожидающий вопрос
POST /message/:id/answerclient-tokenответить на вопрос по id сообщения