Skip to main content

streamocracy/polls/
votekick.rs

1//! Votekick poll implementation
2
3use crate::polls::{Poll, schedule_poll_completion, send_temporary_message};
4use serenity::all::{ChannelId, CommandInteraction, Context, CreateEmbed, UserId};
5use serenity::prelude::Mentionable;
6use std::collections::HashMap;
7use std::sync::Arc;
8use std::sync::LazyLock;
9use tokio::sync::Mutex;
10use tracing::{error, info, warn};
11
12/// Metadata for active votekicks.
13/// (target_user_id, guild_id, channel_id)
14type VotekickMetadata = (u64, u64, u64);
15
16/// Thread-safe storage for active votekick metadata.
17type ActiveVotekicks = Arc<Mutex<HashMap<u64, VotekickMetadata>>>;
18
19static ACTIVE_VOTEKICKS: LazyLock<ActiveVotekicks> =
20    LazyLock::new(|| Arc::new(Mutex::new(HashMap::new())));
21
22/// A poll for voting to kick a user from a voice channel.
23pub struct VotekickPoll {
24    /// The user who initiated the votekick
25    pub initiator_name: String,
26    /// Target user's display name
27    pub target_name: String,
28    /// Poll duration in seconds
29    pub duration_secs: u64,
30}
31
32impl VotekickPoll {
33    /// Create a new votekick poll.
34    pub fn new(initiator_name: String, target_name: String, duration_secs: u64) -> Self {
35        Self {
36            initiator_name,
37            target_name,
38            duration_secs,
39        }
40    }
41}
42
43#[serenity::async_trait]
44impl Poll for VotekickPoll {
45    fn title(&self) -> String {
46        "šŸ“Š Votekick Started".to_string()
47    }
48
49    fn description(&self) -> String {
50        format!(
51            "Vote to kick **{}** from the voice channel?\n\nReact with āœ… to vote **Yes**\nReact with āŒ to vote **No**",
52            self.target_name
53        )
54    }
55
56    fn duration(&self) -> u64 {
57        self.duration_secs
58    }
59
60    fn build_embed(&self) -> CreateEmbed {
61        CreateEmbed::default()
62            .title(self.title())
63            .description(self.description())
64            .field("Duration", format!("{} seconds", self.duration()), false)
65            .footer(serenity::all::CreateEmbedFooter::new(format!(
66                "Initiated by {}",
67                self.initiator_name
68            )))
69    }
70
71    async fn on_complete(&self, ctx: &Context, message_id: u64, yes_votes: u32, no_votes: u32) {
72        let total_votes = yes_votes + no_votes;
73        let (target_user_id, guild_id, channel_id) = {
74            let mut active = ACTIVE_VOTEKICKS.lock().await;
75            match active.remove(&message_id) {
76                Some(info) => info,
77                None => {
78                    warn!("No votekick metadata found for message {}", message_id);
79                    return;
80                }
81            }
82        };
83
84        let guild_id = serenity::all::GuildId::new(guild_id);
85        let target_user_id = UserId::new(target_user_id);
86        let channel_id = ChannelId::new(channel_id);
87
88        if yes_votes < 2 {
89            info!(
90                "Votekick did not pass - need minimum 2 yes votes (got {})",
91                yes_votes
92            );
93            send_temporary_message(
94                ctx,
95                channel_id,
96                format!(
97                    "šŸ“Š Votekick results: **Did not pass**\nNeed at least 2 āœ… votes.\nResults: āœ… {} | āŒ {} (Total votes: {})",
98                    yes_votes, no_votes, total_votes
99                ),
100                10,
101            )
102                .await;
103            return;
104        }
105
106        if yes_votes <= no_votes {
107            info!(
108                "Votekick did not pass (yes: {}, no: {})",
109                yes_votes, no_votes
110            );
111            send_temporary_message(
112                ctx,
113                channel_id,
114                format!(
115                    "šŸ“Š Votekick results: **Did not pass**\nāœ… {} | āŒ {} (Total votes: {})\n\nYes votes needed to exceed No votes.",
116                    yes_votes, no_votes, total_votes
117                ),
118                10,
119            )
120                .await;
121            return;
122        }
123
124        info!(
125            "Votekick passed (yes: {}, no: {}) - kicking {}",
126            yes_votes, no_votes, target_user_id
127        );
128
129        let target_member = match guild_id.member(&ctx.http, target_user_id).await {
130            Ok(m) => m,
131            Err(e) => {
132                error!("Failed to get target member for kick: {}", e);
133                return;
134            }
135        };
136
137        let guild_cache = ctx.cache.guild(guild_id);
138        let in_voice = guild_cache
139            .map(|g| g.voice_states.contains_key(&target_user_id))
140            .unwrap_or(false);
141
142        if !in_voice {
143            info!(
144                "Target user {} is no longer in a voice channel",
145                target_user_id
146            );
147            send_temporary_message(
148                ctx,
149                channel_id,
150                format!(
151                    "šŸ“Š Votekick passed (āœ… {} | āŒ {}) but {} is no longer in the voice channel.",
152                    yes_votes,
153                    no_votes,
154                    target_member.user.mention()
155                ),
156                10,
157            )
158            .await;
159            return;
160        }
161
162        if let Err(e) = guild_id.disconnect_member(&ctx.http, target_user_id).await {
163            error!("Failed to disconnect member: {}", e);
164            send_temporary_message(
165                ctx,
166                channel_id,
167                format!(
168                    "šŸ“Š Votekick passed (āœ… {} | āŒ {}) but failed to kick {}: {}",
169                    yes_votes,
170                    no_votes,
171                    target_member.user.mention(),
172                    e
173                ),
174                10,
175            )
176            .await;
177        } else {
178            info!(
179                "Successfully disconnected {} from voice channel",
180                target_user_id
181            );
182
183            send_temporary_message(
184                ctx,
185                channel_id,
186                format!(
187                    "šŸ‘¢ **{}** was kicked from the voice channel!\n\nšŸ“Š Results: āœ… {} | āŒ {} (Total votes: {})",
188                    target_member.user.mention(),
189                    yes_votes,
190                    no_votes,
191                    total_votes
192                ),
193                10,
194            )
195                .await;
196        }
197    }
198}
199
200/// Start a new votekick poll.
201/// This is the public interface used by the command handler.
202pub async fn start_votekick(
203    ctx: &Context,
204    command: &CommandInteraction,
205    target_user_id: UserId,
206    channel_id: ChannelId,
207    duration_secs: u64,
208) {
209    let guild_id = match command.guild_id {
210        Some(id) => id,
211        None => {
212            error!("Votekick used outside guild");
213            return;
214        }
215    };
216
217    let target_member = match guild_id.member(&ctx.http, target_user_id).await {
218        Ok(m) => m,
219        Err(e) => {
220            error!("Failed to get target member: {}", e);
221            return;
222        }
223    };
224
225    let poll = VotekickPoll::new(
226        command.user.name.clone(),
227        target_member.user.name,
228        duration_secs,
229    );
230
231    match Poll::start(&poll, ctx, command).await {
232        Ok(message_id) => {
233            {
234                let mut active = ACTIVE_VOTEKICKS.lock().await;
235                active.insert(
236                    message_id,
237                    (target_user_id.get(), guild_id.get(), channel_id.get()),
238                );
239            }
240
241            info!(
242                "Votekick poll created for {} (message_id: {})",
243                poll.target_name, message_id
244            );
245
246            schedule_poll_completion(poll, ctx.clone(), message_id, duration_secs).await;
247        }
248        Err(e) => {
249            error!("Failed to start votekick poll: {}", e);
250        }
251    }
252}