From 9e291ee31d0b682ee9c66e6e4b0153146b255633 Mon Sep 17 00:00:00 2001 From: howie-mac-mini Date: Sat, 11 Apr 2026 20:14:46 +0800 Subject: [PATCH 1/7] feat: add configurable auto_archive_duration for Discord threads Adds `auto_archive_duration` to `DiscordConfig` with a default of 1440 (OneDay). Validates at config load time that the value is one of 60/1440/4320/10080. Passes the configured value through `Handler` to `get_or_create_thread` and converts it to the appropriate `AutoArchiveDuration` enum variant. Co-Authored-By: Claude Sonnet 4.6 --- config.toml.example | 3 +++ src/config.rs | 10 ++++++++++ src/discord.rs | 22 +++++++++++++++++++--- src/main.rs | 1 + 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/config.toml.example b/config.toml.example index c4227dc6..80b56f6f 100644 --- a/config.toml.example +++ b/config.toml.example @@ -1,6 +1,9 @@ [discord] bot_token = "${DISCORD_BOT_TOKEN}" allowed_channels = ["1234567890"] +# auto_archive_duration: minutes before an inactive thread is archived. +# Valid values: 60 (1h), 1440 (1d), 4320 (3d), 10080 (1w). Default: 1440. +# auto_archive_duration = 1440 [agent] command = "kiro-cli" diff --git a/src/config.rs b/src/config.rs index 719feafa..63ff7dcd 100644 --- a/src/config.rs +++ b/src/config.rs @@ -18,6 +18,8 @@ pub struct DiscordConfig { pub bot_token: String, #[serde(default)] pub allowed_channels: Vec, + #[serde(default = "default_auto_archive_duration")] + pub auto_archive_duration: u32, } #[derive(Debug, Deserialize)] @@ -85,6 +87,7 @@ pub struct ReactionTiming { // --- defaults --- +fn default_auto_archive_duration() -> u32 { 1440 } fn default_working_dir() -> String { "/tmp".into() } fn default_max_sessions() -> usize { 10 } fn default_ttl_hours() -> u64 { 24 } @@ -156,5 +159,12 @@ pub fn load_config(path: &Path) -> anyhow::Result { let expanded = expand_env_vars(&raw); let config: Config = toml::from_str(&expanded) .map_err(|e| anyhow::anyhow!("failed to parse {}: {e}", path.display()))?; + const VALID_ARCHIVE_DURATIONS: &[u32] = &[60, 1440, 4320, 10080]; + if !VALID_ARCHIVE_DURATIONS.contains(&config.discord.auto_archive_duration) { + anyhow::bail!( + "invalid auto_archive_duration: {}. Must be one of: 60 (1h), 1440 (1d), 4320 (3d), 10080 (1w)", + config.discord.auto_archive_duration + ); + } Ok(config) } diff --git a/src/discord.rs b/src/discord.rs index da52c691..3109a28a 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -16,6 +16,7 @@ pub struct Handler { pub pool: Arc, pub allowed_channels: HashSet, pub reactions_config: ReactionsConfig, + pub auto_archive_duration: u32, } #[async_trait] @@ -97,7 +98,7 @@ impl EventHandler for Handler { let thread_id = if in_thread { msg.channel_id.get() } else { - match get_or_create_thread(&ctx, &msg, &prompt).await { + match get_or_create_thread(&ctx, &msg, &prompt, self.auto_archive_duration).await { Ok(id) => id, Err(e) => { error!("failed to create thread: {e}"); @@ -347,7 +348,21 @@ fn shorten_thread_name(prompt: &str) -> String { } } -async fn get_or_create_thread(ctx: &Context, msg: &Message, prompt: &str) -> anyhow::Result { +fn archive_duration_from_mins(mins: u32) -> serenity::model::channel::AutoArchiveDuration { + match mins { + 60 => serenity::model::channel::AutoArchiveDuration::OneHour, + 4320 => serenity::model::channel::AutoArchiveDuration::ThreeDays, + 10080 => serenity::model::channel::AutoArchiveDuration::OneWeek, + _ => serenity::model::channel::AutoArchiveDuration::OneDay, + } +} + +async fn get_or_create_thread( + ctx: &Context, + msg: &Message, + prompt: &str, + auto_archive_duration: u32, +) -> anyhow::Result { let channel = msg.channel_id.to_channel(&ctx.http).await?; if let serenity::model::channel::Channel::Guild(ref gc) = channel { if gc.thread_metadata.is_some() { @@ -356,6 +371,7 @@ async fn get_or_create_thread(ctx: &Context, msg: &Message, prompt: &str) -> any } let thread_name = shorten_thread_name(prompt); + let archive_duration = archive_duration_from_mins(auto_archive_duration); let thread = msg .channel_id @@ -363,7 +379,7 @@ async fn get_or_create_thread(ctx: &Context, msg: &Message, prompt: &str) -> any &ctx.http, msg.id, serenity::builder::CreateThread::new(thread_name) - .auto_archive_duration(serenity::model::channel::AutoArchiveDuration::OneDay), + .auto_archive_duration(archive_duration), ) .await?; diff --git a/src/main.rs b/src/main.rs index 4d6e6c30..57289b90 100644 --- a/src/main.rs +++ b/src/main.rs @@ -47,6 +47,7 @@ async fn main() -> anyhow::Result<()> { pool: pool.clone(), allowed_channels, reactions_config: cfg.reactions, + auto_archive_duration: cfg.discord.auto_archive_duration, }; let intents = GatewayIntents::GUILD_MESSAGES From 7efc1dd0f3d0d6ba411232f96ffbe165f441db6b Mon Sep 17 00:00:00 2001 From: howie-mac-mini Date: Sat, 11 Apr 2026 20:32:16 +0800 Subject: [PATCH 2/7] feat: add per-channel config, direct mode, and ignore-other-mentions Add per-channel configuration overrides via `[discord.channels."ID"]` TOML sections. Each channel can independently override: - require_mention: when false, any message triggers the bot (direct mode) - ignore_other_mentions: skip messages mentioning other users/bots - auto_archive_duration: per-channel thread archive duration - thread_name_mode: per-channel thread naming strategy New global defaults on DiscordConfig: require_mention (true), ignore_other_mentions (false), thread_name_mode ("truncate"). Channel overrides fall back to global defaults when not set. Validation of auto_archive_duration also applies to channel overrides. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/config.rs | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/discord.rs | 34 ++++++++++++++++++++++++++----- src/main.rs | 3 +++ 3 files changed, 86 insertions(+), 5 deletions(-) diff --git a/src/config.rs b/src/config.rs index 63ff7dcd..3b159506 100644 --- a/src/config.rs +++ b/src/config.rs @@ -20,6 +20,48 @@ pub struct DiscordConfig { pub allowed_channels: Vec, #[serde(default = "default_auto_archive_duration")] pub auto_archive_duration: u32, + #[serde(default = "default_true")] + pub require_mention: bool, + #[serde(default)] + pub ignore_other_mentions: bool, + #[serde(default = "default_thread_name_mode")] + pub thread_name_mode: String, + #[serde(default)] + pub channels: HashMap, +} + +#[derive(Debug, Deserialize, Default, Clone)] +pub struct ChannelConfig { + pub auto_archive_duration: Option, + pub require_mention: Option, + pub ignore_other_mentions: Option, + pub thread_name_mode: Option, +} + +impl DiscordConfig { + pub fn effective_archive_duration(&self, channel_id: &str) -> u32 { + self.channels.get(channel_id) + .and_then(|c| c.auto_archive_duration) + .unwrap_or(self.auto_archive_duration) + } + + pub fn effective_require_mention(&self, channel_id: &str) -> bool { + self.channels.get(channel_id) + .and_then(|c| c.require_mention) + .unwrap_or(self.require_mention) + } + + pub fn effective_ignore_other_mentions(&self, channel_id: &str) -> bool { + self.channels.get(channel_id) + .and_then(|c| c.ignore_other_mentions) + .unwrap_or(self.ignore_other_mentions) + } + + pub fn effective_thread_name_mode(&self, channel_id: &str) -> &str { + self.channels.get(channel_id) + .and_then(|c| c.thread_name_mode.as_deref()) + .unwrap_or(&self.thread_name_mode) + } } #[derive(Debug, Deserialize)] @@ -88,6 +130,7 @@ pub struct ReactionTiming { // --- defaults --- fn default_auto_archive_duration() -> u32 { 1440 } +fn default_thread_name_mode() -> String { "truncate".into() } fn default_working_dir() -> String { "/tmp".into() } fn default_max_sessions() -> usize { 10 } fn default_ttl_hours() -> u64 { 24 } @@ -166,5 +209,16 @@ pub fn load_config(path: &Path) -> anyhow::Result { config.discord.auto_archive_duration ); } + for (channel_id, channel_cfg) in &config.discord.channels { + if let Some(duration) = channel_cfg.auto_archive_duration { + if !VALID_ARCHIVE_DURATIONS.contains(&duration) { + anyhow::bail!( + "invalid auto_archive_duration for channel {}: {}. Must be one of: 60 (1h), 1440 (1d), 4320 (3d), 10080 (1w)", + channel_id, + duration + ); + } + } + } Ok(config) } diff --git a/src/discord.rs b/src/discord.rs index 3109a28a..01832a84 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -1,5 +1,5 @@ use crate::acp::{classify_notification, AcpEvent, SessionPool}; -use crate::config::ReactionsConfig; +use crate::config::{ChannelConfig, ReactionsConfig}; use crate::format; use crate::reactions::StatusReactionController; use serenity::async_trait; @@ -7,7 +7,7 @@ use serenity::model::channel::Message; use serenity::model::gateway::Ready; use serenity::model::id::{ChannelId, MessageId}; use serenity::prelude::*; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use tokio::sync::watch; use tracing::{error, info}; @@ -17,6 +17,9 @@ pub struct Handler { pub allowed_channels: HashSet, pub reactions_config: ReactionsConfig, pub auto_archive_duration: u32, + pub require_mention: bool, + pub ignore_other_mentions: bool, + pub channels: HashMap, } #[async_trait] @@ -61,8 +64,29 @@ impl EventHandler for Handler { if !in_allowed_channel && !in_thread { return; } - if !in_thread && !is_mentioned { - return; + + // Resolve per-channel settings (use parent channel for threads) + let effective_channel_id = channel_id.to_string(); + let require_mention = self.channels.get(&effective_channel_id) + .and_then(|c| c.require_mention) + .unwrap_or(self.require_mention); + let ignore_other_mentions = self.channels.get(&effective_channel_id) + .and_then(|c| c.ignore_other_mentions) + .unwrap_or(self.ignore_other_mentions); + let effective_archive_duration = self.channels.get(&effective_channel_id) + .and_then(|c| c.auto_archive_duration) + .unwrap_or(self.auto_archive_duration); + + if !in_thread { + // When ignore_other_mentions is enabled, skip messages that mention + // other users/bots but do NOT mention this bot + if ignore_other_mentions && !is_mentioned && !msg.mentions.is_empty() { + return; + } + + if require_mention && !is_mentioned { + return; + } } let prompt = if is_mentioned { @@ -98,7 +122,7 @@ impl EventHandler for Handler { let thread_id = if in_thread { msg.channel_id.get() } else { - match get_or_create_thread(&ctx, &msg, &prompt, self.auto_archive_duration).await { + match get_or_create_thread(&ctx, &msg, &prompt, effective_archive_duration).await { Ok(id) => id, Err(e) => { error!("failed to create thread: {e}"); diff --git a/src/main.rs b/src/main.rs index 57289b90..e845513c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -48,6 +48,9 @@ async fn main() -> anyhow::Result<()> { allowed_channels, reactions_config: cfg.reactions, auto_archive_duration: cfg.discord.auto_archive_duration, + require_mention: cfg.discord.require_mention, + ignore_other_mentions: cfg.discord.ignore_other_mentions, + channels: cfg.discord.channels.clone(), }; let intents = GatewayIntents::GUILD_MESSAGES From 4ceadc9052ef76a006b3f7467d9a3494c5df6333 Mon Sep 17 00:00:00 2001 From: howie-mac-mini Date: Sat, 11 Apr 2026 20:34:07 +0800 Subject: [PATCH 3/7] fix: resolve per-channel config using parent channel for threads - Use parent_channel_id (not thread ID) for per-channel config resolution - Remove unused effective_* methods from DiscordConfig (logic is inline in Handler) - Cache channel_override lookup to avoid repeated HashMap access Co-Authored-By: Claude Opus 4.6 (1M context) --- src/config.rs | 25 ------------------------- src/discord.rs | 32 +++++++++++++++++++------------- 2 files changed, 19 insertions(+), 38 deletions(-) diff --git a/src/config.rs b/src/config.rs index 3b159506..59cc4b25 100644 --- a/src/config.rs +++ b/src/config.rs @@ -38,31 +38,6 @@ pub struct ChannelConfig { pub thread_name_mode: Option, } -impl DiscordConfig { - pub fn effective_archive_duration(&self, channel_id: &str) -> u32 { - self.channels.get(channel_id) - .and_then(|c| c.auto_archive_duration) - .unwrap_or(self.auto_archive_duration) - } - - pub fn effective_require_mention(&self, channel_id: &str) -> bool { - self.channels.get(channel_id) - .and_then(|c| c.require_mention) - .unwrap_or(self.require_mention) - } - - pub fn effective_ignore_other_mentions(&self, channel_id: &str) -> bool { - self.channels.get(channel_id) - .and_then(|c| c.ignore_other_mentions) - .unwrap_or(self.ignore_other_mentions) - } - - pub fn effective_thread_name_mode(&self, channel_id: &str) -> &str { - self.channels.get(channel_id) - .and_then(|c| c.thread_name_mode.as_deref()) - .unwrap_or(&self.thread_name_mode) - } -} #[derive(Debug, Deserialize)] pub struct AgentConfig { diff --git a/src/discord.rs b/src/discord.rs index 01832a84..8016edcf 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -39,41 +39,47 @@ impl EventHandler for Handler { || msg.content.contains(&format!("<@{}>", bot_id)) || msg.mention_roles.iter().any(|r| msg.content.contains(&format!("<@&{}>", r))); - let in_thread = if !in_allowed_channel { + // Check if message is in a thread whose parent is an allowed channel. + // Track parent_id so per-channel config resolves against the parent channel. + let (in_thread, parent_channel_id) = if !in_allowed_channel { match msg.channel_id.to_channel(&ctx.http).await { Ok(serenity::model::channel::Channel::Guild(gc)) => { - let result = gc - .parent_id - .map_or(false, |pid| self.allowed_channels.contains(&pid.get())); + let parent = gc.parent_id.map(|pid| pid.get()); + let result = parent.map_or(false, |pid| self.allowed_channels.contains(&pid)); tracing::debug!(channel_id = %msg.channel_id, parent_id = ?gc.parent_id, result, "thread check"); - result + (result, parent) } Ok(other) => { tracing::debug!(channel_id = %msg.channel_id, kind = ?other, "not a guild channel"); - false + (false, None) } Err(e) => { tracing::debug!(channel_id = %msg.channel_id, error = %e, "to_channel failed"); - false + (false, None) } } } else { - false + (false, None) }; if !in_allowed_channel && !in_thread { return; } - // Resolve per-channel settings (use parent channel for threads) - let effective_channel_id = channel_id.to_string(); - let require_mention = self.channels.get(&effective_channel_id) + // Resolve per-channel settings using the parent channel for threads + let config_channel_id = if in_thread { + parent_channel_id.unwrap_or(channel_id).to_string() + } else { + channel_id.to_string() + }; + let channel_override = self.channels.get(&config_channel_id); + let require_mention = channel_override .and_then(|c| c.require_mention) .unwrap_or(self.require_mention); - let ignore_other_mentions = self.channels.get(&effective_channel_id) + let ignore_other_mentions = channel_override .and_then(|c| c.ignore_other_mentions) .unwrap_or(self.ignore_other_mentions); - let effective_archive_duration = self.channels.get(&effective_channel_id) + let effective_archive_duration = channel_override .and_then(|c| c.auto_archive_duration) .unwrap_or(self.auto_archive_duration); From 86fef5c08b180247fc470097fcb742928b95fb67 Mon Sep 17 00:00:00 2001 From: howie-mac-mini Date: Sat, 11 Apr 2026 20:35:53 +0800 Subject: [PATCH 4/7] style: remove extra blank line in config.rs Co-Authored-By: Claude Opus 4.6 (1M context) --- src/config.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/config.rs b/src/config.rs index 59cc4b25..9974454b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -38,7 +38,6 @@ pub struct ChannelConfig { pub thread_name_mode: Option, } - #[derive(Debug, Deserialize)] pub struct AgentConfig { pub command: String, From b011d1ce91e983e28cad2fcb44fd61e9db9b8af9 Mon Sep 17 00:00:00 2001 From: howie-mac-mini Date: Sat, 11 Apr 2026 20:41:04 +0800 Subject: [PATCH 5/7] feat: add LLM-generated thread names via thread_name_mode="generated" When thread_name_mode is set to "generated" (global or per-channel), sends a short naming prompt to the ACP session after thread creation and renames the thread via Discord API before the main response. Falls back to the truncated name on any failure (timeout, error). Includes config validation for thread_name_mode values at load time. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/config.rs | 16 +++++++++++ src/discord.rs | 75 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 1 + 3 files changed, 92 insertions(+) diff --git a/src/config.rs b/src/config.rs index 9974454b..83244d99 100644 --- a/src/config.rs +++ b/src/config.rs @@ -183,6 +183,13 @@ pub fn load_config(path: &Path) -> anyhow::Result { config.discord.auto_archive_duration ); } + const VALID_THREAD_NAME_MODES: &[&str] = &["truncate", "generated"]; + if !VALID_THREAD_NAME_MODES.contains(&config.discord.thread_name_mode.as_str()) { + anyhow::bail!( + "invalid thread_name_mode: \"{}\". Must be one of: truncate, generated", + config.discord.thread_name_mode + ); + } for (channel_id, channel_cfg) in &config.discord.channels { if let Some(duration) = channel_cfg.auto_archive_duration { if !VALID_ARCHIVE_DURATIONS.contains(&duration) { @@ -193,6 +200,15 @@ pub fn load_config(path: &Path) -> anyhow::Result { ); } } + if let Some(ref mode) = channel_cfg.thread_name_mode { + if !VALID_THREAD_NAME_MODES.contains(&mode.as_str()) { + anyhow::bail!( + "invalid thread_name_mode for channel {}: \"{}\". Must be one of: truncate, generated", + channel_id, + mode + ); + } + } } Ok(config) } diff --git a/src/discord.rs b/src/discord.rs index 8016edcf..75024d38 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -19,6 +19,7 @@ pub struct Handler { pub auto_archive_duration: u32, pub require_mention: bool, pub ignore_other_mentions: bool, + pub thread_name_mode: String, pub channels: HashMap, } @@ -154,6 +155,17 @@ impl EventHandler for Handler { return; } + // Generate a better thread name via LLM if configured + let thread_name_mode = channel_override + .and_then(|c| c.thread_name_mode.as_deref()) + .unwrap_or(&self.thread_name_mode); + + if !in_thread && thread_name_mode == "generated" { + if let Err(e) = generate_thread_name(&self.pool, &thread_key, &prompt, &ctx, thread_channel).await { + tracing::warn!("failed to generate thread name: {e}"); + } + } + // Create reaction controller on the user's original message let reactions = Arc::new(StatusReactionController::new( self.reactions_config.enabled, @@ -348,6 +360,69 @@ async fn stream_prompt( .await } +async fn generate_thread_name( + pool: &SessionPool, + thread_key: &str, + prompt: &str, + ctx: &Context, + thread_channel: ChannelId, +) -> anyhow::Result<()> { + let naming_prompt = format!( + "Generate a short title (max 6 words) for the following message. Reply with ONLY the title, nothing else:\n\n{}", + prompt.chars().take(500).collect::() + ); + + let name = tokio::time::timeout( + std::time::Duration::from_secs(10), + request_thread_name(pool, thread_key, &naming_prompt), + ) + .await + .map_err(|_| anyhow::anyhow!("naming request timed out"))??; + + let name = name.chars().take(100).collect::().trim().to_string(); + if !name.is_empty() { + thread_channel + .edit_thread( + &ctx.http, + serenity::builder::EditThread::new().name(&name), + ) + .await?; + } + Ok(()) +} + +async fn request_thread_name( + pool: &SessionPool, + thread_key: &str, + naming_prompt: &str, +) -> anyhow::Result { + let naming_prompt = naming_prompt.to_string(); + pool.with_connection(thread_key, |conn| { + let naming_prompt = naming_prompt.clone(); + Box::pin(async move { + let (mut rx, _) = conn.session_prompt(&naming_prompt).await?; + + let mut text_buf = String::new(); + while let Some(notification) = rx.recv().await { + // A message with id set signals the prompt response is complete + if notification.id.is_some() { + break; + } + if let Some(event) = classify_notification(¬ification) { + if let AcpEvent::Text(t) = event { + text_buf.push_str(&t); + } + // Ignore tool calls, thinking, etc. + } + } + + conn.prompt_done().await; + Ok(text_buf) + }) + }) + .await +} + fn compose_display(tool_lines: &[String], text: &str) -> String { let mut out = String::new(); if !tool_lines.is_empty() { diff --git a/src/main.rs b/src/main.rs index e845513c..0b4a515c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -50,6 +50,7 @@ async fn main() -> anyhow::Result<()> { auto_archive_duration: cfg.discord.auto_archive_duration, require_mention: cfg.discord.require_mention, ignore_other_mentions: cfg.discord.ignore_other_mentions, + thread_name_mode: cfg.discord.thread_name_mode.clone(), channels: cfg.discord.channels.clone(), }; From e681b054e26ba9b847d177972abe57ca764e2421 Mon Sep 17 00:00:00 2001 From: howie-mac-mini Date: Sat, 11 Apr 2026 20:44:25 +0800 Subject: [PATCH 6/7] fix: move thread naming to background task, improve naming prompt - Run LLM thread naming after main prompt completes in a tokio::spawn task instead of blocking before stream_prompt. This avoids: - Holding the pool write-lock during the naming request - Delaying the user's response while a name is generated - The naming prompt appearing before the user's real message in session history - Add "Do not use any tools" to naming prompt to prevent tool-use overhead - Strip quotes and markdown formatting from generated names Co-Authored-By: Claude Opus 4.6 (1M context) --- src/discord.rs | 40 ++++++++++++++++++++++++++-------------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/src/discord.rs b/src/discord.rs index 75024d38..dec02da4 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -155,16 +155,13 @@ impl EventHandler for Handler { return; } - // Generate a better thread name via LLM if configured - let thread_name_mode = channel_override - .and_then(|c| c.thread_name_mode.as_deref()) - .unwrap_or(&self.thread_name_mode); - - if !in_thread && thread_name_mode == "generated" { - if let Err(e) = generate_thread_name(&self.pool, &thread_key, &prompt, &ctx, thread_channel).await { - tracing::warn!("failed to generate thread name: {e}"); - } - } + // Resolve thread_name_mode for potential background rename + let should_generate_name = !in_thread && { + let mode = channel_override + .and_then(|c| c.thread_name_mode.as_deref()) + .unwrap_or(&self.thread_name_mode); + mode == "generated" + }; // Create reaction controller on the user's original message let reactions = Arc::new(StatusReactionController::new( @@ -211,6 +208,20 @@ impl EventHandler for Handler { if let Err(e) = result { let _ = edit(&ctx, thread_channel, thinking_msg.id, &format!("⚠️ {e}")).await; } + + // Generate a better thread name in the background after the main prompt completes. + // This avoids blocking the user's response and holding the pool write-lock during naming. + if should_generate_name { + let pool = self.pool.clone(); + let ctx = ctx.clone(); + let prompt = prompt.clone(); + let thread_key = thread_key.clone(); + tokio::spawn(async move { + if let Err(e) = generate_thread_name(&pool, &thread_key, &prompt, &ctx, thread_channel).await { + tracing::warn!("failed to generate thread name: {e}"); + } + }); + } } async fn ready(&self, _ctx: Context, ready: Ready) { @@ -361,14 +372,14 @@ async fn stream_prompt( } async fn generate_thread_name( - pool: &SessionPool, + pool: &Arc, thread_key: &str, prompt: &str, ctx: &Context, thread_channel: ChannelId, ) -> anyhow::Result<()> { let naming_prompt = format!( - "Generate a short title (max 6 words) for the following message. Reply with ONLY the title, nothing else:\n\n{}", + "Generate a short title (max 6 words) for the following message. Do not use any tools. Reply with ONLY the title text, nothing else:\n\n{}", prompt.chars().take(500).collect::() ); @@ -379,7 +390,8 @@ async fn generate_thread_name( .await .map_err(|_| anyhow::anyhow!("naming request timed out"))??; - let name = name.chars().take(100).collect::().trim().to_string(); + let name = name.trim().trim_matches(|c| c == '"' || c == '\'' || c == '*' || c == '`') + .chars().take(100).collect::(); if !name.is_empty() { thread_channel .edit_thread( @@ -392,7 +404,7 @@ async fn generate_thread_name( } async fn request_thread_name( - pool: &SessionPool, + pool: &Arc, thread_key: &str, naming_prompt: &str, ) -> anyhow::Result { From b313ff8b8409b78149b1d629f217bf8c209079a3 Mon Sep 17 00:00:00 2001 From: howie-mac-mini Date: Sat, 11 Apr 2026 20:45:04 +0800 Subject: [PATCH 7/7] docs: update config example and Helm chart with new thread management options - config.toml.example: add require_mention, ignore_other_mentions, thread_name_mode, and per-channel override examples - values.yaml: add discord.autoArchiveDuration, requireMention, ignoreOtherMentions, threadNameMode, and channels map - configmap.yaml: template per-channel config with range loop Co-Authored-By: Claude Opus 4.6 (1M context) --- charts/openab/templates/configmap.yaml | 20 ++++++++++++++++++++ charts/openab/values.yaml | 11 +++++++++++ config.toml.example | 15 +++++++++++++++ 3 files changed, 46 insertions(+) diff --git a/charts/openab/templates/configmap.yaml b/charts/openab/templates/configmap.yaml index e719c92c..727bcd08 100644 --- a/charts/openab/templates/configmap.yaml +++ b/charts/openab/templates/configmap.yaml @@ -14,6 +14,26 @@ data: {{- end }} {{- end }} allowed_channels = [{{ range $i, $ch := .Values.discord.allowedChannels }}{{ if $i }}, {{ end }}"{{ $ch }}"{{ end }}] + auto_archive_duration = {{ .Values.discord.autoArchiveDuration }} + require_mention = {{ .Values.discord.requireMention }} + ignore_other_mentions = {{ .Values.discord.ignoreOtherMentions }} + thread_name_mode = "{{ .Values.discord.threadNameMode }}" + {{- range $id, $cfg := .Values.discord.channels }} + + [discord.channels."{{ $id }}"] + {{- if $cfg.autoArchiveDuration }} + auto_archive_duration = {{ $cfg.autoArchiveDuration }} + {{- end }} + {{- if ne (toString (default "" $cfg.requireMention)) "" }} + require_mention = {{ $cfg.requireMention }} + {{- end }} + {{- if ne (toString (default "" $cfg.ignoreOtherMentions)) "" }} + ignore_other_mentions = {{ $cfg.ignoreOtherMentions }} + {{- end }} + {{- if $cfg.threadNameMode }} + thread_name_mode = "{{ $cfg.threadNameMode }}" + {{- end }} + {{- end }} [agent] command = "{{ include "openab.agent.command" . | trim }}" diff --git a/charts/openab/values.yaml b/charts/openab/values.yaml index 59ed4d1d..db13b2f5 100644 --- a/charts/openab/values.yaml +++ b/charts/openab/values.yaml @@ -21,6 +21,17 @@ discord: # helm install ... --set-string discord.allowedChannels[0]="" allowedChannels: - "YOUR_CHANNEL_ID" + autoArchiveDuration: 1440 # 60 (1h), 1440 (1d), 4320 (3d), 10080 (1w) + requireMention: true # false = "direct mode" (any message triggers a thread) + ignoreOtherMentions: false # true = skip messages mentioning other bots/users + threadNameMode: "truncate" # "truncate" or "generated" (LLM generates short title) + # Per-channel overrides (optional): + # channels: + # "CHANNEL_ID": + # autoArchiveDuration: 60 + # requireMention: false + # threadNameMode: "generated" + channels: {} agent: preset: "" # kiro (default), codex, or claude — auto-configures image + command diff --git a/config.toml.example b/config.toml.example index 80b56f6f..7b1ecba4 100644 --- a/config.toml.example +++ b/config.toml.example @@ -4,6 +4,21 @@ allowed_channels = ["1234567890"] # auto_archive_duration: minutes before an inactive thread is archived. # Valid values: 60 (1h), 1440 (1d), 4320 (3d), 10080 (1w). Default: 1440. # auto_archive_duration = 1440 +# require_mention: if false, any message in the channel triggers a thread ("direct mode"). +# Default: true (bot must be @mentioned). +# require_mention = true +# ignore_other_mentions: if true, skip messages that mention other users/bots but not this bot. +# Default: false. +# ignore_other_mentions = false +# thread_name_mode: "truncate" (default) or "generated" (LLM generates a short title). +# thread_name_mode = "truncate" + +# Per-channel overrides (optional). Channel IDs must be quoted strings. +# [discord.channels."1234567890"] +# auto_archive_duration = 60 +# require_mention = false +# ignore_other_mentions = true +# thread_name_mode = "generated" [agent] command = "kiro-cli"