1use std::sync::Arc;
4
5use autumn_web::AppState;
6use serde::Deserialize;
7use serde_json::{Map, Value};
8
9use crate::config::SlackConfig;
10use crate::error::SlackError;
11use crate::metrics::SlackMetrics;
12use crate::payload::Message;
13use crate::policy::{Outcome, RetryRule, Step, decide, retry_after_ms};
14use crate::transport::{HttpReply, HttpRequest, HttpTransport, TransportError};
15
16const TRIGGER_MAX_WAIT_MS: u64 = 1_000;
19const TRIGGER_MAX_ATTEMPTS: u32 = 2;
21
22const RESPONSE_URL_LABEL: &str = "response_url";
24const REPLY_ONLY: [&str; 3] = ["response_type", "replace_original", "delete_original"];
26
27#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
29#[non_exhaustive]
30pub struct MessageRef {
31 pub channel: String,
33 pub ts: String,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
39#[non_exhaustive]
40pub struct AuthTest {
41 pub team_id: String,
43 pub user_id: String,
45 #[serde(default)]
47 pub bot_id: Option<String>,
48 #[serde(default)]
50 pub team: Option<String>,
51 #[serde(default)]
53 pub url: Option<String>,
54}
55
56struct Inner {
57 transport: Arc<dyn HttpTransport>,
58 config: SlackConfig,
59 token: Option<String>,
60 metrics: Arc<SlackMetrics>,
61}
62
63#[derive(Clone)]
68pub struct SlackClient {
69 inner: Arc<Inner>,
70}
71
72impl std::fmt::Debug for SlackClient {
73 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 f.debug_struct("SlackClient")
75 .field("api_base_url", &self.inner.config.api_base_url)
76 .field("has_token", &self.inner.token.is_some())
77 .finish_non_exhaustive()
78 }
79}
80
81impl SlackClient {
82 pub(crate) fn new(
83 transport: Arc<dyn HttpTransport>,
84 config: SlackConfig,
85 token: Option<String>,
86 metrics: Arc<SlackMetrics>,
87 ) -> Self {
88 Self {
89 inner: Arc::new(Inner {
90 transport,
91 config,
92 token,
93 metrics,
94 }),
95 }
96 }
97
98 #[must_use]
100 pub fn from_state(state: &AppState) -> Option<Self> {
101 state.extension::<Self>().map(|c| (*c).clone())
102 }
103
104 #[must_use]
106 pub fn has_token(&self) -> bool {
107 self.inner.token.is_some()
108 }
109
110 pub async fn call(&self, method: &str, params: &Value) -> Result<Value, SlackError> {
119 self.call_with(method, params, false, self.rule()).await
120 }
121
122 pub async fn call_read(&self, method: &str, params: &Value) -> Result<Value, SlackError> {
127 self.call_with(method, params, true, self.rule()).await
128 }
129
130 fn rule(&self) -> RetryRule {
132 self.inner.config.api.rule()
133 }
134
135 fn trigger_rule(&self) -> RetryRule {
137 let mut r = self.rule();
138 r.max_wait_ms = r.max_wait_ms.min(TRIGGER_MAX_WAIT_MS);
139 r.max_attempts = r.max_attempts.min(TRIGGER_MAX_ATTEMPTS);
140 r
141 }
142
143 async fn call_with(
144 &self,
145 method: &str,
146 params: &Value,
147 idempotent: bool,
148 rule: RetryRule,
149 ) -> Result<Value, SlackError> {
150 if !valid_method(method) {
151 return Err(SlackError::Config(format!(
152 "not a Web API method name: {method:?}"
153 )));
154 }
155 let Value::Object(map) = params else {
156 return Err(SlackError::Config(format!(
157 "{method}: params must be a JSON object"
158 )));
159 };
160 let Some(token) = self.inner.token.as_deref() else {
161 return Err(SlackError::Config(format!(
162 "{method}: no bot token; set the env var {}",
163 self.inner.config.bot_token_env
164 )));
165 };
166 let url = format!("{}{method}", self.inner.config.api_base_url);
167 let body = form_body(map).into_bytes();
168 let mut attempt: u32 = 1;
169 loop {
170 let req = HttpRequest::new(url.as_str())
171 .header("authorization", format!("Bearer {token}"))
172 .header(
173 "content-type",
174 "application/x-www-form-urlencoded; charset=utf-8",
175 )
176 .body(body.clone());
177 let result = self.inner.transport.post(req).await;
178 let (outcome, retry_after) = classify(&result);
179 match decide(
180 outcome,
181 attempt,
182 idempotent,
183 retry_after.map(retry_after_ms),
184 &rule,
185 ) {
186 Step::Retry(wait_ms) => {
187 self.inner.metrics.api_retry(method);
188 tracing::debug!(method, attempt, wait_ms, "slack api retry");
189 tokio::time::sleep(std::time::Duration::from_millis(wait_ms)).await;
190 attempt += 1;
191 }
192 Step::Done | Step::GiveUp => return self.finish(method, result),
193 }
194 }
195 }
196
197 fn finish(
199 &self,
200 method: &str,
201 result: Result<HttpReply, TransportError>,
202 ) -> Result<Value, SlackError> {
203 let m = &self.inner.metrics;
204 let reply = match result {
205 Ok(r) => r,
206 Err(e) => {
207 m.api_call(method, "transport");
208 return Err(SlackError::Transport(e.0));
209 }
210 };
211 if reply.status == 429 {
212 m.api_call(method, "rate_limited");
213 return Err(SlackError::RateLimited {
214 retry_after_secs: reply.retry_after_secs,
215 });
216 }
217 if !(200..300).contains(&reply.status) {
218 m.api_call(method, "http_error");
219 return Err(http_error(&reply));
220 }
221 let v: Value = serde_json::from_slice(&reply.body).map_err(|e| {
222 m.api_call(method, "decode");
223 SlackError::Decode(format!("{method}: {e}"))
224 })?;
225 if v.get("ok").and_then(Value::as_bool) == Some(true) {
226 m.api_call(method, "ok");
227 return Ok(v);
228 }
229 m.api_call(method, "api_error");
230 Err(SlackError::Api {
231 method: method.to_owned(),
232 code: v
233 .get("error")
234 .and_then(Value::as_str)
235 .unwrap_or("unknown_error")
236 .to_owned(),
237 needed: v.get("needed").and_then(Value::as_str).map(str::to_owned),
238 })
239 }
240
241 async fn call_as<T: serde::de::DeserializeOwned>(
243 &self,
244 method: &str,
245 params: Value,
246 idempotent: bool,
247 ) -> Result<T, SlackError> {
248 let v = self
249 .call_with(method, ¶ms, idempotent, self.rule())
250 .await?;
251 T::deserialize(&v).map_err(|e| SlackError::Decode(format!("{method}: {e}")))
252 }
253
254 pub async fn chat_post_message(
259 &self,
260 channel: &str,
261 msg: &Message,
262 ) -> Result<MessageRef, SlackError> {
263 let mut p = api_fields(msg);
264 p.insert("channel".to_owned(), channel.into());
265 self.call_as("chat.postMessage", Value::Object(p), false)
266 .await
267 }
268
269 pub async fn chat_post_ephemeral(
274 &self,
275 channel: &str,
276 user: &str,
277 msg: &Message,
278 ) -> Result<String, SlackError> {
279 #[derive(Deserialize)]
280 struct Reply {
281 message_ts: String,
282 }
283 let mut p = api_fields(msg);
284 p.insert("channel".to_owned(), channel.into());
285 p.insert("user".to_owned(), user.into());
286 let r: Reply = self
287 .call_as("chat.postEphemeral", Value::Object(p), false)
288 .await?;
289 Ok(r.message_ts)
290 }
291
292 pub async fn chat_update(
297 &self,
298 channel: &str,
299 ts: &str,
300 msg: &Message,
301 ) -> Result<MessageRef, SlackError> {
302 let mut p = api_fields(msg);
303 p.remove("thread_ts");
305 p.insert("channel".to_owned(), channel.into());
306 p.insert("ts".to_owned(), ts.into());
307 self.call_as("chat.update", Value::Object(p), false).await
308 }
309
310 pub async fn chat_delete(&self, channel: &str, ts: &str) -> Result<(), SlackError> {
315 self.call(
316 "chat.delete",
317 &serde_json::json!({"channel": channel, "ts": ts}),
318 )
319 .await
320 .map(drop)
321 }
322
323 pub async fn reactions_add(
328 &self,
329 channel: &str,
330 ts: &str,
331 name: &str,
332 ) -> Result<(), SlackError> {
333 let p = serde_json::json!({"channel": channel, "timestamp": ts, "name": name.trim_matches(':')});
334 self.call("reactions.add", &p).await.map(drop)
335 }
336
337 pub async fn views_open(&self, trigger_id: &str, view: &Value) -> Result<Value, SlackError> {
342 let p = serde_json::json!({"trigger_id": trigger_id, "view": view});
343 self.view_call("views.open", p, self.trigger_rule()).await
344 }
345
346 pub async fn views_update(
352 &self,
353 view_id: &str,
354 view: &Value,
355 hash: Option<&str>,
356 ) -> Result<Value, SlackError> {
357 let p = serde_json::json!({"view_id": view_id, "view": view, "hash": hash});
358 self.view_call("views.update", p, self.rule()).await
359 }
360
361 pub async fn views_push(&self, trigger_id: &str, view: &Value) -> Result<Value, SlackError> {
366 let p = serde_json::json!({"trigger_id": trigger_id, "view": view});
367 self.view_call("views.push", p, self.trigger_rule()).await
368 }
369
370 pub async fn views_publish(&self, user_id: &str, view: &Value) -> Result<Value, SlackError> {
375 let p = serde_json::json!({"user_id": user_id, "view": view});
376 self.view_call("views.publish", p, self.rule()).await
377 }
378
379 async fn view_call(
381 &self,
382 method: &str,
383 params: Value,
384 rule: RetryRule,
385 ) -> Result<Value, SlackError> {
386 let mut v = self.call_with(method, ¶ms, false, rule).await?;
387 Ok(v.get_mut("view").map(Value::take).unwrap_or_default())
388 }
389
390 pub async fn users_info(&self, user: &str) -> Result<Value, SlackError> {
395 let mut v = self
396 .call_read("users.info", &serde_json::json!({"user": user}))
397 .await?;
398 Ok(v.get_mut("user").map(Value::take).unwrap_or_default())
399 }
400
401 pub async fn auth_test(&self) -> Result<AuthTest, SlackError> {
406 self.call_as("auth.test", Value::Object(Map::new()), true)
407 .await
408 }
409
410 pub(crate) async fn auth_test_once(&self) -> Result<(), SlackError> {
413 let mut rule = self.rule();
414 rule.max_attempts = 1;
415 self.call_with("auth.test", &Value::Object(Map::new()), true, rule)
416 .await
417 .map(drop)
418 }
419
420 pub async fn paginate(
426 &self,
427 method: &str,
428 params: &Value,
429 key: &str,
430 max_pages: usize,
431 ) -> Result<Vec<Value>, SlackError> {
432 let Value::Object(base) = params else {
433 return Err(SlackError::Config(format!(
434 "{method}: params must be a JSON object"
435 )));
436 };
437 let mut out = Vec::new();
438 let mut cursor: Option<String> = None;
439 for _ in 0..max_pages {
440 let mut p = base.clone();
441 if let Some(c) = cursor.take() {
442 p.insert("cursor".to_owned(), Value::String(c));
443 }
444 let mut v = self.call_read(method, &Value::Object(p)).await?;
445 if let Some(Value::Array(items)) = v.get_mut(key).map(Value::take) {
446 out.extend(items);
447 }
448 cursor = v
449 .pointer("/response_metadata/next_cursor")
450 .and_then(Value::as_str)
451 .filter(|c| !c.is_empty())
452 .map(str::to_owned);
453 if cursor.is_none() {
454 break;
455 }
456 }
457 Ok(out)
458 }
459
460 pub async fn respond(&self, response_url: &str, msg: &Message) -> Result<(), SlackError> {
466 let m = &self.inner.metrics;
467 if !allowed_response_url(response_url, &self.inner.config.response_url_hosts) {
468 m.api_call(RESPONSE_URL_LABEL, "rejected");
469 return Err(SlackError::BadResponseUrl);
470 }
471 let body = serde_json::to_vec(msg).map_err(|e| SlackError::Decode(e.to_string()))?;
472 let req = HttpRequest::new(response_url)
473 .header("content-type", "application/json; charset=utf-8")
474 .body(body);
475 match self.inner.transport.post(req).await {
477 Err(e) => {
478 m.api_call(RESPONSE_URL_LABEL, "transport");
479 Err(SlackError::Transport(e.0))
480 }
481 Ok(r) if (200..300).contains(&r.status) => {
482 m.api_call(RESPONSE_URL_LABEL, "ok");
483 Ok(())
484 }
485 Ok(r) => {
486 m.api_call(RESPONSE_URL_LABEL, "http_error");
487 Err(http_error(&r))
488 }
489 }
490 }
491}
492
493fn classify(result: &Result<HttpReply, TransportError>) -> (Outcome, Option<u64>) {
495 result
496 .as_ref()
497 .map_or((Outcome::NetworkError, None), |r| match r.status {
498 429 => (Outcome::RateLimited, r.retry_after_secs),
499 200..=299 => (Outcome::Success, None),
500 500..=599 => (Outcome::ServerError, None),
501 _ => (Outcome::ClientError, None),
502 })
503}
504
505fn http_error(reply: &HttpReply) -> SlackError {
507 let end = reply.body.len().min(200);
508 SlackError::Http {
509 status: reply.status,
510 body: String::from_utf8_lossy(&reply.body[..end]).into_owned(),
511 }
512}
513
514fn api_fields(msg: &Message) -> Map<String, Value> {
516 let mut p = msg.to_map();
517 for k in REPLY_ONLY {
518 p.remove(k);
519 }
520 p
521}
522
523pub(crate) fn allowed_response_url(url: &str, hosts: &[String]) -> bool {
526 let Ok(u) = reqwest::Url::parse(url) else {
527 return false;
528 };
529 u.scheme() == "https"
530 && u.username().is_empty()
531 && u.password().is_none()
532 && u.port().is_none()
533 && u.host_str()
534 .is_some_and(|h| hosts.iter().any(|a| a.eq_ignore_ascii_case(h)))
535}
536
537pub(crate) fn valid_method(method: &str) -> bool {
539 let mut parts = 0;
540 for part in method.split('.') {
541 if part.is_empty() || !part.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') {
542 return false;
543 }
544 parts += 1;
545 }
546 parts >= 2
547}
548
549pub(crate) fn form_body(params: &Map<String, Value>) -> String {
551 let pairs: Vec<(&str, String)> = params
552 .iter()
553 .filter_map(|(k, v)| {
554 let text = match v {
555 Value::Null => return None,
556 Value::String(s) => s.clone(),
557 other => other.to_string(),
558 };
559 Some((k.as_str(), text))
560 })
561 .collect();
562 serde_urlencoded::to_string(pairs).unwrap_or_default()
564}
565
566#[cfg(test)]
567mod tests {
568 use super::*;
569 use proptest::prelude::*;
570 use serde_json::json;
571
572 fn hosts() -> Vec<String> {
573 vec!["hooks.slack.com".to_owned()]
574 }
575
576 #[test]
577 fn response_url_rules() {
578 assert!(allowed_response_url(
579 "https://hooks.slack.com/commands/T/1/x",
580 &hosts()
581 ));
582 assert!(allowed_response_url(
583 "https://HOOKS.slack.com/actions/T/1/x",
584 &hosts()
585 ));
586 for bad in [
587 "http://hooks.slack.com/x",
588 "https://hooks.slack.com.evil/x",
589 "https://evil/hooks.slack.com/x",
590 "https://a@hooks.slack.com/x",
591 "https://hooks.slack.com:444/x",
592 "javascript:alert(1)",
593 "",
594 ] {
595 assert!(!allowed_response_url(bad, &hosts()), "{bad}");
596 }
597 }
598
599 #[test]
600 fn method_names() {
601 assert!(valid_method("chat.postMessage"));
602 assert!(valid_method("admin.conversations.setTeams"));
603 for bad in [
604 "chat", "chat.", ".x", "a..b", "../x", "a/b", "a.b?c", "a.b c", "",
605 ] {
606 assert!(!valid_method(bad), "{bad}");
607 }
608 }
609
610 #[test]
611 fn form_encoding() {
612 let p = json!({"a": "x y&z", "n": 1, "b": false, "o": {"k": [1]}, "z": null});
613 let body = form_body(p.as_object().unwrap());
614 let back: std::collections::BTreeMap<String, String> =
615 serde_urlencoded::from_str(&body).unwrap();
616 assert_eq!(back["a"], "x y&z");
617 assert_eq!(back["n"], "1");
618 assert_eq!(back["b"], "false");
619 assert_eq!(back["o"], r#"{"k":[1]}"#);
620 assert!(!back.contains_key("z"));
621 }
622
623 proptest! {
624 #[test]
625 fn form_round_trips_strings(k in "[a-z_]{1,10}", v in ".{0,40}") {
626 let mut m = Map::new();
627 m.insert(k.clone(), Value::String(v.clone()));
628 let back: std::collections::BTreeMap<String, String> = serde_urlencoded::from_str(&form_body(&m)).unwrap();
629 prop_assert_eq!(&back[&k], &v);
630 }
631
632 #[test]
633 fn only_listed_hosts_pass(host in "[a-z]{1,12}(\\.[a-z]{2,6}){1,2}", path in "[a-z/]{0,20}") {
634 let url = format!("https://{host}/{path}");
635 prop_assert_eq!(allowed_response_url(&url, &hosts()), host == "hooks.slack.com");
636 }
637 }
638}