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:
Argenis 2026-02-15 10:00:15 -05:00 committed by GitHub
parent ef00cc9a66
commit b208cc940e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 1226 additions and 2 deletions

1002
src/channels/irc.rs Normal file

File diff suppressed because it is too large Load diff

View file

@ -2,6 +2,7 @@ pub mod cli;
pub mod discord;
pub mod email_channel;
pub mod imessage;
pub mod irc;
pub mod matrix;
pub mod slack;
pub mod telegram;
@ -11,6 +12,7 @@ pub mod whatsapp;
pub use cli::CliChannel;
pub use discord::DiscordChannel;
pub use imessage::IMessageChannel;
pub use irc::IrcChannel;
pub use matrix::MatrixChannel;
pub use slack::SlackChannel;
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()),
("Matrix", config.channels_config.matrix.is_some()),
("WhatsApp", config.channels_config.whatsapp.is_some()),
("IRC", config.channels_config.irc.is_some()),
] {
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() {
println!("No real-time channels configured. Run `zeroclaw onboard` first.");
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() {
println!("No channels configured. Run `zeroclaw onboard` to set up channels.");
return Ok(());

View file

@ -537,6 +537,7 @@ pub struct ChannelsConfig {
pub imessage: Option<IMessageConfig>,
pub matrix: Option<MatrixConfig>,
pub whatsapp: Option<WhatsAppConfig>,
pub irc: Option<IrcConfig>,
}
impl Default for ChannelsConfig {
@ -550,6 +551,7 @@ impl Default for ChannelsConfig {
imessage: None,
matrix: None,
whatsapp: None,
irc: None,
}
}
}
@ -612,6 +614,37 @@ pub struct WhatsAppConfig {
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 ──────────────────────────────────────────────────
impl Default for Config {
@ -847,6 +880,7 @@ mod tests {
imessage: None,
matrix: None,
whatsapp: None,
irc: None,
},
memory: MemoryConfig::default(),
tunnel: TunnelConfig::default(),
@ -1059,6 +1093,7 @@ default_temperature = 0.7
allowed_users: vec!["@u:m".into()],
}),
whatsapp: None,
irc: None,
};
let toml_str = toml::to_string_pretty(&c).unwrap();
let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap();
@ -1215,6 +1250,7 @@ channel_id = "C123"
app_secret: None,
allowed_numbers: vec!["+1".into()],
}),
irc: None,
};
let toml_str = toml::to_string_pretty(&c).unwrap();
let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap();

View file

@ -1,4 +1,4 @@
use crate::config::schema::WhatsAppConfig;
use crate::config::schema::{IrcConfig, WhatsAppConfig};
use crate::config::{
AutonomyConfig, BrowserConfig, ChannelsConfig, ComposioConfig, Config, DiscordConfig,
HeartbeatConfig, IMessageConfig, MatrixConfig, MemoryConfig, ObservabilityConfig,
@ -1114,6 +1114,7 @@ fn setup_channels() -> Result<ChannelsConfig> {
imessage: None,
matrix: None,
whatsapp: None,
irc: None,
};
loop {
@ -1166,6 +1167,14 @@ fn setup_channels() -> Result<ChannelsConfig> {
"— Business Cloud API"
}
),
format!(
"IRC {}",
if config.irc.is_some() {
"✅ configured"
} else {
"— IRC over TLS"
}
),
format!(
"Webhook {}",
if config.webhook.is_some() {
@ -1180,7 +1189,7 @@ fn setup_channels() -> Result<ChannelsConfig> {
let choice = Select::new()
.with_prompt(" Connect a channel (or Done to continue)")
.items(&options)
.default(7)
.default(8)
.interact()?;
match choice {
@ -1687,6 +1696,144 @@ fn setup_channels() -> Result<ChannelsConfig> {
});
}
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 ──
println!();
println!(
@ -1744,6 +1891,9 @@ fn setup_channels() -> Result<ChannelsConfig> {
if config.whatsapp.is_some() {
active.push("WhatsApp");
}
if config.irc.is_some() {
active.push("IRC");
}
if config.webhook.is_some() {
active.push("Webhook");
}