chore: init
Rewrite placidticket code
This commit is contained in:
commit
a517812c48
25 changed files with 3992 additions and 0 deletions
4
.env.example
Normal file
4
.env.example
Normal 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
2
.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
/target
|
||||||
|
.env
|
2816
Cargo.lock
generated
Normal file
2816
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
20
Cargo.toml
Normal file
20
Cargo.toml
Normal 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
9
config.toml
Normal 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
11
diesel.toml
Normal 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"
|
6
migrations/00000000000000_diesel_initial_setup/down.sql
Normal file
6
migrations/00000000000000_diesel_initial_setup/down.sql
Normal 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();
|
36
migrations/00000000000000_diesel_initial_setup/up.sql
Normal file
36
migrations/00000000000000_diesel_initial_setup/up.sql
Normal 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;
|
4
migrations/2025-02-20-065910_add_tickets/down.sql
Normal file
4
migrations/2025-02-20-065910_add_tickets/down.sql
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
-- This file should undo anything in `up.sql`
|
||||||
|
DROP TABLE tickets;
|
||||||
|
DROP TABLE users;
|
||||||
|
DROP TYPE ticket_status;
|
25
migrations/2025-02-20-065910_add_tickets/up.sql
Normal file
25
migrations/2025-02-20-065910_add_tickets/up.sql
Normal 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
2
src/cmd/mod.rs
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
mod ticketpanel;
|
||||||
|
pub use ticketpanel::*;
|
56
src/cmd/ticketpanel.rs
Normal file
56
src/cmd/ticketpanel.rs
Normal 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
148
src/config.rs
Normal 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
65
src/db/connection.rs
Normal 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
6
src/db/mod.rs
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
mod connection;
|
||||||
|
mod schema;
|
||||||
|
|
||||||
|
pub use connection::*;
|
||||||
|
|
||||||
|
pub mod models;
|
195
src/db/models.rs
Normal file
195
src/db/models.rs
Normal 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
32
src/db/schema.rs
Normal 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
133
src/handlers/closebutton.rs
Normal 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
8
src/handlers/mod.rs
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
mod closebutton;
|
||||||
|
pub use closebutton::*;
|
||||||
|
|
||||||
|
mod ticketbutton;
|
||||||
|
pub use ticketbutton::*;
|
||||||
|
|
||||||
|
mod ticketmodal;
|
||||||
|
pub use ticketmodal::*;
|
77
src/handlers/ticketbutton.rs
Normal file
77
src/handlers/ticketbutton.rs
Normal 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
207
src/handlers/ticketmodal.rs
Normal 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
8
src/lib.rs
Normal 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
91
src/main.rs
Normal 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
12
src/types.rs
Normal 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
19
src/util.rs
Normal 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
|
||||||
|
}
|
Loading…
Reference in a new issue