Compare commits

..

21 Commits

Author SHA1 Message Date
9591dcdd3e Added override file 2025-10-30 22:24:17 -05:00
d0f0d07b51 Use static kmeans seed 2025-07-31 07:37:47 -05:00
6ee36cb65a Added arbitrary spotify search 2025-07-31 07:03:24 -05:00
d3ad04bc33 Added color cache 2025-07-31 04:10:02 -05:00
42270b18f0 Merged my own stupid divergence 2025-07-31 02:39:15 -05:00
d6ea8977a6 Fixed spotify more 2025-07-31 00:44:48 -05:00
5da8b2b193 Added help command, fixed Spotify search 2025-07-31 00:27:10 -05:00
158633d077 Added spotify link functionality 2025-07-30 22:31:19 -05:00
045d19c4c8 Increment version number 2025-07-30 22:22:15 -05:00
4841ab9f06 Added more functionality 2025-07-30 17:56:01 -05:00
fd87302642 Offically specified v0.2.0 2025-05-21 21:29:56 -05:00
41fbb5dbdf Added LICENSE 2025-05-17 06:27:06 -05:00
b26b94dfa6 Added text rendering, fixed bugs 2025-05-17 06:22:44 -05:00
270878e4fa Added "TODO" to README 2025-05-16 10:51:18 -05:00
88d4b19655 Improved color processing, improved logging 2025-05-16 10:50:00 -05:00
f66f89dc04 Added avatar display 2025-05-16 06:25:18 -05:00
792d9615d6 Added http caching of lastfm images 2025-05-16 02:28:02 -05:00
06102b6ffa Integrated custom magick-rust, added basic db usage 2025-05-15 07:53:30 -05:00
19cd656028 Added .env example and updated readme 2025-05-15 04:07:16 -05:00
e0b86dbc39 Added mask display 2025-05-15 03:23:27 -05:00
5e3bc7a6d8 Updated unmaintained dependency 2025-05-15 01:37:29 -05:00
33 changed files with 3462 additions and 246 deletions

10
.env.example Normal file
View File

@ -0,0 +1,10 @@
DISCORD_TOKEN=
LASTFM_API_KEY=
DATABASE_URL=sqlite:watcat.db
# optional, for spotify support
RSPOTIFY_CLIENT_ID=
RSPOTIFY_CLIENT_SECRET=
# optional, to set color cache size. set to 0 to disable
MAX_CACHE_SIZE=

3
.gitignore vendored
View File

@ -1,2 +1,5 @@
/target
/.env
/watcat.db*
/http-cacache
/overrides.txt

4
.gitmodules vendored Normal file
View File

@ -0,0 +1,4 @@
[submodule "magick-rust"]
path = magick-rust
url = https://git.starbit.dev/seoxi/magick-rust
branch = sr

2555
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,11 +1,30 @@
[package]
name = "watcat"
version = "0.1.0"
version = "0.6.2"
edition = "2024"
[dependencies]
serenity = "0.12"
tokio = { version = "1.21.2", features = ["macros", "rt-multi-thread"] }
dotenv = "0.15.0"
lastfm = "0.10.0"
magick_rust = "1.0.0" #{ path = "../magick-rust" }
dotenvy = "0.15.7"
reqwest = "0.12"
reqwest-middleware = "0.4.2"
http-cache-reqwest = "0.15.1"
palette = "0.7.6"
rspotify = "0.15.0"
# text rendering dependencies
arabic_reshaper = "0.4.2"
unicode-bidi = "0.3.18"
image = "0.25.6"
imagetext = { git = "https://github.com/nathanielfernandes/imagetext" }
[dependencies.magick_rust]
path = "./magick-rust"
[dependencies.tokio]
version = "1.21.2"
features = ["macros", "rt-multi-thread"]
[dependencies.sqlx]
version = "0.8.5"
features = ["sqlite", "runtime-tokio"]

27
LICENSE Normal file
View File

