yuchatlib 0.2.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
yuchatlib/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Асинхронная библиотека для ботов YuChat."""
2
+
3
+ from .bot import Bot
4
+ from .dispatcher import Dispatcher
5
+ from .router import Router
6
+
7
+ __all__ = ["Bot", "Dispatcher", "Router"]
@@ -0,0 +1,23 @@
1
+ """Интеграция с публичным API YuChat."""
2
+
3
+ from .dto.message.button import ButtonDto
4
+ from .dto.message.button_bar import ButtonBarDto
5
+ from .dto.message.button_group import ButtonGroupDto
6
+ from .dto.message.command_button import CommandButtonDto
7
+ from .dto.message.get_messages_response import GetMessagesResponseDto
8
+ from .dto.update.message import MessageDto
9
+ from .dto.update.message_action import MessageActionDto
10
+ from .dto.update.update import UpdateDto
11
+ from .dto.webhook_info import WebhookInfoDto
12
+
13
+ __all__ = [
14
+ "ButtonDto",
15
+ "ButtonBarDto",
16
+ "ButtonGroupDto",
17
+ "CommandButtonDto",
18
+ "GetMessagesResponseDto",
19
+ "MessageDto",
20
+ "MessageActionDto",
21
+ "UpdateDto",
22
+ "WebhookInfoDto",
23
+ ]
@@ -0,0 +1,59 @@
1
+ """Внутренний HTTP-транспорт публичного API YuChat."""
2
+
3
+ from typing import Any
4
+
5
+ import httpx
6
+
7
+ from yuchatlib.exceptions import ApiError, AuthenticationError, InvalidResponseError, NetworkError, RateLimitError
8
+
9
+
10
+ class _YuChatHttpTransport:
11
+ """Отправляет запросы и преобразует ответы без раскрытия данных."""
12
+
13
+ def __init__(self, client: httpx.AsyncClient, base_url: str, token: str) -> None:
14
+ self._client = client
15
+ self._base_url = base_url
16
+ self._token = token
17
+
18
+ async def post(self, method: str, body: dict[str, Any] | None = None) -> Any:
19
+ try:
20
+ response = await self._client.post(
21
+ f"{self._base_url}/public/v2/{method}",
22
+ headers={"Authorization": f"Bearer {self._token}"},
23
+ json=body,
24
+ )
25
+ except httpx.RequestError:
26
+ pass
27
+ else:
28
+ return self.parse_response(response)
29
+ raise NetworkError("Ошибка соединения с YuChat")
30
+
31
+ async def request(self, http_method: str, method: str) -> httpx.Response:
32
+ try:
33
+ response = await self._client.request(
34
+ http_method,
35
+ f"{self._base_url}/public/v2/{method}",
36
+ headers={"Authorization": f"Bearer {self._token}"},
37
+ )
38
+ except httpx.RequestError:
39
+ pass
40
+ else:
41
+ return response
42
+ raise NetworkError("Ошибка соединения с YuChat")
43
+
44
+ @staticmethod
45
+ def parse_response(response: httpx.Response) -> Any:
46
+ """Возвращает JSON или преобразует ошибки API в исключения SDK."""
47
+ if response.status_code in (401, 403):
48
+ raise AuthenticationError(response.status_code)
49
+ if response.status_code == 429:
50
+ raise RateLimitError(429, response.headers.get("Retry-After"))
51
+ if not response.is_success:
52
+ raise ApiError(response.status_code)
53
+ if not response.content:
54
+ return None
55
+ try:
56
+ return response.json()
57
+ except ValueError:
58
+ pass
59
+ raise InvalidResponseError("Ответ YuChat не содержит корректный JSON")
@@ -0,0 +1 @@
1
+ """Контракты публичного API YuChat."""
@@ -0,0 +1 @@
1
+ """Модели сведений о боте."""
@@ -0,0 +1,19 @@
1
+ from typing import Any
2
+
3
+ from pydantic import BaseModel, ConfigDict, Field
4
+
5
+ from .profile import ProfileDto
6
+
7
+
8
+ class MeInfoDto(BaseModel):
9
+ """Сведения о боте и настройках обновлений."""
10
+
11
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
12
+ profile: ProfileDto = Field(description="Профиль бота.")
13
+ workspaces: list[str] = Field(description="Рабочие пространства бота.")
14
+ update_api_version: int = Field(alias="updateApiVersion", description="Версия API обновлений.")
15
+ update_settings: list[str] = Field(alias="updateSettings", description="Включённые типы обновлений.")
16
+ auto_accept_workspace_invites: bool = Field(
17
+ alias="autoAcceptWorkspaceInvites", description="Автоматическое принятие приглашений."
18
+ )
19
+ scope: dict[str, Any] = Field(description="Область действия бота.")
@@ -0,0 +1,13 @@
1
+ from datetime import datetime
2
+
3
+ from pydantic import BaseModel, ConfigDict, Field
4
+
5
+
6
+ class ProfileDto(BaseModel):
7
+ """Профиль аккаунта бота."""
8
+
9
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
10
+ account_id: str = Field(alias="accountId", description="Идентификатор аккаунта.")
11
+ created_at: datetime = Field(alias="createdAt", description="Время создания аккаунта.")
12
+ updated_at: datetime = Field(alias="updatedAt", description="Время изменения аккаунта.")
13
+ account_type: str = Field(alias="accountType", description="Тип аккаунта.")
@@ -0,0 +1 @@
1
+ """Модели операций с сообщениями."""
@@ -0,0 +1,13 @@
1
+ """Контракт кнопки сообщения."""
2
+
3
+ from pydantic import BaseModel, ConfigDict, Field
4
+
5
+ from .command_button import CommandButtonDto
6
+
7
+
8
+ class ButtonDto(BaseModel):
9
+ """Командная кнопка сообщения."""
10
+
11
+ model_config = ConfigDict(populate_by_name=True)
12
+
13
+ command_button: CommandButtonDto = Field(alias="commandButton", description="Командная кнопка.")
@@ -0,0 +1,13 @@
1
+ """Контракт панели кнопок."""
2
+
3
+ from pydantic import BaseModel, ConfigDict, Field
4
+
5
+ from .button_group import ButtonGroupDto
6
+
7
+
8
+ class ButtonBarDto(BaseModel):
9
+ """Панель кнопок сообщения."""
10
+
11
+ model_config = ConfigDict(populate_by_name=True)
12
+
13
+ button_groups: list[ButtonGroupDto] = Field(alias="buttonGroups", description="Ряды кнопок.")
@@ -0,0 +1,11 @@
1
+ """Контракт группы кнопок."""
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+ from .button import ButtonDto
6
+
7
+
8
+ class ButtonGroupDto(BaseModel):
9
+ """Ряд кнопок сообщения."""
10
+
11
+ buttons: list[ButtonDto] = Field(description="Кнопки в ряду.")
@@ -0,0 +1,12 @@
1
+ """Контракт командной кнопки."""
2
+
3
+ from pydantic import BaseModel, ConfigDict, Field
4
+
5
+
6
+ class CommandButtonDto(BaseModel):
7
+ """Команда и отображаемый текст кнопки."""
8
+
9
+ model_config = ConfigDict(populate_by_name=True)
10
+
11
+ display_text: str = Field(alias="displayText", description="Текст кнопки.")
12
+ command_key: str = Field(alias="commandKey", description="Команда нажатой кнопки.")
@@ -0,0 +1,11 @@
1
+ from pydantic import BaseModel, ConfigDict, Field
2
+
3
+ from yuchatlib.api.dto.update.message import MessageDto
4
+
5
+
6
+ class GetMessagesResponseDto(BaseModel):
7
+ """Список сообщений, полученных из чата."""
8
+
9
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
10
+
11
+ messages: list[MessageDto] = Field(description="Сообщения чата.")
@@ -0,0 +1,8 @@
1
+ from pydantic import BaseModel, ConfigDict, Field
2
+
3
+
4
+ class SendMessageResponseDto(BaseModel):
5
+ """Результат отправки сообщения."""
6
+
7
+ model_config = ConfigDict(populate_by_name=True, extra="allow")
8
+ message_id: str = Field(alias="messageId", description="Идентификатор отправленного сообщения.")
@@ -0,0 +1 @@
1
+ """Модели получаемых обновлений."""
@@ -0,0 +1,20 @@
1
+ from datetime import datetime
2
+ from typing import Literal
3
+
4
+ from pydantic import BaseModel, ConfigDict, Field
5
+
6
+ from .message_content import MessageContentDto
7
+
8
+
9
+ class MessageDto(BaseModel):
10
+ """Сообщение в чате YuChat."""
11
+
12
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
13
+ workspace_id: str = Field(alias="workspaceId", description="Идентификатор рабочего пространства.")
14
+ chat_id: str = Field(alias="chatId", description="Идентификатор чата.")
15
+ message_id: str = Field(alias="messageId", description="Идентификатор сообщения.")
16
+ message_type: Literal["USER", "SYSTEM"] = Field(alias="messageType", description="Тип сообщения.")
17
+ membership_id: str = Field(alias="membershipId", description="Идентификатор автора в рабочем пространстве.")
18
+ created_at: datetime = Field(alias="createdAt", description="Время создания сообщения.")
19
+ updated_at: datetime = Field(alias="updatedAt", description="Время изменения сообщения.")
20
+ content: MessageContentDto = Field(description="Содержимое сообщения.")
@@ -0,0 +1,15 @@
1
+ from pydantic import BaseModel, ConfigDict, Field
2
+
3
+ from .pressed_button_command import PressedButtonCommandDto
4
+
5
+
6
+ class MessageActionDto(BaseModel):
7
+ """Действие с кнопкой сообщения."""
8
+
9
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
10
+ workspace_id: str = Field(alias="workspaceId", description="Идентификатор рабочего пространства.")
11
+ chat_id: str = Field(alias="chatId", description="Идентификатор чата.")
12
+ message_id: str = Field(alias="messageId", description="Идентификатор сообщения.")
13
+ pressed_button_command: PressedButtonCommandDto | None = Field(
14
+ default=None, alias="pressedButtonCommand", description="Команда нажатой кнопки, если передана."
15
+ )
@@ -0,0 +1,11 @@
1
+ from pydantic import BaseModel, ConfigDict, Field
2
+
3
+
4
+ class MessageContentDto(BaseModel):
5
+ """Содержимое сообщения YuChat."""
6
+
7
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
8
+ text: str | None = Field(default=None, description="Текст сообщения в формате Markdown.")
9
+ file_ids: list[str] | None = Field(
10
+ default=None, alias="fileIds", description="Идентификаторы прикреплённых файлов."
11
+ )
@@ -0,0 +1,9 @@
1
+ from pydantic import BaseModel, ConfigDict, Field
2
+
3
+
4
+ class PressedButtonCommandDto(BaseModel):
5
+ """Команда нажатой кнопки."""
6
+
7
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
8
+ display_text: str = Field(alias="displayText", description="Текст кнопки.")
9
+ command_key: str = Field(alias="commandKey", description="Команда нажатой кнопки.")
@@ -0,0 +1,21 @@
1
+ from typing import Any
2
+
3
+ from pydantic import BaseModel, ConfigDict, Field
4
+
5
+ from .message import MessageDto
6
+ from .message_action import MessageActionDto
7
+
8
+
9
+ class UpdateDto(BaseModel):
10
+ """Обновление, полученное ботом."""
11
+
12
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
13
+ update_id: int | None = Field(default=None, alias="updateId", description="Идентификатор обновления.")
14
+ message: MessageDto | None = Field(default=None, description="Новое сообщение.")
15
+ message_action: MessageActionDto | None = Field(
16
+ default=None, alias="messageAction", description="Действие с сообщением."
17
+ )
18
+ notification: dict[str, Any] | None = Field(default=None, description="Уведомление.")
19
+ workspace_invite: dict[str, Any] | None = Field(
20
+ default=None, alias="workspaceInvite", description="Приглашение в рабочее пространство."
21
+ )
@@ -0,0 +1,9 @@
1
+ from pydantic import BaseModel, Field
2
+
3
+ from .update import UpdateDto
4
+
5
+
6
+ class UpdatesResponseDto(BaseModel):
7
+ """Список полученных обновлений."""
8
+
9
+ updates: list[UpdateDto] = Field(description="Полученные обновления.")
@@ -0,0 +1,19 @@
1
+ from datetime import datetime
2
+
3
+ from pydantic import BaseModel, ConfigDict, Field
4
+
5
+
6
+ class WebhookInfoDto(BaseModel):
7
+ """Сведения о зарегистрированном webhook."""
8
+
9
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
10
+
11
+ url: str = Field(description="Адрес webhook.")
12
+ has_custom_certificate: bool = Field(alias="hasCustomCertificate", description="Признак собственного сертификата.")
13
+ pending_update_count: int | None = Field(
14
+ default=None, alias="pendingUpdateCount", description="Число ожидающих событий."
15
+ )
16
+ last_error_date: datetime | None = Field(default=None, alias="lastErrorDate", description="Время последней ошибки.")
17
+ last_error_message: str | None = Field(
18
+ default=None, alias="lastErrorMessage", description="Описание последней ошибки."
19
+ )
yuchatlib/bot.py ADDED
@@ -0,0 +1,220 @@
1
+ """Клиент публичного API YuChat."""
2
+
3
+ from typing import Any, Literal
4
+ from uuid import uuid4
5
+
6
+ import httpx
7
+ from pydantic import ValidationError
8
+
9
+ from yuchatlib.api._transport import _YuChatHttpTransport
10
+ from yuchatlib.api.dto.me.me_info import MeInfoDto
11
+ from yuchatlib.api.dto.message.button_bar import ButtonBarDto
12
+ from yuchatlib.api.dto.message.get_messages_response import GetMessagesResponseDto
13
+ from yuchatlib.api.dto.message.send_message_response import SendMessageResponseDto
14
+ from yuchatlib.api.dto.update.message import MessageDto
15
+ from yuchatlib.api.dto.update.updates_response import UpdatesResponseDto
16
+ from yuchatlib.api.dto.webhook_info import WebhookInfoDto
17
+ from yuchatlib.exceptions import InvalidResponseError
18
+
19
+
20
+ class Bot:
21
+ """Управляет запросами к API и собственной HTTP-сессией."""
22
+
23
+ def __init__(
24
+ self,
25
+ token: str,
26
+ *,
27
+ base_url: str,
28
+ client: httpx.AsyncClient | None = None,
29
+ timeout: float = 30.0,
30
+ fsm_namespace: str | None = None,
31
+ ) -> None:
32
+ if not token:
33
+ raise ValueError("Токен не должен быть пустым")
34
+ if not base_url or not base_url.strip():
35
+ raise ValueError("Адрес API не должен быть пустым")
36
+ if fsm_namespace is not None and (not isinstance(fsm_namespace, str) or not fsm_namespace.strip()):
37
+ raise ValueError("Namespace FSM должен быть непустой строкой")
38
+ if fsm_namespace == token:
39
+ raise ValueError("Токен нельзя использовать как namespace FSM")
40
+ self._token = token
41
+ self._fsm_id = fsm_namespace if fsm_namespace is not None else uuid4().hex
42
+ self._fsm_stable = fsm_namespace is not None
43
+ self._base_url = base_url.rstrip("/")
44
+ self._owns_client = client is None
45
+ self._client = client or httpx.AsyncClient(timeout=timeout)
46
+ self._transport = _YuChatHttpTransport(self._client, self._base_url, self._token)
47
+
48
+ async def __aenter__(self) -> "Bot":
49
+ return self
50
+
51
+ async def __aexit__(self, *args: object) -> None:
52
+ await self.close()
53
+
54
+ async def close(self) -> None:
55
+ """Закрывает HTTP-клиент, созданный библиотекой."""
56
+ if self._owns_client:
57
+ await self._client.aclose()
58
+
59
+ async def _post(self, method: str, body: dict[str, Any] | None = None) -> Any:
60
+ return await self._transport.post(method, body)
61
+
62
+ async def _webhook_request(self, http_method: str, method: str) -> httpx.Response:
63
+ return await self._transport.request(http_method, method)
64
+
65
+ @staticmethod
66
+ def _parse_response(response: httpx.Response) -> Any:
67
+ return _YuChatHttpTransport.parse_response(response)
68
+
69
+ async def get_updates(self, *, offset: int | None = None, limit: int = 20) -> UpdatesResponseDto:
70
+ """Получает пакет обновлений."""
71
+ if not 1 <= limit <= 1000:
72
+ raise ValueError("limit должен находиться в диапазоне от 1 до 1000")
73
+ body = {"limit": limit}
74
+ if offset is not None:
75
+ body["offset"] = offset
76
+ payload = await self._post("getUpdates", body)
77
+ try:
78
+ return UpdatesResponseDto.model_validate(payload)
79
+ except ValidationError:
80
+ pass
81
+ raise InvalidResponseError("Некорректный ответ getUpdates")
82
+
83
+ async def get_me(self) -> MeInfoDto:
84
+ """Получает сведения о боте."""
85
+ payload = await self._post("getMe")
86
+ try:
87
+ return MeInfoDto.model_validate(payload)
88
+ except ValidationError:
89
+ pass
90
+ raise InvalidResponseError("Некорректный ответ getMe")
91
+
92
+ async def set_update_settings(
93
+ self,
94
+ update_settings: list[Literal["MESSAGE", "MESSAGE_ACTION", "NOTIFICATION", "WORKSPACE_INVITE"]],
95
+ *,
96
+ update_api_version: Literal[1, 2] = 2,
97
+ auto_accept_workspace_invites: bool | None = None,
98
+ ) -> None:
99
+ """Явно изменяет настройки обновлений бота."""
100
+ body: dict[str, Any] = {"updateSettings": update_settings, "updateApiVersion": update_api_version}
101
+ if auto_accept_workspace_invites is not None:
102
+ body["autoAcceptWorkspaceInvites"] = auto_accept_workspace_invites
103
+ await self._post("setUpdateSettings", body)
104
+
105
+ async def send_message(
106
+ self, *, workspace_id: str, chat_id: str, text: str, button_bar: ButtonBarDto | None = None
107
+ ) -> SendMessageResponseDto:
108
+ """Отправляет текстовое сообщение с необязательными кнопками."""
109
+ body: dict[str, Any] = {"workspaceId": workspace_id, "chatId": chat_id, "text": text}
110
+ if button_bar is not None:
111
+ body["buttonBar"] = button_bar.model_dump(by_alias=True, mode="json", exclude_none=True)
112
+ payload = await self._post("sendMessage", body)
113
+ try:
114
+ return SendMessageResponseDto.model_validate(payload)
115
+ except ValidationError:
116
+ pass
117
+ raise InvalidResponseError("Некорректный ответ sendMessage")
118
+
119
+ async def get_messages(
120
+ self,
121
+ *,
122
+ workspace_id: str,
123
+ chat_id: str,
124
+ page_size: int | None = None,
125
+ anchor_message_id: str | None = None,
126
+ get_before: bool | None = None,
127
+ ) -> GetMessagesResponseDto:
128
+ """Получает сообщения чата с необязательной пагинацией."""
129
+ if page_size is not None and not 1 <= page_size <= 100:
130
+ raise ValueError("page_size должен находиться в диапазоне от 1 до 100")
131
+ body: dict[str, Any] = {"workspaceId": workspace_id, "chatId": chat_id}
132
+ if page_size is not None:
133
+ body["pageSize"] = page_size
134
+ if anchor_message_id is not None:
135
+ body["anchorMessageId"] = anchor_message_id
136
+ if get_before is not None:
137
+ body["getBefore"] = get_before
138
+ payload = await self._post("getMessages", body)
139
+ try:
140
+ return GetMessagesResponseDto.model_validate(payload)
141
+ except ValidationError:
142
+ pass
143
+ raise InvalidResponseError("Некорректный ответ getMessages")
144
+
145
+ async def get_message_by_id(self, *, workspace_id: str, chat_id: str, message_id: str) -> MessageDto:
146
+ """Получает сообщение по идентификатору."""
147
+ payload = await self._post(
148
+ "getMessageById",
149
+ {"workspaceId": workspace_id, "chatId": chat_id, "messageId": message_id},
150
+ )
151
+ try:
152
+ return MessageDto.model_validate(payload)
153
+ except ValidationError:
154
+ pass
155
+ raise InvalidResponseError("Некорректный ответ getMessageById")
156
+
157
+ async def edit_message(
158
+ self,
159
+ *,
160
+ workspace_id: str,
161
+ chat_id: str,
162
+ message_id: str,
163
+ text: str | None = None,
164
+ file_ids: list[str] | None = None,
165
+ button_bar: ButtonBarDto | None = None,
166
+ ) -> None:
167
+ """Редактирует сообщение в чате."""
168
+ body: dict[str, Any] = {"workspaceId": workspace_id, "chatId": chat_id, "messageId": message_id}
169
+ if text is not None:
170
+ body["text"] = text
171
+ if file_ids is not None:
172
+ body["fileIds"] = file_ids
173
+ if button_bar is not None:
174
+ body["buttonBar"] = button_bar.model_dump(by_alias=True, mode="json", exclude_none=True)
175
+ payload = await self._post("editMessage", body)
176
+ if payload is not None and not isinstance(payload, dict):
177
+ raise InvalidResponseError("Некорректный ответ editMessage")
178
+
179
+ async def set_webhook(self, *, url: str, secret_token: str | None = None, certificate: str | None = None) -> None:
180
+ """Явно регистрирует webhook бота."""
181
+ body = {"url": url}
182
+ if secret_token is not None:
183
+ body["secretToken"] = secret_token
184
+ if certificate is not None:
185
+ body["certificate"] = certificate
186
+ payload = await self._post("setWebhook", body)
187
+ if payload is not None and not isinstance(payload, dict):
188
+ raise InvalidResponseError("Некорректный ответ setWebhook")
189
+
190
+ async def delete_webhook(self) -> None:
191
+ """Явно удаляет регистрацию webhook бота."""
192
+ response = await self._webhook_request("DELETE", "deleteWebhook")
193
+ payload = self._parse_response(response)
194
+ if payload is not None and not isinstance(payload, dict):
195
+ raise InvalidResponseError("Некорректный ответ deleteWebhook")
196
+
197
+ async def get_webhook_info(self) -> WebhookInfoDto | None:
198
+ """Возвращает настройки webhook или None, если он не найден."""
199
+ response = await self._webhook_request("GET", "getWebhookInfo")
200
+ if response.status_code == 404:
201
+ return None
202
+ payload = self._parse_response(response)
203
+ try:
204
+ return WebhookInfoDto.model_validate(payload)
205
+ except ValidationError:
206
+ pass
207
+ raise InvalidResponseError("Некорректный ответ getWebhookInfo")
208
+
209
+
210
+ def _fsm_identity(bot: Bot) -> str:
211
+ """Возвращает внутренний идентификатор экземпляра для изоляции FSM."""
212
+ # Функция представляет внутренний контракт модуля для диспетчера.
213
+ # noinspection PyProtectedMember
214
+ return bot._fsm_id
215
+
216
+
217
+ def _has_stable_fsm_identity(bot: Bot) -> bool:
218
+ """Проверяет, задана ли постоянная идентичность для FSM."""
219
+ # noinspection PyProtectedMember
220
+ return bot._fsm_stable