+11
-1
CHANGELOG.md
+11
-1
CHANGELOG.md
+157
-17
README.md
+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
+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