voting.rs 14.8 KB
Newer Older
tec's avatar
tec committed
1 2 3 4 5
use serenity::{
    model::{channel, channel::Message},
    prelude::*,
    utils::MessageBuilder,
};
tec's avatar
tec committed
6 7
use std::collections::HashMap;
use std::sync::Mutex;
tec's avatar
tec committed
8

9
use crate::config::CONFIG;
10
use crate::util::get_string_from_react;
tec's avatar
tec committed
11 12 13 14 15

pub struct Commands;
impl Commands {
    pub fn move_something(ctx: Context, msg: Message, content: &str) {
        let motion = content;
Timothy du Heaume's avatar
Timothy du Heaume committed
16
        if !motion.is_empty() {
tec's avatar
tec committed
17
            create_motion(&ctx, &msg, motion);
Ash's avatar
cleanup  
Ash committed
18
            return
tec's avatar
tec committed
19
        }
tec's avatar
tec committed
20 21 22 23 24
        send_message!(
            msg.channel_id,
            &ctx.http,
            "If there's something you want to motion, put it after the !move keyword"
        );
tec's avatar
tec committed
25 26
    }
    pub fn motion(ctx: Context, msg: Message, _content: &str) {
tec's avatar
tec committed
27 28 29 30 31
        send_message!(
            msg.channel_id,
            &ctx.http,
            "I hope you're not having a motion. You may have wanted to !move something instead."
        );
tec's avatar
tec committed
32 33 34
    }
    pub fn poll(ctx: Context, msg: Message, content: &str) {
        let topic = content;
Timothy du Heaume's avatar
Timothy du Heaume committed
35
        if !topic.is_empty() {
tec's avatar
tec committed
36
            create_poll(&ctx, &msg, topic);
Ash's avatar
cleanup  
Ash committed
37
            return
tec's avatar
tec committed
38
        }
tec's avatar
tec committed
39 40 41 42 43
        send_message!(
            msg.channel_id,
            &ctx.http,
            "If there's something you want to motion, put it after the !move keyword"
        );
tec's avatar
tec committed
44 45
    }
    pub fn cowsay(ctx: Context, msg: Message, content: &str) {
tec's avatar
tec committed
46
        let output = if !content.trim().is_empty() {
47 48 49 50 51
            let mut text = content.to_owned();
            text.escape_default();
            // Guess what buddy! You definitely are passing a string to cowsay
            text.insert(0, '\'');
            text.insert(text.len(), '\'');
tec's avatar
tec committed
52
            std::process::Command::new("cowsay")
53 54
                .arg(text)
                .output()
tec's avatar
tec committed
55
                .expect("failed to execute cowsay")
56
        } else {
tec's avatar
tec committed
57
            std::process::Command::new("sh")
58
                .arg("-c")
tec's avatar
tec committed
59
                .arg("fortune | cowsay -f \"/usr/share/cowsay/cows/$(echo 'www\nhellokitty\nbud-frogs\nkoala\nsuse\nthree-eyes\npony-smaller\nsheep\nvader\ncower\nmoofasa\nelephant\nflaming-sheep\nskeleton\nsnowman\ntux\napt\nmoose' | shuf -n 1).cow\"")
60
                .output()
tec's avatar
tec committed
61 62
                .expect("failed to execute fortune/cowsay")
        };
tec's avatar
tec committed
63
        let mut message = MessageBuilder::new();
tec's avatar
tec committed
64
        message.push_codeblock_safe(
tec's avatar
tec committed
65 66 67
            String::from_utf8(output.stdout).expect("unable to parse stdout to String"),
            None,
        );
68
        send_message!(msg.channel_id, &ctx.http, message.build());
tec's avatar
tec committed
69 70 71 72
    }
}

