Skip to main content

ai_crew_sync/
webhooks.rs

1//! Outgoing webhooks: forward team-visible bus events to Slack, Discord or a
2//! generic JSON endpoint, so humans see what the agents are doing without
3//! opening anything.
4//!
5//! Privacy rule: direct messages are NEVER forwarded, only channel messages
6//! and team-wide events (tasks, locks, notes).
7
8use sqlx::PgPool;
9use tokio_util::sync::CancellationToken;
10use uuid::Uuid;
11
12use crate::events::{BusEvent, EventHub};
13
14#[derive(sqlx::FromRow)]
15struct WebhookRow {
16    url: String,
17    kind: String,
18    channel_filter: Option<String>,
19}
20
21/// Human-readable line for an event, resolving ids to names. Returns `None`
22/// for events that should not be forwarded (DMs, unknown kinds).
23async fn render_event(
24    pool: &PgPool,
25    event: &BusEvent,
26) -> Option<(String, serde_json::Value, Option<String>)> {
27    match event.kind() {
28        "message" => {
29            if event.is_direct_message() {
30                return None; // never forward DMs
31            }
32            let id = event.message_id()?;
33            let row: (String, String, String) = sqlx::query_as(
34                r#"
35                SELECT s.name, ch.name, left(m.body, 500)
36                FROM messages m
37                JOIN agents s ON s.id = m.sender_agent_id
38                JOIN channels ch ON ch.id = m.channel_id
39                WHERE m.id = $1
40                "#,
41            )
42            .bind(id)
43            .fetch_optional(pool)
44            .await
45            .ok()??;
46            let (sender, channel, body) = row;
47            let text = format!("๐Ÿ’ฌ #{channel} ยท {sender}: {body}");
48            let raw = serde_json::json!({
49                "kind": "message", "channel": channel, "from": sender, "body": body,
50            });
51            Some((text, raw, Some(channel)))
52        }
53        "task" => {
54            let key = event.0.get("key").and_then(|v| v.as_str())?;
55            let status = event
56                .0
57                .get("status")
58                .and_then(|v| v.as_str())
59                .unwrap_or("?");
60            let holder = match event.0.get("claimed_by").and_then(|v| v.as_str()) {
61                Some(uuid) => {
62                    sqlx::query_scalar::<_, String>("SELECT name FROM agents WHERE id = $1::uuid")
63                        .bind(uuid)
64                        .fetch_optional(pool)
65                        .await
66                        .ok()
67                        .flatten()
68                }
69                None => None,
70            };
71            let icon = match status {
72                "done" => "โœ…",
73                "claimed" => "๐Ÿ”ง",
74                "cancelled" => "๐Ÿšซ",
75                _ => "๐Ÿ—’๏ธ",
76            };
77            let who = holder
78                .as_ref()
79                .map(|h| format!(" ({h})"))
80                .unwrap_or_default();
81            let text = format!("{icon} task `{key}` โ†’ {status}{who}");
82            let raw = serde_json::json!({
83                "kind": "task", "key": key, "status": status, "claimed_by": holder,
84            });
85            Some((text, raw, None))
86        }
87        "lock" => {
88            let name = event.0.get("name").and_then(|v| v.as_str())?;
89            let what = event
90                .0
91                .get("event")
92                .and_then(|v| v.as_str())
93                .unwrap_or("changed");
94            let holder = match event.0.get("holder_agent_id").and_then(|v| v.as_str()) {
95                Some(uuid) => {
96                    sqlx::query_scalar::<_, String>("SELECT name FROM agents WHERE id = $1::uuid")
97                        .bind(uuid)
98                        .fetch_optional(pool)
99                        .await
100                        .ok()
101                        .flatten()
102                }
103                None => None,
104            };
105            let who = holder.map(|h| format!(" by {h}")).unwrap_or_default();
106            let text = format!("๐Ÿ”’ lock `{name}` {what}{who}");
107            let raw = serde_json::json!({ "kind": "lock", "name": name, "event": what });
108            Some((text, raw, None))
109        }
110        "note" => {
111            let scope = event
112                .0
113                .get("scope")
114                .and_then(|v| v.as_str())
115                .unwrap_or("global");
116            let key = event.0.get("key").and_then(|v| v.as_str())?;
117            let by = match event.0.get("updated_by").and_then(|v| v.as_str()) {
118                Some(uuid) => {
119                    sqlx::query_scalar::<_, String>("SELECT name FROM agents WHERE id = $1::uuid")
120                        .bind(uuid)
121                        .fetch_optional(pool)
122                        .await
123                        .ok()
124                        .flatten()
125                }
126                None => None,
127            };
128            let who = by.map(|h| format!(" by {h}")).unwrap_or_default();
129            let text = format!("๐Ÿ“ note `{scope}/{key}` updated{who}");
130            let raw = serde_json::json!({ "kind": "note", "scope": scope, "key": key });
131            Some((text, raw, None))
132        }
133        _ => None,
134    }
135}
136
137async fn dispatch(pool: &PgPool, http: &reqwest::Client, event: &BusEvent) {
138    let Some(team_id) = event.team_id() else {
139        return;
140    };
141    let kind = event.kind().to_owned();
142
143    let hooks: Vec<WebhookRow> = match sqlx::query_as(
144        r#"
145        SELECT url, kind, channel_filter
146        FROM webhooks
147        WHERE team_id = $1 AND enabled AND $2 = ANY(events)
148        "#,
149    )
150    .bind(team_id)
151    .bind(&kind)
152    .fetch_all(pool)
153    .await
154    {
155        Ok(rows) => rows,
156        Err(e) => {
157            tracing::warn!(error = %e, "webhook lookup failed");
158            return;
159        }
160    };
161    if hooks.is_empty() {
162        return;
163    }
164
165    let Some((text, raw, channel)) = render_event(pool, event).await else {
166        return;
167    };
168
169    for hook in hooks {
170        // Channel filter only constrains message events.
171        if kind == "message"
172            && let (Some(filter), Some(chan)) = (&hook.channel_filter, &channel)
173            && filter != chan
174        {
175            continue;
176        }
177        let payload = match hook.kind.as_str() {
178            "slack" => serde_json::json!({ "text": text }),
179            "discord" => serde_json::json!({ "content": text }),
180            _ => raw.clone(),
181        };
182        let url = hook.url.clone();
183        match http.post(&url).json(&payload).send().await {
184            Ok(resp) if !resp.status().is_success() => {
185                tracing::warn!(url = %url, status = %resp.status(), "webhook rejected");
186            }
187            Err(e) => tracing::warn!(url = %url, error = %e, "webhook delivery failed"),
188            _ => {}
189        }
190    }
191}
192
193/// Consume the event hub and forward matching events until cancelled.
194pub async fn run_dispatcher(pool: PgPool, hub: EventHub, ct: CancellationToken) {
195    let http = match reqwest::Client::builder()
196        .timeout(std::time::Duration::from_secs(5))
197        .build()
198    {
199        Ok(c) => c,
200        Err(e) => {
201            tracing::error!(error = %e, "webhook dispatcher could not build HTTP client");
202            return;
203        }
204    };
205    let mut rx = hub.subscribe();
206    loop {
207        tokio::select! {
208            _ = ct.cancelled() => return,
209            recv = rx.recv() => match recv {
210                Ok(event) => dispatch(&pool, &http, &event).await,
211                Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
212                    tracing::warn!(missed = n, "webhook dispatcher lagged; some events not forwarded");
213                }
214                Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
215            }
216        }
217    }
218}
219
220// -------------------------------------------------------------- admin CLI --
221
222pub async fn webhook_add(
223    pool: &PgPool,
224    team: &str,
225    url: &str,
226    kind: &str,
227    events: &str,
228    channel: Option<String>,
229) -> anyhow::Result<()> {
230    let kind = kind.trim().to_lowercase();
231    if !["slack", "discord", "generic"].contains(&kind.as_str()) {
232        anyhow::bail!("kind must be slack, discord or generic");
233    }
234    let events: Vec<String> = events
235        .split(',')
236        .map(|e| e.trim().to_lowercase())
237        .filter(|e| !e.is_empty())
238        .collect();
239    for e in &events {
240        if !["message", "task", "lock", "note"].contains(&e.as_str()) {
241            anyhow::bail!("unknown event kind '{e}' (valid: message, task, lock, note)");
242        }
243    }
244    if events.is_empty() {
245        anyhow::bail!("at least one event kind is required");
246    }
247
248    let team_id: Option<(Uuid,)> = sqlx::query_as("SELECT id FROM teams WHERE slug = $1")
249        .bind(team)
250        .fetch_optional(pool)
251        .await?;
252    let Some((team_id,)) = team_id else {
253        anyhow::bail!("no team with slug '{team}'");
254    };
255
256    let (id,): (Uuid,) = sqlx::query_as(
257        r#"
258        INSERT INTO webhooks (team_id, url, kind, events, channel_filter)
259        VALUES ($1, $2, $3, $4, $5)
260        RETURNING id
261        "#,
262    )
263    .bind(team_id)
264    .bind(url)
265    .bind(&kind)
266    .bind(&events)
267    .bind(channel.as_deref().map(str::to_lowercase))
268    .fetch_one(pool)
269    .await?;
270
271    println!(
272        "webhook {id} registered ({kind}, events: {})",
273        events.join(",")
274    );
275    Ok(())
276}
277
278pub async fn webhook_list(pool: &PgPool, team: &str) -> anyhow::Result<()> {
279    let rows: Vec<(Uuid, String, String, Vec<String>, Option<String>, bool)> = sqlx::query_as(
280        r#"
281        SELECT w.id, w.url, w.kind, w.events, w.channel_filter, w.enabled
282        FROM webhooks w JOIN teams t ON t.id = w.team_id
283        WHERE t.slug = $1
284        ORDER BY w.created_at
285        "#,
286    )
287    .bind(team)
288    .fetch_all(pool)
289    .await?;
290    if rows.is_empty() {
291        println!("(no webhooks for team '{team}')");
292    }
293    for (id, url, kind, events, channel, enabled) in rows {
294        let chan = channel.map(|c| format!(" #{c}")).unwrap_or_default();
295        let state = if enabled { "" } else { " [disabled]" };
296        println!("{id}  {kind:<8} {}{chan}{state}  {url}", events.join(","));
297    }
298    Ok(())
299}
300
301pub async fn webhook_remove(pool: &PgPool, id: Uuid) -> anyhow::Result<()> {
302    let res = sqlx::query("DELETE FROM webhooks WHERE id = $1")
303        .bind(id)
304        .execute(pool)
305        .await?;
306    if res.rows_affected() == 0 {
307        anyhow::bail!("no webhook with id {id}");
308    }
309    println!("webhook {id} removed");
310    Ok(())
311}