feat: server management page with log channel editing only for admins

This commit is contained in:
mmacierzanka committed 2026-09-30 20:11:22 +02:00
1 parent e9bd13e48b
commit ddcce85ad5
12 files changed
+236 -84

No files matched your search

+7 -3
View File
@@ -1,11 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import AdminAccessPage from '@/components/AdminAccessPage.vue' import AdminAccessPage from '@/components/AdminAccessPage.vue'
import AdminServersPage from '@/components/AdminServersPage.vue'
import AdminUsersPage from '@/components/AdminUsersPage.vue' import AdminUsersPage from '@/components/AdminUsersPage.vue'
defineProps<{ username: string; userId: number }>() defineProps<{ username: string; userId: number }>()
const currentPath = ref(window.location.pathname) const currentPath = ref(window.location.pathname)
const activePage = computed(() => currentPath.value.endsWith('/access') ? 'access' : 'users') const activePage = computed(() => currentPath.value.endsWith('/access')
? 'access'
: currentPath.value.endsWith('/servers') ? 'servers' : 'users')
function navigate(path: string) { function navigate(path: string) {
window.history.pushState({}, '', path) window.history.pushState({}, '', path)
@@ -38,13 +41,14 @@ async function logout() {
<nav class="sidebar-nav" aria-label="Menu administratora"> <nav class="sidebar-nav" aria-label="Menu administratora">
<a href="/admin/users" title="Użytkownicy" :class="{ active: activePage === 'users' }" @click.prevent="navigate('/admin/users')"><span class="nav-icon">♙</span>Użytkownicy</a> <a href="/admin/users" title="Użytkownicy" :class="{ active: activePage === 'users' }" @click.prevent="navigate('/admin/users')"><span class="nav-icon">♙</span>Użytkownicy</a>
<a href="/admin/access" title="Dostęp do serwerów" :class="{ active: activePage === 'access' }" @click.prevent="navigate('/admin/access')"><span class="nav-icon">▦</span>Dostęp do serwerów</a> <a href="/admin/access" title="Dostęp do serwerów" :class="{ active: activePage === 'access' }" @click.prevent="navigate('/admin/access')"><span class="nav-icon">▦</span>Dostęp do serwerów</a>
<a href="/admin/servers" title="Serwery" :class="{ active: activePage === 'servers' }" @click.prevent="navigate('/admin/servers')"><span class="nav-icon">#</span>Serwery</a>
</nav> </nav>
<div class="sidebar-bottom"><div class="admin-identity"><span class="identity-dot"/><span><small>Zalogowano jako</small><strong>{{ username }}</strong></span></div><button class="sidebar-logout" type="button" @click="logout">Wyloguj</button></div> <div class="sidebar-bottom"><div class="admin-identity"><span class="identity-dot"/><span><small>Zalogowano jako</small><strong>{{ username }}</strong></span></div><button class="sidebar-logout" type="button" @click="logout">Wyloguj</button></div>
</aside> </aside>
<section class="admin-main"> <section class="admin-main">
<header class="admin-topbar"><div><span class="topbar-label">ADMINISTRACJA</span><span class="topbar-separator">/</span><span>{{ activePage === 'access' ? 'Dostęp do serwerów' : 'Użytkownicy' }}</span></div><div class="topbar-actions"><a class="topbar-link" href="/">Wróć do panelu</a><button class="admin-button admin-button-danger" type="button" @click="restartBot">Restart bota</button></div></header> <header class="admin-topbar"><div><span class="topbar-label">ADMINISTRACJA</span><span class="topbar-separator">/</span><span>{{ activePage === 'access' ? 'Dostęp do serwerów' : activePage === 'servers' ? 'Serwery' : 'Użytkownicy' }}</span></div><div class="topbar-actions"><a class="topbar-link" href="/">Wróć do panelu</a><button class="admin-button admin-button-danger" type="button" @click="restartBot">Restart bota</button></div></header>
<div class="admin-content"><AdminUsersPage v-if="activePage === 'users'" :current-user-id="userId"/><AdminAccessPage v-else/></div> <div class="admin-content"><AdminUsersPage v-if="activePage === 'users'" :current-user-id="userId"/><AdminAccessPage v-else-if="activePage === 'access'"/><AdminServersPage v-else/></div>
</section> </section>
</main> </main>
</template> </template>
@@ -0,0 +1,100 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
type LogChannel = { channel_id: string; channel_name: string }
type Server = {
guild_id: string
guild_name: string
log_channel_id: string | null
disabled: boolean
channels: LogChannel[]
}
const servers = ref<Server[]>([])
const drafts = ref<Record<string, string>>({})
const loading = ref(true)
const saving = ref(new Set<string>())
const errors = ref<Record<string, string>>({})
const loadError = ref('')
const changedIds = computed(() => servers.value
.filter((server) => (drafts.value[server.guild_id] ?? '') !== (server.log_channel_id ?? ''))
.map((server) => server.guild_id))
async function loadServers() {
loading.value = true
loadError.value = ''
try {
const response = await fetch('/api/admin/servers')
const payload = await response.json().catch(() => null)
if (!response.ok) throw new Error(payload?.detail ?? 'Nie udało się pobrać serwerów.')
servers.value = payload
drafts.value = Object.fromEntries(payload.map((server: Server) => [
server.guild_id, server.disabled ? '' : server.log_channel_id ?? '',
]))
} catch (cause) {
loadError.value = cause instanceof Error ? cause.message : 'Nie udało się pobrać serwerów.'
} finally {
loading.value = false
}
}
async function save(server: Server) {
const guildId = server.guild_id
saving.value = new Set(saving.value).add(guildId)
errors.value = { ...errors.value, [guildId]: '' }
try {
const selected = drafts.value[guildId] ?? ''
const response = await fetch(`/api/admin/servers/${guildId}/log-channel`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ channel_id: selected || null }),
})
const payload = await response.json().catch(() => null)
if (!response.ok) throw new Error(payload?.detail ?? 'Nie udało się zapisać kanału logów.')
server.log_channel_id = payload.log_channel_id
server.disabled = payload.disabled
} catch (cause) {
errors.value = {
...errors.value,
[guildId]: cause instanceof Error ? cause.message : 'Nie udało się zapisać kanału logów.',
}
} finally {
const pending = new Set(saving.value)
pending.delete(guildId)
saving.value = pending
}
}
onMounted(() => { void loadServers() })
</script>
<template>
<div class="admin-page-content">
<div class="admin-page-heading">
<div><p class="admin-eyebrow">KONFIGURACJA</p><h2>Serwery</h2><p>Ustaw kanał, na który bot wysyła powiadomienia, lub wyłącz je dla serwera.</p></div>
<button class="server-refresh" type="button" :disabled="loading" @click="loadServers">Odśwież</button>
</div>
<p v-if="loadError" class="server-error">{{ loadError }}</p>
<div v-if="loading" class="server-state">Ładowanie serwerów…</div>
<div v-else-if="!servers.length" class="server-state">Bot nie jest obecnie na żadnym serwerze Discorda.</div>
<section v-for="server in servers" :key="server.guild_id" class="server-card">
<header class="server-card-heading">
<span class="server-icon">#</span>
<div><h3>{{ server.guild_name }}</h3><p>{{ server.channels.length }} dostępnych kanałów tekstowych</p></div>
<span class="server-state-badge" :class="drafts[server.guild_id] ? 'server-state-enabled' : 'server-state-disabled'">{{ drafts[server.guild_id] ? 'Logi włączone' : 'Logi wyłączone' }}</span>
</header>
<div class="server-settings-row">
<label :for="`log-channel-${server.guild_id}`">Kanał logów</label>
<select :id="`log-channel-${server.guild_id}`" v-model="drafts[server.guild_id]" class="server-channel-select" :disabled="saving.has(server.guild_id)">
<option value="">Wyłącz kanał logów</option>
<option v-for="channel in server.channels" :key="channel.channel_id" :value="channel.channel_id"># {{ channel.channel_name }}</option>
</select>
<button class="server-save" type="button" :disabled="saving.has(server.guild_id) || !changedIds.includes(server.guild_id)" @click="save(server)">{{ saving.has(server.guild_id) ? 'Zapisywanie…' : 'Zapisz' }}</button>
</div>
<p v-if="errors[server.guild_id]" class="server-error">{{ errors[server.guild_id] }}</p>
</section>
</div>
</template>
<style scoped>
.admin-page-content{display:grid;gap:18px}.admin-page-heading{display:flex;align-items:center;justify-content:space-between;gap:16px}.admin-page-heading h2{margin:2px 0 6px;font-size:28px}.admin-page-heading p:last-child{margin:0;color:#a9b1c4}.admin-eyebrow{margin:0;color:#9c88ff;font-size:11px;font-weight:700;letter-spacing:.14em}.server-refresh,.server-save{min-height:38px;padding:0 15px;border:1px solid #41465c;border-radius:9px;background:#252a3c;color:#eceeff;font-family:inherit;font-size:13px;font-weight:600;cursor:pointer}.server-refresh:hover:not(:disabled){background:#30364b}.server-save{border-color:#8c70f2;background:#7657e8;color:#fff}.server-save:hover:not(:disabled){background:#886df1}.server-refresh:disabled,.server-save:disabled{opacity:.45;cursor:not-allowed}.server-card{padding:22px 24px;border:1px solid #303449;border-radius:16px;background:#171a29;box-shadow:0 12px 36px #0003}.server-card-heading{display:flex;align-items:center;gap:12px;padding-bottom:18px;border-bottom:1px solid #2b2f42}.server-icon{display:grid;place-items:center;width:38px;height:38px;border-radius:11px;background:#30294e;color:#beadff;font-size:20px}.server-card-heading h3{margin:0 0 3px;color:#f3f4f8;font-size:16px}.server-card-heading p{margin:0;color:#9299ac;font-size:12px}.server-state-badge{margin-left:auto;padding:6px 10px;border-radius:999px;font-size:11px}.server-state-enabled{background:#30294e;color:#cbbfff}.server-state-disabled{background:#282a35;color:#a6aabb}.server-settings-row{display:grid;grid-template-columns:140px minmax(220px,1fr) auto;align-items:center;gap:14px;padding-top:18px}.server-settings-row label{color:#c5cada;font-size:13px;font-weight:600}.server-channel-select{width:100%;height:40px;padding:0 11px;border:1px solid #41465c;border-radius:8px;background:#101321;color:#f3f4f8;font:inherit}.server-channel-select:focus{outline:2px solid #9b83ff66;border-color:#9b83ff}.server-state{padding:32px;color:#a9b1c4;text-align:center}.server-error{margin:12px 0 0;color:#ff8d9b;font-size:13px}@media(max-width:680px){.admin-page-heading{align-items:flex-start;flex-direction:column}.server-card{padding:17px}.server-settings-row{grid-template-columns:1fr}.server-settings-row label{margin-bottom:-7px}.server-save{width:100%}.server-state-badge{font-size:10px}}
</style>
@@ -9,13 +9,10 @@ const props = defineProps<{ status: BotStatus | null }>()
const { notify, run } = useToasts() const { notify, run } = useToasts()
const selected = ref('') const selected = ref('')
const selectedNotification = ref('')
const defaultPlays = ref(5) const defaultPlays = ref(5)
const channels = computed(() => props.status?.voice_channels ?? []) const channels = computed(() => props.status?.voice_channels ?? [])
const active = computed(() => props.status?.active_voice_channel ?? null) const active = computed(() => props.status?.active_voice_channel ?? null)
const notificationChannels = computed(() => props.status?.text_channels ?? [])
const activeNotificationId = computed(() => props.status?.active_text_channel_id ?? null)
// Wybór podąża za realnym połączeniem, a bez niego domyślnie wskazuje pierwszy kanał. // Wybór podąża za realnym połączeniem, a bez niego domyślnie wskazuje pierwszy kanał.
watch( watch(
@@ -38,18 +35,6 @@ watch(
{ immediate: true }, { immediate: true },
) )
watch(
[activeNotificationId, notificationChannels],
([activeChannel, available]) => {
if (activeChannel) {
selectedNotification.value = activeChannel
} else if (!available.some((channel) => channel.channel_id === selectedNotification.value)) {
selectedNotification.value = available[0]?.channel_id ?? ''
}
},
{ immediate: true },
)
async function toggle() { async function toggle() {
if (active.value) { if (active.value) {
await run(() => api.disconnect(), '', 'Błąd rozłączania') await run(() => api.disconnect(), '', 'Błąd rozłączania')
@@ -62,15 +47,6 @@ async function toggle() {
await run(() => api.connect(selected.value), '', 'Nie udało się połączyć') await run(() => api.connect(selected.value), '', 'Nie udało się połączyć')
} }
async function setNotificationChannel() {
if (!selectedNotification.value) return
await run(
() => api.setNotificationChannel(selectedNotification.value),
'Ustawiono kanał logów',
'Nie udało się ustawić kanału logów',
)
}
async function saveDefaultPlays() { async function saveDefaultPlays() {
await run( await run(
() => api.setDefaultPlays(defaultPlays.value), () => api.setDefaultPlays(defaultPlays.value),
@@ -114,30 +90,6 @@ async function saveDefaultPlays() {
{{ active ? 'Odłącz' : 'Połącz' }} {{ active ? 'Odłącz' : 'Połącz' }}
</button> </button>
</div> </div>
<div class="notification-channel-row">
<label for="notification-channel">Kanał logów</label>
<select
id="notification-channel"
v-model="selectedNotification"
class="premium-select"
:disabled="!notificationChannels.length"
@change="setNotificationChannel"
>
<option
v-if="!notificationChannels.length"
value=""
>
Brak dostępnych kanałów tekstowych
</option>
<option
v-for="channel in notificationChannels"
:key="channel.channel_id"
:value="channel.channel_id"
>
# {{ channel.channel_name }}
</option>
</select>
</div>
<div class="default-plays-row"> <div class="default-plays-row">
<label for="default-plays">Domyślny licznik</label> <label for="default-plays">Domyślny licznik</label>
<input <input
@@ -160,25 +112,6 @@ async function saveDefaultPlays() {
</template> </template>
<style scoped> <style scoped>
.notification-channel-row {
display: flex;
align-items: center;
gap: 12px;
margin-top: 14px;
}
.notification-channel-row label {
display: flex;
align-items: center;
gap: 8px;
min-width: 130px;
color: var(--text-muted);
}
.notification-channel-row .premium-select {
flex: 1;
}
.default-plays-row { .default-plays-row {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -20,15 +20,17 @@ class NotificationChannelResolver:
def resolve( def resolve(
self, guild_id: int, remembered: discord.abc.Messageable | None self, guild_id: int, remembered: discord.abc.Messageable | None
) -> discord.abc.Messageable | None: ) -> discord.abc.Messageable | None:
if remembered: if self._config.is_notification_channel_disabled(guild_id):
return remembered return None
saved_id = self._config.get_notification_channel_id(guild_id) saved_id = self._config.get_notification_channel_id(guild_id)
if saved_id: if saved_id:
channel = self._bot.get_channel(saved_id) channel = self._bot.get_channel(saved_id)
if channel: if channel:
return channel return channel
if remembered:
return remembered
return self.first_writable(self._bot.get_guild(guild_id)) return self.first_writable(self._bot.get_guild(guild_id))
@staticmethod @staticmethod
@@ -25,7 +25,23 @@ class PostgresGuildConfig:
self._ensure(guild_id) self._ensure(guild_id)
with self._database.connect() as connection: with self._database.connect() as connection:
connection.execute( connection.execute(
"UPDATE guilds SET notification_channel_id=%s WHERE id=%s", (channel_id, guild_id) "UPDATE guilds SET notification_channel_id=%s, notification_channel_disabled=FALSE WHERE id=%s",
(channel_id, guild_id),
)
def is_notification_channel_disabled(self, guild_id: int) -> bool:
with self._database.connect() as connection:
row = connection.execute(
"SELECT notification_channel_disabled FROM guilds WHERE id=%s", (guild_id,)
).fetchone()
return bool(row[0]) if row else False
def set_notification_channel_disabled(self, guild_id: int, disabled: bool) -> None:
self._ensure(guild_id)
with self._database.connect() as connection:
connection.execute(
"UPDATE guilds SET notification_channel_disabled=%s WHERE id=%s",
(disabled, guild_id),
) )
def get_default_plays(self, guild_id: int | None = None) -> int: def get_default_plays(self, guild_id: int | None = None) -> int:
@@ -0,0 +1,18 @@
from __future__ import annotations
import psycopg
class DisableLogChannelMigration:
version = 3
name = "disable_log_channel"
def apply(self, connection: psycopg.Connection) -> None:
connection.execute(
"ALTER TABLE guilds ADD COLUMN notification_channel_disabled BOOLEAN NOT NULL DEFAULT FALSE"
)
def rollback(self, connection: psycopg.Connection) -> None:
connection.execute(
"ALTER TABLE guilds DROP COLUMN notification_channel_disabled"
)
@@ -6,5 +6,8 @@ InitialSchemaMigration = import_module(
".0001_initial_schema", __name__ ".0001_initial_schema", __name__
).InitialSchemaMigration ).InitialSchemaMigration
UsersMigration = import_module(".0002_users", __name__).UsersMigration UsersMigration = import_module(".0002_users", __name__).UsersMigration
DisableLogChannelMigration = import_module(
".0003_disable_log_channel", __name__
).DisableLogChannelMigration
MIGRATIONS = (InitialSchemaMigration(), UsersMigration()) MIGRATIONS = (InitialSchemaMigration(), UsersMigration(), DisableLogChannelMigration())
+4
View File
@@ -8,6 +8,10 @@ class GuildConfigRepository(Protocol):
def set_notification_channel_id(self, guild_id: int, channel_id: int | None) -> None: ... def set_notification_channel_id(self, guild_id: int, channel_id: int | None) -> None: ...
def is_notification_channel_disabled(self, guild_id: int) -> bool: ...
def set_notification_channel_disabled(self, guild_id: int, disabled: bool) -> None: ...
def get_default_plays(self, guild_id: int | None = None) -> int: ... def get_default_plays(self, guild_id: int | None = None) -> int: ...
def set_default_plays(self, guild_id: int, value: int) -> None: ... def set_default_plays(self, guild_id: int, value: int) -> None: ...
+2
View File
@@ -9,6 +9,7 @@ from zyrarbot.storage.ports.guild_config import GuildConfigRepository
from zyrarbot.storage.ports.uploads import UploadsRepository from zyrarbot.storage.ports.uploads import UploadsRepository
from zyrarbot.ui.api.connection_manager import ConnectionManager from zyrarbot.ui.api.connection_manager import ConnectionManager
from zyrarbot.ui.api.routers import ( from zyrarbot.ui.api.routers import (
admin_router,
auth_router, auth_router,
playback_router, playback_router,
queue_router, queue_router,
@@ -22,6 +23,7 @@ from zyrarbot.ui.api.status_service import StatusService
from zyrarbot.ui.bot.guild_resolver import GuildResolver from zyrarbot.ui.bot.guild_resolver import GuildResolver
ROUTERS = ( ROUTERS = (
admin_router.router,
auth_router.router, auth_router.router,
status_router.router, status_router.router,
playback_router.router, playback_router.router,
+76
View File
@@ -0,0 +1,76 @@
from __future__ import annotations
import discord
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from zyrarbot.music.notification_channel_resolver import NotificationChannelResolver
from zyrarbot.ui.api.auth import require_admin
from zyrarbot.ui.api.dependencies import get_broadcaster, get_config, get_guilds, get_player
from zyrarbot.ui.api.status_broadcaster import StatusBroadcaster
from zyrarbot.ui.bot.guild_resolver import GuildResolver
router = APIRouter(prefix="/api/admin", tags=["admin"])
class LogChannelUpdate(BaseModel):
channel_id: str | None
@router.get("/servers")
async def list_servers(
_: dict = Depends(require_admin),
guilds: GuildResolver = Depends(get_guilds),
player=Depends(get_player),
config=Depends(get_config),
) -> list[dict]:
result = []
for guild in guilds.guilds():
channel = player.notification_channel(guild.id)
disabled = config.is_notification_channel_disabled(guild.id)
result.append({
"guild_id": str(guild.id),
"guild_name": guild.name,
"log_channel_id": None if disabled or channel is None else str(channel.id),
"disabled": disabled,
"channels": [
{"channel_id": str(item.id), "channel_name": item.name}
for item in NotificationChannelResolver.writable_channels(guild)
],
})
return result
@router.put("/servers/{guild_id}/log-channel")
async def set_log_channel(
guild_id: int,
body: LogChannelUpdate,
_: dict = Depends(require_admin),
guilds: GuildResolver = Depends(get_guilds),
player=Depends(get_player),
config=Depends(get_config),
broadcaster: StatusBroadcaster = Depends(get_broadcaster),
) -> dict[str, str | bool | None]:
guild = next((item for item in guilds.guilds() if item.id == guild_id), None)
if guild is None:
raise HTTPException(status_code=404, detail="Nie znaleziono serwera")
if body.channel_id is None or body.channel_id == "":
player.set_notification_channel(guild_id, None)
config.set_notification_channel_id(guild_id, None)
config.set_notification_channel_disabled(guild_id, True)
await broadcaster.broadcast()
return {"guild_id": str(guild_id), "log_channel_id": None, "disabled": True}
if not body.channel_id.isdecimal():
raise HTTPException(status_code=400, detail="Nieprawidłowe ID kanału")
channel = guilds.channel(int(body.channel_id))
if not isinstance(channel, discord.TextChannel) or channel.guild.id != guild_id:
raise HTTPException(status_code=404, detail="Kanał nie należy do tego serwera")
if channel not in NotificationChannelResolver.writable_channels(guild):
raise HTTPException(status_code=403, detail="Bot nie może pisać na tym kanale")
player.set_notification_channel(guild_id, channel)
config.set_notification_channel_id(guild_id, channel.id)
await broadcaster.broadcast()
return {"guild_id": str(guild_id), "log_channel_id": str(channel.id), "disabled": False}
+2 -5
View File
@@ -17,6 +17,7 @@ from zyrarbot.ui.api.requests.connect_request import ConnectRequest
from zyrarbot.ui.api.requests.notification_channel_request import NotificationChannelRequest from zyrarbot.ui.api.requests.notification_channel_request import NotificationChannelRequest
from zyrarbot.ui.api.status_broadcaster import StatusBroadcaster from zyrarbot.ui.api.status_broadcaster import StatusBroadcaster
from zyrarbot.ui.api.auth import current_user from zyrarbot.ui.api.auth import current_user
from zyrarbot.ui.api.auth import require_admin
from zyrarbot.ui.bot.guild_resolver import GuildResolver from zyrarbot.ui.bot.guild_resolver import GuildResolver
router = APIRouter(prefix="/api", tags=["voice"]) router = APIRouter(prefix="/api", tags=["voice"])
@@ -30,6 +31,7 @@ async def set_notification_channel(
player: MusicPlayer = Depends(get_player), player: MusicPlayer = Depends(get_player),
config: GuildConfigRepository = Depends(get_config), config: GuildConfigRepository = Depends(get_config),
broadcaster: StatusBroadcaster = Depends(get_broadcaster), broadcaster: StatusBroadcaster = Depends(get_broadcaster),
_: dict = Depends(require_admin),
) -> dict[str, str]: ) -> dict[str, str]:
try: try:
channel_id = int(request.channel_id) channel_id = int(request.channel_id)
@@ -85,11 +87,6 @@ async def connect(
await channel.connect() await channel.connect()
if not player.notification_channel(guild_id):
player.set_notification_channel(
guild_id, NotificationChannelResolver.first_writable(channel.guild)
)
await broadcaster.broadcast() await broadcaster.broadcast()
return {"status": "connected", "channel": channel.name} return {"status": "connected", "channel": channel.name}
+1 -4
View File
@@ -48,16 +48,13 @@ class VoiceCog(MusicCogBase, name="Voice"):
if existing: if existing:
await existing.disconnect() await existing.disconnect()
await channel.connect() await channel.connect()
if not self.player.notification_channel(interaction.guild.id):
self.player.set_notification_channel(
interaction.guild.id, NotificationChannelResolver.first_writable(interaction.guild)
)
await self.player.trigger_state_change() await self.player.trigger_state_change()
await interaction.response.send_message( await interaction.response.send_message(
embed=message_embed("Kanał głosowy", f"Połączono z kanałem **{channel.name}**.") embed=message_embed("Kanał głosowy", f"Połączono z kanałem **{channel.name}**.")
) )
@discord.app_commands.command(name="notificationchannel", description="Ustaw kanał logów") @discord.app_commands.command(name="notificationchannel", description="Ustaw kanał logów")
@discord.app_commands.checks.has_permissions(administrator=True)
@discord.app_commands.describe(channel="Kanał tekstowy") @discord.app_commands.describe(channel="Kanał tekstowy")
async def notification_channel( async def notification_channel(
self, interaction: discord.Interaction, channel: discord.TextChannel self, interaction: discord.Interaction, channel: discord.TextChannel