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 (68)
Showing
with 1671 additions and 853 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" 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"
# cat-disruptor-6500
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:
!START - start the interpreter
!STOP - stop the interpreter
LIST - list all code in memory
SAVE filename - save code into database
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.
### Example session
```
!START
10 for x in 0 to 10
20 print x
RUN
> expected NEXT after FOR statement. # oops - we forgot a line
30 next x
LIST
> 10 for x in 0 to 10
> 20 print x
> 30 next x
RUN
> 0
> 1
> 2
> 3
> 4
> 5
> 6
> 7
> 8
> 9
> 10
# actually, let's print them all on the same line
5 a = ""
20 a += x
LIST
> 5 a = ""
> 10 for x in 0 to 10
> 20 a += x
> 30 next x
RUN
012345678910 # oops - let's add spaces
20 a += " " + x
RUN
> 0 1 2 3 4 5 6 7 8 9 10 # awesome! let's save this program
SAVE count_to_ten
> Saved as count_to_ten
```
The interpreter can execute arbitrary code safely - it includes CPU and memory limits. For information on syntax, check out the xBASIC interpreter: https://git.scd31.com/stephen/xbasic
It even does graphics!
![Example session](example.png)
example.png

135 KiB

-- 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::framebuffer::FrameBuffer;
use crate::program::Program;
use diesel::PgConnection;
use phf::phf_map;
use serenity::async_trait;
use serenity::http::AttachmentType;
use serenity::model::channel::{Message, ReactionType};
use serenity::model::id::UserId;
use serenity::model::prelude::Ready;
use serenity::prelude::*;
use std::borrow::Cow;
use std::collections::HashMap;
use std::str::FromStr;
use std::sync::{Arc, Mutex};
use xbasic::basic_io::BasicIO;
use xbasic::expr::ExprValue;
use xbasic::xbasic::XBasicBuilder;
static EMOJI_MAP: phf::Map<&'static str, &'static str> = phf_map! {
"cat" => "🐈",
"chicken" => "🐔",
"spaghetti" => "🍝",
"dog" => "🐕",
"bot" => "🤖",
"mango" => "🥭",
"banana" => "🍌",
"bee" => "🐝"
};
struct DiscordIO {
s: String,
frame: Option<FrameBuffer>,
}
impl DiscordIO {
fn new() -> Self {
Self {
s: String::new(),
frame: None,
}
}
}
impl BasicIO for DiscordIO {
fn read_line(&mut self) -> String {
unimplemented!()
}
fn write_line(&mut self, line: String) {
self.s += &*(line + "\r\n");
}
}
pub(crate) struct Handler {
programs: Arc<Mutex<HashMap<UserId, Program>>>,
conn: Arc<Mutex<PgConnection>>,
}
impl Handler {
pub fn new(conn: PgConnection) -> Self {
Self {
programs: Arc::new(Mutex::new(HashMap::new())),
conn: Arc::new(Mutex::new(conn)),
}
}
}
#[async_trait]
impl EventHandler for Handler {
async fn message(&self, ctx: Context, msg: Message) {
for (key, value) in EMOJI_MAP.entries() {
let msg_lower = format!(" {} ", msg.content.to_lowercase());
if msg_lower.contains(&format!(" {} ", key))
|| msg_lower.contains(&format!(" {}s ", key))
{
let reaction_type = match ReactionType::from_str(value) {
Ok(x) => x,
Err(x) => {
println!("Could not react: {}", x);
return;
}
};
if let Err(e) = msg.react(&ctx, reaction_type).await {
println!("Error reacting: {}", e);
}
}
}
for line in msg.content.split('\n') {
if self.programs.lock().unwrap().contains_key(&msg.author.id) {
match line {
"!STOP" => {
self.programs
.lock()
.unwrap()
.remove(&msg.author.id)
.unwrap();
}
"RUN" => {
let thinking_react = match ReactionType::from_str("🤔") {
Ok(react) => msg.react(&ctx, react).await.ok(),
Err(_) => None,
};
let code = self.programs.lock().unwrap()[&msg.author.id].stringify();
let io = DiscordIO::new();
let (output, fb, errors) = {
let mut xbb = XBasicBuilder::new(io);
xbb.compute_limit(1000000000);
xbb.define_function("setframe".to_owned(), 2, |args, io| {
let w = args[0].clone().into_decimal() as u32;
let h = args[1].clone().into_decimal() as u32;
io.frame = Some(FrameBuffer::new(w, h));
ExprValue::Decimal(0.0)
})
.unwrap();
xbb.define_function("setpixel".to_owned(), 5, |args, io| {
let x = args[0].clone().into_decimal() as u32;
let y = args[1].clone().into_decimal() as u32;
let red = args[2].clone().into_decimal() as u8;
let green = args[3].clone().into_decimal() as u8;
let blue = args[4].clone().into_decimal() as u8;
match &mut io.frame {
Some(fb) => {
fb.set_pixel(x, y, red, green, blue, 255);
}
None => {}
}
ExprValue::Decimal(0.0)
})
.unwrap();
let mut xb = xbb.build();
let _ = xb.run(&format!("{}\n", code));
let errors = if xb.error_handler.had_errors
|| xb.error_handler.had_runtime_error
{
Some(xb.error_handler.errors.join("\n"))
} else {
None
};
(xb.get_io().s.clone(), xb.get_io().frame.clone(), errors)
};
if !output.is_empty() {
msg.channel_id.say(&ctx, output).await.unwrap();
}
if let Some(fb) = &fb {
let buf = fb.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();
}
if let Some(e) = errors {
msg.channel_id.say(&ctx, e).await.unwrap();
if let Ok(react) = ReactionType::from_str("❌") {
let _ = msg.react(&ctx, react).await;
}
} else if let Ok(react) = ReactionType::from_str("✅") {
let _ = msg.react(&ctx, react).await;
}
if let Some(t) = thinking_react {
let _ = t.delete(&ctx).await;
}
}
"LIST" => {
msg.channel_id
.say(
&ctx,
format!(
"```\n{}\n```",
self.programs.lock().unwrap()[&msg.author.id]
.stringy_line_nums()
),
)
.await
.unwrap();
}
_ => {
if let Some(name) = line.strip_prefix("SAVE ") {
let result = self
.programs
.lock()
.unwrap()
.get_mut(&msg.author.id)
.unwrap()
.save_program(&self.conn.lock().unwrap(), msg.author.id, name);
match result {
Some(_) => {
msg.channel_id
.say(&ctx, format!("Saved as {}", name))
.await
.unwrap();
}
None => {
msg.channel_id
.say(&ctx, "Could not save program.")
.await
.unwrap();
}
}
return;
}
if let Some(name) = line.strip_prefix("LOAD ") {
let result = self
.programs
.lock()
.unwrap()
.get_mut(&msg.author.id)
.unwrap()
.load_program(&self.conn.lock().unwrap(), msg.author.id, name);
match result {
Some(_) => {
msg.channel_id
.say(&ctx, format!("Loaded {} into memory.", name))
.await
.unwrap();
}
None => {
msg.channel_id
.say(&ctx, "Could not load program into memory.")
.await
.unwrap();
}
}
return;
}
let mut split = line.splitn(2, ' ');
let first = split.next().unwrap();
if let Ok(num) = first.parse::<u32>() {
match split.next() {
Some(x) => {
if x.is_empty() {
let _ = self
.programs
.lock()
.unwrap()
.get_mut(&msg.author.id)
.unwrap()
.code
.remove(&num);
return;
}
self.programs
.lock()
.unwrap()
.get_mut(&msg.author.id)
.unwrap()
.code
.insert(num, x.to_owned());
}
None => {
let _ = self
.programs
.lock()
.unwrap()
.get_mut(&msg.author.id)
.unwrap()
.code
.remove(&num);
}
}
}
}
}
}
if line == "!START" {
self.programs
.lock()
.unwrap()
.insert(msg.author.id, Program::new());
}
}
}
async fn ready(&self, _: Context, ready: Ready) {
println!("{} is connected", ready.user.name);
}
}
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}");
}
}
}
}