diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f9b1378fed..25c0207ac6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -147,6 +147,20 @@ jobs: working-directory: python run: tox -e lint,mypy,doc,py3 + - name: build deltachat-rpc-server + if: ${{ matrix.python }} + uses: actions-rs/cargo@v1 + with: + command: build + args: -p deltachat-rpc-server + + - name: run deltachat-rpc-client tests + if: ${{ matrix.python }} + env: + DCC_NEW_TMP_EMAIL: ${{ secrets.DCC_NEW_TMP_EMAIL }} + working-directory: deltachat-rpc-client + run: tox -e py3 + - name: install pypy if: ${{ matrix.python }} uses: actions/setup-python@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dba5780c3..ba1d01aa07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ### API-Changes - Add Python API to send reactions #3762 +- jsonrpc: Add async Python client #3734 ### Fixes - Make sure malformed messsages will never block receiving further messages anymore #3771 diff --git a/deltachat-rpc-client/README.md b/deltachat-rpc-client/README.md new file mode 100644 index 0000000000..6b38c361ab --- /dev/null +++ b/deltachat-rpc-client/README.md @@ -0,0 +1,33 @@ +# Delta Chat RPC python client + +RPC client connects to standalone Delta Chat RPC server `deltachat-rpc-server` +and provides asynchronous interface to it. + +## Getting started + +To use Delta Chat RPC client, first build a `deltachat-rpc-server` with `cargo build -p deltachat-rpc-server`. +Install it anywhere in your `PATH`. + +## Testing + +1. Build `deltachat-rpc-server` with `cargo build -p deltachat-rpc-server`. +2. Run `tox`. + +Additional arguments to `tox` are passed to pytest, e.g. `tox -- -s` does not capture test output. + +## Using in REPL + +It is recommended to use IPython, because it supports using `await` directly +from the REPL. + +``` +PATH="../target/debug:$PATH" ipython +... +In [1]: from deltachat_rpc_client import * +In [2]: dc = Deltachat(await start_rpc_server()) +In [3]: await dc.get_all_accounts() +Out [3]: [] +In [4]: alice = await dc.add_account() +In [5]: (await alice.get_info())["journal_mode"] +Out [5]: 'wal' +``` diff --git a/deltachat-rpc-client/examples/echobot.py b/deltachat-rpc-client/examples/echobot.py new file mode 100755 index 0000000000..a55ca91a4b --- /dev/null +++ b/deltachat-rpc-client/examples/echobot.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +import asyncio +import logging +import sys + +import deltachat_rpc_client as dc + + +async def main(): + rpc = await dc.start_rpc_server() + deltachat = dc.Deltachat(rpc) + system_info = await deltachat.get_system_info() + logging.info("Running deltachat core %s", system_info["deltachat_core_version"]) + + accounts = await deltachat.get_all_accounts() + account = accounts[0] if accounts else await deltachat.add_account() + + await account.set_config("bot", "1") + if not await account.is_configured(): + logging.info("Account is not configured, configuring") + await account.set_config("addr", sys.argv[1]) + await account.set_config("mail_pw", sys.argv[2]) + await account.configure() + logging.info("Configured") + else: + logging.info("Account is already configured") + await deltachat.start_io() + + async def process_messages(): + fresh_messages = await account.get_fresh_messages() + fresh_message_snapshot_tasks = [ + message.get_snapshot() for message in fresh_messages + ] + fresh_message_snapshots = await asyncio.gather(*fresh_message_snapshot_tasks) + for snapshot in reversed(fresh_message_snapshots): + if not snapshot.is_info: + await snapshot.chat.send_text(snapshot.text) + await snapshot.message.mark_seen() + + # Process old messages. + await process_messages() + + while True: + event = await account.wait_for_event() + if event["type"] == "Info": + logging.info("%s", event["msg"]) + elif event["type"] == "Warning": + logging.warning("%s", event["msg"]) + elif event["type"] == "Error": + logging.error("%s", event["msg"]) + elif event["type"] == "IncomingMsg": + logging.info("Got an incoming message") + await process_messages() + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + asyncio.run(main()) diff --git a/deltachat-rpc-client/pyproject.toml b/deltachat-rpc-client/pyproject.toml new file mode 100644 index 0000000000..cabcc848a6 --- /dev/null +++ b/deltachat-rpc-client/pyproject.toml @@ -0,0 +1,14 @@ +[build-system] +requires = ["setuptools>=45", "setuptools_scm[toml]>=6.2"] +build-backend = "setuptools.build_meta" + +[project] +name = "deltachat-rpc-client" +description = "Python client for Delta Chat core JSON-RPC interface" +dependencies = [ + "aiohttp", + "aiodns" +] +dynamic = [ + "version" +] diff --git a/deltachat-rpc-client/src/deltachat_rpc_client/__init__.py b/deltachat-rpc-client/src/deltachat_rpc_client/__init__.py new file mode 100644 index 0000000000..720e1ebdbd --- /dev/null +++ b/deltachat-rpc-client/src/deltachat_rpc_client/__init__.py @@ -0,0 +1,5 @@ +from .account import Account +from .contact import Contact +from .deltachat import Deltachat +from .message import Message +from .rpc import Rpc, new_online_account, start_rpc_server diff --git a/deltachat-rpc-client/src/deltachat_rpc_client/account.py b/deltachat-rpc-client/src/deltachat_rpc_client/account.py new file mode 100644 index 0000000000..61e2395f12 --- /dev/null +++ b/deltachat-rpc-client/src/deltachat_rpc_client/account.py @@ -0,0 +1,68 @@ +from typing import Optional + +from .chat import Chat +from .contact import Contact +from .message import Message + + +class Account: + def __init__(self, rpc, account_id): + self.rpc = rpc + self.account_id = account_id + + def __repr__(self): + return "".format(self.account_id) + + async def wait_for_event(self): + """Wait until the next event and return it.""" + return await self.rpc.get_next_event(self.account_id) + + async def remove(self) -> None: + """Remove the account.""" + await self.rpc.remove_account(self.account_id) + + async def start_io(self) -> None: + """Start the account I/O.""" + await self.rpc.start_io(self.account_id) + + async def stop_io(self) -> None: + """Stop the account I/O.""" + await self.rpc.stop_io(self.account_id) + + async def get_info(self): + return await self.rpc.get_info(self.account_id) + + async def get_file_size(self): + return await self.rpc.get_account_file_size(self.account_id) + + async def is_configured(self) -> bool: + """Return True for configured accounts.""" + return await self.rpc.is_configured(self.account_id) + + async def set_config(self, key: str, value: Optional[str]): + """Set the configuration value key pair.""" + await self.rpc.set_config(self.account_id, key, value) + + async def get_config(self, key: str) -> Optional[str]: + """Get the configuration value.""" + return await self.rpc.get_config(self.account_id, key) + + async def configure(self): + """Configure an account.""" + await self.rpc.configure(self.account_id) + + async def create_contact(self, address: str, name: Optional[str]) -> Contact: + """Create a contact with the given address and, optionally, a name.""" + return Contact( + self.rpc, + self.account_id, + await self.rpc.create_contact(self.account_id, address, name), + ) + + async def secure_join(self, qr: str) -> Chat: + chat_id = await self.rpc.secure_join(self.account_id, qr) + return Chat(self.rpc, self.account_id, self.chat_id) + + async def get_fresh_messages(self): + fresh_msg_ids = await self.rpc.get_fresh_msgs(self.account_id) + return [Message(self.rpc, self.account_id, msg_id) for msg_id in fresh_msg_ids] diff --git a/deltachat-rpc-client/src/deltachat_rpc_client/chat.py b/deltachat-rpc-client/src/deltachat_rpc_client/chat.py new file mode 100644 index 0000000000..6600bb8bc1 --- /dev/null +++ b/deltachat-rpc-client/src/deltachat_rpc_client/chat.py @@ -0,0 +1,33 @@ +class Chat: + def __init__(self, rpc, account_id, chat_id): + self.rpc = rpc + self.account_id = account_id + self.chat_id = chat_id + + async def block(self): + """Block the chat.""" + await self.rpc.block_chat(self.account_id, self.chat_id) + + async def accept(self): + """Accept the contact request.""" + await self.rpc.accept_chat(self.account_id, self.chat_id) + + async def delete(self): + await self.rpc.delete_chat(self.account_id, self.chat_id) + + async def get_encryption_info(self): + await self.rpc.get_chat_encryption_info(self.account_id, self.chat_id) + + async def send_text(self, text: str): + from .message import Message + + msg_id = await self.rpc.misc_send_text_message( + self.account_id, self.chat_id, text + ) + return Message(self.rpc, self.account_id, msg_id) + + async def leave(self): + await self.rpc.leave_group(self.account_id, self.chat_id) + + async def get_fresh_message_count() -> int: + await get_fresh_msg_cnt(self.account_id, self.chat_id) diff --git a/deltachat-rpc-client/src/deltachat_rpc_client/contact.py b/deltachat-rpc-client/src/deltachat_rpc_client/contact.py new file mode 100644 index 0000000000..8f60ad16f4 --- /dev/null +++ b/deltachat-rpc-client/src/deltachat_rpc_client/contact.py @@ -0,0 +1,44 @@ +class Contact: + """ + Contact API. + + Essentially a wrapper for RPC, account ID and a contact ID. + """ + + def __init__(self, rpc, account_id, contact_id): + self.rpc = rpc + self.account_id = account_id + self.contact_id = contact_id + + async def block(self): + """Block contact.""" + await self.rpc.block_contact(self.account_id, self.contact_id) + + async def unblock(self): + """Unblock contact.""" + await self.rpc.unblock_contact(self.account_id, self.contact_id) + + async def delete(self): + """Delete contact.""" + await self.rpc.delete_contact(self.account_id, self.contact_id) + + async def change_name(self, name: str): + await self.rpc.change_contact_name(self.account_id, self.contact_id, name) + + async def get_encryption_info(self) -> str: + return await self.rpc.get_contact_encryption_info( + self.account_id, self.contact_id + ) + + async def get_dictionary(self): + """Returns a dictionary with a snapshot of all contact properties.""" + return await self.rpc.get_contact(self.account_id, self.contact_id) + + async def create_chat(self): + from .chat import Chat + + return Chat( + self.rpc, + self.account_id, + await self.rpc.create_chat_by_contact_id(self.account_id, self.contact_id), + ) diff --git a/deltachat-rpc-client/src/deltachat_rpc_client/deltachat.py b/deltachat-rpc-client/src/deltachat_rpc_client/deltachat.py new file mode 100644 index 0000000000..8b58494182 --- /dev/null +++ b/deltachat-rpc-client/src/deltachat_rpc_client/deltachat.py @@ -0,0 +1,31 @@ +from .account import Account + + +class Deltachat: + """ + Delta Chat account manager. + This is the root of the object oriented API. + """ + + def __init__(self, rpc): + self.rpc = rpc + + async def add_account(self): + account_id = await self.rpc.add_account() + return Account(self.rpc, account_id) + + async def get_all_accounts(self): + account_ids = await self.rpc.get_all_account_ids() + return [Account(self.rpc, account_id) for account_id in account_ids] + + async def start_io(self) -> None: + await self.rpc.start_io_for_all_accounts() + + async def stop_io(self) -> None: + await self.rpc.stop_io_for_all_accounts() + + async def maybe_network(self) -> None: + await self.rpc.maybe_network() + + async def get_system_info(self): + return await self.rpc.get_system_info() diff --git a/deltachat-rpc-client/src/deltachat_rpc_client/message.py b/deltachat-rpc-client/src/deltachat_rpc_client/message.py new file mode 100644 index 0000000000..346c6e787e --- /dev/null +++ b/deltachat-rpc-client/src/deltachat_rpc_client/message.py @@ -0,0 +1,41 @@ +from dataclasses import dataclass +from typing import Optional + +from .chat import Chat +from .contact import Contact + + +class Message: + def __init__(self, rpc, account_id, msg_id): + self.rpc = rpc + self.account_id = account_id + self.msg_id = msg_id + + async def send_reaction(self, reactions): + msg_id = await self.rpc.send_reaction(self.account_id, self.msg_id, reactions) + return Message(self.rpc, self.account_id, msg_id) + + async def get_snapshot(self): + message_object = await self.rpc.get_message(self.account_id, self.msg_id) + return MessageSnapshot( + message=self, + chat=Chat(self.rpc, self.account_id, message_object["chatId"]), + sender=Contact(self.rpc, self.account_id, message_object["fromId"]), + text=message_object["text"], + error=message_object.get("error"), + is_info=message_object["isInfo"], + ) + + async def mark_seen(self) -> None: + """Mark the message as seen.""" + await self.rpc.markseen_msgs(self.account_id, [self.msg_id]) + + +@dataclass +class MessageSnapshot: + message: Message + chat: Chat + sender: Contact + text: str + error: Optional[str] + is_info: bool diff --git a/deltachat-rpc-client/src/deltachat_rpc_client/rpc.py b/deltachat-rpc-client/src/deltachat_rpc_client/rpc.py new file mode 100644 index 0000000000..cffb0565ec --- /dev/null +++ b/deltachat-rpc-client/src/deltachat_rpc_client/rpc.py @@ -0,0 +1,92 @@ +import asyncio +import json +import logging +import os + +import aiohttp + + +class JsonRpcError(Exception): + pass + + +class Rpc: + def __init__(self, process): + self.process = process + self.event_queues = {} + self.id = 0 + self.reader_task = asyncio.create_task(self.reader_loop()) + + # Map from request ID to `asyncio.Future` returning the response. + self.request_events = {} + + async def reader_loop(self): + while True: + line = await self.process.stdout.readline() + response = json.loads(line) + if "id" in response: + fut = self.request_events.pop(response["id"]) + fut.set_result(response) + elif response["method"] == "event": + # An event notification. + params = response["params"] + account_id = params["contextId"] + if account_id not in self.event_queues: + self.event_queues[account_id] = asyncio.Queue() + await self.event_queues[account_id].put(params["event"]) + else: + print(response) + + async def wait_for_event(self, account_id): + """Returns next event.""" + if account_id in self.event_queues: + return await self.event_queues[account_id].get() + + def __getattr__(self, attr): + async def method(*args, **kwargs): + self.id += 1 + request_id = self.id + + params = args + if kwargs: + assert not args + params = kwargs + + request = { + "jsonrpc": "2.0", + "method": attr, + "params": params, + "id": self.id, + } + data = (json.dumps(request) + "\n").encode() + self.process.stdin.write(data) + event = asyncio.Event() + loop = asyncio.get_running_loop() + fut = loop.create_future() + self.request_events[request_id] = fut + response = await fut + if "error" in response: + raise JsonRpcError(response["error"]) + if "result" in response: + return response["result"] + + return method + + +async def start_rpc_server(*args, **kwargs): + proc = await asyncio.create_subprocess_exec( + "deltachat-rpc-server", + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + *args, + **kwargs + ) + rpc = Rpc(proc) + return rpc + + +async def new_online_account(): + url = os.getenv("DCC_NEW_TMP_EMAIL") + async with aiohttp.ClientSession() as session: + async with session.post(url) as response: + return json.loads(await response.text()) diff --git a/deltachat-rpc-client/tests/test_something.py b/deltachat-rpc-client/tests/test_something.py new file mode 100644 index 0000000000..1744f3de91 --- /dev/null +++ b/deltachat-rpc-client/tests/test_something.py @@ -0,0 +1,92 @@ +import asyncio +import os + +import pytest +import pytest_asyncio + +import deltachat_rpc_client +from deltachat_rpc_client import Deltachat + + +@pytest_asyncio.fixture +async def rpc(tmp_path): + return await deltachat_rpc_client.start_rpc_server( + env={**os.environ, "DC_ACCOUNTS_PATH": str(tmp_path / "accounts")} + ) + + +@pytest.mark.asyncio +async def test_system_info(rpc): + system_info = await rpc.get_system_info() + assert "arch" in system_info + assert "deltachat_core_version" in system_info + + +@pytest.mark.asyncio +async def test_email_address_validity(rpc): + valid_addresses = [ + "email@example.com", + "36aa165ae3406424e0c61af17700f397cad3fe8ab83d682d0bddf3338a5dd52e@yggmail@yggmail", + ] + invalid_addresses = ["email@", "example.com", "emai221"] + + for addr in valid_addresses: + assert await rpc.check_email_validity(addr) + for addr in invalid_addresses: + assert not await rpc.check_email_validity(addr) + + +@pytest.mark.asyncio +async def test_online_account(rpc): + account_json = await deltachat_rpc_client.new_online_account() + + account_id = await rpc.add_account() + await rpc.set_config(account_id, "addr", account_json["email"]) + await rpc.set_config(account_id, "mail_pw", account_json["password"]) + + await rpc.configure(account_id) + while True: + event = await rpc.wait_for_event(account_id) + if event["type"] == "ConfigureProgress": + # Progress 0 indicates error. + assert event["progress"] != 0 + + if event["progress"] == 1000: + # Success. + break + else: + print(event) + print("Successful configuration") + + +@pytest.mark.asyncio +async def test_object_account(rpc): + deltachat = Deltachat(rpc) + + async def create_configured_account(): + account = await deltachat.add_account() + assert not await account.is_configured() + account_json = await deltachat_rpc_client.new_online_account() + await account.set_config("addr", account_json["email"]) + await account.set_config("mail_pw", account_json["password"]) + await account.configure() + assert await account.is_configured() + return account + + alice, bob = await asyncio.gather( + create_configured_account(), create_configured_account() + ) + + alice_contact_bob = await alice.create_contact(await bob.get_config("addr"), "Bob") + alice_chat_bob = await alice_contact_bob.create_chat() + await alice_chat_bob.send_text("Hello!") + + while True: + event = await bob.wait_for_event() + if event["type"] == "IncomingMsg": + chat_id = event["chatId"] + msg_id = event["msgId"] + break + + message = await rpc.get_message(bob.account_id, msg_id) + assert message["text"] == "Hello!" diff --git a/deltachat-rpc-client/tox.ini b/deltachat-rpc-client/tox.ini new file mode 100644 index 0000000000..20618227b9 --- /dev/null +++ b/deltachat-rpc-client/tox.ini @@ -0,0 +1,20 @@ +[tox] +isolated_build = true +envlist = + py3 + +[testenv] +commands = + pytest {posargs} +setenv = +# Avoid stack overflow when Rust core is built without optimizations. + RUST_MIN_STACK=8388608 + PATH = {env:PATH}{:}{toxinidir}/../target/debug +passenv = + DCC_NEW_TMP_EMAIL +deps = + pytest + pytest-async + pytest-asyncio + aiohttp + aiodns