generated from lucxjo/template
Compare commits
3 commits
94d5f5afe7
...
6cbcb66cf3
Author | SHA1 | Date | |
---|---|---|---|
Louis Hollingworth | 6cbcb66cf3 | ||
Louis Hollingworth | 9882a127bc | ||
Louis Hollingworth | 46c6a25164 |
845
Cargo.lock
generated
845
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -17,9 +17,14 @@ name = "er"
|
|||
[dependencies]
|
||||
anyhow = "1.0.75"
|
||||
dotenvy = "0.15.7"
|
||||
fluent = "0.16.0"
|
||||
fluent-syntax = "0.11.0"
|
||||
intl-memoizer = "0.5.1"
|
||||
poise = "0.5.6"
|
||||
reqwest = "0.11.22"
|
||||
serde = { version = "1.0.188", features = ["derive"] }
|
||||
serde_json = "1.0.107"
|
||||
sqlx = { version = "0.7.2", features = ["postgres", "macros", "uuid", "chrono", "json", "runtime-tokio"] }
|
||||
thiserror = "1.0.50"
|
||||
tokio = { version = "1.32.0", features = ["full"] }
|
||||
uuid = { version = "1.5.0", features = ["v4"] }
|
||||
|
|
9
build.rs
Normal file
9
build.rs
Normal file
|
@ -0,0 +1,9 @@
|
|||
// SPDX-FileCopyrightText: 2023 Louis Hollingworth <louis@hollingworth.nl>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// generated by `sqlx migrate build-script`
|
||||
fn main() {
|
||||
// trigger recompilation when a new migration is added
|
||||
println!("cargo:rerun-if-changed=migrations");
|
||||
}
|
6
migrations/20231028165341_init.down.sql
Normal file
6
migrations/20231028165341_init.down.sql
Normal file
|
@ -0,0 +1,6 @@
|
|||
-- SPDX-FileCopyrightText: 2023 Louis Hollingworth <louis@hollingworth.nl>
|
||||
--
|
||||
-- SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
-- Add down migration script here
|
||||
DROP TABLE "guild";
|
10
migrations/20231028165341_init.up.sql
Normal file
10
migrations/20231028165341_init.up.sql
Normal file
|
@ -0,0 +1,10 @@
|
|||
-- SPDX-FileCopyrightText: 2023 Louis Hollingworth <louis@hollingworth.nl>
|
||||
--
|
||||
-- SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
-- Add up migration script here
|
||||
CREATE TABLE "guild" (
|
||||
"id" TEXT PRIMARY KEY NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"reports_channel_id" TEXT
|
||||
);
|
70
src/commands/admin.rs
Normal file
70
src/commands/admin.rs
Normal file
|
@ -0,0 +1,70 @@
|
|||
// SPDX-FileCopyrightText: 2023 Louis Hollingworth <louis@hollingworth.nl>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
use poise::serenity_prelude as serenity;
|
||||
|
||||
#[poise::command(
|
||||
slash_command,
|
||||
subcommands("admin_reports", "admin_setup"),
|
||||
subcommand_required,
|
||||
guild_only,
|
||||
default_member_permissions = "ADMINISTRATOR"
|
||||
)]
|
||||
pub async fn admin(_: crate::Context<'_>) -> Result<(), crate::Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set or get the configured channel for reports
|
||||
#[poise::command(slash_command, rename = "reports")]
|
||||
pub async fn admin_reports(
|
||||
ctx: crate::Context<'_>,
|
||||
#[description = "Set where the reports should be sent"] reports_channel: Option<
|
||||
serenity::Channel,
|
||||
>,
|
||||
) -> Result<(), crate::Error> {
|
||||
ctx.defer_ephemeral().await.unwrap();
|
||||
let pool = &ctx.framework().user_data.db_pool;
|
||||
match reports_channel {
|
||||
None => {
|
||||
let dbg = crate::models::db::guild::Guild::get_by_id(
|
||||
ctx.guild_id().unwrap().to_string(),
|
||||
pool.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
ctx.reply(format!(
|
||||
"<#{}> is currently receiving reports for this guild.",
|
||||
dbg.reports_channel_id.unwrap_or("0000000".to_string())
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
Some(cnl) => {
|
||||
crate::models::db::guild::Guild::update_reports_channel(
|
||||
ctx.guild_id().unwrap().to_string(),
|
||||
Some(cnl.id().to_string()),
|
||||
pool.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
ctx.reply(format!(
|
||||
"<#{}> is now setup to receive reports.",
|
||||
cnl.id().to_string()
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// This command shouldn't be needed for anything other than development
|
||||
#[poise::command(slash_command, rename = "setup")]
|
||||
pub async fn admin_setup(ctx: crate::Context<'_>) -> Result<(), crate::Error> {
|
||||
let pool = &ctx.framework().user_data.db_pool;
|
||||
let g = ctx.guild().unwrap();
|
||||
crate::models::db::guild::Guild::create(g.id.to_string(), g.name, pool.clone()).await?;
|
||||
ctx.reply("Setup complete, never run this again. See the [issue tracker](https://git.ludoviko.ch/lucxjo/er/issues) if you are still having issues").await.unwrap();
|
||||
Ok(())
|
||||
}
|
|
@ -2,4 +2,6 @@
|
|||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
pub mod admin;
|
||||
pub mod report;
|
||||
pub mod threads;
|
||||
|
|
44
src/commands/report.rs
Normal file
44
src/commands/report.rs
Normal file
|
@ -0,0 +1,44 @@
|
|||
// SPDX-FileCopyrightText: 2023 Louis Hollingworth <louis@hollingworth.nl>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
use poise::serenity_prelude as serenity;
|
||||
|
||||
/// Report a user to the guild staff
|
||||
#[poise::command(slash_command, guild_only)]
|
||||
pub async fn report(
|
||||
ctx: crate::Context<'_>,
|
||||
#[description = "The user to report"] user: serenity::Member,
|
||||
#[description = "Why you are reporting the user"] reason: String,
|
||||
) -> Result<(), crate::Error> {
|
||||
ctx.defer_ephemeral().await?;
|
||||
let pool = &ctx.framework().user_data.db_pool;
|
||||
let g = crate::models::db::guild::Guild::get_by_id(
|
||||
ctx.guild_id().unwrap().to_string(),
|
||||
pool.clone(),
|
||||
)
|
||||
.await?;
|
||||
match g.reports_channel_id {
|
||||
None => {
|
||||
ctx.reply("It looks like your guild staff haven't enabled this feature")
|
||||
.await?;
|
||||
}
|
||||
Some(rcid) => {
|
||||
let cid = serenity::ChannelId::from(rcid.parse::<u64>().unwrap());
|
||||
cid.send_message(ctx.http(), |m| {
|
||||
m.embed(|embed| {
|
||||
embed.title("Member Report");
|
||||
embed.author(|a| {
|
||||
a.icon_url(ctx.author().avatar_url().unwrap());
|
||||
a.name(ctx.author().name.clone())
|
||||
});
|
||||
embed.colour(0xd1021a);
|
||||
embed.description(format!("{}: {}", user, reason))
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
ctx.reply("Report sent").await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
|
@ -3,9 +3,8 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
use crate::models::discord::{DiscordThreadsResp, DiscordThreadsRespThread, EDiscordThreadsResp};
|
||||
use poise::serenity_prelude as serenity;
|
||||
use crate::translation::tr;
|
||||
|
||||
/// Displays current active threads
|
||||
#[poise::command(slash_command)]
|
||||
pub async fn threads(ctx: crate::Context<'_>) -> Result<(), crate::Error> {
|
||||
let mut threadsr: DiscordThreadsResp = DiscordThreadsResp { threads: vec![] };
|
||||
|
@ -35,7 +34,8 @@ pub async fn threads(ctx: crate::Context<'_>) -> Result<(), crate::Error> {
|
|||
}
|
||||
|
||||
let msg = format!(
|
||||
"The current active threads are:{}",
|
||||
"{}{}",
|
||||
tr!(ctx, "threads-response"),
|
||||
build_thread_response_str(threadsr.threads)
|
||||
);
|
||||
|
||||
|
|
26
src/lib.rs
26
src/lib.rs
|
@ -4,8 +4,30 @@
|
|||
|
||||
pub mod commands;
|
||||
pub mod models;
|
||||
pub mod translation;
|
||||
|
||||
pub type Error = Box<dyn std::error::Error + Send + Sync>;
|
||||
pub type Context<'a> = poise::Context<'a, Data, Error>;
|
||||
pub type Context<'a> = poise::Context<'a, AppState, Error>;
|
||||
|
||||
pub struct Data {}
|
||||
pub struct AppState {
|
||||
pub db_pool: sqlx::Pool<sqlx::Postgres>,
|
||||
pub translations: translation::Translations,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub async fn new(translations: translation::Translations) -> Result<Self, Error> {
|
||||
let pool = establish_db_pool().await?;
|
||||
sqlx::migrate!().run(&pool).await?;
|
||||
Ok(AppState { db_pool: pool, translations })
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn establish_db_pool() -> Result<sqlx::Pool<sqlx::Postgres>, sqlx::Error> {
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
let url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||
Ok(sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect(&url)
|
||||
.await?)
|
||||
}
|
||||
|
|
11
src/main.rs
11
src/main.rs
|
@ -3,16 +3,21 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
use dotenvy::dotenv;
|
||||
use er::{commands, Data};
|
||||
use er::commands;
|
||||
use poise::serenity_prelude as serenity;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
dotenv().ok();
|
||||
|
||||
let translations = er::translation::read_ftl().expect("Failed to read translation files");
|
||||
let mut cmds = vec![commands::admin::admin(), commands::threads::threads(), commands::report::report()];
|
||||
er::translation::apply_translations(&translations, &mut cmds);
|
||||
|
||||
|
||||
let framework = poise::Framework::builder()
|
||||
.options(poise::FrameworkOptions {
|
||||
commands: vec![commands::threads::threads()],
|
||||
commands: cmds,
|
||||
..Default::default()
|
||||
})
|
||||
.token(std::env::var("DISCORD_TOKEN").expect("missing DISCORD_TOKEN"))
|
||||
|
@ -26,7 +31,7 @@ async fn main() {
|
|||
.setup(|ctx, _ready, fw| {
|
||||
Box::pin(async move {
|
||||
poise::builtins::register_globally(ctx, &fw.options().commands).await?;
|
||||
Ok(Data {})
|
||||
Ok(er::AppState::new(translations).await?)
|
||||
})
|
||||
});
|
||||
|
||||
|
|
65
src/models/db/guild.rs
Normal file
65
src/models/db/guild.rs
Normal file
|
@ -0,0 +1,65 @@
|
|||
// SPDX-FileCopyrightText: 2023 Louis Hollingworth <louis@hollingworth.nl>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub struct NewGuild {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub struct Guild {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub reports_channel_id: Option<String>,
|
||||
}
|
||||
|
||||
impl Guild {
|
||||
pub async fn create(
|
||||
id: String,
|
||||
name: String,
|
||||
pool: sqlx::Pool<sqlx::Postgres>,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
NewGuild,
|
||||
"INSERT INTO guild (id, name) VALUES ($1, $2)",
|
||||
id,
|
||||
name
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
Ok(())
|
||||
}
|
||||
pub async fn get_by_id(
|
||||
id: String,
|
||||
pool: sqlx::Pool<sqlx::Postgres>,
|
||||
) -> Result<Self, sqlx::Error> {
|
||||
let guild = sqlx::query_as!(
|
||||
Self,
|
||||
"SELECT id, name, reports_channel_id FROM guild WHERE id = $1",
|
||||
id
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
Ok(guild)
|
||||
}
|
||||
pub async fn update_reports_channel(
|
||||
gid: String,
|
||||
rcid: Option<String>,
|
||||
pool: sqlx::Pool<sqlx::Postgres>,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query!(
|
||||
"UPDATE guild SET reports_channel_id = $1 WHERE id = $2",
|
||||
rcid,
|
||||
gid
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
5
src/models/db/mod.rs
Normal file
5
src/models/db/mod.rs
Normal file
|
@ -0,0 +1,5 @@
|
|||
// SPDX-FileCopyrightText: 2023 Louis Hollingworth <louis@hollingworth.nl>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
pub mod guild;
|
|
@ -2,4 +2,5 @@
|
|||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
pub mod db;
|
||||
pub mod discord;
|
||||
|
|
164
src/translation.rs
Normal file
164
src/translation.rs
Normal file
|
@ -0,0 +1,164 @@
|
|||
// SPDX-FileCopyrightText: 2023 Louis Hollingworth <louis@hollingworth.nl>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
type FluentBundle = fluent::bundle::FluentBundle<
|
||||
fluent::FluentResource,
|
||||
intl_memoizer::concurrent::IntlLangMemoizer,
|
||||
>;
|
||||
|
||||
pub struct Translations {
|
||||
pub main: FluentBundle,
|
||||
pub other: std::collections::HashMap<String, FluentBundle>
|
||||
}
|
||||
|
||||
macro_rules! tr {
|
||||
($ctx:ident, $id:expr $(, $argname:ident: $argvalue:expr)* $(,)? ) => {{
|
||||
#[allow(unused_mut)]
|
||||
let mut args = fluent::FluentArgs::new();
|
||||
$( args.set(stringify!($argname), $argvalue); )*
|
||||
|
||||
$crate::translation::get($ctx, $id, None, Some(&args))
|
||||
}};
|
||||
}
|
||||
|
||||
pub(super) use tr;
|
||||
|
||||
|
||||
pub fn format(
|
||||
bundle: &FluentBundle,
|
||||
id: &str,
|
||||
attr: Option<&str>,
|
||||
args: Option<&fluent::FluentArgs<'_>>,
|
||||
) -> Option<String> {
|
||||
let msg = bundle.get_message(id)?;
|
||||
let ptrn = match attr {
|
||||
None => msg.value()?,
|
||||
Some(a) => msg.get_attribute(a)?.value()
|
||||
};
|
||||
let fmt = bundle.format_pattern(ptrn, args, &mut vec![]);
|
||||
Some(fmt.into_owned())
|
||||
}
|
||||
|
||||
pub fn get<'a>(
|
||||
ctx: crate::Context<'a>,
|
||||
id: &str,
|
||||
attr: Option<&str>,
|
||||
args: Option<&fluent::FluentArgs<'_>>,
|
||||
) -> String {
|
||||
let t = &ctx.data().translations;
|
||||
println!("The current locale is: {}", ctx.locale().unwrap());
|
||||
ctx.locale()
|
||||
.and_then(|lcl| format(t.other.get(lcl)?, id, attr, args))
|
||||
.or_else(|| format(&t.main, id, attr, args))
|
||||
.unwrap_or_else(|| {
|
||||
println!("unknown fluent message identifier `{}`", id);
|
||||
id.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn read_ftl() -> Result<Translations, crate::Error> {
|
||||
fn read_one_ftl(path: &std::path::Path) -> Result<(String, FluentBundle), crate::Error> {
|
||||
let loc = path.file_stem().ok_or("Invalid .ftl filename")?;
|
||||
let loc = loc.to_str().ok_or("Invalid UTF-8 filename")?;
|
||||
|
||||
let fc = std::fs::read_to_string(path)?;
|
||||
let res = fluent::FluentResource::try_new(fc)
|
||||
.map_err(|(_, e)| format!("failed to parse {:?}: {:?}", path, e))?;
|
||||
|
||||
let mut bun = FluentBundle::new_concurrent(vec![loc.parse().map_err(|e| format!("invalid locale `{}`: {}", loc, e))?]);
|
||||
bun.add_resource(res)
|
||||
.map_err(|e| format!("failed to add resource to bundle: {:?}", e))?;
|
||||
Ok((loc.to_string(), bun))
|
||||
}
|
||||
|
||||
Ok(Translations {
|
||||
main: read_one_ftl("translations/en-US.ftl".as_ref())?.1,
|
||||
other: std::fs::read_dir("translations")?
|
||||
.map(|f| read_one_ftl(&f?.path()))
|
||||
.collect::<Result<_, _>>()?,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
/// Given a set of language files, fills in command strings and their localizations accordingly
|
||||
pub fn apply_translations(
|
||||
translations: &Translations,
|
||||
commands: &mut [poise::Command<crate::AppState, crate::Error>],
|
||||
) {
|
||||
for command in &mut *commands {
|
||||
// Add localizations
|
||||
for (locale, bundle) in &translations.other {
|
||||
// Insert localized command name and description
|
||||
let localized_command_name = match format(bundle, &command.name, None, None) {
|
||||
Some(x) => x,
|
||||
None => continue, // no localization entry => skip localization
|
||||
};
|
||||
command
|
||||
.name_localizations
|
||||
.insert(locale.clone(), localized_command_name);
|
||||
command.description_localizations.insert(
|
||||
locale.clone(),
|
||||
format(bundle, &command.name, Some("description"), None).unwrap(),
|
||||
);
|
||||
|
||||
for parameter in &mut command.parameters {
|
||||
// Insert localized parameter name and description
|
||||
parameter.name_localizations.insert(
|
||||
locale.clone(),
|
||||
format(bundle, &command.name, Some(¶meter.name), None).unwrap(),
|
||||
);
|
||||
parameter.description_localizations.insert(
|
||||
locale.clone(),
|
||||
format(
|
||||
bundle,
|
||||
&command.name,
|
||||
Some(&format!("{}-description", parameter.name)),
|
||||
None,
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
// If this is a choice parameter, insert its localized variants
|
||||
for choice in &mut parameter.choices {
|
||||
choice.localizations.insert(
|
||||
locale.clone(),
|
||||
format(bundle, &choice.name, None, None).unwrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// At this point, all translation files have been applied. However, if a user uses a locale
|
||||
// we haven't explicitly inserted, there would be no translations at all -> blank texts. So,
|
||||
// we use the "main" translation file (en-US) as the non-localized strings.
|
||||
|
||||
// Set fallback command name and description to en-US
|
||||
let bundle = &translations.main;
|
||||
match format(bundle, &command.name, None, None) {
|
||||
Some(x) => command.name = x,
|
||||
None => continue, // no localization entry => keep hardcoded names
|
||||
}
|
||||
command.description =
|
||||
Some(format(bundle, &command.name, Some("description"), None).unwrap());
|
||||
|
||||
for parameter in &mut command.parameters {
|
||||
// Set fallback parameter name and description to en-US
|
||||
parameter.name = format(bundle, &command.name, Some(¶meter.name), None).unwrap();
|
||||
parameter.description = Some(
|
||||
format(
|
||||
bundle,
|
||||
&command.name,
|
||||
Some(&format!("{}-description", parameter.name)),
|
||||
None,
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
// If this is a choice parameter, set the choice names to en-US
|
||||
for choice in &mut parameter.choices {
|
||||
choice.name = format(bundle, &choice.name, None, None).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
10
translations/en-US.ftl
Normal file
10
translations/en-US.ftl
Normal file
|
@ -0,0 +1,10 @@
|
|||
# SPDX-FileCopyrightText: 2023 Louis Hollingworth <louis@hollingworth.nl>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
# Command metadata
|
||||
threads = threads
|
||||
.description = Displays current active threads
|
||||
|
||||
# Responses
|
||||
threads-response = The current threads are:
|
Loading…
Reference in a new issue