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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 50342531fa0d4bf18c527038f4644ad15bcbddc7b2468d5d9c9ff7985972f0ce
4
- data.tar.gz: 0de2603028edb2a51c43e2b3e80f25dca6099f4e48ce7cdbf9be98c447d2e289
3
+ metadata.gz: 7a753ae5a6f7d2dd3089ee9b6a637e578051261c615fedd28a44f63ac1774b4d
4
+ data.tar.gz: 479240c065cb3ba705aa4a9d07efa8c4904ec2aca2e6ed0e847d391ab8cd62fe
5
5
  SHA512:
6
- metadata.gz: 2f434becc1505afd47243f3c6f0ad397fd75e2548e606117980cec20a70215f8dbea3d4bcd8c8520dc4ad2733bfc42187b7fe20344fda5dfc7722e73e97980eb
7
- data.tar.gz: 3ce2d7f864b6aea377ca518c78d8b7d1fcbe3ff04fe36a6462b005cacc5a56d0b76271412a395727891489695766e452e5bb89bc444a0ca713a163c859a0dc7d
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.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
+ 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 (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
- bsky = Minisky.new('bsky.social')
38
- bsky.check_access
63
+ bsky = Minisky.new('bsky.social', 'bluesky.yml')
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
- 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
- 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.
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 one configuration option:
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
- When creating the `Minisky` instance, you can pass a name of the YAML config file to use instead of the default:
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
- ```rb
99
- bsky = Minisky.new('bsky.social', 'config/access.yml')
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
- 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:
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
@@ -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,24 @@ 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
+ 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: true)
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
- raise "Invalid response: #{response.code} #{response.body}" if response.code.to_i / 100 != 2
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: true, break_when: nil, max_pages: nil, progress: @default_progress)
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
- else
84
- begin
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
- config['did'] = json['did']
101
- config['access_token'] = json['accessJwt']
102
- config['refresh_token'] = json['refreshJwt']
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
- config['access_token'] = json['accessJwt']
112
- config['refresh_token'] = json['refreshJwt']
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
- { 'Authorization' => "Bearer #{user.access_token}" }
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
@@ -1,5 +1,5 @@
1
1
  require_relative 'minisky'
2
2
 
3
3
  class Minisky
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.1"
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.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-09-02 00:00:00.000000000 Z
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