Skip to content

Commit c565d05

Browse files
committed
Add /fx command to fix Twitter links
1 parent 84d5006 commit c565d05

File tree

5 files changed

+462
-162
lines changed

5 files changed

+462
-162
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ Why? Because for some reason it's hit-or-miss whether or not Discord actually ge
66

77
<img src="demo.gif">
88

9+
It also contains a `/fx` command for fixing up Twitter links via [FixTweet](https://github.com/FixTweet/FixTweet), because why not.
10+
911
## Config
1012

1113
Create a file named `discord-threads-link-expander-config.toml` next to the binary (or at the root of the repo if you're doing `cargo run`). Add your bot token like so:
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
use linkify::{LinkFinder, LinkKind};
2+
use std::sync::Arc;
3+
use twilight_http::Client;
4+
use twilight_model::channel::message::MessageFlags;
5+
use twilight_model::http::interaction::{InteractionResponse, InteractionResponseType};
6+
use twilight_model::{
7+
application::interaction::InteractionData,
8+
channel::message::Embed,
9+
gateway::payload::incoming::InteractionCreate,
10+
id::{marker::ApplicationMarker, Id},
11+
};
12+
use twilight_util::builder::{
13+
embed::{EmbedBuilder, ImageSource},
14+
InteractionResponseDataBuilder,
15+
};
16+
use url::Url;
17+
18+
pub async fn handle_expand_threads_link(
19+
interaction: &InteractionCreate,
20+
application_id: Id<ApplicationMarker>,
21+
http_client: Arc<Client>,
22+
web_client: &reqwest::Client,
23+
) -> Result<(), anyhow::Error> {
24+
let threads_links = parse_threads_links(interaction);
25+
let interaction_client = http_client.interaction(application_id);
26+
27+
if threads_links.is_empty() {
28+
let interaction_response_data = InteractionResponseDataBuilder::new()
29+
.content("Sorry, there are no Threads links in this message.")
30+
.flags(MessageFlags::EPHEMERAL)
31+
.build();
32+
interaction_client
33+
.create_response(
34+
interaction.id,
35+
&interaction.token,
36+
&InteractionResponse {
37+
kind: InteractionResponseType::ChannelMessageWithSource,
38+
data: Some(interaction_response_data),
39+
},
40+
)
41+
.await?;
42+
return Ok(());
43+
} else {
44+
let interaction_response_data = InteractionResponseDataBuilder::new()
45+
.content("Loading Threads link info...")
46+
.build();
47+
interaction_client
48+
.create_response(
49+
interaction.id,
50+
&interaction.token,
51+
&InteractionResponse {
52+
kind: InteractionResponseType::DeferredChannelMessageWithSource,
53+
data: Some(interaction_response_data),
54+
},
55+
)
56+
.await?;
57+
}
58+
59+
// TODO: build embeds for all links, not just the first one
60+
let first_link = threads_links.first().unwrap();
61+
let html = web_client
62+
.get(first_link.as_str())
63+
.send()
64+
.await?
65+
.text()
66+
.await?;
67+
let parsed_html = scraper::Html::parse_document(&html);
68+
69+
let embed = build_threads_embed_from_html(&parsed_html)?;
70+
71+
let embeds: Vec<Embed> = vec![embed];
72+
73+
interaction_client
74+
.update_response(&interaction.token)
75+
.embeds(Some(&embeds))?
76+
.await?;
77+
78+
Ok(())
79+
}
80+
81+
fn build_threads_embed_from_html(parsed_html: &scraper::Html) -> Result<Embed, anyhow::Error> {
82+
let threads_title = meta_tag_content(&parsed_html, "property", "og:title").unwrap_or_default();
83+
let threads_url = meta_tag_content(&parsed_html, "property", "og:url").unwrap_or_default();
84+
let threads_description =
85+
meta_tag_content(&parsed_html, "property", "og:description").unwrap_or_default();
86+
let threads_image = meta_tag_content(&parsed_html, "property", "og:image").unwrap_or_default();
87+
let threads_image_type =
88+
meta_tag_content(&parsed_html, "name", "twitter:card").unwrap_or_default();
89+
let image_is_profile_avatar = threads_image_type == "summary";
90+
91+
let embed_builder = EmbedBuilder::new()
92+
.title(threads_title)
93+
.url(threads_url)
94+
.description(threads_description);
95+
96+
let embed_builder = if image_is_profile_avatar {
97+
embed_builder.thumbnail(ImageSource::url(threads_image)?)
98+
} else {
99+
embed_builder.image(ImageSource::url(threads_image)?)
100+
};
101+
102+
Ok(embed_builder.validate()?.build())
103+
}
104+
105+
fn parse_threads_links(interaction: &InteractionCreate) -> Vec<Url> {
106+
let Some(InteractionData::ApplicationCommand(command_data)) = &interaction.data else {
107+
return Vec::new();
108+
};
109+
let Some(resolved_command_data) = &command_data.resolved else {
110+
return Vec::new();
111+
};
112+
let finder = LinkFinder::new();
113+
114+
let links_iterator = resolved_command_data
115+
.messages
116+
.iter()
117+
.map(|(_, m)| m)
118+
.map(|m| &m.content)
119+
.flat_map(|content| {
120+
finder
121+
.links(&content)
122+
.filter(|l| l.kind() == &LinkKind::Url)
123+
.filter_map(|l| Url::parse(l.as_str()).ok())
124+
.filter(|l| {
125+
if let Some(host_str) = l.host_str() {
126+
host_str == "threads.net" || host_str == "www.threads.net"
127+
} else {
128+
false
129+
}
130+
})
131+
});
132+
links_iterator.collect()
133+
}
134+
135+
fn meta_tag_content<'html>(
136+
parsed_html: &'html scraper::Html,
137+
attr_name: &str,
138+
attr_value: &str,
139+
) -> Option<&'html str> {
140+
let tag_selector =
141+
scraper::Selector::parse(&format!(r#"meta[{}="{}"]"#, attr_name, attr_value)).ok()?;
142+
parsed_html
143+
.select(&tag_selector)
144+
.next()
145+
.and_then(|element_ref| element_ref.attr("content"))
146+
}

src/handlers/fix_twitter_link.rs

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
use std::sync::Arc;
2+
3+
use linkify::{Link, LinkFinder, LinkKind};
4+
use twilight_http::Client;
5+
use twilight_model::{
6+
application::{
7+
command::CommandType,
8+
interaction::{application_command::CommandOptionValue, InteractionData},
9+
},
10+
channel::message::MessageFlags,
11+
gateway::payload::incoming::InteractionCreate,
12+
http::interaction::{InteractionResponse, InteractionResponseType},
13+
id::{marker::ApplicationMarker, Id},
14+
};
15+
use twilight_util::builder::InteractionResponseDataBuilder;
16+
use url::Url;
17+
18+
pub async fn handle_fix_twitter_link(
19+
interaction: &InteractionCreate,
20+
application_id: Id<ApplicationMarker>,
21+
http_client: Arc<Client>,
22+
) -> Result<(), anyhow::Error> {
23+
let (original_message, twitter_links) = parse_twitter_links(interaction);
24+
let interaction_client = http_client.interaction(application_id);
25+
26+
if twitter_links.is_empty() {
27+
let interaction_response_data = InteractionResponseDataBuilder::new()
28+
.content("Sorry, there are no Twitter links in this message.")
29+
.flags(MessageFlags::EPHEMERAL)
30+
.build();
31+
interaction_client
32+
.create_response(
33+
interaction.id,
34+
&interaction.token,
35+
&InteractionResponse {
36+
kind: InteractionResponseType::ChannelMessageWithSource,
37+
data: Some(interaction_response_data),
38+
},
39+
)
40+
.await?;
41+
return Ok(());
42+
} else {
43+
let interaction_response_data = InteractionResponseDataBuilder::new()
44+
.content("Fixing Twitter links...")
45+
.build();
46+
interaction_client
47+
.create_response(
48+
interaction.id,
49+
&interaction.token,
50+
&InteractionResponse {
51+
kind: InteractionResponseType::DeferredChannelMessageWithSource,
52+
data: Some(interaction_response_data),
53+
},
54+
)
55+
.await?;
56+
}
57+
58+
let new_message_content = fix_twitter_links_in_place(original_message.clone(), twitter_links);
59+
60+
interaction_client
61+
.update_response(&interaction.token)
62+
.content(Some(&new_message_content))?
63+
.await?;
64+
65+
Ok(())
66+
}
67+
68+
/// Returns (message_content, link_information). Does not handle more than one message.
69+
fn parse_twitter_links(interaction: &InteractionCreate) -> (String, Vec<(Url, Link)>) {
70+
let Some(message_content) = message_content_from_interaction(interaction) else {
71+
return (String::new(), Vec::new());
72+
};
73+
let parsed_link_information = parse_twitter_links_inner(message_content);
74+
75+
(message_content.to_string(), parsed_link_information)
76+
}
77+
78+
// We need to handle interactions coming from both slash commands and message
79+
// commands. Those require some different plumbing to get to the input message,
80+
// which we take care of here.
81+
fn message_content_from_interaction(interaction: &InteractionCreate) -> Option<&str> {
82+
let Some(InteractionData::ApplicationCommand(command_data)) = &interaction.data else {
83+
return None;
84+
};
85+
86+
match command_data.kind {
87+
CommandType::ChatInput => command_data
88+
.options
89+
.iter()
90+
.next()
91+
.map(|opt| &opt.value)
92+
.and_then(|value| match value {
93+
CommandOptionValue::String(value) => Some(value.as_str()),
94+
_ => None,
95+
}),
96+
CommandType::Message => command_data
97+
.resolved
98+
.as_ref()
99+
.and_then(|resolved_command_data| {
100+
resolved_command_data
101+
.messages
102+
.iter()
103+
.next()
104+
.map(|(_, m)| m)
105+
.map(|m| m.content.as_str())
106+
}),
107+
_ => None,
108+
}
109+
}
110+
111+
fn parse_twitter_links_inner(message_content: &str) -> Vec<(Url, Link)> {
112+
let finder = LinkFinder::new();
113+
let links_iterator = finder
114+
.links(message_content)
115+
.filter(|l| l.kind() == &LinkKind::Url)
116+
.filter_map(|l| Url::parse(l.as_str()).ok().zip(Some(l)))
117+
.filter(|(u, _)| {
118+
if let Some(host_str) = u.host_str() {
119+
host_str == "twitter.com"
120+
|| host_str == "mobile.twitter.com"
121+
|| host_str == "x.com"
122+
|| host_str == "mobile.x.com"
123+
} else {
124+
false
125+
}
126+
});
127+
links_iterator.collect()
128+
}
129+
130+
fn fix_twitter_links_in_place(
131+
original_message: String,
132+
twitter_links: Vec<(Url, Link<'_>)>,
133+
) -> String {
134+
let mut new_message_content = original_message;
135+
136+
for (mut parsed_url, link_info) in twitter_links.into_iter().rev() {
137+
let Some(host) = parsed_url.host_str() else {
138+
continue;
139+
};
140+
141+
let new_host = match host {
142+
"twitter.com" => "fxtwitter.com",
143+
"mobile.twitter.com" => "fxtwitter.com",
144+
"x.com" => "fixupx.com",
145+
"mobile.x.com" => "fixupx.com",
146+
_ => "fxtwitter.com",
147+
};
148+
if let Err(_) = parsed_url.set_host(Some(new_host)) {
149+
continue;
150+
}
151+
152+
new_message_content.replace_range(link_info.start()..link_info.end(), parsed_url.as_str());
153+
}
154+
new_message_content
155+
}
156+
157+
#[cfg(test)]
158+
mod tests {
159+
use super::*;
160+
161+
#[test]
162+
fn test_fix_twitter_links_in_place() {
163+
let original_message = r#"
164+
This is a message that contains a twitter link (https://twitter.com/test/test), a
165+
x.com link (https://x.com/test/test), a mobile.twitter.com link
166+
(https://mobile.twitter.com/test/test), a mobile.x.com link
167+
(https://mobile.x.com/test/test), an unrelated link
168+
(https://otherwebsite/test/test), and a weird twitter link
169+
(https://weird.link.twitter.com/test/test).
170+
"#;
171+
let twitter_links = parse_twitter_links_inner(&original_message);
172+
173+
let new_message = fix_twitter_links_in_place(original_message.to_string(), twitter_links);
174+
assert_eq!(
175+
new_message,
176+
r#"
177+
This is a message that contains a twitter link (https://fxtwitter.com/test/test), a
178+
x.com link (https://fixupx.com/test/test), a mobile.twitter.com link
179+
(https://fxtwitter.com/test/test), a mobile.x.com link
180+
(https://fixupx.com/test/test), an unrelated link
181+
(https://otherwebsite/test/test), and a weird twitter link
182+
(https://weird.link.twitter.com/test/test).
183+
"#
184+
);
185+
}
186+
}

src/handlers/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
pub mod expand_threads_link;
2+
pub mod fix_twitter_link;

0 commit comments

Comments
 (0)