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 {
new_attrs.push_str(" style=\"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 let Some(s) = new_attrs.find("data=\"")
&& let Some(e) = new_attrs[s + "data=\"".len()..].find('"')
{
let data_end = s + "data=\"".len() + e + 1;
let strip_start = if s > 0 && new_attrs.as_bytes()[s - 1] == b' ' {
s - 1
} else {
s
};
new_attrs.replace_range(strip_start..data_end, "");
}
if let Some(s) = new_attrs.find("type=\"")
&& let Some(e) = new_attrs[s + "type=\"".len()..].find('"')
{
let type_end = s + "type=\"".len() + e;
new_attrs.replace_range(s + "type=\"".len()..type_end, "disabled");
} else {
new_attrs.push_str(" type=\"disabled\"");
}
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, label: &str, is_single: bool) -> String {
if is_single {
String::from("Go!")
} else {
let first_word = label.split_whitespace().next().unwrap_or_default();
if first_word.is_empty() {
number.to_string()
} else {
format!("{number}. {first_word}")
}
}
}
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>";
struct EnforcerButton<'a> {
open_tag: &'a str,
label: &'a str,
}
struct EnforcerRow<'a> {
row_start: usize,
row_end: usize,
buttons: Vec<EnforcerButton<'a>>,
}
struct EnforcerSet<'a> {
rows: Vec<EnforcerRow<'a>>,
set_start: usize,
set_end: usize,
}
let mut rows: Vec<EnforcerRow<'_>> = Vec::new();
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 buttons: Vec<EnforcerButton<'_>> = Vec::new();
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];
buttons.push(EnforcerButton { open_tag, label });
bscan = label_start + lrel + BTN_CLOSE.len();
}
rows.push(EnforcerRow {
row_start,
row_end,
buttons,
});
scan_from = row_end;
}
if rows.is_empty() {
return html.to_string();
}
let mut sets: Vec<EnforcerSet<'_>> = Vec::new();
for row in rows {
if let Some(current_set) = sets.last_mut() {
let inter = &html[current_set.set_end..row.row_start];
if inter.trim().is_empty() {
current_set.set_end = row.row_end;
current_set.rows.push(row);
continue;
}
}
sets.push(EnforcerSet {
set_start: row.row_start,
set_end: row.row_end,
rows: vec![row],
});
}
let display_fits = |buttons: &[EnforcerButton<'_>]| -> bool {
let decoded: Vec<String> = buttons
.iter()
.map(|b| super::markdown::unescape_html(b.label))
.collect();
let refs: Vec<&str> = decoded.iter().map(|s| s.as_str()).collect();
row_fits(&refs)
};
let all_as_authored_fit = sets
.iter()
.all(|s| s.rows.iter().all(|r| display_fits(&r.buttons)));
if all_as_authored_fit {
return html.to_string();
}
let mut out = String::with_capacity(html.len() + 64);
let mut pos = 0usize;
for set in &sets {
out.push_str(&html[pos..set.set_start]);
let set_fits_as_authored = set.rows.iter().all(|r| display_fits(&r.buttons));
if set_fits_as_authored {
out.push_str(&html[set.set_start..set.set_end]);
} else {
let can_reshape_to_column = set
.rows
.iter()
.flat_map(|r| &r.buttons)
.all(|b| display_fits(std::slice::from_ref(b)));
if can_reshape_to_column {
let mut first = true;
for row in &set.rows {
for btn in &row.buttons {
if !first {
out.push('\n');
}
first = false;
out.push_str(ROW_OPEN);
out.push_str(btn.open_tag);
out.push('>');
out.push_str(btn.label);
out.push_str(BTN_CLOSE);
out.push_str(ROW_CLOSE);
}
}
} else {
let total_buttons: usize = set.rows.iter().map(|r| r.buttons.len()).sum();
let is_single = total_buttons == 1;
let mut index = 0usize;
let mut set_labels: Vec<&str> = Vec::new();
for row in &set.rows {
out.push_str(ROW_OPEN);
for btn in &row.buttons {
index += 1;
set_labels.push(btn.label);
out.push_str(btn.open_tag);
out.push('>');
let decoded = super::markdown::unescape_html(btn.label);
let folded = super::markdown::escape_html(&go_button_label(
index, &decoded, is_single,
));
if row_fits(&[folded.as_str()]) {
out.push_str(&folded);
} else {
out.push_str(&go_button_label(index, "", is_single));
}
out.push_str(BTN_CLOSE);
}
out.push_str(ROW_CLOSE);
}
out.push_str("\n<ol>");
for label in set_labels {
out.push_str("<li>");
out.push_str(label);
out.push_str("</li>");
}
out.push_str("</ol>");
}
}
pos = set.set_end;
}
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: &[crate::brain::tools::suggest_options::SuggestionItem],
token: &str,
prose: bool,
trailer: Option<&str>,
) {
if prose {
let raw_labels: Vec<String> = options.iter().map(|o| o.label.clone()).collect();
push_blank_line(md);
md.push_str("---\n\n");
md.push_str(&go_tier_lines(&raw_labels));
}
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: &[crate::brain::tools::suggest_options::SuggestionItem],
token: &str,
) -> String {
use crate::brain::tools::suggest_options::SuggestionStyle;
let btn = |i: usize, label: &str, style: SuggestionStyle| {
let style_attr = match style {
SuggestionStyle::Primary => " style=\"primary\"",
SuggestionStyle::Danger => " style=\"danger\"",
SuggestionStyle::Default => "",
};
format!(
"<tg-button type=\"callback_data\" data=\"{FOLLOWUP_PREFIX}{token}:{i}\"{style_attr}>{}</tg-button>",
super::markdown::escape_html(label)
)
};
let labels: Vec<String> = options.iter().map(|o| o.label.clone()).collect();
match pick_layout(&labels) {
SuggestLayout::SharedRow => format!(
"<tg-button-row>{}</tg-button-row>",
options
.iter()
.enumerate()
.map(|(i, opt)| btn(i, &opt.label, opt.style))
.collect::<String>()
),
SuggestLayout::Column => options
.iter()
.enumerate()
.map(|(i, opt)| {
format!(
"<tg-button-row>{}</tg-button-row>",
btn(i, &opt.label, opt.style)
)
})
.collect::<Vec<_>>()
.join("\n"),
SuggestLayout::NumberedProse => (0..options.len())
.map(|i| {
btn(
i,
&go_button_label(i + 1, &options[i].label, options.len() == 1),
options[i].style,
)
})
.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<crate::brain::tools::suggest_options::SuggestionItem>,
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 raw_options: Vec<String> = options.iter().map(|o| o.label.clone()).collect();
let token = state
.register_pending_followups(session_id, raw_options.clone())
.await;
let layout = pick_layout(&raw_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![
raw_options
.iter()
.enumerate()
.map(|(i, opt)| text_btn(i, opt))
.collect(),
],
SuggestLayout::Column => raw_options
.iter()
.enumerate()
.map(|(i, opt)| vec![text_btn(i, opt)])
.collect(),
SuggestLayout::NumberedProse => {
let all: Vec<InlineKeyboardButton> = (0..raw_options.len())
.map(|i| {
InlineKeyboardButton::callback(
go_button_label(i + 1, &raw_options[i], raw_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("<hr/>\n");
body.push_str(&go_tier_lines_rich(&raw_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, &raw_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;
if super::rich::mermaid::should_render_mermaid(trailer) {
match super::rich::send_rich_with_mermaid(
bot.api_url().as_str(),
bot.token(),
chat_id.0,
thread_id,
trailer,
"turn",
"#583-trailer",
)
.await
{
Ok(()) => {
tracing::info!("Telegram: #31 trailer bubble delivered rich (#583) with mermaid");
return;
}
Err(e) => {
tracing::warn!(
"Telegram: #31 trailer rich send failed ({e}) — falling back to legacy HTML path"
);
}
}
}
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}");
}
}
}
}
}