grape-slack-bot 2.0.1 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4d79cd991f4efaa9ae3319b25d73e0addde71aa04d59369236225f1bb97e0b4e
4
- data.tar.gz: 402ede62474860b3009c44107f7e54fba9de531c992315eba021bc84a3a2c21e
3
+ metadata.gz: 9361a3a86619e251da1df7649973cdd911619f2b55f18d5d622f8a1e6d5ee707
4
+ data.tar.gz: ebd8c29c0f6957bbaa0cf1fefbce369f7072b60d4b01cbae4037fa7a5fe3212b
5
5
  SHA512:
6
- metadata.gz: c1bf71eedfc65e15481d6f06ed038bfd637f1ef0784c8040a482386f20087c98c53c435bd43f3b62eed9ac82482c36a35c70f87920e40372eefca45d2f783243
7
- data.tar.gz: daaded041bc84bac15c0c20bc04709df914ab895b222244d05d1aef9f568e7b63ed1ce6475a0a027a8601bcf597a91cb1a97ef1a7e840456f49c3e5104e4549c
6
+ metadata.gz: 9f4bf3f86997214af675d7a21c40c4270fbb3e8c93093dbdacabf08da89a4ed054221e27345702e4e48da4c945ec12ef4291c7c46a412ee7a02d82fadd6e1db5
7
+ data.tar.gz: a2c0ea8a963da6d8aff07dca5bdb90c517e1ae44a64b34b5f1e70903279b33239f6182b1bd8ddef22de17d45c7eb0dda86f101d3ed0cfd4461ea080357e11db2
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 2.1.0 (2026-07-10)
4
+
5
+ - Add `block_action` registration for message-level block action buttons outside modal callbacks
6
+ - Add `user_session_resolver` and `event_dispatcher` configuration hooks for session lookup and background event dispatch
7
+ - Acknowledge Slack event retries with an empty 200 response so duplicate deliveries do not run handlers twice
8
+ - Skip `bot_message` event payloads at ingress to avoid handler loops from bot echoes
9
+ - Retry Slack API requests once when rate limited, sleeping at most 3 seconds before the retry
10
+ - Fix `scheduled_messages_list` to send the `channel` argument to the Slack API
11
+ - Fix team verification when `SLACK_TEAM_ID` is unset so requests are rejected instead of matching any team
12
+ - Raise `SlackBot::Errors::ConfigurationError` when required configuration is missing
13
+ - Raise `SlackBot::Errors::BlockActionNotImplemented` when an unregistered block action is submitted
14
+ - Require `faraday` `>= 2.14.3`
15
+
3
16
  ## 2.0.1 (2026-05-31)
4
17
 
5
18
  - Allow `grape` 3.x by expanding the runtime dependency to `< 4.0`
data/README.md CHANGED
@@ -55,10 +55,18 @@ SlackBot::DevConsole.enabled = Rails.env.development?
55
55
  SlackBot::Config.configure do
56
56
  callback_storage Rails.cache
57
57
  callback_user_finder ->(id) { User.active.find_by(id: id) }
58
+ user_session_resolver ->(team_id, user_id) do
59
+ uid = OmniAuth::Strategies::SlackOpenid.generate_uid(team_id, user_id)
60
+ UserSession.find_by(uid: uid, provider: UserSession.slack_openid_provider)
61
+ end
62
+ event_dispatcher ->(handler:, params:, current_user:) do
63
+ SlackEventJob.perform_later(handler.name, params.deep_stringify_keys, current_user&.id)
64
+ end
58
65
 
59
66
  # Register event handlers
60
67
  event :app_home_opened, MySlackBot::AppHomeOpenedEvent
61
68
  interaction MySlackBot::AppHomeInteraction
69
+ block_action :approve_request, MySlackBot::ApproveRequestInteraction
62
70
 
63
71
  # Register slash command handlers
64
72
  slash_command_endpoint :game, MySlackBot::Game::MenuCommand do
@@ -74,11 +82,6 @@ class SlackBotApi < Grape::API
74
82
  SlackBot::Config.current_instance
75
83
  end
76
84
 
77
- def resolve_user_session(team_id, user_id)
78
- uid = OmniAuth::Strategies::SlackOpenid.generate_uid(team_id, user_id)
79
- UserSession.find_by(uid: uid, provider: UserSession.slack_openid_provider)
80
- end
81
-
82
85
  def current_user_session
