use std::collections::BTreeMap;
use std::path::Path;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::profiles::{read_json5, yaml_key, yaml_scalar};
use crate::{HarnessHomes, HarnessId};
pub const CHANNELS_SCHEMA: &str = "supercode.channels.v1";
pub const CHANNEL_HARNESSES: &[&str] = &[
HarnessId::HERMES,
HarnessId::OPENCLAW,
HarnessId::ORCHESTRATOR,
];
pub const HERMES_ACCOUNT_KEYS: &[&str] = &[
"account",
"account_id",
"app_id",
"bot_id",
"client_id",
"corp_id",
"phone_number_id",
"user_id",
];
pub const OPENCLAW_ACCOUNT_KEYS: &[&str] = &[
"accountId",
"account_id",
"account",
"teamId",
"appId",
"userId",
];
const HERMES_ENV_CREDENTIALS: &[(&str, &[&str], bool)] = &[
("telegram", &["TELEGRAM_BOT_TOKEN"], false),
("discord", &["DISCORD_BOT_TOKEN"], false),
("slack", &["SLACK_BOT_TOKEN"], false),
(
"whatsapp_cloud",
&[
"WHATSAPP_CLOUD_PHONE_NUMBER_ID",
"WHATSAPP_CLOUD_ACCESS_TOKEN",
],
true,
),
("signal", &["SIGNAL_HTTP_URL"], false),
("mattermost", &["MATTERMOST_TOKEN"], false),
("matrix", &["MATRIX_ACCESS_TOKEN", "MATRIX_PASSWORD"], false),
("homeassistant", &["HASS_TOKEN"], false),
(
"email",
&[
"EMAIL_ADDRESS",
"EMAIL_PASSWORD",
"EMAIL_IMAP_HOST",
"EMAIL_SMTP_HOST",
],
true,
),
("sms", &["TWILIO_ACCOUNT_SID"], false),
(
"dingtalk",
&["DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET"],
true,
),
("feishu", &["FEISHU_APP_ID", "FEISHU_APP_SECRET"], true),
("wecom", &["WECOM_BOT_ID", "WECOM_SECRET"], true),
(
"wecom_callback",
&["WECOM_CALLBACK_CORP_ID", "WECOM_CALLBACK_CORP_SECRET"],
true,
),
("weixin", &["WEIXIN_TOKEN", "WEIXIN_ACCOUNT_ID"], false),
(
"bluebubbles",
&["BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_PASSWORD"],
true,
),
("qqbot", &["QQ_APP_ID", "QQ_CLIENT_SECRET"], false),
("yuanbao", &["YUANBAO_APP_ID", "YUANBAO_APP_SECRET"], true),
("relay", &["GATEWAY_RELAY_URL"], false),
("api_server", &["API_SERVER_KEY"], false),
];
pub const HERMES_PLATFORMS: &[&str] = &[
"a2a",
"api_server",
"bluebubbles",
"buzz",
"dingtalk",
"discord",
"email",
"feishu",
"google_chat",
"homeassistant",
"irc",
"line",
"matrix",
"mattermost",
"msgraph_webhook",
"ntfy",
"photon",
"qqbot",
"raft",
"relay",
"signal",
"simplex",
"slack",
"sms",
"teams",
"telegram",
"webhook",
"wecom",
"wecom_callback",
"weixin",
"whatsapp",
"whatsapp_cloud",
"yuanbao",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChannelStatus {
Up,
Down,
Unknown,
}
impl ChannelStatus {
pub const fn as_str(self) -> &'static str {
match self {
Self::Up => "up",
Self::Down => "down",
Self::Unknown => "unknown",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChannelRow {
pub name: String,
pub harness: String,
pub kind: String,
pub account: Option<String>,
pub enabled: Option<bool>,
pub configured: bool,
pub status: ChannelStatus,
pub sessions: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ChannelError {
#[error("harness `{harness}` has no channel concept (channels exist for: {})", CHANNEL_HARNESSES.join(", "))]
UnsupportedHarness {
harness: String,
},
#[error("`{harness}` has no channel `{name}`")]
NotFound {
harness: String,
name: String,
},
}
pub fn list_channels(
homes: &HarnessHomes,
harness: Option<&str>,
) -> Result<Vec<ChannelRow>, ChannelError> {
if let Some(harness) = harness {
if !CHANNEL_HARNESSES.contains(&harness) {
return Err(ChannelError::UnsupportedHarness {
harness: harness.to_string(),
});
}
}
let mut rows = Vec::new();
for id in CHANNEL_HARNESSES {
if harness.is_some_and(|requested| requested != *id) {
continue;
}
let sessions = session_counts(homes, id);
match *id {
HarnessId::HERMES => rows.extend(hermes_rows(
HarnessId::HERMES,
homes.hermes.parent().unwrap_or(Path::new(".")),
sessions.as_ref(),
true,
)),
HarnessId::OPENCLAW => rows.extend(openclaw_rows(&homes.openclaw, sessions.as_ref())),
HarnessId::ORCHESTRATOR => {
for (_, dir) in crate::orchestrator_profile_dirs(&homes.orchestrator) {
rows.extend(hermes_rows(
HarnessId::ORCHESTRATOR,
&dir,
sessions.as_ref(),
false,
));
}
}
_ => {}
}
}
Ok(rows)
}
pub fn channel_status(
homes: &HarnessHomes,
harness: &str,
name: &str,
) -> Result<ChannelRow, ChannelError> {
list_channels(homes, Some(harness))?
.into_iter()
.find(|row| row.name == name)
.ok_or_else(|| ChannelError::NotFound {
harness: harness.to_string(),
name: name.to_string(),
})
}
fn session_counts(homes: &HarnessHomes, harness: &str) -> Option<BTreeMap<String, u64>> {
let query = crate::DiscoveryQuery {
harnesses: vec![HarnessId::new(harness)],
homes: homes.clone(),
..Default::default()
};
let sessions = crate::HarnessCatalog::new().discover(&query).ok()?;
let mut counts: BTreeMap<String, u64> = BTreeMap::new();
for session in sessions {
if let Some(platform) = session
.nouns
.surface
.as_ref()
.and_then(|surface| surface.platform.as_ref())
{
*counts.entry(platform.clone()).or_default() += 1;
}
}
Some(counts)
}
fn is_credential_key(key: &str) -> bool {
let lower = key.to_ascii_lowercase();
["token", "key", "secret", "password", "credential"]
.iter()
.any(|marker| lower.ends_with(marker))
}
fn hermes_env_credentials_present(platform: &str) -> Option<bool> {
let (_, vars, all) = HERMES_ENV_CREDENTIALS
.iter()
.find(|(name, _, _)| *name == platform)?;
let present = |name: &&str| std::env::var_os(name).is_some();
Some(if *all {
vars.iter().all(present)
} else {
vars.iter().any(present)
})
}
fn hermes_rows(
harness: &str,
home: &Path,
sessions: Option<&BTreeMap<String, u64>>,
env_fallback: bool,
) -> Vec<ChannelRow> {
let config = std::fs::read_to_string(home.join("config.yaml")).unwrap_or_default();
let platforms = hermes_platform_blocks(&config);
let mut names: Vec<String> = platforms.iter().map(|(name, _)| name.clone()).collect();
if env_fallback {
for (platform, _, _) in HERMES_ENV_CREDENTIALS {
if hermes_env_credentials_present(platform) == Some(true)
&& !names.iter().any(|name| name == platform)
{
names.push((*platform).to_string());
}
}
}
names.sort();
names.dedup();
names
.into_iter()
.map(|name| {
let blocks: Vec<&String> = platforms
.iter()
.filter(|(platform, _)| *platform == name)
.map(|(_, block)| block)
.collect();
let scopes: Vec<String> = blocks
.iter()
.map(|block| yaml_root_child(block, "extra"))
.chain(blocks.iter().map(|block| (*block).clone()))
.collect();
let env_present = env_fallback
.then(|| hermes_env_credentials_present(&name))
.flatten();
let enabled = match blocks
.iter()
.filter_map(|block| yaml_scalar(block, "enabled"))
.next_back()
{
Some(explicit) => explicit == "true",
None => env_present == Some(true),
};
let configured = env_present == Some(true)
|| scopes
.iter()
.flat_map(|scope| yaml_block_keys(scope))
.any(|key| is_credential_key(&key))
|| env_present.is_none();
ChannelRow {
name: name.clone(),
harness: harness.to_string(),
kind: name.clone(),
account: HERMES_ACCOUNT_KEYS
.iter()
.find_map(|key| scopes.iter().find_map(|scope| yaml_scalar(scope, key)))
.filter(|value| !value.is_empty()),
enabled: Some(enabled),
configured,
status: ChannelStatus::Unknown,
sessions: sessions.map(|counts| counts.get(&name).copied().unwrap_or(0)),
}
})
.collect()
}
fn hermes_platform_blocks(config: &str) -> Vec<(String, String)> {
let is_platform = |name: &String| HERMES_PLATFORMS.contains(&name.as_str());
let top = yaml_block_names(config);
let gateway = yaml_root_child(config, "gateway");
let gateway_children = yaml_block_names(&gateway);
let mut blocks = yaml_block_names(&yaml_root_child(&gateway, "platforms"));
blocks.extend(yaml_block_names(&yaml_root_child(config, "platforms")));
blocks.extend(
gateway_children
.into_iter()
.filter(|(name, _)| is_platform(name)),
);
blocks.extend(top.into_iter().filter(|(name, _)| is_platform(name)));
blocks
}
fn yaml_root_child(block: &str, key: &str) -> String {
yaml_block_names(block)
.into_iter()
.find(|(name, _)| name == key)
.map(|(_, body)| body)
.unwrap_or_default()
}
fn yaml_block_names(block: &str) -> Vec<(String, String)> {
let mut children: Vec<(String, String)> = Vec::new();
let Some(root) = yaml_root_indent(block) else {
return children;
};
let mut current: Option<String> = None;
for line in block.lines() {
let trimmed = line.trim_start();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let indent = line.len() - trimmed.len();
if indent > root {
if let Some(key) = ¤t {
if let Some((_, body)) = children.iter_mut().find(|(name, _)| name == key) {
body.push_str(line);
body.push('\n');
}
}
continue;
}
current = yaml_key(trimmed).map(str::to_string);
if let Some(key) = ¤t {
if !children.iter().any(|(name, _)| name == key) {
children.push((key.clone(), String::new()));
}
}
}
children
}
fn yaml_block_keys(block: &str) -> Vec<String> {
yaml_block_names(block)
.into_iter()
.map(|(name, _)| name)
.collect()
}
fn yaml_root_indent(block: &str) -> Option<usize> {
block
.lines()
.filter(|line| {
let trimmed = line.trim_start();
!trimmed.is_empty() && !trimmed.starts_with('#')
})
.map(|line| line.len() - line.trim_start().len())
.min()
}
fn openclaw_rows(home: &Path, sessions: Option<&BTreeMap<String, u64>>) -> Vec<ChannelRow> {
let config = read_json5(&home.join("openclaw.json"));
let Some(channels) = config.pointer("/channels").and_then(Value::as_object) else {
return Vec::new();
};
let mut rows = Vec::new();
for (kind, entry) in channels {
let count = sessions.map(|counts| counts.get(kind).copied().unwrap_or(0));
let accounts = account_entries(entry);
if accounts.is_empty() {
rows.push(openclaw_row(kind, kind, entry, None, entry, count));
continue;
}
for (id, account) in accounts {
rows.push(openclaw_row(
&format!("{kind}/{id}"),
kind,
entry,
Some(id),
&account,
count,
));
}
}
rows.sort_by(|left, right| left.name.cmp(&right.name));
rows
}
fn openclaw_row(
name: &str,
kind: &str,
entry: &Value,
account: Option<String>,
scope: &Value,
sessions: Option<u64>,
) -> ChannelRow {
let account = account.or_else(|| {
OPENCLAW_ACCOUNT_KEYS
.iter()
.find_map(|key| entry.get(*key).and_then(Value::as_str))
.map(str::to_string)
});
let enabled = scope
.get("enabled")
.or_else(|| entry.get("enabled"))
.and_then(Value::as_bool);
let configured = has_credential_key(scope) || has_credential_key(entry);
ChannelRow {
name: name.to_string(),
harness: HarnessId::OPENCLAW.to_string(),
kind: kind.to_string(),
account,
enabled,
configured,
status: ChannelStatus::Unknown,
sessions,
}
}
fn account_entries(entry: &Value) -> Vec<(String, Value)> {
let mut accounts: Vec<(String, Value)> = match entry.get("accounts") {
Some(Value::Object(map)) => map
.iter()
.map(|(id, account)| (id.clone(), account.clone()))
.collect(),
Some(Value::Array(list)) => list
.iter()
.filter_map(|account| {
OPENCLAW_ACCOUNT_KEYS
.iter()
.find_map(|key| account.get(*key).and_then(Value::as_str))
.or_else(|| account.get("id").and_then(Value::as_str))
.map(|id| (id.to_string(), account.clone()))
})
.collect(),
_ => Vec::new(),
};
accounts.sort_by(|left, right| left.0.cmp(&right.0));
accounts
}
fn has_credential_key(value: &Value) -> bool {
value
.as_object()
.is_some_and(|map| map.keys().any(|key| is_credential_key(key)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unsupported_harness_is_refused_not_silently_empty() {
let error = list_channels(&HarnessHomes::default(), Some(HarnessId::CLAUDE_CODE))
.expect_err("claude-code channels are MCP-protocol declarations");
assert_eq!(
error,
ChannelError::UnsupportedHarness {
harness: HarnessId::CLAUDE_CODE.to_string()
}
);
}
#[test]
fn credential_keys_are_recognised_by_name_in_both_spellings() {
for key in [
"token",
"api_key",
"botToken",
"appToken",
"access_token",
"app_secret",
"client_secret",
"password",
"key",
] {
assert!(is_credential_key(key), "`{key}` is a credential key");
}
for key in [
"enabled",
"port",
"host",
"app_id",
"reply_to_mode",
"extra",
] {
assert!(!is_credential_key(key), "`{key}` is not a credential key");
}
}
#[test]
fn yaml_block_reader_lists_platform_entries_and_their_keys() {
let config = "platforms:\n telegram:\n enabled: true\n token: \"SECRET\"\n api_server:\n enabled: false\n extra:\n key: \"SECRET\"\n port: 8642\ngateway:\n port: 1\n";
let platforms = yaml_root_child(config, "platforms");
let names: Vec<String> = yaml_block_keys(&platforms);
assert_eq!(names, ["telegram", "api_server"]);
let api = yaml_root_child(&platforms, "api_server");
assert_eq!(yaml_block_keys(&api), ["enabled", "extra"]);
assert_eq!(
yaml_block_keys(&yaml_root_child(&api, "extra")),
["key", "port"]
);
}
#[test]
fn every_hermes_platform_block_shape_is_read() {
let config = concat!(
"gateway:\n",
" platforms:\n",
" discord:\n",
" enabled: true\n",
" token: \"x\"\n",
" api_server:\n",
" enabled: true\n",
" extra:\n",
" key: \"x\"\n",
" profile_routes:\n",
" - platform: slack\n",
"platforms:\n",
" webhook:\n",
" enabled: true\n",
"telegram:\n",
" enabled: false\n",
"memory:\n",
" enabled: true\n",
);
let blocks = hermes_platform_blocks(config);
let names: Vec<&str> = blocks.iter().map(|(name, _)| name.as_str()).collect();
assert_eq!(names, ["discord", "webhook", "api_server", "telegram"]);
assert!(!names.contains(&"memory"), "{names:?}");
assert!(!names.contains(&"profile_routes"), "{names:?}");
}
#[test]
fn openclaw_accounts_split_a_channel_into_one_row_each() {
let entry: Value = serde_json::from_str(
r#"{"enabled": true, "accounts": {"T2": {"botToken": "x"}, "T1": {"enabled": false}}}"#,
)
.unwrap();
let ids: Vec<String> = account_entries(&entry)
.into_iter()
.map(|(id, _)| id)
.collect();
assert_eq!(ids, ["T1", "T2"]);
let rows = openclaw_rows(Path::new("/nonexistent-openclaw-home"), None);
assert!(rows.is_empty(), "a missing config declares no channels");
}
}