From dfcf24528b42ce43a41471b1348bb8d7b34f6508 Mon Sep 17 00:00:00 2001 From: Matheus Teixeira <707561+mtxr@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:29:57 -0300 Subject: [PATCH 1/8] feat(channel): add Slack Socket Mode plugin Hermes-style Slack integration for AionCore channels: - credentials: bot token (xoxb) + app_token (xapp) for Socket Mode - DM: always accept (pairing still enforced upstream) - channels/groups: only when listed in config.allowed_channels and @mentioned - empty allowlist means DM-only (safe default) - outbound via chat.postMessage / chat.update for streaming edits - chat_id is the Slack conversation id (same isolation model as Telegram) Includes unit tests for allowlist/mention policy and test-config mapping. --- crates/aionui-app/Cargo.toml | 3 +- crates/aionui-channel/Cargo.toml | 1 + crates/aionui-channel/src/constants.rs | 13 + crates/aionui-channel/src/manager.rs | 1 + crates/aionui-channel/src/plugin.rs | 1 + crates/aionui-channel/src/plugins/mod.rs | 6 + .../aionui-channel/src/plugins/slack/api.rs | 131 ++++ .../aionui-channel/src/plugins/slack/mod.rs | 5 + .../src/plugins/slack/plugin.rs | 578 ++++++++++++++++++ .../aionui-channel/src/plugins/slack/types.rs | 282 +++++++++ .../src/plugins/weixin/plugin.rs | 1 + crates/aionui-channel/src/routes.rs | 25 + crates/aionui-channel/src/types.rs | 17 +- .../tests/dingtalk_integration.rs | 1 + .../aionui-channel/tests/lark_integration.rs | 1 + .../tests/manager_integration.rs | 1 + .../tests/telegram_integration.rs | 1 + .../tests/weixin_integration.rs | 1 + 18 files changed, 1067 insertions(+), 2 deletions(-) create mode 100644 crates/aionui-channel/src/plugins/slack/api.rs create mode 100644 crates/aionui-channel/src/plugins/slack/mod.rs create mode 100644 crates/aionui-channel/src/plugins/slack/plugin.rs create mode 100644 crates/aionui-channel/src/plugins/slack/types.rs diff --git a/crates/aionui-app/Cargo.toml b/crates/aionui-app/Cargo.toml index 6f261b77d..a8e735c2d 100644 --- a/crates/aionui-app/Cargo.toml +++ b/crates/aionui-app/Cargo.toml @@ -9,11 +9,12 @@ name = "aioncore" path = "src/main.rs" [features] -default = ["telegram", "lark", "dingtalk", "weixin"] +default = ["telegram", "lark", "dingtalk", "weixin", "slack"] telegram = ["aionui-channel/telegram"] lark = ["aionui-channel/lark"] dingtalk = ["aionui-channel/dingtalk"] weixin = ["aionui-channel/weixin"] +slack = ["aionui-channel/slack"] [dependencies] aionui-common.workspace = true diff --git a/crates/aionui-channel/Cargo.toml b/crates/aionui-channel/Cargo.toml index a05104794..344a77b04 100644 --- a/crates/aionui-channel/Cargo.toml +++ b/crates/aionui-channel/Cargo.toml @@ -9,6 +9,7 @@ telegram = ["dep:reqwest"] lark = ["dep:reqwest", "dep:tokio-tungstenite", "dep:futures-util", "dep:prost", "dep:rustls", "dep:rustls-native-certs"] dingtalk = ["dep:reqwest", "dep:tokio-tungstenite", "dep:futures-util", "dep:rustls", "dep:rustls-native-certs"] weixin = ["dep:reqwest", "dep:futures-util", "dep:base64", "dep:uuid"] +slack = ["dep:reqwest", "dep:tokio-tungstenite", "dep:futures-util", "dep:rustls", "dep:rustls-native-certs"] [dependencies] aionui-common.workspace = true diff --git a/crates/aionui-channel/src/constants.rs b/crates/aionui-channel/src/constants.rs index f147c780e..e758c6834 100644 --- a/crates/aionui-channel/src/constants.rs +++ b/crates/aionui-channel/src/constants.rs @@ -37,6 +37,9 @@ pub const LARK_MESSAGE_LIMIT: usize = 4000; /// Maximum characters per DingTalk message. pub const DINGTALK_MESSAGE_LIMIT: usize = 4000; +/// Maximum characters per Slack message (practical chunk; API allows more). +pub const SLACK_MESSAGE_LIMIT: usize = 4000; + // --------------------------------------------------------------------------- // Reconnection (Telegram long-polling) // --------------------------------------------------------------------------- @@ -64,6 +67,16 @@ pub const DINGTALK_MAX_RECONNECT_ATTEMPTS: u32 = 10; /// Maximum delay between DingTalk reconnection attempts (exponential backoff cap). pub const DINGTALK_MAX_RECONNECT_DELAY: Duration = Duration::from_secs(30); +// --------------------------------------------------------------------------- +// Slack +// --------------------------------------------------------------------------- + +/// Maximum reconnection attempts for Slack Socket Mode. +pub const SLACK_MAX_RECONNECT_ATTEMPTS: u32 = 10; + +/// Maximum delay between Slack Socket Mode reconnection attempts. +pub const SLACK_MAX_RECONNECT_DELAY: Duration = Duration::from_secs(30); + /// DingTalk access token TTL refresh margin (5 minutes before expiry). /// Used by `DingtalkApi` for proactive token refresh. #[allow(dead_code)] diff --git a/crates/aionui-channel/src/manager.rs b/crates/aionui-channel/src/manager.rs index 3cc32de50..53670cdb2 100644 --- a/crates/aionui-channel/src/manager.rs +++ b/crates/aionui-channel/src/manager.rs @@ -1013,6 +1013,7 @@ mod tests { client_secret: None, account_id: None, bot_token: None, + app_token: None, extra: HashMap::new(), }, config: None, diff --git a/crates/aionui-channel/src/plugin.rs b/crates/aionui-channel/src/plugin.rs index f192f7e7e..78016454e 100644 --- a/crates/aionui-channel/src/plugin.rs +++ b/crates/aionui-channel/src/plugin.rs @@ -181,6 +181,7 @@ mod tests { client_secret: None, account_id: None, bot_token: None, + app_token: None, extra: HashMap::new(), }, config: None, diff --git a/crates/aionui-channel/src/plugins/mod.rs b/crates/aionui-channel/src/plugins/mod.rs index d8fadc656..171bebcbe 100644 --- a/crates/aionui-channel/src/plugins/mod.rs +++ b/crates/aionui-channel/src/plugins/mod.rs @@ -10,6 +10,9 @@ pub mod dingtalk; #[cfg(feature = "weixin")] pub mod weixin; +#[cfg(feature = "slack")] +pub mod slack; + use crate::plugin::ChannelPlugin; use crate::types::PluginType; @@ -30,6 +33,9 @@ pub fn create_plugin(plugin_type: PluginType) -> Option> #[cfg(feature = "weixin")] PluginType::Weixin => Some(Box::new(weixin::WeixinPlugin::new())), + #[cfg(feature = "slack")] + PluginType::Slack => Some(Box::new(slack::SlackPlugin::new())), + #[allow(unreachable_patterns)] _ => None, } diff --git a/crates/aionui-channel/src/plugins/slack/api.rs b/crates/aionui-channel/src/plugins/slack/api.rs new file mode 100644 index 000000000..17cd9c661 --- /dev/null +++ b/crates/aionui-channel/src/plugins/slack/api.rs @@ -0,0 +1,131 @@ +//! Slack Web API client (bot token) + Socket Mode open (app token). + +use reqwest::Client; +use tracing::debug; + +use crate::error::ChannelError; + +use super::types::{ + AuthTestResult, ChatPostMessageRequest, ChatPostResult, ChatUpdateRequest, ConnectionsOpenResult, SlackApiResponse, +}; + +const SLACK_API_BASE: &str = "https://slack.com/api"; + +pub(crate) struct SlackApi { + client: Client, + bot_token: String, + app_token: String, +} + +impl SlackApi { + pub fn new(client: Client, bot_token: &str, app_token: &str) -> Self { + Self { + client, + bot_token: bot_token.to_string(), + app_token: app_token.to_string(), + } + } + + pub fn bot_token(&self) -> &str { + &self.bot_token + } + + pub fn app_token(&self) -> &str { + &self.app_token + } + + /// `auth.test` — validates the bot token and returns bot identity. + pub async fn auth_test(&self) -> Result { + self.bot_post_empty::("auth.test").await + } + + /// `apps.connections.open` — Socket Mode WebSocket URL (app-level token). + pub async fn connections_open(&self) -> Result { + let url = format!("{SLACK_API_BASE}/apps.connections.open"); + let resp: SlackApiResponse = self + .client + .post(&url) + .bearer_auth(&self.app_token) + .send() + .await + .map_err(|e| ChannelError::ConnectionFailed(format!("connections.open request failed: {e}")))? + .json() + .await + .map_err(|e| ChannelError::ConnectionFailed(format!("connections.open parse failed: {e}")))?; + + if !resp.ok { + let err = resp.error.unwrap_or_else(|| "unknown".into()); + return Err(ChannelError::ConnectionFailed(format!( + "Slack connections.open failed: {err}" + ))); + } + + resp.data + .url + .filter(|u| !u.is_empty()) + .ok_or_else(|| ChannelError::ConnectionFailed("connections.open returned no url".into())) + } + + /// `chat.postMessage`. + pub async fn post_message(&self, req: &ChatPostMessageRequest<'_>) -> Result { + debug!(channel = req.channel, "Slack chat.postMessage"); + let result: ChatPostResult = self.bot_post_json("chat.postMessage", req).await?; + result + .ts + .filter(|t| !t.is_empty()) + .ok_or_else(|| ChannelError::MessageSendFailed("chat.postMessage returned no ts".into())) + } + + /// `chat.update` — used for streaming edits. + pub async fn update_message(&self, req: &ChatUpdateRequest<'_>) -> Result<(), ChannelError> { + debug!(channel = req.channel, ts = req.ts, "Slack chat.update"); + let _: ChatPostResult = self.bot_post_json("chat.update", req).await?; + Ok(()) + } + + async fn bot_post_empty(&self, method: &str) -> Result { + let url = format!("{SLACK_API_BASE}/{method}"); + let resp: SlackApiResponse = self + .client + .post(&url) + .bearer_auth(&self.bot_token) + .header("content-type", "application/x-www-form-urlencoded") + .send() + .await + .map_err(|e| ChannelError::PlatformApi(format!("{method} request failed: {e}")))? + .json() + .await + .map_err(|e| ChannelError::PlatformApi(format!("{method} parse failed: {e}")))?; + + if !resp.ok { + let err = resp.error.unwrap_or_else(|| "unknown".into()); + return Err(ChannelError::ConnectionFailed(format!("Slack {method} failed: {err}"))); + } + Ok(resp.data) + } + + async fn bot_post_json( + &self, + method: &str, + body: &B, + ) -> Result { + let url = format!("{SLACK_API_BASE}/{method}"); + let resp: SlackApiResponse = self + .client + .post(&url) + .bearer_auth(&self.bot_token) + .json(body) + .send() + .await + .map_err(|e| ChannelError::MessageSendFailed(format!("{method} request failed: {e}")))? + .json() + .await + .map_err(|e| ChannelError::MessageSendFailed(format!("{method} parse failed: {e}")))?; + + if !resp.ok { + let err = resp.error.unwrap_or_else(|| "unknown".into()); + return Err(ChannelError::MessageSendFailed(format!("Slack {method} failed: {err}"))); + } + Ok(resp.data) + } +} diff --git a/crates/aionui-channel/src/plugins/slack/mod.rs b/crates/aionui-channel/src/plugins/slack/mod.rs new file mode 100644 index 000000000..de91c877b --- /dev/null +++ b/crates/aionui-channel/src/plugins/slack/mod.rs @@ -0,0 +1,5 @@ +mod api; +mod plugin; +mod types; + +pub use plugin::SlackPlugin; diff --git a/crates/aionui-channel/src/plugins/slack/plugin.rs b/crates/aionui-channel/src/plugins/slack/plugin.rs new file mode 100644 index 000000000..8ec4aaa50 --- /dev/null +++ b/crates/aionui-channel/src/plugins/slack/plugin.rs @@ -0,0 +1,578 @@ +//! Slack channel plugin — Socket Mode (Hermes-style), DM + allowlisted @mentions. + +use std::collections::HashSet; +use std::sync::Arc; +use std::time::Duration; + +use dashmap::DashMap; +use futures_util::{SinkExt, StreamExt}; +use reqwest::Client; +use tokio::sync::{mpsc, watch}; +use tokio::task::JoinHandle; +use tracing::{debug, error, info, warn}; + +use crate::constants::{SLACK_MAX_RECONNECT_ATTEMPTS, SLACK_MAX_RECONNECT_DELAY, SLACK_MESSAGE_LIMIT}; +use crate::error::ChannelError; +use crate::plugin::{ChannelPlugin, PluginCallbacks}; +use crate::types::{ + BotInfo, MessageContentType, PluginConfig, PluginStatus, PluginType, UnifiedIncomingMessage, UnifiedMessageContent, + UnifiedOutgoingMessage, UnifiedUser, +}; + +use super::api::SlackApi; +use super::types::{ + ChatPostMessageRequest, ChatUpdateRequest, EventsApiPayload, SocketEnvelope, SlackEvent, is_dm_event, + parse_allowed_channels, should_accept_event, strip_bot_mention, +}; + +/// Slack Bot plugin (Socket Mode). +/// +/// Credentials: +/// - `token` — bot user OAuth token (`xoxb-…`) +/// - `app_token` — app-level token (`xapp-…`) with `connections:write` +/// +/// Config: +/// - `allowed_channels` — comma-separated conversation IDs (`C…`/`G…`). Empty = DM only. +/// In listed channels the bot only responds when @mentioned. +pub struct SlackPlugin { + status: PluginStatus, + bot_info: Option, + last_error: Option, + api: Option>, + callbacks: Option, + allowed_channels: HashSet, + bot_user_id: String, + /// Last thread root per channel for outbound replies (personal-bot MVP). + last_thread_ts: Arc>, + ws_handle: Option>, + shutdown_tx: Option>, +} + +impl Default for SlackPlugin { + fn default() -> Self { + Self { + status: PluginStatus::Created, + bot_info: None, + last_error: None, + api: None, + callbacks: None, + allowed_channels: HashSet::new(), + bot_user_id: String::new(), + last_thread_ts: Arc::new(DashMap::new()), + ws_handle: None, + shutdown_tx: None, + } + } +} + +impl SlackPlugin { + pub fn new() -> Self { + Self::default() + } +} + +#[async_trait::async_trait] +impl ChannelPlugin for SlackPlugin { + async fn initialize(&mut self, config: PluginConfig, callbacks: PluginCallbacks) -> Result<(), ChannelError> { + self.status = PluginStatus::Initializing; + + let bot_token = config + .credentials + .token + .as_deref() + .filter(|t| !t.is_empty()) + .ok_or_else(|| { + self.status = PluginStatus::Error; + self.last_error = Some("Missing Slack bot token (xoxb-…)".into()); + ChannelError::InvalidConfig("Missing Slack bot token (xoxb-…)".into()) + })?; + + let app_token = config + .credentials + .app_token + .as_deref() + .filter(|t| !t.is_empty()) + .ok_or_else(|| { + self.status = PluginStatus::Error; + self.last_error = Some("Missing Slack app token (xapp-…)".into()); + ChannelError::InvalidConfig("Missing Slack app token (xapp-…)".into()) + })?; + + self.allowed_channels = + parse_allowed_channels(config.config.as_ref().and_then(|c| c.allowed_channels.as_deref())); + + let client = Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .map_err(|e| { + self.status = PluginStatus::Error; + self.last_error = Some(format!("HTTP client init failed: {e}")); + ChannelError::ConnectionFailed(format!("HTTP client init failed: {e}")) + })?; + + let api = Arc::new(SlackApi::new(client, bot_token, app_token)); + + let me = api.auth_test().await.map_err(|e| { + self.status = PluginStatus::Error; + self.last_error = Some(format!("auth.test failed: {e}")); + e + })?; + + let user_id = me.user_id.clone().unwrap_or_default(); + self.bot_user_id = user_id.clone(); + self.bot_info = Some(BotInfo { + id: user_id.clone(), + username: me.user.clone(), + display_name: me.user.clone().unwrap_or_else(|| "Slack Bot".into()), + }); + + info!( + bot_user_id = %user_id, + team = ?me.team, + allowed = self.allowed_channels.len(), + "Slack bot initialized" + ); + + self.api = Some(api); + self.callbacks = Some(callbacks); + self.status = PluginStatus::Ready; + Ok(()) + } + + async fn start(&mut self) -> Result<(), ChannelError> { + self.status = PluginStatus::Starting; + + if self.ws_handle.is_some() { + self.status = PluginStatus::Running; + return Ok(()); + } + + let api = self + .api + .as_ref() + .cloned() + .ok_or_else(|| ChannelError::PlatformApi("Slack plugin not initialized".into()))?; + let callbacks = self + .callbacks + .clone() + .ok_or_else(|| ChannelError::PlatformApi("Slack callbacks not initialized".into()))?; + + let (shutdown_tx, shutdown_rx) = watch::channel(false); + self.shutdown_tx = Some(shutdown_tx); + + let allowed = self.allowed_channels.clone(); + let bot_user_id = self.bot_user_id.clone(); + let last_thread_ts = self.last_thread_ts.clone(); + + self.ws_handle = Some(tokio::spawn(socket_mode_loop( + api, + callbacks.message_tx, + shutdown_rx, + allowed, + bot_user_id, + last_thread_ts, + ))); + + self.status = PluginStatus::Running; + info!("Slack plugin started (Socket Mode)"); + Ok(()) + } + + async fn stop(&mut self) -> Result<(), ChannelError> { + self.status = PluginStatus::Stopping; + + if let Some(tx) = self.shutdown_tx.take() { + let _ = tx.send(true); + } + if let Some(handle) = self.ws_handle.take() { + let _ = tokio::time::timeout(Duration::from_secs(5), handle).await; + } + + self.api = None; + self.callbacks = None; + self.last_thread_ts.clear(); + self.status = PluginStatus::Stopped; + info!("Slack plugin stopped"); + Ok(()) + } + + async fn send_message(&self, chat_id: &str, message: UnifiedOutgoingMessage) -> Result { + let api = self + .api + .as_ref() + .ok_or_else(|| ChannelError::PlatformApi("Plugin not initialized".into()))?; + + let text = truncate_message(message.text.as_deref().unwrap_or(""), SLACK_MESSAGE_LIMIT); + let thread_ts = message + .reply_to_message_id + .clone() + .or_else(|| self.last_thread_ts.get(chat_id).map(|v| v.clone())); + + let req = ChatPostMessageRequest { + channel: chat_id, + text: &text, + thread_ts: thread_ts.as_deref(), + mrkdwn: Some(true), + }; + + api.post_message(&req).await + } + + async fn edit_message( + &self, + chat_id: &str, + message_id: &str, + message: UnifiedOutgoingMessage, + ) -> Result<(), ChannelError> { + let api = self + .api + .as_ref() + .ok_or_else(|| ChannelError::PlatformApi("Plugin not initialized".into()))?; + + let text = truncate_message(message.text.as_deref().unwrap_or(""), SLACK_MESSAGE_LIMIT); + let req = ChatUpdateRequest { + channel: chat_id, + ts: message_id, + text: &text, + }; + api.update_message(&req).await + } + + fn active_user_count(&self) -> usize { + 0 + } + + fn bot_info(&self) -> Option<&BotInfo> { + self.bot_info.as_ref() + } + + fn plugin_type(&self) -> PluginType { + PluginType::Slack + } + + fn status(&self) -> PluginStatus { + self.status + } + + fn last_error(&self) -> Option<&str> { + self.last_error.as_deref() + } +} + +// --------------------------------------------------------------------------- +// Socket Mode loop +// --------------------------------------------------------------------------- + +async fn socket_mode_loop( + api: Arc, + message_tx: mpsc::Sender, + mut shutdown_rx: watch::Receiver, + allowed: HashSet, + bot_user_id: String, + last_thread_ts: Arc>, +) { + let mut consecutive_errors: u32 = 0; + + loop { + if *shutdown_rx.borrow() { + debug!("Slack Socket Mode loop received shutdown"); + break; + } + + match connect_and_listen( + &api, + &message_tx, + &mut shutdown_rx, + &allowed, + &bot_user_id, + &last_thread_ts, + ) + .await + { + Ok(()) => { + consecutive_errors = 0; + if *shutdown_rx.borrow() { + break; + } + // Clean disconnect — reconnect after a short pause + warn!("Slack Socket Mode disconnected cleanly; reconnecting"); + } + Err(e) => { + consecutive_errors += 1; + warn!(error = %e, consecutive_errors, "Slack Socket Mode error"); + if consecutive_errors >= SLACK_MAX_RECONNECT_ATTEMPTS { + error!("Slack max reconnect attempts reached; stopping loop"); + break; + } + } + } + + let backoff = backoff_delay(consecutive_errors.max(1)); + tokio::select! { + _ = tokio::time::sleep(backoff) => {} + _ = shutdown_rx.changed() => { + if *shutdown_rx.borrow() { + break; + } + } + } + } + + debug!("Slack Socket Mode loop exited"); +} + +async fn connect_and_listen( + api: &SlackApi, + message_tx: &mpsc::Sender, + shutdown_rx: &mut watch::Receiver, + allowed: &HashSet, + bot_user_id: &str, + last_thread_ts: &DashMap, +) -> Result<(), ChannelError> { + use tokio_tungstenite::connect_async_tls_with_config; + use tokio_tungstenite::tungstenite::Message as WsMessage; + + let ws_url = api.connections_open().await?; + debug!(%ws_url, "Connecting to Slack Socket Mode"); + + let connector = build_ws_tls_connector()?; + let (ws_stream, _) = connect_async_tls_with_config(&ws_url, None, false, Some(connector)) + .await + .map_err(|e| ChannelError::ConnectionFailed(format!("Slack WS connect failed: {e}")))?; + + info!("Slack Socket Mode connected"); + + let (mut write, mut read) = ws_stream.split(); + + loop { + tokio::select! { + _ = shutdown_rx.changed() => { + if *shutdown_rx.borrow() { + debug!("Slack WS shutdown during listen"); + break; + } + } + frame = read.next() => { + match frame { + Some(Ok(WsMessage::Text(text))) => { + handle_socket_text( + &text, + &mut write, + message_tx, + allowed, + bot_user_id, + last_thread_ts, + ).await; + } + Some(Ok(WsMessage::Ping(payload))) => { + let _ = write.send(WsMessage::Pong(payload)).await; + } + Some(Ok(WsMessage::Close(_))) => { + debug!("Slack WS close frame"); + break; + } + Some(Ok(_)) => {} + Some(Err(e)) => { + return Err(ChannelError::ConnectionFailed(format!("Slack WS read error: {e}"))); + } + None => { + debug!("Slack WS stream ended"); + break; + } + } + } + } + } + + Ok(()) +} + +async fn handle_socket_text( + text: &str, + write: &mut S, + message_tx: &mpsc::Sender, + allowed: &HashSet, + bot_user_id: &str, + last_thread_ts: &DashMap, +) where + S: SinkExt + Unpin, + S::Error: std::fmt::Display, +{ + use tokio_tungstenite::tungstenite::Message as WsMessage; + + let envelope: SocketEnvelope = match serde_json::from_str(text) { + Ok(e) => e, + Err(e) => { + warn!(error = %e, "Failed to parse Slack Socket Mode frame"); + return; + } + }; + + // Always ack envelopes that carry an id (required by Socket Mode). + if let Some(ref eid) = envelope.envelope_id { + let ack = serde_json::json!({ "envelope_id": eid }); + if let Ok(payload) = serde_json::to_string(&ack) { + let _ = write.send(WsMessage::Text(payload.into())).await; + } + } + + match envelope.envelope_type.as_str() { + "hello" => { + debug!("Slack Socket Mode hello"); + } + "disconnect" => { + warn!(reason = ?envelope.reason, "Slack Socket Mode disconnect requested"); + } + "events_api" => { + let Some(payload_val) = envelope.payload else { + return; + }; + let payload: EventsApiPayload = match serde_json::from_value(payload_val) { + Ok(p) => p, + Err(e) => { + warn!(error = %e, "Failed to parse events_api payload"); + return; + } + }; + if let Some(event) = payload.event { + handle_slack_event(event, message_tx, allowed, bot_user_id, last_thread_ts).await; + } + } + other => { + debug!(envelope_type = other, "Ignoring Slack Socket Mode envelope"); + } + } +} + +async fn handle_slack_event( + event: SlackEvent, + message_tx: &mpsc::Sender, + allowed: &HashSet, + bot_user_id: &str, + last_thread_ts: &DashMap, +) { + if !should_accept_event(&event, bot_user_id, allowed) { + return; + } + + let channel = match event.channel.as_deref() { + Some(c) if !c.is_empty() => c.to_string(), + _ => return, + }; + let user_id = match event.user.as_deref() { + Some(u) if !u.is_empty() => u.to_string(), + _ => return, + }; + let ts = event.ts.clone().unwrap_or_else(|| chrono_now().to_string()); + let raw_text = event.text.clone().unwrap_or_default(); + let text = if is_dm_event(&event) { + raw_text + } else { + strip_bot_mention(&raw_text, bot_user_id) + }; + + // Thread root for outbound replies: existing thread or this message. + let thread_root = event.thread_ts.clone().unwrap_or_else(|| ts.clone()); + if !is_dm_event(&event) { + last_thread_ts.insert(channel.clone(), thread_root); + } + + let unified = UnifiedIncomingMessage { + owner_user_id: None, + id: ts, + platform: PluginType::Slack, + chat_id: channel, + user: UnifiedUser { + id: user_id, + username: None, + display_name: event.user.clone().unwrap_or_default(), + avatar_url: None, + }, + content: UnifiedMessageContent { + content_type: if text.starts_with('/') { + MessageContentType::Command + } else { + MessageContentType::Text + }, + text, + attachments: None, + }, + timestamp: chrono_now(), + reply_to_message_id: event.thread_ts, + action: None, + raw: None, + }; + + let _ = message_tx.send(unified).await; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn truncate_message(text: &str, limit: usize) -> String { + if text.chars().count() <= limit { + return text.to_string(); + } + let truncated: String = text.chars().take(limit.saturating_sub(3)).collect(); + format!("{truncated}...") +} + +fn backoff_delay(attempt: u32) -> Duration { + let secs = 2u64.saturating_pow(attempt).min(SLACK_MAX_RECONNECT_DELAY.as_secs()); + Duration::from_secs(secs) +} + +fn chrono_now() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +/// Build a TLS connector with ALPN `http/1.1` only (WebSocket upgrade). +fn build_ws_tls_connector() -> Result { + use rustls::ClientConfig; + use std::sync::Arc as StdArc; + use tokio_tungstenite::Connector; + + let mut roots = rustls::RootCertStore::empty(); + for cert in rustls_native_certs::load_native_certs().certs { + let _ = roots.add(cert); + } + + let mut config = ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth(); + config.alpn_protocols = vec![b"http/1.1".to_vec()]; + + Ok(Connector::Rustls(StdArc::new(config))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_plugin_initial_state() { + let plugin = SlackPlugin::new(); + assert_eq!(plugin.status(), PluginStatus::Created); + assert!(plugin.bot_info().is_none()); + assert_eq!(plugin.plugin_type(), PluginType::Slack); + } + + #[test] + fn truncate_message_basic() { + assert_eq!(truncate_message("hi", 10), "hi"); + let long = "a".repeat(20); + let out = truncate_message(&long, 10); + assert!(out.ends_with("...")); + assert!(out.chars().count() <= 10); + } + + #[test] + fn backoff_caps() { + assert_eq!(backoff_delay(1), Duration::from_secs(2)); + assert_eq!(backoff_delay(10), SLACK_MAX_RECONNECT_DELAY); + } +} diff --git a/crates/aionui-channel/src/plugins/slack/types.rs b/crates/aionui-channel/src/plugins/slack/types.rs new file mode 100644 index 000000000..6606e4134 --- /dev/null +++ b/crates/aionui-channel/src/plugins/slack/types.rs @@ -0,0 +1,282 @@ +//! Slack Web API / Socket Mode types used by the channel plugin. + +use serde::{Deserialize, Serialize}; + +// --------------------------------------------------------------------------- +// Web API envelopes +// --------------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +pub(crate) struct SlackApiResponse { + pub ok: bool, + #[serde(default)] + pub error: Option, + #[serde(flatten)] + pub data: T, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct AuthTestResult { + #[serde(default)] + pub user_id: Option, + #[serde(default)] + pub user: Option, + #[serde(default)] + pub bot_id: Option, + #[serde(default)] + pub team: Option, + #[serde(default)] + pub team_id: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct ConnectionsOpenResult { + #[serde(default)] + pub url: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct ChatPostResult { + #[serde(default)] + pub ts: Option, + #[serde(default)] + pub channel: Option, +} + +// --------------------------------------------------------------------------- +// Outbound request bodies +// --------------------------------------------------------------------------- + +#[derive(Debug, Serialize)] +pub(crate) struct ChatPostMessageRequest<'a> { + pub channel: &'a str, + pub text: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + pub thread_ts: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + pub mrkdwn: Option, +} + +#[derive(Debug, Serialize)] +pub(crate) struct ChatUpdateRequest<'a> { + pub channel: &'a str, + pub ts: &'a str, + pub text: &'a str, +} + +// --------------------------------------------------------------------------- +// Socket Mode envelopes +// --------------------------------------------------------------------------- + +/// Top-level Socket Mode frame. +#[derive(Debug, Deserialize)] +pub(crate) struct SocketEnvelope { + #[serde(default)] + pub envelope_id: Option, + #[serde(rename = "type")] + pub envelope_type: String, + #[serde(default)] + pub payload: Option, + /// Present on `disconnect` frames. + #[serde(default)] + pub reason: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct EventsApiPayload { + #[serde(default)] + pub event: Option, + #[serde(default)] + pub team_id: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct SlackEvent { + #[serde(rename = "type")] + pub event_type: String, + #[serde(default)] + pub user: Option, + #[serde(default)] + pub channel: Option, + #[serde(default)] + pub text: Option, + #[serde(default)] + pub ts: Option, + #[serde(default)] + pub thread_ts: Option, + /// `im` | `mpim` | `channel` | `group` (on message events). + #[serde(default)] + pub channel_type: Option, + #[serde(default)] + pub subtype: Option, + #[serde(default)] + pub bot_id: Option, + #[serde(default)] + pub app_id: Option, +} + +// --------------------------------------------------------------------------- +// Policy helpers +// --------------------------------------------------------------------------- + +/// Parse a comma-separated allowlist of conversation IDs (`C…`/`G…`/`D…`). +pub(crate) fn parse_allowed_channels(raw: Option<&str>) -> std::collections::HashSet { + raw.unwrap_or("") + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect() +} + +/// Whether this event is a 1:1 DM (`im`). +pub(crate) fn is_dm_event(event: &SlackEvent) -> bool { + matches!(event.channel_type.as_deref(), Some("im")) + || event + .channel + .as_deref() + .is_some_and(|c| c.starts_with('D')) +} + +/// Whether the message text @mentions the bot user. +pub(crate) fn text_mentions_bot(text: &str, bot_user_id: &str) -> bool { + if bot_user_id.is_empty() { + return false; + } + text.contains(&format!("<@{bot_user_id}>")) +} + +/// Strip Slack user mention tokens for cleaner agent input. +pub(crate) fn strip_bot_mention(text: &str, bot_user_id: &str) -> String { + let token = format!("<@{bot_user_id}>"); + text.replace(&token, "").trim().to_string() +} + +/// Decide whether an inbound event should be processed by the agent. +/// +/// Policy: +/// - Always accept 1:1 DMs. +/// - Channels/groups: only if `channel` is in `allowed` **and** the bot is @mentioned +/// (or the event is `app_mention`). Empty allowlist → drop all non-DMs. +pub(crate) fn should_accept_event( + event: &SlackEvent, + bot_user_id: &str, + allowed: &std::collections::HashSet, +) -> bool { + // Ignore bot-authored / system noise + if event.bot_id.is_some() { + return false; + } + if let Some(sub) = event.subtype.as_deref() { + // message_changed, message_deleted, bot_message, channel_join, etc. + if sub != "file_share" && sub != "me_message" { + return false; + } + } + + let is_app_mention = event.event_type == "app_mention"; + let is_message = event.event_type == "message"; + if !is_app_mention && !is_message { + return false; + } + + if is_dm_event(event) { + return true; + } + + let channel = match event.channel.as_deref() { + Some(c) if !c.is_empty() => c, + _ => return false, + }; + + if !allowed.contains(channel) { + return false; + } + + if is_app_mention { + return true; + } + + let text = event.text.as_deref().unwrap_or(""); + text_mentions_bot(text, bot_user_id) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn msg(channel: &str, channel_type: &str, text: &str) -> SlackEvent { + SlackEvent { + event_type: "message".into(), + user: Some("U_USER".into()), + channel: Some(channel.into()), + text: Some(text.into()), + ts: Some("1.0".into()), + thread_ts: None, + channel_type: Some(channel_type.into()), + subtype: None, + bot_id: None, + app_id: None, + } + } + + #[test] + fn parse_allowed_channels_trims() { + let set = parse_allowed_channels(Some(" C1 , G2, ,D3 ")); + assert!(set.contains("C1")); + assert!(set.contains("G2")); + assert!(set.contains("D3")); + assert_eq!(set.len(), 3); + } + + #[test] + fn dm_always_accepted() { + let allowed = std::collections::HashSet::new(); + let event = msg("D123", "im", "hello"); + assert!(should_accept_event(&event, "U_BOT", &allowed)); + } + + #[test] + fn channel_dropped_when_not_allowlisted() { + let allowed = parse_allowed_channels(Some("C_OTHER")); + let event = msg("C_MAIN", "channel", "<@U_BOT> hi"); + assert!(!should_accept_event(&event, "U_BOT", &allowed)); + } + + #[test] + fn channel_needs_mention_even_when_allowlisted() { + let allowed = parse_allowed_channels(Some("C_MAIN")); + let plain = msg("C_MAIN", "channel", "hi everyone"); + assert!(!should_accept_event(&plain, "U_BOT", &allowed)); + let mentioned = msg("C_MAIN", "channel", "<@U_BOT> hi"); + assert!(should_accept_event(&mentioned, "U_BOT", &allowed)); + } + + #[test] + fn empty_allowlist_blocks_channels() { + let allowed = parse_allowed_channels(None); + let event = msg("C_MAIN", "channel", "<@U_BOT> hi"); + assert!(!should_accept_event(&event, "U_BOT", &allowed)); + } + + #[test] + fn app_mention_accepted_when_allowlisted() { + let allowed = parse_allowed_channels(Some("C_MAIN")); + let mut event = msg("C_MAIN", "channel", "hi"); + event.event_type = "app_mention".into(); + assert!(should_accept_event(&event, "U_BOT", &allowed)); + } + + #[test] + fn bot_messages_dropped() { + let allowed = parse_allowed_channels(Some("C_MAIN")); + let mut event = msg("C_MAIN", "channel", "<@U_BOT> hi"); + event.bot_id = Some("B123".into()); + assert!(!should_accept_event(&event, "U_BOT", &allowed)); + } + + #[test] + fn strip_bot_mention_cleans_text() { + assert_eq!(strip_bot_mention("<@U_BOT> run tests", "U_BOT"), "run tests"); + } +} diff --git a/crates/aionui-channel/src/plugins/weixin/plugin.rs b/crates/aionui-channel/src/plugins/weixin/plugin.rs index 204adb8aa..471aa82f6 100644 --- a/crates/aionui-channel/src/plugins/weixin/plugin.rs +++ b/crates/aionui-channel/src/plugins/weixin/plugin.rs @@ -626,6 +626,7 @@ mod tests { client_secret: None, account_id: account_id.map(String::from), bot_token: bot_token.map(String::from), + app_token: None, extra: HashMap::new(), }, config: None, diff --git a/crates/aionui-channel/src/routes.rs b/crates/aionui-channel/src/routes.rs index 5f366df8f..6eac63414 100644 --- a/crates/aionui-channel/src/routes.rs +++ b/crates/aionui-channel/src/routes.rs @@ -728,6 +728,7 @@ fn build_test_config(req: &TestPluginRequest) -> PluginConfig { client_secret: None, account_id: None, bot_token: None, + app_token: None, extra: HashMap::new(), }; @@ -751,6 +752,13 @@ fn build_test_config(req: &TestPluginRequest) -> PluginConfig { credentials.account_id = extra.app_id.clone(); } } + "slack" => { + // token = bot token (xoxb-…); app_secret maps to Socket Mode app token (xapp-…) + credentials.token = Some(req.token.clone()); + if let Some(ref extra) = req.extra_config { + credentials.app_token = extra.app_secret.clone(); + } + } _ => { // Default: use token field (Telegram) credentials.token = Some(req.token.clone()); @@ -813,6 +821,7 @@ fn build_extension_config( client_secret: None, account_id: None, bot_token: None, + app_token: None, extra: HashMap::new(), }; let mut config_extra = HashMap::new(); @@ -847,6 +856,7 @@ fn build_extension_config( webhook_url: None, rate_limit: None, require_mention: None, + allowed_channels: None, extra: config_extra, }) }, @@ -1021,6 +1031,21 @@ mod tests { assert_eq!(config.credentials.client_secret.as_deref(), Some("client_secret_456")); } + #[test] + fn build_test_config_slack() { + let req = TestPluginRequest { + plugin_id: "slack".into(), + token: "xoxb-bot".into(), + extra_config: Some(TestPluginExtraConfig { + app_id: None, + app_secret: Some("xapp-app".into()), + }), + }; + let config = build_test_config(&req); + assert_eq!(config.credentials.token.as_deref(), Some("xoxb-bot")); + assert_eq!(config.credentials.app_token.as_deref(), Some("xapp-app")); + } + #[test] fn build_test_config_weixin() { let req = TestPluginRequest { diff --git a/crates/aionui-channel/src/types.rs b/crates/aionui-channel/src/types.rs index 58fca617e..5218a5c2c 100644 --- a/crates/aionui-channel/src/types.rs +++ b/crates/aionui-channel/src/types.rs @@ -158,12 +158,13 @@ impl PairingStatus { /// - Lark: `app_id` + `app_secret` + optional `encrypt_key`/`verification_token` /// - DingTalk: `client_id` + `client_secret` /// - WeChat: `account_id` + `bot_token` +/// - Slack: `token` (bot `xoxb-`) + `app_token` (app-level `xapp-` for Socket Mode) /// /// Remaining fields are captured in `extra` for extensibility /// (API Spec `[key: string]: unknown`). #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct PluginCredentials { - // Telegram + // Telegram / Slack bot token #[serde(skip_serializing_if = "Option::is_none")] pub token: Option, @@ -189,6 +190,10 @@ pub struct PluginCredentials { #[serde(skip_serializing_if = "Option::is_none")] pub bot_token: Option, + // Slack Socket Mode app-level token (`xapp-…`) + #[serde(skip_serializing_if = "Option::is_none")] + pub app_token: Option, + // Extensibility #[serde(flatten)] pub extra: HashMap, @@ -210,6 +215,7 @@ impl PluginCredentials { && self.client_secret.is_none() && self.account_id.is_none() && self.bot_token.is_none() + && self.app_token.is_none() && self.extra.is_empty() } } @@ -228,6 +234,11 @@ pub struct PluginConfigOptions { pub rate_limit: Option, #[serde(skip_serializing_if = "Option::is_none")] pub require_mention: Option, + /// Comma-separated Slack conversation IDs (`C…`/`G…`/`D…`) the bot may + /// process outside of 1:1 DMs. Empty / absent → channels and groups are + /// ignored (DM-only). Mentions are still required inside listed channels. + #[serde(skip_serializing_if = "Option::is_none")] + pub allowed_channels: Option, // Extensibility #[serde(flatten)] @@ -605,6 +616,7 @@ mod tests { client_secret: None, account_id: None, bot_token: None, + app_token: None, extra: HashMap::new(), }; let json = serde_json::to_value(&creds).unwrap(); @@ -625,6 +637,7 @@ mod tests { client_secret: None, account_id: None, bot_token: None, + app_token: None, extra: HashMap::new(), }; let json = serde_json::to_value(&creds).unwrap(); @@ -1030,6 +1043,7 @@ mod tests { client_secret: None, account_id: None, bot_token: None, + app_token: None, extra: HashMap::new(), }, config: Some(PluginConfigOptions { @@ -1037,6 +1051,7 @@ mod tests { webhook_url: None, rate_limit: Some(5), require_mention: None, + allowed_channels: None, extra: HashMap::new(), }), }; diff --git a/crates/aionui-channel/tests/dingtalk_integration.rs b/crates/aionui-channel/tests/dingtalk_integration.rs index e57ef820a..0313d5f62 100644 --- a/crates/aionui-channel/tests/dingtalk_integration.rs +++ b/crates/aionui-channel/tests/dingtalk_integration.rs @@ -93,6 +93,7 @@ mod dingtalk_tests { client_secret: client_secret.map(String::from), account_id: None, bot_token: None, + app_token: None, extra: HashMap::new(), }, config: None, diff --git a/crates/aionui-channel/tests/lark_integration.rs b/crates/aionui-channel/tests/lark_integration.rs index 8e3869c71..d253804f1 100644 --- a/crates/aionui-channel/tests/lark_integration.rs +++ b/crates/aionui-channel/tests/lark_integration.rs @@ -92,6 +92,7 @@ mod lark_tests { client_secret: None, account_id: None, bot_token: None, + app_token: None, extra: HashMap::new(), }, config: None, diff --git a/crates/aionui-channel/tests/manager_integration.rs b/crates/aionui-channel/tests/manager_integration.rs index ab207c62e..657d46fa3 100644 --- a/crates/aionui-channel/tests/manager_integration.rs +++ b/crates/aionui-channel/tests/manager_integration.rs @@ -238,6 +238,7 @@ fn make_plugin_config() -> PluginConfig { client_secret: None, account_id: None, bot_token: None, + app_token: None, extra: HashMap::new(), }, config: None, diff --git a/crates/aionui-channel/tests/telegram_integration.rs b/crates/aionui-channel/tests/telegram_integration.rs index e2067982b..41037f6f2 100644 --- a/crates/aionui-channel/tests/telegram_integration.rs +++ b/crates/aionui-channel/tests/telegram_integration.rs @@ -94,6 +94,7 @@ mod telegram_tests { client_secret: None, account_id: None, bot_token: None, + app_token: None, extra: HashMap::new(), }, config: None, diff --git a/crates/aionui-channel/tests/weixin_integration.rs b/crates/aionui-channel/tests/weixin_integration.rs index 607c6a44b..3280a8627 100644 --- a/crates/aionui-channel/tests/weixin_integration.rs +++ b/crates/aionui-channel/tests/weixin_integration.rs @@ -94,6 +94,7 @@ mod weixin_tests { client_secret: None, account_id: account_id.map(String::from), bot_token: bot_token.map(String::from), + app_token: None, extra: HashMap::new(), }, config: None, From 07bd15d7eea1d0d5a1d027a116a74ddf0f6f9bb3 Mon Sep 17 00:00:00 2001 From: Matheus Teixeira <707561+mtxr@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:31:13 -0300 Subject: [PATCH 2/8] chore(channel): silence unused Slack type field warnings Drop unread serde fields and unused accessors from the Slack adapter. --- crates/aionui-channel/src/plugins/slack/api.rs | 8 -------- crates/aionui-channel/src/plugins/slack/types.rs | 11 ----------- 2 files changed, 19 deletions(-) diff --git a/crates/aionui-channel/src/plugins/slack/api.rs b/crates/aionui-channel/src/plugins/slack/api.rs index 17cd9c661..c0d44dd24 100644 --- a/crates/aionui-channel/src/plugins/slack/api.rs +++ b/crates/aionui-channel/src/plugins/slack/api.rs @@ -26,14 +26,6 @@ impl SlackApi { } } - pub fn bot_token(&self) -> &str { - &self.bot_token - } - - pub fn app_token(&self) -> &str { - &self.app_token - } - /// `auth.test` — validates the bot token and returns bot identity. pub async fn auth_test(&self) -> Result { self.bot_post_empty::("auth.test").await diff --git a/crates/aionui-channel/src/plugins/slack/types.rs b/crates/aionui-channel/src/plugins/slack/types.rs index 6606e4134..a4b734314 100644 --- a/crates/aionui-channel/src/plugins/slack/types.rs +++ b/crates/aionui-channel/src/plugins/slack/types.rs @@ -22,11 +22,7 @@ pub(crate) struct AuthTestResult { #[serde(default)] pub user: Option, #[serde(default)] - pub bot_id: Option, - #[serde(default)] pub team: Option, - #[serde(default)] - pub team_id: Option, } #[derive(Debug, Deserialize)] @@ -39,8 +35,6 @@ pub(crate) struct ConnectionsOpenResult { pub(crate) struct ChatPostResult { #[serde(default)] pub ts: Option, - #[serde(default)] - pub channel: Option, } // --------------------------------------------------------------------------- @@ -86,8 +80,6 @@ pub(crate) struct SocketEnvelope { pub(crate) struct EventsApiPayload { #[serde(default)] pub event: Option, - #[serde(default)] - pub team_id: Option, } #[derive(Debug, Clone, Deserialize)] @@ -111,8 +103,6 @@ pub(crate) struct SlackEvent { pub subtype: Option, #[serde(default)] pub bot_id: Option, - #[serde(default)] - pub app_id: Option, } // --------------------------------------------------------------------------- @@ -216,7 +206,6 @@ mod tests { channel_type: Some(channel_type.into()), subtype: None, bot_id: None, - app_id: None, } } From b3da459993caea0dc6576a43cee982d456efa2eb Mon Sep 17 00:00:00 2001 From: Matheus Teixeira <707561+mtxr@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:09:08 -0300 Subject: [PATCH 3/8] fix(channel): log Slack Socket Mode events for pairing debug Surface every inbound Slack event and accept/drop decision at info level so missing Event Subscriptions or allowlist drops are visible in aioncore stdout when pairing does not appear. --- .../src/plugins/slack/plugin.rs | 49 +++++++--- .../aionui-channel/src/plugins/slack/types.rs | 90 +++++++++++++++---- 2 files changed, 113 insertions(+), 26 deletions(-) diff --git a/crates/aionui-channel/src/plugins/slack/plugin.rs b/crates/aionui-channel/src/plugins/slack/plugin.rs index 8ec4aaa50..b746a3d63 100644 --- a/crates/aionui-channel/src/plugins/slack/plugin.rs +++ b/crates/aionui-channel/src/plugins/slack/plugin.rs @@ -21,8 +21,8 @@ use crate::types::{ use super::api::SlackApi; use super::types::{ - ChatPostMessageRequest, ChatUpdateRequest, EventsApiPayload, SocketEnvelope, SlackEvent, is_dm_event, - parse_allowed_channels, should_accept_event, strip_bot_mention, + AcceptDecision, ChatPostMessageRequest, ChatUpdateRequest, EventsApiPayload, SocketEnvelope, SlackEvent, + classify_event, is_dm_event, parse_allowed_channels, strip_bot_mention, }; /// Slack Bot plugin (Socket Mode). @@ -418,13 +418,14 @@ async fn handle_socket_text( match envelope.envelope_type.as_str() { "hello" => { - debug!("Slack Socket Mode hello"); + info!("Slack Socket Mode hello (connection ready for events)"); } "disconnect" => { warn!(reason = ?envelope.reason, "Slack Socket Mode disconnect requested"); } "events_api" => { let Some(payload_val) = envelope.payload else { + warn!("Slack events_api envelope missing payload"); return; }; let payload: EventsApiPayload = match serde_json::from_value(payload_val) { @@ -436,10 +437,12 @@ async fn handle_socket_text( }; if let Some(event) = payload.event { handle_slack_event(event, message_tx, allowed, bot_user_id, last_thread_ts).await; + } else { + warn!("Slack events_api payload missing event"); } } other => { - debug!(envelope_type = other, "Ignoring Slack Socket Mode envelope"); + info!(envelope_type = other, "Slack Socket Mode envelope (ignored)"); } } } @@ -451,17 +454,39 @@ async fn handle_slack_event( bot_user_id: &str, last_thread_ts: &DashMap, ) { - if !should_accept_event(&event, bot_user_id, allowed) { + let decision = classify_event(&event, bot_user_id, allowed); + info!( + event_type = %event.event_type, + channel = ?event.channel, + channel_type = ?event.channel_type, + subtype = ?event.subtype, + user = ?event.user, + bot_id = ?event.bot_id, + text_len = event.text.as_ref().map(|t| t.len()).unwrap_or(0), + ?decision, + "Slack event received" + ); + + if !matches!( + decision, + AcceptDecision::AcceptDm | AcceptDecision::AcceptMention + ) { return; } let channel = match event.channel.as_deref() { Some(c) if !c.is_empty() => c.to_string(), - _ => return, + _ => { + warn!("Slack accepted event missing channel"); + return; + } }; let user_id = match event.user.as_deref() { Some(u) if !u.is_empty() => u.to_string(), - _ => return, + _ => { + warn!(channel = %channel, "Slack accepted event missing user"); + return; + } }; let ts = event.ts.clone().unwrap_or_else(|| chrono_now().to_string()); let raw_text = event.text.clone().unwrap_or_default(); @@ -481,9 +506,9 @@ async fn handle_slack_event( owner_user_id: None, id: ts, platform: PluginType::Slack, - chat_id: channel, + chat_id: channel.clone(), user: UnifiedUser { - id: user_id, + id: user_id.clone(), username: None, display_name: event.user.clone().unwrap_or_default(), avatar_url: None, @@ -503,7 +528,11 @@ async fn handle_slack_event( raw: None, }; - let _ = message_tx.send(unified).await; + if message_tx.send(unified).await.is_err() { + error!("Slack message channel closed; orchestrator not receiving events"); + return; + } + info!(channel = %channel, user = %user_id, "Slack message forwarded to channel pipeline"); } // --------------------------------------------------------------------------- diff --git a/crates/aionui-channel/src/plugins/slack/types.rs b/crates/aionui-channel/src/plugins/slack/types.rs index a4b734314..fae4478a2 100644 --- a/crates/aionui-channel/src/plugins/slack/types.rs +++ b/crates/aionui-channel/src/plugins/slack/types.rs @@ -142,53 +142,82 @@ pub(crate) fn strip_bot_mention(text: &str, bot_user_id: &str) -> String { text.replace(&token, "").trim().to_string() } +/// Why an event was accepted or dropped (for diagnostics). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AcceptDecision { + AcceptDm, + AcceptMention, + DropBotMessage, + DropSubtype, + DropEventType, + DropNotAllowlisted, + DropNoMention, + DropNoChannel, +} + /// Decide whether an inbound event should be processed by the agent. /// /// Policy: /// - Always accept 1:1 DMs. /// - Channels/groups: only if `channel` is in `allowed` **and** the bot is @mentioned /// (or the event is `app_mention`). Empty allowlist → drop all non-DMs. -pub(crate) fn should_accept_event( +pub(crate) fn classify_event( event: &SlackEvent, bot_user_id: &str, allowed: &std::collections::HashSet, -) -> bool { +) -> AcceptDecision { // Ignore bot-authored / system noise if event.bot_id.is_some() { - return false; + return AcceptDecision::DropBotMessage; } if let Some(sub) = event.subtype.as_deref() { // message_changed, message_deleted, bot_message, channel_join, etc. if sub != "file_share" && sub != "me_message" { - return false; + return AcceptDecision::DropSubtype; } } let is_app_mention = event.event_type == "app_mention"; let is_message = event.event_type == "message"; if !is_app_mention && !is_message { - return false; + return AcceptDecision::DropEventType; } if is_dm_event(event) { - return true; + return AcceptDecision::AcceptDm; } let channel = match event.channel.as_deref() { Some(c) if !c.is_empty() => c, - _ => return false, + _ => return AcceptDecision::DropNoChannel, }; if !allowed.contains(channel) { - return false; + return AcceptDecision::DropNotAllowlisted; } if is_app_mention { - return true; + return AcceptDecision::AcceptMention; } let text = event.text.as_deref().unwrap_or(""); - text_mentions_bot(text, bot_user_id) + if text_mentions_bot(text, bot_user_id) { + AcceptDecision::AcceptMention + } else { + AcceptDecision::DropNoMention + } +} + +#[cfg(test)] +pub(crate) fn should_accept_event( + event: &SlackEvent, + bot_user_id: &str, + allowed: &std::collections::HashSet, +) -> bool { + matches!( + classify_event(event, bot_user_id, allowed), + AcceptDecision::AcceptDm | AcceptDecision::AcceptMention + ) } #[cfg(test)] @@ -229,23 +258,35 @@ mod tests { fn channel_dropped_when_not_allowlisted() { let allowed = parse_allowed_channels(Some("C_OTHER")); let event = msg("C_MAIN", "channel", "<@U_BOT> hi"); - assert!(!should_accept_event(&event, "U_BOT", &allowed)); + assert_eq!( + classify_event(&event, "U_BOT", &allowed), + AcceptDecision::DropNotAllowlisted + ); } #[test] fn channel_needs_mention_even_when_allowlisted() { let allowed = parse_allowed_channels(Some("C_MAIN")); let plain = msg("C_MAIN", "channel", "hi everyone"); - assert!(!should_accept_event(&plain, "U_BOT", &allowed)); + assert_eq!( + classify_event(&plain, "U_BOT", &allowed), + AcceptDecision::DropNoMention + ); let mentioned = msg("C_MAIN", "channel", "<@U_BOT> hi"); - assert!(should_accept_event(&mentioned, "U_BOT", &allowed)); + assert_eq!( + classify_event(&mentioned, "U_BOT", &allowed), + AcceptDecision::AcceptMention + ); } #[test] fn empty_allowlist_blocks_channels() { let allowed = parse_allowed_channels(None); let event = msg("C_MAIN", "channel", "<@U_BOT> hi"); - assert!(!should_accept_event(&event, "U_BOT", &allowed)); + assert_eq!( + classify_event(&event, "U_BOT", &allowed), + AcceptDecision::DropNotAllowlisted + ); } #[test] @@ -253,7 +294,10 @@ mod tests { let allowed = parse_allowed_channels(Some("C_MAIN")); let mut event = msg("C_MAIN", "channel", "hi"); event.event_type = "app_mention".into(); - assert!(should_accept_event(&event, "U_BOT", &allowed)); + assert_eq!( + classify_event(&event, "U_BOT", &allowed), + AcceptDecision::AcceptMention + ); } #[test] @@ -261,7 +305,21 @@ mod tests { let allowed = parse_allowed_channels(Some("C_MAIN")); let mut event = msg("C_MAIN", "channel", "<@U_BOT> hi"); event.bot_id = Some("B123".into()); - assert!(!should_accept_event(&event, "U_BOT", &allowed)); + assert_eq!( + classify_event(&event, "U_BOT", &allowed), + AcceptDecision::DropBotMessage + ); + } + + #[test] + fn dm_without_channel_type_still_accepted() { + let allowed = std::collections::HashSet::new(); + let mut event = msg("D999", "channel", "hello"); + event.channel_type = None; + assert_eq!( + classify_event(&event, "U_BOT", &allowed), + AcceptDecision::AcceptDm + ); } #[test] From 29817ea057641a8e5a6960e363e9600d86dd5e4e Mon Sep 17 00:00:00 2001 From: Matheus Teixeira <707561+mtxr@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:12:09 -0300 Subject: [PATCH 4/8] fix(channel): use explicit rustls CryptoProvider for Slack WS Match Lark/DingTalk TLS setup so Socket Mode no longer panics on rustls 0.23 CryptoProvider auto-detection. --- .../src/plugins/slack/plugin.rs | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/crates/aionui-channel/src/plugins/slack/plugin.rs b/crates/aionui-channel/src/plugins/slack/plugin.rs index b746a3d63..4c4aace07 100644 --- a/crates/aionui-channel/src/plugins/slack/plugin.rs +++ b/crates/aionui-channel/src/plugins/slack/plugin.rs @@ -559,23 +559,30 @@ fn chrono_now() -> i64 { .unwrap_or(0) } -/// Build a TLS connector with ALPN `http/1.1` only (WebSocket upgrade). +/// Build a TLS connector for WebSocket connections. +/// +/// Matches Lark/DingTalk: explicit CryptoProvider + ALPN `http/1.1` only +/// (WebSocket upgrade is incompatible with h2). fn build_ws_tls_connector() -> Result { - use rustls::ClientConfig; - use std::sync::Arc as StdArc; + use std::sync::Arc; use tokio_tungstenite::Connector; - let mut roots = rustls::RootCertStore::empty(); - for cert in rustls_native_certs::load_native_certs().certs { - let _ = roots.add(cert); - } + let certs = rustls_native_certs::load_native_certs(); + let mut root_store = rustls::RootCertStore::empty(); + root_store.add_parsable_certificates(certs.certs); + + let provider = rustls::crypto::CryptoProvider::get_default() + .cloned() + .unwrap_or_else(|| Arc::new(rustls::crypto::ring::default_provider())); - let mut config = ClientConfig::builder() - .with_root_certificates(roots) + let mut config = rustls::ClientConfig::builder_with_provider(provider) + .with_safe_default_protocol_versions() + .map_err(|e| ChannelError::ConnectionFailed(format!("TLS config error: {e}")))? + .with_root_certificates(root_store) .with_no_client_auth(); config.alpn_protocols = vec![b"http/1.1".to_vec()]; - Ok(Connector::Rustls(StdArc::new(config))) + Ok(Connector::Rustls(Arc::new(config))) } #[cfg(test)] From cc54c3e2cf1537e31a2cb1205f6bc51049cf1698 Mon Sep 17 00:00:00 2001 From: Matheus Teixeira <707561+mtxr@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:42:38 -0300 Subject: [PATCH 5/8] feat(channel): Hermes-style Slack thread session isolation Map each Slack thread to its own channel session: - top-level message opens a new session (chat_id = channel:message_ts) - replies in that thread continue the same session - outbound posts always set thread_ts so the bot answers inside the thread --- .../src/plugins/slack/plugin.rs | 77 +++++++++---------- .../aionui-channel/src/plugins/slack/types.rs | 62 +++++++++++++++ 2 files changed, 97 insertions(+), 42 deletions(-) diff --git a/crates/aionui-channel/src/plugins/slack/plugin.rs b/crates/aionui-channel/src/plugins/slack/plugin.rs index 4c4aace07..d662dc2d4 100644 --- a/crates/aionui-channel/src/plugins/slack/plugin.rs +++ b/crates/aionui-channel/src/plugins/slack/plugin.rs @@ -4,7 +4,6 @@ use std::collections::HashSet; use std::sync::Arc; use std::time::Duration; -use dashmap::DashMap; use futures_util::{SinkExt, StreamExt}; use reqwest::Client; use tokio::sync::{mpsc, watch}; @@ -22,7 +21,8 @@ use crate::types::{ use super::api::SlackApi; use super::types::{ AcceptDecision, ChatPostMessageRequest, ChatUpdateRequest, EventsApiPayload, SocketEnvelope, SlackEvent, - classify_event, is_dm_event, parse_allowed_channels, strip_bot_mention, + classify_event, decode_session_chat_id, encode_session_chat_id, is_dm_event, parse_allowed_channels, + strip_bot_mention, thread_root_for_event, }; /// Slack Bot plugin (Socket Mode). @@ -42,8 +42,6 @@ pub struct SlackPlugin { callbacks: Option, allowed_channels: HashSet, bot_user_id: String, - /// Last thread root per channel for outbound replies (personal-bot MVP). - last_thread_ts: Arc>, ws_handle: Option>, shutdown_tx: Option>, } @@ -58,7 +56,6 @@ impl Default for SlackPlugin { callbacks: None, allowed_channels: HashSet::new(), bot_user_id: String::new(), - last_thread_ts: Arc::new(DashMap::new()), ws_handle: None, shutdown_tx: None, } @@ -162,7 +159,6 @@ impl ChannelPlugin for SlackPlugin { let allowed = self.allowed_channels.clone(); let bot_user_id = self.bot_user_id.clone(); - let last_thread_ts = self.last_thread_ts.clone(); self.ws_handle = Some(tokio::spawn(socket_mode_loop( api, @@ -170,7 +166,6 @@ impl ChannelPlugin for SlackPlugin { shutdown_rx, allowed, bot_user_id, - last_thread_ts, ))); self.status = PluginStatus::Running; @@ -190,7 +185,6 @@ impl ChannelPlugin for SlackPlugin { self.api = None; self.callbacks = None; - self.last_thread_ts.clear(); self.status = PluginStatus::Stopped; info!("Slack plugin stopped"); Ok(()) @@ -203,13 +197,17 @@ impl ChannelPlugin for SlackPlugin { .ok_or_else(|| ChannelError::PlatformApi("Plugin not initialized".into()))?; let text = truncate_message(message.text.as_deref().unwrap_or(""), SLACK_MESSAGE_LIMIT); + let (channel, session_thread) = decode_session_chat_id(chat_id); + // Prefer explicit reply_to, else thread root embedded in session chat_id + // (Hermes-style: every conversation lives inside a Slack thread). let thread_ts = message .reply_to_message_id - .clone() - .or_else(|| self.last_thread_ts.get(chat_id).map(|v| v.clone())); + .as_deref() + .or(session_thread) + .map(str::to_owned); let req = ChatPostMessageRequest { - channel: chat_id, + channel, text: &text, thread_ts: thread_ts.as_deref(), mrkdwn: Some(true), @@ -230,8 +228,9 @@ impl ChannelPlugin for SlackPlugin { .ok_or_else(|| ChannelError::PlatformApi("Plugin not initialized".into()))?; let text = truncate_message(message.text.as_deref().unwrap_or(""), SLACK_MESSAGE_LIMIT); + let (channel, _) = decode_session_chat_id(chat_id); let req = ChatUpdateRequest { - channel: chat_id, + channel, ts: message_id, text: &text, }; @@ -269,7 +268,6 @@ async fn socket_mode_loop( mut shutdown_rx: watch::Receiver, allowed: HashSet, bot_user_id: String, - last_thread_ts: Arc>, ) { let mut consecutive_errors: u32 = 0; @@ -279,15 +277,7 @@ async fn socket_mode_loop( break; } - match connect_and_listen( - &api, - &message_tx, - &mut shutdown_rx, - &allowed, - &bot_user_id, - &last_thread_ts, - ) - .await + match connect_and_listen(&api, &message_tx, &mut shutdown_rx, &allowed, &bot_user_id).await { Ok(()) => { consecutive_errors = 0; @@ -327,7 +317,6 @@ async fn connect_and_listen( shutdown_rx: &mut watch::Receiver, allowed: &HashSet, bot_user_id: &str, - last_thread_ts: &DashMap, ) -> Result<(), ChannelError> { use tokio_tungstenite::connect_async_tls_with_config; use tokio_tungstenite::tungstenite::Message as WsMessage; @@ -355,14 +344,7 @@ async fn connect_and_listen( frame = read.next() => { match frame { Some(Ok(WsMessage::Text(text))) => { - handle_socket_text( - &text, - &mut write, - message_tx, - allowed, - bot_user_id, - last_thread_ts, - ).await; + handle_socket_text(&text, &mut write, message_tx, allowed, bot_user_id).await; } Some(Ok(WsMessage::Ping(payload))) => { let _ = write.send(WsMessage::Pong(payload)).await; @@ -393,7 +375,6 @@ async fn handle_socket_text( message_tx: &mpsc::Sender, allowed: &HashSet, bot_user_id: &str, - last_thread_ts: &DashMap, ) where S: SinkExt + Unpin, S::Error: std::fmt::Display, @@ -436,7 +417,7 @@ async fn handle_socket_text( } }; if let Some(event) = payload.event { - handle_slack_event(event, message_tx, allowed, bot_user_id, last_thread_ts).await; + handle_slack_event(event, message_tx, allowed, bot_user_id).await; } else { warn!("Slack events_api payload missing event"); } @@ -452,7 +433,6 @@ async fn handle_slack_event( message_tx: &mpsc::Sender, allowed: &HashSet, bot_user_id: &str, - last_thread_ts: &DashMap, ) { let decision = classify_event(&event, bot_user_id, allowed); info!( @@ -462,6 +442,7 @@ async fn handle_slack_event( subtype = ?event.subtype, user = ?event.user, bot_id = ?event.bot_id, + thread_ts = ?event.thread_ts, text_len = event.text.as_ref().map(|t| t.len()).unwrap_or(0), ?decision, "Slack event received" @@ -496,17 +477,22 @@ async fn handle_slack_event( strip_bot_mention(&raw_text, bot_user_id) }; - // Thread root for outbound replies: existing thread or this message. - let thread_root = event.thread_ts.clone().unwrap_or_else(|| ts.clone()); - if !is_dm_event(&event) { - last_thread_ts.insert(channel.clone(), thread_root); - } + // Hermes-style: top-level msg opens a new thread session (root = this ts); + // replies inside a thread continue that session (root = thread_ts). + let thread_root = match thread_root_for_event(&event) { + Some(root) => root, + None => { + warn!(channel = %channel, "Slack event missing ts/thread_ts"); + return; + } + }; + let session_chat_id = encode_session_chat_id(&channel, &thread_root); let unified = UnifiedIncomingMessage { owner_user_id: None, id: ts, platform: PluginType::Slack, - chat_id: channel.clone(), + chat_id: session_chat_id.clone(), user: UnifiedUser { id: user_id.clone(), username: None, @@ -523,7 +509,8 @@ async fn handle_slack_event( attachments: None, }, timestamp: chrono_now(), - reply_to_message_id: event.thread_ts, + // Keep thread root so outbound path can still prefer explicit reply_to + reply_to_message_id: Some(thread_root.clone()), action: None, raw: None, }; @@ -532,7 +519,13 @@ async fn handle_slack_event( error!("Slack message channel closed; orchestrator not receiving events"); return; } - info!(channel = %channel, user = %user_id, "Slack message forwarded to channel pipeline"); + info!( + channel = %channel, + thread_root = %thread_root, + session_chat_id = %session_chat_id, + user = %user_id, + "Slack message forwarded to channel pipeline" + ); } // --------------------------------------------------------------------------- diff --git a/crates/aionui-channel/src/plugins/slack/types.rs b/crates/aionui-channel/src/plugins/slack/types.rs index fae4478a2..6e7b87999 100644 --- a/crates/aionui-channel/src/plugins/slack/types.rs +++ b/crates/aionui-channel/src/plugins/slack/types.rs @@ -128,6 +128,38 @@ pub(crate) fn is_dm_event(event: &SlackEvent) -> bool { .is_some_and(|c| c.starts_with('D')) } +/// Hermes-style session key: each Slack thread is its own conversation. +/// +/// - Top-level message (no `thread_ts`): thread root = this message's `ts` +/// - Reply in an existing thread: thread root = `thread_ts` +/// +/// Encoded as `{channel}:{thread_root}` so session isolation is +/// `(user_id, chat_id)` without changing the core session manager. +pub(crate) fn encode_session_chat_id(channel: &str, thread_root: &str) -> String { + format!("{channel}:{thread_root}") +} + +/// Split a session chat_id into `(channel, thread_ts)`. +/// +/// Bare channel ids (legacy) decode as `(channel, None)`. +pub(crate) fn decode_session_chat_id(chat_id: &str) -> (&str, Option<&str>) { + match chat_id.split_once(':') { + Some((channel, thread_ts)) if !channel.is_empty() && !thread_ts.is_empty() => { + (channel, Some(thread_ts)) + } + _ => (chat_id, None), + } +} + +/// Thread root for an inbound event: existing thread, else this message ts. +pub(crate) fn thread_root_for_event(event: &SlackEvent) -> Option { + event + .thread_ts + .clone() + .or_else(|| event.ts.clone()) + .filter(|s| !s.is_empty()) +} + /// Whether the message text @mentions the bot user. pub(crate) fn text_mentions_bot(text: &str, bot_user_id: &str) -> bool { if bot_user_id.is_empty() { @@ -326,4 +358,34 @@ mod tests { fn strip_bot_mention_cleans_text() { assert_eq!(strip_bot_mention("<@U_BOT> run tests", "U_BOT"), "run tests"); } + + #[test] + fn session_chat_id_roundtrip() { + let encoded = encode_session_chat_id("D123", "1710000.000100"); + assert_eq!(encoded, "D123:1710000.000100"); + let (ch, thr) = decode_session_chat_id(&encoded); + assert_eq!(ch, "D123"); + assert_eq!(thr, Some("1710000.000100")); + } + + #[test] + fn session_chat_id_legacy_bare_channel() { + let (ch, thr) = decode_session_chat_id("D123"); + assert_eq!(ch, "D123"); + assert!(thr.is_none()); + } + + #[test] + fn thread_root_top_level_uses_message_ts() { + let event = msg("D1", "im", "hi"); + assert_eq!(thread_root_for_event(&event).as_deref(), Some("1.0")); + } + + #[test] + fn thread_root_in_thread_uses_thread_ts() { + let mut event = msg("D1", "im", "follow up"); + event.thread_ts = Some("1.0".into()); + event.ts = Some("2.0".into()); + assert_eq!(thread_root_for_event(&event).as_deref(), Some("1.0")); + } } From fd7470e3006c1376e750eab03fb5317e0923fcaa Mon Sep 17 00:00:00 2001 From: Matheus Teixeira <707561+mtxr@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:49:09 -0300 Subject: [PATCH 6/8] feat(channel): convert Markdown to Slack mrkdwn for rendered replies Slack does not render standard Markdown (** / ##). Format assistant output to Slack mrkdwn (*bold*, header lines as bold, links as ) via format_text_for_platform so stream_relay posts render correctly. --- crates/aionui-channel/src/formatter.rs | 101 ++++++++++++++++++ crates/aionui-channel/tests/formatter_test.rs | 29 ++++- 2 files changed, 129 insertions(+), 1 deletion(-) diff --git a/crates/aionui-channel/src/formatter.rs b/crates/aionui-channel/src/formatter.rs index b1bf039d1..cb94e2db9 100644 --- a/crates/aionui-channel/src/formatter.rs +++ b/crates/aionui-channel/src/formatter.rs @@ -8,12 +8,14 @@ use crate::types::PluginType; /// /// - Telegram: escape HTML, then convert markdown → HTML tags /// - Lark/DingTalk: convert HTML tags → markdown +/// - Slack: convert common markdown → Slack `mrkdwn` /// - WeChat/WeCom: strip all HTML /// - Fallback: escape HTML special chars pub fn format_text_for_platform(text: &str, platform: PluginType) -> String { match platform { PluginType::Telegram => markdown_to_telegram_html(text), PluginType::Lark | PluginType::Dingtalk => html_to_markdown(text), + PluginType::Slack => markdown_to_slack_mrkdwn(text), PluginType::Weixin => strip_html(text), _ => escape_html(text), } @@ -65,6 +67,105 @@ fn html_to_markdown(text: &str) -> String { strip_tags_loop(s.as_ref()) } +// ── Slack mrkdwn ───────────────────────────────────────────────── +// +// Slack does not render standard Markdown. With `mrkdwn: true` it expects: +// *bold* _italic_ ~strike~ `code` ```blocks``` +// links +// Headers (##) are not supported — convert to bold lines. + +static RE_SLACK_HEADER: LazyLock = LazyLock::new(|| Regex::new(r"(?m)^(#{1,6})\s+(.+)$").unwrap()); +static RE_SLACK_STRIKE: LazyLock = LazyLock::new(|| Regex::new(r"~~(.+?)~~").unwrap()); +static RE_SLACK_BOLD_STAR: LazyLock = LazyLock::new(|| Regex::new(r"\*\*(.+?)\*\*").unwrap()); +static RE_SLACK_BOLD_UNDER: LazyLock = LazyLock::new(|| Regex::new(r"__(.+?)__").unwrap()); +// Single-asterisk italic only when not already Slack bold (*text*). +static RE_SLACK_ITALIC_STAR: LazyLock = + LazyLock::new(|| Regex::new(r"(?P
^|[^*])\*(?P[^*\n]+?)\*(?P$|[^*])").unwrap());
+
+/// Convert common Markdown to Slack mrkdwn.
+fn markdown_to_slack_mrkdwn(text: &str) -> String {
+    // Protect fenced/inline code so formatting inside them is left alone.
+    let mut blocks: Vec = Vec::new();
+    let s = RE_CODE_BLOCK.replace_all(text, |caps: ®ex::Captures| {
+        let idx = blocks.len();
+        blocks.push(format!("```{}```", &caps[1]));
+        format!("\u{E000}BLOCK{idx}\u{E001}")
+    });
+    let mut inlines: Vec = Vec::new();
+    let s = RE_INLINE_CODE.replace_all(&s, |caps: ®ex::Captures| {
+        let idx = inlines.len();
+        inlines.push(format!("`{}`", &caps[1]));
+        format!("\u{E000}CODE{idx}\u{E001}")
+    });
+
+    // Links before other markup so brackets don't get mangled.
+    let s = RE_LINK.replace_all(&s, "<$2|$1>");
+
+    // Headers → bold (protect placeholders so italic pass won't rewrite them).
+    let mut bolds: Vec = Vec::new();
+    let s = RE_SLACK_HEADER.replace_all(&s, |caps: ®ex::Captures| {
+        let idx = bolds.len();
+        bolds.push(caps[2].to_owned());
+        format!("\u{E000}BOLD{idx}\u{E001}")
+    });
+
+    // Strikethrough ~~x~~ → ~x~
+    let s = RE_SLACK_STRIKE.replace_all(&s, "~$1~");
+
+    // Bold **x** / __x__ → placeholders (Slack bold is *x*, applied on restore)
+    let s = RE_SLACK_BOLD_STAR.replace_all(&s, |caps: ®ex::Captures| {
+        let idx = bolds.len();
+        bolds.push(caps[1].to_owned());
+        format!("\u{E000}BOLD{idx}\u{E001}")
+    });
+    let s = RE_SLACK_BOLD_UNDER.replace_all(&s, |caps: ®ex::Captures| {
+        let idx = bolds.len();
+        bolds.push(caps[1].to_owned());
+        format!("\u{E000}BOLD{idx}\u{E001}")
+    });
+
+    // Italic *x* (remaining singles) → _x_
+    let s = RE_SLACK_ITALIC_STAR.replace_all(&s, "${pre}_${body}_${post}");
+
+    // Materialize bold as Slack *text*
+    let mut s = s.into_owned();
+    for (i, body) in bolds.iter().enumerate() {
+        s = s.replace(&format!("\u{E000}BOLD{i}\u{E001}"), &format!("*{body}*"));
+    }
+    let s = s;
+
+    // Escape & < > that are not already part of  or placeholders.
+    // We escape ampersands first, then leave our intentional  alone by
+    // temporarily protecting them.
+    let mut links: Vec = Vec::new();
+    let s = {
+        static RE_SLACK_LINK: LazyLock =
+            LazyLock::new(|| Regex::new(r"<(https?://[^|>]+)\|([^>]+)>").unwrap());
+        RE_SLACK_LINK.replace_all(&s, |caps: ®ex::Captures| {
+            let idx = links.len();
+            links.push(caps[0].to_owned());
+            format!("\u{E000}LINK{idx}\u{E001}")
+        })
+    };
+    let s = s
+        .replace('&', "&")
+        .replace('<', "<")
+        .replace('>', ">");
+
+    // Restore protected segments (reverse order of replacement).
+    let mut out = s;
+    for (i, link) in links.iter().enumerate() {
+        out = out.replace(&format!("\u{E000}LINK{i}\u{E001}"), link);
+    }
+    for (i, code) in inlines.iter().enumerate() {
+        out = out.replace(&format!("\u{E000}CODE{i}\u{E001}"), code);
+    }
+    for (i, block) in blocks.iter().enumerate() {
+        out = out.replace(&format!("\u{E000}BLOCK{i}\u{E001}"), block);
+    }
+    out
+}
+
 // ── WeChat ───────────────────────────────────────────────────────
 
 fn strip_html(text: &str) -> String {
diff --git a/crates/aionui-channel/tests/formatter_test.rs b/crates/aionui-channel/tests/formatter_test.rs
index 03fb4b755..2aef99bb8 100644
--- a/crates/aionui-channel/tests/formatter_test.rs
+++ b/crates/aionui-channel/tests/formatter_test.rs
@@ -97,11 +97,38 @@ fn weixin_nested_tags() {
     assert!(!result.contains('<'), "got: {result}");
 }
 
+// ── Slack: markdown → mrkdwn ─────────────────────────────────────
+
+#[test]
+fn slack_bold_and_headers() {
+    let input = "## Conforto e diversão\n\n**9. Sistema de som multiroom**";
+    let result = format_text_for_platform(input, PluginType::Slack);
+    assert!(result.contains("*Conforto e diversão*"), "got: {result}");
+    assert!(result.contains("*9. Sistema de som multiroom*"), "got: {result}");
+    assert!(!result.contains("##"), "got: {result}");
+    assert!(!result.contains("**"), "got: {result}");
+}
+
+#[test]
+fn slack_inline_code_and_links() {
+    let input = "see `foo` and [docs](https://example.com)";
+    let result = format_text_for_platform(input, PluginType::Slack);
+    assert!(result.contains("`foo`"), "got: {result}");
+    assert!(result.contains(""), "got: {result}");
+}
+
+#[test]
+fn slack_escapes_raw_angles() {
+    let input = "a  tag";
+    let result = format_text_for_platform(input, PluginType::Slack);
+    assert!(result.contains("<b>"), "got: {result}");
+}
+
 // ── Fallback: escape HTML ────────────────────────────────────────
 
 #[test]
 fn fallback_escapes_html() {
     let input = "bold";
-    let result = format_text_for_platform(input, PluginType::Slack);
+    let result = format_text_for_platform(input, PluginType::Discord);
     assert!(result.contains("<b>"), "got: {result}");
 }

From ffeded75b9a114967b5a7e1daca69d3856e2c276 Mon Sep 17 00:00:00 2001
From: Matheus Teixeira <707561+mtxr@users.noreply.github.com>
Date: Sat, 1 Aug 2026 11:09:45 -0300
Subject: [PATCH 7/8] fix(channel): unique Slack conversation titles and
 ConversationSource::Slack

Composite chat_ids (channel:thread_root) now include a thread suffix in the
sidebar name so threads in the same DM no longer collide. Map Slack to its
own conversation source instead of the generic aionui fallback.
---
 crates/aionui-channel/src/message_service.rs | 65 ++++++++++++++++++--
 crates/aionui-common/src/enums.rs            |  1 +
 2 files changed, 60 insertions(+), 6 deletions(-)

diff --git a/crates/aionui-channel/src/message_service.rs b/crates/aionui-channel/src/message_service.rs
index a35601b16..83943dedc 100644
--- a/crates/aionui-channel/src/message_service.rs
+++ b/crates/aionui-channel/src/message_service.rs
@@ -365,8 +365,9 @@ fn platform_to_source(platform: PluginType) -> ConversationSource {
         PluginType::Lark => ConversationSource::Lark,
         PluginType::Dingtalk => ConversationSource::Dingtalk,
         PluginType::Weixin => ConversationSource::Weixin,
-        // Reserved variants default to Aionui
-        PluginType::Slack | PluginType::Discord => ConversationSource::Aionui,
+        PluginType::Slack => ConversationSource::Slack,
+        // Discord keeps the generic source until a dedicated channel ships.
+        PluginType::Discord => ConversationSource::Aionui,
     }
 }
 