83
86
  # NOTE: fetch_team_id and fetch_user_id are provided by SlackBot::GrapeHelpers
84
87
  @current_user_session ||=
@@ -23,19 +23,18 @@ Gem::Specification.new do |gem|
23
23
  "rubygems_mfa_required" => "true"
24
24
  }
25
25
 
26
- gem.executables = Dir.glob("bin/*").map { |f| File.basename(f) }
27
- gem.files = Dir.glob("lib/**/*.rb") + Dir.glob("bin/**/*") + Dir.glob("sig/**/*.rbs") + root_files
26
+ gem.files = Dir.glob("lib/**/*.rb") + Dir.glob("sig/**/*.rbs") + root_files
28
27
 
29
28
  gem.required_ruby_version = ">= 3"
30
29
  gem.require_paths = ["lib"]
31
30
 
32
31
  gem.add_runtime_dependency "rack", "~> 3.0"
33
32
  gem.add_runtime_dependency "grape", ">= 1.6", "< 4.0"
34
- gem.add_runtime_dependency "faraday", "~> 2.0"
33
+ gem.add_runtime_dependency "faraday", ">= 2.14.3", "< 3.0"
35
34
  gem.add_runtime_dependency "activesupport", ">= 6.1", "< 9.0"
36
35
 
37
36
  gem.add_development_dependency "rspec", "~> 3"
38
- gem.add_development_dependency "polyrun", "~> 1.4.2"
37
+ gem.add_development_dependency "polyrun", "~> 1.5.0"
39
38
  gem.add_development_dependency "webmock", "~> 3"
40
39
  gem.add_development_dependency "rake", "~> 13"
41
40
  gem.add_development_dependency "standard", "~> 1.52"
@@ -45,6 +44,7 @@ Gem::Specification.new do |gem|
45
44
  gem.add_development_dependency "rubocop-rspec", "~> 3.8"
46
45
  gem.add_development_dependency "rubocop-thread_safety", "~> 0.7"
47
46
  gem.add_development_dependency "appraisal", "~> 2"
47
+ gem.add_development_dependency "bundler-audit", "~> 0.9"
48
48
  gem.add_development_dependency "memory_profiler", "~> 1"
49
49
  gem.add_development_dependency "rbs", "~> 3"
50
50
  gem.add_development_dependency "rack-test", "~> 2"
@@ -2,7 +2,23 @@ require "faraday"
2
2
 
3
3
  module SlackBot
4
4
  class ApiResponse
5
+ # Cap in-request sleep so rate-limit retries do not hold web workers for Slack's default Retry-After.
6
+ RATE_LIMIT_RETRY_SLEEP_SECONDS = 3
7
+
5
8
  attr_reader :response
9
+
10
+ def self.from_request(&block)
11
+ response = new(&block)
12
+ return response unless response.rate_limited?
13
+
14
+ Kernel.sleep(rate_limit_retry_sleep_seconds(response.retry_after))
15
+ new(&block)
16
+ end
17
+
18
+ def self.rate_limit_retry_sleep_seconds(retry_after)
19
+ [retry_after.to_i, RATE_LIMIT_RETRY_SLEEP_SECONDS].min
20
+ end
21
+
6
22
  def initialize(&block)
7
23
  @response = block.call
8
24
  SlackBot::DevConsole.log_output "#{self.class.name}: #{response.body}"
@@ -57,47 +73,27 @@ module SlackBot
57
73
  end
58
74
 
59
75
  def views_open(trigger_id:, view:)
60
- ApiResponse.new do
61
- client.post("views.open", {trigger_id: trigger_id, view: view}.to_json)
62
- rescue Faraday::Error => e
63
- raise SlackBot::Errors::SlackApiError.new("Network error: #{e.message}")
64
- end
76
+ perform_request { client.post("views.open", {trigger_id: trigger_id, view: view}.to_json) }
65
77
  end
66
78
 
67
79
  def views_update(view_id:, view:)
68
- ApiResponse.new do
69
- client.post("views.update", {view_id: view_id, view: view}.to_json)
70
- rescue Faraday::Error => e
71
- raise SlackBot::Errors::SlackApiError.new("Network error: #{e.message}")
72
- end
80
+ perform_request { client.post("views.update", {view_id: view_id, view: view}.to_json) }
73
81
  end
