diff options
| author | Gustav Sörnäs <gustav@sornas.net> | 2020-11-16 16:19:21 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2020-11-16 16:19:21 +0100 |
| commit | a20e0162412ac4af6ef629e4b99654afad5a464a (patch) | |
| tree | 0841535afce3070ab71591d56cb46a9d3e174aac | |
| parent | f771b73bcda915ce69db49f40473558ae567223f (diff) | |
| parent | 96b9a8f66f62e64bc4907e12de0772392f7804c7 (diff) | |
| download | kodapa-a20e0162412ac4af6ef629e4b99654afad5a464a.tar.gz | |
Merge pull request #1 from lithekod/rust
Initial minimum viable bot
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | Cargo.toml | 24 | ||||
| -rw-r--r-- | README.md | 73 | ||||
| -rw-r--r-- | TODO | 44 | ||||
| -rw-r--r-- | src/agenda.rs | 91 | ||||
| -rw-r--r-- | src/discord.rs | 126 | ||||
| -rw-r--r-- | src/main.rs | 21 | ||||
| -rw-r--r-- | src/slack.rs | 139 |
8 files changed, 513 insertions, 6 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..c9eefec --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "agenda-bot" +version = "0.1.0" +authors = ["Gustav Sörnäs <gustav@sornas.net>"] +edition = "2018" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +futures = "0.3" +serde_json = "1.0" +slack = "0.25" + +[dependencies.discord] +git = "https://github.com/SpaceManiac/discord-rs" +default-features = false + +[dependencies.serde] +version = "1.0" +features = [ "derive" ] + +[dependencies.tokio] +version = "0.2" +features = [ "macros", "sync" ] @@ -1,6 +1,69 @@ A bot to help the board with their meeting agenda and meeting reminders. +## Requirements + +The binary itself depends on OpenSSL, as well as the usual suspects (glibc): + +``` +$ ldd target/debug/agenda-bot + linux-vdso.so.1 (0x00007ffc353fd000) + libssl.so.1.1 => /usr/lib/libssl.so.1.1 (0x00007f58987d2000) + libcrypto.so.1.1 => /usr/lib/libcrypto.so.1.1 (0x00007f58984f4000) + libdl.so.2 => /usr/lib/libdl.so.2 (0x00007f58984ee000) + libpthread.so.0 => /usr/lib/libpthread.so.0 (0x00007f58984cc000) + libgcc_s.so.1 => /usr/lib/libgcc_s.so.1 (0x00007f58984b2000) + libc.so.6 => /usr/lib/libc.so.6 (0x00007f58982e9000) + /lib64/ld-linux-x86-64.so.2 => /usr/lib64/ld-linux-x86-64.so.2 (0x00007f5899b1b000) + libm.so.6 => /usr/lib/libm.so.6 (0x00007f58981a1000) +``` + +It has only been tested on Linux. macOS should work. Rust stable is needed to +compile. + +## Building + +In order to actually use the bot you need: + +- Somewhere for it to live +- A Slack "classic" bot user +- A Discord bot user +- Necessary permissions to add bots to your Slack workspace and Discord server + +Then, either pass the bot tokens as enviornment variables (`DISCORD_API_TOKEN` +and `SLACK_API_TOKEN`), or hard-code them into the binary (**NOT RECOMMENDED** +except for development purposes) by editing `src/discord.rs` and `src/slack.rs`. + +Which channels the messages are sent to is currently specified via either +hard-coded constant values (again, not recommended, but at least not a security +issue here) or environment variables (`DISCORD_CHANNEL` and `SLACK_CHANNEL`). If +any of the two isn't set the bot will print a list of channels and their IDs +when starting so you can specify a channel. + +The following shows all necessary steps needed to build and run the bot: + +```shell +$ git clone https://github.com/lithekod/agenda-bot.git +$ cd agenda-bot +$ DISCORD_API_TOKEN="" \ # fill + SLACK_API_TOKEN="" \ # in + DISCORD_CHANNEL="" \ # your + SLACK_CHANNEL="" \ # values + cargo run +``` + +## Current (non-)features + +- Messages are sent where they should +- ...but they aren't stored anywhere and can't be summarized. +- No reminders. +- No permissions / trusted users / trusted channels. Please, only private + testing servers for now. + +See the TODO for more planned features. + +## Sales pitch (not yet implemented) + Board members can add items to the agenda by sending a message containing something like @@ -9,10 +72,8 @@ containing something like ``` in either Slack or Discord. The bot sends a confirmation in both Slack -and Discord so everyone can see what's added. - -Every wednesday afternoon (configurable), the day before the meeting, -the bot sends a reminder in both Slack and Discord, as well as the -agenda. +and Discord so everyone can see what's being added. -More features TBD. +Every Wednesday afternoon (configurable) the bot sends a reminder and the agenda +in both Slack and Discord. An additional reminder is sent 1 hour before the +meeting. @@ -0,0 +1,44 @@ +META: +* tag with binary on github +* aur?? + +FEATURES: +* specify trusted users (i.e. board members) +* mention the bot to do stuff? +* react to messages with +1 when they are read +* output formatting (!help mainly) +* configurable command prefix + +ISSUES: +* sending to slack is very slow +* too many unwraps +* use correct form of string +* panics on !agenda with empty agenda +* general refactoring + +DONE: +* login +* send messages both ways +* readme +* build without voice support +* build only needed tokio features +* store the agenda in a (configurable) file +* basic commands + add + print + clear + help +* specify channels to read from and send in + +LATER: (ordering and scope not actual) +* send reminders + 24h and 1h (?) +* more commands + next + plan + skip +* allowed users +* customize meeting times + store dates in a file? commands? +* customize reminders +* gcal diff --git a/src/agenda.rs b/src/agenda.rs new file mode 100644 index 0000000..d1a940d --- /dev/null +++ b/src/agenda.rs @@ -0,0 +1,91 @@ +use serde::{ + Deserialize, + Serialize, +}; +use std::{ + fmt, + fs, +}; +use tokio::sync::mpsc; + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct AgendaPoint { + title: String, + adder: String, +} + +impl fmt::Display for AgendaPoint { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} ({})", self.title, self.adder) + } +} + +impl AgendaPoint { + pub fn to_add_message(&self) -> String { + format!("'{}' added by {}", self.title, self.adder) + } + + fn to_add_message_response(&self) -> String { + //TODO should add a reaction instead + format!("Added '{}'", self.title) + } +} + +#[derive(Deserialize, Serialize)] +pub struct Agenda { + points: Vec<AgendaPoint>, +} + +impl Agenda { + fn write(&self) { + fs::write(std::path::Path::new("agenda.json"), + serde_json::to_string_pretty(&self).expect("Can't serialize agenda")) + .expect("Can't write agenda.json"); + } +} + +pub enum ParseError { + NoSuchCommand, +} + +pub fn parse_message( + message: &str, + sender: &str, + point_sender: &mpsc::UnboundedSender<AgendaPoint> +) -> Result<Option<String>, ParseError> { + if message.starts_with("!add ") { + let mut agenda = read_agenda(); + let agenda_point = AgendaPoint { + title: message[5..].to_string(), + adder: sender.to_string(), + }; + point_sender.send(agenda_point.clone()).unwrap(); + let response = agenda_point.to_add_message_response(); + agenda.points.push(agenda_point); + agenda.write(); + Ok(Some(response)) + } else if message.starts_with("!agenda") { + Ok(Some(read_agenda() + .points + .iter() + .map(|p| p.to_string()) + .collect::<Vec<_>>() + .join("\n"))) + } else if message.starts_with("!clear") { + Agenda { + points: Vec::new(), + }.write(); + Ok(None) + } else if message.starts_with("!help") { + Ok(Some("Available commands:\n```!add -- Add something\n!agenda -- Print the agenda\n!clear -- Remove all items\n!help```".to_string())) + } else { + Err(ParseError::NoSuchCommand) + } +} + +fn read_agenda() -> Agenda { + serde_json::from_str::<Agenda>( + &fs::read_to_string("agenda.json") + .expect("Can't read agenda.json")) + .expect("Error parsing agenda.json") +} diff --git a/src/discord.rs b/src/discord.rs new file mode 100644 index 0000000..0507da8 --- /dev/null +++ b/src/discord.rs @@ -0,0 +1,126 @@ +use crate::agenda::{ + parse_message, + AgendaPoint +}; + +use discord::{ + model::{ + ChannelId, + Event, + PossibleServer, + }, + Discord, + Error, +}; +use futures::join; +use std::sync::{ + Arc, + Mutex, +}; +use tokio::{ + sync::mpsc, + task::{ + spawn, + spawn_blocking, + }, +}; + +const TOKEN: Option<&str> = None; +const CHANNEL: Option<ChannelId> = None; + +pub async fn handle( + sender: mpsc::UnboundedSender<AgendaPoint>, + receiver: mpsc::UnboundedReceiver<AgendaPoint>, +) { + println!("Setting up Discord"); + + let token = std::env::var("DISCORD_API_TOKEN").unwrap_or_else(|_| TOKEN.expect("Missing Discord token").to_string()); + let client = Discord::from_bot_token(&token); + + if let Ok(client) = client { + let (connection, _) = client.connect().expect("Discord connect failed"); //TODO + let our_id = client.get_current_user().unwrap().id; + let client = Arc::new(Mutex::new(client)); + + let channel = match std::env::var("DISCORD_CHANNEL") { + Ok(channel) => Some(ChannelId(channel.parse::<u64>().unwrap())), + Err(_) => CHANNEL, + }; + + let (_, _) = join!( //TODO? + spawn(receive_from_slack(receiver, Arc::clone(&client), channel)), + spawn_blocking(move || receive_events(our_id, connection, sender, client, channel)), + ); + } +} + +fn receive_events( + _our_id: discord::model::UserId, + mut connection: discord::Connection, + sender: mpsc::UnboundedSender<AgendaPoint>, + client: Arc<Mutex<discord::Discord>>, + channel: Option<ChannelId>, +) { + loop { + match connection.recv_event() { + Ok(Event::ServerCreate(server)) => { + if channel.is_none() { + if let PossibleServer::Online(server) = server { + println!("Discord channels in {}: {:#?}", + server.name, + server + .channels + .iter() + .map(|channel| format!("{}: {} ({:?})", + channel.name, + channel.id, + channel.kind)) + .collect::<Vec<_>>()); + } + } + } + + Ok(Event::MessageCreate(message)) => { + if let Some(channel) = channel { + if channel == message.channel_id { + if let Ok(Some(s)) = parse_message( + &message.content, + &message.author.name, + &sender, + ) { + client.lock().unwrap().send_message(channel, + &s, + "", + false).unwrap(); + } + } + } + } + Ok(_) => {} + Err(Error::Closed(code, body)) => { + println!("Discord closed with code {:?}: {}", code, body); + break; + } + Err(e) => { + println!("Discord error: {:?}", e); + } + } + } +} + +async fn receive_from_slack( + mut receiver: mpsc::UnboundedReceiver<AgendaPoint>, + client: Arc<Mutex<discord::Discord>>, + channel: Option<ChannelId> +) { + if let Some(channel) = channel { + while let Some(point) = receiver.recv().await { + println!("Discord received '{}'", point); + client.lock().unwrap().send_message(channel, + &point.to_add_message(), + "", + false).unwrap(); + } + } + +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..0bcf53c --- /dev/null +++ b/src/main.rs @@ -0,0 +1,21 @@ +mod agenda; +mod discord; +mod slack; + +use crate::agenda::AgendaPoint; +use futures::join; +use tokio::sync::mpsc; + +#[tokio::main] +async fn main() { + println!("Hello, world!"); + + let (from_discord, to_slack) = mpsc::unbounded_channel::<AgendaPoint>(); + let (from_slack, to_discord) = mpsc::unbounded_channel::<AgendaPoint>(); + + join!( + discord::handle(from_discord, to_discord), + slack::handle(from_slack, to_slack), + ); +} + diff --git a/src/slack.rs b/src/slack.rs new file mode 100644 index 0000000..1c272cf --- /dev/null +++ b/src/slack.rs @@ -0,0 +1,139 @@ +use crate::agenda::{ + parse_message, + AgendaPoint +}; + +use futures::join; +use slack::{ + Event, + Message, +}; +use tokio::{ + sync::mpsc, + task::{ + spawn, + spawn_blocking, + }, +}; + +const TOKEN: Option<&str> = None; +const CHANNEL: Option<&str> = None; + +struct Handler { + sender: mpsc::UnboundedSender<AgendaPoint>, + slack_sender: slack::Sender, + slack_channel: Option<String>, + print_channels: bool, +} + +impl Handler { + fn new( + sender: mpsc::UnboundedSender<AgendaPoint>, + slack_sender: slack::Sender, + slack_channel: Option<String>, + ) -> Self { + Self { + sender, + slack_sender, + slack_channel: slack_channel.clone(), + print_channels: slack_channel.is_none() + } + } +} + +impl slack::EventHandler for Handler { + fn on_event(&mut self, cli: &slack::RtmClient, event: slack::Event) { + match event { + Event::Hello => { + if self.print_channels { + println!("Slack channels found: {:#?}", + cli + .start_response() + .channels + .as_ref() + .and_then(|channels| { + Some(channels + .iter() + .map(|channel| format!("{}: {}", + channel.name.as_ref().unwrap_or(&"??".to_string()), //TODO &"".to_string() ? + channel.id.as_ref().unwrap_or(&"??".to_string()))) //TODO + .collect::<Vec<_>>()) + })); + } + } + Event::Message(msg) => { + if let Some(channel) = &self.slack_channel { + match *msg { + Message::Standard(msg) => { + if msg.channel.is_some() && *channel == msg.channel.unwrap() { //TODO + if let Ok(Some(s)) = parse_message( + &msg.text.unwrap_or("".to_string()), + &msg.user.unwrap_or("??".to_string()), + &self.sender, + ) { + self.slack_sender.send_message(channel.as_str(), &s).unwrap(); + } + } + } + _ => {} // message type + } + } + } + _ => {} // event type + } + } + + fn on_close(&mut self, _cli: &slack::RtmClient) {} + + fn on_connect(&mut self, _cli: &slack::RtmClient) {} +} + +pub async fn handle( + sender: mpsc::UnboundedSender<AgendaPoint>, + receiver: mpsc::UnboundedReceiver<AgendaPoint>, +) { + println!("Setting up Slack"); + + let token = std::env::var("SLACK_API_TOKEN").unwrap_or_else(|_| TOKEN.expect("Missing slack token").to_string()); + let channel = match std::env::var("SLACK_CHANNEL") { + Ok(channel) => Some(channel), + Err(_) => match CHANNEL { + Some(channel) => Some(channel.to_string()), + None => None + } + }; + let client = spawn_blocking(move || { + slack::RtmClient::login(&token).unwrap() + }).await.unwrap(); + + let mut handler = Handler::new(sender, client.sender().clone(), channel.clone()); + let slack_sender = client.sender().clone(); + + let (_, _) = join!( + spawn_blocking(move || { + match client.run(&mut handler) { + Ok(_) => {} + Err(e) => { + println!("Error: {}", e) + } + } + }), + spawn(receive_from_discord(receiver, slack_sender, channel)) + ); +} + +async fn receive_from_discord( + mut receiver: mpsc::UnboundedReceiver<AgendaPoint>, + sender: slack::Sender, + channel: Option<String>, +) { + if let Some(channel) = channel { + while let Some(point) = receiver.recv().await { + //TODO Sending messages is very slow sometimes. Have seen delays + // from 5 up to 20(!) seconds. + sender.send_typing(&channel).unwrap(); + sender.send_message(&channel, &point.to_add_message()).unwrap(); + println!("Slack message sent"); + } + } +} |
