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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: afde2461017bd9cc0127a64921329a07f884377d5e434dd9b81f15e42493f5b2
4
- data.tar.gz: 29c86293b904fa973cdc2c2a74e17a7f8096ac02c97377d39c9daef9a1cdc194
3
+ metadata.gz: 8f4836f5b8d8de9c5f9af0368144fe60f274e1b61176d1c919b70d3fd645a3f3
4
+ data.tar.gz: 8c87cdbb362b19651e848804aa6c551c88abc759bbdd3df6e609b62b94a7da4b
5
5
  SHA512:
6
- metadata.gz: 23f5239dddeed66c6ad1a4195d53adea8d7b35d8dbc2a0680d39351a7ca9b5aeb42ef8ccdb38d4d2cd589406e93cc90ae9b5cea9f3ad4f91d3586e3b3e7fc85f
7
- data.tar.gz: fdfce69b885db91ca2efbe19a92cdfc2e11e7fd5ac363be71e47f1e7c4186ba160f9dd2f99ae55950f3404b33a5a72014e3f14e917ceb76cbca7657cce09ddf6
6
+ metadata.gz: 84052665f10f23db1513a5135dcb9032f0fbc4c5ae55fc7bbeb15e26d85904acda3ac55170e58d8901e5549ceaaafa34aa038ec8987da9dfdded5349080f12d2
7
+ data.tar.gz: 5557b97bb51b8feb757a2d16dccfefd7dfd31655a833b379681d89cc924717e234baa19523cda89368f6610481cf11c0dd9494355539d4c7364c9913a05c2ca1
data/CHANGELOG.md CHANGED
@@ -1 +1,44 @@
1
- ## [Unreleased]
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
- TODO: Delete this and the text below, and describe your gem
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
- TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_PRIOR_TO_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
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
- Install the gem and add to the application's Gemfile by executing:
10
+ To install the Minisky gem, run the command:
12
11
 
13
- $ bundle add UPDATE_WITH_YOUR_GEM_NAME_PRIOR_TO_RELEASE_TO_RUBYGEMS_ORG
12
+ [sudo] gem install minisky
14
13
 
15
- If bundler is not being used to manage dependencies, install the gem by executing:
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
- TODO: Write usage instructions here
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
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
142
+ ## Credits
26
143
 
27
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
144
+ Copyright © 2023 Kuba Suder ([@mackuba.eu](https://bsky.app/profile/mackuba.eu)).
28
145
 
29
- ## Contributing
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 on GitHub at https://github.com/[USERNAME]/minisky.
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
@@ -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
- CONFIG_FILE = 'bluesky.yml'
8
-
9
- def initialize
10
- @config = YAML.load(File.read(CONFIG_FILE))
11
- @base_url = "https://#{@config['host']}/xrpc"
12
- end
13
-
14
- def my_id
15
- @config['ident']
16
- end
17
-
18
- def my_did
19
- @config['did']
20
- end
21
-
22
- def access_token
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 get_request(method, params = nil, auth = nil)
73
- headers = {}
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
@@ -1,5 +1,5 @@
1
1
  require_relative 'minisky'
2
2
 
3
3
  class Minisky
4
- VERSION = "0.0.1"
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.0.1
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-08-30 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,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