refactor(send): model a payload's media as MediaRef, not media + file_id

MediaItemPayload carried one String field with two meanings and a file_id
bool beside it to say which, in all three variants; every reader re-checked
the flag (input_file had a three-arm pattern just to find the file-id case).
MediaRef::Source/FileId says it once, and the readers now match on it —
local_media_paths, item_url, input_file and the download path each shrank to
the one branch they care about.

Fixes a real failure that the flag was hiding: send_animation built its
InputFile with input_file_for(media_url), which read a cached file id as a
local path and answered "local media file missing" — permanent. So the
second request for a single-gif post always failed (the third worked, from
the degraded entry). It now uses the payload's own input_file, and
a_cached_animation_sends_by_file_id fails without that line.

Wire shape: a queued row from before this change no longer parses, and the
queue already handles that shape (handle_task dead-letters it as an invalid
payload, and dead_letter_notify still names the post and drops the stale
cache entry).
This commit is contained in:
2026-09-21 18:44:26 +08:00
parent 70d4f2ec88
commit aa70b45ae6
7 changed files with 164 additions and 110 deletions
@@ -12,6 +12,7 @@ use parking_lot::Mutex;
pub(crate) enum Outcome {
GroupOk,
GroupErr,
AnimationOk,
AnimationErr,
CopyOk,
CopyErr,
@@ -229,11 +230,25 @@ pub(crate) struct MockSender {
answers: Mutex<Vec<Option<String>>>,
/// `(chat, message, text)` of every text rewrite, in order.
edited_texts: Mutex<Vec<(i64, i64, String)>>,
/// What each `send_animation` handed Telegram: a URL or a file id as that
/// string, an upload as `attach://<id>`.
animation_files: Mutex<Vec<String>>,
/// Builds the error every `*Err` outcome returns (RequestError is not
/// cloneable, so the factory recreates it per call).
error: Box<dyn Fn() -> RequestError + Send + Sync>,
}
/// The smallest `Message` the send paths accept, for the outcomes that must
/// report one (`send_animation` reads its id, and its media for the cache).
pub(crate) fn mock_message(id: i64) -> Message {
serde_json::from_value(serde_json::json!({
"message_id": id,
"date": 0,
"chat": { "id": 1, "type": "private" },
}))
.expect("a minimal message deserializes")
}
impl MockSender {
/// The message id a successful `send_message` reports.
pub(crate) const SENT_ID: i64 = 1;
@@ -250,6 +265,7 @@ impl MockSender {
captions: Mutex::new(Vec::new()),
answers: Mutex::new(Vec::new()),
edited_texts: Mutex::new(Vec::new()),
animation_files: Mutex::new(Vec::new()),
error: Box::new(error),
}
}
@@ -280,6 +296,11 @@ impl MockSender {
self.edited_texts.lock().clone()
}
/// What every `send_animation` handed Telegram, in order.
pub(crate) fn animation_files(&self) -> Vec<String> {
self.animation_files.lock().clone()
}
fn next(&self, kind: &'static str) -> Outcome {
self.calls.lock().push(kind);
let script = self.script.lock();
@@ -330,10 +351,20 @@ impl MediaSender for MockSender {
_reply_to: MessageId,
_caption: &'a str,
_spoiler: bool,
_file: InputFile,
file: InputFile,
) -> BoxFuture<'a, Result<Message, RequestError>> {
// Record what Telegram was handed: a URL or a file id serializes as
// that string, an upload as `attach://<id>`. Enough to tell a cached
// send (which must not re-upload) from a fresh one.
self.animation_files.lock().push(
serde_json::to_value(&file)
.ok()
.and_then(|value| value.as_str().map(str::to_string))
.unwrap_or_default(),
);
Box::pin(async move {
match self.next("send_animation") {
Outcome::AnimationOk => Ok(mock_message(MockSender::SENT_ID)),
Outcome::AnimationErr => Err(self.error()),
other => panic!("unexpected outcome {other:?} for send_animation"),
}