fn create_motion(ctx: &Context, msg: &Message, topic: &str) {
tec's avatar
tec committed
73
    info!("{} created a motion {}", msg.author.name, topic);
tec's avatar
tec committed
74
    if let Err(why) = msg.delete(ctx) {
tec's avatar
tec committed
75
        error!("Error deleting motion prompt: {:?}", why);
tec's avatar
tec committed
76
    }
Timothy du Heaume's avatar
Timothy du Heaume committed
77
    let result = msg.channel_id.send_message(&ctx.http, |m| {
tec's avatar
tec committed
78 79 80 81 82 83 84 85 86 87 88 89 90
        m.embed(|embed| {
            embed.author(|a| {
                a.name(&msg.author.name);
                a.icon_url(
                    msg.author
                        .static_avatar_url()
                        .expect("Expected author to have avatar"),
                );
                a
            });
            embed.colour(serenity::utils::Colour::GOLD);
            embed.title(format!("Motion to {}", topic));
            let mut desc = MessageBuilder::new();
91
            desc.role(CONFIG.vote_role);
tec's avatar
tec committed
92 93 94 95 96 97 98 99 100
            desc.push(" take a look at this motion from ");
            desc.mention(&msg.author);
            embed.description(desc.build());
            embed.field("Status", "Under Consideration", true);
            embed.field("Votes", "For: 0\nAgainst: 0\nAbstain: 0", true);
            embed.timestamp(msg.timestamp.to_rfc3339());
            embed
        });
        m.reactions(vec![
101 102 103 104 105
            CONFIG.for_vote.to_string(),
            CONFIG.against_vote.to_string(),
            CONFIG.abstain_vote.to_string(),
            CONFIG.approve_react.to_string(),
            CONFIG.disapprove_react.to_string(),
tec's avatar
tec committed
106 107
        ]);
        m
Timothy du Heaume's avatar
Timothy du Heaume committed
108 109 110
    });
    if let Err(why) = result {
        error!("Error creating motion: {:?}", why);
tec's avatar
tec committed
111 112 113 114
    }
}

fn create_poll(ctx: &Context, msg: &Message, topic: &str) {
tec's avatar
tec committed
115
    info!("{} created a poll {}", msg.author.name, topic);
tec's avatar
tec committed
116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136
    match msg.channel_id.send_message(&ctx.http, |m| {
        m.embed(|embed| {
            embed.author(|a| {
                a.name(&msg.author.name);
                a.icon_url(
                    msg.author
                        .static_avatar_url()
                        .expect("Expected author to have avatar"),
                );
                a
            });
            embed.colour(serenity::utils::Colour::BLUE);
            embed.title(format!("Poll {}", topic));
            let mut desc = MessageBuilder::new();
            desc.mention(&msg.author);
            desc.push(" wants to know what you think.");
            embed.description(desc.build());
            embed.timestamp(msg.timestamp.to_rfc3339());
            embed
        });
        m.reactions(vec![
137 138 139
            CONFIG.approve_react.to_string(),
            CONFIG.disapprove_react.to_string(),
            CONFIG.unsure_react.to_string(),
tec's avatar
tec committed
140 141 142 143
        ]);
        m
    }) {
        Err(why) => {
tec's avatar
tec committed
144
            error!("Error sending message: {:?}", why);
tec's avatar
tec committed
145 146 147
        }
        Ok(_) => {
            if let Err(why) = msg.delete(ctx) {
tec's avatar
tec committed
148
                error!("Error deleting motion prompt: {:?}", why);
tec's avatar
tec committed
149 150 151 152 153
            }
        }
    }
}

tec's avatar
tec committed
154 155
#[derive(Debug, Clone)]
struct MotionInfo {
156
    votes: HashMap<String, Vec<serenity::model::user::User>>,
tec's avatar
tec committed
157 158 159 160 161 162 163 164 165 166
}

lazy_static! {
    static ref MOTIONS_CACHE: Mutex<HashMap<serenity::model::id::MessageId, MotionInfo>> =
        Mutex::new(HashMap::new());
}

fn get_cached_motion(ctx: &Context, msg: &Message) -> MotionInfo {
    let mut cached_motions = MOTIONS_CACHE.lock().unwrap();
    if !cached_motions.contains_key(&msg.id) {
tec's avatar
tec committed
167
        info!("Initialising representation of motion {:?}", msg.id);
tec's avatar
tec committed
168 169 170 171
        let this_motion = MotionInfo {
            votes: {
                let mut m = HashMap::new();
                m.insert(
172
                    CONFIG.for_vote.to_string(),
173
                    msg.reaction_users(ctx, CONFIG.for_vote.to_string(), Some(100), None)
tec's avatar
tec committed
174 175 176
                        .unwrap(),
                );
                m.insert(
177
                    CONFIG.against_vote.to_string(),
178
                    msg.reaction_users(ctx, CONFIG.against_vote.to_string(), Some(100), None)
tec's avatar
tec committed
179 180 181
                        .unwrap(),
                );
                m.insert(
182
                    CONFIG.abstain_vote.to_string(),
183
                    msg.reaction_users(ctx, CONFIG.abstain_vote.to_string(), Some(100), None)
tec's avatar
tec committed
184 185 186 187 188 189 190
                        .unwrap(),
                );
                m
            },
        };
        cached_motions.insert(msg.id, this_motion);
    }
Timothy du Heaume's avatar
Timothy du Heaume committed
191
    (*cached_motions.get(&msg.id).unwrap()).clone()
tec's avatar
tec committed
192
}
Timothy du Heaume's avatar
Timothy du Heaume committed
193 194
fn set_cached_motion(id: serenity::model::id::MessageId, motion_info: MotionInfo) {
    if let Some(motion) = MOTIONS_CACHE.lock().unwrap().get_mut(&id) {
tec's avatar
tec committed
195
        *motion = motion_info;
Ash's avatar
cleanup  
Ash committed
196
        return
tec's avatar
tec committed
197
    }
tec's avatar
tec committed
198
    warn!("{}", "Couldn't find motion in cache to set");
tec's avatar
tec committed
199 200
}

