mos-eisley-lambda 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a229b8d14271b6bf7882e8d8484fafa898e802a5deb4c91e3f9c39770d2331a5
4
+ data.tar.gz: c9095d2f7bae1afcf598d2339f7bae922eac62eef1dba562953d0a625ab3a735
5
+ SHA512:
6
+ metadata.gz: '048d9d0fab4a2a2e0a9dcc5dc5b5d22ad57b1c1566821b984ec2a93f14ba28ace529af47f7a3117004a0504d91f90c68f4b3c42af44c7b8f0343e2e0d241a7ab'
7
+ data.tar.gz: 0d826f2055702e1dd1f79378c6fe823337c88605c19e51edef64271359b51540bba150de6920d443d8e46ba1119d4722c9c312a7dc90bf8b53f656cf755afda9
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2021 Ken J.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/Makefile ADDED
@@ -0,0 +1,15 @@
1
+ all: build package
2
+
3
+ build:
4
+ gem i mos-eisley-lambda -Ni ruby/gems/2.7.0
5
+ ls -m ruby/gems/2.7.0/gems
6
+
7
+ package:
8
+ zip -r lambda-layers ruby -x ".*" -x "*/.*" -x "Makefile"
9
+ zipinfo -t lambda-layers
10
+
11
+ clean:
12
+ rm -Rfv "ruby"
13
+
14
+ cleanall: clean
15
+ rm -fv lambda-layers.zip
data/README.md ADDED
@@ -0,0 +1,136 @@
1
+ # mos-eisley-lambda
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/mos-eisley-lambda.svg)](http://badge.fury.io/rb/mos-eisley-lambda)
4
+
5
+ “You will never find a more wretched hive of scum and villainy.” – Obi-Wan Kenobi
6
+
7
+ Episode 2 of the Ruby based [Slack app](https://api.slack.com/) framework, this time for [AWS Lambda](https://aws.amazon.com/lambda/). Pure ruby, no external dependency.
8
+
9
+ ## Setup
10
+
11
+ ### AWS
12
+
13
+ 1. Create an SQS queue for MosEisley
14
+ 1. Create an IAM role for MosEisley Lambda function
15
+ 1. Create a Lambda function for MosEisley
16
+ - You can install this gem using [Lambda Layer](#mos-eisley-lambda) or just copy the `lib` directory to your Lambda code.
17
+ 1. Create an HTTP API Gateway
18
+ 1. Create the appropriate routes (or use [the OpenAPI spec](https://github.com/kenjij/mos-eisley-lambda/blob/main/openapi3.yaml))
19
+ 1. Create Lambda integration and attach it to all the routes
20
+
21
+ Configure Lambda environment variable.
22
+
23
+ - `SLACK_SIGNING_SECRET`: your Slack app credentials
24
+ - `SLACK_BOT_ACCESS_TOKEN`: your Slack app OAuth token
25
+ - `MOSEISLEY_SQS_URL`: AWS SQS URL used for the event pipeline
26
+ - `MOSEISLEY_LOG_LEVEL` – optional, could be `DEBUG`, `INFO`, `WARN`, or `ERROR`
27
+
28
+ Configure Lambda code in your `lambda_function.rb` file.
29
+
30
+ ```ruby
31
+ require 'mos-eisley-lambda'
32
+ # Or, you can just copy the `lib` directory to your Lambda and...
33
+ # require_relative './lib/mos-eisley-lambda'
34
+
35
+ MosEisley::Handler.import
36
+ # Or, if you store your handlers in a non-default location, dictate by...
37
+ # MosEisley::Handler.import_from_path('./my-handlers')
38
+
39
+ def lambda_handler(event:, context:)
40
+ MosEisley::lambda_event(event)
41
+ end
42
+ ```
43
+
44
+ ### Slack
45
+
46
+ Create a Slack app and configure the following.
47
+
48
+ - **Interactivity & Shortcuts** – Request URL should be set to the `/actions` endpoint and Options Load URL should be set to the `/menus` endpoint.
49
+ - **Slash Commands** – Request URL should be set to the `/commands` endpoint.
50
+ - **OAuth & Permissions** – This is where you get the OAuth Tokens and set Scopes.
51
+ - **Event Subscriptions** – Request URL should be set to the `/events` endpoint. You'll likely Subscribe to bot events `app_mention` at a minimum.
52
+
53
+ ### Handlers
54
+
55
+ Create your own Mos Eisley handlers as blocks and register them. By default, store these Ruby files in the `handlers` directory.
56
+
57
+ `ME::Handler.command_acks` holds `[Hash<String, Hash>]` which are Slack command keyword and response pair. The response is sent as-is back to Slack as an [immediate response](https://api.slack.com/interactivity/slash-commands#responding_immediate_response).
58
+
59
+ ```ruby
60
+ ME::Handler.command_acks.merge!({
61
+ '/command' => {
62
+ response_type: 'in_channel',
63
+ text: '_Working on it…_',
64
+ },
65
+ '/secret' => {
66
+ response_type: 'ephemeral',
67
+ text: '_Just for you…_',
68
+ },
69
+ })
70
+ ```
71
+
72
+ Add handlers to process the Slack event.
73
+
74
+ ```ruby
75
+ ME::Handler.add(:command, 'A Slack command') do |event, myself|
76
+ next unless event[:command] == '/command'
77
+ myself.stop
78
+ txt = "Your wish is my command."
79
+ payload = {
80
+ response_type: 'ephemeral',
81
+ text: txt,
82
+ blocks: [ME::S3PO::BlockKit.sec_text(txt)],
83
+ }
84
+ ME::SlackWeb.post_response_url(event[:response_url], payload)
85
+ end
86
+ ```
87
+
88
+ ## Protocols
89
+
90
+ ### SQS
91
+
92
+ - Attributes
93
+ - `source`: `slack`, `moseisley`, or other
94
+ - `endpoint`: if source is `slack`, which endpoint it arrived at
95
+ - `destination`: `slack` or `moseisley`
96
+ - `api`: if destination is `slack`
97
+ - Message (JSON)
98
+ - `params`: object, meant to be passed to Slack API
99
+ - `payload`: the data/payload itself
100
+
101
+ ### Helpers
102
+
103
+ `ME::S3PO` – collection of helpers to analyze/create Slack messages.
104
+ `ME::SlackWeb` – methods for sending payloads to Slack Web API calls.
105
+
106
+ ## Event Lifecycle
107
+
108
+ ### Inbound
109
+
110
+ 1. Slack event is sent to Mos Eisley Lambda function via API Gateway
111
+ 1. Slack event is verified and returned with parsed object
112
+ 1. If it's a slash command, MosEisley::Handler.command_acks is referenced and immediate response is sent
113
+ 1. The original Slack event JSON is sent to SQS with attributes
114
+
115
+ ### Event Processing
116
+
117
+ 1. Slack event is recieved by SQS trigger
118
+ 1. Handlers are called and processed according to original endpoint the event was sent to; actions, commands, events, menus
119
+ 1. Should send a Slack message to complete the event cycle
120
+
121
+ <!-- ### Message Publishing
122
+
123
+ Send a message to SQS from another app to send a Slack message
124
+
125
+ 1. Create a Slack message packaged to be sent to the API and send to SQS
126
+ 1. Message event is recieved by SQS trigger
127
+ 1. Message is sent to Slack API -->
128
+
129
+ ## Using with Lambda Layers
130
+
131
+ Used the Makefile to create a zip file which can be uploaded as a Lambda Layer.
132
+
133
+ ```sh
134
+ make
135
+ # Installs the gem to './ruby' then archives it to 'lambda-layers.zip'
136
+ ```
data/lib/handler.rb ADDED
@@ -0,0 +1,95 @@
1
+ module MosEisley
2
+ def self.handlers
3
+ MosEisley::Handler.handlers
4
+ end
5
+
6
+ class Handler
7
+ # Import handlers from designated directory
8
+ def self.import
9
+ path = File.expand_path('./handlers')
10
+ import_from_path(path)
11
+ end
12
+
13
+ # Import handlers from a directory
14
+ # @param path [String] directory name
15
+ def self.import_from_path(path)
16
+ Dir.chdir(path) {
17
+ Dir.foreach('.') { |f| load f unless File.directory?(f) }
18
+ }
19
+ end
20
+
21
+ # Call as often as necessary to add handlers with blocks; each call creates a MosEisley::Handler object
22
+ # @param type [Symbol] :action | :command | :event | :menu
23
+ # @param name [String]
24
+ def self.add(type, name = nil, &block)
25
+ @handlers ||= {
26
+ action: [],
27
+ command: [],
28
+ event: [],
29
+ menu: [],
30
+ }
31
+ @handlers[type] << MosEisley::Handler.new(type, name, &block)
32
+ MosEisley.logger.debug("Added #{type} handler: #{@handlers[type].last}")
33
+ end
34
+
35
+ # Example: {'/command' => {response_type: 'ephemeral', text: nil}}
36
+ # @return [Hash<String, Hash>] commands to acknowledge
37
+ def self.command_acks
38
+ @command_acks ||= {}
39
+ end
40
+
41
+ # @return [Hash<Symbol, Array>] containing all the handlers
42
+ def self.handlers
43
+ @handlers
44
+ end
45
+
46
+ # Run the handlers, typically called by the server
47
+ # @param event [Hash] from Slack Events API JSON data
48
+ def self.run(type, event)
49
+ logger = MosEisley.logger
50
+ response = nil
51
+ @handlers[type].each do |h|
52
+ response = h.run(event)
53
+ if h.stopped?
54
+ logger.debug('Handler stop was requested.')
55
+ break
56
+ end
57
+ end
58
+ logger.info("Done running #{type} handlers.")
59
+ response
60
+ end
61
+
62
+ attr_reader :type, :name
63
+
64
+ def initialize(t, n = nil, &block)
65
+ @type = t
66
+ @name = n
67
+ @block = block
68
+ @stopped = false
69
+ end
70
+
71
+ def run(event)
72
+ logger = MosEisley.logger
73
+ logger.warn("No block to execute for #{@type} handler: #{self}") unless @block
74
+ logger.debug("Running #{@type} handler: #{self}")
75
+ @stopped = false
76
+ @block.call(event, self)
77
+ rescue => e
78
+ logger.error(e.message)
79
+ logger.error(e.backtrace.join("\n"))
80
+ {text: "Woops, encountered an error."}
81
+ end
82
+
83
+ def stop
84
+ @stopped = true
85
+ end
86
+
87
+ def stopped?
88
+ @stopped
89
+ end
90
+
91
+ def to_s
92
+ "#<#{self.class}:#{self.object_id.to_s(16)}(#{name})>"
93
+ end
94
+ end
95
+ end
data/lib/logger.rb ADDED
@@ -0,0 +1,11 @@
1
+ require 'logger'
2
+
3
+ module MosEisley
4
+ def self.logger=(logger)
5
+ @logger = logger
6
+ end
7
+
8
+ def self.logger
9
+ @logger ||= Logger.new($stdout, formatter: proc { |s, d, n, m| "#{s} : #{m}\n" })
10
+ end
11
+ end
@@ -0,0 +1,145 @@
1
+ require_relative './logger'
2
+ require_relative './slack'
3
+ require_relative './s3po/s3po'
4
+ require_relative './handler'
5
+ require 'aws-sdk-sqs'
6
+ require 'base64'
7
+ require 'json'
8
+
9
+ ME = MosEisley
10
+ SQS = Aws::SQS::Client.new
11
+
12
+ module MosEisley
13
+ def self.lambda_event(event)
14
+ abort unless preflightcheck
15
+ # Inbound Slack event (via API GW)
16
+ if event['routeKey']
17
+ MosEisley.logger.info('API GW event')
18
+ return apigw_event(event)
19
+ end
20
+ # Internal event (via SQS)
21
+ if event.dig('Records',0,'eventSource') == 'aws:sqs'
22
+ MosEisley.logger.info('SQS event')
23
+ return sqs_event(event)
24
+ end
25
+ end
26
+
27
+ def self.preflightcheck
28
+ l = ENV['MOSEISLEY_LOG_LEVEL']
29
+ if String === l && ['DEBUG', 'INFO', 'WARN', 'ERROR'].include?(l.upcase)
30
+ MosEisley.logger.level = eval("Logger::#{l.upcase}")
31
+ end
32
+ env_required = [
33
+ 'MOSEISLEY_SQS_URL',
34
+ 'SLACK_SIGNING_SECRET',
35
+ 'SLACK_BOT_ACCESS_TOKEN',
36
+ ]
37
+ env_optional = [
38
+ 'MOSEISLEY_LOG_LEVEL',
39
+ ]
40
+ env_required.each do |e|
41
+ if ENV[e].nil?
42
+ MosEisley.logger.error("Missing environment variable: #{e}")
43
+ return false
44
+ end
45
+ end
46
+ return true
47
+ end
48
+
49
+ def self.apigw_event(event)
50
+ se = ME::SlackEvent.validate(event)
51
+ unless se[:valid?]
52
+ MosEisley.logger.warn("#{se[:msg]}")
53
+ return {statusCode: 401}
54
+ end
55
+ resp = {statusCode: 200}
56
+ ep = event['routeKey'].split[-1]
57
+ MosEisley.logger.debug("Inbound Slack request to: #{ep}")
58
+ case ep
59
+ when '/actions'
60
+ # Nothing to do, just pass to SQS
61
+ when '/commands'
62
+ ser = {}
63
+ ack = ME::Handler.command_acks[se[:event][:command]]
64
+ if ack
65
+ ser[:response_type] = ack[:response_type]
66
+ ser[:text] =
67
+ if ack[:text]
68
+ ack[:text]
69
+ else
70
+ text = sep[:text].empty? ? '' : " #{se[:event][:text]}"
71
+ "Received: `#{se[:event][:command]}#{text}`"
72
+ end
73
+ # AWS sets status code and headers by passing JSON string
74
+ resp = JSON.fast_generate(ser)
75
+ end
76
+ when '/events'
77
+ # Respond to Slack challenge request
78
+ if se[:event][:url_verification]
79
+ c = se[:event][:challenge]
80
+ MosEisley.logger.info("Slack Events API challenge accepted: #{c}")
81
+ return "{\"challenge\": \"#{c}\"}"
82
+ end
83
+ when '/menus'
84
+ # ME::Handler.run(:menu, se)
85
+ # TODO to be implemented
86
+ return "{\"options\": []}"
87
+ else
88
+ MosEisley.logger.warn('Unknown request, ignored.')
89
+ return {statusCode: 400}
90
+ end
91
+ m = {
92
+ queue_url: ENV['MOSEISLEY_SQS_URL'],
93
+ message_attributes: {
94
+ 'source' => {
95
+ data_type: 'String',
96
+ string_value: 'slack',
97
+ },
98
+ 'destination' => {
99
+ data_type: 'String',
100
+ string_value: 'moseisley',
101
+ },
102
+ 'endpoint' => {
103
+ data_type: 'String',
104
+ string_value: ep,
105
+ },
106
+ },
107
+ message_body: "{\"payload\":#{se[:json]}}",
108
+ }
109
+ SQS.send_message(m)
110
+ s = m[:message_body].length
111
+ MosEisley.logger.debug("Sent message to SQS with body size #{s}.")
112
+ return resp
113
+ end
114
+
115
+ def self.sqs_event(event)
116
+ a = event.dig('Records',0,'messageAttributes')
117
+ src = a.dig('source','stringValue')
118
+ dst = a.dig('destination','stringValue')
119
+ ep = a.dig('endpoint','stringValue')
120
+ se = JSON.parse(event.dig('Records',0,'body'), {symbolize_names: true})
121
+ se = se[:payload]
122
+ MosEisley.logger.debug("Event src: #{src}, dst: #{dst}")
123
+ if src == 'slack'
124
+ # Inbound Slack event
125
+ case ep
126
+ when '/actions'
127
+ ME::Handler.run(:action, se)
128
+ when '/commands'
129
+ ME::Handler.run(:command, se)
130
+ when '/events'
131
+ ME::Handler.run(:event, se)
132
+ when '/menus'
133
+ MosEisley.logger.warn('Menu request cannot be processed here.')
134
+ else
135
+ MosEisley.logger.warn("Unknown request: #{ep}")
136
+ end
137
+ elsif dst == 'slack'
138
+ # An event to be sent to Slack
139
+ MosEisley.logger.debug a.dig('api','stringValue')
140
+ else
141
+ MosEisley.logger.warn('Unknown event, ignored.')
142
+ end
143
+ return 0
144
+ end
145
+ end
data/lib/neko-http.rb ADDED
@@ -0,0 +1,204 @@
1
+ # NekoHTTP - Pure Ruby HTTP client using net/http
2
+ #
3
+ # v.20200629
4
+
5
+ require 'json'
6
+ require 'logger'
7
+ require 'net/http'
8
+ require 'openssl'
9
+
10
+ module Neko
11
+ def self.logger=(logger)
12
+ @logger = logger
13
+ end
14
+
15
+ def self.logger
16
+ @logger ||= NullLogger.new()
17
+ end
18
+
19
+ class HTTP
20
+ METHOD_HTTP_CLASS = {
21
+ get: Net::HTTP::Get,
22
+ put: Net::HTTP::Put,
23
+ patch: Net::HTTP::Patch,
24
+ post: Net::HTTP::Post,
25
+ delete: Net::HTTP::Delete
26
+ }
27
+
28
+ def self.get(url, params, hdrs = nil)
29
+ h = HTTP.new(url, hdrs)
30
+ data = h.get(params: params)
31
+ h.close
32
+ return data
33
+ end
34
+
35
+ def self.post_form(url, params, hdrs = nil)
36
+ h = HTTP.new(url, hdrs)
37
+ data = h.post(params: params)
38
+ h.close
39
+ return data
40
+ end
41
+
42
+ # Send POST request with JSON body
43
+ # It will set the Content-Type to application/json.
44
+ # @param url [String] full URL string
45
+ # @param obj [Array, Hash, String] Array/Hash will be converted to JSON
46
+ # @param hdrs [Array, Hash, String] Array/Hash will be converted to JSON
47
+ def self.post_json(url, obj, hdrs = {})
48
+ hdrs['Content-Type'] = 'application/json'
49
+ h = HTTP.new(url, hdrs)
50
+ case obj
51
+ when Array, Hash
52
+ body = JSON.fast_generate(obj)
53
+ when String
54
+ body = obj
55
+ else
56
+ raise ArgumentError, 'Argument is neither Array, Hash, String'
57
+ end
58
+ data = h.post(body: body)
59
+ h.close
60
+ return data
61
+ end
62
+
63
+ attr_reader :init_uri, :http
64
+ attr_accessor :logger, :headers
65
+
66
+ def initialize(url, hdrs = nil)
67
+ @logger = Neko.logger
68
+ @init_uri = URI(url)
69
+ raise ArgumentError, 'Invalid URL' unless @init_uri.class <= URI::HTTP
70
+ @http = Net::HTTP.new(init_uri.host, init_uri.port)
71
+ http.use_ssl = init_uri.scheme == 'https'
72
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
73
+ @headers = hdrs
74
+ end
75
+
76
+ def get(path: nil, params: nil, query: nil)
77
+ return operate(__method__, path: path, params: params, query: query)
78
+ end
79
+
80
+ def post(path: nil, params: nil, body: nil, query: nil)
81
+ return operate(__method__, path: path, params: params, body: body, query: query)
82
+ end
83
+
84
+ def put(path: nil, params: nil, body: nil, query: nil)
85
+ return operate(__method__, path: path, params: params, body: body, query: query)
86
+ end
87
+
88
+ def patch(path: nil, params: nil, body: nil, query: nil)
89
+ return operate(__method__, path: path, params: params, body: body, query: query)
90
+ end
91
+
92
+ def delete(path: nil, params: nil, query: nil)
93
+ return operate(__method__, path: path, params: params, query: query)
94
+ end
95
+
96
+ def close
97
+ http.finish if http.started?
98
+ end
99
+
100
+ private
101
+
102
+ def operate(method, path: nil, params: nil, body: nil, query: nil)
103
+ uri = uri_with_path(path)
104
+ case method
105
+ when :get, :delete
106
+ if params
107
+ query = URI.encode_www_form(params)
108
+ logger.info('Created urlencoded query from params')
109
+ end
110
+ uri.query = query if query
111
+ req = METHOD_HTTP_CLASS[method].new(uri)
112
+ when :put, :patch, :post
113
+ uri.query = query if query
114
+ req = METHOD_HTTP_CLASS[method].new(uri)
115
+ if params
116
+ req.form_data = params
117
+ logger.info('Created form data from params')
118
+ elsif body
119
+ req.body = body
120
+ end
121
+ else
122
+ return nil
123
+ end
124
+ if uri.userinfo
125
+ req.basic_auth(uri.user, uri.password)
126
+ logger.info('Created basic auth header from URL')
127
+ end
128
+ data = send(req)
129
+ data = redirect(method, uri: data, body: req.body) if data.class <= URI::HTTP
130
+ return data
131
+ end
132
+
133
+ def uri_with_path(path)
134
+ uri = init_uri.clone
135
+ uri.path = path unless path.nil?
136
+ return uri
137
+ end
138
+
139
+ def send(req)
140
+ inject_headers_to(req)
141
+ unless http.started?
142
+ logger.info('HTTP session not started; starting now')
143
+ http.start
144
+ logger.debug("Opened connection to #{http.address}:#{http.port}")
145
+ end
146
+ logger.debug("Sending HTTP #{req.method} request to #{req.path}")
147
+ logger.debug("Body size: #{req.body.length}") if req.request_body_permitted?
148
+ res = http.request(req)
149
+ return handle_response(res)
150
+ end
151
+
152
+ def inject_headers_to(req)
153
+ return if headers.nil?
154
+ headers.each { |k, v| req[k] = v }
155
+ logger.info('Header injected into HTTP request header')
156
+ end
157
+
158
+ def handle_response(res)
159
+ if res.connection_close?
160
+ logger.info('HTTP response header says connection close; closing session now')
161
+ close
162
+ end
163
+ case res
164
+ when Net::HTTPRedirection
165
+ logger.info('HTTP response was a redirect')
166
+ data = URI(res['Location'])
167
+ if data.class == URI::Generic
168
+ data = uri_with_path(res['Location'])
169
+ logger.debug("Full URI object built for local redirect with path: #{data.path}")
170
+ end
171
+ # when Net::HTTPSuccess
172
+ # when Net::HTTPClientError
173
+ # when Net::HTTPServerError
174
+ else
175
+ data = {
176
+ code: res.code.to_i,
177
+ headers: res.to_hash,
178
+ body: res.body,
179
+ message: res.msg
180
+ }
181
+ end
182
+ return data
183
+ end
184
+
185
+ def redirect(method, uri:, body: nil)
186
+ if uri.host == init_uri.host && uri.port == init_uri.port
187
+ logger.info("Local #{method.upcase} redirect, reusing HTTP session")
188
+ new_http = self
189
+ else
190
+ logger.info("External #{method.upcase} redirect, spawning new HTTP object")
191
+ new_http = HTTP.new("#{uri.scheme}://#{uri.host}#{uri.path}", headers)
192
+ end
193
+ new_http.__send__(:operate, method, path: uri.path, body: body, query: uri.query)
194
+ end
195
+ end
196
+
197
+ class NullLogger < Logger
198
+ def initialize(*args)
199
+ end
200
+
201
+ def add(*args, &block)
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,56 @@
1
+ module MosEisley
2
+ module S3PO
3
+ module BlockKit
4
+ # @param txt [String]
5
+ # @param type [Symbol] :plain | :emoji | :mrkdwn
6
+ # @return [Hash] Block Kit section object
7
+ def self.sec_text(txt, type = :mrkdwn)
8
+ {
9
+ type: :section,
10
+ text: text(txt, type),
11
+ }
12
+ end
13
+
14
+ # @param txt [String]
15
+ # @param type [Symbol] :plain | :emoji | :mrkdwn
16
+ # @return [Hash] Block Kit text object
17
+ def self.text(txt, type = :mrkdwn)
18
+ obj = {text: txt}
19
+ case type
20
+ when :mrkdwn
21
+ obj[:type] = type
22
+ when :emoji
23
+ obj[:emoji] = true
24
+ else
25
+ obj[:emoji] = false
26
+ end
27
+ obj[:type] ||= :plain_text
28
+ obj
29
+ end
30
+
31
+ # @param txt [String]
32
+ # @return [Hash] Block Kit plain_text object with emoji:false
33
+ def self.plain_text(txt)
34
+ text(txt, :plain)
35
+ end
36
+
37
+ # @param txt [String]
38
+ # @return [Hash] Block Kit plain_text object with emoji:true
39
+ def self.emoji_text(txt)
40
+ text(txt, :emoji)
41
+ end
42
+
43
+ # @param value [String] string that will be passed to the app when selected
44
+ # @param txt [String]
45
+ # @param type [Symbol] :plain_text | :emoji | :mrkdwn
46
+ # @return [Hash] Block Kit option object
47
+ def self.option(value, txt, type = :mrkdwn)
48
+ t = MosEisley::S3PO::BlockKit.text(txt, type)
49
+ {
50
+ text: t,
51
+ value: value,
52
+ }
53
+ end
54
+ end
55
+ end
56
+ end
data/lib/s3po/s3po.rb ADDED
@@ -0,0 +1,108 @@
1
+ require 'json'
2
+ require 'time'
3
+ require_relative './blockkit'
4
+
5
+ module MosEisley
6
+ module S3PO
7
+ def self.parse_json(json)
8
+ return JSON.parse(json, {symbolize_names: true})
9
+ rescue => e
10
+ MosEisley.logger.warn("JSON parse error: #{e}")
11
+ return nil
12
+ end
13
+
14
+ # Convert object into JSON, optionally pretty-format
15
+ # @param obj [Object] any Ruby object
16
+ # @param opts [Hash] any JSON options
17
+ # @return [String] JSON string
18
+ def self.json_with_object(obj, pretty: false, opts: nil)
19
+ return '{}' if obj.nil?
20
+ if pretty
21
+ opts = {
22
+ indent: ' ',
23
+ space: ' ',
24
+ object_nl: "\n",
25
+ array_nl: "\n"
26
+ }
27
+ end
28
+ JSON.fast_generate(MosEisley::S3PO.format_json_value(obj), opts)
29
+ end
30
+
31
+ # Return Ruby object/value to JSON standard format
32
+ # @param val [Object]
33
+ # @return [Object]
34
+ def self.format_json_value(val)
35
+ s3po = MosEisley::S3PO
36
+ case val
37
+ when Array
38
+ val.map { |v| s3po.format_json_value(v) }
39
+ when Hash
40
+ val.reduce({}) { |h, (k, v)| h.merge({k => s3po.format_json_value(v)}) }
41
+ when String
42
+ val.encode('UTF-8', {invalid: :replace, undef: :replace})
43
+ when Time
44
+ val.utc.iso8601
45
+ else
46
+ val
47
+ end
48
+ end
49
+
50
+ def self.create_event(e, my_id: nil, type: nil)
51
+ type ||= e[:type] if e[:type]
52
+ case type
53
+ when 'message', 'app_mention'
54
+ return Message.new(e, my_id)
55
+ when :action
56
+ return Action.new(e)
57
+ else
58
+ return GenericEvent.new(e)
59
+ end
60
+ end
61
+
62
+ # Escape string with basic Slack rules; no command encoding is done as it often requires more information than provided in the text
63
+ # @param text [String] string to escape
64
+ # @return [String] escaped text
65
+ def self.escape_text(text)
66
+ esced = String.new(text)
67
+ esced.gsub!('&', '&amp;')
68
+ esced.gsub!('<', '&lt;')
69
+ esced.gsub!('>', '&gt;')
70
+ return esced
71
+ end
72
+
73
+ # Return plain text parsing Slack escapes and commands
74
+ # @param text [String] string to decode
75
+ # @return [String] plain text
76
+ def self.decode_text(text)
77
+ plain = String.new(text)
78
+ # keep just the labels
79
+ plain.gsub!(/<([#@]*)[^>|]*\|([^>]*)>/, '<\1\2>')
80
+ # process commands
81
+ plain.gsub!(/<!(everyone|channel|here)>/, '<@\1>')
82
+ plain.gsub!(/<!(.*?)>/, '<\1>')
83
+ # remove brackets
84
+ plain.gsub!(/<(.*?)>/, '\1')
85
+ # unescape
86
+ plain.gsub!('&gt;', '>')
87
+ plain.gsub!('&lt;', '<')
88
+ plain.gsub!('&amp;', '&')
89
+ return plain
90
+ end
91
+
92
+ # Return text with basic visual formatting symbols removed;
93
+ # it will remove all symbols regardless of syntax
94
+ # @param text [String] string to clean up
95
+ # @return [String] cleaned text
96
+ def self.remove_symbols(text)
97
+ text.delete('_*~`')
98
+ end
99
+
100
+ # Enclose Slack command in control characters
101
+ # @param cmd [String] command
102
+ # @param label [String] optional label
103
+ # @return [String] escaped command
104
+ def self.escape_command(cmd, label = nil)
105
+ "<#{cmd}" + (label ? "|#{label}" : '') + '>'
106
+ end
107
+ end
108
+ end
data/lib/slack.rb ADDED
@@ -0,0 +1,143 @@
1
+ require 'openssl'
2
+ require 'time'
3
+ require_relative './neko-http'
4
+
5
+ module MosEisley
6
+ module SlackEvent
7
+ # Validate incoming Slack request, decodes the body then into JSON
8
+ # @param e [Hash] original AWS API GW event object
9
+ # @return [Hash] {valid?: [Bool], msg: [String], json: [String], event: [Hash]}
10
+ def self.validate(e)
11
+ t = e.dig('headers', 'x-slack-request-timestamp')
12
+ return {valid?: false, msg: 'Invalid request.'} if t.nil?
13
+ if (Time.new - Time.at(t.to_i)).abs > 300
14
+ return {valid?: false, msg: 'Request too old.'}
15
+ end
16
+ b = e['isBase64Encoded'] ? Base64.decode64(e['body']) : e['body']
17
+ s = "v0:#{t}:#{b}"
18
+ k = ENV['SLACK_SIGNING_SECRET']
19
+ sig = "v0=#{OpenSSL::HMAC.hexdigest('sha256', k, s)}"
20
+ if e.dig('headers', 'x-slack-signature') != sig
21
+ return {valid?: false, msg: 'Invalid signature.'}
22
+ end
23
+ b = SlackEvent.parse_http_body(b, e.dig('headers', 'content-type'))
24
+ h = JSON.parse(b, {symbolize_names: true})
25
+ {valid?: true, msg: 'Validated.', json: b, event: h}
26
+ end
27
+
28
+ def self.parse_http_body(b, t)
29
+ case t
30
+ when 'application/json'
31
+ b
32
+ when 'application/x-www-form-urlencoded'
33
+ JSON.fast_generate(URI.decode_www_form(b).to_h)
34
+ when 'application/xml'
35
+ require 'rexml/document'
36
+ REXML::Document.new(b)
37
+ else
38
+ b
39
+ end
40
+ end
41
+ end
42
+
43
+ module SlackWeb
44
+ BASE_URL = 'https://slack.com/api/'.freeze
45
+
46
+ def self.chat_memessage(channel:, text:)
47
+ data = {channel: channel, text: text}
48
+ post_to_slack('chat.meMessage', data)
49
+ end
50
+
51
+ def self.chat_postephemeral()
52
+ end
53
+
54
+ def self.chat_postmessage(channel:, blocks: nil, text: nil, thread_ts: nil)
55
+ data = {channel: channel}
56
+ if blocks
57
+ data[:blocks] = blocks
58
+ data[:text] = text if text
59
+ else
60
+ text ? data[:text] = text : raise
61
+ end
62
+ data[:thread_ts] = thread_ts if thread_ts
63
+ post_to_slack('chat.postMessage', data)
64
+ end
65
+
66
+ def self.chat_schedulemessage()
67
+ end
68
+
69
+ def self.post_response_url(url, payload)
70
+ post_to_slack(nil, payload, url)
71
+ end
72
+
73
+ def self.post_log(blocks: nil, text: nil)
74
+ if c = ENV['SLACK_LOG_CHANNEL_ID']
75
+ d = {channel: c}
76
+ if blocks
77
+ d[:blocks] = blocks
78
+ if text
79
+ d[:text] = text
80
+ end
81
+ else
82
+ if text
83
+ d[:text] = text
84
+ else
85
+ return nil
86
+ end
87
+ end
88
+ chat_postmessage(d)
89
+ else
90
+ return nil
91
+ end
92
+ end
93
+
94
+ def self.views_open(trigger_id:, view:)
95
+ data = {
96
+ trigger_id: trigger_id,
97
+ view: view,
98
+ }
99
+ post_to_slack('views.open', data)
100
+ end
101
+
102
+ def self.views_update(view_id:, view:, hash: nil)
103
+ data = {
104
+ view_id: view_id,
105
+ view: view,
106
+ }
107
+ data[:hash] if hash
108
+ post_to_slack('views.update', data)
109
+ end
110
+
111
+ def self.views_push(trigger_id:, view:)
112
+ end
113
+
114
+ # def self.auth_test
115
+ # post_to_slack('auth.test')
116
+ # end
117
+
118
+ private
119
+
120
+ def self.post_to_slack(method, data, url = nil)
121
+ l = MosEisley.logger
122
+ url ||= BASE_URL + method
123
+ head = {authorization: "Bearer #{ENV['SLACK_BOT_ACCESS_TOKEN']}"}
124
+ r = Neko::HTTP.post_json(url, data, head)
125
+ if r[:code] != 200
126
+ l.warn("post_to_slack HTTP failed: #{r[:message]}")
127
+ return nil
128
+ end
129
+ begin
130
+ h = JSON.parse(r[:body], {symbolize_names: true})
131
+ if h[:ok]
132
+ return h
133
+ else
134
+ l.warn("post_to_slack Slack failed: #{h[:error]}")
135
+ l.debug("#{h[:response_metadata]}")
136
+ return nil
137
+ end
138
+ rescue
139
+ return {body: r[:body]}
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,15 @@
1
+ Gem::Specification.new do |s|
2
+ s.name = 'mos-eisley-lambda'
3
+ s.version = '0.4.0'
4
+ s.authors = ['Ken J.']
5
+ s.email = ['kenjij@gmail.com']
6
+ s.summary = %q{Ruby based Slack bot framework, for AWS Lambda use}
7
+ s.description = %q{Ruby based Slack bot framework, for AWS Lambda use. Also provides helpers to analyze/build messages. Event based utilizing SQS.}
8
+ s.homepage = 'https://github.com/kenjij/mos-eisley-lambda'
9
+ s.license = 'MIT'
10
+
11
+ s.files = `git ls-files`.split($/)
12
+ s.require_paths = ['lib']
13
+
14
+ s.required_ruby_version = '>= 2.7'
15
+ end
data/openapi3.yaml ADDED
@@ -0,0 +1,27 @@
1
+ openapi: "3.0.1"
2
+ info:
3
+ title: "MosEisley"
4
+ description: "MosEisley API gateway"
5
+ version: "2020-01-01 00:00:00UTC"
6
+ paths:
7
+ /events:
8
+ post:
9
+ responses:
10
+ default:
11
+ description: "Default response for POST /events"
12
+ /actions:
13
+ post:
14
+ responses:
15
+ default:
16
+ description: "Default response for POST /actions"
17
+ /menus:
18
+ post:
19
+ responses:
20
+ default:
21
+ description: "Default response for POST /menus"
22
+ /commands:
23
+ post:
24
+ responses:
25
+ default:
26
+ description: "Default response for POST /commands"
27
+ x-amazon-apigateway-importexport-version: "1.0"
metadata ADDED
@@ -0,0 +1,56 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mos-eisley-lambda
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.4.0
5
+ platform: ruby
6
+ authors:
7
+ - Ken J.
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2021-07-26 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Ruby based Slack bot framework, for AWS Lambda use. Also provides helpers
14
+ to analyze/build messages. Event based utilizing SQS.
15
+ email:
16
+ - kenjij@gmail.com
17
+ executables: []
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - LICENSE
22
+ - Makefile
23
+ - README.md
24
+ - lib/handler.rb
25
+ - lib/logger.rb
26
+ - lib/mos-eisley-lambda.rb
27
+ - lib/neko-http.rb
28
+ - lib/s3po/blockkit.rb
29
+ - lib/s3po/s3po.rb
30
+ - lib/slack.rb
31
+ - mos-eisley-lambda.gemspec
32
+ - openapi3.yaml
33
+ homepage: https://github.com/kenjij/mos-eisley-lambda
34
+ licenses:
35
+ - MIT
36
+ metadata: {}
37
+ post_install_message:
38
+ rdoc_options: []
39
+ require_paths:
40
+ - lib
41
+ required_ruby_version: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: '2.7'
46
+ required_rubygems_version: !ruby/object:Gem::Requirement
47
+ requirements:
48
+ - - ">="
49
+ - !ruby/object:Gem::Version
50
+ version: '0'
51
+ requirements: []
52
+ rubygems_version: 3.1.2
53
+ signing_key:
54
+ specification_version: 4
55
+ summary: Ruby based Slack bot framework, for AWS Lambda use
56
+ test_files: []