PartyFlow

Guide: Bot Interactive Quickstart — от нуля до первой кнопки#

Linear onboarding для интерактивных компонентов. Цель — поднять approval-flow "Approve/Reject" за 5-10 минут. За полным справочником полей, флагов и visibility — идите в reference/interactive-components.md.


Что делаем#

  • Бот отправляет сообщение в канал с кнопками Approve / Reject.
  • В основном сценарии этого гайда PartyFlow шлёт HMAC-подписанный POST на callback URL бота, а бот отвечает sync JSON: обновляет сообщение или показывает ephemeral-текст.
  • Если у вас нет публичного HTTPS endpoint'а, включите event_delivery_mode="poll": клики придут через GET /api/v1/bot/events, а ответ отправляется в POST /api/v1/bot/interactions/{interaction_id}/respond.

В конце у вас будет рабочий bot + receiver, которые можно расширить под любой interactive flow.

Понадобятся: инстанс PartyFlow, bot token (fri_bot_...), Python 3.10+. Для callback mode нужен публичный HTTPS-endpoint (ngrok/cloudflared для локальной разработки подойдут); для poll mode публичный endpoint не нужен.


Шаг 1. Создать бота#

Если бота ещё нет — guides/create-bot.md, шаги 1-3. Токен сохраните в secrets manager. Бот должен быть добавлен в канал, в который будет слать сообщение.

export BASE='https://api.partyflow.ru'
export TOKEN='fri_bot_...'
export CHANNEL_ID='<uuid-канала>'

Шаг 2. Выбрать режим доставки#

Callback mode#

Используйте callback mode, если бот может принять публичный HTTPS POST.

Бот сообщает PartyFlow, куда слать click-события, через PATCH /api/v1/bot/interactive_config. В ответ PartyFlow выдаёт signing secret — показывается один раз, сохраните немедленно.

curl -X PATCH "$BASE/api/v1/bot/interactive_config" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"callback_url": "https://your-bot.example.com/partyflow/interactive"}'

Ответ при первой настройке:

{
  "ok": true,
  "callback_url": "https://your-bot.example.com/partyflow/interactive",
  "signing_secret": "<hex-secret>"
}
export BUTTONS_SECRET='<hex-secret>'

Заметки:

  • Этот secret — buttons_signing_secret, отличный от signing secret у обычных webhook'ов.
  • Если повторно вызвать PATCH — вернётся {"ok": true, "callback_url": "..."} без signing_secret. Используется прежний.
  • Для ротации: POST /api/v1/bot/interactive_config/rotate_secret — выдаёт новый, все активные кнопки инвалидируются.

Проверить текущий статус без секрета:

curl "$BASE/api/v1/bot/interactive_config" -H "Authorization: Bearer $TOKEN"
# {"ok": true, "enabled": true, "callback_url": "https://..."}

Poll mode#

Используйте poll mode, если бот запускается в dev/CI/on-prem и не имеет публичного callback URL:

curl -X PATCH "$BASE/api/v1/bot/me" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"event_delivery_mode": "poll"}'

В этом режиме пропустите HMAC receiver из шага 4. После клика читайте событие через:

curl "$BASE/api/v1/bot/events?cursor=&limit=100" \
  -H "Authorization: Bearer $TOKEN"

payload_json будет содержать canonical interactive payload плюс interaction_id; ответ отправляется отдельным запросом POST /api/v1/bot/interactions/{interaction_id}/respond. Полный контракт — reference/bot-event-poll.md.


Шаг 3. Отправить сообщение с кнопками#

Используем POST /api/v1/bot/messages с полем metadata_json. signed_token для каждой кнопки PartyFlow проставит автоматически — боту не нужно их вычислять.

METADATA=$(cat <<'JSON'
{
  "version": 1,
  "blocks": [
    {
      "type": "actions",
      "elements": [
        {
          "type": "button",
          "action_id": "approve",
          "text": "Approve",
          "style": "primary",
          "data": "{\"release\":\"v2.3.0\"}"
        },
        {
          "type": "button",
          "action_id": "reject",
          "text": "Reject",
          "style": "danger",
          "data": "{\"release\":\"v2.3.0\"}"
        }
      ]
    }
  ]
}
JSON
)
 
curl -X POST "$BASE/api/v1/bot/messages" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d "$(jq -n --arg cid "$CHANNEL_ID" --arg meta "$METADATA" '{
    conversation_id: $cid,
    content: "Release v2.3.0 готов к выкатке. Подтверди или отклони.",
    metadata_json: $meta
  }')"

В канале появится сообщение с двумя кнопками. Если выбрали callback mode, не кликайте пока — callback receiver ещё не поднят. В poll mode можно кликать сразу и смотреть событие в GET /api/v1/bot/events.


Шаг 4. Поднять receiver#

