feat: shorten the /join message and report the git revision in the /info command for more transparency

This commit is contained in:
2026-05-06 20:21:27 -04:00
parent fcd856b61a
commit a752838a46
4 changed files with 150 additions and 170 deletions

View File

@@ -1,22 +1,20 @@
use futures::TryStreamExt; use futures::TryStreamExt;
use snafu::{OptionExt, Snafu};
use std::sync::LazyLock; use std::sync::LazyLock;
use twilight_model::{ use twilight_model::{
application::{ application::{
command::{Command, CommandType}, command::{Command, CommandType},
interaction::Interaction, interaction::Interaction,
}, },
channel::message::{Embed, MessageFlags}, channel::message::MessageFlags,
http::interaction::{InteractionResponse, InteractionResponseType}, http::interaction::{InteractionResponse, InteractionResponseType},
id::{Id, marker::UserMarker},
}; };
use twilight_util::builder::{ use twilight_util::builder::{
InteractionResponseDataBuilder, InteractionResponseDataBuilder,
command::CommandBuilder, command::CommandBuilder,
embed::{EmbedAuthorBuilder, EmbedBuilder, EmbedFieldBuilder}, embed::{EmbedAuthorBuilder, EmbedBuilder, EmbedFieldBuilder, EmbedFooterBuilder},
}; };
use crate::command::State; use crate::{build_info, command::State};
const NAME: &str = "info"; const NAME: &str = "info";
const DESCRIPTION: &str = "Show various information"; const DESCRIPTION: &str = "Show various information";
@@ -28,67 +26,31 @@ pub static COMMAND: LazyLock<Command> = LazyLock::new(|| {
.build() .build()
}); });
#[derive(Debug, Snafu)]
enum NoPermission {
/// there isn't a user who invoked this command
NoUser,
/// the user isn't allowed to use this command because they're not the bot owner
NotInvokedByBotOwner,
}
fn no_permission_to_embed(error: NoPermission) -> Embed {
match error {
NoPermission::NoUser => {
EmbedBuilder::new().title("Not invoked by a user").description("This command works by joining the same VC as the user, but this bot didn't receive any user data. So did no user invoke it?! (This error should be impossible!)").validate().unwrap().build()
},
NoPermission::NotInvokedByBotOwner => {
EmbedBuilder::new().title("No permission to see debug info").description("Only the owner of this bot is allowed to see its information for debugging purposes.").validate().unwrap().build()
},
}
}
fn check_permission(
interaction: &Interaction,
bot_owner_user_id: Id<UserMarker>,
) -> Result<(), NoPermission> {
let user_id = interaction
.member
.as_ref()
.and_then(|member| member.user.as_ref().map(|user| user.id))
.context(NoUserSnafu)?;
if user_id != bot_owner_user_id {
return Err(NoPermission::NotInvokedByBotOwner);
}
Ok(())
}
#[tracing::instrument] #[tracing::instrument]
pub async fn handle(state: State, interaction: Interaction) { pub async fn handle(state: State, interaction: Interaction) {
if let Err(no_permission) = check_permission(&interaction, state.discord_bot_owner_user_id) { let revision = build_info::COMMIT_HASH;
state
.discord_client
.interaction(state.discord_application_id)
.create_response(
interaction.id,
&interaction.token,
&InteractionResponse {
kind: InteractionResponseType::ChannelMessageWithSource,
data: Some(
InteractionResponseDataBuilder::new()
.embeds([no_permission_to_embed(no_permission)])
.flags(MessageFlags::EPHEMERAL)
.build(),
),
},
)
.await
.expect("TODO");
return; let bot_owner_user_id = state.discord_bot_owner_user_id;
}
let is_bot_owner =
interaction
.member
.as_ref()
.and_then(|member| member.user.as_ref().map(|user| user.id))
.map(|user_id| user_id == bot_owner_user_id)
.unwrap_or(false);
let bot_owner_mention = format!("<@{}>", bot_owner_user_id);
let opt_in_mention = format!(
"</{}:{}>",
state.discord_opt_in_command_name, state.discord_opt_in_command_id
);
let opt_out_mention = format!(
"</{}:{}>",
state.discord_opt_out_command_name, state.discord_opt_out_command_id
);
state state
.discord_client .discord_client
@@ -96,90 +58,110 @@ pub async fn handle(state: State, interaction: Interaction) {
.create_response( .create_response(
interaction.id, interaction.id,
&interaction.token, &interaction.token,
&InteractionResponse { &InteractionResponse {
kind: InteractionResponseType::ChannelMessageWithSource, kind: InteractionResponseType::ChannelMessageWithSource,
data: Some( data: Some(
InteractionResponseDataBuilder::new() InteractionResponseDataBuilder::new().embeds([
.flags(MessageFlags::EPHEMERAL) EmbedBuilder::new()
.content("some debug info is coming your way!") .title("About This Bot")
.build(), .description(format!("This bot intends to record VCs it's in. You can opt out with {opt_out_mention} or explicitly opt in with {opt_in_mention} (I'd appreciate this one). Here are some pledges backed by faith (because there is no way to verify them yourself) in {bot_owner_mention}:"))
), .field(
}, EmbedFieldBuilder::new("Recordings are never shared", "Audio recordings are only stored on my home server and desktop computer and will never be uploaded to services or hardware that is owned by another person: not even curated clips, and not even to people who were in the recording. When transcription to text is implemented, this will only be run on my personally owned devices and not on any internet or cloud offering.").build()
) )
.await .field(
.expect("TODO"); EmbedFieldBuilder::new("You won't be \"audited\"", "I will not reference things said in past recordings with the goal of \"making a point\", nor pull them up on the spot (even by the request of the person who said it). Ideally, these are just peace of mind for me that I'm not missing out by not being in a Discord call all the time and can take my life back, so using them in an unhealthy way isn't in my interest.").build()
)
let heat_script_description = state .field(
.bot_data_manager EmbedFieldBuilder::new("Code is publicly available", format!("The latest source code is at https://gitea.katniss.top/jacob/fomo-reducer so that I don't have to write guarantees about the technology here (e.g. what data is acquired, how it's used or stored) and you can just check it yourself. To that end: this bot is running revision `{revision}` at the time of this message.")).build()
.with(|bot_data| { )
let heat_script_option = bot_data.has_heat_script().then(|| { .footer(
bot_data EmbedFooterBuilder::new("Thanks for your patience and understanding as I have bad and unusual mental health and it's crazy that I need this. This - especially if I learn if I can record streams or webcams so I don't miss out on those experiences either - should be the end of abrasion and force about how we spend our time. Again, thank you, I appreciate it.")
.get_heat_script() )
.expect("TODO") .validate()
.to_string() .unwrap()
.expect("TODO") .build()
}); ])
heat_script_option.map_or("none set yet".into(), |heat_script| { .flags(MessageFlags::EPHEMERAL)
format!("```\n{heat_script}\n```") .build()
}) )
}) })
.await .await
.expect("TODO"); .expect("TODO");
state if is_bot_owner {
.discord_client let heat_script_description = state
.interaction(state.discord_application_id) .bot_data_manager
.create_followup(&interaction.token) .with(|bot_data| {
.embeds(&[EmbedBuilder::new() let heat_script_option = bot_data.has_heat_script().then(|| {
.field(EmbedFieldBuilder::new("Heat Script", heat_script_description).build()) bot_data
.validate() .get_heat_script()
.unwrap()
.build()])
.flags(MessageFlags::EPHEMERAL)
.await
.expect("TODO");
let mut user_id_stream = state.user_data_manager.list().await.expect("TODO");
while let Some(user_id) = user_id_stream.try_next().await.expect("TODO") {
let (consent, notification_script) = state
.user_data_manager
.with(user_id, |user_data| {
let consent = user_data.get_voice_recording_consent().unwrap();
let notification_script = user_data.has_notification_script().then_some(
user_data
.get_notification_script()
.expect("TODO") .expect("TODO")
.to_string() .to_str()
.expect("TODO"), .expect("TODO")
); });
heat_script_option.map_or("none set yet".into(), |heat_script| {
(consent, notification_script) format!("```\n{heat_script}\n```")
})
}) })
.await .await
.expect("TODO"); .expect("TODO");
let user_mention = format!("<@{user_id}>");
state state
.discord_client .discord_client
.interaction(state.discord_application_id) .interaction(state.discord_application_id)
.create_followup(&interaction.token) .create_followup(&interaction.token)
.embeds(&[EmbedBuilder::new() .embeds(&[EmbedBuilder::new()
.author(EmbedAuthorBuilder::new(user_mention)) .title("Bot Data")
.field(EmbedFieldBuilder::new("Consent", format!("{consent:?}")).build()) .field(EmbedFieldBuilder::new("Heat Script", heat_script_description).build())
.field(
EmbedFieldBuilder::new(
"Notification Script",
format!("{notification_script:?}"),
)
.build(),
)
.validate() .validate()
.unwrap() .unwrap()
.build()]) .build()])
.flags(MessageFlags::EPHEMERAL) .flags(MessageFlags::EPHEMERAL)
.await .await
.expect("TODO"); .expect("TODO");
let mut user_id_stream = state.user_data_manager.list().await.expect("TODO");
while let Some(user_id) = user_id_stream.try_next().await.expect("TODO") {
let (consent, notification_script) = state
.user_data_manager
.with(user_id, |user_data| {
let consent = user_data.get_voice_recording_consent().unwrap();
let notification_script = user_data.has_notification_script().then_some(
user_data
.get_notification_script()
.expect("TODO")
.to_string()
.expect("TODO"),
);
(consent, notification_script)
})
.await
.expect("TODO");
let user_mention = format!("<@{user_id}>");
state
.discord_client
.interaction(state.discord_application_id)
.create_followup(&interaction.token)
.embeds(&[EmbedBuilder::new()
.author(EmbedAuthorBuilder::new(user_mention))
.field(EmbedFieldBuilder::new("Consent", format!("{consent:?}")).build())
.field(
EmbedFieldBuilder::new(
"Notification Script",
format!("{notification_script:?}"),
)
.build(),
)
.validate()
.unwrap()
.build()])
.flags(MessageFlags::EPHEMERAL)
.await
.expect("TODO");
}
} }
} }

View File

@@ -27,9 +27,7 @@ use twilight_model::{
}, },
}; };
use twilight_util::builder::{ use twilight_util::builder::{
InteractionResponseDataBuilder, InteractionResponseDataBuilder, command::CommandBuilder, embed::EmbedBuilder,
command::CommandBuilder,
embed::{EmbedBuilder, EmbedFieldBuilder, EmbedFooterBuilder},
}; };
const NAME: &str = "join"; const NAME: &str = "join";
@@ -236,33 +234,33 @@ impl EventHandler for Handler {
#[tracing::instrument(skip(state))] #[tracing::instrument(skip(state))]
pub async fn handle(state: State, interaction: Interaction) { pub async fn handle(state: State, interaction: Interaction) {
let guild_and_voice_channel_id_res = get_guild_and_voice_channel_id(&interaction, &state.vcs_watcher.borrow()); let guild_and_voice_channel_id_res =
let (guild_id, voice_channel_id) = get_guild_and_voice_channel_id(&interaction, &state.vcs_watcher.borrow());
match guild_and_voice_channel_id_res { let (guild_id, voice_channel_id) = match guild_and_voice_channel_id_res {
Ok((guild_id, voice_channel_id)) => (guild_id, voice_channel_id), Ok((guild_id, voice_channel_id)) => (guild_id, voice_channel_id),
Err(error) => { Err(error) => {
state state
.discord_client .discord_client
.interaction(state.discord_application_id) .interaction(state.discord_application_id)
.create_response( .create_response(
interaction.id, interaction.id,
&interaction.token, &interaction.token,
&InteractionResponse { &InteractionResponse {
kind: InteractionResponseType::ChannelMessageWithSource, kind: InteractionResponseType::ChannelMessageWithSource,
data: Some( data: Some(
InteractionResponseDataBuilder::new() InteractionResponseDataBuilder::new()
.embeds([get_guild_and_vc_error_to_embed(error)]) .embeds([get_guild_and_vc_error_to_embed(error)])
.flags(MessageFlags::EPHEMERAL) .flags(MessageFlags::EPHEMERAL)
.build(), .build(),
), ),
}, },
) )
.await .await
.expect("TODO"); .expect("TODO");
return; return;
} }
}; };
state state
.discord_client .discord_client
@@ -317,8 +315,11 @@ pub async fn handle(state: State, interaction: Interaction) {
} }
let channel_mention = format!("<#{voice_channel_id}>"); let channel_mention = format!("<#{voice_channel_id}>");
let bot_owner_mention = format!("<@{}>", state.discord_bot_owner_user_id);
let info_mention = format!(
"</{}:{}>",
state.discord_info_command_name, state.discord_info_command_id
);
let opt_in_mention = format!( let opt_in_mention = format!(
"</{}:{}>", "</{}:{}>",
state.discord_opt_in_command_name, state.discord_opt_in_command_id state.discord_opt_in_command_name, state.discord_opt_in_command_id
@@ -336,19 +337,7 @@ pub async fn handle(state: State, interaction: Interaction) {
).embeds(Some(&[ ).embeds(Some(&[
EmbedBuilder::new() EmbedBuilder::new()
.title("Joined VC to record") .title("Joined VC to record")
.description(format!("This bot joined {channel_mention} and intends to record. You can opt out with {opt_out_mention} or explicitly opt in with {opt_in_mention} (I'd appreciate this one). Here are some pledges backed by faith (because there is no way to verify them yourself) in {bot_owner_mention}:")) .description(format!("This bot joined {channel_mention} and intends to record. You can opt out with {opt_out_mention} or explicitly opt in with {opt_in_mention} (I'd appreciate this one). Please use {info_mention} for more information about this bot."))
.field(
EmbedFieldBuilder::new("Recordings are never shared", "Audio recordings are only stored on my home server and desktop computer and will never be uploaded to services or hardware that is owned by another person: not even curated clips, and not even to people who were in the recording. When transcription to text is implemented, this will only be run on my personally owned devices and not on any internet or cloud offering.").build()
)
.field(
EmbedFieldBuilder::new("You won't be \"audited\"", "I will not reference things said in past recordings with the goal of \"making a point\", nor pull them up on the spot (even by the request of the person who said it). Ideally, these are just peace of mind for me that I'm not missing out by not being in a Discord call all the time and can take my life back, so using them in an unhealthy way isn't in my interest.").build()
)
.field(
EmbedFieldBuilder::new("Code is publicly available", "The latest source code is at https://gitea.katniss.top/jacob/fomo-reducer so that I don't have to write guarantees about the technology here (e.g. what data is acquired, how it's used or stored) and you can just check it yourself.").build()
)
.footer(
EmbedFooterBuilder::new("Thanks for your patience and understanding as I have bad and unusual mental health and it's crazy that I need this. This - especially if I learn if I can record streams or webcams so I don't miss out on those experiences either - should be the end of abrasion and force about how we spend our time. Again, thank you, I appreciate it.")
)
.validate() .validate()
.unwrap() .unwrap()
.build() .build()

View File

@@ -16,7 +16,7 @@ use twilight_model::{
}, },
}; };
use crate::{BotDataManager, GuildVoiceChannelToTextChannel, UserDataManager, VCs, track_vcs::VCsWatcher}; use crate::{BotDataManager, GuildVoiceChannelToTextChannel, UserDataManager, VCsWatcher};
pub mod info; pub mod info;
pub mod join; pub mod join;
@@ -33,6 +33,8 @@ pub struct State {
pub discord_application_id: Id<ApplicationMarker>, pub discord_application_id: Id<ApplicationMarker>,
pub discord_bot_owner_user_id: Id<UserMarker>, pub discord_bot_owner_user_id: Id<UserMarker>,
pub discord_client: Arc<twilight_http::Client>, pub discord_client: Arc<twilight_http::Client>,
pub discord_info_command_id: Id<CommandMarker>,
pub discord_info_command_name: Arc<str>,
pub discord_opt_in_command_id: Id<CommandMarker>, pub discord_opt_in_command_id: Id<CommandMarker>,
pub discord_opt_in_command_name: Arc<str>, pub discord_opt_in_command_name: Arc<str>,
pub discord_opt_out_command_id: Id<CommandMarker>, pub discord_opt_out_command_id: Id<CommandMarker>,

View File

@@ -295,6 +295,9 @@ async fn main() -> Result<(), MainError> {
.map(|command| (command.name.clone(), command)), .map(|command| (command.name.clone(), command)),
); );
let discord_info_command = discord_command_name_to_returned_command
.remove(&command::info::COMMAND.name)
.expect("TODO");
let discord_opt_in_command = discord_command_name_to_returned_command let discord_opt_in_command = discord_command_name_to_returned_command
.remove(&command::opt_in::COMMAND.name) .remove(&command::opt_in::COMMAND.name)
.expect("TODO"); .expect("TODO");
@@ -302,9 +305,11 @@ async fn main() -> Result<(), MainError> {
.remove(&command::opt_out::COMMAND.name) .remove(&command::opt_out::COMMAND.name)
.expect("TODO"); .expect("TODO");
let discord_info_command_id = discord_info_command.id.expect("TODO");
let discord_opt_in_command_id = discord_opt_in_command.id.expect("TODO"); let discord_opt_in_command_id = discord_opt_in_command.id.expect("TODO");
let discord_opt_out_command_id = discord_opt_out_command.id.expect("TODO"); let discord_opt_out_command_id = discord_opt_out_command.id.expect("TODO");
let discord_info_command_name = discord_info_command.name.into();
let discord_opt_in_command_name = discord_opt_in_command.name.into(); let discord_opt_in_command_name = discord_opt_in_command.name.into();
let discord_opt_out_command_name = discord_opt_out_command.name.into(); let discord_opt_out_command_name = discord_opt_out_command.name.into();
@@ -348,6 +353,8 @@ async fn main() -> Result<(), MainError> {
discord_application_id, discord_application_id,
discord_bot_owner_user_id, discord_bot_owner_user_id,
discord_client, discord_client,
discord_info_command_id,
discord_info_command_name,
discord_opt_in_command_id, discord_opt_in_command_id,
discord_opt_in_command_name, discord_opt_in_command_name,
discord_opt_out_command_id, discord_opt_out_command_id,