From 708e3591ed833d8f622254614961d729307f17da Mon Sep 17 00:00:00 2001 From: Marcin Macierzanka Date: Fri, 11 Sep 2026 01:21:26 +0200 Subject: [PATCH] feat: add bot restart functionality via API and Discord command --- frontend/src/api/client.ts | 1 + frontend/src/components/AppHeader.vue | 91 +++++++++++++++++++++++- tests/test_api_surface.py | 14 ++++ zyrarbot/application.py | 34 ++++++++- zyrarbot/ui/api/routers/status_router.py | 10 ++- zyrarbot/ui/bot/client.py | 19 +++++ zyrarbot/ui/bot/cogs/control_cog.py | 8 +++ 7 files changed, 172 insertions(+), 5 deletions(-) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 379415d..b901b44 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -40,6 +40,7 @@ function post(path: string, body?: unknown): Promise { export const api = { status: () => request('/api/status'), + restart: () => post<{ status: string }>('/api/restart'), connect: (channelId: string) => post('/api/connect', { channel_id: channelId }), disconnect: () => post('/api/disconnect'), diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue index 5fd0896..735cc3c 100644 --- a/frontend/src/components/AppHeader.vue +++ b/frontend/src/components/AppHeader.vue @@ -1,6 +1,9 @@ @@ -67,10 +125,32 @@ const label = computed(() => gap: 16px; } -.btn-secondary { - display: flex; +.btn-restart { + display: inline-flex; align-items: center; gap: 8px; + padding: 8px 16px; + border-radius: 30px; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + transition: var(--transition-smooth); +} + +.btn-restart:hover:not(:disabled) { + border-color: rgba(239, 68, 68, 0.4); + color: #fca5a5; + background: rgba(239, 68, 68, 0.12); + box-shadow: 0 0 12px rgba(239, 68, 68, 0.2); +} + +.btn-restart:disabled { + opacity: 0.65; + cursor: not-allowed; +} + +.spin-icon { + animation: spin 1s linear infinite; } @media (max-width: 768px) { @@ -85,5 +165,10 @@ const label = computed(() => flex-direction: column; align-items: flex-start; } + + .btn-restart { + width: 100%; + justify-content: center; + } } diff --git a/tests/test_api_surface.py b/tests/test_api_surface.py index 4cbd1a9..9706868 100644 --- a/tests/test_api_surface.py +++ b/tests/test_api_surface.py @@ -11,6 +11,7 @@ from zyrarbot.settings import Settings EXPECTED_ENDPOINTS = { ("get", "/api/status"), ("get", "/api/health"), + ("post", "/api/restart"), ("post", "/api/play"), ("post", "/api/play_file"), ("post", "/api/pause"), @@ -71,6 +72,19 @@ class ApiSurfaceTest(unittest.TestCase): self.assertTrue(self.spec["openapi"].startswith("3.")) self.assertIn("ŻyrarBOT", self.spec["info"]["title"]) + def test_restart_endpoint_calls_restart_state(self): + import asyncio + from unittest.mock import MagicMock + from zyrarbot.ui.api.routers.status_router import restart_bot + + mock_request = MagicMock() + mock_restart = MagicMock() + mock_request.app.state.restart = mock_restart + + result = asyncio.run(restart_bot(mock_request)) + self.assertEqual(result, {"status": "restarting"}) + mock_restart.assert_called_once() + if __name__ == "__main__": unittest.main() diff --git a/zyrarbot/application.py b/zyrarbot/application.py index c63f211..f98f28e 100644 --- a/zyrarbot/application.py +++ b/zyrarbot/application.py @@ -46,7 +46,15 @@ class Application: self.queues = QueueRepository(os.path.join(self.settings.config_dir, "queues")) self.tracks = TrackFactory(self.uploads, MediaProbe()) - self.bot = MusicBot(self.settings, self.tracks, self.uploads, self.config, self.history) + self._server: uvicorn.Server | None = None + self.bot = MusicBot( + self.settings, + self.tracks, + self.uploads, + self.config, + self.history, + restart_callback=self.schedule_restart, + ) self.guilds = GuildResolver(self.bot) self.player = MusicPlayer( bot=self.bot, @@ -60,6 +68,7 @@ class Application: self.bot.player = self.player self.web = FastAPI(title="ŻyrarBOT") + self.web.state.restart = self.schedule_restart broadcaster = mount_api( self.web, settings=self.settings, @@ -99,6 +108,28 @@ class Application: "/", StaticFiles(directory=self.settings.static_dir, html=True), name="static" ) + def schedule_restart(self, delay: float = 0.5) -> None: + """Zaplanuj restart bota (zakończenie procesu Pythona).""" + asyncio.create_task(self._restart_worker(delay)) + + async def _restart_worker(self, delay: float) -> None: + logger.info("Zaplanowano restart aplikacji za %.1f s...", delay) + await asyncio.sleep(delay) + logger.info("Kończenie pracy aplikacji w celu restartu...") + try: + if not self.bot.is_closed(): + await self.bot.close() + except Exception as error: # noqa: BLE001 + logger.error("Błąd podczas zamykania bota: %s", error) + + if self._server is not None: + self._server.should_exit = True + + # Bezpiecznik: jeśli proces nie zakończy się w ciągu 2.0 s, wymuś wyjście + await asyncio.sleep(2.0) + logger.warning("Wymuszanie zakończenia procesu (os._exit)") + os._exit(0) + async def run(self) -> None: """Startuje serwer web, a następnie bota; panel działa też bez Discorda.""" config = uvicorn.Config( @@ -108,6 +139,7 @@ class Application: log_level="info", ) server = uvicorn.Server(config) + self._server = server async with self.bot: server_task = asyncio.create_task(server.serve()) diff --git a/zyrarbot/ui/api/routers/status_router.py b/zyrarbot/ui/api/routers/status_router.py index 860100c..0ca4477 100644 --- a/zyrarbot/ui/api/routers/status_router.py +++ b/zyrarbot/ui/api/routers/status_router.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any import discord -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Request from zyrarbot.music.music_player import MusicPlayer from zyrarbot.ui.api.dependencies import get_active_guild, get_player, get_status_service @@ -23,3 +23,11 @@ async def get_status( @router.get("/health") async def get_health(player: MusicPlayer = Depends(get_player)) -> dict[str, Any]: return {"status": "ok", "ready": player.is_ready} + + +@router.post("/restart") +async def restart_bot(request: Request) -> dict[str, Any]: + restart_action = getattr(request.app.state, "restart", None) + if restart_action: + restart_action() + return {"status": "restarting"} diff --git a/zyrarbot/ui/bot/client.py b/zyrarbot/ui/bot/client.py index 9f3a465..8a160d5 100644 --- a/zyrarbot/ui/bot/client.py +++ b/zyrarbot/ui/bot/client.py @@ -5,6 +5,8 @@ import logging 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 @@ -35,6 +37,7 @@ class MusicBot(commands.Bot): uploads: UploadsRepository, config: GuildConfigRepository, history: HistoryRepository, + restart_callback: Callable[[], None] | None = None, ): intents = discord.Intents.default() intents.message_content = True @@ -47,6 +50,22 @@ class MusicBot(commands.Bot): self.config = config self.history = history self.player: MusicPlayer | None = None + self._restart_callback = restart_callback + + def schedule_restart(self) -> None: + """Żąda restartu bota za pomocą zarejestrowanego wywołania zwrotnego.""" + if self._restart_callback is not None: + self._restart_callback() + else: + import asyncio + import os + + async def _default_restart() -> None: + await asyncio.sleep(0.5) + await self.close() + os._exit(0) + + asyncio.create_task(_default_restart()) def _create_music_cogs(self) -> tuple[commands.Cog, ...]: if self.player is None: diff --git a/zyrarbot/ui/bot/cogs/control_cog.py b/zyrarbot/ui/bot/cogs/control_cog.py index 220493b..6699c22 100644 --- a/zyrarbot/ui/bot/cogs/control_cog.py +++ b/zyrarbot/ui/bot/cogs/control_cog.py @@ -122,3 +122,11 @@ class ControlCog(MusicCogBase, name="Control"): return title = discord.utils.escape_markdown(current.title or "") await interaction.response.send_message(embed=message_embed("Teraz odtwarzane", title)) + + @app_commands.command(name="restart", description="Zrestartuj bota (restart kontenera)") + async def restart(self, interaction: discord.Interaction) -> None: + await interaction.response.send_message( + embed=message_embed("Restartowanie", "Bot jest restartowany...") + ) + if hasattr(self.bot, "schedule_restart"): + self.bot.schedule_restart()