twitchrb 1.10.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +2 -2
  3. data/.rubocop.yml +1 -1
  4. data/CHANGELOG.md +104 -1
  5. data/Gemfile.lock +5 -5
  6. data/README.md +360 -45
  7. data/lib/twitch/client.rb +109 -20
  8. data/lib/twitch/collection.rb +58 -11
  9. data/lib/twitch/error_generator.rb +6 -4
  10. data/lib/twitch/eventsub_webhook.rb +140 -0
  11. data/lib/twitch/oauth.rb +50 -15
  12. data/lib/twitch/object.rb +28 -0
  13. data/lib/twitch/objects/ad_schedule.rb +4 -0
  14. data/lib/twitch/objects/analytics_report.rb +4 -0
  15. data/lib/twitch/objects/bits_leaderboard_entry.rb +4 -0
  16. data/lib/twitch/objects/charity_donation.rb +4 -0
  17. data/lib/twitch/objects/chat_settings.rb +4 -0
  18. data/lib/twitch/objects/cheermote.rb +4 -0
  19. data/lib/twitch/objects/content_classification_label.rb +4 -0
  20. data/lib/twitch/objects/drops_entitlement.rb +4 -0
  21. data/lib/twitch/objects/drops_entitlement_update.rb +4 -0
  22. data/lib/twitch/objects/extension.rb +4 -0
  23. data/lib/twitch/objects/extension_bits_product.rb +4 -0
  24. data/lib/twitch/objects/extension_configuration_segment.rb +4 -0
  25. data/lib/twitch/objects/extension_live_channel.rb +4 -0
  26. data/lib/twitch/objects/extension_secret.rb +4 -0
  27. data/lib/twitch/objects/extension_transaction.rb +4 -0
  28. data/lib/twitch/objects/guest_star_invite.rb +4 -0
  29. data/lib/twitch/objects/guest_star_session.rb +4 -0
  30. data/lib/twitch/objects/guest_star_settings.rb +4 -0
  31. data/lib/twitch/objects/shield_mode_status.rb +4 -0
  32. data/lib/twitch/objects/{tag.rb → team.rb} +1 -1
  33. data/lib/twitch/objects/user_active_extensions.rb +4 -0
  34. data/lib/twitch/objects/user_extension.rb +4 -0
  35. data/lib/twitch/resource.rb +49 -9
  36. data/lib/twitch/resources/ads.rb +18 -0
  37. data/lib/twitch/resources/analytics.rb +19 -0
  38. data/lib/twitch/resources/announcements.rb +1 -1
  39. data/lib/twitch/resources/automod.rb +8 -8
  40. data/lib/twitch/resources/badges.rb +3 -3
  41. data/lib/twitch/resources/banned_users.rb +4 -4
  42. data/lib/twitch/resources/bits.rb +16 -0
  43. data/lib/twitch/resources/blocked_terms.rb +5 -5
  44. data/lib/twitch/resources/channels.rb +7 -7
  45. data/lib/twitch/resources/charity_campaigns.rb +9 -1
  46. data/lib/twitch/resources/chat_messages.rb +1 -1
  47. data/lib/twitch/resources/chat_settings.rb +20 -0
  48. data/lib/twitch/resources/chatters.rb +1 -1
  49. data/lib/twitch/resources/clips.rb +3 -9
  50. data/lib/twitch/resources/content_classification_labels.rb +8 -0
  51. data/lib/twitch/resources/custom_power_ups.rb +1 -1
  52. data/lib/twitch/resources/custom_reward_redemptions.rb +3 -4
  53. data/lib/twitch/resources/custom_rewards.rb +5 -5
  54. data/lib/twitch/resources/drops_entitlements.rb +17 -0
  55. data/lib/twitch/resources/emotes.rb +5 -5
  56. data/lib/twitch/resources/eventsub_conduits.rb +14 -4
  57. data/lib/twitch/resources/eventsub_subscriptions.rb +9 -13
  58. data/lib/twitch/resources/extensions.rb +99 -0
  59. data/lib/twitch/resources/games.rb +5 -7
  60. data/lib/twitch/resources/goals.rb +1 -1
  61. data/lib/twitch/resources/guest_star.rb +102 -0
  62. data/lib/twitch/resources/moderators.rb +4 -4
  63. data/lib/twitch/resources/pinned_chat_messages.rb +4 -11
  64. data/lib/twitch/resources/polls.rb +1 -1
  65. data/lib/twitch/resources/predictions.rb +1 -1
  66. data/lib/twitch/resources/raids.rb +3 -4
  67. data/lib/twitch/resources/search.rb +2 -2
  68. data/lib/twitch/resources/shield_mode.rb +18 -0
  69. data/lib/twitch/resources/shoutouts.rb +1 -1
  70. data/lib/twitch/resources/stream_markers.rb +1 -1
  71. data/lib/twitch/resources/stream_schedule.rb +4 -5
  72. data/lib/twitch/resources/streams.rb +2 -2
  73. data/lib/twitch/resources/subscriptions.rb +13 -2
  74. data/lib/twitch/resources/suspicious_users.rb +6 -2
  75. data/lib/twitch/resources/teams.rb +20 -0
  76. data/lib/twitch/resources/unban_requests.rb +2 -2
  77. data/lib/twitch/resources/users.rb +37 -43
  78. data/lib/twitch/resources/videos.rb +3 -3
  79. data/lib/twitch/resources/vips.rb +3 -3
  80. data/lib/twitch/resources/warnings.rb +2 -2
  81. data/lib/twitch/resources/whispers.rb +1 -1
  82. data/lib/twitch/version.rb +1 -1
  83. data/lib/twitch.rb +33 -9
  84. data/twitchrb.gemspec +5 -5
  85. metadata +42 -18
  86. data/lib/twitch/objects/banned_event.rb +0 -4
  87. data/lib/twitch/objects/followed_user.rb +0 -4
  88. data/lib/twitch/objects/hype_train_event.rb +0 -4
  89. data/lib/twitch/objects/moderator_event.rb +0 -4
  90. data/lib/twitch/resources/banned_events.rb +0 -8
  91. data/lib/twitch/resources/hype_train_events.rb +0 -10
  92. data/lib/twitch/resources/moderator_events.rb +0 -9
  93. data/lib/twitch/resources/tags.rb +0 -18
