driftwood 0.0.1

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
+ SHA1:
3
+ metadata.gz: c22c3ad6165aea9d79243d7b720f931ca538b298
4
+ data.tar.gz: 1745c96c0da424c36cb06fb9a40c6b553569269a
5
+ SHA512:
6
+ metadata.gz: 342f82ed397346348e5578d31c45ef6b8c8d47f11de7756253f3f04de58de9c70275f44671fcd4280ff848a523f4b769a30b91dcf3dd05fed8689e56af80168f
7
+ data.tar.gz: cdb16d92ad679d4e040feda4be6c486727f6c57e2a0f07caad2030fe28eeb34fa2003ba5dda378655421426b523da3ef3e205f96903bb63dc0b7f4cc0ddf699f
data/CHANGELOG.md ADDED
@@ -0,0 +1,3 @@
1
+ # v0.0.1
2
+
3
+ * Initial release.
data/LICENSE ADDED
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "{}"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright {yyyy} {name of copyright owner}
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
data/README.md ADDED
@@ -0,0 +1,122 @@
1
+ # Driftwood
2
+ Simple Slack logging and community management
3
+
4
+ ## Overview
5
+
6
+ The Slack logging service isn't very complete, especially if you're on the free
7
+ tier. For example, messages expire far too soon on an active channel and users
8
+ don't include creation times. This tool provides those missing features, and soon
9
+ will also provide features for building a positive team culture.
10
+
11
+ ## Operation
12
+
13
+ At its core, the core functionality logs teams, channels, users, and messages to
14
+ BigQuery. When you start the service for the first time, it starts a background
15
+ thread to crawl the API and synchronize as much data as it can get or reconstruct.
16
+
17
+ 1. All channels in a team are iterated and recorded
18
+ 1. All users in a channel are iterated and recorded. If the user hasn't been seen
19
+ before, we assume that they were created while the service was stopped or during
20
+ restart and so record a creation time of now().
21
+ 1. All available messages that are newer than the newest message in our database
22
+ are iterated and recorded. This doesn't guarantee log consistency because
23
+ messages expire quickly and may be lost. Messages also do not have a unique ID,
24
+ though the microsecond timestamp is pretty close to it.
25
+
26
+ Then a webhook & bot are started up. The webhook receives all events we've
27
+ subscribed to and the bot is available for two-way conversations. Out of the box
28
+ we just log messages and respond to a few silly messages, but that's easily
29
+ extensible.
30
+
31
+
32
+ ## Configuration
33
+
34
+ Driftwood loads configuration from the first found of either `~/.driftwood/config.yaml`
35
+ or `/etc/abalone/config.yaml`. You can pass the path to another config file at
36
+ the command line.
37
+
38
+ Example configuration:
39
+
40
+ ```
41
+ ---
42
+ :slack:
43
+ :client_id: "<client ID from Slack>"
44
+ :client_secret: <client secret from Slack>
45
+ :verification_token: <verification token from Slack>
46
+ :redirect_uri: https://driftwood.example.com
47
+ :gcloud:
48
+ :dataset: <dataset>
49
+ :project: <project>
50
+ :keyfile: ~/.driftwood/credentials.json
51
+ ```
52
+
53
+ ### Setting up integration
54
+
55
+ The installation steps from https://github.com/slackapi/Slack-Ruby-Onboarding-Tutorial
56
+ can be followed with a few obvious modifications. Configure and application for
57
+ your team, and configure the service before starting it up.
58
+
59
+ Once it's running, load up the webpage and press the **Add to Slack** button
60
+ to add it to your workspace.
61
+
62
+
63
+ ## Extending
64
+
65
+ Eventually this will have a real plugin system that makes this more obvious. For
66
+ the time being, you'll have to invoke the server yourself and register any
67
+ handlers you need.
68
+
69
+ A simple echo bot:
70
+
71
+ ```
72
+ require 'driftwood'
73
+
74
+ options = {...}
75
+ server = Driftwood.new(options)
76
+ server.slack.register_handler do |team_id, event_data|
77
+ next unless event_data['channel_type'] == 'im'
78
+
79
+ user = event_data['user']
80
+ text = event_data['text']
81
+ name = server.slack.real_name(team_id, user)
82
+
83
+ case text
84
+ when /^hello/i
85
+ server.slack.send_response(team_id, user, "Hi there, #{name}!")
86
+ when /^ping/i
87
+ server.slack.send_response(team_id, user, "Pong")
88
+ when /^echo/i
89
+ server.slack.send_response(team_id, user, "Echo response: #{text.sub(/^echo /, '')}")
90
+ end
91
+ end
92
+
93
+ server.run!
94
+ ```
95
+
96
+ ## Roadmap & Todo
97
+
98
+ The next features coming up are those for community management and culture building.
99
+ We'll be adding a templated onboarding process that will welcome people with a
100
+ Getting Started guide that encourages the culture we want to develop and lays the
101
+ foundation for community members to help self moderate and encourage the behaviours
102
+ we'd like to see more of.
103
+ Then we'll be adding multi-faceted karma tracking that allows members to reward
104
+ each other for contributions (such as answering a question) and behaviours (such
105
+ as kindness while welcoming a new member).
106
+ Then we'll start doing language analysis so that we can automate more of this. For
107
+ example, we'll look for certain keywords, and see who asks questions of who.
108
+
109
+
110
+ ## Limitations
111
+
112
+ This is super early in development and has not yet been battle tested.
113
+
114
+ ## Disclaimer
115
+
116
+ I take no liability for the use of this tool.
117
+
118
+ Contact
119
+ -------
120
+
121
+ binford2k@gmail.com
122
+
data/bin/driftwood ADDED
@@ -0,0 +1,107 @@
1
+ #! /usr/bin/env ruby
2
+
3
+ require 'rubygems'
4
+ require 'optparse'
5
+ require 'yaml'
6
+ require 'driftwood'
7
+
8
+ defaults = {
9
+ :port => 9292,
10
+ :bind => '0.0.0.0',
11
+ :logfile => $stderr,
12
+ :slack => {
13
+ :name => 'driftwood',
14
+ },
15
+ }
16
+ loglevel = Logger::WARN
17
+ configfile = [ File.expand_path('~/.driftwood/config.yaml'), '/etc/driftwood/config.yaml'].select { |file| File.exist? file }.first
18
+ options = {}
19
+ ssl_opts = {:verify_peer => false}
20
+
21
+ optparse = OptionParser.new { |opts|
22
+ opts.banner = "Usage : driftwood [-c config] [-p <port>] [-l [logfile]] [-d]
23
+ -- Runs the Driftwood slack community interaction service.
24
+
25
+ "
26
+
27
+ opts.on("-c CONFIGFILE", "--config CONFIGFILE", "Load configuration from a file. (/etc/driftwood/config.yaml)") do
28
+ configfile = arg
29
+ end
30
+
31
+ opts.on("-d", "--debug", "Display or log debugging messages") do
32
+ loglevel = Logger::DEBUG
33
+ end
34
+
35
+ opts.on("-l [LOGFILE]", "--logfile [LOGFILE]", "Path to logfile. Defaults to no logging, or /var/log/driftwood if no filename is passed.") do |arg|
36
+ options[:logfile] = arg || '/var/log/driftwood'
37
+ end
38
+
39
+ opts.on("-p PORT", "--port PORT", "Port for the Slack webhook server to listen on. Defaults to 9292.") do |arg|
40
+ options[:port] = arg
41
+ end
42
+
43
+ opts.separator('')
44
+
45
+ opts.on("--ssl", "Run with SSL support. Autogenerates a self-signed certificates by default.") do
46
+ options[:ssl] = true
47
+ end
48
+
49
+ opts.on("--ssl-cert FILE", "Specify the SSL certificate you'd like use use. Pair with --ssl-key.") do |arg|
50
+ ssl_opts[:cert_chain_file] = arg
51
+ end
52
+
53
+ opts.on("--ssl-key FILE", "Specify the SSL key file you'd like use use. Pair with --ssl-cert.") do |arg|
54
+ ssl_opts[:private_key_file] = arg
55
+ end
56
+
57
+ opts.separator('')
58
+
59
+ opts.on("-h", "--help", "Displays this help") do
60
+ puts
61
+ puts opts
62
+ puts
63
+ exit
64
+ end
65
+ }
66
+ optparse.parse!
67
+
68
+ config = YAML.load_file(configfile) rescue {}
69
+ config[:slack] ||= {}
70
+ options[:slack] = defaults[:slack].merge(config[:slack])
71
+ options = defaults.merge(config.merge(options))
72
+
73
+ $logger = Logger.new(options[:logfile])
74
+ $logger.level = loglevel
75
+
76
+ if ssl_opts[:cert_chain_file] and ssl_opts[:private_key_file]
77
+ options[:ssl] = true
78
+ end
79
+
80
+ # These options should either both be nil or both be Strings
81
+ unless ssl_opts[:cert_chain_file].class == ssl_opts[:private_key_file].class
82
+ raise 'You must specify both the certificate and key file!'
83
+ end
84
+
85
+ if ARGV.first == 'shell'
86
+ require 'pry'
87
+ binding.pry
88
+ exit 0
89
+ end
90
+
91
+ puts
92
+ puts
93
+ puts "Starting Driftwood slack logbot. Browse to http://localhost:#{options[:port]}"
94
+ puts
95
+ puts
96
+
97
+ Driftwood.run!(options) do |server|
98
+ if options[:ssl]
99
+ if server.respond_to? 'ssl='
100
+ $logger.info 'Enabling SSL support.'
101
+ server.ssl = true
102
+ server.ssl_options = ssl_opts
103
+ else
104
+ $logger.warn "Please 'gem install thin' or run via an app server for SSL support."
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,154 @@
1
+ require "google/cloud/bigquery"
2
+
3
+ class Driftwood::Bigquery
4
+ def initialize(config)
5
+ $logger.info "Starting Bigquery connection"
6
+
7
+ config[:keyfile] = File.expand_path(config[:keyfile])
8
+
9
+ @bigquery = Google::Cloud::Bigquery.new(
10
+ :project_id => config[:project],
11
+ :credentials => Google::Cloud::Bigquery::Credentials.new(config[:keyfile]),
12
+ )
13
+ @dataset = @bigquery.dataset(config[:dataset])
14
+ raise "\nThere is a problem with the gCloud configuration: \n #{JSON.pretty_generate(config)}" if @dataset.nil?
15
+
16
+ @teams = @dataset.table('slack_teams') || @dataset.create_table('slack_teams') do |table|
17
+ table.name = 'Slack Teams'
18
+ table.description = 'A list of all team names & ID, plus some metadata'
19
+ table.schema do |s|
20
+ s.string "team_id", mode: :required
21
+ s.string "name", mode: :required
22
+ s.string "user_access_token", mode: :required
23
+ s.string "bot_user_id", mode: :required
24
+ s.string "bot_access_token", mode: :required
25
+ end
26
+ end
27
+
28
+ @creation = @dataset.table('slack_user_creation') || @dataset.create_table('slack_user_creation') do |table|
29
+ table.name = 'Slack User Creation'
30
+ table.description = 'This keeps track of when users are created. The Slack API does not provide this info.'
31
+ table.schema do |s|
32
+ s.string "team_id", mode: :required
33
+ s.string "user_id", mode: :required
34
+ s.timestamp "creation", mode: :required
35
+ end
36
+ end
37
+
38
+ @messages = @dataset.table('slack_messages') || @dataset.create_table('slack_messages') do |table|
39
+ table.name = 'Slack Message Log'
40
+ table.description = 'All messages are logged here permanently.'
41
+ table.schema do |s|
42
+ s.string "team_id", mode: :required
43
+ s.string "channel_id", mode: :required
44
+ s.string "user_id", mode: :required
45
+ s.string "ts", mode: :required
46
+ s.string "text", mode: :required
47
+ end
48
+ end
49
+
50
+ # these we delete and rebuild because it's about 17 million times faster. Only slightly exaggerated.
51
+ @dataset.table('slack_channels').delete rescue nil
52
+ @channels = @dataset.create_table('slack_channels') do |table|
53
+ table.name = 'Slack Channels'
54
+ table.description = 'A list of all channels and metadata'
55
+ table.schema do |s|
56
+ s.string "team_id", mode: :required
57
+ s.string "channel_id", mode: :required
58
+ s.string "name", mode: :required
59
+ s.integer "created", mode: :required
60
+ s.boolean "is_private", mode: :required
61
+ s.string "topic", mode: :required
62
+ s.string "purpose", mode: :required
63
+ s.integer "num_members", mode: :required
64
+ end
65
+ end
66
+
67
+ @dataset.table('slack_users').delete rescue nil
68
+ @users = @dataset.create_table('slack_users') do |table|
69
+ table.name = 'Slack Users'
70
+ table.description = 'A list of all users and metadata. Ironically, we only store the email so we can delete if a GDPR request is made. We cannot do anything else with it.'
71
+ table.schema do |s|
72
+ s.string "team_id", mode: :required
73
+ s.string "user_id", mode: :required
74
+ s.string "name", mode: :required
75
+ s.string "real_name", mode: :required
76
+ s.string "display_name", mode: :required
77
+ s.boolean "is_owner", mode: :required
78
+ s.boolean "is_admin", mode: :required
79
+ s.string "title"
80
+ s.string "phone"
81
+ s.string "skype"
82
+ s.string "email"
83
+ s.string "tz"
84
+ s.string "tz_offset"
85
+ s.string "status_text", mode: :required
86
+ s.string "status_emoji", mode: :required
87
+ s.string "image_72", mode: :required
88
+ s.string "image_192", mode: :required
89
+ s.integer "updated", mode: :required
90
+ s.boolean "deleted", mode: :required
91
+ end
92
+ end
93
+ $logger.info "Bigquery initialization complete"
94
+ end
95
+
96
+ def get_auth_tokens()
97
+ @dataset.query("SELECT * FROM slack_teams").to_a rescue []
98
+ end
99
+
100
+ def insert_team(auth_token)
101
+ # We can take the 2.5s delete-before-insert here because it only happens when
102
+ # authorizing a new team integration.
103
+ @dataset.query("DELETE FROM slack_teams WHERE team_id = '#{auth_token[:team_id]}'")
104
+ @teams.insert(auth_token).success?
105
+ end
106
+
107
+ def insert_channel(record)
108
+ @channels.insert(record).success?
109
+ end
110
+
111
+ def insert_user(record)
112
+ @users.insert(record).success?
113
+ end
114
+
115
+ def insert_message(record)
116
+ @messages.insert(record).success?
117
+ end
118
+
119
+ def newest_message_timestamp
120
+ @dataset.query("SELECT ts FROM slack_messages ORDER BY ts DESC LIMIT 1").first[:ts] rescue 0
121
+ end
122
+
123
+ def record_user_creation(team_id, user_id)
124
+ @creation.insert(:team_id => team_id,
125
+ :user_id => user_id,
126
+ :creation => Time.now.strftime('%Y-%m-%d %H:%M:%S%z')).success?
127
+ end
128
+
129
+ # Ensure that all users have creation dates. If they don't have one yet, assume now
130
+ # This will catch any users who've been created during downtime if we restart the service
131
+ def reconcile_user_creations()
132
+ @dataset.query("INSERT INTO
133
+ slack_user_creation (team_id, user_id, creation)
134
+ (
135
+ SELECT team_id, user_id, CURRENT_TIMESTAMP()
136
+ FROM
137
+ slack_users
138
+ WHERE
139
+ user_id NOT IN
140
+ (
141
+ SELECT user_id from slack_user_creation
142
+ )
143
+ )")
144
+ end
145
+
146
+ def deduplicate_table(table)
147
+
148
+ end
149
+
150
+ def shell
151
+ require 'pry'
152
+ binding.pry
153
+ end
154
+ end
@@ -0,0 +1,331 @@
1
+ require 'slack-ruby-client'
2
+
3
+ class Driftwood::Slack
4
+ def initialize(config)
5
+ @config = config
6
+ @teams = {}
7
+ @handlers = {}
8
+
9
+ $logger.info "Started Slack bot"
10
+
11
+ # # Example event handler. See https://api.slack.com/events
12
+ # register_handler('message') do |team_id, event_data|
13
+ # $logger.debug 'message handler'
14
+ # $logger.debug "Team ID: #{team_id}"
15
+ # $logger.debug JSON.pretty_generate(event_data)
16
+ #
17
+ # next unless event_data['channel_type'] == 'im'
18
+ #
19
+ # user = event_data['user']
20
+ # text = event_data['text']
21
+ #
22
+ # case text
23
+ # when /^hello/i
24
+ # send_response(team_id, user, "Hi there, #{real_name(team_id, user)}!")
25
+ # when /^ping/i
26
+ # send_response(team_id, user, "Pong")
27
+ # when /^echo/i
28
+ # send_response(team_id, user, "Echo response: #{text.sub(/^echo /, '')}")
29
+ # end
30
+ # end
31
+ end
32
+
33
+ def authorize(auth)
34
+ team_id = nil
35
+ client = Slack::Web::Client.new
36
+ # OAuth Step 3: Success or failure
37
+ # is this a cached authentication object?
38
+ if auth.is_a? Array
39
+ $logger.info "Using cached authentication for:"
40
+ auth.each do |row|
41
+ team_id = row.delete(:team_id)
42
+ @teams[team_id] = row
43
+ $logger.info " * #{row[:name] || team_id}"
44
+ end
45
+
46
+ # we have a token from a single oauth exchange
47
+ elsif auth.is_a? String
48
+ begin
49
+ response = client.oauth_access(
50
+ {
51
+ client_id: @config[:client_id],
52
+ client_secret: @config[:client_secret],
53
+ redirect_uri: @config[:redirect_uri],
54
+ code: auth
55
+ }
56
+ )
57
+ team_id = response['team_id']
58
+ $logger.info "Authenticated new integration for team '#{team_id}'."
59
+ rescue Slack::Web::Api::Error => e
60
+ # Failure:
61
+ # D'oh! Let the user know that something went wrong and output the error message returned by the Slack client.
62
+ $logger.warn "Slack Authentication failed! Reason: #{e.message}"
63
+ raise "Slack Authentication failed! Reason: #{e.message}"
64
+ end
65
+
66
+ # Yay! Auth succeeded! Let's store the tokens
67
+ @teams[team_id] = {
68
+ :user_access_token => response['access_token'],
69
+ :bot_user_id => response['bot']['bot_user_id'],
70
+ :bot_access_token => response['bot']['bot_access_token']
71
+ }
72
+ else
73
+ raise "The authentication must either be a cached auth object or a token, not #{auth.class}"
74
+ end
75
+
76
+ # Create each Team's bot user and authorize the app to access the Team's Events.
77
+ @teams.each do |team_id, team|
78
+ begin
79
+ team[:client] = create_slack_client(team[:bot_access_token])
80
+ team[:user] = create_slack_client(team[:user_access_token])
81
+ team[:name] = team_name(team_id)
82
+ $logger.info "Authenticated tokens for team '#{team[:name]}'."
83
+ $logger.debug JSON.pretty_generate(@teams[team_id])
84
+ rescue Slack::Web::Api::Error => e
85
+ $logger.error "Cached authentication failed for team '#{team[:name] || team_id}'."
86
+ end
87
+ end
88
+
89
+ # return the hash structure w/o the client object for caching
90
+ team(team_id)
91
+ end
92
+
93
+ def register_handler(event, &block)
94
+ raise ArgumentError, "Handler (#{event}): no block passed" unless block_given?
95
+ raise ArgumentError, "Handler (#{event}): incorrect parameters" unless block.parameters.length == 2
96
+
97
+ @handlers[event] ||= Array.new
98
+ @handlers[event] << block
99
+
100
+ $logger.info "Registered slack handler for #{event}"
101
+ $logger.debug 'Current handlers:'
102
+ $logger.debug @handlers.inspect
103
+ end
104
+
105
+ def create_slack_client(slack_api_secret)
106
+ Slack.configure do |config|
107
+ config.token = slack_api_secret
108
+ fail 'Missing API token' unless config.token
109
+ end
110
+ Slack::Web::Client.new
111
+ end
112
+
113
+ def event(request_data)
114
+ # Check the verification token provided with the request to make sure it matches the verification token in
115
+ # your app's setting to confirm that the request came from Slack.
116
+ unless @config[:verification_token] == request_data['token']
117
+ $logger.warn "Invalid Slack verification token received: #{request_data['token']}"
118
+ raise "Invalid Slack verification token received: #{request_data['token']}"
119
+ end
120
+
121
+ case request_data['type']
122
+ # When you enter your Events webhook URL into your app's Event Subscription settings, Slack verifies the
123
+ # URL's authenticity by sending a challenge token to your endpoint, expecting your app to echo it back.
124
+ # More info: https://api.slack.com/events/url_verification
125
+ when 'url_verification'
126
+ $logger.debug "Authorizing challenge: #{request_data['challenge']}"
127
+ return request_data['challenge']
128
+
129
+ when 'event_callback'
130
+ # Get the Team ID and Event data from the request object
131
+ team_id = request_data['team_id']
132
+ event_data = request_data['event']
133
+ event_type = event_data['type']
134
+ user_id = event_data['user']
135
+
136
+ # Don't process events from our bot user
137
+ unless user_id == @teams[team_id][:bot_user_id]
138
+ if @handlers.include? event_type
139
+ begin
140
+ @handlers[event_type].each do |handler|
141
+ handler.call(team_id, event_data)
142
+ end
143
+ rescue => e
144
+ $logger.error "Handler for #{event_type} failed: #{e.message}"
145
+ $logger.debug e.backtrace.join("\n")
146
+ end
147
+ else
148
+ $logger.info "Unhandled event:"
149
+ $logger.debug JSON.pretty_generate(request_data)
150
+ end
151
+ end
152
+
153
+ end
154
+ 'ok'
155
+ end
156
+
157
+ # Send a response to an Event via the Web API.
158
+ def send_response(team_id, channel, text, unfurl_links = false, attachment = nil, ts = nil)
159
+ # `ts` is optional, depending on whether we're sending the initial
160
+ # welcome message or updating the existing welcome message tutorial items.
161
+ # We open a new DM with `chat.postMessage` and update an existing DM with
162
+ # `chat.update`.
163
+ if ts
164
+ @teams[team_id][:client].chat_update(
165
+ as_user: 'true',
166
+ channel: channel,
167
+ ts: ts,
168
+ text: text,
169
+ attachments: attachment,
170
+ unfurl_links: unfurl_links,
171
+ )
172
+ else
173
+ @teams[team_id][:client].chat_postMessage(
174
+ as_user: 'true',
175
+ channel: channel,
176
+ text: text,
177
+ attachments: attachment,
178
+ unfurl_links: unfurl_links,
179
+ )
180
+ end
181
+ end
182
+
183
+ def team(team_id)
184
+ teams.select {|team| team[:team_id] == team_id }.first
185
+ end
186
+
187
+ def teams()
188
+ # munge into an array of hashes, easier for external tools to use.
189
+ # ensure that the client object is removed to prevent serialization issues
190
+ # This weird copy-like thing is to prevent mutation of the original object
191
+ @teams.map do |team_id, team|
192
+ {:team_id => team_id}.merge(team.reject { |key,value| key == :client})
193
+ end
194
+ end
195
+
196
+ def channels(team_id)
197
+ preserve = ['team_id', 'channel_id', 'name', 'created', 'is_private', 'topic', 'purpose', 'num_members']
198
+ channels = @teams[team_id][:client].conversations_list(:types => 'public_channel, private_channel')
199
+ channels = channels['channels'].reject{|i| i['is_archived']}
200
+ channels.each do |row|
201
+ row['team_id'] = team_id
202
+ row['topic'] = row['topic']['value']
203
+ row['purpose'] = row['purpose']['value']
204
+ row['channel_id'] = row.delete('id')
205
+ row.select! {|field| preserve.include? field}
206
+ end
207
+ end
208
+
209
+ def users(team_id)
210
+ users = []
211
+ cursor = nil
212
+ loop do
213
+ data = @teams[team_id][:client].users_list(limit: 500, cursor: cursor)
214
+ cursor = data['response_metadata']['next_cursor']
215
+ users.concat data['members']
216
+
217
+ break if (cursor.nil? or cursor.empty?)
218
+ end
219
+
220
+ users.each do |row|
221
+ normalize_user(row)
222
+ end
223
+ end
224
+
225
+ def messages(team_id, starting_from = 0)
226
+ messages = []
227
+ channels = channels(team_id).map{|c| c['channel_id']}
228
+
229
+ channels.each do |channel_id|
230
+ oldest = starting_from
231
+ data = { 'has_more' => true }
232
+
233
+ while data['has_more'] do
234
+ # note that we need the *user* scope here
235
+ data = @teams[team_id][:user].channels_history(:channel => channel_id,
236
+ :count => 1000,
237
+ :oldest => oldest)
238
+
239
+ oldest = data['messages'].last['ts'] rescue oldest
240
+ messages.concat normalize_messages(team_id, channel_id, data['messages'])
241
+ end
242
+ end
243
+ messages
244
+ end
245
+
246
+ def all_channels
247
+ @teams.keys.inject([]) do |channels, team_id|
248
+ channels.concat(channels(team_id))
249
+ end
250
+ end
251
+
252
+ def all_users
253
+ @teams.keys.inject([]) do |users, team_id|
254
+ users.concat(users(team_id))
255
+ end
256
+ end
257
+
258
+ def all_messages(starting_from = 0)
259
+ @teams.keys.inject([]) do |messages, team_id|
260
+ messages.concat(messages(team_id, starting_from))
261
+ end
262
+ end
263
+
264
+ def user_info(team_id, user_id)
265
+ @teams[team_id][:client].users_info(user: user_id)['user']
266
+ end
267
+
268
+ def channel_info(team_id, channel_id)
269
+ @teams[team_id][:client].channels_info(channel: channel_id)['channel']
270
+ end
271
+
272
+ def team_info(team_id)
273
+ @teams[team_id][:client].team_info()['team']
274
+ end
275
+
276
+
277
+ def real_name(team_id, user_id)
278
+ user_info(team_id, user_id)['real_name'] rescue user_id
279
+ end
280
+
281
+ def channel_name(team_id, channel_id)
282
+ channel_info(team_id, channel_id)['name'] rescue channel_id
283
+ end
284
+
285
+ def team_name(team_id)
286
+ team_info(team_id)['name'] rescue team_id
287
+ end
288
+
289
+ def normalize_user(user)
290
+ preserve_fields = [ 'team_id', 'user_id', 'name', 'real_name','display_name',
291
+ 'updated', 'deleted', 'tz', 'tz_offset', 'is_owner', 'is_admin',
292
+ 'status_text', 'status_emoji', 'image_72', 'image_192',
293
+ ]
294
+ # Why slack? Why?
295
+ preserve_fields.concat ['title', 'phone', 'skype', 'email'] unless user['deleted']
296
+
297
+ user['user_id'] = user.delete('id')
298
+ user['title'] = user['profile']['title']
299
+ user['phone'] = user['profile']['phone']
300
+ user['skype'] = user['profile']['skype']
301
+ user['email'] = user['profile']['email']
302
+ user['display_name'] = user['profile']['display_name']
303
+ user['status_text'] = user['profile']['status_text']
304
+ user['status_emoji'] = user['profile']['status_emoji']
305
+ user['image_72'] = user['profile']['image_72']
306
+ user['image_192'] = user['profile']['image_192']
307
+ user.select! {|field| preserve_fields.include? field}
308
+ end
309
+
310
+ def normalize_message(team_id, message)
311
+ preserve_fields = [ 'team_id', 'user_id', 'text', 'channel_id','ts' ]
312
+
313
+ message['team_id'] = team_id
314
+ message['user_id'] = message.delete('user')
315
+ message['channel_id'] = message.delete('channel')
316
+ message.select! {|field| preserve_fields.include? field}
317
+ end
318
+
319
+ def normalize_messages(team_id, channel_id, messages)
320
+ messages.map do |message|
321
+ message['channel'] = channel_id
322
+ normalize_message(team_id, message)
323
+ end
324
+ end
325
+
326
+
327
+ def shell
328
+ require 'pry'
329
+ binding.pry
330
+ end
331
+ end
data/lib/driftwood.rb ADDED
@@ -0,0 +1,144 @@
1
+ require 'json'
2
+ require 'logger'
3
+ require 'sinatra/base'
4
+
5
+ class Driftwood < Sinatra::Base
6
+ require 'driftwood/slack'
7
+ require 'driftwood/bigquery'
8
+
9
+ set :logging, true
10
+ set :strict, true
11
+ set :views, File.dirname(__FILE__) + '/../views'
12
+ set :public_folder, File.dirname(__FILE__) + '/../public'
13
+
14
+ # this allows other tools to register handlers and interact with the db
15
+ attr_reader :slack, :bigquery
16
+
17
+ def initialize(app=nil)
18
+ super(app)
19
+ @bigquery = Driftwood::Bigquery.new(settings.gcloud)
20
+ @slack = Driftwood::Slack.new(settings.slack)
21
+ @slack.authorize(cached_auth)
22
+
23
+ @slack.register_handler('message') do |team_id, event_data|
24
+ $logger.debug 'message handler'
25
+ $logger.debug "Team ID: #{team_id}"
26
+ $logger.debug "Channel Name: #{@slack.channel_name(team_id, event_data['channel'])}"
27
+ $logger.debug JSON.pretty_generate(event_data)
28
+
29
+ next unless event_data['channel_type'] == 'im'
30
+
31
+ user = event_data['user']
32
+ text = event_data['text']
33
+
34
+ case text
35
+ when /^hello/i
36
+ @slack.send_response(team_id, user, "Hi there, #{@slack.real_name(team_id, user)}!")
37
+ when /^ping/i
38
+ @slack.send_response(team_id, user, "Pong")
39
+ when /^echo/i
40
+ @slack.send_response(team_id, user, "Echo response: #{text.sub(/^echo /, '')}")
41
+ end
42
+
43
+ end
44
+
45
+ @slack.register_handler('team_join') do |team_id, event_data|
46
+ user = @slack.normalize_user(event_data['user'])
47
+ $logger.info "Registered new user: #{user['name']} (#{user['real_name']})"
48
+ @bigquery.insert_user(user)
49
+ end
50
+
51
+ @slack.register_handler('message') do |team_id, event_data|
52
+ next unless event_data['channel_type'] == 'channel'
53
+ $logger.debug "Logged message for #{team_id}: #{event_data['text']}"
54
+ @bigquery.insert_message(@slack.normalize_message(team_id, event_data))
55
+ end
56
+
57
+ # Do this in a thread so it doesn't block initialization
58
+ # These methods may take a long time to complete
59
+ Thread.new do
60
+ begin
61
+ $logger.info "Starting data synchronization..."
62
+ sync_channels
63
+ sync_users
64
+ sync_messages
65
+ rescue => e
66
+ $logger.error e.message
67
+ $logger.debug e.backtrace.join("\n")
68
+ end
69
+ end
70
+
71
+ end
72
+
73
+ get '/' do
74
+ if params[:code]
75
+ begin
76
+ save_auth_cache(@slack.authorize(params[:code]))
77
+ @message = "Rad, you've been authenticated!"
78
+ status 200
79
+
80
+ rescue => e
81
+ $logger.error e.message
82
+ $logger.debug e.backtrace.join("\n")
83
+
84
+ status 403
85
+ @error = e.message
86
+ end
87
+ end
88
+
89
+ erb :index
90
+ end
91
+
92
+ post '/events' do
93
+ begin
94
+ # Return HTTP status code 200 so Slack knows we've received the Event
95
+ status 200
96
+ body @slack.event(JSON.parse(request.body.read))
97
+ rescue => e
98
+ $logger.error e.message
99
+ $logger.debug e.backtrace.join("\n")
100
+ status 403
101
+ body "Error: #{e.message}"
102
+ end
103
+ end
104
+
105
+
106
+ not_found do
107
+ halt 404, "You shall not pass! (page not found)\n"
108
+ end
109
+
110
+ helpers do
111
+ def cached_auth
112
+ @bigquery.get_auth_tokens()
113
+ end
114
+
115
+ def save_auth_cache(auth)
116
+ @bigquery.insert_team(auth)
117
+ end
118
+
119
+ def sync_channels
120
+ @slack.all_channels.each do |channel|
121
+ @bigquery.insert_channel(channel)
122
+ end
123
+ $logger.info "All channels synced."
124
+ end
125
+
126
+ def sync_users
127
+ @slack.all_users.each do |user|
128
+ @bigquery.insert_user(user)
129
+ end
130
+ @bigquery.reconcile_user_creations
131
+ $logger.info "All users synced."
132
+ end
133
+
134
+ def sync_messages
135
+ starting_from = @bigquery.newest_message_timestamp
136
+ @slack.all_messages(starting_from).each do |message|
137
+ @bigquery.insert_message(message)
138
+ end
139
+ $logger.info "All messages synced."
140
+ end
141
+
142
+ end
143
+
144
+ end
Binary file
data/views/index.erb ADDED
@@ -0,0 +1,83 @@
1
+ <html
2
+ <head>
3
+ <title>Driftwood</title>
4
+ <style>
5
+ body {
6
+ background-color: #f7fafd;
7
+ }
8
+ div.content {
9
+ width: 80%;
10
+ padding: 2em;
11
+ margin: 2em auto;
12
+ border: 2px ridge #a29999;
13
+ border-radius: 1em;
14
+ box-shadow: 5px 10px 8px #888888;
15
+ background-color: #efefef;
16
+ background-image: url(driftwood.jpg);
17
+ background-position: center;
18
+ }
19
+ h1, h2 {
20
+ text-align: center;
21
+ text-shadow: #ffe896 1px 0 10px;
22
+ }
23
+ p {
24
+ width: 80%;
25
+ margin: 1em auto;
26
+ text-shadow: #7f7d7e 1px 0 10px;
27
+ }
28
+ p.message {
29
+ width: 50%;
30
+ margin: 3em auto;
31
+ padding: 1em;
32
+ border-radius: 0.5em;
33
+ border: 2px solid #207c2b;
34
+ background-color: #f6f6f6;
35
+ }
36
+ p.error {
37
+ border: 2px solid red;
38
+ }
39
+ a#slack {
40
+ display: block;
41
+ margin: 0 auto;
42
+ text-align: center;
43
+ }
44
+ </style>
45
+ </head>
46
+ <body>
47
+ <div class="content">
48
+ <h1>Driftwood</h1>
49
+ <h2>Extensible Slack Logbot</h2>
50
+ <p>
51
+ The Slack logging service isn't very complete, especially if you're on the free
52
+ tier. For example, messages expire far too soon on an active channel and users
53
+ don't include creation times. This tool provides those missing features, and soon
54
+ will also provide features for building a positive team culture.
55
+ </p>
56
+ <p>
57
+ Out of the box, Driftwood will log channels, users, and messages for Slack
58
+ teams. It also generates & tracks some metadata that the Slack API doesn't
59
+ provide, such as user account age. The database backend is currently limited
60
+ to Google BigQuery only, which makes it very easy to connect it to data
61
+ visualization dashboards, such as Looker.
62
+ </p>
63
+ <p>
64
+ Driftwood is extensible and allows you to easily hook into any Slack event
65
+ notifications. It's currently a relatively manual process to do so, but we'll
66
+ soon have a real plugin architecture and will then be able to list out each
67
+ integration installed below.
68
+ </p>
69
+ <hr />
70
+ <% if @message %>
71
+ <p class="message"><%= @message %></p>
72
+ <% end %>
73
+ <% if @error %>
74
+ <p class="message error"><%= @error %></p>
75
+ <% end %>
76
+ <p>
77
+ <a id="slack" href="https://slack.com/oauth/authorize?scope=bot%20channels:history&client_id=<%= settings.slack[:client_id] %>&redirect_uri=<%= settings.slack[:redirect_uri] %>">
78
+ <img alt="Add to Slack" height="40" width="139" src="https://platform.slack-edge.com/img/add_to_slack.png"/>
79
+ </a>
80
+ </p>
81
+ </div>
82
+ </body>
83
+ </html>
@@ -0,0 +1,7 @@
1
+ Hi <%= @user[:real_name] %>!
2
+
3
+ We know you hate these as much as we do, but sometimes we need to notify you of certain changes. We'll keep it super brief.
4
+ <% @updates.each do |row| %>
5
+ We've updated our <%= row[:name] %>: <%= row[:summary] %>
6
+ See more at <%= row[:url] %>
7
+ <% end %>
metadata ADDED
@@ -0,0 +1,100 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: driftwood
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Ben Ford
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2018-07-18 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: sinatra
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.3'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.3'
27
+ - !ruby/object:Gem::Dependency
28
+ name: google-cloud-bigquery
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: slack-ruby-client
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ description: |2
56
+ The Slack logging service isn't very complete, especially if you're on the free
57
+ tier. For example, messages expire far too soon on an active channel and users
58
+ don't include creation times. This tool provides those missing features, and soon
59
+ will also provide features for building a positive team culture.
60
+ email: ben.ford@puppet.com
61
+ executables:
62
+ - driftwood
63
+ extensions: []
64
+ extra_rdoc_files: []
65
+ files:
66
+ - CHANGELOG.md
67
+ - LICENSE
68
+ - README.md
69
+ - bin/driftwood
70
+ - lib/driftwood.rb
71
+ - lib/driftwood/bigquery.rb
72
+ - lib/driftwood/slack.rb
73
+ - public/driftwood.jpg
74
+ - views/index.erb
75
+ - views/policy_updates.erb
76
+ homepage: https://github.com/puppetlabs/driftwood
77
+ licenses:
78
+ - Apache-2.0
79
+ metadata: {}
80
+ post_install_message:
81
+ rdoc_options: []
82
+ require_paths:
83
+ - lib
84
+ required_ruby_version: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
89
+ required_rubygems_version: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - ">="
92
+ - !ruby/object:Gem::Version
93
+ version: '0'
94
+ requirements: []
95
+ rubyforge_project:
96
+ rubygems_version: 2.6.10
97
+ signing_key:
98
+ specification_version: 4
99
+ summary: Simple Sinatra based Slack logbot.
100
+ test_files: []