git.iko.soy/personality-traits
on master help sign in
Combine single media file with text as caption in one message

Previously the bot sent media files separately, then text with the inline keyboard as a second message. Now when an event has exactly one file and the text fits within Telegram's 1024-char caption limit, the photo/video/document is sent with the text as caption and inline buttons attached to the same message. For multi-file events or text exceeding the caption limit, the bot falls back to the previous behaviour (files first, then text).

commit2598ef28307648f6317915b9bdcc41713e654995 authoriko <mail@iko.soy> dateWed 13 May 2026, 08:15 (5 months ago) parent9bfb4eb
2 files changed+112−17
src/handlers.rs+5−14 src/media.rs+107−3
src/handlers.rs+5−14
@@ -153,7 +153,6 @@ async fn send_intro(bot: &Bot, chat: ChatId, course: &Course, restart_mode: bool
153153 .iter()
154154 .filter_map(|f| course.resolve_file(f))
155155 .collect();
156− media::send_files(bot, chat, &files).await;
157156
158157 let (button_text, callback_data) = if restart_mode {
159158 ("Начать курс заново".to_string(), "crs:restart".to_string())
@@ -176,7 +175,7 @@ async fn send_intro(bot: &Bot, chat: ChatId, course: &Course, restart_mode: bool
176175 } else {
177176 intro.text.as_str()
178177 };
179− bot.send_message(chat, text).reply_markup(kb).await?;
178+ media::send_event_message(bot, chat, &files, text, Some(kb)).await;
180179 Ok(())
181180}
182181
@@ -368,7 +367,6 @@ pub async fn send_event_with_keyboard(
368367 .iter()
369368 .filter_map(|f| course.resolve_file(f))
370369 .collect();
371− media::send_files(bot, chat, &files).await;
372370
373371 let rows: Vec<Vec<InlineKeyboardButton>> = event
374372 .buttons
@@ -382,18 +380,11 @@ pub async fn send_event_with_keyboard(
382380 })
383381 .collect();
384382
385− let text = if event.text.trim().is_empty() {
386− "(пустое сообщение)"
383+ let keyboard = if rows.is_empty() {
384+ None
387385 } else {
388− event.text.as_str()
386+ Some(InlineKeyboardMarkup::new(rows))
389387 };
390−
391− if rows.is_empty() {
392− bot.send_message(chat, text).await?;
393− } else {
394− bot.send_message(chat, text)
395− .reply_markup(InlineKeyboardMarkup::new(rows))
396− .await?;
397− }
388+ media::send_event_message(bot, chat, &files, &event.text, keyboard).await;
398389 Ok(())
399390}
src/media.rs+107−3
@@ -1,15 +1,119 @@
11use std::path::{Path, PathBuf};
22
33use teloxide::prelude::*;
4−use teloxide::types::InputFile;
4+use teloxide::types::{InlineKeyboardMarkup, InputFile};
5+
6+const CAPTION_LIMIT: usize = 1024;
7+
8+pub async fn send_event_message(
9+ bot: &Bot,
10+ chat: ChatId,
11+ files: &[PathBuf],
12+ text: &str,
13+ keyboard: Option<InlineKeyboardMarkup>,
14+) {
15+ if files.is_empty() {
16+ send_text(bot, chat, text, keyboard).await;
17+ return;
18+ }
19+
20+ let fits_in_caption = text.chars().count() <= CAPTION_LIMIT;
21+ if files.len() == 1 && fits_in_caption {
22+ match send_one_with_caption(bot, chat, &files[0], text, keyboard.clone()).await {
23+ Ok(()) => return,
24+ Err(e) => tracing::warn!(
25+ "failed to send {} with caption: {}; falling back to separate messages",
26+ files[0].display(),
27+ e
28+ ),
29+ }
30+ }
531
6−pub async fn send_files(bot: &Bot, chat: ChatId, files: &[PathBuf]) {
732 for path in files {
833 send_one(bot, chat, path).await;
934 }
35+ send_text(bot, chat, text, keyboard).await;
36+}
37+
38+async fn send_text(bot: &Bot, chat: ChatId, text: &str, keyboard: Option<InlineKeyboardMarkup>) {
39+ let text = if text.trim().is_empty() {
40+ "(пустое сообщение)"
41+ } else {
42+ text
43+ };
44+ let result = match keyboard {
45+ Some(kb) => bot.send_message(chat, text).reply_markup(kb).await.map(|_| ()),
46+ None => bot.send_message(chat, text).await.map(|_| ()),
47+ };
48+ if let Err(e) = result {
49+ tracing::error!("failed to send text to {}: {}", chat, e);
50+ }
51+}
52+
53+async fn send_one_with_caption(
54+ bot: &Bot,
55+ chat: ChatId,
56+ path: &Path,
57+ caption: &str,
58+ keyboard: Option<InlineKeyboardMarkup>,
59+) -> Result<(), teloxide::RequestError> {
60+ let ext = path
61+ .extension()
62+ .and_then(|e| e.to_str())
63+ .unwrap_or("")
64+ .to_ascii_lowercase();
65+
66+ match ext.as_str() {
67+ "jpg" | "jpeg" | "png" | "webp" => {
68+ let req = bot
69+ .send_photo(chat, InputFile::file(path))
70+ .caption(caption);
71+ match keyboard {
72+ Some(kb) => req.reply_markup(kb).await?,
73+ None => req.await?,
74+ };
75+ }
76+ "mp4" => {
77+ let req = bot
78+ .send_video(chat, InputFile::file(path))
79+ .caption(caption);
80+ match keyboard {
81+ Some(kb) => req.reply_markup(kb).await?,
82+ None => req.await?,
83+ };
84+ }
85+ "mp3" | "m4a" => {
86+ let req = bot
87+ .send_audio(chat, InputFile::file(path))
88+ .caption(caption);
89+ match keyboard {
90+ Some(kb) => req.reply_markup(kb).await?,
91+ None => req.await?,
92+ };
93+ }
94+ "ogg" => {
95+ let req = bot
96+ .send_voice(chat, InputFile::file(path))
97+ .caption(caption);
98+ match keyboard {
99+ Some(kb) => req.reply_markup(kb).await?,
100+ None => req.await?,
101+ };
102+ }
103+ _ => {
104+ let req = bot
105+ .send_document(chat, InputFile::file(path))
106+ .caption(caption);
107+ match keyboard {
108+ Some(kb) => req.reply_markup(kb).await?,
109+ None => req.await?,
110+ };
111+ }
112+ }
113+ Ok(())
10114}
11115
12−pub async fn send_one(bot: &Bot, chat: ChatId, path: &Path) {
116+async fn send_one(bot: &Bot, chat: ChatId, path: &Path) {
13117 let ext = path
14118 .extension()
15119 .and_then(|e| e.to_str())