1use async_trait::async_trait;
9use hmac::{Hmac, KeyInit, Mac};
10use serde_json::{Value, json};
11use sha2::Sha256;
12
13use crate::connection::Secret;
14
15#[derive(Clone, Debug, Default)]
17pub struct Inbound {
18 pub headers: Vec<(String, String)>,
20 pub body: Vec<u8>,
21}
22
23impl Inbound {
24 pub fn header(&self, name: &str) -> Option<&str> {
25 self.headers
26 .iter()
27 .find(|(key, _)| key.eq_ignore_ascii_case(name))
28 .map(|(_, value)| value.as_str())
29 }
30
31 pub fn json(&self) -> crate::Result<Value> {
32 Ok(serde_json::from_slice(&self.body)?)
33 }
34}
35
36#[derive(Clone, Debug, PartialEq)]
38pub enum ChannelEvent {
39 Message {
42 thread: String,
43 reply_to: String,
44 text: String,
45 },
46 Respond(Value),
48 Ignore,
50}
51
52#[async_trait]
54pub trait Channel: Send + Sync + 'static {
55 fn kind(&self) -> &'static str;
57
58 fn secrets(&self) -> Vec<Secret> {
60 Vec::new()
61 }
62
63 async fn receive(&self, inbound: Inbound) -> crate::Result<ChannelEvent>;
65
66 async fn deliver(&self, target: &str, text: &str) -> crate::Result;
69}
70
71#[derive(Clone, Debug, Default)]
74pub struct Webhook;
75
76impl Webhook {
77 pub fn new() -> Self {
78 Self
79 }
80}
81
82#[async_trait]
83impl Channel for Webhook {
84 fn kind(&self) -> &'static str {
85 "webhook"
86 }
87
88 async fn receive(&self, inbound: Inbound) -> crate::Result<ChannelEvent> {
89 let body = inbound.json()?;
90 let Some(text) = body.get("text").and_then(Value::as_str) else {
91 anyhow::bail!("webhook body needs a `text` string");
92 };
93 let thread = body
94 .get("thread")
95 .and_then(Value::as_str)
96 .unwrap_or("default")
97 .to_string();
98 let reply_to = body
99 .get("callback")
100 .and_then(Value::as_str)
101 .map(str::to_string)
102 .unwrap_or_else(|| format!("log:{thread}"));
103 Ok(ChannelEvent::Message {
104 thread,
105 reply_to,
106 text: text.to_string(),
107 })
108 }
109
110 async fn deliver(&self, target: &str, text: &str) -> crate::Result {
111 if target.starts_with("http://") || target.starts_with("https://") {
112 reqwest::Client::new()
113 .post(target)
114 .json(&json!({ "text": text }))
115 .send()
116 .await?
117 .error_for_status()?;
118 } else {
119 println!(" ↳ [webhook {target}] {text}");
120 }
121 Ok(())
122 }
123}
124
125#[derive(Clone, Debug)]
132pub struct Slack {
133 token: Secret,
134 signing_secret: Secret,
135 mention_only: bool,
136 api_base: String,
137}
138
139impl Slack {
140 pub fn from_secrets() -> Self {
142 Self {
143 token: Secret::named("SLACK_BOT_TOKEN"),
144 signing_secret: Secret::named("SLACK_SIGNING_SECRET"),
145 mention_only: false,
146 api_base: "https://slack.com/api".to_string(),
147 }
148 }
149
150 pub fn mention_only(mut self) -> Self {
152 self.mention_only = true;
153 self
154 }
155
156 fn verify(&self, inbound: &Inbound) -> crate::Result {
157 let Ok(secret) = self.signing_secret.value() else {
158 return Ok(());
160 };
161 let timestamp = inbound
162 .header("x-slack-request-timestamp")
163 .ok_or_else(|| anyhow::anyhow!("missing x-slack-request-timestamp"))?;
164 let signature = inbound
165 .header("x-slack-signature")
166 .and_then(|value| value.strip_prefix("v0="))
167 .ok_or_else(|| anyhow::anyhow!("missing x-slack-signature"))?;
168 let expected = hex::decode(signature)?;
169 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
170 .map_err(|err| anyhow::anyhow!("signing secret: {err}"))?;
171 mac.update(format!("v0:{timestamp}:").as_bytes());
172 mac.update(&inbound.body);
173 mac.verify_slice(&expected)
174 .map_err(|_| anyhow::anyhow!("Slack signature does not match"))
175 }
176}
177
178#[async_trait]
179impl Channel for Slack {
180 fn kind(&self) -> &'static str {
181 "slack"
182 }
183
184 fn secrets(&self) -> Vec<Secret> {
185 vec![self.token, self.signing_secret]
186 }
187
188 async fn receive(&self, inbound: Inbound) -> crate::Result<ChannelEvent> {
189 self.verify(&inbound)?;
190 let body = inbound.json()?;
191 match body.get("type").and_then(Value::as_str) {
192 Some("url_verification") => {
193 return Ok(ChannelEvent::Respond(
194 json!({ "challenge": body.get("challenge").cloned().unwrap_or(Value::Null) }),
195 ));
196 }
197 Some("event_callback") => {}
198 _ => return Ok(ChannelEvent::Ignore),
199 }
200 let Some(event) = body.get("event") else {
201 return Ok(ChannelEvent::Ignore);
202 };
203 let kind = event.get("type").and_then(Value::as_str).unwrap_or("");
204 let from_bot = event.get("bot_id").is_some();
205 let wanted = kind == "app_mention" || (!self.mention_only && kind == "message");
206 if from_bot || !wanted {
207 return Ok(ChannelEvent::Ignore);
208 }
209 let channel = event.get("channel").and_then(Value::as_str).unwrap_or("");
210 let ts = event.get("ts").and_then(Value::as_str).unwrap_or("");
211 let thread_ts = event.get("thread_ts").and_then(Value::as_str).unwrap_or(ts);
212 let text = strip_mentions(event.get("text").and_then(Value::as_str).unwrap_or(""));
213 Ok(ChannelEvent::Message {
214 thread: format!("{channel}:{thread_ts}"),
215 reply_to: format!("{channel}:{thread_ts}"),
216 text,
217 })
218 }
219
220 async fn deliver(&self, target: &str, text: &str) -> crate::Result {
221 let (channel, thread_ts) = match target.split_once(':') {
222 Some((channel, ts)) => (channel, Some(ts)),
223 None => (target, None),
224 };
225 let Ok(token) = self.token.value() else {
226 println!(" ↳ [slack {target}] (no SLACK_BOT_TOKEN, not posted) {text}");
227 return Ok(());
228 };
229 let mut body = json!({ "channel": channel, "text": text });
230 if let Some(ts) = thread_ts {
231 body["thread_ts"] = json!(ts);
232 }
233 let response: Value = reqwest::Client::new()
234 .post(format!("{}/chat.postMessage", self.api_base))
235 .bearer_auth(token)
236 .json(&body)
237 .send()
238 .await?
239 .json()
240 .await?;
241 if response.get("ok").and_then(Value::as_bool) != Some(true) {
242 anyhow::bail!("chat.postMessage failed: {response}");
243 }
244 Ok(())
245 }
246}
247
248fn strip_mentions(text: &str) -> String {
250 let mut out = String::with_capacity(text.len());
251 let mut rest = text;
252 while let Some(start) = rest.find("<@") {
253 out.push_str(&rest[..start]);
254 match rest[start..].find('>') {
255 Some(end) => rest = &rest[start + end + 1..],
256 None => {
257 rest = &rest[start..];
258 break;
259 }
260 }
261 }
262 out.push_str(rest);
263 out.split_whitespace().collect::<Vec<_>>().join(" ")
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269
270 fn inbound(body: Value) -> Inbound {
271 Inbound {
272 headers: Vec::new(),
273 body: serde_json::to_vec(&body).unwrap(),
274 }
275 }
276
277 #[tokio::test]
278 async fn slack_answers_url_verification() {
279 let slack = Slack::from_secrets();
280 let event = slack
281 .receive(inbound(
282 json!({"type": "url_verification", "challenge": "abc"}),
283 ))
284 .await
285 .unwrap();
286 assert_eq!(event, ChannelEvent::Respond(json!({"challenge": "abc"})));
287 }
288
289 #[tokio::test]
290 async fn slack_mention_becomes_a_threaded_message() {
291 let slack = Slack::from_secrets().mention_only();
292 let event = slack
293 .receive(inbound(json!({
294 "type": "event_callback",
295 "event": {"type": "app_mention", "channel": "C1", "ts": "1.2", "text": "<@U9> revenue?"}
296 })))
297 .await
298 .unwrap();
299 assert_eq!(
300 event,
301 ChannelEvent::Message {
302 thread: "C1:1.2".into(),
303 reply_to: "C1:1.2".into(),
304 text: "revenue?".into()
305 }
306 );
307 }
308
309 #[tokio::test]
310 async fn slack_mention_only_ignores_plain_messages_and_bots() {
311 let slack = Slack::from_secrets().mention_only();
312 let plain = json!({"type": "event_callback", "event": {"type": "message", "channel": "C1", "ts": "1", "text": "hi"}});
313 assert_eq!(
314 slack.receive(inbound(plain)).await.unwrap(),
315 ChannelEvent::Ignore
316 );
317 let bot = json!({"type": "event_callback", "event": {"type": "app_mention", "bot_id": "B1", "channel": "C1", "ts": "1", "text": "hi"}});
318 assert_eq!(
319 slack.receive(inbound(bot)).await.unwrap(),
320 ChannelEvent::Ignore
321 );
322 }
323
324 #[tokio::test]
325 async fn webhook_requires_text() {
326 assert!(
327 Webhook::new()
328 .receive(inbound(json!({"thread": "t"})))
329 .await
330 .is_err()
331 );
332 let event = Webhook::new()
333 .receive(inbound(json!({"thread": "t", "text": "hi"})))
334 .await
335 .unwrap();
336 assert!(matches!(event, ChannelEvent::Message { ref thread, .. } if thread == "t"));
337 }
338
339 #[test]
340 fn strip_mentions_keeps_the_rest() {
341 assert_eq!(strip_mentions("<@U1> hello <@U2>there"), "hello there");
342 assert_eq!(strip_mentions("no mentions"), "no mentions");
343 assert_eq!(strip_mentions("broken <@U1"), "broken <@U1");
344 }
345}