html terminal
Diffstat (limited to 'src/bot/voting.rs')
-rw-r--r--src/bot/voting.rs690
1 files changed, 529 insertions, 161 deletions
diff --git a/src/bot/voting.rs b/src/bot/voting.rs
index 090ac73..9fa57a6 100644
--- a/src/bot/voting.rs
+++ b/src/bot/voting.rs
@@ -1,68 +1,399 @@
use super::{Context, DISABLED, SUCCESS};
use ::serenity::builder::CreateActionRow;
use ::serenity::builder::CreateEmbed;
+use anyhow::anyhow;
use anyhow::Result;
use itertools::Itertools;
-use poise::serenity_prelude::CollectComponentInteraction as Interaction;
use poise::serenity_prelude::*;
+use serde::{Deserialize, Serialize};
use std::collections::HashMap;
+use std::fs::{read_dir, File};
+use std::io::BufReader;
+use std::path::Path;
use std::str::FromStr;
use std::sync::Mutex;
use std::time::SystemTime;
+use tokio::time::*;
pub type Vote = usize;
+
+#[derive(Default, Clone, Serialize, Deserialize, Debug)]
+struct VoteOptions {
+ options: Vec<String>,
+ styles: Vec<ButtonStyle>,
+ title: String,
+ fields: HashMap<String, String>,
+ image: Option<String>,
+}
+
+#[derive(Serialize, Deserialize, Debug, Clone, Default)]
+pub struct BeforePushVoteData {
+ votes: HashMap<UserId, Vote>,
+ // you can parse this from the message but its easier to do this
+ deadline: Duration,
+ #[serde(skip)]
+ deadline_changed: Duration,
+ options: VoteOptions,
+ #[serde(skip)]
+ reply: Option<Box<Message>>,
+ id: u64,
+ mid: MessageId,
+ cid: ChannelId,
+ endat: u64,
+}
+
+#[derive(Clone, Debug)]
+pub struct AfterPushVoteData {
+ index: usize,
+ deadline_changed: Duration,
+ options: VoteOptions,
+ id: u64,
+}
+
+macro_rules! read {
+ ($self:expr,$ctx:expr,$v:expr) => {{
+ $v = $ctx.data().vote_data.lock().unwrap();
+ match $v.get_mut($self.index).unwrap() {
+ VoteData::Before(x) => x,
+ VoteData::After(_) => unreachable!(),
+ }
+ }};
+}
+
+#[derive(Debug)]
pub enum VoteData {
- Running(HashMap<UserId, Vote>),
- Finished(Vec<usize>),
+ Before(BeforePushVoteData),
+ After(AfterPushVoteData),
+}
+
+pub type Votes = Mutex<Vec<VoteData>>;
+
+trait EmbedUtil {
+ fn imageor<S: ToString>(&mut self, img: Option<S>) -> &mut Self;
+ fn set_fields<M: IntoIterator<Item = (S, S)>, S: ToString>(&mut self, fields: M) -> &mut Self;
+}
+
+impl EmbedUtil for CreateEmbed {
+ fn imageor<S: ToString>(&mut self, img: Option<S>) -> &mut Self {
+ if let Some(iuri) = img {
+ self.image(iuri)
+ } else {
+ self
+ }
+ }
+
+ fn set_fields<M: IntoIterator<Item = (S, S)>, S: ToString>(&mut self, fields: M) -> &mut Self {
+ for (k, v) in fields {
+ self.field(k, v, false);
+ }
+ self
+ }
+}
+
+macro_rules! votes {
+ ($self:expr, $ctx:expr, $v:expr) => {
+ match &mut $self {
+ VoteData::After(a) => &mut read!(a, $ctx, $v).votes,
+ VoteData::Before(b) => &mut b.votes,
+ }
+ };
}
impl VoteData {
- pub fn get(&mut self) -> &mut HashMap<UserId, Vote> {
+ pub fn summarize(&mut self, ctx: &Context<'_>, optcount: usize) -> Vec<usize> {
+ let mut ret = vec![];
+ ret.resize(optcount, 0);
+ let mut v;
+ for v in votes!(*self, ctx, v).values() {
+ ret[*v] += 1;
+ }
+ ret
+ }
+
+ fn deadline(&self) -> Duration {
match self {
- Self::Running(x) => x,
- Self::Finished(_) => unreachable!(),
+ Self::After(a) => a.deadline_changed,
+ Self::Before(b) => b.deadline_changed,
}
}
- pub fn summarize_running(&self, optcount: usize) -> Vec<usize> {
+ fn id(&self) -> u64 {
match self {
- Self::Running(s) => {
- let mut ret = vec![];
- ret.resize(optcount, 0);
- for (_, v) in s {
- ret[*v] += 1
- }
- ret
+ Self::After(a) => a.id,
+ Self::Before(b) => b.id,
+ }
+ }
+
+ fn options(&self) -> &VoteOptions {
+ match self {
+ Self::After(a) => &a.options,
+ Self::Before(b) => &b.options,
+ }
+ }
+
+ fn set_reply(&mut self, ctx: &Context<'_>, reply: Message) {
+ let mut v;
+ let mid = reply.id;
+ let cid = reply.channel_id;
+ let reply = Some(Box::new(reply));
+ match self {
+ Self::After(a) => {
+ let read = read!(a, ctx, v);
+ read.reply = reply;
+ read.mid = mid;
+ read.cid = cid;
+ }
+ Self::Before(b) => {
+ b.reply = reply;
+ b.mid = mid;
+ b.cid = cid;
}
- Self::Finished(_) => unreachable!(),
}
}
- pub fn get_summarized(&self) -> &Vec<usize> {
+ fn get_reply(&mut self, ctx: &Context<'_>) -> Box<Message> {
+ let mut v;
match self {
- Self::Finished(ret) => ret,
- Self::Running(_) => unreachable!(),
+ Self::After(a) => read!(a, ctx, v).reply.take().unwrap(),
+ Self::Before(b) => b.reply.take().unwrap(),
}
}
- pub fn finish(self, optcount: usize) -> Self {
- Self::Finished(self.summarize_running(optcount))
+ fn set_end(&mut self) {
+ let end = self.dead_secs();
+ match self {
+ Self::Before(x) => x.endat = end,
+ _ => unreachable!(),
+ }
}
-}
-pub type Votes = Mutex<Vec<VoteData>>;
+ fn dead_secs(&self) -> u64 {
+ (SystemTime::now() + self.deadline())
+ .duration_since(SystemTime::UNIX_EPOCH)
+ .unwrap()
+ .as_secs()
+ }
-trait Imgor {
- fn imageor<S: ToString>(&mut self, img: Option<S>) -> &mut Self;
+ fn end_stamp(&self) -> String {
+ format!("<t:{}:R>", self.dead_secs())
+ }
+
+ pub async fn begin(mut self, ctx: Context<'_>) -> Result<Self> {
+ self.set_end();
+ let o = self.options();
+ let handle = poise::send_reply(ctx, |m| {
+ m.embed(|e| {
+ e.imageor(o.image.as_ref())
+ .color(SUCCESS)
+ .title(&o.title)
+ .description(format!("vote ends {}", self.end_stamp()))
+ .set_fields(&o.fields)
+ })
+ .components(|c| {
+ c.create_action_row(|r| {
+ for (n, option) in o.options.iter().enumerate() {
+ r.create_button(|b| {
+ b.custom_id(format!("{}{n}", self.id()))
+ .label(option)
+ .style(o.styles[n])
+ });
+ }
+ r
+ })
+ })
+ })
+ .await?;
+ let msg = handle.into_message().await?;
+ self.set_reply(&ctx, msg);
+ self.push(&ctx).save(&ctx)
+ }
+
+ fn save_ref(&self, ctx: &Context<'_>) -> Result<()> {
+ let t = self.options().title.clone() + ".vd";
+ let mut re;
+ let thing = match &self {
+ Self::Before(x) => x,
+ Self::After(y) => {
+ re = ctx.data().vote_data.lock().unwrap();
+ match re.get_mut(y.index).unwrap() {
+ VoteData::Before(x) => x,
+ VoteData::After(_) => unreachable!(),
+ }
+ }
+ };
+ std::fs::write(t, serde_json::to_string(thing)?)?;
+ Ok(())
+ }
+
+ pub fn save(self, ctx: &Context<'_>) -> Result<Self> {
+ self.save_ref(ctx)?;
+ Ok(self)
+ }
+
+ pub fn push(self, ctx: &Context<'_>) -> Self {
+ let mut data = ctx.data().vote_data.lock().unwrap();
+ let n = data.len();
+ let v = Self::After(AfterPushVoteData {
+ index: n,
+ id: self.id(),
+ deadline_changed: self.deadline(),
+ options: self.options().clone(),
+ });
+ data.push(self);
+ v
+ }
+
+ fn remove(self, ctx: &Context<'_>) -> Self {
+ match self {
+ Self::After(x) => ctx.data().vote_data.lock().unwrap().remove(x.index),
+ Self::Before(_) => unreachable!(),
+ }
+ }
+
+ pub async fn input(mut self, ctx: &Context<'_>) -> Result<Self> {
+ let dead = self.deadline();
+ let ctx_id = self.id();
+ let ctx_id_len = ctx_id.to_string().len();
+ let o = self.options().clone();
+ let timestamp = self.end_stamp();
+ while let Some(press) = CollectComponentInteraction::new(ctx)
+ .filter(move |press| press.data.custom_id.starts_with(&ctx_id.to_string()))
+ .timeout(dead)
+ .await
+ {
+ let s = {
+ let mut v;
+ if votes!(self, ctx, v)
+ .insert(
+ press.user.id,
+ Vote::from_str(&press.data.custom_id[ctx_id_len..]).unwrap(),
+ )
+ .is_some()
+ {
+ "updated"
+ } else {
+ "voted"
+ }
+ };
+ self.save_ref(ctx)?;
+ let (_m, _) = tokio::join!(
+ press.create_followup_message(ctx, |m| { m.ephemeral(true).content(s) }),
+ press.create_interaction_response(ctx, |c| {
+ c.kind(InteractionResponseType::UpdateMessage)
+ .interaction_response_data(|m| {
+ m.embed(|e| {
+ for (option, votes) in
+ self.summarize(ctx, o.options.len()).iter().enumerate()
+ {
+ e.field(&o.options[option], votes, true);
+ }
+ e.imageor(o.image.as_ref())
+ .color(SUCCESS)
+ .title(&o.title)
+ .set_fields(&o.fields)
+ .description(format!("vote ends {timestamp}"))
+ })
+ })
+ })
+ );
+ // let m = m?;
+ // let http = ctx.serenity_context().http.clone();
+ // tokio::spawn(async move {
+ // sleep(Duration::from_secs(10)).await;
+ // m.delete(http).await.unwrap();
+ // });
+ }
+ Ok(self)
+ }
+
+ pub async fn finish(mut self, ctx: &Context<'_>) -> Result<()> {
+ let o = self.options().clone();
+ let filename = format!("{}.vd", o.title);
+ let p = std::path::Path::new(&filename);
+ if p.exists() {
+ let _ = std::fs::remove_file(p);
+ }
+ self.get_reply(ctx)
+ .edit(ctx, |m| {
+ m.embed(|e| {
+ for (option, votes) in self
+ .remove(ctx)
+ .summarize(ctx, o.options.len())
+ .iter()
+ .enumerate()
+ {
+ e.field(&o.options[option], votes, true);
+ }
+ e.color(DISABLED)
+ .title(&o.title)
+ .imageor(o.image.as_ref())
+ .set_fields(o.fields)
+ .description(format!("vote ended!"))
+ })
+ .components(|c| {
+ c.set_action_row({
+ let mut r = CreateActionRow::default();
+ for (n, option) in o.options.iter().enumerate() {
+ r.create_button(|b| {
+ b.label(option)
+ .disabled(true)
+ .style(o.styles[n])
+ .custom_id("_")
+ });
+ }
+ r
+ })
+ })
+ })
+ .await?;
+ Ok(())
+ }
}
-impl Imgor for CreateEmbed {
- fn imageor<S: ToString>(&mut self, img: Option<S>) -> &mut Self {
- if let Some(iuri) = img {
- self.image(iuri)
- } else {
- self
+trait Parsing {
+ fn parse_pscsp(self) -> HashMap<String, String>;
+ fn parse_psv(self) -> Vec<String>;
+ fn parse_psv_to_styles(self) -> Vec<ButtonStyle>;
+}
+
+impl<S> Parsing for S
+where
+ S: AsRef<str>,
+{
+ fn parse_pscsp(self) -> HashMap<String, String> {
+ let pair = self.as_ref().split('|');
+ let pairs = pair.map(|s| {
+ s.split(':')
+ .map(|s| s.trim().to_owned())
+ .collect_tuple()
+ .unwrap()
+ });
+ let mut map = HashMap::new();
+ for (k, v) in pairs {
+ map.insert(k, v);
}
+ map
+ }
+ fn parse_psv(self) -> Vec<String> {
+ self.as_ref()
+ .split('|')
+ .map(|s| s.trim().to_owned())
+ .collect()
+ }
+ fn parse_psv_to_styles(self) -> Vec<ButtonStyle> {
+ self.as_ref()
+ .split('|')
+ .map(|s| {
+ use ButtonStyle::*;
+ match s.trim().to_lowercase().as_str() {
+ // "blue" => Primary,
+ "gray" => Secondary,
+ "green" => Success,
+ "red" => Danger,
+ _ => Primary,
+ }
+ })
+ .collect()
}
}
@@ -72,148 +403,185 @@ pub async fn create(
ctx: Context<'_>,
#[description = "picture url"] image: Option<String>,
#[description = "pressables (psv)"] options: String,
- #[description = "option styles (psv)"] styles: String,
+ #[description = "option styles (psv) {blue|gray|green|red}"] styles: Option<String>,
#[description = "how long the vote will be up"] length: String,
+ #[description = "fields (pipe separated colon seperated pairs) (a:b|c:d)"] fields: Option<
+ String,
+ >,
title: String,
) -> Result<()> {
- let ctx_id = ctx.id();
- let options = options.split('|').map(|s| s.trim()).collect_vec();
- let styles = styles
- .split('|')
- .map(|s| s.trim().to_lowercase())
- .map(|s| {
- use ButtonStyle::*;
- match s.as_str() {
- "blue" => Primary,
- "gray" => Secondary,
- "green" => Success,
- "red" => Danger,
- _ => Primary,
- }
- })
- .collect_vec();
- let dur = if let Ok(t) = parse_duration::parse(&length) {
- t
- } else {
+ let options = options.parse_psv();
+ let styles = styles.map_or(
+ vec![ButtonStyle::Primary; options.len()],
+ Parsing::parse_psv_to_styles,
+ );
+ let fields = fields.as_ref().map_or(HashMap::new(), Parsing::parse_pscsp);
+ let Ok(dur) = parse_duration::parse(&length) else {
ctx.say(format!("`{length}` is not time")).await?;
return Ok(());
};
- let end = format!(
- "<t:{}:R>",
- (SystemTime::now() + dur)
- .duration_since(SystemTime::UNIX_EPOCH)?
- .as_secs()
- );
- macro_rules! update_msg {
- // give me unhygenic macros
- ($m:expr, $ctx:expr, $image:expr, $title:expr, $options:expr, $ctx_id:expr, $n:expr) => {
- $m.embed(|e| {
- for (option, votes) in $ctx.data().vote_data.lock().unwrap()[$n]
- .summarize_running($options.len())
- .iter()
- .enumerate()
- {
- e.field(&$options[option], votes, true);
- }
- e.imageor($image.as_ref())
- .color(SUCCESS)
- .title(&$title)
- .description(format!("vote ends {end}"))
- })
- };
+
+ VoteData::Before({
+ BeforePushVoteData {
+ votes: HashMap::new(),
+ deadline: dur,
+ deadline_changed: dur,
+ options: VoteOptions {
+ options,
+ styles,
+ title,
+ fields,
+ image,
+ },
+ reply: None,
+ id: ctx.id(),
+ ..Default::default()
+ }
+ })
+ .begin(ctx)
+ .await?
+ .input(&ctx)
+ .await?
+ .finish(&ctx)
+ .await
+}
+
+async fn fix(ctx: &Context<'_>, data: BufReader<std::fs::File>) -> Result<()> {
+ let mut v: BeforePushVoteData = serde_json::from_reader(data)?;
+ let m = ctx.http().get_message(v.cid.0, v.mid.0).await?;
+ let end = dbg!(m.timestamp.unix_timestamp()) as u64;
+ v.reply = Some(Box::new(m));
+ let now = SystemTime::now()
+ .duration_since(SystemTime::UNIX_EPOCH)
+ .unwrap()
+ .as_secs();
+ let end = end + dbg!(v.deadline.as_secs());
+ println!("@{now} | :{end}");
+ // cant use abs() because unsigned
+ v.deadline_changed = if now < end {
+ Duration::from_secs(end - now)
+ } else {
+ Duration::from_secs(now - end)
+ };
+ let v = VoteData::Before(v);
+ if end < now {
+ v.push(&ctx).finish(&ctx).await
+ } else {
+ v.push(&ctx).input(&ctx).await?.finish(&ctx).await
}
- let n = {
- let mut data = ctx.data().vote_data.lock().unwrap();
- let n = data.len();
- data.push(VoteData::Running(HashMap::new()));
- n
+}
+
+#[poise::command(
+ slash_command,
+ category = "Discord",
+ required_permissions = "ADMINISTRATOR"
+)]
+pub async fn fixall(ctx: Context<'_>) -> Result<()> {
+ use futures::future;
+ let mut futs = vec![];
+ for e in read_dir(".")? {
+ let e = e?;
+ let fname = e.file_name();
+ let p = Path::new(&fname);
+ if let Some(ext) = p.extension() {
+ if ext == "vd" {
+ futs.push(fix(&ctx, BufReader::new(File::open(p).unwrap())));
+ }
+ }
+ }
+ let msg = format!("fixed {}", futs.len());
+ poise::send_reply(ctx, |m| m.content(msg).ephemeral(true)).await?;
+ future::join_all(futs).await;
+ Ok(())
+}
+
+// #[poise::command(
+// context_menu_command = "fix vote",
+// slash_command,
+// category = "Discord",
+// rename = "fix_vote",
+// required_permissions = "ADMINISTRATOR"
+// )]
+// /// restart vote (use ctx menu)
+// pub async fn reinstate(
+// ctx: Context<'_>,
+// #[description = "previous vote, id or link"] m: Message,
+// ) -> Result<()> {
+// static RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"<t:([0-9]+):R>"#).unwrap());
+// let e = m.embeds.get(0).ok_or(anyhow!("no embed?"))?;
+// let end = u64::from_str(
+// RE.captures(
+// m.embeds
+// .get(0)
+// .ok_or(anyhow!("no embed?"))?
+// .description
+// .as_ref()
+// .ok_or(anyhow!("no desc?"))?,
+// )
+// .ok_or(anyhow!("no timestamp?"))?
+// .get(1)
+// .unwrap()
+// .as_str(),
+// )
+// .unwrap();
+// let now = SystemTime::now()
+// .duration_since(SystemTime::UNIX_EPOCH)
+// .unwrap()
+// .as_secs();
+// let f = BufReader::new(std::fs::File::open(
+// e.title.to_owned().ok_or(anyhow!("no title?"))? + ".vd",
+// )?);
+// poise::send_reply(ctx, |m| m.content("yes").ephemeral(true)).await?;
+// let mut v: BeforePushVoteData = serde_json::from_reader(f)?;
+// v.reply = Some(Box::new(m));
+// // cant use abs() because unsigned
+// v.deadline = if now < end {
+// Duration::from_secs(end - now)
+// } else {
+// Duration::from_secs(now - end)
+// };
+// let v = VoteData::Before(v);
+// if end < now {
+// v.push(&ctx).finish(&ctx).await
+// } else {
+// v.push(&ctx).input(&ctx).await?.finish(&ctx).await
+// }
+// }
+
+// voters
+
+#[poise::command(prefix_command, slash_command, category = "Discord", rename = "votes")]
+pub async fn list(ctx: Context<'_>, #[description = "the vote title"] vote: String) -> Result<()> {
+ let vd = {
+ let buf = ctx.data().vote_data.lock().unwrap();
+ match &buf[buf
+ .iter()
+ .position(|x| x.options().title == vote)
+ .ok_or(anyhow!("vote doesnt exist"))?]
+ {
+ VoteData::Before(x) => x.clone(),
+ VoteData::After(_) => unreachable!(),
+ }
};
- let handle = poise::send_reply(ctx, |m| {
- update_msg!(m, ctx, image, title, options, ctx_id, n).components(|c| {
- c.create_action_row(|r| {
- for (n, option) in options.iter().enumerate() {
- r.create_button(|b| {
- b.custom_id(format!("{}{n}", ctx_id))
- .label(option)
- .style(styles[n])
- });
+ poise::send_reply(ctx, |m| {
+ m.allowed_mentions(|x| x.empty_parse()).embed(|e| {
+ let mut votes: HashMap<usize, Vec<u64>> = HashMap::new();
+ for (user, vote) in vd.votes {
+ votes.entry(vote).or_default().push(user.0);
+ }
+ for (vote, voters) in votes {
+ let mut s = vec![];
+ s.reserve(voters.len());
+ for person in voters {
+ s.push(format!("<@{person}>"));
}
- r
- })
+ e.field(&vd.options.options[vote], s.join("\n"), false);
+ }
+ e.color(SUCCESS)
+ .title(format!("voter list for {vote}"))
+ .footer(|f| f.text("privacy is a illusion"))
})
})
.await?;
- let ctx_id_len = ctx_id.to_string().len();
- while let Some(press) = Interaction::new(ctx)
- .filter(move |press| press.data.custom_id.starts_with(&ctx_id.to_string()))
- .timeout(dur)
- .await
- {
- let s = {
- if ctx.data().vote_data.lock().unwrap()[n]
- .get()
- .insert(
- press.user.id,
- Vote::from_str(&press.data.custom_id[ctx_id_len..]).unwrap(),
- )
- .is_some()
- {
- "updated"
- } else {
- "voted"
- }
- };
- println!("got vote!");
- tokio::join!(
- press.create_followup_message(ctx, |m| {
- m.ephemeral(true).embed(|e| e.title(s).color(SUCCESS))
- }),
- press.create_interaction_response(ctx, |c| {
- c.kind(InteractionResponseType::UpdateMessage)
- .interaction_response_data(|m| {
- update_msg!(m, ctx, image, title, options, ctx_id, n)
- })
- })
- )
- .0?;
- }
- println!("vote ended!");
- handle
- .edit(ctx, |m| {
- m.embed(|e| {
- for (option, votes) in ctx
- .data()
- .vote_data
- .lock()
- .unwrap()
- .remove(n)
- .finish(options.len())
- .get_summarized()
- .into_iter()
- .enumerate()
- {
- e.field(&options[option], votes, true);
- }
- e.color(DISABLED)
- .title(&title)
- .imageor(image.as_ref())
- .description(format!("vote ended!"))
- })
- .components(|c| {
- c.set_action_row({
- let mut r = CreateActionRow::default();
- for (n, option) in options.iter().enumerate() {
- r.create_button(|b| {
- b.custom_id(format!("{}{n}", ctx_id))
- .label(option)
- .disabled(true)
- .style(styles[n])
- });
- }
- r
- })
- })
- })
- .await?;
Ok(())
}