grape-slack-bot 1.0.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.
@@ -0,0 +1,109 @@
1
+ require 'active_support/core_ext/object'
2
+ require 'active_support/core_ext/numeric/time'
3
+
4
+ module SlackBot
5
+ class Callback
6
+ CALLBACK_CACHE_KEY = "slack-bot-callback".freeze
7
+
8
+ def self.find(id, config: nil)
9
+ callback = new(id: id, config: config)
10
+ callback.reload
11
+ end
12
+
13
+ def self.create(class_name:, method_name:, user:, channel_id: nil, config: nil)
14
+ callback =
15
+ new(class_name: class_name, method_name: method_name, user: user, channel_id: channel_id, config: config)
16
+ callback.save
17
+ callback
18
+ end
19
+
20
+ attr_reader :id, :data, :args, :config
21
+ def initialize(id: nil, class_name: nil, method_name: nil, user: nil, channel_id: nil, extra: nil, config: nil)
22
+ @id = id
23
+ @data = {
24
+ class_name: class_name,
25
+ method_name: method_name,
26
+ user_id: user&.id,
27
+ channel_id: channel_id,
28
+ extra: extra
29
+ }
30
+ @args = SlackBot::Args.new
31
+ @config = config || SlackBot::Config.current_instance
32
+ end
33
+
34
+ def reload
35
+ @data = read_data
36
+ SlackBot::DevConsole.log_check("SlackBot::Callback#read_data: #{id} | #{data}")
37
+
38
+ parse_args
39
+ self
40
+ end
41
+
42
+ def save
43
+ @id = generate_id if id.blank?
44
+ serialize_args
45
+
46
+ SlackBot::DevConsole.log_check("SlackBot::Callback#write_data: #{id} | #{data}")
47
+ write_data(data)
48
+ end
49
+
50
+ def update(payload)
51
+ return if id.blank?
52
+ return if data.blank?
53
+
54
+ @data = data.merge(payload)
55
+ save
56
+ end
57
+
58
+ def destroy
59
+ return if id.blank?
60
+
61
+ delete_data
62
+ end
63
+
64
+ def user
65
+ @user ||= begin
66
+ user_id = data&.dig(:user_id)
67
+ config.callback_user_finder_method.call(user_id) if user_id.present?
68
+ end
69
+ end
70
+
71
+ def handler_class
72
+ return if class_name.blank?
73
+
74
+ config.find_handler_class(class_name)
75
+ end
76
+
77
+ def method_missing(method_name, *args, &block)
78
+ return data[method_name.to_sym] if data.key?(method_name.to_sym)
79
+
80
+ super
81
+ end
82
+
83
+ private
84
+
85
+ def parse_args
86
+ args.raw_args = data&.dig(:args)
87
+ end
88
+
89
+ def serialize_args
90
+ data[:args] = args.to_s
91
+ end
92
+
93
+ def generate_id
94
+ SecureRandom.uuid
95
+ end
96
+
97
+ def read_data
98
+ config.callback_storage_instance.read("#{CALLBACK_CACHE_KEY}:#{id}")
99
+ end
100
+
101
+ def write_data(data)
102
+ config.callback_storage_instance.write("#{CALLBACK_CACHE_KEY}:#{id}", data, expires_in: 1.hour)
103
+ end
104
+
105
+ def delete_data
106
+ config.callback_storage_instance.delete("#{CALLBACK_CACHE_KEY}:#{id}")
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,15 @@
1
+ module SlackBot
2
+ class CallbackStorage
3
+ def read(*_args, **_kwargs)
4
+ raise "Not implemented"
5
+ end
6
+
7
+ def write(*_args, **_kwargs)
8
+ raise "Not implemented"
9
+ end
10
+
11
+ def delete(*_args, **_kwargs)
12
+ raise "Not implemented"
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,72 @@
1
+ module SlackBot
2
+ class Command
3
+ def self.interaction(klass)
4
+ define_singleton_method(:interaction_klass) { klass }
5
+ end
6
+
7
+ def self.view(klass)
8
+ define_singleton_method(:view_klass) { klass }
9
+ end
10
+
11
+ attr_reader :current_user, :params, :args, :config
12
+ def initialize(current_user:, params:, args:, config: nil)
13
+ @current_user = current_user
14
+ @params = params
15
+ @config = config || SlackBot::Config.current_instance
16
+
17
+ @args = SlackBot::Args.new
18
+ @args.raw_args = args
19
+ end
20
+
21
+ def command
22
+ params[:command]
23
+ end
24
+
25
+ def text
26
+ params[:text]
27
+ end
28
+
29
+ def only_user?
30
+ true
31
+ end
32
+
33
+ def only_direct_message?
34
+ true
35
+ end
36
+
37
+ def only_slack_team?
38
+ true
39
+ end
40
+
41
+ def render_response(response_type = nil, **kwargs)
42
+ return if !response_type
43
+
44
+ {
45
+ response_type: response_type
46
+ }.merge(kwargs)
47
+ end
48
+
49
+ private
50
+
51
+ def open_modal(view_name, method_name: nil, context: nil)
52
+ view = self.class.view_klass.new(
53
+ args: args,
54
+ current_user: @current_user,
55
+ params: params,
56
+ context: context,
57
+ config: config
58
+ )
59
+ payload = view.send(view_name)
60
+ self.class.interaction_klass.open_modal(
61
+ trigger_id: params[:trigger_id],
62
+ channel_id: params[:channel_id],
63
+ class_name: self.class.name,
64
+ method_name: method_name,
65
+ user: @current_user,
66
+ payload: payload,
67
+ config: config
68
+ )
69
+ render_response
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,168 @@
1
+ require 'active_support/core_ext/object'
2
+
3
+ module SlackBot
4
+ class Config
5
+ def self.current_instance
6
+ @@current_instances ||= {}
7
+ @@current_instances[self.name] ||= self.new
8
+ end
9
+
10
+ def self.configure(&block)
11
+ current_instance.instance_eval(&block)
12
+ end
13
+
14
+ attr_reader :callback_storage_instance
15
+ def callback_storage(klass)
16
+ @callback_storage_instance = klass
17
+ end
18
+
19
+ attr_reader :callback_user_finder_method
20
+ def callback_user_finder(method_lambda)
21
+ @callback_user_finder_method = method_lambda
22
+ end
23
+
24
+ def event_handlers
25
+ @event_handlers ||= {}
26
+ end
27
+
28
+ def event(event_type, event_klass)
29
+ event_handlers[event_type.to_sym] = event_klass
30
+ end
31
+
32
+ def find_event_handler(event_type)
33
+ event_handlers[event_type.to_sym]
34
+ end
35
+
36
+ def slash_command_endpoint(url_token, command_klass = nil, &block)
37
+ @slash_command_endpoints ||= {}
38
+ @slash_command_endpoints[url_token.to_sym] ||=
39
+ begin
40
+ endpoint =
41
+ SlashCommandEndpointConfig.new(url_token, command_klass: command_klass, config: self)
42
+ endpoint.instance_eval(&block) if block_given?
43
+ endpoint
44
+ end
45
+ end
46
+
47
+ def slash_command_endpoints
48
+ @slash_command_endpoints ||= {}
49
+ end
50
+
51
+ def find_slash_command_config(url_token, command, text)
52
+ endpoint_config = slash_command_endpoints[url_token.to_sym]
53
+ return if endpoint_config.blank?
54
+
55
+ endpoint_config.find_command_config(text) || endpoint_config
56
+ end
57
+
58
+ def menu_options(action_id, klass)
59
+ @menu_options ||= {}
60
+ @menu_options[action_id.to_sym] = klass
61
+ end
62
+
63
+ def find_menu_options(action_id)
64
+ @menu_options ||= {}
65
+ @menu_options[action_id.to_sym]
66
+ end
67
+
68
+ def handler_class(class_name, klass)
69
+ @handler_classes ||= {}
70
+ @handler_classes[class_name.to_sym] = klass
71
+ end
72
+
73
+ def find_handler_class(class_name)
74
+ @handler_classes ||= {}
75
+ @handler_classes[class_name.to_sym]
76
+ end
77
+ end
78
+
79
+ class SlashCommandEndpointConfig
80
+ attr_reader :url_token, :command_klass, :routes, :config
81
+ def initialize(url_token, config:, command_klass: nil, routes: {})
82
+ @url_token = url_token
83
+ @command_klass = command_klass
84
+ @routes = routes
85
+ @config = config
86
+
87
+ config.handler_class(command_klass.name, command_klass) if command_klass.present?
88
+ end
89
+
90
+ def command(command_token, command_klass, &block)
91
+ @command_configs ||= {}
92
+ @command_configs[command_token.to_sym] ||=
93
+ begin
94
+ command =
95
+ SlashCommandConfig.new(
96
+ command_klass: command_klass,
97
+ token: command_token,
98
+ endpoint: self
99
+ )
100
+ command.instance_eval(&block) if block_given?
101
+ command
102
+ end
103
+ end
104
+
105
+ def command_configs
106
+ @command_configs ||= {}
107
+ end
108
+
109
+ def find_command_config(text)
110
+ route_key = text.scan(/^(#{routes.keys.join("|")})(?:\s|$)/).flatten.first
111
+ return if route_key.blank?
112
+
113
+ routes[route_key]
114
+ end
115
+
116
+ def full_token
117
+ ""
118
+ end
119
+ end
120
+
121
+ class SlashCommandConfig
122
+ def self.delimiter
123
+ " "
124
+ end
125
+
126
+ attr_accessor :command_klass, :token, :parent_configs, :endpoint
127
+ def initialize(command_klass:, token:, endpoint:, parent_configs: [])
128
+ @command_klass = command_klass
129
+ @token = token
130
+ @parent_configs = parent_configs || []
131
+ @endpoint = endpoint
132
+
133
+ endpoint.routes[full_token] = self
134
+ endpoint.config.handler_class(command_klass.name, command_klass)
135
+ end
136
+
137
+ def argument_command(argument_token, klass = nil, &block)
138
+ @argument_command_configs ||= {}
139
+ @argument_command_configs[argument_token.to_sym] ||=
140
+ SlashCommandConfig.new(
141
+ command_klass: command_klass,
142
+ token: argument_token,
143
+ parent_configs: [self] + (parent_configs || []),
144
+ endpoint: endpoint
145
+ )
146
+
147
+ command_config = @argument_command_configs[argument_token.to_sym]
148
+ command_config.instance_eval(&block) if block_given?
149
+
150
+ command_config
151
+ end
152
+
153
+ def find_argument_command_config(argument_token)
154
+ @argument_command_configs ||= {}
155
+ @argument_command_configs[argument_token.to_sym]
156
+ end
157
+
158
+ def full_token
159
+ [parent_configs.map(&:token), token].flatten.compact.join(
160
+ self.class.delimiter
161
+ )
162
+ end
163
+
164
+ def url_token
165
+ endpoint.url_token
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,33 @@
1
+ module SlackBot
2
+ class DevConsole
3
+ def self.enabled=(value)
4
+ @enabled = value
5
+ end
6
+
7
+ def self.enabled?
8
+ @enabled
9
+ end
10
+
11
+ def self.log(message = nil, &)
12
+ return unless enabled?
13
+
14
+ message = yield if block_given?
15
+ Rails.logger.info(message)
16
+ end
17
+
18
+ def self.log_input(message = nil, &)
19
+ message = yield if block_given?
20
+ log(">>> #{message}")
21
+ end
22
+
23
+ def self.log_output(message = nil, &)
24
+ message = yield if block_given?
25
+ log("<<< #{message}")
26
+ end
27
+
28
+ def self.log_check(message = nil, &)
29
+ message = yield if block_given?
30
+ log("!!! #{message}")
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,39 @@
1
+ module SlackBot
2
+ module Errors
3
+ class SignatureAuthenticationError < StandardError
4
+ end
5
+
6
+ class TeamAuthenticationError < StandardError
7
+ end
8
+
9
+ class ChannelAuthenticationError < StandardError
10
+ end
11
+
12
+ class UserAuthenticationError < StandardError
13
+ end
14
+
15
+ class SlashCommandNotImplemented < StandardError
16
+ end
17
+
18
+ class MenuOptionsNotImplemented < StandardError
19
+ end
20
+
21
+ class SlackResponseError < StandardError
22
+ attr_reader :error, :data, :payload
23
+ def initialize(error, data: nil, payload: nil)
24
+ @error = error
25
+ @data = data
26
+ @payload = payload
27
+ end
28
+ end
29
+
30
+ class OpenModalError < SlackResponseError
31
+ end
32
+
33
+ class UpdateModalError < SlackResponseError
34
+ end
35
+
36
+ class PublishViewError < SlackResponseError
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,41 @@
1
+ module SlackBot
2
+ class Event
3
+ def self.view(klass)
4
+ define_singleton_method(:view_klass) { klass }
5
+ end
6
+
7
+ attr_reader :current_user, :params, :callback, :config
8
+ def initialize(current_user: nil, params: nil, callback: nil, config: nil)
9
+ @current_user = current_user
10
+ @params = params
11
+ @callback = callback
12
+ @config = config || SlackBot::Config.current_instance
13
+ end
14
+
15
+ def call
16
+ nil
17
+ end
18
+
19
+ private
20
+
21
+ def event_type
22
+ params["event"]["type"]
23
+ end
24
+
25
+ def publish_view(view_method_name)
26
+ user_id = params["event"]["user"]
27
+ view =
28
+ self.class.view_klass
29
+ .new(current_user: current_user, params: params)
30
+ .send(view_method_name)
31
+ response =
32
+ SlackBot::ApiClient.new.views_publish(user_id: user_id, view: view)
33
+
34
+ if !response.ok?
35
+ raise SlackBot::Errors::PublishViewError.new(response.error, data: response.data, payload: view)
36
+ end
37
+
38
+ nil
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,198 @@
1
+ require 'active_support/core_ext/object'
2
+
3
+ module SlackBot
4
+ module GrapeExtension
5
+ def self.included(base)
6
+ base.format :json
7
+ base.content_type :json, "application/json"
8
+ base.use ActionDispatch::RemoteIp
9
+ base.helpers do
10
+ def fetch_team_id
11
+ params.dig("team_id") || params.dig("team", "id")
12
+ end
13
+
14
+ def fetch_user_id
15
+ params.dig("user_id") || params.dig("user", "id") ||
16
+ params.dig("event", "user")
17
+ end
18
+
19
+ def verify_slack_signature!
20
+ slack_signing_secret = ENV.fetch("SLACK_SIGNING_SECRET")
21
+ timestamp = request.headers["X-Slack-Request-Timestamp"]
22
+ request_body = request.body.read
23
+ sig_basestring = "v0:#{timestamp}:#{request_body}"
24
+ my_signature =
25
+ "v0=" +
26
+ OpenSSL::HMAC.hexdigest(
27
+ OpenSSL::Digest.new("sha256"),
28
+ slack_signing_secret,
29
+ sig_basestring
30
+ )
31
+ slack_signature = request.headers["X-Slack-Signature"]
32
+ if ActiveSupport::SecurityUtils.secure_compare(
33
+ my_signature,
34
+ slack_signature
35
+ )
36
+ true
37
+ else
38
+ raise SlackBot::SignatureAuthenticationError.new("Signature mismatch")
39
+ end
40
+ end
41
+
42
+ def verify_slack_team!
43
+ slack_team_id = ENV.fetch("SLACK_TEAM_ID")
44
+ if slack_team_id == fetch_team_id
45
+ true
46
+ else
47
+ raise SlackBot::TeamAuthenticationError.new("Team is not authorized")
48
+ end
49
+ end
50
+
51
+ def verify_direct_message_channel!
52
+ if params[:channel_name] == "directmessage"
53
+ true
54
+ else
55
+ raise SlackBot::ChannelAuthenticationError.new(
56
+ "This command is only available in direct messages"
57
+ )
58
+ end
59
+ end
60
+
61
+ def verify_current_user!
62
+ if current_user
63
+ true
64
+ else
65
+ raise SlackBot::UserAuthenticationError.new("User is not authorized")
66
+ end
67
+ end
68
+
69
+ def events_callback(params)
70
+ verify_slack_team!
71
+
72
+ SlackBot::DevConsole.log_input "SlackApi::Events#events_callback: #{params.inspect}"
73
+ handler = config.find_event_handler(params[:event][:type].to_sym)
74
+ return if handler.blank?
75
+
76
+ event = handler.new(params: params, current_user: current_user)
77
+ event.call
78
+ end
79
+
80
+ def url_verification(params)
81
+ SlackBot::DevConsole.log_input "SlackApi::Events#url_verification: #{params.inspect}"
82
+ { challenge: params[:challenge] }
83
+ end
84
+
85
+ def handle_block_actions_view(view:, user:, params:)
86
+ callback_id = view&.dig("callback_id")
87
+ callback = SlackBot::Callback.find(callback_id, config: config)
88
+
89
+ if callback.blank?
90
+ raise "Callback not found"
91
+ end
92
+
93
+ SlackBot::DevConsole.log_check "SlackApi::Interactions##{__method__}: #{callback.id} #{callback.extra} #{callback.user_id} #{user&.id}"
94
+
95
+ if callback.user_id != user.id
96
+ raise "Callback user is not equal to action user"
97
+ end
98
+
99
+ interaction_klass = callback.handler_class&.interaction_klass
100
+ return if interaction_klass.blank?
101
+
102
+ interaction_klass.new(current_user: user, params: params, callback: callback, config: config).call
103
+ end
104
+ end
105
+
106
+ base.before do
107
+ verify_slack_signature!
108
+ end
109
+
110
+ base.resource :commands do
111
+ post ":url_token" do
112
+ command_config = config.find_slash_command_config(params[:url_token], params[:command], params[:text])
113
+ command_klass = command_config&.command_klass
114
+ raise SlackBot::Errors::SlashCommandNotImplemented.new if command_klass.blank?
115
+
116
+ args = params[:text].gsub(/^#{command_config.full_token}\s?/, "")
117
+ SlackBot::DevConsole.log_input "SlackApi::SlashCommands#post: #{command_config.url_token} | #{command_config.full_token} | #{args}"
118
+
119
+ action =
120
+ command_klass.new(
121
+ current_user: current_user,
122
+ params: params,
123
+ args: args,
124
+ config: config
125
+ )
126
+ verify_slack_team! if action.only_slack_team?
127
+ verify_direct_message_channel! if action.only_direct_message?
128
+ verify_current_user! if action.only_user?
129
+
130
+ result = action.call
131
+ return body false if !result
132
+
133
+ result
134
+ end
135
+ end
136
+
137
+ base.resource :interactions do
138
+ post do
139
+ payload = JSON.parse(params[:payload])
140
+
141
+ action_user_session =
142
+ resolve_user_session(
143
+ payload.dig("user", "team_id"),
144
+ payload.dig("user", "id")
145
+ )
146
+ action_user = action_user_session&.user
147
+
148
+ action_type = payload["type"]
149
+ result = case action_type
150
+ when "block_actions", "view_submission"
151
+ handle_block_actions_view(
152
+ view: payload["view"],
153
+ user: action_user,
154
+ params: params
155
+ )
156
+ else
157
+ raise "Unknown action type: #{action_type}"
158
+ end
159
+
160
+ return body false if result.blank?
161
+
162
+ result
163
+ end
164
+ end
165
+
166
+ base.resource :events do
167
+ post do
168
+ result =
169
+ case params[:type]
170
+ when "url_verification"
171
+ url_verification(params)
172
+ when "event_callback"
173
+ events_callback(params)
174
+ end
175
+
176
+ return body false if result.blank?
177
+
178
+ result
179
+ end
180
+ end
181
+
182
+ base.resource :menu_options do
183
+ get do
184
+ SlackBot::DevConsole.log_input "SlackApi::MenuOptions#get: #{params.inspect}"
185
+
186
+ action_id = params[:action_id]
187
+ menu_options_klass = config.find_menu_options(action_id)
188
+ raise SlackBot::Errors::MenuOptionsNotImplemented.new if menu_options_klass.blank?
189
+
190
+ menu_options = menu_options_klass.new(current_user: current_user, params: params, config: config).call
191
+ return body false if menu_options.blank?
192
+
193
+ menu_options
194
+ end
195
+ end
196
+ end
197
+ end
198
+ end