Making it possible to do partial message refreshes
[gc-dialer] / src / session.py
1 from __future__ import with_statement
2
3 import os
4 import time
5 import datetime
6 import contextlib
7 import logging
8
9 try:
10         import cPickle
11         pickle = cPickle
12 except ImportError:
13         import pickle
14
15 from PyQt4 import QtCore
16
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
21
22 import constants
23
24
25 _moduleLogger = logging.getLogger(__name__)
26
27
28 class _DraftContact(object):
29
30         def __init__(self, title, description, numbersWithDescriptions):
31                 self.title = title
32                 self.description = description
33                 self.numbers = numbersWithDescriptions
34                 self.selectedNumber = numbersWithDescriptions[0][0]
35
36
37 class Draft(QtCore.QObject):
38
39         sendingMessage = QtCore.pyqtSignal()
40         sentMessage = QtCore.pyqtSignal()
41         calling = QtCore.pyqtSignal()
42         called = QtCore.pyqtSignal()
43         cancelling = QtCore.pyqtSignal()
44         cancelled = QtCore.pyqtSignal()
45         error = QtCore.pyqtSignal(str)
46
47         recipientsChanged = QtCore.pyqtSignal()
48
49         def __init__(self, pool, backend, errorLog):
50                 QtCore.QObject.__init__(self)
51                 self._errorLog = errorLog
52                 self._contacts = {}
53                 self._pool = pool
54                 self._backend = backend
55                 self._busyReason = None
56                 self._message = ""
57
58         def send(self):
59                 assert 0 < len(self._contacts), "No contacts selected"
60                 assert 0 < len(self._message), "No message to send"
61                 numbers = [misc_utils.make_ugly(contact.selectedNumber) for contact in self._contacts.itervalues()]
62                 le = concurrent.AsyncLinearExecution(self._pool, self._send)
63                 le.start(numbers, self._message)
64
65         def call(self):
66                 assert len(self._contacts) == 1, "Must select 1 and only 1 contact"
67                 assert len(self._message) == 0, "Cannot send message with call"
68                 (contact, ) = self._contacts.itervalues()
69                 number = misc_utils.make_ugly(contact.selectedNumber)
70                 le = concurrent.AsyncLinearExecution(self._pool, self._call)
71                 le.start(number)
72
73         def cancel(self):
74                 le = concurrent.AsyncLinearExecution(self._pool, self._cancel)
75                 le.start()
76
77         def _get_message(self):
78                 return self._message
79
80         def _set_message(self, message):
81                 self._message = message
82
83         message = property(_get_message, _set_message)
84
85         def add_contact(self, contactId, title, description, numbersWithDescriptions):
86                 if self._busyReason is not None:
87                         raise RuntimeError("Please wait for %r" % self._busyReason)
88                 # Allow overwriting of contacts so that the message can be updated and the SMS dialog popped back up
89                 contactDetails = _DraftContact(title, description, numbersWithDescriptions)
90                 self._contacts[contactId] = contactDetails
91                 self.recipientsChanged.emit()
92
93         def remove_contact(self, contactId):
94                 if self._busyReason is not None:
95                         raise RuntimeError("Please wait for %r" % self._busyReason)
96                 assert contactId in self._contacts, "Contact missing"
97                 del self._contacts[contactId]
98                 self.recipientsChanged.emit()
99
100         def get_contacts(self):
101                 return self._contacts.iterkeys()
102
103         def get_num_contacts(self):
104                 return len(self._contacts)
105
106         def get_title(self, cid):
107                 return self._contacts[cid].title
108
109         def get_description(self, cid):
110                 return self._contacts[cid].description
111
112         def get_numbers(self, cid):
113                 return self._contacts[cid].numbers
114
115         def get_selected_number(self, cid):
116                 return self._contacts[cid].selectedNumber
117
118         def set_selected_number(self, cid, number):
119                 # @note I'm lazy, this isn't firing any kind of signal since only one
120                 # controller right now and that is the viewer
121                 assert number in (nWD[0] for nWD in self._contacts[cid].numbers), "Number not selectable"
122                 self._contacts[cid].selectedNumber = number
123
124         def clear(self):
125                 if self._busyReason is not None:
126                         raise RuntimeError("Please wait for %r" % self._busyReason)
127                 self._clear()
128
129         def _clear(self):
130                 oldContacts = self._contacts
131                 self._contacts = {}
132                 self._message = ""
133                 if oldContacts:
134                         self.recipientsChanged.emit()
135
136         @contextlib.contextmanager
137         def _busy(self, message):
138                 if self._busyReason is not None:
139                         raise RuntimeError("Already busy doing %r" % self._busyReason)
140                 try:
141                         self._busyReason = message
142                         yield
143                 finally:
144                         self._busyReason = None
145
146         def _send(self, numbers, text):
147                 self.sendingMessage.emit()
148                 try:
149                         with self._busy("Sending Text"):
150                                 with qui_utils.notify_busy(self._errorLog, "Sending Text"):
151                                         yield (
152                                                 self._backend[0].send_sms,
153                                                 (numbers, text),
154                                                 {},
155                                         )
156                                 self.sentMessage.emit()
157                                 self._clear()
158                 except Exception, e:
159                         _moduleLogger.exception("Reporting error to user")
160                         self.error.emit(str(e))
161
162         def _call(self, number):
163                 self.calling.emit()
164                 try:
165                         with self._busy("Calling"):
166                                 with qui_utils.notify_busy(self._errorLog, "Calling"):
167                                         yield (
168                                                 self._backend[0].call,
169                                                 (number, ),
170                                                 {},
171                                         )
172                                 self.called.emit()
173                                 self._clear()
174                 except Exception, e:
175                         _moduleLogger.exception("Reporting error to user")
176                         self.error.emit(str(e))
177
178         def _cancel(self):
179                 self.cancelling.emit()
180                 try:
181                         with qui_utils.notify_busy(self._errorLog, "Cancelling"):
182                                 yield (
183                                         self._backend[0].cancel,
184                                         (),
185                                         {},
186                                 )
187                         self.cancelled.emit()
188                 except Exception, e:
189                         _moduleLogger.exception("Reporting error to user")
190                         self.error.emit(str(e))
191
192
193 class Session(QtCore.QObject):
194
195         # @todo Somehow add support for csv contacts
196
197         stateChange = QtCore.pyqtSignal(str)
198         loggedOut = QtCore.pyqtSignal()
199         loggedIn = QtCore.pyqtSignal()
200         callbackNumberChanged = QtCore.pyqtSignal(str)
201
202         accountUpdated = QtCore.pyqtSignal()
203         messagesUpdated = QtCore.pyqtSignal()
204         newMessages = QtCore.pyqtSignal()
205         historyUpdated = QtCore.pyqtSignal()
206         dndStateChange = QtCore.pyqtSignal(bool)
207
208         error = QtCore.pyqtSignal(str)
209
210         LOGGEDOUT_STATE = "logged out"
211         LOGGINGIN_STATE = "logging in"
212         LOGGEDIN_STATE = "logged in"
213
214         _OLDEST_COMPATIBLE_FORMAT_VERSION = misc_utils.parse_version("1.3.0")
215
216         _LOGGEDOUT_TIME = -1
217         _LOGGINGIN_TIME = 0
218
219         def __init__(self, errorLog, cachePath = None):
220                 QtCore.QObject.__init__(self)
221                 self._errorLog = errorLog
222                 self._pool = qore_utils.AsyncPool()
223                 self._backend = []
224                 self._loggedInTime = self._LOGGEDOUT_TIME
225                 self._loginOps = []
226                 self._cachePath = cachePath
227                 self._username = None
228                 self._draft = Draft(self._pool, self._backend, self._errorLog)
229
230                 self._contacts = {}
231                 self._accountUpdateTime = datetime.datetime(1971, 1, 1)
232                 self._messages = []
233                 self._cleanMessages = []
234                 self._messageUpdateTime = datetime.datetime(1971, 1, 1)
235                 self._history = []
236                 self._historyUpdateTime = datetime.datetime(1971, 1, 1)
237                 self._dnd = False
238                 self._callback = ""
239
240         @property
241         def state(self):
242                 return {
243                         self._LOGGEDOUT_TIME: self.LOGGEDOUT_STATE,
244                         self._LOGGINGIN_TIME: self.LOGGINGIN_STATE,
245                 }.get(self._loggedInTime, self.LOGGEDIN_STATE)
246
247         @property
248         def draft(self):
249                 return self._draft
250
251         def login(self, username, password):
252                 assert self.state == self.LOGGEDOUT_STATE, "Can only log-in when logged out (currently %s" % self.state
253                 assert username != "", "No username specified"
254                 if self._cachePath is not None:
255                         cookiePath = os.path.join(self._cachePath, "%s.cookies" % username)
256                 else:
257                         cookiePath = None
258
259                 if self._username != username or not self._backend:
260                         from backends import gv_backend
261                         del self._backend[:]
262                         self._backend[0:0] = [gv_backend.GVDialer(cookiePath)]
263
264                 self._pool.start()
265                 le = concurrent.AsyncLinearExecution(self._pool, self._login)
266                 le.start(username, password)
267
268         def logout(self):
269                 assert self.state != self.LOGGEDOUT_STATE, "Can only logout if logged in (currently %s" % self.state
270                 _moduleLogger.info("Logging out")
271                 self._pool.stop()
272                 self._loggedInTime = self._LOGGEDOUT_TIME
273                 self._backend[0].persist()
274                 self._save_to_cache()
275                 self.stateChange.emit(self.LOGGEDOUT_STATE)
276                 self.loggedOut.emit()
277
278         def clear(self):
279                 assert self.state == self.LOGGEDOUT_STATE, "Can only clear when logged out (currently %s" % self.state
280                 self._backend[0].logout()
281                 del self._backend[0]
282                 self._clear_cache()
283                 self._draft.clear()
284
285         def logout_and_clear(self):
286                 assert self.state != self.LOGGEDOUT_STATE, "Can only logout if logged in (currently %s" % self.state
287                 _moduleLogger.info("Logging out and clearing the account")
288                 self._pool.stop()
289                 self._loggedInTime = self._LOGGEDOUT_TIME
290                 self.clear()
291                 self.stateChange.emit(self.LOGGEDOUT_STATE)
292                 self.loggedOut.emit()
293
294         def update_account(self, force = True):
295                 if not force and self._contacts:
296                         return
297                 le = concurrent.AsyncLinearExecution(self._pool, self._update_account)
298                 self._perform_op_while_loggedin(le)
299
300         def get_contacts(self):
301                 return self._contacts
302
303         def get_when_contacts_updated(self):
304                 return self._accountUpdateTime
305
306         def update_messages(self, force = True):
307                 if not force and self._messages:
308                         return
309                 le = concurrent.AsyncLinearExecution(self._pool, self._update_messages)
310                 self._perform_op_while_loggedin(le)
311
312         def get_messages(self):
313                 return self._messages
314
315         def get_when_messages_updated(self):
316                 return self._messageUpdateTime
317
318         def update_history(self, force = True):
319                 if not force and self._history:
320                         return
321                 le = concurrent.AsyncLinearExecution(self._pool, self._update_history)
322                 self._perform_op_while_loggedin(le)
323
324         def get_history(self):
325                 return self._history
326
327         def get_when_history_updated(self):
328                 return self._historyUpdateTime
329
330         def update_dnd(self):
331                 le = concurrent.AsyncLinearExecution(self._pool, self._update_dnd)
332                 self._perform_op_while_loggedin(le)
333
334         def set_dnd(self, dnd):
335                 le = concurrent.AsyncLinearExecution(self._pool, self._set_dnd)
336                 le.start(dnd)
337
338         def _set_dnd(self, dnd):
339                 oldDnd = self._dnd
340                 try:
341                         assert self.state == self.LOGGEDIN_STATE, "DND requires being logged in (currently %s" % self.state
342                         with qui_utils.notify_busy(self._errorLog, "Setting DND Status"):
343                                 yield (
344                                         self._backend[0].set_dnd,
345                                         (dnd, ),
346                                         {},
347                                 )
348                 except Exception, e:
349                         _moduleLogger.exception("Reporting error to user")
350                         self.error.emit(str(e))
351                         return
352                 self._dnd = dnd
353                 if oldDnd != self._dnd:
354                         self.dndStateChange.emit(self._dnd)
355
356         def get_dnd(self):
357                 return self._dnd
358
359         def get_account_number(self):
360                 if self.state != self.LOGGEDIN_STATE:
361                         return ""
362                 return self._backend[0].get_account_number()
363
364         def get_callback_numbers(self):
365                 if self.state != self.LOGGEDIN_STATE:
366                         return {}
367                 return self._backend[0].get_callback_numbers()
368
369         def get_callback_number(self):
370                 return self._callback
371
372         def set_callback_number(self, callback):
373                 le = concurrent.AsyncLinearExecution(self._pool, self._set_callback_number)
374                 le.start(callback)
375
376         def _set_callback_number(self, callback):
377                 oldCallback = self._callback
378                 try:
379                         assert self.state == self.LOGGEDIN_STATE, "Callbacks configurable only when logged in (currently %s" % self.state
380                         yield (
381                                 self._backend[0].set_callback_number,
382                                 (callback, ),
383                                 {},
384                         )
385                 except Exception, e:
386                         _moduleLogger.exception("Reporting error to user")
387                         self.error.emit(str(e))
388                         return
389                 self._callback = callback
390                 if oldCallback != self._callback:
391                         self.callbackNumberChanged.emit(self._callback)
392
393         def _login(self, username, password):
394                 with qui_utils.notify_busy(self._errorLog, "Logging In"):
395                         self._loggedInTime = self._LOGGINGIN_TIME
396                         self.stateChange.emit(self.LOGGINGIN_STATE)
397                         finalState = self.LOGGEDOUT_STATE
398                         accountData = None
399                         try:
400                                 if accountData is None and self._backend[0].is_quick_login_possible():
401                                         accountData = yield (
402                                                 self._backend[0].refresh_account_info,
403                                                 (),
404                                                 {},
405                                         )
406                                         if accountData is not None:
407                                                 _moduleLogger.info("Logged in through cookies")
408                                         else:
409                                                 # Force a clearing of the cookies
410                                                 yield (
411                                                         self._backend[0].logout,
412                                                         (),
413                                                         {},
414                                                 )
415
416                                 if accountData is None:
417                                         accountData = yield (
418                                                 self._backend[0].login,
419                                                 (username, password),
420                                                 {},
421                                         )
422                                         if accountData is not None:
423                                                 _moduleLogger.info("Logged in through credentials")
424
425                                 if accountData is not None:
426                                         self._loggedInTime = int(time.time())
427                                         oldUsername = self._username
428                                         self._username = username
429                                         finalState = self.LOGGEDIN_STATE
430                                         if oldUsername != self._username:
431                                                 needOps = not self._load()
432                                         else:
433                                                 needOps = True
434
435                                         self.loggedIn.emit()
436                                         self.stateChange.emit(finalState)
437                                         finalState = None # Mark it as already set
438                                         self._process_account_data(accountData)
439
440                                         if needOps:
441                                                 loginOps = self._loginOps[:]
442                                         else:
443                                                 loginOps = []
444                                         del self._loginOps[:]
445                                         for asyncOp in loginOps:
446                                                 asyncOp.start()
447                                 else:
448                                         self._loggedInTime = self._LOGGEDOUT_TIME
449                                         self.error.emit("Error logging in")
450                         except Exception, e:
451                                 _moduleLogger.exception("Booh")
452                                 self._loggedInTime = self._LOGGEDOUT_TIME
453                                 _moduleLogger.exception("Reporting error to user")
454                                 self.error.emit(str(e))
455                         finally:
456                                 if finalState is not None:
457                                         self.stateChange.emit(finalState)
458                         if accountData is not None and self._callback:
459                                 self.set_callback_number(self._callback)
460
461         def _load(self):
462                 updateMessages = len(self._messages) != 0
463                 updateHistory = len(self._history) != 0
464                 oldDnd = self._dnd
465                 oldCallback = self._callback
466
467                 self._messages = []
468                 self._cleanMessages = []
469                 self._history = []
470                 self._dnd = False
471                 self._callback = ""
472
473                 loadedFromCache = self._load_from_cache()
474                 if loadedFromCache:
475                         updateMessages = True
476                         updateHistory = True
477
478                 if updateMessages:
479                         self.messagesUpdated.emit()
480                 if updateHistory:
481                         self.historyUpdated.emit()
482                 if oldDnd != self._dnd:
483                         self.dndStateChange.emit(self._dnd)
484                 if oldCallback != self._callback:
485                         self.callbackNumberChanged.emit(self._callback)
486
487                 return loadedFromCache
488
489         def _load_from_cache(self):
490                 if self._cachePath is None:
491                         return False
492                 cachePath = os.path.join(self._cachePath, "%s.cache" % self._username)
493
494                 try:
495                         with open(cachePath, "rb") as f:
496                                 dumpedData = pickle.load(f)
497                 except (pickle.PickleError, IOError, EOFError, ValueError, ImportError):
498                         _moduleLogger.exception("Pickle fun loading")
499                         return False
500                 except:
501                         _moduleLogger.exception("Weirdness loading")
502                         return False
503
504                 try:
505                         version, build = dumpedData[0:2]
506                 except ValueError:
507                         _moduleLogger.exception("Upgrade/downgrade fun")
508                         return False
509                 except:
510                         _moduleLogger.exception("Weirdlings")
511                         return False
512
513                 if misc_utils.compare_versions(
514                         self._OLDEST_COMPATIBLE_FORMAT_VERSION,
515                         misc_utils.parse_version(version),
516                 ) <= 0:
517                         try:
518                                 (
519                                         version, build,
520                                         messages, messageUpdateTime,
521                                         history, historyUpdateTime,
522                                         dnd, callback
523                                 ) = dumpedData
524                         except ValueError:
525                                 _moduleLogger.exception("Upgrade/downgrade fun")
526                                 return False
527                         except:
528                                 _moduleLogger.exception("Weirdlings")
529                                 return False
530
531                         _moduleLogger.info("Loaded cache")
532                         self._messages = messages
533                         self._alert_on_messages(self._messages)
534                         self._messageUpdateTime = messageUpdateTime
535                         self._history = history
536                         self._historyUpdateTime = historyUpdateTime
537                         self._dnd = dnd
538                         self._callback = callback
539                         return True
540                 else:
541                         _moduleLogger.debug(
542                                 "Skipping cache due to version mismatch (%s-%s)" % (
543                                         version, build
544                                 )
545                         )
546                         return False
547
548         def _save_to_cache(self):
549                 _moduleLogger.info("Saving cache")
550                 if self._cachePath is None:
551                         return
552                 cachePath = os.path.join(self._cachePath, "%s.cache" % self._username)
553
554                 try:
555                         dataToDump = (
556                                 constants.__version__, constants.__build__,
557                                 self._messages, self._messageUpdateTime,
558                                 self._history, self._historyUpdateTime,
559                                 self._dnd, self._callback
560                         )
561                         with open(cachePath, "wb") as f:
562                                 pickle.dump(dataToDump, f, pickle.HIGHEST_PROTOCOL)
563                         _moduleLogger.info("Cache saved")
564                 except (pickle.PickleError, IOError):
565                         _moduleLogger.exception("While saving")
566
567         def _clear_cache(self):
568                 updateMessages = len(self._messages) != 0
569                 updateHistory = len(self._history) != 0
570                 oldDnd = self._dnd
571                 oldCallback = self._callback
572
573                 self._messages = []
574                 self._messageUpdateTime = datetime.datetime(1971, 1, 1)
575                 self._history = []
576                 self._historyUpdateTime = datetime.datetime(1971, 1, 1)
577                 self._dnd = False
578                 self._callback = ""
579
580                 if updateMessages:
581                         self.messagesUpdated.emit()
582                 if updateHistory:
583                         self.historyUpdated.emit()
584                 if oldDnd != self._dnd:
585                         self.dndStateChange.emit(self._dnd)
586                 if oldCallback != self._callback:
587                         self.callbackNumberChanged.emit(self._callback)
588
589                 self._save_to_cache()
590
591         def _update_account(self):
592                 try:
593                         assert self.state == self.LOGGEDIN_STATE, "Contacts requires being logged in (currently %s)" % self.state
594                         with qui_utils.notify_busy(self._errorLog, "Updating Account"):
595                                 accountData = yield (
596                                         self._backend[0].refresh_account_info,
597                                         (),
598                                         {},
599                                 )
600                 except Exception, e:
601                         _moduleLogger.exception("Reporting error to user")
602                         self.error.emit(str(e))
603                         return
604                 self._process_account_data(accountData)
605
606         def _update_messages(self):
607                 try:
608                         assert self.state == self.LOGGEDIN_STATE, "Messages requires being logged in (currently %s" % self.state
609                         with qui_utils.notify_busy(self._errorLog, "Updating Messages"):
610                                 self._messages = yield (
611                                         self._backend[0].get_messages,
612                                         (self._backend[0].MESSAGE_ALL, ),
613                                         {},
614                                 )
615                 except Exception, e:
616                         _moduleLogger.exception("Reporting error to user")
617                         self.error.emit(str(e))
618                         return
619                 self._messageUpdateTime = datetime.datetime.now()
620                 self.messagesUpdated.emit()
621                 self._alert_on_messages(self._messages)
622
623         def _update_history(self):
624                 try:
625                         assert self.state == self.LOGGEDIN_STATE, "History requires being logged in (currently %s" % self.state
626                         with qui_utils.notify_busy(self._errorLog, "Updating History"):
627                                 self._history = yield (
628                                         self._backend[0].get_recent,
629                                         (),
630                                         {},
631                                 )
632                 except Exception, e:
633                         _moduleLogger.exception("Reporting error to user")
634                         self.error.emit(str(e))
635                         return
636                 self._historyUpdateTime = datetime.datetime.now()
637                 self.historyUpdated.emit()
638
639         def _update_dnd(self):
640                 oldDnd = self._dnd
641                 try:
642                         assert self.state == self.LOGGEDIN_STATE, "DND requires being logged in (currently %s" % self.state
643                         self._dnd = yield (
644                                 self._backend[0].is_dnd,
645                                 (),
646                                 {},
647                         )
648                 except Exception, e:
649                         _moduleLogger.exception("Reporting error to user")
650                         self.error.emit(str(e))
651                         return
652                 if oldDnd != self._dnd:
653                         self.dndStateChange(self._dnd)
654
655         def _perform_op_while_loggedin(self, op):
656                 if self.state == self.LOGGEDIN_STATE:
657                         op.start()
658                 else:
659                         self._push_login_op(op)
660
661         def _push_login_op(self, asyncOp):
662                 assert self.state != self.LOGGEDIN_STATE, "Can only queue work when logged out"
663                 if asyncOp in self._loginOps:
664                         _moduleLogger.info("Skipping queueing duplicate op: %r" % asyncOp)
665                         return
666                 self._loginOps.append(asyncOp)
667
668         def _process_account_data(self, accountData):
669                 self._contacts = dict(
670                         (contactId, contactDetails)
671                         for contactId, contactDetails in accountData["contacts"].iteritems()
672                         # A zero contact id is the catch all for unknown contacts
673                         if contactId != "0"
674                 )
675
676                 self._accountUpdateTime = datetime.datetime.now()
677                 self.accountUpdated.emit()
678
679         def _alert_on_messages(self, messages):
680                 cleanNewMessages = list(self._clean_messages(messages))
681                 cleanNewMessages.sort(key=lambda m: m["contactId"])
682                 if self._cleanMessages:
683                         if self._cleanMessages != cleanNewMessages:
684                                 self.newMessages.emit()
685                 self._cleanMessages = cleanNewMessages
686
687         def _clean_messages(self, messages):
688                 for message in messages:
689                         cleaned = dict(
690                                 kv
691                                 for kv in message.iteritems()
692                                 if kv[0] not in
693                                 [
694                                         "relTime",
695                                         "time",
696                                         "isArchived",
697                                         "isRead",
698                                         "isSpam",
699                                         "isTrash",
700                                 ]
701                         )
702
703                         # Don't let outbound messages cause alerts, especially if the package has only outbound
704                         cleaned["messageParts"] = [
705                                 tuple(part[0:-1]) for part in cleaned["messageParts"] if part[0] != "Me:"
706                         ]
707                         if not cleaned["messageParts"]:
708                                 continue
709
710                         yield cleaned