Skip to main content

hermes_agent_cli_core/
webhooks.rs

1use anyhow::{Context, Result};
2use directories::ProjectDirs;
3use serde::{Deserialize, Serialize};
4use std::fs;
5use std::path::PathBuf;
6
7/// Webhook configuration stored in webhooks.json
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct Webhook {
10    pub name: String,
11    pub url: String,
12    pub events: Vec<String>,
13    pub enabled: bool,
14    #[serde(default)]
15    pub description: String,
16    #[serde(default)]
17    pub skills: Vec<String>,
18    #[serde(default = "default_deliver")]
19    pub deliver: String,
20    #[serde(default)]
21    pub deliver_chat_id: String,
22    #[serde(default)]
23    pub secret: String,
24    #[serde(default = "default_added_at")]
25    pub added_at: String,
26}
27
28fn default_deliver() -> String {
29    "log".to_string()
30}
31
32fn default_added_at() -> String {
33    chrono::Utc::now().to_rfc3339()
34}
35
36/// Storage for webhook configurations
37#[derive(Debug, Clone, Serialize, Deserialize, Default)]
38pub struct WebhookStore {
39    #[serde(default)]
40    pub webhooks: Vec<Webhook>,
41}
42
43impl WebhookStore {
44    /// Load webhook store from HERMES_HOME/webhooks.json
45    pub fn load() -> Result<Self> {
46        let path = Self::webhooks_path();
47        if !path.exists() {
48            return Ok(WebhookStore::default());
49        }
50        let content = fs::read_to_string(&path)
51            .with_context(|| format!("failed to read webhooks store from {:?}", path))?;
52        let store: WebhookStore = serde_json::from_str(&content)
53            .with_context(|| format!("failed to parse webhooks store from {:?}", path))?;
54        Ok(store)
55    }
56
57    /// Save webhook store to HERMES_HOME/webhooks.json
58    pub fn save(&self) -> Result<()> {
59        let path = Self::webhooks_path();
60        if let Some(parent) = path.parent() {
61            fs::create_dir_all(parent)
62                .with_context(|| format!("failed to create webhooks directory {:?}", parent))?;
63        }
64        let content =
65            serde_json::to_string_pretty(self).context("failed to serialize webhooks store")?;
66        fs::write(&path, content)
67            .with_context(|| format!("failed to write webhooks store to {:?}", path))?;
68        Ok(())
69    }
70
71    /// Get webhooks path
72    pub fn webhooks_path() -> PathBuf {
73        if let Ok(home) = std::env::var("HERMES_HOME") {
74            return PathBuf::from(home).join("webhooks.json");
75        }
76        if let Ok(profile) = std::env::var("HERMES_PROFILE") {
77            if let Some(proj_dirs) =
78                ProjectDirs::from("ai", "hermes", &format!("hermes-{}", profile))
79            {
80                return proj_dirs.config_dir().join("webhooks.json");
81            }
82        }
83        if let Some(proj_dirs) = ProjectDirs::from("ai", "hermes", "hermes-cli") {
84            return proj_dirs.config_dir().join("webhooks.json");
85        }
86        if let Ok(home) = std::env::var("USERPROFILE") {
87            return PathBuf::from(home).join(".hermes").join("webhooks.json");
88        }
89        PathBuf::from(".hermes").join("webhooks.json")
90    }
91
92    /// Add a webhook
93    pub fn add_webhook(&mut self, webhook: Webhook) -> Result<()> {
94        // Check if name already exists
95        if self.webhooks.iter().any(|w| w.name == webhook.name) {
96            anyhow::bail!(
97                "Webhook '{}' already exists. Use a different name or remove it first.",
98                webhook.name
99            );
100        }
101
102        self.webhooks.push(webhook);
103        Ok(())
104    }
105
106    /// Remove a webhook by name
107    pub fn remove_webhook(&mut self, name: &str) -> Result<()> {
108        let len = self.webhooks.len();
109        self.webhooks.retain(|w| w.name != name);
110        if self.webhooks.len() == len {
111            anyhow::bail!("Webhook '{}' not found", name);
112        }
113        Ok(())
114    }
115
116    /// Get a webhook by name
117    pub fn get_webhook(&self, name: &str) -> Option<&Webhook> {
118        self.webhooks.iter().find(|w| w.name == name)
119    }
120
121    /// List all webhooks
122    pub fn list_webhooks(&self) -> &[Webhook] {
123        &self.webhooks
124    }
125
126    /// Update webhook enabled status
127    pub fn set_enabled(&mut self, name: &str, enabled: bool) -> Result<()> {
128        let webhook = self.webhooks.iter_mut().find(|w| w.name == name);
129        match webhook {
130            Some(w) => {
131                w.enabled = enabled;
132                Ok(())
133            }
134            None => anyhow::bail!("Webhook '{}' not found", name),
135        }
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn test_webhook_store_default() {
145        let store = WebhookStore::default();
146        assert!(store.webhooks.is_empty());
147    }
148
149    #[test]
150    fn test_webhook_store_add() {
151        let mut store = WebhookStore::default();
152        let webhook = Webhook {
153            name: "test".to_string(),
154            url: "https://example.com/webhook".to_string(),
155            events: vec!["message".to_string()],
156            enabled: true,
157            description: "Test webhook".to_string(),
158            skills: vec![],
159            deliver: "log".to_string(),
160            deliver_chat_id: String::new(),
161            secret: String::new(),
162            added_at: "2026-01-01T00:00:00Z".to_string(),
163        };
164        store.add_webhook(webhook).unwrap();
165        assert_eq!(store.webhooks.len(), 1);
166        assert_eq!(store.webhooks[0].name, "test");
167    }
168
169    #[test]
170    fn test_webhook_store_add_duplicate() {
171        let mut store = WebhookStore::default();
172        let webhook = Webhook {
173            name: "test".to_string(),
174            url: "https://example.com/webhook".to_string(),
175            events: vec![],
176            enabled: true,
177            description: String::new(),
178            skills: vec![],
179            deliver: "log".to_string(),
180            deliver_chat_id: String::new(),
181            secret: String::new(),
182            added_at: String::new(),
183        };
184        store.add_webhook(webhook.clone()).unwrap();
185        let result = store.add_webhook(webhook);
186        assert!(result.is_err());
187    }
188
189    #[test]
190    fn test_webhook_store_remove() {
191        let mut store = WebhookStore::default();
192        let webhook = Webhook {
193            name: "test".to_string(),
194            url: "https://example.com/webhook".to_string(),
195            events: vec![],
196            enabled: true,
197            description: String::new(),
198            skills: vec![],
199            deliver: "log".to_string(),
200            deliver_chat_id: String::new(),
201            secret: String::new(),
202            added_at: String::new(),
203        };
204        store.add_webhook(webhook).unwrap();
205        store.remove_webhook("test").unwrap();
206        assert!(store.webhooks.is_empty());
207    }
208}