diff --git a/telegram/_chat.py b/telegram/_chat.py index fe49dc3593e..1ad7d2d229d 100644 --- a/telegram/_chat.py +++ b/telegram/_chat.py @@ -39,6 +39,7 @@ ReplyMarkup, TimePeriod, ) +from telegram._utils.usernames import get_link from telegram.helpers import escape_markdown from telegram.helpers import mention_html as helpers_mention_html from telegram.helpers import mention_markdown as helpers_mention_markdown @@ -162,9 +163,7 @@ def link(self) -> Optional[str]: """:obj:`str`: Convenience property. If the chat has a :attr:`~Chat.username`, returns a t.me link of the chat. """ - if self.username: - return f"https://t.me/{self.username}" - return None + return get_link(user=self) def mention_markdown(self, name: Optional[str] = None) -> str: """ diff --git a/telegram/_shared.py b/telegram/_shared.py index 9c0d3684ec2..1334625cb42 100644 --- a/telegram/_shared.py +++ b/telegram/_shared.py @@ -18,11 +18,12 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains two objects used for request chats/users service messages.""" from collections.abc import Sequence -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Optional, Union from telegram._files.photosize import PhotoSize from telegram._telegramobject import TelegramObject from telegram._utils.argumentparsing import de_list_optional, parse_sequence_arg +from telegram._utils.usernames import get_name, get_full_name, get_link from telegram._utils.types import JSONDict if TYPE_CHECKING: @@ -244,6 +245,27 @@ def __init__( self._freeze() + @property + def name(self) -> Union[str, None]: + """:obj:`str`: Convenience property. If available, returns the user's :attr:`username` + prefixed with "@". If :attr:`username` is not available, returns :attr:`full_name`. + """ + return get_name(user=self, ) + + @property + def full_name(self) -> Union[str, None]: + """:obj:`str`: Convenience property. The user's :attr:`first_name`, followed by (if + available) :attr:`last_name`, otherwise None. + """ + return get_full_name(user=self, ) + + @property + def link(self) -> Union[str, None]: + """:obj:`str`: Convenience property. The user's :attr:`first_name`, followed by (if + available) :attr:`last_name`, otherwise None. + """ + return get_link(user=self, ) + @classmethod def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "SharedUser": """See :meth:`telegram.TelegramObject.de_json`.""" diff --git a/telegram/_user.py b/telegram/_user.py index 640a3573acc..1e04c59c953 100644 --- a/telegram/_user.py +++ b/telegram/_user.py @@ -26,6 +26,7 @@ from telegram._menubutton import MenuButton from telegram._telegramobject import TelegramObject from telegram._utils.defaultvalue import DEFAULT_NONE +from telegram._utils.usernames import get_name, get_full_name, get_link from telegram._utils.types import ( CorrectOptionID, FileInput, @@ -207,27 +208,21 @@ def name(self) -> str: """:obj:`str`: Convenience property. If available, returns the user's :attr:`username` prefixed with "@". If :attr:`username` is not available, returns :attr:`full_name`. """ - if self.username: - return f"@{self.username}" - return self.full_name + return get_name(self, ) @property def full_name(self) -> str: """:obj:`str`: Convenience property. The user's :attr:`first_name`, followed by (if available) :attr:`last_name`. """ - if self.last_name: - return f"{self.first_name} {self.last_name}" - return self.first_name + return get_full_name(self, ) @property def link(self) -> Optional[str]: """:obj:`str`: Convenience property. If :attr:`username` is available, returns a t.me link of the user. """ - if self.username: - return f"https://t.me/{self.username}" - return None + return get_link(self, ) async def get_profile_photos( self, diff --git a/telegram/_utils/usernames.py b/telegram/_utils/usernames.py new file mode 100644 index 00000000000..1f4e393c9d7 --- /dev/null +++ b/telegram/_utils/usernames.py @@ -0,0 +1,103 @@ +#!/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/]. +"""Shared properties to extract username, first_name, last_name values if filled.""" +from __future__ import annotations +from typing import Protocol, overload + + +class UserLikeOptional(Protocol): + """ + Note: + `User`, `Contact` (and maybe some other) objects always have first_name, + unlike the `Chat` and `Shared`, were they are optional. + The `last_name` is always optional. + """ + last_name: str | None + username: str | None + + +class UserLike(UserLikeOptional): + """ + Note: + `User`, `Contact` (and maybe some other) objects always have first_name, + unlike the `Chat` and `Shared`, were they are optional. + The `last_name` is always optional. + """ + first_name: str + + +class MiniUserLike(UserLikeOptional): + """ + Note: + `User`, `Contact` (and maybe some other) objects always have first_name, + unlike the `Chat` and `Shared`, were they are optional. + The `last_name` is always optional. + """ + first_name: str | None + + +@overload +def get_name(user: UserLike) -> str: + ... + + +@overload +def get_name(user: MiniUserLike) -> str | None: + ... + + +def get_name(user: UserLike | MiniUserLike) -> str | None: + """:obj:`str`: Convenience property. If available, returns the user's :attr:`username` + prefixed with "@". If :attr:`username` is not available, returns :attr:`full_name`. + For the UserLike object str will always be returned as `first_name`always exists. + """ + if user.username: + return f"@{user.username}" + return get_full_name(user=user, ) + + +@overload +def get_full_name(user: UserLike) -> str: + ... + + +@overload +def get_full_name(user: MiniUserLike) -> str | None: + ... + + +def get_full_name(user: UserLike | MiniUserLike) -> str | None: + """:obj:`str`: Convenience property. The user's :attr:`first_name`, followed by (if + available) :attr:`last_name`, otherwise None. + For the UserLike object str will always be returned as `first_name`always exists. + """ + if user.first_name and user.last_name: + return f"{user.first_name} {user.last_name}" + if user.first_name or user.last_name: + return f"{user.first_name or user.last_name}" + return None + + +def get_link(user: UserLike | MiniUserLike) -> str | None: + """:obj:`str`: Convenience property. If :attr:`username` is available, returns a t.me link + of the user. + """ + if user.username: + return f"https://t.me/{user.username}" + return None diff --git a/tests/_utils/test_usernames.py b/tests/_utils/test_usernames.py new file mode 100644 index 00000000000..8f84e465d9a --- /dev/null +++ b/tests/_utils/test_usernames.py @@ -0,0 +1,72 @@ +#!/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/]. + +from __future__ import annotations +import pytest +from typing import TYPE_CHECKING +from telegram import SharedUser +from tests.test_user import user # noqa: F401 noqa: F811 +from telegram._utils.usernames import get_name, get_full_name, get_link + +if TYPE_CHECKING: + from telegram._utils.usernames import UserLike, MiniUserLike + + +@pytest.fixture(scope="class") +def shared_user(): + result = SharedUser( + user_id=1, + first_name="first\u2022name", + last_name="last\u2022name", + username="username", + ) + result._unfreeze() + return result + + +def test_get_name(user: UserLike, shared_user: MiniUserLike, ): # noqa: F811 + assert get_name(user=user) == get_name(user=shared_user) == "@username" + shared_user.username = user.username = None + assert get_name(user=user) == get_name(user=shared_user) == "first\u2022name last\u2022name" + + +def test_full_name_both_exists(user: UserLike, shared_user: MiniUserLike, ): # noqa: F811 + expected = "first\u2022name last\u2022name" + assert get_full_name(user=user) == get_full_name(user=shared_user) == expected + + +def test_full_name_last_name_missed(user: UserLike, shared_user: MiniUserLike, ): # noqa: F811 + user.last_name = shared_user.last_name = None + assert get_full_name(user=user) == get_full_name(user=shared_user) == "first\u2022name" + + +def test_full_name_first_name_missed(user: UserLike, shared_user: MiniUserLike, ): # noqa: F811 + user.first_name = shared_user.first_name = None + assert get_full_name(user=user) == get_full_name(user=shared_user) == "last\u2022name" + + +def test_full_name_both_missed(user: UserLike, shared_user: MiniUserLike, ): # noqa: F811 + user.first_name = user.last_name = shared_user.first_name = shared_user.last_name = None + assert get_full_name(user=user) is get_full_name(user=shared_user) is None + + +def test_link(user: UserLike, shared_user: MiniUserLike, ): # noqa: F811 + assert get_link(user=user, ) == get_link(user=shared_user, ) == f"https://t.me/{user.username}" + user.username = shared_user.username = None + assert get_link(user=user, ) is get_link(user=shared_user, ) is None