74
82
 
75
83
  def chat_post_message(channel:, text:, blocks:)
76
- ApiResponse.new do
77
- client.post("chat.postMessage", {channel: channel, text: text, blocks: blocks}.to_json)
78
- rescue Faraday::Error => e
79
- raise SlackBot::Errors::SlackApiError.new("Network error: #{e.message}")
80
- end
84
+ perform_request { client.post("chat.postMessage", {channel: channel, text: text, blocks: blocks}.to_json) }
81
85
  end
82
86
 
83
87
  def chat_update(channel:, ts:, text:, blocks:)
84
- ApiResponse.new do
85
- client.post("chat.update", {channel: channel, ts: ts, text: text, blocks: blocks}.to_json)
86
- rescue Faraday::Error => e
87
- raise SlackBot::Errors::SlackApiError.new("Network error: #{e.message}")
88
- end
88
+ perform_request { client.post("chat.update", {channel: channel, ts: ts, text: text, blocks: blocks}.to_json) }
89
89
  end
90
90
 
91
91
  def chat_delete(channel:, ts:)
92
- ApiResponse.new do
93
- client.post("chat.delete", {channel: channel, ts: ts}.to_json)
94
- rescue Faraday::Error => e
95
- raise SlackBot::Errors::SlackApiError.new("Network error: #{e.message}")
96
- end
92
+ perform_request { client.post("chat.delete", {channel: channel, ts: ts}.to_json) }
97
93
  end
98
94
 
99
95
  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)
100
- ApiResponse.new do
96
+ perform_request do
101
97
  client.post("chat.unfurl", {
102
98
  channel: channel,
103
99
  ts: ts,
@@ -109,73 +105,37 @@ module SlackBot
109
105
  user_auth_required: user_auth_required,
110
106
  user_auth_url: user_auth_url
111
107
  }.to_json)
112
- rescue Faraday::Error => e
113
- raise SlackBot::Errors::SlackApiError.new("Network error: #{e.message}")
114
108
  end
115
109
  end
116
110
 
117
111
  def chat_schedule_message(channel:, text:, post_at:, blocks: nil)
118
- ApiResponse.new do
119
- client.post("chat.scheduleMessage", {channel: channel, text: text, post_at: post_at, blocks: blocks}.to_json)
120
- rescue Faraday::Error => e
121
- raise SlackBot::Errors::SlackApiError.new("Network error: #{e.message}")
122
- end
112
+ perform_request { client.post("chat.scheduleMessage", {channel: channel, text: text, post_at: post_at, blocks: blocks}.to_json) }
123
113
  end
124
114
 
125
115
  def scheduled_messages_list(channel: nil, cursor: nil, latest: nil, limit: nil, oldest: nil, team_id: nil)
126
- ApiResponse.new do
127
- client.post("scheduled_messages.list", {
128
- channel: channel,
129
- cursor: cursor,
130
- latest: latest,
131
- limit: limit,
132
- oldest: oldest,
133
- team_id: team_id
134
- }.to_json)
135
- rescue Faraday::Error => e
136
- raise SlackBot::Errors::SlackApiError.new("Network error: #{e.message}")
137
- end
116
+ 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) }
138
118
  end
139
119
 
140
120
  def chat_delete_scheduled_message(channel:, scheduled_message_id:)
141
- ApiResponse.new do
142
- client.post("chat.deleteScheduledMessage", {channel: channel, scheduled_message_id: scheduled_message_id}.to_json)
143
- rescue Faraday::Error => e
144
- raise SlackBot::Errors::SlackApiError.new("Network error: #{e.message}")
145
- end
121
+ perform_request { client.post("chat.deleteScheduledMessage", {channel: channel, scheduled_message_id: scheduled_message_id}.to_json) }
146
122
  end
147
123
 
148
124
  def chat_get_permalink(channel:, message_ts:)
149
- ApiResponse.new do
150
- client.post("chat.getPermalink", {channel: channel, message_ts: message_ts}.to_json)
151
- rescue Faraday::Error => e
152
- raise SlackBot::Errors::SlackApiError.new("Network error: #{e.message}")
153
- end
125
+ perform_request { client.post("chat.getPermalink", {channel: channel, message_ts: message_ts}.to_json) }
154
126
  end
