mirror of
https://github.com/System-End/plura.git
synced 2026-04-19 15:18:23 +00:00
feat: initial commit
This commit is contained in:
commit
7ee71d4a95
30 changed files with 7257 additions and 0 deletions
9
.env.example
Executable file
9
.env.example
Executable 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
18
.gitignore
vendored
Executable 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
3719
Cargo.lock
generated
Executable file
File diff suppressed because it is too large
Load diff
45
Cargo.toml
Executable file
45
Cargo.toml
Executable 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
2
README.md
Executable file
|
|
@ -0,0 +1,2 @@
|
|||
# slack-system-bot
|
||||
A bot of all time for plural folks :D
|
||||
5
build.rs
Executable file
5
build.rs
Executable 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");
|
||||
}
|
||||
9
migrations/20241217133534_init_systems.sql
Executable file
9
migrations/20241217133534_init_systems.sql
Executable 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
|
||||
);
|
||||
22
migrations/20241217163630_init_members.sql
Executable file
22
migrations/20241217163630_init_members.sql
Executable 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)
|
||||
);
|
||||
71
migrations/20250109093158_switch_triggers.sql
Executable file
71
migrations/20250109093158_switch_triggers.sql
Executable 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;
|
||||
3
migrations/20250110153933_systems_owner_index.sql
Executable file
3
migrations/20250110153933_systems_owner_index.sql
Executable 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);
|
||||
7
migrations/20250112134655_system_oauth_process.sql
Executable file
7
migrations/20250112134655_system_oauth_process.sql
Executable 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
|
||||
);
|
||||
100
migrations/20250608185643_merge_trigger_fields.sql
Normal file
100
migrations/20250608185643_merge_trigger_fields.sql
Normal 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;
|
||||
3
migrations/20250608190743_rename_trigger_text.sql
Normal file
3
migrations/20250608190743_rename_trigger_text.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
-- Add migration script here
|
||||
ALTER TABLE triggers
|
||||
RENAME COLUMN trigger_text TO text;
|
||||
3
rust-toolchain.toml
Executable file
3
rust-toolchain.toml
Executable file
|
|
@ -0,0 +1,3 @@
|
|||
[toolchain]
|
||||
channel = "stable"
|
||||
components = ["rustfmt", "rust-src", "rust-analyzer"]
|
||||
342
src/commands/members.rs
Executable file
342
src/commands/members.rs
Executable 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
125
src/commands/mod.rs
Executable 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
216
src/commands/system.rs
Executable 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
306
src/commands/triggers.rs
Executable 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
26
src/env.rs
Executable 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
291
src/events/mod.rs
Executable 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
107
src/interactions/member.rs
Executable 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
189
src/interactions/mod.rs
Executable 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
101
src/interactions/trigger.rs
Normal 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
168
src/main.rs
Executable 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
422
src/models/member.rs
Executable 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
87
src/models/mod.rs
Executable 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
192
src/models/system.rs
Executable 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
325
src/models/trigger.rs
Executable 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
151
src/models/user.rs
Executable 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
193
src/oauth.rs
Executable 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue