Skip to content

feat: add Slack adapter with multi-platform ChatAdapter architecture#259

Open
dogzzdogzz wants to merge 18 commits intoopenabdev:mainfrom
dogzzdogzz:feat/slack-adapter
Open

feat: add Slack adapter with multi-platform ChatAdapter architecture#259
dogzzdogzz wants to merge 18 commits intoopenabdev:mainfrom
dogzzdogzz:feat/slack-adapter

Conversation

@dogzzdogzz
Copy link
Copy Markdown
Contributor

@dogzzdogzz dogzzdogzz commented Apr 12, 2026

Discord discussion: https://discord.com/channels/1491295327620169908/1491969620754567270/1491969624135438386

Summary

  • Introduces ChatAdapter trait + AdapterRouter, decoupling session management, streaming, and reactions from any specific chat platform
  • Refactors existing Discord support to implement the new trait (no behavior change)
  • Adds Slack adapter using Socket Mode (WebSocket, no public URL needed) with auto-reconnect
  • Extracts shared image/audio processing into media.rs for cross-adapter reuse
  • Both adapters can run simultaneously, sharing one SessionPool with namespaced keys
  • Updates Helm chart to support Slack deployment (backward compatible)

Closes #93. Implements Phase 1–3 of RFC #94.

Changes

File Change
src/adapter.rs NewChatAdapter trait, ChannelRef/MessageRef/SenderContext, AdapterRouter
src/slack.rs NewSlackAdapter + Socket Mode event loop
src/media.rs New — shared image resize/compress + STT download (extracted from discord.rs)
src/discord.rs Refactored — DiscordAdapter implements ChatAdapter, delegates to router
src/reactions.rs Decoupled from serenity — uses Arc<dyn ChatAdapter>
src/format.rs Added shared shorten_thread_name()
src/config.rs discord now optional, added SlackConfig
src/main.rs Multi-adapter startup, Slack as background task
config.toml.example Added [slack] section with docs
charts/openab/values.yaml Added slack config per agent (enabled, botToken, appToken, allowedChannels, allowedUsers)
charts/openab/templates/configmap.yaml Conditionally generates [discord] and/or [slack] sections
charts/openab/templates/secret.yaml Stores slack-bot-token + slack-app-token alongside discord token
charts/openab/templates/deployment.yaml Injects SLACK_BOT_TOKEN + SLACK_APP_TOKEN env vars
charts/openab/templates/NOTES.txt Added Slack setup instructions

Design Decisions

  • Socket Mode over Events API — no public URL needed, mirrors Discord's persistent WebSocket model
  • Session keys namespaced by platform (discord:xxx, slack:xxx) to prevent collision
  • Sender context wrapping centralized in AdapterRouter (not duplicated per adapter)
  • Image/audio processing shared via media.rs with optional auth_token param (Discord CDN is public, Slack files need Bearer token)
  • Unicode → Slack emoji mapping in adapter (Slack reactions API requires short names like eyes not 👀)
  • Slack user name resolution via users.info API (Discord provides names in message payload, Slack only gives user ID)
  • Thread follow-ups — Slack message events with thread_ts handled without requiring re-mention (parity with Discord)
  • Helm backward compatible — existing Discord-only values work unchanged; [discord] section now conditional (omitted when no botToken)

Helm Usage

# Discord only (unchanged)
helm install openab openab/openab \
  --set agents.kiro.discord.botToken="$DISCORD_TOKEN" \
  --set-string 'agents.kiro.discord.allowedChannels[0]=CHANNEL_ID'

# Slack only
helm install openab openab/openab \
  --set agents.kiro.slack.enabled=true \
  --set agents.kiro.slack.botToken="$SLACK_BOT_TOKEN" \
  --set agents.kiro.slack.appToken="$SLACK_APP_TOKEN" \
  --set-string 'agents.kiro.slack.allowedChannels[0]=C0123456789'

# Both simultaneously
helm install openab openab/openab \
  --set agents.kiro.discord.botToken="$DISCORD_TOKEN" \
  --set-string 'agents.kiro.discord.allowedChannels[0]=DISCORD_CHANNEL' \
  --set agents.kiro.slack.enabled=true \
  --set agents.kiro.slack.botToken="$SLACK_BOT_TOKEN" \
  --set agents.kiro.slack.appToken="$SLACK_APP_TOKEN"

Test plan

  • cargo build — zero warnings
  • cargo test — 40 tests pass
  • cargo clippy — clean
  • helm template — Discord-only, Slack-only, and both modes validated
  • Discord-only config works identically (no regression)
  • Slack-only config starts Socket Mode and responds to @mentions
  • Discord + Slack simultaneous config works
  • Thread follow-up in Slack (reply without @mention)
  • Reactions display correctly in Slack
  • Image attachments processed in Slack
  • Auto-reconnect on Socket Mode disconnect

🤖 Generated with Claude Code

@chaodu-agent
Copy link
Copy Markdown
Collaborator

PR Review: feat: add Slack adapter with multi-platform ChatAdapter architecture

Author: Tony Lee (dogzzdogzz) | +1820 / -761 | 20 files changed


1. What problem does it solve?

OpenAB previously only supported Discord. Many engineering teams use Slack as their primary communication tool, so not supporting Slack blocks a large segment of enterprise users. This PR does two things:

  • Abstracts the Discord-hardcoded logic into a ChatAdapter trait, making the platform layer pluggable
  • Implements a Slack adapter (Socket Mode) so Slack users can @mention the bot, open threads, and interact with ACP agents

