use std::sync::Arc;
use std::time::Duration;
use teloxide::payloads::{EditMessageTextSetters, SendMessageSetters};
use teloxide::types::{
ChatId, InlineKeyboardButton, InlineKeyboardMarkup, MessageId, ParseMode, ThreadId,
};
use uuid::Uuid;
use super::TelegramState;
pub(crate) const FOLLOWUP_PREFIX: &str = "followup:";
pub(crate) fn standalone_fallback_body(layout: &SuggestLayout, options: &[String]) -> String {
if *layout == SuggestLayout::NumberedProse {
go_tier_lines_rich(options)
} else {
String::from("\u{1f4a1} <i>(choices may have expired)</i>")
}
}
pub(crate) fn picked_block(text: &str, chooser: Option<&str>) -> String {
match chooser {
Some(name) if !name.trim().is_empty() => {
format!("\u{25b6}\u{fe0f} {} \u{2014} {text}", name.trim())
}
_ => format!("\u{25b6}\u{fe0f} {text}"),
}
}
pub(crate) fn echo_fallback(text: &str, chooser: Option<&str>) -> String {
match chooser {
Some(name) if !name.trim().is_empty() => {
format!("> \u{25b6}\u{fe0f} {} \u{2014} {text}", name.trim())
}
_ => format!("> \u{25b6}\u{fe0f} {text}"),
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum PickRewrite {
RichHost(String),
RichMarkdownHost(String),
ClassicHost(String),
Standalone(String),
}
pub(crate) fn pick_rewrite(
host: Option<(&str, bool, Option<&str>)>,
picked_html: &str,
picked_md: &str,
picked_idx: usize,
) -> PickRewrite {
match host {
Some((full, rich, markdown)) => {
if rich {
if let Some(md) = markdown {
PickRewrite::RichMarkdownHost(format!(
"{}\n\n{picked_md}",
mark_picked_button(md, picked_idx)
))
} else {
PickRewrite::RichHost(format!(
"{}\n\n{picked_html}",
mark_picked_button(full, picked_idx)
))
}
} else {
PickRewrite::ClassicHost(format!("{full}\n\n{picked_html}"))
}
}
None => PickRewrite::Standalone(picked_html.to_string()),
}
}
pub(crate) fn mark_picked_button(html: &str, picked_idx: usize) -> String {
let mut out = String::with_capacity(html.len() + 64);
let mut rest = html;
while let Some(tag_start) = rest.find("<tg-button") {
let after_open = &rest[tag_start + "<tg-button".len()..];
if !after_open.starts_with('>') && !after_open.starts_with(' ') {
out.push_str(&rest[..tag_start + "<tg-button".len()]);
rest = after_open;
continue;
}
let Some(attrs_rel) = after_open.find('>') else {
out.push_str(rest);
return out;
};
let tag_open_len = "<tg-button".len();
let attrs = &after_open[..attrs_rel];
let after_attrs = &after_open[attrs_rel + 1..];
let body_rel = attrs_rel + 1;
let Some(label_rel) = after_attrs.find("</tg-button>") else {
out.push_str(&rest[..tag_start + tag_open_len + body_rel]);
rest = after_attrs;
continue;
};
let label = &after_attrs[..label_rel];
let tail = &after_attrs[label_rel + "</tg-button>".len()..];
let idx = attrs
.split("data=\"followup:")
.nth(1)
.and_then(|v| v.split('"').next())
.and_then(|v| v.rsplit(':').next())
.and_then(|v| v.parse::<usize>().ok());
let picked = idx == Some(picked_idx);
if picked || idx.is_some() {
let mut new_attrs = attrs.to_string();
if picked {
if let Some(s) = new_attrs.find("style=\"")
&& let Some(e) = new_attrs[s + "style=\"".len()..].find('"')
{
let style_end = s + "style=\"".len() + e;
new_attrs.replace_range(s + "style=\"".len()..style_end, "success");
}
} else {
if let Some(s) = new_attrs.find(" style=\"")
&& let Some(e) = new_attrs[s + " style=\"".len()..].find('"')
{
let style_end = s + " style=\"".len() + e + 1;
new_attrs.replace_range(s..style_end, "");
}
}
if !new_attrs.contains("disabled") {
new_attrs.push_str(" disabled");
}
out.push_str(&rest[..tag_start]);
out.push_str("<tg-button");
out.push_str(&new_attrs);
out.push('>');
if picked {
out.push_str("\u{2713} ");
}
out.push_str(label);
out.push_str("</tg-button>");
} else {
out.push_str(&rest[..tag_start + tag_open_len + body_rel + label_rel]);
out.push_str("</tg-button>");
}
rest = tail;
}
out.push_str(rest);
out
}
pub(crate) const BUTTON_LABEL_MAX_UNITS: usize = 20;
pub(crate) const SINGLE_BUTTON_MAX_UNITS: usize = 30;
pub(crate) const SHARED_ROW_TOTAL_UNITS: usize = 20;
pub(crate) const SHARED_ROW_MAX_CHARS: usize = 12;
pub(crate) const MAX_NUMBERS_PER_ROW: usize = 4;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum SuggestLayout {
SharedRow,
Column,
NumberedProse,
}
pub(crate) fn row_fits(labels: &[&str]) -> bool {
if labels.len() == 1 {
labels[0].chars().count() <= SINGLE_BUTTON_MAX_UNITS
} else {
let total: usize = labels.iter().map(|l| l.chars().count()).sum();
labels
.iter()
.all(|l| l.chars().count() <= SHARED_ROW_MAX_CHARS)
&& total <= SHARED_ROW_TOTAL_UNITS
}
}
pub(crate) fn pick_layout(options: &[String]) -> SuggestLayout {
let width = |o: &String| o.chars().count();
let refs: Vec<&str> = options.iter().map(|o| o.as_str()).collect();
if options.len() > 1 && options.len() <= MAX_NUMBERS_PER_ROW && row_fits(&refs) {
SuggestLayout::SharedRow
} else if options.iter().all(|o| width(o) <= BUTTON_LABEL_MAX_UNITS)
|| (options.len() == 1 && width(&options[0]) <= SINGLE_BUTTON_MAX_UNITS)
{
SuggestLayout::Column
} else {
SuggestLayout::NumberedProse
}
}
pub(crate) fn go_tier_lines_rich(options: &[String]) -> String {
if options.len() == 1 {
return options
.iter()
.map(|opt| {
super::markdown::format_inline(&super::markdown::escape_html(&go_tier_line(opt)))
})
.collect::<Vec<_>>()
.join("\n");
}
options
.iter()
.enumerate()
.map(|(i, opt)| {
format!(
"{}. {}",
i + 1,
super::markdown::format_inline(&super::markdown::escape_html(opt))
)
})
.collect::<Vec<_>>()
.join("\n")
}
pub(crate) fn go_button_label(number: usize, is_single: bool) -> String {
if is_single {
String::from("Go!")
} else {
number.to_string()
}
}
const GO_TIER_VERB: &str = "Go";
pub(crate) fn go_tier_line(label: &str) -> String {
let first_word = label.split_whitespace().next().unwrap_or_default();
let starts_with_verb = first_word.eq_ignore_ascii_case(GO_TIER_VERB);
let terminator = if label.ends_with('?') { "" } else { "?" };
if starts_with_verb {
format!("**{label}{terminator}**")
} else {
format!("**{GO_TIER_VERB}: {label}{terminator}**")
}
}
pub(crate) fn go_tier_lines(options: &[String]) -> String {
if options.len() == 1 {
return go_tier_line(&options[0]);
}
options
.iter()
.enumerate()
.map(|(i, o)| format!("{}. {}", i + 1, o))
.collect::<Vec<_>>()
.join("\n")
}
pub(crate) fn strip_button_rows(html: &str) -> String {
let mut out = String::with_capacity(html.len());
let mut rest = html;
while let Some(start) = rest.find("<tg-button-row>") {
match rest[start..].find("</tg-button-row>") {
Some(rel) => {
let end = start + rel + "</tg-button-row>".len();
out.push_str(&rest[..start]);
rest = &rest[end..];
}
None => break, }
}
out.push_str(rest);
out.trim_end().to_string()
}
pub(crate) fn empty_keyboard() -> teloxide::types::InlineKeyboardMarkup {
teloxide::types::InlineKeyboardMarkup::new(
Vec::<Vec<teloxide::types::InlineKeyboardButton>>::new(),
)
}
pub(crate) fn enforce_button_fit(html: &str) -> String {
const ROW_OPEN: &str = "<tg-button-row>";
const ROW_CLOSE: &str = "</tg-button-row>";
const BTN_OPEN: &str = "<tg-button";
const BTN_CLOSE: &str = "</tg-button>";
let mut rows: Vec<(usize, usize)> = Vec::new();
let mut open_tags: Vec<Vec<&str>> = Vec::new();
let mut labels: Vec<&str> = Vec::new();
let mut row_labels: Vec<&str> = Vec::new();
let mut fits = true;
let mut scan_from = 0usize;
while let Some(rel) = html[scan_from..].find(ROW_OPEN) {
let row_start = scan_from + rel;
let Some(crel) = html[row_start..].find(ROW_CLOSE) else {
break; };
let row_end = row_start + crel + ROW_CLOSE.len();
let block = &html[row_start + ROW_OPEN.len()..row_end - ROW_CLOSE.len()];
let mut tags_in_row: Vec<&str> = Vec::new();
row_labels.clear();
let mut bscan = 0usize;
while let Some(brel) = block[bscan..].find(BTN_OPEN) {
let bstart = bscan + brel;
let after = &block[bstart + BTN_OPEN.len()..];
if !after.starts_with(' ') && !after.starts_with('>') {
bscan = bstart + BTN_OPEN.len();
continue;
}
let Some(orel) = after.find('>') else {
break;
};
let open_tag = &block[bstart..bstart + BTN_OPEN.len() + orel];
let label_start = bstart + BTN_OPEN.len() + orel + 1;
let Some(lrel) = block[label_start..].find(BTN_CLOSE) else {
break;
};
let label = &block[label_start..label_start + lrel];
row_labels.push(label);
tags_in_row.push(open_tag);
labels.push(label);
bscan = label_start + lrel + BTN_CLOSE.len();
}
fits &= row_fits(&row_labels);
rows.push((row_start, row_end));
open_tags.push(tags_in_row);
scan_from = row_end;
}
if rows.is_empty() || fits {
return html.to_string();
}
let mut out = String::with_capacity(html.len() + 64);
let mut pos = 0usize;
let mut index = 0usize;
let last_row = rows.len() - 1;
for (i, &(row_start, row_end)) in rows.iter().enumerate() {
out.push_str(&html[pos..row_start]);
out.push_str(ROW_OPEN);
for tag in &open_tags[i] {
index += 1;
out.push_str(tag);
out.push('>');
out.push_str(&index.to_string());
out.push_str(BTN_CLOSE);
}
out.push_str(ROW_CLOSE);
pos = row_end;
if i == last_row {
out.push_str("\n<ol>");
for label in &labels {
out.push_str("<li>");
out.push_str(label);
out.push_str("</li>");
}
out.push_str("</ol>");
}
}
out.push_str(&html[pos..]);
out
}
fn push_blank_line(md: &mut String) {
if !md.ends_with('\n') {
md.push('\n');
}
md.push('\n');
}
pub(crate) fn append_rows_and_trailer_md(
md: &mut String,
options: &[String],
token: &str,
prose: bool,
trailer: Option<&str>,
) {
if prose {
push_blank_line(md);
md.push_str(&go_tier_lines(options));
}
push_blank_line(md);
md.push_str(&suggestion_rows_rich_html(options, token));
if let Some(t) = trailer {
push_blank_line(md);
md.push_str(t);
}
}
pub(crate) fn suggestion_rows_rich_html(options: &[String], token: &str) -> String {
let btn = |i: usize, label: &str| {
format!(
"<tg-button type=\"callback_data\" data=\"{FOLLOWUP_PREFIX}{token}:{i}\" \
style=\"primary\">{}</tg-button>",
super::markdown::escape_html(label)
)
};
match pick_layout(options) {
SuggestLayout::SharedRow => format!(
"<tg-button-row>{}</tg-button-row>",
options
.iter()
.enumerate()
.map(|(i, opt)| btn(i, opt))
.collect::<String>()
),
SuggestLayout::Column => options
.iter()
.enumerate()
.map(|(i, opt)| format!("<tg-button-row>{}</tg-button-row>", btn(i, opt)))
.collect::<Vec<_>>()
.join("\n"),
SuggestLayout::NumberedProse => (0..options.len())
.map(|i| btn(i, &go_button_label(i + 1, options.len() == 1)))
.collect::<Vec<_>>()
.chunks(MAX_NUMBERS_PER_ROW)
.map(|c| format!("<tg-button-row>{}</tg-button-row>", c.concat()))
.collect::<Vec<_>>()
.join("\n"),
}
}
#[allow(clippy::too_many_arguments)] pub(crate) async fn render_suggestions(
bot: &teloxide::Bot,
state: &Arc<TelegramState>,
session_id: Uuid,
chat_id: ChatId,
thread_id: Option<ThreadId>,
options: Vec<String>,
merge_host: Option<super::state::MergeBubble>,
trailer: Option<String>,
) {
if options.is_empty() {
if let Some(t) = &trailer {
send_trailer_bubble(bot, chat_id, thread_id, t).await;
}
return;
}
let token = state
.register_pending_followups(session_id, options.clone())
.await;
let layout = pick_layout(&options);
let text_btn = |i: usize, opt: &str| {
InlineKeyboardButton::callback(opt.to_string(), format!("{FOLLOWUP_PREFIX}{token}:{i}"))
};
let rows: Vec<Vec<InlineKeyboardButton>> = match layout {
SuggestLayout::SharedRow => vec![
options
.iter()
.enumerate()
.map(|(i, opt)| text_btn(i, opt))
.collect(),
],
SuggestLayout::Column => options
.iter()
.enumerate()
.map(|(i, opt)| vec![text_btn(i, opt)])
.collect(),
SuggestLayout::NumberedProse => {
let all: Vec<InlineKeyboardButton> = (0..options.len())
.map(|i| {
InlineKeyboardButton::callback(
go_button_label(i + 1, options.len() == 1),
format!("{FOLLOWUP_PREFIX}{token}:{i}"),
)
})
.collect();
all.chunks(MAX_NUMBERS_PER_ROW)
.map(|c| c.to_vec())
.collect()
}
};
let keyboard = InlineKeyboardMarkup::new(rows);
let merge_payload: Option<MergePayload> = merge_host.map(|host| {
let mid = host.message_id;
let (mut new_html, rich, new_markdown) = match host.body {
super::state::BubbleBody::Html(html) => {
let mut body = html;
if layout == SuggestLayout::NumberedProse {
if !body.ends_with('\n') {
body.push('\n');
}
body.push('\n');
body.push_str(&go_tier_lines_rich(&options));
}
(body, false, None)
}
super::state::BubbleBody::Markdown(md) => {
let mut new_md = md;
append_rows_and_trailer_md(
&mut new_md,
&options,
&token,
layout == SuggestLayout::NumberedProse,
trailer.as_deref(),
);
let strip_source = super::rich::markdown_to_html_p(&new_md);
(strip_source, true, Some(new_md))
}
};
if rich && new_markdown.is_none() {
new_html.push('\n');
new_html.push_str(&suggestion_rows_rich_html(&options, &token));
if let Some(t) = &trailer {
new_html.push('\n');
new_html.push_str(&super::rich::markdown_to_html_p(t));
}
}
MergePayload {
message_id: mid,
new_html,
rich,
new_markdown,
}
});
let placement_payload = merge_payload;
let standalone_body = standalone_fallback_body(&layout, &options);
let option_count = options.len();
match place_once(
bot,
state,
chat_id,
thread_id,
&token,
option_count,
&keyboard,
placement_payload.as_ref(),
&standalone_body,
)
.await
{
Ok(()) => {
let embedded = state
.peek_followup_host(&token)
.await
.map(|h| h.rich && trailer.is_some())
.unwrap_or(false);
if !embedded && let Some(t) = &trailer {
send_trailer_bubble(bot, chat_id, thread_id, t).await;
}
}
Err(PlaceErr::Fatal(e)) => {
tracing::warn!("Telegram suggest_options: send failed: {e}");
state.drop_pending_followup(&token).await;
}
Err(PlaceErr::RetryAfter(wait)) => {
tracing::warn!(
"Telegram suggest_options: placement hit Retry-After {}s (token {token}) — \
stash kept, deferring",
wait.as_secs()
);
let bot = bot.clone();
let state = state.clone();
let token = token.clone();
let keyboard = keyboard.clone();
let trailer = trailer.clone();
tokio::spawn(async move {
let mut wait = wait;
for attempt in 1..=MAX_DEFERRED_PLACEMENT_ATTEMPTS {
use rand::Rng;
let jitter = Duration::from_millis(rand::rng().random_range(0..=2000));
tokio::time::sleep(wait + jitter).await;
match place_once(
&bot,
&state,
chat_id,
thread_id,
&token,
option_count,
&keyboard,
placement_payload.as_ref(),
&standalone_body,
)
.await
{
Ok(()) => {
tracing::info!(
"Telegram suggest_options: deferred placement {attempt}/\
{MAX_DEFERRED_PLACEMENT_ATTEMPTS} landed (token {token})"
);
let embedded = state
.peek_followup_host(&token)
.await
.map(|h| h.rich && trailer.is_some())
.unwrap_or(false);
if !embedded && let Some(t) = &trailer {
send_trailer_bubble(&bot, chat_id, thread_id, t).await;
}
return;
}
Err(PlaceErr::Fatal(e)) => {
tracing::warn!(
"Telegram suggest_options: deferred placement {attempt} \
failed permanently: {e}"
);
state.drop_pending_followup(&token).await;
return;
}
Err(PlaceErr::RetryAfter(w)) => {
tracing::warn!(
"Telegram suggest_options: deferred placement {attempt} hit \
Retry-After {}s again (token {token})",
w.as_secs()
);
wait = w;
}
}
}
tracing::warn!(
"Telegram suggest_options: placement budget spent after \
{MAX_DEFERRED_PLACEMENT_ATTEMPTS} deferred attempts (token {token}) — dropping"
);
state.drop_pending_followup(&token).await;
});
}
}
}
#[derive(Clone)]
struct MergePayload {
message_id: MessageId,
new_html: String,
rich: bool,
new_markdown: Option<String>,
}
enum PlaceErr {
RetryAfter(Duration),
Fatal(String),
}
const MAX_DEFERRED_PLACEMENT_ATTEMPTS: u32 = 2;
const RICH_429_FALLBACK_WAIT_SECS: u64 = 30;
fn classify_request_err(e: teloxide::RequestError) -> PlaceErr {
match e {
teloxide::RequestError::RetryAfter(secs) => PlaceErr::RetryAfter(secs.duration()),
other => PlaceErr::Fatal(other.to_string()),
}
}
fn classify_rich_err(e: &str) -> PlaceErr {
if e.contains("(429)") {
PlaceErr::RetryAfter(Duration::from_secs(RICH_429_FALLBACK_WAIT_SECS))
} else {
PlaceErr::Fatal(e.to_string())
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn edit_rich_md_fencesafe(
api_url: &str,
token: &str,
chat_id: i64,
message_id: i32,
markdown: &str,
reply_markup: Option<&serde_json::Value>,
origin: &str,
origin_detail: &str,
) -> Result<(), String> {
if !super::rich::mermaid::has_mermaid_fence(markdown) {
return super::rich::api::edit_rich_markdown(
api_url,
token,
chat_id,
message_id,
markdown,
reply_markup,
origin,
origin_detail,
)
.await
.map_err(|e| e.to_string());
}
let (resolved, media) = super::rich::mermaid::resolve_markdown_media(markdown).await;
if media.is_empty() {
return super::rich::api::edit_rich_markdown(
api_url,
token,
chat_id,
message_id,
&resolved,
reply_markup,
origin,
origin_detail,
)
.await
.map_err(|e| e.to_string());
}
match super::rich::api::edit_rich_markdown_media(
api_url,
token,
chat_id,
message_id,
&resolved,
&media,
reply_markup,
origin,
origin_detail,
)
.await
{
Ok(()) => Ok(()),
Err(e) => {
let s = e.to_string();
if s.contains("(429)") {
return Err(s);
}
tracing::warn!(
"Telegram rich edit: media edit rejected ({s}) — plain markdown fallback \
(keyboard lands; fence degrades to text)"
);
super::rich::api::edit_rich_markdown(
api_url,
token,
chat_id,
message_id,
markdown,
reply_markup,
origin,
origin_detail,
)
.await
.map_err(|e| e.to_string())
}
}
}
#[allow(clippy::too_many_arguments)]
async fn place_once(
bot: &teloxide::Bot,
state: &Arc<TelegramState>,
chat_id: ChatId,
thread_id: Option<ThreadId>,
token: &str,
option_count: usize,
keyboard: &InlineKeyboardMarkup,
merge: Option<&MergePayload>,
standalone_body: &str,
) -> Result<(), PlaceErr> {
use teloxide::prelude::Requester;
if let Some(mp) = merge {
let mid = mp.message_id;
let outcome: Result<(), PlaceErr> = if let Some(md) = &mp.new_markdown {
edit_rich_md_fencesafe(
bot.api_url().as_str(),
bot.token(),
chat_id.0,
mid.0,
md,
None,
"turn",
"-",
)
.await
.map_err(|e| classify_rich_err(&e))
} else if mp.rich {
super::rich::api::edit_rich_html(
bot.api_url().as_str(),
bot.token(),
chat_id.0,
mid.0,
&mp.new_html,
None,
"turn",
"-",
)
.await
.map_err(|e| classify_rich_err(&e.to_string()))
} else {
bot.edit_message_text(chat_id, mid, &mp.new_html)
.parse_mode(ParseMode::Html)
.reply_markup(keyboard.clone())
.await
.map(|_| ())
.map_err(classify_request_err)
};
match outcome {
Ok(()) => {
tracing::info!(
"Telegram suggest_options: keyboard merged onto msg {mid} \
({} host, token {token}, {option_count} options)",
if mp.new_markdown.is_some() {
"rich-md"
} else if mp.rich {
"rich"
} else {
"classic"
}
);
state
.attach_followup_host(
token,
super::state::MergedHost {
message_id: mid,
html: mp.new_html.clone(),
rich: mp.rich,
markdown: mp.new_markdown.clone(),
},
)
.await;
return Ok(());
}
Err(PlaceErr::RetryAfter(wait)) => return Err(PlaceErr::RetryAfter(wait)),
Err(PlaceErr::Fatal(e)) => {
tracing::warn!(
"Telegram suggest_options: merge onto msg {mid} failed ({e}) — standalone fallback"
);
}
}
}
let mut req = bot
.send_message(chat_id, standalone_body)
.reply_markup(keyboard.clone());
req = req.parse_mode(ParseMode::Html);
if let Some(tid) = thread_id {
req = req.message_thread_id(tid);
}
match req.await {
Ok(msg) => {
tracing::info!(
"Telegram suggest_options: standalone block msg {} \
(token {token}, {option_count} options)",
msg.id
);
Ok(())
}
Err(e) => Err(classify_request_err(e)),
}
}
async fn send_trailer_bubble(
bot: &teloxide::Bot,
chat_id: ChatId,
thread_id: Option<ThreadId>,
trailer: &str,
) {
use teloxide::prelude::Requester;
let html = super::markdown::markdown_to_telegram_html(trailer);
let mut req = bot.send_message(chat_id, html).parse_mode(ParseMode::Html);
if let Some(tid) = thread_id {
req = req.message_thread_id(tid);
}
match req.await {
Ok(msg) => {
tracing::info!("Telegram: #31 trailer bubble delivered as msg {}", msg.id);
}
Err(e) => {
tracing::warn!("Telegram: #31 trailer bubble HTML send failed ({e}) — retrying plain");
let mut plain = bot.send_message(chat_id, trailer);
if let Some(tid) = thread_id {
plain = plain.message_thread_id(tid);
}
match plain.await {
Ok(msg) => {
tracing::info!(
"Telegram: #31 trailer bubble delivered plain as msg {}",
msg.id
);
}
Err(e2) => {
tracing::warn!("Telegram: #31 trailer bubble dropped after plain retry: {e2}");
}
}
}
}
}