FastAPI-endpoint, который принимает click-события от PartyFlow. Три задачи:

  1. Проверить HMAC-подпись.
  2. Распарсить событие (button.click, select.submit, modal.submit).
  3. Вернуть sync JSON-ответ в пределах 3 секунд.
"""
PartyFlow interactive callback receiver.
"""
import hashlib
import hmac
import json
import os
import time
 
from fastapi import FastAPI, Header, HTTPException, Request
 
BUTTONS_SECRET = os.environ["BUTTONS_SECRET"].encode()
ALLOWED_USERS = set(os.environ.get("APPROVERS", "").split(","))  # optional ACL
 
app = FastAPI()
 
 
def verify_signature(secret: bytes, timestamp: str, body: bytes, signature: str) -> bool:
    # Anti-replay: reject requests older than 5 minutes.
    try:
        if abs(int(time.time()) - int(timestamp)) > 300:
            return False
    except ValueError:
        return False
    base = f"v1:{timestamp}:".encode() + body
    expected = "sha256=" + hmac.new(secret, base, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)
 
 
@app.post("/partyflow/interactive")
async def interactive(
    request: Request,
    x_partyflow_signature: str = Header(default=""),
    x_partyflow_timestamp: str = Header(default=""),
    x_partyflow_event: str = Header(default=""),
):
    body = await request.body()
 
    if not verify_signature(BUTTONS_SECRET, x_partyflow_timestamp, body, x_partyflow_signature):
        raise HTTPException(401, "invalid signature")
 
    event = json.loads(body)
    action_id = event.get("action_id")
    actor_user_id = event.get("actor_user_id")
 
    # button.click — approve/reject release.
    if x_partyflow_event == "button.click":
        # Optional: restrict by user list. If you set visibility_user_ids
        # in the button schema, PartyFlow already enforces this — but a
        # server-side check is still cheap insurance.
        if ALLOWED_USERS and actor_user_id not in ALLOWED_USERS:
            return {"ephemeral_text": "У вас нет прав для этого действия."}
 
        data = json.loads(event.get("data") or "{}")
        release = data.get("release", "?")
 
        if action_id == "approve":
            return {
                "update_message": {
                    "text": f":white_check_mark: Release {release} approved by <@{actor_user_id}>. Deploying…",
                    "metadata_json": "",  # пустая строка = убрать все интерактивные блоки
                }
            }
        if action_id == "reject":
            return {
                "update_message": {
                    "text": f":x: Release {release} rejected by <@{actor_user_id}>.",
                    "metadata_json": "",
                }
            }
 
    # Default — silent ack.
    return {"noop": True}

Запустить:

pip install fastapi uvicorn
export BUTTONS_SECRET='<тот-же-hex-secret-из-шага-2>'
uvicorn receiver:app --host 0.0.0.0 --port 8081

Для локальной разработки публичный URL можно получить через ngrok http 8081. Его же нужно прописать в callback_url (шаг 2).

Poll mode вместо receiver#

В poll mode бот получает тот же interactive payload внутри payload_json, но дополнительно с interaction_id:

{
  "event_type": "button.click",
  "payload_json": "{\"interaction_id\":\"int-1\",\"event_type\":\"button.click\",\"space_id\":\"sp-1\",\"actor_user_id\":\"u-1\",\"conversation_id\":\"conv-1\",\"message_id\":\"msg-1\",\"action_id\":\"approve\",\"data\":\"{\\\"release\\\":\\\"v2.3.0\\\"}\",\"occurred_at\":\"2026-04-18T10:00:00Z\"}"
}

После JSON.parse(payload_json) отправьте ответ:

curl -X POST "$BASE/api/v1/bot/interactions/$INTERACTION_ID/respond" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"update_message":{"text":"Release approved.","metadata_json":""}}'

Типы ответов#

Callback receiver или poll response endpoint принимает одно из пяти взаимоисключающих тел:

Ответ Эффект
{"update_message": {"text": "...", "metadata_json": "..."}} Обновить исходное сообщение. metadata_json: "" — убрать все блоки.
{"send_message": {"text": "...", "metadata_json": "..."}} Новое сообщение в канал.
{"ephemeral_text": "..."} Текст видит только нажавший, не сохраняется.
{"open_modal": {...}} Открыть форму. Submit придёт как modal.submit: callback POST с X-PartyFlow-Event или poll event с interaction_id.
{"noop": true} или пустое тело Ничего не показывать.

В callback mode лимит sync-ответа — 3 секунды. Если нужен долгий процессинг — верните {"ephemeral_text": "Запущено, подожди минутку"} сразу, а результат пришлите обратным POST /api/v1/bot/messages позже. В poll mode interaction_id живёт 15 минут по умолчанию, но лучше отвечать быстро, пока пользователь помнит действие.


Шаг 5 (бонус). Modal submit#

Если в ответ на клик вернули open_modal, пользователь увидит форму. Когда он submit'ит, PartyFlow шлёт отдельный запрос:

