chore: init

Rewrite placidticket code
This commit is contained in:
azyrite 2025-02-21 22:52:20 +11:00
commit a517812c48
Signed by: azyrite
SSH key fingerprint: SHA256:YlQ5V4DtSbnuUxJxw4cwU7L9q8NbeAOAsK4NZWybTkM
25 changed files with 3992 additions and 0 deletions

4
.env.example Normal file
View file

@ -0,0 +1,4 @@
DISCORD_TOKEN=
ENV=development # can be development or production
TEST_GUILD= # the guild id to use for testing
DATABASE_URL=postgres:// # the database url; must be postgres

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/target
.env

2816
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

20
Cargo.toml Normal file
View file

@ -0,0 +1,20 @@
[package]
name = "placid"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = "1.0.96"
chrono = "0.4.39"
diesel = { version = "2.2.7", features = ["chrono"] }
diesel-async = { version = "0.5.2", features = [
"deadpool",
"postgres",
"async-connection-wrapper",
] }
diesel_migrations = { version = "2.2.0", features = ["postgres"] }
dotenvy = "0.15.7"
poise = "0.6.1"
serde = { version = "1.0.218", features = ["derive"] }
tokio = { version = "1.43.0", features = ["full"] }
toml = "0.8.20"

9
config.toml Normal file
View file

@ -0,0 +1,9 @@
[access]
roles = [1325945997016174683]
# TODO: non hard coded categories
[categories]
general = 1341652978385551424
collab = 1341652998451105852
bug = 1341653036245975122
suggestion = 1341653055049043980

11
diesel.toml Normal file
View file

@ -0,0 +1,11 @@
# For documentation on how to configure this file,
# see https://diesel.rs/guides/configuring-diesel-cli
[print_schema]
file = "src/db/schema.rs"
custom_type_derives = ["diesel::query_builder::QueryId", "Debug"]
generate_missing_sql_type_definitions = false
import_types = ["diesel::sql_types::*", "crate::db::models::exports::*"]
[migrations_directory]
dir = "./migrations"

View file

@ -0,0 +1,6 @@
-- This file was automatically created by Diesel to setup helper functions
-- and other internal bookkeeping. This file is safe to edit, any future
-- changes will be added to existing projects as new migrations.
DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass);
DROP FUNCTION IF EXISTS diesel_set_updated_at();

View file

@ -0,0 +1,36 @@
-- This file was automatically created by Diesel to setup helper functions
-- and other internal bookkeeping. This file is safe to edit, any future
-- changes will be added to existing projects as new migrations.
-- Sets up a trigger for the given table to automatically set a column called
-- `updated_at` whenever the row is modified (unless `updated_at` was included
-- in the modified columns)
--
-- # Example
--
-- ```sql
-- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW());
--
-- SELECT diesel_manage_updated_at('users');
-- ```
CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS $$
BEGIN
EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s
FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl);
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS $$
BEGIN
IF (
NEW IS DISTINCT FROM OLD AND
NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at
) THEN
NEW.updated_at := current_timestamp;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;

View file

@ -0,0 +1,4 @@
-- This file should undo anything in `up.sql`
DROP TABLE tickets;
DROP TABLE users;
DROP TYPE ticket_status;

View file

@ -0,0 +1,25 @@
-- Your SQL goes here
CREATE TABLE users (
id BIGINT PRIMARY KEY
);
CREATE TYPE ticket_status AS ENUM ('open', 'closed', 'deleted');
CREATE TABLE tickets (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT REFERENCES users(id) NOT NULL,
channel_id BIGINT,
starting_message TEXT NOT NULL,
category TEXT NOT NULL,
vip BOOLEAN DEFAULT FALSE NOT NULL,
status ticket_status DEFAULT 'open' NOT NULL,
closed_by BIGINT REFERENCES users(id),
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
closed_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ
);
SELECT diesel_manage_updated_at('tickets');

2
src/cmd/mod.rs Normal file
View file

@ -0,0 +1,2 @@
mod ticketpanel;
pub use ticketpanel::*;

56
src/cmd/ticketpanel.rs Normal file
View file

