fix(twitter): treat empty tombstone as withheld content, not deleted

This commit is contained in:
2026-08-14 21:21:15 +08:00
parent ac72e414c3
commit fae8dc6f2d
+72 -16
View File
@@ -107,8 +107,9 @@ fn empty_fetched(url: &str) -> Fetched {
}
}
/// Fetches a tweet from the syndication endpoint. Deleted/blocked/tombstoned
/// tweets surface as `FetchError::NotFound`.
/// Fetches a tweet from the syndication endpoint. Deleted/blocked tweets
/// surface as `FetchError::NotFound`; withheld content (empty tombstone,
/// age-restricted) as `FetchError::Sensitive`.
pub async fn fetch(id: &str) -> Result<Tweet, FetchError> {
let id_num = id.parse::<u64>().map_err(|_| FetchError::NotFound)?;
let response = crate::site::CLIENT
@@ -127,9 +128,7 @@ pub async fn fetch(id: &str) -> Result<Tweet, FetchError> {
};
}
let text = response.text().await?;
// Deleted/blocked tweets answer with an `errors` array or a
// TweetTombstone (HTTP 200, no `id_str`); NSFW withholding is an empty
// `{}`. Both classes are permanent — classify before parsing the tweet.
// Classify before parsing the tweet (see [`parse_syndication_body`]).
parse_syndication_body(&text)?;
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
/// 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).
/// **with a reason** — "This Post was deleted by the Post author." /
/// "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.
///
/// 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 {
if body.get("errors").is_some() {
return Err(FetchError::NotFound);
}
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);
}
if body.get("id_str").is_none() {
@@ -656,9 +670,10 @@ mod tests {
#[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.
// Deleted tweets answer HTTP 200 with a TweetTombstone carrying a
// reason (no `errors`, no `id_str`); they 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": {
@@ -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]
fn syndication_errors_maps_to_not_found() {
// The classic gone shape: {"errors": [...]}.
@@ -733,4 +776,17 @@ mod tests {
"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:?}"
);
}
}