[no description]
bendn 4 weeks ago
commit 7a61184
-rw-r--r--.gitignore2
-rw-r--r--Cargo.toml38
-rw-r--r--src/main.rs285
-rw-r--r--src/tenor.rs171
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())
+ }))
+}