1use std::path::Path;
25
26use serde::{Deserialize, Serialize};
27use serde_json::Value;
28
29use crate::catalog::HarnessHomes;
30use crate::HarnessId;
31
32pub const TRIGGERS_SCHEMA: &str = "supercode.triggers.v1";
34
35pub const TRIGGER_HARNESSES: &[&str] = &[
37 HarnessId::HERMES,
38 HarnessId::OPENCLAW,
39 HarnessId::ORCHESTRATOR,
40];
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "snake_case")]
45pub enum TriggerKind {
46 Webhook,
48 HookMapping,
50 BuiltinWake,
52 BuiltinAgent,
54}
55
56#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
58pub struct TriggerTarget {
59 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub action: Option<String>,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub profile: Option<String>,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub session_key: Option<String>,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub wake_mode: Option<String>,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub model: Option<String>,
70}
71
72#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
74pub struct TriggerDeliver {
75 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub target: Option<String>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub chat_id: Option<String>,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub struct TriggerRow {
84 pub name: String,
85 pub harness: String,
86 pub kind: TriggerKind,
87 pub route: String,
89 #[serde(default, skip_serializing_if = "Vec::is_empty")]
92 pub events: Vec<String>,
93 pub target: TriggerTarget,
94 pub deliver: TriggerDeliver,
95 pub enabled: bool,
96 pub authenticated: bool,
98 pub source: String,
100 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub description: Option<String>,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq)]
106pub enum TriggerError {
107 UnsupportedHarness { harness: String },
109}
110
111impl std::fmt::Display for TriggerError {
112 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113 match self {
114 TriggerError::UnsupportedHarness { harness } => write!(
115 f,
116 "`{harness}` has no inbound-trigger store supercode reads; `triggers.list` is supported for: {}",
117 TRIGGER_HARNESSES.join(", ")
118 ),
119 }
120 }
121}
122
123impl std::error::Error for TriggerError {}
124
125pub fn list_triggers(
127 homes: &HarnessHomes,
128 harness: Option<&str>,
129) -> Result<Vec<TriggerRow>, TriggerError> {
130 let harnesses: Vec<&str> = match harness {
131 Some(id) if TRIGGER_HARNESSES.contains(&id) => vec![id],
132 Some(id) => {
133 return Err(TriggerError::UnsupportedHarness {
134 harness: id.to_string(),
135 })
136 }
137 None => TRIGGER_HARNESSES.to_vec(),
138 };
139 use supercode_interchange::orchestration::codec::{
140 from_hermes, from_openclaw, load_home, Flavor,
141 };
142 let mut rows = Vec::new();
143 for id in harnesses {
144 match id {
145 HarnessId::HERMES => {
146 if let Ok(loaded) = from_hermes(homes.hermes.parent().unwrap_or(Path::new("."))) {
147 rows.extend(hermes_shaped_rows(
149 HarnessId::HERMES,
150 &loaded.orchestration.profiles["default"],
151 None,
152 ));
153 }
154 }
155 HarnessId::OPENCLAW => {
156 if let Ok(loaded) = from_openclaw(&homes.openclaw) {
157 rows.extend(openclaw_rows(&loaded));
158 }
159 }
160 HarnessId::ORCHESTRATOR => {
161 if let Ok(loaded) = load_home(&homes.orchestrator, Flavor::Orchestrator) {
162 let mut names: Vec<&String> = loaded.orchestration.profiles.keys().collect();
163 names.sort_by_key(|name| (name.as_str() != "default", name.as_str()));
164 for name in names {
165 rows.extend(hermes_shaped_rows(
166 HarnessId::ORCHESTRATOR,
167 &loaded.orchestration.profiles[name],
168 (name != "default").then_some(name.as_str()),
169 ));
170 }
171 }
172 }
173 _ => {}
174 }
175 }
176 Ok(rows)
177}
178
179fn hermes_webhook_row(
184 harness: &str,
185 name: &str,
186 events: Vec<String>,
187 deliver: Option<&supercode_interchange::orchestration::Target>,
188 residue: &std::collections::BTreeMap<String, Value>,
189 authenticated: bool,
190 description: Option<String>,
191 source: &str,
192 profile: Option<&str>,
193) -> TriggerRow {
194 use supercode_interchange::orchestration::Target;
195 let (target, chat_id) = match deliver {
196 Some(Target::Explicit {
197 platform, chat_id, ..
198 }) => (Some(platform.clone()), chat_id.clone()),
199 Some(other) => (Some(other.render()), None),
200 None => (Some("log".into()), None),
201 };
202 TriggerRow {
203 name: name.to_string(),
204 harness: harness.into(),
205 kind: TriggerKind::Webhook,
206 route: match profile {
207 Some(p) => format!("/p/{p}/webhooks/{name}"),
208 None => format!("/webhooks/{name}"),
209 },
210 events,
211 target: TriggerTarget {
212 action: Some("background".into()),
213 profile: profile.map(str::to_string),
214 ..TriggerTarget::default()
215 },
216 deliver: TriggerDeliver {
217 target,
218 chat_id: chat_id.or_else(|| {
219 residue
220 .get("deliver_chat_id")
221 .and_then(scalar_text)
222 .filter(|s| !s.is_empty())
223 }),
224 },
225 enabled: residue.get("enabled").is_none_or(|v| match v {
226 Value::Bool(b) => *b,
227 Value::String(s) => s != "false",
228 _ => true,
229 }),
230 authenticated,
231 source: source.to_string(),
232 description,
233 }
234}
235
236fn scalar_text(value: &Value) -> Option<String> {
238 match value {
239 Value::String(s) => Some(s.clone()),
240 Value::Number(n) => Some(n.to_string()),
241 Value::Bool(b) => Some(b.to_string()),
242 _ => None,
243 }
244}
245
246fn hermes_shaped_rows(
251 harness: &str,
252 profile: &supercode_interchange::orchestration::Profile,
253 profile_name: Option<&str>,
254) -> Vec<TriggerRow> {
255 let mut rows = Vec::new();
256 let subs_source = profile
257 .dir
258 .join("webhook_subscriptions.json")
259 .display()
260 .to_string();
261 for (name, sub) in &profile.subscriptions {
262 rows.push(hermes_webhook_row(
263 harness,
264 name,
265 sub.events.clone().unwrap_or_default(),
266 sub.deliver.as_ref(),
267 &sub.residue.0,
268 sub.secret.is_some(),
269 sub.description.clone(),
270 &subs_source,
271 profile_name,
272 ));
273 }
274 let config_source = profile.dir.join("config.yaml").display().to_string();
275 let routes = profile
276 .channels
277 .get("webhook")
278 .and_then(|webhook| webhook.extra.get("extra.routes"))
279 .and_then(Value::as_object);
280 for (name, route) in routes.into_iter().flatten() {
281 let Some(route) = route.as_object() else {
282 continue;
283 };
284 let events = match route.get("events") {
285 Some(Value::Array(list)) => list.iter().filter_map(scalar_text).collect(),
286 Some(Value::String(text)) => text
287 .trim_matches(|c| c == '[' || c == ']')
288 .split(',')
289 .map(|e| e.trim().trim_matches(|c| c == '"' || c == '\'').to_string())
290 .filter(|e| !e.is_empty())
291 .collect(),
292 _ => Vec::new(),
293 };
294 let deliver = route
295 .get("deliver")
296 .and_then(scalar_text)
297 .filter(|s| !s.is_empty());
298 let chat_id = route
299 .get("deliver_extra")
300 .and_then(|e| e.get("chat_id"))
301 .and_then(scalar_text)
302 .filter(|s| !s.is_empty());
303 let target = deliver.map(
304 |word| supercode_interchange::orchestration::Target::Explicit {
305 platform: word,
306 chat_id,
307 thread_id: None,
308 },
309 );
310 let residue: std::collections::BTreeMap<String, Value> =
311 route.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
312 rows.push(hermes_webhook_row(
313 harness,
314 name,
315 events,
316 target.as_ref(),
317 &residue,
318 route.get("secret").is_some(),
319 route.get("description").and_then(scalar_text),
320 &config_source,
321 profile_name,
322 ));
323 }
324 rows
325}
326
327fn openclaw_rows(
332 loaded: &supercode_interchange::orchestration::codec::OpenclawLoaded,
333) -> Vec<TriggerRow> {
334 let Some(hooks) = loaded.orchestration.profiles["default"]
335 .residue
336 .config
337 .get("openclaw")
338 .and_then(|o| o.get("hooks"))
339 .filter(|h| !h.is_null())
340 else {
341 return Vec::new();
342 };
343 let block = hooks.get("block").and_then(Value::as_object);
344 let field = |key: &str| block.and_then(|b| b.get(key));
345 let source = loaded
346 .root
347 .state_dir
348 .join("openclaw.json")
349 .display()
350 .to_string();
351 let enabled = field("enabled").and_then(Value::as_bool).unwrap_or(false);
352 let authenticated = hooks
353 .get("has_token")
354 .and_then(Value::as_bool)
355 .unwrap_or(false)
356 || field("tokenFile").is_some();
357 let base = field("path")
358 .and_then(Value::as_str)
359 .filter(|s| !s.is_empty())
360 .unwrap_or("/hooks")
361 .trim_end_matches('/')
362 .to_string();
363 let mut rows = vec![
364 TriggerRow {
365 name: "wake".into(),
366 harness: HarnessId::OPENCLAW.into(),
367 kind: TriggerKind::BuiltinWake,
368 route: format!("{base}/wake"),
369 events: Vec::new(),
370 target: TriggerTarget {
371 action: Some("wake".into()),
372 session_key: Some("main".into()),
373 ..TriggerTarget::default()
374 },
375 deliver: TriggerDeliver::default(),
376 enabled,
377 authenticated,
378 source: source.clone(),
379 description: Some("built-in: enqueue a system event into the main session".into()),
380 },
381 TriggerRow {
382 name: "agent".into(),
383 harness: HarnessId::OPENCLAW.into(),
384 kind: TriggerKind::BuiltinAgent,
385 route: format!("{base}/agent"),
386 events: Vec::new(),
387 target: TriggerTarget {
388 action: Some("agent".into()),
389 session_key: Some("isolated".into()),
390 ..TriggerTarget::default()
391 },
392 deliver: TriggerDeliver::default(),
393 enabled,
394 authenticated,
395 source: source.clone(),
396 description: Some("built-in: run an isolated agent turn".into()),
397 },
398 ];
399 let mut mappings: Vec<_> = loaded
400 .orchestration
401 .profiles
402 .values()
403 .flat_map(|profile| profile.subscriptions.values())
404 .filter_map(|sub| Some((sub.residue.0.get("__index")?.as_u64()?, sub)))
405 .collect();
406 mappings.sort_by_key(|(index, _)| *index);
407 for (index, sub) in mappings {
408 let mapping = &sub.residue.0;
409 let text = |key: &str| {
410 mapping
411 .get(key)
412 .and_then(Value::as_str)
413 .filter(|s| !s.is_empty())
414 .map(str::to_string)
415 };
416 let matcher = mapping.get("match").cloned().unwrap_or(Value::Null);
417 let match_text = |key: &str| {
418 matcher
419 .get(key)
420 .and_then(Value::as_str)
421 .filter(|s| !s.is_empty())
422 .map(str::to_string)
423 };
424 let name = text("id")
425 .or_else(|| match_text("path").map(|p| p.trim_start_matches('/').to_string()))
426 .unwrap_or_else(|| format!("mapping-{index}"));
427 let path = match_text("path")
428 .map(|p| format!("{base}/{}", p.trim_start_matches('/')))
429 .unwrap_or_else(|| format!("{base}/{name}"));
430 let mut events = Vec::new();
431 for key in ["source", "event"] {
432 if let Some(v) = match_text(key) {
433 events.push(format!("{key}={v}"));
434 }
435 }
436 let (deliver_target, chat_id) = match &sub.deliver {
437 Some(supercode_interchange::orchestration::Target::Explicit {
438 platform,
439 chat_id,
440 ..
441 }) => (Some(platform.clone()), chat_id.clone()),
442 _ => (None, None),
443 };
444 rows.push(TriggerRow {
445 name,
446 harness: HarnessId::OPENCLAW.into(),
447 kind: TriggerKind::HookMapping,
448 route: path,
449 events,
450 target: TriggerTarget {
451 action: text("action"),
452 profile: text("agentId"),
453 session_key: text("sessionKey"),
454 wake_mode: text("wakeMode"),
455 model: text("model"),
456 },
457 deliver: TriggerDeliver {
458 target: deliver_target.or_else(|| text("channel")),
459 chat_id,
460 },
461 enabled: enabled
462 && mapping
463 .get("enabled")
464 .and_then(Value::as_bool)
465 .unwrap_or(true),
466 authenticated,
467 source: source.clone(),
468 description: text("description"),
469 });
470 }
471 rows
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477
478 fn scratch(tag: &str) -> std::path::PathBuf {
479 let dir = std::env::temp_dir().join(format!(
480 "supercode-triggers-{tag}-{}-{}",
481 std::process::id(),
482 std::time::SystemTime::now()
483 .duration_since(std::time::UNIX_EPOCH)
484 .unwrap()
485 .as_nanos()
486 ));
487 std::fs::create_dir_all(&dir).unwrap();
488 dir
489 }
490
491 #[test]
492 fn openclaw_hooks_block_yields_builtins_and_mappings() {
493 let dir = scratch("openclaw");
494 std::fs::write(
495 dir.join("openclaw.json"),
496 r#"{ "hooks": { "enabled": true, "token": "FAKE-HOOK-TOKEN", "path": "/hooks",
497 "mappings": [ { "id": "gmail", "match": { "path": "gmail", "source": "gmail" }, "action": "agent", "agentId": "main", "sessionKey": "hook:gmail:{{id}}", "deliver": "slack", "to": "C1" } ] } }"#,
498 )
499 .unwrap();
500 let loaded = supercode_interchange::orchestration::codec::from_openclaw(&dir).unwrap();
501 let rows = openclaw_rows(&loaded);
502 let names: Vec<&str> = rows.iter().map(|r| r.name.as_str()).collect();
503 assert_eq!(names, vec!["wake", "agent", "gmail"]);
504 assert_eq!(rows[2].route, "/hooks/gmail");
505 assert_eq!(rows[2].events, vec!["source=gmail"]);
506 assert_eq!(rows[2].target.action.as_deref(), Some("agent"));
507 assert_eq!(
508 rows[2].target.session_key.as_deref(),
509 Some("hook:gmail:{{id}}")
510 );
511 assert!(rows.iter().all(|r| r.authenticated && r.enabled));
512 let rendered = serde_json::to_string(&rows).unwrap();
513 assert!(!rendered.contains("FAKE-"), "{rendered}");
514 }
515
516 #[test]
517 fn a_core_harness_is_refused() {
518 let err = list_triggers(&HarnessHomes::default(), Some("claude-code")).unwrap_err();
519 assert!(err.to_string().contains("triggers.list"));
520 }
521}