1use std::time::Duration;
20
21use sqlx::PgPool;
22use tokio_util::sync::CancellationToken;
23use uuid::Uuid;
24
25use crate::events::EventHub;
26
27const MAX_ATTEMPTS: i32 = 6;
29const BATCH: i64 = 32;
31const CONCURRENCY: usize = 8;
34const POLL_INTERVAL: Duration = Duration::from_secs(5);
36const PRUNE_INTERVAL: Duration = Duration::from_secs(600);
39const KEEP_SENT: Duration = Duration::from_secs(24 * 3600);
41const KEEP_FAILED: Duration = Duration::from_secs(7 * 24 * 3600);
43
44#[derive(sqlx::FromRow)]
45struct Delivery {
46 id: i64,
47 url: String,
48 kind: String,
49 summary: String,
50 payload: serde_json::Value,
51 attempts: i32,
52}
53
54async fn claim(pool: &PgPool) -> Result<Vec<Delivery>, sqlx::Error> {
60 sqlx::query_as(
61 r#"
62 WITH due AS (
63 SELECT d.id
64 FROM webhook_deliveries d
65 WHERE d.status = 'pending' AND d.next_attempt_at <= now()
66 ORDER BY d.next_attempt_at, d.id
67 FOR UPDATE SKIP LOCKED
68 LIMIT $1
69 )
70 UPDATE webhook_deliveries d
71 SET attempts = d.attempts + 1,
72 -- Hold the row for the duration of the attempt: if this replica
73 -- dies mid-POST, the row becomes due again instead of vanishing.
74 next_attempt_at = now() + interval '60 seconds',
75 updated_at = now()
76 FROM due, webhooks w
77 WHERE d.id = due.id AND w.id = d.webhook_id
78 RETURNING d.id, w.url, w.kind, d.summary, d.payload, d.attempts
79 "#,
80 )
81 .bind(BATCH)
82 .fetch_all(pool)
83 .await
84}
85
86fn body_for(kind: &str, summary: &str, payload: &serde_json::Value) -> serde_json::Value {
88 match kind {
89 "slack" => serde_json::json!({ "text": summary }),
90 "discord" => serde_json::json!({ "content": summary }),
91 _ => payload.clone(),
92 }
93}
94
95async fn mark_sent(pool: &PgPool, id: i64) {
96 if let Err(e) = sqlx::query(
100 "UPDATE webhook_deliveries SET status = 'sent', last_error = NULL, updated_at = now()
101 WHERE id = $1",
102 )
103 .bind(id)
104 .execute(pool)
105 .await
106 {
107 tracing::warn!(delivery = id, error = %e, "could not record a successful delivery");
108 }
109}
110
111async fn mark_failed(pool: &PgPool, id: i64, attempts: i32, error: &str) {
114 let backoff = 2i64.saturating_pow(attempts.clamp(1, 8) as u32).min(600);
117 let terminal = attempts >= MAX_ATTEMPTS;
118 if terminal {
119 tracing::warn!(
120 delivery = id,
121 attempts,
122 error,
123 "webhook delivery permanently failed"
124 );
125 }
126 let recorded = sqlx::query(
127 r#"
128 UPDATE webhook_deliveries
129 SET status = CASE WHEN $2 THEN 'failed' ELSE 'pending' END,
130 next_attempt_at = now() + make_interval(secs => $3),
131 last_error = left($4, 500),
132 updated_at = now()
133 WHERE id = $1
134 "#,
135 )
136 .bind(id)
137 .bind(terminal)
138 .bind(backoff as f64)
139 .bind(error)
140 .execute(pool)
141 .await;
142 if let Err(e) = recorded {
143 tracing::warn!(delivery = id, error = %e, "could not record a failed delivery");
147 }
148}
149
150async fn send_batch(pool: &PgPool, http: &reqwest::Client, batch: Vec<Delivery>) {
152 let mut queue = batch.into_iter();
153 let mut running = tokio::task::JoinSet::new();
154
155 loop {
156 while running.len() < CONCURRENCY {
157 let Some(d) = queue.next() else { break };
158 let http = http.clone();
159 let pool = pool.clone();
160 running.spawn(async move {
161 let body = body_for(&d.kind, &d.summary, &d.payload);
162 match http.post(&d.url).json(&body).send().await {
163 Ok(resp) if resp.status().is_success() => {
164 let _ = resp.bytes().await;
168 mark_sent(&pool, d.id).await;
169 }
170 Ok(resp) => {
171 let status = resp.status();
172 let permanent = status.is_client_error()
175 && status != reqwest::StatusCode::REQUEST_TIMEOUT
176 && status != reqwest::StatusCode::TOO_MANY_REQUESTS;
177 let attempts = if permanent { MAX_ATTEMPTS } else { d.attempts };
178 let _ = resp.bytes().await;
179 mark_failed(&pool, d.id, attempts, &format!("HTTP {status}")).await;
180 }
181 Err(e) => mark_failed(&pool, d.id, d.attempts, &e.to_string()).await,
182 }
183 });
184 }
185 match running.join_next().await {
186 None => break,
187 Some(Ok(())) => {}
188 Some(Err(e)) => tracing::error!(error = %e, "webhook delivery task failed"),
191 }
192 }
193}
194
195async fn prune(pool: &PgPool) {
197 let _ = sqlx::query(
198 r#"
199 DELETE FROM webhook_deliveries
200 WHERE (status = 'sent' AND updated_at < now() - make_interval(secs => $1))
201 OR (status = 'failed' AND updated_at < now() - make_interval(secs => $2))
202 "#,
203 )
204 .bind(KEEP_SENT.as_secs() as f64)
205 .bind(KEEP_FAILED.as_secs() as f64)
206 .execute(pool)
207 .await;
208}
209
210pub async fn run_dispatcher(pool: PgPool, hub: EventHub, ct: CancellationToken) {
216 let http = match reqwest::Client::builder()
217 .timeout(Duration::from_secs(5))
218 .build()
219 {
220 Ok(c) => c,
221 Err(e) => {
222 tracing::error!(error = %e, "webhook dispatcher could not build HTTP client");
223 return;
224 }
225 };
226 let mut rx = hub.subscribe();
227
228 let mut last_prune = tokio::time::Instant::now();
229
230 loop {
231 if last_prune.elapsed() >= PRUNE_INTERVAL {
235 last_prune = tokio::time::Instant::now();
236 prune(&pool).await;
237 }
238
239 match claim(&pool).await {
240 Ok(batch) if !batch.is_empty() => {
241 send_batch(&pool, &http, batch).await;
242 continue; }
244 Ok(_) => {}
245 Err(e) => tracing::warn!(error = %e, "claiming webhook deliveries failed"),
246 }
247
248 tokio::select! {
250 _ = ct.cancelled() => return,
251 _ = tokio::time::sleep(POLL_INTERVAL) => {}
252 recv = rx.recv() => {
253 if matches!(recv, Err(tokio::sync::broadcast::error::RecvError::Closed)) {
254 return;
255 }
256 }
259 }
260 }
261}
262
263pub async fn webhook_add(
266 pool: &PgPool,
267 team: &str,
268 url: &str,
269 kind: &str,
270 events: &str,
271 channel: Option<String>,
272) -> anyhow::Result<()> {
273 let kind = kind.trim().to_lowercase();
274 if !["slack", "discord", "generic"].contains(&kind.as_str()) {
275 anyhow::bail!("kind must be slack, discord or generic");
276 }
277 let events: Vec<String> = events
278 .split(',')
279 .map(|e| e.trim().to_lowercase())
280 .filter(|e| !e.is_empty())
281 .collect();
282 for e in &events {
283 if !["message", "task", "lock", "note"].contains(&e.as_str()) {
284 anyhow::bail!("unknown event kind '{e}' (valid: message, task, lock, note)");
285 }
286 }
287 if events.is_empty() {
288 anyhow::bail!("at least one event kind is required");
289 }
290
291 let team_id: Option<(Uuid,)> = sqlx::query_as("SELECT id FROM teams WHERE slug = $1")
292 .bind(team)
293 .fetch_optional(pool)
294 .await?;
295 let Some((team_id,)) = team_id else {
296 anyhow::bail!("no team with slug '{team}'");
297 };
298
299 let (id,): (Uuid,) = sqlx::query_as(
300 r#"
301 INSERT INTO webhooks (team_id, url, kind, events, channel_filter)
302 VALUES ($1, $2, $3, $4, $5)
303 RETURNING id
304 "#,
305 )
306 .bind(team_id)
307 .bind(url)
308 .bind(&kind)
309 .bind(&events)
310 .bind(channel.as_deref().map(str::to_lowercase))
311 .fetch_one(pool)
312 .await?;
313
314 println!(
315 "webhook {id} registered ({kind}, events: {})",
316 events.join(",")
317 );
318 Ok(())
319}
320
321pub async fn webhook_list(pool: &PgPool, team: &str) -> anyhow::Result<()> {
322 let rows: Vec<(Uuid, String, String, Vec<String>, Option<String>, bool)> = sqlx::query_as(
323 r#"
324 SELECT w.id, w.url, w.kind, w.events, w.channel_filter, w.enabled
325 FROM webhooks w JOIN teams t ON t.id = w.team_id
326 WHERE t.slug = $1
327 ORDER BY w.created_at
328 "#,
329 )
330 .bind(team)
331 .fetch_all(pool)
332 .await?;
333 if rows.is_empty() {
334 println!("(no webhooks for team '{team}')");
335 }
336 for (id, url, kind, events, channel, enabled) in rows {
337 let chan = channel.map(|c| format!(" #{c}")).unwrap_or_default();
338 let state = if enabled { "" } else { " [disabled]" };
339 println!("{id} {kind:<8} {}{chan}{state} {url}", events.join(","));
340 }
341 Ok(())
342}
343
344pub async fn webhook_remove(pool: &PgPool, id: Uuid) -> anyhow::Result<()> {
345 let res = sqlx::query("DELETE FROM webhooks WHERE id = $1")
346 .bind(id)
347 .execute(pool)
348 .await?;
349 if res.rows_affected() == 0 {
350 anyhow::bail!("no webhook with id {id}");
351 }
352 println!("webhook {id} removed");
353 Ok(())
354}