Closes #93 (Slack adapter proposal) and implements Phase 1–3 of RFC #94 (Multi-Platform Adapter Architecture).

2. How does it solve it?

Clean architectural split:

  • adapter.rs — Defines ChatAdapter trait (send/edit/thread/react) + AdapterRouter (shared session routing, streaming, reactions logic)
  • discord.rsDiscordAdapter implements ChatAdapter; the Handler becomes a thin serenity EventHandler that delegates to AdapterRouter
  • slack.rsSlackAdapter implements ChatAdapter using tokio-tungstenite for Socket Mode WebSocket directly — no third-party Slack SDK dependency
  • media.rs — Image resize/compress and STT download extracted from discord.rs, with an auth_token parameter for Slack private file downloads
  • reactions.rs — Decoupled from serenity, now uses Arc<dyn ChatAdapter>
  • main.rs — Slack runs as a background tokio task, Discord runs foreground (blocking); both can run simultaneously
  • Session keys namespaced by platform (discord:xxx, slack:xxx) to prevent collisions
  • Slack emoji mapping via unicode_to_slack_emoji() (Unicode → short name)
  • Helm chart is backward compatible; both [discord] and [slack] are now optional

3. Were alternatives considered?

From the PR description and RFC #94:

  • Events API + HTTP vs Socket Mode — Socket Mode was chosen because it requires no public URL and mirrors Discord's Gateway WebSocket model, which is more K8S-friendly
  • slack-morphism crate vs raw implementation — Issue feat: Slack adapter — bring agent-broker to Slack workspaces #93 originally proposed slack-morphism, but the PR uses tokio-tungstenite + raw Slack API directly. This reduces dependencies at the cost of maintaining WebSocket reconnection and API calls manually
  • Trait design — The RFC proposed a ChatAdapter with a start() method; the final implementation dropped it, letting each adapter manage its own lifecycle (Discord via serenity client, Slack via run_slack_adapter() function), which is more flexible

4. Is this the best approach?

Overall architecture is solid, the refactor is clean, and tests were correctly relocated to media.rs.


🟢 INFO

  • ChatAdapter trait design is concise; ChannelRef / MessageRef abstractions are generic enough that adding Telegram later should be straightforward
  • Centralizing streaming/session/reaction logic in AdapterRouter is the right call — avoids duplicating a large block of code per adapter
  • Helm chart backward compatibility is well handled; discord.enabled defaults to true so existing deployments won't break
  • Tests moved from discord.rs to media.rs without loss; 26 tests pass
  • docs/slack-bot-howto.md is thorough, and the troubleshooting section is practical

🟡 NIT

  • SlackAdapter calls users.info API on every handle_message to resolve display names — under high traffic this could hit Slack rate limits. An LRU cache with TTL would be more robust
  • unicode_to_slack_emoji() is a hardcoded mapping; if ReactionEmojis config is customized with emojis not in the map, it silently falls back to grey_question. It might be worth validating at config load time or documenting this limitation
  • get_socket_mode_url() creates a new reqwest::Client on every reconnect — could reuse media::HTTP_CLIENT or the adapter's own client
  • DiscordAdapter is instantiated via Arc::new(DiscordAdapter::new(ctx.http.clone())) on every message() event. It might be worth initializing once in ready() and storing in a OnceCell<Arc<dyn ChatAdapter>>

🔴 SUGGESTED CHANGES

  • Potential duplicate processing of Slack thread messages: When an app_mention event occurs inside a thread, Slack also fires a message event (since has_thread is true). There doesn't appear to be a dedup mechanism, so the same message could be processed twice by handle_message. It might be worth considering a seen-message dedup (e.g. HashSet<String> keyed by ts with TTL), or checking in the message handler whether the text contains a bot mention and skipping it
  • Slack adapter lacks graceful shutdown: Discord has shard_manager.shutdown_all(), but run_slack_adapter is an infinite loop that can only be killed via handle.abort(). The WebSocket connection won't be gracefully closed. Suggestion for maintainers: pass in a CancellationToken or watch::Receiver so the loop can exit cleanly
  • discord.enabled: true with empty botToken in values.yaml: The configmap template guards with if and ($cfg.discord).enabled ($cfg.discord).botToken, so an empty token effectively disables it — which is correct. However, it might be worth explicitly documenting that enabled: true without a token is treated as disabled, to avoid user confusion

Overall this is a high-quality PR. The architecture aligns with the RFC, the refactor introduces no regressions, and Helm backward compatibility is maintained. The main areas to watch are Slack-side event dedup and graceful shutdown.

Review by 超渡法師 🙏

@dogzzdogzz
Copy link
Copy Markdown
Contributor Author

@chaodu-agent Thanks for the thorough review! Just pushed a commit addressing all feedback:

🔴 Suggested Changes:

  • Duplicate processing of Slack thread messages: Fixed. The message handler now checks if the message @mentions the bot (using bot user ID from auth.test API) and skips it — app_mention handles those. This is precise: mentioning other users in thread replies won't be skipped.

  • Slack adapter graceful shutdown: Fixed. run_slack_adapter now accepts a watch::Receiver<bool> shutdown signal. The WebSocket loop uses tokio::select! to listen for both messages and shutdown simultaneously. On shutdown, it sends a WebSocket Close frame before exiting. main.rs signals via watch::channel and waits up to 5 seconds for clean exit.

  • discord.enabled: true + empty botToken: Documented in values.yaml — "enabled with empty botToken is treated as disabled".

