feat: initial commit

This commit is contained in:
Suya1671 2025-06-08 21:12:07 +02:00
commit 7ee71d4a95
No known key found for this signature in database
30 changed files with 7257 additions and 0 deletions

9
.env.example Executable file
View file

@ -0,0 +1,9 @@
SLACK_APP_TOKEN=
SLACK_BOT_TOKEN=
SLACK_CLIENT_ID=
SLACK_CLIENT_SECRET=
SLACK_SIGNING_SECRET=
# make sure to enable encryption in the feature flags to use this!
# highly recommened for production
# ENCRYPTION_KEY=
DATABASE_URL=sqlite://slackbot.db

18
.gitignore vendored Executable file
View file

@ -0,0 +1,18 @@
/target
result*
.envrc
.env
!.env.example
# Devenv
.devenv*
devenv.local.nix
# direnv
.direnv
# pre-commit
.pre-commit-config.yaml
# sqlite dev files
*.db

3719
Cargo.lock generated Executable file

File diff suppressed because it is too large Load diff

45
Cargo.toml Executable file
View file

@ -0,0 +1,45 @@
[package]
name = "slack-system-bot"
version = "0.1.0"
edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
axum = "0.8.4"
clap = { version = "4.5.39", features = ["derive"] }
displaydoc = "0.2.5"
error-stack = { version = "0.5.0", features = [
"eyre",
"hooks",
"serde",
"spantrace",
] }
eyre = "0.6.12"
http-body-util = "0.1.3"
menv = "0.2.7"
oauth2 = "5.0.0"
redact = "0.1.10"
rustls = "0.23.27"
serde = { version = "1.0.219", features = ["derive"] }
slack-morphism = { version = "2.12.0", features = ["axum"] }
sqlx = { version = "0.8.6", features = [
"runtime-tokio",
"sqlite",
"sqlite-preupdate-hook",
"migrate",
"time",
] }
libsqlite3-sys = { version = "0.30.1" }
thiserror = "2.0.12"
time = "0.3.41"
tokio = { version = "1.45.1", features = ["rt", "macros", "rt-multi-thread"] }
tracing = "0.1.41"
tracing-error = "0.2.1"
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
dotenvy = { git = "https://github.com/allan2/dotenvy", features = ["macros"] }
url = "2.5.4"
serde_json = "1.0.140"
[features]
encrypt = ["libsqlite3-sys/bundled-sqlcipher"]

2
README.md Executable file
View file

@ -0,0 +1,2 @@
# slack-system-bot
A bot of all time for plural folks :D

5
build.rs Executable file
View file

@ -0,0 +1,5 @@
// generated by `sqlx migrate build-script`
fn main() {
// trigger recompilation when a new migration is added
println!("cargo:rerun-if-changed=migrations");
}

View file

@ -0,0 +1,9 @@
-- Add migration script here
CREATE TABLE systems (
id INTEGER NOT NULL PRIMARY KEY,
owner_id TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
slack_oauth_token TEXT NOT NULL,
-- unix timestamp
created_at INTEGER DEFAULT CURRENT_TIMESTAMP NOT NULL
);

View file

@ -0,0 +1,22 @@
-- Add migration script here
CREATE TABLE members (
id INTEGER NOT NULL PRIMARY KEY,
-- shown in extended info
full_name TEXT NOT NULL,
-- shown on messages
display_name TEXT NOT NULL,
-- shown on messages
profile_picture_url TEXT,
-- shown in extended info
title TEXT,
-- shown in extended info
pronouns TEXT,
-- shown in extended info
name_pronunciation TEXT,
-- shown in extended info
name_recording_url TEXT,
system_id INTEGER NOT NULL,
-- unix timestamp
created_at INTEGER DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (system_id) REFERENCES systems (id)
);

View file

@ -0,0 +1,71 @@
-- Add migration script here
-- The current fronting member. If null, no member we have is fronting and the raw account messages are used
ALTER TABLE systems
ADD COLUMN active_member_id INTEGER REFERENCES members (id);
-- If a trigger is used, should the active member be changed to the new member?
ALTER TABLE systems
ADD COLUMN trigger_changes_active_member BOOLEAN DEFAULT FALSE NOT NULL;
-- note: prefix and suffix can both happen on the same trigger. It means either will trigger the switch
CREATE TABLE triggers (
id INTEGER NOT NULL PRIMARY KEY,
-- The member that will front
member_id INTEGER NOT NULL REFERENCES members (id),
-- The prefix that will trigger this member
prefix TEXT,
-- The suffix that will trigger this member
suffix TEXT,
system_id INTEGER NOT NULL,
-- Create unique constraints using the system_id from the member table
CONSTRAINT unique_prefix UNIQUE (system_id, prefix),
CONSTRAINT unique_suffix UNIQUE (system_id, suffix)
);
-- ensure system id is the same as the system id on member
CREATE TRIGGER ensure_system_id BEFORE INSERT ON triggers FOR EACH ROW BEGIN
SELECT
RAISE (
ABORT,
'system_id must be the same as the system_id on member'
)
WHERE
NEW.system_id != (
SELECT
system_id
FROM
members
WHERE
id = NEW.member_id
);
END;
CREATE TRIGGER ensure_system_id_update BEFORE
UPDATE ON triggers FOR EACH ROW BEGIN
SELECT
RAISE (
ABORT,
'system_id must be the same as the system_id on member'
)
WHERE
NEW.system_id != (
SELECT
system_id
FROM
members
WHERE
id = NEW.member_id
);
END;
CREATE TRIGGER ensure_system_id_update_members_table BEFORE
UPDATE ON members FOR EACH ROW BEGIN
UPDATE triggers
SET
system_id = NEW.system_id
WHERE
member_id = NEW.id;
END;

View file

@ -0,0 +1,3 @@
-- Add migration script here
-- Adds an index on systems.owner_id (which also means 1 system per owner. Probably fine)
CREATE UNIQUE INDEX systems_owner_index ON systems (owner_id);

View file

@ -0,0 +1,7 @@
-- Add migration script here
CREATE TABLE system_oauth_process (
id INTEGER NOT NULL PRIMARY KEY,
owner_id TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
csrf TEXT NOT NULL
);

View file

@ -0,0 +1,100 @@
-- Add migration script here
-- Consolidate prefix and suffix into a single trigger field with a boolean to indicate type
-- First, drop the trigger that references the triggers table
DROP TRIGGER IF EXISTS ensure_system_id_update_members_table;
-- Create a new table with the updated schema
CREATE TABLE triggers_new (
id INTEGER NOT NULL PRIMARY KEY,
-- The member that will front
member_id INTEGER NOT NULL REFERENCES members (id),
-- The trigger text. This will be the prefix or suffix depending on the is_prefix flag
trigger_text TEXT NOT NULL,
-- True if this is a prefix trigger, false if suffix
is_prefix BOOLEAN NOT NULL,
system_id INTEGER NOT NULL,
-- Create unique constraints using the system_id and trigger type
CONSTRAINT unique_trigger UNIQUE (system_id, trigger_text, is_prefix)
);
-- Migrate existing data from the old table
INSERT INTO
triggers_new (member_id, trigger_text, is_prefix, system_id)
SELECT
member_id,
prefix,
TRUE,
system_id
FROM
triggers
WHERE
prefix IS NOT NULL;
INSERT INTO
triggers_new (member_id, trigger_text, is_prefix, system_id)
SELECT
member_id,
suffix,
FALSE,
system_id
FROM
triggers
WHERE
suffix IS NOT NULL;
-- Recreate original state/names
DROP TRIGGER IF EXISTS ensure_system_id;
DROP TRIGGER IF EXISTS ensure_system_id_update;
DROP TABLE triggers;
ALTER TABLE triggers_new
RENAME TO triggers;
CREATE TRIGGER ensure_system_id BEFORE INSERT ON triggers FOR EACH ROW BEGIN
SELECT
RAISE (
ABORT,
'system_id must be the same as the system_id on member'
)
WHERE
NEW.system_id != (
SELECT
system_id
FROM
members
WHERE
id = NEW.member_id
);
END;
CREATE TRIGGER ensure_system_id_update BEFORE
UPDATE ON triggers FOR EACH ROW BEGIN
SELECT
RAISE (
ABORT,
'system_id must be the same as the system_id on member'
)
WHERE
NEW.system_id != (
SELECT
system_id
FROM
members
WHERE
id = NEW.member_id
);
END;
CREATE TRIGGER ensure_system_id_update_members_table BEFORE
UPDATE ON members FOR EACH ROW BEGIN
UPDATE triggers
SET
system_id = NEW.system_id
WHERE
member_id = NEW.id;
END;

View file

@ -0,0 +1,3 @@
-- Add migration script here
ALTER TABLE triggers
RENAME COLUMN trigger_text TO text;

3
rust-toolchain.toml Executable file
View file

@ -0,0 +1,3 @@
[toolchain]
channel = "stable"
components = ["rustfmt", "rust-src", "rust-analyzer"]

342
src/commands/members.rs Executable file
View file

