tapfall 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 +7 -0
- data/CHANGELOG.md +3 -0
- data/LICENSE.txt +21 -0
- data/README.md +209 -0
- data/lib/tapfall/api.rb +78 -0
- data/lib/tapfall/errors.rb +32 -0
- data/lib/tapfall/messages/identity_message.rb +30 -0
- data/lib/tapfall/messages/operation.rb +56 -0
- data/lib/tapfall/messages/record_message.rb +15 -0
- data/lib/tapfall/messages/tap_message.rb +44 -0
- data/lib/tapfall/messages/unknown_message.rb +4 -0
- data/lib/tapfall/stream.rb +101 -0
- data/lib/tapfall/version.rb +5 -0
- data/lib/tapfall.rb +7 -0
- data/sig/tapfall.rbs +4 -0
- metadata +71 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 10d16e103fb9bb828f86ba932076d45f3f7d5e89b81eceb3f60292dfc5605aa7
|
|
4
|
+
data.tar.gz: 3087c8bbc196e557f046aaef5bfc48ac49220d2efc835996319b3b74db352aa7
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: e18feb0d610d2d6e16edcb6e68fd6f8e8a949117e609e519501b4b2299aa0f2b721e7b008f8f4be5f3fdb1239f43ae8a5ce7fa3cc2d6991262fa99917e7b21d2
|
|
7
|
+
data.tar.gz: 91557b3f07d953e622ad190c24c0bf471ece14184e004731f30eaa104a36f78121704ee1e38b0191ec7d3e5d02549f383300002d5bb11efeaed48363c0537b3a
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The zlib License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 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,209 @@
|
|
|
1
|
+
# Tapfall
|
|
2
|
+
|
|
3
|
+
A Ruby gem for ingesting ATProto repository data from a [Tap](https://github.com/bluesky-social/indigo/tree/main/cmd/tap) service (extension of the [Skyfall](https://tangled.org/mackuba.eu/skyfall) gem).
|
|
4
|
+
|
|
5
|
+
> [!NOTE]
|
|
6
|
+
> Part of ATProto Ruby SDK: [ruby.sdk.blue](https://ruby.sdk.blue)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
## What does it do
|
|
10
|
+
|
|
11
|
+
[Tap](https://github.com/bluesky-social/indigo/tree/main/cmd/tap) is a [tool made by Bluesky](https://docs.bsky.app/blog/introducing-tap), which combines a firehose client/adapter with a JSON output like [Jetstream](https://github.com/bluesky-social/jetstream) and a repository/PDS crawler and importer. It can be useful if you're building some kind of backend app that needs to import and store some set of records from the Atmosphere. It's meant to be run locally on your app's server, with only your app connecting to it, and it simplifies a lot of code for you if you need to both import & backfill existing records of some kind and also stream any new ones that are added after you start the import.
|
|
12
|
+
|
|
13
|
+
Basically, before Tap, your app code needed to:
|
|
14
|
+
|
|
15
|
+
1) Connect to a relay or Jetstream
|
|
16
|
+
2) Filter only records of selected kinds
|
|
17
|
+
3) Find out which repos on which PDSes have records that are relevant to you
|
|
18
|
+
4) Connect to those PDSes and get those records via listRecords or getRepo
|
|
19
|
+
5) Handle possible duplicates between imported repo and the firehose
|
|
20
|
+
|
|
21
|
+
If you only want some kinds of records from some specific repos, that still leaves you with both a firehose client and a repo importer and merging the results from both somehow.
|
|
22
|
+
|
|
23
|
+
With Tap, you only need to:
|
|
24
|
+
|
|
25
|
+
1) Run Tap, passing it a list of record types to sync in command line parameters or in env
|
|
26
|
+
2) Connect to the Tap stream on localhost
|
|
27
|
+
3) Save everything coming from the stream
|
|
28
|
+
|
|
29
|
+
So instead of two ways of importing the records, you only have one and it's the much less involved one. And this library also handles a lot of this for you.
|
|
30
|
+
|
|
31
|
+
**Tapfall** is an extension of [Skyfall](https://tangled.org/mackuba.eu/skyfall), which is a gem for streaming records from a relay/PDS firehose or Jetstream, and it adds support for the event format used by Tap and for some additional HTTP APIs it provides.
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
|
|
36
|
+
Add this to your `Gemfile`:
|
|
37
|
+
|
|
38
|
+
gem 'tapfall'
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
Create a `Tapfall::Stream` object, specifying the address of the Tap service websocket:
|
|
44
|
+
|
|
45
|
+
```rb
|
|
46
|
+
require 'tapfall'
|
|
47
|
+
|
|
48
|
+
tap = Tapfall::Stream.new('ws://localhost:2480')
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
You can also just pass a hostname, but then it's interpreted as HTTPS/WSS, which might not be what you want.
|
|
52
|
+
|
|
53
|
+
Next, set up event listeners to handle incoming messages and get notified of errors. Here are all the available listeners (you will need at least `on_message`):
|
|
54
|
+
|
|
55
|
+
```rb
|
|
56
|
+
# this gives you a parsed message object, one of subclasses of Tapfall::TapMessage
|
|
57
|
+
tap.on_message { |msg| p msg }
|
|
58
|
+
|
|
59
|
+
# lifecycle events
|
|
60
|
+
tap.on_connecting { |url| puts "Connecting to #{url}..." }
|
|
61
|
+
tap.on_connect { puts "Connected" }
|
|
62
|
+
tap.on_disconnect { puts "Disconnected" }
|
|
63
|
+
tap.on_reconnect { puts "Connection lost, trying to reconnect..." }
|
|
64
|
+
tap.on_timeout { puts "Connection stalled, triggering a reconnect..." }
|
|
65
|
+
|
|
66
|
+
# handling errors (there's a default error handler that does exactly this)
|
|
67
|
+
tap.on_error { |e| puts "ERROR: #{e}" }
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
You can also call these as setters accepting a `Proc` – e.g. to disable default error handling, you can do:
|
|
71
|
+
|
|
72
|
+
```rb
|
|
73
|
+
tap.on_error = nil
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
When you're ready, open the connection by calling `connect`:
|
|
77
|
+
|
|
78
|
+
```rb
|
|
79
|
+
tap.connect
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The `#connect` method blocks until the connection is explicitly closed with `#disconnect` from an event or interrupt handler. Tapfall & Skyfall use [EventMachine](https://github.com/eventmachine/eventmachine) under the hood, so in order to run some things in parallel, you can use e.g. `EM::PeriodicTimer`.
|
|
83
|
+
|
|
84
|
+
Tapfall also supports Skyfall's `on_raw_message` handler version, but only if you use Tap in "disable acks" mode (see below), which is not recommended beyond testing, unless you're doing the acks yourself. (This is because Tapfall needs to parse the message into a JSON form in order to get the `id` of the event to send the "ack".)
|
|
85
|
+
|
|
86
|
+
> [!NOTE]
|
|
87
|
+
> Unlike standard firehose and Jetstream, Tap streams don't have a cursor that you store and pass when reconnecting. It's meant to be used only by one client, and it tracks internally itself which events have been sent to you and which weren't. You can think about it this way: it's not a public service like Jetstream that you can share with others, it's a microservice that you run as a component of your app.
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
### Acks
|
|
91
|
+
|
|
92
|
+
Tap by default runs in a mode where it expects the client to send back an "ack" after receiving and processing each event. When it gets the ack, it marks the event as processed and will not send it again. If you don't send an ack, it tries to retransmit the event after a moment.
|
|
93
|
+
|
|
94
|
+
You can also run it with acks disabled, by passing a `--disable-acks` option or `TAP_DISABLE_ACKS=true` env var, in which case it will assume an event has been processed as soon as it's sent to you. This is not recommended to do in production, since if your process crashes during an event processing loop, that event will be lost (and you can't ask for an earlier cursor because there's no cursor).
|
|
95
|
+
|
|
96
|
+
Tapfall handles the acks for you automatically. If you want it to not send acks, pass an `:ack => false` option to the constructor:
|
|
97
|
+
|
|
98
|
+
```rb
|
|
99
|
+
tap = Tapfall::Stream.new(server, { ack: false })
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Password-protected access
|
|
103
|
+
|
|
104
|
+
Tap also lets you set an admin password, which you can set with the `--admin-password` option or `TAP_ADMIN_PASSWORD` env var. This locks the stream and the API behind HTTP Basic auth (with the user `admin`). Pass the password to Tapfall constructor like this:
|
|
105
|
+
|
|
106
|
+
```rb
|
|
107
|
+
tap = Tapfall::Stream.new(server { admin_password: 'abracadabra' })
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Processing messages
|
|
111
|
+
|
|
112
|
+
Each message passed to `on_message` is an instance of a subclass of `Tapfall::TapMessage`. The main event type is `Tapfall::RecordMessage`, which includes a record operation; you will also receive `Tapfall::IdentityMessage` events, which provide info about an account change like changed handle or migration to a new PDS. `UnknownMessage` might be sent if new unrecognized message types are sent in the future.
|
|
113
|
+
|
|
114
|
+
All message types share these properties:
|
|
115
|
+
|
|
116
|
+
- `type` (symbol) – the message type identifier, e.g. `:record`
|
|
117
|
+
- `id` (integer), aliased as `seq` – a sequential index of the message
|
|
118
|
+
|
|
119
|
+
The `:record` messages have an `operations` method, which includes an array of add/remove/edit `Operation`s done on some records. Currently Tap event format only includes one single record operation in each event, but it's returned as an array here for symmetry with the `Skyfall::Firehose` stream version.
|
|
120
|
+
|
|
121
|
+
An `Operation` has such fields (also matching the API of `Skyfall::Firehose::Operation` and `Skyfall::Jetstream::Operation`):
|
|
122
|
+
|
|
123
|
+
- `repo` or `did` (string) – DID of the repository (user account)
|
|
124
|
+
- `collection` (string) – name of the collection / record type, e.g. `app.bsky.feed.post` for posts
|
|
125
|
+
- `type` (symbol) – short name of the collection, e.g. `:bsky_post`
|
|
126
|
+
- `rkey` (string) – identifier of a record in a collection
|
|
127
|
+
- `path` (string) – the path part of the at:// URI – collection name + ID (rkey) of the item
|
|
128
|
+
- `uri` (string) – the complete at:// URI
|
|
129
|
+
- `action` (symbol) – `:create`, `:update` or `:delete`
|
|
130
|
+
- `cid` (CID) – CID of the operation/record (`nil` for delete operations)
|
|
131
|
+
- `live?` (boolean) – true if the record was received from the firehose, false if it was backfilled from the repo
|
|
132
|
+
|
|
133
|
+
Create and update operations will also have an attached record (JSON object) with details of the post, like etc. The record data is currently available as a Ruby hash via `raw_record` property (custom types may be added in future).
|
|
134
|
+
|
|
135
|
+
So for example, in order to filter only "create post" operations and print their details, you can do something like this:
|
|
136
|
+
|
|
137
|
+
```rb
|
|
138
|
+
tap.on_message do |m|
|
|
139
|
+
next if m.type != :record
|
|
140
|
+
|
|
141
|
+
m.operations.each do |op|
|
|
142
|
+
next unless op.action == :create && op.type == :bsky_post
|
|
143
|
+
|
|
144
|
+
puts "#{op.repo}:"
|
|
145
|
+
puts op.raw_record['text']
|
|
146
|
+
puts
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
### Note on custom lexicons
|
|
153
|
+
|
|
154
|
+
Note that the `Operation` objects have two properties that tell you the kind of record they're about: `#collection`, which is a string containing the official name of the collection/lexicon, e.g. `"app.bsky.feed.post"`; and `#type`, which is a symbol meant to save you some typing, e.g. `:bsky_post`.
|
|
155
|
+
|
|
156
|
+
When Tapfall receives a message about a record type that's not on the list, whether in the `app.bsky` namespace or not, the operation `type` will be `:unknown`, while the `collection` will be the original string. So if an app like e.g. "Skygram" appears with a `zz.skygram.*` namespace that lets you share photos on ATProto, the operations will have a type `:unknown` and collection names like `zz.skygram.feed.photo`, and you can check the `collection` field for record types known to you and process them in some appropriate way, even if Tapfall doesn't recognize the record type.
|
|
157
|
+
|
|
158
|
+
Do not however check if such operations have a `type` equal to `:unknown` first – just ignore the type and only check the `collection` string. The reason is that some next version might start recognizing those records and add a new `type` value for them like e.g. `:skygram_photo`, and then they won't match your condition anymore.
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
### Reconnection logic
|
|
162
|
+
|
|
163
|
+
See the section in the [Skyfall readme](https://tangled.org/mackuba.eu/skyfall#reconnection-logic) about the options for handling reconnecting to a flaky firehose – but since in this case the Tap service will run under your control, likely on the same machine, this might not be as useful in practice.
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
## HTTP API
|
|
167
|
+
|
|
168
|
+
Apart from the `/channel` websocket endpoint, Tap also has [a few other endpoints](https://github.com/bluesky-social/indigo/tree/main/cmd/tap#http-api) for adding/removing repos and checking various stats.
|
|
169
|
+
|
|
170
|
+
You can call all the below methods either on the `Tapfall::Stream` instance you use for connecting to the websocket, or on a separate `Tapfall::API` object if you prefer.
|
|
171
|
+
|
|
172
|
+
Currently implemented endpoints:
|
|
173
|
+
|
|
174
|
+
### /repos/add
|
|
175
|
+
|
|
176
|
+
Tap can work in three possible ways regarding the subset of repos it tracks:
|
|
177
|
+
|
|
178
|
+
1) `TAP_FULL_NETWORK`, when it tracks *all repos everywhere*
|
|
179
|
+
2) `TAP_SIGNAL_COLLECTION`, when it finds and tracks all repos that have some specific types of records you're interested in
|
|
180
|
+
3) default mode, when it only tracks repos you've added manually
|
|
181
|
+
|
|
182
|
+
In that third mode, use this to add repos to the tracking list:
|
|
183
|
+
|
|
184
|
+
```rb
|
|
185
|
+
@tap.add_repo('did:plc:uh4errluyq5thgszrwwrtpuq')
|
|
186
|
+
|
|
187
|
+
# or:
|
|
188
|
+
@tap.add_repos(did_list)
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### /repos/remove
|
|
192
|
+
|
|
193
|
+
To remove repos from the list, use:
|
|
194
|
+
|
|
195
|
+
```rb
|
|
196
|
+
@tap.remove_repo('did:plc:uh4errluyq5thgszrwwrtpuq')
|
|
197
|
+
|
|
198
|
+
# or:
|
|
199
|
+
@tap.remove_repos(did_list)
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
## Credits
|
|
204
|
+
|
|
205
|
+
Copyright © 2025 Kuba Suder ([@mackuba.eu](https://bsky.app/profile/did:plc:oio4hkxaop4ao4wz2pp3f4cr)).
|
|
206
|
+
|
|
207
|
+
The code is available under the terms of the [zlib license](https://choosealicense.com/licenses/zlib/) (permissive, similar to MIT).
|
|
208
|
+
|
|
209
|
+
Bug reports and pull requests are welcome 😎
|
data/lib/tapfall/api.rb
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require 'net/http'
|
|
3
|
+
require 'uri'
|
|
4
|
+
|
|
5
|
+
module Tapfall
|
|
6
|
+
class API
|
|
7
|
+
def initialize(server, options = {})
|
|
8
|
+
@root_url = build_root_url(server)
|
|
9
|
+
@options = options
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def add_repo(did)
|
|
13
|
+
add_repos([did])
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def add_repos(dids)
|
|
17
|
+
post_request('/repos/add', { dids: dids })
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def remove_repo(did)
|
|
21
|
+
remove_repos([did])
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def remove_repos(dids)
|
|
25
|
+
post_request('/repos/remove', { dids: dids })
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def build_root_url(server)
|
|
31
|
+
if !server.is_a?(String)
|
|
32
|
+
raise ArgumentError, "Server parameter should be a string"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
if server.include?('://')
|
|
36
|
+
uri = URI(server)
|
|
37
|
+
|
|
38
|
+
if uri.scheme != 'http' && uri.scheme != 'https'
|
|
39
|
+
raise ArgumentError, "Server parameter should be a hostname or a http:// or https:// URL"
|
|
40
|
+
elsif uri.path != ''
|
|
41
|
+
raise ArgumentError, "Server URL should only include a hostname, without path"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
uri.to_s
|
|
45
|
+
else
|
|
46
|
+
server = "https://#{server}"
|
|
47
|
+
uri = URI(server) # raises if invalid
|
|
48
|
+
server
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def post_request(path, json_data)
|
|
53
|
+
uri = URI(@root_url + path)
|
|
54
|
+
|
|
55
|
+
request = Net::HTTP::Post.new(uri)
|
|
56
|
+
request.body = JSON.generate(json_data)
|
|
57
|
+
request.content_type = "application/json"
|
|
58
|
+
|
|
59
|
+
if @options[:admin_password]
|
|
60
|
+
request.basic_auth('admin', @options[:admin_password])
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
response = Net::HTTP.start(uri.hostname, uri.port, :use_ssl => (uri.scheme == 'https')) do |http|
|
|
64
|
+
http.request(request)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
status = response.code.to_i
|
|
68
|
+
message = response.message
|
|
69
|
+
response_body = (response.content_type == 'application/json') ? JSON.parse(response.body) : response.body
|
|
70
|
+
|
|
71
|
+
if response.is_a?(Net::HTTPSuccess)
|
|
72
|
+
response_body
|
|
73
|
+
else
|
|
74
|
+
raise BadResponseError.new(status, message, response_body)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module Tapfall
|
|
2
|
+
class BadResponseError < StandardError
|
|
3
|
+
attr_reader :status, :data
|
|
4
|
+
|
|
5
|
+
def initialize(status, status_message, data)
|
|
6
|
+
@status = status
|
|
7
|
+
@data = data
|
|
8
|
+
|
|
9
|
+
message = if error_message
|
|
10
|
+
"#{status} #{status_message}: #{error_message}"
|
|
11
|
+
else
|
|
12
|
+
"#{status} #{status_message}"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
super(message)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def error_type
|
|
19
|
+
@data['error'] if @data.is_a?(Hash)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def error_message
|
|
23
|
+
@data['message'] if @data.is_a?(Hash)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class ConfigError < StandardError
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
class DecodeError < StandardError
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
require_relative '../errors'
|
|
2
|
+
require_relative 'operation'
|
|
3
|
+
require_relative 'tap_message'
|
|
4
|
+
|
|
5
|
+
module Tapfall
|
|
6
|
+
class IdentityMessage < TapMessage
|
|
7
|
+
def initialize(json)
|
|
8
|
+
raise DecodeError.new("Missing event details") if json['identity'].nil?
|
|
9
|
+
@identity = json['identity']
|
|
10
|
+
|
|
11
|
+
super
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def did
|
|
15
|
+
@identity['did']
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def handle
|
|
19
|
+
@identity['handle']
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def active?
|
|
23
|
+
@identity['isActive']
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def status
|
|
27
|
+
@identity['status']&.to_sym
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
require 'skyfall/cid'
|
|
2
|
+
require 'skyfall/collection'
|
|
3
|
+
|
|
4
|
+
module Tapfall
|
|
5
|
+
class Operation
|
|
6
|
+
def initialize(json)
|
|
7
|
+
@json = json
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def live?
|
|
11
|
+
@json['live']
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def did
|
|
15
|
+
@json['did']
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
alias repo did
|
|
19
|
+
|
|
20
|
+
def rev
|
|
21
|
+
@json['rev']
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def path
|
|
25
|
+
@json['collection'] + '/' + @json['rkey']
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def action
|
|
29
|
+
@json['action'].to_sym
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def collection
|
|
33
|
+
@json['collection']
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def rkey
|
|
37
|
+
@json['rkey']
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def uri
|
|
41
|
+
"at://#{repo}/#{collection}/#{rkey}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def cid
|
|
45
|
+
@cid ||= @json['cid'] && Skyfall::CID.from_json(@json['cid'])
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def raw_record
|
|
49
|
+
@json['record']
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def type
|
|
53
|
+
Skyfall::Collection.short_code(collection)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
require_relative '../errors'
|
|
2
|
+
require_relative 'operation'
|
|
3
|
+
|
|
4
|
+
module Tapfall
|
|
5
|
+
class RecordMessage < TapMessage
|
|
6
|
+
def initialize(json)
|
|
7
|
+
raise DecodeError.new("Missing record details") if json['record'].nil?
|
|
8
|
+
super
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def operations
|
|
12
|
+
@operations ||= [Operation.new(json['record'])]
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require_relative '../errors'
|
|
3
|
+
|
|
4
|
+
module Tapfall
|
|
5
|
+
class TapMessage
|
|
6
|
+
attr_reader :id, :type
|
|
7
|
+
alias seq id
|
|
8
|
+
|
|
9
|
+
# :nodoc: - consider this as semi-private API
|
|
10
|
+
attr_reader :json
|
|
11
|
+
|
|
12
|
+
def self.new(data)
|
|
13
|
+
require_relative 'identity_message'
|
|
14
|
+
require_relative 'record_message'
|
|
15
|
+
require_relative 'unknown_message'
|
|
16
|
+
|
|
17
|
+
json = JSON.parse(data)
|
|
18
|
+
|
|
19
|
+
message_class = case json['type']
|
|
20
|
+
when 'record' then RecordMessage
|
|
21
|
+
when 'identity' then IdentityMessage
|
|
22
|
+
else UnknownMessage
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
message = message_class.allocate
|
|
26
|
+
message.send(:initialize, json)
|
|
27
|
+
message
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def initialize(json)
|
|
31
|
+
@json = json
|
|
32
|
+
@type = @json['type'].to_sym
|
|
33
|
+
@id = @json['id']
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def unknown?
|
|
37
|
+
self.is_a?(UnknownMessage)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def operations
|
|
41
|
+
[]
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
require 'forwardable'
|
|
2
|
+
require 'skyfall/stream'
|
|
3
|
+
|
|
4
|
+
require_relative 'api'
|
|
5
|
+
require_relative 'errors'
|
|
6
|
+
require_relative 'messages/tap_message'
|
|
7
|
+
require_relative 'version'
|
|
8
|
+
|
|
9
|
+
module Tapfall
|
|
10
|
+
class Tapfall::Stream < Skyfall::Stream
|
|
11
|
+
extend Forwardable
|
|
12
|
+
|
|
13
|
+
def_delegators :@api, :add_repo, :add_repos, :remove_repo, :remove_repos
|
|
14
|
+
|
|
15
|
+
def initialize(server, options = {})
|
|
16
|
+
super(server)
|
|
17
|
+
|
|
18
|
+
@options = options
|
|
19
|
+
@root_url = ensure_empty_path(@root_url)
|
|
20
|
+
@ack = true unless options[:ack] == false
|
|
21
|
+
@password = options[:admin_password]
|
|
22
|
+
|
|
23
|
+
@api = build_api
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def connect
|
|
27
|
+
if @ack && @handlers[:message].nil?
|
|
28
|
+
raise ConfigError, "The on(:message) handler must be set unless :ack => false option is passed"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
super
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def handle_message(packet)
|
|
35
|
+
data = packet.data
|
|
36
|
+
@handlers[:raw_message]&.call(data)
|
|
37
|
+
|
|
38
|
+
if @handlers[:message]
|
|
39
|
+
tap_message = TapMessage.new(data)
|
|
40
|
+
@handlers[:message].call(tap_message)
|
|
41
|
+
send_ack(tap_message) if @ack
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def send_ack(msg)
|
|
46
|
+
json = %({"type":"ack","id":#{msg.id}})
|
|
47
|
+
send_data(json)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# TMP
|
|
53
|
+
def send_data(data)
|
|
54
|
+
@ws.send(data)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def build_websocket_client(url)
|
|
58
|
+
Faye::WebSocket::Client.new(url, nil, { headers: { 'User-Agent' => user_agent }.merge(request_headers) })
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def ensure_empty_path(url)
|
|
62
|
+
url = url.chomp('/')
|
|
63
|
+
|
|
64
|
+
if URI(url).path != ''
|
|
65
|
+
raise ArgumentError, "Server URL should only include a hostname, without any path"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
url
|
|
69
|
+
end
|
|
70
|
+
# END
|
|
71
|
+
|
|
72
|
+
def basic_auth(account, password)
|
|
73
|
+
'Basic ' + ["#{account}:#{password}"].pack('m0')
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def request_headers
|
|
77
|
+
if @password
|
|
78
|
+
{ 'Authorization' => basic_auth('admin', @password) }
|
|
79
|
+
else
|
|
80
|
+
{}
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def build_websocket_url
|
|
85
|
+
@root_url + "/channel"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def build_api_url
|
|
89
|
+
if @root_url.start_with?('ws://')
|
|
90
|
+
@root_url.gsub(/^ws:/, 'http:')
|
|
91
|
+
else
|
|
92
|
+
@root_url.gsub(/^wss:/, 'https:')
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def build_api
|
|
97
|
+
api_url = build_api_url
|
|
98
|
+
API.new(api_url, { admin_password: @password })
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
data/lib/tapfall.rb
ADDED
data/sig/tapfall.rbs
ADDED
metadata
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: tapfall
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.0.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Kuba Suder
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: skyfall
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.6'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0.6'
|
|
26
|
+
email:
|
|
27
|
+
- jakub.suder@gmail.com
|
|
28
|
+
executables: []
|
|
29
|
+
extensions: []
|
|
30
|
+
extra_rdoc_files: []
|
|
31
|
+
files:
|
|
32
|
+
- CHANGELOG.md
|
|
33
|
+
- LICENSE.txt
|
|
34
|
+
- README.md
|
|
35
|
+
- lib/tapfall.rb
|
|
36
|
+
- lib/tapfall/api.rb
|
|
37
|
+
- lib/tapfall/errors.rb
|
|
38
|
+
- lib/tapfall/messages/identity_message.rb
|
|
39
|
+
- lib/tapfall/messages/operation.rb
|
|
40
|
+
- lib/tapfall/messages/record_message.rb
|
|
41
|
+
- lib/tapfall/messages/tap_message.rb
|
|
42
|
+
- lib/tapfall/messages/unknown_message.rb
|
|
43
|
+
- lib/tapfall/stream.rb
|
|
44
|
+
- lib/tapfall/version.rb
|
|
45
|
+
- sig/tapfall.rbs
|
|
46
|
+
homepage: https://ruby.sdk.blue
|
|
47
|
+
licenses:
|
|
48
|
+
- Zlib
|
|
49
|
+
metadata:
|
|
50
|
+
bug_tracker_uri: https://tangled.org/mackuba.eu/tapfall/issues
|
|
51
|
+
changelog_uri: https://tangled.org/mackuba.eu/tapfall/blob/master/CHANGELOG.md
|
|
52
|
+
source_code_uri: https://tangled.org/mackuba.eu/tapfall
|
|
53
|
+
rdoc_options: []
|
|
54
|
+
require_paths:
|
|
55
|
+
- lib
|
|
56
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: 3.2.0
|
|
61
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
62
|
+
requirements:
|
|
63
|
+
- - ">="
|
|
64
|
+
- !ruby/object:Gem::Version
|
|
65
|
+
version: '0'
|
|
66
|
+
requirements: []
|
|
67
|
+
rubygems_version: 4.0.1
|
|
68
|
+
specification_version: 4
|
|
69
|
+
summary: A gem for ingesting ATProto repository data from a Tap service (extension
|
|
70
|
+
of the Skyfall gem)
|
|
71
|
+
test_files: []
|