]> code.delx.au - pymsnt/blob - src/legacy/glue.py
Fixed iq error responses to include xmlns
[pymsnt] / src / legacy / glue.py
1 # Copyright 2004-2005 James Bunton <james@delx.cjb.net>
2 # Licensed for distribution under the GPL version 2, check COPYING for details
3
4 import os.path
5 import utils
6 from twisted.internet import task
7 from tlib.xmlw import Element
8 from tlib import msn
9 from debug import LogEvent, INFO, WARN, ERROR
10 import disco
11 import groupchat
12 import ft
13 import avatar
14 import config
15 import lang
16
17
18
19
20 url = "http://msn-transport.jabberstudio.org"
21 version = "0.11-dev" # The transport version
22 mangle = True # XDB '@' -> '%' mangling
23 id = "msn" # The transport identifier
24
25
26 # Load the default avatars
27 f = open(os.path.join("data", "defaultJabberAvatar.png"), "rb")
28 defaultJabberAvatarData = f.read()
29 f.close()
30
31 f = open(os.path.join("data", "defaultMSNAvatar.png"), "rb")
32 defaultAvatarData = f.read()
33 f.close()
34 defaultAvatar = avatar.AvatarCache().setAvatar(defaultAvatarData)
35
36
37 def reloadConfig():
38 msn.MSNConnection.GETALLAVATARS = config.getAllAvatars
39
40 def isGroupJID(jid):
41 """ Returns True if the JID passed is a valid groupchat JID (for MSN, does not contain '%') """
42 return (jid.find('%') == -1)
43
44
45
46 # This should be set to the name space the registration entries are in, in the xdb spool
47 namespace = "jabber:iq:register"
48
49
50 def formRegEntry(username, password):
51 """ Returns a domish.Element representation of the data passed. This element will be written to the XDB spool file """
52 reginfo = Element((None, "query"))
53 reginfo.attributes["xmlns"] = "jabber:iq:register"
54
55 userEl = reginfo.addElement("username")
56 userEl.addContent(username)
57
58 passEl = reginfo.addElement("password")
59 passEl.addContent(password)
60
61 return reginfo
62
63
64
65
66 def getAttributes(base):
67 """ This function should, given a spool domish.Element, pull the username, password,
68 and out of it and return them """
69 username = ""
70 password = ""
71 for child in base.elements():
72 try:
73 if child.name == "username":
74 username = child.__str__()
75 elif child.name == "password":
76 password = child.__str__()
77 except AttributeError:
78 continue
79
80 return username, password
81
82
83 def startStats(statistics):
84 stats = statistics.stats
85 stats["MessageCount"] = 0
86 stats["FailedMessageCount"] = 0
87 stats["AvatarCount"] = 0
88 stats["FailedAvatarCount"] = 0
89
90 def updateStats(statistics):
91 stats = statistics.stats
92 # FIXME
93 #stats["AvatarCount"] = msnp2p.MSNP2P_Avatar.TRANSFER_COUNT
94 #stats["FailedAvatarCount"] = msnp2p.MSNP2P_Avatar.ERROR_COUNT
95
96
97 def msn2jid(msnid, withResource):
98 """ Converts a MSN passport into a JID representation to be used with the transport """
99 return msnid.replace('@', '%') + "@" + config.jid + (withResource and "/msn" or "")
100
101 # Marks this as the function to be used in jabber:iq:gateway (Service ID Translation)
102 translateAccount = lambda a: msn2jid(a, False)
103
104 def jid2msn(jid):
105 """ Converts a JID representation of a MSN passport into the original MSN passport """
106 return unicode(jid[:jid.find('@')].replace('%', '@')).split("/")[0]
107
108
109 def presence2state(show, ptype):
110 """ Converts a Jabber presence into an MSN status code """
111 if ptype == "unavailable":
112 return msn.STATUS_OFFLINE
113 elif not show or show == "online" or show == "chat":
114 return msn.STATUS_ONLINE
115 elif show == "dnd":
116 return msn.STATUS_BUSY
117 elif show == "away" or show == "xa":
118 return msn.STATUS_AWAY
119 return msn.STATUS_ONLINE
120
121
122 def state2presence(state):
123 """ Converts a MSN status code into a Jabber presence """
124 if state == msn.STATUS_ONLINE:
125 return (None, None)
126 elif state == msn.STATUS_BUSY:
127 return ("dnd", None)
128 elif state == msn.STATUS_AWAY:
129 return ("away", None)
130 elif state == msn.STATUS_IDLE:
131 return ("away", None)
132 elif state == msn.STATUS_BRB:
133 return ("away", None)
134 elif state == msn.STATUS_PHONE:
135 return ("dnd", None)
136 elif state == msn.STATUS_LUNCH:
137 return ("away", None)
138 else:
139 return (None, "unavailable")
140
141
142 def getGroupNames(msnContact, msnContactList):
143 """ Gets a list of groups that this contact is in """
144 groups = []
145 for groupGUID in msnContact.groups:
146 try:
147 groups.append(msnContactList.groups[groupGUID])
148 except KeyError:
149 pass
150 return groups
151
152 def msnlist2jabsub(lists):
153 """ Converts MSN contact lists ORed together into the corresponding Jabber subscription state """
154 if lists & msn.FORWARD_LIST and lists & msn.REVERSE_LIST:
155 return "both"
156 elif lists & msn.REVERSE_LIST:
157 return "from"
158 elif lists & msn.FORWARD_LIST:
159 return "to"
160 else:
161 return "none"
162
163
164 def jabsub2msnlist(sub):
165 """ Converts a Jabber subscription state into the corresponding MSN contact lists ORed together """
166 if sub == "to":
167 return msn.FORWARD_LIST
168 elif sub == "from":
169 return msn.REVERSE_LIST
170 elif sub == "both":
171 return (msn.FORWARD_LIST | msn.REVERSE_LIST)
172 else:
173 return 0
174
175
176
177
178
179 # This class handles groupchats with the legacy protocol
180 class LegacyGroupchat(groupchat.BaseGroupchat):
181 def __init__(self, session, resource=None, ID=None, switchboardSession=None):
182 """ Possible entry points for groupchat
183 - User starts an empty switchboard session by sending presence to a blank room
184 - An existing switchboard session is joined by another MSN user
185 - User invited to an existing switchboard session with more than one user
186 """
187 groupchat.BaseGroupchat.__init__(self, session, resource, ID)
188 if switchboardSession:
189 self.switchboardSession = switchboardSession
190 else:
191 self.switchboardSession = msn.MultiSwitchboardSession(self.session.legacycon)
192 self.switchboardSession.groupchat = self
193
194 LogEvent(INFO, self.roomJID())
195
196 def removeMe(self):
197 if self.switchboardSession.transport:
198 self.switchboardSession.transport.loseConnection()
199 self.switchboardSession.groupchat = None
200 del self.switchboardSession
201 groupchat.BaseGroupchat.removeMe(self)
202 LogEvent(INFO, self.roomJID())
203
204 def sendLegacyMessage(self, message, noerror):
205 LogEvent(INFO, self.roomJID())
206 self.switchboardSession.sendMessage(message.replace("\n", "\r\n"), noerror)
207
208 def sendContactInvite(self, contactJID):
209 LogEvent(INFO, self.roomJID())
210 userHandle = jid2msn(contactJID)
211 self.switchboardSession.inviteUser(userHandle)
212
213 def gotMessage(self, userHandle, text):
214 LogEvent(INFO, self.roomJID())
215 self.messageReceived(userHandle, text)
216
217
218
219 # This class handles most interaction with the legacy protocol
220 class LegacyConnection(msn.MSNConnection):
221 """ A glue class that connects to the legacy network """
222 def __init__(self, username, password, session):
223 self.jabberID = session.jabberID
224
225 self.session = session
226 self.listSynced = False
227 self.initialListVersion = 0
228
229 self.remoteShow = ""
230 self.remoteStatus = ""
231 self.remoteNick = ""
232
233 # Init the MSN bits
234 msn.MSNConnection.__init__(self, username, password, self.jabberID)
235
236 # User typing notification stuff
237 self.userTyping = dict() # Indexed by contact MSN ID, stores whether the user is typing to this contact
238 # Contact typing notification stuff
239 self.contactTyping = dict() # Indexed by contact MSN ID, stores an integer that is incremented at 5 second intervals. If it reaches 3 then the contact has stopped typing. It is set to zero whenever MSN typing notification messages are received
240 # Looping function
241 self.userTypingSend = task.LoopingCall(self.sendTypingNotifications)
242 self.userTypingSend.start(5.0)
243
244 self.legacyList = LegacyList(self.session)
245
246 LogEvent(INFO, self.jabberID)
247
248 def removeMe(self):
249 LogEvent(INFO, self.jabberID)
250
251 self.userTypingSend.stop()
252
253 self.legacyList.removeMe()
254 self.legacyList = None
255 self.session = None
256 self.logOut()
257
258
259 # Implemented from baseproto
260 def sendShowStatus(self, jid=None):
261 if not self.session: return
262 source = config.jid
263 if not jid:
264 jid = self.jabberID
265 self.session.sendPresence(to=jid, fro=source, show=self.remoteShow, status=self.remoteStatus, nickname=self.remoteNick)
266
267 def resourceOffline(self, resource):
268 pass
269
270 def highestResource(self):
271 """ Returns highest priority resource """
272 return self.session.highestResource()
273
274 def sendMessage(self, dest, resource, body, noerror):
275 dest = jid2msn(dest)
276 if self.userTyping.has_key(dest):
277 del self.userTyping[dest]
278 try:
279 msn.MSNConnection.sendMessage(self, dest, body, noerror)
280 self.session.pytrans.statistics.stats["MessageCount"] += 1
281 except:
282 self.failedMessage(dest, body)
283 raise
284
285 def sendFile(self, dest, ftSend):
286 dest = jid2msn(dest)
287 def continueSendFile1((msnFileSend, d)):
288 def continueSendFile2((success, )):
289 if success:
290 ftSend.accept(msnFileSend)
291 else:
292 sendFileFail()
293 d.addCallbacks(continueSendFile2, sendFileFail)
294
295 def sendFileFail():
296 ftSend.reject()
297
298 d = msn.MSNConnection.sendFile(self, dest, ftSend.filename, ftSend.filesize)
299 d.addCallbacks(continueSendFile1, sendFileFail)
300
301 def setStatus(self, nickname, show, status):
302 statusCode = presence2state(show, None)
303 msn.MSNConnection.changeStatus(self, statusCode, nickname, status)
304
305 def updateAvatar(self, av=None):
306 global defaultJabberAvatarData
307
308 if av:
309 msn.MSNConnection.changeAvatar(self, av.getImageData())
310 else:
311 msn.MSNConnection.changeAvatar(self, defaultJabberAvatarData)
312
313 def sendTypingNotifications(self):
314 if not self.session: return
315
316 # Send any typing notification messages to the user's contacts
317 for contact in self.userTyping.keys():
318 if self.userTyping[contact]:
319 self.sendTypingToContact(contact)
320
321 # Send any typing notification messages from contacts to the user
322 for contact in self.contactTyping.keys():
323 self.contactTyping[contact] += 1
324 if self.contactTyping[contact] >= 3:
325 self.session.sendTypingNotification(self.jabberID, msn2jid(contact, True), False)
326 del self.contactTyping[contact]
327
328 def userTypingNotification(self, dest, resource, composing):
329 if not self.session: return
330 dest = jid2msn(dest)
331 self.userTyping[dest] = composing
332 if composing: # Make it instant
333 self.sendTypingToContact(dest)
334
335
336
337 # Implement callbacks from msn.MSNConnection
338 def connectionFailed(self, reason):
339 LogEvent(INFO, self.jabberID)
340 text = lang.get(self.session.lang).msnConnectFailed % reason
341 self.session.sendMessage(to=self.jabberID, fro=config.jid, body=text)
342 self.session.removeMe()
343
344 def loginFailed(self, reason):
345 LogEvent(INFO, self.jabberID)
346 text = lang.get(self.session.lang).msnLoginFailure % (self.session.username)
347 self.session.sendErrorMessage(to=self.jabberID, fro=config.jid, etype="auth", condition="not-authorized", explanation=text, body="Login Failure")
348 self.session.removeMe()
349
350 def connectionLost(self, reason):
351 LogEvent(INFO, self.jabberID)
352 text = lang.get(self.session.lang).msnDisconnected % reason
353 self.session.sendMessage(to=self.jabberID, fro=config.jid, body=text)
354 self.session.removeMe() # Tear down the session
355
356 def multipleLogin(self):
357 LogEvent(INFO, self.jabberID)
358 self.session.sendMessage(to=self.jabberID, fro=config.jid, body=lang.get(self.session.lang).msnMultipleLogin)
359 self.session.removeMe()
360
361 def serverGoingDown(self):
362 LogEvent(INFO, self.jabberID)
363 self.session.sendMessage(to=self.jabberID, fro=config.jid, body=lang.get(self.session.lang).msnMaintenance)
364
365 def accountNotVerified(self):
366 LogEvent(INFO, self.jabberID)
367 text = lang.get(self.session.lang).msnNotVerified % (self.session.username)
368 self.session.sendMessage(to=self.jabberID, fro=config.jid, body=text)
369
370 def userMapping(self, passport, jid):
371 LogEvent(INFO, self.jabberID)
372 text = lang.get(self.session.lang).userMapping % (passport, jid)
373 self.session.sendMessage(to=self.jabberID, fro=msn2jid(passport, True), body=text)
374
375 def loggedIn(self):
376 LogEvent(INFO, self.jabberID)
377 self.session.ready = True
378
379 def listSynchronized(self):
380 LogEvent(INFO, self.jabberID)
381 self.session.sendPresence(to=self.jabberID, fro=config.jid)
382 self.legacyList.syncJabberLegacyLists()
383 self.listSynced = True
384 #self.legacyList.flushSubscriptionBuffer()
385
386 def ourStatusChanged(self, statusCode, screenName, personal):
387 # Send out a new presence packet to the Jabber user so that the transport icon changes
388 LogEvent(INFO, self.jabberID)
389 self.remoteShow, ptype = state2presence(statusCode)
390 self.remoteStatus = personal
391 self.remoteNick = screenName
392 self.sendShowStatus()
393
394 def gotMessage(self, remoteUser, text):
395 LogEvent(INFO, self.jabberID)
396 source = msn2jid(remoteUser, True)
397 if self.contactTyping.has_key(remoteUser):
398 del self.contactTyping[remoteUser]
399 self.session.sendMessage(self.jabberID, fro=source, body=text, mtype="chat")
400 self.session.pytrans.statistics.stats["MessageCount"] += 1
401
402 def gotGroupchat(self, msnGroupchat, userHandle):
403 LogEvent(INFO, self.jabberID)
404 msnGroupchat.groupchat = LegacyGroupchat(self.session, switchboardSession=msnGroupchat)
405 msnGroupchat.groupchat.sendUserInvite(msn2jid(userHandle, True))
406
407 def gotContactTyping(self, contact):
408 LogEvent(INFO, self.jabberID)
409 # Check if the contact has only just started typing
410 if not self.contactTyping.has_key(contact):
411 self.session.sendTypingNotification(self.jabberID, msn2jid(contact, True), True)
412
413 # Reset the counter
414 self.contactTyping[contact] = 0
415
416 def failedMessage(self, remoteUser, message):
417 LogEvent(INFO, self.jabberID)
418 self.session.pytrans.statistics.stats["FailedMessageCount"] += 1
419 fro = msn2jid(remoteUser, True)
420 self.session.sendErrorMessage(to=self.jabberID, fro=fro, etype="wait", condition="recipient-unavailable", explanation=lang.get(self.session.lang).msnFailedMessage, body=message)
421
422 def contactAvatarChanged(self, userHandle, hash):
423 LogEvent(INFO, self.jabberID)
424 jid = msn2jid(userHandle, False)
425 c = self.session.contactList.findContact(jid)
426 if not c: return
427
428 if hash:
429 # New avatar
430 av = self.session.pytrans.avatarCache.getAvatar(hash)
431 if av:
432 msnContact = self.getContacts().getContact(userHandle)
433 msnContact.msnobjGot = True
434 c.updateAvatar(av)
435 else:
436 def updateAvatarCB((imageData,)):
437 av = self.session.pytrans.avatarCache.setAvatar(imageData)
438 c.updateAvatar(av)
439 d = self.sendAvatarRequest(userHandle)
440 if d:
441 d.addCallback(updateAvatarCB)
442 else:
443 # They've turned off their avatar
444 global defaultAvatar
445 c.updateAvatar(defaultAvatar)
446
447 def contactStatusChanged(self, remoteUser):
448 LogEvent(INFO, self.jabberID)
449
450 msnContact = self.getContacts().getContact(remoteUser)
451 c = self.session.contactList.findContact(msn2jid(remoteUser, False))
452 if not (c and msnContact): return
453
454 show, ptype = state2presence(msnContact.status)
455 status = msnContact.personal.decode("utf-8")
456 screenName = msnContact.screenName.decode("utf-8")
457
458 c.updateNickname(screenName, push=False)
459 c.updatePresence(show, status, ptype, force=True)
460
461 def gotFileReceive(self, fileReceive):
462 LogEvent(INFO, self.jabberID)
463 # FIXME
464 ft.FTReceive(self.session, msn2jid(fileReceive.userHandle, True), fileReceive)
465
466 def contactAddedMe(self, userHandle):
467 LogEvent(INFO, self.jabberID)
468 self.session.contactList.getContact(msn2jid(userHandle, False)).contactRequestsAuth()
469
470 def contactRemovedMe(self, userHandle):
471 LogEvent(INFO, self.jabberID)
472 c = self.session.contactList.getContact(msn2jid(userHandle, True))
473 c.contactDerequestsAuth()
474 c.contactRemovesAuth()
475
476 def gotInitialEmailNotification(self, inboxunread, foldersunread):
477 if config.mailNotifications:
478 LogEvent(INFO, self.jabberID)
479 text = lang.get(self.session.lang).msnInitialMail % (inboxunread, foldersunread)
480 self.session.sendMessage(to=self.jabberID, fro=config.jid, body=text, mtype="headline")
481
482 def gotRealtimeEmailNotification(self, mailfrom, fromaddr, subject):
483 if config.mailNotifications:
484 LogEvent(INFO, self.jabberID)
485 text = lang.get(self.session.lang).msnRealtimeMail % (mailfrom, fromaddr, subject)
486 self.session.sendMessage(to=self.jabberID, fro=config.jid, body=text, mtype="headline")
487
488 def gotMSNAlert(self, text, actionurl, subscrurl):
489 LogEvent(INFO, self.jabberID)
490
491 el = Element((None, "message"))
492 el.attributes["to"] = self.jabberID
493 el.attributes["from"] = config.jid
494 el.attributes["type"] = "headline"
495 body = el.addElement("body")
496 body.addContent(text)
497
498 x = el.addElement("x")
499 x.attributes["xmlns"] = "jabber:x:oob"
500 x.addElement("desc").addContent("More information on this notice.")
501 x.addElement("url").addContent(actionurl)
502
503 x = el.addElement("x")
504 x.attributes["xmlns"] = "jabber:x:oob"
505 x.addElement("desc").addContent("Manage subscriptions to alerts.")
506 x.addElement("url").addContent(subscrurl)
507
508 self.session.pytrans.send(el)
509
510 def gotAvatarImageData(self, userHandle, imageData):
511 LogEvent(INFO, self.jabberID)
512 av = self.session.pytrans.avatarCache.setAvatar(imageData)
513 jid = msn2jid(userHandle, False)
514 c = self.session.contactList.findContact(jid)
515 c.updateAvatar(av)
516
517
518
519
520 class LegacyList:
521 def __init__(self, session):
522 self.jabberID = session.jabberID
523 self.session = session
524 self.subscriptionBuffer = []
525
526 def removeMe(self):
527 self.subscriptionBuffer = None
528 self.session = None
529
530 def addContact(self, jid):
531 LogEvent(INFO, self.jabberID)
532 userHandle = jid2msn(jid)
533 self.session.legacycon.addContact(msn.FORWARD_LIST, userHandle)
534 self.session.contactList.getContact(jid).contactGrantsAuth()
535
536 def removeContact(self, jid):
537 LogEvent(INFO, self.jabberID)
538 jid = jid2msn(jid)
539 self.session.legacycon.remContact(msn.FORWARD_LIST, jid)
540
541
542 def authContact(self, jid):
543 LogEvent(INFO, self.jabberID)
544 jid = jid2msn(jid)
545 d = self.session.legacycon.remContact(msn.PENDING_LIST, jid)
546 if d:
547 self.session.legacycon.addContact(msn.REVERSE_LIST, jid)
548 self.session.legacycon.remContact(msn.BLOCK_LIST, jid)
549 self.session.legacycon.addContact(msn.ALLOW_LIST, jid)
550
551 def deauthContact(self, jid):
552 LogEvent(INFO, self.jabberID)
553 jid = jid2msn(jid)
554 self.session.legacycon.remContact(msn.ALLOW_LIST, jid)
555 self.session.legacycon.remContact(msn.PENDING_LIST, jid)
556 self.session.legacycon.addContact(msn.BLOCK_LIST, jid)
557
558
559
560 def syncJabberLegacyLists(self):
561 """ Synchronises the MSN contact list on server with the Jabber contact list """
562
563 global defaultAvatar
564
565 # We have to make an MSNContactList from the XDB data, then compare it with the one the server sent
566 # Any subscription changes must be sent to the client, as well as changed in the XDB
567 LogEvent(INFO, self.jabberID, "Start.")
568 result = self.session.pytrans.xdb.request(self.jabberID, disco.IQROSTER)
569 oldContactList = msn.MSNContactList()
570 if result:
571 for item in result.elements():
572 user = item.getAttribute("jid")
573 sub = item.getAttribute("subscription")
574 lists = item.getAttribute("lists")
575 if not lists:
576 lists = jabsub2msnlist(sub) # Backwards compatible
577 lists = int(lists)
578 contact = msn.MSNContact(userHandle=user, screenName="", lists=lists)
579 oldContactList.addContact(contact)
580
581 newXDB = Element((None, "query"))
582 newXDB.attributes["xmlns"] = disco.IQROSTER
583
584 contactList = self.session.legacycon.getContacts()
585
586
587 # Convienence functions
588 def addedToList(num):
589 return (not (oldLists & num) and (lists & num))
590 def removedFromList(num):
591 return ((oldLists & num) and not (lists & num))
592
593 for contact in contactList.contacts.values():
594 # Compare with the XDB <item/> entry
595 oldContact = oldContactList.getContact(contact.userHandle)
596 if oldContact == None:
597 oldLists = 0
598 else:
599 oldLists = oldContact.lists
600 lists = contact.lists
601
602 # Create the Jabber representation of the
603 # contact base on the old list data and then
604 # sync it with current
605 jabContact = self.session.contactList.createContact(msn2jid(contact.userHandle, False), msnlist2jabsub(oldLists))
606 jabContact.updateAvatar(defaultAvatar, push=False)
607
608 if addedToList(msn.FORWARD_LIST):
609 jabContact.syncGroups(getGroupNames(contact, contactList), push=False)
610 jabContact.syncContactGrantedAuth()
611
612 if removedFromList(msn.FORWARD_LIST):
613 jabContact.syncContactRemovedAuth()
614
615 if addedToList(msn.ALLOW_LIST):
616 jabContact.syncUserGrantedAuth()
617
618 if addedToList(msn.BLOCK_LIST) or removedFromList(msn.ALLOW_LIST):
619 jabContact.syncUserRemovedAuth()
620
621 if (not (lists & msn.ALLOW_LIST) and not (lists & msn.BLOCK_LIST) and (lists & msn.REVERSE_LIST)) or (lists & msn.PENDING_LIST):
622 jabContact.contactRequestsAuth()
623
624 if removedFromList(msn.REVERSE_LIST):
625 jabContact.contactDerequestsAuth()
626
627 jabContact.syncRoster()
628
629 item = newXDB.addElement("item")
630 item.attributes["jid"] = contact.userHandle
631 item.attributes["subscription"] = msnlist2jabsub(lists)
632 item.attributes["lists"] = str(lists)
633
634 # Update the XDB
635 self.session.pytrans.xdb.set(self.jabberID, disco.IQROSTER, newXDB)
636 LogEvent(INFO, self.jabberID, "End.")
637
638 def saveLegacyList(self):
639 contactList = self.session.legacycon.getContacts()
640 if not contactList: return
641
642 newXDB = Element((None, "query"))
643 newXDB.attributes["xmlns"] = disco.IQROSTER
644
645 for contact in contactList.contacts.values():
646 item = newXDB.addElement("item")
647 item.attributes["jid"] = contact.userHandle
648 item.attributes["subscription"] = msnlist2jabsub(contact.lists) # Backwards compat
649 item.attributes["lists"] = str(contact.lists)
650
651 self.session.pytrans.xdb.set(self.jabberID, disco.IQROSTER, newXDB)
652 LogEvent(INFO, self.jabberID, "Finished saving list.")
653
654