🟡 NITs:

  • users.info LRU cache: Added a HashMap cache with 5-minute TTL on SlackAdapter. Cache hit returns immediately, miss calls API then caches. Avoids hitting Slack rate limits under traffic.

  • unicode_to_slack_emoji fallback: Documented — comment notes it only covers the default emoji set and custom emoji falls back to grey_question.

  • DiscordAdapter created per message: Fixed. Now uses OnceLock on Handler — initialized on first message, reused for all subsequent events. Verified that serenity's Arc<Http> is stable across reconnects (created once at Client construction, never recreated).

  • get_socket_mode_url creates new client: Skipped — only runs on reconnect (rare), negligible cost.

Commit: b04d9e2

@antigenius0910
Copy link
Copy Markdown

antigenius0910 commented Apr 14, 2026

Thanks for the detailed explanation @dogzzdogzz! The overall architecture is really clean. the ChatAdapter trait, centralising streaming/session/reaction logic in AdapterRouter, and the Socket Mode choice all make a lot of sense. We learned a lot reading through it.

One thing we wanted to double-check with you. regarding "prompt streaming doesn't hold the pool lock". We wrote a small timing test to verify:

// Reproduces the with_connection locking pattern
async fn with_lock(...) {
    let mut guard = map.write().await;    // write lock acquired
    let val = guard.get_mut(key).unwrap();
    f(val).await                          // lock held until f() completes
}
// Two tasks with different keys (discord: / slack:), each sleeping 1s, started concurrently
[BEFORE] Elapsed: 2.01s  ← serial

The conns WriteGuard inside with_connection() can't be dropped while conn borrows from it, so the write lock is held for the full duration of f(conn).await — which includes the entire streaming loop.

We tried a small fix: changing RwLock<HashMap<String, AcpConnection>> to RwLock<HashMap<String, Arc<Mutex<AcpConnection>>>> so the map lock is released right after the Arc::clone(), and only the per-session Mutex is held during streaming:

[AFTER]  Elapsed: 1.00s  ← concurrent

You can reproduce both results with cargo test lock_contention -- --nocapture.

This might be out of scope for this PR and if there's something we've missed or you see it differently, we'd love to hear it. Just wanted to make sure this was on the radar! 🙏

@dogzzdogzz
Copy link
Copy Markdown
Contributor Author

@antigenius0910 Great catch — you're absolutely right. with_connection() holds the pool write lock for the entire duration of the streaming closure, which serializes all concurrent sessions. Your timing test clearly demonstrates the issue.

This is a pre-existing upstream design in pool.rs — our PR doesn't modify with_connection(), we only call it. But multi-platform support makes it more visible since Discord and Slack now share the same SessionPool.

The Arc<Mutex<AcpConnection>> approach you tested is the correct fix, and aligns with what @ruan330 already validated in production (see #78 comment). Since upstream just refactored pool.rs in v0.7.3 (added PoolState, suspend/resume, LRU eviction), I'd suggest we keep this PR focused on the adapter layer and follow up on the per-connection lock change in #78 after this merges.

Your test case would be a great addition to that discussion — concrete proof of the lock contention and the fix. Would you be up for posting it there?

@thepagent
Copy link
Copy Markdown
Collaborator

CI is failing.

Copy link
Copy Markdown
Collaborator

@chaodu-agent chaodu-agent left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT Fixes — Inline Suggested Changes

Here are the concrete fixes for the NITs identified in the review. Each suggestion can be applied individually via GitHub's "Apply suggestion" button.

Summary:

  1. Cargo.toml — Add lru crate
  2. src/config.rs — Add ReactionEmojis::all_emojis() for validation (posted as regular comment — outside diff hunk)
  3. src/slack.rsunicode_to_slack_emoji() returns Option, add validate_emoji_mapping() startup check
  4. src/slack.rs — Replace unbounded HashMap user cache with lru::LruCache (cap 1000)
  5. src/slack.rs — Reuse media::HTTP_CLIENT in get_socket_mode_url()
  6. src/adapter.rs#[must_use] on ChatAdapter trait methods, improved doc comments, reactions_emojis() accessor

config.rs — outside diff hunk

Add after impl Default for ReactionTiming block (after line 182):

impl ReactionEmojis {
    /// Return all configured emoji values for validation purposes.
    pub fn all_emojis(&self) -> Vec<&str> {
        vec![
            &self.queued, &self.thinking, &self.tool, &self.coding,
            &self.web, &self.done, &self.error,
        ]
    }
}

Suggestions by 超渡法師 🙏

base64 = "0.22"
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] }
libc = "0.2"
tokio-tungstenite = { version = "0.21", features = ["rustls-tls-webpki-roots"] }
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add lru crate for bounded user cache:

Suggested change
tokio-tungstenite = { version = "0.21", features = ["rustls-tls-webpki-roots"] }
libc = "0.2"
lru = "0.12"
tokio-tungstenite = { version = "0.21", features = ["rustls-tls-webpki-roots"] }

Comment on lines +8 to +9
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, LazyLock};
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Import NonZeroUsize for LRU capacity, drop HashMap:

Suggested change
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, LazyLock};
use std::collections::HashSet;
use std::num::NonZeroUsize;

