python-trueconf-bot 1.0.0__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.
Files changed (120) hide show
  1. python_trueconf_bot-1.0.0.dist-info/METADATA +115 -0
  2. python_trueconf_bot-1.0.0.dist-info/RECORD +120 -0
  3. python_trueconf_bot-1.0.0.dist-info/WHEEL +5 -0
  4. python_trueconf_bot-1.0.0.dist-info/licenses/LICENSE +32 -0
  5. python_trueconf_bot-1.0.0.dist-info/top_level.txt +1 -0
  6. trueconf/__init__.py +18 -0
  7. trueconf/_version.py +34 -0
  8. trueconf/client/__init__.py +0 -0
  9. trueconf/client/bot.py +1269 -0
  10. trueconf/client/context_controller.py +27 -0
  11. trueconf/client/session.py +60 -0
  12. trueconf/dispatcher/__init__.py +0 -0
  13. trueconf/dispatcher/dispatcher.py +56 -0
  14. trueconf/dispatcher/router.py +207 -0
  15. trueconf/enums/__init__.py +23 -0
  16. trueconf/enums/aouth_error.py +15 -0
  17. trueconf/enums/chat_participant_role.py +18 -0
  18. trueconf/enums/chat_type.py +17 -0
  19. trueconf/enums/envelope_author_type.py +13 -0
  20. trueconf/enums/file_ready_state.py +14 -0
  21. trueconf/enums/incoming_update_method.py +14 -0
  22. trueconf/enums/message_type.py +27 -0
  23. trueconf/enums/parse_mode.py +14 -0
  24. trueconf/enums/survey_type.py +6 -0
  25. trueconf/enums/update_type.py +14 -0
  26. trueconf/exceptions.py +17 -0
  27. trueconf/filters/__init__.py +11 -0
  28. trueconf/filters/base.py +8 -0
  29. trueconf/filters/command.py +32 -0
  30. trueconf/filters/instance_of.py +11 -0
  31. trueconf/filters/message.py +15 -0
  32. trueconf/filters/method.py +12 -0
  33. trueconf/loggers.py +4 -0
  34. trueconf/methods/__init__.py +59 -0
  35. trueconf/methods/add_participant_to_chat.py +22 -0
  36. trueconf/methods/auth.py +25 -0
  37. trueconf/methods/base.py +107 -0
  38. trueconf/methods/create_channel.py +20 -0
  39. trueconf/methods/create_group_chat.py +20 -0
  40. trueconf/methods/create_p2p_chat.py +20 -0
  41. trueconf/methods/edit_message.py +25 -0
  42. trueconf/methods/edit_survey.py +32 -0
  43. trueconf/methods/forward_message.py +22 -0
  44. trueconf/methods/get_chat_by_id.py +20 -0
  45. trueconf/methods/get_chat_history.py +24 -0
  46. trueconf/methods/get_chat_participants.py +24 -0
  47. trueconf/methods/get_chats.py +22 -0
  48. trueconf/methods/get_file_info.py +20 -0
  49. trueconf/methods/get_message_by_id.py +19 -0
  50. trueconf/methods/get_user_display_name.py +20 -0
  51. trueconf/methods/has_chat_participant.py +22 -0
  52. trueconf/methods/remove_chat.py +20 -0
  53. trueconf/methods/remove_message.py +23 -0
  54. trueconf/methods/remove_participant_from_chat.py +23 -0
  55. trueconf/methods/send_file.py +25 -0
  56. trueconf/methods/send_message.py +29 -0
  57. trueconf/methods/send_survey.py +40 -0
  58. trueconf/methods/subscribe_file_progress.py +21 -0
  59. trueconf/methods/unsubscribe_file_progress.py +21 -0
  60. trueconf/methods/upload_file.py +21 -0
  61. trueconf/py.typed +0 -0
  62. trueconf/types/__init__.py +25 -0
  63. trueconf/types/author_box.py +17 -0
  64. trueconf/types/chat_participant.py +11 -0
  65. trueconf/types/content/__init__.py +25 -0
  66. trueconf/types/content/attachment.py +14 -0
  67. trueconf/types/content/base.py +8 -0
  68. trueconf/types/content/chat_created.py +9 -0
  69. trueconf/types/content/document.py +71 -0
  70. trueconf/types/content/forward_message.py +21 -0
  71. trueconf/types/content/photo.py +70 -0
  72. trueconf/types/content/remove_participant.py +9 -0
  73. trueconf/types/content/sticker.py +70 -0
  74. trueconf/types/content/survey.py +19 -0
  75. trueconf/types/content/text.py +9 -0
  76. trueconf/types/content/video.py +71 -0
  77. trueconf/types/last_message.py +33 -0
  78. trueconf/types/message.py +411 -0
  79. trueconf/types/parser.py +90 -0
  80. trueconf/types/requests/__init__.py +21 -0
  81. trueconf/types/requests/added_chat_participant.py +40 -0
  82. trueconf/types/requests/created_channel.py +42 -0
  83. trueconf/types/requests/created_group_chat.py +42 -0
  84. trueconf/types/requests/created_personal_chat.py +42 -0
  85. trueconf/types/requests/edited_message.py +37 -0
  86. trueconf/types/requests/removed_chat.py +32 -0
  87. trueconf/types/requests/removed_chat_participant.py +39 -0
  88. trueconf/types/requests/removed_message.py +37 -0
  89. trueconf/types/requests/uploading_progress.py +34 -0
  90. trueconf/types/responses/__init__.py +57 -0
  91. trueconf/types/responses/add_chat_participant_response.py +8 -0
  92. trueconf/types/responses/api_error.py +38 -0
  93. trueconf/types/responses/auth_response_payload.py +9 -0
  94. trueconf/types/responses/create_channel_response.py +8 -0
  95. trueconf/types/responses/create_group_chat_response.py +8 -0
  96. trueconf/types/responses/create_p2p_chat_response.py +8 -0
  97. trueconf/types/responses/edit_message_response.py +9 -0
  98. trueconf/types/responses/edit_survey_response.py +9 -0
  99. trueconf/types/responses/forward_message_response.py +10 -0
  100. trueconf/types/responses/get_chat_by_id_response.py +13 -0
  101. trueconf/types/responses/get_chat_history_response.py +13 -0
  102. trueconf/types/responses/get_chat_participants_response.py +12 -0
  103. trueconf/types/responses/get_chats_response.py +12 -0
  104. trueconf/types/responses/get_file_info_response.py +24 -0
  105. trueconf/types/responses/get_message_by_id_response.py +26 -0
  106. trueconf/types/responses/get_user_display_name_response.py +8 -0
  107. trueconf/types/responses/has_chat_participant_response.py +8 -0
  108. trueconf/types/responses/remove_chat_participant_response.py +8 -0
  109. trueconf/types/responses/remove_chat_response.py +8 -0
  110. trueconf/types/responses/remove_message_response.py +8 -0
  111. trueconf/types/responses/send_file_response.py +10 -0
  112. trueconf/types/responses/send_message_response.py +10 -0
  113. trueconf/types/responses/send_survey_response.py +10 -0
  114. trueconf/types/responses/subscribe_file_progress_response.py +8 -0
  115. trueconf/types/responses/unsubscribe_file_progress_response.py +8 -0
  116. trueconf/types/responses/upload_file_response.py +8 -0
  117. trueconf/types/update.py +12 -0
  118. trueconf/utils/__init__.py +3 -0
  119. trueconf/utils/generate_secret_for_survey.py +10 -0
  120. trueconf/utils/token.py +78 -0
