plura/src/interactions/message.rs
Suya1671 1185763ea0
feat: remove system name
It didn't make much sense to have it tbh
2025-07-20 00:25:33 +02:00

688 lines
20 KiB
Rust

use error_stack::{Result, ResultExt};
use std::sync::Arc;
use tracing::{debug, warn};
use slack_morphism::prelude::*;
use crate::{
BOT_TOKEN, fields,
models::{
Member, MessageLog, System, member,
trust::Trusted,
user::{self, State},
},
};
#[derive(Debug, displaydoc::Display, thiserror::Error)]
pub enum Error {
/// Error while calling the Slack API
Slack,
/// Error while calling the database
Sqlx,
/// Unable to parse view
ParsingView,
}
#[tracing::instrument(skip_all, fields(trigger_id = ?event.trigger_id))]
pub async fn start_edit(
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 edit to, well, have a message");
let Some(log) = MessageLog::fetch_by_message_id(&message.origin.ts, &user_state.db)
.await
.change_context(Error::Sqlx)?
else {
debug!(
"Message not found in database. User is trying to edit a message that isn't sent by us. Send an error back"
);
session
.chat_post_ephemeral(&SlackApiChatPostEphemeralRequest::new(
event.channel.unwrap().id,
event.user.id,
SlackMessageContent::new().with_text(
"This message was not sent by a member! Did you maybe want to reproxy instead?"
.into(),
),
))
.await
.change_context(Error::Slack)?;
return Ok(());
};
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 message_content = message.content.text.unwrap_or_default();
let view = EditMessageView {
message: message_content,
}
.create_view(&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(client, user_state))]
pub async fn edit(
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 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(());
};
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 != user_id {
warn!("User is not the owner of the system. This shouldn't happen. Bailing");
return Ok(());
}
let view = EditMessageView::try_from(view_state).change_context(Error::ParsingView)?;
fields!(view = ?&view);
session
.chat_update(&SlackApiChatUpdateRequest::new(
channel_id,
SlackMessageContent::new().with_text(view.message),
message_id,
))
.await
.change_context(Error::Slack)?;
debug!("Edited message");
Ok(())
}
#[derive(Debug, Default, Clone)]
pub struct EditMessageView {
pub message: String,
}
impl EditMessageView {
/// 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) -> Vec<SlackBlock> {
slack_blocks![some_into(SlackInputBlock::new(
// https://github.com/abdolence/slack-morphism-rust/issues/327
"Message (No rich text support. Sorry!)".into(),
SlackBlockPlainTextInputElement::new("message".into())
.with_initial_value(self.message)
.into(),
))]
}
pub fn create_view(self, message_id: &SlackTs, channel_id: &SlackChannelId) -> SlackView {
SlackView::Modal(
SlackModalView::new("Edit message".into(), self.create_blocks())
.with_submit("Edit".into())
.with_external_id(format!("edit_message_{}_{}", message_id.0, channel_id.0)),
)
}
}
#[derive(thiserror::Error, displaydoc::Display, Debug)]
/// A field was missing from the view
pub struct MissingFieldError(String);
impl TryFrom<SlackViewState> for EditMessageView {
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 {
"message" => {
view.message = content
.value
.ok_or_else(|| MissingFieldError("message".to_string()))?;
}
other => {
warn!("Unknown field in view when parsing a member::View: {other}");
}
}
}
}
if view.message.is_empty() {
return Err(MissingFieldError("message".to_string()));
}
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)
}
}
#[tracing::instrument(skip(client, user_state))]
pub async fn delete(
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 edit to, well, have a message");
let Some(log) = MessageLog::fetch_by_message_id(&message.origin.ts, &user_state.db)
.await
.change_context(Error::Sqlx)?
else {
debug!(
"Message not found in database. User is trying to delete a message that isn't sent by us."
);
session
.chat_post_ephemeral(&SlackApiChatPostEphemeralRequest::new(
event.channel.unwrap().id,
event.user.id,
SlackMessageContent::new().with_text("A member didn't send this message.".into()),
))
.await
.change_context(Error::Slack)?;
return Ok(());
};
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 {
session
.chat_post_ephemeral(&SlackApiChatPostEphemeralRequest::new(
event.channel.unwrap().id,
event.user.id,
SlackMessageContent::new()
.with_text("Your system didn't send this message.".into()),
))
.await
.change_context(Error::Slack)?;
return Ok(());
}
session
.chat_delete(&SlackApiChatDeleteRequest::new(
event.channel.unwrap().id,
message.origin.ts,
))
.await
.change_context(Error::Slack)?;
debug!("Deleted message");
Ok(())
}
#[tracing::instrument(skip(client, user_state))]
pub async fn info(
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 edit to, well, have a message");
let Some(log) = MessageLog::fetch_by_message_id(&message.origin.ts, &user_state.db)
.await
.change_context(Error::Sqlx)?
else {
debug!(
"Message not found in database. User is trying to get information about a message that isn't sent by us."
);
session
.chat_post_ephemeral(&SlackApiChatPostEphemeralRequest::new(
event.channel.unwrap().id,
event.user.id,
SlackMessageContent::new().with_text("A member didn't send this message.".into()),
))
.await
.change_context(Error::Slack)?;
return Ok(());
};
let member = log
.member_id
.fetch(&user_state.db)
.await
.change_context(Error::Sqlx)?;
let system = member
.system_id
.fetch(&user_state.db)
.await
.change_context(Error::Sqlx)?;
let blocks = slack_blocks![
some_into(SlackHeaderBlock::new(member.full_name.into())),
some_into(SlackDividerBlock::new()),
some_into(
SlackSectionBlock::new()
.with_text(md!(
"*{}*\n{}{}\n*System*: {}",
member.display_name,
member.pronouns.unwrap_or_default(),
member
.name_pronunciation
.map(|pronunciation| format!(" - {pronunciation}"))
.unwrap_or_default(),
system.owner_id.to_slack_format()
))
.opt_accessory(member.profile_picture_url.and_then(|url| Some(
SlackSectionBlockElement::Image(SlackBlockImageElement::new(
url.parse().ok()?,
"Profile picture".into()
))
)))
),
optionally_into(system.currently_fronting_member_id.is_some_and(|id| id == member.id) => SlackSectionBlock::new().with_text(md!("*Fronting*")))
// TO-DO: fields
];
session
.chat_post_ephemeral(&SlackApiChatPostEphemeralRequest::new(
event.channel.unwrap().id,
event.user.id,
SlackMessageContent::new().with_blocks(blocks),
))
.await
.change_context(Error::Slack)?;
debug!("Deleted message");
Ok(())
}