Compare commits

...

13 Commits

Author SHA1 Message Date
34cb744091
Removed imgur support 2023-12-08 20:15:32 -07:00
6dd12fd561
Added vampires roles and buffed spy/agent 2023-12-08 19:43:45 -07:00
f86ef6fc21
Fixed pings and debugs in messages
+ Also added more logging to ensure spy is working okay
+ clippy + fmt
2023-02-15 17:06:27 -07:00
6ebf9e0ad2
Fixed issue with automated messages and spying
+ Discord blocks "System Messages" from usersnames because of course it does
2023-02-14 18:46:19 -07:00
fa7d78c272
Added new roles 2023-01-28 13:36:22 -07:00
b91223d6ed
Trying to fix woodpecker pipeline 2023-01-09 18:51:38 -07:00
ae7d9cf996
More cleanup for new theme
+ Clippy + fmt
2023-01-08 21:00:59 -07:00
66442efae6
Bit of refactoring and cleanup
+ Split command groups into their own files
+ Upped version
+ Clippy + fmt
2023-01-08 20:13:31 -07:00
275f5c9305
Added basic role handling
+ Games now have a config for which roles they have
+ Roles are assigned at game start
+ Roles don't function within the bot, except the spy
+ Added a spy listener
+ Clippy + fmt
2023-01-08 16:23:50 -07:00
7af3bab486
Added basic listener
+ Only support chat events now
+ Created a listener for host message snooping
+ Clippy + fmt
2023-01-08 13:44:11 -07:00
4b7570b24c Merge pull request 'msg_refactor' (#4) from msg_refactor into main
Reviewed-on: https://git.jojodev.com/joeyahines/wOxlf/pulls/4
2023-01-06 05:07:03 +00:00
9a91a16e0d Message router refactor
+ Created the WoxlfMessage struct to streamline interface
+ Message tasks are now joined at once instead of sequentially
+ Clippy + fmt
2023-01-06 05:07:03 +00:00
3c219f5bff Initial refactor of message handling
+ Split all message handling into message_router.rs
+ Added whisper command
+ Updated serenity version
+ Fmt, but clippy failing
2023-01-06 05:07:03 +00:00
21 changed files with 1606 additions and 897 deletions

View File

@ -8,13 +8,13 @@ pipeline:
- "cargo build --verbose" - "cargo build --verbose"
- "cargo clippy --workspace --tests --all-targets --all-features" - "cargo clippy --workspace --tests --all-targets --all-features"
- "cargo test --workspace --no-fail-fast" - "cargo test --workspace --no-fail-fast"
image: "rust:latest" image: "rust:1-bust"
when: when:
event: pull_request, push event: pull_request, push
build: build:
commands: commands:
- "cargo build --release" - "cargo build --release"
image: "rust:latest" image: "rust:1-buster"
when: when:
branch: main branch: main
event: push event: push

625
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,24 +1,25 @@
[package] [package]
name = "woxlf" name = "woxlf"
version = "0.2.0" version = "0.3.1"
edition = "2021" edition = "2021"
# 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]
config = "0.12.0" config = "0.13.3"
structopt = "0.3.26" structopt = "0.3.26"
chrono = {version="0.4.19", features=["serde"]} chrono = {version="0.4.19", features=["serde"]}
serde = "1.0.136" serde = "1.0.136"
rand = "0.8.5" rand = "0.8.5"
toml = "0.5.8" toml = "0.7.2"
regex = "1.5.5" regex = "1.5.5"
futures = "0.3.21" futures = "0.3.21"
reqwest = "0.11.10" reqwest = "0.11.10"
tera = "1.15.0" tera = "1.15.0"
bitflags = "1.3.2"
[dependencies.serenity] [dependencies.serenity]
version = "0.10.10" version = "0.11.5"
features = ["framework", "standard_framework"] features = ["framework", "standard_framework"]
[dependencies.tokio] [dependencies.tokio]

View File

@ -1,6 +1,7 @@
use std::path::Path; use std::path::Path;
use std::path::PathBuf; use std::path::PathBuf;
use crate::game::role::Role;
use config::{Config, File}; use config::{Config, File};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use structopt::StructOpt; use structopt::StructOpt;
@ -15,10 +16,13 @@ pub struct Args {
pub struct GameConfig { pub struct GameConfig {
pub game_name: String, pub game_name: String,
pub bot_name: String, pub bot_name: String,
pub bot_profile_pic: String,
pub vote_phase_name: String, pub vote_phase_name: String,
pub enemy_phase_name: String, pub enemy_phase_name: String,
pub player_group_name: String, pub player_group_name: String,
pub profile_album_hash: String, pub profile_album: Vec<String>,
pub whispers_allowed: bool,
pub roles: Vec<Role>,
pub first_name: Vec<String>, pub first_name: Vec<String>,
pub last_name: Vec<String>, pub last_name: Vec<String>,
pub messages: MessageConfig, pub messages: MessageConfig,
@ -43,6 +47,7 @@ pub struct DiscordConfig {
pub host_webhook_id: u64, pub host_webhook_id: u64,
pub vote_channel: u64, pub vote_channel: u64,
pub category: u64, pub category: u64,
pub guild_id: u64,
} }
#[derive(Debug, Deserialize, Serialize, Clone)] #[derive(Debug, Deserialize, Serialize, Clone)]

View File

@ -1,13 +1,13 @@
use serenity::async_trait; use serenity::async_trait;
use serenity::client::{Context, EventHandler}; use serenity::client::{Context, EventHandler};
use serenity::http::AttachmentType;
use serenity::model::channel::Message; use serenity::model::channel::Message;
use serenity::model::gateway::Ready; use serenity::model::gateway::Ready;
use serenity::model::prelude::AttachmentType;
use serenity::utils::parse_emoji; use serenity::utils::parse_emoji;
use crate::discord::helper::send_webhook_msg_to_player_channels;
use crate::game::global_data::GlobalData; use crate::game::global_data::GlobalData;
use crate::game::MessageSource; use crate::game::message_router::{dispatch_message, MessageDest};
use crate::game::message_router::{Median, MessageSource, WoxlfMessage};
pub struct Handler {} pub struct Handler {}
@ -43,7 +43,6 @@ impl EventHandler for Handler {
return; return;
} }
let guild = msg.guild(&ctx.cache).await.unwrap();
let user_msg = msg.content.clone(); let user_msg = msg.content.clone();
let re = regex::Regex::new(r"<a?:.+:\d+>").unwrap(); let re = regex::Regex::new(r"<a?:.+:\d+>").unwrap();
@ -52,7 +51,6 @@ impl EventHandler for Handler {
if let Some(emoji) = parse_emoji(&emoji_cap[0]) { if let Some(emoji) = parse_emoji(&emoji_cap[0]) {
if !msg if !msg
.guild(&ctx.cache) .guild(&ctx.cache)
.await
.unwrap() .unwrap()
.emojis .emojis
.contains_key(&emoji.id) .contains_key(&emoji.id)
@ -66,21 +64,20 @@ impl EventHandler for Handler {
let attachments: Vec<AttachmentType> = msg let attachments: Vec<AttachmentType> = msg
.attachments .attachments
.iter() .iter()
.map(|a| AttachmentType::Image(&a.url)) .map(|a| AttachmentType::Image((a.url).parse().unwrap()))
.collect(); .collect();
let msg_source = MessageSource::Player(Box::new(player_data.clone())); let woxlf_msg = WoxlfMessage::default()
.source(MessageSource::Player(Box::new(player_data.clone())))
.dest(MessageDest::Broadcast)
.median(Median::Webhook)
.content(&user_msg)
.attachments(attachments)
.clone();
send_webhook_msg_to_player_channels( dispatch_message(&ctx, &mut global_data, woxlf_msg)
&ctx, .await
&guild, .expect("Unable to send message to players");
&mut global_data,
msg_source,
&user_msg,
Some(attachments),
)
.await
.expect("Unable to send message to players");
} }
} }

View File

@ -1,9 +1,8 @@
use serenity::client::Context; use serenity::client::Context;
use serenity::framework::standard::Args; use serenity::framework::standard::Args;
use serenity::http::{AttachmentType, Http}; use serenity::model::channel::{PermissionOverwrite, PermissionOverwriteType};
use serenity::model::channel::{Message, PermissionOverwrite, PermissionOverwriteType};
use serenity::model::guild::{Guild, Member}; use serenity::model::guild::{Guild, Member};
use serenity::model::id::{ChannelId, UserId}; use serenity::model::id::ChannelId;
use serenity::model::Permissions; use serenity::model::Permissions;
use crate::error; use crate::error;
@ -11,202 +10,9 @@ use crate::error::WoxlfError;
use crate::game::game_state::PhaseDuration; use crate::game::game_state::PhaseDuration;
use crate::game::global_data::GlobalData; use crate::game::global_data::GlobalData;
use crate::game::player_data::PlayerData; use crate::game::player_data::PlayerData;
use crate::game::MessageSource; use crate::game::role::Role;
use crate::imgur::Image;
use serenity::prelude::SerenityError;
fn filter_source_channel(player_data: &&PlayerData, msg_source: &MessageSource) -> bool {
if let MessageSource::Player(source_player) = &msg_source {
if source_player.channel == player_data.channel {
return false;
}
}
true
}
pub async fn send_msg_to_player_channels(
ctx: &Context,
guild: &Guild,
global_data: &mut GlobalData,
msg_source: MessageSource,
msg: &str,
attachment: Option<Vec<AttachmentType<'_>>>,
pin: bool,
) -> error::Result<()> {
let msg_tasks = global_data
.game_state_mut()?
.player_data
.iter()
.filter(|player| filter_source_channel(player, &msg_source))
.map(|player_data| {
let channel = guild
.channels
.get(&ChannelId::from(player_data.channel))
.unwrap();
channel.send_message(&ctx.http, |m| {
m.content(&msg);
if let Some(attachment) = attachment.clone() {
m.add_files(attachment);
}
m
})
});
let msgs: Result<Vec<Message>, SerenityError> = futures::future::join_all(msg_tasks)
.await
.into_iter()
.collect();
let msgs = msgs?;
if pin {
let pin_tasks = msgs.iter().map(|msg| msg.pin(&ctx.http));
let pins: Result<(), SerenityError> = futures::future::join_all(pin_tasks)
.await
.into_iter()
.collect();
pins?;
}
let host_channel = guild
.channels
.get(&ChannelId::from(
global_data.cfg.discord_config.host_channel,
))
.unwrap();
let source = match msg_source {
MessageSource::Player(player_data) => {
let name = guild
.members
.get(&UserId::from(player_data.discord_id))
.unwrap()
.display_name();
name.to_string()
}
MessageSource::Host => "Host".to_string(),
MessageSource::Automated => "Automated".to_string(),
};
host_channel
.send_message(&ctx.http, |m| {
m.content(format!("({}): {}", source, msg));
if let Some(attachment) = attachment {
m.add_files(attachment);
}
m
})
.await?;
Ok(())
}
pub async fn send_webhook_msg(
http: &Http,
webhook_id: u64,
username: &str,
profile_pic_url: Option<String>,
msg: &str,
attachment: Option<Vec<AttachmentType<'_>>>,
) -> error::Result<()> {
let webhook = http.get_webhook(webhook_id).await?;
webhook
.execute(http, false, |w| {
w.content(&msg).username(username);
if let Some(profile_pic_url) = profile_pic_url {
w.avatar_url(profile_pic_url);
}
if let Some(attachment) = attachment.clone() {
w.add_files(attachment);
}
w
})
.await?;
Ok(())
}
pub async fn send_webhook_msg_to_player_channels(
ctx: &Context,
guild: &Guild,
global_data: &mut GlobalData,
msg_source: MessageSource,
msg: &str,
attachment: Option<Vec<AttachmentType<'_>>>,
) -> error::Result<()> {
let msg_username = match &msg_source {
MessageSource::Player(p) => p.codename.clone(),
MessageSource::Host => "Woxlf".to_string(),
MessageSource::Automated => "Woxlf System Message".to_string(),
};
let profile_pic = match &msg_source {
MessageSource::Player(p) => Some(p.profile_pic_url.clone()),
MessageSource::Host | MessageSource::Automated => None,
};
let msg_tasks = global_data
.game_state_mut()?
.player_data
.iter()
.filter(|player| filter_source_channel(player, &msg_source))
.map(|player_data| {
send_webhook_msg(
&ctx.http,
player_data.channel_webhook_id,
&msg_username,
profile_pic.clone(),
msg,
attachment.clone(),
)
});
let msgs: Result<(), WoxlfError> = futures::future::join_all(msg_tasks)
.await
.into_iter()
.collect();
msgs?;
let source = match &msg_source {
MessageSource::Player(player_data) => {
let name = guild
.members
.get(&UserId::from(player_data.discord_id))
.unwrap()
.display_name();
name.to_string()
}
MessageSource::Host => "Host".to_string(),
MessageSource::Automated => "Automated".to_string(),
};
let host_channel_username = format!("{} ({})", msg_username, source);
send_webhook_msg(
&ctx.http,
global_data.cfg.discord_config.host_webhook_id,
&host_channel_username,
profile_pic,
msg,
attachment,
)
.await?;
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub async fn add_user_to_game( pub async fn add_user_to_game(
ctx: &Context, ctx: &Context,
guild: &Guild, guild: &Guild,
@ -214,7 +20,8 @@ pub async fn add_user_to_game(
discord_user: &Member, discord_user: &Member,
first_name: Option<String>, first_name: Option<String>,
last_name: Option<String>, last_name: Option<String>,
profile_pic: Option<Image>, profile_pic: Option<String>,
role: Option<Role>,
) -> error::Result<PlayerData> { ) -> error::Result<PlayerData> {
if first_name.is_none() && last_name.is_none() { if first_name.is_none() && last_name.is_none() {
return Err(WoxlfError::RanOutOfCodenames); return Err(WoxlfError::RanOutOfCodenames);
@ -224,6 +31,10 @@ pub async fn add_user_to_game(
return Err(WoxlfError::RanOutOfProfilePics); return Err(WoxlfError::RanOutOfProfilePics);
} }
if role.is_none() {
return Err(WoxlfError::RanOutOfRoles);
}
let codename = global_data let codename = global_data
.templates()? .templates()?
.build_name(global_data, first_name, last_name) .build_name(global_data, first_name, last_name)
@ -231,13 +42,13 @@ pub async fn add_user_to_game(
let channel = guild let channel = guild
.create_channel(&ctx.http, |c| { .create_channel(&ctx.http, |c| {
c.category(&ChannelId::from(global_data.cfg.discord_config.category)) c.category(ChannelId::from(global_data.cfg.discord_config.category))
.name(format!("{}'s Channel", discord_user.display_name())) .name(format!("{}'s Channel", discord_user.display_name()))
}) })
.await?; .await?;
let allow = let allow =
Permissions::SEND_MESSAGES | Permissions::READ_MESSAGE_HISTORY | Permissions::READ_MESSAGES; Permissions::SEND_MESSAGES | Permissions::READ_MESSAGE_HISTORY | Permissions::VIEW_CHANNEL;
let overwrite = PermissionOverwrite { let overwrite = PermissionOverwrite {
allow, allow,
@ -260,8 +71,9 @@ pub async fn add_user_to_game(
vote_target: None, vote_target: None,
codename, codename,
channel_webhook_id: webhook.id.0, channel_webhook_id: webhook.id.0,
profile_pic_url: profile_pic.unwrap().link, profile_pic_url: profile_pic.unwrap(),
alive: true, alive: true,
role: role.unwrap(),
}; };
global_data global_data

View File

@ -1,26 +1,24 @@
use chrono::Duration; use crate::discord::helper::{add_user_to_game, parse_duration_arg};
use std::collections::HashSet; use crate::error;
use crate::error::WoxlfError;
use rand::prelude::SliceRandom;
use rand::thread_rng;
use serenity::framework::standard::macros::{command, group, help, hook};
use serenity::framework::standard::{
help_commands, Args, CommandGroup, CommandResult, HelpOptions,
};
use serenity::framework::StandardFramework;
use serenity::model::guild::Member;
use serenity::model::id::ChannelId;
use serenity::model::prelude::{Message, UserId};
use serenity::prelude::Context;
use serenity::utils::MessageBuilder;
use crate::discord::helper::{add_user_to_game, parse_duration_arg, send_msg_to_player_channels};
use crate::error::{Result, WoxlfError};
use crate::game::global_data::GlobalData; use crate::game::global_data::GlobalData;
use crate::game::message_router::{
dispatch_message, Median, MessageDest, MessageSource, WoxlfMessage,
};
use crate::game::player_data::PlayerData; use crate::game::player_data::PlayerData;
use crate::game::MessageSource; use crate::game::role::Role;
use crate::game::Phase; use crate::game::Phase;
use crate::messages::DiscordUser; use crate::messages::DiscordUser;
use chrono::Duration;
use rand::prelude::SliceRandom;
use rand::thread_rng;
use serenity::client::Context;
use serenity::framework::standard::macros::{command, group};
use serenity::framework::standard::{Args, CommandResult};
use serenity::model::channel::Message;
use serenity::model::guild::Member;
use serenity::model::id::{ChannelId, UserId};
use serenity::utils::MessageBuilder;
#[group] #[group]
#[commands(start, say, end, broadcast, next_phase, kill, add_time, test_theme)] #[commands(start, say, end, broadcast, next_phase, kill, add_time, test_theme)]
@ -34,7 +32,7 @@ async fn start(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult {
let mut data = ctx.data.write().await; let mut data = ctx.data.write().await;
let global_data = data.get_mut::<GlobalData>().unwrap(); let global_data = data.get_mut::<GlobalData>().unwrap();
let guild = msg.guild(&ctx.cache).await.unwrap(); let guild = msg.guild(&ctx.cache).unwrap();
let mut global_data = global_data.lock().await; let mut global_data = global_data.lock().await;
let game_name = args.single::<String>()?; let game_name = args.single::<String>()?;
@ -42,18 +40,18 @@ async fn start(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult {
global_data.start_game(&game_name, Phase::Night, duration.into())?; global_data.start_game(&game_name, Phase::Night, duration.into())?;
let players: Result<Vec<&Member>> = args let players: error::Result<Vec<&Member>> = args
.iter::<String>() .iter::<String>()
.flatten() .flatten()
.map(|discord_id| { .map(|discord_id| {
let discord_id = match discord_id.parse::<u64>() { let discord_id = match discord_id.parse::<UserId>() {
Ok(discord_id) => discord_id, Ok(discord_id) => discord_id,
Err(_) => { Err(_) => {
return Err(WoxlfError::DiscordIdParseError(discord_id)); return Err(WoxlfError::DiscordIdParseError(discord_id));
} }
}; };
if let Some(discord_user) = guild.members.get(&UserId::from(discord_id)) { if let Some(discord_user) = guild.members.get(&discord_id) {
Ok(discord_user) Ok(discord_user)
} else { } else {
Err(WoxlfError::DiscordIdParseError(discord_id.to_string())) Err(WoxlfError::DiscordIdParseError(discord_id.to_string()))
@ -85,14 +83,19 @@ async fn start(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult {
first_names.shuffle(&mut thread_rng()); first_names.shuffle(&mut thread_rng());
last_names.shuffle(&mut thread_rng()); last_names.shuffle(&mut thread_rng());
let mut profile_pics = global_data.get_profile_pic_album().await?; let mut profile_pics = global_data.game_cfg()?.profile_album.clone();
profile_pics.shuffle(&mut thread_rng()); profile_pics.shuffle(&mut thread_rng());
let mut roles = global_data.game_cfg()?.roles.clone();
roles.shuffle(&mut thread_rng());
for player in players { for player in players {
let first_name = first_names.pop(); let first_name = first_names.pop();
let last_name = last_names.pop(); let last_name = last_names.pop();
let profile_pic_url = profile_pics.pop(); let profile_pic_url = profile_pics.pop();
let role = roles.pop();
add_user_to_game( add_user_to_game(
ctx, ctx,
&guild, &guild,
@ -101,6 +104,7 @@ async fn start(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult {
first_name, first_name,
last_name, last_name,
profile_pic_url, profile_pic_url,
role,
) )
.await?; .await?;
} }
@ -136,7 +140,7 @@ async fn start(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult {
async fn end(ctx: &Context, msg: &Message, mut _args: Args) -> CommandResult { async fn end(ctx: &Context, msg: &Message, mut _args: Args) -> CommandResult {
let mut data = ctx.data.write().await; let mut data = ctx.data.write().await;
let global_data = data.get_mut::<GlobalData>().unwrap(); let global_data = data.get_mut::<GlobalData>().unwrap();
let guild = msg.guild(&ctx.cache).await.unwrap(); let guild = msg.guild(&ctx.cache).unwrap();
let mut global_data = global_data.lock().await; let mut global_data = global_data.lock().await;
@ -159,23 +163,20 @@ async fn end(ctx: &Context, msg: &Message, mut _args: Args) -> CommandResult {
#[command] #[command]
#[only_in(guilds)] #[only_in(guilds)]
#[allowed_roles("wolfx host")] #[allowed_roles("wolfx host")]
async fn say(ctx: &Context, msg: &Message, args: Args) -> CommandResult { async fn say(ctx: &Context, _msg: &Message, args: Args) -> CommandResult {
let mut data = ctx.data.write().await; let data = ctx.data.read().await;
let global_data = data.get_mut::<GlobalData>().unwrap(); let global_data = data.get::<GlobalData>().unwrap();
let guild = msg.guild(&ctx.cache).await.unwrap();
let mut global_data = global_data.lock().await; let mut global_data = global_data.lock().await;
send_msg_to_player_channels( let msg = WoxlfMessage::default()
ctx, .source(MessageSource::Host)
&guild, .dest(MessageDest::Broadcast)
&mut global_data, .median(Median::Webhook)
MessageSource::Host, .content(args.rest())
args.rest(), .clone();
None,
false, dispatch_message(ctx, &mut global_data, msg).await?;
)
.await?;
Ok(()) Ok(())
} }
@ -183,27 +184,26 @@ async fn say(ctx: &Context, msg: &Message, args: Args) -> CommandResult {
#[command] #[command]
#[only_in(guilds)] #[only_in(guilds)]
#[allowed_roles("wolfx host")] #[allowed_roles("wolfx host")]
async fn broadcast(ctx: &Context, msg: &Message, args: Args) -> CommandResult { async fn broadcast(ctx: &Context, _msg: &Message, args: Args) -> CommandResult {
let mut data = ctx.data.write().await; let data = ctx.data.read().await;
let global_data = data.get_mut::<GlobalData>().unwrap(); let global_data = data.get::<GlobalData>().unwrap();
let guild = msg.guild(&ctx.cache).await.unwrap();
let mut global_data = global_data.lock().await; let mut global_data = global_data.lock().await;
let msg = global_data let broadcast = global_data
.templates()? .templates()?
.build_announcement(&global_data, args.rest())?; .build_announcement(&global_data, args.rest())?;
send_msg_to_player_channels( let woxlf_msg = WoxlfMessage::default()
ctx, .source(MessageSource::Automated)
&guild, .dest(MessageDest::Broadcast)
&mut global_data, .content(&broadcast)
MessageSource::Automated, .median(Median::Webhook)
&msg, .ping()
None, .pin()
true, .clone();
)
.await?; dispatch_message(ctx, &mut global_data, woxlf_msg).await?;
Ok(()) Ok(())
} }
@ -212,9 +212,9 @@ async fn broadcast(ctx: &Context, msg: &Message, args: Args) -> CommandResult {
#[only_in(guilds)] #[only_in(guilds)]
#[allowed_roles("wolfx host")] #[allowed_roles("wolfx host")]
async fn next_phase(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult { async fn next_phase(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult {
let mut data = ctx.data.write().await; let data = ctx.data.read().await;
let global_data = data.get_mut::<GlobalData>().unwrap(); let global_data = data.get::<GlobalData>().unwrap();
let guild = msg.guild(&ctx.cache).await.unwrap(); let guild = msg.guild(&ctx.cache).unwrap();
let mut global_data = global_data.lock().await; let mut global_data = global_data.lock().await;
@ -232,16 +232,16 @@ async fn next_phase(ctx: &Context, msg: &Message, mut args: Args) -> CommandResu
.templates()? .templates()?
.build_announcement(&global_data, &broadcast)?; .build_announcement(&global_data, &broadcast)?;
send_msg_to_player_channels( let woxlf_msg = WoxlfMessage::default()
ctx, .source(MessageSource::Automated)
&guild, .dest(MessageDest::Broadcast)
&mut global_data, .median(Median::Webhook)
MessageSource::Automated, .content(&broadcast)
&broadcast, .pin()
None, .ping()
true, .clone();
)
.await?; dispatch_message(ctx, &mut global_data, woxlf_msg).await?;
if global_data.game_state_mut()?.current_phase == Phase::Day { if global_data.game_state_mut()?.current_phase == Phase::Day {
let vote_channel = guild let vote_channel = guild
@ -251,6 +251,7 @@ async fn next_phase(ctx: &Context, msg: &Message, mut args: Args) -> CommandResu
)) ))
.unwrap(); .unwrap();
vote_channel vote_channel
.id()
.send_message(&ctx.http, |m| { .send_message(&ctx.http, |m| {
m.content(format!( m.content(format!(
"**{} {} Votes:**", "**{} {} Votes:**",
@ -302,55 +303,6 @@ async fn kill(ctx: &Context, msg: &Message, args: Args) -> CommandResult {
Ok(()) Ok(())
} }
#[command]
#[only_in(guilds)]
#[allowed_roles("wolfx host")]
async fn add_time(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult {
let mut data = ctx.data.write().await;
let global_data = data.get_mut::<GlobalData>().unwrap();
let guild = msg.guild(&ctx.cache).await.unwrap();
let mut global_data = global_data.lock().await;
let duration = parse_duration_arg(&mut args).await?;
global_data
.game_state_mut()?
.add_time_to_phase(duration.into());
let broadcast = MessageBuilder::new()
.push(
global_data
.templates()?
.build_phase_extend_message(&global_data)?,
)
.push_line("")
.push(global_data.templates()?.build_satus_message(&global_data)?)
.build();
let broadcast = global_data
.templates()?
.build_announcement(&global_data, &broadcast)?;
send_msg_to_player_channels(
ctx,
&guild,
&mut global_data,
MessageSource::Automated,
&broadcast,
None,
true,
)
.await?;
msg.reply(&ctx.http, "Phase has been updated")
.await
.unwrap();
global_data.save_game_state().unwrap();
Ok(())
}
#[command] #[command]
#[only_in(guilds)] #[only_in(guilds)]
#[allowed_roles("wolfx host")] #[allowed_roles("wolfx host")]
@ -375,6 +327,7 @@ async fn test_theme(ctx: &Context, msg: &Message, args: Args) -> CommandResult {
profile_pic_url: "".to_string(), profile_pic_url: "".to_string(),
channel_webhook_id: 0, channel_webhook_id: 0,
alive: true, alive: true,
role: Role::Villager,
}; };
let player_1_discord = DiscordUser { let player_1_discord = DiscordUser {
@ -390,6 +343,7 @@ async fn test_theme(ctx: &Context, msg: &Message, args: Args) -> CommandResult {
profile_pic_url: "".to_string(), profile_pic_url: "".to_string(),
channel_webhook_id: 0, channel_webhook_id: 0,
alive: false, alive: false,
role: Role::Villager,
}; };
let mut players = vec![test_player_1.clone(), test_player_2.clone()]; let mut players = vec![test_player_1.clone(), test_player_2.clone()];
@ -491,195 +445,50 @@ async fn test_theme(ctx: &Context, msg: &Message, args: Args) -> CommandResult {
Ok(()) Ok(())
} }
#[group]
#[commands(vote, status, players)]
struct Player;
#[command] #[command]
#[only_in(guilds)] #[only_in(guilds)]
#[description = "vote another subject for termination. $vote <code_name>"] #[allowed_roles("wolfx host")]
async fn vote(ctx: &Context, msg: &Message, args: Args) -> CommandResult { async fn add_time(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult {
let mut data = ctx.data.write().await; let data = ctx.data.read().await;
let global_data = data.get_mut::<GlobalData>().unwrap(); let global_data = data.get::<GlobalData>().unwrap();
let guild = msg.guild(&ctx.cache).await.unwrap();
let mut global_data = global_data.lock().await; let mut global_data = global_data.lock().await;
if global_data.game_state_mut()?.current_phase != Phase::Day { let duration = parse_duration_arg(&mut args).await?;
msg.reply(
&ctx.http, global_data
format!( .game_state_mut()?
"You can only vote during the {} phase.", .add_time_to_phase(duration.into());
global_data.game_cfg()?.vote_phase_name
), let broadcast = MessageBuilder::new()
.push(
global_data
.templates()?
.build_phase_extend_message(&global_data)?,
) )
.push_line("")
.push(global_data.templates()?.build_satus_message(&global_data)?)
.build();
let broadcast = global_data
.templates()?
.build_announcement(&global_data, &broadcast)?;
let woxlf_msg = WoxlfMessage::default()
.source(MessageSource::Automated)
.dest(MessageDest::Broadcast)
.median(Median::Webhook)
.content(&broadcast)
.ping()
.pin()
.clone();
dispatch_message(ctx, &mut global_data, woxlf_msg).await?;
msg.reply(&ctx.http, "Phase has been updated")
.await .await
.unwrap(); .unwrap();
return Ok(());
}
if global_data
.game_state_mut()?
.get_player_from_channel(msg.channel_id.0)
.is_some()
{
let target_player = global_data
.game_state_mut()?
.get_player_by_codename(args.rest());
if let Some(target_player) = target_player {
let vote_channel = guild
.channels
.get(&ChannelId::from(
global_data.cfg.discord_config.vote_channel,
))
.unwrap();
let player_data = global_data
.game_state_mut()?
.get_player_from_channel_mut(msg.channel_id.0)
.unwrap();
player_data.cast_vote(target_player.discord_id);
// borrow as immutable
let player_data = global_data
.game_state()?
.get_player_from_channel(msg.channel_id.0)
.unwrap();
let vote_msg = global_data.templates()?.build_vote_message(
&global_data,
player_data,
&target_player,
)?;
vote_channel
.send_message(&ctx.http, |m| m.content(vote_msg))
.await?;
} else {
msg.reply(&ctx.http, "Target not found!").await.unwrap();
}
} else {
msg.reply(
&ctx.http,
"This command needs to be run in a game channel, goober",
)
.await?;
}
global_data.save_game_state().unwrap(); global_data.save_game_state().unwrap();
Ok(()) Ok(())
} }
#[command]
#[only_in(guilds)]
#[description = "Get the game status."]
async fn status(ctx: &Context, msg: &Message, _args: Args) -> CommandResult {
let mut data = ctx.data.write().await;
let global_data = data.get_mut::<GlobalData>().unwrap();
let mut global_data = global_data.lock().await;
let mut msg_builder = MessageBuilder::new();
msg_builder.push(
global_data
.templates()?
.build_satus_message(&global_data)
.unwrap(),
);
if global_data.game_state_mut()?.current_phase == Phase::Day {
msg_builder.push_line(
global_data
.templates()?
.build_vote_tally(&global_data)
.unwrap(),
);
}
msg.reply(&ctx.http, msg_builder.build()).await.unwrap();
Ok(())
}
#[command]
#[only_in(guilds)]
#[description = "Get the other players in the game."]
async fn players(ctx: &Context, msg: &Message, _args: Args) -> CommandResult {
let data = ctx.data.read().await;
let global_data = data.get::<GlobalData>().unwrap();
let global_data = global_data.lock().await;
let mut msg_builder = MessageBuilder::new();
msg_builder.push_line(&global_data.game_cfg()?.player_group_name);
for player in &global_data.game_state()?.player_data {
let alive_status = if !player.alive { " (Dead) " } else { "" };
msg_builder
.push("* ")
.push(&player.codename)
.push(alive_status);
if msg.channel_id.0 == global_data.cfg.discord_config.host_channel {
let guild = msg.guild(&ctx.cache).await.unwrap();
let member = guild.members.get(&UserId::from(player.discord_id)).unwrap();
msg_builder.push_line(format!(" ({})", member.display_name()));
} else {
msg_builder.push_line("");
}
}
msg.reply(&ctx.http, msg_builder.build()).await.unwrap();
Ok(())
}
#[help]
#[individual_command_tip = "If you want more information about a specific command, just pass the command as argument."]
#[command_not_found_text = "Could not find: `{}`."]
#[max_levenshtein_distance(3)]
#[indention_prefix = "+"]
#[lacking_role = "Strike"]
#[wrong_channel = "Strike"]
async fn help(
context: &Context,
msg: &Message,
args: Args,
help_options: &'static HelpOptions,
groups: &[&'static CommandGroup],
owners: HashSet<UserId>,
) -> CommandResult {
let _ = help_commands::with_embeds(context, msg, args, help_options, groups, owners).await;
Ok(())
}
#[hook]
async fn handle_errors(
ctx: &Context,
msg: &Message,
command_name: &str,
command_result: CommandResult,
) {
match command_result {
Ok(()) => println!("Successfully processed command '{}'", command_name),
Err(err) => {
let reply_msg = format!("Command '{}' returned an error. {}", command_name, err,);
println!("{}", reply_msg);
msg.reply(&ctx.http, reply_msg).await.unwrap();
}
};
}
pub fn command_framework() -> StandardFramework {
StandardFramework::new()
.configure(|c| c.prefix('$'))
.group(&HOST_GROUP)
.group(&PLAYER_GROUP)
.help(&HELP)
.after(handle_errors)
}

View File

@ -1,3 +1,59 @@
pub mod commands; use serenity::client::Context;
use serenity::framework::standard::macros::{help, hook};
use serenity::framework::standard::{
help_commands, Args, CommandGroup, CommandResult, HelpOptions,
};
use serenity::framework::StandardFramework;
use serenity::model::channel::Message;
use serenity::model::id::UserId;
use std::collections::HashSet;
pub mod event_handler; pub mod event_handler;
pub mod helper; pub mod helper;
mod host;
mod players;
#[help]
#[individual_command_tip = "If you want more information about a specific command, just pass the command as argument."]
#[command_not_found_text = "Could not find: `{}`."]
#[max_levenshtein_distance(3)]
#[indention_prefix = "+"]
#[lacking_role = "Strike"]
#[wrong_channel = "Strike"]
async fn help(
context: &Context,
msg: &Message,
args: Args,
help_options: &'static HelpOptions,
groups: &[&'static CommandGroup],
owners: HashSet<UserId>,
) -> CommandResult {
let _ = help_commands::with_embeds(context, msg, args, help_options, groups, owners).await;
Ok(())
}
#[hook]
async fn handle_errors(
ctx: &Context,
msg: &Message,
command_name: &str,
command_result: CommandResult,
) {
match command_result {
Ok(()) => println!("Successfully processed command '{}'", command_name),
Err(err) => {
let reply_msg = format!("Command '{}' returned an error. {}", command_name, err,);
println!("{}", reply_msg);
msg.reply(&ctx.http, reply_msg).await.unwrap();
}
};
}
pub fn command_framework() -> StandardFramework {
StandardFramework::new()
.configure(|c| c.prefix('$'))
.group(&host::HOST_GROUP)
.group(&players::PLAYER_GROUP)
.help(&HELP)
.after(handle_errors)
}

224
src/discord/players.rs Normal file
View File

@ -0,0 +1,224 @@
use crate::game::global_data::GlobalData;
use crate::game::message_router::{
dispatch_message, Median, MessageDest, MessageSource, WoxlfMessage,
};
use crate::game::Phase;
use serenity::client::Context;
use serenity::framework::standard::macros::{command, group};
use serenity::framework::standard::{Args, CommandResult};
use serenity::model::channel::Message;
use serenity::model::id::{ChannelId, UserId};
use serenity::utils::MessageBuilder;
#[group]
#[commands(vote, status, players, whisper)]
struct Player;
#[command]
#[only_in(guilds)]
#[description = "vote another subject for termination. $vote <code_name>"]
async fn vote(ctx: &Context, msg: &Message, args: Args) -> CommandResult {
let mut data = ctx.data.write().await;
let global_data = data.get_mut::<GlobalData>().unwrap();
let guild = msg.guild(&ctx.cache).unwrap();
let mut global_data = global_data.lock().await;
if global_data.game_state_mut()?.current_phase != Phase::Day {
msg.reply(
&ctx.http,
format!(
"You can only vote during the {} phase.",
global_data.game_cfg()?.vote_phase_name
),
)
.await
.unwrap();
return Ok(());
}
if global_data
.game_state_mut()?
.get_player_from_channel(msg.channel_id.0)
.is_some()
{
let target_player = global_data
.game_state_mut()?
.get_player_by_codename(args.rest());
if let Some(target_player) = target_player {
let vote_channel = guild
.channels
.get(&ChannelId::from(
global_data.cfg.discord_config.vote_channel,
))
.unwrap();
let player_data = global_data
.game_state_mut()?
.get_player_from_channel_mut(msg.channel_id.0)
.unwrap();
player_data.cast_vote(target_player.discord_id);
// borrow as immutable
let player_data = global_data
.game_state()?
.get_player_from_channel(msg.channel_id.0)
.unwrap();
let vote_msg = global_data.templates()?.build_vote_message(
&global_data,
player_data,
&target_player,
)?;
vote_channel
.id()
.send_message(&ctx.http, |m| m.content(vote_msg))
.await?;
} else {
msg.reply(&ctx.http, "Target not found!").await.unwrap();
}
} else {
msg.reply(
&ctx.http,
"This command needs to be run in a game channel, goober",
)
.await?;
}
global_data.save_game_state().unwrap();
Ok(())
}
#[command]
#[only_in(guilds)]
#[description = "Get the game status."]
async fn status(ctx: &Context, msg: &Message, _args: Args) -> CommandResult {
let mut data = ctx.data.write().await;
let global_data = data.get_mut::<GlobalData>().unwrap();
let mut global_data = global_data.lock().await;
let mut msg_builder = MessageBuilder::new();
msg_builder.push(
global_data
.templates()?
.build_satus_message(&global_data)
.unwrap(),
);
if global_data.game_state_mut()?.current_phase == Phase::Day {
msg_builder.push_line(
global_data
.templates()?
.build_vote_tally(&global_data)
.unwrap(),
);
}
msg.reply(&ctx.http, msg_builder.build()).await.unwrap();
Ok(())
}
#[command]
#[only_in(guilds)]
#[description = "Get the other players in the game."]
async fn players(ctx: &Context, msg: &Message, _args: Args) -> CommandResult {
let data = ctx.data.read().await;
let global_data = data.get::<GlobalData>().unwrap();
let global_data = global_data.lock().await;
let mut msg_builder = MessageBuilder::new();
msg_builder.push_line(&global_data.game_cfg()?.player_group_name);
for player in &global_data.game_state()?.player_data {
let alive_status = if !player.alive { " (Dead) " } else { "" };
msg_builder
.push("* ")
.push(&player.codename)
.push(alive_status);
if msg.channel_id.0 == global_data.cfg.discord_config.host_channel {
let guild = msg.guild(&ctx.cache).unwrap();
let member = guild.members.get(&UserId::from(player.discord_id)).unwrap();
msg_builder.push_line(format!(
" ({}) [{} {}]",
member.display_name(),
player.role,
player.role.seer_color()
));
} else {
msg_builder.push_line("");
}
}
msg.reply(&ctx.http, msg_builder.build()).await.unwrap();
Ok(())
}
#[command]
#[aliases("pm", "w")]
#[description = "Send a private message to another player."]
async fn whisper(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult {
let data = ctx.data.read().await;
let global_data = data.get::<GlobalData>().unwrap();
let mut global_data = global_data.lock().await;
if !global_data.game_cfg()?.whispers_allowed {
msg.reply(&ctx.http, "No private messages are allowed in this game")
.await?;
return Ok(());
}
if args.len() < 2 {
msg.reply(&ctx.http, "Need a recipient and message!")
.await?;
} else {
let target = args.single::<String>()?;
let pm = args.rest();
let src_player = match global_data
.game_state()?
.get_player_from_discord_id(msg.author.id.0)
{
None => {
msg.reply(&ctx.http, "You are not in the game!").await?;
return Ok(());
}
Some(player) => player,
};
if let Some(target_player) = global_data.game_state()?.get_player_by_codename(&target) {
if src_player.discord_id == target_player.discord_id {
msg.reply(&ctx.http, "You can't send messages to yourself!")
.await?;
return Ok(());
}
let woxlf_msg = WoxlfMessage::default()
.source(MessageSource::Player(Box::new(src_player.clone())))
.dest(MessageDest::Player(Box::new(target_player.clone())))
.median(Median::DirectMessage)
.content(pm)
.clone();
dispatch_message(ctx, &mut global_data, woxlf_msg).await?;
} else {
msg.reply(
&ctx.http,
format!("Could not find a player with codename {}.", target),
)
.await?;
}
}
Ok(())
}

View File

@ -1,4 +1,3 @@
use crate::imgur::ImgurError;
use serenity::prelude::SerenityError; use serenity::prelude::SerenityError;
use std::fmt::{Display, Formatter}; use std::fmt::{Display, Formatter};
use tera::Error; use tera::Error;
@ -15,10 +14,13 @@ pub enum WoxlfError {
DiscordIdParseError(String), DiscordIdParseError(String),
GameNotInProgress, GameNotInProgress,
HostWebhookError, HostWebhookError,
ImgurError(ImgurError),
RanOutOfCodenames, RanOutOfCodenames,
TemplateError(tera::Error), TemplateError(tera::Error),
RanOutOfProfilePics, RanOutOfProfilePics,
UnsupportedMsgMedium,
RanOutOfRoles,
ConfigNotfound,
WebhookMsgError,
} }
impl std::error::Error for WoxlfError {} impl std::error::Error for WoxlfError {}
@ -34,13 +36,18 @@ impl Display for WoxlfError {
WoxlfError::DiscordIdParseError(e) => format!("Unable to parse player id {}", e), WoxlfError::DiscordIdParseError(e) => format!("Unable to parse player id {}", e),
WoxlfError::GameNotInProgress => "A game is not currently in progress".to_string(), WoxlfError::GameNotInProgress => "A game is not currently in progress".to_string(),
WoxlfError::HostWebhookError => "Unable to communicate to the host webhook".to_string(), WoxlfError::HostWebhookError => "Unable to communicate to the host webhook".to_string(),
WoxlfError::ImgurError(err) => format!("Imgur module error: {}", err),
WoxlfError::RanOutOfCodenames => { WoxlfError::RanOutOfCodenames => {
"Ran out of codename combinations, add more first/last names to the config" "Ran out of codename combinations, add more first/last names to the config"
.to_string() .to_string()
} }
WoxlfError::TemplateError(e) => format!("Template error: {}", e), WoxlfError::TemplateError(e) => format!("Template error: {}", e),
WoxlfError::RanOutOfProfilePics => "Ran out of user profile pics".to_string(), WoxlfError::RanOutOfProfilePics => "Ran out of user profile pics".to_string(),
WoxlfError::UnsupportedMsgMedium => {
"Tried to send a message over an unsupported medium".to_string()
}
WoxlfError::RanOutOfRoles => "Ran out of user roles".to_string(),
WoxlfError::ConfigNotfound => "Config not found".to_string(),
WoxlfError::WebhookMsgError => "Webhook returned is None".to_string(),
}; };
write!(f, "Woxlf Error: {}", msg) write!(f, "Woxlf Error: {}", msg)
@ -71,12 +78,6 @@ impl From<toml::ser::Error> for WoxlfError {
} }
} }
impl From<ImgurError> for WoxlfError {
fn from(err: ImgurError) -> Self {
Self::ImgurError(err)
}
}
impl From<tera::Error> for WoxlfError { impl From<tera::Error> for WoxlfError {
fn from(err: Error) -> Self { fn from(err: Error) -> Self {
Self::TemplateError(err) Self::TemplateError(err)

View File

@ -1,5 +1,6 @@
use std::sync::Arc; use std::sync::Arc;
use chrono::Duration;
use serenity::prelude::{Mutex, TypeMapKey}; use serenity::prelude::{Mutex, TypeMapKey};
use std::fs::File; use std::fs::File;
use std::io::{Read, Write}; use std::io::{Read, Write};
@ -8,9 +9,7 @@ use crate::config::{BotConfig, GameConfig};
use crate::error::{Result, WoxlfError}; use crate::error::{Result, WoxlfError};
use crate::game::game_state::GameState; use crate::game::game_state::GameState;
use crate::game::Phase; use crate::game::Phase;
use crate::imgur::{get_album_images, Image};
use crate::messages::MessageTemplates; use crate::messages::MessageTemplates;
use chrono::Duration;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct GlobalData { pub struct GlobalData {
@ -36,7 +35,10 @@ impl GlobalData {
starting_phase: Phase, starting_phase: Phase,
starting_phase_duration: Duration, starting_phase_duration: Duration,
) -> Result<()> { ) -> Result<()> {
let game_config = self.cfg.get_game_config(game_name).unwrap(); let game_config = self
.cfg
.get_game_config(game_name)
.ok_or(WoxlfError::ConfigNotfound)?;
self.templates = Some(MessageTemplates::try_from(game_config.messages.clone())?); self.templates = Some(MessageTemplates::try_from(game_config.messages.clone())?);
self.game_cfg = Some(game_config); self.game_cfg = Some(game_config);
@ -126,14 +128,6 @@ impl GlobalData {
Ok(()) Ok(())
} }
pub async fn get_profile_pic_album(&self) -> Result<Vec<Image>> {
Ok(get_album_images(
&self.cfg.imgur_client_id,
&self.game_cfg()?.profile_album_hash,
)
.await?)
}
pub fn get_phase_name(&self) -> Result<String> { pub fn get_phase_name(&self) -> Result<String> {
let game_cfg = self.game_cfg()?; let game_cfg = self.game_cfg()?;
let state = self.game_state()?; let state = self.game_state()?;

View File

@ -0,0 +1,33 @@
use crate::game::listener::{EventStatus, Listener, ListenerContext, Priority};
use crate::game::message_router::{send_to_host_channel, WoxlfMessage};
use serenity::async_trait;
use serenity::model::prelude::GuildId;
use std::fmt::Debug;
#[derive(Debug)]
pub struct HostSnooper {}
#[async_trait]
impl Listener for HostSnooper {
fn name(&self) -> String {
"Host Snooper".to_string()
}
fn get_priority(&self) -> Priority {
Priority::Logging
}
async fn on_chat(
&mut self,
ctx: &mut ListenerContext,
msg: &WoxlfMessage,
) -> crate::error::Result<EventStatus> {
let guild_id = ctx.data.cfg.discord_config.guild_id;
let guild = GuildId::from(guild_id)
.to_guild_cached(&ctx.ctx.cache)
.unwrap();
send_to_host_channel(&ctx.ctx.http, &guild, ctx.data, msg.clone()).await?;
Ok(EventStatus::Okay)
}
}

106
src/game/listener/mod.rs Normal file
View File

@ -0,0 +1,106 @@
pub mod host_snooper;
use crate::error::Result;
use crate::game::global_data::GlobalData;
use crate::game::listener::host_snooper::HostSnooper;
use crate::game::message_router::WoxlfMessage;
use serenity::async_trait;
use serenity::client::Context;
use serenity::prelude::TypeMapKey;
use std::fmt::Debug;
use std::sync::Arc;
use tokio::sync::Mutex;
#[derive(Debug)]
pub struct Listeners {
listeners: Vec<Box<dyn Listener>>,
}
impl Listeners {
pub async fn process_event(
&mut self,
ctx: &Context,
data: &mut GlobalData,
event: WoxlfEvent<'_>,
) -> Result<EventStatus> {
let mut listener_ctx = ListenerContext { data, ctx };
for listener in &mut self.listeners {
let status = match &event {
WoxlfEvent::Chat(msg) => listener.on_chat(&mut listener_ctx, msg).await?,
};
if status == EventStatus::Canceled {
return Ok(status);
}
}
Ok(EventStatus::Okay)
}
pub fn add_listener(&mut self, listener: Box<dyn Listener>) {
println!("Adding {} listener", listener.name());
self.listeners.push(listener);
self.listeners.sort_by_key(|l1| l1.get_priority());
}
}
impl Default for Listeners {
fn default() -> Self {
let mut listeners = Self { listeners: vec![] };
// Add default listeners here
listeners.add_listener(Box::new(HostSnooper {}));
listeners
}
}
impl TypeMapKey for Listeners {
type Value = Arc<Mutex<Listeners>>;
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum EventStatus {
Okay,
Canceled,
}
#[derive(Debug, Clone)]
pub enum WoxlfEvent<'a> {
Chat(WoxlfMessage<'a>),
}
#[derive(Debug, Clone, PartialEq, PartialOrd, Ord, Eq)]
#[allow(dead_code)]
pub enum Priority {
Highest,
High,
Medium,
Low,
Lowest,
Logging,
}
pub struct ListenerContext<'a> {
pub data: &'a mut GlobalData,
pub ctx: &'a Context,
}
#[async_trait]
pub trait Listener: Debug + Send + Sync {
fn name(&self) -> String;
fn get_priority(&self) -> Priority {
Priority::Medium
}
async fn on_chat(
&mut self,
_ctx: &mut ListenerContext,
_msg: &WoxlfMessage,
) -> Result<EventStatus> {
Ok(EventStatus::Okay)
}
}

348
src/game/message_router.rs Normal file
View File

@ -0,0 +1,348 @@
use crate::error;
use crate::error::WoxlfError;
use crate::game::global_data::GlobalData;
use crate::game::listener::{EventStatus, Listeners, WoxlfEvent};
use crate::game::player_data::PlayerData;
use bitflags::bitflags;
use serenity::client::Context;
use serenity::http::{CacheHttp, Http};
use serenity::model::guild::Guild;
use serenity::model::id::{ChannelId, UserId};
use serenity::model::prelude::{AttachmentType, Message, WebhookId};
use serenity::prelude::Mentionable;
use serenity::utils::MessageBuilder;
#[derive(Debug, Clone)]
pub enum MessageSource {
Player(Box<PlayerData>),
Host,
Automated,
}
impl Default for MessageSource {
fn default() -> Self {
Self::Automated
}
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub enum MessageDest {
Player(Box<PlayerData>),
Host,
Broadcast,
}
impl Default for MessageDest {
fn default() -> Self {
Self::Broadcast
}
}
#[derive(Debug, Clone, Eq, PartialEq)]
#[allow(dead_code)]
pub enum Median {
DirectMessage,
Webhook,
StandardMessage,
}
impl Default for Median {
fn default() -> Self {
Self::Webhook
}
}
bitflags! {
#[derive(Default)]
pub struct MsgFlags: u32 {
const PIN_MSG = 0b00000001;
const PING = 0b00000010;
}
}
#[derive(Debug, Clone, Default)]
pub struct WoxlfMessage<'a> {
pub source: MessageSource,
pub dest: MessageDest,
pub median: Median,
pub content: String,
pub attachments: Option<Vec<AttachmentType<'a>>>,
pub flags: MsgFlags,
}
#[allow(dead_code)]
impl<'a> WoxlfMessage<'a> {
pub fn get_profile_pic(&self, global_data: &GlobalData) -> Result<String, WoxlfError> {
Ok(match &self.source {
MessageSource::Player(p) => p.profile_pic_url.clone(),
MessageSource::Host | MessageSource::Automated => {
global_data.game_cfg()?.bot_profile_pic.clone()
}
})
}
pub fn get_message_username(&self, global_data: &GlobalData) -> Result<String, WoxlfError> {
Ok(match &self.source {
MessageSource::Player(p) => p.codename.clone(),
MessageSource::Host => global_data.game_cfg()?.bot_name.clone(),
MessageSource::Automated => "Woxlf Game Message".to_string(),
})
}
pub fn source(mut self, source: MessageSource) -> Self {
self.source = source;
self
}
pub fn dest(mut self, dest: MessageDest) -> Self {
self.dest = dest;
self
}
pub fn median(mut self, median: Median) -> Self {
self.median = median;
self
}
pub fn content(mut self, content: &str) -> Self {
self.content = content.to_string();
self
}
pub fn attachments(mut self, attachments: Vec<AttachmentType<'a>>) -> Self {
self.attachments = Some(attachments);
self
}
pub fn flags(mut self, flags: MsgFlags) -> Self {
self.flags = flags;
self
}
pub fn pin(mut self) -> Self {
self.flags |= MsgFlags::PIN_MSG;
self
}
pub fn ping(mut self) -> Self {
self.flags |= MsgFlags::PING;
self
}
}
fn filter_source_channel(player_data: &&PlayerData, msg_source: &MessageSource) -> bool {
if let MessageSource::Player(source_player) = &msg_source {
if source_player.channel == player_data.channel {
return false;
}
}
true
}
pub async fn send_webhook_msg(
http: &Http,
webhook_id: WebhookId,
username: &str,
profile_pic_url: Option<String>,
msg: &str,
attachments: &Option<Vec<AttachmentType<'_>>>,
) -> error::Result<Message> {
let webhook = http.get_webhook(webhook_id.0).await?;
let sent_msg = webhook
.execute(http, true, move |w| {
w.content(msg).username(username);
if let Some(profile_pic_url) = profile_pic_url {
w.avatar_url(profile_pic_url);
}
if let Some(attachments) = attachments {
w.add_files(attachments.clone());
}
w
})
.await?;
if let Some(sent_msg) = sent_msg {
Ok(sent_msg)
} else {
Err(WoxlfError::WebhookMsgError)
}
}
pub async fn send_private_message(
http: &Http,
dest: UserId,
msg_content: &str,
attachments: &Option<Vec<AttachmentType<'_>>>,
) -> error::Result<Message> {
let dest_user = dest.to_user(http).await?;
let msg = dest_user
.dm(http, |msg| {
msg.content(msg_content);
if let Some(attachments) = attachments {
msg.add_files(attachments.clone());
}
msg
})
.await?;
Ok(msg)
}
pub async fn send_to_host_channel(
http: &Http,
guild: &Guild,
global_data: &GlobalData,
msg: WoxlfMessage<'_>,
) -> error::Result<Message> {
let source = match &msg.source {
MessageSource::Player(player_data) => {
let name = guild
.members
.get(&UserId::from(player_data.discord_id))
.unwrap()
.display_name();
name.to_string()
}
MessageSource::Host => "Host".to_string(),
MessageSource::Automated => "Automated".to_string(),
};
let dest = match &msg.median {
Median::DirectMessage => {
if let MessageDest::Player(dest_player) = &msg.dest {
let name = guild
.members
.get(&UserId::from(dest_player.discord_id))
.unwrap()
.display_name();
format!("**[DM to {} ({})]** ", dest_player.codename, name)
} else {
"".to_string()
}
}
_ => "".to_string(),
};
let host_channel_username = format!("{} ({})", msg.get_message_username(global_data)?, source,);
let content = format!("{}{}", dest, msg.content);
send_webhook_msg(
http,
WebhookId::from(global_data.cfg.discord_config.host_webhook_id),
&host_channel_username,
Some(msg.get_profile_pic(global_data)?),
&content,
&msg.attachments,
)
.await
}
pub async fn send_message(
ctx: &Context,
global_data: &GlobalData,
msg: &WoxlfMessage<'_>,
dest_player: &PlayerData,
) -> Result<(), WoxlfError> {
let content = if msg.flags.contains(MsgFlags::PING) {
let dest_player_id = UserId::from(dest_player.discord_id);
MessageBuilder::new()
.push_line(dest_player_id.mention())
.push(msg.content.clone())
.build()
} else {
msg.content.clone()
};
let send_msg = match &msg.median {
Median::Webhook => {
send_webhook_msg(
&ctx.http,
WebhookId::from(dest_player.channel_webhook_id),
&msg.get_message_username(global_data)?,
Some(msg.get_profile_pic(global_data)?),
&content,
&msg.attachments,
)
.await?
}
Median::DirectMessage => {
let dm_msg = MessageBuilder::new()
.push_bold_line_safe(format!(
"{} has sent you a private message:",
&msg.get_message_username(global_data)?
))
.push(content)
.build();
send_private_message(
&ctx.http,
UserId(dest_player.discord_id),
&dm_msg,
&msg.attachments,
)
.await?
}
Median::StandardMessage => {
let channel = ChannelId::from(dest_player.channel);
channel.say(&ctx.http(), &content).await?
}
};
if msg.flags.contains(MsgFlags::PIN_MSG) {
send_msg.pin(&ctx.http).await?;
}
Ok(())
}
/// Send a message to the proper channels
/// Note safe to use in an event handler
pub async fn dispatch_message(
ctx: &Context,
global_data: &mut GlobalData,
msg: WoxlfMessage<'_>,
) -> error::Result<()> {
let data = ctx.data.read().await;
let listeners = data.get::<Listeners>().unwrap();
let mut listeners = listeners.lock().await;
if listeners
.process_event(ctx, global_data, WoxlfEvent::Chat(msg.clone()))
.await?
== EventStatus::Canceled
{
return Ok(());
};
let msg_tasks = global_data
.game_state()?
.player_data
.iter()
.filter(|player| filter_source_channel(player, &msg.source))
.filter(|player| match &msg.dest {
MessageDest::Player(dest_user) => dest_user.discord_id == player.discord_id,
MessageDest::Host => false,
MessageDest::Broadcast => true,
})
.map(|p| send_message(ctx, global_data, &msg, p));
let results: Result<(), WoxlfError> = futures::future::join_all(msg_tasks)
.await
.into_iter()
.collect();
results?;
Ok(())
}

View File

@ -1,10 +1,11 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::game::player_data::PlayerData;
pub mod game_state; pub mod game_state;
pub mod global_data; pub mod global_data;
pub mod listener;
pub mod message_router;
pub mod player_data; pub mod player_data;
pub mod role;
#[derive(Debug, Deserialize, Serialize, Clone, Eq, PartialEq, Copy)] #[derive(Debug, Deserialize, Serialize, Clone, Eq, PartialEq, Copy)]
pub enum Phase { pub enum Phase {
@ -17,10 +18,3 @@ impl Default for Phase {
Self::Night Self::Night
} }
} }
#[derive(Debug, Clone)]
pub enum MessageSource {
Player(Box<PlayerData>),
Host,
Automated,
}

View File

@ -1,3 +1,4 @@
use crate::game::role::Role;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize, Clone)] #[derive(Debug, Deserialize, Serialize, Clone)]
@ -9,6 +10,7 @@ pub struct PlayerData {
pub profile_pic_url: String, pub profile_pic_url: String,
pub channel_webhook_id: u64, pub channel_webhook_id: u64,
pub alive: bool, pub alive: bool,
pub role: Role,
} }
impl PlayerData { impl PlayerData {

164
src/game/role/mod.rs Normal file
View File

@ -0,0 +1,164 @@
use crate::game::listener::Listeners;
use crate::game::role::spy::SpyListener;
use serde::{Deserialize, Serialize};
use std::fmt::{Display, Formatter};
mod spy;
#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq)]
pub enum RoleColor {
Green,
Red,
Blue,
Purple,
}
impl Display for RoleColor {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
let s = match self {
RoleColor::Green => "Green",
RoleColor::Red => "Red",
RoleColor::Blue => "Blue",
RoleColor::Purple => "Purple",
};
write!(f, "{}", s)
}
}
#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq)]
pub enum Role {
// Human Roles
Villager,
Seer,
Guardian,
Jailer,
Coroner,
Vigilante,
Hunter,
Psychic,
Miller,
Herring,
Spy,
Jester,
Mason,
Shepard,
Dreamer,
ApprenticeSeer,
Necromancer,
Helsing,
Priest,
// Wolf Roles
MasterWolf,
WolfShaman,
Wolf,
WolfAgent,
WolfDreamer,
// Vampires,
TheCount,
ThinBlood,
Vampire,
// Custom Role
Custom(String, RoleColor),
}
impl Display for Role {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
let s = match self {
Role::Villager => "Villager",
Role::Seer => "Seer",
Role::Guardian => "Guardian",
Role::Jailer => "Jailer",
Role::Coroner => "Coroner",
Role::Vigilante => "Vigilante",
Role::Hunter => "Hunter",
Role::Psychic => "Psychic",
Role::Miller => "Miller",
Role::Herring => "Herring",
Role::Spy => "Spy",
Role::Jester => "Jester",
Role::Mason => "Mason",
Role::Shepard => "Shepard",
Role::MasterWolf => "Master Wolf",
Role::WolfShaman => "Wolf Shaman",
Role::Wolf => "Wolf",
Role::Custom(role_name, _) => role_name,
Role::Dreamer => "Dreamer",
Role::ApprenticeSeer => "Apprentice Seer",
Role::WolfAgent => "Wolf Agent",
Role::WolfDreamer => "Wolf Dreamer",
Role::Necromancer => "Necromancer",
Role::Helsing => "Helsing",
Role::TheCount => "The Count",
Role::ThinBlood => "Thin Blood",
Role::Vampire => "Vampire",
Role::Priest => "Priest",
};
write!(f, "{}", s)
}
}
impl Role {
/// Color as seen by host or seer
pub fn seer_color(&self) -> RoleColor {
match self {
Role::Villager => RoleColor::Green,
Role::Seer => RoleColor::Blue,
Role::Guardian => RoleColor::Blue,
Role::Jailer => RoleColor::Blue,
Role::Coroner => RoleColor::Blue,
Role::Vigilante => RoleColor::Blue,
Role::Hunter => RoleColor::Blue,
Role::Psychic => RoleColor::Blue,
Role::Miller => RoleColor::Red,
Role::Herring => RoleColor::Blue,
Role::Spy => RoleColor::Green,
Role::Jester => RoleColor::Purple,
Role::Mason => RoleColor::Green,
Role::Shepard => RoleColor::Green,
Role::MasterWolf => RoleColor::Green,
Role::WolfShaman => RoleColor::Red,
Role::Wolf => RoleColor::Red,
Role::Dreamer => RoleColor::Red,
Role::Custom(_, color) => color.clone(),
Role::ApprenticeSeer => RoleColor::Blue,
Role::WolfAgent => RoleColor::Red,
Role::WolfDreamer => RoleColor::Red,
Role::Necromancer => RoleColor::Blue,
Role::Helsing => RoleColor::Blue,
Role::TheCount => RoleColor::Purple,
Role::ThinBlood => RoleColor::Green,
Role::Vampire => RoleColor::Purple,
Role::Priest => RoleColor::Blue,
}
}
/// Color as seen by player
pub fn player_color(&self) -> RoleColor {
match self {
Role::Miller => RoleColor::Green,
Role::Herring => RoleColor::Green,
_ => self.seer_color(),
}
}
/// Role name as seen by the player
pub fn player_role_name(&self) -> String {
match self {
Role::Miller => Role::Villager.to_string(),
Role::Herring => Role::Villager.to_string(),
_ => self.to_string(),
}
}
pub fn register_role_listener(&self, listeners: &mut Listeners) {
match self {
Role::Spy => listeners.add_listener(Box::new(SpyListener { role: Role::Spy })),
Role::WolfAgent => listeners.add_listener(Box::new(SpyListener {
role: Role::WolfAgent,
})),
_ => {}
}
}
}

87
src/game/role/spy.rs Normal file
View File

@ -0,0 +1,87 @@
use crate::game::listener::{EventStatus, Listener, ListenerContext, Priority};
use crate::game::message_router::{
send_private_message, Median, MessageDest, MessageSource, WoxlfMessage,
};
use crate::game::role::Role;
use rand::{thread_rng, Rng};
use serenity::async_trait;
use serenity::model::prelude::UserId;
use serenity::utils::MessageBuilder;
#[derive(Debug)]
pub struct SpyListener {
pub role: Role,
}
#[async_trait]
impl Listener for SpyListener {
fn name(&self) -> String {
format!("{} DM Listener", self.role)
}
fn get_priority(&self) -> Priority {
Priority::Logging
}
async fn on_chat(
&mut self,
ctx: &mut ListenerContext,
msg: &WoxlfMessage,
) -> crate::error::Result<EventStatus> {
if msg.median != Median::DirectMessage {
return Ok(EventStatus::Okay);
}
println!("{} is processing {:?}", self.name(), msg);
let src_player = if let MessageSource::Player(p) = &msg.source {
p
} else {
return Ok(EventStatus::Okay);
};
let dest_player = if let MessageDest::Player(p) = &msg.dest {
p
} else {
return Ok(EventStatus::Okay);
};
let spy_player = ctx
.data
.game_state()?
.player_data
.iter()
.find(|p| p.alive && p.role == self.role);
if let Some(spy_player) = spy_player {
if spy_player.discord_id == dest_player.discord_id
|| spy_player.discord_id == src_player.discord_id
{
return Ok(EventStatus::Okay);
}
// 50% chance to intercept message
if thread_rng().gen_bool(0.50) {
println!("Sending a spy message...");
let msg_content = MessageBuilder::default()
.push_bold_line_safe(format!(
"{} Sent {} a private message:",
src_player.codename, dest_player.codename
))
.push(msg.content.clone())
.build();
send_private_message(
&ctx.ctx.http,
UserId::from(spy_player.discord_id),
&msg_content,
&None,
)
.await?;
}
}
return Ok(EventStatus::Okay);
}
}

View File

@ -1,73 +0,0 @@
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::fmt::{Display, Formatter};
#[derive(Debug)]
pub enum ImgurError {
ReqwestError(reqwest::Error),
ImgurRequestError(String),
}
impl From<reqwest::Error> for ImgurError {
fn from(e: reqwest::Error) -> Self {
Self::ReqwestError(e)
}
}
impl Display for ImgurError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
let msg = match self {
ImgurError::ReqwestError(err) => format!("Reqwest error: {}", err),
ImgurError::ImgurRequestError(msg) => format!("Imgur request error: {}", msg),
};
write!(f, "{}", msg)
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct AlbumData {
images: Option<Vec<Image>>,
error: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct AlbumResponse {
data: AlbumData,
success: bool,
status: i32,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Image {
pub id: String,
pub title: Option<String>,
pub description: Option<String>,
#[serde(rename = "type")]
pub img_type: String,
pub animated: bool,
pub width: i32,
pub height: i32,
pub size: i32,
pub link: String,
}
pub async fn get_album_images(client_id: &str, album_hash: &str) -> Result<Vec<Image>, ImgurError> {
let client = Client::new();
let res = client
.get(format!("https://api.imgur.com/3/album/{}", album_hash))
.header("Authorization", format!("Client-ID {}", client_id))
.send()
.await?;
let album_response: AlbumResponse = res.json().await?;
if album_response.success {
Ok(album_response.data.images.unwrap())
} else {
Err(ImgurError::ImgurRequestError(
album_response.data.error.unwrap(),
))
}
}

View File

@ -1,22 +1,22 @@
use std::sync::Arc; use std::sync::Arc;
use serenity::client::bridge::gateway::GatewayIntents;
use serenity::prelude::*; use serenity::prelude::*;
use structopt::StructOpt; use structopt::StructOpt;
use discord::commands::command_framework; use discord::command_framework;
use discord::event_handler::Handler; use discord::event_handler::Handler;
use game::global_data::GlobalData; use game::global_data::GlobalData;
use crate::config::{Args, BotConfig}; use crate::config::{Args, BotConfig};
use crate::game::listener::Listeners;
use crate::game::role::Role;
mod config; mod config;
mod discord; mod discord;
mod error; mod error;
mod game; mod game;
mod imgur;
mod messages;
mod messages;
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
let args: Args = Args::from_args(); let args: Args = Args::from_args();
@ -32,11 +32,15 @@ async fn main() {
.expect("Unable to open saved game state."); .expect("Unable to open saved game state.");
} }
let mut client = Client::builder(&bot_cfg.discord_config.token) let mut listeners = Listeners::default();
Role::Spy.register_role_listener(&mut listeners);
Role::WolfAgent.register_role_listener(&mut listeners);
let mut client = Client::builder(&bot_cfg.discord_config.token, GatewayIntents::all())
.event_handler(Handler {}) .event_handler(Handler {})
.framework(command_framework()) .framework(command_framework())
.intents(GatewayIntents::all())
.type_map_insert::<GlobalData>(Arc::new(Mutex::new(global_data))) .type_map_insert::<GlobalData>(Arc::new(Mutex::new(global_data)))
.type_map_insert::<Listeners>(Arc::new(Mutex::new(listeners)))
.await .await
.expect("Err creating client"); .expect("Err creating client");

View File

@ -1,5 +1,6 @@
use crate::config::MessageConfig; use crate::config::MessageConfig;
use crate::game::player_data::PlayerData; use crate::game::player_data::PlayerData;
use crate::game::role::Role;
use crate::GlobalData; use crate::GlobalData;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -8,9 +9,9 @@ use serenity::prelude::Mentionable;
use std::collections::HashMap; use std::collections::HashMap;
use tera::{Tera, Value}; use tera::{Tera, Value};
fn time_to_discord_time( type TeraFnRet = Box<dyn Fn(&HashMap<String, Value>) -> tera::Result<Value> + Send + Sync>;
time_flag: &str,
) -> Box<dyn Fn(&HashMap<String, Value>) -> tera::Result<Value> + Send + Sync> { fn time_to_discord_time(time_flag: &str) -> TeraFnRet {
let time_flag = time_flag.to_string(); let time_flag = time_flag.to_string();
Box::new( Box::new(
@ -33,6 +34,48 @@ fn time_to_discord_time(
) )
} }
fn role_name() -> TeraFnRet {
Box::new(
move |args: &HashMap<String, Value>| -> tera::Result<Value> {
match args.get("role") {
Some(val) => match tera::from_value::<Role>(val.clone()) {
Ok(v) => Ok(tera::to_value(v.player_role_name()).unwrap()),
Err(_) => Err("Failed to parse value as role".into()),
},
None => Err("Missing parameter".into()),
}
},
)
}
fn role_color() -> TeraFnRet {
Box::new(
move |args: &HashMap<String, Value>| -> tera::Result<Value> {
match args.get("role") {
Some(val) => match tera::from_value::<Role>(val.clone()) {
Ok(v) => Ok(tera::to_value(v.player_color().to_string()).unwrap()),
Err(_) => Err("Failed to parse value as role".into()),
},
None => Err("Missing parameter".into()),
}
},
)
}
fn seer_color() -> TeraFnRet {
Box::new(
move |args: &HashMap<String, Value>| -> tera::Result<Value> {
match args.get("role") {
Some(val) => match tera::from_value::<Role>(val.clone()) {
Ok(v) => Ok(tera::to_value(v.seer_color().to_string()).unwrap()),
Err(_) => Err("Failed to parse value as role".into()),
},
None => Err("Missing parameter".into()),
}
},
)
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiscordUser { pub struct DiscordUser {
pub(crate) display_name: String, pub(crate) display_name: String,
@ -157,6 +200,9 @@ impl TryFrom<MessageConfig> for MessageTemplates {
templates.register_function("to_countdown", time_to_discord_time("R")); templates.register_function("to_countdown", time_to_discord_time("R"));
templates.register_function("to_local_time", time_to_discord_time("f")); templates.register_function("to_local_time", time_to_discord_time("f"));
templates.register_function("player_color", role_color());
templates.register_function("player_role", role_name());
templates.register_function("seer_color", seer_color());
templates.add_raw_template("welcome_message", &config.welcome_message)?; templates.add_raw_template("welcome_message", &config.welcome_message)?;
templates.add_raw_template("status_message", &config.status_message)?; templates.add_raw_template("status_message", &config.status_message)?;