grape-slack-bot 1.8.2 → 2.0.1

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.
@@ -1,8 +1,14 @@
1
1
  require "active_support"
2
2
  require "active_support/core_ext/object"
3
+ require "active_support/security_utils"
3
4
 
4
5
  module SlackBot
5
6
  module GrapeHelpers
7
+ # Slack recommends rejecting requests older than 5 minutes
8
+ TIMESTAMP_TOLERANCE_SECONDS = 300
9
+ # Minimum length for Slack signing secret (Slack's requirement)
10
+ MIN_SIGNING_SECRET_LENGTH = 32
11
+
6
12
  def fetch_team_id
7
13
  params.dig("team_id") || params.dig("team", "id")
8
14
  end
@@ -13,32 +19,12 @@ module SlackBot
13
19
 
14
20
  def verify_slack_signature!
15
21
  slack_signing_secret = ENV["SLACK_SIGNING_SECRET"]
16
- timestamp = request.headers["x-slack-request-timestamp"] || request.headers["X-Slack-Request-Timestamp"]
17
- slack_signature = request.headers["x-slack-signature"] || request.headers["X-Slack-Signature"]
18
- if slack_signing_secret.blank? || timestamp.blank? || slack_signature.blank?
19
- raise SlackBot::Errors::SignatureAuthenticationError.new("Missing signature headers")
20
- end
22
+ timestamp = slack_request_header("x-slack-request-timestamp", "X-Slack-Request-Timestamp")
23
+ slack_signature = slack_request_header("x-slack-signature", "X-Slack-Signature")
21
24
 
22
- request.body.rewind
23
- request_body = request.body.read
24
- request.body.rewind
25
-
26
- sig_basestring = "v0:#{timestamp}:#{request_body}"
27
- my_signature =
28
- "v0=" +
29
- OpenSSL::HMAC.hexdigest(
30
- OpenSSL::Digest.new("sha256"),
31
- slack_signing_secret,
32
- sig_basestring
33
- )
34
- if ActiveSupport::SecurityUtils.secure_compare(
35
- my_signature,
36
- slack_signature
37
- )
38
- true
39
- else
40
- raise SlackBot::Errors::SignatureAuthenticationError.new("Signature mismatch")
41
- end
25
+ validate_signature_headers!(slack_signing_secret, timestamp, slack_signature)
26
+ validate_request_timestamp!(timestamp)
27
+ verify_signature_match!(slack_signing_secret, timestamp, slack_signature)
42
28
  end
43
29
 
44
30
  def verify_slack_team!
@@ -61,11 +47,9 @@ module SlackBot
61
47
  end
62
48
 
63
49
  def verify_current_user!
64
- if current_user
65
- true
66
- else
67
- raise SlackBot::Errors::UserAuthenticationError.new("User is not authorized")
68
- end
50
+ return true if current_user
51
+
52
+ raise SlackBot::Errors::UserAuthenticationError.new("User is not authorized")
69
53
  end
70
54
 
71
55
  def events_callback(params)
@@ -73,7 +57,7 @@ module SlackBot
73
57
 
74
58
  SlackBot::DevConsole.log_input "SlackApi::Events#events_callback: #{params.inspect}"
75
59
  handler = config.find_event_handler(params[:event][:type].to_sym)
76
- return if handler.blank?
60
+ return false if handler.blank?
77
61
 
78
62
  event = handler.new(params: params, current_user: current_user)
79
63
  event.call
@@ -84,36 +68,124 @@ module SlackBot
84
68
  {challenge: params[:challenge]}
85
69
  end
86
70
 
71
+ def validate_callback_user!(callback, user)
72
+ if callback.user_id != user.id
73
+ raise SlackBot::Errors::CallbackUserMismatchError.new("Callback user is not equal to action user")
74
+ end
75
+ end
76
+
87
77
  def handle_block_actions_view(view:, user:, params:)
