├── .gitattributes ├── .github └── workflows │ └── pythonpackage.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── dev-requirements.txt ├── html └── kyros │ ├── client.html │ ├── constants.html │ ├── crypto.html │ ├── exceptions.html │ ├── index.html │ ├── message.html │ ├── proto │ ├── def_pb2.html │ └── index.html │ ├── session.html │ ├── utilities.html │ └── websocket.html ├── kyros ├── __init__.py ├── client.py ├── constants.py ├── crypto.py ├── dev-requirements.txt ├── exceptions.py ├── message.py ├── proto │ ├── __init__.py │ ├── def.proto │ └── def_pb2.py ├── session.py ├── utilities.py └── websocket.py ├── requirements.txt └── setup.py /.gitattributes: -------------------------------------------------------------------------------- 1 | html/kyros/* linguist-vendored 2 | -------------------------------------------------------------------------------- /.github/workflows/pythonpackage.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Python package 5 | 6 | on: 7 | push: 8 | branches: [master, develop] 9 | pull_request: 10 | branches: [master, develop] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | python-version: [3.6, 3.7, 3.8] 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v1 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install flake8 30 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 31 | - name: Lint with flake8 32 | run: | 33 | # stop the build if there are Python syntax errors or undefined names 34 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 35 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 36 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 37 | - name: Install with pip test 38 | run: | 39 | pip install git+https://git@github.com/ttycelery/kyros 40 | 41 | 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | venv 2 | .vscode 3 | __pycache__ -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | kyros@lcat.dev. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | First of all, we would like to thank you for reading this. Kyros needs volunteer developers to strive and grow. 3 | The following is a set of guidelines for contributing to Kyros. 4 | Those are mostly guidelines, not rules. Use your best judgement, and feel free to propose changes to this document in a pull request. 5 | 6 | ## Testing 7 | Since Kyros is a wrapper to WhatsApp Web API, it is quite hard to automate tests. This far, we haven't made any automated tests (if you have an idea, feel free to propose it). 8 | Before you submit your changes, please make sure that they work properly and don't break other things. 9 | 10 | ## Submitting changes 11 | Please send a GitHub Pull Request with a clear list of what you've done. A brief summary will also be helpful. 12 | Before submitting, try reading your pull request. If you find it easy to understand, it should be good to go. 13 | 14 | ## Coding conventions 15 | Kyros is a Python project and therefore should comply to the [PEP-8](https://www.python.org/dev/peps/pep-0008/) coding style standard. 16 | > "Beautiful is better than ugly." 17 | — one of the lines in the Zen of Python, by Tim Peters 18 | 19 | The use of linters like `flake8` will be very helpful. Sometimes, there are some times where you really have to break the rules. 20 | Just make sure that you write a consistent (with the whole project), efficient, and readable enough code. 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 loncat 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Important Note 2 | 3 | **This project is no longer maintained**. As per this commit, I decided not to 4 | maintain the project anymore. Since last year, WhatsApp has been making many 5 | changes (most notably the offline feature) that directly affected this project. 6 | I have been so busy that I can't keep up with the pace, and I don't plan to 7 | continue it in the future either. 8 | 9 | Thanks to anyone who has been using and/or helping with the development of this 10 | project. 11 | 12 | If someone wants to continue the project (e.g. by making a fork), I would 13 | appreciate that. 14 | 15 | # Kyros 16 | 17 | Kyros, for now, is a Python interface to communicate easier with WhatsApp Web 18 | API. It provides an interface to connect and communicate with WhatsApp Web's 19 | websocket server. Kyros will handle encryption and decryption kind of things. In 20 | the future, Kyros is aimed to provide a full implementation of WhatsApp Web API 21 | which will give developers a clean interface to work with (more or less like 22 | [go-whatsapp](https://github.com/Rhymen/go-whatsapp)). This module is designed 23 | to work with Python 3.6 or latest. Special thanks to the creator of 24 | [whatsapp-web-reveng](https://github.com/sigalor/whatsapp-web-reveng) and 25 | [go-whatsapp](https://github.com/Rhymen/go-whatsapp). This project is largely 26 | motivated by their work. Please note that Kyros is not meant to be used actively 27 | in production servers as it is currently not production ready. Use it at your 28 | own risk. 29 | 30 | ## Installation 31 | 32 | Kyros could be installed by using `pip` or directly cloning it then invoking 33 | `setup.py`. For example, if you want to use pip, run the following command: 34 | 35 | pip install git+https://git@github.com/ttycelery/kyros 36 | 37 | ## Documentation 38 | 39 | ### A simple example 40 | 41 | ``` {.python} 42 | import asyncio 43 | import logging 44 | 45 | import pyqrcode 46 | 47 | import kyros 48 | 49 | logging.basicConfig() 50 | # set a logging level: just to know if something (bad?) happens 51 | logging.getLogger("kyros").setLevel(logging.WARNING) 52 | 53 | async def main(): 54 | # create the Client instance using create class method 55 | whatsapp = await kyros.Client.create() 56 | 57 | # do a QR login 58 | qr_data, scanned = await whatsapp.qr_login() 59 | 60 | # generate qr code image 61 | qr_code = pyqrcode.create(qr_data) 62 | print(qr_code.terminal(quiet_zone=1)) 63 | 64 | try: 65 | # wait for the QR code to be scanned 66 | await scanned 67 | except asyncio.TimeoutError: 68 | # timed out (left unscanned), do a shutdown 69 | await whatsapp.shutdown() 70 | return 71 | 72 | # how to send a websocket message 73 | message = kyros.WebsocketMessage(None, ["query", "exist", "1234@c.us"]) 74 | await whatsapp.websocket.send_message(message) 75 | 76 | # receive a websocket message 77 | print(await whatsapp.websocket.messages.get(message.tag)) 78 | 79 | 80 | if __name__ == "__main__": 81 | asyncio.run(main()) 82 | ``` 83 | 84 | A "much more detailed documentation" kind of thing for this project is available 85 | [here](https://ttycelery.github.io/kyros/). You will see a piece of nightmare, 86 | happy exploring! Better documentation are being planned. 87 | 88 | ## Contribution 89 | 90 | This work is still being slowly developed. Your contribution will of course make 91 | the development process of this project even faster. Any kind of contribution is 92 | highly appreciated. 93 | 94 | ## License 95 | 96 | This project is licensed with MIT License. 97 | 98 | ## Disclaimer 99 | 100 | This code is in no way affiliated with, authorized, maintained, sponsored or 101 | endorsed by WhatsApp or any of its affiliates or subsidiaries. This is an 102 | independent and unofficial software. Use at your own risk. 103 | -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | appdirs==1.4.3 2 | astroid==2.3.3 3 | attrs==19.3.0 4 | black==19.10b0 5 | click==7.1.2 6 | curve25519==0.1 7 | dodgy==0.2.1 8 | donna25519==0.1.1 9 | entrypoints==0.3 10 | flake8==3.7.9 11 | isort==4.3.21 12 | lazy-object-proxy==1.4.3 13 | mccabe==0.6.1 14 | pathspec==0.8.0 15 | pep8-naming==0.4.1 16 | pkg-resources==0.0.0 17 | prospector==1.2.0 18 | pycodestyle==2.4.0 19 | pydocstyle==5.0.2 20 | pyflakes==2.1.1 21 | pylint==2.4.4 22 | pylint-celery==0.3 23 | pylint-django==2.0.12 24 | pylint-flask==0.6 25 | pylint-plugin-utils==0.6 26 | PyQRCode==1.2.1 27 | PyYAML==5.4 28 | regex==2020.4.4 29 | requirements-detector==0.6 30 | rope==0.16.0 31 | setoptconf==0.2.0 32 | six==1.14.0 33 | snowballstemmer==2.0.0 34 | toml==0.10.0 35 | typed-ast==1.4.1 36 | websocket-client==0.57.0 37 | websockets==9.1 38 | wrapt==1.11.2 39 | yapf==0.30.0 40 | -------------------------------------------------------------------------------- /html/kyros/client.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | kyros.client API documentation 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 |
19 |
20 |

Module kyros.client

21 |
22 |
23 |
24 | 25 | Expand source code 26 | 27 |
from __future__ import annotations
 28 | 
 29 | import asyncio
 30 | import base64
 31 | import logging
 32 | from typing import Any, Awaitable, Callable, Sequence, Union
 33 | 
 34 | import donna25519
 35 | 
 36 | from . import (constants, crypto, exceptions, message, session, utilities,
 37 |                websocket)
 38 | 
 39 | logger = logging.getLogger(__name__)  # pylint: disable=invalid-name
 40 | 
 41 | 
 42 | class Client:
 43 |     """This class is the wrapper for WhatsApp Web.
 44 |     Errors mainly shown as log messages (using `logging`).
 45 |     Some methods might raise an exception that will interrupt the
 46 |     whole session. Please make sure to catch any exception thrown.
 47 |     You might want to use the `Client.ensure_safe` method.
 48 |     Please note that some exceptions should not be ignored as it might
 49 |     be important (example: a timeout error when sending a message will
 50 |     result in the failing of message delivery). A much better and pythonic
 51 |     way to handle and raise exception is still a pending task."""
 52 |     @classmethod
 53 |     async def create(cls) -> Client:
 54 |         """The proper way to instantiate `Client` class. Connects to
 55 |         websocket server, also sets up the default client profile.
 56 |         Returns a ready to use `Client` instance."""
 57 |         instance = cls()
 58 |         await instance.setup_ws()
 59 |         instance.load_profile(constants.CLIENT_VERSION,
 60 |                               constants.CLIENT_LONG_DESC,
 61 |                               constants.CLIENT_SHORT_DESC)
 62 |         logger.info("Kyros instance created")
 63 |         return instance
 64 | 
 65 |     def __init__(self) -> None:
 66 |         """Initiate class. Do not initiate this way, use `Client.create()`
 67 |         instead."""
 68 |         self.profile = None
 69 |         self.message_handler = message.MessageHandler()
 70 |         self.session = session.Session()
 71 |         self.session.client_id = utilities.generate_client_id()
 72 |         self.session.private_key = donna25519.PrivateKey()
 73 |         self.session.public_key = self.session.private_key.get_public()
 74 |         self.phone_info = {}
 75 |         self.websocket = None
 76 | 
 77 |     async def setup_ws(self) -> None:
 78 |         """Connect to websocket server."""
 79 |         self.websocket = websocket.WebsocketClient(self.message_handler)
 80 |         await self.websocket.connect()
 81 |         self.websocket.load_session(self.session)
 82 | 
 83 |     def load_profile(self, ver: Sequence[Union[float, int]], long_desc: str,
 84 |                      short_desc: str) -> None:
 85 |         """Loads a new client profile (which will be shown in the WhatsApp
 86 |         mobile app). Please note that the client profile is unchangeable after
 87 |         logging in (after admin init)."""
 88 |         logger.debug("Loaded new profile")
 89 |         self.profile = {
 90 |             "version": ver,
 91 |             "long_description": long_desc,
 92 |             "short_description": short_desc,
 93 |         }
 94 | 
 95 |     async def send_init(self) -> None:
 96 |         """Send an admin init message. Usually not used directly. Used whens
 97 |         doing QR login or restoring session."""
 98 |         init_message = websocket.WebsocketMessage(None, [
 99 |             "admin", "init", self.profile["version"],
100 |             [
101 |                 self.profile["long_description"],
102 |                 self.profile["short_description"]
103 |             ], self.session.client_id, True
104 |         ])
105 |         await self.websocket.send_message(init_message)
106 | 
107 |         resp = await self.websocket.messages.get(init_message.tag)
108 |         if resp["status"] != 200:
109 |             logger.error("unexpected init stts code, resp:%s", resp)
110 |             raise exceptions.StatusCodeError(resp["status"])
111 | 
112 |         self.session.server_id = resp["ref"]
113 | 
114 |     async def qr_login(self) -> (str, Awaitable):
115 |         """Does a QR login. Sends init then return the qr data
116 |         which will be shown using `pyqrcode` or another library and.
117 |         also returns a waitable which will timeout in 20 seconds.
118 |         20 seconds is the maximum amount of time for the QR code to be
119 |         considered valid.
120 |         Raises `asyncio.TimeoutError` if timeout reached.
121 |         Another exception might also possible."""
122 |         await self.send_init()
123 | 
124 |         async def wait_qr_scan():
125 |             ws_message = await self.websocket.messages.get("s1", 20)
126 |             connection_data = ws_message[1]
127 | 
128 |             self.phone_info = connection_data["phone"]
129 |             self.session.secret = base64.b64decode(
130 |                 connection_data["secret"].encode())
131 |             self.session.server_token = connection_data["serverToken"]
132 |             self.session.client_token = connection_data["clientToken"]
133 |             self.session.browser_token = connection_data["browserToken"]
134 |             self.session.wid = connection_data["wid"]
135 | 
136 |             self.session.shared_secret = self.session.private_key.do_exchange(
137 |                 donna25519.PublicKey(self.session.secret[:32]))
138 |             self.session.shared_secret_expanded = crypto.hkdf_expand(
139 |                 self.session.shared_secret, 80)
140 | 
141 |             if not crypto.validate_secrets(
142 |                     self.session.secret, self.session.shared_secret_expanded):
143 |                 raise exceptions.HMACValidationError
144 | 
145 |             self.session.keys_encrypted = self.session.shared_secret_expanded[
146 |                 64:] + self.session.secret[64:]
147 |             self.session.keys_decrypted = crypto.aes_decrypt(
148 |                 self.session.shared_secret_expanded[:32],
149 |                 self.session.keys_encrypted)
150 | 
151 |             self.session.enc_key = self.session.keys_decrypted[:32]
152 |             self.session.mac_key = self.session.keys_decrypted[32:64]
153 |             print(self.session.enc_key, self.session.mac_key)
154 | 
155 |         qr_fragments = [
156 |             self.session.server_id,
157 |             base64.b64encode(self.session.public_key.public).decode(),
158 |             self.session.client_id
159 |         ]
160 |         qr_data = ",".join(qr_fragments)
161 | 
162 |         return qr_data, wait_qr_scan()
163 | 
164 |     async def restore_session(  # noqa: mc0001
165 |             self, new_session: session.Session = None) -> session.Session:
166 |         """Restores a session. Returns the new session object.
167 |         If `new_session` argument specified, replace current session with
168 |         the new one.
169 |         Raises asyncio.TimeoutError when a websocket request reaches timeout.
170 |         Old session is restored when it fails restoring the new one."""
171 |         old_session = self.session
172 |         if new_session:
173 |             self.session = new_session
174 | 
175 |         async def restore():
176 |             await self.send_init()
177 | 
178 |             login_message = websocket.WebsocketMessage(None, [
179 |                 "admin", "login", self.session.client_token,
180 |                 self.session.server_token, self.session.client_id, "takeover"
181 |             ])
182 |             await self.websocket.send_message(login_message)
183 | 
184 |             s1_message = None
185 |             try:
186 |                 s1_message = await self.websocket.messages.get("s1")
187 |             except asyncio.TimeoutError:
188 |                 logger.error("s1 message timed out")
189 | 
190 |                 login_resp = await self.websocket.messages.get(
191 |                     login_message.tag)
192 |                 if login_resp["status"] != 200:
193 |                     raise exceptions.StatusCodeError(login_resp["status"])
194 |                 self.websocket.messages.add(login_message.tag, login_resp)
195 | 
196 |             s2_message = None
197 |             if len(s1_message) == 2 and s1_message[0] == "Cmd" \
198 |                     and s1_message[1]["type"] == "challenge":
199 |                 if not self.resolve_challenge(s1_message["challenge"]):
200 |                     logger.error("failed to solve challenge")
201 |                     return False
202 | 
203 |                 s2_message = self.websocket.messages.get("s2")
204 | 
205 |             login_resp = await self.websocket.messages.get(login_message.tag)
206 |             if login_resp["status"] != 200:
207 |                 raise exceptions.StatusCodeError(login_resp["status"])
208 | 
209 |             conn_resp = s2_message if s2_message else s1_message
210 |             self.phone_info = conn_resp["phone"]
211 |             self.session.wid = conn_resp["wid"]
212 |             self.session.client_token = conn_resp["clientToken"]
213 |             self.session.server_token = conn_resp["serverToken"]
214 | 
215 |             self.websocket.load_session(self.session)  # reload references
216 | 
217 |             return self.session
218 | 
219 |         try:
220 |             return await restore()
221 |         except Exception:  # pylint: disable=broad-except
222 |             if old_session:
223 |                 self.session = old_session
224 |             raise
225 | 
226 |     async def resolve_challenge(self, challenge: str) -> None:
227 |         """Resolve a challenge string. Sings challenge with mac_key and send
228 |         a challenge response ws message. Usually called when restoring session.
229 |         Raises `asyncio.TimeoutError` when timeout reached."""
230 |         challenge = base64.b64decode(challenge.encode()).decode()
231 |         signed = crypto.hmac_sha256(self.session.mac_key, challenge)
232 | 
233 |         chall_reply_message = websocket.WebsocketMessage(
234 |             None, [
235 |                 "admin", "challenge",
236 |                 base64.b64encode(signed).decode(), self.session.server_token,
237 |                 self.session.client_id
238 |             ])
239 |         await self.websocket.send_message(chall_reply_message)
240 | 
241 |         status = self.websocket.messages.get(chall_reply_message)["status"]
242 |         if status != 200:
243 |             raise exceptions.StatusCodeError(status)
244 | 
245 |         return
246 | 
247 |     async def ensure_safe(self, func: Callable, *args: Any,
248 |                           **kwargs: Any) -> (Union[None, Exception], Any):
249 |         """A function intended to be used to run another function without
250 |         raising any exception. Returns an exception as first element of
251 |         the tuple if available. Also returns the result of the function call
252 |         as the second element of the tuple if no exceptions raised. If `func`
253 |         is a coroutine function, this function returns the awaited result.
254 |         """
255 |         try:
256 |             return_value = func(*args, **kwargs)
257 |             if asyncio.iscoroutine(return_value):
258 |                 return None, await return_value
259 |             return None, return_value
260 |         except Exception as exc:  # pylint: disable=broad-except
261 |             logger.error("Exception %s raised at %s", exc, func.__name__)
262 |             return exc, None
263 | 
264 |     async def logout(self) -> None:
265 |         """Sends a logout message to the websocket server. This will
266 |         invalidate the session."""
267 |         await self.websocket.send_message(
268 |             websocket.WebsocketMessage(None, ["admin", "Conn", "disconnect"]))
269 | 
270 |     async def shutdown(self) -> None:
271 |         """Do a cleanup. Closes websocket connection."""
272 |         logger.info("Shutting down")
273 |         await self.websocket.shutdown()
274 |
275 |
276 |
277 |
278 |
279 |
280 |
281 |
282 |
283 |

Classes

284 |
285 |
286 | class Client 287 |
288 |
289 |

This class is the wrapper for WhatsApp Web. 290 | Errors mainly shown as log messages (using logging). 291 | Some methods might raise an exception that will interrupt the 292 | whole session. Please make sure to catch any exception thrown. 293 | You might want to use the Client.ensure_safe() method. 294 | Please note that some exceptions should not be ignored as it might 295 | be important (example: a timeout error when sending a message will 296 | result in the failing of message delivery). A much better and pythonic 297 | way to handle and raise exception is still a pending task.

298 |

Initiate class. Do not initiate this way, use Client.create() 299 | instead.

300 |
301 | 302 | Expand source code 303 | 304 |
class Client:
305 |     """This class is the wrapper for WhatsApp Web.
306 |     Errors mainly shown as log messages (using `logging`).
307 |     Some methods might raise an exception that will interrupt the
308 |     whole session. Please make sure to catch any exception thrown.
309 |     You might want to use the `Client.ensure_safe` method.
310 |     Please note that some exceptions should not be ignored as it might
311 |     be important (example: a timeout error when sending a message will
312 |     result in the failing of message delivery). A much better and pythonic
313 |     way to handle and raise exception is still a pending task."""
314 |     @classmethod
315 |     async def create(cls) -> Client:
316 |         """The proper way to instantiate `Client` class. Connects to
317 |         websocket server, also sets up the default client profile.
318 |         Returns a ready to use `Client` instance."""
319 |         instance = cls()
320 |         await instance.setup_ws()
321 |         instance.load_profile(constants.CLIENT_VERSION,
322 |                               constants.CLIENT_LONG_DESC,
323 |                               constants.CLIENT_SHORT_DESC)
324 |         logger.info("Kyros instance created")
325 |         return instance
326 | 
327 |     def __init__(self) -> None:
328 |         """Initiate class. Do not initiate this way, use `Client.create()`
329 |         instead."""
330 |         self.profile = None
331 |         self.message_handler = message.MessageHandler()
332 |         self.session = session.Session()
333 |         self.session.client_id = utilities.generate_client_id()
334 |         self.session.private_key = donna25519.PrivateKey()
335 |         self.session.public_key = self.session.private_key.get_public()
336 |         self.phone_info = {}
337 |         self.websocket = None
338 | 
339 |     async def setup_ws(self) -> None:
340 |         """Connect to websocket server."""
341 |         self.websocket = websocket.WebsocketClient(self.message_handler)
342 |         await self.websocket.connect()
343 |         self.websocket.load_session(self.session)
344 | 
345 |     def load_profile(self, ver: Sequence[Union[float, int]], long_desc: str,
346 |                      short_desc: str) -> None:
347 |         """Loads a new client profile (which will be shown in the WhatsApp
348 |         mobile app). Please note that the client profile is unchangeable after
349 |         logging in (after admin init)."""
350 |         logger.debug("Loaded new profile")
351 |         self.profile = {
352 |             "version": ver,
353 |             "long_description": long_desc,
354 |             "short_description": short_desc,
355 |         }
356 | 
357 |     async def send_init(self) -> None:
358 |         """Send an admin init message. Usually not used directly. Used whens
359 |         doing QR login or restoring session."""
360 |         init_message = websocket.WebsocketMessage(None, [
361 |             "admin", "init", self.profile["version"],
362 |             [
363 |                 self.profile["long_description"],
364 |                 self.profile["short_description"]
365 |             ], self.session.client_id, True
366 |         ])
367 |         await self.websocket.send_message(init_message)
368 | 
369 |         resp = await self.websocket.messages.get(init_message.tag)
370 |         if resp["status"] != 200:
371 |             logger.error("unexpected init stts code, resp:%s", resp)
372 |             raise exceptions.StatusCodeError(resp["status"])
373 | 
374 |         self.session.server_id = resp["ref"]
375 | 
376 |     async def qr_login(self) -> (str, Awaitable):
377 |         """Does a QR login. Sends init then return the qr data
378 |         which will be shown using `pyqrcode` or another library and.
379 |         also returns a waitable which will timeout in 20 seconds.
380 |         20 seconds is the maximum amount of time for the QR code to be
381 |         considered valid.
382 |         Raises `asyncio.TimeoutError` if timeout reached.
383 |         Another exception might also possible."""
384 |         await self.send_init()
385 | 
386 |         async def wait_qr_scan():
387 |             ws_message = await self.websocket.messages.get("s1", 20)
388 |             connection_data = ws_message[1]
389 | 
390 |             self.phone_info = connection_data["phone"]
391 |             self.session.secret = base64.b64decode(
392 |                 connection_data["secret"].encode())
393 |             self.session.server_token = connection_data["serverToken"]
394 |             self.session.client_token = connection_data["clientToken"]
395 |             self.session.browser_token = connection_data["browserToken"]
396 |             self.session.wid = connection_data["wid"]
397 | 
398 |             self.session.shared_secret = self.session.private_key.do_exchange(
399 |                 donna25519.PublicKey(self.session.secret[:32]))
400 |             self.session.shared_secret_expanded = crypto.hkdf_expand(
401 |                 self.session.shared_secret, 80)
402 | 
403 |             if not crypto.validate_secrets(
404 |                     self.session.secret, self.session.shared_secret_expanded):
405 |                 raise exceptions.HMACValidationError
406 | 
407 |             self.session.keys_encrypted = self.session.shared_secret_expanded[
408 |                 64:] + self.session.secret[64:]
409 |             self.session.keys_decrypted = crypto.aes_decrypt(
410 |                 self.session.shared_secret_expanded[:32],
411 |                 self.session.keys_encrypted)
412 | 
413 |             self.session.enc_key = self.session.keys_decrypted[:32]
414 |             self.session.mac_key = self.session.keys_decrypted[32:64]
415 |             print(self.session.enc_key, self.session.mac_key)
416 | 
417 |         qr_fragments = [
418 |             self.session.server_id,
419 |             base64.b64encode(self.session.public_key.public).decode(),
420 |             self.session.client_id
421 |         ]
422 |         qr_data = ",".join(qr_fragments)
423 | 
424 |         return qr_data, wait_qr_scan()
425 | 
426 |     async def restore_session(  # noqa: mc0001
427 |             self, new_session: session.Session = None) -> session.Session:
428 |         """Restores a session. Returns the new session object.
429 |         If `new_session` argument specified, replace current session with
430 |         the new one.
431 |         Raises asyncio.TimeoutError when a websocket request reaches timeout.
432 |         Old session is restored when it fails restoring the new one."""
433 |         old_session = self.session
434 |         if new_session:
435 |             self.session = new_session
436 | 
437 |         async def restore():
438 |             await self.send_init()
439 | 
440 |             login_message = websocket.WebsocketMessage(None, [
441 |                 "admin", "login", self.session.client_token,
442 |                 self.session.server_token, self.session.client_id, "takeover"
443 |             ])
444 |             await self.websocket.send_message(login_message)
445 | 
446 |             s1_message = None
447 |             try:
448 |                 s1_message = await self.websocket.messages.get("s1")
449 |             except asyncio.TimeoutError:
450 |                 logger.error("s1 message timed out")
451 | 
452 |                 login_resp = await self.websocket.messages.get(
453 |                     login_message.tag)
454 |                 if login_resp["status"] != 200:
455 |                     raise exceptions.StatusCodeError(login_resp["status"])
456 |                 self.websocket.messages.add(login_message.tag, login_resp)
457 | 
458 |             s2_message = None
459 |             if len(s1_message) == 2 and s1_message[0] == "Cmd" \
460 |                     and s1_message[1]["type"] == "challenge":
461 |                 if not self.resolve_challenge(s1_message["challenge"]):
462 |                     logger.error("failed to solve challenge")
463 |                     return False
464 | 
465 |                 s2_message = self.websocket.messages.get("s2")
466 | 
467 |             login_resp = await self.websocket.messages.get(login_message.tag)
468 |             if login_resp["status"] != 200:
469 |                 raise exceptions.StatusCodeError(login_resp["status"])
470 | 
471 |             conn_resp = s2_message if s2_message else s1_message
472 |             self.phone_info = conn_resp["phone"]
473 |             self.session.wid = conn_resp["wid"]
474 |             self.session.client_token = conn_resp["clientToken"]
475 |             self.session.server_token = conn_resp["serverToken"]
476 | 
477 |             self.websocket.load_session(self.session)  # reload references
478 | 
479 |             return self.session
480 | 
481 |         try:
482 |             return await restore()
483 |         except Exception:  # pylint: disable=broad-except
484 |             if old_session:
485 |                 self.session = old_session
486 |             raise
487 | 
488 |     async def resolve_challenge(self, challenge: str) -> None:
489 |         """Resolve a challenge string. Sings challenge with mac_key and send
490 |         a challenge response ws message. Usually called when restoring session.
491 |         Raises `asyncio.TimeoutError` when timeout reached."""
492 |         challenge = base64.b64decode(challenge.encode()).decode()
493 |         signed = crypto.hmac_sha256(self.session.mac_key, challenge)
494 | 
495 |         chall_reply_message = websocket.WebsocketMessage(
496 |             None, [
497 |                 "admin", "challenge",
498 |                 base64.b64encode(signed).decode(), self.session.server_token,
499 |                 self.session.client_id
500 |             ])
501 |         await self.websocket.send_message(chall_reply_message)
502 | 
503 |         status = self.websocket.messages.get(chall_reply_message)["status"]
504 |         if status != 200:
505 |             raise exceptions.StatusCodeError(status)
506 | 
507 |         return
508 | 
509 |     async def ensure_safe(self, func: Callable, *args: Any,
510 |                           **kwargs: Any) -> (Union[None, Exception], Any):
511 |         """A function intended to be used to run another function without
512 |         raising any exception. Returns an exception as first element of
513 |         the tuple if available. Also returns the result of the function call
514 |         as the second element of the tuple if no exceptions raised. If `func`
515 |         is a coroutine function, this function returns the awaited result.
516 |         """
517 |         try:
518 |             return_value = func(*args, **kwargs)
519 |             if asyncio.iscoroutine(return_value):
520 |                 return None, await return_value
521 |             return None, return_value
522 |         except Exception as exc:  # pylint: disable=broad-except
523 |             logger.error("Exception %s raised at %s", exc, func.__name__)
524 |             return exc, None
525 | 
526 |     async def logout(self) -> None:
527 |         """Sends a logout message to the websocket server. This will
528 |         invalidate the session."""
529 |         await self.websocket.send_message(
530 |             websocket.WebsocketMessage(None, ["admin", "Conn", "disconnect"]))
531 | 
532 |     async def shutdown(self) -> None:
533 |         """Do a cleanup. Closes websocket connection."""
534 |         logger.info("Shutting down")
535 |         await self.websocket.shutdown()
536 |
537 |

Static methods

538 |
539 |
540 | async def create() -> Client 541 |
542 |
543 |

The proper way to instantiate Client class. Connects to 544 | websocket server, also sets up the default client profile. 545 | Returns a ready to use Client instance.

546 |
547 | 548 | Expand source code 549 | 550 |
@classmethod
551 | async def create(cls) -> Client:
552 |     """The proper way to instantiate `Client` class. Connects to
553 |     websocket server, also sets up the default client profile.
554 |     Returns a ready to use `Client` instance."""
555 |     instance = cls()
556 |     await instance.setup_ws()
557 |     instance.load_profile(constants.CLIENT_VERSION,
558 |                           constants.CLIENT_LONG_DESC,
559 |                           constants.CLIENT_SHORT_DESC)
560 |     logger.info("Kyros instance created")
561 |     return instance
562 |
563 |
564 |
565 |

Methods

566 |
567 |
568 | async def ensure_safe(self, func: Callable, *args: Any, **kwargs: Any) -> '(Union[None, Exception], Any)' 569 |
570 |
571 |

A function intended to be used to run another function without 572 | raising any exception. Returns an exception as first element of 573 | the tuple if available. Also returns the result of the function call 574 | as the second element of the tuple if no exceptions raised. If func 575 | is a coroutine function, this function returns the awaited result.

576 |
577 | 578 | Expand source code 579 | 580 |
async def ensure_safe(self, func: Callable, *args: Any,
581 |                       **kwargs: Any) -> (Union[None, Exception], Any):
582 |     """A function intended to be used to run another function without
583 |     raising any exception. Returns an exception as first element of
584 |     the tuple if available. Also returns the result of the function call
585 |     as the second element of the tuple if no exceptions raised. If `func`
586 |     is a coroutine function, this function returns the awaited result.
587 |     """
588 |     try:
589 |         return_value = func(*args, **kwargs)
590 |         if asyncio.iscoroutine(return_value):
591 |             return None, await return_value
592 |         return None, return_value
593 |     except Exception as exc:  # pylint: disable=broad-except
594 |         logger.error("Exception %s raised at %s", exc, func.__name__)
595 |         return exc, None
596 |
597 |
598 |
599 | def load_profile(self, ver: Sequence[Union[float, int]], long_desc: str, short_desc: str) -> NoneType 600 |
601 |
602 |

Loads a new client profile (which will be shown in the WhatsApp 603 | mobile app). Please note that the client profile is unchangeable after 604 | logging in (after admin init).

605 |
606 | 607 | Expand source code 608 | 609 |
def load_profile(self, ver: Sequence[Union[float, int]], long_desc: str,
610 |                  short_desc: str) -> None:
611 |     """Loads a new client profile (which will be shown in the WhatsApp
612 |     mobile app). Please note that the client profile is unchangeable after
613 |     logging in (after admin init)."""
614 |     logger.debug("Loaded new profile")
615 |     self.profile = {
616 |         "version": ver,
617 |         "long_description": long_desc,
618 |         "short_description": short_desc,
619 |     }
620 |
621 |
622 |
623 | async def logout(self) -> NoneType 624 |
625 |
626 |

Sends a logout message to the websocket server. This will 627 | invalidate the session.

628 |
629 | 630 | Expand source code 631 | 632 |
async def logout(self) -> None:
633 |     """Sends a logout message to the websocket server. This will
634 |     invalidate the session."""
635 |     await self.websocket.send_message(
636 |         websocket.WebsocketMessage(None, ["admin", "Conn", "disconnect"]))
637 |
638 |
639 |
640 | async def qr_login(self) -> '(str, Awaitable)' 641 |
642 |
643 |

Does a QR login. Sends init then return the qr data 644 | which will be shown using pyqrcode or another library and. 645 | also returns a waitable which will timeout in 20 seconds. 646 | 20 seconds is the maximum amount of time for the QR code to be 647 | considered valid. 648 | Raises asyncio.TimeoutError if timeout reached. 649 | Another exception might also possible.

650 |
651 | 652 | Expand source code 653 | 654 |
async def qr_login(self) -> (str, Awaitable):
655 |     """Does a QR login. Sends init then return the qr data
656 |     which will be shown using `pyqrcode` or another library and.
657 |     also returns a waitable which will timeout in 20 seconds.
658 |     20 seconds is the maximum amount of time for the QR code to be
659 |     considered valid.
660 |     Raises `asyncio.TimeoutError` if timeout reached.
661 |     Another exception might also possible."""
662 |     await self.send_init()
663 | 
664 |     async def wait_qr_scan():
665 |         ws_message = await self.websocket.messages.get("s1", 20)
666 |         connection_data = ws_message[1]
667 | 
668 |         self.phone_info = connection_data["phone"]
669 |         self.session.secret = base64.b64decode(
670 |             connection_data["secret"].encode())
671 |         self.session.server_token = connection_data["serverToken"]
672 |         self.session.client_token = connection_data["clientToken"]
673 |         self.session.browser_token = connection_data["browserToken"]
674 |         self.session.wid = connection_data["wid"]
675 | 
676 |         self.session.shared_secret = self.session.private_key.do_exchange(
677 |             donna25519.PublicKey(self.session.secret[:32]))
678 |         self.session.shared_secret_expanded = crypto.hkdf_expand(
679 |             self.session.shared_secret, 80)
680 | 
681 |         if not crypto.validate_secrets(
682 |                 self.session.secret, self.session.shared_secret_expanded):
683 |             raise exceptions.HMACValidationError
684 | 
685 |         self.session.keys_encrypted = self.session.shared_secret_expanded[
686 |             64:] + self.session.secret[64:]
687 |         self.session.keys_decrypted = crypto.aes_decrypt(
688 |             self.session.shared_secret_expanded[:32],
689 |             self.session.keys_encrypted)
690 | 
691 |         self.session.enc_key = self.session.keys_decrypted[:32]
692 |         self.session.mac_key = self.session.keys_decrypted[32:64]
693 |         print(self.session.enc_key, self.session.mac_key)
694 | 
695 |     qr_fragments = [
696 |         self.session.server_id,
697 |         base64.b64encode(self.session.public_key.public).decode(),
698 |         self.session.client_id
699 |     ]
700 |     qr_data = ",".join(qr_fragments)
701 | 
702 |     return qr_data, wait_qr_scan()
703 |
704 |
705 |
706 | async def resolve_challenge(self, challenge: str) -> NoneType 707 |
708 |
709 |

Resolve a challenge string. Sings challenge with mac_key and send 710 | a challenge response ws message. Usually called when restoring session. 711 | Raises asyncio.TimeoutError when timeout reached.

712 |
713 | 714 | Expand source code 715 | 716 |
async def resolve_challenge(self, challenge: str) -> None:
717 |     """Resolve a challenge string. Sings challenge with mac_key and send
718 |     a challenge response ws message. Usually called when restoring session.
719 |     Raises `asyncio.TimeoutError` when timeout reached."""
720 |     challenge = base64.b64decode(challenge.encode()).decode()
721 |     signed = crypto.hmac_sha256(self.session.mac_key, challenge)
722 | 
723 |     chall_reply_message = websocket.WebsocketMessage(
724 |         None, [
725 |             "admin", "challenge",
726 |             base64.b64encode(signed).decode(), self.session.server_token,
727 |             self.session.client_id
728 |         ])
729 |     await self.websocket.send_message(chall_reply_message)
730 | 
731 |     status = self.websocket.messages.get(chall_reply_message)["status"]
732 |     if status != 200:
733 |         raise exceptions.StatusCodeError(status)
734 | 
735 |     return
736 |
737 |
738 |
739 | async def restore_session(self, new_session: session.Session = None) -> Session 740 |
741 |
742 |

Restores a session. Returns the new session object. 743 | If new_session argument specified, replace current session with 744 | the new one. 745 | Raises asyncio.TimeoutError when a websocket request reaches timeout. 746 | Old session is restored when it fails restoring the new one.

747 |
748 | 749 | Expand source code 750 | 751 |
async def restore_session(  # noqa: mc0001
752 |         self, new_session: session.Session = None) -> session.Session:
753 |     """Restores a session. Returns the new session object.
754 |     If `new_session` argument specified, replace current session with
755 |     the new one.
756 |     Raises asyncio.TimeoutError when a websocket request reaches timeout.
757 |     Old session is restored when it fails restoring the new one."""
758 |     old_session = self.session
759 |     if new_session:
760 |         self.session = new_session
761 | 
762 |     async def restore():
763 |         await self.send_init()
764 | 
765 |         login_message = websocket.WebsocketMessage(None, [
766 |             "admin", "login", self.session.client_token,
767 |             self.session.server_token, self.session.client_id, "takeover"
768 |         ])
769 |         await self.websocket.send_message(login_message)
770 | 
771 |         s1_message = None
772 |         try:
773 |             s1_message = await self.websocket.messages.get("s1")
774 |         except asyncio.TimeoutError:
775 |             logger.error("s1 message timed out")
776 | 
777 |             login_resp = await self.websocket.messages.get(
778 |                 login_message.tag)
779 |             if login_resp["status"] != 200:
780 |                 raise exceptions.StatusCodeError(login_resp["status"])
781 |             self.websocket.messages.add(login_message.tag, login_resp)
782 | 
783 |         s2_message = None
784 |         if len(s1_message) == 2 and s1_message[0] == "Cmd" \
785 |                 and s1_message[1]["type"] == "challenge":
786 |             if not self.resolve_challenge(s1_message["challenge"]):
787 |                 logger.error("failed to solve challenge")
788 |                 return False
789 | 
790 |             s2_message = self.websocket.messages.get("s2")
791 | 
792 |         login_resp = await self.websocket.messages.get(login_message.tag)
793 |         if login_resp["status"] != 200:
794 |             raise exceptions.StatusCodeError(login_resp["status"])
795 | 
796 |         conn_resp = s2_message if s2_message else s1_message
797 |         self.phone_info = conn_resp["phone"]
798 |         self.session.wid = conn_resp["wid"]
799 |         self.session.client_token = conn_resp["clientToken"]
800 |         self.session.server_token = conn_resp["serverToken"]
801 | 
802 |         self.websocket.load_session(self.session)  # reload references
803 | 
804 |         return self.session
805 | 
806 |     try:
807 |         return await restore()
808 |     except Exception:  # pylint: disable=broad-except
809 |         if old_session:
810 |             self.session = old_session
811 |         raise
812 |
813 |
814 |
815 | async def send_init(self) -> NoneType 816 |
817 |
818 |

Send an admin init message. Usually not used directly. Used whens 819 | doing QR login or restoring session.

820 |
821 | 822 | Expand source code 823 | 824 |
async def send_init(self) -> None:
825 |     """Send an admin init message. Usually not used directly. Used whens
826 |     doing QR login or restoring session."""
827 |     init_message = websocket.WebsocketMessage(None, [
828 |         "admin", "init", self.profile["version"],
829 |         [
830 |             self.profile["long_description"],
831 |             self.profile["short_description"]
832 |         ], self.session.client_id, True
833 |     ])
834 |     await self.websocket.send_message(init_message)
835 | 
836 |     resp = await self.websocket.messages.get(init_message.tag)
837 |     if resp["status"] != 200:
838 |         logger.error("unexpected init stts code, resp:%s", resp)
839 |         raise exceptions.StatusCodeError(resp["status"])
840 | 
841 |     self.session.server_id = resp["ref"]
842 |
843 |
844 |
845 | async def setup_ws(self) -> NoneType 846 |
847 |
848 |

Connect to websocket server.

849 |
850 | 851 | Expand source code 852 | 853 |
async def setup_ws(self) -> None:
854 |     """Connect to websocket server."""
855 |     self.websocket = websocket.WebsocketClient(self.message_handler)
856 |     await self.websocket.connect()
857 |     self.websocket.load_session(self.session)
858 |
859 |
860 |
861 | async def shutdown(self) -> NoneType 862 |
863 |
864 |

Do a cleanup. Closes websocket connection.

865 |
866 | 867 | Expand source code 868 | 869 |
async def shutdown(self) -> None:
870 |     """Do a cleanup. Closes websocket connection."""
871 |     logger.info("Shutting down")
872 |     await self.websocket.shutdown()
873 |
874 |
875 |
876 |
877 |
878 |
879 |
880 | 912 |
913 | 916 | 917 | 918 | 919 | -------------------------------------------------------------------------------- /html/kyros/constants.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | kyros.constants API documentation 8 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 |
20 |
21 |

Module kyros.constants

22 |
23 |
24 |

This file contains the required constants in order for 25 | the client to work. These values might be changed when necessary.

26 |
27 | 28 | Expand source code 29 | 30 |
"""
31 | This file contains the required constants in order for
32 | the client to work. These values might be changed when necessary.
33 | """
34 | 
35 | WEBSOCKET_URI = "wss://web.whatsapp.com/ws"
36 | WEBSOCKET_ORIGIN = "https://web.whatsapp.com"
37 | CLIENT_VERSION = [100, 100, 100]
38 | CLIENT_LONG_DESC = "Kyros"
39 | CLIENT_SHORT_DESC = "Kyros"
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 | 64 |
65 | 68 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /html/kyros/crypto.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | kyros.crypto API documentation 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 |
19 |
20 |

Module kyros.crypto

21 |
22 |
23 |
24 | 25 | Expand source code 26 | 27 |
# pylint: disable=invalid-name
 28 | import Crypto.Cipher.AES
 29 | import Crypto.Hash
 30 | import Crypto.Protocol
 31 | import Crypto.Util.Padding
 32 | 
 33 | 
 34 | def hkdf_expand(key: bytes, length: int) -> bytes:
 35 |     """Expand a key to a length."""
 36 |     return Crypto.Protocol.KDF.HKDF(key, length, None, Crypto.Hash.SHA256)
 37 | 
 38 | 
 39 | def validate_secrets(secret: bytes, shared_secret_expanded: bytes) -> bool:
 40 |     """Validate secrets. Used during QR login process."""
 41 |     return Crypto.Hash.HMAC.new(shared_secret_expanded[32:64],
 42 |                                 secret[:32] + secret[64:],
 43 |                                 Crypto.Hash.SHA256).digest() == secret[32:64]
 44 | 
 45 | 
 46 | def hmac_sha256(mac: bytes, message: bytes) -> bytes:
 47 |     """Sign a message with a mac key."""
 48 |     return Crypto.Hash.HMAC.new(mac, message, Crypto.Hash.SHA256).digest()
 49 | 
 50 | 
 51 | def aes_encrypt(key: bytes, plaintext: bytes) -> bytes:
 52 |     """Encrypt a plaintext using AES CBC."""
 53 |     plaintext = Crypto.Util.Padding.pad(plaintext,
 54 |                                         Crypto.Cipher.AES.block_size)
 55 |     cipher = Crypto.Cipher.AES.new(key, Crypto.Cipher.AES.MODE_CBC)
 56 |     ciphertext = cipher.encrypt(plaintext)
 57 |     return cipher.iv + ciphertext
 58 | 
 59 | 
 60 | def aes_decrypt(key: bytes, ciphertext: bytes) -> bytes:
 61 |     """Decrypt a ciphertext using AES CBC."""
 62 |     iv = ciphertext[:Crypto.Cipher.AES.block_size]
 63 |     cipher = Crypto.Cipher.AES.new(key, Crypto.Cipher.AES.MODE_CBC, iv)
 64 |     plaintext = cipher.decrypt(ciphertext[Crypto.Cipher.AES.block_size:])
 65 |     return Crypto.Util.Padding.unpad(plaintext, Crypto.Cipher.AES.block_size)
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |

Functions

74 |
75 |
76 | def aes_decrypt(key: bytes, ciphertext: bytes) -> bytes 77 |
78 |
79 |

Decrypt a ciphertext using AES CBC.

80 |
81 | 82 | Expand source code 83 | 84 |
def aes_decrypt(key: bytes, ciphertext: bytes) -> bytes:
 85 |     """Decrypt a ciphertext using AES CBC."""
 86 |     iv = ciphertext[:Crypto.Cipher.AES.block_size]
 87 |     cipher = Crypto.Cipher.AES.new(key, Crypto.Cipher.AES.MODE_CBC, iv)
 88 |     plaintext = cipher.decrypt(ciphertext[Crypto.Cipher.AES.block_size:])
 89 |     return Crypto.Util.Padding.unpad(plaintext, Crypto.Cipher.AES.block_size)
90 |
91 |
92 |
93 | def aes_encrypt(key: bytes, plaintext: bytes) -> bytes 94 |
95 |
96 |

Encrypt a plaintext using AES CBC.

97 |
98 | 99 | Expand source code 100 | 101 |
def aes_encrypt(key: bytes, plaintext: bytes) -> bytes:
102 |     """Encrypt a plaintext using AES CBC."""
103 |     plaintext = Crypto.Util.Padding.pad(plaintext,
104 |                                         Crypto.Cipher.AES.block_size)
105 |     cipher = Crypto.Cipher.AES.new(key, Crypto.Cipher.AES.MODE_CBC)
106 |     ciphertext = cipher.encrypt(plaintext)
107 |     return cipher.iv + ciphertext
108 |
109 |
110 |
111 | def hkdf_expand(key: bytes, length: int) -> bytes 112 |
113 |
114 |

Expand a key to a length.

115 |
116 | 117 | Expand source code 118 | 119 |
def hkdf_expand(key: bytes, length: int) -> bytes:
120 |     """Expand a key to a length."""
121 |     return Crypto.Protocol.KDF.HKDF(key, length, None, Crypto.Hash.SHA256)
122 |
123 |
124 |
125 | def hmac_sha256(mac: bytes, message: bytes) -> bytes 126 |
127 |
128 |

Sign a message with a mac key.

129 |
130 | 131 | Expand source code 132 | 133 |
def hmac_sha256(mac: bytes, message: bytes) -> bytes:
134 |     """Sign a message with a mac key."""
135 |     return Crypto.Hash.HMAC.new(mac, message, Crypto.Hash.SHA256).digest()
136 |
137 |
138 |
139 | def validate_secrets(secret: bytes, shared_secret_expanded: bytes) -> bool 140 |
141 |
142 |

Validate secrets. Used during QR login process.

143 |
144 | 145 | Expand source code 146 | 147 |
def validate_secrets(secret: bytes, shared_secret_expanded: bytes) -> bool:
148 |     """Validate secrets. Used during QR login process."""
149 |     return Crypto.Hash.HMAC.new(shared_secret_expanded[32:64],
150 |                                 secret[:32] + secret[64:],
151 |                                 Crypto.Hash.SHA256).digest() == secret[32:64]
152 |
153 |
154 |
155 |
156 |
157 |
158 |
159 | 181 |
182 | 185 | 186 | 187 | 188 | -------------------------------------------------------------------------------- /html/kyros/exceptions.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | kyros.exceptions API documentation 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 |
19 |
20 |

Module kyros.exceptions

21 |
22 |
23 |
24 | 25 | Expand source code 26 | 27 |
class HMACValidationError(Exception):
 28 |     """Raised when checksum does not match. For example, when
 29 |     validating binary messages."""
 30 |     message = "checksum verification failed"
 31 | 
 32 | 
 33 | class StatusCodeError(Exception):
 34 |     """Raised when a websocket message responded with an unexpected
 35 |     status code."""
 36 |     def __init__(self, code):
 37 |         self.code = code
 38 |         message = f"Unexpected status code: {code}"
 39 |         super().__init__(message)
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |

Classes

50 |
51 |
52 | class HMACValidationError 53 | (...) 54 |
55 |
56 |

Raised when checksum does not match. For example, when 57 | validating binary messages.

58 |
59 | 60 | Expand source code 61 | 62 |
class HMACValidationError(Exception):
 63 |     """Raised when checksum does not match. For example, when
 64 |     validating binary messages."""
 65 |     message = "checksum verification failed"
66 |
67 |

Ancestors

68 |
    69 |
  • builtins.Exception
  • 70 |
  • builtins.BaseException
  • 71 |
72 |

Class variables

73 |
74 |
var message
75 |
76 |
77 |
78 |
79 |
80 |
81 | class StatusCodeError 82 | (code) 83 |
84 |
85 |

Raised when a websocket message responded with an unexpected 86 | status code.

87 |
88 | 89 | Expand source code 90 | 91 |
class StatusCodeError(Exception):
 92 |     """Raised when a websocket message responded with an unexpected
 93 |     status code."""
 94 |     def __init__(self, code):
 95 |         self.code = code
 96 |         message = f"Unexpected status code: {code}"
 97 |         super().__init__(message)
98 |
99 |

Ancestors

100 |
    101 |
  • builtins.Exception
  • 102 |
  • builtins.BaseException
  • 103 |
104 |
105 |
106 |
107 |
108 | 134 |
135 | 138 | 139 | 140 | 141 | -------------------------------------------------------------------------------- /html/kyros/message.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | kyros.message API documentation 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 |
19 |
20 |

Module kyros.message

21 |
22 |
23 |
24 | 25 | Expand source code 26 | 27 |
class MessageHandler:
 28 |     """Future class. To be implemented soon."""
 29 |     def handle_text_message(self):
 30 |         pass
 31 | 
 32 |     def handle_image_message(self):
 33 |         pass
 34 | 
 35 |     def handle_video_message(self):
 36 |         pass
 37 | 
 38 |     def handle_json_message(self):
 39 |         pass
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |

Classes

50 |
51 |
52 | class MessageHandler 53 |
54 |
55 |

Future class. To be implemented soon.

56 |
57 | 58 | Expand source code 59 | 60 |
class MessageHandler:
 61 |     """Future class. To be implemented soon."""
 62 |     def handle_text_message(self):
 63 |         pass
 64 | 
 65 |     def handle_image_message(self):
 66 |         pass
 67 | 
 68 |     def handle_video_message(self):
 69 |         pass
 70 | 
 71 |     def handle_json_message(self):
 72 |         pass
73 |
74 |

Methods

75 |
76 |
77 | def handle_image_message(self) 78 |
79 |
80 |
81 |
82 | 83 | Expand source code 84 | 85 |
def handle_image_message(self):
 86 |     pass
87 |
88 |
89 |
90 | def handle_json_message(self) 91 |
92 |
93 |
94 |
95 | 96 | Expand source code 97 | 98 |
def handle_json_message(self):
 99 |     pass
100 |
101 |
102 |
103 | def handle_text_message(self) 104 |
105 |
106 |
107 |
108 | 109 | Expand source code 110 | 111 |
def handle_text_message(self):
112 |     pass
113 |
114 |
115 |
116 | def handle_video_message(self) 117 |
118 |
119 |
120 |
121 | 122 | Expand source code 123 | 124 |
def handle_video_message(self):
125 |     pass
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 | 159 |
160 | 163 | 164 | 165 | 166 | -------------------------------------------------------------------------------- /html/kyros/session.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | kyros.session API documentation 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 |
19 |
20 |

Module kyros.session

21 |
22 |
23 |
24 | 25 | Expand source code 26 | 27 |
from __future__ import annotations
 28 | 
 29 | import pickle
 30 | 
 31 | 
 32 | class Session:
 33 |     """Container class for session data.
 34 |     Has methods to import and export a serialized
 35 |     `Session` object."""
 36 |     client_id = None
 37 |     server_id = None
 38 | 
 39 |     client_token = None
 40 |     server_token = None
 41 |     client_secret = None
 42 | 
 43 |     secret = None
 44 |     shared_secret = None
 45 |     shared_secret_expanded = None
 46 | 
 47 |     private_key = None
 48 |     public_key = None
 49 | 
 50 |     keys_encrypted = None
 51 |     keys_decrypted = None
 52 | 
 53 |     enc_key = None
 54 |     mac_key = None
 55 | 
 56 |     wid = None
 57 | 
 58 |     @staticmethod
 59 |     def from_file(filename: str) -> Session:
 60 |         with open(filename, "rb") as file:
 61 |             return pickle.load(file)
 62 | 
 63 |     def save_to_file(self, filename: str) -> Session:
 64 |         with open(filename, "wb") as file:
 65 |             return pickle.dump(self, file)
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |

Classes

76 |
77 |
78 | class Session 79 |
80 |
81 |

Container class for session data. 82 | Has methods to import and export a serialized 83 | Session object.

84 |
85 | 86 | Expand source code 87 | 88 |
class Session:
 89 |     """Container class for session data.
 90 |     Has methods to import and export a serialized
 91 |     `Session` object."""
 92 |     client_id = None
 93 |     server_id = None
 94 | 
 95 |     client_token = None
 96 |     server_token = None
 97 |     client_secret = None
 98 | 
 99 |     secret = None
100 |     shared_secret = None
101 |     shared_secret_expanded = None
102 | 
103 |     private_key = None
104 |     public_key = None
105 | 
106 |     keys_encrypted = None
107 |     keys_decrypted = None
108 | 
109 |     enc_key = None
110 |     mac_key = None
111 | 
112 |     wid = None
113 | 
114 |     @staticmethod
115 |     def from_file(filename: str) -> Session:
116 |         with open(filename, "rb") as file:
117 |             return pickle.load(file)
118 | 
119 |     def save_to_file(self, filename: str) -> Session:
120 |         with open(filename, "wb") as file:
121 |             return pickle.dump(self, file)
122 |
123 |

Class variables

124 |
125 |
var client_id
126 |
127 |
128 |
129 |
var client_secret
130 |
131 |
132 |
133 |
var client_token
134 |
135 |
136 |
137 |
var enc_key
138 |
139 |
140 |
141 |
var keys_decrypted
142 |
143 |
144 |
145 |
var keys_encrypted
146 |
147 |
148 |
149 |
var mac_key
150 |
151 |
152 |
153 |
var private_key
154 |
155 |
156 |
157 |
var public_key
158 |
159 |
160 |
161 |
var secret
162 |
163 |
164 |
165 |
var server_id
166 |
167 |
168 |
169 |
var server_token
170 |
171 |
172 |
173 |
var shared_secret
174 |
175 |
176 |
177 |
var shared_secret_expanded
178 |
179 |
180 |
181 |
var wid
182 |
183 |
184 |
185 |
186 |

Static methods

187 |
188 |
189 | def from_file(filename: str) -> Session 190 |
191 |
192 |
193 |
194 | 195 | Expand source code 196 | 197 |
@staticmethod
198 | def from_file(filename: str) -> Session:
199 |     with open(filename, "rb") as file:
200 |         return pickle.load(file)
201 |
202 |
203 |
204 |

Methods

205 |
206 |
207 | def save_to_file(self, filename: str) -> Session 208 |
209 |
210 |
211 |
212 | 213 | Expand source code 214 | 215 |
def save_to_file(self, filename: str) -> Session:
216 |     with open(filename, "wb") as file:
217 |         return pickle.dump(self, file)
218 |
219 |
220 |
221 |
222 |
223 |
224 |
225 | 264 |
265 | 268 | 269 | 270 | 271 | -------------------------------------------------------------------------------- /html/kyros/utilities.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | kyros.utilities API documentation 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 |
19 |
20 |

Module kyros.utilities

21 |
22 |
23 |
24 | 25 | Expand source code 26 | 27 |
import base64
 28 | import os
 29 | import time
 30 | 
 31 | 
 32 | def generate_message_tag() -> str:
 33 |     """Generate a message tag. Spawns a string of
 34 |     current timestamp."""
 35 |     return str(time.time())
 36 | 
 37 | 
 38 | def generate_client_id() -> str:
 39 |     """Generates client id, base64 encoded random 16 bytes
 40 |     long string."""
 41 |     return base64.b64encode(os.urandom(16)).decode()
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |

Functions

50 |
51 |
52 | def generate_client_id() -> str 53 |
54 |
55 |

Generates client id, base64 encoded random 16 bytes 56 | long string.

57 |
58 | 59 | Expand source code 60 | 61 |
def generate_client_id() -> str:
 62 |     """Generates client id, base64 encoded random 16 bytes
 63 |     long string."""
 64 |     return base64.b64encode(os.urandom(16)).decode()
65 |
66 |
67 |
68 | def generate_message_tag() -> str 69 |
70 |
71 |

Generate a message tag. Spawns a string of 72 | current timestamp.

73 |
74 | 75 | Expand source code 76 | 77 |
def generate_message_tag() -> str:
 78 |     """Generate a message tag. Spawns a string of
 79 |     current timestamp."""
 80 |     return str(time.time())
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 | 107 |
108 | 111 | 112 | 113 | 114 | -------------------------------------------------------------------------------- /html/kyros/websocket.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | kyros.websocket API documentation 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 |
19 |
20 |

Module kyros.websocket

21 |
22 |
23 |
24 | 25 | Expand source code 26 | 27 |
from __future__ import annotations
 28 | 
 29 | import asyncio
 30 | import json
 31 | import logging
 32 | from typing import AnyStr, Optional, Sequence
 33 | 
 34 | import websockets
 35 | 
 36 | from . import constants, crypto, exceptions, utilities
 37 | from .message import MessageHandler
 38 | from .session import Session
 39 | 
 40 | logger = logging.getLogger(__name__)  # pylint: disable=invalid-name
 41 | 
 42 | 
 43 | class WebsocketMessage:
 44 |     """
 45 |     `WebsocketMessage` acts as a container for websocket messages.
 46 |     `data` attribute always contains a decoded or decrypted
 47 |     data (for binary messages).
 48 |     `tag` is also automatically generated if None is given as the tag.
 49 |     """
 50 |     def __init__(self,
 51 |                  tag: Optional[str] = None,
 52 |                  data: Optional[AnyStr] = None,
 53 |                  is_binary: Optional[bool] = False) -> None:
 54 |         """Initiate the class."""
 55 | 
 56 |         self.tag = tag
 57 |         if not self.tag:
 58 |             self.tag = utilities.generate_message_tag()
 59 | 
 60 |         self.data = data
 61 |         self.is_binary = is_binary
 62 | 
 63 |     def serialize(self, keys: Sequence[bytes]) -> AnyStr:
 64 |         """Unserialize the message. A regular JSON message
 65 |         will be encoded. A binary message will be encrypted and also
 66 |         prefixed with an HMAC checksum. It returns a ready-to-send
 67 |         websocket message."""
 68 |         if not self.is_binary:
 69 |             return self.encode()
 70 |         return self.encrypt(keys)
 71 | 
 72 |     def encrypt(self, keys: Sequence[bytes]) -> bytes:
 73 |         """Encrypts a binary message."""
 74 |         enc_key, mac_key = keys
 75 |         checksum = crypto.hmac_sha256(mac_key, self.data)
 76 |         serialized = f"{self.tag},".encode()
 77 |         serialized += checksum
 78 |         serialized += crypto.aes_encrypt(enc_key, self.data)
 79 |         return serialized
 80 | 
 81 |     def encode(self) -> str:
 82 |         """JSON encode the message if the message is not a
 83 |         binary message."""
 84 |         encoded_message = f"{self.tag},{json.dumps(self.data)}"
 85 |         return encoded_message
 86 | 
 87 |     @classmethod
 88 |     def unserialize(cls, message: AnyStr,
 89 |                     keys: Sequence[bytes]) -> WebsocketMessage:
 90 |         """Process a message and decide whether it is a binary
 91 |         message or a regular JSON message. Then it will serialize
 92 |         the message according to its type."""
 93 |         if not isinstance(message, bytes):
 94 |             return cls.from_encoded(message)
 95 |         return cls.from_encrypted(message, keys)
 96 | 
 97 |     @classmethod
 98 |     def from_encoded(cls, message: str) -> WebsocketMessage:
 99 |         """Returns an initiated class from an encoded message."""
100 |         tag, encoded_data = message.split(",", 1)
101 |         return cls(tag, json.loads(encoded_data))
102 | 
103 |     @classmethod
104 |     def from_encrypted(cls, message: bytes,
105 |                        keys: Sequence[bytes]) -> WebsocketMessage:
106 |         """Returns an initiated class from a binary message.
107 |         This function also decrypts the contained message."""
108 |         enc_key, mac_key = keys
109 |         instance = cls()
110 |         instance.is_binary = True
111 | 
112 |         tag, data = message.split(b",", 1)
113 |         instance.tag = tag
114 | 
115 |         checksum = data[:32]
116 |         encrypted_data = data[32:]
117 | 
118 |         if crypto.hmac_sha256(mac_key, encrypted_data) != checksum:
119 |             raise exceptions.HMACValidationError
120 | 
121 |         instance.data = crypto.aes_decrypt(enc_key, encrypted_data)
122 | 
123 |         return instance
124 | 
125 | 
126 | class WebsocketMessages:
127 |     """This class acts as a container for `WebsocketMessage` instances.
128 |     Allows an easy access to messages in queue. The messages are feed
129 |     by `WebsocketClient` class."""
130 |     messages = {}
131 | 
132 |     def add(self, tag: str, data: AnyStr):
133 |         """Appends a message to the messages mapping."""
134 |         self.messages[tag] = data
135 | 
136 |     def get(self, tag: str, timeout: Optional[int] = 10):
137 |         """Gets a message with specified tag. If not currently
138 |         present, it will wait until `timeout` reached. Raises
139 |         asyncio.TimeoutError when timed out."""
140 |         async def get_message():
141 |             while tag not in self.messages:
142 |                 await asyncio.sleep(0)
143 |             return self.messages.pop(tag)
144 | 
145 |         logger.debug("Getting message with tag %s", tag)
146 | 
147 |         return asyncio.wait_for(get_message(), timeout)
148 | 
149 | 
150 | class WebsocketClient:
151 |     """Acts as interface for websocket communication with WhatsApp's
152 |     websocket server."""
153 |     websocket: websockets.WebSocketClientProtocol = None
154 |     kyros_session: Session = None
155 |     messages: WebsocketMessages = WebsocketMessages()
156 | 
157 |     def __init__(self, message_handler: MessageHandler) -> None:
158 |         """Initiate the class. Registers message handler."""
159 |         self.handle_message = message_handler
160 | 
161 |     async def connect(self) -> None:
162 |         """Connects to the websocket server. Starts message receiver or
163 |         listener."""
164 |         logger.debug("Connecting to ws server")
165 |         self.websocket = await websockets.connect(
166 |             constants.WEBSOCKET_URI, origin=constants.WEBSOCKET_ORIGIN)
167 |         logger.debug("Websocket connected")
168 |         self._start_receiver()
169 | 
170 |     def load_session(self, session: Session) -> None:
171 |         """Loads a session. This will make sure that all references are
172 |         updated. If there is a key change, the new key will be used to
173 |         decrypt new messages."""
174 |         self.kyros_session = session
175 | 
176 |     def get_keys(self) -> Sequence[bytes]:
177 |         """Extract necessary keys from session to decrypt and encrypt
178 |         binary messages."""
179 |         return self.kyros_session.enc_key, self.kyros_session.mac_key
180 | 
181 |     async def shutdown(self) -> None:
182 |         """Does a cleanup. Closes websocket connection."""
183 |         if self.websocket.open:
184 |             logger.debug("Closing websocket server")
185 |             await self.websocket.close()
186 | 
187 |     def _start_receiver(self) -> None:
188 |         """Starts a receiver coroutine. Listens for a new websocket message
189 |         from queue."""
190 |         async def receiver():
191 |             while True:
192 |                 if not self.websocket or not self.websocket.open:
193 |                     logger.debug("receiver returned: no ws/connection closed")
194 |                     return
195 | 
196 |                 if not self.websocket.messages or self.websocket.closed:
197 |                     await asyncio.sleep(0)
198 |                     continue
199 | 
200 |                 raw_message = self.websocket.messages.pop()
201 |                 try:
202 |                     message = WebsocketMessage.unserialize(
203 |                         raw_message, self.get_keys())
204 |                 except Exception as exc:  # pylint: disable=broad-except
205 |                     logger.warning("Ignored error decoding message: %s", exc)
206 |                     await asyncio.sleep(0)
207 |                     continue
208 | 
209 |                 logger.debug("Received WS message with tag %s", message.tag)
210 |                 self.messages.add(message.tag, message.data)
211 | 
212 |         asyncio.ensure_future(receiver())
213 |         logger.debug("Executed receiver coroutine")
214 | 
215 |     async def send_message(self, message: WebsocketMessage) -> None:
216 |         """Sends a websocket message."""
217 |         logger.debug("Sending a WS message with tag %s", message.tag)
218 |         await self.websocket.send(message.serialize(self.get_keys()))
219 |
220 |
221 |
222 |
223 |
224 |
225 |
226 |
227 |
228 |

Classes

229 |
230 |
231 | class WebsocketClient 232 | (message_handler: MessageHandler) 233 |
234 |
235 |

Acts as interface for websocket communication with WhatsApp's 236 | websocket server.

237 |

Initiate the class. Registers message handler.

238 |
239 | 240 | Expand source code 241 | 242 |
class WebsocketClient:
243 |     """Acts as interface for websocket communication with WhatsApp's
244 |     websocket server."""
245 |     websocket: websockets.WebSocketClientProtocol = None
246 |     kyros_session: Session = None
247 |     messages: WebsocketMessages = WebsocketMessages()
248 | 
249 |     def __init__(self, message_handler: MessageHandler) -> None:
250 |         """Initiate the class. Registers message handler."""
251 |         self.handle_message = message_handler
252 | 
253 |     async def connect(self) -> None:
254 |         """Connects to the websocket server. Starts message receiver or
255 |         listener."""
256 |         logger.debug("Connecting to ws server")
257 |         self.websocket = await websockets.connect(
258 |             constants.WEBSOCKET_URI, origin=constants.WEBSOCKET_ORIGIN)
259 |         logger.debug("Websocket connected")
260 |         self._start_receiver()
261 | 
262 |     def load_session(self, session: Session) -> None:
263 |         """Loads a session. This will make sure that all references are
264 |         updated. If there is a key change, the new key will be used to
265 |         decrypt new messages."""
266 |         self.kyros_session = session
267 | 
268 |     def get_keys(self) -> Sequence[bytes]:
269 |         """Extract necessary keys from session to decrypt and encrypt
270 |         binary messages."""
271 |         return self.kyros_session.enc_key, self.kyros_session.mac_key
272 | 
273 |     async def shutdown(self) -> None:
274 |         """Does a cleanup. Closes websocket connection."""
275 |         if self.websocket.open:
276 |             logger.debug("Closing websocket server")
277 |             await self.websocket.close()
278 | 
279 |     def _start_receiver(self) -> None:
280 |         """Starts a receiver coroutine. Listens for a new websocket message
281 |         from queue."""
282 |         async def receiver():
283 |             while True:
284 |                 if not self.websocket or not self.websocket.open:
285 |                     logger.debug("receiver returned: no ws/connection closed")
286 |                     return
287 | 
288 |                 if not self.websocket.messages or self.websocket.closed:
289 |                     await asyncio.sleep(0)
290 |                     continue
291 | 
292 |                 raw_message = self.websocket.messages.pop()
293 |                 try:
294 |                     message = WebsocketMessage.unserialize(
295 |                         raw_message, self.get_keys())
296 |                 except Exception as exc:  # pylint: disable=broad-except
297 |                     logger.warning("Ignored error decoding message: %s", exc)
298 |                     await asyncio.sleep(0)
299 |                     continue
300 | 
301 |                 logger.debug("Received WS message with tag %s", message.tag)
302 |                 self.messages.add(message.tag, message.data)
303 | 
304 |         asyncio.ensure_future(receiver())
305 |         logger.debug("Executed receiver coroutine")
306 | 
307 |     async def send_message(self, message: WebsocketMessage) -> None:
308 |         """Sends a websocket message."""
309 |         logger.debug("Sending a WS message with tag %s", message.tag)
310 |         await self.websocket.send(message.serialize(self.get_keys()))
311 |
312 |

Class variables

313 |
314 |
var kyros_sessionSession
315 |
316 |
317 |
318 |
var messagesWebsocketMessages
319 |
320 |
321 |
322 |
var websocket : websockets.client.WebSocketClientProtocol
323 |
324 |
325 |
326 |
327 |

Methods

328 |
329 |
330 | async def connect(self) -> NoneType 331 |
332 |
333 |

Connects to the websocket server. Starts message receiver or 334 | listener.

335 |
336 | 337 | Expand source code 338 | 339 |
async def connect(self) -> None:
340 |     """Connects to the websocket server. Starts message receiver or
341 |     listener."""
342 |     logger.debug("Connecting to ws server")
343 |     self.websocket = await websockets.connect(
344 |         constants.WEBSOCKET_URI, origin=constants.WEBSOCKET_ORIGIN)
345 |     logger.debug("Websocket connected")
346 |     self._start_receiver()
347 |
348 |
349 |
350 | def get_keys(self) -> Sequence[bytes] 351 |
352 |
353 |

Extract necessary keys from session to decrypt and encrypt 354 | binary messages.

355 |
356 | 357 | Expand source code 358 | 359 |
def get_keys(self) -> Sequence[bytes]:
360 |     """Extract necessary keys from session to decrypt and encrypt
361 |     binary messages."""
362 |     return self.kyros_session.enc_key, self.kyros_session.mac_key
363 |
364 |
365 |
366 | def load_session(self, session: Session) -> NoneType 367 |
368 |
369 |

Loads a session. This will make sure that all references are 370 | updated. If there is a key change, the new key will be used to 371 | decrypt new messages.

372 |
373 | 374 | Expand source code 375 | 376 |
def load_session(self, session: Session) -> None:
377 |     """Loads a session. This will make sure that all references are
378 |     updated. If there is a key change, the new key will be used to
379 |     decrypt new messages."""
380 |     self.kyros_session = session
381 |
382 |
383 |
384 | async def send_message(self, message: WebsocketMessage) -> NoneType 385 |
386 |
387 |

Sends a websocket message.

388 |
389 | 390 | Expand source code 391 | 392 |
async def send_message(self, message: WebsocketMessage) -> None:
393 |     """Sends a websocket message."""
394 |     logger.debug("Sending a WS message with tag %s", message.tag)
395 |     await self.websocket.send(message.serialize(self.get_keys()))
396 |
397 |
398 |
399 | async def shutdown(self) -> NoneType 400 |
401 |
402 |

Does a cleanup. Closes websocket connection.

403 |
404 | 405 | Expand source code 406 | 407 |
async def shutdown(self) -> None:
408 |     """Does a cleanup. Closes websocket connection."""
409 |     if self.websocket.open:
410 |         logger.debug("Closing websocket server")
411 |         await self.websocket.close()
412 |
413 |
414 |
415 |
416 |
417 | class WebsocketMessage 418 | (tag: Optional[str] = None, data: Optional[AnyStr] = None, is_binary: Optional[bool] = False) 419 |
420 |
421 |

WebsocketMessage acts as a container for websocket messages. 422 | data attribute always contains a decoded or decrypted 423 | data (for binary messages). 424 | tag is also automatically generated if None is given as the tag.

425 |

Initiate the class.

426 |
427 | 428 | Expand source code 429 | 430 |
class WebsocketMessage:
431 |     """
432 |     `WebsocketMessage` acts as a container for websocket messages.
433 |     `data` attribute always contains a decoded or decrypted
434 |     data (for binary messages).
435 |     `tag` is also automatically generated if None is given as the tag.
436 |     """
437 |     def __init__(self,
438 |                  tag: Optional[str] = None,
439 |                  data: Optional[AnyStr] = None,
440 |                  is_binary: Optional[bool] = False) -> None:
441 |         """Initiate the class."""
442 | 
443 |         self.tag = tag
444 |         if not self.tag:
445 |             self.tag = utilities.generate_message_tag()
446 | 
447 |         self.data = data
448 |         self.is_binary = is_binary
449 | 
450 |     def serialize(self, keys: Sequence[bytes]) -> AnyStr:
451 |         """Unserialize the message. A regular JSON message
452 |         will be encoded. A binary message will be encrypted and also
453 |         prefixed with an HMAC checksum. It returns a ready-to-send
454 |         websocket message."""
455 |         if not self.is_binary:
456 |             return self.encode()
457 |         return self.encrypt(keys)
458 | 
459 |     def encrypt(self, keys: Sequence[bytes]) -> bytes:
460 |         """Encrypts a binary message."""
461 |         enc_key, mac_key = keys
462 |         checksum = crypto.hmac_sha256(mac_key, self.data)
463 |         serialized = f"{self.tag},".encode()
464 |         serialized += checksum
465 |         serialized += crypto.aes_encrypt(enc_key, self.data)
466 |         return serialized
467 | 
468 |     def encode(self) -> str:
469 |         """JSON encode the message if the message is not a
470 |         binary message."""
471 |         encoded_message = f"{self.tag},{json.dumps(self.data)}"
472 |         return encoded_message
473 | 
474 |     @classmethod
475 |     def unserialize(cls, message: AnyStr,
476 |                     keys: Sequence[bytes]) -> WebsocketMessage:
477 |         """Process a message and decide whether it is a binary
478 |         message or a regular JSON message. Then it will serialize
479 |         the message according to its type."""
480 |         if not isinstance(message, bytes):
481 |             return cls.from_encoded(message)
482 |         return cls.from_encrypted(message, keys)
483 | 
484 |     @classmethod
485 |     def from_encoded(cls, message: str) -> WebsocketMessage:
486 |         """Returns an initiated class from an encoded message."""
487 |         tag, encoded_data = message.split(",", 1)
488 |         return cls(tag, json.loads(encoded_data))
489 | 
490 |     @classmethod
491 |     def from_encrypted(cls, message: bytes,
492 |                        keys: Sequence[bytes]) -> WebsocketMessage:
493 |         """Returns an initiated class from a binary message.
494 |         This function also decrypts the contained message."""
495 |         enc_key, mac_key = keys
496 |         instance = cls()
497 |         instance.is_binary = True
498 | 
499 |         tag, data = message.split(b",", 1)
500 |         instance.tag = tag
501 | 
502 |         checksum = data[:32]
503 |         encrypted_data = data[32:]
504 | 
505 |         if crypto.hmac_sha256(mac_key, encrypted_data) != checksum:
506 |             raise exceptions.HMACValidationError
507 | 
508 |         instance.data = crypto.aes_decrypt(enc_key, encrypted_data)
509 | 
510 |         return instance
511 |
512 |

Static methods

513 |
514 |
515 | def from_encoded(message: str) -> WebsocketMessage 516 |
517 |
518 |

Returns an initiated class from an encoded message.

519 |
520 | 521 | Expand source code 522 | 523 |
@classmethod
524 | def from_encoded(cls, message: str) -> WebsocketMessage:
525 |     """Returns an initiated class from an encoded message."""
526 |     tag, encoded_data = message.split(",", 1)
527 |     return cls(tag, json.loads(encoded_data))
528 |
529 |
530 |
531 | def from_encrypted(message: bytes, keys: Sequence[bytes]) -> WebsocketMessage 532 |
533 |
534 |

Returns an initiated class from a binary message. 535 | This function also decrypts the contained message.

536 |
537 | 538 | Expand source code 539 | 540 |
@classmethod
541 | def from_encrypted(cls, message: bytes,
542 |                    keys: Sequence[bytes]) -> WebsocketMessage:
543 |     """Returns an initiated class from a binary message.
544 |     This function also decrypts the contained message."""
545 |     enc_key, mac_key = keys
546 |     instance = cls()
547 |     instance.is_binary = True
548 | 
549 |     tag, data = message.split(b",", 1)
550 |     instance.tag = tag
551 | 
552 |     checksum = data[:32]
553 |     encrypted_data = data[32:]
554 | 
555 |     if crypto.hmac_sha256(mac_key, encrypted_data) != checksum:
556 |         raise exceptions.HMACValidationError
557 | 
558 |     instance.data = crypto.aes_decrypt(enc_key, encrypted_data)
559 | 
560 |     return instance
561 |
562 |
563 |
564 | def unserialize(message: AnyStr, keys: Sequence[bytes]) -> WebsocketMessage 565 |
566 |
567 |

Process a message and decide whether it is a binary 568 | message or a regular JSON message. Then it will serialize 569 | the message according to its type.

570 |
571 | 572 | Expand source code 573 | 574 |
@classmethod
575 | def unserialize(cls, message: AnyStr,
576 |                 keys: Sequence[bytes]) -> WebsocketMessage:
577 |     """Process a message and decide whether it is a binary
578 |     message or a regular JSON message. Then it will serialize
579 |     the message according to its type."""
580 |     if not isinstance(message, bytes):
581 |         return cls.from_encoded(message)
582 |     return cls.from_encrypted(message, keys)
583 |
584 |
585 |
586 |

Methods

587 |
588 |
589 | def encode(self) -> str 590 |
591 |
592 |

JSON encode the message if the message is not a 593 | binary message.

594 |
595 | 596 | Expand source code 597 | 598 |
def encode(self) -> str:
599 |     """JSON encode the message if the message is not a
600 |     binary message."""
601 |     encoded_message = f"{self.tag},{json.dumps(self.data)}"
602 |     return encoded_message
603 |
604 |
605 |
606 | def encrypt(self, keys: Sequence[bytes]) -> bytes 607 |
608 |
609 |

Encrypts a binary message.

610 |
611 | 612 | Expand source code 613 | 614 |
def encrypt(self, keys: Sequence[bytes]) -> bytes:
615 |     """Encrypts a binary message."""
616 |     enc_key, mac_key = keys
617 |     checksum = crypto.hmac_sha256(mac_key, self.data)
618 |     serialized = f"{self.tag},".encode()
619 |     serialized += checksum
620 |     serialized += crypto.aes_encrypt(enc_key, self.data)
621 |     return serialized
622 |
623 |
624 |
625 | def serialize(self, keys: Sequence[bytes]) -> ~AnyStr 626 |
627 |
628 |

Unserialize the message. A regular JSON message 629 | will be encoded. A binary message will be encrypted and also 630 | prefixed with an HMAC checksum. It returns a ready-to-send 631 | websocket message.

632 |
633 | 634 | Expand source code 635 | 636 |
def serialize(self, keys: Sequence[bytes]) -> AnyStr:
637 |     """Unserialize the message. A regular JSON message
638 |     will be encoded. A binary message will be encrypted and also
639 |     prefixed with an HMAC checksum. It returns a ready-to-send
640 |     websocket message."""
641 |     if not self.is_binary:
642 |         return self.encode()
643 |     return self.encrypt(keys)
644 |
645 |
646 |
647 |
648 |
649 | class WebsocketMessages 650 |
651 |
652 |

This class acts as a container for WebsocketMessage instances. 653 | Allows an easy access to messages in queue. The messages are feed 654 | by WebsocketClient class.

655 |
656 | 657 | Expand source code 658 | 659 |
class WebsocketMessages:
660 |     """This class acts as a container for `WebsocketMessage` instances.
661 |     Allows an easy access to messages in queue. The messages are feed
662 |     by `WebsocketClient` class."""
663 |     messages = {}
664 | 
665 |     def add(self, tag: str, data: AnyStr):
666 |         """Appends a message to the messages mapping."""
667 |         self.messages[tag] = data
668 | 
669 |     def get(self, tag: str, timeout: Optional[int] = 10):
670 |         """Gets a message with specified tag. If not currently
671 |         present, it will wait until `timeout` reached. Raises
672 |         asyncio.TimeoutError when timed out."""
673 |         async def get_message():
674 |             while tag not in self.messages:
675 |                 await asyncio.sleep(0)
676 |             return self.messages.pop(tag)
677 | 
678 |         logger.debug("Getting message with tag %s", tag)
679 | 
680 |         return asyncio.wait_for(get_message(), timeout)
681 |
682 |

Class variables

683 |
684 |
var messages
685 |
686 |
687 |
688 |
689 |

Methods

690 |
691 |
692 | def add(self, tag: str, data: AnyStr) 693 |
694 |
695 |

Appends a message to the messages mapping.

696 |
697 | 698 | Expand source code 699 | 700 |
def add(self, tag: str, data: AnyStr):
701 |     """Appends a message to the messages mapping."""
702 |     self.messages[tag] = data
703 |
704 |
705 |
706 | def get(self, tag: str, timeout: Optional[int] = 10) 707 |
708 |
709 |

Gets a message with specified tag. If not currently 710 | present, it will wait until timeout reached. Raises 711 | asyncio.TimeoutError when timed out.

712 |
713 | 714 | Expand source code 715 | 716 |
def get(self, tag: str, timeout: Optional[int] = 10):
717 |     """Gets a message with specified tag. If not currently
718 |     present, it will wait until `timeout` reached. Raises
719 |     asyncio.TimeoutError when timed out."""
720 |     async def get_message():
721 |         while tag not in self.messages:
722 |             await asyncio.sleep(0)
723 |         return self.messages.pop(tag)
724 | 
725 |     logger.debug("Getting message with tag %s", tag)
726 | 
727 |     return asyncio.wait_for(get_message(), timeout)
728 |
729 |
730 |
731 |
732 |
733 |
734 |
735 | 784 |
785 | 788 | 789 | 790 | 791 | -------------------------------------------------------------------------------- /kyros/__init__.py: -------------------------------------------------------------------------------- 1 | from .client import Client 2 | from .exceptions import HMACValidationError, StatusCodeError 3 | from .message import MessageHandler 4 | from .session import Session 5 | from .websocket import WebsocketMessage 6 | 7 | __all__ = [ 8 | "Client", "MessageHandler", "Session", "StatusCodeError", 9 | "HMACValidationError", "WebsocketMessage" 10 | ] 11 | -------------------------------------------------------------------------------- /kyros/client.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import base64 5 | import logging 6 | from typing import Any, Awaitable, Callable, Sequence, Union 7 | 8 | import donna25519 9 | 10 | from . import (constants, crypto, exceptions, message, session, utilities, 11 | websocket) 12 | 13 | logger = logging.getLogger(__name__) # pylint: disable=invalid-name 14 | 15 | 16 | class Client: 17 | """This class is the wrapper for WhatsApp Web. 18 | Errors mainly shown as log messages (using `logging`). 19 | Some methods might raise an exception that will interrupt the 20 | whole session. Please make sure to catch any exception thrown. 21 | You might want to use the `Client.ensure_safe` method. 22 | Please note that some exceptions should not be ignored as it might 23 | be important (example: a timeout error when sending a message will 24 | result in the failing of message delivery). A much better and pythonic 25 | way to handle and raise exception is still a pending task.""" 26 | @classmethod 27 | async def create(cls) -> Client: 28 | """The proper way to instantiate `Client` class. Connects to 29 | websocket server, also sets up the default client profile. 30 | Returns a ready to use `Client` instance.""" 31 | instance = cls() 32 | await instance.setup_ws() 33 | instance.load_profile(constants.CLIENT_VERSION, 34 | constants.CLIENT_LONG_DESC, 35 | constants.CLIENT_SHORT_DESC) 36 | logger.info("Kyros instance created") 37 | return instance 38 | 39 | def __init__(self) -> None: 40 | """Initiate class. Do not initiate this way, use `Client.create()` 41 | instead.""" 42 | self.profile = None 43 | self.message_handler = message.MessageHandler() 44 | self.session = session.Session() 45 | self.session.client_id = utilities.generate_client_id() 46 | self.session.private_key = donna25519.PrivateKey() 47 | self.session.public_key = self.session.private_key.get_public() 48 | self.phone_info = {} 49 | self.websocket = None 50 | 51 | async def setup_ws(self) -> None: 52 | """Connect to websocket server.""" 53 | self.websocket = websocket.WebsocketClient(self.message_handler) 54 | await self.websocket.connect() 55 | self.websocket.load_session(self.session) 56 | 57 | def load_profile(self, ver: Sequence[Union[float, int]], long_desc: str, 58 | short_desc: str) -> None: 59 | """Loads a new client profile (which will be shown in the WhatsApp 60 | mobile app). Please note that the client profile is unchangeable after 61 | logging in (after admin init).""" 62 | logger.debug("Loaded new profile") 63 | self.profile = { 64 | "version": ver, 65 | "long_description": long_desc, 66 | "short_description": short_desc, 67 | } 68 | 69 | async def send_init(self) -> None: 70 | """Send an admin init message. Usually not used directly. Used whens 71 | doing QR login or restoring session.""" 72 | init_message = websocket.WebsocketMessage(None, [ 73 | "admin", "init", self.profile["version"], 74 | [ 75 | self.profile["long_description"], 76 | self.profile["short_description"] 77 | ], self.session.client_id, True 78 | ]) 79 | await self.websocket.send_message(init_message) 80 | 81 | resp = await self.websocket.messages.get(init_message.tag) 82 | if resp["status"] != 200: 83 | logger.error("unexpected init stts code, resp:%s", resp) 84 | raise exceptions.StatusCodeError(resp["status"]) 85 | 86 | self.session.server_id = resp["ref"] 87 | 88 | async def qr_login(self) -> (str, Awaitable): 89 | """Does a QR login. Sends init then return the qr data 90 | which will be shown using `pyqrcode` or another library and. 91 | also returns a waitable which will timeout in 20 seconds. 92 | 20 seconds is the maximum amount of time for the QR code to be 93 | considered valid. 94 | Raises `asyncio.TimeoutError` if timeout reached. 95 | Another exception might also possible.""" 96 | await self.send_init() 97 | 98 | async def wait_qr_scan(): 99 | ws_message = await self.websocket.messages.get("s1", 20) 100 | connection_data = ws_message[1] 101 | 102 | self.phone_info = connection_data["phone"] 103 | self.session.secret = base64.b64decode( 104 | connection_data["secret"].encode()) 105 | self.session.server_token = connection_data["serverToken"] 106 | self.session.client_token = connection_data["clientToken"] 107 | self.session.browser_token = connection_data["browserToken"] 108 | self.session.wid = connection_data["wid"] 109 | 110 | self.session.shared_secret = self.session.private_key.do_exchange( 111 | donna25519.PublicKey(self.session.secret[:32])) 112 | self.session.shared_secret_expanded = crypto.hkdf_expand( 113 | self.session.shared_secret, 80) 114 | 115 | if not crypto.validate_secrets( 116 | self.session.secret, self.session.shared_secret_expanded): 117 | raise exceptions.HMACValidationError 118 | 119 | self.session.keys_encrypted = self.session.shared_secret_expanded[ 120 | 64:] + self.session.secret[64:] 121 | self.session.keys_decrypted = crypto.aes_decrypt( 122 | self.session.shared_secret_expanded[:32], 123 | self.session.keys_encrypted) 124 | 125 | self.session.enc_key = self.session.keys_decrypted[:32] 126 | self.session.mac_key = self.session.keys_decrypted[32:64] 127 | 128 | qr_fragments = [ 129 | self.session.server_id, 130 | base64.b64encode(self.session.public_key.public).decode(), 131 | self.session.client_id 132 | ] 133 | qr_data = ",".join(qr_fragments) 134 | 135 | return qr_data, wait_qr_scan() 136 | 137 | async def restore_session( # noqa: mc0001 138 | self, new_session: session.Session = None) -> session.Session: 139 | """Restores a session. Returns the new session object. 140 | If `new_session` argument specified, replace current session with 141 | the new one. 142 | Raises asyncio.TimeoutError when a websocket request reaches timeout. 143 | Old session is restored when it fails restoring the new one.""" 144 | old_session = self.session 145 | if new_session: 146 | self.session = new_session 147 | 148 | async def restore(): 149 | await self.send_init() 150 | 151 | login_message = websocket.WebsocketMessage(None, [ 152 | "admin", "login", self.session.client_token, 153 | self.session.server_token, self.session.client_id, "takeover" 154 | ]) 155 | await self.websocket.send_message(login_message) 156 | 157 | s1_message = None 158 | try: 159 | s1_message = await self.websocket.messages.get("s1") 160 | except asyncio.TimeoutError: 161 | logger.error("s1 message timed out") 162 | 163 | login_resp = await self.websocket.messages.get( 164 | login_message.tag) 165 | if login_resp["status"] != 200: 166 | raise exceptions.StatusCodeError(login_resp["status"]) 167 | self.websocket.messages.add(login_message.tag, login_resp) 168 | 169 | s2_message = None 170 | if len(s1_message) == 2 and s1_message[0] == "Cmd" \ 171 | and s1_message[1]["type"] == "challenge": 172 | await self.resolve_challenge(s1_message[1]["challenge"]) 173 | s2_message = await self.websocket.messages.get("s2") 174 | 175 | login_resp = await self.websocket.messages.get(login_message.tag) 176 | if login_resp["status"] != 200: 177 | raise exceptions.StatusCodeError(login_resp["status"]) 178 | 179 | conn_resp = s2_message if s2_message else s1_message 180 | info = conn_resp[1] 181 | self.phone_info = info["phone"] 182 | self.session.wid = info["wid"] 183 | self.session.client_token = info["clientToken"] 184 | self.session.server_token = info["serverToken"] 185 | 186 | self.websocket.load_session(self.session) # reload references 187 | return self.session 188 | 189 | try: 190 | return await restore() 191 | except Exception: # pylint: disable=broad-except 192 | if old_session: 193 | self.session = old_session 194 | raise 195 | 196 | async def resolve_challenge(self, challenge: str) -> None: 197 | """Resolve a challenge string. Sings challenge with mac_key and send 198 | a challenge response ws message. Usually called when restoring session. 199 | Raises `asyncio.TimeoutError` when timeout reached.""" 200 | challenge = base64.b64decode(challenge.encode()) 201 | signed = crypto.hmac_sha256(self.session.mac_key, challenge) 202 | 203 | chall_reply_message = websocket.WebsocketMessage( 204 | None, [ 205 | "admin", "challenge", 206 | base64.b64encode(signed).decode(), self.session.server_token, 207 | self.session.client_id 208 | ]) 209 | await self.websocket.send_message(chall_reply_message) 210 | 211 | resp = await self.websocket.messages.get(chall_reply_message.tag) 212 | if resp["status"] != 200: 213 | raise exceptions.StatusCodeError(resp["status"]) 214 | 215 | return True 216 | 217 | async def ensure_safe(self, func: Callable, *args: Any, 218 | **kwargs: Any) -> (Union[None, Exception], Any): 219 | """A function intended to be used to run another function without 220 | raising any exception. Returns an exception as first element of 221 | the tuple if available. Also returns the result of the function call 222 | as the second element of the tuple if no exceptions raised. If `func` 223 | is a coroutine function, this function returns the awaited result. 224 | """ 225 | try: 226 | return_value = func(*args, **kwargs) 227 | if asyncio.iscoroutine(return_value): 228 | return None, await return_value 229 | return None, return_value 230 | except Exception as exc: # pylint: disable=broad-except 231 | logger.error("Exception %s raised at %s", exc, func.__name__) 232 | return exc, None 233 | 234 | async def logout(self) -> None: 235 | """Sends a logout message to the websocket server. This will 236 | invalidate the session.""" 237 | await self.websocket.send_message( 238 | websocket.WebsocketMessage(None, ["admin", "Conn", "disconnect"])) 239 | 240 | async def shutdown(self) -> None: 241 | """Do a cleanup. Closes websocket connection.""" 242 | logger.info("Shutting down") 243 | await self.websocket.shutdown() 244 | -------------------------------------------------------------------------------- /kyros/constants.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file contains the required constants in order for 3 | the client to work. These values might be changed when necessary. 4 | """ 5 | 6 | WEBSOCKET_URI = "wss://web.whatsapp.com/ws" 7 | WEBSOCKET_ORIGIN = "https://web.whatsapp.com" 8 | CLIENT_VERSION = [100, 100, 100] 9 | CLIENT_LONG_DESC = "Kyros" 10 | CLIENT_SHORT_DESC = "Kyros" 11 | -------------------------------------------------------------------------------- /kyros/crypto.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=invalid-name 2 | import Crypto.Cipher.AES 3 | import Crypto.Hash 4 | import Crypto.Protocol 5 | import Crypto.Util.Padding 6 | 7 | 8 | def hkdf_expand(key: bytes, length: int) -> bytes: 9 | """Expand a key to a length.""" 10 | return Crypto.Protocol.KDF.HKDF(key, length, None, Crypto.Hash.SHA256) 11 | 12 | 13 | def validate_secrets(secret: bytes, shared_secret_expanded: bytes) -> bool: 14 | """Validate secrets. Used during QR login process.""" 15 | return Crypto.Hash.HMAC.new(shared_secret_expanded[32:64], 16 | secret[:32] + secret[64:], 17 | Crypto.Hash.SHA256).digest() == secret[32:64] 18 | 19 | 20 | def hmac_sha256(mac: bytes, message: bytes) -> bytes: 21 | """Sign a message with a mac key.""" 22 | return Crypto.Hash.HMAC.new(mac, message, Crypto.Hash.SHA256).digest() 23 | 24 | 25 | def aes_encrypt(key: bytes, plaintext: bytes) -> bytes: 26 | """Encrypt a plaintext using AES CBC.""" 27 | plaintext = Crypto.Util.Padding.pad(plaintext, 28 | Crypto.Cipher.AES.block_size) 29 | cipher = Crypto.Cipher.AES.new(key, Crypto.Cipher.AES.MODE_CBC) 30 | ciphertext = cipher.encrypt(plaintext) 31 | return cipher.iv + ciphertext 32 | 33 | 34 | def aes_decrypt(key: bytes, ciphertext: bytes) -> bytes: 35 | """Decrypt a ciphertext using AES CBC.""" 36 | iv = ciphertext[:Crypto.Cipher.AES.block_size] 37 | cipher = Crypto.Cipher.AES.new(key, Crypto.Cipher.AES.MODE_CBC, iv) 38 | plaintext = cipher.decrypt(ciphertext[Crypto.Cipher.AES.block_size:]) 39 | return Crypto.Util.Padding.unpad(plaintext, Crypto.Cipher.AES.block_size) 40 | -------------------------------------------------------------------------------- /kyros/dev-requirements.txt: -------------------------------------------------------------------------------- 1 | appdirs==1.4.3 2 | astroid==2.3.3 3 | attrs==19.3.0 4 | beautifulsoup4==4.9.1 5 | black==19.10b0 6 | click==7.1.2 7 | curve25519==0.1 8 | dodgy==0.2.1 9 | donna25519==0.1.1 10 | entrypoints==0.3 11 | flake8==3.7.9 12 | google==2.0.3 13 | importlib-metadata==1.6.0 14 | isort==4.3.21 15 | lazy-object-proxy==1.4.3 16 | Mako==1.2.2 17 | Markdown==3.2.2 18 | MarkupSafe==1.1.1 19 | mccabe==0.6.1 20 | nr.collections==0.0.1 21 | nr.databind.core==0.0.14 22 | nr.databind.json==0.0.9 23 | nr.interface==0.0.2 24 | nr.metaclass==0.0.5 25 | nr.parsing.date==0.1.0 26 | nr.pylang.utils==0.0.2 27 | nr.stream==0.0.3 28 | pathspec==0.8.0 29 | pathtools==0.1.2 30 | pdoc3==0.8.1 31 | pep8-naming==0.4.1 32 | pkg-resources==0.0.0 33 | prospector==1.2.0 34 | protobuf==3.18.3 35 | pycodestyle==2.4.0 36 | pycryptodome==3.9.7 37 | pydoc-markdown==3.0.2 38 | pydocstyle==5.0.2 39 | pyflakes==2.1.1 40 | pylint==2.4.4 41 | pylint-celery==0.3 42 | pylint-django==2.0.12 43 | pylint-flask==0.6 44 | pylint-plugin-utils==0.6 45 | PyQRCode==1.2.1 46 | PyYAML==5.4 47 | regex==2020.4.4 48 | requirements-detector==0.6 49 | rope==0.16.0 50 | setoptconf==0.2.0 51 | six==1.14.0 52 | snowballstemmer==2.0.0 53 | soupsieve==2.0.1 54 | toml==0.10.0 55 | typed-ast==1.4.1 56 | typing==3.7.4.1 57 | watchdog==0.10.2 58 | websocket-client==0.57.0 59 | websockets==9.1 60 | wrapt==1.11.2 61 | yapf==0.30.0 62 | zipp==3.1.0 63 | -------------------------------------------------------------------------------- /kyros/exceptions.py: -------------------------------------------------------------------------------- 1 | class HMACValidationError(Exception): 2 | """Raised when checksum does not match. For example, when 3 | validating binary messages.""" 4 | message = "checksum verification failed" 5 | 6 | 7 | class StatusCodeError(Exception): 8 | """Raised when a websocket message responded with an unexpected 9 | status code.""" 10 | def __init__(self, code): 11 | self.code = code 12 | message = f"Unexpected status code: {code}" 13 | super().__init__(message) 14 | -------------------------------------------------------------------------------- /kyros/message.py: -------------------------------------------------------------------------------- 1 | class MessageHandler: 2 | """Future class. To be implemented soon.""" 3 | def handle_text_message(self): 4 | pass 5 | 6 | def handle_image_message(self): 7 | pass 8 | 9 | def handle_video_message(self): 10 | pass 11 | 12 | def handle_json_message(self): 13 | pass 14 | -------------------------------------------------------------------------------- /kyros/proto/__init__.py: -------------------------------------------------------------------------------- 1 | from .def_pb2 import * # noqa: F401, F403 2 | -------------------------------------------------------------------------------- /kyros/proto/def.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto2"; 2 | package proto; 3 | 4 | message HydratedQuickReplyButton { 5 | optional string displayText = 1; 6 | optional string id = 2; 7 | } 8 | 9 | message HydratedURLButton { 10 | optional string displayText = 1; 11 | optional string url = 2; 12 | } 13 | 14 | message HydratedCallButton { 15 | optional string displayText = 1; 16 | optional string phoneNumber = 2; 17 | } 18 | 19 | message HydratedTemplateButton { 20 | optional uint32 index = 4; 21 | oneof hydratedButton { 22 | HydratedQuickReplyButton quickReplyButton = 1; 23 | HydratedURLButton urlButton = 2; 24 | HydratedCallButton callButton = 3; 25 | } 26 | } 27 | 28 | message QuickReplyButton { 29 | optional HighlyStructuredMessage displayText = 1; 30 | optional string id = 2; 31 | } 32 | 33 | message URLButton { 34 | optional HighlyStructuredMessage displayText = 1; 35 | optional HighlyStructuredMessage url = 2; 36 | } 37 | 38 | message CallButton { 39 | optional HighlyStructuredMessage displayText = 1; 40 | optional HighlyStructuredMessage phoneNumber = 2; 41 | } 42 | 43 | message TemplateButton { 44 | optional uint32 index = 4; 45 | oneof button { 46 | QuickReplyButton quickReplyButton = 1; 47 | URLButton urlButton = 2; 48 | CallButton callButton = 3; 49 | } 50 | } 51 | 52 | message Location { 53 | optional double degreesLatitude = 1; 54 | optional double degreesLongitude = 2; 55 | optional string name = 3; 56 | } 57 | 58 | message Point { 59 | optional int32 xDeprecated = 1; 60 | optional int32 yDeprecated = 2; 61 | optional double x = 3; 62 | optional double y = 4; 63 | } 64 | 65 | message InteractiveAnnotation { 66 | repeated Point polygonVertices = 1; 67 | oneof action { 68 | Location location = 2; 69 | } 70 | } 71 | 72 | message AdReplyInfo { 73 | optional string advertiserName = 1; 74 | enum AD_REPLY_INFO_MEDIATYPE { 75 | NONE = 0; 76 | IMAGE = 1; 77 | VIDEO = 2; 78 | } 79 | optional AD_REPLY_INFO_MEDIATYPE mediaType = 2; 80 | optional bytes jpegThumbnail = 16; 81 | optional string caption = 17; 82 | } 83 | 84 | message ContextInfo { 85 | optional string stanzaId = 1; 86 | optional string participant = 2; 87 | optional Message quotedMessage = 3; 88 | optional string remoteJid = 4; 89 | repeated string mentionedJid = 15; 90 | optional string conversionSource = 18; 91 | optional bytes conversionData = 19; 92 | optional uint32 conversionDelaySeconds = 20; 93 | optional uint32 forwardingScore = 21; 94 | optional bool isForwarded = 22; 95 | optional AdReplyInfo quotedAd = 23; 96 | optional MessageKey placeholderKey = 24; 97 | optional uint32 expiration = 25; 98 | optional int64 ephemeralSettingTimestamp = 26; 99 | } 100 | 101 | message SenderKeyDistributionMessage { 102 | optional string groupId = 1; 103 | optional bytes axolotlSenderKeyDistributionMessage = 2; 104 | } 105 | 106 | message ImageMessage { 107 | optional string url = 1; 108 | optional string mimetype = 2; 109 | optional string caption = 3; 110 | optional bytes fileSha256 = 4; 111 | optional uint64 fileLength = 5; 112 | optional uint32 height = 6; 113 | optional uint32 width = 7; 114 | optional bytes mediaKey = 8; 115 | optional bytes fileEncSha256 = 9; 116 | repeated InteractiveAnnotation interactiveAnnotations = 10; 117 | optional string directPath = 11; 118 | optional int64 mediaKeyTimestamp = 12; 119 | optional bytes jpegThumbnail = 16; 120 | optional ContextInfo contextInfo = 17; 121 | optional bytes firstScanSidecar = 18; 122 | optional uint32 firstScanLength = 19; 123 | optional uint32 experimentGroupId = 20; 124 | optional bytes scansSidecar = 21; 125 | repeated uint32 scanLengths = 22; 126 | optional bytes midQualityFileSha256 = 23; 127 | optional bytes midQualityFileEncSha256 = 24; 128 | } 129 | 130 | message ContactMessage { 131 | optional string displayName = 1; 132 | optional string vcard = 16; 133 | optional ContextInfo contextInfo = 17; 134 | } 135 | 136 | message LocationMessage { 137 | optional double degreesLatitude = 1; 138 | optional double degreesLongitude = 2; 139 | optional string name = 3; 140 | optional string address = 4; 141 | optional string url = 5; 142 | optional bool isLive = 6; 143 | optional uint32 accuracyInMeters = 7; 144 | optional float speedInMps = 8; 145 | optional uint32 degreesClockwiseFromMagneticNorth = 9; 146 | optional string comment = 11; 147 | optional bytes jpegThumbnail = 16; 148 | optional ContextInfo contextInfo = 17; 149 | } 150 | 151 | message ExtendedTextMessage { 152 | optional string text = 1; 153 | optional string matchedText = 2; 154 | optional string canonicalUrl = 4; 155 | optional string description = 5; 156 | optional string title = 6; 157 | optional fixed32 textArgb = 7; 158 | optional fixed32 backgroundArgb = 8; 159 | enum EXTENDED_TEXT_MESSAGE_FONTTYPE { 160 | SANS_SERIF = 0; 161 | SERIF = 1; 162 | NORICAN_REGULAR = 2; 163 | BRYNDAN_WRITE = 3; 164 | BEBASNEUE_REGULAR = 4; 165 | OSWALD_HEAVY = 5; 166 | } 167 | optional EXTENDED_TEXT_MESSAGE_FONTTYPE font = 9; 168 | enum EXTENDED_TEXT_MESSAGE_PREVIEWTYPE { 169 | NONE = 0; 170 | VIDEO = 1; 171 | } 172 | optional EXTENDED_TEXT_MESSAGE_PREVIEWTYPE previewType = 10; 173 | optional bytes jpegThumbnail = 16; 174 | optional ContextInfo contextInfo = 17; 175 | optional bool doNotPlayInline = 18; 176 | } 177 | 178 | message DocumentMessage { 179 | optional string url = 1; 180 | optional string mimetype = 2; 181 | optional string title = 3; 182 | optional bytes fileSha256 = 4; 183 | optional uint64 fileLength = 5; 184 | optional uint32 pageCount = 6; 185 | optional bytes mediaKey = 7; 186 | optional string fileName = 8; 187 | optional bytes fileEncSha256 = 9; 188 | optional string directPath = 10; 189 | optional int64 mediaKeyTimestamp = 11; 190 | optional bytes jpegThumbnail = 16; 191 | optional ContextInfo contextInfo = 17; 192 | } 193 | 194 | message AudioMessage { 195 | optional string url = 1; 196 | optional string mimetype = 2; 197 | optional bytes fileSha256 = 3; 198 | optional uint64 fileLength = 4; 199 | optional uint32 seconds = 5; 200 | optional bool ptt = 6; 201 | optional bytes mediaKey = 7; 202 | optional bytes fileEncSha256 = 8; 203 | optional string directPath = 9; 204 | optional int64 mediaKeyTimestamp = 10; 205 | optional ContextInfo contextInfo = 17; 206 | optional bytes streamingSidecar = 18; 207 | } 208 | 209 | message VideoMessage { 210 | optional string url = 1; 211 | optional string mimetype = 2; 212 | optional bytes fileSha256 = 3; 213 | optional uint64 fileLength = 4; 214 | optional uint32 seconds = 5; 215 | optional bytes mediaKey = 6; 216 | optional string caption = 7; 217 | optional bool gifPlayback = 8; 218 | optional uint32 height = 9; 219 | optional uint32 width = 10; 220 | optional bytes fileEncSha256 = 11; 221 | repeated InteractiveAnnotation interactiveAnnotations = 12; 222 | optional string directPath = 13; 223 | optional int64 mediaKeyTimestamp = 14; 224 | optional bytes jpegThumbnail = 16; 225 | optional ContextInfo contextInfo = 17; 226 | optional bytes streamingSidecar = 18; 227 | enum VIDEO_MESSAGE_ATTRIBUTION { 228 | NONE = 0; 229 | GIPHY = 1; 230 | TENOR = 2; 231 | } 232 | optional VIDEO_MESSAGE_ATTRIBUTION gifAttribution = 19; 233 | } 234 | 235 | message Call { 236 | optional bytes callKey = 1; 237 | } 238 | 239 | message Chat { 240 | optional string displayName = 1; 241 | optional string id = 2; 242 | } 243 | 244 | message ProtocolMessage { 245 | optional MessageKey key = 1; 246 | enum PROTOCOL_MESSAGE_TYPE { 247 | REVOKE = 0; 248 | EPHEMERAL_SETTING = 3; 249 | EPHEMERAL_SYNC_RESPONSE = 4; 250 | HISTORY_SYNC_NOTIFICATION = 5; 251 | } 252 | optional PROTOCOL_MESSAGE_TYPE type = 2; 253 | optional uint32 ephemeralExpiration = 4; 254 | optional int64 ephemeralSettingTimestamp = 5; 255 | optional HistorySyncNotification historySyncNotification = 6; 256 | } 257 | 258 | message HistorySyncNotification { 259 | optional bytes fileSha256 = 1; 260 | optional uint64 fileLength = 2; 261 | optional bytes mediaKey = 3; 262 | optional bytes fileEncSha256 = 4; 263 | optional string directPath = 5; 264 | enum HISTORY_SYNC_NOTIFICATION_HISTORYSYNCTYPE { 265 | INITIAL_BOOTSTRAP = 0; 266 | INITIAL_STATUS_V3 = 1; 267 | FULL = 2; 268 | RECENT = 3; 269 | } 270 | optional HISTORY_SYNC_NOTIFICATION_HISTORYSYNCTYPE syncType = 6; 271 | optional uint32 chunkOrder = 7; 272 | } 273 | 274 | message ContactsArrayMessage { 275 | optional string displayName = 1; 276 | repeated ContactMessage contacts = 2; 277 | optional ContextInfo contextInfo = 17; 278 | } 279 | 280 | message HSMCurrency { 281 | optional string currencyCode = 1; 282 | optional int64 amount1000 = 2; 283 | } 284 | 285 | message HSMDateTimeComponent { 286 | enum HSM_DATE_TIME_COMPONENT_DAYOFWEEKTYPE { 287 | MONDAY = 1; 288 | TUESDAY = 2; 289 | WEDNESDAY = 3; 290 | THURSDAY = 4; 291 | FRIDAY = 5; 292 | SATURDAY = 6; 293 | SUNDAY = 7; 294 | } 295 | optional HSM_DATE_TIME_COMPONENT_DAYOFWEEKTYPE dayOfWeek = 1; 296 | optional uint32 year = 2; 297 | optional uint32 month = 3; 298 | optional uint32 dayOfMonth = 4; 299 | optional uint32 hour = 5; 300 | optional uint32 minute = 6; 301 | enum HSM_DATE_TIME_COMPONENT_CALENDARTYPE { 302 | GREGORIAN = 1; 303 | SOLAR_HIJRI = 2; 304 | } 305 | optional HSM_DATE_TIME_COMPONENT_CALENDARTYPE calendar = 7; 306 | } 307 | 308 | message HSMDateTimeUnixEpoch { 309 | optional int64 timestamp = 1; 310 | } 311 | 312 | message HSMDateTime { 313 | oneof datetimeOneof { 314 | HSMDateTimeComponent component = 1; 315 | HSMDateTimeUnixEpoch unixEpoch = 2; 316 | } 317 | } 318 | 319 | message HSMLocalizableParameter { 320 | optional string default = 1; 321 | oneof paramOneof { 322 | HSMCurrency currency = 2; 323 | HSMDateTime dateTime = 3; 324 | } 325 | } 326 | 327 | message HighlyStructuredMessage { 328 | optional string namespace = 1; 329 | optional string elementName = 2; 330 | repeated string params = 3; 331 | optional string fallbackLg = 4; 332 | optional string fallbackLc = 5; 333 | repeated HSMLocalizableParameter localizableParams = 6; 334 | optional string deterministicLg = 7; 335 | optional string deterministicLc = 8; 336 | optional TemplateMessage hydratedHsm = 9; 337 | } 338 | 339 | message SendPaymentMessage { 340 | optional Message noteMessage = 2; 341 | optional MessageKey requestMessageKey = 3; 342 | } 343 | 344 | message RequestPaymentMessage { 345 | optional Message noteMessage = 4; 346 | optional string currencyCodeIso4217 = 1; 347 | optional uint64 amount1000 = 2; 348 | optional string requestFrom = 3; 349 | optional int64 expiryTimestamp = 5; 350 | } 351 | 352 | message DeclinePaymentRequestMessage { 353 | optional MessageKey key = 1; 354 | } 355 | 356 | message CancelPaymentRequestMessage { 357 | optional MessageKey key = 1; 358 | } 359 | 360 | message LiveLocationMessage { 361 | optional double degreesLatitude = 1; 362 | optional double degreesLongitude = 2; 363 | optional uint32 accuracyInMeters = 3; 364 | optional float speedInMps = 4; 365 | optional uint32 degreesClockwiseFromMagneticNorth = 5; 366 | optional string caption = 6; 367 | optional int64 sequenceNumber = 7; 368 | optional uint32 timeOffset = 8; 369 | optional bytes jpegThumbnail = 16; 370 | optional ContextInfo contextInfo = 17; 371 | } 372 | 373 | message StickerMessage { 374 | optional string url = 1; 375 | optional bytes fileSha256 = 2; 376 | optional bytes fileEncSha256 = 3; 377 | optional bytes mediaKey = 4; 378 | optional string mimetype = 5; 379 | optional uint32 height = 6; 380 | optional uint32 width = 7; 381 | optional string directPath = 8; 382 | optional uint64 fileLength = 9; 383 | optional int64 mediaKeyTimestamp = 10; 384 | optional uint32 firstFrameLength = 11; 385 | optional bytes firstFrameSidecar = 12; 386 | optional bool isAnimated = 13; 387 | optional bytes pngThumbnail = 16; 388 | optional ContextInfo contextInfo = 17; 389 | } 390 | 391 | message FourRowTemplate { 392 | optional HighlyStructuredMessage content = 6; 393 | optional HighlyStructuredMessage footer = 7; 394 | repeated TemplateButton buttons = 8; 395 | oneof title { 396 | DocumentMessage documentMessage = 1; 397 | HighlyStructuredMessage highlyStructuredMessage = 2; 398 | ImageMessage imageMessage = 3; 399 | VideoMessage videoMessage = 4; 400 | LocationMessage locationMessage = 5; 401 | } 402 | } 403 | 404 | message HydratedFourRowTemplate { 405 | optional string hydratedContentText = 6; 406 | optional string hydratedFooterText = 7; 407 | repeated HydratedTemplateButton hydratedButtons = 8; 408 | optional string templateId = 9; 409 | oneof title { 410 | DocumentMessage documentMessage = 1; 411 | string hydratedTitleText = 2; 412 | ImageMessage imageMessage = 3; 413 | VideoMessage videoMessage = 4; 414 | LocationMessage locationMessage = 5; 415 | } 416 | } 417 | 418 | message TemplateMessage { 419 | optional ContextInfo contextInfo = 3; 420 | optional HydratedFourRowTemplate hydratedTemplate = 4; 421 | oneof format { 422 | FourRowTemplate fourRowTemplate = 1; 423 | HydratedFourRowTemplate hydratedFourRowTemplate = 2; 424 | } 425 | } 426 | 427 | message TemplateButtonReplyMessage { 428 | optional string selectedId = 1; 429 | optional string selectedDisplayText = 2; 430 | optional ContextInfo contextInfo = 3; 431 | optional uint32 selectedIndex = 4; 432 | } 433 | 434 | message CatalogSnapshot { 435 | optional ImageMessage catalogImage = 1; 436 | optional string title = 2; 437 | optional string description = 3; 438 | } 439 | 440 | message ProductSnapshot { 441 | optional ImageMessage productImage = 1; 442 | optional string productId = 2; 443 | optional string title = 3; 444 | optional string description = 4; 445 | optional string currencyCode = 5; 446 | optional int64 priceAmount1000 = 6; 447 | optional string retailerId = 7; 448 | optional string url = 8; 449 | optional uint32 productImageCount = 9; 450 | optional string firstImageId = 11; 451 | } 452 | 453 | message ProductMessage { 454 | optional ProductSnapshot product = 1; 455 | optional string businessOwnerJid = 2; 456 | optional CatalogSnapshot catalog = 4; 457 | optional ContextInfo contextInfo = 17; 458 | } 459 | 460 | message GroupInviteMessage { 461 | optional string groupJid = 1; 462 | optional string inviteCode = 2; 463 | optional int64 inviteExpiration = 3; 464 | optional string groupName = 4; 465 | optional bytes jpegThumbnail = 5; 466 | optional string caption = 6; 467 | optional ContextInfo contextInfo = 7; 468 | } 469 | 470 | message DeviceSentMessage { 471 | optional string destinationJid = 1; 472 | optional Message message = 2; 473 | } 474 | 475 | message DeviceSyncMessage { 476 | optional bytes serializedXmlBytes = 1; 477 | } 478 | 479 | message Message { 480 | optional string conversation = 1; 481 | optional SenderKeyDistributionMessage senderKeyDistributionMessage = 2; 482 | optional ImageMessage imageMessage = 3; 483 | optional ContactMessage contactMessage = 4; 484 | optional LocationMessage locationMessage = 5; 485 | optional ExtendedTextMessage extendedTextMessage = 6; 486 | optional DocumentMessage documentMessage = 7; 487 | optional AudioMessage audioMessage = 8; 488 | optional VideoMessage videoMessage = 9; 489 | optional Call call = 10; 490 | optional Chat chat = 11; 491 | optional ProtocolMessage protocolMessage = 12; 492 | optional ContactsArrayMessage contactsArrayMessage = 13; 493 | optional HighlyStructuredMessage highlyStructuredMessage = 14; 494 | optional SenderKeyDistributionMessage fastRatchetKeySenderKeyDistributionMessage = 15; 495 | optional SendPaymentMessage sendPaymentMessage = 16; 496 | optional LiveLocationMessage liveLocationMessage = 18; 497 | optional RequestPaymentMessage requestPaymentMessage = 22; 498 | optional DeclinePaymentRequestMessage declinePaymentRequestMessage = 23; 499 | optional CancelPaymentRequestMessage cancelPaymentRequestMessage = 24; 500 | optional TemplateMessage templateMessage = 25; 501 | optional StickerMessage stickerMessage = 26; 502 | optional GroupInviteMessage groupInviteMessage = 28; 503 | optional TemplateButtonReplyMessage templateButtonReplyMessage = 29; 504 | optional ProductMessage productMessage = 30; 505 | optional DeviceSentMessage deviceSentMessage = 31; 506 | optional DeviceSyncMessage deviceSyncMessage = 32; 507 | } 508 | 509 | message MessageKey { 510 | optional string remoteJid = 1; 511 | optional bool fromMe = 2; 512 | optional string id = 3; 513 | optional string participant = 4; 514 | } 515 | 516 | message WebFeatures { 517 | enum WEB_FEATURES_FLAG { 518 | NOT_STARTED = 0; 519 | FORCE_UPGRADE = 1; 520 | DEVELOPMENT = 2; 521 | PRODUCTION = 3; 522 | } 523 | optional WEB_FEATURES_FLAG labelsDisplay = 1; 524 | optional WEB_FEATURES_FLAG voipIndividualOutgoing = 2; 525 | optional WEB_FEATURES_FLAG groupsV3 = 3; 526 | optional WEB_FEATURES_FLAG groupsV3Create = 4; 527 | optional WEB_FEATURES_FLAG changeNumberV2 = 5; 528 | optional WEB_FEATURES_FLAG queryStatusV3Thumbnail = 6; 529 | optional WEB_FEATURES_FLAG liveLocations = 7; 530 | optional WEB_FEATURES_FLAG queryVname = 8; 531 | optional WEB_FEATURES_FLAG voipIndividualIncoming = 9; 532 | optional WEB_FEATURES_FLAG quickRepliesQuery = 10; 533 | optional WEB_FEATURES_FLAG payments = 11; 534 | optional WEB_FEATURES_FLAG stickerPackQuery = 12; 535 | optional WEB_FEATURES_FLAG liveLocationsFinal = 13; 536 | optional WEB_FEATURES_FLAG labelsEdit = 14; 537 | optional WEB_FEATURES_FLAG mediaUpload = 15; 538 | optional WEB_FEATURES_FLAG mediaUploadRichQuickReplies = 18; 539 | optional WEB_FEATURES_FLAG vnameV2 = 19; 540 | optional WEB_FEATURES_FLAG videoPlaybackUrl = 20; 541 | optional WEB_FEATURES_FLAG statusRanking = 21; 542 | optional WEB_FEATURES_FLAG voipIndividualVideo = 22; 543 | optional WEB_FEATURES_FLAG thirdPartyStickers = 23; 544 | optional WEB_FEATURES_FLAG frequentlyForwardedSetting = 24; 545 | optional WEB_FEATURES_FLAG groupsV4JoinPermission = 25; 546 | optional WEB_FEATURES_FLAG recentStickers = 26; 547 | optional WEB_FEATURES_FLAG catalog = 27; 548 | optional WEB_FEATURES_FLAG starredStickers = 28; 549 | optional WEB_FEATURES_FLAG voipGroupCall = 29; 550 | optional WEB_FEATURES_FLAG templateMessage = 30; 551 | optional WEB_FEATURES_FLAG templateMessageInteractivity = 31; 552 | optional WEB_FEATURES_FLAG ephemeralMessages = 32; 553 | optional WEB_FEATURES_FLAG e2ENotificationSync = 33; 554 | optional WEB_FEATURES_FLAG recentStickersV2 = 34; 555 | } 556 | 557 | message TabletNotificationsInfo { 558 | optional uint64 timestamp = 2; 559 | optional uint32 unreadChats = 3; 560 | optional uint32 notifyMessageCount = 4; 561 | repeated NotificationMessageInfo notifyMessage = 5; 562 | } 563 | 564 | message NotificationMessageInfo { 565 | optional MessageKey key = 1; 566 | optional Message message = 2; 567 | optional uint64 messageTimestamp = 3; 568 | optional string participant = 4; 569 | } 570 | 571 | message WebNotificationsInfo { 572 | optional uint64 timestamp = 2; 573 | optional uint32 unreadChats = 3; 574 | optional uint32 notifyMessageCount = 4; 575 | repeated WebMessageInfo notifyMessages = 5; 576 | } 577 | 578 | message PaymentInfo { 579 | enum PAYMENT_INFO_CURRENCY { 580 | UNKNOWN_CURRENCY = 0; 581 | INR = 1; 582 | } 583 | optional PAYMENT_INFO_CURRENCY currencyDeprecated = 1; 584 | optional uint64 amount1000 = 2; 585 | optional string receiverJid = 3; 586 | enum PAYMENT_INFO_STATUS { 587 | UNKNOWN_STATUS = 0; 588 | PROCESSING = 1; 589 | SENT = 2; 590 | NEED_TO_ACCEPT = 3; 591 | COMPLETE = 4; 592 | COULD_NOT_COMPLETE = 5; 593 | REFUNDED = 6; 594 | EXPIRED = 7; 595 | REJECTED = 8; 596 | CANCELLED = 9; 597 | WAITING_FOR_PAYER = 10; 598 | WAITING = 11; 599 | } 600 | optional PAYMENT_INFO_STATUS status = 4; 601 | optional uint64 transactionTimestamp = 5; 602 | optional MessageKey requestMessageKey = 6; 603 | optional uint64 expiryTimestamp = 7; 604 | optional bool futureproofed = 8; 605 | optional string currency = 9; 606 | enum PAYMENT_INFO_TXNSTATUS { 607 | UNKNOWN = 0; 608 | PENDING_SETUP = 1; 609 | PENDING_RECEIVER_SETUP = 2; 610 | INIT = 3; 611 | SUCCESS = 4; 612 | COMPLETED = 5; 613 | FAILED = 6; 614 | FAILED_RISK = 7; 615 | FAILED_PROCESSING = 8; 616 | FAILED_RECEIVER_PROCESSING = 9; 617 | FAILED_DA = 10; 618 | FAILED_DA_FINAL = 11; 619 | REFUNDED_TXN = 12; 620 | REFUND_FAILED = 13; 621 | REFUND_FAILED_PROCESSING = 14; 622 | REFUND_FAILED_DA = 15; 623 | EXPIRED_TXN = 16; 624 | AUTH_CANCELED = 17; 625 | AUTH_CANCEL_FAILED_PROCESSING = 18; 626 | AUTH_CANCEL_FAILED = 19; 627 | COLLECT_INIT = 20; 628 | COLLECT_SUCCESS = 21; 629 | COLLECT_FAILED = 22; 630 | COLLECT_FAILED_RISK = 23; 631 | COLLECT_REJECTED = 24; 632 | COLLECT_EXPIRED = 25; 633 | COLLECT_CANCELED = 26; 634 | COLLECT_CANCELLING = 27; 635 | } 636 | optional PAYMENT_INFO_TXNSTATUS txnStatus = 10; 637 | } 638 | 639 | message WebMessageInfo { 640 | required MessageKey key = 1; 641 | optional Message message = 2; 642 | optional uint64 messageTimestamp = 3; 643 | enum WEB_MESSAGE_INFO_STATUS { 644 | ERROR = 0; 645 | PENDING = 1; 646 | SERVER_ACK = 2; 647 | DELIVERY_ACK = 3; 648 | READ = 4; 649 | PLAYED = 5; 650 | } 651 | optional WEB_MESSAGE_INFO_STATUS status = 4; 652 | optional string participant = 5; 653 | optional bool ignore = 16; 654 | optional bool starred = 17; 655 | optional bool broadcast = 18; 656 | optional string pushName = 19; 657 | optional bytes mediaCiphertextSha256 = 20; 658 | optional bool multicast = 21; 659 | optional bool urlText = 22; 660 | optional bool urlNumber = 23; 661 | enum WEB_MESSAGE_INFO_STUBTYPE { 662 | UNKNOWN = 0; 663 | REVOKE = 1; 664 | CIPHERTEXT = 2; 665 | FUTUREPROOF = 3; 666 | NON_VERIFIED_TRANSITION = 4; 667 | UNVERIFIED_TRANSITION = 5; 668 | VERIFIED_TRANSITION = 6; 669 | VERIFIED_LOW_UNKNOWN = 7; 670 | VERIFIED_HIGH = 8; 671 | VERIFIED_INITIAL_UNKNOWN = 9; 672 | VERIFIED_INITIAL_LOW = 10; 673 | VERIFIED_INITIAL_HIGH = 11; 674 | VERIFIED_TRANSITION_ANY_TO_NONE = 12; 675 | VERIFIED_TRANSITION_ANY_TO_HIGH = 13; 676 | VERIFIED_TRANSITION_HIGH_TO_LOW = 14; 677 | VERIFIED_TRANSITION_HIGH_TO_UNKNOWN = 15; 678 | VERIFIED_TRANSITION_UNKNOWN_TO_LOW = 16; 679 | VERIFIED_TRANSITION_LOW_TO_UNKNOWN = 17; 680 | VERIFIED_TRANSITION_NONE_TO_LOW = 18; 681 | VERIFIED_TRANSITION_NONE_TO_UNKNOWN = 19; 682 | GROUP_CREATE = 20; 683 | GROUP_CHANGE_SUBJECT = 21; 684 | GROUP_CHANGE_ICON = 22; 685 | GROUP_CHANGE_INVITE_LINK = 23; 686 | GROUP_CHANGE_DESCRIPTION = 24; 687 | GROUP_CHANGE_RESTRICT = 25; 688 | GROUP_CHANGE_ANNOUNCE = 26; 689 | GROUP_PARTICIPANT_ADD = 27; 690 | GROUP_PARTICIPANT_REMOVE = 28; 691 | GROUP_PARTICIPANT_PROMOTE = 29; 692 | GROUP_PARTICIPANT_DEMOTE = 30; 693 | GROUP_PARTICIPANT_INVITE = 31; 694 | GROUP_PARTICIPANT_LEAVE = 32; 695 | GROUP_PARTICIPANT_CHANGE_NUMBER = 33; 696 | BROADCAST_CREATE = 34; 697 | BROADCAST_ADD = 35; 698 | BROADCAST_REMOVE = 36; 699 | GENERIC_NOTIFICATION = 37; 700 | E2E_IDENTITY_CHANGED = 38; 701 | E2E_ENCRYPTED = 39; 702 | CALL_MISSED_VOICE = 40; 703 | CALL_MISSED_VIDEO = 41; 704 | INDIVIDUAL_CHANGE_NUMBER = 42; 705 | GROUP_DELETE = 43; 706 | GROUP_ANNOUNCE_MODE_MESSAGE_BOUNCE = 44; 707 | CALL_MISSED_GROUP_VOICE = 45; 708 | CALL_MISSED_GROUP_VIDEO = 46; 709 | PAYMENT_CIPHERTEXT = 47; 710 | PAYMENT_FUTUREPROOF = 48; 711 | PAYMENT_TRANSACTION_STATUS_UPDATE_FAILED = 49; 712 | PAYMENT_TRANSACTION_STATUS_UPDATE_REFUNDED = 50; 713 | PAYMENT_TRANSACTION_STATUS_UPDATE_REFUND_FAILED = 51; 714 | PAYMENT_TRANSACTION_STATUS_RECEIVER_PENDING_SETUP = 52; 715 | PAYMENT_TRANSACTION_STATUS_RECEIVER_SUCCESS_AFTER_HICCUP = 53; 716 | PAYMENT_ACTION_ACCOUNT_SETUP_REMINDER = 54; 717 | PAYMENT_ACTION_SEND_PAYMENT_REMINDER = 55; 718 | PAYMENT_ACTION_SEND_PAYMENT_INVITATION = 56; 719 | PAYMENT_ACTION_REQUEST_DECLINED = 57; 720 | PAYMENT_ACTION_REQUEST_EXPIRED = 58; 721 | PAYMENT_ACTION_REQUEST_CANCELLED = 59; 722 | BIZ_VERIFIED_TRANSITION_TOP_TO_BOTTOM = 60; 723 | BIZ_VERIFIED_TRANSITION_BOTTOM_TO_TOP = 61; 724 | BIZ_INTRO_TOP = 62; 725 | BIZ_INTRO_BOTTOM = 63; 726 | BIZ_NAME_CHANGE = 64; 727 | BIZ_MOVE_TO_CONSUMER_APP = 65; 728 | BIZ_TWO_TIER_MIGRATION_TOP = 66; 729 | BIZ_TWO_TIER_MIGRATION_BOTTOM = 67; 730 | OVERSIZED = 68; 731 | GROUP_CHANGE_NO_FREQUENTLY_FORWARDED = 69; 732 | GROUP_V4_ADD_INVITE_SENT = 70; 733 | GROUP_PARTICIPANT_ADD_REQUEST_JOIN = 71; 734 | CHANGE_EPHEMERAL_SETTING = 72; 735 | } 736 | optional WEB_MESSAGE_INFO_STUBTYPE messageStubType = 24; 737 | optional bool clearMedia = 25; 738 | repeated string messageStubParameters = 26; 739 | optional uint32 duration = 27; 740 | repeated string labels = 28; 741 | optional PaymentInfo paymentInfo = 29; 742 | optional LiveLocationMessage finalLiveLocation = 30; 743 | optional PaymentInfo quotedPaymentInfo = 31; 744 | optional uint64 ephemeralStartTimestamp = 32; 745 | optional uint32 ephemeralDuration = 33; 746 | } 747 | 748 | -------------------------------------------------------------------------------- /kyros/session.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pickle 4 | 5 | 6 | class Session: 7 | """Container class for session data. 8 | Has methods to import and export a serialized 9 | `Session` object.""" 10 | client_id = None 11 | server_id = None 12 | 13 | client_token = None 14 | server_token = None 15 | client_secret = None 16 | 17 | secret = None 18 | shared_secret = None 19 | shared_secret_expanded = None 20 | 21 | private_key = None 22 | public_key = None 23 | 24 | keys_encrypted = None 25 | keys_decrypted = None 26 | 27 | enc_key = None 28 | mac_key = None 29 | 30 | wid = None 31 | 32 | @staticmethod 33 | def from_file(filename: str) -> Session: 34 | with open(filename, "rb") as file: 35 | return pickle.load(file) 36 | 37 | def save_to_file(self, filename: str) -> Session: 38 | with open(filename, "wb") as file: 39 | return pickle.dump(self, file) 40 | -------------------------------------------------------------------------------- /kyros/utilities.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import os 3 | import time 4 | 5 | 6 | def generate_message_tag() -> str: 7 | """Generate a message tag. Spawns a string of 8 | current timestamp.""" 9 | return str(time.time()) 10 | 11 | 12 | def generate_client_id() -> str: 13 | """Generates client id, base64 encoded random 16 bytes 14 | long string.""" 15 | return base64.b64encode(os.urandom(16)).decode() 16 | -------------------------------------------------------------------------------- /kyros/websocket.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import json 5 | import logging 6 | from typing import AnyStr, Optional, Sequence, Union 7 | 8 | import websockets 9 | 10 | from . import constants, crypto, exceptions, utilities 11 | from .message import MessageHandler 12 | from .session import Session 13 | 14 | logger = logging.getLogger(__name__) # pylint: disable=invalid-name 15 | 16 | 17 | class WebsocketMessage: 18 | """ 19 | `WebsocketMessage` acts as a container for websocket messages. 20 | `data` attribute always contains a decoded or decrypted 21 | data (for binary messages). 22 | `tag` is also automatically generated if None is given as the tag. 23 | """ 24 | def __init__(self, 25 | tag: Optional[str] = None, 26 | data: Optional[AnyStr] = None, 27 | is_binary: Optional[bool] = False) -> None: 28 | """Initiate the class.""" 29 | 30 | self.tag = tag 31 | if not self.tag: 32 | self.tag = utilities.generate_message_tag() 33 | 34 | self.data = data 35 | self.is_binary = is_binary 36 | 37 | def serialize(self, keys: Sequence[bytes]) -> AnyStr: 38 | """Unserialize the message. A regular JSON message 39 | will be encoded. A binary message will be encrypted and also 40 | prefixed with an HMAC checksum. It returns a ready-to-send 41 | websocket message.""" 42 | if not self.is_binary: 43 | return self.encode() 44 | return self.encrypt(keys) 45 | 46 | def encrypt(self, keys: Sequence[bytes]) -> bytes: 47 | """Encrypts a binary message.""" 48 | enc_key, mac_key = keys 49 | checksum = crypto.hmac_sha256(mac_key, self.data) 50 | serialized = f"{self.tag},".encode() 51 | serialized += checksum 52 | serialized += crypto.aes_encrypt(enc_key, self.data) 53 | return serialized 54 | 55 | def encode(self) -> str: 56 | """JSON encode the message if the message is not a 57 | binary message.""" 58 | encoded_message = f"{self.tag},{json.dumps(self.data)}" 59 | return encoded_message 60 | 61 | @classmethod 62 | def unserialize(cls, message: AnyStr, 63 | keys: Sequence[bytes]) -> Union[WebsocketMessage, None]: 64 | """Process a message and decide whether it is a binary 65 | message or a regular JSON message. Then it will serialize 66 | the message according to its type.""" 67 | if not isinstance(message, bytes): 68 | return cls.from_encoded(message) 69 | return cls.from_encrypted(message, keys) 70 | 71 | @classmethod 72 | def from_encoded(cls, message: str) -> WebsocketMessage: 73 | """Returns an initiated class from an encoded message.""" 74 | tag, encoded_data = message.split(",", 1) 75 | return cls(tag, json.loads(encoded_data)) 76 | 77 | @classmethod 78 | def from_encrypted(cls, message: bytes, 79 | keys: Sequence[bytes]) -> Union[WebsocketMessage, None]: 80 | """Returns an initiated class from a binary message. 81 | This function also decrypts the contained message. """ 82 | enc_key, mac_key = keys 83 | 84 | instance = cls() 85 | instance.is_binary = True 86 | 87 | tag, data = message.split(b",", 1) 88 | instance.tag = tag 89 | 90 | checksum = data[:32] 91 | encrypted_data = data[32:] 92 | 93 | if not (enc_key and mac_key): 94 | logging.info("dropping binary message with tag %s (no keys)", tag) 95 | return None 96 | 97 | if crypto.hmac_sha256(mac_key, encrypted_data) != checksum: 98 | raise exceptions.HMACValidationError 99 | 100 | instance.data = crypto.aes_decrypt(enc_key, encrypted_data) 101 | 102 | return instance 103 | 104 | 105 | class WebsocketMessages: 106 | """This class acts as a container for `WebsocketMessage` instances. 107 | Allows an easy access to messages in queue. The messages are feed 108 | by `WebsocketClient` class.""" 109 | messages = {} 110 | 111 | def add(self, tag: str, data: AnyStr): 112 | """Appends a message to the messages mapping.""" 113 | self.messages[tag] = data 114 | 115 | def get(self, tag: str, timeout: Optional[int] = 10): 116 | """Gets a message with specified tag. If not currently 117 | present, it will wait until `timeout` reached. Raises 118 | asyncio.TimeoutError when timed out.""" 119 | async def get_message(): 120 | while tag not in self.messages: 121 | await asyncio.sleep(0) 122 | return self.messages.pop(tag) 123 | 124 | logger.debug("Getting message with tag %s", tag) 125 | 126 | return asyncio.wait_for(get_message(), timeout) 127 | 128 | 129 | class WebsocketClient: 130 | """Acts as interface for websocket communication with WhatsApp's 131 | websocket server.""" 132 | websocket: websockets.WebSocketClientProtocol = None 133 | kyros_session: Session = None 134 | messages: WebsocketMessages = WebsocketMessages() 135 | 136 | def __init__(self, message_handler: MessageHandler) -> None: 137 | """Initiate the class. Registers message handler.""" 138 | self.handle_message = message_handler 139 | 140 | async def connect(self) -> None: 141 | """Connects to the websocket server. Starts message receiver or 142 | listener.""" 143 | logger.debug("Connecting to ws server") 144 | self.websocket = await websockets.connect( 145 | constants.WEBSOCKET_URI, origin=constants.WEBSOCKET_ORIGIN) 146 | logger.debug("Websocket connected") 147 | self._start_receiver() 148 | 149 | def load_session(self, session: Session) -> None: 150 | """Loads a session. This will make sure that all references are 151 | updated. If there is a key change, the new key will be used to 152 | decrypt new messages.""" 153 | self.kyros_session = session 154 | 155 | def get_keys(self) -> Sequence[bytes]: 156 | """Extract necessary keys from session to decrypt and encrypt 157 | binary messages. """ 158 | return self.kyros_session.enc_key, self.kyros_session.mac_key 159 | 160 | async def shutdown(self) -> None: 161 | """Does a cleanup. Closes websocket connection.""" 162 | if self.websocket.open: 163 | logger.debug("Closing websocket server") 164 | await self.websocket.close() 165 | 166 | def _start_receiver(self) -> None: 167 | """Starts a receiver coroutine. Listens for a new websocket message 168 | from queue.""" 169 | async def receiver(): 170 | while True: 171 | if not self.websocket or not self.websocket.open: 172 | logger.debug("receiver returned: no ws/connection closed") 173 | return 174 | 175 | if not self.websocket.messages or self.websocket.closed: 176 | await asyncio.sleep(0) 177 | continue 178 | 179 | raw_message = self.websocket.messages.pop() 180 | try: 181 | message = WebsocketMessage.unserialize( 182 | raw_message, self.get_keys()) 183 | except Exception as exc: # pylint: disable=broad-except 184 | logger.warning("Ignored error decoding message: %s", exc) 185 | await asyncio.sleep(0) 186 | continue 187 | 188 | if message: 189 | logger.debug("Received WS message with tag %s", 190 | message.tag) 191 | self.messages.add(message.tag, message.data) 192 | 193 | asyncio.ensure_future(receiver()) 194 | logger.debug("Executed receiver coroutine") 195 | 196 | async def send_message(self, message: WebsocketMessage) -> None: 197 | """Sends a websocket message.""" 198 | logger.debug("Sending a WS message with tag %s", message.tag) 199 | await self.websocket.send(message.serialize(self.get_keys())) 200 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | websockets 2 | donna25519 3 | pycryptodome 4 | protobuf 5 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open("README.md", "r") as fh: 4 | long_description = fh.read() # noqa 5 | 6 | 7 | def get_requirements(): 8 | with open("requirements.txt") as req_file: 9 | requirements = [ 10 | l.strip() for l in req_file.readlines() if len(l.strip()) 11 | ] 12 | return requirements 13 | 14 | 15 | setuptools.setup( 16 | name="kyros", 17 | version="1.0.2", 18 | author="loncat", 19 | author_email="me@lcat.dev", 20 | description="A Python wrapper for WhatsApp Web API", 21 | long_description=long_description, 22 | long_description_content_type="text/plain", 23 | url="https://github.com/ttycelery/kyros", 24 | packages=setuptools.find_packages(), 25 | install_requires=get_requirements(), 26 | classifiers=[ 27 | "Programming Language :: Python :: 3", 28 | "License :: MIT License", 29 | "Operating System :: OS Independent", 30 | ], 31 | ) 32 | --------------------------------------------------------------------------------