@ -0,0 +1,342 @@
use std::sync::Arc;
use error_stack::{Result, ResultExt, report};
use slack_morphism::prelude::*;
use tracing::{debug, info};
use crate::{
BOT_TOKEN,
commands::members,
models::{
member::{self, Member, View},
system::{ChangeActiveMemberError, System},
user,
},
};
#[derive(clap::Subcommand, Debug)]
pub enum Members {
/// Adds a new member to your system. Expect a popup to fill in the member info!
Add,
/// Deletes a member from your system. Use the member id from /member list
Delete {
/// The member to delete
member: i64,
},
/// Gets info about a member
Info {
/// The member to get info about. Use the member id from /member list
member_id: i64,
},
/// Lists all members in a system
List {
/// The system to list members from. If left blank, defaults to your system.
system: Option<String>,
},
/// Edits a member's info
Edit {
/// The member to edit. Use the member id from /member list. Expect a popup to edit the info!
member_id: i64,
},
/// Switch to a different member
#[group(required = true)]
Switch {
/// The member to switch to. Use the member id from /member list
#[clap(group = "member")]
member_id: Option<i64>,
/// Don't switch to another member, just message with the base account
#[clap(long, short, action, group = "member", alias = "none")]
base: bool,
},
}
#[derive(thiserror::Error, displaydoc::Display, Debug)]
pub enum CommandError {
/// Error while calling the Slack API
Slack,
/// Error while calling the database
Sqlx,
}
impl Members {
pub async fn run(
self,
event: SlackCommandEvent,
client: Arc<SlackHyperClient>,
state: SlackClientEventsUserState,
) -> Result<SlackCommandEventResponse, CommandError> {
match self {
Self::Add => {
let token = &BOT_TOKEN;
let session = client.open_session(token);
Self::create_member(event, session).await
}
Self::Delete { member } => {
info!("Deleting member {member}");
Ok(SlackCommandEventResponse::new(
SlackMessageContent::new().with_text("Working on it".into()),
))
}
Self::Info { member_id } => Self::member_info(event, &state, member_id).await,
Self::Edit { member_id } => {
Self::edit_member(event, client.open_session(&BOT_TOKEN), &state, member_id).await
}
Self::List { system } => Self::list_members(event, state, system).await,
Self::Switch { member_id, base } => {
Self::switch_member(event, state, member_id, base).await
}
}
}
async fn switch_member(
event: SlackCommandEvent,
state: SlackClientEventsUserState,
member_id: Option<i64>,
base: bool,
) -> Result<SlackCommandEventResponse, CommandError> {
let states = state.read().await;
let user_state = states.get_user_state::<user::State>().unwrap();
let Some(mut system) = System::fetch_by_user_id(&user_state.db, &event.user_id.into())
.await
.change_context(CommandError::Sqlx)?
else {
return Ok(SlackCommandEventResponse::new(
SlackMessageContent::new().with_text("You don't have a system yet!".into()),
));
};
let new_active_member_id = if base {
None
} else {
member::Id::new(
member_id.expect("member_id to be Some, as the clap rules require it to be."),
)
.validate_by_system(system.id, &user_state.db)
.await
.ok()
};
let new_member = system
.change_active_member(new_active_member_id, &user_state.db)
.await;
let response = match new_member {
Ok(Some(member)) => format!("Switch to member {}", member.full_name),
Ok(None) => "Switched to base account".into(),
Err(ChangeActiveMemberError::MemberNotFound) => {
"The member you gave doesn't exist!".into()
}
Err(ChangeActiveMemberError::Sqlx(err)) => {
return Err(report!(err).change_context(CommandError::Sqlx));
}
};
Ok(SlackCommandEventResponse::new(
SlackMessageContent::new().with_text(response),
))
}
async fn list_members(
event: SlackCommandEvent,
state: SlackClientEventsUserState,
system: Option<String>,
) -> Result<SlackCommandEventResponse, CommandError> {
let states = state.read().await;
let user_state = states.get_user_state::<user::State>().unwrap();
// If the input exists, parse it into a user ID
// If it doesn't exist, use the user ID of the event.
// If the user ID is invalid, return an error.
// Theres probably a better way to write this behaviour but I'm not sure how.
let Some((user_id, is_author)) = system.map_or_else(
|| Some((user::Id::new(event.user_id), true)),
|u| user::parse_slack_user_id(&u).map(|id| (id, false)),
) else {
return Ok(SlackCommandEventResponse::new(
SlackMessageContent::new().with_text("Invalid user ID".into()),
));
};
let Some(system) = System::fetch_by_user_id(&user_state.db, &user_id)
.await
.change_context(CommandError::Sqlx)?
else {
return if is_author {
Ok(SlackCommandEventResponse::new(
SlackMessageContent::new().with_text("You don't have a system yet!".into()),
))
} else {
Ok(SlackCommandEventResponse::new(
SlackMessageContent::new().with_text("This user doesn't have a system!".into()),
))
};
};
let members = system
.get_members(&user_state.db)
.await
.change_context(CommandError::Sqlx)?;
let member_blocks = members
.into_iter()
.map(|member| {
let fields = [
Some(md!("Display Name: {}", member.display_name)),
Some(md!("Member ID: {}", member.id)),
member.title.as_ref().map(|title| md!("Title: {}", title)),
member
.pronouns
.as_ref()
.map(|pronouns| md!("Pronouns: {}", pronouns)),
member
.name_pronunciation
.as_ref()
.map(|name_pronunciation| {
md!("Name Pronunciation: {}", name_pronunciation)
}),
Some(md!("Created At: {}", member.created_at)),
]
.into_iter()
.flatten()
.collect();
SlackSectionBlock::new()
.with_text(md!("Name: {}", member.full_name))
.with_fields(fields)
})
.map(Into::into)
.collect();
Ok(SlackCommandEventResponse::new(
SlackMessageContent::new().with_blocks(member_blocks),
))
}
async fn member_info(
event: SlackCommandEvent,
state: &SlackClientEventsUserState,
member_id: i64,
) -> Result<SlackCommandEventResponse, CommandError> {
let states = state.read().await;
let user_state = states.get_user_state::<user::State>().unwrap();
let member_id = member::Id::new(member_id);
let Some(system_id) = System::fetch_by_user_id(&user_state.db, &event.user_id.into())
.await
.change_context(CommandError::Sqlx)?
.map(|system| system.id)
else {
return Ok(SlackCommandEventResponse::new(
SlackMessageContent::new().with_text(
"You don't have a system yet! Make one with `/system create <name>`".into(),
),
));
};
let Some(member) = Member::fetch_by_and_trust_id(system_id, member_id, &user_state.db)
.await
.change_context(CommandError::Sqlx)?
else {
return Ok(SlackCommandEventResponse::new(
SlackMessageContent::new()
.with_text("Member not found. Make sure you used the correct ID".into()),
));
};
let fields = [
Some(md!("Display Name: {}", member.display_name)),
Some(md!("Member ID: {}", member.id)),
member.title.as_ref().map(|title| md!("Title: {}", title)),
member
.pronouns
.as_ref()
.map(|pronouns| md!("Pronouns: {}", pronouns)),
member
.name_pronunciation
.as_ref()
.map(|name_pronunciation| md!("Name Pronunciation: {}", name_pronunciation)),
]
.into_iter()
.flatten()
.collect();
let block = SlackSectionBlock::new()
.with_text(md!("Name: {}", member.full_name))
.with_fields(fields);
Ok(SlackCommandEventResponse::new(
SlackMessageContent::new().with_blocks(vec![block.into()]),
))
}
async fn create_member(
event: SlackCommandEvent,
session: SlackClientSession<'_, SlackClientHyperHttpsConnector>,
) -> Result<SlackCommandEventResponse, CommandError> {
let view = View::create_add_view();
let view = session
.views_open(&SlackApiViewsOpenRequest::new(event.trigger_id, view))
.await
.attach_printable("Error opening view")
.change_context(CommandError::Slack)?;
debug!("Opened view: {:#?}", view);
Ok(SlackCommandEventResponse::new(
SlackMessageContent::new().with_text("View opened!".into()),
))
}
async fn edit_member(
event: SlackCommandEvent,
session: SlackClientSession<'_, SlackClientHyperHttpsConnector>,
state: &SlackClientEventsUserState,
member_id: i64,
) -> Result<SlackCommandEventResponse, CommandError> {
let states = state.read().await;
let user_state = states.get_user_state::<user::State>().unwrap();
let user_id = user::Id::new(event.user_id);
let member_id = member::Id::new(member_id);
let Some(system_id) = System::fetch_by_user_id(&user_state.db, &user_id)
.await
.change_context(CommandError::Sqlx)?
.map(|system| system.id)
else {
return Ok(SlackCommandEventResponse::new(
SlackMessageContent::new().with_text(
"You don't have a system yet! Make one with `/system create <name>`".into(),
),
));
};
let Some(member) = Member::fetch_by_and_trust_id(system_id, member_id, &user_state.db)
.await
.change_context(CommandError::Sqlx)?
else {
return Ok(SlackCommandEventResponse::new(
SlackMessageContent::new()
.with_text("Member not found. Make sure you used the correct ID".into()),
));
};
let member_id = member.id;
let view = members::View::from(member).create_edit_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!("Opened view: {:#?}", view);
Ok(SlackCommandEventResponse::new(SlackMessageContent::new()))
}
}

125
src/commands/mod.rs Executable file
View file

@ -0,0 +1,125 @@
use std::sync::Arc;
mod members;
mod system;
mod triggers;
use axum::{Extension, Json};
use clap::Parser;
use error_stack::ResultExt;
use members::Members;
use slack_morphism::prelude::*;
use system::System;
use tracing::{debug, error};
use triggers::Triggers;
#[derive(clap::Parser, Debug)]
#[command(color(clap::ColorChoice::Never))]
enum Command {
#[clap(subcommand)]
Members(Members),
#[clap(subcommand)]
System(System),
#[clap(subcommand)]
Triggers(Triggers),
}
impl Command {
pub async fn run(
self,
event: SlackCommandEvent,
client: Arc<SlackHyperClient>,
state: SlackClientEventsUserState,
) -> error_stack::Result<SlackCommandEventResponse, CommandError> {
match self {
Self::Members(members) => members
.run(event, client, state)
.await
.attach_printable("Failed to run members command")
.change_context(CommandError::Command),
Self::System(system) => system
.run(event, client, state)
.await
.attach_printable("Failed to run system command")
.change_context(CommandError::Command),
Self::Triggers(triggers) => triggers
.run(event, client, state)
.await
.attach_printable("Failed to run triggers command")
.change_context(CommandError::Command),
}
}
}
#[derive(thiserror::Error, displaydoc::Display, Debug)]
enum CommandError {
/// Error running the command
Command,
}
// TODO: figure out error handling
#[tracing::instrument(skip(environment, event))]
pub async fn process_command_event(
Extension(environment): Extension<Arc<SlackHyperListenerEnvironment>>,
Extension(event): Extension<SlackCommandEvent>,
) -> Json<SlackCommandEventResponse> {
let client = environment.client.clone();
let state = environment.user_state.clone();
match command_event_callback(event, client, state).await {
Ok(response) => Json(response),
Err(e) => {
error!("Error processing command event: {:#?}", e);
Json(SlackCommandEventResponse::new(
SlackMessageContent::new()
.with_text("Error processing command! Logged to developers".into()),
))
}
}
}
async fn command_event_callback(
event: SlackCommandEvent,
client: Arc<SlackHyperClient>,
state: SlackClientEventsUserState,
) -> Result<SlackCommandEventResponse, CommandError> {
debug!("Received command: {:?}", event.command);
let formatted_command = event.command.0.trim_start_matches('/');
let formatted = event.text.as_ref().map_or_else(
|| format!("slack-system-bot {formatted_command}"),
|text| format!("slack-system-bot {formatted_command} {text}"),
);
debug!("Formatted command: {formatted}");
let parser = Command::try_parse_from(formatted.split_whitespace());
match parser {
Ok(parser) => {
debug!("Parsed command: {:?}", parser);
let result = parser.run(event, client, state).await;
match result {
Ok(res) => {
debug!("Command {} executed successfully", formatted);
Ok(res)
}
Err(e) => {
error!("Error running command {formatted}");
error!("{e:?}");
Ok(SlackCommandEventResponse::new(
SlackMessageContent::new().with_text(
"Error running command! TODO: show error info on slack".into(),
),
))
}
}
}
Err(error) => {
let formatted = error.render();
Ok(SlackCommandEventResponse::new(
SlackMessageContent::new().with_text(formatted.to_string()),
))
}
}
}

