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 class Draft(QtCore.QObject):
41 sendingMessage = qt_compat.Signal()
42 sentMessage = qt_compat.Signal()
43 calling = qt_compat.Signal()
44 called = qt_compat.Signal()
45 cancelling = qt_compat.Signal()
46 cancelled = qt_compat.Signal()
47 error = qt_compat.Signal(str)
49 recipientsChanged = qt_compat.Signal()
51 def __init__(self, pool, backend, errorLog):
52 QtCore.QObject.__init__(self)
53 self._errorLog = errorLog
56 self._backend = backend
57 self._busyReason = None
61 assert 0 < len(self._contacts), "No contacts selected"
62 assert 0 < len(self._message), "No message to send"
63 numbers = [misc_utils.make_ugly(contact.selectedNumber) for contact in self._contacts.itervalues()]
64 le = concurrent.AsyncLinearExecution(self._pool, self._send)
65 le.start(numbers, self._message)
68 assert len(self._contacts) == 1, "Must select 1 and only 1 contact"
69 assert len(self._message) == 0, "Cannot send message with call"
70 (contact, ) = self._contacts.itervalues()
71 number = misc_utils.make_ugly(contact.selectedNumber)
72 le = concurrent.AsyncLinearExecution(self._pool, self._call)
76 le = concurrent.AsyncLinearExecution(self._pool, self._cancel)
79 def _get_message(self):
82 def _set_message(self, message):
83 self._message = message
85 message = property(_get_message, _set_message)
87 def add_contact(self, contactId, messageId, title, description, numbersWithDescriptions):
88 if self._busyReason is not None:
89 raise RuntimeError("Please wait for %r" % self._busyReason)
90 # Allow overwriting of contacts so that the message can be updated and the SMS dialog popped back up
91 contactDetails = _DraftContact(messageId, title, description, numbersWithDescriptions)
92 self._contacts[contactId] = contactDetails
93 self.recipientsChanged.emit()
95 def remove_contact(self, contactId):
96 if self._busyReason is not None:
97 raise RuntimeError("Please wait for %r" % self._busyReason)
98 assert contactId in self._contacts, "Contact missing"
99 del self._contacts[contactId]
100 self.recipientsChanged.emit()
102 def get_contacts(self):
103 return self._contacts.iterkeys()
105 def get_num_contacts(self):
106 return len(self._contacts)
108 def get_message_id(self, cid):
109 return self._contacts[cid].messageId
111 def get_title(self, cid):
112 return self._contacts[cid].title
114 def get_description(self, cid):
115 return self._contacts[cid].description
117 def get_numbers(self, cid):
118 return self._contacts[cid].numbers
120 def get_selected_number(self, cid):
121 return self._contacts[cid].selectedNumber
123 def set_selected_number(self, cid, number):
124 # @note I'm lazy, this isn't firing any kind of signal since only one
125 # controller right now and that is the viewer
126 assert number in (nWD[0] for nWD in self._contacts[cid].numbers), "Number not selectable"
127 self._contacts[cid].selectedNumber = number
130 if self._busyReason is not None:
131 raise RuntimeError("Please wait for %r" % self._busyReason)
135 oldContacts = self._contacts
139 self.recipientsChanged.emit()
141 @contextlib.contextmanager
142 def _busy(self, message):
143 if self._busyReason is not None:
144 raise RuntimeError("Already busy doing %r" % self._busyReason)
146 self._busyReason = message
149 self._busyReason = None
151 def _send(self, numbers, text):
152 self.sendingMessage.emit()
154 with self._busy("Sending Text"):
155 with qui_utils.notify_busy(self._errorLog, "Sending Text"):
157 self._backend[0].send_sms,
161 self.sentMessage.emit()
164 _moduleLogger.exception("Reporting error to user")
165 self.error.emit(str(e))
167 def _call(self, number):
170 with self._busy("Calling"):
171 with qui_utils.notify_busy(self._errorLog, "Calling"):
173 self._backend[0].call,
180 _moduleLogger.exception("Reporting error to user")
181 self.error.emit(str(e))
184 self.cancelling.emit()
186 with qui_utils.notify_busy(self._errorLog, "Cancelling"):
188 self._backend[0].cancel,
192 self.cancelled.emit()
194 _moduleLogger.exception("Reporting error to user")
195 self.error.emit(str(e))
198 class Session(QtCore.QObject):
200 # @todo Somehow add support for csv contacts
202 stateChange = qt_compat.Signal(str)
203 loggedOut = qt_compat.Signal()
204 loggedIn = qt_compat.Signal()
205 callbackNumberChanged = qt_compat.Signal(str)
207 accountUpdated = qt_compat.Signal()
208 messagesUpdated = qt_compat.Signal()
209 newMessages = qt_compat.Signal()
210 historyUpdated = qt_compat.Signal()
211 dndStateChange = qt_compat.Signal(bool)
212 voicemailAvailable = qt_compat.Signal(str, str)
214 error = qt_compat.Signal(str)
216 LOGGEDOUT_STATE = "logged out"
217 LOGGINGIN_STATE = "logging in"
218 LOGGEDIN_STATE = "logged in"
220 MESSAGE_TEXTS = "Text"
221 MESSAGE_VOICEMAILS = "Voicemail"
224 HISTORY_RECEIVED = "Received"
225 HISTORY_MISSED = "Missed"
226 HISTORY_PLACED = "Placed"
229 _OLDEST_COMPATIBLE_FORMAT_VERSION = misc_utils.parse_version("1.3.0")
234 def __init__(self, errorLog, cachePath = None):
235 QtCore.QObject.__init__(self)
236 self._errorLog = errorLog
237 self._pool = qore_utils.AsyncPool()
239 self._loggedInTime = self._LOGGEDOUT_TIME
241 self._cachePath = cachePath
242 self._voicemailCachePath = None
243 self._username = None
244 self._password = None
245 self._draft = Draft(self._pool, self._backend, self._errorLog)
246 self._delayedRelogin = QtCore.QTimer()
247 self._delayedRelogin.setInterval(0)
248 self._delayedRelogin.setSingleShot(True)
249 self._delayedRelogin.timeout.connect(self._on_delayed_relogin)
252 self._accountUpdateTime = datetime.datetime(1971, 1, 1)
254 self._cleanMessages = []
255 self._messageUpdateTime = datetime.datetime(1971, 1, 1)
257 self._historyUpdateTime = datetime.datetime(1971, 1, 1)
264 self._LOGGEDOUT_TIME: self.LOGGEDOUT_STATE,
265 self._LOGGINGIN_TIME: self.LOGGINGIN_STATE,
266 }.get(self._loggedInTime, self.LOGGEDIN_STATE)
272 def login(self, username, password):
273 assert self.state == self.LOGGEDOUT_STATE, "Can only log-in when logged out (currently %s" % self.state
274 assert username != "", "No username specified"
275 if self._cachePath is not None:
276 cookiePath = os.path.join(self._cachePath, "%s.cookies" % username)
280 if self._username != username or not self._backend:
281 from backends import gv_backend
283 self._backend[0:0] = [gv_backend.GVDialer(cookiePath)]
286 le = concurrent.AsyncLinearExecution(self._pool, self._login)
287 le.start(username, password)
290 assert self.state != self.LOGGEDOUT_STATE, "Can only logout if logged in (currently %s" % self.state
291 _moduleLogger.info("Logging out")
293 self._loggedInTime = self._LOGGEDOUT_TIME
294 self._backend[0].persist()
295 self._save_to_cache()
296 self._clear_voicemail_cache()
297 self.stateChange.emit(self.LOGGEDOUT_STATE)
298 self.loggedOut.emit()
301 assert self.state == self.LOGGEDOUT_STATE, "Can only clear when logged out (currently %s" % self.state
302 self._backend[0].logout()
307 def logout_and_clear(self):
308 assert self.state != self.LOGGEDOUT_STATE, "Can only logout if logged in (currently %s" % self.state
309 _moduleLogger.info("Logging out and clearing the account")
311 self._loggedInTime = self._LOGGEDOUT_TIME
313 self.stateChange.emit(self.LOGGEDOUT_STATE)
314 self.loggedOut.emit()
316 def update_account(self, force = True):
317 if not force and self._contacts:
319 le = concurrent.AsyncLinearExecution(self._pool, self._update_account), (), {}
320 self._perform_op_while_loggedin(le)
322 def refresh_connection(self):
323 le = concurrent.AsyncLinearExecution(self._pool, self._refresh_authentication)
326 def get_contacts(self):
327 return self._contacts
329 def get_when_contacts_updated(self):
330 return self._accountUpdateTime
332 def update_messages(self, messageType, force = True):
333 if not force and self._messages:
335 le = concurrent.AsyncLinearExecution(self._pool, self._update_messages), (messageType, ), {}
336 self._perform_op_while_loggedin(le)
338 def get_messages(self):
339 return self._messages
341 def get_when_messages_updated(self):
342 return self._messageUpdateTime
344 def update_history(self, historyType, force = True):
345 if not force and self._history:
347 le = concurrent.AsyncLinearExecution(self._pool, self._update_history), (historyType, ), {}
348 self._perform_op_while_loggedin(le)
350 def get_history(self):
353 def get_when_history_updated(self):
354 return self._historyUpdateTime
356 def update_dnd(self):
357 le = concurrent.AsyncLinearExecution(self._pool, self._update_dnd), (), {}
358 self._perform_op_while_loggedin(le)
360 def set_dnd(self, dnd):
361 le = concurrent.AsyncLinearExecution(self._pool, self._set_dnd)
364 def is_available(self, messageId):
365 actualPath = os.path.join(self._voicemailCachePath, "%s.mp3" % messageId)
366 return os.path.exists(actualPath)
368 def voicemail_path(self, messageId):
369 actualPath = os.path.join(self._voicemailCachePath, "%s.mp3" % messageId)
370 if not os.path.exists(actualPath):
371 raise RuntimeError("Voicemail not available")
374 def download_voicemail(self, messageId):
375 le = concurrent.AsyncLinearExecution(self._pool, self._download_voicemail)
378 def _set_dnd(self, dnd):
381 assert self.state == self.LOGGEDIN_STATE, "DND requires being logged in (currently %s" % self.state
382 with qui_utils.notify_busy(self._errorLog, "Setting DND Status"):
384 self._backend[0].set_dnd,
389 _moduleLogger.exception("Reporting error to user")
390 self.error.emit(str(e))
393 if oldDnd != self._dnd:
394 self.dndStateChange.emit(self._dnd)
399 def get_account_number(self):
400 if self.state != self.LOGGEDIN_STATE:
402 return self._backend[0].get_account_number()
404 def get_callback_numbers(self):
405 if self.state != self.LOGGEDIN_STATE:
407 return self._backend[0].get_callback_numbers()
409 def get_callback_number(self):
410 return self._callback
412 def set_callback_number(self, callback):
413 le = concurrent.AsyncLinearExecution(self._pool, self._set_callback_number)
416 def _set_callback_number(self, callback):
417 oldCallback = self._callback
419 assert self.state == self.LOGGEDIN_STATE, "Callbacks configurable only when logged in (currently %s" % self.state
421 self._backend[0].set_callback_number,
426 _moduleLogger.exception("Reporting error to user")
427 self.error.emit(str(e))
429 self._callback = callback
430 if oldCallback != self._callback:
431 self.callbackNumberChanged.emit(self._callback)
433 def _login(self, username, password):
434 with qui_utils.notify_busy(self._errorLog, "Logging In"):
435 self._loggedInTime = self._LOGGINGIN_TIME
436 self.stateChange.emit(self.LOGGINGIN_STATE)
437 finalState = self.LOGGEDOUT_STATE
440 if accountData is None and self._backend[0].is_quick_login_possible():
441 accountData = yield (
442 self._backend[0].refresh_account_info,
446 if accountData is not None:
447 _moduleLogger.info("Logged in through cookies")
449 # Force a clearing of the cookies
451 self._backend[0].logout,
456 if accountData is None:
457 accountData = yield (
458 self._backend[0].login,
459 (username, password),
462 if accountData is not None:
463 _moduleLogger.info("Logged in through credentials")
465 if accountData is not None:
466 self._loggedInTime = int(time.time())
467 oldUsername = self._username
468 self._username = username
469 self._password = password
470 finalState = self.LOGGEDIN_STATE
471 if oldUsername != self._username:
472 needOps = not self._load()
476 self._voicemailCachePath = os.path.join(self._cachePath, "%s.voicemail.cache" % self._username)
478 os.makedirs(self._voicemailCachePath)
484 self.stateChange.emit(finalState)
485 finalState = None # Mark it as already set
486 self._process_account_data(accountData)
489 loginOps = self._loginOps[:]
492 del self._loginOps[:]
493 for asyncOp, args, kwds in loginOps:
494 asyncOp.start(*args, **kwds)
496 self._loggedInTime = self._LOGGEDOUT_TIME
497 self.error.emit("Error logging in")
499 _moduleLogger.exception("Booh")
500 self._loggedInTime = self._LOGGEDOUT_TIME
501 _moduleLogger.exception("Reporting error to user")
502 self.error.emit(str(e))
504 if finalState is not None:
505 self.stateChange.emit(finalState)
506 if accountData is not None and self._callback:
507 self.set_callback_number(self._callback)
509 def _update_account(self):
511 with qui_utils.notify_busy(self._errorLog, "Updating Account"):
512 accountData = yield (
513 self._backend[0].refresh_account_info,
518 _moduleLogger.exception("Reporting error to user")
519 self.error.emit(str(e))
521 self._loggedInTime = int(time.time())
522 self._process_account_data(accountData)
524 def _refresh_authentication(self):
526 with qui_utils.notify_busy(self._errorLog, "Updating Account"):
527 accountData = yield (
528 self._backend[0].refresh_account_info,
534 _moduleLogger.exception("Passing to user")
535 self.error.emit(str(e))
536 # refresh_account_info does not normally throw, so it is fine if we
537 # just quit early because something seriously wrong is going on
540 if accountData is not None:
541 self._loggedInTime = int(time.time())
542 self._process_account_data(accountData)
544 self._delayedRelogin.start()
547 updateMessages = len(self._messages) != 0
548 updateHistory = len(self._history) != 0
550 oldCallback = self._callback
553 self._cleanMessages = []
558 loadedFromCache = self._load_from_cache()
560 updateMessages = True
564 self.messagesUpdated.emit()
566 self.historyUpdated.emit()
567 if oldDnd != self._dnd:
568 self.dndStateChange.emit(self._dnd)
569 if oldCallback != self._callback:
570 self.callbackNumberChanged.emit(self._callback)
572 return loadedFromCache
574 def _load_from_cache(self):
575 if self._cachePath is None:
577 cachePath = os.path.join(self._cachePath, "%s.cache" % self._username)
580 with open(cachePath, "rb") as f:
581 dumpedData = pickle.load(f)
582 except (pickle.PickleError, IOError, EOFError, ValueError, ImportError):
583 _moduleLogger.exception("Pickle fun loading")
586 _moduleLogger.exception("Weirdness loading")
590 version, build = dumpedData[0:2]
592 _moduleLogger.exception("Upgrade/downgrade fun")
595 _moduleLogger.exception("Weirdlings")
598 if misc_utils.compare_versions(
599 self._OLDEST_COMPATIBLE_FORMAT_VERSION,
600 misc_utils.parse_version(version),
605 messages, messageUpdateTime,
606 history, historyUpdateTime,
610 _moduleLogger.exception("Upgrade/downgrade fun")
613 _moduleLogger.exception("Weirdlings")
616 _moduleLogger.info("Loaded cache")
617 self._messages = messages
618 self._alert_on_messages(self._messages)
619 self._messageUpdateTime = messageUpdateTime
620 self._history = history
621 self._historyUpdateTime = historyUpdateTime
623 self._callback = callback
627 "Skipping cache due to version mismatch (%s-%s)" % (
633 def _save_to_cache(self):
634 _moduleLogger.info("Saving cache")
635 if self._cachePath is None:
637 cachePath = os.path.join(self._cachePath, "%s.cache" % self._username)
641 constants.__version__, constants.__build__,
642 self._messages, self._messageUpdateTime,
643 self._history, self._historyUpdateTime,
644 self._dnd, self._callback
646 with open(cachePath, "wb") as f:
647 pickle.dump(dataToDump, f, pickle.HIGHEST_PROTOCOL)
648 _moduleLogger.info("Cache saved")
649 except (pickle.PickleError, IOError):
650 _moduleLogger.exception("While saving")
652 def _clear_cache(self):
653 updateMessages = len(self._messages) != 0
654 updateHistory = len(self._history) != 0
656 oldCallback = self._callback
659 self._messageUpdateTime = datetime.datetime(1971, 1, 1)
661 self._historyUpdateTime = datetime.datetime(1971, 1, 1)
666 self.messagesUpdated.emit()
668 self.historyUpdated.emit()
669 if oldDnd != self._dnd:
670 self.dndStateChange.emit(self._dnd)
671 if oldCallback != self._callback:
672 self.callbackNumberChanged.emit(self._callback)
674 self._save_to_cache()
675 self._clear_voicemail_cache()
677 def _clear_voicemail_cache(self):
679 shutil.rmtree(self._voicemailCachePath, True)
681 def _update_messages(self, messageType):
683 assert self.state == self.LOGGEDIN_STATE, "Messages requires being logged in (currently %s" % self.state
684 with qui_utils.notify_busy(self._errorLog, "Updating %s Messages" % messageType):
685 self._messages = yield (
686 self._backend[0].get_messages,
691 _moduleLogger.exception("Reporting error to user")
692 self.error.emit(str(e))
694 self._messageUpdateTime = datetime.datetime.now()
695 self.messagesUpdated.emit()
696 self._alert_on_messages(self._messages)
698 def _update_history(self, historyType):
700 assert self.state == self.LOGGEDIN_STATE, "History requires being logged in (currently %s" % self.state
701 with qui_utils.notify_busy(self._errorLog, "Updating '%s' History" % historyType):
702 self._history = yield (
703 self._backend[0].get_call_history,
708 _moduleLogger.exception("Reporting error to user")
709 self.error.emit(str(e))
711 self._historyUpdateTime = datetime.datetime.now()
712 self.historyUpdated.emit()
714 def _update_dnd(self):
715 with qui_utils.notify_busy(self._errorLog, "Updating Do-Not-Disturb Status"):
718 assert self.state == self.LOGGEDIN_STATE, "DND requires being logged in (currently %s" % self.state
720 self._backend[0].is_dnd,
725 _moduleLogger.exception("Reporting error to user")
726 self.error.emit(str(e))
728 if oldDnd != self._dnd:
729 self.dndStateChange(self._dnd)
731 def _download_voicemail(self, messageId):
732 actualPath = os.path.join(self._voicemailCachePath, "%s.mp3" % messageId)
733 targetPath = "%s.%s.part" % (actualPath, time.time())
734 if os.path.exists(actualPath):
735 self.voicemailAvailable.emit(messageId, actualPath)
737 with qui_utils.notify_busy(self._errorLog, "Downloading Voicemail"):
740 self._backend[0].download,
741 (messageId, targetPath),
745 _moduleLogger.exception("Passing to user")
746 self.error.emit(str(e))
749 if os.path.exists(actualPath):
751 os.remove(targetPath)
753 _moduleLogger.exception("Ignoring file problems with cache")
754 self.voicemailAvailable.emit(messageId, actualPath)
757 os.rename(targetPath, actualPath)
758 self.voicemailAvailable.emit(messageId, actualPath)
760 def _perform_op_while_loggedin(self, op):
761 if self.state == self.LOGGEDIN_STATE:
763 op.start(*args, **kwds)
765 self._push_login_op(op)
767 def _push_login_op(self, asyncOp):
768 assert self.state != self.LOGGEDIN_STATE, "Can only queue work when logged out"
769 if asyncOp in self._loginOps:
770 _moduleLogger.info("Skipping queueing duplicate op: %r" % asyncOp)
772 self._loginOps.append(asyncOp)
774 def _process_account_data(self, accountData):
775 self._contacts = dict(
776 (contactId, contactDetails)
777 for contactId, contactDetails in accountData["contacts"].iteritems()
778 # A zero contact id is the catch all for unknown contacts
782 self._accountUpdateTime = datetime.datetime.now()
783 self.accountUpdated.emit()
785 def _alert_on_messages(self, messages):
786 cleanNewMessages = list(self._clean_messages(messages))
787 cleanNewMessages.sort(key=lambda m: m["contactId"])
788 if self._cleanMessages:
789 if self._cleanMessages != cleanNewMessages:
790 self.newMessages.emit()
791 self._cleanMessages = cleanNewMessages
793 def _clean_messages(self, messages):
794 for message in messages:
797 for kv in message.iteritems()
809 # Don't let outbound messages cause alerts, especially if the package has only outbound
810 cleaned["messageParts"] = [
811 tuple(part[0:-1]) for part in cleaned["messageParts"] if part[0] != "Me:"
813 if not cleaned["messageParts"]:
818 @misc_utils.log_exception(_moduleLogger)
819 def _on_delayed_relogin(self):
821 username = self._username
822 password = self._password
824 self.login(username, password)
826 _moduleLogger.exception("Passing to user")
827 self.error.emit(str(e))