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 +7 -0
- data/LICENSE +21 -0
- data/Makefile +15 -0
- data/README.md +136 -0
- data/lib/handler.rb +95 -0
- data/lib/logger.rb +11 -0
- data/lib/mos-eisley-lambda.rb +145 -0
- data/lib/neko-http.rb +204 -0
- data/lib/s3po/blockkit.rb +56 -0
- data/lib/s3po/s3po.rb +108 -0
- data/lib/slack.rb +143 -0
- data/mos-eisley-lambda.gemspec +15 -0
- data/openapi3.yaml +27 -0
- metadata +56 -0
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
|
+
[](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,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!('&', '&')
|
68
|
+
esced.gsub!('<', '<')
|
69
|
+
esced.gsub!('>', '>')
|
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!('>', '>')
|
87
|
+
plain.gsub!('<', '<')
|
88
|
+
plain.gsub!('&', '&')
|
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: []
|