4 DialCentral - Front end for Google's Grand Central service.
5 Copyright (C) 2008 Eric Warnke ericew AT gmail DOT com
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.
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.
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
21 Google Voice backend code
24 http://thatsmith.com/2009/03/google-voice-addon-for-firefox/
25 http://posttopic.com/topic/google-voice-add-on-development
38 from xml.sax import saxutils
40 from xml.etree import ElementTree
50 _TRUE_REGEX = re.compile("true")
51 _FALSE_REGEX = re.compile("false")
55 s = _TRUE_REGEX.sub("True", s)
56 s = _FALSE_REGEX.sub("False", s)
57 return eval(s, {}, {})
60 if simplejson is None:
61 def parse_json(flattened):
62 return safe_eval(flattened)
64 def parse_json(flattened):
65 return simplejson.loads(flattened)
68 def itergroup(iterator, count, padValue = None):
70 Iterate in groups of 'count' values. If there
71 aren't enough values, the last result is padded with
74 >>> for val in itergroup([1, 2, 3, 4, 5, 6], 3):
78 >>> for val in itergroup([1, 2, 3, 4, 5, 6], 3):
82 >>> for val in itergroup([1, 2, 3, 4, 5, 6, 7], 3):
87 >>> for val in itergroup("123456", 3):
91 >>> for val in itergroup("123456", 3):
92 ... print repr("".join(val))
96 paddedIterator = itertools.chain(iterator, itertools.repeat(padValue, count-1))
97 nIterators = (paddedIterator, ) * count
98 return itertools.izip(*nIterators)
101 def abbrev_relative_date(date):
103 >>> abbrev_relative_date("42 hours ago")
105 >>> abbrev_relative_date("2 days ago")
107 >>> abbrev_relative_date("4 weeks ago")
110 parts = date.split(" ")
111 return "%s %s" % (parts[0], parts[1][0])
114 class GVDialer(object):
116 This class encapsulates all of the knowledge necessary to interace with the grandcentral servers
117 the functions include login, setting up a callback number, and initalting a callback
120 def __init__(self, cookieFile = None):
121 # Important items in this function are the setup of the browser emulation and cookie file
122 self._browser = browser_emu.MozillaEmulator(1)
123 if cookieFile is None:
124 cookieFile = os.path.join(os.path.expanduser("~"), ".gv_cookies.txt")
125 self._browser.cookies.filename = cookieFile
126 if os.path.isfile(cookieFile):
127 self._browser.cookies.load()
130 self._accountNum = ""
131 self._lastAuthed = 0.0
132 self._callbackNumber = ""
133 self._callbackNumbers = {}
135 self.__contacts = None
137 def is_authed(self, force = False):
139 Attempts to detect a current session
140 @note Once logged in try not to reauth more than once a minute.
141 @returns If authenticated
144 if (time.time() - self._lastAuthed) < 60 and not force:
148 self._grab_account_info()
149 except StandardError, e:
150 warnings.warn(traceback.format_exc())
153 self._browser.cookies.save()
154 self._lastAuthed = time.time()
157 _loginURL = "https://www.google.com/accounts/ServiceLoginAuth"
159 def login(self, username, password):
161 Attempt to login to grandcentral
162 @returns Whether login was successful or not
167 loginPostData = urllib.urlencode({
170 'service': "grandcentral",
173 "PersistentCookie": "yes",
177 loginSuccessOrFailurePage = self._browser.download(self._loginURL, loginPostData)
178 except urllib2.URLError, e:
179 warnings.warn(traceback.format_exc())
180 raise RuntimeError("%s is not accesible" % self._loginURL)
182 return self.is_authed()
185 self._lastAuthed = 0.0
186 self._browser.cookies.clear()
187 self._browser.cookies.save()
191 _gvDialingStrRe = re.compile("This may take a few seconds", re.M)
192 _clicktocallURL = "https://www.google.com/voice/m/sendcall"
194 def dial(self, number):
196 This is the main function responsible for initating the callback
198 number = self._send_validation(number)
200 clickToCallData = urllib.urlencode({
202 "phone": self._callbackNumber,
203 "_rnr_se": self._token,
206 'Referer' : 'https://google.com/voice/m/callsms',
208 callSuccessPage = self._browser.download(self._clicktocallURL, clickToCallData, None, otherData)
209 except urllib2.URLError, e:
210 warnings.warn(traceback.format_exc())
211 raise RuntimeError("%s is not accesible" % self._clicktocallURL)
213 if self._gvDialingStrRe.search(callSuccessPage) is None:
214 raise RuntimeError("Google Voice returned an error")
218 _sendSmsURL = "https://www.google.com/voice/m/sendsms"
220 def send_sms(self, number, message):
221 number = self._send_validation(number)
223 smsData = urllib.urlencode({
226 "_rnr_se": self._token,
231 'Referer' : 'https://google.com/voice/m/sms',
233 smsSuccessPage = self._browser.download(self._sendSmsURL, smsData, None, otherData)
234 except urllib2.URLError, e:
235 warnings.warn(traceback.format_exc())
236 raise RuntimeError("%s is not accesible" % self._sendSmsURL)
240 def clear_caches(self):
241 self.__contacts = None
243 _validateRe = re.compile("^[0-9]{10,}$")
245 def is_valid_syntax(self, number):
247 @returns If This number be called ( syntax validation only )
249 return self._validateRe.match(number) is not None
251 def get_account_number(self):
253 @returns The grand central phone number
255 return self._accountNum
257 def set_sane_callback(self):
259 Try to set a sane default callback number on these preferences
260 1) 1747 numbers ( Gizmo )
261 2) anything with gizmo in the name
262 3) anything with computer in the name
265 numbers = self.get_callback_numbers()
267 for number, description in numbers.iteritems():
268 if re.compile(r"""1747""").match(number) is not None:
269 self.set_callback_number(number)
272 for number, description in numbers.iteritems():
273 if re.compile(r"""gizmo""", re.I).search(description) is not None:
274 self.set_callback_number(number)
277 for number, description in numbers.iteritems():
278 if re.compile(r"""computer""", re.I).search(description) is not None:
279 self.set_callback_number(number)
282 for number, description in numbers.iteritems():
283 self.set_callback_number(number)
286 def get_callback_numbers(self):
288 @returns a dictionary mapping call back numbers to descriptions
289 @note These results are cached for 30 minutes.
291 if time.time() - self._lastAuthed < 1800 or self.is_authed():
292 return self._callbackNumbers
296 _setforwardURL = "https://www.google.com//voice/m/setphone"
298 def set_callback_number(self, callbacknumber):
300 Set the number that grandcental calls
301 @param callbacknumber should be a proper 10 digit number
303 self._callbackNumber = callbacknumber
304 callbackPostData = urllib.urlencode({
305 '_rnr_se': self._token,
306 'phone': callbacknumber
309 callbackSetPage = self._browser.download(self._setforwardURL, callbackPostData)
310 except urllib2.URLError, e:
311 warnings.warn(traceback.format_exc())
312 raise RuntimeError("%s is not accesible" % self._setforwardURL)
314 self._browser.cookies.save()
317 def get_callback_number(self):
319 @returns Current callback number or None
321 for c in self._browser.cookies:
322 if c.name == "gv-ph":
324 return self._callbackNumber
326 def get_recent(self):
328 @returns Iterable of (personsName, phoneNumber, date, action)
331 (exactDate, name, number, relativeDate, action)
332 for (name, number, exactDate, relativeDate, action) in self._get_recent()
334 sortedRecent.sort(reverse = True)
335 for exactDate, name, number, relativeDate, action in sortedRecent:
336 relativeDate = abbrev_relative_date(relativeDate)
337 yield name, number, relativeDate, action
339 def get_addressbooks(self):
341 @returns Iterable of (Address Book Factory, Book Id, Book Name)
345 def open_addressbook(self, bookId):
349 def contact_source_short_name(contactId):
354 return "Google Voice"
356 _contactsRe = re.compile(r"""<a href="/voice/m/contact/(\d+)">(.*?)</a>""", re.S)
357 _contactsNextRe = re.compile(r""".*<a href="/voice/m/contacts(\?p=\d+)">Next.*?</a>""", re.S)
358 _contactsURL = "https://www.google.com/voice/mobile/contacts"
360 def get_contacts(self):
362 @returns Iterable of (contact id, contact name)
364 if self.__contacts is None:
367 contactsPagesUrls = [self._contactsURL]
368 for contactsPageUrl in contactsPagesUrls:
370 contactsPage = self._browser.download(contactsPageUrl)
371 except urllib2.URLError, e:
372 warnings.warn(traceback.format_exc())
373 raise RuntimeError("%s is not accesible" % contactsPageUrl)
374 for contact_match in self._contactsRe.finditer(contactsPage):
375 contactId = contact_match.group(1)
376 contactName = saxutils.unescape(contact_match.group(2))
377 contact = contactId, contactName
378 self.__contacts.append(contact)
381 next_match = self._contactsNextRe.match(contactsPage)
382 if next_match is not None:
383 newContactsPageUrl = self._contactsURL + next_match.group(1)
384 contactsPagesUrls.append(newContactsPageUrl)
386 for contact in self.__contacts:
389 _contactDetailPhoneRe = re.compile(r"""<div.*?>([0-9\-\(\) \t]+?)<span.*?>\((\w+)\)</span>""", re.S)
390 _contactDetailURL = "https://www.google.com/voice/mobile/contact"
392 def get_contact_details(self, contactId):
394 @returns Iterable of (Phone Type, Phone Number)
397 detailPage = self._browser.download(self._contactDetailURL + '/' + contactId)
398 except urllib2.URLError, e:
399 warnings.warn(traceback.format_exc())
400 raise RuntimeError("%s is not accesible" % self._contactDetailURL)
402 for detail_match in self._contactDetailPhoneRe.finditer(detailPage):
403 phoneNumber = detail_match.group(1)
404 phoneType = saxutils.unescape(detail_match.group(2))
405 yield (phoneType, phoneNumber)
407 _voicemailURL = "https://www.google.com/voice/inbox/recent/voicemail/"
408 _smsURL = "https://www.google.com/voice/inbox/recent/sms/"
410 def get_messages(self):
412 voicemailPage = self._browser.download(self._voicemailURL)
413 except urllib2.URLError, e:
414 warnings.warn(traceback.format_exc())
415 raise RuntimeError("%s is not accesible" % self._voicemailURL)
416 voicemailHtml = self._grab_html(voicemailPage)
417 parsedVoicemail = self._parse_voicemail(voicemailHtml)
418 decoratedVoicemails = self._decorate_voicemail(parsedVoicemail)
421 smsPage = self._browser.download(self._smsURL)
422 except urllib2.URLError, e:
423 warnings.warn(traceback.format_exc())
424 raise RuntimeError("%s is not accesible" % self._smsURL)
425 smsHtml = self._grab_html(smsPage)
426 parsedSms = self._parse_sms(smsHtml)
427 decoratedSms = self._decorate_sms(parsedSms)
429 allMessages = itertools.chain(decoratedVoicemails, decoratedSms)
430 sortedMessages = list(allMessages)
431 sortedMessages.sort(reverse=True)
432 for exactDate, header, number, relativeDate, message in sortedMessages:
433 relativeDate = abbrev_relative_date(relativeDate)
434 yield header, number, relativeDate, message
436 def _grab_json(self, flatXml):
437 xmlTree = ElementTree.fromstring(flatXml)
438 jsonElement = xmlTree.getchildren()[0]
439 flatJson = jsonElement.text
440 jsonTree = parse_json(flatJson)
443 def _grab_html(self, flatXml):
444 xmlTree = ElementTree.fromstring(flatXml)
445 htmlElement = xmlTree.getchildren()[1]
446 flatHtml = htmlElement.text
449 _tokenRe = re.compile(r"""<input.*?name="_rnr_se".*?value="(.*?)"\s*/>""")
450 _accountNumRe = re.compile(r"""<b class="ms\d">(.{14})</b></div>""")
451 _callbackRe = re.compile(r"""\s+(.*?):\s*(.*?)<br\s*/>\s*$""", re.M)
452 _forwardURL = "https://www.google.com/voice/mobile/phones"
454 def _grab_account_info(self):
455 page = self._browser.download(self._forwardURL)
457 tokenGroup = self._tokenRe.search(page)
458 if tokenGroup is None:
459 raise RuntimeError("Could not extract authentication token from GoogleVoice")
460 self._token = tokenGroup.group(1)
462 anGroup = self._accountNumRe.search(page)
463 if anGroup is not None:
464 self._accountNum = anGroup.group(1)
466 warnings.warn("Could not extract account number from GoogleVoice", UserWarning, 2)
468 self._callbackNumbers = {}
469 for match in self._callbackRe.finditer(page):
470 callbackNumber = match.group(2)
471 callbackName = match.group(1)
472 self._callbackNumbers[callbackNumber] = callbackName
474 def _send_validation(self, number):
475 if not self.is_valid_syntax(number):
476 raise ValueError('Number is not valid: "%s"' % number)
477 elif not self.is_authed():
478 raise RuntimeError("Not Authenticated")
480 if len(number) == 11 and number[0] == 1:
481 # Strip leading 1 from 11 digit dialing
485 _recentCallsURL = "https://www.google.com/voice/inbox/recent/"
486 _placedCallsURL = "https://www.google.com/voice/inbox/recent/placed/"
487 _receivedCallsURL = "https://www.google.com/voice/inbox/recent/received/"
488 _missedCallsURL = "https://www.google.com/voice/inbox/recent/missed/"
490 def _get_recent(self):
492 @returns Iterable of (personsName, phoneNumber, exact date, relative date, action)
495 ("Recieved", self._receivedCallsURL),
496 ("Missed", self._missedCallsURL),
497 ("Placed", self._placedCallsURL),
500 flatXml = self._browser.download(url)
501 except urllib2.URLError, e:
502 warnings.warn(traceback.format_exc())
503 raise RuntimeError("%s is not accesible" % url)
505 allRecentHtml = self._grab_html(flatXml)
506 allRecentData = self._parse_voicemail(allRecentHtml)
507 for recentCallData in allRecentData:
508 exactTime = recentCallData["time"]
509 if recentCallData["name"]:
510 header = recentCallData["name"]
511 elif recentCallData["prettyNumber"]:
512 header = recentCallData["prettyNumber"]
513 elif recentCallData["location"]:
514 header = recentCallData["location"]
517 yield header, recentCallData["number"], exactTime, recentCallData["relTime"], action
519 _seperateVoicemailsRegex = re.compile(r"""^\s*<div id="(\w+)"\s* class="gc-message.*?">""", re.MULTILINE | re.DOTALL)
520 _exactVoicemailTimeRegex = re.compile(r"""<span class="gc-message-time">(.*?)</span>""", re.MULTILINE)
521 _relativeVoicemailTimeRegex = re.compile(r"""<span class="gc-message-relative">(.*?)</span>""", re.MULTILINE)
522 _voicemailNameRegex = re.compile(r"""<a class=.*?gc-message-name-link.*?>(.*?)</a>""", re.MULTILINE | re.DOTALL)
523 _voicemailNumberRegex = re.compile(r"""<input type="hidden" class="gc-text gc-quickcall-ac" value="(.*?)"/>""", re.MULTILINE)
524 _prettyVoicemailNumberRegex = re.compile(r"""<span class="gc-message-type">(.*?)</span>""", re.MULTILINE)
525 _voicemailLocationRegex = re.compile(r"""<span class="gc-message-location">.*?<a.*?>(.*?)</a></span>""", re.MULTILINE)
526 _voicemailMessageRegex = re.compile(r"""<span class="gc-word-(.*?)">(.*?)</span>""", re.MULTILINE)
528 def _parse_voicemail(self, voicemailHtml):
529 splitVoicemail = self._seperateVoicemailsRegex.split(voicemailHtml)
530 for messageId, messageHtml in itergroup(splitVoicemail[1:], 2):
531 exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml)
532 exactTime = exactTimeGroup.group(1).strip() if exactTimeGroup else ""
533 exactTime = datetime.datetime.strptime(exactTime, "%m/%d/%y %I:%M %p")
534 relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml)
535 relativeTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else ""
536 locationGroup = self._voicemailLocationRegex.search(messageHtml)
537 location = locationGroup.group(1).strip() if locationGroup else ""
539 nameGroup = self._voicemailNameRegex.search(messageHtml)
540 name = nameGroup.group(1).strip() if nameGroup else ""
541 numberGroup = self._voicemailNumberRegex.search(messageHtml)
542 number = numberGroup.group(1).strip() if numberGroup else ""
543 prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml)
544 prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else ""
546 messageGroups = self._voicemailMessageRegex.finditer(messageHtml)
548 (group.group(1).strip(), group.group(2).strip())
549 for group in messageGroups
550 ) if messageGroups else ()
553 "id": messageId.strip(),
556 "relTime": relativeTime,
557 "prettyNumber": prettyNumber,
559 "location": location,
560 "messageParts": messageParts,
563 def _decorate_voicemail(self, parsedVoicemail):
564 messagePartFormat = {
569 for voicemailData in parsedVoicemail:
570 exactTime = voicemailData["time"]
571 if voicemailData["name"]:
572 header = voicemailData["name"]
573 elif voicemailData["prettyNumber"]:
574 header = voicemailData["prettyNumber"]
575 elif voicemailData["location"]:
576 header = voicemailData["location"]
580 messagePartFormat[quality] % part
581 for (quality, part) in voicemailData["messageParts"]
584 message = "No Transcription"
585 yield exactTime, header, voicemailData["number"], voicemailData["relTime"], message
587 _smsFromRegex = re.compile(r"""<span class="gc-message-sms-from">(.*?)</span>""", re.MULTILINE | re.DOTALL)
588 _smsTextRegex = re.compile(r"""<span class="gc-message-sms-time">(.*?)</span>""", re.MULTILINE | re.DOTALL)
589 _smsTimeRegex = re.compile(r"""<span class="gc-message-sms-text">(.*?)</span>""", re.MULTILINE | re.DOTALL)
591 def _parse_sms(self, smsHtml):
592 splitSms = self._seperateVoicemailsRegex.split(smsHtml)
593 for messageId, messageHtml in itergroup(splitSms[1:], 2):
594 exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml)
595 exactTime = exactTimeGroup.group(1).strip() if exactTimeGroup else ""
596 exactTime = datetime.datetime.strptime(exactTime, "%m/%d/%y %I:%M %p")
597 relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml)
598 relativeTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else ""
600 nameGroup = self._voicemailNameRegex.search(messageHtml)
601 name = nameGroup.group(1).strip() if nameGroup else ""
602 numberGroup = self._voicemailNumberRegex.search(messageHtml)
603 number = numberGroup.group(1).strip() if numberGroup else ""
604 prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml)
605 prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else ""
607 fromGroups = self._smsFromRegex.finditer(messageHtml)
608 fromParts = (group.group(1).strip() for group in fromGroups)
609 textGroups = self._smsTextRegex.finditer(messageHtml)
610 textParts = (group.group(1).strip() for group in textGroups)
611 timeGroups = self._smsTimeRegex.finditer(messageHtml)
612 timeParts = (group.group(1).strip() for group in timeGroups)
614 messageParts = itertools.izip(fromParts, textParts, timeParts)
617 "id": messageId.strip(),
620 "relTime": relativeTime,
621 "prettyNumber": prettyNumber,
623 "messageParts": messageParts,
626 def _decorate_sms(self, parsedSms):
627 for messageData in parsedSms:
628 exactTime = messageData["time"]
629 if messageData["name"]:
630 header = messageData["name"]
631 elif messageData["prettyNumber"]:
632 header = messageData["prettyNumber"]
635 number = messageData["number"]
636 relativeTime = messageData["relTime"]
637 message = "\n".join((
638 "<b>%s</b>: %s" % (messagePart[0], messagePart[-1])
639 for messagePart in messageData["messageParts"]
642 message = "No Transcription"
643 yield exactTime, header, number, relativeTime, message
646 def test_backend(username, password):
649 print "Authenticated: ", backend.is_authed()
650 print "Login?: ", backend.login(username, password)
651 print "Authenticated: ", backend.is_authed()
652 # print "Token: ", backend._token
653 print "Account: ", backend.get_account_number()
654 print "Callback: ", backend.get_callback_number()
655 # print "All Callback: ",
656 # pprint.pprint(backend.get_callback_numbers())
658 # pprint.pprint(list(backend.get_recent()))
659 # print "Contacts: ",
660 # for contact in backend.get_contacts():
662 # pprint.pprint(list(backend.get_contact_details(contact[0])))