diff options
| author | salmonik <salmonik@tuta.io> | 2026-11-04 01:09:10 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-11-04 01:09:10 +0100 |
| commit | ceb96326ccf3daff0472cafdaba3e223d3a20724 (patch) | |
| tree | 8bd84f484ce714bd10f1c9f25f612ab16fbd8eae | |
| parent | cron: scope jobs to the guild that created them (diff) | |
| download | senator-sperma-ceb96326ccf3daff0472cafdaba3e223d3a20724.tar.gz senator-sperma-ceb96326ccf3daff0472cafdaba3e223d3a20724.zip | |
triggers: scope groups to the guild that created them
| -rw-r--r-- | src/commands/autocomplete.rs | 5 | ||||
| -rw-r--r-- | src/commands/trigger.rs | 17 | ||||
| -rw-r--r-- | src/database/mod.rs | 59 | ||||
| -rw-r--r-- | src/events/mod.rs | 7 |
4 files changed, 64 insertions, 24 deletions
diff --git a/src/commands/autocomplete.rs b/src/commands/autocomplete.rs index a909738..d05a48b 100644 --- a/src/commands/autocomplete.rs +++ b/src/commands/autocomplete.rs | |||
| @@ -208,8 +208,11 @@ pub async fn any_file(ctx: Context<'_>, partial: &str) -> Vec<serenity::Autocomp | |||
| 208 | } | 208 | } |
| 209 | 209 | ||
| 210 | pub async fn trigger_name(ctx: Context<'_>, partial: &str) -> Vec<String> { | 210 | pub async fn trigger_name(ctx: Context<'_>, partial: &str) -> Vec<String> { |
| 211 | let Some(guild_id) = ctx.guild_id() else { | ||
| 212 | return Vec::new(); | ||
| 213 | }; | ||
| 211 | let db_pool = &ctx.data().db_pool; | 214 | let db_pool = &ctx.data().db_pool; |
| 212 | match crate::database::list_triggers(db_pool).await { | 215 | match crate::database::list_triggers(db_pool, guild_id.get() as i64).await { |
| 213 | Ok(triggers) => { | 216 | Ok(triggers) => { |
| 214 | let names: Vec<_> = triggers | 217 | let names: Vec<_> = triggers |
| 215 | .into_iter() | 218 | .into_iter() |
diff --git a/src/commands/trigger.rs b/src/commands/trigger.rs index 3a91edd..5d481e4 100644 --- a/src/commands/trigger.rs +++ b/src/commands/trigger.rs | |||
| @@ -27,7 +27,8 @@ pub async fn create( | |||
| 27 | #[autocomplete = "trigger_name"] | 27 | #[autocomplete = "trigger_name"] |
| 28 | name: String, | 28 | name: String, |
| 29 | ) -> Result<()> { | 29 | ) -> Result<()> { |
| 30 | match database::create_trigger(&ctx.data().db_pool, &name).await { | 30 | let guild_id = ctx.guild_id().ok_or_else(lang::err_not_in_guild)?; |
| 31 | match database::create_trigger(&ctx.data().db_pool, guild_id.get() as i64, &name).await { | ||
| 31 | Ok(_) => { | 32 | Ok(_) => { |
| 32 | ctx.data().reload_triggers().await; | 33 | ctx.data().reload_triggers().await; |
| 33 | ctx.say(lang::fmt_trigger_created(&name)).await?; | 34 | ctx.say(lang::fmt_trigger_created(&name)).await?; |
| @@ -49,7 +50,8 @@ pub async fn add( | |||
| 49 | #[description = "Keywords separated by \\"] trigger_keywords: Option<String>, | 50 | #[description = "Keywords separated by \\"] trigger_keywords: Option<String>, |
| 50 | #[description = "Responses separated by \\"] response: Option<String>, | 51 | #[description = "Responses separated by \\"] response: Option<String>, |
| 51 | ) -> Result<()> { | 52 | ) -> Result<()> { |
| 52 | let t = database::get_trigger_by_name(&ctx.data().db_pool, &name) | 53 | let guild_id = ctx.guild_id().ok_or_else(lang::err_not_in_guild)?; |
| 54 | let t = database::get_trigger_by_name(&ctx.data().db_pool, guild_id.get() as i64, &name) | ||
| 53 | .await? | 55 | .await? |
| 54 | .ok_or_else(lang::err_trigger_not_found)?; | 56 | .ok_or_else(lang::err_trigger_not_found)?; |
| 55 | 57 | ||
| @@ -90,7 +92,8 @@ pub async fn remove( | |||
| 90 | #[description = "Keyword to remove"] trigger_keyword: Option<String>, | 92 | #[description = "Keyword to remove"] trigger_keyword: Option<String>, |
| 91 | #[description = "Response to remove"] response: Option<String>, | 93 | #[description = "Response to remove"] response: Option<String>, |
| 92 | ) -> Result<()> { | 94 | ) -> Result<()> { |
| 93 | let t = database::get_trigger_by_name(&ctx.data().db_pool, &name) | 95 | let guild_id = ctx.guild_id().ok_or_else(lang::err_not_in_guild)?; |
| 96 | let t = database::get_trigger_by_name(&ctx.data().db_pool, guild_id.get() as i64, &name) | ||
| 94 | .await? | 97 | .await? |
| 95 | .ok_or_else(lang::err_trigger_not_found)?; | 98 | .ok_or_else(lang::err_trigger_not_found)?; |
| 96 | 99 | ||
| @@ -119,11 +122,12 @@ pub async fn delete( | |||
| 119 | #[autocomplete = "trigger_name"] | 122 | #[autocomplete = "trigger_name"] |
| 120 | name: String, | 123 | name: String, |
| 121 | ) -> Result<()> { | 124 | ) -> Result<()> { |
| 122 | let t = database::get_trigger_by_name(&ctx.data().db_pool, &name) | 125 | let guild_id = ctx.guild_id().ok_or_else(lang::err_not_in_guild)?; |
| 126 | let t = database::get_trigger_by_name(&ctx.data().db_pool, guild_id.get() as i64, &name) | ||
| 123 | .await? | 127 | .await? |
| 124 | .ok_or_else(lang::err_trigger_not_found)?; | 128 | .ok_or_else(lang::err_trigger_not_found)?; |
| 125 | 129 | ||
| 126 | database::delete_trigger(&ctx.data().db_pool, t.id).await?; | 130 | database::delete_trigger(&ctx.data().db_pool, t.id, guild_id.get() as i64).await?; |
| 127 | ctx.data().reload_triggers().await; | 131 | ctx.data().reload_triggers().await; |
| 128 | ctx.say(lang::fmt_trigger_deleted(&name)).await?; | 132 | ctx.say(lang::fmt_trigger_deleted(&name)).await?; |
| 129 | Ok(()) | 133 | Ok(()) |
| @@ -137,7 +141,8 @@ pub async fn list( | |||
| 137 | #[autocomplete = "trigger_name"] | 141 | #[autocomplete = "trigger_name"] |
| 138 | name: String, | 142 | name: String, |
| 139 | ) -> Result<()> { | 143 | ) -> Result<()> { |
| 140 | let t = database::get_trigger_by_name(&ctx.data().db_pool, &name) | 144 | let guild_id = ctx.guild_id().ok_or_else(lang::err_not_in_guild)?; |
| 145 | let t = database::get_trigger_by_name(&ctx.data().db_pool, guild_id.get() as i64, &name) | ||
| 141 | .await? | 146 | .await? |
| 142 | .ok_or_else(lang::err_trigger_not_found)?; | 147 | .ok_or_else(lang::err_trigger_not_found)?; |
| 143 | 148 | ||
diff --git a/src/database/mod.rs b/src/database/mod.rs index 9679811..e81e448 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs | |||
| @@ -75,14 +75,24 @@ async fn run_migrations(pool: &DbPool) -> Result<()> { | |||
| 75 | sqlx::query( | 75 | sqlx::query( |
| 76 | "CREATE TABLE IF NOT EXISTS triggers ( | 76 | "CREATE TABLE IF NOT EXISTS triggers ( |
| 77 | id INTEGER PRIMARY KEY AUTOINCREMENT, | 77 | id INTEGER PRIMARY KEY AUTOINCREMENT, |
| 78 | name TEXT UNIQUE NOT NULL, | 78 | guild_id INTEGER NOT NULL DEFAULT 0, |
| 79 | name TEXT NOT NULL, | ||
| 79 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, | 80 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, |
| 80 | last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP | 81 | last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP, |
| 82 | UNIQUE(guild_id, name) | ||
| 81 | )", | 83 | )", |
| 82 | ) | 84 | ) |
| 83 | .execute(pool) | 85 | .execute(pool) |
| 84 | .await?; | 86 | .await?; |
| 85 | 87 | ||
| 88 | // Add guild_id for pre-existing trigger tables. (The old table also kept a | ||
| 89 | // global UNIQUE(name), so existing databases still require unique trigger | ||
| 90 | // names across guilds; new databases use UNIQUE(guild_id, name).) | ||
| 91 | sqlx::query("ALTER TABLE triggers ADD COLUMN guild_id INTEGER NOT NULL DEFAULT 0") | ||
| 92 | .execute(pool) | ||
| 93 | .await | ||
| 94 | .ok(); | ||
| 95 | |||
| 86 | sqlx::query( | 96 | sqlx::query( |
| 87 | "CREATE TABLE IF NOT EXISTS trigger_keywords ( | 97 | "CREATE TABLE IF NOT EXISTS trigger_keywords ( |
| 88 | id INTEGER PRIMARY KEY AUTOINCREMENT, | 98 | id INTEGER PRIMARY KEY AUTOINCREMENT, |
| @@ -416,6 +426,7 @@ pub async fn set_cron_job_active( | |||
| 416 | #[derive(Debug, Clone, FromRow)] | 426 | #[derive(Debug, Clone, FromRow)] |
| 417 | pub struct Trigger { | 427 | pub struct Trigger { |
| 418 | pub id: i64, | 428 | pub id: i64, |
| 429 | pub guild_id: i64, | ||
| 419 | pub name: String, | 430 | pub name: String, |
| 420 | pub created_at: Option<DateTime<Utc>>, | 431 | pub created_at: Option<DateTime<Utc>>, |
| 421 | pub last_updated: Option<DateTime<Utc>>, | 432 | pub last_updated: Option<DateTime<Utc>>, |
| @@ -437,33 +448,45 @@ pub struct TriggerResponse { | |||
| 437 | pub response_text: String, | 448 | pub response_text: String, |
| 438 | } | 449 | } |
| 439 | 450 | ||
| 440 | pub async fn create_trigger(pool: &DbPool, name: &str) -> Result<i64> { | 451 | pub async fn create_trigger(pool: &DbPool, guild_id: i64, name: &str) -> Result<i64> { |
| 441 | let result = sqlx::query("INSERT INTO triggers (name) VALUES (?)") | 452 | let result = sqlx::query("INSERT INTO triggers (guild_id, name) VALUES (?, ?)") |
| 453 | .bind(guild_id) | ||
| 442 | .bind(name) | 454 | .bind(name) |
| 443 | .execute(pool) | 455 | .execute(pool) |
| 444 | .await?; | 456 | .await?; |
| 445 | Ok(result.last_insert_rowid()) | 457 | Ok(result.last_insert_rowid()) |
| 446 | } | 458 | } |
| 447 | 459 | ||
| 448 | pub async fn delete_trigger(pool: &DbPool, id: i64) -> Result<bool> { | 460 | pub async fn delete_trigger(pool: &DbPool, id: i64, guild_id: i64) -> Result<bool> { |
| 449 | let result = sqlx::query("DELETE FROM triggers WHERE id = ?") | 461 | let result = sqlx::query("DELETE FROM triggers WHERE id = ? AND guild_id = ?") |
| 450 | .bind(id) | 462 | .bind(id) |
| 463 | .bind(guild_id) | ||
| 451 | .execute(pool) | 464 | .execute(pool) |
| 452 | .await?; | 465 | .await?; |
| 453 | Ok(result.rows_affected() > 0) | 466 | Ok(result.rows_affected() > 0) |
| 454 | } | 467 | } |
| 455 | 468 | ||
| 456 | pub async fn get_trigger_by_name(pool: &DbPool, name: &str) -> Result<Option<Trigger>> { | 469 | pub async fn get_trigger_by_name( |
| 457 | Ok(sqlx::query_as("SELECT * FROM triggers WHERE name = ?") | 470 | pool: &DbPool, |
| 458 | .bind(name) | 471 | guild_id: i64, |
| 459 | .fetch_optional(pool) | 472 | name: &str, |
| 460 | .await?) | 473 | ) -> Result<Option<Trigger>> { |
| 474 | Ok( | ||
| 475 | sqlx::query_as("SELECT * FROM triggers WHERE guild_id = ? AND name = ?") | ||
| 476 | .bind(guild_id) | ||
| 477 | .bind(name) | ||
| 478 | .fetch_optional(pool) | ||
| 479 | .await?, | ||
| 480 | ) | ||
| 461 | } | 481 | } |
| 462 | 482 | ||
| 463 | pub async fn list_triggers(pool: &DbPool) -> Result<Vec<Trigger>> { | 483 | pub async fn list_triggers(pool: &DbPool, guild_id: i64) -> Result<Vec<Trigger>> { |
| 464 | Ok(sqlx::query_as("SELECT * FROM triggers ORDER BY name") | 484 | Ok( |
| 465 | .fetch_all(pool) | 485 | sqlx::query_as("SELECT * FROM triggers WHERE guild_id = ? ORDER BY name") |
| 466 | .await?) | 486 | .bind(guild_id) |
| 487 | .fetch_all(pool) | ||
| 488 | .await?, | ||
| 489 | ) | ||
| 467 | } | 490 | } |
| 468 | 491 | ||
| 469 | pub async fn add_keyword(pool: &DbPool, trigger_id: i64, keyword: &str) -> Result<()> { | 492 | pub async fn add_keyword(pool: &DbPool, trigger_id: i64, keyword: &str) -> Result<()> { |
| @@ -526,7 +549,11 @@ pub async fn get_responses(pool: &DbPool, trigger_id: i64) -> Result<Vec<String> | |||
| 526 | pub async fn load_all_triggers_with_keywords_and_responses( | 549 | pub async fn load_all_triggers_with_keywords_and_responses( |
| 527 | pool: &DbPool, | 550 | pool: &DbPool, |
| 528 | ) -> Result<Vec<(Trigger, Vec<String>, Vec<String>)>> { | 551 | ) -> Result<Vec<(Trigger, Vec<String>, Vec<String>)>> { |
| 529 | let triggers = list_triggers(pool).await?; | 552 | // The event cache needs every guild's triggers; `/trigger list` and |
| 553 | // mutations use the guild-scoped helpers above. | ||
| 554 | let triggers: Vec<Trigger> = sqlx::query_as("SELECT * FROM triggers ORDER BY name") | ||
| 555 | .fetch_all(pool) | ||
| 556 | .await?; | ||
| 530 | let mut result = Vec::new(); | 557 | let mut result = Vec::new(); |
| 531 | for t in triggers { | 558 | for t in triggers { |
| 532 | let keywords = get_keywords(pool, t.id).await?; | 559 | let keywords = get_keywords(pool, t.id).await?; |
diff --git a/src/events/mod.rs b/src/events/mod.rs index 5006444..5fb6a30 100644 --- a/src/events/mod.rs +++ b/src/events/mod.rs | |||
| @@ -380,10 +380,15 @@ pub async fn handle_message( | |||
| 380 | } | 380 | } |
| 381 | 381 | ||
| 382 | let content_lower = new_message.content.to_lowercase(); | 382 | let content_lower = new_message.content.to_lowercase(); |
| 383 | let message_guild_id = new_message.guild_id.map(|guild| guild.get() as i64); | ||
| 383 | let response_to_send = { | 384 | let response_to_send = { |
| 384 | let triggers = data.triggers.lock().await; | 385 | let triggers = data.triggers.lock().await; |
| 385 | let mut chosen = None; | 386 | let mut chosen = None; |
| 386 | for (_trigger, keywords, responses) in triggers.iter() { | 387 | for (trigger, keywords, responses) in triggers.iter() { |
| 388 | // Triggers belong to one guild and never fire in DMs. | ||
| 389 | if message_guild_id != Some(trigger.guild_id) { | ||
| 390 | continue; | ||
| 391 | } | ||
| 387 | if keywords.iter().any(|k| content_lower.contains(k)) { | 392 | if keywords.iter().any(|k| content_lower.contains(k)) { |
| 388 | chosen = responses.choose(&mut rand::rng()).cloned(); | 393 | chosen = responses.choose(&mut rand::rng()).cloned(); |
| 389 | break; | 394 | break; |
