Making the code slightly easier to maintain
[theonering] / src / gvoice / backend.py
1 #!/usr/bin/python
2
3 """
4 DialCentral - Front end for Google's GoogleVoice service.
5 Copyright (C) 2008  Eric Warnke ericew AT gmail DOT com
6
7 This library is free software; you can redistribute it and/or
8 modify it under the terms of the GNU Lesser General Public
9 License as published by the Free Software Foundation; either
10 version 2.1 of the License, or (at your option) any later version.
11
12 This library is distributed in the hope that it will be useful,
13 but WITHOUT ANY WARRANTY; without even the implied warranty of
14 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
15 Lesser General Public License for more details.
16
17 You should have received a copy of the GNU Lesser General Public
18 License along with this library; if not, write to the Free Software
19 Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
20
21 Google Voice backend code
22
23 Resources
24         http://thatsmith.com/2009/03/google-voice-addon-for-firefox/
25         http://posttopic.com/topic/google-voice-add-on-development
26 """
27
28 from __future__ import with_statement
29
30 import os
31 import re
32 import urllib
33 import urllib2
34 import time
35 import datetime
36 import itertools
37 import logging
38 import inspect
39
40 from xml.etree import ElementTree
41
42 try:
43         import simplejson as _simplejson
44         simplejson = _simplejson
45 except ImportError:
46         simplejson = None
47
48 import browser_emu
49
50
51 _moduleLogger = logging.getLogger(__name__)
52
53
54 class NetworkError(RuntimeError):
55         pass
56
57
58 class MessageText(object):
59
60         ACCURACY_LOW = "med1"
61         ACCURACY_MEDIUM = "med2"
62         ACCURACY_HIGH = "high"
63
64         def __init__(self):
65                 self.accuracy = None
66                 self.text = None
67
68         def __str__(self):
69                 return self.text
70
71         def to_dict(self):
72                 return to_dict(self)
73
74         def __eq__(self, other):
75                 return self.accuracy == other.accuracy and self.text == other.text
76
77
78 class Message(object):
79
80         def __init__(self):
81                 self.whoFrom = None
82                 self.body = None
83                 self.when = None
84
85         def __str__(self):
86                 return "%s (%s): %s" % (
87                         self.whoFrom,
88                         self.when,
89                         "".join(str(part) for part in self.body)
90                 )
91
92         def to_dict(self):
93                 selfDict = to_dict(self)
94                 selfDict["body"] = [text.to_dict() for text in self.body] if self.body is not None else None
95                 return selfDict
96
97         def __eq__(self, other):
98                 return self.whoFrom == other.whoFrom and self.when == other.when and self.body == other.body
99
100
101 class Conversation(object):
102
103         TYPE_VOICEMAIL = "Voicemail"
104         TYPE_SMS = "SMS"
105
106         def __init__(self):
107                 self.type = None
108                 self.id = None
109                 self.contactId = None
110                 self.name = None
111                 self.location = None
112                 self.prettyNumber = None
113                 self.number = None
114
115                 self.time = None
116                 self.relTime = None
117                 self.messages = None
118                 self.isRead = None
119                 self.isSpam = None
120                 self.isTrash = None
121                 self.isArchived = None
122
123         def __cmp__(self, other):
124                 cmpValue = cmp(self.contactId, other.contactId)
125                 if cmpValue != 0:
126                         return cmpValue
127
128                 cmpValue = cmp(self.time, other.time)
129                 if cmpValue != 0:
130                         return cmpValue
131
132                 cmpValue = cmp(self.id, other.id)
133                 if cmpValue != 0:
134                         return cmpValue
135
136         def to_dict(self):
137                 selfDict = to_dict(self)
138                 selfDict["messages"] = [message.to_dict() for message in self.messages] if self.messages is not None else None
139                 return selfDict
140
141
142 class GVoiceBackend(object):
143         """
144         This class encapsulates all of the knowledge necessary to interact with the GoogleVoice servers
145         the functions include login, setting up a callback number, and initalting a callback
146         """
147
148         PHONE_TYPE_HOME = 1
149         PHONE_TYPE_MOBILE = 2
150         PHONE_TYPE_WORK = 3
151         PHONE_TYPE_GIZMO = 7
152
153         def __init__(self, cookieFile = None):
154                 # Important items in this function are the setup of the browser emulation and cookie file
155                 self._browser = browser_emu.MozillaEmulator(1)
156                 self._loadedFromCookies = self._browser.load_cookies(cookieFile)
157
158                 self._token = ""
159                 self._accountNum = ""
160                 self._lastAuthed = 0.0
161                 self._callbackNumber = ""
162                 self._callbackNumbers = {}
163
164                 # Suprisingly, moving all of these from class to self sped up startup time
165
166                 self._validateRe = re.compile("^\+?[0-9]{10,}$")
167
168                 self._loginURL = "https://www.google.com/accounts/ServiceLoginAuth"
169
170                 SECURE_URL_BASE = "https://www.google.com/voice/"
171                 SECURE_MOBILE_URL_BASE = SECURE_URL_BASE + "mobile/"
172                 self._forwardURL = SECURE_MOBILE_URL_BASE + "phones"
173                 self._tokenURL = SECURE_URL_BASE + "m"
174                 self._callUrl = SECURE_URL_BASE + "call/connect"
175                 self._callCancelURL = SECURE_URL_BASE + "call/cancel"
176                 self._sendSmsURL = SECURE_URL_BASE + "sms/send"
177
178                 self._isDndURL = "https://www.google.com/voice/m/donotdisturb"
179                 self._isDndRe = re.compile(r"""<input.*?id="doNotDisturb".*?checked="(.*?)"\s*/>""")
180                 self._setDndURL = "https://www.google.com/voice/m/savednd"
181
182                 self._downloadVoicemailURL = SECURE_URL_BASE + "media/send_voicemail/"
183                 self._markAsReadURL = SECURE_URL_BASE + "m/mark"
184                 self._archiveMessageURL = SECURE_URL_BASE + "m/archive"
185
186                 self._XML_SEARCH_URL = SECURE_URL_BASE + "inbox/search/"
187                 self._XML_ACCOUNT_URL = SECURE_URL_BASE + "contacts/"
188                 # HACK really this redirects to the main pge and we are grabbing some javascript
189                 self._XML_CONTACTS_URL = "http://www.google.com/voice/inbox/search/contact"
190                 self._XML_RECENT_URL = SECURE_URL_BASE + "inbox/recent/"
191
192                 self.XML_FEEDS = (
193                         'inbox', 'starred', 'all', 'spam', 'trash', 'voicemail', 'sms',
194                         'recorded', 'placed', 'received', 'missed'
195                 )
196                 self._XML_INBOX_URL = SECURE_URL_BASE + "inbox/recent/inbox"
197                 self._XML_STARRED_URL = SECURE_URL_BASE + "inbox/recent/starred"
198                 self._XML_ALL_URL = SECURE_URL_BASE + "inbox/recent/all"
199                 self._XML_SPAM_URL = SECURE_URL_BASE + "inbox/recent/spam"
200                 self._XML_TRASH_URL = SECURE_URL_BASE + "inbox/recent/trash"
201                 self._XML_VOICEMAIL_URL = SECURE_URL_BASE + "inbox/recent/voicemail/"
202                 self._XML_SMS_URL = SECURE_URL_BASE + "inbox/recent/sms/"
203                 self._XML_RECORDED_URL = SECURE_URL_BASE + "inbox/recent/recorded/"
204                 self._XML_PLACED_URL = SECURE_URL_BASE + "inbox/recent/placed/"
205                 self._XML_RECEIVED_URL = SECURE_URL_BASE + "inbox/recent/received/"
206                 self._XML_MISSED_URL = SECURE_URL_BASE + "inbox/recent/missed/"
207
208                 self._galxRe = re.compile(r"""<input.*?name="GALX".*?value="(.*?)".*?/>""", re.MULTILINE | re.DOTALL)
209                 self._tokenRe = re.compile(r"""<input.*?name="_rnr_se".*?value="(.*?)"\s*/>""")
210                 self._accountNumRe = re.compile(r"""<b class="ms\d">(.{14})</b></div>""")
211                 self._callbackRe = re.compile(r"""\s+(.*?):\s*(.*?)<br\s*/>\s*$""", re.M)
212
213                 self._contactsBodyRe = re.compile(r"""gcData\s*=\s*({.*?});""", re.MULTILINE | re.DOTALL)
214                 self._seperateVoicemailsRegex = re.compile(r"""^\s*<div id="(\w+)"\s* class=".*?gc-message.*?">""", re.MULTILINE | re.DOTALL)
215                 self._exactVoicemailTimeRegex = re.compile(r"""<span class="gc-message-time">(.*?)</span>""", re.MULTILINE)
216                 self._relativeVoicemailTimeRegex = re.compile(r"""<span class="gc-message-relative">(.*?)</span>""", re.MULTILINE)
217                 self._voicemailNameRegex = re.compile(r"""<a class=.*?gc-message-name-link.*?>(.*?)</a>""", re.MULTILINE | re.DOTALL)
218                 self._voicemailNumberRegex = re.compile(r"""<input type="hidden" class="gc-text gc-quickcall-ac" value="(.*?)"/>""", re.MULTILINE)
219                 self._prettyVoicemailNumberRegex = re.compile(r"""<span class="gc-message-type">(.*?)</span>""", re.MULTILINE)
220                 self._voicemailLocationRegex = re.compile(r"""<span class="gc-message-location">.*?<a.*?>(.*?)</a></span>""", re.MULTILINE)
221                 self._messagesContactIDRegex = re.compile(r"""<a class=".*?gc-message-name-link.*?">.*?</a>\s*?<span .*?>(.*?)</span>""", re.MULTILINE)
222                 self._voicemailMessageRegex = re.compile(r"""(<span id="\d+-\d+" class="gc-word-(.*?)">(.*?)</span>|<a .*? class="gc-message-mni">(.*?)</a>)""", re.MULTILINE)
223                 self._smsFromRegex = re.compile(r"""<span class="gc-message-sms-from">(.*?)</span>""", re.MULTILINE | re.DOTALL)
224                 self._smsTimeRegex = re.compile(r"""<span class="gc-message-sms-time">(.*?)</span>""", re.MULTILINE | re.DOTALL)
225                 self._smsTextRegex = re.compile(r"""<span class="gc-message-sms-text">(.*?)</span>""", re.MULTILINE | re.DOTALL)
226
227         def is_quick_login_possible(self):
228                 """
229                 @returns True then is_authed might be enough to login, else full login is required
230                 """
231                 return self._loadedFromCookies or 0.0 < self._lastAuthed
232
233         def is_authed(self, force = False):
234                 """
235                 Attempts to detect a current session
236                 @note Once logged in try not to reauth more than once a minute.
237                 @returns If authenticated
238                 """
239                 isRecentledAuthed = (time.time() - self._lastAuthed) < 120
240                 isPreviouslyAuthed = self._token is not None
241                 if isRecentledAuthed and isPreviouslyAuthed and not force:
242                         return True
243
244                 try:
245                         page = self._get_page(self._forwardURL)
246                         self._grab_account_info(page)
247                 except Exception, e:
248                         _moduleLogger.exception(str(e))
249                         return False
250
251                 self._browser.save_cookies()
252                 self._lastAuthed = time.time()
253                 return True
254
255         def _get_token(self):
256                 tokenPage = self._get_page(self._tokenURL)
257
258                 galxTokens = self._galxRe.search(tokenPage)
259                 if galxTokens is not None:
260                         galxToken = galxTokens.group(1)
261                 else:
262                         galxToken = ""
263                         _moduleLogger.debug("Could not grab GALX token")
264                 return galxToken
265
266         def _login(self, username, password, token):
267                 loginData = {
268                         'Email' : username,
269                         'Passwd' : password,
270                         'service': "grandcentral",
271                         "ltmpl": "mobile",
272                         "btmpl": "mobile",
273                         "PersistentCookie": "yes",
274                         "GALX": token,
275                         "continue": self._forwardURL,
276                 }
277
278                 loginSuccessOrFailurePage = self._get_page(self._loginURL, loginData)
279                 return loginSuccessOrFailurePage
280
281         def login(self, username, password):
282                 """
283                 Attempt to login to GoogleVoice
284                 @returns Whether login was successful or not
285                 """
286                 self.logout()
287                 galxToken = self._get_token()
288                 loginSuccessOrFailurePage = self._login(username, password, galxToken)
289
290                 try:
291                         self._grab_account_info(loginSuccessOrFailurePage)
292                 except Exception, e:
293                         # Retry in case the redirect failed
294                         # luckily is_authed does everything we need for a retry
295                         loggedIn = self.is_authed(True)
296                         if not loggedIn:
297                                 _moduleLogger.exception(str(e))
298                                 return False
299                         _moduleLogger.info("Redirection failed on initial login attempt, auto-corrected for this")
300
301                 self._browser.save_cookies()
302                 self._lastAuthed = time.time()
303                 return True
304
305         def logout(self):
306                 self._browser.clear_cookies()
307                 self._browser.save_cookies()
308                 self._token = None
309                 self._lastAuthed = 0.0
310
311         def is_dnd(self):
312                 isDndPage = self._get_page(self._isDndURL)
313
314                 dndGroup = self._isDndRe.search(isDndPage)
315                 if dndGroup is None:
316                         return False
317                 dndStatus = dndGroup.group(1)
318                 isDnd = True if dndStatus.strip().lower() == "true" else False
319                 return isDnd
320
321         def set_dnd(self, doNotDisturb):
322                 dndPostData = {
323                         "doNotDisturb": 1 if doNotDisturb else 0,
324                 }
325
326                 dndPage = self._get_page_with_token(self._setDndURL, dndPostData)
327
328         def call(self, outgoingNumber):
329                 """
330                 This is the main function responsible for initating the callback
331                 """
332                 outgoingNumber = self._send_validation(outgoingNumber)
333                 subscriberNumber = None
334                 phoneType = guess_phone_type(self._callbackNumber) # @todo Fix this hack
335
336                 callData = {
337                                 'outgoingNumber': outgoingNumber,
338                                 'forwardingNumber': self._callbackNumber,
339                                 'subscriberNumber': subscriberNumber or 'undefined',
340                                 'phoneType': str(phoneType),
341                                 'remember': '1',
342                 }
343                 _moduleLogger.info("%r" % callData)
344
345                 page = self._get_page_with_token(
346                         self._callUrl,
347                         callData,
348                 )
349                 self._parse_with_validation(page)
350                 return True
351
352         def cancel(self, outgoingNumber=None):
353                 """
354                 Cancels a call matching outgoing and forwarding numbers (if given). 
355                 Will raise an error if no matching call is being placed
356                 """
357                 page = self._get_page_with_token(
358                         self._callCancelURL,
359                         {
360                         'outgoingNumber': outgoingNumber or 'undefined',
361                         'forwardingNumber': self._callbackNumber or 'undefined',
362                         'cancelType': 'C2C',
363                         },
364                 )
365                 self._parse_with_validation(page)
366
367         def send_sms(self, phoneNumber, message):
368                 phoneNumber = self._send_validation(phoneNumber)
369                 page = self._get_page_with_token(
370                         self._sendSmsURL,
371                         {
372                                 'phoneNumber': phoneNumber,
373                                 'text': message
374                         },
375                 )
376                 self._parse_with_validation(page)
377
378         def search(self, query):
379                 """
380                 Search your Google Voice Account history for calls, voicemails, and sms
381                 Returns ``Folder`` instance containting matching messages
382                 """
383                 page = self._get_page(
384                         self._XML_SEARCH_URL,
385                         {"q": query},
386                 )
387                 json, html = extract_payload(page)
388                 return json
389
390         def get_feed(self, feed):
391                 actualFeed = "_XML_%s_URL" % feed.upper()
392                 feedUrl = getattr(self, actualFeed)
393
394                 page = self._get_page(feedUrl)
395                 json, html = extract_payload(page)
396
397                 return json
398
399         def download(self, messageId, adir):
400                 """
401                 Download a voicemail or recorded call MP3 matching the given ``msg``
402                 which can either be a ``Message`` instance, or a SHA1 identifier. 
403                 Saves files to ``adir`` (defaults to current directory). 
404                 Message hashes can be found in ``self.voicemail().messages`` for example. 
405                 Returns location of saved file.
406                 """
407                 page = self._get_page(self._downloadVoicemailURL, {"id": messageId})
408                 fn = os.path.join(adir, '%s.mp3' % messageId)
409                 with open(fn, 'wb') as fo:
410                         fo.write(page)
411                 return fn
412
413         def is_valid_syntax(self, number):
414                 """
415                 @returns If This number be called ( syntax validation only )
416                 """
417                 return self._validateRe.match(number) is not None
418
419         def get_account_number(self):
420                 """
421                 @returns The GoogleVoice phone number
422                 """
423                 return self._accountNum
424
425         def get_callback_numbers(self):
426                 """
427                 @returns a dictionary mapping call back numbers to descriptions
428                 @note These results are cached for 30 minutes.
429                 """
430                 if not self.is_authed():
431                         return {}
432                 return self._callbackNumbers
433
434         def set_callback_number(self, callbacknumber):
435                 """
436                 Set the number that GoogleVoice calls
437                 @param callbacknumber should be a proper 10 digit number
438                 """
439                 self._callbackNumber = callbacknumber
440                 _moduleLogger.info("Callback number changed: %r" % self._callbackNumber)
441                 return True
442
443         def get_callback_number(self):
444                 """
445                 @returns Current callback number or None
446                 """
447                 return self._callbackNumber
448
449         def get_recent(self):
450                 """
451                 @returns Iterable of (personsName, phoneNumber, exact date, relative date, action)
452                 """
453                 for action, url in (
454                         ("Received", self._XML_RECEIVED_URL),
455                         ("Missed", self._XML_MISSED_URL),
456                         ("Placed", self._XML_PLACED_URL),
457                 ):
458                         flatXml = self._get_page(url)
459
460                         allRecentHtml = self._grab_html(flatXml)
461                         allRecentData = self._parse_history(allRecentHtml)
462                         for recentCallData in allRecentData:
463                                 recentCallData["action"] = action
464                                 yield recentCallData
465
466         def get_contacts(self):
467                 """
468                 @returns Iterable of (contact id, contact name)
469                 """
470                 page = self._get_page(self._XML_CONTACTS_URL)
471                 contactsBody = self._contactsBodyRe.search(page)
472                 if contactsBody is None:
473                         raise RuntimeError("Could not extract contact information")
474                 accountData = _fake_parse_json(contactsBody.group(1))
475                 for contactId, contactDetails in accountData["contacts"].iteritems():
476                         # A zero contact id is the catch all for unknown contacts
477                         if contactId != "0":
478                                 yield contactId, contactDetails
479
480         def get_voicemails(self):
481                 voicemailPage = self._get_page(self._XML_VOICEMAIL_URL)
482                 voicemailHtml = self._grab_html(voicemailPage)
483                 voicemailJson = self._grab_json(voicemailPage)
484                 parsedVoicemail = self._parse_voicemail(voicemailHtml)
485                 voicemails = self._merge_conversation_sources(parsedVoicemail, voicemailJson)
486                 return voicemails
487
488         def get_texts(self):
489                 smsPage = self._get_page(self._XML_SMS_URL)
490                 smsHtml = self._grab_html(smsPage)
491                 smsJson = self._grab_json(smsPage)
492                 parsedSms = self._parse_sms(smsHtml)
493                 smss = self._merge_conversation_sources(parsedSms, smsJson)
494                 return smss
495
496         def mark_message(self, messageId, asRead):
497                 postData = {
498                         "read": 1 if asRead else 0,
499                         "id": messageId,
500                 }
501
502                 markPage = self._get_page(self._markAsReadURL, postData)
503
504         def archive_message(self, messageId):
505                 postData = {
506                         "id": messageId,
507                 }
508
509                 markPage = self._get_page(self._archiveMessageURL, postData)
510
511         def _grab_json(self, flatXml):
512                 xmlTree = ElementTree.fromstring(flatXml)
513                 jsonElement = xmlTree.getchildren()[0]
514                 flatJson = jsonElement.text
515                 jsonTree = parse_json(flatJson)
516                 return jsonTree
517
518         def _grab_html(self, flatXml):
519                 xmlTree = ElementTree.fromstring(flatXml)
520                 htmlElement = xmlTree.getchildren()[1]
521                 flatHtml = htmlElement.text
522                 return flatHtml
523
524         def _grab_account_info(self, page):
525                 tokenGroup = self._tokenRe.search(page)
526                 if tokenGroup is None:
527                         raise RuntimeError("Could not extract authentication token from GoogleVoice")
528                 self._token = tokenGroup.group(1)
529
530                 anGroup = self._accountNumRe.search(page)
531                 if anGroup is not None:
532                         self._accountNum = anGroup.group(1)
533                 else:
534                         _moduleLogger.debug("Could not extract account number from GoogleVoice")
535
536                 self._callbackNumbers = {}
537                 for match in self._callbackRe.finditer(page):
538                         callbackNumber = match.group(2)
539                         callbackName = match.group(1)
540                         self._callbackNumbers[callbackNumber] = callbackName
541                 if len(self._callbackNumbers) == 0:
542                         _moduleLogger.debug("Could not extract callback numbers from GoogleVoice (the troublesome page follows):\n%s" % page)
543
544         def _send_validation(self, number):
545                 if not self.is_valid_syntax(number):
546                         raise ValueError('Number is not valid: "%s"' % number)
547                 elif not self.is_authed():
548                         raise RuntimeError("Not Authenticated")
549                 return number
550
551         def _parse_history(self, historyHtml):
552                 splitVoicemail = self._seperateVoicemailsRegex.split(historyHtml)
553                 for messageId, messageHtml in itergroup(splitVoicemail[1:], 2):
554                         exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml)
555                         exactTime = exactTimeGroup.group(1).strip() if exactTimeGroup else ""
556                         exactTime = datetime.datetime.strptime(exactTime, "%m/%d/%y %I:%M %p")
557                         relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml)
558                         relativeTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else ""
559                         locationGroup = self._voicemailLocationRegex.search(messageHtml)
560                         location = locationGroup.group(1).strip() if locationGroup else ""
561
562                         nameGroup = self._voicemailNameRegex.search(messageHtml)
563                         name = nameGroup.group(1).strip() if nameGroup else ""
564                         numberGroup = self._voicemailNumberRegex.search(messageHtml)
565                         number = numberGroup.group(1).strip() if numberGroup else ""
566                         prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml)
567                         prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else ""
568                         contactIdGroup = self._messagesContactIDRegex.search(messageHtml)
569                         contactId = contactIdGroup.group(1).strip() if contactIdGroup else ""
570
571                         yield {
572                                 "id": messageId.strip(),
573                                 "contactId": contactId,
574                                 "name": name,
575                                 "time": exactTime,
576                                 "relTime": relativeTime,
577                                 "prettyNumber": prettyNumber,
578                                 "number": number,
579                                 "location": location,
580                         }
581
582         @staticmethod
583         def _interpret_voicemail_regex(group):
584                 quality, content, number = group.group(2), group.group(3), group.group(4)
585                 text = MessageText()
586                 if quality is not None and content is not None:
587                         text.accuracy = quality
588                         text.text = content
589                         return text
590                 elif number is not None:
591                         text.accuracy = MessageText.ACCURACY_HIGH
592                         text.text = number
593                         return text
594
595         def _parse_voicemail(self, voicemailHtml):
596                 splitVoicemail = self._seperateVoicemailsRegex.split(voicemailHtml)
597                 for messageId, messageHtml in itergroup(splitVoicemail[1:], 2):
598                         conv = Conversation()
599                         conv.type = Conversation.TYPE_VOICEMAIL
600                         conv.id = messageId.strip()
601
602                         exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml)
603                         exactTimeText = exactTimeGroup.group(1).strip() if exactTimeGroup else ""
604                         conv.time = datetime.datetime.strptime(exactTimeText, "%m/%d/%y %I:%M %p")
605                         relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml)
606                         conv.relTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else ""
607                         locationGroup = self._voicemailLocationRegex.search(messageHtml)
608                         conv.location = locationGroup.group(1).strip() if locationGroup else ""
609
610                         nameGroup = self._voicemailNameRegex.search(messageHtml)
611                         conv.name = nameGroup.group(1).strip() if nameGroup else ""
612                         numberGroup = self._voicemailNumberRegex.search(messageHtml)
613                         conv.number = numberGroup.group(1).strip() if numberGroup else ""
614                         prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml)
615                         conv.prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else ""
616                         contactIdGroup = self._messagesContactIDRegex.search(messageHtml)
617                         conv.contactId = contactIdGroup.group(1).strip() if contactIdGroup else ""
618
619                         messageGroups = self._voicemailMessageRegex.finditer(messageHtml)
620                         messageParts = [
621                                 self._interpret_voicemail_regex(group)
622                                 for group in messageGroups
623                         ] if messageGroups else ((MessageText.ACCURACY_LOW, "No Transcription"), )
624                         message = Message()
625                         message.body = messageParts
626                         message.whoFrom = conv.name
627                         message.when = conv.time.strftime("%I:%M %p")
628                         conv.messages = (message, )
629
630                         yield conv
631
632         @staticmethod
633         def _interpret_sms_message_parts(fromPart, textPart, timePart):
634                 text = MessageText()
635                 text.accuracy = MessageText.ACCURACY_MEDIUM
636                 text.text = textPart
637
638                 message = Message()
639                 message.body = (text, )
640                 message.whoFrom = fromPart
641                 message.when = timePart
642
643                 return message
644
645         def _parse_sms(self, smsHtml):
646                 splitSms = self._seperateVoicemailsRegex.split(smsHtml)
647                 for messageId, messageHtml in itergroup(splitSms[1:], 2):
648                         conv = Conversation()
649                         conv.type = Conversation.TYPE_SMS
650                         conv.id = messageId.strip()
651
652                         exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml)
653                         exactTimeText = exactTimeGroup.group(1).strip() if exactTimeGroup else ""
654                         conv.time = datetime.datetime.strptime(exactTimeText, "%m/%d/%y %I:%M %p")
655                         relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml)
656                         conv.relTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else ""
657                         conv.location = ""
658
659                         nameGroup = self._voicemailNameRegex.search(messageHtml)
660                         conv.name = nameGroup.group(1).strip() if nameGroup else ""
661                         numberGroup = self._voicemailNumberRegex.search(messageHtml)
662                         conv.number = numberGroup.group(1).strip() if numberGroup else ""
663                         prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml)
664                         conv.prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else ""
665                         contactIdGroup = self._messagesContactIDRegex.search(messageHtml)
666                         conv.contactId = contactIdGroup.group(1).strip() if contactIdGroup else ""
667
668                         fromGroups = self._smsFromRegex.finditer(messageHtml)
669                         fromParts = (group.group(1).strip() for group in fromGroups)
670                         textGroups = self._smsTextRegex.finditer(messageHtml)
671                         textParts = (group.group(1).strip() for group in textGroups)
672                         timeGroups = self._smsTimeRegex.finditer(messageHtml)
673                         timeParts = (group.group(1).strip() for group in timeGroups)
674
675                         messageParts = itertools.izip(fromParts, textParts, timeParts)
676                         messages = [self._interpret_sms_message_parts(*parts) for parts in messageParts]
677                         conv.messages = messages
678
679                         yield conv
680
681         @staticmethod
682         def _merge_conversation_sources(parsedMessages, json):
683                 for message in parsedMessages:
684                         jsonItem = json["messages"][message.id]
685                         message.isRead = jsonItem["isRead"]
686                         message.isSpam = jsonItem["isSpam"]
687                         message.isTrash = jsonItem["isTrash"]
688                         message.isArchived = "inbox" not in jsonItem["labels"]
689                         yield message
690
691         def _get_page(self, url, data = None, refererUrl = None):
692                 headers = {}
693                 if refererUrl is not None:
694                         headers["Referer"] = refererUrl
695
696                 encodedData = urllib.urlencode(data) if data is not None else None
697
698                 try:
699                         page = self._browser.download(url, encodedData, None, headers)
700                 except urllib2.URLError, e:
701                         _moduleLogger.error("Translating error: %s" % str(e))
702                         raise NetworkError("%s is not accesible" % url)
703
704                 return page
705
706         def _get_page_with_token(self, url, data = None, refererUrl = None):
707                 if data is None:
708                         data = {}
709                 data['_rnr_se'] = self._token
710
711                 page = self._get_page(url, data, refererUrl)
712
713                 return page
714
715         def _parse_with_validation(self, page):
716                 json = parse_json(page)
717                 validate_response(json)
718                 return json
719
720
721 def itergroup(iterator, count, padValue = None):
722         """
723         Iterate in groups of 'count' values. If there
724         aren't enough values, the last result is padded with
725         None.
726
727         >>> for val in itergroup([1, 2, 3, 4, 5, 6], 3):
728         ...     print tuple(val)
729         (1, 2, 3)
730         (4, 5, 6)
731         >>> for val in itergroup([1, 2, 3, 4, 5, 6], 3):
732         ...     print list(val)
733         [1, 2, 3]
734         [4, 5, 6]
735         >>> for val in itergroup([1, 2, 3, 4, 5, 6, 7], 3):
736         ...     print tuple(val)
737         (1, 2, 3)
738         (4, 5, 6)
739         (7, None, None)
740         >>> for val in itergroup("123456", 3):
741         ...     print tuple(val)
742         ('1', '2', '3')
743         ('4', '5', '6')
744         >>> for val in itergroup("123456", 3):
745         ...     print repr("".join(val))
746         '123'
747         '456'
748         """
749         paddedIterator = itertools.chain(iterator, itertools.repeat(padValue, count-1))
750         nIterators = (paddedIterator, ) * count
751         return itertools.izip(*nIterators)
752
753
754 def safe_eval(s):
755         _TRUE_REGEX = re.compile("true")
756         _FALSE_REGEX = re.compile("false")
757         s = _TRUE_REGEX.sub("True", s)
758         s = _FALSE_REGEX.sub("False", s)
759         return eval(s, {}, {})
760
761
762 def _fake_parse_json(flattened):
763         return safe_eval(flattened)
764
765
766 def _actual_parse_json(flattened):
767         return simplejson.loads(flattened)
768
769
770 if simplejson is None:
771         parse_json = _fake_parse_json
772 else:
773         parse_json = _actual_parse_json
774
775
776 def extract_payload(flatXml):
777         xmlTree = ElementTree.fromstring(flatXml)
778
779         jsonElement = xmlTree.getchildren()[0]
780         flatJson = jsonElement.text
781         jsonTree = parse_json(flatJson)
782
783         htmlElement = xmlTree.getchildren()[1]
784         flatHtml = htmlElement.text
785
786         return jsonTree, flatHtml
787
788
789 def validate_response(response):
790         """
791         Validates that the JSON response is A-OK
792         """
793         try:
794                 assert 'ok' in response and response['ok']
795         except AssertionError:
796                 raise RuntimeError('There was a problem with GV: %s' % response)
797
798
799 def guess_phone_type(number):
800         if number.startswith("747") or number.startswith("1747") or number.startswith("+1747"):
801                 return GVoiceBackend.PHONE_TYPE_GIZMO
802         else:
803                 return GVoiceBackend.PHONE_TYPE_MOBILE
804
805
806 def get_sane_callback(backend):
807         """
808         Try to set a sane default callback number on these preferences
809         1) 1747 numbers ( Gizmo )
810         2) anything with gizmo in the name
811         3) anything with computer in the name
812         4) the first value
813         """
814         numbers = backend.get_callback_numbers()
815
816         priorityOrderedCriteria = [
817                 ("\+1747", None),
818                 ("1747", None),
819                 ("747", None),
820                 (None, "gizmo"),
821                 (None, "computer"),
822                 (None, "sip"),
823                 (None, None),
824         ]
825
826         for numberCriteria, descriptionCriteria in priorityOrderedCriteria:
827                 numberMatcher = None
828                 descriptionMatcher = None
829                 if numberCriteria is not None:
830                         numberMatcher = re.compile(numberCriteria)
831                 elif descriptionCriteria is not None:
832                         descriptionMatcher = re.compile(descriptionCriteria, re.I)
833
834                 for number, description in numbers.iteritems():
835                         if numberMatcher is not None and numberMatcher.match(number) is None:
836                                 continue
837                         if descriptionMatcher is not None and descriptionMatcher.match(description) is None:
838                                 continue
839                         return number
840
841
842 def set_sane_callback(backend):
843         """
844         Try to set a sane default callback number on these preferences
845         1) 1747 numbers ( Gizmo )
846         2) anything with gizmo in the name
847         3) anything with computer in the name
848         4) the first value
849         """
850         number = get_sane_callback(backend)
851         backend.set_callback_number(number)
852
853
854 def _is_not_special(name):
855         return not name.startswith("_") and name[0].lower() == name[0] and "_" not in name
856
857
858 def to_dict(obj):
859         members = inspect.getmembers(obj)
860         return dict((name, value) for (name, value) in members if _is_not_special(name))
861
862
863 def grab_debug_info(username, password):
864         cookieFile = os.path.join(".", "raw_cookies.txt")
865         try:
866                 os.remove(cookieFile)
867         except OSError:
868                 pass
869
870         backend = GVoiceBackend(cookieFile)
871         browser = backend._browser
872
873         _TEST_WEBPAGES = [
874                 ("forward", backend._forwardURL),
875                 ("token", backend._tokenURL),
876                 ("login", backend._loginURL),
877                 ("isdnd", backend._isDndURL),
878                 ("account", backend._XML_ACCOUNT_URL),
879                 ("contacts", backend._XML_CONTACTS_URL),
880
881                 ("voicemail", backend._XML_VOICEMAIL_URL),
882                 ("sms", backend._XML_SMS_URL),
883
884                 ("recent", backend._XML_RECENT_URL),
885                 ("placed", backend._XML_PLACED_URL),
886                 ("recieved", backend._XML_RECEIVED_URL),
887                 ("missed", backend._XML_MISSED_URL),
888         ]
889
890         # Get Pages
891         print "Grabbing pre-login pages"
892         for name, url in _TEST_WEBPAGES:
893                 try:
894                         page = browser.download(url)
895                 except StandardError, e:
896                         print e.message
897                         continue
898                 print "\tWriting to file"
899                 with open("not_loggedin_%s.txt" % name, "w") as f:
900                         f.write(page)
901
902         # Login
903         print "Attempting login"
904         galxToken = backend._get_token()
905         loginSuccessOrFailurePage = backend._login(username, password, galxToken)
906         with open("loggingin.txt", "w") as f:
907                 print "\tWriting to file"
908                 f.write(loginSuccessOrFailurePage)
909         try:
910                 backend._grab_account_info(loginSuccessOrFailurePage)
911         except Exception:
912                 # Retry in case the redirect failed
913                 # luckily is_authed does everything we need for a retry
914                 loggedIn = backend.is_authed(True)
915                 if not loggedIn:
916                         raise
917
918         # Get Pages
919         print "Grabbing post-login pages"
920         for name, url in _TEST_WEBPAGES:
921                 try:
922                         page = browser.download(url)
923                 except StandardError, e:
924                         print str(e)
925                         continue
926                 print "\tWriting to file"
927                 with open("loggedin_%s.txt" % name, "w") as f:
928                         f.write(page)
929
930         # Cookies
931         browser.save_cookies()
932         print "\tWriting cookies to file"
933         with open("cookies.txt", "w") as f:
934                 f.writelines(
935                         "%s: %s\n" % (c.name, c.value)
936                         for c in browser._cookies
937                 )
938
939
940 def main():
941         import sys
942         logging.basicConfig(level=logging.DEBUG)
943         args = sys.argv
944         if 3 <= len(args):
945                 username = args[1]
946                 password = args[2]
947
948         grab_debug_info(username, password)
949
950
951 if __name__ == "__main__":
952         main()