Skip to main content

streamocracy/commands/
votekick.rs

1use crate::commands::SlashCommand;
2use crate::config::Config;
3use serenity::all::{
4    ChannelId, CommandInteraction, CommandOptionType, Context, CreateCommand, CreateCommandOption,
5    CreateInteractionResponse, CreateInteractionResponseMessage, UserId,
6};
7use tracing::{error, info, warn};
8
9/// Slash command for starting a votekick poll against a user.
10pub struct VotekickCommand;
11
12#[serenity::async_trait]
13impl SlashCommand for VotekickCommand {
14    fn name(&self) -> &'static str {
15        "votekick"
16    }
17
18    fn register(&self) -> CreateCommand {
19        CreateCommand::new(self.name())
20            .description("Start a votekick poll against a user")
21            .add_option(
22                CreateCommandOption::new(CommandOptionType::User, "user", "The user to votekick")
23                    .required(true),
24            )
25            .add_option(
26                CreateCommandOption::new(
27                    CommandOptionType::Integer,
28                    "duration",
29                    "Poll duration in seconds",
30                )
31                .required(false),
32            )
33    }
34
35    async fn run(&self, ctx: Context, command: CommandInteraction, config: Config) {
36        if let Err(e) = self.run_internal(&ctx, &command, &config).await {
37            error!("Votekick error: {}", e);
38        }
39    }
40}
41
42impl VotekickCommand {
43    /// Internal implementation of the votekick command.
44    /// Validates preconditions and starts the poll if all checks pass.
45    async fn run_internal(
46        &self,
47        ctx: &Context,
48        command: &CommandInteraction,
49        config: &Config,
50    ) -> anyhow::Result<()> {
51        let user = &command.user;
52        let guild_id = command
53            .guild_id
54            .map(|id| id.to_string())
55            .unwrap_or_else(|| "DM".to_string());
56
57        info!(
58            "Command 'votekick' invoked by {} ({}) in {}",
59            user.name, user.id, guild_id,
60        );
61
62        let Some(guild_id) = command.guild_id else {
63            warn!("votekick used in DM by {}", user.name);
64            self.send_error(ctx, command, "This command can only be used in a server.")
65                .await;
66            return Ok(());
67        };
68
69        let target_user_id = command
70            .data
71            .options
72            .first()
73            .and_then(|opt| opt.value.as_user_id())
74            .expect("User option is required");
75
76        let duration = command
77            .data
78            .options
79            .get(1)
80            .and_then(|opt| opt.value.as_i64())
81            .map(|v| {
82                v.clamp(
83                    config.min_votekick_duration as i64,
84                    config.max_votekick_duration as i64,
85                ) as u64
86            })
87            .unwrap_or(config.default_votekick_duration);
88
89        let user_channel_id = match self.get_user_voice_channel(ctx, guild_id, user.id) {
90            Some(cid) => cid,
91            None => {
92                warn!("{} tried votekick but is not in a voice channel", user.name);
93                self.send_error(
94                    ctx,
95                    command,
96                    "You must be in a voice channel to use this command.",
97                )
98                .await;
99                return Ok(());
100            }
101        };
102
103        let (target_in_same_channel, target_screensharing) =
104            self.check_target_user(ctx, guild_id, target_user_id, user_channel_id);
105
106        if !target_in_same_channel {
107            warn!(
108                "Target user {} is not in the same voice channel as {}",
109                target_user_id, user.name
110            );
111            self.send_error(
112                ctx,
113                command,
114                "The target user must be in the same voice channel as you.",
115            )
116            .await;
117            return Ok(());
118        }
119
120        if !target_screensharing {
121            warn!("Target user {} is not screensharing", target_user_id);
122            self.send_error(
123                ctx,
124                command,
125                "The target user must be screensharing to start a votekick.",
126            )
127            .await;
128            return Ok(());
129        }
130
131        info!(
132            "Votekick starting by {} targeting {} in channel {} (duration: {}s)",
133            user.name, target_user_id, user_channel_id, duration
134        );
135
136        crate::polls::votekick::start_votekick(
137            ctx,
138            command,
139            target_user_id,
140            user_channel_id,
141            duration,
142        )
143        .await;
144
145        Ok(())
146    }
147
148    /// Get the voice channel ID for a user in a guild.
149    fn get_user_voice_channel(
150        &self,
151        ctx: &Context,
152        guild_id: serenity::all::GuildId,
153        user_id: UserId,
154    ) -> Option<ChannelId> {
155        let guild = ctx.cache.guild(guild_id)?;
156        let vs = guild.voice_states.get(&user_id)?;
157        vs.channel_id
158    }
159
160    /// Check if target user is in the same channel and screensharing.
161    /// Returns (in_same_channel, is_screensharing).
162    fn check_target_user(
163        &self,
164        ctx: &Context,
165        guild_id: serenity::all::GuildId,
166        target_user_id: UserId,
167        user_channel_id: ChannelId,
168    ) -> (bool, bool) {
169        let Some(guild) = ctx.cache.guild(guild_id) else {
170            return (false, false);
171        };
172        let Some(vs) = guild.voice_states.get(&target_user_id) else {
173            return (false, false);
174        };
175        let in_same = vs.channel_id == Some(user_channel_id);
176        let screensharing = vs.self_stream.unwrap_or(false);
177        (in_same, screensharing)
178    }
179
180    /// Send an ephemeral error response to the user.
181    async fn send_error(&self, ctx: &Context, command: &CommandInteraction, message: &str) {
182        if let Err(e) = command
183            .create_response(
184                &ctx.http,
185                CreateInteractionResponse::Message(
186                    CreateInteractionResponseMessage::new()
187                        .content(message)
188                        .ephemeral(true),
189                ),
190            )
191            .await
192        {
193            error!("Failed to send error response: {}", e);
194        }
195    }
196}