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