data/lib/twitch/client.rb CHANGED
@@ -1,24 +1,42 @@
1
1
  module Twitch
2
2
  class Client
3
3
  BASE_URL = "https://api.twitch.tv/helix"
4
+ DEFAULT_TIMEOUT = 30
5
+ DEFAULT_OPEN_TIMEOUT = 10
4
6
 
5
7
  attr_reader :client_id, :access_token, :adapter, :rate_limiter
6
8
  attr_reader :rate_limit_threshold, :auto_retry_rate_limit, :logger
9
+ attr_reader :timeout, :open_timeout
10
+ attr_reader :refresh_token, :client_secret, :on_token_refresh
7
11
 
12
+ # Pass refresh_token to refresh expired user access tokens automatically. client_secret is
13
+ # needed unless your app is a public client. on_token_refresh is called with the new token
14
+ # (access_token, refresh_token, expires_in, scope) so you can store it.
8
15
  def initialize(
9
16
  client_id:,
10
17
  access_token:,
18
+ refresh_token: nil,
19
+ client_secret: nil,
20
+ on_token_refresh: nil,
11
21
  adapter: Faraday.default_adapter,
12
22
  rate_limit_threshold: 10,
13
23
  auto_retry_rate_limit: true,
14
- logger: nil
24
+ logger: nil,
25
+ timeout: DEFAULT_TIMEOUT,
26
+ open_timeout: DEFAULT_OPEN_TIMEOUT
15
27
  )
16
28
  @client_id = client_id
17
29
  @access_token = access_token
30
+ @refresh_token = refresh_token
31
+ @client_secret = client_secret
32
+ @on_token_refresh = on_token_refresh
33
+ @token_refresh_mutex = Mutex.new
18
34
  @adapter = adapter
19
35
  @rate_limit_threshold = rate_limit_threshold
20
36
  @auto_retry_rate_limit = auto_retry_rate_limit
21
37
  @logger = logger
38
+ @timeout = timeout
39
+ @open_timeout = open_timeout
22
40
  @rate_limiter = RateLimiter.new(logger: logger)
23
41
  end
24
42
 
@@ -58,10 +76,6 @@ module Twitch
58
76
  EventsubConduitsResource.new(self)
59
77
  end
60
78
 
