Rust crate for building IRC clients, bots, and integrations that authenticate with AT Protocol identities.
# Cargo.toml
[dependencies]
freeq-sdk = { git = "https://github.com/chad/freeq" }
The SDK exposes a (ClientHandle, Receiver<Event>) pattern.
Connect, authenticate, then consume events in a loop:
use freeq_sdk::client::{ClientHandle, Config};
use freeq_sdk::event::Event;
let config = Config {
server: "irc.freeq.at".into(),
port: 6697,
tls: true,
nick: "myapp".into(),
..Default::default()
};
let (handle, mut events) = freeq_sdk::client::connect(config).await?;
// Send commands
handle.join("#mychannel").await?;
handle.privmsg("#mychannel", "Hello from my app").await?;
// Receive events
while let Some(event) = events.recv().await {
match event {
Event::Privmsg { from, target, text, .. } => {
println!("{from} -> {target}: {text}");
}
_ => {}
}
}
The SDK supports multiple authentication methods via the pluggable
ChallengeSigner trait:
| Signer | Use Case |
|---|---|
KeySigner | Sign challenges with a secp256k1 or ed25519 private key |
PdsSessionSigner | App password or OAuth token — calls PDS to verify |
StubSigner | Testing — returns a fixed response |
use freeq_sdk::oauth;
// Full browser-based OAuth flow
let session = oauth::authenticate("yourname.bsky.social").await?;
// session contains access_jwt, refresh_jwt, DPoP key, PDS URL
// Create a signer from the OAuth session
let signer = PdsSessionSigner::new_with_refresh(
session.did, session.pds_url,
session.access_jwt, session.refresh_jwt,
session.dpop_key,
);
High-level framework for building command-based bots with permission levels:
use freeq_sdk::bot::{Bot, BotConfig, Command, PermissionLevel};
let mut bot = Bot::new(BotConfig {
server: "irc.freeq.at".into(),
port: 6697, tls: true,
nick: "mybot".into(),
channels: vec!["#bots".into()],
admin_dids: vec!["did:plc:yourdid".into()],
..Default::default()
});
// Anyone can use this
bot.command(Command::new("ping", "Check if bot is alive",
PermissionLevel::Anyone,
|ctx| Box::pin(async move { ctx.reply("pong!").await; Ok(()) })
));
// Only authenticated users
bot.command(Command::new("whoami", "Show your DID",
PermissionLevel::Authenticated,
|ctx| Box::pin(async move {
let did = ctx.sender_did.as_deref().unwrap_or("not authenticated");
ctx.reply(&format!("You are {did}")).await;
Ok(())
})
));
// Only admin DIDs
bot.command(Command::new("shutdown", "Stop the bot",
PermissionLevel::Admin,
|ctx| Box::pin(async move { ctx.reply("Shutting down...").await; Ok(()) })
));
bot.run().await?;
Channel encryption with AES-256-GCM:
use freeq_sdk::e2ee;
// Encrypt with a shared passphrase
let encrypted = e2ee::encrypt("#mychannel", "secret passphrase", "Hello!");
// → "ENC1:<nonce>:<ciphertext>"
let plaintext = e2ee::decrypt("#mychannel", "secret passphrase", &encrypted)?;
// → "Hello!"
DID-based group encryption (no shared secret):
use freeq_sdk::e2ee_did;
// Group key derived from sorted member DIDs
let members = vec!["did:plc:alice", "did:plc:bob", "did:plc:carol"];
let encrypted = e2ee_did::encrypt_group(&members, epoch, "Hello group!")?;
// → "ENC2:<epoch>:<nonce>:<ciphertext>"
Upload media to the AT Protocol PDS and send as IRC message tags:
use freeq_sdk::media;
// Upload an image
let blob = media::upload_to_pds(&session, image_bytes, "image/jpeg").await?;
// Send as a tagged message
handle.send_media("#channel", "Check this out",
"image/jpeg", &blob.url, Some("A cool photo")).await?;
| Module | Description |
|---|---|
freeq_sdk::client | IRC connection, handle, events |
freeq_sdk::auth | SASL challenge signing |
freeq_sdk::oauth | AT Protocol OAuth 2.0 flow |
freeq_sdk::did | DID resolution (plc, web) |
freeq_sdk::pds | PDS client (sessions, verification) |
freeq_sdk::bot | Bot framework with commands |
freeq_sdk::e2ee | Passphrase-based channel encryption |
freeq_sdk::e2ee_did | DID-based group + DM encryption |
freeq_sdk::media | PDS media upload, link previews |
freeq_sdk::p2p | Peer-to-peer encrypted DMs |
freeq_sdk::irc | IRC message parser with tags |
freeq_sdk::event | Event types |