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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +2 -2
- data/.rubocop.yml +1 -1
- data/CHANGELOG.md +104 -1
- data/Gemfile.lock +5 -5
- data/README.md +360 -45
- data/lib/twitch/client.rb +109 -20
- data/lib/twitch/collection.rb +58 -11
- data/lib/twitch/error_generator.rb +6 -4
- data/lib/twitch/eventsub_webhook.rb +140 -0
- data/lib/twitch/oauth.rb +50 -15
- data/lib/twitch/object.rb +28 -0
- data/lib/twitch/objects/ad_schedule.rb +4 -0
- data/lib/twitch/objects/analytics_report.rb +4 -0
- data/lib/twitch/objects/bits_leaderboard_entry.rb +4 -0
- data/lib/twitch/objects/charity_donation.rb +4 -0
- data/lib/twitch/objects/chat_settings.rb +4 -0
- data/lib/twitch/objects/cheermote.rb +4 -0
- data/lib/twitch/objects/content_classification_label.rb +4 -0
- data/lib/twitch/objects/drops_entitlement.rb +4 -0
- data/lib/twitch/objects/drops_entitlement_update.rb +4 -0
- data/lib/twitch/objects/extension.rb +4 -0
- data/lib/twitch/objects/extension_bits_product.rb +4 -0
- data/lib/twitch/objects/extension_configuration_segment.rb +4 -0
- data/lib/twitch/objects/extension_live_channel.rb +4 -0
- data/lib/twitch/objects/extension_secret.rb +4 -0
- data/lib/twitch/objects/extension_transaction.rb +4 -0
- data/lib/twitch/objects/guest_star_invite.rb +4 -0
- data/lib/twitch/objects/guest_star_session.rb +4 -0
- data/lib/twitch/objects/guest_star_settings.rb +4 -0
- data/lib/twitch/objects/shield_mode_status.rb +4 -0
- data/lib/twitch/objects/{tag.rb → team.rb} +1 -1
- data/lib/twitch/objects/user_active_extensions.rb +4 -0
- data/lib/twitch/objects/user_extension.rb +4 -0
- data/lib/twitch/resource.rb +49 -9
- data/lib/twitch/resources/ads.rb +18 -0
- data/lib/twitch/resources/analytics.rb +19 -0
- data/lib/twitch/resources/announcements.rb +1 -1
- data/lib/twitch/resources/automod.rb +8 -8
- data/lib/twitch/resources/badges.rb +3 -3
- data/lib/twitch/resources/banned_users.rb +4 -4
- data/lib/twitch/resources/bits.rb +16 -0
- data/lib/twitch/resources/blocked_terms.rb +5 -5
- data/lib/twitch/resources/channels.rb +7 -7
- data/lib/twitch/resources/charity_campaigns.rb +9 -1
- data/lib/twitch/resources/chat_messages.rb +1 -1
- data/lib/twitch/resources/chat_settings.rb +20 -0
- data/lib/twitch/resources/chatters.rb +1 -1
- data/lib/twitch/resources/clips.rb +3 -9
- data/lib/twitch/resources/content_classification_labels.rb +8 -0
- data/lib/twitch/resources/custom_power_ups.rb +1 -1
- data/lib/twitch/resources/custom_reward_redemptions.rb +3 -4
- data/lib/twitch/resources/custom_rewards.rb +5 -5
- data/lib/twitch/resources/drops_entitlements.rb +17 -0
- data/lib/twitch/resources/emotes.rb +5 -5
- data/lib/twitch/resources/eventsub_conduits.rb +14 -4
- data/lib/twitch/resources/eventsub_subscriptions.rb +9 -13
- data/lib/twitch/resources/extensions.rb +99 -0
- data/lib/twitch/resources/games.rb +5 -7
- data/lib/twitch/resources/goals.rb +1 -1
- data/lib/twitch/resources/guest_star.rb +102 -0
- data/lib/twitch/resources/moderators.rb +4 -4
- data/lib/twitch/resources/pinned_chat_messages.rb +4 -11
- data/lib/twitch/resources/polls.rb +1 -1
- data/lib/twitch/resources/predictions.rb +1 -1
- data/lib/twitch/resources/raids.rb +3 -4
- data/lib/twitch/resources/search.rb +2 -2
- data/lib/twitch/resources/shield_mode.rb +18 -0
- data/lib/twitch/resources/shoutouts.rb +1 -1
- data/lib/twitch/resources/stream_markers.rb +1 -1
- data/lib/twitch/resources/stream_schedule.rb +4 -5
- data/lib/twitch/resources/streams.rb +2 -2
- data/lib/twitch/resources/subscriptions.rb +13 -2
- data/lib/twitch/resources/suspicious_users.rb +6 -2
- data/lib/twitch/resources/teams.rb +20 -0
- data/lib/twitch/resources/unban_requests.rb +2 -2
- data/lib/twitch/resources/users.rb +37 -43
- data/lib/twitch/resources/videos.rb +3 -3
- data/lib/twitch/resources/vips.rb +3 -3
- data/lib/twitch/resources/warnings.rb +2 -2
- data/lib/twitch/resources/whispers.rb +1 -1
- data/lib/twitch/version.rb +1 -1
- data/lib/twitch.rb +33 -9
- data/twitchrb.gemspec +5 -5
- metadata +42 -18
- data/lib/twitch/objects/banned_event.rb +0 -4
- data/lib/twitch/objects/followed_user.rb +0 -4
- data/lib/twitch/objects/hype_train_event.rb +0 -4
- data/lib/twitch/objects/moderator_event.rb +0 -4
- data/lib/twitch/resources/banned_events.rb +0 -8
- data/lib/twitch/resources/hype_train_events.rb +0 -10
- data/lib/twitch/resources/moderator_events.rb +0 -9
- 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
|
-
|
|
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/
|
|
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
|
|
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
|
data/lib/twitch/collection.rb
CHANGED
|
@@ -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:
|
|
12
|
-
total: body["
|
|
13
|
-
cursor: body
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
32
|
-
|
|
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 <
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3
|
+
BASE_URL = "https://id.twitch.tv/oauth2"
|
|
4
|
+
DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"
|
|
4
5
|
|
|
5
|
-
|
|
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
|
-
|
|
11
|
-
|
|
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: "
|
|
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: "
|
|
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 =
|
|
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 =
|
|
66
|
+
response = connection.post("revoke", {
|
|
42
67
|
client_id: client_id,
|
|
43
68
|
token: token
|
|
44
69
|
})
|
|
45
70
|
|
|
46
|
-
|
|
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 =
|
|
85
|
+
response = connection.post(url, body.compact)
|
|
55
86
|
|
|
56
|
-
|
|
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
|