Comment on lines +16 to +44
/// Map Unicode emoji to Slack short names for reactions API.
/// Only covers the default `[reactions.emojis]` set. Custom emoji configured
/// outside this map will fall back to `grey_question`.
fn unicode_to_slack_emoji(unicode: &str) -> &str {
match unicode {
"👀" => "eyes",
"🤔" => "thinking_face",
"🔥" => "fire",
"👨\u{200d}💻" => "technologist",
"⚡" => "zap",
"🆗" => "ok",
"😱" => "scream",
"🚫" => "no_entry_sign",
"😊" => "blush",
"😎" => "sunglasses",
"🫡" => "saluting_face",
"🤓" => "nerd_face",
"😏" => "smirk",
"✌\u{fe0f}" => "v",
"💪" => "muscle",
"🦾" => "mechanical_arm",
"🥱" => "yawning_face",
"😨" => "fearful",
"✅" => "white_check_mark",
"❌" => "x",
"🔧" => "wrench",
_ => "grey_question",
}
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Return Option so unmapped emoji can be detected at startup. Add validate_emoji_mapping() to warn operators about missing mappings:

Suggested change
/// Map Unicode emoji to Slack short names for reactions API.
/// Only covers the default `[reactions.emojis]` set. Custom emoji configured
/// outside this map will fall back to `grey_question`.
fn unicode_to_slack_emoji(unicode: &str) -> &str {
match unicode {
"👀" => "eyes",
"🤔" => "thinking_face",
"🔥" => "fire",
"👨\u{200d}💻" => "technologist",
"⚡" => "zap",
"🆗" => "ok",
"😱" => "scream",
"🚫" => "no_entry_sign",
"😊" => "blush",
"😎" => "sunglasses",
"🫡" => "saluting_face",
"🤓" => "nerd_face",
"😏" => "smirk",
"✌\u{fe0f}" => "v",
"💪" => "muscle",
"🦾" => "mechanical_arm",
"🥱" => "yawning_face",
"😨" => "fearful",
"✅" => "white_check_mark",
"❌" => "x",
"🔧" => "wrench",
_ => "grey_question",
}
}
/// Map Unicode emoji to Slack short names for reactions API.
/// Returns `None` for unmapped emoji.
fn unicode_to_slack_emoji(unicode: &str) -> Option<&'static str> {
match unicode {
"👀" => Some("eyes"),
"🤔" => Some("thinking_face"),
"🔥" => Some("fire"),
"👨\u200d💻" => Some("technologist"),
"⚡" => Some("zap"),
"🆗" => Some("ok"),
"😱" => Some("scream"),
"🚫" => Some("no_entry_sign"),
"😊" => Some("blush"),
"😎" => Some("sunglasses"),
"🫡" => Some("saluting_face"),
"🤓" => Some("nerd_face"),
"😏" => Some("smirk"),
"✌\ufe0f" => Some("v"),
"💪" => Some("muscle"),
"🦾" => Some("mechanical_arm"),
"🥱" => Some("yawning_face"),
"😨" => Some("fearful"),
"✅" => Some("white_check_mark"),
"❌" => Some("x"),
"🔧" => Some("wrench"),
_ => None,
}
}
/// Validate that all configured reaction emojis have Slack mappings.
/// Logs a warning at startup for any unmapped emoji.
pub fn validate_emoji_mapping(emojis: &crate::config::ReactionEmojis) {
for emoji in emojis.all_emojis() {
if unicode_to_slack_emoji(emoji).is_none() {
warn!(
emoji,
"reaction emoji has no Slack mapping — will show as :grey_question:"
);
}
}
}

Comment on lines +48 to +56
/// TTL for cached user display names (5 minutes).
const USER_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(300);

pub struct SlackAdapter {
client: reqwest::Client,
bot_token: String,
bot_user_id: tokio::sync::OnceCell<String>,
user_cache: tokio::sync::Mutex<HashMap<String, (String, tokio::time::Instant)>>,
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replace unbounded HashMap with lru::LruCache capped at 1000 entries to prevent slow memory growth in large workspaces:

Suggested change
/// TTL for cached user display names (5 minutes).
const USER_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(300);
pub struct SlackAdapter {
client: reqwest::Client,
bot_token: String,
bot_user_id: tokio::sync::OnceCell<String>,
user_cache: tokio::sync::Mutex<HashMap<String, (String, tokio::time::Instant)>>,
}
/// TTL for cached user display names (5 minutes).
const USER_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(300);
/// Maximum number of cached user display names.
const USER_CACHE_CAPACITY: NonZeroUsize = unsafe { NonZeroUsize::new_unchecked(1000) };
pub struct SlackAdapter {
client: reqwest::Client,
bot_token: String,
bot_user_id: tokio::sync::OnceCell<String>,
user_cache: tokio::sync::Mutex<lru::LruCache<String, (String, tokio::time::Instant)>>,
}

client: reqwest::Client::new(),
bot_token,
bot_user_id: tokio::sync::OnceCell::new(),
user_cache: tokio::sync::Mutex::new(HashMap::new()),
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Init with bounded LRU:

Suggested change
user_cache: tokio::sync::Mutex::new(HashMap::new()),
user_cache: tokio::sync::Mutex::new(lru::LruCache::new(USER_CACHE_CAPACITY)),

/// Edit an existing message in-place.
async fn edit_message(&self, msg: &MessageRef, content: &str) -> Result<()>;

/// Create a thread from a trigger message, returns the thread channel ref.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// Create a thread from a trigger message, returns the thread channel ref.
/// Create a thread from a trigger message, returns the thread channel ref.
#[must_use = "create_thread returns a Result that should be checked"]

Comment on lines +71 to +72
/// Add a reaction/emoji to a message.
async fn add_reaction(&self, msg: &MessageRef, emoji: &str) -> Result<()>;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// Add a reaction/emoji to a message.
async fn add_reaction(&self, msg: &MessageRef, emoji: &str) -> Result<()>;
/// Add a reaction/emoji to a message.
#[must_use = "add_reaction returns a Result that should be checked"]
async fn add_reaction(&self, msg: &MessageRef, emoji: &str) -> Result<()>;

Comment on lines +74 to +75
/// Remove a reaction/emoji from a message.
async fn remove_reaction(&self, msg: &MessageRef, emoji: &str) -> Result<()>;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// Remove a reaction/emoji from a message.
async fn remove_reaction(&self, msg: &MessageRef, emoji: &str) -> Result<()>;
/// Remove a reaction/emoji from a message.
#[must_use = "remove_reaction returns a Result that should be checked"]
async fn remove_reaction(&self, msg: &MessageRef, emoji: &str) -> Result<()>;

Comment on lines +80 to +82
/// Shared logic for routing messages to ACP agents, managing sessions,
/// streaming edits, and controlling reactions. Platform-independent.
pub struct AdapterRouter {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Expanded doc comment for AdapterRouter:

Suggested change
/// Shared logic for routing messages to ACP agents, managing sessions,
/// streaming edits, and controlling reactions. Platform-independent.
pub struct AdapterRouter {
/// Shared logic for routing messages to ACP agents, managing sessions,
/// streaming edits, and controlling reactions.
///
/// Platform adapters call [`AdapterRouter::handle_message`] after resolving
/// platform-specific details (threads, sender names, file attachments).
/// The router owns the [`SessionPool`] and [`ReactionsConfig`], keeping
/// all session and streaming logic in one place rather than duplicated
/// per adapter.
pub struct AdapterRouter {

Comment on lines +92 to +93
}
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add accessor for emoji validation at startup:

Suggested change
}
}
}
}
/// Return the configured reaction emojis (for startup validation).
pub fn reactions_emojis(&self) -> &ReactionEmojis {
&self.reactions_config.emojis
}

