Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions charts/openab/templates/configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}"
Expand Down
11 changes: 11 additions & 0 deletions charts/openab/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,17 @@ discord:
# helm install ... --set-string discord.allowedChannels[0]="<CHANNEL_ID>"
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
Expand Down
18 changes: 18 additions & 0 deletions config.toml.example
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
54 changes: 54 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,24 @@ pub struct DiscordConfig {
pub bot_token: String,
#[serde(default)]
pub allowed_channels: Vec<String>,
#[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<String, ChannelConfig>,
}

#[derive(Debug, Deserialize, Default, Clone)]
pub struct ChannelConfig {
pub auto_archive_duration: Option<u32>,
pub require_mention: Option<bool>,
pub ignore_other_mentions: Option<bool>,
pub thread_name_mode: Option<String>,
}

#[derive(Debug, Deserialize)]
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -156,5 +176,39 @@ pub fn load_config(path: &Path) -> anyhow::Result<Config> {
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)
}
163 changes: 148 additions & 15 deletions src/discord.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
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;
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};
Expand All @@ -16,6 +16,11 @@ pub struct Handler {
pub pool: Arc<SessionPool>,
pub allowed_channels: HashSet<u64>,
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<String, ChannelConfig>,
}

#[async_trait]
Expand All @@ -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 {
Expand Down Expand Up @@ -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}");
Expand All @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -317,6 +371,70 @@ async fn stream_prompt(
.await
}

async fn generate_thread_name(
pool: &Arc<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. Do not use any tools. Reply with ONLY the title text, nothing else:\n\n{}",
prompt.chars().take(500).collect::<String>()
);

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::<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: &Arc<SessionPool>,
thread_key: &str,
naming_prompt: &str,
) -> anyhow::Result<String> {
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(&notification) {
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() {
Expand Down Expand Up @@ -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<u64> {
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<u64> {
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() {
Expand All @@ -356,14 +488,15 @@ 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
.create_thread_from_message(
&ctx.http,
msg.id,
serenity::builder::CreateThread::new(thread_name)
.auto_archive_duration(serenity::model::channel::AutoArchiveDuration::OneDay),
.auto_archive_duration(archive_duration),
)
.await?;

Expand Down
Loading