// atirc: read and post lines on an atirc channel (irc.fedfork.com) as the signed-in account. // // Calls carry `proxy`, so the host sends them to the user's PDS with the atproto-proxy // header; the PDS forwards them to atirc with a service-auth JWT (aud = PROXY, lxm = nsid). // read -> chat.atirc.list (paged by seq) -> the last `limit` messages and the cursor to poll from // post -> chat.atirc.post -> the delivered message // irc_post -> com.fedfork.exec.iroh.connect (host pseudo-call, alpn atirc/irc/1) -> one line // posted over IRC on atirc's iroh endpoint, authenticated by an atirc token use abi::records::exec::iroh; use abi::{Call, Did, Event, Handle, Run, entries}; use serde::Deserialize; use serde_json::{Value, json}; const PROXY: &str = "did:web:irc.fedfork.com#atirc"; const PAGE: usize = 200; // Pages read in one run. `more` tells the caller to read on. const MAX_PAGES: u64 = 6; const IROH: &str = "com.fedfork.exec.iroh.connect"; const IRC_ALPN: &str = "atirc/irc/1"; #[derive(Deserialize)] struct Args { #[allow(dead_code)] did: Did, #[serde(default)] handle: Option, #[serde(default)] channel: Option, #[serde(default)] cursor: Option, #[serde(default)] limit: Option, #[serde(default)] text: Option, #[serde(default)] nick: Option, #[serde(default)] token: Option, } // atirc's own default: "#" + handle, '.' and whitespace to '_', lowercased, 64 bytes max. fn handle_channel(handle: &str) -> String { let flat: String = handle.chars().take(63).map(|c| if c == '.' || c == ',' || c.is_whitespace() { '_' } else { c }).collect(); format!("#{}", flat.to_ascii_lowercase()) } fn channel(args: &Args) -> Result, String> { match args.channel.as_deref().map(str::trim).filter(|c| !c.is_empty()) { Some(c) if c.starts_with('#') && c.len() > 1 && c.len() <= 64 && !c.contains(|ch: char| ch.is_whitespace() || ch == ',' || ch == '\0') => { Ok(Some(c.to_string())) } Some(_) => Err("channel must be #name, at most 64 bytes, no spaces or commas".into()), None => Ok(None), } } fn list(channel: &str, cursor: u64) -> Call { Call::new("page", "chat.atirc.list").params(json!({ "channel": channel, "limit": PAGE.to_string(), "cursor": cursor.to_string() })).proxy(PROXY) } // A full page means more may follow: read on, keeping only the last `limit` messages. fn read_handler(run: Run) -> Result { let Run { event, state, args } = run; Ok(match event { Event::Start => { let channel = match channel(&args)? { Some(channel) => channel, None => handle_channel(args.handle.as_deref().filter(|h| h.contains('.')).ok_or("channel or handle is required")?), }; let limit = args.limit.unwrap_or(50).clamp(1, PAGE); let cursor = args.cursor.unwrap_or(0); Handle::new().call(list(&channel, cursor)).state(json!({ "channel": channel, "limit": limit, "pages": 1, "messages": [] })) } Event::Result { result, .. } => { let output = result.output()?; let page = output["messages"].as_array().cloned().unwrap_or_default(); let cursor = output["cursor"].as_u64().ok_or("list returned no cursor")?; let channel = state["channel"].as_str().ok_or("missing channel")?; let limit = state["limit"].as_u64().unwrap_or(50) as usize; let pages = state["pages"].as_u64().unwrap_or(1); let more = page.len() >= PAGE; let mut messages = state["messages"].as_array().cloned().unwrap_or_default(); messages.extend(page); let messages = messages.split_off(messages.len().saturating_sub(limit)); if more && pages < MAX_PAGES { return Ok(Handle::new().call(list(channel, cursor)).state(json!({ "channel": channel, "limit": limit, "pages": pages + 1, "messages": messages }))); } Handle::new().result(json!({ "channel": channel, "messages": messages, "cursor": cursor, "more": more })).done() } _ => Handle::new(), }) } // No channel: atirc picks the account's handle channel (or its owner's, for an agent account). fn post_handler(run: Run) -> Result { let args = run.args; Ok(match run.event { Event::Start => { let text = args.text.as_deref().unwrap_or_default().trim(); if text.is_empty() || text.len() > 4000 || text.contains('\0') { return Err("text is required, at most 4000 bytes".into()); } let mut input = json!({ "text": text }); if let Some(channel) = channel(&args)? { input["channel"] = json!(channel); } if let Some(nick) = args.nick.as_deref().map(str::trim).filter(|n| !n.is_empty()) { input["nick"] = json!(nick); } Handle::new().call(Call::new("post", "chat.atirc.post").input(input).proxy(PROXY)) } Event::Result { result, .. } => Handle::new().result(result.output()?.clone()).done(), _ => Handle::new(), }) } // A JWT's claims, unverified: atirc checks the token; here it only names the ticket and nick. fn claims(token: &str) -> Result { let payload = token.split('.').nth(1).ok_or("token must be a JWT")?; let mut bytes = Vec::new(); let (mut acc, mut bits) = (0u32, 0); for c in payload.bytes() { let v = match c { b'A'..=b'Z' => c - b'A', b'a'..=b'z' => c - b'a' + 26, b'0'..=b'9' => c - b'0' + 52, b'-' => 62, b'_' => 63, _ => return Err("token payload must be base64url".into()), }; acc = (acc << 6) | v as u32; bits += 6; if bits >= 8 { bits -= 8; bytes.push((acc >> bits) as u8); acc &= (1 << bits) - 1; } } serde_json::from_slice(&bytes).map_err(|e| format!("token payload: {e}")) } // One exchange: the host writes every line, closes its side and returns what atirc sent. // atirc drops PRIVMSG to a channel the session has not joined, so JOIN goes first. fn irc_post_handler(run: Run) -> Result { let Run { event, state, args } = run; match event { Event::Start => irc_post_start(args), Event::Result { result, .. } => irc_post_done(result.output()?, &state), _ => Ok(Handle::new()), } } fn irc_post_start(args: Args) -> Result { let token = args.token.as_deref().map(str::trim).filter(|t| !t.is_empty()).ok_or("token (an atirc token) is required")?; let claims = claims(token)?; let ticket = claims["endpoint"].as_str().ok_or("token has no endpoint (iroh ticket)")?; let text = args.text.as_deref().unwrap_or_default().trim(); if text.is_empty() || text.len() > 400 || text.contains(['\r', '\n', '\0']) { return Err("text is required: one line, at most 400 bytes".into()); } let channel = match channel(&args)? { Some(channel) => channel, None => handle_channel(claims["handle"].as_str().ok_or("token has no handle; pass channel")?), }; let nick = args.nick.as_deref().map(str::trim).filter(|n| !n.is_empty()).or(claims["nick"].as_str()).ok_or("nick is required")?; if nick.contains(|c: char| c.is_whitespace() || c == ':') { return Err("nick must be one word".into()); } let send = format!("PASS {token}\r\nNICK {nick}\r\nUSER {nick} 0 * :{nick}\r\nJOIN {channel}\r\nPRIVMSG {channel} :{text}\r\nQUIT :done\r\n"); Ok(Handle::new() .call(Call::new("iroh", IROH).input(json!(iroh::Connect { ticket: ticket.into(), alpn: Some(IRC_ALPN.into()), host_keys: None, command: None, stdin: None, send: Some(send) }))) .state(json!({ "channel": channel, "nick": nick }))) } // Registered (001) and joined means the PRIVMSG that followed was delivered. fn irc_post_done(output: &Value, state: &Value) -> Result { let output = output["output"].as_str().unwrap_or_default().to_string(); let (channel, nick) = (state["channel"].as_str().unwrap_or_default(), state["nick"].as_str().unwrap_or_default()); let command = |line: &str| line.split(' ').nth(if line.starts_with(':') { 1 } else { 0 }).unwrap_or_default().to_string(); let lines: Vec<&str> = output.lines().map(str::trim).filter(|l| !l.is_empty()).collect(); let failed = lines.iter().find(|l| matches!(command(l).as_str(), "433" | "464" | "465" | "403" | "474")); if let Some(line) = failed { return Err(format!("atirc refused: {line}")); } if !lines.iter().any(|l| command(l) == "001") { return Err(format!("not registered: {}", lines.first().unwrap_or(&"no reply"))); } if !lines.iter().any(|l| command(l) == "JOIN" && l.starts_with(&format!(":{nick}!"))) { return Err(format!("did not join {channel}")); } Ok(Handle::new().result(json!({ "channel": channel, "nick": nick, "transport": "iroh", "alpn": IRC_ALPN })).done()) } entries!( read => read_handler, post => post_handler, irc_post => irc_post_handler );