Copy link
Copy Markdown
Collaborator

@chaodu-agent chaodu-agent left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT Fixes — Inline Suggested Changes

Here are the concrete fixes for the NITs identified in the review. Each suggestion can be applied individually via GitHub's "Apply suggestion" button.

Summary:

  1. Cargo.toml — Add lru crate
  2. src/config.rs — Add ReactionEmojis::all_emojis() for validation (posted as regular comment — outside diff hunk)
  3. src/slack.rsunicode_to_slack_emoji() returns Option, add validate_emoji_mapping() startup check
  4. src/slack.rs — Replace unbounded HashMap user cache with lru::LruCache (cap 1000)
  5. src/slack.rs — Reuse media::HTTP_CLIENT in get_socket_mode_url()
  6. src/adapter.rs#[must_use] on ChatAdapter trait methods, improved doc comments, reactions_emojis() accessor

config.rs — outside diff hunk

Add after impl Default for ReactionTiming block (after line 182):

impl ReactionEmojis {
    /// Return all configured emoji values for validation purposes.
    pub fn all_emojis(&self) -> Vec<&str> {
        vec![
            &self.queued, &self.thinking, &self.tool, &self.coding,
            &self.web, &self.done, &self.error,
        ]
    }
}

Suggestions by 超渡法師 🙏

base64 = "0.22"
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] }
libc = "0.2"
tokio-tungstenite = { version = "0.21", features = ["rustls-tls-webpki-roots"] }
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add lru crate for bounded user cache:

Suggested change
tokio-tungstenite = { version = "0.21", features = ["rustls-tls-webpki-roots"] }
libc = "0.2"
lru = "0.12"
tokio-tungstenite = { version = "0.21", features = ["rustls-tls-webpki-roots"] }

Comment on lines +8 to +9
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, LazyLock};
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Import NonZeroUsize for LRU capacity, drop HashMap:

Suggested change
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, LazyLock};
use std::collections::HashSet;
use std::num::NonZeroUsize;

Comment on lines +16 to +44
/// Map Unicode emoji to Slack short names for reactions API.
/// Only covers the default `[reactions.emojis]` set. Custom emoji configured
/// outside this map will fall back to `grey_question`.
fn unicode_to_slack_emoji(unicode: &str) -> &str {
match unicode {
"👀" => "eyes",
"🤔" => "thinking_face",
"🔥" => "fire",
"👨\u{200d}💻" => "technologist",
"⚡" => "zap",
"🆗" => "ok",
"😱" => "scream",
"🚫" => "no_entry_sign",
"😊" => "blush",
"😎" => "sunglasses",
"🫡" => "saluting_face",
"🤓" => "nerd_face",
"😏" => "smirk",
"✌\u{fe0f}" => "v",
"💪" => "muscle",
"🦾" => "mechanical_arm",
"🥱" => "yawning_face",
"😨" => "fearful",
"✅" => "white_check_mark",
"❌" => "x",
"🔧" => "wrench",
_ => "grey_question",
}
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Return Option so unmapped emoji can be detected at startup. Add validate_emoji_mapping() to warn operators about missing mappings:

Suggested change
/// Map Unicode emoji to Slack short names for reactions API.
/// Only covers the default `[reactions.emojis]` set. Custom emoji configured
/// outside this map will fall back to `grey_question`.
fn unicode_to_slack_emoji(unicode: &str) -> &str {
match unicode {
"👀" => "eyes",
"🤔" => "thinking_face",
"🔥" => "fire",
"👨\u{200d}💻" => "technologist",
"⚡" => "zap",
"🆗" => "ok",
"😱" => "scream",
"🚫" => "no_entry_sign",
"😊" => "blush",
"😎" => "sunglasses",
"🫡" => "saluting_face",
"🤓" => "nerd_face",
"😏" => "smirk",
"✌\u{fe0f}" => "v",
"💪" => "muscle",
"🦾" => "mechanical_arm",
"🥱" => "yawning_face",
"😨" => "fearful",
"✅" => "white_check_mark",
"❌" => "x",
"🔧" => "wrench",
_ => "grey_question",
}
}
/// Map Unicode emoji to Slack short names for reactions API.
/// Returns `None` for unmapped emoji.
fn unicode_to_slack_emoji(unicode: &str) -> Option<&'static str> {
match unicode {
"👀" => Some("eyes"),
"🤔" => Some("thinking_face"),
"🔥" => Some("fire"),
"👨\u200d💻" => Some("technologist"),
"⚡" => Some("zap"),
"🆗" => Some("ok"),
"😱" => Some("scream"),
"🚫" => Some("no_entry_sign"),
"😊" => Some("blush"),
"😎" => Some("sunglasses"),
"🫡" => Some("saluting_face"),
"🤓" => Some("nerd_face"),
"😏" => Some("smirk"),
"✌\ufe0f" => Some("v"),
"💪" => Some("muscle"),
"🦾" => Some("mechanical_arm"),
"🥱" => Some("yawning_face"),
"😨" => Some("fearful"),
"✅" => Some("white_check_mark"),
"❌" => Some("x"),
"🔧" => Some("wrench"),
_ => None,
}
}
/// Validate that all configured reaction emojis have Slack mappings.
/// Logs a warning at startup for any unmapped emoji.
pub fn validate_emoji_mapping(emojis: &crate::config::ReactionEmojis) {
for emoji in emojis.all_emojis() {
if unicode_to_slack_emoji(emoji).is_none() {
warn!(
emoji,
"reaction emoji has no Slack mapping — will show as :grey_question:"
);
}
}
}

