From 1adbefaf87237116b6634a2b82cb9b8a55623743 Mon Sep 17 00:00:00 2001 From: Valeriy Selitskiy <239034+iamwavecut@users.noreply.github.com> Date: Sun, 25 Oct 2026 10:41:00 +0100 Subject: [PATCH] Send Gradius dialogue banners as rich messages after the answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` 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. --- .../openplotva-app/src/dialog_jobs/effects.rs | 25 +- .../openplotva-app/src/dialog_jobs/tests.rs | 165 ++++- .../openplotva-app/src/dialog_turn/session.rs | 67 +- crates/openplotva-app/src/gradius_ads.rs | 637 +++++++++++++++++- crates/openplotva-app/src/lib.rs | 8 +- crates/openplotva-app/src/telegram_outbox.rs | 12 +- crates/openplotva-llm/src/gradius.rs | 210 +++++- crates/openplotva-storage/src/gradius_ads.rs | 234 ++++++- 8 files changed, 1298 insertions(+), 60 deletions(-) diff --git a/crates/openplotva-app/src/dialog_jobs/effects.rs b/crates/openplotva-app/src/dialog_jobs/effects.rs index 5b7a5dde..5bf42a61 100644 --- a/crates/openplotva-app/src/dialog_jobs/effects.rs +++ b/crates/openplotva-app/src/dialog_jobs/effects.rs @@ -570,11 +570,7 @@ fn dialog_intermediate_outbox_batch( bot_id, chat_id: Some(params.chat_id), thread_id: params.thread_id, - ordering_key: format!( - "dialog:{bot_id}:{}:{}", - params.chat_id, - params.thread_id.unwrap_or_default() - ), + ordering_key: dialog_outbox_ordering_key(bot_id, params.chat_id, params.thread_id), causation_update_id, dialog_job_id: Some(dialog_job_id), trigger_message_id: Some(i64::from(params.message_id)), @@ -585,6 +581,18 @@ fn dialog_intermediate_outbox_batch( } } +/// Per-chat delivery order shared by dialog messages and the banners queued after them. +pub(crate) fn dialog_outbox_ordering_key( + bot_id: i64, + chat_id: i64, + thread_id: Option, +) -> String { + format!( + "dialog:{bot_id}:{chat_id}:{}", + thread_id.unwrap_or_default() + ) +} + fn dialog_answer_batch_id(bot_id: i64, dialog_job_id: i64) -> String { format!("dialog-answer:v1:{bot_id}:{dialog_job_id}") } @@ -658,12 +666,7 @@ async fn enqueue_durable_dialog_answer( bot_id: outbox.bot_id, chat_id: Some(params.chat_id), thread_id: params.thread_id, - ordering_key: format!( - "dialog:{}:{}:{}", - outbox.bot_id, - params.chat_id, - params.thread_id.unwrap_or_default() - ), + ordering_key: dialog_outbox_ordering_key(outbox.bot_id, params.chat_id, params.thread_id), causation_update_id, dialog_job_id: Some(dialog_job_id), trigger_message_id: Some(i64::from(params.message_id)), diff --git a/crates/openplotva-app/src/dialog_jobs/tests.rs b/crates/openplotva-app/src/dialog_jobs/tests.rs index b77ce364..56715f8b 100644 --- a/crates/openplotva-app/src/dialog_jobs/tests.rs +++ b/crates/openplotva-app/src/dialog_jobs/tests.rs @@ -4091,6 +4091,8 @@ fn session_options<'a>( #[derive(Clone, Default)] struct GradiusAppenderStub { + banner: Option, + queued_banners: Arc>>, requests: Arc>>, render_errors: Arc>>, queued: Arc>>, @@ -4104,17 +4106,38 @@ impl crate::gradius_ads::GradiusAdAppender for GradiusAppenderStub { fn append<'a>( &'a self, request: crate::gradius_ads::GradiusAdAppendRequest, - ) -> crate::gradius_ads::GradiusServiceFuture<'a, Option> + ) -> crate::gradius_ads::GradiusServiceFuture<'a, Option> { Box::pin(async move { self.requests .lock() .expect("Gradius requests") .push(request); - Ok(Some(crate::gradius_ads::GradiusAdTail { - opportunity_id: 91, - html: TEST_GRADIUS_TAIL.to_owned(), - })) + if let Some(banner) = &self.banner { + return Ok(Some(crate::gradius_ads::GradiusPreparedAd::Banner( + banner.clone(), + ))); + } + Ok(Some(crate::gradius_ads::GradiusPreparedAd::Tail( + crate::gradius_ads::GradiusAdTail { + opportunity_id: 91, + html: TEST_GRADIUS_TAIL.to_owned(), + }, + ))) + }) + } + + fn queue_banner<'a>( + &'a self, + banner: &'a crate::gradius_ads::GradiusAdBanner, + target: crate::gradius_ads::GradiusBannerTarget, + ) -> crate::gradius_ads::GradiusServiceFuture<'a, String> { + Box::pin(async move { + self.queued_banners + .lock() + .expect("Gradius queued banners") + .push(target); + Ok(format!("gradius-banner:v1:test:{}", banner.opportunity_id)) }) } @@ -4282,6 +4305,138 @@ async fn session_marks_prepared_gradius_ad_failed_when_answer_enqueue_fails() Ok(()) } +fn gradius_banner_appender() -> GradiusAppenderStub { + GradiusAppenderStub { + banner: Some(crate::gradius_ads::GradiusAdBanner { + opportunity_id: 92, + html: "

Баннер

Реклама
".to_owned(), + keyboard: Vec::new(), + }), + ..GradiusAppenderStub::default() + } +} + +#[tokio::test] +async fn session_sends_answer_unchanged_and_queues_gradius_banner_after_it() +-> Result<(), Box> { + let now = OffsetDateTime::from_unix_timestamp(1_779_193_800)?; + let queue = InMemoryTaskQueue::new(); + let job_id = queue.assign( + DIALOG_AIFARM_QUEUE_NAME, + new_dialog_job_at(dialog_params("расскажи шутку"), now), + ); + let provider = StepProviderStub::with_steps(vec![Ok(step_text("Базовый ответ &&&banner&&&"))]); + let appender = gradius_banner_appender(); + let toolbox: Arc = + Arc::new(SessionToolboxStub::default()); + let mut wiring = session_wiring(toolbox, None); + wiring.gradius = Some(Arc::new(appender.clone())); + let effects = EffectsStub::default(); + let outcomes = crate::dialog_turn::DialogTurnObserver::new( + crate::dialog_turn::RuntimeTurnOutcomeBuffer::new(8), + None, + ); + + let report = process_dialog_job_once_in_queue_with_materializer_history_and_retry_at( + &queue, + &provider, + &effects, + &BasicDialogInputMaterializer, + &NoopDialogToolCallHistoryStore, + session_options(now, &outcomes, &wiring), + ) + .await; + + assert!(report.sent_answer, "{report:?}"); + let sent = effects.sent(); + assert_eq!(sent.len(), 1); + assert!( + sent[0].1.starts_with("Базовый ответ &&&banner"), + "{}", + sent[0].1 + ); + assert!(!sent[0].1.contains("Реклама")); + assert_eq!( + effects.answer_options(), + vec![DialogAnswerSendOptions::default()] + ); + assert_eq!( + appender.requests.lock().expect("Gradius requests")[0].assistant_text, + "Базовый ответ &&&banner&&&" + ); + assert_eq!( + appender + .queued_banners + .lock() + .expect("Gradius queued banners") + .as_slice(), + &[crate::gradius_ads::GradiusBannerTarget { + dialog_job_id: job_id, + chat_id: dialog_params("").chat_id, + thread_id: dialog_params("").thread_id, + }] + ); + assert!(appender.queued.lock().expect("Gradius queued").is_empty()); + assert!( + appender + .failed_delivery_opportunities + .lock() + .expect("Gradius failed delivery opportunities") + .is_empty() + ); + Ok(()) +} + +#[tokio::test] +async fn session_closes_gradius_banner_without_queueing_when_answer_enqueue_fails() +-> Result<(), Box> { + let now = OffsetDateTime::from_unix_timestamp(1_779_193_800)?; + let queue = InMemoryTaskQueue::new(); + queue.assign( + DIALOG_AIFARM_QUEUE_NAME, + new_dialog_job_at(dialog_params("покажи рекламу"), now), + ); + let provider = StepProviderStub::with_steps(vec![Ok(step_text("Базовый ответ"))]); + let appender = gradius_banner_appender(); + let toolbox: Arc = + Arc::new(SessionToolboxStub::default()); + let mut wiring = session_wiring(toolbox, None); + wiring.gradius = Some(Arc::new(appender.clone())); + let effects = EffectsStub::failing(); + let outcomes = crate::dialog_turn::DialogTurnObserver::new( + crate::dialog_turn::RuntimeTurnOutcomeBuffer::new(8), + None, + ); + + let report = process_dialog_job_once_in_queue_with_materializer_history_and_retry_at( + &queue, + &provider, + &effects, + &BasicDialogInputMaterializer, + &NoopDialogToolCallHistoryStore, + session_options(now, &outcomes, &wiring), + ) + .await; + + assert_eq!(report.send_error.as_deref(), Some("send down")); + assert!( + appender + .queued_banners + .lock() + .expect("Gradius queued banners") + .is_empty() + ); + assert_eq!( + appender + .failed_delivery_opportunities + .lock() + .expect("Gradius failed delivery opportunities") + .as_slice(), + &[(92, "send down".to_owned())] + ); + Ok(()) +} + #[tokio::test] async fn session_runs_tool_round_then_final_text() -> Result<(), Box> { let now = OffsetDateTime::from_unix_timestamp(1_779_193_800)?; diff --git a/crates/openplotva-app/src/dialog_turn/session.rs b/crates/openplotva-app/src/dialog_turn/session.rs index 978edf90..1f642d50 100644 --- a/crates/openplotva-app/src/dialog_turn/session.rs +++ b/crates/openplotva-app/src/dialog_turn/session.rs @@ -1078,6 +1078,7 @@ where let mut contains_advertising = false; let mut advertising_tail_bytes = None; let mut advertising_opportunity_id = None; + let mut advertising_banner = None; if let Some(gradius) = cfg.gradius { let request = crate::gradius_ads::GradiusAdAppendRequest { dialog_job_id: ctx.item_id, @@ -1106,7 +1107,10 @@ where } } else { match gradius.append(request).await { - Ok(Some(tail)) => { + Ok(Some(crate::gradius_ads::GradiusPreparedAd::Banner(banner))) => { + advertising_banner = Some(banner); + } + Ok(Some(crate::gradius_ads::GradiusPreparedAd::Tail(tail))) => { let tail_bytes = tail.html.len(); let candidate = format!("{sanitized}\n\n{}", tail.html); if let Err(error) = validate_dialog_answer_deliverable(&candidate) { @@ -1189,6 +1193,19 @@ where ); } } + if let (Some(banner), Some(gradius)) = (advertising_banner.as_ref(), cfg.gradius) { + queue_gradius_banner( + gradius, + banner, + send_result.as_ref().map(|_| ()).map_err(ToString::to_string), + crate::gradius_ads::GradiusBannerTarget { + dialog_job_id: ctx.item_id, + chat_id: active_params.chat_id, + thread_id: active_params.thread_id, + }, + ) + .await; + } if let (Err(send_error), Some(opportunity_id), Some(gradius)) = ( send_result.as_ref(), advertising_opportunity_id, @@ -2342,6 +2359,54 @@ where } } +/// Queue the banner only after the answer was accepted, so it follows the answer +/// in the chat's outbox order; a failed answer closes the banner without sending it. +async fn queue_gradius_banner( + gradius: &dyn crate::gradius_ads::GradiusAdAppender, + banner: &crate::gradius_ads::GradiusAdBanner, + answer: Result<(), String>, + target: crate::gradius_ads::GradiusBannerTarget, +) { + let opportunity_id = banner.opportunity_id; + match answer { + Ok(()) => match gradius.queue_banner(banner, target).await { + Ok(batch_id) => tracing::info!( + opportunity_id, + job_id = target.dialog_job_id, + integration_kind = "native_dialogue", + placement = "banner", + delivery_state = "queued", + batch_id, + "Gradius banner queued after the dialog answer" + ), + Err(error) => tracing::warn!( + opportunity_id, + job_id = target.dialog_job_id, + integration_kind = "native_dialogue", + placement = "banner", + %error, + "failed to queue Gradius banner" + ), + }, + Err(send_error) => { + if let Err(error) = gradius + .mark_delivery_failed(opportunity_id, &send_error) + .await + { + tracing::warn!( + opportunity_id, + job_id = target.dialog_job_id, + integration_kind = "native_dialogue", + placement = "banner", + delivery_state = "failed", + %error, + "failed to persist Gradius banner enqueue failure" + ); + } + } + } +} + async fn append_session_queued_marker( queue: &Queue, job_id: i64, diff --git a/crates/openplotva-app/src/gradius_ads.rs b/crates/openplotva-app/src/gradius_ads.rs index d5afc5a3..d0a69bb1 100644 --- a/crates/openplotva-app/src/gradius_ads.rs +++ b/crates/openplotva-app/src/gradius_ads.rs @@ -2,17 +2,22 @@ use std::{future::Future, pin::Pin, sync::Arc}; use openplotva_llm::gradius::vip_hint_for_impression; use openplotva_llm::gradius::{ - GradiusApiExchange, GradiusClient, GradiusDialogueRole, GradiusDialogueTurn, - GradiusGenerationRequest, GradiusIntegrationKind, GradiusPlacement, GradiusPrivacyRedactor, - GradiusSyntheticIds, GradiusUtilityRequest, ReqwestGradiusTransport, + GradiusApiExchange, GradiusBannerAd, GradiusBannerButton, GradiusClient, GradiusDialogueRole, + GradiusDialogueTurn, GradiusGenerationRequest, GradiusIntegrationKind, GradiusPlacement, + GradiusPrivacyRedactor, GradiusSyntheticIds, GradiusUtilityRequest, ReqwestGradiusTransport, }; use openplotva_storage::gradius_ads::{ GradiusAdOpportunityInput, GradiusAdReservation, GradiusApiCallRecord, GradiusStoredAd, PostgresGradiusAdStore, }; +use openplotva_storage::{ + TelegramDeliveryPolicy, TelegramOutboxBatchInput, TelegramOutboxPartInput, +}; use openplotva_telegram::{ - TELEGRAM_TEXT_MAX_BYTES, escape_telegram_html_text, is_valid_telegram_html, - telegram_html_from_markdown, + ChatRef, OutboundCommand, ReplyMarkup, RichMessageRequest, TELEGRAM_TEXT_MAX_BYTES, + TelegramOutboundMethod, build_inline_keyboard_button_url, build_inline_keyboard_markup, + build_rich_message_method, escape_telegram_html_text, format_rich_html, is_valid_telegram_html, + rich_message_within_char_limit, sanitize_rich_html, telegram_html_from_markdown, }; use serde_json::Value; use serde_json::json; @@ -21,6 +26,9 @@ use time::OffsetDateTime; const VIP_URL: &str = "https://t.me/PlotvoBot?start=vip"; const LINKED_VIP: &str = "VIP"; const GRADIUS_AD_LABEL: &str = "📢 "; +const GRADIUS_BANNER_FOOTER: &str = "
Реклама
"; +const GRADIUS_BANNER_METHOD: &str = "sendRichMessage"; +pub const GRADIUS_BANNER_BATCH_PREFIX: &str = "gradius-banner:v1:"; pub type GradiusServiceFuture<'a, T> = Pin> + Send + 'a>>; @@ -200,6 +208,12 @@ pub trait GradiusAdLedger: Send + Sync { opportunity_id: i64, error: &'a str, ) -> GradiusServiceFuture<'a, ()>; + + fn enqueue_banner<'a>( + &'a self, + opportunity_id: i64, + batch: TelegramOutboxBatchInput, + ) -> GradiusServiceFuture<'a, ()>; } impl GradiusAdLedger for PostgresGradiusAdStore { @@ -403,6 +417,18 @@ impl GradiusAdLedger for PostgresGradiusAdStore { .map_err(|store_error| store_error.to_string()) }) } + + fn enqueue_banner<'a>( + &'a self, + opportunity_id: i64, + batch: TelegramOutboxBatchInput, + ) -> GradiusServiceFuture<'a, ()> { + Box::pin(async move { + PostgresGradiusAdStore::enqueue_dialogue_banner(self, opportunity_id, &batch) + .await + .map_err(|error| error.to_string()) + }) + } } pub trait GradiusVipChecker: Send + Sync { @@ -448,6 +474,29 @@ pub struct GradiusAdTail { pub html: String, } +/// Rich banner sent as its own message after the dialogue answer. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GradiusAdBanner { + pub opportunity_id: i64, + pub html: String, + pub keyboard: Vec>, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum GradiusPreparedAd { + /// Appended to the final answer text. + Tail(GradiusAdTail), + /// Queued after the final answer in the same chat order. + Banner(GradiusAdBanner), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct GradiusBannerTarget { + pub dialog_job_id: i64, + pub chat_id: i64, + pub thread_id: Option, +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum GradiusUtilitySurface { Image, @@ -851,7 +900,14 @@ pub trait GradiusAdAppender: Send + Sync { fn append<'a>( &'a self, request: GradiusAdAppendRequest, - ) -> GradiusServiceFuture<'a, Option>; + ) -> GradiusServiceFuture<'a, Option>; + + /// Queue a prepared banner and return its outbox batch id. + fn queue_banner<'a>( + &'a self, + banner: &'a GradiusAdBanner, + target: GradiusBannerTarget, + ) -> GradiusServiceFuture<'a, String>; fn record_unsupported_surface<'a>( &'a self, @@ -889,6 +945,7 @@ pub struct GradiusAdService { redactor: Arc, ledger: Arc, vip: Arc, + banner_bot_id: Option, } impl GradiusAdService { @@ -904,13 +961,21 @@ impl GradiusAdService { redactor, ledger, vip, + banner_bot_id: None, } } + /// Enable banner placements, which are queued in the bot's dialogue chat order. + #[must_use] + pub fn with_banner_delivery(mut self, bot_id: i64) -> Self { + self.banner_bot_id = Some(bot_id); + self + } + pub async fn append( &self, request: GradiusAdAppendRequest, - ) -> Result, String> { + ) -> Result, String> { if request.chat_id <= 0 || self .vip @@ -941,10 +1006,7 @@ impl GradiusAdService { let opportunity_id = reservation.opportunity_id(); let attempt_generation = match reservation { GradiusAdReservation::Replay { ad, .. } => { - return Ok(Some(GradiusAdTail { - opportunity_id, - html: ad.rendered_html, - })); + return replayed_ad(opportunity_id, ad).map(Some); } GradiusAdReservation::Reserved { attempt_generation, .. @@ -959,6 +1021,12 @@ impl GradiusAdService { .await; let ad = match result { Ok(Some(GradiusPlacement::NativeDialogue(ad))) => ad, + Ok(Some(GradiusPlacement::Banner(banner))) => { + return self + .prepare_banner(opportunity_id, attempt_generation, &request, banner) + .await + .map(Some); + } Ok(Some(_)) => { let error = "Gradius returned an unsupported placement for native_dialogue"; let _ = self @@ -1027,7 +1095,95 @@ impl GradiusAdService { delivery_state = "prepared", "Gradius advertising opportunity prepared" ); - Ok(Some(tail)) + Ok(Some(GradiusPreparedAd::Tail(tail))) + } + + async fn prepare_banner( + &self, + opportunity_id: i64, + attempt_generation: i32, + request: &GradiusAdAppendRequest, + banner: GradiusBannerAd, + ) -> Result { + let rendered = if self.banner_bot_id.is_some() { + render_banner_html(&banner) + } else { + Err("Gradius banner delivery is not configured".to_owned()) + }; + let html = match rendered { + Ok(html) => html, + Err(error) => { + let _ = self + .ledger + .finish_render_error(opportunity_id, attempt_generation, &error) + .await; + return Err(error); + } + }; + self.ledger + .finish_ad( + opportunity_id, + attempt_generation, + GradiusStoredAd { + markdown: banner.text.clone(), + rendered_html: html.clone(), + selected_placement: banner.to_placement_json(), + insert_index: None, + show_price: banner.show_price, + click_price: banner.click_price, + prepared_at: request.completed_at, + shown_at: None, + }, + ) + .await?; + tracing::info!( + opportunity_id, + job_id = request.dialog_job_id, + integration_kind = GradiusIntegrationKind::NativeDialogue.as_str(), + placement = "banner", + outcome = "ad", + delivery_state = "prepared", + "Gradius advertising opportunity prepared" + ); + Ok(GradiusPreparedAd::Banner(GradiusAdBanner { + opportunity_id, + html, + keyboard: banner.keyboard, + })) + } + + pub async fn queue_banner( + &self, + banner: &GradiusAdBanner, + target: GradiusBannerTarget, + ) -> Result { + let batch = self + .banner_bot_id + .ok_or_else(|| "Gradius banner delivery is not configured".to_owned()) + .and_then(|bot_id| banner_outbox_batch(bot_id, banner, target)); + let batch = match batch { + Ok(batch) => batch, + Err(error) => { + let _ = self + .ledger + .mark_delivery_failed(banner.opportunity_id, "outbox_build_failed") + .await; + return Err(error); + } + }; + let batch_id = batch.batch_id.clone(); + if let Err(error) = self + .ledger + .enqueue_banner(banner.opportunity_id, batch) + .await + { + let _ = self + .ledger + .mark_delivery_failed(banner.opportunity_id, "outbox_enqueue_failed") + .await; + return Err(error); + } + Ok(batch_id) } pub async fn record_unsupported_surface( @@ -1180,6 +1336,7 @@ impl GradiusAdService { GradiusPlacement::NativeDialogue(ad) => { ad.insert_index != assistant_text.chars().count() } + GradiusPlacement::Banner(_) => false, GradiusPlacement::Standalone { .. } => true, }) { let error = "Gradius returned a non-terminal or unsupported placement"; @@ -1246,10 +1403,18 @@ impl GradiusAdAppender for GradiusAdService { fn append<'a>( &'a self, request: GradiusAdAppendRequest, - ) -> GradiusServiceFuture<'a, Option> { + ) -> GradiusServiceFuture<'a, Option> { Box::pin(async move { GradiusAdService::append(self, request).await }) } + fn queue_banner<'a>( + &'a self, + banner: &'a GradiusAdBanner, + target: GradiusBannerTarget, + ) -> GradiusServiceFuture<'a, String> { + Box::pin(async move { GradiusAdService::queue_banner(self, banner, target).await }) + } + fn record_unsupported_surface<'a>( &'a self, request: GradiusAdAppendRequest, @@ -1290,6 +1455,123 @@ impl GradiusAdAppender for GradiusAdService { } } +fn replayed_ad(opportunity_id: i64, ad: GradiusStoredAd) -> Result { + if ad.selected_placement.get("type").and_then(Value::as_str) != Some("banner") { + return Ok(GradiusPreparedAd::Tail(GradiusAdTail { + opportunity_id, + html: ad.rendered_html, + })); + } + let banner = GradiusBannerAd::from_placement_json(ad.selected_placement) + .map_err(|error| format!("saved Gradius banner placement is invalid: {error}"))?; + Ok(GradiusPreparedAd::Banner(GradiusAdBanner { + opportunity_id, + html: ad.rendered_html, + keyboard: banner.keyboard, + })) +} + +/// Keep the provider banner markup except for the rich-HTML allowlist and a fallback +/// advertising footer. +fn render_banner_html(banner: &GradiusBannerAd) -> Result { + if banner.method != GRADIUS_BANNER_METHOD { + return Err(format!( + "Gradius banner method {:?} is not supported", + banner.method + )); + } + if !banner.message_type.eq_ignore_ascii_case("html") { + return Err(format!( + "Gradius banner message type {:?} is not supported", + banner.message_type + )); + } + for button in banner.keyboard.iter().flatten() { + if button.text.trim().is_empty() { + return Err("Gradius banner button text is empty".to_owned()); + } + let url = reqwest::Url::parse(button.url.trim()) + .map_err(|_| "Gradius banner button URL is invalid".to_owned())?; + if url.scheme() != "https" { + return Err("Gradius banner button URL is not HTTPS".to_owned()); + } + } + let mut html = sanitize_rich_html(&banner.text); + if html.is_empty() { + return Err("Gradius banner has no rich content".to_owned()); + } + if !html.contains("