61
- def banned_events
62
- BannedEventsResource.new(self)
63
- end
64
-
65
79
  def banned_users
66
80
  BannedUsersResource.new(self)
67
81
  end
@@ -70,10 +84,6 @@ module Twitch
70
84
  ModeratorsResource.new(self)
71
85
  end
72
86
 
73
- def moderator_events
74
- ModeratorEventsResource.new(self)
75
- end
76
-
77
87
  def polls
78
88
  PollsResource.new(self)
79
89
  end
@@ -102,10 +112,6 @@ module Twitch
102
112
  SubscriptionsResource.new(self)
103
113
  end
104
114
 
105
- def tags
106
- TagsResource.new(self)
107
- end
108
-
109
115
  def custom_rewards
110
116
  CustomRewardsResource.new(self)
111
117
  end
@@ -122,10 +128,6 @@ module Twitch
122
128
  GoalsResource.new(self)
123
129
  end
124
130
 
125
- def hype_train_events
126
- HypeTrainEventsResource.new(self)
127
- end
128
-
129
131
  def hype_train_status
130
132
  HypeTrainStatusResource.new(self)
131
133
  end
@@ -190,21 +192,108 @@ module Twitch
190
192
  SuspiciousUsersResource.new(self)
191
193
  end
192
194
 
195
+ def ads
196
+ AdsResource.new(self)
197
+ end
198
+
199
+ def analytics
200
+ AnalyticsResource.new(self)
201
+ end
202
+
203
+ def bits
204
+ BitsResource.new(self)
205
+ end
206
+
207
+ def chat_settings
208
+ ChatSettingsResource.new(self)
209
+ end
210
+
211
+ def content_classification_labels
212
+ ContentClassificationLabelsResource.new(self)
213
+ end
214
+
215
+ def drops_entitlements
216
+ DropsEntitlementsResource.new(self)
217
+ end
218
+
219
+ def extensions
220
+ ExtensionsResource.new(self)
221
+ end
222
+
223
+ def guest_star
224
+ GuestStarResource.new(self)
225
+ end
226
+
227
+ def shield_mode
228
+ ShieldModeResource.new(self)
229
+ end
230
+
231
+ def teams
232
+ TeamsResource.new(self)
233
+ end
234
+
235
+ # Refreshes the access token using the refresh token, and returns the new token
236
+ def refresh_access_token!
237
+ raise Error, "A refresh_token is required to refresh the access token" unless refresh_token
238
+
239
+ @token_refresh_mutex.synchronize { perform_token_refresh }
240
+ end
241
+
242
+ # Called after a 401. Refreshes the token if the response means it has expired, unless
243
+ # another thread already refreshed it. Returns true if the request should be retried.
244
+ def refresh_after_unauthorized(response, failed_access_token)
245
+ return false unless refresh_token && token_expired_response?(response)
246
+
247
+ @token_refresh_mutex.synchronize do
248
+ perform_token_refresh if access_token == failed_access_token
249
+ end
250
+
251
+ true
252
+ end
253
+
193
254
  def connection
194
255
  @connection ||= Faraday.new(BASE_URL) do |conn|
195
- conn.request :authorization, :Bearer, access_token
256
+ # A lambda, so requests pick up a refreshed token
257
+ conn.request :authorization, :Bearer, -> { access_token }
196
258
 
197
259
  conn.headers = {
198
- "User-Agent" => "twitchrb/v#{VERSION} (github.com/deanpcmad/twitchrb)",
260
+ "User-Agent" => "twitchrb/v#{VERSION} (github.com/d34ndev/twitchrb)",
199
261
  "Client-ID": client_id
200
262
  }
201
263
 
264
+ # Helix expects repeated keys for multiple values (id=1&id=2), not id[]=1&id[]=2
265
+ conn.options.params_encoder = Faraday::FlatParamsEncoder
266
+ conn.options.timeout = timeout
267
+ conn.options.open_timeout = open_timeout
268
+
202
269
  conn.request :json
203
270
 
204
271
  conn.response :json, content_type: "application/json"
205
272
 
206
- conn.adapter adapter, @stubs
273
+ conn.adapter adapter
207
274
  end
208
275
  end
