1use std::collections::HashMap;
11use std::time::{Duration, Instant};
12
13use anyhow::Context;
14use forge_primitives::{GotifyConfig, GotifyMessage, GotifySubscription};
15use futures::{SinkExt, StreamExt};
16use serde::Deserialize;
17use tokio::net::TcpStream;
18use tokio::sync::{mpsc, oneshot};
19use tokio_tungstenite::tungstenite::{Bytes, Message};
20use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async};
21
22pub trait GotifyHost: Send + Sync {
26 fn http_client(&self, timeout: Duration) -> Result<reqwest::Client, String>;
29
30 fn subscriptions(&self) -> Vec<GotifySubscription>;
32
33 fn app_name(&self, appid: u64) -> Option<String>;
37
38 fn store_app_index(&self, index: HashMap<String, u64>);
40
41 fn set_connected(&self, connected: bool);
43
44 fn deliver(&self, subscription: &GotifySubscription, app: &str, message: &GotifyMessage);
48}
49
50const BACKOFF_START: Duration = Duration::from_millis(500);
54const BACKOFF_CAP: Duration = Duration::from_secs(30);
55
56const MIN_HEALTHY_UPTIME: Duration = Duration::from_secs(5);
61
62const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
66
67const PING_INTERVAL: Duration = Duration::from_secs(22);
71
72const IDLE_DEADLINE: Duration = Duration::from_secs(80);
78
79const REST_TIMEOUT: Duration = Duration::from_secs(10);
83
84const RECENT_FETCH_WINDOW: u32 = 200;
88
89#[derive(Debug)]
92enum GotifyEvent {
93 Connected,
94 Disconnected,
95 Message(GotifyMessage),
96}
97
98#[derive(Debug, Deserialize)]
101struct GotifyApp {
102 id: u64,
103 name: String,
104}
105
106#[derive(Debug, Deserialize)]
110struct GotifyMessages {
111 messages: Vec<GotifyMessage>,
112}
113
114#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct GotifyRecent {
120 pub app: String,
121 pub title: String,
122 pub message: String,
123 pub priority: u8,
124 pub date: String,
125}
126
127fn rest_client(host: &dyn GotifyHost) -> anyhow::Result<reqwest::Client> {
130 host.http_client(REST_TIMEOUT)
131 .map_err(|error| anyhow::anyhow!("build Gotify http client: {error}"))
132}
133
134async fn fetch_apps(host: &dyn GotifyHost, cfg: &GotifyConfig) -> anyhow::Result<Vec<GotifyApp>> {
137 let url = format!("{}/application", cfg.url.trim_end_matches('/'));
138 rest_client(host)?
139 .get(url)
140 .header("X-Gotify-Key", &cfg.client_token)
141 .send()
142 .await
143 .context("GET /application")?
144 .error_for_status()
145 .context("/application returned an error status")?
146 .json()
147 .await
148 .context("parse /application body")
149}
150
151pub async fn app_index(
155 host: &dyn GotifyHost,
156 cfg: &GotifyConfig,
157) -> anyhow::Result<HashMap<String, u64>> {
158 Ok(build_app_index(fetch_apps(host, cfg).await?))
159}
160
161fn build_app_index(apps: Vec<GotifyApp>) -> HashMap<String, u64> {
162 apps.into_iter().map(|app| (app.name, app.id)).collect()
163}
164
165pub async fn app_names(host: &dyn GotifyHost, cfg: &GotifyConfig) -> anyhow::Result<Vec<String>> {
169 Ok(build_app_names(fetch_apps(host, cfg).await?))
170}
171
172fn build_app_names(apps: Vec<GotifyApp>) -> Vec<String> {
173 apps.into_iter().map(|app| app.name).collect()
174}
175
176fn build_id_index(apps: Vec<GotifyApp>) -> HashMap<u64, String> {
177 apps.into_iter().map(|app| (app.id, app.name)).collect()
178}
179
180pub async fn recent_messages(
185 host: &dyn GotifyHost,
186 cfg: &GotifyConfig,
187 applications: &[String],
188 min_priority: Option<u8>,
189 limit: usize,
190) -> anyhow::Result<Vec<GotifyRecent>> {
191 let messages = fetch_messages(host, cfg, RECENT_FETCH_WINDOW).await?;
192 let id_index = build_id_index(fetch_apps(host, cfg).await?);
193 Ok(filter_recent(messages, &id_index, applications, min_priority, limit))
194}
195
196async fn fetch_messages(
197 host: &dyn GotifyHost,
198 cfg: &GotifyConfig,
199 limit: u32,
200) -> anyhow::Result<Vec<GotifyMessage>> {
201 let url = format!("{}/message?limit={limit}", cfg.url.trim_end_matches('/'));
202 let page: GotifyMessages = rest_client(host)?
203 .get(url)
204 .header("X-Gotify-Key", &cfg.client_token)
205 .send()
206 .await
207 .context("GET /message")?
208 .error_for_status()
209 .context("/message returned an error status")?
210 .json()
211 .await
212 .context("parse /message body")?;
213 Ok(page.messages)
214}
215
216fn filter_recent(
222 mut messages: Vec<GotifyMessage>,
223 id_index: &HashMap<u64, String>,
224 applications: &[String],
225 min_priority: Option<u8>,
226 limit: usize,
227) -> Vec<GotifyRecent> {
228 messages.sort_unstable_by_key(|m| std::cmp::Reverse(m.id));
229 messages
230 .into_iter()
231 .filter_map(|msg| {
232 let resolved = id_index.get(&msg.appid);
233 let priority_ok = min_priority.is_none_or(|floor| msg.priority >= floor);
234 let app_ok =
235 applications.is_empty() || resolved.is_some_and(|name| applications.contains(name));
236 if !(priority_ok && app_ok) {
237 return None;
238 }
239 Some(GotifyRecent {
240 app: resolved.cloned().unwrap_or_else(|| msg.appid.to_string()),
241 title: msg.title,
242 message: msg.message,
243 priority: msg.priority,
244 date: msg.date,
245 })
246 })
247 .take(limit)
248 .collect()
249}
250
251fn normalize(text: &str) -> anyhow::Result<GotifyMessage> {
253 serde_json::from_str(text).context("parse Gotify stream message")
254}
255
256pub fn matching_subscriptions<'a>(
262 subs: &'a [GotifySubscription],
263 resolved_app: Option<&str>,
264 priority: u8,
265) -> Vec<&'a GotifySubscription> {
266 subs.iter()
267 .filter(|sub| {
268 let priority_ok = sub.min_priority.is_none_or(|floor| priority >= floor);
269 let app_ok = sub.applications.is_empty()
270 || resolved_app.is_some_and(|name| sub.applications.iter().any(|app| app == name));
271 priority_ok && app_ok
272 })
273 .collect()
274}
275
276struct GotifyStream {
278 ws: WebSocketStream<MaybeTlsStream<TcpStream>>,
279}
280
281enum PumpOutcome {
283 Stop,
285 Reconnect,
288}
289
290impl GotifyStream {
291 async fn connect(cfg: &GotifyConfig) -> anyhow::Result<Self> {
294 let url = stream_url(cfg)?;
295 let (ws, _resp) = connect_async(url.as_str()).await.context("connect Gotify stream")?;
296 Ok(Self { ws })
297 }
298
299 async fn pump(
306 self,
307 tx: &mpsc::Sender<GotifyEvent>,
308 shutdown: &mut oneshot::Receiver<()>,
309 ) -> PumpOutcome {
310 let (mut sink, mut frames) = self.ws.split();
311 let mut ping = tokio::time::interval(PING_INTERVAL);
312 ping.tick().await;
314 let mut last_activity = Instant::now();
315 loop {
316 tokio::select! {
317 _ = &mut *shutdown => return PumpOutcome::Stop,
318 _ = ping.tick() => {
319 if sink.send(Message::Ping(Bytes::new())).await.is_err() {
320 return PumpOutcome::Reconnect;
321 }
322 if idle_past_deadline(last_activity, Instant::now()) {
323 tracing::warn!(
324 target: "forge_connectors::gotify",
325 idle_secs = IDLE_DEADLINE.as_secs(),
326 "Gotify stream idle past deadline; treating path as dead",
327 );
328 return PumpOutcome::Reconnect;
329 }
330 }
331 frame = frames.next() => match frame {
332 Some(Ok(Message::Text(text))) => {
333 last_activity = Instant::now();
334 match normalize(text.as_str()) {
335 Ok(msg) => {
336 if tx.send(GotifyEvent::Message(msg)).await.is_err() {
337 return PumpOutcome::Stop;
338 }
339 }
340 Err(error) => {
341 tracing::warn!(target: "forge_connectors::gotify", %error, "skipping unparseable stream frame");
342 }
343 }
344 }
345 Some(Ok(_)) => last_activity = Instant::now(),
346 Some(Err(error)) => {
347 tracing::warn!(target: "forge_connectors::gotify", %error, "Gotify stream read error");
348 return PumpOutcome::Reconnect;
349 }
350 None => return PumpOutcome::Reconnect,
351 }
352 }
353 }
354 }
355}
356
357fn stream_url(cfg: &GotifyConfig) -> anyhow::Result<String> {
358 let base = cfg.url.trim_end_matches('/');
359 let ws_base = if let Some(rest) = base.strip_prefix("https://") {
360 format!("wss://{rest}")
361 } else if let Some(rest) = base.strip_prefix("http://") {
362 format!("ws://{rest}")
363 } else {
364 anyhow::bail!("Gotify url must start with http:// or https://: {}", cfg.url);
365 };
366 Ok(format!("{ws_base}/stream?token={}", cfg.client_token))
367}
368
369fn next_backoff(current: Duration, healthy: bool) -> Duration {
373 if healthy { BACKOFF_START } else { (current * 2).min(BACKOFF_CAP) }
374}
375
376fn idle_past_deadline(last_activity: Instant, now: Instant) -> bool {
380 now.saturating_duration_since(last_activity) > IDLE_DEADLINE
381}
382
383async fn run(
390 cfg: GotifyConfig,
391 tx: mpsc::Sender<GotifyEvent>,
392 mut shutdown: oneshot::Receiver<()>,
393) {
394 let mut backoff = BACKOFF_START;
395 loop {
396 let dial = tokio::select! {
399 _ = &mut shutdown => return,
400 result = tokio::time::timeout(CONNECT_TIMEOUT, GotifyStream::connect(&cfg)) => result,
401 };
402 let healthy = match dial {
403 Ok(Ok(stream)) => {
404 let connected_at = tokio::time::Instant::now();
405 if tx.send(GotifyEvent::Connected).await.is_err() {
406 return;
407 }
408 if let PumpOutcome::Stop = stream.pump(&tx, &mut shutdown).await {
409 return;
410 }
411 let _ = tx.send(GotifyEvent::Disconnected).await;
412 connected_at.elapsed() >= MIN_HEALTHY_UPTIME
413 }
414 Ok(Err(error)) => {
415 tracing::warn!(target: "forge_connectors::gotify", %error, "Gotify connect failed; backing off");
416 let _ = tx.send(GotifyEvent::Disconnected).await;
417 false
418 }
419 Err(_) => {
420 tracing::warn!(
421 target: "forge_connectors::gotify",
422 timeout_secs = CONNECT_TIMEOUT.as_secs(),
423 "Gotify connect timed out; backing off",
424 );
425 let _ = tx.send(GotifyEvent::Disconnected).await;
426 false
427 }
428 };
429 backoff = next_backoff(backoff, healthy);
430 tokio::select! {
431 _ = &mut shutdown => return,
432 () = tokio::time::sleep(backoff) => {}
433 }
434 }
435}
436
437pub async fn run_subsystem(
444 host: std::sync::Arc<dyn GotifyHost>,
445 cfg: GotifyConfig,
446 mut shutdown: oneshot::Receiver<()>,
447) {
448 let (tx, mut rx) = mpsc::channel::<GotifyEvent>(64);
449 let (_run_shutdown_tx, run_shutdown_rx) = oneshot::channel();
451 tokio::spawn(run(cfg.clone(), tx, run_shutdown_rx));
452
453 loop {
454 tokio::select! {
455 _ = &mut shutdown => break,
456 event = rx.recv() => {
457 let Some(event) = event else { break };
458 match event {
459 GotifyEvent::Connected => {
460 let index_host = host.clone();
464 let index_cfg = cfg.clone();
465 tokio::spawn(async move {
466 refresh_app_index(&*index_host, &index_cfg).await;
467 });
468 host.set_connected(true);
469 }
470 GotifyEvent::Disconnected => {
471 host.set_connected(false);
472 }
473 GotifyEvent::Message(msg) => {
474 route(&*host, &msg);
475 }
476 }
477 }
478 }
479 }
480
481 host.set_connected(false);
482}
483
484pub async fn refresh_app_index(host: &dyn GotifyHost, cfg: &GotifyConfig) -> bool {
491 match app_index(host, cfg).await {
492 Ok(index) => {
493 host.store_app_index(index);
494 true
495 }
496 Err(error) => {
497 tracing::warn!(
498 target: "forge_connectors::gotify",
499 %error,
500 "Gotify /application lookup failed; application-name filters will not match until the next reconnect",
501 );
502 false
503 }
504 }
505}
506
507fn route(host: &dyn GotifyHost, msg: &GotifyMessage) {
510 let subs = host.subscriptions();
511 let resolved_name = host.app_name(msg.appid);
512 let display_name = resolved_name.clone().unwrap_or_else(|| msg.appid.to_string());
516 for sub in matching_subscriptions(&subs, resolved_name.as_deref(), msg.priority) {
517 host.deliver(sub, &display_name, msg);
518 }
519}
520
521#[cfg(test)]
522mod tests {
523 use super::*;
524
525 #[test]
526 fn normalize_parses_sample_message() {
527 let line = r#"{"id":1,"appid":3,"title":"t","message":"m","priority":5,"date":"2026-07-03T09:18:00Z"}"#;
528 let msg = normalize(line).expect("parse sample message");
529 assert_eq!(msg.appid, 3);
530 assert_eq!(msg.priority, 5);
531 }
532
533 #[test]
534 fn next_backoff_resets_on_healthy_and_escalates_otherwise() {
535 assert_eq!(next_backoff(BACKOFF_CAP, true), BACKOFF_START);
537 assert_eq!(next_backoff(Duration::from_secs(8), true), BACKOFF_START);
538 assert_eq!(next_backoff(BACKOFF_START, false), BACKOFF_START * 2);
540 assert_eq!(next_backoff(Duration::from_secs(20), false), BACKOFF_CAP);
541 assert_eq!(next_backoff(BACKOFF_CAP, false), BACKOFF_CAP);
542 }
543
544 #[test]
545 fn idle_past_deadline_trips_only_after_the_deadline() {
546 let base = Instant::now();
547 assert!(!idle_past_deadline(base, base + Duration::from_secs(1)));
549 assert!(!idle_past_deadline(base, base + IDLE_DEADLINE));
550 assert!(idle_past_deadline(base, base + IDLE_DEADLINE + Duration::from_secs(1)));
552 }
553
554 #[test]
555 fn fresh_activity_resets_the_idle_clock() {
556 let base = Instant::now();
557 let refreshed = base + Duration::from_secs(10_000);
560 assert!(!idle_past_deadline(refreshed, refreshed + Duration::from_secs(1)));
561 }
562
563 #[test]
564 fn app_name_to_id_maps() {
565 let body = r#"[{"id":3,"name":"web-api","token":"A.abc","description":""}]"#;
566 let apps: Vec<GotifyApp> = serde_json::from_str(body).expect("parse application list");
567 let index = build_app_index(apps);
568 assert_eq!(index.get("web-api"), Some(&3));
569 }
570
571 #[test]
572 fn app_names_extracts_names_in_server_order() {
573 let apps = vec![
574 GotifyApp { id: 3, name: "web-api".to_owned() },
575 GotifyApp { id: 1, name: "Backups".to_owned() },
576 ];
577 assert_eq!(build_app_names(apps), vec!["web-api".to_owned(), "Backups".to_owned()]);
578 }
579
580 #[test]
581 fn message_page_parses_ignoring_extras_and_paging() {
582 let body = r#"{"messages":[{"id":25,"appid":1,"title":"t","message":"m","priority":4,"date":"2026-07-04T09:18:00Z","extras":{"x":1}}],"paging":{"size":1,"limit":100,"since":25}}"#;
583 let page: GotifyMessages = serde_json::from_str(body).expect("parse /message page");
584 assert_eq!(page.messages.len(), 1);
585 assert_eq!(page.messages[0].appid, 1);
586 }
587
588 fn msg(id: u64, appid: u64, priority: u8) -> GotifyMessage {
589 GotifyMessage {
590 id,
591 appid,
592 title: format!("t{id}"),
593 message: format!("m{id}"),
594 priority,
595 date: format!("2026-07-04T{id:02}:00:00Z"),
596 }
597 }
598
599 #[test]
600 fn filter_recent_orders_newest_first_and_truncates_to_limit() {
601 let index = HashMap::from([(1u64, "CI".to_owned())]);
602 let out =
604 filter_recent(vec![msg(1, 1, 5), msg(3, 1, 5), msg(2, 1, 5)], &index, &[], None, 2);
605 assert_eq!(out.len(), 2, "truncated to the limit");
606 assert_eq!(out[0].title, "t3", "highest id (newest) first");
607 assert_eq!(out[1].title, "t2");
608 }
609
610 #[test]
611 fn filter_recent_applies_app_name_and_priority_filters() {
612 let index = HashMap::from([(1u64, "CI".to_owned()), (2u64, "Backups".to_owned())]);
613 let msgs = vec![msg(10, 1, 8), msg(9, 2, 8), msg(8, 1, 2)];
614 let out = filter_recent(msgs, &index, &["CI".to_owned()], Some(5), 20);
615 assert_eq!(out.len(), 1, "only CI at priority >= 5 survives");
616 assert_eq!(out[0].app, "CI");
617 assert_eq!(out[0].priority, 8);
618 }
619
620 #[test]
621 fn filter_recent_unresolved_appid_shows_id_but_never_matches_a_named_filter() {
622 let index = HashMap::from([(1u64, "CI".to_owned())]);
626 let unfiltered = filter_recent(vec![msg(5, 7, 9)], &index, &[], None, 20);
627 assert_eq!(unfiltered[0].app, "7", "unresolved appid displays as its id string");
628 let named = filter_recent(vec![msg(5, 7, 9)], &index, &["7".to_owned()], None, 20);
629 assert!(named.is_empty(), "a numeric-string filter can't match an unresolved appid");
630 }
631
632 fn sub(applications: &[&str], min_priority: Option<u8>) -> GotifySubscription {
633 GotifySubscription {
634 id: uuid::Uuid::new_v4(),
635 project: "p".to_owned(),
636 team_role: None,
637 applications: applications.iter().map(|s| (*s).to_owned()).collect(),
638 min_priority,
639 created_at: std::time::SystemTime::UNIX_EPOCH,
640 }
641 }
642
643 #[test]
646 fn matching_subscriptions_applies_priority_and_application_filters() {
647 let subs = [sub(&["alerts"], None), sub(&[], Some(5)), sub(&["alerts", "backups"], None)];
648 let matches = |resolved: Option<&str>, priority| {
649 matching_subscriptions(&subs, resolved, priority)
650 .iter()
651 .map(|s| s.id)
652 .collect::<Vec<_>>()
653 };
654
655 assert_eq!(matches(Some("alerts"), 2), [subs[0].id, subs[2].id]);
658 assert_eq!(matches(Some("backups"), 1), [subs[2].id]);
661 assert_eq!(matches(Some("alerts"), 5), [subs[0].id, subs[1].id, subs[2].id]);
663 assert_eq!(matches(Some("ci"), 9), [subs[1].id]);
665 }
666
667 #[test]
671 fn matching_subscriptions_unresolved_appid_never_matches_a_numeric_filter() {
672 let subs = [sub(&["5"], None), sub(&[], None)];
673 let matches = matching_subscriptions(&subs, None, 1);
674 assert_eq!(matches.iter().map(|s| s.id).collect::<Vec<_>>(), [subs[1].id]);
675 }
676
677 #[test]
680 fn matching_subscriptions_returns_every_matching_subscription() {
681 let subs = [sub(&[], None), sub(&[], None), sub(&[], Some(9))];
682 assert_eq!(matching_subscriptions(&subs, None, 5).len(), 2);
683 }
684
685 type Delivery = (String, Option<String>, String, u8);
687
688 struct MockHost {
689 subs: Vec<GotifySubscription>,
690 index: HashMap<u64, String>,
691 delivered: std::sync::Mutex<Vec<Delivery>>,
692 }
693
694 impl GotifyHost for MockHost {
695 fn http_client(&self, _timeout: Duration) -> Result<reqwest::Client, String> {
696 Err("unused by route".to_owned())
697 }
698
699 fn subscriptions(&self) -> Vec<GotifySubscription> {
700 self.subs.clone()
701 }
702
703 fn app_name(&self, appid: u64) -> Option<String> {
704 self.index.get(&appid).cloned()
705 }
706
707 fn store_app_index(&self, _index: HashMap<String, u64>) {}
708
709 fn set_connected(&self, _connected: bool) {}
710
711 fn deliver(&self, subscription: &GotifySubscription, app: &str, message: &GotifyMessage) {
712 self.delivered.lock().expect("mock lock").push((
713 subscription.project.clone(),
714 subscription.team_role.clone(),
715 app.to_owned(),
716 message.priority,
717 ));
718 }
719 }
720
721 #[test]
726 fn route_matches_on_resolved_names_and_falls_back_to_the_id_for_display() {
727 let numeric_filter = sub(&["5"], None);
728 let match_any = sub(&[], None);
729 let host = MockHost {
730 subs: vec![numeric_filter, match_any],
731 index: HashMap::new(),
732 delivered: std::sync::Mutex::new(Vec::new()),
733 };
734
735 route(&host, &msg(1, 5, 1));
736
737 let delivered = host.delivered.lock().expect("mock lock").clone();
738 assert_eq!(
739 delivered.len(),
740 1,
741 "an unresolved appid matches only the empty filter set: {delivered:?}",
742 );
743 assert_eq!(delivered[0].2, "5", "the delivered app falls back to the id string");
744 }
745
746 #[test]
749 fn route_fans_out_to_every_matching_subscriber() {
750 let lead = sub(&["alerts"], None);
751 let mut worker = sub(&[], Some(5));
752 worker.team_role = Some("reviewer".to_owned());
753 let host = MockHost {
754 subs: vec![lead, worker],
755 index: HashMap::from([(3u64, "alerts".to_owned())]),
756 delivered: std::sync::Mutex::new(Vec::new()),
757 };
758
759 route(&host, &msg(1, 3, 2));
761 assert_eq!(host.delivered.lock().expect("mock lock").len(), 1);
762
763 route(&host, &msg(2, 3, 5));
765 let delivered = host.delivered.lock().expect("mock lock").clone();
766 assert_eq!(delivered.len(), 3, "one more delivery on the second message");
767 assert_eq!(delivered[1].0, "p");
768 assert_eq!(delivered[2].1.as_deref(), Some("reviewer"));
769 assert_eq!(
770 delivered[1].2, "alerts",
771 "a resolved appid delivers its NAME, not the id string",
772 );
773 }
774}