/
robot.coffee
353 lines (310 loc) · 10.3 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
Fs = require 'fs'
Url = require 'url'
Path = require 'path'
EventEmitter = require('events').EventEmitter
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: (path, name = "Hubot") ->
@name = name
@brain = new Robot.Brain
@commands = []
@Response = Robot.Response
@listeners = []
@loadPaths = []
@enableSlash = false
@load path if path
# 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 "^"
console.log "\nWARNING: Anchors don't work well with respond, perhaps you want to use 'hear'"
console.log "WARNING: The regex in question was #{regex.toString()}\n"
pattern = re.join("/") # combine the pattern back again
if @enableSlash
newRegex = new RegExp("^(?:\/|#{@name}:?)\\s*#{pattern}", modifiers)
else
newRegex = new RegExp("^#{@name}:?\\s*#{pattern}", modifiers)
console.log 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 Robot.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 Robot.LeaveMessage), callback)
# Public: Passes the given message to any interested Listeners.
#
# message - A Robot.Message instance.
#
# Returns nothing.
receive: (message) ->
for lst in @listeners
try
lst.call message
catch ex
console.log "error while calling listener: #{ex}"
# Public: Loads every script in the given path.
#
# path - A String path on the filesystem.
#
# Returns nothing.
load: (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'
require(full) @
@parseHelp "#{path}/#{file}"
# 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] != '#'
continue if !line.match('-')
@commands.push line[2..line.length]
# Public: Raw method for sending data back to the chat source. Extend this.
#
# user - A Robot.User instance.
# strings - One or more Strings for each message to send.
send: (user, strings...) ->
# Public: Raw method for building a reply and sending it back to the chat
# source. Extend this.
#
# user - A Robot.User instance.
# strings - One or more Strings for each reply to send.
reply: (user, strings...) ->
# Public: Raw method for invoking the bot to run
# Extend this.
run: ->
# Public: Raw method for shutting the bot down.
# Extend this.
close: ->
@brain.close()
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 Robot.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 { })
if @brain.data.users[k]['name'].toLowerCase() is lowerName
result = @brain.data.users[k]
result
class Robot.User
# Represents a participating user in the chat.
#
# id - A unique ID for the user.
# options - An optional Hash of key, value pairs for this user.
constructor: (@id, options = { }) ->
for k of (options or { })
@[k] = options[k]
# http://www.the-isb.com/images/Nextwave-Aaron01.jpg
class Robot.Brain extends EventEmitter
# Represents somewhat persistent storage for the robot.
#
# Returns a new Brain with no external storage. Extend this!
constructor: () ->
@data =
users: { }
@resetSaveInterval 5
save: ->
@emit 'save', @data
close: ->
clearInterval @saveInterval
@save()
@emit 'close'
resetSaveInterval: (seconds) ->
clearInterval @saveInterval if @saveInterval
@saveInterval = setInterval =>
@save()
, seconds * 1000
# Merge keys loaded from a DB against the in memory representation
#
# Returns nothing
#
# Caveats: Deeply nested structures don't merge well
mergeData: (data) ->
for k of (data or { })
@data[k] = data[k]
class Robot.Message
# Represents an incoming message from the chat.
#
# user - A Robot.User instance that sent the message.
constructor: (@user) ->
class Robot.TextMessage extends Robot.Message
# Represents an incoming message from the chat.
#
# user - A Robot.User instance that sent the message.
# text - The String message contents.
constructor: (@user, @text) ->
super @user
# Determines if the message matches the given regex.
#
# regex - The Regex to check.
#
# Returns a Match object or null.
match: (regex) ->
@text.match regex
# Represents an incoming user entrance notification.
#
# user - A Robot.User instance for the user who entered.
class Robot.EnterMessage extends Robot.Message
# Represents an incoming user exit notification.
#
# user - A Robot.User instance for the user who left.
class Robot.LeaveMessage extends Robot.Message
class Listener
# Listeners receive every message from the chat source and decide if they
# want to act on it.
#
# robot - The current Robot instance.
# matcher - The Function that determines if this listener should trigger the
# callback.
# callback - The Function that is triggered if the incoming message matches.
constructor: (@robot, @matcher, @callback) ->
# Public: Determines if the listener likes the content of the message. If
# so, a Response built from the given Message is passed to the Listener
# callback.
#
# message - a Robot.Message instance.
#
# Returns nothing.
call: (message) ->
if match = @matcher message
@callback new @robot.Response(@robot, message, match)
class TextListener extends Listener
# TextListeners receive every message from the chat source and decide if they want
# to act on it.
#
# robot - The current Robot instance.
# regex - The Regex that determines if this listener should trigger the
# callback.
# callback - The Function that is triggered if the incoming message matches.
constructor: (@robot, @regex, @callback) ->
@matcher = (message) =>
if message instanceof Robot.TextMessage
message.match @regex
class Robot.Response
# Public: Responses are sent to matching listeners. Messages know about the
# content and user that made the original message, and how to reply back to
# them.
#
# robot - The current Robot instance.
# message - The current Robot.Message instance.
# match - The Match object from the successful Regex match.
constructor: (@robot, @message, @match) ->
# Public: Posts a message back to the chat source
#
# strings - One or more strings to be posted. The order of these strings
# should be kept intact.
#
# Returns nothing.
send: (strings...) ->
@robot.send @message.user, strings...
# Public: Posts a message mentioning the current user.
#
# strings - One or more strings to be posted. The order of these strings
# should be kept intact.
#
# Returns nothing.
reply: (strings...) ->
@robot.reply @message.user, strings...
# Public: Picks a random item from the given items.
#
# items - An Array of items (usually Strings).
#
# Returns a random item.
random: (items) ->
items[ Math.floor(Math.random() * items.length) ]
# 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)
Robot.Response.prototype.httpClient = require 'scoped-http-client'
module.exports = Robot