Repository: python-telegram-bot/ptbcontrib
Pull request #124: Extract passed user
Author: login: david-shiko
Created: 2025-03-08T17:37:02Z | Merged: 2025-03-09T00:07:09Z

This module is intended for a single purpose:
Extracting the side user's information from the message provided by the current user. 
For instance, User 1 passes data about User 2 (who could be their friend).  

P.S. In this code, I utilize the `UsernameToChatAPI` module, but I personally have no idea how to use it and simply delegate its execution. In my own code, I merely utilized Telethon's methods with public available hash and an app ID.


Diff:
diff --git a/ptbcontrib/extract_passed_user/README.md b/ptbcontrib/extract_passed_user/README.md
new file mode 100644
index 0000000..537bb94
--- /dev/null
+++ b/ptbcontrib/extract_passed_user/README.md
@@ -0,0 +1,56 @@
+# Extract passed user
+
+This module intended for a single function:   
+Extract side user from the message provided by current user.
+i.e. 1 user passes data of third-party user.
+
+Currently, the function will check:
+1. `Message.users_shared`
+2. `Message.contact`
+3. `Message.text` with numbers (if user id typed manually)
+4. `Message.text` with @name (if user @name typed manually)
+
+## Usage:
+
+### 1. By the users_shared:
+Note: `users_shared` object is tuple and may contain many users but only first of them will be returned. 
+```python
+Message=(..., users_shared)
+shared_user = extract_passed_user(message=message)  # Just the first user in the users_shared tuple 
+```
+
+### 2. By the contact:  
+Note: PTB `Contact.user_id` field is optional but required for the extraction or `None` otherwise will be returned. 
+```python
+Message=(..., contact=Contact(...))
+shared_user = extract_passed_user(message=message) 
+```
+
+### 3. By the text id:   
+Note: Only first found number in the text will be used as `user_id`.
+```python
+Message=(..., text='Here my fried id - 123456, only numbers will be extracted.')
+shared_user = extract_passed_user(message=message) 
+```
+
+### 4. By the text @name:  
+Note: telegram bot API has no method to convert `@name` into `user_id`, 
+so ptbcontrlib module `username_to_chat_api` will be user for this.
+`username_resolver` parameter required for this, 
+it should be `UsernameToChatAPI` instance or custom async function.  
+Only first found world with `@` prefix in the text will be used as future `user_id`.
+```python
+Message=(..., text='@friend_nickname')
+shared_user = extract_passed_user(message=message, username_resolver=UsernameToChatAPI(..., )) 
+```
+
+### `get_num_from_text` function:
+helper function which extracts first found number from the string as said in the docs above
+
+## Requirements
+
+*   `python-telegram-bot>=21.1`
+
+## Authors
+
+*   [david Shiko](https://github.com/david-shiko)
diff --git a/ptbcontrib/extract_passed_user/__init__.py b/ptbcontrib/extract_passed_user/__init__.py
new file mode 100644
index 0000000..866f818
--- /dev/null
+++ b/ptbcontrib/extract_passed_user/__init__.py
@@ -0,0 +1,24 @@
+# A library containing community-based extension for the python-telegram-bot library
+# Copyright (C) 2020-2025
+# The ptbcontrib developers
+#
+# 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 a helper function to get joinable links from chats."""
+
+from .extract import extract_passed_user, get_num_from_text
+
+__all__ = [
+    "extract_passed_user",
+    "get_num_from_text",
+]
diff --git a/ptbcontrib/extract_passed_user/extract.py b/ptbcontrib/extract_passed_user/extract.py
new file mode 100644
index 0000000..3aa50c5
--- /dev/null
+++ b/ptbcontrib/extract_passed_user/extract.py
@@ -0,0 +1,110 @@
+#!/usr/bin/env python
+#
+# A library containing community-based extension for the python-telegram-bot library
+# Copyright (C) 2020-2025
+# The ptbcontrib developers
+#
+# 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 attempts to extract side user passed actual user from incoming message"""
+
+from __future__ import annotations
+
+import asyncio
+import re
+from typing import TYPE_CHECKING, Callable
+
+from telegram import SharedUser
+from telegram.error import TelegramError
+
+from ..username_to_chat_api import UsernameToChatAPI
+
+if TYPE_CHECKING:
+    from telegram import Chat, Message
+
+
+def get_num_from_text(
+    text: str,
+) -> int | None:
+    """Extract first number from text"""
+    if matched_number := re.search(r"\d+", text):
+        return int(matched_number.group())
+    return None
+
+
+def _default_resolver(
+    wrapper: UsernameToChatAPI,
+    username: str,
+) -> Chat | None:
+    """
+    Try to resolve username to Chat object.
+    Catch exceptions from username_to_chat_api ?
+    """
+    return asyncio.run(
+        wrapper.resolve(
+            username=username.strip(),
+        )
+    )
+
+
+async def extract_passed_user(
+    message: Message,
+    username_resolver: Callable | UsernameToChatAPI | None = None,
+) -> SharedUser | None:
+    """
+    4 cases:
+    1. message.users_shared
+    2. message.contact.user_id
+    3. message.text starts with '@' and resolved by wrapper
+    4. message.text has only numbers, resolved by get_num_from_text
+
+    About contact entity:
+        1. `contact` may be without user_id.
+        2. `contact` not contain @name at all, so not convertable to complete SharedUser.
+    """
+    chat = user_id = None
+    if message.users_shared:  # Note: May be select multiple
+        return message.users_shared.users[0]
+    if username_resolver and message.text and (username := re.search(r"@\S+", message.text)):
+        if isinstance(username_resolver, UsernameToChatAPI):
+            chat = _default_resolver(
+                wrapper=username_resolver,
+                username=username.group(),
+            )
+        else:
+            chat: Chat | None = await username_resolver(  # type: ignore[no-redef]
+                username=message.text,
+            )
+    elif message.contact and message.contact.user_id:
+        user_id = message.contact.user_id
+    elif message.text:
+        user_id = get_num_from_text(
+            text=message.text,
+        )
+
+    if user_id:
+        try:
+            chat = await message.get_bot().get_chat(
+                chat_id=user_id,
+            )
+        except TelegramError:  # pragma: no cover
+            return None
+    if chat:
+        return SharedUser(
+            user_id=chat.id,
+            username=chat.username,
+            first_name=chat.first_name,
+            last_name=chat.last_name,
+        )
+    return None
diff --git a/ptbcontrib/extract_passed_user/requirements.txt b/ptbcontrib/extract_passed_user/requirements.txt
new file mode 100644
index 0000000..85551de
--- /dev/null
+++ b/ptbcontrib/extract_passed_user/requirements.txt
@@ -0,0 +1 @@
+python-telegram-bot>=21.1
diff --git a/tests/test_extract_passed_user.py b/tests/test_extract_passed_user.py
new file mode 100644
index 0000000..f9b26bb
--- /dev/null
+++ b/tests/test_extract_passed_user.py
@@ -0,0 +1,204 @@
+#!/usr/bin/env python
+#
+# A library containing community-based extension for the python-telegram-bot library
+# Copyright (C) 2020-2025
+# The ptbcontrib developers
+#
+# 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
+
+from datetime import datetime
+from functools import partial
+from typing import TYPE_CHECKING
+from unittest.mock import AsyncMock, create_autospec, patch
+
+import pytest
+from telegram import Chat, Contact, Message, SharedUser, UsersShared
+from telegram.constants import ChatType
+
+from ptbcontrib.extract_passed_user import extract, extract_passed_user, get_num_from_text
+from ptbcontrib.username_to_chat_api import UsernameToChatAPI
+
+if TYPE_CHECKING:
+    from unittest.mock import MagicMock
+
+chat = Chat(
+    id=1,
+    type=ChatType.PRIVATE,
+    username="username",
+    first_name="first_name",
+    last_name="last_name",
+)
+message_fabric = partial(
+    Message,
+    message_id=1,
+    date=datetime.now(),
+    chat=chat,
+)
+shared_user = SharedUser(
+    user_id=1,
+    username=chat.username,
+    first_name=chat.first_name,
+    last_name=chat.last_name,
+)
+
+
+class TestGetNumsFromText:
+    """test_get_nums_from_text"""
+
+    @staticmethod
+    @pytest.mark.parametrize(
+        argnames="text, expected",
+        argvalues=[
+            ("abc123xyz", 123),
+            ("98765", 98765),
+            ("  ab#$@c9def8g7h6 ", 9),
+        ],
+    )
+    def test_success(
+        text: str,
+        expected: int,
+    ):
+        assert (
+            get_num_from_text(
+                text=text,
+            )
+            == expected
+        )
+
+    @staticmethod
+    def test_exceptions():
+        for text in ("abcdef", ""):
+            assert (
+                get_num_from_text(
+                    text=text,
+                )
+                is None
+            )
+
+
+def test_default_resolver():
+    extract._default_resolver(
+        username="username",
+        wrapper=create_autospec(
+            spec=UsernameToChatAPI,
+            spec_set=True,
+            instance=False,
+        ),
+    )
+
+
+@pytest.fixture(
+    scope="function",
+)
+def mock_message():
+    result = create_autospec(
+        spec=message_fabric(
+            text="foo",
+        ),
+        users_shared=None,
+        contact=None,
+        spec_set=True,
+    )
+    result.get_bot.return_value.get_chat = AsyncMock(
+        spec_set=True,
+        return_value=chat,
+    )
+    yield result
+
+
+async def test_shared_user():
+    result = await extract_passed_user(
+        message=message_fabric(
+            users_shared=UsersShared(
+                request_id=1,
+                users=(shared_user,),
+            ),
+        ),
+    )
+    assert result == shared_user
+
+
+async def test_text_username():
+    async def resolver(
+        **_,
+    ):
+        return chat
+
+    result = await extract_passed_user(
+        message=message_fabric(
+            text="  foo 434 @username @second_user ",
+        ),
+        username_resolver=resolver,
+    )
+    assert result == shared_user
+
+
+async def test_text_username_default_resolver():
+    with patch.object(
+        target=extract,
+        attribute="_default_resolver",
+        autospec=True,
+        spec_set=True,
+        return_value=chat,
+    ):
+        result = await extract_passed_user(
+            message=message_fabric(
+                text="  @username ",
+            ),
+            username_resolver=create_autospec(
+                spec=UsernameToChatAPI,
+                spec_set=True,
+                instance=False,
+            ),
+        )
+        assert result == shared_user
+
+
+async def test_contact_with_user_id(
+    mock_message: MagicMock,
+):
+    mock_message.contact = Contact(
+        phone_number="qwerty123",
+        first_name="John",
+        user_id=1,
+    )
+    result = await extract_passed_user(
+        message=mock_message,
+    )
+    assert result == shared_user
+
+
+async def test_contact_without_user_id():
+    result = await extract_passed_user(
+        message=message_fabric(
+            contact=Contact(
+                phone_number="qwerty123",
+                first_name="John",
+            ),
+            users_shared=None,
+        ),
+    )
+    assert result is None
+
+
+async def test_text_with_user_id(
+    mock_message: MagicMock,
+):
+    mock_message.text = " qwerty123 bla bla 456 "
+    result = await extract_passed_user(
+        message=mock_message,
+    )
+    assert result == shared_user


Reviews:
user: login: Bibo-Joshi
body: Thanks for the PR! I've left some comments
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-03-08T18:43:53Z
user: login: david-shiko
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-08T19:05:52Z
user: login: david-shiko
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-08T19:10:57Z
user: login: david-shiko
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-08T19:26:30Z
user: login: Bibo-Joshi
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-08T19:27:01Z
user: login: Bibo-Joshi
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-08T19:28:34Z
user: login: Bibo-Joshi
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-08T19:28:56Z
user: login: david-shiko
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-08T19:29:23Z
user: login: david-shiko
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-08T19:43:17Z
user: login: Bibo-Joshi
body: just two nitpicks. Please don't force-push that makes it hard to review.
please also merge the current master into your branch.
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-03-08T21:06:42Z
user: login: david-shiko
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-08T22:31:23Z

Review comments:
diff_hunk: @@ -0,0 +1,53 @@
+from tests.test_extract_passed_user import shared_user
+
path: ptbcontrib/extract_passed_user/README.md
user: login: Bibo-Joshi
body: ```suggestion
```
created_at: 2025-03-08T18:36:58Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,113 @@
+#!/usr/bin/env python
+#
+# A library containing community-based extension for the python-telegram-bot library
+# Copyright (C) 2020-2025
+# The ptbcontrib developers
+#
+# 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 attempts to extract side user passed actual user from incoming message"""
+
+from __future__ import annotations
+
+import asyncio
+from typing import TYPE_CHECKING, Callable
+
+from telegram import SharedUser
+from telegram.error import TelegramError
+
+from ..username_to_chat_api import UsernameToChatAPI
+
+if TYPE_CHECKING:
+    from telegram import Chat, Message
+
+
+def get_nums_from_text(
path: ptbcontrib/extract_passed_user/extract.py
user: login: Bibo-Joshi
body: please use a leading underscore for functions that are intended to be used only internally in this module and not by the user
created_at: 2025-03-08T18:38:12Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,113 @@
+#!/usr/bin/env python
+#
+# A library containing community-based extension for the python-telegram-bot library
+# Copyright (C) 2020-2025
+# The ptbcontrib developers
+#
+# 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 attempts to extract side user passed actual user from incoming message"""
+
+from __future__ import annotations
+
+import asyncio
+from typing import TYPE_CHECKING, Callable
+
+from telegram import SharedUser
+from telegram.error import TelegramError
+
+from ..username_to_chat_api import UsernameToChatAPI
+
+if TYPE_CHECKING:
+    from telegram import Chat, Message
+
+
+def get_nums_from_text(
+    text: str,
+) -> int | None:
+    """Extract numbers from text"""
+    try:
+        return int("".join([letter for letter in text if letter.isdigit()]))
path: ptbcontrib/extract_passed_user/extract.py
user: login: Bibo-Joshi
body: note that for a message with text `123 text 456` this will yield `123456`. That would be unexpected IMO. please consider adding some handling for that. I expect that using regex will be more efficient.
created_at: 2025-03-08T18:39:11Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,113 @@
+#!/usr/bin/env python
+#
+# A library containing community-based extension for the python-telegram-bot library
+# Copyright (C) 2020-2025
+# The ptbcontrib developers
+#
+# 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 attempts to extract side user passed actual user from incoming message"""
+
+from __future__ import annotations
+
+import asyncio
+from typing import TYPE_CHECKING, Callable
+
+from telegram import SharedUser
+from telegram.error import TelegramError
+
+from ..username_to_chat_api import UsernameToChatAPI
+
+if TYPE_CHECKING:
+    from telegram import Chat, Message
+
+
+def get_nums_from_text(
+    text: str,
+) -> int | None:
+    """Extract numbers from text"""
+    try:
+        return int("".join([letter for letter in text if letter.isdigit()]))
+    except ValueError:
+        return None
+
+
+def default_resolver(
+    wrapper: UsernameToChatAPI,
+    username: str,
+) -> Chat | None:
+    """
+    Try to resolve username to Chat object.
+    Catch exceptions from username_to_chat_api ?
+    """
+    return asyncio.run(
+        wrapper.resolve(
+            username=username.strip(),
+        )
+    )
+
+
+async def extract_passed_user(
+    message: Message,
+    username_resolver: Callable | UsernameToChatAPI | None = None,
+) -> SharedUser | None:
+    """
+    4 cases:
+    1. message.users_shared
+    2. message.contact.user_id
+    3. message.text starts with '@' and resolved by wrapper
+    4. message.text has only numbers, resolved by get_nums_from_text
+
+    About contact entity:
+        1. `contact` may be without user_id.
+        2. `contact` not contain @name at all, so not convertable to complete SharedUser.
+    """
+    chat = user_id = None
+    if message.users_shared:  # Note: May be select multiple
+        return message.users_shared.users[0]
path: ptbcontrib/extract_passed_user/extract.py
user: login: Bibo-Joshi
body: The behavior of selecting only the first user is not explained in the documentation and can have unexpected consequences for the user IMO. At the very least I recommend to document and issue a warning at runtime if `users_shared` cantains more than one user.
created_at: 2025-03-08T18:40:58Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,113 @@
+#!/usr/bin/env python
+#
+# A library containing community-based extension for the python-telegram-bot library
+# Copyright (C) 2020-2025
+# The ptbcontrib developers
+#
+# 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 attempts to extract side user passed actual user from incoming message"""
+
+from __future__ import annotations
+
+import asyncio
+from typing import TYPE_CHECKING, Callable
+
+from telegram import SharedUser
+from telegram.error import TelegramError
+
+from ..username_to_chat_api import UsernameToChatAPI
+
+if TYPE_CHECKING:
+    from telegram import Chat, Message
+
+
+def get_nums_from_text(
+    text: str,
+) -> int | None:
+    """Extract numbers from text"""
+    try:
+        return int("".join([letter for letter in text if letter.isdigit()]))
+    except ValueError:
+        return None
+
+
+def default_resolver(
+    wrapper: UsernameToChatAPI,
+    username: str,
+) -> Chat | None:
+    """
+    Try to resolve username to Chat object.
+    Catch exceptions from username_to_chat_api ?
+    """
+    return asyncio.run(
+        wrapper.resolve(
+            username=username.strip(),
+        )
+    )
+
+
+async def extract_passed_user(
+    message: Message,
+    username_resolver: Callable | UsernameToChatAPI | None = None,
+) -> SharedUser | None:
+    """
+    4 cases:
+    1. message.users_shared
+    2. message.contact.user_id
+    3. message.text starts with '@' and resolved by wrapper
+    4. message.text has only numbers, resolved by get_nums_from_text
+
+    About contact entity:
+        1. `contact` may be without user_id.
+        2. `contact` not contain @name at all, so not convertable to complete SharedUser.
+    """
+    chat = user_id = None
+    if message.users_shared:  # Note: May be select multiple
+        return message.users_shared.users[0]
+    if username_resolver and message.text and message.text.strip().startswith("@"):
+        if isinstance(username_resolver, UsernameToChatAPI):
+            chat = default_resolver(
+                wrapper=username_resolver,
+                username=message.text,
+            )
+        else:
+            chat: Chat | None = await username_resolver(  # type: ignore[no-redef]
+                username=message.text,
+            )
+    elif hasattr(
+        message.contact,
+        "user_id",
+    ):
path: ptbcontrib/extract_passed_user/extract.py
user: login: Bibo-Joshi
body: this will always be `True`. I guess you'd rather want to check `if message.cont
```suggestion
    elif message.contact:
```
created_at: 2025-03-08T18:42:43Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,24 @@
+# A library containing community-based extension for the python-telegram-bot library
+# Copyright (C) 2020-2025
+# The ptbcontrib developers
+#
+# 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 a helper function to get joinable links from chats."""
+
+from .extract import extract_passed_user, get_nums_from_text
+
+__all__ = [
+    "extract_passed_user",
+    "get_nums_from_text",
path: ptbcontrib/extract_passed_user/__init__.py
user: login: Bibo-Joshi
body: are you exposing this on purpose? you mention it nowhere in the readme.
created_at: 2025-03-08T18:43:41Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,113 @@
+#!/usr/bin/env python
+#
+# A library containing community-based extension for the python-telegram-bot library
+# Copyright (C) 2020-2025
+# The ptbcontrib developers
+#
+# 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 attempts to extract side user passed actual user from incoming message"""
+
+from __future__ import annotations
+
+import asyncio
+from typing import TYPE_CHECKING, Callable
+
+from telegram import SharedUser
+from telegram.error import TelegramError
+
+from ..username_to_chat_api import UsernameToChatAPI
+
+if TYPE_CHECKING:
+    from telegram import Chat, Message
+
+
+def get_nums_from_text(
path: ptbcontrib/extract_passed_user/extract.py
user: login: david-shiko
body: Technically, user may use it, no problem
created_at: 2025-03-08T19:05:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,24 @@
+# A library containing community-based extension for the python-telegram-bot library
+# Copyright (C) 2020-2025
+# The ptbcontrib developers
+#
+# 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 a helper function to get joinable links from chats."""
+
+from .extract import extract_passed_user, get_nums_from_text
+
+__all__ = [
+    "extract_passed_user",
+    "get_nums_from_text",
path: ptbcontrib/extract_passed_user/__init__.py
user: login: david-shiko
body: Why not? I cannot envision all possible scenarios, but why should we limit the user?
created_at: 2025-03-08T19:10:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,113 @@
+#!/usr/bin/env python
+#
+# A library containing community-based extension for the python-telegram-bot library
+# Copyright (C) 2020-2025
+# The ptbcontrib developers
+#
+# 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 attempts to extract side user passed actual user from incoming message"""
+
+from __future__ import annotations
+
+import asyncio
+from typing import TYPE_CHECKING, Callable
+
+from telegram import SharedUser
+from telegram.error import TelegramError
+
+from ..username_to_chat_api import UsernameToChatAPI
+
+if TYPE_CHECKING:
+    from telegram import Chat, Message
+
+
+def get_nums_from_text(
+    text: str,
+) -> int | None:
+    """Extract numbers from text"""
+    try:
+        return int("".join([letter for letter in text if letter.isdigit()]))
+    except ValueError:
+        return None
+
+
+def default_resolver(
+    wrapper: UsernameToChatAPI,
+    username: str,
+) -> Chat | None:
+    """
+    Try to resolve username to Chat object.
+    Catch exceptions from username_to_chat_api ?
+    """
+    return asyncio.run(
+        wrapper.resolve(
+            username=username.strip(),
+        )
+    )
+
+
+async def extract_passed_user(
+    message: Message,
+    username_resolver: Callable | UsernameToChatAPI | None = None,
+) -> SharedUser | None:
+    """
+    4 cases:
+    1. message.users_shared
+    2. message.contact.user_id
+    3. message.text starts with '@' and resolved by wrapper
+    4. message.text has only numbers, resolved by get_nums_from_text
+
+    About contact entity:
+        1. `contact` may be without user_id.
+        2. `contact` not contain @name at all, so not convertable to complete SharedUser.
+    """
+    chat = user_id = None
+    if message.users_shared:  # Note: May be select multiple
+        return message.users_shared.users[0]
+    if username_resolver and message.text and message.text.strip().startswith("@"):
+        if isinstance(username_resolver, UsernameToChatAPI):
+            chat = default_resolver(
+                wrapper=username_resolver,
+                username=message.text,
+            )
+        else:
+            chat: Chat | None = await username_resolver(  # type: ignore[no-redef]
+                username=message.text,
+            )
+    elif hasattr(
+        message.contact,
+        "user_id",
+    ):
path: ptbcontrib/extract_passed_user/extract.py
user: login: david-shiko
body: In most cases message.contact is None, however, even if it isn't, the contact object might not have a user_id field, and it will still be a valid contact, but I really missed the case with `user_id` intentionally filled with `None`.
created_at: 2025-03-08T19:26:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,113 @@
+#!/usr/bin/env python
+#
+# A library containing community-based extension for the python-telegram-bot library
+# Copyright (C) 2020-2025
+# The ptbcontrib developers
+#
+# 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 attempts to extract side user passed actual user from incoming message"""
+
+from __future__ import annotations
+
+import asyncio
+from typing import TYPE_CHECKING, Callable
+
+from telegram import SharedUser
+from telegram.error import TelegramError
+
+from ..username_to_chat_api import UsernameToChatAPI
+
+if TYPE_CHECKING:
+    from telegram import Chat, Message
+
+
+def get_nums_from_text(
path: ptbcontrib/extract_passed_user/extract.py
user: login: Bibo-Joshi
body: also `default_resolver`?
created_at: 2025-03-08T19:27:01Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,113 @@
+#!/usr/bin/env python
+#
+# A library containing community-based extension for the python-telegram-bot library
+# Copyright (C) 2020-2025
+# The ptbcontrib developers
+#
+# 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 attempts to extract side user passed actual user from incoming message"""
+
+from __future__ import annotations
+
+import asyncio
+from typing import TYPE_CHECKING, Callable
+
+from telegram import SharedUser
+from telegram.error import TelegramError
+
+from ..username_to_chat_api import UsernameToChatAPI
+
+if TYPE_CHECKING:
+    from telegram import Chat, Message
+
+
+def get_nums_from_text(
+    text: str,
+) -> int | None:
+    """Extract numbers from text"""
+    try:
+        return int("".join([letter for letter in text if letter.isdigit()]))
+    except ValueError:
+        return None
+
+
+def default_resolver(
+    wrapper: UsernameToChatAPI,
+    username: str,
+) -> Chat | None:
+    """
+    Try to resolve username to Chat object.
+    Catch exceptions from username_to_chat_api ?
+    """
+    return asyncio.run(
+        wrapper.resolve(
+            username=username.strip(),
+        )
+    )
+
+
+async def extract_passed_user(
+    message: Message,
+    username_resolver: Callable | UsernameToChatAPI | None = None,
+) -> SharedUser | None:
+    """
+    4 cases:
+    1. message.users_shared
+    2. message.contact.user_id
+    3. message.text starts with '@' and resolved by wrapper
+    4. message.text has only numbers, resolved by get_nums_from_text
+
+    About contact entity:
+        1. `contact` may be without user_id.
+        2. `contact` not contain @name at all, so not convertable to complete SharedUser.
+    """
+    chat = user_id = None
+    if message.users_shared:  # Note: May be select multiple
+        return message.users_shared.users[0]
+    if username_resolver and message.text and message.text.strip().startswith("@"):
+        if isinstance(username_resolver, UsernameToChatAPI):
+            chat = default_resolver(
+                wrapper=username_resolver,
+                username=message.text,
+            )
+        else:
+            chat: Chat | None = await username_resolver(  # type: ignore[no-redef]
+                username=message.text,
+            )
+    elif hasattr(
+        message.contact,
+        "user_id",
+    ):
path: ptbcontrib/extract_passed_user/extract.py
user: login: Bibo-Joshi
body: `contact.user_id` will always be there - it just may be `None`. So `if message.contact and message.contact.user_id` might be what you need.
created_at: 2025-03-08T19:28:34Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,24 @@
+# A library containing community-based extension for the python-telegram-bot library
+# Copyright (C) 2020-2025
+# The ptbcontrib developers
+#
+# 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 a helper function to get joinable links from chats."""
+
+from .extract import extract_passed_user, get_nums_from_text
+
+__all__ = [
+    "extract_passed_user",
+    "get_nums_from_text",
path: ptbcontrib/extract_passed_user/__init__.py
user: login: Bibo-Joshi
body: Your call, just seemed surprising to me
created_at: 2025-03-08T19:28:56Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,53 @@
+from tests.test_extract_passed_user import shared_user
+
path: ptbcontrib/extract_passed_user/README.md
user: login: david-shiko
body: Autoinsertion by PyCharm :)
created_at: 2025-03-08T19:29:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,113 @@
+#!/usr/bin/env python
+#
+# A library containing community-based extension for the python-telegram-bot library
+# Copyright (C) 2020-2025
+# The ptbcontrib developers
+#
+# 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 attempts to extract side user passed actual user from incoming message"""
+
+from __future__ import annotations
+
+import asyncio
+from typing import TYPE_CHECKING, Callable
+
+from telegram import SharedUser
+from telegram.error import TelegramError
+
+from ..username_to_chat_api import UsernameToChatAPI
+
+if TYPE_CHECKING:
+    from telegram import Chat, Message
+
+
+def get_nums_from_text(
path: ptbcontrib/extract_passed_user/extract.py
user: login: david-shiko
body: The `default_resolver` is more specific, so I didn't include it. It mainly concerns the style of the package, so I will export things according to the package policy.
created_at: 2025-03-08T19:43:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,55 @@
+# Extract passed user
+
+This module intended for a single function:   
+Extract side user from the message provided by current user.
+i.e. 1 user passes data of third-party user.
+
+Currently, the function will check:
+1. `Message.users_shared`
+2. `Message.contact`
+3. `Message.text` with numbers (if user id typed manually)
+4. `Message.text` with @name (if user @name typed manually)
+
+## Usage:
+
+### 1. By the users_shared:
+Note: `users_shared` object is tuple and may contain many users but only first of them will be returned. 
+```python
+Message=(..., users_shared)
+shared_user = extract_passed_user(message=message)  # Just the first user in the users_shared tuple 
+```
+
+### 2. By the contact:  
+Note: PTB `Contact.user_id` field is optional but required for the extraction or `None` otherwise will be returned. 
+```python
+Message=(..., contact=Contact(...))
+shared_user = extract_passed_user(message=message) 
+```
+
+### 3. By the text id:   
+Note: Only first found number in the text will be used as `user_id`.
+```python
+Message=(..., text='Here my fried id - 123456, only numbers will be extracted.')
+shared_user = extract_passed_user(message=message) 
+```
+
+### 4. By the text @name:  
+Note: telegram bot API has no method to convert `@name` into `user_id`, 
+so ptbcontrlib module `username_to_chat_api` will be user for this.
+`username_resolver` parameter required for this, 
+it should be `UsernameToChatAPI` instance or custom async function.  
+Only first found world with `@` prefix in the text will be used as future `user_id`.
+```python
+Message=(..., text='@friend_nickname')
+shared_user = extract_passed_user(message=message, username_resolver=UsernameToChatAPI(..., )) 
+```
+
+### `get_num_from_text` - helper function which extracts first found number from the string as said in the docs above
path: ptbcontrib/extract_passed_user/README.md
user: login: Bibo-Joshi
body: ```suggestion
### `get_num_from_text` 

helper function which extracts first found number from the string as said in the docs above
```
created_at: 2025-03-08T21:04:53Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,110 @@
+#!/usr/bin/env python
+#
+# A library containing community-based extension for the python-telegram-bot library
+# Copyright (C) 2020-2025
+# The ptbcontrib developers
+#
+# 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 attempts to extract side user passed actual user from incoming message"""
+
+from __future__ import annotations
+
+import asyncio
+import re
+from typing import TYPE_CHECKING, Callable
+
+from telegram import SharedUser
+from telegram.error import TelegramError
+
+from ..username_to_chat_api import UsernameToChatAPI
+
+if TYPE_CHECKING:
+    from telegram import Chat, Message
+
+
+def get_num_from_text(
+    text: str,
+) -> int | None:
+    """Extract first number from text"""
+    if matched_number := re.search(r"\d+", text):
+        return int(matched_number.group())
+    return None
+
+
+def default_resolver(
path: ptbcontrib/extract_passed_user/extract.py
user: login: Bibo-Joshi
body: ```suggestion
def _default_resolver(
```
created_at: 2025-03-08T21:05:42Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,113 @@
+#!/usr/bin/env python
+#
+# A library containing community-based extension for the python-telegram-bot library
+# Copyright (C) 2020-2025
+# The ptbcontrib developers
+#
+# 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 attempts to extract side user passed actual user from incoming message"""
+
+from __future__ import annotations
+
+import asyncio
+from typing import TYPE_CHECKING, Callable
+
+from telegram import SharedUser
+from telegram.error import TelegramError
+
+from ..username_to_chat_api import UsernameToChatAPI
+
+if TYPE_CHECKING:
+    from telegram import Chat, Message
+
+
+def get_nums_from_text(
+    text: str,
+) -> int | None:
+    """Extract numbers from text"""
+    try:
+        return int("".join([letter for letter in text if letter.isdigit()]))
+    except ValueError:
+        return None
+
+
+def default_resolver(
+    wrapper: UsernameToChatAPI,
+    username: str,
+) -> Chat | None:
+    """
+    Try to resolve username to Chat object.
+    Catch exceptions from username_to_chat_api ?
+    """
+    return asyncio.run(
+        wrapper.resolve(
+            username=username.strip(),
+        )
+    )
+
+
+async def extract_passed_user(
+    message: Message,
+    username_resolver: Callable | UsernameToChatAPI | None = None,
+) -> SharedUser | None:
+    """
+    4 cases:
+    1. message.users_shared
+    2. message.contact.user_id
+    3. message.text starts with '@' and resolved by wrapper
+    4. message.text has only numbers, resolved by get_nums_from_text
+
+    About contact entity:
+        1. `contact` may be without user_id.
+        2. `contact` not contain @name at all, so not convertable to complete SharedUser.
+    """
+    chat = user_id = None
+    if message.users_shared:  # Note: May be select multiple
+        return message.users_shared.users[0]
path: ptbcontrib/extract_passed_user/extract.py
user: login: david-shiko
body: Is a warning really necessary? The keyboard for selecting a user has a limit option, so it is likely that the developer will use it. Even if they don't, the warning will inform about third-party domains (Telegram Bot API or PTB, which are simply not fully utilized). 

In summary: on one hand, these are different modules, but on the other hand, this is a good place to let the developer know about such an option (if it is not removed later, as it would be difficult to keep track of).
created_at: 2025-03-08T22:31:23Z
author_association: CONTRIBUTOR

PR comments:
user: login: Poolitzer
created_at: 2025-03-08T18:22:08Z
body: @david-shiko Basically the same, just not pre filled with those.
author_association: MEMBER
user: login: david-shiko
created_at: 2025-03-08T23:23:38Z
body: > just two nitpicks. Please don't force-push that makes it hard to review. please also merge the current master into your branch.

It's already up to date with main branch.
author_association: CONTRIBUTOR
user: login: Bibo-Joshi
created_at: 2025-03-09T00:07:18Z
body: Thank you for the contribution!
author_association: MEMBER