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 +4 -4
- data/CHANGELOG.md +12 -0
- data/grape-slack-bot.gemspec +1 -1
- data/lib/slack_bot/api_client.rb +71 -32
- data/lib/slack_bot/grape_extension.rb +16 -9
- data/lib/slack_bot.rb +1 -1
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c3e52396043a337cfa348d788ca11ea7d6b76c8486d080a2a0d43c69cf73ad4e
|
|
4
|
+
data.tar.gz: a20fd5e43a9695978b06250cd66a9c13bc2732b9246c9a1a8685e1986b9302f1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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`
|
data/grape-slack-bot.gemspec
CHANGED
|
@@ -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.
|
|
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"
|
data/lib/slack_bot/api_client.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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(
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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",
|
|
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
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
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.
|
|
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.
|
|
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.
|
|
127
|
+
version: 2.2.9
|
|
128
128
|
- !ruby/object:Gem::Dependency
|
|
129
129
|
name: webmock
|
|
130
130
|
requirement: !ruby/object:Gem::Requirement
|