Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 73 additions & 98 deletions poetry.lock

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions pybotx/bot/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
)
from pybotx.models.system_events.left_from_chat import LeftFromChatEvent
from pybotx.models.system_events.smartapp_event import SmartAppEvent
from pybotx.models.system_events.user_joined_to_chat import JoinToChatEvent

if TYPE_CHECKING: # To avoid circular import
from pybotx.bot.bot import Bot
Expand All @@ -42,6 +43,7 @@
HandlerFunc[InternalBotNotificationEvent],
HandlerFunc[SmartAppEvent],
HandlerFunc[EventEdit],
HandlerFunc[JoinToChatEvent],
]

VisibleFunc = Callable[[StatusRecipient, "Bot"], Awaitable[bool]]
Expand Down
9 changes: 9 additions & 0 deletions pybotx/bot/handler_collector.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
)
from pybotx.models.system_events.left_from_chat import LeftFromChatEvent
from pybotx.models.system_events.smartapp_event import SmartAppEvent
from pybotx.models.system_events.user_joined_to_chat import JoinToChatEvent

if TYPE_CHECKING: # To avoid circular import
from pybotx.bot.bot import Bot
Expand Down Expand Up @@ -269,6 +270,14 @@ def left_from_chat(
self._system_event(LeftFromChatEvent, handler_func)
return handler_func

def user_joined_to_chat(
self,
handler_func: HandlerFunc[JoinToChatEvent],
) -> HandlerFunc[JoinToChatEvent]:
"""Decorate `user_joined_to_chat` event handler."""
self._system_event(JoinToChatEvent, handler_func)
return handler_func

def internal_bot_notification(
self,
handler_func: HandlerFunc[InternalBotNotificationEvent],
Expand Down
6 changes: 6 additions & 0 deletions pybotx/models/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@
BotAPISmartAppEvent,
SmartAppEvent,
)
from pybotx.models.system_events.user_joined_to_chat import (
BotAPIJoinToChat,
JoinToChatEvent,
)

# Sorted by frequency of occurrence to speedup validation
BotAPISystemEvent = Union[
Expand All @@ -45,6 +49,7 @@
BotAPICTSLogin,
BotAPICTSLogout,
BotAPIEventEdit,
BotAPIJoinToChat,
]
BotAPICommand = Union[BotAPIIncomingMessage, BotAPISystemEvent]

Expand All @@ -60,5 +65,6 @@
CTSLoginEvent,
CTSLogoutEvent,
EventEdit,
JoinToChatEvent,
]
BotCommand = Union[IncomingMessage, SystemEvent]
1 change: 1 addition & 0 deletions pybotx/models/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ class BotAPISystemEventTypes(StrEnum):
LEFT_FROM_CHAT = "system:left_from_chat"
SMARTAPP_EVENT = "system:smartapp_event"
EVENT_EDIT = "system:event_edit"
JOIN_TO_CHAT = "system:user_joined_to_chat"


class BotAPIClientPlatforms(Enum):
Expand Down
94 changes: 94 additions & 0 deletions pybotx/models/system_events/user_joined_to_chat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
from dataclasses import dataclass
from typing import Any, Dict, List, Literal
from uuid import UUID

from pydantic import Field

from pybotx.models.api_base import VerifiedPayloadBaseModel
from pybotx.models.base_command import (
BotAPIBaseCommand,
BotAPIBaseSystemEventPayload,
BotAPIChatContext,
BotCommandBase,
)
from pybotx.models.bot_account import BotAccount
from pybotx.models.chats import Chat
from pybotx.models.enums import BotAPISystemEventTypes, convert_chat_type_to_domain


@dataclass
class JoinToChatEvent(BotCommandBase):
"""Domain model for user joined to chat event.

This model represents the domain entity for system:user_joined_to_chat events
after being converted from the API representation.

Attributes:
bot: The bot account that received the event.
raw_command: The original raw command dictionary.
huids: List of UUIDs of users who joined the chat.
chat: The chat that users joined.
"""

huids: List[UUID]
chat: Chat


class BotAPIJoinToChatData(VerifiedPayloadBaseModel):
"""Data model for user joined to chat event.

This model represents the data field in the BotX API payload
for system:user_joined_to_chat events.

Attributes:
added_members: List of UUIDs of users who joined the chat.
"""

added_members: List[UUID]


class BotAPIJoinToChatPayload(BotAPIBaseSystemEventPayload):
"""Payload model for user joined to chat event.

This model represents the command field in the BotX API request
for system:user_joined_to_chat events.

Attributes:
body: Literal value of BotAPISystemEventTypes.JOIN_TO_CHAT.
data: The data containing information about users who joined.
"""

body: Literal[BotAPISystemEventTypes.JOIN_TO_CHAT]
data: BotAPIJoinToChatData


class BotAPIJoinToChat(BotAPIBaseCommand):
"""API model for user joined to chat event.

This model represents the complete BotX API request structure
for system:user_joined_to_chat events.

Attributes:
payload: The command payload with event data.
sender: The chat context information.
"""

payload: BotAPIJoinToChatPayload = Field(..., alias="command")
sender: BotAPIChatContext = Field(..., alias="from")

