grape-slack-bot 2.1.2 → 2.1.3

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: 45d672bb2de61c8d0d1335a0ddf37c9242dc97dd6db90ba630a1d70d7330436c
4
- data.tar.gz: a42ebdc015514c1e66a38511d54ce21e718be36161d14ead247859e09bffbe87
3
+ metadata.gz: c3e52396043a337cfa348d788ca11ea7d6b76c8486d080a2a0d43c69cf73ad4e
4
+ data.tar.gz: a20fd5e43a9695978b06250cd66a9c13bc2732b9246c9a1a8685e1986b9302f1
5
5
  SHA512:
6
- metadata.gz: 1e41c14e09be9c8687b6653ea29a102586e3fb2750f68533dc63aef9a914f8d4aa818e2af8d8a850f9b9ecaeefff1268d5315fa3f5b42405b9df7c0311f19968
7
- data.tar.gz: c1e26e64aa95b0ff1ceef088a171888e508348e3afca8fb865eca5071841c052178daa015ce19f6ffee7a70850597fdf276f843ca78b4698c8cd7640afeff766
6
+ metadata.gz: 4a25326a96fafe69c94ff3a2490bc9b674e5f8ee40e3c7dc481a5fef4f7ccb3a19cbc1fe471f6fb155b605e4c544c68785bf817a1a4f1a8686351e672201cd59
7
+ data.tar.gz: bb3fd2179189e08f5795a4a6cccbbb82f96d4157e455fbd0f28b955a7ca5ae7112b9da548326d1523aacfd2fb5640972aecaa05677ce9ff2b7bad760106f3822
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 2.1.3 (2026-11-07)
4
+
5
+ - Memoize parsed JSON in `SlackBot::ApiResponse#data` so repeated reads do not re-parse the response body
6
+ - Encode Slack API request bodies with `JSON.generate` instead of ActiveSupport `Hash#to_json`
7
+ - Verify Slack signatures with `HMAC.digest` and binary `secure_compare`; cache the request body for the verify pass
8
+ - Keep `ApiResponse` hot-path shortcuts always on; drop `SlackBot::PressureOpts` / `SLACK_PRESSURE_*` feature flags
9
+ - Return HTTP 401 when Slack request signature verification fails
10
+ - Clamp a negative `Retry-After` value to zero before the bounded retry
11
+ - Fail closed when release confirmation input reaches EOF
12
+ - Require a matching `CHANGELOG.md` version heading before publish
13
+ - Give the test workflow read-only `contents` permission
14
+
3
15
  ## 2.1.2 (2026-09-15)
4
16
 
5
17
  - Allow grape 4.x by expanding the runtime dependency to `< 5.0`
@@ -35,7 +35,7 @@ Gem::Specification.new do |gem|
35
35
  gem.add_runtime_dependency "ostruct", ">= 0.6"
36
36
 
37
37
  gem.add_development_dependency "rspec", "~> 3"
38
- gem.add_development_dependency "polyrun", ">= 2.2.0"
38
+ gem.add_development_dependency "polyrun", ">= 2.2.9"
39
39
  gem.add_development_dependency "webmock", "~> 3"
40
40
  gem.add_development_dependency "rake", "~> 13"
41
41
  gem.add_development_dependency "standard", "~> 1.52"
@@ -1,4 +1,5 @@
1
1
  require "faraday"
2
+ require "json"
2
3
 
3
4
  module SlackBot
4
5
  class ApiResponse
@@ -16,43 +17,78 @@ module SlackBot
16
17
  end
17
18
 
18
19
  def self.rate_limit_retry_sleep_seconds(retry_after)
19
- [retry_after.to_i, RATE_LIMIT_RETRY_SLEEP_SECONDS].min
20
+ retry_after.to_i.clamp(0, RATE_LIMIT_RETRY_SLEEP_SECONDS)
20
21
  end
21
22
 
22
23
  def initialize(&block)
23
24
  @response = block.call
24
- SlackBot::DevConsole.log_output "#{self.class.name}: #{response.body}"
25
+ if response.respond_to?(:headers)
26
+ @retry_after_header = response.headers["Retry-After"]&.to_i
27
+ end
25
28
  end
26
29
 