155
127
 
156
128
  def users_info(user_id:)
157
- ApiResponse.new do
158
- client.post("users.info", {user: user_id}.to_json)
159
- rescue Faraday::Error => e
160
- raise SlackBot::Errors::SlackApiError.new("Network error: #{e.message}")
161
- end
129
+ perform_request { client.post("users.info", {user: user_id}.to_json) }
162
130
  end
163
131
 
164
132
  def views_publish(user_id:, view:)
165
- ApiResponse.new do
166
- client.post("views.publish", {user_id: user_id, view: view}.to_json)
167
- rescue Faraday::Error => e
168
- raise SlackBot::Errors::SlackApiError.new("Network error: #{e.message}")
169
- end
133
+ perform_request { client.post("views.publish", {user_id: user_id, view: view}.to_json) }
170
134
  end
171
135
 
172
136
  def users_list(cursor: nil, limit: 200, include_locale: nil, team_id: nil)
173
137
  args = compact_payload(cursor: cursor, limit: limit, include_locale: include_locale, team_id: team_id)
174
- ApiResponse.new do
175
- client.post("users.list", args.to_json)
176
- rescue Faraday::Error => e
177
- raise SlackBot::Errors::SlackApiError.new("Network error: #{e.message}")
178
- end
138
+ perform_request { client.post("users.list", args.to_json) }
179
139
  end
180
140
 
181
141
  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)
@@ -193,15 +153,19 @@ module SlackBot
193
153
  thread_ts: thread_ts,
194
154
  username: username
195
155
  )
196
- ApiResponse.new do
197
- client.post("chat.postEphemeral", args.to_json)
156
+ perform_request { client.post("chat.postEphemeral", args.to_json) }
157
+ end
158
+
159
+ private
160
+
161
+ def perform_request(&block)
162
+ ApiResponse.from_request do
163
+ block.call
198
164
  rescue Faraday::Error => e
199
165
  raise SlackBot::Errors::SlackApiError.new("Network error: #{e.message}")
200
166
  end
201
167
  end
202
168
 
203
- private
204
-
205
169
  def validate_authorization_token!(authorization_token)
206
170
  raise SlackBot::Errors::SlackApiError.new("Slack bot API token is not set") unless valid_authorization_token?(authorization_token)
207
171
  return if authorization_token.start_with?("test_") || authorization_token.match?(TOKEN_FORMAT)
@@ -94,7 +94,7 @@ module SlackBot
94
94
  def user
95
95
  @user ||= begin
96
96
  user_id = data&.dig(:user_id)
97
- config.callback_user_finder_method.call(user_id) if user_id.present?
97
+ config.callback_user_finder!.call(user_id) if user_id.present?
98
98
  end
99
99
  end
100
100
 
@@ -137,7 +137,7 @@ module SlackBot
137
137
 
138
138
  def handler_class=(handler_class)
139
139
  new_class_name = handler_class&.name
140
- config.find_handler_class(class_name)
140
+ config.find_handler_class(new_class_name)
141
141
 
142
142
  self.class_name = new_class_name
143
143
  end
@@ -162,7 +162,7 @@ module SlackBot
162
162
  def read_view_callback_id
163
163
  return if view_id.blank?
164
164
 
165
- config.callback_storage_instance.read(view_storage_key)
165
+ config.callback_storage!.read(view_storage_key)
166
166
  end
167
167
 
168
168
  private
@@ -196,18 +196,18 @@ module SlackBot
196
196
  end
197
197
 
198
198
  def read_data
199
- config.callback_storage_instance.read(storage_key)
199
+ config.callback_storage!.read(storage_key)
200
200
  end
201
201
 
202
202
  def write_data(data, expires_in: nil)
203
203
  expires_in ||= CALLBACK_RECORD_EXPIRES_IN
204
- config.callback_storage_instance.write(view_storage_key, id, expires_in: expires_in) if view_id.present?
205
- config.callback_storage_instance.write(storage_key, data, expires_in: expires_in)
204
+ config.callback_storage!.write(view_storage_key, id, expires_in: expires_in) if view_id.present?
205
+ config.callback_storage!.write(storage_key, data, expires_in: expires_in)
206
206
  end
207
207
 
