Skip to content

feat(slack): app_mention, ephemeral messages, Block Kit, scheduled messages, typing indicator#58

Merged
jamiepine merged 1 commit intospacedriveapp:mainfrom
sookochoff:feat/slack-enhancements-pr1
Feb 19, 2026
Merged

feat(slack): app_mention, ephemeral messages, Block Kit, scheduled messages, typing indicator#58
jamiepine merged 1 commit intospacedriveapp:mainfrom
sookochoff:feat/slack-enhancements-pr1

Conversation

@sookochoff
Copy link
Contributor

Summary

Adds five coordinated Slack capabilities in one cohesive PR. See docs/PRD-slack-enhancements.md (included) for full design rationale.

New OutboundResponse variants (src/lib.rs)

Variant Description
RemoveReaction(String) Remove an emoji reaction from a message
Ephemeral { text, user_id } Message visible only to the triggering user
RichMessage { text, blocks } Block Kit blocks with plain-text fallback
ScheduledMessage { text, post_at } Schedule a message at a Unix timestamp

All four variants are handled exhaustively in every adapter (Slack, Discord, Telegram, Webhook) so the compiler enforces completeness.

Slack adapter (src/messaging/slack.rs)

app_mention handling

  • New handle_app_mention_event function registered alongside the existing handle_push_event in the Socket Mode listener
  • Fires when the bot is @-mentioned in any channel it isn't a primary member of
  • Strips the leading <@BOT_ID> token before forwarding text to the agent
  • Bot user ID resolved at start() via auth.test; self-mentions filtered

Typing indicator

  • send_status() was previously a no-op with an incorrect comment
  • Now calls assistant.threads.setStatus with Thinking… / Working… / empty string (to clear)
  • Silently no-ops when there is no thread_ts (regular channel messages outside an Assistant thread context)

RemoveReaction

  • reactions.remove with channel + timestamp via builder pattern

Ephemeral

  • chat.postEphemeral threaded to the triggering message's thread_ts

RichMessage

  • chat.postMessage with a Vec<SlackBlock> deserialised from serde_json::Value
  • Per-block deserialisation errors are logged as warnings and the block is skipped; the message still sends with remaining blocks or falls back to plain text if all blocks fail

ScheduledMessage

  • chat.scheduleMessage with post_at: SlackDateTime(DateTime<Utc>) constructed from a caller-supplied Unix timestamp

broadcast()

  • Extended to support RichMessage in addition to Text

Other adapters

Platform-appropriate fallbacks — no behaviour change for existing traffic:

  • Discord: Ephemeral/RichMessage/ScheduledMessage → plain text; RemoveReaction → no-op
  • Telegram: same plain-text fallbacks via bot.send_message()
  • Webhook: Ephemeral/RichMessage/ScheduledMessage → response_type: text; RemoveReaction → early Ok(())

Testing

  • cargo check — clean (zero new errors; pre-existing warnings untouched)
  • cargo test --lib — 43/43 pass

Notes

Copy link
Member

@jamiepine jamiepine left a comment

Choose a reason for hiding this comment

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

Review

Solid PR overall. The refactoring of handle_push_event into extracted helpers is clean, and the new OutboundResponse variants are well-designed with appropriate fallbacks across adapters.

Two issues to address:

1. Channel filter bypass in app_mention

Location: src/messaging/slack.rs:685-733 (handle_app_mention_event)

The handle_app_mention_event function applies the workspace filter but skips the channel filter entirely. This means if someone @-mentions the bot in a filtered channel, it'll still respond.

// Workspace filter applies to mentions too
if let Some(ref filter) = perms.workspace_filter {
    if !filter.contains(&team_id_str) {
        return Ok(());
    }
}

// MISSING: Channel filter (compare to handle_message_event:158-164)

The regular message handler applies both filters. This should too.

Fix: Add the channel filter block after the workspace filter check (after line 733):

