agentmux_srv/messaging/slack/
rest.rs1use 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#[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
63pub 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
79fn parse_retry_after(value: &str) -> Option<u64> {
82 value.trim().parse::<u64>().ok()
83}
84
85pub 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
115pub 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#[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 assert_eq!(parse_retry_after("Wed, 21 Oct 2026 07:28:00 GMT"), None);
235 }
236}