rebot 0.0.3
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/.gitignore +9 -0
- data/.rspec +2 -0
- data/.travis.yml +4 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +41 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/setup +7 -0
- data/exe/rebot +5 -0
- data/lib/rebot.rb +30 -0
- data/lib/rebot/bot.rb +320 -0
- data/lib/rebot/cli.rb +43 -0
- data/lib/rebot/conversation.rb +141 -0
- data/lib/rebot/message.rb +48 -0
- data/lib/rebot/redis_queue.rb +26 -0
- data/lib/rebot/remote_control.rb +23 -0
- data/lib/rebot/server.rb +96 -0
- data/lib/rebot/version.rb +3 -0
- data/rebot.gemspec +34 -0
- data/template/project/%bot_name%.rb.tt +9 -0
- data/template/project/Gemfile +3 -0
- data/template/project/conversations/hello.rb +14 -0
- data/template/project/server.tt +28 -0
- metadata +195 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 9383bbb0fcee4b0c1a3a2f1fee70503e64331680
|
4
|
+
data.tar.gz: 9ae4bf2210a7d052b17ed1ecff6b90f00da6f479
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 11c8cc7d702e01f75ecfe41747ff648161841b7589dafbde346040c2a0409986628f7018b5f8fd4639a592e4a10994c3187d326f0e49196f4212f88d0d0cb213
|
7
|
+
data.tar.gz: f1f004e708d9c695dc50d2ea2770b6fce27cad13917efac01b4024383f4a25f48a1cda1bf369e7863419e9b6bda73e6bd1fef9ce8457f21c21354347f51baaba
|
data/.gitignore
ADDED
data/.rspec
ADDED
data/.travis.yml
ADDED
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2015 Artyom Keydunov
|
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
|
13
|
+
all 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
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,41 @@
|
|
1
|
+
# Rebot
|
2
|
+
|
3
|
+
Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/rebot`. To experiment with that code, run `bin/console` for an interactive prompt.
|
4
|
+
|
5
|
+
TODO: Delete this and the text above, and describe your gem
|
6
|
+
|
7
|
+
## Installation
|
8
|
+
|
9
|
+
Add this line to your application's Gemfile:
|
10
|
+
|
11
|
+
```ruby
|
12
|
+
gem 'rebot'
|
13
|
+
```
|
14
|
+
|
15
|
+
And then execute:
|
16
|
+
|
17
|
+
$ bundle
|
18
|
+
|
19
|
+
Or install it yourself as:
|
20
|
+
|
21
|
+
$ gem install rebot
|
22
|
+
|
23
|
+
## Usage
|
24
|
+
|
25
|
+
TODO: Write usage instructions here
|
26
|
+
|
27
|
+
## Development
|
28
|
+
|
29
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
30
|
+
|
31
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
32
|
+
|
33
|
+
## Contributing
|
34
|
+
|
35
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/rebot. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](contributor-covenant.org) code of conduct.
|
36
|
+
|
37
|
+
|
38
|
+
## License
|
39
|
+
|
40
|
+
The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
|
41
|
+
|
data/Rakefile
ADDED
data/bin/console
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "rebot"
|
5
|
+
|
6
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
7
|
+
# with your gem easier. You can also use a different console, if you like.
|
8
|
+
|
9
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
10
|
+
# require "pry"
|
11
|
+
# Pry.start
|
12
|
+
|
13
|
+
require "irb"
|
14
|
+
IRB.start
|
data/bin/setup
ADDED
data/exe/rebot
ADDED
data/lib/rebot.rb
ADDED
@@ -0,0 +1,30 @@
|
|
1
|
+
require 'slack'
|
2
|
+
require 'logger'
|
3
|
+
require 'faye/websocket'
|
4
|
+
|
5
|
+
require "rebot/version"
|
6
|
+
require "rebot/message"
|
7
|
+
require "rebot/bot"
|
8
|
+
require "rebot/server"
|
9
|
+
require "rebot/conversation"
|
10
|
+
require "rebot/redis_queue"
|
11
|
+
require "rebot/remote_control"
|
12
|
+
|
13
|
+
module Rebot
|
14
|
+
def self.logger
|
15
|
+
@logger ||= Logger.new(STDOUT)
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.conversation(name, &block)
|
19
|
+
@convos ||= {}
|
20
|
+
@convos[name.to_sym] = block
|
21
|
+
end
|
22
|
+
|
23
|
+
def self.find_conversation(name)
|
24
|
+
if convo = @convos[name.to_sym]
|
25
|
+
convo
|
26
|
+
else
|
27
|
+
raise "Cannot find conversation: #{name}"
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
data/lib/rebot/bot.rb
ADDED
@@ -0,0 +1,320 @@
|
|
1
|
+
module Rebot
|
2
|
+
class Bot
|
3
|
+
attr_reader :token, :identity
|
4
|
+
|
5
|
+
class InvalidToken < RuntimeError; end
|
6
|
+
|
7
|
+
class Identity < Struct.new(:name, :id); end
|
8
|
+
|
9
|
+
def initialize(token:)
|
10
|
+
@token = token
|
11
|
+
@identity = nil
|
12
|
+
@api = ::Slack::Client.new(token: @token)
|
13
|
+
|
14
|
+
@im_channel_ids = []
|
15
|
+
@channel_ids = []
|
16
|
+
@group_ids = []
|
17
|
+
|
18
|
+
@message_count = 0
|
19
|
+
|
20
|
+
@ims = []
|
21
|
+
|
22
|
+
@convos = []
|
23
|
+
|
24
|
+
@connected = false
|
25
|
+
@running = false
|
26
|
+
end
|
27
|
+
|
28
|
+
def say(message)
|
29
|
+
@message_count += 1
|
30
|
+
slack_message = {
|
31
|
+
:id => @message_count,
|
32
|
+
:type => "message",
|
33
|
+
|
34
|
+
:channel => message[:channel],
|
35
|
+
:text => message[:text] || "", # slack-web-api gem does not allow nil for text,
|
36
|
+
:username => message[:username],
|
37
|
+
:parse => message[:parse],
|
38
|
+
:link_names => message[:link_names],
|
39
|
+
:attachments => (message[:attachments] ? JSON.dump(message[:attachments]) : nil),
|
40
|
+
:unfurl_links => message[:unfurl_links],
|
41
|
+
:unfurl_media => message[:unfurl_media],
|
42
|
+
:icon_url => message[:icon_url],
|
43
|
+
:icon_emoji => message[:icon_emoji],
|
44
|
+
:as_user => message[:as_user] || true
|
45
|
+
}
|
46
|
+
|
47
|
+
#if (message[:icon_url] || message[:icon_emoji] || message[:username] )
|
48
|
+
# slack_message[:as_user] = false
|
49
|
+
#else
|
50
|
+
# slack_message[:as_user] = message[:as_user] || true
|
51
|
+
#end
|
52
|
+
|
53
|
+
# These options are not supported by the RTM
|
54
|
+
# so if they are specified, we use the web API to send messages.
|
55
|
+
if slack_message[:attachments] || slack_message[:icon_emoji] || slack_message[:username] || slack_message[:icon_url]
|
56
|
+
@api.chat_postMessage(slack_message)
|
57
|
+
else
|
58
|
+
@ws.send(JSON.dump(slack_message))
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def typing
|
63
|
+
@message_count += 1
|
64
|
+
@ws.send(JSON.dump(channel: @last_received_message.channel, id: @message_count, type: "typing"))
|
65
|
+
end
|
66
|
+
|
67
|
+
def reply(text_or_options)
|
68
|
+
channel = @last_received_message.channel
|
69
|
+
|
70
|
+
if text_or_options.is_a?(String)
|
71
|
+
options = { channel: channel, text: text_or_options }
|
72
|
+
elsif text_or_options.is_a?(Hash)
|
73
|
+
options = text_or_options.merge(channel: channel)
|
74
|
+
else
|
75
|
+
raise "unreachable"
|
76
|
+
end
|
77
|
+
|
78
|
+
say(options)
|
79
|
+
end
|
80
|
+
|
81
|
+
def call(method, args)
|
82
|
+
args.symbolize_keys!
|
83
|
+
@api.send(method, args)
|
84
|
+
end
|
85
|
+
|
86
|
+
def start
|
87
|
+
unless auth_test['ok']
|
88
|
+
log "Error connecting bot (token: #{token}) to Slack: #{auth_test}"
|
89
|
+
return
|
90
|
+
end
|
91
|
+
|
92
|
+
# TODO: it should be async
|
93
|
+
rtm_start = @api.post('rtm.start')
|
94
|
+
@identity = Identity.new(rtm_start['self']['name'], rtm_start['self']['id'])
|
95
|
+
@ws = Faye::WebSocket::Client.new(rtm_start['url'], nil, ping: 60)
|
96
|
+
|
97
|
+
@running = true
|
98
|
+
@ws.on :open do |event|
|
99
|
+
@connected = true
|
100
|
+
log "connected to '#{team}'"
|
101
|
+
load_im_channels
|
102
|
+
load_channels
|
103
|
+
end
|
104
|
+
|
105
|
+
@ws.on :message do |event|
|
106
|
+
begin
|
107
|
+
debug event.data
|
108
|
+
handle_event(event)
|
109
|
+
rescue => e
|
110
|
+
log error: e
|
111
|
+
log backtrace: e.backtrace
|
112
|
+
Rollbar.error(e)
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
@ws.on :close do |event|
|
117
|
+
log "disconnected"
|
118
|
+
@connected = false
|
119
|
+
@auth_test = nil
|
120
|
+
if @running
|
121
|
+
start
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
EM.add_periodic_timer(1) do
|
126
|
+
@convos.each { |convo| convo.tick }
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
def stop
|
131
|
+
log "closing connection"
|
132
|
+
@running = false
|
133
|
+
@ws.close
|
134
|
+
log "closed"
|
135
|
+
end
|
136
|
+
|
137
|
+
def connected?
|
138
|
+
@connected
|
139
|
+
end
|
140
|
+
|
141
|
+
def start_conversation(name = nil, *args, &block)
|
142
|
+
if name
|
143
|
+
convo_block = Rebot.find_conversation(name)
|
144
|
+
elsif block_given?
|
145
|
+
convo_block = block
|
146
|
+
else
|
147
|
+
raise "Pass registered conversation name or block"
|
148
|
+
end
|
149
|
+
|
150
|
+
convo = Conversation.new(self, @last_received_message)
|
151
|
+
@convos.push(convo)
|
152
|
+
convo.start(convo_block, *args)
|
153
|
+
end
|
154
|
+
|
155
|
+
def conversation_ended(convo)
|
156
|
+
@convos.delete(convo)
|
157
|
+
end
|
158
|
+
|
159
|
+
class << self
|
160
|
+
def callbacks_for(type)
|
161
|
+
callbacks = @callbacks[type.to_sym] || []
|
162
|
+
if superclass.respond_to?(:callbacks_for)
|
163
|
+
callbacks += superclass.callbacks_for(type)
|
164
|
+
end
|
165
|
+
callbacks
|
166
|
+
end
|
167
|
+
|
168
|
+
def on(type, &block)
|
169
|
+
@callbacks ||= {}
|
170
|
+
@callbacks[type.to_sym] ||= []
|
171
|
+
@callbacks[type.to_sym] << block
|
172
|
+
end
|
173
|
+
|
174
|
+
def hears(pattern, &block)
|
175
|
+
callback = Proc.new do |message|
|
176
|
+
pattern = pattern.is_a?(String) ? Regexp.new(pattern, true) : pattern
|
177
|
+
if match_data = pattern.match(message.text)
|
178
|
+
debug "I heard #{pattern}"
|
179
|
+
instance_exec(message, *match_data.captures, &block)
|
180
|
+
false
|
181
|
+
end
|
182
|
+
end
|
183
|
+
|
184
|
+
on(:dm, &callback)
|
185
|
+
on(:mention, &callback)
|
186
|
+
end
|
187
|
+
|
188
|
+
def afters
|
189
|
+
@afters ||= []
|
190
|
+
end
|
191
|
+
|
192
|
+
def befores
|
193
|
+
@befores ||= []
|
194
|
+
end
|
195
|
+
|
196
|
+
def after(&block)
|
197
|
+
afters.push(block)
|
198
|
+
end
|
199
|
+
|
200
|
+
def before(&block)
|
201
|
+
befores.push(block)
|
202
|
+
end
|
203
|
+
end
|
204
|
+
|
205
|
+
on :im_created do |data|
|
206
|
+
channel_id = data['channel']['id']
|
207
|
+
log "Adding new IM channel: #{channel_id}"
|
208
|
+
@im_channel_ids << channel_id
|
209
|
+
end
|
210
|
+
|
211
|
+
on :channel_joined do |data|
|
212
|
+
channel_id = data['channel']['id']
|
213
|
+
log "Adding new channel: #{channel_id}"
|
214
|
+
@channel_ids << channel_id
|
215
|
+
end
|
216
|
+
|
217
|
+
on :channel_left do |data|
|
218
|
+
channel_id = data['channel']
|
219
|
+
log "Removing channel: #{channel_id}"
|
220
|
+
@channel_ids.delete(channel_id)
|
221
|
+
end
|
222
|
+
|
223
|
+
def to_s
|
224
|
+
"<#{self.class.name} token:#{token}>"
|
225
|
+
end
|
226
|
+
|
227
|
+
private
|
228
|
+
|
229
|
+
def handle_event(event)
|
230
|
+
data = JSON.parse(event.data)
|
231
|
+
|
232
|
+
# this is a confirmation of something we sent.
|
233
|
+
return unless data['ok'].nil?
|
234
|
+
|
235
|
+
if data['type'] == 'message'
|
236
|
+
return if data['user'] == auth_test['user_id']
|
237
|
+
|
238
|
+
# Ignore messages from slackbot
|
239
|
+
return if data['user'] == "USLACKBOT" || data['username'] == 'slackbot'
|
240
|
+
# message without text is probably an edit
|
241
|
+
return if data['text'].nil?
|
242
|
+
|
243
|
+
message = Message.new(data, self)
|
244
|
+
@last_received_message = message
|
245
|
+
|
246
|
+
self.class.befores.each { |c| instance_exec(message, &c) }
|
247
|
+
|
248
|
+
if convo = find_conversation(message)
|
249
|
+
convo.handle(message)
|
250
|
+
else
|
251
|
+
trigger(message.event, message)
|
252
|
+
end
|
253
|
+
|
254
|
+
self.class.afters.each { |c| instance_exec(message, &c) }
|
255
|
+
else
|
256
|
+
trigger(data['type'], data)
|
257
|
+
end
|
258
|
+
end
|
259
|
+
|
260
|
+
def trigger(event, message)
|
261
|
+
relevant_callbacks = self.class.callbacks_for(event)
|
262
|
+
if relevant_callbacks && relevant_callbacks.any?
|
263
|
+
relevant_callbacks.each do |c|
|
264
|
+
resp = instance_exec(message, &c)
|
265
|
+
break if resp == false
|
266
|
+
end
|
267
|
+
end
|
268
|
+
end
|
269
|
+
|
270
|
+
def find_conversation(message)
|
271
|
+
@convos.detect do |convo|
|
272
|
+
convo.active? &&
|
273
|
+
convo.source_message.channel == message.channel &&
|
274
|
+
convo.source_message.user == message.user
|
275
|
+
end
|
276
|
+
end
|
277
|
+
|
278
|
+
def log(message)
|
279
|
+
text = message.is_a?(String) ? message : message.inspect
|
280
|
+
text = "[BOT/#{user}] #{text}"
|
281
|
+
Rebot.logger.info(message)
|
282
|
+
end
|
283
|
+
|
284
|
+
def debug(message)
|
285
|
+
text = message.is_a?(String) ? message : message.inspect
|
286
|
+
text = "[BOT/#{user}] #{text}"
|
287
|
+
Rebot.logger.debug(message)
|
288
|
+
end
|
289
|
+
|
290
|
+
def user
|
291
|
+
auth_test['user']
|
292
|
+
end
|
293
|
+
|
294
|
+
def user_id
|
295
|
+
auth_test['user_id']
|
296
|
+
end
|
297
|
+
|
298
|
+
def team
|
299
|
+
auth_test['team']
|
300
|
+
end
|
301
|
+
|
302
|
+
def auth_test
|
303
|
+
@auth_test ||= @api.auth_test
|
304
|
+
end
|
305
|
+
|
306
|
+
def load_im_channels
|
307
|
+
debug "Loading IM channels"
|
308
|
+
result = @api.im_list
|
309
|
+
@im_channel_ids = result['ims'].map { |d| d['id'] }
|
310
|
+
debug im_channels: @im_channel_ids
|
311
|
+
end
|
312
|
+
|
313
|
+
def load_channels
|
314
|
+
debug "Loading Channels"
|
315
|
+
result = @api.channels_list(exclude_archived: 1)
|
316
|
+
@channel_ids = result['channels'].select { |d| d['is_member'] == true }.map { |d| d['id'] }
|
317
|
+
debug channels: @channel_ids
|
318
|
+
end
|
319
|
+
end
|
320
|
+
end
|
data/lib/rebot/cli.rb
ADDED
@@ -0,0 +1,43 @@
|
|
1
|
+
require "thor"
|
2
|
+
|
3
|
+
module Rebot
|
4
|
+
class CLI < Thor
|
5
|
+
include Thor::Actions
|
6
|
+
class_option :verbose, type: :boolean, default: true, aliases: "-v"
|
7
|
+
|
8
|
+
attr_reader :bot_name
|
9
|
+
|
10
|
+
source_root File.expand_path("../../../template", __FILE__)
|
11
|
+
|
12
|
+
desc "new [BOT_NAME]", "Creates a BOT_NAME dir with your new bot structure."
|
13
|
+
def new(bot_name)
|
14
|
+
@bot_name = bot_name
|
15
|
+
@bot_class = camel_case(bot_name)
|
16
|
+
directory "project", bot_name, verbose: verbose
|
17
|
+
after_create
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
def camel_case(string)
|
23
|
+
return string.gsub(/^./) { |l| l.capitalize } if !string.match(/[_-]/)
|
24
|
+
altered_string = string.downcase.capitalize
|
25
|
+
altered_string.scan(/[_-][a-zA-Z]/).each do |match|
|
26
|
+
altered_string.gsub!(match, match[1].upcase)
|
27
|
+
end
|
28
|
+
|
29
|
+
altered_string
|
30
|
+
end
|
31
|
+
|
32
|
+
def verbose
|
33
|
+
options[:verbose]
|
34
|
+
end
|
35
|
+
|
36
|
+
def after_create
|
37
|
+
inside @bot_name do
|
38
|
+
run "chmod +x server"
|
39
|
+
run "bundle install"
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,141 @@
|
|
1
|
+
module Rebot
|
2
|
+
class Conversation
|
3
|
+
DEFAULT_TIMEOUT = 180 # set default timeout to 180 seconds (3 minutes)
|
4
|
+
DEFAULT_TIMEOUT_MESSAGE = "We can pick this up later."
|
5
|
+
|
6
|
+
attr_reader :data, :source_message
|
7
|
+
|
8
|
+
def initialize(bot, source_message)
|
9
|
+
@bot = bot
|
10
|
+
@source_message = source_message
|
11
|
+
@data = {}
|
12
|
+
@handlers_stack = [[]]
|
13
|
+
@messages = []
|
14
|
+
@sent = []
|
15
|
+
@last_active_at = Time.now
|
16
|
+
|
17
|
+
@timeout = DEFAULT_TIMEOUT
|
18
|
+
@timeout_message = DEFAULT_TIMEOUT_MESSAGE
|
19
|
+
end
|
20
|
+
|
21
|
+
def timeout(value = nil)
|
22
|
+
if value
|
23
|
+
@timeout = value
|
24
|
+
else
|
25
|
+
@timeout
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def timeout_message(value = nil)
|
30
|
+
if value
|
31
|
+
@timeout_message = value
|
32
|
+
else
|
33
|
+
@timeout_message
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def start(block, *args)
|
38
|
+
@status = :active
|
39
|
+
instance_exec(*args, &block)
|
40
|
+
end
|
41
|
+
|
42
|
+
def matched_option(text)
|
43
|
+
stack = @handlers_stack.last
|
44
|
+
handler = stack.find do |o|
|
45
|
+
if o[:pattern]
|
46
|
+
@last_matched_data = o[:pattern].match(text)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
unless handler
|
51
|
+
handler = stack.find { |o| o[:default] }
|
52
|
+
end
|
53
|
+
|
54
|
+
handler
|
55
|
+
end
|
56
|
+
|
57
|
+
def active?
|
58
|
+
@status == :active
|
59
|
+
end
|
60
|
+
|
61
|
+
def tick
|
62
|
+
return unless active?
|
63
|
+
|
64
|
+
if @messages.any?
|
65
|
+
message = @messages.shift
|
66
|
+
@bot.say(message)
|
67
|
+
@sent.push(message)
|
68
|
+
@last_active_at = Time.now
|
69
|
+
end
|
70
|
+
|
71
|
+
if @messages.empty? && @handlers_stack.last.empty?
|
72
|
+
@status = :completed
|
73
|
+
@bot.conversation_ended(self)
|
74
|
+
end
|
75
|
+
|
76
|
+
if Time.now - @last_active_at > timeout
|
77
|
+
# TODO: keep it simple for now
|
78
|
+
@status = :timeout
|
79
|
+
@bot.say(text: timeout_message, channel: @source_message.channel)
|
80
|
+
@bot.conversation_ended(self)
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
def handle(message)
|
85
|
+
@last_active_at = Time.now
|
86
|
+
Rebot.logger.debug "Handling message in conversation: #{message.text}"
|
87
|
+
if option = matched_option(message.text)
|
88
|
+
@handlers_stack.push([])
|
89
|
+
if option[:default]
|
90
|
+
instance_exec(message.text, &option[:callback])
|
91
|
+
else
|
92
|
+
instance_exec(*@last_matched_data.captures, &option[:callback])
|
93
|
+
end
|
94
|
+
else
|
95
|
+
# FIXME
|
96
|
+
say(@sent.last) if @messages.empty?
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
def say(message)
|
101
|
+
if message.is_a?(String)
|
102
|
+
message = { text: message, channel: @source_message.channel }
|
103
|
+
else
|
104
|
+
message[:channel] = @source_message.channel
|
105
|
+
end
|
106
|
+
@messages.push(message)
|
107
|
+
end
|
108
|
+
|
109
|
+
def hears(pattern, meta = {}, &callback)
|
110
|
+
pattern = Regexp.new(pattern) if pattern.is_a?(String)
|
111
|
+
@handlers_stack.last.push(
|
112
|
+
{ pattern: pattern, callback: callback }.merge(meta)
|
113
|
+
)
|
114
|
+
end
|
115
|
+
|
116
|
+
def repeat
|
117
|
+
@handlers_stack.pop
|
118
|
+
end
|
119
|
+
|
120
|
+
def default( &callback)
|
121
|
+
hears(nil, { default: true }, &callback)
|
122
|
+
end
|
123
|
+
|
124
|
+
# extract into Conversation::Helpers
|
125
|
+
def time
|
126
|
+
/\d{1,2}:\d{1,2}\s*(am|pm)/i
|
127
|
+
end
|
128
|
+
|
129
|
+
def anything
|
130
|
+
/(.*)/i
|
131
|
+
end
|
132
|
+
|
133
|
+
def yes
|
134
|
+
/^(yes|yea|yup|yep|ya|sure|ok|y|yeah|yah)/i
|
135
|
+
end
|
136
|
+
|
137
|
+
def no
|
138
|
+
/^(no|nah|nope|n)/i
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
module Rebot
|
2
|
+
class Message
|
3
|
+
attr_reader :text, :user, :channel, :event
|
4
|
+
|
5
|
+
def initialize(data, bot)
|
6
|
+
@bot = bot
|
7
|
+
@data = data
|
8
|
+
|
9
|
+
# FIXME: calling private method
|
10
|
+
@mention_regex = /\A(<@#{@bot.send(:user_id)}>)[\s\:](.*)/
|
11
|
+
|
12
|
+
@event = resolve_event(data['type'])
|
13
|
+
@text = cleanup_text(data['text'])
|
14
|
+
@user = data['user']
|
15
|
+
@channel = data['channel']
|
16
|
+
end
|
17
|
+
|
18
|
+
def []=(key, value)
|
19
|
+
@data[key.to_s] = value
|
20
|
+
end
|
21
|
+
|
22
|
+
def [](key)
|
23
|
+
@data[key.to_s]
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
def resolve_event(type)
|
29
|
+
if @data['text'] =~ @mention_regex
|
30
|
+
type = 'mention'
|
31
|
+
elsif @data['channel'].match(/^D/)
|
32
|
+
type = 'dm'
|
33
|
+
end
|
34
|
+
type
|
35
|
+
end
|
36
|
+
|
37
|
+
def cleanup_text(text)
|
38
|
+
text = text.strip
|
39
|
+
if md = text.match(@mention_regex)
|
40
|
+
text = md[2].strip
|
41
|
+
end
|
42
|
+
text = text.gsub(/</, '<');
|
43
|
+
text = text.gsub(/>/, '>');
|
44
|
+
text = text.gsub(/&/, '&');
|
45
|
+
text
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
module Rebot
|
2
|
+
class RedisQueue
|
3
|
+
def initialize(redis=nil)
|
4
|
+
@key = 'rebot:queue'
|
5
|
+
@redis = if redis
|
6
|
+
redis
|
7
|
+
else
|
8
|
+
require 'redis'
|
9
|
+
Redis.new
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
def push(value)
|
14
|
+
@redis.rpush @key, JSON.dump(value)
|
15
|
+
end
|
16
|
+
|
17
|
+
def pop
|
18
|
+
json_value = @redis.lpop @key
|
19
|
+
if json_value
|
20
|
+
MultiJson.load(json_value, symbolize_keys: true)
|
21
|
+
else
|
22
|
+
nil
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module Rebot
|
2
|
+
class RemoteControl
|
3
|
+
def self.add_token(token)
|
4
|
+
queue.push([:add_token, token])
|
5
|
+
end
|
6
|
+
|
7
|
+
def self.remove_bot(key)
|
8
|
+
@queue.push([:remove_bot, key])
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.say(key, message_data)
|
12
|
+
queue.push([:say, key, message_data])
|
13
|
+
end
|
14
|
+
|
15
|
+
def self.call(key, method, args)
|
16
|
+
queue.push([:call, [key, method, args]])
|
17
|
+
end
|
18
|
+
|
19
|
+
def self.queue
|
20
|
+
@queue ||= RedisQueue.new
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
data/lib/rebot/server.rb
ADDED
@@ -0,0 +1,96 @@
|
|
1
|
+
module Rebot
|
2
|
+
class Server
|
3
|
+
attr_reader :queue
|
4
|
+
|
5
|
+
def initialize(queue: nil)
|
6
|
+
@queue = queue
|
7
|
+
@bots = {}
|
8
|
+
@new_token_proc = -> (token) { Rebot::BaseBot.new(token: token) }
|
9
|
+
@running = false
|
10
|
+
end
|
11
|
+
|
12
|
+
def on_new_token(&block)
|
13
|
+
@new_token_proc = block
|
14
|
+
end
|
15
|
+
|
16
|
+
def start
|
17
|
+
EM.run do
|
18
|
+
begin
|
19
|
+
@running = true
|
20
|
+
@bots.each { |key, bot| bot.start }
|
21
|
+
listen_for_instructions if @queue
|
22
|
+
rescue => e
|
23
|
+
log_error(e)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def listen_for_instructions
|
29
|
+
EM.add_periodic_timer(1) do
|
30
|
+
next_message = queue.pop
|
31
|
+
process_instruction(next_message) if next_message
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def add_bot(bot)
|
36
|
+
# Do not add bot same bot twice
|
37
|
+
return if @bots[bot.token]
|
38
|
+
log "adding bot #{bot}"
|
39
|
+
@bots[bot.token] = bot
|
40
|
+
bot.start if @running
|
41
|
+
end
|
42
|
+
|
43
|
+
def add_token(token)
|
44
|
+
bot = @new_token_proc.call(token)
|
45
|
+
add_bot(bot) if bot
|
46
|
+
rescue => e
|
47
|
+
log_error(e)
|
48
|
+
end
|
49
|
+
|
50
|
+
def remove_bot(token)
|
51
|
+
if (bot = @bots[token])
|
52
|
+
bot.stop
|
53
|
+
@bots.delete(token)
|
54
|
+
end
|
55
|
+
rescue => e
|
56
|
+
log_error(e)
|
57
|
+
end
|
58
|
+
|
59
|
+
private
|
60
|
+
|
61
|
+
def process_instruction(instruction)
|
62
|
+
type, *args = instruction
|
63
|
+
case type.to_sym
|
64
|
+
when :add_token
|
65
|
+
token = args.first
|
66
|
+
log "got new token: '#{token}'"
|
67
|
+
add_token(token)
|
68
|
+
when :remove_bot
|
69
|
+
token = args.first
|
70
|
+
remove_bot(token)
|
71
|
+
when :say
|
72
|
+
token, message_data = args
|
73
|
+
if bot = @bots[token]
|
74
|
+
bot.say(message_data)
|
75
|
+
end
|
76
|
+
when :call
|
77
|
+
token, method, method_args = args
|
78
|
+
if bot = @bots[token]
|
79
|
+
bot.call(method, method_args)
|
80
|
+
end
|
81
|
+
else
|
82
|
+
log unknown_command: instruction
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
def log(message)
|
87
|
+
text = message.is_a?(String) ? message : message.inspect
|
88
|
+
Rebot.logger.info(text)
|
89
|
+
end
|
90
|
+
|
91
|
+
def log_error(e)
|
92
|
+
Rebot.logger.warn("Error in server: #{e} - #{e.message}")
|
93
|
+
Rebot.logger.warn(e.backtrace.join("\n"))
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
data/rebot.gemspec
ADDED
@@ -0,0 +1,34 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'rebot/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "rebot"
|
8
|
+
spec.version = Rebot::VERSION
|
9
|
+
spec.authors = ["Artyom Keydunov"]
|
10
|
+
spec.email = ["artyom.keydunov@gmail.com"]
|
11
|
+
|
12
|
+
spec.summary = %q{Framework for building bot applications}
|
13
|
+
spec.description = %q{Framework for building bot applications}
|
14
|
+
spec.homepage = "https://github.com/keydunov/rebot"
|
15
|
+
spec.license = "MIT"
|
16
|
+
|
17
|
+
spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
18
|
+
spec.bindir = "exe"
|
19
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
20
|
+
spec.require_paths = ["lib"]
|
21
|
+
|
22
|
+
spec.required_ruby_version = "~>2.0"
|
23
|
+
|
24
|
+
spec.add_runtime_dependency "eventmachine"
|
25
|
+
spec.add_runtime_dependency "faye-websocket"
|
26
|
+
spec.add_runtime_dependency "thor"
|
27
|
+
spec.add_runtime_dependency "faraday"
|
28
|
+
spec.add_runtime_dependency "slack-web-api"
|
29
|
+
spec.add_runtime_dependency "redis"
|
30
|
+
|
31
|
+
spec.add_development_dependency "bundler", "~> 1.10"
|
32
|
+
spec.add_development_dependency "rake", "~> 10.0"
|
33
|
+
spec.add_development_dependency "rspec"
|
34
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
Rebot.conversation :hello do
|
2
|
+
say "Hi!"
|
3
|
+
say "How are you doing?"
|
4
|
+
say "Do you have any plans for a weekend?"
|
5
|
+
|
6
|
+
hears yes do
|
7
|
+
say "Nice! :+1:. Hope you have a great weekend!"
|
8
|
+
end
|
9
|
+
|
10
|
+
hears no do
|
11
|
+
say "Oh! Do you want to play with rebot framework?"
|
12
|
+
say "Check it out at https://github.com/keydunov/rebot"
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
require 'bundler'
|
3
|
+
Bundler.setup
|
4
|
+
|
5
|
+
require 'rebot'
|
6
|
+
|
7
|
+
require "./<%= @bot_name %>"
|
8
|
+
Dir[File.join(__dir__, 'conversations/*.rb')].each { |f| require f }
|
9
|
+
|
10
|
+
Rebot.logger.level = Logger::INFO unless ENV['VERBOSE']
|
11
|
+
|
12
|
+
#Create a new server
|
13
|
+
server = Rebot::Server.new
|
14
|
+
|
15
|
+
server.on_new_token do |token|
|
16
|
+
<%= @bot_class %>.new(token: token)
|
17
|
+
end
|
18
|
+
|
19
|
+
tokens = (ENV['TOKENS'] || "").split(",")
|
20
|
+
if tokens.empty?
|
21
|
+
puts "Please create new bot at https://slack.com/apps/new/A0F7YS25R-bots"
|
22
|
+
puts "Add run TOKENS=first-token,second-token ./server"
|
23
|
+
end
|
24
|
+
tokens.each do |token|
|
25
|
+
server.add_token(token)
|
26
|
+
end
|
27
|
+
|
28
|
+
server.start
|
metadata
ADDED
@@ -0,0 +1,195 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: rebot
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.3
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Artyom Keydunov
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2016-01-07 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: eventmachine
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: faye-websocket
|
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: thor
|
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
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: faraday
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
type: :runtime
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: slack-web-api
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - ">="
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '0'
|
76
|
+
type: :runtime
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - ">="
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '0'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: redis
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - ">="
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '0'
|
90
|
+
type: :runtime
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - ">="
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '0'
|
97
|
+
- !ruby/object:Gem::Dependency
|
98
|
+
name: bundler
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - "~>"
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: '1.10'
|
104
|
+
type: :development
|
105
|
+
prerelease: false
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - "~>"
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: '1.10'
|
111
|
+
- !ruby/object:Gem::Dependency
|
112
|
+
name: rake
|
113
|
+
requirement: !ruby/object:Gem::Requirement
|
114
|
+
requirements:
|
115
|
+
- - "~>"
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
version: '10.0'
|
118
|
+
type: :development
|
119
|
+
prerelease: false
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
121
|
+
requirements:
|
122
|
+
- - "~>"
|
123
|
+
- !ruby/object:Gem::Version
|
124
|
+
version: '10.0'
|
125
|
+
- !ruby/object:Gem::Dependency
|
126
|
+
name: rspec
|
127
|
+
requirement: !ruby/object:Gem::Requirement
|
128
|
+
requirements:
|
129
|
+
- - ">="
|
130
|
+
- !ruby/object:Gem::Version
|
131
|
+
version: '0'
|
132
|
+
type: :development
|
133
|
+
prerelease: false
|
134
|
+
version_requirements: !ruby/object:Gem::Requirement
|
135
|
+
requirements:
|
136
|
+
- - ">="
|
137
|
+
- !ruby/object:Gem::Version
|
138
|
+
version: '0'
|
139
|
+
description: Framework for building bot applications
|
140
|
+
email:
|
141
|
+
- artyom.keydunov@gmail.com
|
142
|
+
executables:
|
143
|
+
- rebot
|
144
|
+
extensions: []
|
145
|
+
extra_rdoc_files: []
|
146
|
+
files:
|
147
|
+
- ".gitignore"
|
148
|
+
- ".rspec"
|
149
|
+
- ".travis.yml"
|
150
|
+
- Gemfile
|
151
|
+
- LICENSE.txt
|
152
|
+
- README.md
|
153
|
+
- Rakefile
|
154
|
+
- bin/console
|
155
|
+
- bin/setup
|
156
|
+
- exe/rebot
|
157
|
+
- lib/rebot.rb
|
158
|
+
- lib/rebot/bot.rb
|
159
|
+
- lib/rebot/cli.rb
|
160
|
+
- lib/rebot/conversation.rb
|
161
|
+
- lib/rebot/message.rb
|
162
|
+
- lib/rebot/redis_queue.rb
|
163
|
+
- lib/rebot/remote_control.rb
|
164
|
+
- lib/rebot/server.rb
|
165
|
+
- lib/rebot/version.rb
|
166
|
+
- rebot.gemspec
|
167
|
+
- template/project/%bot_name%.rb.tt
|
168
|
+
- template/project/Gemfile
|
169
|
+
- template/project/conversations/hello.rb
|
170
|
+
- template/project/server.tt
|
171
|
+
homepage: https://github.com/keydunov/rebot
|
172
|
+
licenses:
|
173
|
+
- MIT
|
174
|
+
metadata: {}
|
175
|
+
post_install_message:
|
176
|
+
rdoc_options: []
|
177
|
+
require_paths:
|
178
|
+
- lib
|
179
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
180
|
+
requirements:
|
181
|
+
- - "~>"
|
182
|
+
- !ruby/object:Gem::Version
|
183
|
+
version: '2.0'
|
184
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
185
|
+
requirements:
|
186
|
+
- - ">="
|
187
|
+
- !ruby/object:Gem::Version
|
188
|
+
version: '0'
|
189
|
+
requirements: []
|
190
|
+
rubyforge_project:
|
191
|
+
rubygems_version: 2.4.5
|
192
|
+
signing_key:
|
193
|
+
specification_version: 4
|
194
|
+
summary: Framework for building bot applications
|
195
|
+
test_files: []
|