aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--Cargo.toml24
-rw-r--r--README.md73
-rw-r--r--TODO44
-rw-r--r--src/agenda.rs91
-rw-r--r--src/discord.rs126
-rw-r--r--src/main.rs21
-rw-r--r--src/slack.rs139
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" ]
diff --git a/README.md b/README.md
index a30ac49..4ef3515 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/TODO b/TODO
new file mode 100644
index 0000000..1acc15c
--- /dev/null
+++ b/TODO
@@ -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");
+ }
+ }
+}