Comment on lines +48 to +56
/// TTL for cached user display names (5 minutes).
const USER_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(300);

pub struct SlackAdapter {
client: reqwest::Client,
bot_token: String,
bot_user_id: tokio::sync::OnceCell<String>,
user_cache: tokio::sync::Mutex<HashMap<String, (String, tokio::time::Instant)>>,
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replace unbounded HashMap with lru::LruCache capped at 1000 entries to prevent slow memory growth in large workspaces:

Suggested change
/// TTL for cached user display names (5 minutes).
const USER_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(300);
pub struct SlackAdapter {
client: reqwest::Client,
bot_token: String,
bot_user_id: tokio::sync::OnceCell<String>,
user_cache: tokio::sync::Mutex<HashMap<String, (String, tokio::time::Instant)>>,
}
/// TTL for cached user display names (5 minutes).
const USER_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(300);
/// Maximum number of cached user display names.
const USER_CACHE_CAPACITY: NonZeroUsize = unsafe { NonZeroUsize::new_unchecked(1000) };
pub struct SlackAdapter {
client: reqwest::Client,
bot_token: String,
bot_user_id: tokio::sync::OnceCell<String>,
user_cache: tokio::sync::Mutex<lru::LruCache<String, (String, tokio::time::Instant)>>,
}

client: reqwest::Client::new(),
bot_token,
bot_user_id: tokio::sync::OnceCell::new(),
user_cache: tokio::sync::Mutex::new(HashMap::new()),
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Init with bounded LRU:

Suggested change
user_cache: tokio::sync::Mutex::new(HashMap::new()),
user_cache: tokio::sync::Mutex::new(lru::LruCache::new(USER_CACHE_CAPACITY)),

/// Edit an existing message in-place.
async fn edit_message(&self, msg: &MessageRef, content: &str) -> Result<()>;

/// Create a thread from a trigger message, returns the thread channel ref.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// Create a thread from a trigger message, returns the thread channel ref.
/// Create a thread from a trigger message, returns the thread channel ref.
#[must_use = "create_thread returns a Result that should be checked"]

Comment on lines +71 to +72
/// Add a reaction/emoji to a message.
async fn add_reaction(&self, msg: &MessageRef, emoji: &str) -> Result<()>;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// Add a reaction/emoji to a message.
async fn add_reaction(&self, msg: &MessageRef, emoji: &str) -> Result<()>;
/// Add a reaction/emoji to a message.
#[must_use = "add_reaction returns a Result that should be checked"]
async fn add_reaction(&self, msg: &MessageRef, emoji: &str) -> Result<()>;

Comment on lines +74 to +75
/// Remove a reaction/emoji from a message.
async fn remove_reaction(&self, msg: &MessageRef, emoji: &str) -> Result<()>;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// Remove a reaction/emoji from a message.
async fn remove_reaction(&self, msg: &MessageRef, emoji: &str) -> Result<()>;
/// Remove a reaction/emoji from a message.
#[must_use = "remove_reaction returns a Result that should be checked"]
async fn remove_reaction(&self, msg: &MessageRef, emoji: &str) -> Result<()>;

Comment on lines +80 to +82
/// Shared logic for routing messages to ACP agents, managing sessions,
/// streaming edits, and controlling reactions. Platform-independent.
pub struct AdapterRouter {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Expanded doc comment for AdapterRouter:

Suggested change
/// Shared logic for routing messages to ACP agents, managing sessions,
/// streaming edits, and controlling reactions. Platform-independent.
pub struct AdapterRouter {
/// Shared logic for routing messages to ACP agents, managing sessions,
/// streaming edits, and controlling reactions.
///
/// Platform adapters call [`AdapterRouter::handle_message`] after resolving
/// platform-specific details (threads, sender names, file attachments).
/// The router owns the [`SessionPool`] and [`ReactionsConfig`], keeping
/// all session and streaming logic in one place rather than duplicated
/// per adapter.
pub struct AdapterRouter {

Comment on lines +92 to +93
}
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add accessor for emoji validation at startup:

Suggested change
}
}
}
}
/// Return the configured reaction emojis (for startup validation).
pub fn reactions_emojis(&self) -> &ReactionEmojis {
&self.reactions_config.emojis
}

