Ruby CLI tool for accessing Bluesky API / ATProto

Compare changes

Choose any two refs to compare.

Changed files
+288 -276
exe
rat
lib
ratproto
+11 -1
CHANGELOG.md
··· 1 - ## [Unreleased]
··· 1 + ## [0.1.1] - 2026-01-03 2 + 3 + - fixed rat emoji in the help output :D 4 + 5 + ## [0.1] - 2026-01-03 6 + 7 + - cleaned up / rewritten the code 8 + 9 + ## [0.0.1] - 2026-01-02 10 + 11 + - first working proof of concept
+157 -17
README.md
··· 1 - # Ratproto 2 3 - TODO: Delete this and the text below, and describe your gem 4 5 - Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/ratproto`. To experiment with that code, run `bin/console` for an interactive prompt. 6 7 ## Installation 8 9 - TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org. 10 11 - Install the gem and add to the application's Gemfile by executing: 12 13 - ```bash 14 - bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG 15 ``` 16 17 - If bundler is not being used to manage dependencies, install the gem by executing: 18 19 - ```bash 20 - gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG 21 ``` 22 23 - ## Usage 24 25 - TODO: Write usage instructions here 26 27 - ## Development 28 29 - After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 30 31 - To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). 32 33 - ## Contributing 34 35 - Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/ratproto.
··· 1 + # RatProto โ€“ Ruby ATProto Tool ๐Ÿ€ 2 3 + RatProto (`rat`) is a small command-line tool in Ruby for accessing the Bluesky API / AT Protocol. 4 + 5 + It builds on top of the existing ATProto Ruby gems: 6 7 + - [`minisky`](https://tangled.org/mackuba.eu/minisky/) โ€” XRPC client 8 + - [`skyfall`](https://tangled.org/mackuba.eu/skyfall/) โ€” firehose & Jetstream streaming 9 + - [`didkit`](https://tangled.org/mackuba.eu/didkit/) โ€” DID & handle resolution 10 + 11 + > [!NOTE] 12 + > Part of ATProto Ruby SDK: [ruby.sdk.blue](https://ruby.sdk.blue) 13 + 14 15 ## Installation 16 17 + To run this tool, you need some reasonably recent version of Ruby installed โ€“ it should run on Ruby 2.6 and above, although it's recommended to use a version that still gets maintainance updates, i.e. currently 3.2+. 18 + 19 + An older version of Ruby (2.6.x) that should work is shipped with macOS versions 11.0 or later, a recent version of Ruby is also likely to be already installed on most Linux systems, or at least available through the OS's package manager. More installation options can be found on [ruby-lang.org](https://www.ruby-lang.org/en/downloads/). 20 21 + To install `rat`, run: 22 23 + ``` 24 + [sudo] gem install ratproto 25 ``` 26 27 + ## Features 28 + 29 + Currently implemented features/commands: 30 31 + - resolving DIDs & handles (`rat resolve`) 32 + - fetching & printing ATProto records (`rat fetch`) 33 + - streaming firehose / Jetstream events with optional filters (`rat stream`) 34 + 35 + 36 + ## Resolving a DID or handle 37 + 38 ``` 39 + rat resolve <did>|<handle> 40 + ``` 41 + 42 + Pass a DID or a handle (@ optional) to look up the given account's identity & print the DID document: 43 + 44 + ``` 45 + $ rat resolve atproto.com 46 47 + { 48 + "@context": [ 49 + "https://www.w3.org/ns/did/v1", 50 + "https://w3id.org/security/multikey/v1", 51 + "https://w3id.org/security/suites/secp256k1-2019/v1" 52 + ], 53 + "id": "did:plc:ewvi7nxzyoun6zhxrhs64oiz", 54 + "alsoKnownAs": [ 55 + "at://atproto.com" 56 + ], 57 + "verificationMethod": [ 58 + { 59 + "id": "did:plc:ewvi7nxzyoun6zhxrhs64oiz#atproto", 60 + "type": "Multikey", 61 + "controller": "did:plc:ewvi7nxzyoun6zhxrhs64oiz", 62 + "publicKeyMultibase": "zQ3shunBKsXixLxKtC5qeSG9E4J5RkGN57im31pcTzbNQnm5w" 63 + } 64 + ], 65 + "service": [ 66 + { 67 + "id": "#atproto_pds", 68 + "type": "AtprotoPersonalDataServer", 69 + "serviceEndpoint": "https://enoki.us-east.host.bsky.network" 70 + } 71 + ] 72 + } 73 + ``` 74 75 76 + ## Fetching a record 77 78 + ``` 79 + rat fetch at://<did>/<collection>/<rkey> 80 + ``` 81 82 + Pass an at:// URI as the argument to fetch a single record from the userโ€™s PDS: 83 84 + ``` 85 + % rat fetch at://did:plc:ragtjsm2j2vknwkz3zp4oxrd/app.bsky.feed.post/3lxxmboqmf22j 86 87 + { 88 + "text": "a gatinha gorda", 89 + "$type": "app.bsky.feed.post", 90 + "embed": { 91 + "$type": "app.bsky.embed.images", 92 + "images": [ 93 + { 94 + "alt": "Kit lying on the ground under the chair ", 95 + "image": { 96 + "$type": "blob", 97 + "ref": { 98 + "$link": "bafkreiebzsetrrvymvq6dvwma77zqprnv73ovgeqilanzi453dm6xart4q" 99 + }, 100 + "mimeType": "image/jpeg", 101 + "size": 963750 102 + }, 103 + "aspectRatio": { 104 + "width": 2000, 105 + "height": 1500 106 + } 107 + } 108 + ] 109 + }, 110 + "langs": [ 111 + "en" 112 + ], 113 + "createdAt": "2025-09-03T21:48:05.910Z" 114 + } 115 + ``` 116 + 117 + ## Streaming commit events 118 + 119 + ``` 120 + rat stream <firehose-host> [-j] [-r cursor] [-c collections] [-d dids] 121 + ``` 122 + 123 + Rat can connect to either a relay/PDS firehose: 124 + 125 + ``` 126 + rat stream bsky.network 127 + ``` 128 + 129 + or a Jetstream service (use `-j`): 130 + 131 + ``` 132 + rat stream -j jetstream2.us-east.bsky.network 133 + ``` 134 + 135 + You can also pass a cursor to connect with using `-r` / `--cursor` (tip: pass `-r0` to rewind as far back as the buffer allows). 136 + 137 + Once connected, youโ€™ll see output like: 138 + 139 + ``` 140 + [2025-01-02T12:34:56+01:00] did:plc:xwnehmdpjluz2kv3oh2mfi47 :create app.bsky.feed.post 3mbjs5rgtin2z {"text":"hi", ...} 141 + [2025-01-02T12:34:57+01:00] did:plc:3mfkuhmjxd2wvz2e7nhl4poi :delete app.bsky.graph.follow 3mbjs5rghri23 142 + ``` 143 + 144 + Press Ctrl-C to disconnect. 145 + 146 + You can also apply the filtering options below (for Jetstream, these are passed to the server to apply filtering server-side via `wantedCollections` / `wantedDids` parameters): 147 + 148 + 149 + ### Filtering by DID 150 + 151 + To print only events from given DID(s): 152 + 153 + ``` 154 + rat stream bsky.network -d did:plc:abcd1234,did:plc:xyz9999,... 155 + ``` 156 + 157 + You can either pass a comma-separated list as one parameter, or repeat `-d val` more than once. 158 + 159 + 160 + ### Filtering by collection 161 + 162 + To print only records from given collection(s): 163 + 164 + ``` 165 + rat stream bsky.network -c app.bsky.feed.post -c app.bsky.actor.profile 166 + ``` 167 + 168 + 169 + ## Credits 170 + 171 + Copyright ยฉ 2026 Kuba Suder ([@mackuba.eu](https://bsky.app/profile/did:plc:oio4hkxaop4ao4wz2pp3f4cr)). 172 + 173 + The code is available under the terms of the [zlib license](https://choosealicense.com/licenses/zlib/) (permissive, similar to MIT). 174 + 175 + Bug reports and pull requests are welcome ๐Ÿ˜Ž
+119 -257
exe/rat
··· 1 #!/usr/bin/env ruby 2 - # frozen_string_literal: true 3 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' 20 require 'json' 21 require 'time' 22 require 'uri' 23 24 - require 'minisky' 25 - require 'skyfall' 26 - require 'didkit' # defines DIDKit::DID and top-level DID alias 27 28 - VERSION = '0.0.1' 29 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 33 - 34 - def global_help 35 - <<~HELP 36 - rat #{VERSION} 37 38 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 44 45 Commands: 46 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 49 help Show this help 50 version Show version 51 52 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) 57 -d, --did DID[,DID...] Filter only events from given DID(s) 58 (can be passed multiple times) 59 -c, --collection NSID[,NSID...] Filter only events of given collection(s) ··· 61 HELP 62 end 63 64 - def abort_with_help(message = nil) 65 - warn "Error: #{message}" if message 66 - warn 67 - warn global_help 68 exit 1 69 end 70 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}") 87 end 88 - s 89 end 90 91 - def validate_did!(s) 92 - unless valid_did?(s) 93 - abort_with_help("#{s.inspect} doesn't look like a valid DID") 94 end 95 - s 96 end 97 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}") 108 end 109 110 - m = /\Aat:\/\/([^\/]+)\/([^\/]+)\/([^\/]+)\z/.match(str) 111 - unless m 112 - abort_with_help("invalid at:// URI: expected at://<repo>/<collection>/<rkey>") 113 end 114 115 - repo, collection, rkey = m.captures 116 - [repo, collection, rkey] 117 end 118 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 128 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") 133 end 134 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(' ')}") 145 end 146 147 - repo_str, collection, rkey = parse_at_uri(uri) 148 - 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 } 156 157 begin 158 - res = client.get_request('com.atproto.repo.getRecord', params) 159 rescue StandardError => e 160 - warn "Error calling com.atproto.repo.getRecord: #{e.class}: #{e.message}" 161 - exit 1 162 end 163 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 171 end 172 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(' ')}") 177 end 178 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 204 end 205 - end 206 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 224 end 225 226 - host 227 - rescue URI::InvalidURIError 228 - abort_with_help("invalid relay URL: #{host.inspect}") 229 end 230 231 - def parse_stream_options(argv) 232 - options = { 233 - use_jetstream: false, 234 - cursor: nil, 235 - dids: [], 236 - collections: [] 237 - } 238 239 parser = OptionParser.new do |opts| 240 - opts.banner = "Usage: rat stream <relay.host> [options]" 241 242 - opts.on('-j', '--jetstream', 'Use Skyfall::Jetstream (JSON)') do 243 - options[:use_jetstream] = true 244 end 245 246 - opts.on('-rCURSOR', '--cursor=CURSOR', 'Start from cursor (seq or time_us)') do |cursor| 247 options[:cursor] = cursor 248 end 249 250 - opts.on('-dLIST', '--did=LIST', 251 - 'Filter only events from DID(s) (comma-separated or repeated)') do |list| 252 items = list.split(',').map(&:strip).reject(&:empty?) 253 if items.empty? 254 - abort_with_help("empty argument to -d/--did") 255 end 256 - options[:dids].concat(items) 257 end 258 259 - opts.on('-cLIST', '--collection=LIST', 260 - 'Filter only events of given collection NSID(s)') do |list| 261 items = list.split(',').map(&:strip).reject(&:empty?) 262 if items.empty? 263 - abort_with_help("empty argument to -c/--collection") 264 end 265 - options[:collections].concat(items) 266 end 267 268 opts.on('-h', '--help', 'Show stream-specific help') do 269 puts opts 270 - exit 0 271 end 272 end 273 274 remaining = [] 275 begin 276 - parser.order!(argv) { |nonopt| remaining << nonopt } 277 rescue OptionParser::InvalidOption, OptionParser::MissingArgument => e 278 - warn "Error: #{e.message}" 279 - warn parser 280 exit 1 281 end 282 283 [options, remaining] 284 end 285 286 - def do_stream(argv) 287 - options, remaining = parse_stream_options(argv) 288 289 - host = remaining.shift or abort_with_help("stream requires a relay hostname") 290 - validate_relay_host!(host) 291 292 - unless remaining.empty? 293 - abort_with_help("unexpected extra arguments for stream: #{remaining.join(' ')}") 294 end 295 296 - # validate cursor (if given) 297 if options[:cursor] && options[:cursor] !~ /\A\d+\z/ 298 - abort_with_help("cursor must be a decimal integer, got #{options[:cursor].inspect}") 299 end 300 301 - # validate DIDs 302 - options[:dids].each do |did| 303 - validate_did!(did) 304 end 305 306 - # validate collections 307 - options[:collections].each do |nsid| 308 - validate_nsid!(nsid) 309 end 310 311 - # Build Skyfall client 312 - if options[:use_jetstream] 313 jet_opts = {} 314 jet_opts[:cursor] = options[:cursor].to_i if options[:cursor] 315 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? 319 320 - sky = Skyfall::Jetstream.new(host, jet_opts) 321 else 322 cursor = options[:cursor]&.to_i 323 - sky = Skyfall::Firehose.new(host, :subscribe_repos, cursor) 324 end 325 326 - # Lifecycle logging 327 sky.on_connecting { |url| puts "Connecting to #{url}..." } 328 sky.on_connect { puts "Connected" } 329 sky.on_disconnect { puts "Disconnected" } 330 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}" } 333 334 - # Message handler 335 sky.on_message do |msg| 336 next unless msg.type == :commit 337 - 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' 351 352 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 358 - 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 367 end 368 end 369 370 - # Clean disconnect on Ctrl+C 371 trap('SIGINT') do 372 puts 'Disconnecting...' 373 sky.disconnect ··· 376 sky.connect 377 end 378 379 - # ---- main dispatcher ---- 380 - 381 if ARGV.empty? 382 - puts global_help 383 - exit 0 384 end 385 386 cmd = ARGV.shift 387 388 case cmd 389 when 'help', '--help', '-h' 390 - puts global_help 391 - exit 0 392 when 'version', '--version' 393 - puts VERSION 394 - exit 0 395 when 'fetch' 396 - do_fetch(ARGV) 397 when 'resolve' 398 - do_resolve(ARGV) 399 when 'stream' 400 - do_stream(ARGV) 401 else 402 - abort_with_help("unknown command: #{cmd}") 403 end
··· 1 #!/usr/bin/env ruby 2 3 + $LOAD_PATH.unshift File.expand_path('../lib', __dir__) 4 5 + require 'didkit' 6 require 'json' 7 + require 'minisky' 8 + require 'optparse' 9 require 'time' 10 require 'uri' 11 12 + require 'ratproto/version' 13 14 + DID_REGEXP = /\Adid:[a-z]+:[a-zA-Z0-9.\-_]+\z/ 15 + NSID_REGEXP = /\A[a-z0-9]+(\.[a-z0-9]+)+\z/ 16 17 + def print_help 18 + puts <<~HELP 19 + rat #{RatProto::VERSION} ๐Ÿ€ 20 21 Usage: 22 + rat fetch at://uri 23 + rat stream <firehose-host> [options] 24 + rat resolve <did>|<handle> 25 26 Commands: 27 fetch Fetch a single record given its at:// URI 28 + stream Stream events from a relay / PDS firehose 29 + resolve Resolve a DID or @handle 30 help Show this help 31 version Show version 32 33 Stream options: 34 + -j, --jetstream Use a Jetstream source instead of a CBOR firehose 35 + -r, --cursor CURSOR Start from a given cursor 36 -d, --did DID[,DID...] Filter only events from given DID(s) 37 (can be passed multiple times) 38 -c, --collection NSID[,NSID...] Filter only events of given collection(s) ··· 40 HELP 41 end 42 43 + def abort_with_error(message) 44 + puts message 45 exit 1 46 end 47 48 + def validate_did(did) 49 + unless did =~ DID_REGEXP 50 + abort_with_error "Error: #{did.inspect} is not a valid DID" 51 end 52 end 53 54 + def validate_nsid(collection) 55 + unless collection =~ NSID_REGEXP 56 + abort_with_error "Error: #{collection.inspect} is not a valid collection NSID" 57 end 58 end 59 60 + def parse_at_uri(uri) 61 + unless uri.start_with?('at://') 62 + abort_with_error "Error: not an at:// URI: #{uri.inspect}" 63 end 64 65 + unless uri =~ /\Aat:\/\/([^\/]+)\/([^\/]+)\/([^\/]+)\z/ 66 + abort_with_error "Error: invalid at:// URI: #{uri.inspect}" 67 end 68 69 + [$1, $2, $3] 70 end 71 72 + def run_fetch(args) 73 + uri = args.shift 74 75 + if uri.nil? 76 + abort_with_error "Usage: #{$PROGRAM_NAME} fetch <at://uri>" 77 end 78 79 + if !args.empty? 80 + abort_with_error "Error: Unexpected arguments for fetch: #{args.join(' ')}" 81 end 82 83 + repo, collection, rkey = parse_at_uri(uri) 84 85 begin 86 + pds = DID.new(repo).document.pds_host 87 + sky = Minisky.new(pds, nil) 88 + 89 + response = sky.get_request('com.atproto.repo.getRecord', { 90 + repo: repo, collection: collection, rkey: rkey 91 + }) 92 rescue StandardError => e 93 + abort_with_error "Error loading record: #{e.class}: #{e.message}" 94 end 95 96 + puts JSON.pretty_generate(response['value']) 97 end 98 99 + def run_resolve(args) 100 + target = args.shift 101 + 102 + if target.nil? 103 + abort_with_error "Usage: #{$PROGRAM_NAME} resolve <did>|<handle>" 104 end 105 106 + if !args.empty? 107 + abort_with_error "Error: Unexpected arguments for resolve: #{args.join(' ')}" 108 end 109 110 + did = DID.resolve_handle(target) 111 + 112 + if did.nil? 113 + abort_with_error "Couldn't resolve #{target}" 114 end 115 116 + puts JSON.pretty_generate(did.document.json) 117 + rescue StandardError => e 118 + abort_with_error "Error resolving #{target.inspect}: #{e.class}: #{e.message}" 119 end 120 121 + def parse_stream_options(args) 122 + options = {} 123 124 parser = OptionParser.new do |opts| 125 + opts.banner = "Usage: #{$PROGRAM_NAME} stream <relay.host> [options]" 126 127 + opts.on('-j', '--jetstream', 'Use a Jetstream source') do 128 + options[:jetstream] = true 129 end 130 131 + opts.on('-rCURSOR', '--cursor=CURSOR', 'Start from a given cursor') do |cursor| 132 options[:cursor] = cursor 133 end 134 135 + opts.on('-dLIST', '--did=LIST', 'Filter only events from given DID(s) (comma-separated or repeated)') do |list| 136 items = list.split(',').map(&:strip).reject(&:empty?) 137 + 138 if items.empty? 139 + abort_with_error "Error: empty argument to -d/--did" 140 end 141 + 142 + options[:dids] ||= [] 143 + options[:dids] += items 144 end 145 146 + opts.on('-cLIST', '--collection=LIST', 'Filter only events of given collections') do |list| 147 items = list.split(',').map(&:strip).reject(&:empty?) 148 + 149 if items.empty? 150 + abort_with_error "Error: empty argument to -c/--collection" 151 end 152 + 153 + options[:collections] ||= [] 154 + options[:collections] += items 155 end 156 157 opts.on('-h', '--help', 'Show stream-specific help') do 158 puts opts 159 + exit 160 end 161 end 162 163 remaining = [] 164 + 165 begin 166 + parser.order!(args) { |other| remaining << other } 167 rescue OptionParser::InvalidOption, OptionParser::MissingArgument => e 168 + puts "Error: #{e.message}" 169 + puts parser 170 exit 1 171 end 172 173 [options, remaining] 174 end 175 176 + def run_stream(args) 177 + options, arguments = parse_stream_options(args) 178 179 + service = arguments.shift 180 181 + if service.nil? 182 + abort_with_error "Usage: #{$PROGRAM_NAME} stream <firehose-host> [options]" 183 end 184 185 + if !arguments.empty? 186 + abort_with_error "Error: Unexpected arguments for stream: #{arguments.join(' ')}" 187 + end 188 + 189 if options[:cursor] && options[:cursor] !~ /\A\d+\z/ 190 + abort_with_error "Error: cursor must be a decimal integer, got: #{options[:cursor].inspect}" 191 end 192 193 + if options[:dids] 194 + options[:dids].each { |did| validate_did(did) } 195 end 196 197 + if options[:collections] 198 + options[:collections].each { |collection| validate_nsid(collection) } 199 end 200 201 + if options[:jetstream] 202 jet_opts = {} 203 jet_opts[:cursor] = options[:cursor].to_i if options[:cursor] 204 205 + # pass DID/collection filters to Jetstream to filter events server-side 206 + jet_opts[:wanted_dids] = options[:dids] if options[:dids] 207 + jet_opts[:wanted_collections] = options[:collections] if options[:collections] 208 209 + sky = Skyfall::Jetstream.new(service, jet_opts) 210 else 211 cursor = options[:cursor]&.to_i 212 + sky = Skyfall::Firehose.new(service, :subscribe_repos, cursor) 213 end 214 215 sky.on_connecting { |url| puts "Connecting to #{url}..." } 216 sky.on_connect { puts "Connected" } 217 sky.on_disconnect { puts "Disconnected" } 218 sky.on_reconnect { puts "Connection lost, trying to reconnect..." } 219 + sky.on_timeout { puts "Connection stalled, triggering a reconnect..." } 220 + sky.on_error { |e| puts "ERROR: #{e}" } 221 222 sky.on_message do |msg| 223 next unless msg.type == :commit 224 + next if options[:dids] && !options[:dids].include?(msg.repo) 225 226 + time = msg.time.getlocal.iso8601 227 228 msg.operations.each do |op| 229 + next if options[:collections] && !options[:collections].include?(op.collection) 230 231 + json = op.raw_record && JSON.generate(op.raw_record) 232 + puts "[#{time}] #{msg.repo} #{op.action.inspect} #{op.collection} #{op.rkey} #{json}" 233 end 234 end 235 236 trap('SIGINT') do 237 puts 'Disconnecting...' 238 sky.disconnect ··· 241 sky.connect 242 end 243 244 if ARGV.empty? 245 + print_help 246 + exit 247 end 248 249 cmd = ARGV.shift 250 251 case cmd 252 when 'help', '--help', '-h' 253 + print_help 254 when 'version', '--version' 255 + puts "RatProto #{RatProto::VERSION} ๐Ÿ€" 256 when 'fetch' 257 + run_fetch(ARGV) 258 when 'resolve' 259 + run_resolve(ARGV) 260 when 'stream' 261 + require 'skyfall' 262 + run_stream(ARGV) 263 else 264 + abort_with_error "Error: unknown command: #{cmd}" 265 end
+1 -1
lib/ratproto/version.rb
··· 1 # frozen_string_literal: true 2 3 module RatProto 4 - VERSION = "0.0.1" 5 end
··· 1 # frozen_string_literal: true 2 3 module RatProto 4 + VERSION = "0.1.1" 5 end