/
xmpp.coffee
444 lines (359 loc) · 15.5 KB
/
xmpp.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
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
{Adapter,Robot,TextMessage,EnterMessage,LeaveMessage} = require 'hubot'
{JID, Stanza, Client, parse, Element} = require 'node-xmpp-client'
util = require 'util'
class XmppBot extends Adapter
reconnectTryCount: 0
constructor: ( robot ) ->
@robot = robot
# Flag to log a warning message about group chat configuration only once
@anonymousGroupChatWarningLogged = false
# Store the room JID to private JID map.
# Key is the room JID, value is the private JID
@roomToPrivateJID = {}
# http://stackoverflow.com/a/646643
String::startsWith ?= (s) -> @slice(0, s.length) == s
run: ->
options =
username: process.env.HUBOT_XMPP_USERNAME
password: '********'
host: process.env.HUBOT_XMPP_HOST
port: process.env.HUBOT_XMPP_PORT
rooms: @parseRooms process.env.HUBOT_XMPP_ROOMS.split(',')
# ms interval to send whitespace to xmpp server
keepaliveInterval: process.env.HUBOT_XMPP_KEEPALIVE_INTERVAL || 30000
reconnectTry: process.env.HUBOT_XMPP_RECONNECT_TRY || 5
reconnectWait: process.env.HUBOT_XMPP_RECONNECT_WAIT || 5000
legacySSL: process.env.HUBOT_XMPP_LEGACYSSL
preferredSaslMechanism: process.env.HUBOT_XMPP_PREFERRED_SASL_MECHANISM
disallowTLS: process.env.HUBOT_XMPP_DISALLOW_TLS
pmAddPrefix: process.env.HUBOT_XMPP_PM_ADD_PREFIX
@robot.logger.info util.inspect(options)
options.password = process.env.HUBOT_XMPP_PASSWORD
@options = options
@connected = false
@makeClient()
# Only try to reconnect 5 times
reconnect: () ->
options = @options
@reconnectTryCount += 1
if @reconnectTryCount > options.reconnectTry
@robot.logger.error 'Unable to reconnect to jabber server dying.'
process.exit 1
@client.removeListener 'error', @.error
@client.removeListener 'online', @.online
@client.removeListener 'offline', @.offline
@client.removeListener 'stanza', @.read
setTimeout () =>
@makeClient()
, options.reconnectWait
makeClient: () ->
options = @options
@client = new Client
reconnect: true
jid: options.username
password: options.password
host: options.host
port: options.port
legacySSL: options.legacySSL
preferredSaslMechanism: options.preferredSaslMechanism
disallowTLS: options.disallowTLS
@configClient(options)
configClient: (options) ->
@client.connection.socket.setTimeout 0
setInterval(@ping, options.keepaliveInterval)
@client.on 'error', @.error
@client.on 'online', @.online
@client.on 'offline', @.offline
@client.on 'stanza', @.read
@client.on 'end', () =>
@robot.logger.info 'Connection closed, attempting to reconnect'
@reconnect()
error: (error) =>
@robot.logger.error "Received error #{error.toString()}"
online: =>
@robot.logger.info 'Hubot XMPP client online'
# Setup keepalive
@client.connection.socket.setTimeout 0
@client.connection.socket.setKeepAlive true, @options.keepaliveInterval
presence = new Stanza 'presence'
presence.c('nick', xmlns: 'http://jabber.org/protocol/nick').t(@robot.name)
@client.send presence
@robot.logger.info 'Hubot XMPP sent initial presence'
@joinRoom room for room in @options.rooms
@emit if @connected then 'reconnected' else 'connected'
@connected = true
@reconnectTryCount = 0
ping: =>
ping = new Stanza('iq', type: 'get')
ping.c('ping', xmlns: 'urn:xmpp:ping')
@robot.logger.debug "[sending ping] #{ping}"
@client.send ping
parseRooms: (items) ->
rooms = []
for room in items
index = room.indexOf(':')
rooms.push
jid: room.slice(0, if index > 0 then index else room.length)
password: if index > 0 then room.slice(index+1) else false
return rooms
# XMPP Joining a room - http://xmpp.org/extensions/xep-0045.html#enter-muc
joinRoom: (room) ->
@client.send do =>
@robot.logger.debug "Joining #{room.jid}/#{@robot.name}"
# prevent the server from confusing us with old messages
# and it seems that servers don't reliably support maxchars
# or zero values
el = new Stanza('presence', to: "#{room.jid}/#{@robot.name}")
x = el.c('x', xmlns: 'http://jabber.org/protocol/muc')
x.c('history', seconds: 1 )
if (room.password)
x.c('password').t(room.password)
return x
# XMPP Leaving a room - http://xmpp.org/extensions/xep-0045.html#exit
leaveRoom: (room) ->
# messageFromRoom check for joined rooms so remvove it from the list
for joined, index in @options.rooms
if joined.jid == room.jid
@options.rooms.splice index, 1
@client.send do =>
@robot.logger.debug "Leaving #{room.jid}/#{@robot.name}"
return new Stanza('presence',
to: "#{room.jid}/#{@robot.name}",
type: 'unavailable')
# Send query for users in the room and once the server response is parsed,
# apply the callback against the retrieved data.
# callback should be of the form `(usersInRoom) -> console.log usersInRoom`
# where usersInRoom is an array of username strings.
# For normal use, no need to pass requestId: it's there for testing purposes.
getUsersInRoom: (room, callback, requestId) ->
# (pseudo) random string to keep track of the current request
# Useful in case of concurrent requests
unless requestId
requestId = 'get_users_in_room_' + Date.now() + Math.random().toString(36).slice(2)
# http://xmpp.org/extensions/xep-0045.html#disco-roomitems
@client.send do =>
@robot.logger.debug "Fetching users in the room #{room.jid}"
message = new Stanza('iq',
from : @options.username,
id: requestId,
to : room.jid,
type: 'get')
message.c('query',
xmlns : 'http://jabber.org/protocol/disco#items')
return message
# Listen to the event with the current request id, one time only
@once "completedRequest#{requestId}", callback
# XMPP invite to a room, directly - http://xmpp.org/extensions/xep-0249.html
sendInvite: (room, invitee, reason) ->
@client.send do =>
@robot.logger.debug "Inviting #{invitee} to #{room.jid}"
message = new Stanza('message',
to : invitee)
message.c('x',
xmlns : 'jabber:x:conference',
jid: room.jid,
reason: reason)
return message
read: (stanza) =>
if stanza.attrs.type is 'error'
@robot.logger.error '[xmpp error]' + stanza
return
switch stanza.name
when 'message'
@readMessage stanza
when 'presence'
@readPresence stanza
when 'iq'
@readIq stanza
readIq: (stanza) =>
@robot.logger.debug "[received iq] #{stanza}"
# Some servers use iq pings to make sure the client is still functional.
# We need to reply or we'll get kicked out of rooms we've joined.
if (stanza.attrs.type == 'get' && stanza.children[0].name == 'ping')
pong = new Stanza('iq',
to: stanza.attrs.from
from: stanza.attrs.to
type: 'result'
id: stanza.attrs.id)
@robot.logger.debug "[sending pong] #{pong}"
@client.send pong
else if ((stanza.attrs.id?.startsWith 'get_users_in_room') && stanza.children[0].children)
roomJID = stanza.attrs.from
userItems = stanza.children[0].children
# Note that this contains usernames and NOT the full user JID.
usersInRoom = (item.attrs.name for item in userItems)
@robot.logger.debug "[users in room] #{roomJID} has #{usersInRoom}"
@emit "completedRequest#{stanza.attrs.id}", usersInRoom
readMessage: (stanza) =>
# ignore non-messages
return if stanza.attrs.type not in ['groupchat', 'direct', 'chat']
return if stanza.attrs.from is undefined
# ignore empty bodies (i.e., topic changes -- maybe watch these someday)
body = stanza.getChild 'body'
return unless body
from = stanza.attrs.from
message = body.getText()
if stanza.attrs.type == 'groupchat'
# Everything before the / is the room name in groupchat JID
[room, user] = from.split '/'
# ignore our own messages in rooms or messaged without user part
return if user is undefined or user == "" or user == @robot.name
# Convert the room JID to private JID if we have one
privateChatJID = @roomToPrivateJID[from]
else
# Not sure how to get the user's alias. Use the username.
# The resource is not the user's alias but the unique client
# ID which is often the machine name
[user] = from.split '@'
# Not from a room
room = undefined
# Also store the private JID so we can use it in the send method
privateChatJID = from
# For private messages, make the commands work even when they are not prefixed with hubot name or alias
if @options.pmAddPrefix and
message.slice(0, @robot.name.length).toLowerCase() != @robot.name.toLowerCase() and
message.slice(0, process.env.HUBOT_ALIAS?.length).toLowerCase() != process.env.HUBOT_ALIAS?.toLowerCase()
message = "#{@robot.name} #{message}"
# note that 'user' isn't a full JID in case of group chat,
# just the local user part
# FIXME Not sure it's a good idea to use the groupchat JID resource part
# as two users could have the same resource in two different rooms.
# I leave it as-is for backward compatiblity. A better idea would
# be to use the full groupchat JID.
user = @robot.brain.userForId user
user.type = stanza.attrs.type
user.room = room
user.privateChatJID = privateChatJID if privateChatJID
@robot.logger.debug "Received message: #{message} in room: #{user.room}, from: #{user.name}. Private chat JID is #{user.privateChatJID}"
@receive new TextMessage(user, message)
readPresence: (stanza) =>
fromJID = new JID(stanza.attrs.from)
# xmpp doesn't add types for standard available mesages
# note that upon joining a room, server will send available
# presences for all members
# http://xmpp.org/rfcs/rfc3921.html#rfc.section.2.2.1
stanza.attrs.type ?= 'available'
switch stanza.attrs.type
when 'subscribe'
@robot.logger.debug "#{stanza.attrs.from} subscribed to me"
@client.send new Stanza('presence',
from: stanza.attrs.to
to: stanza.attrs.from
id: stanza.attrs.id
type: 'subscribed'
)
when 'probe'
@robot.logger.debug "#{stanza.attrs.from} probed me"
@client.send new Stanza('presence',
from: stanza.attrs.to
to: stanza.attrs.from
id: stanza.attrs.id
)
when 'available'
# If the presence is from us, track that.
if fromJID.resource is @robot.name or
stanza.getChild?('nick')?.getText?() is @robot.name
@heardOwnPresence = true
return
# ignore presence messages that sometimes get broadcast
# Group chat jid are of the form
# room_name@conference.hostname/Room specific id
room = fromJID.bare().toString()
return if not @messageFromRoom room
# Try to resolve the private JID
privateChatJID = @resolvePrivateJID(stanza)
# Keep the room JID to private JID map in this class as there
# is an initialization race condition between the presence messages
# and the brain initial load.
# See https://github.com/github/hubot/issues/619
@roomToPrivateJID[fromJID.toString()] = privateChatJID?.toString()
@robot.logger.debug "Available received from #{fromJID.toString()} in room #{room} and private chat jid is #{privateChatJID?.toString()}"
# Use the resource part from the room jid as this
# is most likely the user's name
user = @robot.brain.userForId(fromJID.resource,
room: room,
jid: fromJID.toString(),
privateChatJID: privateChatJID?.toString())
# Xmpp sends presence for every person in a room, when join it
# Only after we've heard our own presence should we respond to
# presence messages.
@receive new EnterMessage user unless not @heardOwnPresence
when 'unavailable'
[room, user] = stanza.attrs.from.split '/'
# ignore presence messages that sometimes get broadcast
return if not @messageFromRoom room
# ignore our own messages in rooms
return if user == @options.username
@robot.logger.debug "Unavailable received from #{user} in room #{room}"
user = @robot.brain.userForId user, room: room
@receive new LeaveMessage(user)
# Accept a stanza from a group chat
# return privateJID (instanceof JID) or the
# http://jabber.org/protocol/muc#user extension was not provided
resolvePrivateJID: ( stanza ) ->
jid = new JID(stanza.attrs.from)
# room presence in group chat uses a jid which is not the real user jid
# To send private message to a user seen in a groupchat,
# you need to get the real jid. If the groupchat is configured to do so,
# the real jid is also sent as an extension
# http://xmpp.org/extensions/xep-0045.html#enter-nonanon
privateJID = stanza.getChild('x', 'http://jabber.org/protocol/muc#user')?.getChild?('item')?.attrs?.jid
unless privateJID
unless @anonymousGroupChatWarningLogged
@robot.logger.warning "Could not get private JID from group chat. Make sure the server is configured to broadcast real jid for groupchat (see http://xmpp.org/extensions/xep-0045.html#enter-nonanon)"
@anonymousGroupChatWarningLogged = true
return null
return new JID(privateJID)
# Checks that the room parameter is a room the bot is in.
messageFromRoom: (room) ->
for joined in @options.rooms
return true if joined.jid.toUpperCase() == room.toUpperCase()
return false
send: (envelope, messages...) ->
for msg in messages
@robot.logger.debug "Sending to #{envelope.room}: #{msg}"
to = envelope.room
if envelope.user?.type in ['direct', 'chat']
to = envelope.user.privateChatJID ? "#{envelope.room}/#{envelope.user.name}"
params =
# Send a real private chat if we know the real private JID,
# else, send to the groupchat JID but in private mode
# Note that if the original message was not a group chat
# message, envelope.user.privateChatJID will be
# set to the JID from that private message
to: to
type: envelope.user?.type or 'groupchat'
if msg instanceof Element
message = msg.root()
message.attrs.to ?= params.to
message.attrs.type ?= params.type
else
parsedMsg = try parse(msg)
bodyMsg = new Stanza('message', params).
c('body').t(msg)
message = if parsedMsg?
bodyMsg.up().
c('html',{xmlns:'http://jabber.org/protocol/xhtml-im'}).
c('body',{xmlns:'http://www.w3.org/1999/xhtml'}).
cnode(parsedMsg)
else
bodyMsg
@client.send message
reply: (envelope, messages...) ->
for msg in messages
if msg instanceof Element
@send envelope, msg
else
@send envelope, "#{envelope.user.name}: #{msg}"
topic: (envelope, strings...) ->
string = strings.join "\n"
message = new Stanza('message',
to: envelope.room
type: envelope.user.type
).
c('subject').t(string)
@client.send message
offline: =>
@robot.logger.debug "Received offline event"
exports.use = (robot) ->
new XmppBot robot