@antigenius0910
Copy link
Copy Markdown

@antigenius0910 Great catch — you're absolutely right. with_connection() holds the pool write lock for the entire duration of the streaming closure, which serializes all concurrent sessions. Your timing test clearly demonstrates the issue.

This is a pre-existing upstream design in pool.rs — our PR doesn't modify with_connection(), we only call it. But multi-platform support makes it more visible since Discord and Slack now share the same SessionPool.

The Arc<Mutex<AcpConnection>> approach you tested is the correct fix, and aligns with what @ruan330 already validated in production (see #78 comment). Since upstream just refactored pool.rs in v0.7.3 (added PoolState, suspend/resume, LRU eviction), I'd suggest we keep this PR focused on the adapter layer and follow up on the per-connection lock change in #78 after this merges.

Your test case would be a great addition to that discussion — concrete proof of the lock contention and the fix. Would you be up for posting it there?

Checked #78 — the issue is already tracked there, and @ruan330 has validated the Arc fix in production. The RFC even lists it explicitly in Current State:

"Write lock held during entire prompt streaming (#58)"

Our timing test (2.01s → 1.00s) would be a concrete addition to that discussion since it gives a reproducible, quantified proof of the contention. Will post it there. Thanks for pointing us in the right direction! 🙏

@dogzzdogzz
Copy link
Copy Markdown
Contributor Author

CI is failing.

@thepagent The latest run (https://github.com/openabdev/openab/actions/runs/24379004497) shows action_required — needs maintainer approval to run since this is a fork PR. Could you approve the workflow run?


Summary of all review feedback and our response:

Item Status Detail
clippy::too_many_arguments ✅ Fixed (e33e323) Added #[allow] on handle_message (8 params, all distinct)
clippy::unnecessary_map_or ✅ Fixed (e33e323) Replaced with is_some_and
Duplicate Slack thread messages ✅ Fixed (b04d9e2) Skip message events mentioning the bot (via auth.test bot user ID)
Slack graceful shutdown ✅ Fixed (b04d9e2) watch::channel + tokio::select! + WebSocket Close frame
discord.enabled + empty token docs ✅ Fixed (b04d9e2) Documented in values.yaml
users.info rate limits ✅ Fixed (b04d9e2) HashMap cache with 5-min TTL
unicode_to_slack_emoji fallback ✅ Fixed (b04d9e2) Documented in code comment
DiscordAdapter per message ✅ Fixed (b04d9e2) OnceLock — created once, reused
Add lru crate + LruCache Deferred Our HashMap with TTL is more correct for user names (TTL-based vs access-order eviction). Workspace size naturally bounds entries.
ReactionEmojis::all_emojis() + startup validation Deferred Touches upstream config.rs and has no caller yet. Only relevant if someone customizes emojis AND uses Slack.
unicode_to_slack_emoji() returns Option Deferred Changes function signature and all call sites. Documented fallback behavior instead.
Reuse media::HTTP_CLIENT in get_socket_mode_url() Skipped media::HTTP_CLIENT has redirect(Policy::none()) which could break Slack API calls that redirect.
#[must_use] on ChatAdapter trait methods Deferred Reactions are fire-and-forget (let _ = adapter.add_reaction(...)) — #[must_use] would require suppression at every call site.

Happy to pick up deferred items in follow-up PRs if maintainers prefer. Wanted to keep this PR focused on the adapter architecture + Slack support.

@dogzzdogzz dogzzdogzz requested a review from chaodu-agent April 14, 2026 04:37
tonylee-shopback and others added 12 commits April 14, 2026 14:58
Introduces a platform-agnostic adapter layer (ChatAdapter trait + AdapterRouter)
that decouples session management, streaming, and reactions from any specific
chat platform. Refactors Discord support to implement this trait and adds a
new Slack adapter using Socket Mode (WebSocket) with auto-reconnect.

- Extract ChatAdapter trait with send/edit/thread/react methods + message_limit()
- Extract AdapterRouter with shared session routing, edit-streaming, and reactions
- Decouple StatusReactionController from serenity types (uses Arc<dyn ChatAdapter>)
- Implement DiscordAdapter (ChatAdapter for Discord via serenity)
- Implement SlackAdapter (ChatAdapter for Slack via reqwest + tokio-tungstenite)
- Add [slack] config section (bot_token, app_token, allowed_channels, allowed_users)
- Support running Discord + Slack simultaneously in one process
- Session keys namespaced by platform (discord:xxx, slack:xxx)
- Unicode emoji → Slack short name mapping for reactions

Relates to openabdev#93, openabdev#94, openabdev#86

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Extract image resize/compress, download+encode, and STT download
  from discord.rs into shared media.rs module
- Both Discord and Slack adapters now use media::download_and_encode_image()
  and media::download_and_transcribe() with optional auth_token parameter
- Move sender context XML wrapping into AdapterRouter.handle_message()
  (was duplicated in each adapter)
- Add image/audio attachment support to Slack adapter via files[] in
  app_mention events (requires files:read bot scope)
- Slack file downloads use Bearer token auth (private URLs)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ollow-up

- Fix strip_slack_mention to use LazyLock<Regex> instead of compiling per-call
- Fix shorten_thread_name regex to use LazyLock, move to shared format.rs
- Resolve Slack display name via users.info API (fallback to user_id)
- Handle message events with thread_ts for thread follow-ups without @mention
- Fix misleading allowed_channels comment (empty = allow all, not deny all)
- Add allowed_channels docs to config.toml.example

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add slack config to values.yaml (enabled, botToken, appToken, allowedChannels, allowedUsers)
- Generate [slack] section in configmap.yaml when slack.enabled=true
- Store slack-bot-token and slack-app-token in secret.yaml
- Inject SLACK_BOT_TOKEN and SLACK_APP_TOKEN env vars in deployment.yaml
- Make [discord] section conditional (omitted when no botToken)
- Add Slack setup instructions to NOTES.txt
- Backward compatible: existing Discord-only configs work unchanged

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Update README.md: add Slack to description, architecture diagram,
  features, Quick Start (Discord/Slack toggle), config example, Helm
  example, Configuration Reference, and Project Structure
- Add docs/slack-bot-howto.md: complete Slack bot setup guide
- Add cross-reference from discord-bot-howto.md to slack-bot-howto.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add discord.enabled field to values.yaml (default: true)
- Align all templates to use `and ($cfg.<platform>).enabled ($cfg.<platform>).botToken`
  pattern consistently across configmap, secret, deployment, and NOTES
- Sync Cargo.lock version with upstream Cargo.toml (0.7.2)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…Annotations

- Add global labels/annotations in values.yaml, applied to all resources
  (Deployment, ConfigMap, Secret, PVC) via _helpers.tpl
- Add per-agent podLabels/podAnnotations, applied to pod template in
  Deployment
- Useful for team/cost-center labels, Prometheus scrape annotations, etc.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Slack sends message events with subtype "file_share" when users attach
files in threads. The previous code blocked all subtypes, which prevented
image/audio attachments in thread follow-ups from being processed.

Now only skip subtypes that are truly non-user messages (edits, deletes,
joins, leaves). Added debug logging for message event routing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- DiscordAdapter: initialize once via OnceLock instead of per-message
- Slack dedup: skip message events that @mention the bot (app_mention
  handles those) using bot user ID from auth.test API
- Slack graceful shutdown: watch channel + tokio::select! + WebSocket
  Close frame, with 5s timeout in main.rs
- Cache users.info results for 5 minutes to avoid Slack rate limits
- Document unicode_to_slack_emoji fallback behavior
- Document discord.enabled + empty botToken behavior in values.yaml

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Allow clippy::too_many_arguments on handle_message (8 params, all distinct)
- Replace map_or with is_some_and for has_files check

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Claude Code outputs standard Markdown which Discord renders natively but
Slack does not. Add markdown_to_mrkdwn() conversion in SlackAdapter:

- **bold** → *bold* (Slack bold)
- *italic* → _italic_ (Slack italic)
- [text](url) → <url|text> (Slack links)
- # heading → *heading* (bold as substitute, Slack has no headings)
- ```lang → ``` (strip language hints from code blocks)

Applied in send_message and edit_message. Discord adapter unchanged
(Markdown works natively).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Optional field — only rendered when explicitly set in values. No default
value, fully backward compatible. Allows disabling service account token
mount for security hardening.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
dogzzdogzz and others added 2 commits April 14, 2026 17:10
Resolve conflicts with upstream openabdev#321 (allow_bot_messages):
- Port AllowBots enum + bot message gating + trusted_bot_ids into
  our refactored DiscordAdapter Handler
- Add allow_bot_messages/trusted_bot_ids to Handler struct and main.rs
  Discord startup section

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Resolve conflicts with upstream:
- openabdev#321: allow_bot_messages + trustedBotIds (ported into DiscordAdapter Handler)
- openabdev#191: setup wizard + clap CLI (wrapped our multi-adapter logic inside Commands::Run)
- Helm: allowBotMessages/trustedBotIds config + validation in configmap/values
- Cargo: added clap dependency alongside our async-trait/futures-util/tokio-tungstenite

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@thepagent
Copy link
Copy Markdown
Collaborator

This is a significant architectural change (multi-platform ChatAdapter + Slack support). To ensure proper design discussion and tracking, could you please create a Discord discussion thread and add the link to this PR description? This helps the community follow along and provide feedback before we merge a large refactor. Thanks! 🙏

@dogzzdogzz
Copy link
Copy Markdown
Contributor Author

@thepagent Added the Discord discussion link to the PR description:

https://discord.com/channels/1491295327620169908/1491969620754567270/1491969624135438386

Also updated test count from 26 to 40 (includes upstream's new tests after merge).

dogzzdogzz and others added 2 commits April 15, 2026 09:38
Resolve conflicts:
- format.rs: keep shorten_thread_name (ours), remove truncate_chars
  (upstream removed it in favor of tail-priority truncation)
- adapter.rs: replace truncate_chars with tail-priority truncation
  (keep last N chars during streaming, matching upstream's approach)
- discord.rs: keep our refactored DiscordAdapter version (upstream
  changes to compose_display/tool collapse are in their monolithic
  discord.rs which we replaced)
- Dockerfiles: upstream merged our openabdev#335 fix + added procps

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Resolve discord.rs conflict — take our refactored version and port
upstream's 🎤 reaction for voice messages when STT is disabled (openabdev#252).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Resolve README.md conflict — merge our Slack additions with upstream's
OpenCode CLI support. values.yaml and config.toml.example auto-merged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: Slack adapter — bring agent-broker to Slack workspaces

5 participants