201 202 203 204 205 206 207 208 209 210
macro_rules! tiebreaker {
    ($ctx: expr, $vote: expr, $motion_info: expr) => {
        if $motion_info.votes.get($vote).unwrap().iter().any(|u| {
            u.has_role($ctx, CONFIG.server_id, CONFIG.tiebreaker_role)
                .unwrap()
        }) {
            0.25
        } else {
            0.0
        }
Ash's avatar
cleanup  
Ash committed
211
    }
212 213
}

tec's avatar
tec committed
214 215 216 217 218 219 220
fn update_motion(
    ctx: &Context,
    msg: &mut Message,
    user: &serenity::model::user::User,
    change: &str,
    reaction: channel::Reaction,
) {
tec's avatar
tec committed
221 222
    let motion_info: MotionInfo = get_cached_motion(ctx, msg);

223 224 225
    let for_votes = motion_info.votes.get(&CONFIG.for_vote).unwrap().len() as isize - 1;
    let against_votes = motion_info.votes.get(&CONFIG.against_vote).unwrap().len() as isize - 1;
    let abstain_votes = motion_info.votes.get(&CONFIG.abstain_vote).unwrap().len() as isize - 1;
tec's avatar
tec committed
226

227
    let for_strength = for_votes as f32 + tiebreaker!(ctx, &CONFIG.for_vote, motion_info);
Ash's avatar
cleanup  
Ash committed
228 229
    let against_strength = against_votes as f32 + tiebreaker!(ctx, &CONFIG.against_vote, motion_info);
    let abstain_strength = abstain_votes as f32 + tiebreaker!(ctx, &CONFIG.abstain_vote, motion_info);
tec's avatar
tec committed
230 231 232 233

    let old_embed = msg.embeds[0].clone();
    let topic = old_embed.clone().title.unwrap();

tec's avatar
tec committed
234
    info!(
tec's avatar
tec committed
235 236 237
        "  {:10} {:6} {} on {}",
        user.name,
        change,
238
        get_string_from_react(&reaction.emoji),
tec's avatar
tec committed
239 240 241 242
        topic
    );

    let update_status = |e: &mut serenity::builder::CreateEmbed,
tec's avatar
tec committed
243 244 245
                         status: &str,
                         last_status_full: String,
                         topic: &str| {
tec's avatar
tec committed
246 247 248 249 250 251 252 253 254
        let last_status = last_status_full.lines().next().expect("No previous status");
        if last_status == status {
            e.field("Status", last_status_full, true);
        } else {
            e.field(
                "Status",
                format!("{}\n_was_ {}", status, last_status_full),
                true,
            );
tec's avatar
tec committed
255
            info!("Motion to {} now {}", topic, status);
tec's avatar
tec committed
256 257 258 259 260 261
            //
            let mut message = MessageBuilder::new();
            message.push_bold(topic);
            message.push(" is now ");
            message.push_bold(status);
            message.push_italic(format!(" (was {})", last_status));
262
            send_message!(CONFIG.announcement_channel, &ctx.http, message.build());
tec's avatar
tec committed
263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282
        }
    };

    if let Err(why) = msg.edit(ctx, |m| {
        m.embed(|e| {
            e.author(|a| {
                let old_author = old_embed.clone().author.expect("Expected author in embed");
                a.name(old_author.name);
                a.icon_url(
                    old_author
                        .icon_url
                        .expect("Expected embed author to have icon"),
                );
                a
            });
            e.title(&topic);
            e.description(old_embed.description.unwrap());
            let last_status_full = old_embed
                .fields
                .iter()
Timothy du Heaume's avatar
Timothy du Heaume committed
283
                .find(|f| f.name == "Status")
tec's avatar
tec committed
284 285 286
                .expect("No previous status")
                .clone()
                .value;
287
            if for_strength > (CONFIG.vote_pool_size as f32 / 2.0) {
tec's avatar
tec committed
288 289
                e.colour(serenity::utils::Colour::TEAL);
                update_status(e, "Passed", last_status_full, &topic);
290
            } else if against_strength + abstain_strength > (CONFIG.vote_pool_size as f32 / 2.0) {
tec's avatar
tec committed
291 292 293 294 295 296 297 298 299 300
                e.colour(serenity::utils::Colour::RED);
                update_status(e, "Failed", last_status_full, &topic);
            } else {
                e.colour(serenity::utils::Colour::GOLD);
                update_status(e, "Under Consideration", last_status_full, &topic);
            }
            e.field(
                format!(
                    "Votes ({}/{})",
                    for_votes + against_votes + abstain_votes,
301
                    CONFIG.vote_pool_size
tec's avatar
tec committed
302 303 304 305 306 307 308 309 310 311 312 313 314 315 316
                ),
                format!(
                    "For: {}\nAgainst: {}\nAbstain: {}",
                    for_votes, against_votes, abstain_votes
                ),
                true,
            );
            e.timestamp(
                old_embed
                    .timestamp
                    .expect("Expected embed to have timestamp"),
            );
            e
        })
    }) {
tec's avatar
tec committed
317
        error!("Error updating motion: {:?}", why);
tec's avatar
tec committed
318 319 320 321
    }
}

