Compare commits

...

3 commits

Author SHA1 Message Date
Louis Hollingworth 6cbcb66cf3
(chore) Updated licences on last commit
Signed-off-by: Louis Hollingworth <louis@hollingworth.nl>
2023-10-29 17:42:08 +00:00
Louis Hollingworth 9882a127bc
(feat) Commands are now translatable.
Closes #8

Signed-off-by: Louis Hollingworth <louis@hollingworth.nl>
2023-10-29 17:39:36 +00:00
Louis Hollingworth 46c6a25164
(#8) Reporting now added, admin commands are also here
Signed-off-by: Louis Hollingworth <louis@hollingworth.nl>
2023-10-29 14:40:12 +00:00
16 changed files with 1270 additions and 9 deletions

845
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -17,9 +17,14 @@ name = "er"
[dependencies] [dependencies]
anyhow = "1.0.75" anyhow = "1.0.75"
dotenvy = "0.15.7" dotenvy = "0.15.7"
fluent = "0.16.0"
fluent-syntax = "0.11.0"
intl-memoizer = "0.5.1"
poise = "0.5.6" poise = "0.5.6"
reqwest = "0.11.22" reqwest = "0.11.22"
serde = { version = "1.0.188", features = ["derive"] } serde = { version = "1.0.188", features = ["derive"] }
serde_json = "1.0.107" serde_json = "1.0.107"
sqlx = { version = "0.7.2", features = ["postgres", "macros", "uuid", "chrono", "json", "runtime-tokio"] }
thiserror = "1.0.50" thiserror = "1.0.50"
tokio = { version = "1.32.0", features = ["full"] } tokio = { version = "1.32.0", features = ["full"] }
uuid = { version = "1.5.0", features = ["v4"] }

9
build.rs Normal file
View 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");
}

View 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";

View 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
View 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(())
}

View file

@ -2,4 +2,6 @@
// //
// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-License-Identifier: GPL-3.0-or-later
pub mod admin;
pub mod report;
pub mod threads; pub mod threads;

44
src/commands/report.rs Normal file
View 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(())
}

View file

@ -3,9 +3,8 @@
// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-License-Identifier: GPL-3.0-or-later
use crate::models::discord::{DiscordThreadsResp, DiscordThreadsRespThread, EDiscordThreadsResp}; 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)] #[poise::command(slash_command)]
pub async fn threads(ctx: crate::Context<'_>) -> Result<(), crate::Error> { pub async fn threads(ctx: crate::Context<'_>) -> Result<(), crate::Error> {
let mut threadsr: DiscordThreadsResp = DiscordThreadsResp { threads: vec![] }; let mut threadsr: DiscordThreadsResp = DiscordThreadsResp { threads: vec![] };
@ -35,7 +34,8 @@ pub async fn threads(ctx: crate::Context<'_>) -> Result<(), crate::Error> {
} }
let msg = format!( let msg = format!(
"The current active threads are:{}", "{}{}",
tr!(ctx, "threads-response"),
build_thread_response_str(threadsr.threads) build_thread_response_str(threadsr.threads)
); );

View file

@ -4,8 +4,30 @@
pub mod commands; pub mod commands;
pub mod models; pub mod models;
pub mod translation;
pub type Error = Box<dyn std::error::Error + Send + Sync>; 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?)
}

View file

@ -3,16 +3,21 @@
// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-License-Identifier: GPL-3.0-or-later
use dotenvy::dotenv; use dotenvy::dotenv;
use er::{commands, Data}; use er::commands;
use poise::serenity_prelude as serenity; use poise::serenity_prelude as serenity;
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
dotenv().ok(); 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() let framework = poise::Framework::builder()
.options(poise::FrameworkOptions { .options(poise::FrameworkOptions {
commands: vec![commands::threads::threads()], commands: cmds,
..Default::default() ..Default::default()
}) })
.token(std::env::var("DISCORD_TOKEN").expect("missing DISCORD_TOKEN")) .token(std::env::var("DISCORD_TOKEN").expect("missing DISCORD_TOKEN"))
@ -26,7 +31,7 @@ async fn main() {
.setup(|ctx, _ready, fw| { .setup(|ctx, _ready, fw| {
Box::pin(async move { Box::pin(async move {
poise::builtins::register_globally(ctx, &fw.options().commands).await?; 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
View 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
View file

@ -0,0 +1,5 @@
// SPDX-FileCopyrightText: 2023 Louis Hollingworth <louis@hollingworth.nl>
//
// SPDX-License-Identifier: GPL-3.0-or-later
pub mod guild;

View file

@ -2,4 +2,5 @@
// //
// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-License-Identifier: GPL-3.0-or-later
pub mod db;
pub mod discord; pub mod discord;

164
src/translation.rs Normal file
View 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(&parameter.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(&parameter.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
View 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: