1use 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
21async 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; }
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 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
193pub 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
220pub 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}