use std::collections::HashMap;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::channels::wasm::capabilities::{
ChannelCapabilities, EmitRateLimitConfig, MIN_POLL_INTERVAL_MS,
};
use crate::tools::wasm::{CapabilitiesFile as ToolCapabilitiesFile, RateLimitSchema};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ChannelCapabilitiesFile {
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub wit_version: Option<String>,
#[serde(default = "default_type")]
pub r#type: String,
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub setup: SetupSchema,
#[serde(default)]
pub capabilities: ChannelCapabilitiesSchema,
#[serde(default)]
pub config: HashMap<String, serde_json::Value>,
}
fn default_type() -> String {
"channel".to_string()
}
impl ChannelCapabilitiesFile {
pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(json)
}
pub fn from_bytes(bytes: &[u8]) -> Result<Self, serde_json::Error> {
serde_json::from_slice(bytes)
}
pub fn validate(&self) {
const MIN_PROMPT_LENGTH: usize = 30;
for secret in &self.setup.required_secrets {
if secret.prompt.len() < MIN_PROMPT_LENGTH {
tracing::warn!(
channel = self.name,
secret = secret.name,
prompt = secret.prompt,
"setup.required_secrets prompt is shorter than {} chars — \
consider a more descriptive prompt that tells the user where to find this value",
MIN_PROMPT_LENGTH
);
}
}
if !self.setup.required_secrets.is_empty() && self.setup.setup_url.is_none() {
tracing::warn!(
channel = self.name,
"setup.required_secrets defined but no setup.setup_url — \
user has no link to obtain credentials"
);
}
}
pub fn to_capabilities(&self) -> ChannelCapabilities {
self.capabilities.to_channel_capabilities(&self.name)
}
pub fn config_json(&self) -> String {
serde_json::to_string(&self.config).unwrap_or_else(|_| "{}".to_string())
}
pub fn webhook_secret_header(&self) -> Option<&str> {
self.capabilities
.channel
.as_ref()
.and_then(|c| c.webhook.as_ref())
.and_then(|w| w.secret_header.as_deref())
}
pub fn signature_key_secret_name(&self) -> Option<&str> {
self.capabilities
.channel
.as_ref()
.and_then(|c| c.webhook.as_ref())
.and_then(|w| w.signature_key_secret_name.as_deref())
}
pub fn hmac_secret_name(&self) -> Option<&str> {
self.capabilities
.channel
.as_ref()
.and_then(|c| c.webhook.as_ref())
.and_then(|w| w.hmac_secret_name.as_deref())
}
pub fn webhook_secret_name(&self) -> String {
self.capabilities
.channel
.as_ref()
.and_then(|c| c.webhook.as_ref())
.and_then(|w| w.secret_name.clone())
.unwrap_or_else(|| format!("{}_webhook_secret", self.name))
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ChannelCapabilitiesSchema {
#[serde(flatten)]
pub tool: ToolCapabilitiesFile,
#[serde(default)]
pub channel: Option<ChannelSpecificCapabilitiesSchema>,
}
impl ChannelCapabilitiesSchema {
pub fn to_channel_capabilities(&self, channel_name: &str) -> ChannelCapabilities {
let tool_caps = self.tool.to_capabilities();
let mut caps =
ChannelCapabilities::for_channel(channel_name).with_tool_capabilities(tool_caps);
if let Some(channel) = &self.channel {
caps.allowed_paths = channel.allowed_paths.clone();
caps.allow_polling = channel.allow_polling;
caps.min_poll_interval_ms = channel
.min_poll_interval_ms
.unwrap_or(MIN_POLL_INTERVAL_MS)
.max(MIN_POLL_INTERVAL_MS);
if let Some(prefix) = &channel.workspace_prefix {
caps.workspace_prefix = prefix.clone();
}
if let Some(rate) = &channel.emit_rate_limit {
caps.emit_rate_limit = rate.to_emit_rate_limit();
}
if let Some(max_size) = channel.max_message_size {
caps.max_message_size = max_size;
}
if let Some(timeout_secs) = channel.callback_timeout_secs {
caps.callback_timeout = Duration::from_secs(timeout_secs);
}
}
caps
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ChannelSpecificCapabilitiesSchema {
#[serde(default)]
pub allowed_paths: Vec<String>,
#[serde(default)]
pub allow_polling: bool,
#[serde(default)]
pub min_poll_interval_ms: Option<u32>,
#[serde(default)]
pub workspace_prefix: Option<String>,
#[serde(default)]
pub emit_rate_limit: Option<EmitRateLimitSchema>,
#[serde(default)]
pub max_message_size: Option<usize>,
#[serde(default)]
pub callback_timeout_secs: Option<u64>,
#[serde(default)]
pub webhook: Option<WebhookSchema>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebhookSchema {
#[serde(default)]
pub secret_header: Option<String>,
#[serde(default)]
pub secret_name: Option<String>,
#[serde(default)]
pub signature_key_secret_name: Option<String>,
#[serde(default)]
pub hmac_secret_name: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SetupSchema {
#[serde(default)]
pub required_secrets: Vec<SecretSetupSchema>,
#[serde(default)]
pub validation_endpoint: Option<String>,
#[serde(default)]
pub setup_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecretSetupSchema {
pub name: String,
pub prompt: String,
#[serde(default)]
pub validation: Option<String>,
#[serde(default)]
pub optional: bool,
#[serde(default)]
pub auto_generate: Option<AutoGenerateSchema>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AutoGenerateSchema {
#[serde(default = "default_auto_generate_length")]
pub length: usize,
}
fn default_auto_generate_length() -> usize {
32
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmitRateLimitSchema {
#[serde(default = "default_messages_per_minute")]
pub messages_per_minute: u32,
#[serde(default = "default_messages_per_hour")]
pub messages_per_hour: u32,
}
fn default_messages_per_minute() -> u32 {
100
}
fn default_messages_per_hour() -> u32 {
5000
}
impl EmitRateLimitSchema {
fn to_emit_rate_limit(&self) -> EmitRateLimitConfig {
EmitRateLimitConfig {
messages_per_minute: self.messages_per_minute,
messages_per_hour: self.messages_per_hour,
}
}
}
impl From<RateLimitSchema> for EmitRateLimitSchema {
fn from(schema: RateLimitSchema) -> Self {
Self {
messages_per_minute: schema.requests_per_minute,
messages_per_hour: schema.requests_per_hour,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChannelConfig {
pub display_name: String,
#[serde(default)]
pub http_endpoints: Vec<HttpEndpointConfigSchema>,
#[serde(default)]
pub poll: Option<PollConfigSchema>,
}
impl Default for ChannelConfig {
fn default() -> Self {
Self {
display_name: "WASM Channel".to_string(),
http_endpoints: Vec::new(),
poll: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HttpEndpointConfigSchema {
pub path: String,
#[serde(default)]
pub methods: Vec<String>,
#[serde(default)]
pub require_secret: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PollConfigSchema {
pub interval_ms: u32,
#[serde(default)]
pub enabled: bool,
}
#[cfg(test)]
mod tests {
use crate::channels::wasm::schema::ChannelCapabilitiesFile;
#[test]
fn test_parse_minimal() {
let json = r#"{
"name": "test"
}"#;
let file = ChannelCapabilitiesFile::from_json(json).unwrap();
assert_eq!(file.name, "test");
assert_eq!(file.r#type, "channel");
}
#[test]
fn test_parse_full_slack_example() {
let json = r#"{
"type": "channel",
"name": "slack",
"description": "Slack Events API channel",
"capabilities": {
"http": {
"allowlist": [
{ "host": "slack.com", "path_prefix": "/api/" }
],
"credentials": {
"slack_bot": {
"secret_name": "slack_bot_token",
"location": { "type": "bearer" },
"host_patterns": ["slack.com"]
}
},
"rate_limit": { "requests_per_minute": 50, "requests_per_hour": 1000 }
},
"secrets": { "allowed_names": ["slack_*"] },
"channel": {
"allowed_paths": ["/webhook/slack"],
"allow_polling": false,
"emit_rate_limit": { "messages_per_minute": 100, "messages_per_hour": 5000 }
}
},
"config": {
"signing_secret_name": "slack_signing_secret"
}
}"#;
let file = ChannelCapabilitiesFile::from_json(json).unwrap();
assert_eq!(file.name, "slack");
assert_eq!(
file.description,
Some("Slack Events API channel".to_string())
);
let caps = file.to_capabilities();
assert!(caps.is_path_allowed("/webhook/slack"));
assert!(!caps.allow_polling);
assert_eq!(caps.workspace_prefix, "channels/slack/");
assert!(caps.tool_capabilities.http.is_some());
assert!(caps.tool_capabilities.secrets.is_some());
let config_json = file.config_json();
assert!(config_json.contains("signing_secret_name"));
}
#[test]
fn test_parse_with_polling() {
let json = r#"{
"name": "telegram",
"capabilities": {
"channel": {
"allowed_paths": [],
"allow_polling": true,
"min_poll_interval_ms": 60000
}
}
}"#;
let file = ChannelCapabilitiesFile::from_json(json).unwrap();
let caps = file.to_capabilities();
assert!(caps.allow_polling);
assert_eq!(caps.min_poll_interval_ms, 60000);
}
#[test]
fn test_min_poll_interval_enforced() {
let json = r#"{
"name": "test",
"capabilities": {
"channel": {
"allow_polling": true,
"min_poll_interval_ms": 1000
}
}
}"#;
let file = ChannelCapabilitiesFile::from_json(json).unwrap();
let caps = file.to_capabilities();
assert_eq!(caps.min_poll_interval_ms, 30000);
}
#[test]
fn test_workspace_prefix_override() {
let json = r#"{
"name": "custom",
"capabilities": {
"channel": {
"workspace_prefix": "integrations/custom/"
}
}
}"#;
let file = ChannelCapabilitiesFile::from_json(json).unwrap();
let caps = file.to_capabilities();
assert_eq!(caps.workspace_prefix, "integrations/custom/");
}
#[test]
fn test_emit_rate_limit() {
let json = r#"{
"name": "test",
"capabilities": {
"channel": {
"emit_rate_limit": {
"messages_per_minute": 50,
"messages_per_hour": 1000
}
}
}
}"#;
let file = ChannelCapabilitiesFile::from_json(json).unwrap();
let caps = file.to_capabilities();
assert_eq!(caps.emit_rate_limit.messages_per_minute, 50);
assert_eq!(caps.emit_rate_limit.messages_per_hour, 1000);
}
#[test]
fn test_webhook_schema() {
let json = r#"{
"name": "telegram",
"capabilities": {
"channel": {
"allowed_paths": ["/webhook/telegram"],
"webhook": {
"secret_header": "X-Telegram-Bot-Api-Secret-Token",
"secret_name": "telegram_webhook_secret"
}
}
}
}"#;
let file = ChannelCapabilitiesFile::from_json(json).unwrap();
assert_eq!(
file.webhook_secret_header(),
Some("X-Telegram-Bot-Api-Secret-Token")
);
assert_eq!(file.webhook_secret_name(), "telegram_webhook_secret");
}
#[test]
fn test_webhook_secret_name_default() {
let json = r#"{
"name": "mybot",
"capabilities": {}
}"#;
let file = ChannelCapabilitiesFile::from_json(json).unwrap();
assert_eq!(file.webhook_secret_header(), None);
assert_eq!(file.webhook_secret_name(), "mybot_webhook_secret");
}
#[test]
fn test_setup_schema() {
let json = r#"{
"name": "telegram",
"setup": {
"required_secrets": [
{
"name": "telegram_bot_token",
"prompt": "Enter your Telegram Bot Token",
"validation": "^[0-9]+:[A-Za-z0-9_-]+$"
},
{
"name": "telegram_webhook_secret",
"prompt": "Webhook secret (leave empty to auto-generate)",
"optional": true,
"auto_generate": { "length": 64 }
}
],
"validation_endpoint": "https://api.telegram.org/bot{telegram_bot_token}/getMe"
}
}"#;
let file = ChannelCapabilitiesFile::from_json(json).unwrap();
assert_eq!(file.setup.required_secrets.len(), 2);
assert_eq!(file.setup.required_secrets[0].name, "telegram_bot_token");
assert!(!file.setup.required_secrets[0].optional);
assert!(file.setup.required_secrets[1].optional);
assert_eq!(
file.setup.required_secrets[1]
.auto_generate
.as_ref()
.unwrap()
.length,
64
);
}
#[test]
fn test_validate_channel_short_prompt() {
let json = r#"{
"name": "test-channel",
"setup": {
"required_secrets": [
{ "name": "bot_token", "prompt": "Bot token" }
],
"setup_url": "https://example.com"
}
}"#;
let file = ChannelCapabilitiesFile::from_json(json).unwrap();
file.validate();
}
#[test]
fn test_validate_channel_missing_setup_url() {
let json = r#"{
"name": "test-channel",
"setup": {
"required_secrets": [
{
"name": "bot_token",
"prompt": "Enter your bot token from the developer portal settings"
}
]
}
}"#;
let file = ChannelCapabilitiesFile::from_json(json).unwrap();
file.validate();
}
#[test]
fn test_validate_clean_channel() {
let json = r#"{
"name": "good-channel",
"setup": {
"required_secrets": [
{
"name": "bot_token",
"prompt": "Enter your bot token from https://example.com/bot-settings"
}
],
"setup_url": "https://example.com/bot-settings"
}
}"#;
let file = ChannelCapabilitiesFile::from_json(json).unwrap();
file.validate();
}
#[test]
fn test_discord_capabilities_has_public_key_secret() {
let json = include_str!("../../../channels-src/discord/discord.capabilities.json");
let file = ChannelCapabilitiesFile::from_json(json).unwrap();
let secret_names: Vec<&str> = file
.setup
.required_secrets
.iter()
.map(|s| s.name.as_str())
.collect();
assert!(
secret_names.contains(&"discord_public_key"),
"discord.capabilities.json must include discord_public_key in setup.required_secrets, \
found: {:?}",
secret_names
);
}
#[test]
fn test_webhook_schema_signature_key_secret_name() {
let json = r#"{
"name": "discord",
"capabilities": {
"channel": {
"allowed_paths": ["/webhook/discord"],
"webhook": {
"signature_key_secret_name": "discord_public_key"
}
}
}
}"#;
let file = ChannelCapabilitiesFile::from_json(json).unwrap();
assert_eq!(file.signature_key_secret_name(), Some("discord_public_key"));
}
#[test]
fn test_signature_key_secret_name_none_when_missing() {
let json = r#"{
"name": "telegram",
"capabilities": {
"channel": {
"allowed_paths": ["/webhook/telegram"],
"webhook": {
"secret_header": "X-Telegram-Bot-Api-Secret-Token"
}
}
}
}"#;
let file = ChannelCapabilitiesFile::from_json(json).unwrap();
assert_eq!(file.signature_key_secret_name(), None);
}
#[test]
fn test_discord_capabilities_signature_key() {
let json = include_str!("../../../channels-src/discord/discord.capabilities.json");
let file = ChannelCapabilitiesFile::from_json(json).unwrap();
assert_eq!(
file.signature_key_secret_name(),
Some("discord_public_key"),
"discord.capabilities.json must declare signature_key_secret_name"
);
}
#[test]
fn test_discord_capabilities_secrets_allowlist() {
let json = include_str!("../../../channels-src/discord/discord.capabilities.json");
let file = ChannelCapabilitiesFile::from_json(json).unwrap();
let caps = file.to_capabilities();
let secrets_caps = caps
.tool_capabilities
.secrets
.expect("Discord should have secrets capability");
assert!(
secrets_caps.is_allowed("discord_public_key"),
"discord_public_key must be in the secrets allowlist"
);
}
}