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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f76091a8b305ba916ed2217c639f46deca24f997c148aaac21dd1cdeb7664482
4
- data.tar.gz: bd4e54bd949250094bb2d3ee3c350312f3faebe927c5521733e9ffd6bb08e90a
3
+ metadata.gz: 4a99c43d6a3163fd4978c76884ae662c86b2651ab15f99fa088f2ef9e8f4126a
4
+ data.tar.gz: b3243d7e83eb1e03e0ed9e270cfa4d439882a67836e04382c4d169e6594f01e1
5
5
  SHA512:
6
- metadata.gz: c4352825a7532841cb8667de383e977c6a8ae9908bd2e44dc8de5f714cb8ea6a186d90e7b820b795ce097f78492838bb453dba1e216a7d8284ff6f7347f0b4d4
7
- data.tar.gz: f9e18435119dda8be34f8e282004fe9597cee212e34d5a676428d562005c44cdbcee8af19c4c3e2c878268c86beca1120a9173e9fe632341ee46221e6b758d38
6
+ metadata.gz: 9f80e08e784f75111e85c56b4cecf159375fc2ffca2eab9d53aa8cd488365a39d9fcef93001351b2ecb0e9d29e900184507fafc067cbfcdab0c92ec331075d86
7
+ data.tar.gz: f3f66091808fdfcaa3980fd33338d94a41afe37631e39b9561cad90c955db4ddc8eca273ae44f30e91e8b84b8577f298f06f0f32f9b7a666537f3176d3f008de
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --require spec_helper
2
+ --format documentation
3
+ --profile 25
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
 
@@ -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)
@@ -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 added to a webhook event.
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
- signature = OpenSSL::HMAC.hexdigest('SHA256', @api_secret, request_body)
705
- signature == x_signature
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]) }
@@ -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
- @json_response = false
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
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module StreamChat
5
- VERSION = '3.26.1'
5
+ VERSION = '3.27.0'
6
6
  end
@@ -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.26.1
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-04-29 00:00:00.000000000 Z
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: []