+403
exe/rat
+403
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)
60
+
(can be passed multiple times)
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
374
+
end
375
+
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
+12
-6
ratproto.gemspec
+12
-6
ratproto.gemspec
···
8
8
spec.authors = ["Kuba Suder"]
9
9
spec.email = ["jakub.suder@gmail.com"]
10
10
11
-
spec.summary = "TODO: Write a short summary, because RubyGems requires one."
12
-
spec.description = "TODO: Write a longer description or delete this line."
13
-
spec.homepage = "TODO: Put your gem's website or public repo URL here."
11
+
spec.summary = "Ruby CLI tool for accessing Bluesky API / ATProto"
12
+
spec.homepage = "https://ruby.sdk.blue"
14
13
15
14
spec.license = "Zlib"
16
15
spec.required_ruby_version = ">= 2.6.0"
17
16
18
-
spec.metadata["homepage_uri"] = spec.homepage
19
-
spec.metadata["source_code_uri"] = "TODO: Put your gem's public repo URL here."
20
-
spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here."
17
+
spec.metadata = {
18
+
"bug_tracker_uri" => "https://tangled.org/mackuba.eu/ratproto/issues",
19
+
"changelog_uri" => "https://tangled.org/mackuba.eu/ratproto/blob/master/CHANGELOG.md",
20
+
"source_code_uri" => "https://tangled.org/mackuba.eu/ratproto",
21
+
}
21
22
22
23
spec.files = Dir.chdir(__dir__) do
23
24
Dir['*.md'] + Dir['*.txt'] + Dir['exe/*'] + Dir['lib/**/*'] + Dir['sig/**/*']
24
25
end
25
26
26
27
spec.bindir = 'exe'
28
+
spec.executables = ['rat']
27
29
spec.require_paths = ['lib']
30
+
31
+
spec.add_dependency 'minisky', '>= 0.5', '< 2.0'
32
+
spec.add_dependency 'skyfall', '>= 0.6', '< 2.0'
33
+
spec.add_dependency 'didkit', '>= 0.3.1', '< 2.0'
28
34
end