1 from __future__ import with_statement
15 import util.qt_compat as qt_compat
16 QtCore = qt_compat.QtCore
18 from util import qore_utils
19 from util import qui_utils
20 from util import concurrent
21 from util import misc as misc_utils
26 _moduleLogger = logging.getLogger(__name__)
29 class _DraftContact(object):
31 def __init__(self, messageId, title, description, numbersWithDescriptions):
32 self.messageId = messageId
34 self.description = description
35 self.numbers = numbersWithDescriptions
36 self.selectedNumber = numbersWithDescriptions[0][0]
39 return "<%s (%s)>" % (
42 "%r=%r" % (m, getattr(self, m))
43 for m in ("messageId", "title", "numbers", "selectedNumber")
48 class Draft(QtCore.QObject):
50 sendingMessage = qt_compat.Signal()
51 sentMessage = qt_compat.Signal()
52 calling = qt_compat.Signal()
53 called = qt_compat.Signal()
54 cancelling = qt_compat.Signal()
55 cancelled = qt_compat.Signal()
56 error = qt_compat.Signal(str)
58 recipientsChanged = qt_compat.Signal()
60 def __init__(self, asyncQueue, backend, errorLog):
61 QtCore.QObject.__init__(self)
62 self._errorLog = errorLog
64 self._asyncQueue = asyncQueue
65 self._backend = backend
66 self._busyReason = None
70 assert 0 < len(self._contacts), "No contacts selected"
71 assert 0 < len(self._message), "No message to send"
72 numbers = [misc_utils.make_ugly(contact.selectedNumber) for contact in self._contacts.itervalues()]
73 le = self._asyncQueue.add_async(self._send)
74 le.start(numbers, self._message)
77 assert len(self._contacts) == 1, "Must select 1 and only 1 contact"
78 assert len(self._message) == 0, "Cannot send message with call"
79 (contact, ) = self._contacts.itervalues()
80 number = misc_utils.make_ugly(contact.selectedNumber)
81 le = self._asyncQueue.add_async(self._call)
85 le = self._asyncQueue.add_async(self._cancel)
88 def _get_message(self):
91 def _set_message(self, message):
92 self._message = message
94 message = property(_get_message, _set_message)
96 def add_contact(self, contactId, messageId, title, description, numbersWithDescriptions):
97 if self._busyReason is not None:
98 raise RuntimeError("Please wait for %r" % self._busyReason)
99 # Allow overwriting of contacts so that the message can be updated and the SMS dialog popped back up
100 contactDetails = _DraftContact(messageId, title, description, numbersWithDescriptions)
101 self._contacts[contactId] = contactDetails
102 self.recipientsChanged.emit()
104 def remove_contact(self, contactId):
105 if self._busyReason is not None:
106 raise RuntimeError("Please wait for %r" % self._busyReason)
107 assert contactId in self._contacts, "Contact missing"
108 del self._contacts[contactId]
109 self.recipientsChanged.emit()
111 def get_contacts(self):
112 return self._contacts.iterkeys()
114 def get_num_contacts(self):
115 return len(self._contacts)
117 def get_message_id(self, cid):
118 return self._contacts[cid].messageId
120 def get_title(self, cid):
121 return self._contacts[cid].title
123 def get_description(self, cid):
124 return self._contacts[cid].description
126 def get_numbers(self, cid):
127 return self._contacts[cid].numbers
129 def get_selected_number(self, cid):
130 return self._contacts[cid].selectedNumber
132 def set_selected_number(self, cid, number):
133 # @note I'm lazy, this isn't firing any kind of signal since only one
134 # controller right now and that is the viewer
135 assert number in (nWD[0] for nWD in self._contacts[cid].numbers), "Number not selectable"
136 self._contacts[cid].selectedNumber = number
139 if self._busyReason is not None:
140 raise RuntimeError("Please wait for %r" % self._busyReason)
144 oldContacts = self._contacts
148 self.recipientsChanged.emit()
150 @contextlib.contextmanager
151 def _busy(self, message):
152 if self._busyReason is not None:
153 raise RuntimeError("Already busy doing %r" % self._busyReason)
155 self._busyReason = message
158 self._busyReason = None
160 def _send(self, numbers, text):
161 self.sendingMessage.emit()
163 with self._busy("Sending Text"):
164 with qui_utils.notify_busy(self._errorLog, "Sending Text"):
166 self._backend[0].send_sms,
170 self.sentMessage.emit()
173 _moduleLogger.exception("Reporting error to user")
174 self.error.emit(str(e))
176 def _call(self, number):
179 with self._busy("Calling"):
180 with qui_utils.notify_busy(self._errorLog, "Calling"):
182 self._backend[0].call,
189 _moduleLogger.exception("Reporting error to user")
190 self.error.emit(str(e))
193 self.cancelling.emit()
195 with qui_utils.notify_busy(self._errorLog, "Cancelling"):
197 self._backend[0].cancel,
201 self.cancelled.emit()
203 _moduleLogger.exception("Reporting error to user")
204 self.error.emit(str(e))
207 class Session(QtCore.QObject):
209 # @todo Somehow add support for csv contacts
210 # @BUG When loading without caches, downloads messages twice
212 stateChange = qt_compat.Signal(str)
213 loggedOut = qt_compat.Signal()
214 loggedIn = qt_compat.Signal()
215 callbackNumberChanged = qt_compat.Signal(str)
217 accountUpdated = qt_compat.Signal()
218 messagesUpdated = qt_compat.Signal()
219 newMessages = qt_compat.Signal()
220 historyUpdated = qt_compat.Signal()
221 dndStateChange = qt_compat.Signal(bool)
222 voicemailAvailable = qt_compat.Signal(str, str)
224 error = qt_compat.Signal(str)
226 LOGGEDOUT_STATE = "logged out"
227 LOGGINGIN_STATE = "logging in"
228 LOGGEDIN_STATE = "logged in"
230 MESSAGE_TEXTS = "Text"
231 MESSAGE_VOICEMAILS = "Voicemail"
234 HISTORY_RECEIVED = "Received"
235 HISTORY_MISSED = "Missed"
236 HISTORY_PLACED = "Placed"
239 _OLDEST_COMPATIBLE_FORMAT_VERSION = misc_utils.parse_version("1.3.0")
244 def __init__(self, errorLog, cachePath):
245 QtCore.QObject.__init__(self)
246 self._errorLog = errorLog
247 self._pool = qore_utils.FutureThread()
248 self._asyncQueue = concurrent.AsyncTaskQueue(self._pool)
250 self._loggedInTime = self._LOGGEDOUT_TIME
252 self._cachePath = cachePath
253 self._voicemailCachePath = None
254 self._username = None
255 self._password = None
256 self._draft = Draft(self._asyncQueue, self._backend, self._errorLog)
257 self._delayedRelogin = QtCore.QTimer()
258 self._delayedRelogin.setInterval(0)
259 self._delayedRelogin.setSingleShot(True)
260 self._delayedRelogin.timeout.connect(self._on_delayed_relogin)
263 self._accountUpdateTime = datetime.datetime(1971, 1, 1)
265 self._cleanMessages = []
266 self._messageUpdateTime = datetime.datetime(1971, 1, 1)
268 self._historyUpdateTime = datetime.datetime(1971, 1, 1)
275 self._LOGGEDOUT_TIME: self.LOGGEDOUT_STATE,
276 self._LOGGINGIN_TIME: self.LOGGINGIN_STATE,
277 }.get(self._loggedInTime, self.LOGGEDIN_STATE)
283 def login(self, username, password):
284 assert self.state == self.LOGGEDOUT_STATE, "Can only log-in when logged out (currently %s" % self.state
285 assert username != "", "No username specified"
286 if self._cachePath is not None:
287 cookiePath = os.path.join(self._cachePath, "%s.cookies" % username)
291 if self._username != username or not self._backend:
292 from backends import gv_backend
294 self._backend[0:0] = [gv_backend.GVDialer(cookiePath)]
297 le = self._asyncQueue.add_async(self._login)
298 le.start(username, password)
301 assert self.state != self.LOGGEDOUT_STATE, "Can only logout if logged in (currently %s" % self.state
302 _moduleLogger.info("Logging out")
304 self._loggedInTime = self._LOGGEDOUT_TIME
305 self._backend[0].persist()
306 self._save_to_cache()
307 self._clear_voicemail_cache()
308 self.stateChange.emit(self.LOGGEDOUT_STATE)
309 self.loggedOut.emit()
312 assert self.state == self.LOGGEDOUT_STATE, "Can only clear when logged out (currently %s" % self.state
313 self._backend[0].logout()
318 def logout_and_clear(self):
319 assert self.state != self.LOGGEDOUT_STATE, "Can only logout if logged in (currently %s" % self.state
320 _moduleLogger.info("Logging out and clearing the account")
322 self._loggedInTime = self._LOGGEDOUT_TIME
324 self.stateChange.emit(self.LOGGEDOUT_STATE)
325 self.loggedOut.emit()
327 def update_account(self, force = True):
328 if not force and self._contacts:
330 le = self._asyncQueue.add_async(self._update_account), (), {}
331 self._perform_op_while_loggedin(le)
333 def refresh_connection(self):
334 le = self._asyncQueue.add_async(self._refresh_authentication)
337 def get_contacts(self):
338 return self._contacts
340 def get_when_contacts_updated(self):
341 return self._accountUpdateTime
343 def update_messages(self, messageType, force = True):
344 if not force and self._messages:
346 le = self._asyncQueue.add_async(self._update_messages), (messageType, ), {}
347 self._perform_op_while_loggedin(le)
349 def get_messages(self):
350 return self._messages
352 def get_when_messages_updated(self):
353 return self._messageUpdateTime
355 def update_history(self, historyType, force = True):
356 if not force and self._history:
358 le = self._asyncQueue.add_async(self._update_history), (historyType, ), {}
359 self._perform_op_while_loggedin(le)
361 def get_history(self):
364 def get_when_history_updated(self):
365 return self._historyUpdateTime
367 def update_dnd(self):
368 le = self._asyncQueue.add_async(self._update_dnd), (), {}
369 self._perform_op_while_loggedin(le)
371 def set_dnd(self, dnd):
372 le = self._asyncQueue.add_async(self._set_dnd)
375 def is_available(self, messageId):
376 actualPath = os.path.join(self._voicemailCachePath, "%s.mp3" % messageId)
377 return os.path.exists(actualPath)
379 def voicemail_path(self, messageId):
380 actualPath = os.path.join(self._voicemailCachePath, "%s.mp3" % messageId)
381 if not os.path.exists(actualPath):
382 raise RuntimeError("Voicemail not available")
385 def download_voicemail(self, messageId):
386 le = self._asyncQueue.add_async(self._download_voicemail)
389 def _set_dnd(self, dnd):
392 assert self.state == self.LOGGEDIN_STATE, "DND requires being logged in (currently %s" % self.state
393 with qui_utils.notify_busy(self._errorLog, "Setting DND Status"):
395 self._backend[0].set_dnd,
400 _moduleLogger.exception("Reporting error to user")
401 self.error.emit(str(e))
404 if oldDnd != self._dnd:
405 self.dndStateChange.emit(self._dnd)
410 def get_account_number(self):
411 if self.state != self.LOGGEDIN_STATE:
413 return self._backend[0].get_account_number()
415 def get_callback_numbers(self):
416 if self.state != self.LOGGEDIN_STATE:
418 return self._backend[0].get_callback_numbers()
420 def get_callback_number(self):
421 return self._callback
423 def set_callback_number(self, callback):
424 le = self._asyncQueue.add_async(self._set_callback_number)
427 def _set_callback_number(self, callback):
428 oldCallback = self._callback
430 assert self.state == self.LOGGEDIN_STATE, "Callbacks configurable only when logged in (currently %s" % self.state
432 self._backend[0].set_callback_number,
437 _moduleLogger.exception("Reporting error to user")
438 self.error.emit(str(e))
440 self._callback = callback
441 if oldCallback != self._callback:
442 self.callbackNumberChanged.emit(self._callback)
444 def _login(self, username, password):
445 with qui_utils.notify_busy(self._errorLog, "Logging In"):
446 self._loggedInTime = self._LOGGINGIN_TIME
447 self.stateChange.emit(self.LOGGINGIN_STATE)
448 finalState = self.LOGGEDOUT_STATE
451 if accountData is None and self._backend[0].is_quick_login_possible():
452 accountData = yield (
453 self._backend[0].refresh_account_info,
457 if accountData is not None:
458 _moduleLogger.info("Logged in through cookies")
460 # Force a clearing of the cookies
462 self._backend[0].logout,
467 if accountData is None:
468 accountData = yield (
469 self._backend[0].login,
470 (username, password),
473 if accountData is not None:
474 _moduleLogger.info("Logged in through credentials")
476 if accountData is not None:
477 self._loggedInTime = int(time.time())
478 oldUsername = self._username
479 self._username = username
480 self._password = password
481 finalState = self.LOGGEDIN_STATE
482 if oldUsername != self._username:
483 needOps = not self._load()
487 self._voicemailCachePath = os.path.join(self._cachePath, "%s.voicemail.cache" % self._username)
489 os.makedirs(self._voicemailCachePath)
495 self.stateChange.emit(finalState)
496 finalState = None # Mark it as already set
497 self._process_account_data(accountData)
500 loginOps = self._loginOps[:]
503 del self._loginOps[:]
504 for asyncOp, args, kwds in loginOps:
505 asyncOp.start(*args, **kwds)
507 self._loggedInTime = self._LOGGEDOUT_TIME
508 self.error.emit("Error logging in")
510 _moduleLogger.exception("Booh")
511 self._loggedInTime = self._LOGGEDOUT_TIME
512 _moduleLogger.exception("Reporting error to user")
513 self.error.emit(str(e))
515 if finalState is not None:
516 self.stateChange.emit(finalState)
517 if accountData is not None and self._callback:
518 self.set_callback_number(self._callback)
520 def _update_account(self):
522 with qui_utils.notify_busy(self._errorLog, "Updating Account"):
523 accountData = yield (
524 self._backend[0].refresh_account_info,
529 _moduleLogger.exception("Reporting error to user")
530 self.error.emit(str(e))
532 self._loggedInTime = int(time.time())
533 self._process_account_data(accountData)
535 def _refresh_authentication(self):
537 with qui_utils.notify_busy(self._errorLog, "Updating Account"):
538 accountData = yield (
539 self._backend[0].refresh_account_info,
545 _moduleLogger.exception("Passing to user")
546 self.error.emit(str(e))
547 # refresh_account_info does not normally throw, so it is fine if we
548 # just quit early because something seriously wrong is going on
551 if accountData is not None:
552 self._loggedInTime = int(time.time())
553 self._process_account_data(accountData)
555 self._delayedRelogin.start()
558 updateMessages = len(self._messages) != 0
559 updateHistory = len(self._history) != 0
561 oldCallback = self._callback
564 self._cleanMessages = []
569 loadedFromCache = self._load_from_cache()
571 updateMessages = True
575 self.messagesUpdated.emit()
577 self.historyUpdated.emit()
578 if oldDnd != self._dnd:
579 self.dndStateChange.emit(self._dnd)
580 if oldCallback != self._callback:
581 self.callbackNumberChanged.emit(self._callback)
583 return loadedFromCache
585 def _load_from_cache(self):
586 if self._cachePath is None:
588 cachePath = os.path.join(self._cachePath, "%s.cache" % self._username)
591 with open(cachePath, "rb") as f:
592 dumpedData = pickle.load(f)
593 except (pickle.PickleError, IOError, EOFError, ValueError, ImportError):
594 _moduleLogger.exception("Pickle fun loading")
597 _moduleLogger.exception("Weirdness loading")
601 version, build = dumpedData[0:2]
603 _moduleLogger.exception("Upgrade/downgrade fun")
606 _moduleLogger.exception("Weirdlings")
609 if misc_utils.compare_versions(
610 self._OLDEST_COMPATIBLE_FORMAT_VERSION,
611 misc_utils.parse_version(version),
616 messages, messageUpdateTime,
617 history, historyUpdateTime,
621 _moduleLogger.exception("Upgrade/downgrade fun")
624 _moduleLogger.exception("Weirdlings")
627 _moduleLogger.info("Loaded cache")
628 self._messages = messages
629 self._alert_on_messages(self._messages)
630 self._messageUpdateTime = messageUpdateTime
631 self._history = history
632 self._historyUpdateTime = historyUpdateTime
634 self._callback = callback
638 "Skipping cache due to version mismatch (%s-%s)" % (
644 def _save_to_cache(self):
645 _moduleLogger.info("Saving cache")
646 if self._cachePath is None:
648 cachePath = os.path.join(self._cachePath, "%s.cache" % self._username)
652 constants.__version__, constants.__build__,
653 self._messages, self._messageUpdateTime,
654 self._history, self._historyUpdateTime,
655 self._dnd, self._callback
657 with open(cachePath, "wb") as f:
658 pickle.dump(dataToDump, f, pickle.HIGHEST_PROTOCOL)
659 _moduleLogger.info("Cache saved")
660 except (pickle.PickleError, IOError):
661 _moduleLogger.exception("While saving")
663 def _clear_cache(self):
664 updateMessages = len(self._messages) != 0
665 updateHistory = len(self._history) != 0
667 oldCallback = self._callback
670 self._messageUpdateTime = datetime.datetime(1971, 1, 1)
672 self._historyUpdateTime = datetime.datetime(1971, 1, 1)
677 self.messagesUpdated.emit()
679 self.historyUpdated.emit()
680 if oldDnd != self._dnd:
681 self.dndStateChange.emit(self._dnd)
682 if oldCallback != self._callback:
683 self.callbackNumberChanged.emit(self._callback)
685 self._save_to_cache()
686 self._clear_voicemail_cache()
688 def _clear_voicemail_cache(self):
690 shutil.rmtree(self._voicemailCachePath, True)
692 def _update_messages(self, messageType):
694 assert self.state == self.LOGGEDIN_STATE, "Messages requires being logged in (currently %s" % self.state
695 with qui_utils.notify_busy(self._errorLog, "Updating %s Messages" % messageType):
696 self._messages = yield (
697 self._backend[0].get_messages,
702 _moduleLogger.exception("Reporting error to user")
703 self.error.emit(str(e))
705 self._messageUpdateTime = datetime.datetime.now()
706 self.messagesUpdated.emit()
707 self._alert_on_messages(self._messages)
709 def _update_history(self, historyType):
711 assert self.state == self.LOGGEDIN_STATE, "History requires being logged in (currently %s" % self.state
712 with qui_utils.notify_busy(self._errorLog, "Updating '%s' History" % historyType):
713 self._history = yield (
714 self._backend[0].get_call_history,
719 _moduleLogger.exception("Reporting error to user")
720 self.error.emit(str(e))
722 self._historyUpdateTime = datetime.datetime.now()
723 self.historyUpdated.emit()
725 def _update_dnd(self):
726 with qui_utils.notify_busy(self._errorLog, "Updating Do-Not-Disturb Status"):
729 assert self.state == self.LOGGEDIN_STATE, "DND requires being logged in (currently %s" % self.state
731 self._backend[0].is_dnd,
736 _moduleLogger.exception("Reporting error to user")
737 self.error.emit(str(e))
739 if oldDnd != self._dnd:
740 self.dndStateChange(self._dnd)
742 def _download_voicemail(self, messageId):
743 actualPath = os.path.join(self._voicemailCachePath, "%s.mp3" % messageId)
744 targetPath = "%s.%s.part" % (actualPath, time.time())
745 if os.path.exists(actualPath):
746 self.voicemailAvailable.emit(messageId, actualPath)
748 with qui_utils.notify_busy(self._errorLog, "Downloading Voicemail"):
751 self._backend[0].download,
752 (messageId, targetPath),
756 _moduleLogger.exception("Passing to user")
757 self.error.emit(str(e))
760 if os.path.exists(actualPath):
762 os.remove(targetPath)
764 _moduleLogger.exception("Ignoring file problems with cache")
765 self.voicemailAvailable.emit(messageId, actualPath)
768 os.rename(targetPath, actualPath)
769 self.voicemailAvailable.emit(messageId, actualPath)
771 def _perform_op_while_loggedin(self, op):
772 if self.state == self.LOGGEDIN_STATE:
774 op.start(*args, **kwds)
776 self._push_login_op(op)
778 def _push_login_op(self, asyncOp):
779 assert self.state != self.LOGGEDIN_STATE, "Can only queue work when logged out"
780 if asyncOp in self._loginOps:
781 _moduleLogger.info("Skipping queueing duplicate op: %r" % asyncOp)
783 self._loginOps.append(asyncOp)
785 def _process_account_data(self, accountData):
786 self._contacts = dict(
787 (contactId, contactDetails)
788 for contactId, contactDetails in accountData["contacts"].iteritems()
789 # A zero contact id is the catch all for unknown contacts
793 self._accountUpdateTime = datetime.datetime.now()
794 self.accountUpdated.emit()
796 def _alert_on_messages(self, messages):
797 cleanNewMessages = list(self._clean_messages(messages))
798 cleanNewMessages.sort(key=lambda m: m["contactId"])
799 if self._cleanMessages:
800 if self._cleanMessages != cleanNewMessages:
801 self.newMessages.emit()
802 self._cleanMessages = cleanNewMessages
804 def _clean_messages(self, messages):
805 for message in messages:
808 for kv in message.iteritems()
820 # Don't let outbound messages cause alerts, especially if the package has only outbound
821 cleaned["messageParts"] = [
822 tuple(part[0:-1]) for part in cleaned["messageParts"] if part[0] != "Me:"
824 if not cleaned["messageParts"]:
829 @misc_utils.log_exception(_moduleLogger)
830 def _on_delayed_relogin(self):
832 username = self._username
833 password = self._password
835 self.login(username, password)
837 _moduleLogger.exception("Passing to user")
838 self.error.emit(str(e))