Merge pull request #3835 from deltachat/adb/rpc-client-better-hooks

improve deltachat-rpc-client lib (part #2)
This commit is contained in:
Asiel Díaz Benítez
2022-12-21 14:05:10 -05:00
committed by GitHub
14 changed files with 425 additions and 60 deletions

View File

@@ -17,8 +17,9 @@ async def log_event(event):
@hooks.on(events.NewMessage)
async def echo(msg):
await msg.chat.send_text(msg.text)
async def echo(event):
snapshot = event.message_snapshot
await snapshot.chat.send_text(snapshot.text)
if __name__ == "__main__":

View File

@@ -25,14 +25,34 @@ 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.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.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.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):
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):
snapshot = event.message_snapshot
await snapshot.chat.send_text("Send me any message and I will echo it back")
async def main():

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -1,12 +1,35 @@
"""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
from .const import EventType
from .events import EventFilter, NewInfoMessage, 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:
@@ -21,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(
@@ -36,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:
@@ -56,6 +92,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 +116,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:
@@ -78,17 +132,82 @@ class Client:
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 _parse_command(self, event: AttrDict) -> None:
cmds = [
hook[1].command
for hook in self._hooks.get(NewMessage, [])
if hook[1].command
]
parts = event.message_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
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()
if snapshot.is_info:
await self._on_event(snapshot, NewInfoMessage)
else:
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()

View File

@@ -1,5 +1,7 @@
from enum import Enum, IntEnum
COMMAND_PREFIX = "/"
class ContactFlag(IntEnum):
VERIFIED_ONLY = 0x01

View File

@@ -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

View File

@@ -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:

View File

@@ -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:
@@ -91,8 +91,19 @@ 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).
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__(
@@ -103,9 +114,17 @@ 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 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):
@@ -119,13 +138,22 @@ 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.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:
@@ -133,8 +161,91 @@ class NewMessage(EventFilter):
return await super()._call_func(event)
class NewInfoMessage(NewMessage):
"""Matches whenever a new info/system message arrives."""
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:

View File

@@ -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

View File

@@ -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,44 @@ 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,
)
return await to_client.run_until(lambda e: e.type == EventType.INCOMING_MSG)
@pytest_asyncio.fixture
async def rpc(tmp_path) -> AsyncGenerator:

View File

@@ -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,31 +218,42 @@ async def test_message(acfactory) -> None:
@pytest.mark.asyncio
async def test_bot(acfactory) -> None:
async def callback(e):
res.append(e)
res = []
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 = 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.msg_id)
bot.remove_hook(*hook)
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
track = lambda e: mock.hook(e.message_snapshot.id)
mock.hook.reset_mock()
hook = track, events.NewMessage(r"hello")
bot.add_hook(*hook)
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.msg_id)
event = await acfactory.process_message(
from_account=user, to_client=bot, text="hello!"
)
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)
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.msg_id)