trueconf/client/bot.py ADDED
@@ -0,0 +1,1269 @@
1
+ import aiofiles
2
+ import asyncio
3
+ import contextlib
4
+ import httpx
5
+ import json
6
+ import mimetypes
7
+ import signal
8
+ import ssl
9
+ import tempfile
10
+ import websockets
11
+ from pathlib import Path
12
+ from contextlib import AsyncExitStack
13
+ from typing import (
14
+ Callable,
15
+ Awaitable,
16
+ Dict,
17
+ List,
18
+ Tuple,
19
+ TypeVar,
20
+ Self,
21
+ TypedDict,
22
+ Unpack
23
+ )
24
+
25
+ from trueconf import loggers
26
+ from trueconf.client.session import WebSocketSession
27
+ from trueconf.dispatcher.dispatcher import Dispatcher
28
+ from trueconf.enums.file_ready_state import FileReadyState
29
+ from trueconf.enums.parse_mode import ParseMode
30
+ from trueconf.enums.survey_type import SurveyType
31
+ from trueconf.exceptions import ApiError
32
+ from trueconf.methods.add_participant_to_chat import AddChatParticipant
33
+ from trueconf.methods.auth import AuthMethod
34
+ from trueconf.methods.base import TrueConfMethod
35
+ from trueconf.methods.create_channel import CreateChannel
36
+ from trueconf.methods.create_group_chat import CreateGroupChat
37
+ from trueconf.methods.create_p2p_chat import CreateP2PChat
38
+ from trueconf.methods.edit_message import EditMessage
39
+ from trueconf.methods.edit_survey import EditSurvey
40
+ from trueconf.methods.forward_message import ForwardMessage
41
+ from trueconf.methods.get_chat_by_id import GetChatByID
42
+ from trueconf.methods.get_chat_history import GetChatHistory
43
+ from trueconf.methods.get_chat_participants import GetChatParticipants
44
+ from trueconf.methods.get_chats import GetChats
45
+ from trueconf.methods.get_file_info import GetFileInfo
46
+ from trueconf.methods.get_message_by_id import GetMessageById
47
+ from trueconf.methods.get_user_display_name import GetUserDisplayName
48
+ from trueconf.methods.has_chat_participant import HasChatParticipant
49
+ from trueconf.methods.remove_chat import RemoveChat
50
+ from trueconf.methods.remove_message import RemoveMessage
51
+ from trueconf.methods.remove_participant_from_chat import RemoveChatParticipant
52
+ from trueconf.methods.send_file import SendFile
53
+ from trueconf.methods.send_message import SendMessage
54
+ from trueconf.methods.send_survey import SendSurvey
55
+ from trueconf.methods.subscribe_file_progress import SubscribeFileProgress
56
+ from trueconf.methods.unsubscribe_file_progress import UnsubscribeFileProgress
57
+ from trueconf.methods.upload_file import UploadFile
58
+ from trueconf.types.parser import parse_update
59
+ from trueconf.types.requests.uploading_progress import UploadingProgress
60
+ from trueconf.types.responses.add_chat_participant_response import AddChatParticipantResponse
61
+ from trueconf.types.responses.api_error import ApiError
62
+ from trueconf.types.responses.create_channel_response import CreateChannelResponse
63
+ from trueconf.types.responses.create_group_chat_response import CreateGroupChatResponse
64
+ from trueconf.types.responses.create_p2p_chat_response import CreateP2PChatResponse
65
+ from trueconf.types.responses.edit_message_response import EditMessageResponse
66
+ from trueconf.types.responses.edit_survey_response import EditSurveyResponse
67
+ from trueconf.types.responses.forward_message_response import ForwardMessageResponse
68
+ from trueconf.types.responses.get_chat_by_id_response import GetChatByIdResponse
69
+ from trueconf.types.responses.get_chat_history_response import GetChatHistoryResponse
70
+ from trueconf.types.responses.get_chat_participants_response import GetChatParticipantsResponse
71
+ from trueconf.types.responses.get_chats_response import GetChatsResponse
72
+ from trueconf.types.responses.get_file_info_response import GetFileInfoResponse
73
+ from trueconf.types.responses.get_message_by_id_response import GetMessageByIdResponse
74
+ from trueconf.types.responses.get_user_display_name_response import GetUserDisplayNameResponse
75
+ from trueconf.types.responses.has_chat_participant_response import HasChatParticipantResponse
76
+ from trueconf.types.responses.remove_chat_participant_response import RemoveChatParticipantResponse
77
+ from trueconf.types.responses.remove_chat_response import RemoveChatResponse
78
+ from trueconf.types.responses.remove_message_response import RemoveMessageResponse
79
+ from trueconf.types.responses.send_file_response import SendFileResponse
80
+ from trueconf.types.responses.send_message_response import SendMessageResponse
81
+ from trueconf.types.responses.send_survey_response import SendSurveyResponse
82
+ from trueconf.types.responses.subscribe_file_progress_response import SubscribeFileProgressResponse
83
+ from trueconf.types.responses.unsubscribe_file_progress_response import UnsubscribeFileProgressResponse
84
+ from trueconf.utils import generate_secret_for_survey
85
+ from trueconf.utils import get_auth_token
86
+ from trueconf.utils import validate_token
87
+
88
+ T = TypeVar("T")
89
+
90
+
91
+ class TokenOpts(TypedDict, total=False):
92
+ web_port: int
93
+ https: bool
94
+
95
+
96
+ class Bot:
97
+ def __init__(
98
+ self,
99
+ server: str,
100
+ token: str,
101
+ web_port: int = 443,
102
+ https: bool = True,
103
+ debug: bool = False,
104
+ verify_ssl: bool = True,
105
+ dispatcher: Dispatcher | None = None,
106
+ receive_unread_messages: bool = False,
107
+ ):
108
+ """
109
+ Initializes a TrueConf chatbot instance with WebSocket connection and configuration options.
110
+
111
+ Source:
112
+ https://trueconf.com/docs/chatbot-connector/en/connect-and-auth/#websocket-connection-authorization
113
+
114
+ Args:
115
+ server (str): Address of the TrueConf server.
116
+ token (str): Bot authorization token.
117
+ web_port (int, optional): WebSocket connection port. Defaults to 443.
118
+ https (bool, optional): Whether to use HTTPS protocol. Defaults to True.
119
+ debug (bool, optional): Enables debug mode. Defaults to False.
120
+ verify_ssl (bool, optional): Whether to verify the server's SSL certificate. Defaults to True.
121
+ dispatcher (Dispatcher | None, optional): Dispatcher instance for registering handlers.
122
+ receive_unread_messages (bool, optional): Whether to receive unread messages on connection. Defaults to False.
123
+
124
+ Note:
125
+ Alternatively, you can authorize using a username and password via the `from_credentials()` class method.
126
+ """
127
+
128
+ validate_token(token)
129
+
130
+ self.server = server
131
+ self.__token = token
132
+ self.web_port = web_port
133
+ self.https = https
134
+ self.debug = debug
135
+ self.connected_event = asyncio.Event()
136
+ self.authorized_event = asyncio.Event()
137
+ self._session: WebSocketSession | None = None
138
+ self._connect_task: asyncio.Task | None = None
139
+ self.stopped_event = asyncio.Event()
140
+ self.dp = dispatcher or Dispatcher()
141
+ self._protocol = "https" if self.https else "http"
142
+ self.port = 443 if self.https else self.web_port
143
+ self.receive_unread_messages = receive_unread_messages
144
+ self._url_for_upload_files = (
145
+ f"{self._protocol}://{self.server}:{self.port}/bridge/api/client/v1/files"
146
+ )
147
+ self.verify_ssl = verify_ssl
148
+ self._progress_queues: Dict[str, asyncio.Queue] = {}
149
+
150
+ self._domain = None
151
+
152
+ self._futures: Dict[int, asyncio.Future] = {}
153
+ self._handlers: List[Tuple[dict, Callable[[dict], Awaitable]]] = []
154
+
155
+ self._stop = False
156
+ self._ws = None
157
+
158
+ async def __call__(self, method: TrueConfMethod[T]) -> T:
159
+ return await method(self)
160
+
161
+ def __get_domain_name(self):
162
+ url = f"{self._protocol}://{self.server}:{self.port}/api/v4/server"
163
+
164
+ try:
165
+ with httpx.Client(verify=False, timeout=5) as client:
166
+ response = client.get(url)
167
+ return response.json().get("product").get("display_name")
168
+ except Exception as e:
169
+ loggers.chatbot.error(f"Failed to get server domain_name: {e}")
170
+ return None
171
+
172
+ @property
173
+ def token(self) -> str:
174
+ """
175
+ Returns the bot's authorization token.
176
+
177
+ Returns:
178
+ str: The access token used for authentication.
179
+ """
180
+
181
+ return self.__token
182
+
183
+ @property
184
+ def server_name(self) -> str:
185
+ """
186
+ Returns the domain name of the TrueConf server.
187
+
188
+ Returns:
189
+ str: Domain name of the connected server.
190
+ """
191
+
192
+ if self._domain is None:
193
+ self._domain = self.__get_domain_name()
194
+ return self._domain
195
+
196
+ @classmethod
197
+ def from_credentials(
198
+ cls,
199
+ server: str,
200
+ username: str,
201
+ password: str,
202
+ dispatcher: Dispatcher | None = None,
203
+ receive_unread_messages: bool = False,
204
+ verify_ssl: bool = True,
205
+ **token_opts: Unpack[TokenOpts],
206
+ ) -> Self:
207
+ """
208
+ Creates a bot instance using username and password authentication.
209
+
210
+ Source:
211
+ https://trueconf.com/docs/chatbot-connector/en/getting-started/#authorization
212
+
213
+ Args:
214
+ server (str): Address of the TrueConf server.
215
+ username (str): Username for authentication.
216
+ password (str): Password for authentication.
217
+ dispatcher (Dispatcher | None, optional): Dispatcher instance for registering handlers.
218
+ receive_unread_messages (bool, optional): Whether to receive unread messages on connection. Defaults to False.
219
+ verify_ssl (bool, optional): Whether to verify the server's SSL certificate. Defaults to True.
220
+ **token_opts: Additional options passed to the token request, such as `web_port` and `https`.
221
+
222
+ Returns:
223
+ Bot: An authorized bot instance.
224
+
225
+ Raises:
226
+ RuntimeError: If the token could not be obtained.
227
+ """
228
+
229
+ token = get_auth_token(server, username, password, verify=verify_ssl)
230
+ if not token:
231
+ raise RuntimeError("Failed to obtain token")
232
+ return cls(
233
+ server,
234
+ token,
235
+ web_port=token_opts.get("web_port", 443),
236
+ https=token_opts.get("https", True),
237
+ dispatcher=dispatcher,
238
+ receive_unread_messages=receive_unread_messages,
239
+ verify_ssl=verify_ssl,
240
+ )
241
+
242
+ async def __wait_upload_complete(
243
+ self,
244
+ file_id: str,
245
+ expected_size: int,
246
+ timeout: float | None = None,
247
+ ) -> bool:
248
+ q = self._progress_queues.get(file_id)
249
+ if q is None:
250
+ q = asyncio.Queue()
251
+ self._progress_queues[file_id] = q
252
+
253
+ await self.subscribe_file_progress(file_id)
254
+ try:
255
+ while True:
256
+ if timeout is None:
257
+ update = await q.get()
258
+ else:
259
+ update = await asyncio.wait_for(q.get(), timeout=timeout)
260
+
261
+ if update.progress >= expected_size:
262
+ return True
263
+ except asyncio.TimeoutError:
264
+ return False
265
+ finally:
266
+ await self.unsubscribe_file_progress(file_id)
267
+ if self._progress_queues.get(file_id) is q:
268
+ self._progress_queues.pop(file_id, None)
269
+
270
+ async def __download_file_from_server(
271
+ self,
272
+ url: str,
273
+ file_name: str,
274
+ dest_path: str | Path | None = None,
275
+ verify: bool | None = None,
276
+ timeout: int = 60,
277
+ chunk_size: int = 64 * 1024,
278
+ ) -> Path | None:
279
+
280
+ """
281
+ Asynchronously download file by URL and save it to disk.
282
+
283
+ If `dest_path` isn't provided, a temporary file will be created (similar to aiogram.File.download()).
284
+
285
+ Args:
286
+ url: Direct download URL.
287
+ dest_path: Destination path; if None, a NamedTemporaryFile will be created and returned.
288
+ verify: SSL verification flag; defaults to self.verify_ssl.
289
+ timeout: Request timeout (seconds).
290
+ chunk_size: Stream chunk size in bytes.
291
+
292
+
293
+ Returns:
294
+ Path | None: Path to saved file, or None on error.
295
+ """
296
+
297
+ v = self.verify_ssl if verify is None else verify
298
+
299
+ if dest_path is None:
300
+ tmp = tempfile.NamedTemporaryFile(prefix="tc_dl_", suffix=file_name, delete=False)
301
+ dest = Path(tmp.name)
302
+ tmp.close()
303
+ else:
304
+ dest = Path(dest_path) / file_name
305
+ dest.parent.mkdir(parents=True, exist_ok=True)
306
+
307
+ try:
308
+ async with httpx.AsyncClient(verify=v, timeout=httpx.Timeout(timeout)) as client:
309
+ async with client.stream("GET", url) as resp:
310
+ resp.raise_for_status()
311
+ async with aiofiles.open(dest, "wb") as f:
312
+ async for chunk in resp.aiter_bytes(chunk_size):
313
+ if chunk:
314
+ await f.write(chunk)
315
+ return dest
316
+ except Exception as e:
317
+ loggers.chatbot.error(f"Failed to download file from {url}: {e}")
318
+ with contextlib.suppress(Exception):
319
+ if dest.exists():
320
+ dest.unlink()
321
+ return None
322
+
323
+ async def __upload_file_to_server(
324
+ self,
325
+ file_path: str,
326
+ preview_path: str | None = None,
327
+ preview_mimetype: str | None = None,
328
+ verify: bool = True,
329
+ timeout: int = 60,
330
+ ) -> str | None:
331
+ """
332
+ Uploads a file to the server and returns its temporary identifier (temporalFileId).
333
+
334
+ Source:
335
+ https://trueconf.com/docs/chatbot-connector/en/files/#uploading-a-file-to-the-file-storage
336
+
337
+ Args:
338
+ file_path (str): Path to the file to be uploaded.
339
+ preview_path (bytes | None, optional): Preview data in WebP format (if applicable).
340
+ preview_mimetype (str, optional): MIME type of the preview, e.g., "image/webp".
341
+ verify (bool, optional): Whether to verify the SSL certificate. Defaults to True.
342
+ timeout (int, optional): Upload timeout in seconds. Defaults to 60.
343
+
344
+ Returns:
345
+ str | None: Temporary file identifier (temporalFileId), or None if the upload failed.
346
+ """
347
+
348
+ file = Path(file_path)
349
+ file_size = file.stat().st_size
350
+ file_name = file.name
351
+ file_mimetype = mimetypes.guess_type(file_path)[0]
352
+
353
+ res = await self(UploadFile(file_size=file_size))
354
+ upload_task_id = res.upload_task_id
355
+
356
+ headers = {
357
+ "Upload-Task-Id": upload_task_id,
358
+ }
359
+
360
+ try:
361
+
362
+ async with AsyncExitStack() as stack:
363
+ client = await stack.enter_async_context(
364
+ httpx.AsyncClient(verify=verify, timeout=httpx.Timeout(timeout)))
365
+
366
+ f = stack.enter_context(open(file_path, "rb"))
367
+ files = {"file": (file_name, f, file_mimetype)}
368
+
369
+ if preview_path is not None:
370
+ p = stack.enter_context(open(preview_path, "rb"))
371
+ files["preview"] = (file_name, p, mimetypes.guess_type(preview_path)[0])
372
+
373
+ elif preview_mimetype == "sticker/webp":
374
+ files["preview"] = (file_name, f, preview_mimetype)
375
+
376
+ response = await client.post(self._url_for_upload_files, headers=headers, files=files)
377
+ return response.json().get("temporalFileId")
378
+ except Exception as e:
379
+ loggers.chatbot.error(f"Failed to upload file to server: {e}")
380
+ return None
381
+
382
+ async def _send_ws_payload(self, message: dict) -> bool:
383
+ if not self._session:
384
+ loggers.chatbot.warning("Session is None — not connected")
385
+ return False
386
+ try:
387
+ await self._session.send_json(message)
388
+ return True
389
+ except Exception as e:
390
+ loggers.chatbot.error(f"❌ Send failed or connection closed: {e}")
391
+ return False
392
+
393
+ async def __connect_and_listen(self):
394
+ ssl_context = ssl._create_unverified_context() if self.https else None
395
+ uri = f"wss://{self.server}:{self.web_port}/websocket/chat_bot"
396
+
397
+ try:
398
+ async for ws in websockets.connect(
399
+ uri, ssl=ssl_context, ping_interval=30, ping_timeout=10
400
+ ):
401
+ if self._stop:
402
+ break
403
+
404
+ self._ws = ws
405
+ loggers.chatbot.info("✅ WebSocket connected")
406
+
407
+ if self._session is None:
408
+ self._session = WebSocketSession(on_message=self.__on_raw_message)
409
+ self._session.attach(ws)
410
+
411
+ self.connected_event.set()
412
+ self.authorized_event.clear()
413
+
414
+ try:
415
+ await self.__authorize()
416
+ self.authorized_event.set()
417
+ try:
418
+ await ws.wait_closed()
419
+ except asyncio.CancelledError:
420
+ loggers.chatbot.info("🛑 Cancellation requested; closing ws")
421
+ with contextlib.suppress(Exception):
422
+ await ws.close()
423
+ raise
424
+
425
+ except websockets.exceptions.ConnectionClosed as e:
426
+ loggers.chatbot.warning(
427
+ f"🔌 Connection closed: {getattr(e, 'code', '?')} - {getattr(e, 'reason', '?')}"
428
+ )
429
+ continue
430
+ except asyncio.CancelledError:
431
+ loggers.chatbot.info("🛑 Connect loop cancelled")
432
+ raise
433
+ except ApiError as e:
434
+ print(e)
435
+ await self.shutdown()
436
+ finally:
437
+ self.connected_event.clear()
438
+ if self._session:
439
+ await self._session.detach()
440
+ if self._stop:
441
+ break
442
+ except asyncio.CancelledError:
443
+ loggers.chatbot.info("🛑 connect_and_listen task finished by cancellation")
444
+ raise
445
+
446
+ def _register_future(self, id_: int, future):
447
+ loggers.chatbot.debug(f"📬 Registered future for id={id_}")
448
+ self._futures[id_] = future
449
+
450
+ def __resolve_future(self, message: dict):
451
+ if message.get("type") == 2 and "id" in message:
452
+ future = self._futures.pop(message["id"], None)
453
+ if future and not future.done():
454
+ future.set_result(message)
455
+
456
+ async def __authorize(self):
457
+ loggers.chatbot.info("🚀 Starting authorization")
458
+
459
+ call = AuthMethod(
460
+ token=self.__token, receive_unread_messages=self.receive_unread_messages
461
+ )
462
+ loggers.chatbot.info(f"🛠 Created AuthMethod with id={call.id}")
463
+ result = await self(call)
464
+ loggers.chatbot.info(f"🔐 Authenticated as {result.user_id}")
465
+
466
+ async def __process_message(self, data: dict):
467
+ data = parse_update(data)
468
+ if data is None:
469
+ return
470
+
471
+ if isinstance(data, UploadingProgress):
472
+ q = self._progress_queues.get(data.file_id)
473
+ if q:
474
+ q.put_nowait(data)
475
+ return
476
+
477
+ if hasattr(data, "bind"):
478
+ data.bind(self)
479
+
480
+ payload = getattr(data, "payload", None)
481
+ if hasattr(payload, "bind"):
482
+ payload.bind(self)
483
+
484
+ await self.dp._feed_update(data)
485
+
486
+ async def __on_raw_message(self, raw: str):
487
+ try:
488
+ data = json.loads(raw)
489
+ except Exception as e:
490
+ loggers.chatbot.error(f"Failed to parse incoming message: {e}; raw={raw!r}")
491
+ return
492
+ # --- auto‑acknowledge every server request (type == 1) ---
493
+ if isinstance(data, dict) and data.get("type") == 1 and "id" in data:
494
+ # reply with {"type": 2, "id": <same id>}
495
+ asyncio.create_task(self._send_ws_payload({"type": 2, "id": data["id"]}))
496
+ self.__resolve_future(data)
497
+ asyncio.create_task(self.__process_message(data))
498
+
499
+ async def add_participant_to_chat(
500
+ self, chat_id: str, user_id: str
501
+ ) -> AddChatParticipantResponse:
502
+ """
503
+ Adds a participant to the specified chat.
504
+
505
+ Source:
506
+ https://trueconf.com/docs/chatbot-connector/en/chats/#adding-a-participant-to-the-chat
507
+
508
+ Args:
509
+ chat_id (str): Identifier of the chat to add the participant to.
510
+ user_id (str): Identifier of the user to be added.
511
+
512
+ Returns:
513
+ AddChatParticipantResponse: Object containing the result of the participant addition.
514
+ """
515
+
516
+ if "@" not in user_id:
517
+ user_id = f"{user_id}@{self.server_name}"
518
+
519
+ call = AddChatParticipant(chat_id=chat_id, user_id=user_id)
520
+ return await self(call)
521
+
522
+ async def create_channel(self, title: str) -> CreateChannelResponse:
523
+ """
524
+ Creates a new channel with the specified title.
525
+
526
+ Source:
527
+ https://trueconf.com/docs/chatbot-connector/en/chats/#creating-a-channel
528
+
529
+ Args:
530
+ title (str): Title of the new channel.
531
+
532
+ Returns:
533
+ CreateChannelResponse: Object containing the result of the channel creation.
534
+ """
535
+
536
+ loggers.chatbot.info(f"✉️ Create channel with name {title}")
537
+ call = CreateChannel(title=title)
538
+ return await self(call)
539
+
540
+ async def create_group_chat(self, title: str) -> CreateGroupChatResponse:
541
+ """
542
+ Creates a new group chat with the specified title.
543
+
544
+ Source:
545
+ https://trueconf.com/docs/chatbot-connector/en/chats/#creating-a-group-chat
546
+
547
+ Args:
548
+ title (str): Title of the new group chat.
549
+
550
+ Returns:
551
+ CreateGroupChatResponse: Object containing the result of the group chat creation.
552
+ """
553
+
554
+ loggers.chatbot.info(f"✉️ Create group chat with name {title}")
555
+ call = CreateGroupChat(title=title)
556
+ return await self(call)
557
+
558
+ async def create_personal_chat(self, user_id: str) -> CreateP2PChatResponse:
559
+ """
560
+ Creates a personal (P2P) chat with a user by their identifier.
561
+
562
+ Source:
563
+ https://trueconf.com/docs/chatbot-connector/en/chats/#creating-a-personal-chat-with-a-user
564
+
565
+ Args:
566
+ user_id (str): Identifier of the user. Can be with or without a domain.
567
+
568
+ Returns:
569
+ CreateP2PChatResponse: Object containing the result of the personal chat creation.
570
+
571
+ Note:
572
+ Creating a personal chat (peer-to-peer) with a server user.
573
+ If the bot has never messaged this user before, a new chat will be created.
574
+ If the bot has previously sent messages to this user, the existing chat will be returned.
575
+ """
576
+
577
+ loggers.chatbot.info(f"✉️ Create personal chat with name {user_id}")
578
+
579
+ if "@" not in user_id:
580
+ user_id = f"{user_id}@{self.server_name}"
581
+
582
+ call = CreateP2PChat(user_id=user_id)
583
+ return await self(call)
584
+
585
+ async def delete_chat(self, chat_id: str) -> RemoveChatResponse:
586
+ """
587
+ Deletes a chat by its identifier.
588
+
589
+ Source:
590
+ https://trueconf.com/docs/chatbot-connector/en/chats/#deleting-chat
591
+
592
+ Args:
593
+ chat_id: Identifier of the chat to be deleted.
594
+
595
+ Returns:
596
+ RemoveChatResponse: Object containing the result of the chat deletion.
597
+ """
598
+
599
+ call = RemoveChat(chat_id=chat_id)
600
+ return await self(call)
601
+
602
+ async def download_file_by_id(self, file_id, dest_path: str = None) -> Path | None:
603
+ """
604
+ Downloads a file by its ID, waiting for the upload to complete if necessary.
605
+
606
+ If the file is already in the READY state, it will be downloaded immediately.
607
+ If the file is in the NOT_AVAILABLE state, the method will exit without downloading.
608
+ In other cases, the bot will wait for the upload to finish and then attempt to download the file.
609
+
610
+ Args:
611
+ file_id (str): Unique identifier of the file on the server.
612
+ dest_path (str, optional): Path where the file should be saved.
613
+ If not specified, a temporary file will be created using `NamedTemporaryFile`
614
+ (with prefix `tc_dl_`, suffix set to the original file name, and `delete=False` to keep the file on disk).
615
+
616
+ Returns:
617
+ Path | None: Path to the downloaded file, or None if the download failed.
618
+ """
619
+
620
+ info = await self.get_file_info(file_id)
621
+
622
+ if info.ready_state == FileReadyState.READY:
623
+ return await self.__download_file_from_server(
624
+ url=info.download_url,
625
+ file_name=info.name,
626
+ dest_path=dest_path,
627
+ verify=self.verify_ssl
628
+ )
629
+
630
+ if info.ready_state == FileReadyState.NOT_AVAILABLE:
631
+ loggers.chatbot.warning(f"File {file_id} is NOT_AVAILABLE")
632
+ return None
633
+
634
+ ok = await self.__wait_upload_complete(file_id, expected_size=info.size, timeout=None)
635
+ if not ok:
636
+ loggers.chatbot.error(f"Wait upload complete failed for {file_id}")
637
+ return None
638
+
639
+ for _ in range(20):
640
+ info = await self.get_file_info(file_id)
641
+ if info.ready_state == FileReadyState.READY:
642
+ break
643
+ await asyncio.sleep(1)
644
+ else:
645
+ loggers.chatbot.warning(f"File {file_id} didn’t reach READY in time")
646
+ return None
647
+
648
+ return await self.__download_file_from_server(
649
+ url=info.download_url,
650
+ file_name=info.name,
651
+ dest_path=dest_path,
652
+ verify=self.verify_ssl
653
+ )
654
+
655
+ async def edit_message(
656
+ self, message_id: str, text: str, parse_mode: ParseMode | str = ParseMode.TEXT
657
+ ) -> EditMessageResponse:
658
+ """
659
+ Edits a previously sent message.
660
+
661
+ Source:
662
+ https://trueconf.com/docs/chatbot-connector/en/messages/#editing-an-existing-message
663
+
664
+ Args:
665
+ message_id (str): Identifier of the message to be edited.
666
+ text (str): New text content for the message.
667
+ parse_mode (ParseMode | str, optional): Text formatting mode.
668
+ Defaults to plain text.
669
+
670
+ Returns:
671
+ EditMessageResponse: Object containing the result of the message update.
672
+ """
673
+
674
+ call = EditMessage(message_id=message_id, text=text, parse_mode=parse_mode)
675
+ return await self(call)
676
+
677
+ async def edit_survey(
678
+ self,
679
+ message_id: str,
680
+ title: str,
681
+ survey_campaign_id: str,
682
+ survey_type: SurveyType = SurveyType.NON_ANONYMOUS,
683
+ ) -> EditSurveyResponse:
684
+ """
685
+ Edits a previously sent survey.
686
+
687
+ Source:
688
+ https://trueconf.com/docs/chatbot-connector/en/surveys/#editing-a-poll-message
689
+
690
+ Args:
691
+ message_id (str): Identifier of the message containing the survey to edit.
692
+ title (str): New title of the survey.
693
+ survey_campaign_id (str): Identifier of the survey campaign.
694
+ survey_type (SurveyType, optional): Type of the survey (anonymous or non-anonymous). Defaults to non-anonymous.
695
+
696
+ Returns:
697
+ EditSurveyResponse: Object containing the result of the survey update.
698
+ """
699
+
700
+ call = EditSurvey(
701
+ message_id=message_id,
702
+ server=self.server,
703
+ path=survey_campaign_id,
704
+ title=title,
705
+ description=survey_type,
706
+ )
707
+ return await self(call)
708
+
709
+ async def forward_message(
710
+ self, chat_id: str, message_id: str
711
+ ) -> ForwardMessageResponse:
712
+ """
713
+ Forwards a message to the specified chat.
714
+
715
+ Source:
716
+ https://trueconf.com/docs/chatbot-connector/en/messages/#forwarding-a-message-to-another-chat
717
+
718
+ Args:
719
+ chat_id (str): Identifier of the chat to forward the message to.
720
+ message_id (str): Identifier of the message to be forwarded.
721
+
722
+ Returns:
723
+ ForwardMessageResponse: Object containing the result of the message forwarding.
724
+ """
725
+
726
+ call = ForwardMessage(chat_id=chat_id, message_id=message_id)
727
+ return await self(call)
728
+
729
+ async def get_chats(
730
+ self, count: int = 10, page: int = 1
731
+ ) -> GetChatsResponse:
732
+ """
733
+ Retrieves a paginated list of chats available to the bot.
734
+
735
+ Source:
736
+ https://trueconf.com/docs/chatbot-connector/en/chats/#retrieving-the-list-of-chats
737
+
738
+ Args:
739
+ count (int, optional): Number of chats per page. Defaults to 10.
740
+ page (int, optional): Page number. Must be greater than 0. Defaults to 1.
741
+
742
+ Returns:
743
+ GetChatsResponse: Object containing the result of the chat list request.
744
+
745
+ Raises:
746
+ ValueError: If the page number is less than 1.
747
+ """
748
+
749
+ if page < 1:
750
+ raise ValueError("Argument <page> must be greater than 0")
751
+ loggers.chatbot.info(f"✉️ Get info all chats by ")
752
+ call = GetChats(count=count, page=page)
753
+ return await self(call)
754
+
755
+ async def get_chat_by_id(self, chat_id: str) -> GetChatByIdResponse:
756
+ """
757
+ Retrieves information about a chat by its identifier.
758
+
759
+ Source:
760
+ https://trueconf.com/docs/chatbot-connector/en/chats/#retrieving-chat-information-by-id
761
+
762
+ Args:
763
+ chat_id (str): Identifier of the chat.
764
+
765
+ Returns:
766
+ GetChatByIDResponse: Object containing information about the chat.
767
+ """
768
+
769
+ loggers.chatbot.info(f"✉️ Get info chat by {chat_id}")
770
+ call = GetChatByID(chat_id=chat_id)
771
+ return await self(call)
772
+
773
+ async def get_chat_participants(
774
+ self,
775
+ chat_id: str,
776
+ page_size: int,
777
+ page_number: int
778
+ ) -> GetChatParticipantsResponse:
779
+ """
780
+ Retrieves a paginated list of chat participants.
781
+
782
+ Source:
783
+ https://trueconf.com/docs/chatbot-connector/en/chats/#retrieving-the-list-of-chat-participants
784
+
785
+ Args:
786
+ chat_id (str): Identifier of the chat.
787
+ page_size (int): Number of participants per page.
788
+ page_number (int): Page number.
789
+
790
+ Returns:
791
+ GetChatParticipantsResponse: Object containing the result of the participant list request.
792
+ """
793
+
794
+ call = GetChatParticipants(
795
+ chat_id=chat_id, page_size=page_size, page_number=page_number
796
+ )
797
+ return await self(call)
798
+
799
+ async def get_chat_history(
800
+ self,
801
+ chat_id: str,
802
+ count: int,
803
+ from_message_id: str | None = None,
804
+ ) -> GetChatHistoryResponse:
805
+ """
806
+ Retrieves the message history of the specified chat.
807
+
808
+ Source:
809
+ https://trueconf.com/docs/chatbot-connector/en/messages/#retrieving-chat-history
810
+
811
+ Args:
812
+ chat_id (str): Identifier of the chat.
813
+ count (int): Number of messages to retrieve.
814
+ from_message_id (str | None, optional): Identifier of the message to start retrieving history from.
815
+ If not specified, the history will be loaded from the most recent message.
816
+
817
+ Returns:
818
+ GetChatHistoryResponse: Object containing the result of the chat history request.
819
+
820
+ Raises:
821
+ ValueError: If the count number is less than 1.
822
+ """
823
+
824
+ if count < 1:
825
+ raise ValueError("Argument <count> must be greater than 0")
826
+
827
+ call = GetChatHistory(
828
+ chat_id=chat_id, count=count, from_message_id=from_message_id
829
+ )
830
+ return await self(call)
831
+
832
+ async def get_file_info(self, file_id: str) -> GetFileInfoResponse:
833
+ """
834
+ Retrieves information about a file by its identifier.
835
+
836
+ Source:
837
+ https://trueconf.com/docs/chatbot-connector/en/files/#retrieving-file-information-and-downloading-the-file
838
+
839
+ Args:
840
+ file_id (str): Identifier of the file.
841
+
842
+ Returns:
843
+ GetFileInfoResponse: Object containing information about the file.
844
+ """
845
+
846
+ call = GetFileInfo(file_id=file_id)
847
+ return await self(call)
848
+
849
+ async def get_message_by_id(
850
+ self, message_id: str
851
+ ) -> GetMessageByIdResponse:
852
+ """
853
+ Retrieves a message by its identifier.
854
+
855
+ Source:
856
+ https://trueconf.com/docs/chatbot-connector/en/messages/#retrieving-a-message-by-its-id
857
+
858
+ Args:
859
+ message_id (str): Identifier of the message to retrieve.
860
+
861
+ Returns:
862
+ GetMessageByIdResponse: Object containing the retrieved message data.
863
+ """
864
+
865
+ call = GetMessageById(message_id=message_id)
866
+ return await self(call)
867
+
868
+ async def get_user_display_name(
869
+ self, user_id: str
870
+ ) -> GetUserDisplayNameResponse:
871
+ """
872
+ Retrieves the display name of a user by their TrueConf ID.
873
+
874
+ Source:
875
+ https://trueconf.com/docs/chatbot-connector/en/contacts/#retrieving-the-display-name-of-a-user-by-their-trueconf-id
876
+
877
+ Args:
878
+ user_id (str): User's TrueConf ID. Can be specified with or without a domain.
879
+
880
+ Returns:
881
+ GetUserDisplayNameResponse: Object containing the user's display name.
882
+ """
883
+
884
+ if "@" not in user_id:
885
+ user_id = f"{user_id}@{self.server_name}"
886
+
887
+ call = GetUserDisplayName(user_id=user_id)
888
+ return await self(call)
889
+
890
+ async def has_chat_participant(
891
+ self,
892
+ chat_id: str,
893
+ user_id: str
894
+ ) -> HasChatParticipantResponse:
895
+ """
896
+ Checks whether the specified user is a participant in the chat.
897
+
898
+ Source:
899
+ https://trueconf.com/docs/chatbot-connector/en/chats/#checking-participant-presence-in-chat
900
+
901
+ Args:
902
+ chat_id (str): Identifier of the chat.
903
+ user_id (str): Identifier of the user. Can be with or without a domain.
904
+
905
+ Returns:
906
+ HasChatParticipantResponse: Object containing the result of the check.
907
+ """
908
+
909
+ if "@" not in user_id:
910
+ user_id = f"{user_id}@{self.server_name}"
911
+
912
+ call = HasChatParticipant(chat_id=chat_id, user_id=user_id)
913
+ return await self(call)
914
+
915
+ async def remove_message(
916
+ self, message_id: str, for_all: bool = False
917
+ ) -> RemoveMessageResponse:
918
+ """
919
+ Removes a message by its identifier.
920
+
921
+ Source:
922
+ https://trueconf.com/docs/chatbot-connector/en/messages/#deleting-a-message
923
+
924
+ Args:
925
+ message_id (str): Identifier of the message to be removed.
926
+ for_all (bool, optional): If True, the message will be removed for all participants.
927
+ Default to False (the message is removed only for the bot).
928
+
929
+ Returns:
930
+ RemoveMessageResponse: Object containing the result of the message deletion.
931
+ """
932
+
933
+ call = RemoveMessage(message_id=message_id, for_all=for_all)
934
+ return await self(call)
935
+
936
+ async def remove_participant_from_chat(
937
+ self, chat_id: str, user_id: str
938
+ ) -> RemoveChatParticipantResponse:
939
+ """
940
+ Removes a participant from the specified chat.
941
+
942
+ Source:
943
+ https://trueconf.com/docs/chatbot-connector/en/chats/#removing-a-participant-from-the-chat
944
+
945
+ Args:
946
+ chat_id (str): Identifier of the chat to remove the participant from.
947
+ user_id (str): Identifier of the user to be removed.
948
+
949
+ Returns:
950
+ RemoveChatParticipantResponse: Object containing the result of the participant removal.
951
+ """
952
+
953
+ call = RemoveChatParticipant(chat_id=chat_id, user_id=user_id)
954
+ return await self(call)
955
+
956
+ async def reply_message(
957
+ self,
958
+ chat_id: str,
959
+ message_id: str,
960
+ text: str,
961
+ parse_mode: ParseMode | str = ParseMode.TEXT,
962
+ ) -> SendMessageResponse:
963
+ """
964
+ Sends a reply to an existing message in the chat.
965
+
966
+ Source:
967
+ https://trueconf.com/docs/chatbot-connector/en/messages/#reply-to-an-existing-message
968
+
969
+ Args:
970
+ chat_id (str): Identifier of the chat where the reply will be sent.
971
+ message_id (str): Identifier of the message to reply to.
972
+ text (str): Text content of the reply.
973
+ parse_mode (ParseMode | str, optional): Text formatting mode.
974
+ Defaults to plain text.
975
+
976
+ Returns:
977
+ SendMessageResponse: Object containing the result of the message delivery.
978
+ """
979
+
980
+ call = SendMessage(
981
+ chat_id=chat_id,
982
+ reply_message_id=message_id,
983
+ text=text,
984
+ parse_mode=parse_mode,
985
+ )
986
+ return await self(call)
987
+
988
+ async def run(self, handle_signals: bool = True) -> None:
989
+ """
990
+ Runs the bot and waits until it stops. Supports handling termination signals (SIGINT, SIGTERM).
991
+
992
+ Args:
993
+ handle_signals (bool, optional): Whether to handle termination signals. Defaults to True.
994
+
995
+ Returns:
996
+ None
997
+ """
998
+
999
+ if handle_signals:
1000
+ loop = asyncio.get_running_loop()
1001
+ try:
1002
+ loop.add_signal_handler(
1003
+ signal.SIGINT, lambda: asyncio.create_task(self.shutdown())
1004
+ )
1005
+ loop.add_signal_handler(
1006
+ signal.SIGTERM, lambda: asyncio.create_task(self.shutdown())
1007
+ )
1008
+ except NotImplementedError:
1009
+ pass
1010
+
1011
+ await self.start()
1012
+ await self.connected_event.wait()
1013
+ await self.authorized_event.wait()
1014
+ await self.stopped_event.wait()
1015
+
1016
+ async def send_document(self, chat_id: str, file_path: str) -> SendFileResponse:
1017
+ """
1018
+ Sends a document to the specified chat.
1019
+
1020
+ Files of any format are supported. A preview is automatically generated for the following file types:
1021
+ .jpg, .jpeg, .png, .webp, .bmp, .gif, .tiff, .pdf
1022
+
1023
+ Source:
1024
+ https://trueconf.com/docs/chatbot-connector/en/files/#file-transfer
1025
+
1026
+ Args:
1027
+ chat_id (str): Identifier of the chat to send the document to.
1028
+ file_path (str): Path to the document file.
1029
+
1030
+ Returns:
1031
+ SendFileResponse: Object containing the result of the file upload.
1032
+ """
1033
+
1034
+ loggers.chatbot.info(f"✉️ Sending file to {chat_id}")
1035
+
1036
+ temporal_file_id = await self.__upload_file_to_server(
1037
+ file_path=file_path,
1038
+ verify=self.verify_ssl,
1039
+ )
1040
+
1041
+ call = SendFile(chat_id=chat_id, temporal_file_id=temporal_file_id)
1042
+ return await self(call)
1043
+
1044
+ async def send_message(
1045
+ self,
1046
+ chat_id: str,
1047
+ text: str,
1048
+ parse_mode: ParseMode | str = ParseMode.TEXT
1049
+ ) -> SendMessageResponse:
1050
+ """
1051
+ Sends a message to the specified chat.
1052
+
1053
+ Source:
1054
+ https://trueconf.com/docs/chatbot-connector/en/messages/#sending-a-text-message-in-chat
1055
+
1056
+ Args:
1057
+ chat_id (str): Identifier of the chat to send the message to.
1058
+ text (str): Text content of the message.
1059
+ parse_mode (ParseMode | str, optional): Text formatting mode.
1060
+ Defaults to plain text.
1061
+
1062
+ Returns:
1063
+ SendMessageResponse: Object containing the result of the message delivery.
1064
+ """
1065
+
1066
+ loggers.chatbot.info(f"✉️ Sending message to {chat_id}")
1067
+ call = SendMessage(chat_id=chat_id, text=text, parse_mode=parse_mode)
1068
+ return await self(call)
1069
+
1070
+ async def send_photo(self, chat_id: str, file_path: str, preview_path: str | None) -> SendFileResponse:
1071
+ """
1072
+ Sends a photo to the specified chat with preview (optional).
1073
+
1074
+ Supported image formats: .jpg, .jpeg, .png, .webp, .bmp, .gif, .tiff
1075
+
1076
+ Source:
1077
+ https://trueconf.com/docs/chatbot-connector/en/files/#file-transfer
1078
+
1079
+ Args:
1080
+ chat_id (str): Identifier of the chat to send the photo to.
1081
+ file_path (str): Path to the image file.
1082
+ preview_path (str | None): Path to the preview image.
1083
+
1084
+ Returns:
1085
+ SendFileResponse: Object containing the result of the file upload.
1086
+
1087
+ Examples:
1088
+ >>> bot.send_photo(chat_id="a1s2d3f4f5g6", file_path="/path/to/image.jpg", preview_path="/path/to/preview.webp")
1089
+ """
1090
+
1091
+ loggers.chatbot.info(f"✉️ Sending photo to {chat_id}")
1092
+
1093
+ temporal_file_id = await self.__upload_file_to_server(
1094
+ file_path=file_path,
1095
+ preview_path=preview_path,
1096
+ verify=self.verify_ssl,
1097
+ )
1098
+
1099
+ call = SendFile(chat_id=chat_id, temporal_file_id=temporal_file_id)
1100
+ return await self(call)
1101
+
1102
+ async def send_sticker(
1103
+ self, chat_id: str, file_path: str
1104
+ ) -> SendFileResponse:
1105
+ """
1106
+ Sends a WebP-format sticker to the specified chat.
1107
+
1108
+ Source:
1109
+ https://trueconf.com/docs/chatbot-connector/en/files/#file-transfer
1110
+
1111
+ Args:
1112
+ chat_id (str): Identifier of the chat to send the sticker to.
1113
+ file_path (str): Path to the sticker file in WebP format.
1114
+
1115
+ Returns:
1116
+ SendFileResponse: Object containing the result of the file upload.
1117
+
1118
+ Raises:
1119
+ TypeError: If the file does not have the MIME type 'image/webp'.
1120
+ """
1121
+
1122
+ if mimetypes.guess_type(file_path)[0] != "image/webp":
1123
+ raise TypeError("File type not supported. File type must be 'image/webp'")
1124
+
1125
+ loggers.chatbot.info(f"✉️ Sending file to {chat_id}")
1126
+
1127
+ temporal_file_id = await self.__upload_file_to_server(
1128
+ file_path=file_path, preview_mimetype="sticker/webp", verify=self.verify_ssl
1129
+ )
1130
+
1131
+ call = SendFile(chat_id=chat_id, temporal_file_id=temporal_file_id)
1132
+ return await self(call)
1133
+
1134
+ async def send_survey(
1135
+ self,
1136
+ chat_id: str,
1137
+ title: str,
1138
+ survey_campaign_id: str,
1139
+ reply_message_id: str = None,
1140
+ survey_type: SurveyType = SurveyType.NON_ANONYMOUS,
1141
+ ) -> SendSurveyResponse:
1142
+ """
1143
+ Sends a survey to the specified chat.
1144
+
1145
+ Source:
1146
+ https://trueconf.com/docs/chatbot-connector/en/surveys/#sending-a-poll-message-in-chat
1147
+
1148
+ Args:
1149
+ chat_id (str): Identifier of the chat to send the survey to.
1150
+ title (str): Title of the survey displayed in the chat.
1151
+ survey_campaign_id (str): Identifier of the survey campaign.
1152
+ reply_message_id (str, optional): Identifier of the message being replied to.
1153
+ survey_type (SurveyType, optional): Type of the survey (anonymous or non-anonymous). Defaults to non-anonymous.
1154
+
1155
+ Returns:
1156
+ SendSurveyResponse: Object containing the result of the survey submission.
1157
+ """
1158
+
1159
+ secret = await generate_secret_for_survey(title=title)
1160
+
1161
+ call = SendSurvey(
1162
+ chat_id=chat_id,
1163
+ server=self.server,
1164
+ reply_message_id=reply_message_id,
1165
+ path=survey_campaign_id,
1166
+ title=title,
1167
+ description=survey_type,
1168
+ secret=secret,
1169
+ )
1170
+ return await self(call)
1171
+
1172
+ async def start(self) -> None:
1173
+ """
1174
+ Starts the bot by connecting to the server and listening for incoming events.
1175
+
1176
+ Note:
1177
+ This method is safe to call multiple times — subsequent calls are ignored
1178
+ if the connection is already active.
1179
+
1180
+ Returns:
1181
+ None
1182
+ """
1183
+
1184
+ if self._connect_task and not self._connect_task.done():
1185
+ return
1186
+ self._stop = False
1187
+ self._connect_task = asyncio.create_task(self.__connect_and_listen())
1188
+
1189
+ async def shutdown(self) -> None:
1190
+ """
1191
+ Gracefully shuts down the bot, cancels the connection task, and closes active sessions.
1192
+
1193
+ This method:
1194
+ - Cancels the connection task if it is still active;
1195
+ - Closes the WebSocket session or `self.session` if they are open;
1196
+ - Clears the connection and authorization events;
1197
+ - Sets the `stopped_event` flag.
1198
+
1199
+ Returns:
1200
+ None
1201
+ """
1202
+
1203
+ self._stop = True
1204
+ if self._connect_task and not self._connect_task.done():
1205
+ self._connect_task.cancel()
1206
+ with contextlib.suppress(asyncio.CancelledError):
1207
+ await self._connect_task
1208
+ self._connect_task = None
1209
+
1210
+ try:
1211
+ if self._session:
1212
+ with contextlib.suppress(Exception):
1213
+ await self._session.close()
1214
+ elif self._ws:
1215
+ with contextlib.suppress(Exception):
1216
+ await self._ws.close()
1217
+ finally:
1218
+ self._ws = None
1219
+ self.connected_event.clear()
1220
+ self.authorized_event.clear()
1221
+ loggers.chatbot.info("🛑 ChatBot stopped")
1222
+ self.stopped_event.set()
1223
+ # sys.exit()
1224
+
1225
+ async def subscribe_file_progress(
1226
+ self, file_id: str
1227
+ ) -> SubscribeFileProgressResponse:
1228
+ """
1229
+ Subscribes to file transfer progress updates.
1230
+
1231
+ Source:
1232
+ https://trueconf.com/docs/chatbot-connector/en/files/#subscription-to-file-upload-progress-on-the-server
1233
+
1234
+ Args:
1235
+ file_id (str): Identifier of the file.
1236
+
1237
+ Returns:
1238
+ SubscribeFileProgressResponse: Object containing the result of the subscription.
1239
+
1240
+ Note:
1241
+ If the file is in the UPLOADING status, you can subscribe to the upload process
1242
+ to be notified when the file becomes available.
1243
+ """
1244
+
1245
+ call = SubscribeFileProgress(file_id=file_id)
1246
+ return await self(call)
1247
+
1248
+ async def unsubscribe_file_progress(
1249
+ self, file_id: str
1250
+ ) -> UnsubscribeFileProgressResponse:
1251
+ """
1252
+ Unsubscribes from receiving file upload progress events.
1253
+
1254
+ Source:
1255
+ https://trueconf.com/docs/chatbot-connector/en/files/#unsubscribe-from-receiving-upload-event-notifications
1256
+
1257
+ Args:
1258
+ file_id (str): Identifier of the file.
1259
+
1260
+ Returns:
1261
+ UnsubscribeFileProgressResponse: Object containing the result of the unsubscription.
1262
+
1263
+ Note:
1264
+ If necessary, you can unsubscribe from file upload events that were previously subscribed to
1265
+ using the `subscribe_file_progress()` method.
1266
+ """
1267
+
1268
+ call = UnsubscribeFileProgress(file_id=file_id)
1269
+ return await self(call)