├── .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 |
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()
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
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
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"]))
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()
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
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.
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)
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
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
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
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
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)