Skip to content

Send Gradius dialogue banners as rich messages after the answer - #282

Merged
iamwavecut merged 1 commit into
mainfrom
feat/gradius-dialogue-banners
Oct 25, 2026
Merged

iamwavecut merged 1 commit into
mainfrom
feat/gradius-dialogue-banners

Conversation

@iamwavecut

@iamwavecut iamwavecut commented Oct 25, 2026 •

Copy link
Copy Markdown
Owner

Gradius now returns a banner placement from /v1/native/dialogue_model/chat: rich HTML for sendRichMessage and a list of URL buttons. The bot now sends it as a separate rich message right after the dialog answer. Text ads (native-text-ad) still go to the end of the answer as before.

Banner rendering

  • GradiusPlacement::Banner is decoded next to native-text-ad; the first supported entry wins. A flat inline_keyboard list becomes one button per row; nested rows are kept.
  • The provider HTML only passes the rich-message allowlist (sanitize_rich_html). The bot adds <footer>Реклама</footer> only when the banner has no footer. Banners get no 📢 label and no VIP spoiler, so the advertiser layout stays as sent.
  • Render errors (the opportunity is closed and no ad is counted): a method other than sendRichMessage, a message type other than html, empty content after sanitizing, a button that is empty or not HTTPS, or content over the rich-message limit.
  • The banner is sent with disable_notification, because the answer already notified the user.

Delivery and accounting

  • The banner goes to its own outbox batch gradius-banner:v1:{bot}:{job} (dialog_job_id NULL) with the answer's ordering key dialog:{bot}:{chat}:{thread}. The outbox claims one ordering key in FIFO order, so the banner is delivered after the answer, and a failed banner never fails the dialog job.
  • enqueue_dialogue_banner inserts the batch and sets delivery_state = 'queued' in one transaction. The banner is queued only after the answer was accepted; if the answer enqueue fails, the banner is marked failed and is not sent.
  • reconcile_dialogue_delivered, reconcile_dialogue_delivery_failed and the pending-slot fallback skip selected_placement->>'type' = 'banner'. Without this, delivery of the answer would mark a banner shown before it was sent. Banner batches resolve like utility batches (mark_delivered_by_batch / mark_delivery_failed_by_batch, plus the missed-callback scan).
  • A replayed opportunity rebuilds the banner from the stored placement and HTML. No schema change: banners use the existing columns (insert_index NULL).
  • Rich answers (tables etc.) still skip Gradius as unsupported_surface.

Verification

  • tools/rust-fast-gate.sh: cargo fmt --all -- --check, cargo clippy --locked --workspace --all-targets --all-features -- -D warnings, cargo test --locked --workspace: 2677 tests passed.
  • PostgreSQL tests on pgvector/pgvector:pg17: the new postgres_dialogue_banner_resolves_only_through_its_own_batch (answer reconciliation ignores the banner, atomic and idempotent enqueue, chat mismatch rejected, delivery by the banner batch) and the outbox/Gradius tests (outbox_roundtrip_is_idempotent_ordered_and_fenced, postgres_gradius_receipts_confirm_delivery_and_recover_missed_callbacks, the live_ storage tests) pass. postgres_store_holds_one_slot_until_ad_delivery_is_terminal fails locally: its own raw insert of a telegram_outbox row dated 1970 hits "no partition of relation telegram_outbox" (the table is partitioned by day since migration 206). This PR does not change that insert; CI does not run the test.
  • Live API check in Gradius test mode: a user turn returned []; an assistant turn containing &&&banner&&& returned one banner entry. That body is the LIVE_TEST_BANNER_RESPONSE fixture: the rendered HTML equals the provider HTML except <br> → <br/>, with three collage images, one footer and two button rows.
  • Session tests: the answer is sent unchanged and the banner is queued after it; the test marker reaches Gradius as &&&banner&&& (not HTML-escaped); a failed answer enqueue closes the banner without queueing it.
  • Not checked: how the banner renders in a real Telegram chat.

Gradius now returns a `banner` placement from the dialogue endpoint: rich
HTML for sendRichMessage plus URL buttons. The bot sends it as its own
message after the dialog answer instead of appending it to the answer.

- The client decodes `banner` next to `native-text-ad`; a flat button list
  becomes one button per row.
- The banner HTML passes the rich-message allowlist unchanged otherwise;
  a `<footer>Реклама</footer>` label is added only when it is missing.
  Buttons must be HTTPS, and other methods or message types are rejected
  as render errors.
- The banner goes to its own outbox batch `gradius-banner:v1:{bot}:{job}`
  with the answer's ordering key, so it is delivered after the answer and
  a failed banner never fails the dialog job. The batch and the queued
  state are written in one transaction.
- Answer-batch reconciliation skips banners, so a delivered answer cannot
  mark an unsent banner as shown; banner batches resolve like utility ads.
@github-actions

Copy link
Copy Markdown
Warnings
⚠️ Large PR (1358 changed lines). Consider splitting unless this is generated/mechanical.
Messages
📖 Danger checked 8 changed file(s).

Generated by 🚫 dangerJS against 1adbefa

@iamwavecut

Copy link
Copy Markdown
Owner Author

Danger "Large PR" warning: this is one feature and it is not split. About 560 of the added lines are code (decoder, rendering, outbox batch, reconciliation guards); about 740 are tests, including a captured live API response used as a fixture. Splitting the decoder from delivery would ship a placement type that the bot cannot send.

@iamwavecut
iamwavecut merged commit dfc9a9b into main Oct 25, 2026
12 checks passed
@iamwavecut
iamwavecut deleted the feat/gradius-dialogue-banners branch October 25, 2026 09:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant