minisky 0.0.1 → 0.3.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +44 -1
- data/README.md +130 -13
- data/example/fetch_my_posts.rb +46 -0
- data/example/post_skeet.rb +34 -0
- data/example/science_feed.rb +52 -0
- data/lib/minisky/errors.rb +55 -0
- data/lib/minisky/minisky.rb +20 -94
- data/lib/minisky/requests.rb +212 -0
- 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: 8f4836f5b8d8de9c5f9af0368144fe60f274e1b61176d1c919b70d3fd645a3f3
|
4
|
+
data.tar.gz: 8c87cdbb362b19651e848804aa6c551c88abc759bbdd3df6e609b62b94a7da4b
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 84052665f10f23db1513a5135dcb9032f0fbc4c5ae55fc7bbeb15e26d85904acda3ac55170e58d8901e5549ceaaafa34aa038ec8987da9dfdded5349080f12d2
|
7
|
+
data.tar.gz: 5557b97bb51b8feb757a2d16dccfefd7dfd31655a833b379681d89cc924717e234baa19523cda89368f6610481cf11c0dd9494355539d4c7364c9913a05c2ca1
|
data/CHANGELOG.md
CHANGED
@@ -1 +1,44 @@
|
|
1
|
-
## [
|
1
|
+
## [0.3.0] - 2023-10-05
|
2
|
+
|
3
|
+
* authentication improvements & changes:
|
4
|
+
- Minisky now automatically manages access tokens, calling `check_access` manually is not necessary (set `auto_manage_tokens` to `false` to disable this)
|
5
|
+
- `check_access` now just checks token's expiry time instead of making a request to `getSession`
|
6
|
+
- added `send_auth_headers` option - set to `false` to not set auth header automatically, which is the default
|
7
|
+
- removed default config file name - explicit file name is now required
|
8
|
+
- Minisky can now be used in unauthenticated mode - pass `nil` as the config file name
|
9
|
+
- added `reset_tokens` helper method
|
10
|
+
* refactored response handling - typed errors are now raised on non-success response status
|
11
|
+
* `user` wrapper can also be used for writing fields to the config
|
12
|
+
* improved error handling
|
13
|
+
|
14
|
+
## [0.2.0] - 2023-09-02
|
15
|
+
|
16
|
+
* more consistent handling of parameters in the main methods:
|
17
|
+
- `auth` is now a named parameter
|
18
|
+
- access token is used by default, pass `nil` or an explicit token as `auth` to override
|
19
|
+
- `params` is always optional
|
20
|
+
* progress dots in `#fetch_all`:
|
21
|
+
- default is now to not print anything
|
22
|
+
- pass `'.'` or any other character/string to show progress
|
23
|
+
- set `default_progress` on the client object to use for all `#fetch_all` calls
|
24
|
+
* added `max_pages` option to `#fetch_all`
|
25
|
+
* `#login` and `#perform_token_refresh` methods use the JSON response as return value
|
26
|
+
* renamed `ident` field in the config hash to `id`
|
27
|
+
* config is now accessed in `Requests` from the client object as a `config` property instead of `@config` ivar
|
28
|
+
* config fields are exposed as a `user` wrapper object, e.g. `user.did` delegates to `@config['did']`
|
29
|
+
|
30
|
+
## [0.1.0] - 2023-09-01
|
31
|
+
|
32
|
+
- extracted most code to a `Requests` module that can be included into a different client class with custom config handling
|
33
|
+
- added `#check_access` method
|
34
|
+
- hostname is now passed as a parameter
|
35
|
+
- config file name can be passed as a parameter
|
36
|
+
- added tests
|
37
|
+
|
38
|
+
## [0.0.1] - 2023-08-30
|
39
|
+
|
40
|
+
Initial release - extracted from original gist:
|
41
|
+
|
42
|
+
- logging in and refreshing the token
|
43
|
+
- making GET & POST requests
|
44
|
+
- fetching paginated responses
|
data/README.md
CHANGED
@@ -1,31 +1,148 @@
|
|
1
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
|
-
Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/minisky`. To experiment with that code, run `bin/console` for an interactive prompt.
|
6
5
|
|
7
6
|
## Installation
|
8
7
|
|
9
|
-
|
8
|
+
To use Minisky, you need a reasonably new version of Ruby (2.6+). Such version should be preinstalled on macOS Big Sur and above and some 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.
|
10
9
|
|
11
|
-
|
10
|
+
To install the Minisky gem, run the command:
|
12
11
|
|
13
|
-
|
12
|
+
[sudo] gem install minisky
|
14
13
|
|
15
|
-
|
14
|
+
Or alternatively, add it to the `Gemfile` file for Bundler:
|
15
|
+
|
16
|
+
gem 'minisky', '~> 0.3'
|
16
17
|
|
17
|
-
$ gem install UPDATE_WITH_YOUR_GEM_NAME_PRIOR_TO_RELEASE_TO_RUBYGEMS_ORG
|
18
18
|
|
19
19
|
## Usage
|
20
20
|
|
21
|
-
|
21
|
+
First, you need to create a `.yml` config file with the authentication data, e.g. `bluesky.yml`. It should look like this:
|
22
|
+
|
23
|
+
```yaml
|
24
|
+
id: my.bsky.username
|
25
|
+
pass: very-secret-password
|
26
|
+
```
|
27
|
+
|
28
|
+
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.
|
29
|
+
|
30
|
+
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
|
+
|
32
|
+
Next, create the Minisky client instance, passing the server name and the config file name (at the moment there is only one server at `bsky.social`, but there will be more once federation support goes live):
|
33
|
+
|
34
|
+
```rb
|
35
|
+
require 'minisky'
|
36
|
+
|
37
|
+
bsky = Minisky.new('bsky.social', 'bluesky.yml')
|
38
|
+
bsky.check_access
|
39
|
+
```
|
40
|
+
|
41
|
+
`check_access` will check if an access token is saved, if not - it will log you in using the login & password, otherwise it will check if the token is still valid and refresh it if needed.
|
42
|
+
|
43
|
+
Now, you can make requests to the Bluesky API using `get_request` and `post_request`:
|
44
|
+
|
45
|
+
```rb
|
46
|
+
json = bsky.get_request('com.atproto.repo.listRecords', {
|
47
|
+
repo: bsky.user.did,
|
48
|
+
collection: 'app.bsky.feed.like'
|
49
|
+
})
|
50
|
+
|
51
|
+
json['records'].each do |r|
|
52
|
+
puts r['value']['subject']['uri']
|
53
|
+
end
|
54
|
+
|
55
|
+
bsky.post_request('com.atproto.repo.createRecord', {
|
56
|
+
repo: bsky.user.did,
|
57
|
+
collection: 'app.bsky.feed.post',
|
58
|
+
record: {
|
59
|
+
text: "Hello world!",
|
60
|
+
createdAt: Time.now.iso8601,
|
61
|
+
langs: ["en"]
|
62
|
+
}
|
63
|
+
})
|
64
|
+
```
|
65
|
+
|
66
|
+
The requests use the saved access token for authentication automatically. You can also pass `auth: false` or `auth: nil` to not send any authentication headers, or `auth: sometoken` to use a specific other token.
|
67
|
+
|
68
|
+
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:
|
69
|
+
|
70
|
+
```rb
|
71
|
+
time_limit = Time.now - 86400 * 30
|
72
|
+
|
73
|
+
posts = bsky.fetch_all('com.atproto.repo.listRecords',
|
74
|
+
{ repo: bsky.user.did, collection: 'app.bsky.feed.post' },
|
75
|
+
field: 'records',
|
76
|
+
max_pages: 10,
|
77
|
+
break_when: ->(x) { Time.parse(x['value']['createdAt']) < time_limit })
|
78
|
+
```
|
79
|
+
|
80
|
+
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:
|
81
|
+
|
82
|
+
```rb
|
83
|
+
likes = bsky.fetch_all('com.atproto.repo.listRecords',
|
84
|
+
{ repo: bsky.user.did, collection: 'app.bsky.feed.like' },
|
85
|
+
field: 'records',
|
86
|
+
progress: '.')
|
87
|
+
```
|
88
|
+
|
89
|
+
This will output a line like this:
|
90
|
+
|
91
|
+
```
|
92
|
+
.................
|
93
|
+
```
|
94
|
+
|
95
|
+
You can find more examples in the [example](https://github.com/mackuba/minisky/tree/master/example) directory.
|
96
|
+
|
97
|
+
|
98
|
+
## Customization
|
99
|
+
|
100
|
+
The `Minisky` client currently supports one configuration option:
|
101
|
+
|
102
|
+
- `default_progress` - a progress character to automatically use for `#fetch_all` calls (default: `nil`)
|
103
|
+
|
104
|
+
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:
|
105
|
+
|
106
|
+
```rb
|
107
|
+
class BlueskyClient
|
108
|
+
include Minisky::Requests
|
109
|
+
|
110
|
+
attr_reader :config
|
111
|
+
|
112
|
+
def initialize(config_file)
|
113
|
+
@config_file = config_file
|
114
|
+
@config = JSON.parse(File.read(@config_file))
|
115
|
+
end
|
116
|
+
|
117
|
+
def host
|
118
|
+
'bsky.social'
|
119
|
+
end
|
120
|
+
|
121
|
+
def save_config
|
122
|
+
File.write(@config_file, JSON.pretty_generate(@config))
|
123
|
+
end
|
124
|
+
end
|
125
|
+
```
|
126
|
+
|
127
|
+
It can then be used just like the `Minisky` class:
|
128
|
+
|
129
|
+
```rb
|
130
|
+
bsky = BlueskyClient.new('config/access.json')
|
131
|
+
bsky.check_access
|
132
|
+
bsky.get_request(...)
|
133
|
+
```
|
134
|
+
|
135
|
+
The class needs to provide:
|
136
|
+
|
137
|
+
- a `host` method or property that returns the hostname of the server
|
138
|
+
- a `config` property which returns a hash or a hash-like object with the configuration and user data - it needs to support reading and writing arbitrary key-value pairs with string keys
|
139
|
+
- a `save_config` method which persists the config object to the chosen storage
|
22
140
|
|
23
|
-
## Development
|
24
141
|
|
25
|
-
|
142
|
+
## Credits
|
26
143
|
|
27
|
-
|
144
|
+
Copyright © 2023 Kuba Suder ([@mackuba.eu](https://bsky.app/profile/mackuba.eu)).
|
28
145
|
|
29
|
-
|
146
|
+
The code is available under the terms of the [zlib license](https://choosealicense.com/licenses/zlib/) (permissive, similar to MIT).
|
30
147
|
|
31
|
-
Bug reports and pull requests are welcome
|
148
|
+
Bug reports and pull requests are welcome 😎
|
@@ -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,34 @@
|
|
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
|
+
bsky.post_request('com.atproto.repo.createRecord', {
|
25
|
+
repo: bsky.user.did,
|
26
|
+
collection: 'app.bsky.feed.post',
|
27
|
+
record: {
|
28
|
+
text: text,
|
29
|
+
langs: ["en"],
|
30
|
+
createdAt: Time.now.iso8601
|
31
|
+
}
|
32
|
+
})
|
33
|
+
|
34
|
+
puts "Posted ✓"
|
@@ -0,0 +1,52 @@
|
|
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
|
+
|
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
|
+
# the "What's Science" custom feed by @bossett.bsky.social
|
15
|
+
# the service host is hardcoded here, ideally you should fetch the feed record and read the hostname from there
|
16
|
+
FEED_HOST = "bs.bossett.io"
|
17
|
+
FEED_URI = "at://did:plc:jfhpnnst6flqway4eaeqzj2a/app.bsky.feed.generator/for-science"
|
18
|
+
|
19
|
+
# fetch the feed from the feed server (getFeedSkeleton returns only a list or URIs of posts)
|
20
|
+
# pass nil as the config file parameter to create an unauthenticated client
|
21
|
+
feed_api = Minisky.new(FEED_HOST, nil)
|
22
|
+
feed = feed_api.get_request('app.bsky.feed.getFeedSkeleton', { feed: FEED_URI, limit: 10 })
|
23
|
+
|
24
|
+
# second client instance for the Bluesky API - again, pass nil to use without authentication
|
25
|
+
bsky = Minisky.new('bsky.social', nil)
|
26
|
+
|
27
|
+
# for each post URI, fetch the post record and the profile of its author
|
28
|
+
entries = feed['feed'].map do |r|
|
29
|
+
# AT URI is always: at://<did>/<collection>/<rkey>
|
30
|
+
did, collection, rkey = r['post'].gsub('at://', '').split('/')
|
31
|
+
|
32
|
+
print '.'
|
33
|
+
post = bsky.get_request('com.atproto.repo.getRecord', { repo: did, collection: collection, rkey: rkey })
|
34
|
+
author = bsky.get_request('com.atproto.repo.describeRepo', { repo: did })
|
35
|
+
|
36
|
+
[post, author]
|
37
|
+
end
|
38
|
+
|
39
|
+
puts
|
40
|
+
|
41
|
+
entries.each do |post, author|
|
42
|
+
handle = author['handle']
|
43
|
+
timestamp = Time.parse(post['value']['createdAt']).getlocal
|
44
|
+
link = "https://bsky.app/profile/#{handle}/post/#{post['uri'].split('/').last}"
|
45
|
+
|
46
|
+
puts "@#{handle} • #{timestamp} • #{link}"
|
47
|
+
puts
|
48
|
+
puts post['value']['text']
|
49
|
+
puts
|
50
|
+
puts "=" * 120
|
51
|
+
puts
|
52
|
+
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,102 +1,28 @@
|
|
1
|
-
require 'json'
|
2
|
-
require 'net/http'
|
3
|
-
require 'open-uri'
|
4
1
|
require 'yaml'
|
5
2
|
|
6
3
|
class Minisky
|
7
|
-
|
8
|
-
|
9
|
-
def initialize
|
10
|
-
@
|
11
|
-
@
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
@config['access_token']
|
24
|
-
end
|
25
|
-
|
26
|
-
def refresh_token
|
27
|
-
@config['refresh_token']
|
28
|
-
end
|
29
|
-
|
30
|
-
def save_config
|
31
|
-
File.write(CONFIG_FILE, YAML.dump(@config))
|
32
|
-
end
|
33
|
-
|
34
|
-
def log_in
|
35
|
-
json = post_request('com.atproto.server.createSession', {
|
36
|
-
identifier: @config['ident'],
|
37
|
-
password: @config['pass']
|
38
|
-
})
|
39
|
-
|
40
|
-
@config['did'] = json['did']
|
41
|
-
@config['access_token'] = json['accessJwt']
|
42
|
-
@config['refresh_token'] = json['refreshJwt']
|
43
|
-
save_config
|
44
|
-
end
|
45
|
-
|
46
|
-
def perform_token_refresh
|
47
|
-
json = post_request('com.atproto.server.refreshSession', nil, refresh_token)
|
48
|
-
@config['access_token'] = json['accessJwt']
|
49
|
-
@config['refresh_token'] = json['refreshJwt']
|
50
|
-
save_config
|
51
|
-
end
|
52
|
-
|
53
|
-
def fetch_all(method, params, auth = nil, field:, break_when: ->(x) { false }, progress: true)
|
54
|
-
data = []
|
55
|
-
|
56
|
-
loop do
|
57
|
-
print '.' if progress
|
58
|
-
|
59
|
-
response = get_request(method, params, auth)
|
60
|
-
records = response[field]
|
61
|
-
cursor = response['cursor']
|
62
|
-
|
63
|
-
data.concat(records)
|
64
|
-
params[:cursor] = cursor
|
65
|
-
|
66
|
-
break if cursor.nil? || records.empty? || records.any? { |x| break_when.call(x) }
|
4
|
+
attr_reader :host, :config
|
5
|
+
|
6
|
+
def initialize(host, config_file)
|
7
|
+
@host = host
|
8
|
+
@config_file = config_file
|
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
|
67
20
|
end
|
68
|
-
|
69
|
-
data.reject { |x| break_when.call(x) }
|
70
21
|
end
|
71
22
|
|
72
|
-
def
|
73
|
-
|
74
|
-
headers['Authorization'] = "Bearer #{auth}" if auth
|
75
|
-
|
76
|
-
url = "#{@base_url}/#{method}"
|
77
|
-
|
78
|
-
if params && !params.empty?
|
79
|
-
url += "?" + params.map { |k, v|
|
80
|
-
if v.is_a?(Array)
|
81
|
-
v.map { |x| "#{k}=#{x}" }.join('&')
|
82
|
-
else
|
83
|
-
"#{k}=#{v}"
|
84
|
-
end
|
85
|
-
}.join('&')
|
86
|
-
end
|
87
|
-
|
88
|
-
JSON.parse(URI.open(url, headers).read)
|
89
|
-
end
|
90
|
-
|
91
|
-
def post_request(method, params, auth = nil)
|
92
|
-
headers = { "Content-Type" => "application/json" }
|
93
|
-
headers['Authorization'] = "Bearer #{auth}" if auth
|
94
|
-
|
95
|
-
body = params ? params.to_json : ''
|
96
|
-
|
97
|
-
response = Net::HTTP.post(URI("#{@base_url}/#{method}"), body, headers)
|
98
|
-
raise "Invalid response: #{response.code} #{response.body}" if response.code.to_i / 100 != 2
|
99
|
-
|
100
|
-
JSON.parse(response.body)
|
23
|
+
def save_config
|
24
|
+
File.write(@config_file, YAML.dump(@config)) if @config_file
|
101
25
|
end
|
102
26
|
end
|
27
|
+
|
28
|
+
require_relative 'requests'
|
@@ -0,0 +1,212 @@
|
|
1
|
+
require_relative 'minisky'
|
2
|
+
require_relative 'errors'
|
3
|
+
|
4
|
+
require 'base64'
|
5
|
+
require 'json'
|
6
|
+
require 'net/http'
|
7
|
+
require 'time'
|
8
|
+
require 'uri'
|
9
|
+
|
10
|
+
class Minisky
|
11
|
+
class User
|
12
|
+
def initialize(config)
|
13
|
+
@config = config
|
14
|
+
end
|
15
|
+
|
16
|
+
def logged_in?
|
17
|
+
!!(access_token && refresh_token)
|
18
|
+
end
|
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
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
module Requests
|
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
|
41
|
+
|
42
|
+
def base_url
|
43
|
+
@base_url ||= "https://#{host}/xrpc"
|
44
|
+
end
|
45
|
+
|
46
|
+
def user
|
47
|
+
@user ||= User.new(config)
|
48
|
+
end
|
49
|
+
|
50
|
+
def get_request(method, params = nil, auth: default_auth_mode)
|
51
|
+
check_access if auto_manage_tokens && auth == true
|
52
|
+
|
53
|
+
headers = authentication_header(auth)
|
54
|
+
url = URI("#{base_url}/#{method}")
|
55
|
+
|
56
|
+
if params && !params.empty?
|
57
|
+
url.query = URI.encode_www_form(params)
|
58
|
+
end
|
59
|
+
|
60
|
+
response = Net::HTTP.get_response(url, headers)
|
61
|
+
handle_response(response)
|
62
|
+
end
|
63
|
+
|
64
|
+
def post_request(method, params = nil, auth: default_auth_mode)
|
65
|
+
check_access if auto_manage_tokens && auth == true
|
66
|
+
|
67
|
+
headers = authentication_header(auth).merge({ "Content-Type" => "application/json" })
|
68
|
+
body = params ? params.to_json : ''
|
69
|
+
|
70
|
+
response = Net::HTTP.post(URI("#{base_url}/#{method}"), body, headers)
|
71
|
+
handle_response(response)
|
72
|
+
end
|
73
|
+
|
74
|
+
def fetch_all(method, params = nil, field:,
|
75
|
+
auth: default_auth_mode, break_when: nil, max_pages: nil, progress: @default_progress)
|
76
|
+
data = []
|
77
|
+
params = {} if params.nil?
|
78
|
+
pages = 0
|
79
|
+
|
80
|
+
loop do
|
81
|
+
print(progress) if progress
|
82
|
+
|
83
|
+
response = get_request(method, params, auth: auth)
|
84
|
+
records = response[field]
|
85
|
+
cursor = response['cursor']
|
86
|
+
|
87
|
+
data.concat(records)
|
88
|
+
params[:cursor] = cursor
|
89
|
+
pages += 1
|
90
|
+
|
91
|
+
break if !cursor || records.empty? || pages == max_pages
|
92
|
+
break if break_when && records.any? { |x| break_when.call(x) }
|
93
|
+
end
|
94
|
+
|
95
|
+
data.delete_if { |x| break_when.call(x) } if break_when
|
96
|
+
data
|
97
|
+
end
|
98
|
+
|
99
|
+
def check_access
|
100
|
+
if !user.logged_in?
|
101
|
+
log_in
|
102
|
+
elsif token_expiration_date(user.access_token) < Time.now + 60
|
103
|
+
perform_token_refresh
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
def log_in
|
108
|
+
if user.id.nil? || user.pass.nil?
|
109
|
+
raise AuthError, "To log in, please provide a user id and password"
|
110
|
+
end
|
111
|
+
|
112
|
+
data = {
|
113
|
+
identifier: user.id,
|
114
|
+
password: user.pass
|
115
|
+
}
|
116
|
+
|
117
|
+
json = post_request('com.atproto.server.createSession', data, auth: false)
|
118
|
+
|
119
|
+
user.did = json['did']
|
120
|
+
user.access_token = json['accessJwt']
|
121
|
+
user.refresh_token = json['refreshJwt']
|
122
|
+
|
123
|
+
save_config
|
124
|
+
json
|
125
|
+
end
|
126
|
+
|
127
|
+
def perform_token_refresh
|
128
|
+
if user.refresh_token.nil?
|
129
|
+
raise AuthError, "Can't refresh access token - refresh token is missing"
|
130
|
+
end
|
131
|
+
|
132
|
+
json = post_request('com.atproto.server.refreshSession', auth: user.refresh_token)
|
133
|
+
|
134
|
+
user.access_token = json['accessJwt']
|
135
|
+
user.refresh_token = json['refreshJwt']
|
136
|
+
|
137
|
+
save_config
|
138
|
+
json
|
139
|
+
end
|
140
|
+
|
141
|
+
def reset_tokens
|
142
|
+
user.access_token = nil
|
143
|
+
user.refresh_token = nil
|
144
|
+
save_config
|
145
|
+
nil
|
146
|
+
end
|
147
|
+
|
148
|
+
private
|
149
|
+
|
150
|
+
def default_auth_mode
|
151
|
+
!!send_auth_headers
|
152
|
+
end
|
153
|
+
|
154
|
+
def authentication_header(auth)
|
155
|
+
if auth.is_a?(String)
|
156
|
+
{ 'Authorization' => "Bearer #{auth}" }
|
157
|
+
elsif auth
|
158
|
+
if user.access_token
|
159
|
+
{ 'Authorization' => "Bearer #{user.access_token}" }
|
160
|
+
else
|
161
|
+
raise AuthError, "Can't send auth headers, access token is missing"
|
162
|
+
end
|
163
|
+
else
|
164
|
+
{}
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
def token_expiration_date(token)
|
169
|
+
parts = token.split('.')
|
170
|
+
raise AuthError, "Invalid access token format" unless parts.length == 3
|
171
|
+
|
172
|
+
begin
|
173
|
+
payload = JSON.parse(Base64.decode64(parts[1]))
|
174
|
+
rescue JSON::ParserError
|
175
|
+
raise AuthError, "Couldn't decode payload from access token"
|
176
|
+
end
|
177
|
+
|
178
|
+
exp = payload['exp']
|
179
|
+
raise AuthError, "Invalid token expiry data" unless exp.is_a?(Numeric) && exp > 0
|
180
|
+
|
181
|
+
Time.at(exp)
|
182
|
+
end
|
183
|
+
|
184
|
+
def handle_response(response)
|
185
|
+
status = response.code.to_i
|
186
|
+
message = response.message
|
187
|
+
|
188
|
+
case response
|
189
|
+
when Net::HTTPSuccess
|
190
|
+
JSON.parse(response.body)
|
191
|
+
when Net::HTTPRedirection
|
192
|
+
raise UnexpectedRedirect.new(status, message, response['location'])
|
193
|
+
else
|
194
|
+
data = (response.content_type == 'application/json') ? JSON.parse(response.body) : response.body
|
195
|
+
|
196
|
+
error_class = if data.is_a?(Hash) && data['error'] == 'ExpiredToken'
|
197
|
+
ExpiredTokenError
|
198
|
+
elsif response.is_a?(Net::HTTPClientError)
|
199
|
+
ClientErrorResponse
|
200
|
+
elsif response.is_a?(Net::HTTPServerError)
|
201
|
+
ServerErrorResponse
|
202
|
+
else
|
203
|
+
BadResponse
|
204
|
+
end
|
205
|
+
|
206
|
+
raise error_class.new(status, message, data)
|
207
|
+
end
|
208
|
+
end
|
209
|
+
end
|
210
|
+
|
211
|
+
include Requests
|
212
|
+
end
|
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.0
|
4
|
+
version: 0.3.0
|
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-05 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,8 +21,13 @@ files:
|
|
21
21
|
- CHANGELOG.md
|
22
22
|
- LICENSE.txt
|
23
23
|
- README.md
|
24
|
+
- example/fetch_my_posts.rb
|
25
|
+
- example/post_skeet.rb
|
26
|
+
- example/science_feed.rb
|
24
27
|
- lib/minisky.rb
|
28
|
+
- lib/minisky/errors.rb
|
25
29
|
- lib/minisky/minisky.rb
|
30
|
+
- lib/minisky/requests.rb
|
26
31
|
- lib/minisky/version.rb
|
27
32
|
- sig/minisky.rbs
|
28
33
|
homepage: https://github.com/mackuba/minisky
|