diff --git a/aiotfm/client.py b/aiotfm/client.py index a66911b..b4645d4 100644 --- a/aiotfm/client.py +++ b/aiotfm/client.py @@ -1,1395 +1,1395 @@ -import asyncio -import logging -import random -import warnings -from typing import AnyStr, ByteString, Callable, List, Optional, Union - -from aiotfm.connection import Connection -from aiotfm.enums import Community, GameMode, TradeError -from aiotfm.errors import ( - AiotfmException, - AlreadyConnected, - CommunityPlatformError, - IncorrectPassword, - InvalidEvent, - LoginError, - MaintenanceError, - ServerUnreachable, -) -from aiotfm.friend import Friend, FriendList -from aiotfm.inventory import Inventory, InventoryItem, Trade -from aiotfm.message import Channel, ChannelMessage, Message, Whisper -from aiotfm.packet import Packet -from aiotfm.player import Player, Profile -from aiotfm.room import Room, RoomList -from aiotfm.shop import Shop -from aiotfm.tribe import Tribe -from aiotfm.utils import Keys, Locale, get_ip, get_keys, shakikoo - -logger = logging.getLogger("aiotfm") - - -class Client: - """Represents a client that connects to Transformice. - Two argument can be passed to the :class:`Client`. - - .. _event loop: https://docs.python.org/3/library/asyncio-eventloops.html - - Parameters - ---------- - community: Optional[:class:`int`] - Defines the community of the client. Defaults to 0 (EN community). - auto_restart: Optional[:class:`bool`] - Whether the client should automatically restart on error. Defaults to False. - bot_role: Optional[:class:`bool`] - Whether the has the game's special role bot or not. - Avoids using the api endpoint and gives more stability. - loop: Optional[event loop] - The `event loop`_ to use for asynchronous operations. If ``None`` is passed (defaults), - the event loop used will be ``asyncio.get_event_loop()``. - - max_retries: Optional[:class:`int`] - The maximum number of retries the client should attempt while connecting to the game. - - Attributes - ---------- - username: Optional[:class:`str`] - The bot's username received from the server. Might be None if the bot didn't log in yet. - room: Optional[:class:`aiotfm.room.Room`] - The bot's room. Might be None if the bot didn't log in yet or couldn't join any room yet. - trade: Optional[:class:`aiotfm.inventory.Trade`] - The current trade that's going on (i.e: both traders accepted it). - trades: :class:`list`[:class:`aiotfm.inventory.Trade`] - All the trades that the bot participates. Most of them might be invitations only. - inventory: Optional[:class:`aiotfm.inventory.Inventory`] - The bot's inventory. Might be None if the bot didn't log in yet or it didn't receive - anything. - locale: :class:`aiotfm.locale.Locale` - The bot's locale (translations). - friends: Optional[:class:`aiotfm.friends.FriendList`] - The bot's friend list - """ - - LOG_UNHANDLED_PACKETS = False - - def __init__( - self, - community: Union[Community, int] = Community.en, - auto_restart: bool = False, - bot_role: bool = False, - loop: Optional[asyncio.AbstractEventLoop] = None, - max_retries: int = 6, - ): - self.loop: asyncio.AbstractEventLoop = loop or asyncio.get_event_loop() - - self.main: Connection = Connection("main", self, self.loop) - self.bulle: Connection = None - - self._waiters: dict = {} - self._close_event: asyncio.Future = None - self._sequenceId: int = 0 - self._channels: List[Channel] = [] - self._restarting: bool = False - self._closed: bool = False - self._logged: bool = False - self._max_retries: int = max_retries - - self.room: Room = None - self.trade: Trade = None - self.trades: dict = {} - self.inventory: Inventory = None - - self.username: str = None - self.locale: Locale = Locale() - self.community: Community = Community(community) - - self.friends: FriendList = None - - self.keys: Keys = None - self.authkey: int = 0 - - self.auto_restart: bool = auto_restart - self.api_tfmid: int = None - self.api_token: str = None - self.bot_role: bool = bot_role - - @property - def restarting(self) -> bool: - return self._restarting - - @property - def closed(self) -> bool: - return self._closed - - def _backoff(self, n: int) -> float: - """Returns the numbers of seconds to wait until the n-th connection attempt. Capped at 10 minutes.""" - return random.uniform(20, 30 * 2 ** min(n, 5)) - - def data_received(self, data: bytes, connection: Connection): - """|coro| - Dispatches the received data. - - :param data: :class:`bytes` the received data. - :param connection: :class:`aiotfm.Connection` the connection that received - the data. - """ - # :desc: Called when a socket receives a packet. Does not interfere - # with :meth:`Client.handle_packet`. - # :param connection: :class:`aiotfm.Connection` the connection that received - # the packet. - # :param packet: :class:`aiotfm.Packet` a copy of the packet. - self.dispatch("raw_socket", connection, Packet(data)) - self.loop.create_task(self.handle_packet(connection, Packet(data))) - - async def handle_packet(self, connection: Connection, packet: Packet) -> bool: - """|coro| - Handles the known packets and dispatches events. - Subclasses should handle only the unhandled packets from this method. - - Example: :: - class Bot(aiotfm.Client): - async def handle_packet(self, conn, packet): - handled = await super().handle_packet(conn, packet.copy()) - - if not handled: - # Handle here the unhandled packets. - pass - - :param connection: :class:`aiotfm.Connection` the connection that received - the packet. - :param packet: :class:`aiotfm.Packet` the packet. - :return: True if the packet got handled, False otherwise. - """ - CCC = packet.readCode() - if CCC == (1, 1): # Old packets - oldCCC, *data = packet.readString().split(b"\x01") - data = list(map(bytes.decode, data)) - oldCCC = tuple(oldCCC[:2]) - - # :desc: Called when an old packet is received. Does not interfere - # with :meth:`Client.handle_old_packet`. - # :param connection: :class:`aiotfm.Connection` the connection that received - # the packet. - # :param oldCCC: :class:`tuple` the packet identifiers on the old protocol. - # :param data: :class:`list` the packet data. - self.dispatch("old_packet", connection, oldCCC, data) - return await self.handle_old_packet(connection, oldCCC, data) - - if CCC == (5, 21): # Joined room - self.room = Room(official=packet.readBool(), name=packet.readUTF()) - - # :desc: Called when the client has joined a room. - # :param room: :class:`aiotfm.room.Room` the room the client has entered. - self.dispatch("joined_room", self.room) - - elif CCC == (5, 39): # Password required for the room - # :desc: Called when a password is required to enter a room - # :param room: :class:`aiotfm.room.Room` the room the server is asking for a password. - self.dispatch("room_password", Room(packet.readUTF())) - - elif CCC == (6, 6): # Room message - username = packet.readUTF() - message = packet.readUTF() - player = self.room.get_player(username=username) - - if player is None: - player = Player(username) - - # :desc: Called when the client receives a message from the room. - # :param message: :class:`aiotfm.message.Message` the message. - self.dispatch("room_message", Message(player, message, self)) - - elif CCC == (6, 20): # Server message - packet.readBool() # if False then the message will appear in the #Server channel - t_key = packet.readUTF() - t_args = [packet.readUTF() for i in range(packet.read8())] - - # :desc: Called when the client receives a message from the server that needs to be translated. - # :param message: :class:`aiotfm.locale.Translation` the message translated with the - # current locale. - # :param *args: a list of string used as replacement inside the message. - self.dispatch("server_message", self.locale[t_key], *t_args) - - elif CCC == (8, 1): # Play emote - player = self.room.get_player(pid=packet.read32()) - emote = packet.read8() - flag = packet.readUTF() if emote == 10 else "" - - # :desc: Called when a player plays an emote. - # :param player: :class:`aiotfm.Player` the player. - # :param emote: :class:`int` the emote's id. - # :param flag: :class:`str` the flag's id. - self.dispatch("emote", player, emote, flag) - - elif CCC == (8, 5): # Show emoji - player = self.room.get_player(pid=packet.read32()) - emoji = packet.read8() - - # :desc: Called when a player is showing an emoji above its head. - # :param player: :class:`aiotfm.Player` the player. - # :param emoji: :class:`int` the emoji's id. - self.dispatch("emoji", player, emoji) - - elif CCC == (8, 6): # Player won - packet.read8() - player = self.room.get_player(pid=packet.read32()) - player.score = packet.read16() - order = packet.read8() - player_time = packet.read16() / 100 - - # :desc: Called when a player get the cheese to the hole. - # :param player: :class:`aiotfm.Player` the player. - # :param order: :class:`int` the order of the player in the hole. - # :param player_time: :class:`float` player's time in the hole in seconds. - self.dispatch("player_won", player, order, player_time) - - elif CCC == (8, 16): # Profile - # :desc: Called when the client receives the result of a /profile command. - # :param profile: :class:`aiotfm.player.Profile` the profile. - self.dispatch("profile", Profile(packet)) - - elif CCC == (8, 20): # Shop - # :desc: Called when the client receives the content of the shop. - # :param shop: :class:`aiotfm.shop.Shop` the shop. - self.dispatch("shop", Shop(packet)) - - elif CCC == (8, 22): # Skills - skills = {} - for _ in range(packet.read8()): - key, value = packet.read8(), packet.read8() - skills[key] = value - - # :desc: Called when the client receives its skill tree. - # :param skills: :class:`dict` the skills. - self.dispatch("skills", skills) - - elif CCC == (16, 2): # Tribe invitation received - author = packet.readUTF() - tribe = packet.readUTF() - - # :desc: Called when the client receives an invitation to a tribe. (/inv) - # :param author: :class:`str` the player that invited you. - # :param tribe: :class:`str` the tribe. - self.dispatch("tribe_inv", author, tribe) - - elif CCC == (26, 2): # Logged in successfully - player_id = packet.read32() - self.username = username = packet.readUTF() - played_time = packet.read32() - community = Community(packet.read8()) - pid = packet.read32() - - # :desc: Called when the client successfully logged in. - # :param uid: :class:`int` the client's unique id. - # :param username: :class:`str` the client's username. - # :param played_time: :class:`int` the total number of minutes the client has played. - # :param community: :class:`aiotfm.enums.Community` the community the client has connected to. - # :param pid: :class:`int` the client's player id. - self.dispatch("logged", player_id, username, played_time, community, pid) - - elif CCC == (26, 3): # Handshake OK - online_players = packet.read32() - language = packet.readUTF() - country = packet.readUTF() - self.authkey = packet.read32() - self._logged = False - - os_info = Packet.new(28, 17).writeString("en").writeString("Linux") - os_info.writeString("LNX 29,0,0,140").write8(0) - - await connection.send(os_info) - - # :desc: Called when the client can login through the game. - # :param online_players: :class:`int` the number of player connected to the game. - # :param language: :class:`str` the language the server is suggesting. - # :param country: :class:`str` the country detected from your ip. - self.dispatch("login_ready", online_players, language, country) - - elif CCC == (26, 12): # Login result - self._logged = False - # :desc: Called when the client failed logging. - # :param code: :class:`int` the error code. - # :param error1: :class:`str` error messages. - # :param error2: :class:`str` error messages. - self.dispatch("login_result", packet.read8(), packet.readUTF(), packet.readUTF()) - - elif CCC == (26, 25): # Ping - # :desc: Called when the client receives the ping response from the server. - self.dispatch("ping") - - elif CCC == (26, 35): # Room list - roomlist = RoomList.from_packet(packet) - # :desc: Dispatched when the client receives the room list - self.dispatch("room_list", roomlist) - - elif CCC == (28, 6): # Server ping - await connection.send(Packet.new(28, 6).write8(packet.read8())) - - elif CCC == (29, 6): # Lua logs - # :desc: Called when the client receives lua logs from #Lua. - # :param log: :class:`str` a log message. - self.dispatch("lua_log", packet.readUTF()) - - elif CCC == (31, 1): # Inventory data - self.inventory = Inventory.from_packet(packet) - self.inventory.client = self - - # :desc: Called when the client receives its inventory's content. - # :param inventory: :class:`aiotfm.inventory.Inventory` the client's inventory. - self.dispatch("inventory_update", self.inventory) - - elif CCC == (31, 2): # Update inventory item - item_id = packet.read16() - quantity = packet.read8() - - if item_id in self.inventory.items: - item = self.inventory.items[item_id] - previous = item.quantity - item.quantity = quantity - - # :desc: Called when the quantity of an item has been updated. - # :param item: :class:`aiotfm.inventory.InventoryItem` the new item. - # :param previous: :class:`int` the previous quantity. - self.dispatch("item_update", item, previous) - - else: - item = InventoryItem(item_id=item_id, quantity=quantity) - self.inventory.items[item.id] = item - - # :desc: Called when the client receives a new item in its inventory. - # :param item: :class:`aiotfm.inventory.InventoryItem` the new item. - self.dispatch("new_item", item) - - elif CCC == (31, 5): # Trade invite - pid = packet.read32() - - self.trades[pid] = Trade(self, self.room.get_player(pid=pid)) - - # :desc: Called when received an invitation to trade. - # :param trade: :class:`aiotfm.inventory.Trade` the trade object. - self.dispatch("trade_invite", self.trades[pid]) - - elif CCC == (31, 6): # Trade error - name = packet.readUTF().lower() - error = packet.read8() - - if name == self.username.lower(): - trade = self.trade - else: - for t in self.trades.values(): - if t.trader.lower() == name: - trade = t - break - - # :desc: Called when an error occurred with a trade. - # :param trade: :class:`aiotfm.inventory.Trade` the trade that failed. - # :param error: :class:`aiotfm.enums.TradeError` the error. - self.dispatch("trade_error", trade, TradeError(error)) - trade._close() - - elif CCC == (31, 7): # Trade start - pid = packet.read32() - trade = self.trades.get(pid) - - if trade is None: - raise AiotfmException(f"Cannot find the trade from pid {pid}.") - - trade._start() - self.trade = trade - - # :desc: Called when a trade starts. You can access the trade object with `Client.trade`. - self.dispatch("trade_start") - - elif CCC == (31, 8): # Trade items - export = packet.readBool() - id_ = packet.read16() - quantity = (1 if packet.readBool() else -1) * packet.read8() - - items = self.trade.exports if export else self.trade.imports - items.add(id_, quantity) - - trader = self if export else self.trade.trader - self.trade.locked = [False, False] - - # :desc: Called when an item has been added/removed from the current trade. - # :param trader: :class:`aiotfm.Player` the player that triggered the event. - # :param id: :class:`int` the item's id. - # :param quantity: :class:`int` the quantity added/removed. Can be negative. - # :param item: :class:`aiotfm.inventory.InventoryItem` the item after the change. - self.dispatch("trade_item_change", trader, id_, quantity, items.get(id_)) - - elif CCC == (31, 9): # Trade lock - index = packet.read8() - locked = packet.readBool() - if index > 1: - self.trade.locked = [locked, locked] - who = "both" - else: - self.trade.locked[index] = locked - who = self.trade.trader if index == 0 else self - - # :desc: Called when the trade got (un)locked. - # :param who: :class:`aiotfm.Player` the player that triggered the event. - # :param locked: :class:`bool` either the trade got locked or unlocked. - self.dispatch("trade_lock", who, locked) - - elif CCC == (31, 10): # Trade complete - trade = self.trade - self.trade._close(succeed=True) - - elif CCC == (44, 1): # Bulle switching - timestamp = packet.read32() - uid = packet.read32() - pid = packet.read32() - bulle_ip = packet.readUTF() - ports = packet.readUTF().split("-") - - if self.bulle is not None: - self.bulle.close() - - self.bulle = Connection("bulle", self, self.loop) - await self.bulle.connect(bulle_ip, int(random.choice(ports))) - await self.bulle.send(Packet.new(44, 1).write32(timestamp).write32(uid).write32(pid)) - - elif CCC == (44, 22): # Fingerprint offset changed - connection.fingerprint = packet.read8() - - elif CCC == (60, 3): # Community platform - TC = packet.read16() - - # :desc: Called when the client receives a packet from the community platform. - # :param TC: :class:`int` the packet's code. - # :param packet: :class:`aiotfm.Packet` the packet. - self.dispatch("raw_cp", TC, packet.copy(copy_pos=True)) - - if TC == 3: # Connected to the community platform - await self.sendCP(28) # Request friend list - - # :desc: Called when the client is successfully connected to the community platform. - self.dispatch("ready") - - elif TC == 32: # Friend connected - if self.friends is None: - return True - - friend = self.friends.get_friend(packet.readUTF()) - friend.isConnected = True - - # :desc: Called when a friend connects to the game (not entirely fetched) - # :param friend: :class:`aiotfm.friend.Friend` friend after this update - self.dispatch("friend_connected", friend) - - elif TC == 33: # Friend disconnected - if self.friends is None: - return True - - friend = self.friends.get_friend(packet.readUTF()) - friend.isConnected = False - - # :desc: Called when a friend disconnects from the game (not entirely fetched) - # :param friend: :class:`aiotfm.friend.Friend` friend after this update - self.dispatch("friend_disconnected", friend) - - elif TC == 34: # Friend list loaded - self.friends = FriendList(self, packet) - - # :desc: Called when the friend list is loaded. - # :param friends: :class:`aiotfm.friend.FriendList` the friend list - self.dispatch("friends_loaded", self.friends) - - elif TC == 35 or TC == 36: # Friend update / addition - if self.friends is None: - return True - - new = Friend(self.friends, packet) - old = self.friends.get_friend(new.name) - - if old is not None: - if old.isSoulmate: # Not sent by the server, checked locally. - self.friends.soulmate = new - new.isSoulmate = True - - self.friends.friends.remove(old) - self.friends.friends.append(new) - - if old is None: - # :desc: Called when a friend is added - # :param friend: :class:`aiotfm.friend.Friend` the friend - self.dispatch("new_friend", new) - - else: - # :desc: Called when a friend is updated - # :param before: :class:`aiotfm.friend.Friend` friend before this update - # :param after: :class:`aiotfm.friend.Friend` friend after this update - self.dispatch("friend_update", old, new) - - elif TC == 37: # Remove friend - if self.friends is None: - return True - - friend = self.friends.get_friend(packet.read32()) - if friend is not None: - if friend == self.friends.soulmate: - self.friends.soulmate = None - - self.friends.friends.remove(friend) - - # :desc: Called when a friend is removed - # :param friend: :class:`aiotfm.friend.Friend` the friend - self.dispatch("friend_remove", friend) - - elif TC == 55: # Channel join result - sequenceId = packet.read32() - result = packet.read8() - - # :desc: Called when the client receives the result of joining a channel. - # :param sequenceId: :class:`int` identifier returned by :meth:`Client.sendCP`. - # :param result: :class:`int` result code. - self.dispatch("channel_joined_result", sequenceId, result) - - elif TC == 57: # Channel leave result - sequenceId = packet.read32() - result = packet.read8() - - # :desc: Called when the client receives the result of leaving a channel. - # :param sequenceId: :class:`int` identifier returned by :meth:`Client.sendCP`. - # :param result: :class:`int` result code. - self.dispatch("channel_left_result", sequenceId, result) - - elif TC == 59: # Channel /who result - idSequence = packet.read32() - result = packet.read8() - players = [Player(packet.readUTF()) for _ in range(packet.read16())] - - # :desc: Called when the client receives the result of the /who command in a channel. - # :param idSequence: :class:`int` the reference to the packet that performed the request. - # :param players: List[:class:`aiotfm.Player`] the list of players inside the channel. - self.dispatch("channel_who", idSequence, players) - - elif TC == 62: # Joined a channel - name = packet.readUTF() - - if name in self._channels: - channel = [c for c in self._channels if c == name][0] - else: - channel = Channel(name, self) - self._channels.append(channel) - - # :desc: Called when the client joined a channel. - # :param channel: :class:`aiotfm.message.Channel` the channel. - self.dispatch("channel_joined", channel) - - elif TC == 63: # Quit a channel - name = packet.readUTF() - if name in self._channels: - self._channels.remove(name) - - # :desc: Called when the client leaves a channel. - # :param name: :class:`str` the channel's name. - self.dispatch("channel_closed", name) - - elif TC == 64: # Channel message - username, community = packet.readUTF(), packet.read32() - channel_name, message = packet.readUTF(), packet.readUTF() - channel = self.get_channel(channel_name) - author = self.room.get_player(username=username) - - if author is None: - author = Player(username) - - if channel is None: - channel = Channel(channel_name, self) - self._channels.append(channel) - - channel_message = ChannelMessage(author, community, message, channel) - - # :desc: Called when the client receives a message from a channel. - # :param message: :class:`aiotfm.message.ChannelMessage` the message. - self.dispatch("channel_message", channel_message) - - elif TC == 65: # Tribe message - author, message = packet.readUTF(), packet.readUTF() - - # :desc: Called when the client receives a message from the tribe. - # :param author: :class:`str` the message's author. - # :param message: :class:`str` the message's content. - self.dispatch("tribe_message", author, message) - - elif TC == 66: # Whisper - author = Player(packet.readUTF()) - commu = packet.read32() - receiver = Player(packet.readUTF()) - message = packet.readUTF() - - author = self.room.get_player(name=author, default=author) - receiver = self.room.get_player(name=receiver, default=receiver) - - # :desc: Called when the client receives a whisper. - # :param message: :class:`aiotfm.message.Whisper` the message. - self.dispatch("whisper", Whisper(author, commu, receiver, message, self)) - - elif TC == 88: # tribe member connected - # :desc: Called when a tribe member connected. - # :param name: :class:`str` the member's name. - self.dispatch("member_connected", packet.readUTF()) - - elif TC == 90: # tribe member disconnected - # :desc: Called when a tribe member disconnected. - # :param name: :class:`str` the member's name. - self.dispatch("member_disconnected", packet.readUTF()) - - else: - if self.LOG_UNHANDLED_PACKETS: - print(CCC, TC, bytes(packet.buffer)[4:]) - return False - - elif CCC == (144, 1): # Set player list - before = self.room.players - self.room.players = {} - - for _ in range(packet.read16()): - player = Player.from_packet(packet) - self.room.players[player.pid] = player - - # :desc: Called when the client receives an update of all player in the room. - # :param before: Dict[:class:`aiotfm.Player`] the list of player before the update. - # :param players: Dict[:class:`aiotfm.Player`] the list of player updated. - self.dispatch("bulk_player_update", before, self.room.players) - - elif CCC == (144, 2): # Add a player - after = Player.from_packet(packet) - before = self.room.players.pop(after.pid, None) - - self.room.players[after.pid] = after - if before is None: - # :desc: Called when a player joined the room. - # :param player: :class:`aiotfm.Player` the player. - self.dispatch("player_join", after) - else: - # :desc: Called when a player's data on the room has been updated. - # :param before: :class:`aiotfm.Player` the player before the update. - # :param player: :class:`aiotfm.Player` the player updated. - self.dispatch("player_update", before, after) - - else: - if self.LOG_UNHANDLED_PACKETS: - print(CCC, bytes(packet.buffer)[2:]) - return False - - return True - - async def handle_old_packet(self, connection: Connection, oldCCC: tuple, data: list) -> bool: - """|coro| - Handles the known packets from the old protocol and dispatches events. - Subclasses should handle only the unhandled packets from this method. - - Example: :: - class Bot(aiotfm.Client): - async def handle_old_packet(self, conn, oldCCC, data): - handled = await super().handle_old_packet(conn, data.copy()) - - if not handled: - # Handle here the unhandled packets. - pass - - :param connection: :class:`aiotfm.Connection` the connection that received - the packet. - :param oldCCC: :class:`tuple` the packet identifiers on the old protocol. - :param data: :class:`list` the packet data. - :return: True if the packet got handled, False otherwise. - """ - if oldCCC == (8, 5): # Player died - player = self.room.get_player(pid=data[0]) - if player is not None: - player.score = int(data[2]) - - # :desc: Called when a player dies. - # :param player: :class:`aiotfm.Player` the player. - self.dispatch("player_died", player) - - elif oldCCC == (8, 7): # Remove a player - player = self.room.players.pop(int(data[0]), None) - - if player is not None: - # :desc: Called when a player leaves the room. - # :param player: :class:`aiotfm.Player` the player. - self.dispatch("player_remove", player) - - else: - if self.LOG_UNHANDLED_PACKETS: - print("[OLD]", oldCCC, data) - return False - - return True - - def get_channel(self, name: str) -> Optional[Channel]: - """Returns a channel from it's name or None if not found. - :param name: :class:`str` the name of the channel. - :return: :class:`aiotfm.message.ChannelMessage` or None - """ - if name is None: - return None - - for channel in self._channels: - if channel.name == name: - return channel - - def get_trade(self, player: Union[str, Player]) -> Optional[Trade]: - """Returns the pending/current trade with a player. - :param player: :class:`aiotfm.Player` or :class:`str` the player. - :return: :class:`aiotfm.inventory.Trade` the trade with the player. - """ - if not isinstance(player, (str, Player)): - raise TypeError(f"Expected Player or str types got {type(player)}") - - if isinstance(player, Player): - return self.trades.get(player.pid) - - player = player.lower() - for trade in self.trades.values(): - if trade.trader.lower() == player: - return trade - - def event(self, coro: Callable) -> Callable: - """A decorator that registers an event. - - More about events [here](Events.md). - """ - name = coro.__name__ - if not name.startswith("on_"): - raise InvalidEvent(f"{name!r} isn't a correct event naming.") - if not asyncio.iscoroutinefunction(coro): - message = f"Couldn't register a non-coroutine function for the event {name}." - raise InvalidEvent(message) - - setattr(self, name, coro) - return coro - - def wait_for( - self, - event: str, - condition: Optional[Callable] = None, - timeout: Optional[float] = None, - stopPropagation: bool = False, - ) -> asyncio.Future: - """Wait for an event. - - Example: :: - @client.event - async def on_room_message(author, message): - if message == 'id': - await client.sendCommand(f'profile {author.username}') - profile = await client.wait_for('on_profile', lambda p: p.username == author) - await client.sendRoomMessage('Your id: {}'.format(profile.id)) - - :param event: :class:`str` the event name. - :param condition: Optionnal[`function`] A predicate to check what to wait for. - The arguments must meet the parameters of the event being waited for. - :param timeout: Optionnal[:class:`float`] the number of seconds before - throwing asyncio.TimeoutError - :return: [`asyncio.Future`](https://docs.python.org/3/library/asyncio-future.html#asyncio.Future) - a future that you must await. - """ - event = event.lower() - future = self.loop.create_future() - - if condition is None: - - def everything(*a): - return True - - condition = everything - - if event not in self._waiters: - self._waiters[event] = [] - - self._waiters[event].append((condition, future, stopPropagation)) - - return asyncio.wait_for(future, timeout) - - async def _run_event(self, coro: Callable, event_name: str, *args, **kwargs): - """|coro| - Runs an event and handle the error if any. - - :param coro: a coroutine function. - :param event_name: :class:`str` the event's name. - :param args: arguments to pass to the coro. - :param kwargs: keyword arguments to pass to the coro. - - :return: :class:`bool` whether the event ran successfully or not - """ - try: - await coro(*args, **kwargs) - return True - # except asyncio.CancelledError: - # raise - except Exception as e: - if hasattr(self, "on_error"): - try: - await self.on_error(event_name, e, *args, **kwargs) - # except asyncio.CancelledError: - # raise - except Exception: - if self.auto_restart: - await self.restart(5) - else: - self.close() - - return False - - def dispatch(self, event: str, *args, **kwargs): - """Dispatches events - - :param event: :class:`str` event's name. (without 'on_') - :param args: arguments to pass to the coro. - :param kwargs: keyword arguments to pass to the coro. - - :return: [`Task`](https://docs.python.org/3/library/asyncio-task.html#asyncio.Task) - the _run_event wrapper task - """ - method = "on_" + event - - if method in self._waiters: - to_remove = [] - waiters = self._waiters[method] - for i, (cond, fut, stop) in enumerate(waiters): - if fut.cancelled(): - to_remove.append(i) - continue - - try: - result = bool(cond(*args)) - except Exception as e: - fut.set_exception(e) - else: - if result: - fut.set_result(args[0] if len(args) == 1 else args if len(args) > 0 else None) - if stop: - del waiters[i] - return None - to_remove.append(i) - - if len(to_remove) == len(waiters): - del self._waiters[method] - else: - for i in to_remove[::-1]: - del waiters[i] - - coro = getattr(self, method, None) - if coro is not None: - dispatch = self._run_event(coro, method, *args, **kwargs) - return asyncio.ensure_future(dispatch, loop=self.loop) - - async def on_error(self, event: str, err: Exception, *a, **kw): - """Default on_error event handler. Prints the traceback of the error.""" - logger.error('An error occurred while dispatching the event "%s":', event, exc_info=-3) - - async def on_connection_error(self, conn: Connection, error: Exception): - """Default on_connection_error event handler. Prints the error.""" - logger.error("The %s connection has been closed.", conn.name, exc_info=error) - - async def on_login_result(self, code: int, *args): - """Default on_login_result handler. Raise an error and closes the connection.""" - self.loop.call_later(3, self.close) - if code == 1: - raise AlreadyConnected() - if code == 2: - raise IncorrectPassword() - raise LoginError(code) - - async def _connect(self): - """|coro| - Creates a connection with the main server. - """ - if self._close_event is None: - raise AiotfmException(f"{self._connect.__name__} should not be called directly. Use start() instead.") - - for port in random.sample(self.keys.server_ports, 4): - try: - await self.main.connect(self.keys.server_ip, port) - except Exception: - logger.debug("Unable to connect to the server %s:%s.", self.keys.server_ip, port) - else: - break - else: - raise ServerUnreachable("Unable to connect to the server.") - - while not self.main.open: - await asyncio.sleep(0) - - async def sendHandshake(self): - """|coro| - Sends the handshake packet so the server recognizes this socket as a player. - """ - packet = Packet.new(28, 1).write16(self.keys.version) - if not self.bot_role: - packet.writeString("en").writeString(self.keys.connection) - - packet.writeString("Desktop").writeString("-").write32(0x1FBD).writeString("") - packet.writeString("74696720697320676f6e6e61206b696c6c206d7920626f742e20736f20736164") - packet.writeString( - "A=t&SA=t&SV=t&EV=t&MP3=t&AE=t&VE=t&ACC=t&PR=t&SP=f&SB=f&DEB=f&V=LNX 32,0,0,182&M=Adobe" - " Linux&R=1920x1080&COL=color&AR=1.0&OS=Linux&ARCH=x86&L=en&IME=t&PR32=t&PR64=t&LS=en-U" - "S&PT=Desktop&AVD=f&LFD=f&WD=f&TLS=t&ML=5.1&DP=72" - ) - packet.write32(0).write32(0x6257).writeString("") - - await self.main.send(packet) - - async def start( - self, - api_tfmid: Optional[int] = None, - api_token: Optional[str] = None, - keys: Optional[Keys] = None, - **kwargs, - ): - """|coro| - Starts the client. - - :param api_tfmid: Optional[:class:`int`] your Transformice id. - :param api_token: Optional[:class:`str`] your token to access the API. - """ - if self.bot_role: - self.keys = keys or await get_ip() - else: - if self.auto_restart and api_tfmid is None or api_token is None: - warnings.warn("The api token were not provided. The Client won't be able to restart.", stacklevel=2) - self.auto_restart = False - - self.keys = keys - if keys is None: - self.keys = await get_keys(api_tfmid, api_token) - - if "username" in kwargs and "password" in kwargs: - # Monkey patch the on_login_ready event - if hasattr(self, "on_login_ready"): - event = self.on_login_ready - self.on_login_ready = lambda *a: asyncio.gather(self.login(**kwargs), event(*a)) - else: - self.on_login_ready = lambda *a: self.login(**kwargs) - - retries = 0 - on_started = None - keep_alive = Packet.new(26, 26) - while True: - self._close_event = asyncio.Future() - try: - logger.info("Connecting to the game.") - await self._connect() - await self.sendHandshake() - await self.locale.load() - retries = 0 # Connection successful - self._restarting = False - except Exception as e: - logger.error("Connection to the server failed.", exc_info=e) - if on_started is not None: - on_started.set_exception(e) - elif retries > self._max_retries: - raise e - else: - retries += 1 - backoff = self._backoff(retries) - logger.info("Attempt %d failed. Reconnecting in %.2fs", retries, backoff) - await asyncio.sleep(backoff) - continue - else: - if on_started is not None: - on_started.set_result(None) - - while not self._close_event.done(): - # Keep the connection(s) alive - await asyncio.gather(*[c.send(keep_alive) for c in (self.main, self.bulle) if c]) - await asyncio.wait((self._close_event,), timeout=15) - - reason, delay, on_started = self._close_event.result() - self._close_event = asyncio.Future() - - logger.debug("[Close Event] Reason: %s, Delay: %d, Callback: %s", reason, delay, on_started) - logger.debug("Will restart: %s", reason != "stop" and self.auto_restart) - - # clean up - for conn in (self.main, self.bulle): - if conn is not None: - conn.close() - - if reason == "stop" or not self.auto_restart: - break - - await asyncio.sleep(delay) - - # If we don't recreate the connection, we won't be able to connect. - self.main = Connection("main", self, self.loop) - self.bulle = None - - # Fetch some fresh keys - if not self.bot_role and (reason != "restart" or self.keys is None): - for i in range(self._max_retries): - try: - self.keys = await get_keys(api_tfmid, api_token) - break - except MaintenanceError: - if i == 0: - logger.info("The game is under maintenance.") - - await asyncio.sleep(30) - else: - raise MaintenanceError("The game is under heavy maintenance.") - - async def restart_soon(self, delay: float = 5.0, **kwargs): - """|coro| - Restarts the client in several seconds. - - :param delay: :class:`float` the delay before restarting. Default is 5 seconds. - :param args: arguments to pass to the :meth:`Client.restart` method. - :param kwargs: keyword arguments to pass to the :meth:`Client.restart` method.""" - warnings.warn( - "`Client.restart_soon` is deprecated, use `Client.restart` instead.", DeprecationWarning, stacklevel=2 - ) - await self.restart(delay, **kwargs) - - async def restart(self, delay: float = 0, keys: Optional[Keys] = None): - """|coro| - Restarts the client. - - :param delay: the delay before restarting. By default, there is no delay. - :param keys: - """ - if not self.auto_restart or self._close_event is None: - raise AiotfmException( - "Unable to restart the Client. Either `auto_restart` is set to " - "False or you have not started the Client using `Client.start`." - ) - - if self._restarting or self._close_event.done(): - return - - self.keys = keys - self._restarting = True - # :desc: Notify when the client restarts. - self.dispatch("restart") - - restarted = asyncio.Future() - self._close_event.set_result(("restart", delay, restarted)) - await restarted - - async def login(self, username: str, password: str, encrypted: bool = True, room: str = "*aiotfm"): - """|coro| - Log in the game. - - :param username: :class:`str` the client username. - :param password: :class:`str` the client password. - :param encrypted: Optional[:class:`bool`] whether the password is already encrypted or not. - :param room: Optional[:class:`str`] the room where the client will be logged in. - """ - if self._logged: - raise AiotfmException("You cannot log in twice.") - - self._logged = True - if not encrypted: - password = shakikoo(password) - - packet = Packet.new(26, 8).writeString(username).writeString(password) - packet.writeString("app:/TransformiceAIR.swf/[[DYNAMIC]]/2/[[DYNAMIC]]/4").writeString(room) - - if self.bot_role: - packet.write16(0).write8(0).writeString("") - else: - packet.write32(self.authkey ^ self.keys.auth) - packet.write8(0).writeString("") - packet.cipher(self.keys.identification) - - await self.main.send(Packet.new(176, 1).writeUTF(self.community.name)) - await self.main.send(packet.write8(0)) - - def run(self, api_tfmid: int, api_token: str, username: str, password: str, **kwargs): - """A blocking call that does the event loop initialization for you. - - Equivalent to: :: - @bot.event - async def on_login_ready(*a): - await bot.login(username, password) - - loop = asyncio.get_event_loop() - loop.create_task(bot.start(api_id, api_token)) - loop.run_forever() - """ - try: - kwargs.update({"username": username, "password": password}) - self.loop.run_until_complete(self.start(api_tfmid, api_token, **kwargs)) - finally: - self.loop.run_until_complete(self.loop.shutdown_asyncgens()) - self.loop.close() - - def close(self): - """Closes the sockets.""" - if self._closed: - return - - self._closed = True - self._close_event.set_result(("stop", 0, None)) - - async def sendCP(self, code: int, data: Union[Packet, ByteString] = b"") -> int: - """|coro| - Send a packet to the community platform. - - :param code: :class:`int` the community platform code. - :param data: :class:`aiotfm.Packet` or :class:`bytes` the data. - :return: :class:`int` returns the sequence id. - """ - self._sequenceId = sid = (self._sequenceId + 1) % 0xFFFFFFFF - - packet = Packet.new(60, 3).write16(code) - packet.write32(self._sequenceId).writeBytes(data) - await self.main.send(packet, cipher=True) - - return sid - - async def sendRoomMessage(self, message: str): - """|coro| - Send a message to the room. - - :param message: :class:`str` the content of the message. - """ - packet = Packet.new(6, 6).writeString(message) - - await self.bulle.send(packet, cipher=True) - - async def sendTribeMessage(self, message: str): - """|coro| - Send a message to the tribe. - - :param message: :class:`str` the content of the message. - """ - await self.sendCP(50, Packet().writeString(message)) - - async def sendChannelMessage(self, channel: Union[Channel, str], message: str): - """|coro| - Send a message to a public channel. - - :param channel: :class:`str` the channel's name. - :param message: :class:`str` the content of the message. - """ - if isinstance(channel, Channel): - channel = channel.name - - return await self.sendCP(48, Packet().writeString(channel).writeString(message)) - - async def whisper(self, username: Union[Player, str], message: AnyStr, overflow: bool = False): - """|coro| - Whisper to a player. - - :param username: :class:`str` the player to whisper. - :param message: :class:`str` the content of the whisper. - :param overflow: :class:`bool` will send the complete message if True, splitted - in several messages. - """ - if isinstance(username, Player): - username = username.username - - async def send(msg): - await self.sendCP(52, Packet().writeString(username).writeString(msg)) - - if isinstance(message, str): - message = message.encode() - message = message.replace(b"<", b"<").replace(b">", b">") - - await send(message[:255]) - for i in range(255, len(message), 255): - await asyncio.sleep(1) - await self.whisper(username, message[i : i + 255]) - - async def getTribe(self, disconnected: bool = True) -> Optional[Tribe]: - """|coro| - Gets the client's :class:`aiotfm.Tribe` and return it - - :param disconnected: :class:`bool` if True retrieves also the disconnected members. - :return: :class:`aiotfm.Tribe` or ``None``. - """ - sid = await self.sendCP(108, Packet().writeBool(disconnected)) - - def is_tribe(tc, packet): - return (tc == 109 and packet.read32() == sid) or tc == 130 - - tc, packet = await self.wait_for("on_raw_cp", is_tribe, timeout=5) - if tc == 109: - result = packet.read8() - if result == 1: - tc, packet = await self.wait_for("on_raw_cp", lambda tc, p: tc == 130, timeout=5) - elif result == 17: - return None - else: - raise CommunityPlatformError(118, result) - return Tribe(packet) - - async def getRoomList(self, gamemode: Union[GameMode, int] = 0, timeout: float = 3) -> Optional[RoomList]: - """|coro| - Get the room list - - :param gamemode: Optional[:class:`aiotfm.enums.GameMode`] the room's gamemode. - :param timeout: Optional[:class:`int`] timeout in seconds. Defaults to 3 seconds. - :return: :class:`aiotfm.room.RoomList` the room list for the given gamemode or None - """ - await self.main.send(Packet.new(26, 35).write8(int(gamemode))) - - def predicate(roomlist): - return gamemode == 0 or roomlist.gamemode == gamemode - - try: - return await self.wait_for("on_room_list", predicate, timeout=timeout) - except asyncio.TimeoutError: - return None - - async def playEmote(self, emote: int, flag: str = "be"): - """|coro| - Play an emote. - - :param emote: :class:`int` the emote's id. - :param flag: Optional[:class:`str`] the flag for the emote id 10. Defaults to 'be'. - """ - packet = Packet.new(8, 1).write8(emote).write32(0) - if emote == 10: - packet.writeString(flag) - - await self.bulle.send(packet) - - async def sendSmiley(self, smiley: int): - """|coro| - Makes the client showing a smiley above it's head. - - :param smiley: :class:`int` the smiley's id. (from 0 to 9) - """ - if smiley < 0 or smiley > 9: - raise AiotfmException("Invalid smiley id") - - packet = Packet.new(8, 5).write8(smiley) - - await self.bulle.send(packet) - - async def loadLua(self, lua_code: AnyStr): - """|coro| - Load a lua code in the room. - - :param lua_code: :class:`str` or :class:`bytes` the lua code to send. - """ - if isinstance(lua_code, str): - lua_code = lua_code.encode() - - await self.bulle.send(Packet.new(29, 1).write24(len(lua_code)).writeBytes(lua_code)) - - async def sendCommand(self, command: str): - """|coro| - Send a command to the game. - - :param command: :class:`str` the command to send. - """ - await self.main.send(Packet.new(6, 26).writeString(command[:255]), cipher=True) - - async def enterTribe(self): - """|coro| - Enter the tribe house - """ - await self.main.send(Packet.new(16, 1)) - - async def enterTribeHouse(self): - """|coro| - Alias for :meth:`Client.enterTribe` - """ - await self.enterTribe() - - async def enterInvTribeHouse(self, author: str): - """|coro| - Join the tribe house of another player after receiving an /inv. - - :param author: :class:`str` the author's username who sent the invitation. - """ - await self.main.send(Packet.new(16, 2).writeString(author)) - - async def recruit(self, username: Union[Player, str]): - """|coro| - Send a recruit request to a player. - - :param username: :class:`str` the player's username you want to recruit. - """ - if isinstance(username, Player): - username = username.username - await self.sendCP(78, Packet().writeString(username)) - - async def joinRoom( - self, - room_name: str, - password: Optional[str] = None, - community: Optional[int] = None, - auto: bool = False, - ): - """|coro| - Join a room. - The event 'on_joined_room' is dispatched when the client has successfully joined the room. - - :param password: :class:`str` if given the client will ignore `community` and `auto` parameters - and will connect to the room with the given password. - :param room_name: :class:`str` the room's name. - :param community: Optional[:class:`int`] the room's community. - :param auto: Optional[:class:`bool`] joins a random room (I think). - """ - if password is not None: - packet = Packet.new(5, 39).writeString(password).writeString(room_name) - else: - packet = Packet.new(5, 38).writeString(Community(community or self.community).name) - packet.writeString(room_name).writeBool(auto) - - await self.main.send(packet) - - async def joinChannel(self, name: str, permanent: bool = True): - """|coro| - Join a #channel. - The event 'on_channel_joined' is dispatched when the client has successfully joined - a channel. - - :param name: :class:`str` the channel's name - :param permanent: Optional[:class:`bool`] if True (default) the server will automatically - reconnect the user to this channel when logged in. - """ - await self.sendCP(54, Packet().writeString(name).writeBool(permanent)) - - async def leaveChannel(self, channel: Union[Channel, str]): - """|coro| - Leaves a #channel. - - :param channel: :class:`aiotfm.message.Channel` channel to leave. - """ - if isinstance(channel, Channel): - name = channel.name - else: - name = channel - - await self.sendCP(56, Packet().writeString(name)) - - async def requestShopList(self): - """|coro| - Send a request to the server to get the shop list.""" - await self.main.send(Packet.new(8, 20)) - - async def startTrade(self, player: Union[Player, str]) -> Trade: - """|coro| - Starts a trade with the given player. - - :param player: :class:`aiotfm.Player` the player to trade with. - :return: :class:`aiotfm.inventory.Trade` the resulting trade""" - if isinstance(player, Player) and player.pid == -1: - player = player.username - - if isinstance(player, str): - player = self.room.get_player(username=player) - if player is None: - raise AiotfmException("The player must be in your room to start a trade.") - - trade = Trade(self, player) - - self.trades[player.pid] = trade - await trade.accept() - return trade - - async def requestInventory(self): - """|coro| - Send a request to the server to get the bot's inventory.""" - await self.main.send(Packet.new(31, 1)) +import asyncio +import logging +import random +import warnings +from typing import AnyStr, ByteString, Callable, List, Optional, Union + +from aiotfm.connection import Connection +from aiotfm.enums import Community, GameMode, TradeError +from aiotfm.errors import ( + AiotfmException, + AlreadyConnected, + CommunityPlatformError, + IncorrectPassword, + InvalidEvent, + LoginError, + MaintenanceError, + ServerUnreachable, +) +from aiotfm.friend import Friend, FriendList +from aiotfm.inventory import Inventory, InventoryItem, Trade +from aiotfm.message import Channel, ChannelMessage, Message, Whisper +from aiotfm.packet import Packet +from aiotfm.player import Player, Profile +from aiotfm.room import Room, RoomList +from aiotfm.shop import Shop +from aiotfm.tribe import Tribe +from aiotfm.utils import Keys, Locale, get_ip, get_keys, shakikoo + +logger = logging.getLogger("aiotfm") + + +class Client: + """Represents a client that connects to Transformice. + Two argument can be passed to the :class:`Client`. + + .. _event loop: https://docs.python.org/3/library/asyncio-eventloops.html + + Parameters + ---------- + community: Optional[:class:`int`] + Defines the community of the client. Defaults to 0 (EN community). + auto_restart: Optional[:class:`bool`] + Whether the client should automatically restart on error. Defaults to False. + bot_role: Optional[:class:`bool`] + Whether the has the game's special role bot or not. + Avoids using the api endpoint and gives more stability. + loop: Optional[event loop] + The `event loop`_ to use for asynchronous operations. If ``None`` is passed (defaults), + the event loop used will be ``asyncio.get_event_loop()``. + + max_retries: Optional[:class:`int`] + The maximum number of retries the client should attempt while connecting to the game. + + Attributes + ---------- + username: Optional[:class:`str`] + The bot's username received from the server. Might be None if the bot didn't log in yet. + room: Optional[:class:`aiotfm.room.Room`] + The bot's room. Might be None if the bot didn't log in yet or couldn't join any room yet. + trade: Optional[:class:`aiotfm.inventory.Trade`] + The current trade that's going on (i.e: both traders accepted it). + trades: :class:`list`[:class:`aiotfm.inventory.Trade`] + All the trades that the bot participates. Most of them might be invitations only. + inventory: Optional[:class:`aiotfm.inventory.Inventory`] + The bot's inventory. Might be None if the bot didn't log in yet or it didn't receive + anything. + locale: :class:`aiotfm.locale.Locale` + The bot's locale (translations). + friends: Optional[:class:`aiotfm.friends.FriendList`] + The bot's friend list + """ + + LOG_UNHANDLED_PACKETS = False + + def __init__( + self, + community: Union[Community, int] = Community.en, + auto_restart: bool = False, + bot_role: bool = False, + loop: Optional[asyncio.AbstractEventLoop] = None, + max_retries: int = 6, + ): + self.loop: asyncio.AbstractEventLoop = loop or asyncio.get_event_loop() + + self.main: Connection = Connection("main", self, self.loop) + self.bulle: Connection = None + + self._waiters: dict = {} + self._close_event: asyncio.Future = None + self._sequenceId: int = 0 + self._channels: List[Channel] = [] + self._restarting: bool = False + self._closed: bool = False + self._logged: bool = False + self._max_retries: int = max_retries + + self.room: Room = None + self.trade: Trade = None + self.trades: dict = {} + self.inventory: Inventory = None + + self.username: str = None + self.locale: Locale = Locale() + self.community: Community = Community(community) + + self.friends: FriendList = None + + self.keys: Keys = None + self.authkey: int = 0 + + self.auto_restart: bool = auto_restart + self.api_tfmid: int = None + self.api_token: str = None + self.bot_role: bool = bot_role + + @property + def restarting(self) -> bool: + return self._restarting + + @property + def closed(self) -> bool: + return self._closed + + def _backoff(self, n: int) -> float: + """Returns the numbers of seconds to wait until the n-th connection attempt. Capped at 10 minutes.""" + return random.uniform(20, 30 * 2 ** min(n, 5)) + + def data_received(self, data: bytes, connection: Connection): + """|coro| + Dispatches the received data. + + :param data: :class:`bytes` the received data. + :param connection: :class:`aiotfm.Connection` the connection that received + the data. + """ + # :desc: Called when a socket receives a packet. Does not interfere + # with :meth:`Client.handle_packet`. + # :param connection: :class:`aiotfm.Connection` the connection that received + # the packet. + # :param packet: :class:`aiotfm.Packet` a copy of the packet. + self.dispatch("raw_socket", connection, Packet(data)) + self.loop.create_task(self.handle_packet(connection, Packet(data))) + + async def handle_packet(self, connection: Connection, packet: Packet) -> bool: + """|coro| + Handles the known packets and dispatches events. + Subclasses should handle only the unhandled packets from this method. + + Example: :: + class Bot(aiotfm.Client): + async def handle_packet(self, conn, packet): + handled = await super().handle_packet(conn, packet.copy()) + + if not handled: + # Handle here the unhandled packets. + pass + + :param connection: :class:`aiotfm.Connection` the connection that received + the packet. + :param packet: :class:`aiotfm.Packet` the packet. + :return: True if the packet got handled, False otherwise. + """ + CCC = packet.readCode() + if CCC == (1, 1): # Old packets + oldCCC, *data = packet.readString().split(b"\x01") + data = list(map(bytes.decode, data)) + oldCCC = tuple(oldCCC[:2]) + + # :desc: Called when an old packet is received. Does not interfere + # with :meth:`Client.handle_old_packet`. + # :param connection: :class:`aiotfm.Connection` the connection that received + # the packet. + # :param oldCCC: :class:`tuple` the packet identifiers on the old protocol. + # :param data: :class:`list` the packet data. + self.dispatch("old_packet", connection, oldCCC, data) + return await self.handle_old_packet(connection, oldCCC, data) + + if CCC == (5, 21): # Joined room + self.room = Room(official=packet.readBool(), name=packet.readUTF()) + + # :desc: Called when the client has joined a room. + # :param room: :class:`aiotfm.room.Room` the room the client has entered. + self.dispatch("joined_room", self.room) + + elif CCC == (5, 39): # Password required for the room + # :desc: Called when a password is required to enter a room + # :param room: :class:`aiotfm.room.Room` the room the server is asking for a password. + self.dispatch("room_password", Room(packet.readUTF())) + + elif CCC == (6, 6): # Room message + username = packet.readUTF() + message = packet.readUTF() + player = self.room.get_player(username=username) + + if player is None: + player = Player(username) + + # :desc: Called when the client receives a message from the room. + # :param message: :class:`aiotfm.message.Message` the message. + self.dispatch("room_message", Message(player, message, self)) + + elif CCC == (6, 20): # Server message + packet.readBool() # if False then the message will appear in the #Server channel + t_key = packet.readUTF() + t_args = [packet.readUTF() for i in range(packet.read8())] + + # :desc: Called when the client receives a message from the server that needs to be translated. + # :param message: :class:`aiotfm.locale.Translation` the message translated with the + # current locale. + # :param *args: a list of string used as replacement inside the message. + self.dispatch("server_message", self.locale[t_key], *t_args) + + elif CCC == (8, 1): # Play emote + player = self.room.get_player(pid=packet.read32()) + emote = packet.read8() + flag = packet.readUTF() if emote == 10 else "" + + # :desc: Called when a player plays an emote. + # :param player: :class:`aiotfm.Player` the player. + # :param emote: :class:`int` the emote's id. + # :param flag: :class:`str` the flag's id. + self.dispatch("emote", player, emote, flag) + + elif CCC == (8, 5): # Show emoji + player = self.room.get_player(pid=packet.read32()) + emoji = packet.read8() + + # :desc: Called when a player is showing an emoji above its head. + # :param player: :class:`aiotfm.Player` the player. + # :param emoji: :class:`int` the emoji's id. + self.dispatch("emoji", player, emoji) + + elif CCC == (8, 6): # Player won + packet.read8() + player = self.room.get_player(pid=packet.read32()) + player.score = packet.read16() + order = packet.read8() + player_time = packet.read16() / 100 + + # :desc: Called when a player get the cheese to the hole. + # :param player: :class:`aiotfm.Player` the player. + # :param order: :class:`int` the order of the player in the hole. + # :param player_time: :class:`float` player's time in the hole in seconds. + self.dispatch("player_won", player, order, player_time) + + elif CCC == (8, 16): # Profile + # :desc: Called when the client receives the result of a /profile command. + # :param profile: :class:`aiotfm.player.Profile` the profile. + self.dispatch("profile", Profile(packet)) + + elif CCC == (8, 20): # Shop + # :desc: Called when the client receives the content of the shop. + # :param shop: :class:`aiotfm.shop.Shop` the shop. + self.dispatch("shop", Shop(packet)) + + elif CCC == (8, 22): # Skills + skills = {} + for _ in range(packet.read8()): + key, value = packet.read8(), packet.read8() + skills[key] = value + + # :desc: Called when the client receives its skill tree. + # :param skills: :class:`dict` the skills. + self.dispatch("skills", skills) + + elif CCC == (16, 2): # Tribe invitation received + author = packet.readUTF() + tribe = packet.readUTF() + + # :desc: Called when the client receives an invitation to a tribe. (/inv) + # :param author: :class:`str` the player that invited you. + # :param tribe: :class:`str` the tribe. + self.dispatch("tribe_inv", author, tribe) + + elif CCC == (26, 2): # Logged in successfully + player_id = packet.read32() + self.username = username = packet.readUTF() + played_time = packet.read32() + community = Community(packet.read8()) + pid = packet.read32() + + # :desc: Called when the client successfully logged in. + # :param uid: :class:`int` the client's unique id. + # :param username: :class:`str` the client's username. + # :param played_time: :class:`int` the total number of minutes the client has played. + # :param community: :class:`aiotfm.enums.Community` the community the client has connected to. + # :param pid: :class:`int` the client's player id. + self.dispatch("logged", player_id, username, played_time, community, pid) + + elif CCC == (26, 3): # Handshake OK + online_players = packet.read32() + language = packet.readUTF() + country = packet.readUTF() + self.authkey = packet.read32() + self._logged = False + + os_info = Packet.new(28, 17).writeString("en").writeString("Linux") + os_info.writeString("LNX 29,0,0,140").write8(0) + + await connection.send(os_info) + + # :desc: Called when the client can login through the game. + # :param online_players: :class:`int` the number of player connected to the game. + # :param language: :class:`str` the language the server is suggesting. + # :param country: :class:`str` the country detected from your ip. + self.dispatch("login_ready", online_players, language, country) + + elif CCC == (26, 12): # Login result + self._logged = False + # :desc: Called when the client failed logging. + # :param code: :class:`int` the error code. + # :param error1: :class:`str` error messages. + # :param error2: :class:`str` error messages. + self.dispatch("login_result", packet.read8(), packet.readUTF(), packet.readUTF()) + + elif CCC == (26, 25): # Ping + # :desc: Called when the client receives the ping response from the server. + self.dispatch("ping") + + elif CCC == (26, 35): # Room list + roomlist = RoomList.from_packet(packet) + # :desc: Dispatched when the client receives the room list + self.dispatch("room_list", roomlist) + + elif CCC == (28, 6): # Server ping + await connection.send(Packet.new(28, 6).write8(packet.read8())) + + elif CCC == (29, 6): # Lua logs + # :desc: Called when the client receives lua logs from #Lua. + # :param log: :class:`str` a log message. + self.dispatch("lua_log", packet.readUTF()) + + elif CCC == (31, 1): # Inventory data + self.inventory = Inventory.from_packet(packet) + self.inventory.client = self + + # :desc: Called when the client receives its inventory's content. + # :param inventory: :class:`aiotfm.inventory.Inventory` the client's inventory. + self.dispatch("inventory_update", self.inventory) + + elif CCC == (31, 2): # Update inventory item + item_id = packet.read16() + quantity = packet.read8() + + if item_id in self.inventory.items: + item = self.inventory.items[item_id] + previous = item.quantity + item.quantity = quantity + + # :desc: Called when the quantity of an item has been updated. + # :param item: :class:`aiotfm.inventory.InventoryItem` the new item. + # :param previous: :class:`int` the previous quantity. + self.dispatch("item_update", item, previous) + + else: + item = InventoryItem(item_id=item_id, quantity=quantity) + self.inventory.items[item.id] = item + + # :desc: Called when the client receives a new item in its inventory. + # :param item: :class:`aiotfm.inventory.InventoryItem` the new item. + self.dispatch("new_item", item) + + elif CCC == (31, 5): # Trade invite + pid = packet.read32() + + self.trades[pid] = Trade(self, self.room.get_player(pid=pid)) + + # :desc: Called when received an invitation to trade. + # :param trade: :class:`aiotfm.inventory.Trade` the trade object. + self.dispatch("trade_invite", self.trades[pid]) + + elif CCC == (31, 6): # Trade error + name = packet.readUTF().lower() + error = packet.read8() + + if name == self.username.lower(): + trade = self.trade + else: + for t in self.trades.values(): + if t.trader.lower() == name: + trade = t + break + + # :desc: Called when an error occurred with a trade. + # :param trade: :class:`aiotfm.inventory.Trade` the trade that failed. + # :param error: :class:`aiotfm.enums.TradeError` the error. + self.dispatch("trade_error", trade, TradeError(error)) + trade._close() + + elif CCC == (31, 7): # Trade start + pid = packet.read32() + trade = self.trades.get(pid) + + if trade is None: + raise AiotfmException(f"Cannot find the trade from pid {pid}.") + + trade._start() + self.trade = trade + + # :desc: Called when a trade starts. You can access the trade object with `Client.trade`. + self.dispatch("trade_start") + + elif CCC == (31, 8): # Trade items + export = packet.readBool() + id_ = packet.read16() + quantity = (1 if packet.readBool() else -1) * packet.read8() + + items = self.trade.exports if export else self.trade.imports + items.add(id_, quantity) + + trader = self if export else self.trade.trader + self.trade.locked = [False, False] + + # :desc: Called when an item has been added/removed from the current trade. + # :param trader: :class:`aiotfm.Player` the player that triggered the event. + # :param id: :class:`int` the item's id. + # :param quantity: :class:`int` the quantity added/removed. Can be negative. + # :param item: :class:`aiotfm.inventory.InventoryItem` the item after the change. + self.dispatch("trade_item_change", trader, id_, quantity, items.get(id_)) + + elif CCC == (31, 9): # Trade lock + index = packet.read8() + locked = packet.readBool() + if index > 1: + self.trade.locked = [locked, locked] + who = "both" + else: + self.trade.locked[index] = locked + who = self.trade.trader if index == 0 else self + + # :desc: Called when the trade got (un)locked. + # :param who: :class:`aiotfm.Player` the player that triggered the event. + # :param locked: :class:`bool` either the trade got locked or unlocked. + self.dispatch("trade_lock", who, locked) + + elif CCC == (31, 10): # Trade complete + trade = self.trade + self.trade._close(succeed=True) + + elif CCC == (44, 1): # Bulle switching + timestamp = packet.read32() + uid = packet.read32() + pid = packet.read32() + bulle_ip = packet.readUTF() + ports = packet.readUTF().split("-") + + if self.bulle is not None: + self.bulle.close() + + self.bulle = Connection("bulle", self, self.loop) + await self.bulle.connect(bulle_ip, int(random.choice(ports))) + await self.bulle.send(Packet.new(44, 1).write32(timestamp).write32(uid).write32(pid)) + + elif CCC == (44, 22): # Fingerprint offset changed + connection.fingerprint = packet.read8() + + elif CCC == (60, 3): # Community platform + TC = packet.read16() + + # :desc: Called when the client receives a packet from the community platform. + # :param TC: :class:`int` the packet's code. + # :param packet: :class:`aiotfm.Packet` the packet. + self.dispatch("raw_cp", TC, packet.copy(copy_pos=True)) + + if TC == 3: # Connected to the community platform + await self.sendCP(28) # Request friend list + + # :desc: Called when the client is successfully connected to the community platform. + self.dispatch("ready") + + elif TC == 32: # Friend connected + if self.friends is None: + return True + + friend = self.friends.get_friend(packet.readUTF()) + friend.isConnected = True + + # :desc: Called when a friend connects to the game (not entirely fetched) + # :param friend: :class:`aiotfm.friend.Friend` friend after this update + self.dispatch("friend_connected", friend) + + elif TC == 33: # Friend disconnected + if self.friends is None: + return True + + friend = self.friends.get_friend(packet.readUTF()) + friend.isConnected = False + + # :desc: Called when a friend disconnects from the game (not entirely fetched) + # :param friend: :class:`aiotfm.friend.Friend` friend after this update + self.dispatch("friend_disconnected", friend) + + elif TC == 34: # Friend list loaded + self.friends = FriendList(self, packet) + + # :desc: Called when the friend list is loaded. + # :param friends: :class:`aiotfm.friend.FriendList` the friend list + self.dispatch("friends_loaded", self.friends) + + elif TC == 35 or TC == 36: # Friend update / addition + if self.friends is None: + return True + + new = Friend(self.friends, packet) + old = self.friends.get_friend(new.name) + + if old is not None: + if old.isSoulmate: # Not sent by the server, checked locally. + self.friends.soulmate = new + new.isSoulmate = True + + self.friends.friends.remove(old) + self.friends.friends.append(new) + + if old is None: + # :desc: Called when a friend is added + # :param friend: :class:`aiotfm.friend.Friend` the friend + self.dispatch("new_friend", new) + + else: + # :desc: Called when a friend is updated + # :param before: :class:`aiotfm.friend.Friend` friend before this update + # :param after: :class:`aiotfm.friend.Friend` friend after this update + self.dispatch("friend_update", old, new) + + elif TC == 37: # Remove friend + if self.friends is None: + return True + + friend = self.friends.get_friend(packet.read32()) + if friend is not None: + if friend == self.friends.soulmate: + self.friends.soulmate = None + + self.friends.friends.remove(friend) + + # :desc: Called when a friend is removed + # :param friend: :class:`aiotfm.friend.Friend` the friend + self.dispatch("friend_remove", friend) + + elif TC == 55: # Channel join result + sequenceId = packet.read32() + result = packet.read8() + + # :desc: Called when the client receives the result of joining a channel. + # :param sequenceId: :class:`int` identifier returned by :meth:`Client.sendCP`. + # :param result: :class:`int` result code. + self.dispatch("channel_joined_result", sequenceId, result) + + elif TC == 57: # Channel leave result + sequenceId = packet.read32() + result = packet.read8() + + # :desc: Called when the client receives the result of leaving a channel. + # :param sequenceId: :class:`int` identifier returned by :meth:`Client.sendCP`. + # :param result: :class:`int` result code. + self.dispatch("channel_left_result", sequenceId, result) + + elif TC == 59: # Channel /who result + idSequence = packet.read32() + result = packet.read8() + players = [Player(packet.readUTF()) for _ in range(packet.read16())] + + # :desc: Called when the client receives the result of the /who command in a channel. + # :param idSequence: :class:`int` the reference to the packet that performed the request. + # :param players: List[:class:`aiotfm.Player`] the list of players inside the channel. + self.dispatch("channel_who", idSequence, players) + + elif TC == 62: # Joined a channel + name = packet.readUTF() + + if name in self._channels: + channel = [c for c in self._channels if c == name][0] + else: + channel = Channel(name, self) + self._channels.append(channel) + + # :desc: Called when the client joined a channel. + # :param channel: :class:`aiotfm.message.Channel` the channel. + self.dispatch("channel_joined", channel) + + elif TC == 63: # Quit a channel + name = packet.readUTF() + if name in self._channels: + self._channels.remove(name) + + # :desc: Called when the client leaves a channel. + # :param name: :class:`str` the channel's name. + self.dispatch("channel_closed", name) + + elif TC == 64: # Channel message + username, community = packet.readUTF(), packet.read32() + channel_name, message = packet.readUTF(), packet.readUTF() + channel = self.get_channel(channel_name) + author = self.room.get_player(username=username) + + if author is None: + author = Player(username) + + if channel is None: + channel = Channel(channel_name, self) + self._channels.append(channel) + + channel_message = ChannelMessage(author, community, message, channel) + + # :desc: Called when the client receives a message from a channel. + # :param message: :class:`aiotfm.message.ChannelMessage` the message. + self.dispatch("channel_message", channel_message) + + elif TC == 65: # Tribe message + author, message = packet.readUTF(), packet.readUTF() + + # :desc: Called when the client receives a message from the tribe. + # :param author: :class:`str` the message's author. + # :param message: :class:`str` the message's content. + self.dispatch("tribe_message", author, message) + + elif TC == 66: # Whisper + author = Player(packet.readUTF()) + commu = packet.read32() + receiver = Player(packet.readUTF()) + message = packet.readUTF() + + author = self.room.get_player(name=author, default=author) + receiver = self.room.get_player(name=receiver, default=receiver) + + # :desc: Called when the client receives a whisper. + # :param message: :class:`aiotfm.message.Whisper` the message. + self.dispatch("whisper", Whisper(author, commu, receiver, message, self)) + + elif TC == 88: # tribe member connected + # :desc: Called when a tribe member connected. + # :param name: :class:`str` the member's name. + self.dispatch("member_connected", packet.readUTF()) + + elif TC == 90: # tribe member disconnected + # :desc: Called when a tribe member disconnected. + # :param name: :class:`str` the member's name. + self.dispatch("member_disconnected", packet.readUTF()) + + else: + if self.LOG_UNHANDLED_PACKETS: + print(CCC, TC, bytes(packet.buffer)[4:]) + return False + + elif CCC == (144, 1): # Set player list + before = self.room.players + self.room.players = {} + + for _ in range(packet.read16()): + player = Player.from_packet(packet) + self.room.players[player.pid] = player + + # :desc: Called when the client receives an update of all player in the room. + # :param before: Dict[:class:`aiotfm.Player`] the list of player before the update. + # :param players: Dict[:class:`aiotfm.Player`] the list of player updated. + self.dispatch("bulk_player_update", before, self.room.players) + + elif CCC == (144, 2): # Add a player + after = Player.from_packet(packet) + before = self.room.players.pop(after.pid, None) + + self.room.players[after.pid] = after + if before is None: + # :desc: Called when a player joined the room. + # :param player: :class:`aiotfm.Player` the player. + self.dispatch("player_join", after) + else: + # :desc: Called when a player's data on the room has been updated. + # :param before: :class:`aiotfm.Player` the player before the update. + # :param player: :class:`aiotfm.Player` the player updated. + self.dispatch("player_update", before, after) + + else: + if self.LOG_UNHANDLED_PACKETS: + print(CCC, bytes(packet.buffer)[2:]) + return False + + return True + + async def handle_old_packet(self, connection: Connection, oldCCC: tuple, data: list) -> bool: + """|coro| + Handles the known packets from the old protocol and dispatches events. + Subclasses should handle only the unhandled packets from this method. + + Example: :: + class Bot(aiotfm.Client): + async def handle_old_packet(self, conn, oldCCC, data): + handled = await super().handle_old_packet(conn, data.copy()) + + if not handled: + # Handle here the unhandled packets. + pass + + :param connection: :class:`aiotfm.Connection` the connection that received + the packet. + :param oldCCC: :class:`tuple` the packet identifiers on the old protocol. + :param data: :class:`list` the packet data. + :return: True if the packet got handled, False otherwise. + """ + if oldCCC == (8, 5): # Player died + player = self.room.get_player(pid=data[0]) + if player is not None: + player.score = int(data[2]) + + # :desc: Called when a player dies. + # :param player: :class:`aiotfm.Player` the player. + self.dispatch("player_died", player) + + elif oldCCC == (8, 7): # Remove a player + player = self.room.players.pop(int(data[0]), None) + + if player is not None: + # :desc: Called when a player leaves the room. + # :param player: :class:`aiotfm.Player` the player. + self.dispatch("player_remove", player) + + else: + if self.LOG_UNHANDLED_PACKETS: + print("[OLD]", oldCCC, data) + return False + + return True + + def get_channel(self, name: str) -> Optional[Channel]: + """Returns a channel from it's name or None if not found. + :param name: :class:`str` the name of the channel. + :return: :class:`aiotfm.message.ChannelMessage` or None + """ + if name is None: + return None + + for channel in self._channels: + if channel.name == name: + return channel + + def get_trade(self, player: Union[str, Player]) -> Optional[Trade]: + """Returns the pending/current trade with a player. + :param player: :class:`aiotfm.Player` or :class:`str` the player. + :return: :class:`aiotfm.inventory.Trade` the trade with the player. + """ + if not isinstance(player, (str, Player)): + raise TypeError(f"Expected Player or str types got {type(player)}") + + if isinstance(player, Player): + return self.trades.get(player.pid) + + player = player.lower() + for trade in self.trades.values(): + if trade.trader.lower() == player: + return trade + + def event(self, coro: Callable) -> Callable: + """A decorator that registers an event. + + More about events [here](Events.md). + """ + name = coro.__name__ + if not name.startswith("on_"): + raise InvalidEvent(f"{name!r} isn't a correct event naming.") + if not asyncio.iscoroutinefunction(coro): + message = f"Couldn't register a non-coroutine function for the event {name}." + raise InvalidEvent(message) + + setattr(self, name, coro) + return coro + + def wait_for( + self, + event: str, + condition: Optional[Callable] = None, + timeout: Optional[float] = None, + stopPropagation: bool = False, + ) -> asyncio.Future: + """Wait for an event. + + Example: :: + @client.event + async def on_room_message(author, message): + if message == 'id': + await client.sendCommand(f'profile {author.username}') + profile = await client.wait_for('on_profile', lambda p: p.username == author) + await client.sendRoomMessage('Your id: {}'.format(profile.id)) + + :param event: :class:`str` the event name. + :param condition: Optionnal[`function`] A predicate to check what to wait for. + The arguments must meet the parameters of the event being waited for. + :param timeout: Optionnal[:class:`float`] the number of seconds before + throwing asyncio.TimeoutError + :return: [`asyncio.Future`](https://docs.python.org/3/library/asyncio-future.html#asyncio.Future) + a future that you must await. + """ + event = event.lower() + future = self.loop.create_future() + + if condition is None: + + def everything(*a): + return True + + condition = everything + + if event not in self._waiters: + self._waiters[event] = [] + + self._waiters[event].append((condition, future, stopPropagation)) + + return asyncio.wait_for(future, timeout) + + async def _run_event(self, coro: Callable, event_name: str, *args, **kwargs): + """|coro| + Runs an event and handle the error if any. + + :param coro: a coroutine function. + :param event_name: :class:`str` the event's name. + :param args: arguments to pass to the coro. + :param kwargs: keyword arguments to pass to the coro. + + :return: :class:`bool` whether the event ran successfully or not + """ + try: + await coro(*args, **kwargs) + return True + # except asyncio.CancelledError: + # raise + except Exception as e: + if hasattr(self, "on_error"): + try: + await self.on_error(event_name, e, *args, **kwargs) + # except asyncio.CancelledError: + # raise + except Exception: + if self.auto_restart: + await self.restart(5) + else: + self.close() + + return False + + def dispatch(self, event: str, *args, **kwargs): + """Dispatches events + + :param event: :class:`str` event's name. (without 'on_') + :param args: arguments to pass to the coro. + :param kwargs: keyword arguments to pass to the coro. + + :return: [`Task`](https://docs.python.org/3/library/asyncio-task.html#asyncio.Task) + the _run_event wrapper task + """ + method = "on_" + event + + if method in self._waiters: + to_remove = [] + waiters = self._waiters[method] + for i, (cond, fut, stop) in enumerate(waiters): + if fut.cancelled(): + to_remove.append(i) + continue + + try: + result = bool(cond(*args)) + except Exception as e: + fut.set_exception(e) + else: + if result: + fut.set_result(args[0] if len(args) == 1 else args if len(args) > 0 else None) + if stop: + del waiters[i] + return None + to_remove.append(i) + + if len(to_remove) == len(waiters): + del self._waiters[method] + else: + for i in to_remove[::-1]: + del waiters[i] + + coro = getattr(self, method, None) + if coro is not None: + dispatch = self._run_event(coro, method, *args, **kwargs) + return asyncio.ensure_future(dispatch, loop=self.loop) + + async def on_error(self, event: str, err: Exception, *a, **kw): + """Default on_error event handler. Prints the traceback of the error.""" + logger.error('An error occurred while dispatching the event "%s":', event, exc_info=-3) + + async def on_connection_error(self, conn: Connection, error: Exception): + """Default on_connection_error event handler. Prints the error.""" + logger.error("The %s connection has been closed.", conn.name, exc_info=error) + + async def on_login_result(self, code: int, *args): + """Default on_login_result handler. Raise an error and closes the connection.""" + self.loop.call_later(3, self.close) + if code == 1: + raise AlreadyConnected() + if code == 2: + raise IncorrectPassword() + raise LoginError(code) + + async def _connect(self): + """|coro| + Creates a connection with the main server. + """ + if self._close_event is None: + raise AiotfmException(f"{self._connect.__name__} should not be called directly. Use start() instead.") + + for port in random.sample(self.keys.server_ports, 4): + try: + await self.main.connect(self.keys.server_ip, port) + except Exception: + logger.debug("Unable to connect to the server %s:%s.", self.keys.server_ip, port) + else: + break + else: + raise ServerUnreachable("Unable to connect to the server.") + + while not self.main.open: + await asyncio.sleep(0) + + async def sendHandshake(self): + """|coro| + Sends the handshake packet so the server recognizes this socket as a player. + """ + packet = Packet.new(28, 1).write16(self.keys.version) + if not self.bot_role: + packet.writeString("en").writeString(self.keys.connection) + + packet.writeString("Desktop").writeString("-").write32(0x1FBD).writeString("") + packet.writeString("74696720697320676f6e6e61206b696c6c206d7920626f742e20736f20736164") + packet.writeString( + "A=t&SA=t&SV=t&EV=t&MP3=t&AE=t&VE=t&ACC=t&PR=t&SP=f&SB=f&DEB=f&V=LNX 32,0,0,182&M=Adobe" + " Linux&R=1920x1080&COL=color&AR=1.0&OS=Linux&ARCH=x86&L=en&IME=t&PR32=t&PR64=t&LS=en-U" + "S&PT=Desktop&AVD=f&LFD=f&WD=f&TLS=t&ML=5.1&DP=72" + ) + packet.write32(0).write32(0x6257).writeString("") + + await self.main.send(packet) + + async def start( + self, + api_tfmid: Optional[int] = None, + api_token: Optional[str] = None, + keys: Optional[Keys] = None, + **kwargs, + ): + """|coro| + Starts the client. + + :param api_tfmid: Optional[:class:`int`] your Transformice id. + :param api_token: Optional[:class:`str`] your token to access the API. + """ + if self.bot_role: + self.keys = keys or await get_ip() + else: + if self.auto_restart and api_tfmid is None or api_token is None: + warnings.warn("The api token were not provided. The Client won't be able to restart.", stacklevel=2) + self.auto_restart = False + + self.keys = keys + if keys is None: + self.keys = await get_keys(api_tfmid, api_token) + + if "username" in kwargs and "password" in kwargs: + # Monkey patch the on_login_ready event + if hasattr(self, "on_login_ready"): + event = self.on_login_ready + self.on_login_ready = lambda *a: asyncio.gather(self.login(**kwargs), event(*a)) + else: + self.on_login_ready = lambda *a: self.login(**kwargs) + + retries = 0 + on_started = None + keep_alive = Packet.new(26, 26) + while True: + self._close_event = asyncio.Future() + try: + logger.info("Connecting to the game.") + await self._connect() + await self.sendHandshake() + await self.locale.load() + retries = 0 # Connection successful + self._restarting = False + except Exception as e: + logger.error("Connection to the server failed.", exc_info=e) + if on_started is not None: + on_started.set_exception(e) + elif retries > self._max_retries: + raise e + else: + retries += 1 + backoff = self._backoff(retries) + logger.info("Attempt %d failed. Reconnecting in %.2fs", retries, backoff) + await asyncio.sleep(backoff) + continue + else: + if on_started is not None: + on_started.set_result(None) + + while not self._close_event.done(): + # Keep the connection(s) alive + await asyncio.gather(*[c.send(keep_alive) for c in (self.main, self.bulle) if c]) + await asyncio.wait((self._close_event,), timeout=15) + + reason, delay, on_started = self._close_event.result() + self._close_event = asyncio.Future() + + logger.debug("[Close Event] Reason: %s, Delay: %d, Callback: %s", reason, delay, on_started) + logger.debug("Will restart: %s", reason != "stop" and self.auto_restart) + + # clean up + for conn in (self.main, self.bulle): + if conn is not None: + conn.close() + + if reason == "stop" or not self.auto_restart: + break + + await asyncio.sleep(delay) + + # If we don't recreate the connection, we won't be able to connect. + self.main = Connection("main", self, self.loop) + self.bulle = None + + # Fetch some fresh keys + if not self.bot_role and (reason != "restart" or self.keys is None): + for i in range(self._max_retries): + try: + self.keys = await get_keys(api_tfmid, api_token) + break + except MaintenanceError: + if i == 0: + logger.info("The game is under maintenance.") + + await asyncio.sleep(30) + else: + raise MaintenanceError("The game is under heavy maintenance.") + + async def restart_soon(self, delay: float = 5.0, **kwargs): + """|coro| + Restarts the client in several seconds. + + :param delay: :class:`float` the delay before restarting. Default is 5 seconds. + :param args: arguments to pass to the :meth:`Client.restart` method. + :param kwargs: keyword arguments to pass to the :meth:`Client.restart` method.""" + warnings.warn( + "`Client.restart_soon` is deprecated, use `Client.restart` instead.", DeprecationWarning, stacklevel=2 + ) + await self.restart(delay, **kwargs) + + async def restart(self, delay: float = 0, keys: Optional[Keys] = None): + """|coro| + Restarts the client. + + :param delay: the delay before restarting. By default, there is no delay. + :param keys: + """ + if not self.auto_restart or self._close_event is None: + raise AiotfmException( + "Unable to restart the Client. Either `auto_restart` is set to " + "False or you have not started the Client using `Client.start`." + ) + + if self._restarting or self._close_event.done(): + return + + self.keys = keys + self._restarting = True + # :desc: Notify when the client restarts. + self.dispatch("restart") + + restarted = asyncio.Future() + self._close_event.set_result(("restart", delay, restarted)) + await restarted + + async def login(self, username: str, password: str, encrypted: bool = True, room: str = "*aiotfm"): + """|coro| + Log in the game. + + :param username: :class:`str` the client username. + :param password: :class:`str` the client password. + :param encrypted: Optional[:class:`bool`] whether the password is already encrypted or not. + :param room: Optional[:class:`str`] the room where the client will be logged in. + """ + if self._logged: + raise AiotfmException("You cannot log in twice.") + + self._logged = True + if not encrypted: + password = shakikoo(password) + + packet = Packet.new(26, 8).writeString(username).writeString(password) + packet.writeString("app:/TransformiceAIR.swf/[[DYNAMIC]]/2/[[DYNAMIC]]/4").writeString(room) + + if self.bot_role: + packet.write16(0).write8(0).writeString("") + else: + packet.write32(self.authkey ^ self.keys.auth) + packet.write8(0).writeString("") + packet.cipher(self.keys.identification) + + await self.main.send(Packet.new(176, 1).writeUTF(self.community.name)) + await self.main.send(packet.write8(0)) + + def run(self, api_tfmid: int, api_token: str, username: str, password: str, **kwargs): + """A blocking call that does the event loop initialization for you. + + Equivalent to: :: + @bot.event + async def on_login_ready(*a): + await bot.login(username, password) + + loop = asyncio.get_event_loop() + loop.create_task(bot.start(api_id, api_token)) + loop.run_forever() + """ + try: + kwargs.update({"username": username, "password": password}) + self.loop.run_until_complete(self.start(api_tfmid, api_token, **kwargs)) + finally: + self.loop.run_until_complete(self.loop.shutdown_asyncgens()) + self.loop.close() + + def close(self): + """Closes the sockets.""" + if self._closed: + return + + self._closed = True + self._close_event.set_result(("stop", 0, None)) + + async def sendCP(self, code: int, data: Union[Packet, ByteString] = b"") -> int: + """|coro| + Send a packet to the community platform. + + :param code: :class:`int` the community platform code. + :param data: :class:`aiotfm.Packet` or :class:`bytes` the data. + :return: :class:`int` returns the sequence id. + """ + self._sequenceId = sid = (self._sequenceId + 1) % 0xFFFFFFFF + + packet = Packet.new(60, 3).write16(code) + packet.write32(self._sequenceId).writeBytes(data) + await self.main.send(packet, cipher=True) + + return sid + + async def sendRoomMessage(self, message: str): + """|coro| + Send a message to the room. + + :param message: :class:`str` the content of the message. + """ + packet = Packet.new(6, 6).writeString(message) + + await self.bulle.send(packet, cipher=True) + + async def sendTribeMessage(self, message: str): + """|coro| + Send a message to the tribe. + + :param message: :class:`str` the content of the message. + """ + await self.sendCP(50, Packet().writeString(message)) + + async def sendChannelMessage(self, channel: Union[Channel, str], message: str): + """|coro| + Send a message to a public channel. + + :param channel: :class:`str` the channel's name. + :param message: :class:`str` the content of the message. + """ + if isinstance(channel, Channel): + channel = channel.name + + return await self.sendCP(48, Packet().writeString(channel).writeString(message)) + + async def whisper(self, username: Union[Player, str], message: AnyStr, overflow: bool = False): + """|coro| + Whisper to a player. + + :param username: :class:`str` the player to whisper. + :param message: :class:`str` the content of the whisper. + :param overflow: :class:`bool` will send the complete message if True, splitted + in several messages. + """ + if isinstance(username, Player): + username = username.username + + async def send(msg): + await self.sendCP(52, Packet().writeString(username).writeString(msg)) + + if isinstance(message, str): + message = message.encode() + message = message.replace(b"<", b"<").replace(b">", b">") + + await send(message[:255]) + for i in range(255, len(message), 255): + await asyncio.sleep(1) + await self.whisper(username, message[i : i + 255]) + + async def getTribe(self, disconnected: bool = True) -> Optional[Tribe]: + """|coro| + Gets the client's :class:`aiotfm.Tribe` and return it + + :param disconnected: :class:`bool` if True retrieves also the disconnected members. + :return: :class:`aiotfm.Tribe` or ``None``. + """ + sid = await self.sendCP(108, Packet().writeBool(disconnected)) + + def is_tribe(tc, packet): + return (tc == 109 and packet.read32() == sid) or tc == 130 + + tc, packet = await self.wait_for("on_raw_cp", is_tribe, timeout=5) + if tc == 109: + result = packet.read8() + if result == 1: + tc, packet = await self.wait_for("on_raw_cp", lambda tc, p: tc == 130, timeout=5) + elif result == 17: + return None + else: + raise CommunityPlatformError(118, result) + return Tribe(packet) + + async def getRoomList(self, gamemode: Union[GameMode, int] = 0, timeout: float = 3) -> Optional[RoomList]: + """|coro| + Get the room list + + :param gamemode: Optional[:class:`aiotfm.enums.GameMode`] the room's gamemode. + :param timeout: Optional[:class:`int`] timeout in seconds. Defaults to 3 seconds. + :return: :class:`aiotfm.room.RoomList` the room list for the given gamemode or None + """ + await self.main.send(Packet.new(26, 35).write8(int(gamemode))) + + def predicate(roomlist): + return gamemode == 0 or roomlist.gamemode == gamemode + + try: + return await self.wait_for("on_room_list", predicate, timeout=timeout) + except asyncio.TimeoutError: + return None + + async def playEmote(self, emote: int, flag: str = "be"): + """|coro| + Play an emote. + + :param emote: :class:`int` the emote's id. + :param flag: Optional[:class:`str`] the flag for the emote id 10. Defaults to 'be'. + """ + packet = Packet.new(8, 1).write8(emote).write32(0) + if emote == 10: + packet.writeString(flag) + + await self.bulle.send(packet) + + async def sendSmiley(self, smiley: int): + """|coro| + Makes the client showing a smiley above it's head. + + :param smiley: :class:`int` the smiley's id. (from 0 to 9) + """ + if smiley < 0 or smiley > 9: + raise AiotfmException("Invalid smiley id") + + packet = Packet.new(8, 5).write8(smiley) + + await self.bulle.send(packet) + + async def loadLua(self, lua_code: AnyStr): + """|coro| + Load a lua code in the room. + + :param lua_code: :class:`str` or :class:`bytes` the lua code to send. + """ + if isinstance(lua_code, str): + lua_code = lua_code.encode() + + await self.bulle.send(Packet.new(29, 1).write24(len(lua_code)).writeBytes(lua_code)) + + async def sendCommand(self, command: str): + """|coro| + Send a command to the game. + + :param command: :class:`str` the command to send. + """ + await self.main.send(Packet.new(6, 26).writeString(command[:255]), cipher=True) + + async def enterTribe(self): + """|coro| + Enter the tribe house + """ + await self.main.send(Packet.new(16, 1)) + + async def enterTribeHouse(self): + """|coro| + Alias for :meth:`Client.enterTribe` + """ + await self.enterTribe() + + async def enterInvTribeHouse(self, author: str): + """|coro| + Join the tribe house of another player after receiving an /inv. + + :param author: :class:`str` the author's username who sent the invitation. + """ + await self.main.send(Packet.new(16, 2).writeString(author)) + + async def recruit(self, username: Union[Player, str]): + """|coro| + Send a recruit request to a player. + + :param username: :class:`str` the player's username you want to recruit. + """ + if isinstance(username, Player): + username = username.username + await self.sendCP(78, Packet().writeString(username)) + + async def joinRoom( + self, + room_name: str, + password: Optional[str] = None, + community: Optional[int] = None, + auto: bool = False, + ): + """|coro| + Join a room. + The event 'on_joined_room' is dispatched when the client has successfully joined the room. + + :param password: :class:`str` if given the client will ignore `community` and `auto` parameters + and will connect to the room with the given password. + :param room_name: :class:`str` the room's name. + :param community: Optional[:class:`int`] the room's community. + :param auto: Optional[:class:`bool`] joins a random room (I think). + """ + if password is not None: + packet = Packet.new(5, 39).writeString(password).writeString(room_name) + else: + packet = Packet.new(5, 38).writeString(Community(community or self.community).name) + packet.writeString(room_name).writeBool(auto) + + await self.main.send(packet) + + async def joinChannel(self, name: str, permanent: bool = True): + """|coro| + Join a #channel. + The event 'on_channel_joined' is dispatched when the client has successfully joined + a channel. + + :param name: :class:`str` the channel's name + :param permanent: Optional[:class:`bool`] if True (default) the server will automatically + reconnect the user to this channel when logged in. + """ + await self.sendCP(54, Packet().writeString(name).writeBool(permanent)) + + async def leaveChannel(self, channel: Union[Channel, str]): + """|coro| + Leaves a #channel. + + :param channel: :class:`aiotfm.message.Channel` channel to leave. + """ + if isinstance(channel, Channel): + name = channel.name + else: + name = channel + + await self.sendCP(56, Packet().writeString(name)) + + async def requestShopList(self): + """|coro| + Send a request to the server to get the shop list.""" + await self.main.send(Packet.new(8, 20)) + + async def startTrade(self, player: Union[Player, str]) -> Trade: + """|coro| + Starts a trade with the given player. + + :param player: :class:`aiotfm.Player` the player to trade with. + :return: :class:`aiotfm.inventory.Trade` the resulting trade""" + if isinstance(player, Player) and player.pid == -1: + player = player.username + + if isinstance(player, str): + player = self.room.get_player(username=player) + if player is None: + raise AiotfmException("The player must be in your room to start a trade.") + + trade = Trade(self, player) + + self.trades[player.pid] = trade + await trade.accept() + return trade + + async def requestInventory(self): + """|coro| + Send a request to the server to get the bot's inventory.""" + await self.main.send(Packet.new(31, 1)) diff --git a/aiotfm/errors.py b/aiotfm/errors.py index b28adb8..762397e 100644 --- a/aiotfm/errors.py +++ b/aiotfm/errors.py @@ -1,123 +1,123 @@ -from aiotfm.enums import TradeState - - -class AiotfmException(Exception): - """Base exception class for aiotfm""" - - -class LoginError(AiotfmException): - """Exception thrown when the login failed.""" - - def __init__(self, code: int): - self.code = code - super().__init__(f"Login Failed ! Error code: {self.code}.") - - -class AlreadyConnected(LoginError): - """Exception thrown when the account provided is already connected.""" - - def __init__(self): - super().__init__(1) - - -class IncorrectPassword(LoginError): - """Exception thrown when trying to connect with a wrong password.""" - - def __init__(self): - super().__init__(2) - - -class InvalidEvent(AiotfmException): - """Exception thrown when you added an invalid event to the client. - - An event is valid only if its name begin by 'on_' and it is coroutine. - """ - - -class ServerUnreachable(AiotfmException): - """Exception thrown when the Client can't connect to the server.""" - - -class ConnectionClosed(AiotfmException): - """Exception thrown when one of the connection closes.""" - - -class InvalidSocketData(AiotfmException): - """Exception thrown when a socket receive an invalid data.""" - - -class EndpointError(AiotfmException): - """Exception thrown when the endpoint sends an abnormal response.""" - - -class InternalError(EndpointError): - """Exception thrown when the endpoint got an internal error.""" - - -class MaintenanceError(EndpointError): - """Exception thrown when the endpoint thinks there is a maintenance.""" - - -class InvalidLocale(AiotfmException): - """Exception thrown when you try to load an inexistent locale.""" - - -class PacketError(AiotfmException): - """Exception thrown when a packet encounter a problem.""" - - -class PacketTooLarge(PacketError): - """Exception thrown when a packet is too large to be exported.""" - - -class XXTEAError(PacketError): - """Exception thrown when the XXTEA algorithm failed.""" - - -class XXTEAInvalidPacket(XXTEAError): - """Exception thrown when you try to cipher an empty Packet.""" - - -class XXTEAInvalidKeys(XXTEAError): - """Exception thrown when you try to cipher a packet with an invalid key.""" - - -class CommunityPlatformError(AiotfmException): - """Exception thrown when the community platform send an error code.""" - - def __init__(self, category: int, code: int): - super().__init__(f"Internal error: {category}-{code}") - self.category = category - self.code = code - - -class TradeOnWrongState(AiotfmException): - """Exception thrown when the client try an impossible action on trade due to its state.""" - - def __init__(self, action: str, state: TradeState): - super().__init__(f"Can not {action} when the trade is {state}.") - self.action: str = action - self.state: TradeState = state - - -class InvalidAccountError(AiotfmException): - """Exception thrown when a server action does not find a user.""" - - def __init__(self, player: str): - super().__init__(f"The account {player} does not exist.") - self.player: str = player - - -class FriendLimitError(AiotfmException): - """Exception thrown when your friend list is full.""" - - def __init__(self): - super().__init__("You can't add more friends.") - - -class CantFriendPlayerError(AiotfmException): - """Exception thrown when the server does not let you friend a player.""" - - def __init__(self, player: str): - super().__init__(f"You can't friend the player {player}") - self.player: str = player +from aiotfm.enums import TradeState + + +class AiotfmException(Exception): + """Base exception class for aiotfm""" + + +class LoginError(AiotfmException): + """Exception thrown when the login failed.""" + + def __init__(self, code: int): + self.code = code + super().__init__(f"Login Failed ! Error code: {self.code}.") + + +class AlreadyConnected(LoginError): + """Exception thrown when the account provided is already connected.""" + + def __init__(self): + super().__init__(1) + + +class IncorrectPassword(LoginError): + """Exception thrown when trying to connect with a wrong password.""" + + def __init__(self): + super().__init__(2) + + +class InvalidEvent(AiotfmException): + """Exception thrown when you added an invalid event to the client. + + An event is valid only if its name begin by 'on_' and it is coroutine. + """ + + +class ServerUnreachable(AiotfmException): + """Exception thrown when the Client can't connect to the server.""" + + +class ConnectionClosed(AiotfmException): + """Exception thrown when one of the connection closes.""" + + +class InvalidSocketData(AiotfmException): + """Exception thrown when a socket receive an invalid data.""" + + +class EndpointError(AiotfmException): + """Exception thrown when the endpoint sends an abnormal response.""" + + +class InternalError(EndpointError): + """Exception thrown when the endpoint got an internal error.""" + + +class MaintenanceError(EndpointError): + """Exception thrown when the endpoint thinks there is a maintenance.""" + + +class InvalidLocale(AiotfmException): + """Exception thrown when you try to load an inexistent locale.""" + + +class PacketError(AiotfmException): + """Exception thrown when a packet encounter a problem.""" + + +class PacketTooLarge(PacketError): + """Exception thrown when a packet is too large to be exported.""" + + +class XXTEAError(PacketError): + """Exception thrown when the XXTEA algorithm failed.""" + + +class XXTEAInvalidPacket(XXTEAError): + """Exception thrown when you try to cipher an empty Packet.""" + + +class XXTEAInvalidKeys(XXTEAError): + """Exception thrown when you try to cipher a packet with an invalid key.""" + + +class CommunityPlatformError(AiotfmException): + """Exception thrown when the community platform send an error code.""" + + def __init__(self, category: int, code: int): + super().__init__(f"Internal error: {category}-{code}") + self.category = category + self.code = code + + +class TradeOnWrongState(AiotfmException): + """Exception thrown when the client try an impossible action on trade due to its state.""" + + def __init__(self, action: str, state: TradeState): + super().__init__(f"Can not {action} when the trade is {state}.") + self.action: str = action + self.state: TradeState = state + + +class InvalidAccountError(AiotfmException): + """Exception thrown when a server action does not find a user.""" + + def __init__(self, player: str): + super().__init__(f"The account {player} does not exist.") + self.player: str = player + + +class FriendLimitError(AiotfmException): + """Exception thrown when your friend list is full.""" + + def __init__(self): + super().__init__("You can't add more friends.") + + +class CantFriendPlayerError(AiotfmException): + """Exception thrown when the server does not let you friend a player.""" + + def __init__(self, player: str): + super().__init__(f"You can't friend the player {player}") + self.player: str = player diff --git a/aiotfm/inventory.py b/aiotfm/inventory.py index 6c1127a..16a0616 100644 --- a/aiotfm/inventory.py +++ b/aiotfm/inventory.py @@ -1,391 +1,391 @@ -from __future__ import annotations - -import asyncio -from functools import cmp_to_key -from typing import TYPE_CHECKING - -from aiotfm.enums import TradeState -from aiotfm.errors import TradeOnWrongState -from aiotfm.packet import Packet -from aiotfm.player import Player - -if TYPE_CHECKING: - from aiotfm import Client - - -class InventoryItem: - """Represents an inventory item. - - Attributes - ---------- - id: `int` - The item id. - quantity: `int` - The quantity of the item. - inventory: Optional[`aiotfm.inventory.Inventory`] - The inventory class. Might be None. - can_use: `bool` - True if you can use this item. - category: `int` - Define the category's item. Used by the sorting algorithm. - img_id: `str` - Id used to get the item's image. - is_event: `bool` - True if it's an item from an event. - slot: `int` - Define the equipped slot with this item. If slot is 0 then the item is not equipped. - """ - - def __init__(self, item_id: int, **kwargs): - self.id: int = item_id - self.quantity: int = kwargs.get("quantity", 0) - self.inventory: Inventory | None = kwargs.get("inventory", None) - - self.can_use: bool = kwargs.get("can_use", True) - self.category: int = kwargs.get("category", 0) - self.img_id: str = kwargs.get("img_id", str(self.id)) - self.is_event: bool = kwargs.get("is_event", False) - self.slot: int = kwargs.get("slot", 0) - - def __repr__(self): - return f"" - - def __eq__(self, other: object): - if isinstance(other, InventoryItem): - return self.id == other.id - return NotImplemented - - @property - def image_url(self) -> str: - """The image's url of the item.""" - return f"https://www.transformice.com/images/x_transformice/x_inventaire/{self.img_id}.jpg" - - @property - def is_currency(self) -> bool: - """Return True if the item is a currency.""" - return self.id in (800, 801, 2253, 2254, 2257, 2260, 2261) - - @property - def is_equipped(self) -> bool: - """Return True if the item is equipped""" - return self.slot > 0 - - @classmethod - def from_packet(cls, packet: Packet): - """Read an item from a packet. - :param packet: :class:`aiotfm.Packet` the packet. - :return: :class:`aiotfm.inventory.InventoryItem` the item. - """ - item_id = packet.read16() - kwargs = { - "quantity": packet.read8(), - "category": packet.read8(), - "is_event": packet.readBool(), - "can_use": packet.readBool(), - } - packet.readBool() # similar to `can_use` - packet.readBool() # similar to `can_use` - packet.readBool() - packet.readBool() - if packet.readBool(): - kwargs["img_id"] = packet.readUTF() - - # if equipped, this is the slot (1, 2, 3); otherwise this is 0 - kwargs["slot"] = packet.read8() - return cls(item_id, **kwargs) - - async def use(self): - """|coro| - Uses this item.""" - if self.inventory is None or self.inventory.client is None: - message = "InventoryItem doesn't have the inventory variable or Inventory doesn't \ - have the client variable." - raise TypeError(message) - await self.inventory.client.main.send(Packet.new(31, 3).write16(self.id)) - - -class Inventory: - """Represents the client's inventory. - - Attributes - ---------- - items: `dict` - A dict containing all the items. The key is an :class:`int` and the value is - an :class:`aiotfm.inventory.InventoryItem`. - client: `aiotfm.client.Client` - The client that this inventory belongs to. - """ - - def __init__(self, client: Client = None, items: dict = None): - self.items: dict = items or {} - self.client: Client = client - - for item in self: - item.inventory = self - - def __repr__(self): - return f"" - - def __iter__(self): - return iter(self.items.values()) - - def __getitem__(self, index: int): - if not isinstance(index, int): - raise TypeError(f"Index must be int, not {type(index)}") - return self.items[index] - - def __setitem__(self, index: int, value: InventoryItem): - if not isinstance(index, int): - raise TypeError(f"Index must be int, not {type(index)}") - self.items[index] = value - - @classmethod - def from_packet(cls, packet: Packet): - """Read the inventory from a packet. - :param packet: :class:`aiotfm.Packet` the packet. - :return: :class:`aiotfm.inventory.Inventory` the inventory. - """ - items = {} - - for item in range(packet.read16()): - item = InventoryItem.from_packet(packet) - items[item.id] = item - - return cls(items=items) - - def get(self, item_id: int) -> InventoryItem: - """Gets an item from this :class:`aiotfm.inventory.Inventory`. - Shorthand for :class:`aiotfm.inventory.Inventory`.items.get""" - return self.items.get(item_id, InventoryItem(item_id)) - - def getEquipped(self) -> list[InventoryItem]: - """Return all equipped items. Items are sorted. - :return: List[:class:`aiotfm.inventory.InventoryItem`] - """ - return sorted((i for i in self.items.values() if i.is_equipped), key=lambda i: i.slot) - - def sort(self) -> list[InventoryItem]: - """Sort the inventory the same way the client does. - :return: List[:class:`aiotfm.inventory.InventoryItem`] - """ - - def cmp(a, b): - if (a.is_currency or b.is_currency) and not (a.is_currency and b.is_currency): - return -1 if a.is_currency else 1 # Currency are always on the top - if (a.is_event or b.is_event) and not (a.is_event and b.is_event): - return -1 if a.is_event else 1 # Event items comes always after the currency - if a.category != b.category: - return b.category - a.category # Higher means first - return a.id - b.id # Lastly the items are sorted by their ids - - return sorted(iter(self), key=cmp_to_key(cmp)) - - -class TradeContainer: - """Represents the content of a Trade.""" - - def __init__(self, trade: Trade): - self.trade: Trade = trade - self._content: list[InventoryItem] = [] - - def __iter__(self): - return iter(self._content) - - def get(self, item_id: int, default: int = 0) -> int: - """Returns the quantity of an item inside the TradeContainer. - :param item_id: :class:`int` the item's id. - :param default: Optional[:class:`int`] the default value if the item is not present. - :return: :class:`int` the quantity of the item. - """ - for item in self._content: - if item.id == item_id: - return item.quantity - - return default - - def getSlot(self, index: int) -> InventoryItem: - """Returns the item inside a certain slot. - :param index: :class:`int` the index. - :return: :class:`aiotfm.inventory.InventoryItem` the item. - """ - return self._content[index] - - def add(self, item_id: int, quantity: int): - """Add a quantity of an item inside the container. - :param item_id: :class:`int` the item's id. - :param quantity: :class:`int` the quantity to add. Can be negative. - """ - for item in self._content: - if item.id == item_id: - item.quantity += quantity - if item.quantity == 0: - self._content.remove(item) - break - else: - self._content.append(InventoryItem(item_id, quantity=quantity)) - - -class Trade: - """Represents a trade that the client is participating (not started, in progress or ended). - - Attributes - ---------- - client: :class:`aiotfm.Client` - The reference to the client involved in the trade. - trader: `str` - The player the client is trading with. - locked: List[`bool`] - A list of two `bool` describing the locked state of each party. - imports: :class:`aiotfm.inventory.TradeContainer` - The container of the items you will receive if the trade succeed. - exports: :class:`aiotfm.inventory.TradeContainer` - The container of the items you will give if the trade succeed. - state: :class:`aiotfm.enums.TradeState` - The current state of the trade. - ON_INVITE: an invitation has been received from/sent to the other party. - ACCEPTING: the client accepted and is waiting for the other party to be ready. - TRADING: the only state of the trade where you are able to add items. - CANCELLED: the trade has been cancelled by one of the parties. - SUCCESS: the trade finished successfully.""" - - def __init__(self, client: Client, trader: Player | str): - self.client: Client = client - self.trader: str = trader - self.locked: list[bool] = [False, False] # 0: trader, 1: client - - self.imports: TradeContainer = TradeContainer(self) - self.exports: TradeContainer = TradeContainer(self) - - self.state: TradeState = TradeState.ON_INVITE - self.pid: int = -1 - - if isinstance(trader, str): - trader = client.room.get_player(name=trader) - if trader is None: - TypeError(f"Can not find the player '{self.trader}' in the room.") - - if isinstance(trader, Player): - if self.trader.isGuest: - raise TypeError("You can not trade with a guest.") - if self.trader == self.client.username: - raise TypeError("You can not trade with yourself.") - if self.trader.pid == 0: - raise TypeError("You can not trade with a player having the same IP.") - self.trader = self.trader.username - self.pid = trader.pid - else: - raise TypeError(f"Trade expected 'Player' or 'str' type, got '{type(trader)}") - - def __repr__(self): - return "".format( - self.state.name, *self.locked, self.trader, self.pid - ) - - def __eq__(self, other: object): - if isinstance(other, Trade): - if self.pid == -1 or other.pid == -1: - return self.trader.lower() == other.trader.lower() - return self.pid == other.pid - return NotImplemented - - @property - def closed(self) -> bool: - """Returns True if the trade is closed.""" - return self.state in (TradeState.SUCCESS, TradeState.CANCELLED) - - def _start(self): - """Set the state of the trade as TRADING.""" - self.state = TradeState.TRADING - - def _close(self, succeed: bool = False): - """Closes the trade.""" - self.state = TradeState.SUCCESS if succeed else TradeState.CANCELLED - if self.client.trade == self: - self.client.trade = None - - # :desc: Called when a trade is closed. - # :param trade: :class:`aiotfm.inventory.Trade` the trade object. - # :param succed: :class:`bool` whether or not the trade is successful. - self.client.trades.pop(self.pid, None) - self.client.dispatch("trade_close", self, succeed) - - async def cancel(self): - """|coro| - Cancels the trade.""" - if self.state != TradeState.TRADING: - raise TradeOnWrongState("cancel", self.state) - - await self.client.main.send(Packet.new(31, 6).writeString(self.trader).write8(2)) - - async def accept(self): - """|coro| - Accepts the trade.""" - if self.state != TradeState.ON_INVITE: - raise TradeOnWrongState("accept", self.state) - - self.state = TradeState.ACCEPTING - await self.client.main.send(Packet.new(31, 5).writeString(self.trader)) - - async def addItem(self, item_id: int, quantity: int): - """|coro| - Adds an item to the trade. - - :param item_id: :class:`int` The item id. - :param quantity: :class:`int` The quantity of item to add.""" - if self.state != TradeState.TRADING: - raise TradeOnWrongState("addItem", self.state) - - quantity = min(max(quantity, 0), 200) - packet = Packet.new(31, 8).write16(item_id).writeBool(True).buffer - - ten = packet + b"\x01" - for _ in range(quantity // 10): - await self.client.main.send(Packet(ten)) - await asyncio.sleep(0.05) - - unit = packet + b"\x00" - for _ in range(quantity % 10): - await self.client.main.send(Packet(unit)) - await asyncio.sleep(0.05) - - async def removeItem(self, item_id: int, quantity: int): - """|coro| - Removes an item from the trade. - - :param item_id: :class:`int` The item id. - :param quantity: :class:`int` The quantity of item to remove.""" - if self.state != TradeState.TRADING: - raise TradeOnWrongState("removeItem", self.state) - - quantity = min(max(quantity, 0), 200) - packet = Packet.new(31, 8).write16(item_id).writeBool(False).buffer - - ten = packet + b"\x01" - for _ in range(quantity // 10): - await self.client.main.send(Packet(ten)) - await asyncio.sleep(0.05) - - unit = packet + b"\x00" - for _ in range(quantity % 10): - await self.client.main.send(Packet(unit)) - await asyncio.sleep(0.05) - - async def lock(self): - """|coro| - Locks (confirms) the trade.""" - if self.state != TradeState.TRADING: - raise TradeOnWrongState("lock", self.state) - if self.locked[1]: - raise TypeError("Can not lock a trade that is already locked by the client.") - - await self.client.main.send(Packet.new(31, 9).writeBool(True)) - - async def unlock(self): - """|coro| - Unlocks (cancels the confirmation) the trade.""" - if self.state != TradeState.TRADING: - raise TradeOnWrongState("lock", self.state) - if not self.locked[1]: - raise TypeError("Can not unlock a trade that is not locked by the client.") - - await self.client.main.send(Packet.new(31, 9).writeBool(False)) +from __future__ import annotations + +import asyncio +from functools import cmp_to_key +from typing import TYPE_CHECKING + +from aiotfm.enums import TradeState +from aiotfm.errors import TradeOnWrongState +from aiotfm.packet import Packet +from aiotfm.player import Player + +if TYPE_CHECKING: + from aiotfm import Client + + +class InventoryItem: + """Represents an inventory item. + + Attributes + ---------- + id: `int` + The item id. + quantity: `int` + The quantity of the item. + inventory: Optional[`aiotfm.inventory.Inventory`] + The inventory class. Might be None. + can_use: `bool` + True if you can use this item. + category: `int` + Define the category's item. Used by the sorting algorithm. + img_id: `str` + Id used to get the item's image. + is_event: `bool` + True if it's an item from an event. + slot: `int` + Define the equipped slot with this item. If slot is 0 then the item is not equipped. + """ + + def __init__(self, item_id: int, **kwargs): + self.id: int = item_id + self.quantity: int = kwargs.get("quantity", 0) + self.inventory: Inventory | None = kwargs.get("inventory", None) + + self.can_use: bool = kwargs.get("can_use", True) + self.category: int = kwargs.get("category", 0) + self.img_id: str = kwargs.get("img_id", str(self.id)) + self.is_event: bool = kwargs.get("is_event", False) + self.slot: int = kwargs.get("slot", 0) + + def __repr__(self): + return f"" + + def __eq__(self, other: object): + if isinstance(other, InventoryItem): + return self.id == other.id + return NotImplemented + + @property + def image_url(self) -> str: + """The image's url of the item.""" + return f"https://www.transformice.com/images/x_transformice/x_inventaire/{self.img_id}.jpg" + + @property + def is_currency(self) -> bool: + """Return True if the item is a currency.""" + return self.id in (800, 801, 2253, 2254, 2257, 2260, 2261) + + @property + def is_equipped(self) -> bool: + """Return True if the item is equipped""" + return self.slot > 0 + + @classmethod + def from_packet(cls, packet: Packet): + """Read an item from a packet. + :param packet: :class:`aiotfm.Packet` the packet. + :return: :class:`aiotfm.inventory.InventoryItem` the item. + """ + item_id = packet.read16() + kwargs = { + "quantity": packet.read8(), + "category": packet.read8(), + "is_event": packet.readBool(), + "can_use": packet.readBool(), + } + packet.readBool() # similar to `can_use` + packet.readBool() # similar to `can_use` + packet.readBool() + packet.readBool() + if packet.readBool(): + kwargs["img_id"] = packet.readUTF() + + # if equipped, this is the slot (1, 2, 3); otherwise this is 0 + kwargs["slot"] = packet.read8() + return cls(item_id, **kwargs) + + async def use(self): + """|coro| + Uses this item.""" + if self.inventory is None or self.inventory.client is None: + message = "InventoryItem doesn't have the inventory variable or Inventory doesn't \ + have the client variable." + raise TypeError(message) + await self.inventory.client.main.send(Packet.new(31, 3).write16(self.id)) + + +class Inventory: + """Represents the client's inventory. + + Attributes + ---------- + items: `dict` + A dict containing all the items. The key is an :class:`int` and the value is + an :class:`aiotfm.inventory.InventoryItem`. + client: `aiotfm.client.Client` + The client that this inventory belongs to. + """ + + def __init__(self, client: Client = None, items: dict = None): + self.items: dict = items or {} + self.client: Client = client + + for item in self: + item.inventory = self + + def __repr__(self): + return f"" + + def __iter__(self): + return iter(self.items.values()) + + def __getitem__(self, index: int): + if not isinstance(index, int): + raise TypeError(f"Index must be int, not {type(index)}") + return self.items[index] + + def __setitem__(self, index: int, value: InventoryItem): + if not isinstance(index, int): + raise TypeError(f"Index must be int, not {type(index)}") + self.items[index] = value + + @classmethod + def from_packet(cls, packet: Packet): + """Read the inventory from a packet. + :param packet: :class:`aiotfm.Packet` the packet. + :return: :class:`aiotfm.inventory.Inventory` the inventory. + """ + items = {} + + for item in range(packet.read16()): + item = InventoryItem.from_packet(packet) + items[item.id] = item + + return cls(items=items) + + def get(self, item_id: int) -> InventoryItem: + """Gets an item from this :class:`aiotfm.inventory.Inventory`. + Shorthand for :class:`aiotfm.inventory.Inventory`.items.get""" + return self.items.get(item_id, InventoryItem(item_id)) + + def getEquipped(self) -> list[InventoryItem]: + """Return all equipped items. Items are sorted. + :return: List[:class:`aiotfm.inventory.InventoryItem`] + """ + return sorted((i for i in self.items.values() if i.is_equipped), key=lambda i: i.slot) + + def sort(self) -> list[InventoryItem]: + """Sort the inventory the same way the client does. + :return: List[:class:`aiotfm.inventory.InventoryItem`] + """ + + def cmp(a, b): + if (a.is_currency or b.is_currency) and not (a.is_currency and b.is_currency): + return -1 if a.is_currency else 1 # Currency are always on the top + if (a.is_event or b.is_event) and not (a.is_event and b.is_event): + return -1 if a.is_event else 1 # Event items comes always after the currency + if a.category != b.category: + return b.category - a.category # Higher means first + return a.id - b.id # Lastly the items are sorted by their ids + + return sorted(iter(self), key=cmp_to_key(cmp)) + + +class TradeContainer: + """Represents the content of a Trade.""" + + def __init__(self, trade: Trade): + self.trade: Trade = trade + self._content: list[InventoryItem] = [] + + def __iter__(self): + return iter(self._content) + + def get(self, item_id: int, default: int = 0) -> int: + """Returns the quantity of an item inside the TradeContainer. + :param item_id: :class:`int` the item's id. + :param default: Optional[:class:`int`] the default value if the item is not present. + :return: :class:`int` the quantity of the item. + """ + for item in self._content: + if item.id == item_id: + return item.quantity + + return default + + def getSlot(self, index: int) -> InventoryItem: + """Returns the item inside a certain slot. + :param index: :class:`int` the index. + :return: :class:`aiotfm.inventory.InventoryItem` the item. + """ + return self._content[index] + + def add(self, item_id: int, quantity: int): + """Add a quantity of an item inside the container. + :param item_id: :class:`int` the item's id. + :param quantity: :class:`int` the quantity to add. Can be negative. + """ + for item in self._content: + if item.id == item_id: + item.quantity += quantity + if item.quantity == 0: + self._content.remove(item) + break + else: + self._content.append(InventoryItem(item_id, quantity=quantity)) + + +class Trade: + """Represents a trade that the client is participating (not started, in progress or ended). + + Attributes + ---------- + client: :class:`aiotfm.Client` + The reference to the client involved in the trade. + trader: `str` + The player the client is trading with. + locked: List[`bool`] + A list of two `bool` describing the locked state of each party. + imports: :class:`aiotfm.inventory.TradeContainer` + The container of the items you will receive if the trade succeed. + exports: :class:`aiotfm.inventory.TradeContainer` + The container of the items you will give if the trade succeed. + state: :class:`aiotfm.enums.TradeState` + The current state of the trade. + ON_INVITE: an invitation has been received from/sent to the other party. + ACCEPTING: the client accepted and is waiting for the other party to be ready. + TRADING: the only state of the trade where you are able to add items. + CANCELLED: the trade has been cancelled by one of the parties. + SUCCESS: the trade finished successfully.""" + + def __init__(self, client: Client, trader: Player | str): + self.client: Client = client + self.trader: str = trader + self.locked: list[bool] = [False, False] # 0: trader, 1: client + + self.imports: TradeContainer = TradeContainer(self) + self.exports: TradeContainer = TradeContainer(self) + + self.state: TradeState = TradeState.ON_INVITE + self.pid: int = -1 + + if isinstance(trader, str): + trader = client.room.get_player(name=trader) + if trader is None: + TypeError(f"Can not find the player '{self.trader}' in the room.") + + if isinstance(trader, Player): + if self.trader.isGuest: + raise TypeError("You can not trade with a guest.") + if self.trader == self.client.username: + raise TypeError("You can not trade with yourself.") + if self.trader.pid == 0: + raise TypeError("You can not trade with a player having the same IP.") + self.trader = self.trader.username + self.pid = trader.pid + else: + raise TypeError(f"Trade expected 'Player' or 'str' type, got '{type(trader)}") + + def __repr__(self): + return "".format( + self.state.name, *self.locked, self.trader, self.pid + ) + + def __eq__(self, other: object): + if isinstance(other, Trade): + if self.pid == -1 or other.pid == -1: + return self.trader.lower() == other.trader.lower() + return self.pid == other.pid + return NotImplemented + + @property + def closed(self) -> bool: + """Returns True if the trade is closed.""" + return self.state in (TradeState.SUCCESS, TradeState.CANCELLED) + + def _start(self): + """Set the state of the trade as TRADING.""" + self.state = TradeState.TRADING + + def _close(self, succeed: bool = False): + """Closes the trade.""" + self.state = TradeState.SUCCESS if succeed else TradeState.CANCELLED + if self.client.trade == self: + self.client.trade = None + + # :desc: Called when a trade is closed. + # :param trade: :class:`aiotfm.inventory.Trade` the trade object. + # :param succed: :class:`bool` whether or not the trade is successful. + self.client.trades.pop(self.pid, None) + self.client.dispatch("trade_close", self, succeed) + + async def cancel(self): + """|coro| + Cancels the trade.""" + if self.state != TradeState.TRADING: + raise TradeOnWrongState("cancel", self.state) + + await self.client.main.send(Packet.new(31, 6).writeString(self.trader).write8(2)) + + async def accept(self): + """|coro| + Accepts the trade.""" + if self.state != TradeState.ON_INVITE: + raise TradeOnWrongState("accept", self.state) + + self.state = TradeState.ACCEPTING + await self.client.main.send(Packet.new(31, 5).writeString(self.trader)) + + async def addItem(self, item_id: int, quantity: int): + """|coro| + Adds an item to the trade. + + :param item_id: :class:`int` The item id. + :param quantity: :class:`int` The quantity of item to add.""" + if self.state != TradeState.TRADING: + raise TradeOnWrongState("addItem", self.state) + + quantity = min(max(quantity, 0), 200) + packet = Packet.new(31, 8).write16(item_id).writeBool(True).buffer + + ten = packet + b"\x01" + for _ in range(quantity // 10): + await self.client.main.send(Packet(ten)) + await asyncio.sleep(0.05) + + unit = packet + b"\x00" + for _ in range(quantity % 10): + await self.client.main.send(Packet(unit)) + await asyncio.sleep(0.05) + + async def removeItem(self, item_id: int, quantity: int): + """|coro| + Removes an item from the trade. + + :param item_id: :class:`int` The item id. + :param quantity: :class:`int` The quantity of item to remove.""" + if self.state != TradeState.TRADING: + raise TradeOnWrongState("removeItem", self.state) + + quantity = min(max(quantity, 0), 200) + packet = Packet.new(31, 8).write16(item_id).writeBool(False).buffer + + ten = packet + b"\x01" + for _ in range(quantity // 10): + await self.client.main.send(Packet(ten)) + await asyncio.sleep(0.05) + + unit = packet + b"\x00" + for _ in range(quantity % 10): + await self.client.main.send(Packet(unit)) + await asyncio.sleep(0.05) + + async def lock(self): + """|coro| + Locks (confirms) the trade.""" + if self.state != TradeState.TRADING: + raise TradeOnWrongState("lock", self.state) + if self.locked[1]: + raise TypeError("Can not lock a trade that is already locked by the client.") + + await self.client.main.send(Packet.new(31, 9).writeBool(True)) + + async def unlock(self): + """|coro| + Unlocks (cancels the confirmation) the trade.""" + if self.state != TradeState.TRADING: + raise TradeOnWrongState("lock", self.state) + if not self.locked[1]: + raise TypeError("Can not unlock a trade that is not locked by the client.") + + await self.client.main.send(Packet.new(31, 9).writeBool(False)) diff --git a/aiotfm/player.py b/aiotfm/player.py index 06a4b96..37aba2a 100644 --- a/aiotfm/player.py +++ b/aiotfm/player.py @@ -1,283 +1,283 @@ -from aiotfm.packet import Packet - - -class Player: - """Represents a player in game. - - Attributes - ---------- - username: :class:`str` - The player's username. - uid: :class:`int` - The player's id. -1 if unknown - pid: :class:`int` - The player's pid. -1 if unknown - look: :class:`str` - The player's look. '' if unknown - gender: :class:`int` - The player's gender. - title: :class:`int` - The player's title id. 0 if unknown - title_stars: :class:`int` - The player's title's stars. - cheeses: :class:`int` - Amount of cheese the player has. - isDead: :class:`bool` - True if the player is dead. - isShaman: :class:`bool` - True if the player is shaman. - isVampire: :class:`bool` - True if the player is vampire. - score: :class:`int` - The player's score. - mouseColor: :class:`int` - The color of the player's fur. - nameColor: :class:`int` - The color of the player's name. - shamanColor: :class:`int` - The color of the player's shaman's feather. - facingRight: :class:`bool` - True if the player is facing right. - movingLeft: :class:`bool` - True if the player is moving to the left. - movingRight: :class:`bool` - True if the player is moving to the right. - x: :class:`int` - The player's x position. - y: :class:`int` - The player's y position. - vx: :class:`int` - The player's horizontal speed. - vy: :class:`int` - The player's vertical speed. - ducking: :class:`bool` - True if the player is ducking (crouching). - jumping: :class:`bool` - True if the player is jumping. - """ - - def __init__(self, username, uid=-1, pid=-1, **kwargs): - self.gender = kwargs.get("gender", 0) - self.look = kwargs.get("look", "") - self.id = uid - self.pid = pid - self.title = kwargs.get("title", 0) - self.title_stars = kwargs.get("title_stars", 0) - self.username = username - - self.cheeses = kwargs.get("cheeses", 0) - self.isDead = kwargs.get("isDead", False) - self.isShaman = kwargs.get("isShaman", False) - self.isVampire = kwargs.get("isVampire", False) - self.score = kwargs.get("score", 0) - - self.mouseColor = kwargs.get("mouseColor", 0) - self.nameColor = kwargs.get("nameColor", -1) - self.shamanColor = kwargs.get("shamanColor", 0) - - self.facingRight = True - self.movingLeft = False - self.movingRight = False - - self.x = 0 - self.y = 0 - self.vx = 0 - self.vy = 0 - self.ducking = False - self.jumping = False - - def __repr__(self): - info = " ".join([f"{field}={getattr(self, field)!r}" for field in ("id", "pid") if getattr(self, field) != -1]) - if info: - info = " " + info - return f"" - - @classmethod - def from_packet(cls, packet: Packet): - """Reads a Player from a packet. - :param packet: :class:`aiotfm.Packet` the packet. - :return: :class:`aiotfm.Player` the player. - """ - name = packet.readUTF() - pid = packet.read32() - kwargs = { - "isShaman": packet.readBool(), - "isDead": packet.read8() > 0, # may be bigger than 1? - "score": packet.read16(), - "cheeses": packet.read8(), - "title": packet.read16(), - "title_stars": packet.read8() - 1, - "gender": packet.read8(), - } - packet.readUTF() # ??? - - look = packet.readUTF() - packet.readBool() # rasterisation ? wth - mouseColor = packet.read32() - shamanColor = packet.read32() - packet.read32() # ??? - color = packet.read32() - packet.read8() # respawn id? - nameColor = -1 if color == 0xFFFFFFFF else color - - kwargs.update({ - "look": look, - "mouseColor": mouseColor, - "shamanColor": shamanColor, - "color": color, - "nameColor": nameColor, - }) - - return cls(name, pid=pid, **kwargs) - - def __str__(self): - return self.username.capitalize().replace("#0000", "") - - def __eq__(self, other): - if isinstance(other, str): - return str(self) == other or self.username.lower() == other.lower() - if -1 not in [self.id, other.id]: - return self.id == other.id - if -1 not in [self.pid, other.pid]: - return self.pid == other.pid - return self.username.lower() == other.username.lower() - - @property - def isGuest(self): - """Return True if the player is a guest (Souris)""" - return self.username.startswith("*") - - @property - def hasCheese(self): - """Return True if the player has cheese.""" - return self.cheeses > 0 - - -class Profile: - """Represents a player's profile. - - Attributes - ---------- - username: `str` - The player's username. - uid: `int` - The player's id. - registration_date: `int` - The registration timestamp of the player. - privLevel: `int` - The privilege level of the player. - gender: `int` - Player's gender. - tribe: `str` - Player's tribe. Can be `None`. - soulmate: `str` - Player's soulmate. Can be `None`. - title: `int` - The title above the player's head. - titles: `set` - The list of the unlocked titles. - titles_stars: `dict` - A dictionary where are stored the number of stars a title has. - look: `str` - The player's look. - level: `int` - The player's shaman level. - badges: `dict` - All badges unlocked by the player and their number. - stats: `Stats` - The player's stats. - equippedOrb: `int` - The equipped orb of the player. - orbs: `set` - The list of unlocked orbs. - isOnline: `bool` - Whether or not the player is online. See [#31](https://github.com/Athesdrake/aiotfm/pull/31#issuecomment-1537433271). - adventurePoints: `int` - Number of adventure points the player has. - """ - - def __init__(self, packet: Packet): - self.username = packet.readUTF() - self.id = packet.read32() - - self.registration_date = packet.read32() - self.privLevel = packet.read8() - self.gender = packet.read8() - self.tribe = packet.readUTF() or None - self.soulmate = packet.readUTF() or None - stats = [packet.read32() for i in range(10)] - self.title = packet.read16() - - self.titles = set() - self.titles_stars = {} - for _ in range(packet.read16()): - title_id, stars = packet.read16(), packet.read8() - self.titles.add(title_id) - if stars > 1: - self.titles_stars[title_id] = stars - - self.look = packet.readUTF() - self.level = packet.read16() - - self.badges = {} - for _ in range(round(packet.read16() / 2)): - badge, quantity = packet.read16(), packet.read16() - self.badges[badge] = quantity - - modeStats = [] - for _ in range(packet.read8()): - modeStats.append((packet.read8(), packet.read32(), packet.read32(), packet.read16())) - self.stats = Stats(stats, modeStats) - - self.equippedOrb = packet.read8() - self.orbs = set() - for _ in range(packet.read8()): - self.orbs.add(packet.read8()) - - self.isOnline = packet.readBool() - self.adventurePoints = packet.read32() - - -class Stats: - """Represents the statistics of a player. - - Attributes - ---------- - normalModeSaves: `int` - Number of shaman saves in normal mode. - hardModeSaves: `int` - Number of shaman saves in hard mode. - divineModeSaves: `int` - Number of shaman saves in divine mode. - shamanCheese: `int` - Number of cheese personally gathered. - firsts: `int` - Number of cheese gathered first. - gatheredCheese: `int` - Total number of gathered cheese. - bootcamps: `int` - Number of bootcamp. - modeStats: `list` - A list of tuples that represents the stats in different mode. - (id, progress, progressLimit, imageId) - noSkillNormalModeSaves: `int` - Number of shaman saves in normal mode without skills. - noSkillHardModeSaves: `int` - Number of shaman saves in hard mode without skills. - noSkillDivineModeSaves: `int` - Number of shaman saves in divine mode without skills. - """ - - def __init__(self, stats, modeStats): - self.normalModeSaves = stats[0] - self.hardModeSaves = stats[4] - self.divineModeSaves = stats[6] - self.shamanCheese = stats[1] - self.firsts = stats[2] - self.gatheredCheese = stats[3] - self.bootcamps = stats[5] - self.noSkillNormalModeSaves = stats[7] - self.noSkillHardModeSaves = stats[8] - self.noSkillDivineModeSaves = stats[9] - - self.modeStats = modeStats # id, progress, progressLimit, imageId +from aiotfm.packet import Packet + + +class Player: + """Represents a player in game. + + Attributes + ---------- + username: :class:`str` + The player's username. + uid: :class:`int` + The player's id. -1 if unknown + pid: :class:`int` + The player's pid. -1 if unknown + look: :class:`str` + The player's look. '' if unknown + gender: :class:`int` + The player's gender. + title: :class:`int` + The player's title id. 0 if unknown + title_stars: :class:`int` + The player's title's stars. + cheeses: :class:`int` + Amount of cheese the player has. + isDead: :class:`bool` + True if the player is dead. + isShaman: :class:`bool` + True if the player is shaman. + isVampire: :class:`bool` + True if the player is vampire. + score: :class:`int` + The player's score. + mouseColor: :class:`int` + The color of the player's fur. + nameColor: :class:`int` + The color of the player's name. + shamanColor: :class:`int` + The color of the player's shaman's feather. + facingRight: :class:`bool` + True if the player is facing right. + movingLeft: :class:`bool` + True if the player is moving to the left. + movingRight: :class:`bool` + True if the player is moving to the right. + x: :class:`int` + The player's x position. + y: :class:`int` + The player's y position. + vx: :class:`int` + The player's horizontal speed. + vy: :class:`int` + The player's vertical speed. + ducking: :class:`bool` + True if the player is ducking (crouching). + jumping: :class:`bool` + True if the player is jumping. + """ + + def __init__(self, username, uid=-1, pid=-1, **kwargs): + self.gender = kwargs.get("gender", 0) + self.look = kwargs.get("look", "") + self.id = uid + self.pid = pid + self.title = kwargs.get("title", 0) + self.title_stars = kwargs.get("title_stars", 0) + self.username = username + + self.cheeses = kwargs.get("cheeses", 0) + self.isDead = kwargs.get("isDead", False) + self.isShaman = kwargs.get("isShaman", False) + self.isVampire = kwargs.get("isVampire", False) + self.score = kwargs.get("score", 0) + + self.mouseColor = kwargs.get("mouseColor", 0) + self.nameColor = kwargs.get("nameColor", -1) + self.shamanColor = kwargs.get("shamanColor", 0) + + self.facingRight = True + self.movingLeft = False + self.movingRight = False + + self.x = 0 + self.y = 0 + self.vx = 0 + self.vy = 0 + self.ducking = False + self.jumping = False + + def __repr__(self): + info = " ".join([f"{field}={getattr(self, field)!r}" for field in ("id", "pid") if getattr(self, field) != -1]) + if info: + info = " " + info + return f"" + + @classmethod + def from_packet(cls, packet: Packet): + """Reads a Player from a packet. + :param packet: :class:`aiotfm.Packet` the packet. + :return: :class:`aiotfm.Player` the player. + """ + name = packet.readUTF() + pid = packet.read32() + kwargs = { + "isShaman": packet.readBool(), + "isDead": packet.read8() > 0, # may be bigger than 1? + "score": packet.read16(), + "cheeses": packet.read8(), + "title": packet.read16(), + "title_stars": packet.read8() - 1, + "gender": packet.read8(), + } + packet.readUTF() # ??? + + look = packet.readUTF() + packet.readBool() # rasterisation ? wth + mouseColor = packet.read32() + shamanColor = packet.read32() + packet.read32() # ??? + color = packet.read32() + packet.read8() # respawn id? + nameColor = -1 if color == 0xFFFFFFFF else color + + kwargs.update({ + "look": look, + "mouseColor": mouseColor, + "shamanColor": shamanColor, + "color": color, + "nameColor": nameColor, + }) + + return cls(name, pid=pid, **kwargs) + + def __str__(self): + return self.username.capitalize().replace("#0000", "") + + def __eq__(self, other): + if isinstance(other, str): + return str(self) == other or self.username.lower() == other.lower() + if -1 not in [self.id, other.id]: + return self.id == other.id + if -1 not in [self.pid, other.pid]: + return self.pid == other.pid + return self.username.lower() == other.username.lower() + + @property + def isGuest(self): + """Return True if the player is a guest (Souris)""" + return self.username.startswith("*") + + @property + def hasCheese(self): + """Return True if the player has cheese.""" + return self.cheeses > 0 + + +class Profile: + """Represents a player's profile. + + Attributes + ---------- + username: `str` + The player's username. + uid: `int` + The player's id. + registration_date: `int` + The registration timestamp of the player. + privLevel: `int` + The privilege level of the player. + gender: `int` + Player's gender. + tribe: `str` + Player's tribe. Can be `None`. + soulmate: `str` + Player's soulmate. Can be `None`. + title: `int` + The title above the player's head. + titles: `set` + The list of the unlocked titles. + titles_stars: `dict` + A dictionary where are stored the number of stars a title has. + look: `str` + The player's look. + level: `int` + The player's shaman level. + badges: `dict` + All badges unlocked by the player and their number. + stats: `Stats` + The player's stats. + equippedOrb: `int` + The equipped orb of the player. + orbs: `set` + The list of unlocked orbs. + isOnline: `bool` + Whether or not the player is online. See [#31](https://github.com/Athesdrake/aiotfm/pull/31#issuecomment-1537433271). + adventurePoints: `int` + Number of adventure points the player has. + """ + + def __init__(self, packet: Packet): + self.username = packet.readUTF() + self.id = packet.read32() + + self.registration_date = packet.read32() + self.privLevel = packet.read8() + self.gender = packet.read8() + self.tribe = packet.readUTF() or None + self.soulmate = packet.readUTF() or None + stats = [packet.read32() for i in range(10)] + self.title = packet.read16() + + self.titles = set() + self.titles_stars = {} + for _ in range(packet.read16()): + title_id, stars = packet.read16(), packet.read8() + self.titles.add(title_id) + if stars > 1: + self.titles_stars[title_id] = stars + + self.look = packet.readUTF() + self.level = packet.read16() + + self.badges = {} + for _ in range(round(packet.read16() / 2)): + badge, quantity = packet.read16(), packet.read16() + self.badges[badge] = quantity + + modeStats = [] + for _ in range(packet.read8()): + modeStats.append((packet.read8(), packet.read32(), packet.read32(), packet.read16())) + self.stats = Stats(stats, modeStats) + + self.equippedOrb = packet.read8() + self.orbs = set() + for _ in range(packet.read8()): + self.orbs.add(packet.read8()) + + self.isOnline = packet.readBool() + self.adventurePoints = packet.read32() + + +class Stats: + """Represents the statistics of a player. + + Attributes + ---------- + normalModeSaves: `int` + Number of shaman saves in normal mode. + hardModeSaves: `int` + Number of shaman saves in hard mode. + divineModeSaves: `int` + Number of shaman saves in divine mode. + shamanCheese: `int` + Number of cheese personally gathered. + firsts: `int` + Number of cheese gathered first. + gatheredCheese: `int` + Total number of gathered cheese. + bootcamps: `int` + Number of bootcamp. + modeStats: `list` + A list of tuples that represents the stats in different mode. + (id, progress, progressLimit, imageId) + noSkillNormalModeSaves: `int` + Number of shaman saves in normal mode without skills. + noSkillHardModeSaves: `int` + Number of shaman saves in hard mode without skills. + noSkillDivineModeSaves: `int` + Number of shaman saves in divine mode without skills. + """ + + def __init__(self, stats, modeStats): + self.normalModeSaves = stats[0] + self.hardModeSaves = stats[4] + self.divineModeSaves = stats[6] + self.shamanCheese = stats[1] + self.firsts = stats[2] + self.gatheredCheese = stats[3] + self.bootcamps = stats[5] + self.noSkillNormalModeSaves = stats[7] + self.noSkillHardModeSaves = stats[8] + self.noSkillDivineModeSaves = stats[9] + + self.modeStats = modeStats # id, progress, progressLimit, imageId diff --git a/aiotfm/room.py b/aiotfm/room.py index 12c8d29..d4ec5a0 100644 --- a/aiotfm/room.py +++ b/aiotfm/room.py @@ -1,224 +1,224 @@ -from dataclasses import dataclass, field -from typing import Any, Callable, List, Optional, Union - -from aiotfm.enums import GameMode -from aiotfm.errors import AiotfmException -from aiotfm.packet import Packet -from aiotfm.player import Player - - -class Room: - """Represents the room that the bot currently is in. - - Attributes - ---------- - name: `str` - The room's name. (i.e: en-1, *bad girls and so on) - official: `bool` - Whether the room is an official room or not. If official, it's name will be displayed in yellow. - players: `list[:class:`aiotfm.Player`]` - The list containing all the players of the room. - """ - - def __init__(self, name: str, official: bool = False): - self.name: str = name - self.official: bool = official - self.players: dict = {} - - def __repr__(self): - return f"" - - @property - def community(self) -> str: - """Returns the room's community.""" - if self.name.startswith("*"): - return "xx" - return self.name.split("-", 1)[0] - - @property - def is_tribe(self) -> bool: - """Returns true if it's a tribe house.""" - return self.name.startswith("*\x03") - - @property - def display_name(self) -> str: - r"""Return the display name of the room. - It removes the \x03 char from the tribe house and the community from the public rooms.""" - if self.is_tribe: - return self.name.replace("\x03", "") - if self.name.startswith("*"): - return self.name - return self.name.split("-", 1)[1] - - def get_players(self, predicate: Callable, max_: Optional[int] = None) -> List[Player]: - """Filters players from the room. - - :param predicate: A function that returns a boolean-like result to filter through - the players. - :param max_: Optional[:class:`int`] The maximum amount of players to return. - :return: `Iterable` The filtered players.""" - return [p for p in self.players.values() if predicate(p)][:max_] - - def get_player(self, default: Optional[Any] = None, **kwargs) -> Union[Player, Any]: - """Gets one player in the room with an identifier. - - :param kwargs: Which identifier to use. Can be either name, username, id or pid. - :return: :class:`aiotfm.Player` The player or None""" - length = len(kwargs.keys()) - - if length == 0: - raise AiotfmException("You did not provide any identifier.") - if length > 1: - raise AiotfmException("You cannot filter one player with more than one identifier.") - - identifier, value = next(iter(kwargs.items())) - - if identifier in ("name", "username"): - - def filter_(p): - return p == value - elif identifier == "id": - - def filter_(p): - return p.id == int(value) - elif identifier == "pid": - return self.players.get(int(value), default) - else: - raise AiotfmException("Invalid filter.") - - for player in self.players.values(): - if filter_(player): - return player - return default - - -@dataclass(slots=True) -class RoomEntry: - name: str - language: str - country: str - player_count: int - limit: int = 0 - is_funcorp: bool = False - is_pinned: bool = False - command: str = "" - args: str = "" - is_modified: bool = False - shaman_skills: bool = True - consumables: bool = True - adventure: bool = True - collision: bool = False - aie: bool = False - map_duration: int = 100 - mice_mass: int = 100 - map_rotation: list = field(default_factory=list) - - -@dataclass(slots=True) -class DropdownRoomEntry(RoomEntry): - entries: list[RoomEntry] = field(default_factory=list) - - -class RoomList: - """Represents the list of rooms in the server. - - Attributes - ---------- - gamemode: :class:`aiotfm.enums.GameMode` - The list's gamemode. - rooms: List[`RoomEntry`] - The list of normal rooms. - pinned_rooms: List[`RoomEntry`] - The list of pinned(/module) rooms. - gamemodes: List[:class:`aiotfm.enums.GameMode`] - The list of gamemodes available. - """ - - def __init__( - self, gamemode: GameMode, rooms: List[RoomEntry], pinned_rooms: List[RoomEntry], gamemodes: List[GameMode] - ): - self.gamemode: GameMode = gamemode - self.rooms: List[RoomEntry] = rooms - self.pinned_rooms: List[RoomEntry] = pinned_rooms - self.gamemodes: List[GameMode] = gamemodes - - @classmethod - def from_packet(cls, packet: Packet): - gamemodes = [GameMode(packet.read8()) for _ in range(packet.read8())] - gamemode = GameMode(packet.read8()) - rooms: List[RoomEntry] = [] - pinned: List[RoomEntry] = [] - - while packet.pos < len(packet.buffer): - is_pinned = packet.readBool() - language = packet.readUTF() - country = packet.readUTF() - name = packet.readUTF() - - if is_pinned: - player_count = packet.readUTF() - command = packet.readUTF() - args = packet.readUTF() - - if player_count.isdigit(): - player_count = int(player_count) - - if command == "lm": - entries: List[RoomEntry] = [] - room = DropdownRoomEntry(name, language, country, player_count, entries=entries, is_pinned=True) - - for mode in args.split("&~"): - if "," not in mode: - continue - - name, count = mode.split(",") - entries.append( - RoomEntry(name, room.language, room.country, int(count), command="mjj", args="m " + name) - ) - - pinned.append(room) - else: - pinned.append( - RoomEntry(name, language, country, player_count, command=command, args=args, is_pinned=True) - ) - else: - player_count = packet.read16() - limit = packet.read8() - is_funcorp = packet.readBool() - is_modified = packet.readBool() - - kwargs = { - "limit": limit, - "is_funcorp": is_funcorp, - } - - # Read the modified properties - if is_modified: - shaman_skills = not packet.readBool() - consumables = not packet.readBool() - adventure = not packet.readBool() - collision = packet.readBool() - aie = packet.readBool() - map_duration = packet.read8() - mice_mass = packet.read32() - map_rotation = [] - - for _ in range(0, packet.read8()): - map_rotation.append(packet.read8()) - - # Append the room's specific properties - kwargs.update({ - "is_modified": is_modified, - "shaman_skills": shaman_skills, - "consumables": consumables, - "adventure": adventure, - "collision": collision, - "aie": aie, - "map_duration": map_duration, - "mice_mass": mice_mass, - "map_rotation": map_rotation, - }) - - rooms.append(RoomEntry(name, language, country, player_count, **kwargs)) - - return cls(gamemode, rooms, pinned, gamemodes) +from dataclasses import dataclass, field +from typing import Any, Callable, List, Optional, Union + +from aiotfm.enums import GameMode +from aiotfm.errors import AiotfmException +from aiotfm.packet import Packet +from aiotfm.player import Player + + +class Room: + """Represents the room that the bot currently is in. + + Attributes + ---------- + name: `str` + The room's name. (i.e: en-1, *bad girls and so on) + official: `bool` + Whether the room is an official room or not. If official, it's name will be displayed in yellow. + players: `list[:class:`aiotfm.Player`]` + The list containing all the players of the room. + """ + + def __init__(self, name: str, official: bool = False): + self.name: str = name + self.official: bool = official + self.players: dict = {} + + def __repr__(self): + return f"" + + @property + def community(self) -> str: + """Returns the room's community.""" + if self.name.startswith("*"): + return "xx" + return self.name.split("-", 1)[0] + + @property + def is_tribe(self) -> bool: + """Returns true if it's a tribe house.""" + return self.name.startswith("*\x03") + + @property + def display_name(self) -> str: + r"""Return the display name of the room. + It removes the \x03 char from the tribe house and the community from the public rooms.""" + if self.is_tribe: + return self.name.replace("\x03", "") + if self.name.startswith("*"): + return self.name + return self.name.split("-", 1)[1] + + def get_players(self, predicate: Callable, max_: Optional[int] = None) -> List[Player]: + """Filters players from the room. + + :param predicate: A function that returns a boolean-like result to filter through + the players. + :param max_: Optional[:class:`int`] The maximum amount of players to return. + :return: `Iterable` The filtered players.""" + return [p for p in self.players.values() if predicate(p)][:max_] + + def get_player(self, default: Optional[Any] = None, **kwargs) -> Union[Player, Any]: + """Gets one player in the room with an identifier. + + :param kwargs: Which identifier to use. Can be either name, username, id or pid. + :return: :class:`aiotfm.Player` The player or None""" + length = len(kwargs.keys()) + + if length == 0: + raise AiotfmException("You did not provide any identifier.") + if length > 1: + raise AiotfmException("You cannot filter one player with more than one identifier.") + + identifier, value = next(iter(kwargs.items())) + + if identifier in ("name", "username"): + + def filter_(p): + return p == value + elif identifier == "id": + + def filter_(p): + return p.id == int(value) + elif identifier == "pid": + return self.players.get(int(value), default) + else: + raise AiotfmException("Invalid filter.") + + for player in self.players.values(): + if filter_(player): + return player + return default + + +@dataclass(slots=True) +class RoomEntry: + name: str + language: str + country: str + player_count: int + limit: int = 0 + is_funcorp: bool = False + is_pinned: bool = False + command: str = "" + args: str = "" + is_modified: bool = False + shaman_skills: bool = True + consumables: bool = True + adventure: bool = True + collision: bool = False + aie: bool = False + map_duration: int = 100 + mice_mass: int = 100 + map_rotation: list = field(default_factory=list) + + +@dataclass(slots=True) +class DropdownRoomEntry(RoomEntry): + entries: list[RoomEntry] = field(default_factory=list) + + +class RoomList: + """Represents the list of rooms in the server. + + Attributes + ---------- + gamemode: :class:`aiotfm.enums.GameMode` + The list's gamemode. + rooms: List[`RoomEntry`] + The list of normal rooms. + pinned_rooms: List[`RoomEntry`] + The list of pinned(/module) rooms. + gamemodes: List[:class:`aiotfm.enums.GameMode`] + The list of gamemodes available. + """ + + def __init__( + self, gamemode: GameMode, rooms: List[RoomEntry], pinned_rooms: List[RoomEntry], gamemodes: List[GameMode] + ): + self.gamemode: GameMode = gamemode + self.rooms: List[RoomEntry] = rooms + self.pinned_rooms: List[RoomEntry] = pinned_rooms + self.gamemodes: List[GameMode] = gamemodes + + @classmethod + def from_packet(cls, packet: Packet): + gamemodes = [GameMode(packet.read8()) for _ in range(packet.read8())] + gamemode = GameMode(packet.read8()) + rooms: List[RoomEntry] = [] + pinned: List[RoomEntry] = [] + + while packet.pos < len(packet.buffer): + is_pinned = packet.readBool() + language = packet.readUTF() + country = packet.readUTF() + name = packet.readUTF() + + if is_pinned: + player_count = packet.readUTF() + command = packet.readUTF() + args = packet.readUTF() + + if player_count.isdigit(): + player_count = int(player_count) + + if command == "lm": + entries: List[RoomEntry] = [] + room = DropdownRoomEntry(name, language, country, player_count, entries=entries, is_pinned=True) + + for mode in args.split("&~"): + if "," not in mode: + continue + + name, count = mode.split(",") + entries.append( + RoomEntry(name, room.language, room.country, int(count), command="mjj", args="m " + name) + ) + + pinned.append(room) + else: + pinned.append( + RoomEntry(name, language, country, player_count, command=command, args=args, is_pinned=True) + ) + else: + player_count = packet.read16() + limit = packet.read8() + is_funcorp = packet.readBool() + is_modified = packet.readBool() + + kwargs = { + "limit": limit, + "is_funcorp": is_funcorp, + } + + # Read the modified properties + if is_modified: + shaman_skills = not packet.readBool() + consumables = not packet.readBool() + adventure = not packet.readBool() + collision = packet.readBool() + aie = packet.readBool() + map_duration = packet.read8() + mice_mass = packet.read32() + map_rotation = [] + + for _ in range(0, packet.read8()): + map_rotation.append(packet.read8()) + + # Append the room's specific properties + kwargs.update({ + "is_modified": is_modified, + "shaman_skills": shaman_skills, + "consumables": consumables, + "adventure": adventure, + "collision": collision, + "aie": aie, + "map_duration": map_duration, + "mice_mass": mice_mass, + "map_rotation": map_rotation, + }) + + rooms.append(RoomEntry(name, language, country, player_count, **kwargs)) + + return cls(gamemode, rooms, pinned, gamemodes)