Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • stephen/cat-disruptor-6500
  • roygbyte/cat-disruptor-6500
  • tinyconan/cat-disruptor-6500
3 results
Show changes
Commits on Source (62)
Showing
with 2010 additions and 549 deletions
image: "rust:latest"
before_script:
- rustup component add rustfmt
- rustup component add clippy
- cargo install cargo-deb
test:
script:
- cargo fmt -- --check
- cargo clippy --all-targets --all-features -- -D warnings
- cargo test
build:
script:
- cargo deb
artifacts:
paths:
- target/debian/*.deb
This diff is collapsed.
[package] [package]
name = "cat-disruptor-6500" name = "cat-disruptor-6500"
version = "0.1.0" version = "0.3.0"
authors = ["Stephen <stephen@stephendownward.ca>"] authors = ["Stephen <wemaster@scd31.com>"]
edition = "2018" edition = "2018"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
serenity = {version = "0.9", default-features = false, features = ["client", "gateway", "rustls_backend", "model", "static_assertions"] } serenity = {version = "0.11", default-features = false, features = ["client", "gateway", "rustls_backend", "model", "static_assertions", "cache"] }
tokio = {version = "0.2", features = ["full", "time"] } tokio = {version = "1.21", features = ["full", "time"] }
phf = { version = "0.8", features = ["macros"] } phf = { version = "0.8", features = ["macros"] }
toml = "0.5" toml = "0.5"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
xbasic = "0.2.1" xbasic = "0.3.1"
png = "0.16" png = "0.16"
diesel = { version = "1.4", features = ["postgres", "numeric"] } diesel = { version = "2", features = ["postgres", "numeric"] }
dotenv = "0.15.0" dotenv = "0.15.0"
bigdecimal = "0.1.2" bigdecimal = "0.1.2"
\ No newline at end of file reqwest = { version = "0.11", features = ["json"] }
serde_json = "1.0"
rusttype = "0.4.3"
rand = "0.8"
itertools = "0.10"
anyhow = "1.0"
ollama-rs = "0.1.5"
...@@ -2,6 +2,21 @@ ...@@ -2,6 +2,21 @@
A Discord bot that reacts to certain messages with certain emojis. More importantly, it also lets you write code! A Discord bot that reacts to certain messages with certain emojis. More importantly, it also lets you write code!
## Install
- Setup PostgreSQL
- Install via package manager `apt install postgresql`. Also get `libpq-dev` if you don't want a miserable life.
- `sudo su postgres` to login to default database user
- `createdb cat_disruptor_6500` to create the database
- `psql` and then `ALTER ROLE postgres WITH PASSWORD 'password';`
- Update the database environment variable (`DATABASE_URL=postgres://postgres:password@localhost/cat_disruptor_6500`)
- Create a new [Discord bot](https://discord.com/developers/applications)
- Enable all Privileged Gateway Intents
- Go to OAuth2 menu, URL generator, click "bot" and "administrator"
- Open link to add bot to server
- Create a `config.toml` file in project directory with `token=<your discord bot token>`
- Generate token on your bot page
## Commands: ## Commands:
!START - start the interpreter !START - start the interpreter
...@@ -14,6 +29,14 @@ SAVE filename - save code into database ...@@ -14,6 +29,14 @@ SAVE filename - save code into database
LOAD filename - load filename into memory LOAD filename - load filename into memory
DIR - list programs you have saved
PUB filename - Publish a program
PUBDIR - List published programs
PUBLOAD id - Load public program by ID (IDs are listed in PUBDIR)
Code is specified by writing `line_num code`. For example, `10 print "hello world"`. This makes it easy to insert new code or overwrite lines. Code is specified by writing `line_num code`. For example, `10 print "hello world"`. This makes it easy to insert new code or overwrite lines.
### Example session ### Example session
...@@ -62,4 +85,4 @@ The interpreter can execute arbitrary code safely - it includes CPU and memory l ...@@ -62,4 +85,4 @@ The interpreter can execute arbitrary code safely - it includes CPU and memory l
It even does graphics! It even does graphics!
![Example session](https://git.scd31.com/stephen/cat-disruptor-6500/raw/branch/master/example.png) ![Example session](example.png)
\ No newline at end of file
-- This file should undo anything in `up.sql`
ALTER TABLE user_programs DROP COLUMN published;
\ No newline at end of file
ALTER TABLE user_programs ADD COLUMN published INT NOT NULL DEFAULT 0;
\ No newline at end of file
DROP TABLE server_settings;
CREATE TABLE server_settings (
id SERIAL PRIMARY KEY NOT NULL,
guild_id NUMERIC NOT NULL UNIQUE,
starboard_threshold INTEGER NOT NULL,
starboard_emoji_id NUMERIC NOT NULL,
starboard_channel NUMERIC NOT NULL
);
DROP TABLE starboard_mappings;
CREATE TABLE starboard_mappings (
original_id NUMERIC NOT NULL UNIQUE PRIMARY KEY,
repost_id NUMERIC UNIQUE
);
tab_spaces = 4 tab_spaces = 4
hard_tabs = true hard_tabs = true
\ No newline at end of file edition = "2018"
File added
use serde::Deserialize; use serde::Deserialize;
use std::fs; use std::{
collections::{HashMap, HashSet},
fs,
};
#[derive(Deserialize)]
pub struct LlamaConfig {
pub(crate) address: String,
pub(crate) port: u16,
pub(crate) models: HashMap<String, String>,
#[serde(default)]
pub(crate) channels: HashSet<u64>,
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct Config { pub struct Config {
pub(crate) token: String, pub(crate) token: String,
pub(crate) llama: Option<LlamaConfig>,
} }
pub fn get_conf() -> Config { pub fn get_conf() -> Config {
......
use crate::handlers::LineHandler;
use serenity::async_trait;
use serenity::model::channel::{Message, ReactionType};
use serenity::prelude::*;
use std::str::FromStr;
#[derive(Default)]
pub struct HorseHandler;
#[async_trait]
impl LineHandler for HorseHandler {
async fn message(&self, ctx: &Context, msg: &Message) {
let reaction = match ReactionType::from_str("🐴") {
Ok(x) => x,
Err(_) => return,
};
if rand::random::<f64>() <= 0.001 {
let _ = msg.react(&ctx, reaction).await;
}
}
}
use crate::handlers::LineHandler;
use crate::joker::tell_joke;
use serenity::async_trait;
use serenity::model::channel::Message;
use serenity::prelude::*;
pub struct JokeHandler;
#[async_trait]
impl LineHandler for JokeHandler {
async fn line(&self, ctx: &Context, msg: &Message, line: &str) {
if line == "!JOKE" {
match tell_joke().await {
Some(s) => msg.channel_id.say(&ctx, s).await.unwrap(),
None => msg
.channel_id
.say(&ctx, "There was an error while fetching a joke.")
.await
.unwrap(),
};
}
}
}
impl Default for JokeHandler {
fn default() -> Self {
Self
}
}
use super::LineHandler;
use crate::config::LlamaConfig;
use anyhow::{anyhow, Context as _};
use itertools::Itertools;
use ollama_rs::{
generation::completion::{request::GenerationRequest, GenerationContext},
Ollama,
};
use serenity::{
async_trait,
http::Typing,
model::prelude::{Message, MessageId},
prelude::*,
};
use std::{
collections::{HashMap, HashSet},
sync::Arc,
};
pub struct LlamaHandler {
ollama: Ollama,
contexts: Arc<Mutex<HashMap<MessageId, (String, GenerationContext)>>>,
models: HashMap<String, String>,
channel_ids: HashSet<u64>,
}
impl LlamaHandler {
pub fn new(lc: LlamaConfig) -> Self {
Self {
ollama: Ollama::new(lc.address, lc.port),
contexts: Arc::new(Mutex::new(HashMap::new())),
models: lc.models,
channel_ids: lc.channels,
}
}
async fn call_llama(
&self,
model: &str,
prompt: &str,
context: Option<GenerationContext>,
) -> anyhow::Result<(String, GenerationContext)> {
let mut req = GenerationRequest::new(model.into(), prompt.into());
if let Some(c) = context {
req = req.context(c);
}
let resp = self
.ollama
.generate(req)
.await
.map_err(|x| anyhow!("{x}"))?;
let context = resp.final_data.context("Missing final data")?.context;
Ok((resp.response, context))
}
async fn list_models(&self, ctx: &Context, msg: &Message) {
let people = self
.models
.keys()
.map(|x| format!("- {x}"))
.collect::<Vec<_>>()
.join("\n");
if let Err(e) = msg
.reply(ctx, format!("Available models:\n{}", people))
.await
{
eprintln!("{:?}", e);
}
}
async fn reply(
&self,
ctx: &Context,
msg: &Message,
model: &str,
txt: &str,
context: Option<GenerationContext>,
) {
if txt.is_empty() {
return;
}
let _typing = try_or_log(|| Typing::start(ctx.http.clone(), msg.channel_id.0));
let resp = self.call_llama(model, txt, context).await;
let (resp, context) =
match resp {
Ok(x) => x,
Err(e) => {
eprintln!("{e:?}");
if let Err(e) = msg.reply(
ctx,
"Could not communicate with Llama. Check the server logs for more details.",
).await {
eprintln!("{e:?}");
};
return;
}
};
let resp = if resp.is_empty() {
"[No response]"
} else {
&resp
};
// discord messages are limited to 2000 codepoints
let chunks: Vec<String> = resp
.chars()
.chunks(2000)
.into_iter()
.map(|chunk| chunk.collect())
.collect();
let mut first = true;
for chunk in chunks {
let res = if first {
msg.reply(ctx, chunk).await
} else {
msg.channel_id.send_message(ctx, |m| m.content(chunk)).await
};
first = false;
match res {
Ok(x) => {
self.contexts
.lock()
.await
.insert(x.id, (model.to_string(), context.clone()));
}
Err(e) => {
eprintln!("{e:?}");
break;
}
}
}
}
}
#[async_trait]
impl LineHandler for LlamaHandler {
async fn message(&self, ctx: &Context, msg: &Message) {
if !self.channel_ids.contains(&msg.channel_id.0) {
return;
}
let txt = &msg.content;
if let Some(p) = &msg.referenced_message {
let x = {
let l = self.contexts.lock().await;
l.get(&p.id).cloned()
};
if let Some((model, context)) = x {
self.reply(ctx, msg, &model, txt, Some(context)).await;
return;
}
}
if txt.starts_with("!people") {
self.list_models(ctx, msg).await;
return;
}
for (name, model) in &self.models {
if let Some(txt) = txt.strip_prefix(&format!("!{name} ")) {
self.reply(ctx, msg, model, txt, None).await;
return;
}
}
}
}
fn try_or_log<T, E: std::fmt::Debug, F: Fn() -> Result<T, E>>(f: F) -> Result<T, E> {
let res = f();
if let Err(e) = res.as_ref() {
eprintln!("{e:?}");
}
res
}
mod horse;
mod joke;
mod llama;
mod react;
mod starboard;
mod sus;
mod xbasic;
use crate::config::LlamaConfig;
use crate::handlers::horse::HorseHandler;
use crate::handlers::joke::*;
use crate::handlers::react::*;
use crate::handlers::starboard::StarboardHandler;
use crate::handlers::sus::*;
use crate::handlers::xbasic::*;
use diesel::{Connection, PgConnection};
use dotenv::dotenv;
use serenity::async_trait;
use serenity::model::channel::Message;
use serenity::model::prelude::{Reaction, Ready};
use serenity::prelude::*;
use std::env;
use std::sync::Arc;
use tokio::sync::Mutex;
use self::llama::LlamaHandler;
#[async_trait]
pub(crate) trait LineHandler: Send + Sync {
async fn message(&self, ctx: &Context, msg: &Message) {
for line in msg.content.split('\n') {
self.line(ctx, msg, line).await
}
}
async fn line(&self, _ctx: &Context, _msg: &Message, _line: &str) {}
}
#[async_trait]
pub(crate) trait ReactionHandler: Send + Sync {
async fn reaction_add(&self, ctx: &Context, reaction: &Reaction);
async fn reaction_del(&self, ctx: &Context, reaction: &Reaction);
}
pub(crate) struct Dispatcher {
handlers: Vec<Box<dyn LineHandler>>,
reacts: Vec<Box<dyn ReactionHandler>>,
}
#[async_trait]
impl EventHandler for Dispatcher {
async fn message(&self, ctx: Context, msg: Message) {
for h in &self.handlers {
h.message(&ctx, &msg).await;
}
}
async fn reaction_add(&self, ctx: Context, reaction: Reaction) {
for r in &self.reacts {
r.reaction_add(&ctx, &reaction).await;
}
}
async fn reaction_remove(&self, ctx: Context, reaction: Reaction) {
for r in &self.reacts {
r.reaction_del(&ctx, &reaction).await;
}
}
async fn ready(&self, _: Context, ready: Ready) {
println!("{} is connected", ready.user.name);
}
}
impl Dispatcher {
pub fn new(llama_config: Option<LlamaConfig>) -> Self {
let conn = Arc::new(Mutex::new(establish_connection()));
let mut handlers: Vec<Box<dyn LineHandler>> = vec![
Box::new(XbasicHandler::new(conn.clone())),
Box::<JokeHandler>::default(),
Box::<ReactHandler>::default(),
Box::<SusHandler>::default(),
Box::<HorseHandler>::default(),
];
if let Some(lc) = llama_config {
handlers.push(Box::new(LlamaHandler::new(lc)));
}
Self {
handlers,
reacts: vec![Box::new(StarboardHandler::new(conn))],
}
}
}
fn establish_connection() -> PgConnection {
dotenv().ok();
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
PgConnection::establish(&database_url).expect("Error connecting to database")
}
use crate::handlers::LineHandler;
use itertools::Itertools;
use phf::phf_map;
use serenity::async_trait;
use serenity::model::channel::{Message, ReactionType};
use serenity::prelude::*;
use std::str::FromStr;
static EMOJI_MAP: phf::Map<&'static str, &'static str> = phf_map! {
"cat" => "🐈",
"chicken" => "🐔",
"spaghetti" => "🍝",
"dog" => "🐕",
"bot" => "🤖",
"mango" => "🥭",
"banana" => "🍌",
"bee" => "🐝",
"horse" => "🐎",
"hat" => "🎩"
};
fn map_lookup(msg: &str) -> Option<&'static str> {
// We lose the O(1) benefits of the hashmap
// But whatever. It doesn't need to be fast
for (k, v) in EMOJI_MAP.entries() {
if &msg == k || msg == format!("{k}s") {
return Some(v);
}
}
None
}
#[derive(Default)]
pub struct ReactHandler;
#[async_trait]
impl LineHandler for ReactHandler {
async fn message(&self, ctx: &Context, msg: &Message) {
// Kind of convoluted, but allows us to react in the correct order.
let groups = msg
.content
.chars()
.map(|c| match c {
'!' | '?' | ',' | '.' | '(' | ')' | '[' | ']' | '\n' | '\r' => ' ',
_ => c,
})
.group_by(|c| *c == ' ');
let reacts: Vec<_> = groups
.into_iter()
.filter_map(|(_, c)| map_lookup(&c.collect::<String>().to_lowercase()))
.unique()
.collect();
for r in reacts {
let reaction_type = match ReactionType::from_str(r) {
Ok(x) => x,
Err(x) => {
eprintln!("Could not react: {x}");
return;
}
};
if let Err(e) = msg.react(&ctx, reaction_type).await {
eprintln!("Error reacting: {e}");
}
}
}
}
use crate::handlers::ReactionHandler;
use crate::models::ServerSetting;
use crate::schema;
use crate::schema::{server_settings, starboard_mappings};
use anyhow::Context as AnyhowContext;
use bigdecimal::{BigDecimal, FromPrimitive, ToPrimitive};
use diesel::{ExpressionMethods, PgConnection, QueryDsl, RunQueryDsl};
use serenity::async_trait;
use serenity::builder::CreateEmbed;
use serenity::model::prelude::{
ChannelId, Emoji, EmojiId, Message, MessageReaction, Reaction, ReactionType,
};
use serenity::prelude::*;
use std::ops::DerefMut;
use std::sync::Arc;
use tokio::sync::Mutex;
#[derive(Clone)]
pub struct SingleMessageHandler {
conn: Arc<Mutex<PgConnection>>,
server_settings: ServerSetting,
reaction_count: u64,
msg: Message,
emoji: Emoji,
name: String,
image: Option<String>,
}
impl SingleMessageHandler {
pub async fn handle_reaction(
conn: Arc<Mutex<PgConnection>>,
ctx: &Context,
reaction: &Reaction,
) -> anyhow::Result<()> {
let guild = match reaction.channel_id.to_channel(ctx).await?.guild() {
Some(x) => x,
None => return Ok(()),
};
// get corresponding guild settings
let mut gs: Vec<ServerSetting> = server_settings::dsl::server_settings
.filter(
schema::server_settings::columns::guild_id
.eq(BigDecimal::from_u64(guild.guild_id.0).unwrap()),
)
.limit(1)
.get_results(conn.lock().await.deref_mut())?;
let gs = match gs.pop() {
Some(x) => x,
None => return Ok(()),
};
let emoji = EmojiId(
gs.starboard_emoji_id
.to_u64()
.context("Could not convert emoji id to u64")?,
);
let msg = reaction.message(ctx).await?;
// reaction from event handler must match
// otherwise we'll update the repost (and add an "edited"
// to the message)
// whenever someone reacts with any reaction
if !Self::emoji_match(&reaction.emoji, emoji) {
return Ok(());
}
let reaction_count = Self::find_emoji_match(msg.reactions.iter(), emoji);
let guild_id = guild.guild_id;
let emoji = guild_id
.emoji(ctx, emoji)
.await
.context("Could not get emoji from guild")?;
let name = msg
.author
.nick_in(ctx, guild_id)
.await
.unwrap_or_else(|| msg.author.tag());
let image = msg
.attachments
.iter()
.filter(|a| a.width.is_some())
.map(|a| &a.url)
.next()
.cloned();
let handler = Self {
conn,
server_settings: gs,
reaction_count,
msg,
emoji,
name,
image,
};
handler.process_match(ctx).await?;
Ok(())
}
async fn process_match(&self, ctx: &Context) -> anyhow::Result<()> {
let original_id = BigDecimal::from(self.msg.id.0);
diesel::insert_into(starboard_mappings::dsl::starboard_mappings)
.values(starboard_mappings::columns::original_id.eq(&original_id))
.returning(starboard_mappings::columns::repost_id)
.on_conflict_do_nothing()
.execute(self.conn.lock().await.deref_mut())?;
let repost_id = starboard_mappings::dsl::starboard_mappings
.filter(starboard_mappings::columns::original_id.eq(&original_id))
.select(starboard_mappings::columns::repost_id)
.limit(1)
.get_results(self.conn.lock().await.deref_mut())?;
let repost_id: &Option<BigDecimal> =
repost_id.first().context("Insert of mapping failed")?;
match repost_id {
Some(id) => {
self.edit_existing_starboard_message(
ctx,
id.to_u64()
.context("Could not convert repost message id to a u64")?,
)
.await?;
}
None => {
if self.reaction_count >= self.server_settings.starboard_threshold as u64 {
// post to repost
let repost = self.post_new_starboard_message(ctx).await?;
// update the DB
let repost_id = BigDecimal::from_u64(repost);
diesel::update(
starboard_mappings::dsl::starboard_mappings
.filter(starboard_mappings::columns::original_id.eq(original_id)),
)
.set(starboard_mappings::columns::repost_id.eq(repost_id))
.execute(self.conn.lock().await.deref_mut())?;
}
}
}
Ok(())
}
async fn post_new_starboard_message(&self, ctx: &Context) -> anyhow::Result<u64> {
let repost = ChannelId(
self.server_settings
.starboard_channel
.to_u64()
.context("Could not convert starboard channel to a u64")?,
)
.send_message(ctx, |m| m.embed(|e| self.clone().starboard_message(e)))
.await?;
Ok(repost.id.0)
}
async fn edit_existing_starboard_message(
&self,
ctx: &Context,
message_id: u64,
) -> anyhow::Result<()> {
let channel_id = ChannelId(
self.server_settings
.starboard_channel
.to_u64()
.context("Could not convert starboard channel to a u64")?,
);
let mut msg = channel_id.message(ctx, message_id).await?;
msg.edit(ctx, |m| m.embed(|e| self.clone().starboard_message(e)))
.await?;
Ok(())
}
fn starboard_message(self, e: &mut CreateEmbed) -> &mut CreateEmbed {
let mut e = e
.description(format!(
"[Jump to source]({})\n{}",
self.msg.link(),
self.msg.content
))
.title(format!("{} {}", self.reaction_count, self.emoji))
.author(|a| a.name(&self.name).icon_url(self.msg.author.face()))
.timestamp(self.msg.timestamp);
if let Some(url) = self.image {
e = e.image(url);
}
e
}
fn find_emoji_match<'a, I: Iterator<Item = &'a MessageReaction>>(iter: I, em: EmojiId) -> u64 {
for mr in iter {
if Self::emoji_match(&mr.reaction_type, em) {
return mr.count;
}
}
0
}
fn emoji_match(rt: &ReactionType, em: EmojiId) -> bool {
matches!(rt, ReactionType::Custom { id, .. } if *id == em)
}
}
pub struct StarboardHandler {
conn: Arc<Mutex<PgConnection>>,
}
impl StarboardHandler {
pub fn new(conn: Arc<Mutex<PgConnection>>) -> Self {
Self { conn }
}
async fn handle_reaction(&self, ctx: &Context, reaction: &Reaction) -> anyhow::Result<()> {
SingleMessageHandler::handle_reaction(self.conn.clone(), ctx, reaction).await
}
}
#[async_trait]
impl ReactionHandler for StarboardHandler {
async fn reaction_add(&self, ctx: &Context, reaction: &Reaction) {
if let Err(e) = self.handle_reaction(ctx, reaction).await {
eprintln!("Error in starboard: {e:?}");
}
}
async fn reaction_del(&self, ctx: &Context, reaction: &Reaction) {
if let Err(e) = self.handle_reaction(ctx, reaction).await {
eprintln!("Error in starboard: {e:?}");
}
}
}
use crate::framebuffer::FrameBuffer;
use crate::handlers::LineHandler;
use rusttype::LayoutIter;
use rusttype::{point, FontCollection, Scale};
use serenity::async_trait;
use serenity::model::channel::{AttachmentType, Message};
use serenity::prelude::*;
use std::borrow::Cow;
const COLOUR: (u8, u8, u8) = (255, 0, 0);
const SCALE: Scale = Scale { x: 48.0, y: 48.0 };
const PADDING_X: u32 = 5;
const PADDING_Y: u32 = 5;
const MAX_WIDTH: i32 = 800;
const Y_GAP: i32 = 5;
// Wrap text when width exceeds MAX_WIDTH
struct WrappingLayoutIter<'a> {
iter: LayoutIter<'a, 'a>,
offset_x: i32,
offset_y: i32,
cur_x: i32,
cur_y: i32,
}
impl<'a> WrappingLayoutIter<'a> {
fn new(iter: LayoutIter<'a, 'a>, offset_x: i32, offset_y: i32) -> Self {
Self {
iter,
offset_x,
offset_y,
cur_x: 0,
cur_y: 0,
}
}
}
impl Iterator for WrappingLayoutIter<'_> {
type Item = Vec<(i32, i32, f32)>;
fn next(&mut self) -> Option<Self::Item> {
let (glyph, bounding_box) = loop {
let glyph = self.iter.next()?;
if let Some(bb) = glyph.pixel_bounding_box() {
break (glyph, bb);
}
};
if bounding_box.max.x + self.cur_x > MAX_WIDTH {
self.cur_x = -bounding_box.min.x;
self.cur_y += bounding_box.max.y - bounding_box.min.y + Y_GAP;
}
let mut buf = Vec::new();
glyph.draw(|x, y, a| {
let x = x as i32 + bounding_box.min.x + self.offset_x + self.cur_x;
let y = y as i32 + bounding_box.min.y + self.offset_y + self.cur_y;
buf.push((x, y, a));
});
Some(buf)
}
}
#[derive(Default)]
pub struct SusHandler;
impl SusHandler {
async fn render_font(&self, ctx: &Context, msg: &Message, s: &str) {
let font_data = include_bytes!("../amongus.ttf");
let collection = FontCollection::from_bytes(font_data as &[u8]);
let font = collection.into_font().unwrap();
let start = point(0.0, 0.0);
// Find image dimensions
let mut min_x = 0;
let mut max_x = 0;
let mut max_y = 0;
let mut min_y = 0;
for arr in WrappingLayoutIter::new(font.layout(s, SCALE, start), 0, 0) {
for (x, y, _) in arr {
if x < min_x {
min_x = x;
}
if x > max_x {
max_x = x;
}
if y < min_y {
min_y = y;
}
if y > max_y {
max_y = y;
}
}
}
let offset_x = -min_x;
let offset_y = -min_y;
max_x += offset_x;
max_y += offset_y;
let mut framebuffer =
FrameBuffer::new(max_x as u32 + PADDING_X * 2, max_y as u32 + PADDING_Y * 2);
for arr in WrappingLayoutIter::new(
font.layout(s, SCALE, start),
offset_x + PADDING_X as i32,
offset_y + PADDING_Y as i32,
) {
for (x, y, a) in arr {
framebuffer.set_pixel(
x as u32,
y as u32,
COLOUR.0,
COLOUR.1,
COLOUR.2,
(a * 255.0) as u8,
);
}
}
let buf = framebuffer.as_png_vec();
msg.channel_id
.send_message(&ctx, |e| {
e.add_file(AttachmentType::Bytes {
data: Cow::Borrowed(&buf),
filename: "output.png".to_string(),
});
e
})
.await
.unwrap();
}
}
#[async_trait]
impl LineHandler for SusHandler {
async fn line(&self, ctx: &Context, msg: &Message, line: &str) {
if line.starts_with("!SUS") {
let s = line.split(' ').collect::<Vec<_>>()[1..]
.join(" ")
.to_uppercase();
if s.is_empty() {
return;
}
self.render_font(ctx, msg, &s).await;
}
}
}