add more high-level event filters

This commit is contained in:
adbenitez
2022-12-21 13:25:46 -05:00
parent 2ebd3f54e6
commit adf754ad32
13 changed files with 272 additions and 44 deletions

View File

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

View File

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

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

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

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

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

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

View File

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