// Channel filter
if let Some(allowed) = perms.channel_filter.get(&team_id_str) {
    if !allowed.is_empty() && !allowed.contains(&channel_id) {
        return Ok(());
    }
}

2. send_status() creates fresh client on every call

Location: src/messaging/slack.rs:868-869

let (client, token) = self.create_session()?;
let session = client.open_session(&token);

This allocates a new SlackClient + SlackHyperClient every time a status update fires. Status updates happen frequently (Thinking, ToolStarted, ToolCompleted, StopTyping) — a single conversation turn with 3 tool calls means ~7 fresh client allocations.

Fix: Cache the client on SlackAdapter (add client: Arc<SlackHyperClient> field, initialize once in new(), reuse across all methods). This is the same pattern Discord and other adapters use.

@sookochoff sookochoff force-pushed the feat/slack-enhancements-pr1 branch 2 times, most recently from c0b2708 to d8d7d5d Compare February 19, 2026 20:55
@sookochoff
Copy link
Contributor Author

Both fixed in the force-push — thanks for the sharp eyes.

1. Channel filter bypass in app_mention — fixed

Added the missing channel filter block immediately after the workspace filter in handle_app_mention_event, matching the exact guard logic in handle_message_event:

// Channel filter — same logic as handle_message_event
if let Some(allowed) = perms.channel_filter.get(&team_id_str) {
    if !allowed.is_empty() && !allowed.contains(&channel_id) {
        return Ok(());
    }
}

2. Cached client — fixed

SlackAdapter now holds client: Arc<SlackHyperClient> and token: SlackApiToken as fields, built once in new(). All API call sites (send_status, respond, broadcast, fetch_history, health_check, auth_test in start()) now call self.session() — a one-liner wrapping self.client.open_session(&self.token). Zero new allocations per call.

The socket mode listener keeps its own separate client instance, which is intentional — it owns a persistent WebSocket connection internally and needs to hold that client for the lifetime of the connection.

new() now returns anyhow::Result<Self> since connector init can fail. All four call sites updated with match + tracing::error! on the failure path.

…yping indicator

Add five coordinated capabilities to the Slack adapter (Phase 1 + 2a + 4
from docs/PRD-slack-enhancements.md).

- RemoveReaction(String)      — remove an emoji reaction
- Ephemeral { text, user_id } — message visible only to the triggering user
- RichMessage { text, blocks } — Block Kit blocks with plain-text fallback
- ScheduledMessage { text, post_at } — chat.scheduleMessage at a unix timestamp

- AppMention handler: fires when the bot is @-mentioned in any channel;
  strips the leading <@BOT_ID> token before passing text to the agent
- Typing indicator: send_status() now calls assistant.threads.setStatus
  (Thinking…/Working…/empty-string-to-clear) instead of being a no-op
- RemoveReaction: reactions.remove via channel + timestamp
- Ephemeral: chat.postEphemeral threaded to the triggering message
- RichMessage: chat.postMessage with SlackBlock list; falls back to text
  if blocks fail to deserialise (per-block, with warning log)
- ScheduledMessage: chat.scheduleMessage with SlackDateTime(DateTime<Utc>)
- broadcast() extended to support RichMessage in addition to Text
- bot_user_id resolved at start() via auth.test; self-messages filtered

New variants handled exhaustively with platform-appropriate fallbacks:
- Discord: Ephemeral/RichMessage/ScheduledMessage fall back to plain text;
  RemoveReaction is a no-op (no per-message remove API on this path)
- Telegram: same plain-text fallbacks via existing bot.send_message()
- Webhook: Ephemeral/RichMessage/ScheduledMessage serialised as
  response_type=text; RemoveReaction returns Ok(()) early

All 43 lib unit tests pass. cargo check clean.
@sookochoff sookochoff force-pushed the feat/slack-enhancements-pr1 branch from d8d7d5d to f591658 Compare February 19, 2026 21:29
@jamiepine jamiepine merged commit ad9483d into spacedriveapp:main Feb 19, 2026
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.

2 participants

Comments