feat: add bot restart functionality via API and Discord command
This commit is contained in:
7 files changed
+172
-5
No files matched your search
@@ -40,6 +40,7 @@ function post<T>(path: string, body?: unknown): Promise<T> {
|
|||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
status: () => request<BotStatus>('/api/status'),
|
status: () => request<BotStatus>('/api/status'),
|
||||||
|
restart: () => post<{ status: string }>('/api/restart'),
|
||||||
|
|
||||||
connect: (channelId: string) => post('/api/connect', { channel_id: channelId }),
|
connect: (channelId: string) => post('/api/connect', { channel_id: channelId }),
|
||||||
disconnect: () => post('/api/disconnect'),
|
disconnect: () => post('/api/disconnect'),
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { RotateCw } from 'lucide-vue-next'
|
||||||
import type { BotStatus, GuildSettings } from '@/types/status'
|
import type { BotStatus, GuildSettings } from '@/types/status'
|
||||||
|
import { api } from '@/api/client'
|
||||||
|
import { useToasts } from '@/composables/useToasts'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
status: BotStatus | null
|
status: BotStatus | null
|
||||||
@@ -9,6 +12,11 @@ const props = defineProps<{
|
|||||||
}>()
|
}>()
|
||||||
defineEmits<{ selectGuild: [guildId: string] }>()
|
defineEmits<{ selectGuild: [guildId: string] }>()
|
||||||
|
|
||||||
|
const { notify } = useToasts()
|
||||||
|
const isRestarting = ref(false)
|
||||||
|
const restartInitiatedAt = ref<number | null>(null)
|
||||||
|
let restartTimer: number | null = null
|
||||||
|
|
||||||
const activeChannel = computed(() => props.status?.active_voice_channel ?? null)
|
const activeChannel = computed(() => props.status?.active_voice_channel ?? null)
|
||||||
|
|
||||||
const label = computed(() =>
|
const label = computed(() =>
|
||||||
@@ -16,6 +24,43 @@ const label = computed(() =>
|
|||||||
? `Połączony: ${activeChannel.value.channel_name}`
|
? `Połączony: ${activeChannel.value.channel_name}`
|
||||||
: 'Rozłączony',
|
: 'Rozłączony',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async function handleRestart() {
|
||||||
|
if (isRestarting.value) return
|
||||||
|
if (!window.confirm('Czy na pewno chcesz zrestartować bota?')) return
|
||||||
|
|
||||||
|
isRestarting.value = true
|
||||||
|
restartInitiatedAt.value = Date.now()
|
||||||
|
notify('Wysyłanie polecenia restartu...', 'info')
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.restart()
|
||||||
|
notify('Wysłano polecenie. Restartowanie bota...', 'info')
|
||||||
|
} catch {
|
||||||
|
notify('Wysłano polecenie restartu.', 'info')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (restartTimer !== null) clearTimeout(restartTimer)
|
||||||
|
restartTimer = window.setTimeout(() => {
|
||||||
|
isRestarting.value = false
|
||||||
|
restartInitiatedAt.value = null
|
||||||
|
}, 15000)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.status,
|
||||||
|
() => {
|
||||||
|
if (isRestarting.value && restartInitiatedAt.value && Date.now() - restartInitiatedAt.value > 3000) {
|
||||||
|
isRestarting.value = false
|
||||||
|
restartInitiatedAt.value = null
|
||||||
|
if (restartTimer !== null) {
|
||||||
|
clearTimeout(restartTimer)
|
||||||
|
restartTimer = null
|
||||||
|
}
|
||||||
|
notify('Bot został uruchomiony ponownie!', 'success')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -56,6 +101,19 @@ const label = computed(() =>
|
|||||||
/>
|
/>
|
||||||
<span>{{ label }}</span>
|
<span>{{ label }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-secondary btn-restart"
|
||||||
|
:disabled="isRestarting"
|
||||||
|
:title="isRestarting ? 'Restartowanie bota...' : 'Zrestartuj bota'"
|
||||||
|
@click="handleRestart"
|
||||||
|
>
|
||||||
|
<RotateCw
|
||||||
|
:size="16"
|
||||||
|
:class="{ 'spin-icon': isRestarting }"
|
||||||
|
/>
|
||||||
|
<span>{{ isRestarting ? 'Restart...' : 'Restart' }}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
@@ -67,10 +125,32 @@ const label = computed(() =>
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-restart {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
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) {
|
@media (max-width: 768px) {
|
||||||
@@ -85,5 +165,10 @@ const label = computed(() =>
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-restart {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -11,6 +11,7 @@ from zyrarbot.settings import Settings
|
|||||||
EXPECTED_ENDPOINTS = {
|
EXPECTED_ENDPOINTS = {
|
||||||
("get", "/api/status"),
|
("get", "/api/status"),
|
||||||
("get", "/api/health"),
|
("get", "/api/health"),
|
||||||
|
("post", "/api/restart"),
|
||||||
("post", "/api/play"),
|
("post", "/api/play"),
|
||||||
("post", "/api/play_file"),
|
("post", "/api/play_file"),
|
||||||
("post", "/api/pause"),
|
("post", "/api/pause"),
|
||||||
@@ -71,6 +72,19 @@ class ApiSurfaceTest(unittest.TestCase):
|
|||||||
self.assertTrue(self.spec["openapi"].startswith("3."))
|
self.assertTrue(self.spec["openapi"].startswith("3."))
|
||||||
self.assertIn("ŻyrarBOT", self.spec["info"]["title"])
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
+33
-1
@@ -46,7 +46,15 @@ class Application:
|
|||||||
self.queues = QueueRepository(os.path.join(self.settings.config_dir, "queues"))
|
self.queues = QueueRepository(os.path.join(self.settings.config_dir, "queues"))
|
||||||
self.tracks = TrackFactory(self.uploads, MediaProbe())
|
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.guilds = GuildResolver(self.bot)
|
||||||
self.player = MusicPlayer(
|
self.player = MusicPlayer(
|
||||||
bot=self.bot,
|
bot=self.bot,
|
||||||
@@ -60,6 +68,7 @@ class Application:
|
|||||||
self.bot.player = self.player
|
self.bot.player = self.player
|
||||||
|
|
||||||
self.web = FastAPI(title="ŻyrarBOT")
|
self.web = FastAPI(title="ŻyrarBOT")
|
||||||
|
self.web.state.restart = self.schedule_restart
|
||||||
broadcaster = mount_api(
|
broadcaster = mount_api(
|
||||||
self.web,
|
self.web,
|
||||||
settings=self.settings,
|
settings=self.settings,
|
||||||
@@ -99,6 +108,28 @@ class Application:
|
|||||||
"/", StaticFiles(directory=self.settings.static_dir, html=True), name="static"
|
"/", 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:
|
async def run(self) -> None:
|
||||||
"""Startuje serwer web, a następnie bota; panel działa też bez Discorda."""
|
"""Startuje serwer web, a następnie bota; panel działa też bez Discorda."""
|
||||||
config = uvicorn.Config(
|
config = uvicorn.Config(
|
||||||
@@ -108,6 +139,7 @@ class Application:
|
|||||||
log_level="info",
|
log_level="info",
|
||||||
)
|
)
|
||||||
server = uvicorn.Server(config)
|
server = uvicorn.Server(config)
|
||||||
|
self._server = server
|
||||||
|
|
||||||
async with self.bot:
|
async with self.bot:
|
||||||
server_task = asyncio.create_task(server.serve())
|
server_task = asyncio.create_task(server.serve())
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
|
||||||
from zyrarbot.music.music_player import MusicPlayer
|
from zyrarbot.music.music_player import MusicPlayer
|
||||||
from zyrarbot.ui.api.dependencies import get_active_guild, get_player, get_status_service
|
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")
|
@router.get("/health")
|
||||||
async def get_health(player: MusicPlayer = Depends(get_player)) -> dict[str, Any]:
|
async def get_health(player: MusicPlayer = Depends(get_player)) -> dict[str, Any]:
|
||||||
return {"status": "ok", "ready": player.is_ready}
|
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"}
|
||||||
@@ -5,6 +5,8 @@ import logging
|
|||||||
import discord
|
import discord
|
||||||
from discord.ext import commands
|
from discord.ext import commands
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
from zyrarbot.audio.track_factory import TrackFactory
|
from zyrarbot.audio.track_factory import TrackFactory
|
||||||
from zyrarbot.music.music_player import MusicPlayer
|
from zyrarbot.music.music_player import MusicPlayer
|
||||||
from zyrarbot.settings import Settings
|
from zyrarbot.settings import Settings
|
||||||
@@ -35,6 +37,7 @@ class MusicBot(commands.Bot):
|
|||||||
uploads: UploadsRepository,
|
uploads: UploadsRepository,
|
||||||
config: GuildConfigRepository,
|
config: GuildConfigRepository,
|
||||||
history: HistoryRepository,
|
history: HistoryRepository,
|
||||||
|
restart_callback: Callable[[], None] | None = None,
|
||||||
):
|
):
|
||||||
intents = discord.Intents.default()
|
intents = discord.Intents.default()
|
||||||
intents.message_content = True
|
intents.message_content = True
|
||||||
@@ -47,6 +50,22 @@ class MusicBot(commands.Bot):
|
|||||||
self.config = config
|
self.config = config
|
||||||
self.history = history
|
self.history = history
|
||||||
self.player: MusicPlayer | None = None
|
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, ...]:
|
def _create_music_cogs(self) -> tuple[commands.Cog, ...]:
|
||||||
if self.player is None:
|
if self.player is None:
|
||||||
|
|||||||
@@ -122,3 +122,11 @@ class ControlCog(MusicCogBase, name="Control"):
|
|||||||
return
|
return
|
||||||
title = discord.utils.escape_markdown(current.title or "")
|
title = discord.utils.escape_markdown(current.title or "")
|
||||||
await interaction.response.send_message(embed=message_embed("Teraz odtwarzane", title))
|
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()
|
||||||
Reference in new issue
Block a user