27
30
  def ok?
28
- response.status == 200 && data["ok"]
31
+ return false unless response.status == 200
32
+
33
+ b = response.body
34
+ return true if b.is_a?(String) && b.start_with?('{"ok":true')
35
+
36
+ data["ok"]
29
37
  end
30
38
 
31
39
  def error
40
+ b = response.body
41
+ return nil if b.is_a?(String) && !b.include?('"error"')
42
+
32
43
  data["error"]
33
44
  end
34
45
 
35
46
  def rate_limited?
36
- response.status == 429 || (data["ok"] == false && data["error"] == "rate_limited")
47
+ return true if response.status == 429
48
+
49
+ b = response.body
50
+ return false if b.is_a?(String) && b.start_with?('{"ok":true')
51
+
52
+ data_rate_limited_payload
53
+ end
54
+
55
+ def data_rate_limited_payload
56
+ data["ok"] == false && data["error"] == "rate_limited"
37
57
  end
38
58
 
39
59
  def retry_after
60
+ if @retry_after_header.is_a?(Integer) && !@retry_after_header.zero?
61
+ return @retry_after_header
62
+ end
63
+
64
+ return 60 unless response.respond_to?(:headers)
65
+
40
66
  response.headers["Retry-After"]&.to_i || 60
41
67
  end
42
68
 
43
69
  def slack_error?
44
- !ok? && error.present?
70
+ return false if ok?
71
+
72
+ e = error
73
+ e.present? || response.status != 200
45
74
  end
46
75
 
47
76
  def authentication_error?
48
- slack_error? && %w[invalid_auth account_inactive].include?(error)
77
+ return false unless slack_error?
78
+
79
+ e = error
80
+ return false if e.blank?
81
+
82
+ %w[invalid_auth account_inactive].include?(e)
49
83
  end
50
84
 
51
85
  def data
52
- JSON.parse(response.body)
86
+ return @data if instance_variable_defined?(:@data)
87
+
88
+ @data = JSON.parse(response.body)
53
89
  rescue JSON::ParserError => e
54
90
  SlackBot::Logger.error("Failed to parse Slack API response: #{e.message}")
55
- {"ok" => false, "error" => "invalid_json_response"}
91
+ @data = {"ok" => false, "error" => "invalid_json_response"}
56
92
  end
57
93
  end
58
94
 
@@ -73,69 +109,72 @@ module SlackBot
73
109
  end
74
110
 
75
111
  def views_open(trigger_id:, view:)
76
- perform_request { client.post("views.open", {trigger_id: trigger_id, view: view}.to_json) }
112
+ perform_request { client.post("views.open", JSON.generate(trigger_id: trigger_id, view: view)) }
77
113
  end
78
114
 
79
115
  def views_update(view_id:, view:)
80
- perform_request { client.post("views.update", {view_id: view_id, view: view}.to_json) }
116
+ perform_request { client.post("views.update", JSON.generate(view_id: view_id, view: view)) }
81
117
  end
82
118
 
83
119
  def chat_post_message(channel:, text:, blocks:)
84
- perform_request { client.post("chat.postMessage", {channel: channel, text: text, blocks: blocks}.to_json) }
120
+ perform_request { client.post("chat.postMessage", JSON.generate(channel: channel, text: text, blocks: blocks)) }
85
121
  end
86
122
 
87
123
  def chat_update(channel:, ts:, text:, blocks:)
88
- perform_request { client.post("chat.update", {channel: channel, ts: ts, text: text, blocks: blocks}.to_json) }
124
+ perform_request { client.post("chat.update", JSON.generate(channel: channel, ts: ts, text: text, blocks: blocks)) }
89
125
  end
90
126
 
91
127
  def chat_delete(channel:, ts:)
92
- perform_request { client.post("chat.delete", {channel: channel, ts: ts}.to_json) }
128
+ perform_request { client.post("chat.delete", JSON.generate(channel: channel, ts: ts)) }
93
129
  end
94
130
 
95
131
  def chat_unfurl(channel:, ts:, unfurls:, source: nil, unfurl_id: nil, user_auth_blocks: nil, user_auth_message: nil, user_auth_required: nil, user_auth_url: nil)
96
132
  perform_request do
