diff --git a/Cargo.lock b/Cargo.lock index bfc97ed..ccfe90d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -59,6 +59,7 @@ dependencies = [ "metrics-exporter-prometheus", "redis 0.27.6", "redis-macros", + "regex", "reqwest 0.12.9", "serde", "serde_json", @@ -1289,14 +1290,14 @@ dependencies = [ [[package]] name = "regex" -version = "1.10.5" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b91213439dad192326a0d7c6ee3955910425f441d7038e0d6933b0aec5c4517f" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.7", - "regex-syntax 0.8.4", + "regex-automata 0.4.9", + "regex-syntax 0.8.5", ] [[package]] @@ -1310,13 +1311,13 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.7" +version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.4", + "regex-syntax 0.8.5", ] [[package]] @@ -1327,9 +1328,9 @@ checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" [[package]] name = "regex-syntax" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "reqwest" diff --git a/Cargo.toml b/Cargo.toml index a8c0472..d2b4de4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ metrics = "0.24.1" metrics-exporter-prometheus = { version = "0.16.0", default-features = false } redis = { version = "0.27.6", features = ["ahash", "tokio-comp"] } redis-macros = "0.4.2" +regex = "1.11.1" reqwest = { version = "0.12.9", default-features = false, features = [ "http2", "charset", diff --git a/src/main.rs b/src/main.rs index ced5a59..2169835 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,11 +6,16 @@ mod discord; mod downloads; mod metrics; mod tiers; +mod twitter; mod util; use std::sync::{Arc, LazyLock}; -use axum::{middleware, routing::get, Router}; +use axum::{ + middleware, + routing::{get, post}, + Router, +}; use reqwest::{ header::{HeaderMap, USER_AGENT}, StatusCode, @@ -78,6 +83,7 @@ async fn start_main_app(config: EnvCfg) -> anyhow::Result<()> { .merge(downloads::router()) .merge(tiers::router()) .route("/generate_invite", get(discord::generate_invite)) + .route("/twitter", post(twitter::webhook)) .fallback(|| async { (StatusCode::NOT_FOUND, "Not Found") }) .layer(TraceLayer::new_for_http().on_request(|_: &_, _: &_| {})) .layer(middleware::from_fn(metrics::track)) diff --git a/src/twitter.rs b/src/twitter.rs new file mode 100644 index 0000000..b64a4d4 --- /dev/null +++ b/src/twitter.rs @@ -0,0 +1,136 @@ +use anyhow::Result; +use axum::extract::Json; +use regex::Regex; +use reqwest::header::{ACCEPT, AUTHORIZATION}; +use serde::Deserialize; +use serde_json::json; + +use crate::RouteResponse; + +#[derive(Deserialize)] +pub struct ProcessData { + tweet_url: String, + tweet_body: String, + tweet_author: String, + cobalt_url: String, + cobalt_key: String, + webhook_url: String, + webhook_avatar: String, +} + +// very, *very* minimal representation of a cobalt response, using only the stuff we need +#[derive(Debug, Deserialize)] +#[serde(tag = "status", rename_all = "lowercase")] +enum CobaltResponse { + Error, + Picker { picker: Vec }, + Redirect, + Tunnel, +} + +#[derive(Debug, Deserialize)] +struct PickerObj { + url: String, +} + +pub async fn webhook(Json(data): Json) -> RouteResponse<()> { + tokio::spawn(async move { + let mut message = format!("New tweet by {}: ", data.tweet_author); + + let cobalt = fetch_cobalt(&data.cobalt_url, &data.cobalt_key, &data.tweet_url).await; + let cobalt = match cobalt { + Ok(res) => res, + Err(e) => { + tracing::warn!("Could not fetch cobalt data for {}: {e}", &data.tweet_url); + return; + } + }; + + message.push_str(&cobalt); + message.push_str(&resolve_tco_urls(&data.tweet_body)); + + let webhook_res = send_webhook( + &data.webhook_url, + &message, + &data.tweet_author, + &data.webhook_avatar, + ) + .await; + + if let Err(e) = webhook_res { + tracing::warn!("Could not send webhook message: {e}"); + } + }); + + Ok(()) +} + +/// fetch attachment urls, or replace link with fxtwitter if there are none +async fn fetch_cobalt(cobalt_url: &str, cobalt_key: &str, tweet_url: &str) -> Result { + let request = crate::CLIENT + .post(cobalt_url) + .json(&json!({ "url": tweet_url })) + .header(ACCEPT, "application/json") + .header(AUTHORIZATION, format!("Api-Key {cobalt_key}")); + + let response = request + .send() + .await? + .error_for_status()? + .json::() + .await?; + + let out = match response { + CobaltResponse::Picker { picker } => { + let urls = picker + .iter() + .enumerate() + .map(|(idx, p)| format!("[{}]({})", idx + 1, p.url)) + .collect::>(); + + format!("{tweet_url} (attachments: {})", urls.join(" ")) + } + _ => tweet_url.replace("x.com", "fxtwitter.com"), + }; + + Ok(out) +} + +fn resolve_tco_urls(tweet_body: &str) -> String { + let tco_regex = Regex::new(r"https://t\.co/\S+").unwrap(); + let tco_urls = tco_regex + .captures_iter(tweet_body) + .map(|c| c.extract()) + .map(|(url, [])| url) + .enumerate() + .map(|(idx, url)| format!("[{}]({url})", idx + 1)) + .collect::>(); + + if tco_urls.is_empty() { + String::new() + } else { + format!(" (urls: {})", tco_urls.join(" ")) + } +} + +async fn send_webhook( + webhook_url: &str, + message: &str, + webhook_name: &str, + webhook_avatar: &str, +) -> Result<()> { + let params = json!({ + "content": message, + "username": webhook_name, + "avatar_url": webhook_avatar + }); + + crate::CLIENT + .post(webhook_url) + .json(¶ms) + .send() + .await? + .error_for_status()?; + + Ok(()) +}