ratproto 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c1495c9728dd1630d8f9b62e14187dd7066c16afbbc31161deac5697ee67f5d0
4
+ data.tar.gz: dbfa95dd48889d5c1a5fd06489ea316aeb2e4ec475cf54fe922bd3f31ca28aa8
5
+ SHA512:
6
+ metadata.gz: 6119eda973e4279d58dfad1bc1f35aabd42de1dcbc892808b649593fb09a0a063cfdfec239e4af6c83ec203833c108201a36b4d08fbe2631b0e2c1bbf0ce4b49
7
+ data.tar.gz: e7feeeb64a73705722cf5f4fc457617349c846b310815f6d2bddbb985a171c1c9e1017fb3070a949c7e994eb4cc33f1857b189529e218aeae6303557cc954dfe
data/CHANGELOG.md ADDED
@@ -0,0 +1 @@
1
+ ## [Unreleased]
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The zlib License
2
+
3
+ Copyright (c) 2026 Jakub Suder
4
+
5
+ This software is provided 'as-is', without any express or implied
6
+ warranty. In no event will the authors be held liable for any damages
7
+ arising from the use of this software.
8
+
9
+ Permission is granted to anyone to use this software for any purpose,
10
+ including commercial applications, and to alter it and redistribute it
11
+ freely, subject to the following restrictions:
12
+
13
+ 1. The origin of this software must not be misrepresented; you must not
14
+ claim that you wrote the original software. If you use this software
15
+ in a product, an acknowledgment in the product documentation would be
16
+ appreciated but is not required.
17
+
18
+ 2. Altered source versions must be plainly marked as such, and must not be
19
+ misrepresented as being the original software.
20
+
21
+ 3. This notice may not be removed or altered from any source distribution.
data/README.md ADDED
@@ -0,0 +1,35 @@
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.
data/exe/rat ADDED
@@ -0,0 +1,403 @@
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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RatProto
4
+ VERSION = "0.0.1"
5
+ end
data/lib/ratproto.rb ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'ratproto/version'
4
+
5
+ module RatProto
6
+ end
data/sig/ratproto.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Ratproto
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,110 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ratproto
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Kuba Suder
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: minisky
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0.5'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '2.0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '0.5'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '2.0'
32
+ - !ruby/object:Gem::Dependency
33
+ name: skyfall
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '0.6'
39
+ - - "<"
40
+ - !ruby/object:Gem::Version
41
+ version: '2.0'
42
+ type: :runtime
43
+ prerelease: false
44
+ version_requirements: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '0.6'
49
+ - - "<"
50
+ - !ruby/object:Gem::Version
51
+ version: '2.0'
52
+ - !ruby/object:Gem::Dependency
53
+ name: didkit
54
+ requirement: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: 0.3.1
59
+ - - "<"
60
+ - !ruby/object:Gem::Version
61
+ version: '2.0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: 0.3.1
69
+ - - "<"
70
+ - !ruby/object:Gem::Version
71
+ version: '2.0'
72
+ email:
73
+ - jakub.suder@gmail.com
74
+ executables:
75
+ - rat
76
+ extensions: []
77
+ extra_rdoc_files: []
78
+ files:
79
+ - CHANGELOG.md
80
+ - LICENSE.txt
81
+ - README.md
82
+ - exe/rat
83
+ - lib/ratproto.rb
84
+ - lib/ratproto/version.rb
85
+ - sig/ratproto.rbs
86
+ homepage: https://ruby.sdk.blue
87
+ licenses:
88
+ - Zlib
89
+ metadata:
90
+ bug_tracker_uri: https://tangled.org/mackuba.eu/ratproto/issues
91
+ changelog_uri: https://tangled.org/mackuba.eu/ratproto/blob/master/CHANGELOG.md
92
+ source_code_uri: https://tangled.org/mackuba.eu/ratproto
93
+ rdoc_options: []
94
+ require_paths:
95
+ - lib
96
+ required_ruby_version: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - ">="
99
+ - !ruby/object:Gem::Version
100
+ version: 2.6.0
101
+ required_rubygems_version: !ruby/object:Gem::Requirement
102
+ requirements:
103
+ - - ">="
104
+ - !ruby/object:Gem::Version
105
+ version: '0'
106
+ requirements: []
107
+ rubygems_version: 4.0.3
108
+ specification_version: 4
109
+ summary: Ruby CLI tool for accessing Bluesky API / ATProto
110
+ test_files: []