216
src/commands/system.rs Executable file
View file

@ -0,0 +1,216 @@
use std::sync::Arc;
use error_stack::{Result, ResultExt};
use oauth2::CsrfToken;
use slack_morphism::prelude::*;
use tokio::runtime::Handle;
use tracing::debug;
use crate::{
models::{system, user},
oauth::create_oauth_client,
};
#[derive(clap::Subcommand, Debug)]
pub enum System {
/// Creates a system for your profile
Create {
/// The name of your system
name: String,
},
/// Edits your system name
Rename {
/// Your system's new name
name: String,
},
/// Reauthenticates your system with Slack
Reauth,
/// Get info about your or another user's system
Info {
/// The user to get info about (if left blank, defaults to you)
user: Option<String>,
},
}
#[derive(thiserror::Error, displaydoc::Display, Debug)]
pub enum CommandError {
/// Error while calling the database
Sqlx,
}
impl System {
pub async fn run(
self,
event: SlackCommandEvent,
client: Arc<SlackHyperClient>,
state: SlackClientEventsUserState,
) -> Result<SlackCommandEventResponse, CommandError> {
match self {
Self::Create { name } => Self::create_system(event, state, name).await,
Self::Rename { name } => Self::edit_system_name(event, state, name).await,
Self::Info { user } => Self::get_system_info(event, client, state, user).await,
Self::Reauth => Ok(SlackCommandEventResponse::new(
SlackMessageContent::new().with_text("TODO: reauth".into()),
)),
}
}
async fn get_system_info(
event: SlackCommandEvent,
client: Arc<SlackHyperClient>,
state: SlackClientEventsUserState,
user: Option<String>,
) -> Result<SlackCommandEventResponse, CommandError> {
debug!("Getting system info");
let states = state.read().await;
let user_state = states.get_user_state::<user::State>().unwrap();
// If the input exists, parse it into a user ID.
// If it doesn't exist, use the user ID of the event.
// There's probably a better way to write this behaviour but I'm not sure how.
let Some(user_id) = user.map_or_else(
|| Some(event.user_id.clone().into()),
|u| {
user::parse_slack_user_id(&u).and_then(|id| {
Handle::current().block_on(async { id.trust(&client).await.ok() })
})
},
) else {
return Ok(SlackCommandEventResponse::new(
SlackMessageContent::new().with_text("Invalid user ID".into()),
));
};
let system = system::System::fetch_by_user_id(&user_state.db, &user_id)
.await
.change_context(CommandError::Sqlx)?;
if let Some(system) = system {
let fronting_member = system
.active_member(&user_state.db)
.await
.change_context(CommandError::Sqlx)?;
Ok(SlackCommandEventResponse::new(
SlackMessageContent::new().with_blocks(slack_blocks![
some_into(
SlackSectionBlock::new()
.with_text(md!(format!("System name: {}", system.name)))
),
some_into(SlackSectionBlock::new().with_text(md!(format!(
"Fronting member: {}",
fronting_member.map_or_else(
|| "No fronting member".to_string(),
|m| m.display_name
)
))))
]),
))
} else {
Ok(SlackCommandEventResponse::new(
SlackMessageContent::new().with_blocks(slack_blocks![some_into(
SlackSectionBlock::new().with_text(md!("This user doesn't have a system!"))
)]),
))
}
}
async fn edit_system_name(
event: SlackCommandEvent,
state: SlackClientEventsUserState,
name: String,
) -> Result<SlackCommandEventResponse, CommandError> {
debug!("Editing system name {name}");
let states = state.read().await;
let user_state = states.get_user_state::<user::State>().unwrap();
let Some(system_id) =
system::System::fetch_by_user_id(&user_state.db, &event.user_id.into())
.await
.change_context(CommandError::Sqlx)?
.map(|s| s.id)
else {
return Ok(SlackCommandEventResponse::new(
SlackMessageContent::new().with_blocks(slack_blocks![some_into(
SlackSectionBlock::new().with_text(md!(
"You don't have a system to edit! Create one with `/system create`"
))
)]),
));
};
sqlx::query!(
r#"
UPDATE systems
SET name = $1
WHERE id = $2
"#,
name,
system_id
)
.execute(&user_state.db)
.await
.change_context(CommandError::Sqlx)?;
Ok(SlackCommandEventResponse::new(
SlackMessageContent::new().with_text("Successfully updated system name!".into()),
))
}
async fn create_system(
event: SlackCommandEvent,
state: SlackClientEventsUserState,
name: String,
) -> Result<SlackCommandEventResponse, CommandError> {
debug!("Creating system {name}");
let states = state.read().await;
let user_state = states.get_user_state::<user::State>().unwrap();
// todo: somehow remove this clone with cleaner code in the future`
if system::System::fetch_by_user_id(&user_state.db, &user::Id::new(event.user_id.clone()))
.await
.change_context(CommandError::Sqlx)?
.is_some()
{
return Ok(SlackCommandEventResponse::new(
SlackMessageContent::new().with_text("You already have a system! If you need to reauthenticate, run /system reauth. If you need to change your system name, run /system rename".into()),
));
}
let oauth_client = create_oauth_client();
// note: we aren't doing PKCE since this is only ran on a trusted server
let (auth_url, csrf_token) = oauth_client
.authorize_url(CsrfToken::new_random)
// so we get a regular token as well. Required by oauth2 for some reason
.add_extra_param("scope", "commands")
.add_extra_param("user_scope", "users.profile:read,chat:write")
.url();
let secret = csrf_token.secret();
sqlx::query!(
r#"
INSERT INTO system_oauth_process (name, owner_id, csrf)
VALUES ($1, $2, $3)
"#,
name,
event.user_id.0,
secret
)
.execute(&user_state.db)
.await
.change_context(CommandError::Sqlx)?;
Ok(SlackCommandEventResponse::new(
SlackMessageContent::new().with_blocks(slack_blocks![some_into(
SlackSectionBlock::new()
.with_text(md!("<{}|Finish creating your system>", auth_url))
)]),
))
}
}

306
src/commands/triggers.rs Executable file
View file

@ -0,0 +1,306 @@
use std::sync::Arc;
use error_stack::{Result, ResultExt};
use slack_morphism::prelude::*;
use tracing::debug;
use crate::{
BOT_TOKEN,
models::{
member::{self, Member},
system::System,
trigger, user,
},
};
#[derive(clap::Subcommand, Debug)]
pub enum Triggers {
/// Adds a new trigger for a member. Expect a popup to fill in the info!
Add {
/// The member to add the trigger for. Use the member id from /member list
member: i64,
},
/// Deletes a trigger
Delete {
/// The trigger to delete. Use the trigger id from /trigger list
id: i64,
},
/// Lists all of your triggers
List {
/// If specified, lists the triggers for the given member. Use the member id from /member list
member: Option<i64>,
},
/// Edit a trigger
Edit {
/// The trigger to edit. Use the trigger id from /trigger list
id: i64,
},
}
#[derive(thiserror::Error, displaydoc::Display, Debug)]
pub enum CommandError {
/// Error while calling the Slack API
Slack,
/// Error while calling the database
Sqlx,
}
impl Triggers {
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::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
}
}
}
async fn create_trigger(
event: SlackCommandEvent,
state: &SlackClientEventsUserState,
session: SlackClientSession<'_, SlackClientHyperHttpsConnector>,
member_id: i64,
) -> Result<SlackCommandEventResponse, CommandError> {
let states = state.read().await;
let user_state = states.get_user_state::<user::State>().unwrap();
let member_id = member::Id::new(member_id);
let Some(system_id) =
System::fetch_by_user_id(&user_state.db, &user::Id::new(event.user_id))
.await
.change_context(CommandError::Sqlx)?
.map(|system| system.id)
else {
return Ok(SlackCommandEventResponse::new(
SlackMessageContent::new().with_text(
"You don't have a system yet! Make one with `/system create <name>`".into(),
),
));
};
let Some(member_id) = Member::fetch_by_and_trust_id(system_id, member_id, &user_state.db)
.await
.change_context(CommandError::Sqlx)?
.map(|member| member.id)
else {
return Ok(SlackCommandEventResponse::new(
SlackMessageContent::new()
.with_text("Member not found. Make sure you used the correct ID".into()),
));
};
let view = trigger::View::new(String::new(), true).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!("Opened view: {:#?}", view);
Ok(SlackCommandEventResponse::new(SlackMessageContent::new()))
}
pub async fn delete_trigger(
event: SlackCommandEvent,
state: &SlackClientEventsUserState,
trigger_id: i64,
) -> Result<SlackCommandEventResponse, CommandError> {
let states = state.read().await;
let user_state = states.get_user_state::<user::State>().unwrap();
let trigger_id = trigger::Id::new(trigger_id);
let Some(system_id) =
System::fetch_by_user_id(&user_state.db, &user::Id::new(event.user_id))
.await
.change_context(CommandError::Sqlx)?
.map(|system| system.id)
else {
return Ok(SlackCommandEventResponse::new(
SlackMessageContent::new().with_text(
"You don't have a system yet! Make one with `/system create <name>`".into(),
),
));
};
// Validate the trigger belongs to the user's system
let Ok(trigger_id) = trigger_id
.validate_by_system(system_id, &user_state.db)
.await
else {
return Ok(SlackCommandEventResponse::new(
SlackMessageContent::new()
.with_text("Trigger not found. Make sure you used the correct ID".into()),
));
};
// 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()),
))
}
pub async fn list_triggers(
event: SlackCommandEvent,
state: &SlackClientEventsUserState,
member_id: Option<i64>,
) -> Result<SlackCommandEventResponse, CommandError> {
let states = state.read().await;
let user_state = states.get_user_state::<user::State>().unwrap();
let Some(system_id) =
System::fetch_by_user_id(&user_state.db, &user::Id::new(event.user_id))
.await
.change_context(CommandError::Sqlx)?
.map(|system| system.id)
else {
return Ok(SlackCommandEventResponse::new(
SlackMessageContent::new().with_text(
"You don't have a system yet! Make one with `/system create <name>`".into(),
),
));
};
let triggers = if let Some(member_id) = member_id {
let member_id = member::Id::new(member_id);
// Validate the member belongs to the user's system
let Ok(member_id) = member_id
.validate_by_system(system_id, &user_state.db)
.await
else {
return Ok(SlackCommandEventResponse::new(
SlackMessageContent::new()
.with_text("Member not found. Make sure you used the correct ID".into()),
));
};
member_id
.fetch_triggers(&user_state.db)
.await
.change_context(CommandError::Sqlx)?
} else {
system_id
.list_triggers(&user_state.db)
.await
.change_context(CommandError::Sqlx)?
};
if triggers.is_empty() {
return Ok(SlackCommandEventResponse::new(
SlackMessageContent::new().with_text("No triggers found.".into()),
));
}
let trigger_blocks = triggers
.into_iter()
.map(|trigger| {
let fields = vec![
md!("Trigger ID: {}", trigger.id),
md!("Member ID: {}", trigger.member_id),
md!(
"{}: {}",
if trigger.is_prefix {
"Prefix"
} else {
"Suffix"
},
trigger.text
),
];
SlackSectionBlock::new()
.with_text(md!("**Trigger {}**", trigger.id))
.with_fields(fields)
})
.map(Into::into)
.collect();
Ok(SlackCommandEventResponse::new(
SlackMessageContent::new().with_blocks(trigger_blocks),
))
}
pub async fn edit_trigger(
event: SlackCommandEvent,
state: &SlackClientEventsUserState,
session: SlackClientSession<'_, SlackClientHyperHttpsConnector>,
trigger_id: i64,
) -> Result<SlackCommandEventResponse, CommandError> {
let states = state.read().await;
let user_state = states.get_user_state::<user::State>().unwrap();
let trigger_id = trigger::Id::new(trigger_id);
let Some(system_id) =
System::fetch_by_user_id(&user_state.db, &user::Id::new(event.user_id))
.await
.change_context(CommandError::Sqlx)?
.map(|system| system.id)
else {
return Ok(SlackCommandEventResponse::new(
SlackMessageContent::new().with_text(
"You don't have a system yet! Make one with `/system create <name>`".into(),
),
));
};
// Validate the trigger belongs to the user's system
let Ok(trigger_id) = trigger_id
.validate_by_system(system_id, &user_state.db)
.await
else {
return Ok(SlackCommandEventResponse::new(
SlackMessageContent::new()
.with_text("Trigger not found. Make sure you used the correct ID".into()),
));
};
// Fetch the trigger to edit
let trigger = trigger::Trigger::fetch_by_id(trigger_id, &user_state.db)
.await
.change_context(CommandError::Sqlx)?;
let Some(trigger) = trigger else {
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!("Opened view: {:#?}", view);
Ok(SlackCommandEventResponse::new(SlackMessageContent::new()))
}
}