276
+
277
+ private
278
+
279
+ def oauth
280
+ @oauth ||= OAuth.new(client_id: client_id, client_secret: client_secret, timeout: timeout, open_timeout: open_timeout)
281
+ end
282
+
283
+ def perform_token_refresh
284
+ token = oauth.refresh(refresh_token: refresh_token)
285
+
286
+ @access_token = token.access_token
287
+ @refresh_token = token.refresh_token if token.refresh_token
288
+ on_token_refresh&.call(token)
289
+
290
+ token
291
+ end
292
+
293
+ # Helix also returns 401 for a token missing a required scope, which a refresh won't fix
294
+ def token_expired_response?(response)
295
+ message = response.body.is_a?(Hash) ? response.body["message"].to_s : ""
296
+ !message.match?(/missing scope/i)
297
+ end
209
298
  end
210
299
  end
@@ -2,34 +2,81 @@ module Twitch
2
2
  class Collection
3
3
  include Enumerable
4
4
 
5
- attr_reader :data, :total, :cursor
5
+ attr_reader :data, :total, :cursor, :errors
6
6
 
7
- def self.from_response(response, type:)
7
+ def self.from_response(response, type:, next_page: nil)
8
8
  body = response.body
9
+ data = body["data"] || []
9
10
 
10
11
  new(
11
- data: body["data"].map { |attrs| type.new(attrs) },
12
- total: body["data"].count,
13
- cursor: body.dig("pagination", "cursor")
12
+ data: data.map { |attrs| type.new(attrs) },
13
+ total: body["total"] || data.count,
14
+ cursor: cursor_from(body["pagination"]),
15
+ next_page: next_page,
16
+ errors: (body["errors"] || []).map { |attrs| Object.new(attrs) }
14
17
  )
15
18
  end
16
19
 
17
- def initialize(data:, total:, cursor:)
20
+ # Most endpoints return pagination as { "cursor": "..." }, but a few
21
+ # (e.g. Get Extension Live Channels) return the cursor as a bare string
22
+ def self.cursor_from(pagination)
23
+ pagination.is_a?(Hash) ? pagination["cursor"] : pagination
24
+ end
25
+
26
+ # next_page is a callable that takes a cursor and returns the following Collection.
27
+ # errors lists items that failed, for endpoints that report partial failures (e.g. Update Conduit Shards).
28
+ def initialize(data:, total:, cursor:, next_page: nil, errors: [])
18
29
  @data = data
19
30
  @total = total
20
- @cursor = cursor.nil? ? nil : cursor
31
+ @cursor = cursor
32
+ @next_page = next_page
33
+ @errors = errors
21
34
  end
22
35
 
23
36
  def each(&block)
24
37
  data.each(&block)
25
38
  end
26
39
 
27
- def first
28
- data.first
40
+ def first(...)
41
+ data.first(...)
42
+ end
43
+
44
+ def last(...)
45
+ data.last(...)
46
+ end
47
+
48
+ # Twitch can return a cursor alongside an empty last page, so an empty page has no next page
49
+ def next_page?
50
+ !@next_page.nil? && !cursor.nil? && !cursor.empty? && data.any?
51
+ end
52
+
53
+ # Fetches the following page, or returns nil on the last page
54
+ def next_page
55
+ @next_page.call(cursor) if next_page?
56
+ end
57
+
58
+ # Yields this page and each following page, fetching them as needed.
59
+ # Returns an Enumerator without a block.
60
+ def each_page
61
+ return enum_for(:each_page) unless block_given?
62
+
63
+ page = self
64
+ seen_cursors = []
65
+
66
+ while page
67
+ yield page
68
+ break if page.cursor.nil? || seen_cursors.include?(page.cursor)
69
+
70
+ seen_cursors << page.cursor
71
+ page = page.next_page
72
+ end
29
73
  end
30
74
 
31
- def last
32
- data.last
75
+ # Lazily iterates over every item across all pages, fetching pages only as needed.
76
+ # client.clips.list(broadcaster_id: 123).auto_paginate.first(250)
77
+ # client.channels.followers(broadcaster_id: 123, first: 100).auto_paginate.to_a
78
+ def auto_paginate
79
+ each_page.lazy.flat_map(&:data)
33
80
  end
34
81
  end
35
82
  end
@@ -1,5 +1,6 @@
1
1
  module Twitch
2
- class ErrorGenerator < StandardError
2
+ class ErrorGenerator < Error
3
+ attr_reader :response_body
3
4
  attr_reader :http_status_code
