feat(ux): onboard users, expose the chat's settings, name failed posts

`/start` was "Hello!" and `/help` was the bare command list teloxide can
render — no argument syntax, no caption placeholders, no mention that
links only work in private chats. Both now carry that guidance, and the
bot's profile description / short description are set at startup so a
shared link says what the bot does.

`/settings` reports what this chat is configured to do (forward channel,
edit-before-forward, per-site formats, saved templates) to anyone in the
chat — `/bot_dict` is a raw admin-only dump. Templates can be removed
(`/remove_template`, listing the live names on a typo) and the prompt's
keyboard folds 3 per row with a cap: Telegram rejects a keyboard over 100
buttons outright, which would silently drop the whole prompt.

Inline results hand URLs to Telegram, which fetches them without any
site headers — pixiv's pximg.net answers 403 to that, so those items are
skipped instead of shipped broken. `needs_media_headers` answers that
question from the same per-site rule the downloader uses.

Dead-letter and retry notices name the failing post and the cause
(`failure_text`), since "Task failed after retries: task failed after 2
retries" said neither which link it was nor what happened.
This commit is contained in:
2026-09-20 15:57:45 +08:00
parent d6707133cc
commit 5d0acdac01
10 changed files with 448 additions and 31 deletions
+27
View File
@@ -483,6 +483,15 @@ async fn fetch_with_attempts(url: &str, attempts: u32) -> Result<Option<Fetched>
unreachable!("retry loop always returns")
}
/// Whether fetching `url` requires site-specific headers (pixiv's `Referer`
/// for `pximg.net` hotlink protection, see [`Site::media_headers`]). Telegram's
/// own fetch of a media URL sends none of them, so a URL that needs them fails
/// there — callers that hand a URL to Telegram (inline query results) must
/// skip such media instead of shipping a broken item.
pub fn needs_media_headers(url: &str) -> bool {
SITES.iter().any(|site| site.media_headers(url).is_some())
}
/// Applies every site's media-header rule to a download request (pixiv's
/// `Referer` for pximg.net hotlink protection). Sites contribute via their
/// `media_headers(url)` — the central download code carries no per-site logic.
@@ -733,6 +742,24 @@ mod tests {
assert!(matches!(result, Ok(None)), "got {result:?}");
}
#[test]
fn media_headers_are_reported_only_where_telegram_would_fail() {
// pixiv's CDN needs a Referer, which only the bot can send: an inline
// result pointing at it renders broken, so callers skip it.
assert!(needs_media_headers(
"https://i.pximg.net/img-original/img/2024/01/01/00/00/00/1_p0.jpg"
));
// The rest serve direct requests (verified per site in their modules).
for url in [
"https://pbs.twimg.com/media/1.jpg",
"https://cdn.bsky.app/img/1.jpg",
"https://media.misskeyusercontent.jp/io/1.webp",
"https://i0.hdslb.com/bfs/1.jpg",
] {
assert!(!needs_media_headers(url), "{url}");
}
}
#[tokio::test]
async fn disabled_site_is_reported_not_ignored() {
// pixiv is the only token-gated site; with PIXIV_REFRESH_TOKEN set it
+264 -5
View File
@@ -4,6 +4,7 @@
use super::urls::{PostSend, url_media};
use super::{CHAT_STORE, CONFIG, LINK_CACHE, log_key, reply, reply_html};
use crate::ctx::AppContext;
use crate::state::ChatData;
use teloxide::RequestError;
use teloxide::prelude::*;
use teloxide::types::{ChatId, Message, Recipient};
@@ -33,6 +34,10 @@ pub(crate) enum Command {
parse_with = "split"
)]
SetTemplate(String),
#[command(description = "Remove a saved template", parse_with = "split")]
RemoveTemplate(String),
#[command(description = "Show this chat's settings")]
Settings,
#[command(description = "Show chat state (debug; admin only)")]
BotDict,
#[command(
@@ -69,6 +74,95 @@ fn parse_arg_remainder(s: String) -> Result<(String,), ParseError> {
/// `x_media::site::caption_from_fields` substitutes.
const FORMAT_PLACEHOLDERS: [&str; 6] = ["url", "author", "author_url", "title", "content", "tags"];
/// `/start`'s welcome: what the bot is for, where links work, where to look
/// next. The old "Hello!" left a first-time user with nothing.
const START_TEXT: &str = "\
Send me a post link and I'll send back its images, videos and GIFs with the title, author and tags.
Supported: X/Twitter, Pixiv, Bluesky, Misskey (misskey.io), Bilibili.
In a private chat just paste the link. In a group, use inline mode (type @, pick me, then the link).
/help lists every command.";
/// Appended to `/help`'s command list: argument syntax, caption
/// placeholders and the private-chat rule — none of which teloxide's
/// `descriptions()` renders (it prints `/command — description` only).
const HELP_FOOTER: &str = "\
Arguments
/set_forward_channel <@channel or channel id>
/set_template <name> — reply to a message containing [] to save it
/remove_template <name> — see /settings for the saved names
/set_format <site> <format> — '-' restores the built-in format
/test <link> / /debug <link>
Caption placeholders (for /set_format)
{url} {author} {author_url} {title} {content} {tags}
A template's [] is replaced by the post link when forwarding.
Links are handled in private chats only; in a group use inline mode.";
/// Cap on template names echoed by `/settings`: a chat with hundreds of
/// templates must not produce a message Telegram rejects for length.
const MAX_SETTINGS_TEMPLATE_NAMES: usize = 30;
/// Sorted template names: the order `/settings`, `/remove_template` and the
/// prompt's buttons all show.
fn sorted_template_names(data: &ChatData) -> Vec<String> {
let mut names: Vec<String> = data.template.keys().cloned().collect();
names.sort();
names
}
/// `/settings`: what this chat is configured to do, readable by anyone in it
/// (unlike `/bot_dict`, which dumps the raw state and is admin-only).
fn settings_text(data: &ChatData) -> String {
let mut lines = Vec::new();
match data.forward_channel_id {
Some(id) => lines.push(format!("Forward channel: {id}")),
None => lines.push(
"Forward channel: not set (use /set_forward_channel <@channel or id>)".to_string(),
),
}
lines.push(format!(
"Edit before forward: {}",
if data.edit_before_forward {
"on"
} else {
"off"
}
));
let mut formats: Vec<String> = data
.message_format
.iter()
.map(|(site, format)| format!("{site} => {format}"))
.collect();
formats.sort();
lines.push(if formats.is_empty() {
"Caption formats: built-in for every site".to_string()
} else {
format!("Caption formats:\n {}", formats.join("\n "))
});
let names = sorted_template_names(data);
lines.push(match names.len() {
0 => "Templates: none".to_string(),
n => format!(
"Templates ({n}): {}{}",
names
.iter()
.take(MAX_SETTINGS_TEMPLATE_NAMES)
.cloned()
.collect::<Vec<_>>()
.join(", "),
if n > MAX_SETTINGS_TEMPLATE_NAMES {
format!(", +{} more", n - MAX_SETTINGS_TEMPLATE_NAMES)
} else {
String::new()
}
),
});
lines.join("\n")
}
/// The first `{…}` token in a caption format that is not a known placeholder
/// (`None` when all of them are). The renderer replaces exact keys only, so an
/// unknown token would be published verbatim in every caption of that site —
@@ -166,11 +260,17 @@ pub(crate) async fn execute_command(
) -> Result<(), RequestError> {
match command {
Command::Start => {
bot.send_message(message.chat.id, "Hello!").await?;
bot.send_message(message.chat.id, START_TEXT).await?;
}
Command::Help => {
bot.send_message(message.chat.id, Command::descriptions().to_string())
.await?;
// The command list plus the parts teloxide's `descriptions()`
// cannot show: argument syntax, caption placeholders, and where a
// link actually works.
bot.send_message(
message.chat.id,
format!("{}\n\n{}", Command::descriptions(), HELP_FOOTER),
)
.await?;
}
Command::SetForwardChannel(channel) => {
let result = match set_forward_channel_handler(bot, message, channel).await {
@@ -258,6 +358,41 @@ pub(crate) async fn execute_command(
};
reply(bot, message.chat.id.0, message.id, text).await?;
}
Command::RemoveTemplate(name) => {
let chat_id = message.chat.id.0;
let name = name.trim().to_string();
if name.is_empty() {
reply(
bot,
chat_id,
message.id,
"Usage: /remove_template <name> (see /settings for the saved names)",
)
.await?;
return Ok(());
}
let removed = CHAT_STORE
.update(chat_id, |data| data.template.remove(&name).is_some())
.await;
let text = if removed {
format!("Template '{name}' removed.")
} else {
// Name the live templates: a typo would otherwise look like a
// successful delete.
let names = sorted_template_names(&CHAT_STORE.get(chat_id).await);
if names.is_empty() {
format!("No template named '{name}'. None are saved yet.")
} else {
format!("No template named '{name}'. Saved: {}", names.join(", "))
}
};
reply(bot, chat_id, message.id, text).await?;
}
Command::Settings => {
let chat_id = message.chat.id.0;
let data = CHAT_STORE.get(chat_id).await;
reply(bot, chat_id, message.id, settings_text(&data)).await?;
}
Command::BotDict => {
// Debug dump of the chat's persisted state: admin only (it echoes
// forward-channel ids and templates to whoever asks).
@@ -510,12 +645,32 @@ fn plural(n: usize) -> &'static str {
if n == 1 { "y" } else { "ies" }
}
/// Bot profile texts (Bot API `setMyDescription` / `setMyShortDescription`):
/// shown on the bot's profile page and in the share sheet. Without them a
/// shared link says nothing about what the bot does.
const BOT_DESCRIPTION: &str = "\
Send a post link from X/Twitter, Pixiv, Bluesky, Misskey (misskey.io) or Bilibili and get its images, videos and GIFs back with the title, author and tags.
Links are handled in private chats; a group can use inline mode. /help lists every command.";
const BOT_SHORT_DESCRIPTION: &str =
"Post links (X, Pixiv, Bluesky, Misskey, Bilibili) -> media messages";
/// Registers the bot's command list with Telegram so clients show it in the
/// `/` menu (Bot API `setMyCommands`).
/// `/` menu (Bot API `setMyCommands`), plus its profile description texts.
pub async fn register_commands(bot: &Bot) -> Result<(), RequestError> {
let commands = Command::bot_commands();
bot.set_my_commands(commands.clone()).await?;
log::info!("registered {} commands", commands.len());
// Profile texts are cosmetic: a failure (rare) must not abort startup.
if let Err(e) = bot.set_my_description().description(BOT_DESCRIPTION).await {
log::warn!("failed to set the bot description: {e}");
}
if let Err(e) = bot
.set_my_short_description()
.short_description(BOT_SHORT_DESCRIPTION)
.await
{
log::warn!("failed to set the bot short description: {e}");
}
Ok(())
}
@@ -609,7 +764,7 @@ fn debug_report(
#[cfg(test)]
mod tests {
use super::{MAX_DEBUG_REPORT_CHARS, debug_report, unknown_placeholder};
use super::{MAX_DEBUG_REPORT_CHARS, debug_report, settings_text, unknown_placeholder};
use x_media::media::Media;
#[test]
@@ -728,6 +883,110 @@ mod tests {
assert!(report.ends_with('…'), "{report}");
}
#[test]
fn settings_text_reports_the_chat_configuration() {
use crate::state::ChatData;
// A fresh chat: the defaults must be spelled out, including how to set
// the channel (an empty field is not a status).
let empty = settings_text(&ChatData::default());
assert!(empty.contains("Forward channel: not set"), "{empty}");
assert!(empty.contains("/set_forward_channel"), "{empty}");
assert!(empty.contains("Edit before forward: off"), "{empty}");
assert!(empty.contains("built-in for every site"), "{empty}");
assert!(empty.contains("Templates: none"), "{empty}");
let configured = ChatData {
forward_channel_id: Some(-100123),
edit_before_forward: true,
template: [("b", "[]"), ("a", "[]")]
.into_iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
message_format: [("twitter", "{author}: {content}")]
.into_iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
..ChatData::default()
};
let text = settings_text(&configured);
assert!(text.contains("Forward channel: -100123"), "{text}");
assert!(text.contains("Edit before forward: on"), "{text}");
assert!(text.contains("twitter => {author}: {content}"), "{text}");
// Sorted, so the same chat always reports the same thing.
assert!(text.contains("Templates (2): a, b"), "{text}");
}
#[test]
fn help_and_start_cover_what_the_command_list_cannot() {
// The placeholders the renderer substitutes must be the ones the help
// lists: a stale list is worse than none.
for placeholder in super::FORMAT_PLACEHOLDERS {
assert!(
super::HELP_FOOTER.contains(&format!("{{{placeholder}}}")),
"help does not document {{{placeholder}}}"
);
}
// The private-chat rule and the template placeholder semantics are the
// two things users got wrong most often.
assert!(super::HELP_FOOTER.contains("private chats only"));
assert!(super::HELP_FOOTER.contains("[]"));
assert!(super::START_TEXT.contains("inline mode"));
assert!(super::START_TEXT.contains("/help"));
// Both must stay inside Telegram's message limit.
assert!(super::HELP_FOOTER.chars().count() < 2000);
assert!(super::START_TEXT.chars().count() < 2000);
}
#[test]
fn every_command_is_registered_and_parses() {
use teloxide::utils::command::BotCommands;
use super::Command;
let registered: Vec<String> = Command::bot_commands()
.into_iter()
.map(|command| command.command.trim_start_matches('/').to_string())
.collect();
for expected in [
"start",
"help",
"settings",
"set_forward_channel",
"remove_template",
"set_format",
"test",
"debug",
] {
assert!(
registered.iter().any(|name| name == expected),
"{expected} missing from {registered:?}"
);
}
// Telegram caps a command description at 256 chars.
for command in Command::bot_commands() {
assert!(
command.description.chars().count() <= 256,
"{}: description too long",
command.command
);
}
// A command with a `String` argument must parse with its whole
// argument: without `parse_with`, teloxide's default parser rejects
// `/remove_template x` and the command silently falls through to the
// URL flow.
assert!(matches!(
Command::parse("/settings", ""),
Ok(Command::Settings)
));
match Command::parse("/remove_template tpl", "") {
Ok(Command::RemoveTemplate(name)) => assert_eq!(name, "tpl"),
Ok(_) => panic!("/remove_template parsed as another command"),
Err(e) => panic!("parse error: {e}"),
}
}
#[test]
fn unknown_placeholder_finds_typos_only() {
assert_eq!(unknown_placeholder("{author} — {title}"), None);
+9
View File
@@ -146,6 +146,15 @@ async fn answer_inline_query(bot: Bot, query: InlineQuery) -> Result<bool, Reque
);
for (i, media) in fetched.media.iter().enumerate() {
let id = format!("{i}");
// Telegram fetches an inline result's URL itself and cannot
// send site-specific headers, so hotlink-protected media
// (pixiv's pximg.net) would render as a broken file there.
// Locally produced media (ugoira MP4, bsky remux) is a local
// path and does not parse as a URL at all — same skip.
if x_media::site::needs_media_headers(media.url()) {
log::debug!("inline: skipping hotlink-protected media {id}");
continue;
}
let Some(url) = url::Url::parse(media.url()).ok() else {
continue;
};
+6 -1
View File
@@ -192,11 +192,16 @@ async fn dispatch_send(
log_key(url)
);
send::enqueue_retry(ctx.task_queue, *task, delay_seconds).await;
// Name the post and the wait: "queued for retry" alone left the
// user guessing which link it was and how long the wait is.
let _ = reply(
ctx.sender,
chat_id,
reply_to,
"Send failed. Task queued for retry.",
format!(
"Send failed for {} — retrying in {delay_seconds:.0}s.",
log_key(url)
),
)
.await;
}
+10 -2
View File
@@ -342,8 +342,16 @@ impl QueueWorker {
payload,
}) => {
if row.attempts as u32 >= MAX_RETRIES {
let message = format!("task failed after {MAX_RETRIES} retries");
log::error!("dead-lettering {}: {message}", row.id);
// The queue keeps only the payload, not the last error, so
// the cause of an exhausted retry is just that: exhausted.
// (The dead-letter message is read by the user, so it must
// not restate its own wrapper — see `failure_text`.)
let message = "retries exhausted".to_string();
log::error!(
"dead-lettering {}: {message} after {} attempt(s)",
row.id,
row.attempts + 1
);
self.delete_row(&row.id).await;
(self.dead_letter)(payload, message).await;
} else {
+60
View File
@@ -816,6 +816,66 @@ mod tests {
);
}
#[test]
fn edit_markup_folds_and_caps_the_template_buttons() {
// Telegram rejects a keyboard over 100 buttons, which would drop the
// whole prompt; the cap keeps it well under that.
let templates: HashMap<String, String> = (0..200)
.map(|i| (format!("t{i:03}"), "[]".to_string()))
.collect();
let keyboard = build_edit_markup(&templates);
let buttons: usize = keyboard.inline_keyboard.iter().map(Vec::len).sum();
assert!(
buttons <= 100,
"a keyboard Telegram rejects would lose the prompt: {buttons}"
);
assert_eq!(
buttons,
super::post_send::MAX_TEMPLATE_BUTTONS + 2,
"the cap plus the confirm/skip pair"
);
// Names are folded, not one per row.
assert_eq!(keyboard.inline_keyboard[0].len(), 3);
assert_eq!(keyboard.inline_keyboard.last().unwrap().len(), 2);
assert_eq!(super::post_send::hidden_template_count(&templates), 140);
// Under the cap nothing is hidden and every name gets a button.
let few: HashMap<String, String> = (0..4)
.map(|i| (format!("t{i}"), "[]".to_string()))
.collect();
assert_eq!(super::post_send::hidden_template_count(&few), 0);
assert_eq!(
build_edit_markup(&few)
.inline_keyboard
.iter()
.map(Vec::len)
.sum::<usize>(),
6
);
}
#[test]
fn failure_text_names_the_post_and_the_cause() {
// A send failure names the post (the cache key) and the cause, so the
// user knows which of their links died.
let task = sequence_task("https://x.com/u/status/1");
let text = super::post_send::failure_text(Some(&task), "retries exhausted");
assert!(text.contains("twitter:1"), "{text}");
assert!(text.contains("retries exhausted"), "{text}");
// A channel-forward failure has no source URL: it must not claim a
// post failed.
let forward = Task::ForwardMessages {
from_chat_id: 1,
to_chat_id: 2,
message_ids: vec![1],
notify_chat_id: None,
notify_message_id: None,
};
let text = super::post_send::failure_text(Some(&forward), "chat not found");
assert!(text.starts_with("Forward failed permanently"), "{text}");
assert!(text.contains("chat not found"), "{text}");
}
#[test]
fn edit_prompt_text_states_the_ttl_and_the_confirm_requirement() {
use std::time::Duration;
+53 -14
View File
@@ -132,18 +132,31 @@ fn coarsest_unit(ttl: std::time::Duration) -> String {
}
}
/// Template buttons (one per row), then the confirm/skip pair. Sorted by name:
/// the templates live in a `HashMap`, so an unsorted walk would reshuffle the
/// Templates per keyboard row. Telegram rejects a keyboard with more than 100
/// buttons *outright*, which would silently drop the whole prompt, so the
/// names are folded and capped rather than listed one per row.
pub(super) const TEMPLATE_BUTTONS_PER_ROW: usize = 3;
/// Hard cap on template buttons; the prompt text names the ones not shown.
pub(super) const MAX_TEMPLATE_BUTTONS: usize = 60;
/// Template buttons ([`TEMPLATE_BUTTONS_PER_ROW`] per row, at most
/// [`MAX_TEMPLATE_BUTTONS`]), then the confirm/skip pair. Sorted by name: the
/// templates live in a `HashMap`, so an unsorted walk would reshuffle the
/// buttons between prompts.
pub(super) fn build_edit_markup(templates: &HashMap<String, String>) -> InlineKeyboardMarkup {
let mut names: Vec<&String> = templates.keys().collect();
names.sort();
let mut rows = Vec::with_capacity(names.len() + 1);
for name in names {
rows.push(vec![InlineKeyboardButton::callback(
name.clone(),
format!("template|{name}"),
)]);
let shown = names.len().min(MAX_TEMPLATE_BUTTONS);
let mut rows = Vec::with_capacity(shown / TEMPLATE_BUTTONS_PER_ROW + 2);
for chunk in names[..shown].chunks(TEMPLATE_BUTTONS_PER_ROW) {
rows.push(
chunk
.iter()
.map(|name| {
InlineKeyboardButton::callback(name.as_str(), format!("template|{name}"))
})
.collect(),
);
}
// Skip exists because the prompt holds the forward hostage until Confirm:
// without it the only escape was deleting the message and waiting out the
@@ -155,6 +168,11 @@ pub(super) fn build_edit_markup(templates: &HashMap<String, String>) -> InlineKe
InlineKeyboardMarkup::new(rows)
}
/// How many templates the markup could not fit, for the prompt text.
pub(super) fn hidden_template_count(templates: &HashMap<String, String>) -> usize {
templates.len().saturating_sub(MAX_TEMPLATE_BUTTONS)
}
/// Notifies a chat about a dead-lettered task (skips when `notify_chat_id` is
/// absent).
pub(super) async fn notify_failure(
@@ -217,12 +235,21 @@ pub(crate) async fn post_send_actions(ctx: &AppContext<'_>, task: &Task, message
};
if edit_before_forward {
let keyboard = build_edit_markup(&ctx.chat_store.get(chat_id).await.template);
let templates = ctx.chat_store.get(chat_id).await.template;
let keyboard = build_edit_markup(&templates);
let mut text = edit_prompt_text(ctx.config.edit_message_ttl);
let hidden = hidden_template_count(&templates);
if hidden > 0 {
// The keyboard is capped; say so instead of silently hiding them.
text.push_str(&format!(
"\n({hidden} more templates not shown — /remove_template to prune.)"
));
}
let prompt = ctx
.sender
.send_message(
ChatId(chat_id),
edit_prompt_text(ctx.config.edit_message_ttl),
text,
Some(MessageId(reply_to as i32)),
Some(keyboard),
)
@@ -279,7 +306,7 @@ pub(crate) async fn post_send_actions(ctx: &AppContext<'_>, task: &Task, message
ctx.sender,
notify_chat_id,
notify_message_id,
&format!("Task failed after retries: {message}"),
&failure_text(None, &message),
)
.await;
}
@@ -373,6 +400,17 @@ async fn send_media_or_animation(ctx: &AppContext<'_>, task: &Task) -> Result<Ve
}
}
/// User-facing text for a task that will never run again: which link died and
/// why. The raw error alone left the user guessing which post it was about.
pub(super) fn failure_text(task: Option<&Task>, message: &str) -> String {
match task.and_then(|task| task.source_url()).map(log_key) {
Some(key) => format!("Send failed permanently for {key}: {message}"),
// `ForwardMessages` carries no source URL: that failure is about the
// channel copy, not about a post.
None => format!("Forward failed permanently: {message}"),
}
}
/// Dead-letter callback wired to the queue in main: settles the task and
/// notifies its chat.
pub(crate) async fn dead_letter_notify(
@@ -383,8 +421,9 @@ pub(crate) async fn dead_letter_notify(
// A dead-lettered task never runs again, and the queue dead-letters retry
// exhaustion itself (the handler is not called again), so this is the only
// place that sees the final payload.
if let Ok(task) = serde_json::from_value::<Task>(payload.clone()) {
settle_task(ctx, &task, Settled::Failed).await;
let task = serde_json::from_value::<Task>(payload.clone()).ok();
if let Some(task) = &task {
settle_task(ctx, task, Settled::Failed).await;
}
let notify_chat_id = payload.get("notify_chat_id").and_then(|v| v.as_i64());
let notify_message_id = payload.get("notify_message_id").and_then(|v| v.as_i64());
@@ -392,7 +431,7 @@ pub(crate) async fn dead_letter_notify(
ctx.sender,
notify_chat_id,
notify_message_id,
&format!("Task failed after retries: {message}"),
&failure_text(task.as_ref(), &message),
)
.await;
}