88
- callback_id = view&.dig("callback_id")
78
+ callback = find_callback!(view: view, user: user)
79
+ log_callback_check(callback, user)
80
+ validate_callback_user!(callback, user)
81
+
82
+ interaction_klass = callback_interaction_klass(callback)
83
+ return false if interaction_klass.blank?
84
+
85
+ interaction_klass.new(current_user: user, params: params, callback: callback, config: config).call
86
+ end
87
+
88
+ private
89
+
90
+ def slack_request_header(*names)
91
+ names.each do |name|
92
+ header = request.headers[name]
93
+ return header if header
94
+ end
95
+
96
+ nil
97
+ end
98
+
99
+ def validate_signature_headers!(slack_signing_secret, timestamp, slack_signature)
100
+ raise SlackBot::Errors::SignatureAuthenticationError.new("Missing signature headers") if slack_signing_secret.blank? || timestamp.blank? || slack_signature.blank?
101
+ return if slack_signing_secret.start_with?("test_") || slack_signing_secret.length >= MIN_SIGNING_SECRET_LENGTH
102
+
103
+ raise SlackBot::Errors::SignatureAuthenticationError.new("Invalid signing secret format")
104
+ end
105
+
106
+ def validate_request_timestamp!(timestamp)
107
+ request_timestamp = timestamp.to_i
108
+ current_timestamp = Time.now.to_i
109
+ return if (current_timestamp - request_timestamp).abs <= TIMESTAMP_TOLERANCE_SECONDS
110
+
111
+ raise SlackBot::Errors::SignatureAuthenticationError.new("Request timestamp too old")
112
+ end
89
113
 
90
- callback = SlackBot::Callback.find(callback_id, user: user, config: config)
114
+ def verify_signature_match!(slack_signing_secret, timestamp, slack_signature)
115
+ return true if ActiveSupport::SecurityUtils.secure_compare(computed_signature(slack_signing_secret, timestamp), slack_signature)
116
+
117
+ raise SlackBot::Errors::SignatureAuthenticationError.new("Signature mismatch")
118
+ end
119
+
120
+ def computed_signature(slack_signing_secret, timestamp)
121
+ sig_basestring = "v0:#{timestamp}:#{request_body_content}"
122
+ "v0=" + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), slack_signing_secret, sig_basestring)
123
+ end
124
+
125
+ def request_body_content
126
+ return "" unless request.body
127
+
128
+ request.body.rewind if request.body.respond_to?(:rewind)
129
+ body_content = request.body.read
130
+ request.body.rewind if request.body.respond_to?(:rewind)
131
+ body_content
132
+ end
133
+
134
+ def find_callback!(view:, user:)
135
+ callback = SlackBot::Callback.find(view&.dig("callback_id"), user: user, config: config)
91
136
  raise SlackBot::Errors::CallbackNotFound.new if callback.blank?
92
137
 
138
+ callback
139
+ end
140
+
141
+ def log_callback_check(callback, user)
93
142
  SlackBot::DevConsole.log_check "SlackApi::Interactions##{__method__}: #{callback.id} #{callback.payload} #{callback.user_id} #{user&.id}"
143
+ end
94
144
 
95
- if callback.user_id != user.id
96
- raise "Callback user is not equal to action user"
97
- end
145
+ def callback_interaction_klass(callback)
146
+ handler_class_obj = callback.handler_class
147
+ handler_class_obj&.interaction_klass if handler_class_obj&.respond_to?(:interaction_klass)
148
+ end
98
149
 
99
- interaction_klass = callback.handler_class&.interaction_klass
100
- return if interaction_klass.blank?
150
+ def parse_interaction_payload!(raw_payload)
151
+ JSON.parse(raw_payload)
152
+ rescue JSON::ParserError => e
153
+ raise SlackBot::Errors::InvalidPayloadError.new("Invalid JSON payload: #{e.message}")
154
+ end
101
155
 
102
- interaction_klass.new(current_user: user, params: params, callback: callback, config: config).call
156
+ def resolve_action_user(payload)
157
+ resolve_user_session(payload.dig("user", "team_id"), payload.dig("user", "id"))
158
+ end
159
+
160
+ def blank_slack_response!
161
+ body false
162
+ status 200
103
163
  end
