hermes_agent_cli_core/
webhooks.rs1use anyhow::{Context, Result};
2use directories::ProjectDirs;
3use serde::{Deserialize, Serialize};
4use std::fs;
5use std::path::PathBuf;
6
7#[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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
38pub struct WebhookStore {
39 #[serde(default)]
40 pub webhooks: Vec<Webhook>,
41}
42
43impl WebhookStore {
44 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 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 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 pub fn add_webhook(&mut self, webhook: Webhook) -> Result<()> {
94 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 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 pub fn get_webhook(&self, name: &str) -> Option<&Webhook> {
118 self.webhooks.iter().find(|w| w.name == name)
119 }
120
121 pub fn list_webhooks(&self) -> &[Webhook] {
123 &self.webhooks
124 }
125
126 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}