~ knotless Search/
↑↓ move↵ open⇧↵ all results

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
main
199 lines · 9.1 KB Raw History
// 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
);