Kurs vibe coding z AI · Moduł 9: Discord Clone

Roles, Permissions i Moderation

9 min czytania
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 limitu

Nasz 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. 1. Jak działają permissions w Discord?

  2. 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

Przydatne artykuły