minisky 0.3.0 → 0.4.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: 8f4836f5b8d8de9c5f9af0368144fe60f274e1b61176d1c919b70d3fd645a3f3
4
- data.tar.gz: 8c87cdbb362b19651e848804aa6c551c88abc759bbdd3df6e609b62b94a7da4b
3
+ metadata.gz: b22e3b707a428f0381e5c194ce6b5bd4118f6b6dfb7f2d249140c8025e3b1dd7
4
+ data.tar.gz: 2a6370ea6cb538c680252836295c411ff4c6df93b8c5aab9d8b73b843753febb
5
5
  SHA512:
6
- metadata.gz: 84052665f10f23db1513a5135dcb9032f0fbc4c5ae55fc7bbeb15e26d85904acda3ac55170e58d8901e5549ceaaafa34aa038ec8987da9dfdded5349080f12d2
7
- data.tar.gz: 5557b97bb51b8feb757a2d16dccfefd7dfd31655a833b379681d89cc924717e234baa19523cda89368f6610481cf11c0dd9494355539d4c7364c9913a05c2ca1
6
+ metadata.gz: 3f86def9480e86d2989a52f143eacee8e5b78fede45bd6ba49ea7ad54f1da88a55555421d3fdfdddad025bed21a708c830e5e9b2c351ba57e016234e200b9f74
7
+ data.tar.gz: f8f7faa8d7e5df7807d6e9bb7ee1b87d1d5ab5503c0193fd031190be6e6346004618c66efdba96970fc93117d4f587c53ff5f17585a2ffa3ddafb503abc5a3cc
data/CHANGELOG.md CHANGED
@@ -1,3 +1,16 @@
1
+ ## [0.4.0] - 2024-03-31 🐣
2
+
3
+ * allow passing non-JSON body to requests (e.g. when uploading blobs)
4
+ * allow passing custom headers to requests, including overriding `Content-Type`
5
+ * fixed error when the response is success but not JSON (e.g. an empty body like in deleteRecord)
6
+ * allow passing options to the client in the initializer
7
+ * aliased `default_progress` setting as `progress`
8
+ * 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
+
10
+ ## [0.3.1] - 2023-10-10
11
+
12
+ * fixed Minisky not working on Ruby 2.x
13
+
1
14
  ## [0.3.0] - 2023-10-05
2
15
 
3
16
  * authentication improvements & changes:
data/README.md CHANGED
@@ -18,6 +18,32 @@ Or alternatively, add it to the `Gemfile` file for Bundler:
18
18
 
19
19
  ## Usage
20
20
 
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
+
21
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
@@ -29,18 +55,20 @@ 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 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):
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
63
  bsky = Minisky.new('bsky.social', 'bluesky.yml')
38
- bsky.check_access
39
64
  ```
40
65
 
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.
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
- Now, you can make requests to the Bluesky API using `get_request` and `post_request`:
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
74
  json = bsky.get_request('com.atproto.repo.listRecords', {
@@ -63,7 +91,7 @@ bsky.post_request('com.atproto.repo.createRecord', {
63
91
  })
64
92
  ```
65
93
 
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.
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.
67
95
 
68
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:
69
97
 
@@ -97,9 +125,18 @@ You can find more examples in the [example](https://github.com/mackuba/minisky/t
97
125
 
98
126
  ## Customization
99
127
 
100
- The `Minisky` client currently supports one configuration option:
128
+ The `Minisky` client currently supports such configuration options:
101
129
 
102
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)
133
+
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.
135
+
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
103
140
 
104
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:
105
142
 
@@ -128,7 +165,6 @@ It can then be used just like the `Minisky` class:
128
165
 
129
166
  ```rb
130
167
  bsky = BlueskyClient.new('config/access.json')
131
- bsky.check_access
132
168
  bsky.get_request(...)
133
169
  ```
134
170
 
@@ -0,0 +1,76 @@
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
@@ -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
+ }
@@ -21,13 +21,15 @@ text = ARGV[0]
21
21
  # create a client instance
22
22
  bsky = Minisky.new('bsky.social', File.join(__dir__, 'bluesky.yml'))
23
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
+
24
26
  bsky.post_request('com.atproto.repo.createRecord', {
25
27
  repo: bsky.user.did,
26
28
  collection: 'app.bsky.feed.post',
27
29
  record: {
28
30
  text: text,
29
- langs: ["en"],
30
- createdAt: Time.now.iso8601
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
31
33
  }
32
34
  })
33
35
 
@@ -4,6 +4,7 @@
4
4
  # terminal. Does not require any authentication.
5
5
  #
6
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.)
7
8
 
8
9
  # load minisky from a local folder - you normally won't need this
9
10
  $LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
@@ -3,7 +3,7 @@ require 'yaml'
3
3
  class Minisky
4
4
  attr_reader :host, :config
5
5
 
6
- def initialize(host, config_file)
6
+ def initialize(host, config_file, options = {})
7
7
  @host = host
8
8
  @config_file = config_file
9
9
 
@@ -18,6 +18,12 @@ class Minisky
18
18
  @send_auth_headers = false
19
19
  @auto_manage_tokens = false
20
20
  end
21
+
22
+ if options
23
+ options.each do |k, v|
24
+ self.send("#{k}=", v)
25
+ end
26
+ end
21
27
  end
22
28
 
