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
+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;