4
5
  attr_reader :twitch_error_code
5
6
  attr_reader :twitch_error_message
@@ -40,10 +41,11 @@ module Twitch
40
41
  end
41
42
 
42
43
  def build_message
43
- if twitch_error_code.nil?
44
- return "Error #{@http_status_code}: #{error_message}"
44
+ message = "Error #{@http_status_code}: #{error_message}"
45
+ if twitch_error_message && twitch_error_message != error_message
46
+ message += " '#{twitch_error_message}'"
45
47
  end
46
- "Error #{@http_status_code}: #{error_message} '#{twitch_error_message}'"
48
+ message
47
49
  end
48
50
  end
49
51
 
@@ -0,0 +1,140 @@
1
+ require "openssl"
2
+ require "time"
3
+
4
+ module Twitch
5
+ # Verifies and parses an EventSub webhook request from Twitch.
6
+ # See https://dev.twitch.tv/docs/eventsub/handling-webhook-events/
7
+ #
8
+ # webhook = Twitch::EventsubWebhook.new(secret: ENV["TWITCH_EVENTSUB_SECRET"], headers: request.headers, body: request.raw_post)
9
+ # return head :forbidden unless webhook.valid?
10
+ #
11
+ # body must be the raw request body, as the signature covers the exact bytes Twitch sent.
12
+ class EventsubWebhook
13
+ MESSAGE_ID_HEADER = "Twitch-Eventsub-Message-Id".freeze
14
+ MESSAGE_TIMESTAMP_HEADER = "Twitch-Eventsub-Message-Timestamp".freeze
15
+ MESSAGE_SIGNATURE_HEADER = "Twitch-Eventsub-Message-Signature".freeze
16
+ MESSAGE_TYPE_HEADER = "Twitch-Eventsub-Message-Type".freeze
17
+ MESSAGE_RETRY_HEADER = "Twitch-Eventsub-Message-Retry".freeze
18
+ SUBSCRIPTION_TYPE_HEADER = "Twitch-Eventsub-Subscription-Type".freeze
19
+ SUBSCRIPTION_VERSION_HEADER = "Twitch-Eventsub-Subscription-Version".freeze
20
+
21
+ # Twitch recommends rejecting messages older than 10 minutes to guard against replay attacks
22
+ DEFAULT_MAX_AGE = 600
23
+
24
+ attr_reader :body
25
+
26
+ # Returns true if the request's signature is valid and it isn't older than max_age seconds
27
+ def self.verify(secret:, headers:, body:, max_age: DEFAULT_MAX_AGE)
28
+ new(secret: secret, headers: headers, body: body, max_age: max_age).valid?
29
+ end
30
+
31
+ # headers can be a Hash (any header name casing), a Rack env, or Rails' request.headers
32
+ def initialize(secret:, headers:, body:, max_age: DEFAULT_MAX_AGE)
33
+ raise ArgumentError, "body must be the raw request body string, not parsed JSON" unless body.is_a?(String)
34
+
35
+ @secret = secret
36
+ @headers = headers
37
+ @body = body
38
+ @max_age = max_age
39
+ end
40
+
41
+ def valid?
42
+ signature_valid? && !expired?
43
+ end
44
+
45
+ def signature_valid?
46
+ return false if @secret.nil? || @secret.empty?
47
+ return false if message_id.nil? || timestamp_header.nil? || signature.nil?
48
+
49
+ expected = "sha256=#{OpenSSL::HMAC.hexdigest("SHA256", @secret, message_id + timestamp_header + body)}"
50
+ OpenSSL.secure_compare(expected, signature)
51
+ end
52
+
53
+ # True if the message is older than max_age seconds, or has no readable timestamp.
54
+ # Also rejects timestamps far in the future.
55
+ def expired?
56
+ return true if timestamp.nil?
57
+
58
+ (Time.now - timestamp).abs > @max_age
59
+ end
60
+
61
+ # Use this to ignore messages you've already processed, as Twitch may send a message more than once
62
+ def message_id
63
+ header(MESSAGE_ID_HEADER)
64
+ end
65
+
66
+ # notification, webhook_callback_verification or revocation
67
+ def message_type
68
+ header(MESSAGE_TYPE_HEADER)
69
+ end
70
+
71
+ def notification?
72
+ message_type == "notification"
73
+ end
74
+
75
+ def verification?
76
+ message_type == "webhook_callback_verification"
77
+ end
78
+
79
+ def revocation?
80
+ message_type == "revocation"
81
+ end
82
+
83
+ def retry?
84
+ header(MESSAGE_RETRY_HEADER).to_i > 0
85
+ end
86
+
87
+ def timestamp
88
+ Time.iso8601(timestamp_header) if timestamp_header
89
+ rescue ArgumentError
90
+ nil
91
+ end
92
+
93
+ # e.g. channel.follow
94
+ def subscription_type
95
+ header(SUBSCRIPTION_TYPE_HEADER)
96
+ end
97
+
98
+ def subscription_version
99
+ header(SUBSCRIPTION_VERSION_HEADER)
100
+ end
101
+
102
+ def payload
103
+ @payload ||= JSON.parse(body)
104
+ end
105
+
106
+ def subscription
107
+ EventsubSubscription.new(payload["subscription"]) if payload["subscription"]
108
+ end
109
+
110
+ # The event data for notification messages
111
+ def event
112
+ Object.new(payload["event"]) if payload["event"]
113
+ end
114
+
115
+ # For webhook_callback_verification messages. Respond with this as a text/plain 200 response.
116
+ def challenge
117
+ payload["challenge"]
118
+ end
119
+
120
+ private
121
+
122
+ def signature
123
+ header(MESSAGE_SIGNATURE_HEADER)
124
+ end
125
+
126
+ def timestamp_header
127
+ header(MESSAGE_TIMESTAMP_HEADER)
128
+ end
129
+
130
+ def header(name)
131
+ rack_key = "HTTP_#{name.upcase.tr("-", "_")}"
132
+
133
+ value = @headers[name] || @headers[rack_key]
134
+ return value if value
135
+
136
+ @headers.each { |key, val| return val if key.to_s.casecmp?(name) }
137
+ nil
138
+ end
139
+ end
140
+ end
data/lib/twitch/oauth.rb CHANGED
@@ -1,23 +1,35 @@
1
1
  module Twitch
