aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-11-04 01:09:10 +0100
committersalmonik <salmonik@tuta.io>2026-11-04 01:09:10 +0100
commitceb96326ccf3daff0472cafdaba3e223d3a20724 (patch)
tree8bd84f484ce714bd10f1c9f25f612ab16fbd8eae
parentcron: scope jobs to the guild that created them (diff)
downloadsenator-sperma-ceb96326ccf3daff0472cafdaba3e223d3a20724.tar.gz
senator-sperma-ceb96326ccf3daff0472cafdaba3e223d3a20724.zip
triggers: scope groups to the guild that created them
-rw-r--r--src/commands/autocomplete.rs5
-rw-r--r--src/commands/trigger.rs17
-rw-r--r--src/database/mod.rs59
-rw-r--r--src/events/mod.rs7
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
210pub async fn trigger_name(ctx: Context<'_>, partial: &str) -> Vec<String> { 210pub 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)]
417pub struct Trigger { 427pub 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
440pub async fn create_trigger(pool: &DbPool, name: &str) -> Result<i64> { 451pub 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
448pub async fn delete_trigger(pool: &DbPool, id: i64) -> Result<bool> { 460pub 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
456pub async fn get_trigger_by_name(pool: &DbPool, name: &str) -> Result<Option<Trigger>> { 469pub 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
463pub async fn list_triggers(pool: &DbPool) -> Result<Vec<Trigger>> { 483pub 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
469pub async fn add_keyword(pool: &DbPool, trigger_id: i64, keyword: &str) -> Result<()> { 492pub 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>
526pub async fn load_all_triggers_with_keywords_and_responses( 549pub 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;