@ -0,0 +1,56 @@
use crate::types;
use poise::{serenity_prelude as serenity, CreateReply};
use serenity::{ButtonStyle, CreateActionRow, CreateButton, CreateEmbed, CreateMessage};
#[poise::command(
slash_command,
category = "Tickets",
guild_only,
required_permissions = "MANAGE_CHANNELS"
)]
pub async fn ticketpanel(ctx: types::Context<'_>) -> types::Result<()> {
let embed = CreateEmbed::new()
.title("Create a ticket!")
// Pastel green from catppuccin
.colour(0xa6e3a1)
.description(
"Use one of the buttons below to open a ticket and speak directly with the team.\n\nNever share your seed phrase, private keys, or passwords.",
);
let components = CreateActionRow::Buttons(vec![
CreateButton::new("ticket_create:general")
.label("General")
.emoji('📩')
.style(ButtonStyle::Primary),
CreateButton::new("ticket_create:collab")
.label("Collab")
.emoji('🤝')
.style(ButtonStyle::Success),
CreateButton::new("ticket_create:bug")
.label("Bug")
.emoji('🐛')
.style(ButtonStyle::Secondary),
CreateButton::new("ticket_create:suggestion")
.label("Suggestion")
.emoji('💡')
.style(ButtonStyle::Secondary),
]);
ctx.send(
CreateReply::default()
.ephemeral(true)
.content("Successfully created ticket panel in this channel!"),
)
.await?;
ctx.channel_id()
.send_message(
ctx,
CreateMessage::new()
.embed(embed)
.components(vec![components]),
)
.await?;
Ok(())
}

148
src/config.rs Normal file
View file

@ -0,0 +1,148 @@
use std::{collections::HashMap, fmt, ops::Deref};
use dotenvy::dotenv;
use poise::serenity_prelude::prelude::TypeMapKey;
use serde::Deserialize;
pub struct CategoriesMap(pub HashMap<String, u64>);
impl TypeMapKey for CategoriesMap {
type Value = Self;
}
impl Deref for CategoriesMap {
type Target = HashMap<String, u64>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl<'de> Deserialize<'de> for CategoriesMap {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let map = HashMap::<String, u64>::deserialize(deserializer)?;
Ok(Self(map))
}
}
pub struct AllowedRoles(pub Vec<u64>);
impl TypeMapKey for AllowedRoles {
type Value = Self;
}
impl Deref for AllowedRoles {
type Target = Vec<u64>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl<'de> Deserialize<'de> for AllowedRoles {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let vec = Vec::<u64>::deserialize(deserializer)?;
Ok(Self(vec))
}
}
/// Bot configuration
pub struct Config {
/// Discord bot token... this is a secret!
pub token: String,
/// Whether the bot is running in production
pub prod: bool,
/// The guild to use for testing, if in development
pub test_guild: Option<u64>,
/// Database URL
pub db_url: String,
/// Maps category names to their respective category IDs in Discord
///
/// Read from config.toml
pub categories: CategoriesMap,
/// Access configuration
///
/// Configures which users can access all tickets (typically mods)
pub access: AccessConfig,
}
/// Load environment variable or panic with a message
macro_rules! chkenv {
($name:expr) => {
std::env::var($name).expect(concat!($name, " environment variable is required"))
};
}
impl Config {
/// Load configuration, panicing if anything required is missing
///
/// Calls `dotenv` to load environment variables from a `.env` file
///
/// Reads some config parameters from config.toml
pub fn build() -> Self {
// Load dotenv file
dotenv().ok();
// Load from envrionment variables
let token = chkenv!("DISCORD_TOKEN");
let prod = match chkenv!("ENV").as_str() {
"development" => false,
"production" => true,
_ => panic!("ENV must be either 'development' or 'production'"),
};
let test_guild = match prod {
true => None,
false => Some(
chkenv!("TEST_GUILD")
.parse()
.expect("TEST_GUILD must be a valid u64 snowflake"),
),
};
let db_url = chkenv!("DATABASE_URL");
// Try to read config file
let config_file = ConfigFile::load();
Self {
token,
prod,
test_guild,
db_url,
categories: config_file.categories,
access: config_file.access,
}
}
}
impl fmt::Display for Config {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Config {{ token: [REDACTED], prod: {}, test_guild: {:?}, db_url: [REDACTED] }}",
self.prod, self.test_guild
)
}
}
#[derive(Deserialize)]
struct ConfigFile {
pub categories: CategoriesMap,
pub access: AccessConfig,
}
#[derive(Deserialize)]
pub struct AccessConfig {
pub roles: AllowedRoles,
}
impl ConfigFile {
pub fn load() -> Self {
let config_path = std::env::var("CONFIG_PATH").unwrap_or("config.toml".to_string());
let config = std::fs::read_to_string(config_path).expect("Failed to read config.toml");
let config: ConfigFile = toml::from_str(&config).expect("Failed to parse config.toml");
config
}
}

