,
+ ) -> GradiusAdService {
+ let service = GradiusAdService::new(
+ Arc::new(dialogue.clone()),
+ Arc::new(RedactorStub::default()),
+ Arc::new(ledger.clone()),
+ Arc::new(VipStub(false)),
+ );
+ match bot_id {
+ Some(bot_id) => service.with_banner_delivery(bot_id),
+ None => service,
+ }
+ }
+
+ #[tokio::test]
+ async fn service_prepares_banner_and_queues_it_after_the_answer_in_chat_order() {
+ let dialogue = DialogueStub::default();
+ {
+ let mut responses = dialogue.responses.lock().expect("dialogue responses");
+ responses.push_back(Ok(None));
+ responses.push_back(Ok(Some(GradiusPlacement::Banner(test_banner(
+ "Это тестовый баннер Gradius
\
+ Тестовая реклама
Тестовый баннер.
\
+
\
+
\
+ ",
+ "https://gradius.pro",
+ )))));
+ }
+ let ledger = LedgerStub::default();
+ let service = banner_service(&dialogue, &ledger, Some(7));
+
+ let Some(GradiusPreparedAd::Banner(banner)) = service
+ .append(test_request(81))
+ .await
+ .expect("banner orchestration")
+ else {
+ panic!("banner expected");
+ };
+ assert_eq!(banner.opportunity_id, 1);
+ assert!(banner.html.starts_with("Это тестовый баннер"));
+ assert!(
+ banner
+ .html
+ .contains("
")
+ );
+ assert!(!banner.html.contains("script"));
+ assert_eq!(banner.html.matches(""));
+ assert_eq!(
+ payload["options"]["reply_markup"]["inline_keyboard"],
+ json!([
+ [{"text": "Кнопка 1", "url": "https://gradius.pro"}],
+ [{"text": "Кнопка 2", "url": "https://gradius.pro/publishers"}],
+ ])
+ );
+ }
+
+ #[tokio::test]
+ async fn banner_without_footer_gets_ad_label_and_replays_from_saved_placement() {
+ let dialogue = DialogueStub::default();
+ {
+ let mut responses = dialogue.responses.lock().expect("dialogue responses");
+ responses.push_back(Ok(None));
+ responses.push_back(Ok(Some(GradiusPlacement::Banner(test_banner(
+ "Без подписи
",
+ "https://ads.example/1",
+ )))));
+ }
+ let ledger = LedgerStub::default();
+ let Some(GradiusPreparedAd::Banner(banner)) = banner_service(&dialogue, &ledger, Some(7))
+ .append(test_request(82))
+ .await
+ .expect("banner orchestration")
+ else {
+ panic!("banner expected");
+ };
+ assert_eq!(banner.html, "Без подписи
");
+
+ let stored = ledger.finished_ads.lock().expect("finished ads")[0]
+ .1
+ .clone();
+ let replay_ledger = LedgerStub::with_reservation(GradiusAdReservation::Replay {
+ opportunity_id: 5,
+ ad: stored,
+ });
+ let replay_dialogue = DialogueStub::default();
+ let Some(GradiusPreparedAd::Banner(replayed)) =
+ banner_service(&replay_dialogue, &replay_ledger, Some(7))
+ .append(test_request(82))
+ .await
+ .expect("banner replay")
+ else {
+ panic!("replayed banner expected");
+ };
+ assert_eq!(replayed.opportunity_id, 5);
+ assert_eq!(replayed.html, banner.html);
+ assert_eq!(replayed.keyboard, banner.keyboard);
+ assert!(
+ replay_dialogue
+ .calls
+ .lock()
+ .expect("dialogue calls")
+ .is_empty()
+ );
+ }
+
+ #[tokio::test]
+ async fn banner_render_errors_fail_closed_without_counting_an_ad() {
+ let cases = [
+ (
+ Some(7),
+ test_banner("ad
", "http://ads.example/plain"),
+ "HTTPS",
+ ),
+ (
+ Some(7),
+ test_banner("", "https://ads.example/1"),
+ "no rich content",
+ ),
+ (
+ None,
+ test_banner("ad
", "https://ads.example/1"),
+ "not configured",
+ ),
+ (
+ Some(7),
+ GradiusBannerAd {
+ method: "sendPhoto".to_owned(),
+ ..test_banner("ad
", "https://ads.example/1")
+ },
+ "sendPhoto",
+ ),
+ ];
+ for (bot_id, banner, expected) in cases {
+ let dialogue = DialogueStub::default();
+ {
+ let mut responses = dialogue.responses.lock().expect("dialogue responses");
+ responses.push_back(Ok(None));
+ responses.push_back(Ok(Some(GradiusPlacement::Banner(banner))));
+ }
+ let ledger = LedgerStub::default();
+ let error = banner_service(&dialogue, &ledger, bot_id)
+ .append(test_request(83))
+ .await
+ .expect_err("banner must be rejected");
+ assert!(error.contains(expected), "{error}");
+ assert!(ledger.finished_ads.lock().expect("finished ads").is_empty());
+ let render_errors = ledger.render_errors.lock().expect("render errors");
+ assert_eq!(render_errors.len(), 1);
+ assert!(render_errors[0].1.contains(expected));
+ }
+ }
}
diff --git a/crates/openplotva-app/src/lib.rs b/crates/openplotva-app/src/lib.rs
index b082a0aa..3b710092 100644
--- a/crates/openplotva-app/src/lib.rs
+++ b/crates/openplotva-app/src/lib.rs
@@ -12903,10 +12903,10 @@ async fn start_runtime_workers(
"gradius",
"Gradius dialogue advertising is enabled with fail-closed privacy redaction",
));
- Some(Arc::new(gradius_ads::GradiusAdService::new(
- dialogue, privacy, ledger, vip,
- ))
- as Arc)
+ Some(Arc::new(
+ gradius_ads::GradiusAdService::new(dialogue, privacy, ledger, vip)
+ .with_banner_delivery(bot_identity.id),
+ ) as Arc)
}
Err(error) => {
tracing::warn!(%error, "Gradius privacy redactor unavailable");
diff --git a/crates/openplotva-app/src/telegram_outbox.rs b/crates/openplotva-app/src/telegram_outbox.rs
index e8b6ab24..da7ae453 100644
--- a/crates/openplotva-app/src/telegram_outbox.rs
+++ b/crates/openplotva-app/src/telegram_outbox.rs
@@ -246,7 +246,7 @@ impl TelegramOutboxJobResolver for RunAwareTelegramOutboxJobResolver {
batch_id: &'a str,
) -> TelegramOutboxJobFuture<'a, Self::Error> {
Box::pin(async move {
- if !batch_id.starts_with("gradius-utility:v1:") {
+ if !is_gradius_standalone_batch(batch_id) {
return Ok(());
}
self.gradius
@@ -263,7 +263,7 @@ impl TelegramOutboxJobResolver for RunAwareTelegramOutboxJobResolver {
error: &'a str,
) -> TelegramOutboxJobFuture<'a, Self::Error> {
Box::pin(async move {
- if !batch_id.starts_with("gradius-utility:v1:") {
+ if !is_gradius_standalone_batch(batch_id) {
return Ok(());
}
self.gradius
@@ -275,6 +275,11 @@ impl TelegramOutboxJobResolver for RunAwareTelegramOutboxJobResolver {
}
}
+fn is_gradius_standalone_batch(batch_id: &str) -> bool {
+ batch_id.starts_with("gradius-utility:v1:")
+ || batch_id.starts_with(crate::gradius_ads::GRADIUS_BANNER_BATCH_PREFIX)
+}
+
fn is_final_dialog_answer_batch(batch_id: &str, dialog_job_id: i64) -> bool {
batch_id
.strip_prefix("dialog-answer:v1:")
@@ -1611,7 +1616,8 @@ async fn reconcile_resolved_taskman_batches(
"SELECT DISTINCT opportunity.outbox_batch_id \
FROM gradius_ad_opportunities AS opportunity \
JOIN telegram_outbox AS operation ON operation.batch_id = opportunity.outbox_batch_id \
- WHERE opportunity.integration_kind IN ('native_utility', 'native_generation') \
+ WHERE (opportunity.integration_kind IN ('native_utility', 'native_generation') \
+ OR opportunity.outbox_batch_id LIKE 'gradius-banner:v1:%') \
AND opportunity.delivery_state = 'queued' \
AND opportunity.outbox_batch_id IS NOT NULL \
AND NOT EXISTS (SELECT 1 FROM telegram_outbox AS unresolved \
diff --git a/crates/openplotva-llm/src/gradius.rs b/crates/openplotva-llm/src/gradius.rs
index 86b712a5..1ace2e17 100644
--- a/crates/openplotva-llm/src/gradius.rs
+++ b/crates/openplotva-llm/src/gradius.rs
@@ -26,6 +26,8 @@ const GRADIUS_REQUEST_TIMEOUT_DEFAULT: Duration = Duration::from_secs(5);
const GRADIUS_DIALOGUE_PATH: &str = "/v1/native/dialogue_model/chat";
const GRADIUS_GENERATION_PATH: &str = "/v1/native/generation_model/chat";
const GRADIUS_UTILITY_PATH: &str = "/v1/native/utility_service/chat";
+const GRADIUS_NATIVE_TEXT_TYPE: &str = "native-text-ad";
+const GRADIUS_BANNER_TYPE: &str = "banner";
pub const GRADIUS_RAW_BODY_MAX_BYTES: usize = 65_536;
const GRADIUS_REDACTION_CATEGORIES: [&str; 8] = [
"account_number",
@@ -189,10 +191,71 @@ pub struct GradiusDialogueAd {
pub click_price: Option,
}
+/// One URL button of a Gradius banner keyboard.
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+pub struct GradiusBannerButton {
+ pub text: String,
+ pub url: String,
+}
+
+/// Standalone dialogue banner delivered as its own Telegram message.
+#[derive(Clone, Debug, PartialEq)]
+pub struct GradiusBannerAd {
+ pub method: String,
+ pub message_type: String,
+ pub text: String,
+ /// Keyboard rows; a flat provider button list becomes one button per row.
+ pub keyboard: Vec>,
+ pub show_price: Option,
+ pub click_price: Option,
+}
+
+impl GradiusBannerAd {
+ /// Decode one provider `banner` entry, or an entry previously produced by
+ /// [`Self::to_placement_json`].
+ pub fn from_placement_json(value: serde_json::Value) -> Result {
+ let entry = serde_json::from_value::(value)?;
+ Ok(Self {
+ method: entry.content.method,
+ message_type: entry.content.message_type,
+ text: entry.content.text,
+ keyboard: entry
+ .content
+ .inline_keyboard
+ .unwrap_or_default()
+ .into_iter()
+ .map(|entry| match entry {
+ GradiusBannerKeyboardEntry::Row(row) => row,
+ GradiusBannerKeyboardEntry::Button(button) => vec![button],
+ })
+ .filter(|row| !row.is_empty())
+ .collect(),
+ show_price: entry.show_price,
+ click_price: entry.click_price,
+ })
+ }
+
+ #[must_use]
+ pub fn to_placement_json(&self) -> serde_json::Value {
+ serde_json::json!({
+ "type": GRADIUS_BANNER_TYPE,
+ "content": {
+ "method": self.method,
+ "message_type": self.message_type,
+ "text": self.text,
+ "inline_keyboard": self.keyboard,
+ },
+ "show_price": self.show_price,
+ "click_price": self.click_price,
+ })
+ }
+}
+
/// Typed selected placement while retaining the complete raw provider response separately.
#[derive(Clone, Debug, PartialEq)]
pub enum GradiusPlacement {
NativeDialogue(GradiusDialogueAd),
+ Banner(GradiusBannerAd),
Standalone {
source_index: usize,
markdown: String,
@@ -278,6 +341,7 @@ impl fmt::Debug for GradiusApiResult {
"placement_kind",
&self.placement.as_ref().map(|placement| match placement {
GradiusPlacement::NativeDialogue(_) => "native_dialogue",
+ GradiusPlacement::Banner(_) => "banner",
GradiusPlacement::Standalone { .. } => "standalone",
}),
)
@@ -541,8 +605,8 @@ where
Some(exchange),
));
}
- let ad = match decode_dialogue_ad(&response.body) {
- Ok(ad) => ad,
+ let placement = match decode_dialogue_placement(&response.body) {
+ Ok(placement) => placement,
Err(error) => {
let exchange = exchange(
&turn,
@@ -558,13 +622,13 @@ where
return Err(failure(error, Some(exchange)));
}
};
- let outcome = if ad.is_some() {
+ let outcome = if placement.is_some() {
GradiusCallOutcome::Ad
} else {
GradiusCallOutcome::NoAd
};
Ok(GradiusApiResult {
- placement: ad.map(GradiusPlacement::NativeDialogue),
+ placement,
exchange: exchange(
&turn,
endpoint,
@@ -887,6 +951,31 @@ struct GradiusNativeTextAdContent {
content: String,
}
+#[derive(Deserialize)]
+struct GradiusBannerEntry {
+ content: GradiusBannerContent,
+ #[serde(default)]
+ show_price: Option,
+ #[serde(default)]
+ click_price: Option,
+}
+
+#[derive(Deserialize)]
+struct GradiusBannerContent {
+ method: String,
+ message_type: String,
+ text: String,
+ #[serde(default)]
+ inline_keyboard: Option>,
+}
+
+#[derive(Deserialize)]
+#[serde(untagged)]
+enum GradiusBannerKeyboardEntry {
+ Row(Vec),
+ Button(GradiusBannerButton),
+}
+
#[derive(Deserialize)]
struct GradiusUtilityAd {
content: String,
@@ -1000,22 +1089,32 @@ fn dialogue_endpoint(
Ok(endpoint.into())
}
-fn decode_dialogue_ad(body: &[u8]) -> Result