+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