208
208
  def delete_data
209
- config.callback_storage_instance.delete(view_storage_key) if view_id.present?
210
- config.callback_storage_instance.delete(storage_key)
209
+ config.callback_storage!.delete(view_storage_key) if view_id.present?
210
+ config.callback_storage!.delete(storage_key)
211
211
  end
212
212
  end
213
213
  end
@@ -17,11 +17,45 @@ module SlackBot
17
17
  @callback_storage_instance = klass
18
18
  end
19
19
 
20
+ def callback_storage!
21
+ return @callback_storage_instance if @callback_storage_instance
22
+
23
+ raise SlackBot::Errors::ConfigurationError.new(
24
+ "callback_storage is not configured; call SlackBot::Config.configure { |config| config.callback_storage ... }"
25
+ )
26
+ end
27
+
20
28
  attr_reader :callback_user_finder_method
21
29
  def callback_user_finder(method_lambda)
22
30
  @callback_user_finder_method = method_lambda
23
31
  end
24
32
 
33
+ def callback_user_finder!
34
+ return @callback_user_finder_method if @callback_user_finder_method
35
+
36
+ raise SlackBot::Errors::ConfigurationError.new(
37
+ "callback_user_finder is not configured; call SlackBot::Config.configure { |config| config.callback_user_finder ... }"
38
+ )
39
+ end
40
+
41
+ attr_reader :user_session_resolver_method
42
+ def user_session_resolver(method_lambda)
43
+ @user_session_resolver_method = method_lambda
44
+ end
45
+
46
+ def user_session_resolver!
47
+ return @user_session_resolver_method if @user_session_resolver_method
48
+
49
+ raise SlackBot::Errors::ConfigurationError.new(
50
+ "user_session_resolver is not configured; call SlackBot::Config.configure { |config| config.user_session_resolver ... }"
51
+ )
52
+ end
53
+
54
+ attr_reader :event_dispatcher_method
55
+ def event_dispatcher(method_lambda)
56
+ @event_dispatcher_method = method_lambda
57
+ end
58
+
25
59
  def interaction(interaction_klass, handler_name: nil)
26
60
  handler_name ||= interaction_klass.name
27
61
  handler_class(handler_name, interaction_klass)
@@ -78,6 +112,17 @@ module SlackBot
78
112
  @menu_options[action_id.to_sym]
79
113
  end
80
114
 
115
+ def block_action(action_id, interaction_klass, handler_name: nil)
116
+ @block_actions ||= {}
117
+ @block_actions[action_id.to_sym] = interaction_klass
118
+ interaction(interaction_klass, handler_name: handler_name)
119
+ end
120
+
121
+ def find_block_action(action_id)
122
+ @block_actions ||= {}
123
+ @block_actions[action_id.to_sym]
124
+ end
125
+
81
126
  def handler_class(class_name, klass)
82
127
  @handler_classes ||= {}
83
128
  return if class_name.nil?
@@ -130,7 +175,10 @@ module SlackBot
130
175
  end
131
176
 
132
177
  def find_command_config(text)