23
29
  def save_config
@@ -39,6 +39,9 @@ class Minisky
39
39
  instance_variable_defined?('@auto_manage_tokens') ? @auto_manage_tokens : true
40
40
  end
41
41
 
42
+ alias progress default_progress
43
+ alias progress= default_progress=
44
+
42
45
  def base_url
43
46
  @base_url ||= "https://#{host}/xrpc"
44
47
  end
@@ -47,32 +50,40 @@ class Minisky
47
50
  @user ||= User.new(config)
48
51
  end
49
52
 
50
- def get_request(method, params = nil, auth: default_auth_mode)
53
+ def get_request(method, params = nil, auth: default_auth_mode, headers: nil)
51
54
  check_access if auto_manage_tokens && auth == true
52
55
 
53
- headers = authentication_header(auth)
56
+ headers = authentication_header(auth).merge(headers || {})
54
57
  url = URI("#{base_url}/#{method}")
55
58
 
56
59
  if params && !params.empty?
57
60
  url.query = URI.encode_www_form(params)
58
61
  end
59
62
 
60
- response = Net::HTTP.get_response(url, headers)
63
+ request = Net::HTTP::Get.new(url, headers)
64
+
65
+ response = make_request(request)
61
66
  handle_response(response)
62
67
  end
63
68
 
64
- def post_request(method, params = nil, auth: default_auth_mode)
69
+ def post_request(method, params = nil, auth: default_auth_mode, headers: nil)
65
70
  check_access if auto_manage_tokens && auth == true
66
71
 
67
- headers = authentication_header(auth).merge({ "Content-Type" => "application/json" })
68
- body = params ? params.to_json : ''
72
+ headers = authentication_header(auth).merge(headers || {})
73
+ headers["Content-Type"] = "application/json" unless headers.keys.any? { |k| k.to_s.downcase == 'content-type' }
74
+
75
+ body = if params.is_a?(String) || params.nil?
76
+ params.to_s
77
+ else
78
+ params.to_json
79
+ end
69
80
 
70
81
  response = Net::HTTP.post(URI("#{base_url}/#{method}"), body, headers)
71
82
  handle_response(response)
72
83
  end
73
84
 
74
85
  def fetch_all(method, params = nil, field:,
75
- auth: default_auth_mode, break_when: nil, max_pages: nil, progress: @default_progress)
86
+ auth: default_auth_mode, break_when: nil, max_pages: nil, headers: nil, progress: @default_progress)
76
87
  data = []
77
88
  params = {} if params.nil?
78
89
  pages = 0
@@ -80,7 +91,7 @@ class Minisky
80
91
  loop do
81
92
  print(progress) if progress
82
93
 
83
- response = get_request(method, params, auth: auth)
94
+ response = get_request(method, params, auth: auth, headers: headers)
84
95
  records = response[field]
85
96
  cursor = response['cursor']
86
97
 
@@ -145,8 +156,32 @@ class Minisky
145
156
  nil
146
157
  end
147
158
 
159
+ if RUBY_VERSION.to_i == 2
160
+ alias_method :do_get_request, :get_request
161
+ alias_method :do_post_request, :post_request
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
173
+ end
174
+
175
+
148
176
  private
149
177
 
178
+ def make_request(request)
179
+ # 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: true) do |http|
181
+ http.request(request)
182
+ end
183
+ end
184
+
150
185
  def default_auth_mode
151
186
  !!send_auth_headers
152
187
  end
@@ -184,16 +219,15 @@ class Minisky
184
219
  def handle_response(response)
185
220
  status = response.code.to_i
186
221
  message = response.message
222
+ response_body = (response.content_type == 'application/json') ? JSON.parse(response.body) : response.body
187
223
 
188
224
  case response
189
225
  when Net::HTTPSuccess
190
- JSON.parse(response.body)
226
+ response_body
191
227
  when Net::HTTPRedirection
192
228
  raise UnexpectedRedirect.new(status, message, response['location'])
193
229
  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'
230
+ error_class = if response_body.is_a?(Hash) && response_body['error'] == 'ExpiredToken'
197
231
  ExpiredTokenError
198
232
  elsif response.is_a?(Net::HTTPClientError)
199
233
  ClientErrorResponse
@@ -203,7 +237,7 @@ class Minisky
203
237
  BadResponse
204
238
  end
205
239
 
206
- raise error_class.new(status, message, data)
240
+ raise error_class.new(status, message, response_body)
207
241
  end
208
242
  end
209
243
  end
@@ -1,5 +1,5 @@
1
1
  require_relative 'minisky'
2
2
 
3
3
  class Minisky
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
metadata CHANGED
@@ -1,15 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: minisky
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.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-10-05 00:00:00.000000000 Z
12
- dependencies: []
11
+ date: 2024-03-31 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: base64
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.1'
13
27
  description: A very simple client class that lets you log in to the Bluesky API and
14
28
  make any requests there.
15
29
  email:
@@ -21,7 +35,9 @@ files:
21
35
  - CHANGELOG.md
22
36
  - LICENSE.txt
23
37
  - README.md
38
+ - example/ask_password.rb
24
39
  - example/fetch_my_posts.rb
40
+ - example/fetch_profile.rb
25
41
  - example/post_skeet.rb
26
42
  - example/science_feed.rb
27
43
  - lib/minisky.rb