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