Skip to main content

forge_primitives/
gotify.rs

1//! Gotify inbound-integration wire types.
2//!
3//! A forge session subscribes to a Gotify server via the
4//! `mcp__forge__gotify__*` tool family; matching notifications deliver
5//! as a user-turn into the durable subscriber. These are pure data
6//! shapes - the stream, REST lookups and match-and-route logic live in
7//! `forge-connectors::gotify`, which reaches workspace state through
8//! its host port, and the subscription store in
9//! `forge-workspace::store::gotify`.
10
11use serde::{Deserialize, Serialize};
12
13/// Connection to a Gotify server, parsed from the `[gotify]` block of
14/// forge.toml. One server per v1. Unknown keys are rejected so a
15/// mistyped `client_token` fails the load instead of leaving the
16/// connector tokenless.
17#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(deny_unknown_fields)]
19pub struct GotifyConfig {
20    pub url: String,
21    /// Client token for the receive stream (`/stream?token=`) and the
22    /// `/application` lookup (`X-Gotify-Key`).
23    pub client_token: String,
24}
25
26/// Hand-written because the client token must never be printed, the
27/// same reason `SlackConfig` writes its own.
28impl std::fmt::Debug for GotifyConfig {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        f.debug_struct("GotifyConfig")
31            .field("url", &self.url)
32            .field("client_token", &"[redacted]")
33            .finish()
34    }
35}
36
37/// A notification decoded from the Gotify server's WebSocket stream.
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct GotifyMessage {
40    pub id: u64,
41    /// Gotify's stream sends the source application as lowercase `appid`.
42    pub appid: u64,
43    pub title: String,
44    pub message: String,
45    pub priority: u8,
46    /// RFC3339 timestamp from the server, kept verbatim for v1.
47    pub date: String,
48}
49
50/// An active subscription: which project (and optional team-worker
51/// role) receives notifications matching the `applications` +
52/// `min_priority` filter. Durable ones persist to redb; ephemeral
53/// ad-hoc-worker ones stay in memory only.
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(from = "GotifySubscriptionRepr")]
56pub struct GotifySubscription {
57    pub id: uuid::Uuid,
58    pub project: String,
59    /// Worker label owning this subscription; `None` targets the
60    /// project lead. Named `team_role` for the stored shape - a rename
61    /// would decode cleanly to `None` and silently reroute every
62    /// worker's subscription to the lead.
63    pub team_role: Option<String>,
64    /// Gotify application NAME filters; empty matches any application,
65    /// otherwise the message's app must be one of the listed names.
66    pub applications: Vec<String>,
67    /// Priority floor; `None` matches any priority.
68    pub min_priority: Option<u8>,
69    pub created_at: std::time::SystemTime,
70}
71
72/// Deserialization shape, tolerant of the v0.20.0 record that stored a
73/// single `application: Option<String>` and no `applications` key. A
74/// non-null legacy `application` folds forward into `applications`; a
75/// null or absent one leaves the empty match-any set. Serialization
76/// always emits the current `applications` shape (this is deser-only).
77#[derive(Deserialize)]
78struct GotifySubscriptionRepr {
79    id: uuid::Uuid,
80    project: String,
81    team_role: Option<String>,
82    #[serde(default)]
83    applications: Vec<String>,
84    #[serde(default)]
85    application: Option<String>,
86    min_priority: Option<u8>,
87    created_at: std::time::SystemTime,
88}
89
90impl From<GotifySubscriptionRepr> for GotifySubscription {
91    fn from(repr: GotifySubscriptionRepr) -> Self {
92        let mut applications = repr.applications;
93        if applications.is_empty() {
94            applications.extend(repr.application);
95        }
96        Self {
97            id: repr.id,
98            project: repr.project,
99            team_role: repr.team_role,
100            applications,
101            min_priority: repr.min_priority,
102            created_at: repr.created_at,
103        }
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use std::time::{Duration, SystemTime};
111
112    #[test]
113    fn gotify_types_round_trip() {
114        let sub = GotifySubscription {
115            id: uuid::Uuid::from_u128(0x1234_5678_9abc_def0_1234_5678_9abc_def0),
116            project: "web-api".to_owned(),
117            team_role: Some("analyst".to_owned()),
118            applications: vec!["alerts".to_owned()],
119            min_priority: Some(5),
120            created_at: SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000),
121        };
122        let json = serde_json::to_string(&sub).expect("serialize");
123        let back: GotifySubscription = serde_json::from_str(&json).expect("deserialize");
124        assert_eq!(sub, back);
125
126        let line = r#"{"id":1,"appid":3,"title":"t","message":"m","priority":5,"date":"2026-07-03T09:18:00Z"}"#;
127        let msg: GotifyMessage = serde_json::from_str(line).expect("deserialize stream line");
128        assert_eq!(msg.appid, 3);
129        assert_eq!(msg.priority, 5);
130    }
131
132    #[test]
133    fn legacy_v0_20_0_record_migrates_forward() {
134        // v0.20.0 persisted `application: Option<String>` with no
135        // `applications` key. Build that shape from a current record so
136        // the SystemTime encoding stays whatever serde emits.
137        let base = GotifySubscription {
138            id: uuid::Uuid::from_u128(1),
139            project: "p".to_owned(),
140            team_role: None,
141            applications: vec![],
142            min_priority: Some(5),
143            created_at: SystemTime::UNIX_EPOCH,
144        };
145        let mut legacy = serde_json::to_value(&base).expect("serialize");
146        legacy.as_object_mut().expect("object").remove("applications");
147
148        legacy.as_object_mut().expect("object").insert("application".to_owned(), "alerts".into());
149        let named: GotifySubscription =
150            serde_json::from_value(legacy.clone()).expect("legacy named record");
151        assert_eq!(
152            named.applications,
153            vec!["alerts".to_owned()],
154            "a real old single-app filter folds forward",
155        );
156
157        legacy
158            .as_object_mut()
159            .expect("object")
160            .insert("application".to_owned(), serde_json::Value::Null);
161        let any: GotifySubscription =
162            serde_json::from_value(legacy).expect("legacy null-application record");
163        assert!(any.applications.is_empty(), "a null application becomes the match-any set");
164    }
165}