diff --git a/.github/workflows/check_formatting.yml b/.github/workflows/check_formatting.yml new file mode 100644 index 0000000..88e86cd --- /dev/null +++ b/.github/workflows/check_formatting.yml @@ -0,0 +1,32 @@ +name: check_formatting.yml + +on: + push: + branches: [main] + pull_request: + +jobs: + rust: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust (nightly) + uses: dtolnay/rust-toolchain@master + with: + toolchain: stable + components: rustfmt, clippy + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + + - name: Format check + run: cargo fmt -- --check + + - name: Clippy + run: cargo clippy --all-targets --all-features -- -D warnings + + - name: Test + run: cargo test --all-features diff --git a/.gitignore b/.gitignore index fe06271..f0cb534 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .env +.zed Cargo.lock target diff --git a/Cargo.toml b/Cargo.toml index 69201a2..ab7f4e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,9 +5,11 @@ authors = ["LTurret "] edition = "2024" [dependencies] +async-trait = "0.1.89" dotenv = "0.15.0" +html-escape = "0.2.13" regex = "1.12.2" -reqwest = "0.12.24" +reqwest = { version = "0.12.24", features = ["cookies", "json"] } serde_json = "1.0.145" serenity = { version = "0.12.4", default-features = false, features = ["client", "gateway", "rustls_backend", "model"] } tokio = { version = "1.48.0", features = ["macros", "rt-multi-thread"] } diff --git a/README.md b/README.md index deef6d9..878b1ed 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,6 @@ # ArisaMatsuda -English|[繁體中文](./README_zh-TW.md) - -A discord bot for private server management. +A discord bot for help domain content embedding. ## Configuration @@ -12,12 +10,20 @@ Before hosting this bot directly from this repo, There are few steps need to do, ```plain ArisaMatsuda/ -├── src -│   └── main.rs +. ├── Cargo.toml ├── LICENSE ├── README.md -└── README_zh-TW.md +├── rustfmt.toml +└── src + ├── main.rs + └── commands + ├── author.rs + ├── embed.rs + ├── instagram.rs + ├── mod.rs + ├── threads.rs + └── twitter.rs ``` ### Secrets @@ -53,31 +59,6 @@ cargo build --release ./target/release/arisa_rust & ``` -- Docker: - - ```sh - wip - ``` - -## Roadmap - -> [!NOTE] -> Some of features are removed from the list since the last Python build. -> Please refer to the main branch for more information. - -There would be more tracks in the project tab! - -| **File** | **Status** | -|------------------|------------------| -| **tweet_fix.py** | Work in Progress | -| _emotes.py_ | Not implemented | -| _goods.py_ | Not implemented | -| _ping.py_ | Not implemented | -| fun.py | Not implemented | -| tweet_subscribe | Not implemented | -| ~~delete.py~~ | Despreated | -| ~~join.py~~ | Despreated | - ## License Licensed under [MIT](LICENSE). diff --git a/README_zh-TW.md b/README_zh-TW.md deleted file mode 100644 index fb006ec..0000000 --- a/README_zh-TW.md +++ /dev/null @@ -1,83 +0,0 @@ -# ArisaMatsuda - -[English](./README.md)|繁體中文 - -私人伺服器管理用 Discord 機器人。 - -## 設定 - -在直接從此儲存庫執行機器人之前,需要完成一些必要的步驟,否則機器人將無法正常運行。 - -### 目錄結構 - -```plain -ArisaMatsuda/ -├── src -│   └── main.rs -├── Cargo.toml -├── LICENSE -├── README.md -└── README_zh-TW.md -``` - -### 機密資訊與設定 - -機器人權杖(Token)使用 `dotenv::dotenv.ok()` 和 `std::env::var()` 存取。 - -`.env` 檔案應包含以下設定: - -```env -# 必需的設定 -DISCORD_TOKEN="" -``` - -請確保將 plcaeholders 替換為你的實際值。 - -## 建置 - -```sh -cargo build --release -``` - -### 執行 - -- npm/pm2: - - ```sh - pm2 start target/release/arisa_rust --name "arisa" --update-env - ``` - -- Background job: - - ```sh - ./target/release/arisa_rust & - ``` - -- Docker: - - ```sh - wip - ``` - -## 規劃路線圖 - -> [!NOTE] -> 某些功能在之前的 Python 版本中被規劃移除,因此不列入以下表中。 -> 請參照主分支以獲得更多資訊。 - -接下來會再 project 中有更詳細的規劃! - -| **File** | **Status** | -|------------------|------------| -| **tweet_fix.py** | 實作中 | -| _emotes.py_ | 尚未實作 | -| _goods.py_ | 尚未實作 | -| _ping.py_ | 尚未實作 | -| fun.py | 尚未實作 | -| tweet_subscribe | 尚未實作 | -| ~~delete.py~~ | 已棄用 | -| ~~join.py~~ | 已棄用 | - -## 授權 - -本專案採用 [MIT](LICENSE) 授權。 diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..f560266 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,4 @@ +max_width = 80 +tab_spaces = 4 +use_small_heuristics = "Max" +reorder_imports = true diff --git a/src/commands/author.rs b/src/commands/author.rs index 8b41404..8019ebb 100644 --- a/src/commands/author.rs +++ b/src/commands/author.rs @@ -5,7 +5,7 @@ pub struct Author { pub url: String, pub name: String, pub screen_name: String, - pub icon_url: String, + pub icon_url: Option, } impl Author { @@ -18,7 +18,21 @@ impl Author { url: get_string(json_data, "url"), name: get_string(json_data, "name"), screen_name: get_string(json_data, "screen_name"), - icon_url: get_string(json_data, "avatar_url"), + icon_url: json_data["avatar_url"].as_str().map(|s| s.to_string()), + } + } + + pub fn from_str( + url: &String, + name: &String, + screen_name: &String, + icon_url: Option, + ) -> Self { + Self { + url: String::from(url), + name: String::from(name), + screen_name: String::from(screen_name), + icon_url, } } } diff --git a/src/commands/embed.rs b/src/commands/embed.rs index f722cd9..63f2d64 100644 --- a/src/commands/embed.rs +++ b/src/commands/embed.rs @@ -1,86 +1,96 @@ -use crate::commands::twitter::Tweet; -use regex::{Captures, Regex}; -use reqwest::{header::USER_AGENT, Client as HttpClient, Error}; -use serenity::{builder::CreateMessage, prelude::*}; +use crate::commands::{ + instagram::InstagramBuilder, threads::ThreadBuilder, twitter::TweetBuilder, +}; +use async_trait::async_trait; +use regex::Captures; +use reqwest::Client as HttpClient; +use serde_json::json; +use serenity::{ + builder::CreateMessage, http::Typing, model::channel::Message, prelude::*, +}; +use std::env; +use tokio::time::{Duration, sleep}; pub struct Embed; -impl Embed { - pub async fn new_embed(&self, ctx: &Context, caps: &Captures<'_>) -> CreateMessage { - let endpoint: String = caps - .name("endpoint") - .expect("Expected a valid haystack") - .as_str() - .to_string(); - - // Regex Pattern: (http|https)://(?.+)\.com(?(/.+)*) - let raw_api_data: String = match caps - .name("domain") - .expect("Expacted a valid haystack") - .as_str() - { - "x" | "twitter" => self - .fetch_tweet_json(endpoint) - .await - .expect("Err while fetching FxTwitter API"), - "instagram" => self - .fetch_instagram_json(endpoint) - .await - .expect("Err while fetching Instagram post"), - _ => unimplemented!(), - }; - - let tweet: Tweet = Tweet::from_raw(&ctx, raw_api_data).await; - let embed_message: CreateMessage = tweet.to_embed().await; - - embed_message - } +#[async_trait] +pub trait ContentBuilder: Send + Sync { + async fn embed_message( + &self, + endpoint: &str, + ctx: &Context, + ) -> CreateMessage; +} - async fn fetch_tweet_json(&self, endpoint: String) -> Result { - let caps: Captures<'_> = Regex::new(r"(?/.+/status/[0-9]+)(\?.=.+)*") - .expect("Expected a valid regex pattern") - .captures(endpoint.as_str()) - .expect("Expected a valid haystack"); +impl Embed { + async fn suppress_original_embed(msg: &Message) -> () { + let token = env::var("DISCORD_TOKEN") + .expect("Expected a token in the environment"); + sleep(Duration::from_millis(250)).await; - let api_url: String = format!( - "https://api.fxtwitter.com{}", - caps.name("tweet_endpoint") - .expect("Expected a valid haystack") - .as_str() + let url: String = format!( + "https://discord.com/api/v9/channels/{}/messages/{}", + &msg.channel_id, &msg.id ); let client: HttpClient = HttpClient::new(); - let api_json: String = client - .get(api_url) - .header( - USER_AGENT, - "Rust Discord Bot (https://github.com/LTurret/ArisaMatsuda)", + client + .patch(&url) + .header("accept", "*/*") + .header("authorization", format!("Bot {}", token)) + .header("content-type", "application/json") + .body( + json!({ + "flags": 4 + }) + .to_string(), ) .send() - .await? - .text() - .await?; + .await + .ok(); + } + + pub async fn process_url( + ctx: &Context, + msg: &Message, + caps: &Captures<'_>, + ) -> () { + let typing = Typing::start(ctx.http.clone(), msg.channel_id); + + let embed_message = Self::new_embed(ctx, caps).await; + if let Err(why) = + msg.channel_id.send_message(&ctx.http, embed_message).await + { + eprintln!("Error sending message: {why:?}"); + } - Ok(api_json) + Self::suppress_original_embed(msg).await; + typing.stop(); } - async fn fetch_instagram_json(&self, endpoint: String) -> Result { - let clean_endpoint = format!( - "https://www.instagram.com/{}/", - Regex::new(r"(?(/p/.+)/(.+))") - .expect("Expected a valid regex pattern") - .captures(endpoint.as_str()) - .expect("Expected a valid haystack") - .get(0) - .expect("Expected a valid matching") - .as_str() - .to_string() - ); + pub async fn new_embed( + ctx: &Context, + caps: &Captures<'_>, + ) -> CreateMessage { + let endpoint: &str = + caps.name("endpoint").expect("Expected a valid haystack").as_str(); - let client: HttpClient = HttpClient::new(); - let api_json: String = client.get(clean_endpoint).send().await?.text().await?; - println!("{:#?}", &api_json); + // Regex Pattern: (http|https)://(?.+)\.com(?(/.+)*) + let fetcher: Box = match caps + .name("domain") + .expect("Expected a valid haystack") + .as_str() + { + // ContentFetcher selector by domain + "x" | "twitter" => Box::new(TweetBuilder), + "threads" => Box::new(ThreadBuilder), + "instagram" => Box::new(InstagramBuilder), + _ => unimplemented!(), + }; + + let embed_message: CreateMessage = + fetcher.embed_message(endpoint, ctx).await; - Ok(api_json) + embed_message } } diff --git a/src/commands/instagram.rs b/src/commands/instagram.rs index dd1f26d..4f9ace9 100644 --- a/src/commands/instagram.rs +++ b/src/commands/instagram.rs @@ -1,7 +1,231 @@ -use regex::Captures; -use serenity::{builder::CreateMessage, prelude::*}; +use crate::commands::{author::Author, embed::ContentBuilder}; +use async_trait::async_trait; +use html_escape::decode_html_entities; +use regex::Regex; +use reqwest::{Client as HttpClient, header::USER_AGENT}; +use serenity::{ + builder::{ + CreateAllowedMentions, CreateEmbed, CreateEmbedAuthor, + CreateEmbedFooter, CreateMessage, + }, + model::Color, + prelude::*, +}; -pub async fn handler(_ctx: &Context, _caps: &Captures<'_>) -> CreateMessage { - let message = CreateMessage::new(); - message +#[derive(Debug)] +pub struct InstagramPost { + pub author: Author, + pub content: String, + pub videos_supplementary: String, +} + +impl InstagramPost { + async fn from_raw_response(raw_response: String) -> Self { + let author: String = Regex::new( + r#".+)\/p\/"#, + ) + .expect("Regex syntax invalid") + .captures(&raw_response) + .expect("Expected a valid haystack") + .name("author") + .expect("String not match") + .as_str() + .to_string(); + + let url: &String = &format!("https://www.instagram.com/{}", &author); + // let icon_url: String = InstagramPost::get_profile_pic_url(&author).await; + + let raw_content: &str = Regex::new( + r#"(?s).+)".+> = + decode_html_entities(&raw_content).chars().collect(); + let content: String = + content_chars[2..content_chars.len() - 1].iter().collect(); + let videos_supplementary: String = String::from(""); + + Self { + author: Author::from_str(url, &author, &author, None), + content, + videos_supplementary, + } + } + + #[deprecated = "Meta API changes make this don't work anymore, too shame :("] + #[cfg(false)] + async fn get_profile_pic_url(username: &String) -> String { + let mut headers = HeaderMap::new(); + headers + .insert("x-ig-app-id", HeaderValue::from_static("936619743392459")); + + let response = reqwest::Client::new() + .get(format!( + "https://www.instagram.com/api/v1/users/web_profile_info/?username={}", + username + )) + .headers(headers) + .send() + .await + .expect("Request user manifest failed"); + + let res: Value = response.json().await.expect("JSON parse failed"); + + res["data"]["user"]["profile_pic_url"] + .as_str() + .unwrap_or(r#"Did not found "profile_pic_url" in the JSON"#) + .to_string() + } + + #[deprecated = "get_profile_pic_url() is enough to get user icon url"] + #[cfg(false)] + async fn get_icon_url(author: &String) -> String { + use reqwest::{ + Url, + cookie::{CookieStore, Jar}, + header::HeaderName, + }; + use std::sync::Arc; + + let user_id: String = InstagramPost::get_user_id(author).await; + let jar = Arc::new(Jar::default()); + let _client: Response = HttpClient::builder() + .cookie_provider(jar.clone()) + .user_agent("Mozilla/5.0") + .build() + .unwrap() + .get("https://www.instagram.com/") + .send() + .await + .unwrap(); + + let cookie_val: HeaderValue = jar + .as_ref() + .cookies( + &Url::parse( + format!("https://www.instagram.com/{}", author).as_str(), + ) + .unwrap(), + ) + .ok_or("no cookies found") + .unwrap(); + + let cookie_header: String = cookie_val.to_str().unwrap().to_string(); + let csrftoken: String = cookie_header + .split(';') + .find(|c| c.trim().starts_with("csrftoken=")) + .map(|c| { + c.trim().strip_prefix("csrftoken=").unwrap_or("").to_string() + }) + .expect("csrftoken not found"); + + let body = format!( + "variables=%7B%22enable_integrity_filters%22%3Atrue%2C%22id%22%3A%22{}%22%2C%22render_surface%22%3A%22PROFILE%22%2C%22__relay_internal__pv__PolarisCannesGuardianExperienceEnabledrelayprovider%22%3Atrue%2C%22__relay_internal__pv__PolarisCASB976ProfileEnabledrelayprovider%22%3Afalse%2C%22__relay_internal__pv__PolarisRepostsConsumptionEnabledrelayprovider%22%3Afalse%7D&doc_id=25980296051578533", + user_id + ); + + let mut headers = HeaderMap::new(); + let cookie = format!( + "ig_did=B9C9BB5D-2753-46D0-9784-3C94B0FAD0C9;csrftoken={};datr=rl40aad9n6XXVIcGcEsaMfZU;mid=aTRergALAAGAj_Wk-MQWM3oJJrI3;ps_l=1; ps_n=1; ig_nrcb=1; wd=958x944", + csrftoken + ); + + headers.insert( + HeaderName::from_static("cookie"), + HeaderValue::try_from(cookie).expect("cookie invalid"), + ); + headers.insert( + HeaderName::from_static("content-type"), + HeaderValue::from_static("application/x-www-form-urlencoded"), + ); + headers.insert( + HeaderName::from_static("x-csrftoken"), + HeaderValue::try_from(csrftoken).expect("x-csrftoken"), + ); + + let response = HttpClient::new() + .post("https://www.instagram.com/graphql/query") + .headers(headers) + .body(body) + .send() + .await; + + match response { + Ok(res) => { + res.text().await.unwrap(); + } + Err(e) => { + eprintln!("Response parse error: {}", e); + } + } + + response + } + + async fn into_embed(self) -> CreateMessage { + let embed: CreateEmbed = CreateEmbed::new() + .color(Color::new(0xce0071)) + .author( + CreateEmbedAuthor::new(self.author.name).url(self.author.url), + ) + .description(self.content) + .footer(CreateEmbedFooter::new("Instagram").icon_url( + "https://cdn-icons-png.flaticon.com/512/15707/15707749.png", + )) + .url("https://lturret.xyz"); + + let builder: CreateMessage = CreateMessage::new() + .content(&self.videos_supplementary) + .allowed_mentions(CreateAllowedMentions::new().empty_users()) + .embed(embed); + + builder + } +} + +pub struct InstagramBuilder; + +#[async_trait] +impl ContentBuilder for InstagramBuilder { + async fn embed_message( + &self, + endpoint: &str, + _ctx: &Context, + ) -> CreateMessage { + let clean_endpoint = format!( + "https://www.instagram.com/p/{}/", + Regex::new(r"/p/(?.+)/?") + .expect("Expected a valid regex pattern") + .captures(endpoint) + .expect("Expected a valid haystack") + .name("post_id") + .expect("Expected a valid matching") + .as_str() + ); + + let response_result: Result = HttpClient::new() + .get(clean_endpoint) + .header(USER_AGENT, "Rust Discord Bot (https://github.com/LTurret/ArisaMatsuda)") + .send() + .await; + + let response: String = match response_result { + Ok(res) => res.text().await.expect("Failed to read response text"), + Err(e) => { + eprintln!("{}", e); + return CreateMessage::new().content("Failed to fetch post"); + } + }; + + let instagram_post: InstagramPost = + InstagramPost::from_raw_response(response).await; + let embed_message: CreateMessage = instagram_post.into_embed().await; + embed_message + } } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 37c20c0..13f0d78 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,5 +1,5 @@ pub mod author; pub mod embed; pub mod instagram; -pub mod patcher; +pub mod threads; pub mod twitter; diff --git a/src/commands/patcher.rs b/src/commands/patcher.rs deleted file mode 100644 index 1cc2000..0000000 --- a/src/commands/patcher.rs +++ /dev/null @@ -1,80 +0,0 @@ -use crate::commands::{instagram, twitter}; -use regex::{Captures, Regex}; -use reqwest::Client as HttpClient; -use serde_json::json; -use serenity::{builder::CreateMessage, http::Typing, model::channel::Message, prelude::*}; -use std::env; -use tokio::time::{sleep, Duration}; - -pub struct Patcher { - ctx: Context, - msg: Message, -} - -impl Patcher { - pub fn new(ctx: Context, msg: Message) -> Self { - Self { ctx: ctx, msg: msg } - } - - pub async fn parse(&self) -> () { - let typing = Typing::start(self.ctx.http.clone(), self.msg.channel_id); - - let caps: Captures = - Regex::new(r"(http|https)://(www\.)*(?.+)\.(cc|com)(?(/.+)*)") - .expect("Regex syntax inv`alid") - .captures(&self.msg.content) - .expect("Expected a valid haystack"); - - let embed_message: CreateMessage = match caps - .name("domain") - .expect("Expected domain in haystack") - .as_str() - { - "x" | "twitter" => twitter::handler(&self.ctx, &caps).await, - "instagram" => instagram::handler(&self.ctx, &caps).await, - "facebook" => unimplemented!(), - "threads" => unimplemented!(), - "youtube" => unimplemented!(), - "ptt" => unimplemented!(), - _ => unimplemented!(), - }; - - if let Err(why) = self - .msg - .channel_id - .send_message(&self.ctx.http, embed_message) - .await - { - eprintln!("Error sending message: {why:?}"); - } - - let _ = self.remove_old_embed().await; - typing.stop(); - } - - async fn remove_old_embed(&self) -> () { - let token = env::var("DISCORD_TOKEN").expect("Expected a token in the environment"); - sleep(Duration::from_millis(250)).await; - - let url: String = format!( - "https://discord.com/api/v9/channels/{}/messages/{}", - &self.msg.channel_id, &self.msg.id - ); - - let client: HttpClient = HttpClient::new(); - client - .patch(&url) - .header("accept", "*/*") - .header("authorization", format!("Bot {}", token)) - .header("content-type", "application/json") - .body( - json!({ - "flags": 4 - }) - .to_string(), - ) - .send() - .await - .ok(); - } -} diff --git a/src/commands/threads.rs b/src/commands/threads.rs new file mode 100644 index 0000000..791a5f2 --- /dev/null +++ b/src/commands/threads.rs @@ -0,0 +1,148 @@ +use crate::commands::{author::Author, embed::ContentBuilder}; +use async_trait::async_trait; +use html_escape::decode_html_entities; +use regex::Regex; +use reqwest::{Client as HttpClient, header::USER_AGENT}; +use serenity::{ + builder::{ + CreateAllowedMentions, CreateEmbed, CreateEmbedAuthor, + CreateEmbedFooter, CreateMessage, + }, + model::Color, + prelude::*, +}; + +#[derive(Debug)] +pub struct Thread { + pub author: Author, + pub content: String, +} + +impl Thread { + async fn from_raw_response(raw_response: String) -> Self { + let author: String = Regex::new( + r"https://www\.threads\.com/(?@[a-zA-Z0-9._-]+)/", + ) + .expect("Regex syntax invalid") + .captures(&raw_response) + .expect("Expected a valid haystack") + .name("author") + .expect("String not match") + .as_str() + .to_string(); + + let decoded_author_name: String = + decode_html_entities(&author).to_string(); + let url: &String = + &format!("https://www.threads.com/{}", decoded_author_name); + let profile: String = HttpClient::new() + .get(url) + .header( + USER_AGENT, + "Rust Discord Bot (https://github.com/LTurret/ArisaMatsuda)", + ) + .send() + .await + .expect("Connection error") + .text() + .await + .expect("Failed to read response text"); + + let author_alias: String = Regex::new( + r"(?<author_alias>.+)\s\(@.+\)\s.\s.+", + ) + .expect("Regex syntax invalid") + .captures(&profile) + .expect("Expected a valid haystack") + .name("author_alias") + .expect("String not match") + .as_str() + .to_string(); + + let content: String = Regex::new(r"(?<content>[\s\S]+)") + .expect("Regex syntax invalid") + .captures(&raw_response) + .expect("Expected a valid haystack") + .name("content") + .expect("String not match") + .as_str() + .to_string(); + + Self { + author: Author::from_str( + url, + &decoded_author_name, + &author_alias, + None, + ), + content, + } + } + + async fn into_embed(self) -> CreateMessage { + let embed: CreateEmbed = CreateEmbed::new() + .color(Color::new(0x181818)) + .author( + CreateEmbedAuthor::new(format!( + "{} ({})", + self.author.screen_name, self.author.name + )) + .url(self.author.url), + ) + .description(self.content) + .footer(CreateEmbedFooter::new("Threads").icon_url( + "https://cdn-icons-png.flaticon.com/512/12105/12105338.png", + )) + .url("https://lturret.xyz"); + + let builder: CreateMessage = CreateMessage::new() + .allowed_mentions(CreateAllowedMentions::new().empty_users()) + .embed(embed); + + builder + } +} + +pub struct ThreadBuilder; + +#[async_trait] +impl ContentBuilder for ThreadBuilder { + async fn embed_message( + &self, + endpoint: &str, + _ctx: &Context, + ) -> CreateMessage { + let clean_url = format!( + "https://www.threads.com/{}", + Regex::new(r"(?@.+/post/[a-zA-Z0-9._-]+)/?") + .expect("Expected a valid regex pattern") + .captures(endpoint) + .expect("Expected a valid haystack") + .name("thread_endpoint") + .expect("Expected a valid matching") + .as_str() + ); + + let response_result: Result = HttpClient::new() + .get(clean_url) + .header( + USER_AGENT, + "Rust Discord Bot (https://github.com/LTurret/ArisaMatsuda)", + ) + .send() + .await; + + let response: String = match response_result { + Ok(res) => res.text().await.expect("Failed to read response text"), + Err(e) => { + eprintln!("{}", e); + return CreateMessage::new().content("Failed to fetch post"); + } + }; + + let embed_message: CreateMessage = + Thread::from_raw_response(response).await.into_embed().await; + + embed_message + } +} diff --git a/src/commands/twitter.rs b/src/commands/twitter.rs index 41b1ff0..2da4fa4 100644 --- a/src/commands/twitter.rs +++ b/src/commands/twitter.rs @@ -1,14 +1,16 @@ -use crate::commands::{author::Author, embed::Embed}; +use crate::commands::{author::Author, embed::ContentBuilder}; +use async_trait::async_trait; use regex::{Captures, Regex}; -use serde_json::{from_str, Value}; +use reqwest::{Client as HttpClient, header::USER_AGENT}; +use serde_json::{Value, from_str}; use serenity::{ builder::{ - CreateAllowedMentions, CreateAttachment, CreateEmbed, CreateEmbedAuthor, CreateEmbedFooter, - CreateMessage, + CreateAllowedMentions, CreateAttachment, CreateEmbed, + CreateEmbedAuthor, CreateEmbedFooter, CreateMessage, }, model::{ - timestamp::{InvalidTimestamp, Timestamp}, Color, + timestamp::{InvalidTimestamp, Timestamp}, }, prelude::*, }; @@ -24,20 +26,19 @@ pub struct Tweet { } impl Tweet { - pub async fn from_raw(ctx: &Context, raw_api_data: String) -> Self { + async fn from_raw_api(ctx: &Context, raw_api_data: String) -> Self { let json_api_data: Value = from_str(raw_api_data.as_str()).expect("Expected a valid payload"); - let content: String = json_api_data["tweet"]["text"] - .as_str() - .unwrap_or("") - .to_string(); + let content: String = + json_api_data["tweet"]["text"].as_str().unwrap_or("").to_string(); - let timestamp: Result = Timestamp::from_unix_timestamp( - json_api_data["tweet"]["created_timestamp"] - .as_i64() - .unwrap_or(0), - ); + let timestamp: Result = + Timestamp::from_unix_timestamp( + json_api_data["tweet"]["created_timestamp"] + .as_i64() + .unwrap_or(0), + ); let images: Vec = json_api_data["tweet"]["media"]["photos"] .as_array() @@ -47,7 +48,7 @@ impl Tweet { CreateEmbed::new().url("https://lturret.xyz").image( Regex::new(r"(?https://pbs.twimg.com/media/.+\.jpg)(\?.+)*") .expect("Expected a valid regex") - .captures(&obj["url"].as_str().unwrap_or("")) + .captures(obj["url"].as_str().unwrap_or("")) .expect("Expected a valid haystack") .name("image_cdn_url") .expect("Expected a valid matchig") @@ -85,22 +86,23 @@ impl Tweet { } let mut videos_supplementary: String = String::new(); - let _ = raw_videos.iter().enumerate().for_each(|(i, url)| { - videos_supplementary - .push_str(format!("-# [推文影片連結 {}]({})\n", i + 1, url).as_str()) + raw_videos.iter().enumerate().for_each(|(i, url)| { + videos_supplementary.push_str( + format!("-# [推文影片連結 {}]({})\n", i + 1, url).as_str(), + ) }); Self { author: Author::from_json(&json_api_data["tweet"]["author"]), - content: content, - timestamp: timestamp, - images: images, - videos: videos, - videos_supplementary: videos_supplementary, + content, + timestamp, + images, + videos, + videos_supplementary, } } - pub async fn to_embed(self) -> CreateMessage { + async fn into_embed(self) -> CreateMessage { let embed: CreateEmbed = CreateEmbed::new() .color(Color::new(0x00b0f4)) .author( @@ -108,14 +110,13 @@ impl Tweet { "{}(@{})", self.author.name, self.author.screen_name )) - .icon_url(self.author.icon_url) + .icon_url(self.author.icon_url.unwrap()) .url(self.author.url), ) .description(self.content) - .footer( - CreateEmbedFooter::new("Twitter (X)") - .icon_url("https://abs.twimg.com/icons/apple-touch-icon-192x192.png"), - ) + .footer(CreateEmbedFooter::new("Twitter (X)").icon_url( + "https://abs.twimg.com/icons/apple-touch-icon-192x192.png", + )) .url("https://lturret.xyz") .timestamp( self.timestamp @@ -134,7 +135,50 @@ impl Tweet { } } -pub async fn handler(ctx: &Context, caps: &Captures<'_>) -> CreateMessage { - let embed_message = Embed.new_embed(ctx, caps).await; - embed_message +pub struct TweetBuilder; + +#[async_trait] +impl ContentBuilder for TweetBuilder { + async fn embed_message( + &self, + endpoint: &str, + ctx: &Context, + ) -> CreateMessage { + let caps: Captures<'_> = + Regex::new(r"(?/.+/status/[0-9]+)(\?.=.+)*") + .expect("Expected a valid regex pattern") + .captures(endpoint) + .expect("Expected a valid haystack"); + + let api_url: String = format!( + "https://api.fxtwitter.com{}", + caps.name("tweet_endpoint") + .expect("Expected a valid haystack") + .as_str() + ); + + let client: HttpClient = HttpClient::new(); + let response_result = client + .get(api_url) + .header( + USER_AGENT, + "Rust Discord Bot (https://github.com/LTurret/ArisaMatsuda)", + ) + .send() + .await; + + let response = match response_result { + Ok(resp) => resp, + Err(err) => { + eprintln!("{}", err); + return CreateMessage::new().content("Failed to fetch tweet"); + } + }; + + let api_json = + response.text().await.expect("Failed to read response text"); + let tweet: Tweet = Tweet::from_raw_api(ctx, api_json).await; + let embed_message: CreateMessage = tweet.into_embed().await; + embed_message + } } diff --git a/src/main.rs b/src/main.rs index eb84b87..f6befac 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,5 @@ mod commands; -use crate::commands::patcher::Patcher; +use crate::commands::embed::Embed; use dotenv::dotenv; use regex::Regex; use serenity::{ @@ -14,10 +14,15 @@ struct Handler; #[async_trait] impl EventHandler for Handler { async fn message(&self, ctx: Context, msg: Message) { - let re: Regex = Regex::new(r"(http|https)://(www\.)*(?(x|twitter|instagram|facebook|threads|ppt)\.(cc|com))") - .expect("Regex syntax invalid"); - if msg.author.id != UserId::new(1441446989362626772) && re.is_match(&msg.content) { - Patcher::new(ctx, msg).parse().await; + let re: Regex = Regex::new( + r"(http|https)://(www\.)*(?(instagram|twitter|threads|x))\.(cc|com)(?(/.+)*)", + ) + .expect("Regex syntax invalid"); + + if let Some(caps) = re.captures(&msg.content) + && msg.author.id != UserId::new(1441446989362626772) + { + let _ = Embed::process_url(&ctx, &msg, &caps).await; } } @@ -30,7 +35,8 @@ impl EventHandler for Handler { async fn main() { dotenv().ok(); - let token: String = env::var("DISCORD_TOKEN").expect("Expected a token in the environment"); + let token: String = + env::var("DISCORD_TOKEN").expect("Expected a token in the environment"); let intents: GatewayIntents = GatewayIntents::GUILD_MESSAGES | GatewayIntents::DIRECT_MESSAGES