From 2bc9ddd1e666f996d52d52589fbba4972a19bf45 Mon Sep 17 00:00:00 2001 From: Hinrich Mahler <22366557+Bibo-Joshi@users.noreply.github.com> Date: Thu, 3 Jul 2025 21:05:31 +0200 Subject: [PATCH 1/9] Add `Checklist` class --- docs/source/telegram.at-tree.rst | 1 + docs/source/telegram.checklist.rst | 6 ++ src/telegram/__init__.py | 3 +- src/telegram/_checklists.py | 110 +++++++++++++++++++++++++++ tests/test_checklists.py | 117 ++++++++++++++++++++++++++++- 5 files changed, 235 insertions(+), 2 deletions(-) create mode 100644 docs/source/telegram.checklist.rst diff --git a/docs/source/telegram.at-tree.rst b/docs/source/telegram.at-tree.rst index 133b3fd79d4..3fdfe942ea1 100644 --- a/docs/source/telegram.at-tree.rst +++ b/docs/source/telegram.at-tree.rst @@ -31,6 +31,7 @@ Available Types telegram.chat telegram.chatadministratorrights telegram.chatbackground + telegram.checklist telegram.checklisttask telegram.copytextbutton telegram.backgroundtype diff --git a/docs/source/telegram.checklist.rst b/docs/source/telegram.checklist.rst new file mode 100644 index 00000000000..a01dac43aad --- /dev/null +++ b/docs/source/telegram.checklist.rst @@ -0,0 +1,6 @@ +Checklist +========= + +.. autoclass:: telegram.Checklist + :members: + :show-inheritance: diff --git a/src/telegram/__init__.py b/src/telegram/__init__.py index f25e90b9b7e..717c22f2db8 100644 --- a/src/telegram/__init__.py +++ b/src/telegram/__init__.py @@ -82,6 +82,7 @@ "ChatPermissions", "ChatPhoto", "ChatShared", + "Checklist", "ChecklistTask", "ChosenInlineResult", "Contact", @@ -382,7 +383,7 @@ ) from ._chatmemberupdated import ChatMemberUpdated from ._chatpermissions import ChatPermissions -from ._checklists import ChecklistTask +from ._checklists import Checklist, ChecklistTask from ._choseninlineresult import ChosenInlineResult from ._copytextbutton import CopyTextButton from ._dice import Dice diff --git a/src/telegram/_checklists.py b/src/telegram/_checklists.py index f6b8c24e085..fff0dd8a8c6 100644 --- a/src/telegram/_checklists.py +++ b/src/telegram/_checklists.py @@ -154,3 +154,113 @@ def parse_entities(self, types: Optional[list[str]] = None) -> dict[MessageEntit the text that belongs to them, calculated based on UTF-16 codepoints. """ return parse_message_entities(self.text, self.text_entities, types) + + +class Checklist(TelegramObject): + """ + Describes a checklist. + + Objects of this class are comparable in terms of equality. + Two objects of this class are considered equal, if all their :attr:`tasks` are equal. + + .. versionadded:: NEXT.VERSION + + Args: + title (:obj:`str`): Title of the checklist. + title_entities (Sequence[:class:`telegram.MessageEntity`], optional): Special + entities that appear in the checklist title. + tasks (Sequence[:class:`telegram.ChecklistTask`]): List of tasks in the checklist. + others_can_add_tasks (:obj:`bool`, optional): :obj:`True` if users other than the creator + of the list can add tasks to the list + others_can_mark_tasks_as_done (:obj:`bool`, optional): :obj:`True` if users other than the + creator of the list can mark tasks as done or not done + + Attributes: + title (:obj:`str`): Title of the checklist. + title_entities (Tuple[:class:`telegram.MessageEntity`]): Special + entities that appear in the checklist title. + tasks (Tuple[:class:`telegram.ChecklistTask`]): List of tasks in the checklist. + others_can_add_tasks (:obj:`bool`): :obj:`True` if users other than the creator + of the list can add tasks to the list + others_can_mark_tasks_as_done (:obj:`bool`): :obj:`True` if users other than the + creator of the list can mark tasks as done or not done + """ + + __slots__ = ( + "others_can_add_tasks", + "others_can_mark_tasks_as_done", + "tasks", + "title", + "title_entities", + ) + + def __init__( + self, + title: str, + tasks: Sequence[ChecklistTask], + title_entities: Optional[Sequence[MessageEntity]] = None, + others_can_add_tasks: Optional[bool] = None, + others_can_mark_tasks_as_done: Optional[bool] = None, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.title: str = title + self.title_entities: tuple[MessageEntity, ...] = parse_sequence_arg(title_entities) + self.tasks: tuple[ChecklistTask, ...] = parse_sequence_arg(tasks) + self.others_can_add_tasks: Optional[bool] = others_can_add_tasks + self.others_can_mark_tasks_as_done: Optional[bool] = others_can_mark_tasks_as_done + + self._id_attrs = (self.tasks,) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "Checklist": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["title_entities"] = de_list_optional(data.get("title_entities"), MessageEntity, bot) + data["tasks"] = de_list_optional(data.get("tasks"), ChecklistTask, bot) + + return super().de_json(data=data, bot=bot) + + def parse_entity(self, entity: MessageEntity) -> str: + """Returns the text in :attr:`text` + from a given :class:`telegram.MessageEntity` of :attr:`title_entities`. + + Note: + This method is present because Telegram calculates the offset and length in + UTF-16 codepoint pairs, which some versions of Python don't handle automatically. + (That is, you can't just slice ``Message.text`` with the offset and length.) + + Args: + entity (:class:`telegram.MessageEntity`): The entity to extract the text from. It must + be an entity that belongs to :attr:`title_entities`. + + Returns: + :obj:`str`: The text of the given entity. + """ + return parse_message_entity(self.title, entity) + + def parse_entities(self, types: Optional[list[str]] = None) -> dict[MessageEntity, str]: + """ + Returns a :obj:`dict` that maps :class:`telegram.MessageEntity` to :obj:`str`. + It contains entities from this polls question filtered by their ``type`` attribute as + the key, and the text that each entity belongs to as the value of the :obj:`dict`. + + Note: + This method should always be used instead of the :attr:`title_entities` + attribute, since it calculates the correct substring from the message text based on + UTF-16 codepoints. See :attr:`parse_entity` for more info. + + Args: + types (list[:obj:`str`], optional): List of ``MessageEntity`` types as strings. If the + ``type`` attribute of an entity is contained in this list, it will be returned. + Defaults to :attr:`telegram.MessageEntity.ALL_TYPES`. + + Returns: + dict[:class:`telegram.MessageEntity`, :obj:`str`]: A dictionary of entities mapped to + the text that belongs to them, calculated based on UTF-16 codepoints. + """ + return parse_message_entities(self.title, self.title_entities, types) diff --git a/tests/test_checklists.py b/tests/test_checklists.py index d55cf7467b0..2f85e025e58 100644 --- a/tests/test_checklists.py +++ b/tests/test_checklists.py @@ -20,7 +20,8 @@ import pytest -from telegram import ChecklistTask, MessageEntity, User +from telegram import ChecklistTask, Dice, MessageEntity, User +from telegram._checklists import Checklist from telegram._utils.datetime import UTC, to_timestamp from telegram.constants import ZERO_DATE from tests.auxil.slots import mro_slots @@ -154,9 +155,123 @@ def test_equality(self, checklist_task): id=self.id + 1, text=self.text, ) + clt4 = Dice(value=1, emoji="🎲") assert clt1 == clt2 assert hash(clt1) == hash(clt2) assert clt1 != clt3 assert hash(clt1) != hash(clt3) + + assert clt1 != clt4 + assert hash(clt1) != hash(clt4) + + +class ChecklistTestBase: + title = "Checklist Title" + title_entities = [ + MessageEntity(type="bold", offset=0, length=9), + MessageEntity(type="italic", offset=10, length=5), + ] + tasks = [ + ChecklistTask( + id=1, + text="Task 1", + ), + ChecklistTask( + id=2, + text="Task 2", + ), + ] + others_can_add_tasks = True + others_can_mark_tasks_as_done = False + + +@pytest.fixture(scope="module") +def checklist(): + return Checklist( + title=ChecklistTestBase.title, + title_entities=ChecklistTestBase.title_entities, + tasks=ChecklistTestBase.tasks, + others_can_add_tasks=ChecklistTestBase.others_can_add_tasks, + others_can_mark_tasks_as_done=ChecklistTestBase.others_can_mark_tasks_as_done, + ) + + +class TestChecklistWithoutRequest(ChecklistTestBase): + def test_slot_behaviour(self, checklist): + for attr in checklist.__slots__: + assert getattr(checklist, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(checklist)) == len(set(mro_slots(checklist))), "duplicate slot" + + def test_to_dict(self, checklist): + cl_dict = checklist.to_dict() + assert isinstance(cl_dict, dict) + assert cl_dict["title"] == self.title + assert cl_dict["title_entities"] == [entity.to_dict() for entity in self.title_entities] + assert cl_dict["tasks"] == [task.to_dict() for task in self.tasks] + assert cl_dict["others_can_add_tasks"] is self.others_can_add_tasks + assert cl_dict["others_can_mark_tasks_as_done"] is self.others_can_mark_tasks_as_done + + def test_de_json(self, offline_bot): + json_dict = { + "title": self.title, + "title_entities": [entity.to_dict() for entity in self.title_entities], + "tasks": [task.to_dict() for task in self.tasks], + "others_can_add_tasks": self.others_can_add_tasks, + "others_can_mark_tasks_as_done": self.others_can_mark_tasks_as_done, + } + cl = Checklist.de_json(json_dict, offline_bot) + assert isinstance(cl, Checklist) + assert cl.title == self.title + assert cl.title_entities == tuple(self.title_entities) + assert cl.tasks == tuple(self.tasks) + assert cl.others_can_add_tasks is self.others_can_add_tasks + assert cl.others_can_mark_tasks_as_done is self.others_can_mark_tasks_as_done + + def test_de_json_required_fields(self, offline_bot): + json_dict = { + "title": self.title, + "tasks": [task.to_dict() for task in self.tasks], + } + cl = Checklist.de_json(json_dict, offline_bot) + assert isinstance(cl, Checklist) + assert cl.title == self.title + assert cl.title_entities == () + assert cl.tasks == tuple(self.tasks) + assert not cl.others_can_add_tasks + assert not cl.others_can_mark_tasks_as_done + + def test_parse_entity(self, checklist): + assert checklist.parse_entity(checklist.title_entities[0]) == "Checklist" + assert checklist.parse_entity(checklist.title_entities[1]) == "Title" + + def test_parse_entities(self, checklist): + assert checklist.parse_entities(MessageEntity.BOLD) == { + checklist.title_entities[0]: "Checklist" + } + assert checklist.parse_entities() == { + checklist.title_entities[0]: "Checklist", + checklist.title_entities[1]: "Title", + } + + def test_equality(self, checklist, checklist_task): + cl1 = checklist + cl2 = Checklist( + title=self.title + " other", + tasks=[ChecklistTask(id=1, text="something"), ChecklistTask(id=2, text="something")], + ) + cl3 = Checklist( + title=self.title + " other", + tasks=[ChecklistTask(id=42, text="Task 2")], + ) + cl4 = checklist_task + + assert cl1 == cl2 + assert hash(cl1) == hash(cl2) + + assert cl1 != cl3 + assert hash(cl1) != hash(cl3) + + assert cl1 != cl4 + assert hash(cl1) != hash(cl4) From 622ce14bfa56e2a2a3532f41f9613cad3819928a Mon Sep 17 00:00:00 2001 From: Hinrich Mahler <22366557+Bibo-Joshi@users.noreply.github.com> Date: Thu, 3 Jul 2025 21:09:00 +0200 Subject: [PATCH 2/9] Add chango fragment --- changes/unreleased/4847.8ujbbBbaZ2VTEdRLeqirSZ.toml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/changes/unreleased/4847.8ujbbBbaZ2VTEdRLeqirSZ.toml b/changes/unreleased/4847.8ujbbBbaZ2VTEdRLeqirSZ.toml index b0ef7271f19..4b524f714fc 100644 --- a/changes/unreleased/4847.8ujbbBbaZ2VTEdRLeqirSZ.toml +++ b/changes/unreleased/4847.8ujbbBbaZ2VTEdRLeqirSZ.toml @@ -10,7 +10,7 @@ other = "Optional Section Content" documentation = "Optional Section Content" internal = "Optional Section Content" -[[pull_requests]] -uid = "4847" -author_uid = "Bibo-Joshi" -closes_threads = ["4845"] +pull_requests = [ + { uid = "4847", author_uid = "Bibo-Joshi", closes_threads = ["4845"] }, + { uid = "4848", author_uid = "Bibo-Joshi", closes_threads = [] } +] From d8470402ebdde447514fa62b135ac117ba667cf1 Mon Sep 17 00:00:00 2001 From: Hinrich Mahler <22366557+Bibo-Joshi@users.noreply.github.com> Date: Thu, 3 Jul 2025 21:23:31 +0200 Subject: [PATCH 3/9] Add class `ChecklistsTasksDone` --- docs/source/telegram.at-tree.rst | 1 + docs/source/telegram.checklisttasksdone.rst | 6 ++ src/telegram/__init__.py | 3 +- src/telegram/_checklists.py | 67 +++++++++++++++++ tests/test_checklists.py | 80 ++++++++++++++++++++- 5 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 docs/source/telegram.checklisttasksdone.rst diff --git a/docs/source/telegram.at-tree.rst b/docs/source/telegram.at-tree.rst index 3fdfe942ea1..651bb9bde34 100644 --- a/docs/source/telegram.at-tree.rst +++ b/docs/source/telegram.at-tree.rst @@ -33,6 +33,7 @@ Available Types telegram.chatbackground telegram.checklist telegram.checklisttask + telegram.checklisttasksdone telegram.copytextbutton telegram.backgroundtype telegram.backgroundtypefill diff --git a/docs/source/telegram.checklisttasksdone.rst b/docs/source/telegram.checklisttasksdone.rst new file mode 100644 index 00000000000..aa1e0b83f84 --- /dev/null +++ b/docs/source/telegram.checklisttasksdone.rst @@ -0,0 +1,6 @@ +ChecklistTasksDone +================== + +.. autoclass:: telegram.ChecklistTasksDone + :members: + :show-inheritance: diff --git a/src/telegram/__init__.py b/src/telegram/__init__.py index 717c22f2db8..494aaef722a 100644 --- a/src/telegram/__init__.py +++ b/src/telegram/__init__.py @@ -84,6 +84,7 @@ "ChatShared", "Checklist", "ChecklistTask", + "ChecklistTasksDone", "ChosenInlineResult", "Contact", "CopyTextButton", @@ -383,7 +384,7 @@ ) from ._chatmemberupdated import ChatMemberUpdated from ._chatpermissions import ChatPermissions -from ._checklists import Checklist, ChecklistTask +from ._checklists import Checklist, ChecklistTask, ChecklistTasksDone from ._choseninlineresult import ChosenInlineResult from ._copytextbutton import CopyTextButton from ._dice import Dice diff --git a/src/telegram/_checklists.py b/src/telegram/_checklists.py index fff0dd8a8c6..a593caa82a3 100644 --- a/src/telegram/_checklists.py +++ b/src/telegram/_checklists.py @@ -21,6 +21,7 @@ from collections.abc import Sequence from typing import TYPE_CHECKING, Optional +from telegram._message import Message from telegram._messageentity import MessageEntity from telegram._telegramobject import TelegramObject from telegram._user import User @@ -264,3 +265,69 @@ def parse_entities(self, types: Optional[list[str]] = None) -> dict[MessageEntit the text that belongs to them, calculated based on UTF-16 codepoints. """ return parse_message_entities(self.title, self.title_entities, types) + + +class ChecklistTasksDone(TelegramObject): + """ + Describes a service message about checklist tasks marked as done or not done. + + Objects of this class are comparable in terms of equality. + Two objects of this class are considered equal, if their :attr:`marked_as_done_task_ids` and + :attr:`marked_as_not_done_task_ids` are equal. + + .. versionadded:: NEXT.VERSION + + Args: + checklist_message (:class:`telegram.Message`, optional): Message containing the checklist + whose tasks were marked as done or not done. Note that the ~:class:`telegram.Message` + object in this field will not contain the :attr:`~telegram.Message.reply_to_message` + field even if it itself is a reply. + marked_as_done_task_ids (Sequence[:obj:`int`], optional): Identifiers of the tasks that + were marked as done + marked_as_not_done_task_ids (Sequence[:obj:`int`], optional): Identifiers of the tasks that + were marked as not done + + Attributes: + checklist_message (:class:`telegram.Message`): Optional. Message containing the checklist + whose tasks were marked as done or not done. Note that the ~:class:`telegram.Message` + object in this field will not contain the :attr:`~telegram.Message.reply_to_message` + field even if it itself is a reply. + marked_as_done_task_ids (Tuple[:obj:`int`]): Identifiers of the tasks that were marked + as done + marked_as_not_done_task_ids (Tuple[:obj:`int`]): Identifiers of the tasks that were + marked as not done + """ + + __slots__ = ( + "checklist_message", + "marked_as_done_task_ids", + "marked_as_not_done_task_ids", + ) + + def __init__( + self, + checklist_message: Optional[Message] = None, + marked_as_done_task_ids: Optional[Sequence[int]] = None, + marked_as_not_done_task_ids: Optional[Sequence[int]] = None, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.checklist_message: Optional[Message] = checklist_message + self.marked_as_done_task_ids: tuple[int, ...] = parse_sequence_arg(marked_as_done_task_ids) + self.marked_as_not_done_task_ids: tuple[int, ...] = parse_sequence_arg( + marked_as_not_done_task_ids + ) + + self._id_attrs = (self.marked_as_done_task_ids, self.marked_as_not_done_task_ids) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "ChecklistTasksDone": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["checklist_message"] = de_json_optional(data.get("checklist_message"), Message, bot) + + return super().de_json(data=data, bot=bot) diff --git a/tests/test_checklists.py b/tests/test_checklists.py index 2f85e025e58..6013c66d3a7 100644 --- a/tests/test_checklists.py +++ b/tests/test_checklists.py @@ -21,9 +21,10 @@ import pytest from telegram import ChecklistTask, Dice, MessageEntity, User -from telegram._checklists import Checklist +from telegram._checklists import Checklist, ChecklistTasksDone from telegram._utils.datetime import UTC, to_timestamp from telegram.constants import ZERO_DATE +from tests.auxil.build_messages import make_message from tests.auxil.slots import mro_slots @@ -81,6 +82,7 @@ def test_de_json(self, offline_bot): assert clt.text_entities == tuple(self.text_entities) assert clt.completed_by_user == self.completed_by_user assert clt.completion_date == self.completion_date + assert clt.api_kwargs == {} def test_de_json_required_fields(self, offline_bot): json_dict = { @@ -94,6 +96,7 @@ def test_de_json_required_fields(self, offline_bot): assert clt.text_entities == () assert clt.completed_by_user is None assert clt.completion_date is None + assert clt.api_kwargs == {} def test_de_json_localization(self, offline_bot, raw_bot, tz_bot): json_dict = { @@ -228,6 +231,7 @@ def test_de_json(self, offline_bot): assert cl.tasks == tuple(self.tasks) assert cl.others_can_add_tasks is self.others_can_add_tasks assert cl.others_can_mark_tasks_as_done is self.others_can_mark_tasks_as_done + assert cl.api_kwargs == {} def test_de_json_required_fields(self, offline_bot): json_dict = { @@ -275,3 +279,77 @@ def test_equality(self, checklist, checklist_task): assert cl1 != cl4 assert hash(cl1) != hash(cl4) + + +class ChecklistTasksDoneTestBase: + checklist_message = make_message("Checklist message") + marked_as_done_task_ids = [1, 2, 3] + marked_as_not_done_task_ids = [4, 5] + + +@pytest.fixture(scope="module") +def checklist_tasks_done(): + return ChecklistTasksDone( + checklist_message=ChecklistTasksDoneTestBase.checklist_message, + marked_as_done_task_ids=ChecklistTasksDoneTestBase.marked_as_done_task_ids, + marked_as_not_done_task_ids=ChecklistTasksDoneTestBase.marked_as_not_done_task_ids, + ) + + +class TestChecklistTasksDoneWithoutRequest(ChecklistTasksDoneTestBase): + def test_slot_behaviour(self, checklist_tasks_done): + for attr in checklist_tasks_done.__slots__: + assert getattr(checklist_tasks_done, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(checklist_tasks_done)) == len( + set(mro_slots(checklist_tasks_done)) + ), "duplicate slot" + + def test_to_dict(self, checklist_tasks_done): + cltd_dict = checklist_tasks_done.to_dict() + assert isinstance(cltd_dict, dict) + assert cltd_dict["checklist_message"] == self.checklist_message.to_dict() + assert cltd_dict["marked_as_done_task_ids"] == self.marked_as_done_task_ids + assert cltd_dict["marked_as_not_done_task_ids"] == self.marked_as_not_done_task_ids + + def test_de_json(self, offline_bot): + json_dict = { + "checklist_message": self.checklist_message.to_dict(), + "marked_as_done_task_ids": self.marked_as_done_task_ids, + "marked_as_not_done_task_ids": self.marked_as_not_done_task_ids, + } + cltd = ChecklistTasksDone.de_json(json_dict, offline_bot) + assert isinstance(cltd, ChecklistTasksDone) + assert cltd.checklist_message == self.checklist_message + assert cltd.marked_as_done_task_ids == tuple(self.marked_as_done_task_ids) + assert cltd.marked_as_not_done_task_ids == tuple(self.marked_as_not_done_task_ids) + assert cltd.api_kwargs == {} + + def test_de_json_required_fields(self, offline_bot): + cltd = ChecklistTasksDone.de_json({}, offline_bot) + assert isinstance(cltd, ChecklistTasksDone) + assert cltd.checklist_message is None + assert cltd.marked_as_done_task_ids == () + assert cltd.marked_as_not_done_task_ids == () + assert cltd.api_kwargs == {} + + def test_equality(self, checklist_tasks_done): + cltd1 = checklist_tasks_done + cltd2 = ChecklistTasksDone( + checklist_message=None, + marked_as_done_task_ids=[1, 2, 3], + marked_as_not_done_task_ids=[4, 5], + ) + cltd3 = ChecklistTasksDone( + checklist_message=make_message("Checklist message"), + marked_as_done_task_ids=[1, 2, 3], + ) + cltd4 = make_message("Not a checklist tasks done") + + assert cltd1 == cltd2 + assert hash(cltd1) == hash(cltd2) + + assert cltd1 != cltd3 + assert hash(cltd1) != hash(cltd3) + + assert cltd1 != cltd4 + assert hash(cltd1) != hash(cltd4) From fd87e616b523337f379745518697bba7b816efbe Mon Sep 17 00:00:00 2001 From: Hinrich Mahler <22366557+Bibo-Joshi@users.noreply.github.com> Date: Thu, 3 Jul 2025 21:41:52 +0200 Subject: [PATCH 4/9] Add class `ChecklistsTasksAdded` --- docs/source/telegram.at-tree.rst | 1 + docs/source/telegram.checklisttasksadded.rst | 6 ++ src/telegram/__init__.py | 3 +- src/telegram/_checklists.py | 52 +++++++++++++ tests/test_checklists.py | 79 +++++++++++++++++++- 5 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 docs/source/telegram.checklisttasksadded.rst diff --git a/docs/source/telegram.at-tree.rst b/docs/source/telegram.at-tree.rst index 651bb9bde34..404fef3a816 100644 --- a/docs/source/telegram.at-tree.rst +++ b/docs/source/telegram.at-tree.rst @@ -33,6 +33,7 @@ Available Types telegram.chatbackground telegram.checklist telegram.checklisttask + telegram.checklisttasksadded telegram.checklisttasksdone telegram.copytextbutton telegram.backgroundtype diff --git a/docs/source/telegram.checklisttasksadded.rst b/docs/source/telegram.checklisttasksadded.rst new file mode 100644 index 00000000000..d3c33c02300 --- /dev/null +++ b/docs/source/telegram.checklisttasksadded.rst @@ -0,0 +1,6 @@ +ChecklistTasksAdded +=================== + +.. autoclass:: telegram.ChecklistTasksAdded + :members: + :show-inheritance: diff --git a/src/telegram/__init__.py b/src/telegram/__init__.py index 494aaef722a..a88a71c2f0f 100644 --- a/src/telegram/__init__.py +++ b/src/telegram/__init__.py @@ -84,6 +84,7 @@ "ChatShared", "Checklist", "ChecklistTask", + "ChecklistTasksAdded", "ChecklistTasksDone", "ChosenInlineResult", "Contact", @@ -384,7 +385,7 @@ ) from ._chatmemberupdated import ChatMemberUpdated from ._chatpermissions import ChatPermissions -from ._checklists import Checklist, ChecklistTask, ChecklistTasksDone +from ._checklists import Checklist, ChecklistTask, ChecklistTasksAdded, ChecklistTasksDone from ._choseninlineresult import ChosenInlineResult from ._copytextbutton import CopyTextButton from ._dice import Dice diff --git a/src/telegram/_checklists.py b/src/telegram/_checklists.py index a593caa82a3..369e75e83d4 100644 --- a/src/telegram/_checklists.py +++ b/src/telegram/_checklists.py @@ -331,3 +331,55 @@ def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "ChecklistTasks data["checklist_message"] = de_json_optional(data.get("checklist_message"), Message, bot) return super().de_json(data=data, bot=bot) + + +class ChecklistTasksAdded(TelegramObject): + """ + Describes a service message about checklist tasks added to a checklist. + + Objects of this class are comparable in terms of equality. + Two objects of this class are considered equal, if their :attr:`tasks` are equal. + + .. versionadded:: NEXT.VERSION + + Args: + checklist_message (:class:`telegram.Message`, optional): Message containing the checklist + to which tasks were added. Note that the ~:class:`telegram.Message` + object in this field will not contain the :attr:`~telegram.Message.reply_to_message` + field even if it itself is a reply. + tasks (Sequence[:class:`telegram.ChecklistTask`]): List of tasks added to the checklist + + Attributes: + checklist_message (:class:`telegram.Message`): Optional. Message containing the checklist + to which tasks were added. Note that the ~:class:`telegram.Message` + object in this field will not contain the :attr:`~telegram.Message.reply_to_message` + field even if it itself is a reply. + tasks (Tuple[:class:`telegram.ChecklistTask`]): List of tasks added to the checklist + """ + + __slots__ = ("checklist_message", "tasks") + + def __init__( + self, + tasks: Sequence[ChecklistTask], + checklist_message: Optional[Message] = None, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.checklist_message: Optional[Message] = checklist_message + self.tasks: tuple[ChecklistTask, ...] = parse_sequence_arg(tasks) + + self._id_attrs = (self.tasks,) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "ChecklistTasksAdded": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["checklist_message"] = de_json_optional(data.get("checklist_message"), Message, bot) + data["tasks"] = ChecklistTask.de_list(data.get("tasks", []), bot) + + return super().de_json(data=data, bot=bot) diff --git a/tests/test_checklists.py b/tests/test_checklists.py index 6013c66d3a7..c322966143f 100644 --- a/tests/test_checklists.py +++ b/tests/test_checklists.py @@ -21,7 +21,7 @@ import pytest from telegram import ChecklistTask, Dice, MessageEntity, User -from telegram._checklists import Checklist, ChecklistTasksDone +from telegram._checklists import Checklist, ChecklistTasksAdded, ChecklistTasksDone from telegram._utils.datetime import UTC, to_timestamp from telegram.constants import ZERO_DATE from tests.auxil.build_messages import make_message @@ -353,3 +353,80 @@ def test_equality(self, checklist_tasks_done): assert cltd1 != cltd4 assert hash(cltd1) != hash(cltd4) + + +class ChecklistTasksAddedTestBase: + checklist_message = make_message("Checklist message") + tasks = [ + ChecklistTask(id=1, text="Task 1"), + ChecklistTask(id=2, text="Task 2"), + ChecklistTask(id=3, text="Task 3"), + ] + + +@pytest.fixture(scope="module") +def checklist_tasks_added(): + return ChecklistTasksAdded( + checklist_message=ChecklistTasksAddedTestBase.checklist_message, + tasks=ChecklistTasksAddedTestBase.tasks, + ) + + +class TestChecklistTasksAddedWithoutRequest(ChecklistTasksAddedTestBase): + def test_slot_behaviour(self, checklist_tasks_added): + for attr in checklist_tasks_added.__slots__: + assert getattr(checklist_tasks_added, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(checklist_tasks_added)) == len( + set(mro_slots(checklist_tasks_added)) + ), "duplicate slot" + + def test_to_dict(self, checklist_tasks_added): + clta_dict = checklist_tasks_added.to_dict() + assert isinstance(clta_dict, dict) + assert clta_dict["checklist_message"] == self.checklist_message.to_dict() + assert clta_dict["tasks"] == [task.to_dict() for task in self.tasks] + + def test_de_json(self, offline_bot): + json_dict = { + "checklist_message": self.checklist_message.to_dict(), + "tasks": [task.to_dict() for task in self.tasks], + } + clta = ChecklistTasksAdded.de_json(json_dict, offline_bot) + assert isinstance(clta, ChecklistTasksAdded) + assert clta.checklist_message == self.checklist_message + assert clta.tasks == tuple(self.tasks) + assert clta.api_kwargs == {} + + def test_de_json_required_fields(self, offline_bot): + clta = ChecklistTasksAdded.de_json( + {"tasks": [task.to_dict() for task in self.tasks]}, offline_bot + ) + assert isinstance(clta, ChecklistTasksAdded) + assert clta.checklist_message is None + assert clta.tasks == tuple(self.tasks) + assert clta.api_kwargs == {} + + def test_equality(self, checklist_tasks_added): + clta1 = checklist_tasks_added + clta2 = ChecklistTasksAdded( + checklist_message=None, + tasks=[ + ChecklistTask(id=1, text="Other Task 1"), + ChecklistTask(id=2, text="Other Task 2"), + ChecklistTask(id=3, text="Other Task 3"), + ], + ) + clta3 = ChecklistTasksAdded( + checklist_message=make_message("Checklist message"), + tasks=[ChecklistTask(id=1, text="Task 1")], + ) + clta4 = make_message("Not a checklist tasks added") + + assert clta1 == clta2 + assert hash(clta1) == hash(clta2) + + assert clta1 != clta3 + assert hash(clta1) != hash(clta3) + + assert clta1 != clta4 + assert hash(clta1) != hash(clta4) From 92001270ea65ebafc62168de44410709a153a141 Mon Sep 17 00:00:00 2001 From: Hinrich Mahler <22366557+Bibo-Joshi@users.noreply.github.com> Date: Sat, 5 Jul 2025 13:42:29 +0200 Subject: [PATCH 5/9] Add `Message/ERI.checklist` --- .../unreleased/4847.8ujbbBbaZ2VTEdRLeqirSZ.toml | 1 + src/telegram/_checklists.py | 13 +++++++++---- src/telegram/_message.py | 11 +++++++++++ src/telegram/_reply.py | 11 +++++++++++ src/telegram/ext/filters.py | 15 +++++++++++++++ tests/ext/test_filters.py | 7 +++++++ tests/test_message.py | 9 +++++++++ tests/test_reply.py | 13 +++++++++++++ 8 files changed, 76 insertions(+), 4 deletions(-) diff --git a/changes/unreleased/4847.8ujbbBbaZ2VTEdRLeqirSZ.toml b/changes/unreleased/4847.8ujbbBbaZ2VTEdRLeqirSZ.toml index 32df9b7e248..f4bc8d849b4 100644 --- a/changes/unreleased/4847.8ujbbBbaZ2VTEdRLeqirSZ.toml +++ b/changes/unreleased/4847.8ujbbBbaZ2VTEdRLeqirSZ.toml @@ -4,6 +4,7 @@ features = """ New filters based on Bot API 9.1: * ``filters.StatusUpdate.DIRECT_MESSAGE_PRICE_CHANGED`` for ``Message.direct_message_price_changed`` +* ``filters.CHECKLIST`` for ``Message.checklist`` """ pull_requests = [ diff --git a/src/telegram/_checklists.py b/src/telegram/_checklists.py index 369e75e83d4..3c20d5f4f99 100644 --- a/src/telegram/_checklists.py +++ b/src/telegram/_checklists.py @@ -21,7 +21,6 @@ from collections.abc import Sequence from typing import TYPE_CHECKING, Optional -from telegram._message import Message from telegram._messageentity import MessageEntity from telegram._telegramobject import TelegramObject from telegram._user import User @@ -32,7 +31,7 @@ from telegram.constants import ZERO_DATE if TYPE_CHECKING: - from telegram import Bot + from telegram import Bot, Message class ChecklistTask(TelegramObject): @@ -306,7 +305,7 @@ class ChecklistTasksDone(TelegramObject): def __init__( self, - checklist_message: Optional[Message] = None, + checklist_message: Optional["Message"] = None, marked_as_done_task_ids: Optional[Sequence[int]] = None, marked_as_not_done_task_ids: Optional[Sequence[int]] = None, *, @@ -328,6 +327,9 @@ def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "ChecklistTasks """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) + # needs to be imported here to avoid circular import issues + from telegram import Message # pylint: disable=import-outside-toplevel + data["checklist_message"] = de_json_optional(data.get("checklist_message"), Message, bot) return super().de_json(data=data, bot=bot) @@ -362,7 +364,7 @@ class ChecklistTasksAdded(TelegramObject): def __init__( self, tasks: Sequence[ChecklistTask], - checklist_message: Optional[Message] = None, + checklist_message: Optional["Message"] = None, *, api_kwargs: Optional[JSONDict] = None, ): @@ -379,6 +381,9 @@ def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "ChecklistTasks """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) + # needs to be imported here to avoid circular import issues + from telegram import Message # pylint: disable=import-outside-toplevel + data["checklist_message"] = de_json_optional(data.get("checklist_message"), Message, bot) data["tasks"] = ChecklistTask.de_list(data.get("tasks", []), bot) diff --git a/src/telegram/_message.py b/src/telegram/_message.py index 46d755bb71f..c6c152a63a8 100644 --- a/src/telegram/_message.py +++ b/src/telegram/_message.py @@ -28,6 +28,7 @@ from telegram._chat import Chat from telegram._chatbackground import ChatBackground from telegram._chatboost import ChatBoostAdded +from telegram._checklists import Checklist from telegram._dice import Dice from telegram._directmessagepricechanged import DirectMessagePriceChanged from telegram._files.animation import Animation @@ -525,6 +526,9 @@ class Message(MaybeInaccessibleMessage): by a spoiler animation. .. versionadded:: 20.0 + checklist (:class:`telegram.Checklist`, optional): Message is a checklist + + .. versionadded:: NEXT.VERSION users_shared (:class:`telegram.UsersShared`, optional): Service message: users were shared with the bot @@ -874,6 +878,9 @@ class Message(MaybeInaccessibleMessage): by a spoiler animation. .. versionadded:: 20.0 + checklist (:class:`telegram.Checklist`): Optional. Message is a checklist + + .. versionadded:: NEXT.VERSION users_shared (:class:`telegram.UsersShared`): Optional. Service message: users were shared with the bot @@ -994,6 +1001,7 @@ class Message(MaybeInaccessibleMessage): "channel_chat_created", "chat_background_set", "chat_shared", + "checklist", "connected_website", "contact", "delete_chat_photo", @@ -1165,6 +1173,7 @@ def __init__( paid_message_price_changed: Optional[PaidMessagePriceChanged] = None, paid_star_count: Optional[int] = None, direct_message_price_changed: Optional[DirectMessagePriceChanged] = None, + checklist: Optional[Checklist] = None, *, api_kwargs: Optional[JSONDict] = None, ): @@ -1246,6 +1255,7 @@ def __init__( ) self.write_access_allowed: Optional[WriteAccessAllowed] = write_access_allowed self.has_media_spoiler: Optional[bool] = has_media_spoiler + self.checklist: Optional[Checklist] = checklist self.users_shared: Optional[UsersShared] = users_shared self.chat_shared: Optional[ChatShared] = chat_shared self.story: Optional[Story] = story @@ -1456,6 +1466,7 @@ def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "Message": data["direct_message_price_changed"] = de_json_optional( data.get("direct_message_price_changed"), DirectMessagePriceChanged, bot ) + data["checklist"] = de_json_optional(data.get("checklist"), Checklist, bot) api_kwargs = {} # This is a deprecated field that TG still returns for backwards compatibility diff --git a/src/telegram/_reply.py b/src/telegram/_reply.py index ca6b23b0507..97c88215869 100644 --- a/src/telegram/_reply.py +++ b/src/telegram/_reply.py @@ -21,6 +21,7 @@ from typing import TYPE_CHECKING, Optional, Union from telegram._chat import Chat +from telegram._checklists import Checklist from telegram._dice import Dice from telegram._files.animation import Animation from telegram._files.audio import Audio @@ -89,6 +90,9 @@ class ExternalReplyInfo(TelegramObject): the file. has_media_spoiler (:obj:`bool`, optional): :obj:`True`, if the message media is covered by a spoiler animation. + checklist (:class:`telegram.Checklist`, optional): Message is a checklist + + .. versionadded:: NEXT.VERSION contact (:class:`telegram.Contact`, optional): Message is a shared contact, information about the contact. dice (:class:`telegram.Dice`, optional): Message is a dice with random value. @@ -138,6 +142,9 @@ class ExternalReplyInfo(TelegramObject): the file. has_media_spoiler (:obj:`bool`): Optional. :obj:`True`, if the message media is covered by a spoiler animation. + checklist (:class:`telegram.Checklist`): Optional. Message is a checklist + + .. versionadded:: NEXT.VERSION contact (:class:`telegram.Contact`): Optional. Message is a shared contact, information about the contact. dice (:class:`telegram.Dice`): Optional. Message is a dice with random value. @@ -164,6 +171,7 @@ class ExternalReplyInfo(TelegramObject): "animation", "audio", "chat", + "checklist", "contact", "dice", "document", @@ -213,6 +221,7 @@ def __init__( poll: Optional[Poll] = None, venue: Optional[Venue] = None, paid_media: Optional[PaidMediaInfo] = None, + checklist: Optional[Checklist] = None, *, api_kwargs: Optional[JSONDict] = None, ): @@ -232,6 +241,7 @@ def __init__( self.video_note: Optional[VideoNote] = video_note self.voice: Optional[Voice] = voice self.has_media_spoiler: Optional[bool] = has_media_spoiler + self.checklist: Optional[Checklist] = checklist self.contact: Optional[Contact] = contact self.dice: Optional[Dice] = dice self.game: Optional[Game] = game @@ -278,6 +288,7 @@ def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "ExternalReplyI data["poll"] = de_json_optional(data.get("poll"), Poll, bot) data["venue"] = de_json_optional(data.get("venue"), Venue, bot) data["paid_media"] = de_json_optional(data.get("paid_media"), PaidMediaInfo, bot) + data["checklist"] = de_json_optional(data.get("checklist"), Checklist, bot) return super().de_json(data=data, bot=bot) diff --git a/src/telegram/ext/filters.py b/src/telegram/ext/filters.py index 914ba4fbb05..bcb5b67e0b2 100644 --- a/src/telegram/ext/filters.py +++ b/src/telegram/ext/filters.py @@ -46,6 +46,7 @@ "AUDIO", "BOOST_ADDED", "CAPTION", + "CHECKLIST", "COMMAND", "CONTACT", "EFFECT_ID", @@ -920,6 +921,20 @@ def filter(self, message: Message) -> bool: """Updates from supergroup.""" +class _Checklist(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.checklist) + + +CHECKLIST = _Checklist(name="filters.CHECKLIST") +"""Messages that contain :attr:`telegram.Message.checklist`. + +.. versionadded:: NEXT.VERSION +""" + + class Command(MessageFilter): """ Messages with a :attr:`telegram.MessageEntity.BOT_COMMAND`. By default, only allows diff --git a/tests/ext/test_filters.py b/tests/ext/test_filters.py index ca2d01dfc9e..019845007d6 100644 --- a/tests/ext/test_filters.py +++ b/tests/ext/test_filters.py @@ -2797,3 +2797,10 @@ def test_filters_sender_boost_count(self, update): update.message.sender_boost_count = "test" assert filters.SENDER_BOOST_COUNT.check_update(update) assert str(filters.SENDER_BOOST_COUNT) == "filters.SENDER_BOOST_COUNT" + + def test_filters_checklist(self, update): + assert not filters.CHECKLIST.check_update(update) + + update.message.checklist = "test" + assert filters.CHECKLIST.check_update(update) + assert str(filters.CHECKLIST) == "filters.CHECKLIST" diff --git a/tests/test_message.py b/tests/test_message.py index 805b877169a..9c4200a3a4b 100644 --- a/tests/test_message.py +++ b/tests/test_message.py @@ -31,6 +31,8 @@ ChatBackground, ChatBoostAdded, ChatShared, + Checklist, + ChecklistTask, Contact, Dice, DirectMessagePriceChanged, @@ -333,6 +335,12 @@ def message(bot): {"paid_star_count": 291}, {"paid_message_price_changed": PaidMessagePriceChanged(291)}, {"direct_message_price_changed": DirectMessagePriceChanged(True, 100)}, + { + "checklist": Checklist( + "checklist_id", + tasks=[ChecklistTask(id=42, text="task 1"), ChecklistTask(id=43, text="task 2")], + ) + }, ], ids=[ "reply", @@ -411,6 +419,7 @@ def message(bot): "paid_star_count", "paid_message_price_changed", "direct_message_price_changed", + "checklist", ], ) def message_params(bot, request): diff --git a/tests/test_reply.py b/tests/test_reply.py index ad95de4bfe6..6d29c910761 100644 --- a/tests/test_reply.py +++ b/tests/test_reply.py @@ -24,6 +24,8 @@ from telegram import ( BotCommand, Chat, + Checklist, + ChecklistTask, ExternalReplyInfo, Giveaway, LinkPreviewOptions, @@ -47,6 +49,7 @@ def external_reply_info(): link_preview_options=ExternalReplyInfoTestBase.link_preview_options, giveaway=ExternalReplyInfoTestBase.giveaway, paid_media=ExternalReplyInfoTestBase.paid_media, + checklist=ExternalReplyInfoTestBase.checklist, ) @@ -63,6 +66,13 @@ class ExternalReplyInfoTestBase: 1, ) paid_media = PaidMediaInfo(5, [PaidMediaPreview(10, 10, 10)]) + checklist = Checklist( + title="Checklist Title", + tasks=[ + ChecklistTask(text="Item 1", id=1), + ChecklistTask(text="Item 2", id=2), + ], + ) class TestExternalReplyInfoWithoutRequest(ExternalReplyInfoTestBase): @@ -81,6 +91,7 @@ def test_de_json(self, offline_bot): "link_preview_options": self.link_preview_options.to_dict(), "giveaway": self.giveaway.to_dict(), "paid_media": self.paid_media.to_dict(), + "checklist": self.checklist.to_dict(), } external_reply_info = ExternalReplyInfo.de_json(json_dict, offline_bot) @@ -92,6 +103,7 @@ def test_de_json(self, offline_bot): assert external_reply_info.link_preview_options == self.link_preview_options assert external_reply_info.giveaway == self.giveaway assert external_reply_info.paid_media == self.paid_media + assert external_reply_info.checklist == self.checklist def test_to_dict(self, external_reply_info): ext_reply_info_dict = external_reply_info.to_dict() @@ -103,6 +115,7 @@ def test_to_dict(self, external_reply_info): assert ext_reply_info_dict["link_preview_options"] == self.link_preview_options.to_dict() assert ext_reply_info_dict["giveaway"] == self.giveaway.to_dict() assert ext_reply_info_dict["paid_media"] == self.paid_media.to_dict() + assert ext_reply_info_dict["checklist"] == self.checklist.to_dict() def test_equality(self, external_reply_info): a = external_reply_info From 0958918423d5420e2e1e1ff153f37f8ac5a9108f Mon Sep 17 00:00:00 2001 From: Hinrich Mahler <22366557+Bibo-Joshi@users.noreply.github.com> Date: Sat, 5 Jul 2025 14:01:34 +0200 Subject: [PATCH 6/9] Add `Message.checklist_tasks_done/added` --- .../4847.8ujbbBbaZ2VTEdRLeqirSZ.toml | 2 ++ src/telegram/_message.py | 30 ++++++++++++++++++- src/telegram/ext/filters.py | 28 ++++++++++++++++- tests/ext/test_filters.py | 10 +++++++ tests/test_message.py | 15 ++++++++++ 5 files changed, 83 insertions(+), 2 deletions(-) diff --git a/changes/unreleased/4847.8ujbbBbaZ2VTEdRLeqirSZ.toml b/changes/unreleased/4847.8ujbbBbaZ2VTEdRLeqirSZ.toml index f4bc8d849b4..b49564f44ab 100644 --- a/changes/unreleased/4847.8ujbbBbaZ2VTEdRLeqirSZ.toml +++ b/changes/unreleased/4847.8ujbbBbaZ2VTEdRLeqirSZ.toml @@ -4,6 +4,8 @@ features = """ New filters based on Bot API 9.1: * ``filters.StatusUpdate.DIRECT_MESSAGE_PRICE_CHANGED`` for ``Message.direct_message_price_changed`` +* ``filters.StatusUpdate.CHECKLIST_TASKS_ADDED`` for ``Message.checklist_tasks_added`` +* ``filters.StatusUpdate.CHECKLIST_TASKS_DONE`` for ``Message.checklist_tasks_done`` * ``filters.CHECKLIST`` for ``Message.checklist`` """ diff --git a/src/telegram/_message.py b/src/telegram/_message.py index c6c152a63a8..3783a87e9fc 100644 --- a/src/telegram/_message.py +++ b/src/telegram/_message.py @@ -28,7 +28,7 @@ from telegram._chat import Chat from telegram._chatbackground import ChatBackground from telegram._chatboost import ChatBoostAdded -from telegram._checklists import Checklist +from telegram._checklists import Checklist, ChecklistTasksAdded, ChecklistTasksDone from telegram._dice import Dice from telegram._directmessagepricechanged import DirectMessagePriceChanged from telegram._files.animation import Animation @@ -606,6 +606,14 @@ class Message(MaybeInaccessibleMessage): background set. .. versionadded:: 21.2 + checklist_tasks_done (:class:`telegram.ChecklistTasksDone`, optional): Service message: + some tasks in a checklist were marked as done or not done + + .. versionadded:: NEXT.VERSION + checklist_tasks_added (:class:`telegram.ChecklistTasksAdded`, optional): Service message: + tasks were added to a checklist + + .. versionadded:: NEXT.VERSION paid_media (:class:`telegram.PaidMediaInfo`, optional): Message contains paid media; information about the paid media. @@ -959,6 +967,14 @@ class Message(MaybeInaccessibleMessage): background set .. versionadded:: 21.2 + checklist_tasks_done (:class:`telegram.ChecklistTasksDone`): Optional. Service message: + some tasks in a checklist were marked as done or not done + + .. versionadded:: NEXT.VERSION + checklist_tasks_added (:class:`telegram.ChecklistTasksAdded`): Optional. Service message: + tasks were added to a checklist + + .. versionadded:: NEXT.VERSION paid_media (:class:`telegram.PaidMediaInfo`): Optional. Message contains paid media; information about the paid media. @@ -1002,6 +1018,8 @@ class Message(MaybeInaccessibleMessage): "chat_background_set", "chat_shared", "checklist", + "checklist_tasks_added", + "checklist_tasks_done", "connected_website", "contact", "delete_chat_photo", @@ -1174,6 +1192,8 @@ def __init__( paid_star_count: Optional[int] = None, direct_message_price_changed: Optional[DirectMessagePriceChanged] = None, checklist: Optional[Checklist] = None, + checklist_tasks_done: Optional[ChecklistTasksDone] = None, + checklist_tasks_added: Optional[ChecklistTasksAdded] = None, *, api_kwargs: Optional[JSONDict] = None, ): @@ -1274,6 +1294,8 @@ def __init__( self.sender_business_bot: Optional[User] = sender_business_bot self.is_from_offline: Optional[bool] = is_from_offline self.chat_background_set: Optional[ChatBackground] = chat_background_set + self.checklist_tasks_done: Optional[ChecklistTasksDone] = checklist_tasks_done + self.checklist_tasks_added: Optional[ChecklistTasksAdded] = checklist_tasks_added self.effect_id: Optional[str] = effect_id self.show_caption_above_media: Optional[bool] = show_caption_above_media self.paid_media: Optional[PaidMediaInfo] = paid_media @@ -1467,6 +1489,12 @@ def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "Message": data.get("direct_message_price_changed"), DirectMessagePriceChanged, bot ) data["checklist"] = de_json_optional(data.get("checklist"), Checklist, bot) + data["checklist_tasks_done"] = de_json_optional( + data.get("checklist_tasks_done"), ChecklistTasksDone, bot + ) + data["checklist_tasks_added"] = de_json_optional( + data.get("checklist_tasks_added"), ChecklistTasksAdded, bot + ) api_kwargs = {} # This is a deprecated field that TG still returns for backwards compatibility diff --git a/src/telegram/ext/filters.py b/src/telegram/ext/filters.py index bcb5b67e0b2..9703c235de2 100644 --- a/src/telegram/ext/filters.py +++ b/src/telegram/ext/filters.py @@ -1933,7 +1933,10 @@ def filter(self, update: Update) -> bool: StatusUpdate.CHAT_BACKGROUND_SET.check_update(update) or StatusUpdate.CHAT_CREATED.check_update(update) or StatusUpdate.CHAT_SHARED.check_update(update) + or StatusUpdate.CHECKLIST_TASKS_ADDED.check_update(update) + or StatusUpdate.CHECKLIST_TASKS_DONE.check_update(update) or StatusUpdate.CONNECTED_WEBSITE.check_update(update) + or StatusUpdate.DIRECT_MESSAGE_PRICE_CHANGED.check_update(update) or StatusUpdate.DELETE_CHAT_PHOTO.check_update(update) or StatusUpdate.FORUM_TOPIC_CLOSED.check_update(update) or StatusUpdate.FORUM_TOPIC_CREATED.check_update(update) @@ -1962,7 +1965,6 @@ def filter(self, update: Update) -> bool: or StatusUpdate.VIDEO_CHAT_STARTED.check_update(update) or StatusUpdate.WEB_APP_DATA.check_update(update) or StatusUpdate.WRITE_ACCESS_ALLOWED.check_update(update) - or StatusUpdate.DIRECT_MESSAGE_PRICE_CHANGED.check_update(update) ) ALL = _All(name="filters.StatusUpdate.ALL") @@ -2004,6 +2006,30 @@ def filter(self, message: Message) -> bool: .. versionadded:: 20.1 """ + class _ChecklistTasksAdded(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.checklist_tasks_added) + + CHECKLIST_TASKS_ADDED = _ChecklistTasksAdded(name="filters.StatusUpdate.CHECKLIST_TASKS_ADDED") + """Messages that contain :attr:`telegram.Message.checklist_tasks_added`. + + .. versionadded:: NEXT.VERSION + """ + + class _ChecklistTasksDone(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.checklist_tasks_done) + + CHECKLIST_TASKS_DONE = _ChecklistTasksDone(name="filters.StatusUpdate.CHECKLIST_TASKS_DONE") + """Messages that contain :attr:`telegram.Message.checklist_tasks_done`. + + .. versionadded:: NEXT.VERSION + """ + class _ConnectedWebsite(MessageFilter): __slots__ = () diff --git a/tests/ext/test_filters.py b/tests/ext/test_filters.py index 019845007d6..097d6ddf706 100644 --- a/tests/ext/test_filters.py +++ b/tests/ext/test_filters.py @@ -1121,6 +1121,16 @@ def test_filters_status_update(self, update): assert filters.StatusUpdate.DIRECT_MESSAGE_PRICE_CHANGED.check_update(update) update.message.direct_message_price_changed = None + update.message.checklist_tasks_added = "checklist_tasks_added" + assert filters.StatusUpdate.ALL.check_update(update) + assert filters.StatusUpdate.CHECKLIST_TASKS_ADDED.check_update(update) + update.message.checklist_tasks_added = None + + update.message.checklist_tasks_done = "checklist_tasks_done" + assert filters.StatusUpdate.ALL.check_update(update) + assert filters.StatusUpdate.CHECKLIST_TASKS_DONE.check_update(update) + update.message.checklist_tasks_done = None + def test_filters_forwarded(self, update): assert filters.FORWARDED.check_update(update) update.message.forward_origin = MessageOriginHiddenUser(dtm.datetime.utcnow(), 1) diff --git a/tests/test_message.py b/tests/test_message.py index 9c4200a3a4b..1c9d67ecf3b 100644 --- a/tests/test_message.py +++ b/tests/test_message.py @@ -33,6 +33,8 @@ ChatShared, Checklist, ChecklistTask, + ChecklistTasksAdded, + ChecklistTasksDone, Contact, Dice, DirectMessagePriceChanged, @@ -341,6 +343,17 @@ def message(bot): tasks=[ChecklistTask(id=42, text="task 1"), ChecklistTask(id=43, text="task 2")], ) }, + { + "checklist_tasks_done": ChecklistTasksDone( + marked_as_done_task_ids=[1, 2, 3], + marked_as_not_done_task_ids=[4, 5], + ) + }, + { + "checklist_tasks_added": ChecklistTasksAdded( + tasks=[ChecklistTask(id=42, text="task 1"), ChecklistTask(id=43, text="task 2")], + ) + }, ], ids=[ "reply", @@ -420,6 +433,8 @@ def message(bot): "paid_message_price_changed", "direct_message_price_changed", "checklist", + "checklist_tasks_done", + "checklist_tasks_added", ], ) def message_params(bot, request): From 7e80491794e852a0dcb8e8add13907aff1d360ee Mon Sep 17 00:00:00 2001 From: Hinrich Mahler <22366557+Bibo-Joshi@users.noreply.github.com> Date: Sat, 5 Jul 2025 14:10:51 +0200 Subject: [PATCH 7/9] copilot review --- tests/test_checklists.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/test_checklists.py b/tests/test_checklists.py index c322966143f..dda79105437 100644 --- a/tests/test_checklists.py +++ b/tests/test_checklists.py @@ -20,8 +20,15 @@ import pytest -from telegram import ChecklistTask, Dice, MessageEntity, User -from telegram._checklists import Checklist, ChecklistTasksAdded, ChecklistTasksDone +from telegram import ( + Checklist, + ChecklistTask, + ChecklistTasksAdded, + ChecklistTasksDone, + Dice, + MessageEntity, + User, +) from telegram._utils.datetime import UTC, to_timestamp from telegram.constants import ZERO_DATE from tests.auxil.build_messages import make_message From a545b9021031879cbfee131b57d1c7740c2a5540 Mon Sep 17 00:00:00 2001 From: Hinrich Mahler <22366557+Bibo-Joshi@users.noreply.github.com> Date: Sat, 5 Jul 2025 14:17:47 +0200 Subject: [PATCH 8/9] add missing new constants --- src/telegram/constants.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/telegram/constants.py b/src/telegram/constants.py index 5ca7d4ebd5c..907ccb2aba8 100644 --- a/src/telegram/constants.py +++ b/src/telegram/constants.py @@ -2059,6 +2059,21 @@ class MessageType(StringEnum): .. versionadded:: 21.2 """ + CHECKLIST = "checklist" + """:obj:`str`: Messages with :attr:`telegram.Message.checklist`. + + .. versionadded:: NEXT.VERSION + """ + CHECKLIST_TASKS_ADDED = "checklist_tasks_added" + """:obj:`str`: Messages with :attr:`telegram.Message.checklist_tasks_added`. + + .. versionadded:: NEXT.VERSION + """ + CHECKLIST_TASKS_DONE = "checklist_tasks_done" + """:obj:`str`: Messages with :attr:`telegram.Message.checklist_tasks_done`. + + .. versionadded:: NEXT.VERSION + """ CONNECTED_WEBSITE = "connected_website" """:obj:`str`: Messages with :attr:`telegram.Message.connected_website`.""" CONTACT = "contact" From d311128ef626ba8b853cee8e140f12606b4faf3e Mon Sep 17 00:00:00 2001 From: Hinrich Mahler <22366557+Bibo-Joshi@users.noreply.github.com> Date: Sat, 5 Jul 2025 22:14:35 +0200 Subject: [PATCH 9/9] review aelkheir - thanks for the precise reading :) --- src/telegram/_checklists.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/telegram/_checklists.py b/src/telegram/_checklists.py index 3c20d5f4f99..d53309be4b1 100644 --- a/src/telegram/_checklists.py +++ b/src/telegram/_checklists.py @@ -177,12 +177,12 @@ class Checklist(TelegramObject): Attributes: title (:obj:`str`): Title of the checklist. - title_entities (Tuple[:class:`telegram.MessageEntity`]): Special + title_entities (Tuple[:class:`telegram.MessageEntity`]): Optional. Special entities that appear in the checklist title. tasks (Tuple[:class:`telegram.ChecklistTask`]): List of tasks in the checklist. - others_can_add_tasks (:obj:`bool`): :obj:`True` if users other than the creator + others_can_add_tasks (:obj:`bool`): Optional. :obj:`True` if users other than the creator of the list can add tasks to the list - others_can_mark_tasks_as_done (:obj:`bool`): :obj:`True` if users other than the + others_can_mark_tasks_as_done (:obj:`bool`): Optional. :obj:`True` if users other than the creator of the list can mark tasks as done or not done """ @@ -226,13 +226,13 @@ def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "Checklist": return super().de_json(data=data, bot=bot) def parse_entity(self, entity: MessageEntity) -> str: - """Returns the text in :attr:`text` + """Returns the text in :attr:`title` from a given :class:`telegram.MessageEntity` of :attr:`title_entities`. Note: This method is present because Telegram calculates the offset and length in UTF-16 codepoint pairs, which some versions of Python don't handle automatically. - (That is, you can't just slice ``Message.text`` with the offset and length.) + (That is, you can't just slice :attr:`title` with the offset and length.) Args: entity (:class:`telegram.MessageEntity`): The entity to extract the text from. It must @@ -246,7 +246,7 @@ def parse_entity(self, entity: MessageEntity) -> str: def parse_entities(self, types: Optional[list[str]] = None) -> dict[MessageEntity, str]: """ Returns a :obj:`dict` that maps :class:`telegram.MessageEntity` to :obj:`str`. - It contains entities from this polls question filtered by their ``type`` attribute as + It contains entities from this checklist's title filtered by their ``type`` attribute as the key, and the text that each entity belongs to as the value of the :obj:`dict`. Note: @@ -291,10 +291,10 @@ class ChecklistTasksDone(TelegramObject): whose tasks were marked as done or not done. Note that the ~:class:`telegram.Message` object in this field will not contain the :attr:`~telegram.Message.reply_to_message` field even if it itself is a reply. - marked_as_done_task_ids (Tuple[:obj:`int`]): Identifiers of the tasks that were marked - as done - marked_as_not_done_task_ids (Tuple[:obj:`int`]): Identifiers of the tasks that were - marked as not done + marked_as_done_task_ids (Tuple[:obj:`int`]): Optional. Identifiers of the tasks that were + marked as done + marked_as_not_done_task_ids (Tuple[:obj:`int`]): Optional. Identifiers of the tasks that + were marked as not done """ __slots__ = ( @@ -337,7 +337,7 @@ def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "ChecklistTasks class ChecklistTasksAdded(TelegramObject): """ - Describes a service message about checklist tasks added to a checklist. + Describes a service message about tasks added to a checklist. Objects of this class are comparable in terms of equality. Two objects of this class are considered equal, if their :attr:`tasks` are equal.