talkomatic.py 0.1.2
An easy-to-use Python versatile wrapper for the Talkomatic Bot interface ensuring best practices for making bots, and handling the REST API.
 
Loading...
Searching...
No Matches
bot.py
Go to the documentation of this file.
1from .api.v1 import ServerConfig, can_join_room, RoomJoinStatus, get_auth_bot_token
2from .commands import Command, CommandParameter
3from .dataclasses import *
4
5from socketio import AsyncClient
6
7from asyncio import run as async_run
8from asyncio import sleep as async_sleep
9from time import time
10from typing import Callable, Awaitable, Any
11
12
13_server_config = ServerConfig.get()
14Binding = Callable[..., Awaitable[Any]] | None
15
16class Bot:
17 """
18 A wrapper class around the socket.io Talkomatic API. This bot uses
19 an asynchronous socket.io client to optimize for concurrency,
20 while abstracting error handling, disconnecting, and other low-level
21 details, to make writing bots easier.
22
23 Args:
24 username (str): The username of the bot.
25 location (str): The location of the bot.
26 The bot's name will show up as "username / location".
27
28 Optional:
29 rate_limits (RateLimits): The rate limits of the bot. (disabled by default to make
30 development easier, consider putting rate limits in to prevent getting blocked by the API)
31 debug (bool): Whether to enable debug mode for the internal socket.io
32 client. Defaults to False.
33 """
34
35 sio: AsyncClient
36 rate_limits: RateLimits
37 user: User
38 current_room: Room | None= None
39 commands: dict[str, Command] = {}
40 command_marker: str
41 rooms: list[Room] = []
42 user_database: dict[str, User] = {}
43 user_messages: dict[User, str] = {}
44 _fully_started: bool = False
45
46 on_connect_binding : Binding = None
47 on_inactivity_disconnect_binding : Binding = None
48 on_room_creation_binding : Binding = None
49 on_room_join_binding : Binding = None
50 on_room_leave_binding : Binding = None
51 on_user_message_binding : Binding = None
52 on_user_join_binding : Binding = None
53 on_user_leave_binding : Binding = None
54 on_user_vote_binding : Binding = None
55
56 _time_since_last_message: float = 0
57 _time_since_last_room_join: float = 0
58 _time_since_last_room_creation: float = 0
59
60 def __init__(self, command_marker: str = "/", rate_limits: RateLimits = DISABLE_RATE_LIMITS, debug: bool = False) -> None:
61 self.sio = AsyncClient(engineio_logger = debug, logger = debug)
62 self.rate_limits = rate_limits
63 self.command_marker = command_marker
64
65 def run(self, username: str, location: str, create_help_command: bool = False) -> None:
66 """
67 Runs the bot.
68
69 Args:
70 username (str): The username of the bot.
71 location (str): The location of the bot.
72 """
73
74 assert len(username) <= _server_config.max_username_length, f"Username must be less (or equal) than {_server_config.max_username_length} characters."
75 assert len(location) <= _server_config.max_location_length, f"Location must be less (or equal) than {_server_config.max_location_length} characters."
76
77 if create_help_command:
78 @self.command(
79 name = "help",
80 description = "Shows this help menu.",
81 parameters = [
83 "command_name",
84 "The command to get help for.",
85 str,
86 positional = True,
87 required = False
88 )
89 ]
90 ) # type: ignore
91 async def _help_command(user: User, command_name: str | None = None) -> None:
92 message = ""
93 if command_name:
94 command = self.commands.get(command_name)
95 if command:
96 if not command.hidden:
97 message += f"{self.command_marker}{command_name} - {command.description}\n"
98 for parameter in command.parameters:
99 message += f" {'' if parameter.positional else '--'}{parameter.name} - {parameter.description} ({parameter.data_type.__name__}){'' if parameter.required else ' [optional]'}\n"
100 else:
101 message += f"Command '{self.command_marker}{command_name}' not found."
102 else:
103 message += f"Command '{self.command_marker}{command_name}' not found."
104 else:
105 for command_name, command in self.commands.items():
106 if not command.hidden:
107 message += f" {', '.join([self.command_marker + alias for alias in [command.name] + command.aliases])} - {command.description}\n"
108 await self.send_message(message)
109
110 try:
111 async_run(self._run(username, location))
112 except KeyboardInterrupt:
113 print("\033[1;33mCtrl+C pressed, disconnecting...\033[0m")
114 async_run(self.disconnect())
115
116 async def _run(self, username: str, location: str) -> None:
117 await self.sio.connect("https://classic.talkomatic.co/", auth = {
118 "token": get_auth_bot_token()
119 })
120 await self._join_lobby(username, location)
121 self.sio.on("signin status", self._signin_status)
122 await self.sio.emit("check signin status")
123
124 def _bind_event_ignore_args(event_name: str, binding: Binding) -> None:
125 if binding:
126 async def _(*_) -> None:
127 await binding()
128 self.sio.on(event_name, _)
129
130 def _bind_room_created(binding: Binding) -> None:
131 if binding:
132 async def _(room_id: str) -> None:
133 await binding(int(room_id))
134 self.sio.on("room created", _)
135
136 self.sio.on ("lobby update", self._lobby_update)
137 self.sio.on ("chat update", self._chat_update)
138 _bind_event_ignore_args("connect", self.on_connect_binding)
139 _bind_room_created ( self.on_room_creation_binding)
140 self.sio.on ("room joined", self._room_join)
141 self.sio.on ("room update", self._room_update)
142 self.sio.on ("user joined", self._user_join)
143 self.sio.on ("user left", self._user_leave)
144 self.sio.on ("update votes", self._update_votes)
145 self.sio.on ("error", self._error)
146 while not self._fully_started:
147 await async_sleep(0.01) # HACK
148
149 if self.on_connect_binding:
150 await self.on_connect_binding()
151
152 await self.sio.wait()
153 await self.disconnect()
154
155 async def _chat_update(self, data: dict) -> None:
156 user = self.get_user_by_id(data["userId"])
157 if user == None:
158 user = User(data["userId"], data["username"], "", id_only = True) # i have serious beef with mohd.
159 if user not in self.user_messages:
160 self.user_messages[user] = ""
161
162 diff = data["diff"]
163 if diff["type"] == "full-replace":
164 self.user_messages[user] = diff["text"]
165 elif diff["type"] == "add":
166 self.user_messages[user] = self.user_messages[user][:diff["index"]] + diff["text"] + self.user_messages[user][diff["index"]:]
167 elif diff["type"] == "delete":
168 self.user_messages[user] = self.user_messages[user][:diff["index"]] + self.user_messages[user][diff["index"] + diff["count"]:]
169 elif diff["type"] == "replace":
170 self.user_messages[user] = self.user_messages[user][:diff["index"]] + diff["text"] + self.user_messages[user][diff["index"] + diff["count"]:]
171 else:
172 raise RuntimeError(f"Unknown chat update type: {diff['type']}")
173
174 if self.commands != {}:
175 for command_name, command in self.commands.items():
176 if self.user_messages[user].startswith(self.command_marker + command_name):
177 first_line = self.user_messages[user].split("\n")[0]
178 val = await command.execute(user, first_line[len(self.command_marker + command_name):].strip())
179 if val != "":
180 await self.send_message(val)
181
183 await self.on_user_message_binding(user, self.user_messages[user])
184
185 async def _error(self, data: dict) -> None:
186 error = data["error"]
187 if error["code"] == "ACCESS_DENIED" and error["message"] == "Disconnected due to inactivity":
188 self.current_room = None
189 self.user_messages = {}
192 else:
193 print(f"Uncaught error socket.io event: {error}")
194
195 async def _join_lobby(self, username: str, location: str) -> None:
196 await self.sio.emit("join lobby", {
197 "username": username,
198 "location": location
199 })
200
201 async def _lobby_update(self, rooms_data: list[dict]) -> None:
202 rooms = [Room.from_raw_json(room_data) for room_data in rooms_data]
203 self.rooms = rooms
204
205 for room in self.rooms:
206 for user in room.users:
207 self.user_database[user.id] = user
208
209 self._fully_started = True
210
211 async def _signin_status(self, data: dict) -> None:
212 self.user = User.from_raw_json(data)
213
214 async def _room_join(self, data: dict) -> None:
215 self.current_room = Room.from_raw_json(data)
216 lobby_room_info = self.get_room_by_id(self.current_room.room_id)
217 if lobby_room_info != None: # private room, we can't really do anything about it
218 self.current_room.banned_users = lobby_room_info.banned_users
219 self.current_room.last_time_active = lobby_room_info.last_time_active
220 self.current_room.is_full = lobby_room_info.is_full
221 self.user_messages = {self.get_user_by_id(user_id): message for user_id, message in data["currentMessages"].items()} # type: ignore
222
223 if self.on_room_join_binding:
224 await self.on_room_join_binding(self.current_room)
225
226 async def _room_update(self, data: dict) -> None:
227 self.current_room = Room.from_raw_json(data)
228
229 async def _user_join(self, data: dict) -> None:
230 if self.current_room == None:
231 return # we just wait for room update event to do the job
232
233 user = User.from_raw_json(data)
234 self.current_room.users.append(user)
235 self.user_messages[user] = ""
236
237 if self.on_user_join_binding:
238 await self.on_user_join_binding(user)
239
240 async def _user_leave(self, data: str) -> None:
241 if self.current_room == None: return
242 for user in self.current_room.users:
243 if user.id == data:
244 self.current_room.users.remove(user)
245 if user in self.user_messages:
246 del self.user_messages[user]
247 if self.on_user_leave_binding:
248 await self.on_user_leave_binding(user)
249 return
250
251 async def _update_votes(self, data: dict) -> None:
252 if self.current_room == None: return
253 if self.current_room.votes == None: return
254 new_votes = Room._parse_votes({
255 "votes": data,
256 "users": self.current_room.users
257 })
258
259 # get voted and voter, and whether the voted was added or removed
260 if new_votes == {}:
261 if not self.current_room.votes:
262 return
263 voted = list(self.current_room.votes.keys())[0]
264 voter = self.get_user_by_id(self.current_room.votes[voted][0].id)
265 voted = self.get_user_by_id(voted.id)
266 self.current_room.votes = new_votes
267 if self.on_user_vote_binding:
268 await self.on_user_vote_binding(voter, voted, False)
269 else:
270 for voted, voters in new_votes.items():
271 old_voters = self.current_room.votes.get(voted, [])
272 for voter in voters:
273 if voter not in old_voters:
274 if self.on_user_vote_binding:
275 self.current_room.votes = new_votes
276 await self.on_user_vote_binding(
277 self.get_user_by_id(voter.id),
278 self.get_user_by_id(voted.id),
279 True
280 )
281 return
282 for voter in old_voters:
283 if voter not in voters:
284 if self.on_user_vote_binding:
285 self.current_room.votes = new_votes
286 await self.on_user_vote_binding(
287 self.get_user_by_id(voter.id),
288 self.get_user_by_id(voted.id),
289 False
290 )
291 return
292
293 def command(
294 self,
295 name: str,
296 description: str = "",
297 parameters: list[CommandParameter] = [],
298 aliases: list[str] = [],
299 hidden: bool = False
300 ) -> Callable[[Callable[..., Awaitable[Any]]], Callable[..., Awaitable[Any]]]:
301 """
302 Decorator for registering commands.
303
304 Args:
305 name (str): The name of the command.
306 description (str): The description of the command.
307 parameters (list[CommandParameter]): The parameters of the command.
308 aliases (list[str]): The aliases of the command.
309 hidden (bool): Whether the command will show up in the built-in /help command.
310 """
311
312 def register_command(function: Callable[..., Awaitable[Any]]) -> Callable[..., Awaitable[Any]]:
313 command = Command(name, description, aliases, hidden)
314 command.function = function
315 for parameter in parameters:
316 command.add_argument(
317 [parameter.name] + parameter.aliases,
318 parameter.description,
319 parameter.data_type,
320 parameter.positional,
321 parameter.required,
322 parameter.number_of_args
323 )
324 command.parameters = parameters
325 self.commands[name] = command
326 return function
327 return register_command
328
329 async def create_room(self, room_name: str, room_type: RoomType = RoomType.PUBLIC, room_layout: RoomLayoutType = RoomLayoutType.VERTICAL, room_password: int | None = None) -> bool:
330 """
331 Creates a room with the given name, type, layout, and password.
332
333 Args:
334 room_name (str): The name of the room.
335 room_type (RoomType): The type of the room.
336 room_layout (RoomLayoutType): The layout of the room.
337 room_password (int, **must be 6 digits**): The password of the room (if the room is not public).
338
339 Returns:
340 bool: Whether the room was created successfully (returns False if the room was
341 not created due to rate limits).
342 """
343
344 if time() - self._time_since_last_room_creation < self.rate_limits.room_creation_cooldown:
345 return False
346
347 if room_password != None:
348 assert 100000 <= room_password <= 999999, "Room password must be a 6 digits integer."
349
350 assert len(room_name) <= _server_config.max_room_name_length, f"Room name must be less (or equal) than {_server_config.max_room_name_length} characters."
351
352 await self.sio.emit("create room", {
353 "name": room_name,
354 "type": room_type.value,
355 "layout": room_layout.value,
356 "accessCode": str(room_password) if room_password != None else ""
357 })
358 return True
359
360 async def disconnect(self) -> None:
361 """
362 Disconnects the bot from the server.
363 """
364
365 await self.sio.disconnect()
366
367 def get_room_by_id(self, id: int) -> Room | None:
368 """
369 Gets a room by its ID.
370 Returns None if the room does not exist.
371
372 Args:
373 id (int): The ID of the room to get.
374
375 Returns:
376 Room: The room object. (None if the room does not exist)
377 """
378
379 for room in self.rooms:
380 if room.room_id == id:
381 return room
382 return None
383
384 def get_user_by_id(self, id: str) -> User | None:
385 """
386 Allows you to get a user from the user database by their ID.
387 Useful for getting a user from a "id-only" user, but is not
388 guaranteed to work if the user is not in a room anymore.
389
390 Args:
391 id (str): The ID of the user to get.
392
393 Returns:
394 User: The full user object. (None if the user is not in the database)
395 """
396
397 return self.user_database.get(id)
398
399 def get_user_message(self, user: User) -> str:
400 """
401 Gets the message of a user.
402
403 Args:
404 user (User): The user to get the message of.
405
406 Returns:
407 str: The message of the user.
408 """
409
410 return self.user_messages.get(user, "")
411
412 async def join_room(self, room: Room | int, access_code: int | None = None, do_api_check: bool = True) -> bool:
413 """
414 Joins a room with the given room/room ID and access code.
415 **Reminder: your bot can only be in 1 room at a time!**
416
417 Args:
418 room (Room | int): The room (or ID of the room) to join.
419 access_code (int | None): The 6 digit access code of the room to join (if the room is not public).
420 do_api_check (bool): Whether to check if the room can be joined using the Talkomatic API.
421
422 Returns:
423 bool: Whether the room was joined successfully (returns False if the room was
424 not joined due to rate limits).
425 """
426
427 if time() - self._time_since_last_room_join < self.rate_limits.room_join_cooldown:
428 return False
429
430 if isinstance(room, int):
431 room_id = room
432 else:
433 room_id = room.room_id
434
435 if do_api_check:
436 status = can_join_room(room_id, access_code)
437 if status != RoomJoinStatus.SUCCESS:
438 raise RuntimeError(f"Failed to join room {room_id}: {status}")
439
440 await self.sio.emit("join room", {
441 "roomId": str(room_id),
442 "accessCode": str(access_code) if access_code != None else None
443 })
444 return True
445
446 async def leave_room(self) -> None:
447 """
448 Leaves the room the bot is currently in.
449 """
450
451 await self.sio.emit("leave room")
452
453 def on_connect(self, binding: Binding) -> Binding:
454 """
455 Binds a function to when the bot finishes connection to the server.
456
457 Args:
458 binding (Coroutine): The function to bind to the event.
459 """
460
461 self.on_connect_binding = binding
462 return binding
463
464 def on_inactivity_disconnect(self, binding: Binding) -> Binding:
465 """
466 Binds a function to when the bot is disconnected due to having not interacted for too long.
467
468 Args:
469 binding (Coroutine): The function to bind to the event.
470 """
471
473 return binding
474
475 def on_room_creation(self, binding: Binding) -> Binding:
476 """
477 Binds a function to when the bot has successfully created the room.
478
479 Args:
480 binding (Coroutine): The function to bind to the event.
481 """
482
483 self.on_room_creation_binding = binding
484 return binding
485
486 def on_room_join(self, binding: Binding) -> Binding:
487 """
488 Binds a function to when the bot has successfully joined the room.
489
490 Args:
491 binding (Coroutine): The function to bind to the event.
492 """
493
494 self.on_room_join_binding = binding
495 return binding
496
497 def on_room_leave(self, binding: Binding) -> Binding:
498 """
499 Binds a function to when the bot has successfully left the room.
500
501 Args:
502 binding (Coroutine): The function to bind to the event.
503 """
504
505 self.on_room_leave_binding = binding
506 return binding
507
508 def on_user_message(self, binding: Binding) -> Binding:
509 """
510 Binds a function to when a user sent a message in the bot's current room.
511
512 Args:
513 binding (Coroutine): The function to bind to the event.
514 """
515
516 self.on_user_message_binding = binding
517 return binding
518
519 def on_user_join(self, binding: Binding) -> Binding:
520 """
521 Binds a function to when another user has joined the bot's current room.
522
523 Args:
524 binding (Coroutine): The function to bind to the event.
525 """
526
527 self.on_user_join_binding = binding
528 return binding
529
530 def on_user_leave(self, binding: Binding) -> Binding:
531 """
532 Binds a function to when a user has left the bot's current room.
533
534 Args:
535 binding (Coroutine): The function to bind to the event.
536 """
537
538 self.on_user_leave_binding = binding
539 return binding
540
541 def on_user_vote(self, binding: Binding) -> Binding:
542 """
543 Binds a function to when a user has voted to a ban another user from the bot's current room.
544
545 Args:
546 binding (Coroutine): The function to bind to the event.
547 """
548
549 self.on_user_vote_binding = binding
550 return binding
551
552 async def send_message(self, message: str) -> bool:
553 """
554 Sends the message to the room the bot is currently in.
555
556 Args:
557 message (str): The message to send.
558
559 Returns:
560 bool: Whether the message was sent successfully (returns False if the message was
561 not sent due to rate limits).
562 """
563
564 assert len(message) <= _server_config.max_message_length, f"Message is too long, max length is {_server_config.max_message_length} characters."
565
566 if time() - self._time_since_last_message < self.rate_limits.message_cooldown:
567 return False
568
569 await self.sio.emit("chat update", {
570 "diff": {
571 "type": "full-replace",
572 "text": message
573 }
574 })
575 return True
576
577 async def toggle_vote(self, user: User) -> None:
578 """
579 Votes the user to be banned (or remove vote if the user is already voted by the bot)
580 from the room the bot is currently in.
581
582 Args:
583 user (User): The user to vote for.
584 """
585
586 await self.sio.emit("vote", {
587 "targetUserId": user.id
588 })
589
590
591__all__ = ["Bot"]
None disconnect(self)
Definition bot.py:360
Binding on_room_join_binding
Definition bot.py:49
Binding on_connect(self, Binding binding)
Definition bot.py:453
None _join_lobby(self, str username, str location)
Definition bot.py:195
Binding on_connect_binding
Definition bot.py:46
Binding on_inactivity_disconnect_binding
Definition bot.py:47
Room current_room
Definition bot.py:38
Binding on_user_message_binding
Definition bot.py:51
float _time_since_last_message
Definition bot.py:56
Room|None get_room_by_id(self, int id)
Definition bot.py:367
str get_user_message(self, User user)
Definition bot.py:399
bool send_message(self, str message)
Definition bot.py:552
Binding on_user_join(self, Binding binding)
Definition bot.py:519
Binding on_room_join(self, Binding binding)
Definition bot.py:486
Binding on_room_leave(self, Binding binding)
Definition bot.py:497
Binding on_user_vote(self, Binding binding)
Definition bot.py:541
None _run(self, str username, str location)
Definition bot.py:116
None __init__(self, str command_marker="/", RateLimits rate_limits=DISABLE_RATE_LIMITS, bool debug=False)
Definition bot.py:60
Binding on_user_vote_binding
Definition bot.py:54
None leave_room(self)
Definition bot.py:446
User|None get_user_by_id(self, str id)
Definition bot.py:384
Binding on_room_creation_binding
Definition bot.py:48
dict user_messages
Definition bot.py:43
bool _fully_started
Definition bot.py:44
Binding on_room_leave_binding
Definition bot.py:50
None run(self, str username, str location, bool create_help_command=False)
Definition bot.py:65
float _time_since_last_room_join
Definition bot.py:57
dict user_database
Definition bot.py:42
Binding on_inactivity_disconnect(self, Binding binding)
Definition bot.py:464
Binding on_room_creation(self, Binding binding)
Definition bot.py:475
Binding on_user_message(self, Binding binding)
Definition bot.py:508
None toggle_vote(self, User user)
Definition bot.py:577
bool join_room(self, Room|int room, int|None access_code=None, bool do_api_check=True)
Definition bot.py:412
dict commands
Definition bot.py:39
Binding on_user_join_binding
Definition bot.py:52
Binding on_user_leave(self, Binding binding)
Definition bot.py:530
Binding on_user_leave_binding
Definition bot.py:53
float _time_since_last_room_creation
Definition bot.py:58