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 HISTORY_RECEIVED = "Received"
224 HISTORY_MISSED = "Missed"
225 HISTORY_PLACED = "Placed"
228 _OLDEST_COMPATIBLE_FORMAT_VERSION = misc_utils.parse_version("1.3.0")
233 def __init__(self, errorLog, cachePath = None):
234 QtCore.QObject.__init__(self)
235 self._errorLog = errorLog
236 self._pool = qore_utils.AsyncPool()
238 self._loggedInTime = self._LOGGEDOUT_TIME
240 self._cachePath = cachePath
241 self._voicemailCachePath = None
242 self._username = None
243 self._password = None
244 self._draft = Draft(self._pool, self._backend, self._errorLog)
245 self._delayedRelogin = QtCore.QTimer()
246 self._delayedRelogin.setInterval(0)
247 self._delayedRelogin.setSingleShot(True)
248 self._delayedRelogin.timeout.connect(self._on_delayed_relogin)
251 self._accountUpdateTime = datetime.datetime(1971, 1, 1)
253 self._cleanMessages = []
254 self._messageUpdateTime = datetime.datetime(1971, 1, 1)
256 self._historyUpdateTime = datetime.datetime(1971, 1, 1)
263 self._LOGGEDOUT_TIME: self.LOGGEDOUT_STATE,
264 self._LOGGINGIN_TIME: self.LOGGINGIN_STATE,
265 }.get(self._loggedInTime, self.LOGGEDIN_STATE)
271 def login(self, username, password):
272 assert self.state == self.LOGGEDOUT_STATE, "Can only log-in when logged out (currently %s" % self.state
273 assert username != "", "No username specified"
274 if self._cachePath is not None:
275 cookiePath = os.path.join(self._cachePath, "%s.cookies" % username)
279 if self._username != username or not self._backend:
280 from backends import gv_backend
282 self._backend[0:0] = [gv_backend.GVDialer(cookiePath)]
285 le = concurrent.AsyncLinearExecution(self._pool, self._login)
286 le.start(username, password)
289 assert self.state != self.LOGGEDOUT_STATE, "Can only logout if logged in (currently %s" % self.state
290 _moduleLogger.info("Logging out")
292 self._loggedInTime = self._LOGGEDOUT_TIME
293 self._backend[0].persist()
294 self._save_to_cache()
295 self._clear_voicemail_cache()
296 self.stateChange.emit(self.LOGGEDOUT_STATE)
297 self.loggedOut.emit()
300 assert self.state == self.LOGGEDOUT_STATE, "Can only clear when logged out (currently %s" % self.state
301 self._backend[0].logout()
306 def logout_and_clear(self):
307 assert self.state != self.LOGGEDOUT_STATE, "Can only logout if logged in (currently %s" % self.state
308 _moduleLogger.info("Logging out and clearing the account")
310 self._loggedInTime = self._LOGGEDOUT_TIME
312 self.stateChange.emit(self.LOGGEDOUT_STATE)
313 self.loggedOut.emit()
315 def update_account(self, force = True):
316 if not force and self._contacts:
318 le = concurrent.AsyncLinearExecution(self._pool, self._update_account), (), {}
319 self._perform_op_while_loggedin(le)
321 def refresh_connection(self):
322 le = concurrent.AsyncLinearExecution(self._pool, self._refresh_authentication)
325 def get_contacts(self):
326 return self._contacts
328 def get_when_contacts_updated(self):
329 return self._accountUpdateTime
331 def update_messages(self, messageType, force = True):
332 if not force and self._messages:
334 le = concurrent.AsyncLinearExecution(self._pool, self._update_messages), (messageType, ), {}
335 self._perform_op_while_loggedin(le)
337 def get_messages(self):
338 return self._messages
340 def get_when_messages_updated(self):
341 return self._messageUpdateTime
343 def update_history(self, historyType, force = True):
344 if not force and self._history:
346 le = concurrent.AsyncLinearExecution(self._pool, self._update_history), (historyType, ), {}
347 self._perform_op_while_loggedin(le)
349 def get_history(self):
352 def get_when_history_updated(self):
353 return self._historyUpdateTime
355 def update_dnd(self):
356 le = concurrent.AsyncLinearExecution(self._pool, self._update_dnd), (), {}
357 self._perform_op_while_loggedin(le)
359 def set_dnd(self, dnd):
360 le = concurrent.AsyncLinearExecution(self._pool, self._set_dnd)
363 def is_available(self, messageId):
364 actualPath = os.path.join(self._voicemailCachePath, "%s.mp3" % messageId)
365 return os.path.exists(actualPath)
367 def voicemail_path(self, messageId):
368 actualPath = os.path.join(self._voicemailCachePath, "%s.mp3" % messageId)
369 if not os.path.exists(actualPath):
370 raise RuntimeError("Voicemail not available")
373 def download_voicemail(self, messageId):
374 le = concurrent.AsyncLinearExecution(self._pool, self._download_voicemail)
377 def _set_dnd(self, dnd):
380 assert self.state == self.LOGGEDIN_STATE, "DND requires being logged in (currently %s" % self.state
381 with qui_utils.notify_busy(self._errorLog, "Setting DND Status"):
383 self._backend[0].set_dnd,
388 _moduleLogger.exception("Reporting error to user")
389 self.error.emit(str(e))
392 if oldDnd != self._dnd:
393 self.dndStateChange.emit(self._dnd)
398 def get_account_number(self):
399 if self.state != self.LOGGEDIN_STATE:
401 return self._backend[0].get_account_number()
403 def get_callback_numbers(self):
404 if self.state != self.LOGGEDIN_STATE:
406 return self._backend[0].get_callback_numbers()
408 def get_callback_number(self):
409 return self._callback
411 def set_callback_number(self, callback):
412 le = concurrent.AsyncLinearExecution(self._pool, self._set_callback_number)
415 def _set_callback_number(self, callback):
416 oldCallback = self._callback
418 assert self.state == self.LOGGEDIN_STATE, "Callbacks configurable only when logged in (currently %s" % self.state
420 self._backend[0].set_callback_number,
425 _moduleLogger.exception("Reporting error to user")
426 self.error.emit(str(e))
428 self._callback = callback
429 if oldCallback != self._callback:
430 self.callbackNumberChanged.emit(self._callback)
432 def _login(self, username, password):
433 with qui_utils.notify_busy(self._errorLog, "Logging In"):
434 self._loggedInTime = self._LOGGINGIN_TIME
435 self.stateChange.emit(self.LOGGINGIN_STATE)
436 finalState = self.LOGGEDOUT_STATE
439 if accountData is None and self._backend[0].is_quick_login_possible():
440 accountData = yield (
441 self._backend[0].refresh_account_info,
445 if accountData is not None:
446 _moduleLogger.info("Logged in through cookies")
448 # Force a clearing of the cookies
450 self._backend[0].logout,
455 if accountData is None:
456 accountData = yield (
457 self._backend[0].login,
458 (username, password),
461 if accountData is not None:
462 _moduleLogger.info("Logged in through credentials")
464 if accountData is not None:
465 self._loggedInTime = int(time.time())
466 oldUsername = self._username
467 self._username = username
468 self._password = password
469 finalState = self.LOGGEDIN_STATE
470 if oldUsername != self._username:
471 needOps = not self._load()
475 self._voicemailCachePath = os.path.join(self._cachePath, "%s.voicemail.cache" % self._username)
477 os.makedirs(self._voicemailCachePath)
483 self.stateChange.emit(finalState)
484 finalState = None # Mark it as already set
485 self._process_account_data(accountData)
488 loginOps = self._loginOps[:]
491 del self._loginOps[:]
492 for asyncOp, args, kwds in loginOps:
493 asyncOp.start(*args, **kwds)
495 self._loggedInTime = self._LOGGEDOUT_TIME
496 self.error.emit("Error logging in")
498 _moduleLogger.exception("Booh")
499 self._loggedInTime = self._LOGGEDOUT_TIME
500 _moduleLogger.exception("Reporting error to user")
501 self.error.emit(str(e))
503 if finalState is not None:
504 self.stateChange.emit(finalState)
505 if accountData is not None and self._callback:
506 self.set_callback_number(self._callback)
508 def _update_account(self):
510 with qui_utils.notify_busy(self._errorLog, "Updating Account"):
511 accountData = yield (
512 self._backend[0].refresh_account_info,
517 _moduleLogger.exception("Reporting error to user")
518 self.error.emit(str(e))
520 self._loggedInTime = int(time.time())
521 self._process_account_data(accountData)
523 def _refresh_authentication(self):
525 with qui_utils.notify_busy(self._errorLog, "Updating Account"):
526 accountData = yield (
527 self._backend[0].refresh_account_info,
533 _moduleLogger.exception("Passing to user")
534 self.error.emit(str(e))
535 # refresh_account_info does not normally throw, so it is fine if we
536 # just quit early because something seriously wrong is going on
539 if accountData is not None:
540 self._loggedInTime = int(time.time())
541 self._process_account_data(accountData)
543 self._delayedRelogin.start()
546 updateMessages = len(self._messages) != 0
547 updateHistory = len(self._history) != 0
549 oldCallback = self._callback
552 self._cleanMessages = []
557 loadedFromCache = self._load_from_cache()
559 updateMessages = True
563 self.messagesUpdated.emit()
565 self.historyUpdated.emit()
566 if oldDnd != self._dnd:
567 self.dndStateChange.emit(self._dnd)
568 if oldCallback != self._callback:
569 self.callbackNumberChanged.emit(self._callback)
571 return loadedFromCache
573 def _load_from_cache(self):
574 if self._cachePath is None:
576 cachePath = os.path.join(self._cachePath, "%s.cache" % self._username)
579 with open(cachePath, "rb") as f:
580 dumpedData = pickle.load(f)
581 except (pickle.PickleError, IOError, EOFError, ValueError, ImportError):
582 _moduleLogger.exception("Pickle fun loading")
585 _moduleLogger.exception("Weirdness loading")
589 version, build = dumpedData[0:2]
591 _moduleLogger.exception("Upgrade/downgrade fun")
594 _moduleLogger.exception("Weirdlings")
597 if misc_utils.compare_versions(
598 self._OLDEST_COMPATIBLE_FORMAT_VERSION,
599 misc_utils.parse_version(version),
604 messages, messageUpdateTime,
605 history, historyUpdateTime,
609 _moduleLogger.exception("Upgrade/downgrade fun")
612 _moduleLogger.exception("Weirdlings")
615 _moduleLogger.info("Loaded cache")
616 self._messages = messages
617 self._alert_on_messages(self._messages)
618 self._messageUpdateTime = messageUpdateTime
619 self._history = history
620 self._historyUpdateTime = historyUpdateTime
622 self._callback = callback
626 "Skipping cache due to version mismatch (%s-%s)" % (
632 def _save_to_cache(self):
633 _moduleLogger.info("Saving cache")
634 if self._cachePath is None:
636 cachePath = os.path.join(self._cachePath, "%s.cache" % self._username)
640 constants.__version__, constants.__build__,
641 self._messages, self._messageUpdateTime,
642 self._history, self._historyUpdateTime,
643 self._dnd, self._callback
645 with open(cachePath, "wb") as f:
646 pickle.dump(dataToDump, f, pickle.HIGHEST_PROTOCOL)
647 _moduleLogger.info("Cache saved")
648 except (pickle.PickleError, IOError):
649 _moduleLogger.exception("While saving")
651 def _clear_cache(self):
652 updateMessages = len(self._messages) != 0
653 updateHistory = len(self._history) != 0
655 oldCallback = self._callback
658 self._messageUpdateTime = datetime.datetime(1971, 1, 1)
660 self._historyUpdateTime = datetime.datetime(1971, 1, 1)
665 self.messagesUpdated.emit()
667 self.historyUpdated.emit()
668 if oldDnd != self._dnd:
669 self.dndStateChange.emit(self._dnd)
670 if oldCallback != self._callback:
671 self.callbackNumberChanged.emit(self._callback)
673 self._save_to_cache()
674 self._clear_voicemail_cache()
676 def _clear_voicemail_cache(self):
678 shutil.rmtree(self._voicemailCachePath, True)
680 def _update_messages(self, messageType):
682 assert self.state == self.LOGGEDIN_STATE, "Messages requires being logged in (currently %s" % self.state
683 with qui_utils.notify_busy(self._errorLog, "Updating %s Messages" % messageType):
684 self._messages = yield (
685 self._backend[0].get_messages,
690 _moduleLogger.exception("Reporting error to user")
691 self.error.emit(str(e))
693 self._messageUpdateTime = datetime.datetime.now()
694 self.messagesUpdated.emit()
695 self._alert_on_messages(self._messages)
697 def _update_history(self, historyType):
699 assert self.state == self.LOGGEDIN_STATE, "History requires being logged in (currently %s" % self.state
700 with qui_utils.notify_busy(self._errorLog, "Updating History"):
701 self._history = yield (
702 self._backend[0].get_call_history,
707 _moduleLogger.exception("Reporting error to user")
708 self.error.emit(str(e))
710 self._historyUpdateTime = datetime.datetime.now()
711 self.historyUpdated.emit()
713 def _update_dnd(self):
714 with qui_utils.notify_busy(self._errorLog, "Updating Do-Not-Disturb Status"):
717 assert self.state == self.LOGGEDIN_STATE, "DND requires being logged in (currently %s" % self.state
719 self._backend[0].is_dnd,
724 _moduleLogger.exception("Reporting error to user")
725 self.error.emit(str(e))
727 if oldDnd != self._dnd:
728 self.dndStateChange(self._dnd)
730 def _download_voicemail(self, messageId):
731 actualPath = os.path.join(self._voicemailCachePath, "%s.mp3" % messageId)
732 targetPath = "%s.%s.part" % (actualPath, time.time())
733 if os.path.exists(actualPath):
734 self.voicemailAvailable.emit(messageId, actualPath)
736 with qui_utils.notify_busy(self._errorLog, "Downloading Voicemail"):
739 self._backend[0].download,
740 (messageId, targetPath),
744 _moduleLogger.exception("Passing to user")
745 self.error.emit(str(e))
748 if os.path.exists(actualPath):
750 os.remove(targetPath)
752 _moduleLogger.exception("Ignoring file problems with cache")
753 self.voicemailAvailable.emit(messageId, actualPath)
756 os.rename(targetPath, actualPath)
757 self.voicemailAvailable.emit(messageId, actualPath)
759 def _perform_op_while_loggedin(self, op):
760 if self.state == self.LOGGEDIN_STATE:
762 op.start(*args, **kwds)
764 self._push_login_op(op)
766 def _push_login_op(self, asyncOp):
767 assert self.state != self.LOGGEDIN_STATE, "Can only queue work when logged out"
768 if asyncOp in self._loginOps:
769 _moduleLogger.info("Skipping queueing duplicate op: %r" % asyncOp)
771 self._loginOps.append(asyncOp)
773 def _process_account_data(self, accountData):
774 self._contacts = dict(
775 (contactId, contactDetails)
776 for contactId, contactDetails in accountData["contacts"].iteritems()
777 # A zero contact id is the catch all for unknown contacts
781 self._accountUpdateTime = datetime.datetime.now()
782 self.accountUpdated.emit()
784 def _alert_on_messages(self, messages):
785 cleanNewMessages = list(self._clean_messages(messages))
786 cleanNewMessages.sort(key=lambda m: m["contactId"])
787 if self._cleanMessages:
788 if self._cleanMessages != cleanNewMessages:
789 self.newMessages.emit()
790 self._cleanMessages = cleanNewMessages
792 def _clean_messages(self, messages):
793 for message in messages:
796 for kv in message.iteritems()
808 # Don't let outbound messages cause alerts, especially if the package has only outbound
809 cleaned["messageParts"] = [
810 tuple(part[0:-1]) for part in cleaned["messageParts"] if part[0] != "Me:"
812 if not cleaned["messageParts"]:
817 @misc_utils.log_exception(_moduleLogger)
818 def _on_delayed_relogin(self):
820 username = self._username
821 password = self._password
823 self.login(username, password)
825 _moduleLogger.exception("Passing to user")
826 self.error.emit(str(e))