pub fn reaction_add(ctx: Context, add_reaction: channel::Reaction) {
322
    let react_as_string = get_string_from_react(&add_reaction.emoji);
tec's avatar
tec committed
323 324
    match add_reaction.message(&ctx.http) {
        Ok(mut message) => {
325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341
            guard!(let Ok(user) = add_reaction.user(&ctx) else {
                return
            });
            match user.has_role(&ctx, CONFIG.server_id, CONFIG.vote_role) {
                Ok(true) => {
                    // remove vote if already voted
                    for react in [
                        CONFIG.for_vote.to_string(),
                        CONFIG.against_vote.to_string(),
                        CONFIG.abstain_vote.to_string(),
                    ]
                    .iter()
                    .filter(|r| r != &&react_as_string)
                    {
                        for a_user in message
                            .reaction_users(&ctx, react.as_str(), None, None)
                            .unwrap()
tec's avatar
tec committed
342
                        {
343 344 345 346 347
                            if a_user.id.0 == user.id.0 {
                                if let Err(why) = add_reaction.delete(&ctx) {
                                    error!("Error deleting react: {:?}", why);
                                };
                                return;
tec's avatar
tec committed
348
                            }
tec's avatar
tec committed
349 350
                        }
                    }
351 352 353 354 355 356 357 358 359 360 361 362
                    // remove 'illegal' reacts
                    if !CONFIG.allowed_reacts().contains(&react_as_string) {
                        if let Err(why) = add_reaction.delete(&ctx) {
                            error!("Error deleting react: {:?}", why);
                        };
                        return;
                    }
                    // update motion
                    let mut motion_info = get_cached_motion(&ctx, &message);
                    if let Some(vote) = motion_info.votes.get_mut(&react_as_string) {
                        vote.retain(|u| u.id != user.id);
                        vote.push(user.clone());
tec's avatar
tec committed
363
                    }
364 365 366 367 368 369 370 371 372 373 374 375 376 377
                    set_cached_motion(message.id, motion_info);
                    update_motion(&ctx, &mut message, &user, "add", add_reaction);
                }
                Ok(false) => {
                    if ![
                        CONFIG.approve_react.to_string(),
                        CONFIG.disapprove_react.to_string(),
                    ]
                    .contains(&react_as_string)
                    {
                        if let Err(why) = add_reaction.delete(&ctx) {
                            error!("Error deleting react: {:?}", why);
                        };
                        return;
tec's avatar
tec committed
378
                    }
tec's avatar
tec committed
379
                }
380 381 382
                Err(why) => {
                    error!("Error getting user role: {:?}", why);
                }
tec's avatar
tec committed
383 384 385
            }
        }
        Err(why) => {
tec's avatar
tec committed
386
            error!("Error processing react: {:?}", why);
tec's avatar
tec committed
387 388 389 390 391 392 393
        }
    }
}

pub fn reaction_remove(ctx: Context, removed_reaction: channel::Reaction) {
    match removed_reaction.message(&ctx.http) {
        Ok(mut message) => {
tec's avatar
tec committed
394 395 396 397
            if let Ok(user) = removed_reaction.user(&ctx) {
                let mut motion_info = get_cached_motion(&ctx, &message);
                if let Some(vote) = motion_info
                    .votes
398
                    .get_mut(&get_string_from_react(&removed_reaction.emoji))
tec's avatar
tec committed
399 400
                {
                    vote.retain(|u| u.id != user.id);
tec's avatar
tec committed
401
                }
Timothy du Heaume's avatar
Timothy du Heaume committed
402
                set_cached_motion(message.id, motion_info);
tec's avatar
tec committed
403
                update_motion(&ctx, &mut message, &user, "remove", removed_reaction);
tec's avatar
tec committed
404 405 406
            }
        }
        Err(why) => {
tec's avatar
tec committed
407
            error!("Error getting user role: {:?}", why);
tec's avatar
tec committed
408 409 410
        }
    }
}