@ -0,0 +1,27 @@
Copyright (c) 2025, Seoxi Ryouko
All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of watcat nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@ -8,9 +8,34 @@ why?
cosmo relies on [imagetext-py](https://github.com/nathanielfernandes/imagetext-py), which either does not have a freebsd wheel or else uv cannot find it
also I'm a rust shill and python hater and just needed an excuse
now it seems that magick-rust can't link on freebsd... guess I'll die
but your code sucks
-------------------
I'm trying, okay?
running
-------
populate .env with necessary values. a template .env.example is given
create the database ``sqlx database create``
(note: make sure you have sqlx installed first)
init the database ``sqlx migrate run``
TODO
----
+ ~~add art and lastfm url fetching~~
+ ~~add help command~~
+ ~~refactor to combine repeated parts of art, url, and fmi.~~
+ ~~use macro for commands~~
+ ~~use Option instead of empty string sentinel value~~
+ ~~add cache for album art colors~~
+ ~~add spotify link fetching~~
+ ~~add arbitrary spotify searching~~
+ ~~use static seed for kmeans~~
+ make various parameters user-configurable
+ use tiny-skia instead of magick-rust?

BIN
fonts/Heebo-Regular.ttf Normal file

Binary file not shown.

BIN
fonts/Heebo-SemiBold.ttf Normal file

Binary file not shown.

BIN
fonts/NotoEmoji-Medium.ttf Normal file

Binary file not shown.

BIN
fonts/NotoEmoji-Regular.ttf Normal file

Binary file not shown.

BIN
fonts/NotoSans-Regular.ttf Normal file

Binary file not shown.

BIN
fonts/NotoSans-SemiBold.ttf Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
fonts/NotoSansHK-Medium.otf Normal file

Binary file not shown.

Binary file not shown.

BIN
fonts/NotoSansJP-Medium.otf Normal file

Binary file not shown.

Binary file not shown.

BIN
fonts/NotoSansKR-Medium.otf Normal file

Binary file not shown.

Binary file not shown.

BIN
fonts/NotoSansSC-Medium.otf Normal file

Binary file not shown.

Binary file not shown.

BIN
fonts/NotoSansTC-Medium.otf Normal file

Binary file not shown.

Binary file not shown.

BIN
fonts/Symbola.ttf Normal file

Binary file not shown.

BIN
fonts/Unifont.otf Normal file

Binary file not shown.

1
magick-rust Submodule

Submodule magick-rust added at 5fbc506983

View File

@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS users
(
discord_userid INTEGER PRIMARY KEY NOT NULL,
lastfm_username TEXT NOT NULL
) STRICT;

BIN
src/accent_mask.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1008 B

BIN
src/avatar_mask.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@ -1,16 +1,43 @@
extern crate dotenvy;
extern crate lastfm;
use dotenv::dotenv;
extern crate reqwest;
extern crate reqwest_middleware;
use http_cache_reqwest::{CACacheManager, Cache, CacheMode, HttpCache, HttpCacheOptions,};
use serenity::async_trait;
use serenity::model::channel::Message;
use serenity::prelude::*;
use serenity::builder::{CreateAttachment, CreateMessage};
use serenity::model::channel::Message;
use serenity::model::id::UserId;
use serenity::prelude::*;
extern crate magick_rust;
use magick_rust::{magick_wand_genesis, MagickWand, PixelWand, CompositeOperator, ColorspaceType};
use magick_rust::{
ColorspaceType, CompositeOperator, FilterType, MagickWand, PixelWand, magick_wand_genesis,
};
use magick_rust::bindings::MagickSetSeed;
use std::env;
use std::sync::Once;
use std::collections::HashMap;
use std::sync::{Arc, Once};
use tokio::sync::Mutex;
use palette::color_difference::ImprovedCiede2000;
use palette::lab::Lab;
use palette::white_point::D65;
extern crate sqlx;
use sqlx::sqlite::SqlitePool;
use rspotify::model::{enums::types::SearchType, search::SearchResult};
use rspotify::{ClientCredsSpotify, Credentials, clients::BaseClient};
use imagetext::fontdb::FontDB;
use imagetext::superfont::SuperFont;
use std::time::SystemTime;
use std::io::Read;
mod text;
static START: Once = Once::new();
@ -18,94 +45,595 @@ struct Handler;
enum Reply {
Image(Vec<u8>),
Text(String)
Text(String),
}
macro_rules! now {
() => {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs_f32() as usize
};
}
macro_rules! log {
($a:expr) => {
if env::var("DEBUG").unwrap_or("0".to_owned()) == "1" {
println!(concat!("debug: {} ", $a), now!())
}
};
($a:expr, $($b:expr),+) => {
if env::var("DEBUG").unwrap_or("0".to_owned()) == "1" {
println!(concat!("debug: {} ", $a), now!(), $($b),+)
}
};
}
macro_rules! handle_magick_result {
($a: expr, $b: literal) => {
match $a {
Ok(_) => {},
Ok(a) => {
log!("{} run successfully", stringify!($a));
a
}
Err(e) => return Reply::Text(format!("Error: {} {}", $b, e)),
}
}
};
}
macro_rules! handle_magick_option {
($a: expr, $b: literal) => {
match $a {
Some(res) => res,
None => return Reply::Text($b.to_string()),
None => return Reply::Text($b.to_owned()),
}
};
}
// this function is almost assuredly the least idiomatic rust code I will ever write.
// but until I know what the "correct" way is, it'll have to do
fn parse_cielab(s: String) -> Result<(f32, f32, f32), &'static str> {
if s.len() < 13 || &s[0..7] != "cielab(" || s.chars().nth(s.len() - 1).unwrap() != ')' {
return Err("Error parsing cielab string");
}
let vals = s[7..(s.len() - 1)]
.split(",")
.map(|x| match x.parse() {
Ok(f) => f,
Err(_) => f32::NAN,
})
.collect::<Vec<f32>>();
if vals.iter().any(|x| x.is_nan()) {
return Err("Error parsing cielab string");
}
match &vals[..] {
&[a, b, c] => Ok((a, b, c)),
_ => Err("Error parsing cielab string"),
}
}
async fn fmi(_arg: &str) -> Reply {
let client = lastfm::Client::<String, &str>::from_env("seoxi");
let now_playing = match client.now_playing().await {
fn validate_color(col: &PixelWand) -> Option<Lab> {
let color_str = match col.get_color_as_string() {
Ok(s) => s,
Err(_) => return None,
};
let color_raw = match parse_cielab(color_str) {
Ok(f) => f,
Err(_) => return None,
};
Some(Lab::<D65>::from_components(color_raw))
}
async fn get_track(
ctx: &Context,
arg: Option<&str>,
id: UserId,
) -> Result<lastfm::track::NowPlayingTrack, Reply> {
let lastfm_user = match arg {
None => get_lastfm_username(ctx, id).await,
Some(user) => Some(user.to_owned()),
};
let lastfm_client = match lastfm_user {
Some(s) => lastfm::Client::<String, String>::from_env(s),
None => return Err(Reply::Text("No last.fm username set.".to_owned())),
};
let now_playing = match lastfm_client.now_playing().await {
Ok(np) => np,
Err(e) => return Reply::Text(format!("Error: grabbing last.fm user data failed {e}")),
Err(e) => {
return Err(Reply::Text(format!(
"Error: grabbing last.fm user data failed {e}"
)));
}
};
if let Some(track) = now_playing {
let image_uri = match track.image.extralarge {
let track = match now_playing {
Some(track) => track,
None => return Err(Reply::Text("Nothing playing.".to_owned())),
};
Ok(track)
}
async fn art(ctx: &Context, arg: Option<&str>, id: UserId) -> Reply {
let track = match get_track(ctx, arg, id).await {
Ok(track) => track,
Err(e) => return e,
};
let track_art = match track.image.extralarge {
Some(track_art) => track_art,
None => return Reply::Text("Error: getting image uri failed".to_owned()),
};
Reply::Text(track_art.to_owned())
}
async fn url(ctx: &Context, arg: Option<&str>, id: UserId) -> Reply {
let track = match get_track(ctx, arg, id).await {
Ok(track) => track,
Err(e) => return e,
};
Reply::Text(track.url)
}
async fn spot(ctx: &Context, arg: Option<&str>, id: UserId) -> Reply {
let track = match get_track(ctx, arg, id).await {
Ok(track) => track,
Err(e) => return e,
};
let mut data = ctx.data.write().await;
let spotify_option = data.get_mut::<SpotifyHaver>().expect("Failed to have spotify option");
if spotify_option.is_none() {
return Reply::Text("Unable to use Spotify commands: Contact bot Administrator.".to_owned())
}
let spotify_arc_mutex = spotify_option.as_mut().unwrap();
let spotify_client = spotify_arc_mutex.lock().await;
spotify_client.request_token().await.unwrap();
let search = spotify_client.search(format!("{} {}", track.name, track.artist.name).as_str(), SearchType::Track, None, None, None, None).await;
let tracks = match search {
Ok(SearchResult::Tracks(track_page)) => track_page,
Err(e) => return Reply::Text(format!("Failed to get track {e}")),
_ => return Reply::Text("Spotify search failed".to_owned()),
};
match &tracks.items.first().map(|x| x.external_urls.get("spotify")) {
Some(Some(url)) => Reply::Text(url.to_owned().to_owned()),
_ => Reply::Text("Unable to get spotify url".to_owned()),
}
}
async fn get_overrides_inner(last_modified: Option<SystemTime>) -> Option<HashMap<String, String>> {
let mut file = match std::fs::File::open("overrides.txt") {
Ok(a) => a,
Err(_) => return None,
};
let file_stats = match file.metadata() {
Ok(a) => a,
Err(_) => return None,
};
let file_modified_time = match file_stats.modified() {
Ok(a) => a,
Err(_) => return None,
};
let reload = match last_modified {
None => true,
Some(last_modified) => match file_modified_time.duration_since(last_modified) {
Err(_) => true,
Ok(a) => a != std::time::Duration::default()
}
};
if !reload { return None; }
let mut contents = "".to_owned();
match file.read_to_string(&mut contents) {
Ok(_) => (),
Err(_) => return None,
};
Some(contents.as_str().lines().map(|x| {
let mut split = x.split("@");
let a: &str = split.next().unwrap_or("").trim();
let b: &str = split.next().unwrap_or("").trim();
(a.to_owned(), b.to_owned())
}).collect())
}
async fn get_overrides(ctx: &Context) -> HashMap<String, String> {
let data = ctx.data.write().await;
let overrides = data.get::<OverridesHaver>().expect("Failed to have overrides");
let mut map = overrides.map.lock().await;
let new_map = get_overrides_inner(overrides.last_modified).await;
if let Some(new_map) = new_map {
*map = new_map;
}
map.clone()
}
async fn fmi(ctx: &Context, arg: Option<&str>, id: UserId, avatar: Option<String>) -> Reply {
let track = match get_track(ctx, arg, id).await {
Ok(track) => track,
Err(e) => return e,
};
let track_info = text::TrackInfo {
title: track.name,
artist: track.artist.name,
album: track.album,
};
let original_image_uri = match track.image.extralarge {
Some(iu) => iu,
None => return Reply::Text("Error: getting image uri failed".to_string()),
None => return Reply::Text("Error: getting image uri failed".to_owned()),
};
let overrides = get_overrides(ctx).await;
let image_uri: String = match overrides.get(&format!("{} | {}", track_info.artist, track_info.album)) {
Some(s) => s,
None => match overrides.get(&track_info.album) {
Some(s) => s,
None => &original_image_uri,
},
}.to_owned();
let mut base_color = PixelWand::new();
#[allow(unused_mut)]
let mut main_wand = MagickWand::new();
#[allow(unused_mut)]
let mut art_wand = MagickWand::new();
//#[allow(unused_mut)]
let mut accent_color = PixelWand::new();
let mut white = PixelWand::new();
let main_wand = MagickWand::new();
let art_wand = MagickWand::new();
let mut art_wand_cluster = MagickWand::new();
let mask_wand = MagickWand::new();
let text_image_wand = MagickWand::new();
handle_magick_result!(base_color.set_color("#7f7f7f"), "Failed to set base color");
handle_magick_result!(art_wand.read_image(image_uri.as_str()), "Failed to read image from uri");
handle_magick_result!(art_wand.transform_image_colorspace(ColorspaceType::Lab), "Failed to set art colorspace");
let image = match get_image(ctx, image_uri.as_str()).await {
Ok(i) => i,
Err(e) => return Reply::Text(format!("{e}")),
};
handle_magick_result!(
art_wand.read_image_blob(image),
"Failed to read image from uri"
);
handle_magick_result!(
art_wand.transform_image_colorspace(ColorspaceType::Lab),
"Failed to set art colorspace"
);
let (art_width, art_height) = (art_wand.get_image_width(), art_wand.get_image_height());
handle_magick_result!(art_wand_cluster.new_image(art_width, art_height, &base_color), "Failed to init cluster image");
handle_magick_result!(art_wand_cluster.compose_images(&art_wand, CompositeOperator::SrcOver, true, 0, 0), "Failed to copy onto cluster image");
// least stupid way I could figure out to do this, since it seems that MagickWand::new_from_wand() doesn't work
handle_magick_result!(art_wand_cluster.set_image_colorspace(ColorspaceType::Lab), "Failed to set cluster colorspace");
handle_magick_result!(art_wand_cluster.kmeans(5, 200, 0.001), "Failed to run kmeans");
let mut colors = handle_magick_option!(art_wand_cluster.get_image_histogram(), "Failed to get color histogram");
println!("{}", colors.len());
colors.sort_by_cached_key(|color| -(color.get_color_count() as isize));
handle_magick_result!(
base_color.set_color("#7f7f7f"),
"Failed to set init base color"
);
handle_magick_result!(main_wand.new_image(548, 147, &colors[0]), "Failed to create wand");
handle_magick_result!(main_wand.transform_image_colorspace(ColorspaceType::Lab), "Failed to set main colorspace");
handle_magick_result!(art_wand.adaptive_resize_image(124, 124), "Failed to resize art");
handle_magick_result!(art_wand_cluster.adaptive_resize_image(124, 124), "Failed to resize art_cluster");
handle_magick_result!(main_wand.compose_images(&art_wand, CompositeOperator::SrcOver, false, 12, 12), "Failed to combine images");
handle_magick_result!(main_wand.compose_images(&art_wand_cluster, CompositeOperator::SrcOver, false, 160, 12), "Failed to combine images");
Reply::Image(main_wand.write_image_blob("png").unwrap())
let color_cache_entry = {
let data = ctx.data.write().await;
let color_cache = data.get::<ColorsHaver>().expect("Failed to have colors option");
if color_cache.max > 0 {
let color_cache_map = color_cache.map.lock().await;
color_cache_map.get(image_uri.as_str()).map(|s| s.to_owned())
} else {
Reply::Text("Nothing playing.".to_string())
None
}
};
let (base_lab, accent_lab) = match color_cache_entry {
Some(value) => value.to_owned(),
None => {
let (art_width, art_height) = (art_wand.get_image_width(), art_wand.get_image_height());
handle_magick_result!(
art_wand_cluster.new_image(art_width, art_height, &base_color),
"Failed to init cluster image"
);
handle_magick_result!(
art_wand_cluster.compose_images(&art_wand, CompositeOperator::SrcOver, true, 0, 0),
"Failed to copy onto cluster image"
);
// least stupid way I could figure out to do this, since it seems that MagickWand::new_from_wand() doesn't work
handle_magick_result!(
art_wand_cluster.set_image_colorspace(ColorspaceType::Lab),
"Failed to set cluster colorspace"
);
unsafe { MagickSetSeed(0x5F3759DF); }
handle_magick_result!(
art_wand_cluster.kmeans(10, 200, 0.001),
"Failed to run kmeans"
);
let mut colors = handle_magick_option!(
art_wand_cluster.get_image_histogram(),
"Failed to get color histogram"
);
colors.sort_by_cached_key(|color| -(color.get_color_count() as isize));
let base_lab = validate_color(&colors[0]).expect("Failed to validate base color");
let other_colors = colors
.iter()
.filter_map(|col| {
let testing_color = validate_color(col)?;
match testing_color.improved_difference(base_lab) {
10.0.. => Some(testing_color),
_ => None,
}
})
.collect::<Vec<_>>();
let mut accent_lab = "cielab(0.0,0.0,0.0)".to_owned();
if let Some(color) = other_colors.first() {
accent_lab = format!("cielab{:?}", color.into_components());
}
let base_lab = handle_magick_result!(colors[0].get_color_as_string(), "Failed to serialize base color");
let data = ctx.data.write().await;
let color_cache = data.get::<ColorsHaver>().expect("Failed to have colors option");
let mut color_cache_map = color_cache.map.lock().await;
if color_cache_map.keys().len() > color_cache.max {
let key: String = color_cache_map.iter().next().unwrap().0.to_owned();
color_cache_map.remove(&key);
}
color_cache_map.insert(image_uri, (base_lab.clone(), accent_lab.clone()));
(base_lab, accent_lab)
}
};
handle_magick_result!(base_color.set_color(base_lab.as_str()), "Failed to deserialize base color");
handle_magick_result!(accent_color.set_color(accent_lab.as_str()), "Failed to deserialize accent color");
let use_dark_color = match validate_color(&base_color) {
Some(color) => {
let black_delta_e = Lab::<D65>::new(0.0, 0.0, 0.0).improved_difference(color);
let white_delta_e = Lab::<D65>::new(100.0, 0.0, 0.0).improved_difference(color);
black_delta_e > white_delta_e
}
_ => false,
};
handle_magick_result!(white.set_color("#ffffff"), "Failed to init white");
handle_magick_result!(
mask_wand.read_image_blob(include_bytes!("accent_mask.png")),
"Failed to load mask"
);
handle_magick_result!(
mask_wand.opaque_paint_image(&white, &accent_color, 0.0, false),
"Failed to paint accent mask"
);
let avatar_wand = MagickWand::new();
match avatar {
Some(avatar_uri) => {
if let Ok(avatar) = get_image(ctx, avatar_uri.as_str()).await {
handle_magick_result!(
avatar_wand.read_image_blob(avatar),
"Failed to load user avatar"
);
} else {
handle_magick_result!(
avatar_wand.new_image(100, 100, &white),
"Failed to load dummy avatar"
);
}
}
None => handle_magick_result!(
avatar_wand.new_image(100, 100, &white),
"Failed to load dummy avatar"
),
}
let avatar_mask_wand = MagickWand::new();
handle_magick_result!(
avatar_mask_wand.read_image_blob(include_bytes!("avatar_mask.png")),
"Failed to load avatar mask"
);
handle_magick_result!(
avatar_wand.compose_images(&avatar_mask_wand, CompositeOperator::CopyAlpha, false, 0, 0),
"Failed to mask avatar"
);
handle_magick_result!(
avatar_wand.resize_image(64, 64, FilterType::RobidouxSharp),
"Failed to resize avatar"
);
handle_magick_result!(
avatar_wand.transform_image_colorspace(ColorspaceType::Lab),
"Failed to set avatar colorspace"
);
let (image_width, image_height) = (548, 147);
let data = ctx.data.write().await;
let fonts = data.get::<FontsHaver>().expect("Failed to load fonts");
let text_image_blob = match text::fmi_text(
image_width,
image_height,
track_info,
fonts,
19.0,
use_dark_color,
) {
Ok(bytes) => bytes,
Err(e) => return Reply::Text(e),
};
handle_magick_result!(
text_image_wand.read_image_blob(&text_image_blob),
"Failed to load text image"
);
handle_magick_result!(
text_image_wand.transform_image_colorspace(ColorspaceType::Lab),
"Failed to set avatar colorspace"
);
handle_magick_result!(
main_wand.new_image(image_width as usize, image_height as usize, &base_color),
"Failed to create wand"
);
handle_magick_result!(
main_wand.transform_image_colorspace(ColorspaceType::Lab),
"Failed to set main colorspace"
);
handle_magick_result!(
art_wand.resize_image(124, 124, FilterType::RobidouxSharp),
"Failed to resize art"
);
/*handle_magick_result!(
art_wand_cluster.adaptive_resize_image(124, 124),
"Failed to resize art_cluster"
);*/
handle_magick_result!(
main_wand.compose_images(&art_wand, CompositeOperator::SrcOver, false, 12, 12),
"Failed to combine art image"
);
handle_magick_result!(
main_wand.compose_images(&mask_wand, CompositeOperator::SrcOver, false, 401, 0),
"Failed to combine mask"
);
#[cfg(debug_assertions)]
if env::var("DEBUG_IMAGE").unwrap_or("0".to_owned()) == "1" {
handle_magick_result!(
main_wand.compose_images(
&art_wand_cluster,
CompositeOperator::SrcOver,
false,
160,
12
),
"Failed to combine debug image"
);
}
handle_magick_result!(
main_wand.compose_images(&text_image_wand, CompositeOperator::SrcOver, false, 0, 0),
"Failed to combine mask"
);
handle_magick_result!(
main_wand.compose_images(&avatar_wand, CompositeOperator::SrcOver, false, 473, 73),
"Failed to combine avatar image"
);
Reply::Image(main_wand.write_image_blob("png").unwrap())
}
async fn text(ctx: &Context, arg: Option<&str>, id: UserId) -> Reply {
let track = match get_track(ctx, arg, id).await {
Ok(track) => track,
Err(e) => return e,
};
Reply::Text(format!(
"{}\n{}\n{}",
track.name,
track.artist.name,
track.album,
))
}
fn help(arg: Option<&str>) -> Reply {
Reply::Text(format!(
"```{}```",
match arg {
None =>
"usage: .k <img|set|art|url|spotify|help> [lastfm user]\n .k img [lastfm user]: get now playing\n .k set <lastfm user>: sets your username for the future\n .k art [lastfm user]: get now playing album art\n .k url [lastfm user]: get now playing lastfm url\n .k spot [lastfm user]: get now playing spotify link\n .k search <search query>: searches spotify\n .k help [command]: displays a help message"
.to_owned(),
Some("img" | "fmi" | "fm" | "get") =>
"usage: .k img [lastfm user]\nreturns a pretty-printed image of the user's now playing track on last.fm, using the album art's colors to theme the image. see also: .k set\naliases: .kfmi, .k <fmi|get|img>, .kf, .ki, or even just .k"
.to_owned(),
Some("set") =>
"usage: .k set <lastfm user>\nties a lastfm username to your account. use this to not require your username for .k fmi every time, for example\naliases: .kset"
.to_owned(),
Some("art") =>
"usage: .k art [lastfm user]\nfetches user's now playing track's album art\naliases: .k art, .kart, .ka"
.to_owned(),
Some("url" | "link") =>
"usage: .k url [lastfm user]\nfetches a lastfm link to user's now playing track\naliases: .k url, .k link, .kl, .ku, .kurl"
.to_owned(),
Some("spot" | "spotify") =>
"usage: .k spotify [lastfm user]\nsearches spotify for the user's now playing track and returns a link to that track\naliases: .k <spot|spotify>, .kx, .kspot"
.to_owned(),
Some("search") =>
"usage: .k search <search query>\nsearches spotify with an arbitrary query and returns the first result\naliases: .k search, .ks".to_owned(),
Some(cmd) => format!("unknown command: {cmd}"),
}
))
}
async fn set(ctx: &Context, arg: &str, id: UserId) -> Reply {
set_lastfm_username(ctx, id, arg.to_owned()).await;
Reply::Text(format!("set user {arg}"))
}
async fn search(ctx: &Context, arg: &str) -> Reply {
let mut data = ctx.data.write().await;
let spotify_option = data.get_mut::<SpotifyHaver>().expect("Failed to have spotify option");
if spotify_option.is_none() {
return Reply::Text("Unable to use Spotify commands: Contact bot Administrator.".to_owned())
}
let spotify_arc_mutex = spotify_option.as_mut().unwrap();
let spotify_client = spotify_arc_mutex.lock().await;
spotify_client.request_token().await.unwrap();
let search = spotify_client.search(arg, SearchType::Track, None, None, None, None).await;
let tracks = match search {
Ok(SearchResult::Tracks(track_page)) => track_page,
Err(e) => return Reply::Text(format!("Failed to get track {e}")),
_ => return Reply::Text("Spotify search failed".to_owned()),
};
match &tracks.items.first().map(|x| x.external_urls.get("spotify")) {
Some(Some(url)) => Reply::Text(url.to_owned().to_owned()),
_ => Reply::Text("Unable to get spotify url".to_owned()),
}
}
async fn set(arg: &str) -> Reply {
Reply::Text(format!("set user {}", arg))
macro_rules! cmd_pattern {
($long_cmd:pat, $short_pattern:pat, $arg_name:pat) => {
(Some(".k"), Some($long_cmd), $arg_name) | (Some(".k"), $arg_name, Some($long_cmd)) | (Some($short_pattern), $arg_name, None)
}
}
#[async_trait]
impl EventHandler for Handler {
async fn message(&self, ctx: Context, msg: Message) {
let mut cmd_iter = msg.content.split(" ");
let cmd = cmd_iter.next().unwrap_or("");
let arg = cmd_iter.next().unwrap_or("");
let resp = match cmd {
".fmi" => Some(fmi(arg).await),
".set" => Some(set(arg).await),
_ => None
let cmd = cmd_iter.next();
let arg1 = cmd_iter.next();
let arg2 = cmd_iter.next();
let resp = match (cmd, arg1, arg2) {
(Some(".k"), Some("help" | "h" | "?"), arg) // skip the ".k <command> help" syntax
| (Some(".kh") | Some(".k?"), arg, None) => {
log!("{} received", msg.content);
Some(help(arg))
}
cmd_pattern!("set", ".kset" | ".set", Some(arg)) =>
/*(".set", arg, "") | (".k", "set", arg) | (".kset", arg, "") =>*/ {
log!("{} received", msg.content);
Some(set(&ctx, arg, msg.author.id).await)
},
cmd_pattern!("art", ".ka" | ".kart", arg) =>
/*(".k", "art", arg) | (".k", arg, "art") | (".ka" | ".kart", arg, "") =>*/ {
log!("{} received", msg.content);
Some(art(&ctx, arg, msg.author.id).await)
},
cmd_pattern!("url" | "uri" | "link", ".ku" | ".kl", arg) =>
/*(".k", "url" | "uri" | "link", arg)
| (".k", arg, "url" | "uri" | "link")
| (".ku" | ".kl" | ".kurl", arg, "") =>*/ {
log!("{} received", msg.content);
Some(url(&ctx, arg, msg.author.id).await)
},
cmd_pattern!("text", ".kt", arg) =>
/*(".k", "url" | "uri" | "link", arg)
| (".k", arg, "url" | "uri" | "link")
| (".ku" | ".kl" | ".kurl", arg, "") =>*/ {
log!("{} received", msg.content);
Some(text(&ctx, arg, msg.author.id).await)
},
cmd_pattern!("spot" | "spotify", ".kx", arg) =>
/*(".k", "spot" | "spotify", arg)
| (".k", arg, "spot" | "spotify")
| (".ks" | ".kspot", arg, "") =>*/ {
log!("{} received", msg.content);
Some(spot(&ctx, arg, msg.author.id).await)
},
(Some(".ks"), Some(_), _) => {
log!("{} received", msg.content);
Some(search(&ctx, &msg.content[4..]).await)
},
(Some(".k"), Some("search"), Some(_)) => {
log!("{} received", msg.content);
Some(search(&ctx, &msg.content[10..]).await)
},
cmd_pattern!("fm" | "fmi" | "get" | "img", ".fmi" | ".k" | ".kf" | ".ki" | ".kfmi", arg) =>
/*(".fmi" | ".k" | ".kf" | ".ki" | ".kfmi", arg, "")
| (".k", "fm" | "fmi" | "get" | "img", arg) =>*/ {
log!("{} received", msg.content);
Some(fmi(&ctx, arg, msg.author.id, msg.author.avatar_url()).await)
}
_ => None,
};
if let Some(reply) = resp {
let m = match reply {
Reply::Image(img) => {
let file = CreateAttachment::bytes(img, "fmi.png");
CreateMessage::new().add_file(file)
},
Reply::Text(txt) => CreateMessage::new().content(txt)
}
Reply::Text(txt) => CreateMessage::new().content(txt),
};
let message = msg.channel_id.send_message(&ctx.http, m).await;
@ -116,26 +644,203 @@ impl EventHandler for Handler {
}
}
struct HttpHaver;
impl TypeMapKey for HttpHaver {
type Value = reqwest_middleware::ClientWithMiddleware;
}
struct PoolHaver;
impl TypeMapKey for PoolHaver {
type Value = SqlitePool;
}
struct FontsHaver;
impl TypeMapKey for FontsHaver {
type Value = (SuperFont<'static>, SuperFont<'static>);
}
struct SpotifyHaver;
impl TypeMapKey for SpotifyHaver {
type Value = Option<Arc<Mutex<ClientCredsSpotify>>>;
}
struct ColorCache {
map: Arc<Mutex<HashMap<String, (String, String)>>>,
max: usize,
}
struct ColorsHaver;
impl TypeMapKey for ColorsHaver {
type Value = ColorCache;
}
struct Overrides {
map: Arc<Mutex<HashMap<String, String>>>,
last_modified: Option<SystemTime>,
}
struct OverridesHaver;
impl TypeMapKey for OverridesHaver {
type Value = Overrides;
}
struct DBResponse {
lastfm_username: String,
}
async fn get_lastfm_username(ctx: &Context, id: UserId) -> Option<String> {
log!("get db user {}", id);
let data = ctx.data.write().await;
let pool = data
.get::<PoolHaver>()
.expect("Failed to get pool container");
let id = i64::from(id);
let resp = sqlx::query_as!(
DBResponse,
r#"
SELECT lastfm_username
FROM users
WHERE discord_userid = ?1
"#,
id
)
.fetch_one(pool)
.await;
match resp {
Ok(r) => {
log!("got user {}", r.lastfm_username);
Some(r.lastfm_username)
}
Err(_) => None,
}
}
async fn set_lastfm_username(ctx: &Context, id: UserId, user: String) {
log!("set db user {} {}", id, user);
let data = ctx.data.read().await;
let pool = data
.get::<PoolHaver>()
.expect("Failed to get pool container");
let id = i64::from(id);
sqlx::query!(
r#"
INSERT INTO users
VALUES (?1, ?2)
"#,
id,
user
)
.execute(pool)
.await
.expect("Failed to insert");
}
async fn get_image(ctx: &Context, url: &str) -> Result<Vec<u8>, reqwest_middleware::Error> {
log!("get {}", url);
let data = ctx.data.read().await;
let http = data.get::<HttpHaver>().expect("Failed to get http client");
match http.get(url).send().await {
Ok(resp) => {
log!("response received");
Ok(resp
.bytes()
.await
.expect("Unable to resolve bytes")
.to_vec())
}
Err(e) => Err(e),
}
}
#[tokio::main]
async fn main() {
START.call_once(|| {
dotenv().ok();
dotenvy::dotenv().expect("Failed to load .env");
magick_wand_genesis();
});
// Login with a bot token from the environment
let token = env::var("DISCORD_TOKEN").expect("Expected a token in the environment");
let db_url = env::var("DATABASE_URL").expect("Failed to load DATABASE_URL");
let pool = SqlitePool::connect(&db_url)
.await
.expect("Failed to load db");
let token = env::var("DISCORD_TOKEN").expect("Failed to load DISCORD_TOKEN");
// Set gateway intents, which decides what events the bot will be notified about
let intents = GatewayIntents::GUILD_MESSAGES
| GatewayIntents::DIRECT_MESSAGES
| GatewayIntents::MESSAGE_CONTENT;
// Create a new instance of the Client, logging in as a bot.
let mut client =
Client::builder(&token, intents).event_handler(Handler).await.expect("Err creating client");
let http = reqwest_middleware::ClientBuilder::new(reqwest::Client::new())
.with(Cache(HttpCache {
mode: CacheMode::Default,
manager: CACacheManager::default(),
options: HttpCacheOptions::default(),
}))
.build();
// Start listening for events by starting a single shard
if let Err(why) = client.start().await {
FontDB::load_from_dir("fonts"); //singlet instance??? wha?????
let regular_fonts = FontDB::query(
"
NotoSans-Regular
NotoSansHK-Regular
NotoSansJP-Regular
NotoSansKR-Regular
NotoSansSC-Regular
NotoSansTC-Regular
NotoSansArabic-Regular
Heebo-Regular
NotoEmoji-Regular
Symbola
Unifont
",
)
.expect("Failed to load regular fonts");
let bold_fonts = FontDB::query(
"
NotoSans-SemiBold
NotoSansJP-Medium
NotoSansKR-Medium
NotoSansSC-Medium
NotoSansTC-Medium
NotoSansArabic-SemiBold
Heebo-SemiBold
NotoEmoji-Medium
Symbola
Unifont
",
)
.expect("Failed to load bold fonts");
let spotify_creds = Credentials::from_env();
let spotify = spotify_creds.map(ClientCredsSpotify::new);
if let Some(spotify_client) = &spotify {
let _ = &spotify_client.request_token().await.unwrap();
}
let colors_cache = ColorCache {
map: Arc::new(Mutex::new(HashMap::new())),
max: env::var("MAX_CACHE_SIZE").unwrap_or("".to_owned()).parse::<usize>().unwrap_or(1000000),
};
let overrides = Overrides {
map: Arc::new(Mutex::new(HashMap::new())),
last_modified: None,
};
let mut discord_client = Client::builder(&token, intents)
.event_handler(Handler)
.await
.expect("Err creating Discord client");
{
let mut data = discord_client.data.write().await;
data.insert::<PoolHaver>(pool);
data.insert::<HttpHaver>(http);
data.insert::<FontsHaver>((regular_fonts, bold_fonts));
data.insert::<SpotifyHaver>(spotify.map(Mutex::new).map(Arc::new));
data.insert::<ColorsHaver>(colors_cache);
data.insert::<OverridesHaver>(overrides);
}
if let Err(why) = discord_client.start().await {
println!("Client error: {why:?}");
}
}

156
src/text.rs Normal file
View File

@ -0,0 +1,156 @@
// this code is mostly just blindly ported from
// https://github.com/adamanldo/Cosmo/blob/946af25dcac8a81cb7b49d1a4f83731a5f7c7b46/cogs/utils/fmi_text.py
// since I'm using the same text rendering library, my code is almost one-to-one
use arabic_reshaper::arabic_reshape;
use image::codecs::png::PngEncoder;
use image::{ExtendedColorType, ImageEncoder, RgbaImage};
use imagetext::measure::text_width;
use imagetext::prelude::{BLACK, Outline, TextAlign, WHITE, draw_text_wrapped, scale, stroke};
use imagetext::superfont::SuperFont;
use imagetext::wrap::{WrapStyle, text_wrap};
use unicode_bidi::BidiInfo;
enum TextField {
Title,
Artist,
Album,
}
fn process_text(
text: &str,
field: &TextField,
font: &SuperFont<'static>,
font_size: f32,
) -> (Vec<String>, i32) {
let mut text = text.to_string();
if text
.chars()
.any(|x| matches!(x as u32, 0x590..=0x5fe | 0x600..=0x6ff0))
{
if text.chars().any(|x| matches!(x as u32, 0x600..=0x6ff0)) {
text = arabic_reshape(text.as_str());
}
let bidi = BidiInfo::new(text.as_str(), None);
text = bidi
.paragraphs
.iter()
.map(|para| {
let line = para.range.clone();
bidi.reorder_line(para, line)
})
.collect();
}
let (width, allowed_lines) = match field {
TextField::Title => (350, 2),
TextField::Artist => (312, 1),
TextField::Album => (280, 2),
};
let mut wrapped_strs = text_wrap(
text.as_str(),
width,
font,
scale(font_size),
WrapStyle::Character,
text_width,
);
if wrapped_strs.len() > allowed_lines {
wrapped_strs.truncate(allowed_lines);
let text_len = wrapped_strs[allowed_lines - 1].len() - 3;
wrapped_strs[allowed_lines - 1] = match allowed_lines {
1..=2 => format!(
"{}...",
wrapped_strs[allowed_lines - 1].get(..text_len).unwrap()
),
_ => unreachable!(),
};
}
(wrapped_strs, width)
}
fn draw_info(
image: &mut RgbaImage,
text: String,
text_type: TextField,
font: &SuperFont<'static>,
font_size: f32,
dark_text: bool,
) -> Result<(), String> {
let (lines, width) = process_text(&text, &text_type, font, font_size);
let text = lines.join("");
let y = match text_type {
TextField::Title => 24.0,
TextField::Artist => 73.0,
TextField::Album => 96.0,
};
let color = if dark_text { &BLACK } else { &WHITE };
match draw_text_wrapped(
image,
color,
Outline::Solid {
stroke: &stroke(0.5),
fill: color,
},
146.0,
y,
0.0,
0.0,
width as f32,
scale(font_size),
font,
&text,
1.0,
TextAlign::Left,
WrapStyle::Character,
) {
Ok(_) => Ok(()),
Err(e) => Err(e.to_string()),
}
}
pub struct TrackInfo {
pub title: String,
pub artist: String,
pub album: String,
}
pub fn fmi_text(
width: u32,
height: u32,
track: TrackInfo,
fonts: &(SuperFont<'static>, SuperFont<'static>),
font_size: f32,
dark_text: bool,
) -> Result<Vec<u8>, String> {
let mut img = RgbaImage::new(width, height);
draw_info(
&mut img,
track.title,
TextField::Title,
&fonts.1.clone(),
font_size,
dark_text,
)?;
draw_info(
&mut img,
track.artist,
TextField::Artist,
&fonts.0.clone(),
font_size,
dark_text,
)?;
draw_info(
&mut img,
track.album,
TextField::Album,
&fonts.0.clone(),
font_size,
dark_text,
)?;
let mut blob = Vec::<u8>::new();
let png_encoder = PngEncoder::new(&mut blob);
match png_encoder.write_image(&img, width, height, ExtendedColorType::Rgba8) {
Ok(()) => Ok(blob),
Err(_) => Err("Failed to encode text png".to_string()),
}
}