POST https://your-bot.example.com/partyflow/interactive
X-PartyFlow-Event: modal.submit

В poll mode modal.submit приходит через GET /api/v1/bot/events с interaction_id, как и button.click.

Body:

{
  "event_type": "modal.submit",
  "action_id": "deploy_config",
  "actor_user_id": "...",
  "space_id": "...",
  "conversation_id": "...",
  "message_id": "...",
  "values": {"reason": "Fix critical bug", "notify": true},
  "occurred_at": "..."
}

Обработчик в receiver:

if x_partyflow_event == "modal.submit":
    values = event.get("values", {})
    reason = values.get("reason", "")
    return {
        "send_message": {
            "text": f":rocket: Deploy started. Reason: {reason}",
            "metadata_json": "",
        }
    }

В callback mode open_modal в ответ на modal.submit не принимается: используйте send_message с новыми кнопками, если нужен многошаговый flow. В poll mode open_modal после modal.submit поддерживается через POST /api/v1/bot/interactions/{interaction_id}/respond; глубина цепочки ограничена настройкой инстанса.

Полная схема modal fields (text/textarea/select/checkbox) — reference/interactive-components.md → Modal submit flow.


Troubleshooting#

Симптом Причина Решение
В канале кнопки не появились Не выбран режим доставки interactive-событий, либо ошибка в metadata_json. Для callback mode проверьте GET /api/v1/bot/interactive_config"enabled": true. Для poll mode проверьте GET /api/v1/bot/me"event_delivery_mode": "poll". Проверьте JSON через jq . и лимиты (blocks ≤ 5, elements ≤ 5, action_id уникален).
400 invalid JSON при send metadata_json — не строка, а вложенный объект. Сериализуйте в строку: "metadata_json": "...", не "metadata_json": {...}.
Клик ничего не делает, receiver не вызывается В callback mode callback_url недоступен из публичного интернета или неправильный URL в config. В poll mode receiver не вызывается по дизайну. Для callback mode используйте ngrok и проверьте GET /api/v1/bot/interactive_config. Для poll mode читайте GET /api/v1/bot/events и отвечайте через POST /api/v1/bot/interactions/{interaction_id}/respond.
Poll возвращает events: [] после клика Бот не в event_delivery_mode="poll", используется не тот token, или вы передаёте cursor после нужного события. Проверьте GET /api/v1/bot/me, используйте тот же bot token, которым отправили сообщение, и временно запросите cursor=&limit=100.
Receiver получает 401 от собственной проверки подписи Неправильный secret, clock skew, JSON парсится перед проверкой (меняет bytes). Проверяйте подпись от raw await request.body() ДО json.loads. Проверьте, что BUTTONS_SECRET — это значение, полученное в шаге 2 (а не signing secret от обычных webhook'ов). Проверьте NTP (clock skew > 5 мин → отказ).
410 Gone при клике Сообщение удалено, или signed_token кнопки устарел (default TTL 24ч), или секрет был ротирован. Ожидаемое поведение — старые кнопки инвалидируются после rotate_secret. Отправьте сообщение заново.
429 Rate limited Пользователь жмёт кнопку слишком часто (30/мин per user × message) или бот в целом перегружен (600/мин). Покажите ephemeral_text "Подождите немного". Проверьте, не запустили ли user'ы retry-storm.
Receiver ответил, но сообщение не обновилось Receiver вернул 200, но JSON не распарсился, или таймаут > 3 сек. Проверьте, что Content-Type: application/json в ответе. Логируйте время ответа — если > 3 сек, выносите логику в фон и отвечайте быстрым ephemeral_text.
Кнопки видны не всем участникам Вы указали visibility_user_ids в schema. Либо это намеренно (approval только для approver'ов), либо уберите это поле для "видно всем".

Для отладки подписи callback mode локально — см. guides/verify-signatures.md (snippets на Python/Node/Go/Bash). Схема подписи у interactive та же, что у outgoing webhooks: v1:{timestamp}:{body}. В poll mode HMAC-подпись callback'а не используется, потому что события забираются Bearer-запросом.


Что дальше#

  • Select menus вместо кнопок — тот же type: "actions" block, но type: "select" element с options[]. Событие — X-PartyFlow-Event: select.submit, в body selected_value.
  • Restricted actions — поле visibility_user_ids в button/select element ограничивает, кто может кликать (1..100 user_ids). Enforce происходит на PartyFlow, receiver'у не нужно дублировать проверку.
  • Confirm dialogs — поле confirm: {title, text, confirm_label, cancel_label} на button. Пользователь увидит модалку подтверждения до отправки click.
  • Full referencereference/interactive-components.md для schema, styles, лимитов, error paths, best practices.
  • Poll deliveryreference/bot-event-poll.md для payload_json, interaction_id и next_cursor.

Связанное#