97
- client.post("chat.unfurl", {
98
- channel: channel,
99
- ts: ts,
100
- unfurls: unfurls,
101
- source: source,
102
- unfurl_id: unfurl_id,
103
- user_auth_blocks: user_auth_blocks,
104
- user_auth_message: user_auth_message,
105
- user_auth_required: user_auth_required,
106
- user_auth_url: user_auth_url
107
- }.to_json)
133
+ client.post(
134
+ "chat.unfurl",
135
+ JSON.generate(
136
+ channel: channel,
137
+ ts: ts,
138
+ unfurls: unfurls,
139
+ source: source,
140
+ unfurl_id: unfurl_id,
141
+ user_auth_blocks: user_auth_blocks,
142
+ user_auth_message: user_auth_message,
143
+ user_auth_required: user_auth_required,
144
+ user_auth_url: user_auth_url
145
+ )
146
+ )
108
147
  end
109
148
  end
110
149
 
111
150
  def chat_schedule_message(channel:, text:, post_at:, blocks: nil)
112
- perform_request { client.post("chat.scheduleMessage", {channel: channel, text: text, post_at: post_at, blocks: blocks}.to_json) }
151
+ perform_request { client.post("chat.scheduleMessage", JSON.generate(channel: channel, text: text, post_at: post_at, blocks: blocks)) }
113
152
  end
114
153
 
115
154
  def scheduled_messages_list(channel: nil, cursor: nil, latest: nil, limit: nil, oldest: nil, team_id: nil)
116
155
  args = compact_payload(channel: channel, cursor: cursor, limit: limit, latest: latest, oldest: oldest, team_id: team_id)
117
- perform_request { client.post("scheduled_messages.list", args.to_json) }
156
+ perform_request { client.post("scheduled_messages.list", JSON.generate(args)) }
118
157
  end
119
158
 
120
159
  def chat_delete_scheduled_message(channel:, scheduled_message_id:)
121
- perform_request { client.post("chat.deleteScheduledMessage", {channel: channel, scheduled_message_id: scheduled_message_id}.to_json) }
160
+ perform_request { client.post("chat.deleteScheduledMessage", JSON.generate(channel: channel, scheduled_message_id: scheduled_message_id)) }
122
161
  end
123
162
 
124
163
  def chat_get_permalink(channel:, message_ts:)
125
- perform_request { client.post("chat.getPermalink", {channel: channel, message_ts: message_ts}.to_json) }
164
+ perform_request { client.post("chat.getPermalink", JSON.generate(channel: channel, message_ts: message_ts)) }
126
165
  end
127
166
 
128
167
  def users_info(user_id:)
129
- perform_request { client.post("users.info", {user: user_id}.to_json) }
168
+ perform_request { client.post("users.info", JSON.generate(user: user_id)) }
130
169
  end
131
170
 
132
171
  def views_publish(user_id:, view:)
133
- perform_request { client.post("views.publish", {user_id: user_id, view: view}.to_json) }
172
+ perform_request { client.post("views.publish", JSON.generate(user_id: user_id, view: view)) }
134
173
  end
135
174
 
136
175
  def users_list(cursor: nil, limit: 200, include_locale: nil, team_id: nil)
137
176
  args = compact_payload(cursor: cursor, limit: limit, include_locale: include_locale, team_id: team_id)
138
- perform_request { client.post("users.list", args.to_json) }
177
+ perform_request { client.post("users.list", JSON.generate(args)) }
139
178
  end
140
179
 
141
180
  def chat_post_ephemeral(channel:, user:, text:, as_user: nil, attachments: nil, blocks: nil, icon_emoji: nil, icon_url: nil, link_names: nil, parse: nil, thread_ts: nil, username: nil)
@@ -153,7 +192,7 @@ module SlackBot
153
192
  thread_ts: thread_ts,
154
193
  username: username
155
194
  )
156
- perform_request { client.post("chat.postEphemeral", args.to_json) }
195
+ perform_request { client.post("chat.postEphemeral", JSON.generate(args)) }
157
196
  end
158
197
 
159
198
  private
@@ -24,6 +24,8 @@ module SlackBot
24
24
  end
25
25
 
26
26
  def verify_slack_signature!
