minisky 0.2.0 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 50342531fa0d4bf18c527038f4644ad15bcbddc7b2468d5d9c9ff7985972f0ce
4
- data.tar.gz: 0de2603028edb2a51c43e2b3e80f25dca6099f4e48ce7cdbf9be98c447d2e289
3
+ metadata.gz: 8f4836f5b8d8de9c5f9af0368144fe60f274e1b61176d1c919b70d3fd645a3f3
4
+ data.tar.gz: 8c87cdbb362b19651e848804aa6c551c88abc759bbdd3df6e609b62b94a7da4b
5
5
  SHA512:
6
- metadata.gz: 2f434becc1505afd47243f3c6f0ad397fd75e2548e606117980cec20a70215f8dbea3d4bcd8c8520dc4ad2733bfc42187b7fe20344fda5dfc7722e73e97980eb
7
- data.tar.gz: 3ce2d7f864b6aea377ca518c78d8b7d1fcbe3ff04fe36a6462b005cacc5a56d0b76271412a395727891489695766e452e5bb89bc444a0ca713a163c859a0dc7d
6
+ metadata.gz: 84052665f10f23db1513a5135dcb9032f0fbc4c5ae55fc7bbeb15e26d85904acda3ac55170e58d8901e5549ceaaafa34aa038ec8987da9dfdded5349080f12d2
7
+ data.tar.gz: 5557b97bb51b8feb757a2d16dccfefd7dfd31655a833b379681d89cc924717e234baa19523cda89368f6610481cf11c0dd9494355539d4c7364c9913a05c2ca1
data/CHANGELOG.md CHANGED
@@ -1,3 +1,16 @@
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
+
1
14
  ## [0.2.0] - 2023-09-02
2
15
 
3
16
  * more consistent handling of parameters in the main methods:
data/README.md CHANGED
@@ -13,12 +13,12 @@ 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.2'
16
+ gem 'minisky', '~> 0.3'
17
17
 
18
18
 
19
19
  ## Usage
20
20
 
21
- First, you need to create a `.yml` config file (by default, `bluesky.yml`) with the authentication data. It should look like this:
21
+ First, you need to create a `.yml` config file with the authentication data, e.g. `bluesky.yml`. It should look like this:
22
22
 
23
23
  ```yaml
24
24
  id: my.bsky.username
@@ -29,12 +29,12 @@ The `id` can be either your handle, or your DID, or the email you've used to sig
29
29
 
30
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
31
 
32
- Next, create the Minisky client instance, passing the server name (at the moment there is only one server at `bsky.social`, but there will be more once federation support goes live):
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
33
 
34
34
  ```rb
35
35
  require 'minisky'
36
36
 
37
- bsky = Minisky.new('bsky.social')
37
+ bsky = Minisky.new('bsky.social', 'bluesky.yml')
38
38
  bsky.check_access
39
39
  ```
40
40
 
@@ -43,17 +43,22 @@ bsky.check_access
43
43
  Now, you can make requests to the Bluesky API using `get_request` and `post_request`:
44
44
 
45
45
  ```rb
