slacky 0.1
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.
- data/.gitignore +4 -0
- data/.ruby-version +1 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +70 -0
- data/README.md +15 -0
- data/Rakefile +10 -0
- data/lib/slacky.rb +12 -0
- data/lib/slacky/bookkeeper.rb +55 -0
- data/lib/slacky/bot.rb +229 -0
- data/lib/slacky/channel.rb +103 -0
- data/lib/slacky/cli.rb +35 -0
- data/lib/slacky/config.rb +66 -0
- data/lib/slacky/daemon.rb +67 -0
- data/lib/slacky/message.rb +61 -0
- data/lib/slacky/service.rb +73 -0
- data/lib/slacky/user.rb +91 -0
- data/lib/slacky/version.rb +3 -0
- data/slacky.gemspec +31 -0
- metadata +245 -0
data/.gitignore
ADDED
data/.ruby-version
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
1.9.3-p448
|
data/Gemfile
ADDED
data/Gemfile.lock
ADDED
@@ -0,0 +1,70 @@
|
|
1
|
+
PATH
|
2
|
+
remote: .
|
3
|
+
specs:
|
4
|
+
slacky (0.1)
|
5
|
+
dotenv
|
6
|
+
em-cron
|
7
|
+
eventmachine
|
8
|
+
faye-websocket
|
9
|
+
pg
|
10
|
+
slack-ruby-client (>= 0.6)
|
11
|
+
tzinfo
|
12
|
+
tzinfo-data
|
13
|
+
|
14
|
+
GEM
|
15
|
+
remote: http://rubygems.org/
|
16
|
+
specs:
|
17
|
+
activesupport (4.2.6)
|
18
|
+
i18n (~> 0.7)
|
19
|
+
json (~> 1.7, >= 1.7.7)
|
20
|
+
minitest (~> 5.1)
|
21
|
+
thread_safe (~> 0.3, >= 0.3.4)
|
22
|
+
tzinfo (~> 1.1)
|
23
|
+
dotenv (2.1.1)
|
24
|
+
em-cron (0.2.0)
|
25
|
+
eventmachine
|
26
|
+
parse-cron (~> 0.1)
|
27
|
+
eventmachine (1.2.0.1)
|
28
|
+
factory_girl (4.7.0)
|
29
|
+
activesupport (>= 3.0.0)
|
30
|
+
faraday (0.9.2)
|
31
|
+
multipart-post (>= 1.2, < 3)
|
32
|
+
faraday_middleware (0.10.0)
|
33
|
+
faraday (>= 0.7.4, < 0.10)
|
34
|
+
faye-websocket (0.10.3)
|
35
|
+
eventmachine (>= 0.12.0)
|
36
|
+
websocket-driver (>= 0.5.1)
|
37
|
+
gli (2.13.4)
|
38
|
+
hashie (3.4.4)
|
39
|
+
i18n (0.7.0)
|
40
|
+
json (1.8.3)
|
41
|
+
minitest (5.8.4)
|
42
|
+
multipart-post (2.0.0)
|
43
|
+
parse-cron (0.1.4)
|
44
|
+
pg (0.18.4)
|
45
|
+
rake (11.1.2)
|
46
|
+
slack-ruby-client (0.7.2)
|
47
|
+
activesupport
|
48
|
+
faraday
|
49
|
+
faraday_middleware
|
50
|
+
gli
|
51
|
+
hashie
|
52
|
+
json
|
53
|
+
websocket-driver
|
54
|
+
thread_safe (0.3.5)
|
55
|
+
tzinfo (1.2.2)
|
56
|
+
thread_safe (~> 0.1)
|
57
|
+
tzinfo-data (1.2016.4)
|
58
|
+
tzinfo (>= 1.0.0)
|
59
|
+
websocket-driver (0.6.3)
|
60
|
+
websocket-extensions (>= 0.1.0)
|
61
|
+
websocket-extensions (0.1.2)
|
62
|
+
|
63
|
+
PLATFORMS
|
64
|
+
ruby
|
65
|
+
|
66
|
+
DEPENDENCIES
|
67
|
+
factory_girl
|
68
|
+
minitest
|
69
|
+
rake
|
70
|
+
slacky!
|
data/README.md
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
# Slacky
|
2
|
+
|
3
|
+
### Carbon Five Slack bot starter kit
|
4
|
+
|
5
|
+
This gem is an out of the box Slack bot running in Ruby. It runs as a
|
6
|
+
daemon, has a CLI, and in the future will integrate with other Carbon
|
7
|
+
Five services (like Timesheet).
|
8
|
+
|
9
|
+
## Installation
|
10
|
+
|
11
|
+
...
|
12
|
+
|
13
|
+
## Usage
|
14
|
+
|
15
|
+
...
|
data/Rakefile
ADDED
data/lib/slacky.rb
ADDED
@@ -0,0 +1,12 @@
|
|
1
|
+
module Slacky
|
2
|
+
require 'slacky/version'
|
3
|
+
require 'slacky/cli'
|
4
|
+
require 'slacky/config'
|
5
|
+
require 'slacky/daemon'
|
6
|
+
require 'slacky/service'
|
7
|
+
require 'slacky/bot'
|
8
|
+
require 'slacky/bookkeeper'
|
9
|
+
require 'slacky/user'
|
10
|
+
require 'slacky/channel'
|
11
|
+
require 'slacky/message'
|
12
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
module Slacky
|
2
|
+
class Bookkeeper
|
3
|
+
def initialize(client)
|
4
|
+
@client = client
|
5
|
+
end
|
6
|
+
|
7
|
+
def web_client
|
8
|
+
@client.web_client
|
9
|
+
end
|
10
|
+
|
11
|
+
def keep_the_books
|
12
|
+
@client.on :presence_change do |data|
|
13
|
+
next unless ( user = User.find data.user )
|
14
|
+
user.presence = data['presence']
|
15
|
+
user.save
|
16
|
+
end
|
17
|
+
|
18
|
+
@client.on :channel_created do |data|
|
19
|
+
web_client.channels_info(channel: data.channel.id).tap do |resp|
|
20
|
+
if resp.ok
|
21
|
+
channel = Channel.channel resp.channel
|
22
|
+
puts "Channel ##{channel.name}: channel_created"
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
handle_channel(:channel_deleted) { |c| c.delete }
|
28
|
+
handle_channel(:channel_archive) { |c| c.archive }
|
29
|
+
handle_channel(:channel_unarchive) { |c| c.unarchive }
|
30
|
+
handle_channel(:channel_rename) { |c, data| c.rename data.channel.name }
|
31
|
+
|
32
|
+
@client.on :group_joined do |data|
|
33
|
+
next unless data.channel.is_group
|
34
|
+
channel = Channel.group data.channel
|
35
|
+
puts "Channel ##{channel.name}: group_joined"
|
36
|
+
end
|
37
|
+
|
38
|
+
handle_channel(:group_left) { |g| g.delete }
|
39
|
+
handle_channel(:group_archive) { |g| g.archive }
|
40
|
+
handle_channel(:group_unarchive) { |g| g.unarchive }
|
41
|
+
handle_channel(:group_rename) { |g, data| g.rename data.channel.name }
|
42
|
+
end
|
43
|
+
|
44
|
+
def handle_channel(event)
|
45
|
+
@client.on event do |data|
|
46
|
+
channel_id = data.channel.is_a?(String) ? data.channel : data.channel.id
|
47
|
+
Channel.find(channel_id).tap do |channel|
|
48
|
+
yield channel, data
|
49
|
+
puts "Channel ##{channel.name}: #{event}"
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
end
|
55
|
+
end
|
data/lib/slacky/bot.rb
ADDED
@@ -0,0 +1,229 @@
|
|
1
|
+
require 'slack-ruby-client'
|
2
|
+
require 'set'
|
3
|
+
require 'tzinfo'
|
4
|
+
require 'em/cron'
|
5
|
+
|
6
|
+
module Slacky
|
7
|
+
class Bot
|
8
|
+
attr_reader :client, :config, :slack_id
|
9
|
+
|
10
|
+
def initialize(config)
|
11
|
+
puts "#{config.name} is starting up..."
|
12
|
+
|
13
|
+
@config = config
|
14
|
+
@restarts = []
|
15
|
+
@command_handlers = []
|
16
|
+
@channel_handlers = []
|
17
|
+
@im_handlers = []
|
18
|
+
@raw_handlers = []
|
19
|
+
@cron_handlers = []
|
20
|
+
|
21
|
+
unless @config.slack_api_token
|
22
|
+
@config.log "No Slack API token found. Use environment variable SLACK_API_TOKEN."
|
23
|
+
return
|
24
|
+
end
|
25
|
+
|
26
|
+
Slack.configure do |slack_cfg|
|
27
|
+
slack_cfg.token = @config.slack_api_token
|
28
|
+
end
|
29
|
+
|
30
|
+
@client = Slack::RealTime::Client.new
|
31
|
+
|
32
|
+
auth = web_client.auth_test
|
33
|
+
if auth.ok
|
34
|
+
@slack_id = auth.user_id
|
35
|
+
@config.log "Slackbot is active!"
|
36
|
+
else
|
37
|
+
@config.log "Slackbot is doomed :-("
|
38
|
+
return
|
39
|
+
end
|
40
|
+
|
41
|
+
@bookkeeper = Bookkeeper.new @client
|
42
|
+
|
43
|
+
Channel.bot = self
|
44
|
+
Message.bot = self
|
45
|
+
|
46
|
+
populate_users
|
47
|
+
populate_channels
|
48
|
+
end
|
49
|
+
|
50
|
+
def web_client
|
51
|
+
@client.web_client
|
52
|
+
end
|
53
|
+
|
54
|
+
def name
|
55
|
+
@config.down_name
|
56
|
+
end
|
57
|
+
|
58
|
+
def known_commands
|
59
|
+
@command_handlers.map { |ch| ch[:command] }
|
60
|
+
end
|
61
|
+
|
62
|
+
def on(type, &block)
|
63
|
+
@raw_handlers << { type: type, handler: block }
|
64
|
+
end
|
65
|
+
|
66
|
+
def on_command(command, &block)
|
67
|
+
@command_handlers << { command: command.downcase, handler: block }
|
68
|
+
end
|
69
|
+
|
70
|
+
def on_message(attrs, &block)
|
71
|
+
attrs ||= {}
|
72
|
+
@channel_handlers << { match: attrs[:match], channels: attrs[:channels], handler: block }
|
73
|
+
end
|
74
|
+
|
75
|
+
def on_im(attrs, &block)
|
76
|
+
attrs ||= {}
|
77
|
+
@im_handlers << { match: attrs[:match], handler: block }
|
78
|
+
end
|
79
|
+
|
80
|
+
def at(cron, &block)
|
81
|
+
@cron_handlers << { cron: cron, handler: block }
|
82
|
+
end
|
83
|
+
|
84
|
+
def handle_channel(message)
|
85
|
+
handled = false
|
86
|
+
|
87
|
+
if message.command?
|
88
|
+
@command_handlers.each do |h|
|
89
|
+
command, handler = h.values_at :command, :handler
|
90
|
+
next unless command == message.command
|
91
|
+
@client.typing channel: message.channel.slack_id
|
92
|
+
handler.call message
|
93
|
+
handled = true
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
return if handled
|
98
|
+
|
99
|
+
@channel_handlers.each do |h|
|
100
|
+
match, channels, handler = h.values_at :match, :channels, :handler
|
101
|
+
accept = Channel.find channels
|
102
|
+
next if accept && accept.include?(message.channel)
|
103
|
+
next if match && ! match === message
|
104
|
+
@client.typing channel: message.channel.slack_id
|
105
|
+
handler.call message
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
def handle_im(message)
|
110
|
+
unless message.user.slack_im_id == message.channel.slack_id
|
111
|
+
message.user.slack_im_id = message.channel.slack_id
|
112
|
+
message.user.save
|
113
|
+
end
|
114
|
+
|
115
|
+
handled = false
|
116
|
+
|
117
|
+
@command_handlers.each do |h|
|
118
|
+
command, handler = h.values_at :command, :handler
|
119
|
+
next unless command == message.command
|
120
|
+
@client.typing channel: message.channel.slack_id
|
121
|
+
handler.call message
|
122
|
+
handled = true
|
123
|
+
end
|
124
|
+
|
125
|
+
return if handled
|
126
|
+
|
127
|
+
@im_handlers.each do |h|
|
128
|
+
match, handler = h.values_at :match, :handler
|
129
|
+
next if match && ! match === message
|
130
|
+
@client.typing channel: message.channel.slack_id
|
131
|
+
handler.call message
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
def run
|
136
|
+
@bookkeeper.keep_the_books
|
137
|
+
|
138
|
+
@client.on :message do |data|
|
139
|
+
next unless ( user = User.find data.user )
|
140
|
+
|
141
|
+
channel = Channel.find data.channel
|
142
|
+
channel = Channel.im data.channel, user if data.channel =~ /^D/ && ! channel
|
143
|
+
next unless channel
|
144
|
+
|
145
|
+
reject = Channel.find @config.slack_reject_channels
|
146
|
+
next if reject && reject.find { |c| c.slack_id == data.channel }
|
147
|
+
|
148
|
+
accept = Channel.find @config.slack_accept_channels
|
149
|
+
next if accept && ! accept.find { |c| c.slack_id == data.channel }
|
150
|
+
|
151
|
+
message = Message.new(user, channel, data)
|
152
|
+
handle_channel message if [ :group, :channel ].include? channel.type
|
153
|
+
handle_im message if [ :im ].include? channel.type
|
154
|
+
end
|
155
|
+
|
156
|
+
@raw_handlers.each do |h|
|
157
|
+
type, handler = h.values_at :type, :handler
|
158
|
+
@client.on type do |data|
|
159
|
+
handler.call data
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
@cron_thread ||= Thread.new do
|
164
|
+
EM.run do
|
165
|
+
@cron_handlers.each do |h|
|
166
|
+
cron, handler = h.values_at :cron, :handler
|
167
|
+
EM::Cron.schedule cron do |time|
|
168
|
+
begin
|
169
|
+
handler.call
|
170
|
+
rescue => e
|
171
|
+
@config.log "An error ocurred inside the Slackbot (in a scheduled block)", e
|
172
|
+
end
|
173
|
+
end
|
174
|
+
end
|
175
|
+
end
|
176
|
+
end
|
177
|
+
|
178
|
+
puts "#{@config.name} is listening to: #{@config.slack_accept_channels}"
|
179
|
+
|
180
|
+
@client.start!
|
181
|
+
rescue => e
|
182
|
+
@config.log "An error ocurred inside the Slackbot", e
|
183
|
+
@restarts << Time.new
|
184
|
+
@restarts.shift while (@restarts.length > 3)
|
185
|
+
if @restarts.length == 3 and ( Time.new - @restarts.first < 30 )
|
186
|
+
@config.log "Too many errors. Not restarting anymore."
|
187
|
+
else
|
188
|
+
run
|
189
|
+
end
|
190
|
+
end
|
191
|
+
|
192
|
+
def populate_users
|
193
|
+
print "Getting users from Slack..."
|
194
|
+
resp = web_client.users_list presence: 1
|
195
|
+
throw resp unless resp.ok
|
196
|
+
resp.members.map do |member|
|
197
|
+
next unless member.profile.email # no bots
|
198
|
+
next if member.deleted # no ghosts
|
199
|
+
next if member.is_ultra_restricted # no single channel guests
|
200
|
+
user = User.find(member.id) || User.new(slack_id: member.id)
|
201
|
+
user.populate(member).save
|
202
|
+
end
|
203
|
+
puts " done!"
|
204
|
+
end
|
205
|
+
|
206
|
+
def populate_channels
|
207
|
+
print "Getting channels from Slack..."
|
208
|
+
resp = web_client.channels_list
|
209
|
+
throw resp unless resp.ok
|
210
|
+
resp.channels.map do |channel|
|
211
|
+
Channel.channel channel
|
212
|
+
end
|
213
|
+
|
214
|
+
resp = web_client.groups_list
|
215
|
+
throw resp unless resp.ok
|
216
|
+
resp.groups.map do |group|
|
217
|
+
Channel.group group
|
218
|
+
end
|
219
|
+
puts " done!"
|
220
|
+
end
|
221
|
+
|
222
|
+
def blowup(user, data, args, &respond)
|
223
|
+
respond.call "Tick... tick... tick... BOOM! Goodbye."
|
224
|
+
EM.next_tick do
|
225
|
+
raise "kablammo!"
|
226
|
+
end
|
227
|
+
end
|
228
|
+
end
|
229
|
+
end
|
@@ -0,0 +1,103 @@
|
|
1
|
+
module Slacky
|
2
|
+
class Channel
|
3
|
+
attr_reader :slack_id, :topic, :purpose, :members, :type
|
4
|
+
attr_accessor :name
|
5
|
+
|
6
|
+
@@channels = {}
|
7
|
+
@@bot = nil
|
8
|
+
|
9
|
+
def self.bot=(bot)
|
10
|
+
@@bot = bot
|
11
|
+
end
|
12
|
+
|
13
|
+
def self.find(channel)
|
14
|
+
return nil unless channel
|
15
|
+
return channel.map { |c| Channel.find c }.compact if channel.is_a? Array
|
16
|
+
return channel if channel.is_a? Channel
|
17
|
+
@@channels[channel]
|
18
|
+
end
|
19
|
+
|
20
|
+
def self.im(channel_id, user)
|
21
|
+
Channel.new.populate_im(channel_id, user).save
|
22
|
+
end
|
23
|
+
|
24
|
+
def self.channel(channel_data)
|
25
|
+
Channel.new.populate_channel(channel_data).save
|
26
|
+
end
|
27
|
+
|
28
|
+
def self.group(group_data)
|
29
|
+
Channel.new.populate_group(group_data).save
|
30
|
+
end
|
31
|
+
|
32
|
+
def archived?
|
33
|
+
@archived
|
34
|
+
end
|
35
|
+
|
36
|
+
def archive
|
37
|
+
@archived = true
|
38
|
+
end
|
39
|
+
|
40
|
+
def unarchive
|
41
|
+
@archived = false
|
42
|
+
end
|
43
|
+
|
44
|
+
def delete
|
45
|
+
@@channels.delete @slack_id
|
46
|
+
@@channels.delete "##{@name}"
|
47
|
+
@@channels.delete "@#{@user.username}" if @user
|
48
|
+
end
|
49
|
+
|
50
|
+
def rename(name)
|
51
|
+
@@channels.delete "##{@name}"
|
52
|
+
@name = name
|
53
|
+
@@channels["##{@name}"] = self
|
54
|
+
end
|
55
|
+
|
56
|
+
def member?
|
57
|
+
case @type
|
58
|
+
when :channel ; @member
|
59
|
+
when :group ; @members[@@bot.slack_id]
|
60
|
+
when :im ; true
|
61
|
+
else throw "Unknown channel type: #{@type}"
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def save
|
66
|
+
@@channels[@slack_id] = self
|
67
|
+
@@channels["##{@name}"] = self if @name
|
68
|
+
@@channels["@#{@user.username}"] = self if @user
|
69
|
+
self
|
70
|
+
end
|
71
|
+
|
72
|
+
def populate_channel(channel)
|
73
|
+
populate channel
|
74
|
+
@type = :channel
|
75
|
+
@member = channel.is_member
|
76
|
+
self
|
77
|
+
end
|
78
|
+
|
79
|
+
def populate_group(group)
|
80
|
+
populate group
|
81
|
+
@type = :group
|
82
|
+
@members = User.find group.members
|
83
|
+
self
|
84
|
+
end
|
85
|
+
|
86
|
+
def populate_im(slack_id, user)
|
87
|
+
@type = :im
|
88
|
+
@slack_id = slack_id
|
89
|
+
@user = user
|
90
|
+
self
|
91
|
+
end
|
92
|
+
|
93
|
+
private
|
94
|
+
|
95
|
+
def populate(channel)
|
96
|
+
@slack_id = channel.id
|
97
|
+
@name = channel.name
|
98
|
+
@archived = channel.is_archived
|
99
|
+
@topic = channel.topic.value
|
100
|
+
@purpose = channel.purpose.value
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
data/lib/slacky/cli.rb
ADDED
@@ -0,0 +1,35 @@
|
|
1
|
+
module Slacky
|
2
|
+
class CLI
|
3
|
+
attr_reader :bot
|
4
|
+
|
5
|
+
def initialize(name, opts)
|
6
|
+
throw "CLI must be passed a name" unless name
|
7
|
+
@options = { :verbose => false }.merge opts
|
8
|
+
config = Config.new name
|
9
|
+
@bot = Bot.new config
|
10
|
+
daemon = Daemon.new config, bot
|
11
|
+
@service = Service.new config, daemon
|
12
|
+
end
|
13
|
+
|
14
|
+
def run(params)
|
15
|
+
@service.run
|
16
|
+
end
|
17
|
+
|
18
|
+
def start(params)
|
19
|
+
@service.start
|
20
|
+
end
|
21
|
+
|
22
|
+
def stop(params)
|
23
|
+
@service.stop
|
24
|
+
end
|
25
|
+
|
26
|
+
def restart(params)
|
27
|
+
@service.restart
|
28
|
+
end
|
29
|
+
|
30
|
+
def status(params)
|
31
|
+
@service.status
|
32
|
+
end
|
33
|
+
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
require 'yaml'
|
2
|
+
require 'time'
|
3
|
+
require 'pg'
|
4
|
+
require 'dotenv'
|
5
|
+
|
6
|
+
module Slacky
|
7
|
+
class Config
|
8
|
+
attr_reader :pid_file, :name, :db
|
9
|
+
|
10
|
+
def initialize(name, opts = {})
|
11
|
+
@name = name
|
12
|
+
Dotenv.load ".env", "#{config_dir}/.env"
|
13
|
+
FileUtils.mkdir config_dir unless File.directory? config_dir
|
14
|
+
@pid_file = "#{config_dir}/pid"
|
15
|
+
@db = PG.connect(db_connect_params)
|
16
|
+
User.db = @db
|
17
|
+
User.initialize_table
|
18
|
+
|
19
|
+
@timestamps = {}
|
20
|
+
end
|
21
|
+
|
22
|
+
def down_name
|
23
|
+
@name.downcase
|
24
|
+
end
|
25
|
+
|
26
|
+
def slack_api_token
|
27
|
+
ENV['SLACK_API_TOKEN']
|
28
|
+
end
|
29
|
+
|
30
|
+
def config_dir
|
31
|
+
ENV['CONFIG_DIR'] || "#{ENV['HOME']}/.#{down_name}"
|
32
|
+
end
|
33
|
+
|
34
|
+
def slack_reject_channels
|
35
|
+
return nil unless ENV['REJECT_CHANNELS']
|
36
|
+
ENV['REJECT_CHANNELS'].split(',').map {|c| c.strip}
|
37
|
+
end
|
38
|
+
|
39
|
+
def slack_accept_channels
|
40
|
+
return nil unless ENV['ACCEPT_CHANNELS']
|
41
|
+
ENV['ACCEPT_CHANNELS'].split(',').map {|c| c.strip}
|
42
|
+
end
|
43
|
+
|
44
|
+
def log(msg, ex = nil)
|
45
|
+
log = File.new(log_file, 'a')
|
46
|
+
timestamp = Time.now.strftime('%Y-%m-%d %H:%M:%S')
|
47
|
+
type = ex ? 'ERROR' : ' INFO'
|
48
|
+
log.puts "#{type} #{timestamp} #{msg}"
|
49
|
+
if ex
|
50
|
+
log.puts ex.message
|
51
|
+
log.puts("Stacktrace:\n" + ex.backtrace.join("\n"))
|
52
|
+
end
|
53
|
+
log.flush
|
54
|
+
end
|
55
|
+
|
56
|
+
private
|
57
|
+
|
58
|
+
def db_connect_params
|
59
|
+
ENV['DATABASE_URL'] || { dbname: "slacky_#{down_name}" }
|
60
|
+
end
|
61
|
+
|
62
|
+
def log_file
|
63
|
+
"#{config_dir}/#{down_name}.log"
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
module Slacky
|
2
|
+
class Daemon
|
3
|
+
|
4
|
+
def initialize(config, bot)
|
5
|
+
@config = config
|
6
|
+
@bot = bot
|
7
|
+
@active = true
|
8
|
+
@running = false
|
9
|
+
end
|
10
|
+
|
11
|
+
def start(daemonize = true)
|
12
|
+
Process.daemon if daemonize
|
13
|
+
write_pid
|
14
|
+
|
15
|
+
[ 'HUP', 'INT', 'QUIT', 'TERM' ].each do |sig|
|
16
|
+
Signal.trap(sig) do
|
17
|
+
@config.log "Interrupted with signal: #{sig}"
|
18
|
+
kill
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
begin
|
23
|
+
@slackthread = Thread.new { @bot.run }
|
24
|
+
run @bot
|
25
|
+
rescue => e
|
26
|
+
@config.log "Unexpected error", e
|
27
|
+
ensure
|
28
|
+
cleanup
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def cleanup
|
33
|
+
delete_pid
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
def run(slackbot)
|
39
|
+
@config.log "#{@config.name} is running."
|
40
|
+
while active? do
|
41
|
+
# TODO: handle timed tasks
|
42
|
+
#slackbot.ask_all if time.min % 10 == 0 # every 10 minutes
|
43
|
+
sleep 0.5
|
44
|
+
end
|
45
|
+
@config.log "#{@config.name} got killed"
|
46
|
+
@slackthread.kill
|
47
|
+
end
|
48
|
+
|
49
|
+
def active?
|
50
|
+
@active
|
51
|
+
end
|
52
|
+
|
53
|
+
def kill
|
54
|
+
@active = false
|
55
|
+
end
|
56
|
+
|
57
|
+
def write_pid
|
58
|
+
File.open @config.pid_file, 'w' do |f|
|
59
|
+
f.write Process.pid.to_s
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def delete_pid
|
64
|
+
File.delete @config.pid_file if File.exists? @config.pid_file
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
module Slacky
|
2
|
+
class Message
|
3
|
+
attr_reader :raw, :text, :user, :channel
|
4
|
+
|
5
|
+
@@decorator = @@bot = nil
|
6
|
+
|
7
|
+
def self.decorator=(decorator)
|
8
|
+
@@decorator = decorator
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.bot=(bot)
|
12
|
+
@@bot = bot
|
13
|
+
end
|
14
|
+
|
15
|
+
def initialize(user, channel, raw)
|
16
|
+
@raw = raw
|
17
|
+
@user = user
|
18
|
+
@channel = channel
|
19
|
+
@text = raw.text.strip
|
20
|
+
@pieces = @text.split ' '
|
21
|
+
self.extend @@decorator if @@decorator
|
22
|
+
end
|
23
|
+
|
24
|
+
def reply(msg)
|
25
|
+
@@bot.client.message channel: @channel.slack_id, reply_to: @raw.id, text: msg
|
26
|
+
end
|
27
|
+
|
28
|
+
def command?
|
29
|
+
first = word 0
|
30
|
+
return false unless first
|
31
|
+
first == @@bot.name || first == "<@#{@@bot.slack_id}>"
|
32
|
+
end
|
33
|
+
|
34
|
+
def command
|
35
|
+
if @channel.type == :im
|
36
|
+
command? ? downword(1) : downword(0)
|
37
|
+
else
|
38
|
+
command? ? downword(1) : nil
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def yes?
|
43
|
+
[ 'y', 'yes' ].include? @text
|
44
|
+
end
|
45
|
+
|
46
|
+
def no?
|
47
|
+
[ 'n', 'no' ].include? @text
|
48
|
+
end
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
def word(n)
|
53
|
+
return nil unless @pieces.length > n
|
54
|
+
@pieces[n]
|
55
|
+
end
|
56
|
+
|
57
|
+
def downword(n)
|
58
|
+
word(n) && word(n).downcase
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
require 'fileutils'
|
2
|
+
|
3
|
+
class Slacky::Service
|
4
|
+
def initialize(config, daemon)
|
5
|
+
@config = config
|
6
|
+
@daemon = daemon
|
7
|
+
end
|
8
|
+
|
9
|
+
def run
|
10
|
+
@daemon.start false
|
11
|
+
end
|
12
|
+
|
13
|
+
def start(persist = false)
|
14
|
+
pid = get_pid
|
15
|
+
if pid
|
16
|
+
puts "#{@config.name} is already running with PID #{pid}"
|
17
|
+
return
|
18
|
+
end
|
19
|
+
|
20
|
+
print "Starting #{@config.name}... "
|
21
|
+
new_pid = Process.fork { @daemon.start }
|
22
|
+
Process.detach new_pid
|
23
|
+
puts "started"
|
24
|
+
end
|
25
|
+
|
26
|
+
def stop(persist = false)
|
27
|
+
pid = get_pid
|
28
|
+
unless pid
|
29
|
+
puts "#{@config.name} is not running"
|
30
|
+
return
|
31
|
+
end
|
32
|
+
|
33
|
+
print "Stopping #{@config.name}..."
|
34
|
+
|
35
|
+
begin
|
36
|
+
Process.kill 'HUP', pid
|
37
|
+
rescue
|
38
|
+
@daemon.cleanup
|
39
|
+
end
|
40
|
+
|
41
|
+
ticks = 0
|
42
|
+
while pid = get_pid && ticks < 40
|
43
|
+
sleep 0.5
|
44
|
+
ticks += 1
|
45
|
+
print '.' if ticks % 4 == 0
|
46
|
+
end
|
47
|
+
puts " #{pid.nil? ? 'stopped' : 'failed'}"
|
48
|
+
end
|
49
|
+
|
50
|
+
def restart
|
51
|
+
stop
|
52
|
+
start
|
53
|
+
end
|
54
|
+
|
55
|
+
def status
|
56
|
+
pid = get_pid
|
57
|
+
if pid
|
58
|
+
puts "#{@config.name} is running with PID #{pid}"
|
59
|
+
true
|
60
|
+
else
|
61
|
+
puts "#{@config.name} is not running"
|
62
|
+
false
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
private
|
67
|
+
|
68
|
+
def get_pid
|
69
|
+
return nil unless File.exists? @config.pid_file
|
70
|
+
IO.read(@config.pid_file).to_i
|
71
|
+
end
|
72
|
+
|
73
|
+
end
|
data/lib/slacky/user.rb
ADDED
@@ -0,0 +1,91 @@
|
|
1
|
+
require 'json'
|
2
|
+
|
3
|
+
module Slacky
|
4
|
+
class User
|
5
|
+
attr_accessor :username, :slack_id, :slack_im_id, :first_name, :last_name, :email, :timezone, :presence, :data
|
6
|
+
attr_reader :tz
|
7
|
+
|
8
|
+
@@decorator = @@db = nil
|
9
|
+
|
10
|
+
def self.decorator=(decorator)
|
11
|
+
@@decorator = decorator
|
12
|
+
end
|
13
|
+
|
14
|
+
def self.db=(db)
|
15
|
+
@@db = db
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.initialize_table
|
19
|
+
@@db.exec <<-SQL
|
20
|
+
create table if not exists users (
|
21
|
+
username varchar(64) not null,
|
22
|
+
slack_id varchar(20) not null,
|
23
|
+
slack_im_id varchar(20),
|
24
|
+
first_name varchar(64),
|
25
|
+
last_name varchar(64),
|
26
|
+
email varchar(128) not null,
|
27
|
+
timezone varchar(256),
|
28
|
+
presence varchar(64),
|
29
|
+
data jsonb not null
|
30
|
+
);
|
31
|
+
SQL
|
32
|
+
end
|
33
|
+
|
34
|
+
def self.find(user)
|
35
|
+
return user.map { |u| User.find u }.compact if user.is_a? Array
|
36
|
+
result = @@db.exec_params "select * from users where slack_id = $1", [ user ]
|
37
|
+
if result.ntuples == 0
|
38
|
+
result = @@db.exec_params "select * from users where username = $1", [ user ]
|
39
|
+
end
|
40
|
+
return nil if result.ntuples == 0
|
41
|
+
|
42
|
+
row = result[0]
|
43
|
+
user = self.new username: row['username'],
|
44
|
+
slack_id: row['slack_id'],
|
45
|
+
slack_im_id: row['slack_im_id'],
|
46
|
+
first_name: row['first_name'],
|
47
|
+
last_name: row['last_name'],
|
48
|
+
email: row['email'],
|
49
|
+
timezone: row['timezone'],
|
50
|
+
presence: row['presence'],
|
51
|
+
data: JSON.parse(row['data'])
|
52
|
+
user.extend @@decorator if @@decorator
|
53
|
+
user
|
54
|
+
end
|
55
|
+
|
56
|
+
def initialize(attrs={})
|
57
|
+
@username = attrs[:username]
|
58
|
+
@slack_id = attrs[:slack_id]
|
59
|
+
@slack_im_id = attrs[:slack_im_id]
|
60
|
+
@first_name = attrs[:first_name]
|
61
|
+
@last_name = attrs[:last_name]
|
62
|
+
@email = attrs[:email]
|
63
|
+
@timezone = attrs[:timezone] || "America/Los_Angeles"
|
64
|
+
@presence = attrs[:presence]
|
65
|
+
@data = attrs[:data] || {}
|
66
|
+
end
|
67
|
+
|
68
|
+
def populate(member)
|
69
|
+
@username = member.name
|
70
|
+
@first_name = member.profile.first_name
|
71
|
+
@last_name = member.profile.last_name
|
72
|
+
@email = member.profile.email
|
73
|
+
@timezone = member.tz
|
74
|
+
@presence = member.presence
|
75
|
+
@data = {} unless @data
|
76
|
+
self
|
77
|
+
end
|
78
|
+
|
79
|
+
def save
|
80
|
+
@@db.exec_params "delete from users where slack_id = $1", [ @slack_id ]
|
81
|
+
@@db.exec_params "insert into users (username, slack_id, slack_im_id, first_name, last_name, email, timezone, presence, data)
|
82
|
+
values ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
|
83
|
+
[ @username, @slack_id, @slack_im_id, @first_name, @last_name, @email, @timezone, @presence, JSON.dump(@data) ]
|
84
|
+
self
|
85
|
+
end
|
86
|
+
|
87
|
+
def reset
|
88
|
+
@data = {}
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
data/slacky.gemspec
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
$LOAD_PATH << File.dirname(__FILE__) + "/lib"
|
3
|
+
require 'slacky/version'
|
4
|
+
|
5
|
+
Gem::Specification.new do |s|
|
6
|
+
s.name = "slacky"
|
7
|
+
s.version = Slacky::VERSION
|
8
|
+
s.authors = ["Michael Wynholds"]
|
9
|
+
s.email = ["mike@carbonfive.com"]
|
10
|
+
s.homepage = ""
|
11
|
+
s.summary = %q{Carbon Five Slack bot gem}
|
12
|
+
s.description = %q{Carbon Five Slack bot gem}
|
13
|
+
|
14
|
+
s.files = `git ls-files`.split("\n")
|
15
|
+
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
16
|
+
s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
17
|
+
s.require_paths = ["lib"]
|
18
|
+
|
19
|
+
s.add_runtime_dependency 'slack-ruby-client', ">= 0.6"
|
20
|
+
s.add_runtime_dependency 'pg'
|
21
|
+
s.add_runtime_dependency 'eventmachine'
|
22
|
+
s.add_runtime_dependency 'faye-websocket'
|
23
|
+
s.add_runtime_dependency 'em-cron'
|
24
|
+
s.add_runtime_dependency 'tzinfo'
|
25
|
+
s.add_runtime_dependency 'tzinfo-data'
|
26
|
+
s.add_runtime_dependency 'dotenv'
|
27
|
+
|
28
|
+
s.add_development_dependency 'rake'
|
29
|
+
s.add_development_dependency 'minitest'
|
30
|
+
s.add_development_dependency 'factory_girl'
|
31
|
+
end
|
metadata
ADDED
@@ -0,0 +1,245 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: slacky
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: '0.1'
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Michael Wynholds
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2016-05-24 00:00:00.000000000 Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: slack-ruby-client
|
16
|
+
requirement: !ruby/object:Gem::Requirement
|
17
|
+
none: false
|
18
|
+
requirements:
|
19
|
+
- - ! '>='
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: '0.6'
|
22
|
+
type: :runtime
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
25
|
+
none: false
|
26
|
+
requirements:
|
27
|
+
- - ! '>='
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
version: '0.6'
|
30
|
+
- !ruby/object:Gem::Dependency
|
31
|
+
name: pg
|
32
|
+
requirement: !ruby/object:Gem::Requirement
|
33
|
+
none: false
|
34
|
+
requirements:
|
35
|
+
- - ! '>='
|
36
|
+
- !ruby/object:Gem::Version
|
37
|
+
version: '0'
|
38
|
+
type: :runtime
|
39
|
+
prerelease: false
|
40
|
+
version_requirements: !ruby/object:Gem::Requirement
|
41
|
+
none: false
|
42
|
+
requirements:
|
43
|
+
- - ! '>='
|
44
|
+
- !ruby/object:Gem::Version
|
45
|
+
version: '0'
|
46
|
+
- !ruby/object:Gem::Dependency
|
47
|
+
name: eventmachine
|
48
|
+
requirement: !ruby/object:Gem::Requirement
|
49
|
+
none: false
|
50
|
+
requirements:
|
51
|
+
- - ! '>='
|
52
|
+
- !ruby/object:Gem::Version
|
53
|
+
version: '0'
|
54
|
+
type: :runtime
|
55
|
+
prerelease: false
|
56
|
+
version_requirements: !ruby/object:Gem::Requirement
|
57
|
+
none: false
|
58
|
+
requirements:
|
59
|
+
- - ! '>='
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
- !ruby/object:Gem::Dependency
|
63
|
+
name: faye-websocket
|
64
|
+
requirement: !ruby/object:Gem::Requirement
|
65
|
+
none: false
|
66
|
+
requirements:
|
67
|
+
- - ! '>='
|
68
|
+
- !ruby/object:Gem::Version
|
69
|
+
version: '0'
|
70
|
+
type: :runtime
|
71
|
+
prerelease: false
|
72
|
+
version_requirements: !ruby/object:Gem::Requirement
|
73
|
+
none: false
|
74
|
+
requirements:
|
75
|
+
- - ! '>='
|
76
|
+
- !ruby/object:Gem::Version
|
77
|
+
version: '0'
|
78
|
+
- !ruby/object:Gem::Dependency
|
79
|
+
name: em-cron
|
80
|
+
requirement: !ruby/object:Gem::Requirement
|
81
|
+
none: false
|
82
|
+
requirements:
|
83
|
+
- - ! '>='
|
84
|
+
- !ruby/object:Gem::Version
|
85
|
+
version: '0'
|
86
|
+
type: :runtime
|
87
|
+
prerelease: false
|
88
|
+
version_requirements: !ruby/object:Gem::Requirement
|
89
|
+
none: false
|
90
|
+
requirements:
|
91
|
+
- - ! '>='
|
92
|
+
- !ruby/object:Gem::Version
|
93
|
+
version: '0'
|
94
|
+
- !ruby/object:Gem::Dependency
|
95
|
+
name: tzinfo
|
96
|
+
requirement: !ruby/object:Gem::Requirement
|
97
|
+
none: false
|
98
|
+
requirements:
|
99
|
+
- - ! '>='
|
100
|
+
- !ruby/object:Gem::Version
|
101
|
+
version: '0'
|
102
|
+
type: :runtime
|
103
|
+
prerelease: false
|
104
|
+
version_requirements: !ruby/object:Gem::Requirement
|
105
|
+
none: false
|
106
|
+
requirements:
|
107
|
+
- - ! '>='
|
108
|
+
- !ruby/object:Gem::Version
|
109
|
+
version: '0'
|
110
|
+
- !ruby/object:Gem::Dependency
|
111
|
+
name: tzinfo-data
|
112
|
+
requirement: !ruby/object:Gem::Requirement
|
113
|
+
none: false
|
114
|
+
requirements:
|
115
|
+
- - ! '>='
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
version: '0'
|
118
|
+
type: :runtime
|
119
|
+
prerelease: false
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
121
|
+
none: false
|
122
|
+
requirements:
|
123
|
+
- - ! '>='
|
124
|
+
- !ruby/object:Gem::Version
|
125
|
+
version: '0'
|
126
|
+
- !ruby/object:Gem::Dependency
|
127
|
+
name: dotenv
|
128
|
+
requirement: !ruby/object:Gem::Requirement
|
129
|
+
none: false
|
130
|
+
requirements:
|
131
|
+
- - ! '>='
|
132
|
+
- !ruby/object:Gem::Version
|
133
|
+
version: '0'
|
134
|
+
type: :runtime
|
135
|
+
prerelease: false
|
136
|
+
version_requirements: !ruby/object:Gem::Requirement
|
137
|
+
none: false
|
138
|
+
requirements:
|
139
|
+
- - ! '>='
|
140
|
+
- !ruby/object:Gem::Version
|
141
|
+
version: '0'
|
142
|
+
- !ruby/object:Gem::Dependency
|
143
|
+
name: rake
|
144
|
+
requirement: !ruby/object:Gem::Requirement
|
145
|
+
none: false
|
146
|
+
requirements:
|
147
|
+
- - ! '>='
|
148
|
+
- !ruby/object:Gem::Version
|
149
|
+
version: '0'
|
150
|
+
type: :development
|
151
|
+
prerelease: false
|
152
|
+
version_requirements: !ruby/object:Gem::Requirement
|
153
|
+
none: false
|
154
|
+
requirements:
|
155
|
+
- - ! '>='
|
156
|
+
- !ruby/object:Gem::Version
|
157
|
+
version: '0'
|
158
|
+
- !ruby/object:Gem::Dependency
|
159
|
+
name: minitest
|
160
|
+
requirement: !ruby/object:Gem::Requirement
|
161
|
+
none: false
|
162
|
+
requirements:
|
163
|
+
- - ! '>='
|
164
|
+
- !ruby/object:Gem::Version
|
165
|
+
version: '0'
|
166
|
+
type: :development
|
167
|
+
prerelease: false
|
168
|
+
version_requirements: !ruby/object:Gem::Requirement
|
169
|
+
none: false
|
170
|
+
requirements:
|
171
|
+
- - ! '>='
|
172
|
+
- !ruby/object:Gem::Version
|
173
|
+
version: '0'
|
174
|
+
- !ruby/object:Gem::Dependency
|
175
|
+
name: factory_girl
|
176
|
+
requirement: !ruby/object:Gem::Requirement
|
177
|
+
none: false
|
178
|
+
requirements:
|
179
|
+
- - ! '>='
|
180
|
+
- !ruby/object:Gem::Version
|
181
|
+
version: '0'
|
182
|
+
type: :development
|
183
|
+
prerelease: false
|
184
|
+
version_requirements: !ruby/object:Gem::Requirement
|
185
|
+
none: false
|
186
|
+
requirements:
|
187
|
+
- - ! '>='
|
188
|
+
- !ruby/object:Gem::Version
|
189
|
+
version: '0'
|
190
|
+
description: Carbon Five Slack bot gem
|
191
|
+
email:
|
192
|
+
- mike@carbonfive.com
|
193
|
+
executables: []
|
194
|
+
extensions: []
|
195
|
+
extra_rdoc_files: []
|
196
|
+
files:
|
197
|
+
- .gitignore
|
198
|
+
- .ruby-version
|
199
|
+
- Gemfile
|
200
|
+
- Gemfile.lock
|
201
|
+
- README.md
|
202
|
+
- Rakefile
|
203
|
+
- lib/slacky.rb
|
204
|
+
- lib/slacky/bookkeeper.rb
|
205
|
+
- lib/slacky/bot.rb
|
206
|
+
- lib/slacky/channel.rb
|
207
|
+
- lib/slacky/cli.rb
|
208
|
+
- lib/slacky/config.rb
|
209
|
+
- lib/slacky/daemon.rb
|
210
|
+
- lib/slacky/message.rb
|
211
|
+
- lib/slacky/service.rb
|
212
|
+
- lib/slacky/user.rb
|
213
|
+
- lib/slacky/version.rb
|
214
|
+
- slacky.gemspec
|
215
|
+
homepage: ''
|
216
|
+
licenses: []
|
217
|
+
post_install_message:
|
218
|
+
rdoc_options: []
|
219
|
+
require_paths:
|
220
|
+
- lib
|
221
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
222
|
+
none: false
|
223
|
+
requirements:
|
224
|
+
- - ! '>='
|
225
|
+
- !ruby/object:Gem::Version
|
226
|
+
version: '0'
|
227
|
+
segments:
|
228
|
+
- 0
|
229
|
+
hash: -3373873093616992315
|
230
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
231
|
+
none: false
|
232
|
+
requirements:
|
233
|
+
- - ! '>='
|
234
|
+
- !ruby/object:Gem::Version
|
235
|
+
version: '0'
|
236
|
+
segments:
|
237
|
+
- 0
|
238
|
+
hash: -3373873093616992315
|
239
|
+
requirements: []
|
240
|
+
rubyforge_project:
|
241
|
+
rubygems_version: 1.8.23
|
242
|
+
signing_key:
|
243
|
+
specification_version: 3
|
244
|
+
summary: Carbon Five Slack bot gem
|
245
|
+
test_files: []
|