27
+ remove_instance_variable(:@request_body_content) if instance_variable_defined?(:@request_body_content)
28
+
27
29
  slack_signing_secret = ENV["SLACK_SIGNING_SECRET"]
28
30
  timestamp = slack_request_header("x-slack-request-timestamp", "X-Slack-Request-Timestamp")
29
31
  slack_signature = slack_request_header("x-slack-signature", "X-Slack-Signature")
@@ -95,7 +97,7 @@ module SlackBot
95
97
  end
96
98
 
97
99
  def validate_request_timestamp!(timestamp)
98
- request_timestamp = timestamp.to_i
100
+ request_timestamp = Integer(timestamp, 10)
99
101
  current_timestamp = Time.now.to_i
100
102
  return if (current_timestamp - request_timestamp).abs <= TIMESTAMP_TOLERANCE_SECONDS
101
103
 
@@ -103,23 +105,25 @@ module SlackBot
103
105
  end
104
106
 
105
107
  def verify_signature_match!(slack_signing_secret, timestamp, slack_signature)
106
- return true if ActiveSupport::SecurityUtils.secure_compare(computed_signature(slack_signing_secret, timestamp), slack_signature)
108
+ body = request_body_content
109
+ sig_basestring = "v0:#{timestamp}:#{body}"
110
+ expected_digest_bin = OpenSSL::HMAC.digest("SHA256", slack_signing_secret, sig_basestring)
111
+ actual_hex = slack_signature.delete_prefix("v0=")
112
+ return true if ActiveSupport::SecurityUtils.secure_compare(expected_digest_bin, [actual_hex].pack("H*"))
107
113
 
108
114
  raise SlackBot::Errors::SignatureAuthenticationError.new("Signature mismatch")
109
115
  end
110
116
 
111
- def computed_signature(slack_signing_secret, timestamp)
112
- sig_basestring = "v0:#{timestamp}:#{request_body_content}"
113
- "v0=" + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), slack_signing_secret, sig_basestring)
114
- end
115
-
116
117
  def request_body_content
117
- return "" unless request.body
118
+ return @request_body_content if instance_variable_defined?(:@request_body_content)
119
+
120
+ return @request_body_content = "" unless request.body
118
121
 
119
122
  request.body.rewind if request.body.respond_to?(:rewind)
120
123
  body_content = request.body.read
121
124
  request.body.rewind if request.body.respond_to?(:rewind)
122
- body_content
125
+
126
+ @request_body_content = body_content
123
127
  end
124
128
 
125
129
  def parse_interaction_payload!(raw_payload)
@@ -152,6 +156,9 @@ module SlackBot
152
156
  base.content_type :json, "application/json"
153
157
  base.use ActionDispatch::RemoteIp if defined?(ActionDispatch::RemoteIp)
154
158
  base.helpers SlackBot::GrapeHelpers
159
+ base.rescue_from SlackBot::Errors::SignatureAuthenticationError do |e|
160
+ error!({error: e.message}, 401)
161
+ end
155
162
  base.rescue_from SlackBot::Error do |e|
156
163
  error!({error: e.message}, 200)
157
164
  end
data/lib/slack_bot.rb CHANGED
@@ -25,5 +25,5 @@ require "slack_bot/pager"
25
25
  require "slack_bot/grape_extension"
26
26
 
27
27
  module SlackBot
28
- VERSION = "2.1.2".freeze
28
+ VERSION = "2.1.3".freeze
29
29
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: grape-slack-bot
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.1.2
4
+ version: 2.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Makarov
@@ -117,14 +117,14 @@ dependencies:
117
117
  requirements:
118
118
  - - ">="
119
119
  - !ruby/object:Gem::Version
120
- version: 2.2.0
120
+ version: 2.2.9
121
121
  type: :development
122
122
  prerelease: false
123
123
  version_requirements: !ruby/object:Gem::Requirement
124
124
  requirements:
125
125
  - - ">="
126
126
  - !ruby/object:Gem::Version
127
- version: 2.2.0
127
+ version: 2.2.9
128
128
  - !ruby/object:Gem::Dependency
129
129
  name: webmock
130
130
  requirement: !ruby/object:Gem::Requirement