Skip to main content

serve/
channel.rs

1//! Channels: where conversations come from and where replies go.
2//!
3//! A channel is served at `POST /v1/channels/{name}`. It turns a webhook into
4//! a [`ChannelEvent`]; the host maps each conversation thread to one session
5//! (so a Slack thread keeps its context), and after every turn hands the reply
6//! back to [`Channel::deliver`].
7
8use async_trait::async_trait;
9use hmac::{Hmac, KeyInit, Mac};
10use serde_json::{Value, json};
11use sha2::Sha256;
12
13use crate::connection::Secret;
14
15/// An inbound webhook request.
16#[derive(Clone, Debug, Default)]
17pub struct Inbound {
18    /// Lower-cased header names.
19    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/// What a webhook means.
37#[derive(Clone, Debug, PartialEq)]
38pub enum ChannelEvent {
39    /// A message for the agent. `thread` identifies the conversation (one
40    /// session per thread); `reply_to` is where [`Channel::deliver`] posts.
41    Message {
42        thread: String,
43        reply_to: String,
44        text: String,
45    },
46    /// Answer the webhook directly (e.g. Slack's URL verification).
47    Respond(Value),
48    /// Acknowledge and do nothing.
49    Ignore,
50}
51
52/// A conversation surface.
53#[async_trait]
54pub trait Channel: Send + Sync + 'static {
55    /// Short kind for the manifest and agent card, e.g. `"slack"`.
56    fn kind(&self) -> &'static str;
57
58    /// Secrets this channel needs, for the manifest.
59    fn secrets(&self) -> Vec<Secret> {
60        Vec::new()
61    }
62
63    /// Interpret one webhook.
64    async fn receive(&self, inbound: Inbound) -> crate::Result<ChannelEvent>;
65
66    /// Post a reply. `target` is a `reply_to` from [`receive`](Self::receive)
67    /// or the target of a [`DeliveryTarget`](crate::DeliveryTarget).
68    async fn deliver(&self, target: &str, text: &str) -> crate::Result;
69}
70
71/// A generic JSON webhook: `{"thread": "...", "text": "..."}` in, replies
72/// printed (or POSTed to `callback`, when the request carries one).
73#[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/// Slack via the Events API: `app_mention` (and, unless mention-only, direct
126/// messages) in; `chat.postMessage` out, threaded.
127///
128/// Secrets: `SLACK_BOT_TOKEN` to post, `SLACK_SIGNING_SECRET` to verify
129/// requests. Without the token, replies are printed instead of posted, which
130/// is what `dev` wants.
131#[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    /// Read credentials from the host-provided secrets.
141    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    /// Only respond when the bot is @-mentioned.
151    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            // Unverified in dev by design; a hosted deploy always sets it.
159            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
248/// Remove `<@U123>` mention tokens and surrounding whitespace.
249fn 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}