From be63e18ebf2d6ddf7a02a8c39839d7a51b843fe4 Mon Sep 17 00:00:00 2001 From: adbenitez Date: Sat, 10 Dec 2022 19:22:57 -0500 Subject: [PATCH 1/3] improve hook filters --- deltachat-rpc-client/examples/echobot.py | 4 +- .../examples/echobot_advanced.py | 13 +++--- .../src/deltachat_rpc_client/client.py | 46 ++++++++++++++++--- .../src/deltachat_rpc_client/const.py | 2 + .../src/deltachat_rpc_client/events.py | 32 ++++++++++--- deltachat-rpc-client/tests/test_something.py | 15 ++++-- 6 files changed, 87 insertions(+), 25 deletions(-) diff --git a/deltachat-rpc-client/examples/echobot.py b/deltachat-rpc-client/examples/echobot.py index 792dbae86..c77b422d2 100755 --- a/deltachat-rpc-client/examples/echobot.py +++ b/deltachat-rpc-client/examples/echobot.py @@ -17,8 +17,8 @@ async def log_event(event): @hooks.on(events.NewMessage) -async def echo(msg): - await msg.chat.send_text(msg.text) +async def echo(event): + await event.chat.send_text(event.text) if __name__ == "__main__": diff --git a/deltachat-rpc-client/examples/echobot_advanced.py b/deltachat-rpc-client/examples/echobot_advanced.py index 88ddd1303..28b1750b6 100644 --- a/deltachat-rpc-client/examples/echobot_advanced.py +++ b/deltachat-rpc-client/examples/echobot_advanced.py @@ -25,14 +25,15 @@ 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(func=lambda e: not e.command)) +async def echo(event): + if event.text or event.file: + await event.chat.send_message(text=event.text, file=event.file) -@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") +@hooks.on(events.NewMessage(command="/help")) +async def help_command(event): + await event.chat.send_text("Send me any message and I will echo it back") async def main(): diff --git a/deltachat-rpc-client/src/deltachat_rpc_client/client.py b/deltachat-rpc-client/src/deltachat_rpc_client/client.py index 2a749eefe..6abcc8a59 100644 --- a/deltachat-rpc-client/src/deltachat_rpc_client/client.py +++ b/deltachat-rpc-client/src/deltachat_rpc_client/client.py @@ -4,8 +4,8 @@ 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 .const import COMMAND_PREFIX, EventType +from .events import EventFilter, NewMessage, RawEvent from .utils import AttrDict @@ -79,16 +79,48 @@ class Client: self.logger.exception(ex) def _should_process_messages(self) -> bool: - return any(issubclass(filter_type, NewMessage) for filter_type in self._hooks) + return NewMessage in self._hooks + + async def _parse_command(self, snapshot: AttrDict) -> None: + cmds = [ + hook[1].command + for hook in self._hooks.get(NewMessage, []) + if hook[1].command + ] + parts = snapshot.text.split(maxsplit=1) + payload = parts[1] if len(parts) > 1 else "" + cmd = parts.pop(0) + + if "@" in cmd: + suffix = "@" + (await self.account.self_contact.get_snapshot()).address + if cmd.endswith(suffix): + cmd = cmd[: -len(suffix)] + else: + return + + parts = cmd.split("_") + _payload = payload + while parts: + _cmd = "_".join(parts) + if _cmd in cmds: + break + _payload = (parts.pop() + " " + _payload).rstrip() + + if parts: + cmd = _cmd + payload = _payload + + snapshot["command"] = cmd + snapshot["payload"] = payload 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) + snapshot["command"], snapshot["payload"] = "", "" + if not snapshot.is_info and snapshot.text.startswith(COMMAND_PREFIX): + await self._parse_command(snapshot) + await self._on_event(snapshot, NewMessage) await snapshot.message.mark_seen() diff --git a/deltachat-rpc-client/src/deltachat_rpc_client/const.py b/deltachat-rpc-client/src/deltachat_rpc_client/const.py index c8bb925fc..b2a4e7f12 100644 --- a/deltachat-rpc-client/src/deltachat_rpc_client/const.py +++ b/deltachat-rpc-client/src/deltachat_rpc_client/const.py @@ -1,5 +1,7 @@ from enum import Enum, IntEnum +COMMAND_PREFIX = "/" + class ContactFlag(IntEnum): VERIFIED_ONLY = 0x01 diff --git a/deltachat-rpc-client/src/deltachat_rpc_client/events.py b/deltachat-rpc-client/src/deltachat_rpc_client/events.py index 606fe9896..7654bdf8b 100644 --- a/deltachat-rpc-client/src/deltachat_rpc_client/events.py +++ b/deltachat-rpc-client/src/deltachat_rpc_client/events.py @@ -91,8 +91,15 @@ class RawEvent(EventFilter): class NewMessage(EventFilter): """Matches whenever a new message arrives. - Warning: registering a handler for this event or any subclass will cause the messages + Warning: registering a handler for this event will cause the messages to be marked as read. Its usage is mainly intended for bots. + + :param pattern: if set, this Pattern will be used to filter the message by its text + content. + :param command: If set, only match messages with the given command (ex. /help). + :param is_info: If set to True only match info/system messages, if set to False + only match messages that are not info/system messages. If omitted + info/system messages as well as normal messages will be matched. """ def __init__( @@ -103,9 +110,15 @@ class NewMessage(EventFilter): Callable[[str], bool], re.Pattern, ] = None, + command: Optional[str] = None, + is_info: Optional[bool] = None, func: Optional[Callable[[AttrDict], bool]] = None, ) -> None: super().__init__(func=func) + self.is_info = is_info + if command is not None and not isinstance(command, str): + raise TypeError("Invalid command") + self.command = command if isinstance(pattern, str): pattern = re.compile(pattern) if isinstance(pattern, re.Pattern): @@ -119,11 +132,20 @@ class NewMessage(EventFilter): 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) + if isinstance(other, NewMessage): + return (self.pattern, self.command, self.is_info, self.func) == ( + other.pattern, + other.command, + other.is_info, + other.func, + ) return False async def filter(self, event: AttrDict) -> bool: + if self.is_info is not None and self.is_info != event.is_info: + return False + if self.command and self.command != event.command: + return False if self.pattern: match = self.pattern(event.text) if inspect.isawaitable(match): @@ -133,10 +155,6 @@ class NewMessage(EventFilter): 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. diff --git a/deltachat-rpc-client/tests/test_something.py b/deltachat-rpc-client/tests/test_something.py index 6d8136058..7c265f1ec 100644 --- a/deltachat-rpc-client/tests/test_something.py +++ b/deltachat-rpc-client/tests/test_something.py @@ -235,12 +235,21 @@ async def test_bot(acfactory) -> None: res = [] bot.add_hook(callback, events.NewMessage(r"hello")) - snapshot1 = AttrDict(text="hello") - snapshot2 = AttrDict(text="hello, world") - snapshot3 = AttrDict(text="hey!") + bot.add_hook(callback, events.NewMessage(command="/help")) + snapshot1 = AttrDict(text="hello", command=None) + snapshot2 = AttrDict(text="hello, world", command=None) + snapshot3 = AttrDict(text="hey!", command=None) 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 + + res = [] + bot.remove_hook(callback, events.NewMessage(r"hello")) + snapshot4 = AttrDict(command="/help") + await bot._on_event(snapshot, events.NewMessage) + await bot._on_event(snapshot4, events.NewMessage) + assert len(res) == 1 + assert snapshot4 in res From 2ebd3f54e61b22d2170dbc3e5a4ddc12e58fc3a6 Mon Sep 17 00:00:00 2001 From: adbenitez Date: Sun, 11 Dec 2022 03:31:29 -0500 Subject: [PATCH 2/3] add Client.run_until() --- .../src/deltachat_rpc_client/client.py | 31 +++++++- .../src/deltachat_rpc_client/pytestplugin.py | 46 ++++++++++-- deltachat-rpc-client/tests/test_something.py | 70 +++++++++++-------- 3 files changed, 111 insertions(+), 36 deletions(-) diff --git a/deltachat-rpc-client/src/deltachat_rpc_client/client.py b/deltachat-rpc-client/src/deltachat_rpc_client/client.py index 6abcc8a59..f04e0d1e1 100644 --- a/deltachat-rpc-client/src/deltachat_rpc_client/client.py +++ b/deltachat-rpc-client/src/deltachat_rpc_client/client.py @@ -1,6 +1,17 @@ """Event loop implementations offering high level event handling/hooking.""" +import inspect import logging -from typing import Callable, Dict, Iterable, Optional, Set, Tuple, Type, Union +from typing import ( + Callable, + Coroutine, + Dict, + Iterable, + Optional, + Set, + Tuple, + Type, + Union, +) from deltachat_rpc_client.account import Account @@ -56,6 +67,18 @@ class Client: self.logger.debug("Account configured") async def run_forever(self) -> None: + """Process events forever.""" + await self.run_until(lambda _: False) + + async def run_until( + self, func: Callable[[AttrDict], Union[bool, Coroutine]] + ) -> AttrDict: + """Process events until the given callable evaluates to True. + + The callable should accept an AttrDict object representing the + last processed event. The event is returned when the callable + evaluates to True. + """ self.logger.debug("Listening to incoming events...") if await self.is_configured(): await self.account.start_io() @@ -68,6 +91,12 @@ class Client: if event.type == EventType.INCOMING_MSG: await self._process_messages() + stop = func(event) + if inspect.isawaitable(stop): + stop = await stop + if stop: + return event + async def _on_event( self, event: AttrDict, filter_type: Type[EventFilter] = RawEvent ) -> None: diff --git a/deltachat-rpc-client/src/deltachat_rpc_client/pytestplugin.py b/deltachat-rpc-client/src/deltachat_rpc_client/pytestplugin.py index e8cce5f4c..0d8aca5d0 100644 --- a/deltachat-rpc-client/src/deltachat_rpc_client/pytestplugin.py +++ b/deltachat-rpc-client/src/deltachat_rpc_client/pytestplugin.py @@ -1,13 +1,11 @@ import json import os -from typing import AsyncGenerator, List +from typing import AsyncGenerator, List, Optional import aiohttp import pytest_asyncio -from .account import Account -from .client import Bot -from .deltachat import DeltaChat +from . import Account, AttrDict, Bot, Client, DeltaChat, EventType, Message from .rpc import Rpc @@ -51,6 +49,46 @@ class ACFactory: await account.start_io() return accounts + async def send_message( + self, + to_account: Account, + from_account: Optional[Account] = None, + text: Optional[str] = None, + file: Optional[str] = None, + group: Optional[str] = None, + ) -> Message: + if not from_account: + from_account = (await self.get_online_accounts(1))[0] + to_contact = await from_account.create_contact( + await to_account.get_config("addr") + ) + if group: + to_chat = await from_account.create_group(group) + await to_chat.add_contact(to_contact) + else: + to_chat = await to_contact.create_chat() + return await to_chat.send_message(text=text, file=file) + + async def process_message( + self, + to_client: Client, + from_account: Optional[Account] = None, + text: Optional[str] = None, + file: Optional[str] = None, + group: Optional[str] = None, + ) -> AttrDict: + await self.send_message( + to_account=to_client.account, + from_account=from_account, + text=text, + file=file, + group=group, + ) + + event = await to_client.run_until(lambda e: e.type == EventType.INCOMING_MSG) + msg = await to_client.account.get_message_by_id(event.msg_id) + return await msg.get_snapshot() + @pytest_asyncio.fixture async def rpc(tmp_path) -> AsyncGenerator: diff --git a/deltachat-rpc-client/tests/test_something.py b/deltachat-rpc-client/tests/test_something.py index 7c265f1ec..1b2b3625e 100644 --- a/deltachat-rpc-client/tests/test_something.py +++ b/deltachat-rpc-client/tests/test_something.py @@ -1,6 +1,8 @@ +from unittest.mock import MagicMock + import pytest -from deltachat_rpc_client import AttrDict, EventType, events +from deltachat_rpc_client import EventType, events from deltachat_rpc_client.rpc import JsonRpcError @@ -216,40 +218,46 @@ async def test_message(acfactory) -> None: @pytest.mark.asyncio async def test_bot(acfactory) -> None: - async def callback(e): - res.append(e) + def track(key): + async def wrapper(e): + mock.hook(e[key]) - res = [] + return wrapper + + mock = MagicMock() + user = (await acfactory.get_online_accounts(1))[0] 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 + hook = track("msg_id"), events.RawEvent(EventType.INCOMING_MSG) + bot.add_hook(*hook) + event = await acfactory.process_message( + from_account=user, to_client=bot, text="Hello!" + ) + mock.hook.assert_called_once_with(event.id) + bot.remove_hook(*hook) - res = [] - bot.add_hook(callback, events.NewMessage(r"hello")) - bot.add_hook(callback, events.NewMessage(command="/help")) - snapshot1 = AttrDict(text="hello", command=None) - snapshot2 = AttrDict(text="hello, world", command=None) - snapshot3 = AttrDict(text="hey!", command=None) - 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 + mock.hook.reset_mock() + hook = track("id"), events.NewMessage(r"hello") + bot.add_hook(*hook) + bot.add_hook(track("id"), events.NewMessage(command="/help")) + event = await acfactory.process_message( + from_account=user, to_client=bot, text="hello" + ) + mock.hook.assert_called_with(event.id) + event = await acfactory.process_message( + from_account=user, to_client=bot, text="hello!" + ) + mock.hook.assert_called_with(event.id) + await acfactory.process_message(from_account=user, to_client=bot, text="hey!") + assert len(mock.hook.mock_calls) == 2 + bot.remove_hook(*hook) - res = [] - bot.remove_hook(callback, events.NewMessage(r"hello")) - snapshot4 = AttrDict(command="/help") - await bot._on_event(snapshot, events.NewMessage) - await bot._on_event(snapshot4, events.NewMessage) - assert len(res) == 1 - assert snapshot4 in res + mock.hook.reset_mock() + await acfactory.process_message(from_account=user, to_client=bot, text="hello") + event = await acfactory.process_message( + from_account=user, to_client=bot, text="/help" + ) + mock.hook.assert_called_once_with(event.id) From adf754ad3232e4f14cd3db0142c8f3fe9f53fa01 Mon Sep 17 00:00:00 2001 From: adbenitez Date: Wed, 21 Dec 2022 13:25:46 -0500 Subject: [PATCH 3/3] add more high-level event filters --- deltachat-rpc-client/examples/echobot.py | 3 +- .../examples/echobot_advanced.py | 25 ++++- .../src/deltachat_rpc_client/__init__.py | 2 +- .../{utils.py => _utils.py} | 63 ++++++++++++ .../src/deltachat_rpc_client/account.py | 2 +- .../src/deltachat_rpc_client/chat.py | 2 +- .../src/deltachat_rpc_client/client.py | 88 ++++++++++++++--- .../src/deltachat_rpc_client/contact.py | 2 +- .../src/deltachat_rpc_client/deltachat.py | 2 +- .../src/deltachat_rpc_client/events.py | 99 ++++++++++++++++++- .../src/deltachat_rpc_client/message.py | 2 +- .../src/deltachat_rpc_client/pytestplugin.py | 4 +- deltachat-rpc-client/tests/test_something.py | 22 ++--- 13 files changed, 272 insertions(+), 44 deletions(-) rename deltachat-rpc-client/src/deltachat_rpc_client/{utils.py => _utils.py} (63%) diff --git a/deltachat-rpc-client/examples/echobot.py b/deltachat-rpc-client/examples/echobot.py index c77b422d2..65d447bf9 100755 --- a/deltachat-rpc-client/examples/echobot.py +++ b/deltachat-rpc-client/examples/echobot.py @@ -18,7 +18,8 @@ async def log_event(event): @hooks.on(events.NewMessage) async def echo(event): - await event.chat.send_text(event.text) + snapshot = event.message_snapshot + await snapshot.chat.send_text(snapshot.text) if __name__ == "__main__": diff --git a/deltachat-rpc-client/examples/echobot_advanced.py b/deltachat-rpc-client/examples/echobot_advanced.py index 28b1750b6..48e3d025b 100644 --- a/deltachat-rpc-client/examples/echobot_advanced.py +++ b/deltachat-rpc-client/examples/echobot_advanced.py @@ -25,15 +25,34 @@ async def log_error(event): logging.error(event.msg) +@hooks.on(events.MemberListChanged) +async def on_memberlist_changed(event): + logging.info( + "member %s was %s", event.member, "added" if event.member_added else "removed" + ) + + +@hooks.on(events.GroupImageChanged) +async def on_group_image_changed(event): + logging.info("group image %s", "deleted" if event.image_deleted else "changed") + + +@hooks.on(events.GroupNameChanged) +async def on_group_name_changed(event): + logging.info("group name changed, old name: %s", event.old_name) + + @hooks.on(events.NewMessage(func=lambda e: not e.command)) async def echo(event): - if event.text or event.file: - await event.chat.send_message(text=event.text, file=event.file) + snapshot = event.message_snapshot + if snapshot.text or snapshot.file: + await snapshot.chat.send_message(text=snapshot.text, file=snapshot.file) @hooks.on(events.NewMessage(command="/help")) async def help_command(event): - await event.chat.send_text("Send me any message and I will echo it back") + snapshot = event.message_snapshot + await snapshot.chat.send_text("Send me any message and I will echo it back") async def main(): diff --git a/deltachat-rpc-client/src/deltachat_rpc_client/__init__.py b/deltachat-rpc-client/src/deltachat_rpc_client/__init__.py index a62ae5f63..ff15923b5 100644 --- a/deltachat-rpc-client/src/deltachat_rpc_client/__init__.py +++ b/deltachat-rpc-client/src/deltachat_rpc_client/__init__.py @@ -1,4 +1,5 @@ """Delta Chat asynchronous high-level API""" +from ._utils import AttrDict, run_bot_cli, run_client_cli from .account import Account from .chat import Chat from .client import Bot, Client @@ -7,4 +8,3 @@ 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/utils.py b/deltachat-rpc-client/src/deltachat_rpc_client/_utils.py similarity index 63% rename from deltachat-rpc-client/src/deltachat_rpc_client/utils.py rename to deltachat-rpc-client/src/deltachat_rpc_client/_utils.py index f7782ad8b..562c62de1 100644 --- a/deltachat-rpc-client/src/deltachat_rpc_client/utils.py +++ b/deltachat-rpc-client/src/deltachat_rpc_client/_utils.py @@ -17,6 +17,8 @@ def _camel_to_snake(name: str) -> str: def _to_attrdict(obj): + if isinstance(obj, AttrDict): + return obj if isinstance(obj, dict): return AttrDict(obj) if isinstance(obj, list): @@ -112,3 +114,64 @@ async def _run_cli( client.configure(email=args.email, password=args.password) ) await client.run_forever() + + +def extract_addr(text: str) -> str: + """extract email address from the given text.""" + match = re.match(r".*\((.+@.+)\)", text) + if match: + text = match.group(1) + text = text.rstrip(".") + return text.strip() + + +def parse_system_image_changed(text: str) -> Optional[Tuple[str, bool]]: + """return image changed/deleted info from parsing the given system message text.""" + text = text.lower() + match = re.match(r"group image (changed|deleted) by (.+).", text) + if match: + action, actor = match.groups() + return (extract_addr(actor), action == "deleted") + return None + + +def parse_system_title_changed(text: str) -> Optional[Tuple[str, str]]: + text = text.lower() + match = re.match(r'group name changed from "(.+)" to ".+" by (.+).', text) + if match: + old_title, actor = match.groups() + return (extract_addr(actor), old_title) + return None + + +def parse_system_add_remove(text: str) -> Optional[Tuple[str, str, str]]: + """return add/remove info from parsing the given system message text. + + returns a (action, affected, actor) tuple. + """ + # You removed member a@b. + # You added member a@b. + # Member Me (x@y) removed by a@b. + # Member x@y added by a@b + # Member With space (tmp1@x.org) removed by tmp2@x.org. + # Member With space (tmp1@x.org) removed by Another member (tmp2@x.org).", + # Group left by some one (tmp1@x.org). + # Group left by tmp1@x.org. + text = text.lower() + + match = re.match(r"member (.+) (removed|added) by (.+)", text) + if match: + affected, action, actor = match.groups() + return action, extract_addr(affected), extract_addr(actor) + + match = re.match(r"you (removed|added) member (.+)", text) + if match: + action, affected = match.groups() + return action, extract_addr(affected), "me" + + if text.startswith("group left by "): + addr = extract_addr(text[13:]) + if addr: + return "removed", addr, addr + + return None diff --git a/deltachat-rpc-client/src/deltachat_rpc_client/account.py b/deltachat-rpc-client/src/deltachat_rpc_client/account.py index d75ef67dd..9d9d92e41 100644 --- a/deltachat-rpc-client/src/deltachat_rpc_client/account.py +++ b/deltachat-rpc-client/src/deltachat_rpc_client/account.py @@ -1,11 +1,11 @@ from typing import TYPE_CHECKING, List, Optional, Tuple, Union +from ._utils import AttrDict 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 diff --git a/deltachat-rpc-client/src/deltachat_rpc_client/chat.py b/deltachat-rpc-client/src/deltachat_rpc_client/chat.py index 9e053b1fc..4a2f4ae77 100644 --- a/deltachat-rpc-client/src/deltachat_rpc_client/chat.py +++ b/deltachat-rpc-client/src/deltachat_rpc_client/chat.py @@ -2,11 +2,11 @@ import calendar from datetime import datetime from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union +from ._utils import AttrDict from .const import ChatVisibility from .contact import Contact from .message import Message from .rpc import Rpc -from .utils import AttrDict if TYPE_CHECKING: from .account import Account diff --git a/deltachat-rpc-client/src/deltachat_rpc_client/client.py b/deltachat-rpc-client/src/deltachat_rpc_client/client.py index f04e0d1e1..4c6deafa1 100644 --- a/deltachat-rpc-client/src/deltachat_rpc_client/client.py +++ b/deltachat-rpc-client/src/deltachat_rpc_client/client.py @@ -15,9 +15,21 @@ from typing import ( from deltachat_rpc_client.account import Account -from .const import COMMAND_PREFIX, EventType -from .events import EventFilter, NewMessage, RawEvent -from .utils import AttrDict +from ._utils import ( + AttrDict, + parse_system_add_remove, + parse_system_image_changed, + parse_system_title_changed, +) +from .const import COMMAND_PREFIX, EventType, SystemMessageType +from .events import ( + EventFilter, + GroupImageChanged, + GroupNameChanged, + MemberListChanged, + NewMessage, + RawEvent, +) class Client: @@ -32,6 +44,7 @@ class Client: self.account = account self.logger = logger or logging self._hooks: Dict[type, Set[tuple]] = {} + self._should_process_messages = 0 self.add_hooks(hooks or []) def add_hooks( @@ -47,12 +60,24 @@ class Client: if isinstance(event, type): event = event() assert isinstance(event, EventFilter) + self._should_process_messages += int( + isinstance( + event, + (NewMessage, MemberListChanged, GroupImageChanged, GroupNameChanged), + ) + ) 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._should_process_messages -= int( + isinstance( + event, + (NewMessage, MemberListChanged, GroupImageChanged, GroupNameChanged), + ) + ) self._hooks.get(type(event), set()).remove((hook, event)) async def is_configured(self) -> bool: @@ -107,16 +132,13 @@ class Client: except Exception as ex: self.logger.exception(ex) - def _should_process_messages(self) -> bool: - return NewMessage in self._hooks - - async def _parse_command(self, snapshot: AttrDict) -> None: + async def _parse_command(self, event: AttrDict) -> None: cmds = [ hook[1].command for hook in self._hooks.get(NewMessage, []) if hook[1].command ] - parts = snapshot.text.split(maxsplit=1) + parts = event.message_snapshot.text.split(maxsplit=1) payload = parts[1] if len(parts) > 1 else "" cmd = parts.pop(0) @@ -139,17 +161,53 @@ class Client: cmd = _cmd payload = _payload - snapshot["command"] = cmd - snapshot["payload"] = payload + event["command"], event["payload"] = cmd, payload + + async def _on_new_msg(self, snapshot: AttrDict) -> None: + event = AttrDict(command="", payload="", message_snapshot=snapshot) + if not snapshot.is_info and snapshot.text.startswith(COMMAND_PREFIX): + await self._parse_command(event) + await self._on_event(event, NewMessage) + + async def _handle_info_msg(self, snapshot: AttrDict) -> None: + event = AttrDict(message_snapshot=snapshot) + + img_changed = parse_system_image_changed(snapshot.text) + if img_changed: + _, event["image_deleted"] = img_changed + await self._on_event(event, GroupImageChanged) + return + + title_changed = parse_system_title_changed(snapshot.text) + if title_changed: + _, event["old_name"] = title_changed + await self._on_event(event, GroupNameChanged) + return + + members_changed = parse_system_add_remove(snapshot.text) + if members_changed: + action, event["member"], _ = members_changed + event["member_added"] = action == "added" + await self._on_event(event, MemberListChanged) + return + + self.logger.warning( + "ignoring unsupported system message id=%s text=%s", + snapshot.id, + snapshot.text, + ) async def _process_messages(self) -> None: - if self._should_process_messages(): + if self._should_process_messages: for message in await self.account.get_fresh_messages_in_arrival_order(): snapshot = await message.get_snapshot() - snapshot["command"], snapshot["payload"] = "", "" - if not snapshot.is_info and snapshot.text.startswith(COMMAND_PREFIX): - await self._parse_command(snapshot) - await self._on_event(snapshot, NewMessage) + await self._on_new_msg(snapshot) + if ( + snapshot.is_info + and snapshot.system_message_type + != SystemMessageType.WEBXDC_INFO_MESSAGE + ): + await self._handle_info_msg(snapshot) await snapshot.message.mark_seen() diff --git a/deltachat-rpc-client/src/deltachat_rpc_client/contact.py b/deltachat-rpc-client/src/deltachat_rpc_client/contact.py index 804ac4af1..7c5267b4f 100644 --- a/deltachat-rpc-client/src/deltachat_rpc_client/contact.py +++ b/deltachat-rpc-client/src/deltachat_rpc_client/contact.py @@ -1,7 +1,7 @@ from typing import TYPE_CHECKING +from ._utils import AttrDict from .rpc import Rpc -from .utils import AttrDict if TYPE_CHECKING: from .account import Account diff --git a/deltachat-rpc-client/src/deltachat_rpc_client/deltachat.py b/deltachat-rpc-client/src/deltachat_rpc_client/deltachat.py index 2c926bac5..16afe458b 100644 --- a/deltachat-rpc-client/src/deltachat_rpc_client/deltachat.py +++ b/deltachat-rpc-client/src/deltachat_rpc_client/deltachat.py @@ -1,8 +1,8 @@ from typing import Dict, List +from ._utils import AttrDict from .account import Account from .rpc import Rpc -from .utils import AttrDict class DeltaChat: diff --git a/deltachat-rpc-client/src/deltachat_rpc_client/events.py b/deltachat-rpc-client/src/deltachat_rpc_client/events.py index 7654bdf8b..146c89ea5 100644 --- a/deltachat-rpc-client/src/deltachat_rpc_client/events.py +++ b/deltachat-rpc-client/src/deltachat_rpc_client/events.py @@ -4,8 +4,8 @@ import re from abc import ABC, abstractmethod from typing import Callable, Iterable, Iterator, Optional, Set, Tuple, Union +from ._utils import AttrDict from .const import EventType -from .utils import AttrDict def _tuple_of(obj, type_: type) -> tuple: @@ -97,9 +97,13 @@ class NewMessage(EventFilter): :param pattern: if set, this Pattern will be used to filter the message by its text content. :param command: If set, only match messages with the given command (ex. /help). + Setting this property implies `is_info==False`. :param is_info: If set to True only match info/system messages, if set to False only match messages that are not info/system messages. If omitted info/system messages as well as normal messages will be matched. + :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__( @@ -119,6 +123,8 @@ class NewMessage(EventFilter): if command is not None and not isinstance(command, str): raise TypeError("Invalid command") self.command = command + if self.is_info and self.command: + raise AttributeError("Can not use command and is_info at the same time.") if isinstance(pattern, str): pattern = re.compile(pattern) if isinstance(pattern, re.Pattern): @@ -142,12 +148,12 @@ class NewMessage(EventFilter): return False async def filter(self, event: AttrDict) -> bool: - if self.is_info is not None and self.is_info != event.is_info: + if self.is_info is not None and self.is_info != event.message_snapshot.is_info: return False if self.command and self.command != event.command: return False if self.pattern: - match = self.pattern(event.text) + match = self.pattern(event.message_snapshot.text) if inspect.isawaitable(match): match = await match if not match: @@ -155,6 +161,93 @@ class NewMessage(EventFilter): return await super()._call_func(event) +class MemberListChanged(EventFilter): + """Matches when a group member is added or removed. + + Warning: registering a handler for this event will cause the messages + to be marked as read. Its usage is mainly intended for bots. + + :param added: If set to True only match if a member was added, if set to False + only match if a member was removed. If omitted both, member additions + and removals, will be matched. + :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, added: Optional[bool] = None, **kwargs): + super().__init__(**kwargs) + self.added = added + + def __hash__(self) -> int: + return hash((self.added, self.func)) + + def __eq__(self, other) -> bool: + if isinstance(other, MemberListChanged): + return (self.added, self.func) == (other.added, other.func) + return False + + async def filter(self, event: AttrDict) -> bool: + if self.added is not None and self.added != event.member_added: + return False + return await self._call_func(event) + + +class GroupImageChanged(EventFilter): + """Matches when the group image is changed. + + Warning: registering a handler for this event will cause the messages + to be marked as read. Its usage is mainly intended for bots. + + :param deleted: If set to True only match if the image was deleted, if set to False + only match if a new image was set. If omitted both, image changes and + removals, will be matched. + :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, deleted: Optional[bool] = None, **kwargs): + super().__init__(**kwargs) + self.deleted = deleted + + def __hash__(self) -> int: + return hash((self.deleted, self.func)) + + def __eq__(self, other) -> bool: + if isinstance(other, GroupImageChanged): + return (self.deleted, self.func) == (other.deleted, other.func) + return False + + async def filter(self, event: AttrDict) -> bool: + if self.deleted is not None and self.deleted != event.image_deleted: + return False + return await self._call_func(event) + + +class GroupNameChanged(EventFilter): + """Matches when the group name is changed. + + Warning: registering a handler for this event will cause the messages + to be marked as read. Its usage is mainly intended for bots. + + :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 __hash__(self) -> int: + return hash((GroupNameChanged, self.func)) + + def __eq__(self, other) -> bool: + if isinstance(other, GroupNameChanged): + return self.func == other.func + return False + + async def filter(self, event: AttrDict) -> bool: + return await self._call_func(event) + + class HookCollection: """ Helper class to collect event hooks that can later be added to a Delta Chat client. diff --git a/deltachat-rpc-client/src/deltachat_rpc_client/message.py b/deltachat-rpc-client/src/deltachat_rpc_client/message.py index 0001dd308..644984fc1 100644 --- a/deltachat-rpc-client/src/deltachat_rpc_client/message.py +++ b/deltachat-rpc-client/src/deltachat_rpc_client/message.py @@ -1,8 +1,8 @@ from typing import TYPE_CHECKING +from ._utils import AttrDict from .contact import Contact from .rpc import Rpc -from .utils import AttrDict if TYPE_CHECKING: from .account import Account diff --git a/deltachat-rpc-client/src/deltachat_rpc_client/pytestplugin.py b/deltachat-rpc-client/src/deltachat_rpc_client/pytestplugin.py index 0d8aca5d0..f628fdcfa 100644 --- a/deltachat-rpc-client/src/deltachat_rpc_client/pytestplugin.py +++ b/deltachat-rpc-client/src/deltachat_rpc_client/pytestplugin.py @@ -85,9 +85,7 @@ class ACFactory: group=group, ) - event = await to_client.run_until(lambda e: e.type == EventType.INCOMING_MSG) - msg = await to_client.account.get_message_by_id(event.msg_id) - return await msg.get_snapshot() + return await to_client.run_until(lambda e: e.type == EventType.INCOMING_MSG) @pytest_asyncio.fixture diff --git a/deltachat-rpc-client/tests/test_something.py b/deltachat-rpc-client/tests/test_something.py index 1b2b3625e..c0465cc05 100644 --- a/deltachat-rpc-client/tests/test_something.py +++ b/deltachat-rpc-client/tests/test_something.py @@ -218,12 +218,6 @@ async def test_message(acfactory) -> None: @pytest.mark.asyncio async def test_bot(acfactory) -> None: - def track(key): - async def wrapper(e): - mock.hook(e[key]) - - return wrapper - mock = MagicMock() user = (await acfactory.get_online_accounts(1))[0] bot = await acfactory.new_configured_bot() @@ -231,26 +225,28 @@ async def test_bot(acfactory) -> None: assert await bot.is_configured() assert await bot.account.get_config("bot") == "1" - hook = track("msg_id"), events.RawEvent(EventType.INCOMING_MSG) + hook = lambda e: mock.hook(e.msg_id), events.RawEvent(EventType.INCOMING_MSG) bot.add_hook(*hook) event = await acfactory.process_message( from_account=user, to_client=bot, text="Hello!" ) - mock.hook.assert_called_once_with(event.id) + mock.hook.assert_called_once_with(event.msg_id) bot.remove_hook(*hook) + track = lambda e: mock.hook(e.message_snapshot.id) + mock.hook.reset_mock() - hook = track("id"), events.NewMessage(r"hello") + hook = track, events.NewMessage(r"hello") bot.add_hook(*hook) - bot.add_hook(track("id"), events.NewMessage(command="/help")) + bot.add_hook(track, events.NewMessage(command="/help")) event = await acfactory.process_message( from_account=user, to_client=bot, text="hello" ) - mock.hook.assert_called_with(event.id) + mock.hook.assert_called_with(event.msg_id) event = await acfactory.process_message( from_account=user, to_client=bot, text="hello!" ) - mock.hook.assert_called_with(event.id) + mock.hook.assert_called_with(event.msg_id) await acfactory.process_message(from_account=user, to_client=bot, text="hey!") assert len(mock.hook.mock_calls) == 2 bot.remove_hook(*hook) @@ -260,4 +256,4 @@ async def test_bot(acfactory) -> None: event = await acfactory.process_message( from_account=user, to_client=bot, text="/help" ) - mock.hook.assert_called_once_with(event.id) + mock.hook.assert_called_once_with(event.msg_id)