26
src/env.rs Executable file
View file

@ -0,0 +1,26 @@
use menv::require_envs;
require_envs! {
(assert_env_vars, any_set, gen_help);
slack_app_token, "SLACK_APP_TOKEN", String,
"SLACK_APP_TOKEN should be set to the bot's app token";
slack_bot_token, "SLACK_BOT_TOKEN", String,
"SLACK_BOT_TOKEN should be set to the bot's user token";
slack_client_id, "SLACK_CLIENT_ID", String,
"SLACK_CLIENT_ID should be set to the client ID for oauth";
slack_client_secret, "SLACK_CLIENT_SECRET", String,
"SLACK_CLIENT_SECRET should be set to the client secret for oauth";
slack_signing_secret, "SLACK_SIGNING_SECRET", String,
"SLACK_SIGNING_SECRET should be set to the signing secret for verifying slack requests";
database_url, "DATABASE_URL", String,
"DATABASE_URL should be set to a postgres database connection string";
encryption_key?, "ENCRYPTION_KEY", String,
"ENCRYPTION_KEY can be optionally set to a key for encrypting and decrypting the database";
}

291
src/events/mod.rs Executable file
View file

@ -0,0 +1,291 @@
use std::{convert::Infallible, sync::Arc};
use axum::{Extension, body::Bytes, http::Response};
use error_stack::ResultExt;
use http_body_util::{BodyExt, Empty, Full, combinators::BoxBody};
use slack_morphism::prelude::*;
// use sqlx::SqlitePool;
use tracing::{debug, error, trace};
use crate::{
BOT_TOKEN,
models::{
member::{Member, TriggeredMember},
system::System,
user,
},
};
#[tracing::instrument(skip(environment, event))]
pub async fn process_push_event(
Extension(environment): Extension<Arc<SlackHyperListenerEnvironment>>,
Extension(event): Extension<SlackPushEvent>,
) -> Response<BoxBody<Bytes, Infallible>> {
debug!("Received push event!");
match event {
SlackPushEvent::UrlVerification(url_verification) => {
Response::new(Full::new(url_verification.challenge.into()).boxed())
}
SlackPushEvent::EventCallback(event) => {
let client = environment.client.clone();
let state = environment.user_state.clone();
if let Err(e) = push_event_callback(event, client, state).await {
error!("Error processing push event: {:#?}", e);
}
Response::new(Empty::new().boxed())
}
SlackPushEvent::AppRateLimited(rate_limited) => {
trace!("Rate limited event: {:#?}", rate_limited);
Response::new(Empty::new().boxed())
}
}
}
async fn push_event_callback(
event: SlackPushEventCallback,
client: Arc<SlackHyperClient>,
state: SlackClientEventsUserState,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
match event.event {
SlackEventCallbackBody::Message(message_event) => {
debug!("Received message event!");
trace!("Message: {:?}", message_event);
let states = state.read().await;
let user_state = states.get_user_state::<user::State>().unwrap();
if message_event
.subtype
.as_ref()
.is_some_and(|subtype| *subtype == SlackMessageEventType::MessageDeleted)
{
return Ok(());
}
let Some(user_id) = message_event.sender.user else {
return Ok(());
};
let Some(mut system) =
System::fetch_by_user_id(&user_state.db, &user::Id::new(user_id)).await?
else {
return Ok(());
};
let Some(ref channel_id) = message_event.origin.channel else {
return Ok(());
};
let Some(content) = message_event.content else {
return Ok(());
};
if let Some(ref message_content) = content.text {
let Some(member) = system
.fetch_triggered_member(&user_state.db, message_content)
.await?
else {
return Ok(());
};
debug!("Triggered member: {:#?}", member);
if system.trigger_changes_active_member {
system
.change_active_member(Some(member.id), &user_state.db)
.await?;
}
rewrite_message(
&client,
channel_id,
message_event.origin.ts,
content,
member,
&system,
// &user_state.db,
)
.await?;
return Ok(());
}
// No triggers ran, so check if there's any actively fronting member
if let Some(member_id) = system.active_member_id {
let Some(member) = Member::fetch_by_id(member_id, &user_state.db).await? else {
error!("Active member not found. This should not happen.");
return Ok(());
};
rewrite_message(
&client,
channel_id,
message_event.origin.ts,
content,
member.into(),
&system,
// &user_state.db,
)
.await?;
}
Ok(())
}
_ => Ok(()),
}
}
async fn rewrite_message(
client: &SlackHyperClient,
channel_id: &SlackChannelId,
message_id: SlackTs,
mut content: SlackMessageContent,
member: TriggeredMember,
system: &System,
// TODO: log this message in the db for future reference
// db: &SqlitePool,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let token = SlackApiToken::new(system.slack_oauth_token.expose().into())
.with_token_type(SlackApiTokenType::User);
let user_session = client.open_session(&token);
let bot_session = client.open_session(&BOT_TOKEN);
rewrite_content(&mut content, &member);
let mut custom_image_blocks = Vec::new();
if let Some(files) = content.files.take() {
#[derive(serde::Serialize)]
struct CustomSlackFile {
id: String,
}
#[derive(serde::Serialize)]
struct CustomSlackImageBlock {
#[serde(rename = "type")]
typ: String,
slack_file: CustomSlackFile,
alt_text: String,
}
// update files to blocks
let blocks = files
.into_iter()
.filter_map(|file| match file.filetype.map(|f| f.0).as_deref() {
Some("png" | "jpg" | "jpeg" | "gif" | "webp") => {
// https://github.com/abdolence/slack-morphism-rust/issues/320
// Some(SlackImageBlock::new(file.permalink?, String::new()).into())
custom_image_blocks.push(CustomSlackImageBlock {
typ: "image".to_string(),
slack_file: CustomSlackFile {
id: file.id.0,
},
alt_text: String::new(),
});
None
}
Some("mp4" | "mpg" | "mpeg" | "mkv" | "avi" | "mov" | "ogv" | "wmv") => {
debug!("user uploaded a video. Can't really embed this.... Attaching to message as a rich content and calling it a day");
Some(SlackMarkdownBlock::new(format!("Video: [{}]({})", file.name?, file.permalink?)).into())
}
Some(typ) => {
debug!("unknown filetype {}. Don't know how to embed. Attaching to message as a rich content", typ);
Some(SlackMarkdownBlock::new(format!("File attachment: [{}]({})", file.name?, file.permalink?)).into())
}
None => None,
});
if let Some(slack_blocks) = content.blocks.as_mut() {
slack_blocks.extend(blocks);
} else {
content.blocks = Some(blocks.collect());
}
}
let message_request = SlackApiChatPostMessageRequest::new(channel_id.clone(), content)
.with_username(member.display_name.clone())
.opt_icon_url(member.profile_picture_url.clone());
let mut request = serde_json::to_value(message_request).unwrap();
let blocks = request.get_mut("blocks").unwrap().as_array_mut().unwrap();
let custom_image_blocks = custom_image_blocks
.into_iter()
.map(serde_json::to_value)
.collect::<Result<Vec<serde_json::Value>, serde_json::Error>>()?;
blocks.extend(custom_image_blocks);
let _res: SlackApiChatPostMessageResponse = bot_session
.http_session_api
.http_post(
"chat.postMessage",
&request,
Some(&CHAT_POST_MESSAGE_SPECIAL_LIMIT_RATE_CTL),
)
.await
.attach_printable("Error rewriting message")?;
user_session
.chat_delete(
&SlackApiChatDeleteRequest::new(channel_id.clone(), message_id).with_as_user(true),
)
.await
.attach_printable("Error deleting message")?;
Ok(())
}
fn rewrite_content(content: &mut SlackMessageContent, member: &TriggeredMember) {
debug!("Rewriting message content");
if let Some(text) = &mut content.text {
if member.is_prefix {
if let Some(new_text) = text.strip_prefix(&member.trigger_text) {
*text = new_text.to_string();
}
} else if let Some(new_text) = text.strip_suffix(&member.trigger_text) {
*text = new_text.to_string();
}
}
if let Some(blocks) = &mut content.blocks {
for block in blocks {
if let SlackBlock::RichText(richtext) = block {
let elements = richtext["elements"].as_array_mut().unwrap();
let len = elements.len();
// the first and last elements would have the prefix and suffix respectively, so we can filter them
let first = elements.get_mut(0).unwrap();
if let Some(first_text) = first.pointer_mut("/elements/0/text") {
if member.is_prefix {
if let Some(new_text) = first_text
.as_str()
.and_then(|text| text.strip_prefix(&member.trigger_text))
.map(ToString::to_string)
{
*first_text = serde_json::Value::String(new_text);
}
}
}
let last = elements.get_mut(len - 1).unwrap();
if let Some(last_text) = last.pointer_mut("/elements/0/text") {
if !member.is_prefix {
if let Some(new_text) = last_text
.as_str()
.and_then(|text| text.strip_suffix(&member.trigger_text))
.map(ToString::to_string)
{
*last_text = serde_json::Value::String(new_text);
}
}
}
}
}
}
}

