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