johnandersen777.bsky.social/atlibs-agents
agents and atirc modules for atlibs: launch agents from a composition, chat on atirc; pages and the agents service
0
0
// 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<String>,
#[serde(default)]
channel: Option<String>,
#[serde(default)]
cursor: Option<u64>,
#[serde(default)]
limit: Option<usize>,
#[serde(default)]
text: Option<String>,
#[serde(default)]
nick: Option<String>,
#[serde(default)]
token: Option<String>,
}
// 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<Option<String>, 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<Args>) -> Result<Handle, String> {
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<Args>) -> Result<Handle, String> {
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<Value, String> {
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<Args>) -> Result<Handle, String> {
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<Handle, String> {
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<Handle, String> {
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
);