def to_domain(self, raw_command: Dict[str, Any]) -> JoinToChatEvent:
return JoinToChatEvent(
bot=BotAccount(
id=self.bot_id,
host=self.sender.host,
),
raw_command=raw_command,
huids=self.payload.data.added_members,
chat=Chat(
id=self.sender.group_chat_id,
type=convert_chat_type_to_domain(self.sender.chat_type),
),
)

class Config:
allow_population_by_field_name = True
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "pybotx"
version = "0.73.4"
version = "0.73.5"
description = "A python library for interacting with eXpress BotX API"
authors = [
"Sidnev Nikolay <nsidnev@ccsteam.ru>",
Expand Down Expand Up @@ -46,6 +46,7 @@ respx = "0.20.2"
fastapi = "0.95.2"
starlette = "0.27.0" # TODO: Drop dependency after updating end-to-end test
uvicorn = "0.16.0"
factory-boy = "3.3.1"

[build-system]
requires = ["poetry>=0.12"]
Expand Down
2 changes: 1 addition & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ show-source = true

max-line-length = 88
inline-quotes = double
nested_classes_whitelist = Config
nested_classes_whitelist = Config, Meta
allowed_domain_names = data, handler, result, content, file

per-file-ignores =
Expand Down
63 changes: 63 additions & 0 deletions tests/system_events/factories.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import uuid
from typing import Any, Dict, List, Optional

from factory.base import DictFactory # type: ignore
from factory.declarations import SubFactory # type: ignore


class DeviceMetaFactory(DictFactory): # type: ignore[misc]

permissions: Optional[str] = None
pushes: Optional[str] = None
timezone: Optional[str] = None


class FromFactory(DictFactory): # type: ignore[misc]
user_huid: Optional[str] = None
group_chat_id: str = "8dada2c8-67a6-4434-9dec-570d244e78ee"
ad_login: Optional[str] = None
ad_domain: Optional[str] = None
username: Optional[str] = None
chat_type: str = "group_chat"
manufacturer: Optional[str] = None
device: Optional[str] = None
device_software: Optional[str] = None
device_meta: Dict[str, Any] = SubFactory(DeviceMetaFactory) # noqa: F821
platform: Optional[str] = None
platform_package_id: Optional[str] = None
is_admin: Optional[bool] = None
is_creator: Optional[bool] = None
app_version: Optional[str] = None
locale: str = "en"
host: str = "cts.ccteam.ru"


class CommandDataFactory(DictFactory): # type: ignore[misc]

added_members: List[str] = [uuid.uuid4().hex, uuid.uuid4().hex]


class CommandFactory(DictFactory): # type: ignore[misc]

body: str = "system:user_joined_to_chat"
command_type: str = "system"
data: Dict[str, Any] = SubFactory(CommandDataFactory) # noqa: F821
metadata: Dict[str, Any] = {}


class BotAPIJoinToChatFactory(DictFactory): # type: ignore[misc]

sync_id: str = uuid.uuid4().hex
command: Dict[str, Any] = SubFactory(CommandFactory) # noqa: F821
async_files: List[str] = []
attachments: List[str] = []
entities: List[str] = []
from_: Dict[str, Any] = SubFactory(
FromFactory,
) # noqa: F821
bot_id: str = uuid.uuid4().hex
proto_version: int = 4
source_sync_id: Optional[str] = None

class Meta:
rename = {"from_": "from"}
68 changes: 68 additions & 0 deletions tests/system_events/test_join_to_chat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
from typing import Any, Optional
from uuid import UUID

import pytest

from pybotx import (
Bot,
BotAccount,
BotAccountWithSecret,
Chat,
ChatTypes,
HandlerCollector,
lifespan_wrapper,
)
from pybotx.models.system_events.user_joined_to_chat import JoinToChatEvent
from tests.system_events.factories import BotAPIJoinToChatFactory # type: ignore

pytestmark = [
pytest.mark.asyncio,
pytest.mark.mock_authorization,
pytest.mark.usefixtures("respx_mock"),
]


async def test__join_to_chat__succeed(
bot_account: BotAccountWithSecret,
) -> None:
"""Verifies user joining chat message processing.

The test checks that:
1. The system:user_joined_to_chat event is properly routed
2. The event data is correctly converted to a UserJoinedToChatEvent object
3. The registered user_joined_to_chat handler is called with this event
"""

payload: dict[str, Any] = BotAPIJoinToChatFactory(bot_id=bot_account.id.hex)

collector = HandlerCollector()
join_to_chat: Optional[JoinToChatEvent] = None

@collector.user_joined_to_chat
async def join_to_chat_handler(event: JoinToChatEvent, bot: Bot) -> None:
nonlocal join_to_chat
join_to_chat = event
# Drop `raw_command` from asserting
join_to_chat.raw_command = None

built_bot = Bot(collectors=[collector], bot_accounts=[bot_account])

# - Act -
async with lifespan_wrapper(built_bot) as bot:
bot.async_execute_raw_bot_command(payload, verify_request=False)

# - Assert -
expected_event = JoinToChatEvent(
bot=BotAccount(
id=bot_account.id,
host=payload["from"]["host"],
),
raw_command=None,
huids=list(map(UUID, payload["command"]["data"]["added_members"])),
chat=Chat(
id=UUID(payload["from"]["group_chat_id"]),
type=ChatTypes.GROUP_CHAT,
),
)

assert join_to_chat == expected_event
Loading