65
src/db/connection.rs Normal file
View file

@ -0,0 +1,65 @@
use poise::serenity_prelude as serenity;
use serenity::prelude::TypeMapKey;
use diesel_async::{
async_connection_wrapper::AsyncConnectionWrapper,
pooled_connection::{
deadpool::{self, Object, PoolError},
AsyncDieselConnectionManager,
},
AsyncConnection, AsyncPgConnection,
};
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
const MIGRATIONS: EmbeddedMigrations = embed_migrations!();
pub type DbPool = deadpool::Pool<AsyncPgConnection>;
pub struct WrappedDbPool(pub DbPool);
impl std::ops::Deref for WrappedDbPool {
type Target = DbPool;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl std::ops::DerefMut for WrappedDbPool {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl TypeMapKey for WrappedDbPool {
type Value = Self;
}
/// Establish a connection to the db through deadpool and diesel_async
pub async fn pool(db_url: &str) -> Result<DbPool, deadpool::BuildError> {
let config = AsyncDieselConnectionManager::<AsyncPgConnection>::new(db_url);
deadpool::Pool::builder(config).build()
}
pub async fn run_migrations<A>(connection: A) -> Result<(), Box<dyn std::error::Error>>
where
A: AsyncConnection<Backend = diesel::pg::Pg> + 'static,
{
let mut async_wrapper: AsyncConnectionWrapper<A> = AsyncConnectionWrapper::from(connection);
tokio::task::spawn_blocking(move || {
async_wrapper
.run_pending_migrations(MIGRATIONS)
.expect("Failed to run database migrations");
})
.await
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
}
/// Get a connection from the pool provided a Serenity context
pub async fn ctx_conn(ctx: &serenity::Context) -> Result<Object<AsyncPgConnection>, PoolError> {
let data = ctx.data.read().await;
let pool = data
.get::<WrappedDbPool>()
.expect("Expected db pool in context");
pool.get().await
}

6
src/db/mod.rs Normal file
View file

@ -0,0 +1,6 @@
mod connection;
mod schema;
pub use connection::*;
pub mod models;

195
src/db/models.rs Normal file
View file

@ -0,0 +1,195 @@
use super::schema;
use std::io::Write;
use diesel::{
deserialize::{FromSql, FromSqlRow},
expression::AsExpression,
pg::PgValue,
prelude::*,
query_builder::QueryId,
serialize::ToSql,
sql_types::SqlType,
};
use diesel_async::{AsyncPgConnection, RunQueryDsl};
#[derive(Queryable, Identifiable, Insertable, PartialEq, Debug, Selectable)]
#[diesel(table_name = schema::users)]
pub struct User {
pub id: i64,
}
impl User {
pub async fn get_or_insert(
conn: &mut AsyncPgConnection,
id: u64,
) -> Result<User, diesel::result::Error> {
let insert = User { id: id as i64 };
let data = diesel::insert_into(schema::users::table)
.values(&insert)
.on_conflict(schema::users::id)
// Do update to force a return
.do_update()
.set(schema::users::id.eq(schema::users::id))
.returning(schema::users::all_columns)
.get_result(conn)
.await?;
Ok(data)
}
}
#[derive(SqlType, QueryId)]
#[diesel(postgres_type(name = "ticket_status"))]
pub struct TicketStatusType;
#[derive(Clone, Copy, Debug, PartialEq, Eq, FromSqlRow, AsExpression)]
#[diesel(sql_type = TicketStatusType)]
pub enum TicketStatus {
Open,
Closed,
Deleted,
}
impl ToSql<TicketStatusType, diesel::pg::Pg> for TicketStatus {
fn to_sql(
&self,
out: &mut diesel::serialize::Output<diesel::pg::Pg>,
) -> diesel::serialize::Result {
match self {
TicketStatus::Open => out.write_all(b"open")?,
TicketStatus::Closed => out.write_all(b"closed")?,
TicketStatus::Deleted => out.write_all(b"deleted")?,
}
Ok(diesel::serialize::IsNull::No)
}
}
impl FromSql<TicketStatusType, diesel::pg::Pg> for TicketStatus {
fn from_sql(bytes: PgValue<'_>) -> diesel::deserialize::Result<Self> {
match bytes.as_bytes() {
b"open" => Ok(TicketStatus::Open),
b"closed" => Ok(TicketStatus::Closed),
b"deleted" => Ok(TicketStatus::Deleted),
_ => Err("Unrecognized enum variant".into()),
}
}
}
impl From<&str> for TicketStatus {
fn from(s: &str) -> Self {
match s {
"open" => Self::Open,
"closed" => Self::Closed,
"deleted" => Self::Deleted,
_ => panic!("Invalid ticket status"),
}
}
}
impl From<TicketStatus> for &'static str {
fn from(s: TicketStatus) -> Self {
match s {
TicketStatus::Open => "open",
TicketStatus::Closed => "closed",
TicketStatus::Deleted => "deleted",
}
}
}
#[derive(Queryable, Identifiable, Debug, PartialEq, Selectable, Associations)]
#[diesel(table_name = schema::tickets, belongs_to(User))]
pub struct Ticket {
pub id: i64,
pub user_id: i64,
pub channel_id: Option<i64>,
pub starting_message: String,
pub category: String,
pub vip: bool,
pub status: TicketStatus,
pub closed_by: Option<i64>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
pub closed_at: Option<chrono::DateTime<chrono::Utc>>,
pub deleted_at: Option<chrono::DateTime<chrono::Utc>>,
}
impl Ticket {
/// Get a ticket by its id
pub async fn get(
conn: &mut AsyncPgConnection,
get_id: i64,
) -> Result<Option<Ticket>, diesel::result::Error> {
use schema::tickets::dsl::*;
tickets.find(get_id).first(conn).await.optional()
}
/// Insert a new ticket into the database
///
/// **Remember** to add the channel_id in the same tx
pub async fn insert(
conn: &mut AsyncPgConnection,
insert: InsertTicket,
) -> Result<Ticket, diesel::result::Error> {
use schema::tickets::dsl::*;
let ticket = diesel::insert_into(tickets)
.values(&insert)
.get_result(conn)
.await?;
Ok(ticket)
}
/// Set the channel_id for a ticket
///
/// Ideally should be done within the same transaction as creating the ticket
/// and the channel for maximum safety
pub async fn set_channel(
&self,
conn: &mut AsyncPgConnection,
set_id: u64,
) -> Result<usize, diesel::result::Error> {
use schema::tickets::dsl::*;
let set_id = set_id as i64;
diesel::update(tickets.filter(id.eq(self.id)))
.set(channel_id.eq(set_id))
.execute(conn)
.await
}
/// Mark this ticket as deleted
///
/// Does not delete any Discord channels
pub async fn mark_deleted(
&self,
conn: &mut AsyncPgConnection,
) -> Result<usize, diesel::result::Error> {
use schema::tickets::dsl::*;
diesel::update(tickets.filter(id.eq(self.id)))
.set((
deleted_at.eq(chrono::Utc::now()),
status.eq(TicketStatus::Deleted),
))
.execute(conn)
.await
}
}
#[derive(Insertable, Debug)]
#[diesel(table_name = schema::tickets)]
pub struct InsertTicket {
pub user_id: i64,
pub starting_message: String,
pub category: String,
/// Defaults to false
pub vip: Option<bool>,
}
pub mod exports {
pub use super::TicketStatusType as TicketStatus;
}

32
src/db/schema.rs Normal file
View file

@ -0,0 +1,32 @@
// @generated automatically by Diesel CLI.
diesel::table! {
use diesel::sql_types::*;
use crate::db::models::exports::*;
tickets (id) {
id -> Int8,
user_id -> Int8,
channel_id -> Nullable<Int8>,
starting_message -> Text,
category -> Text,
vip -> Bool,
status -> TicketStatus,
closed_by -> Nullable<Int8>,
created_at -> Timestamptz,
updated_at -> Timestamptz,
closed_at -> Nullable<Timestamptz>,
deleted_at -> Nullable<Timestamptz>,
}
}
diesel::table! {
use diesel::sql_types::*;
use crate::db::models::exports::*;
users (id) {
id -> Int8,
}
}
diesel::allow_tables_to_appear_in_same_query!(tickets, users,);

133
src/handlers/closebutton.rs Normal file
View file

@ -0,0 +1,133 @@
use poise::serenity_prelude as serenity;
use serenity::{
async_trait, ButtonStyle, ChannelId, ComponentInteractionDataKind, Context, CreateActionRow,
CreateButton, CreateInteractionResponse, CreateInteractionResponseMessage,
EditInteractionResponse, EventHandler, Interaction,
};
use crate::db;
pub struct CloseButtonHandler;
#[async_trait]
impl EventHandler for CloseButtonHandler {
async fn interaction_create(&self, ctx: Context, interaction: Interaction) {
let Some(interaction) = interaction.message_component() else {
return;
};
if !matches!(interaction.data.kind, ComponentInteractionDataKind::Button) {
return;
}
let custom_id = interaction.data.custom_id.as_str();
if !custom_id.starts_with("ticket_close:") {
return;
}
let mut custom_id = custom_id.split(":");
let Some(ticket_id) = custom_id.nth(1).and_then(|id| id.parse::<i64>().ok()) else {
return;
};
let confirmation = custom_id.next().map(|s| s == "confirm").unwrap_or(false);
// If no confirmation, return early and send confirmation ephemeral message
if !confirmation {
// Return early and send confirmation ephemeral message
let row = CreateActionRow::Buttons(vec![
CreateButton::new(format!("ticket_close:{}:cancel", ticket_id))
.label("Dismiss to cancel")
.disabled(true)
.style(ButtonStyle::Secondary),
CreateButton::new(format!("ticket_close:{}:confirm", ticket_id))
.label("Close")
.emoji('🔒')
.style(ButtonStyle::Danger),
]);
interaction
.create_response(
&ctx,
CreateInteractionResponse::Message(
CreateInteractionResponseMessage::new()
.content("Are you sure you want to close this ticket?\n-# This action is irreversible. Dismiss this message to cancel.")
.components(vec![row])
.ephemeral(true),
),
)
.await
.expect("Failed to send confirmation message");
return;
}
// Defer response
interaction
.create_response(
&ctx.http,
serenity::CreateInteractionResponse::Defer(
CreateInteractionResponseMessage::new().ephemeral(true),
),
)
.await
.expect("Error deferring response");
let mut conn = db::ctx_conn(&ctx)
.await
.expect("Failed to get db connection");
let Some(ticket) = db::models::Ticket::get(&mut conn, ticket_id)
.await
.expect("Failed to get ticket")
else {
// TODO: Error embed
interaction
.edit_response(&ctx.http, EditInteractionResponse::new().content("Error: Ticket not found. This channel is safe to manually delete, please contact an admin."))
.await
.expect("Failed to send message");
return;
};
// TODO: Transcripts?
// Close ticket
match ticket.mark_deleted(&mut conn).await {
Ok(_) => {
interaction
.edit_response(
&ctx.http,
EditInteractionResponse::new()
.content("Ticket closed. This channel will be deleted in a moment."),
)
.await
.expect("Failed to send message");
}
Err(e) => {
eprintln!("Error closing ticket: {:?}", e);
interaction
.edit_response(
&ctx.http,
EditInteractionResponse::new().content("Error closing ticket."),
)
.await
.expect("Failed to send message");
return;
}
};
// Delete channel
let channel_id = Into::<ChannelId>::into(ticket.channel_id.unwrap_or_default() as u64);
if let Err(e) = channel_id.delete(&ctx.http).await {
eprintln!("Error deleting channel: {:?}", e);
interaction
.edit_response(
&ctx.http,
EditInteractionResponse::new().content("Error deleting channel."),
)
.await
.expect("Failed to send message");
}
}
}

8
src/handlers/mod.rs Normal file
View file

@ -0,0 +1,8 @@
mod closebutton;
pub use closebutton::*;
mod ticketbutton;
pub use ticketbutton::*;
mod ticketmodal;
pub use ticketmodal::*;

View file

@ -0,0 +1,77 @@
use poise::serenity_prelude as serenity;
use serenity::{
async_trait, ComponentInteractionDataKind, Context, CreateActionRow, CreateInputText,
CreateInteractionResponse, CreateInteractionResponseMessage, CreateModal, EventHandler,
InputTextStyle, Interaction,
};
use crate::{config::CategoriesMap, util};
/// Handles the events generated from the buttons in the ticket panel.
pub struct TicketButtonHandler;
#[async_trait]
impl EventHandler for TicketButtonHandler {
async fn interaction_create(&self, ctx: Context, interaction: Interaction) {
let Some(interaction) = interaction.message_component() else {
return;
};
if !matches!(interaction.data.kind, ComponentInteractionDataKind::Button) {
return;
}
let custom_id = interaction.data.custom_id.as_str();
if !custom_id.starts_with("ticket_create:") {
return;
}
let Some(category) = custom_id.split(":").nth(1) else {
return;
};
let data = ctx.data.read().await;
let Some(category_id) = data
.get::<CategoriesMap>()
.expect("Could not get the category map from ctx data")
.get(category)
else {
interaction
.create_response(
&ctx.http,
CreateInteractionResponse::Message(
CreateInteractionResponseMessage::new().content("Invalid category"),
),
)
.await
.expect("Failed to edit response");
return;
};
let cat_name = util::title_case(category);
// Send modal
let components = CreateActionRow::InputText(
CreateInputText::new(
InputTextStyle::Paragraph,
"Ticket Subject",
"ticket_modal:starting_message",
)
.min_length(2)
.max_length(500)
.placeholder("How can we help you?"),
);
let modal = CreateModal::new(
format!("ticket_modal:{category}:{category_id}"),
format!("Creating new {cat_name} ticket"),
)
.components(vec![components]);
interaction
.create_response(&ctx.http, CreateInteractionResponse::Modal(modal))
.await
.expect("Failed to send modal");
}
}

207
src/handlers/ticketmodal.rs Normal file
View file

@ -0,0 +1,207 @@
use poise::serenity_prelude as serenity;
use serenity::{
async_trait, ActionRowComponent, ButtonStyle, ChannelType, Context, CreateActionRow,
CreateButton, CreateChannel, CreateEmbed, CreateInteractionResponse,
CreateInteractionResponseMessage, CreateMessage, EditInteractionResponse, EventHandler,
Interaction, PermissionOverwrite, PermissionOverwriteType, Permissions, RoleId,
};
use crate::{config::AllowedRoles, db};
pub struct TicketModalHandler;
#[async_trait]
impl EventHandler for TicketModalHandler {
async fn interaction_create(&self, ctx: Context, interaction: Interaction) {
let Some(interaction) = interaction.modal_submit() else {
return;
};
let custom_id = interaction.data.custom_id.as_str();
if !custom_id.starts_with("ticket_modal:") {
return;
}
// grab custom id components
let mut custom_id = custom_id.split(":");
let Some(category) = custom_id.nth(1) else {
return;
};
let Some(category_id) = custom_id.next().and_then(|id| id.parse::<u64>().ok()) else {
return;
};
let vip = custom_id.next().map(|s| s == "vip").unwrap_or(false);
let Some(guild_id) = interaction.guild_id else {
return;
};
let mut components = interaction.data.components.iter();
let row = components.next().expect("Expected a row!");
let mut row_components = row.components.iter();
// find the starting message component and extract its input
let starting_message = match row_components
.find(|c| match c {
ActionRowComponent::InputText(input) => {
input.custom_id == "ticket_modal:starting_message"
}
_ => false,
})
.expect("Expected starting message input")
{
ActionRowComponent::InputText(input) => input
.value
.clone()
.unwrap_or("User did not provide a message".to_string()),
// checked in the ::find above
_ => unreachable!(),
};
// Defer response
interaction
.create_response(
&ctx.http,
CreateInteractionResponse::Defer(
CreateInteractionResponseMessage::default().ephemeral(true),
),
)
.await
.expect("Failed to defer response");
let data = ctx.data.read().await;
let access_roles = data
.get::<AllowedRoles>()
.expect("Could not get the allowed roles from ctx data");
let mut conn = db::ctx_conn(&ctx)
.await
.expect("Failed to get db connection");
// Create ticket
let creator_id = interaction.user.id.get();
let creator_username = interaction.user.name.clone();
let bot_id = interaction.application_id.get().into();
let ctx_http = ctx.http.clone();
let ticket_results = conn
.build_transaction()
.run(|c| {
Box::pin(async move {
let user = db::models::User::get_or_insert(c, creator_id).await?;
let insert_ticket = db::models::InsertTicket {
user_id: user.id,
starting_message: starting_message.clone(),
category: category.to_string(),
vip: Some(vip),
};
let ticket = db::models::Ticket::insert(c, insert_ticket).await?;
let ticket_perms = Permissions::VIEW_CHANNEL
| Permissions::SEND_MESSAGES
| Permissions::READ_MESSAGE_HISTORY
| Permissions::ATTACH_FILES
| Permissions::EMBED_LINKS
| Permissions::ADD_REACTIONS
| Permissions::USE_EXTERNAL_EMOJIS
| Permissions::USE_EXTERNAL_STICKERS;
let channel = guild_id
.create_channel(
&ctx_http,
CreateChannel::new(format!("{}-{creator_username}", ticket.id))
.audit_log_reason(&format!(
"Create ticket channel for ticket #{} on behalf of {creator_username}",
ticket.id
)).kind(ChannelType::Text)
.category(category_id).permissions({
let mut overwrites = vec![
// Deny view to @everyone
PermissionOverwrite {
allow: Permissions::empty(),
deny: Permissions::VIEW_CHANNEL,
kind: PermissionOverwriteType::Role(guild_id.everyone_role()),
},
// Allow for bot
PermissionOverwrite {
allow: ticket_perms,
deny: Permissions::empty(),
kind: PermissionOverwriteType::Member(bot_id),
},
// Allow for creator
PermissionOverwrite {
allow: ticket_perms,
deny: Permissions::empty(),
kind: PermissionOverwriteType::Member(creator_id.into()),
},
];
// Add allowed role ids to perm list
overwrites.extend(access_roles.iter().map(|role_id| PermissionOverwrite {
allow: ticket_perms,
deny: Permissions::empty(),
kind: PermissionOverwriteType::Role(RoleId::from(*role_id)),
}));
overwrites
}),
)
.await
// Don't care what happened, just log and rollback
.map_err(|e| {
eprintln!("Error creating ticket channel: {:?}", e);
diesel::result::Error::RollbackTransaction
})?;
// Update with channel id
ticket.set_channel(c, channel.id.get()).await?;
// Send starting message to channel
let components = CreateActionRow::Buttons(
vec![
CreateButton::new(format!("ticket_close:{}", ticket.id))
.style(ButtonStyle::Secondary)
.label("Close")
.emoji('🔒'),
]
);
channel.send_message(
&ctx_http,
CreateMessage::new()
.content(format!(
"<@{creator_id}>, this is your ticket, `#{}`", ticket.id
))
.embed(
CreateEmbed::new().description(format!("```{starting_message}```")).colour(0x74c7ec)
)
.components(vec![components])
).await
// Again, do not care just roll back
.map_err(|e| {
eprintln!("Error sending initial ticket message: {:?}", e);
diesel::result::Error::RollbackTransaction
})?;
diesel::result::QueryResult::Ok((channel, ticket))
})
})
.await;
match ticket_results {
Ok((channel, ticket)) => {
interaction
.edit_response(
&ctx.http,
EditInteractionResponse::new().content(format!(
"Created ticket #{} in <#{}>",
ticket.id, channel.id
)),
)
.await
.expect("Failed to edit response");
}
Err(e) => {
eprintln!("Error creating ticket: {:?}", e);
}
};
}
}

8
src/lib.rs Normal file
View file

@ -0,0 +1,8 @@
pub mod config;
pub mod types;
pub mod util;
pub mod db;
pub mod cmd;
pub mod handlers;

91
src/main.rs Normal file
View file

@ -0,0 +1,91 @@
use placid::{
cmd,
config::{AllowedRoles, CategoriesMap, Config},
db, handlers, types,
};
use poise::serenity_prelude as serenity;
use serenity::GatewayIntents;
#[tokio::main]
async fn main() {
println!("Starting...");
let config = Config::build();
if !config.prod {
println!("Development mode activated");
println!(" Test Guild: {:?}", config.test_guild);
println!(" Database migrations will not be applied automatically");
}
println!("Config built");
let pool = db::pool(&config.db_url)
.await
.expect("Failed to create db pool");
let _conn = pool.get().await.expect("Failed to get db connection");
drop(_conn); // Only needed to test the connection
println!("Successfully connected to database");
// If prod, run migrations
if config.prod {
let conn = pool.get().await.expect("Failed to get db connection");
println!("Database connection good, running migrations");
db::run_migrations(conn)
.await
.expect("Error in applying database migrations");
}
let framework = poise::Framework::<types::Data, types::Error>::builder()
.options(poise::FrameworkOptions {
commands: vec![cmd::ticketpanel()],
on_error: |error| {
Box::pin(async move {
eprintln!("poise error > {:?}", error);
})
},
..Default::default()
})
.setup(move |ctx, _ready, framework| {
Box::pin(async move {
if config.prod {
poise::builtins::register_globally(ctx, &framework.options().commands).await?;
} else {
poise::builtins::register_in_guild(
ctx,
&framework.options().commands,
config
.test_guild
.expect("Expected test guild to be set!")
.into(),
)
.await?;
}
println!("Started!");
Ok(types::Data::default())
})
})
.build();
let intents = GatewayIntents::non_privileged()
| GatewayIntents::GUILD_MEMBERS
| GatewayIntents::GUILD_PRESENCES
| GatewayIntents::MESSAGE_CONTENT;
let mut client = serenity::Client::builder(&config.token, intents)
.framework(framework)
.event_handler(handlers::TicketButtonHandler)
.event_handler(handlers::TicketModalHandler)
.event_handler(handlers::CloseButtonHandler)
.await
.expect("Failed to create serenity client");
// Write data to tmap
let mut data = client.data.write().await;
data.insert::<db::WrappedDbPool>(db::WrappedDbPool(pool));
data.insert::<CategoriesMap>(config.categories);
data.insert::<AllowedRoles>(config.access.roles);
drop(data); // Drop the write lock
client.start().await.expect("Failed to start client!");
}

12
src/types.rs Normal file
View file

@ -0,0 +1,12 @@
pub use anyhow::{Error, Result};
#[derive(Debug)]
pub struct Data;
impl Default for Data {
fn default() -> Self {
Self
}
}
pub type Context<'a> = poise::Context<'a, Data, Error>;

19
src/util.rs Normal file
View file

@ -0,0 +1,19 @@
/// Convert a string to title case.
///
/// Allocates a new string
pub fn title_case(s: &str) -> String {
let mut title = String::new();
let mut capitalize = true;
for c in s.chars() {
if c.is_whitespace() {
capitalize = true;
title.push(c);
} else if capitalize {
title.push(c.to_ascii_uppercase());
capitalize = false;
} else {
title.push(c.to_ascii_lowercase());
}
}
title
}