2
2
  class OAuth
3
- attr_reader :client_id, :client_secret
3
+ BASE_URL = "https://id.twitch.tv/oauth2"
4
+ DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"
4
5
 
5
- def initialize(client_id:, client_secret:)
6
+ attr_reader :client_id, :client_secret, :timeout, :open_timeout
7
+
8
+ # client_secret can be omitted for public clients using the device code flow
9
+ def initialize(client_id:, client_secret: nil, timeout: Client::DEFAULT_TIMEOUT, open_timeout: Client::DEFAULT_OPEN_TIMEOUT)
6
10
  @client_id = client_id
7
11
  @client_secret = client_secret
12
+ @timeout = timeout
13
+ @open_timeout = open_timeout
8
14
  end
9
15
 
10
- def create(grant_type:, scope: nil)
11
- send_request(url: "https://id.twitch.tv/oauth2/token", body: {
16
+ # Extra params (e.g. code and redirect_uri) are sent with the request
17
+ def create(grant_type:, scope: nil, **params)
18
+ send_request(url: "token", body: params.merge(
12
19
  client_id: client_id,
13
20
  client_secret: client_secret,
14
21
  grant_type: grant_type,
15
- scope: scope
16
- })
22
+ scope: scope_string(scope)
23
+ ))
24
+ end
25
+
26
+ # Exchanges the code from the authorization code grant flow for an access token and refresh token
27
+ def exchange_code(code:, redirect_uri:)
28
+ create(grant_type: "authorization_code", code: code, redirect_uri: redirect_uri)
17
29
  end
18
30
 
19
31
  def refresh(refresh_token:)
