commit 877f797926a63d43103d0bbca7d0c1b87ac73864 Author: LordMZTE Date: Tue Jan 12 00:44:51 2021 +0100 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..07a1e39 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +Cargo.lock + diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..6364941 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "ruff" +version = "0.1.0" +authors = ["LordMZTE "] +edition = "2018" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +anyhow = "1.0" +async-trait = "0.1.42" +bytes = "0.5" +chrono = "0.4.19" +clap = "2.33.3" +dirs = "3.0.1" +fern = "0.6.0" +log = "0.4.13" +once_cell = "1.5.2" +pin-project-lite = "0.2.4" +serde_json = "1.0.61" +structopt = "0.3.21" +thiserror = "1.0.23" +toml = "0.5.8" + +[dependencies.tokio] +version = "0.2" +features = [ + "macros", + "fs", + "stream", +] + +[dependencies.serde] +version = "1.0" +features = ["derive"] + +[dependencies.reqwest] +version = "0.10" +features = ["stream"] + diff --git a/defaultconfig.toml b/defaultconfig.toml new file mode 100644 index 0000000..2e0b048 --- /dev/null +++ b/defaultconfig.toml @@ -0,0 +1,42 @@ +# The nickname to be used +nickname = "RUFF" +# what matterbridge gateways the bot should work on +gateways = ["gateway1"] +# the api endpoint the bot should use +api = "http://localhost:4242/api" + +[memes] +uffat = "https://jensmemes.tilera.xyz/images/584309714544fbe5961cdb4ddbc880d0/uffat.png" +uffgo = "https://jensmemes.tilera.xyz/images/e8453a9f812165a8686ad32c149774c6/uffgothon.png" +hey = "https://jensmemes.tilera.xyz/images/d9d03ec0275ad5181bb1e3fc5cbc5205/jensgenervt.PNG" +uffch = "https://jensmemes.tilera.xyz/images/d9d03ec0275ad5181bb1e3fc5cbc5205/uffch.png" +drogen = "https://jensmemes.tilera.xyz/images/000/drogen.PNG" +kappa = "https://jensmemes.tilera.xyz/images/d41d8cd98f00b204e9800998ecf8427e/jensKappa%20-%20Kopie%20-%20Kopie.png" +hendrik = "https://jensmemes.tilera.xyz/images/e8453a9f812165a8686ad32c149774c6/hendrik.png" +ufflie = "https://jensmemes.tilera.xyz/images/d9d03ec0275ad5181bb1e3fc5cbc5205/ufflie.png" +uffns = "https://jensmemes.tilera.xyz/images/d9d03ec0275ad5181bb1e3fc5cbc5205/uffns.png" +uffhs = "https://jensmemes.tilera.xyz/images/d9d03ec0275ad5181bb1e3fc5cbc5205/uffhs.png" +uffde = "https://jensmemes.tilera.xyz/images/000/uff.png" +uffhre = "https://jensmemes.tilera.xyz/images/d9d03ec0275ad5181bb1e3fc5cbc5205/uffhre.png" +uffpy = "https://jensmemes.tilera.xyz/images/48f8912f48b94a78b1f0cb939d69a52f/uffpy_ns.png" +itbyhf = "https://jensmemes.tilera.xyz/images/d9d03ec0275ad5181bb1e3fc5cbc5205/itbyhf.png" +tilera = "https://jensmemes.tilera.xyz/images/d9d03ec0275ad5181bb1e3fc5cbc5205/tilera.png" +lordmzte = "https://jensmemes.tilera.xyz/images/d9d03ec0275ad5181bb1e3fc5cbc5205/lord.png" +realtox = "https://jensmemes.tilera.xyz/images/6c42dfd93466145a29b97854b394c62c/realtoxguthosting.jpg" +jonasled = "https://jensmemes.tilera.xyz/images/584309714544fbe5961cdb4ddbc880d0/jonasled.png" +sklave = "https://jensmemes.tilera.xyz/images/e8453a9f812165a8686ad32c149774c6/versklavung.png" +jens = "https://jensmemes.tilera.xyz/images/48f8912f48b94a78b1f0cb939d69a52f/jens_2.mp4" +fresse = "https://jensmemes.tilera.xyz/images/d9d03ec0275ad5181bb1e3fc5cbc5205/fresse.jpg" +bastard = "https://jensmemes.tilera.xyz/images/d9d03ec0275ad5181bb1e3fc5cbc5205/fresse.jpg" +uffsr = "https://jensmemes.tilera.xyz/images/e8453a9f812165a8686ad32c149774c6/uffsr.png" +party = "https://jensmemes.tilera.xyz/images/e8453a9f812165a8686ad32c149774c6/party.mp4" +uffrs = "https://jensmemes.tilera.xyz/images/e8453a9f812165a8686ad32c149774c6/uffrs.png" +uffjs = "https://jensmemes.tilera.xyz/images/e8453a9f812165a8686ad32c149774c6/uffjs.png" +uffkt = "https://jensmemes.tilera.xyz/images/e8453a9f812165a8686ad32c149774c6/uffkt.png" +uffj = "https://jensmemes.tilera.xyz/images/e8453a9f812165a8686ad32c149774c6/uffj.png" +ufftl = "https://jensmemes.tilera.xyz/images/e8453a9f812165a8686ad32c149774c6/ufftl.png" +uffhf = "https://jensmemes.tilera.xyz/images/e8453a9f812165a8686ad32c149774c6/uffhf.png" +uffmz = "https://jensmemes.tilera.xyz/images/e8453a9f812165a8686ad32c149774c6/uffmz.png" +uffal = "https://jensmemes.tilera.xyz/images/e8453a9f812165a8686ad32c149774c6/uffal.png" +alec = "https://jensmemes.tilera.xyz/images/e8453a9f812165a8686ad32c149774c6/alecmichdochamarsch.png" + diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..fb2722d --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,13 @@ +unstable_features = true +binop_separator = "Back" +format_code_in_doc_comments = true +format_macro_matchers = true +format_strings = true +imports_layout = "HorizontalVertical" +match_block_trailing_comma = true +merge_imports = true +normalize_comments = true +use_field_init_shorthand = true +use_try_shorthand = true +wrap_comments = true + diff --git a/src/api.rs b/src/api.rs new file mode 100644 index 0000000..2abf806 --- /dev/null +++ b/src/api.rs @@ -0,0 +1,47 @@ +use serde::{ + de::{Deserializer, Visitor}, + Deserialize, + Serialize, +}; +use std::fmt; + +#[derive(Deserialize, Serialize, Debug)] +pub struct Message { + pub text: String, + pub username: String, + #[serde(skip_serializing)] + pub event: Option, + pub gateway: String, +} + +#[derive(Debug)] +pub enum Event { + ApiConnected, + Other(String), +} + +impl<'de> Deserialize<'de> for Event { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct EventVisitor; + + impl<'de> Visitor<'de> for EventVisitor { + type Value = Event; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("an event type in the form of a string") + } + + fn visit_str(self, value: &str) -> Result { + Ok(match value { + "api_connected" => Event::ApiConnected, + _ => Event::Other(value.into()), + }) + } + } + + deserializer.deserialize_str(EventVisitor) + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..3da391a --- /dev/null +++ b/src/config.rs @@ -0,0 +1,37 @@ +use anyhow::{Context, Result}; +use once_cell::sync::OnceCell; +use serde::Deserialize; +use std::{collections::HashMap, path::PathBuf}; + +#[derive(Deserialize)] +pub struct Config { + pub nickname: String, + pub gateways: Vec, + pub api: String, + pub memes: HashMap, +} + +static CONFIG: OnceCell = OnceCell::new(); +pub static CONFIG_PATH: OnceCell = OnceCell::new(); + +pub async fn try_get_config<'a>() -> Result<&'a Config> { + match CONFIG.get() { + None => { + log::info!("Initializing config"); + let config = CONFIG_PATH.get_or_try_init::<_, anyhow::Error>(|| { + let mut config = dirs::config_dir().context("Failed to get config dir")?; + config.push("ruff/config.toml"); + Ok(config) + })?; + + if let Some(p) = config.to_str() { + log::info!("trying to read config at {}", p); + } + + let bytes = tokio::fs::read(config).await?; + let config = toml::from_slice::(&bytes)?; + Ok(CONFIG.get_or_init(|| config)) + }, + Some(c) => Ok(c), + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..4d07257 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,245 @@ +use anyhow::{anyhow, Result}; +use api::{Event, Message}; +use bytes::Bytes; +use pin_project_lite::pin_project; +use reqwest::Client; +use std::{ + pin::Pin, + string::FromUtf8Error, + task::{Context, Poll}, +}; +use structopt::StructOpt; +use thiserror::Error; +use tokio::stream::{Stream, StreamExt}; + +pub mod api; +pub mod config; + +#[derive(StructOpt)] +#[structopt(about = "The next generation uffbot for matterbridge!")] +pub struct Opt { + #[structopt( + long, + short, + help = "Use the given config file instead of the default. (located at \ + .config/ruff/config.toml)" + )] + config: Option, +} + +#[tokio::main] +async fn main() -> Result<()> { + fern::Dispatch::new() + .format(|out, message, record| { + out.finish(format_args!( + "{}[{}][{}] {}", + chrono::Local::now().format("[%Y-%m-%d][%H:%M:%S]"), + record.target(), + record.level(), + message + )) + }) + .level(log::LevelFilter::Debug) + .chain(std::io::stdout()) + .apply()?; + + let client = Client::new(); + let opt = Opt::from_args_safe()?; + + if let Some(c) = opt.config { + let _ = config::CONFIG_PATH.set(c.into()); + } + + loop { + let mut stream = stream(&client).await?; + + while let Some(msg) = stream.next().await { + if let Err(e) = next_message(&client, msg).await { + log::error!("Got error processing message: {}", e); + } + } + + log::error!("Stream to server closed. restarting."); + } +} + +#[derive(Debug, Error)] +enum MessageProcessError { + #[error("Got error `{error}` trying to deserialize message:\n{data}")] + Deserialize { + error: serde_json::Error, + data: String, + }, + + #[error("Got Error `{error}` try to deserialize invalid UTF-8 data:\n{data:X?}")] + InvalidUtf8 { error: FromUtf8Error, data: Vec }, +} + +async fn next_message(client: &Client, raw_msg: Vec) -> Result<()> { + let s_data = + String::from_utf8(raw_msg.clone()).map_err(|error| MessageProcessError::InvalidUtf8 { + error, + data: raw_msg, + })?; + let message = + serde_json::from_str(&s_data).map_err(|error| MessageProcessError::Deserialize { + error, + data: s_data, + })?; + + log::info!("Processing message {:?}", &message); + process_message(client, message).await +} + +async fn process_message(client: &Client, message: Message) -> Result<()> { + let config = config::try_get_config().await?; + match message { + Message { + event: Some(Event::ApiConnected), + .. + } => log::info!("got api connected event"), + Message { text, gateway, .. } if config.gateways.contains(&gateway) && !text.is_empty() => { + if let Some(start) = text.split(" ").next() { + if let Some(meme) = config.memes.get(&start.to_lowercase()) { + log::info!( + r#"found meme matching message "{}". responding with "{}""#, + text, + &meme + ); + + let message = Message { + text: meme.clone(), + gateway, + username: config.nickname.clone(), + event: None, + }; + + send_message(client, &message).await?; + } + } + }, + msg => log::warn!("Got unknown message: {:?}", msg), + } + + Ok(()) +} + +async fn send_message(client: &Client, msg: &Message) -> Result<()> { + let config = config::try_get_config().await?; + log::info!("Sending message: {:?}", msg); + let res: Result<()> = { + let response = client + .post(&format!("{}/message", config.api)) + .header("Content-Type", "application/json") + .body(serde_json::to_vec(&msg)?) + .send() + .await?; + + log::info!( + "sent message. server responded with `{}`", + response.text().await? + ); + + Ok(()) + }; + + res.map_err(|e| anyhow!("Error sending message:\n{}", e)) +} + +async fn stream(client: &Client) -> Result>> { + let conf = config::try_get_config().await?; + let stream = client + .get(&format!("{}/stream", conf.api)) + .send() + .await? + .bytes_stream(); + Ok(NewlineStream::new(stream.filter_map(Result::ok))) +} + +pin_project! { + struct NewlineStream> { + #[pin] + inner: T, + buf: Vec, + } +} + +impl> Stream for NewlineStream { + type Item = Vec; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + loop { + let poll = self.as_mut().project().inner.poll_next(cx); + // if the inner stream is not done yet, we are not either + let poll = match poll { + Poll::Ready(x) => x, + Poll::Pending => return Poll::Pending, + }; + + match poll { + Some(i) => { + let buf = &mut self.as_mut().project().buf; + buf.extend(i.into_iter()); + + let pos = match buf.iter().position(|&e| e == b'\n') { + Some(n) => n, + // if there is no newline yet, try again + None => continue, + }; + + let result = Vec::from(&buf[0..pos]); + **buf = Vec::from(&buf[1 + pos..]); + + return Poll::Ready(Some(result)); + }, + // if the inner stream had nothing, we return the buffer and are done + // in order to avoid an inifite loop when the inner stream is done, we clear the + // buffer and return None once the buffer has been output once + None => { + if !self.buf.is_empty() { + let buf = self.buf.clone(); + *self.as_mut().project().buf = vec![]; + return Poll::Ready(Some(buf)); + } else { + return Poll::Ready(None); + } + }, + } + } + } +} + +impl> NewlineStream { + pub fn new(stream: T) -> Self { + Self { + inner: stream, + buf: vec![], + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bytes::Bytes; + use tokio::stream::StreamExt; + + #[tokio::test] + async fn newline_stream() { + let stream = tokio::stream::iter( + vec![ + Bytes::from("hello "), + Bytes::from("world"), + Bytes::from("\nfoobar"), + ] + .into_iter(), + ); + + let mut newline_stream = NewlineStream::new(stream); + + assert_eq!(newline_stream.next().await, Some(b"hello world".to_vec())); + assert_eq!(newline_stream.next().await, Some(b"foobar".to_vec())); + assert_eq!(newline_stream.next().await, None); + } +} + diff --git a/testenv/data/matterbridge/matterbridge.toml b/testenv/data/matterbridge/matterbridge.toml new file mode 100644 index 0000000..77a8282 --- /dev/null +++ b/testenv/data/matterbridge/matterbridge.toml @@ -0,0 +1,24 @@ +[irc.testirc] +# "irc" is the hostname of the irc container in docker-compose +Server = "irc:6667" +Nick = "bridge" +RemoteNickFormat = "[{PROTOCOL}] <{NICK}> " + +[[gateway]] +name = "gateway1" +enable = true + +[[gateway.inout]] +account = "irc.testirc" +channel = "#testing" + +# api config +[api.ruff] +BindAddress = "0.0.0.0:4242" +Buffer = 1000 +RemoteNickFormat = "{NICK}" + +[[gateway.inout]] +account = "api.ruff" +channel = "ruffapi" + diff --git a/testenv/docker-compose.yml b/testenv/docker-compose.yml new file mode 100644 index 0000000..52db8aa --- /dev/null +++ b/testenv/docker-compose.yml @@ -0,0 +1,15 @@ +version: "3.3" +services: + matterbridge: + image: "42wim/matterbridge" + restart: "unless-stopped" + volumes: + - "./data/matterbridge:/etc/matterbridge" + ports: + - "4242:4242" + + irc: + image: "inspircd/inspircd-docker" + ports: + - "6667:6667" +