use serde::Deserialize; use std::collections::HashMap; #[derive(Debug)] pub enum IrcCloudError { Auth(String), Network(String), Parse(String), } impl std::fmt::Display for IrcCloudError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { IrcCloudError::Auth(msg) => write!(f, "authentication failed: {msg}"), IrcCloudError::Network(msg) => write!(f, "network error: {msg}"), IrcCloudError::Parse(msg) => write!(f, "parse error: {msg}"), } } } pub struct IrcCloudServer { #[allow(dead_code)] pub cid: i64, pub hostname: String, pub port: u16, pub ssl: bool, pub name: String, pub nick: String, pub realname: String, pub server_pass: Option, pub nickserv_pass: Option, pub join_commands: Option, pub channels: Vec, } impl IrcCloudServer { pub fn addr(&self) -> String { if self.ssl { format!("ircs://{}:{}", self.hostname, self.port) } else { format!("irc+insecure://{}:{}", self.hostname, self.port) } } pub fn connect_commands(&self) -> String { let mut cmds = Vec::new(); if let Some(ref pass) = self.nickserv_pass { if !pass.is_empty() { cmds.push(format!("PRIVMSG NickServ :IDENTIFY {pass}")); } } if let Some(ref join) = self.join_commands { if !join.is_empty() { cmds.push(join.clone()); } } cmds.join("\r\n") } } #[derive(Deserialize)] struct AuthFormTokenResponse { success: bool, token: Option, } #[derive(Deserialize)] struct LoginResponse { success: bool, session: Option, message: Option, } #[derive(Deserialize)] struct StreamMessage { #[serde(rename = "type")] msg_type: Option, cid: Option, hostname: Option, port: Option, ssl: Option, name: Option, nick: Option, realname: Option, server_pass: Option, nickserv_pass: Option, join_commands: Option, buffer_type: Option, } pub async fn authenticate(email: &str, password: &str) -> Result { let client = reqwest::Client::builder() .cookie_store(true) .build() .map_err(|e| IrcCloudError::Network(e.to_string()))?; let resp = client .post("https://www.irccloud.com/chat/auth-formtoken") .send() .await .map_err(|e| IrcCloudError::Network(e.to_string()))?; let token_resp: AuthFormTokenResponse = resp .json() .await .map_err(|e| IrcCloudError::Parse(e.to_string()))?; if !token_resp.success { return Err(IrcCloudError::Auth("failed to get form token".to_string())); } let token = token_resp .token .ok_or_else(|| IrcCloudError::Auth("missing form token".to_string()))?; let resp = client .post("https://www.irccloud.com/chat/login") .header("x-auth-formtoken", &token) .form(&[ ("email", email), ("password", password), ("token", &token), ]) .send() .await .map_err(|e| IrcCloudError::Network(e.to_string()))?; let login_resp: LoginResponse = resp .json() .await .map_err(|e| IrcCloudError::Parse(e.to_string()))?; if !login_resp.success { let msg = login_resp .message .unwrap_or_else(|| "unknown error".to_string()); return Err(IrcCloudError::Auth(msg)); } login_resp .session .ok_or_else(|| IrcCloudError::Auth("missing session token".to_string())) } pub async fn fetch_state(session: &str) -> Result, IrcCloudError> { let client = reqwest::Client::new(); let resp = client .get("https://www.irccloud.com/chat/stream") .header("Cookie", format!("session={session}")) .timeout(std::time::Duration::from_secs(15)) .send() .await .map_err(|e| IrcCloudError::Network(e.to_string()))?; let body = resp .text() .await .map_err(|e| IrcCloudError::Network(e.to_string()))?; parse_stream(&body) } fn parse_stream(body: &str) -> Result, IrcCloudError> { let mut servers: HashMap = HashMap::new(); let mut channels: HashMap> = HashMap::new(); for line in body.lines() { let line = line.trim(); if line.is_empty() { continue; } let msg: StreamMessage = match serde_json::from_str(line) { Ok(m) => m, Err(_) => continue, }; match msg.msg_type.as_deref() { Some("makeserver") => { if let Some(cid) = msg.cid { servers.insert( cid, IrcCloudServer { cid, hostname: msg.hostname.unwrap_or_default(), port: msg.port.unwrap_or(6667), ssl: msg.ssl.unwrap_or(false), name: msg.name.unwrap_or_default(), nick: msg.nick.unwrap_or_default(), realname: msg.realname.unwrap_or_default(), server_pass: msg.server_pass, nickserv_pass: msg.nickserv_pass, join_commands: msg.join_commands, channels: Vec::new(), }, ); } } Some("makebuffer") => { if msg.buffer_type.as_deref() == Some("channel") { if let (Some(cid), Some(name)) = (msg.cid, msg.name) { channels.entry(cid).or_default().push(name); } } } Some("oob_include") => break, _ => {} } } for (cid, chans) in channels { if let Some(server) = servers.get_mut(&cid) { server.channels = chans; } } let mut result: Vec = servers.into_values().collect(); result.sort_by(|a, b| a.name.cmp(&b.name)); Ok(result) } #[cfg(test)] mod tests { use super::*; fn sample_stream() -> &'static str { concat!( r#"{"type":"header","idle_interval":29000}"#, "\n", r#"{"type":"makeserver","cid":1,"hostname":"irc.hackint.org","port":6697,"ssl":true,"name":"hackint","nick":"testuser","realname":"Test User","server_pass":null,"nickserv_pass":"secret123","join_commands":null}"#, "\n", r#"{"type":"makeserver","cid":2,"hostname":"irc.libera.chat","port":6667,"ssl":false,"name":"libera","nick":"testuser","realname":"Test User","server_pass":"spass","nickserv_pass":null,"join_commands":"PRIVMSG ChanServ :op #test"}"#, "\n", r#"{"type":"makebuffer","cid":1,"name":"#hackint","buffer_type":"channel"}"#, "\n", r#"{"type":"makebuffer","cid":1,"name":"#rust","buffer_type":"channel"}"#, "\n", r#"{"type":"makebuffer","cid":2,"name":"#libera","buffer_type":"channel"}"#, "\n", r#"{"type":"makebuffer","cid":1,"name":"testuser","buffer_type":"console"}"#, "\n", r#"{"type":"oob_include","url":"/chat/backlog"}"#, "\n", ) } #[test] fn parse_servers_and_channels() { let servers = parse_stream(sample_stream()).unwrap(); assert_eq!(servers.len(), 2); let hackint = servers.iter().find(|s| s.name == "hackint").unwrap(); assert_eq!(hackint.hostname, "irc.hackint.org"); assert_eq!(hackint.port, 6697); assert!(hackint.ssl); assert_eq!(hackint.channels.len(), 2); assert!(hackint.channels.contains(&"#hackint".to_string())); assert!(hackint.channels.contains(&"#rust".to_string())); let libera = servers.iter().find(|s| s.name == "libera").unwrap(); assert_eq!(libera.hostname, "irc.libera.chat"); assert_eq!(libera.port, 6667); assert!(!libera.ssl); assert_eq!(libera.channels.len(), 1); assert!(libera.channels.contains(&"#libera".to_string())); } #[test] fn addr_ssl() { let server = IrcCloudServer { cid: 1, hostname: "irc.hackint.org".to_string(), port: 6697, ssl: true, name: "hackint".to_string(), nick: "test".to_string(), realname: "Test".to_string(), server_pass: None, nickserv_pass: None, join_commands: None, channels: vec![], }; assert_eq!(server.addr(), "ircs://irc.hackint.org:6697"); } #[test] fn addr_plaintext() { let server = IrcCloudServer { cid: 1, hostname: "irc.libera.chat".to_string(), port: 6667, ssl: false, name: "libera".to_string(), nick: "test".to_string(), realname: "Test".to_string(), server_pass: None, nickserv_pass: None, join_commands: None, channels: vec![], }; assert_eq!(server.addr(), "irc+insecure://irc.libera.chat:6667"); } #[test] fn connect_commands_nickserv_only() { let server = IrcCloudServer { cid: 1, hostname: "host".to_string(), port: 6667, ssl: false, name: "test".to_string(), nick: "test".to_string(), realname: "Test".to_string(), server_pass: None, nickserv_pass: Some("secret".to_string()), join_commands: None, channels: vec![], }; assert_eq!( server.connect_commands(), "PRIVMSG NickServ :IDENTIFY secret" ); } #[test] fn connect_commands_both() { let server = IrcCloudServer { cid: 1, hostname: "host".to_string(), port: 6667, ssl: false, name: "test".to_string(), nick: "test".to_string(), realname: "Test".to_string(), server_pass: None, nickserv_pass: Some("secret".to_string()), join_commands: Some("PRIVMSG ChanServ :op #test".to_string()), channels: vec![], }; assert_eq!( server.connect_commands(), "PRIVMSG NickServ :IDENTIFY secret\r\nPRIVMSG ChanServ :op #test" ); } #[test] fn connect_commands_empty() { let server = IrcCloudServer { cid: 1, hostname: "host".to_string(), port: 6667, ssl: false, name: "test".to_string(), nick: "test".to_string(), realname: "Test".to_string(), server_pass: None, nickserv_pass: None, join_commands: None, channels: vec![], }; assert_eq!(server.connect_commands(), ""); } #[test] fn skips_console_buffers() { let servers = parse_stream(sample_stream()).unwrap(); let hackint = servers.iter().find(|s| s.name == "hackint").unwrap(); assert!(!hackint.channels.contains(&"testuser".to_string())); } #[test] fn stops_at_oob_include() { let stream = concat!( r#"{"type":"makeserver","cid":1,"hostname":"h","port":6667,"ssl":false,"name":"a","nick":"n","realname":"r"}"#, "\n", r#"{"type":"oob_include","url":"/chat/backlog"}"#, "\n", r#"{"type":"makeserver","cid":2,"hostname":"h2","port":6667,"ssl":false,"name":"b","nick":"n","realname":"r"}"#, "\n", ); let servers = parse_stream(stream).unwrap(); assert_eq!(servers.len(), 1); } #[test] fn handles_empty_stream() { let servers = parse_stream("").unwrap(); assert!(servers.is_empty()); } }