From a4edc13ec0ae9f31e93ee6bb8e0a77ca46bd727b Mon Sep 17 00:00:00 2001 From: Hinrich Mahler <22366557+Bibo-Joshi@users.noreply.github.com> Date: Wed, 2 Jul 2025 21:58:49 +0200 Subject: [PATCH 01/11] Add `ChecklistTask` --- docs/source/telegram.at-tree.rst | 1 + docs/source/telegram.checklisttask.rst | 6 + src/telegram/__init__.py | 2 + src/telegram/_checklists.py | 156 ++++++++++++++++++++++++ src/telegram/constants.py | 1 + tests/test_checklists.py | 162 +++++++++++++++++++++++++ 6 files changed, 328 insertions(+) create mode 100644 docs/source/telegram.checklisttask.rst create mode 100644 src/telegram/_checklists.py create mode 100644 tests/test_checklists.py diff --git a/docs/source/telegram.at-tree.rst b/docs/source/telegram.at-tree.rst index 63da86e76de..133b3fd79d4 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.checklisttask telegram.copytextbutton telegram.backgroundtype telegram.backgroundtypefill diff --git a/docs/source/telegram.checklisttask.rst b/docs/source/telegram.checklisttask.rst new file mode 100644 index 00000000000..27f44d629de --- /dev/null +++ b/docs/source/telegram.checklisttask.rst @@ -0,0 +1,6 @@ +ChecklistTask +============= + +.. autoclass:: telegram.ChecklistTask + :members: + :show-inheritance: diff --git a/src/telegram/__init__.py b/src/telegram/__init__.py index 0f20f0ba605..f25e90b9b7e 100644 --- a/src/telegram/__init__.py +++ b/src/telegram/__init__.py @@ -82,6 +82,7 @@ "ChatPermissions", "ChatPhoto", "ChatShared", + "ChecklistTask", "ChosenInlineResult", "Contact", "CopyTextButton", @@ -381,6 +382,7 @@ ) from ._chatmemberupdated import ChatMemberUpdated from ._chatpermissions import ChatPermissions +from ._checklists import 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 new file mode 100644 index 00000000000..21555a79c35 --- /dev/null +++ b/src/telegram/_checklists.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2025 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +"""This module contains an objects related to Telegram checklists.""" +import datetime as dtm +from collections.abc import Sequence +from typing import TYPE_CHECKING, Optional + +from telegram._messageentity import MessageEntity +from telegram._telegramobject import TelegramObject +from telegram._user import User +from telegram._utils.argumentparsing import de_json_optional, de_list_optional, parse_sequence_arg +from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp +from telegram._utils.entities import parse_message_entities, parse_message_entity +from telegram._utils.types import JSONDict +from telegram.constants import ZERO_DATE + +if TYPE_CHECKING: + from telegram import Bot + + +class ChecklistTask(TelegramObject): + """ + Describes a task in a checklist. + + Objects of this class are comparable in terms of equality. + Two objects of this class are considered equal, if all their :attr:`id` is equal. + + .. versionadded:: NEXT.VERSION + + Args: + id (:obj:`int`): Unique identifier of the task. + text (:obj:`str`): Text of the task. + text_entities (Sequence[:class:`telegram.MessageEntity`], optional): Special + entities that appear in the task text. + completed_by_user (:class:`telegram.User`, optional): User that completed the task; omitted + if the task wasn't completed + completion_date (:class:`datetime.datetime`, optional): Point in time (Unix timestamp) when + the task was completed; 0 in Univ time if the task wasn't completed + + |datetime_localization| + + Attributes: + id (:obj:`int`): Unique identifier of the task. + text (:obj:`str`): Text of the task. + text_entities (Tuple[:class:`telegram.MessageEntity`]): Optional. Special + entities that appear in the task text. + completed_by_user (:class:`telegram.User`): Optional. User that completed the task; omitted + if the task wasn't completed + completion_date (:class:`datetime.datetime`): Optional. Point in time (Unix timestamp) when + the task was completed; 0 in Univ time if the task wasn't completed + + |datetime_localization| + """ + + __slots__ = ( + "completed_by_user", + "completion_date", + "id", + "text", + "text_entities", + ) + + def __init__( + self, + id: int, # pylint: disable=redefined-builtin + text: str, + text_entities: Optional[Sequence["MessageEntity"]] = None, + completed_by_user: Optional["User"] = None, + completion_date: Optional[dtm.datetime] = None, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.id = id + self.text = text + self.text_entities = parse_sequence_arg(text_entities) + self.completed_by_user = completed_by_user + self.completion_date = completion_date + + self._id_attrs = (self.id,) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "ChecklistTask": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + # Get the local timezone from the bot if it has defaults + loc_tzinfo = extract_tzinfo_from_defaults(bot) + + if (date := data.get("completion_date")) == 0: + data["completion_date"] = ZERO_DATE + else: + data["completion_date"] = from_timestamp(date, tzinfo=loc_tzinfo) + + data["completed_by_user"] = de_json_optional(data.get("completed_by_user"), User, bot) + data["text_entities"] = de_list_optional(data.get("text_entities"), MessageEntity, 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:`text_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:`text_entities`. + + Returns: + :obj:`str`: The text of the given entity. + """ + return parse_message_entity(self.text, 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:`text_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.text, self.text_entities, types) diff --git a/src/telegram/constants.py b/src/telegram/constants.py index 3e5777803b7..f57fe4c4c67 100644 --- a/src/telegram/constants.py +++ b/src/telegram/constants.py @@ -185,6 +185,7 @@ class _AccentColor(NamedTuple): #: :obj:`datetime.datetime`, value of unix 0. #: This date literal is used in :class:`telegram.InaccessibleMessage` +# and :class:`telegram.ChecklistTask`. #: #: .. versionadded:: 20.8 ZERO_DATE: Final[dtm.datetime] = dtm.datetime(1970, 1, 1, tzinfo=UTC) diff --git a/tests/test_checklists.py b/tests/test_checklists.py new file mode 100644 index 00000000000..b73a2e54d5a --- /dev/null +++ b/tests/test_checklists.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2025 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +import datetime as dtm + +import pytest + +from telegram import ChecklistTask, MessageEntity, User +from telegram._utils.datetime import UTC, to_timestamp +from telegram.constants import ZERO_DATE +from tests.auxil.slots import mro_slots + + +class ChecklistTaskTestBase: + id = 42 + text = "here is a text" + text_entities = [ + MessageEntity(type="bold", offset=0, length=4), + MessageEntity(type="italic", offset=5, length=2), + ] + completed_by_user = User(id=1, first_name="Test", last_name="User", is_bot=False) + completion_date = dtm.datetime.now(tz=UTC).replace(microsecond=0) + + +@pytest.fixture(scope="module") +def checklist_task(): + return ChecklistTask( + id=ChecklistTaskTestBase.id, + text=ChecklistTaskTestBase.text, + text_entities=ChecklistTaskTestBase.text_entities, + completed_by_user=ChecklistTaskTestBase.completed_by_user, + completion_date=ChecklistTaskTestBase.completion_date, + ) + + +class TestChecklistTaskWithoutRequest(ChecklistTaskTestBase): + def test_slot_behaviour(self, checklist_task): + for attr in checklist_task.__slots__: + assert getattr(checklist_task, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(checklist_task)) == len( + set(mro_slots(checklist_task)) + ), "duplicate slot" + + def test_to_dict(self, checklist_task): + clt_dict = checklist_task.to_dict() + assert isinstance(clt_dict, dict) + assert clt_dict["id"] == self.id + assert clt_dict["text"] == self.text + assert clt_dict["text_entities"] == [entity.to_dict() for entity in self.text_entities] + assert clt_dict["completed_by_user"] == self.completed_by_user.to_dict() + assert clt_dict["completion_date"] == to_timestamp(self.completion_date) + + def test_de_json(self, offline_bot): + json_dict = { + "id": self.id, + "text": self.text, + "text_entities": [entity.to_dict() for entity in self.text_entities], + "completed_by_user": self.completed_by_user.to_dict(), + "completion_date": to_timestamp(self.completion_date), + } + clt = ChecklistTask.de_json(json_dict, offline_bot) + assert isinstance(clt, ChecklistTask) + assert clt.id == self.id + assert clt.text == self.text + assert clt.text_entities == tuple(self.text_entities) + assert clt.completed_by_user == self.completed_by_user + assert clt.completion_date == self.completion_date + + def test_de_json_required_fields(self, offline_bot): + json_dict = { + "id": self.id, + "text": self.text, + } + clt = ChecklistTask.de_json(json_dict, offline_bot) + assert isinstance(clt, ChecklistTask) + assert clt.id == self.id + assert clt.text == self.text + assert clt.text_entities == () + assert clt.completed_by_user is None + assert clt.completion_date is None + + def test_de_json_localization(self, offline_bot, raw_bot, tz_bot): + json_dict = { + "id": self.id, + "text": self.text, + "completion_date": to_timestamp(self.completion_date), + } + clt_bot = ChecklistTask.de_json(json_dict, offline_bot) + clt_bot_raw = ChecklistTask.de_json(json_dict, raw_bot) + clt_bot_tz = ChecklistTask.de_json(json_dict, tz_bot) + + # comparing utcoffsets because comparing tzinfo objects is not reliable + completion_date_offset = clt_bot_tz.completion_date.utcoffset() + completion_date_offset_tz = tz_bot.defaults.tzinfo.utcoffset( + clt_bot_tz.completion_date.replace(tzinfo=None) + ) + + assert clt_bot.completion_date.tzinfo == UTC + assert clt_bot_raw.completion_date.tzinfo == UTC + assert completion_date_offset_tz == completion_date_offset + + @pytest.mark.parametrize( + ("completion_date", "expected"), + [ + (None, None), + (0, ZERO_DATE), + (1735689600, dtm.datetime(2025, 1, 1, tzinfo=dtm.UTC)), + ], + ) + def test_de_json_completion_date(self, offline_bot, completion_date, expected): + json_dict = { + "id": self.id, + "text": self.text, + "completion_date": completion_date, + } + clt = ChecklistTask.de_json(json_dict, offline_bot) + assert isinstance(clt, ChecklistTask) + assert clt.completion_date == expected + + def test_parse_entity(self, checklist_task): + assert checklist_task.parse_entity(checklist_task.text_entities[0]) == "here" + + def test_parse_entities(self, checklist_task): + assert checklist_task.parse_entities(MessageEntity.BOLD) == { + checklist_task.text_entities[0]: "here" + } + assert checklist_task.parse_entities() == { + checklist_task.text_entities[0]: "here", + checklist_task.text_entities[1]: "is", + } + + def test_equality(self, checklist_task): + clt1 = checklist_task + clt2 = ChecklistTask( + id=self.id, + text="other text", + ) + clt3 = ChecklistTask( + id=self.id + 1, + text=self.text, + ) + + assert clt1 == clt2 + assert hash(clt1) == hash(clt2) + + assert clt1 != clt3 + assert hash(clt1) != hash(clt3) From f467b67b04c4371c498eee8c7afa449ec797ff7c Mon Sep 17 00:00:00 2001 From: Hinrich Mahler <22366557+Bibo-Joshi@users.noreply.github.com> Date: Thu, 3 Jul 2025 19:37:30 +0200 Subject: [PATCH 02/11] Bump Bot API Version --- README.rst | 4 ++-- src/telegram/constants.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index a1aa26871e8..9d0ff953ba7 100644 --- a/README.rst +++ b/README.rst @@ -11,7 +11,7 @@ :target: https://pypi.org/project/python-telegram-bot/ :alt: Supported Python versions -.. image:: https://img.shields.io/badge/Bot%20API-9.0-blue?logo=telegram +.. image:: https://img.shields.io/badge/Bot%20API-9.1-blue?logo=telegram :target: https://core.telegram.org/bots/api-changelog :alt: Supported Bot API version @@ -81,7 +81,7 @@ After installing_ the library, be sure to check out the section on `working with Telegram API support ~~~~~~~~~~~~~~~~~~~~ -All types and methods of the Telegram Bot API **9.0** are natively supported by this library. +All types and methods of the Telegram Bot API **9.1** are natively supported by this library. In addition, Bot API functionality not yet natively included can still be used as described `in our wiki `_. Notable Features diff --git a/src/telegram/constants.py b/src/telegram/constants.py index f57fe4c4c67..81d21e67c9a 100644 --- a/src/telegram/constants.py +++ b/src/telegram/constants.py @@ -169,7 +169,7 @@ class _AccentColor(NamedTuple): #: :data:`telegram.__bot_api_version_info__`. #: #: .. versionadded:: 20.0 -BOT_API_VERSION_INFO: Final[_BotAPIVersion] = _BotAPIVersion(major=9, minor=0) +BOT_API_VERSION_INFO: Final[_BotAPIVersion] = _BotAPIVersion(major=9, minor=1) #: :obj:`str`: Telegram Bot API #: version supported by this version of `python-telegram-bot`. Also available as #: :data:`telegram.__bot_api_version__`. From c2980632bb7298936224110e774236e7fd21fc71 Mon Sep 17 00:00:00 2001 From: Hinrich Mahler <22366557+Bibo-Joshi@users.noreply.github.com> Date: Thu, 3 Jul 2025 19:40:48 +0200 Subject: [PATCH 03/11] Add chango fragment --- .../unreleased/4847.8ujbbBbaZ2VTEdRLeqirSZ.toml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 changes/unreleased/4847.8ujbbBbaZ2VTEdRLeqirSZ.toml diff --git a/changes/unreleased/4847.8ujbbBbaZ2VTEdRLeqirSZ.toml b/changes/unreleased/4847.8ujbbBbaZ2VTEdRLeqirSZ.toml new file mode 100644 index 00000000000..b0ef7271f19 --- /dev/null +++ b/changes/unreleased/4847.8ujbbBbaZ2VTEdRLeqirSZ.toml @@ -0,0 +1,16 @@ +highlights = "Full Support for Bot API 9.1" + +breaking = "Optional Section Content" +security = "Optional Section Content" +deprecations = "Optional Section Content" +features = "Optional Section Content" +bugfixes = "Optional Section Content" +dependencies = "Optional Section Content" +other = "Optional Section Content" +documentation = "Optional Section Content" +internal = "Optional Section Content" + +[[pull_requests]] +uid = "4847" +author_uid = "Bibo-Joshi" +closes_threads = ["4845"] From 204e51c331b1514654d978d4e5803f82d4f280d1 Mon Sep 17 00:00:00 2001 From: Hinrich Mahler <22366557+Bibo-Joshi@users.noreply.github.com> Date: Thu, 3 Jul 2025 19:53:54 +0200 Subject: [PATCH 04/11] try fixing type completeness --- src/telegram/_checklists.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/telegram/_checklists.py b/src/telegram/_checklists.py index 21555a79c35..f6b8c24e085 100644 --- a/src/telegram/_checklists.py +++ b/src/telegram/_checklists.py @@ -80,18 +80,18 @@ def __init__( self, id: int, # pylint: disable=redefined-builtin text: str, - text_entities: Optional[Sequence["MessageEntity"]] = None, - completed_by_user: Optional["User"] = None, + text_entities: Optional[Sequence[MessageEntity]] = None, + completed_by_user: Optional[User] = None, completion_date: Optional[dtm.datetime] = None, *, api_kwargs: Optional[JSONDict] = None, ): super().__init__(api_kwargs=api_kwargs) - self.id = id - self.text = text - self.text_entities = parse_sequence_arg(text_entities) - self.completed_by_user = completed_by_user - self.completion_date = completion_date + self.id: int = id + self.text: str = text + self.text_entities: tuple[MessageEntity, ...] = parse_sequence_arg(text_entities) + self.completed_by_user: Optional[User] = completed_by_user + self.completion_date: Optional[dtm.datetime] = completion_date self._id_attrs = (self.id,) From 9ece965da210fe9043bca909100e5fe318a76370 Mon Sep 17 00:00:00 2001 From: Hinrich Mahler <22366557+Bibo-Joshi@users.noreply.github.com> Date: Thu, 3 Jul 2025 19:54:38 +0200 Subject: [PATCH 05/11] try fixing tests --- tests/test_checklists.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_checklists.py b/tests/test_checklists.py index b73a2e54d5a..d55cf7467b0 100644 --- a/tests/test_checklists.py +++ b/tests/test_checklists.py @@ -119,7 +119,7 @@ def test_de_json_localization(self, offline_bot, raw_bot, tz_bot): [ (None, None), (0, ZERO_DATE), - (1735689600, dtm.datetime(2025, 1, 1, tzinfo=dtm.UTC)), + (1735689600, dtm.datetime(2025, 1, 1, tzinfo=UTC)), ], ) def test_de_json_completion_date(self, offline_bot, completion_date, expected): From faa553d2f3ddfefac7dfe4184f132e2e304eee9a Mon Sep 17 00:00:00 2001 From: Harshil <37377066+harshil21@users.noreply.github.com> Date: Sat, 5 Jul 2025 00:25:05 +0400 Subject: [PATCH 06/11] API 9.1 Gifts (#4849) --- .../4849.4mPSpBY2r77o75ycywXPiz.toml | 5 ++ src/telegram/_ownedgift.py | 18 ++++++- src/telegram/_uniquegift.py | 50 +++++++++++++++++-- src/telegram/constants.py | 5 ++ tests/test_ownedgift.py | 9 +++- tests/test_uniquegift.py | 42 ++++++++++++++++ 6 files changed, 121 insertions(+), 8 deletions(-) create mode 100644 changes/unreleased/4849.4mPSpBY2r77o75ycywXPiz.toml diff --git a/changes/unreleased/4849.4mPSpBY2r77o75ycywXPiz.toml b/changes/unreleased/4849.4mPSpBY2r77o75ycywXPiz.toml new file mode 100644 index 00000000000..693c1b277e4 --- /dev/null +++ b/changes/unreleased/4849.4mPSpBY2r77o75ycywXPiz.toml @@ -0,0 +1,5 @@ +features = "API 9.1 Gifts " +[[pull_requests]] +uid = "4849" +author_uid = "harshil21" +closes_threads = [] diff --git a/src/telegram/_ownedgift.py b/src/telegram/_ownedgift.py index 875a01540f1..8efdccfe111 100644 --- a/src/telegram/_ownedgift.py +++ b/src/telegram/_ownedgift.py @@ -347,13 +347,17 @@ class OwnedGiftUnique(OwnedGift): bot; for gifts received on behalf of business accounts only. sender_user (:class:`telegram.User`, optional): Sender of the gift if it is a known user. send_date (:obj:`datetime.datetime`): Date the gift was sent as :class:`datetime.datetime`. - |datetime_localization|. + |datetime_localization| is_saved (:obj:`bool`, optional): :obj:`True`, if the gift is displayed on the account's profile page; for gifts received on behalf of business accounts only. can_be_transferred (:obj:`bool`, optional): :obj:`True`, if the gift can be transferred to another owner; for gifts received on behalf of business accounts only. transfer_star_count (:obj:`int`, optional): Number of Telegram Stars that must be paid to transfer the gift; omitted if the bot cannot transfer the gift. + next_transfer_date (:obj:`datetime.datetime`, optional): Date when the gift can be + transferred. If it's in the past, then the gift can be transferred now. + |datetime_localization| + .. versionadded:: NEXT.VERSION Attributes: type (:obj:`str`): Type of the owned gift, always :tg-const:`~telegram.OwnedGift.UNIQUE`. @@ -362,19 +366,24 @@ class OwnedGiftUnique(OwnedGift): bot; for gifts received on behalf of business accounts only. sender_user (:class:`telegram.User`): Optional. Sender of the gift if it is a known user. send_date (:obj:`datetime.datetime`): Date the gift was sent as :class:`datetime.datetime`. - |datetime_localization|. + |datetime_localization| is_saved (:obj:`bool`): Optional. :obj:`True`, if the gift is displayed on the account's profile page; for gifts received on behalf of business accounts only. can_be_transferred (:obj:`bool`): Optional. :obj:`True`, if the gift can be transferred to another owner; for gifts received on behalf of business accounts only. transfer_star_count (:obj:`int`): Optional. Number of Telegram Stars that must be paid to transfer the gift; omitted if the bot cannot transfer the gift. + next_transfer_date (:obj:`datetime.datetime`): Optional. Date when the gift can be + transferred. If it's in the past, then the gift can be transferred now. + |datetime_localization| + .. versionadded:: NEXT.VERSION """ __slots__ = ( "can_be_transferred", "gift", "is_saved", + "next_transfer_date", "owned_gift_id", "send_date", "sender_user", @@ -390,6 +399,7 @@ def __init__( is_saved: Optional[bool] = None, can_be_transferred: Optional[bool] = None, transfer_star_count: Optional[int] = None, + next_transfer_date: Optional[dtm.datetime] = None, *, api_kwargs: Optional[JSONDict] = None, ) -> None: @@ -403,6 +413,7 @@ def __init__( self.is_saved: Optional[bool] = is_saved self.can_be_transferred: Optional[bool] = can_be_transferred self.transfer_star_count: Optional[int] = transfer_star_count + self.next_transfer_date: Optional[dtm.datetime] = next_transfer_date self._id_attrs = (self.type, self.gift, self.send_date) @@ -415,5 +426,8 @@ def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "OwnedGiftUniqu data["send_date"] = from_timestamp(data.get("send_date"), tzinfo=loc_tzinfo) data["sender_user"] = de_json_optional(data.get("sender_user"), User, bot) data["gift"] = de_json_optional(data.get("gift"), UniqueGift, bot) + data["next_transfer_date"] = from_timestamp( + data.get("next_transfer_date"), tzinfo=loc_tzinfo + ) return super().de_json(data=data, bot=bot) # type: ignore[return-value] diff --git a/src/telegram/_uniquegift.py b/src/telegram/_uniquegift.py index fa494a8e55a..9c2efae7faa 100644 --- a/src/telegram/_uniquegift.py +++ b/src/telegram/_uniquegift.py @@ -18,6 +18,7 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/] """This module contains classes related to unique gifs.""" +import datetime as dtm from typing import TYPE_CHECKING, Final, Optional from telegram import constants @@ -25,6 +26,7 @@ from telegram._telegramobject import TelegramObject from telegram._utils import enum from telegram._utils.argumentparsing import de_json_optional +from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp from telegram._utils.types import JSONDict if TYPE_CHECKING: @@ -340,31 +342,63 @@ class UniqueGiftInfo(TelegramObject): Args: gift (:class:`UniqueGift`): Information about the gift. - origin (:obj:`str`): Origin of the gift. Currently, either :attr:`UPGRADE` - or :attr:`TRANSFER`. + origin (:obj:`str`): Origin of the gift. Currently, either :attr:`UPGRADE` for gifts + upgraded from regular gifts, :attr:`TRANSFER` for gifts transferred from other users + or channels, or :attr:`RESALE` for gifts bought from other users. + + .. versionchanged:: NEXT.VERSION + The :attr:`RESALE` origin was added. owned_gift_id (:obj:`str`, optional) Unique identifier of the received gift for the bot; only present for gifts received on behalf of business accounts. transfer_star_count (:obj:`int`, optional): Number of Telegram Stars that must be paid to transfer the gift; omitted if the bot cannot transfer the gift. + last_resale_star_count (:obj:`int`, optional): For gifts bought from other users, the price + paid for the gift. + + .. versionadded:: NEXT.VERSION + next_transfer_date (:obj:`datetime.datetime`, optional): Date when the gift can be + transferred. If it's in the past, then the gift can be transferred now. + |datetime_localization| + + .. versionadded:: NEXT.VERSION Attributes: gift (:class:`UniqueGift`): Information about the gift. - origin (:obj:`str`): Origin of the gift. Currently, either :attr:`UPGRADE` - or :attr:`TRANSFER`. + origin (:obj:`str`): Origin of the gift. Currently, either :attr:`UPGRADE` for gifts + upgraded from regular gifts, :attr:`TRANSFER` for gifts transferred from other users + or channels, or :attr:`RESALE` for gifts bought from other users. + + .. versionchanged:: NEXT.VERSION + The :attr:`RESALE` origin was added. owned_gift_id (:obj:`str`) Optional. Unique identifier of the received gift for the bot; only present for gifts received on behalf of business accounts. transfer_star_count (:obj:`int`): Optional. Number of Telegram Stars that must be paid to transfer the gift; omitted if the bot cannot transfer the gift. + last_resale_star_count (:obj:`int`): Optional. For gifts bought from other users, the price + paid for the gift. + .. versionadded:: NEXT.VERSION + next_transfer_date (:obj:`datetime.datetime`): Optional. Date when the gift can be + transferred. If it's in the past, then the gift can be transferred now. + |datetime_localization| + + .. versionadded:: NEXT.VERSION """ UPGRADE: Final[str] = constants.UniqueGiftInfoOrigin.UPGRADE """:const:`telegram.constants.UniqueGiftInfoOrigin.UPGRADE`""" TRANSFER: Final[str] = constants.UniqueGiftInfoOrigin.TRANSFER """:const:`telegram.constants.UniqueGiftInfoOrigin.TRANSFER`""" + RESALE: Final[str] = constants.UniqueGiftInfoOrigin.RESALE + """:const:`telegram.constants.UniqueGiftInfoOrigin.RESALE` + + .. versionadded:: NEXT.VERSION + """ __slots__ = ( "gift", + "last_resale_star_count", + "next_transfer_date", "origin", "owned_gift_id", "transfer_star_count", @@ -376,6 +410,8 @@ def __init__( origin: str, owned_gift_id: Optional[str] = None, transfer_star_count: Optional[int] = None, + last_resale_star_count: Optional[int] = None, + next_transfer_date: Optional[dtm.datetime] = None, *, api_kwargs: Optional[JSONDict] = None, ): @@ -386,6 +422,8 @@ def __init__( # Optional self.owned_gift_id: Optional[str] = owned_gift_id self.transfer_star_count: Optional[int] = transfer_star_count + self.last_resale_star_count: Optional[int] = last_resale_star_count + self.next_transfer_date: Optional[dtm.datetime] = next_transfer_date self._id_attrs = (self.gift, self.origin) @@ -396,6 +434,10 @@ def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "UniqueGiftInfo """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) + loc_tzinfo = extract_tzinfo_from_defaults(bot) data["gift"] = de_json_optional(data.get("gift"), UniqueGift, bot) + data["next_transfer_date"] = from_timestamp( + data.get("next_transfer_date"), tzinfo=loc_tzinfo + ) return super().de_json(data=data, bot=bot) diff --git a/src/telegram/constants.py b/src/telegram/constants.py index 81d21e67c9a..92426b94f32 100644 --- a/src/telegram/constants.py +++ b/src/telegram/constants.py @@ -3211,6 +3211,11 @@ class UniqueGiftInfoOrigin(StringEnum): """:obj:`str` gift upgraded""" TRANSFER = "transfer" """:obj:`str` gift transfered""" + RESALE = "resale" + """:obj:`str` gift bought from other users + + .. versionadded:: NEXT.VERSION + """ class UpdateType(StringEnum): diff --git a/tests/test_ownedgift.py b/tests/test_ownedgift.py index b37794f3483..67ecfada2ee 100644 --- a/tests/test_ownedgift.py +++ b/tests/test_ownedgift.py @@ -18,7 +18,6 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. import datetime as dtm -from collections.abc import Sequence from copy import deepcopy import pytest @@ -96,6 +95,7 @@ class OwnedGiftTestBase: prepaid_upgrade_star_count = 200 can_be_transferred = True transfer_star_count = 300 + next_transfer_date = dtm.datetime.now(tz=UTC).replace(microsecond=0) class TestOwnedGiftWithoutRequest(OwnedGiftTestBase): @@ -139,6 +139,7 @@ def test_de_json_subclass(self, offline_bot, og_type, subclass, gift): "prepaid_upgrade_star_count": self.prepaid_upgrade_star_count, "can_be_transferred": self.can_be_transferred, "transfer_star_count": self.transfer_star_count, + "next_transfer_date": to_timestamp(self.next_transfer_date), } og = OwnedGift.de_json(json_dict, offline_bot) @@ -292,6 +293,7 @@ def owned_gift_unique(): is_saved=TestOwnedGiftUniqueWithoutRequest.is_saved, can_be_transferred=TestOwnedGiftUniqueWithoutRequest.can_be_transferred, transfer_star_count=TestOwnedGiftUniqueWithoutRequest.transfer_star_count, + next_transfer_date=TestOwnedGiftUniqueWithoutRequest.next_transfer_date, ) @@ -313,6 +315,7 @@ def test_de_json(self, offline_bot): "is_saved": self.is_saved, "can_be_transferred": self.can_be_transferred, "transfer_star_count": self.transfer_star_count, + "next_transfer_date": to_timestamp(self.next_transfer_date), } ogu = OwnedGiftUnique.de_json(json_dict, offline_bot) assert ogu.gift == self.unique_gift @@ -322,6 +325,7 @@ def test_de_json(self, offline_bot): assert ogu.is_saved == self.is_saved assert ogu.can_be_transferred == self.can_be_transferred assert ogu.transfer_star_count == self.transfer_star_count + assert ogu.next_transfer_date == self.next_transfer_date assert ogu.api_kwargs == {} def test_to_dict(self, owned_gift_unique): @@ -335,6 +339,7 @@ def test_to_dict(self, owned_gift_unique): assert json_dict["is_saved"] == self.is_saved assert json_dict["can_be_transferred"] == self.can_be_transferred assert json_dict["transfer_star_count"] == self.transfer_star_count + assert json_dict["next_transfer_date"] == to_timestamp(self.next_transfer_date) def test_equality(self, owned_gift_unique): a = owned_gift_unique @@ -365,7 +370,7 @@ def owned_gifts(request): class OwnedGiftsTestBase: total_count = 2 next_offset = "next_offset_str" - gifts: Sequence[OwnedGifts] = [ + gifts: list[OwnedGift] = [ OwnedGiftRegular( gift=Gift( id="id1", diff --git a/tests/test_uniquegift.py b/tests/test_uniquegift.py index 051974b959b..926cdcae91f 100644 --- a/tests/test_uniquegift.py +++ b/tests/test_uniquegift.py @@ -17,6 +17,8 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. +import datetime as dtm + import pytest from telegram import ( @@ -29,6 +31,8 @@ UniqueGiftModel, UniqueGiftSymbol, ) +from telegram._utils.datetime import UTC, to_timestamp +from telegram.constants import UniqueGiftInfoOrigin from tests.auxil.slots import mro_slots @@ -383,6 +387,8 @@ def unique_gift_info(): origin=UniqueGiftInfoTestBase.origin, owned_gift_id=UniqueGiftInfoTestBase.owned_gift_id, transfer_star_count=UniqueGiftInfoTestBase.transfer_star_count, + last_resale_star_count=UniqueGiftInfoTestBase.last_resale_star_count, + next_transfer_date=UniqueGiftInfoTestBase.next_transfer_date, ) @@ -410,6 +416,8 @@ class UniqueGiftInfoTestBase: origin = UniqueGiftInfo.UPGRADE owned_gift_id = "some_id" transfer_star_count = 10 + last_resale_star_count = 5 + next_transfer_date = dtm.datetime.now(tz=UTC).replace(microsecond=0) class TestUniqueGiftInfoWithoutRequest(UniqueGiftInfoTestBase): @@ -426,6 +434,8 @@ def test_de_json(self, offline_bot): "origin": self.origin, "owned_gift_id": self.owned_gift_id, "transfer_star_count": self.transfer_star_count, + "last_resale_star_count": self.last_resale_star_count, + "next_transfer_date": to_timestamp(self.next_transfer_date), } unique_gift_info = UniqueGiftInfo.de_json(json_dict, offline_bot) assert unique_gift_info.api_kwargs == {} @@ -433,6 +443,32 @@ def test_de_json(self, offline_bot): assert unique_gift_info.origin == self.origin assert unique_gift_info.owned_gift_id == self.owned_gift_id assert unique_gift_info.transfer_star_count == self.transfer_star_count + assert unique_gift_info.last_resale_star_count == self.last_resale_star_count + assert unique_gift_info.next_transfer_date == self.next_transfer_date + + def test_de_json_localization(self, tz_bot, offline_bot, raw_bot): + json_dict = { + "gift": self.gift.to_dict(), + "origin": self.origin, + "owned_gift_id": self.owned_gift_id, + "transfer_star_count": self.transfer_star_count, + "last_resale_star_count": self.last_resale_star_count, + "next_transfer_date": to_timestamp(self.next_transfer_date), + } + + unique_gift_info_raw = UniqueGiftInfo.de_json(json_dict, raw_bot) + unique_gift_info_offline = UniqueGiftInfo.de_json(json_dict, offline_bot) + unique_gift_info_tz = UniqueGiftInfo.de_json(json_dict, tz_bot) + + # comparing utcoffsets because comparing timezones is unpredicatable + unique_gift_info_tz_offset = unique_gift_info_tz.next_transfer_date.utcoffset() + tz_bot_offset = tz_bot.defaults.tzinfo.utcoffset( + unique_gift_info_tz.next_transfer_date.replace(tzinfo=None) + ) + + assert unique_gift_info_raw.next_transfer_date.tzinfo == UTC + assert unique_gift_info_offline.next_transfer_date.tzinfo == UTC + assert unique_gift_info_tz_offset == tz_bot_offset def test_to_dict(self, unique_gift_info): json_dict = unique_gift_info.to_dict() @@ -440,6 +476,12 @@ def test_to_dict(self, unique_gift_info): assert json_dict["origin"] == self.origin assert json_dict["owned_gift_id"] == self.owned_gift_id assert json_dict["transfer_star_count"] == self.transfer_star_count + assert json_dict["last_resale_star_count"] == self.last_resale_star_count + assert json_dict["next_transfer_date"] == to_timestamp(self.next_transfer_date) + + def test_enum_type_conversion(self, unique_gift_info): + assert type(unique_gift_info.origin) is UniqueGiftInfoOrigin + assert unique_gift_info.origin == UniqueGiftInfoOrigin.UPGRADE def test_equality(self, unique_gift_info): a = unique_gift_info From 98dfdac4ca6538c3405d2c2e5ee4143534ae7698 Mon Sep 17 00:00:00 2001 From: Harshil <37377066+harshil21@users.noreply.github.com> Date: Sat, 5 Jul 2025 05:47:20 +0400 Subject: [PATCH 07/11] API 9.1 General (#4851) --- .../4851.jGu7ZujzXWWGJATTXGxn4u.toml | 5 ++ docs/source/inclusions/bot_methods.rst | 2 + docs/source/telegram.at-tree.rst | 1 + .../telegram.directmessagepricechanged.rst | 6 ++ src/telegram/__init__.py | 2 + src/telegram/_bot.py | 32 +++++++ src/telegram/_directmessagepricechanged.py | 73 ++++++++++++++++ src/telegram/_message.py | 19 ++++ src/telegram/constants.py | 10 ++- src/telegram/ext/_extbot.py | 19 ++++ src/telegram/ext/filters.py | 15 ++++ tests/ext/test_filters.py | 7 +- tests/test_bot.py | 17 ++++ tests/test_directmessagepricechanged.py | 86 +++++++++++++++++++ tests/test_message.py | 3 + 15 files changed, 295 insertions(+), 2 deletions(-) create mode 100644 changes/unreleased/4851.jGu7ZujzXWWGJATTXGxn4u.toml create mode 100644 docs/source/telegram.directmessagepricechanged.rst create mode 100644 src/telegram/_directmessagepricechanged.py create mode 100644 tests/test_directmessagepricechanged.py diff --git a/changes/unreleased/4851.jGu7ZujzXWWGJATTXGxn4u.toml b/changes/unreleased/4851.jGu7ZujzXWWGJATTXGxn4u.toml new file mode 100644 index 00000000000..f711cac4734 --- /dev/null +++ b/changes/unreleased/4851.jGu7ZujzXWWGJATTXGxn4u.toml @@ -0,0 +1,5 @@ +other = "API 9.1 General" +[[pull_requests]] +uid = "4851" +author_uid = "harshil21" +closes_threads = [] diff --git a/docs/source/inclusions/bot_methods.rst b/docs/source/inclusions/bot_methods.rst index d1ff3c3ac13..1915ffca661 100644 --- a/docs/source/inclusions/bot_methods.rst +++ b/docs/source/inclusions/bot_methods.rst @@ -390,6 +390,8 @@ - Used to generate an HTTP link for an invoice * - :meth:`~telegram.Bot.edit_user_star_subscription` - Used for editing a user's star subscription + * - :meth:`~telegram.Bot.get_my_star_balance` + - Used for obtaining the bot's Telegram Stars balance * - :meth:`~telegram.Bot.get_star_transactions` - Used for obtaining the bot's Telegram Stars transactions * - :meth:`~telegram.Bot.refund_star_payment` diff --git a/docs/source/telegram.at-tree.rst b/docs/source/telegram.at-tree.rst index 133b3fd79d4..e248edb31e8 100644 --- a/docs/source/telegram.at-tree.rst +++ b/docs/source/telegram.at-tree.rst @@ -67,6 +67,7 @@ Available Types telegram.chatshared telegram.contact telegram.dice + telegram.directmessagepricechanged telegram.document telegram.externalreplyinfo telegram.file diff --git a/docs/source/telegram.directmessagepricechanged.rst b/docs/source/telegram.directmessagepricechanged.rst new file mode 100644 index 00000000000..64356e1a689 --- /dev/null +++ b/docs/source/telegram.directmessagepricechanged.rst @@ -0,0 +1,6 @@ +DirectMessagePriceChanged +========================= + +.. autoclass:: telegram.DirectMessagePriceChanged + :members: + :show-inheritance: \ No newline at end of file diff --git a/src/telegram/__init__.py b/src/telegram/__init__.py index f25e90b9b7e..b8632ac53b9 100644 --- a/src/telegram/__init__.py +++ b/src/telegram/__init__.py @@ -89,6 +89,7 @@ "Credentials", "DataCredentials", "Dice", + "DirectMessagePriceChanged", "Document", "EncryptedCredentials", "EncryptedPassportElement", @@ -386,6 +387,7 @@ from ._choseninlineresult import ChosenInlineResult from ._copytextbutton import CopyTextButton from ._dice import Dice +from ._directmessagepricechanged import DirectMessagePriceChanged from ._files._inputstorycontent import ( InputStoryContent, InputStoryContentPhoto, diff --git a/src/telegram/_bot.py b/src/telegram/_bot.py index 56072fbe0d6..f79b34997e4 100644 --- a/src/telegram/_bot.py +++ b/src/telegram/_bot.py @@ -11072,6 +11072,36 @@ async def remove_user_verification( api_kwargs=api_kwargs, ) + async def get_my_star_balance( + self, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: Optional[JSONDict] = None, + ) -> StarAmount: + """A method to get the current Telegram Stars balance of the bot. Requires no parameters. + + .. versionadded:: NEXT.VERSION + + Returns: + :class:`telegram.StarAmount` + + Raises: + :class:`telegram.error.TelegramError` + """ + return StarAmount.de_json( + await self._post( + "getMyStarBalance", + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + ) + def to_dict(self, recursive: bool = True) -> JSONDict: # noqa: ARG002 """See :meth:`telegram.TelegramObject.to_dict`.""" data: JSONDict = {"id": self.id, "username": self.username, "first_name": self.first_name} @@ -11386,3 +11416,5 @@ def to_dict(self, recursive: bool = True) -> JSONDict: # noqa: ARG002 """Alias for :meth:`remove_chat_verification`""" removeUserVerification = remove_user_verification """Alias for :meth:`remove_user_verification`""" + getMyStarBalance = get_my_star_balance + """Alias for :meth:`get_my_star_balance`""" diff --git a/src/telegram/_directmessagepricechanged.py b/src/telegram/_directmessagepricechanged.py new file mode 100644 index 00000000000..9d4ed925137 --- /dev/null +++ b/src/telegram/_directmessagepricechanged.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2025 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +"""This module contains an object that represents a Direct Message Price.""" + + +from typing import Optional + +from telegram._telegramobject import TelegramObject +from telegram._utils.types import JSONDict + + +class DirectMessagePriceChanged(TelegramObject): + """ + Describes a service message about a change in the price of direct messages sent to a channel + chat. + + .. versionadded:: NEXT.VERSION + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`are_direct_messages_enabled`, and + :attr:`direct_message_star_count` are equal. + + Args: + are_direct_messages_enabled (:obj:`bool`): + :obj:`True`, if direct messages are enabled for the channel chat; :obj:`False` + otherwise. + direct_message_star_count (:obj:`int`, optional): + The new number of Telegram Stars that must be paid by users for each direct message + sent to the channel. Does not apply to users who have been exempted by administrators. + Defaults to ``0``. + + Attributes: + are_direct_messages_enabled (:obj:`bool`): + :obj:`True`, if direct messages are enabled for the channel chat; :obj:`False` + otherwise. + direct_message_star_count (:obj:`int`): + Optional. The new number of Telegram Stars that must be paid by users for each direct + message sent to the channel. Does not apply to users who have been exempted by + administrators. Defaults to ``0``. + """ + + __slots__ = ("are_direct_messages_enabled", "direct_message_star_count") + + def __init__( + self, + are_direct_messages_enabled: bool, + direct_message_star_count: Optional[int] = None, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.are_direct_messages_enabled: bool = are_direct_messages_enabled + self.direct_message_star_count: Optional[int] = direct_message_star_count + + self._id_attrs = (self.are_direct_messages_enabled, self.direct_message_star_count) + + self._freeze() diff --git a/src/telegram/_message.py b/src/telegram/_message.py index 274089bff50..46d755bb71f 100644 --- a/src/telegram/_message.py +++ b/src/telegram/_message.py @@ -29,6 +29,7 @@ from telegram._chatbackground import ChatBackground from telegram._chatboost import ChatBoostAdded from telegram._dice import Dice +from telegram._directmessagepricechanged import DirectMessagePriceChanged from telegram._files.animation import Animation from telegram._files.audio import Audio from telegram._files.contact import Contact @@ -609,6 +610,11 @@ class Message(MaybeInaccessibleMessage): message about a refunded payment, information about the payment. .. versionadded:: 21.4 + direct_message_price_changed (:class:`telegram.DirectMessagePriceChanged`, optional): + Service message: the price for paid messages in the corresponding direct messages chat + of a channel has changed. + + .. versionadded:: NEXT.VERSION Attributes: message_id (:obj:`int`): Unique message identifier inside this chat. In specific instances @@ -954,6 +960,11 @@ class Message(MaybeInaccessibleMessage): message about a refunded payment, information about the payment. .. versionadded:: 21.4 + direct_message_price_changed (:class:`telegram.DirectMessagePriceChanged`): + Optional. Service message: the price for paid messages in the corresponding direct + messages chat of a channel has changed. + + .. versionadded:: NEXT.VERSION .. |custom_emoji_no_md1_support| replace:: Since custom emoji entities are not supported by :attr:`~telegram.constants.ParseMode.MARKDOWN`, this method now raises a @@ -987,6 +998,7 @@ class Message(MaybeInaccessibleMessage): "contact", "delete_chat_photo", "dice", + "direct_message_price_changed", "document", "edit_date", "effect_id", @@ -1152,6 +1164,7 @@ def __init__( unique_gift: Optional[UniqueGiftInfo] = None, paid_message_price_changed: Optional[PaidMessagePriceChanged] = None, paid_star_count: Optional[int] = None, + direct_message_price_changed: Optional[DirectMessagePriceChanged] = None, *, api_kwargs: Optional[JSONDict] = None, ): @@ -1261,6 +1274,9 @@ def __init__( paid_message_price_changed ) self.paid_star_count: Optional[int] = paid_star_count + self.direct_message_price_changed: Optional[DirectMessagePriceChanged] = ( + direct_message_price_changed + ) self._effective_attachment = DEFAULT_NONE @@ -1437,6 +1453,9 @@ def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "Message": data["reply_to_story"] = de_json_optional(data.get("reply_to_story"), Story, bot) data["boost_added"] = de_json_optional(data.get("boost_added"), ChatBoostAdded, bot) data["sender_business_bot"] = de_json_optional(data.get("sender_business_bot"), User, bot) + data["direct_message_price_changed"] = de_json_optional( + data.get("direct_message_price_changed"), DirectMessagePriceChanged, bot + ) api_kwargs = {} # This is a deprecated field that TG still returns for backwards compatibility diff --git a/src/telegram/constants.py b/src/telegram/constants.py index 92426b94f32..e9da0f12276 100644 --- a/src/telegram/constants.py +++ b/src/telegram/constants.py @@ -2064,6 +2064,11 @@ class MessageType(StringEnum): """:obj:`str`: Messages with :attr:`telegram.Message.delete_chat_photo`.""" DICE = "dice" """:obj:`str`: Messages with :attr:`telegram.Message.dice`.""" + DIRECT_MESSAGE_PRICE_CHANGED = "direct_message_price_changed" + """:obj:`str`: Messages with :attr:`telegram.Message.direct_message_price_changed`. + + .. versionadded:: NEXT.VERSION + """ DOCUMENT = "document" """:obj:`str`: Messages with :attr:`telegram.Message.document`.""" EFFECT_ID = "effect_id" @@ -3153,10 +3158,13 @@ class PollLimit(IntEnum): to the :paramref:`~telegram.Bot.send_poll.options` parameter of :meth:`telegram.Bot.send_poll`. """ - MAX_OPTION_NUMBER = 10 + MAX_OPTION_NUMBER = 12 """:obj:`int`: Maximum number of strings passed in a :obj:`list` to the :paramref:`~telegram.Bot.send_poll.options` parameter of :meth:`telegram.Bot.send_poll`. + + .. versionchanged:: NEXT.VERSION + This value was changed from ``10`` to ``12`` in accordance to Bot API 9.1. """ MAX_EXPLANATION_LENGTH = 200 """:obj:`int`: Maximum number of characters in a :obj:`str` passed as the diff --git a/src/telegram/ext/_extbot.py b/src/telegram/ext/_extbot.py index 5781cf817bc..7c6f5ef8cb8 100644 --- a/src/telegram/ext/_extbot.py +++ b/src/telegram/ext/_extbot.py @@ -5057,6 +5057,24 @@ async def remove_user_verification( api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), ) + async def get_my_star_balance( + self, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: Optional[JSONDict] = None, + rate_limit_args: Optional[RLARGS] = None, + ) -> StarAmount: + return await super().get_my_star_balance( + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + # updated camelCase aliases getMe = get_me sendMessage = send_message @@ -5210,3 +5228,4 @@ async def remove_user_verification( verifyUser = verify_user removeChatVerification = remove_chat_verification removeUserVerification = remove_user_verification + getMyStarBalance = get_my_star_balance diff --git a/src/telegram/ext/filters.py b/src/telegram/ext/filters.py index 6322dafd296..914ba4fbb05 100644 --- a/src/telegram/ext/filters.py +++ b/src/telegram/ext/filters.py @@ -1947,6 +1947,7 @@ 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") @@ -1997,6 +1998,20 @@ def filter(self, message: Message) -> bool: CONNECTED_WEBSITE = _ConnectedWebsite(name="filters.StatusUpdate.CONNECTED_WEBSITE") """Messages that contain :attr:`telegram.Message.connected_website`.""" + class _DirectMessagePriceChanged(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.direct_message_price_changed) + + DIRECT_MESSAGE_PRICE_CHANGED = _DirectMessagePriceChanged( + name="filters.StatusUpdate.DIRECT_MESSAGE_PRICE_CHANGED" + ) + """Messages that contain :attr:`telegram.Message.direct_message_price_changed`. + + .. versionadded:: NEXT.VERSION + """ + class _DeleteChatPhoto(MessageFilter): __slots__ = () diff --git a/tests/ext/test_filters.py b/tests/ext/test_filters.py index 6802db2a206..ca2d01dfc9e 100644 --- a/tests/ext/test_filters.py +++ b/tests/ext/test_filters.py @@ -1116,7 +1116,12 @@ def test_filters_status_update(self, update): assert filters.StatusUpdate.PAID_MESSAGE_PRICE_CHANGED.check_update(update) update.message.paid_message_price_changed = None - def test_filters_forwarded(self, update, message_origin_user): + update.message.direct_message_price_changed = "direct_message_price_changed" + assert filters.StatusUpdate.ALL.check_update(update) + assert filters.StatusUpdate.DIRECT_MESSAGE_PRICE_CHANGED.check_update(update) + update.message.direct_message_price_changed = None + + def test_filters_forwarded(self, update): assert filters.FORWARDED.check_update(update) update.message.forward_origin = MessageOriginHiddenUser(dtm.datetime.utcnow(), 1) assert filters.FORWARDED.check_update(update) diff --git a/tests/test_bot.py b/tests/test_bot.py index 4e78cd0a449..0e6e1355e3a 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -79,6 +79,7 @@ User, WebAppInfo, ) +from telegram._payment.stars.staramount import StarAmount from telegram._utils.datetime import UTC, from_timestamp, localize, to_timestamp from telegram._utils.defaultvalue import DEFAULT_NONE from telegram._utils.strings import to_camel_case @@ -2574,6 +2575,17 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): await offline_bot.remove_chat_verification(1234) + async def test_get_my_star_balance(self, offline_bot, monkeypatch): + sa = StarAmount(1000).to_json() + + async def do_request(url, request_data: RequestData, *args, **kwargs): + assert not request_data.parameters + return 200, f'{{"ok": true, "result": {sa}}}'.encode() + + monkeypatch.setattr(offline_bot.request, "do_request", do_request) + obj = await offline_bot.get_my_star_balance() + assert isinstance(obj, StarAmount) + class TestBotWithRequest: """ @@ -4540,3 +4552,8 @@ async def test_create_edit_chat_subscription_link( assert edited_link.name == "sub_name_2" assert sub_link.subscription_period == 2592000 assert sub_link.subscription_price == 13 + + async def test_get_my_star_balance(self, bot): + balance = await bot.get_my_star_balance() + assert isinstance(balance, StarAmount) + assert balance.amount == 0 diff --git a/tests/test_directmessagepricechanged.py b/tests/test_directmessagepricechanged.py new file mode 100644 index 00000000000..39d831bcfb6 --- /dev/null +++ b/tests/test_directmessagepricechanged.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2025 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +"""This module contains an object for testing a Direct Message Price.""" + +from typing import TYPE_CHECKING + +import pytest + +from telegram import DirectMessagePriceChanged, User +from tests.auxil.slots import mro_slots + +if TYPE_CHECKING: + from telegram._utils.types import JSONDict + + +@pytest.fixture +def direct_message_price_changed(): + return DirectMessagePriceChanged( + are_direct_messages_enabled=DirectMessagePriceChangedTestBase.are_direct_messages_enabled, + direct_message_star_count=DirectMessagePriceChangedTestBase.direct_message_star_count, + ) + + +class DirectMessagePriceChangedTestBase: + are_direct_messages_enabled: bool = True + direct_message_star_count: int = 100 + + +class TestDirectMessagePriceChangedWithoutRequest(DirectMessagePriceChangedTestBase): + def test_slot_behaviour(self, direct_message_price_changed): + action = direct_message_price_changed + for attr in action.__slots__: + assert getattr(action, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(action)) == len(set(mro_slots(action))), "duplicate slot" + + def test_de_json(self, offline_bot): + json_dict: JSONDict = { + "are_direct_messages_enabled": self.are_direct_messages_enabled, + "direct_message_star_count": self.direct_message_star_count, + } + dmpc = DirectMessagePriceChanged.de_json(json_dict, offline_bot) + assert dmpc.api_kwargs == {} + + assert dmpc.are_direct_messages_enabled == self.are_direct_messages_enabled + assert dmpc.direct_message_star_count == self.direct_message_star_count + + def test_to_dict(self, direct_message_price_changed): + dmpc_dict = direct_message_price_changed.to_dict() + assert dmpc_dict["are_direct_messages_enabled"] == self.are_direct_messages_enabled + assert dmpc_dict["direct_message_star_count"] == self.direct_message_star_count + + def test_equality(self, direct_message_price_changed): + dmpc1 = direct_message_price_changed + dmpc2 = DirectMessagePriceChanged( + are_direct_messages_enabled=self.are_direct_messages_enabled, + direct_message_star_count=self.direct_message_star_count, + ) + assert dmpc1 == dmpc2 + assert hash(dmpc1) == hash(dmpc2) + + dmpc3 = DirectMessagePriceChanged( + are_direct_messages_enabled=False, + direct_message_star_count=self.direct_message_star_count, + ) + assert dmpc1 != dmpc3 + assert hash(dmpc1) != hash(dmpc3) + + not_a_dmpc = User(id=1, first_name="wrong", is_bot=False) + assert dmpc1 != not_a_dmpc + assert hash(dmpc1) != hash(not_a_dmpc) diff --git a/tests/test_message.py b/tests/test_message.py index 1c5cd152859..805b877169a 100644 --- a/tests/test_message.py +++ b/tests/test_message.py @@ -33,6 +33,7 @@ ChatShared, Contact, Dice, + DirectMessagePriceChanged, Document, ExternalReplyInfo, Game, @@ -331,6 +332,7 @@ def message(bot): {"refunded_payment": RefundedPayment("EUR", 243, "payload", "charge_id", "provider_id")}, {"paid_star_count": 291}, {"paid_message_price_changed": PaidMessagePriceChanged(291)}, + {"direct_message_price_changed": DirectMessagePriceChanged(True, 100)}, ], ids=[ "reply", @@ -408,6 +410,7 @@ def message(bot): "refunded_payment", "paid_star_count", "paid_message_price_changed", + "direct_message_price_changed", ], ) def message_params(bot, request): From 24b0d734b5772ff44362c60e71447f3770a54cfd Mon Sep 17 00:00:00 2001 From: Hinrich Mahler <22366557+Bibo-Joshi@users.noreply.github.com> Date: Sat, 5 Jul 2025 13:13:02 +0200 Subject: [PATCH 08/11] Consolidate chango fragments --- changes/unreleased/4847.8ujbbBbaZ2VTEdRLeqirSZ.toml | 9 +++++---- changes/unreleased/4849.4mPSpBY2r77o75ycywXPiz.toml | 5 ----- changes/unreleased/4851.jGu7ZujzXWWGJATTXGxn4u.toml | 5 ----- 3 files changed, 5 insertions(+), 14 deletions(-) delete mode 100644 changes/unreleased/4849.4mPSpBY2r77o75ycywXPiz.toml delete mode 100644 changes/unreleased/4851.jGu7ZujzXWWGJATTXGxn4u.toml diff --git a/changes/unreleased/4847.8ujbbBbaZ2VTEdRLeqirSZ.toml b/changes/unreleased/4847.8ujbbBbaZ2VTEdRLeqirSZ.toml index b0ef7271f19..7d96f092f72 100644 --- a/changes/unreleased/4847.8ujbbBbaZ2VTEdRLeqirSZ.toml +++ b/changes/unreleased/4847.8ujbbBbaZ2VTEdRLeqirSZ.toml @@ -10,7 +10,8 @@ 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 = "4849", author_uid = "harshil21" }, + { uid = "4851", author_uid = "harshil21" }, +] diff --git a/changes/unreleased/4849.4mPSpBY2r77o75ycywXPiz.toml b/changes/unreleased/4849.4mPSpBY2r77o75ycywXPiz.toml deleted file mode 100644 index 693c1b277e4..00000000000 --- a/changes/unreleased/4849.4mPSpBY2r77o75ycywXPiz.toml +++ /dev/null @@ -1,5 +0,0 @@ -features = "API 9.1 Gifts " -[[pull_requests]] -uid = "4849" -author_uid = "harshil21" -closes_threads = [] diff --git a/changes/unreleased/4851.jGu7ZujzXWWGJATTXGxn4u.toml b/changes/unreleased/4851.jGu7ZujzXWWGJATTXGxn4u.toml deleted file mode 100644 index f711cac4734..00000000000 --- a/changes/unreleased/4851.jGu7ZujzXWWGJATTXGxn4u.toml +++ /dev/null @@ -1,5 +0,0 @@ -other = "API 9.1 General" -[[pull_requests]] -uid = "4851" -author_uid = "harshil21" -closes_threads = [] From f78ba208be60d2978b324891ae80a9af40c0c8d7 Mon Sep 17 00:00:00 2001 From: Hinrich Mahler <22366557+Bibo-Joshi@users.noreply.github.com> Date: Sat, 5 Jul 2025 13:15:42 +0200 Subject: [PATCH 09/11] Elaborate chango fragment a bit --- .../unreleased/4847.8ujbbBbaZ2VTEdRLeqirSZ.toml | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/changes/unreleased/4847.8ujbbBbaZ2VTEdRLeqirSZ.toml b/changes/unreleased/4847.8ujbbBbaZ2VTEdRLeqirSZ.toml index 7d96f092f72..d79e6d4735d 100644 --- a/changes/unreleased/4847.8ujbbBbaZ2VTEdRLeqirSZ.toml +++ b/changes/unreleased/4847.8ujbbBbaZ2VTEdRLeqirSZ.toml @@ -1,14 +1,10 @@ highlights = "Full Support for Bot API 9.1" -breaking = "Optional Section Content" -security = "Optional Section Content" -deprecations = "Optional Section Content" -features = "Optional Section Content" -bugfixes = "Optional Section Content" -dependencies = "Optional Section Content" -other = "Optional Section Content" -documentation = "Optional Section Content" -internal = "Optional Section Content" +features = """ +New filters based on Bot API 9.1: + +* ``filters.StatusUpdate.DIRECT_MESSAGE_PRICE_CHANGED`` for ``Message.direct_message_price_changed`` +""" pull_requests = [ { uid = "4847", author_uid = "Bibo-Joshi", closes_threads = ["4845"] }, From 365e31163bfccfc539d7c0fb6989ac30ec780af2 Mon Sep 17 00:00:00 2001 From: Hinrich Mahler <22366557+Bibo-Joshi@users.noreply.github.com> Date: Sat, 5 Jul 2025 16:21:02 +0200 Subject: [PATCH 10/11] Review aelkheir --- src/telegram/_checklists.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/telegram/_checklists.py b/src/telegram/_checklists.py index f6b8c24e085..528084a1ef9 100644 --- a/src/telegram/_checklists.py +++ b/src/telegram/_checklists.py @@ -50,8 +50,9 @@ class ChecklistTask(TelegramObject): entities that appear in the task text. completed_by_user (:class:`telegram.User`, optional): User that completed the task; omitted if the task wasn't completed - completion_date (:class:`datetime.datetime`, optional): Point in time (Unix timestamp) when - the task was completed; 0 in Univ time if the task wasn't completed + completion_date (:class:`datetime.datetime`, optional): Point in time when + the task was completed; :attr:`~telegram.constants.ZERO_DATE` if the task wasn't + completed |datetime_localization| @@ -62,8 +63,9 @@ class ChecklistTask(TelegramObject): entities that appear in the task text. completed_by_user (:class:`telegram.User`): Optional. User that completed the task; omitted if the task wasn't completed - completion_date (:class:`datetime.datetime`): Optional. Point in time (Unix timestamp) when - the task was completed; 0 in Univ time if the task wasn't completed + completion_date (:class:`datetime.datetime`): Optional. Point in time when + the task was completed; :attr:`~telegram.constants.ZERO_DATE` if the task wasn't + completed |datetime_localization| """ @@ -122,7 +124,7 @@ def parse_entity(self, entity: MessageEntity) -> str: 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 ``ChecklistTask.text`` with the offset and length.) Args: entity (:class:`telegram.MessageEntity`): The entity to extract the text from. It must @@ -136,7 +138,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 task filtered by their ``type`` attribute as the key, and the text that each entity belongs to as the value of the :obj:`dict`. Note: From a9cbca694ff13ee29355ff565570b0e5d192656a Mon Sep 17 00:00:00 2001 From: Bibo-Joshi <22366557+Bibo-Joshi@users.noreply.github.com> Date: Tue, 8 Jul 2025 19:26:21 +0200 Subject: [PATCH 11/11] API 9.1 `Checklist*` classes (#4848) --- .../4847.8ujbbBbaZ2VTEdRLeqirSZ.toml | 4 + docs/source/telegram.at-tree.rst | 3 + docs/source/telegram.checklist.rst | 6 + docs/source/telegram.checklisttasksadded.rst | 6 + docs/source/telegram.checklisttasksdone.rst | 6 + src/telegram/__init__.py | 5 +- src/telegram/_checklists.py | 236 ++++++++++++++- src/telegram/_message.py | 39 +++ src/telegram/_reply.py | 11 + src/telegram/constants.py | 15 + src/telegram/ext/filters.py | 43 ++- tests/ext/test_filters.py | 17 ++ tests/test_checklists.py | 279 +++++++++++++++++- tests/test_message.py | 24 ++ tests/test_reply.py | 13 + 15 files changed, 703 insertions(+), 4 deletions(-) create mode 100644 docs/source/telegram.checklist.rst create mode 100644 docs/source/telegram.checklisttasksadded.rst create mode 100644 docs/source/telegram.checklisttasksdone.rst diff --git a/changes/unreleased/4847.8ujbbBbaZ2VTEdRLeqirSZ.toml b/changes/unreleased/4847.8ujbbBbaZ2VTEdRLeqirSZ.toml index d79e6d4735d..b49564f44ab 100644 --- a/changes/unreleased/4847.8ujbbBbaZ2VTEdRLeqirSZ.toml +++ b/changes/unreleased/4847.8ujbbBbaZ2VTEdRLeqirSZ.toml @@ -4,10 +4,14 @@ 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`` """ pull_requests = [ { uid = "4847", author_uid = "Bibo-Joshi", closes_threads = ["4845"] }, + { uid = "4848", author_uid = "Bibo-Joshi" }, { uid = "4849", author_uid = "harshil21" }, { uid = "4851", author_uid = "harshil21" }, ] diff --git a/docs/source/telegram.at-tree.rst b/docs/source/telegram.at-tree.rst index e248edb31e8..6f653f0ea1c 100644 --- a/docs/source/telegram.at-tree.rst +++ b/docs/source/telegram.at-tree.rst @@ -31,7 +31,10 @@ Available Types telegram.chat telegram.chatadministratorrights telegram.chatbackground + telegram.checklist telegram.checklisttask + telegram.checklisttasksadded + telegram.checklisttasksdone telegram.copytextbutton telegram.backgroundtype telegram.backgroundtypefill 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/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/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 b8632ac53b9..0e2cb7cbe0d 100644 --- a/src/telegram/__init__.py +++ b/src/telegram/__init__.py @@ -82,7 +82,10 @@ "ChatPermissions", "ChatPhoto", "ChatShared", + "Checklist", "ChecklistTask", + "ChecklistTasksAdded", + "ChecklistTasksDone", "ChosenInlineResult", "Contact", "CopyTextButton", @@ -383,7 +386,7 @@ ) from ._chatmemberupdated import ChatMemberUpdated from ._chatpermissions import ChatPermissions -from ._checklists import ChecklistTask +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 528084a1ef9..5e1fb50a0f0 100644 --- a/src/telegram/_checklists.py +++ b/src/telegram/_checklists.py @@ -31,7 +31,7 @@ from telegram.constants import ZERO_DATE if TYPE_CHECKING: - from telegram import Bot + from telegram import Bot, Message class ChecklistTask(TelegramObject): @@ -156,3 +156,237 @@ 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`]): 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`): 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 + """ + + __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:`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 :attr:`title` 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 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: + 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) + + +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`]): 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__ = ( + "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) + + # 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) + + +class ChecklistTasksAdded(TelegramObject): + """ + 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. + + .. 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) + + # 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) + + return super().de_json(data=data, bot=bot) diff --git a/src/telegram/_message.py b/src/telegram/_message.py index 46d755bb71f..3783a87e9fc 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, ChecklistTasksAdded, ChecklistTasksDone 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 @@ -602,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. @@ -874,6 +886,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 @@ -952,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. @@ -994,6 +1017,9 @@ class Message(MaybeInaccessibleMessage): "channel_chat_created", "chat_background_set", "chat_shared", + "checklist", + "checklist_tasks_added", + "checklist_tasks_done", "connected_website", "contact", "delete_chat_photo", @@ -1165,6 +1191,9 @@ 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, + checklist_tasks_done: Optional[ChecklistTasksDone] = None, + checklist_tasks_added: Optional[ChecklistTasksAdded] = None, *, api_kwargs: Optional[JSONDict] = None, ): @@ -1246,6 +1275,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 @@ -1264,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 @@ -1456,6 +1488,13 @@ 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) + 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/_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/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" diff --git a/src/telegram/ext/filters.py b/src/telegram/ext/filters.py index 914ba4fbb05..9703c235de2 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 @@ -1918,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) @@ -1947,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") @@ -1989,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 ca2d01dfc9e..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) @@ -2797,3 +2807,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_checklists.py b/tests/test_checklists.py index d55cf7467b0..dda79105437 100644 --- a/tests/test_checklists.py +++ b/tests/test_checklists.py @@ -20,9 +20,18 @@ import pytest -from telegram import ChecklistTask, MessageEntity, User +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 tests.auxil.slots import mro_slots @@ -80,6 +89,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 = { @@ -93,6 +103,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 = { @@ -154,9 +165,275 @@ 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 + assert cl.api_kwargs == {} + + 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) + + +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) + + +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) diff --git a/tests/test_message.py b/tests/test_message.py index 805b877169a..1c9d67ecf3b 100644 --- a/tests/test_message.py +++ b/tests/test_message.py @@ -31,6 +31,10 @@ ChatBackground, ChatBoostAdded, ChatShared, + Checklist, + ChecklistTask, + ChecklistTasksAdded, + ChecklistTasksDone, Contact, Dice, DirectMessagePriceChanged, @@ -333,6 +337,23 @@ 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")], + ) + }, + { + "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", @@ -411,6 +432,9 @@ def message(bot): "paid_star_count", "paid_message_price_changed", "direct_message_price_changed", + "checklist", + "checklist_tasks_done", + "checklist_tasks_added", ], ) 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