diff --git a/deltachat-rpc-client/examples/echobot.py b/deltachat-rpc-client/examples/echobot.py index 3ca4f6bf8..792dbae86 100755 --- a/deltachat-rpc-client/examples/echobot.py +++ b/deltachat-rpc-client/examples/echobot.py @@ -1,54 +1,25 @@ #!/usr/bin/env python3 +"""Minimal echo bot example. + +it will echo back any text send to it, it also will print to console all Delta Chat core events. +Pass --help to the CLI to see available options. +""" import asyncio -import logging -import sys -import deltachat_rpc_client as dc +from deltachat_rpc_client import events, run_bot_cli + +hooks = events.HookCollection() -async def main(): - async with dc.Rpc() as rpc: - deltachat = dc.DeltaChat(rpc) - system_info = await deltachat.get_system_info() - logging.info("Running deltachat core %s", system_info["deltachat_core_version"]) +@hooks.on(events.RawEvent) +async def log_event(event): + print(event) - 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(): - for message in await account.get_fresh_messages_in_arrival_order(): - snapshot = await message.get_snapshot() - 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() +@hooks.on(events.NewMessage) +async def echo(msg): + await msg.chat.send_text(msg.text) if __name__ == "__main__": - logging.basicConfig(level=logging.INFO) - asyncio.run(main()) + asyncio.run(run_bot_cli(hooks)) diff --git a/deltachat-rpc-client/examples/echobot_advanced.py b/deltachat-rpc-client/examples/echobot_advanced.py new file mode 100644 index 000000000..88ddd1303 --- /dev/null +++ b/deltachat-rpc-client/examples/echobot_advanced.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Advanced echo bot example. + +it will echo back any message that has non-empty text and also supports the /help command. +""" +import asyncio +import logging +import sys + +from deltachat_rpc_client import Bot, DeltaChat, EventType, Rpc, events + +hooks = events.HookCollection() + + +@hooks.on(events.RawEvent) +async def log_event(event): + if event.type == EventType.INFO: + logging.info(event.msg) + elif event.type == EventType.WARNING: + logging.warning(event.msg) + + +@hooks.on(events.RawEvent(EventType.ERROR)) +async def log_error(event): + logging.error(event.msg) + + +@hooks.on(events.NewMessage(r".+", func=lambda msg: not msg.text.startswith("/"))) +async def echo(msg): + await msg.chat.send_text(msg.text) + + +@hooks.on(events.NewMessage(r"/help")) +async def help_command(msg): + await msg.chat.send_text("Send me any text message and I will echo it back") + + +async def main(): + async with Rpc() as rpc: + deltachat = 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() + + bot = Bot(account, hooks) + if not await bot.is_configured(): + asyncio.create_task(bot.configure(email=sys.argv[1], password=sys.argv[2])) + await bot.run_forever() + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + asyncio.run(main()) diff --git a/deltachat-rpc-client/examples/echobot_no_hooks.py b/deltachat-rpc-client/examples/echobot_no_hooks.py new file mode 100644 index 000000000..bf4f6ce9e --- /dev/null +++ b/deltachat-rpc-client/examples/echobot_no_hooks.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +""" +Example echo bot without using hooks +""" +import asyncio +import logging +import sys + +from deltachat_rpc_client import DeltaChat, EventType, Rpc + + +async def main(): + async with Rpc() as rpc: + deltachat = 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(): + for message in await account.get_fresh_messages_in_arrival_order(): + snapshot = await message.get_snapshot() + 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"] == EventType.INFO: + logging.info("%s", event["msg"]) + elif event["type"] == EventType.WARNING: + logging.warning("%s", event["msg"]) + elif event["type"] == EventType.ERROR: + logging.error("%s", event["msg"]) + elif event["type"] == EventType.INCOMING_MSG: + 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/src/deltachat_rpc_client/__init__.py b/deltachat-rpc-client/src/deltachat_rpc_client/__init__.py index 560d4b7d1..a62ae5f63 100644 --- a/deltachat-rpc-client/src/deltachat_rpc_client/__init__.py +++ b/deltachat-rpc-client/src/deltachat_rpc_client/__init__.py @@ -1,5 +1,10 @@ +"""Delta Chat asynchronous high-level API""" from .account import Account +from .chat import Chat +from .client import Bot, Client +from .const import EventType from .contact import Contact from .deltachat import DeltaChat from .message import Message from .rpc import Rpc +from .utils import AttrDict, run_bot_cli, run_client_cli diff --git a/deltachat-rpc-client/src/deltachat_rpc_client/account.py b/deltachat-rpc-client/src/deltachat_rpc_client/account.py index ff339e99f..d75ef67dd 100644 --- a/deltachat-rpc-client/src/deltachat_rpc_client/account.py +++ b/deltachat-rpc-client/src/deltachat_rpc_client/account.py @@ -1,79 +1,265 @@ -from typing import List, Optional +from typing import TYPE_CHECKING, List, Optional, Tuple, Union from .chat import Chat +from .const import ChatlistFlag, ContactFlag, SpecialContactId from .contact import Contact from .message import Message +from .rpc import Rpc +from .utils import AttrDict + +if TYPE_CHECKING: + from .deltachat import DeltaChat class Account: - def __init__(self, rpc, account_id) -> None: - self._rpc = rpc - self.account_id = account_id + """Delta Chat account.""" + + def __init__(self, manager: "DeltaChat", account_id: int) -> None: + self.manager = manager + self.id = account_id + + @property + def _rpc(self) -> Rpc: + return self.manager.rpc + + def __eq__(self, other) -> bool: + if not isinstance(other, Account): + return False + return self.id == other.id and self.manager == other.manager + + def __ne__(self, other) -> bool: + return not self == other def __repr__(self) -> str: - return f"" + return f"" - async def wait_for_event(self) -> dict: + async def wait_for_event(self) -> AttrDict: """Wait until the next event and return it.""" - return await self._rpc.wait_for_event(self.account_id) + return AttrDict(await self._rpc.wait_for_event(self.id)) async def remove(self) -> None: """Remove the account.""" - await self._rpc.remove_account(self.account_id) + await self._rpc.remove_account(self.id) async def start_io(self) -> None: """Start the account I/O.""" - await self._rpc.start_io(self.account_id) + await self._rpc.start_io(self.id) async def stop_io(self) -> None: """Stop the account I/O.""" - await self._rpc.stop_io(self.account_id) + await self._rpc.stop_io(self.id) - async def get_info(self) -> dict: - return await self._rpc.get_info(self.account_id) + async def get_info(self) -> AttrDict: + """Return dictionary of this account configuration parameters.""" + return AttrDict(await self._rpc.get_info(self.id)) - async def get_file_size(self) -> int: - return await self._rpc.get_account_file_size(self.account_id) + async def get_size(self) -> int: + """Get the combined filesize of an account in bytes.""" + return await self._rpc.get_account_file_size(self.id) async def is_configured(self) -> bool: - """Return True for configured accounts.""" - return await self._rpc.is_configured(self.account_id) + """Return True if this account is configured.""" + return await self._rpc.is_configured(self.id) async def set_config(self, key: str, value: Optional[str] = None) -> None: - """Set the configuration value key pair.""" - await self._rpc.set_config(self.account_id, key, value) + """Set configuration value.""" + await self._rpc.set_config(self.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) + """Get configuration value.""" + return await self._rpc.get_config(self.id, key) + + async def update_config(self, **kwargs) -> None: + """update config values.""" + for key, value in kwargs.items(): + await self.set_config(key, value) + + async def set_avatar(self, img_path: Optional[str] = None) -> None: + """Set self avatar. + + Passing None will discard the currently set avatar. + """ + await self.set_config("selfavatar", img_path) + + async def get_avatar(self) -> Optional[str]: + """Get self avatar.""" + return await self.get_config("selfavatar") async def configure(self) -> None: """Configure an account.""" - await self._rpc.configure(self.account_id) + await self._rpc.configure(self.id) - async def create_contact(self, address: str, name: Optional[str] = None) -> 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 create_contact( + self, obj: Union[int, str, Contact], name: Optional[str] = None + ) -> Contact: + """Create a new Contact or return an existing one. + + Calling this method will always result in the same + underlying contact id. If there already is a Contact + with that e-mail address, it is unblocked and its display + name is updated if specified. + + :param obj: email-address or contact id. + :param name: (optional) display name for this contact. + """ + if isinstance(obj, int): + obj = Contact(self, obj) + if isinstance(obj, Contact): + obj = (await obj.get_snapshot()).address + return Contact(self, await self._rpc.create_contact(self.id, obj, name)) + + async def get_contact_by_id(self, contact_id: int) -> Contact: + """Return Contact instance for the given contact ID.""" + return Contact(self, contact_id) + + async def get_contact_by_addr(self, address: str) -> Optional[Contact]: + """Check if an e-mail address belongs to a known and unblocked contact.""" + contact_id = await self._rpc.lookup_contact_id_by_addr(self.id, address) + return contact_id and Contact(self, contact_id) + + async def get_blocked_contacts(self) -> List[AttrDict]: + """Return a list with snapshots of all blocked contacts.""" + contacts = await self._rpc.get_blocked_contacts(self.id) + return [ + AttrDict(contact=Contact(self, contact["id"]), **contact) + for contact in contacts + ] + + async def get_contacts( + self, + query: Optional[str] = None, + with_self: bool = False, + verified_only: bool = False, + snapshot: bool = False, + ) -> Union[List[Contact], List[AttrDict]]: + """Get a filtered list of contacts. + + :param query: if a string is specified, only return contacts + whose name or e-mail matches query. + :param with_self: if True the self-contact is also included if it matches the query. + :param only_verified: if True only return verified contacts. + :param snapshot: If True return a list of contact snapshots instead of Contact instances. + """ + flags = 0 + if verified_only: + flags |= ContactFlag.VERIFIED_ONLY + if with_self: + flags |= ContactFlag.ADD_SELF + + if snapshot: + contacts = await self._rpc.get_contacts(self.id, flags, query) + return [ + AttrDict(contact=Contact(self, contact["id"]), **contact) + for contact in contacts + ] + contacts = await self._rpc.get_contact_ids(self.id, flags, query) + return [Contact(self, contact_id) for contact_id in contacts] + + @property + def self_contact(self) -> Contact: + """This account's identity as a Contact.""" + return Contact(self, SpecialContactId.SELF) + + async def get_chatlist( + self, + query: Optional[str] = None, + contact: Optional[Contact] = None, + archived_only: bool = False, + for_forwarding: bool = False, + no_specials: bool = False, + alldone_hint: bool = False, + snapshot: bool = False, + ) -> Union[List[Chat], List[AttrDict]]: + """Return list of chats. + + :param query: if a string is specified only chats matching this query are returned. + :param contact: if a contact is specified only chats including this contact are returned. + :param archived_only: if True only archived chats are returned. + :param for_forwarding: if True the chat list is sorted with "Saved messages" at the top + and withot "Device chat" and contact requests. + :param no_specials: if True archive link is not added to the list. + :param alldone_hint: if True the "all done hint" special chat will be added to the list + as needed. + :param snapshot: If True return a list of chat snapshots instead of Chat instances. + """ + flags = 0 + if archived_only: + flags |= ChatlistFlag.ARCHIVED_ONLY + if for_forwarding: + flags |= ChatlistFlag.FOR_FORWARDING + if no_specials: + flags |= ChatlistFlag.NO_SPECIALS + if alldone_hint: + flags |= ChatlistFlag.ADD_ALLDONE_HINT + + entries = await self._rpc.get_chatlist_entries( + self.id, flags, query, contact and contact.id ) + if not snapshot: + return [Chat(self, entry[0]) for entry in entries] + + items = await self._rpc.get_chatlist_items_by_entries(self.id, entries) + chats = [] + for item in items.values(): + item["chat"] = Chat(self, item["id"]) + chats.append(AttrDict(item)) + return chats + + async def create_group(self, name: str, protect: bool = False) -> Chat: + """Create a new group chat. + + After creation, the group has only self-contact as member and is in unpromoted state. + """ + return Chat(self, await self._rpc.create_group_chat(self.id, name, protect)) + + async def get_chat_by_id(self, chat_id: int) -> Chat: + """Return the Chat instance with the given ID.""" + return Chat(self, chat_id) async def secure_join(self, qrdata: str) -> Chat: - chat_id = await self._rpc.secure_join(self.account_id, qrdata) - return Chat(self._rpc, self.account_id, chat_id) + """Continue a Setup-Contact or Verified-Group-Invite protocol started on + another device. + + The function returns immediately and the handshake runs in background, sending + and receiving several messages. + Subsequent calls of `secure_join()` will abort previous, unfinished handshakes. + See https://countermitm.readthedocs.io/en/latest/new.html for protocol details. + + :param qrdata: The text of the scanned QR code. + """ + return Chat(self, await self._rpc.secure_join(self.id, qrdata)) + + async def get_qr_code(self) -> Tuple[str, str]: + """Get Setup-Contact QR Code text and SVG data. + + this data needs to be transferred to another Delta Chat account + in a second channel, typically used by mobiles with QRcode-show + scan UX. + """ + return await self._rpc.get_chat_securejoin_qr_code_svg(self.id, None) + + async def get_message_by_id(self, msg_id: int) -> Message: + """Return the Message instance with the given ID.""" + return Message(self, msg_id) + + async def mark_seen_messages(self, messages: List[Message]) -> None: + """Mark the given set of messages as seen.""" + await self._rpc.markseen_msgs(self.id, [msg.id for msg in messages]) + + async def delete_messages(self, messages: List[Message]) -> None: + """Delete messages (local and remote).""" + await self._rpc.delete_messages(self.id, [msg.id for msg in messages]) async def get_fresh_messages(self) -> List[Message]: """Return the list of fresh messages, newest messages first. This call is intended for displaying notifications. - If you are writing a bot, use get_fresh_messages_in_arrival_order instead, + If you are writing a bot, use `get_fresh_messages_in_arrival_order()` instead, to process oldest messages first. """ - 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] + fresh_msg_ids = await self._rpc.get_fresh_msgs(self.id) + return [Message(self, msg_id) for msg_id in fresh_msg_ids] async def get_fresh_messages_in_arrival_order(self) -> List[Message]: """Return fresh messages list sorted in the order of their arrival, with ascending IDs.""" - fresh_msg_ids = sorted(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] + fresh_msg_ids = sorted(await self._rpc.get_fresh_msgs(self.id)) + return [Message(self, 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 index ed8561afa..9e053b1fc 100644 --- a/deltachat-rpc-client/src/deltachat_rpc_client/chat.py +++ b/deltachat-rpc-client/src/deltachat_rpc_client/chat.py @@ -1,41 +1,263 @@ -from typing import TYPE_CHECKING +import calendar +from datetime import datetime +from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union +from .const import ChatVisibility +from .contact import Contact +from .message import Message from .rpc import Rpc +from .utils import AttrDict if TYPE_CHECKING: - from .message import Message + from .account import Account class Chat: - def __init__(self, rpc: Rpc, account_id: int, chat_id: int) -> None: - self._rpc = rpc - self.account_id = account_id - self.chat_id = chat_id + """Chat object which manages members and through which you can send and retrieve messages.""" - async def block(self) -> None: - """Block the chat.""" - await self._rpc.block_chat(self.account_id, self.chat_id) + def __init__(self, account: "Account", chat_id: int) -> None: + self.account = account + self.id = chat_id - async def accept(self) -> None: - """Accept the contact request.""" - await self._rpc.accept_chat(self.account_id, self.chat_id) + @property + def _rpc(self) -> Rpc: + return self.account._rpc + + def __eq__(self, other) -> bool: + if not isinstance(other, Chat): + return False + return self.id == other.id and self.account == other.account + + def __ne__(self, other) -> bool: + return not self == other + + def __repr__(self) -> str: + return f"" async def delete(self) -> None: - await self._rpc.delete_chat(self.account_id, self.chat_id) + """Delete this chat and all its messages. - async def get_encryption_info(self) -> str: - return await self._rpc.get_chat_encryption_info(self.account_id, self.chat_id) + Note: - async def send_text(self, text: str) -> "Message": - from .message import Message + - does not delete messages on server + - the chat or contact is not blocked, new message will arrive + """ + await self._rpc.delete_chat(self.account.id, self.id) - 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 block(self) -> None: + """Block this chat.""" + await self._rpc.block_chat(self.account.id, self.id) + + async def accept(self) -> None: + """Accept this contact request chat.""" + await self._rpc.accept_chat(self.account.id, self.id) async def leave(self) -> None: - await self._rpc.leave_group(self.account_id, self.chat_id) + """Leave this chat.""" + await self._rpc.leave_group(self.account.id, self.id) + + async def mute(self, duration: Optional[int] = None) -> None: + """Mute this chat, if a duration is not provided the chat is muted forever. + + :param duration: mute duration from now in seconds. Must be greater than zero. + """ + if duration is not None: + assert duration > 0, "Invalid duration" + dur: Union[str, dict] = dict(Until=duration) + else: + dur = "Forever" + await self._rpc.set_chat_mute_duration(self.account.id, self.id, dur) + + async def unmute(self) -> None: + """Unmute this chat.""" + await self._rpc.set_chat_mute_duration(self.account.id, self.id, "NotMuted") + + async def pin(self) -> None: + """Pin this chat.""" + await self._rpc.set_chat_visibility( + self.account.id, self.id, ChatVisibility.PINNED + ) + + async def unpin(self) -> None: + """Unpin this chat.""" + await self._rpc.set_chat_visibility( + self.account.id, self.id, ChatVisibility.NORMAL + ) + + async def archive(self) -> None: + """Archive this chat.""" + await self._rpc.set_chat_visibility( + self.account.id, self.id, ChatVisibility.ARCHIVED + ) + + async def unarchive(self) -> None: + """Unarchive this chat.""" + await self._rpc.set_chat_visibility( + self.account.id, self.id, ChatVisibility.NORMAL + ) + + async def set_name(self, name: str) -> None: + """Set name of this chat.""" + await self._rpc.set_chat_name(self.account.id, self.id, name) + + async def set_ephemeral_timer(self, timer: int) -> None: + """Set ephemeral timer of this chat.""" + await self._rpc.set_chat_ephemeral_timer(self.account.id, self.id, timer) + + async def get_encryption_info(self) -> str: + """Return encryption info for this chat.""" + return await self._rpc.get_chat_encryption_info(self.account.id, self.id) + + async def get_qr_code(self) -> Tuple[str, str]: + """Get Join-Group QR code text and SVG data.""" + return await self._rpc.get_chat_securejoin_qr_code_svg(self.account.id, self.id) + + async def get_basic_snapshot(self) -> AttrDict: + """Get a chat snapshot with basic info about this chat.""" + info = await self._rpc.get_basic_chat_info(self.account.id, self.id) + return AttrDict(chat=self, **info) + + async def get_full_snapshot(self) -> AttrDict: + """Get a full snapshot of this chat.""" + info = await self._rpc.get_full_chat_by_id(self.account.id, self.id) + return AttrDict(chat=self, **info) + + async def send_message( + self, + text: Optional[str] = None, + file: Optional[str] = None, + location: Optional[Tuple[float, float]] = None, + quoted_msg: Optional[Union[int, Message]] = None, + ) -> Message: + """Send a message and return the resulting Message instance.""" + if isinstance(quoted_msg, Message): + quoted_msg = quoted_msg.id + + msg_id, _ = await self._rpc.misc_send_msg( + self.account.id, self.id, text, file, location, quoted_msg + ) + return Message(self.account, msg_id) + + async def send_text(self, text: str) -> Message: + """Send a text message and return the resulting Message instance.""" + msg_id = await self._rpc.misc_send_text_message(self.account.id, self.id, text) + return Message(self.account, msg_id) + + async def send_videochat_invitation(self) -> Message: + """Send a videochat invitation and return the resulting Message instance.""" + msg_id = await self._rpc.send_videochat_invitation(self.account.id, self.id) + return Message(self.account, msg_id) + + async def send_sticker(self, path: str) -> Message: + """Send an sticker and return the resulting Message instance.""" + msg_id = await self._rpc.send_sticker(self.account.id, self.id, path) + return Message(self.account, msg_id) + + async def forward_messages(self, messages: List[Message]) -> None: + """Forward a list of messages to this chat.""" + msg_ids = [msg.id for msg in messages] + await self._rpc.forward_messages(self.account.id, msg_ids, self.id) + + async def set_draft( + self, + text: Optional[str] = None, + file: Optional[str] = None, + quoted_msg: Optional[int] = None, + ) -> None: + """Set draft message.""" + if isinstance(quoted_msg, Message): + quoted_msg = quoted_msg.id + await self._rpc.misc_set_draft(self.account.id, self.id, text, file, quoted_msg) + + async def remove_draft(self) -> None: + """Remove draft message.""" + await self._rpc.remove_draft(self.account.id, self.id) + + async def get_draft(self) -> Optional[AttrDict]: + """Get draft message.""" + snapshot = await self._rpc.get_draft(self.account.id, self.id) + if not snapshot: + return None + snapshot = AttrDict(snapshot) + snapshot["chat"] = Chat(self.account, snapshot.chat_id) + snapshot["sender"] = Contact(self.account, snapshot.from_id) + snapshot["message"] = Message(self.account, snapshot.id) + return snapshot + + async def get_messages(self, flags: int = 0) -> List[Message]: + """get the list of messages in this chat.""" + msgs = await self._rpc.get_message_ids(self.account.id, self.id, flags) + return [Message(self.account, msg_id) for msg_id in msgs] async def get_fresh_message_count(self) -> int: - return await self._rpc.get_fresh_msg_cnt(self.account_id, self.chat_id) + """Get number of fresh messages in this chat""" + return await self._rpc.get_fresh_msg_cnt(self.account.id, self.id) + + async def mark_noticed(self) -> None: + """Mark all messages in this chat as noticed.""" + await self._rpc.marknoticed_chat(self.account.id, self.id) + + async def add_contact(self, *contact: Union[int, str, Contact]) -> None: + """Add contacts to this group.""" + for cnt in contact: + if isinstance(cnt, str): + cnt = (await self.account.create_contact(cnt)).id + elif not isinstance(cnt, int): + cnt = cnt.id + await self._rpc.add_contact_to_chat(self.account.id, self.id, cnt) + + async def remove_contact(self, *contact: Union[int, str, Contact]) -> None: + """Remove members from this group.""" + for cnt in contact: + if isinstance(cnt, str): + cnt = (await self.account.create_contact(cnt)).id + elif not isinstance(cnt, int): + cnt = cnt.id + await self._rpc.remove_contact_from_chat(self.account.id, self.id, cnt) + + async def get_contacts(self) -> List[Contact]: + """Get the contacts belonging to this chat. + + For single/direct chats self-address is not included. + """ + contacts = await self._rpc.get_chat_contacts(self.account.id, self.id) + return [Contact(self.account, contact_id) for contact_id in contacts] + + async def set_image(self, path: str) -> None: + """Set profile image of this chat. + + :param path: Full path of the image to use as the group image. + """ + await self._rpc.set_chat_profile_image(self.account.id, self.id, path) + + async def remove_image(self) -> None: + """Remove profile image of this chat.""" + await self._rpc.set_chat_profile_image(self.account.id, self.id, None) + + async def get_locations( + self, + contact: Optional[Contact] = None, + timestamp_from: Optional[datetime] = None, + timestamp_to: Optional[datetime] = None, + ) -> List[AttrDict]: + """Get list of location snapshots for the given contact in the given timespan.""" + time_from = ( + calendar.timegm(timestamp_from.utctimetuple()) if timestamp_from else 0 + ) + time_to = calendar.timegm(timestamp_to.utctimetuple()) if timestamp_to else 0 + contact_id = contact.id if contact else 0 + + result = await self._rpc.get_locations( + self.account.id, self.id, contact_id, time_from, time_to + ) + locations = [] + contacts: Dict[int, Contact] = {} + for loc in result: + loc = AttrDict(loc) + loc["chat"] = self + loc["contact"] = contacts.setdefault( + loc.contact_id, Contact(self.account, loc.contact_id) + ) + loc["message"] = Message(self.account, loc.msg_id) + locations.append(loc) + return locations diff --git a/deltachat-rpc-client/src/deltachat_rpc_client/client.py b/deltachat-rpc-client/src/deltachat_rpc_client/client.py new file mode 100644 index 000000000..2a749eefe --- /dev/null +++ b/deltachat-rpc-client/src/deltachat_rpc_client/client.py @@ -0,0 +1,100 @@ +"""Event loop implementations offering high level event handling/hooking.""" +import logging +from typing import Callable, Dict, Iterable, Optional, Set, Tuple, Type, Union + +from deltachat_rpc_client.account import Account + +from .const import EventType +from .events import EventFilter, NewInfoMessage, NewMessage, RawEvent +from .utils import AttrDict + + +class Client: + """Simple Delta Chat client that listen to events of a single account.""" + + def __init__( + self, + account: Account, + hooks: Optional[Iterable[Tuple[Callable, Union[type, EventFilter]]]] = None, + logger: Optional[logging.Logger] = None, + ) -> None: + self.account = account + self.logger = logger or logging + self._hooks: Dict[type, Set[tuple]] = {} + self.add_hooks(hooks or []) + + def add_hooks( + self, hooks: Iterable[Tuple[Callable, Union[type, EventFilter]]] + ) -> None: + for hook, event in hooks: + self.add_hook(hook, event) + + def add_hook( + self, hook: Callable, event: Union[type, EventFilter] = RawEvent + ) -> None: + """Register hook for the given event filter.""" + if isinstance(event, type): + event = event() + assert isinstance(event, EventFilter) + self._hooks.setdefault(type(event), set()).add((hook, event)) + + def remove_hook(self, hook: Callable, event: Union[type, EventFilter]) -> None: + """Unregister hook from the given event filter.""" + if isinstance(event, type): + event = event() + self._hooks.get(type(event), set()).remove((hook, event)) + + async def is_configured(self) -> bool: + return await self.account.is_configured() + + async def configure(self, email: str, password: str, **kwargs) -> None: + await self.account.set_config("addr", email) + await self.account.set_config("mail_pw", password) + for key, value in kwargs.items(): + await self.account.set_config(key, value) + await self.account.configure() + self.logger.debug("Account configured") + + async def run_forever(self) -> None: + self.logger.debug("Listening to incoming events...") + if await self.is_configured(): + await self.account.start_io() + await self._process_messages() # Process old messages. + while True: + event = await self.account.wait_for_event() + event["type"] = EventType(event.type) + event["account"] = self.account + await self._on_event(event) + if event.type == EventType.INCOMING_MSG: + await self._process_messages() + + async def _on_event( + self, event: AttrDict, filter_type: Type[EventFilter] = RawEvent + ) -> None: + for hook, evfilter in self._hooks.get(filter_type, []): + if await evfilter.filter(event): + try: + await hook(event) + except Exception as ex: + self.logger.exception(ex) + + def _should_process_messages(self) -> bool: + return any(issubclass(filter_type, NewMessage) for filter_type in self._hooks) + + async def _process_messages(self) -> None: + if self._should_process_messages(): + for message in await self.account.get_fresh_messages_in_arrival_order(): + snapshot = await message.get_snapshot() + if snapshot.is_info: + await self._on_event(snapshot, NewInfoMessage) + else: + await self._on_event(snapshot, NewMessage) + await snapshot.message.mark_seen() + + +class Bot(Client): + """Simple bot implementation that listent to events of a single account.""" + + async def configure(self, email: str, password: str, **kwargs) -> None: + kwargs.setdefault("bot", "1") + await super().configure(email, password, **kwargs) diff --git a/deltachat-rpc-client/src/deltachat_rpc_client/const.py b/deltachat-rpc-client/src/deltachat_rpc_client/const.py new file mode 100644 index 000000000..c8bb925fc --- /dev/null +++ b/deltachat-rpc-client/src/deltachat_rpc_client/const.py @@ -0,0 +1,120 @@ +from enum import Enum, IntEnum + + +class ContactFlag(IntEnum): + VERIFIED_ONLY = 0x01 + ADD_SELF = 0x02 + + +class ChatlistFlag(IntEnum): + ARCHIVED_ONLY = 0x01 + NO_SPECIALS = 0x02 + ADD_ALLDONE_HINT = 0x04 + FOR_FORWARDING = 0x08 + + +class SpecialContactId(IntEnum): + SELF = 1 + INFO = 2 # centered messages as "member added", used in all chats + DEVICE = 5 # messages "update info" in the device-chat + LAST_SPECIAL = 9 + + +class EventType(str, Enum): + """Core event types""" + + INFO = "Info" + SMTP_CONNECTED = "SmtpConnected" + IMAP_CONNECTED = "ImapConnected" + SMTP_MESSAGE_SENT = "SmtpMessageSent" + IMAP_MESSAGE_DELETED = "ImapMessageDeleted" + IMAP_MESSAGE_MOVED = "ImapMessageMoved" + NEW_BLOB_FILE = "NewBlobFile" + DELETED_BLOB_FILE = "DeletedBlobFile" + WARNING = "Warning" + ERROR = "Error" + ERROR_SELF_NOT_IN_GROUP = "ErrorSelfNotInGroup" + MSGS_CHANGED = "MsgsChanged" + REACTIONS_CHANGED = "ReactionsChanged" + INCOMING_MSG = "IncomingMsg" + INCOMING_MSG_BUNCH = "IncomingMsgBunch" + MSGS_NOTICED = "MsgsNoticed" + MSG_DELIVERED = "MsgDelivered" + MSG_FAILED = "MsgFailed" + MSG_READ = "MsgRead" + CHAT_MODIFIED = "ChatModified" + CHAT_EPHEMERAL_TIMER_MODIFIED = "ChatEphemeralTimerModified" + CONTACTS_CHANGED = "ContactsChanged" + LOCATION_CHANGED = "LocationChanged" + CONFIGURE_PROGRESS = "ConfigureProgress" + IMEX_PROGRESS = "ImexProgress" + IMEX_FILE_WRITTEN = "ImexFileWritten" + SECUREJOIN_INVITER_PROGRESS = "SecurejoinInviterProgress" + SECUREJOIN_JOINER_PROGRESS = "SecurejoinJoinerProgress" + CONNECTIVITY_CHANGED = "ConnectivityChanged" + SELFAVATAR_CHANGED = "SelfavatarChanged" + WEBXDC_STATUS_UPDATE = "WebxdcStatusUpdate" + WEBXDC_INSTANCE_DELETED = "WebxdcInstanceDeleted" + + +class ChatType(IntEnum): + """Chat types""" + + UNDEFINED = 0 + SINGLE = 100 + GROUP = 120 + MAILINGLIST = 140 + BROADCAST = 160 + + +class ChatVisibility(str, Enum): + """Chat visibility types""" + + NORMAL = "Normal" + ARCHIVED = "Archived" + PINNED = "Pinned" + + +class DownloadState(str, Enum): + """Message download state""" + + DONE = "Done" + AVAILABLE = "Available" + FAILURE = "Failure" + IN_PROGRESS = "InProgress" + + +class ViewType(str, Enum): + """Message view type.""" + + UNKNOWN = "Unknown" + TEXT = "Text" + IMAGE = "Image" + GIF = "Gif" + STICKER = "Sticker" + AUDIO = "Audio" + VOICE = "Voice" + VIDEO = "Video" + FILE = "File" + VIDEOCHAT_INVITATION = "VideochatInvitation" + WEBXDC = "Webxdc" + + +class SystemMessageType(str, Enum): + """System message type.""" + + UNKNOWN = "Unknown" + GROUP_NAME_CHANGED = "GroupNameChanged" + GROUP_IMAGE_CHANGED = "GroupImageChanged" + MEMBER_ADDED_TO_GROUP = "MemberAddedToGroup" + MEMBER_REMOVED_FROM_GROUP = "MemberRemovedFromGroup" + AUTOCRYPT_SETUP_MESSAGE = "AutocryptSetupMessage" + SECUREJOIN_MESSAGE = "SecurejoinMessage" + LOCATION_STREAMING_ENABLED = "LocationStreamingEnabled" + LOCATION_ONLY = "LocationOnly" + CHAT_PROTECTION_ENABLED = "ChatProtectionEnabled" + CHAT_PROTECTION_DISABLED = "ChatProtectionDisabled" + WEBXDC_STATUS_UPDATE = "WebxdcStatusUpdate" + EPHEMERAL_TIMER_CHANGED = "EphemeralTimerChanged" + MULTI_DEVICE_SYNC = "MultiDeviceSync" + WEBXDC_INFO_MESSAGE = "WebxdcInfoMessage" diff --git a/deltachat-rpc-client/src/deltachat_rpc_client/contact.py b/deltachat-rpc-client/src/deltachat_rpc_client/contact.py index 2e9154ef1..804ac4af1 100644 --- a/deltachat-rpc-client/src/deltachat_rpc_client/contact.py +++ b/deltachat-rpc-client/src/deltachat_rpc_client/contact.py @@ -1,8 +1,10 @@ from typing import TYPE_CHECKING from .rpc import Rpc +from .utils import AttrDict if TYPE_CHECKING: + from .account import Account from .chat import Chat @@ -13,40 +15,58 @@ class Contact: Essentially a wrapper for RPC, account ID and a contact ID. """ - def __init__(self, rpc: Rpc, account_id: int, contact_id: int) -> None: - self._rpc = rpc - self.account_id = account_id - self.contact_id = contact_id + def __init__(self, account: "Account", contact_id: int) -> None: + self.account = account + self.id = contact_id + + def __eq__(self, other) -> bool: + if not isinstance(other, Contact): + return False + return self.id == other.id and self.account == other.account + + def __ne__(self, other) -> bool: + return not self == other + + def __repr__(self) -> str: + return f"" + + @property + def _rpc(self) -> Rpc: + return self.account._rpc async def block(self) -> None: """Block contact.""" - await self._rpc.block_contact(self.account_id, self.contact_id) + await self._rpc.block_contact(self.account.id, self.id) async def unblock(self) -> None: """Unblock contact.""" - await self._rpc.unblock_contact(self.account_id, self.contact_id) + await self._rpc.unblock_contact(self.account.id, self.id) async def delete(self) -> None: """Delete contact.""" - await self._rpc.delete_contact(self.account_id, self.contact_id) + await self._rpc.delete_contact(self.account.id, self.id) - async def change_name(self, name: str) -> None: - await self._rpc.change_contact_name(self.account_id, self.contact_id, name) + async def set_name(self, name: str) -> None: + """Change the name of this contact.""" + await self._rpc.change_contact_name(self.account.id, self.id, name) async def get_encryption_info(self) -> str: - return await self._rpc.get_contact_encryption_info( - self.account_id, self.contact_id - ) + """Get a multi-line encryption info, containing your fingerprint and + the fingerprint of the contact. + """ + return await self._rpc.get_contact_encryption_info(self.account.id, self.id) - async def get_dictionary(self) -> dict: + async def get_snapshot(self) -> AttrDict: """Return a dictionary with a snapshot of all contact properties.""" - return await self._rpc.get_contact(self.account_id, self.contact_id) + snapshot = AttrDict(await self._rpc.get_contact(self.account.id, self.id)) + snapshot["contact"] = self + return snapshot async def create_chat(self) -> "Chat": + """Create or get an existing 1:1 chat for this contact.""" 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), + self.account, + await self._rpc.create_chat_by_contact_id(self.account.id, self.id), ) diff --git a/deltachat-rpc-client/src/deltachat_rpc_client/deltachat.py b/deltachat-rpc-client/src/deltachat_rpc_client/deltachat.py index 2414fbb94..2c926bac5 100644 --- a/deltachat-rpc-client/src/deltachat_rpc_client/deltachat.py +++ b/deltachat-rpc-client/src/deltachat_rpc_client/deltachat.py @@ -1,12 +1,13 @@ -from typing import List +from typing import Dict, List from .account import Account from .rpc import Rpc +from .utils import AttrDict class DeltaChat: """ - Delta Chat account manager. + Delta Chat accounts manager. This is the root of the object oriented API. """ @@ -14,21 +15,33 @@ class DeltaChat: self.rpc = rpc async def add_account(self) -> Account: + """Create a new account database.""" account_id = await self.rpc.add_account() - return Account(self.rpc, account_id) + return Account(self, account_id) async def get_all_accounts(self) -> List[Account]: + """Return a list of all available accounts.""" account_ids = await self.rpc.get_all_account_ids() - return [Account(self.rpc, account_id) for account_id in account_ids] + return [Account(self, account_id) for account_id in account_ids] async def start_io(self) -> None: + """Start the I/O of all accounts.""" await self.rpc.start_io_for_all_accounts() async def stop_io(self) -> None: + """Stop the I/O of all accounts.""" await self.rpc.stop_io_for_all_accounts() async def maybe_network(self) -> None: + """Indicate that the network likely has come back or just that the network + conditions might have changed. + """ await self.rpc.maybe_network() - async def get_system_info(self) -> dict: - return await self.rpc.get_system_info() + async def get_system_info(self) -> AttrDict: + """Get information about the Delta Chat core in this system.""" + return AttrDict(await self.rpc.get_system_info()) + + async def set_translations(self, translations: Dict[str, str]) -> None: + """Set stock translation strings.""" + await self.rpc.set_stock_strings(translations) diff --git a/deltachat-rpc-client/src/deltachat_rpc_client/events.py b/deltachat-rpc-client/src/deltachat_rpc_client/events.py new file mode 100644 index 000000000..606fe9896 --- /dev/null +++ b/deltachat-rpc-client/src/deltachat_rpc_client/events.py @@ -0,0 +1,161 @@ +"""High-level classes for event processing and filtering.""" +import inspect +import re +from abc import ABC, abstractmethod +from typing import Callable, Iterable, Iterator, Optional, Set, Tuple, Union + +from .const import EventType +from .utils import AttrDict + + +def _tuple_of(obj, type_: type) -> tuple: + if not obj: + return tuple() + if isinstance(obj, type_): + obj = (obj,) + + if not all(isinstance(elem, type_) for elem in obj): + raise TypeError() + return tuple(obj) + + +class EventFilter(ABC): + """The base event filter. + + :param func: A Callable (async or not) function that should accept the event as input + parameter, and return a bool value indicating whether the event + should be dispatched or not. + """ + + def __init__(self, func: Optional[Callable] = None): + self.func = func + + @abstractmethod + def __hash__(self) -> int: + """Object's unique hash""" + + @abstractmethod + def __eq__(self, other) -> bool: + """Return True if two event filters are equal.""" + + def __ne__(self, other): + return not self.__eq__(other) + + async def _call_func(self, event) -> bool: + if not self.func: + return True + res = self.func(event) + if inspect.isawaitable(res): + return await res + return res + + @abstractmethod + async def filter(self, event): + """Return True-like value if the event passed the filter and should be + used, or False-like value otherwise. + """ + + +class RawEvent(EventFilter): + """Matches raw core events. + + :param types: The types of event to match. + :param func: A Callable (async or not) function that should accept the event as input + parameter, and return a bool value indicating whether the event + should be dispatched or not. + """ + + def __init__( + self, types: Union[None, EventType, Iterable[EventType]] = None, **kwargs + ): + super().__init__(**kwargs) + try: + self.types = _tuple_of(types, EventType) + except TypeError as err: + raise TypeError(f"Invalid event type given: {types}") from err + + def __hash__(self) -> int: + return hash((self.types, self.func)) + + def __eq__(self, other) -> bool: + if isinstance(other, RawEvent): + return (self.types, self.func) == (other.types, other.func) + return False + + async def filter(self, event: AttrDict) -> bool: + if self.types and event.type not in self.types: + return False + return await self._call_func(event) + + +class NewMessage(EventFilter): + """Matches whenever a new message arrives. + + Warning: registering a handler for this event or any subclass will cause the messages + to be marked as read. Its usage is mainly intended for bots. + """ + + def __init__( + self, + pattern: Union[ + None, + str, + Callable[[str], bool], + re.Pattern, + ] = None, + func: Optional[Callable[[AttrDict], bool]] = None, + ) -> None: + super().__init__(func=func) + if isinstance(pattern, str): + pattern = re.compile(pattern) + if isinstance(pattern, re.Pattern): + self.pattern: Optional[Callable] = pattern.match + elif not pattern or callable(pattern): + self.pattern = pattern + else: + raise TypeError("Invalid pattern type") + + def __hash__(self) -> int: + return hash((self.pattern, self.func)) + + def __eq__(self, other) -> bool: + if type(other) is self.__class__: # noqa + return (self.pattern, self.func) == (other.pattern, other.func) + return False + + async def filter(self, event: AttrDict) -> bool: + if self.pattern: + match = self.pattern(event.text) + if inspect.isawaitable(match): + match = await match + if not match: + return False + return await super()._call_func(event) + + +class NewInfoMessage(NewMessage): + """Matches whenever a new info/system message arrives.""" + + +class HookCollection: + """ + Helper class to collect event hooks that can later be added to a Delta Chat client. + """ + + def __init__(self) -> None: + self._hooks: Set[Tuple[Callable, Union[type, EventFilter]]] = set() + + def __iter__(self) -> Iterator[Tuple[Callable, Union[type, EventFilter]]]: + return iter(self._hooks) + + def on(self, event: Union[type, EventFilter]) -> Callable: # noqa + """Register decorated function as listener for the given event.""" + if isinstance(event, type): + event = event() + assert isinstance(event, EventFilter), "Invalid event filter" + + def _decorator(func) -> Callable: + self._hooks.add((func, event)) + return func + + return _decorator diff --git a/deltachat-rpc-client/src/deltachat_rpc_client/message.py b/deltachat-rpc-client/src/deltachat_rpc_client/message.py index 67d90227c..0001dd308 100644 --- a/deltachat-rpc-client/src/deltachat_rpc_client/message.py +++ b/deltachat-rpc-client/src/deltachat_rpc_client/message.py @@ -1,42 +1,49 @@ -from dataclasses import dataclass -from typing import Optional +from typing import TYPE_CHECKING -from .chat import Chat from .contact import Contact from .rpc import Rpc +from .utils import AttrDict + +if TYPE_CHECKING: + from .account import Account class Message: - def __init__(self, rpc: Rpc, account_id: int, msg_id: int) -> None: - self._rpc = rpc - self.account_id = account_id - self.msg_id = msg_id + """Delta Chat Message object.""" - async def send_reaction(self, reactions: str) -> "Message": - msg_id = await self._rpc.send_reaction(self.account_id, self.msg_id, reactions) - return Message(self._rpc, self.account_id, msg_id) + def __init__(self, account: "Account", msg_id: int) -> None: + self.account = account + self.id = msg_id - async def get_snapshot(self) -> "MessageSnapshot": - 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"], - ) + def __eq__(self, other) -> bool: + if not isinstance(other, Message): + return False + return self.id == other.id and self.account == other.account + + def __ne__(self, other) -> bool: + return not self == other + + def __repr__(self) -> str: + return f"" + + @property + def _rpc(self) -> Rpc: + return self.account._rpc + + async def send_reaction(self, *reaction: str): + """Send a reaction to this message.""" + await self._rpc.send_reaction(self.account.id, self.id, reaction) + + async def get_snapshot(self) -> AttrDict: + """Get a snapshot with the properties of this message.""" + from .chat import Chat + + snapshot = AttrDict(await self._rpc.get_message(self.account.id, self.id)) + snapshot["chat"] = Chat(self.account, snapshot.chat_id) + snapshot["sender"] = Contact(self.account, snapshot.from_id) + snapshot["message"] = self + return snapshot 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 + await self._rpc.markseen_msgs(self.account.id, [self.id]) diff --git a/deltachat-rpc-client/src/deltachat_rpc_client/pytestplugin.py b/deltachat-rpc-client/src/deltachat_rpc_client/pytestplugin.py index 0e1be118b..e8cce5f4c 100644 --- a/deltachat-rpc-client/src/deltachat_rpc_client/pytestplugin.py +++ b/deltachat-rpc-client/src/deltachat_rpc_client/pytestplugin.py @@ -1,4 +1,3 @@ -import asyncio import json import os from typing import AsyncGenerator, List @@ -7,6 +6,7 @@ import aiohttp import pytest_asyncio from .account import Account +from .client import Bot from .deltachat import DeltaChat from .rpc import Rpc @@ -23,9 +23,15 @@ class ACFactory: def __init__(self, deltachat: DeltaChat) -> None: self.deltachat = deltachat + async def get_unconfigured_account(self) -> Account: + return await self.deltachat.add_account() + + async def get_unconfigured_bot(self) -> Bot: + return Bot(await self.get_unconfigured_account()) + async def new_configured_account(self) -> Account: credentials = await get_temp_credentials() - account = await self.deltachat.add_account() + account = await self.get_unconfigured_account() assert not await account.is_configured() await account.set_config("addr", credentials["email"]) await account.set_config("mail_pw", credentials["password"]) @@ -33,18 +39,24 @@ class ACFactory: assert await account.is_configured() return account + async def new_configured_bot(self) -> Bot: + credentials = await get_temp_credentials() + bot = await self.get_unconfigured_bot() + await bot.configure(credentials["email"], credentials["password"]) + return bot + async def get_online_accounts(self, num: int) -> List[Account]: accounts = [await self.new_configured_account() for _ in range(num)] - await self.deltachat.start_io() + for account in accounts: + await account.start_io() return accounts @pytest_asyncio.fixture async def rpc(tmp_path) -> AsyncGenerator: - env = {**os.environ, "DC_ACCOUNTS_PATH": str(tmp_path / "accounts")} - rpc_server = Rpc(env=env) - async with rpc_server as rpc: - yield rpc + rpc_server = Rpc(accounts_dir=str(tmp_path / "accounts")) + async with rpc_server: + yield rpc_server @pytest_asyncio.fixture diff --git a/deltachat-rpc-client/src/deltachat_rpc_client/rpc.py b/deltachat-rpc-client/src/deltachat_rpc_client/rpc.py index 174a54e2d..78474aa8b 100644 --- a/deltachat-rpc-client/src/deltachat_rpc_client/rpc.py +++ b/deltachat-rpc-client/src/deltachat_rpc_client/rpc.py @@ -1,6 +1,7 @@ import asyncio import json -from typing import Any, AsyncGenerator, Dict, Optional +import os +from typing import Any, Dict, Optional class JsonRpcError(Exception): @@ -8,26 +9,36 @@ class JsonRpcError(Exception): class Rpc: - def __init__(self, *args, **kwargs): + def __init__(self, accounts_dir: Optional[str] = None, **kwargs): """The given arguments will be passed to asyncio.create_subprocess_exec()""" - self._args = args + if accounts_dir: + kwargs["env"] = { + **kwargs.get("env", os.environ), + "DC_ACCOUNTS_PATH": os.path.abspath( + os.path.expanduser(str(accounts_dir)) + ), + } + self._kwargs = kwargs + self.process: asyncio.subprocess.Process + self.id: int + self.event_queues: Dict[int, asyncio.Queue] + # Map from request ID to `asyncio.Future` returning the response. + self.request_events: Dict[int, asyncio.Future] + self.reader_task: asyncio.Task async def start(self) -> None: self.process = await asyncio.create_subprocess_exec( "deltachat-rpc-server", stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, - *self._args, **self._kwargs ) - self.event_queues: Dict[int, asyncio.Queue] = {} self.id = 0 + self.event_queues = {} + self.request_events = {} self.reader_task = asyncio.create_task(self.reader_loop()) - # Map from request ID to `asyncio.Future` returning the response. - self.request_events: Dict[int, asyncio.Future] = {} - async def close(self) -> None: """Terminate RPC server process and wait until the reader loop finishes.""" self.process.terminate() @@ -42,7 +53,7 @@ class Rpc: async def reader_loop(self) -> None: while True: - line = await self.process.stdout.readline() + line = await self.process.stdout.readline() # noqa if not line: # EOF break response = json.loads(line) @@ -79,7 +90,7 @@ class Rpc: "id": self.id, } data = (json.dumps(request) + "\n").encode() - self.process.stdin.write(data) + self.process.stdin.write(data) # noqa loop = asyncio.get_running_loop() fut = loop.create_future() self.request_events[request_id] = fut diff --git a/deltachat-rpc-client/src/deltachat_rpc_client/utils.py b/deltachat-rpc-client/src/deltachat_rpc_client/utils.py new file mode 100644 index 000000000..f7782ad8b --- /dev/null +++ b/deltachat-rpc-client/src/deltachat_rpc_client/utils.py @@ -0,0 +1,114 @@ +import argparse +import asyncio +import re +import sys +from typing import TYPE_CHECKING, Callable, Iterable, Optional, Tuple, Type, Union + +if TYPE_CHECKING: + from .client import Client + from .events import EventFilter + + +def _camel_to_snake(name: str) -> str: + name = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name) + name = re.sub("__([A-Z])", r"_\1", name) + name = re.sub("([a-z0-9])([A-Z])", r"\1_\2", name) + return name.lower() + + +def _to_attrdict(obj): + if isinstance(obj, dict): + return AttrDict(obj) + if isinstance(obj, list): + return [_to_attrdict(elem) for elem in obj] + return obj + + +class AttrDict(dict): + """Dictionary that allows accessing values usin the "dot notation" as attributes.""" + + def __init__(self, *args, **kwargs) -> None: + super().__init__( + { + _camel_to_snake(key): _to_attrdict(value) + for key, value in dict(*args, **kwargs).items() + } + ) + + def __getattr__(self, attr): + if attr in self: + return self[attr] + raise AttributeError("Attribute not found: " + str(attr)) + + def __setattr__(self, attr, val): + if attr in self: + raise AttributeError("Attribute-style access is read only") + super().__setattr__(attr, val) + + +async def run_client_cli( + hooks: Optional[Iterable[Tuple[Callable, Union[type, "EventFilter"]]]] = None, + argv: Optional[list] = None, + **kwargs +) -> None: + """Run a simple command line app, using the given hooks. + + Extra keyword arguments are passed to the internal Rpc object. + """ + from .client import Client + + await _run_cli(Client, hooks, argv, **kwargs) + + +async def run_bot_cli( + hooks: Optional[Iterable[Tuple[Callable, Union[type, "EventFilter"]]]] = None, + argv: Optional[list] = None, + **kwargs +) -> None: + """Run a simple bot command line using the given hooks. + + Extra keyword arguments are passed to the internal Rpc object. + """ + from .client import Bot + + await _run_cli(Bot, hooks, argv, **kwargs) + + +async def _run_cli( + client_type: Type["Client"], + hooks: Optional[Iterable[Tuple[Callable, Union[type, "EventFilter"]]]] = None, + argv: Optional[list] = None, + **kwargs +) -> None: + from .deltachat import DeltaChat + from .rpc import Rpc + + if argv is None: + argv = sys.argv + + parser = argparse.ArgumentParser(prog=argv[0] if argv else None) + parser.add_argument( + "accounts_dir", + help="accounts folder (default: current working directory)", + nargs="?", + ) + parser.add_argument("--email", action="store", help="email address") + parser.add_argument("--password", action="store", help="password") + args = parser.parse_args(argv[1:]) + + async with Rpc(accounts_dir=args.accounts_dir, **kwargs) as rpc: + deltachat = DeltaChat(rpc) + core_version = (await deltachat.get_system_info()).deltachat_core_version + accounts = await deltachat.get_all_accounts() + account = accounts[0] if accounts else await deltachat.add_account() + + client = client_type(account, hooks) + client.logger.debug("Running deltachat core %s", core_version) + if not await client.is_configured(): + assert ( + args.email and args.password + ), "Account is not configured and email and password must be provided" + asyncio.create_task( + client.configure(email=args.email, password=args.password) + ) + await client.run_forever() diff --git a/deltachat-rpc-client/tests/test_something.py b/deltachat-rpc-client/tests/test_something.py index bfce9107e..6d8136058 100644 --- a/deltachat-rpc-client/tests/test_something.py +++ b/deltachat-rpc-client/tests/test_something.py @@ -1,5 +1,8 @@ import pytest +from deltachat_rpc_client import AttrDict, EventType, events +from deltachat_rpc_client.rpc import JsonRpcError + @pytest.mark.asyncio async def test_system_info(rpc) -> None: @@ -27,12 +30,9 @@ async def test_acfactory(acfactory) -> None: account = await acfactory.new_configured_account() while True: event = await account.wait_for_event() - if event["type"] == "ConfigureProgress": - # Progress 0 indicates error. - assert event["progress"] != 0 - - if event["progress"] == 1000: - # Success. + if event.type == EventType.CONFIGURE_PROGRESS: + assert event.progress != 0 # Progress 0 indicates error. + if event.progress == 1000: # Success break else: print(event) @@ -40,21 +40,207 @@ async def test_acfactory(acfactory) -> None: @pytest.mark.asyncio -async def test_object_account(acfactory) -> None: +async def test_account(acfactory) -> None: alice, bob = await acfactory.get_online_accounts(2) - alice_contact_bob = await alice.create_contact(await bob.get_config("addr"), "Bob") + bob_addr = await bob.get_config("addr") + alice_contact_bob = await alice.create_contact(bob_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"] + if event.type == EventType.INCOMING_MSG: + chat_id = event.chat_id + msg_id = event.msg_id break - rpc = acfactory.deltachat.rpc - message = await rpc.get_message(bob.account_id, msg_id) - assert message["chatId"] == chat_id - assert message["text"] == "Hello!" + message = await bob.get_message_by_id(msg_id) + snapshot = await message.get_snapshot() + assert snapshot.chat_id == chat_id + assert snapshot.text == "Hello!" + await bob.mark_seen_messages([message]) + + assert alice != bob + assert repr(alice) + assert (await alice.get_info()).level + assert await alice.get_size() + assert await alice.is_configured() + assert not await alice.get_avatar() + assert await alice.get_contact_by_addr(bob_addr) == alice_contact_bob + assert await alice.get_contacts() + assert await alice.get_contacts(snapshot=True) + assert alice.self_contact + assert await alice.get_chatlist() + assert await alice.get_chatlist(snapshot=True) + assert await alice.get_qr_code() + await alice.get_fresh_messages() + await alice.get_fresh_messages_in_arrival_order() + + group = await alice.create_group("test group") + await group.add_contact(alice_contact_bob) + group_msg = await group.send_message(text="hello") + assert group_msg == await alice.get_message_by_id(group_msg.id) + assert group == await alice.get_chat_by_id(group.id) + await alice.delete_messages([group_msg]) + + await alice.set_config("selfstatus", "test") + assert await alice.get_config("selfstatus") == "test" + await alice.update_config(selfstatus="test2") + assert await alice.get_config("selfstatus") == "test2" + + assert not await alice.get_blocked_contacts() + await alice_contact_bob.block() + blocked_contacts = await alice.get_blocked_contacts() + assert blocked_contacts + assert blocked_contacts[0].contact == alice_contact_bob + + await bob.remove() + await alice.stop_io() + + +@pytest.mark.asyncio +async def test_chat(acfactory) -> None: + alice, bob = await acfactory.get_online_accounts(2) + + bob_addr = await bob.get_config("addr") + alice_contact_bob = await alice.create_contact(bob_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 == EventType.INCOMING_MSG: + chat_id = event.chat_id + msg_id = event.msg_id + break + message = await bob.get_message_by_id(msg_id) + snapshot = await message.get_snapshot() + assert snapshot.chat_id == chat_id + assert snapshot.text == "Hello!" + bob_chat_alice = await bob.get_chat_by_id(chat_id) + + assert alice_chat_bob != bob_chat_alice + assert repr(alice_chat_bob) + await alice_chat_bob.delete() + await bob_chat_alice.accept() + await bob_chat_alice.block() + bob_chat_alice = await snapshot.sender.create_chat() + await bob_chat_alice.mute() + await bob_chat_alice.unmute() + await bob_chat_alice.pin() + await bob_chat_alice.unpin() + await bob_chat_alice.archive() + await bob_chat_alice.unarchive() + with pytest.raises(JsonRpcError): # can't set name for 1:1 chats + await bob_chat_alice.set_name("test") + await bob_chat_alice.set_ephemeral_timer(300) + await bob_chat_alice.get_encryption_info() + + group = await alice.create_group("test group") + await group.add_contact(alice_contact_bob) + await group.get_qr_code() + + snapshot = await group.get_basic_snapshot() + assert snapshot.name == "test group" + await group.set_name("new name") + snapshot = await group.get_full_snapshot() + assert snapshot.name == "new name" + + msg = await group.send_message(text="hi") + assert (await msg.get_snapshot()).text == "hi" + await group.forward_messages([msg]) + + await group.set_draft(text="test draft") + draft = await group.get_draft() + assert draft.text == "test draft" + await group.remove_draft() + assert not await group.get_draft() + + assert await group.get_messages() + await group.get_fresh_message_count() + await group.mark_noticed() + assert await group.get_contacts() + await group.remove_contact(alice_chat_bob) + await group.get_locations() + + +@pytest.mark.asyncio +async def test_contact(acfactory) -> None: + alice, bob = await acfactory.get_online_accounts(2) + + bob_addr = await bob.get_config("addr") + alice_contact_bob = await alice.create_contact(bob_addr, "Bob") + + assert alice_contact_bob == await alice.get_contact_by_id(alice_contact_bob.id) + assert repr(alice_contact_bob) + await alice_contact_bob.block() + await alice_contact_bob.unblock() + await alice_contact_bob.set_name("new name") + await alice_contact_bob.get_encryption_info() + snapshot = await alice_contact_bob.get_snapshot() + assert snapshot.address == bob_addr + await alice_contact_bob.create_chat() + + +@pytest.mark.asyncio +async def test_message(acfactory) -> None: + alice, bob = await acfactory.get_online_accounts(2) + + bob_addr = await bob.get_config("addr") + alice_contact_bob = await alice.create_contact(bob_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 == EventType.INCOMING_MSG: + chat_id = event.chat_id + msg_id = event.msg_id + break + + message = await bob.get_message_by_id(msg_id) + snapshot = await message.get_snapshot() + assert snapshot.chat_id == chat_id + assert snapshot.text == "Hello!" + assert repr(message) + + with pytest.raises(JsonRpcError): # chat is not accepted + await snapshot.chat.send_text("hi") + await snapshot.chat.accept() + await snapshot.chat.send_text("hi") + + await message.mark_seen() + await message.send_reaction("😎") + + +@pytest.mark.asyncio +async def test_bot(acfactory) -> None: + async def callback(e): + res.append(e) + + res = [] + bot = await acfactory.new_configured_bot() + assert await bot.is_configured() + assert await bot.account.get_config("bot") == "1" + + bot.add_hook(callback, events.RawEvent(EventType.INFO)) + info_event = AttrDict(account=bot.account, type=EventType.INFO, msg="info") + warn_event = AttrDict(account=bot.account, type=EventType.WARNING, msg="warning") + await bot._on_event(info_event) + await bot._on_event(warn_event) + assert info_event in res + assert warn_event not in res + assert len(res) == 1 + + res = [] + bot.add_hook(callback, events.NewMessage(r"hello")) + snapshot1 = AttrDict(text="hello") + snapshot2 = AttrDict(text="hello, world") + snapshot3 = AttrDict(text="hey!") + for snapshot in [snapshot1, snapshot2, snapshot3]: + await bot._on_event(snapshot, events.NewMessage) + assert len(res) == 2 + assert snapshot1 in res + assert snapshot2 in res + assert snapshot3 not in res