46
- bsky.get_request('com.atproto.repo.listRecords', {
46
+ json = bsky.get_request('com.atproto.repo.listRecords', {
47
47
  repo: bsky.user.did,
48
48
  collection: 'app.bsky.feed.like'
49
49
  })
50
50
 
51
+ json['records'].each do |r|
52
+ puts r['value']['subject']['uri']
53
+ end
54
+
51
55
  bsky.post_request('com.atproto.repo.createRecord', {
52
56
  repo: bsky.user.did,
53
57
  collection: 'app.bsky.feed.post',
54
58
  record: {
55
59
  text: "Hello world!",
56
- createdAt: Time.now.iso8601
60
+ createdAt: Time.now.iso8601,
61
+ langs: ["en"]
57
62
  }
58
63
  })
59
64
  ```
@@ -65,7 +70,7 @@ The third useful method you can use is `#fetch_all`, which loads multiple pagina
65
70
  ```rb
66
71
  time_limit = Time.now - 86400 * 30
67
72
 
68
- bsky.fetch_all('com.atproto.repo.listRecords',
73
+ posts = bsky.fetch_all('com.atproto.repo.listRecords',
69
74
  { repo: bsky.user.did, collection: 'app.bsky.feed.post' },
70
75
  field: 'records',
71
76
  max_pages: 10,
@@ -75,7 +80,7 @@ bsky.fetch_all('com.atproto.repo.listRecords',
75
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:
76
81
 
77
82
  ```rb
78
- bsky.fetch_all('com.atproto.repo.listRecords',
83
+ likes = bsky.fetch_all('com.atproto.repo.listRecords',
79
84
  { repo: bsky.user.did, collection: 'app.bsky.feed.like' },
80
85
  field: 'records',
81
86
  progress: '.')
@@ -87,19 +92,16 @@ This will output a line like this:
87
92
  .................
88
93
  ```
89
94
 
95
+ You can find more examples in the [example](https://github.com/mackuba/minisky/tree/master/example) directory.
96
+
97
+
90
98
  ## Customization
91
99
 
92
100
  The `Minisky` client currently supports one configuration option:
93
101
 
94
102
  - `default_progress` - a progress character to automatically use for `#fetch_all` calls (default: `nil`)
95
103
 
96
- When creating the `Minisky` instance, you can pass a name of the YAML config file to use instead of the default:
97
-
98
- ```rb
99
- bsky = Minisky.new('bsky.social', 'config/access.yml')
100
- ```
101
-
102
- Alternatively, instead of using the `Minisky` class, you can 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:
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:
103
105
 
104
106
  ```rb
105
107
  class BlueskyClient
@@ -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
@@ -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 = DEFAULT_CONFIG_FILE)
6
+ def initialize(host, config_file)
9
7
  @host = host
10
8
  @config_file = config_file
11
- @config = YAML.load(File.read(@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
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
 
@@ -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 'open-uri'
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
- @config[name.to_s]
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: true)
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,22 @@ class Minisky
39
57
  url.query = URI.encode_www_form(params)
40
58
  end
41
59
 
42
- JSON.parse(URI.open(url, headers).read)
60
+ response = Net::HTTP.get_response(url, headers)
61
+ handle_response(response)
43
62
  end
44
63
 
45
- def post_request(method, params = nil, auth: true)
64
+ def post_request(method, params = nil, auth: default_auth_mode)
65
+ check_access if auto_manage_tokens && auth == true
66
+
46
67
  headers = authentication_header(auth).merge({ "Content-Type" => "application/json" })
47
68
  body = params ? params.to_json : ''
48
69
 
49
70
  response = Net::HTTP.post(URI("#{base_url}/#{method}"), body, headers)
50
- raise "Invalid response: #{response.code} #{response.body}" if response.code.to_i / 100 != 2
51
-
52
- JSON.parse(response.body)
71
+ handle_response(response)
53
72
  end
54
73
 
55
74
  def fetch_all(method, params = nil, field:,
56
- auth: true, break_when: nil, max_pages: nil, progress: @default_progress)
75
+ auth: default_auth_mode, break_when: nil, max_pages: nil, progress: @default_progress)
57
76
  data = []
58
77
  params = {} if params.nil?
59
78
  pages = 0
@@ -80,16 +99,16 @@ class Minisky
80
99
  def check_access
81
100
  if !user.logged_in?
82
101
  log_in
83
- else
84
- begin
85
- get_request('com.atproto.server.getSession')
86
- rescue OpenURI::HTTPError
87
- perform_token_refresh
88
- end
102
+ elsif token_expiration_date(user.access_token) < Time.now + 60
103
+ perform_token_refresh
89
104
  end
90
105
  end
91
106
 
92
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
+
93
112
  data = {
94
113
  identifier: user.id,
95
114
  password: user.pass
@@ -97,35 +116,96 @@ class Minisky
97
116
 
98
117
  json = post_request('com.atproto.server.createSession', data, auth: false)
99
118
 
100
- config['did'] = json['did']
101
- config['access_token'] = json['accessJwt']
102
- config['refresh_token'] = json['refreshJwt']
119
+ user.did = json['did']
120
+ user.access_token = json['accessJwt']
121
+ user.refresh_token = json['refreshJwt']
103
122
 
104
123
  save_config
105
124
  json
106
125
  end
107
126
 
108
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
+
109
132
  json = post_request('com.atproto.server.refreshSession', auth: user.refresh_token)
110
133
 
111
- config['access_token'] = json['accessJwt']
112
- config['refresh_token'] = json['refreshJwt']
134
+ user.access_token = json['accessJwt']
135
+ user.refresh_token = json['refreshJwt']
113
136
 
114
137
  save_config
115
138
  json
116
139
  end
117
140
 
141
+ def reset_tokens
142
+ user.access_token = nil
143
+ user.refresh_token = nil
144
+ save_config
145
+ nil
146
+ end
147
+
118
148
  private
119
149
 
150
+ def default_auth_mode
151
+ !!send_auth_headers
152
+ end
153
+
120
154
  def authentication_header(auth)
121
155
  if auth.is_a?(String)
122
156
  { 'Authorization' => "Bearer #{auth}" }
123
157
  elsif auth
124
- { 'Authorization' => "Bearer #{user.access_token}" }
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
125
163
  else
126
164
  {}
127
165
  end
128
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
129
209
  end
130
210
 
131
211
  include Requests
@@ -1,5 +1,5 @@
1
1
  require_relative 'minisky'
2
2
 
3
3
  class Minisky
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
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.2.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-09-02 00:00:00.000000000 Z
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,7 +21,11 @@ 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
26
30
  - lib/minisky/requests.rb
27
31
  - lib/minisky/version.rb