diff --git a/.gitattributes b/.gitattributes index 9a8c3f1..43e952d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1 @@ -html/* linguist-vendored -html/kyros linguist-vendored \ No newline at end of file +html/kyros/* linguist-vendored diff --git a/README.md b/README.md index a5b560b..7e0918c 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,10 @@ Kyros, for now, is a Python interface to communicate easier with WhatsApp Web AP It provides an interface to connect and communicate with WhatsApp Web's websocket server. Kyros will handle encryption and decryption kind of things. In the future, Kyros is aimed to provide a full implementation of WhatsApp Web API which will give developers -a clean interface to work with (more or less like ![go-whatsapp](https://github.com/Rhymen/go-whatsapp)). +a clean interface to work with (more or less like [go-whatsapp](https://github.com/Rhymen/go-whatsapp)). This module is designed to work with Python 3.6 or latest. -Special thanks to the creator of ![whatsapp-web-reveng](https://github.com/sigalor/whatsapp-web-reveng) -and ![go-whatsapp](https://github.com/Rhymen/go-whatsapp). This project is largely motivated by their work. +Special thanks to the creator of [whatsapp-web-reveng](https://github.com/sigalor/whatsapp-web-reveng) +and [go-whatsapp](https://github.com/Rhymen/go-whatsapp). This project is largely motivated by their work. Please note that Kyros is not meant to be used actively in production servers as it is currently not production ready. Use it at your own risk. @@ -22,34 +22,47 @@ pip install git+https://git@github.com/p4kl0nc4t/kyros ```python import asyncio import logging +from os.path import exists import pyqrcode -from kyros import Client, WebsocketMessage +import kyros logging.basicConfig() # set a logging level: just to know if something (bad?) happens -logging.getLogger("kyros").setLevel(logging.WARNING) +logger = logging.getLogger("kyros") +logger.setLevel(logging.DEBUG) + +def handle_message(message): + logger.debug("Sample received message: %s", message) + async def main(): # create the Client instance using create class method - whatsapp = await kyros.Client.create() - - # do a QR login - qr_data, scanned = await whatsapp.qr_login() - - # generate qr code image - qr_code = pyqrcode.create(qr_data) - print(qr_code.terminal(quiet_zone=1)) - - try: - # wait for the QR code to be scanned - await scanned - except asyncio.TimeoutError: - # timed out (left unscanned), do a shutdown - await whatsapp.shutdown() - return - + whatsapp = await kyros.Client.create(handle_message) + + if exists("wp_session.json"): + currSession = kyros.Session.from_file("wp_session.json") + await whatsapp.restore_session(currSession) + else: + # do a QR login + qr_data, scanned = await whatsapp.qr_login() + + # generate qr code image + qr_code = pyqrcode.create(qr_data) + print(qr_code.terminal(quiet_zone=1)) + qr_code.svg('sample-qr.svg', scale=2) + + try: + # wait for the QR code to be scanned + await scanned + except asyncio.TimeoutError: + # timed out (left unscanned), do a shutdown + await whatsapp.shutdown() + return + + whatsapp.session.save_to_file("wp_session.json") + # how to send a websocket message message = kyros.WebsocketMessage(None, ["query", "exist", "1234@c.us"]) await whatsapp.websocket.send_message(message) @@ -57,11 +70,13 @@ async def main(): # receive a websocket message print(await whatsapp.websocket.messages.get(message.tag)) + # Await forever until app stopped with Ctrl+C + await asyncio.Future() if __name__ == "__main__": asyncio.run(main()) ``` -A "much more detailed documentation" kind of thing for this project is available ![here](https://p4kl0nc4t.github.io/kyros/). +A "much more detailed documentation" kind of thing for this project is available [here](https://p4kl0nc4t.github.io/kyros/). You will see a piece of nightmare, happy exploring! Better documentation are being planned. ## Contribution diff --git a/dev-requirements.txt b/dev-requirements.txt index b0a3db1..3a1e919 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -24,7 +24,7 @@ pylint-django==2.0.12 pylint-flask==0.6 pylint-plugin-utils==0.6 PyQRCode==1.2.1 -PyYAML==5.3.1 +PyYAML==5.4 regex==2020.4.4 requirements-detector==0.6 rope==0.16.0 @@ -34,6 +34,6 @@ snowballstemmer==2.0.0 toml==0.10.0 typed-ast==1.4.1 websocket-client==0.57.0 -websockets==8.1 +websockets==9.1 wrapt==1.11.2 yapf==0.30.0 diff --git a/kyros/bin_reader.py b/kyros/bin_reader.py new file mode 100644 index 0000000..c24b517 --- /dev/null +++ b/kyros/bin_reader.py @@ -0,0 +1,225 @@ +from .defines import WATags, WASingleByteTokens, WADoubleByteTokens, WAWebMessageInfo + + +class WABinaryReader: + """WhatsApp Binary Reader + Read binary data from WhatsApp stream protocol + """ + + def __init__(self, data): + self.data = data + self.index = 0 + + def check_eos(self, length): + """Check if the end of the stream has been reached""" + if self.index + length > len(self.data): + raise EOFError("end of stream reached") + + def read_byte(self): + """Read single byte from the stream""" + self.check_eos(1) + ret = ord(chr(self.data[self.index])) + self.index += 1 + return ret + + def read_int_n(self, n, littleEndian=False): + """Read integer value of n bytes""" + self.check_eos(n) + ret = 0 + for i in range(n): + currShift = i if littleEndian else n - 1 - i + ret |= ord(chr(self.data[self.index + i])) << (currShift * 8) + self.index += n + return ret + + def read_int16(self, littleEndian=False): + """Read 16-bit integer value""" + return self.read_int_n(2, littleEndian) + + def read_int20(self): + """Read 20-bit integer value""" + self.check_eos(3) + ret = ((ord(chr(self.data[self.index])) & 15) << 16) + (ord(chr(self.data[self.index + 1])) << 8) + ord(chr( + self.data[self.index + 2])) + self.index += 3 + return ret + + def read_int32(self, littleEndian=False): + """Read 32-bit integer value""" + return self.read_int_n(4, littleEndian) + + def read_int64(self, littleEndian=False): + """Read 64-bit integer value""" + return self.read_int_n(8, littleEndian) + + def read_packed8(self, tag): + """Read packed 8-bit string""" + startByte = self.read_byte() + ret = "" + for i in range(startByte & 127): + currByte = self.read_byte() + ret += self.unpack_byte(tag, (currByte & 0xF0) + >> 4) + self.unpack_byte(tag, currByte & 0x0F) + if (startByte >> 7) != 0: + ret = ret[:len(ret) - 1] + return ret + + def unpack_byte(self, tag, value): + """Handle byte as nibble digit or hex""" + if tag == WATags.NIBBLE_8: + return self.unpack_nibble(value) + elif tag == WATags.HEX_8: + return self.unpack_hex(value) + + def unpack_nibble(self, value): + """Convert value to digit or special chars""" + if 0 <= value <= 9: + return chr(ord('0') + value) + elif value == 10: + return "-" + elif value == 11: + return "." + elif value == 15: + return "\0" + raise ValueError("invalid nibble to unpack: " + value) + + def unpack_hex(self, value): + """Convert value to hex number""" + if value < 0 or value > 15: + raise ValueError("invalid hex to unpack: " + str(value)) + if value < 10: + return chr(ord('0') + value) + else: + return chr(ord('A') + value - 10) + + def is_list_tag(self, tag): + """Check if the given tag is a list tag""" + return tag == WATags.LIST_EMPTY or tag == WATags.LIST_8 or tag == WATags.LIST_16 + + def read_list_size(self, tag): + """Read the size of a list""" + if (tag == WATags.LIST_EMPTY): + return 0 + elif (tag == WATags.LIST_8): + return self.read_byte() + elif (tag == WATags.LIST_16): + return self.read_int16() + raise ValueError("invalid tag for list size: " + str(tag)) + + def read_string(self, tag): + """Read a string from the stream depending on the given tag""" + if tag >= 3 and tag <= 235: + token = self.get_token(tag) + if token == "s.whatsapp.net": + token = "c.us" + return token + + if tag == WATags.DICTIONARY_0 or tag == WATags.DICTIONARY_1 or tag == WATags.DICTIONARY_2 or tag == WATags.DICTIONARY_3: + return self.get_token_double(tag - WATags.DICTIONARY_0, self.read_byte()) + elif tag == WATags.LIST_EMPTY: + return + elif tag == WATags.BINARY_8: + return self.read_string_from_chars(self.read_byte()) + elif tag == WATags.BINARY_20: + return self.read_string_from_chars(self.read_int20()) + elif tag == WATags.BINARY_32: + return self.read_string_from_chars(self.read_int32()) + elif tag == WATags.JID_PAIR: + i = self.read_string(self.read_byte()) + j = self.read_string(self.read_byte()) + if i is None or j is None: + raise ValueError("invalid jid pair: " + str(i) + ", " + str(j)) + return i + "@" + j + elif tag == WATags.NIBBLE_8 or tag == WATags.HEX_8: + return self.read_packed8(tag) + else: + raise ValueError("invalid string with tag " + str(tag)) + + def read_string_from_chars(self, length): + """Read indexed string from the stream with the given length""" + self.check_eos(length) + ret = self.data[self.index:self.index + length] + self.index += length + return ret + + def read_attributes(self, n): + """Read n data attributes""" + ret = {} + if n == 0: + return + for i in range(n): + index = self.read_string(self.read_byte()) + ret[index] = self.read_string(self.read_byte()) + return ret + + def read_list(self, tag): + """Read a list of data""" + ret = [] + for i in range(self.read_list_size(tag)): + ret.append(self.read_node()) + return ret + + def read_node(self): + """Read an information node""" + listSize = self.read_list_size(self.read_byte()) + descrTag = self.read_byte() + if descrTag == WATags.STREAM_END: + raise ValueError("unexpected stream end") + descr = self.read_string(descrTag) + if listSize == 0 or not descr: + raise ValueError("invalid node") + attrs = self.read_attributes((listSize - 1) >> 1) + if listSize % 2 == 1: + return [descr, attrs, None] + + tag = self.read_byte() + if self.is_list_tag(tag): + content = self.read_list(tag) + elif tag == WATags.BINARY_8: + content = self.read_bytes(self.read_byte()) + elif tag == WATags.BINARY_20: + content = self.read_bytes(self.read_int20()) + elif tag == WATags.BINARY_32: + content = self.read_bytes(self.read_int32()) + else: + content = self.read_string(tag) + return [descr, attrs, content] + + def read_bytes(self, n): + """Read n bytes from the stream and return them as a string""" + ret = "" + for i in range(n): + ret += chr(self.read_byte()) + return ret + + def get_token(self, index): + """Get the token at the given index.""" + if index < 3 or index >= len(WASingleByteTokens): + raise ValueError("invalid token index: " + str(index)) + return WASingleByteTokens[index] + + def get_token_double(self, index1, index2): + """Get a token from a double byte index""" + n = 256 * index1 + index2 + if n < 0 or n >= len(WADoubleByteTokens): + raise ValueError("invalid token index: " + str(n)) + return WADoubleByteTokens[n] + + +def read_message_array(msgs): + """Read a list of messages""" + if not isinstance(msgs, list): + return msgs + ret = [] + for x in msgs: + ret.append(WAWebMessageInfo.decode(bytes(x[2], "utf-8")) if isinstance( + x, list) and x[0] == "message" else x) + return ret + + +def read_binary(data, withMessages=False): + """Read a binary message from WhatsApp stream""" + node = WABinaryReader(data).read_node() + if withMessages and node is not None and isinstance(node, list) and node[1] is not None: + node[2] = read_message_array(node[2]) + return node diff --git a/kyros/client.py b/kyros/client.py index fa0b4d9..9d6cbcf 100644 --- a/kyros/client.py +++ b/kyros/client.py @@ -24,11 +24,11 @@ class Client: result in the failing of message delivery). A much better and pythonic way to handle and raise exception is still a pending task.""" @classmethod - async def create(cls) -> Client: + async def create(cls, on_message=None) -> Client: """The proper way to instantiate `Client` class. Connects to websocket server, also sets up the default client profile. Returns a ready to use `Client` instance.""" - instance = cls() + instance = cls(on_message) await instance.setup_ws() instance.load_profile(constants.CLIENT_VERSION, constants.CLIENT_LONG_DESC, @@ -36,11 +36,11 @@ async def create(cls) -> Client: logger.info("Kyros instance created") return instance - def __init__(self) -> None: + def __init__(self, on_message=None) -> None: """Initiate class. Do not initiate this way, use `Client.create()` instead.""" self.profile = None - self.message_handler = message.MessageHandler() + self.message_handler = message.MessageHandler(on_message) self.session = session.Session() self.session.client_id = utilities.generate_client_id() self.session.private_key = donna25519.PrivateKey() @@ -125,6 +125,8 @@ async def wait_qr_scan(): self.session.enc_key = self.session.keys_decrypted[:32] self.session.mac_key = self.session.keys_decrypted[32:64] + await self.websocket.keep_alive() + qr_fragments = [ self.session.server_id, base64.b64encode(self.session.public_key.public).decode(), @@ -184,6 +186,8 @@ async def restore(): self.session.server_token = info["serverToken"] self.websocket.load_session(self.session) # reload references + + await self.websocket.keep_alive() return self.session try: diff --git a/kyros/defines.py b/kyros/defines.py new file mode 100644 index 0000000..df75f86 --- /dev/null +++ b/kyros/defines.py @@ -0,0 +1,152 @@ +from google.protobuf import json_format +import json +from .proto import WebMessageInfo + + +class WAFlags: + IGNORE = 1 << 7 + ACK_REQUEST = 1 << 6 + AVAILABLE = 1 << 5 + NOT_AVAILABLE = 1 << 4 + EXPIRES = 1 << 3 + SKIP_OFFLINE = 1 << 2 + + @staticmethod + def get(str): + return WAFlags.__dict__[str] + + +class WAMediaAppInfo: + imageMessage = "WhatsApp Image Keys" + stickerMessage = "WhatsApp Image Keys" + videoMessage = "WhatsApp Video Keys" + audioMessage = "WhatsApp Audio Keys" + documentMessage = "WhatsApp Document Keys" + + @staticmethod + def get(str): + return WAMediaAppInfo.__dict__[str] + + +class WAWebMessageInfo: + @staticmethod + def decode(data): + msg = WebMessageInfo() + msg.ParseFromString(data) + return json.loads(json_format.MessageToJson(msg)) + + @staticmethod + def encode(msg): + data = json_format.Parse(json.dumps( + msg), WebMessageInfo(), ignore_unknown_fields=True) + return data.SerializeToString() + + +class WAMetrics: + DEBUG_LOG = 1 + QUERY_RESUME = 2 + QUERY_RECEIPT = 3 + QUERY_MEDIA = 4 + QUERY_CHAT = 5 + QUERY_CONTACTS = 6 + QUERY_MESSAGES = 7 + PRESENCE = 8 + PRESENCE_SUBSCRIBE = 9 + GROUP = 10 + READ = 11 + CHAT = 12 + RECEIVED = 13 + PIC = 14 + STATUS = 15 + MESSAGE = 16 + QUERY_ACTIONS = 17 + BLOCK = 18 + QUERY_GROUP = 19 + QUERY_PREVIEW = 20 + QUERY_EMOJI = 21 + QUERY_MESSAGE_INFO = 22 + SPAM = 23 + QUERY_SEARCH = 24 + QUERY_IDENTITY = 25 + QUERY_URL = 26 + PROFILE = 27 + CONTACT = 28 + QUERY_VCARD = 29 + QUERY_STATUS = 30 + QUERY_STATUS_UPDATE = 31 + PRIVACY_STATUS = 32 + QUERY_LIVE_LOCATIONS = 33 + LIVE_LOCATION = 34 + QUERY_VNAME = 35 + QUERY_LABELS = 36 + CALL = 37 + QUERY_CALL = 38 + QUERY_QUICK_REPLIES = 39 + QUERY_CALL_OFFER = 40 + QUERY_RESPONSE = 41 + QUERY_STICKER_PACKS = 42 + QUERY_STICKERS = 43 + ADD_OR_REMOVE_LABELS = 44 + QUERY_NEXT_LABEL_COLOR = 45 + QUERY_LABEL_PALETTE = 46 + CREATE_OR_DELETE_LABELS = 47 + EDIT_LABELS = 48 + + @staticmethod + def get(str): + return WAMetrics.__dict__[str] + + +class WATags: + LIST_EMPTY = 0 + STREAM_END = 2 + DICTIONARY_0 = 236 + DICTIONARY_1 = 237 + DICTIONARY_2 = 238 + DICTIONARY_3 = 239 + LIST_8 = 248 + LIST_16 = 249 + JID_PAIR = 250 + HEX_8 = 251 + BINARY_8 = 252 + BINARY_20 = 253 + BINARY_32 = 254 + NIBBLE_8 = 255 + SINGLE_BYTE_MAX = 256 + PACKED_MAX = 254 + + @staticmethod + def get(str): + return WATags.__dict__[str] + + +WASingleByteTokens = [ + None, None, None, "200", "400", "404", "500", "501", "502", "action", "add", + "after", "archive", "author", "available", "battery", "before", "body", + "broadcast", "chat", "clear", "code", "composing", "contacts", "count", + "create", "debug", "delete", "demote", "duplicate", "encoding", "error", + "false", "filehash", "from", "g.us", "group", "groups_v2", "height", "id", + "image", "in", "index", "invis", "item", "jid", "kind", "last", "leave", + "live", "log", "media", "message", "mimetype", "missing", "modify", "name", + "notification", "notify", "out", "owner", "participant", "paused", + "picture", "played", "presence", "preview", "promote", "query", "raw", + "read", "receipt", "received", "recipient", "recording", "relay", + "remove", "response", "resume", "retry", "s.whatsapp.net", "seconds", + "set", "size", "status", "subject", "subscribe", "t", "text", "to", "true", + "type", "unarchive", "unavailable", "url", "user", "value", "web", "width", + "mute", "read_only", "admin", "creator", "short", "update", "powersave", + "checksum", "epoch", "block", "previous", "409", "replaced", "reason", + "spam", "modify_tag", "message_info", "delivery", "emoji", "title", + "description", "canonical-url", "matched-text", "star", "unstar", + "media_key", "filename", "identity", "unread", "page", "page_count", + "search", "media_message", "security", "call_log", "profile", "ciphertext", + "invite", "gif", "vcard", "frequent", "privacy", "blacklist", "whitelist", + "verify", "location", "document", "elapsed", "revoke_invite", "expiration", + "unsubscribe", "disable", "vname", "old_jid", "new_jid", "announcement", + "locked", "prop", "label", "color", "call", "offer", "call-id", + "quick_reply", "sticker", "pay_t", "accept", "reject", "sticker_pack", + "invalid", "canceled", "missed", "connected", "result", "audio", + "video", "recent" +] + +WADoubleByteTokens = [] diff --git a/kyros/dev-requirements.txt b/kyros/dev-requirements.txt index 6a80a4b..d79bbb7 100644 --- a/kyros/dev-requirements.txt +++ b/kyros/dev-requirements.txt @@ -43,7 +43,7 @@ pylint-django==2.0.12 pylint-flask==0.6 pylint-plugin-utils==0.6 PyQRCode==1.2.1 -PyYAML==5.3.1 +PyYAML==5.4 regex==2020.4.4 requirements-detector==0.6 rope==0.16.0 @@ -56,7 +56,7 @@ typed-ast==1.4.1 typing==3.7.4.1 watchdog==0.10.2 websocket-client==0.57.0 -websockets==8.1 +websockets==9.1 wrapt==1.11.2 yapf==0.30.0 zipp==3.1.0 diff --git a/kyros/message.py b/kyros/message.py index 2a0e489..db57881 100644 --- a/kyros/message.py +++ b/kyros/message.py @@ -1,5 +1,31 @@ +import logging +from .bin_reader import read_binary +logger = logging.getLogger(__name__) # pylint: disable=invalid-name + + class MessageHandler: - """Future class. To be implemented soon.""" + + def __init__(self, on_message=None) -> None: + """Initialize the message handler setting the callback function""" + self.on_message = on_message + + def handle_message(self, message): + """Decode binary message""" + if message.data != "": + try: + msg_data = read_binary(message.data, True) + if self.on_message is not None: + self.on_message(msg_data) + + """Message must be identified by type to call related handler""" + + logger.debug("Received message: %s", msg_data) + except Exception as exc: + logger.error( + "There were an exception error processing received message: %s", str(exc)) + else: + logger.error("Unknown empty message: %s", message) + def handle_text_message(self): pass diff --git a/kyros/websocket.py b/kyros/websocket.py index 46925a9..2268f02 100644 --- a/kyros/websocket.py +++ b/kyros/websocket.py @@ -14,6 +14,20 @@ logger = logging.getLogger(__name__) # pylint: disable=invalid-name +class Timer: + def __init__(self, timeout, callback): + self._timeout = timeout + self._callback = callback + self._task = asyncio.ensure_future(self._job()) + + async def _job(self): + await asyncio.sleep(self._timeout) + await self._callback() + + def cancel(self): + self._task.cancel() + + class WebsocketMessage: """ `WebsocketMessage` acts as a container for websocket messages. @@ -21,6 +35,7 @@ class WebsocketMessage: data (for binary messages). `tag` is also automatically generated if None is given as the tag. """ + def __init__(self, tag: Optional[str] = None, data: Optional[AnyStr] = None, @@ -142,10 +157,16 @@ async def connect(self) -> None: listener.""" logger.debug("Connecting to ws server") self.websocket = await websockets.connect( - constants.WEBSOCKET_URI, origin=constants.WEBSOCKET_ORIGIN) + constants.WEBSOCKET_URI, origin=constants.WEBSOCKET_ORIGIN, close_timeout=None, ping_interval=None) logger.debug("Websocket connected") self._start_receiver() + async def keep_alive(self): + """Emits a message to the server to keep the connection alive.""" + if self.websocket and self.websocket.open: + await self.websocket.send('?,,') + Timer(10.0, self.keep_alive) + def load_session(self, session: Session) -> None: """Loads a session. This will make sure that all references are updated. If there is a key change, the new key will be used to @@ -177,6 +198,11 @@ async def receiver(): continue raw_message = self.websocket.messages.pop() + + # Ignore server timestamp responses + if raw_message[:1] == "!": + continue + try: message = WebsocketMessage.unserialize( raw_message, self.get_keys()) @@ -188,7 +214,18 @@ async def receiver(): if message: logger.debug("Received WS message with tag %s", message.tag) - self.messages.add(message.tag, message.data) + + """Try to parse message as textplain JSON to differentiate from binary messages + Plaintext messages are added to the messages queue. + Binary messages are handled by the message handler.""" + try: + obj = json.loads(json.dumps(message.data)) + self.messages.add(message.tag, message.data) + if obj[0] == "Conn": + await self.keep_alive() + continue + except: + self.handle_message.handle_message(message) asyncio.ensure_future(receiver()) logger.debug("Executed receiver coroutine")