mirror of
https://github.com/TheFunny/TelegramTwitterMediaBot.git
synced 2026-09-23 23:32:05 +00:00
fix(twitter): map syndication TweetTombstone to NotFound
Deleted tweets answer the syndication endpoint with HTTP 200 and a TweetTombstone (no `errors`, no `id_str`). The body classifier only knew the `errors` shape, so tombstones fell through to the `no id_str -> Sensitive` branch and degraded to an empty result, making the bot reply "No media found" for a deleted tweet. - extract parse_syndication_body(); tombstone shape -> NotFound - propagate NotFound from the TWITTER_AUTH_TOKEN GraphQL fallback instead of swallowing it into empty_fetched - unit tests for all body classes + live regression test on a real tombstoned tweet
This commit is contained in:
@@ -29,6 +29,12 @@ pub async fn fetch_from_url(url: &str) -> Result<Fetched, FetchError> {
|
|||||||
if super::auth::enabled() {
|
if super::auth::enabled() {
|
||||||
match super::auth::fetch(id).await {
|
match super::auth::fetch(id).await {
|
||||||
Ok(tweet) => Ok(tweet.into()),
|
Ok(tweet) => Ok(tweet.into()),
|
||||||
|
// The tweet is genuinely gone (deleted / suspended /
|
||||||
|
// tombstoned): report it instead of degrading to an
|
||||||
|
// empty result ("No media found"). Only unexpected
|
||||||
|
// fallback failures (network, parse) keep the NSFW
|
||||||
|
// placeholder.
|
||||||
|
Err(FetchError::NotFound) => Err(FetchError::NotFound),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::warn!("twitter auth fallback failed for {id}: {e}");
|
log::warn!("twitter auth fallback failed for {id}: {e}");
|
||||||
Ok(empty_fetched(url))
|
Ok(empty_fetched(url))
|
||||||
@@ -59,8 +65,8 @@ fn empty_fetched(url: &str) -> Fetched {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetches a tweet from the syndication endpoint. Deleted/blocked tweets
|
/// Fetches a tweet from the syndication endpoint. Deleted/blocked/tombstoned
|
||||||
/// surface as `FetchError::NotFound`.
|
/// tweets surface as `FetchError::NotFound`.
|
||||||
pub async fn fetch(id: &str) -> Result<Tweet, FetchError> {
|
pub async fn fetch(id: &str) -> Result<Tweet, FetchError> {
|
||||||
let id_num = id.parse::<u64>().map_err(|_| FetchError::NotFound)?;
|
let id_num = id.parse::<u64>().map_err(|_| FetchError::NotFound)?;
|
||||||
let response = crate::site::CLIENT
|
let response = crate::site::CLIENT
|
||||||
@@ -79,23 +85,33 @@ pub async fn fetch(id: &str) -> Result<Tweet, FetchError> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
let text = response.text().await?;
|
let text = response.text().await?;
|
||||||
// Deleted tweets answer with {"errors": [...]} instead of a tweet.
|
// Deleted/blocked tweets answer with an `errors` array or a
|
||||||
if serde_json::from_str::<serde_json::Value>(&text)
|
// TweetTombstone (HTTP 200, no `id_str`); NSFW withholding is an empty
|
||||||
.map(|v| v.get("errors").is_some())
|
// `{}`. Both classes are permanent — classify before parsing the tweet.
|
||||||
.unwrap_or(false)
|
parse_syndication_body(&text)?;
|
||||||
{
|
Tweet::from_syndication_json(&text).map_err(FetchError::Json)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses and classifies a syndication response body. `Ok` means the body is
|
||||||
|
/// a real tweet payload; `Err` carries the permanent error class:
|
||||||
|
/// - `NotFound`: an `errors` array (deleted/blocked) or a `TweetTombstone`
|
||||||
|
/// (deleted by the author / suspended — HTTP 200, no `errors`, no `id_str`).
|
||||||
|
/// - `Sensitive`: an empty `{}` (NSFW / age-restricted withholding).
|
||||||
|
/// - `Json`: an unparseable body.
|
||||||
|
///
|
||||||
|
/// The tombstone shape must NOT fall through to `Sensitive`: the bot would
|
||||||
|
/// otherwise answer "No media found" for a deleted tweet instead of failing.
|
||||||
|
fn parse_syndication_body(text: &str) -> Result<serde_json::Value, FetchError> {
|
||||||
|
let body: serde_json::Value = serde_json::from_str(text)?;
|
||||||
|
let tombstoned = body.get("tombstone").is_some()
|
||||||
|
|| body.get("__typename").and_then(|t| t.as_str()) == Some("TweetTombstone");
|
||||||
|
if body.get("errors").is_some() || tombstoned {
|
||||||
return Err(FetchError::NotFound);
|
return Err(FetchError::NotFound);
|
||||||
}
|
}
|
||||||
// NSFW / age-restricted tweets exist but are served as an empty `{}` —
|
if body.get("id_str").is_none() {
|
||||||
// they surface as FetchError::Sensitive so the caller can retry as a
|
|
||||||
// logged-in user.
|
|
||||||
if serde_json::from_str::<serde_json::Value>(&text)
|
|
||||||
.map(|v| v.get("id_str").is_none())
|
|
||||||
.unwrap_or(false)
|
|
||||||
{
|
|
||||||
return Err(FetchError::Sensitive);
|
return Err(FetchError::Sensitive);
|
||||||
}
|
}
|
||||||
Tweet::from_syndication_json(&text).map_err(FetchError::Json)
|
Ok(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The syndication token: JS `((id / 1e15) * PI).toString(36)` (the
|
/// The syndication token: JS `((id / 1e15) * PI).toString(36)` (the
|
||||||
@@ -572,6 +588,48 @@ mod tests {
|
|||||||
assert!(token.starts_with("236.v"), "got {token}");
|
assert!(token.starts_with("236.v"), "got {token}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn syndication_tombstone_maps_to_not_found() {
|
||||||
|
// Deleted tweets answer HTTP 200 with a TweetTombstone (no `errors`,
|
||||||
|
// no `id_str`); it must not fall through to Sensitive, which would
|
||||||
|
// make the bot reply "No media found" for a deleted tweet.
|
||||||
|
let raw = serde_json::json!({
|
||||||
|
"__typename": "TweetTombstone",
|
||||||
|
"tombstone": {
|
||||||
|
"text": { "rtl": false, "text": "This Post was deleted by the Post author. Learn more" }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assert!(matches!(
|
||||||
|
parse_syndication_body(&raw.to_string()),
|
||||||
|
Err(FetchError::NotFound)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn syndication_errors_maps_to_not_found() {
|
||||||
|
// The classic gone shape: {"errors": [...]}.
|
||||||
|
let raw = serde_json::json!({ "errors": [{ "message": "Couldn't find Tweet" }] });
|
||||||
|
assert!(matches!(
|
||||||
|
parse_syndication_body(&raw.to_string()),
|
||||||
|
Err(FetchError::NotFound)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn syndication_empty_object_maps_to_sensitive() {
|
||||||
|
// NSFW / age-restricted withholding: an empty `{}`.
|
||||||
|
assert!(matches!(
|
||||||
|
parse_syndication_body("{}"),
|
||||||
|
Err(FetchError::Sensitive)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn syndication_tweet_body_passes() {
|
||||||
|
let raw = fixture(serde_json::json!([]));
|
||||||
|
assert!(parse_syndication_body(&raw.to_string()).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[ignore = "live network: requires outbound HTTPS to cdn.syndication.twimg.com"]
|
#[ignore = "live network: requires outbound HTTPS to cdn.syndication.twimg.com"]
|
||||||
async fn live_fetch_with_photos() {
|
async fn live_fetch_with_photos() {
|
||||||
@@ -596,4 +654,17 @@ mod tests {
|
|||||||
"got {result:?}"
|
"got {result:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "live network: requires outbound HTTPS to cdn.syndication.twimg.com"]
|
||||||
|
async fn live_fetch_tombstone_deleted_tweet_is_not_found() {
|
||||||
|
// Regression: a real deleted tweet answering with a TweetTombstone
|
||||||
|
// (HTTP 200, no errors/id_str) used to surface as Sensitive and
|
||||||
|
// degrade to an empty result ("No media found").
|
||||||
|
let result = fetch("2085948045967986859").await;
|
||||||
|
assert!(
|
||||||
|
matches!(result, Err(FetchError::NotFound)),
|
||||||
|
"got {result:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user