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