diff --git a/INSTALL.md b/INSTALL.md index 29cb6ca..6c30fb4 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -63,6 +63,9 @@ Create a new file `/etc/spectrum2/transports/whatsapp.cfg` with the following co [logging] config = /etc/spectrum2/logging.cfg backend_config = /etc/spectrum2/backend-logging.cfg + + [database] + type = sqlite3 ## transWhat @@ -74,7 +77,7 @@ Checkout the latest version of transWhat from GitHub: Install required dependencies: - $ pip install --pre e4u protobuf python-dateutil yowsup + $ pip install --pre e4u protobuf python-dateutil yowsup2 - **e4u**: is a simple emoji4unicode python bindings - [**yowsup**](https://github.com/tgalal/yowsup): is a python library that enables you build application which use WhatsApp service. diff --git a/Spectrum2/backend.py b/Spectrum2/backend.py index feb2e94..b2e5891 100644 --- a/Spectrum2/backend.py +++ b/Spectrum2/backend.py @@ -222,13 +222,29 @@ class SpectrumBackend: message = WRAP(d.SerializeToString(), protocol_pb2.WrapperMessage.TYPE_FT_DATA); self.send(message) - def handleBackendConfig(self, section, key, value): + def handleBackendConfig(self, data): + """ + data is a dictionary, whose keys are sections and values are a list of + tuples of configuration key and configuration value. + """ c = protocol_pb2.BackendConfig() - c.config = "[%s]\n%s = %s\n" % (section, key, value) + config = [] + for section, rest in data.items(): + config.append('[%s]' % section) + for key, value in rest: + config.append('%s = %s' % (key, value)) + + c.config = '\n'.join(config) message = WRAP(c.SerializeToString(), protocol_pb2.WrapperMessage.TYPE_BACKEND_CONFIG); self.send(message) + def handleQuery(self, command): + c = protocol_pb2.BackendConfig() + c.config = command + message = WRAP(c.SerializeToString(), protocol_pb2.WrapperMessage.TYPE_QUERY); + self.send(message) + def handleLoginPayload(self, data): payload = protocol_pb2.Login() if (payload.ParseFromString(data) == False): @@ -252,7 +268,6 @@ class SpectrumBackend: def handleConvMessagePayload(self, data): payload = protocol_pb2.ConversationMessage() - self.logger.error("handleConvMessagePayload") if (payload.ParseFromString(data) == False): #TODO: ERROR return diff --git a/USAGE.md b/USAGE.md index dcea058..67b4c8b 100644 --- a/USAGE.md +++ b/USAGE.md @@ -28,7 +28,6 @@ The bot is one of the contacts every user has in its contact list. It offers you | ------------ | --------------- | | `\help` | show this message | | `\prune` | clear your buddylist | -| `\sync` | sync your imported contacts with WhatsApp | | `\lastseen` | request last online timestamp from buddy | | `\leave` | permanently leave group chat | | `\groups` | print all attended groups | diff --git a/bot.py b/bot.py index dcd3708..631d82a 100644 --- a/bot.py +++ b/bot.py @@ -37,13 +37,12 @@ class Bot(): self.commands = { "help": self._help, "prune": self._prune, - "sync": self._sync, "groups": self._groups, "getgroups": self._getgroups } def parse(self, message): - args = message.split(" ") + args = message.strip().split(" ") cmd = args.pop(0) if cmd[0] == '\\': @@ -57,7 +56,7 @@ class Bot(): self.send("a valid command starts with a backslash") def call(self, cmd, args = []): - func = self.commands[cmd] + func = self.commands[cmd.lower()] spec = inspect.getargspec(func) maxs = len(spec.args) - 1 reqs = maxs - len(spec.defaults or []) @@ -71,23 +70,10 @@ class Bot(): self.session.backend.handleMessage(self.session.user, self.name, message) # commands - def _sync(self): - user = self.session.legacyName - password = self.session.password - - count = self.session.buddies.sync(user, password) - self.session.updateRoster() - - if count: - self.send("sync complete, %d buddies are using WhatsApp" % count) - else: - self.send("sync failed, sorry something went wrong") - def _help(self): self.send("""following bot commands are available: \\help show this message \\prune clear your buddylist -\\sync sync your imported contacts with WhatsApp following user commands are available: \\lastseen request last online timestamp from buddy diff --git a/buddy.py b/buddy.py index 0823163..d8d3da7 100644 --- a/buddy.py +++ b/buddy.py @@ -24,6 +24,12 @@ __email__ = "post@steffenvogel.de" from Spectrum2 import protocol_pb2 import logging +import time +import utils +import base64 + +import deferred +from deferred import call class Buddy(): @@ -33,11 +39,10 @@ class Buddy(): self.number = number self.groups = groups self.image_hash = image_hash if image_hash is not None else "" - self.statusMsg = "" + self.statusMsg = u"" self.lastseen = 0 self.presence = 0 - def update(self, nick, groups, image_hash): self.nick = nick self.groups = groups @@ -55,13 +60,12 @@ class BuddyList(dict): self.session = session self.user = user self.logger = logging.getLogger(self.__class__.__name__) - self.synced = False def _load(self, buddies): for buddy in buddies: number = buddy.buddyName nick = buddy.alias - statusMsg = buddy.statusMessage + statusMsg = buddy.statusMessage.decode('utf-8') groups = [g for g in buddy.group] image_hash = buddy.iconHash self[number] = Buddy(self.owner, number, nick, statusMsg, @@ -69,35 +73,45 @@ class BuddyList(dict): self.logger.debug("Update roster") -# old = self.buddies.keys() -# self.buddies.load() -# new = self.buddies.keys() -# contacts = new contacts = self.keys() + contacts.remove('bot') - if self.synced == False: - self.session.sendSync(contacts, delta = False, interactive = True) - self.synced = True + self.session.sendSync(contacts, delta=False, interactive=True, + success=self.onSync) -# add = set(new) - set(old) -# remove = set(old) - set(new) - -# self.logger.debug("Roster remove: %s", str(list(remove))) self.logger.debug("Roster add: %s", str(list(contacts))) -# for number in remove: -# self.backend.handleBuddyChanged(self.user, number, "", [], -# protocol_pb2.STATUS_NONE) -# self.backend.handleBuddyRemoved(self.user, number) -# self.unsubscribePresence(number) -# for number in contacts: buddy = self[number] - if number != 'bot': - self.backend.handleBuddyChanged(self.user, number, buddy.nick, - buddy.groups, protocol_pb2.STATUS_NONE, - iconHash = buddy.image_hash if buddy.image_hash is not None else "") - self.session.subscribePresence(number) + self.updateSpectrum(buddy) + + def onSync(self, existing, nonexisting, invalid): + """We should only presence subscribe to existing numbers""" + + for number in existing: + self.session.subscribePresence(number) + self.logger.debug("%s is requesting statuses of: %s", self.user, existing) + self.session.requestStatuses(existing, success = self.onStatus) + + self.logger.debug("Removing nonexisting buddies %s", nonexisting) + for number in nonexisting: + self.remove(number) + del self[number] + + self.logger.debug("Removing invalid buddies %s", invalid) + for number in invalid: + self.remove(number) + del self[number] + + def onStatus(self, contacts): + self.logger.debug("%s received statuses of: %s", self.user, contacts) + for number, (status, time) in contacts.iteritems(): + buddy = self[number] + if status is None: + buddy.statusMsg = "" + else: + buddy.statusMsg = utils.softToUni(status) + self.updateSpectrum(buddy) def load(self, buddies): @@ -111,23 +125,38 @@ class BuddyList(dict): buddy = self[number] buddy.update(nick, groups, image_hash) else: - self.session.sendSync([number], delta = True, interactive = True) - self.session.subscribePresence(number) buddy = Buddy(self.owner, number, nick, "", groups, image_hash) self[number] = buddy self.logger.debug("Roster add: %s", buddy) + self.session.sendSync([number], delta = True, interactive = True) + self.session.subscribePresence(number) + self.session.requestStatuses([number], success = self.onStatus) + if image_hash == "" or image_hash is None: + self.requestVCard(number) + self.updateSpectrum(buddy) + return buddy + def updateSpectrum(self, buddy): if buddy.presence == 0: status = protocol_pb2.STATUS_NONE elif buddy.presence == 'unavailable': status = protocol_pb2.STATUS_AWAY else: status = protocol_pb2.STATUS_ONLINE - self.backend.handleBuddyChanged(self.user, number, buddy.nick, - buddy.groups, status, - iconHash = buddy.image_hash if buddy.image_hash is not None else "") - return buddy + statusmsg = buddy.statusMsg + if buddy.lastseen != 0: + timestamp = time.localtime(buddy.lastseen) + statusmsg += time.strftime("\n Last seen: %a, %d %b %Y %H:%M:%S", timestamp) + + iconHash = buddy.image_hash if buddy.image_hash is not None else "" + + self.logger.debug("Updating buddy %s (%s) in %s, image_hash = %s", + buddy.nick, buddy.number, buddy.groups, iconHash) + self.logger.debug("Status Message: %s", statusmsg) + self.backend.handleBuddyChanged(self.user, buddy.number, buddy.nick, + buddy.groups, status, statusMessage=statusmsg, iconHash=iconHash) + def remove(self, number): try: @@ -141,3 +170,49 @@ class BuddyList(dict): return buddy except KeyError: return None + + def requestVCard(self, buddy, ID=None): + if buddy == self.user or buddy == self.user.split('@')[0]: + buddy = self.session.legacyName + + # Get profile picture + self.logger.debug('Requesting profile picture of %s', buddy) + response = deferred.Deferred() + # Error probably means image doesn't exist + error = deferred.Deferred() + self.session.requestProfilePicture(buddy, onSuccess=response.run, + onFailure=error.run) + response = response.arg(0) + + pictureData = response.pictureData() + # Send VCard + if ID != None: + call(self.logger.debug, 'Sending VCard (%s) with image id %s: %s', + ID, response.pictureId(), pictureData.then(base64.b64encode)) + call(self.backend.handleVCard, self.user, ID, buddy, "", "", + pictureData) + # If error + error.when(self.logger.debug, 'Sending VCard (%s) without image', ID) + error.when(self.backend.handleVCard, self.user, ID, buddy, "", "", "") + + # Send image hash + if not buddy == self.session.legacyName: + try: + obuddy = self[buddy] + nick = obuddy.nick + groups = obuddy.groups + except KeyError: + nick = "" + groups = [] + image_hash = pictureData.then(utils.sha1hash) + call(self.logger.debug, 'Image hash is %s', image_hash) + call(self.update, buddy, nick, groups, image_hash) + # No image + error.when(self.logger.debug, 'No image') + error.when(self.update, buddy, nick, groups, '') + + def refresh(self, number): + self.session.unsubscribePresence(number) + self.session.subscribePresence(number) + self.requestVCard(number) + self.session.requestStatuses([number], success = self.onStatus) diff --git a/deferred.py b/deferred.py new file mode 100644 index 0000000..e270c5d --- /dev/null +++ b/deferred.py @@ -0,0 +1,139 @@ +from functools import partial + +class Deferred(object): + """ + Represents a delayed computation. This is a more elegant way to deal with + callbacks. + + A Deferred object can be thought of as a computation whose value is yet to + be determined. We can manipulate the Deferred as if it where a regular + value by using the then method. Computations dependent on the Deferred will + only proceed when the run method is called. + + Attributes of a Deferred can be accessed directly as methods. The result of + calling these functions will be Deferred. + + Example: + image = Deferred() + getImageWithCallback(image.run) + image.then(displayFunc) + + colors = Deferred() + colors.append('blue') + colors.then(print) + colors.run(['red', 'green']) #=> ['red', 'green', 'blue'] + """ + + def __init__(self): + self.subscribers = [] + self.computed = False + self.args = None + self.kwargs = None + + def run(self, *args, **kwargs): + """ + Give a value to the deferred. Calling this method more than once will + result in a DeferredHasValue exception to be raised. + """ + if self.computed: + raise DeferredHasValue("Deferred object already has a value.") + else: + self.args = args + self.kwargs = kwargs + for func, deferred in self.subscribers: + deferred.run(func(*args, **kwargs)) + self.computed = True + + def then(self, func): + """ + Apply func to Deferred value. Returns a Deferred whose value will be + the result of applying func. + """ + result = Deferred() + if self.computed: + result.run(func(*self.args, **self.kwargs)) + else: + self.subscribers.append((func, result)) + return result + + def arg(self, n): + """ + Returns the nth positional argument of a deferred as a deferred + + Args: + n - the index of the positional argument + """ + def helper(*args, **kwargs): + return args[n] + return self.then(helper) + + def when(self, func, *args, **kwargs): + """ Calls when func(*args, **kwargs) when deferred gets a value """ + def helper(*args2, **kwargs2): + func(*args, **kwargs) + return self.then(helper) + + def __getattr__(self, method_name): + return getattr(Then(self), method_name) + + +class Then(object): + """ + Allows you to call methods on a Deferred. + + Example: + colors = Deferred() + Then(colors).append('blue') + colors.run(['red', 'green']) + colors.then(print) #=> ['red', 'green', 'blue'] + """ + def __init__(self, deferred): + self.deferred = deferred + + def __getattr__(self, name): + def tryCall(obj, *args, **kwargs): + if callable(obj): + return obj(*args, **kwargs) + else: + return obj + def helper(*args, **kwargs): + func = (lambda x: tryCall(getattr(x, name), *args, **kwargs)) + return self.deferred.then(func) + return helper + +def call(func, *args, **kwargs): + """ + Call a function with deferred arguments + + Example: + colors = Deferred() + colors.append('blue') + colors.run(['red', 'green']) + call(print, colors) #=> ['red', 'green', 'blue'] + call(print, 'hi', colors) #=> hi ['red', 'green', 'blue'] + """ + for i, c in enumerate(args): + if isinstance(c, Deferred): + # Function without deferred arguments + normalfunc = partial(func, *args[:i]) + # Function with deferred and possibly deferred arguments + def restfunc(*arg2, **kwarg2): + apply_deferred = partial(normalfunc, *arg2, **kwarg2) + return call(apply_deferred, *args[i + 1:], **kwargs) + return c.then(restfunc) + items = kwargs.items() + for i, (k, v) in enumerate(items): + if isinstance(v, Deferred): + # Function without deferred arguments + normalfunc = partial(func, *args, **dict(items[:i])) + # Function with deferred and possibly deferred arguments + def restfunc2(*arg2, **kwarg2): + apply_deferred = partial(normalfunc, *arg2, **kwarg2) + return call(apply_deferred, **dict(items[i + 1:])) + return v.then(restfunc2) + # No items deferred + return func(*args, **kwargs) + +class DeferredHasValue(Exception): + def __init__(self, string): + super(DeferredHasValue, self).__init__(string) diff --git a/group.py b/group.py index 8708fe7..224b183 100644 --- a/group.py +++ b/group.py @@ -21,14 +21,83 @@ __email__ = "post@steffenvogel.de" along with transWhat. If not, see . """ +from Spectrum2 import protocol_pb2 + class Group(): - def __init__(self, id, owner, subject, subjectOwner): + def __init__(self, id, owner, subject, subjectOwner, backend, user): self.id = id self.subject = subject self.subjectOwner = subjectOwner self.owner = owner self.joined = False + self.backend = backend + self.user = user self.nick = "me" - self.participants = [] + # Participants is a number -> nickname dict + self.participants = {} + + def addParticipants(self, participants, buddies, yourNumber): + """ + Adds participants to the group. + + Args: + - participants: (Iterable) phone numbers of participants + - buddies: (dict) Used to get the nicknames of the participants + - yourNumber: The number you are using + """ + for jid in participants: + number = jid.split('@')[0] + try: + nick = buddies[number].nick + except KeyError: + nick = number + if number == yourNumber: + nick = self.nick + if nick == "": + nick = number + self.participants[number] = nick + + def sendParticipantsToSpectrum(self, yourNumber): + for number, nick in self.participants.iteritems(): + if number == self.owner: + flags = protocol_pb2.PARTICIPANT_FLAG_MODERATOR + else: + flags = protocol_pb2.PARTICIPANT_FLAG_NONE + if number == yourNumber: + flags = flags | protocol_pb2.PARTICIPANT_FLAG_ME + + self._updateParticipant(number, flags, protocol_pb2.STATUS_ONLINE) + + def removeParticipants(self, participants): + for jid in participants: + number = jid.split('@')[0] + nick = self.participants[number] + flags = protocol_pb2.PARTICIPANT_FLAG_NONE + self._updateParticipant(number, flags, protocol_pb2.STATUS_NONE) + del self.participants[number] + + def leaveRoom(self): + for number in self.participants: + nick = self.participants[number] + flags = protocol_pb2.PARTICIPANT_FLAG_ROOM_NOT_FOUND + self._updateParticipant(number, flags, protocol_pb2.STATUS_NONE) + + def changeNick(self, number, new_nick): + if self.participants[number] == new_nick: + return + if number == self.owner: + flags = protocol_pb2.PARTICIPANT_FLAG_MODERATOR + else: + flags = protocol_pb2.PARTICIPANT_FLAG_NONE + self._updateParticipant(number, flags, protocol_pb2.STATUS_ONLINE, new_nick) + self.participants[number] = new_nick + + def _updateParticipant(self, number, flags, status, newNick = ""): + nick = self.participants[number] + # Notice the status message is the buddy's number + if self.joined: + self.backend.handleParticipantChanged( + self.user, nick, self.id, flags, + status, number, newname = newNick) diff --git a/reader.py b/reader.py deleted file mode 100644 index 1137973..0000000 --- a/reader.py +++ /dev/null @@ -1,48 +0,0 @@ -__author__ = "Steffen Vogel" -__copyright__ = "Copyright 2015, Steffen Vogel" -__license__ = "GPLv3" -__maintainer__ = "Steffen Vogel" -__email__ = "post@steffenvogel.de" - -""" - This file is part of transWhat - - transWhat is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - any later version. - - transwhat is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with transWhat. If not, see . -""" - -import time - -def get_token(number, timeout = 30): - file = open('tokens') - file.seek(-1, 2) - - count = 0 - while count < timeout: - line = file.readline() - - if line in ["", "\n"]: - time.sleep(1) - count += 1 - continue - else: - t, n, tk = line[:-1].split("\t") - - if (n == number): - file.close() - return tk - - file.close() - - -print get_token("4917696978528") diff --git a/registersession.py b/registersession.py new file mode 100644 index 0000000..2fc63ca --- /dev/null +++ b/registersession.py @@ -0,0 +1,147 @@ +from Spectrum2 import protocol_pb2 + +from yowsupwrapper import YowsupApp +import logging +import threadutils +import sys + +class RegisterSession(YowsupApp): + """ + A dummy Session object that is used to register a user to whatsapp + """ + WANT_CC = 0 + WANT_SMS = 1 + def __init__(self, backend, user, legacyName, extra): + self.user = user + self.number = legacyName + self.backend = backend + self.countryCode = '' + self.logger = logging.getLogger(self.__class__.__name__) + self.state = self.WANT_CC + + def login(self, password=""): + self.backend.handleConnected(self.user) + self.backend.handleBuddyChanged(self.user, 'bot', 'bot', + ['Admin'], protocol_pb2.STATUS_ONLINE) + self.backend.handleMessage(self.user, 'bot', + 'Please enter your country code') + + def sendMessageToWA(self, buddy, message, ID='', xhtml=''): + if buddy == 'bot' and self.state == self.WANT_CC: + try: + country_code = int(message.strip()) + except ValueError: + self.backend.handleMessage(self.user, 'bot', + 'Country code must be a number') + else: # Succeded in decoding country code + country_code = str(country_code) + if country_code != self.number[:len(country_code)]: + self.backend.handleMessage(self.user, + 'bot', 'Number does not start with provided country code') + else: + self.backend.handleMessage(self.user, 'bot', 'Requesting sms code') + self.logger.debug('Requesting SMS code for %s', self.user) + self.countryCode = country_code + self._requestSMSCodeNonBlock() + elif buddy == 'bot' and self.state == self.WANT_SMS: + code = message.strip() + if self._checkSMSFormat(code): + self._requestPassword(code) + else: + self.backend.handleMessage(self.user, + 'bot', 'Invalid code. Must be of the form XXX-XXX.') + else: + self.logger.warn('Unauthorised user (%s) attempting to send messages', + self.user) + self.backend.handleMessage(self.user, buddy, + 'You are not logged in yet. You can only send messages to bot.') + + def _checkSMSFormat(self, sms): + splitting = sms.split('-') + if len(splitting) != 2: + return False + a, b = splitting + if len(a) != 3 and len(b) != 3: + return False + try: + int(a) + int(b) + except ValueError: + return False + return True + + def _requestSMSCodeNonBlock(self): + number = self.number[len(self.countryCode):] + threadFunc = lambda: self.requestSMSCode(self.countryCode, number) + threadutils.runInThread(threadFunc, self._confirmation) + self.backend.handleMessage(self.user, 'bot', 'SMS Code Sent') + + def _confirmation(self, result): + self.state = self.WANT_SMS + resultStr = self._resultToString(result) + self.backend.handleMessage(self.user, 'bot', 'Response:') + self.backend.handleMessage(self.user, 'bot', resultStr) + self.backend.handleMessage(self.user, 'bot', 'Please enter SMS Code') + + def _requestPassword(self, smsCode): + cc = self.countryCode + number = self.number[len(cc):] + threadFunc = lambda: self.requestPassword(cc, number, smsCode) + threadutils.runInThread(threadFunc, self._gotPassword) + self.backend.handleMessage(self.user, 'bot', 'Getting Password') + + def _gotPassword(self, result): + resultStr = self._resultToString(result) + self.backend.handleMessage(self.user, 'bot', 'Response:') + self.backend.handleMessage(self.user, 'bot', resultStr) + self.backend.handleMessage(self.user, 'bot', 'Logging you in') + password = result['pw'] + self.backend.relogin(self.user, self.number, password, None) + + def _resultToString(self, result): + unistr = str if sys.version_info >= (3, 0) else unicode + out = [] + for k, v in result.items(): + if v is None: + continue + out.append("%s: %s" %(k, v.encode("utf-8") if type(v) is unistr else v)) + + return "\n".join(out) + + # Dummy methods. Whatsapp backend might call these, but they should have no + # effect + def logout(self): + pass + + def joinRoom(self, room, nickname): + pass + + def leaveRoom(self, room): + pass + + def changeStatusMessage(self, statusMessage): + pass + + def changeStatus(self, status): + pass + + def loadBuddies(self, buddies): + pass + + def updateBuddy(self, buddies): + pass + + def removeBuddy(self, buddies): + pass + + def sendTypingStarted(self, buddy): + pass + + def sendTypingStopped(self, buddy): + pass + + def requestVCard(self, buddy, ID): + pass + + def setProfilePicture(self, previewPicture, fullPicture = None): + pass diff --git a/session.py b/session.py index 6dfed66..f1f6c34 100644 --- a/session.py +++ b/session.py @@ -30,7 +30,6 @@ from PIL import Image import sys import os -from yowsup.common.tools import TimeTools from yowsup.layers.protocol_media.mediauploader import MediaUploader from yowsup.layers.protocol_media.mediadownloader import MediaDownloader @@ -40,6 +39,8 @@ from buddy import BuddyList from threading import Timer from group import Group from bot import Bot +import deferred +from deferred import call from yowsupwrapper import YowsupApp @@ -53,6 +54,7 @@ class MsgIDs: class Session(YowsupApp): + broadcast_prefix = u'\U0001F4E2 ' def __init__(self, backend, user, legacyName, extra): super(Session, self).__init__() @@ -144,9 +146,10 @@ class Session(YowsupApp): oroom.subjectOwner = subjectOwner oroom.subject = subject else: - self.groups[room] = Group(room, owner, subject, subjectOwner) + self.groups[room] = Group(room, owner, subject, subjectOwner, self.backend, self.user) # self.joinRoom(self._shortenGroupId(room), self.user.split("@")[0]) - self.groups[room].participants = group.getParticipants().keys() + self.groups[room].addParticipants(group.getParticipants().keys(), + self.buddies, self.legacyName) #self._addParticipantsToRoom(room, group.getParticipants()) @@ -173,13 +176,15 @@ class Session(YowsupApp): self.legacyName, room, nick) group = self.groups[room] + group.joined = True group.nick = nick + group.participants[self.legacyName] = nick try: - ownerNick = self.buddies[group.subjectOwner].nick + ownerNick = group.participants[group.subjectOwner] except KeyError: ownerNick = group.subjectOwner - self._refreshParticipants(room) + group.sendParticipantsToSpectrum(self.legacyName) self.backend.handleSubject(self.user, self._shortenGroupId(room), group.subject, ownerNick) self.logger.debug("Room subject: room=%s, subject=%s", @@ -187,7 +192,6 @@ class Session(YowsupApp): self.backend.handleRoomNicknameChanged( self.user, self._shortenGroupId(room), group.subject ) - group.joined = True else: self.logger.warn("Room doesn't exist: %s", room) @@ -199,29 +203,6 @@ class Session(YowsupApp): else: self.logger.warn("Room doesn't exist: %s. Unable to leave.", room) - def _refreshParticipants(self, room): - group = self.groups[room] - for jid in group.participants: - buddy = jid.split("@")[0] - self.logger.info("Added %s to room %s", buddy, room) - try: - nick = self.buddies[buddy].nick - except KeyError: - nick = buddy - if nick == "": - nick = buddy - - if buddy == group.owner: - flags = protocol_pb2.PARTICIPANT_FLAG_MODERATOR - else: - flags = protocol_pb2.PARTICIPANT_FLAG_NONE - if buddy == self.legacyName: - nick = group.nick - flags = flags | protocol_pb2.PARTICIPANT_FLAG_ME - self.backend.handleParticipantChanged( - self.user, nick, self._shortenGroupId(room), flags, - protocol_pb2.STATUS_ONLINE, buddy) - def _lastSeen(self, number, seconds): self.logger.debug("Last seen %s at %s seconds" % (number, str(seconds))) if seconds < 60: @@ -237,16 +218,26 @@ class Session(YowsupApp): self.backend.handleConnected(self.user) self.backend.handleBuddyChanged(self.user, "bot", self.bot.name, ["Admin"], protocol_pb2.STATUS_ONLINE) - if self.initialized == False: - self.sendOfflineMessages() - #self.bot.call("welcome") - self.initialized = True + # Initialisation? + self.requestPrivacyList() + self.requestClientConfig() + self.requestServerProperties() + # ? + + self.logger.debug('Requesting groups list') + self.requestGroupsList(self._updateGroups) + # self.requestBroadcastList() + + # This should handle, sync, statuses, and presence self.sendPresence(True) for func in self.loginQueue: func() - self.logger.debug('Requesting groups list') - self.requestGroupsList(self._updateGroups) + if self.initialized == False: + self.sendOfflineMessages() + #self.bot.call("welcome") + self.initialized = True + self.loggedIn = True # Called by superclass @@ -268,16 +259,13 @@ class Session(YowsupApp): type, participant, offline, items])) ) try: - buddy = self.buddies[_from.split('@')[0]] - #self.backend.handleBuddyChanged(self.user, buddy.number.number, - # buddy.nick, buddy.groups, protocol_pb2.STATUS_ONLINE) - self.backend.handleMessageAck(self.user, buddy.number, self.msgIDs[_id].xmppId) - self.msgIDs[_id].cnt = self.msgIDs[_id].cnt +1 - if self.msgIDs[_id].cnt == 2: - del self.msgIDs[_id] - + number = _from.split('@')[0] + self.backend.handleMessageAck(self.user, number, self.msgIDs[_id].xmppId) + self.msgIDs[_id].cnt = self.msgIDs[_id].cnt + 1 + if self.msgIDs[_id].cnt == 2: + del self.msgIDs[_id] except KeyError: - pass + self.logger.error("Message %s not found. Unable to send ack", _id) # Called by superclass def onAck(self, _id, _class, _from, timestamp): @@ -299,29 +287,18 @@ class Session(YowsupApp): self.sendReceipt(_id, _from, None, participant) self.logger.info("Message received from %s to %s: %s (at ts=%s)", buddy, self.legacyName, messageContent, timestamp) - if participant is not None: # Group message + if participant is not None: # Group message or broadcast partname = participant.split('@')[0] - try: - part = self.buddies[partname] - if part.nick == "": - part.nick = notify - self.backend.handleParticipantChanged( - self.user, partname, self._shortenGroupId(buddy), - protocol_pb2.PARTICIPANT_FLAG_NONE, - protocol_pb2.STATUS_ONLINE, "", part.nick - ) # TODO - except KeyError: - self.updateBuddy(partname, notify, []) - self.sendGroupMessageToXMPP(buddy, partname, messageContent, - timestamp) + if _from.split('@')[1] == 'broadcast': # Broadcast message + message = self.broadcast_prefix + messageContent + self.sendMessageToXMPP(partname, message, timestamp) + else: # Group message + if notify is None: + notify = "" + self.sendGroupMessageToXMPP(buddy, partname, messageContent, + timestamp, notify) else: self.sendMessageToXMPP(buddy, messageContent, timestamp) - # isBroadcast always returns false, I'm not sure how to get a broadcast - # message. - #if messageEntity.isBroadcast(): - # self.logger.info("Broadcast received from %s to %s: %s (at ts=%s)",\ - # buddy, self.legacyName, messageContent, timestamp) - # messageContent = "[Broadcast] " + messageContent # Called by superclass def onImage(self, image): @@ -329,14 +306,19 @@ class Session(YowsupApp): buddy = image._from.split('@')[0] participant = image.participant if image.caption is None: - image.caption = '' - message = image.url + ' ' + image.caption + image.caption = '' if participant is not None: # Group message - partname = participant.split('@')[0] - self.sendGroupMessageToXMPP(buddy, partname, message, image.timestamp) - else: - - self.sendMessageToXMPP(buddy, message, image.timestamp) + partname = participant.split('@')[0] + if image._from.split('@')[1] == 'broadcast': # Broadcast message + self.sendMessageToXMPP(partname, self.broadcast_prefix, image.timestamp) + self.sendMessageToXMPP(partname, image.url, image.timestamp) + self.sendMessageToXMPP(partname, image.caption, image.timestamp) + else: # Group message + self.sendGroupMessageToXMPP(buddy, partname, image.url, image.timestamp) + self.sendGroupMessageToXMPP(buddy, partname, image.caption, image.timestamp) + else: + self.sendMessageToXMPP(buddy, image.url, image.timestamp) + self.sendMessageToXMPP(buddy, image.caption, image.timestamp) self.sendReceipt(image._id, image._from, None, image.participant) # Called by superclass @@ -346,10 +328,13 @@ class Session(YowsupApp): participant = audio.participant message = audio.url if participant is not None: # Group message - partname = participant.split('@')[0] - self.sendGroupMessageToXMPP(buddy, partname, message, audio.timestamp) - else: - + partname = participant.split('@')[0] + if audio._from.split('@')[1] == 'broadcast': # Broadcast message + self.sendMessageToXMPP(partname, self.broadcast_prefix, audio.timestamp) + self.sendMessageToXMPP(partname, message, audio.timestamp) + else: # Group message + self.sendGroupMessageToXMPP(buddy, partname, message, audio.timestamp) + else: self.sendMessageToXMPP(buddy, message, audio.timestamp) self.sendReceipt(audio._id, audio._from, None, audio.participant) @@ -361,10 +346,13 @@ class Session(YowsupApp): message = video.url if participant is not None: # Group message - partname = participant.split('@')[0] - self.sendGroupMessageToXMPP(buddy, partname, message, video.timestamp) - else: - + partname = participant.split('@')[0] + if video._from.split('@')[1] == 'broadcast': # Broadcast message + self.sendMessageToXMPP(partname, self.broadcast_prefix, video.timestamp) + self.sendMessageToXMPP(partname, message, video.timestamp) + else: # Group message + self.sendGroupMessageToXMPP(buddy, partname, message, video.timestamp) + else: self.sendMessageToXMPP(buddy, message, video.timestamp) self.sendReceipt(video._id, video._from, None, video.participant) @@ -372,23 +360,30 @@ class Session(YowsupApp): buddy = location._from.split('@')[0] latitude = location.getLatitude() longitude = location.getLongitude() - url = location.getLocationUrl() + url = location.getLocationURL() participant = location.participant + latlong = 'geo:' + latitude + ',' + longitude self.logger.debug("Location received from %s: %s, %s", buddy, latitude, longitude) - if participant is not None: # Group message - partname = participant.split('@')[0] - self.sendGroupMessageToXMPP(buddy, partname, url, location.timestamp) - self.sendGroupMessageToXMPP(buddy, partname, 'geo:' + latitude + ',' + longitude, - location.timestamp) - else: - self.sendMessageToXMPP(buddy, url, location.timestamp) - self.sendMessageToXMPP(buddy, 'geo:' + latitude + ',' + longitude, - location.timestamp) - self.sendReceipt(location._id, location._from, None, location.participant, location.timestamp) + if participant is not None: # Group message + partname = participant.split('@')[0] + if location._from.split('@')[1] == 'broadcast': # Broadcast message + self.sendMessageToXMPP(partname, self.broadcast_prefix, location.timestamp) + if url is not None: + self.sendMessageToXMPP(partname, url, location.timestamp) + self.sendMessageToXMPP(partname, latlong, location.timestamp) + else: # Group message + if url is not None: + self.sendGroupMessageToXMPP(buddy, partname, url, location.timestamp) + self.sendGroupMessageToXMPP(buddy, partname, latlong, location.timestamp) + else: + if url is not None: + self.sendMessageToXMPP(buddy, url, location.timestamp) + self.sendMessageToXMPP(buddy, latlong, location.timestamp) + self.sendReceipt(location._id, location._from, None, location.participant) # Called by superclass @@ -398,13 +393,17 @@ class Session(YowsupApp): _id, _from, name, card_data, to, notify, timestamp, participant ])) ) + message = "Received VCard (not implemented yet)" buddy = _from.split("@")[0] if participant is not None: # Group message - partname = participant.split('@')[0] - self.sendGroupMessageToXMPP(buddy, partname, "Received VCard (not implemented yet)", timestamp) - else: - - self.sendMessageToXMPP(buddy, "Received VCard (not implemented yet)") + partname = participant.split('@')[0] + if _from.split('@')[1] == 'broadcast': # Broadcast message + message = self.broadcast_prefix + message + self.sendMessageToXMPP(partname, message, timestamp) + else: # Group message + self.sendGroupMessageToXMPP(buddy, partname, message, timestamp) + else: + self.sendMessageToXMPP(buddy, message, timestamp) # self.sendMessageToXMPP(buddy, card_data) #self.transferFile(buddy, str(name), card_data) self.sendReceipt(_id, _from, None, participant) @@ -442,11 +441,8 @@ class Session(YowsupApp): subjectOwner = group.getSubjectOwnerJid(full = False) subject = utils.softToUni(group.getSubject()) - self.groups[room] = Group(room, owner, subject, subjectOwner) - self.groups[room].participants = group.getParticipants().keys() -# self.joinRoom(self._shortenGroupId(room), self.user.split("@")[0]) - - #self._addParticipantsToRoom(room, group.getParticipants()) + self.groups[room] = Group(room, owner, subject, subjectOwner, self.backend, self.user) + self.groups[room].addParticipants(group.getParticipants, self.buddies, self.legacyName) self.bot.send("You have been added to group: %s@%s (%s)" % (self._shortenGroupId(room), subject, self.backend.spectrum_jid)) @@ -454,68 +450,110 @@ class Session(YowsupApp): def onParticipantsAddedToGroup(self, group): self.logger.debug("Participants added to group: %s", group) room = group.getGroupId().split('@')[0] - self.groups[room].participants.extend(group.getParticipants()) - self._refreshParticipants(room) + self.groups[room].addParticipants(group.getParticipants(), self.buddies, self.legacyName) + self.groups[room].sendParticipantsToSpectrum(self.legacyName) + + # Called by superclass + def onSubjectChanged(self, room, subject, subjectOwner, timestamp): + self.logger.debug( + "onSubjectChange(rrom=%s, subject=%s, subjectOwner=%s, timestamp=%s)", + room, subject, subjectOwner, timestamp) + try: + group = self.groups[room] + except KeyError: + self.logger.error("Subject of non-existant group (%s) changed", group) + else: + group.subject = subject + group.subjectOwner = subjectOwner + if not group.joined: + # We have not joined group so we should not send subject + return + self.backend.handleSubject(self.user, room, subject, subjectOwner) + self.backend.handleRoomNicknameChanged(self.user, room, subject) # Called by superclass def onParticipantsRemovedFromGroup(self, room, participants): self.logger.debug("Participants removed from group: %s, %s", room, participants) - group = self.groups[room] - for jid in participants: - group.participants.remove(jid) - buddy = jid.split("@")[0] - try: - nick = self.buddies[buddy].nick - except KeyError: - nick = buddy - if nick == "": - nick = buddy - if buddy == self.legacyName: - nick = group.nick - flags = protocol_pb2.PARTICIPANT_FLAG_NONE - self.backend.handleParticipantChanged( - self.user, nick, self._shortenGroupId(room), flags, - protocol_pb2.STATUS_NONE, buddy) + self.groups[room].removeParticipants(participants) + + # Called by superclass + def onContactStatusChanged(self, number, status): + self.logger.debug("%s changed their status to %s", number, status) + try: + buddy = self.buddies[number] + buddy.statusMsg = status + self.buddies.updateSpectrum(buddy) + except KeyError: + self.logger.debug("%s not in buddy list", number) + + # Called by superclass + def onContactPictureChanged(self, number): + self.logger.debug("%s changed their profile picture", number) + self.buddies.requestVCard(number) + + # Called by superclass + def onContactAdded(self, number, nick): + self.logger.debug("Adding new contact %s (%s)", nick, number) + self.updateBuddy(number, nick, []) + + # Called by superclass + def onContactRemoved(self, number): + self.logger.debug("Removing contact %s", number) + self.removeBuddy(number) + + def onContactUpdated(self, oldnumber, newnumber): + self.logger.debug("Contact has changed number from %s to %s", + oldnumber, newnumber) + if newnumber in self.buddies: + self.logger.warn("Contact %s exists, just updating", newnumber) + self.buddies.refresh(newnumber) + try: + buddy = self.buddies[oldnumber] + except KeyError: + self.logger.warn("Old contact (%s) not found. Adding new contact (%s)", + oldnumber, newnumber) + nick = "" + else: + self.removeBuddy(buddy.number) + nick = buddy.nick + self.updateBuddy(newnumber, nick, []) def onPresenceReceived(self, _type, name, jid, lastseen): self.logger.info("Presence received: %s %s %s %s", _type, name, jid, lastseen) buddy = jid.split("@")[0] - try: - buddy = self.buddies[buddy] + try: + buddy = self.buddies[buddy] except KeyError: - self.logger.error("Buddy not found: %s", buddy) + # Sometimes whatsapp send our own presence + if buddy != self.legacyName: + self.logger.error("Buddy not found: %s", buddy) return if (lastseen == str(buddy.lastseen)) and (_type == buddy.presence): return - if ((lastseen != "deny") and (lastseen != None) and (lastseen != "none")): + if ((lastseen != "deny") and (lastseen != None) and (lastseen != "none")): buddy.lastseen = int(lastseen) if (_type == None): buddy.lastseen = time.time() buddy.presence = _type - timestamp = time.localtime(buddy.lastseen) - statusmsg = buddy.statusMsg + time.strftime("\n Last seen: %a, %d %b %Y %H:%M:%S", timestamp) - if _type == "unavailable": - self.onPresenceUnavailable(buddy, statusmsg) + self.onPresenceUnavailable(buddy) else: - self.onPresenceAvailable(buddy, statusmsg) + self.onPresenceAvailable(buddy) - def onPresenceAvailable(self, buddy, statusmsg): + def onPresenceAvailable(self, buddy): self.logger.info("Is available: %s", buddy) - self.backend.handleBuddyChanged(self.user, buddy.number, - buddy.nick, buddy.groups, protocol_pb2.STATUS_ONLINE, statusmsg, buddy.image_hash) + self.buddies.updateSpectrum(buddy) - def onPresenceUnavailable(self, buddy, statusmsg): + def onPresenceUnavailable(self, buddy): self.logger.info("Is unavailable: %s", buddy) - self.backend.handleBuddyChanged(self.user, buddy.number, - buddy.nick, buddy.groups, protocol_pb2.STATUS_AWAY, statusmsg, buddy.image_hash) + self.buddies.updateSpectrum(buddy) # spectrum RequestMethods def sendTypingStarted(self, buddy): @@ -532,29 +570,65 @@ class Session(YowsupApp): self.logger.info("Stopped typing: %s to %s", self.legacyName, buddy) self.sendTyping(buddy, False) - def sendMessageToWA(self, sender, message, ID): - self.logger.info("Message sent from %s to %s: %s", - self.legacyName, sender, message) + def sendImage(self, message, ID, to): + if (".jpg" in message.lower()): + imgType = "jpg" + if (".webp" in message.lower()): + imgType = "webp" + + success = deferred.Deferred() + error = deferred.Deferred() + self.downloadMedia(message, success.run, error.run) + + # Success + path = success.arg(0) + call(self.logger.info, "Success: Image downloaded to %s", path) + pathWithExt = path.then(lambda p: p + "." + imgType) + call(os.rename, path, pathWithExt) + pathJpg = path.then(lambda p: p + ".jpg") + if imgType != "jpg": + im = call(Image.open, pathWithExt) + call(im.save, pathJpg) + call(os.remove, pathWithExt) + call(self.logger.info, "Sending image to %s", to) + waId = deferred.Deferred() + call(super(Session, self).sendImage, to, pathJpg, onSuccess = waId.run) + call(self.setWaId, ID, waId) + waId.when(call, os.remove, pathJpg) + waId.when(self.logger.info, "Image sent") + + # Error + error.when(self.logger.info, "Download Error. Sending message as is.") + waId = error.when(self.sendTextMessage, to, message) + call(self.setWaId, ID, waId) + + def setWaId(self, XmppId, waId): + self.msgIDs[waId] = MsgIDs(XmppId, waId) + + def sendMessageToWA(self, sender, message, ID, xhtml=""): + self.logger.info("Message sent from %s to %s: %s (xhtml=%s)", + self.legacyName, sender, message, xhtml) message = message.encode("utf-8") - # FIXME: Fragile, should pass this in to onDlerror - self.dlerror_message = message - self.dlerror_sender = sender - self.dlerror_ID = ID - # End Fragile if sender == "bot": self.bot.parse(message) elif "-" in sender: # group msg if "/" in sender: # directed at single user room, nick = sender.split("/") - for buddy, buddy3 in self.buddies.iteritems(): - self.logger.info("Group buddy=%s nick=%s", buddy, - buddy3.nick) - if buddy3.nick == nick: - nick = buddy - waId = self.sendTextMessage(nick + '@s.whatsapp.net', message) - self.msgIDs[waId] = MsgIDs( ID, waId) + group = self.groups[room] + number = None + for othernumber, othernick in group.participants.iteritems(): + if othernick == nick: + number = othernumber + break + if number is not None: + self.logger.debug("Private message sent from %s to %s", self.legacyName, number) + waId = self.sendTextMessage(number + '@s.whatsapp.net', message) + self.msgIDs[waId] = MsgIDs( ID, waId) + else: + self.logger.error("Attempted to send private message to non-existent user") + self.logger.debug("%s to %s in %s", self.legacyName, nick, room) else: room = sender if message[0] == '\\' and message[:1] != '\\\\': @@ -566,30 +640,17 @@ class Session(YowsupApp): self.logger.debug("Group Message from %s to %s Groups: %s", group.nick , group , self.groups) self.backend.handleMessage( - self.user, room, message.decode('utf-8'), group.nick + self.user, room, message.decode('utf-8'), group.nick, xhtml=xhtml ) except KeyError: self.logger.error('Group not found: %s', room) - + if (".jpg" in message.lower()) or (".webp" in message.lower()): - if (".jpg" in message.lower()): - self.imgType = "jpg" - if (".webp" in message.lower()): - self.imgType = "webp" - self.imgMsgId = ID - self.imgBuddy = room + "@g.us" - - - downloader = MediaDownloader(self.onDlsuccess, self.onDlerror) - downloader.download(message) - #self.imgMsgId = ID - #self.imgBuddy = room + "@g.us" - elif "geo:" in message.lower(): - self._sendLocation(room + "@g.us", message, ID) - - else: - - self.sendTextMessage(self._lengthenGroupId(room) + '@g.us', message) + self.sendImage(message, ID, room + '@g.us') + elif "geo:" in message.lower(): + self._sendLocation(room + "@g.us", message, ID) + else: + self.sendTextMessage(room + '@g.us', message) else: # private msg buddy = sender # if message == "\\lastseen": @@ -605,20 +666,8 @@ class Session(YowsupApp): #self.call("contact_getProfilePicture", (buddy + "@s.whatsapp.net",)) self.requestVCard(buddy) else: - if (".jpg" in message.lower()) or (".webp" in message.lower()): - #waId = self.call("message_imageSend", (buddy + "@s.whatsapp.net", message, None, 0, None)) - #waId = self.call("message_send", (buddy + "@s.whatsapp.net", message)) - if (".jpg" in message.lower()): - self.imgType = "jpg" - if (".webp" in message.lower()): - self.imgType = "webp" - self.imgMsgId = ID - self.imgBuddy = buddy + "@s.whatsapp.net" - - downloader = MediaDownloader(self.onDlsuccess, self.onDlerror) - downloader.download(message) - #self.imgMsgId = ID - #self.imgBuddy = buddy + "@s.whatsapp.net" + if (".jpg" in message.lower()) or (".webp" in message.lower()): + self.sendImage(message, ID, buddy + "@s.whatsapp.net") elif "geo:" in message.lower(): self._sendLocation(buddy + "@s.whatsapp.net", message, ID) else: @@ -635,20 +684,7 @@ class Session(YowsupApp): self.leaveGroup(room) # Delete Room on spectrum side group = self.groups[room] - for jid in group.participants: - buddy = jid.split("@")[0] - try: - nick = self.buddies[buddy].nick - except KeyError: - nick = buddy - if nick == "": - nick = buddy - if buddy == self.legacyName: - nick = group.nick - flags = protocol_pb2.PARTICIPANT_FLAG_ROOM_NOT_FOUND - self.backend.handleParticipantChanged( - self.user, nick, self._shortenGroupId(room), flags, - protocol_pb2.STATUS_NONE, buddy) + group.leaveRoom() del self.groups[room] def _requestLastSeen(self, buddy): @@ -686,35 +722,36 @@ class Session(YowsupApp): self.backend.handleMessage(self.user, buddy, messageContent, "", "", timestamp) - def sendGroupMessageToXMPP(self, room, buddy, messageContent, timestamp = ""): - # self._refreshParticipants(room) - try: - nick = self.buddies[buddy].nick - except KeyError: - nick = buddy - if nick == "": - nick = buddy - + def sendGroupMessageToXMPP(self, room, number, messageContent, timestamp = u"", defaultname = u""): if timestamp: timestamp = time.strftime("%Y%m%dT%H%M%S", time.gmtime(timestamp)) if self.initialized == False: self.logger.debug("Group message queued from %s to %s: %s", - buddy, room, messageContent) + number, room, messageContent) if room not in self.groupOfflineQueue: self.groupOfflineQueue[room] = [ ] self.groupOfflineQueue[room].append( - (buddy, messageContent, timestamp) + (number, messageContent, timestamp) ) else: - self.logger.debug("Group message sent from %s (%s) to %s: %s", - buddy, nick, room, messageContent) + self.logger.debug("Group message sent from %s to %s: %s", + number, room, messageContent) try: group = self.groups[room] + # Update nickname + try: + if defaultname != "" and group.participants[number] == number: + group.changeNick(number, defaultname) + if self.buddies[number].nick != "": + group.changeNick(number, self.buddies[number].nick) + except KeyError: + pass + nick = group.participants[number] if group.joined: - self.backend.handleMessage(self.user,room, messageContent, + self.backend.handleMessage(self.user, room, messageContent, nick, "", timestamp) else: self.bot.send("You have received a message in group: %s@%s" @@ -724,7 +761,7 @@ class Session(YowsupApp): except KeyError: self.logger.warn("Group is not in group list") self.backend.handleMessage(self.user, self._shortenGroupId(room), - messageContent, nick, "", timestamp) + messageContent, number, "", timestamp) def changeStatus(self, status): @@ -770,61 +807,8 @@ class Session(YowsupApp): self.buddies.remove(buddy) def requestVCard(self, buddy, ID=None): - def onSuccess(response, request): - self.logger.debug('Sending VCard (%s) with image id %s', - ID, response.pictureId) - image_hash = utils.sha1hash(response.pictureData) - self.logger.debug('Image hash is %s', image_hash) - if ID != None: - self.backend.handleVCard(self.user, ID, buddy, "", "", response.pictureData) - obuddy = self.buddies[buddy] - self.updateBuddy(buddy, obuddy.nick, obuddy.groups, image_hash) + self.buddies.requestVCard(buddy, ID) - self.logger.debug('Requesting profile picture of %s', buddy) - self.requestProfilePicture(buddy, onSuccess = onSuccess) - - def onDlsuccess(self, path): - self.logger.info("Success: Image downloaded to %s", path) - os.rename(path, path+"."+self.imgType) - if self.imgType != "jpg": - im = Image.open(path+"."+self.imgType) - im.save(path+".jpg") - self.imgPath = path+".jpg" - statinfo = os.stat(self.imgPath) - name=os.path.basename(self.imgPath) - self.logger.info("Buddy %s",self.imgBuddy) - self.image_send(self.imgBuddy, self.imgPath) - - #self.logger.info("Sending picture %s of size %s with name %s",self.imgPath, statinfo.st_size, name) - #mtype = "image" - - #sha1 = hashlib.sha256() - #fp = open(self.imgPath, 'rb') - #try: - # sha1.update(fp.read()) - # hsh = base64.b64encode(sha1.digest()) - # self.call("media_requestUpload", (hsh, mtype, os.path.getsize(self.imgPath))) - #finally: - # fp.close() - - - def onDlerror(self): - self.logger.info("Download Error. Sending message as is.") - waId = self.sendTextMessage(self.dlerror_sender + '@s.whatsapp.net', self.dlerror_message) - self.msgIDs[waId] = MsgIDs(self.dlerror_ID, waId) - - - def _doSendImage(self, filePath, url, to, ip = None, caption = None): - waId = self.doSendImage(filePath, url, to, ip, caption) - self.msgIDs[waId] = MsgIDs(self.imgMsgId, waId) - - def _doSendAudio(self, filePath, url, to, ip = None, caption = None): - waId = self.doSendAudio(filePath, url, to, ip, caption) - self.msgIDs[waId] = MsgIDs(self.imgMsgId, waId) - - - - def createThumb(self, size=100, raw=False): img = Image.open(self.imgPath) width, height = img.size diff --git a/threadutils.py b/threadutils.py new file mode 100644 index 0000000..59e7d51 --- /dev/null +++ b/threadutils.py @@ -0,0 +1,19 @@ +import Queue +import threading + +# This queue is for other threads that want to execute code in the main thread +eventQueue = Queue.Queue() + +def runInThread(threadFunc, callback): + """ + Executes threadFunc in a new thread. The result of threadFunc will be + pass as the first argument to callback. callback will be called in the main + thread. + """ + def helper(): + # Execute threadfunc in new thread + result = threadFunc() + # Queue callback to be call in main thread + eventQueue.put(lambda: callback(result)) + thread = threading.Thread(target=helper) + thread.start() diff --git a/transwhat.py b/transwhat.py index aa58ad9..07e36bf 100755 --- a/transwhat.py +++ b/transwhat.py @@ -29,8 +29,8 @@ import logging import asyncore import sys, os import e4u -import threading import Queue +import threadutils sys.path.insert(0, os.getcwd()) @@ -62,7 +62,13 @@ logging.basicConfig( \ # Handler def handleTransportData(data): - plugin.handleDataRead(data) + try: + plugin.handleDataRead(data) + except SystemExit as e: + raise e + except: + logger = logging.getLogger('transwhat') + logger.error(traceback.format_exc()) e4u.load() @@ -76,7 +82,13 @@ io = IOChannel(args.host, args.port, handleTransportData, connectionClosed) plugin = WhatsAppBackend(io, args.j) -plugin.handleBackendConfig('features', 'send_buddies_on_login', 1) +plugin.handleBackendConfig({ + 'features': [ + ('send_buddies_on_login', 1), + ('muc', 'true'), + ], +}) + while True: try: @@ -90,6 +102,13 @@ while True: break if closed: break + while True: + try: + callback = threadutils.eventQueue.get_nowait() + except Queue.Empty: + break + else: + callback() except SystemExit: break except: diff --git a/whatsappbackend.py b/whatsappbackend.py index a2ddc9e..691dc73 100644 --- a/whatsappbackend.py +++ b/whatsappbackend.py @@ -25,6 +25,7 @@ from Spectrum2.backend import SpectrumBackend from Spectrum2 import protocol_pb2 from session import Session +from registersession import RegisterSession import logging @@ -36,18 +37,20 @@ class WhatsAppBackend(SpectrumBackend): self.sessions = { } self.spectrum_jid = spectrum_jid # Used to prevent duplicate messages - self.lastMessage = {} + self.lastMsgId = {} self.logger.debug("Backend started") # RequestsHandlers def handleLoginRequest(self, user, legacyName, password, extra): self.logger.debug("handleLoginRequest(user=%s, legacyName=%s)", user, legacyName) - if user not in self.sessions: - self.sessions[user] = Session(self, user, legacyName, extra) - - if user not in self.lastMessage: - self.lastMessage[user] = {} + # Key word means we should register a new password + if password == 'register': + if user not in self.sessions: + self.sessions[user] = RegisterSession(self, user, legacyName, extra) + else: + if user not in self.sessions: + self.sessions[user] = Session(self, user, legacyName, extra) self.sessions[user].login(password) @@ -57,20 +60,17 @@ class WhatsAppBackend(SpectrumBackend): self.sessions[user].logout() del self.sessions[user] - def handleMessageSendRequest(self, user, buddy, message, xhtml = "", ID = 0): - self.logger.debug("handleMessageSendRequest(user=%s, buddy=%s, message=%s, xhtml = %s)", user, buddy, message, xhtml) + def handleMessageSendRequest(self, user, buddy, message, xhtml="", ID=""): + self.logger.debug("handleMessageSendRequest(user=%s, buddy=%s, message=%s, xhtml=%s, ID=%s)", user, buddy, message, xhtml, ID) # For some reason spectrum occasionally sends to identical messages to - # a buddy, one to the bare jid and one to /bot. This causes duplicate - # messages. Since it is unlikely a user wants to send the same message - # twice, we should just ignore the second message - # - # TODO Proper fix, this work around drops all duplicate messages even - # intentional ones. - # IDEA there is an ID field in ConvMessage. If it is extracted it will work - usersMessage = self.lastMessage[user] - if buddy not in usersMessage or usersMessage[buddy] != message: - self.sessions[user].sendMessageToWA(buddy, message, ID) - usersMessage[buddy] = message + # a buddy, one to the bare jid and one to the /bot resource. This + # causes duplicate messages. Thus we should not send consecutive + # messages with the same id + if ID == '': + self.sessions[user].sendMessageToWA(buddy, message, ID, xhtml) + elif user not in self.lastMsgId or self.lastMsgId[user] != ID: + self.sessions[user].sendMessageToWA(buddy, message, ID, xhtml) + self.lastMsgId[user] = ID def handleJoinRoomRequest(self, user, room, nickname, pasword): self.logger.debug("handleJoinRoomRequest(user=%s, room=%s, nickname=%s)", user, room, nickname) @@ -117,14 +117,29 @@ class WhatsAppBackend(SpectrumBackend): self.logger.debug("handleVCardRequest(user=%s, buddy=%s, ID=%s)", user, buddy, ID) self.sessions[user].requestVCard(buddy, ID) + def handleVCardUpdatedRequest(self, user, photo, nickname): + self.logger.debug("handleVCardUpdatedRequest(user=%s, nickname=%s)", user, nickname) + self.sessions[user].setProfilePicture(photo) + + def handleBuddyBlockToggled(self, user, buddy, blocked): + self.logger.debug("handleBuddyBlockedToggled(user=%s, buddy=%s, blocked=%s)", user, buddy, blocked) + + def relogin(self, user, legacyName, password, extra): + """ + Used to re-initialize the session object. Used when finished with + registration session and the user needs to login properly + """ + self.logger.debug("relogin(user=%s, legacyName=%s)", user, legacyName) + # Change password in spectrum database + self.handleQuery('register %s %s %s' % (user, legacyName, password)) + # Key word means we should register a new password + if password == 'register': # This shouldn't happen, but just in case + self.sessions[user] = RegisterSession(self, user, legacyName, extra) + else: + self.sessions[user] = Session(self, user, legacyName, extra) + self.sessions[user].login(password) # TODO - def handleBuddyBlockToggled(self, user, buddy, blocked): - pass - - def handleVCardUpdatedRequest(self, user, photo, nickname): - pass - def handleAttentionRequest(self, user, buddy, message): pass diff --git a/yowsupwrapper.py b/yowsupwrapper.py index d7c3a79..f5bece2 100644 --- a/yowsupwrapper.py +++ b/yowsupwrapper.py @@ -35,11 +35,21 @@ from yowsup.layers.protocol_chatstate.protocolentities import * from yowsup.layers.protocol_contacts.protocolentities import * from yowsup.layers.protocol_groups.protocolentities import * from yowsup.layers.protocol_media.protocolentities import * +from yowsup.layers.protocol_notifications.protocolentities import * from yowsup.layers.protocol_messages.protocolentities import * from yowsup.layers.protocol_presence.protocolentities import * from yowsup.layers.protocol_profiles.protocolentities import * +from yowsup.layers.protocol_privacy.protocolentities import * from yowsup.layers.protocol_receipts.protocolentities import * +from yowsup.layers.protocol_iq.protocolentities import * from yowsup.layers.protocol_media.mediauploader import MediaUploader +from yowsup.layers.protocol_media.mediadownloader import MediaDownloader + + +# Registration + +from yowsup.registration import WACodeRequest +from yowsup.registration import WARegRequest from functools import partial @@ -127,6 +137,10 @@ class YowsupApp(object): receipt = OutgoingReceiptProtocolEntity(_id, _from, read, participant) self.sendEntity(receipt) + def downloadMedia(self, url, onSuccess = None, onFailure = None): + downloader = MediaDownloader(onSuccess, onFailure) + downloader.download(url) + def sendTextMessage(self, to, message): """ Sends a text message @@ -144,26 +158,27 @@ class YowsupApp(object): self.sendEntity(messageEntity) return messageEntity.getId() - def image_send(self, jid, path, caption = None): - entity = RequestUploadIqProtocolEntity(RequestUploadIqProtocolEntity.MEDIA_TYPE_IMAGE, filePath=path) - successFn = lambda successEntity, originalEntity: self.onRequestUploadResult(jid, path, successEntity, originalEntity, caption) + def sendImage(self, jid, path, caption = None, onSuccess = None, onFailure = None): + entity = RequestUploadIqProtocolEntity(RequestUploadIqProtocolEntity.MEDIA_TYPE_IMAGE, filePath=path) + successFn = lambda successEntity, originalEntity: self.onRequestUploadResult(jid, path, successEntity, originalEntity, caption, onSuccess, onFailure) errorFn = lambda errorEntity, originalEntity: self.onRequestUploadError(jid, path, errorEntity, originalEntity) self.sendIq(entity, successFn, errorFn) - def onRequestUploadResult(self, jid, filePath, resultRequestUploadIqProtocolEntity, requestUploadIqProtocolEntity, caption = None): + def onRequestUploadResult(self, jid, filePath, resultRequestUploadIqProtocolEntity, requestUploadIqProtocolEntity, caption = None, onSuccess=None, onFailure=None): if requestUploadIqProtocolEntity.mediaType == RequestUploadIqProtocolEntity.MEDIA_TYPE_AUDIO: - doSendFn = self._doSendAudio + doSendFn = self.doSendAudio else: - doSendFn = self._doSendImage + doSendFn = self.doSendImage if resultRequestUploadIqProtocolEntity.isDuplicate(): doSendFn(filePath, resultRequestUploadIqProtocolEntity.getUrl(), jid, resultRequestUploadIqProtocolEntity.getIp(), caption) else: - successFn = lambda filePath, jid, url: doSendFn(filePath, url, jid, resultRequestUploadIqProtocolEntity.getIp(), caption) - mediaUploader = MediaUploader(jid, self.legacyName, filePath, + successFn = lambda filePath, jid, url: doSendFn(filePath, url, jid, resultRequestUploadIqProtocolEntity.getIp(), caption, onSuccess, onFailure) + ownNumber = self.stack.getLayerInterface(YowAuthenticationProtocolLayer).getUsername(full=False) + mediaUploader = MediaUploader(jid, ownNumber, filePath, resultRequestUploadIqProtocolEntity.getUrl(), resultRequestUploadIqProtocolEntity.getResumeOffset(), successFn, self.onUploadError, self.onUploadProgress, async=False) @@ -181,17 +196,21 @@ class YowsupApp(object): #sys.stdout.flush() pass - def doSendImage(self, filePath, url, to, ip = None, caption = None): + def doSendImage(self, filePath, url, to, ip = None, caption = None, onSuccess = None, onFailure = None): entity = ImageDownloadableMediaMessageProtocolEntity.fromFilePath(filePath, url, ip, to, caption = caption) self.sendEntity(entity) - #self.msgIDs[entity.getId()] = MsgIDs(self.imgMsgId, entity.getId()) + #self.msgIDs[entity.getId()] = MsgIDs(self.imgMsgId, entity.getId()) + if onSuccess is not None: + onSuccess(entity.getId()) return entity.getId() - def doSendAudio(self, filePath, url, to, ip = None, caption = None): + def doSendAudio(self, filePath, url, to, ip = None, caption = None, onSuccess = None, onFailure = None): entity = AudioDownloadableMediaMessageProtocolEntity.fromFilePath(filePath, url, ip, to) self.sendEntity(entity) #self.msgIDs[entity.getId()] = MsgIDs(self.imgMsgId, entity.getId()) + if onSuccess is not None: + onSuccess(entity.getId()) return entity.getId() @@ -252,7 +271,20 @@ class YowsupApp(object): """ iq = SetStatusIqProtocolEntity(statusText) self.sendIq(iq) - + + def setProfilePicture(self, previewPicture, fullPicture = None): + """ + Requests profile picture of whatsapp user + Args: + - previewPicture: (bytes) The preview picture + - fullPicture: (bytes) The full profile picture + """ + if fullPicture == None: + fullPicture = previewPicture + ownJid = self.stack.getLayerInterface(YowAuthenticationProtocolLayer).getUsername(full = True) + iq = SetPictureIqProtocolEntity(ownJid, previewPicture, fullPicture) + self.sendIq(iq) + def sendTyping(self, phoneNumber, typing): """ Notify buddy using phoneNumber that you are typing to him @@ -271,12 +303,12 @@ class YowsupApp(object): ChatstateProtocolEntity.STATE_PAUSED, jid ) self.sendEntity(state) - - def sendSync(self, contacts, delta = False, interactive = True): + + def sendSync(self, contacts, delta = False, interactive = True, success = None, failure = None): """ You need to sync new contacts before you interact with them, failure to do so could result in a temporary ban. - + Args: - contacts: ([str]) a list of phone numbers of the contacts you wish to sync @@ -285,12 +317,62 @@ class YowsupApp(object): contact list. - interactive: (bool; default: True) Set to false if you are sure this is the first time registering + - success: (func) - Callback; Takes three arguments: existing numbers, + non-existing numbers, invalid numbers. """ - # TODO: Implement callbacks mode = GetSyncIqProtocolEntity.MODE_DELTA if delta else GetSyncIqProtocolEntity.MODE_FULL context = GetSyncIqProtocolEntity.CONTEXT_INTERACTIVE if interactive else GetSyncIqProtocolEntity.CONTEXT_REGISTRATION + # International contacts must be preceded by a plus. Other numbers are + # considered local. + contacts = ['+' + c for c in contacts] iq = GetSyncIqProtocolEntity(contacts, mode, context) - self.sendIq(iq) + def onSuccess(response, request): + # Remove leading plus + if success is not None: + existing = [s[1:] for s in response.inNumbers.keys()] + nonexisting = [s[1:] for s in response.outNumbers.keys()] + invalid = [s[1:] for s in response.invalidNumbers] + success(existing, nonexisting, invalid) + + self.sendIq(iq, onSuccess = onSuccess, onError = failure) + + def requestClientConfig(self, success = None, failure = None): + """I'm not sure what this does, but it might be required on first login.""" + iq = PushIqProtocolEntity() + self.sendIq(iq, onSuccess = success, onError = failure) + + + def requestPrivacyList(self, success = None, failure = None): + """I'm not sure what this does, but it might be required on first login.""" + iq = PrivacyListIqProtocolEntity() + self.sendIq(iq, onSuccess = success, onError = failure) + + def requestServerProperties(self, success = None, failure = None): + """I'm not sure what this does, but it might be required on first login.""" + iq = PropsIqProtocolEntity() + self.sendIq(iq, onSuccess = success, onError = failure) + + def requestStatuses(self, contacts, success = None, failure = None): + """ + Request the statuses of a number of users. + + Args: + - contacts: ([str]) the phone numbers of users whose statuses you + wish to request + - success: (func) called when request is successful + - failure: (func) called when request has failed + """ + iq = GetStatusesIqProtocolEntity([c + '@s.whatsapp.net' for c in contacts]) + def onSuccess(response, request): + if success is not None: + self.logger.debug("Received Statuses %s", response) + s = {} + for k, v in response.statuses.iteritems(): + s[k.split('@')[0]] = v + success(s) + + self.sendIq(iq, onSuccess = onSuccess, onError = failure) + def requestLastSeen(self, phoneNumber, success = None, failure = None): """ @@ -336,6 +418,34 @@ class YowsupApp(object): iq = InfoGroupsIqProtocolEntity(group + '@g.us') self.sendIq(iq, onSuccess = onSuccess, onError = onFailure) + def requestSMSCode(self, countryCode, phoneNumber): + """ + Request an sms regitration code. WARNING: this function is blocking + + Args: + countryCode: The country code of the phone you wish to register + phoneNumber: phoneNumber of the phone you wish to register without + the country code. + """ + request = WACodeRequest(countryCode, phoneNumber) + return request.send() + + def requestPassword(self, countryCode, phoneNumber, smsCode): + """ + Request a password. WARNING: this function is blocking + + Args: + countryCode: The country code of the phone you wish to register + phoneNumber: phoneNumber of the phone you wish to register without + the country code. + smsCode: The sms code that you asked for previously + """ + smsCode = smsCode.replace('-', '') + request = WARegRequest(countryCode, phoneNumber, smsCode) + return request.send() + + + def onAuthSuccess(self, status, kind, creation, expiration, props, nonce, t): """ Called when login is successful. @@ -504,13 +614,66 @@ class YowsupApp(object): def onParticipantsRemovedFromGroup(self, group, participants): """Called when participants have been removed from a group - + Args: - group: (str) id of the group (e.g. 27831788123-144024456) - participants: (list) jids of participants that are removed """ pass + def onSubjectChanged(self, group, subject, subjectOwner, timestamp): + """Called when someone changes the grousp subject + + Args: + - group: (str) id of the group (e.g. 27831788123-144024456) + - subject: (str) the new subject + - subjectOwner: (str) the number of the person who changed the subject + - timestamp: (str) time the subject was changed + """ + pass + + def onContactStatusChanged(self, number, status): + """Called when a contacts changes their status + + Args: + number: (str) the number of the contact who changed their status + status: (str) the new status + """ + pass + + def onContactPictureChanged(self, number): + """Called when a contact changes their profile picture + Args + number: (str) the number of the contact who changed their picture + """ + pass + + def onContactRemoved(self, number): + """Called when a contact has been removed + + Args: + number: (str) the number of the contact who has been removed + """ + pass + + def onContactAdded(self, number, nick): + """Called when a contact has been added + + Args: + number: (str) contacts number + nick: (str) contacts nickname + """ + pass + + def onContactUpdated(self, oldNumber, newNumber): + """Called when a contact has changed their number + + Args: + oldNumber: (str) the number the contact previously used + newNumber: (str) the new number of the contact + """ + pass + def sendEntity(self, entity): """Sends an entity down the stack (as if YowsupAppLayer called toLower)""" self.stack.broadcastEvent(YowLayerEvent(YowsupAppLayer.TO_LOWER_EVENT, @@ -617,6 +780,34 @@ class YowsupAppLayer(YowInterfaceLayer): entity.getGroupId().split('@')[0], entity.getParticipants().keys() ) + elif isinstance(entity, SubjectGroupsNotificationProtocolEntity): + self.caller.onSubjectChanged( + entity.getGroupId().split('@')[0], + entity.getSubject(), + entity.getSubjectOwner(full=False), + entity.getSubjectTimestamp() + ) + elif isinstance(entity, StatusNotificationProtocolEntity): + self.caller.onContactStatusChanged( + entity._from.split('@')[0], + entity.status + ) + elif isinstance(entity, SetPictureNotificationProtocolEntity): + self.caller.onContactPictureChanged(entity.setJid.split('@')[0]) + elif isinstance(entity, DeletePictureNotificationProtocolEntity): + self.caller.onContactPictureChanged(entity.deleteJid.split('@')[0]) + elif isinstance(entity, RemoveContactNotificationProtocolEntity): + self.caller.onContactRemoved(entity.contactJid.split('@')[0]) + elif isinstance(entity, AddContactNotificationProtocolEntity): + self.caller.onContactAdded( + entity.contactJid.split('@')[0], + entity.notify + ) + elif isinstance(entity, UpdateContactNotificationProtocolEntity): + self.caller.onContactUpdated( + entity._from.split('@')[0], + entity.contactJid.split('@')[0], + ) @ProtocolEntityCallback('message') def onMessageReceived(self, entity):