commit db857d981caf28b7cf2fc590b5ecb7f829ce98a2 Author: dsc Date: Sat Mar 27 20:23:38 2021 +0100 Intial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f0d6f52 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.idea/* +*.pyc +__pycache__ diff --git a/README.md b/README.md new file mode 100644 index 0000000..effa6e6 --- /dev/null +++ b/README.md @@ -0,0 +1,125 @@ +# WOWlet WS Client + +Sample Python asyncio websocket client for communicating with +wowlet running in "background mode" via websockets. + +This library was made in a few hours, so it's not complete, and should +serve as an example and PoC. + +## Example + +First, start [wowlet](https://git.wownero.com/wowlet/wowlet/): + + ./wowlet --background 127.0.0.1:42069 + +Then run the following script, it will: + +1. Open a wallet +2. Request a receiving address listing +3. Run forever + +```python +import asyncio + +from wowlet_ws_client.client import WowletWSClient + + +wowlet = WowletWSClient(host="127.0.0.1", port=42069, debug=True) + + +@wowlet.event("walletList") +async def on_wallet_list(data): + print("wallet listing received") + print(data) + + +@wowlet.event("addressList") +async def on_address_list(data): + print("address listing received") + print(data) + + +@wowlet.event("transactionHistory") +async def on_transaction_history(data): + print(data) + + +@wowlet.event("addressBook") +async def on_address_book(data): + print(data) + + +@wowlet.event("synchronized") +async def on_synchronized(data): + # called when the wallet is synchronized with the network + print(data) + + +@wowlet.event("transactionCommitted") +async def on_transaction_commited(data): + # when a transaction was sent + print(data) + + +async def main(): + await wowlet.connect() + await asyncio.sleep(2) + + await wowlet.open_wallet("/home/user/Wownero/wallets/wstest.keys", "test") + + await asyncio.sleep(3) + await wowlet.address_list(0, 0) + + # hack to keep the script running + while True: + await asyncio.sleep(1) + +loop = asyncio.get_event_loop() +loop.run_until_complete(main()) +``` + +## Creating a wallet + +Default directory = wowlet's default wallet directory + +```python +@wowlet.event("walletCreated") +async def on_wallet_created(data): + print(data) + +@wowlet.event("walletCreatedError") +async def on_wallet_created_error(data): + print("error") + print(data) + +await wowlet.create_wallet("cool_wallet", password="sekrit") +``` + +## Creating a transaction + +```python +address = "WW2cyFaAu4NSpZdpEP3egWcK99QqU8afJEpfZW1HNxofB9sH86Nir5pes7ofwAE3ZWVNdgxFrWeSiSiSLumSeZ7538zCXb6gp" +await wowlet.send_transaction( + address=address, + amount=2, + description="test transaction from Python") +``` + +## QR + +This library can also generate QR codes because why not. + +```python +import aiofiles +from wowlet_ws_client.qr import qr_png + +addr = "some_address_here" +qr_image = qr_png(addr) + +async with aiofiles.open('output.png', mode="wb") as f: + await f.write(qr_image.getbuffer()) +``` + +## License + +BSD \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..82f5491 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +aiohttp +aiofiles +pyqrcode +pypng +pillow-simd \ No newline at end of file diff --git a/settings.py b/settings.py new file mode 100644 index 0000000..d75ac40 --- /dev/null +++ b/settings.py @@ -0,0 +1,10 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2021, The Wownero Project. + +COIN_ADDRESS_LENGTH = [97, 108] +COINCODE = 'WOW' + +# QR image related +PIL_IMAGE_DIMENSIONS = (512, 512) +PIL_QUALITY = 50 +PIL_OPTIMIZE = True diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..0a8df87 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[wheel] +universal = 1 \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..baf1caa --- /dev/null +++ b/setup.py @@ -0,0 +1,44 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2021, The Wownero Project. + +""" +wowlet-ws-client +------------- +Sample websocket client for communicating with +wowlet running in "background mode" via websockets. +""" +from setuptools import setup + +with open('README.md') as f: + long_description = f.read() + + +INSTALL_REQUIRES = open("requirements.txt").read().splitlines() + +setup( + name='wowlet_ws_client', + version='0.0.1', + url='https://git.wownero.com/wownero/wowlet-ws-client', + author='dsc', + author_email='dsc@xmr.pm', + description='Sample websocket client for communicating with wowlet running in "background mode" via websockets.', + long_description=long_description, + long_description_content_type='text/markdown', + packages=['wowlet_ws_client'], + zip_safe=False, + include_package_data=True, + platforms='any', + install_requires=INSTALL_REQUIRES, + tests_require=INSTALL_REQUIRES + ["asynctest", "hypothesis", "pytest", "pytest-asyncio"], + extras_require={"dotenv": ["python-dotenv"]}, + classifiers=[ + 'Environment :: Web Environment', + 'Intended Audience :: Developers', + 'Operating System :: OS Independent', + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', + 'Topic :: Software Development :: Libraries :: Python Modules' + ] +) diff --git a/wowlet_ws_client/__init__.py b/wowlet_ws_client/__init__.py new file mode 100644 index 0000000..c31763e --- /dev/null +++ b/wowlet_ws_client/__init__.py @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2021, The Wownero Project. + +from typing import Dict +from enum import Enum + + +class WowletState(Enum): + IDLE = ["walletList", "walletClosed", "walletOpenedError", "walletCreatedError"] + OPENED = ["walletOpened"] + BOOTSTRAP = ["refreshSync"] + SYNCHRONIZED = ["synchronized", "transactionError"] + CREATING_TX = ["transactionStarted"] + + +WALLET_OPERATIONAL = [WowletState.OPENED, WowletState.BOOTSTRAP, WowletState.SYNCHRONIZED] + + +def decorator_parametrized(dec): + def layer(*args, **kwargs): + def repl(view_func): + return dec(args[0], view_func, *args[1:], **kwargs) + return repl + return layer diff --git a/wowlet_ws_client/client.py b/wowlet_ws_client/client.py new file mode 100644 index 0000000..e091762 --- /dev/null +++ b/wowlet_ws_client/client.py @@ -0,0 +1,188 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2021, The Wownero Project. + +import logging +from typing import Awaitable, Callable, Dict, Optional, Union, List +import asyncio +import json + +import aiohttp + +from wowlet_ws_client import WowletState, decorator_parametrized, WALLET_OPERATIONAL + + +class WowletWSClient: + def __init__(self, host: str = "127.0.0.1", port: int = 42069, debug: bool = False): + self.host = host + self.port = port + self.ses = aiohttp.ClientSession() + self.debug = debug + + self._ws = None + + self._state = WowletState.IDLE + + self._cmd_callbacks: Dict[str, Callable] = {} + + self._loop_task: Callable = None + + self.wallets: List[str] = [] + self.addresses: List[str] = [] + self.address_book: List[Dict[str: str]] = [] + self.unlocked_balance: float + self.balance: float + + async def open_wallet(self, path: str, password: str = ""): + """ + Opens a wallet + :param path: absolute path to wallet file + :param password: wallet password + :return: + """ + if self._state != WowletState.IDLE: + raise Exception("You may only open wallets in IDLE " + "state. Close the current wallet first.") + await self.sendMessage("openWallet", { + "path": path, + "password": password + }) + + async def close_wallet(self): + """Close currently opened wallet""" + if self._state not in WALLET_OPERATIONAL: + raise Exception("There is no opened wallet to close.") + await self.sendMessage("closeWallet", {}) + + async def address_list(self, account_index: int = 0, address_index: int = 0, limit: int = 50, offset: int = 0): + if self._state not in WALLET_OPERATIONAL: + raise Exception("There is no opened wallet to close.") + await self.sendMessage("addressList", { + "accountIndex": account_index, + "addressIndex": address_index, + "limit": limit, + "offset": offset + }) + + async def send_transaction(self, address: str, amount: float, description: str = "", _all: bool = False): + """ + Send someone some monies. + :param address: address + :param amount: amount + :param description: optional description + :param _all: send all available funds (use with caution) + :return: + """ + if amount <= 0: + raise Exception("y u send 0") + if self._state not in WALLET_OPERATIONAL: + raise Exception("There is no opened wallet to close.") + await self.sendMessage("sendTransaction", { + "address": address, + "description": description, + "amount": amount, + "all": _all + }) + + async def create_wallet(self, name: str, password: str = "", path: str = None): + """ + Automatically create a new wallet. Comes up with it's own + mnemonic seed and stuffz. + :param name: name of the new wallet + :param password: (optional) password for the wallet + :param path: (optional) absolute path *to a directory*, default is + Wownero wallet directory. + :return: + """ + if self._state != WowletState.IDLE: + raise Exception("Please close the currently opened wallet first.") + + await self.sendMessage("createWallet", { + "name": name, + "path": path, + "password": password + }) + + async def get_transaction_history(self): + """Get transaction history in its entirety.""" + # if self._state != WALLET_OPERATIONAL: + # raise Exception("The wallet is currently busy doing something else.") + await self.sendMessage("transactionHistory", {}) + + async def get_address_book(self): + """Get all address book entries.""" + # if self._state != WALLET_OPERATIONAL: + # raise Exception("The wallet is currently busy doing something else.") + await self.sendMessage("addressBook", {}) + + async def address_book_insert(self, name: str, address: str): + if not name or not address: + raise Exception("name or address empty") + await self.sendMessage("addressBookItemAdd", { + "name": name, + "address": address + }) + + async def address_book_remove(self, address: str): + """Remove by address""" + if not address: + raise Exception("address empty") + await self.sendMessage("addressBookItemRemove", { + "address": address + }) + + async def create_wallet_advanced(self): + pass + + async def loop(self): + while True: + buffer = await self._ws.receive() + msg = json.loads(buffer.data) + + if "cmd" not in msg: + logging.error("invalid message received") + continue + + cmd = msg['cmd'] + data = msg.get("data") + if self.debug: + print(msg['cmd']) + + for k, v in WowletState.__members__.items(): + state = WowletState[k] + if cmd in v.value and self._state != state: + if self.debug: + print(f"Changing state to {k}") + self._state = state + + if cmd == "walletList": + self.wallets = data + elif cmd == "addressBook": + self.address_book = data + elif cmd == "addressList": + if data.get("accountIndex", 0) == 0 and \ + data.get("addressIndex") == 0 and \ + data.get("offset") == 0: + self.addresses = data + elif cmd == "balanceUpdated": + self.balance = data.get('balance', 0) + self.balance_unlocked = data.get('spendable', 0) + + if cmd in self._cmd_callbacks: + await self._cmd_callbacks[cmd](data) + + @decorator_parametrized + def event(self, view_func, cmd_name): + self._cmd_callbacks[cmd_name] = view_func + + async def connect(self): + self._ws = await self.ses.ws_connect(f"ws://{self.host}:{self.port}") + self._loop_task = asyncio.create_task(self.loop()) + + async def send(self, data: bytes) -> None: + return await self._ws.send_bytes(data) + + async def sendMessage(self, command: str, data: dict) -> None: + return await self.send(json.dumps({ + "cmd": command, + "data": data + }).encode()) diff --git a/wowlet_ws_client/qr.py b/wowlet_ws_client/qr.py new file mode 100644 index 0000000..8c8d59c --- /dev/null +++ b/wowlet_ws_client/qr.py @@ -0,0 +1,66 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2021, The Wownero Project. + +import os +from io import BytesIO + +import pyqrcode +from PIL import Image, ImageDraw + +import settings + + +def qr_png( + address: str, + size=settings.PIL_IMAGE_DIMENSIONS, + color_from=(210, 83, 200), + color_to=(255, 169, 62) +) -> BytesIO: + """Pretty QR c0dez j00""" + created = pyqrcode.create(address, error='L') + buffer = BytesIO() + result = BytesIO() + created.png(buffer, scale=14, quiet_zone=2) + + im = Image.open(buffer) + im = im.convert("RGBA") + im.thumbnail(size) + + im_data = im.getdata() + + # make black color transparent + im_transparent = [] + for color_point in im_data: + if sum(color_point[:3]) == 255 * 3: + im_transparent.append(color_point) + else: + # get rid of the subtle grey borders + alpha = 0 if color_from and color_to else 1 + im_transparent.append((0, 0, 0, alpha)) + continue + + if not color_from and not color_to: + im.save(result, format="PNG", quality=settings.PIL_QUALITY, optimize=settings.PIL_OPTIMIZE) + result.seek(0) + return result + + # turn QR into a gradient + im.putdata(im_transparent) + + gradient = Image.new('RGBA', im.size, color=0) + draw = ImageDraw.Draw(gradient) + + for i, color in enumerate(gradient_interpolate(color_from, color_to, im.width * 2)): + draw.line([(i, 0), (0, i)], tuple(color), width=1) + + im_gradient = Image.alpha_composite(gradient, im) + im_gradient.save(result, format="PNG", quality=settings.PIL_QUALITY, optimize=settings.PIL_OPTIMIZE) + + result.seek(0) + return result + + +def gradient_interpolate(color_from, color_to, interval): + det_co = [(t - f) / interval for f, t in zip(color_from, color_to)] + for i in range(interval): + yield [round(f + det * i) for f, det in zip(color_from, det_co)]