@@ -419,12 +420,35 @@ fn channel_conversation_name(
         parts.push(b.to_owned());
     }
     if let Some(cid) = chat_id {
-        let end = cid.len().min(8);
-        parts.push(cid[..end].to_owned());
+        parts.push(chat_id_name_suffix(cid));
     }
     parts.join("-")
 }
 
+/// Short, display-friendly slug from a session `chat_id`.
+///
+/// Plain ids (Telegram, etc.) keep the historical first-8 truncation.
+/// Composite keys like Slack `{channel}:{thread_root}` also include a
+/// thread suffix so two threads in the same channel do not share the same
+/// conversation title in the sidebar.
+fn chat_id_name_suffix(chat_id: &str) -> String {
+    match chat_id.split_once(':') {
+        Some((channel, thread)) if !channel.is_empty() && !thread.is_empty() => {
+            let channel_part: String = channel.chars().take(8).collect();
+            // Slack thread ts is e.g. "1712345678.123456" — keep alphanumerics only
+            // and take the last 8 so nearby threads still differ.
+            let thread_digits: String = thread.chars().filter(|c| c.is_ascii_alphanumeric()).collect();
+            let thread_part = if thread_digits.len() <= 8 {
+                thread_digits
+            } else {
+                thread_digits[thread_digits.len() - 8..].to_owned()
+            };
+            format!("{channel_part}-{thread_part}")
+        }
+        _ => chat_id.chars().take(8).collect(),
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -457,8 +481,12 @@ mod tests {
     }
 
     #[test]
-    fn platform_to_source_reserved_defaults_to_aionui() {
-        assert_eq!(platform_to_source(PluginType::Slack), ConversationSource::Aionui);
+    fn platform_to_source_slack() {
+        assert_eq!(platform_to_source(PluginType::Slack), ConversationSource::Slack);
+    }
+
+    #[test]
+    fn platform_to_source_discord_defaults_to_aionui() {
         assert_eq!(platform_to_source(PluginType::Discord), ConversationSource::Aionui);
     }
 
@@ -706,4 +734,29 @@ mod tests {
         let name = channel_conversation_name(PluginType::Telegram, "aionrs", Some("claude"), Some("70880480"));
         assert_eq!(name, "tg-aionrs-70880480");
     }
+
+    #[test]
+    fn conv_name_slack_composite_chat_id_includes_thread() {
+        let a = channel_conversation_name(
+            PluginType::Slack,
+            "aionrs",
+            None,
+            Some("D0BKLLCM:1710000000.000100"),
+        );
+        let b = channel_conversation_name(
+            PluginType::Slack,
+            "aionrs",
+            None,
+            Some("D0BKLLCM:1710000000.000200"),
+        );
+        assert_eq!(a, "slack-aionrs-D0BKLLCM-00000100");
+        assert_eq!(b, "slack-aionrs-D0BKLLCM-00000200");
+        assert_ne!(a, b);
+    }
+
+    #[test]
+    fn chat_id_suffix_plain_still_truncates_to_eight() {
+        assert_eq!(chat_id_name_suffix("123456789abcdef"), "12345678");
+        assert_eq!(chat_id_name_suffix("70880480"), "70880480");
+    }
 }
diff --git a/crates/aionui-common/src/enums.rs b/crates/aionui-common/src/enums.rs
index 9c88aaa4b..365874d98 100644
--- a/crates/aionui-common/src/enums.rs
+++ b/crates/aionui-common/src/enums.rs
@@ -143,6 +143,7 @@ pub enum ConversationSource {
     Lark,
     Dingtalk,
     Weixin,
+    Slack,
 }
 
 /// Type discriminant for messages in a conversation.

From 0fb6ed1a8c9498d2db8829b1afa267c456a95150 Mon Sep 17 00:00:00 2001
From: Matheus Teixeira <707561+mtxr@users.noreply.github.com>
Date: Sat, 1 Aug 2026 12:20:18 -0300
Subject: [PATCH 8/8] style(channel): rustfmt Slack plugin sources

---
 crates/aionui-channel/src/formatter.rs        |  8 ++----
 crates/aionui-channel/src/message_service.rs  | 14 ++---------
 .../src/plugins/slack/plugin.rs               | 10 +++-----
 .../aionui-channel/src/plugins/slack/types.rs | 25 ++++---------------
 4 files changed, 12 insertions(+), 45 deletions(-)

diff --git a/crates/aionui-channel/src/formatter.rs b/crates/aionui-channel/src/formatter.rs
index cb94e2db9..d3931d6ae 100644
--- a/crates/aionui-channel/src/formatter.rs
+++ b/crates/aionui-channel/src/formatter.rs
@@ -139,18 +139,14 @@ fn markdown_to_slack_mrkdwn(text: &str) -> String {
     // temporarily protecting them.
     let mut links: Vec = Vec::new();
     let s = {
-        static RE_SLACK_LINK: LazyLock =
-            LazyLock::new(|| Regex::new(r"<(https?://[^|>]+)\|([^>]+)>").unwrap());
+        static RE_SLACK_LINK: LazyLock = LazyLock::new(|| Regex::new(r"<(https?://[^|>]+)\|([^>]+)>").unwrap());
         RE_SLACK_LINK.replace_all(&s, |caps: ®ex::Captures| {
             let idx = links.len();
             links.push(caps[0].to_owned());
             format!("\u{E000}LINK{idx}\u{E001}")
         })
     };
-    let s = s
-        .replace('&', "&")
-        .replace('<', "<")
-        .replace('>', ">");
+    let s = s.replace('&', "&").replace('<', "<").replace('>', ">");
 
     // Restore protected segments (reverse order of replacement).
     let mut out = s;
diff --git a/crates/aionui-channel/src/message_service.rs b/crates/aionui-channel/src/message_service.rs
index 83943dedc..cd5904d75 100644
--- a/crates/aionui-channel/src/message_service.rs
+++ b/crates/aionui-channel/src/message_service.rs
@@ -737,18 +737,8 @@ mod tests {
 
     #[test]
     fn conv_name_slack_composite_chat_id_includes_thread() {
-        let a = channel_conversation_name(
-            PluginType::Slack,
-            "aionrs",
-            None,
-            Some("D0BKLLCM:1710000000.000100"),
-        );
-        let b = channel_conversation_name(
-            PluginType::Slack,
-            "aionrs",
-            None,
-            Some("D0BKLLCM:1710000000.000200"),
-        );
+        let a = channel_conversation_name(PluginType::Slack, "aionrs", None, Some("D0BKLLCM:1710000000.000100"));
+        let b = channel_conversation_name(PluginType::Slack, "aionrs", None, Some("D0BKLLCM:1710000000.000200"));
         assert_eq!(a, "slack-aionrs-D0BKLLCM-00000100");
         assert_eq!(b, "slack-aionrs-D0BKLLCM-00000200");
         assert_ne!(a, b);
diff --git a/crates/aionui-channel/src/plugins/slack/plugin.rs b/crates/aionui-channel/src/plugins/slack/plugin.rs
index d662dc2d4..0a3942bf4 100644
--- a/crates/aionui-channel/src/plugins/slack/plugin.rs
+++ b/crates/aionui-channel/src/plugins/slack/plugin.rs
@@ -20,7 +20,7 @@ use crate::types::{
 
 use super::api::SlackApi;
 use super::types::{
-    AcceptDecision, ChatPostMessageRequest, ChatUpdateRequest, EventsApiPayload, SocketEnvelope, SlackEvent,
+    AcceptDecision, ChatPostMessageRequest, ChatUpdateRequest, EventsApiPayload, SlackEvent, SocketEnvelope,
     classify_event, decode_session_chat_id, encode_session_chat_id, is_dm_event, parse_allowed_channels,
     strip_bot_mention, thread_root_for_event,
 };
@@ -277,8 +277,7 @@ async fn socket_mode_loop(
             break;
         }
 
-        match connect_and_listen(&api, &message_tx, &mut shutdown_rx, &allowed, &bot_user_id).await
-        {
+        match connect_and_listen(&api, &message_tx, &mut shutdown_rx, &allowed, &bot_user_id).await {
             Ok(()) => {
                 consecutive_errors = 0;
                 if *shutdown_rx.borrow() {
@@ -448,10 +447,7 @@ async fn handle_slack_event(
         "Slack event received"
     );
 
-    if !matches!(
-        decision,
-        AcceptDecision::AcceptDm | AcceptDecision::AcceptMention
-    ) {
+    if !matches!(decision, AcceptDecision::AcceptDm | AcceptDecision::AcceptMention) {
         return;
     }
 
diff --git a/crates/aionui-channel/src/plugins/slack/types.rs b/crates/aionui-channel/src/plugins/slack/types.rs
index 6e7b87999..cc6569519 100644
--- a/crates/aionui-channel/src/plugins/slack/types.rs
+++ b/crates/aionui-channel/src/plugins/slack/types.rs
@@ -121,11 +121,7 @@ pub(crate) fn parse_allowed_channels(raw: Option<&str>) -> std::collections::Has
 
 /// Whether this event is a 1:1 DM (`im`).
 pub(crate) fn is_dm_event(event: &SlackEvent) -> bool {
-    matches!(event.channel_type.as_deref(), Some("im"))
-        || event
-            .channel
-            .as_deref()
-            .is_some_and(|c| c.starts_with('D'))
+    matches!(event.channel_type.as_deref(), Some("im")) || event.channel.as_deref().is_some_and(|c| c.starts_with('D'))
 }
 
 /// Hermes-style session key: each Slack thread is its own conversation.
@@ -144,9 +140,7 @@ pub(crate) fn encode_session_chat_id(channel: &str, thread_root: &str) -> String
 /// Bare channel ids (legacy) decode as `(channel, None)`.
 pub(crate) fn decode_session_chat_id(chat_id: &str) -> (&str, Option<&str>) {
     match chat_id.split_once(':') {
-        Some((channel, thread_ts)) if !channel.is_empty() && !thread_ts.is_empty() => {
-            (channel, Some(thread_ts))
-        }
+        Some((channel, thread_ts)) if !channel.is_empty() && !thread_ts.is_empty() => (channel, Some(thread_ts)),
         _ => (chat_id, None),
     }
 }
@@ -300,10 +294,7 @@ mod tests {
     fn channel_needs_mention_even_when_allowlisted() {
         let allowed = parse_allowed_channels(Some("C_MAIN"));
         let plain = msg("C_MAIN", "channel", "hi everyone");
-        assert_eq!(
-            classify_event(&plain, "U_BOT", &allowed),
-            AcceptDecision::DropNoMention
-        );
+        assert_eq!(classify_event(&plain, "U_BOT", &allowed), AcceptDecision::DropNoMention);
         let mentioned = msg("C_MAIN", "channel", "<@U_BOT> hi");
         assert_eq!(
             classify_event(&mentioned, "U_BOT", &allowed),
@@ -326,10 +317,7 @@ mod tests {
         let allowed = parse_allowed_channels(Some("C_MAIN"));
         let mut event = msg("C_MAIN", "channel", "hi");
         event.event_type = "app_mention".into();
-        assert_eq!(
-            classify_event(&event, "U_BOT", &allowed),
-            AcceptDecision::AcceptMention
-        );
+        assert_eq!(classify_event(&event, "U_BOT", &allowed), AcceptDecision::AcceptMention);
     }
 
     #[test]
@@ -348,10 +336,7 @@ mod tests {
         let allowed = std::collections::HashSet::new();
         let mut event = msg("D999", "channel", "hello");
         event.channel_type = None;
-        assert_eq!(
-            classify_event(&event, "U_BOT", &allowed),
-            AcceptDecision::AcceptDm
-        );
+        assert_eq!(classify_event(&event, "U_BOT", &allowed), AcceptDecision::AcceptDm);
     }
 
     #[test]