-
Notifications
You must be signed in to change notification settings - Fork 3.8k
/
robot.coffee
374 lines (320 loc) · 11.5 KB
/
robot.coffee
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
Fs = require 'fs'
Log = require 'log'
Path = require 'path'
HttpClient = require 'scoped-http-client'
User = require './user'
Brain = require './brain'
Response = require './response'
{Listener,TextListener} = require './listener'
{TextMessage,EnterMessage,LeaveMessage,CatchAllMessage} = require './message'
HUBOT_DEFAULT_ADAPTERS = [ 'campfire', 'shell' ]
class Robot
# Robots receive messages from a chat source (Campfire, irc, etc), and
# dispatch them to matching listeners.
#
# path - String directory full of Hubot scripts to load.
constructor: (adapterPath, adapter, httpd, name = 'Hubot') ->
@name = name
@brain = new Brain
@alias = false
@adapter = null
@commands = []
@Response = Response
@listeners = []
@loadPaths = []
@enableSlash = false
@logger = new Log process.env.HUBOT_LOG_LEVEL or 'info'
@parseVersion()
@setupConnect() if httpd
@loadAdapter adapterPath, adapter if adapter?
# Public: Specify a router and callback to register as Connect middleware.
#
# route - A String of the route to match.
# callback - A Function that is called when the route is requested
#
# Returns nothing.
route: (route, callback) ->
@router.get route, callback
# Public: Adds a Listener that attempts to match incoming messages based on
# a Regex.
#
# regex - A Regex that determines if the callback should be called.
# callback - A Function that is called with a Response object.
#
# Returns nothing.
hear: (regex, callback) ->
@listeners.push new TextListener(@, regex, callback)
# Public: Adds a Listener that attempts to match incoming messages directed
# at the robot based on a Regex. All regexes treat patterns like they begin
# with a '^'
#
# regex - A Regex that determines if the callback should be called.
# callback - A Function that is called with a Response object.
#
# Returns nothing.
respond: (regex, callback) ->
re = regex.toString().split('/')
re.shift() # remove empty first item
modifiers = re.pop() # pop off modifiers
if re[0] and re[0][0] is '^'
@logger.warning "Anchors don't work well with respond, perhaps you want to use 'hear'"
@logger.warning "The regex in question was #{regex.toString()}"
pattern = re.join('/') # combine the pattern back again
if @alias
alias = @alias.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&') # escape alias for regexp
newRegex = new RegExp("^(?:#{alias}[:,]?|#{@name}[:,]?)\\s*(?:#{pattern})", modifiers)
else
newRegex = new RegExp("^#{@name}[:,]?\\s*(?:#{pattern})", modifiers)
@logger.debug newRegex.toString()
@listeners.push new TextListener(@, newRegex, callback)
# Public: Adds a Listener that triggers when anyone enters the room.
#
# callback - A Function that is called with a Response object.
#
# Returns nothing.
enter: (callback) ->
@listeners.push new Listener(@, ((msg) -> msg instanceof EnterMessage), callback)
# Public: Adds a Listener that triggers when anyone leaves the room.
#
# callback - A Function that is called with a Response object.
#
# Returns nothing.
leave: (callback) ->
@listeners.push new Listener(@, ((msg) -> msg instanceof LeaveMessage), callback)
# Public: Adds a Listener that triggers when no other text matchers match.
#
# callback - A Function that is called with a Response object.
#
# Returns nothing.
catchAll: (callback) ->
@listeners.push new Listener(@, ((msg) -> msg instanceof CatchAllMessage), ((msg) -> msg.message = msg.message.message; callback msg))
# Public: Passes the given message to any interested Listeners.
#
# message - A Message instance. Listeners can flag this message as
# 'done' to prevent further execution
#
# Returns nothing.
receive: (message) ->
results = []
for listener in @listeners
try
results.push listener.call(message)
break if message.done
catch error
@logger.error "Unable to call the listener: #{error}\n#{error.stack}"
false
if message not instanceof CatchAllMessage and results.indexOf(true) is -1
@receive new CatchAllMessage(message)
# Public: Loads every script in the given path.
#
# path - A String path on the filesystem.
#
# Returns nothing.
load: (path) ->
@logger.debug "Loading scripts from #{path}"
Path.exists path, (exists) =>
if exists
@loadPaths.push path
for file in Fs.readdirSync(path)
@loadFile path, file
# Public: Loads a file in path
#
# path - A String path on the filesystem.
# file - A String filename in path on the filesystem.
#
# Returns nothing.
loadFile: (path, file) ->
ext = Path.extname file
full = Path.join path, Path.basename(file, ext)
if ext is '.coffee' or ext is '.js'
try
require(full) @
@parseHelp "#{path}/#{file}"
catch error
@logger.error "Unable to load #{path}: #{error}\n#{error.stack}"
loadHubotScripts: (path, scripts) ->
@logger.info "Loading hubot-scripts from #{path}"
for script in scripts
@loadFile path, script
# Setup the Connect server's defaults
#
# Sets up basic authentication if parameters are provided
#
# Returns nothing.
setupConnect: ->
user = process.env.CONNECT_USER
pass = process.env.CONNECT_PASSWORD
Connect = require 'connect'
Connect.router = require 'connect_router'
@connect = Connect()
@connect.use Connect.basicAuth(user, pass) if user and pass
@connect.use Connect.bodyParser()
@connect.use Connect.router (app) =>
@router =
get: (route, callback) =>
@logger.debug "Registered route: GET #{route}"
app.get route, callback
post: (route, callback) =>
@logger.debug "Registered route: POST #{route}"
app.post route, callback
put: (route, callback) =>
@logger.debug "Registered route: PUT #{route}"
app.put route, callback
delete: (route, callback) =>
@logger.debug "Registered route: DELETE #{route}"
app.delete route, callback
@connect.listen process.env.PORT || 8080
herokuUrl = process.env.HEROKU_URL
if herokuUrl
herokuUrl += '/' unless /\/$/.test herokuUrl
setInterval =>
HttpClient.create("#{herokuUrl}hubot/ping")
.post() (err, res, body) =>
@logger.info 'keep alive ping!'
, 1200000
# Load the adapter Hubot is going to use.
#
# path - A String of the path to adapter if local.
# adapter - A String of the adapter name to use.
#
# Returns nothing.
loadAdapter: (path, adapter) ->
@logger.debug 'Loading adapter #{adapter}'
try
path = if adapter in HUBOT_DEFAULT_ADAPTERS
"#{path}/#{adapter}"
else
"hubot-#{adapter}"
@adapter = require("#{path}").use(@)
catch err
@logger.error "Cannot load adapter #{adapter} - #{err}\n#{err.stack}"
# Public: Help Commands for Running Scripts
#
# Returns an array of help commands for running scripts
helpCommands: ->
@commands.sort()
# Private: load help info from a loaded script
#
# path - The path to the file on disk
#
# Returns nothing
parseHelp: (path) ->
Fs.readFile path, 'utf-8', (err, body) =>
throw err if err?
for i, line of body.split("\n")
break if !(line[0] == '#' or line.substr(0, 2) == '//')
continue if !line.match('-')
@commands.push line[2..line.length]
# Public: A helper send function which delegates to the adapter's send
# function.
#
# user - A User instance.
# strings - One or more Strings for each message to send.
send: (user, strings...) ->
@adapter.send user, strings...
# Public: A helper send function to message a room that the robot is in
#
# room - String designating the room to message
# strings - One or more Strings for each message to send.
messageRoom: (room, strings...) ->
user = @userForId @id, { room: room }
@adapter.send user, strings...
# Public: A helper reply function which delegates to the adapter's reply
# function.
#
# user - A User instance.
# strings - One or more Strings for each message to send.
reply: (user, strings...) ->
@adapter.reply user, strings...
# Public: Get an Array of User objects stored in the brain.
users: ->
@brain.data.users
# Public: Get a User object given a unique identifier.
userForId: (id, options) ->
user = @brain.data.users[id]
unless user
user = new User id, options
@brain.data.users[id] = user
if options and options.room and (!user.room or user.room isnt options.room)
user = new User id, options
@brain.data.users[id] = user
user
# Public: Get a User object given a name.
userForName: (name) ->
result = null
lowerName = name.toLowerCase()
for k of (@brain.data.users or { })
userName = @brain.data.users[k]['name']
if userName? and userName.toLowerCase() is lowerName
result = @brain.data.users[k]
result
# Public: Get all users whose names match fuzzyName. Currently, match
# means 'starts with', but this could be extended to match initials,
# nicknames, etc.
#
usersForRawFuzzyName: (fuzzyName) ->
lowerFuzzyName = fuzzyName.toLowerCase()
user for key, user of (@brain.data.users or {}) when (
user.name.toLowerCase().lastIndexOf(lowerFuzzyName, 0) == 0)
# Public: If fuzzyName is an exact match for a user, returns an array with
# just that user. Otherwise, returns an array of all users for which
# fuzzyName is a raw fuzzy match (see usersForRawFuzzyName).
#
usersForFuzzyName: (fuzzyName) ->
matchedUsers = @usersForRawFuzzyName(fuzzyName)
lowerFuzzyName = fuzzyName.toLowerCase()
# We can scan matchedUsers rather than all users since usersForRawFuzzyName
# will include exact matches
for user in matchedUsers
return [user] if user.name.toLowerCase() is lowerFuzzyName
matchedUsers
# Kick off the event loop for the adapter
#
# Returns: Nothing.
run: ->
@adapter.run()
# Public: Gracefully shutdown the robot process
#
# Returns: Nothing.
shutdown: ->
@adapter.close()
@brain.close()
# Public: The version of Hubot from npm
#
# Returns: SemVer compliant version number
parseVersion: ->
package_path = __dirname + '/../package.json'
data = Fs.readFileSync package_path, 'utf8'
content = JSON.parse data
@version = content.version
# Public: Creates a scoped http client with chainable methods for
# modifying the request. This doesn't actually make a request though.
# Once your request is assembled, you can call `get()`/`post()`/etc to
# send the request.
#
# url - String URL to access.
#
# Examples:
#
# res.http("http://example.com")
# # set a single header
# .header('Authorization', 'bearer abcdef')
#
# # set multiple headers
# .headers(Authorization: 'bearer abcdef', Accept: 'application/json')
#
# # add URI query parameters
# .query(a: 1, b: 'foo & bar')
#
# # make the actual request
# .get() (err, res, body) ->
# console.log body
#
# # or, you can POST data
# .post(data) (err, res, body) ->
# console.log body
#
# Returns a ScopedClient instance.
http: (url) ->
HttpClient.create(url)
module.exports = Robot