8000 Add `extract_passed_user` Contribution (#124) · python-telegram-bot/ptbcontrib@88329f6 · GitHub
[go: up one dir, main page]

Skip to content

Commit 88329f6

Browse files
authored
Add extract_passed_user Contribution (#124)
1 parent 3f85604 commit 88329f6

File tree

5 files changed

+395
-0
lines changed

5 files changed

+395
-0
lines changed
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Extract passed user
2+
3+
This module intended for a single function:
4+
Extract side user from the message provided by current user.
5+
i.e. 1 user passes data of third-party user.
6+
7+
Currently, the function will check:
8+
1. `Message.users_shared`
9+
2. `Message.contact`
10+
3. `Message.text` with numbers (if user id typed manually)
11+
4. `Message.text` with @name (if user @name typed manually)
12+
13+
## Usage:
14+
15+
### 1. By the users_shared:
16+
Note: `users_shared` object is tuple and may contain many users but only first of them will be returned.
17+
```python
18+
Message=(..., users_shared)
19+
shared_user = extract_passed_user(message=message) # Just the first user in the users_shared tuple
20+
```
21+
22+
### 2. By the contact:
23+
Note: PTB `Contact.user_id` field is optional but required for the extraction or `None` otherwise will be returned.
24+
```python
25+
Message=(..., contact=Contact(...))
26+
shared_user = extract_passed_user(message=message)
27+
```
28+
29+
### 3. By the text id:
30+
Note: Only first found number in the text will be used as `user_id`.
31+
```python
32+
Message=(..., text='Here my fried id - 123456, only numbers will be extracted.')
33+
shared_user = extract_passed_user(message=message)
34+
```
35+
36+
### 4. By the text @name:
37+
Note: telegram bot API has no method to convert `@name` into `user_id`,
38+
so ptbcontrlib module `username_to_chat_api` will be user for this.
39+
`username_resolver` parameter required for this,
40+
it should be `UsernameToChatAPI` instance or custom async function.
41+
Only first found world with `@` prefix in the text will be used as future `user_id`.
42+
```python
43+
Message=(..., text='@friend_nickname')
44+
shared_user = extract_passed_user(message=message, username_resolver=UsernameToChatAPI(..., ))
45+
```
46+
47+
### `get_num_from_text` function:
48+
helper function which extracts first found number from the string as said in the docs above
49+
50+
## Requirements
51+
52+
* `python-telegram-bot>=21.1`
53+
54+
## Authors
55+
56+
* [david Shiko](https://github.com/david-shiko)
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# A library containing community-based extension for the python-telegram-bot library
2+
# Copyright (C) 2020-2025
3+
# The ptbcontrib developers
4+
#
5+
# This program is free software: you can redistribute it and/or modify
6+
# it under the terms of the GNU Lesser Public License as published by
7< A3DB span class="diff-text-marker">+
# the Free Software Foundation, either version 3 of the License, or
8+
# (at your option) any later version.
9+
#
10+
# This program is distributed in the hope that it will be useful,
11+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
# GNU Lesser Public License for more details.
14+
#
15+
# You should have received a copy of the GNU Lesser Public License
16+
# along with this program. If not, see [http://www.gnu.org/licenses/].
17+
"""This module contains a helper function to get joinable links from chats."""
18+
19+
from .extract import extract_passed_user, get_num_from_text
20+
21+
__all__ = [
22+
"extract_passed_user",
23+
"get_num_from_text",
24+
]
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
#!/usr/bin/env python
2+
#
3+
# A library containing community-based extension for the python-telegram-bot library
4+
# Copyright (C) 2020-2025
5+
# The ptbcontrib developers
6+
#
7+
# This program is free software: you can redistribute it and/or modify
8+
# it under the terms of the GNU Lesser Public License as published by
9+
# the Free Software Foundation, either version 3 of the License, or
10+
# (at your option) any later version.
11+
#
12+
# This program is distributed in the hope that it will be useful,
13+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
14+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15+
# GNU Lesser Public License for more details.
16+
#
17+
# You should have received a copy of the GNU Lesser Public License
18+
# along with this program. If not, see [http://www.gnu.org/licenses/].
19+
20+
"""This module attempts to extract side user passed actual user from incoming message"""
21+
22+
from __future__ import annotations
23+
24+
import asyncio
25+
import re
26+
from typing import TYPE_CHECKING, Callable
27+
28+
from telegram import SharedUser
29+
from telegram.error import TelegramError
30+
31+
from ..username_to_chat_api import UsernameToChatAPI
32+
33+
if TYPE_CHECKING:
34+
from telegram import Chat, Message
35+
36+
37+
def get_num_from_text(
38+
text: str,
39+
) -> int | None:
40+
"""Extract first number from text"""
41+
if matched_number := re.search(r"\d+", text):
42+
return int(matched_number.group())
43+
return None
44+
45+
46+
def _default_resolver(
47+
wrapper: UsernameToChatAPI,
48+
username: str,
49+
) -> Chat | None:
50+
"""
51+
Try to resolve username to Chat object.
52+
Catch exceptions from username_to_chat_api ?
53+
"""
54+
return asyncio.run(
55+
wrapper.resolve(
56+
username=username.strip(),
57+
)
58+
)
59+
60+
61+
async def extract_passed_user(
62+
message: Message,
63+
username_resolver: Callable | UsernameToChatAPI | None = None,
64+
) -> SharedUser | None:
65+
"""
66+
4 cases:
67+
1. message.users_shared
68+
2. message.contact.user_id
69+
3. message.text starts with '@' and resolved by wrapper
70+
4. message.text has only numbers, resolved by get_num_from_text
71+
72+
About contact entity:
73+
1. `contact` may be without user_id.
74+
2. `contact` not contain @name at all, so not convertable to complete SharedUser.
75+
"""
76+
chat = user_id = None
77+
if message.users_shared: # Note: May be select multiple
78+
return message.users_shared.users[0]
79+
if username_resolver and message.text and (username := re.search(r"@\S+", message.text)):
80+
if isinstance(username_resolver, UsernameToChatAPI):
81+
chat = _default_resolver(
82+
wrapper=username_resolver,
83+
username=username.group(),
84+
)
85+
else:
86+
chat: Chat | None = await username_resolver( # type: ignore[no-redef]
87+
username=message.text,
88+
)
89+
elif message.contact and message.contact.user_id:
90+
user_id = message.contact.user_id
91+
elif message.text:
92+
user_id = get_num_from_text(
93+
text=message.text,
94+
)
95+
96+
if user_id:
97+
try:
98+
chat = await message.get_bot().get_chat(
99+
chat_id=user_id,
100+
)
101+
except TelegramError: # pragma: no cover
102+
return None
103+
if chat:
104+
return SharedUser(
105+
user_id=chat.id,
106+
username=chat.username,
107+
first_name=chat.first_name,
108+
last_name=chat.last_name,
109+
)
110+
return None
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
python-telegram-bot>=21.1

tests/test_extract_passed_user.py

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
#!/usr/bin/env python
2+
#
3+
# A library containing community-based extension for the python-telegram-bot library
4+
# Copyright (C) 2020-2025
5+
# The ptbcontrib developers
6+
#
7+
# This program is free software: you can redistribute it and/or modify
8+
# it under the terms of the GNU Lesser Public License as published by
9+
# the Free Software Foundation, either version 3 of the License, or
10+
# (at your option) any later version.
11+
#
12+
# This program is distributed in the hope that it will be useful,
13+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
14+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15+
# GNU Lesser Public License for more details.
16+
#
17+
# You should have received a copy of the GNU Lesser Public License
18+
# along with this program. If not, see [http://www.gnu.org/licenses/].
19+
20+
from __future__ import annotations
21+
22+
from datetime import datetime
23+
from functools import partial
24+
from typing import TYPE_CHECKING
25+
from unittest.mock import AsyncMock, create_autospec, patch
26+
27+
import pytest
28+
from telegram import Chat, Contact, Message, SharedUser, UsersShared
29+
from telegram.constants import ChatType
30+
31+
from ptbcontrib.extract_passed_user import extract, extract_passed_user, get_num_from_text
32+
from ptbcontrib.username_to_chat_api import UsernameToChatAPI
33+
34+
if TYPE_CHECKING:
35+
from unittest.mock import MagicMock
36+
37+
chat = Chat(
38+
id=1,
39+
type=ChatType.PRIVATE,
40+
username="username",
41+
first_name="first_name",
42+
last_name="last_name",
43+
)
44+
message_fabric = partial(
45+
Message,
46+
message_id=1,
47+
date=datetime.now(),
48+
chat=chat,
49+
)
50+
shared_user = SharedUser(
51+
user_id=1,
52+
username=chat.username,
53+
first_name=chat.first_name,
54+
last_name=chat.last_name,
55+
)
56+
57+
58+
class TestGetNumsFromText:
59+
"""test_get_nums_from_text"""
60+
61+
@staticmethod
62+
@pytest.mark.parametrize(
63+
argnames="text, expected",
64+
argvalues=[
65+
("abc123xyz", 123),
66+
("98765", 98765),
67+
(" ab#$@c9def8g7h6 ", 9),
68+
],
69+
)
70+
def test_success(
71+
text: str,
72+
expected: int,
73+
):
74+
assert (
75+
get_num_from_text(
76+
text=text,
77+
)
78+
== expected
79+
)
80+
81+
@staticmethod
82+
def test_exceptions():
83+
for text in ("abcdef", ""):
84+
assert (
85+
get_num_from_text(
86+
text=text,
87+
)
88+
is None
89+
)
90+
91+
92+
def test_default_resolver():
93+
extract._default_resolver(
94+
username="username",
95+
wrapper=create_autospec(
96+
spec=UsernameToChatAPI,
97+
spec_set=True,
98+
instance=False,
99+
),
100+
)
101+
102+
103+
@pytest.fixture(
104+
scope="function",
105+
)
106+
def mock_message():
107+
result = create_autospec(
108+
spec=message_fabric(
109+
text="foo",
110+
),
111+
users_shared=None,
112+
contact=None,
113+
spec_set=True,
114+
)
115+
result.get_bot.return_value.get_chat = AsyncMock(
116+
spec_set=True,
117+
return_value=chat,
118+
)
119+
yield result
120+
121+
122+
async def test_shared_user():
123+
result = await extract_passed_user(
124+
message=message_fabric(
125+
users_shared=UsersShared(
126+
request_id=1,
127+
users=(shared_user,),
128+
),
129+
),
130+
)
131+
assert result == shared_user
132+
133+
134+
async def test_text_username():
135+
async def resolver(
136+
**_,
137+
):
138+
return chat
139+
140+
result = await extract_passed_user(
141+
message=message_fabric(
142+
text=" foo 434 @username @second_user ",
143+
),
144+
username_resolver=resolver,
145+
)
146+
assert result == shared_user
147+
148+
149+
async def test_text_username_default_resolver():
150+
with patch.object(
151+
target=extract,
152+
attribute="_default_resolver",
153+
autospec=True,
154+
spec_set=True,
155+
return_value=chat,
156+
):
157+
result = await extract_passed_user(
158+
message=message_fabric(
159+
text=" @username ",
160+
),
161+
username_resolver=create_autospec(
162+
spec=UsernameToChatAPI,
163+
spec_set=True,
164+
instance=False,
165+
),
166+
)
167+
assert result == shared_user
168+
169+
170+
async def test_contact_with_user_id(
171+
mock_message: MagicMock,
172+
):
173+
mock_message.contact = Contact(
174+
phone_number="qwerty123",
175+
first_name="John",
176+
user_id=1,
177+
)
178+
result = await extract_passed_user(
179+
message=mock_message,
180+
)
181+
assert result == shared_user
182+
183+
184+
async def test_contact_without_user_id():
185+
result = await extract_passed_user(
186+
message=message_fabric(
187+
contact=Contact(
188+
phone_number="qwerty123",
189+
first_name="John",
190+
),
191+
users_shared=None,
192+
),
193+
)
194+
assert result is None
195+
196+
197+
async def test_text_with_user_id(
198+
mock_message: MagicMock,
199+
):
200+
mock_message.text = " qwerty123 bla bla 456 "
201+
result = await extract_passed_user(
202+
message=mock_message,
203+
)
204+
assert result == shared_user

0 commit comments

Comments
 (0)
0