use std::path::Path;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::catalog::HarnessHomes;
use crate::HarnessId;
pub const TRIGGERS_SCHEMA: &str = "supercode.triggers.v1";
pub const TRIGGER_HARNESSES: &[&str] = &[
HarnessId::HERMES,
HarnessId::OPENCLAW,
HarnessId::ORCHESTRATOR,
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TriggerKind {
Webhook,
HookMapping,
BuiltinWake,
BuiltinAgent,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TriggerTarget {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub action: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wake_mode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TriggerDeliver {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub chat_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TriggerRow {
pub name: String,
pub harness: String,
pub kind: TriggerKind,
pub route: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub events: Vec<String>,
pub target: TriggerTarget,
pub deliver: TriggerDeliver,
pub enabled: bool,
pub authenticated: bool,
pub source: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TriggerError {
UnsupportedHarness { harness: String },
}
impl std::fmt::Display for TriggerError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TriggerError::UnsupportedHarness { harness } => write!(
f,
"`{harness}` has no inbound-trigger store supercode reads; `triggers.list` is supported for: {}",
TRIGGER_HARNESSES.join(", ")
),
}
}
}
impl std::error::Error for TriggerError {}
pub fn list_triggers(
homes: &HarnessHomes,
harness: Option<&str>,
) -> Result<Vec<TriggerRow>, TriggerError> {
let harnesses: Vec<&str> = match harness {
Some(id) if TRIGGER_HARNESSES.contains(&id) => vec![id],
Some(id) => {
return Err(TriggerError::UnsupportedHarness {
harness: id.to_string(),
})
}
None => TRIGGER_HARNESSES.to_vec(),
};
use supercode_interchange::orchestration::codec::{
from_hermes, from_openclaw, load_home, Flavor,
};
let mut rows = Vec::new();
for id in harnesses {
match id {
HarnessId::HERMES => {
if let Ok(loaded) = from_hermes(homes.hermes.parent().unwrap_or(Path::new("."))) {
rows.extend(hermes_shaped_rows(
HarnessId::HERMES,
&loaded.orchestration.profiles["default"],
None,
));
}
}
HarnessId::OPENCLAW => {
if let Ok(loaded) = from_openclaw(&homes.openclaw) {
rows.extend(openclaw_rows(&loaded));
}
}
HarnessId::ORCHESTRATOR => {
if let Ok(loaded) = load_home(&homes.orchestrator, Flavor::Orchestrator) {
let mut names: Vec<&String> = loaded.orchestration.profiles.keys().collect();
names.sort_by_key(|name| (name.as_str() != "default", name.as_str()));
for name in names {
rows.extend(hermes_shaped_rows(
HarnessId::ORCHESTRATOR,
&loaded.orchestration.profiles[name],
(name != "default").then_some(name.as_str()),
));
}
}
}
_ => {}
}
}
Ok(rows)
}
fn hermes_webhook_row(
harness: &str,
name: &str,
events: Vec<String>,
deliver: Option<&supercode_interchange::orchestration::Target>,
residue: &std::collections::BTreeMap<String, Value>,
authenticated: bool,
description: Option<String>,
source: &str,
profile: Option<&str>,
) -> TriggerRow {
use supercode_interchange::orchestration::Target;
let (target, chat_id) = match deliver {
Some(Target::Explicit {
platform, chat_id, ..
}) => (Some(platform.clone()), chat_id.clone()),
Some(other) => (Some(other.render()), None),
None => (Some("log".into()), None),
};
TriggerRow {
name: name.to_string(),
harness: harness.into(),
kind: TriggerKind::Webhook,
route: match profile {
Some(p) => format!("/p/{p}/webhooks/{name}"),
None => format!("/webhooks/{name}"),
},
events,
target: TriggerTarget {
action: Some("background".into()),
profile: profile.map(str::to_string),
..TriggerTarget::default()
},
deliver: TriggerDeliver {
target,
chat_id: chat_id.or_else(|| {
residue
.get("deliver_chat_id")
.and_then(scalar_text)
.filter(|s| !s.is_empty())
}),
},
enabled: residue.get("enabled").is_none_or(|v| match v {
Value::Bool(b) => *b,
Value::String(s) => s != "false",
_ => true,
}),
authenticated,
source: source.to_string(),
description,
}
}
fn scalar_text(value: &Value) -> Option<String> {
match value {
Value::String(s) => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
Value::Bool(b) => Some(b.to_string()),
_ => None,
}
}
fn hermes_shaped_rows(
harness: &str,
profile: &supercode_interchange::orchestration::Profile,
profile_name: Option<&str>,
) -> Vec<TriggerRow> {
let mut rows = Vec::new();
let subs_source = profile
.dir
.join("webhook_subscriptions.json")
.display()
.to_string();
for (name, sub) in &profile.subscriptions {
rows.push(hermes_webhook_row(
harness,
name,
sub.events.clone().unwrap_or_default(),
sub.deliver.as_ref(),
&sub.residue.0,
sub.secret.is_some(),
sub.description.clone(),
&subs_source,
profile_name,
));
}
let config_source = profile.dir.join("config.yaml").display().to_string();
let routes = profile
.channels
.get("webhook")
.and_then(|webhook| webhook.extra.get("extra.routes"))
.and_then(Value::as_object);
for (name, route) in routes.into_iter().flatten() {
let Some(route) = route.as_object() else {
continue;
};
let events = match route.get("events") {
Some(Value::Array(list)) => list.iter().filter_map(scalar_text).collect(),
Some(Value::String(text)) => text
.trim_matches(|c| c == '[' || c == ']')
.split(',')
.map(|e| e.trim().trim_matches(|c| c == '"' || c == '\'').to_string())
.filter(|e| !e.is_empty())
.collect(),
_ => Vec::new(),
};
let deliver = route
.get("deliver")
.and_then(scalar_text)
.filter(|s| !s.is_empty());
let chat_id = route
.get("deliver_extra")
.and_then(|e| e.get("chat_id"))
.and_then(scalar_text)
.filter(|s| !s.is_empty());
let target = deliver.map(
|word| supercode_interchange::orchestration::Target::Explicit {
platform: word,
chat_id,
thread_id: None,
},
);
let residue: std::collections::BTreeMap<String, Value> =
route.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
rows.push(hermes_webhook_row(
harness,
name,
events,
target.as_ref(),
&residue,
route.get("secret").is_some(),
route.get("description").and_then(scalar_text),
&config_source,
profile_name,
));
}
rows
}
fn openclaw_rows(
loaded: &supercode_interchange::orchestration::codec::OpenclawLoaded,
) -> Vec<TriggerRow> {
let Some(hooks) = loaded.orchestration.profiles["default"]
.residue
.config
.get("openclaw")
.and_then(|o| o.get("hooks"))
.filter(|h| !h.is_null())
else {
return Vec::new();
};
let block = hooks.get("block").and_then(Value::as_object);
let field = |key: &str| block.and_then(|b| b.get(key));
let source = loaded
.root
.state_dir
.join("openclaw.json")
.display()
.to_string();
let enabled = field("enabled").and_then(Value::as_bool).unwrap_or(false);
let authenticated = hooks
.get("has_token")
.and_then(Value::as_bool)
.unwrap_or(false)
|| field("tokenFile").is_some();
let base = field("path")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.unwrap_or("/hooks")
.trim_end_matches('/')
.to_string();
let mut rows = vec![
TriggerRow {
name: "wake".into(),
harness: HarnessId::OPENCLAW.into(),
kind: TriggerKind::BuiltinWake,
route: format!("{base}/wake"),
events: Vec::new(),
target: TriggerTarget {
action: Some("wake".into()),
session_key: Some("main".into()),
..TriggerTarget::default()
},
deliver: TriggerDeliver::default(),
enabled,
authenticated,
source: source.clone(),
description: Some("built-in: enqueue a system event into the main session".into()),
},
TriggerRow {
name: "agent".into(),
harness: HarnessId::OPENCLAW.into(),
kind: TriggerKind::BuiltinAgent,
route: format!("{base}/agent"),
events: Vec::new(),
target: TriggerTarget {
action: Some("agent".into()),
session_key: Some("isolated".into()),
..TriggerTarget::default()
},
deliver: TriggerDeliver::default(),
enabled,
authenticated,
source: source.clone(),
description: Some("built-in: run an isolated agent turn".into()),
},
];
let mut mappings: Vec<_> = loaded
.orchestration
.profiles
.values()
.flat_map(|profile| profile.subscriptions.values())
.filter_map(|sub| Some((sub.residue.0.get("__index")?.as_u64()?, sub)))
.collect();
mappings.sort_by_key(|(index, _)| *index);
for (index, sub) in mappings {
let mapping = &sub.residue.0;
let text = |key: &str| {
mapping
.get(key)
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(str::to_string)
};
let matcher = mapping.get("match").cloned().unwrap_or(Value::Null);
let match_text = |key: &str| {
matcher
.get(key)
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(str::to_string)
};
let name = text("id")
.or_else(|| match_text("path").map(|p| p.trim_start_matches('/').to_string()))
.unwrap_or_else(|| format!("mapping-{index}"));
let path = match_text("path")
.map(|p| format!("{base}/{}", p.trim_start_matches('/')))
.unwrap_or_else(|| format!("{base}/{name}"));
let mut events = Vec::new();
for key in ["source", "event"] {
if let Some(v) = match_text(key) {
events.push(format!("{key}={v}"));
}
}
let (deliver_target, chat_id) = match &sub.deliver {
Some(supercode_interchange::orchestration::Target::Explicit {
platform,
chat_id,
..
}) => (Some(platform.clone()), chat_id.clone()),
_ => (None, None),
};
rows.push(TriggerRow {
name,
harness: HarnessId::OPENCLAW.into(),
kind: TriggerKind::HookMapping,
route: path,
events,
target: TriggerTarget {
action: text("action"),
profile: text("agentId"),
session_key: text("sessionKey"),
wake_mode: text("wakeMode"),
model: text("model"),
},
deliver: TriggerDeliver {
target: deliver_target.or_else(|| text("channel")),
chat_id,
},
enabled: enabled
&& mapping
.get("enabled")
.and_then(Value::as_bool)
.unwrap_or(true),
authenticated,
source: source.clone(),
description: text("description"),
});
}
rows
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"supercode-triggers-{tag}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn openclaw_hooks_block_yields_builtins_and_mappings() {
let dir = scratch("openclaw");
std::fs::write(
dir.join("openclaw.json"),
r#"{ "hooks": { "enabled": true, "token": "FAKE-HOOK-TOKEN", "path": "/hooks",
"mappings": [ { "id": "gmail", "match": { "path": "gmail", "source": "gmail" }, "action": "agent", "agentId": "main", "sessionKey": "hook:gmail:{{id}}", "deliver": "slack", "to": "C1" } ] } }"#,
)
.unwrap();
let loaded = supercode_interchange::orchestration::codec::from_openclaw(&dir).unwrap();
let rows = openclaw_rows(&loaded);
let names: Vec<&str> = rows.iter().map(|r| r.name.as_str()).collect();
assert_eq!(names, vec!["wake", "agent", "gmail"]);
assert_eq!(rows[2].route, "/hooks/gmail");
assert_eq!(rows[2].events, vec!["source=gmail"]);
assert_eq!(rows[2].target.action.as_deref(), Some("agent"));
assert_eq!(
rows[2].target.session_key.as_deref(),
Some("hook:gmail:{{id}}")
);
assert!(rows.iter().all(|r| r.authenticated && r.enabled));
let rendered = serde_json::to_string(&rows).unwrap();
assert!(!rendered.contains("FAKE-"), "{rendered}");
}
#[test]
fn a_core_harness_is_refused() {
let err = list_triggers(&HarnessHomes::default(), Some("claude-code")).unwrap_err();
assert!(err.to_string().contains("triggers.list"));
}
}