generated from lucxjo/template
Compare commits
No commits in common. "6cbcb66cf36a1836927c4e6796c266ea061bdf67" and "94d5f5afe77def832815ef7d34c7e1fbfeecb8d4" have entirely different histories.
6cbcb66cf3
...
94d5f5afe7
845
Cargo.lock
generated
845
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -17,14 +17,9 @@ 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
9
build.rs
|
@ -1,9 +0,0 @@
|
|||
// 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");
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
-- SPDX-FileCopyrightText: 2023 Louis Hollingworth <louis@hollingworth.nl>
|
||||
--
|
||||
-- SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
-- Add down migration script here
|
||||
DROP TABLE "guild";
|
|
@ -1,10 +0,0 @@
|
|||
-- 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
|
||||
);
|
|
@ -1,70 +0,0 @@
|
|||
// 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,6 +2,4 @@
|
|||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
pub mod admin;
|
||||
pub mod report;
|
||||
pub mod threads;
|
||||
|
|
|
@ -1,44 +0,0 @@
|
|||
// 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,8 +3,9 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
use crate::models::discord::{DiscordThreadsResp, DiscordThreadsRespThread, EDiscordThreadsResp};
|
||||
use crate::translation::tr;
|
||||
use poise::serenity_prelude as serenity;
|
||||
|
||||
/// 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![] };
|
||||
|
@ -34,8 +35,7 @@ pub async fn threads(ctx: crate::Context<'_>) -> Result<(), crate::Error> {
|
|||
}
|
||||
|
||||
let msg = format!(
|
||||
"{}{}",
|
||||
tr!(ctx, "threads-response"),
|
||||
"The current active threads are:{}",
|
||||
build_thread_response_str(threadsr.threads)
|
||||
);
|
||||
|
||||
|
|
26
src/lib.rs
26
src/lib.rs
|
@ -4,30 +4,8 @@
|
|||
|
||||
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, AppState, Error>;
|
||||
pub type Context<'a> = poise::Context<'a, Data, Error>;
|
||||
|
||||
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?)
|
||||
}
|
||||
pub struct Data {}
|
||||
|
|
11
src/main.rs
11
src/main.rs
|
@ -3,21 +3,16 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
use dotenvy::dotenv;
|
||||
use er::commands;
|
||||
use er::{commands, Data};
|
||||
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: cmds,
|
||||
commands: vec![commands::threads::threads()],
|
||||
..Default::default()
|
||||
})
|
||||
.token(std::env::var("DISCORD_TOKEN").expect("missing DISCORD_TOKEN"))
|
||||
|
@ -31,7 +26,7 @@ async fn main() {
|
|||
.setup(|ctx, _ready, fw| {
|
||||
Box::pin(async move {
|
||||
poise::builtins::register_globally(ctx, &fw.options().commands).await?;
|
||||
Ok(er::AppState::new(translations).await?)
|
||||
Ok(Data {})
|
||||
})
|
||||
});
|
||||
|
||||
|
|
|
@ -1,65 +0,0 @@
|
|||
// 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(())
|
||||
}
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
// SPDX-FileCopyrightText: 2023 Louis Hollingworth <louis@hollingworth.nl>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
pub mod guild;
|
|
@ -2,5 +2,4 @@
|
|||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
pub mod db;
|
||||
pub mod discord;
|
||||
|
|
|
@ -1,164 +0,0 @@
|
|||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,10 +0,0 @@
|
|||
# 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