107
src/interactions/member.rs Executable file
View file

@ -0,0 +1,107 @@
use error_stack::{bail, Result, ResultExt};
use slack_morphism::prelude::*;
use crate::{
models::{
member,
system::System,
user::{self, State},
Trusted,
},
BOT_TOKEN,
};
#[derive(thiserror::Error, displaydoc::Display, Debug)]
pub enum Error {
/// Error while calling the database
Sqlx,
/// Error while calling the Slack API
Slack,
/// Unable to parse view
ParsingView,
/// No system found for the user
NoSystem,
}
pub async fn create_member(
view_state: SlackViewState,
client: &SlackHyperClient,
user_state: &State,
user_id: user::Id<Trusted>,
) -> Result<(), Error> {
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)
.await
.attach_printable("Error checking if system exists")
.change_context(Error::Sqlx)?
.map(|system| system.id)
else {
bail!(Error::NoSystem);
};
let id = data
.add(system_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(format!(
"Successfully added {}! Their ID is {}",
data.display_name, id
)),
))
.await
.change_context(Error::Slack)?;
Ok(())
}
pub async fn edit_member(
view_state: SlackViewState,
client: &SlackHyperClient,
user_state: &State,
user_id: user::Id<Trusted>,
member_id: member::Id<Trusted>,
) -> Result<(), Error> {
let data = member::View::try_from(view_state).change_context(Error::ParsingView)?;
data.update(member_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(format!(
"Successfully edited {} (ID {})",
data.display_name, member_id
)),
))
.await
.change_context(Error::Slack)?;
Ok(())
}

189
src/interactions/mod.rs Executable file
View file

@ -0,0 +1,189 @@
mod member;
mod trigger;
use std::error::Error;
use std::sync::Arc;
use axum::Extension;
use member::{create_member, edit_member};
use slack_morphism::prelude::*;
use tracing::{debug, error};
use trigger::{create_trigger, edit_trigger};
use crate::models::system::System;
use crate::models::{self, Trusted, user};
#[tracing::instrument(skip(event, environment))]
pub async fn process_interaction_event(
Extension(environment): Extension<Arc<SlackHyperListenerEnvironment>>,
Extension(event): Extension<SlackInteractionEvent>,
) {
let client = environment.client.clone();
let states = environment.user_state.clone();
if let Err(err) = interaction_event(client, event, states).await {
error!("Error processing interaction event: {:#?}", err);
}
}
async fn interaction_event(
client: Arc<SlackHyperClient>,
event: SlackInteractionEvent,
states: SlackClientEventsUserState,
) -> Result<(), Box<dyn Error + Send + Sync>> {
match event {
SlackInteractionEvent::ViewSubmission(slack_interaction_view_submission_event) => {
match slack_interaction_view_submission_event.view.view {
SlackView::Home(view) => {
debug!("Received home view: {:#?}", view);
Ok(())
}
SlackView::Modal(ref view) => {
debug!("Received modal view: {:#?}", view);
let user_id: user::Id<Trusted> =
slack_interaction_view_submission_event.user.id.into();
let states = states.read().await;
let user_state = states.get_user_state::<user::State>().unwrap();
let Some(view_state) = slack_interaction_view_submission_event
.view
.state_params
.state
else {
error!("No state found in view submission");
return Ok(());
};
handle_modal_view(
client,
view_state,
user_state,
user_id,
view.external_id.as_deref(),
)
.await
}
}
}
event => {
debug!("Received interaction event: {:#?}", event);
Ok(())
}
}
}
#[tracing::instrument(skip(client, view_state, user_state))]
async fn handle_modal_view(
client: Arc<SlackHyperClient>,
view_state: SlackViewState,
user_state: &user::State,
user_id: user::Id<Trusted>,
external_id: Option<&str>,
) -> Result<(), Box<dyn Error + Send + Sync>> {
match external_id {
None => {
error!(
"No external id found in modal view. To the person that created the modal: How do you expect the bot to figure out what to do?"
);
Ok(())
}
Some("create_member") => {
debug!("Received create member modal view");
create_member(view_state, &client, user_state, user_id).await?;
Ok(())
}
Some(id) if id.starts_with("edit_member_") => {
debug!("Received edit member modal view");
let Ok(member_id) = id
.strip_prefix("edit_member_")
.expect("id starts with edit_member_")
.parse::<i64>()
.map(models::member::Id::new)
else {
error!(
"Failed to parse member id from external id {}. Bailing in case this was a malicious call",
id
);
return Ok(());
};
let Ok(trusted_member_id) = member_id.validate_by_user(&user_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 Ok(());
};
edit_member(view_state, &client, user_state, user_id, trusted_member_id).await?;
Ok(())
}
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");
let Ok(trusted_member_id) = member_id.validate_by_user(&user_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 Ok(());
};
create_trigger(view_state, &client, user_state, user_id, trusted_member_id).await?;
Ok(())
}
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!(
"Failed to fetch system id for user id {}. Bailing in case this was a malicious call",
user_id
);
return Ok(());
};
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 Ok(());
};
edit_trigger(view_state, &client, user_state, user_id, trusted_trigger_id).await?;
Ok(())
}
Some(id) => {
error!("receieved unknown external id: {id}");
Ok(())
}
}
}

101
src/interactions/trigger.rs Normal file
View file

@ -0,0 +1,101 @@
use error_stack::{Result, ResultExt, bail};
use slack_morphism::prelude::*;
use crate::{
BOT_TOKEN,
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,
}
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);
};
let _id = data
.add(system_id, member_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 added trigger!".into()),
))
.await
.change_context(Error::Slack)?;
Ok(())
}
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(())
}

168
src/main.rs Executable file
View file

@ -0,0 +1,168 @@
#![doc = include_str!("../README.md")]
#![warn(clippy::pedantic, clippy::nursery, missing_docs)]
mod commands;
mod env;
mod events;
mod interactions;
mod models;
mod oauth;
use crate::models::Trusted;
use std::str::FromStr;
use std::sync::LazyLock;
use std::{process::ExitCode, sync::Arc};
use commands::process_command_event;
use error_stack::{report, ResultExt};
use events::process_push_event;
use interactions::process_interaction_event;
use models::{system, user};
use oauth::oauth_handler;
use slack_morphism::prelude::*;
use sqlx::sqlite::SqliteConnectOptions;
use sqlx::SqlitePool;
use tracing::debug;
use tracing::{info, level_filters::LevelFilter};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
/// The slack app token. Used for socket mode if we ever decide to use it.
pub static APP_TOKEN: LazyLock<SlackApiToken> =
LazyLock::new(|| SlackApiToken::new(env::slack_app_token().into()));
/// The slack bot token. Used for most interactions
pub static BOT_TOKEN: LazyLock<SlackApiToken> =
LazyLock::new(|| SlackApiToken::new(env::slack_bot_token().into()));
#[derive(thiserror::Error, displaydoc::Display, Debug)]
enum Error {
/// Error initializing environment variables
Env,
/// Error during slack client initialization
Initialization,
}
#[dotenvy::load]
#[tokio::main]
#[tracing::instrument]
async fn main() -> error_stack::Result<ExitCode, Error> {
let console_subscriber = tracing_subscriber::fmt::layer().pretty();
let error_subscriber = tracing_error::ErrorLayer::default();
let env_subscriber = EnvFilter::builder()
.with_default_directive(LevelFilter::INFO.into())
.from_env_lossy();
tracing_subscriber::registry()
.with(console_subscriber)
.with(error_subscriber)
.with(env_subscriber)
.init();
if env::any_set() {
env::assert_env_vars();
} else {
return Err(report!(Error::Env)
.attach_printable("No environment variables are set. See help message below:")
.attach_printable(env::gen_help()));
}
rustls::crypto::ring::default_provider()
.install_default()
.map_err(|_| report!(Error::Initialization))
.attach_printable("Error installing default ring crypto provider")?;
let mut options = SqliteConnectOptions::from_str(&env::database_url())
.unwrap()
.optimize_on_close(true, None)
.create_if_missing(true);
if let Some(key) = env::encryption_key() {
options = options.pragma("key", key);
}
let pool = SqlitePool::connect_with(options)
.await
.attach_printable("Error connecting to database")
.change_context(Error::Initialization)?;
sqlx::migrate!()
.run(&pool)
.await
.attach_printable("Error running database migrations")
.change_context(Error::Initialization)?;
// Test query to make sure stuff works before we start the bot
debug!("Testing database connection");
sqlx::query!(
r#"
SELECT
id as "id: system::Id<Trusted>"
FROM
systems
"#
)
.fetch_all(&pool)
.await
.attach_printable("Error fetching systems from database")
.change_context(Error::Initialization)?;
let client = Arc::new(SlackClient::new(
SlackClientHyperConnector::new()
.attach_printable("Error creating Slack hyper connector")
.change_context(Error::Initialization)?,
));
let state = user::State { db: pool.clone() };
let listener_environment: Arc<SlackHyperListenerEnvironment> = Arc::new(
SlackClientEventsListenerEnvironment::new(client.clone()).with_user_state(state.clone()),
);
let signing_secret: SlackSigningSecret = env::slack_signing_secret().into();
let listener: SlackEventsAxumListener<SlackHyperHttpsConnector> =
SlackEventsAxumListener::new(listener_environment.clone());
let app = axum::routing::Router::new()
// Note: I do not use the slack-morphism oauth thing because it's a bit too much for me
.route("/auth", axum::routing::get(oauth_handler))
.with_state(state.clone())
.route(
"/push",
axum::routing::post(process_push_event).layer(
listener
.events_layer(&signing_secret)
.with_event_extractor(SlackEventsExtractors::push_event()),
),
)
.route(
"/command",
axum::routing::post(process_command_event).layer(
listener
.events_layer(&signing_secret)
.with_event_extractor(SlackEventsExtractors::command_event()),
),
)
.route(
"/interaction",
axum::routing::post(process_interaction_event).layer(
listener
.events_layer(&signing_secret)
.with_event_extractor(SlackEventsExtractors::interaction_event()),
),
);
info!("Slack bot is running");
let listener = tokio::net::TcpListener::bind("0.0.0.0:8080")
.await
.attach_printable("Failed to bind to address")
.change_context(Error::Initialization)?;
axum::serve(listener, app)
.await
.attach_printable("Failed to start server")
.change_context(Error::Initialization)?;
Ok(ExitCode::SUCCESS)
}

422
src/models/member.rs Executable file
View file

@ -0,0 +1,422 @@
use error_stack::ResultExt;
use slack_morphism::prelude::*;
use sqlx::{SqlitePool, prelude::*, sqlite::SqliteQueryResult};
use tracing::{debug, warn};
use crate::id;
use super::{
Trusted, Untrusted, system,
trigger::{self, Trigger},
user,
};
#[derive(thiserror::Error, displaydoc::Display, Debug)]
pub enum Error {
/// Error while calling the database
Sqlx,
/// A field was missing from the view
MissingField(String),
}
id!(
/// For an ID to be trusted, it must
///
/// - Be a valid ID in the database
/// - Be associated with a trusted system
=> Member
);
impl Id<Untrusted> {
pub const fn new(id: i64) -> Self {
Self {
id,
trusted: std::marker::PhantomData,
}
}
pub async fn validate_by_system(
self,
system_id: system::Id<Trusted>,
db: &SqlitePool,
) -> Result<Id<Trusted>, Self> {
let exists = sqlx::query!(
"SELECT EXISTS(SELECT 1 FROM members WHERE id = $1 AND system_id = $2) AS 'exists: bool'",
self.id,
system_id.id
)
.fetch_one(db)
.await
.ok()
.is_some_and(|record| record.exists);
if exists {
Ok(Id {
id: self.id,
trusted: std::marker::PhantomData,
})
} else {
Err(self)
}
}
pub async fn validate_by_user(
self,
user_id: &user::Id<Trusted>,
db: &SqlitePool,
) -> Result<Id<Trusted>, Self> {
let exists = sqlx::query!(
"SELECT EXISTS(
SELECT 1
FROM members
JOIN systems ON members.system_id = systems.id
WHERE members.id = $1 AND systems.owner_id = $2
) AS 'exists: bool'",
self.id,
user_id
)
.fetch_one(db)
.await
.ok()
.is_some_and(|record| record.exists);
if exists {
Ok(Id {
id: self.id,
trusted: std::marker::PhantomData,
})
} else {
Err(self)
}
}
}
impl Id<Trusted> {
pub async fn fetch_triggers(
self,
db: &SqlitePool,
) -> error_stack::Result<Vec<Trigger>, trigger::Error> {
Trigger::fetch_by_member_id(db, self).await
}
}
// TODO: move sql to rust struct
#[derive(FromRow, Debug)]
pub struct Member {
/// The ID of the member
pub id: Id<Trusted>,
pub system_id: system::Id<Trusted>,
/// The display name of the member
pub display_name: String,
/// The full name of the member
pub full_name: String,
/// Profile picture to use on messages
pub profile_picture_url: Option<String>,
pub title: Option<String>,
pub pronouns: Option<String>,
pub name_pronunciation: Option<String>,
pub name_recording_url: Option<String>,
pub created_at: time::PrimitiveDateTime,
}
impl Member {
pub async fn fetch_by_and_trust_id(
system_id: system::Id<Trusted>,
member_id: Id<Untrusted>,
db: &SqlitePool,
) -> Result<Option<Self>, sqlx::Error> {
sqlx::query_as!(
Member,
r#"
SELECT
id as "id: Id<Trusted>",
system_id as "system_id: system::Id<Trusted>",
full_name,
display_name,
profile_picture_url,
title,
pronouns,
name_pronunciation,
name_recording_url,
created_at as "created_at: time::PrimitiveDateTime"
FROM members
WHERE system_id = $1 AND id = $2
"#,
system_id,
// safe because this query also checks if the ID is trusted
member_id.id
)
.fetch_optional(db)
.await
}
/// Fetch a member by their id
pub async fn fetch_by_id(
member_id: Id<Trusted>,
db: &SqlitePool,
) -> Result<Option<Self>, sqlx::Error> {
sqlx::query_as!(
Member,
r#"
SELECT
id as "id: Id<Trusted>",
system_id as "system_id: system::Id<Trusted>",
full_name,
display_name,
profile_picture_url,
title,
pronouns,
name_pronunciation,
name_recording_url,
created_at as "created_at: time::PrimitiveDateTime"
FROM members
WHERE id = $2
"#,
member_id
)
.fetch_optional(db)
.await
}
}
/// all information required to display a member
#[derive(FromRow, Debug)]
pub struct TriggeredMember {
/// The ID of the member
pub id: Id<Trusted>,
/// The display name of the member
pub display_name: String,
/// Profile picture to use on messages
pub profile_picture_url: Option<String>,
/// The trigger text that was matched
pub trigger_text: String,
/// Whether this was a prefix trigger (true) or suffix trigger (false)
pub is_prefix: bool,
}
impl From<Member> for TriggeredMember {
fn from(value: Member) -> Self {
Self {
id: value.id,
display_name: value.display_name,
profile_picture_url: value.profile_picture_url,
trigger_text: String::new(),
is_prefix: true,
}
}
}
#[derive(Default, Clone)]
pub struct View {
pub full_name: String,
pub display_name: String,
pub profile_picture_url: Option<String>,
pub title: Option<String>,
pub pronouns: Option<String>,
pub name_pronunciation: Option<String>,
pub name_recording_url: Option<String>,
}
impl View {
/// 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![
// display info
some_into(
SlackHeaderBlock::new("Display info".into()).with_block_id("display_info".into())
),
some_into(SlackInputBlock::new(
"Display name".into(),
SlackBlockPlainTextInputElement::new("display_name".into())
.with_initial_value(self.display_name)
.into(),
)),
some_into(
SlackInputBlock::new(
"Profile picture URL".into(),
SlackBlockPlainTextInputElement::new("profile_picture_url".into())
.with_initial_value(self.profile_picture_url.unwrap_or_default())
.into(),
)
.with_optional(true)
),
// personal info
some_into(SlackDividerBlock::new()),
some_into(
SlackHeaderBlock::new("Personal info".into()).with_block_id("personal_info".into())
),
some_into(SlackInputBlock::new(
"Full name".into(),
SlackBlockPlainTextInputElement::new("full_name".into())
.with_initial_value(self.full_name)
.into(),
)),
some_into(
SlackInputBlock::new(
"Pronouns".into(),
SlackBlockPlainTextInputElement::new("pronouns".into())
.with_initial_value(self.pronouns.unwrap_or_default())
.into(),
)
.with_optional(true)
),
some_into(
SlackInputBlock::new(
"Title".into(),
SlackBlockPlainTextInputElement::new("title".into())
.with_initial_value(self.title.unwrap_or_default())
.into(),
)
.with_optional(true)
),
some_into(
SlackInputBlock::new(
"Name pronunciation".into(),
SlackBlockPlainTextInputElement::new("name_pronunciation".into())
.with_initial_value(self.name_pronunciation.unwrap_or_default())
.into(),
)
.with_optional(true)
),
some_into(
SlackInputBlock::new(
"Name recording URL".into(),
SlackBlockPlainTextInputElement::new("name_recording_url".into())
.with_initial_value(self.name_recording_url.unwrap_or_default())
.into(),
)
.with_optional(true)
)
]
}
pub fn create_add_view() -> SlackView {
SlackView::Modal(
SlackModalView::new("Add a new member".into(), Self::default().create_blocks())
.with_submit("Add".into())
.with_external_id("create_member".into()),
)
}
pub fn create_edit_view(self, member_id: Id<Trusted>) -> SlackView {
SlackView::Modal(
SlackModalView::new("Edit member".into(), self.create_blocks())
.with_submit("Edit".into())
.with_external_id(format!("edit_member_{}", member_id.id)),
)
}
/// Add a member to the database
///
/// Returns the id of the new member
pub async fn add(
&self,
system_id: system::Id<Trusted>,
db: &SqlitePool,
) -> error_stack::Result<i64, Error> {
debug!("Adding member {} to database", self.display_name);
sqlx::query!("
INSERT INTO members (full_name, display_name, profile_picture_url, title, pronouns, name_pronunciation, name_recording_url, system_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id
",
self.full_name,
self.display_name,
self.profile_picture_url,
self.title,
self.pronouns,
self.name_pronunciation,
self.name_recording_url,
system_id.id,
)
.fetch_one(db)
.await
.attach_printable("Error adding member to database")
.change_context(Error::Sqlx)
.map(|row| row.id)
}
/// Update a member in the database to match this view
///
/// Returns None if the member does not exist
pub async fn update(
&self,
member_id: Id<Trusted>,
db: &SqlitePool,
) -> error_stack::Result<Option<SqliteQueryResult>, Error> {
sqlx::query!("
UPDATE members
SET full_name = $1, display_name = $2, profile_picture_url = $3, title = $4, pronouns = $5, name_pronunciation = $6, name_recording_url = $7
WHERE id = $8
",
self.full_name,
self.display_name,
self.profile_picture_url,
self.title,
self.pronouns,
self.name_pronunciation,
self.name_recording_url,
member_id,
).execute(db).await
.attach_printable("Error editing member in database")
.change_context(Error::Sqlx)
.map(Some)
}
}
impl TryFrom<SlackViewState> for View {
type Error = Error;
fn try_from(value: SlackViewState) -> Result<Self, Self::Error> {
let mut view = Self::default();
for (_id, values) in value.values {
for (id, content) in values {
match &*id.0 {
"full_name" => {
view.full_name = content
.value
.ok_or_else(|| Error::MissingField("display_name".to_string()))?;
}
"display_name" => {
view.display_name = content
.value
.ok_or_else(|| Error::MissingField("display_name".to_string()))?;
}
"profile_picture_url" => view.profile_picture_url = content.value,
"title" => view.title = content.value,
"pronouns" => view.pronouns = content.value,
"name_pronunciation" => view.name_pronunciation = content.value,
"name_recording_url" => view.name_recording_url = content.value,
other => {
warn!("Unknown field in view when parsing a member::View: {other}");
}
}
}
}
if view.full_name.is_empty() {
return Err(Error::MissingField("full_name".to_string()));
}
if view.display_name.is_empty() {
return Err(Error::MissingField("display_name".to_string()));
}
Ok(view)
}
}
impl From<Member> for View {
fn from(value: Member) -> Self {
Self {
full_name: value.full_name,
display_name: value.display_name,
profile_picture_url: value.profile_picture_url,
title: value.title,
pronouns: value.pronouns,
name_pronunciation: value.name_pronunciation,
name_recording_url: value.name_recording_url,
}
}
}

87
src/models/mod.rs Executable file
View file

