[no description]
init
| -rw-r--r-- | .gitignore | 2 | ||||
| -rw-r--r-- | Cargo.toml | 38 | ||||
| -rw-r--r-- | src/main.rs | 285 | ||||
| -rw-r--r-- | src/tenor.rs | 171 |
4 files changed, 496 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0db62eb --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +token +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..e47ff13 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "resizr" +version = "0.1.0" +edition = "2024" + +[dependencies] +tokio = { version = "1.28.2", features = [ + "net", + "sync", + "rt", + "parking_lot", + "rt-multi-thread", +], default-features = false } +anyhow = "1.0" +serenity = { version = "0.12", features = [ + "builder", + "client", + "rustls_backend", + "gateway", + "model", +], default-features = false, git = "https://github.com/serenity-rs/serenity" } +clap = { version = "4.4", features = ["derive"] } +env_logger = "0.10" +log = "0.4" +regex = { version = "1.12.2", features = ["unstable", "use_std"] } +reqwest = "0.11" +scraper = "0.17" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +poise = { git = "https://github.com/serenity-rs/poise", branch = "current" } +rustls = "0.23.36" +openssl = { version = "0.10.35", features = ["vendored"] } +fimg = { git = "https://git.bendn.org/fimg", version = "0.4.51" } +gif = "0.14.1" +image = { version = "0.25.9", features = ["bmp", "dds", "hdr", "ico", "pnm", "tga"] } + +[patch.crates-io] +serenity = { git = "https://github.com/serenity-rs/serenity" } diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..88fb223 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,285 @@ +#![feature(impl_trait_in_fn_trait_return, try_blocks, try_blocks_heterogeneous)] +mod tenor; + +use anyhow::Result; +use fimg::Image; +use fimg::WritePng; +use fimg::scale::Lanczos3; +use fimg::scale::Nearest; +use gif::Frame; +use regex::Regex; +use serenity::all::*; + +use std::sync::Arc; +use std::sync::LazyLock; + +#[tokio::main] +async fn main() { + spawn().await; +} + +async fn on_error(error: poise::FrameworkError<'_, (), anyhow::Error>) { + use poise::FrameworkError::Command; + match error { + Command { error, ctx, .. } => { + let mut msg; + { + let mut chain = error.chain(); + msg = format!("e: `{}`", chain.next().unwrap()); + for mut source in chain { + use std::fmt::Write; + write!(msg, "from: `{source}`").unwrap(); + while let Some(next) = source.source() { + write!(msg, "from: `{next}`").unwrap(); + source = next; + } + } + } + ctx.say(msg).await.unwrap(); + } + err => poise::builtins::on_error(err).await.unwrap(), + } +} + +pub async fn spawn() { + println!("bot startup"); + let tok = include_str!("../token"); + let f = poise::Framework::<(), anyhow::Error>::builder() + .options(poise::FrameworkOptions { + commands: vec![], + event_handler: |c, e, _, ()| { + Box::pin(async move { + match e { + FullEvent::Ready { .. } => { + println!("bot ready"); + } + FullEvent::Message { new_message } => { + if let Err(error) = handle_message(c, new_message).await { + eprintln!("{}", error); + } + } + _ => {} + }; + Ok(()) + }) + }, + on_error: |e| Box::pin(on_error(e)), + prefix_options: poise::PrefixFrameworkOptions { + edit_tracker: Some(Arc::new(poise::EditTracker::for_timespan( + std::time::Duration::from_secs(2 * 60), + ))), + ..Default::default() + }, + ..Default::default() + }) + .setup(|ctx, _ready, _| { + Box::pin(async move { + println!("registered"); + Ok(()) + }) + }) + .build(); + ClientBuilder::new( + tok, + GatewayIntents::non_privileged() | GatewayIntents::MESSAGE_CONTENT, + ) + .framework(f) + .await + .unwrap() + .start() + .await + .unwrap(); +} + +async fn handle_message(c: &poise::serenity_prelude::Context, new_message: &Message) -> Result<()> { + static TENOR: LazyLock<Regex> = + LazyLock::new(|| Regex::new("https?://tenor.com/view/.+-[0-9]+").unwrap()); + static DISCORD: LazyLock<Regex> = LazyLock::new(|| { + Regex::new( + r"https:\/\/(?:cdn|media)\.discordapp\.(?:com|net)\/attachments\/\d+\/\d+\/(?<name>[\w-]+)\.(?<ext>\w+)(?:\?ex=[0-9a-f]+&is=[0-9a-f]+&hm=[0-9a-f]+)?(?:&animated=true)?(?:&width=\d+)?(?:&height=\d+)?&?", + ) + .unwrap() + }); + + if new_message.author.bot { + return Ok(()); + } + if let Some(x) = TENOR.find(&new_message.content) { + let x = x.as_str(); + let (r, e) = tenor::download_url(&x).await?; + if r.media_formats.gif.dims[1] > 160 { + let r = try bikeshed anyhow::Result<()> { + let gif = e().await?; + let (dec, vec) = regif(&gif)?; + let vec = vec(dec)?; + + new_message + .channel_id + .send_message( + c, + CreateMessage::new() + .files([CreateAttachment::bytes(vec, format!("g{}.gif", r.h1_title))]), + ) + .await?; + }; + new_message.delete(c).await?; + r?; + } + } else if let Some(x) = DISCORD.captures(&new_message.content) + && let Some(url) = x.get(0) + && let Some(name) = x.name("name") + && let Some(x) = x.name("ext") + { + let dat = reqwest::get(url.as_str()).await?.bytes().await?; + if x.as_str() == "gif" { + let (dec, vec) = regif(&dat)?; + if dec.height() > 160 { + new_message + .channel_id + .send_message( + c, + CreateMessage::new() + .files([CreateAttachment::bytes( + vec(dec)?, + format!("{}.gif", name.as_str()), + )]) + .content(format!("<@{}>", new_message.author.id)), + ) + .await?; + new_message.delete(c).await?; + } + } else { + let i = if let Some(f) = image::ImageFormat::from_extension(x.as_str()) { + image::load_from_memory_with_format(&*dat, f)?.to_rgba8() + } else { + let Ok(i) = image::load_from_memory(&*dat) else { + return Ok(()); + }; + i.to_rgba8() + }; + if i.height() < 160 { + return Ok(()); + } + let i = Image::<_, 4>::build(i.width(), i.height()) + .buf(i.into_vec()) + .boxed(); + let mut to = Vec::with_capacity(1 << 10); + resize4(i).write(&mut to)?; + new_message + .channel_id + .send_message( + c, + CreateMessage::new() + .files([CreateAttachment::bytes( + to, + format!("{}.png", name.as_str()), + )]) + .content(format!("<@{}>", new_message.author.id)), + ) + .await?; + new_message.delete(c).await?; + } + } + Ok(()) +} + +fn regif( + x: &[u8], +) -> Result<( + gif::Decoder<&[u8]>, + impl FnOnce(gif::Decoder<&[u8]>) -> Result<Vec<u8>>, +)> { + let mut options = gif::DecodeOptions::new(); + options.set_color_output(gif::ColorOutput::Indexed); + + let decoder = options.read_info(x)?; + Ok((decoder, |decoder: gif::Decoder<&[u8]>| { + let mut vec = Vec::with_capacity(1 << 14); + println!("resizing gif..."); + let now = std::time::Instant::now(); + let p = decoder.global_palette(); + let nw = ((decoder.width() as f32 / decoder.height() as f32) * 152.0).round() as u16; + let mut encoder = gif::Encoder::new(&mut vec, nw, 152, p.unwrap_or(&[])).unwrap(); + encoder.set_repeat(decoder.repeat())?; + let bg = decoder.bg_color().unwrap_or(0); + let mut pf = Image::<Vec<u8>, 1>::build(decoder.width() as _, decoder.height() as _).buf( + vec![bg as u8; decoder.width() as usize * decoder.height() as usize], + ); + // let mut first = true; + for frame in decoder.into_iter() { + let frame = frame?; + // if take(&mut first) && let Some(x) = frame.transparent{ + // pf = Image::<Vec<u8>, 1>::build(pf.width(), pf.height()) + // .buf(vec![x as u8; pf.width() as usize * pf.height() as usize]); + // } + + let _pf = pf.clone(); + match frame.dispose { + gif::DisposalMethod::Background => { + pf = Image::<Vec<u8>, 1>::build(pf.width(), pf.height()) + .buf(vec![bg as u8; pf.width() as usize * pf.height() as usize]); + } + _ => {} + } + let x_ = Image::<_, 1>::build(frame.width as _, frame.height as _).buf(&*frame.buffer); + + pf.as_mut().clipping_overlay_at( + &x_, + frame.left as _, + frame.top as _, + frame.transparent, + ); + let nf = pf.scale::<Nearest>(nw as _, 152); + + let f = Frame { + width: nw, + height: 152, + buffer: std::borrow::Cow::Borrowed(nf.bytes()), + top: 0, + left: 0, + ..frame + }; + if f.dispose == gif::DisposalMethod::Previous { + pf = _pf; + } + encoder.write_frame(&f)?; + } + drop(encoder); + println!("took {:?}", now.elapsed()); + Ok(vec) + })) +} + +fn resize4(mut x: Image<Box<[u8]>, 4>) -> Image<Box<[u8]>, 4> { + let nw = ((x.width() as f32 / x.height() as f32) * 152.0).round() as u32; + x.scale::<Lanczos3>(nw, 152) +} + +impl<T: AsMut<[u8]> + AsRef<[u8]>, U: AsRef<[u8]>> OverlayAtClipping<Image<U, 1>> for Image<T, 1> { + #[inline] + #[cfg_attr(debug_assertions, track_caller)] + fn clipping_overlay_at( + &mut self, + with: &Image<U, 1>, + x: u32, + y: u32, + t: Option<u8>, + ) -> &mut Self { + for j in 0..with.height() { + for i in 0..with.width() { + // SAFETY: i, j is in bounds. + if let Some([their_px]) = with.get_pixel(i, j) + && let Some([our_px]) = self.get_pixel_mut(i + x, j + y) + && t != Some(*their_px) + { + *our_px = *their_px; + } + } + } + self + } +} + +trait OverlayAtClipping<W> { + fn clipping_overlay_at(&mut self, with: &W, x: u32, y: u32, t: Option<u8>) -> &mut Self; +} diff --git a/src/tenor.rs b/src/tenor.rs new file mode 100644 index 0000000..0cf79af --- /dev/null +++ b/src/tenor.rs @@ -0,0 +1,171 @@ +use std::collections::HashMap; + +use anyhow::Context; +use reqwest::get; +use scraper::{Html, Selector}; +use serde::Deserialize; + +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Tracks { + pub app_config: AppConfig, + pub config: AppConfig, + pub tags: AppConfig, + pub gifs: Gifs, + pub stickers: Memes, + pub memes: Memes, + pub universal: Universal, + pub packs: Collections, + pub collections: Collections, + pub exploreterms: AppConfig, + pub search_suggestions: AppConfig, + pub profiles: AppConfig, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct AppConfig {} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Collections { + pub by_id: AppConfig, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Gifs { + pub by_id: HashMap<u64, Item>, + pub search_by_username: AppConfig, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct Item { + pub results: Vec<PurpleResult>, + pub promise: AppConfig, + pub loaded: bool, + pub pending: bool, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct PurpleResult { + pub id: String, + pub legacy_info: LegacyInfo, + pub title: String, + // media_formats: HashMap<String, MediaFormat>, + pub media_formats: MediaFormats, + pub bg_color: String, + pub created: f64, + pub content_description: String, + pub h1_title: String, + pub long_title: String, + pub embed: String, + pub itemurl: String, + pub url: String, + pub tags: Vec<String>, + pub flags: Vec<Option<serde_json::Value>>, + pub user: PurpleUser, + pub hasaudio: bool, + pub source_id: String, + pub shares: i64, + pub policy_status: PolicyStatus, + pub index: i64, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct LegacyInfo { + pub post_id: String, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct MediaFormat { + pub url: String, + pub duration: f64, + pub preview: String, + pub dims: [i64; 2], + pub size: i64, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum PolicyStatus { + #[serde(rename = "POLICY_STATUS_UNSPECIFIED")] + PolicyStatusUnspecified, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct PurpleUser { + pub username: String, + pub partnername: String, + pub url: String, + pub tagline: String, + pub userid: String, + pub profile_id: String, + pub avatars: AppConfig, + pub usertype: Usertype, + pub partnerbanner: AppConfig, + pub partnercategories: Vec<Option<serde_json::Value>>, + pub partnerlinks: Vec<Option<serde_json::Value>>, + pub flags: Vec<Option<serde_json::Value>>, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Usertype { + User, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct MediaFormats { + pub gif: MediaFormat, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Memes { + search_by_username: AppConfig, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct Universal { + search: AppConfig, +} + +pub async fn download_url( + url: &str, +) -> anyhow::Result<( + PurpleResult, + impl FnOnce() -> impl Future<Output = anyhow::Result<Vec<u8>>>, +)> { + let response = get(dbg!(url)).await.context("h")?; + let html_content = response.text().await.context("ah")?; + let document = Html::parse_document(&html_content); + let selector = Selector::parse("#store-cache").unwrap(); + let element = document + .select(&selector) + .next() + .ok_or_else(|| anyhow::anyhow!("!#store-cache"))?; + + let json_str = element.inner_html(); + let data = serde_json::from_str::<Tracks>(&json_str).context("alas")?; + let id = url.rsplit('-').next().unwrap_or(""); + let result = data + .gifs + .by_id + .get(&id.parse().unwrap()) + .ok_or_else(|| anyhow::anyhow!("failure to find {}", id))? + .results + .get(0) + .ok_or_else(|| anyhow::anyhow!("not in data"))? + .clone(); + + let gif_url = result.media_formats.gif.url.clone(); + Ok((result, move || async move { + Ok(get(gif_url) + .await + .context("no gif")? + .bytes() + .await + .context("no gif")? + .to_vec()) + })) +} |