diff --git a/README.md b/README.md index b0f8ff8..dddcadc 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,42 @@ async def on_message(message): bot.run() ``` +#### Manual login flows + +When you rely on the BearWare.dk web login flow, the recommended pattern is: + +```python +bot = pytalk.TeamTalkBot() + +@bot.event +async def on_ready(): + # Call your own helper to fetch a short-lived BearWare session token + await bot.add_server( + pytalk.TeamTalkServerInfo( + { + "host": "example.org", + "tcp_port": 10333, + "udp_port": 10333, + # BearWare.dk requires the e-mail-style login as the username + "username": "login@bearware.dk", + "encrypted": True, + # Store the preference with the server info + "auto_login": False, + } + ), + ) + +@bot.event +async def on_my_connect(server: pytalk.server.Server): + token = pytalk.sdk.ttstr(server.get_properties().properties.szAccessToken) + # Tell your helper to register/log in using the token + server.teamtalk_instance.login() +``` + +With ``auto_login`` disabled in the server info, the instance still establishes +the TCP/UDP connection and manages its backoff timers, but you stay in control +of *when* ``login()`` runs so the BearWare token exchange can complete first. + ## Documentation diff --git a/docs/api.rst b/docs/api.rst index bfbf43f..d6523ee 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -18,8 +18,17 @@ Bot :members: :exclude-members: event,dispatch - .. automethod:: pytalk.bot.TeamTalkBot.event() - :decorator: +.. automethod:: pytalk.bot.TeamTalkBot.event() + :decorator: + +.. tip:: + + Set ``auto_login=False`` on :class:`pytalk.enums.TeamTalkServerInfo` (or pass + it explicitly to :meth:`pytalk.bot.TeamTalkBot.add_server`) when you need to + defer login. The instance will connect and manage backoff as usual, but the + caller is responsible for invoking + :meth:`pytalk.instance.TeamTalkInstance.login` at the appropriate time (e.g., + after completing a BearWare.dk token exchange). Enums diff --git a/pytalk/bot.py b/pytalk/bot.py index 64920f8..92ccf89 100644 --- a/pytalk/bot.py +++ b/pytalk/bot.py @@ -58,6 +58,7 @@ async def add_server( server: TeamTalkServerInfo | dict[str, Any], reconnect: bool = True, backoff_config: dict[str, Any] | None = None, + auto_login: bool | None = None, ) -> None: """Add a server to the bot. @@ -73,12 +74,14 @@ async def add_server( Can contain keys: `base`, `exponent`, `max_value`, `max_tries`. These settings govern the retry behavior for both the initial connection sequence and for reconnections after a connection loss. + auto_login (Optional[bool]): Whether to automatically log in to the + server. ``None`` (default) defers to ``server.auto_login``. """ if isinstance(server, dict): server = TeamTalkServerInfo.from_dict(server) _log.debug("Adding server: %s, %s", self, server) - tt = TeamTalkInstance(self, server, reconnect, backoff_config) + tt = TeamTalkInstance(self, server, reconnect, backoff_config, auto_login) successful_initial_connection = await tt.initial_connect_loop() if not successful_initial_connection: _log.error( diff --git a/pytalk/enums.py b/pytalk/enums.py index 54fba57..b2fe591 100644 --- a/pytalk/enums.py +++ b/pytalk/enums.py @@ -22,6 +22,7 @@ def __init__(self, data: dict[str, Any]) -> None: self.nickname = data.get("nickname", data.get("username")) self.join_channel_id = data.get("join_channel_id", -1) self.join_channel_password = data.get("join_channel_password", "") + self.auto_login = data.get("auto_login", True) @classmethod def from_dict(cls, data: dict[str, Any]) -> Self: @@ -53,6 +54,7 @@ def to_dict(self) -> dict[str, Any]: "nickname": self.nickname if self.nickname else "", "join_channel_id": self.join_channel_id, "join_channel_password": self.join_channel_password, + "auto_login": self.auto_login, } def __eq__(self, other: object) -> bool: @@ -74,6 +76,7 @@ def __eq__(self, other: object) -> bool: and self.username == other.username and self.password == other.password and self.encrypted == other.encrypted + and self.auto_login == other.auto_login ) def __ne__(self, other: object) -> bool: @@ -91,7 +94,14 @@ def __ne__(self, other: object) -> bool: def __hash__(self) -> int: """Return the hash value of the object.""" return hash( - (self.host, self.tcp_port, self.udp_port, self.username, self.encrypted) + ( + self.host, + self.tcp_port, + self.udp_port, + self.username, + self.encrypted, + self.auto_login, + ) ) diff --git a/pytalk/instance.py b/pytalk/instance.py index 291fbd2..1d60025 100644 --- a/pytalk/instance.py +++ b/pytalk/instance.py @@ -78,6 +78,7 @@ def __init__( server_info: TeamTalkServerInfo, reconnect: bool = True, backoff_config: dict[str, Any] | None = None, + auto_login: bool | None = None, ) -> None: """Initialize a pytalk.TeamTalkInstance instance. @@ -91,6 +92,8 @@ def __init__( These settings govern the retry behavior for both the initial connection sequence and for reconnections after a connection loss. Defaults to `None` (using default Backoff settings). + auto_login (Optional[bool]): Whether to automatically log in after + connecting. ``None`` (default) defers to ``server_info.auto_login``. """ super().__init__() @@ -106,6 +109,7 @@ def __init__( self._current_input_device_id: int | None = -1 self._audio_sdk_lock = threading.Lock() self.reconnect_enabled = reconnect + self.auto_login = server_info.auto_login if auto_login is None else auto_login if backoff_config: self._backoff = Backoff(**backoff_config) else: @@ -1526,6 +1530,36 @@ async def _process_events(self) -> None: # noqa: C901, PLR0911, PLR0912, PLR091 ): _log.warning("Unhandled event: %s", event) + async def _login_after_connect(self, *, reconnect: bool) -> bool: + """Handle login steps shared by initial connect and reconnect paths.""" + if not self.auto_login: + _log.info( + "Auto-login is disabled. Skipping login after %s.", + "reconnect" if reconnect else "initial connect", + ) + self._backoff.reset() + return True + + login_args = (True,) if reconnect else () + logged_in_ok = await self.bot.loop.run_in_executor( + None, self.login, *login_args + ) + if logged_in_ok: + _log.info( + "Successfully %slogged in to %s.", + "re" if reconnect else "", + self.server_info.host, + ) + self._backoff.reset() + return True + + _log.warning( + "Login failed for %s after successful %s.", + self.server_info.host, + "reconnect" if reconnect else "connection", + ) + return False + async def initial_connect_loop(self) -> bool: """Attempt to establish an initial connection and login to the server. @@ -1550,15 +1584,8 @@ async def initial_connect_loop(self) -> bool: "Successfully connected to %s. Attempting login...", self.server_info.host, ) - logged_in_ok = await self.bot.loop.run_in_executor(None, self.login) - if logged_in_ok: - _log.info("Successfully logged in to %s.", self.server_info.host) - self._backoff.reset() + if await self._login_after_connect(reconnect=False): return True - _log.warning( - "Login failed for %s after successful connection.", - self.server_info.host, - ) else: _log.warning( "Initial connection attempt failed for %s.", @@ -1613,20 +1640,8 @@ async def _reconnect(self) -> None: "Re-established connection to %s. Attempting login...", self.server_info.host, ) - logged_in_ok = await self.bot.loop.run_in_executor( - None, self.login, True - ) - if logged_in_ok: - _log.info( - "Successfully reconnected and logged in to %s.", - self.server_info.host, - ) - self._backoff.reset() + if await self._login_after_connect(reconnect=True): return - _log.warning( - "Login failed for %s after successful reconnect.", - self.server_info.host, - ) else: _log.warning( "Reconnect attempt %s failed for %s.",