Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions channels/slack/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ Behavior:
- Events API ingress uses host-managed webhook callbacks
- Socket Mode ingress supports both one-shot `poll_ingress` fetches and background `start_ingress` sessions
- Socket Mode opens Slack's websocket via `apps.connections.open` and emits normalized inbound events back to Dispatch
- accepted inbound messages receive an immediate `:eyes:` reaction when bot-token delivery is configured
- challenge and acknowledgement replies are returned through `callback_reply`
- status frames render visible status messages into Slack conversations

Expand Down Expand Up @@ -87,6 +88,7 @@ Bot-token mode:
Recommended bot scopes:

- `chat:write` - required for `deliver`, `push`, and `status`
- `reactions:write` - required for inbound `:eyes:` acknowledgements
- `app_mentions:read` - required if you subscribe to `app_mention`
- `channels:history` - required for `message.channels`
- `im:history` - required for `message.im`
Expand All @@ -105,9 +107,9 @@ Recommended event subscriptions:

Common scope sets:

- mention-only Socket Mode: `chat:write`, `app_mentions:read`
- public-channel Events API or Socket Mode: `chat:write`, `channels:history`
- full plugin test setup: `chat:write`, `files:write`, `app_mentions:read`, `channels:history`, `groups:history`, `im:history`, `mpim:history`
- mention-only Socket Mode: `chat:write`, `reactions:write`, `app_mentions:read`
- public-channel Events API or Socket Mode: `chat:write`, `reactions:write`, `channels:history`
- full plugin test setup: `chat:write`, `reactions:write`, `files:write`, `app_mentions:read`, `channels:history`, `groups:history`, `im:history`, `mpim:history`

Socket Mode setup:

Expand Down
32 changes: 30 additions & 2 deletions channels/slack/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ const META_PATH: &str = "path";
const META_API_APP_ID: &str = "api_app_id";
const META_EVENT_CONTEXT: &str = "event_context";
const META_CHANNEL_TYPE: &str = "channel_type";
const META_MESSAGE_TS: &str = "message_ts";
const META_STATUS_KIND: &str = "status_kind";
const META_REASON_CODE: &str = "reason_code";

Expand All @@ -68,6 +69,7 @@ const MODE_INCOMING_WEBHOOK: &str = "incoming_webhook";
const DELIVERY_MODE_CHAT_POST_MESSAGE: &str = "chat.postMessage";
const TRANSPORT_EVENTS_WEBHOOK: &str = "events_webhook";
const TRANSPORT_SOCKET_MODE: &str = "socket_mode";
const THINKING_REACTION: &str = "eyes";
const MAX_SIGNATURE_AGE_SECS: i64 = 300;

const ROUTE_CONVERSATION_ID: &str = "conversation_id";
Expand Down Expand Up @@ -773,13 +775,16 @@ fn build_inbound_event(
if let Some(event_context) = &envelope.event_context {
event_metadata.insert(META_EVENT_CONTEXT.to_string(), event_context.clone());
}
if let Some(message_ts) = &event.ts {
event_metadata.insert(META_MESSAGE_TS.to_string(), message_ts.clone());
}

let account_id = envelope.team_id.clone();
if !team_is_allowed(config, account_id.as_deref()) {
return Ok(None);
}

Ok(Some(InboundEventEnvelope {
let inbound_event = InboundEventEnvelope {
event_id: envelope.event_id.clone().unwrap_or_else(|| {
let ts = event
.event_ts
Expand Down Expand Up @@ -808,7 +813,30 @@ fn build_inbound_event(
},
account_id,
metadata: event_metadata,
}))
};

acknowledge_inbound_event(config, channel_id, event.ts.as_deref());

Ok(Some(inbound_event))
}

fn acknowledge_inbound_event(config: &ChannelConfig, channel_id: &str, message_ts: Option<&str>) {
if !has_optional_env(bot_token_env(config)) {
return;
}

let result = message_ts
.ok_or_else(|| anyhow!("Slack event is missing its message timestamp"))
.and_then(|message_ts| {
SlackClient::from_env(bot_token_env(config)).map(|client| (client, message_ts))
})
.and_then(|(client, message_ts)| {
client.add_reaction(channel_id, message_ts, THINKING_REACTION)
});

if result.is_err() {
eprintln!("slack inbound acknowledgement failed");
}
}

fn supports_inbound_event(event: &SlackEventPayload) -> bool {
Expand Down
64 changes: 57 additions & 7 deletions channels/slack/src/slack_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use std::{
use tungstenite::{Message, WebSocket, stream::MaybeTlsStream};

const DEFAULT_API_BASE: &str = "https://slack.com/api";
const REACTION_TIMEOUT: Duration = Duration::from_secs(2);

#[derive(Debug)]
pub struct SlackClient {
Expand Down Expand Up @@ -133,6 +134,38 @@ impl SlackClient {
})
}

pub fn add_reaction(
&self,
channel_id: &str,
message_ts: &str,
reaction_name: &str,
) -> Result<()> {
let agent = ureq::Agent::config_builder()
.timeout_global(Some(REACTION_TIMEOUT))
.build()
.new_agent();
let body = self
.post_json_response(
Some(&agent),
"reactions.add",
json!({
"channel": channel_id,
"timestamp": message_ts,
"name": reaction_name,
}),
"failed to add Slack reaction",
)
.map_err(|_| anyhow!("failed to add Slack reaction"))?;

match body.get("ok").and_then(Value::as_bool) {
Some(true) => Ok(()),
Some(false) if body.get("error").and_then(Value::as_str) == Some("already_reacted") => {
Ok(())
}
_ => bail!("failed to add Slack reaction"),
}
}

fn upload_file(
&self,
channel_id: &str,
Expand Down Expand Up @@ -200,13 +233,7 @@ impl SlackClient {
}

fn post_json(&self, method: &str, payload: Value, context: &str) -> Result<Value> {
let url = format!("{}/{}", self.base_url, method);
let mut response = ureq::post(&url)
.header("Authorization", &format!("Bearer {}", self.bot_token))
.header("Content-Type", "application/json")
.send_json(payload)
.map_err(|error| anyhow!("{context}: {error}"))?;
let body = read_json_body(&mut response, context)?;
let body = self.post_json_response(None, method, payload, context)?;
let ok = body
.get("ok")
.and_then(Value::as_bool)
Expand All @@ -221,6 +248,29 @@ impl SlackClient {
Ok(body)
}

fn post_json_response(
&self,
agent: Option<&ureq::Agent>,
method: &str,
payload: Value,
context: &str,
) -> Result<Value> {
let url = format!("{}/{}", self.base_url, method);
let mut response = match agent {
Some(agent) => agent
.post(&url)
.header("Authorization", &format!("Bearer {}", self.bot_token))
.header("Content-Type", "application/json")
.send_json(payload),
None => ureq::post(&url)
.header("Authorization", &format!("Bearer {}", self.bot_token))
.header("Content-Type", "application/json")
.send_json(payload),
}
.map_err(|error| anyhow!("{context}: {error}"))?;
read_json_body(&mut response, context)
}

fn upload_file_bytes(&self, upload_url: &str, upload: &SlackUpload) -> Result<()> {
let mut response = ureq::post(upload_url)
.header("Content-Type", &upload.mime_type)
Expand Down
Loading
Loading