Skip to main content

greentic_setup/webhook/
mod.rs

1//! Webhook registration for messaging providers during setup.
2//!
3//! Calls provider-specific APIs (e.g. Telegram `setWebhook`, Slack manifest
4//! update, Webex webhook management) to register the operator's ingress
5//! endpoint so that external services can deliver messages to the running
6//! instance.
7//!
8//! Ported from `greentic-operator/src/onboard/webhook_setup.rs` so that
9//! `gtc setup` can handle webhook registration without the operator.
10
11mod instructions;
12mod slack;
13mod telegram;
14mod webex;
15
16use serde_json::{Value, json};
17
18// Re-export public functions from submodules
19pub use instructions::{
20    ProviderInstruction, collect_post_setup_instructions, print_post_setup_instructions,
21};
22pub use slack::update_manifest_urls as slack_update_manifest_urls;
23
24/// Extract registration result from declared ops in config.
25///
26/// If the config contains `webhook_ops`, return a result indicating
27/// declared ops mode instead of performing live registration.
28pub fn registration_result_from_declared_ops(config: &Value) -> Option<Value> {
29    let webhook_ops = config.get("webhook_ops")?.as_array()?;
30    if webhook_ops.is_empty() {
31        return None;
32    }
33    let subscription_ops = config
34        .get("subscription_ops")
35        .and_then(Value::as_array)
36        .cloned()
37        .unwrap_or_default();
38    let oauth_ops = config
39        .get("oauth_ops")
40        .and_then(Value::as_array)
41        .cloned()
42        .unwrap_or_default();
43
44    Some(json!({
45        "ok": true,
46        "mode": "declared_ops",
47        "webhook_ops": webhook_ops,
48        "subscription_ops": subscription_ops,
49        "oauth_ops": oauth_ops,
50    }))
51}
52
53/// Check whether a provider's answers contain a valid `public_base_url`
54/// suitable for webhook registration.
55pub fn has_webhook_url(answers: &Value) -> Option<&str> {
56    answers
57        .as_object()?
58        .get("public_base_url")?
59        .as_str()
60        .filter(|url| !url.is_empty() && url.starts_with("https://"))
61}
62
63/// Register a webhook for a provider based on its setup answers.
64///
65/// Supports: Telegram, Slack, Webex.
66/// Returns `Some(result)` with status JSON, or `None` if the provider
67/// doesn't need webhook registration.
68pub fn register_webhook(
69    provider_id: &str,
70    config: &Value,
71    tenant: &str,
72    team: Option<&str>,
73) -> Option<Value> {
74    if let Some(result) = registration_result_from_declared_ops(config) {
75        return Some(result);
76    }
77
78    let public_base_url = config.get("public_base_url").and_then(Value::as_str)?;
79    if public_base_url.is_empty() || !public_base_url.starts_with("https://") {
80        return None;
81    }
82
83    let team = team.unwrap_or("default");
84
85    let provider_short = provider_id
86        .strip_prefix("messaging-")
87        .unwrap_or(provider_id);
88
89    match provider_short {
90        "telegram" => {
91            telegram::setup_telegram_webhook(config, public_base_url, provider_id, tenant, team)
92        }
93        "slack" => slack::setup_slack_manifest(config, public_base_url, provider_id, tenant, team),
94        "webex" => webex::setup_webex_webhook(config, public_base_url, provider_id, tenant, team),
95        _ => None,
96    }
97}
98
99/// Build the webhook URL for a provider.
100pub(crate) fn build_webhook_url(
101    public_base_url: &str,
102    provider_id: &str,
103    tenant: &str,
104    team: &str,
105) -> String {
106    format!(
107        "{}/v1/messaging/ingress/{}/{}/{}",
108        public_base_url.trim_end_matches('/'),
109        provider_id,
110        tenant,
111        team,
112    )
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn has_webhook_url_valid() {
121        let config = json!({"public_base_url": "https://example.com"});
122        assert_eq!(has_webhook_url(&config), Some("https://example.com"));
123    }
124
125    #[test]
126    fn registration_result_from_declared_ops_uses_declared_ops() {
127        let config = json!({
128            "webhook_ops": [{"op": "register", "url": "https://example.com/webhook"}],
129            "subscription_ops": [{"op": "sync"}],
130            "oauth_ops": []
131        });
132        let result =
133            registration_result_from_declared_ops(&config).expect("declared ops registration");
134        assert_eq!(result["ok"], Value::Bool(true));
135        assert_eq!(result["mode"], Value::String("declared_ops".to_string()));
136        assert_eq!(
137            result["webhook_ops"][0]["op"],
138            Value::String("register".to_string())
139        );
140    }
141
142    #[test]
143    fn register_webhook_prefers_declared_ops() {
144        let config = json!({
145            "public_base_url": "http://example.com",
146            "webhook_ops": [{"op": "register", "url": "https://example.com/webhook"}]
147        });
148        let result = register_webhook("messaging-unknown", &config, "demo", None)
149            .expect("declared ops fallback");
150        assert_eq!(result["mode"], Value::String("declared_ops".to_string()));
151        assert_eq!(
152            result["webhook_ops"][0]["url"],
153            "https://example.com/webhook"
154        );
155    }
156
157    #[test]
158    fn has_webhook_url_http_rejected() {
159        let config = json!({"public_base_url": "http://example.com"});
160        assert_eq!(has_webhook_url(&config), None);
161    }
162
163    #[test]
164    fn has_webhook_url_empty_rejected() {
165        let config = json!({"public_base_url": ""});
166        assert_eq!(has_webhook_url(&config), None);
167    }
168
169    #[test]
170    fn register_webhook_skips_unknown_provider() {
171        let config = json!({"public_base_url": "https://example.com", "bot_token": "x"});
172        assert!(register_webhook("messaging-unknown", &config, "demo", None).is_none());
173    }
174
175    #[test]
176    fn register_webhook_skips_without_public_url() {
177        let config = json!({"bot_token": "x"});
178        assert!(register_webhook("messaging-telegram", &config, "demo", None).is_none());
179    }
180
181    #[test]
182    fn register_webhook_skips_http_url() {
183        let config = json!({"public_base_url": "http://example.com", "bot_token": "x"});
184        assert!(register_webhook("messaging-telegram", &config, "demo", None).is_none());
185    }
186
187    #[test]
188    fn build_webhook_url_format() {
189        let url = build_webhook_url(
190            "https://example.com",
191            "messaging-telegram",
192            "demo",
193            "default",
194        );
195        assert_eq!(
196            url,
197            "https://example.com/v1/messaging/ingress/messaging-telegram/demo/default"
198        );
199    }
200
201    #[test]
202    fn build_webhook_url_trims_trailing_slash() {
203        let url = build_webhook_url(
204            "https://example.com/",
205            "messaging-telegram",
206            "demo",
207            "default",
208        );
209        assert_eq!(
210            url,
211            "https://example.com/v1/messaging/ingress/messaging-telegram/demo/default"
212        );
213    }
214
215    #[test]
216    fn slack_skips_without_credentials() {
217        let config = json!({"public_base_url": "https://example.com"});
218        assert!(register_webhook("messaging-slack", &config, "demo", None).is_none());
219    }
220
221    #[test]
222    fn webex_skips_without_token() {
223        let config = json!({"public_base_url": "https://example.com"});
224        assert!(register_webhook("messaging-webex", &config, "demo", None).is_none());
225    }
226
227    #[test]
228    fn slack_update_manifest_urls_creates_settings() {
229        let mut manifest = json!({});
230        slack_update_manifest_urls(&mut manifest, "https://example.com/webhook");
231        let settings = manifest.get("settings").unwrap();
232        assert_eq!(
233            settings["event_subscriptions"]["request_url"],
234            "https://example.com/webhook"
235        );
236        assert_eq!(
237            settings["interactivity"]["request_url"],
238            "https://example.com/webhook"
239        );
240        assert_eq!(settings["interactivity"]["is_enabled"], true);
241    }
242
243    #[test]
244    fn slack_update_manifest_urls_updates_existing() {
245        let mut manifest = json!({
246            "settings": {
247                "event_subscriptions": { "request_url": "https://old.com" },
248                "interactivity": { "request_url": "https://old.com", "is_enabled": false }
249            }
250        });
251        slack_update_manifest_urls(&mut manifest, "https://new.com/webhook");
252        let settings = manifest.get("settings").unwrap();
253        assert_eq!(
254            settings["event_subscriptions"]["request_url"],
255            "https://new.com/webhook"
256        );
257        assert_eq!(
258            settings["interactivity"]["request_url"],
259            "https://new.com/webhook"
260        );
261        assert_eq!(settings["interactivity"]["is_enabled"], true);
262    }
263}