minisky 0.2.0 → 0.3.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +17 -0
- data/README.md +57 -19
- data/example/fetch_my_posts.rb +46 -0
- data/example/fetch_profile.rb +53 -0
- data/example/post_skeet.rb +36 -0
- data/example/science_feed.rb +53 -0
- data/lib/minisky/errors.rb +55 -0
- data/lib/minisky/minisky.rb +14 -5
- data/lib/minisky/requests.rb +128 -22
- data/lib/minisky/version.rb +1 -1
- metadata +7 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 7a753ae5a6f7d2dd3089ee9b6a637e578051261c615fedd28a44f63ac1774b4d
|
4
|
+
data.tar.gz: 479240c065cb3ba705aa4a9d07efa8c4904ec2aca2e6ed0e847d391ab8cd62fe
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: f87814325757d1b19cee4e403d6e13afbf84b1353b932eedbce23faa6cb1abfe772d0cf2c459b21bb45ea08fa34173457dea3fdb266f32f1f4334e2e90d22692
|
7
|
+
data.tar.gz: c4bea17a09982db0462e15e092fdf43c454fd94163d04099b119104fde555387621c5c1b8d51e1a1247ad2cece0b5fd9258b1c98555b0ffb7eca4b96107dd83a
|
data/CHANGELOG.md
CHANGED
@@ -1,3 +1,20 @@
|
|
1
|
+
## [0.3.1] - 2023-10-10
|
2
|
+
|
3
|
+
* fixed Minisky not working on Ruby 2.x
|
4
|
+
|
5
|
+
## [0.3.0] - 2023-10-05
|
6
|
+
|
7
|
+
* authentication improvements & changes:
|
8
|
+
- Minisky now automatically manages access tokens, calling `check_access` manually is not necessary (set `auto_manage_tokens` to `false` to disable this)
|
9
|
+
- `check_access` now just checks token's expiry time instead of making a request to `getSession`
|
10
|
+
- added `send_auth_headers` option - set to `false` to not set auth header automatically, which is the default
|
11
|
+
- removed default config file name - explicit file name is now required
|
12
|
+
- Minisky can now be used in unauthenticated mode - pass `nil` as the config file name
|
13
|
+
- added `reset_tokens` helper method
|
14
|
+
* refactored response handling - typed errors are now raised on non-success response status
|
15
|
+
* `user` wrapper can also be used for writing fields to the config
|
16
|
+
* improved error handling
|
17
|
+
|
1
18
|
## [0.2.0] - 2023-09-02
|
2
19
|
|
3
20
|
* more consistent handling of parameters in the main methods:
|
data/README.md
CHANGED
@@ -13,12 +13,38 @@ To install the Minisky gem, run the command:
|
|
13
13
|
|
14
14
|
Or alternatively, add it to the `Gemfile` file for Bundler:
|
15
15
|
|
16
|
-
gem 'minisky', '~> 0.
|
16
|
+
gem 'minisky', '~> 0.3'
|
17
17
|
|
18
18
|
|
19
19
|
## Usage
|
20
20
|
|
21
|
-
|
21
|
+
All calls to the XRPC API are made through an instance of the `Minisky` class. There are two ways to use the library: with or without authentication.
|
22
|
+
|
23
|
+
|
24
|
+
### Unauthenticated access
|
25
|
+
|
26
|
+
You can access parts of the API anonymously without any authentication. This currently includes: read-only `com.atproto.*` routes on the PDS (user's data server) and most read-only `app.bsky.*` routes on the AppView server.
|
27
|
+
|
28
|
+
This allows you to do things like:
|
29
|
+
|
30
|
+
- look up specific records or lists of all records of a given type in any account (in their raw form)
|
31
|
+
- look up profile information about any account
|
32
|
+
- load complete threads or users' profile feeds from the AppView
|
33
|
+
|
34
|
+
To use Minisky this way, create a `Minisky` instance passing the API hostname string (at the moment there is only one server at `bsky.social`, but there will be more once federation support goes live) and `nil` as the configuration in the arguments:
|
35
|
+
|
36
|
+
```rb
|
37
|
+
require 'minisky'
|
38
|
+
|
39
|
+
bsky = Minisky.new('bsky.social', nil)
|
40
|
+
```
|
41
|
+
|
42
|
+
|
43
|
+
### Authenticated access
|
44
|
+
|
45
|
+
To use the complete API including posting or reading your home feed, you need to log in using your account info and get an access token which will be added as an authentication header to all requests.
|
46
|
+
|
47
|
+
First, you need to create a `.yml` config file with the authentication data, e.g. `bluesky.yml`. It should look like this:
|
22
48
|
|
23
49
|
```yaml
|
24
50
|
id: my.bsky.username
|
@@ -29,43 +55,50 @@ The `id` can be either your handle, or your DID, or the email you've used to sig
|
|
29
55
|
|
30
56
|
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`.
|
31
57
|
|
32
|
-
Next, create the Minisky client instance, passing the server name
|
58
|
+
Next, create the Minisky client instance, passing the server name and the config file name:
|
33
59
|
|
34
60
|
```rb
|
35
61
|
require 'minisky'
|
36
62
|
|
37
|
-
bsky = Minisky.new('bsky.social')
|
38
|
-
bsky.check_access
|
63
|
+
bsky = Minisky.new('bsky.social', 'bluesky.yml')
|
39
64
|
```
|
40
65
|
|
41
|
-
|
66
|
+
Minisky automatically manages your access and refresh tokens - it will first log you in using the login & password, and then use the refresh token to update the access token before the request when it expires.
|
67
|
+
|
42
68
|
|
43
|
-
|
69
|
+
### Making requests
|
70
|
+
|
71
|
+
With a `Minisky` client instance, you can make requests to the Bluesky API using `get_request` and `post_request`:
|
44
72
|
|
45
73
|
```rb
|
46
|
-
bsky.get_request('com.atproto.repo.listRecords', {
|
74
|
+
json = bsky.get_request('com.atproto.repo.listRecords', {
|
47
75
|
repo: bsky.user.did,
|
48
76
|
collection: 'app.bsky.feed.like'
|
49
77
|
})
|
50
78
|
|
79
|
+
json['records'].each do |r|
|
80
|
+
puts r['value']['subject']['uri']
|
81
|
+
end
|
82
|
+
|
51
83
|
bsky.post_request('com.atproto.repo.createRecord', {
|
52
84
|
repo: bsky.user.did,
|
53
85
|
collection: 'app.bsky.feed.post',
|
54
86
|
record: {
|
55
87
|
text: "Hello world!",
|
56
|
-
createdAt: Time.now.iso8601
|
88
|
+
createdAt: Time.now.iso8601,
|
89
|
+
langs: ["en"]
|
57
90
|
}
|
58
91
|
})
|
59
92
|
```
|
60
93
|
|
61
|
-
|
94
|
+
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.
|
62
95
|
|
63
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, but not earlier:
|
64
97
|
|
65
98
|
```rb
|
66
99
|
time_limit = Time.now - 86400 * 30
|
67
100
|
|
68
|
-
bsky.fetch_all('com.atproto.repo.listRecords',
|
101
|
+
posts = bsky.fetch_all('com.atproto.repo.listRecords',
|
69
102
|
{ repo: bsky.user.did, collection: 'app.bsky.feed.post' },
|
70
103
|
field: 'records',
|
71
104
|
max_pages: 10,
|
@@ -75,7 +108,7 @@ bsky.fetch_all('com.atproto.repo.listRecords',
|
|
75
108
|
There is also a `progress` option you can use to print some kind of character for every page load. E.g. pass `progress: '.'` to print dots as the pages are loading:
|
76
109
|
|
77
110
|
```rb
|
78
|
-
bsky.fetch_all('com.atproto.repo.listRecords',
|
111
|
+
likes = bsky.fetch_all('com.atproto.repo.listRecords',
|
79
112
|
{ repo: bsky.user.did, collection: 'app.bsky.feed.like' },
|
80
113
|
field: 'records',
|
81
114
|
progress: '.')
|
@@ -87,19 +120,25 @@ This will output a line like this:
|
|
87
120
|
.................
|
88
121
|
```
|
89
122
|
|
123
|
+
You can find more examples in the [example](https://github.com/mackuba/minisky/tree/master/example) directory.
|
124
|
+
|
125
|
+
|
90
126
|
## Customization
|
91
127
|
|
92
|
-
The `Minisky` client currently supports
|
128
|
+
The `Minisky` client currently supports such configuration options:
|
93
129
|
|
94
130
|
- `default_progress` - a progress character to automatically use for `#fetch_all` calls (default: `nil`)
|
131
|
+
- `send_auth_headers` - whether auth headers should be added by default (default: `true` in authenticated mode)
|
132
|
+
- `auto_manage_tokens` - whether access tokens should be generated and refreshed automatically when needed (default: `true` in authenticated mode)
|
95
133
|
|
96
|
-
|
134
|
+
In authenticated mode, you can disable the `send_auth_headers` option and then explicitly add `auth: true` to specific requests to include a header there.
|
97
135
|
|
98
|
-
|
99
|
-
|
100
|
-
|
136
|
+
You can also disable the `auto_manage_tokens` option - in this case you will need to call the `#check_access` method before a request to refresh a token if needed, or alternatively, call either `#login` or `#perform_token_refresh`.
|
137
|
+
|
138
|
+
|
139
|
+
### Using your own class
|
101
140
|
|
102
|
-
|
141
|
+
Instead of using the `Minisky` class, you can also make your own class that includes the `Minisky::Requests` module and provides a different way to load & save the config, e.g. from a JSON file:
|
103
142
|
|
104
143
|
```rb
|
105
144
|
class BlueskyClient
|
@@ -126,7 +165,6 @@ It can then be used just like the `Minisky` class:
|
|
126
165
|
|
127
166
|
```rb
|
128
167
|
bsky = BlueskyClient.new('config/access.json')
|
129
|
-
bsky.check_access
|
130
168
|
bsky.get_request(...)
|
131
169
|
```
|
132
170
|
|
@@ -0,0 +1,46 @@
|
|
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))
|
@@ -0,0 +1,53 @@
|
|
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
|
+
}
|
@@ -0,0 +1,36 @@
|
|
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 ✓"
|
@@ -0,0 +1,53 @@
|
|
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
|
@@ -0,0 +1,55 @@
|
|
1
|
+
require_relative 'minisky'
|
2
|
+
|
3
|
+
class Minisky
|
4
|
+
class Error < StandardError
|
5
|
+
end
|
6
|
+
|
7
|
+
class AuthError < Error
|
8
|
+
def initialize(message)
|
9
|
+
super(message)
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
class BadResponse < Error
|
14
|
+
attr_reader :status, :data
|
15
|
+
|
16
|
+
def initialize(status, status_message, data)
|
17
|
+
@status = status
|
18
|
+
@data = data
|
19
|
+
|
20
|
+
message = if error_message
|
21
|
+
"#{status} #{status_message}: #{error_message}"
|
22
|
+
else
|
23
|
+
"#{status} #{status_message}"
|
24
|
+
end
|
25
|
+
|
26
|
+
super(message)
|
27
|
+
end
|
28
|
+
|
29
|
+
def error_type
|
30
|
+
@data['error'] if @data.is_a?(Hash)
|
31
|
+
end
|
32
|
+
|
33
|
+
def error_message
|
34
|
+
@data['message'] if @data.is_a?(Hash)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
class ClientErrorResponse < BadResponse
|
39
|
+
end
|
40
|
+
|
41
|
+
class ServerErrorResponse < BadResponse
|
42
|
+
end
|
43
|
+
|
44
|
+
class ExpiredTokenError < ClientErrorResponse
|
45
|
+
end
|
46
|
+
|
47
|
+
class UnexpectedRedirect < BadResponse
|
48
|
+
attr_reader :location
|
49
|
+
|
50
|
+
def initialize(status, status_message, location)
|
51
|
+
super(status, status_message, { 'message' => "Unexpected redirect: #{location}" })
|
52
|
+
@location = location
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
data/lib/minisky/minisky.rb
CHANGED
@@ -1,18 +1,27 @@
|
|
1
1
|
require 'yaml'
|
2
2
|
|
3
3
|
class Minisky
|
4
|
-
DEFAULT_CONFIG_FILE = 'bluesky.yml'
|
5
|
-
|
6
4
|
attr_reader :host, :config
|
7
5
|
|
8
|
-
def initialize(host, config_file
|
6
|
+
def initialize(host, config_file)
|
9
7
|
@host = host
|
10
8
|
@config_file = config_file
|
11
|
-
|
9
|
+
|
10
|
+
if @config_file
|
11
|
+
@config = YAML.load(File.read(@config_file))
|
12
|
+
|
13
|
+
if user.id.nil? || user.pass.nil?
|
14
|
+
raise AuthError, "Missing user id or password in the config file #{@config_file}"
|
15
|
+
end
|
16
|
+
else
|
17
|
+
@config = {}
|
18
|
+
@send_auth_headers = false
|
19
|
+
@auto_manage_tokens = false
|
20
|
+
end
|
12
21
|
end
|
13
22
|
|
14
23
|
def save_config
|
15
|
-
File.write(@config_file, YAML.dump(@config))
|
24
|
+
File.write(@config_file, YAML.dump(@config)) if @config_file
|
16
25
|
end
|
17
26
|
end
|
18
27
|
|
data/lib/minisky/requests.rb
CHANGED
@@ -1,8 +1,10 @@
|
|
1
1
|
require_relative 'minisky'
|
2
|
+
require_relative 'errors'
|
2
3
|
|
4
|
+
require 'base64'
|
3
5
|
require 'json'
|
4
6
|
require 'net/http'
|
5
|
-
require '
|
7
|
+
require 'time'
|
6
8
|
require 'uri'
|
7
9
|
|
8
10
|
class Minisky
|
@@ -15,13 +17,27 @@ class Minisky
|
|
15
17
|
!!(access_token && refresh_token)
|
16
18
|
end
|
17
19
|
|
18
|
-
def method_missing(name)
|
19
|
-
|
20
|
+
def method_missing(name, *args)
|
21
|
+
if name.end_with?('=')
|
22
|
+
@config[name.to_s.chop] = args[0]
|
23
|
+
else
|
24
|
+
@config[name.to_s]
|
25
|
+
end
|
20
26
|
end
|
21
27
|
end
|
22
28
|
|
23
29
|
module Requests
|
24
30
|
attr_accessor :default_progress
|
31
|
+
attr_writer :send_auth_headers
|
32
|
+
attr_writer :auto_manage_tokens
|
33
|
+
|
34
|
+
def send_auth_headers
|
35
|
+
instance_variable_defined?('@send_auth_headers') ? @send_auth_headers : true
|
36
|
+
end
|
37
|
+
|
38
|
+
def auto_manage_tokens
|
39
|
+
instance_variable_defined?('@auto_manage_tokens') ? @auto_manage_tokens : true
|
40
|
+
end
|
25
41
|
|
26
42
|
def base_url
|
27
43
|
@base_url ||= "https://#{host}/xrpc"
|
@@ -31,7 +47,9 @@ class Minisky
|
|
31
47
|
@user ||= User.new(config)
|
32
48
|
end
|
33
49
|
|
34
|
-
def get_request(method, params = nil, auth:
|
50
|
+
def get_request(method, params = nil, auth: default_auth_mode)
|
51
|
+
check_access if auto_manage_tokens && auth == true
|
52
|
+
|
35
53
|
headers = authentication_header(auth)
|
36
54
|
url = URI("#{base_url}/#{method}")
|
37
55
|
|
@@ -39,21 +57,24 @@ class Minisky
|
|
39
57
|
url.query = URI.encode_www_form(params)
|
40
58
|
end
|
41
59
|
|
42
|
-
|
60
|
+
request = Net::HTTP::Get.new(url, headers)
|
61
|
+
|
62
|
+
response = make_request(request)
|
63
|
+
handle_response(response)
|
43
64
|
end
|
44
65
|
|
45
|
-
def post_request(method, params = nil, auth:
|
66
|
+
def post_request(method, params = nil, auth: default_auth_mode)
|
67
|
+
check_access if auto_manage_tokens && auth == true
|
68
|
+
|
46
69
|
headers = authentication_header(auth).merge({ "Content-Type" => "application/json" })
|
47
70
|
body = params ? params.to_json : ''
|
48
71
|
|
49
72
|
response = Net::HTTP.post(URI("#{base_url}/#{method}"), body, headers)
|
50
|
-
|
51
|
-
|
52
|
-
JSON.parse(response.body)
|
73
|
+
handle_response(response)
|
53
74
|
end
|
54
75
|
|
55
76
|
def fetch_all(method, params = nil, field:,
|
56
|
-
auth:
|
77
|
+
auth: default_auth_mode, break_when: nil, max_pages: nil, progress: @default_progress)
|
57
78
|
data = []
|
58
79
|
params = {} if params.nil?
|
59
80
|
pages = 0
|
@@ -80,16 +101,16 @@ class Minisky
|
|
80
101
|
def check_access
|
81
102
|
if !user.logged_in?
|
82
103
|
log_in
|
83
|
-
|
84
|
-
|
85
|
-
get_request('com.atproto.server.getSession')
|
86
|
-
rescue OpenURI::HTTPError
|
87
|
-
perform_token_refresh
|
88
|
-
end
|
104
|
+
elsif token_expiration_date(user.access_token) < Time.now + 60
|
105
|
+
perform_token_refresh
|
89
106
|
end
|
90
107
|
end
|
91
108
|
|
92
109
|
def log_in
|
110
|
+
if user.id.nil? || user.pass.nil?
|
111
|
+
raise AuthError, "To log in, please provide a user id and password"
|
112
|
+
end
|
113
|
+
|
93
114
|
data = {
|
94
115
|
identifier: user.id,
|
95
116
|
password: user.pass
|
@@ -97,35 +118,120 @@ class Minisky
|
|
97
118
|
|
98
119
|
json = post_request('com.atproto.server.createSession', data, auth: false)
|
99
120
|
|
100
|
-
|
101
|
-
|
102
|
-
|
121
|
+
user.did = json['did']
|
122
|
+
user.access_token = json['accessJwt']
|
123
|
+
user.refresh_token = json['refreshJwt']
|
103
124
|
|
104
125
|
save_config
|
105
126
|
json
|
106
127
|
end
|
107
128
|
|
108
129
|
def perform_token_refresh
|
130
|
+
if user.refresh_token.nil?
|
131
|
+
raise AuthError, "Can't refresh access token - refresh token is missing"
|
132
|
+
end
|
133
|
+
|
109
134
|
json = post_request('com.atproto.server.refreshSession', auth: user.refresh_token)
|
110
135
|
|
111
|
-
|
112
|
-
|
136
|
+
user.access_token = json['accessJwt']
|
137
|
+
user.refresh_token = json['refreshJwt']
|
113
138
|
|
114
139
|
save_config
|
115
140
|
json
|
116
141
|
end
|
117
142
|
|
143
|
+
def reset_tokens
|
144
|
+
user.access_token = nil
|
145
|
+
user.refresh_token = nil
|
146
|
+
save_config
|
147
|
+
nil
|
148
|
+
end
|
149
|
+
|
150
|
+
if RUBY_VERSION.to_i == 2
|
151
|
+
alias_method :do_get_request, :get_request
|
152
|
+
alias_method :do_post_request, :post_request
|
153
|
+
private :do_get_request, :do_post_request
|
154
|
+
|
155
|
+
def get_request(method, params = nil, auth: default_auth_mode, **kwargs)
|
156
|
+
params ||= kwargs unless kwargs.empty?
|
157
|
+
do_get_request(method, params, auth: auth)
|
158
|
+
end
|
159
|
+
|
160
|
+
def post_request(method, params = nil, auth: default_auth_mode, **kwargs)
|
161
|
+
params ||= kwargs unless kwargs.empty?
|
162
|
+
do_post_request(method, params, auth: auth)
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
|
118
167
|
private
|
119
168
|
|
169
|
+
def make_request(request)
|
170
|
+
# this long form is needed because #get_response only supports a headers param in Ruby 3.x
|
171
|
+
response = Net::HTTP.start(request.uri.hostname, request.uri.port, use_ssl: true) do |http|
|
172
|
+
http.request(request)
|
173
|
+
end
|
174
|
+
end
|
175
|
+
|
176
|
+
def default_auth_mode
|
177
|
+
!!send_auth_headers
|
178
|
+
end
|
179
|
+
|
120
180
|
def authentication_header(auth)
|
121
181
|
if auth.is_a?(String)
|
122
182
|
{ 'Authorization' => "Bearer #{auth}" }
|
123
183
|
elsif auth
|
124
|
-
|
184
|
+
if user.access_token
|
185
|
+
{ 'Authorization' => "Bearer #{user.access_token}" }
|
186
|
+
else
|
187
|
+
raise AuthError, "Can't send auth headers, access token is missing"
|
188
|
+
end
|
125
189
|
else
|
126
190
|
{}
|
127
191
|
end
|
128
192
|
end
|
193
|
+
|
194
|
+
def token_expiration_date(token)
|
195
|
+
parts = token.split('.')
|
196
|
+
raise AuthError, "Invalid access token format" unless parts.length == 3
|
197
|
+
|
198
|
+
begin
|
199
|
+
payload = JSON.parse(Base64.decode64(parts[1]))
|
200
|
+
rescue JSON::ParserError
|
201
|
+
raise AuthError, "Couldn't decode payload from access token"
|
202
|
+
end
|
203
|
+
|
204
|
+
exp = payload['exp']
|
205
|
+
raise AuthError, "Invalid token expiry data" unless exp.is_a?(Numeric) && exp > 0
|
206
|
+
|
207
|
+
Time.at(exp)
|
208
|
+
end
|
209
|
+
|
210
|
+
def handle_response(response)
|
211
|
+
status = response.code.to_i
|
212
|
+
message = response.message
|
213
|
+
|
214
|
+
case response
|
215
|
+
when Net::HTTPSuccess
|
216
|
+
JSON.parse(response.body)
|
217
|
+
when Net::HTTPRedirection
|
218
|
+
raise UnexpectedRedirect.new(status, message, response['location'])
|
219
|
+
else
|
220
|
+
data = (response.content_type == 'application/json') ? JSON.parse(response.body) : response.body
|
221
|
+
|
222
|
+
error_class = if data.is_a?(Hash) && data['error'] == 'ExpiredToken'
|
223
|
+
ExpiredTokenError
|
224
|
+
elsif response.is_a?(Net::HTTPClientError)
|
225
|
+
ClientErrorResponse
|
226
|
+
elsif response.is_a?(Net::HTTPServerError)
|
227
|
+
ServerErrorResponse
|
228
|
+
else
|
229
|
+
BadResponse
|
230
|
+
end
|
231
|
+
|
232
|
+
raise error_class.new(status, message, data)
|
233
|
+
end
|
234
|
+
end
|
129
235
|
end
|
130
236
|
|
131
237
|
include Requests
|
data/lib/minisky/version.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: minisky
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.3.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Kuba Suder
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2023-
|
11
|
+
date: 2023-10-10 00:00:00.000000000 Z
|
12
12
|
dependencies: []
|
13
13
|
description: A very simple client class that lets you log in to the Bluesky API and
|
14
14
|
make any requests there.
|
@@ -21,7 +21,12 @@ files:
|
|
21
21
|
- CHANGELOG.md
|
22
22
|
- LICENSE.txt
|
23
23
|
- README.md
|
24
|
+
- example/fetch_my_posts.rb
|
25
|
+
- example/fetch_profile.rb
|
26
|
+
- example/post_skeet.rb
|
27
|
+
- example/science_feed.rb
|
24
28
|
- lib/minisky.rb
|
29
|
+
- lib/minisky/errors.rb
|
25
30
|
- lib/minisky/minisky.rb
|
26
31
|
- lib/minisky/requests.rb
|
27
32
|
- lib/minisky/version.rb
|