use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::config::ChannelsConfig;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub enum ChannelAuthMode {
#[serde(rename = "api_key")]
ApiKey,
#[serde(rename = "bot_token")]
BotToken,
#[serde(rename = "oauth")]
OAuth,
#[serde(rename = "managed_dm")]
ManagedDm,
}
impl std::fmt::Display for ChannelAuthMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ApiKey => write!(f, "api_key"),
Self::BotToken => write!(f, "bot_token"),
Self::OAuth => write!(f, "oauth"),
Self::ManagedDm => write!(f, "managed_dm"),
}
}
}
impl std::str::FromStr for ChannelAuthMode {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"api_key" => Ok(Self::ApiKey),
"bot_token" => Ok(Self::BotToken),
"oauth" => Ok(Self::OAuth),
"managed_dm" => Ok(Self::ManagedDm),
other => Err(format!("unknown auth mode: {other}")),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct FieldRequirement {
pub key: &'static str,
pub label: &'static str,
pub field_type: &'static str,
pub required: bool,
pub placeholder: &'static str,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_bool: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct AuthModeSpec {
pub mode: ChannelAuthMode,
pub description: &'static str,
pub fields: Vec<FieldRequirement>,
#[serde(skip_serializing_if = "Option::is_none")]
pub auth_action: Option<&'static str>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ChannelCapability {
SendText,
SendRichText,
ReceiveText,
Typing,
DraftUpdates,
ThreadedReplies,
FileAttachments,
Reactions,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ChannelDefinition {
pub id: &'static str,
pub display_name: &'static str,
pub description: &'static str,
pub icon: &'static str,
pub auth_modes: Vec<AuthModeSpec>,
pub capabilities: Vec<ChannelCapability>,
}
impl ChannelDefinition {
pub fn auth_mode_spec(&self, mode: ChannelAuthMode) -> Option<&AuthModeSpec> {
self.auth_modes.iter().find(|s| s.mode == mode)
}
pub fn validate_credentials(
&self,
mode: ChannelAuthMode,
credentials: &serde_json::Map<String, serde_json::Value>,
) -> Result<(), String> {
let spec = self.auth_mode_spec(mode).ok_or_else(|| {
format!(
"channel '{}' does not support auth mode '{}'",
self.id, mode
)
})?;
let missing: Vec<&str> = spec
.fields
.iter()
.filter(|f| f.required)
.filter(|f| {
credentials
.get(f.key)
.is_none_or(|v| v.as_str().is_some_and(|s| s.is_empty()))
})
.map(|f| f.key)
.collect();
if missing.is_empty() {
Ok(())
} else {
Err(format!(
"missing required fields for {}.{}: {}",
self.id,
mode,
missing.join(", ")
))
}
}
}
pub fn all_channel_definitions() -> Vec<ChannelDefinition> {
vec![
telegram_definition(),
discord_definition(),
web_definition(),
imessage_definition(),
lark_definition(),
dingtalk_definition(),
email_definition(),
yuanbao_definition(),
]
}
pub fn find_channel_definition(channel_id: &str) -> Option<ChannelDefinition> {
all_channel_definitions()
.into_iter()
.find(|d| d.id == channel_id)
}
pub fn channel_config_connected(
channels: &ChannelsConfig,
channel_id: &str,
mode: ChannelAuthMode,
) -> bool {
match (channel_id, mode) {
("telegram", ChannelAuthMode::BotToken) => channels.telegram.is_some(),
("discord", ChannelAuthMode::BotToken) => channels.discord.is_some(),
("slack", _) => channels.slack.is_some(),
("mattermost", _) => channels.mattermost.is_some(),
("imessage", ChannelAuthMode::ManagedDm) => channels.imessage.is_some(),
("matrix", _) => channels.matrix.is_some(),
("signal", _) => channels.signal.is_some(),
("whatsapp", _) => channels.whatsapp.is_some(),
("linq", _) => channels.linq.is_some(),
("email", _) => channels.email.is_some(),
("irc", _) => channels.irc.is_some(),
("lark", _) => channels.lark.is_some(),
("dingtalk", _) => channels.dingtalk.is_some(),
("qq", _) => channels.qq.is_some(),
("yuanbao", ChannelAuthMode::ApiKey) => channels.yuanbao.is_some(),
_ => false,
}
}
fn telegram_definition() -> ChannelDefinition {
ChannelDefinition {
id: "telegram",
display_name: "Telegram",
description: "Send and receive messages via Telegram.",
icon: "telegram",
auth_modes: vec![
AuthModeSpec {
mode: ChannelAuthMode::ManagedDm,
description: "Message the OpenHuman Telegram bot directly.",
fields: vec![],
auth_action: Some("telegram_managed_dm"),
},
AuthModeSpec {
mode: ChannelAuthMode::BotToken,
description: "Provide your own Telegram Bot token from @BotFather.",
fields: vec![
FieldRequirement {
key: "bot_token",
label: "Bot Token",
field_type: "secret",
required: true,
placeholder: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11",
default_bool: None,
},
FieldRequirement {
key: "chat_id",
label: "Chat ID",
field_type: "string",
required: false,
placeholder: "Optional: default chat for outbound messages",
default_bool: None,
},
FieldRequirement {
key: "allowed_users",
label: "Allowed Users",
field_type: "string",
required: false,
placeholder: "Comma-separated Telegram usernames",
default_bool: None,
},
],
auth_action: None,
},
],
capabilities: vec![
ChannelCapability::SendText,
ChannelCapability::ReceiveText,
ChannelCapability::Typing,
ChannelCapability::DraftUpdates,
],
}
}
fn discord_definition() -> ChannelDefinition {
ChannelDefinition {
id: "discord",
display_name: "Discord",
description: "Send and receive messages via Discord.",
icon: "discord",
auth_modes: vec![
AuthModeSpec {
mode: ChannelAuthMode::BotToken,
description: "Provide your own Discord bot token.",
fields: vec![
FieldRequirement {
key: "bot_token",
label: "Bot Token",
field_type: "secret",
required: true,
placeholder: "Your Discord bot token",
default_bool: None,
},
FieldRequirement {
key: "guild_id",
label: "Server (Guild) ID",
field_type: "string",
required: false,
placeholder: "Optional: restrict to a specific server",
default_bool: None,
},
FieldRequirement {
key: "channel_id",
label: "Channel ID",
field_type: "string",
required: false,
placeholder: "Optional: default channel for outbound messages",
default_bool: None,
},
FieldRequirement {
key: "allowed_users",
label: "Allowed Users",
field_type: "string",
required: false,
placeholder: "Comma-separated Discord user IDs, or * for everyone (blank = everyone)",
default_bool: None,
},
],
auth_action: None,
},
AuthModeSpec {
mode: ChannelAuthMode::OAuth,
description: "Install the OpenHuman bot to your Discord server via OAuth.",
fields: vec![],
auth_action: Some("discord_oauth"),
},
AuthModeSpec {
mode: ChannelAuthMode::ManagedDm,
description: "Link your personal Discord account to the OpenHuman bot.",
fields: vec![],
auth_action: Some("discord_managed_link"),
},
],
capabilities: vec![
ChannelCapability::SendText,
ChannelCapability::ReceiveText,
ChannelCapability::Typing,
ChannelCapability::ThreadedReplies,
],
}
}
fn web_definition() -> ChannelDefinition {
ChannelDefinition {
id: "web",
display_name: "Web",
description: "Chat via the built-in web UI.",
icon: "web",
auth_modes: vec![AuthModeSpec {
mode: ChannelAuthMode::ManagedDm,
description: "Use the embedded web chat — no setup required.",
fields: vec![],
auth_action: None,
}],
capabilities: vec![
ChannelCapability::SendText,
ChannelCapability::SendRichText,
ChannelCapability::ReceiveText,
],
}
}
fn imessage_definition() -> ChannelDefinition {
ChannelDefinition {
id: "imessage",
display_name: "iMessage",
description: "Send and receive via macOS Messages (local, AppleScript bridge).",
icon: "imessage",
auth_modes: vec![AuthModeSpec {
mode: ChannelAuthMode::ManagedDm,
description: "Local-only — no credentials. Grant Full Disk Access to OpenHuman.",
fields: vec![FieldRequirement {
key: "allowed_contacts",
label: "Allowed Contacts",
field_type: "string",
required: false,
placeholder: "Comma-separated phone numbers or emails; * to allow any",
default_bool: None,
}],
auth_action: None,
}],
capabilities: vec![ChannelCapability::SendText, ChannelCapability::ReceiveText],
}
}
fn lark_definition() -> ChannelDefinition {
ChannelDefinition {
id: "lark",
display_name: "Lark / Feishu",
description: "Send and receive via Lark (international) or Feishu (中国版).",
icon: "lark",
auth_modes: vec![AuthModeSpec {
mode: ChannelAuthMode::ApiKey,
description: "Provide your Lark/Feishu app credentials from the Open Platform.",
fields: vec![
FieldRequirement {
key: "app_id",
label: "App ID",
field_type: "string",
required: true,
placeholder: "cli_xxxxxxxxxxxx",
default_bool: None,
},
FieldRequirement {
key: "app_secret",
label: "App Secret",
field_type: "secret",
required: true,
placeholder: "Your Lark app secret",
default_bool: None,
},
FieldRequirement {
key: "encrypt_key",
label: "Encrypt Key",
field_type: "secret",
required: false,
placeholder: "Optional — required only if you enabled message encryption",
default_bool: None,
},
FieldRequirement {
key: "verification_token",
label: "Verification Token",
field_type: "secret",
required: false,
placeholder: "Optional — used for HTTP webhook verification",
default_bool: None,
},
FieldRequirement {
key: "use_feishu",
label: "Use Feishu (中国版)",
field_type: "boolean",
required: false,
placeholder: "On = open.feishu.cn (China); off = open.larksuite.com",
default_bool: None,
},
FieldRequirement {
key: "receive_mode",
label: "Receive Mode",
field_type: "string",
required: false,
placeholder: "websocket (default) or webhook",
default_bool: None,
},
FieldRequirement {
key: "port",
label: "Webhook Port",
field_type: "string",
required: false,
placeholder: "Optional — local HTTP port when receive_mode = webhook (e.g. 8080)",
default_bool: None,
},
FieldRequirement {
key: "allowed_users",
label: "Allowed Users",
field_type: "string",
required: false,
placeholder: "Comma-separated open_id / union_id; leave empty to allow any",
default_bool: None,
},
],
auth_action: None,
}],
capabilities: vec![
ChannelCapability::SendText,
ChannelCapability::ReceiveText,
ChannelCapability::ThreadedReplies,
],
}
}
fn dingtalk_definition() -> ChannelDefinition {
ChannelDefinition {
id: "dingtalk",
display_name: "DingTalk (钉钉)",
description: "Send and receive via DingTalk Stream Mode (钉钉).",
icon: "dingtalk",
auth_modes: vec![AuthModeSpec {
mode: ChannelAuthMode::ApiKey,
description: "Provide your DingTalk app credentials from the developer console.",
fields: vec![
FieldRequirement {
key: "client_id",
label: "Client ID (AppKey)",
field_type: "string",
required: true,
placeholder: "ding_xxxxxxxxxxxx",
default_bool: None,
},
FieldRequirement {
key: "client_secret",
label: "Client Secret (AppSecret)",
field_type: "secret",
required: true,
placeholder: "Your DingTalk app secret",
default_bool: None,
},
FieldRequirement {
key: "allowed_users",
label: "Allowed Users",
field_type: "string",
required: false,
placeholder: "Comma-separated DingTalk userIds; leave empty to allow any",
default_bool: None,
},
],
auth_action: None,
}],
capabilities: vec![ChannelCapability::SendText, ChannelCapability::ReceiveText],
}
}
fn email_definition() -> ChannelDefinition {
ChannelDefinition {
id: "email",
display_name: "Email (IMAP/SMTP)",
description: "Send and receive email via any standard IMAP/SMTP mailbox.",
icon: "email",
auth_modes: vec![AuthModeSpec {
mode: ChannelAuthMode::ApiKey,
description: "Provide your mailbox's IMAP/SMTP server settings and an app password.",
fields: vec![
FieldRequirement {
key: "imap_host",
label: "IMAP Host",
field_type: "string",
required: true,
placeholder: "imap.fastmail.com",
default_bool: None,
},
FieldRequirement {
key: "imap_port",
label: "IMAP Port",
field_type: "string",
required: false,
placeholder: "993 (TLS)",
default_bool: None,
},
FieldRequirement {
key: "username",
label: "Email Address",
field_type: "string",
required: true,
placeholder: "you@example.com",
default_bool: None,
},
FieldRequirement {
key: "password",
label: "Password / App Password",
field_type: "secret",
required: true,
placeholder: "App-specific password (recommended)",
default_bool: None,
},
FieldRequirement {
key: "smtp_host",
label: "SMTP Host",
field_type: "string",
required: true,
placeholder: "smtp.fastmail.com",
default_bool: None,
},
FieldRequirement {
key: "smtp_port",
label: "SMTP Port",
field_type: "string",
required: false,
placeholder: "465 (TLS)",
default_bool: None,
},
FieldRequirement {
key: "smtp_tls",
label: "Use TLS for SMTP",
field_type: "boolean",
required: false,
placeholder: "On = TLS (recommended)",
default_bool: Some(true),
},
FieldRequirement {
key: "from_address",
label: "From Address",
field_type: "string",
required: false,
placeholder: "Optional — defaults to the email address above",
default_bool: None,
},
FieldRequirement {
key: "imap_folder",
label: "IMAP Folder",
field_type: "string",
required: false,
placeholder: "Optional — defaults to INBOX",
default_bool: None,
},
FieldRequirement {
key: "allowed_senders",
label: "Allowed Senders",
field_type: "string",
required: false,
placeholder: "Comma-separated addresses or @domain; * to allow any",
default_bool: None,
},
],
auth_action: None,
}],
capabilities: vec![
ChannelCapability::SendText,
ChannelCapability::ReceiveText,
ChannelCapability::FileAttachments,
],
}
}
fn yuanbao_definition() -> ChannelDefinition {
ChannelDefinition {
id: "yuanbao",
display_name: "元宝",
description: "通过元宝(Yuanbao)机器人收发消息。",
icon: "yuanbao",
auth_modes: vec![AuthModeSpec {
mode: ChannelAuthMode::ApiKey,
description: "提供元宝开放平台的 AppID 和 AppSecret。",
fields: vec![
FieldRequirement {
key: "app_key",
label: "AppID",
field_type: "string",
required: true,
placeholder: "元宝开放平台 AppID",
default_bool: None,
},
FieldRequirement {
key: "app_secret",
label: "AppSecret",
field_type: "secret",
required: true,
placeholder: "元宝开放平台 AppSecret",
default_bool: None,
},
],
auth_action: None,
}],
capabilities: vec![
ChannelCapability::SendText,
ChannelCapability::ReceiveText,
ChannelCapability::Typing,
],
}
}
#[cfg(test)]
#[path = "definitions_tests.rs"]
mod tests;