Skip to content

Commit 3a64639

Browse files
authored
Merge pull request #211 from david-lev/dev
Enhance filters and contacts with new properties and fixes
2 parents 616fec2 + b36ff60 commit 3a64639

10 files changed

Lines changed: 125 additions & 26 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@
33

44
> NOTE: pywa follows the [semver](https://semver.org/) versioning standard.
55
6-
#### 4.1.0 (2026-06-16) **Latest**
6+
#### 4.2.0 (2026-06-19) **Latest**
7+
8+
- [filters] allowing `filters.new` to be used as decorator:
9+
- [contacts] adding `first_wa_id` property to `ContactList`
10+
- [api] fix `recipient_type` in `send_marketing_message`
11+
12+
#### 4.1.0 (2026-06-16)
713

814
- [client] add `archive_templates` and `unarchive_templates` methods for template archival management
915
- [client] add `force_transfer` option to `set_username` method for username management

docs/source/content/filters/overview.rst

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,20 +81,22 @@ Write a function that accepts the client and the update, and returns a boolean.
8181
- Async functions can be used as filters **only** with the async client.
8282

8383
.. code-block:: python
84-
:emphasize-lines: 3-4, 8, 13
84+
:emphasize-lines: 3-5, 9, 14
8585
8686
from pywa import WhatsApp, types, filters
8787
88+
@filters.new
8889
def without_xyz_filter(_: WhatsApp, msg: types.Message) -> bool:
89-
return msg.text and "xyz" not in msg.text
90+
return "xyz" not in msg.text
9091
9192
wa = WhatsApp(...)
9293
93-
@wa.on_message(filters.new(without_xyz_filter))
94+
# Using the new filter:
95+
@wa.on_message(filters.text & without_xyz_filter)
9496
def messages_without_xyz(wa: WhatsApp, msg: types.Message):
9597
msg.reply("You said something without xyz!")
9698
97-
# Or inline with a lambda — combine with built-in filters:
99+
# Or passing the function directly:
98100
@wa.on_message(filters.text & filters.new(lambda _, msg: "xyz" not in msg.text))
99101
def messages_without_xyz(wa: WhatsApp, msg: types.Message):
100102
msg.reply("You said something without xyz!")

docs/source/content/updates/message.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ The :class:`Message` type is used to represent incoming messages from WhatsApp u
4545

4646
.. autoclass:: pywa.types.others.ContactList()
4747
:show-inheritance:
48-
:members: first
48+
:members: first, first_wa_id
4949

5050
.. autoclass:: pywa.types.others.ContactsOrigin()
5151

pywa/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,6 @@
99
from pywa.client import WhatsApp
1010
from pywa.utils import Version
1111

12-
__version__ = "4.1.0"
12+
__version__ = "4.2.0"
1313
__author__ = "David Lev"
1414
__license__ = "MIT"

pywa/_helpers.py

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -88,20 +88,14 @@ def _missing_(cls, value: str):
8888
return cls.UNKNOWN
8989

9090

91-
@dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
9291
class FromDict:
9392
"""Allows to ignore extra fields when creating a dataclass from a dict."""
9493

9594
# noinspection PyArgumentList
9695
@classmethod
97-
def from_dict(cls, data: dict, **kwargs):
98-
return cls(
99-
**{
100-
k: v
101-
for k, v in (data | kwargs).items()
102-
if k in (f.name for f in dataclasses.fields(cls))
103-
}
104-
)
96+
def from_dict(cls, data: dict):
97+
fields = {f.name for f in dataclasses.fields(cls)}
98+
return cls(**{k: v for k, v in data.items() if k in fields})
10599

106100

107101
class APIObject:

pywa/api.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -583,6 +583,7 @@ def send_marketing_message(
583583
sender: str,
584584
to: str,
585585
recipient: str,
586+
recipient_type: str,
586587
template: dict,
587588
reply_to_message_id: str | None = None,
588589
message_activity_sharing: bool | None = None,
@@ -599,6 +600,7 @@ def send_marketing_message(
599600
sender: The phone id to send the message from.
600601
to: The WhatsApp ID to send the message to.
601602
recipient: The recipient unique identifier (BSUID).
603+
recipient_type: The type of the recipient (e.g. ``individual``, ``group``).
602604
template: The template object to send.
603605
reply_to_message_id: The ID of the message to reply to.
604606
message_activity_sharing: Toggles on / off sharing message activities (e.g. message read) for that specific marketing message to Meta to help optimize marketing messages.
@@ -610,7 +612,7 @@ def send_marketing_message(
610612
"""
611613
body = self._filter_none(
612614
messaging_product="whatsapp",
613-
recipient_type="individual",
615+
recipient_type=recipient_type,
614616
to=to,
615617
type="template",
616618
template=template,

pywa/filters.py

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@
116116
]
117117

118118
import re
119-
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Iterable, TypeVar
119+
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Iterable, TypeVar, overload
120120

121121
from . import _helpers as helpers
122122
from .errors import WhatsAppError
@@ -245,10 +245,50 @@ def has_async(self) -> bool:
245245
return self.filter.has_async()
246246

247247

248+
@overload
249+
def new() -> Callable[[Callable[[_Wa, _T], bool | Awaitable[bool]]], Filter]: ...
250+
251+
252+
@overload
253+
def new(
254+
name: str,
255+
) -> Callable[[Callable[[_Wa, _T], bool | Awaitable[bool]]], Filter]: ...
256+
257+
258+
@overload
248259
def new(
249260
func: Callable[[_Wa, _T], bool | Awaitable[bool]], name: str | None = None
261+
) -> Filter: ...
262+
263+
264+
def new(
265+
func: Callable[[_Wa, _T], bool | Awaitable[bool]] | str | None = None,
266+
name: str | None = None,
250267
) -> Filter:
251-
"""Factory function to create a filter from a function (sync or async)."""
268+
"""
269+
A factory function to create custom filter from a function (sync or async).
270+
271+
>>> @filters.new
272+
... def is_registered(_: WhatsApp, msg: types.Message) -> bool:
273+
... return my_db.is_user_registered(msg.from_user.bsuid)
274+
275+
Using it:
276+
277+
>>> @wa.on_message(is_registered)
278+
... def only_registered_users(wa: WhatsApp, msg: types.Message):
279+
... msg.reply("Hello registered user!")
280+
281+
Or passing the function directly:
282+
283+
>>> @wa.on_message(filters.new(lambda _, msg: my_db.is_user_registered(msg.from_user.bsuid)))
284+
... def only_registered_users(wa: WhatsApp, msg: types.Message):
285+
... msg.reply("Hello registered user!")"""
286+
if func is None or not callable(func):
287+
288+
def decorator(f: Callable[[_Wa, _T], bool | Awaitable[bool]]) -> Filter:
289+
return new(f, name=name or (func if isinstance(func, str) else None))
290+
291+
return decorator
252292

253293
is_async = helpers.is_async_callable(func)
254294

@@ -264,7 +304,9 @@ def has_async(self) -> bool:
264304
return is_async
265305

266306
return type(
267-
name or func.__name__ or Filter,
307+
name or getattr(func, "__name__", None) or Filter.__name__
308+
if hasattr(Filter, "__name__")
309+
else "Filter",
268310
(Filter,),
269311
{
270312
"check_sync": check_sync,

pywa/types/others.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -201,10 +201,10 @@ class Contact:
201201

202202
name: Name
203203
birthday: str | None = None
204-
phones: Iterable[Phone] = dataclasses.field(default_factory=tuple)
205-
emails: Iterable[Email] = dataclasses.field(default_factory=tuple)
206-
urls: Iterable[Url] = dataclasses.field(default_factory=tuple)
207-
addresses: Iterable[Address] = dataclasses.field(default_factory=tuple)
204+
phones: Sequence[Phone] = dataclasses.field(default_factory=tuple)
205+
emails: Sequence[Email] = dataclasses.field(default_factory=tuple)
206+
urls: Sequence[Url] = dataclasses.field(default_factory=tuple)
207+
addresses: Sequence[Address] = dataclasses.field(default_factory=tuple)
208208
org: Org | None = None
209209

210210
@classmethod
@@ -398,7 +398,7 @@ class ContactList(tuple[Contact, ...]):
398398
Represents an shared contacts in a message, which can be iterated over to get the individual contacts.
399399
400400
Attributes:
401-
origin: The origin of the shared contacts (e.g. ``contact_request`` if the contacts were shared as a contact request, ``other`` otherwise).
401+
origin: The origin of the shared contacts (e.g. ``contact_request`` if the contacts were shared as a contact info request, ``other`` otherwise).
402402
"""
403403

404404
origin: ContactsOrigin
@@ -422,6 +422,11 @@ def first(self) -> Contact:
422422
"""Get the first contact in the list."""
423423
return self[0]
424424

425+
@property
426+
def first_wa_id(self) -> str | None:
427+
"""Get the WhatsApp ID of the first contact in the list. Shortcut for ``msg.contacts[0].phones[0].wa_id``."""
428+
return self.first.phones[0].wa_id
429+
425430

426431
@dataclasses.dataclass(frozen=True, slots=True)
427432
class ReferredProduct:

pywa_async/api.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -569,6 +569,7 @@ async def send_marketing_message(
569569
sender: str,
570570
to: str,
571571
recipient: str,
572+
recipient_type: str,
572573
template: dict,
573574
reply_to_message_id: str | None = None,
574575
message_activity_sharing: bool | None = None,
@@ -585,6 +586,7 @@ async def send_marketing_message(
585586
sender: The phone id to send the message from.
586587
to: The WhatsApp ID to send the message to.
587588
recipient: The recipient unique identifier (BSUID).
589+
recipient_type: The type of the recipient (e.g. ``individual``, ``group``).
588590
template: The template object to send.
589591
reply_to_message_id: The ID of the message to reply to.
590592
message_activity_sharing: Toggles on / off sharing message activities (e.g. message read) for that specific marketing message to Meta to help optimize marketing messages.
@@ -596,7 +598,7 @@ async def send_marketing_message(
596598
"""
597599
body = self._filter_none(
598600
messaging_product="whatsapp",
599-
recipient_type="individual",
601+
recipient_type=recipient_type,
600602
to=to,
601603
type="template",
602604
template=template,

tests/test_filters.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,52 @@ def test_filters():
327327
) from e
328328

329329

330+
def test_new():
331+
@fil.new
332+
def my_filter(_, __): ...
333+
334+
assert isinstance(my_filter, Filter)
335+
assert my_filter.__class__.__name__ == "my_filter"
336+
337+
@fil.new
338+
async def my_filter(_, __): ...
339+
340+
assert isinstance(my_filter, Filter)
341+
assert my_filter.__class__.__name__ == "my_filter"
342+
343+
@fil.new()
344+
def my_filter(_, __): ...
345+
346+
assert isinstance(my_filter, Filter)
347+
assert my_filter.__class__.__name__ == "my_filter"
348+
349+
@fil.new()
350+
async def my_filter(_, __): ...
351+
352+
assert isinstance(my_filter, Filter)
353+
assert my_filter.__class__.__name__ == "my_filter"
354+
355+
@fil.new("custom_name")
356+
def my_filter(_, __): ...
357+
358+
assert isinstance(my_filter, Filter)
359+
assert my_filter.__class__.__name__ == "custom_name"
360+
361+
@fil.new("custom_name")
362+
async def my_filter(_, __): ...
363+
364+
assert isinstance(my_filter, Filter)
365+
assert my_filter.__class__.__name__ == "custom_name"
366+
367+
my_filter = fil.new(lambda _, __: True)
368+
assert isinstance(my_filter, Filter)
369+
assert my_filter.__class__.__name__ == "<lambda>"
370+
371+
my_filter = fil.new(func=lambda _, __: True, name="custom_name")
372+
assert isinstance(my_filter, Filter)
373+
assert my_filter.__class__.__name__ == "custom_name"
374+
375+
330376
def modify_text(msg: Message, to: str):
331377
return dataclasses.replace(msg, text=to)
332378

0 commit comments

Comments
 (0)