use atproto_tap::{TapClient, TapConfig, TapEvent, connect};
use clap::{Parser, Subcommand};
use std::time::Duration;
use tokio_stream::StreamExt;
#[derive(Parser)]
#[command(
name = "atproto-tap-client",
version,
about = "TAP service client for AT Protocol",
long_about = "Connect to a TAP service to stream repository/identity events or manage tracked repositories.\n\n\
Events are printed to stdout as JSON, one per line.\n\
Use Ctrl+C to gracefully stop the consumer."
)]
struct Args {
hostname: String,
#[arg(short, long, global = true)]
password: Option<String>,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Read {
#[arg(long)]
no_acks: bool,
#[arg(long, default_value = "0")]
max_reconnects: u32,
#[arg(short, long)]
debug: bool,
#[arg(long)]
collections: Option<String>,
#[arg(long)]
live_only: bool,
},
Repos {
#[command(subcommand)]
action: ReposAction,
},
Resolve {
did: String,
#[arg(long)]
handle_only: bool,
},
Info {
did: String,
},
}
#[derive(Subcommand)]
enum ReposAction {
Add {
#[arg(required = true)]
dids: Vec<String>,
},
Remove {
#[arg(required = true)]
dids: Vec<String>,
},
}
#[tokio::main]
async fn main() {
let args = Args::parse();
match args.command {
Command::Read {
no_acks,
max_reconnects,
debug,
collections,
live_only,
} => {
run_read(
&args.hostname,
args.password,
no_acks,
max_reconnects,
debug,
collections,
live_only,
)
.await;
}
Command::Repos { action } => {
run_repos(&args.hostname, args.password, action).await;
}
Command::Resolve { did, handle_only } => {
run_resolve(&args.hostname, args.password, &did, handle_only).await;
}
Command::Info { did } => {
run_info(&args.hostname, args.password, &did).await;
}
}
}
async fn run_read(
hostname: &str,
password: Option<String>,
no_acks: bool,
max_reconnects: u32,
debug: bool,
collections: Option<String>,
live_only: bool,
) {
if debug {
tracing_subscriber::fmt()
.with_env_filter("atproto_tap=debug")
.with_writer(std::io::stderr)
.init();
}
let mut config_builder = TapConfig::builder().hostname(hostname).send_acks(!no_acks);
if let Some(password) = password {
config_builder = config_builder.admin_password(password);
}
if max_reconnects > 0 {
config_builder = config_builder.max_reconnect_attempts(Some(max_reconnects));
}
config_builder = config_builder
.initial_reconnect_delay(Duration::from_secs(1))
.max_reconnect_delay(Duration::from_secs(30));
let config = config_builder.build();
eprintln!("Connecting to TAP service at {}...", hostname);
let mut stream = connect(config);
let collection_filters: Vec<String> = collections
.map(|c| c.split(',').map(|s| s.trim().to_string()).collect())
.unwrap_or_default();
let ctrl_c = tokio::signal::ctrl_c();
tokio::pin!(ctrl_c);
loop {
tokio::select! {
Some(result) = stream.next() => {
match result {
Ok(event) => {
let should_print = match event.as_ref() {
TapEvent::Record { record, .. } => {
if live_only && !record.live {
false
}
else if !collection_filters.is_empty() {
collection_filters.iter().any(|c| record.collection.as_ref() == c)
} else {
true
}
}
TapEvent::Identity { .. } => !live_only, };
if should_print {
match serde_json::to_string(event.as_ref()) {
Ok(json) => println!("{}", json),
Err(e) => {
eprintln!("Failed to serialize event: {}", e);
}
}
}
}
Err(e) => {
eprintln!("Error: {}", e);
if e.is_fatal() {
eprintln!("Fatal error, exiting");
std::process::exit(1);
}
}
}
}
_ = &mut ctrl_c => {
eprintln!("\nReceived Ctrl+C, shutting down...");
stream.close().await;
break;
}
}
}
eprintln!("Client stopped");
}
async fn run_repos(hostname: &str, password: Option<String>, action: ReposAction) {
let client = TapClient::new(hostname, password);
match action {
ReposAction::Add { dids } => {
let did_refs: Vec<&str> = dids.iter().map(|s| s.as_str()).collect();
match client.add_repos(&did_refs).await {
Ok(()) => {
eprintln!("Added {} repository(ies) to tracking", dids.len());
for did in &dids {
println!("{}", did);
}
}
Err(e) => {
eprintln!("Failed to add repositories: {}", e);
std::process::exit(1);
}
}
}
ReposAction::Remove { dids } => {
let did_refs: Vec<&str> = dids.iter().map(|s| s.as_str()).collect();
match client.remove_repos(&did_refs).await {
Ok(()) => {
eprintln!("Removed {} repository(ies) from tracking", dids.len());
for did in &dids {
println!("{}", did);
}
}
Err(e) => {
eprintln!("Failed to remove repositories: {}", e);
std::process::exit(1);
}
}
}
}
}
async fn run_resolve(hostname: &str, password: Option<String>, did: &str, handle_only: bool) {
let client = TapClient::new(hostname, password);
match client.resolve(did).await {
Ok(doc) => {
if handle_only {
match doc.handles() {
Some(handle) => println!("{}", handle),
None => {
eprintln!("No handle found in DID document");
std::process::exit(1);
}
}
} else {
match serde_json::to_string_pretty(&doc) {
Ok(json) => println!("{}", json),
Err(e) => {
eprintln!("Failed to serialize DID document: {}", e);
std::process::exit(1);
}
}
}
}
Err(e) => {
eprintln!("Failed to resolve DID: {}", e);
std::process::exit(1);
}
}
}
async fn run_info(hostname: &str, password: Option<String>, did: &str) {
let client = TapClient::new(hostname, password);
match client.info(did).await {
Ok(info) => {
match serde_json::to_string_pretty(&info) {
Ok(json) => println!("{}", json),
Err(e) => {
eprintln!("Failed to serialize info: {}", e);
std::process::exit(1);
}
}
}
Err(e) => {
eprintln!("Failed to get repository info: {}", e);
std::process::exit(1);
}
}
}