feat(interactions::message): Add the ability to reproxy message

This also cleans up some related APIs.
This commit is contained in:
Suya1671 2025-06-22 17:40:13 +02:00
parent 4005e8167c
commit 17a9622739
No known key found for this signature in database
8 changed files with 367 additions and 16 deletions

View file

@ -159,7 +159,7 @@ impl Member {
fields!(user_id = %user_id.clone());
let Some(system) = models::System::fetch_by_user_id(&user_state.db, &user_id)
let Some(system) = models::System::fetch_by_user_id(&user_id, &user_state.db)
.await
.change_context(CommandError::Sqlx)?
else {
@ -178,7 +178,7 @@ impl Member {
fields!(system_id = %system.id);
let members = system
.get_members(&user_state.db)
.members(&user_state.db)
.await
.change_context(CommandError::Sqlx)?;

View file

@ -88,7 +88,7 @@ impl System {
fields!(user_id = %&user_id);
trace!("Mapped user ID");
let system = models::System::fetch_by_user_id(&user_state.db, &user_id)
let system = models::System::fetch_by_user_id(&user_id, &user_state.db)
.await
.change_context(CommandError::Sqlx)?;
@ -137,7 +137,7 @@ impl System {
let user_state = states.get_user_state::<user::State>().unwrap();
let Some(system_id) =
models::System::fetch_by_user_id(&user_state.db, &event.user_id.into())
models::System::fetch_by_user_id(&event.user_id.into(), &user_state.db)
.await
.change_context(CommandError::Sqlx)?
.map(|s| s.id)
@ -175,7 +175,7 @@ impl System {
let user_state = states.get_user_state::<user::State>().unwrap();
let user_id = user::Id::new(event.user_id);
if let Some(system) = models::System::fetch_by_user_id(&user_state.db, &user_id)
if let Some(system) = models::System::fetch_by_user_id(&user_id, &user_state.db)
.await
.change_context(CommandError::Sqlx)?
{
@ -228,8 +228,8 @@ impl System {
macro_rules! fetch_system {
($event:expr, $user_state:expr => $system_var_name:ident) => {
let Some($system_var_name) = $crate::models::System::fetch_by_user_id(
&$user_state.db,
&$crate::models::user::Id::new($event.user_id),
&$user_state.db,
)
.await
.change_context(CommandError::Sqlx)?

View file

@ -102,7 +102,7 @@ impl Trigger {
let user_state = states.get_user_state::<user::State>().unwrap();
let Some(system_id) =
models::System::fetch_by_user_id(&user_state.db, &user::Id::new(event.user_id))
models::System::fetch_by_user_id(&user::Id::new(event.user_id), &user_state.db)
.await
.change_context(CommandError::Sqlx)?
.map(|system| system.id)

View file

@ -128,7 +128,7 @@ async fn handle_message(
fields!(user_id = ?&user_id);
let Some(mut system) = models::System::fetch_by_user_id(&user_state.db, &user_id)
let Some(mut system) = models::System::fetch_by_user_id(&user_id, &user_state.db)
.await
.change_context(PushEventError::SystemFetch)?
else {

View file

@ -33,7 +33,7 @@ pub async fn create_member(
trace!("Creating member");
let data = member::View::try_from(view_state).change_context(Error::ParsingView)?;
let Some(system_id) = System::fetch_by_user_id(&user_state.db, &user_id)
let Some(system_id) = System::fetch_by_user_id(&user_id, &user_state.db)
.await
.attach_printable("Error checking if system exists")
.change_context(Error::Sqlx)?

View file

@ -6,7 +6,10 @@ use slack_morphism::prelude::*;
use crate::{
BOT_TOKEN, fields,
models::{message, user::State},
models::{
Member, MessageLog, System, Trusted, member,
user::{self, State},
},
};
#[derive(Debug, displaydoc::Display, thiserror::Error)]
@ -30,7 +33,7 @@ pub async fn start_edit(
.message
.expect("Expected message to edit to, well, have a message");
let Some(log) = message::MessageLog::fetch_by_message_id(&message.origin.ts, &user_state.db)
let Some(log) = MessageLog::fetch_by_message_id(&message.origin.ts, &user_state.db)
.await
.change_context(Error::Sqlx)?
else {
@ -108,14 +111,13 @@ pub async fn edit(
) -> Result<(), Error> {
let session = client.open_session(&BOT_TOKEN);
let Some(log) = message::MessageLog::fetch_by_message_id(&message_id, &user_state.db)
let Some(log) = MessageLog::fetch_by_message_id(&message_id, &user_state.db)
.await
.change_context(Error::Sqlx)?
else {
warn!(
"Message not found in database. User is trying to edit a message that isn't sent by us. Bailing since this shouldn't happen"
);
return Ok(());
};
@ -131,7 +133,6 @@ pub async fn edit(
if system.owner_id != user_id {
warn!("User is not the owner of the system. This shouldn't happen. Bailing");
return Ok(());
}
@ -211,3 +212,323 @@ impl TryFrom<SlackViewState> for EditMessageView {
Ok(view)
}
}
#[tracing::instrument(skip_all, fields(trigger_id = ?event.trigger_id))]
pub async fn start_reproxy(
event: SlackInteractionMessageActionEvent,
client: Arc<SlackHyperClient>,
user_state: &State,
) -> Result<(), Error> {
let message = event
.message
.as_ref()
.expect("Expected message to reproxy to, well, have a message");
match MessageLog::fetch_by_message_id(&message.origin.ts, &user_state.db)
.await
.change_context(Error::Sqlx)?
{
Some(log) => start_reproxy_log(log, event, client, user_state).await?,
None => start_reproxy_user(event, client, user_state).await?,
}
Ok(())
}
#[tracing::instrument(skip(client, user_state))]
async fn start_reproxy_user(
event: SlackInteractionMessageActionEvent,
client: Arc<SlackHyperClient>,
user_state: &State,
) -> Result<(), Error> {
let session = client.open_session(&BOT_TOKEN);
let message = event
.message
.expect("Expected message to reproxy to, well, have a message");
let Some(user_id) = message.sender.user.filter(|user| *user == event.user.id) else {
debug!("User is not the owner of the system");
session
.chat_post_ephemeral(&SlackApiChatPostEphemeralRequest::new(
event.channel.unwrap().id,
event.user.id,
SlackMessageContent::new().with_text("This message was not sent by you!".into()),
))
.await
.change_context(Error::Slack)?;
return Ok(());
};
let user_id: user::Id<Trusted> = user_id.into();
let Some(system) = System::fetch_by_user_id(&user_id, &user_state.db)
.await
.change_context(Error::Sqlx)?
else {
debug!("System not found for user");
session
.chat_post_ephemeral(&SlackApiChatPostEphemeralRequest::new(
event.channel.unwrap().id,
event.user.id,
SlackMessageContent::new().with_text("System not found! Make sure you have a system set up. You can use /system create to create one.".into()),
))
.await
.change_context(Error::Slack)?;
return Ok(());
};
let members = system
.members(&user_state.db)
.await
.change_context(Error::Sqlx)?;
let view = ReproxyView { member: None }.create_view(
&members,
&message.origin.ts,
&event.channel.unwrap().id,
);
fields!(view = ?&view);
session
.views_open(&SlackApiViewsOpenRequest::new(event.trigger_id, view))
.await
.change_context(Error::Slack)?;
debug!("Opened view");
Ok(())
}
#[tracing::instrument(skip(event, client, user_state))]
async fn start_reproxy_log(
log: MessageLog,
event: SlackInteractionMessageActionEvent,
client: Arc<SlackHyperClient>,
user_state: &State,
) -> Result<(), Error> {
let session = client.open_session(&BOT_TOKEN);
let system = log
.member_id
.fetch(&user_state.db)
.await
.change_context(Error::Sqlx)?
.system_id
.fetch(&user_state.db)
.await
.change_context(Error::Sqlx)?;
if system.owner_id != event.user.id {
debug!("User is not the owner of the system");
session
.chat_post_ephemeral(&SlackApiChatPostEphemeralRequest::new(
event.channel.unwrap().id,
event.user.id,
SlackMessageContent::new().with_text("This message was not sent by you!".into()),
))
.await
.change_context(Error::Slack)?;
return Ok(());
}
let members = system
.members(&user_state.db)
.await
.change_context(Error::Sqlx)?;
let view = ReproxyView {
member: Some(log.member_id.id),
}
.create_view(&members, &log.message_id, &event.channel.unwrap().id);
fields!(view = ?&view);
session
.views_open(&SlackApiViewsOpenRequest::new(event.trigger_id, view))
.await
.change_context(Error::Slack)?;
debug!("Opened view");
Ok(())
}
pub async fn reproxy(
view_state: SlackViewState,
client: &SlackHyperClient,
user_state: &State,
user_id: SlackUserId,
message_id: SlackTs,
channel_id: SlackChannelId,
) -> Result<(), Error> {
let session = client.open_session(&BOT_TOKEN);
let view = ReproxyView::try_from(view_state).change_context(Error::ParsingView)?;
fields!(view = ?&view);
let Some(id) = view.member.map(member::Id::new) else {
warn!("Missing member on view. This should not happen. bailing");
return Ok(());
};
let Some(system) = System::fetch_by_user_id(&user_id.into(), &user_state.db)
.await
.change_context(Error::Sqlx)?
else {
warn!("System not found for user. This should not happen. bailing");
return Ok(());
};
let Some(id) = id
.validate_by_system(system.id, &user_state.db)
.await
.change_context(Error::Sqlx)?
else {
warn!("Member not found in database. This should not happen. bailing");
return Ok(());
};
let member = id.fetch(&user_state.db).await.change_context(Error::Sqlx)?;
let Ok(messages) = session
.conversations_history(
&SlackApiConversationsHistoryRequest::new()
.with_channel(channel_id.clone())
.with_latest(message_id.clone())
.with_limit(1)
.with_inclusive(true),
)
.await
else {
warn!("Failed to fetch message history");
return Ok(());
};
let Some(message) = messages.messages.first() else {
warn!(?messages, "Message not found in history");
return Ok(());
};
let message_request =
SlackApiChatPostMessageRequest::new(channel_id.clone(), message.content.clone())
.with_username(member.display_name.clone())
.opt_icon_url(member.profile_picture_url.clone());
session
.chat_post_message(&message_request)
.await
.change_context(Error::Slack)?;
let token = SlackApiToken::new(system.slack_oauth_token.expose().into())
.with_token_type(SlackApiTokenType::User);
let user_session = client.open_session(&token);
user_session
.chat_delete(&SlackApiChatDeleteRequest::new(channel_id, message_id))
.await
.change_context(Error::Slack)?;
debug!("Reproxied message");
Ok(())
}
#[derive(Debug, Default, Clone)]
pub struct ReproxyView {
pub member: Option<i64>,
}
impl ReproxyView {
/// Due to the way the slack blocks are created, all fields are moved.
/// Clone the whole struct if you need to keep the original.
pub fn create_blocks(self, members: &[Member]) -> Vec<SlackBlock> {
let options = members
.iter()
.map(|member| {
SlackBlockChoiceItem::<SlackBlockPlainTextOnly>::new(
format!(
"{} ({}, ID: {})",
member.display_name, member.full_name, member.id
)
.into(),
member.id.to_string(),
)
})
.collect();
let value = self.member.and_then(|member_id| {
members
.iter()
.find(|member| member.id.id == member_id)
.map(|member| {
SlackBlockChoiceItem::<SlackBlockPlainTextOnly>::new(
format!(
"{} ({}, ID: {})",
member.display_name, member.full_name, member.id
)
.into(),
member.id.to_string(),
)
})
});
slack_blocks![some_into(
SlackSectionBlock::new()
.with_text(SlackBlockText::Plain("Member".into()))
.with_accessory(
SlackBlockStaticSelectElement::new("member".into())
.with_options(options)
.opt_initial_option(value)
.into()
)
)]
}
pub fn create_view(
self,
members: &[Member],
message_id: &SlackTs,
channel_id: &SlackChannelId,
) -> SlackView {
SlackView::Modal(
SlackModalView::new("Reproxy message".into(), self.create_blocks(members))
.with_submit("Reproxy".into())
.with_external_id(format!("reproxy_message_{}_{}", message_id.0, channel_id.0)),
)
}
}
impl TryFrom<SlackViewState> for ReproxyView {
type Error = MissingFieldError;
fn try_from(value: SlackViewState) -> std::result::Result<Self, Self::Error> {
let mut view = Self::default();
for (_id, values) in value.values {
for (id, content) in values {
match &*id.0 {
"member" => {
view.member = content
.selected_option
.and_then(|option| option.value.parse::<i64>().ok());
}
other => {
warn!("Unknown field in view when parsing a member::View: {other}");
}
}
}
}
if view.member.is_none() {
return Err(MissingFieldError("member".to_string()));
}
Ok(view)
}
}

View file

@ -46,6 +46,14 @@ async fn interaction_event(
)
.await?;
}
"reproxy_message" => {
message::start_reproxy(
message_event,
client,
states.read().await.get_user_state().unwrap(),
)
.await?;
}
id => warn!(id, "Unknown message action callback ID"),
}
Ok(())
@ -135,6 +143,28 @@ async fn handle_modal_view(
handle_user_error(e, user_id.into(), client).await;
}
}
Some(id) if id.starts_with("reproxy_message_") => {
debug!("Received reproxy message modal view");
let stripped = id.strip_prefix("reproxy_message_").unwrap();
let (message_id, channel_id) = stripped.split_once('_').unwrap();
let message_id = SlackTs::new(message_id.to_owned());
let channel_id = SlackChannelId::new(channel_id.to_owned());
if let Err(e) = message::reproxy(
view_state,
&client,
user_state,
user_id.clone().into(),
message_id,
channel_id,
)
.await
{
handle_user_error(e, user_id.into(), client).await;
}
}
Some(id) if id.starts_with("edit_member_") => {
debug!("Received edit member modal view");

View file

@ -136,8 +136,8 @@ pub struct System {
impl System {
#[tracing::instrument(skip(db))]
pub async fn fetch_by_user_id<T>(
db: &SqlitePool,
user_id: &user::Id<T>,
db: &SqlitePool,
) -> Result<Option<Self>, sqlx::Error>
where
T: Trustability,
@ -188,7 +188,7 @@ impl System {
Ok(new_active_member)
}
pub async fn get_members(&self, db: &SqlitePool) -> Result<Vec<Member>, sqlx::Error> {
pub async fn members(&self, db: &SqlitePool) -> Result<Vec<Member>, sqlx::Error> {
sqlx::query_as!(
Member,
r#"