Kurs vibe coding z AI · Moduł 9: Discord Clone
Roles, Permissions i Moderation
W tej lekcji3
Na serwer gildii wkradł się Blackbeard i zaczął zasypywać spamem kanał ogłoszeń. Zwykły członek nic nie może zrobić, kapitana nie ma, a pierwszy oficer nie wie, czy wolno mu kogoś wyrzucić. Serwer potrzebuje jasnych zasad: kto może pisać, kto usuwać wiadomości, a kto wyrzucać i banować. Discord zapisuje je jako uprawnienia bitowe (bitwise permissions) z hierarchią rang (role hierarchy) i nadpisaniami w kanałach (channel overrides).
Uprawnienia jako bity
Każde uprawnienie to jeden bit w liczbie. Wartość tworzymy przesunięciem bitowym, na przykład KICK_MEMBERS = 1 << 1 albo ADMINISTRATOR = 1 << 3:
// Bitwise permissions like Discord
enum PermissionBits {
CREATE_INSTANT_INVITE = 1 << 0,
KICK_MEMBERS = 1 << 1,
BAN_MEMBERS = 1 << 2,
ADMINISTRATOR = 1 << 3,
MANAGE_CHANNELS = 1 << 4,
MANAGE_SERVER = 1 << 5,
ADD_REACTIONS = 1 << 6,
VIEW_AUDIT_LOG = 1 << 7,
PRIORITY_SPEAKER = 1 << 8, // voice
STREAM = 1 << 9, // voice
VIEW_CHANNEL = 1 << 10,
SEND_MESSAGES = 1 << 11,
SEND_TTS_MESSAGES = 1 << 12,
MANAGE_MESSAGES = 1 << 13,
EMBED_LINKS = 1 << 14,
ATTACH_FILES = 1 << 15,
READ_MESSAGE_HISTORY = 1 << 16,
MENTION_EVERYONE = 1 << 17,
USE_EXTERNAL_EMOJIS = 1 << 18,
CONNECT = 1 << 20, // voice
SPEAK = 1 << 21, // voice
MUTE_MEMBERS = 1 << 22, // voice
DEAFEN_MEMBERS = 1 << 23, // voice
MOVE_MEMBERS = 1 << 24, // voice
USE_VAD = 1 << 25, // voice activity detection
CHANGE_NICKNAME = 1 << 26,
MANAGE_NICKNAMES = 1 << 27,
MANAGE_ROLES = 1 << 28,
MANAGE_WEBHOOKS = 1 << 29,
MANAGE_EMOJIS = 1 << 30,
}
// Check if user has permission
function hasPermission(
member: Member,
permission: PermissionBits,
roles: Role[],
channel?: Channel
): boolean {
// Owner has all permissions
if (member.isOwner) return true;
// Get all member roles
const memberRoles = roles.filter(r => member.roles.includes(r.id));
// Check for ADMINISTRATOR
if (memberRoles.some(r => r.permissions & PermissionBits.ADMINISTRATOR)) {
return true;
}
// Calculate base permissions from roles
let permissions = 0;
memberRoles.forEach(role => {
permissions |= role.permissions;
});
// Apply channel-specific overrides
if (channel) {
channel.permissions.forEach(override => {
if (member.roles.includes(override.roleId)) {
// Apply denies first
override.deny.forEach(perm => {
permissions &= ~perm;
});
// Then apply allows
override.allow.forEach(perm => {
permissions |= perm;
});
}
});
}
return (permissions & permission) === permission;
}1 << 3 przesuwa jedynkę o trzy miejsca w lewo, co daje binarnie 1000, czyli 8. Ranga z kilkoma uprawnieniami ma po prostu kilka zapalonych bitów. W tej lekcji role.permissions jest więc liczbą, a nie tablicą nazw jak w lekcji o serwerach.
hasPermission liczy uprawnienia w czterech krokach. Właściciel od razu dostaje true (w przykładzie pole isOwner, w pełnym kodzie porównasz server.owner z id członka). Jeśli któraś z rang członka, wybranych warunkiem member.roles.includes(role.id), ma ADMINISTRATOR, funkcja też zwraca true, bo ADMINISTRATOR daje pełny dostęp do wszystkich uprawnień. Potem łączy uprawnienia rang operatorem OR: permissions |= role.permissions. Na koniec nakłada channel overrides: najpierw deny, czyli usunięcie bitu przez &= ~perm, potem allow, czyli dodanie przez |=. Discord stosuje tę samą zasadę: zakazy przed zezwoleniami.
Ostatnia linia, (permissions & permission) === permission, sprawdza, czy szukany bit jest zapalony. Wywołanie wygląda tak: hasPermission(member, KICK_MEMBERS, roles).
Uważaj na granicę liczb. Operatory bitowe w JavaScripcie działają na liczbach 32-bitowych, a Discord ma już uprawnienia na bitach powyżej 50 i przesyła je jako tekst:
console.log(1 << 31); // -2147483648: liczba ujemna
console.log(1 << 32); // 1: przesunięcie zawinęło się do początku
console.log(1n << 40n); // 1099511627776n: BigInt nie ma tego limituNasz enum kończy się na bicie 30, więc mieści się w zakresie, ale przy prawdziwym API Discorda uprawnienia liczy się na typie BigInt.
Menedżer rang
Typowa hierarchia, od najwyższej rangi: Owner (ADMINISTRATOR), Admin (MANAGE_SERVER), Moderator (KICK_MEMBERS) i Member (SEND_MESSAGES). Menedżer pokazuje rangi jako listę z edytorem:
'use client';
import { useState } from 'react';
interface RoleManagerProps {
server: Server;
onCreateRole: () => void;
onEditRole: (roleId: string) => void;
onDeleteRole: (roleId: string) => void;
}
export default function RoleManager({
server,
onCreateRole,
onEditRole,
onDeleteRole
}: RoleManagerProps) {
const [selectedRoleId, setSelectedRoleId] = useState<string | null>(null);
const selectedRole = server.roles.find(r => r.id === selectedRoleId);
return (
<div className="flex h-screen bg-gray-800">
{/* Roles List */}
<div className="w-64 bg-gray-900 p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="font-bold text-white">Roles</h3>
<button
onClick={onCreateRole}
className="w-8 h-8 bg-green-600 hover:bg-green-700 rounded-full text-white"
>
+
</button>
</div>
<div className="space-y-1">
{server.roles
.sort((a, b) => b.position - a.position)
.map(role => (
<button
key={role.id}
onClick={() => setSelectedRoleId(role.id)}
className={`w-full text-left px-3 py-2 rounded flex items-center justify-between ${
selectedRoleId === role.id
? 'bg-gray-700'
: 'hover:bg-gray-800'
}`}
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: role.color || '#99AAB5' }}
/>
<span className="text-white text-sm truncate">
{role.name}
</span>
</div>
<span className="text-xs text-gray-400">
{role.memberCount}
</span>
</button>
))}
</div>
<div className="mt-4 pt-4 border-t border-gray-800 text-xs text-gray-400">
Drag to reorder roles
</div>
</div>
{/* Role Editor */}
{selectedRole ? (
<div className="flex-1 p-6 overflow-y-auto">
<div className="max-w-2xl">
<h2 className="text-2xl font-bold text-white mb-6">
Edit Role: {selectedRole.name}
</h2>
{/* Display */}
<div className="bg-gray-700 rounded-lg p-4 mb-6">
<h3 className="font-bold text-white mb-3">Display</h3>
<div className="mb-4">
<label className="block text-sm text-gray-300 mb-2">
Role Name
</label>
<input
type="text"
value={selectedRole.name}
className="w-full bg-gray-900 text-white px-3 py-2 rounded"
/>
</div>
<div className="mb-4">
<label className="block text-sm text-gray-300 mb-2">
Role Color
</label>
<div className="flex items-center gap-3">
<input
type="color"
value={selectedRole.color || '#99AAB5'}
className="w-16 h-10 rounded cursor-pointer"
/>
<span className="text-gray-400 text-sm">
{selectedRole.color || 'Default'}
</span>
</div>
</div>
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm text-gray-300">
<input
type="checkbox"
checked={selectedRole.isHoisted}
className="w-4 h-4"
/>
Display role members separately from online members
</label>
<label className="flex items-center gap-2 text-sm text-gray-300">
<input
type="checkbox"
checked={selectedRole.isMentionable}
className="w-4 h-4"
/>
Allow anyone to @mention this role
</label>
</div>
</div>
{/* Permissions */}
<div className="bg-gray-700 rounded-lg p-4">
<h3 className="font-bold text-white mb-3">Permissions</h3>
<div className="space-y-3">
{Object.entries(PermissionBits).map(([name, bit]) => {
const hasPermission = (selectedRole.permissions & bit) === bit;
return (
<label
key={name}
className="flex items-center gap-3 text-sm text-gray-300 hover:bg-gray-600 p-2 rounded cursor-pointer"
>
<input
type="checkbox"
checked={hasPermission}
className="w-4 h-4"
/>
<div className="flex-1">
<div className="font-semibold">
{name.split('_').map(w => w.charAt(0) + w.slice(1).toLowerCase()).join(' ')}
</div>
<div className="text-xs text-gray-400">
{getPermissionDescription(name)}
</div>
</div>
</label>
);
})}
</div>
</div>
{/* Actions */}
<div className="mt-6 flex gap-3">
<button
onClick={() => onEditRole(selectedRole.id)}
className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded font-semibold"
>
Save Changes
</button>
<button
onClick={() => onDeleteRole(selectedRole.id)}
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded font-semibold"
>
Delete Role
</button>
</div>
</div>
</div>
) : (
<div className="flex-1 flex items-center justify-center text-gray-500">
Select a role to edit
</div>
)}
</div>
);
}
function getPermissionDescription(permission: string): string {
const descriptions: Record<string, string> = {
ADMINISTRATOR: 'Full access to all channels and settings',
MANAGE_SERVER: 'Change server name, region, and other settings',
MANAGE_ROLES: 'Create, edit, and delete roles lower than this one',
MANAGE_CHANNELS: 'Create, edit, and delete channels',
KICK_MEMBERS: 'Remove members from the server',
BAN_MEMBERS: 'Ban members from the server',
SEND_MESSAGES: 'Send messages in text channels',
MANAGE_MESSAGES: 'Delete messages sent by other members',
MENTION_EVERYONE: 'Use @everyone and @here mentions',
CONNECT: 'Connect to voice channels',
SPEAK: 'Speak in voice channels',
MUTE_MEMBERS: 'Mute members in voice channels',
DEAFEN_MEMBERS: 'Deafen members in voice channels',
// Add more descriptions...
};
return descriptions[permission] || 'Permission description';
}Lista sortuje rangi malejąco po position, więc najważniejsza jest na górze, a kropka obok nazwy ma kolor rangi. Pole wyboru przy uprawnieniu jest zaznaczone, gdy (selectedRole.permissions & bit) === bit, czyli dokładnie ten sam test co w hasPermission. Pola formularza nie mają jeszcze onChange, więc na razie to podgląd, a zapisem zajmą się onEditRole i serwer.
Narzędzia moderatora
Typ ModerationAction obejmuje akcje moderatora kick, ban, timeout i warn, a także unban do cofnięcia bana:
interface ModerationAction {
id: string;
type: 'kick' | 'ban' | 'timeout' | 'warn' | 'unban';
targetUserId: string;
moderatorId: string;
reason: string;
duration?: number; // for timeout/ban (seconds)
createdAt: Date;
}
interface ModeratorToolsProps {
server: Server;
targetMember: Member;
currentUserId: string;
onKick: (reason: string) => void;
onBan: (reason: string, duration?: number) => void;
onTimeout: (duration: number, reason: string) => void;
}
export default function ModeratorTools({
server,
targetMember,
currentUserId,
onKick,
onBan,
onTimeout
}: ModeratorToolsProps) {
const [action, setAction] = useState<'kick' | 'ban' | 'timeout' | null>(null);
const [reason, setReason] = useState('');
const [duration, setDuration] = useState(3600); // 1 hour default
const currentMember = server.members.find(m => m.userId === currentUserId);
if (!currentMember) return null;
const canKick = hasPermission(currentMember, PermissionBits.KICK_MEMBERS, server.roles);
const canBan = hasPermission(currentMember, PermissionBits.BAN_MEMBERS, server.roles);
return (
<div className="bg-gray-800 rounded-lg p-4">
<h3 className="font-bold text-white mb-4">Moderator Actions</h3>
<div className="space-y-2 mb-4">
{canKick && (
<button
onClick={() => setAction('kick')}
className="w-full py-2 bg-orange-600 hover:bg-orange-700 text-white rounded font-semibold"
>
Kick from Server
</button>
)}
{canBan && (
<>
<button
onClick={() => setAction('ban')}
className="w-full py-2 bg-red-600 hover:bg-red-700 text-white rounded font-semibold"
>
Ban from Server
</button>
<button
onClick={() => setAction('timeout')}
className="w-full py-2 bg-yellow-600 hover:bg-yellow-700 text-white rounded font-semibold"
>
Timeout
</button>
</>
)}
</div>
{action && (
<div className="border-t border-gray-700 pt-4">
<h4 className="text-white font-semibold mb-3">
{action === 'kick' && 'Kick Member'}
{action === 'ban' && 'Ban Member'}
{action === 'timeout' && 'Timeout Member'}
</h4>
{action === 'timeout' && (
<div className="mb-3">
<label className="block text-sm text-gray-300 mb-2">
Duration
</label>
<select
value={duration}
onChange={(e) => setDuration(parseInt(e.target.value))}
className="w-full bg-gray-900 text-white px-3 py-2 rounded"
>
<option value={60}>60 seconds</option>
<option value={300}>5 minutes</option>
<option value={600}>10 minutes</option>
<option value={3600}>1 hour</option>
<option value={86400}>1 day</option>
<option value={604800}>1 week</option>
</select>
</div>
)}
<div className="mb-3">
<label className="block text-sm text-gray-300 mb-2">
Reason
</label>
<textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
className="w-full bg-gray-900 text-white px-3 py-2 rounded resize-none"
rows={3}
/>
</div>
<div className="flex gap-2">
<button
onClick={() => {
if (action === 'kick') onKick(reason);
if (action === 'ban') onBan(reason);
if (action === 'timeout') onTimeout(duration, reason);
setAction(null);
setReason('');
}}
disabled={!reason.trim()}
className="flex-1 py-2 bg-red-600 hover:bg-red-700 disabled:bg-gray-600 text-white rounded font-semibold"
>
Confirm
</button>
<button
onClick={() => setAction(null)}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded"
>
Cancel
</button>
</div>
</div>
)}
</div>
);
}Kick usuwa członka z serwera, ale może on wrócić z nowym zaproszeniem. Ban blokuje powrót. Timeout czasowo odbiera prawo pisania i mówienia; czasy z listy, od najkrótszego, to 60 sekund, 5 minut, 10 minut, 1 godzina, 1 dzień i 1 tydzień, a Discord pozwala najwyżej na 28 dni. Przyciski widzi tylko ten, kto ma uprawnienie, bo canKick i canBan liczy hasPermission. Potwierdzenie wymaga podania powodu: disabled={!reason.trim()}.
Rada Kapitana Redbearda: ukrycie przycisku to nie zabezpieczenie. Serwer musi sprawdzić hasPermission jeszcze raz przy każdym wyrzuceniu i banie, bo sprytny pirat wyśle zapytanie z pominięciem Twojego widoku.
Zapamiętaj: uprawnienia to bity sumowane z rang, a nadpisania w kanale najpierw zabierają, potem dodają. Pirate's Cove ma już wszystko, co budowaliśmy po kolei: serwery z kategoriami i kanałami, kanały głosowe z rich presence oraz rangi z moderacją. W edytorze poniżej masz menedżer rang załogi: wybierz rangę, zobacz jej kolor i listę uprawnień do zaznaczenia, a potem rozwiąż zadania.
Kod do tej lekcji: App.jsx
// Menedżer rang
import { useState } from 'react';
const roles = [
{ id: 1, name: 'Kapitan', color: '#e74c3c', memberCount: 3, position: 4 },
{ id: 2, name: 'Pierwszy oficer', color: '#3498db', memberCount: 5, position: 3 },
{ id: 3, name: 'Nawigator', color: '#2ecc71', memberCount: 8, position: 2 },
{ id: 4, name: 'Załoga', color: '#95a5a6', memberCount: 45, position: 1 }
];
const permissions = [
'ADMINISTRATOR',
'MANAGE_SERVER',
'MANAGE_ROLES',
'MANAGE_CHANNELS',
'KICK_MEMBERS',
'BAN_MEMBERS',
'SEND_MESSAGES',
'MANAGE_MESSAGES'
];
export default function App() {
const [selectedRoleId, setSelectedRoleId] = useState(1);
const selectedRole = roles.find(r => r.id === selectedRoleId);
return (
<div className="flex h-screen bg-gray-800">
{/* Lista rang */}
<div className="w-64 bg-gray-900 p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="font-bold text-white">Rangi</h3>
<button className="w-8 h-8 bg-green-600 hover:bg-green-700 rounded-full text-white">
+
</button>
</div>
<div className="space-y-1">
{roles.sort((a, b) => b.position - a.position).map(role => (
<button
key={role.id}
onClick={() => setSelectedRoleId(role.id)}
className={`w-full text-left px-3 py-2 rounded flex items-center justify-between ${
selectedRoleId === role.id ? 'bg-gray-700' : 'hover:bg-gray-800'
}`}
>
<div className="flex items-center gap-2 flex-1">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: role.color }}
/>
<span className="text-white text-sm truncate">{role.name}</span>
</div>
<span className="text-xs text-gray-400">{role.memberCount}</span>
</button>
))}
</div>
</div>
{/* Edytor rangi */}
{selectedRole && (
<div className="flex-1 p-6 overflow-y-auto">
<h2 className="text-2xl font-bold text-white mb-6">
Edytuj rangę: {selectedRole.name}
</h2>
{/* Wygląd */}
<div className="bg-gray-700 rounded-lg p-4 mb-6">
<h3 className="font-bold text-white mb-3">Wygląd</h3>
<div className="mb-4">
<label className="block text-sm text-gray-300 mb-2">Nazwa rangi</label>
<input
type="text"
key={selectedRole.id}
defaultValue={selectedRole.name}
className="w-full bg-gray-900 text-white px-3 py-2 rounded"
/>
</div>
<div className="mb-4">
<label className="block text-sm text-gray-300 mb-2">Kolor rangi</label>
<div className="flex items-center gap-3">
<input
type="color"
key={selectedRole.id}
defaultValue={selectedRole.color}
className="w-16 h-10 rounded cursor-pointer"
/>
<span className="text-gray-400 text-sm">{selectedRole.color}</span>
</div>
</div>
<label className="flex items-center gap-2 text-sm text-gray-300 mb-2">
<input type="checkbox" className="w-4 h-4" />
Wyświetlaj członków tej rangi osobno
</label>
<label className="flex items-center gap-2 text-sm text-gray-300">
<input type="checkbox" className="w-4 h-4" />
Pozwól każdemu wspominać tę rangę przez @
</label>
</div>
{/* Uprawnienia */}
<div className="bg-gray-700 rounded-lg p-4">
<h3 className="font-bold text-white mb-3">Uprawnienia</h3>
<div className="space-y-2">
{permissions.map(permission => (
<label
key={permission}
className="flex items-center gap-3 text-sm text-gray-300 hover:bg-gray-600 p-2 rounded cursor-pointer"
>
<input type="checkbox" className="w-4 h-4" />
<span>{permission.split('_').join(' ')}</span>
</label>
))}
</div>
</div>
{/* Akcje */}
<div className="mt-6 flex gap-3">
<button className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded font-semibold">
Zapisz zmiany
</button>
<button className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded font-semibold">
Usuń rangę
</button>
</div>
</div>
)}
</div>
);
}Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Jak działają permissions w Discord?
2. Jak w enumie PermissionBits definiuje się wartość uprawnienia, np. KICK_MEMBERS?
To 2 z 8 pytań do tej lekcji. Pozostałe rozwiążesz w grze.
Zadania praktyczne w grze
- Klikanie w kolejności
Klikaj, aby zbudować wartość bitu uprawnienia: ADMINISTRATOR = 1 << 3
- Edytor kodu
Role management interface
- Układanie w poziomie
Ułóż role hierarchy (od najwyższej):
- Układanie w pionie
Ułóż kroki wyliczania uprawnień w funkcji hasPermission:
- Klikanie w kolejności
Klikaj, aby zbudować dodanie uprawnienia rangi do zmiennej permissions:
- Układanie w poziomie
Ułóż sprawdzenie, czy członek ma daną rangę: member.roles.includes(role.id)
- Układanie w pionie
Ułóż dostępne czasy trwania timeout (od najkrótszego):
- Edytor kodu
Moderation panel
- Układanie w pionie
Ułóż kroki działania funkcji getHighestRole:
- Układanie w poziomie
Ułóż argumenty wywołania: hasPermission(member, KICK_MEMBERS, roles)
- Klikanie w kolejności
Klikaj, aby zbudować sprawdzenie czy uprawnienie jest ustawione: (permissions & permission) === permission