stream-chat-ruby 3.26.1 → 3.27.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/.rspec +3 -0
- data/CHANGELOG.md +31 -0
- data/lib/stream-chat/channel.rb +17 -0
- data/lib/stream-chat/client.rb +143 -3
- data/lib/stream-chat/errors.rb +29 -4
- data/lib/stream-chat/version.rb +1 -1
- data/lib/stream-chat/webhook.rb +206 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4a99c43d6a3163fd4978c76884ae662c86b2651ab15f99fa088f2ef9e8f4126a
|
|
4
|
+
data.tar.gz: b3243d7e83eb1e03e0ed9e270cfa4d439882a67836e04382c4d169e6594f01e1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9f80e08e784f75111e85c56b4cecf159375fc2ffca2eab9d53aa8cd488365a39d9fcef93001351b2ecb0e9d29e900184507fafc067cbfcdab0c92ec331075d86
|
|
7
|
+
data.tar.gz: f3f66091808fdfcaa3980fd33338d94a41afe37631e39b9561cad90c955db4ddc8eca273ae44f30e91e8b84b8577f298f06f0f32f9b7a666537f3176d3f008de
|
data/.rspec
ADDED
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,37 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
|
4
4
|
|
|
5
|
+
## [3.27.0](https://github.com/GetStream/stream-chat-ruby/compare/v3.26.2...v3.27.0) (2026-10-29)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* add scheduled messages support (CHA-5933) ([#221](https://github.com/GetStream/stream-chat-ruby/issues/221)) ([65f5573](https://github.com/GetStream/stream-chat-ruby/commit/65f55738d23fb9e6d011c6eab6b2ded48c129c43))
|
|
11
|
+
* add history window team methods: `set_history_window_teams`, `delete_history_window_teams`, `get_history_window_teams` (CHA-5958) ([#225](https://github.com/GetStream/stream-chat-ruby/issues/225)) ([f581079](https://github.com/GetStream/stream-chat-ruby/commit/f581079f39a5b97c6518320c00bab29c85568936))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
### Other
|
|
15
|
+
|
|
16
|
+
* run scheduled message specs unconditionally ([#224](https://github.com/GetStream/stream-chat-ruby/issues/224)) ([b7352bf](https://github.com/GetStream/stream-chat-ruby/commit/b7352bf5575a1940bbe465f74f7f7a8dbc6ba59d))
|
|
17
|
+
|
|
18
|
+
### [3.26.2](https://github.com/GetStream/stream-chat-ruby/compare/v3.26.1...v3.26.2) (2026-06-30)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
### Features
|
|
22
|
+
|
|
23
|
+
* **webhooks:** verify_and_parse_* API for compressed payloads (CHA-3071) ([#211](https://github.com/GetStream/stream-chat-ruby/issues/211)) ([dd76c8f](https://github.com/GetStream/stream-chat-ruby/commit/dd76c8f39d0fae98c3d2c331635e9369b40e3696))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
### Bug Fixes
|
|
27
|
+
|
|
28
|
+
* guard StreamAPIException against empty and non-integer error bodies ([#217](https://github.com/GetStream/stream-chat-ruby/issues/217)) ([415f6c0](https://github.com/GetStream/stream-chat-ruby/commit/415f6c0bd7b51b87631c5a1e8f821e50a5def2ec))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
### Other
|
|
32
|
+
|
|
33
|
+
* before-message-send hook attempt timeout (CHA-3267) ([#216](https://github.com/GetStream/stream-chat-ruby/issues/216)) ([e721776](https://github.com/GetStream/stream-chat-ruby/commit/e721776376c064b434485bd4cb4188762f9f3c2b)), closes [GetStream/chat#13541](https://github.com/GetStream/chat/issues/13541)
|
|
34
|
+
* **spec:** clean fellowship sediment + retry commands eventually ([#212](https://github.com/GetStream/stream-chat-ruby/issues/212)) ([f3c8f5b](https://github.com/GetStream/stream-chat-ruby/commit/f3c8f5be841f95c2e0e399d3d180d7565d80fd6a))
|
|
35
|
+
|
|
5
36
|
### [3.26.1](https://github.com/GetStream/stream-chat-ruby/compare/v3.25.0...v3.26.1) (2026-04-29)
|
|
6
37
|
|
|
7
38
|
|
data/lib/stream-chat/channel.rb
CHANGED
|
@@ -55,6 +55,23 @@ module StreamChat
|
|
|
55
55
|
@client.post("#{url}/message", data: payload)
|
|
56
56
|
end
|
|
57
57
|
|
|
58
|
+
# Schedules a message to be sent to this channel at a later time.
|
|
59
|
+
#
|
|
60
|
+
# The message is not sent now: it is stored and sent at scheduled_at on
|
|
61
|
+
# behalf of user_id. The response carries `scheduled_message`, whose `id`
|
|
62
|
+
# is used to update, delete or send the scheduled message now.
|
|
63
|
+
#
|
|
64
|
+
# @param [StringKeyHash] message The message to send
|
|
65
|
+
# @param [String] user_id The ID of the user the message is sent as
|
|
66
|
+
# @param [DateTime, Time, String] scheduled_at When to send the message
|
|
67
|
+
# (at least 1 minute and at most the app's maximum lead time from now)
|
|
68
|
+
# @param [Hash] options Additional SendMessage options, e.g. skip_push
|
|
69
|
+
# @return [StreamChat::StreamResponse]
|
|
70
|
+
sig { params(message: StringKeyHash, user_id: String, scheduled_at: T.any(DateTime, Time, String), options: T.untyped).returns(StreamChat::StreamResponse) }
|
|
71
|
+
def schedule_message(message, user_id, scheduled_at, **options)
|
|
72
|
+
send_message(message, user_id, **options, scheduled_at: StreamChat.normalize_timestamp(scheduled_at))
|
|
73
|
+
end
|
|
74
|
+
|
|
58
75
|
# Sends an event on this channel.
|
|
59
76
|
sig { params(event: StringKeyHash, user_id: String).returns(StreamChat::StreamResponse) }
|
|
60
77
|
def send_event(event, user_id)
|
data/lib/stream-chat/client.rb
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
# typed: strict
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
+
require 'base64'
|
|
4
5
|
require 'open-uri'
|
|
6
|
+
require 'openssl'
|
|
7
|
+
require 'stringio'
|
|
8
|
+
require 'zlib'
|
|
5
9
|
require 'faraday'
|
|
6
10
|
require 'faraday/multipart'
|
|
7
11
|
require 'faraday/net_http_persistent'
|
|
@@ -18,6 +22,7 @@ require 'stream-chat/util'
|
|
|
18
22
|
require 'stream-chat/types'
|
|
19
23
|
require 'stream-chat/moderation'
|
|
20
24
|
require 'stream-chat/channel_batch_updater'
|
|
25
|
+
require 'stream-chat/webhook'
|
|
21
26
|
|
|
22
27
|
module StreamChat
|
|
23
28
|
DEFAULT_BLOCKLIST = 'profanity'
|
|
@@ -698,11 +703,57 @@ module StreamChat
|
|
|
698
703
|
get('rate_limits', params: params)
|
|
699
704
|
end
|
|
700
705
|
|
|
701
|
-
# Verify the signature
|
|
706
|
+
# Verify the signature on a webhook event using the client's API secret.
|
|
707
|
+
#
|
|
708
|
+
# Backward-compatible boolean helper. New integrations should call
|
|
709
|
+
# {#verify_and_parse_webhook} (or the SQS / SNS variants), which also handle
|
|
710
|
+
# gzip payload compression and return the parsed event.
|
|
702
711
|
sig { params(request_body: String, x_signature: String).returns(T::Boolean) }
|
|
703
712
|
def verify_webhook(request_body, x_signature)
|
|
704
|
-
|
|
705
|
-
|
|
713
|
+
StreamChat::Webhook.verify_signature(request_body, x_signature, @api_secret)
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
# Verify and parse an HTTP webhook event.
|
|
717
|
+
#
|
|
718
|
+
# Decompresses `body` when gzipped (detected from the body bytes), verifies
|
|
719
|
+
# the `X-Signature` header against the client's API secret, and returns the
|
|
720
|
+
# parsed event as a `Hash`. Typed event classes are planned for a future
|
|
721
|
+
# release.
|
|
722
|
+
#
|
|
723
|
+
# @param body [String, Array<Integer>] Raw HTTP request body bytes Stream
|
|
724
|
+
# signed.
|
|
725
|
+
# @param signature [String] Value of the `X-Signature` header.
|
|
726
|
+
# @return [Hash] The parsed event.
|
|
727
|
+
# @raise [InvalidWebhookError] When the signature does not match or the
|
|
728
|
+
# gzip envelope is malformed.
|
|
729
|
+
sig do
|
|
730
|
+
params(
|
|
731
|
+
body: T.any(String, T::Array[Integer]),
|
|
732
|
+
signature: String
|
|
733
|
+
).returns(T::Hash[String, T.untyped])
|
|
734
|
+
end
|
|
735
|
+
def verify_and_parse_webhook(body, signature)
|
|
736
|
+
StreamChat::Webhook.verify_and_parse_webhook(body, signature, @api_secret)
|
|
737
|
+
end
|
|
738
|
+
|
|
739
|
+
# Parse an SQS-delivered webhook event (decode only; Stream does not HMAC-sign SQS bodies).
|
|
740
|
+
#
|
|
741
|
+
# @param message_body [String] SQS message `Body` string.
|
|
742
|
+
# @return [Hash] The parsed event.
|
|
743
|
+
# @raise [InvalidWebhookError] on malformed base64 or gzip envelope
|
|
744
|
+
sig { params(message_body: String).returns(T::Hash[String, T.untyped]) }
|
|
745
|
+
def parse_sqs(message_body)
|
|
746
|
+
StreamChat::Webhook.parse_sqs(message_body)
|
|
747
|
+
end
|
|
748
|
+
|
|
749
|
+
# Parse an SNS-delivered webhook event (unwraps envelope JSON when needed).
|
|
750
|
+
#
|
|
751
|
+
# @param message [String] Raw SNS POST body or pre-extracted Message string.
|
|
752
|
+
# @return [Hash] The parsed event.
|
|
753
|
+
# @raise [InvalidWebhookError] on decode failures
|
|
754
|
+
sig { params(message: String).returns(T::Hash[String, T.untyped]) }
|
|
755
|
+
def parse_sns(message)
|
|
756
|
+
StreamChat::Webhook.parse_sns(message)
|
|
706
757
|
end
|
|
707
758
|
|
|
708
759
|
# Allows you to send custom events to a connected user.
|
|
@@ -1171,6 +1222,58 @@ module StreamChat
|
|
|
1171
1222
|
post('reminders/query', data: params)
|
|
1172
1223
|
end
|
|
1173
1224
|
|
|
1225
|
+
# Updates a scheduled message. Schedule a message with Channel#schedule_message.
|
|
1226
|
+
#
|
|
1227
|
+
# At least one of message or scheduled_at is required. The message, when
|
|
1228
|
+
# given, replaces the scheduled message as a whole; the sender stays the
|
|
1229
|
+
# user the message was scheduled for.
|
|
1230
|
+
#
|
|
1231
|
+
# @param id [String] The ID of the scheduled message
|
|
1232
|
+
# @param message [StringKeyHash, nil] The new message
|
|
1233
|
+
# @param scheduled_at [DateTime, Time, String, nil] The new send time
|
|
1234
|
+
# @return [StreamChat::StreamResponse] API response with scheduled_message
|
|
1235
|
+
sig { params(id: String, message: T.nilable(StringKeyHash), scheduled_at: T.nilable(T.any(DateTime, Time, String))).returns(StreamChat::StreamResponse) }
|
|
1236
|
+
def update_scheduled_message(id, message: nil, scheduled_at: nil)
|
|
1237
|
+
data = {}
|
|
1238
|
+
data[:message] = message if message
|
|
1239
|
+
data[:scheduled_at] = StreamChat.normalize_timestamp(scheduled_at) if scheduled_at
|
|
1240
|
+
patch("scheduled_messages/#{id}", data: data)
|
|
1241
|
+
end
|
|
1242
|
+
|
|
1243
|
+
# Deletes a scheduled message: cancels it so it is never sent, or removes a failed one.
|
|
1244
|
+
#
|
|
1245
|
+
# @param id [String] The ID of the scheduled message
|
|
1246
|
+
# @return [StreamChat::StreamResponse] API response with scheduled_message
|
|
1247
|
+
sig { params(id: String).returns(StreamChat::StreamResponse) }
|
|
1248
|
+
def delete_scheduled_message(id)
|
|
1249
|
+
delete("scheduled_messages/#{id}")
|
|
1250
|
+
end
|
|
1251
|
+
|
|
1252
|
+
# Sends a scheduled message right away.
|
|
1253
|
+
#
|
|
1254
|
+
# @param id [String] The ID of the scheduled message
|
|
1255
|
+
# @return [StreamChat::StreamResponse] API response with scheduled_message
|
|
1256
|
+
sig { params(id: String).returns(StreamChat::StreamResponse) }
|
|
1257
|
+
def send_scheduled_message_now(id)
|
|
1258
|
+
post("scheduled_messages/#{id}/send_now")
|
|
1259
|
+
end
|
|
1260
|
+
|
|
1261
|
+
# Queries the scheduled messages of a user across channels: pending ones
|
|
1262
|
+
# first, then failed ones, each by scheduled_at.
|
|
1263
|
+
#
|
|
1264
|
+
# @param user_id [String] The ID of the user whose scheduled messages to query
|
|
1265
|
+
# @param filter [StringKeyHash, nil] Optional filter, e.g. { cid: 'messaging:general', status: ['failed'] }
|
|
1266
|
+
# @param sort [Array<Hash>, nil] Optional sort; only [{ field: 'scheduled_at', direction: 1 }] is supported
|
|
1267
|
+
# @param options [Hash] Additional query options: limit (1 to 100, default 20) and next
|
|
1268
|
+
# @return [StreamChat::StreamResponse] API response with scheduled_messages and next
|
|
1269
|
+
sig { params(user_id: String, filter: T.nilable(StringKeyHash), sort: T.nilable(T::Array[StringKeyHash]), options: T.untyped).returns(StreamChat::StreamResponse) }
|
|
1270
|
+
def query_scheduled_messages(user_id, filter: nil, sort: nil, **options)
|
|
1271
|
+
data = options.merge({ user_id: user_id })
|
|
1272
|
+
data[:filter] = filter if filter
|
|
1273
|
+
data[:sort] = sort if sort
|
|
1274
|
+
post('scheduled_messages/query', data: data)
|
|
1275
|
+
end
|
|
1276
|
+
|
|
1174
1277
|
# Send the mark delivered event for this user, only works if the `delivery_receipts` setting is enabled
|
|
1175
1278
|
#
|
|
1176
1279
|
# @param [StringKeyHash, nil] data The delivery confirmation data
|
|
@@ -1216,6 +1319,43 @@ module StreamChat
|
|
|
1216
1319
|
post('stats/team_usage', data: options)
|
|
1217
1320
|
end
|
|
1218
1321
|
|
|
1322
|
+
# Gives teams full history (the paid tier of the history window).
|
|
1323
|
+
#
|
|
1324
|
+
# Teams without full history keep the app's history window. This endpoint is
|
|
1325
|
+
# server-side only and only for apps with the history window enabled; other
|
|
1326
|
+
# apps get a StreamAPIException with code 17 (HTTP 403).
|
|
1327
|
+
#
|
|
1328
|
+
# @param teams [Array<String>] 1 to 10 teams, each listed once
|
|
1329
|
+
# @return [StreamChat::StreamResponse] Response with the marked teams
|
|
1330
|
+
sig { params(teams: T::Array[String]).returns(StreamChat::StreamResponse) }
|
|
1331
|
+
def set_history_window_teams(teams)
|
|
1332
|
+
post('history_window/teams', data: { teams: teams })
|
|
1333
|
+
end
|
|
1334
|
+
|
|
1335
|
+
# Puts teams back on the app's default history window.
|
|
1336
|
+
#
|
|
1337
|
+
# A team without full history is ignored. This endpoint is server-side only
|
|
1338
|
+
# and only for apps with the history window enabled; other apps get a
|
|
1339
|
+
# StreamAPIException with code 17 (HTTP 403).
|
|
1340
|
+
#
|
|
1341
|
+
# @param teams [Array<String>] 1 to 10 teams, each listed once
|
|
1342
|
+
# @return [StreamChat::StreamResponse] Response with the teams that had full history
|
|
1343
|
+
sig { params(teams: T::Array[String]).returns(StreamChat::StreamResponse) }
|
|
1344
|
+
def delete_history_window_teams(teams)
|
|
1345
|
+
post('history_window/teams/delete', data: { teams: teams })
|
|
1346
|
+
end
|
|
1347
|
+
|
|
1348
|
+
# Returns the app's default history window and the teams with full history.
|
|
1349
|
+
#
|
|
1350
|
+
# This endpoint is server-side only and only for apps with the history window
|
|
1351
|
+
# enabled; other apps get a StreamAPIException with code 17 (HTTP 403).
|
|
1352
|
+
#
|
|
1353
|
+
# @return [StreamChat::StreamResponse] Response with default_window_hours and teams
|
|
1354
|
+
sig { returns(StreamChat::StreamResponse) }
|
|
1355
|
+
def get_history_window_teams
|
|
1356
|
+
get('history_window/teams')
|
|
1357
|
+
end
|
|
1358
|
+
|
|
1219
1359
|
private
|
|
1220
1360
|
|
|
1221
1361
|
sig { returns(T::Hash[String, String]) }
|
data/lib/stream-chat/errors.rb
CHANGED
|
@@ -21,14 +21,28 @@ module StreamChat
|
|
|
21
21
|
def initialize(response)
|
|
22
22
|
super()
|
|
23
23
|
@response = response
|
|
24
|
+
|
|
25
|
+
# Seed defaults first so the typed readers never return nil. A 5xx can
|
|
26
|
+
# arrive with an empty or non-JSON body (e.g. a load balancer emitting a
|
|
27
|
+
# bare 503), and an error envelope may omit "code"/"message" or send a
|
|
28
|
+
# non-integer "code". Without these defaults, reading error_code on such
|
|
29
|
+
# an exception raised a Sorbet TypeError that masked the real HTTP error.
|
|
30
|
+
@json_response = T.let(false, T::Boolean)
|
|
31
|
+
@error_code = T.let(-1, Integer)
|
|
32
|
+
@error_message = T.let('unknown', String)
|
|
33
|
+
|
|
24
34
|
begin
|
|
25
35
|
parsed_response = JSON.parse(response.body)
|
|
26
|
-
@json_response = T.let(true, T::Boolean)
|
|
27
|
-
@error_code = T.let(parsed_response.fetch('code', 'unknown'), Integer)
|
|
28
|
-
@error_message = T.let(parsed_response.fetch('message', 'unknown'), String)
|
|
29
36
|
rescue JSON::ParserError
|
|
30
|
-
|
|
37
|
+
return
|
|
31
38
|
end
|
|
39
|
+
return unless parsed_response.is_a?(Hash)
|
|
40
|
+
|
|
41
|
+
@json_response = true
|
|
42
|
+
code = parsed_response['code']
|
|
43
|
+
@error_code = code if code.is_a?(Integer)
|
|
44
|
+
msg = parsed_response['message']
|
|
45
|
+
@error_message = msg if msg.is_a?(String)
|
|
32
46
|
end
|
|
33
47
|
|
|
34
48
|
sig { returns(String) }
|
|
@@ -47,4 +61,15 @@ module StreamChat
|
|
|
47
61
|
end
|
|
48
62
|
|
|
49
63
|
class StreamChannelException < StandardError; end
|
|
64
|
+
|
|
65
|
+
# Raised by webhook verify/parse helpers when the HMAC does not match or a
|
|
66
|
+
# gzip/base64/JSON envelope cannot be decoded. The message identifies which
|
|
67
|
+
# failure mode fired; the class-level constants below are the canonical
|
|
68
|
+
# strings for callers that prefer exact-match filtering over substring.
|
|
69
|
+
class InvalidWebhookError < StandardError
|
|
70
|
+
SIGNATURE_MISMATCH = 'signature mismatch'
|
|
71
|
+
INVALID_BASE64 = 'invalid base64 encoding'
|
|
72
|
+
GZIP_FAILED = 'gzip decompression failed'
|
|
73
|
+
INVALID_JSON = 'invalid JSON payload'
|
|
74
|
+
end
|
|
50
75
|
end
|
data/lib/stream-chat/version.rb
CHANGED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'base64'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'openssl'
|
|
7
|
+
require 'sorbet-runtime'
|
|
8
|
+
require 'stringio'
|
|
9
|
+
require 'zlib'
|
|
10
|
+
|
|
11
|
+
require 'stream-chat/errors'
|
|
12
|
+
|
|
13
|
+
module StreamChat
|
|
14
|
+
# Stateless helpers implementing the cross-SDK webhook contract documented at
|
|
15
|
+
# https://getstream.io/chat/docs/node/webhooks_overview/.
|
|
16
|
+
#
|
|
17
|
+
# The composite functions (`verify_and_parse_webhook`, `parse_sqs`,
|
|
18
|
+
# `parse_sns`) are the recommended entry points. The primitives
|
|
19
|
+
# they compose (`gunzip_payload`, `decode_sqs_payload`, `decode_sns_payload`,
|
|
20
|
+
# `verify_signature`, `parse_event`) are exposed so callers can build custom
|
|
21
|
+
# flows or run individual steps in isolation.
|
|
22
|
+
#
|
|
23
|
+
# The Ruby SDK currently returns the parsed JSON as a `Hash`; typed event
|
|
24
|
+
# classes will land in a future release.
|
|
25
|
+
module Webhook # rubocop:disable Metrics/ModuleLength
|
|
26
|
+
extend T::Sig
|
|
27
|
+
|
|
28
|
+
GZIP_MAGIC = T.let("\x1f\x8b".b.freeze, String)
|
|
29
|
+
|
|
30
|
+
# Coerces the webhook body into a binary `String` regardless of whether
|
|
31
|
+
# the caller hands us a `String` (HTTP `request.raw_post`) or an array of
|
|
32
|
+
# bytes (which is what some Ruby SQS clients yield when the message body
|
|
33
|
+
# is binary safe).
|
|
34
|
+
sig { params(body: T.any(String, T::Array[Integer])).returns(String) }
|
|
35
|
+
def self.normalize_body(body)
|
|
36
|
+
raw =
|
|
37
|
+
if body.is_a?(Array)
|
|
38
|
+
body.pack('C*')
|
|
39
|
+
else
|
|
40
|
+
String.new(body)
|
|
41
|
+
end
|
|
42
|
+
raw.force_encoding(Encoding::ASCII_8BIT)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Returns `body` unchanged unless it starts with the gzip magic
|
|
46
|
+
# (`1f 8b`, per RFC 1952), in which case the gzip stream is inflated and
|
|
47
|
+
# the decompressed bytes are returned.
|
|
48
|
+
#
|
|
49
|
+
# Magic-byte detection (rather than relying on a header) keeps the same
|
|
50
|
+
# handler correct when middleware - Rack, Rails - auto-decompresses the
|
|
51
|
+
# request before your code sees it.
|
|
52
|
+
sig { params(body: T.any(String, T::Array[Integer])).returns(String) }
|
|
53
|
+
def self.gunzip_payload(body)
|
|
54
|
+
raw = normalize_body(body)
|
|
55
|
+
return raw unless raw.start_with?(GZIP_MAGIC)
|
|
56
|
+
|
|
57
|
+
begin
|
|
58
|
+
Zlib::GzipReader.new(StringIO.new(raw)).read.force_encoding(Encoding::ASCII_8BIT)
|
|
59
|
+
rescue Zlib::Error
|
|
60
|
+
raise InvalidWebhookError, InvalidWebhookError::GZIP_FAILED
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Reverses the SQS firehose envelope: the message `Body` is base64-decoded
|
|
65
|
+
# and, when the result begins with the gzip magic, gzip-decompressed. The
|
|
66
|
+
# same call works whether or not Stream is currently compressing payloads.
|
|
67
|
+
sig { params(body: String).returns(String) }
|
|
68
|
+
def self.decode_sqs_payload(body)
|
|
69
|
+
decoded =
|
|
70
|
+
begin
|
|
71
|
+
Base64.strict_decode64(body)
|
|
72
|
+
rescue ArgumentError
|
|
73
|
+
raise InvalidWebhookError, InvalidWebhookError::INVALID_BASE64
|
|
74
|
+
end
|
|
75
|
+
gunzip_payload(decoded)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Reverses an SNS HTTP notification envelope. When `notification_body` is a
|
|
79
|
+
# JSON envelope (`{"Type":"Notification","Message":"..."}`), the inner
|
|
80
|
+
# `Message` field is extracted and run through the SQS pipeline
|
|
81
|
+
# (base64-decode, then gzip-if-magic). When the input is not a JSON
|
|
82
|
+
# envelope it is treated as the already-extracted `Message` string, so
|
|
83
|
+
# call sites that pre-unwrap continue to work.
|
|
84
|
+
sig { params(notification_body: String).returns(String) }
|
|
85
|
+
def self.decode_sns_payload(notification_body)
|
|
86
|
+
inner = extract_sns_message(notification_body)
|
|
87
|
+
decode_sqs_payload(inner.nil? ? notification_body : inner)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
sig { params(notification_body: String).returns(T.nilable(String)) }
|
|
91
|
+
def self.extract_sns_message(notification_body)
|
|
92
|
+
trimmed = notification_body.to_s.lstrip
|
|
93
|
+
return nil unless trimmed.start_with?('{')
|
|
94
|
+
|
|
95
|
+
begin
|
|
96
|
+
parsed = JSON.parse(trimmed)
|
|
97
|
+
rescue JSON::ParserError
|
|
98
|
+
return nil
|
|
99
|
+
end
|
|
100
|
+
return nil unless parsed.is_a?(Hash)
|
|
101
|
+
|
|
102
|
+
message = parsed['Message']
|
|
103
|
+
message.is_a?(String) ? message : nil
|
|
104
|
+
end
|
|
105
|
+
private_class_method :extract_sns_message
|
|
106
|
+
|
|
107
|
+
# Constant-time HMAC-SHA256 verification of `signature` against the digest
|
|
108
|
+
# of `body` keyed by `secret`.
|
|
109
|
+
#
|
|
110
|
+
# The signature is always computed over the **uncompressed** JSON bytes,
|
|
111
|
+
# so callers that decoded a gzipped or base64-wrapped payload must pass
|
|
112
|
+
# the inflated bytes here.
|
|
113
|
+
sig do
|
|
114
|
+
params(
|
|
115
|
+
body: T.any(String, T::Array[Integer]),
|
|
116
|
+
signature: String,
|
|
117
|
+
secret: String
|
|
118
|
+
).returns(T::Boolean)
|
|
119
|
+
end
|
|
120
|
+
def self.verify_signature(body, signature, secret)
|
|
121
|
+
raw = normalize_body(body)
|
|
122
|
+
expected = OpenSSL::HMAC.hexdigest('SHA256', secret, raw)
|
|
123
|
+
constant_time_equal?(expected, signature)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Parse a JSON-encoded webhook event into a `Hash`.
|
|
127
|
+
#
|
|
128
|
+
# The Ruby SDK currently returns the parsed JSON as a `Hash`; typed event
|
|
129
|
+
# classes will land in a future release. The function name matches the
|
|
130
|
+
# documented primitive so callers can swap in a typed parser later without
|
|
131
|
+
# changing call sites.
|
|
132
|
+
sig { params(payload: String).returns(T::Hash[String, T.untyped]) }
|
|
133
|
+
def self.parse_event(payload)
|
|
134
|
+
result =
|
|
135
|
+
begin
|
|
136
|
+
JSON.parse(payload)
|
|
137
|
+
rescue JSON::ParserError
|
|
138
|
+
raise InvalidWebhookError, InvalidWebhookError::INVALID_JSON
|
|
139
|
+
end
|
|
140
|
+
raise InvalidWebhookError, InvalidWebhookError::INVALID_JSON unless result.is_a?(Hash)
|
|
141
|
+
|
|
142
|
+
result
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
sig do
|
|
146
|
+
params(
|
|
147
|
+
payload: String,
|
|
148
|
+
signature: String,
|
|
149
|
+
secret: String
|
|
150
|
+
).returns(T::Hash[String, T.untyped])
|
|
151
|
+
end
|
|
152
|
+
def self.verify_and_parse_internal(payload, signature, secret)
|
|
153
|
+
raise InvalidWebhookError, InvalidWebhookError::SIGNATURE_MISMATCH unless verify_signature(payload, signature, secret)
|
|
154
|
+
|
|
155
|
+
parse_event(payload)
|
|
156
|
+
end
|
|
157
|
+
private_class_method :verify_and_parse_internal
|
|
158
|
+
|
|
159
|
+
# Decompress `body` when gzipped, verify the HMAC `signature`, and return
|
|
160
|
+
# the parsed event.
|
|
161
|
+
sig do
|
|
162
|
+
params(
|
|
163
|
+
body: T.any(String, T::Array[Integer]),
|
|
164
|
+
signature: String,
|
|
165
|
+
secret: String
|
|
166
|
+
).returns(T::Hash[String, T.untyped])
|
|
167
|
+
end
|
|
168
|
+
def self.verify_and_parse_webhook(body, signature, secret)
|
|
169
|
+
verify_and_parse_internal(gunzip_payload(body), signature, secret)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Decode the SQS `Body` (base64, then gzip-if-magic) and return the parsed
|
|
173
|
+
# event JSON. Stream does not ship an application-level signature on SQS payloads.
|
|
174
|
+
sig { params(message_body: String).returns(T::Hash[String, T.untyped]) }
|
|
175
|
+
def self.parse_sqs(message_body)
|
|
176
|
+
parse_event(decode_sqs_payload(message_body))
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Decode SNS (unwrap envelope when needed) and return the parsed event JSON.
|
|
180
|
+
sig { params(message: String).returns(T::Hash[String, T.untyped]) }
|
|
181
|
+
def self.parse_sns(message)
|
|
182
|
+
parse_event(decode_sns_payload(message))
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Timing-safe equality check used to compare the locally computed HMAC
|
|
186
|
+
# with the `X-Signature` header. Prefers the OpenSSL primitive when the
|
|
187
|
+
# Ruby build exposes it, otherwise falls back to a manual byte XOR loop
|
|
188
|
+
# that does not short-circuit on the first mismatch.
|
|
189
|
+
sig { params(left: String, right: String).returns(T::Boolean) }
|
|
190
|
+
def self.constant_time_equal?(left, right)
|
|
191
|
+
a = left.b
|
|
192
|
+
b = right.b
|
|
193
|
+
return false unless a.bytesize == b.bytesize
|
|
194
|
+
|
|
195
|
+
if OpenSSL.respond_to?(:fixed_length_secure_compare)
|
|
196
|
+
OpenSSL.fixed_length_secure_compare(a, b)
|
|
197
|
+
else
|
|
198
|
+
a_bytes = a.bytes
|
|
199
|
+
b_bytes = b.bytes
|
|
200
|
+
diff = 0
|
|
201
|
+
a_bytes.each_with_index { |byte, i| diff |= byte ^ b_bytes[i] }
|
|
202
|
+
diff.zero?
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: stream-chat-ruby
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.
|
|
4
|
+
version: 3.27.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- getstream.io
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-10-29 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: faraday
|
|
@@ -113,6 +113,7 @@ extensions: []
|
|
|
113
113
|
extra_rdoc_files: []
|
|
114
114
|
files:
|
|
115
115
|
- ".gitignore"
|
|
116
|
+
- ".rspec"
|
|
116
117
|
- ".rubocop.yml"
|
|
117
118
|
- ".versionrc.js"
|
|
118
119
|
- CHANGELOG.md
|
|
@@ -136,6 +137,7 @@ files:
|
|
|
136
137
|
- lib/stream-chat/types.rb
|
|
137
138
|
- lib/stream-chat/util.rb
|
|
138
139
|
- lib/stream-chat/version.rb
|
|
140
|
+
- lib/stream-chat/webhook.rb
|
|
139
141
|
- stream-chat.gemspec
|
|
140
142
|
homepage: http://github.com/GetStream/stream-chat-ruby
|
|
141
143
|
licenses: []
|