Ruby CLI tool for accessing Bluesky API / ATProto

cleaned up/simplified the code

Changed files
+117 -257
exe
rat
+117 -257
exe/rat
··· 1 1 #!/usr/bin/env ruby 2 - # frozen_string_literal: true 3 2 4 - # rat – Ruby ATProto Tool 5 - # 6 - # Simple CLI for Bluesky AT Protocol using: 7 - # - Minisky (XRPC client) 8 - # - Skyfall (firehose / Jetstream streaming) 9 - # - DIDKit (DID / handle resolution) 10 - # 11 - # Usage: 12 - # rat fetch at://<did-or-handle>/<collection-nsid>/<rkey> 13 - # rat stream <relay.host> [-j|--jetstream] [-r|--cursor CURSOR] \ 14 - # [-d|--did DID,...] [-c|--collection NSID,...] 15 - # rat resolve <did-or-handle> 16 - # rat help | --help 17 - # rat version | --version 18 - 19 - require 'optparse' 3 + require 'didkit' 20 4 require 'json' 5 + require 'minisky' 6 + require 'optparse' 7 + require 'skyfall' 21 8 require 'time' 22 9 require 'uri' 23 10 24 - require 'minisky' 25 - require 'skyfall' 26 - require 'didkit' # defines DIDKit::DID and top-level DID alias 27 - 28 11 VERSION = '0.0.1' 29 12 30 - DID_REGEX = /\Adid:[a-z0-9]+:[a-zA-Z0-9.\-_:]+\z/ 31 - DOMAIN_REGEX = /\A[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+\z/i 32 - HOST_PORT_REGEX = /\A#{DOMAIN_REGEX.source}(?::\d+)?\z/i 13 + DID_REGEXP = /\Adid:[a-z]+:[a-zA-Z0-9.\-_]+\z/ 14 + NSID_REGEXP = /\A[a-z0-9]+(\.[a-z0-9]+)+\z/ 33 15 34 - def global_help 35 - <<~HELP 36 - rat #{VERSION} 16 + def print_help 17 + puts <<~HELP 18 + rat #{VERSION} 🦡 37 19 38 20 Usage: 39 - rat fetch at://<did-or-handle>/<collection-nsid>/<rkey> 40 - rat stream <relay.host> [options] 41 - rat resolve <did-or-handle> 42 - rat help | --help 43 - rat version | --version 21 + rat fetch at://uri 22 + rat stream <firehose-host> [options] 23 + rat resolve <did>|<handle> 44 24 45 25 Commands: 46 26 fetch Fetch a single record given its at:// URI 47 - stream Stream commit events from a relay / PDS firehose 48 - resolve Resolve a DID or @handle using DIDKit 27 + stream Stream events from a relay / PDS firehose 28 + resolve Resolve a DID or @handle 49 29 help Show this help 50 30 version Show version 51 31 52 32 Stream options: 53 - -j, --jetstream Use Skyfall::Jetstream (JSON) 54 - (uses Jetstream wanted_dids / wanted_collections 55 - when -d/-c are provided) 56 - -r, --cursor CURSOR Start from cursor (seq or time_us) 33 + -j, --jetstream Use a Jetstream source instead of a CBOR firehose 34 + -r, --cursor CURSOR Start from a given cursor 57 35 -d, --did DID[,DID...] Filter only events from given DID(s) 58 36 (can be passed multiple times) 59 37 -c, --collection NSID[,NSID...] Filter only events of given collection(s) ··· 61 39 HELP 62 40 end 63 41 64 - def abort_with_help(message = nil) 65 - warn "Error: #{message}" if message 66 - warn 67 - warn global_help 42 + def abort_with_error(message) 43 + puts message 68 44 exit 1 69 45 end 70 46 71 - def valid_did?(s) 72 - DID_REGEX.match?(s) 73 - end 74 - 75 - def valid_handle?(s) 76 - DOMAIN_REGEX.match?(s) 77 - end 78 - 79 - def valid_nsid?(s) 80 - # NSIDs look like domain names (reverse DNS), so reuse DOMAIN_REGEX 81 - DOMAIN_REGEX.match?(s) 82 - end 83 - 84 - def validate_handle!(s, context: 'handle') 85 - unless valid_handle?(s) 86 - abort_with_help("#{s.inspect} doesn't look like a valid #{context}") 47 + def validate_did(did) 48 + unless did =~ DID_REGEXP 49 + abort_with_error "Error: #{did.inspect} is not a valid DID" 87 50 end 88 - s 89 51 end 90 52 91 - def validate_did!(s) 92 - unless valid_did?(s) 93 - abort_with_help("#{s.inspect} doesn't look like a valid DID") 53 + def validate_nsid(collection) 54 + unless collection =~ NSID_REGEXP 55 + abort_with_error "Error: #{collection.inspect} is not a valid collection NSID" 94 56 end 95 - s 96 57 end 97 58 98 - def validate_nsid!(nsid) 99 - unless valid_nsid?(nsid) 100 - abort_with_help("#{nsid.inspect} doesn't look like a valid collection NSID") 101 - end 102 - nsid 103 - end 104 - 105 - def parse_at_uri(str) 106 - unless str.start_with?('at://') 107 - abort_with_help("not an at:// URI: #{str.inspect}") 59 + def parse_at_uri(uri) 60 + unless uri.start_with?('at://') 61 + abort_with_error "Error: not an at:// URI: #{uri.inspect}" 108 62 end 109 63 110 - m = /\Aat:\/\/([^\/]+)\/([^\/]+)\/([^\/]+)\z/.match(str) 111 - unless m 112 - abort_with_help("invalid at:// URI: expected at://<repo>/<collection>/<rkey>") 64 + unless uri =~ /\Aat:\/\/([^\/]+)\/([^\/]+)\/([^\/]+)\z/ 65 + abort_with_error "Error: invalid at:// URI: #{uri.inspect}" 113 66 end 114 67 115 - repo, collection, rkey = m.captures 116 - [repo, collection, rkey] 68 + [$1, $2, $3] 117 69 end 118 70 119 - def resolve_repo_to_did_and_pds(repo_str) 120 - if repo_str.start_with?('did:') 121 - validate_did!(repo_str) 122 - did_obj = DID.new(repo_str) 123 - else 124 - handle = repo_str.sub(/\A@/, '') 125 - validate_handle!(handle, context: 'handle in at:// URI') 126 - did_obj = DID.resolve_handle(handle) 127 - end 71 + def run_fetch(args) 72 + uri = args.shift 128 73 129 - doc = did_obj.document 130 - pds_host = doc.pds_host 131 - unless pds_host && !pds_host.empty? 132 - abort_with_help("DID document for #{did_obj} does not contain a pds_host") 74 + if uri.nil? 75 + abort_with_error "Usage: #{$PROGRAM_NAME} fetch <at://uri>" 133 76 end 134 77 135 - [did_obj.to_s, pds_host] 136 - rescue StandardError => e 137 - warn "Error resolving repo #{repo_str.inspect}: #{e.class}: #{e.message}" 138 - exit 1 139 - end 140 - 141 - def do_fetch(argv) 142 - uri = argv.shift or abort_with_help("fetch requires an at:// URI") 143 - unless argv.empty? 144 - abort_with_help("unexpected extra arguments for fetch: #{argv.join(' ')}") 78 + if !args.empty? 79 + abort_with_error "Error: Unexpected arguments for fetch: #{args.join(' ')}" 145 80 end 146 81 147 - repo_str, collection, rkey = parse_at_uri(uri) 82 + repo, collection, rkey = parse_at_uri(uri) 148 83 149 - # basic validation of collection (NSID-ish) 150 - validate_nsid!(collection) 151 - 152 - did, pds_host = resolve_repo_to_did_and_pds(repo_str) 153 - 154 - client = Minisky.new(pds_host, nil) 155 - params = { repo: did, collection: collection, rkey: rkey } 84 + begin 85 + pds = DID.new(repo).document.pds_host 86 + sky = Minisky.new(pds, nil) 156 87 157 - begin 158 - res = client.get_request('com.atproto.repo.getRecord', params) 88 + response = sky.get_request('com.atproto.repo.getRecord', { 89 + repo: repo, collection: collection, rkey: rkey 90 + }) 159 91 rescue StandardError => e 160 - warn "Error calling com.atproto.repo.getRecord: #{e.class}: #{e.message}" 161 - exit 1 92 + abort_with_error "Error loading record: #{e.class}: #{e.message}" 162 93 end 163 94 164 - value = res['value'] 165 - if value.nil? 166 - warn "Warning: response did not contain a 'value' field" 167 - puts JSON.pretty_generate(res) 168 - else 169 - puts JSON.pretty_generate(value) 170 - end 95 + puts JSON.pretty_generate(response['value']) 171 96 end 172 97 173 - def do_resolve(argv) 174 - target = argv.shift or abort_with_help("resolve requires a DID or handle") 175 - unless argv.empty? 176 - abort_with_help("unexpected extra arguments for resolve: #{argv.join(' ')}") 98 + def run_resolve(args) 99 + target = args.shift 100 + 101 + if target.nil? 102 + abort_with_error "Usage: #{$PROGRAM_NAME} resolve <did>|<handle>" 177 103 end 178 104 179 - if target.start_with?('did:') 180 - validate_did!(target) 181 - begin 182 - did_obj = DID.new(target) 183 - doc = did_obj.document 184 - json = doc.respond_to?(:json) ? doc.json : doc 185 - puts did_obj.to_s 186 - puts JSON.pretty_generate(json) 187 - rescue StandardError => e 188 - warn "Error resolving DID #{target.inspect}: #{e.class}: #{e.message}" 189 - exit 1 190 - end 191 - else 192 - handle = target.sub(/\A@/, '') 193 - validate_handle!(handle) 194 - begin 195 - did_obj = DID.resolve_handle(handle) 196 - doc = did_obj.document 197 - json = doc.respond_to?(:json) ? doc.json : doc 198 - puts did_obj.to_s 199 - puts JSON.pretty_generate(json) 200 - rescue StandardError => e 201 - warn "Error resolving handle #{target.inspect}: #{e.class}: #{e.message}" 202 - exit 1 203 - end 105 + if !args.empty? 106 + abort_with_error "Error: Unexpected arguments for resolve: #{args.join(' ')}" 204 107 end 205 - end 206 108 207 - def validate_relay_host!(host) 208 - # Accept: 209 - # - bare hostname or hostname:port 210 - # - ws://host[:port] 211 - # - wss://host[:port] 212 - if host =~ /\Aws:\/\//i || host =~ /\Awss:\/\//i 213 - uri = URI(host) 214 - if uri.path && uri.path != '' && uri.path != '/' 215 - abort_with_help("relay URL must not contain a path: #{host.inspect}") 216 - end 217 - unless uri.host && DOMAIN_REGEX.match?(uri.host) 218 - abort_with_help("invalid relay hostname in URL: #{host.inspect}") 219 - end 220 - else 221 - unless HOST_PORT_REGEX.match?(host) 222 - abort_with_help("invalid relay hostname: #{host.inspect}") 223 - end 109 + did = DID.resolve_handle(target) 110 + 111 + if did.nil? 112 + abort_with_error "Couldn't resolve #{target}" 224 113 end 225 114 226 - host 227 - rescue URI::InvalidURIError 228 - abort_with_help("invalid relay URL: #{host.inspect}") 115 + puts JSON.pretty_generate(did.document.json) 116 + rescue StandardError => e 117 + abort_with_error "Error resolving #{target.inspect}: #{e.class}: #{e.message}" 229 118 end 230 119 231 - def parse_stream_options(argv) 232 - options = { 233 - use_jetstream: false, 234 - cursor: nil, 235 - dids: [], 236 - collections: [] 237 - } 120 + def parse_stream_options(args) 121 + options = {} 238 122 239 123 parser = OptionParser.new do |opts| 240 - opts.banner = "Usage: rat stream <relay.host> [options]" 124 + opts.banner = "Usage: #{$PROGRAM_NAME} stream <relay.host> [options]" 241 125 242 - opts.on('-j', '--jetstream', 'Use Skyfall::Jetstream (JSON)') do 243 - options[:use_jetstream] = true 126 + opts.on('-j', '--jetstream', 'Use a Jetstream source') do 127 + options[:jetstream] = true 244 128 end 245 129 246 - opts.on('-rCURSOR', '--cursor=CURSOR', 'Start from cursor (seq or time_us)') do |cursor| 130 + opts.on('-rCURSOR', '--cursor=CURSOR', 'Start from a given cursor') do |cursor| 247 131 options[:cursor] = cursor 248 132 end 249 133 250 - opts.on('-dLIST', '--did=LIST', 251 - 'Filter only events from DID(s) (comma-separated or repeated)') do |list| 134 + opts.on('-dLIST', '--did=LIST', 'Filter only events from given DID(s) (comma-separated or repeated)') do |list| 252 135 items = list.split(',').map(&:strip).reject(&:empty?) 136 + 253 137 if items.empty? 254 - abort_with_help("empty argument to -d/--did") 138 + abort_with_error "Error: empty argument to -d/--did" 255 139 end 256 - options[:dids].concat(items) 140 + 141 + options[:dids] ||= [] 142 + options[:dids] += items 257 143 end 258 144 259 - opts.on('-cLIST', '--collection=LIST', 260 - 'Filter only events of given collection NSID(s)') do |list| 145 + opts.on('-cLIST', '--collection=LIST', 'Filter only events of given collections') do |list| 261 146 items = list.split(',').map(&:strip).reject(&:empty?) 147 + 262 148 if items.empty? 263 - abort_with_help("empty argument to -c/--collection") 149 + abort_with_error "Error: empty argument to -c/--collection" 264 150 end 265 - options[:collections].concat(items) 151 + 152 + options[:collections] ||= [] 153 + options[:collections] += items 266 154 end 267 155 268 156 opts.on('-h', '--help', 'Show stream-specific help') do 269 157 puts opts 270 - exit 0 158 + exit 271 159 end 272 160 end 273 161 274 162 remaining = [] 163 + 275 164 begin 276 - parser.order!(argv) { |nonopt| remaining << nonopt } 165 + parser.order!(args) { |other| remaining << other } 277 166 rescue OptionParser::InvalidOption, OptionParser::MissingArgument => e 278 - warn "Error: #{e.message}" 279 - warn parser 167 + puts "Error: #{e.message}" 168 + puts parser 280 169 exit 1 281 170 end 282 171 283 172 [options, remaining] 284 173 end 285 174 286 - def do_stream(argv) 287 - options, remaining = parse_stream_options(argv) 175 + def run_stream(args) 176 + options, arguments = parse_stream_options(args) 177 + 178 + service = arguments.shift 288 179 289 - host = remaining.shift or abort_with_help("stream requires a relay hostname") 290 - validate_relay_host!(host) 180 + if service.nil? 181 + abort_with_error "Usage: #{$PROGRAM_NAME} stream <firehose-host> [options]" 182 + end 291 183 292 - unless remaining.empty? 293 - abort_with_help("unexpected extra arguments for stream: #{remaining.join(' ')}") 184 + if !arguments.empty? 185 + abort_with_error "Error: Unexpected arguments for stream: #{arguments.join(' ')}" 294 186 end 295 187 296 - # validate cursor (if given) 297 188 if options[:cursor] && options[:cursor] !~ /\A\d+\z/ 298 - abort_with_help("cursor must be a decimal integer, got #{options[:cursor].inspect}") 189 + abort_with_error "Error: cursor must be a decimal integer, got: #{options[:cursor].inspect}" 299 190 end 300 191 301 - # validate DIDs 302 - options[:dids].each do |did| 303 - validate_did!(did) 192 + if options[:dids] 193 + options[:dids].each { |did| validate_did(did) } 304 194 end 305 195 306 - # validate collections 307 - options[:collections].each do |nsid| 308 - validate_nsid!(nsid) 196 + if options[:collections] 197 + options[:collections].each { |collection| validate_nsid(collection) } 309 198 end 310 199 311 - # Build Skyfall client 312 - if options[:use_jetstream] 200 + if options[:jetstream] 313 201 jet_opts = {} 314 202 jet_opts[:cursor] = options[:cursor].to_i if options[:cursor] 315 203 316 - # Pass filters through to Jetstream server-side 317 - jet_opts[:wanted_dids] = options[:dids] unless options[:dids].empty? 318 - jet_opts[:wanted_collections] = options[:collections] unless options[:collections].empty? 204 + # pass DID/collection filters to Jetstream to filter events server-side 205 + jet_opts[:wanted_dids] = options[:dids] if options[:dids] 206 + jet_opts[:wanted_collections] = options[:collections] if options[:collections] 319 207 320 - sky = Skyfall::Jetstream.new(host, jet_opts) 208 + sky = Skyfall::Jetstream.new(service, jet_opts) 321 209 else 322 210 cursor = options[:cursor]&.to_i 323 - sky = Skyfall::Firehose.new(host, :subscribe_repos, cursor) 211 + sky = Skyfall::Firehose.new(service, :subscribe_repos, cursor) 324 212 end 325 213 326 - # Lifecycle logging 327 214 sky.on_connecting { |url| puts "Connecting to #{url}..." } 328 215 sky.on_connect { puts "Connected" } 329 216 sky.on_disconnect { puts "Disconnected" } 330 217 sky.on_reconnect { puts "Connection lost, trying to reconnect..." } 331 - sky.on_timeout { puts "Connection stalled, triggering a reconnect..." } if sky.respond_to?(:on_timeout) 332 - sky.on_error { |e| warn "ERROR: #{e}" } 218 + sky.on_timeout { puts "Connection stalled, triggering a reconnect..." } 219 + sky.on_error { |e| puts "ERROR: #{e}" } 333 220 334 - # Message handler 335 221 sky.on_message do |msg| 336 222 next unless msg.type == :commit 223 + next if options[:dids] && !options[:dids].include?(msg.repo) 337 224 338 - did = if msg.respond_to?(:repo) && msg.repo 339 - msg.repo 340 - elsif msg.respond_to?(:did) 341 - msg.did 342 - else 343 - nil 344 - end 345 - 346 - if !options[:dids].empty? && (!did || !options[:dids].include?(did)) 347 - next 348 - end 349 - 350 - ts = msg.time ? msg.time.getlocal.iso8601 : 'unknown-time' 225 + time = msg.time.getlocal.iso8601 351 226 352 227 msg.operations.each do |op| 353 - # collection filter (still applied client-side, in case server doesn't) 354 - if !options[:collections].empty? && 355 - !options[:collections].include?(op.collection) 356 - next 357 - end 228 + next if options[:collections] && !options[:collections].include?(op.collection) 358 229 359 - line = "[#{ts}] #{did || '-'} :#{op.action} #{op.collection} #{op.rkey}" 360 - 361 - if op.respond_to?(:raw_record) && op.raw_record && !op.raw_record.empty? 362 - # compact JSON on one line 363 - line << " " << JSON.generate(op.raw_record) 364 - end 365 - 366 - puts line 230 + json = op.raw_record && JSON.generate(op.raw_record) 231 + puts "[#{time}] #{msg.repo} #{op.action.inspect} #{op.collection} #{op.rkey} #{json}" 367 232 end 368 233 end 369 234 370 - # Clean disconnect on Ctrl+C 371 235 trap('SIGINT') do 372 236 puts 'Disconnecting...' 373 237 sky.disconnect ··· 376 240 sky.connect 377 241 end 378 242 379 - # ---- main dispatcher ---- 380 - 381 243 if ARGV.empty? 382 - puts global_help 383 - exit 0 244 + print_help 245 + exit 384 246 end 385 247 386 248 cmd = ARGV.shift 387 249 388 250 case cmd 389 251 when 'help', '--help', '-h' 390 - puts global_help 391 - exit 0 252 + print_help 392 253 when 'version', '--version' 393 - puts VERSION 394 - exit 0 254 + puts "RatProto #{VERSION} 🐀" 395 255 when 'fetch' 396 - do_fetch(ARGV) 256 + run_fetch(ARGV) 397 257 when 'resolve' 398 - do_resolve(ARGV) 258 + run_resolve(ARGV) 399 259 when 'stream' 400 - do_stream(ARGV) 260 + run_stream(ARGV) 401 261 else 402 - abort_with_help("unknown command: #{cmd}") 262 + abort_with_error "Error: unknown command: #{cmd}" 403 263 end