Skip to main content

agentmux_srv/messaging/slack/
rest.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Slack Web API REST client — `apps.connections.open` (Socket Mode URL
5//! fetch) + `chat.postMessage` for outbound delivery.
6//!
7//! Rate limiting: v1 implements only per-channel ~1msg/s spacing (enforced
8//! by the caller in `socket.rs`'s outbound loop) and reactive
9//! `429`/`Retry-After` backoff. Full token-bucket accounting for every
10//! documented Slack API tier is explicitly out of scope (spec §2.6) —
11//! mirrors Discord's and Telegram's `rest.rs`, neither of which does
12//! header-driven throttling beyond reacting to a rate-limit signal.
13//!
14//! Secret-in-error-message hygiene: both Slack tokens (`xoxb-`/`xapp-`) are
15//! sent as `Authorization: Bearer` headers, never embedded in request URLs —
16//! unlike Telegram's `bot{token}` path convention — so `reqwest::Error`'s
17//! `Display` (which can embed request URLs on connection failures) does not
18//! leak either token via that vector, and no `redact()`-style scrubbing of
19//! `reqwest::Error` text is needed here the way `telegram/rest.rs` needs it.
20//! The one Slack-specific value that *is* both URL-embedded and secret is
21//! the Socket Mode WS URL's `ticket=` query param returned by
22//! `apps.connections.open`: it's a single-use, short-lived credential (spec
23//! §2.1, §9.6). We never pass that URL through this module's error paths
24//! (the WS connect happens in `socket.rs`), but we still provide
25//! `redact_ticket` here so any code that logs the URL (today: one `debug!`
26//! call in `socket.rs`) redacts it first — unnecessary exposure in a log
27//! that can persist long past the ticket's few-seconds validity window is
28//! not worth the convenience of an unredacted log line.
29
30use std::time::Duration;
31
32use super::types::{OpenConnectionResponse, PostMessageBody, PostMessageResponse};
33use crate::messaging::OutboundMsg;
34
35const SLACK_API: &str = "https://slack.com/api";
36const REQUEST_TIMEOUT_SECS: u64 = 10;
37
38/// Error from a Slack Web API call, carrying the `Retry-After` seconds when
39/// present (429 responses only — Slack signals rate limits via the HTTP
40/// header, not a response-body field like Telegram's `parameters.retry_after`
41/// — spec §2.6).
42#[derive(Debug, Clone)]
43pub struct SlackApiError {
44    pub message: String,
45    pub retry_after: Option<u64>,
46}
47
48impl std::fmt::Display for SlackApiError {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        write!(f, "{}", self.message)
51    }
52}
53
54impl SlackApiError {
55    fn simple(message: impl Into<String>) -> Self {
56        SlackApiError {
57            message: message.into(),
58            retry_after: None,
59        }
60    }
61}
62
63/// Redacts the single-use `ticket` query param from a Socket Mode WS URL
64/// before it's ever logged (spec §9.6). Leaves the rest of the URL (host,
65/// `app_id`, path) intact since that part isn't secret and is useful for
66/// debugging connection issues.
67pub fn redact_ticket(url: &str) -> String {
68    let Some(idx) = url.find("ticket=") else {
69        return url.to_string();
70    };
71    let value_start = idx + "ticket=".len();
72    let end = url[value_start..]
73        .find('&')
74        .map(|i| value_start + i)
75        .unwrap_or(url.len());
76    format!("{}ticket=***{}", &url[..idx], &url[end..])
77}
78
79/// Parses an HTTP `Retry-After` header value (seconds, per RFC 9110 — Slack
80/// always sends the delta-seconds form, never the HTTP-date form).
81fn parse_retry_after(value: &str) -> Option<u64> {
82    value.trim().parse::<u64>().ok()
83}
84
85/// POST apps.connections.open — `Authorization: Bearer {app_token}`. Returns
86/// the ephemeral Socket Mode WS URL. Must be called before the initial
87/// connect and before every reconnect (spec §2.1) — the URL is single-use
88/// and short-lived; never cache/reuse it.
89pub async fn open_connection(http: &reqwest::Client, app_token: &str) -> Result<String, String> {
90    let resp = http
91        .post(format!("{SLACK_API}/apps.connections.open"))
92        .header("Authorization", format!("Bearer {app_token}"))
93        .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS))
94        .send()
95        .await
96        .map_err(|e| format!("slack rest: apps.connections.open http error: {e}"))?;
97
98    let status = resp.status();
99    let body: OpenConnectionResponse = resp
100        .json()
101        .await
102        .map_err(|e| format!("slack rest: apps.connections.open parse error: {e}"))?;
103
104    if !status.is_success() || !body.ok {
105        return Err(format!(
106            "slack rest: apps.connections.open failed ({status}): {}",
107            body.error.unwrap_or_else(|| "unknown error".to_string())
108        ));
109    }
110
111    body.url
112        .ok_or_else(|| "slack rest: apps.connections.open ok but no url".to_string())
113}
114
115/// POST chat.postMessage — `Authorization: Bearer {bot_token}`. MUST check
116/// `body.ok`, not just HTTP status: Slack's REST responses are HTTP 200 even
117/// on many logical failures (e.g. `channel_not_found`, `not_in_channel`) —
118/// spec §2.4.
119pub async fn post_message(
120    http: &reqwest::Client,
121    bot_token: &str,
122    channel_id: &str,
123    msg: &OutboundMsg,
124) -> Result<(), SlackApiError> {
125    let body = PostMessageBody {
126        channel: channel_id.to_string(),
127        text: msg.text.clone(),
128        blocks: msg.blocks.clone(),
129    };
130
131    let resp = http
132        .post(format!("{SLACK_API}/chat.postMessage"))
133        .header("Authorization", format!("Bearer {bot_token}"))
134        .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS))
135        .json(&body)
136        .send()
137        .await
138        .map_err(|e| SlackApiError::simple(format!("slack rest: chat.postMessage http error: {e}")))?;
139
140    let status = resp.status();
141    let retry_after = if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
142        resp.headers()
143            .get(reqwest::header::RETRY_AFTER)
144            .and_then(|v| v.to_str().ok())
145            .and_then(parse_retry_after)
146    } else {
147        None
148    };
149
150    let parsed: PostMessageResponse = resp.json().await.map_err(|e| SlackApiError {
151        message: format!("slack rest: chat.postMessage parse error: {e}"),
152        retry_after,
153    })?;
154
155    if status == reqwest::StatusCode::TOO_MANY_REQUESTS || !parsed.ok {
156        return Err(SlackApiError {
157            message: format!(
158                "slack rest: chat.postMessage failed ({status}): {}",
159                parsed.error.unwrap_or_else(|| "unknown error".to_string())
160            ),
161            retry_after,
162        });
163    }
164
165    Ok(())
166}
167
168/// POST to a slash-command `response_url`. Stub for the deferred-response
169/// path (spec §2.5, §4.3) — not called anywhere in v1 scope; slash commands
170/// are explicitly out of scope for this PR. Kept so the module shape doesn't
171/// need rework when they land.
172#[allow(dead_code)]
173pub async fn post_response_url(
174    http: &reqwest::Client,
175    response_url: &str,
176    body: serde_json::Value,
177) -> Result<(), String> {
178    let resp = http
179        .post(response_url)
180        .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS))
181        .json(&body)
182        .send()
183        .await
184        .map_err(|e| format!("slack rest: response_url post http error: {e}"))?;
185
186    if !resp.status().is_success() {
187        return Err(format!("slack rest: response_url post failed: {}", resp.status()));
188    }
189    Ok(())
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    fn redact_ticket_strips_value_keeps_rest_of_url() {
198        let url = "wss://wss-primary.slack.com/link/?ticket=abc123secret&app_id=A1";
199        let redacted = redact_ticket(url);
200        assert!(!redacted.contains("abc123secret"), "ticket leaked into: {redacted}");
201        assert!(redacted.contains("ticket=***"));
202        assert!(redacted.contains("app_id=A1"));
203        assert!(redacted.starts_with("wss://wss-primary.slack.com/link/?"));
204    }
205
206    #[test]
207    fn redact_ticket_handles_ticket_as_last_param() {
208        let url = "wss://wss-primary.slack.com/link/?app_id=A1&ticket=abc123secret";
209        let redacted = redact_ticket(url);
210        assert!(!redacted.contains("abc123secret"));
211        assert!(redacted.ends_with("ticket=***"));
212    }
213
214    #[test]
215    fn redact_ticket_noop_when_no_ticket_param() {
216        let url = "wss://wss-primary.slack.com/link/?app_id=A1";
217        assert_eq!(redact_ticket(url), url);
218    }
219
220    #[test]
221    fn parse_retry_after_parses_plain_seconds() {
222        assert_eq!(parse_retry_after("30"), Some(30));
223    }
224
225    #[test]
226    fn parse_retry_after_trims_whitespace() {
227        assert_eq!(parse_retry_after("  5  "), Some(5));
228    }
229
230    #[test]
231    fn parse_retry_after_none_on_http_date_form() {
232        // Slack always sends delta-seconds, but guard against the HTTP-date
233        // form anyway rather than panicking/misbehaving on it.
234        assert_eq!(parse_retry_after("Wed, 21 Oct 2026 07:28:00 GMT"), None);
235    }
236}