feat(triggers): change triggers to edit entirely through commands

This commit is contained in:
Suya1671 2025-06-21 13:22:42 +02:00
parent dcf078f3fa
commit e2e9b05845
No known key found for this signature in database
6 changed files with 89 additions and 466 deletions

View file

@ -49,7 +49,7 @@ impl Command {
.await
.change_context(CommandError::System),
Self::Triggers(triggers) => triggers
.run(event, client, state)
.run(event, state)
.await
.change_context(CommandError::Triggers),
Self::Aliases(aliases) => aliases

View file

@ -1,11 +1,9 @@
use std::sync::Arc;
use error_stack::{Result, ResultExt};
use slack_morphism::prelude::*;
use tracing::debug;
use crate::{
BOT_TOKEN, fetch_member, fetch_system, fields,
fetch_member, fetch_system, fields,
models::{self, Untrusted, member::MemberRef, trigger, user},
};
@ -15,6 +13,11 @@ pub enum Trigger {
Add {
/// The member to add the trigger for.
member: MemberRef,
/// The type of trigger
#[clap(name = "type")]
typ: trigger::Type,
/// The trigger content
content: String,
},
/// Deletes a trigger
Delete {
@ -30,13 +33,17 @@ pub enum Trigger {
Edit {
/// The trigger to edit. Use the trigger id from /trigger list
id: trigger::Id<Untrusted>,
/// The type of trigger
#[clap(name = "type", long = "type", short)]
typ: Option<trigger::Type>,
/// The trigger content
#[clap(long, short)]
content: Option<String>,
},
}
#[derive(thiserror::Error, displaydoc::Display, Debug)]
pub enum CommandError {
/// Error while calling the Slack API
Slack,
/// Error while calling the database
Sqlx,
}
@ -46,64 +53,43 @@ impl Trigger {
pub async fn run(
self,
event: SlackCommandEvent,
client: Arc<SlackHyperClient>,
state: SlackClientEventsUserState,
) -> Result<SlackCommandEventResponse, CommandError> {
match self {
Self::Add { member } => {
let token = &BOT_TOKEN;
let session = client.open_session(token);
Self::create_trigger(event, &state, session, member).await
}
Self::Add {
member,
typ,
content,
} => Self::create_trigger(event, &state, member, typ, content).await,
Self::Delete { id } => Self::delete_trigger(event, &state, id).await,
Self::List { member } => Self::list_triggers(event, &state, member).await,
Self::Edit { id } => {
let token = &BOT_TOKEN;
let session = client.open_session(token);
Self::edit_trigger(event, &state, session, id).await
Self::Edit { id, typ, content } => {
Self::edit_trigger(event, &state, id, typ, content).await
}
}
}
#[tracing::instrument(skip(event, state, session), fields(system_id, member_id))]
#[tracing::instrument(skip(event, state), fields(system_id, member_id))]
async fn create_trigger(
event: SlackCommandEvent,
state: &SlackClientEventsUserState,
session: SlackClientSession<'_, SlackClientHyperHttpsConnector>,
member_id: MemberRef,
typ: trigger::Type,
content: String,
) -> Result<SlackCommandEventResponse, CommandError> {
let states = state.read().await;
let user_state = states.get_user_state::<user::State>().unwrap();
fetch_system!(event, user_state => system_id);
fetch_member!(member_id, user_state, system_id => member_id);
let Some(member_id) = member_id
.validate_by_system(system_id, &user_state.db)
models::Trigger::insert(member_id, system_id, typ, content, &user_state.db)
.await
.change_context(CommandError::Sqlx)?
else {
debug!("Member not found");
return Ok(SlackCommandEventResponse::new(
SlackMessageContent::new()
.with_text("Member not found. Make sure you used the correct ID".into()),
));
};
.change_context(CommandError::Sqlx)?;
fields!(member_id = %member_id);
let view = trigger::View::default().create_add_view(member_id);
let view = session
.views_open(&SlackApiViewsOpenRequest::new(
event.trigger_id.clone(),
view,
))
.await
.attach_printable("Error opening view")
.change_context(CommandError::Slack)?;
debug!(?view, "Opened view");
Ok(SlackCommandEventResponse::new(SlackMessageContent::new()))
Ok(SlackCommandEventResponse::new(
SlackMessageContent::new().with_text("Trigger created!".into()),
))
}
#[tracing::instrument(skip(event, state), fields(system_id))]
@ -142,14 +128,13 @@ impl Trigger {
));
};
// Fetch the trigger to delete it
trigger_id
.delete(&user_state.db)
.await
.change_context(CommandError::Sqlx)?;
Ok(SlackCommandEventResponse::new(
SlackMessageContent::new().with_text("Successfully deleted trigger!".into()),
SlackMessageContent::new().with_text("Deleted trigger!".into()),
))
}
@ -191,13 +176,12 @@ impl Trigger {
.into_iter()
.map(|trigger| {
let fields = vec![
md!("Trigger ID: {}", trigger.id),
md!("Member ID: {}", trigger.member_id),
md!("{}: {}", trigger.typ, trigger.text),
];
SlackSectionBlock::new()
.with_text(md!("**Trigger {}**", trigger.id))
.with_text(md!("*Trigger {}*", trigger.id))
.with_fields(fields)
})
.map(Into::into)
@ -208,12 +192,13 @@ impl Trigger {
))
}
#[tracing::instrument(skip(event, state, session), fields(system_id))]
#[tracing::instrument(skip(event, state), fields(system_id))]
pub async fn edit_trigger(
event: SlackCommandEvent,
state: &SlackClientEventsUserState,
session: SlackClientSession<'_, SlackClientHyperHttpsConnector>,
trigger_id: trigger::Id<Untrusted>,
typ: Option<trigger::Type>,
text: Option<String>,
) -> Result<SlackCommandEventResponse, CommandError> {
let states = state.read().await;
let user_state = states.get_user_state::<user::State>().unwrap();
@ -233,32 +218,13 @@ impl Trigger {
fields!(trigger_id = %trigger_id);
// Fetch the trigger to edit
let trigger = models::Trigger::fetch_by_id(trigger_id, &user_state.db)
trigger_id
.update(typ, text, &user_state.db)
.await
.change_context(CommandError::Sqlx)?;
let Some(trigger) = trigger else {
debug!("Trigger not found");
return Ok(SlackCommandEventResponse::new(
SlackMessageContent::new()
.with_text("Trigger not found. Make sure you used the correct ID".into()),
));
};
let view = trigger::View::from(trigger).create_edit_view(trigger_id);
let view = session
.views_open(&SlackApiViewsOpenRequest::new(
event.trigger_id.clone(),
view,
))
.await
.attach_printable("Error opening view")
.change_context(CommandError::Slack)?;
debug!(?view, "Opened view");
Ok(SlackCommandEventResponse::new(SlackMessageContent::new()))
Ok(SlackCommandEventResponse::new(
SlackMessageContent::new().with_text("Updated trigger!".into()),
))
}
}

View file

@ -109,7 +109,7 @@ async fn push_event_callback(
}
}
#[tracing::instrument(skip_all)]
#[tracing::instrument(skip(client, state))]
async fn handle_message(
message_event: SlackMessageEvent,
client: &SlackHyperClient,
@ -156,6 +156,7 @@ async fn handle_message(
.await
.change_context(PushEventError::MemberFetch)?
else {
debug!("Member not triggered");
return Ok(());
};

View file

@ -1,5 +1,4 @@
mod member;
mod trigger;
use std::error::Error;
use std::sync::Arc;
@ -8,10 +7,8 @@ use error_stack::Report;
use member::{create_member, edit_member};
use slack_morphism::prelude::*;
use tracing::{debug, error};
use trigger::{create_trigger, edit_trigger};
use crate::BOT_TOKEN;
use crate::models::system::System;
use crate::models::{self, Trusted, user};
#[tracing::instrument(skip(event, environment))]
@ -141,84 +138,6 @@ async fn handle_modal_view(
handle_user_error(error, user_id.into(), client).await;
}
}
Some(id) if id.starts_with("create_trigger_") => {
debug!("Creating trigger");
let member_id = id
.strip_prefix("create_trigger_")
.expect("Failed to parse member id from external id")
.parse::<i64>()
.map(models::member::Id::new)
.expect("Failed to parse member id from external id");
// TO-DO: Better handling of Err case
let Ok(Some(trusted_member_id)) =
member_id.validate_by_user(&user_id, &user_state.db).await
else {
error!(
id,
"Failed to validate member id from external id. Bailing in case this was a malicious call",
);
return;
};
if let Err(error) = create_trigger(
view_state,
&client,
user_state,
user_id.clone(),
trusted_member_id,
)
.await
{
handle_user_error(error, user_id.into(), client).await;
};
}
Some(id) if id.starts_with("edit_trigger_") => {
debug!("Editing trigger");
let trigger_id = id
.strip_prefix("edit_trigger_")
.expect("Failed to parse member id from external id")
.parse::<i64>()
.map(models::trigger::Id::new)
.expect("Failed to parse member id from external id");
let Some(system) = System::fetch_by_user_id(&user_state.db, &user_id)
.await
.ok()
.flatten()
else {
error!(
%user_id,
"Failed to fetch system id for user id. Bailing in case this was a malicious call"
);
return;
};
let Ok(trusted_trigger_id) = trigger_id
.validate_by_system(system.id, &user_state.db)
.await
else {
error!(
"Failed to validate member id from external id {}. Bailing in case this was a malicious call",
id
);
return;
};
if let Err(error) = edit_trigger(
view_state,
&client,
user_state,
user_id.clone(),
trusted_trigger_id,
)
.await
{
handle_user_error(error, user_id.into(), client.clone()).await;
}
}
Some(id) => {
error!("receieved unknown external id: {id}");
}

View file

@ -1,109 +0,0 @@
use error_stack::{Result, ResultExt, bail};
use slack_morphism::prelude::*;
use tracing::debug;
use crate::{
BOT_TOKEN, fields,
models::{
Trusted, member,
system::System,
trigger,
user::{self, State},
},
};
#[derive(thiserror::Error, displaydoc::Display, Debug)]
pub enum Error {
/// Error while calling the database
Sqlx,
/// Error while calling the Slack API
Slack,
/// No system found for the user
NoSystem,
}
#[tracing::instrument(skip(view_state, client, user_state), fields(system_id))]
pub async fn create_trigger(
view_state: SlackViewState,
client: &SlackHyperClient,
user_state: &State,
user_id: user::Id<Trusted>,
member_id: member::Id<Trusted>,
) -> Result<(), Error> {
let data = trigger::View::from(view_state);
let Some(system_id) = System::fetch_by_user_id(&user_state.db, &user_id)
.await
.attach_printable("Error checking if system exists")
.change_context(Error::Sqlx)?
.map(|system| system.id)
else {
bail!(Error::NoSystem);
};
fields!(system_id = %system_id);
let id = data
.add(system_id, member_id, &user_state.db)
.await
.change_context(Error::Sqlx)?;
fields!(id = %id);
debug!("Trigger created");
let session = client.open_session(&BOT_TOKEN);
let user: SlackUserId = user_id.into();
let conversation = session
.conversations_open(&SlackApiConversationsOpenRequest::new().with_users(vec![user.clone()]))
.await
.change_context(Error::Slack)?
.channel;
session
.chat_post_ephemeral(&SlackApiChatPostEphemeralRequest::new(
conversation.id,
user,
SlackMessageContent::new().with_text("Successfully added trigger!".into()),
))
.await
.change_context(Error::Slack)?;
Ok(())
}
#[tracing::instrument(skip(view_state, client, user_state))]
pub async fn edit_trigger(
view_state: SlackViewState,
client: &SlackHyperClient,
user_state: &State,
user_id: user::Id<Trusted>,
trigger_id: trigger::Id<Trusted>,
) -> Result<(), Error> {
let trigger_view = trigger::View::from(view_state);
trigger_view
.update(trigger_id, &user_state.db)
.await
.change_context(Error::Sqlx)?;
let session = client.open_session(&BOT_TOKEN);
let user: SlackUserId = user_id.into();
let conversation = session
.conversations_open(&SlackApiConversationsOpenRequest::new().with_users(vec![user.clone()]))
.await
.change_context(Error::Slack)?
.channel;
session
.chat_post_ephemeral(&SlackApiChatPostEphemeralRequest::new(
conversation.id,
user,
SlackMessageContent::new().with_text("Successfully edited trigger!".into()),
))
.await
.change_context(Error::Slack)?;
Ok(())
}

View file

@ -2,11 +2,9 @@ use std::str::FromStr;
use crate::id;
use super::{Trustability, Trusted, Untrusted, member, system};
use super::{Trusted, Untrusted, member, system};
use error_stack::{Result, ResultExt};
use slack_morphism::prelude::*;
use sqlx::{SqlitePool, prelude::*, sqlite::SqliteQueryResult};
use tracing::{debug, warn};
id!(
/// For an ID to be trusted, it must
@ -17,13 +15,6 @@ id!(
);
impl Id<Untrusted> {
pub const fn new(id: i64) -> Self {
Self {
id,
trusted: std::marker::PhantomData,
}
}
#[tracing::instrument(skip(db))]
pub async fn validate_by_system(
self,
@ -59,15 +50,36 @@ impl Id<Trusted> {
.await
.attach_printable("Error deleting trigger")
}
#[tracing::instrument(skip(db))]
pub async fn update(
self,
typ: Option<Type>,
content: Option<String>,
db: &SqlitePool,
) -> error_stack::Result<Self, sqlx::Error> {
sqlx::query!(
r#"
UPDATE triggers
SET
typ = coalesce($2, typ),
text = coalesce($3, text)
WHERE id = $1
RETURNING
id as "id: Id<Trusted>"
"#,
self,
typ,
content
)
.fetch_one(db)
.await
.attach_printable("Failed to update trigger")
.map(|record| record.id)
}
}
#[derive(thiserror::Error, displaydoc::Display, Debug)]
pub enum Error {
/// Error while calling the database
Sqlx,
}
#[derive(Debug, sqlx::Type, displaydoc::Display, PartialEq, Eq)]
#[derive(Debug, sqlx::Type, displaydoc::Display, PartialEq, Eq, clap::ValueEnum, Clone, Copy)]
#[repr(i64)]
pub enum Type {
/// Suffix
@ -115,30 +127,6 @@ pub struct Trigger {
}
impl Trigger {
pub async fn fetch_by_id<T>(id: Id<T>, db: &SqlitePool) -> Result<Option<Self>, sqlx::Error>
where
T: Trustability,
{
sqlx::query_as!(
Trigger,
r#"
SELECT
id as "id: Id<Trusted>",
member_id as "member_id: member::Id<Trusted>",
system_id as "system_id: system::Id<Trusted>",
text,
typ
FROM
triggers
WHERE id = $1
"#,
id.id,
)
.fetch_optional(db)
.await
.attach_printable("Error fetching trigger")
}
#[tracing::instrument(skip(db))]
pub async fn fetch_by_system_id(
system_id: system::Id<Trusted>,
@ -189,176 +177,34 @@ impl Trigger {
.await
.attach_printable("Error fetching triggers")
}
}
#[derive(Debug)]
pub struct View {
pub text: String,
pub typ: Type,
}
impl Default for View {
fn default() -> Self {
Self {
text: String::new(),
typ: Type::Prefix,
}
}
}
impl View {
pub fn create_blocks(self) -> Vec<SlackBlock> {
let prefix_choice = SlackBlockChoiceItem::new(
SlackBlockText::Plain(Type::Prefix.to_string().into()),
Type::Prefix.to_string(),
);
let suffix_choice = SlackBlockChoiceItem::new(
SlackBlockText::Plain(Type::Suffix.to_string().into()),
Type::Suffix.to_string(),
);
slack_blocks!(
some_into(
SlackHeaderBlock::new("Trigger settings".into())
.with_block_id("trigger_settings".into())
),
some_into(
SlackInputBlock::new(
"Trigger Text".into(),
SlackBlockPlainTextInputElement::new("trigger_text".into())
.with_initial_value(self.text)
.into(),
)
.with_optional(false)
),
some_into(
SlackInputBlock::new(
"Trigger Type".into(),
SlackBlockRadioButtonsElement::new(
"type".into(),
vec![prefix_choice, suffix_choice]
)
.with_initial_option(SlackBlockChoiceItem::new(
SlackBlockText::Plain(Type::Prefix.to_string().into()),
Type::Prefix.to_string(),
))
.into(),
)
.with_optional(false)
)
)
}
/// Add a trigger to the database
///
/// Returns the id of the new trigger
#[tracing::instrument(skip(db))]
pub async fn add(
&self,
system_id: system::Id<Trusted>,
pub async fn insert(
member_id: member::Id<Trusted>,
system_id: system::Id<Trusted>,
typ: Type,
content: String,
db: &SqlitePool,
) -> error_stack::Result<Id<Trusted>, Error> {
debug!(
"Adding trigger for {} (Member ID {}) to database",
system_id, member_id
);
sqlx::query!(
) -> error_stack::Result<Self, sqlx::Error> {
sqlx::query_as!(
Self,
r#"
INSERT INTO triggers (system_id, member_id, text, typ)
INSERT INTO triggers (member_id, system_id, typ, text)
VALUES ($1, $2, $3, $4)
RETURNING id
RETURNING
id as "id: Id<Trusted>",
member_id as "member_id: member::Id<Trusted>",
system_id as "system_id: system::Id<Trusted>",
typ,
text
"#,
system_id.id,
member_id.id,
self.text,
self.typ
member_id,
system_id,
typ,
content
)
.fetch_one(db)
.await
.attach_printable("Error adding trigger to database")
.change_context(Error::Sqlx)
.map(|row| Id {
id: row.id,
trusted: std::marker::PhantomData,
})
}
/// Update a trigger in the database to match this view
#[tracing::instrument(skip(db))]
pub async fn update(
&self,
trigger_id: Id<Trusted>,
db: &SqlitePool,
) -> Result<SqliteQueryResult, sqlx::Error> {
sqlx::query!(
r#"
UPDATE triggers
SET text = $1, typ = $2
WHERE id = $3
"#,
self.text,
self.typ,
trigger_id.id,
)
.execute(db)
.await
.attach_printable("Error updating trigger in database")
}
pub fn create_add_view(self, member_id: member::Id<Trusted>) -> SlackView {
SlackView::Modal(
SlackModalView::new("Add a new trigger".into(), self.create_blocks())
.with_submit("Add".into())
.with_external_id(format!("create_trigger_{}", member_id.id)),
)
}
pub fn create_edit_view(self, trigger_id: Id<Trusted>) -> SlackView {
SlackView::Modal(
SlackModalView::new("Edit trigger".into(), self.create_blocks())
.with_submit("Save".into())
.with_external_id(format!("edit_trigger_{}", trigger_id.id)),
)
}
}
impl From<SlackViewState> for View {
fn from(value: SlackViewState) -> Self {
let mut view = Self::default();
for (_id, values) in value.values {
for (id, content) in values {
match &*id.0 {
"trigger_text" => {
if let Some(text) = content.value {
view.text = text;
}
}
"typ" => {
if let Some(option) = content.selected_option {
match option.value.parse::<Type>() {
Ok(typ) => view.typ = typ,
Err(error) => warn!(?error, "Error parsing trigger type"),
}
}
}
other => {
warn!("Unknown field in view when parsing a trigger::View: {other}");
}
}
}
}
view
}
}
impl From<Trigger> for View {
fn from(trigger: Trigger) -> Self {
Self {
text: trigger.text,
typ: trigger.typ,
}
.attach_printable("Failed to insert trigger into database")
}
}