feat: add voice channel cleanup and signal handling during application shutdown

This commit is contained in:
mmacierzanka committed 2026-09-13 16:29:31 +02:00
1 parent 8f1395117a
commit f7f820691b
3 files changed
+211 -9

No files matched your search

+60 -7
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio
import logging
import os
import signal
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
@@ -89,6 +90,8 @@ class Application:
finally:
self.player.on_state_change = None
await broadcaster.stop()
if not self.bot.is_closed():
await self.bot.close()
self.web.router.lifespan_context = lifespan
@@ -141,19 +144,69 @@ class Application:
server = uvicorn.Server(config)
self._server = server
async with self.bot:
server_task = asyncio.create_task(server.serve())
loop = asyncio.get_running_loop()
def _handle_shutdown_signal() -> None:
logger.info("Odebrano sygnał zakończenia, zamykanie serwera i bota...")
server.should_exit = True
if not self.bot.is_closed():
asyncio.create_task(self.bot.close())
for sig in (signal.SIGINT, signal.SIGTERM):
try:
loop.add_signal_handler(sig, _handle_shutdown_signal)
except (NotImplementedError, RuntimeError):
pass
async with self.bot:
server_task = asyncio.create_task(server.serve(), name="web-server")
async def run_bot() -> None:
if not self.settings.discord_token:
raise RuntimeError("Brak DISCORD_TOKEN w środowisku")
await self.bot.start(self.settings.discord_token)
except Exception as error: # noqa: BLE001 - panel ma przetrwać awarię bota
logger.error("Nie udało się wystartować bota Discord: %s", error)
logger.info("Serwer webowy działa dalej...")
await self._wait_for_shutdown(server)
bot_task = asyncio.create_task(run_bot(), name="discord-bot")
try:
done, pending = await asyncio.wait(
[server_task, bot_task],
return_when=asyncio.FIRST_COMPLETED,
)
if server_task in done:
# Serwer webowy zakończył działanie (np. przez sygnał wyjścia lub restart)
logger.info("Serwer webowy zakończył pracę, zamykanie bota Discord...")
if not self.bot.is_closed():
await self.bot.close()
try:
await bot_task
except (asyncio.CancelledError, Exception) as error:
logger.debug("Zakończenie zadania bota: %s", error)
elif bot_task in done:
# Bot Discord zakończył działanie (błąd startu lub wyjątek)
try:
bot_task.result()
except Exception as error: # noqa: BLE001
logger.error("Bot Discord zakończył pracę z błędem: %s", error)
logger.info("Serwer webowy działa dalej...")
await self._wait_for_shutdown(server)
except asyncio.CancelledError:
pass
finally:
server.should_exit = True
await server_task
if not self.bot.is_closed():
await self.bot.close()
if not server_task.done():
await server_task
if not bot_task.done():
bot_task.cancel()
try:
await bot_task
except (asyncio.CancelledError, Exception):
pass
@staticmethod
async def _wait_for_shutdown(server: uvicorn.Server) -> None:
+72 -2
View File
@@ -1,12 +1,12 @@
from __future__ import annotations
import asyncio
import logging
from collections.abc import Callable
import discord
from discord.ext import commands
from collections.abc import Callable
from zyrarbot.audio.track_factory import TrackFactory
from zyrarbot.music.music_player import MusicPlayer
from zyrarbot.settings import Settings
@@ -51,6 +51,7 @@ class MusicBot(commands.Bot):
self.history = history
self.player: MusicPlayer | None = None
self._restart_callback = restart_callback
self._is_closing = False
def schedule_restart(self) -> None:
"""Żąda restartu bota za pomocą zarejestrowanego wywołania zwrotnego."""
@@ -96,11 +97,80 @@ class MusicBot(commands.Bot):
await self.tree.sync()
logger.info("Zsynchronizowano komendy globalnie")
async def disconnect_all_voice(self) -> None:
"""Rozłącza bota ze wszystkich kanałów głosowych na wszystkich serwerach."""
if self.voice_clients:
logger.info(
"Odłączanie bota ze wszystkich kanałów głosowych (%d)...",
len(self.voice_clients),
)
async def _disconnect_vc(vc: discord.VoiceClient) -> None:
try:
guild_id = vc.guild.id
if self.player:
try:
self.player.save_queue(guild_id)
self.player.clear(guild_id)
except Exception as err:
logger.warning("Błąd podczas czyszczenia odtwarzacza dla serwera %s: %s", guild_id, err)
if vc.is_connected():
await asyncio.wait_for(vc.disconnect(force=False), timeout=2.0)
except Exception as error:
logger.warning("Błąd podczas łagodnego odłączania z kanału %s, wymuszanie: %s", getattr(vc, "channel", None), error)
try:
await vc.disconnect(force=True)
except Exception:
pass
if self.voice_clients:
await asyncio.gather(*[_disconnect_vc(vc) for vc in list(self.voice_clients)], return_exceptions=True)
async def _clear_guild_voice(guild: discord.Guild) -> None:
try:
if guild.me and guild.me.voice and guild.me.voice.channel:
await guild.change_voice_state(channel=None)
except Exception as error:
logger.warning("Błąd podczas czyszczenia stanu głosowego na serwerze %s: %s", guild.name, error)
# Dla każdego serwera upewnij się przez gateway, że stan głosowy bota to None
if self.guilds:
await asyncio.gather(*[_clear_guild_voice(guild) for guild in self.guilds], return_exceptions=True)
async def close(self) -> None:
"""Zamykanie bota: najpierw odłącz się od wszystkich kanałów głosowych."""
if getattr(self, "_is_closing", False) or self.is_closed():
return
self._is_closing = True
try:
await self.disconnect_all_voice()
except Exception as error:
logger.error("Błąd podczas odłączania bota przed zamknięciem: %s", error)
await super().close()
async def on_ready(self) -> None:
if self.guilds:
first_guild_id = self.guilds[0].id
self.uploads.migrate_legacy(first_guild_id)
self.config.migrate_legacy(first_guild_id)
self.history.migrate_legacy(first_guild_id)
# Jeśli po uruchomieniu bot z jakiegoś powodu znajduje się w kanale głosowym (np. po awaryjnym restarcie),
# odłącz go, aby stan był czysty i zgodny z API.
for guild in self.guilds:
if guild.me and guild.me.voice and guild.me.voice.channel:
logger.warning(
"Wykryto wiszące połączenie głosowe po starcie na serwerze %s (%s). Odłączanie...",
guild.name,
guild.me.voice.channel.name,
)
try:
if guild.voice_client:
await guild.voice_client.disconnect(force=True)
else:
await guild.change_voice_state(channel=None)
except Exception as error:
logger.error("Błąd podczas czyszczenia wiszącego kanału głosowego: %s", error)
if self.user:
logger.info("Zalogowano jako %s (%s)", self.user.name, self.user.id)