20
- send_request(url: "https://id.twitch.tv/oauth2/token", body: {
32
+ send_request(url: "token", body: {
21
33
  client_id: client_id,
22
34
  client_secret: client_secret,
23
35
  grant_type: "refresh_token",
@@ -25,12 +37,25 @@ module Twitch
25
37
  })
26
38
  end
27
39
 
40
+ # Starts the device code grant flow. scopes can be an array or a space-delimited string.
28
41
  def device(scopes:)
29
- send_request(url: "https://id.twitch.tv/oauth2/device", body: { client_id: client_id, scope: scopes })
42
+ send_request(url: "device", body: { client_id: client_id, scopes: scope_string(scopes) })
43
+ end
44
+
45
+ # Exchanges a device code for an access token and refresh token once the user has authorized.
46
+ # Until then, raises Twitch::Errors::BadRequestError with twitch_error_message "authorization_pending".
47
+ def device_token(device_code:, scopes:)
48
+ send_request(url: "token", body: {
49
+ client_id: client_id,
50
+ client_secret: client_secret,
51
+ device_code: device_code,
52
+ scopes: scope_string(scopes),
53
+ grant_type: DEVICE_CODE_GRANT_TYPE
54
+ })
30
55
  end
31
56
 
32
57
  def validate(token:)
33
- response = Faraday.get("https://id.twitch.tv/oauth2/validate", nil, { "Authorization" => "OAuth #{token}" })
58
+ response = connection.get("validate", nil, { "Authorization" => "OAuth #{token}" })
34
59
 
35
60
  return false if response.status != 200
36
61
 
@@ -38,24 +63,34 @@ module Twitch
38
63
  end
39
64
 
40
65
  def revoke(token:)
41
- response = Faraday.post("https://id.twitch.tv/oauth2/revoke", {
66
+ response = connection.post("revoke", {
42
67
  client_id: client_id,
43
68
  token: token
44
69
  })
45
70
 
46
- JSON.parse(response.body, object_class: OpenStruct) if response.status != 200
47
-
48
- true
71
+ response.status == 200
49
72
  end
50
73
 
51
74
  private
52
75
 
76
+ def connection
77
+ @connection ||= Faraday.new(BASE_URL) do |conn|
78
+ conn.options.timeout = timeout
79
+ conn.options.open_timeout = open_timeout
80
+ conn.request :url_encoded
81
+ end
82
+ end
83
+
53
84
  def send_request(url:, body:)
54
- response = Faraday.post(url, body)
85
+ response = connection.post(url, body.compact)
55
86
 
56
- return false if response.status != 200
87
+ raise ErrorFactory.create(response.body, response.status) if response.status != 200
57
88
 
58
89
  JSON.parse(response.body, object_class: OpenStruct)
59
90
  end
91
+
92
+ def scope_string(scopes)
93
+ scopes.is_a?(Array) ? scopes.join(" ") : scopes
94
+ end
60
95
  end
61
96
  end
data/lib/twitch/object.rb CHANGED
@@ -15,5 +15,33 @@ module Twitch
15
15
  obj
16
16
  end
17
17
  end
18
+
19
+ # Unlike OpenStruct#to_h, converts nested objects back into hashes too,
20
+ # so the result can be serialized
21
+ def to_h(&block)
22
+ hash = super(&nil).transform_values { |value| to_hash_value(value) }
23
+ block ? hash.to_h(&block) : hash
24
+ end
25
+
26
+ def as_json(*)
27
+ to_h
28
+ end
29
+
30
+ def to_json(*args)
31
+ to_h.to_json(*args)
32
+ end
33
+
34
+ private
35
+
36
+ def to_hash_value(value)
37
+ case value
38
+ when OpenStruct
39
+ value.to_h.transform_values { |v| to_hash_value(v) }
40
+ when Array
41
+ value.map { |v| to_hash_value(v) }
42
+ else
43
+ value
44
+ end
45
+ end
18
46
  end
19
47
  end
@@ -0,0 +1,4 @@
1
+ module Twitch
2
+ class AdSchedule < Object
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module Twitch
2
+ class AnalyticsReport < Object
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module Twitch
2
+ class BitsLeaderboardEntry < Object
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module Twitch
2
+ class CharityDonation < Object
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module Twitch
2
+ class ChatSettings < Object
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module Twitch
2
+ class Cheermote < Object
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module Twitch
2
+ class ContentClassificationLabel < Object
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module Twitch
2
+ class DropsEntitlement < Object
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module Twitch
2
+ class DropsEntitlementUpdate < Object
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module Twitch
2
+ class Extension < Object
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module Twitch
2
+ class ExtensionBitsProduct < Object
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module Twitch
2
+ class ExtensionConfigurationSegment < Object
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module Twitch
2
+ class ExtensionLiveChannel < Object
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module Twitch
2
+ class ExtensionSecret < Object
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module Twitch
2
+ class ExtensionTransaction < Object
3
+ end
4
+ end