@ -0,0 +1,87 @@
pub mod member;
pub mod system;
pub mod trigger;
pub mod user;
pub trait Trustability: Send + Sync {}
/// A trusted/valid ID
#[derive(Debug, Clone, Copy)]
pub struct Trusted;
impl Trustability for Trusted {}
/// An untrusted ID
#[derive(Debug, Clone, Copy)]
pub struct Untrusted;
impl Trustability for Untrusted {}
#[macro_export]
/// Creates a new ID wrapper for a database ID that can be trusted or untrusted
macro_rules! id {
($(#[$attr:meta])* => $name:ident) => {
#[derive(::sqlx::Type, Debug, PartialEq, Eq, Clone, Copy)]
$(#[$attr])*
pub struct Id<T> {
pub id: i64,
trusted: ::std::marker::PhantomData<T>,
}
impl<'q, DB> Encode<'q, DB> for Id<$crate::models::Trusted>
where
DB: ::sqlx::Database,
i64: ::sqlx::Encode<'q, DB>,
{
fn encode_by_ref(
&self,
buf: &mut <DB as ::sqlx::Database>::ArgumentBuffer<'q>,
) -> Result<::sqlx::encode::IsNull, ::sqlx::error::BoxDynError> {
<i64 as ::sqlx::Encode<'_, DB>>::encode_by_ref(&self.id, buf)
}
fn produces(&self) -> Option<<DB as ::sqlx::Database>::TypeInfo> {
<i64 as ::sqlx::Encode<'_, DB>>::produces(&self.id)
}
}
impl<'q, DB> Decode<'q, DB> for Id<$crate::models::Trusted>
where
DB: ::sqlx::Database,
i64: ::sqlx::Decode<'q, DB>,
{
fn decode(
value: <DB as ::sqlx::Database>::ValueRef<'q>,
) -> Result<Self, ::sqlx::error::BoxDynError> {
let id = <i64 as ::sqlx::Decode<'_, DB>>::decode(value)?;
Ok(Id {
id,
trusted: std::marker::PhantomData,
})
}
}
impl<DB> ::sqlx::Type<DB> for Id<Trusted>
where
DB: ::sqlx::Database,
i64: ::sqlx::Type<DB>,
{
fn type_info() -> <DB as ::sqlx::Database>::TypeInfo {
<i64 as ::sqlx::Type<DB>>::type_info()
}
}
impl ::std::fmt::Display for Id<$crate::models::Trusted> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.id)
}
}
impl ::std::fmt::Display for Id<$crate::models::Untrusted> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.id)
}
}
};
}

192
src/models/system.rs Executable file
View file

@ -0,0 +1,192 @@
use crate::{
id,
models::member::{Member, TriggeredMember},
};
use super::{
Trustability, Trusted,
member::{self},
trigger::Trigger,
user,
};
use redact::Secret;
use sqlx::{SqlitePool, prelude::*};
use tracing::debug;
id!(
/// For an ID to be trusted, it must
///
/// - Be a valid ID in the database
/// - Be associated with a valid user
=> System
);
impl Id<Trusted> {
pub async fn list_triggers(self, db: &SqlitePool) -> Result<Vec<Trigger>, sqlx::Error> {
Trigger::fetch_by_system_id(db, self).await
}
}
#[derive(Debug, FromRow, PartialEq, Eq, Clone)]
#[sqlx(transparent)]
pub struct SlackOauthToken(Secret<String>);
impl SlackOauthToken {
pub fn expose(&self) -> &str {
self.0.expose_secret()
}
}
impl From<String> for SlackOauthToken {
fn from(value: String) -> Self {
Self(Secret::new(value))
}
}
#[derive(FromRow, Debug)]
pub struct System {
#[sqlx(flatten)]
pub id: Id<Trusted>,
pub owner_id: user::Id<Trusted>,
pub active_member_id: Option<member::Id<Trusted>>,
pub trigger_changes_active_member: bool,
pub name: String,
pub slack_oauth_token: SlackOauthToken,
pub created_at: time::PrimitiveDateTime,
}
#[derive(Debug, thiserror::Error, displaydoc::Display)]
/// Error while changing the active member
pub enum ChangeActiveMemberError {
/// Error while calling the database
Sqlx(#[from] sqlx::Error),
/// The member is not part of the system
MemberNotFound,
}
impl System {
pub async fn fetch_by_user_id<T>(
db: &SqlitePool,
user_id: &user::Id<T>,
) -> Result<Option<Self>, sqlx::Error>
where
T: Trustability,
{
sqlx::query_as!(
System,
r#"
SELECT
id as "id: Id<Trusted>",
owner_id as "owner_id: user::Id<Trusted>",
active_member_id as "active_member_id: member::Id<Trusted>",
trigger_changes_active_member,
slack_oauth_token,
name,
created_at as "created_at: time::PrimitiveDateTime"
FROM
systems
WHERE owner_id = $1
"#,
// This is safe, as this function effectively checks if the user is trusted before fetching the system
user_id.id
)
.fetch_optional(db)
.await
}
pub async fn active_member(&self, db: &SqlitePool) -> Result<Option<Member>, sqlx::Error> {
match self.active_member_id {
Some(id) => Member::fetch_by_id(id, db).await,
None => Ok(None),
}
}
#[tracing::instrument(skip(db))]
pub async fn change_active_member(
&mut self,
new_active_member_id: Option<member::Id<Trusted>>,
db: &SqlitePool,
) -> Result<Option<Member>, ChangeActiveMemberError> {
debug!(
"Changing active member for {} to {:?}",
self.id, new_active_member_id
);
let mut new_active_member = None;
if let Some(new_active_member_id) = new_active_member_id {
let Some(member) = Member::fetch_by_id(new_active_member_id, db).await? else {
return Err(ChangeActiveMemberError::MemberNotFound);
};
new_active_member = Some(member);
}
sqlx::query!(
r#"
UPDATE systems
SET active_member_id = $1
WHERE id = $2
"#,
new_active_member_id,
self.id
)
.execute(db)
.await?;
self.active_member_id = new_active_member_id;
Ok(new_active_member)
}
pub async fn get_members(&self, db: &SqlitePool) -> Result<Vec<Member>, sqlx::Error> {
sqlx::query_as!(
Member,
r#"
SELECT
id as "id: member::Id<Trusted>",
system_id as "system_id: Id<Trusted>",
full_name,
display_name,
profile_picture_url,
title,
pronouns,
name_pronunciation,
name_recording_url,
created_at as "created_at: time::PrimitiveDateTime"
FROM
members
WHERE system_id = $1
"#,
self.id
)
.fetch_all(db)
.await
}
pub async fn fetch_triggered_member(
&self,
db: &SqlitePool,
message: &str,
) -> Result<Option<TriggeredMember>, sqlx::Error> {
sqlx::query_as!(
TriggeredMember,
r#"
SELECT
members.id as "id: member::Id<Trusted>",
display_name,
profile_picture_url,
triggers.text as trigger_text,
triggers.is_prefix
FROM
members
JOIN
triggers ON members.id = triggers.member_id
WHERE
(triggers.is_prefix = TRUE AND ?1 LIKE triggers.text || '%') OR
(triggers.is_prefix = FALSE AND ?1 LIKE '%' || triggers.text)
"#,
message
)
.fetch_optional(db)
.await
}
}

325
src/models/trigger.rs Executable file
View file

@ -0,0 +1,325 @@
use crate::id;
use super::{Trustability, Trusted, Untrusted, member, system};
use error_stack::ResultExt;
use slack_morphism::prelude::*;
use sqlx::{SqlitePool, prelude::*};
use tracing::{debug, warn};
id!(
/// For an ID to be trusted, it must
///
/// - Be a valid ID in the database
/// - Be associated with a valid member or system
=> Trigger
);
impl Id<Untrusted> {
pub const fn new(id: i64) -> Self {
Self {
id,
trusted: std::marker::PhantomData,
}
}
pub async fn validate_by_system(
self,
system_id: system::Id<Trusted>,
db: &SqlitePool,
) -> Result<Id<Trusted>, Self> {
let exists = sqlx::query!(
"SELECT EXISTS(SELECT 1 FROM triggers WHERE id = $1 AND system_id = $2) AS 'exists: bool'",
self.id,
system_id.id
)
.fetch_one(db)
.await
.ok()
.is_some_and(|record| record.exists);
if exists {
Ok(Id {
id: self.id,
trusted: std::marker::PhantomData,
})
} else {
Err(self)
}
}
}
impl Id<Trusted> {
pub async fn delete(self, db_pool: &SqlitePool) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"
DELETE FROM triggers
WHERE id = $1
"#,
self.id
)
.execute(db_pool)
.await
.map(|_| ())
}
}
#[derive(thiserror::Error, displaydoc::Display, Debug)]
pub enum Error {
/// Error while calling the database
Sqlx,
}
#[derive(FromRow, Debug)]
pub struct Trigger {
pub id: Id<Trusted>,
pub member_id: member::Id<Trusted>,
pub system_id: system::Id<Trusted>,
pub text: String,
pub is_prefix: bool,
}
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,
is_prefix
FROM
triggers
WHERE id = $1
"#,
id.id,
)
.fetch_optional(db)
.await
}
pub async fn fetch_by_system_id(
db: &SqlitePool,
system_id: system::Id<Trusted>,
) -> Result<Vec<Self>, sqlx::Error> {
sqlx::query_as!(
Trigger,
r#"
SELECT
triggers.id as "id: Id<Trusted>",
triggers.member_id as "member_id: member::Id<Trusted>",
triggers.system_id as "system_id: system::Id<Trusted>",
triggers.text,
triggers.is_prefix
FROM
triggers
WHERE
system_id = $1
"#,
system_id
)
.fetch_all(db)
.await
}
pub async fn fetch_by_member_id(
db: &SqlitePool,
member_id: member::Id<Trusted>,
) -> error_stack::Result<Vec<Self>, Error> {
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,
is_prefix
FROM
triggers
WHERE member_id = $1
"#,
member_id,
)
.fetch_all(db)
.await
.change_context(Error::Sqlx)
}
}
#[derive(Debug)]
pub struct View {
pub text: String,
pub is_prefix: bool,
}
impl Default for View {
fn default() -> Self {
Self {
text: String::new(),
is_prefix: true,
}
}
}
impl View {
pub fn create_blocks(self) -> Vec<SlackBlock> {
let prefix_choice =
SlackBlockChoiceItem::new(SlackBlockText::Plain("prefix".into()), "prefix".into());
let suffix_choice =
SlackBlockChoiceItem::new(SlackBlockText::Plain("suffix".into()), "suffix".into());
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(
"is_prefix".into(),
vec![prefix_choice.clone(), suffix_choice.clone()]
)
.with_initial_option(if self.is_prefix {
prefix_choice
} else {
suffix_choice
})
.into(),
)
.with_optional(false)
)
)
}
/// Add a trigger to the database
///
/// Returns the id of the new trigger
pub async fn add(
&self,
system_id: system::Id<Trusted>,
member_id: member::Id<Trusted>,
db_pool: &SqlitePool,
) -> error_stack::Result<Id<Trusted>, Error> {
debug!(
"Adding trigger for {} (Member ID {}) to database",
system_id, member_id
);
sqlx::query!(
r#"
INSERT INTO triggers (system_id, member_id, text, is_prefix)
VALUES ($1, $2, $3, $4)
RETURNING id
"#,
system_id.id,
member_id.id,
self.text,
self.is_prefix
)
.fetch_one(db_pool)
.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
pub async fn update(
&self,
trigger_id: Id<Trusted>,
db: &SqlitePool,
) -> error_stack::Result<(), Error> {
sqlx::query!(
r#"
UPDATE triggers
SET text = $1, is_prefix = $2
WHERE id = $3
"#,
self.text,
self.is_prefix,
trigger_id.id,
)
.execute(db)
.await
.attach_printable("Error updating trigger in database")
.change_context(Error::Sqlx)
.map(|_| ())
}
pub const fn new(trigger_text: String, is_prefix: bool) -> Self {
Self {
text: trigger_text,
is_prefix,
}
}
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;
}
}
"is_prefix" => {
if let Some(option) = content.selected_option {
view.is_prefix = option.value == "prefix";
}
}
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,
is_prefix: trigger.is_prefix,
}
}
}

