mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
1747 lines
57 KiB
Rust
1747 lines
57 KiB
Rust
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
use super::{ResolveContext, Resolver, ResolverResult};
|
|
use crate::http_fetch;
|
|
use crate::media_proxy::embed_media_flags;
|
|
use crate::text_limits;
|
|
use crate::types::{EmbedAuthor, EmbedFooter, EmbedMedia, MessageEmbed};
|
|
use serde::{Deserialize, Deserializer};
|
|
use serde_json::Value;
|
|
use std::future::Future;
|
|
use std::pin::Pin;
|
|
use std::time::Duration;
|
|
use url::Url;
|
|
|
|
const FXTWITTER_COLOR: u32 = 0x6364FF;
|
|
const FXTWITTER_FOOTER_TEXT: &str = "FxTwitter";
|
|
const FXTWITTER_FOOTER_ICON: &str = "https://assets.fxembed.com/logos/fxtwitter64.png";
|
|
const FXTWITTER_API_BASE: &str = "https://api.fxtwitter.com";
|
|
const API_MAX_BYTES: usize = 512 * 1024;
|
|
const API_TIMEOUT: Duration = Duration::from_secs(8);
|
|
const MAX_GALLERY_IMAGES: usize = 10;
|
|
|
|
const FXTWITTER_HOSTS: &[&str] = &["fxtwitter.com", "fixupx.com", "twittpr.com", "xfixup.com"];
|
|
const DIRECT_MEDIA_EXTENSIONS: &[&str] = &["mp4", "png", "jpg", "jpeg", "gif", "gifv"];
|
|
|
|
const EMOJI_REPLY: &str = "\u{1F4AC}";
|
|
const EMOJI_RETWEET: &str = "\u{1F501}";
|
|
const EMOJI_LIKE: &str = "\u{2764}\u{FE0F}";
|
|
const EMOJI_VIEWS: &str = "\u{1F441}\u{FE0F}";
|
|
const STAT_SEP: &str = "\u{2002}";
|
|
const QUOTE_EMPTY_LINE: &str = "> \u{FE00}";
|
|
|
|
pub struct FxTwitterResolver;
|
|
|
|
impl Resolver for FxTwitterResolver {
|
|
fn matches(&self, url: &Url) -> bool {
|
|
is_fxtwitter_host(url.host_str())
|
|
}
|
|
|
|
fn resolve<'a>(
|
|
&'a self,
|
|
ctx: &'a ResolveContext<'_>,
|
|
) -> Pin<Box<dyn Future<Output = anyhow::Result<ResolverResult>> + Send + 'a>> {
|
|
Box::pin(async move {
|
|
let Some(request) = parse_status_request(&ctx.original_url) else {
|
|
return Ok(ResolverResult { embeds: vec![] });
|
|
};
|
|
resolve_status(ctx, &request).await
|
|
})
|
|
}
|
|
}
|
|
|
|
fn is_fxtwitter_host(host: Option<&str>) -> bool {
|
|
fxtwitter_host_prefixes(host).is_some()
|
|
}
|
|
|
|
fn fxtwitter_host_prefixes(host: Option<&str>) -> Option<Vec<String>> {
|
|
let host = host?.to_ascii_lowercase();
|
|
let domain = FXTWITTER_HOSTS
|
|
.iter()
|
|
.find(|domain| host == **domain || host.ends_with(&format!(".{}", **domain)))?;
|
|
if host == **domain {
|
|
return Some(Vec::new());
|
|
}
|
|
let prefix = host.strip_suffix(*domain)?.trim_end_matches('.');
|
|
Some(
|
|
prefix
|
|
.split('.')
|
|
.filter(|label| !label.is_empty())
|
|
.map(str::to_owned)
|
|
.collect(),
|
|
)
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
struct FxUrlFlags {
|
|
direct: bool,
|
|
text_only: bool,
|
|
gallery: bool,
|
|
force_mosaic: bool,
|
|
force_instant_view: bool,
|
|
old_embed: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum MediaSelectorKind {
|
|
Photo,
|
|
Video,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct FxStatusRequest {
|
|
screen_name: String,
|
|
status_id: String,
|
|
language: Option<String>,
|
|
media_index: Option<usize>,
|
|
media_kind: Option<MediaSelectorKind>,
|
|
direct_media_name: Option<String>,
|
|
flags: FxUrlFlags,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct ParsedStatusId {
|
|
id: String,
|
|
direct_media_suffix: bool,
|
|
direct_media_name: Option<String>,
|
|
}
|
|
|
|
fn parse_status_request(url: &Url) -> Option<FxStatusRequest> {
|
|
let mut flags = flags_from_host(url);
|
|
let mut segments: Vec<&str> = url
|
|
.path()
|
|
.split('/')
|
|
.filter(|segment| !segment.is_empty())
|
|
.collect();
|
|
|
|
if matches!(segments.first().copied(), Some("dir" | "dl")) {
|
|
flags.direct = true;
|
|
segments.remove(0);
|
|
}
|
|
|
|
let status_idx = segments
|
|
.iter()
|
|
.position(|segment| *segment == "status" || *segment == "statuses")?;
|
|
let parsed_id = parse_status_id_segment(segments.get(status_idx + 1)?)?;
|
|
if parsed_id.direct_media_suffix {
|
|
flags.direct = true;
|
|
}
|
|
|
|
let screen_name = if status_idx == 0 {
|
|
"i"
|
|
} else {
|
|
segments.first().copied().unwrap_or("i")
|
|
};
|
|
let mut suffix = &segments[(status_idx + 2)..];
|
|
let mut media_kind = None;
|
|
let mut media_index = None;
|
|
if suffix.len() >= 2
|
|
&& let Some(kind) = parse_media_selector_kind(suffix[0])
|
|
&& let Some(index) = parse_media_selector_index(suffix[1])
|
|
{
|
|
media_kind = Some(kind);
|
|
media_index = Some(index);
|
|
suffix = &suffix[2..];
|
|
}
|
|
let language = suffix
|
|
.first()
|
|
.filter(|segment| is_language_suffix(segment))
|
|
.map(|segment| (*segment).to_owned());
|
|
|
|
let query_name = url
|
|
.query_pairs()
|
|
.find(|(key, _)| key == "name")
|
|
.map(|(_, value)| value.into_owned());
|
|
|
|
Some(FxStatusRequest {
|
|
screen_name: screen_name.to_owned(),
|
|
status_id: parsed_id.id,
|
|
language,
|
|
media_index,
|
|
media_kind,
|
|
direct_media_name: query_name.or(parsed_id.direct_media_name),
|
|
flags,
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn parse_status(url: &Url) -> Option<(String, String)> {
|
|
parse_status_request(url).map(|request| (request.screen_name, request.status_id))
|
|
}
|
|
|
|
fn flags_from_host(url: &Url) -> FxUrlFlags {
|
|
let mut flags = FxUrlFlags::default();
|
|
let Some(prefixes) = fxtwitter_host_prefixes(url.host_str()) else {
|
|
return flags;
|
|
};
|
|
for prefix in prefixes {
|
|
match prefix.as_str() {
|
|
"d" | "dl" => flags.direct = true,
|
|
"t" => flags.text_only = true,
|
|
"g" => flags.gallery = true,
|
|
"m" => flags.force_mosaic = true,
|
|
"i" => flags.force_instant_view = true,
|
|
"o" => flags.old_embed = true,
|
|
"www" => {}
|
|
_ => {}
|
|
}
|
|
}
|
|
flags
|
|
}
|
|
|
|
fn parse_status_id_segment(segment: &str) -> Option<ParsedStatusId> {
|
|
let digit_end = segment
|
|
.bytes()
|
|
.take_while(|byte| byte.is_ascii_digit())
|
|
.count();
|
|
if digit_end == 0 {
|
|
return None;
|
|
}
|
|
|
|
let id = segment[..digit_end].to_owned();
|
|
let rest = &segment[digit_end..];
|
|
if rest.is_empty() {
|
|
return Some(ParsedStatusId {
|
|
id,
|
|
direct_media_suffix: false,
|
|
direct_media_name: None,
|
|
});
|
|
}
|
|
|
|
if let Some(name) = rest.strip_prefix(':') {
|
|
return Some(ParsedStatusId {
|
|
id,
|
|
direct_media_suffix: false,
|
|
direct_media_name: (!name.is_empty()).then(|| name.to_owned()),
|
|
});
|
|
}
|
|
|
|
let ext_and_name = rest.strip_prefix('.')?;
|
|
let (extension, name) = ext_and_name
|
|
.split_once(':')
|
|
.map_or((ext_and_name, None), |(extension, name)| {
|
|
(extension, (!name.is_empty()).then_some(name))
|
|
});
|
|
if !DIRECT_MEDIA_EXTENSIONS
|
|
.iter()
|
|
.any(|known| extension.eq_ignore_ascii_case(known))
|
|
{
|
|
return None;
|
|
}
|
|
|
|
Some(ParsedStatusId {
|
|
id,
|
|
direct_media_suffix: true,
|
|
direct_media_name: name.map(str::to_owned),
|
|
})
|
|
}
|
|
|
|
fn parse_media_selector_kind(segment: &str) -> Option<MediaSelectorKind> {
|
|
match segment {
|
|
"photo" | "photos" => Some(MediaSelectorKind::Photo),
|
|
"video" | "videos" => Some(MediaSelectorKind::Video),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn parse_media_selector_index(segment: &str) -> Option<usize> {
|
|
let index = segment.parse::<usize>().ok()?;
|
|
(1..=4).contains(&index).then_some(index - 1)
|
|
}
|
|
|
|
fn is_language_suffix(segment: &str) -> bool {
|
|
let mut parts = segment.split('-');
|
|
let Some(language) = parts.next() else {
|
|
return false;
|
|
};
|
|
if !(2..=3).contains(&language.len())
|
|
|| !language.bytes().all(|byte| byte.is_ascii_alphabetic())
|
|
{
|
|
return false;
|
|
}
|
|
parts.all(|part| {
|
|
(2..=8).contains(&part.len())
|
|
&& part
|
|
.bytes()
|
|
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
|
|
})
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct FxResponse {
|
|
tweet: Option<FxTweet>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct FxTweet {
|
|
id: Option<String>,
|
|
text: Option<String>,
|
|
created_timestamp: Option<i64>,
|
|
author: Option<FxAuthor>,
|
|
replies: Option<u64>,
|
|
#[serde(alias = "reposts")]
|
|
retweets: Option<u64>,
|
|
likes: Option<u64>,
|
|
views: Option<u64>,
|
|
quote: Option<Box<FxQuote>>,
|
|
media: Option<FxMedia>,
|
|
translation: Option<FxTranslation>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct FxAuthor {
|
|
screen_name: Option<String>,
|
|
name: Option<String>,
|
|
avatar_url: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct FxQuote {
|
|
url: Option<String>,
|
|
text: Option<String>,
|
|
author: Option<FxAuthor>,
|
|
media: Option<FxMedia>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct FxTranslation {
|
|
text: Option<String>,
|
|
source_lang: Option<String>,
|
|
target_lang: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Default, Deserialize)]
|
|
struct FxMedia {
|
|
all: Option<Vec<FxMediaItem>>,
|
|
photos: Option<Vec<FxPhoto>>,
|
|
videos: Option<Vec<FxVideo>>,
|
|
mosaic: Option<FxMosaicPhoto>,
|
|
}
|
|
|
|
impl FxMedia {
|
|
fn has_any(&self) -> bool {
|
|
self.all.as_ref().is_some_and(|all| !all.is_empty())
|
|
|| self.photos.as_ref().is_some_and(|p| !p.is_empty())
|
|
|| self.videos.as_ref().is_some_and(|v| !v.is_empty())
|
|
|| self
|
|
.mosaic
|
|
.as_ref()
|
|
.and_then(FxMosaicPhoto::image_url)
|
|
.is_some()
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct FxMediaItem {
|
|
#[serde(rename = "type")]
|
|
item_type: Option<String>,
|
|
url: Option<String>,
|
|
thumbnail_url: Option<String>,
|
|
width: Option<u32>,
|
|
height: Option<u32>,
|
|
duration: Option<f64>,
|
|
transcode_url: Option<String>,
|
|
#[serde(default, deserialize_with = "deserialize_optional_mosaic_formats")]
|
|
formats: Option<FxMosaicFormats>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct FxPhoto {
|
|
url: Option<String>,
|
|
width: Option<u32>,
|
|
height: Option<u32>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct FxMosaicPhoto {
|
|
url: Option<String>,
|
|
width: Option<u32>,
|
|
height: Option<u32>,
|
|
formats: Option<FxMosaicFormats>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct FxMosaicFormats {
|
|
jpeg: Option<String>,
|
|
webp: Option<String>,
|
|
}
|
|
|
|
fn deserialize_optional_mosaic_formats<'de, D>(
|
|
deserializer: D,
|
|
) -> Result<Option<FxMosaicFormats>, D::Error>
|
|
where
|
|
D: Deserializer<'de>,
|
|
{
|
|
match Option::<Value>::deserialize(deserializer)? {
|
|
Some(Value::Object(map)) => serde_json::from_value(Value::Object(map))
|
|
.map(Some)
|
|
.map_err(serde::de::Error::custom),
|
|
_ => Ok(None),
|
|
}
|
|
}
|
|
|
|
impl FxMosaicPhoto {
|
|
fn image_url(&self) -> Option<&str> {
|
|
self.formats
|
|
.as_ref()
|
|
.and_then(|formats| formats.jpeg.as_deref().or(formats.webp.as_deref()))
|
|
.or(self.url.as_deref())
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct FxVideo {
|
|
url: Option<String>,
|
|
thumbnail_url: Option<String>,
|
|
width: Option<u32>,
|
|
height: Option<u32>,
|
|
duration: Option<f64>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
enum MediaChoice<'a> {
|
|
Image {
|
|
url: &'a str,
|
|
width: Option<u32>,
|
|
height: Option<u32>,
|
|
},
|
|
Video {
|
|
url: &'a str,
|
|
thumbnail_url: Option<&'a str>,
|
|
width: Option<u32>,
|
|
height: Option<u32>,
|
|
duration: Option<f64>,
|
|
},
|
|
}
|
|
|
|
async fn resolve_status(
|
|
ctx: &ResolveContext<'_>,
|
|
request: &FxStatusRequest,
|
|
) -> anyhow::Result<ResolverResult> {
|
|
let api_url = fxtwitter_api_url(request);
|
|
let result =
|
|
http_fetch::fetch_url(&ctx.http_client, &api_url, API_MAX_BYTES, API_TIMEOUT).await?;
|
|
if result.status != 200 {
|
|
return Ok(ResolverResult { embeds: vec![] });
|
|
}
|
|
let Ok(response) = serde_json::from_slice::<FxResponse>(&result.bytes) else {
|
|
return Ok(ResolverResult { embeds: vec![] });
|
|
};
|
|
let Some(tweet) = response.tweet else {
|
|
return Ok(ResolverResult { embeds: vec![] });
|
|
};
|
|
|
|
if request.flags.direct
|
|
&& let Some(embed) = build_direct_media_embed(ctx, &tweet, request).await
|
|
{
|
|
return Ok(ResolverResult {
|
|
embeds: vec![embed],
|
|
});
|
|
}
|
|
|
|
let posted_url = ctx.original_url.to_string();
|
|
let mut embed = MessageEmbed::new("rich");
|
|
embed.url = Some(posted_url.clone());
|
|
if !request.flags.gallery {
|
|
embed.color = Some(FXTWITTER_COLOR);
|
|
embed.description = Some(build_description(&tweet));
|
|
embed.timestamp = tweet.created_timestamp.and_then(format_timestamp);
|
|
embed.footer = Some(build_fxtwitter_footer(ctx));
|
|
}
|
|
|
|
if let Some(author) = tweet.author.as_ref() {
|
|
embed.author = Some(build_fxtwitter_author(
|
|
ctx,
|
|
author,
|
|
&posted_url,
|
|
&request.screen_name,
|
|
));
|
|
}
|
|
|
|
let mut gallery: Vec<EmbedMedia> = Vec::new();
|
|
if !request.flags.text_only {
|
|
apply_media_to_embed(ctx, &tweet, request, &mut embed, &mut gallery).await;
|
|
}
|
|
|
|
let mut embeds = vec![embed];
|
|
for image in gallery {
|
|
let mut extra = MessageEmbed::new("rich");
|
|
extra.url = Some(posted_url.clone());
|
|
extra.image = Some(image);
|
|
embeds.push(extra);
|
|
}
|
|
|
|
Ok(ResolverResult { embeds })
|
|
}
|
|
|
|
fn build_fxtwitter_footer(ctx: &ResolveContext<'_>) -> EmbedFooter {
|
|
EmbedFooter {
|
|
text: FXTWITTER_FOOTER_TEXT.to_owned(),
|
|
icon_url: Some(FXTWITTER_FOOTER_ICON.to_owned()),
|
|
proxy_icon_url: ctx.media_proxy.external_proxy_url(FXTWITTER_FOOTER_ICON),
|
|
}
|
|
}
|
|
|
|
fn build_fxtwitter_author(
|
|
ctx: &ResolveContext<'_>,
|
|
author: &FxAuthor,
|
|
posted_url: &str,
|
|
fallback_screen_name: &str,
|
|
) -> EmbedAuthor {
|
|
let screen = author
|
|
.screen_name
|
|
.as_deref()
|
|
.unwrap_or(fallback_screen_name);
|
|
let name = author.name.as_deref().unwrap_or(screen);
|
|
EmbedAuthor {
|
|
name: text_limits::truncate(&format!("{name} (@{screen})"), text_limits::AUTHOR_NAME_MAX),
|
|
url: Some(posted_url.to_owned()),
|
|
icon_url: author.avatar_url.clone(),
|
|
proxy_icon_url: author
|
|
.avatar_url
|
|
.as_deref()
|
|
.and_then(|url| ctx.media_proxy.external_proxy_url(url)),
|
|
}
|
|
}
|
|
|
|
fn fxtwitter_api_url(request: &FxStatusRequest) -> String {
|
|
let mut api_url = format!(
|
|
"{FXTWITTER_API_BASE}/{}/status/{}",
|
|
request.screen_name, request.status_id
|
|
);
|
|
if let Some(language) = request.language.as_deref() {
|
|
api_url.push('/');
|
|
api_url.push_str(language);
|
|
}
|
|
api_url
|
|
}
|
|
|
|
async fn build_direct_media_embed(
|
|
ctx: &ResolveContext<'_>,
|
|
tweet: &FxTweet,
|
|
request: &FxStatusRequest,
|
|
) -> Option<MessageEmbed> {
|
|
let media = tweet.media.as_ref()?;
|
|
let choice = if let Some(index) = request.media_index {
|
|
selected_choice_from_media(media, index, request.media_kind)
|
|
} else if request.flags.force_mosaic {
|
|
mosaic_choice(media).or_else(|| first_direct_choice_from_media(media))
|
|
} else {
|
|
first_direct_choice_from_media(media)
|
|
}?;
|
|
|
|
build_direct_media_embed_for_choice(ctx, choice, request.direct_media_name.as_deref()).await
|
|
}
|
|
|
|
async fn build_direct_media_embed_for_choice(
|
|
ctx: &ResolveContext<'_>,
|
|
choice: MediaChoice<'_>,
|
|
image_name: Option<&str>,
|
|
) -> Option<MessageEmbed> {
|
|
match choice {
|
|
MediaChoice::Image { url, width, height } => {
|
|
let media_url = apply_image_name(url, image_name);
|
|
let media = build_image_media(ctx, &media_url, width, height).await?;
|
|
let mut embed = MessageEmbed::new("image");
|
|
embed.url = Some(media_url);
|
|
embed.thumbnail = Some(media);
|
|
Some(embed)
|
|
}
|
|
MediaChoice::Video {
|
|
url,
|
|
thumbnail_url,
|
|
width,
|
|
height,
|
|
duration,
|
|
} => {
|
|
let (thumbnail, video) =
|
|
build_video_pair_from_parts(ctx, url, thumbnail_url, width, height, duration).await;
|
|
let video = video?;
|
|
let mut embed = MessageEmbed::new("video");
|
|
embed.url = video.url.clone();
|
|
embed.thumbnail = thumbnail;
|
|
embed.video = Some(video);
|
|
Some(embed)
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn apply_media_to_embed(
|
|
ctx: &ResolveContext<'_>,
|
|
tweet: &FxTweet,
|
|
request: &FxStatusRequest,
|
|
embed: &mut MessageEmbed,
|
|
gallery: &mut Vec<EmbedMedia>,
|
|
) {
|
|
let Some(media) = select_media_source(tweet) else {
|
|
return;
|
|
};
|
|
|
|
if let Some(index) = request.media_index {
|
|
if let Some(choice) = selected_choice_from_media(media, index, request.media_kind) {
|
|
apply_media_choice(ctx, embed, choice).await;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if request.flags.force_mosaic
|
|
&& let Some(choice) = mosaic_choice(media)
|
|
&& apply_media_choice(ctx, embed, choice).await
|
|
{
|
|
return;
|
|
}
|
|
|
|
if let Some(video) = media.videos.as_ref().and_then(|videos| videos.first()) {
|
|
let (thumbnail, video_media) = build_video_pair(ctx, video).await;
|
|
embed.thumbnail = thumbnail;
|
|
embed.video = video_media;
|
|
} else if let Some(photos) = media.photos.as_ref() {
|
|
let mut photos = photos.iter();
|
|
if let Some(first) = photos.next()
|
|
&& let Some(image) = build_photo_media(ctx, first).await
|
|
{
|
|
embed.image = Some(image);
|
|
}
|
|
for photo in photos.take(MAX_GALLERY_IMAGES.saturating_sub(1)) {
|
|
if let Some(image) = build_photo_media(ctx, photo).await {
|
|
gallery.push(image);
|
|
}
|
|
}
|
|
} else if let Some(choice) = first_choice_from_all(media) {
|
|
apply_media_choice(ctx, embed, choice).await;
|
|
}
|
|
}
|
|
|
|
async fn apply_media_choice(
|
|
ctx: &ResolveContext<'_>,
|
|
embed: &mut MessageEmbed,
|
|
choice: MediaChoice<'_>,
|
|
) -> bool {
|
|
match choice {
|
|
MediaChoice::Image { url, width, height } => {
|
|
let Some(image) = build_image_media(ctx, url, width, height).await else {
|
|
return false;
|
|
};
|
|
embed.image = Some(image);
|
|
true
|
|
}
|
|
MediaChoice::Video {
|
|
url,
|
|
thumbnail_url,
|
|
width,
|
|
height,
|
|
duration,
|
|
} => {
|
|
let (thumbnail, video_media) =
|
|
build_video_pair_from_parts(ctx, url, thumbnail_url, width, height, duration).await;
|
|
embed.thumbnail = thumbnail;
|
|
embed.video = video_media;
|
|
embed.video.is_some()
|
|
}
|
|
}
|
|
}
|
|
|
|
fn select_media_source(tweet: &FxTweet) -> Option<&FxMedia> {
|
|
if let Some(media) = tweet.media.as_ref().filter(|media| media.has_any()) {
|
|
return Some(media);
|
|
}
|
|
tweet
|
|
.quote
|
|
.as_deref()
|
|
.and_then(|quote| quote.media.as_ref())
|
|
.filter(|media| media.has_any())
|
|
}
|
|
|
|
fn selected_choice_from_media<'a>(
|
|
media: &'a FxMedia,
|
|
index: usize,
|
|
media_kind: Option<MediaSelectorKind>,
|
|
) -> Option<MediaChoice<'a>> {
|
|
media
|
|
.all
|
|
.as_ref()
|
|
.and_then(|all| all.get(index))
|
|
.and_then(media_item_choice)
|
|
.or_else(|| match media_kind {
|
|
Some(MediaSelectorKind::Photo) => media
|
|
.photos
|
|
.as_ref()
|
|
.and_then(|photos| photos.get(index))
|
|
.and_then(photo_choice),
|
|
Some(MediaSelectorKind::Video) => media
|
|
.videos
|
|
.as_ref()
|
|
.and_then(|videos| videos.get(index))
|
|
.and_then(video_choice),
|
|
None => first_direct_choice_from_media(media),
|
|
})
|
|
}
|
|
|
|
fn first_direct_choice_from_media(media: &FxMedia) -> Option<MediaChoice<'_>> {
|
|
first_choice_from_all(media)
|
|
.or_else(|| media.photos.as_ref()?.first().and_then(photo_choice))
|
|
.or_else(|| media.videos.as_ref()?.first().and_then(video_choice))
|
|
.or_else(|| mosaic_choice(media))
|
|
}
|
|
|
|
fn first_choice_from_all(media: &FxMedia) -> Option<MediaChoice<'_>> {
|
|
media
|
|
.all
|
|
.as_ref()
|
|
.and_then(|all| all.first())
|
|
.and_then(media_item_choice)
|
|
}
|
|
|
|
fn mosaic_choice(media: &FxMedia) -> Option<MediaChoice<'_>> {
|
|
let mosaic = media.mosaic.as_ref()?;
|
|
Some(MediaChoice::Image {
|
|
url: mosaic.image_url()?,
|
|
width: mosaic.width,
|
|
height: mosaic.height,
|
|
})
|
|
}
|
|
|
|
fn photo_choice(photo: &FxPhoto) -> Option<MediaChoice<'_>> {
|
|
Some(MediaChoice::Image {
|
|
url: photo.url.as_deref()?,
|
|
width: photo.width,
|
|
height: photo.height,
|
|
})
|
|
}
|
|
|
|
fn video_choice(video: &FxVideo) -> Option<MediaChoice<'_>> {
|
|
Some(MediaChoice::Video {
|
|
url: video.url.as_deref()?,
|
|
thumbnail_url: video.thumbnail_url.as_deref(),
|
|
width: video.width,
|
|
height: video.height,
|
|
duration: video.duration,
|
|
})
|
|
}
|
|
|
|
fn media_item_choice(item: &FxMediaItem) -> Option<MediaChoice<'_>> {
|
|
let item_type = item.item_type.as_deref().unwrap_or_default();
|
|
let is_video = item_type == "video"
|
|
|| (item_type == "gif" && (item.thumbnail_url.is_some() || item.duration.is_some()))
|
|
|| item.thumbnail_url.is_some()
|
|
|| item.duration.is_some();
|
|
|
|
if is_video {
|
|
return Some(MediaChoice::Video {
|
|
url: item.transcode_url.as_deref().or(item.url.as_deref())?,
|
|
thumbnail_url: item.thumbnail_url.as_deref(),
|
|
width: item.width,
|
|
height: item.height,
|
|
duration: item.duration,
|
|
});
|
|
}
|
|
|
|
let image_url = item
|
|
.formats
|
|
.as_ref()
|
|
.and_then(|formats| formats.jpeg.as_deref().or(formats.webp.as_deref()))
|
|
.or(item.url.as_deref())?;
|
|
Some(MediaChoice::Image {
|
|
url: image_url,
|
|
width: item.width,
|
|
height: item.height,
|
|
})
|
|
}
|
|
|
|
async fn build_photo_media(ctx: &ResolveContext<'_>, photo: &FxPhoto) -> Option<EmbedMedia> {
|
|
let url = photo.url.as_deref()?;
|
|
build_image_media(ctx, url, photo.width, photo.height).await
|
|
}
|
|
|
|
async fn build_image_media(
|
|
ctx: &ResolveContext<'_>,
|
|
url: &str,
|
|
width: Option<u32>,
|
|
height: Option<u32>,
|
|
) -> Option<EmbedMedia> {
|
|
let nsfw = crate::media_proxy::MediaProxyClient::nsfw_mode_str(ctx.nsfw_mode);
|
|
let meta = ctx.media_proxy.get_metadata(url, nsfw).await.ok()?;
|
|
Some(EmbedMedia {
|
|
url: Some(url.to_owned()),
|
|
proxy_url: ctx.media_proxy.external_proxy_url(url),
|
|
content_type: Some(meta.content_type.clone()),
|
|
content_hash: Some(meta.content_hash.clone()),
|
|
width: width.or(meta.width),
|
|
height: height.or(meta.height),
|
|
placeholder: meta.placeholder.clone(),
|
|
duration: meta.duration.map(|duration| duration as u32),
|
|
flags: embed_media_flags(&meta),
|
|
..Default::default()
|
|
})
|
|
}
|
|
|
|
async fn build_video_pair(
|
|
ctx: &ResolveContext<'_>,
|
|
video: &FxVideo,
|
|
) -> (Option<EmbedMedia>, Option<EmbedMedia>) {
|
|
build_video_pair_from_parts(
|
|
ctx,
|
|
video.url.as_deref().unwrap_or_default(),
|
|
video.thumbnail_url.as_deref(),
|
|
video.width,
|
|
video.height,
|
|
video.duration,
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn build_video_pair_from_parts(
|
|
ctx: &ResolveContext<'_>,
|
|
video_url: &str,
|
|
thumbnail_url: Option<&str>,
|
|
width: Option<u32>,
|
|
height: Option<u32>,
|
|
duration: Option<f64>,
|
|
) -> (Option<EmbedMedia>, Option<EmbedMedia>) {
|
|
let nsfw = crate::media_proxy::MediaProxyClient::nsfw_mode_str(ctx.nsfw_mode);
|
|
|
|
let thumbnail = match thumbnail_url {
|
|
Some(thumb_url) => match ctx.media_proxy.get_metadata(thumb_url, nsfw).await {
|
|
Ok(meta) => Some(EmbedMedia {
|
|
url: Some(thumb_url.to_owned()),
|
|
proxy_url: ctx.media_proxy.external_proxy_url(thumb_url),
|
|
content_type: Some(meta.content_type.clone()),
|
|
content_hash: Some(meta.content_hash.clone()),
|
|
width: meta.width,
|
|
height: meta.height,
|
|
placeholder: meta.placeholder.clone(),
|
|
flags: embed_media_flags(&meta),
|
|
..Default::default()
|
|
}),
|
|
Err(_) => None,
|
|
},
|
|
None => None,
|
|
};
|
|
|
|
if video_url.is_empty() {
|
|
return (thumbnail, None);
|
|
}
|
|
let Ok(meta) = ctx.media_proxy.get_metadata(video_url, nsfw).await else {
|
|
return (thumbnail, None);
|
|
};
|
|
let video_media = EmbedMedia {
|
|
url: Some(video_url.to_owned()),
|
|
proxy_url: ctx.media_proxy.external_proxy_url(video_url),
|
|
content_type: Some(meta.content_type.clone()),
|
|
content_hash: Some(meta.content_hash.clone()),
|
|
width: width.or(meta.width),
|
|
height: height.or(meta.height),
|
|
placeholder: meta.placeholder.clone(),
|
|
duration: duration.or(meta.duration).map(|duration| duration as u32),
|
|
flags: embed_media_flags(&meta),
|
|
..Default::default()
|
|
};
|
|
(thumbnail, Some(video_media))
|
|
}
|
|
|
|
fn apply_image_name(url: &str, name: Option<&str>) -> String {
|
|
let Some(name) = name else {
|
|
return url.to_owned();
|
|
};
|
|
let Ok(mut parsed) = Url::parse(url) else {
|
|
return url.to_owned();
|
|
};
|
|
|
|
let path = parsed.path().to_owned();
|
|
if let Some(colon_idx) = path.rfind(':') {
|
|
let slash_idx = path.rfind('/').unwrap_or(0);
|
|
if colon_idx > slash_idx {
|
|
parsed.set_path(&path[..colon_idx]);
|
|
}
|
|
}
|
|
let query_pairs: Vec<(String, String)> = parsed
|
|
.query_pairs()
|
|
.filter(|(key, _)| key != "name")
|
|
.map(|(key, value)| (key.into_owned(), value.into_owned()))
|
|
.collect();
|
|
{
|
|
let mut query = parsed.query_pairs_mut();
|
|
query.clear();
|
|
for (key, value) in query_pairs {
|
|
query.append_pair(&key, &value);
|
|
}
|
|
if !name.is_empty() {
|
|
query.append_pair("name", name);
|
|
}
|
|
}
|
|
parsed.to_string()
|
|
}
|
|
|
|
fn build_description(tweet: &FxTweet) -> String {
|
|
let mut sections: Vec<String> = Vec::new();
|
|
let text = tweet.text.as_deref().unwrap_or("");
|
|
if !text.is_empty() {
|
|
sections.push(format_body(text));
|
|
}
|
|
if let Some(translation) = tweet.translation.as_ref().and_then(build_translation_block) {
|
|
sections.push(translation);
|
|
}
|
|
if let Some(quote) = tweet.quote.as_deref() {
|
|
sections.push(build_quote_block(quote));
|
|
}
|
|
sections.push(build_stats(tweet));
|
|
sections.join("\n\n")
|
|
}
|
|
|
|
fn build_translation_block(translation: &FxTranslation) -> Option<String> {
|
|
let text = translation
|
|
.text
|
|
.as_deref()
|
|
.filter(|text| !text.is_empty())?;
|
|
let label = match (
|
|
translation.source_lang.as_deref(),
|
|
translation.target_lang.as_deref(),
|
|
) {
|
|
(Some(source), Some(target)) => format!("**Translation ({source} -> {target})**"),
|
|
(_, Some(target)) => format!("**Translation ({target})**"),
|
|
_ => "**Translation**".to_owned(),
|
|
};
|
|
Some(format!("{label}\n{}", format_body(text)))
|
|
}
|
|
|
|
fn build_quote_block(quote: &FxQuote) -> String {
|
|
let screen = quote
|
|
.author
|
|
.as_ref()
|
|
.and_then(|author| author.screen_name.as_deref())
|
|
.unwrap_or_default();
|
|
let name = quote
|
|
.author
|
|
.as_ref()
|
|
.and_then(|author| author.name.as_deref())
|
|
.unwrap_or(screen);
|
|
let quote_url = quote
|
|
.url
|
|
.clone()
|
|
.unwrap_or_else(|| format!("https://x.com/{screen}/status/"));
|
|
let author_url = format!("https://x.com/{screen}");
|
|
let header = format!(
|
|
"> **[Quoting]({quote_url}) {name} \\([@{screen}]({author_url})\\)**",
|
|
name = escape_markdown(name),
|
|
);
|
|
let body = format_body(quote.text.as_deref().unwrap_or(""))
|
|
.split('\n')
|
|
.map(|line| format!("> {line}"))
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
format!("{header}\n{QUOTE_EMPTY_LINE}\n{body}")
|
|
}
|
|
|
|
fn build_stats(tweet: &FxTweet) -> String {
|
|
let id = tweet.id.as_deref().unwrap_or_default();
|
|
let mut out = String::from("**");
|
|
out.push_str(&stat_segment(
|
|
EMOJI_REPLY,
|
|
&format!("https://x.com/intent/tweet?in_reply_to={id}"),
|
|
tweet.replies.unwrap_or(0),
|
|
));
|
|
out.push_str(&stat_segment(
|
|
EMOJI_RETWEET,
|
|
&format!("https://x.com/intent/retweet?tweet_id={id}"),
|
|
tweet.retweets.unwrap_or(0),
|
|
));
|
|
out.push_str(&stat_segment(
|
|
EMOJI_LIKE,
|
|
&format!("https://x.com/intent/like?tweet_id={id}"),
|
|
tweet.likes.unwrap_or(0),
|
|
));
|
|
if let Some(views) = tweet.views {
|
|
out.push_str(EMOJI_VIEWS);
|
|
out.push(' ');
|
|
out.push_str(&escape_markdown(&format_number(views)));
|
|
out.push_str(STAT_SEP);
|
|
}
|
|
out.push_str("**");
|
|
out
|
|
}
|
|
|
|
fn stat_segment(emoji: &str, intent_url: &str, count: u64) -> String {
|
|
format!(
|
|
"[{emoji}]({intent_url}) {count}{STAT_SEP}",
|
|
count = escape_markdown(&format_number(count)),
|
|
)
|
|
}
|
|
|
|
fn format_number(count: u64) -> String {
|
|
if count >= 1_000_000 {
|
|
format!("{:.2}M", count as f64 / 1_000_000.0)
|
|
} else if count >= 1_000 {
|
|
format!("{:.1}K", count as f64 / 1_000.0)
|
|
} else {
|
|
count.to_string()
|
|
}
|
|
}
|
|
|
|
fn format_body(text: &str) -> String {
|
|
let mut out = String::with_capacity(text.len());
|
|
let mut rest = text;
|
|
while let Some((start, end)) = next_url(rest) {
|
|
out.push_str(&escape_markdown(&rest[..start]));
|
|
out.push_str(&linkify_url(&rest[start..end]));
|
|
rest = &rest[end..];
|
|
}
|
|
out.push_str(&escape_markdown(rest));
|
|
out
|
|
}
|
|
|
|
fn next_url(text: &str) -> Option<(usize, usize)> {
|
|
const SCHEMES: [&str; 2] = ["https://", "http://"];
|
|
for (start, _) in text.char_indices() {
|
|
let rest = &text[start..];
|
|
if SCHEMES.iter().any(|scheme| rest.starts_with(scheme)) {
|
|
let end = rest
|
|
.find(char::is_whitespace)
|
|
.map_or(text.len(), |offset| start + offset);
|
|
return Some((start, end));
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn linkify_url(url: &str) -> String {
|
|
let label = url
|
|
.strip_prefix("https://")
|
|
.or_else(|| url.strip_prefix("http://"))
|
|
.unwrap_or(url);
|
|
format!("[{label}]({url})")
|
|
}
|
|
|
|
fn escape_markdown(text: &str) -> String {
|
|
let mut out = String::with_capacity(text.len());
|
|
for ch in text.chars() {
|
|
if matches!(
|
|
ch,
|
|
'\\' | '*' | '_' | '~' | '`' | '|' | '>' | '[' | ']' | '(' | ')' | '.'
|
|
) {
|
|
out.push('\\');
|
|
}
|
|
out.push(ch);
|
|
}
|
|
out
|
|
}
|
|
|
|
fn format_timestamp(unix_seconds: i64) -> Option<String> {
|
|
chrono::DateTime::from_timestamp(unix_seconds, 0)
|
|
.map(|dt| dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
const TEXT_JACK: &str = include_str!("testdata/fxtwitter/text_jack.json");
|
|
const TRANSLATED_JACK_ES: &str = include_str!("testdata/fxtwitter/translated_jack_es.json");
|
|
const SINGLE_PHOTO_INTERIOR: &str =
|
|
include_str!("testdata/fxtwitter/single_photo_interior.json");
|
|
const VIDEO_EMINEM: &str = include_str!("testdata/fxtwitter/video_eminem.json");
|
|
const GIF_ROROFLI: &str = include_str!("testdata/fxtwitter/gif_rorofli.json");
|
|
const GALLERY_ATTIC: &str = include_str!("testdata/fxtwitter/gallery_attic.json");
|
|
const QUOTE_SCANDINAVIAN: &str = include_str!("testdata/fxtwitter/quote_scandinavian.json");
|
|
const MIXED_BLACKPRINTS: &str = include_str!("testdata/fxtwitter/mixed_blackprints.json");
|
|
|
|
fn u(s: &str) -> Url {
|
|
Url::parse(s).unwrap()
|
|
}
|
|
|
|
fn fixture(json: &str) -> FxTweet {
|
|
serde_json::from_str::<FxResponse>(json)
|
|
.expect("fixture parses")
|
|
.tweet
|
|
.expect("fixture contains tweet")
|
|
}
|
|
|
|
fn test_media_proxy() -> crate::media_proxy::MediaProxyClient {
|
|
crate::media_proxy::MediaProxyClient::new_with_public_endpoint(
|
|
"http://media-proxy:8080",
|
|
"secret",
|
|
Some("https://media.example.test"),
|
|
reqwest::Client::new(),
|
|
)
|
|
}
|
|
|
|
fn test_context<'a>(
|
|
media_proxy: &'a crate::media_proxy::MediaProxyClient,
|
|
) -> ResolveContext<'a> {
|
|
ResolveContext {
|
|
url: u("https://fxtwitter.com/AdolphsonFalkk/status/2050662857763688642"),
|
|
original_url: u("https://fxtwitter.com/AdolphsonFalkk/status/2050662857763688642"),
|
|
http_client: reqwest::Client::new(),
|
|
nsfw_mode: crate::types::NsfwMode::Block,
|
|
media_proxy,
|
|
self_hosted: false,
|
|
youtube_api_key: None,
|
|
klipy_api_key: None,
|
|
}
|
|
}
|
|
|
|
fn assert_image_choice(
|
|
choice: MediaChoice<'_>,
|
|
expected_url: &str,
|
|
expected_width: Option<u32>,
|
|
expected_height: Option<u32>,
|
|
) {
|
|
match choice {
|
|
MediaChoice::Image { url, width, height } => {
|
|
assert_eq!(url, expected_url);
|
|
assert_eq!(width, expected_width);
|
|
assert_eq!(height, expected_height);
|
|
}
|
|
MediaChoice::Video { .. } => panic!("expected image media choice"),
|
|
}
|
|
}
|
|
|
|
fn assert_video_choice(
|
|
choice: MediaChoice<'_>,
|
|
expected_url: &str,
|
|
expected_thumbnail_url: Option<&str>,
|
|
expected_width: Option<u32>,
|
|
expected_height: Option<u32>,
|
|
expected_duration: Option<f64>,
|
|
) {
|
|
match choice {
|
|
MediaChoice::Video {
|
|
url,
|
|
thumbnail_url,
|
|
width,
|
|
height,
|
|
duration,
|
|
} => {
|
|
assert_eq!(url, expected_url);
|
|
assert_eq!(thumbnail_url, expected_thumbnail_url);
|
|
assert_eq!(width, expected_width);
|
|
assert_eq!(height, expected_height);
|
|
assert_eq!(duration, expected_duration);
|
|
}
|
|
MediaChoice::Image { .. } => panic!("expected video media choice"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn fxtwitter_author_and_footer_icons_include_proxy_urls() {
|
|
let media_proxy = test_media_proxy();
|
|
let ctx = test_context(&media_proxy);
|
|
let author = FxAuthor {
|
|
screen_name: Some("AdolphsonFalkk".to_owned()),
|
|
name: Some("Elise".to_owned()),
|
|
avatar_url: Some(
|
|
"https://pbs.twimg.com/profile_images/2011041762391842816/cMiGvRb-_200x200.jpg"
|
|
.to_owned(),
|
|
),
|
|
};
|
|
|
|
let embed_author = build_fxtwitter_author(
|
|
&ctx,
|
|
&author,
|
|
"https://fxtwitter.com/AdolphsonFalkk/status/2050662857763688642",
|
|
"fallback",
|
|
);
|
|
assert_eq!(
|
|
embed_author.icon_url.as_deref(),
|
|
author.avatar_url.as_deref()
|
|
);
|
|
assert!(
|
|
embed_author
|
|
.proxy_icon_url
|
|
.as_deref()
|
|
.is_some_and(|url| url.starts_with("https://media.example.test/external/"))
|
|
);
|
|
|
|
let footer = build_fxtwitter_footer(&ctx);
|
|
assert_eq!(footer.icon_url.as_deref(), Some(FXTWITTER_FOOTER_ICON));
|
|
assert!(
|
|
footer
|
|
.proxy_icon_url
|
|
.as_deref()
|
|
.is_some_and(|url| url.starts_with("https://media.example.test/external/"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn matches_only_fxtwitter_domains() {
|
|
for host in [
|
|
"https://fxtwitter.com/a/status/1",
|
|
"https://fixupx.com/a/status/1",
|
|
"https://twittpr.com/a/status/1",
|
|
"https://xfixup.com/a/status/1",
|
|
"https://www.fxtwitter.com/a/status/1",
|
|
"https://d.fixupx.com/a/status/1",
|
|
"https://dl.xfixup.com/a/status/1",
|
|
"https://g.twittpr.com/a/status/1",
|
|
"https://t.fxtwitter.com/a/status/1",
|
|
"https://m.fixupx.com/a/status/1",
|
|
] {
|
|
assert!(FxTwitterResolver.matches(&u(host)), "should match {host}");
|
|
}
|
|
for host in [
|
|
"https://x.com/a/status/1",
|
|
"https://twitter.com/a/status/1",
|
|
"https://mobile.twitter.com/a/status/1",
|
|
"https://vxtwitter.com/a/status/1",
|
|
"https://fixvx.com/a/status/1",
|
|
"https://example.com/a/status/1",
|
|
"https://notfxtwitter.com/a/status/1",
|
|
] {
|
|
assert!(
|
|
!FxTwitterResolver.matches(&u(host)),
|
|
"should NOT match {host}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parses_status_ids_from_path_shapes() {
|
|
assert_eq!(
|
|
parse_status(&u("https://x.com/jack/status/20")),
|
|
Some(("jack".to_owned(), "20".to_owned()))
|
|
);
|
|
assert_eq!(
|
|
parse_status(&u("https://twitter.com/jack/statuses/20?s=21")),
|
|
Some(("jack".to_owned(), "20".to_owned()))
|
|
);
|
|
assert_eq!(
|
|
parse_status(&u("https://x.com/i/web/status/12345")),
|
|
Some(("i".to_owned(), "12345".to_owned()))
|
|
);
|
|
assert_eq!(
|
|
parse_status(&u("https://fxtwitter.com/status/12345/es")),
|
|
Some(("i".to_owned(), "12345".to_owned()))
|
|
);
|
|
assert_eq!(parse_status(&u("https://x.com/jack")), None);
|
|
assert_eq!(parse_status(&u("https://x.com/jack/status/notanid")), None);
|
|
assert_eq!(parse_status(&u("https://x.com/home")), None);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_fxembed_url_modifiers() {
|
|
let direct = parse_status_request(&u("https://d.fxtwitter.com/user/status/123")).unwrap();
|
|
assert_eq!(direct.screen_name, "user");
|
|
assert_eq!(direct.status_id, "123");
|
|
assert!(direct.flags.direct);
|
|
assert_eq!(direct.language, None);
|
|
|
|
let stacked = parse_status_request(&u(
|
|
"https://dl.xfixup.com/user/status/123/photo/3/es?name=small",
|
|
))
|
|
.unwrap();
|
|
assert!(stacked.flags.direct);
|
|
assert_eq!(stacked.media_kind, Some(MediaSelectorKind::Photo));
|
|
assert_eq!(stacked.media_index, Some(2));
|
|
assert_eq!(stacked.language.as_deref(), Some("es"));
|
|
assert_eq!(stacked.direct_media_name.as_deref(), Some("small"));
|
|
assert_eq!(
|
|
fxtwitter_api_url(&stacked),
|
|
"https://api.fxtwitter.com/user/status/123/es"
|
|
);
|
|
|
|
let suffix =
|
|
parse_status_request(&u("https://fxtwitter.com/user/status/123.jpg:orig")).unwrap();
|
|
assert!(suffix.flags.direct);
|
|
assert_eq!(suffix.status_id, "123");
|
|
assert_eq!(suffix.direct_media_name.as_deref(), Some("orig"));
|
|
|
|
let path_prefix =
|
|
parse_status_request(&u("https://fxtwitter.com/dl/user/status/123/video/2/en"))
|
|
.unwrap();
|
|
assert!(path_prefix.flags.direct);
|
|
assert_eq!(path_prefix.screen_name, "user");
|
|
assert_eq!(path_prefix.media_kind, Some(MediaSelectorKind::Video));
|
|
assert_eq!(path_prefix.media_index, Some(1));
|
|
assert_eq!(path_prefix.language.as_deref(), Some("en"));
|
|
}
|
|
|
|
#[test]
|
|
fn parses_presentation_modifier_flags() {
|
|
let gallery = parse_status_request(&u("https://g.fixupx.com/a/status/1")).unwrap();
|
|
assert!(gallery.flags.gallery);
|
|
assert!(!gallery.flags.direct);
|
|
|
|
let text = parse_status_request(&u("https://t.fxtwitter.com/a/status/1/fr")).unwrap();
|
|
assert!(text.flags.text_only);
|
|
assert_eq!(text.language.as_deref(), Some("fr"));
|
|
|
|
let mosaic = parse_status_request(&u("https://m.twittpr.com/a/status/1")).unwrap();
|
|
assert!(mosaic.flags.force_mosaic);
|
|
|
|
let instant = parse_status_request(&u("https://i.fxtwitter.com/a/status/1")).unwrap();
|
|
assert!(instant.flags.force_instant_view);
|
|
|
|
let old = parse_status_request(&u("https://o.fixupx.com/a/status/1")).unwrap();
|
|
assert!(old.flags.old_embed);
|
|
}
|
|
|
|
#[test]
|
|
fn applies_direct_image_name_like_fxembed() {
|
|
assert_eq!(
|
|
apply_image_name(
|
|
"https://pbs.twimg.com/media/a.jpg?name=orig&format=jpg",
|
|
Some("small")
|
|
),
|
|
"https://pbs.twimg.com/media/a.jpg?format=jpg&name=small"
|
|
);
|
|
assert_eq!(
|
|
apply_image_name("https://pbs.twimg.com/media/a.jpg:orig", Some("large")),
|
|
"https://pbs.twimg.com/media/a.jpg?name=large"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_video_media_item_with_formats_array() {
|
|
let tweet = fixture(VIDEO_EMINEM);
|
|
let media = tweet.media.as_ref().unwrap();
|
|
let choice = first_choice_from_all(media).unwrap();
|
|
assert_video_choice(
|
|
choice,
|
|
"https://video.twimg.com/amplify_video/943561926340218880/vid/1280x720/2RNJO2a6D60ZDTTi.mp4",
|
|
Some(
|
|
"https://pbs.twimg.com/amplify_video_thumb/943561926340218880/img/s0HDjV8X4Qhvpg3J.jpg",
|
|
),
|
|
Some(1280),
|
|
Some(720),
|
|
Some(32.366),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_live_fxtwitter_fixture_shapes() {
|
|
for (name, json) in [
|
|
("text", TEXT_JACK),
|
|
("translation", TRANSLATED_JACK_ES),
|
|
("photo", SINGLE_PHOTO_INTERIOR),
|
|
("video", VIDEO_EMINEM),
|
|
("gif", GIF_ROROFLI),
|
|
("gallery", GALLERY_ATTIC),
|
|
("quote", QUOTE_SCANDINAVIAN),
|
|
("mixed", MIXED_BLACKPRINTS),
|
|
] {
|
|
let response = serde_json::from_str::<FxResponse>(json)
|
|
.unwrap_or_else(|err| panic!("{name} fixture failed to parse: {err}"));
|
|
assert!(response.tweet.is_some(), "{name} fixture has no tweet");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn text_fixture_formats_description_and_timestamp() {
|
|
let tweet = fixture(TEXT_JACK);
|
|
assert_eq!(
|
|
tweet
|
|
.author
|
|
.as_ref()
|
|
.and_then(|author| author.screen_name.as_deref()),
|
|
Some("jack")
|
|
);
|
|
assert_eq!(
|
|
tweet
|
|
.created_timestamp
|
|
.and_then(format_timestamp)
|
|
.as_deref(),
|
|
Some("2006-03-21T20:50:14Z")
|
|
);
|
|
assert!(select_media_source(&tweet).is_none());
|
|
assert_eq!(
|
|
build_description(&tweet),
|
|
"just setting up my twttr\n\n**[\u{1F4AC}](https://x.com/intent/tweet?in_reply_to=20) 17\\.9K\u{2002}[\u{1F501}](https://x.com/intent/retweet?tweet_id=20) 125\\.8K\u{2002}[\u{2764}\u{FE0F}](https://x.com/intent/like?tweet_id=20) 308\\.7K\u{2002}**"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn translation_fixture_adds_translation_block() {
|
|
let tweet = fixture(TRANSLATED_JACK_ES);
|
|
let description = build_description(&tweet);
|
|
assert!(description.starts_with(
|
|
"just setting up my twttr\n\n**Translation (en -> es)**\nsolo configurando mi twttr"
|
|
));
|
|
assert!(description.ends_with("308\\.7K\u{2002}**"));
|
|
}
|
|
|
|
#[test]
|
|
fn single_photo_fixture_selects_image_media() {
|
|
let tweet = fixture(SINGLE_PHOTO_INTERIOR);
|
|
let media = select_media_source(&tweet).expect("media source");
|
|
assert_eq!(media.all.as_ref().map(Vec::len), Some(1));
|
|
assert_eq!(media.photos.as_ref().map(Vec::len), Some(1));
|
|
assert_image_choice(
|
|
first_choice_from_all(media).unwrap(),
|
|
"https://pbs.twimg.com/media/Bwni7DgCQAABVQh.jpg?name=orig",
|
|
Some(1024),
|
|
Some(682),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn video_fixture_matches_reported_eminem_embed_data() {
|
|
let tweet = fixture(VIDEO_EMINEM);
|
|
assert_eq!(
|
|
tweet
|
|
.author
|
|
.as_ref()
|
|
.and_then(|author| author.name.as_deref()),
|
|
Some("Marshall Mathers")
|
|
);
|
|
assert_eq!(
|
|
tweet
|
|
.created_timestamp
|
|
.and_then(format_timestamp)
|
|
.as_deref(),
|
|
Some("2017-12-20T21:15:03Z")
|
|
);
|
|
let description = build_description(&tweet);
|
|
assert!(description.starts_with("Can\u{2019}t wait to share this\n\n**"));
|
|
assert!(description.contains("tweet_id=943590594491772928) 8\\.4K"));
|
|
assert!(description.contains("31\\.6K"));
|
|
}
|
|
|
|
#[test]
|
|
fn gif_fixture_is_treated_as_video_media() {
|
|
let tweet = fixture(GIF_ROROFLI);
|
|
let media = select_media_source(&tweet).expect("media source");
|
|
assert_eq!(media.videos.as_ref().map(Vec::len), Some(1));
|
|
assert_video_choice(
|
|
first_choice_from_all(media).unwrap(),
|
|
"https://video.twimg.com/tweet_video/HDCxjM_XYAAG6nY.mp4",
|
|
Some("https://pbs.twimg.com/tweet_video_thumb/HDCxjM_XYAAG6nY.jpg"),
|
|
Some(164),
|
|
Some(206),
|
|
Some(0.0),
|
|
);
|
|
assert!(build_description(&tweet).contains("\u{1F441}\u{FE0F} 4\\.80M"));
|
|
}
|
|
|
|
#[test]
|
|
fn gallery_fixture_parses_photo_list_and_mosaic_object_formats() {
|
|
let tweet = fixture(GALLERY_ATTIC);
|
|
let media = select_media_source(&tweet).expect("media source");
|
|
assert_eq!(media.all.as_ref().map(Vec::len), Some(4));
|
|
assert_eq!(media.photos.as_ref().map(Vec::len), Some(4));
|
|
assert_image_choice(
|
|
first_choice_from_all(media).unwrap(),
|
|
"https://pbs.twimg.com/media/EbNvNcpWAAAKY6x.jpg?name=orig",
|
|
Some(2048),
|
|
Some(1365),
|
|
);
|
|
assert_image_choice(
|
|
mosaic_choice(media).unwrap(),
|
|
"https://mosaic.fxtwitter.com/jpeg/1275485415903019009/EbNvNcpWAAAKY6x/EbNvNcrXkAAr940/EbNvNcqWkAAxPzx/EbNvNcqWoAI4OCc",
|
|
None,
|
|
None,
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn quote_fixture_promotes_quote_media_when_status_media_is_empty() {
|
|
let tweet = fixture(QUOTE_SCANDINAVIAN);
|
|
let media = select_media_source(&tweet).expect("quote media source");
|
|
assert_image_choice(
|
|
first_choice_from_all(media).unwrap(),
|
|
"https://pbs.twimg.com/media/HHVbs9nW0AAaeVg.jpg?name=orig",
|
|
Some(1170),
|
|
Some(1428),
|
|
);
|
|
let description = build_description(&tweet);
|
|
assert!(description.contains(
|
|
"> **[Quoting](https://x.com/ScandinavianAE/status/2050646038420045877) Scandinavian Aesthetics"
|
|
));
|
|
assert!(description.contains("\u{1F441}\u{FE0F} 7\\.40M"));
|
|
}
|
|
|
|
#[test]
|
|
fn mixed_media_fixture_keeps_all_order_for_numbered_selection() {
|
|
let tweet = fixture(MIXED_BLACKPRINTS);
|
|
let media = select_media_source(&tweet).expect("media source");
|
|
assert_eq!(media.all.as_ref().map(Vec::len), Some(2));
|
|
assert_eq!(media.photos.as_ref().map(Vec::len), Some(1));
|
|
assert_eq!(media.videos.as_ref().map(Vec::len), Some(1));
|
|
assert_image_choice(
|
|
selected_choice_from_media(media, 0, None).unwrap(),
|
|
"https://pbs.twimg.com/media/FeLsnyxUoAE9IQQ.jpg?name=orig",
|
|
Some(1364),
|
|
Some(2048),
|
|
);
|
|
assert_video_choice(
|
|
selected_choice_from_media(media, 1, None).unwrap(),
|
|
"https://video.twimg.com/ext_tw_video/1577082987920834561/pu/vid/720x1280/nheMJUJTacHgkP-5.mp4?tag=12",
|
|
Some(
|
|
"https://pbs.twimg.com/ext_tw_video_thumb/1577082987920834561/pu/img/lpjkeE_5PK1x0Dyp.jpg",
|
|
),
|
|
Some(720),
|
|
Some(1280),
|
|
Some(12.866),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn format_number_matches_fxtwitter() {
|
|
assert_eq!(format_number(538), "538");
|
|
assert_eq!(format_number(723), "723");
|
|
assert_eq!(format_number(9_057), "9.1K");
|
|
assert_eq!(format_number(17_876), "17.9K");
|
|
assert_eq!(format_number(125_897), "125.9K");
|
|
assert_eq!(format_number(308_997), "309.0K");
|
|
assert_eq!(format_number(296_573), "296.6K");
|
|
assert_eq!(format_number(7_401_595), "7.40M");
|
|
assert_eq!(format_number(1_230_000), "1.23M");
|
|
assert_eq!(format_number(3_440_000), "3.44M");
|
|
assert_eq!(format_number(0), "0");
|
|
assert_eq!(format_number(999), "999");
|
|
assert_eq!(format_number(1_000), "1.0K");
|
|
}
|
|
|
|
#[test]
|
|
fn escape_markdown_escapes_observed_set() {
|
|
assert_eq!(escape_markdown("religion..."), "religion\\.\\.\\.");
|
|
assert_eq!(escape_markdown("9.1K"), "9\\.1K");
|
|
assert_eq!(escape_markdown("a*b_c~d"), "a\\*b\\_c\\~d");
|
|
assert_eq!(escape_markdown("(x)[y]"), "\\(x\\)\\[y\\]");
|
|
assert_eq!(escape_markdown("@handle #tag it's"), "@handle #tag it's");
|
|
}
|
|
|
|
#[test]
|
|
fn format_timestamp_matches_reference() {
|
|
assert_eq!(
|
|
format_timestamp(1_777_751_099).as_deref(),
|
|
Some("2026-05-02T19:44:59Z")
|
|
);
|
|
assert_eq!(
|
|
format_timestamp(1_142_974_214).as_deref(),
|
|
Some("2006-03-21T20:50:14Z")
|
|
);
|
|
}
|
|
|
|
fn quote_example() -> FxQuote {
|
|
FxQuote {
|
|
url: Some("https://x.com/ScandinavianAE/status/2050646038420045877".to_owned()),
|
|
text: Some("Sweden \u{1F1F8}\u{1F1EA}".to_owned()),
|
|
author: Some(FxAuthor {
|
|
screen_name: Some("ScandinavianAE".to_owned()),
|
|
name: Some("Scandinavian Aesthetics".to_owned()),
|
|
avatar_url: None,
|
|
}),
|
|
media: None,
|
|
}
|
|
}
|
|
|
|
fn tweet_example() -> FxTweet {
|
|
FxTweet {
|
|
id: Some("2050662857763688642".to_owned()),
|
|
text: Some("No that's fine I just imagined it would be a bit bigger".to_owned()),
|
|
created_timestamp: Some(1_777_751_099),
|
|
author: Some(FxAuthor {
|
|
screen_name: Some("AdolphsonFalkk".to_owned()),
|
|
name: Some("Elise \u{1F352}\u{1F352}\u{2671}".to_owned()),
|
|
avatar_url: Some("https://example.com/a.jpg".to_owned()),
|
|
}),
|
|
replies: Some(538),
|
|
retweets: Some(9_057),
|
|
likes: Some(296_573),
|
|
views: Some(7_401_595),
|
|
quote: Some(Box::new(quote_example())),
|
|
media: None,
|
|
translation: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn build_quote_block_matches_reference() {
|
|
assert_eq!(
|
|
build_quote_block("e_example()),
|
|
"> **[Quoting](https://x.com/ScandinavianAE/status/2050646038420045877) Scandinavian Aesthetics \\([@ScandinavianAE](https://x.com/ScandinavianAE)\\)**\n> \u{FE00}\n> Sweden \u{1F1F8}\u{1F1EA}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_stats_matches_reference_with_views() {
|
|
assert_eq!(
|
|
build_stats(&tweet_example()),
|
|
"**[\u{1F4AC}](https://x.com/intent/tweet?in_reply_to=2050662857763688642) 538\u{2002}[\u{1F501}](https://x.com/intent/retweet?tweet_id=2050662857763688642) 9\\.1K\u{2002}[\u{2764}\u{FE0F}](https://x.com/intent/like?tweet_id=2050662857763688642) 296\\.6K\u{2002}\u{1F441}\u{FE0F} 7\\.40M\u{2002}**"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_stats_omits_views_when_absent() {
|
|
let mut tweet = tweet_example();
|
|
tweet.id = Some("20".to_owned());
|
|
tweet.replies = Some(17_876);
|
|
tweet.retweets = Some(125_897);
|
|
tweet.likes = Some(308_997);
|
|
tweet.views = None;
|
|
tweet.quote = None;
|
|
assert_eq!(
|
|
build_stats(&tweet),
|
|
"**[\u{1F4AC}](https://x.com/intent/tweet?in_reply_to=20) 17\\.9K\u{2002}[\u{1F501}](https://x.com/intent/retweet?tweet_id=20) 125\\.9K\u{2002}[\u{2764}\u{FE0F}](https://x.com/intent/like?tweet_id=20) 309\\.0K\u{2002}**"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_description_quote_tweet_matches_reference() {
|
|
assert_eq!(
|
|
build_description(&tweet_example()),
|
|
"No that's fine I just imagined it would be a bit bigger\n\n> **[Quoting](https://x.com/ScandinavianAE/status/2050646038420045877) Scandinavian Aesthetics \\([@ScandinavianAE](https://x.com/ScandinavianAE)\\)**\n> \u{FE00}\n> Sweden \u{1F1F8}\u{1F1EA}\n\n**[\u{1F4AC}](https://x.com/intent/tweet?in_reply_to=2050662857763688642) 538\u{2002}[\u{1F501}](https://x.com/intent/retweet?tweet_id=2050662857763688642) 9\\.1K\u{2002}[\u{2764}\u{FE0F}](https://x.com/intent/like?tweet_id=2050662857763688642) 296\\.6K\u{2002}\u{1F441}\u{FE0F} 7\\.40M\u{2002}**"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_description_single_photo_matches_reference() {
|
|
let tweet = FxTweet {
|
|
id: Some("896523232098078720".to_owned()),
|
|
text: Some(
|
|
"\"No one is born hating another person because of the color of his skin or his background or his religion...\""
|
|
.to_owned(),
|
|
),
|
|
created_timestamp: Some(1_502_582_769),
|
|
author: None,
|
|
replies: Some(58_500),
|
|
retweets: Some(1_230_000),
|
|
likes: Some(3_440_000),
|
|
views: None,
|
|
quote: None,
|
|
media: None,
|
|
translation: None,
|
|
};
|
|
assert_eq!(
|
|
build_description(&tweet),
|
|
"\"No one is born hating another person because of the color of his skin or his background or his religion\\.\\.\\.\"\n\n**[\u{1F4AC}](https://x.com/intent/tweet?in_reply_to=896523232098078720) 58\\.5K\u{2002}[\u{1F501}](https://x.com/intent/retweet?tweet_id=896523232098078720) 1\\.23M\u{2002}[\u{2764}\u{FE0F}](https://x.com/intent/like?tweet_id=896523232098078720) 3\\.44M\u{2002}**"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn format_body_linkifies_urls_and_escapes_the_rest() {
|
|
assert_eq!(
|
|
format_body("Four more years. http://t.co/bAJE6Vom"),
|
|
"Four more years\\. [t.co/bAJE6Vom](http://t.co/bAJE6Vom)"
|
|
);
|
|
assert_eq!(
|
|
format_body("see https://t.co/aB9 ...thanks."),
|
|
"see [t.co/aB9](https://t.co/aB9) \\.\\.\\.thanks\\."
|
|
);
|
|
assert_eq!(
|
|
format_body("http://t.co/a http://t.co/b"),
|
|
"[t.co/a](http://t.co/a) [t.co/b](http://t.co/b)"
|
|
);
|
|
assert_eq!(format_body("just text."), "just text\\.");
|
|
}
|
|
|
|
#[test]
|
|
fn build_description_linkifies_body_url_matches_reference() {
|
|
let tweet = FxTweet {
|
|
id: Some("266031293945503744".to_owned()),
|
|
text: Some("Four more years. http://t.co/bAJE6Vom".to_owned()),
|
|
created_timestamp: Some(1_352_261_778),
|
|
author: None,
|
|
replies: Some(46_995),
|
|
retweets: Some(693_048),
|
|
likes: Some(461_344),
|
|
views: None,
|
|
quote: None,
|
|
media: None,
|
|
translation: None,
|
|
};
|
|
assert_eq!(
|
|
build_description(&tweet),
|
|
"Four more years\\. [t.co/bAJE6Vom](http://t.co/bAJE6Vom)\n\n**[\u{1F4AC}](https://x.com/intent/tweet?in_reply_to=266031293945503744) 47\\.0K\u{2002}[\u{1F501}](https://x.com/intent/retweet?tweet_id=266031293945503744) 693\\.0K\u{2002}[\u{2764}\u{FE0F}](https://x.com/intent/like?tweet_id=266031293945503744) 461\\.3K\u{2002}**"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_description_includes_translation_suffix_result() {
|
|
let mut tweet = tweet_example();
|
|
tweet.quote = None;
|
|
tweet.translation = Some(FxTranslation {
|
|
text: Some("Hello world.".to_owned()),
|
|
source_lang: Some("ja".to_owned()),
|
|
target_lang: Some("en".to_owned()),
|
|
});
|
|
let description = build_description(&tweet);
|
|
assert!(description.starts_with(
|
|
"No that's fine I just imagined it would be a bit bigger\n\n**Translation (ja -> en)**\nHello world\\."
|
|
));
|
|
assert!(description.contains(
|
|
"\n\n**[\u{1F4AC}](https://x.com/intent/tweet?in_reply_to=2050662857763688642) "
|
|
));
|
|
}
|
|
|
|
fn media_with(photos: usize, videos: usize) -> FxMedia {
|
|
FxMedia {
|
|
all: None,
|
|
photos: (photos > 0).then(|| {
|
|
(0..photos)
|
|
.map(|i| FxPhoto {
|
|
url: Some(format!("https://pbs.twimg.com/media/p{i}.jpg")),
|
|
width: Some(100),
|
|
height: Some(100),
|
|
})
|
|
.collect()
|
|
}),
|
|
videos: (videos > 0).then(|| {
|
|
(0..videos)
|
|
.map(|_| FxVideo {
|
|
url: Some("https://video.twimg.com/v.mp4".to_owned()),
|
|
thumbnail_url: Some("https://pbs.twimg.com/t.jpg".to_owned()),
|
|
width: Some(1280),
|
|
height: Some(720),
|
|
duration: None,
|
|
})
|
|
.collect()
|
|
}),
|
|
mosaic: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn select_media_source_prefers_tweet_then_quote() {
|
|
let mut tweet = tweet_example();
|
|
tweet.media = Some(media_with(1, 0));
|
|
let selected = select_media_source(&tweet).unwrap();
|
|
assert_eq!(
|
|
selected.photos.as_ref().unwrap()[0].url.as_deref(),
|
|
Some("https://pbs.twimg.com/media/p0.jpg")
|
|
);
|
|
|
|
let mut quote = quote_example();
|
|
quote.media = Some(media_with(1, 0));
|
|
let promoting = FxTweet {
|
|
media: None,
|
|
quote: Some(Box::new(quote)),
|
|
..tweet_example()
|
|
};
|
|
assert!(select_media_source(&promoting).is_some());
|
|
|
|
let bare = FxTweet {
|
|
media: None,
|
|
quote: None,
|
|
..tweet_example()
|
|
};
|
|
assert!(select_media_source(&bare).is_none());
|
|
let empty = FxTweet {
|
|
media: Some(FxMedia::default()),
|
|
quote: None,
|
|
..tweet_example()
|
|
};
|
|
assert!(select_media_source(&empty).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn build_description_empty_text_is_stats_only() {
|
|
let tweet = FxTweet {
|
|
id: Some("1299530165463199747".to_owned()),
|
|
text: Some(String::new()),
|
|
created_timestamp: None,
|
|
author: None,
|
|
replies: Some(135_100),
|
|
retweets: Some(1_760_000),
|
|
likes: Some(6_590_000),
|
|
views: None,
|
|
quote: None,
|
|
media: None,
|
|
translation: None,
|
|
};
|
|
assert_eq!(
|
|
build_description(&tweet),
|
|
"**[\u{1F4AC}](https://x.com/intent/tweet?in_reply_to=1299530165463199747) 135\\.1K\u{2002}[\u{1F501}](https://x.com/intent/retweet?tweet_id=1299530165463199747) 1\\.76M\u{2002}[\u{2764}\u{FE0F}](https://x.com/intent/like?tweet_id=1299530165463199747) 6\\.59M\u{2002}**"
|
|
);
|
|
}
|
|
}
|