feat(trogon-gateway): add Slack Socket Mode - #175
Conversation
yordis
commented
May 22, 2026
- Enables private and local-first Slack deployments that cannot depend on public webhook ingress.
- Keeps Slack delivery durable at the gateway boundary while leaving replay and resume semantics with JetStream consumers.
- Preserves existing webhook deployments while making transport ownership explicit per integration.
PR SummaryMedium Risk Overview Slack integrations now require configuring exactly one transport ( Gateway startup now spawns a Socket Mode runner for Slack integrations using Reviewed by Cursor Bugbot for commit 65cac59. Bugbot is set up for automated code reviews on this repo. Configure here. |
|
Warning Rate limit exceeded
You’ve run out of usage credits. Purchase more in the billing tab. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (11)
WalkthroughThis PR adds Slack Socket Mode transport support as an alternative to HTTP webhooks. It refactors webhook handling into a reusable SlackBridge component, introduces a WebSocket-based event ingestion path with automatic reconnection, and updates configuration to enforce exactly one transport mode per integration. ChangesSlack Socket Mode Transport Support
Sequence DiagramsequenceDiagram
participant Config as Configuration
participant Main as serve()
participant WS as Socket Mode<br/>Task
participant Slack as Slack API
participant Bridge as SlackBridge
participant NATS
Config->>Config: validate transport<br/>(webhook XOR socket_mode)
Main->>Main: iterate integrations<br/>with socket_mode enabled
Main->>WS: spawn async task<br/>per integration
WS->>WS: run() reconnect loop
WS->>Slack: open_socket_url()<br/>apps.connections.open
Slack-->>WS: WebSocket url
WS->>Slack: connect WebSocket
Slack-->>WS: envelope stream
WS->>WS: parse SocketEnvelope
WS->>WS: handle_payload_envelope()
WS->>Bridge: dispatch by kind<br/>(events/interactive/slash)
Bridge->>NATS: publish event
NATS-->>Bridge: success
Bridge-->>WS: StatusCode
WS->>Slack: send ack frame
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Suggested labels
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Code Coverage SummaryDetailsDiff against mainResults for commit: 65cac59 Minimum allowed coverage is ♻️ This comment has been updated with latest results |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
rsworkspace/crates/trogon-gateway/src/source/slack/socket_mode.rs (1)
125-148: ⚡ Quick winRedundant JSON parsing on every text frame.
Each text message is parsed twice: once in
is_disconnect_frame(line 128) and again inhandle_text_frame(line 129). Sincehandle_text_framealready handles the"disconnect"case (lines 190-196), parse once and consolidate.♻️ Proposed refactor to parse once
async fn connect_once<P: JetStreamPublisher, S: ObjectStorePut>( client: &reqwest::Client, open_url: &str, bridge: &SlackBridge<P, S>, config: &SlackSocketModeConfig, ) -> Result<(), SocketModeError> { let websocket_url = open_socket_url(client, open_url, config).await?; info!("connecting to Slack Socket Mode"); let (ws, _) = tokio_tungstenite::connect_async(&websocket_url).await?; let (mut sender, mut receiver) = ws.split(); while let Some(message) = receiver.next().await { match message? { Message::Text(text) => { - let reconnect = is_disconnect_frame(text.as_str())?; - if let Some(ack) = handle_text_frame(bridge, text.as_str()).await? { + let (reconnect, ack) = handle_text_frame(bridge, text.as_str()).await?; + if let Some(ack) = ack { sender.send(Message::Text(ack.into())).await?; } if reconnect { return Ok(()); } } Message::Close(_) => return Ok(()), Message::Ping(payload) => sender.send(Message::Pong(payload)).await?, Message::Binary(_) | Message::Pong(_) | Message::Frame(_) => {} } } Ok(()) } -fn is_disconnect_frame(text: &str) -> Result<bool, SocketModeError> { - let envelope: SocketEnvelope = serde_json::from_str(text)?; - Ok(envelope.kind == "disconnect") -}Then update
handle_text_frameto return(bool, Option<String>)where the bool indicates disconnect:async fn handle_text_frame<P: JetStreamPublisher, S: ObjectStorePut>( bridge: &SlackBridge<P, S>, text: &str, -) -> Result<Option<String>, SocketModeError> { +) -> Result<(bool, Option<String>), SocketModeError> { let envelope: SocketEnvelope = serde_json::from_str(text)?; match envelope.kind.as_str() { "hello" => { info!("Slack Socket Mode connection established"); - Ok(None) + Ok((false, None)) } "disconnect" => { warn!( reason = envelope.reason.as_deref().unwrap_or("unknown"), "Slack Socket Mode disconnect requested" ); - Ok(None) + Ok((true, None)) } - "events_api" | "interactive" | "slash_commands" => handle_payload_envelope(bridge, envelope, text).await, + "events_api" | "interactive" | "slash_commands" => { + handle_payload_envelope(bridge, envelope, text).await.map(|ack| (false, ack)) + } other => { warn!(kind = other, "Unhandled Slack Socket Mode envelope type"); bridge .publish_socket_unroutable("unhandled_socket_mode_type", Bytes::copy_from_slice(text.as_bytes())) .await; - Ok(envelope.envelope_id.map(ack_frame)) + Ok((false, envelope.envelope_id.map(ack_frame))) } } }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@rsworkspace/crates/trogon-gateway/src/source/slack/socket_mode.rs` around lines 125 - 148, Parse the incoming text frame only once by removing the separate is_disconnect_frame call: change handle_text_frame to parse the JSON envelope and return a tuple like (bool, Option<String>) where the bool indicates a disconnect; update the message loop (the while let Some(message) receiver.next() block) to call handle_text_frame(text) once, send the returned ack if Some, and use the returned bool to trigger reconnect/return Ok(()); you can then delete or deprecate is_disconnect_frame (and ensure SocketEnvelope deserialization stays in handle_text_frame).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@rsworkspace/crates/trogon-gateway/src/source/slack/socket_mode.rs`:
- Around line 125-148: Parse the incoming text frame only once by removing the
separate is_disconnect_frame call: change handle_text_frame to parse the JSON
envelope and return a tuple like (bool, Option<String>) where the bool indicates
a disconnect; update the message loop (the while let Some(message)
receiver.next() block) to call handle_text_frame(text) once, send the returned
ack if Some, and use the returned bool to trigger reconnect/return Ok(()); you
can then delete or deprecate is_disconnect_frame (and ensure SocketEnvelope
deserialization stays in handle_text_frame).
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 5fff5790-dc3b-4f33-8226-75a4146b7b86
⛔ Files ignored due to path filters (1)
rsworkspace/Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (11)
devops/docker/compose/.env.exampledevops/docker/compose/services/trogon-gateway/README.mddevops/docker/compose/services/trogon-gateway/gateway.tomlrsworkspace/crates/trogon-gateway/Cargo.tomlrsworkspace/crates/trogon-gateway/src/config.rsrsworkspace/crates/trogon-gateway/src/http.rsrsworkspace/crates/trogon-gateway/src/main.rsrsworkspace/crates/trogon-gateway/src/source/slack/config.rsrsworkspace/crates/trogon-gateway/src/source/slack/mod.rsrsworkspace/crates/trogon-gateway/src/source/slack/server.rsrsworkspace/crates/trogon-gateway/src/source/slack/socket_mode.rs
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit f77c1b0. Configure here.
f77c1b0 to
ab434b2
Compare
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
ab434b2 to
65cac59
Compare
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
