use std::collections::BTreeMap;
use std::path::Path;
use serde::{Deserialize, Serialize};
use serde_json::Value;
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(),
});
}
}
use supercode_interchange::orchestration::codec::{
from_hermes, from_openclaw, load_home, Flavor,
};
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 => {
if let Ok(loaded) = from_hermes(homes.hermes.parent().unwrap_or(Path::new("."))) {
rows.extend(hermes_shaped_rows(
HarnessId::HERMES,
&loaded.orchestration.profiles["default"],
sessions.as_ref(),
true,
));
}
}
HarnessId::OPENCLAW => {
if let Ok(loaded) = from_openclaw(&homes.openclaw) {
rows.extend(openclaw_rows(&loaded, sessions.as_ref()));
}
}
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],
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 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_shaped_rows(
harness: &str,
profile: &supercode_interchange::orchestration::Profile,
sessions: Option<&BTreeMap<String, u64>>,
env_fallback: bool,
) -> Vec<ChannelRow> {
let mut names: Vec<String> = profile.channels.keys().cloned().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 channel = profile.channels.get(&name);
let env_present = env_fallback
.then(|| hermes_env_credentials_present(&name))
.flatten();
let enabled = match channel {
Some(channel) => channel.enabled,
None => env_present == Some(true),
};
let configured = env_present == Some(true)
|| channel.is_some_and(|channel| !channel.credentials.is_empty())
|| env_present.is_none();
let account = channel.and_then(|channel| {
HERMES_ACCOUNT_KEYS.iter().find_map(|key| {
channel
.extra
.get(*key)
.or_else(|| channel.extra.get(&format!("extra.{key}")))
.and_then(|v| match v {
Value::String(s) => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
_ => None,
})
.filter(|value| !value.is_empty())
})
});
ChannelRow {
name: name.clone(),
harness: harness.to_string(),
kind: name.clone(),
account,
enabled: Some(enabled),
configured,
status: ChannelStatus::Unknown,
sessions: sessions.map(|counts| counts.get(&name).copied().unwrap_or(0)),
}
})
.collect()
}
fn openclaw_rows(
loaded: &supercode_interchange::orchestration::codec::OpenclawLoaded,
sessions: Option<&BTreeMap<String, u64>>,
) -> Vec<ChannelRow> {
loaded.orchestration.profiles["default"]
.channels
.iter()
.map(|(name, channel)| {
let text = |key: &str| {
channel
.extra
.get(key)
.and_then(Value::as_str)
.map(str::to_string)
};
let kind = text("kind").unwrap_or_else(|| name.clone());
ChannelRow {
name: name.clone(),
harness: HarnessId::OPENCLAW.to_string(),
kind: kind.clone(),
account: text("accountId"),
enabled: text("enabled_on").map(|_| channel.enabled),
configured: !channel.credentials.is_empty(),
status: ChannelStatus::Unknown,
sessions: sessions.map(|counts| counts.get(&kind).copied().unwrap_or(0)),
}
})
.collect()
}
#[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()
}
);
}
}