Skip to content

Commit e29f18c

Browse files
committed
- update tests
- some method fix
1 parent b9a0204 commit e29f18c

44 files changed

Lines changed: 4455 additions & 3389 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

poetry.lock

Lines changed: 956 additions & 619 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pybotx/bot/bot.py

Lines changed: 114 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@
103103
BotXAPITypingEventRequestPayload,
104104
TypingEventMethod,
105105
)
106-
from pybotx.client.exceptions.common import InvalidBotAccountError
106+
from pybotx.client.exceptions.common import ChatNotFoundError, InvalidBotAccountError
107107
from pybotx.client.files_api.download_file import (
108108
BotXAPIDownloadFileRequestPayload,
109109
DownloadFileMethod,
@@ -281,7 +281,7 @@ def __init__(
281281
exception_handlers: Optional[ExceptionHandlersDict] = None,
282282
default_callback_timeout: float = BOTX_DEFAULT_TIMEOUT,
283283
callback_repo: Optional[CallbackRepoProto] = None,
284-
auth_version: BotXAuthVersion = BotXAuthVersion.V1,
284+
auth_version: BotXAuthVersion = BotXAuthVersion.V2,
285285
) -> None:
286286
if not collectors:
287287
logger.warning("Bot has no connected collectors")
@@ -1109,6 +1109,37 @@ async def personal_chat(
11091109

11101110
return botx_api_personal_chat.to_domain()
11111111

1112+
async def ensure_personal_chat(
1113+
self,
1114+
*,
1115+
bot_id: UUID,
1116+
user_huid: UUID,
1117+
name: Optional[str] = None,
1118+
) -> ChatInfo:
1119+
"""Get or create personal chat with user.
1120+
1121+
Tries to fetch existing personal chat. If not found, creates it and
1122+
returns chat info for the new chat.
1123+
1124+
:param bot_id: Bot which should perform the request.
1125+
:param user_huid: Target user HUID.
1126+
:param name: Optional chat name for creation.
1127+
1128+
:return: Chat information.
1129+
"""
1130+
1131+
try:
1132+
return await self.personal_chat(bot_id=bot_id, user_huid=user_huid)
1133+
except ChatNotFoundError:
1134+
chat_name = name or f"Personal chat {user_huid}"
1135+
chat_id = await self.create_chat(
1136+
bot_id=bot_id,
1137+
name=chat_name,
1138+
chat_type=ChatTypes.PERSONAL_CHAT,
1139+
huids=[user_huid],
1140+
)
1141+
return await self.chat_info(bot_id=bot_id, chat_id=chat_id)
1142+
11121143
async def add_users_to_chat(
11131144
self,
11141145
*,
@@ -1419,6 +1450,8 @@ async def search_user_by_email_post(
14191450
) -> UserFromSearch:
14201451
"""Search user by email for search.
14211452
1453+
For multiple emails use `search_user_by_emails`.
1454+
14221455
:param bot_id: Bot which should perform the request.
14231456
:param email: User email.
14241457
@@ -2279,9 +2312,87 @@ def _verify_request(
22792312
)
22802313
except jwt.DecodeError as decode_exc:
22812314
raise UnverifiedRequestError(decode_exc.args[0]) from decode_exc
2315+
if self._is_v2_payload(token_payload):
2316+
self._verify_request_v2(token, token_payload, decode_algorithms)
2317+
else:
2318+
self._verify_request_v1(
2319+
token,
2320+
token_payload,
2321+
decode_algorithms,
2322+
trusted_issuers,
2323+
)
22822324

2325+
@staticmethod
2326+
def _is_v2_payload(token_payload: Mapping[str, Any]) -> bool:
2327+
if token_payload.get("version") == 2:
2328+
return True
2329+
2330+
audience = token_payload.get("aud")
2331+
issuer = token_payload.get("iss")
2332+
if not isinstance(audience, str) or not isinstance(issuer, str):
2333+
return False
2334+
2335+
try:
2336+
UUID(issuer)
2337+
except (TypeError, ValueError):
2338+
return False
2339+
2340+
return True
2341+
2342+
def _verify_request_v2(
2343+
self,
2344+
token: str,
2345+
token_payload: Mapping[str, Any],
2346+
decode_algorithms: List[str],
2347+
) -> None:
2348+
issuer = token_payload.get("iss")
2349+
if issuer is None:
2350+
raise UnverifiedRequestError('Token is missing the "iss" claim')
2351+
if not isinstance(issuer, str):
2352+
raise UnverifiedRequestError("Invalid issuer")
2353+
2354+
try:
2355+
bot_id = UUID(issuer)
2356+
except (TypeError, ValueError) as exc:
2357+
raise UnverifiedRequestError("Invalid issuer") from exc
2358+
2359+
try:
2360+
bot_account = self._bot_accounts_storage.get_bot_account(bot_id)
2361+
except UnknownBotAccountError as unknown_bot_exc:
2362+
raise UnverifiedRequestError(unknown_bot_exc.args[0]) from unknown_bot_exc
2363+
2364+
audience = token_payload.get("aud")
2365+
if not audience or not isinstance(audience, str):
2366+
raise UnverifiedRequestError("Invalid audience parameter was provided.")
2367+
if audience != bot_account.host:
2368+
raise UnverifiedRequestError("Invalid audience parameter was provided.")
2369+
2370+
try:
2371+
jwt.decode(
2372+
jwt=token,
2373+
key=bot_account.secret_key,
2374+
algorithms=decode_algorithms,
2375+
issuer=str(bot_account.id),
2376+
audience=bot_account.host,
2377+
leeway=1,
2378+
)
2379+
except jwt.InvalidTokenError as exc:
2380+
raise UnverifiedRequestError(exc.args[0]) from exc
2381+
2382+
def _verify_request_v1(
2383+
self,
2384+
token: str,
2385+
token_payload: Mapping[str, Any],
2386+
decode_algorithms: List[str],
2387+
trusted_issuers: Optional[Set[str]],
2388+
) -> None:
22832389
audience = token_payload.get("aud")
2284-
if not audience or not isinstance(audience, Sequence) or len(audience) != 1:
2390+
if (
2391+
not audience
2392+
or not isinstance(audience, Sequence)
2393+
or isinstance(audience, str)
2394+
or len(audience) != 1
2395+
):
22852396
raise UnverifiedRequestError("Invalid audience parameter was provided.")
22862397

22872398
try:

pybotx/bot/bot_accounts_storage.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ class BotAccountsStorage:
1313
def __init__(
1414
self,
1515
bot_accounts: List[BotAccountWithSecret],
16-
auth_version: BotXAuthVersion = BotXAuthVersion.V1,
16+
auth_version: BotXAuthVersion = BotXAuthVersion.V2,
1717
) -> None:
1818
self._bot_accounts = bot_accounts
1919
self._auth_tokens: Dict[UUID, str] = {}

pybotx/bot/callbacks/callback_memory_repo.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ async def stop_callbacks_waiting(self) -> None:
5454
f"Callback with sync_id `{sync_id!s}` can't be received",
5555
),
5656
)
57+
# Mark exception as retrieved to avoid "Future exception was never retrieved"
58+
future.exception()
59+
self._callback_futures.clear()
5760

5861
def _get_botx_method_callback(self, sync_id: UUID) -> "Future[BotXMethodCallback]":
5962
try:

pybotx/client/chats_api/personal_chat.py

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -39,40 +39,41 @@ class BotXAPIPersonalChatMember(VerifiedPayloadBaseModel):
3939
user_huid: UUID
4040
user_kind: APIUserKinds
4141

42-
model_config = ConfigDict(extra="forbid")
42+
model_config = ConfigDict(extra="ignore")
4343

4444

4545
class BotXAPIPersonalChatResult(VerifiedPayloadBaseModel):
4646
"""Результат API-ответа по персональному чату."""
4747

4848
chat_type: APIChatTypes
49-
creator: Optional[UUID]
49+
creator: Optional[UUID] = None
5050
description: Optional[str] = None
5151
group_chat_id: UUID
5252
inserted_at: dt
53-
members: List[Union[BotXAPIPersonalChatMember, Dict[str, Any]]] = Field(
54-
default_factory=list
53+
updated_at: Optional[dt] = None
54+
members: List[Union[BotXAPIPersonalChatMember, Dict[str, Any], UUID]] = Field(
55+
default_factory=list,
5556
)
5657
name: str
5758
shared_history: bool
5859

59-
model_config = ConfigDict(extra="forbid")
60+
model_config = ConfigDict(extra="ignore")
6061

6162
@field_validator("members", mode="before")
6263
@classmethod
6364
def validate_members(
6465
cls,
65-
value: List[Union[BotXAPIPersonalChatMember, Dict[str, Any]]],
66+
value: List[Union[BotXAPIPersonalChatMember, Dict[str, Any], UUID, str]],
6667
info: Any,
67-
) -> List[Union[BotXAPIPersonalChatMember, Dict[str, Any]]]:
68+
) -> List[Union[BotXAPIPersonalChatMember, Dict[str, Any], UUID]]:
6869
return cls._parse_members(value)
6970

7071
@staticmethod
7172
def _parse_members(
72-
members_data: List[Union[BotXAPIPersonalChatMember, Dict[str, Any]]],
73-
) -> List[Union[BotXAPIPersonalChatMember, Dict[str, Any]]]:
73+
members_data: List[Union[BotXAPIPersonalChatMember, Dict[str, Any], UUID, str]],
74+
) -> List[Union[BotXAPIPersonalChatMember, Dict[str, Any], UUID]]:
7475
# Явная аннотация решает проблему инвариантности List в mypy
75-
parsed: List[Union[BotXAPIPersonalChatMember, Dict[str, Any]]] = []
76+
parsed: List[Union[BotXAPIPersonalChatMember, Dict[str, Any], UUID]] = []
7677
for item in members_data:
7778
if isinstance(item, dict):
7879
try:
@@ -82,6 +83,13 @@ def _parse_members(
8283
parsed.append(item)
8384
elif isinstance(item, BotXAPIPersonalChatMember):
8485
parsed.append(item)
86+
elif isinstance(item, UUID):
87+
parsed.append(item)
88+
elif isinstance(item, str):
89+
try:
90+
parsed.append(UUID(item))
91+
except ValueError:
92+
logger.warning("Unknown member type: %s", item)
8593
else:
8694
logger.warning("Unknown member type: %s", item)
8795
return parsed
@@ -96,6 +104,12 @@ class BotXAPIPersonalChatResponsePayload(VerifiedPayloadBaseModel):
96104
model_config = ConfigDict(extra="forbid")
97105

98106
def to_domain(self) -> ChatInfo:
107+
if any(
108+
not isinstance(member, BotXAPIPersonalChatMember)
109+
for member in self.result.members
110+
):
111+
logger.warning("Unsupported user type skipped in members list")
112+
99113
members: List[ChatInfoMember] = []
100114
for member in self.result.members:
101115
if isinstance(member, BotXAPIPersonalChatMember):
@@ -109,10 +123,6 @@ def to_domain(self) -> ChatInfo:
109123
)
110124
except Exception as exc:
111125
logger.warning("Failed to convert member kind: %s", exc)
112-
else:
113-
logger.warning(
114-
"Unsupported user type skipped in members list: %s", member
115-
)
116126

117127
return ChatInfo(
118128
chat_type=convert_chat_type_to_domain(self.result.chat_type),

pyproject.toml

Lines changed: 32 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -13,37 +13,40 @@ repository = "https://github.com/ExpressApp/pybotx"
1313

1414

1515
[tool.poetry.dependencies]
16-
python = ">=3.9,<3.14"
16+
python = ">=3.10,<4.0"
1717

18-
aiofiles = ">=0.7.0,<=24.1.0"
19-
httpx = "^0.28.0"
18+
aiofiles = "^25.1.0"
19+
httpx = "^0.28.1"
2020
# The v1.0.3 cause some troubles with no-wait callbacks functionality.
2121
# It will be fixed in the next versions.
2222
# https://github.com/encode/httpcore/pull/880
23-
httpcore = "1.0.9"
24-
loguru = ">=0.6.0,<0.7.0"
25-
pydantic = ">=2.8.2,<3.0"
26-
aiocsv = ">=1.2.3,<=1.4.0"
27-
pyjwt = ">=2.0.0,<3.0.0"
28-
mypy-extensions = ">=0.2.0,<=1.1.0"
23+
httpcore = "^1.0.9"
24+
loguru = "^0.7.3"
25+
pydantic = "^2.12.5"
26+
aiocsv = "^1.4.0"
27+
pyjwt = "^2.11.0"
28+
mypy-extensions = "^1.1.0"
2929

3030
[tool.poetry.group.dev.dependencies]
31-
mypy = "1.15.0"
32-
typing-extensions = ">=3.7.4,<5.0.0"
33-
bandit = "1.8.3" # https://github.com/PyCQA/bandit/issues/837
31+
mypy = "^1.19.1"
32+
typing-extensions = "^4.15.0"
33+
bandit = "^1.9.3" # https://github.com/PyCQA/bandit/issues/837
3434

35-
pytest = "8.3.5"
36-
pytest-asyncio = "0.26.0"
37-
pytest-cov = "6.1.1"
38-
requests = "2.32.3"
39-
respx = "0.22.0"
40-
factory-boy = ">=3.3.3,<=4.0.0"
41-
deepdiff = "^8.5.0,<=9.0.0"
35+
pytest = "^9.0.2"
36+
pytest-asyncio = "^1.3.0"
37+
pytest-cov = "^7.0.0"
38+
pytest-timeout = "^2.4.0"
39+
pytest-xdist = "^3.8.0"
40+
hypothesis = "^6.151.5"
41+
requests = "^2.32.5"
42+
respx = "^0.22.0"
43+
factory-boy = "^3.3.3"
44+
deepdiff = "^8.6.1"
4245

43-
fastapi = "0.115.12 "
44-
starlette = "0.46.2" # TODO: Drop dependency after updating end-to-end test
45-
uvicorn = "0.34.2"
46-
ruff = "0.12.3"
46+
fastapi = "^0.128.1"
47+
starlette = ">=0.40,<0.51" # TODO: Drop dependency after updating end-to-end test
48+
uvicorn = "^0.40.0"
49+
ruff = "^0.15.0"
4750

4851
[build-system]
4952
requires = ["poetry>=1.2.0"]
@@ -56,15 +59,21 @@ testpaths = ["tests"]
5659
addopts = [
5760
"--strict-markers",
5861
"--tb=short",
62+
"-n",
63+
"auto",
64+
"--dist=loadscope",
5965
"--cov=pybotx",
6066
"--cov-report=term-missing",
6167
"--cov-branch",
6268
"--no-cov-on-fail",
6369
"--cov-fail-under=100",
70+
"--timeout=10",
71+
"--timeout-method=thread",
6472
]
6573
markers = [
6674
"wip: Work in progress",
6775
"mock_authorization: Mock authorization",
76+
"allow_network: Allow real network access in a test",
6877
]
6978
filterwarnings = [
7079
"ignore:Pydantic serializer warnings:UserWarning",

0 commit comments

Comments
 (0)