ratproto 0.0.1 β 0.1.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 +4 -4
- data/CHANGELOG.md +11 -1
- data/README.md +157 -17
- data/exe/rat +120 -258
- data/lib/ratproto/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a51437de2f57f2f70c839165f4721e3b9fc05ffced806ce652971d7bfe4d5e01
|
|
4
|
+
data.tar.gz: 6fd9738bde5c0c366a28f36e79c496e18f0686331af7b2f2b8385f60e8827024
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 38410b341c5c79fad987cea4e4d6430cda0c63735f58f204497ac8e240525729ab00e01e894512c2d57b55a1da1e913a233da977d084f1f23d909be2f55f1ffb
|
|
7
|
+
data.tar.gz: 0264f915d70d891447af5e87bc7b88635a1b530fd91e50eebcf9c9902c8037a21c2ab8d2e135ecf9519280dfe2d4ae765819f45c04341c81422709588efe42c7
|
data/CHANGELOG.md
CHANGED
data/README.md
CHANGED
|
@@ -1,35 +1,175 @@
|
|
|
1
|
-
#
|
|
1
|
+
# RatProto β Ruby ATProto Tool π
|
|
2
2
|
|
|
3
|
-
|
|
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)
|
|
4
13
|
|
|
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
14
|
|
|
7
15
|
## Installation
|
|
8
16
|
|
|
9
|
-
|
|
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
|
+
```
|
|
10
74
|
|
|
11
|
-
Install the gem and add to the application's Gemfile by executing:
|
|
12
75
|
|
|
13
|
-
|
|
14
|
-
|
|
76
|
+
## Fetching a record
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
rat fetch at://<did>/<collection>/<rkey>
|
|
15
80
|
```
|
|
16
81
|
|
|
17
|
-
|
|
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
|
|
18
86
|
|
|
19
|
-
|
|
20
|
-
|
|
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
|
+
}
|
|
21
115
|
```
|
|
22
116
|
|
|
23
|
-
##
|
|
117
|
+
## Streaming commit events
|
|
118
|
+
|
|
119
|
+
```
|
|
120
|
+
rat stream <firehose-host> [-j] [-r cursor] [-c collections] [-d dids]
|
|
121
|
+
```
|
|
24
122
|
|
|
25
|
-
|
|
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
|
+
```
|
|
26
167
|
|
|
27
|
-
## Development
|
|
28
168
|
|
|
29
|
-
|
|
169
|
+
## Credits
|
|
30
170
|
|
|
31
|
-
|
|
171
|
+
Copyright Β© 2026 Kuba Suder ([@mackuba.eu](https://bsky.app/profile/did:plc:oio4hkxaop4ao4wz2pp3f4cr)).
|
|
32
172
|
|
|
33
|
-
|
|
173
|
+
The code is available under the terms of the [zlib license](https://choosealicense.com/licenses/zlib/) (permissive, similar to MIT).
|
|
34
174
|
|
|
35
|
-
Bug reports and pull requests are welcome
|
|
175
|
+
Bug reports and pull requests are welcome π
|
data/exe/rat
CHANGED
|
@@ -1,59 +1,39 @@
|
|
|
1
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
2
|
|
|
19
|
-
|
|
20
|
-
require 'json'
|
|
21
|
-
require 'time'
|
|
22
|
-
require 'uri'
|
|
3
|
+
$LOAD_PATH.unshift File.expand_path('../lib', __dir__)
|
|
23
4
|
|
|
5
|
+
require 'didkit'
|
|
6
|
+
require 'json'
|
|
24
7
|
require 'minisky'
|
|
8
|
+
require 'optparse'
|
|
25
9
|
require 'skyfall'
|
|
26
|
-
require '
|
|
10
|
+
require 'time'
|
|
11
|
+
require 'uri'
|
|
27
12
|
|
|
28
|
-
|
|
13
|
+
require 'ratproto/version'
|
|
29
14
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
HOST_PORT_REGEX = /\A#{DOMAIN_REGEX.source}(?::\d+)?\z/i
|
|
15
|
+
DID_REGEXP = /\Adid:[a-z]+:[a-zA-Z0-9.\-_]+\z/
|
|
16
|
+
NSID_REGEXP = /\A[a-z0-9]+(\.[a-z0-9]+)+\z/
|
|
33
17
|
|
|
34
|
-
def
|
|
35
|
-
<<~HELP
|
|
36
|
-
rat #{VERSION}
|
|
18
|
+
def print_help
|
|
19
|
+
puts <<~HELP
|
|
20
|
+
rat #{RatProto::VERSION} π
|
|
37
21
|
|
|
38
22
|
Usage:
|
|
39
|
-
rat fetch at
|
|
40
|
-
rat stream <
|
|
41
|
-
rat resolve <did
|
|
42
|
-
rat help | --help
|
|
43
|
-
rat version | --version
|
|
23
|
+
rat fetch at://uri
|
|
24
|
+
rat stream <firehose-host> [options]
|
|
25
|
+
rat resolve <did>|<handle>
|
|
44
26
|
|
|
45
27
|
Commands:
|
|
46
28
|
fetch Fetch a single record given its at:// URI
|
|
47
|
-
stream Stream
|
|
48
|
-
resolve Resolve a DID or @handle
|
|
29
|
+
stream Stream events from a relay / PDS firehose
|
|
30
|
+
resolve Resolve a DID or @handle
|
|
49
31
|
help Show this help
|
|
50
32
|
version Show version
|
|
51
33
|
|
|
52
34
|
Stream options:
|
|
53
|
-
-j, --jetstream Use
|
|
54
|
-
|
|
55
|
-
when -d/-c are provided)
|
|
56
|
-
-r, --cursor CURSOR Start from cursor (seq or time_us)
|
|
35
|
+
-j, --jetstream Use a Jetstream source instead of a CBOR firehose
|
|
36
|
+
-r, --cursor CURSOR Start from a given cursor
|
|
57
37
|
-d, --did DID[,DID...] Filter only events from given DID(s)
|
|
58
38
|
(can be passed multiple times)
|
|
59
39
|
-c, --collection NSID[,NSID...] Filter only events of given collection(s)
|
|
@@ -61,313 +41,199 @@ def global_help
|
|
|
61
41
|
HELP
|
|
62
42
|
end
|
|
63
43
|
|
|
64
|
-
def
|
|
65
|
-
|
|
66
|
-
warn
|
|
67
|
-
warn global_help
|
|
44
|
+
def abort_with_error(message)
|
|
45
|
+
puts message
|
|
68
46
|
exit 1
|
|
69
47
|
end
|
|
70
48
|
|
|
71
|
-
def
|
|
72
|
-
|
|
73
|
-
|
|
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}")
|
|
49
|
+
def validate_did(did)
|
|
50
|
+
unless did =~ DID_REGEXP
|
|
51
|
+
abort_with_error "Error: #{did.inspect} is not a valid DID"
|
|
87
52
|
end
|
|
88
|
-
s
|
|
89
53
|
end
|
|
90
54
|
|
|
91
|
-
def
|
|
92
|
-
unless
|
|
93
|
-
|
|
55
|
+
def validate_nsid(collection)
|
|
56
|
+
unless collection =~ NSID_REGEXP
|
|
57
|
+
abort_with_error "Error: #{collection.inspect} is not a valid collection NSID"
|
|
94
58
|
end
|
|
95
|
-
s
|
|
96
59
|
end
|
|
97
60
|
|
|
98
|
-
def
|
|
99
|
-
unless
|
|
100
|
-
|
|
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}")
|
|
61
|
+
def parse_at_uri(uri)
|
|
62
|
+
unless uri.start_with?('at://')
|
|
63
|
+
abort_with_error "Error: not an at:// URI: #{uri.inspect}"
|
|
108
64
|
end
|
|
109
65
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
abort_with_help("invalid at:// URI: expected at://<repo>/<collection>/<rkey>")
|
|
66
|
+
unless uri =~ /\Aat:\/\/([^\/]+)\/([^\/]+)\/([^\/]+)\z/
|
|
67
|
+
abort_with_error "Error: invalid at:// URI: #{uri.inspect}"
|
|
113
68
|
end
|
|
114
69
|
|
|
115
|
-
|
|
116
|
-
[repo, collection, rkey]
|
|
70
|
+
[$1, $2, $3]
|
|
117
71
|
end
|
|
118
72
|
|
|
119
|
-
def
|
|
120
|
-
|
|
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
|
|
73
|
+
def run_fetch(args)
|
|
74
|
+
uri = args.shift
|
|
128
75
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
unless pds_host && !pds_host.empty?
|
|
132
|
-
abort_with_help("DID document for #{did_obj} does not contain a pds_host")
|
|
76
|
+
if uri.nil?
|
|
77
|
+
abort_with_error "Usage: #{$PROGRAM_NAME} fetch <at://uri>"
|
|
133
78
|
end
|
|
134
79
|
|
|
135
|
-
|
|
136
|
-
|
|
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(' ')}")
|
|
80
|
+
if !args.empty?
|
|
81
|
+
abort_with_error "Error: Unexpected arguments for fetch: #{args.join(' ')}"
|
|
145
82
|
end
|
|
146
83
|
|
|
147
|
-
|
|
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 }
|
|
84
|
+
repo, collection, rkey = parse_at_uri(uri)
|
|
156
85
|
|
|
157
86
|
begin
|
|
158
|
-
|
|
87
|
+
pds = DID.new(repo).document.pds_host
|
|
88
|
+
sky = Minisky.new(pds, nil)
|
|
89
|
+
|
|
90
|
+
response = sky.get_request('com.atproto.repo.getRecord', {
|
|
91
|
+
repo: repo, collection: collection, rkey: rkey
|
|
92
|
+
})
|
|
159
93
|
rescue StandardError => e
|
|
160
|
-
|
|
161
|
-
exit 1
|
|
94
|
+
abort_with_error "Error loading record: #{e.class}: #{e.message}"
|
|
162
95
|
end
|
|
163
96
|
|
|
164
|
-
|
|
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
|
|
97
|
+
puts JSON.pretty_generate(response['value'])
|
|
171
98
|
end
|
|
172
99
|
|
|
173
|
-
def
|
|
174
|
-
target =
|
|
175
|
-
|
|
176
|
-
|
|
100
|
+
def run_resolve(args)
|
|
101
|
+
target = args.shift
|
|
102
|
+
|
|
103
|
+
if target.nil?
|
|
104
|
+
abort_with_error "Usage: #{$PROGRAM_NAME} resolve <did>|<handle>"
|
|
177
105
|
end
|
|
178
106
|
|
|
179
|
-
if
|
|
180
|
-
|
|
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
|
|
107
|
+
if !args.empty?
|
|
108
|
+
abort_with_error "Error: Unexpected arguments for resolve: #{args.join(' ')}"
|
|
204
109
|
end
|
|
205
|
-
end
|
|
206
110
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
|
111
|
+
did = DID.resolve_handle(target)
|
|
112
|
+
|
|
113
|
+
if did.nil?
|
|
114
|
+
abort_with_error "Couldn't resolve #{target}"
|
|
224
115
|
end
|
|
225
116
|
|
|
226
|
-
|
|
227
|
-
rescue
|
|
228
|
-
|
|
117
|
+
puts JSON.pretty_generate(did.document.json)
|
|
118
|
+
rescue StandardError => e
|
|
119
|
+
abort_with_error "Error resolving #{target.inspect}: #{e.class}: #{e.message}"
|
|
229
120
|
end
|
|
230
121
|
|
|
231
|
-
def parse_stream_options(
|
|
232
|
-
options = {
|
|
233
|
-
use_jetstream: false,
|
|
234
|
-
cursor: nil,
|
|
235
|
-
dids: [],
|
|
236
|
-
collections: []
|
|
237
|
-
}
|
|
122
|
+
def parse_stream_options(args)
|
|
123
|
+
options = {}
|
|
238
124
|
|
|
239
125
|
parser = OptionParser.new do |opts|
|
|
240
|
-
opts.banner = "Usage:
|
|
126
|
+
opts.banner = "Usage: #{$PROGRAM_NAME} stream <relay.host> [options]"
|
|
241
127
|
|
|
242
|
-
opts.on('-j', '--jetstream', 'Use
|
|
243
|
-
options[:
|
|
128
|
+
opts.on('-j', '--jetstream', 'Use a Jetstream source') do
|
|
129
|
+
options[:jetstream] = true
|
|
244
130
|
end
|
|
245
131
|
|
|
246
|
-
opts.on('-rCURSOR', '--cursor=CURSOR', 'Start from
|
|
132
|
+
opts.on('-rCURSOR', '--cursor=CURSOR', 'Start from a given cursor') do |cursor|
|
|
247
133
|
options[:cursor] = cursor
|
|
248
134
|
end
|
|
249
135
|
|
|
250
|
-
opts.on('-dLIST', '--did=LIST',
|
|
251
|
-
'Filter only events from DID(s) (comma-separated or repeated)') do |list|
|
|
136
|
+
opts.on('-dLIST', '--did=LIST', 'Filter only events from given DID(s) (comma-separated or repeated)') do |list|
|
|
252
137
|
items = list.split(',').map(&:strip).reject(&:empty?)
|
|
138
|
+
|
|
253
139
|
if items.empty?
|
|
254
|
-
|
|
140
|
+
abort_with_error "Error: empty argument to -d/--did"
|
|
255
141
|
end
|
|
256
|
-
|
|
142
|
+
|
|
143
|
+
options[:dids] ||= []
|
|
144
|
+
options[:dids] += items
|
|
257
145
|
end
|
|
258
146
|
|
|
259
|
-
opts.on('-cLIST', '--collection=LIST',
|
|
260
|
-
'Filter only events of given collection NSID(s)') do |list|
|
|
147
|
+
opts.on('-cLIST', '--collection=LIST', 'Filter only events of given collections') do |list|
|
|
261
148
|
items = list.split(',').map(&:strip).reject(&:empty?)
|
|
149
|
+
|
|
262
150
|
if items.empty?
|
|
263
|
-
|
|
151
|
+
abort_with_error "Error: empty argument to -c/--collection"
|
|
264
152
|
end
|
|
265
|
-
|
|
153
|
+
|
|
154
|
+
options[:collections] ||= []
|
|
155
|
+
options[:collections] += items
|
|
266
156
|
end
|
|
267
157
|
|
|
268
158
|
opts.on('-h', '--help', 'Show stream-specific help') do
|
|
269
159
|
puts opts
|
|
270
|
-
exit
|
|
160
|
+
exit
|
|
271
161
|
end
|
|
272
162
|
end
|
|
273
163
|
|
|
274
164
|
remaining = []
|
|
165
|
+
|
|
275
166
|
begin
|
|
276
|
-
parser.order!(
|
|
167
|
+
parser.order!(args) { |other| remaining << other }
|
|
277
168
|
rescue OptionParser::InvalidOption, OptionParser::MissingArgument => e
|
|
278
|
-
|
|
279
|
-
|
|
169
|
+
puts "Error: #{e.message}"
|
|
170
|
+
puts parser
|
|
280
171
|
exit 1
|
|
281
172
|
end
|
|
282
173
|
|
|
283
174
|
[options, remaining]
|
|
284
175
|
end
|
|
285
176
|
|
|
286
|
-
def
|
|
287
|
-
options,
|
|
177
|
+
def run_stream(args)
|
|
178
|
+
options, arguments = parse_stream_options(args)
|
|
288
179
|
|
|
289
|
-
|
|
290
|
-
validate_relay_host!(host)
|
|
180
|
+
service = arguments.shift
|
|
291
181
|
|
|
292
|
-
|
|
293
|
-
|
|
182
|
+
if service.nil?
|
|
183
|
+
abort_with_error "Usage: #{$PROGRAM_NAME} stream <firehose-host> [options]"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
if !arguments.empty?
|
|
187
|
+
abort_with_error "Error: Unexpected arguments for stream: #{arguments.join(' ')}"
|
|
294
188
|
end
|
|
295
189
|
|
|
296
|
-
# validate cursor (if given)
|
|
297
190
|
if options[:cursor] && options[:cursor] !~ /\A\d+\z/
|
|
298
|
-
|
|
191
|
+
abort_with_error "Error: cursor must be a decimal integer, got: #{options[:cursor].inspect}"
|
|
299
192
|
end
|
|
300
193
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
validate_did!(did)
|
|
194
|
+
if options[:dids]
|
|
195
|
+
options[:dids].each { |did| validate_did(did) }
|
|
304
196
|
end
|
|
305
197
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
validate_nsid!(nsid)
|
|
198
|
+
if options[:collections]
|
|
199
|
+
options[:collections].each { |collection| validate_nsid(collection) }
|
|
309
200
|
end
|
|
310
201
|
|
|
311
|
-
|
|
312
|
-
if options[:use_jetstream]
|
|
202
|
+
if options[:jetstream]
|
|
313
203
|
jet_opts = {}
|
|
314
204
|
jet_opts[:cursor] = options[:cursor].to_i if options[:cursor]
|
|
315
205
|
|
|
316
|
-
#
|
|
317
|
-
jet_opts[:wanted_dids] = options[:dids]
|
|
318
|
-
jet_opts[:wanted_collections] = options[:collections]
|
|
206
|
+
# pass DID/collection filters to Jetstream to filter events server-side
|
|
207
|
+
jet_opts[:wanted_dids] = options[:dids] if options[:dids]
|
|
208
|
+
jet_opts[:wanted_collections] = options[:collections] if options[:collections]
|
|
319
209
|
|
|
320
|
-
sky = Skyfall::Jetstream.new(
|
|
210
|
+
sky = Skyfall::Jetstream.new(service, jet_opts)
|
|
321
211
|
else
|
|
322
212
|
cursor = options[:cursor]&.to_i
|
|
323
|
-
sky = Skyfall::Firehose.new(
|
|
213
|
+
sky = Skyfall::Firehose.new(service, :subscribe_repos, cursor)
|
|
324
214
|
end
|
|
325
215
|
|
|
326
|
-
# Lifecycle logging
|
|
327
216
|
sky.on_connecting { |url| puts "Connecting to #{url}..." }
|
|
328
217
|
sky.on_connect { puts "Connected" }
|
|
329
218
|
sky.on_disconnect { puts "Disconnected" }
|
|
330
219
|
sky.on_reconnect { puts "Connection lost, trying to reconnect..." }
|
|
331
|
-
sky.on_timeout { puts "Connection stalled, triggering a reconnect..." }
|
|
332
|
-
sky.on_error { |e|
|
|
220
|
+
sky.on_timeout { puts "Connection stalled, triggering a reconnect..." }
|
|
221
|
+
sky.on_error { |e| puts "ERROR: #{e}" }
|
|
333
222
|
|
|
334
|
-
# Message handler
|
|
335
223
|
sky.on_message do |msg|
|
|
336
224
|
next unless msg.type == :commit
|
|
225
|
+
next if options[:dids] && !options[:dids].include?(msg.repo)
|
|
337
226
|
|
|
338
|
-
|
|
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'
|
|
227
|
+
time = msg.time.getlocal.iso8601
|
|
351
228
|
|
|
352
229
|
msg.operations.each do |op|
|
|
353
|
-
|
|
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}"
|
|
230
|
+
next if options[:collections] && !options[:collections].include?(op.collection)
|
|
360
231
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
line << " " << JSON.generate(op.raw_record)
|
|
364
|
-
end
|
|
365
|
-
|
|
366
|
-
puts line
|
|
232
|
+
json = op.raw_record && JSON.generate(op.raw_record)
|
|
233
|
+
puts "[#{time}] #{msg.repo} #{op.action.inspect} #{op.collection} #{op.rkey} #{json}"
|
|
367
234
|
end
|
|
368
235
|
end
|
|
369
236
|
|
|
370
|
-
# Clean disconnect on Ctrl+C
|
|
371
237
|
trap('SIGINT') do
|
|
372
238
|
puts 'Disconnecting...'
|
|
373
239
|
sky.disconnect
|
|
@@ -376,28 +242,24 @@ def do_stream(argv)
|
|
|
376
242
|
sky.connect
|
|
377
243
|
end
|
|
378
244
|
|
|
379
|
-
# ---- main dispatcher ----
|
|
380
|
-
|
|
381
245
|
if ARGV.empty?
|
|
382
|
-
|
|
383
|
-
exit
|
|
246
|
+
print_help
|
|
247
|
+
exit
|
|
384
248
|
end
|
|
385
249
|
|
|
386
250
|
cmd = ARGV.shift
|
|
387
251
|
|
|
388
252
|
case cmd
|
|
389
253
|
when 'help', '--help', '-h'
|
|
390
|
-
|
|
391
|
-
exit 0
|
|
254
|
+
print_help
|
|
392
255
|
when 'version', '--version'
|
|
393
|
-
puts VERSION
|
|
394
|
-
exit 0
|
|
256
|
+
puts "RatProto #{RatProto::VERSION} π"
|
|
395
257
|
when 'fetch'
|
|
396
|
-
|
|
258
|
+
run_fetch(ARGV)
|
|
397
259
|
when 'resolve'
|
|
398
|
-
|
|
260
|
+
run_resolve(ARGV)
|
|
399
261
|
when 'stream'
|
|
400
|
-
|
|
262
|
+
run_stream(ARGV)
|
|
401
263
|
else
|
|
402
|
-
|
|
264
|
+
abort_with_error "Error: unknown command: #{cmd}"
|
|
403
265
|
end
|
data/lib/ratproto/version.rb
CHANGED