mirror of
https://github.com/TheFunny/TelegramTwitterMediaBot.git
synced 2026-09-23 23:32:05 +00:00
fix(twitter): treat empty tombstone as withheld content, not deleted
This commit is contained in:
@@ -107,8 +107,9 @@ fn empty_fetched(url: &str) -> Fetched {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetches a tweet from the syndication endpoint. Deleted/blocked/tombstoned
|
/// Fetches a tweet from the syndication endpoint. Deleted/blocked tweets
|
||||||
/// tweets surface as `FetchError::NotFound`.
|
/// surface as `FetchError::NotFound`; withheld content (empty tombstone,
|
||||||
|
/// age-restricted) as `FetchError::Sensitive`.
|
||||||
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
|
||||||
@@ -127,9 +128,7 @@ pub async fn fetch(id: &str) -> Result<Tweet, FetchError> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
let text = response.text().await?;
|
let text = response.text().await?;
|
||||||
// Deleted/blocked tweets answer with an `errors` array or a
|
// Classify before parsing the tweet (see [`parse_syndication_body`]).
|
||||||
// TweetTombstone (HTTP 200, no `id_str`); NSFW withholding is an empty
|
|
||||||
// `{}`. Both classes are permanent — classify before parsing the tweet.
|
|
||||||
parse_syndication_body(&text)?;
|
parse_syndication_body(&text)?;
|
||||||
Tweet::from_syndication_json(&text).map_err(FetchError::Json)
|
Tweet::from_syndication_json(&text).map_err(FetchError::Json)
|
||||||
}
|
}
|
||||||
@@ -137,17 +136,32 @@ pub async fn fetch(id: &str) -> Result<Tweet, FetchError> {
|
|||||||
/// Parses and classifies a syndication response body. `Ok` means the body is
|
/// Parses and classifies a syndication response body. `Ok` means the body is
|
||||||
/// a real tweet payload; `Err` carries the permanent error class:
|
/// a real tweet payload; `Err` carries the permanent error class:
|
||||||
/// - `NotFound`: an `errors` array (deleted/blocked) or a `TweetTombstone`
|
/// - `NotFound`: an `errors` array (deleted/blocked) or a `TweetTombstone`
|
||||||
/// (deleted by the author / suspended — HTTP 200, no `errors`, no `id_str`).
|
/// **with a reason** — "This Post was deleted by the Post author." /
|
||||||
/// - `Sensitive`: an empty `{}` (NSFW / age-restricted withholding).
|
/// "This Post is from a suspended account." (the tweet is gone).
|
||||||
|
/// - `Sensitive`: content withheld **without a deletion reason** — the empty
|
||||||
|
/// `{}` shape or an *empty* `TweetTombstone` (`{"__typename":
|
||||||
|
/// "TweetTombstone","tombstone":{}}`). Live tweets in restricted contexts
|
||||||
|
/// surface this way; treating them as deleted is a regression (a normal
|
||||||
|
/// tweet must not report "deleted"). Age-restricted tombstones route here
|
||||||
|
/// too so the logged-in GraphQL fallback can fetch the real tweet.
|
||||||
/// - `Json`: an unparseable body.
|
/// - `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> {
|
fn parse_syndication_body(text: &str) -> Result<serde_json::Value, FetchError> {
|
||||||
let body: serde_json::Value = serde_json::from_str(text)?;
|
let body: serde_json::Value = serde_json::from_str(text)?;
|
||||||
let tombstoned = body.get("tombstone").is_some()
|
if body.get("errors").is_some() {
|
||||||
|| body.get("__typename").and_then(|t| t.as_str()) == Some("TweetTombstone");
|
return Err(FetchError::NotFound);
|
||||||
if body.get("errors").is_some() || tombstoned {
|
}
|
||||||
|
if let Some(tombstone) = body.get("tombstone") {
|
||||||
|
// Only a tombstone with an explicit reason means the tweet is gone;
|
||||||
|
// a missing reason (empty `tombstone: {}`) or an age-restricted
|
||||||
|
// reason means the tweet exists but is withheld.
|
||||||
|
let reason = tombstone
|
||||||
|
.get("text")
|
||||||
|
.and_then(|t| t.get("text"))
|
||||||
|
.and_then(|t| t.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
if reason.is_empty() || reason.to_ascii_lowercase().contains("age-restricted") {
|
||||||
|
return Err(FetchError::Sensitive);
|
||||||
|
}
|
||||||
return Err(FetchError::NotFound);
|
return Err(FetchError::NotFound);
|
||||||
}
|
}
|
||||||
if body.get("id_str").is_none() {
|
if body.get("id_str").is_none() {
|
||||||
@@ -656,9 +670,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn syndication_tombstone_maps_to_not_found() {
|
fn syndication_tombstone_maps_to_not_found() {
|
||||||
// Deleted tweets answer HTTP 200 with a TweetTombstone (no `errors`,
|
// Deleted tweets answer HTTP 200 with a TweetTombstone carrying a
|
||||||
// no `id_str`); it must not fall through to Sensitive, which would
|
// reason (no `errors`, no `id_str`); they must not fall through to
|
||||||
// make the bot reply "No media found" for a deleted tweet.
|
// Sensitive, which would make the bot reply "No media found" for a
|
||||||
|
// deleted tweet.
|
||||||
let raw = serde_json::json!({
|
let raw = serde_json::json!({
|
||||||
"__typename": "TweetTombstone",
|
"__typename": "TweetTombstone",
|
||||||
"tombstone": {
|
"tombstone": {
|
||||||
@@ -671,6 +686,34 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn syndication_empty_tombstone_maps_to_sensitive() {
|
||||||
|
// Regression: live tweets in restricted contexts answer with an
|
||||||
|
// EMPTY tombstone (`{"__typename":"TweetTombstone","tombstone":{}}`)
|
||||||
|
// — no deletion reason. They must not be reported as deleted.
|
||||||
|
let raw = serde_json::json!({ "__typename": "TweetTombstone", "tombstone": {} });
|
||||||
|
assert!(matches!(
|
||||||
|
parse_syndication_body(&raw.to_string()),
|
||||||
|
Err(FetchError::Sensitive)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn syndication_age_restricted_tombstone_maps_to_sensitive() {
|
||||||
|
// An age-restricted tombstone withholds a live tweet; route it to
|
||||||
|
// the logged-in fallback instead of reporting it as gone.
|
||||||
|
let raw = serde_json::json!({
|
||||||
|
"__typename": "TweetTombstone",
|
||||||
|
"tombstone": {
|
||||||
|
"text": { "rtl": false, "text": "Age-restricted adult content" }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assert!(matches!(
|
||||||
|
parse_syndication_body(&raw.to_string()),
|
||||||
|
Err(FetchError::Sensitive)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn syndication_errors_maps_to_not_found() {
|
fn syndication_errors_maps_to_not_found() {
|
||||||
// The classic gone shape: {"errors": [...]}.
|
// The classic gone shape: {"errors": [...]}.
|
||||||
@@ -733,4 +776,17 @@ mod tests {
|
|||||||
"got {result:?}"
|
"got {result:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "live network: requires outbound HTTPS to cdn.syndication.twimg.com"]
|
||||||
|
async fn live_fetch_empty_tombstone_is_sensitive() {
|
||||||
|
// Regression: a LIVE tweet (verified via a third-party API) answers
|
||||||
|
// syndication with an empty TweetTombstone; it must surface as
|
||||||
|
// Sensitive (withheld), never as NotFound (deleted).
|
||||||
|
let result = fetch("2087851366253555752").await;
|
||||||
|
assert!(
|
||||||
|
matches!(result, Err(FetchError::Sensitive)),
|
||||||
|
"got {result:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user