From 6e3015c70b706975b8d6a9fb84480e5eb866df0a Mon Sep 17 00:00:00 2001 From: Tony Lee Date: Fri, 10 Apr 2026 17:53:05 +0800 Subject: [PATCH 01/16] feat: add Slack adapter with multi-platform ChatAdapter architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a platform-agnostic adapter layer (ChatAdapter trait + AdapterRouter) that decouples session management, streaming, and reactions from any specific chat platform. Refactors Discord support to implement this trait and adds a new Slack adapter using Socket Mode (WebSocket) with auto-reconnect. - Extract ChatAdapter trait with send/edit/thread/react methods + message_limit() - Extract AdapterRouter with shared session routing, edit-streaming, and reactions - Decouple StatusReactionController from serenity types (uses Arc) - Implement DiscordAdapter (ChatAdapter for Discord via serenity) - Implement SlackAdapter (ChatAdapter for Slack via reqwest + tokio-tungstenite) - Add [slack] config section (bot_token, app_token, allowed_channels, allowed_users) - Support running Discord + Slack simultaneously in one process - Session keys namespaced by platform (discord:xxx, slack:xxx) - Unicode emoji → Slack short name mapping for reactions Relates to #93, #94, #86 Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 32 +-- Cargo.toml | 3 + config.toml.example | 8 + src/adapter.rs | 400 ++++++++++++++++++++++++++++ src/config.rs | 13 +- src/discord.rs | 616 +++++++++++++++----------------------------- src/main.rs | 117 ++++++--- src/reactions.rs | 76 ++---- src/slack.rs | 374 +++++++++++++++++++++++++++ 9 files changed, 1134 insertions(+), 505 deletions(-) create mode 100644 src/adapter.rs create mode 100644 src/slack.rs diff --git a/Cargo.lock b/Cargo.lock index 6b016571..7a81daa5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -493,15 +493,14 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "c2b52f86d1d4bc0d6b4e6826d960b1b333217e07d36b882dca570a5e1c48895b" dependencies = [ "http", "hyper", "hyper-util", - "rustls 0.23.37", - "rustls-pki-types", + "rustls 0.23.38", "tokio", "tokio-rustls 0.26.4", "tower-service", @@ -728,9 +727,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.184" +version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" [[package]] name = "litemap" @@ -856,7 +855,9 @@ name = "openab" version = "0.7.3" dependencies = [ "anyhow", + "async-trait", "base64", + "futures-util", "image", "libc", "rand 0.8.5", @@ -866,6 +867,7 @@ dependencies = [ "serde_json", "serenity", "tokio", + "tokio-tungstenite", "toml", "tracing", "tracing-subscriber", @@ -987,7 +989,7 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls 0.23.37", + "rustls 0.23.38", "socket2", "thiserror 2.0.18", "tokio", @@ -1004,10 +1006,10 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.2", + "rand 0.9.4", "ring", "rustc-hash", - "rustls 0.23.37", + "rustls 0.23.38", "rustls-pki-types", "slab", "thiserror 2.0.18", @@ -1064,9 +1066,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.2" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", @@ -1170,7 +1172,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.37", + "rustls 0.23.38", "rustls-pki-types", "serde", "serde_json", @@ -1226,9 +1228,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" dependencies = [ "once_cell", "ring", @@ -1665,7 +1667,7 @@ version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls 0.23.37", + "rustls 0.23.38", "tokio", ] diff --git a/Cargo.toml b/Cargo.toml index 829d7bd1..fa36e948 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,8 +14,11 @@ serenity = { version = "0.12", default-features = false, features = ["client", " uuid = { version = "1", features = ["v4"] } regex = "1" anyhow = "1" +async-trait = "0.1" +futures-util = "0.3" rand = "0.8" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "multipart", "json"] } base64 = "0.22" image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] } libc = "0.2" +tokio-tungstenite = { version = "0.21", features = ["rustls-tls-webpki-roots"] } diff --git a/config.toml.example b/config.toml.example index 6b377e5f..c33ac249 100644 --- a/config.toml.example +++ b/config.toml.example @@ -1,8 +1,16 @@ +# Enable one or more adapters. At least one [discord] or [slack] section is required. + [discord] bot_token = "${DISCORD_BOT_TOKEN}" allowed_channels = ["1234567890"] # allowed_users = [""] # empty or omitted = allow all users +# [slack] +# bot_token = "${SLACK_BOT_TOKEN}" # Bot User OAuth Token (xoxb-...) +# app_token = "${SLACK_APP_TOKEN}" # App-Level Token (xapp-...) for Socket Mode +# allowed_channels = ["C0123456789"] # Slack channel IDs +# allowed_users = ["U0123456789"] # Slack user IDs (optional) + [agent] command = "kiro-cli" args = ["acp", "--trust-all-tools"] diff --git a/src/adapter.rs b/src/adapter.rs new file mode 100644 index 00000000..1f97a388 --- /dev/null +++ b/src/adapter.rs @@ -0,0 +1,400 @@ +use anyhow::Result; +use async_trait::async_trait; +use serde::Serialize; +use std::sync::Arc; +use tokio::sync::watch; +use tracing::error; + +use crate::acp::{classify_notification, AcpEvent, ContentBlock, SessionPool}; +use crate::config::ReactionsConfig; +use crate::error_display::{format_coded_error, format_user_error}; +use crate::format; +use crate::reactions::StatusReactionController; + +// --- Platform-agnostic types --- + +/// Identifies a channel or thread across platforms. +#[derive(Clone, Debug, Hash, Eq, PartialEq)] +pub struct ChannelRef { + pub platform: String, + pub channel_id: String, + /// Thread within a channel (e.g. Slack thread_ts, Telegram topic_id). + /// For Discord, threads are separate channels so this is None. + pub thread_id: Option, + /// Parent channel if this is a thread-as-channel (Discord). + pub parent_id: Option, +} + +impl ChannelRef { + pub fn is_thread(&self) -> bool { + self.thread_id.is_some() || self.parent_id.is_some() + } +} + +/// Identifies a message across platforms. +#[derive(Clone, Debug)] +pub struct MessageRef { + pub channel: ChannelRef, + pub message_id: String, +} + +/// Sender identity injected into prompts for downstream agent context. +#[derive(Clone, Debug, Serialize)] +pub struct SenderContext { + pub schema: String, + pub sender_id: String, + pub sender_name: String, + pub display_name: String, + pub channel: String, + pub channel_id: String, + pub is_bot: bool, +} + +// --- ChatAdapter trait --- + +#[async_trait] +pub trait ChatAdapter: Send + Sync + 'static { + /// Platform name for logging and session key namespacing. + fn platform(&self) -> &'static str; + + /// Maximum message length for this platform (e.g. 2000 for Discord, 4000 for Slack). + fn message_limit(&self) -> usize; + + /// Send a new message, returns a reference to the sent message. + async fn send_message(&self, channel: &ChannelRef, content: &str) -> Result; + + /// Edit an existing message in-place. + async fn edit_message(&self, msg: &MessageRef, content: &str) -> Result<()>; + + /// Create a thread from a trigger message, returns the thread channel ref. + async fn create_thread( + &self, + channel: &ChannelRef, + trigger_msg: &MessageRef, + title: &str, + ) -> Result; + + /// Add a reaction/emoji to a message. + async fn add_reaction(&self, msg: &MessageRef, emoji: &str) -> Result<()>; + + /// Remove a reaction/emoji from a message. + async fn remove_reaction(&self, msg: &MessageRef, emoji: &str) -> Result<()>; +} + +// --- AdapterRouter --- + +/// Shared logic for routing messages to ACP agents, managing sessions, +/// streaming edits, and controlling reactions. Platform-independent. +pub struct AdapterRouter { + pool: Arc, + reactions_config: ReactionsConfig, +} + +impl AdapterRouter { + pub fn new(pool: Arc, reactions_config: ReactionsConfig) -> Self { + Self { + pool, + reactions_config, + } + } + + pub fn pool(&self) -> &SessionPool { + &self.pool + } + + /// Handle an incoming user message. The adapter is responsible for + /// filtering, resolving the thread, and building the SenderContext. + /// This method handles session management and streaming. + pub async fn handle_message( + &self, + adapter: &Arc, + thread_channel: &ChannelRef, + _sender: &SenderContext, + content_blocks: Vec, + trigger_msg: &MessageRef, + ) -> Result<()> { + tracing::debug!(platform = adapter.platform(), "processing message"); + + let thinking_msg = adapter.send_message(thread_channel, "...").await?; + + let thread_key = format!( + "{}:{}", + adapter.platform(), + thread_channel + .thread_id + .as_deref() + .unwrap_or(&thread_channel.channel_id) + ); + + if let Err(e) = self.pool.get_or_create(&thread_key).await { + let msg = format_user_error(&e.to_string()); + let _ = adapter + .edit_message(&thinking_msg, &format!("⚠️ {msg}")) + .await; + error!("pool error: {e}"); + return Err(e); + } + + let reactions = Arc::new(StatusReactionController::new( + self.reactions_config.enabled, + adapter.clone(), + trigger_msg.clone(), + self.reactions_config.emojis.clone(), + self.reactions_config.timing.clone(), + )); + reactions.set_queued().await; + + let result = self + .stream_prompt( + adapter, + &thread_key, + content_blocks, + thread_channel, + &thinking_msg, + reactions.clone(), + ) + .await; + + match &result { + Ok(()) => reactions.set_done().await, + Err(_) => reactions.set_error().await, + } + + let hold_ms = if result.is_ok() { + self.reactions_config.timing.done_hold_ms + } else { + self.reactions_config.timing.error_hold_ms + }; + if self.reactions_config.remove_after_reply { + let reactions = reactions; + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(hold_ms)).await; + reactions.clear().await; + }); + } + + if let Err(ref e) = result { + let _ = adapter + .edit_message(&thinking_msg, &format!("⚠️ {e}")) + .await; + } + + result + } + + async fn stream_prompt( + &self, + adapter: &Arc, + thread_key: &str, + content_blocks: Vec, + thread_channel: &ChannelRef, + thinking_msg: &MessageRef, + reactions: Arc, + ) -> Result<()> { + let adapter = adapter.clone(); + let thread_channel = thread_channel.clone(); + let msg_ref = thinking_msg.clone(); + let message_limit = adapter.message_limit(); + + self.pool + .with_connection(thread_key, |conn| { + let content_blocks = content_blocks.clone(); + Box::pin(async move { + let reset = conn.session_reset; + conn.session_reset = false; + + let (mut rx, _) = conn.session_prompt(content_blocks).await?; + reactions.set_thinking().await; + + let initial = if reset { + "⚠️ _Session expired, starting fresh..._\n\n...".to_string() + } else { + "...".to_string() + }; + let (buf_tx, buf_rx) = watch::channel(initial); + + let mut text_buf = String::new(); + let mut tool_lines: Vec = Vec::new(); + + if reset { + text_buf.push_str("⚠️ _Session expired, starting fresh..._\n\n"); + } + + // Spawn edit-streaming task — only edits the single message, never sends new ones. + // Long content is truncated during streaming; final multi-message split happens after. + let streaming_limit = message_limit.saturating_sub(100); + let edit_handle = { + let adapter = adapter.clone(); + let msg_ref = msg_ref.clone(); + let mut buf_rx = buf_rx.clone(); + tokio::spawn(async move { + let mut last_content = String::new(); + loop { + tokio::time::sleep(std::time::Duration::from_millis(1500)).await; + if buf_rx.has_changed().unwrap_or(false) { + let content = buf_rx.borrow_and_update().clone(); + if content != last_content { + let display = if content.chars().count() > streaming_limit { + let truncated = format::truncate_chars(&content, streaming_limit); + format!("{truncated}…") + } else { + content.clone() + }; + let _ = adapter.edit_message(&msg_ref, &display).await; + last_content = content; + } + } + if buf_rx.has_changed().is_err() { + break; + } + } + }) + }; + + // Process ACP notifications + let mut got_first_text = false; + let mut response_error: Option = None; + while let Some(notification) = rx.recv().await { + if notification.id.is_some() { + if let Some(ref err) = notification.error { + response_error = Some(format_coded_error(err.code, &err.message)); + } + break; + } + + if let Some(event) = classify_notification(¬ification) { + match event { + AcpEvent::Text(t) => { + if !got_first_text { + got_first_text = true; + } + text_buf.push_str(&t); + let _ = + buf_tx.send(compose_display(&tool_lines, &text_buf)); + } + AcpEvent::Thinking => { + reactions.set_thinking().await; + } + AcpEvent::ToolStart { id, title } if !title.is_empty() => { + reactions.set_tool(&title).await; + let title = sanitize_title(&title); + if let Some(slot) = tool_lines.iter_mut().find(|e| e.id == id) { + slot.title = title; + slot.state = ToolState::Running; + } else { + tool_lines.push(ToolEntry { + id, + title, + state: ToolState::Running, + }); + } + let _ = + buf_tx.send(compose_display(&tool_lines, &text_buf)); + } + AcpEvent::ToolDone { id, title, status } => { + reactions.set_thinking().await; + let new_state = if status == "completed" { + ToolState::Completed + } else { + ToolState::Failed + }; + if let Some(slot) = tool_lines.iter_mut().find(|e| e.id == id) { + if !title.is_empty() { + slot.title = sanitize_title(&title); + } + slot.state = new_state; + } else if !title.is_empty() { + tool_lines.push(ToolEntry { + id, + title: sanitize_title(&title), + state: new_state, + }); + } + let _ = + buf_tx.send(compose_display(&tool_lines, &text_buf)); + } + _ => {} + } + } + } + + conn.prompt_done().await; + drop(buf_tx); + let _ = edit_handle.await; + + // Final edit with complete content + let final_content = compose_display(&tool_lines, &text_buf); + let final_content = if final_content.is_empty() { + if let Some(err) = response_error { + format!("⚠️ {err}") + } else { + "_(no response)_".to_string() + } + } else if let Some(err) = response_error { + format!("⚠️ {err}\n\n{final_content}") + } else { + final_content + }; + + let chunks = format::split_message(&final_content, message_limit); + let mut current_msg = msg_ref; + for (i, chunk) in chunks.iter().enumerate() { + if i == 0 { + let _ = adapter.edit_message(¤t_msg, chunk).await; + } else if let Ok(new_msg) = + adapter.send_message(&thread_channel, chunk).await + { + current_msg = new_msg; + } + } + + Ok(()) + }) + }) + .await + } +} + +/// Flatten a tool-call title into a single line safe for inline-code spans. +fn sanitize_title(title: &str) -> String { + title.replace('\r', "").replace('\n', " ; ").replace('`', "'") +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ToolState { + Running, + Completed, + Failed, +} + +#[derive(Debug, Clone)] +struct ToolEntry { + id: String, + title: String, + state: ToolState, +} + +impl ToolEntry { + fn render(&self) -> String { + let icon = match self.state { + ToolState::Running => "🔧", + ToolState::Completed => "✅", + ToolState::Failed => "❌", + }; + let suffix = if self.state == ToolState::Running { "..." } else { "" }; + format!("{icon} `{}`{}", self.title, suffix) + } +} + +fn compose_display(tool_lines: &[ToolEntry], text: &str) -> String { + let mut out = String::new(); + if !tool_lines.is_empty() { + for entry in tool_lines { + out.push_str(&entry.render()); + out.push('\n'); + } + out.push('\n'); + } + out.push_str(text.trim_end()); + out +} diff --git a/src/config.rs b/src/config.rs index 9855e3a4..2649a0fe 100644 --- a/src/config.rs +++ b/src/config.rs @@ -5,7 +5,8 @@ use std::path::Path; #[derive(Debug, Deserialize)] pub struct Config { - pub discord: DiscordConfig, + pub discord: Option, + pub slack: Option, pub agent: AgentConfig, #[serde(default)] pub pool: PoolConfig, @@ -50,6 +51,16 @@ pub struct DiscordConfig { pub allowed_users: Vec, } +#[derive(Debug, Deserialize)] +pub struct SlackConfig { + pub bot_token: String, + pub app_token: String, + #[serde(default)] + pub allowed_channels: Vec, + #[serde(default)] + pub allowed_users: Vec, +} + #[derive(Debug, Deserialize)] pub struct AgentConfig { pub command: String, diff --git a/src/discord.rs b/src/discord.rs index e267064e..0b96bd7f 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -1,25 +1,23 @@ -use crate::acp::{classify_notification, AcpEvent, ContentBlock, SessionPool}; -use crate::config::{ReactionsConfig, SttConfig}; -use crate::error_display::{format_coded_error, format_user_error}; -use crate::format; -use crate::reactions::StatusReactionController; +use crate::acp::ContentBlock; +use crate::adapter::{AdapterRouter, ChatAdapter, ChannelRef, MessageRef, SenderContext}; +use crate::config::SttConfig; +use async_trait::async_trait; use base64::engine::general_purpose::STANDARD as BASE64; use base64::Engine; use image::ImageReader; use std::io::Cursor; use std::sync::LazyLock; -use serenity::async_trait; -use serenity::model::channel::{Message, ReactionType}; +use serenity::builder::{CreateThread, EditMessage}; +use serenity::http::Http; +use serenity::model::channel::{AutoArchiveDuration, Message, ReactionType}; use serenity::model::gateway::Ready; use serenity::model::id::{ChannelId, MessageId}; use serenity::prelude::*; use std::collections::HashSet; use std::sync::Arc; -use tokio::sync::watch; use tracing::{debug, error, info}; /// Reusable HTTP client for downloading Discord attachments. -/// Built once with a 30s timeout and rustls TLS (no native-tls deps). static HTTP_CLIENT: LazyLock = LazyLock::new(|| { reqwest::Client::builder() .timeout(std::time::Duration::from_secs(30)) @@ -27,21 +25,117 @@ static HTTP_CLIENT: LazyLock = LazyLock::new(|| { .expect("static HTTP client must build") }); +// --- DiscordAdapter: implements ChatAdapter for Discord via serenity --- + +pub struct DiscordAdapter { + http: Arc, +} + +impl DiscordAdapter { + pub fn new(http: Arc) -> Self { + Self { http } + } +} + +#[async_trait] +impl ChatAdapter for DiscordAdapter { + fn platform(&self) -> &'static str { + "discord" + } + + fn message_limit(&self) -> usize { + 2000 + } + + async fn send_message(&self, channel: &ChannelRef, content: &str) -> anyhow::Result { + let ch_id: u64 = channel.channel_id.parse()?; + let msg = ChannelId::new(ch_id).say(&self.http, content).await?; + Ok(MessageRef { + channel: channel.clone(), + message_id: msg.id.to_string(), + }) + } + + async fn edit_message(&self, msg: &MessageRef, content: &str) -> anyhow::Result<()> { + let ch_id: u64 = msg.channel.channel_id.parse()?; + let msg_id: u64 = msg.message_id.parse()?; + ChannelId::new(ch_id) + .edit_message( + &self.http, + MessageId::new(msg_id), + EditMessage::new().content(content), + ) + .await?; + Ok(()) + } + + async fn create_thread( + &self, + channel: &ChannelRef, + trigger_msg: &MessageRef, + title: &str, + ) -> anyhow::Result { + let ch_id: u64 = channel.channel_id.parse()?; + let msg_id: u64 = trigger_msg.message_id.parse()?; + let thread = ChannelId::new(ch_id) + .create_thread_from_message( + &self.http, + MessageId::new(msg_id), + CreateThread::new(title).auto_archive_duration(AutoArchiveDuration::OneDay), + ) + .await?; + Ok(ChannelRef { + platform: "discord".into(), + channel_id: thread.id.to_string(), + thread_id: None, + parent_id: Some(channel.channel_id.clone()), + }) + } + + async fn add_reaction(&self, msg: &MessageRef, emoji: &str) -> anyhow::Result<()> { + let ch_id: u64 = msg.channel.channel_id.parse()?; + let msg_id: u64 = msg.message_id.parse()?; + self.http + .create_reaction( + ChannelId::new(ch_id), + MessageId::new(msg_id), + &ReactionType::Unicode(emoji.to_string()), + ) + .await?; + Ok(()) + } + + async fn remove_reaction(&self, msg: &MessageRef, emoji: &str) -> anyhow::Result<()> { + let ch_id: u64 = msg.channel.channel_id.parse()?; + let msg_id: u64 = msg.message_id.parse()?; + self.http + .delete_reaction_me( + ChannelId::new(ch_id), + MessageId::new(msg_id), + &ReactionType::Unicode(emoji.to_string()), + ) + .await?; + Ok(()) + } +} + +// --- Handler: serenity EventHandler that delegates to AdapterRouter --- + pub struct Handler { - pub pool: Arc, + pub router: Arc, pub allowed_channels: HashSet, pub allowed_users: HashSet, - pub reactions_config: ReactionsConfig, pub stt_config: SttConfig, } -#[async_trait] +#[serenity::async_trait] impl EventHandler for Handler { async fn message(&self, ctx: Context, msg: Message) { if msg.author.bot { return; } + let adapter: Arc = Arc::new(DiscordAdapter::new(ctx.http.clone())); let bot_id = ctx.cache.current_user().id; let channel_id = msg.channel_id.get(); @@ -50,7 +144,10 @@ impl EventHandler for Handler { let is_mentioned = msg.mentions_user_id(bot_id) || msg.content.contains(&format!("<@{}>", bot_id)) - || msg.mention_roles.iter().any(|r| msg.content.contains(&format!("<@&{}>", r))); + || msg + .mention_roles + .iter() + .any(|r| msg.content.contains(&format!("<@&{}>", r))); let in_thread = if !in_allowed_channel { match msg.channel_id.to_channel(&ctx.http).await { @@ -83,9 +180,8 @@ impl EventHandler for Handler { if !self.allowed_users.is_empty() && !self.allowed_users.contains(&msg.author.id.get()) { tracing::info!(user_id = %msg.author.id, "denied user, ignoring"); - if let Err(e) = msg.react(&ctx.http, ReactionType::Unicode("🚫".into())).await { - tracing::warn!(error = %e, "failed to react with 🚫"); - } + let msg_ref = discord_msg_ref(&msg); + let _ = adapter.add_reaction(&msg_ref, "🚫").await; return; } @@ -95,75 +191,73 @@ impl EventHandler for Handler { msg.content.trim().to_string() }; - // No text and no image attachments → skip to avoid wasting session slots + // No text and no attachments → skip if prompt.is_empty() && msg.attachments.is_empty() { return; } - // Build content blocks: text + image attachments - let mut content_blocks = vec![]; - - // Inject structured sender context so the downstream CLI can identify who sent the message - let display_name = msg.member.as_ref() + // Build content blocks: text + image/audio attachments + let display_name = msg + .member + .as_ref() .and_then(|m| m.nick.as_ref()) .unwrap_or(&msg.author.name); - let sender_ctx = serde_json::json!({ - "schema": "openab.sender.v1", - "sender_id": msg.author.id.to_string(), - "sender_name": msg.author.name, - "display_name": display_name, - "channel": "discord", - "channel_id": msg.channel_id.to_string(), - "is_bot": msg.author.bot, - }); + let sender = SenderContext { + schema: "openab.sender.v1".into(), + sender_id: msg.author.id.to_string(), + sender_name: msg.author.name.clone(), + display_name: display_name.to_string(), + channel: "discord".into(), + channel_id: msg.channel_id.to_string(), + is_bot: msg.author.bot, + }; + + let sender_json = serde_json::to_string(&sender).unwrap(); let prompt_with_sender = format!( "\n{}\n\n\n{}", - serde_json::to_string(&sender_ctx).unwrap(), - prompt + sender_json, prompt ); - // Add text block (always, even if empty, we still send for sender context) - content_blocks.push(ContentBlock::Text { - text: prompt_with_sender.clone(), - }); + let mut content_blocks = vec![ContentBlock::Text { + text: prompt_with_sender, + }]; // Process attachments: route by content type (audio → STT, image → encode) - if !msg.attachments.is_empty() { - for attachment in &msg.attachments { - if is_audio_attachment(attachment) { - if self.stt_config.enabled { - if let Some(transcript) = download_and_transcribe(attachment, &self.stt_config).await { - debug!(filename = %attachment.filename, chars = transcript.len(), "voice transcript injected"); - content_blocks.insert(0, ContentBlock::Text { - text: format!("[Voice message transcript]: {transcript}"), - }); - } - } else { - debug!(filename = %attachment.filename, "skipping audio attachment (STT disabled)"); + for attachment in &msg.attachments { + if is_audio_attachment(attachment) { + if self.stt_config.enabled { + if let Some(transcript) = download_and_transcribe(attachment, &self.stt_config).await { + debug!(filename = %attachment.filename, chars = transcript.len(), "voice transcript injected"); + content_blocks.insert(0, ContentBlock::Text { + text: format!("[Voice message transcript]: {transcript}"), + }); } - } else if let Some(content_block) = download_and_encode_image(attachment).await { - debug!(url = %attachment.url, filename = %attachment.filename, "adding image attachment"); - content_blocks.push(content_block); + } else { + debug!(filename = %attachment.filename, "skipping audio attachment (STT disabled)"); } + } else if let Some(content_block) = download_and_encode_image(attachment).await { + debug!(url = %attachment.url, filename = %attachment.filename, "adding image attachment"); + content_blocks.push(content_block); } } tracing::debug!( - text_len = prompt_with_sender.len(), + num_blocks = content_blocks.len(), num_attachments = msg.attachments.len(), in_thread, "processing" ); - // Note: image-only messages (no text) are intentionally allowed since - // prompt_with_sender always includes the non-empty sender_context XML. - // The guard above (prompt.is_empty() && no attachments) handles stickers/embeds. - - let thread_id = if in_thread { - msg.channel_id.get() + let thread_channel = if in_thread { + ChannelRef { + platform: "discord".into(), + channel_id: msg.channel_id.get().to_string(), + thread_id: None, + parent_id: None, + } } else { - match get_or_create_thread(&ctx, &msg, &prompt).await { - Ok(id) => id, + match get_or_create_thread(&ctx, &adapter, &msg, &prompt).await { + Ok(ch) => ch, Err(e) => { error!("failed to create thread: {e}"); return; @@ -171,68 +265,14 @@ impl EventHandler for Handler { } }; - let thread_channel = ChannelId::new(thread_id); - - let thinking_msg = match thread_channel.say(&ctx.http, "...").await { - Ok(m) => m, - Err(e) => { - error!("failed to post: {e}"); - return; - } - }; + let trigger_msg = discord_msg_ref(&msg); - let thread_key = thread_id.to_string(); - if let Err(e) = self.pool.get_or_create(&thread_key).await { - let msg = format_user_error(&e.to_string()); - let _ = edit(&ctx, thread_channel, thinking_msg.id, &format!("⚠️ {}", msg)).await; - error!("pool error: {e}"); - return; - } - - // Create reaction controller on the user's original message - let reactions = Arc::new(StatusReactionController::new( - self.reactions_config.enabled, - ctx.http.clone(), - msg.channel_id, - msg.id, - self.reactions_config.emojis.clone(), - self.reactions_config.timing.clone(), - )); - reactions.set_queued().await; - - // Stream prompt with live edits (pass content blocks instead of just text) - let result = stream_prompt( - &self.pool, - &thread_key, - content_blocks, - &ctx, - thread_channel, - thinking_msg.id, - reactions.clone(), - ) - .await; - - match &result { - Ok(()) => reactions.set_done().await, - Err(_) => reactions.set_error().await, - } - - // Hold emoji briefly then clear - let hold_ms = if result.is_ok() { - self.reactions_config.timing.done_hold_ms - } else { - self.reactions_config.timing.error_hold_ms - }; - if self.reactions_config.remove_after_reply { - let reactions = reactions; - tokio::spawn(async move { - tokio::time::sleep(std::time::Duration::from_millis(hold_ms)).await; - reactions.clear().await; - }); - } - - if let Err(e) = result { - let _ = edit(&ctx, thread_channel, thinking_msg.id, &format!("⚠️ {e}")).await; + if let Err(e) = self + .router + .handle_message(&adapter, &thread_channel, &sender, content_blocks, &trigger_msg) + .await + { + error!("handle_message error: {e}"); } } @@ -241,6 +281,20 @@ impl EventHandler for Handler { } } +// --- Discord-specific helpers --- + +fn discord_msg_ref(msg: &Message) -> MessageRef { + MessageRef { + channel: ChannelRef { + platform: "discord".into(), + channel_id: msg.channel_id.get().to_string(), + thread_id: None, + parent_id: None, + }, + message_id: msg.id.to_string(), + } +} + /// Check if an attachment is an audio file (voice messages are typically audio/ogg). fn is_audio_attachment(attachment: &serenity::model::channel::Attachment) -> bool { let mime = attachment.content_type.as_deref().unwrap_or(""); @@ -273,19 +327,13 @@ async fn download_and_transcribe( } /// Maximum dimension (width or height) for resized images. -/// Matches OpenClaw's DEFAULT_IMAGE_MAX_DIMENSION_PX. const IMAGE_MAX_DIMENSION_PX: u32 = 1200; -/// JPEG quality for compressed output (OpenClaw uses progressive 85→35; -/// we start at 75 which is a good balance of quality vs size). +/// JPEG quality for compressed output. const IMAGE_JPEG_QUALITY: u8 = 75; /// Download a Discord image attachment, resize/compress it, then base64-encode /// as an ACP image content block. -/// -/// Large images are resized so the longest side is at most 1200px and -/// re-encoded as JPEG at quality 75. This keeps the base64 payload well -/// under typical JSON-RPC transport limits (~200-400KB after encoding). async fn download_and_encode_image(attachment: &serenity::model::channel::Attachment) -> Option { const MAX_SIZE: u64 = 10 * 1024 * 1024; // 10 MB @@ -294,7 +342,6 @@ async fn download_and_encode_image(attachment: &serenity::model::channel::Attach return None; } - // Determine media type — prefer content-type header, fallback to extension let media_type = attachment .content_type .as_deref() @@ -340,17 +387,14 @@ async fn download_and_encode_image(attachment: &serenity::model::channel::Attach Err(e) => { error!(url = %url, error = %e, "read failed"); return None; } }; - // Defense-in-depth: verify actual download size if bytes.len() as u64 > MAX_SIZE { error!(filename = %attachment.filename, size = bytes.len(), "downloaded image exceeds limit"); return None; } - // Resize and compress let (output_bytes, output_mime) = match resize_and_compress(&bytes) { Ok(result) => result, Err(e) => { - // Fallback: use original bytes but reject if too large for transport if bytes.len() > 1024 * 1024 { error!(filename = %attachment.filename, error = %e, size = bytes.len(), "resize failed and original too large, skipping"); return None; @@ -374,16 +418,14 @@ async fn download_and_encode_image(attachment: &serenity::model::channel::Attach }) } -/// Resize image so longest side ≤ IMAGE_MAX_DIMENSION_PX, then encode as JPEG. -/// Returns (compressed_bytes, mime_type). GIFs are passed through unchanged -/// to preserve animation. +/// Resize image so longest side <= IMAGE_MAX_DIMENSION_PX, then encode as JPEG. +/// GIFs are passed through unchanged to preserve animation. fn resize_and_compress(raw: &[u8]) -> Result<(Vec, String), image::ImageError> { let reader = ImageReader::new(Cursor::new(raw)) .with_guessed_format()?; let format = reader.format(); - // Pass through GIFs unchanged to preserve animation if format == Some(image::ImageFormat::Gif) { return Ok((raw.to_vec(), "image/gif".to_string())); } @@ -391,7 +433,6 @@ fn resize_and_compress(raw: &[u8]) -> Result<(Vec, String), image::ImageErro let img = reader.decode()?; let (w, h) = (img.width(), img.height()); - // Resize preserving aspect ratio: scale so longest side = 1200px let img = if w > IMAGE_MAX_DIMENSION_PX || h > IMAGE_MAX_DIMENSION_PX { let max_side = std::cmp::max(w, h); let ratio = f64::from(IMAGE_MAX_DIMENSION_PX) / f64::from(max_side); @@ -402,7 +443,6 @@ fn resize_and_compress(raw: &[u8]) -> Result<(Vec, String), image::ImageErro img }; - // Encode as JPEG let mut buf = Cursor::new(Vec::new()); let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, IMAGE_JPEG_QUALITY); img.write_with_encoder(encoder)?; @@ -410,247 +450,33 @@ fn resize_and_compress(raw: &[u8]) -> Result<(Vec, String), image::ImageErro Ok((buf.into_inner(), "image/jpeg".to_string())) } -async fn edit(ctx: &Context, ch: ChannelId, msg_id: MessageId, content: &str) -> serenity::Result { - ch.edit_message(&ctx.http, msg_id, serenity::builder::EditMessage::new().content(content)).await -} - -async fn stream_prompt( - pool: &SessionPool, - thread_key: &str, - content_blocks: Vec, +async fn get_or_create_thread( ctx: &Context, - channel: ChannelId, - msg_id: MessageId, - reactions: Arc, -) -> anyhow::Result<()> { - let reactions = reactions.clone(); - - pool.with_connection(thread_key, |conn| { - let content_blocks = content_blocks.clone(); - let ctx = ctx.clone(); - let reactions = reactions.clone(); - Box::pin(async move { - let reset = conn.session_reset; - conn.session_reset = false; - - let (mut rx, _): (_, _) = conn.session_prompt(content_blocks).await?; - reactions.set_thinking().await; - - let initial = if reset { - "⚠️ _Session expired, starting fresh..._\n\n...".to_string() - } else { - "...".to_string() - }; - let (buf_tx, buf_rx) = watch::channel(initial); - - let mut text_buf = String::new(); - // Tool calls indexed by toolCallId. Vec preserves first-seen - // order. We store id + title + state separately so a ToolDone - // event that arrives without a refreshed title (claude-agent-acp's - // update events don't always re-send the title field) can still - // reuse the title we already learned from a prior - // tool_call_update — only the icon flips 🔧 → ✅ / ❌. Rendering - // happens on the fly in compose_display(). - let mut tool_lines: Vec = Vec::new(); - let current_msg_id = msg_id; - - if reset { - text_buf.push_str("⚠️ _Session expired, starting fresh..._\n\n"); - } - - // Spawn edit-streaming task — only edits the single message, never sends new ones. - // Long content is truncated during streaming; final multi-message split happens after. - let edit_handle = { - let ctx = ctx.clone(); - let mut buf_rx = buf_rx.clone(); - tokio::spawn(async move { - let mut last_content = String::new(); - loop { - tokio::time::sleep(std::time::Duration::from_millis(1500)).await; - if buf_rx.has_changed().unwrap_or(false) { - let content = buf_rx.borrow_and_update().clone(); - if content != last_content { - let display = if content.chars().count() > 1900 { - let truncated = format::truncate_chars(&content, 1900); - format!("{truncated}…") - } else { - content.clone() - }; - let _ = edit(&ctx, channel, msg_id, &display).await; - last_content = content; - } - } - if buf_rx.has_changed().is_err() { - break; - } - } - }) - }; - - // Process ACP notifications - let mut got_first_text = false; - let mut response_error: Option = None; - while let Some(notification) = rx.recv().await { - if notification.id.is_some() { - // Capture error from ACP response to display in Discord - if let Some(ref err) = notification.error { - response_error = Some(format_coded_error(err.code, &err.message)); - } - break; - } - - if let Some(event) = classify_notification(¬ification) { - match event { - AcpEvent::Text(t) => { - if !got_first_text { - got_first_text = true; - // Reaction: back to thinking after tools - } - text_buf.push_str(&t); - let _ = buf_tx.send(compose_display(&tool_lines, &text_buf)); - } - AcpEvent::Thinking => { - reactions.set_thinking().await; - } - AcpEvent::ToolStart { id, title } if !title.is_empty() => { - reactions.set_tool(&title).await; - let title = sanitize_title(&title); - // Dedupe by toolCallId: replace if we've already - // seen this id, otherwise append a new entry. - // claude-agent-acp emits a placeholder title - // ("Terminal", "Edit", etc.) on the first event - // and refines it via tool_call_update; without - // dedup the placeholder and refined version - // appear as two separate orphaned lines. - if let Some(slot) = tool_lines.iter_mut().find(|e| e.id == id) { - slot.title = title; - slot.state = ToolState::Running; - } else { - tool_lines.push(ToolEntry { - id, - title, - state: ToolState::Running, - }); - } - let _ = buf_tx.send(compose_display(&tool_lines, &text_buf)); - } - AcpEvent::ToolDone { id, title, status } => { - reactions.set_thinking().await; - let new_state = if status == "completed" { - ToolState::Completed - } else { - ToolState::Failed - }; - // Find by id (the title is unreliable — substring - // match against the placeholder "Terminal" would - // never find the refined entry). Preserve the - // existing title if the Done event omits it. - if let Some(slot) = tool_lines.iter_mut().find(|e| e.id == id) { - if !title.is_empty() { - slot.title = sanitize_title(&title); - } - slot.state = new_state; - } else if !title.is_empty() { - // Done arrived without a prior Start (rare - // race) — record it so we still show - // something. - tool_lines.push(ToolEntry { - id, - title: sanitize_title(&title), - state: new_state, - }); - } - let _ = buf_tx.send(compose_display(&tool_lines, &text_buf)); - } - _ => {} - } - } - } - - conn.prompt_done().await; - drop(buf_tx); - let _ = edit_handle.await; - - // Final edit - let final_content = compose_display(&tool_lines, &text_buf); - // If ACP returned both an error and partial text, show both. - // This can happen when the agent started producing content before hitting an error - // (e.g. context length limit, rate limit mid-stream). Showing both gives users - // full context rather than hiding the partial response. - let final_content = if final_content.is_empty() { - if let Some(err) = response_error { - format!("⚠️ {}", err) - } else { - "_(no response)_".to_string() - } - } else if let Some(err) = response_error { - format!("⚠️ {}\n\n{}", err, final_content) - } else { - final_content - }; - - let chunks = format::split_message(&final_content, 2000); - for (i, chunk) in chunks.iter().enumerate() { - if i == 0 { - let _ = edit(&ctx, channel, current_msg_id, chunk).await; - } else { - let _ = channel.say(&ctx.http, chunk).await; - } - } - - Ok(()) - }) - }) - .await -} - -/// Flatten a tool-call title into a single line that's safe to render -/// inside Discord inline-code spans. Discord renders single-backtick -/// code on a single line only, so multi-line shell commands (heredocs, -/// `&&`-chained commands split across lines) appear truncated; we -/// collapse newlines to ` ; ` and rewrite embedded backticks so they -/// don't break the wrapping span. -fn sanitize_title(title: &str) -> String { - title.replace('\r', "").replace('\n', " ; ").replace('`', "'") -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ToolState { - Running, - Completed, - Failed, -} - -#[derive(Debug, Clone)] -struct ToolEntry { - id: String, - title: String, - state: ToolState, -} - -impl ToolEntry { - fn render(&self) -> String { - let icon = match self.state { - ToolState::Running => "🔧", - ToolState::Completed => "✅", - ToolState::Failed => "❌", - }; - let suffix = if self.state == ToolState::Running { "..." } else { "" }; - format!("{icon} `{}`{}", self.title, suffix) - } -} - -fn compose_display(tool_lines: &[ToolEntry], text: &str) -> String { - let mut out = String::new(); - if !tool_lines.is_empty() { - for entry in tool_lines { - out.push_str(&entry.render()); - out.push('\n'); + adapter: &Arc, + msg: &Message, + prompt: &str, +) -> 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() { + return Ok(ChannelRef { + platform: "discord".into(), + channel_id: msg.channel_id.get().to_string(), + thread_id: None, + parent_id: None, + }); } - out.push('\n'); } - out.push_str(text.trim_end()); - out + + let thread_name = shorten_thread_name(prompt); + let parent = ChannelRef { + platform: "discord".into(), + channel_id: msg.channel_id.get().to_string(), + thread_id: None, + parent_id: None, + }; + let trigger_ref = discord_msg_ref(msg); + adapter.create_thread(&parent, &trigger_ref, &thread_name).await } static MENTION_RE: LazyLock = LazyLock::new(|| { @@ -662,7 +488,6 @@ fn strip_mention(content: &str) -> String { } fn shorten_thread_name(prompt: &str) -> String { - // Shorten GitHub URLs: https://github.com/owner/repo/issues/123 → owner/repo#123 let re = regex::Regex::new(r"https?://github\.com/([^/]+/[^/]+)/(issues|pull)/(\d+)").unwrap(); let shortened = re.replace_all(prompt, "$1#$3"); let name: String = shortened.chars().take(40).collect(); @@ -673,30 +498,6 @@ fn shorten_thread_name(prompt: &str) -> String { } } -async fn get_or_create_thread(ctx: &Context, msg: &Message, prompt: &str) -> 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() { - return Ok(msg.channel_id.get()); - } - } - - let thread_name = shorten_thread_name(prompt); - - 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), - ) - .await?; - - Ok(thread.id.get()) -} - - #[cfg(test)] mod tests { use super::*; @@ -760,13 +561,12 @@ mod tests { #[test] fn gif_passes_through_unchanged() { - // Minimal valid GIF89a (1x1 pixel) let gif: Vec = vec![ - 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, // GIF89a - 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, // logical screen descriptor - 0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, // image descriptor - 0x02, 0x02, 0x44, 0x01, 0x00, // image data - 0x3B, // trailer + 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, + 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, + 0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, + 0x02, 0x02, 0x44, 0x01, 0x00, + 0x3B, ]; let (output, mime) = resize_and_compress(&gif).unwrap(); diff --git a/src/main.rs b/src/main.rs index 225bf236..7194ad3d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,16 +1,19 @@ mod acp; +mod adapter; mod config; mod discord; mod error_display; mod format; mod reactions; +mod slack; mod stt; +use adapter::AdapterRouter; use serenity::prelude::*; use std::collections::HashSet; use std::path::PathBuf; use std::sync::Arc; -use tracing::info; +use tracing::{error, info}; #[tokio::main] async fn main() -> anyhow::Result<()> { @@ -30,20 +33,20 @@ async fn main() -> anyhow::Result<()> { info!( agent_cmd = %cfg.agent.command, pool_max = cfg.pool.max_sessions, - channels = ?cfg.discord.allowed_channels, - users = ?cfg.discord.allowed_users, + discord = cfg.discord.is_some(), + slack = cfg.slack.is_some(), reactions = cfg.reactions.enabled, "config loaded" ); + if cfg.discord.is_none() && cfg.slack.is_none() { + anyhow::bail!("no adapter configured — add [discord] and/or [slack] to config.toml"); + } + let pool = Arc::new(acp::SessionPool::new(cfg.agent, cfg.pool.max_sessions)); let ttl_secs = cfg.pool.session_ttl_hours * 3600; - let allowed_channels = parse_id_set(&cfg.discord.allowed_channels, "allowed_channels")?; - let allowed_users = parse_id_set(&cfg.discord.allowed_users, "allowed_users")?; - info!(channels = allowed_channels.len(), users = allowed_users.len(), "parsed allowlists"); - - // Resolve STT config before constructing handler (auto-detect mutates cfg.stt) + // Resolve STT config (auto-detect GROQ_API_KEY from env) if cfg.stt.enabled { if cfg.stt.api_key.is_empty() && cfg.stt.base_url.contains("groq.com") { if let Ok(key) = std::env::var("GROQ_API_KEY") { @@ -59,21 +62,7 @@ async fn main() -> anyhow::Result<()> { info!(model = %cfg.stt.model, base_url = %cfg.stt.base_url, "STT enabled"); } - let handler = discord::Handler { - pool: pool.clone(), - allowed_channels, - allowed_users, - reactions_config: cfg.reactions, - stt_config: cfg.stt.clone(), - }; - - let intents = GatewayIntents::GUILD_MESSAGES - | GatewayIntents::MESSAGE_CONTENT - | GatewayIntents::GUILDS; - - let mut client = Client::builder(&cfg.discord.bot_token, intents) - .event_handler(handler) - .await?; + let router = Arc::new(AdapterRouter::new(pool.clone(), cfg.reactions)); // Spawn cleanup task let cleanup_pool = pool.clone(); @@ -84,20 +73,80 @@ async fn main() -> anyhow::Result<()> { } }); - // Run bot until SIGINT/SIGTERM - let shard_manager = client.shard_manager.clone(); - let shutdown_pool = pool.clone(); - tokio::spawn(async move { + // Spawn Slack adapter (background task) + let slack_handle = if let Some(slack_cfg) = cfg.slack { + info!( + channels = slack_cfg.allowed_channels.len(), + users = slack_cfg.allowed_users.len(), + "starting slack adapter" + ); + let router = router.clone(); + Some(tokio::spawn(async move { + if let Err(e) = slack::run_slack_adapter( + slack_cfg.bot_token, + slack_cfg.app_token, + slack_cfg.allowed_channels.into_iter().collect(), + slack_cfg.allowed_users.into_iter().collect(), + router, + ) + .await + { + error!("slack adapter error: {e}"); + } + })) + } else { + None + }; + + // Run Discord adapter (foreground, blocking) or wait for ctrl_c + if let Some(discord_cfg) = cfg.discord { + let allowed_channels = + parse_id_set(&discord_cfg.allowed_channels, "discord.allowed_channels")?; + let allowed_users = parse_id_set(&discord_cfg.allowed_users, "discord.allowed_users")?; + info!( + channels = allowed_channels.len(), + users = allowed_users.len(), + "starting discord adapter" + ); + + let handler = discord::Handler { + router, + allowed_channels, + allowed_users, + stt_config: cfg.stt.clone(), + }; + + let intents = GatewayIntents::GUILD_MESSAGES + | GatewayIntents::MESSAGE_CONTENT + | GatewayIntents::GUILDS; + + let mut client = Client::builder(&discord_cfg.bot_token, intents) + .event_handler(handler) + .await?; + + // Graceful Discord shutdown on ctrl_c + let shard_manager = client.shard_manager.clone(); + tokio::spawn(async move { + tokio::signal::ctrl_c().await.ok(); + info!("shutdown signal received"); + shard_manager.shutdown_all().await; + }); + + info!("discord bot running"); + client.start().await?; + } else { + // No Discord — just wait for ctrl_c + info!("running without discord, press ctrl+c to stop"); tokio::signal::ctrl_c().await.ok(); info!("shutdown signal received"); - shard_manager.shutdown_all().await; - }); - - info!("starting discord bot"); - client.start().await?; + } // Cleanup cleanup_handle.abort(); + if let Some(handle) = slack_handle { + handle.abort(); + } + let shutdown_pool = pool; shutdown_pool.shutdown().await; info!("openab shut down"); Ok(()) @@ -115,7 +164,9 @@ fn parse_id_set(raw: &[String], label: &str) -> anyhow::Result> { }) .collect(); if !raw.is_empty() && set.is_empty() { - anyhow::bail!("all {label} entries failed to parse — refusing to start with an empty allowlist"); + anyhow::bail!( + "all {label} entries failed to parse — refusing to start with an empty allowlist" + ); } Ok(set) } diff --git a/src/reactions.rs b/src/reactions.rs index 683d334b..8638d86f 100644 --- a/src/reactions.rs +++ b/src/reactions.rs @@ -1,7 +1,5 @@ +use crate::adapter::{ChatAdapter, MessageRef}; use crate::config::{ReactionEmojis, ReactionTiming}; -use serenity::http::Http; -use serenity::model::channel::ReactionType; -use serenity::model::id::{ChannelId, MessageId}; use std::sync::Arc; use tokio::sync::Mutex; use tokio::time::Duration; @@ -21,9 +19,8 @@ fn classify_tool<'a>(name: &str, emojis: &'a ReactionEmojis) -> &'a str { } struct Inner { - http: Arc, - channel: ChannelId, - message: MessageId, + adapter: Arc, + message: MessageRef, emojis: ReactionEmojis, timing: ReactionTiming, current: String, @@ -41,16 +38,14 @@ pub struct StatusReactionController { impl StatusReactionController { pub fn new( enabled: bool, - http: Arc, - channel: ChannelId, - message: MessageId, + adapter: Arc, + message: MessageRef, emojis: ReactionEmojis, timing: ReactionTiming, ) -> Self { Self { inner: Arc::new(Mutex::new(Inner { - http, - channel, + adapter, message, emojis, timing, @@ -93,7 +88,7 @@ impl StatusReactionController { let faces = ["😊", "😎", "🫡", "🤓", "😏", "✌️", "💪", "🦾"]; let face = faces[rand::random::() % faces.len()]; let inner = self.inner.lock().await; - let _ = add_reaction(&inner.http, inner.channel, inner.message, face).await; + let _ = inner.adapter.add_reaction(&inner.message, face).await; } pub async fn set_error(&self) { @@ -108,7 +103,7 @@ impl StatusReactionController { cancel_timers(&mut inner); let current = inner.current.clone(); if !current.is_empty() { - let _ = remove_reaction(&inner.http, inner.channel, inner.message, ¤t).await; + let _ = inner.adapter.remove_reaction(&inner.message, ¤t).await; inner.current.clear(); } } @@ -121,15 +116,14 @@ impl StatusReactionController { cancel_debounce(&mut inner); let old = inner.current.clone(); inner.current = emoji.to_string(); - let http = inner.http.clone(); - let ch = inner.channel; - let msg = inner.message; + let adapter = inner.adapter.clone(); + let msg = inner.message.clone(); let new = emoji.to_string(); drop(inner); - let _ = add_reaction(&http, ch, msg, &new).await; + let _ = adapter.add_reaction(&msg, &new).await; if !old.is_empty() && old != new { - let _ = remove_reaction(&http, ch, msg, &old).await; + let _ = adapter.remove_reaction(&msg, &old).await; } self.reset_stall_timers().await; } @@ -151,14 +145,13 @@ impl StatusReactionController { if inner.finished { return; } let old = inner.current.clone(); inner.current = emoji.clone(); - let http = inner.http.clone(); - let ch = inner.channel; - let msg = inner.message; + let adapter = inner.adapter.clone(); + let msg = inner.message.clone(); drop(inner); - let _ = add_reaction(&http, ch, msg, &emoji).await; + let _ = adapter.add_reaction(&msg, &emoji).await; if !old.is_empty() && old != emoji { - let _ = remove_reaction(&http, ch, msg, &old).await; + let _ = adapter.remove_reaction(&msg, &old).await; } })); self.reset_stall_timers_inner(&mut inner); @@ -172,15 +165,14 @@ impl StatusReactionController { let old = inner.current.clone(); inner.current = emoji.to_string(); - let http = inner.http.clone(); - let ch = inner.channel; - let msg = inner.message; + let adapter = inner.adapter.clone(); + let msg = inner.message.clone(); let new = emoji.to_string(); drop(inner); - let _ = add_reaction(&http, ch, msg, &new).await; + let _ = adapter.add_reaction(&msg, &new).await; if !old.is_empty() && old != new { - let _ = remove_reaction(&http, ch, msg, &old).await; + let _ = adapter.remove_reaction(&msg, &old).await; } } @@ -205,13 +197,12 @@ impl StatusReactionController { if inner.finished { return; } let old = inner.current.clone(); inner.current = "🥱".to_string(); - let http = inner.http.clone(); - let ch = inner.channel; - let msg = inner.message; + let adapter = inner.adapter.clone(); + let msg = inner.message.clone(); drop(inner); - let _ = add_reaction(&http, ch, msg, "🥱").await; + let _ = adapter.add_reaction(&msg, "🥱").await; if !old.is_empty() && old != "🥱" { - let _ = remove_reaction(&http, ch, msg, &old).await; + let _ = adapter.remove_reaction(&msg, &old).await; } } })); @@ -222,13 +213,12 @@ impl StatusReactionController { if inner.finished { return; } let old = inner.current.clone(); inner.current = "😨".to_string(); - let http = inner.http.clone(); - let ch = inner.channel; - let msg = inner.message; + let adapter = inner.adapter.clone(); + let msg = inner.message.clone(); drop(inner); - let _ = add_reaction(&http, ch, msg, "😨").await; + let _ = adapter.add_reaction(&msg, "😨").await; if !old.is_empty() && old != "😨" { - let _ = remove_reaction(&http, ch, msg, &old).await; + let _ = adapter.remove_reaction(&msg, &old).await; } })); } @@ -243,13 +233,3 @@ fn cancel_timers(inner: &mut Inner) { if let Some(h) = inner.stall_soft_handle.take() { h.abort(); } if let Some(h) = inner.stall_hard_handle.take() { h.abort(); } } - -async fn add_reaction(http: &Http, ch: ChannelId, msg: MessageId, emoji: &str) -> serenity::Result<()> { - let reaction = ReactionType::Unicode(emoji.to_string()); - http.create_reaction(ch, msg, &reaction).await -} - -async fn remove_reaction(http: &Http, ch: ChannelId, msg: MessageId, emoji: &str) -> serenity::Result<()> { - let reaction = ReactionType::Unicode(emoji.to_string()); - http.delete_reaction_me(ch, msg, &reaction).await -} diff --git a/src/slack.rs b/src/slack.rs new file mode 100644 index 00000000..302ee354 --- /dev/null +++ b/src/slack.rs @@ -0,0 +1,374 @@ +use crate::acp::ContentBlock; +use crate::adapter::{AdapterRouter, ChatAdapter, ChannelRef, MessageRef, SenderContext}; +use anyhow::{anyhow, Result}; +use async_trait::async_trait; +use futures_util::{SinkExt, StreamExt}; +use std::collections::HashSet; +use std::sync::Arc; +use tokio_tungstenite::tungstenite; +use tracing::{error, info, warn}; + +const SLACK_API: &str = "https://slack.com/api"; + +/// Map Unicode emoji to Slack short names for reactions API. +fn unicode_to_slack_emoji(unicode: &str) -> &str { + match unicode { + "👀" => "eyes", + "🤔" => "thinking_face", + "🔥" => "fire", + "👨\u{200d}💻" => "technologist", + "⚡" => "zap", + "🆗" => "ok", + "😱" => "scream", + "🚫" => "no_entry_sign", + "😊" => "blush", + "😎" => "sunglasses", + "🫡" => "saluting_face", + "🤓" => "nerd_face", + "😏" => "smirk", + "✌\u{fe0f}" => "v", + "💪" => "muscle", + "🦾" => "mechanical_arm", + "🥱" => "yawning_face", + "😨" => "fearful", + "✅" => "white_check_mark", + "❌" => "x", + "🔧" => "wrench", + _ => "grey_question", + } +} + +// --- SlackAdapter: implements ChatAdapter for Slack --- + +pub struct SlackAdapter { + client: reqwest::Client, + bot_token: String, +} + +impl SlackAdapter { + pub fn new(bot_token: String) -> Self { + Self { + client: reqwest::Client::new(), + bot_token, + } + } + + async fn api_post(&self, method: &str, body: serde_json::Value) -> Result { + let resp = self + .client + .post(format!("{SLACK_API}/{method}")) + .header("Authorization", format!("Bearer {}", self.bot_token)) + .header("Content-Type", "application/json; charset=utf-8") + .json(&body) + .send() + .await?; + + let json: serde_json::Value = resp.json().await?; + if json["ok"].as_bool() != Some(true) { + let err = json["error"].as_str().unwrap_or("unknown error"); + return Err(anyhow!("Slack API {method}: {err}")); + } + Ok(json) + } +} + +#[async_trait] +impl ChatAdapter for SlackAdapter { + fn platform(&self) -> &'static str { + "slack" + } + + fn message_limit(&self) -> usize { + 4000 + } + + async fn send_message(&self, channel: &ChannelRef, content: &str) -> Result { + let mut body = serde_json::json!({ + "channel": channel.channel_id, + "text": content, + }); + if let Some(thread_ts) = &channel.thread_id { + body["thread_ts"] = serde_json::Value::String(thread_ts.clone()); + } + let resp = self.api_post("chat.postMessage", body).await?; + let ts = resp["ts"] + .as_str() + .ok_or_else(|| anyhow!("no ts in chat.postMessage response"))?; + Ok(MessageRef { + channel: ChannelRef { + platform: "slack".into(), + channel_id: channel.channel_id.clone(), + thread_id: channel.thread_id.clone(), + parent_id: None, + }, + message_id: ts.to_string(), + }) + } + + async fn edit_message(&self, msg: &MessageRef, content: &str) -> Result<()> { + self.api_post( + "chat.update", + serde_json::json!({ + "channel": msg.channel.channel_id, + "ts": msg.message_id, + "text": content, + }), + ) + .await?; + Ok(()) + } + + async fn create_thread( + &self, + channel: &ChannelRef, + trigger_msg: &MessageRef, + _title: &str, + ) -> Result { + // Slack threads are implicit — posting with thread_ts creates/continues a thread. + // The "thread" is the channel + the trigger message's ts as thread_ts. + Ok(ChannelRef { + platform: "slack".into(), + channel_id: channel.channel_id.clone(), + thread_id: Some(trigger_msg.message_id.clone()), + parent_id: None, + }) + } + + async fn add_reaction(&self, msg: &MessageRef, emoji: &str) -> Result<()> { + let name = unicode_to_slack_emoji(emoji); + self.api_post( + "reactions.add", + serde_json::json!({ + "channel": msg.channel.channel_id, + "timestamp": msg.message_id, + "name": name, + }), + ) + .await?; + Ok(()) + } + + async fn remove_reaction(&self, msg: &MessageRef, emoji: &str) -> Result<()> { + let name = unicode_to_slack_emoji(emoji); + self.api_post( + "reactions.remove", + serde_json::json!({ + "channel": msg.channel.channel_id, + "timestamp": msg.message_id, + "name": name, + }), + ) + .await?; + Ok(()) + } +} + +// --- Socket Mode event loop --- + +/// Run the Slack adapter using Socket Mode (persistent WebSocket, no public URL needed). +/// Reconnects automatically on disconnect. +pub async fn run_slack_adapter( + bot_token: String, + app_token: String, + allowed_channels: HashSet, + allowed_users: HashSet, + router: Arc, +) -> Result<()> { + let adapter = Arc::new(SlackAdapter::new(bot_token)); + + loop { + let ws_url = match get_socket_mode_url(&app_token).await { + Ok(url) => url, + Err(e) => { + error!("failed to get Socket Mode URL: {e}"); + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + continue; + } + }; + info!(url = %ws_url, "connecting to Slack Socket Mode"); + + match tokio_tungstenite::connect_async(&ws_url).await { + Ok((ws_stream, _)) => { + info!("Slack Socket Mode connected"); + let (mut write, mut read) = ws_stream.split(); + + while let Some(msg_result) = read.next().await { + match msg_result { + Ok(tungstenite::Message::Text(text)) => { + let envelope: serde_json::Value = + match serde_json::from_str(&text) { + Ok(v) => v, + Err(_) => continue, + }; + + // Acknowledge the envelope immediately + if let Some(envelope_id) = envelope["envelope_id"].as_str() { + let ack = serde_json::json!({"envelope_id": envelope_id}); + let _ = write + .send(tungstenite::Message::Text(ack.to_string())) + .await; + } + + // Route events + if envelope["type"].as_str() == Some("events_api") { + let event = &envelope["payload"]["event"]; + if event["type"].as_str() == Some("app_mention") { + handle_app_mention( + event, + &adapter, + &allowed_channels, + &allowed_users, + &router, + ) + .await; + } + } + } + Ok(tungstenite::Message::Ping(data)) => { + let _ = write.send(tungstenite::Message::Pong(data)).await; + } + Ok(tungstenite::Message::Close(_)) => { + warn!("Slack Socket Mode connection closed by server"); + break; + } + Err(e) => { + error!("Socket Mode read error: {e}"); + break; + } + _ => {} + } + } + } + Err(e) => { + error!("failed to connect to Slack Socket Mode: {e}"); + } + } + + warn!("reconnecting to Slack Socket Mode in 5s..."); + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + } +} + +/// Call apps.connections.open to get a WebSocket URL for Socket Mode. +async fn get_socket_mode_url(app_token: &str) -> Result { + let client = reqwest::Client::new(); + let resp = client + .post(format!("{SLACK_API}/apps.connections.open")) + .header("Authorization", format!("Bearer {app_token}")) + .header("Content-Type", "application/x-www-form-urlencoded") + .send() + .await?; + let json: serde_json::Value = resp.json().await?; + if json["ok"].as_bool() != Some(true) { + let err = json["error"].as_str().unwrap_or("unknown"); + return Err(anyhow!("apps.connections.open: {err}")); + } + json["url"] + .as_str() + .map(|s| s.to_string()) + .ok_or_else(|| anyhow!("no url in apps.connections.open response")) +} + +async fn handle_app_mention( + event: &serde_json::Value, + adapter: &Arc, + allowed_channels: &HashSet, + allowed_users: &HashSet, + router: &Arc, +) { + let channel_id = match event["channel"].as_str() { + Some(ch) => ch.to_string(), + None => return, + }; + let user_id = match event["user"].as_str() { + Some(u) => u.to_string(), + None => return, + }; + let text = match event["text"].as_str() { + Some(t) => t.to_string(), + None => return, + }; + let ts = match event["ts"].as_str() { + Some(ts) => ts.to_string(), + None => return, + }; + let thread_ts = event["thread_ts"].as_str().map(|s| s.to_string()); + + // Check allowed channels (empty = deny all, secure by default per #91) + if !allowed_channels.is_empty() && !allowed_channels.contains(&channel_id) { + return; + } + + // Check allowed users + if !allowed_users.is_empty() && !allowed_users.contains(&user_id) { + tracing::info!(user_id, "denied Slack user, ignoring"); + let msg_ref = MessageRef { + channel: ChannelRef { + platform: "slack".into(), + channel_id: channel_id.clone(), + thread_id: thread_ts.clone(), + parent_id: None, + }, + message_id: ts.clone(), + }; + let _ = adapter.add_reaction(&msg_ref, "🚫").await; + return; + } + + // Strip bot mention (<@UBOTID>) from text + let prompt = strip_slack_mention(&text); + if prompt.is_empty() { + return; + } + + let sender = SenderContext { + schema: "openab.sender.v1".into(), + sender_id: user_id.clone(), + sender_name: user_id.clone(), // app_mention events don't include display name + display_name: user_id.clone(), + channel: "slack".into(), + channel_id: channel_id.clone(), + is_bot: false, + }; + + let sender_json = serde_json::to_string(&sender).unwrap(); + let prompt_with_sender = format!( + "\n{}\n\n\n{}", + sender_json, prompt + ); + + let content_blocks = vec![ContentBlock::Text { + text: prompt_with_sender, + }]; + + let trigger_msg = MessageRef { + channel: ChannelRef { + platform: "slack".into(), + channel_id: channel_id.clone(), + thread_id: thread_ts.clone(), + parent_id: None, + }, + message_id: ts.clone(), + }; + + // Determine thread: if already in a thread, continue it; otherwise start a new thread + let thread_channel = ChannelRef { + platform: "slack".into(), + channel_id: channel_id.clone(), + thread_id: Some(thread_ts.unwrap_or(ts)), + parent_id: None, + }; + + let adapter_dyn: Arc = adapter.clone(); + if let Err(e) = router + .handle_message(&adapter_dyn, &thread_channel, &sender, content_blocks, &trigger_msg) + .await + { + error!("Slack handle_message error: {e}"); + } +} + +fn strip_slack_mention(text: &str) -> String { + let re = regex::Regex::new(r"<@[A-Z0-9]+>").unwrap(); + re.replace_all(text, "").trim().to_string() +} From bd61069e936c393f0fe3a7695cd73e069b003655 Mon Sep 17 00:00:00 2001 From: Tony Lee Date: Sun, 12 Apr 2026 14:25:09 +0800 Subject: [PATCH 02/16] refactor: extract shared media module, centralize sender context - Extract image resize/compress, download+encode, and STT download from discord.rs into shared media.rs module - Both Discord and Slack adapters now use media::download_and_encode_image() and media::download_and_transcribe() with optional auth_token parameter - Move sender context XML wrapping into AdapterRouter.handle_message() (was duplicated in each adapter) - Add image/audio attachment support to Slack adapter via files[] in app_mention events (requires files:read bot scope) - Slack file downloads use Bearer token auth (private URLs) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/adapter.rs | 31 +++++- src/discord.rs | 295 +++++-------------------------------------------- src/main.rs | 3 + src/media.rs | 271 +++++++++++++++++++++++++++++++++++++++++++++ src/slack.rs | 80 +++++++++++--- 5 files changed, 392 insertions(+), 288 deletions(-) create mode 100644 src/media.rs diff --git a/src/adapter.rs b/src/adapter.rs index 1f97a388..562abd5c 100644 --- a/src/adapter.rs +++ b/src/adapter.rs @@ -104,17 +104,42 @@ impl AdapterRouter { /// Handle an incoming user message. The adapter is responsible for /// filtering, resolving the thread, and building the SenderContext. - /// This method handles session management and streaming. + /// This method handles sender context injection, session management, and streaming. pub async fn handle_message( &self, adapter: &Arc, thread_channel: &ChannelRef, - _sender: &SenderContext, - content_blocks: Vec, + sender: &SenderContext, + prompt: &str, + extra_blocks: Vec, trigger_msg: &MessageRef, ) -> Result<()> { tracing::debug!(platform = adapter.platform(), "processing message"); + // Build content blocks: sender context + prompt text, then extra (images, transcripts) + let sender_json = serde_json::to_string(sender).unwrap(); + let prompt_with_sender = format!( + "\n{}\n\n\n{}", + sender_json, prompt + ); + + let mut content_blocks = Vec::with_capacity(1 + extra_blocks.len()); + // Prepend any transcript blocks (they go before the text block) + for block in &extra_blocks { + if matches!(block, ContentBlock::Text { .. }) { + content_blocks.push(block.clone()); + } + } + content_blocks.push(ContentBlock::Text { + text: prompt_with_sender, + }); + // Append non-text blocks (images) + for block in extra_blocks { + if !matches!(block, ContentBlock::Text { .. }) { + content_blocks.push(block); + } + } + let thinking_msg = adapter.send_message(thread_channel, "...").await?; let thread_key = format!( diff --git a/src/discord.rs b/src/discord.rs index 0b96bd7f..a3d0f88f 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -1,11 +1,8 @@ use crate::acp::ContentBlock; use crate::adapter::{AdapterRouter, ChatAdapter, ChannelRef, MessageRef, SenderContext}; use crate::config::SttConfig; +use crate::media; use async_trait::async_trait; -use base64::engine::general_purpose::STANDARD as BASE64; -use base64::Engine; -use image::ImageReader; -use std::io::Cursor; use std::sync::LazyLock; use serenity::builder::{CreateThread, EditMessage}; use serenity::http::Http; @@ -17,14 +14,6 @@ use std::collections::HashSet; use std::sync::Arc; use tracing::{debug, error, info}; -/// Reusable HTTP client for downloading Discord attachments. -static HTTP_CLIENT: LazyLock = LazyLock::new(|| { - reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .build() - .expect("static HTTP client must build") -}); - // --- DiscordAdapter: implements ChatAdapter for Discord via serenity --- pub struct DiscordAdapter { @@ -196,7 +185,6 @@ impl EventHandler for Handler { return; } - // Build content blocks: text + image/audio attachments let display_name = msg .member .as_ref() @@ -212,37 +200,44 @@ impl EventHandler for Handler { is_bot: msg.author.bot, }; - let sender_json = serde_json::to_string(&sender).unwrap(); - let prompt_with_sender = format!( - "\n{}\n\n\n{}", - sender_json, prompt - ); - - let mut content_blocks = vec![ContentBlock::Text { - text: prompt_with_sender, - }]; - - // Process attachments: route by content type (audio → STT, image → encode) + // Build extra content blocks from attachments (images, audio) + let mut extra_blocks = Vec::new(); for attachment in &msg.attachments { - if is_audio_attachment(attachment) { + let mime = attachment.content_type.as_deref().unwrap_or(""); + if media::is_audio_mime(mime) { if self.stt_config.enabled { - if let Some(transcript) = download_and_transcribe(attachment, &self.stt_config).await { + let mime_clean = mime.split(';').next().unwrap_or(mime).trim(); + if let Some(transcript) = media::download_and_transcribe( + &attachment.url, + &attachment.filename, + mime_clean, + u64::from(attachment.size), + &self.stt_config, + None, // Discord CDN is public + ).await { debug!(filename = %attachment.filename, chars = transcript.len(), "voice transcript injected"); - content_blocks.insert(0, ContentBlock::Text { + // Prepend transcript before the main text block + extra_blocks.insert(0, ContentBlock::Text { text: format!("[Voice message transcript]: {transcript}"), }); } } else { debug!(filename = %attachment.filename, "skipping audio attachment (STT disabled)"); } - } else if let Some(content_block) = download_and_encode_image(attachment).await { + } else if let Some(block) = media::download_and_encode_image( + &attachment.url, + attachment.content_type.as_deref(), + &attachment.filename, + u64::from(attachment.size), + None, // Discord CDN is public + ).await { debug!(url = %attachment.url, filename = %attachment.filename, "adding image attachment"); - content_blocks.push(content_block); + extra_blocks.push(block); } } tracing::debug!( - num_blocks = content_blocks.len(), + num_extra_blocks = extra_blocks.len(), num_attachments = msg.attachments.len(), in_thread, "processing" @@ -269,7 +264,7 @@ impl EventHandler for Handler { if let Err(e) = self .router - .handle_message(&adapter, &thread_channel, &sender, content_blocks, &trigger_msg) + .handle_message(&adapter, &thread_channel, &sender, &prompt, extra_blocks, &trigger_msg) .await { error!("handle_message error: {e}"); @@ -295,161 +290,6 @@ fn discord_msg_ref(msg: &Message) -> MessageRef { } } -/// Check if an attachment is an audio file (voice messages are typically audio/ogg). -fn is_audio_attachment(attachment: &serenity::model::channel::Attachment) -> bool { - let mime = attachment.content_type.as_deref().unwrap_or(""); - mime.starts_with("audio/") -} - -/// Download an audio attachment and transcribe it via the configured STT provider. -async fn download_and_transcribe( - attachment: &serenity::model::channel::Attachment, - stt_config: &SttConfig, -) -> Option { - const MAX_SIZE: u64 = 25 * 1024 * 1024; // 25 MB (Whisper API limit) - - if u64::from(attachment.size) > MAX_SIZE { - error!(filename = %attachment.filename, size = attachment.size, "audio exceeds 25MB limit"); - return None; - } - - let resp = HTTP_CLIENT.get(&attachment.url).send().await.ok()?; - if !resp.status().is_success() { - error!(url = %attachment.url, status = %resp.status(), "audio download failed"); - return None; - } - let bytes = resp.bytes().await.ok()?.to_vec(); - - let mime_type = attachment.content_type.as_deref().unwrap_or("audio/ogg"); - let mime_type = mime_type.split(';').next().unwrap_or(mime_type).trim(); - - crate::stt::transcribe(&HTTP_CLIENT, stt_config, bytes, attachment.filename.clone(), mime_type).await -} - -/// Maximum dimension (width or height) for resized images. -const IMAGE_MAX_DIMENSION_PX: u32 = 1200; - -/// JPEG quality for compressed output. -const IMAGE_JPEG_QUALITY: u8 = 75; - -/// Download a Discord image attachment, resize/compress it, then base64-encode -/// as an ACP image content block. -async fn download_and_encode_image(attachment: &serenity::model::channel::Attachment) -> Option { - const MAX_SIZE: u64 = 10 * 1024 * 1024; // 10 MB - - let url = &attachment.url; - if url.is_empty() { - return None; - } - - let media_type = attachment - .content_type - .as_deref() - .or_else(|| { - attachment - .filename - .rsplit('.') - .next() - .and_then(|ext| match ext.to_lowercase().as_str() { - "png" => Some("image/png"), - "jpg" | "jpeg" => Some("image/jpeg"), - "gif" => Some("image/gif"), - "webp" => Some("image/webp"), - _ => None, - }) - }); - - let Some(mime) = media_type else { - debug!(filename = %attachment.filename, "skipping non-image attachment"); - return None; - }; - let mime = mime.split(';').next().unwrap_or(mime).trim(); - if !mime.starts_with("image/") { - debug!(filename = %attachment.filename, mime = %mime, "skipping non-image attachment"); - return None; - } - - if u64::from(attachment.size) > MAX_SIZE { - error!(filename = %attachment.filename, size = attachment.size, "image exceeds 10MB limit"); - return None; - } - - let response = match HTTP_CLIENT.get(url).send().await { - Ok(resp) => resp, - Err(e) => { error!(url = %url, error = %e, "download failed"); return None; } - }; - if !response.status().is_success() { - error!(url = %url, status = %response.status(), "HTTP error downloading image"); - return None; - } - let bytes = match response.bytes().await { - Ok(b) => b, - Err(e) => { error!(url = %url, error = %e, "read failed"); return None; } - }; - - if bytes.len() as u64 > MAX_SIZE { - error!(filename = %attachment.filename, size = bytes.len(), "downloaded image exceeds limit"); - return None; - } - - let (output_bytes, output_mime) = match resize_and_compress(&bytes) { - Ok(result) => result, - Err(e) => { - if bytes.len() > 1024 * 1024 { - error!(filename = %attachment.filename, error = %e, size = bytes.len(), "resize failed and original too large, skipping"); - return None; - } - debug!(filename = %attachment.filename, error = %e, "resize failed, using original"); - (bytes.to_vec(), mime.to_string()) - } - }; - - debug!( - filename = %attachment.filename, - original_size = bytes.len(), - compressed_size = output_bytes.len(), - "image processed" - ); - - let encoded = BASE64.encode(&output_bytes); - Some(ContentBlock::Image { - media_type: output_mime, - data: encoded, - }) -} - -/// Resize image so longest side <= IMAGE_MAX_DIMENSION_PX, then encode as JPEG. -/// GIFs are passed through unchanged to preserve animation. -fn resize_and_compress(raw: &[u8]) -> Result<(Vec, String), image::ImageError> { - let reader = ImageReader::new(Cursor::new(raw)) - .with_guessed_format()?; - - let format = reader.format(); - - if format == Some(image::ImageFormat::Gif) { - return Ok((raw.to_vec(), "image/gif".to_string())); - } - - let img = reader.decode()?; - let (w, h) = (img.width(), img.height()); - - let img = if w > IMAGE_MAX_DIMENSION_PX || h > IMAGE_MAX_DIMENSION_PX { - let max_side = std::cmp::max(w, h); - let ratio = f64::from(IMAGE_MAX_DIMENSION_PX) / f64::from(max_side); - let new_w = (f64::from(w) * ratio) as u32; - let new_h = (f64::from(h) * ratio) as u32; - img.resize(new_w, new_h, image::imageops::FilterType::Lanczos3) - } else { - img - }; - - let mut buf = Cursor::new(Vec::new()); - let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, IMAGE_JPEG_QUALITY); - img.write_with_encoder(encoder)?; - - Ok((buf.into_inner(), "image/jpeg".to_string())) -} - async fn get_or_create_thread( ctx: &Context, adapter: &Arc, @@ -497,86 +337,3 @@ fn shorten_thread_name(prompt: &str) -> String { name } } - -#[cfg(test)] -mod tests { - use super::*; - - fn make_png(width: u32, height: u32) -> Vec { - let img = image::RgbImage::new(width, height); - let mut buf = Cursor::new(Vec::new()); - img.write_to(&mut buf, image::ImageFormat::Png).unwrap(); - buf.into_inner() - } - - #[test] - fn large_image_resized_to_max_dimension() { - let png = make_png(3000, 2000); - let (compressed, mime) = resize_and_compress(&png).unwrap(); - - assert_eq!(mime, "image/jpeg"); - let result = image::load_from_memory(&compressed).unwrap(); - assert!(result.width() <= IMAGE_MAX_DIMENSION_PX); - assert!(result.height() <= IMAGE_MAX_DIMENSION_PX); - } - - #[test] - fn small_image_keeps_original_dimensions() { - let png = make_png(800, 600); - let (compressed, mime) = resize_and_compress(&png).unwrap(); - - assert_eq!(mime, "image/jpeg"); - let result = image::load_from_memory(&compressed).unwrap(); - assert_eq!(result.width(), 800); - assert_eq!(result.height(), 600); - } - - #[test] - fn landscape_image_respects_aspect_ratio() { - let png = make_png(4000, 2000); - let (compressed, _) = resize_and_compress(&png).unwrap(); - - let result = image::load_from_memory(&compressed).unwrap(); - assert_eq!(result.width(), 1200); - assert_eq!(result.height(), 600); - } - - #[test] - fn portrait_image_respects_aspect_ratio() { - let png = make_png(2000, 4000); - let (compressed, _) = resize_and_compress(&png).unwrap(); - - let result = image::load_from_memory(&compressed).unwrap(); - assert_eq!(result.width(), 600); - assert_eq!(result.height(), 1200); - } - - #[test] - fn compressed_output_is_smaller_than_original() { - let png = make_png(3000, 2000); - let (compressed, _) = resize_and_compress(&png).unwrap(); - - assert!(compressed.len() < png.len(), "compressed {} should be < original {}", compressed.len(), png.len()); - } - - #[test] - fn gif_passes_through_unchanged() { - let gif: Vec = vec![ - 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, - 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, - 0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, - 0x02, 0x02, 0x44, 0x01, 0x00, - 0x3B, - ]; - let (output, mime) = resize_and_compress(&gif).unwrap(); - - assert_eq!(mime, "image/gif"); - assert_eq!(output, gif); - } - - #[test] - fn invalid_data_returns_error() { - let garbage = vec![0x00, 0x01, 0x02, 0x03]; - assert!(resize_and_compress(&garbage).is_err()); - } -} diff --git a/src/main.rs b/src/main.rs index 7194ad3d..f4067f6d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ mod config; mod discord; mod error_display; mod format; +mod media; mod reactions; mod slack; mod stt; @@ -81,12 +82,14 @@ async fn main() -> anyhow::Result<()> { "starting slack adapter" ); let router = router.clone(); + let stt = cfg.stt.clone(); Some(tokio::spawn(async move { if let Err(e) = slack::run_slack_adapter( slack_cfg.bot_token, slack_cfg.app_token, slack_cfg.allowed_channels.into_iter().collect(), slack_cfg.allowed_users.into_iter().collect(), + stt, router, ) .await diff --git a/src/media.rs b/src/media.rs new file mode 100644 index 00000000..835d4f3f --- /dev/null +++ b/src/media.rs @@ -0,0 +1,271 @@ +use crate::acp::ContentBlock; +use crate::config::SttConfig; +use base64::engine::general_purpose::STANDARD as BASE64; +use base64::Engine; +use image::ImageReader; +use std::io::Cursor; +use std::sync::LazyLock; +use tracing::{debug, error}; + +/// Reusable HTTP client for downloading attachments (shared across adapters). +pub static HTTP_CLIENT: LazyLock = LazyLock::new(|| { + reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .expect("static HTTP client must build") +}); + +/// Maximum dimension (width or height) for resized images. +const IMAGE_MAX_DIMENSION_PX: u32 = 1200; + +/// JPEG quality for compressed output. +const IMAGE_JPEG_QUALITY: u8 = 75; + +/// Download an image from a URL, resize/compress it, and return as a ContentBlock. +/// Pass `auth_token` for platforms that require authentication (e.g. Slack private files). +pub async fn download_and_encode_image( + url: &str, + mime_hint: Option<&str>, + filename: &str, + size: u64, + auth_token: Option<&str>, +) -> Option { + const MAX_SIZE: u64 = 10 * 1024 * 1024; // 10 MB + + if url.is_empty() { + return None; + } + + let mime = mime_hint.or_else(|| { + filename + .rsplit('.') + .next() + .and_then(|ext| match ext.to_lowercase().as_str() { + "png" => Some("image/png"), + "jpg" | "jpeg" => Some("image/jpeg"), + "gif" => Some("image/gif"), + "webp" => Some("image/webp"), + _ => None, + }) + }); + + let Some(mime) = mime else { + debug!(filename, "skipping non-image attachment"); + return None; + }; + let mime = mime.split(';').next().unwrap_or(mime).trim(); + if !mime.starts_with("image/") { + debug!(filename, mime, "skipping non-image attachment"); + return None; + } + + if size > MAX_SIZE { + error!(filename, size, "image exceeds 10MB limit"); + return None; + } + + let mut req = HTTP_CLIENT.get(url); + if let Some(token) = auth_token { + req = req.header("Authorization", format!("Bearer {token}")); + } + + let response = match req.send().await { + Ok(resp) => resp, + Err(e) => { error!(url, error = %e, "download failed"); return None; } + }; + if !response.status().is_success() { + error!(url, status = %response.status(), "HTTP error downloading image"); + return None; + } + let bytes = match response.bytes().await { + Ok(b) => b, + Err(e) => { error!(url, error = %e, "read failed"); return None; } + }; + + if bytes.len() as u64 > MAX_SIZE { + error!(filename, size = bytes.len(), "downloaded image exceeds limit"); + return None; + } + + let (output_bytes, output_mime) = match resize_and_compress(&bytes) { + Ok(result) => result, + Err(e) => { + if bytes.len() > 1024 * 1024 { + error!(filename, error = %e, size = bytes.len(), "resize failed and original too large, skipping"); + return None; + } + debug!(filename, error = %e, "resize failed, using original"); + (bytes.to_vec(), mime.to_string()) + } + }; + + debug!( + filename, + original_size = bytes.len(), + compressed_size = output_bytes.len(), + "image processed" + ); + + let encoded = BASE64.encode(&output_bytes); + Some(ContentBlock::Image { + media_type: output_mime, + data: encoded, + }) +} + +/// Download an audio file and transcribe it via the configured STT provider. +/// Pass `auth_token` for platforms that require authentication. +pub async fn download_and_transcribe( + url: &str, + filename: &str, + mime_type: &str, + size: u64, + stt_config: &SttConfig, + auth_token: Option<&str>, +) -> Option { + const MAX_SIZE: u64 = 25 * 1024 * 1024; // 25 MB (Whisper API limit) + + if size > MAX_SIZE { + error!(filename, size, "audio exceeds 25MB limit"); + return None; + } + + let mut req = HTTP_CLIENT.get(url); + if let Some(token) = auth_token { + req = req.header("Authorization", format!("Bearer {token}")); + } + + let resp = req.send().await.ok()?; + if !resp.status().is_success() { + error!(url, status = %resp.status(), "audio download failed"); + return None; + } + let bytes = resp.bytes().await.ok()?.to_vec(); + + crate::stt::transcribe(&HTTP_CLIENT, stt_config, bytes, filename.to_string(), mime_type).await +} + +/// Resize image so longest side <= IMAGE_MAX_DIMENSION_PX, then encode as JPEG. +/// GIFs are passed through unchanged to preserve animation. +pub fn resize_and_compress(raw: &[u8]) -> Result<(Vec, String), image::ImageError> { + let reader = ImageReader::new(Cursor::new(raw)) + .with_guessed_format()?; + + let format = reader.format(); + + if format == Some(image::ImageFormat::Gif) { + return Ok((raw.to_vec(), "image/gif".to_string())); + } + + let img = reader.decode()?; + let (w, h) = (img.width(), img.height()); + + let img = if w > IMAGE_MAX_DIMENSION_PX || h > IMAGE_MAX_DIMENSION_PX { + let max_side = std::cmp::max(w, h); + let ratio = f64::from(IMAGE_MAX_DIMENSION_PX) / f64::from(max_side); + let new_w = (f64::from(w) * ratio) as u32; + let new_h = (f64::from(h) * ratio) as u32; + img.resize(new_w, new_h, image::imageops::FilterType::Lanczos3) + } else { + img + }; + + let mut buf = Cursor::new(Vec::new()); + let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, IMAGE_JPEG_QUALITY); + img.write_with_encoder(encoder)?; + + Ok((buf.into_inner(), "image/jpeg".to_string())) +} + +/// Check if a MIME type is audio. +pub fn is_audio_mime(mime: &str) -> bool { + mime.starts_with("audio/") +} + +/// Check if a MIME type is an image we can process. +pub fn is_image_mime(mime: &str) -> bool { + mime.starts_with("image/") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_png(width: u32, height: u32) -> Vec { + let img = image::RgbImage::new(width, height); + let mut buf = Cursor::new(Vec::new()); + img.write_to(&mut buf, image::ImageFormat::Png).unwrap(); + buf.into_inner() + } + + #[test] + fn large_image_resized_to_max_dimension() { + let png = make_png(3000, 2000); + let (compressed, mime) = resize_and_compress(&png).unwrap(); + + assert_eq!(mime, "image/jpeg"); + let result = image::load_from_memory(&compressed).unwrap(); + assert!(result.width() <= IMAGE_MAX_DIMENSION_PX); + assert!(result.height() <= IMAGE_MAX_DIMENSION_PX); + } + + #[test] + fn small_image_keeps_original_dimensions() { + let png = make_png(800, 600); + let (compressed, mime) = resize_and_compress(&png).unwrap(); + + assert_eq!(mime, "image/jpeg"); + let result = image::load_from_memory(&compressed).unwrap(); + assert_eq!(result.width(), 800); + assert_eq!(result.height(), 600); + } + + #[test] + fn landscape_image_respects_aspect_ratio() { + let png = make_png(4000, 2000); + let (compressed, _) = resize_and_compress(&png).unwrap(); + + let result = image::load_from_memory(&compressed).unwrap(); + assert_eq!(result.width(), 1200); + assert_eq!(result.height(), 600); + } + + #[test] + fn portrait_image_respects_aspect_ratio() { + let png = make_png(2000, 4000); + let (compressed, _) = resize_and_compress(&png).unwrap(); + + let result = image::load_from_memory(&compressed).unwrap(); + assert_eq!(result.width(), 600); + assert_eq!(result.height(), 1200); + } + + #[test] + fn compressed_output_is_smaller_than_original() { + let png = make_png(3000, 2000); + let (compressed, _) = resize_and_compress(&png).unwrap(); + + assert!(compressed.len() < png.len(), "compressed {} should be < original {}", compressed.len(), png.len()); + } + + #[test] + fn gif_passes_through_unchanged() { + let gif: Vec = vec![ + 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, + 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, + 0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, + 0x02, 0x02, 0x44, 0x01, 0x00, + 0x3B, + ]; + let (output, mime) = resize_and_compress(&gif).unwrap(); + + assert_eq!(mime, "image/gif"); + assert_eq!(output, gif); + } + + #[test] + fn invalid_data_returns_error() { + let garbage = vec![0x00, 0x01, 0x02, 0x03]; + assert!(resize_and_compress(&garbage).is_err()); + } +} diff --git a/src/slack.rs b/src/slack.rs index 302ee354..a637272f 100644 --- a/src/slack.rs +++ b/src/slack.rs @@ -1,12 +1,14 @@ use crate::acp::ContentBlock; use crate::adapter::{AdapterRouter, ChatAdapter, ChannelRef, MessageRef, SenderContext}; +use crate::config::SttConfig; +use crate::media; use anyhow::{anyhow, Result}; use async_trait::async_trait; use futures_util::{SinkExt, StreamExt}; use std::collections::HashSet; use std::sync::Arc; use tokio_tungstenite::tungstenite; -use tracing::{error, info, warn}; +use tracing::{debug, error, info, warn}; const SLACK_API: &str = "https://slack.com/api"; @@ -125,7 +127,6 @@ impl ChatAdapter for SlackAdapter { _title: &str, ) -> Result { // Slack threads are implicit — posting with thread_ts creates/continues a thread. - // The "thread" is the channel + the trigger message's ts as thread_ts. Ok(ChannelRef { platform: "slack".into(), channel_id: channel.channel_id.clone(), @@ -172,9 +173,10 @@ pub async fn run_slack_adapter( app_token: String, allowed_channels: HashSet, allowed_users: HashSet, + stt_config: SttConfig, router: Arc, ) -> Result<()> { - let adapter = Arc::new(SlackAdapter::new(bot_token)); + let adapter = Arc::new(SlackAdapter::new(bot_token.clone())); loop { let ws_url = match get_socket_mode_url(&app_token).await { @@ -216,8 +218,10 @@ pub async fn run_slack_adapter( handle_app_mention( event, &adapter, + &bot_token, &allowed_channels, &allowed_users, + &stt_config, &router, ) .await; @@ -272,8 +276,10 @@ async fn get_socket_mode_url(app_token: &str) -> Result { async fn handle_app_mention( event: &serde_json::Value, adapter: &Arc, + bot_token: &str, allowed_channels: &HashSet, allowed_users: &HashSet, + stt_config: &SttConfig, router: &Arc, ) { let channel_id = match event["channel"].as_str() { @@ -317,30 +323,72 @@ async fn handle_app_mention( // Strip bot mention (<@UBOTID>) from text let prompt = strip_slack_mention(&text); - if prompt.is_empty() { + + // Process file attachments (images, audio) + let files = event["files"].as_array(); + let has_files = files.map_or(false, |f| !f.is_empty()); + + if prompt.is_empty() && !has_files { return; } + let mut extra_blocks = Vec::new(); + if let Some(files) = files { + for file in files { + let mimetype = file["mimetype"].as_str().unwrap_or(""); + let filename = file["name"].as_str().unwrap_or("file"); + let size = file["size"].as_u64().unwrap_or(0); + // Slack private files require Bearer token to download + let url = file["url_private_download"] + .as_str() + .or_else(|| file["url_private"].as_str()) + .unwrap_or(""); + + if url.is_empty() { + continue; + } + + if media::is_audio_mime(mimetype) { + if stt_config.enabled { + if let Some(transcript) = media::download_and_transcribe( + url, + filename, + mimetype, + size, + stt_config, + Some(bot_token), + ).await { + debug!(filename, chars = transcript.len(), "voice transcript injected"); + extra_blocks.insert(0, ContentBlock::Text { + text: format!("[Voice message transcript]: {transcript}"), + }); + } + } else { + debug!(filename, "skipping audio attachment (STT disabled)"); + } + } else if let Some(block) = media::download_and_encode_image( + url, + Some(mimetype), + filename, + size, + Some(bot_token), + ).await { + debug!(filename, "adding image attachment"); + extra_blocks.push(block); + } + } + } + let sender = SenderContext { schema: "openab.sender.v1".into(), sender_id: user_id.clone(), - sender_name: user_id.clone(), // app_mention events don't include display name + sender_name: user_id.clone(), display_name: user_id.clone(), channel: "slack".into(), channel_id: channel_id.clone(), is_bot: false, }; - let sender_json = serde_json::to_string(&sender).unwrap(); - let prompt_with_sender = format!( - "\n{}\n\n\n{}", - sender_json, prompt - ); - - let content_blocks = vec![ContentBlock::Text { - text: prompt_with_sender, - }]; - let trigger_msg = MessageRef { channel: ChannelRef { platform: "slack".into(), @@ -361,7 +409,7 @@ async fn handle_app_mention( let adapter_dyn: Arc = adapter.clone(); if let Err(e) = router - .handle_message(&adapter_dyn, &thread_channel, &sender, content_blocks, &trigger_msg) + .handle_message(&adapter_dyn, &thread_channel, &sender, &prompt, extra_blocks, &trigger_msg) .await { error!("Slack handle_message error: {e}"); From 67d76abb3f809b1d92edf8d215b4cce4aabf0181 Mon Sep 17 00:00:00 2001 From: Tony Lee Date: Sun, 12 Apr 2026 23:57:36 +0800 Subject: [PATCH 03/16] chore: remove unused methods (is_thread, pool, is_image_mime) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/adapter.rs | 10 ---------- src/media.rs | 5 ----- 2 files changed, 15 deletions(-) diff --git a/src/adapter.rs b/src/adapter.rs index 562abd5c..95f47aea 100644 --- a/src/adapter.rs +++ b/src/adapter.rs @@ -25,12 +25,6 @@ pub struct ChannelRef { pub parent_id: Option, } -impl ChannelRef { - pub fn is_thread(&self) -> bool { - self.thread_id.is_some() || self.parent_id.is_some() - } -} - /// Identifies a message across platforms. #[derive(Clone, Debug)] pub struct MessageRef { @@ -98,10 +92,6 @@ impl AdapterRouter { } } - pub fn pool(&self) -> &SessionPool { - &self.pool - } - /// Handle an incoming user message. The adapter is responsible for /// filtering, resolving the thread, and building the SenderContext. /// This method handles sender context injection, session management, and streaming. diff --git a/src/media.rs b/src/media.rs index 835d4f3f..709f7885 100644 --- a/src/media.rs +++ b/src/media.rs @@ -182,11 +182,6 @@ pub fn is_audio_mime(mime: &str) -> bool { mime.starts_with("audio/") } -/// Check if a MIME type is an image we can process. -pub fn is_image_mime(mime: &str) -> bool { - mime.starts_with("image/") -} - #[cfg(test)] mod tests { use super::*; From ecb983b020e6bf6e8347a4248ae353611b57520a Mon Sep 17 00:00:00 2001 From: Tony Lee Date: Mon, 13 Apr 2026 00:12:13 +0800 Subject: [PATCH 04/16] =?UTF-8?q?fix:=20review=20findings=20=E2=80=94=20re?= =?UTF-8?q?gex=20LazyLock,=20user=20name=20resolution,=20thread=20follow-u?= =?UTF-8?q?p?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix strip_slack_mention to use LazyLock instead of compiling per-call - Fix shorten_thread_name regex to use LazyLock, move to shared format.rs - Resolve Slack display name via users.info API (fallback to user_id) - Handle message events with thread_ts for thread follow-ups without @mention - Fix misleading allowed_channels comment (empty = allow all, not deny all) - Add allowed_channels docs to config.toml.example Co-Authored-By: Claude Opus 4.6 (1M context) --- config.toml.example | 6 +-- src/discord.rs | 13 +----- src/format.rs | 15 +++++++ src/slack.rs | 105 +++++++++++++++++++++++++++++++++++--------- 4 files changed, 105 insertions(+), 34 deletions(-) diff --git a/config.toml.example b/config.toml.example index c33ac249..1fc22a8e 100644 --- a/config.toml.example +++ b/config.toml.example @@ -2,14 +2,14 @@ [discord] bot_token = "${DISCORD_BOT_TOKEN}" -allowed_channels = ["1234567890"] +allowed_channels = ["1234567890"] # empty or omitted = allow all channels # allowed_users = [""] # empty or omitted = allow all users # [slack] # bot_token = "${SLACK_BOT_TOKEN}" # Bot User OAuth Token (xoxb-...) # app_token = "${SLACK_APP_TOKEN}" # App-Level Token (xapp-...) for Socket Mode -# allowed_channels = ["C0123456789"] # Slack channel IDs -# allowed_users = ["U0123456789"] # Slack user IDs (optional) +# allowed_channels = ["C0123456789"] # empty or omitted = allow all channels +# allowed_users = ["U0123456789"] # empty or omitted = allow all users [agent] command = "kiro-cli" diff --git a/src/discord.rs b/src/discord.rs index a3d0f88f..8b16db00 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -1,6 +1,7 @@ use crate::acp::ContentBlock; use crate::adapter::{AdapterRouter, ChatAdapter, ChannelRef, MessageRef, SenderContext}; use crate::config::SttConfig; +use crate::format; use crate::media; use async_trait::async_trait; use std::sync::LazyLock; @@ -308,7 +309,7 @@ async fn get_or_create_thread( } } - let thread_name = shorten_thread_name(prompt); + let thread_name = format::shorten_thread_name(prompt); let parent = ChannelRef { platform: "discord".into(), channel_id: msg.channel_id.get().to_string(), @@ -327,13 +328,3 @@ fn strip_mention(content: &str) -> String { MENTION_RE.replace_all(content, "").trim().to_string() } -fn shorten_thread_name(prompt: &str) -> String { - let re = regex::Regex::new(r"https?://github\.com/([^/]+/[^/]+)/(issues|pull)/(\d+)").unwrap(); - let shortened = re.replace_all(prompt, "$1#$3"); - let name: String = shortened.chars().take(40).collect(); - if name.len() < shortened.len() { - format!("{name}...") - } else { - name - } -} diff --git a/src/format.rs b/src/format.rs index 841cf559..b1fc0f5f 100644 --- a/src/format.rs +++ b/src/format.rs @@ -43,6 +43,21 @@ pub fn split_message(text: &str, limit: usize) -> Vec { chunks } +/// Shorten a prompt into a thread title: collapse GitHub URLs and cap at 40 chars. +pub fn shorten_thread_name(prompt: &str) -> String { + use std::sync::LazyLock; + static GH_RE: LazyLock = LazyLock::new(|| { + regex::Regex::new(r"https?://github\.com/([^/]+/[^/]+)/(issues|pull)/(\d+)").unwrap() + }); + let shortened = GH_RE.replace_all(prompt, "$1#$3"); + let name: String = shortened.chars().take(40).collect(); + if name.len() < shortened.len() { + format!("{name}...") + } else { + name + } +} + /// Truncate a string to at most `limit` Unicode characters. /// Discord's message limit counts Unicode characters, not bytes. pub fn truncate_chars(s: &str, limit: usize) -> &str { diff --git a/src/slack.rs b/src/slack.rs index a637272f..8f4e2e82 100644 --- a/src/slack.rs +++ b/src/slack.rs @@ -6,7 +6,7 @@ use anyhow::{anyhow, Result}; use async_trait::async_trait; use futures_util::{SinkExt, StreamExt}; use std::collections::HashSet; -use std::sync::Arc; +use std::sync::{Arc, LazyLock}; use tokio_tungstenite::tungstenite; use tracing::{debug, error, info, warn}; @@ -72,6 +72,32 @@ impl SlackAdapter { } Ok(json) } + + /// Resolve a Slack user ID to display name via users.info API. + async fn resolve_user_name(&self, user_id: &str) -> Option { + let resp = self + .api_post( + "users.info", + serde_json::json!({ "user": user_id }), + ) + .await + .ok()?; + let user = resp.get("user")?; + // Prefer display_name from profile, fallback to real_name, then name + let profile = user.get("profile")?; + let display = profile + .get("display_name") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()); + let real = profile + .get("real_name") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()); + let name = user + .get("name") + .and_then(|v| v.as_str()); + Some(display.or(real).or(name)?.to_string()) + } } #[async_trait] @@ -214,17 +240,43 @@ pub async fn run_slack_adapter( // Route events if envelope["type"].as_str() == Some("events_api") { let event = &envelope["payload"]["event"]; - if event["type"].as_str() == Some("app_mention") { - handle_app_mention( - event, - &adapter, - &bot_token, - &allowed_channels, - &allowed_users, - &stt_config, - &router, - ) - .await; + let event_type = event["type"].as_str().unwrap_or(""); + match event_type { + "app_mention" => { + handle_message( + event, + true, + &adapter, + &bot_token, + &allowed_channels, + &allowed_users, + &stt_config, + &router, + ) + .await; + } + "message" => { + // Handle thread follow-ups without @mention. + // Skip bot messages and subtypes (join/leave/edits). + let has_thread = event["thread_ts"].is_string(); + let is_bot = event["bot_id"].is_string() + || event["subtype"].as_str() == Some("bot_message"); + let has_subtype = event["subtype"].is_string(); + if has_thread && !is_bot && !has_subtype { + handle_message( + event, + false, + &adapter, + &bot_token, + &allowed_channels, + &allowed_users, + &stt_config, + &router, + ) + .await; + } + } + _ => {} } } } @@ -273,8 +325,9 @@ async fn get_socket_mode_url(app_token: &str) -> Result { .ok_or_else(|| anyhow!("no url in apps.connections.open response")) } -async fn handle_app_mention( +async fn handle_message( event: &serde_json::Value, + is_mention: bool, adapter: &Arc, bot_token: &str, allowed_channels: &HashSet, @@ -300,7 +353,7 @@ async fn handle_app_mention( }; let thread_ts = event["thread_ts"].as_str().map(|s| s.to_string()); - // Check allowed channels (empty = deny all, secure by default per #91) + // Check allowed channels (empty = allow all) if !allowed_channels.is_empty() && !allowed_channels.contains(&channel_id) { return; } @@ -321,8 +374,12 @@ async fn handle_app_mention( return; } - // Strip bot mention (<@UBOTID>) from text - let prompt = strip_slack_mention(&text); + // Strip bot mention from text only for @mention events + let prompt = if is_mention { + strip_slack_mention(&text) + } else { + text.trim().to_string() + }; // Process file attachments (images, audio) let files = event["files"].as_array(); @@ -379,11 +436,17 @@ async fn handle_app_mention( } } + // Resolve Slack display name (best-effort, fallback to user_id) + let display_name = adapter + .resolve_user_name(&user_id) + .await + .unwrap_or_else(|| user_id.clone()); + let sender = SenderContext { schema: "openab.sender.v1".into(), sender_id: user_id.clone(), - sender_name: user_id.clone(), - display_name: user_id.clone(), + sender_name: display_name.clone(), + display_name, channel: "slack".into(), channel_id: channel_id.clone(), is_bot: false, @@ -416,7 +479,9 @@ async fn handle_app_mention( } } +static SLACK_MENTION_RE: LazyLock = + LazyLock::new(|| regex::Regex::new(r"<@[A-Z0-9]+>").unwrap()); + fn strip_slack_mention(text: &str) -> String { - let re = regex::Regex::new(r"<@[A-Z0-9]+>").unwrap(); - re.replace_all(text, "").trim().to_string() + SLACK_MENTION_RE.replace_all(text, "").trim().to_string() } From 9c1c35a6610162f0fd08c236e6d837f4ecadc0a5 Mon Sep 17 00:00:00 2001 From: Tony Lee Date: Mon, 13 Apr 2026 00:57:17 +0800 Subject: [PATCH 05/16] feat(helm): add Slack adapter support to Helm chart - Add slack config to values.yaml (enabled, botToken, appToken, allowedChannels, allowedUsers) - Generate [slack] section in configmap.yaml when slack.enabled=true - Store slack-bot-token and slack-app-token in secret.yaml - Inject SLACK_BOT_TOKEN and SLACK_APP_TOKEN env vars in deployment.yaml - Make [discord] section conditional (omitted when no botToken) - Add Slack setup instructions to NOTES.txt - Backward compatible: existing Discord-only configs work unchanged Co-Authored-By: Claude Opus 4.6 (1M context) --- charts/openab/templates/NOTES.txt | 15 ++++++++++++--- charts/openab/templates/configmap.yaml | 20 ++++++++++++++++++++ charts/openab/templates/deployment.yaml | 14 ++++++++++++++ charts/openab/templates/secret.yaml | 15 +++++++++++++-- charts/openab/values.yaml | 6 ++++++ 5 files changed, 65 insertions(+), 5 deletions(-) diff --git a/charts/openab/templates/NOTES.txt b/charts/openab/templates/NOTES.txt index 37f1c709..84965fa2 100644 --- a/charts/openab/templates/NOTES.txt +++ b/charts/openab/templates/NOTES.txt @@ -1,15 +1,24 @@ openab {{ .Chart.AppVersion }} has been installed! -⚠️ Discord channel IDs must be set with --set-string (not --set) to avoid float64 precision loss. +⚠️ Channel/user IDs must be set with --set-string (not --set) to avoid float64 precision loss. Agents deployed: {{- range $name, $cfg := .Values.agents }} {{- if ne (include "openab.agentEnabled" $cfg) "false" }} • {{ $name }} ({{ $cfg.command }}) -{{- if not $cfg.discord.botToken }} +{{- if not (or $cfg.discord.botToken (and ($cfg.slack).enabled ($cfg.slack).botToken)) }} ⚠️ No bot token provided. Create the secret manually: kubectl create secret generic {{ include "openab.agentFullname" (dict "ctx" $ "agent" $name) }} \ - --from-literal=discord-bot-token="YOUR_TOKEN" + --from-literal=discord-bot-token="YOUR_DISCORD_TOKEN" +{{- end }} + +{{- if $cfg.discord.botToken }} + Discord: ✅ configured +{{- end }} +{{- if and ($cfg.slack).enabled ($cfg.slack).botToken }} + Slack: ✅ configured (Socket Mode) + Ensure your Slack app has these bot events: app_mention, message.channels, message.groups + Required scopes: app_mentions:read, chat:write, channels:history, groups:history, channels:read, groups:read, reactions:write, files:read {{- end }} {{- if eq $cfg.command "kiro-cli" }} diff --git a/charts/openab/templates/configmap.yaml b/charts/openab/templates/configmap.yaml index 194d8c25..91809dc9 100644 --- a/charts/openab/templates/configmap.yaml +++ b/charts/openab/templates/configmap.yaml @@ -10,6 +10,7 @@ metadata: {{- include "openab.labels" $d | nindent 4 }} data: config.toml: | + {{- if $cfg.discord.botToken }} [discord] bot_token = "${DISCORD_BOT_TOKEN}" {{- range $cfg.discord.allowedChannels }} @@ -24,6 +25,25 @@ data: {{- end }} {{- end }} allowed_users = {{ $cfg.discord.allowedUsers | default list | toJson }} + {{- end }} + + {{- if and ($cfg.slack).enabled }} + [slack] + bot_token = "${SLACK_BOT_TOKEN}" + app_token = "${SLACK_APP_TOKEN}" + {{- range ($cfg.slack).allowedChannels }} + {{- if regexMatch "e\\+|E\\+" (toString .) }} + {{- fail (printf "slack.allowedChannels contains a mangled ID: %s — use --set-string instead of --set for channel IDs" (toString .)) }} + {{- end }} + {{- end }} + allowed_channels = {{ ($cfg.slack).allowedChannels | default list | toJson }} + {{- range ($cfg.slack).allowedUsers }} + {{- if regexMatch "e\\+|E\\+" (toString .) }} + {{- fail (printf "slack.allowedUsers contains a mangled ID: %s — use --set-string instead of --set for user IDs" (toString .)) }} + {{- end }} + {{- end }} + allowed_users = {{ ($cfg.slack).allowedUsers | default list | toJson }} + {{- end }} [agent] command = "{{ $cfg.command }}" diff --git a/charts/openab/templates/deployment.yaml b/charts/openab/templates/deployment.yaml index 0d45041d..80643968 100644 --- a/charts/openab/templates/deployment.yaml +++ b/charts/openab/templates/deployment.yaml @@ -45,6 +45,20 @@ spec: name: {{ include "openab.agentFullname" $d }} key: discord-bot-token {{- end }} + {{- if and ($cfg.slack).enabled ($cfg.slack).botToken }} + - name: SLACK_BOT_TOKEN + valueFrom: + secretKeyRef: + name: {{ include "openab.agentFullname" $d }} + key: slack-bot-token + {{- end }} + {{- if and ($cfg.slack).enabled ($cfg.slack).appToken }} + - name: SLACK_APP_TOKEN + valueFrom: + secretKeyRef: + name: {{ include "openab.agentFullname" $d }} + key: slack-app-token + {{- end }} {{- if and ($cfg.stt).enabled ($cfg.stt).apiKey }} - name: STT_API_KEY valueFrom: diff --git a/charts/openab/templates/secret.yaml b/charts/openab/templates/secret.yaml index 2cdd27c8..e10269d7 100644 --- a/charts/openab/templates/secret.yaml +++ b/charts/openab/templates/secret.yaml @@ -1,6 +1,9 @@ {{- range $name, $cfg := .Values.agents }} {{- if ne (include "openab.agentEnabled" $cfg) "false" }} -{{- if $cfg.discord.botToken }} +{{- $hasDiscord := $cfg.discord.botToken }} +{{- $hasSlack := and ($cfg.slack).enabled (or ($cfg.slack).botToken ($cfg.slack).appToken) }} +{{- $hasStt := and ($cfg.stt).enabled ($cfg.stt).apiKey }} +{{- if or $hasDiscord $hasSlack $hasStt }} {{- $d := dict "ctx" $ "agent" $name "cfg" $cfg }} --- apiVersion: v1 @@ -13,8 +16,16 @@ metadata: "helm.sh/resource-policy": keep type: Opaque data: + {{- if $hasDiscord }} discord-bot-token: {{ $cfg.discord.botToken | b64enc | quote }} - {{- if and ($cfg.stt).enabled ($cfg.stt).apiKey }} + {{- end }} + {{- if and ($cfg.slack).enabled ($cfg.slack).botToken }} + slack-bot-token: {{ $cfg.slack.botToken | b64enc | quote }} + {{- end }} + {{- if and ($cfg.slack).enabled ($cfg.slack).appToken }} + slack-app-token: {{ $cfg.slack.appToken | b64enc | quote }} + {{- end }} + {{- if $hasStt }} stt-api-key: {{ $cfg.stt.apiKey | b64enc | quote }} {{- end }} {{- end }} diff --git a/charts/openab/values.yaml b/charts/openab/values.yaml index 956374cb..30a491a1 100644 --- a/charts/openab/values.yaml +++ b/charts/openab/values.yaml @@ -60,6 +60,12 @@ agents: - "YOUR_CHANNEL_ID" # ⚠️ Use --set-string for user IDs to avoid float64 precision loss allowedUsers: [] # empty = allow all users (default) + slack: + enabled: false + botToken: "" # Bot User OAuth Token (xoxb-...) + appToken: "" # App-Level Token (xapp-...) for Socket Mode + allowedChannels: [] # empty = allow all channels + allowedUsers: [] # empty = allow all users workingDir: /home/agent env: {} envFrom: [] From a76f7a69122d028936e70a6244518594c789bff6 Mon Sep 17 00:00:00 2001 From: Tony Lee Date: Mon, 13 Apr 2026 09:58:17 +0800 Subject: [PATCH 06/16] docs: add Slack adapter documentation - Update README.md: add Slack to description, architecture diagram, features, Quick Start (Discord/Slack toggle), config example, Helm example, Configuration Reference, and Project Structure - Add docs/slack-bot-howto.md: complete Slack bot setup guide - Add cross-reference from discord-bot-howto.md to slack-bot-howto.md Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 52 ++++++++++++++--- docs/slack-bot-howto.md | 124 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+), 9 deletions(-) create mode 100644 docs/slack-bot-howto.md diff --git a/README.md b/README.md index 6ad1dbcd..78f50253 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,17 @@ # OpenAB — Open Agent Broker -A lightweight, secure, cloud-native ACP harness that bridges Discord and any [Agent Client Protocol](https://github.com/anthropics/agent-protocol)-compatible coding CLI (Kiro CLI, Claude Code, Codex, Gemini, Copilot CLI, etc.) over stdio JSON-RPC — delivering the next-generation development experience. +A lightweight, secure, cloud-native ACP harness that bridges **Discord, Slack**, and any [Agent Client Protocol](https://github.com/anthropics/agent-protocol)-compatible coding CLI (Kiro CLI, Claude Code, Codex, Gemini, Copilot CLI, etc.) over stdio JSON-RPC — delivering the next-generation development experience. 🪼 **Join our community!** Come say hi on Discord — we'd love to have you: **[🪼 OpenAB — Official](https://discord.gg/YNksK9M6)** 🎉 ``` ┌──────────────┐ Gateway WS ┌──────────────┐ ACP stdio ┌──────────────┐ -│ Discord │◄─────────────►│ openab │──────────────►│ coding CLI │ -│ User │ │ (Rust) │◄── JSON-RPC ──│ (acp mode) │ -└──────────────┘ └──────────────┘ └──────────────┘ +│ Discord │◄─────────────►│ │──────────────►│ coding CLI │ +│ User │ │ openab │◄── JSON-RPC ──│ (acp mode) │ +├──────────────┤ Socket Mode │ (Rust) │ └──────────────┘ +│ Slack │◄─────────────►│ │ +│ User │ └──────────────┘ +└──────────────┘ ``` ## Demo @@ -17,6 +20,7 @@ A lightweight, secure, cloud-native ACP harness that bridges Discord and any [Ag ## Features +- **Multi-platform** — supports Discord and Slack, run one or both simultaneously - **Pluggable agent backend** — swap between Kiro CLI, Claude Code, Codex, Gemini, Copilot CLI via config - **@mention trigger** — mention the bot in an allowed channel to start a conversation - **Thread-based multi-turn** — auto-creates threads; no @mention needed for follow-ups @@ -29,10 +33,22 @@ A lightweight, secure, cloud-native ACP harness that bridges Discord and any [Ag ## Quick Start -### 1. Create a Discord Bot +### 1. Create a Bot + +
+Discord See [docs/discord-bot-howto.md](docs/discord-bot-howto.md) for a detailed step-by-step guide. +
+ +
+Slack + +See [docs/slack-bot-howto.md](docs/slack-bot-howto.md) for a detailed step-by-step guide. + +
+ ### 2. Install with Helm (Kiro CLI — default) ```bash @@ -42,6 +58,13 @@ helm repo update helm install openab openab/openab \ --set agents.kiro.discord.botToken="$DISCORD_BOT_TOKEN" \ --set-string 'agents.kiro.discord.allowedChannels[0]=YOUR_CHANNEL_ID' + +# Slack +helm install openab openab/openab \ + --set agents.kiro.slack.enabled=true \ + --set agents.kiro.slack.botToken="$SLACK_BOT_TOKEN" \ + --set agents.kiro.slack.appToken="$SLACK_APP_TOKEN" \ + --set-string 'agents.kiro.slack.allowedChannels[0]=C0123456789' ``` ### 3. Authenticate (first time only) @@ -60,6 +83,8 @@ In your Discord channel: The bot creates a thread. After that, just type in the thread — no @mention needed. +**Slack:** `@YourBot explain this code` in a channel — same thread-based workflow as Discord. + ## Other Agents | Agent | CLI | ACP Adapter | Guide | @@ -90,6 +115,12 @@ bot_token = "${DISCORD_BOT_TOKEN}" # supports env var expansion allowed_channels = ["123456789"] # channel ID allowlist # allowed_users = ["987654321"] # user ID allowlist (empty = all users) +[slack] +bot_token = "${SLACK_BOT_TOKEN}" # Bot User OAuth Token (xoxb-...) +app_token = "${SLACK_APP_TOKEN}" # App-Level Token (xapp-...) for Socket Mode +allowed_channels = ["C0123456789"] # channel ID allowlist (empty = allow all) +# allowed_users = ["U0123456789"] # user ID allowlist (empty = allow all) + [agent] command = "kiro-cli" # CLI command args = ["acp", "--trust-all-tools"] # ACP mode args @@ -178,15 +209,18 @@ kubectl apply -f k8s/deployment.yaml ├── config.toml.example # example config with all agent backends ├── k8s/ # Kubernetes manifests └── src/ - ├── main.rs # entrypoint: tokio + serenity + cleanup + shutdown + ├── main.rs # entrypoint: multi-adapter startup, cleanup, shutdown + ├── adapter.rs # ChatAdapter trait, AdapterRouter (platform-agnostic) ├── config.rs # TOML config + ${ENV_VAR} expansion - ├── discord.rs # Discord bot: mention, threads, edit-streaming - ├── format.rs # message splitting (2000 char limit) + ├── discord.rs # DiscordAdapter: serenity EventHandler + ChatAdapter impl + ├── slack.rs # SlackAdapter: Socket Mode + ChatAdapter impl + ├── media.rs # shared image resize/compress + STT download + ├── format.rs # message splitting, thread name shortening ├── reactions.rs # status reaction controller (debounce, stall detection) └── acp/ ├── protocol.rs # JSON-RPC types + ACP event classification ├── connection.rs # spawn CLI, stdio JSON-RPC communication - └── pool.rs # thread_id → AcpConnection map + └── pool.rs # session key → AcpConnection map ``` ## Inspired By diff --git a/docs/slack-bot-howto.md b/docs/slack-bot-howto.md new file mode 100644 index 00000000..8fa774e5 --- /dev/null +++ b/docs/slack-bot-howto.md @@ -0,0 +1,124 @@ +# Slack Bot Setup Guide + +Step-by-step guide to create and configure a Slack bot for openab. + +## 1. Create a Slack App + +1. Go to https://api.slack.com/apps +2. Click **Create New App** → **From scratch** +3. Enter an app name (e.g. "OpenAB") and select your workspace +4. Click **Create App** + +## 2. Enable Socket Mode + +Socket Mode uses a persistent WebSocket connection — no public URL or ingress needed. + +1. In the left sidebar, click **Socket Mode** +2. Toggle **Enable Socket Mode** to ON +3. You'll be prompted to generate an **App-Level Token**: + - Token name: `openab-socket` (or any name) + - Scope: `connections:write` + - Click **Generate** +4. Copy the token (`xapp-...`) — this is your `SLACK_APP_TOKEN` + +## 3. Subscribe to Events + +1. In the left sidebar, click **Event Subscriptions** +2. Toggle **Enable Events** to ON +3. Under **Subscribe to bot events**, add: + - `app_mention` — triggers when someone @mentions the bot + - `message.channels` — receives messages in public channels (for thread follow-ups) + - `message.groups` — receives messages in private channels (for thread follow-ups) +4. Click **Save Changes** + +## 4. Add Bot Token Scopes + +1. In the left sidebar, click **OAuth & Permissions** +2. Under **Bot Token Scopes**, add: + +| Scope | Purpose | +|-------|---------| +| `app_mentions:read` | Receive @mention events | +| `chat:write` | Send and edit messages | +| `channels:history` | Read public channel messages (for thread context) | +| `groups:history` | Read private channel messages (for thread context) | +| `channels:read` | List public channels | +| `groups:read` | List private channels | +| `reactions:write` | Add/remove emoji reactions | +| `files:read` | Download file attachments (images, audio) | +| `users:read` | Resolve user display names | + +## 5. Install to Workspace + +1. In the left sidebar, click **Install App** +2. Click **Install to Workspace** (or **Reinstall** if you've changed scopes) +3. Authorize the requested permissions +4. Copy the **Bot User OAuth Token** (`xoxb-...`) — this is your `SLACK_BOT_TOKEN` + +## 6. Configure openab + +Add the `[slack]` section to your `config.toml`: + +```toml +[slack] +bot_token = "${SLACK_BOT_TOKEN}" +app_token = "${SLACK_APP_TOKEN}" +allowed_channels = [] # empty = allow all channels +# allowed_users = ["U0123456789"] # empty = allow all users +``` + +Set the environment variables: + +```bash +export SLACK_BOT_TOKEN="xoxb-..." +export SLACK_APP_TOKEN="xapp-..." +``` + +## 7. Invite the Bot + +In each Slack channel where you want to use the bot: + +``` +/invite @OpenAB +``` + +## 8. Test + +In a channel where the bot is invited: + +``` +@OpenAB explain this code +``` + +The bot will reply in a thread. After that, just type in the thread — no @mention needed for follow-ups. + +## Finding Channel and User IDs + +- **Channel ID**: Right-click the channel name → **View channel details** → ID at the bottom (starts with `C` for public, `G` for private) +- **User ID**: Click a user's profile → **...** menu → **Copy member ID** (starts with `U`) + +## Troubleshooting + +### Bot doesn't respond to @mentions + +1. Verify Socket Mode is enabled in your app settings +2. Check that `app_mention` is subscribed under **bot events** (not user events) +3. Ensure the app is reinstalled after adding new event subscriptions +4. Check the bot is invited to the channel (`/invite @YourSlackAppName`) +5. Run with `RUST_LOG=openab=debug cargo run` to see incoming events + +### Bot doesn't respond to thread follow-ups + +1. Verify `message.channels` (and `message.groups` for private channels) are subscribed under **bot events** +2. Reinstall the app after adding these events + +### "not_authed" or "invalid_auth" errors + +1. Verify your `SLACK_BOT_TOKEN` starts with `xoxb-` +2. Verify your `SLACK_APP_TOKEN` starts with `xapp-` +3. Check the tokens haven't been revoked in your app settings + +### Reactions not showing + +1. Verify `reactions:write` scope is added +2. Reinstall the app after adding the scope From 0e04df0b32599515a610b4d24b37d086b31f7dd4 Mon Sep 17 00:00:00 2001 From: dogzzdogzz Date: Mon, 13 Apr 2026 16:13:42 +0800 Subject: [PATCH 07/16] fix(helm): align discord/slack enabled conditions across all templates - Add discord.enabled field to values.yaml (default: true) - Align all templates to use `and ($cfg.).enabled ($cfg.).botToken` pattern consistently across configmap, secret, deployment, and NOTES - Sync Cargo.lock version with upstream Cargo.toml (0.7.2) Co-Authored-By: Claude Opus 4.6 (1M context) --- charts/openab/templates/NOTES.txt | 4 ++-- charts/openab/templates/configmap.yaml | 2 +- charts/openab/templates/deployment.yaml | 2 +- charts/openab/templates/secret.yaml | 2 +- charts/openab/values.yaml | 1 + 5 files changed, 6 insertions(+), 5 deletions(-) diff --git a/charts/openab/templates/NOTES.txt b/charts/openab/templates/NOTES.txt index 84965fa2..f170e265 100644 --- a/charts/openab/templates/NOTES.txt +++ b/charts/openab/templates/NOTES.txt @@ -6,13 +6,13 @@ Agents deployed: {{- range $name, $cfg := .Values.agents }} {{- if ne (include "openab.agentEnabled" $cfg) "false" }} • {{ $name }} ({{ $cfg.command }}) -{{- if not (or $cfg.discord.botToken (and ($cfg.slack).enabled ($cfg.slack).botToken)) }} +{{- if not (or (and ($cfg.discord).enabled ($cfg.discord).botToken) (and ($cfg.slack).enabled ($cfg.slack).botToken)) }} ⚠️ No bot token provided. Create the secret manually: kubectl create secret generic {{ include "openab.agentFullname" (dict "ctx" $ "agent" $name) }} \ --from-literal=discord-bot-token="YOUR_DISCORD_TOKEN" {{- end }} -{{- if $cfg.discord.botToken }} +{{- if and ($cfg.discord).enabled ($cfg.discord).botToken }} Discord: ✅ configured {{- end }} {{- if and ($cfg.slack).enabled ($cfg.slack).botToken }} diff --git a/charts/openab/templates/configmap.yaml b/charts/openab/templates/configmap.yaml index 91809dc9..fd681c56 100644 --- a/charts/openab/templates/configmap.yaml +++ b/charts/openab/templates/configmap.yaml @@ -10,7 +10,7 @@ metadata: {{- include "openab.labels" $d | nindent 4 }} data: config.toml: | - {{- if $cfg.discord.botToken }} + {{- if and ($cfg.discord).enabled ($cfg.discord).botToken }} [discord] bot_token = "${DISCORD_BOT_TOKEN}" {{- range $cfg.discord.allowedChannels }} diff --git a/charts/openab/templates/deployment.yaml b/charts/openab/templates/deployment.yaml index 80643968..963d6759 100644 --- a/charts/openab/templates/deployment.yaml +++ b/charts/openab/templates/deployment.yaml @@ -38,7 +38,7 @@ spec: {{- toYaml . | nindent 12 }} {{- end }} env: - {{- if $cfg.discord.botToken }} + {{- if and ($cfg.discord).enabled ($cfg.discord).botToken }} - name: DISCORD_BOT_TOKEN valueFrom: secretKeyRef: diff --git a/charts/openab/templates/secret.yaml b/charts/openab/templates/secret.yaml index e10269d7..d6907fd4 100644 --- a/charts/openab/templates/secret.yaml +++ b/charts/openab/templates/secret.yaml @@ -1,6 +1,6 @@ {{- range $name, $cfg := .Values.agents }} {{- if ne (include "openab.agentEnabled" $cfg) "false" }} -{{- $hasDiscord := $cfg.discord.botToken }} +{{- $hasDiscord := and ($cfg.discord).enabled ($cfg.discord).botToken }} {{- $hasSlack := and ($cfg.slack).enabled (or ($cfg.slack).botToken ($cfg.slack).appToken) }} {{- $hasStt := and ($cfg.stt).enabled ($cfg.stt).apiKey }} {{- if or $hasDiscord $hasSlack $hasStt }} diff --git a/charts/openab/values.yaml b/charts/openab/values.yaml index 30a491a1..05c7ce7f 100644 --- a/charts/openab/values.yaml +++ b/charts/openab/values.yaml @@ -54,6 +54,7 @@ agents: - acp - --trust-all-tools discord: + enabled: true botToken: "" # ⚠️ Use --set-string for channel IDs to avoid float64 precision loss allowedChannels: From 15602067c102e42a4c243158f4e37b05b25ed185 Mon Sep 17 00:00:00 2001 From: dogzzdogzz Date: Mon, 13 Apr 2026 16:53:28 +0800 Subject: [PATCH 08/16] feat(helm): add global labels/annotations and per-agent podLabels/podAnnotations - Add global labels/annotations in values.yaml, applied to all resources (Deployment, ConfigMap, Secret, PVC) via _helpers.tpl - Add per-agent podLabels/podAnnotations, applied to pod template in Deployment - Useful for team/cost-center labels, Prometheus scrape annotations, etc. Co-Authored-By: Claude Opus 4.6 (1M context) --- charts/openab/templates/_helpers.tpl | 9 +++++++++ charts/openab/templates/configmap.yaml | 4 ++++ charts/openab/templates/deployment.yaml | 10 ++++++++++ charts/openab/templates/pvc.yaml | 4 ++++ charts/openab/templates/secret.yaml | 3 +++ charts/openab/values.yaml | 6 ++++++ 6 files changed, 36 insertions(+) diff --git a/charts/openab/templates/_helpers.tpl b/charts/openab/templates/_helpers.tpl index 770d557a..7886595a 100644 --- a/charts/openab/templates/_helpers.tpl +++ b/charts/openab/templates/_helpers.tpl @@ -28,6 +28,15 @@ app.kubernetes.io/component: {{ .agent }} app.kubernetes.io/version: {{ .ctx.Chart.AppVersion | quote }} {{- end }} app.kubernetes.io/managed-by: {{ .ctx.Release.Service }} +{{- with .ctx.Values.labels }} +{{ toYaml . }} +{{- end }} +{{- end }} + +{{- define "openab.annotations" -}} +{{- with .ctx.Values.annotations }} +{{ toYaml . }} +{{- end }} {{- end }} {{- define "openab.selectorLabels" -}} diff --git a/charts/openab/templates/configmap.yaml b/charts/openab/templates/configmap.yaml index fd681c56..0e991390 100644 --- a/charts/openab/templates/configmap.yaml +++ b/charts/openab/templates/configmap.yaml @@ -8,6 +8,10 @@ metadata: name: {{ include "openab.agentFullname" $d }} labels: {{- include "openab.labels" $d | nindent 4 }} + {{- with (include "openab.annotations" $d) }} + annotations: + {{- . | nindent 4 }} + {{- end }} data: config.toml: | {{- if and ($cfg.discord).enabled ($cfg.discord).botToken }} diff --git a/charts/openab/templates/deployment.yaml b/charts/openab/templates/deployment.yaml index 963d6759..d2a0f075 100644 --- a/charts/openab/templates/deployment.yaml +++ b/charts/openab/templates/deployment.yaml @@ -9,6 +9,10 @@ metadata: name: {{ include "openab.agentFullname" $d }} labels: {{- include "openab.labels" $d | nindent 4 }} + {{- with (include "openab.annotations" $d) }} + annotations: + {{- . | nindent 4 }} + {{- end }} spec: # Hardcoded for PVC-backed agents: RWO volumes can't be shared across pods, # so rolling updates and multiple replicas are not supported. @@ -22,8 +26,14 @@ spec: metadata: annotations: checksum/config: {{ $cfg | toJson | sha256sum }} + {{- with $cfg.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} labels: {{- include "openab.selectorLabels" $d | nindent 8 }} + {{- with $cfg.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} spec: {{- with $.Values.podSecurityContext }} securityContext: diff --git a/charts/openab/templates/pvc.yaml b/charts/openab/templates/pvc.yaml index e771e608..f3390689 100644 --- a/charts/openab/templates/pvc.yaml +++ b/charts/openab/templates/pvc.yaml @@ -9,6 +9,10 @@ metadata: name: {{ include "openab.agentFullname" $d }} labels: {{- include "openab.labels" $d | nindent 4 }} + {{- with (include "openab.annotations" $d) }} + annotations: + {{- . | nindent 4 }} + {{- end }} spec: accessModes: - ReadWriteOnce diff --git a/charts/openab/templates/secret.yaml b/charts/openab/templates/secret.yaml index d6907fd4..4a7db922 100644 --- a/charts/openab/templates/secret.yaml +++ b/charts/openab/templates/secret.yaml @@ -14,6 +14,9 @@ metadata: {{- include "openab.labels" $d | nindent 4 }} annotations: "helm.sh/resource-policy": keep + {{- with (include "openab.annotations" $d) }} + {{- . | nindent 4 }} + {{- end }} type: Opaque data: {{- if $hasDiscord }} diff --git a/charts/openab/values.yaml b/charts/openab/values.yaml index 05c7ce7f..de8c78ba 100644 --- a/charts/openab/values.yaml +++ b/charts/openab/values.yaml @@ -16,6 +16,10 @@ containerSecurityContext: drop: - ALL +# Global labels/annotations applied to all resources +labels: {} +annotations: {} + agents: kiro: enabled: true # set to false to skip creating resources for this agent @@ -86,6 +90,8 @@ agents: storageClass: "" size: 1Gi # defaults to 1Gi if not set agentsMd: "" + podLabels: {} + podAnnotations: {} resources: {} nodeSelector: {} tolerations: [] From 74387eb7102cdd94b12a92efa367d573b6cbe18e Mon Sep 17 00:00:00 2001 From: dogzzdogzz Date: Mon, 13 Apr 2026 21:27:36 +0800 Subject: [PATCH 09/16] fix(slack): allow file_share subtype in thread follow-ups Slack sends message events with subtype "file_share" when users attach files in threads. The previous code blocked all subtypes, which prevented image/audio attachments in thread follow-ups from being processed. Now only skip subtypes that are truly non-user messages (edits, deletes, joins, leaves). Added debug logging for message event routing. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/slack.rs | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/slack.rs b/src/slack.rs index 8f4e2e82..84fe6ed2 100644 --- a/src/slack.rs +++ b/src/slack.rs @@ -257,12 +257,26 @@ pub async fn run_slack_adapter( } "message" => { // Handle thread follow-ups without @mention. - // Skip bot messages and subtypes (join/leave/edits). + // Skip bot messages and subtypes that aren't real user messages. let has_thread = event["thread_ts"].is_string(); let is_bot = event["bot_id"].is_string() || event["subtype"].as_str() == Some("bot_message"); - let has_subtype = event["subtype"].is_string(); - if has_thread && !is_bot && !has_subtype { + let subtype = event["subtype"].as_str().unwrap_or(""); + let has_files = event["files"].is_array(); + debug!( + has_thread, + is_bot, + subtype, + has_files, + text = event["text"].as_str().unwrap_or(""), + "message event received" + ); + let skip_subtype = matches!(subtype, + "message_changed" | "message_deleted" | + "channel_join" | "channel_leave" | + "channel_topic" | "channel_purpose" + ); + if has_thread && !is_bot && !skip_subtype { handle_message( event, false, From 7811e768cf26da0d1d31fddc675e17e7efd49ffa Mon Sep 17 00:00:00 2001 From: dogzzdogzz Date: Tue, 14 Apr 2026 09:11:37 +0800 Subject: [PATCH 10/16] fix: address PR review feedback - DiscordAdapter: initialize once via OnceLock instead of per-message - Slack dedup: skip message events that @mention the bot (app_mention handles those) using bot user ID from auth.test API - Slack graceful shutdown: watch channel + tokio::select! + WebSocket Close frame, with 5s timeout in main.rs - Cache users.info results for 5 minutes to avoid Slack rate limits - Document unicode_to_slack_emoji fallback behavior - Document discord.enabled + empty botToken behavior in values.yaml Co-Authored-By: Claude Opus 4.6 (1M context) --- charts/openab/values.yaml | 2 +- src/discord.rs | 7 +- src/main.rs | 10 +- src/slack.rs | 229 ++++++++++++++++++++++++-------------- 4 files changed, 162 insertions(+), 86 deletions(-) diff --git a/charts/openab/values.yaml b/charts/openab/values.yaml index de8c78ba..10ff2459 100644 --- a/charts/openab/values.yaml +++ b/charts/openab/values.yaml @@ -58,7 +58,7 @@ agents: - acp - --trust-all-tools discord: - enabled: true + enabled: true # set to false to disable; enabled with empty botToken is treated as disabled botToken: "" # ⚠️ Use --set-string for channel IDs to avoid float64 precision loss allowedChannels: diff --git a/src/discord.rs b/src/discord.rs index 8b16db00..1bf3c990 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -12,7 +12,7 @@ use serenity::model::gateway::Ready; use serenity::model::id::{ChannelId, MessageId}; use serenity::prelude::*; use std::collections::HashSet; -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; use tracing::{debug, error, info}; // --- DiscordAdapter: implements ChatAdapter for Discord via serenity --- @@ -116,6 +116,7 @@ pub struct Handler { pub allowed_channels: HashSet, pub allowed_users: HashSet, pub stt_config: SttConfig, + pub adapter: OnceLock>, } #[serenity::async_trait] @@ -125,7 +126,9 @@ impl EventHandler for Handler { return; } - let adapter: Arc = Arc::new(DiscordAdapter::new(ctx.http.clone())); + let adapter = self.adapter.get_or_init(|| { + Arc::new(DiscordAdapter::new(ctx.http.clone())) + }).clone(); let bot_id = ctx.cache.current_user().id; let channel_id = msg.channel_id.get(); diff --git a/src/main.rs b/src/main.rs index f4067f6d..578d5ef6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -74,6 +74,9 @@ async fn main() -> anyhow::Result<()> { } }); + // Shutdown signal for Slack adapter + let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false); + // Spawn Slack adapter (background task) let slack_handle = if let Some(slack_cfg) = cfg.slack { info!( @@ -91,6 +94,7 @@ async fn main() -> anyhow::Result<()> { slack_cfg.allowed_users.into_iter().collect(), stt, router, + shutdown_rx, ) .await { @@ -117,6 +121,7 @@ async fn main() -> anyhow::Result<()> { allowed_channels, allowed_users, stt_config: cfg.stt.clone(), + adapter: std::sync::OnceLock::new(), }; let intents = GatewayIntents::GUILD_MESSAGES @@ -146,8 +151,11 @@ async fn main() -> anyhow::Result<()> { // Cleanup cleanup_handle.abort(); + // Signal Slack adapter to shut down gracefully + let _ = shutdown_tx.send(true); if let Some(handle) = slack_handle { - handle.abort(); + // Give Slack adapter time to close WebSocket cleanly + let _ = tokio::time::timeout(std::time::Duration::from_secs(5), handle).await; } let shutdown_pool = pool; shutdown_pool.shutdown().await; diff --git a/src/slack.rs b/src/slack.rs index 84fe6ed2..e95c0d7b 100644 --- a/src/slack.rs +++ b/src/slack.rs @@ -5,14 +5,17 @@ use crate::media; use anyhow::{anyhow, Result}; use async_trait::async_trait; use futures_util::{SinkExt, StreamExt}; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::sync::{Arc, LazyLock}; +use tokio::sync::watch; use tokio_tungstenite::tungstenite; use tracing::{debug, error, info, warn}; const SLACK_API: &str = "https://slack.com/api"; /// Map Unicode emoji to Slack short names for reactions API. +/// Only covers the default `[reactions.emojis]` set. Custom emoji configured +/// outside this map will fall back to `grey_question`. fn unicode_to_slack_emoji(unicode: &str) -> &str { match unicode { "👀" => "eyes", @@ -42,9 +45,14 @@ fn unicode_to_slack_emoji(unicode: &str) -> &str { // --- SlackAdapter: implements ChatAdapter for Slack --- +/// TTL for cached user display names (5 minutes). +const USER_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(300); + pub struct SlackAdapter { client: reqwest::Client, bot_token: String, + bot_user_id: tokio::sync::OnceCell, + user_cache: tokio::sync::Mutex>, } impl SlackAdapter { @@ -52,9 +60,23 @@ impl SlackAdapter { Self { client: reqwest::Client::new(), bot_token, + bot_user_id: tokio::sync::OnceCell::new(), + user_cache: tokio::sync::Mutex::new(HashMap::new()), } } + /// Get the bot's own Slack user ID (cached after first call). + async fn get_bot_user_id(&self) -> Option<&str> { + self.bot_user_id.get_or_try_init(|| async { + let resp = self.api_post("auth.test", serde_json::json!({})).await + .map_err(|e| anyhow!("auth.test failed: {e}"))?; + resp["user_id"] + .as_str() + .map(|s| s.to_string()) + .ok_or_else(|| anyhow!("no user_id in auth.test response")) + }).await.ok().map(|s| s.as_str()) + } + async fn api_post(&self, method: &str, body: serde_json::Value) -> Result { let resp = self .client @@ -74,7 +96,18 @@ impl SlackAdapter { } /// Resolve a Slack user ID to display name via users.info API. + /// Results are cached for 5 minutes to avoid hitting Slack rate limits. async fn resolve_user_name(&self, user_id: &str) -> Option { + // Check cache first + { + let cache = self.user_cache.lock().await; + if let Some((name, ts)) = cache.get(user_id) { + if ts.elapsed() < USER_CACHE_TTL { + return Some(name.clone()); + } + } + } + let resp = self .api_post( "users.info", @@ -83,7 +116,6 @@ impl SlackAdapter { .await .ok()?; let user = resp.get("user")?; - // Prefer display_name from profile, fallback to real_name, then name let profile = user.get("profile")?; let display = profile .get("display_name") @@ -96,7 +128,15 @@ impl SlackAdapter { let name = user .get("name") .and_then(|v| v.as_str()); - Some(display.or(real).or(name)?.to_string()) + let resolved = display.or(real).or(name)?.to_string(); + + // Cache the result + self.user_cache.lock().await.insert( + user_id.to_string(), + (resolved.clone(), tokio::time::Instant::now()), + ); + + Some(resolved) } } @@ -201,10 +241,17 @@ pub async fn run_slack_adapter( allowed_users: HashSet, stt_config: SttConfig, router: Arc, + mut shutdown_rx: watch::Receiver, ) -> Result<()> { let adapter = Arc::new(SlackAdapter::new(bot_token.clone())); loop { + // Check for shutdown before (re)connecting + if *shutdown_rx.borrow() { + info!("Slack adapter shutting down"); + return Ok(()); + } + let ws_url = match get_socket_mode_url(&app_token).await { Ok(url) => url, Err(e) => { @@ -220,92 +267,110 @@ pub async fn run_slack_adapter( info!("Slack Socket Mode connected"); let (mut write, mut read) = ws_stream.split(); - while let Some(msg_result) = read.next().await { - match msg_result { - Ok(tungstenite::Message::Text(text)) => { - let envelope: serde_json::Value = - match serde_json::from_str(&text) { - Ok(v) => v, - Err(_) => continue, - }; - - // Acknowledge the envelope immediately - if let Some(envelope_id) = envelope["envelope_id"].as_str() { - let ack = serde_json::json!({"envelope_id": envelope_id}); - let _ = write - .send(tungstenite::Message::Text(ack.to_string())) - .await; - } - - // Route events - if envelope["type"].as_str() == Some("events_api") { - let event = &envelope["payload"]["event"]; - let event_type = event["type"].as_str().unwrap_or(""); - match event_type { - "app_mention" => { - handle_message( - event, - true, - &adapter, - &bot_token, - &allowed_channels, - &allowed_users, - &stt_config, - &router, - ) - .await; - } - "message" => { - // Handle thread follow-ups without @mention. - // Skip bot messages and subtypes that aren't real user messages. - let has_thread = event["thread_ts"].is_string(); - let is_bot = event["bot_id"].is_string() - || event["subtype"].as_str() == Some("bot_message"); - let subtype = event["subtype"].as_str().unwrap_or(""); - let has_files = event["files"].is_array(); - debug!( - has_thread, - is_bot, - subtype, - has_files, - text = event["text"].as_str().unwrap_or(""), - "message event received" - ); - let skip_subtype = matches!(subtype, - "message_changed" | "message_deleted" | - "channel_join" | "channel_leave" | - "channel_topic" | "channel_purpose" - ); - if has_thread && !is_bot && !skip_subtype { - handle_message( - event, - false, - &adapter, - &bot_token, - &allowed_channels, - &allowed_users, - &stt_config, - &router, - ) + loop { + tokio::select! { + msg_result = read.next() => { + let Some(msg_result) = msg_result else { break }; + match msg_result { + Ok(tungstenite::Message::Text(text)) => { + let envelope: serde_json::Value = + match serde_json::from_str(&text) { + Ok(v) => v, + Err(_) => continue, + }; + + // Acknowledge the envelope immediately + if let Some(envelope_id) = envelope["envelope_id"].as_str() { + let ack = serde_json::json!({"envelope_id": envelope_id}); + let _ = write + .send(tungstenite::Message::Text(ack.to_string())) .await; + } + + // Route events + if envelope["type"].as_str() == Some("events_api") { + let event = &envelope["payload"]["event"]; + let event_type = event["type"].as_str().unwrap_or(""); + match event_type { + "app_mention" => { + handle_message( + event, + true, + &adapter, + &bot_token, + &allowed_channels, + &allowed_users, + &stt_config, + &router, + ) + .await; + } + "message" => { + // Handle thread follow-ups without @mention. + // Skip bot messages and subtypes that aren't real user messages. + let has_thread = event["thread_ts"].is_string(); + let is_bot = event["bot_id"].is_string() + || event["subtype"].as_str() == Some("bot_message"); + let subtype = event["subtype"].as_str().unwrap_or(""); + let has_files = event["files"].is_array(); + // Skip messages that @mention the bot — app_mention handles those + let msg_text = event["text"].as_str().unwrap_or(""); + let mentions_bot = if let Some(bot_id) = adapter.get_bot_user_id().await { + msg_text.contains(&format!("<@{bot_id}>")) + } else { + false + }; + debug!( + has_thread, + is_bot, + subtype, + has_files, + mentions_bot, + text = msg_text, + "message event received" + ); + let skip_subtype = matches!(subtype, + "message_changed" | "message_deleted" | + "channel_join" | "channel_leave" | + "channel_topic" | "channel_purpose" + ); + if has_thread && !is_bot && !skip_subtype && !mentions_bot { + handle_message( + event, + false, + &adapter, + &bot_token, + &allowed_channels, + &allowed_users, + &stt_config, + &router, + ) + .await; + } + } + _ => {} } } - _ => {} } + Ok(tungstenite::Message::Ping(data)) => { + let _ = write.send(tungstenite::Message::Pong(data)).await; + } + Ok(tungstenite::Message::Close(_)) => { + warn!("Slack Socket Mode connection closed by server"); + break; + } + Err(e) => { + error!("Socket Mode read error: {e}"); + break; + } + _ => {} } } - Ok(tungstenite::Message::Ping(data)) => { - let _ = write.send(tungstenite::Message::Pong(data)).await; - } - Ok(tungstenite::Message::Close(_)) => { - warn!("Slack Socket Mode connection closed by server"); - break; - } - Err(e) => { - error!("Socket Mode read error: {e}"); - break; + _ = shutdown_rx.changed() => { + info!("Slack adapter received shutdown signal"); + let _ = write.send(tungstenite::Message::Close(None)).await; + return Ok(()); } - _ => {} } } } From 4a159dcd56a70175d63120100767f33ba3543189 Mon Sep 17 00:00:00 2001 From: dogzzdogzz Date: Tue, 14 Apr 2026 11:00:12 +0800 Subject: [PATCH 11/16] fix: resolve clippy errors (too_many_arguments, unnecessary_map_or) - Allow clippy::too_many_arguments on handle_message (8 params, all distinct) - Replace map_or with is_some_and for has_files check Co-Authored-By: Claude Opus 4.6 (1M context) --- src/slack.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/slack.rs b/src/slack.rs index e95c0d7b..1085b11a 100644 --- a/src/slack.rs +++ b/src/slack.rs @@ -404,6 +404,7 @@ async fn get_socket_mode_url(app_token: &str) -> Result { .ok_or_else(|| anyhow!("no url in apps.connections.open response")) } +#[allow(clippy::too_many_arguments)] async fn handle_message( event: &serde_json::Value, is_mention: bool, @@ -462,7 +463,7 @@ async fn handle_message( // Process file attachments (images, audio) let files = event["files"].as_array(); - let has_files = files.map_or(false, |f| !f.is_empty()); + let has_files = files.is_some_and(|f| !f.is_empty()); if prompt.is_empty() && !has_files { return; From 37b627581fab52c65211a3b09d428872551c588c Mon Sep 17 00:00:00 2001 From: dogzzdogzz Date: Tue, 14 Apr 2026 12:31:04 +0800 Subject: [PATCH 12/16] feat(slack): convert Markdown to Slack mrkdwn format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code outputs standard Markdown which Discord renders natively but Slack does not. Add markdown_to_mrkdwn() conversion in SlackAdapter: - **bold** → *bold* (Slack bold) - *italic* → _italic_ (Slack italic) - [text](url) → (Slack links) - # heading → *heading* (bold as substitute, Slack has no headings) - ```lang → ``` (strip language hints from code blocks) Applied in send_message and edit_message. Discord adapter unchanged (Markdown works natively). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/slack.rs | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/src/slack.rs b/src/slack.rs index 1085b11a..695f55f7 100644 --- a/src/slack.rs +++ b/src/slack.rs @@ -151,9 +151,10 @@ impl ChatAdapter for SlackAdapter { } async fn send_message(&self, channel: &ChannelRef, content: &str) -> Result { + let mrkdwn = markdown_to_mrkdwn(content); let mut body = serde_json::json!({ "channel": channel.channel_id, - "text": content, + "text": mrkdwn, }); if let Some(thread_ts) = &channel.thread_id { body["thread_ts"] = serde_json::Value::String(thread_ts.clone()); @@ -174,12 +175,13 @@ impl ChatAdapter for SlackAdapter { } async fn edit_message(&self, msg: &MessageRef, content: &str) -> Result<()> { + let mrkdwn = markdown_to_mrkdwn(content); self.api_post( "chat.update", serde_json::json!({ "channel": msg.channel.channel_id, "ts": msg.message_id, - "text": content, + "text": mrkdwn, }), ) .await?; @@ -565,3 +567,27 @@ static SLACK_MENTION_RE: LazyLock = fn strip_slack_mention(text: &str) -> String { SLACK_MENTION_RE.replace_all(text, "").trim().to_string() } + +/// Convert Markdown (as output by Claude Code) to Slack mrkdwn format. +fn markdown_to_mrkdwn(text: &str) -> String { + static BOLD_RE: LazyLock = + LazyLock::new(|| regex::Regex::new(r"\*\*(.+?)\*\*").unwrap()); + static ITALIC_RE: LazyLock = + LazyLock::new(|| regex::Regex::new(r"\*([^*]+?)\*").unwrap()); + static LINK_RE: LazyLock = + LazyLock::new(|| regex::Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").unwrap()); + static HEADING_RE: LazyLock = + LazyLock::new(|| regex::Regex::new(r"(?m)^#{1,6}\s+(.+)$").unwrap()); + static CODE_BLOCK_LANG_RE: LazyLock = + LazyLock::new(|| regex::Regex::new(r"```\w+\n").unwrap()); + + // Order: bold first (** → placeholder), then italic (* → _), then restore bold + let text = BOLD_RE.replace_all(text, "\x01$1\x02"); // **bold** → \x01bold\x02 + let text = ITALIC_RE.replace_all(&text, "_${1}_"); // *italic* → _italic_ + // Restore bold: \x01bold\x02 → *bold* + let text = text.replace(['\x01', '\x02'], "*"); + let text = LINK_RE.replace_all(&text, "<$2|$1>"); // [text](url) → + let text = HEADING_RE.replace_all(&text, "*$1*"); // # heading → *heading* + let text = CODE_BLOCK_LANG_RE.replace_all(&text, "```\n"); // ```rust → ``` + text.into_owned() +} From a7c5f50d96a6a2c5385c81a8a354738065ecb23d Mon Sep 17 00:00:00 2001 From: dogzzdogzz Date: Tue, 14 Apr 2026 16:07:11 +0800 Subject: [PATCH 13/16] feat(helm): support automountServiceAccountToken in deployment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optional field — only rendered when explicitly set in values. No default value, fully backward compatible. Allows disabling service account token mount for security hardening. Co-Authored-By: Claude Opus 4.6 (1M context) --- charts/openab/templates/deployment.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/charts/openab/templates/deployment.yaml b/charts/openab/templates/deployment.yaml index d2a0f075..4940f34c 100644 --- a/charts/openab/templates/deployment.yaml +++ b/charts/openab/templates/deployment.yaml @@ -35,6 +35,9 @@ spec: {{- toYaml . | nindent 8 }} {{- end }} spec: + {{- if hasKey $.Values "automountServiceAccountToken" }} + automountServiceAccountToken: {{ $.Values.automountServiceAccountToken }} + {{- end }} {{- with $.Values.podSecurityContext }} securityContext: {{- toYaml . | nindent 8 }} From bb34e4556b7b7f96a28511040029ec7ff54798f5 Mon Sep 17 00:00:00 2001 From: dogzzdogzz Date: Thu, 16 Apr 2026 08:46:56 +0800 Subject: [PATCH 14/16] revert(helm): remove labels/annotations/automountServiceAccountToken Revert non-Slack Helm chart additions to reduce scope: - Remove global labels/annotations from values.yaml and _helpers.tpl - Remove podLabels/podAnnotations from values.yaml and deployment.yaml - Remove automountServiceAccountToken from deployment.yaml These can be added in a separate Helm optimization PR. Co-Authored-By: Claude Opus 4.6 (1M context) --- charts/openab/templates/_helpers.tpl | 9 --------- charts/openab/templates/configmap.yaml | 4 ---- charts/openab/templates/deployment.yaml | 13 ------------- charts/openab/templates/pvc.yaml | 4 ---- charts/openab/templates/secret.yaml | 3 --- charts/openab/values.yaml | 6 ------ 6 files changed, 39 deletions(-) diff --git a/charts/openab/templates/_helpers.tpl b/charts/openab/templates/_helpers.tpl index 7886595a..770d557a 100644 --- a/charts/openab/templates/_helpers.tpl +++ b/charts/openab/templates/_helpers.tpl @@ -28,15 +28,6 @@ app.kubernetes.io/component: {{ .agent }} app.kubernetes.io/version: {{ .ctx.Chart.AppVersion | quote }} {{- end }} app.kubernetes.io/managed-by: {{ .ctx.Release.Service }} -{{- with .ctx.Values.labels }} -{{ toYaml . }} -{{- end }} -{{- end }} - -{{- define "openab.annotations" -}} -{{- with .ctx.Values.annotations }} -{{ toYaml . }} -{{- end }} {{- end }} {{- define "openab.selectorLabels" -}} diff --git a/charts/openab/templates/configmap.yaml b/charts/openab/templates/configmap.yaml index 539d1d4d..d90c3c5c 100644 --- a/charts/openab/templates/configmap.yaml +++ b/charts/openab/templates/configmap.yaml @@ -8,10 +8,6 @@ metadata: name: {{ include "openab.agentFullname" $d }} labels: {{- include "openab.labels" $d | nindent 4 }} - {{- with (include "openab.annotations" $d) }} - annotations: - {{- . | nindent 4 }} - {{- end }} data: config.toml: | {{- if and ($cfg.discord).enabled ($cfg.discord).botToken }} diff --git a/charts/openab/templates/deployment.yaml b/charts/openab/templates/deployment.yaml index 4940f34c..963d6759 100644 --- a/charts/openab/templates/deployment.yaml +++ b/charts/openab/templates/deployment.yaml @@ -9,10 +9,6 @@ metadata: name: {{ include "openab.agentFullname" $d }} labels: {{- include "openab.labels" $d | nindent 4 }} - {{- with (include "openab.annotations" $d) }} - annotations: - {{- . | nindent 4 }} - {{- end }} spec: # Hardcoded for PVC-backed agents: RWO volumes can't be shared across pods, # so rolling updates and multiple replicas are not supported. @@ -26,18 +22,9 @@ spec: metadata: annotations: checksum/config: {{ $cfg | toJson | sha256sum }} - {{- with $cfg.podAnnotations }} - {{- toYaml . | nindent 8 }} - {{- end }} labels: {{- include "openab.selectorLabels" $d | nindent 8 }} - {{- with $cfg.podLabels }} - {{- toYaml . | nindent 8 }} - {{- end }} spec: - {{- if hasKey $.Values "automountServiceAccountToken" }} - automountServiceAccountToken: {{ $.Values.automountServiceAccountToken }} - {{- end }} {{- with $.Values.podSecurityContext }} securityContext: {{- toYaml . | nindent 8 }} diff --git a/charts/openab/templates/pvc.yaml b/charts/openab/templates/pvc.yaml index f3390689..e771e608 100644 --- a/charts/openab/templates/pvc.yaml +++ b/charts/openab/templates/pvc.yaml @@ -9,10 +9,6 @@ metadata: name: {{ include "openab.agentFullname" $d }} labels: {{- include "openab.labels" $d | nindent 4 }} - {{- with (include "openab.annotations" $d) }} - annotations: - {{- . | nindent 4 }} - {{- end }} spec: accessModes: - ReadWriteOnce diff --git a/charts/openab/templates/secret.yaml b/charts/openab/templates/secret.yaml index 4a7db922..d6907fd4 100644 --- a/charts/openab/templates/secret.yaml +++ b/charts/openab/templates/secret.yaml @@ -14,9 +14,6 @@ metadata: {{- include "openab.labels" $d | nindent 4 }} annotations: "helm.sh/resource-policy": keep - {{- with (include "openab.annotations" $d) }} - {{- . | nindent 4 }} - {{- end }} type: Opaque data: {{- if $hasDiscord }} diff --git a/charts/openab/values.yaml b/charts/openab/values.yaml index 1bddcf0c..4fa46aec 100644 --- a/charts/openab/values.yaml +++ b/charts/openab/values.yaml @@ -16,10 +16,6 @@ containerSecurityContext: drop: - ALL -# Global labels/annotations applied to all resources -labels: {} -annotations: {} - agents: kiro: enabled: true # set to false to skip creating resources for this agent @@ -127,8 +123,6 @@ agents: storageClass: "" size: 1Gi # defaults to 1Gi if not set agentsMd: "" - podLabels: {} - podAnnotations: {} resources: {} nodeSelector: {} tolerations: [] From f641becdec58f104991d6d991c4690caee252b04 Mon Sep 17 00:00:00 2001 From: thepagent <262003297+thepagent@users.noreply.github.com> Date: Wed, 15 Apr 2026 21:55:07 -0400 Subject: [PATCH 15/16] fix: restore streaming tool collapse + spawn Slack handle_message - Restore compose_display streaming: bool parameter with TOOL_COLLAPSE_THRESHOLD=3 to prevent tool lines from consuming excessive message budget during streaming (Discord compatibility) - Spawn handle_message in Slack event loop instead of awaiting inline to prevent blocking the WebSocket read loop during long ACP sessions --- src/adapter.rs | 55 ++++++++++++++++++++++++++++++++++++-------- src/slack.rs | 62 ++++++++++++++++++++++++++++++++------------------ 2 files changed, 86 insertions(+), 31 deletions(-) diff --git a/src/adapter.rs b/src/adapter.rs index 753bb9ec..2c2d0962 100644 --- a/src/adapter.rs +++ b/src/adapter.rs @@ -289,7 +289,7 @@ impl AdapterRouter { } text_buf.push_str(&t); let _ = - buf_tx.send(compose_display(&tool_lines, &text_buf)); + buf_tx.send(compose_display(&tool_lines, &text_buf, true)); } AcpEvent::Thinking => { reactions.set_thinking().await; @@ -308,7 +308,7 @@ impl AdapterRouter { }); } let _ = - buf_tx.send(compose_display(&tool_lines, &text_buf)); + buf_tx.send(compose_display(&tool_lines, &text_buf, true)); } AcpEvent::ToolDone { id, title, status } => { reactions.set_thinking().await; @@ -330,7 +330,7 @@ impl AdapterRouter { }); } let _ = - buf_tx.send(compose_display(&tool_lines, &text_buf)); + buf_tx.send(compose_display(&tool_lines, &text_buf, true)); } _ => {} } @@ -342,7 +342,7 @@ impl AdapterRouter { let _ = edit_handle.await; // Final edit with complete content - let final_content = compose_display(&tool_lines, &text_buf); + let final_content = compose_display(&tool_lines, &text_buf, false); let final_content = if final_content.is_empty() { if let Some(err) = response_error { format!("⚠️ {err}") @@ -405,14 +405,51 @@ impl ToolEntry { } } -fn compose_display(tool_lines: &[ToolEntry], text: &str) -> String { +/// Maximum number of finished tool entries to show individually +/// during streaming before collapsing into a summary line. +const TOOL_COLLAPSE_THRESHOLD: usize = 3; + +fn compose_display(tool_lines: &[ToolEntry], text: &str, streaming: bool) -> String { let mut out = String::new(); if !tool_lines.is_empty() { - for entry in tool_lines { - out.push_str(&entry.render()); - out.push('\n'); + if streaming { + let done = tool_lines.iter().filter(|e| e.state == ToolState::Completed).count(); + let failed = tool_lines.iter().filter(|e| e.state == ToolState::Failed).count(); + let running: Vec<_> = tool_lines.iter().filter(|e| e.state == ToolState::Running).collect(); + let finished = done + failed; + + if finished <= TOOL_COLLAPSE_THRESHOLD { + for entry in tool_lines.iter().filter(|e| e.state != ToolState::Running) { + out.push_str(&entry.render()); + out.push('\n'); + } + } else { + let mut parts = Vec::new(); + if done > 0 { parts.push(format!("✅ {done}")); } + if failed > 0 { parts.push(format!("❌ {failed}")); } + out.push_str(&format!("{} tool(s) completed\n", parts.join(" · "))); + } + + if running.len() <= TOOL_COLLAPSE_THRESHOLD { + for entry in &running { + out.push_str(&entry.render()); + out.push('\n'); + } + } else { + let hidden = running.len() - TOOL_COLLAPSE_THRESHOLD; + out.push_str(&format!("🔧 {hidden} more running\n")); + for entry in running.iter().skip(hidden) { + out.push_str(&entry.render()); + out.push('\n'); + } + } + } else { + for entry in tool_lines { + out.push_str(&entry.render()); + out.push('\n'); + } } - out.push('\n'); + if !out.is_empty() { out.push('\n'); } } out.push_str(text.trim_end()); out diff --git a/src/slack.rs b/src/slack.rs index 695f55f7..8b49ad85 100644 --- a/src/slack.rs +++ b/src/slack.rs @@ -295,17 +295,26 @@ pub async fn run_slack_adapter( let event_type = event["type"].as_str().unwrap_or(""); match event_type { "app_mention" => { - handle_message( - event, - true, - &adapter, - &bot_token, - &allowed_channels, - &allowed_users, - &stt_config, - &router, - ) - .await; + let event = event.clone(); + let adapter = adapter.clone(); + let bot_token = bot_token.clone(); + let allowed_channels = allowed_channels.clone(); + let allowed_users = allowed_users.clone(); + let stt_config = stt_config.clone(); + let router = router.clone(); + tokio::spawn(async move { + handle_message( + &event, + true, + &adapter, + &bot_token, + &allowed_channels, + &allowed_users, + &stt_config, + &router, + ) + .await; + }); } "message" => { // Handle thread follow-ups without @mention. @@ -337,17 +346,26 @@ pub async fn run_slack_adapter( "channel_topic" | "channel_purpose" ); if has_thread && !is_bot && !skip_subtype && !mentions_bot { - handle_message( - event, - false, - &adapter, - &bot_token, - &allowed_channels, - &allowed_users, - &stt_config, - &router, - ) - .await; + let event = event.clone(); + let adapter = adapter.clone(); + let bot_token = bot_token.clone(); + let allowed_channels = allowed_channels.clone(); + let allowed_users = allowed_users.clone(); + let stt_config = stt_config.clone(); + let router = router.clone(); + tokio::spawn(async move { + handle_message( + &event, + false, + &adapter, + &bot_token, + &allowed_channels, + &allowed_users, + &stt_config, + &router, + ) + .await; + }); } } _ => {} From 4684c6a58211e4f94cb2b5fa873eff5c77538bbf Mon Sep 17 00:00:00 2001 From: thepagent <262003297+thepagent@users.noreply.github.com> Date: Wed, 15 Apr 2026 22:07:46 -0400 Subject: [PATCH 16/16] fix: Slack bot-loop protection + silence already_reacted errors - Add is_bot_loop() check using conversations.replies: if the last MAX_CONSECUTIVE_BOT_TURNS (10) messages in a thread are all from bots, stop responding to prevent runaway loops. Only applies to thread follow-ups (not @mentions). Fail-open on API error. - Silently ignore already_reacted in add_reaction and no_reaction in remove_reaction instead of logging noisy errors. --- src/slack.rs | 60 ++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 54 insertions(+), 6 deletions(-) diff --git a/src/slack.rs b/src/slack.rs index 8b49ad85..9779e6d3 100644 --- a/src/slack.rs +++ b/src/slack.rs @@ -205,7 +205,7 @@ impl ChatAdapter for SlackAdapter { async fn add_reaction(&self, msg: &MessageRef, emoji: &str) -> Result<()> { let name = unicode_to_slack_emoji(emoji); - self.api_post( + match self.api_post( "reactions.add", serde_json::json!({ "channel": msg.channel.channel_id, @@ -213,13 +213,17 @@ impl ChatAdapter for SlackAdapter { "name": name, }), ) - .await?; - Ok(()) + .await + { + Ok(_) => Ok(()), + Err(e) if e.to_string().contains("already_reacted") => Ok(()), + Err(e) => Err(e), + } } async fn remove_reaction(&self, msg: &MessageRef, emoji: &str) -> Result<()> { let name = unicode_to_slack_emoji(emoji); - self.api_post( + match self.api_post( "reactions.remove", serde_json::json!({ "channel": msg.channel.channel_id, @@ -227,8 +231,12 @@ impl ChatAdapter for SlackAdapter { "name": name, }), ) - .await?; - Ok(()) + .await + { + Ok(_) => Ok(()), + Err(e) if e.to_string().contains("no_reaction") => Ok(()), + Err(e) => Err(e), + } } } @@ -478,6 +486,13 @@ async fn handle_message( let prompt = if is_mention { strip_slack_mention(&text) } else { + // Thread follow-up: check for bot loop before processing + if let Some(ref tts) = thread_ts { + if is_bot_loop(adapter, &channel_id, tts).await { + tracing::warn!(channel_id, thread_ts = tts, "bot loop detected, ignoring"); + return; + } + } text.trim().to_string() }; @@ -582,6 +597,39 @@ async fn handle_message( static SLACK_MENTION_RE: LazyLock = LazyLock::new(|| regex::Regex::new(r"<@[A-Z0-9]+>").unwrap()); +/// Hard cap on consecutive bot messages in a thread. +/// Mirrors Discord's MAX_CONSECUTIVE_BOT_TURNS to prevent runaway loops. +const MAX_CONSECUTIVE_BOT_TURNS: usize = 10; + +/// Check if the last N messages in a Slack thread are all from bots. +async fn is_bot_loop(adapter: &SlackAdapter, channel: &str, thread_ts: &str) -> bool { + let resp = adapter + .api_post( + "conversations.replies", + serde_json::json!({ + "channel": channel, + "ts": thread_ts, + "limit": MAX_CONSECUTIVE_BOT_TURNS + 1, + "inclusive": true, + }), + ) + .await; + + let Ok(json) = resp else { return false }; // fail-open on API error + let Some(messages) = json["messages"].as_array() else { return false }; + + // Skip the first message (thread parent), count consecutive bot messages from the end + let recent: Vec<_> = messages.iter().skip(1).rev().collect(); + if recent.len() < MAX_CONSECUTIVE_BOT_TURNS { + return false; + } + + recent + .iter() + .take(MAX_CONSECUTIVE_BOT_TURNS) + .all(|m| m["bot_id"].is_string() || m["subtype"].as_str() == Some("bot_message")) +} + fn strip_slack_mention(text: &str) -> String { SLACK_MENTION_RE.replace_all(text, "").trim().to_string() }