mirror of
https://github.com/TheFunny/TelegramTwitterMediaBot.git
synced 2026-09-23 23:32:05 +00:00
feat(ux): answer every link, name fetch failures, keep the chat action alive
Four ways a user could get silence are closed: a registered-but-disabled
site (pixiv without a token) now answers instead of being dropped as an
unsupported link, `/test` on such a link replies instead of doing nothing,
a supported link posted in a group gets a one-line hint (channels stay
silent), and fetch failures name their cause — gone / withheld / source
risk control / site disabled / source down — instead of one generic
sentence. `FetchError::Disabled` carries the "matched but switched off"
answer, which `find_site` used to fold into `Ok(None)`.
A withheld tweet no longer degrades to "no media": without
`TWITTER_AUTH_TOKEN` it stays `Sensitive` so the reply says the media is
age-restricted, and a failed authenticated fallback propagates its own
class instead of masquerading as an empty post (`empty_fetched` is gone).
Long jobs stop looking stalled: `run_with_chat_action` re-sends the chat
action every 4s while the pipeline is pending and the hint switches from
typing to send-photo/video once the media kinds are known. Media groups
go from 9 to Telegram's 10.
`/set_format` rejects unknown `{…}` placeholders (a typo used to be
published verbatim in every caption) and resets with `-`. The
edit-before-forward prompt states its TTL and that Confirm is required,
gains a Skip button, and is rewritten in place to "expired" by the sweep
— an edit, never a new message, so a background timer cannot wake a chat.
This commit is contained in:
@@ -14,6 +14,8 @@ use teloxide::types::{CallbackQuery, CallbackQueryId, MessageId};
|
||||
|
||||
/// The `"forward"` button's data.
|
||||
const FORWARD: &str = "forward";
|
||||
/// The `"skip"` button's data: drop the prompt without forwarding.
|
||||
const SKIP: &str = "skip";
|
||||
/// Prefix of a template button's data: `"template|<name>"`.
|
||||
const TEMPLATE_PREFIX: &str = "template|";
|
||||
|
||||
@@ -70,6 +72,29 @@ async fn handle_callback(
|
||||
}
|
||||
|
||||
log::info!("callback from {chat_id} on prompt {prompt_message_id}: {data}");
|
||||
if data == SKIP {
|
||||
// Skip works with or without a forward channel: it is the explicit
|
||||
// "do not forward this" answer, and it drops the record so the forward
|
||||
// can never happen later.
|
||||
log::info!("edit-before-forward prompt {prompt_message_id} skipped");
|
||||
ctx.chat_store
|
||||
.update(chat_id, |data| {
|
||||
data.edit_message.remove(&prompt_message_id);
|
||||
})
|
||||
.await;
|
||||
let _ = ctx
|
||||
.sender
|
||||
.delete_message(ChatId(chat_id), MessageId(prompt_message_id as i32))
|
||||
.await;
|
||||
let _ = ctx
|
||||
.sender
|
||||
.answer_callback_query(
|
||||
callback_query_id,
|
||||
Some("Skipped — nothing was forwarded.".to_string()),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
if data == FORWARD {
|
||||
match chat_data.forward_channel_id {
|
||||
Some(channel_id) => {
|
||||
@@ -244,6 +269,31 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skip_drops_the_prompt_without_forwarding() {
|
||||
// "skip" needs no forward channel and no scripted outcomes: it deletes
|
||||
// the prompt and drops the record, so no forward can ever happen.
|
||||
let sender = MockSender::scripted(vec![], api_error);
|
||||
let stores = TestStores::new();
|
||||
let ctx = stores.ctx(&sender);
|
||||
seed_prompt(&ctx, crate::db::unix_now()).await;
|
||||
|
||||
handle_callback(&ctx, callback_id(), 1, PROMPT_ID, "skip").await;
|
||||
|
||||
assert_eq!(
|
||||
sender.calls(),
|
||||
vec!["delete_message", "answer_callback_query"]
|
||||
);
|
||||
assert_eq!(
|
||||
sender.answers(),
|
||||
vec![Some("Skipped — nothing was forwarded.".to_string())]
|
||||
);
|
||||
assert!(
|
||||
ctx.chat_store.get(1).await.edit_message.is_empty(),
|
||||
"a skipped prompt must drop its record"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn forward_without_a_channel_is_reported() {
|
||||
let sender = MockSender::scripted(vec![], api_error);
|
||||
|
||||
@@ -35,7 +35,10 @@ pub(crate) enum Command {
|
||||
SetTemplate(String),
|
||||
#[command(description = "Show chat state (debug; admin only)")]
|
||||
BotDict,
|
||||
#[command(description = "Set site caption format", parse_with = "split")]
|
||||
#[command(
|
||||
description = "Set site caption format (- to reset)",
|
||||
parse_with = "split"
|
||||
)]
|
||||
SetFormat(String),
|
||||
#[command(
|
||||
description = "Clear link cache (admin; optional URL, else all)",
|
||||
@@ -62,6 +65,29 @@ fn parse_arg_remainder(s: String) -> Result<(String,), ParseError> {
|
||||
Ok((s.trim().to_string(),))
|
||||
}
|
||||
|
||||
/// Placeholders `/set_format` accepts, mirroring what
|
||||
/// `x_media::site::caption_from_fields` substitutes.
|
||||
const FORMAT_PLACEHOLDERS: [&str; 6] = ["url", "author", "author_url", "title", "content", "tags"];
|
||||
|
||||
/// 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 —
|
||||
/// caught here instead.
|
||||
fn unknown_placeholder(format: &str) -> Option<&str> {
|
||||
let mut rest = format;
|
||||
while let Some(open) = rest.find('{') {
|
||||
let after = &rest[open + 1..];
|
||||
// An unclosed `{` is not a placeholder token at all.
|
||||
let close = after.find('}')?;
|
||||
let token = &after[..close];
|
||||
if !FORMAT_PLACEHOLDERS.contains(&token) {
|
||||
return Some(token);
|
||||
}
|
||||
rest = &after[close + 1..];
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
enum SetForwardChannelError {
|
||||
EmptyParameter,
|
||||
NotChannel,
|
||||
@@ -284,12 +310,56 @@ pub(crate) async fn execute_command(
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
// `-` resets to the site's built-in caption: without it a chat that
|
||||
// set a format once could never get back to the default (the
|
||||
// built-in format string is not something a user can retype).
|
||||
if format == "-" {
|
||||
CHAT_STORE
|
||||
.update(chat_id, |data| {
|
||||
data.message_format.remove(site);
|
||||
})
|
||||
.await;
|
||||
reply(
|
||||
bot,
|
||||
message.chat.id.0,
|
||||
message.id,
|
||||
"Format reset to the built-in one.",
|
||||
)
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
// A typo like {titel} would otherwise be rendered literally into
|
||||
// every caption of that site (the renderer only substitutes the
|
||||
// exact keys), which is invisible until a post arrives.
|
||||
if let Some(token) = unknown_placeholder(&format) {
|
||||
reply(
|
||||
bot,
|
||||
message.chat.id.0,
|
||||
message.id,
|
||||
format!(
|
||||
"Unknown placeholder {{{token}}}. Available: {}",
|
||||
FORMAT_PLACEHOLDERS
|
||||
.iter()
|
||||
.map(|name| format!("{{{name}}}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
CHAT_STORE
|
||||
.update(chat_id, |data| {
|
||||
data.message_format.insert(site.to_string(), format);
|
||||
})
|
||||
.await;
|
||||
reply(bot, message.chat.id.0, message.id, "Format set.").await?;
|
||||
reply(
|
||||
bot,
|
||||
message.chat.id.0,
|
||||
message.id,
|
||||
"Format set. Use /debug <link> to preview the caption.",
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Command::ClearCache(arg) => {
|
||||
let sender_id = message
|
||||
@@ -539,7 +609,7 @@ fn debug_report(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{MAX_DEBUG_REPORT_CHARS, debug_report};
|
||||
use super::{MAX_DEBUG_REPORT_CHARS, debug_report, unknown_placeholder};
|
||||
use x_media::media::Media;
|
||||
|
||||
#[test]
|
||||
@@ -657,4 +727,22 @@ mod tests {
|
||||
assert!(report.chars().count() <= MAX_DEBUG_REPORT_CHARS, "{report}");
|
||||
assert!(report.ends_with('…'), "{report}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_placeholder_finds_typos_only() {
|
||||
assert_eq!(unknown_placeholder("{author} — {title}"), None);
|
||||
// Every key the renderer substitutes must pass, in any combination.
|
||||
assert_eq!(
|
||||
unknown_placeholder("{url}{author}{author_url}{title}{content}{tags}"),
|
||||
None
|
||||
);
|
||||
// Plain text and braces Telegram renders literally are not tokens.
|
||||
assert_eq!(unknown_placeholder("no placeholders here"), None);
|
||||
assert_eq!(unknown_placeholder("{unclosed"), None);
|
||||
|
||||
assert_eq!(unknown_placeholder("{titel}"), Some("titel"));
|
||||
assert_eq!(unknown_placeholder("{title} {Content}"), Some("Content"));
|
||||
// A typo after a valid token is still found.
|
||||
assert_eq!(unknown_placeholder("{url} {tag}"), Some("tag"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,9 @@ use crate::media_sender::MediaSender;
|
||||
use commands::{Command, execute_command};
|
||||
use teloxide::RequestError;
|
||||
use teloxide::prelude::*;
|
||||
use teloxide::types::{ChatId, ChatKind, Message, MessageId, ParseMode, ReplyParameters};
|
||||
use teloxide::types::{
|
||||
ChatId, ChatKind, Message, MessageId, ParseMode, PublicChatKind, ReplyParameters,
|
||||
};
|
||||
use teloxide::utils::command::BotCommands;
|
||||
use urls::{URL_JOBS, extract_urls};
|
||||
|
||||
@@ -175,10 +177,38 @@ pub async fn message_handler(bot: Bot, message: Message) -> Result<(), RequestEr
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if is_group(&message.chat.kind)
|
||||
&& extract_urls(&message)
|
||||
.iter()
|
||||
.any(|url| x_media::site::cache_key(url).is_some())
|
||||
{
|
||||
// A supported link in a group used to be dropped in silence, which
|
||||
// reads as a broken bot (the command menu is registered globally, so
|
||||
// the expectation is there). Unsupported links stay ignored; the hint
|
||||
// names the two paths that do work. Channels are excluded — the reply
|
||||
// would be posted into the channel itself.
|
||||
let _ = reply(&bot, message.chat.id.0, message.id, GROUP_LINK_HINT).await;
|
||||
}
|
||||
respond(())
|
||||
}
|
||||
|
||||
/// Answer for a link posted where the pipeline does not run (a group): links
|
||||
/// are private-chat only, inline mode is the group path.
|
||||
const GROUP_LINK_HINT: &str =
|
||||
"Links are handled in private chat only — send me this link there, or use inline mode here.";
|
||||
|
||||
/// Groups and supergroups, as opposed to private chats and channels.
|
||||
fn is_group(kind: &ChatKind) -> bool {
|
||||
matches!(
|
||||
kind,
|
||||
ChatKind::Public(chat)
|
||||
if matches!(
|
||||
chat.kind,
|
||||
PublicChatKind::Group | PublicChatKind::Supergroup(_)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -271,4 +301,37 @@ mod tests {
|
||||
assert!(!edit_message_handler(&ctx, 1, PROMPT_ID, "hello").await);
|
||||
assert!(sender.calls().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_link_hint_is_for_groups_only() {
|
||||
use teloxide::types::{ChatPrivate, ChatPublic, PublicChatChannel, PublicChatSupergroup};
|
||||
|
||||
let group = ChatKind::Public(ChatPublic {
|
||||
title: None,
|
||||
kind: PublicChatKind::Group,
|
||||
});
|
||||
let supergroup = ChatKind::Public(ChatPublic {
|
||||
title: None,
|
||||
kind: PublicChatKind::Supergroup(PublicChatSupergroup {
|
||||
username: None,
|
||||
is_forum: false,
|
||||
}),
|
||||
});
|
||||
// A channel must stay silent: the hint reply would be posted into the
|
||||
// channel itself.
|
||||
let channel = ChatKind::Public(ChatPublic {
|
||||
title: None,
|
||||
kind: PublicChatKind::Channel(PublicChatChannel { username: None }),
|
||||
});
|
||||
let private = ChatKind::Private(ChatPrivate {
|
||||
username: None,
|
||||
first_name: None,
|
||||
last_name: None,
|
||||
});
|
||||
|
||||
assert!(is_group(&group));
|
||||
assert!(is_group(&supergroup));
|
||||
assert!(!is_group(&channel));
|
||||
assert!(!is_group(&private));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
use super::{log_key, reply};
|
||||
use crate::ctx::{AppContext, CONTEXT};
|
||||
use crate::link_cache::{CachedMediaKind, CachedPost};
|
||||
use crate::media_sender::MediaSender;
|
||||
use crate::send::{self, MediaItemPayload, Task};
|
||||
use crate::state::ChatData;
|
||||
use std::collections::HashSet;
|
||||
use std::future::Future;
|
||||
use std::sync::LazyLock;
|
||||
use teloxide::types::{ChatAction, ChatId, Message, MessageEntityKind, MessageId};
|
||||
use x_media::media::Media;
|
||||
@@ -285,6 +287,11 @@ fn build_send_task(
|
||||
/// workers pass [`PostSend::FromChat`], the `/test` command
|
||||
/// [`PostSend::Suppressed`]. Everything else (cache write, retry enqueue,
|
||||
/// dead-letter notification) is identical.
|
||||
///
|
||||
/// Wraps [`url_media_inner`] with the chat-action keep-alive: Telegram expires
|
||||
/// an action indicator after ~5s, while a fetch (ugoira encode, HLS remux) plus
|
||||
/// a download-and-reupload fallback routinely takes longer — without the
|
||||
/// refresh the chat shows nothing and the bot reads as stalled.
|
||||
pub(crate) async fn url_media(
|
||||
ctx: &AppContext<'_>,
|
||||
chat_id: i64,
|
||||
@@ -292,14 +299,136 @@ pub(crate) async fn url_media(
|
||||
url: &str,
|
||||
post_send: PostSend,
|
||||
) {
|
||||
let reply_to = MessageId(reply_to_message_id as i32);
|
||||
if let Err(e) = ctx
|
||||
.sender
|
||||
.send_chat_action(ChatId(chat_id), ChatAction::Typing)
|
||||
.await
|
||||
{
|
||||
// Shared with the pipeline: once the media types are known the indicator
|
||||
// switches from "typing" to "sending photo/video".
|
||||
let hint = parking_lot::Mutex::new(ActionHint::Typing);
|
||||
run_with_chat_action(
|
||||
ctx.sender,
|
||||
chat_id,
|
||||
&hint,
|
||||
url_media_inner(ctx, chat_id, reply_to_message_id, url, post_send, &hint),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Runs `pipeline` while keeping the chat's action indicator alive: Telegram
|
||||
/// expires an action after ~5s, while a fetch (ugoira encode, HLS remux) plus a
|
||||
/// download-and-reupload fallback routinely takes longer. The pipeline updates
|
||||
/// `hint` when it knows what it is sending.
|
||||
async fn run_with_chat_action<F: Future<Output = ()>>(
|
||||
sender: &dyn MediaSender,
|
||||
chat_id: i64,
|
||||
hint: &parking_lot::Mutex<ActionHint>,
|
||||
pipeline: F,
|
||||
) {
|
||||
// The guard is released before the await: a parking_lot guard held across
|
||||
// it makes the future !Send, and the URL workers spawn these.
|
||||
let action = hint.lock().action();
|
||||
if let Err(e) = sender.send_chat_action(ChatId(chat_id), action).await {
|
||||
log::error!("send_chat_action failed: {e}");
|
||||
}
|
||||
tokio::pin!(pipeline);
|
||||
loop {
|
||||
tokio::select! {
|
||||
// `biased` polls the pipeline first, so a finished pipeline returns
|
||||
// without ever arming the refresh timer (no stray actions).
|
||||
biased;
|
||||
() = &mut pipeline => return,
|
||||
() = tokio::time::sleep(ACTION_REFRESH) => {
|
||||
let action = hint.lock().action();
|
||||
if let Err(e) = sender.send_chat_action(ChatId(chat_id), action).await {
|
||||
log::error!("send_chat_action failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How often the chat-action indicator is refreshed while a pipeline runs.
|
||||
/// Telegram's indicator lasts ~5s; refreshing slightly inside that keeps it
|
||||
/// on-screen continuously.
|
||||
const ACTION_REFRESH: std::time::Duration = std::time::Duration::from_secs(4);
|
||||
|
||||
/// What the chat action should say. Unknown before the fetch, so the pipeline
|
||||
/// starts with `Typing` and switches as soon as the media types are known.
|
||||
#[derive(Clone, Copy)]
|
||||
enum ActionHint {
|
||||
Typing,
|
||||
Photo,
|
||||
Video,
|
||||
}
|
||||
|
||||
impl ActionHint {
|
||||
/// Photos make Telegram label the send "sending photo"; video/animation
|
||||
/// only payloads get "sending video". A mixed post takes the photo label
|
||||
/// (the group's first item is always a photo, see `photos_first`).
|
||||
fn for_items(items: &[MediaItemPayload]) -> Self {
|
||||
if items
|
||||
.iter()
|
||||
.any(|item| matches!(item, MediaItemPayload::Photo { .. }))
|
||||
{
|
||||
Self::Photo
|
||||
} else {
|
||||
Self::Video
|
||||
}
|
||||
}
|
||||
|
||||
fn action(self) -> ChatAction {
|
||||
match self {
|
||||
Self::Typing => ChatAction::Typing,
|
||||
Self::Photo => ChatAction::UploadPhoto,
|
||||
Self::Video => ChatAction::UploadVideo,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// User-facing text for a failed fetch. The [`FetchError`] class is what tells
|
||||
/// the user whether the post is gone, withheld or the source is refusing
|
||||
/// requests; a single generic sentence threw that away.
|
||||
fn fetch_error_message(err: &x_media::site::FetchError) -> String {
|
||||
use x_media::site::FetchError;
|
||||
match err {
|
||||
FetchError::NotFound => "Post not found (deleted, private or unavailable).".to_string(),
|
||||
FetchError::Sensitive => concat!(
|
||||
"This post's media is withheld (age-restricted). ",
|
||||
"The bot owner must set TWITTER_AUTH_TOKEN to fetch it."
|
||||
)
|
||||
.to_string(),
|
||||
FetchError::Blocked => {
|
||||
"The source site refused the request (risk control). Try again later.".to_string()
|
||||
}
|
||||
FetchError::Disabled { site } => {
|
||||
format!("{} support is disabled on this bot.", site_title(site))
|
||||
}
|
||||
FetchError::Transient(_) | FetchError::Http(_) => {
|
||||
"The source site is unavailable right now (tried 3 times). Try again later.".to_string()
|
||||
}
|
||||
// Parse/shape surprises, pixiv auth details, oversized media: nothing
|
||||
// actionable for the user beyond "this did not work".
|
||||
_ => "Failed to fetch media from this link.".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Site ids are lowercase ASCII (`pixiv`); user-facing text capitalizes the
|
||||
/// first letter.
|
||||
fn site_title(site: &str) -> String {
|
||||
let mut chars = site.chars();
|
||||
match chars.next() {
|
||||
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn url_media_inner(
|
||||
ctx: &AppContext<'_>,
|
||||
chat_id: i64,
|
||||
reply_to_message_id: i64,
|
||||
url: &str,
|
||||
post_send: PostSend,
|
||||
hint: &parking_lot::Mutex<ActionHint>,
|
||||
) {
|
||||
let reply_to = MessageId(reply_to_message_id as i32);
|
||||
|
||||
// Link cache: a post sent before is re-sent from Telegram file ids —
|
||||
// no source-site request, no download, no upload. Keyed by the
|
||||
@@ -355,6 +484,9 @@ pub(crate) async fn url_media(
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
// The indicator switches to "sending photo/video" once the kinds are
|
||||
// known; `items` is moved into the task below.
|
||||
*hint.lock() = ActionHint::for_items(&items);
|
||||
let task = build_send_task(
|
||||
&chat_data,
|
||||
chat_id,
|
||||
@@ -378,13 +510,7 @@ pub(crate) async fn url_media(
|
||||
// Retries exhausted: notify the user (Rust-only requirement 3).
|
||||
Err(e) => {
|
||||
log::error!("fetch {url}: {e}");
|
||||
let _ = reply(
|
||||
ctx.sender,
|
||||
chat_id,
|
||||
reply_to,
|
||||
"Failed to fetch media from this link.",
|
||||
)
|
||||
.await;
|
||||
let _ = reply(ctx.sender, chat_id, reply_to, fetch_error_message(&e)).await;
|
||||
}
|
||||
Ok(Some(mut fetched)) => {
|
||||
if fetched.media.is_empty() {
|
||||
@@ -426,6 +552,9 @@ pub(crate) async fn url_media(
|
||||
.iter()
|
||||
.map(|media| media_to_payload(media, fetched.sensitive))
|
||||
.collect();
|
||||
// The indicator switches to "sending photo/video" once the kinds
|
||||
// are known; `items` is moved into the task below.
|
||||
*hint.lock() = ActionHint::for_items(&items);
|
||||
let task = build_send_task(
|
||||
&chat_data,
|
||||
chat_id,
|
||||
@@ -695,4 +824,89 @@ mod tests {
|
||||
// Dead-letter notification still reaches the chat that asked.
|
||||
assert_eq!(notify_chat_id, Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_errors_map_to_distinct_user_messages() {
|
||||
use x_media::site::FetchError;
|
||||
|
||||
let disabled = fetch_error_message(&FetchError::Disabled { site: "pixiv" });
|
||||
assert_eq!(disabled, "Pixiv support is disabled on this bot.");
|
||||
assert_eq!(
|
||||
fetch_error_message(&FetchError::NotFound),
|
||||
"Post not found (deleted, private or unavailable)."
|
||||
);
|
||||
let sensitive = fetch_error_message(&FetchError::Sensitive);
|
||||
assert!(sensitive.contains("TWITTER_AUTH_TOKEN"), "{sensitive}");
|
||||
let blocked = fetch_error_message(&FetchError::Blocked);
|
||||
assert!(blocked.contains("refused"), "{blocked}");
|
||||
|
||||
// Each class that has something to say must differ from the generic
|
||||
// fallback — one generic sentence for everything is what this fixes.
|
||||
let generic = fetch_error_message(&FetchError::TooLarge);
|
||||
for text in [disabled, sensitive, blocked] {
|
||||
assert_ne!(text, generic);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn action_hint_follows_the_media_kind() {
|
||||
use MediaItemPayload::{Animation, Photo, Video};
|
||||
|
||||
let photo = || Photo {
|
||||
media: "https://p/1.jpg".into(),
|
||||
has_spoiler: false,
|
||||
fallback_url: None,
|
||||
file_id: false,
|
||||
};
|
||||
let video = || Video {
|
||||
media: "https://v/1.mp4".into(),
|
||||
has_spoiler: false,
|
||||
thumbnail: None,
|
||||
fallback_url: None,
|
||||
file_id: false,
|
||||
};
|
||||
|
||||
// Unknown before the fetch: the pipeline starts on "typing".
|
||||
assert!(matches!(ActionHint::Typing.action(), ChatAction::Typing));
|
||||
assert!(matches!(
|
||||
ActionHint::for_items(&[photo()]).action(),
|
||||
ChatAction::UploadPhoto
|
||||
));
|
||||
assert!(matches!(
|
||||
ActionHint::for_items(&[
|
||||
video(),
|
||||
Animation {
|
||||
media: "https://v/2.mp4".into(),
|
||||
has_spoiler: false,
|
||||
file_id: false,
|
||||
}
|
||||
])
|
||||
.action(),
|
||||
ChatAction::UploadVideo
|
||||
));
|
||||
// A mixed post takes the photo label: `photos_first` always leads with
|
||||
// a photo, which is what Telegram shows.
|
||||
assert!(matches!(
|
||||
ActionHint::for_items(&[video(), photo()]).action(),
|
||||
ChatAction::UploadPhoto
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn a_long_pipeline_keeps_the_chat_action_alive() {
|
||||
let sender = MockSender::scripted(vec![], permanent_error);
|
||||
let hint = parking_lot::Mutex::new(ActionHint::Typing);
|
||||
// Three refresh windows of work: Telegram would have dropped the
|
||||
// indicator twice without the keep-alive.
|
||||
let pipeline = async { tokio::time::sleep(ACTION_REFRESH * 3).await };
|
||||
|
||||
run_with_chat_action(&sender, 1, &hint, pipeline).await;
|
||||
|
||||
let actions = sender
|
||||
.calls()
|
||||
.iter()
|
||||
.filter(|call| **call == "send_chat_action")
|
||||
.count();
|
||||
assert_eq!(actions, 3, "expected the initial action plus two refreshes");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ use dotenv::dotenv;
|
||||
use teloxide::dptree::endpoint;
|
||||
use teloxide::prelude::*;
|
||||
use teloxide::stop::StopToken;
|
||||
use teloxide::types::{ChatId, InputFile, MessageId};
|
||||
use teloxide::types::{ChatId, InlineKeyboardMarkup, InputFile, MessageId};
|
||||
use teloxide::update_listeners::{self, UpdateListener, webhooks};
|
||||
use tokio::sync::watch;
|
||||
use x_media::site;
|
||||
@@ -119,13 +119,19 @@ async fn main() {
|
||||
log::debug!("rate limiter: dropped {idle_limiters} idle bucket(s)");
|
||||
}
|
||||
for (chat_id, prompt_message_id) in removed {
|
||||
// If the prompt was already deleted, this fails with a
|
||||
// 400 "message to edit not found" — log and ignore.
|
||||
// Rewritten in place, not announced: the sweep is a
|
||||
// background timer, and a fresh message would wake the chat
|
||||
// up to a full TTL later about a prompt the user already
|
||||
// walked away from. The edit drops the buttons too. If the
|
||||
// prompt was already deleted this fails with a 400
|
||||
// "message to edit not found" — log and ignore.
|
||||
if let Err(e) = bot
|
||||
.edit_message_reply_markup(
|
||||
.edit_message_text(
|
||||
ChatId(chat_id),
|
||||
MessageId(prompt_message_id as i32),
|
||||
send::EDIT_PROMPT_EXPIRED_TEXT,
|
||||
)
|
||||
.reply_markup(InlineKeyboardMarkup::default())
|
||||
.await
|
||||
{
|
||||
log::info!("edit-expiry sweep: prompt message gone: {e}");
|
||||
|
||||
@@ -28,8 +28,8 @@ use upload::{FallbackError, PreparedItem, prepare_upload_item, send_batch_via_up
|
||||
// The crate-facing API of this module lives in its submodules; re-export the
|
||||
// parts other modules use so call sites stay `send::x`.
|
||||
pub(crate) use post_send::{
|
||||
KEEP_ALIVE, Settled, dead_letter_notify, enqueue_retry, handle_task, post_send_actions,
|
||||
settle_task,
|
||||
EDIT_PROMPT_EXPIRED_TEXT, KEEP_ALIVE, Settled, dead_letter_notify, enqueue_retry, handle_task,
|
||||
post_send_actions, settle_task,
|
||||
};
|
||||
|
||||
/// One process-wide Bot for queue workers. Building a fresh Bot (and its HTTP
|
||||
@@ -230,7 +230,9 @@ fn collect_file_ids(messages: &[Message], batch: &[MediaItemPayload], out: &mut
|
||||
}
|
||||
}
|
||||
|
||||
pub const MAX_MEDIA_GROUP: usize = 9;
|
||||
/// Telegram's `sendMediaGroup` accepts 2–10 items per group; 10 (not the older
|
||||
/// 9) means a 10-image post arrives as one album instead of two messages.
|
||||
pub const MAX_MEDIA_GROUP: usize = 10;
|
||||
|
||||
/// Splits media into batches of at most [`MAX_MEDIA_GROUP`] items, moving the
|
||||
/// items out (no per-item clone).
|
||||
@@ -728,13 +730,14 @@ mod tests {
|
||||
fn chunk_media_items_sizes() {
|
||||
assert_eq!(chunk_media_items::<i32>(vec![]), Vec::<Vec<i32>>::new());
|
||||
assert_eq!(chunk_media_items((0..9).collect()).len(), 1);
|
||||
assert_eq!(chunk_media_items((0..10).collect()).len(), 2);
|
||||
assert_eq!(chunk_media_items((0..10).collect()).len(), 1);
|
||||
assert_eq!(chunk_media_items((0..11).collect()).len(), 2);
|
||||
assert_eq!(chunk_media_items((0..25).collect()).len(), 3);
|
||||
assert_eq!(chunk_media_items((0..25).collect())[2].len(), 7);
|
||||
assert_eq!(chunk_media_items((0..25).collect())[2].len(), 5);
|
||||
assert!(
|
||||
chunk_media_items((0..25).collect())
|
||||
.iter()
|
||||
.all(|c| c.len() <= 9)
|
||||
.all(|c| c.len() <= MAX_MEDIA_GROUP)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -807,7 +810,28 @@ mod tests {
|
||||
.flatten()
|
||||
.map(|button| button.text.clone())
|
||||
.collect();
|
||||
assert_eq!(labels, ["a", "b", "m", "q", "y", "z", "↩️ Confirm"]);
|
||||
assert_eq!(
|
||||
labels,
|
||||
["a", "b", "m", "q", "y", "z", "↩️ Confirm", "🛑 Skip"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_prompt_text_states_the_ttl_and_the_confirm_requirement() {
|
||||
use std::time::Duration;
|
||||
|
||||
let text = super::post_send::edit_prompt_text(Duration::from_secs(24 * 3600));
|
||||
assert!(text.contains("Expires in 24h"), "{text}");
|
||||
assert!(text.contains("Confirm"), "{text}");
|
||||
// The wording of the whole point: no Confirm, no forward.
|
||||
assert!(text.contains("Nothing is forwarded"), "{text}");
|
||||
// Sub-hour TTLs must not render "0h".
|
||||
assert!(
|
||||
super::post_send::edit_prompt_text(Duration::from_secs(90)).contains("Expires in 1m")
|
||||
);
|
||||
assert!(
|
||||
super::post_send::edit_prompt_text(Duration::from_secs(30)).contains("Expires in 30s")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1311,7 +1335,11 @@ mod tests {
|
||||
post_send_actions(&ctx, &task, vec![10, 11]).await;
|
||||
|
||||
assert_eq!(sender.calls(), vec!["send_message"]);
|
||||
assert_eq!(sender.messages(), vec!["Reply to edit message."]);
|
||||
// The prompt explains the Confirm requirement and the TTL (see the
|
||||
// pure `edit_prompt_text` test for the exact wording).
|
||||
let prompt_text = sender.messages().first().cloned().unwrap_or_default();
|
||||
assert!(prompt_text.contains("Expires in"), "{prompt_text}");
|
||||
assert!(prompt_text.contains("Confirm"), "{prompt_text}");
|
||||
// The prompt's own message id keys the record the reply will edit.
|
||||
let data = stores.chat_store().get(1).await;
|
||||
let record = data
|
||||
|
||||
@@ -103,9 +103,38 @@ pub(crate) fn release_keep_alive(task: &Task) {
|
||||
});
|
||||
}
|
||||
|
||||
/// One button per template name (column layout), then the confirm button.
|
||||
/// Sorted by name: the templates live in a `HashMap`, so an unsorted walk
|
||||
/// would reshuffle the buttons between prompts.
|
||||
/// The edit-before-forward prompt's text. It names both controls and the TTL,
|
||||
/// because the buttons alone left users waiting for a forward that never came
|
||||
/// (nothing is forwarded until Confirm).
|
||||
pub(super) fn edit_prompt_text(ttl: std::time::Duration) -> String {
|
||||
format!(
|
||||
"Reply to edit the caption, or tap a template, then ↩️ Confirm to forward. \
|
||||
Expires in {}. Nothing is forwarded until you confirm.",
|
||||
coarsest_unit(ttl)
|
||||
)
|
||||
}
|
||||
|
||||
/// Text the prompt is rewritten to once its record expires. The sweep edits
|
||||
/// the prompt in place (see `main`): announcing the expiry with a new message
|
||||
/// would wake the chat up to a full TTL later about a prompt nobody is
|
||||
/// waiting on.
|
||||
pub(crate) const EDIT_PROMPT_EXPIRED_TEXT: &str = "⌛ Expired — nothing was forwarded.";
|
||||
|
||||
/// `24h` / `90m` / `45s`: the coarsest whole unit, so the prompt stays short.
|
||||
fn coarsest_unit(ttl: std::time::Duration) -> String {
|
||||
let secs = ttl.as_secs();
|
||||
if secs >= 3600 {
|
||||
format!("{}h", secs / 3600)
|
||||
} else if secs >= 60 {
|
||||
format!("{}m", secs / 60)
|
||||
} else {
|
||||
format!("{secs}s")
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// buttons between prompts.
|
||||
pub(super) fn build_edit_markup(templates: &HashMap<String, String>) -> InlineKeyboardMarkup {
|
||||
let mut names: Vec<&String> = templates.keys().collect();
|
||||
names.sort();
|
||||
@@ -116,10 +145,13 @@ pub(super) fn build_edit_markup(templates: &HashMap<String, String>) -> InlineKe
|
||||
format!("template|{name}"),
|
||||
)]);
|
||||
}
|
||||
rows.push(vec![InlineKeyboardButton::callback(
|
||||
"↩️ Confirm",
|
||||
"forward",
|
||||
)]);
|
||||
// Skip exists because the prompt holds the forward hostage until Confirm:
|
||||
// without it the only escape was deleting the message and waiting out the
|
||||
// TTL for a forward that then never happens.
|
||||
rows.push(vec![
|
||||
InlineKeyboardButton::callback("↩️ Confirm", "forward"),
|
||||
InlineKeyboardButton::callback("🛑 Skip", "skip"),
|
||||
]);
|
||||
InlineKeyboardMarkup::new(rows)
|
||||
}
|
||||
|
||||
@@ -190,7 +222,7 @@ pub(crate) async fn post_send_actions(ctx: &AppContext<'_>, task: &Task, message
|
||||
.sender
|
||||
.send_message(
|
||||
ChatId(chat_id),
|
||||
"Reply to edit message.".to_string(),
|
||||
edit_prompt_text(ctx.config.edit_message_ttl),
|
||||
Some(MessageId(reply_to as i32)),
|
||||
Some(keyboard),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user