1 from __future__ import with_statement
15 from PyQt4 import QtCore
17 from util import qore_utils
18 from util import qui_utils
19 from util import concurrent
20 from util import misc as misc_utils
25 _moduleLogger = logging.getLogger(__name__)
28 class _DraftContact(object):
30 def __init__(self, messageId, title, description, numbersWithDescriptions):
31 self.messageId = messageId
33 self.description = description
34 self.numbers = numbersWithDescriptions
35 self.selectedNumber = numbersWithDescriptions[0][0]
38 class Draft(QtCore.QObject):
40 sendingMessage = QtCore.pyqtSignal()
41 sentMessage = QtCore.pyqtSignal()
42 calling = QtCore.pyqtSignal()
43 called = QtCore.pyqtSignal()
44 cancelling = QtCore.pyqtSignal()
45 cancelled = QtCore.pyqtSignal()
46 error = QtCore.pyqtSignal(str)
48 recipientsChanged = QtCore.pyqtSignal()
50 def __init__(self, pool, backend, errorLog):
51 QtCore.QObject.__init__(self)
52 self._errorLog = errorLog
55 self._backend = backend
56 self._busyReason = None
60 assert 0 < len(self._contacts), "No contacts selected"
61 assert 0 < len(self._message), "No message to send"
62 numbers = [misc_utils.make_ugly(contact.selectedNumber) for contact in self._contacts.itervalues()]
63 le = concurrent.AsyncLinearExecution(self._pool, self._send)
64 le.start(numbers, self._message)
67 assert len(self._contacts) == 1, "Must select 1 and only 1 contact"
68 assert len(self._message) == 0, "Cannot send message with call"
69 (contact, ) = self._contacts.itervalues()
70 number = misc_utils.make_ugly(contact.selectedNumber)
71 le = concurrent.AsyncLinearExecution(self._pool, self._call)
75 le = concurrent.AsyncLinearExecution(self._pool, self._cancel)
78 def _get_message(self):
81 def _set_message(self, message):
82 self._message = message
84 message = property(_get_message, _set_message)
86 def add_contact(self, contactId, messageId, title, description, numbersWithDescriptions):
87 if self._busyReason is not None:
88 raise RuntimeError("Please wait for %r" % self._busyReason)
89 # Allow overwriting of contacts so that the message can be updated and the SMS dialog popped back up
90 contactDetails = _DraftContact(messageId, title, description, numbersWithDescriptions)
91 self._contacts[contactId] = contactDetails
92 self.recipientsChanged.emit()
94 def remove_contact(self, contactId):
95 if self._busyReason is not None:
96 raise RuntimeError("Please wait for %r" % self._busyReason)
97 assert contactId in self._contacts, "Contact missing"
98 del self._contacts[contactId]
99 self.recipientsChanged.emit()
101 def get_contacts(self):
102 return self._contacts.iterkeys()
104 def get_num_contacts(self):
105 return len(self._contacts)
107 def get_message_id(self, cid):
108 return self._contacts[cid].messageId
110 def get_title(self, cid):
111 return self._contacts[cid].title
113 def get_description(self, cid):
114 return self._contacts[cid].description
116 def get_numbers(self, cid):
117 return self._contacts[cid].numbers
119 def get_selected_number(self, cid):
120 return self._contacts[cid].selectedNumber
122 def set_selected_number(self, cid, number):
123 # @note I'm lazy, this isn't firing any kind of signal since only one
124 # controller right now and that is the viewer
125 assert number in (nWD[0] for nWD in self._contacts[cid].numbers), "Number not selectable"
126 self._contacts[cid].selectedNumber = number
129 if self._busyReason is not None:
130 raise RuntimeError("Please wait for %r" % self._busyReason)
134 oldContacts = self._contacts
138 self.recipientsChanged.emit()
140 @contextlib.contextmanager
141 def _busy(self, message):
142 if self._busyReason is not None:
143 raise RuntimeError("Already busy doing %r" % self._busyReason)
145 self._busyReason = message
148 self._busyReason = None
150 def _send(self, numbers, text):
151 self.sendingMessage.emit()
153 with self._busy("Sending Text"):
154 with qui_utils.notify_busy(self._errorLog, "Sending Text"):
156 self._backend[0].send_sms,
160 self.sentMessage.emit()
163 _moduleLogger.exception("Reporting error to user")
164 self.error.emit(str(e))
166 def _call(self, number):
169 with self._busy("Calling"):
170 with qui_utils.notify_busy(self._errorLog, "Calling"):
172 self._backend[0].call,
179 _moduleLogger.exception("Reporting error to user")
180 self.error.emit(str(e))
183 self.cancelling.emit()
185 with qui_utils.notify_busy(self._errorLog, "Cancelling"):
187 self._backend[0].cancel,
191 self.cancelled.emit()
193 _moduleLogger.exception("Reporting error to user")
194 self.error.emit(str(e))
197 class Session(QtCore.QObject):
199 # @todo Somehow add support for csv contacts
201 stateChange = QtCore.pyqtSignal(str)
202 loggedOut = QtCore.pyqtSignal()
203 loggedIn = QtCore.pyqtSignal()
204 callbackNumberChanged = QtCore.pyqtSignal(str)
206 accountUpdated = QtCore.pyqtSignal()
207 messagesUpdated = QtCore.pyqtSignal()
208 newMessages = QtCore.pyqtSignal()
209 historyUpdated = QtCore.pyqtSignal()
210 dndStateChange = QtCore.pyqtSignal(bool)
211 voicemailAvailable = QtCore.pyqtSignal(str, str)
213 error = QtCore.pyqtSignal(str)
215 LOGGEDOUT_STATE = "logged out"
216 LOGGINGIN_STATE = "logging in"
217 LOGGEDIN_STATE = "logged in"
219 MESSAGE_TEXTS = "Text"
220 MESSAGE_VOICEMAILS = "Voicemail"
223 _OLDEST_COMPATIBLE_FORMAT_VERSION = misc_utils.parse_version("1.3.0")
228 def __init__(self, errorLog, cachePath = None):
229 QtCore.QObject.__init__(self)
230 self._errorLog = errorLog
231 self._pool = qore_utils.AsyncPool()
233 self._loggedInTime = self._LOGGEDOUT_TIME
235 self._cachePath = cachePath
236 self._voicemailCachePath = None
237 self._username = None
238 self._password = None
239 self._draft = Draft(self._pool, self._backend, self._errorLog)
240 self._delayedRelogin = QtCore.QTimer()
241 self._delayedRelogin.setInterval(0)
242 self._delayedRelogin.setSingleShot(True)
243 self._delayedRelogin.timeout.connect(self._on_delayed_relogin)
246 self._accountUpdateTime = datetime.datetime(1971, 1, 1)
248 self._cleanMessages = []
249 self._messageUpdateTime = datetime.datetime(1971, 1, 1)
251 self._historyUpdateTime = datetime.datetime(1971, 1, 1)
258 self._LOGGEDOUT_TIME: self.LOGGEDOUT_STATE,
259 self._LOGGINGIN_TIME: self.LOGGINGIN_STATE,
260 }.get(self._loggedInTime, self.LOGGEDIN_STATE)
266 def login(self, username, password):
267 assert self.state == self.LOGGEDOUT_STATE, "Can only log-in when logged out (currently %s" % self.state
268 assert username != "", "No username specified"
269 if self._cachePath is not None:
270 cookiePath = os.path.join(self._cachePath, "%s.cookies" % username)
274 if self._username != username or not self._backend:
275 from backends import gv_backend
277 self._backend[0:0] = [gv_backend.GVDialer(cookiePath)]
280 le = concurrent.AsyncLinearExecution(self._pool, self._login)
281 le.start(username, password)
284 assert self.state != self.LOGGEDOUT_STATE, "Can only logout if logged in (currently %s" % self.state
285 _moduleLogger.info("Logging out")
287 self._loggedInTime = self._LOGGEDOUT_TIME
288 self._backend[0].persist()
289 self._save_to_cache()
290 self._clear_voicemail_cache()
291 self.stateChange.emit(self.LOGGEDOUT_STATE)
292 self.loggedOut.emit()
295 assert self.state == self.LOGGEDOUT_STATE, "Can only clear when logged out (currently %s" % self.state
296 self._backend[0].logout()
301 def logout_and_clear(self):
302 assert self.state != self.LOGGEDOUT_STATE, "Can only logout if logged in (currently %s" % self.state
303 _moduleLogger.info("Logging out and clearing the account")
305 self._loggedInTime = self._LOGGEDOUT_TIME
307 self.stateChange.emit(self.LOGGEDOUT_STATE)
308 self.loggedOut.emit()
310 def update_account(self, force = True):
311 if not force and self._contacts:
313 le = concurrent.AsyncLinearExecution(self._pool, self._update_account), (), {}
314 self._perform_op_while_loggedin(le)
316 def refresh_connection(self):
317 le = concurrent.AsyncLinearExecution(self._pool, self._refresh_authentication)
320 def get_contacts(self):
321 return self._contacts
323 def get_when_contacts_updated(self):
324 return self._accountUpdateTime
326 def update_messages(self, messageType, force = True):
327 if not force and self._messages:
329 le = concurrent.AsyncLinearExecution(self._pool, self._update_messages), (messageType, ), {}
330 self._perform_op_while_loggedin(le)
332 def get_messages(self):
333 return self._messages
335 def get_when_messages_updated(self):
336 return self._messageUpdateTime
338 def update_history(self, force = True):
339 if not force and self._history:
341 le = concurrent.AsyncLinearExecution(self._pool, self._update_history), (), {}
342 self._perform_op_while_loggedin(le)
344 def get_history(self):
347 def get_when_history_updated(self):
348 return self._historyUpdateTime
350 def update_dnd(self):
351 le = concurrent.AsyncLinearExecution(self._pool, self._update_dnd), (), {}
352 self._perform_op_while_loggedin(le)
354 def set_dnd(self, dnd):
355 le = concurrent.AsyncLinearExecution(self._pool, self._set_dnd)
358 def is_available(self, messageId):
359 actualPath = os.path.join(self._voicemailCachePath, "%s.mp3" % messageId)
360 return os.path.exists(actualPath)
362 def voicemail_path(self, messageId):
363 actualPath = os.path.join(self._voicemailCachePath, "%s.mp3" % messageId)
364 if not os.path.exists(actualPath):
365 raise RuntimeError("Voicemail not available")
368 def download_voicemail(self, messageId):
369 le = concurrent.AsyncLinearExecution(self._pool, self._download_voicemail)
372 def _set_dnd(self, dnd):
375 assert self.state == self.LOGGEDIN_STATE, "DND requires being logged in (currently %s" % self.state
376 with qui_utils.notify_busy(self._errorLog, "Setting DND Status"):
378 self._backend[0].set_dnd,
383 _moduleLogger.exception("Reporting error to user")
384 self.error.emit(str(e))
387 if oldDnd != self._dnd:
388 self.dndStateChange.emit(self._dnd)
393 def get_account_number(self):
394 if self.state != self.LOGGEDIN_STATE:
396 return self._backend[0].get_account_number()
398 def get_callback_numbers(self):
399 if self.state != self.LOGGEDIN_STATE:
401 return self._backend[0].get_callback_numbers()
403 def get_callback_number(self):
404 return self._callback
406 def set_callback_number(self, callback):
407 le = concurrent.AsyncLinearExecution(self._pool, self._set_callback_number)
410 def _set_callback_number(self, callback):
411 oldCallback = self._callback
413 assert self.state == self.LOGGEDIN_STATE, "Callbacks configurable only when logged in (currently %s" % self.state
415 self._backend[0].set_callback_number,
420 _moduleLogger.exception("Reporting error to user")
421 self.error.emit(str(e))
423 self._callback = callback
424 if oldCallback != self._callback:
425 self.callbackNumberChanged.emit(self._callback)
427 def _login(self, username, password):
428 with qui_utils.notify_busy(self._errorLog, "Logging In"):
429 self._loggedInTime = self._LOGGINGIN_TIME
430 self.stateChange.emit(self.LOGGINGIN_STATE)
431 finalState = self.LOGGEDOUT_STATE
434 if accountData is None and self._backend[0].is_quick_login_possible():
435 accountData = yield (
436 self._backend[0].refresh_account_info,
440 if accountData is not None:
441 _moduleLogger.info("Logged in through cookies")
443 # Force a clearing of the cookies
445 self._backend[0].logout,
450 if accountData is None:
451 accountData = yield (
452 self._backend[0].login,
453 (username, password),
456 if accountData is not None:
457 _moduleLogger.info("Logged in through credentials")
459 if accountData is not None:
460 self._loggedInTime = int(time.time())
461 oldUsername = self._username
462 self._username = username
463 self._password = password
464 finalState = self.LOGGEDIN_STATE
465 if oldUsername != self._username:
466 needOps = not self._load()
470 self._voicemailCachePath = os.path.join(self._cachePath, "%s.voicemail.cache" % self._username)
472 os.makedirs(self._voicemailCachePath)
478 self.stateChange.emit(finalState)
479 finalState = None # Mark it as already set
480 self._process_account_data(accountData)
483 loginOps = self._loginOps[:]
486 del self._loginOps[:]
487 for asyncOp, args, kwds in loginOps:
488 asyncOp.start(*args, **kwds)
490 self._loggedInTime = self._LOGGEDOUT_TIME
491 self.error.emit("Error logging in")
493 _moduleLogger.exception("Booh")
494 self._loggedInTime = self._LOGGEDOUT_TIME
495 _moduleLogger.exception("Reporting error to user")
496 self.error.emit(str(e))
498 if finalState is not None:
499 self.stateChange.emit(finalState)
500 if accountData is not None and self._callback:
501 self.set_callback_number(self._callback)
503 def _update_account(self):
505 with qui_utils.notify_busy(self._errorLog, "Updating Account"):
506 accountData = yield (
507 self._backend[0].refresh_account_info,
512 _moduleLogger.exception("Reporting error to user")
513 self.error.emit(str(e))
515 self._loggedInTime = int(time.time())
516 self._process_account_data(accountData)
518 def _refresh_authentication(self):
520 with qui_utils.notify_busy(self._errorLog, "Updating Account"):
521 accountData = yield (
522 self._backend[0].refresh_account_info,
528 _moduleLogger.exception("Passing to user")
529 self.error.emit(str(e))
530 # refresh_account_info does not normally throw, so it is fine if we
531 # just quit early because something seriously wrong is going on
534 if accountData is not None:
535 self._loggedInTime = int(time.time())
536 self._process_account_data(accountData)
538 self._delayedRelogin.start()
541 updateMessages = len(self._messages) != 0
542 updateHistory = len(self._history) != 0
544 oldCallback = self._callback
547 self._cleanMessages = []
552 loadedFromCache = self._load_from_cache()
554 updateMessages = True
558 self.messagesUpdated.emit()
560 self.historyUpdated.emit()
561 if oldDnd != self._dnd:
562 self.dndStateChange.emit(self._dnd)
563 if oldCallback != self._callback:
564 self.callbackNumberChanged.emit(self._callback)
566 return loadedFromCache
568 def _load_from_cache(self):
569 if self._cachePath is None:
571 cachePath = os.path.join(self._cachePath, "%s.cache" % self._username)
574 with open(cachePath, "rb") as f:
575 dumpedData = pickle.load(f)
576 except (pickle.PickleError, IOError, EOFError, ValueError, ImportError):
577 _moduleLogger.exception("Pickle fun loading")
580 _moduleLogger.exception("Weirdness loading")
584 version, build = dumpedData[0:2]
586 _moduleLogger.exception("Upgrade/downgrade fun")
589 _moduleLogger.exception("Weirdlings")
592 if misc_utils.compare_versions(
593 self._OLDEST_COMPATIBLE_FORMAT_VERSION,
594 misc_utils.parse_version(version),
599 messages, messageUpdateTime,
600 history, historyUpdateTime,
604 _moduleLogger.exception("Upgrade/downgrade fun")
607 _moduleLogger.exception("Weirdlings")
610 _moduleLogger.info("Loaded cache")
611 self._messages = messages
612 self._alert_on_messages(self._messages)
613 self._messageUpdateTime = messageUpdateTime
614 self._history = history
615 self._historyUpdateTime = historyUpdateTime
617 self._callback = callback
621 "Skipping cache due to version mismatch (%s-%s)" % (
627 def _save_to_cache(self):
628 _moduleLogger.info("Saving cache")
629 if self._cachePath is None:
631 cachePath = os.path.join(self._cachePath, "%s.cache" % self._username)
635 constants.__version__, constants.__build__,
636 self._messages, self._messageUpdateTime,
637 self._history, self._historyUpdateTime,
638 self._dnd, self._callback
640 with open(cachePath, "wb") as f:
641 pickle.dump(dataToDump, f, pickle.HIGHEST_PROTOCOL)
642 _moduleLogger.info("Cache saved")
643 except (pickle.PickleError, IOError):
644 _moduleLogger.exception("While saving")
646 def _clear_cache(self):
647 updateMessages = len(self._messages) != 0
648 updateHistory = len(self._history) != 0
650 oldCallback = self._callback
653 self._messageUpdateTime = datetime.datetime(1971, 1, 1)
655 self._historyUpdateTime = datetime.datetime(1971, 1, 1)
660 self.messagesUpdated.emit()
662 self.historyUpdated.emit()
663 if oldDnd != self._dnd:
664 self.dndStateChange.emit(self._dnd)
665 if oldCallback != self._callback:
666 self.callbackNumberChanged.emit(self._callback)
668 self._save_to_cache()
669 self._clear_voicemail_cache()
671 def _clear_voicemail_cache(self):
673 shutil.rmtree(self._voicemailCachePath, True)
675 def _update_messages(self, messageType):
677 assert self.state == self.LOGGEDIN_STATE, "Messages requires being logged in (currently %s" % self.state
678 with qui_utils.notify_busy(self._errorLog, "Updating %s Messages" % messageType):
679 self._messages = yield (
680 self._backend[0].get_messages,
685 _moduleLogger.exception("Reporting error to user")
686 self.error.emit(str(e))
688 self._messageUpdateTime = datetime.datetime.now()
689 self.messagesUpdated.emit()
690 self._alert_on_messages(self._messages)
692 def _update_history(self):
694 assert self.state == self.LOGGEDIN_STATE, "History requires being logged in (currently %s" % self.state
695 with qui_utils.notify_busy(self._errorLog, "Updating History"):
696 self._history = yield (
697 self._backend[0].get_recent,
702 _moduleLogger.exception("Reporting error to user")
703 self.error.emit(str(e))
705 self._historyUpdateTime = datetime.datetime.now()
706 self.historyUpdated.emit()
708 def _update_dnd(self):
709 with qui_utils.notify_busy(self._errorLog, "Updating Do-Not-Disturb Status"):
712 assert self.state == self.LOGGEDIN_STATE, "DND requires being logged in (currently %s" % self.state
714 self._backend[0].is_dnd,
719 _moduleLogger.exception("Reporting error to user")
720 self.error.emit(str(e))
722 if oldDnd != self._dnd:
723 self.dndStateChange(self._dnd)
725 def _download_voicemail(self, messageId):
726 actualPath = os.path.join(self._voicemailCachePath, "%s.mp3" % messageId)
727 targetPath = "%s.%s.part" % (actualPath, time.time())
728 if os.path.exists(actualPath):
729 self.voicemailAvailable.emit(messageId, actualPath)
731 with qui_utils.notify_busy(self._errorLog, "Downloading Voicemail"):
734 self._backend[0].download,
735 (messageId, targetPath),
739 _moduleLogger.exception("Passing to user")
740 self.error.emit(str(e))
743 if os.path.exists(actualPath):
745 os.remove(targetPath)
747 _moduleLogger.exception("Ignoring file problems with cache")
748 self.voicemailAvailable.emit(messageId, actualPath)
751 os.rename(targetPath, actualPath)
752 self.voicemailAvailable.emit(messageId, actualPath)
754 def _perform_op_while_loggedin(self, op):
755 if self.state == self.LOGGEDIN_STATE:
757 op.start(*args, **kwds)
759 self._push_login_op(op)
761 def _push_login_op(self, asyncOp):
762 assert self.state != self.LOGGEDIN_STATE, "Can only queue work when logged out"
763 if asyncOp in self._loginOps:
764 _moduleLogger.info("Skipping queueing duplicate op: %r" % asyncOp)
766 self._loginOps.append(asyncOp)
768 def _process_account_data(self, accountData):
769 self._contacts = dict(
770 (contactId, contactDetails)
771 for contactId, contactDetails in accountData["contacts"].iteritems()
772 # A zero contact id is the catch all for unknown contacts
776 self._accountUpdateTime = datetime.datetime.now()
777 self.accountUpdated.emit()
779 def _alert_on_messages(self, messages):
780 cleanNewMessages = list(self._clean_messages(messages))
781 cleanNewMessages.sort(key=lambda m: m["contactId"])
782 if self._cleanMessages:
783 if self._cleanMessages != cleanNewMessages:
784 self.newMessages.emit()
785 self._cleanMessages = cleanNewMessages
787 def _clean_messages(self, messages):
788 for message in messages:
791 for kv in message.iteritems()
803 # Don't let outbound messages cause alerts, especially if the package has only outbound
804 cleaned["messageParts"] = [
805 tuple(part[0:-1]) for part in cleaned["messageParts"] if part[0] != "Me:"
807 if not cleaned["messageParts"]:
812 @misc_utils.log_exception(_moduleLogger)
813 def _on_delayed_relogin(self):
815 username = self._username
816 password = self._password
818 self.login(username, password)
820 _moduleLogger.exception("Passing to user")
821 self.error.emit(str(e))