minisky 0.4.0 → 0.5.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 +36 -7
- data/LICENSE.txt +1 -1
- data/README.md +26 -11
- data/lib/minisky/compat.rb +20 -0
- data/lib/minisky/errors.rb +71 -4
- data/lib/minisky/minisky.rb +54 -4
- data/lib/minisky/requests.rb +352 -50
- data/lib/minisky/version.rb +1 -1
- metadata +9 -16
- data/example/ask_password.rb +0 -76
- data/example/fetch_my_posts.rb +0 -46
- data/example/fetch_profile.rb +0 -53
- data/example/post_skeet.rb +0 -36
- data/example/science_feed.rb +0 -53
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 11cd25820ca8ab1f4cd6f9c94604a8229fd2f824695341ccd052aae62bc085c6
|
|
4
|
+
data.tar.gz: ecca2bbad124246a93e7090a3be0ace4675208ad1750d36adfdfa523cf9fd420
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2169712a4d8596840ae0aa9ab891cfccee951f73a7fce146105503a69d647545b3ee0ed8f475bf65096d88226cf66c07a8637048a076c65e9363023877b8b55d
|
|
7
|
+
data.tar.gz: 2283843f151c6f03ce5a275195e21ff85e33085c06a6db56bc9301c205d97ef7ea86ff245b0f609e406c2d54ddaa732b2e72930aba5ad75e27dab2ef7c1bac1c
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,32 @@
|
|
|
1
|
+
## Unreleased
|
|
2
|
+
|
|
3
|
+
The "really niche bugfix" edition:
|
|
4
|
+
|
|
5
|
+
* don't stop fetching in `fetch_all` if an empty page is returned but the cursor is not nil; it's technically allowed for the server to return an empty page but still have more data to send, e.g. if the service does some filtering at the last step and all records happen to be filtered out
|
|
6
|
+
- unfortunately, this on the other hand causes problems with some specific endpoints which incorrectly return a cursor when reaching the end of data, so you can also restore the previous behavior if needed, by setting the `stop_fetch_on_empty_page` option
|
|
7
|
+
* in `post_request`, don't set Content-Type to "application/json" if the data sent is a string or nil (it might cause an error in some cases, like when uploading some binary content)
|
|
8
|
+
* handle the (somewhat theoretical but possible) case where an access token is not a JWT but just some opaque blob – in that case, Minisky will now not throw an error trying to parse it, but just treat it as "unknown" and will not try to refresh it
|
|
9
|
+
- note: at the moment Minisky will not catch the "token expired" error and refresh the token automatically in such scenario
|
|
10
|
+
* allow connecting to non-HTTPS servers (e.g. `http://localhost:3000`)
|
|
11
|
+
* allow making unauthenticated clients with custom classes by returning `nil` from `#config`; custom clients with a config that's missing an `id` or `pass` are treated as an error
|
|
12
|
+
* deprecate logging in using an email address in the `id` field – `createSession` accepts such identifier, but unlike with handle or DID, there's no way to use it to look up the DID document and PDS location if we wanted to
|
|
13
|
+
* fixed URL query params in POST requests on Ruby 2.x
|
|
14
|
+
* marked `Minisky#active_repl?` method as private
|
|
15
|
+
|
|
16
|
+
Also added YARD API documentation for most of the code.
|
|
17
|
+
|
|
18
|
+
## [0.5.0] - 2024-12-27 🎄
|
|
19
|
+
|
|
20
|
+
* `host` param in the initializer can be passed with a `https://` prefix (useful if you're passing it directly from a DID document, e.g. using DIDKit)
|
|
21
|
+
* added validation of the `method` parameter in request calls: it needs to be either a proper NSID, or a full URL as a string or a URI object
|
|
22
|
+
* added new optional `params` keyword argument in `post_request`, which lets you append query parameters to the URL if a POST endpoint requires passing them this way (e.g. `uploadVideo`)
|
|
23
|
+
* `default_progress` is set by default to show progress using dots (`.`) if Minisky is loaded inside an IRB or Pry context
|
|
24
|
+
* when experimenting with Minisky in the console, you can now skip the `field:` parameter to `fetch_all` if you don't remember the expected key name in the response, and the method will make a request and return an error which tells you the list of available keys
|
|
25
|
+
* added `access_token_expired?` helper method
|
|
26
|
+
* moved `token_expiration_date` to public methods
|
|
27
|
+
* `check_access` now returns a result symbol: `:logged_in`, `:refreshed` or `:ok`
|
|
28
|
+
* fixed `method_missing` setter on `User`
|
|
29
|
+
|
|
1
30
|
## [0.4.0] - 2024-03-31 🐣
|
|
2
31
|
|
|
3
32
|
* allow passing non-JSON body to requests (e.g. when uploading blobs)
|
|
@@ -5,7 +34,7 @@
|
|
|
5
34
|
* fixed error when the response is success but not JSON (e.g. an empty body like in deleteRecord)
|
|
6
35
|
* allow passing options to the client in the initializer
|
|
7
36
|
* aliased `default_progress` setting as `progress`
|
|
8
|
-
* added `base64` dependency explicitly to the gemspec
|
|
37
|
+
* added `base64` dependency explicitly to the gemspec – fixes a warning in Ruby 3.3, since it will be extracted as an optional gem in 3.4
|
|
9
38
|
|
|
10
39
|
## [0.3.1] - 2023-10-10
|
|
11
40
|
|
|
@@ -16,11 +45,11 @@
|
|
|
16
45
|
* authentication improvements & changes:
|
|
17
46
|
- Minisky now automatically manages access tokens, calling `check_access` manually is not necessary (set `auto_manage_tokens` to `false` to disable this)
|
|
18
47
|
- `check_access` now just checks token's expiry time instead of making a request to `getSession`
|
|
19
|
-
- added `send_auth_headers` option
|
|
20
|
-
- removed default config file name
|
|
21
|
-
- Minisky can now be used in unauthenticated mode
|
|
48
|
+
- added `send_auth_headers` option – set to `false` to not set auth header automatically, which is the default
|
|
49
|
+
- removed default config file name – explicit file name is now required
|
|
50
|
+
- Minisky can now be used in unauthenticated mode – pass `nil` as the config file name
|
|
22
51
|
- added `reset_tokens` helper method
|
|
23
|
-
* refactored response handling
|
|
52
|
+
* refactored response handling – typed errors are now raised on non-success response status
|
|
24
53
|
* `user` wrapper can also be used for writing fields to the config
|
|
25
54
|
* improved error handling
|
|
26
55
|
|
|
@@ -39,7 +68,7 @@
|
|
|
39
68
|
* renamed `ident` field in the config hash to `id`
|
|
40
69
|
* config is now accessed in `Requests` from the client object as a `config` property instead of `@config` ivar
|
|
41
70
|
* config fields are exposed as a `user` wrapper object, e.g. `user.did` delegates to `@config['did']`
|
|
42
|
-
|
|
71
|
+
|
|
43
72
|
## [0.1.0] - 2023-09-01
|
|
44
73
|
|
|
45
74
|
- extracted most code to a `Requests` module that can be included into a different client class with custom config handling
|
|
@@ -50,7 +79,7 @@
|
|
|
50
79
|
|
|
51
80
|
## [0.0.1] - 2023-08-30
|
|
52
81
|
|
|
53
|
-
Initial release
|
|
82
|
+
Initial release – extracted from original gist:
|
|
54
83
|
|
|
55
84
|
- logging in and refreshing the token
|
|
56
85
|
- making GET & POST requests
|
data/LICENSE.txt
CHANGED
data/README.md
CHANGED
|
@@ -1,19 +1,24 @@
|
|
|
1
|
-
# Minisky
|
|
1
|
+
# Minisky 🌤
|
|
2
2
|
|
|
3
3
|
Minisky is a minimal client of the Bluesky (ATProto) API. It provides a simple API client class that you can use to log in to the Bluesky API and make any GET and POST requests there. It's meant to be an easy way to start playing and experimenting with the AT Protocol API.
|
|
4
4
|
|
|
5
|
+
This is designed as a low-level XRPC client library - it purposefully does not include any convenience methods like "get posts" or "get profile" etc., it only provides base components that you could use to build a higher level API.
|
|
6
|
+
|
|
7
|
+
> [!NOTE]
|
|
8
|
+
> Part of ATProto Ruby SDK: [ruby.sdk.blue](https://ruby.sdk.blue)
|
|
9
|
+
|
|
5
10
|
|
|
6
11
|
## Installation
|
|
7
12
|
|
|
8
|
-
To use Minisky, you need a reasonably new version of Ruby
|
|
13
|
+
To use Minisky, you need a reasonably new version of Ruby – it should run on Ruby 2.6 and above, although it's recommended to use a version that's still getting maintainance updates, i.e. currently 3.2+. A compatible version should be preinstalled on macOS Big Sur and above and on many Linux systems. Otherwise, you can install one using tools such as [RVM](https://rvm.io), [asdf](https://asdf-vm.com), [ruby-install](https://github.com/postmodern/ruby-install) or [ruby-build](https://github.com/rbenv/ruby-build), or `rpm` or `apt-get` on Linux (see more installation options on [ruby-lang.org](https://www.ruby-lang.org/en/downloads/)).
|
|
9
14
|
|
|
10
15
|
To install the Minisky gem, run the command:
|
|
11
16
|
|
|
12
17
|
[sudo] gem install minisky
|
|
13
18
|
|
|
14
|
-
Or
|
|
19
|
+
Or add it to your app's `Gemfile`:
|
|
15
20
|
|
|
16
|
-
gem 'minisky', '~> 0.
|
|
21
|
+
gem 'minisky', '~> 0.5'
|
|
17
22
|
|
|
18
23
|
|
|
19
24
|
## Usage
|
|
@@ -31,14 +36,21 @@ This allows you to do things like:
|
|
|
31
36
|
- look up profile information about any account
|
|
32
37
|
- load complete threads or users' profile feeds from the AppView
|
|
33
38
|
|
|
34
|
-
To use Minisky this way, create a `Minisky` instance passing the API hostname string
|
|
39
|
+
To use Minisky this way, create a `Minisky` instance, passing the API hostname string and `nil` as the configuration in the arguments. Use the hostname `api.bsky.app` or `public.api.bsky.app` for the AppView, or a PDS hostname for the `com.atproto.*` raw data endpoints:
|
|
35
40
|
|
|
36
41
|
```rb
|
|
37
42
|
require 'minisky'
|
|
38
43
|
|
|
39
|
-
bsky = Minisky.new('bsky.
|
|
44
|
+
bsky = Minisky.new('api.bsky.app', nil)
|
|
40
45
|
```
|
|
41
46
|
|
|
47
|
+
> [!NOTE]
|
|
48
|
+
> To call PDS endpoints like `getRecord` or `listRecords`, you need to connect to the PDS of the user whose data you're loading, not to yours (unless it's the same one). Alternatively, you can use the `bsky.social` "entryway" PDS hostname for any Bluesky-hosted accounts, but this will not work for self-hosted accounts.
|
|
49
|
+
>
|
|
50
|
+
> To look up the PDS hostname of a user given their handle or DID, you can use the [didkit](https://tangled.org/mackuba.eu/didkit) library.
|
|
51
|
+
>
|
|
52
|
+
> For the AppView, `api.bsky.app` connects directly to Bluesky's AppView, and `public.api.bsky.app` to a version with extra caching that will usually be faster.
|
|
53
|
+
|
|
42
54
|
|
|
43
55
|
### Authenticated access
|
|
44
56
|
|
|
@@ -53,9 +65,12 @@ pass: very-secret-password
|
|
|
53
65
|
|
|
54
66
|
The `id` can be either your handle, or your DID, or the email you've used to sign up. It's recommended that you use the "app password" that you can create in the settings instead of your main account password.
|
|
55
67
|
|
|
68
|
+
> [!NOTE]
|
|
69
|
+
> Bluesky has recently implemented OAuth, but Minisky doesn't support it yet - it will be added in a future version. App passwords should still be supported for a fairly long time.
|
|
70
|
+
|
|
56
71
|
After you log in, this file will also be used to store your access & request tokens and DID. The data in the config file can be accessed through a `user` wrapper property that exposes them as methods, e.g. the password is available as `user.pass` and the DID as `user.did`.
|
|
57
72
|
|
|
58
|
-
Next, create the Minisky client instance, passing
|
|
73
|
+
Next, create the Minisky client instance, passing your PDS hostname (for Bluesky-hosted PDSes, you can use either `bsky.social` or your specific PDS like `amanita.us-east.host.bsky.network`) and the name of the config file:
|
|
59
74
|
|
|
60
75
|
```rb
|
|
61
76
|
require 'minisky'
|
|
@@ -93,7 +108,7 @@ bsky.post_request('com.atproto.repo.createRecord', {
|
|
|
93
108
|
|
|
94
109
|
In authenticated mode, the requests use the saved access token for auth headers automatically. You can also pass `auth: false` or `auth: nil` to not send any authentication headers for a given request, or `auth: sometoken` to use a specific other token. In unauthenticated mode, sending of auth headers is disabled.
|
|
95
110
|
|
|
96
|
-
The third useful method you can use is `#fetch_all`, which loads multiple paginated responses and collects all returned items on a single list (you need to pass the name of the field that contains the items in the response). Optionally, you can also specify a limit of pages to load as `max_pages: n`, or a break condition `break_when` to stop fetching when any item matches it. You can use it to e.g. to fetch all of your posts from the last 30 days
|
|
111
|
+
The third useful method you can use is `#fetch_all`, which loads multiple paginated responses and collects all returned items on a single list (you need to pass the name of the field that contains the items in the response). Optionally, you can also specify a limit of pages to load as `max_pages: n`, or a break condition `break_when` to stop fetching when any item matches it. You can use it to e.g. to fetch all of your posts from the last 30 days but not earlier:
|
|
97
112
|
|
|
98
113
|
```rb
|
|
99
114
|
time_limit = Time.now - 86400 * 30
|
|
@@ -120,14 +135,14 @@ This will output a line like this:
|
|
|
120
135
|
.................
|
|
121
136
|
```
|
|
122
137
|
|
|
123
|
-
You can find more examples
|
|
138
|
+
You can find more examples on the [examples page](https://ruby.sdk.blue/examples/) on [ruby.sdk.blue](https://ruby.sdk.blue).
|
|
124
139
|
|
|
125
140
|
|
|
126
141
|
## Customization
|
|
127
142
|
|
|
128
143
|
The `Minisky` client currently supports such configuration options:
|
|
129
144
|
|
|
130
|
-
- `default_progress` - a progress character to automatically use for `#fetch_all` calls (default: `nil`)
|
|
145
|
+
- `default_progress` - a progress character to automatically use for `#fetch_all` calls (default: `.` when in an interactive console, `nil` otherwise)
|
|
131
146
|
- `send_auth_headers` - whether auth headers should be added by default (default: `true` in authenticated mode)
|
|
132
147
|
- `auto_manage_tokens` - whether access tokens should be generated and refreshed automatically when needed (default: `true` in authenticated mode)
|
|
133
148
|
|
|
@@ -177,7 +192,7 @@ The class needs to provide:
|
|
|
177
192
|
|
|
178
193
|
## Credits
|
|
179
194
|
|
|
180
|
-
Copyright ©
|
|
195
|
+
Copyright © 2026 Kuba Suder ([@mackuba.eu](https://bsky.app/profile/did:plc:oio4hkxaop4ao4wz2pp3f4cr)).
|
|
181
196
|
|
|
182
197
|
The code is available under the terms of the [zlib license](https://choosealicense.com/licenses/zlib/) (permissive, similar to MIT).
|
|
183
198
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
require_relative 'minisky'
|
|
2
|
+
|
|
3
|
+
class Minisky
|
|
4
|
+
|
|
5
|
+
#
|
|
6
|
+
# Versions of {Requests#get_request} & {Requests#post_request} that work on Ruby 2.x.
|
|
7
|
+
#
|
|
8
|
+
|
|
9
|
+
module Ruby2Compat
|
|
10
|
+
def get_request(method, params = nil, auth: default_auth_mode, headers: nil, **kwargs)
|
|
11
|
+
params ||= kwargs unless kwargs.empty?
|
|
12
|
+
super(method, params, auth: auth, headers: headers)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def post_request(method, data = nil, auth: default_auth_mode, headers: nil, params: nil, **kwargs)
|
|
16
|
+
data ||= kwargs unless kwargs.empty?
|
|
17
|
+
super(method, data, auth: auth, headers: headers, params: params)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
data/lib/minisky/errors.rb
CHANGED
|
@@ -1,18 +1,34 @@
|
|
|
1
1
|
require_relative 'minisky'
|
|
2
2
|
|
|
3
3
|
class Minisky
|
|
4
|
+
|
|
5
|
+
#
|
|
6
|
+
# Common base error class for Minisky errors.
|
|
7
|
+
#
|
|
4
8
|
class Error < StandardError
|
|
5
9
|
end
|
|
6
10
|
|
|
11
|
+
#
|
|
12
|
+
# Raised when a required token or credentials are missing or invalid.
|
|
13
|
+
#
|
|
7
14
|
class AuthError < Error
|
|
8
|
-
def initialize(message)
|
|
9
|
-
super(message)
|
|
10
|
-
end
|
|
11
15
|
end
|
|
12
16
|
|
|
17
|
+
#
|
|
18
|
+
# Raised when the API returns an error status code.
|
|
19
|
+
#
|
|
13
20
|
class BadResponse < Error
|
|
14
|
-
attr_reader :status, :data
|
|
15
21
|
|
|
22
|
+
# @return [Integer] HTTP status code
|
|
23
|
+
attr_reader :status
|
|
24
|
+
|
|
25
|
+
# @return [String, Hash] response data (JSON hash or string)
|
|
26
|
+
attr_reader :data
|
|
27
|
+
|
|
28
|
+
# @param status [Integer] HTTP status code
|
|
29
|
+
# @param status_message [String] HTTP status message
|
|
30
|
+
# @param data [Hash, String] response data (JSON hash or string)
|
|
31
|
+
#
|
|
16
32
|
def initialize(status, status_message, data)
|
|
17
33
|
@status = status
|
|
18
34
|
@data = data
|
|
@@ -26,30 +42,81 @@ class Minisky
|
|
|
26
42
|
super(message)
|
|
27
43
|
end
|
|
28
44
|
|
|
45
|
+
# @return [String, nil] machine-readable error code from the response data
|
|
29
46
|
def error_type
|
|
30
47
|
@data['error'] if @data.is_a?(Hash)
|
|
31
48
|
end
|
|
32
49
|
|
|
50
|
+
# @return [String, nil] human-readable error message from the response data
|
|
33
51
|
def error_message
|
|
34
52
|
@data['message'] if @data.is_a?(Hash)
|
|
35
53
|
end
|
|
36
54
|
end
|
|
37
55
|
|
|
56
|
+
#
|
|
57
|
+
# Raised when the API returns a client error status code (4xx).
|
|
58
|
+
#
|
|
38
59
|
class ClientErrorResponse < BadResponse
|
|
39
60
|
end
|
|
40
61
|
|
|
62
|
+
#
|
|
63
|
+
# Raised when the API returns a server error status code (5xx).
|
|
64
|
+
#
|
|
41
65
|
class ServerErrorResponse < BadResponse
|
|
42
66
|
end
|
|
43
67
|
|
|
68
|
+
#
|
|
69
|
+
# Raised when the API returns an error indicating that the access or request
|
|
70
|
+
# token that was passed in the header is expired.
|
|
71
|
+
#
|
|
44
72
|
class ExpiredTokenError < ClientErrorResponse
|
|
45
73
|
end
|
|
46
74
|
|
|
75
|
+
#
|
|
76
|
+
# Raised when the API returns a redirect status code (3xx). Minisky doesn't
|
|
77
|
+
# currently follow any redirects.
|
|
78
|
+
#
|
|
47
79
|
class UnexpectedRedirect < BadResponse
|
|
80
|
+
|
|
81
|
+
# @return [String] value of the "Location" header
|
|
48
82
|
attr_reader :location
|
|
49
83
|
|
|
84
|
+
# @param status [Integer] HTTP status code
|
|
85
|
+
# @param status_message [String] HTTP status message
|
|
86
|
+
# @param location [String] value of the "Location" header
|
|
87
|
+
#
|
|
50
88
|
def initialize(status, status_message, location)
|
|
51
89
|
super(status, status_message, { 'message' => "Unexpected redirect: #{location}" })
|
|
52
90
|
@location = location
|
|
53
91
|
end
|
|
54
92
|
end
|
|
93
|
+
|
|
94
|
+
#
|
|
95
|
+
# Raised by {Requests#fetch_all} when the `field` parameter isn't set.
|
|
96
|
+
#
|
|
97
|
+
# The message of the exception lists the fields available in the first fetched page.
|
|
98
|
+
#
|
|
99
|
+
# @example Making a request in the console with empty `field`
|
|
100
|
+
# sky = Minisky.new('public.api.bsky.app', nil)
|
|
101
|
+
# # => #<Minisky:0x0000000120f5f6b0 @host="public.api.bsky.app", ...>
|
|
102
|
+
#
|
|
103
|
+
# sky.fetch_all('app.bsky.graph.getFollowers', { actor: 'sdk.blue' })
|
|
104
|
+
# # ./lib/minisky/requests.rb:270:in 'block in Minisky::Requests#fetch_all':
|
|
105
|
+
# # Field parameter not provided; available fields: ["followers"] (Minisky::FieldNotSetError)
|
|
106
|
+
#
|
|
107
|
+
# sky.fetch_all('app.bsky.graph.getFollowers', { actor: 'sdk.blue' }, field: 'followers')
|
|
108
|
+
# # => .....
|
|
109
|
+
#
|
|
110
|
+
class FieldNotSetError < Error
|
|
111
|
+
|
|
112
|
+
# @return [Array<String>] list of fields in the response data
|
|
113
|
+
attr_reader :fields
|
|
114
|
+
|
|
115
|
+
# @param fields [Array<String>] list of fields in the response data
|
|
116
|
+
#
|
|
117
|
+
def initialize(fields)
|
|
118
|
+
@fields = fields
|
|
119
|
+
super("Field parameter not provided; available fields: #{@fields.inspect}")
|
|
120
|
+
end
|
|
121
|
+
end
|
|
55
122
|
end
|
data/lib/minisky/minisky.rb
CHANGED
|
@@ -1,8 +1,47 @@
|
|
|
1
1
|
require 'yaml'
|
|
2
2
|
|
|
3
|
+
#
|
|
4
|
+
# The default API client class for making requests to AT Protocol servers. Can be used
|
|
5
|
+
# with authentication – with the credentials stored in a YAML file – or without it, for
|
|
6
|
+
# unauthenticated requests only (by passing `nil` as the config file name).
|
|
7
|
+
#
|
|
8
|
+
# @example Authenticated client
|
|
9
|
+
# # Expects a config.yml file like:
|
|
10
|
+
# #
|
|
11
|
+
# # id: test.example.com
|
|
12
|
+
# # pass: secret7
|
|
13
|
+
# #
|
|
14
|
+
# # "id" can be a handle or a DID.
|
|
15
|
+
#
|
|
16
|
+
# sky = Minisky.new('eurosky.social', 'config.yml')
|
|
17
|
+
#
|
|
18
|
+
# feed = sky.get_request('app.bsky.feed.getTimeline', { limit: 100 })
|
|
19
|
+
#
|
|
20
|
+
# @example Unauthenticated client
|
|
21
|
+
# sky = Minisky.new('public.api.bsky.app', nil, progress: '*')
|
|
22
|
+
#
|
|
23
|
+
# follows = sky.get_request('app.bsky.graph.getFollows',
|
|
24
|
+
# { actor: 'atproto.com', limit: 100 },
|
|
25
|
+
# field: 'follows'
|
|
26
|
+
# )
|
|
27
|
+
#
|
|
28
|
+
|
|
3
29
|
class Minisky
|
|
4
|
-
attr_reader :host, :config
|
|
5
30
|
|
|
31
|
+
# @return [String] the hostname (or base URL) of the server
|
|
32
|
+
attr_reader :host
|
|
33
|
+
|
|
34
|
+
# @return [Hash] loaded contents of the config file
|
|
35
|
+
attr_reader :config
|
|
36
|
+
|
|
37
|
+
# Creates a new client instance.
|
|
38
|
+
#
|
|
39
|
+
# @param host [String] the hostname (or base URL) of the server
|
|
40
|
+
# @param config_file [String, nil] path to the YAML config file, or `nil` for unauthenticated client
|
|
41
|
+
# @param options [Hash] option properties to set on the new instance (see {Minisky::Requests} properties)
|
|
42
|
+
#
|
|
43
|
+
# @raise [AuthError] if the config file is missing an ID or password
|
|
44
|
+
#
|
|
6
45
|
def initialize(host, config_file, options = {})
|
|
7
46
|
@host = host
|
|
8
47
|
@config_file = config_file
|
|
@@ -14,9 +53,11 @@ class Minisky
|
|
|
14
53
|
raise AuthError, "Missing user id or password in the config file #{@config_file}"
|
|
15
54
|
end
|
|
16
55
|
else
|
|
17
|
-
@config =
|
|
18
|
-
|
|
19
|
-
|
|
56
|
+
@config = nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
if active_repl?
|
|
60
|
+
@default_progress = '.'
|
|
20
61
|
end
|
|
21
62
|
|
|
22
63
|
if options
|
|
@@ -29,6 +70,15 @@ class Minisky
|
|
|
29
70
|
def save_config
|
|
30
71
|
File.write(@config_file, YAML.dump(@config)) if @config_file
|
|
31
72
|
end
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def active_repl?
|
|
78
|
+
return true if defined?(IRB) && IRB.respond_to?(:CurrentContext) && IRB.CurrentContext
|
|
79
|
+
return true if defined?(Pry) && Pry.respond_to?(:cli) && Pry.cli
|
|
80
|
+
false
|
|
81
|
+
end
|
|
32
82
|
end
|
|
33
83
|
|
|
34
84
|
require_relative 'requests'
|
data/lib/minisky/requests.rb
CHANGED
|
@@ -13,12 +13,16 @@ class Minisky
|
|
|
13
13
|
@config = config
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
def has_credentials?
|
|
17
|
+
!!(id && pass)
|
|
18
|
+
end
|
|
19
|
+
|
|
16
20
|
def logged_in?
|
|
17
21
|
!!(access_token && refresh_token)
|
|
18
22
|
end
|
|
19
23
|
|
|
20
24
|
def method_missing(name, *args)
|
|
21
|
-
if name.end_with?('=')
|
|
25
|
+
if name.to_s.end_with?('=')
|
|
22
26
|
@config[name.to_s.chop] = args[0]
|
|
23
27
|
else
|
|
24
28
|
@config[name.to_s]
|
|
@@ -26,35 +30,113 @@ class Minisky
|
|
|
26
30
|
end
|
|
27
31
|
end
|
|
28
32
|
|
|
33
|
+
# Regexp for NSID identifiers, used in lexicon names for record collection and API endpoints
|
|
34
|
+
NSID_REGEXP = /^[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(\.[a-zA-Z]([a-zA-Z]{0,61}[a-zA-Z])?)$/
|
|
35
|
+
|
|
36
|
+
#
|
|
37
|
+
# This module contains most of the Minisky code for making HTTP requests and managing
|
|
38
|
+
# authentication tokens. The module is included into the {Minisky} API client class and you'll
|
|
39
|
+
# normally use it through that class, but you can also include it into your custom class if you
|
|
40
|
+
# want to implement the data storage differently than using a local YAML file as {Minisky} does.
|
|
41
|
+
#
|
|
42
|
+
|
|
29
43
|
module Requests
|
|
44
|
+
|
|
45
|
+
# A character to print before each request in {#fetch_all} as a progress indicator.
|
|
46
|
+
# Can also be passed explicitly instead or overridden using the `progress:` parameter.
|
|
47
|
+
# Default is `'.'` when running inside IRB, and `nil` otherwise.
|
|
48
|
+
#
|
|
49
|
+
# @return [String, nil]
|
|
50
|
+
#
|
|
30
51
|
attr_accessor :default_progress
|
|
52
|
+
|
|
53
|
+
# By default, when `fetch_all` receives a response with an empty page with no records but
|
|
54
|
+
# which includes a cursor for the next page, it keeps fetching until it receives a response
|
|
55
|
+
# with null cursor. This is more technically correct, but can cause problems with some
|
|
56
|
+
# non-compliant APIs, so you can set this option to stop fetching when an empty page
|
|
57
|
+
# is received.
|
|
58
|
+
#
|
|
59
|
+
# @return [Boolean]
|
|
60
|
+
#
|
|
61
|
+
attr_accessor :stop_fetch_on_empty_page
|
|
62
|
+
|
|
31
63
|
attr_writer :send_auth_headers
|
|
32
64
|
attr_writer :auto_manage_tokens
|
|
33
65
|
|
|
66
|
+
# Tells whether to set authentication headers automatically (default: true if there
|
|
67
|
+
# is a user config).
|
|
68
|
+
#
|
|
69
|
+
# If false, you will need to pass `auth: 'sometoken'` explicitly to requests that
|
|
70
|
+
# require authentication.
|
|
71
|
+
#
|
|
72
|
+
# @return [Boolean] whether to set authentication headers in requests
|
|
73
|
+
#
|
|
34
74
|
def send_auth_headers
|
|
35
|
-
instance_variable_defined?('@send_auth_headers') ? @send_auth_headers :
|
|
75
|
+
instance_variable_defined?('@send_auth_headers') ? @send_auth_headers : (config != nil)
|
|
36
76
|
end
|
|
37
77
|
|
|
78
|
+
# Tells whether the library should manage the access & refresh tokens automatically
|
|
79
|
+
# for you (default: true if there is a user config).
|
|
80
|
+
#
|
|
81
|
+
# If true, {#check_access} is called before each request to make sure that there is a
|
|
82
|
+
# fresh access token available; if false, you will need to call {#log_in} and
|
|
83
|
+
# {#perform_token_refresh} manually when needed.
|
|
84
|
+
#
|
|
85
|
+
# @return [Boolean] whether to automatically manage access tokens
|
|
86
|
+
#
|
|
38
87
|
def auto_manage_tokens
|
|
39
|
-
instance_variable_defined?('@auto_manage_tokens') ? @auto_manage_tokens :
|
|
88
|
+
instance_variable_defined?('@auto_manage_tokens') ? @auto_manage_tokens : (config != nil)
|
|
40
89
|
end
|
|
41
90
|
|
|
42
91
|
alias progress default_progress
|
|
43
92
|
alias progress= default_progress=
|
|
44
93
|
|
|
45
94
|
def base_url
|
|
46
|
-
|
|
95
|
+
if host.include?('://')
|
|
96
|
+
host.chomp('/') + '/xrpc'
|
|
97
|
+
else
|
|
98
|
+
"https://#{host}/xrpc"
|
|
99
|
+
end
|
|
47
100
|
end
|
|
48
101
|
|
|
49
102
|
def user
|
|
50
|
-
@user ||= User.new(config)
|
|
103
|
+
@user ||= config && User.new(config)
|
|
51
104
|
end
|
|
52
105
|
|
|
106
|
+
# Sends a GET request to the service's API.
|
|
107
|
+
#
|
|
108
|
+
# @param method [String, URI] an XRPC endpoint name or a full URL
|
|
109
|
+
# @param params [Hash, nil] query parameters
|
|
110
|
+
#
|
|
111
|
+
# @param auth [Boolean, String]
|
|
112
|
+
# boolean value which tells whether to send an auth header with the access token or not,
|
|
113
|
+
# or an explicit bearer token to use
|
|
114
|
+
# @param headers [Hash, nil]
|
|
115
|
+
# additional headers to include
|
|
116
|
+
#
|
|
117
|
+
# @return [Hash, String] parsed JSON hash for JSON responses, or raw response body otherwise
|
|
118
|
+
#
|
|
119
|
+
# @raise [ArgumentError] if method name is invalid
|
|
120
|
+
# @raise [BadResponse] if the HTTP response has an error status code
|
|
121
|
+
# @raise [AuthError]
|
|
122
|
+
# - if logging in is required, but login or password isn't provided
|
|
123
|
+
# - if token refresh is needed, but refresh token is missing
|
|
124
|
+
# - if a token has invalid format
|
|
125
|
+
# - if required access token is missing, and {#auto_manage_tokens} is disabled
|
|
126
|
+
#
|
|
127
|
+
# @example Unauthenticated call
|
|
128
|
+
# sky = Minisky.new('public.api.bsky.app', nil)
|
|
129
|
+
# profile = sky.get_request('app.bsky.actor.getProfile', { actor: 'ec.europa.eu' })
|
|
130
|
+
#
|
|
131
|
+
# @example Authenticated call
|
|
132
|
+
# sky = Minisky.new('blacksky.app', 'config.yml')
|
|
133
|
+
# feed = sky.get_request('app.bsky.feed.getTimeline', { limit: 100 })
|
|
134
|
+
|
|
53
135
|
def get_request(method, params = nil, auth: default_auth_mode, headers: nil)
|
|
54
136
|
check_access if auto_manage_tokens && auth == true
|
|
55
137
|
|
|
56
138
|
headers = authentication_header(auth).merge(headers || {})
|
|
57
|
-
url =
|
|
139
|
+
url = build_request_uri(method)
|
|
58
140
|
|
|
59
141
|
if params && !params.empty?
|
|
60
142
|
url.query = URI.encode_www_form(params)
|
|
@@ -66,24 +148,133 @@ class Minisky
|
|
|
66
148
|
handle_response(response)
|
|
67
149
|
end
|
|
68
150
|
|
|
69
|
-
|
|
151
|
+
# Sends a POST request to the service's API.
|
|
152
|
+
#
|
|
153
|
+
# @param method [String, URI] an XRPC endpoint name or a full URL
|
|
154
|
+
# @param data [Hash, String, nil] JSON or string data to send
|
|
155
|
+
#
|
|
156
|
+
# @param auth [Boolean, String]
|
|
157
|
+
# boolean value which tells whether to send an auth header with the access token or not,
|
|
158
|
+
# or an explicit bearer token to use
|
|
159
|
+
# @param headers [Hash, nil]
|
|
160
|
+
# additional headers to include
|
|
161
|
+
# @param params [Hash, nil]
|
|
162
|
+
# query parameters to append to the URL
|
|
163
|
+
#
|
|
164
|
+
# @return [Hash, String] parsed JSON hash for JSON responses, or raw response body otherwise
|
|
165
|
+
#
|
|
166
|
+
# @raise [ArgumentError] if method name is invalid
|
|
167
|
+
# @raise [BadResponse] if the HTTP response has an error status code
|
|
168
|
+
# @raise [AuthError]
|
|
169
|
+
# - if logging in is required, but login or password isn't provided
|
|
170
|
+
# - if token refresh is needed, but refresh token is missing
|
|
171
|
+
# - if a token has invalid format
|
|
172
|
+
# - if required access token is missing, and {#auto_manage_tokens} is disabled
|
|
173
|
+
#
|
|
174
|
+
# @example Making a Bluesky post
|
|
175
|
+
# sky = Minisky.new('lab.martianbase.net', 'config.yml')
|
|
176
|
+
#
|
|
177
|
+
# sky.post_request('com.atproto.repo.createRecord', {
|
|
178
|
+
# repo: sky.user.did,
|
|
179
|
+
# collection: 'app.bsky.feed.post',
|
|
180
|
+
# record: {
|
|
181
|
+
# text: "Hello Bluesky!",
|
|
182
|
+
# createdAt: Time.now.iso8601,
|
|
183
|
+
# langs: ['en']
|
|
184
|
+
# }
|
|
185
|
+
# })
|
|
186
|
+
|
|
187
|
+
def post_request(method, data = nil, auth: default_auth_mode, headers: nil, params: nil)
|
|
70
188
|
check_access if auto_manage_tokens && auth == true
|
|
71
189
|
|
|
72
190
|
headers = authentication_header(auth).merge(headers || {})
|
|
73
|
-
headers["Content-Type"] = "application/json" unless headers.keys.any? { |k| k.to_s.downcase == 'content-type' }
|
|
74
191
|
|
|
75
|
-
|
|
76
|
-
|
|
192
|
+
if data.is_a?(String) || data.nil?
|
|
193
|
+
body = data.to_s
|
|
77
194
|
else
|
|
78
|
-
|
|
195
|
+
body = data.to_json
|
|
196
|
+
headers["Content-Type"] = "application/json" unless headers.keys.any? { |k| k.to_s.downcase == 'content-type' }
|
|
79
197
|
end
|
|
80
198
|
|
|
81
|
-
|
|
199
|
+
url = build_request_uri(method)
|
|
200
|
+
|
|
201
|
+
if params && !params.empty?
|
|
202
|
+
url.query = URI.encode_www_form(params)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
response = Net::HTTP.post(url, body, headers)
|
|
82
206
|
handle_response(response)
|
|
83
207
|
end
|
|
84
208
|
|
|
85
|
-
|
|
86
|
-
|
|
209
|
+
# Fetches and merges paginated responses from a service's endpoint in a loop, updating the
|
|
210
|
+
# cursor after each page, until the cursor is nil or a break condition is met. The data is
|
|
211
|
+
# extracted from a designated field of the response (`field`) and added to a single array,
|
|
212
|
+
# which is returned at the end.
|
|
213
|
+
#
|
|
214
|
+
# A condition for when the fetching should stop can be passed as a block in `break_when`, or
|
|
215
|
+
# alternatively, a max number of pages can be passed to `max_pages` (or both together). If
|
|
216
|
+
# neither is set, the fetching continues until the server returns an empty cursor.
|
|
217
|
+
#
|
|
218
|
+
# When experimenting in the Ruby console, you can pass `nil` as `field` (or skip the parameter)
|
|
219
|
+
# to make a single request and raise an exception, which will tell you what fields are available.
|
|
220
|
+
#
|
|
221
|
+
# @param method [String, URI] an XRPC endpoint name or a full URL
|
|
222
|
+
# @param params [Hash, nil] query parameters
|
|
223
|
+
#
|
|
224
|
+
# @param auth [Boolean, String]
|
|
225
|
+
# boolean value which tells whether to send an auth header with the access token or not,
|
|
226
|
+
# or an explicit bearer token to use
|
|
227
|
+
# @param field [String, nil]
|
|
228
|
+
# name of the field in the responses which contains the data array
|
|
229
|
+
# @param break_when [Proc, nil]
|
|
230
|
+
# if passed, the fetching will stop when the block returns true for any of the
|
|
231
|
+
# returned records, and records matching the condition will be deleted from the last page
|
|
232
|
+
# @param max_pages [Integer, nil]
|
|
233
|
+
# maximum number of pages to fetch
|
|
234
|
+
# @param headers [Hash, nil]
|
|
235
|
+
# additional headers to include
|
|
236
|
+
# @param progress [String, nil]
|
|
237
|
+
# a character to print before each request as a progress indicator
|
|
238
|
+
#
|
|
239
|
+
# @return [Array] records or objects collected from all pages
|
|
240
|
+
#
|
|
241
|
+
# @raise [ArgumentError] if method name is invalid
|
|
242
|
+
# @raise [FieldNotSetError] if field parameter wasn't set (the message tells you what fields were in the response)
|
|
243
|
+
# @raise [BadResponse] if the HTTP response has an error status code
|
|
244
|
+
# @raise [AuthError]
|
|
245
|
+
# - if logging in is required, but login or password isn't provided
|
|
246
|
+
# - if token refresh is needed, but refresh token is missing
|
|
247
|
+
# - if a token has invalid format
|
|
248
|
+
# - if required access token is missing, and {#auto_manage_tokens} is disabled
|
|
249
|
+
#
|
|
250
|
+
# @example Fetching with a `break_when` block
|
|
251
|
+
# sky = Minisky.new('public.api.bsky.app', nil)
|
|
252
|
+
# time_limit = Time.now - 86400 * 30
|
|
253
|
+
#
|
|
254
|
+
# sky.fetch_all('app.bsky.feed.getAuthorFeed',
|
|
255
|
+
# { actor: 'pfrazee.com', limit: 100 },
|
|
256
|
+
# field: 'feed',
|
|
257
|
+
# progress: '|',
|
|
258
|
+
# break_when: ->(x) { Time.at(x['post']['record']['createdAt']) < time_limit }
|
|
259
|
+
# )
|
|
260
|
+
#
|
|
261
|
+
# @example Fetching with `max_pages`
|
|
262
|
+
# sky = Minisky.new('tngl.sh', 'config.yml')
|
|
263
|
+
# sky.fetch_all('app.bsky.feed.getTimeline', { limit: 100 }, field: 'feed', max_pages: 10)
|
|
264
|
+
#
|
|
265
|
+
# @example Making a request in the console with empty `field`
|
|
266
|
+
# sky = Minisky.new('public.api.bsky.app', nil)
|
|
267
|
+
# # => #<Minisky:0x0000000120f5f6b0 @host="public.api.bsky.app", ...>
|
|
268
|
+
#
|
|
269
|
+
# sky.fetch_all('app.bsky.graph.getFollowers', { actor: 'sdk.blue' })
|
|
270
|
+
# # ./lib/minisky/requests.rb:270:in 'block in Minisky::Requests#fetch_all':
|
|
271
|
+
# # Field parameter not provided; available fields: ["followers"] (Minisky::FieldNotSetError)
|
|
272
|
+
#
|
|
273
|
+
# sky.fetch_all('app.bsky.graph.getFollowers', { actor: 'sdk.blue' }, field: 'followers')
|
|
274
|
+
# # => .....
|
|
275
|
+
|
|
276
|
+
def fetch_all(method, params = nil, auth: default_auth_mode,
|
|
277
|
+
field: nil, break_when: nil, max_pages: nil, headers: nil, progress: @default_progress)
|
|
87
278
|
data = []
|
|
88
279
|
params = {} if params.nil?
|
|
89
280
|
pages = 0
|
|
@@ -92,6 +283,11 @@ class Minisky
|
|
|
92
283
|
print(progress) if progress
|
|
93
284
|
|
|
94
285
|
response = get_request(method, params, auth: auth, headers: headers)
|
|
286
|
+
|
|
287
|
+
if field.nil?
|
|
288
|
+
raise FieldNotSetError, response.keys.select { |f| response[f].is_a?(Array) }
|
|
289
|
+
end
|
|
290
|
+
|
|
95
291
|
records = response[field]
|
|
96
292
|
cursor = response['cursor']
|
|
97
293
|
|
|
@@ -99,7 +295,7 @@ class Minisky
|
|
|
99
295
|
params[:cursor] = cursor
|
|
100
296
|
pages += 1
|
|
101
297
|
|
|
102
|
-
break if !cursor ||
|
|
298
|
+
break if !cursor || pages == max_pages || (stop_fetch_on_empty_page && records.empty?)
|
|
103
299
|
break if break_when && records.any? { |x| break_when.call(x) }
|
|
104
300
|
end
|
|
105
301
|
|
|
@@ -107,16 +303,64 @@ class Minisky
|
|
|
107
303
|
data
|
|
108
304
|
end
|
|
109
305
|
|
|
306
|
+
# Ensures that the user has a fresh access token, by checking the access token's expiry date
|
|
307
|
+
# and performing a refresh if needed, or by logging in with a password if no tokens are present.
|
|
308
|
+
#
|
|
309
|
+
# If {#auto_manage_tokens} is enabled (the default setting), this method is automatically called
|
|
310
|
+
# before {#get_request}, {#post_request} and {#fetch_all}, so you generally don't need to call it
|
|
311
|
+
# yourself.
|
|
312
|
+
#
|
|
313
|
+
# @return [Symbol]
|
|
314
|
+
# - `:logged_in` if a login using a password was performed
|
|
315
|
+
# - `:refreshed` if the access token was expired and was refreshed
|
|
316
|
+
# - `:ok` if no refresh was needed
|
|
317
|
+
# - `:unknown` if the token is not a valid JWT (e.g. an opaque blob)
|
|
318
|
+
#
|
|
319
|
+
# @raise [BadResponse] if login or refresh returns an error status code
|
|
320
|
+
# @raise [AuthError]
|
|
321
|
+
# - if the client doesn't include user config at all
|
|
322
|
+
# - if logging in is required, but login or password isn't provided
|
|
323
|
+
# - if token refresh is needed, but refresh token is missing
|
|
324
|
+
|
|
110
325
|
def check_access
|
|
111
|
-
if !user
|
|
326
|
+
if !user
|
|
327
|
+
raise AuthError, "User config is missing"
|
|
328
|
+
elsif !user.has_credentials?
|
|
329
|
+
raise AuthError, "User id or password is missing"
|
|
330
|
+
elsif !user.logged_in?
|
|
112
331
|
log_in
|
|
113
|
-
|
|
332
|
+
return :logged_in
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
begin
|
|
336
|
+
expired = access_token_expired?
|
|
337
|
+
rescue AuthError
|
|
338
|
+
return :unknown
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
if expired
|
|
114
342
|
perform_token_refresh
|
|
343
|
+
:refreshed
|
|
344
|
+
else
|
|
345
|
+
:ok
|
|
115
346
|
end
|
|
116
347
|
end
|
|
117
348
|
|
|
349
|
+
# Logs in the user using an ID and password stored in the config by calling the
|
|
350
|
+
# `createSession` endpoint, and stores the received access & refresh tokens.
|
|
351
|
+
#
|
|
352
|
+
# This is generally handled automatically by {#check_access}. Calling this method
|
|
353
|
+
# repeatedly many times in a short period of time may use up your rate limit for this
|
|
354
|
+
# endpoint (which is lower than for others) and make it inaccessible to you for some
|
|
355
|
+
# time.
|
|
356
|
+
#
|
|
357
|
+
# @return [Hash] the response JSON with access tokens
|
|
358
|
+
#
|
|
359
|
+
# @raise [AuthError] if login or password are missing
|
|
360
|
+
# @raise [BadResponse] if the server responds with an error status code
|
|
361
|
+
|
|
118
362
|
def log_in
|
|
119
|
-
if user.
|
|
363
|
+
if user.nil? || !user.has_credentials?
|
|
120
364
|
raise AuthError, "To log in, please provide a user id and password"
|
|
121
365
|
end
|
|
122
366
|
|
|
@@ -125,6 +369,11 @@ class Minisky
|
|
|
125
369
|
password: user.pass
|
|
126
370
|
}
|
|
127
371
|
|
|
372
|
+
if user.id =~ /\A[^@]+@[^@]+\z/
|
|
373
|
+
STDERR.puts "Warning: logging in using an email address is deprecated in Minisky and will be " +
|
|
374
|
+
"removed in a future version. Use either a handle or a DID instead."
|
|
375
|
+
end
|
|
376
|
+
|
|
128
377
|
json = post_request('com.atproto.server.createSession', data, auth: false)
|
|
129
378
|
|
|
130
379
|
user.did = json['did']
|
|
@@ -135,8 +384,19 @@ class Minisky
|
|
|
135
384
|
json
|
|
136
385
|
end
|
|
137
386
|
|
|
387
|
+
# Refreshes the access token using the stored refresh token. If successful, this
|
|
388
|
+
# invalidates *both* old tokens and replaces them with new ones from the response.
|
|
389
|
+
#
|
|
390
|
+
# If {#auto_manage_tokens} is enabled (the default setting), this method is automatically called
|
|
391
|
+
# before any requests through {#check_access}, so you generally don't need to call it yourself.
|
|
392
|
+
#
|
|
393
|
+
# @return [Hash] the response JSON with access tokens
|
|
394
|
+
#
|
|
395
|
+
# @raise [AuthError] if the refresh token is missing
|
|
396
|
+
# @raise [BadResponse] if the server responds with an error status code
|
|
397
|
+
|
|
138
398
|
def perform_token_refresh
|
|
139
|
-
if user
|
|
399
|
+
if user&.refresh_token.nil?
|
|
140
400
|
raise AuthError, "Can't refresh access token - refresh token is missing"
|
|
141
401
|
end
|
|
142
402
|
|
|
@@ -149,7 +409,64 @@ class Minisky
|
|
|
149
409
|
json
|
|
150
410
|
end
|
|
151
411
|
|
|
412
|
+
# Attempts to parse a given token as JWT and extract the expiration date from the payload.
|
|
413
|
+
# An access token technically isn't required to be a (valid) JWT, so if the parsing fails
|
|
414
|
+
# for whatever reason, nil is returned.
|
|
415
|
+
#
|
|
416
|
+
# @return [Time, nil] parsed expiration time, or nil if token is not a valid JWT
|
|
417
|
+
|
|
418
|
+
def token_expiration_date(token)
|
|
419
|
+
return nil unless token.valid_encoding?
|
|
420
|
+
|
|
421
|
+
parts = token.split('.')
|
|
422
|
+
return nil unless parts.length == 3
|
|
423
|
+
|
|
424
|
+
begin
|
|
425
|
+
payload = JSON.parse(Base64.decode64(parts[1]))
|
|
426
|
+
rescue JSON::ParserError
|
|
427
|
+
return nil
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
exp = payload['exp']
|
|
431
|
+
return nil unless exp.is_a?(Numeric) && exp > 0
|
|
432
|
+
|
|
433
|
+
time = Time.at(exp)
|
|
434
|
+
return nil if time.year < 2023 || time.year > 2100
|
|
435
|
+
|
|
436
|
+
time
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# Attempts to parse the user's access token as JWT, extract the expiration date from the
|
|
440
|
+
# payload, and check if the token hasn't expired yet.
|
|
441
|
+
#
|
|
442
|
+
# @return [Boolean] true if the token's expiration time is more than a minute away
|
|
443
|
+
# @raise [AuthError] if the token is not a valid JWT, or user is not logged in
|
|
444
|
+
|
|
445
|
+
def access_token_expired?
|
|
446
|
+
if user&.access_token.nil?
|
|
447
|
+
raise AuthError, "No access token (user is not logged in)"
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
exp_date = token_expiration_date(user.access_token)
|
|
451
|
+
|
|
452
|
+
if exp_date
|
|
453
|
+
exp_date < Time.now + 60
|
|
454
|
+
else
|
|
455
|
+
raise AuthError, "Token expiration date cannot be decoded"
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
#
|
|
460
|
+
# Clear stored access and refresh tokens, effectively logging out the user.
|
|
461
|
+
#
|
|
462
|
+
# @raise [AuthError] if the client doesn't have a user config
|
|
463
|
+
#
|
|
464
|
+
|
|
152
465
|
def reset_tokens
|
|
466
|
+
if !user
|
|
467
|
+
raise AuthError, "User config is missing"
|
|
468
|
+
end
|
|
469
|
+
|
|
153
470
|
user.access_token = nil
|
|
154
471
|
user.refresh_token = nil
|
|
155
472
|
save_config
|
|
@@ -157,19 +474,8 @@ class Minisky
|
|
|
157
474
|
end
|
|
158
475
|
|
|
159
476
|
if RUBY_VERSION.to_i == 2
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
private :do_get_request, :do_post_request
|
|
163
|
-
|
|
164
|
-
def get_request(method, params = nil, auth: default_auth_mode, headers: nil, **kwargs)
|
|
165
|
-
params ||= kwargs unless kwargs.empty?
|
|
166
|
-
do_get_request(method, params, auth: auth, headers: headers)
|
|
167
|
-
end
|
|
168
|
-
|
|
169
|
-
def post_request(method, params = nil, auth: default_auth_mode, headers: nil, **kwargs)
|
|
170
|
-
params ||= kwargs unless kwargs.empty?
|
|
171
|
-
do_post_request(method, params, auth: auth, headers: headers)
|
|
172
|
-
end
|
|
477
|
+
require_relative 'compat'
|
|
478
|
+
prepend Ruby2Compat
|
|
173
479
|
end
|
|
174
480
|
|
|
175
481
|
|
|
@@ -177,11 +483,23 @@ class Minisky
|
|
|
177
483
|
|
|
178
484
|
def make_request(request)
|
|
179
485
|
# this long form is needed because #get_response only supports a headers param in Ruby 3.x
|
|
180
|
-
response = Net::HTTP.start(request.uri.hostname, request.uri.port, use_ssl:
|
|
486
|
+
response = Net::HTTP.start(request.uri.hostname, request.uri.port, use_ssl: (request.uri.scheme == 'https')) do |http|
|
|
181
487
|
http.request(request)
|
|
182
488
|
end
|
|
183
489
|
end
|
|
184
490
|
|
|
491
|
+
def build_request_uri(method)
|
|
492
|
+
if method.is_a?(URI)
|
|
493
|
+
method
|
|
494
|
+
elsif method.include?('://')
|
|
495
|
+
URI(method)
|
|
496
|
+
elsif method =~ NSID_REGEXP
|
|
497
|
+
URI("#{base_url}/#{method}")
|
|
498
|
+
else
|
|
499
|
+
raise ArgumentError, "Invalid method name #{method.inspect} (should be an NSID, URL or an URI object)"
|
|
500
|
+
end
|
|
501
|
+
end
|
|
502
|
+
|
|
185
503
|
def default_auth_mode
|
|
186
504
|
!!send_auth_headers
|
|
187
505
|
end
|
|
@@ -190,7 +508,7 @@ class Minisky
|
|
|
190
508
|
if auth.is_a?(String)
|
|
191
509
|
{ 'Authorization' => "Bearer #{auth}" }
|
|
192
510
|
elsif auth
|
|
193
|
-
if user
|
|
511
|
+
if user&.access_token
|
|
194
512
|
{ 'Authorization' => "Bearer #{user.access_token}" }
|
|
195
513
|
else
|
|
196
514
|
raise AuthError, "Can't send auth headers, access token is missing"
|
|
@@ -200,22 +518,6 @@ class Minisky
|
|
|
200
518
|
end
|
|
201
519
|
end
|
|
202
520
|
|
|
203
|
-
def token_expiration_date(token)
|
|
204
|
-
parts = token.split('.')
|
|
205
|
-
raise AuthError, "Invalid access token format" unless parts.length == 3
|
|
206
|
-
|
|
207
|
-
begin
|
|
208
|
-
payload = JSON.parse(Base64.decode64(parts[1]))
|
|
209
|
-
rescue JSON::ParserError
|
|
210
|
-
raise AuthError, "Couldn't decode payload from access token"
|
|
211
|
-
end
|
|
212
|
-
|
|
213
|
-
exp = payload['exp']
|
|
214
|
-
raise AuthError, "Invalid token expiry data" unless exp.is_a?(Numeric) && exp > 0
|
|
215
|
-
|
|
216
|
-
Time.at(exp)
|
|
217
|
-
end
|
|
218
|
-
|
|
219
521
|
def handle_response(response)
|
|
220
522
|
status = response.code.to_i
|
|
221
523
|
message = response.message
|
data/lib/minisky/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: minisky
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kuba Suder
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: bin
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: base64
|
|
@@ -35,25 +34,20 @@ files:
|
|
|
35
34
|
- CHANGELOG.md
|
|
36
35
|
- LICENSE.txt
|
|
37
36
|
- README.md
|
|
38
|
-
- example/ask_password.rb
|
|
39
|
-
- example/fetch_my_posts.rb
|
|
40
|
-
- example/fetch_profile.rb
|
|
41
|
-
- example/post_skeet.rb
|
|
42
|
-
- example/science_feed.rb
|
|
43
37
|
- lib/minisky.rb
|
|
38
|
+
- lib/minisky/compat.rb
|
|
44
39
|
- lib/minisky/errors.rb
|
|
45
40
|
- lib/minisky/minisky.rb
|
|
46
41
|
- lib/minisky/requests.rb
|
|
47
42
|
- lib/minisky/version.rb
|
|
48
43
|
- sig/minisky.rbs
|
|
49
|
-
homepage: https://
|
|
44
|
+
homepage: https://ruby.sdk.blue
|
|
50
45
|
licenses:
|
|
51
46
|
- Zlib
|
|
52
47
|
metadata:
|
|
53
|
-
bug_tracker_uri: https://
|
|
54
|
-
changelog_uri: https://
|
|
55
|
-
source_code_uri: https://
|
|
56
|
-
post_install_message:
|
|
48
|
+
bug_tracker_uri: https://tangled.org/mackuba.eu/minisky/issues
|
|
49
|
+
changelog_uri: https://tangled.org/mackuba.eu/minisky/blob/master/CHANGELOG.md
|
|
50
|
+
source_code_uri: https://tangled.org/mackuba.eu/minisky
|
|
57
51
|
rdoc_options: []
|
|
58
52
|
require_paths:
|
|
59
53
|
- lib
|
|
@@ -68,8 +62,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
68
62
|
- !ruby/object:Gem::Version
|
|
69
63
|
version: '0'
|
|
70
64
|
requirements: []
|
|
71
|
-
rubygems_version:
|
|
72
|
-
signing_key:
|
|
65
|
+
rubygems_version: 4.0.3
|
|
73
66
|
specification_version: 4
|
|
74
|
-
summary: A minimal client of Bluesky/
|
|
67
|
+
summary: A minimal client of Bluesky/ATProto API
|
|
75
68
|
test_files: []
|
data/example/ask_password.rb
DELETED
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env ruby
|
|
2
|
-
|
|
3
|
-
# Example: print 10 latest posts from the user's home feed.
|
|
4
|
-
#
|
|
5
|
-
# Instead of using a config file to read & store authentication info, this example uses a customized client class
|
|
6
|
-
# which reads the password from the console and creates a throwaway access token.
|
|
7
|
-
#
|
|
8
|
-
# This approach makes sense for one-off scripts, but it shouldn't be used for things that need to be done repeatedly
|
|
9
|
-
# and often (the authentication-related endpoints have lower rate limits than others).
|
|
10
|
-
|
|
11
|
-
# load minisky from a local folder - you normally won't need this
|
|
12
|
-
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
|
|
13
|
-
|
|
14
|
-
require 'io/console'
|
|
15
|
-
require 'minisky'
|
|
16
|
-
|
|
17
|
-
class TransientClient
|
|
18
|
-
include Minisky::Requests
|
|
19
|
-
|
|
20
|
-
attr_reader :config, :host
|
|
21
|
-
|
|
22
|
-
def initialize(host, user)
|
|
23
|
-
@host = host
|
|
24
|
-
@config = { 'id' => user.gsub(/^@/, '') }
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
def ask_for_password
|
|
28
|
-
print "Enter password for @#{config['id']}: "
|
|
29
|
-
@config['pass'] = STDIN.noecho(&:gets).chomp
|
|
30
|
-
puts
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
def save_config
|
|
34
|
-
# ignore
|
|
35
|
-
end
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
host, handle = ARGV
|
|
39
|
-
|
|
40
|
-
unless host && handle
|
|
41
|
-
puts "Usage: #{$PROGRAM_NAME} <pds_hostname> <handle>"
|
|
42
|
-
exit 1
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
# create a client instance & read password
|
|
46
|
-
bsky = TransientClient.new(host, handle)
|
|
47
|
-
bsky.ask_for_password
|
|
48
|
-
|
|
49
|
-
# fetch 10 posts from the user's home feed
|
|
50
|
-
result = bsky.get_request('app.bsky.feed.getTimeline', { limit: 10 })
|
|
51
|
-
|
|
52
|
-
result['feed'].each do |r|
|
|
53
|
-
reason = r['reason']
|
|
54
|
-
reply = r['reply']
|
|
55
|
-
post = r['post']
|
|
56
|
-
|
|
57
|
-
if reason && reason['$type'] == 'app.bsky.feed.defs#reasonRepost'
|
|
58
|
-
puts "[Reposted by @#{reason['by']['handle']}]"
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
handle = post['author']['handle']
|
|
62
|
-
timestamp = Time.parse(post['record']['createdAt']).getlocal
|
|
63
|
-
|
|
64
|
-
puts "@#{handle} • #{timestamp}"
|
|
65
|
-
puts
|
|
66
|
-
|
|
67
|
-
if reply
|
|
68
|
-
puts "[in reply to @#{reply['parent']['author']['handle']}]"
|
|
69
|
-
puts
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
puts post['record']['text']
|
|
73
|
-
puts
|
|
74
|
-
puts "=" * 120
|
|
75
|
-
puts
|
|
76
|
-
end
|
data/example/fetch_my_posts.rb
DELETED
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env ruby
|
|
2
|
-
|
|
3
|
-
# Example: sync all posts from your account (excluding replies and reposts) to a local JSON file. When run again, it
|
|
4
|
-
# will only fetch new posts since the last time and append them to the file.
|
|
5
|
-
#
|
|
6
|
-
# Requires a bluesky.yml config file in the same directory with contents like this:
|
|
7
|
-
# id: your.handle
|
|
8
|
-
# pass: secretpass
|
|
9
|
-
|
|
10
|
-
# load minisky from a local folder - you normally won't need this
|
|
11
|
-
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
|
|
12
|
-
|
|
13
|
-
require 'minisky'
|
|
14
|
-
|
|
15
|
-
CONFIG_FILE = File.join(__dir__, 'bluesky.yml')
|
|
16
|
-
POSTS_FILE = File.join(__dir__, 'posts.json')
|
|
17
|
-
|
|
18
|
-
# create a client instance
|
|
19
|
-
bsky = Minisky.new('bsky.social', CONFIG_FILE)
|
|
20
|
-
|
|
21
|
-
# print progress dots when loading multiple pages
|
|
22
|
-
bsky.default_progress = '.'
|
|
23
|
-
|
|
24
|
-
# load previously saved posts; we will only fetch posts newer than the last saved before
|
|
25
|
-
posts = File.exist?(POSTS_FILE) ? JSON.parse(File.read(POSTS_FILE)) : []
|
|
26
|
-
latest_date = posts[0] && posts[0]['indexedAt']
|
|
27
|
-
|
|
28
|
-
# fetch all posts from my timeline (without replies) until the target timestamp
|
|
29
|
-
results = bsky.fetch_all('app.bsky.feed.getAuthorFeed',
|
|
30
|
-
{ actor: bsky.user.did, filter: 'posts_no_replies', limit: 100 },
|
|
31
|
-
field: 'feed',
|
|
32
|
-
break_when: latest_date && proc { |x| x['post']['indexedAt'] <= latest_date }
|
|
33
|
-
)
|
|
34
|
-
|
|
35
|
-
# trim some data to save space
|
|
36
|
-
new_posts = results.map { |x| x['post'] }
|
|
37
|
-
.reject { |x| x['author']['did'] != bsky.user.did } # skip reposts
|
|
38
|
-
.map { |x| x.except('author') } # skip author profile info
|
|
39
|
-
|
|
40
|
-
posts = new_posts + posts
|
|
41
|
-
|
|
42
|
-
puts
|
|
43
|
-
puts "Fetched #{new_posts.length} new posts (total = #{posts.length})"
|
|
44
|
-
|
|
45
|
-
# save all new and old posts back to the file
|
|
46
|
-
File.write(POSTS_FILE, JSON.pretty_generate(posts))
|
data/example/fetch_profile.rb
DELETED
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env ruby
|
|
2
|
-
|
|
3
|
-
# Example: fetch the profile info of a given user and their last 10 posts (excluding reposts).
|
|
4
|
-
#
|
|
5
|
-
# This script connects to the AppView server at api.bsky.app, which allows calling such endpoints as getProfile or
|
|
6
|
-
# getAuthorFeed without authentication.
|
|
7
|
-
|
|
8
|
-
# load minisky from a local folder - you normally won't need this
|
|
9
|
-
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
|
|
10
|
-
|
|
11
|
-
require 'minisky'
|
|
12
|
-
require 'time'
|
|
13
|
-
|
|
14
|
-
if ARGV[0].to_s !~ /^@?[\w\-]+(\.[\w\-]+)+$/
|
|
15
|
-
puts "Usage: #{$PROGRAM_NAME} <handle>"
|
|
16
|
-
exit 1
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
handle = ARGV[0].gsub(/^@/, '')
|
|
20
|
-
|
|
21
|
-
# passing nil as config file to use an unauthenticated client
|
|
22
|
-
bsky = Minisky.new('api.bsky.app', nil)
|
|
23
|
-
|
|
24
|
-
# fetch profile info
|
|
25
|
-
profile = bsky.get_request('app.bsky.actor.getProfile', { actor: handle })
|
|
26
|
-
|
|
27
|
-
# fetch posts, without replies - we fetch a bit more than we need because we'll also filter out reposts
|
|
28
|
-
posts = bsky.get_request('app.bsky.feed.getAuthorFeed', { actor: handle, filter: 'posts_no_replies', limit: 40 })
|
|
29
|
-
|
|
30
|
-
# print the profile
|
|
31
|
-
|
|
32
|
-
puts
|
|
33
|
-
puts "====[ @#{handle} • #{profile['displayName']} • #{profile['did']} ]===="
|
|
34
|
-
puts
|
|
35
|
-
puts profile['description']
|
|
36
|
-
puts
|
|
37
|
-
puts '=' * 80
|
|
38
|
-
puts
|
|
39
|
-
|
|
40
|
-
# print the posts
|
|
41
|
-
|
|
42
|
-
posts['feed'].map { |r|
|
|
43
|
-
r['post']
|
|
44
|
-
}.select { |p|
|
|
45
|
-
# select only posts from this account
|
|
46
|
-
p['author']['handle'] == handle
|
|
47
|
-
}.slice(0, 10).each { |p|
|
|
48
|
-
time = Time.parse(p['record']['createdAt'])
|
|
49
|
-
timestamp = time.getlocal.strftime('%a %d.%m %H:%M')
|
|
50
|
-
|
|
51
|
-
puts "#{timestamp}: #{p['record']['text']}"
|
|
52
|
-
puts
|
|
53
|
-
}
|
data/example/post_skeet.rb
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env ruby
|
|
2
|
-
|
|
3
|
-
# Example: make a new post (aka "skeet") with text passed in the argument to the script.
|
|
4
|
-
#
|
|
5
|
-
# Requires a bluesky.yml config file in the same directory with contents like this:
|
|
6
|
-
# id: your.handle
|
|
7
|
-
# pass: secretpass
|
|
8
|
-
|
|
9
|
-
# load minisky from a local folder - you normally won't need this
|
|
10
|
-
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
|
|
11
|
-
|
|
12
|
-
require 'minisky'
|
|
13
|
-
|
|
14
|
-
if ARGV[0].to_s.empty?
|
|
15
|
-
puts "Usage: #{$PROGRAM_NAME} <text>"
|
|
16
|
-
exit 1
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
text = ARGV[0]
|
|
20
|
-
|
|
21
|
-
# create a client instance
|
|
22
|
-
bsky = Minisky.new('bsky.social', File.join(__dir__, 'bluesky.yml'))
|
|
23
|
-
|
|
24
|
-
# to make a post, we upload a post record to the posts collection (app.bsky.feed.post) in the user's repo
|
|
25
|
-
|
|
26
|
-
bsky.post_request('com.atproto.repo.createRecord', {
|
|
27
|
-
repo: bsky.user.did,
|
|
28
|
-
collection: 'app.bsky.feed.post',
|
|
29
|
-
record: {
|
|
30
|
-
text: text,
|
|
31
|
-
createdAt: Time.now.iso8601, # we need to set the date to current time manually
|
|
32
|
-
langs: ["en"] # if a post does not have a language set, it may be autodetected as an incorrect language
|
|
33
|
-
}
|
|
34
|
-
})
|
|
35
|
-
|
|
36
|
-
puts "Posted ✓"
|
data/example/science_feed.rb
DELETED
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env ruby
|
|
2
|
-
|
|
3
|
-
# Example: load last 10 posts from the "What's Science" feed and print the post text, data and author handle to the
|
|
4
|
-
# terminal. Does not require any authentication.
|
|
5
|
-
#
|
|
6
|
-
# It's definitely not the most efficient way to do this, but it demonstrates how to load single records from the API.
|
|
7
|
-
# (A more efficient way would be e.g. to connect to the AppView at api.bsky.app and make one call to getPosts.)
|
|
8
|
-
|
|
9
|
-
# load minisky from a local folder - you normally won't need this
|
|
10
|
-
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
|
|
11
|
-
|
|
12
|
-
require 'minisky'
|
|
13
|
-
require 'time'
|
|
14
|
-
|
|
15
|
-
# the "What's Science" custom feed by @bossett.bsky.social
|
|
16
|
-
# the service host is hardcoded here, ideally you should fetch the feed record and read the hostname from there
|
|
17
|
-
FEED_HOST = "bs.bossett.io"
|
|
18
|
-
FEED_URI = "at://did:plc:jfhpnnst6flqway4eaeqzj2a/app.bsky.feed.generator/for-science"
|
|
19
|
-
|
|
20
|
-
# fetch the feed from the feed server (getFeedSkeleton returns only a list or URIs of posts)
|
|
21
|
-
# pass nil as the config file parameter to create an unauthenticated client
|
|
22
|
-
feed_api = Minisky.new(FEED_HOST, nil)
|
|
23
|
-
feed = feed_api.get_request('app.bsky.feed.getFeedSkeleton', { feed: FEED_URI, limit: 10 })
|
|
24
|
-
|
|
25
|
-
# second client instance for the Bluesky API - again, pass nil to use without authentication
|
|
26
|
-
bsky = Minisky.new('bsky.social', nil)
|
|
27
|
-
|
|
28
|
-
# for each post URI, fetch the post record and the profile of its author
|
|
29
|
-
entries = feed['feed'].map do |r|
|
|
30
|
-
# AT URI is always: at://<did>/<collection>/<rkey>
|
|
31
|
-
did, collection, rkey = r['post'].gsub('at://', '').split('/')
|
|
32
|
-
|
|
33
|
-
print '.'
|
|
34
|
-
post = bsky.get_request('com.atproto.repo.getRecord', { repo: did, collection: collection, rkey: rkey })
|
|
35
|
-
author = bsky.get_request('com.atproto.repo.describeRepo', { repo: did })
|
|
36
|
-
|
|
37
|
-
[post, author]
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
puts
|
|
41
|
-
|
|
42
|
-
entries.each do |post, author|
|
|
43
|
-
handle = author['handle']
|
|
44
|
-
timestamp = Time.parse(post['value']['createdAt']).getlocal
|
|
45
|
-
link = "https://bsky.app/profile/#{handle}/post/#{post['uri'].split('/').last}"
|
|
46
|
-
|
|
47
|
-
puts "@#{handle} • #{timestamp} • #{link}"
|
|
48
|
-
puts
|
|
49
|
-
puts post['value']['text']
|
|
50
|
-
puts
|
|
51
|
-
puts "=" * 120
|
|
52
|
-
puts
|
|
53
|
-
end
|