151
src/models/user.rs Executable file
View file

@ -0,0 +1,151 @@
use std::{fmt::Display, marker::PhantomData};
use slack_morphism::{errors::SlackClientError, prelude::*};
use sqlx::{prelude::*, types::Text, Database, SqlitePool};
use crate::BOT_TOKEN;
use super::{Trusted, Untrusted};
#[derive(Type, Debug, PartialEq, Eq, Clone)]
pub struct Id<T> {
pub id: Text<SlackUserId>,
trusted: PhantomData<T>,
}
impl<'q, DB> Encode<'q, DB> for Id<Trusted>
where
DB: Database,
Text<SlackUserId>: Encode<'q, DB>,
{
fn encode_by_ref(
&self,
buf: &mut <DB as Database>::ArgumentBuffer<'q>,
) -> Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> {
<Text<SlackUserId> as Encode<'_, DB>>::encode_by_ref(&self.id, buf)
}
fn produces(&self) -> Option<<DB as sqlx::Database>::TypeInfo> {
<Text<SlackUserId> as sqlx::Encode<'_, DB>>::produces(&self.id)
}
}
impl<'q, DB> Decode<'q, DB> for Id<Trusted>
where
DB: sqlx::Database,
Text<SlackUserId>: sqlx::Decode<'q, DB>,
{
fn decode(
value: <DB as sqlx::Database>::ValueRef<'q>,
) -> Result<Self, sqlx::error::BoxDynError> {
let id = <Text<SlackUserId> as sqlx::Decode<'_, DB>>::decode(value)?;
Ok(Self {
id,
trusted: PhantomData,
})
}
}
impl<DB> ::sqlx::Type<DB> for Id<Trusted>
where
DB: Database,
Text<SlackUserId>: sqlx::Type<DB>,
{
fn type_info() -> <DB as Database>::TypeInfo {
<Text<SlackUserId> as sqlx::Type<DB>>::type_info()
}
}
impl Display for Id<Trusted> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "User(ID: {}. Trusted)", self.id.0)
}
}
impl Display for Id<Untrusted> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "User(ID: {}. Untrusted)", self.id.0)
}
}
impl Id<Untrusted> {
/// Transforms <@U1234|user> into an Id with the value U1234
pub fn from_slack_escaped(escaped: &str) -> Option<Self> {
parse_slack_user_id(escaped)
}
pub const fn new(id: SlackUserId) -> Self {
Self {
id: Text(id),
trusted: PhantomData,
}
}
}
impl Id<Untrusted> {
/// Trusts a user ID by verifying it exists
pub async fn trust<SCHC>(
self,
client: &SlackClient<SCHC>,
) -> Result<Id<Trusted>, SlackClientError>
where
SCHC: SlackClientHttpConnector + Send + Sync,
{
let session = client.open_session(&BOT_TOKEN);
let response = session
.users_profile_get(&SlackApiUsersProfileGetRequest::new().with_user(self.id.0))
.await?;
Ok(Id {
id: Text(response.profile.id.expect("Profile ID to exist")),
trusted: PhantomData,
})
}
}
pub fn parse_slack_user_id(escaped: &str) -> Option<Id<Untrusted>> {
escaped
.strip_prefix("<@")
.and_then(|s| s.strip_suffix('>'))
.and_then(|s| s.split('|').next())
.filter(|s| !s.is_empty())
.filter(|s| s.starts_with('U'))
.map(|s| SlackUserId::new(s.to_string()))
.map(|s| Id {
id: Text(s),
trusted: PhantomData,
})
}
impl From<Id<Trusted>> for SlackUserId {
fn from(value: Id<Trusted>) -> Self {
value.id.0
}
}
impl From<SlackUserId> for Id<Trusted> {
fn from(value: SlackUserId) -> Self {
Self {
id: Text(value),
trusted: PhantomData,
}
}
}
impl<T> PartialEq<SlackUserId> for Id<T> {
fn eq(&self, other: &SlackUserId) -> bool {
self.id.0 == *other
}
}
impl<T> PartialEq<Id<T>> for SlackUserId {
fn eq(&self, other: &Id<T>) -> bool {
*self == *other.id
}
}
#[derive(Debug, Clone)]
pub struct State {
pub db: SqlitePool,
}

193
src/oauth.rs Executable file
View file

@ -0,0 +1,193 @@
use axum::{
extract::{FromRequestParts, Query, State},
http::{StatusCode, request::Parts},
};
use oauth2::{
AuthUrl, AuthorizationCode, ClientId, ClientSecret, EndpointNotSet, EndpointSet, RedirectUrl,
TokenUrl, reqwest,
};
use serde::{Deserialize, Serialize};
use slack_morphism::{SlackClient, SlackUserId, prelude::*};
use tracing::error;
use crate::{
env,
models::{Trusted, user},
};
#[derive(Serialize, Deserialize, Debug)]
pub struct SlackAuthedUser {
pub id: String,
pub scope: String,
pub access_token: String,
pub token_type: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct SlackTokenFields {
pub authed_user: SlackAuthedUser,
}
impl oauth2::ExtraTokenFields for SlackTokenFields {}
pub type SlackOauthClient<
HasAuthUrl = EndpointSet,
HasDeviceAuthUrl = EndpointNotSet,
HasIntrospectionUrl = EndpointNotSet,
HasRevocationUrl = EndpointNotSet,
HasTokenUrl = EndpointSet,
> = oauth2::Client<
oauth2::StandardErrorResponse<oauth2::basic::BasicErrorResponseType>,
oauth2::StandardTokenResponse<SlackTokenFields, oauth2::basic::BasicTokenType>,
oauth2::basic::BasicTokenIntrospectionResponse,
oauth2::StandardRevocableToken,
oauth2::basic::BasicRevocationErrorResponse,
HasAuthUrl,
HasDeviceAuthUrl,
HasIntrospectionUrl,
HasRevocationUrl,
HasTokenUrl,
>;
pub fn create_oauth_client() -> SlackOauthClient {
SlackOauthClient::new(ClientId::new(env::slack_client_id()))
.set_client_secret(ClientSecret::new(env::slack_client_secret()))
.set_auth_uri(AuthUrl::new("https://slack.com/oauth/v2/authorize".to_owned()).unwrap())
.set_token_uri(TokenUrl::new("https://slack.com/api/oauth.v2.access".to_owned()).unwrap())
.set_redirect_uri(
RedirectUrl::new("https://slack-system-bot.wobbl.in/auth".to_owned()).unwrap(),
)
}
#[derive(Deserialize)]
pub struct OauthCode {
pub code: String,
pub state: String,
}
pub struct Url(url::Url);
impl<S> FromRequestParts<S> for Url
where
S: Send + Sync,
{
type Rejection = (StatusCode, &'static str);
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let url = url::Url::parse(&parts.uri.to_string())
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid URL"))?;
Ok(Self(url))
}
}
pub async fn oauth_handler(
Query(code): Query<OauthCode>,
State(state): State<user::State>,
Url(url): Url,
) -> String {
let db = &state.db;
// Retrieve the csrf token and pkce verifier
let csrf = sqlx::query!(
r#"
SELECT
owner_id as "owner_id: user::Id<Trusted>",
name
FROM
system_oauth_process
WHERE csrf = $1
"#,
code.state
)
.fetch_optional(db)
.await;
match csrf {
Ok(Some(record)) => {
let client = create_oauth_client();
let slack_client = SlackClient::new(SlackClientHyperConnector::new().unwrap());
let response = client
.exchange_code(AuthorizationCode::new(code.code))
.request_async(&reqwest::Client::new())
.await
.unwrap();
let user_token = response.extra_fields().authed_user.access_token.clone();
let user_id = response.extra_fields().authed_user.id.clone();
let user_id: SlackUserId = user_id.into();
if user_id != record.owner_id {
return "CSRF token doesn't match the user".to_owned();
}
let user = sqlx::query!(
r#"
INSERT INTO systems (name, owner_id, slack_oauth_token)
VALUES ($1, $2, $3)
RETURNING name
"#,
record.name,
record.owner_id.id,
user_token,
)
.fetch_one(db)
.await;
match user {
Ok(user) => {
sqlx::query!(
r#"
DELETE FROM system_oauth_process
WHERE csrf = $1
"#,
code.state
)
.execute(db)
.await
.unwrap();
let response = format!("System {} created!", user.name);
if let Err(e) = slack_client
.post_webhook_message(
&url,
&SlackApiPostWebhookMessageRequest::new(
SlackMessageContent::new()
.with_text(response.clone()),
),
)
.await {
error!("Error sending Slack message: {:#?}", e);
}
response
}
Err(e) => {
let response = format!("Error creating system: {e:#?}");
if let Err(e) = slack_client
.post_webhook_message(
&url,
&SlackApiPostWebhookMessageRequest::new(
SlackMessageContent::new()
.with_text(response.clone()),
),
)
.await {
error!("Error sending Slack message: {:#?}", e);
}
error!("{response}");
response
}
}
}
Ok(None) => {
"CSRF couldn't be linked to a user. Theres a middleman attack at play or I didn't save the token properly".to_owned()
}
Err(e) => {
error!("Error fetching CSRF token: {:#?}", e);
"Error fetching CSRF token".to_owned()
}
}
}