133
- route_key = text.scan(/^(#{routes.keys.join("|")})(?:\s|$)/).flatten.first
178
+ return if routes.blank?
179
+
180
+ route_pattern = routes.keys.map { |route_key| Regexp.escape(route_key) }.join("|")
181
+ route_key = text.scan(/^(#{route_pattern})(?:\s|$)/).flatten.first
134
182
  return if route_key.blank?
135
183
 
136
184
  routes[route_key]
@@ -18,6 +18,12 @@ module SlackBot
18
18
  class MenuOptionsNotImplemented < SlackBot::Error
19
19
  end
20
20
 
21
+ class BlockActionNotImplemented < SlackBot::Error
22
+ def initialize
23
+ super("Block action is not implemented")
24
+ end
25
+ end
26
+
21
27
  class CallbackNotFound < SlackBot::Error
22
28
  end
23
29
 
@@ -82,5 +88,8 @@ module SlackBot
82
88
 
83
89
  class NotImplementedError < SlackBot::Error
84
90
  end
91
+
92
+ class ConfigurationError < SlackBot::Error
93
+ end
85
94
  end
86
95
  end
@@ -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)
@@ -27,15 +33,20 @@ module SlackBot
27
33
  verify_signature_match!(slack_signing_secret, timestamp, slack_signature)
28
34
  end
29
35
 
30
- def verify_slack_team!
31
- slack_team_id = ENV.fetch("SLACK_TEAM_ID")
32
- 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
33
40
  true
34
41
  else
35
42
  raise SlackBot::Errors::TeamAuthenticationError.new("Team is not authorized")
36
43
  end
37
44
  end
38
45
 
46
+ def interaction_team_id(payload)
47
+ payload.dig("team", "id") || payload["team_id"] || payload.dig("user", "team_id")
48
+ end
49
+
39
50
  def verify_direct_message_channel!
40
51
  if params[:channel_name] == "directmessage"
41
52
  true
@@ -52,15 +63,8 @@ module SlackBot
52
63
  raise SlackBot::Errors::UserAuthenticationError.new("User is not authorized")
53
64
  end
54
65
 
55
- def events_callback(params)
56
- verify_slack_team!
57
-
58
- SlackBot::DevConsole.log_input "SlackApi::Events#events_callback: #{params.inspect}"
59
- handler = config.find_event_handler(params[:event][:type].to_sym)
60
- return false if handler.blank?
61
-
62
- event = handler.new(params: params, current_user: current_user)
63
- event.call
66
+ def slack_request_retry?
67
+ slack_request_header("x-slack-retry-num", "X-Slack-Retry-Num").present?
64
68
  end
65
69
 
66
70
  def url_verification(params)
@@ -68,23 +72,6 @@ module SlackBot
68
72
  {challenge: params[:challenge]}
69
73
  end
70
74
 
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
-
77
- def handle_block_actions_view(view:, user:, params:)
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
75
  private
89
76
 
90
77
  def slack_request_header(*names)
@@ -131,22 +118,6 @@ module SlackBot
131
118
  body_content
132
119
  end
133
120
 
134
- def find_callback!(view:, user:)
135
- callback = SlackBot::Callback.find(view&.dig("callback_id"), user: user, config: config)
136
- raise SlackBot::Errors::CallbackNotFound.new if callback.blank?
137
-
138
- callback
139
- end
140
-
141
- def log_callback_check(callback, user)
142
- SlackBot::DevConsole.log_check "SlackApi::Interactions##{__method__}: #{callback.id} #{callback.payload} #{callback.user_id} #{user&.id}"
143
- end
144
-
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
149
-
150
121
  def parse_interaction_payload!(raw_payload)
151
122
  JSON.parse(raw_payload)
152
123
  rescue JSON::ParserError => e
@@ -212,14 +183,10 @@ module SlackBot
212
183
  base.resource :interactions do
213
184
  post do
214
185
  payload = parse_interaction_payload!(params[:payload])
186
+ verify_slack_team!(interaction_team_id(payload))
215
187
  action_user = resolve_action_user(payload)&.user
216
188
 
217
- result = case payload["type"]
218
- when "block_actions", "view_submission"
219
- handle_block_actions_view(view: payload["view"], user: action_user, params: params)
220
- else
221
- raise SlackBot::Errors::UnknownActionTypeError.new(payload["type"])
222
- end
189
+ result = route_interaction(payload: payload, user: action_user, params: params)
223
190
 
224
191
  return blank_slack_response! if result.blank? || result == false
225
192
 
@@ -231,6 +198,8 @@ module SlackBot
231
198
  def self.add_events_resource!(base)
232
199
  base.resource :events do
233
200
  post do
201
+ return blank_slack_response! if slack_request_retry?
202
+
234
203
  result = case params[:type]
235
204
  when "url_verification"
236
205
  url_verification(params)
@@ -252,6 +221,7 @@ module SlackBot
252
221
  get do
253
222
  SlackBot::DevConsole.log_input "SlackApi::MenuOptions#get: #{params.inspect}"
254
223
 
224
+ verify_slack_team!
255
225
  menu_options_klass = config.find_menu_options(params[:action_id])
256
226
  raise SlackBot::Errors::MenuOptionsNotImplemented.new if menu_options_klass.blank?
257
227
 
@@ -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,20 +9,28 @@ module SlackBot
9
9
 
10
10
  include SlackBot::Concerns::ViewKlass
11
11
 
12
+ def self.api_client
13
+ @api_client ||= SlackBot::ApiClient.new
14
+ end
15
+
16
+ def self.api_client=(client)
17
+ @api_client = client
18
+ end
19
+
12
20
  def self.open_modal(callback:, trigger_id:, view:)
13
21
  view = modal_payload(callback, view)
14
- response = SlackBot::ApiClient.new.views_open(trigger_id: trigger_id, view: view)
22
+ response = api_client.views_open(trigger_id: trigger_id, view: view)
15
23
  build_view_reply(response: response, callback: callback, payload: view, error_class: SlackBot::Errors::OpenModalError)
16
24
  end
17
25
 
18
26
  def self.update_modal(callback:, view_id:, view:)
19
27
  view = modal_payload(callback, view)
20
- response = SlackBot::ApiClient.new.views_update(view_id: view_id, view: view)
28
+ response = api_client.views_update(view_id: view_id, view: view)
21
29
  build_view_reply(response: response, callback: callback, payload: view, error_class: SlackBot::Errors::UpdateModalError)
22
30
  end
23
31
 
24
32
  def self.publish_view(user_id:, view:, callback: nil, metadata: nil)
25
- response = SlackBot::ApiClient.new.views_publish(user_id: user_id, view: publish_payload(callback, metadata, view))
33
+ response = api_client.views_publish(user_id: user_id, view: publish_payload(callback, metadata, view))
26
34
  build_view_reply(response: response, callback: callback, payload: view, error_class: SlackBot::Errors::PublishViewError)
27
35
  end
28
36
 
@@ -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.1".freeze
28
+ VERSION = "2.1.0".freeze
29
29
  end
data/sig/slack_bot.rbs CHANGED
@@ -23,6 +23,9 @@ module SlackBot
23
23
  class MenuOptionsNotImplemented < Error
24
24
  end
25
25
 
26
+ class BlockActionNotImplemented < Error
27
+ end
28
+
26
29
  class CallbackNotFound < Error
27
30
  end
28
31
 
@@ -74,6 +77,9 @@ module SlackBot
74
77
 
75
78
  class NotImplementedError < Error
76
79
  end
80
+
81
+ class ConfigurationError < Error
82
+ end
77
83
  end
78
84
 
79
85
  class Config
@@ -82,9 +88,18 @@ module SlackBot
82
88
 
83
89
  attr_reader callback_storage_instance: untyped
84
90
  def callback_storage: (untyped klass) -> void
91
+ def callback_storage!: () -> untyped
85
92
 
86
93
  attr_reader callback_user_finder_method: Proc
87
94
  def callback_user_finder: (Proc method_lambda) -> void
95
+ def callback_user_finder!: () -> Proc
96
+
97
+ attr_reader user_session_resolver_method: Proc
98
+ def user_session_resolver: (Proc method_lambda) -> void
99
+ def user_session_resolver!: () -> Proc
100
+
101
+ attr_reader event_dispatcher_method: Proc
102
+ def event_dispatcher: (Proc method_lambda) -> void
88
103
 
89
104
  def interaction: (Class interaction_klass, ?handler_name: String?) -> void
90
105
  def event_handlers: () -> Hash[Symbol, Class]
@@ -98,6 +113,9 @@ module SlackBot
98
113
  def menu_options: (Symbol action_id, Class klass) -> void
99
114
  def find_menu_options: (Symbol action_id) -> Class?
100
115
 
116
+ def block_action: (Symbol action_id, Class interaction_klass, ?handler_name: String?) -> void
117
+ def find_block_action: (Symbol action_id) -> Class?
118
+
101
119
  def handler_class: (String class_name, Class klass) -> void
102
120
  def find_handler_class: (String class_name) -> Class
103
121
  end
@@ -157,9 +175,12 @@ module SlackBot
157
175
  class ApiResponse
158
176
  attr_reader response: Faraday::Response
159
177
 
178
+ def self.from_request: () { () -> Faraday::Response } -> ApiResponse
160
179
  def initialize: () { () -> Faraday::Response } -> void
161
180
  def ok?: () -> bool
162
181
  def error: () -> String?
182
+ def rate_limited?: () -> bool
183
+ def retry_after: () -> Integer
163
184
  def data: () -> Hash[String, untyped]
164
185
  end
165
186
 
@@ -301,12 +322,19 @@ module SlackBot
301
322
  def fetch_team_id: () -> String?
302
323
  def fetch_user_id: () -> String?
303
324
  def verify_slack_signature!: () -> bool
304
- def verify_slack_team!: () -> bool
325
+ def verify_slack_team!: (?String? team_id) -> bool
326
+ def interaction_team_id: (Hash[String, untyped] payload) -> String?
305
327
  def verify_direct_message_channel!: () -> bool
306
328
  def verify_current_user!: () -> bool
329
+ def slack_request_retry?: () -> bool
330
+ def resolve_user_session: (String? team_id, String? user_id) -> untyped?
331
+ def resolve_event_user: (Hash[String, untyped] params) -> untyped?
307
332
  def events_callback: (Hash[String, untyped] params) -> untyped
308
333
  def url_verification: (Hash[String, untyped] params) -> Hash[String, String]
309
334
  def handle_block_actions_view: (view: Hash[String, untyped]?, user: untyped, params: Hash[String, untyped]) -> untyped
335
+ def handle_block_actions_message: (payload: Hash[String, untyped], user: untyped, params: Hash[String, untyped]) -> untyped
336
+ def dispatch_event: (handler: Class, params: Hash[String, untyped], current_user: untyped) -> bool
337
+ def route_interaction: (payload: Hash[String, untyped], user: untyped, params: Hash[String, untyped]) -> untyped
310
338
  end
311
339
 
312
340
  module GrapeExtension
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.0.1
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Makarov
@@ -47,16 +47,22 @@ dependencies:
47
47
  name: faraday
48
48
  requirement: !ruby/object:Gem::Requirement
49
49
  requirements:
50
- - - "~>"
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: 2.14.3
53
+ - - "<"
51
54
  - !ruby/object:Gem::Version
52
- version: '2.0'
55
+ version: '3.0'
53
56
  type: :runtime
54
57
  prerelease: false
55
58
  version_requirements: !ruby/object:Gem::Requirement
56
59
  requirements:
57
- - - "~>"
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: 2.14.3
63
+ - - "<"
58
64
  - !ruby/object:Gem::Version
59
- version: '2.0'
65
+ version: '3.0'
60
66
  - !ruby/object:Gem::Dependency
61
67
  name: activesupport
62
68
  requirement: !ruby/object:Gem::Requirement
@@ -97,14 +103,14 @@ dependencies:
97
103
  requirements:
98
104
  - - "~>"
99
105
  - !ruby/object:Gem::Version
100
- version: 1.4.2
106
+ version: 1.5.0
101
107
  type: :development
102
108
  prerelease: false
103
109
  version_requirements: !ruby/object:Gem::Requirement
104
110
  requirements:
105
111
  - - "~>"
106
112
  - !ruby/object:Gem::Version
107
- version: 1.4.2
113
+ version: 1.5.0
108
114
  - !ruby/object:Gem::Dependency
109
115
  name: webmock
110
116
  requirement: !ruby/object:Gem::Requirement
@@ -231,6 +237,20 @@ dependencies:
231
237
  - - "~>"
232
238
  - !ruby/object:Gem::Version
233
239
  version: '2'
240
+ - !ruby/object:Gem::Dependency
241
+ name: bundler-audit
242
+ requirement: !ruby/object:Gem::Requirement
243
+ requirements:
244
+ - - "~>"
245
+ - !ruby/object:Gem::Version
246
+ version: '0.9'
247
+ type: :development
248
+ prerelease: false
249
+ version_requirements: !ruby/object:Gem::Requirement
250
+ requirements:
251
+ - - "~>"
252
+ - !ruby/object:Gem::Version
253
+ version: '0.9'
234
254
  - !ruby/object:Gem::Dependency
235
255
  name: memory_profiler
236
256
  requirement: !ruby/object:Gem::Requirement
@@ -299,6 +319,8 @@ files:
299
319
  - lib/slack_bot/errors.rb
300
320
  - lib/slack_bot/event.rb
301
321
  - lib/slack_bot/grape_extension.rb
322
+ - lib/slack_bot/grape_helpers/dispatch.rb
323
+ - lib/slack_bot/grape_helpers/interactions.rb
302
324
  - lib/slack_bot/interaction.rb
303
325
  - lib/slack_bot/logger.rb
304
326
  - lib/slack_bot/menu_options.rb