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 c4227dc6..7b1ecba4 100644 --- a/config.toml.example +++ b/config.toml.example @@ -1,6 +1,24 @@ [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 +# 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" diff --git a/src/config.rs b/src/config.rs index 719feafa..83244d99 100644 --- a/src/config.rs +++ b/src/config.rs @@ -18,6 +18,24 @@ pub struct DiscordConfig { pub bot_token: String, #[serde(default)] 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, } #[derive(Debug, Deserialize)] @@ -85,6 +103,8 @@ 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 } @@ -156,5 +176,39 @@ 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 + ); + } + 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) { + anyhow::bail!( + "invalid auto_archive_duration for channel {}: {}. Must be one of: 60 (1h), 1440 (1d), 4320 (3d), 10080 (1w)", + channel_id, + duration + ); + } + } + 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 da52c691..dec02da4 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}; @@ -16,6 +16,11 @@ pub struct Handler { pub pool: Arc, pub allowed_channels: HashSet, pub reactions_config: ReactionsConfig, + pub auto_archive_duration: u32, + pub require_mention: bool, + pub ignore_other_mentions: bool, + pub thread_name_mode: String, + pub channels: HashMap, } #[async_trait] @@ -35,33 +40,60 @@ 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; } - if !in_thread && !is_mentioned { - return; + + // 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 = channel_override + .and_then(|c| c.ignore_other_mentions) + .unwrap_or(self.ignore_other_mentions); + let effective_archive_duration = channel_override + .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 { @@ -97,7 +129,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, effective_archive_duration).await { Ok(id) => id, Err(e) => { error!("failed to create thread: {e}"); @@ -123,6 +155,14 @@ impl EventHandler for Handler { return; } + // 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( self.reactions_config.enabled, @@ -168,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) { @@ -317,6 +371,70 @@ async fn stream_prompt( .await } +async fn generate_thread_name( + 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. Do not use any tools. Reply with ONLY the title text, 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.trim().trim_matches(|c| c == '"' || c == '\'' || c == '*' || c == '`') + .chars().take(100).collect::(); + if !name.is_empty() { + thread_channel + .edit_thread( + &ctx.http, + serenity::builder::EditThread::new().name(&name), + ) + .await?; + } + Ok(()) +} + +async fn request_thread_name( + pool: &Arc, + 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() { @@ -347,7 +465,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 +488,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 +496,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..0b4a515c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -47,6 +47,11 @@ async fn main() -> anyhow::Result<()> { pool: pool.clone(), 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, + thread_name_mode: cfg.discord.thread_name_mode.clone(), + channels: cfg.discord.channels.clone(), }; let intents = GatewayIntents::GUILD_MESSAGES