grape-slack-bot 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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