mos-eisley-lambda 0.4.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 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: []