1use 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
12type VotekickMetadata = (u64, u64, u64);
15
16type ActiveVotekicks = Arc<Mutex<HashMap<u64, VotekickMetadata>>>;
18
19static ACTIVE_VOTEKICKS: LazyLock<ActiveVotekicks> =
20 LazyLock::new(|| Arc::new(Mutex::new(HashMap::new())));
21
22pub struct VotekickPoll {
24 pub initiator_name: String,
26 pub target_name: String,
28 pub duration_secs: u64,
30}
31
32impl VotekickPoll {
33 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
200pub 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}