104
164
  end
105
165
 
106
166
  module GrapeExtension
107
167
  def self.included(base)
168
+ configure_base!(base)
169
+ add_commands_resource!(base)
170
+ add_interactions_resource!(base)
171
+ add_events_resource!(base)
172
+ add_menu_options_resource!(base)
173
+ end
174
+
175
+ def self.configure_base!(base)
108
176
  base.format :json
109
177
  base.content_type :json, "application/json"
110
- base.use ActionDispatch::RemoteIp
178
+ base.use ActionDispatch::RemoteIp if defined?(ActionDispatch::RemoteIp)
111
179
  base.helpers SlackBot::GrapeHelpers
112
-
180
+ base.rescue_from SlackBot::Error do |e|
181
+ error!({error: e.message}, 200)
182
+ end
113
183
  base.before do
114
184
  verify_slack_signature!
115
185
  end
186
+ end
116
187
 
188
+ def self.add_commands_resource!(base)
117
189
  base.resource :commands do
118
190
  post ":url_token" do
119
191
  command_config = config.find_slash_command_config(params[:url_token], params[:command], params[:text])
@@ -123,79 +195,68 @@ module SlackBot
123
195
  args = params[:text].gsub(/^#{command_config.full_token}\s?/, "")
124
196
  SlackBot::DevConsole.log_input "SlackApi::SlashCommands#post: #{command_config.url_token} | #{command_config.full_token} | #{args}"
125
197
 
126
- action =
127
- command_klass.new(
128
- current_user: current_user,
129
- params: params,
130
- args: args,
131
- config: config
132
- )
198
+ action = command_klass.new(current_user: current_user, params: params, args: args, config: config)
133
199
  verify_slack_team! if action.only_slack_team?
134
200
  verify_direct_message_channel! if action.only_direct_message?
135
201
  verify_current_user! if action.only_user?
136
202
 
137
203
  result = action.call
138
- return body false if !result
204
+ return blank_slack_response! unless result
139
205
 
140
206
  result
141
207
  end
142
208
  end
209
+ end
143
210
 
211
+ def self.add_interactions_resource!(base)
144
212
  base.resource :interactions do
145
213
  post do
146
- payload = JSON.parse(params[:payload])
214
+ payload = parse_interaction_payload!(params[:payload])
215
+ action_user = resolve_action_user(payload)&.user
147
216
 
148
- action_user_session =
149
- resolve_user_session(
150
- payload.dig("user", "team_id"),
151
- payload.dig("user", "id")
152
- )
153
- action_user = action_user_session&.user
154
-
155
- action_type = payload["type"]
156
- result = case action_type
217
+ result = case payload["type"]
157
218
  when "block_actions", "view_submission"
158
- handle_block_actions_view(
159
- view: payload["view"],
160
- user: action_user,
161
- params: params
162
- )
219
+ handle_block_actions_view(view: payload["view"], user: action_user, params: params)
163
220
  else
164
- raise "Unknown action type: #{action_type}"
221
+ raise SlackBot::Errors::UnknownActionTypeError.new(payload["type"])
165
222
  end
166
223
 
167
- return body false if result.blank?
224
+ return blank_slack_response! if result.blank? || result == false
168
225
 
169
226
  result
170
227
  end
171
228
  end
229
+ end
172
230
 
231
+ def self.add_events_resource!(base)
173
232
  base.resource :events do
174
233
  post do
175
- result =
176
- case params[:type]
177
- when "url_verification"
178
- url_verification(params)
179
- when "event_callback"
180
- events_callback(params)
181
- end
234
+ result = case params[:type]
235
+ when "url_verification"
236
+ url_verification(params)
237
+ when "event_callback"
238
+ events_callback(params)
239
+ else
240
+ raise SlackBot::Errors::UnknownActionTypeError.new(params[:type])
241
+ end
182
242
 
183
- return body false if result.blank?
243
+ return blank_slack_response! if result.blank? || result == false
184
244
 
185
245
  result
186
246
  end
187
247
  end
248
+ end
188
249
 
250
+ def self.add_menu_options_resource!(base)
189
251
  base.resource :menu_options do
190
252
  get do
191
253
  SlackBot::DevConsole.log_input "SlackApi::MenuOptions#get: #{params.inspect}"
192
254
 
193
- action_id = params[:action_id]
194
- menu_options_klass = config.find_menu_options(action_id)
255
+ menu_options_klass = config.find_menu_options(params[:action_id])
195
256
  raise SlackBot::Errors::MenuOptionsNotImplemented.new if menu_options_klass.blank?
196
257
 
197
258
  menu_options = menu_options_klass.new(current_user: current_user, params: params, config: config).call
198
- return body false if menu_options.blank?
259
+ return blank_slack_response! if menu_options.blank?
199
260
 
200
261
  menu_options
201
262
  end
@@ -10,55 +10,20 @@ module SlackBot
10
10
  include SlackBot::Concerns::ViewKlass
11
11
 
12
12
  def self.open_modal(callback:, trigger_id:, view:)
13
- view = view.merge({type: "modal", callback_id: callback&.id})
14
- response =
15
- SlackBot::ApiClient.new.views_open(trigger_id: trigger_id, view: view)
16
-
17
- if !response.ok?
18
- raise SlackBot::Errors::OpenModalError.new(response.error, data: response.data, payload: view)
19
- end
20
-
21
- view_id = response.data.dig("view", "id")
22
- if callback.present? && view_id.present?
23
- callback.view_id = view_id
24
- callback.save
25
- end
26
- SlackViewsReply.new(callback&.id, view_id)
13
+ view = modal_payload(callback, view)
14
+ response = SlackBot::ApiClient.new.views_open(trigger_id: trigger_id, view: view)
15
+ build_view_reply(response: response, callback: callback, payload: view, error_class: SlackBot::Errors::OpenModalError)
27
16
  end
28
17
 
29
18
  def self.update_modal(callback:, view_id:, view:)
30
- view = view.merge({type: "modal", callback_id: callback&.id})
31
- response =
32
- SlackBot::ApiClient.new.views_update(view_id: view_id, view: view)
33
-
34
- if !response.ok?
35
- raise SlackBot::Errors::UpdateModalError.new(response.error, data: response.data, payload: view)
36
- end
37
-
38
- view_id = response.data.dig("view", "id")
39
- if callback.present? && view_id.present?
40
- callback.view_id = view_id
41
- callback.save
42
- end
43
- SlackViewsReply.new(callback&.id, view_id)
19
+ view = modal_payload(callback, view)
20
+ response = SlackBot::ApiClient.new.views_update(view_id: view_id, view: view)
21
+ build_view_reply(response: response, callback: callback, payload: view, error_class: SlackBot::Errors::UpdateModalError)
44
22
  end
45
23
 
46
24
  def self.publish_view(user_id:, view:, callback: nil, metadata: nil)
47
- view = view.merge(callback_id: callback.id) if callback.present?
48
- view = view.merge(private_metadata: metadata) if metadata.present?
49
- response =
50
- SlackBot::ApiClient.new.views_publish(user_id: user_id, view: view)
51
-
52
- if !response.ok?
53
- raise SlackBot::Errors::PublishViewError.new(response.error, data: response.data, payload: view)
54
- end
55
-
56
- view_id = response.data.dig("view", "id")
57
- if callback.present? && view_id.present?
58
- callback.view_id = view_id
59
- callback.save
60
- end
61
- SlackViewsReply.new(callback&.id, view_id)
25
+ response = SlackBot::ApiClient.new.views_publish(user_id: user_id, view: publish_payload(callback, metadata, view))
26
+ build_view_reply(response: response, callback: callback, payload: view, error_class: SlackBot::Errors::PublishViewError)
62
27
  end
63
28
 
64
29
  attr_reader :current_user, :params, :callback, :config
@@ -73,6 +38,31 @@ module SlackBot
73
38
  nil
74
39
  end
75
40
 
41
+ def self.modal_payload(callback, view)
42
+ view.merge(type: "modal", callback_id: callback&.id)
43
+ end
44
+
45
+ def self.publish_payload(callback, metadata, view)
46
+ view = view.merge(callback_id: callback.id) if callback.present?
47
+ view = view.merge(private_metadata: metadata) if metadata.present?
48
+ view
49
+ end
50
+
51
+ def self.build_view_reply(response:, callback:, payload:, error_class:)
52
+ raise error_class.new(response.error, data: response.data, payload: payload) unless response.ok?
53
+
54
+ view_id = response.data.dig("view", "id")
55
+ persist_view_id(callback, view_id)
56
+ SlackViewsReply.new(callback&.id, view_id)
57
+ end
58
+
59
+ def self.persist_view_id(callback, view_id)
60
+ return unless callback.present? && view_id.present?
61
+
62
+ callback.view_id = view_id
63
+ callback.save
64
+ end
65
+
76
66
  private
77
67
 
78
68
  def interaction_type
@@ -149,7 +139,11 @@ module SlackBot
149
139
  end
150
140
 
151
141
  def payload
152
- @payload ||= JSON.parse(params[:payload])
142
+ @payload ||= begin
143
+ JSON.parse(params[:payload])
144
+ rescue JSON::ParserError => e
145
+ raise SlackBot::Errors::InvalidPayloadError.new("Invalid JSON payload: #{e.message}")
146
+ end
153
147
  end
154
148
  end
155
149
  end
@@ -4,5 +4,20 @@ module SlackBot
4
4
  puts args.inspect if args.any?
5
5
  puts kwargs.inspect if kwargs.any?
6
6
  end
7
+
8
+ def error(*args, **kwargs)
9
+ puts args.inspect if args.any?
10
+ puts kwargs.inspect if kwargs.any?
11
+ end
12
+
13
+ def warn(*args, **kwargs)
14
+ puts args.inspect if args.any?
15
+ puts kwargs.inspect if kwargs.any?
16
+ end
17
+
18
+ def debug(*args, **kwargs)
19
+ puts args.inspect if args.any?
20
+ puts kwargs.inspect if kwargs.any?
21
+ end
7
22
  end
8
23
  end
@@ -6,5 +6,9 @@ module SlackBot
6
6
  @params = params
7
7
  @config = config || SlackBot::Config.current_instance
8
8
  end
9
+
10
+ def call
11
+ nil
12
+ end
9
13
  end
10
14
  end
@@ -22,14 +22,18 @@ module SlackBot
22
22
  super
23
23
  end
24
24
 
25
+ def respond_to_missing?(method_name, include_private = false)
26
+ (@context.is_a?(Hash) && @context.key?(method_name.to_sym)) || super
27
+ end
28
+
25
29
  def text_modal
26
30
  {
27
31
  title: {
28
32
  type: "plain_text",
29
- text: context[:title]
33
+ text: context&.dig(:title) || ""
30
34
  },
31
35
  blocks: [
32
- {type: "section", text: {type: "mrkdwn", text: context[:text]}}
36
+ {type: "section", text: {type: "mrkdwn", text: context&.dig(:text) || ""}}
33
37
  ]
34
38
  }
35
39
  end
data/lib/slack_bot.rb CHANGED
@@ -1,3 +1,6 @@
1
+ require "json"
2
+ require "openssl"
3
+
1
4
  require "slack_bot/logger"
2
5
  require "slack_bot/dev_console"
3
6
 
@@ -22,5 +25,5 @@ require "slack_bot/pager"
22
25
  require "slack_bot/grape_extension"
23
26
 
24
27
  module SlackBot
25
- VERSION = "1.8.2".freeze
28
+ VERSION = "2.0.1".freeze
26
29
  end