feat: add IRC channel support
Add comprehensive IRC over TLS channel implementation with: - TLS support with optional certificate verification - SASL PLAIN authentication (IRCv3) - NickServ IDENTIFY authentication - Server password support (for bouncers like ZNC) - Channel and private message (DM) support - Message splitting for IRC 512-byte line limit - UTF-8 safe splitting at character boundaries - Case-insensitive nickname allowlist - IRC style prefix for LLM responses (plain text only) - Configurable via TOML or onboard wizard All 959 tests passing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ef00cc9a66
commit
b208cc940e
4 changed files with 1226 additions and 2 deletions
1002
src/channels/irc.rs
Normal file
1002
src/channels/irc.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -2,6 +2,7 @@ pub mod cli;
|
||||||
pub mod discord;
|
pub mod discord;
|
||||||
pub mod email_channel;
|
pub mod email_channel;
|
||||||
pub mod imessage;
|
pub mod imessage;
|
||||||
|
pub mod irc;
|
||||||
pub mod matrix;
|
pub mod matrix;
|
||||||
pub mod slack;
|
pub mod slack;
|
||||||
pub mod telegram;
|
pub mod telegram;
|
||||||
|
|
@ -11,6 +12,7 @@ pub mod whatsapp;
|
||||||
pub use cli::CliChannel;
|
pub use cli::CliChannel;
|
||||||
pub use discord::DiscordChannel;
|
pub use discord::DiscordChannel;
|
||||||
pub use imessage::IMessageChannel;
|
pub use imessage::IMessageChannel;
|
||||||
|
pub use irc::IrcChannel;
|
||||||
pub use matrix::MatrixChannel;
|
pub use matrix::MatrixChannel;
|
||||||
pub use slack::SlackChannel;
|
pub use slack::SlackChannel;
|
||||||
pub use telegram::TelegramChannel;
|
pub use telegram::TelegramChannel;
|
||||||
|
|
@ -241,6 +243,7 @@ pub fn handle_command(command: crate::ChannelCommands, config: &Config) -> Resul
|
||||||
("iMessage", config.channels_config.imessage.is_some()),
|
("iMessage", config.channels_config.imessage.is_some()),
|
||||||
("Matrix", config.channels_config.matrix.is_some()),
|
("Matrix", config.channels_config.matrix.is_some()),
|
||||||
("WhatsApp", config.channels_config.whatsapp.is_some()),
|
("WhatsApp", config.channels_config.whatsapp.is_some()),
|
||||||
|
("IRC", config.channels_config.irc.is_some()),
|
||||||
] {
|
] {
|
||||||
println!(" {} {name}", if configured { "✅" } else { "❌" });
|
println!(" {} {name}", if configured { "✅" } else { "❌" });
|
||||||
}
|
}
|
||||||
|
|
@ -347,6 +350,24 @@ pub async fn doctor_channels(config: Config) -> Result<()> {
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(ref irc) = config.channels_config.irc {
|
||||||
|
channels.push((
|
||||||
|
"IRC",
|
||||||
|
Arc::new(IrcChannel::new(
|
||||||
|
irc.server.clone(),
|
||||||
|
irc.port,
|
||||||
|
irc.nickname.clone(),
|
||||||
|
irc.username.clone(),
|
||||||
|
irc.channels.clone(),
|
||||||
|
irc.allowed_users.clone(),
|
||||||
|
irc.server_password.clone(),
|
||||||
|
irc.nickserv_password.clone(),
|
||||||
|
irc.sasl_password.clone(),
|
||||||
|
irc.verify_tls.unwrap_or(true),
|
||||||
|
)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
if channels.is_empty() {
|
if channels.is_empty() {
|
||||||
println!("No real-time channels configured. Run `zeroclaw onboard` first.");
|
println!("No real-time channels configured. Run `zeroclaw onboard` first.");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
|
|
@ -514,6 +535,21 @@ pub async fn start_channels(config: Config) -> Result<()> {
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(ref irc) = config.channels_config.irc {
|
||||||
|
channels.push(Arc::new(IrcChannel::new(
|
||||||
|
irc.server.clone(),
|
||||||
|
irc.port,
|
||||||
|
irc.nickname.clone(),
|
||||||
|
irc.username.clone(),
|
||||||
|
irc.channels.clone(),
|
||||||
|
irc.allowed_users.clone(),
|
||||||
|
irc.server_password.clone(),
|
||||||
|
irc.nickserv_password.clone(),
|
||||||
|
irc.sasl_password.clone(),
|
||||||
|
irc.verify_tls.unwrap_or(true),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
if channels.is_empty() {
|
if channels.is_empty() {
|
||||||
println!("No channels configured. Run `zeroclaw onboard` to set up channels.");
|
println!("No channels configured. Run `zeroclaw onboard` to set up channels.");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
|
|
|
||||||
|
|
@ -537,6 +537,7 @@ pub struct ChannelsConfig {
|
||||||
pub imessage: Option<IMessageConfig>,
|
pub imessage: Option<IMessageConfig>,
|
||||||
pub matrix: Option<MatrixConfig>,
|
pub matrix: Option<MatrixConfig>,
|
||||||
pub whatsapp: Option<WhatsAppConfig>,
|
pub whatsapp: Option<WhatsAppConfig>,
|
||||||
|
pub irc: Option<IrcConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ChannelsConfig {
|
impl Default for ChannelsConfig {
|
||||||
|
|
@ -550,6 +551,7 @@ impl Default for ChannelsConfig {
|
||||||
imessage: None,
|
imessage: None,
|
||||||
matrix: None,
|
matrix: None,
|
||||||
whatsapp: None,
|
whatsapp: None,
|
||||||
|
irc: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -612,6 +614,37 @@ pub struct WhatsAppConfig {
|
||||||
pub allowed_numbers: Vec<String>,
|
pub allowed_numbers: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct IrcConfig {
|
||||||
|
/// IRC server hostname
|
||||||
|
pub server: String,
|
||||||
|
/// IRC server port (default: 6697 for TLS)
|
||||||
|
#[serde(default = "default_irc_port")]
|
||||||
|
pub port: u16,
|
||||||
|
/// Bot nickname
|
||||||
|
pub nickname: String,
|
||||||
|
/// Username (defaults to nickname if not set)
|
||||||
|
pub username: Option<String>,
|
||||||
|
/// Channels to join on connect
|
||||||
|
#[serde(default)]
|
||||||
|
pub channels: Vec<String>,
|
||||||
|
/// Allowed nicknames (case-insensitive) or "*" for all
|
||||||
|
#[serde(default)]
|
||||||
|
pub allowed_users: Vec<String>,
|
||||||
|
/// Server password (for bouncers like ZNC)
|
||||||
|
pub server_password: Option<String>,
|
||||||
|
/// NickServ IDENTIFY password
|
||||||
|
pub nickserv_password: Option<String>,
|
||||||
|
/// SASL PLAIN password (IRCv3)
|
||||||
|
pub sasl_password: Option<String>,
|
||||||
|
/// Verify TLS certificate (default: true)
|
||||||
|
pub verify_tls: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_irc_port() -> u16 {
|
||||||
|
6697
|
||||||
|
}
|
||||||
|
|
||||||
// ── Config impl ──────────────────────────────────────────────────
|
// ── Config impl ──────────────────────────────────────────────────
|
||||||
|
|
||||||
impl Default for Config {
|
impl Default for Config {
|
||||||
|
|
@ -847,6 +880,7 @@ mod tests {
|
||||||
imessage: None,
|
imessage: None,
|
||||||
matrix: None,
|
matrix: None,
|
||||||
whatsapp: None,
|
whatsapp: None,
|
||||||
|
irc: None,
|
||||||
},
|
},
|
||||||
memory: MemoryConfig::default(),
|
memory: MemoryConfig::default(),
|
||||||
tunnel: TunnelConfig::default(),
|
tunnel: TunnelConfig::default(),
|
||||||
|
|
@ -1059,6 +1093,7 @@ default_temperature = 0.7
|
||||||
allowed_users: vec!["@u:m".into()],
|
allowed_users: vec!["@u:m".into()],
|
||||||
}),
|
}),
|
||||||
whatsapp: None,
|
whatsapp: None,
|
||||||
|
irc: None,
|
||||||
};
|
};
|
||||||
let toml_str = toml::to_string_pretty(&c).unwrap();
|
let toml_str = toml::to_string_pretty(&c).unwrap();
|
||||||
let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap();
|
let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap();
|
||||||
|
|
@ -1215,6 +1250,7 @@ channel_id = "C123"
|
||||||
app_secret: None,
|
app_secret: None,
|
||||||
allowed_numbers: vec!["+1".into()],
|
allowed_numbers: vec!["+1".into()],
|
||||||
}),
|
}),
|
||||||
|
irc: None,
|
||||||
};
|
};
|
||||||
let toml_str = toml::to_string_pretty(&c).unwrap();
|
let toml_str = toml::to_string_pretty(&c).unwrap();
|
||||||
let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap();
|
let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap();
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use crate::config::schema::WhatsAppConfig;
|
use crate::config::schema::{IrcConfig, WhatsAppConfig};
|
||||||
use crate::config::{
|
use crate::config::{
|
||||||
AutonomyConfig, BrowserConfig, ChannelsConfig, ComposioConfig, Config, DiscordConfig,
|
AutonomyConfig, BrowserConfig, ChannelsConfig, ComposioConfig, Config, DiscordConfig,
|
||||||
HeartbeatConfig, IMessageConfig, MatrixConfig, MemoryConfig, ObservabilityConfig,
|
HeartbeatConfig, IMessageConfig, MatrixConfig, MemoryConfig, ObservabilityConfig,
|
||||||
|
|
@ -1114,6 +1114,7 @@ fn setup_channels() -> Result<ChannelsConfig> {
|
||||||
imessage: None,
|
imessage: None,
|
||||||
matrix: None,
|
matrix: None,
|
||||||
whatsapp: None,
|
whatsapp: None,
|
||||||
|
irc: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
|
|
@ -1166,6 +1167,14 @@ fn setup_channels() -> Result<ChannelsConfig> {
|
||||||
"— Business Cloud API"
|
"— Business Cloud API"
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
format!(
|
||||||
|
"IRC {}",
|
||||||
|
if config.irc.is_some() {
|
||||||
|
"✅ configured"
|
||||||
|
} else {
|
||||||
|
"— IRC over TLS"
|
||||||
|
}
|
||||||
|
),
|
||||||
format!(
|
format!(
|
||||||
"Webhook {}",
|
"Webhook {}",
|
||||||
if config.webhook.is_some() {
|
if config.webhook.is_some() {
|
||||||
|
|
@ -1180,7 +1189,7 @@ fn setup_channels() -> Result<ChannelsConfig> {
|
||||||
let choice = Select::new()
|
let choice = Select::new()
|
||||||
.with_prompt(" Connect a channel (or Done to continue)")
|
.with_prompt(" Connect a channel (or Done to continue)")
|
||||||
.items(&options)
|
.items(&options)
|
||||||
.default(7)
|
.default(8)
|
||||||
.interact()?;
|
.interact()?;
|
||||||
|
|
||||||
match choice {
|
match choice {
|
||||||
|
|
@ -1687,6 +1696,144 @@ fn setup_channels() -> Result<ChannelsConfig> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
6 => {
|
6 => {
|
||||||
|
// ── IRC ──
|
||||||
|
println!();
|
||||||
|
println!(
|
||||||
|
" {} {}",
|
||||||
|
style("IRC Setup").white().bold(),
|
||||||
|
style("— IRC over TLS").dim()
|
||||||
|
);
|
||||||
|
print_bullet("IRC connects over TLS to any IRC server");
|
||||||
|
print_bullet("Supports SASL PLAIN and NickServ authentication");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
let server: String = Input::new()
|
||||||
|
.with_prompt(" IRC server (hostname)")
|
||||||
|
.interact_text()?;
|
||||||
|
|
||||||
|
if server.trim().is_empty() {
|
||||||
|
println!(" {} Skipped", style("→").dim());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let port_str: String = Input::new()
|
||||||
|
.with_prompt(" Port")
|
||||||
|
.default("6697".into())
|
||||||
|
.interact_text()?;
|
||||||
|
|
||||||
|
let port: u16 = match port_str.trim().parse() {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(_) => {
|
||||||
|
println!(" {} Invalid port, using 6697", style("→").dim());
|
||||||
|
6697
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let nickname: String = Input::new()
|
||||||
|
.with_prompt(" Bot nickname")
|
||||||
|
.interact_text()?;
|
||||||
|
|
||||||
|
if nickname.trim().is_empty() {
|
||||||
|
println!(" {} Skipped — nickname required", style("→").dim());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let channels_str: String = Input::new()
|
||||||
|
.with_prompt(" Channels to join (comma-separated: #channel1,#channel2)")
|
||||||
|
.allow_empty(true)
|
||||||
|
.interact_text()?;
|
||||||
|
|
||||||
|
let channels = if channels_str.trim().is_empty() {
|
||||||
|
vec![]
|
||||||
|
} else {
|
||||||
|
channels_str
|
||||||
|
.split(',')
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
print_bullet(
|
||||||
|
"Allowlist nicknames that can interact with the bot (case-insensitive).",
|
||||||
|
);
|
||||||
|
print_bullet("Use '*' to allow anyone (not recommended for production).");
|
||||||
|
|
||||||
|
let users_str: String = Input::new()
|
||||||
|
.with_prompt(" Allowed nicknames (comma-separated, or * for all)")
|
||||||
|
.allow_empty(true)
|
||||||
|
.interact_text()?;
|
||||||
|
|
||||||
|
let allowed_users = if users_str.trim() == "*" {
|
||||||
|
vec!["*".into()]
|
||||||
|
} else {
|
||||||
|
users_str
|
||||||
|
.split(',')
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
if allowed_users.is_empty() {
|
||||||
|
print_bullet("⚠️ Empty allowlist — only you can interact. Add nicknames above.");
|
||||||
|
}
|
||||||
|
|
||||||
|
println!();
|
||||||
|
print_bullet("Optional authentication (press Enter to skip each):");
|
||||||
|
|
||||||
|
let server_password: String = Input::new()
|
||||||
|
.with_prompt(" Server password (for bouncers like ZNC, leave empty if none)")
|
||||||
|
.allow_empty(true)
|
||||||
|
.interact_text()?;
|
||||||
|
|
||||||
|
let nickserv_password: String = Input::new()
|
||||||
|
.with_prompt(" NickServ password (leave empty if none)")
|
||||||
|
.allow_empty(true)
|
||||||
|
.interact_text()?;
|
||||||
|
|
||||||
|
let sasl_password: String = Input::new()
|
||||||
|
.with_prompt(" SASL PLAIN password (leave empty if none)")
|
||||||
|
.allow_empty(true)
|
||||||
|
.interact_text()?;
|
||||||
|
|
||||||
|
let verify_tls: bool = Confirm::new()
|
||||||
|
.with_prompt(" Verify TLS certificate?")
|
||||||
|
.default(true)
|
||||||
|
.interact()?;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
" {} IRC configured as {}@{}:{}",
|
||||||
|
style("✅").green().bold(),
|
||||||
|
style(&nickname).cyan(),
|
||||||
|
style(&server).cyan(),
|
||||||
|
style(port).cyan()
|
||||||
|
);
|
||||||
|
|
||||||
|
config.irc = Some(IrcConfig {
|
||||||
|
server: server.trim().to_string(),
|
||||||
|
port,
|
||||||
|
nickname: nickname.trim().to_string(),
|
||||||
|
username: None,
|
||||||
|
channels,
|
||||||
|
allowed_users,
|
||||||
|
server_password: if server_password.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(server_password.trim().to_string())
|
||||||
|
},
|
||||||
|
nickserv_password: if nickserv_password.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(nickserv_password.trim().to_string())
|
||||||
|
},
|
||||||
|
sasl_password: if sasl_password.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(sasl_password.trim().to_string())
|
||||||
|
},
|
||||||
|
verify_tls: Some(verify_tls),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
7 => {
|
||||||
// ── Webhook ──
|
// ── Webhook ──
|
||||||
println!();
|
println!();
|
||||||
println!(
|
println!(
|
||||||
|
|
@ -1744,6 +1891,9 @@ fn setup_channels() -> Result<ChannelsConfig> {
|
||||||
if config.whatsapp.is_some() {
|
if config.whatsapp.is_some() {
|
||||||
active.push("WhatsApp");
|
active.push("WhatsApp");
|
||||||
}
|
}
|
||||||
|
if config.irc.is_some() {
|
||||||
|
active.push("IRC");
|
||||||
|
}
|
||||||
if config.webhook.is_some() {
|
if config.webhook.is_some() {
|
||||||
active.push("Webhook");
|
active.push("Webhook");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue