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/formatter.rs b/crates/aionui-channel/src/formatter.rs index b1bf039d1..d3931d6ae 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,101 @@ 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/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/message_service.rs b/crates/aionui-channel/src/message_service.rs
index a35601b16..cd5904d75 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,19 @@ 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-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..c0d44dd24
--- /dev/null
+++ b/crates/aionui-channel/src/plugins/slack/api.rs
@@ -0,0 +1,123 @@
+//! 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(),
+        }
+    }
+
+    /// `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..0a3942bf4
--- /dev/null
+++ b/crates/aionui-channel/src/plugins/slack/plugin.rs
@@ -0,0 +1,603 @@
+//! Slack channel plugin — Socket Mode (Hermes-style), DM + allowlisted @mentions.
+
+use std::collections::HashSet;
+use std::sync::Arc;
+use std::time::Duration;
+
+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::{
+    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,
+};
+
+/// 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,
+    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(),
+            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();
+
+        self.ws_handle = Some(tokio::spawn(socket_mode_loop(
+            api,
+            callbacks.message_tx,
+            shutdown_rx,
+            allowed,
+            bot_user_id,
+        )));
+
+        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.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 (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
+            .as_deref()
+            .or(session_thread)
+            .map(str::to_owned);
+
+        let req = ChatPostMessageRequest {
+            channel,
+            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 (channel, _) = decode_session_chat_id(chat_id);
+        let req = ChatUpdateRequest {
+            channel,
+            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,
+) {
+    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).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,
+) -> 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).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,
+) 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" => {
+            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) {
+                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).await;
+            } else {
+                warn!("Slack events_api payload missing event");
+            }
+        }
+        other => {
+            info!(envelope_type = other, "Slack Socket Mode envelope (ignored)");
+        }
+    }
+}
+
+async fn handle_slack_event(
+    event: SlackEvent,
+    message_tx: &mpsc::Sender,
+    allowed: &HashSet,
+    bot_user_id: &str,
+) {
+    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,
+        thread_ts = ?event.thread_ts,
+        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(),
+        _ => {
+            warn!("Slack accepted event missing channel");
+            return;
+        }
+    };
+    let user_id = match event.user.as_deref() {
+        Some(u) if !u.is_empty() => u.to_string(),
+        _ => {
+            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();
+    let text = if is_dm_event(&event) {
+        raw_text
+    } else {
+        strip_bot_mention(&raw_text, bot_user_id)
+    };
+
+    // 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: session_chat_id.clone(),
+        user: UnifiedUser {
+            id: user_id.clone(),
+            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(),
+        // Keep thread root so outbound path can still prefer explicit reply_to
+        reply_to_message_id: Some(thread_root.clone()),
+        action: None,
+        raw: None,
+    };
+
+    if message_tx.send(unified).await.is_err() {
+        error!("Slack message channel closed; orchestrator not receiving events");
+        return;
+    }
+    info!(
+        channel = %channel,
+        thread_root = %thread_root,
+        session_chat_id = %session_chat_id,
+        user = %user_id,
+        "Slack message forwarded to channel pipeline"
+    );
+}
+
+// ---------------------------------------------------------------------------
+// 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 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 std::sync::Arc;
+    use tokio_tungstenite::Connector;
+
+    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 = 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(Arc::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..cc6569519
--- /dev/null
+++ b/crates/aionui-channel/src/plugins/slack/types.rs
@@ -0,0 +1,376 @@
+//! 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 team: 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,
+}
+
+// ---------------------------------------------------------------------------
+// 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,
+}
+
+#[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,
+}
+
+// ---------------------------------------------------------------------------
+// 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'))
+}
+
+/// 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() {
+        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()
+}
+
+/// 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 classify_event(
+    event: &SlackEvent,
+    bot_user_id: &str,
+    allowed: &std::collections::HashSet,
+) -> AcceptDecision {
+    // Ignore bot-authored / system noise
+    if event.bot_id.is_some() {
+        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 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 AcceptDecision::DropEventType;
+    }
+
+    if is_dm_event(event) {
+        return AcceptDecision::AcceptDm;
+    }
+
+    let channel = match event.channel.as_deref() {
+        Some(c) if !c.is_empty() => c,
+        _ => return AcceptDecision::DropNoChannel,
+    };
+
+    if !allowed.contains(channel) {
+        return AcceptDecision::DropNotAllowlisted;
+    }
+
+    if is_app_mention {
+        return AcceptDecision::AcceptMention;
+    }
+
+    let text = event.text.as_deref().unwrap_or("");
+    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)]
+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,
+        }
+    }
+
+    #[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_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_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),
+            AcceptDecision::AcceptMention
+        );
+    }
+
+    #[test]
+    fn empty_allowlist_blocks_channels() {
+        let allowed = parse_allowed_channels(None);
+        let event = msg("C_MAIN", "channel", "<@U_BOT> hi");
+        assert_eq!(
+            classify_event(&event, "U_BOT", &allowed),
+            AcceptDecision::DropNotAllowlisted
+        );
+    }
+
+    #[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_eq!(classify_event(&event, "U_BOT", &allowed), AcceptDecision::AcceptMention);
+    }
+
+    #[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_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]
+    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"));
+    }
+}
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/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}");
 }
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,
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.