commit dd7f61a958028429d76c016dfca74d86c90f66f4 Author: Kilka_v_HJlebe Date: Fri Aug 7 13:41:43 2026 +0300 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8d16946 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.gradle +.idea +build +run diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..cdfaa5e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,50 @@ +# ShbDiscordBot contributor guide + +## Purpose and invariants + +This repository builds one Paper plugin which embeds a JDA Discord bot. Discord is the entry point for issuing short-lived link codes, Minecraft is the only place where a code can be consumed, and ShbUtils Core API is the source of truth for persistent Discord-to-Minecraft links. + +- `/auth` is a global Discord slash command intended for bot DMs. +- `/link ` is a player-only Paper command. +- `/sdb reload` is the operator-only full runtime/config reload command. Do not override Paper's built-in `/reload` command. +- Link codes are process-local, single-use, active for ten minutes by default, and must never be logged. +- Existing Discord or Minecraft links must not be overwritten. +- Discord roles only grant LuckPerms nodes. This project deliberately does not revoke them. + +## Build and verification + +- Use Java 25 and the checked-in Gradle wrapper. +- `./gradlew test` runs the automated test suite. +- `./gradlew shadowJar` creates the deployable plugin JAR; the plain JAR does not include JDA/Jackson. +- `./gradlew build` must pass before handing work off. +- LuckPerms and Paper are compile-only/server-provided dependencies. Do not shade either of them. + +## Architecture rules + +- Keep configuration parsing, Discord/JDA integration, ShbUtils HTTP transport, link orchestration, Paper commands, and LuckPerms synchronization in separate classes. +- Do not perform network requests, JDA waits, or LuckPerms user loads on the Paper main thread. +- Only touch Bukkit player/server state on the Paper main thread. Capture UUIDs and immutable values before crossing thread boundaries. +- Make scheduled role scans non-overlapping and bound API concurrency. +- Treat API `401` and validation/conflict responses as permanent failures; treat I/O, timeouts, `429`, and `5xx` as retryable. +- On disable, cancel tasks and close JDA plus plugin-owned executors. +- A runtime reload must validate the new config before stopping the current runtime, invalidate pending codes, await JDA shutdown off the Paper thread, recreate all config-bound services, and immediately run role synchronization. + +## Security and configuration + +- Never commit real Discord tokens or `X-Internal-Secret` values. The resource `config.yml` contains empty placeholders only. +- Never include secrets, bearer tokens, full HTTP headers, or active link codes in logs or exceptions shown to players. +- Parse Discord snowflakes as strings/unsigned IDs and Minecraft identifiers as UUIDs. +- Generate codes with `SecureRandom`; preserve leading zeroes and enforce rate limiting when consuming invalid codes. + +## API contract and documentation + +- The local API reference is `docs/API.md`, based on `https://api.shlakoblock.com/api.json`. +- Runtime code uses only internal status and Discord-link endpoints. Do not use the API verification-code endpoints for the Discord-to-Minecraft flow: those endpoints implement the reverse direction. +- When the upstream OpenAPI version changes, compare every path/schema and update `docs/API.md`, DTOs, tests, and the recorded snapshot date together. + +## Testing expectations + +- Cover code uniqueness, expiry, replacement, rate limiting, reservation/release, and concurrent single-use behavior. +- Test HTTP paths, JSON fields, internal-secret headers, status handling, and timeout/error classification without contacting production. +- Test role-to-permission union, idempotent grants, unlinked users, and grant-only behavior. +- A feature is incomplete if it can block the Paper main thread or leak a secret/code to logs. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0a97259 --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# ShbDiscordBot + +Paper-плагин со встроенным Discord-ботом для привязки Discord к Minecraft и выдачи LuckPerms permissions по ролям. + +## Как работает привязка + +1. Участник настроенного Discord-сервера пишет боту в личных сообщениях `/auth`. +2. Бот выдаёт одноразовый шестизначный код на 10 минут. +3. Игрок выполняет на Minecraft-сервере `/link <код>`. +4. Плагин проверяет обе стороны и сохраняет `discordId` через ShbUtils Core API. + +Существующие связи не перезаписываются. Незавершённые коды хранятся только в памяти и после рестарта исчезают. + +## Сборка и установка + +```bash +./gradlew test shadowJar +``` + +Установите `build/libs/ShbDiscordBot-1.0.jar` и LuckPerms на Paper-сервер. Обычный `*-plain.jar` не является готовым артефактом для установки. + +В [Discord Developer Portal](https://discord.com/developers/applications): + +1. Создайте bot application. +2. Включите privileged intent **Server Members Intent**. +3. Пригласите бота в guild со scopes `bot` и `applications.commands`. +4. Разрешите участникам сервера отправлять боту личные сообщения. + +Запустите сервер один раз, заполните `plugins/ShbDiscordBot/config.yml` и перезапустите его. Реальные `discord.token` и `api.internal-secret` нельзя публиковать или коммитить. + +## Конфигурация ролей + +```yaml +role-sync: + interval-seconds: 300 + max-concurrent-requests: 4 + mappings: + "123456789012345678": + - "group.vip" + - "server.chat.color" + "234567890123456789": + - "server.fly" +``` + +После успешной привязки, добавления роли и раз в пять минут плагин добавляет отсутствующие nodes игроку. Удаление Discord-роли не снимает ранее выданные permissions. + +После изменения любых настроек выполните из консоли или от имени оператора: + +```text +/sdb reload +``` + +Команда сначала проверяет новый конфиг, затем полностью переподключает Discord-бота и API-клиент, пересоздаёт периодическую задачу и немедленно запускает синхронизацию ролей. Незавершённые шестизначные коды при этом аннулируются. Встроенная Paper-команда `/reload` намеренно не переопределяется. + +Полный локальный справочник ShbUtils API находится в [docs/API.md](docs/API.md). Правила для следующих разработчиков и агентов — в [AGENTS.md](AGENTS.md). + +## Если бот не видит guild + +Сообщение `Discord-бот не видит настроенный guild` означает, что авторизация прошла, но Discord не вернул настроенный сервер для этого bot token. + +- Скопируйте ID сервера через Discord Developer Mode и проверьте `discord.guild-id`. +- Убедитесь, что `discord.token` принадлежит тому же application, которого приглашали на сервер. +- Проверьте список `Видимые guild` в логе: он показывает guild IDs, доступные боту. +- Если список пуст, пригласите именно bot installation со scopes `bot` и `applications.commands`, а не только user installation. +- Включите **Server Members Intent** до повторного запуска плагина. diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..265821c --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,62 @@ +plugins { + id("java-library") + id("xyz.jpenilla.run-paper") version "3.0.2" + id("com.gradleup.shadow") version "9.2.2" +} + +repositories { + mavenCentral() + maven("https://repo.papermc.io/repository/maven-public/") +} + +dependencies { + compileOnly("io.papermc.paper:paper-api:26.1.2.build.+") + compileOnly("net.luckperms:api:5.5") + + implementation("net.dv8tion:JDA:6.5.0") + implementation("com.fasterxml.jackson.core:jackson-databind:2.22.1") + + testImplementation(platform("org.junit:junit-bom:5.13.4")) + testImplementation("org.junit.jupiter:junit-jupiter") + testImplementation("net.luckperms:api:5.5") + testImplementation("org.mockito:mockito-core:5.18.0") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +java { + toolchain.languageVersion = JavaLanguageVersion.of(25) +} + +tasks { + test { + useJUnitPlatform() + } + + shadowJar { + archiveClassifier.set("") + mergeServiceFiles() + } + + jar { + archiveClassifier.set("plain") + } + + build { + dependsOn(shadowJar) + } + + runServer { + // Configure the Minecraft version for our task. + // This is the only required configuration besides applying the plugin. + // Your plugin's jar (or shadowJar if present) will be used automatically. + minecraftVersion("26.1.2") + jvmArgs("-Xms2G", "-Xmx2G") + } + + processResources { + val props = mapOf("version" to version, "description" to project.description) + filesMatching("plugin.yml") { + expand(props) + } + } +} diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..a2831ac --- /dev/null +++ b/docs/API.md @@ -0,0 +1,367 @@ +# ShbUtils Core API + +Локальный справочник по API сервиса `https://api.shlakoblock.com`. + +- Источник: `https://api.shlakoblock.com/api.json` +- OpenAPI: `3.1.0` +- Версия сервиса: `2.1.3-SNAPSHOT` +- Дата снимка: `2026-07-22` +- Формат запросов и ответов, если не указано иное: `application/json` + +Документ является снимком контракта. В OpenAPI не описаны rate limits, гарантии идемпотентности и формат JSON-тел для большинства ошибок. + +## Авторизация + +### Internal endpoints + +Все пути `/internal/*` требуют заголовок: + +```http +X-Internal-Secret: +``` + +При отсутствующем или неверном секрете возвращается `401 invalid_internal_secret`. + +### Player endpoints + +Пути `/api/*` требуют bearer-токен, выданный `POST /internal/auth/start`: + +```http +Authorization: Bearer +``` + +Хотя схема `BearerAuth` присутствует в OpenAPI, требования `security` не проставлены у самих операций. Необходимость токена следует из ответов `401 missing/invalid token`; для этих endpoint также возможен `403 player_offline`. + +## Basic + +### `GET /` + +Маркер сервиса. Возвращает `200` и название сервиса простым текстом. + +### `GET /health` + +Проверяет соединения с БД и Redis. + +| Код | Значение | +| --- | --- | +| `200` | `status: ok` | +| `503` | `status: degraded` | + +## Internal + +### `POST /internal/join` + +Регистрирует вход игрока. + +```json +{ + "uuid": "11111111-1111-1111-1111-111111111111", + "playerName": "Player" +} +``` + +`playerName` необязателен и может быть `null`. Успех: `200 ApiMessage`. Ошибки: `400 invalid_body / invalid_uuid`, `401 invalid_internal_secret`. + +### `POST /internal/quit` + +Регистрирует выход игрока. Тело: `UuidRequest`. Успех: `200 ApiMessage`. Ошибки: `400 invalid_body / invalid_uuid`, `401`. + +### `POST /internal/auth/start` + +Выдаёт bearer-токен для игрока. Игрок должен быть онлайн. + +- Тело: `UuidRequest`. +- Успех: `200 TokenResponse`. +- Ошибки: `400 invalid_body / invalid_uuid`, `401`, `403 player_offline`. + +### `POST /internal/verification/code` + +Создаёт verification-код для Minecraft UUID. Это направление «Minecraft → внешний клиент», поэтому ShbDiscordBot не использует endpoint для своего `/auth`. + +- Тело: `VerificationCodeRequest`. +- Успех: `200 VerificationCodeResponse`. +- Ошибки: `400 invalid_body / invalid_uuid`, `401`. + +### `POST /internal/verification/verify` + +Проверяет ранее созданный код и привязывает Telegram и/или Discord. + +```json +{ + "code": "123456", + "telegramId": 123456789, + "telegramTag": "example", + "discordId": "123456789012345678" +} +``` + +Кроме `code`, поля необязательны и nullable. Успех: `200 VerificationVerifyResponse`. Ошибки: `400 invalid_body`, `401`, `404 invalid_or_expired_code`. + +### `POST /internal/auth/password` + +Проверяет пароль игрока без передачи хэша наружу. + +```json +{ + "uuid": "11111111-1111-1111-1111-111111111111", + "password": "plain-text-password" +} +``` + +Успех: `200 PasswordVerifyResponse`. Ошибки: `400 invalid_body / invalid_uuid`, `401`. + +### `POST /internal/auth/password/change` + +Заменяет пароль игрока. + +```json +{ + "uuid": "11111111-1111-1111-1111-111111111111", + "newPassword": "new-plain-text-password" +} +``` + +Успех: `200 ApiMessage`. Ошибки: `400 invalid_body / invalid_uuid / invalid_password`, `401`, `404 player_not_found`. + +### `POST /internal/players/password/reset` + +Удаляет сохранённый пароль игрока. Тело: `PasswordResetRequest`. Успех: `200 ApiMessage`. Ошибки: `400 invalid_body / invalid_uuid`, `401`, `404 player_not_found`. + +### `POST /internal/players/unlink` + +Отвязывает Telegram и/или Discord. Оба флага по умолчанию равны `true`. + +```json +{ + "uuid": "11111111-1111-1111-1111-111111111111", + "telegram": true, + "discord": true +} +``` + +Успех: `200 ApiMessage`. Ошибки: `400 invalid_body / invalid_uuid`, `401`, `404 player_not_found`. + +### `POST /internal/players/status` + +Ищет игрока и возвращает состояние регистрации/привязок. В теле должно присутствовать ровно одно из полей `uuid`, `name`, `telegramId`, `discordId`. + +```json +{ + "discordId": "123456789012345678" +} +``` + +Успех: `200 PlayerStatusResponse`; если совпадение отсутствует, возвращается объект с `exists: false`, а не `404`. Ошибки: `400 invalid_body / invalid_uuid / exactly_one_identifier_required`, `401`. + +### `GET /internal/players/{uuid}` + +Возвращает полный `PlayerProfile` по UUID. Ошибки: `400 invalid_uuid`, `401`, `404 player_not_found`. + +### `GET /internal/players/by-name/{name}` + +Ищет полный `PlayerProfile` по Minecraft-нику без учёта регистра. Ошибки: `401`, `404 player_not_found`. + +### `GET /internal/players/by-telegram/{telegramId}` + +Ищет полный `PlayerProfile` по Telegram ID. Ошибки: `400 invalid_telegram_id`, `401`, `404 player_not_found`. + +### `POST /internal/players/{uuid}/telegram` + +Устанавливает либо очищает Telegram-привязку. + +```json +{ + "telegramId": 123456789, + "telegramTag": "example" +} +``` + +Оба поля nullable; `null` используется для отвязки. Успех: `200 PlayerProfile`. Ошибки: `400 invalid_body / invalid_uuid`, `401`, `404 player_not_found`. + +### `POST /internal/players/{uuid}/discord` + +Устанавливает либо очищает Discord-привязку. Это endpoint, используемый ShbDiscordBot после погашения локального кода. + +```json +{ + "discordId": "123456789012345678" +} +``` + +`discordId: null` означает отвязку. Успех: `200 PlayerProfile`. Ошибки: `400 invalid_body / invalid_uuid`, `401`, `404 player_not_found`. + +## Player API + +### `GET /api/players/online` + +Возвращает массив `Player` со всеми онлайн-игроками. Ошибки: `401 missing/invalid token`, `403 player_offline`. + +### `GET /api/players/registered` + +Возвращает массив `Player` со всеми зарегистрированными игроками. Ошибки: `401 missing/invalid token`, `403 player_offline`. + +### `GET /api/players/me/state` + +Возвращает `MyPlayerStateResponse` для владельца bearer-токена. Ошибки: `401 missing/invalid token`, `403 player_offline`. + +### `GET /api/players/{uuid}` + +Возвращает зарегистрированного игрока как `Player`. + +Ошибки: `400 invalid_uuid`, `401 missing/invalid token`, `403 player_offline`, `404 player_not_found`. + +## Схемы данных + +`required` ниже означает обязательность по OpenAPI. Nullable-поле может явно содержать `null`. + +### `ApiMessage` + +| Поле | Тип | required | +| --- | --- | --- | +| `message` | string | да | + +### `JoinRequest` + +| Поле | Тип | required | +| --- | --- | --- | +| `uuid` | string | да | +| `playerName` | string или null | нет | + +### `UuidRequest`, `VerificationCodeRequest`, `PasswordResetRequest` + +Каждая схема содержит одно обязательное поле `uuid: string`. + +### `TokenResponse` + +| Поле | Тип | required | +| --- | --- | --- | +| `token` | string | да | + +### `VerificationCodeResponse` + +| Поле | Тип | required | +| --- | --- | --- | +| `code` | string | да | +| `expiresAt` | int64 | да | + +OpenAPI не уточняет единицу `expiresAt`; клиенту следует согласовать её с реализацией сервиса. + +### `VerificationVerifyRequest` + +| Поле | Тип | required | +| --- | --- | --- | +| `code` | string | да | +| `telegramId` | int64 или null | нет | +| `telegramTag` | string или null | нет | +| `discordId` | string или null | нет | + +### `VerificationVerifyResponse` + +| Поле | Тип | required | +| --- | --- | --- | +| `uuid` | string | да | +| `name` | string | да | + +### `PasswordVerifyRequest` + +Обязательные поля: `uuid: string`, `password: string`. + +### `PasswordVerifyResponse` + +Обязательное поле: `valid: boolean`. + +### `PasswordChangeRequest` + +Обязательные поля: `uuid: string`, `newPassword: string`. + +### `UnlinkAccountsRequest` + +| Поле | Тип | required | +| --- | --- | --- | +| `uuid` | string | да | +| `telegram` | boolean | нет | +| `discord` | boolean | нет | + +### `PlayerStatusRequest` + +Все поля необязательны/nullable, но сервер требует ровно один идентификатор: + +| Поле | Тип | +| --- | --- | +| `uuid` | string или null | +| `name` | string или null | +| `telegramId` | int64 или null | +| `discordId` | string или null | + +### `PlayerStatusResponse` + +Только `exists` обязательно по схеме. Остальные поля могут отсутствовать при `exists: false`. + +| Поле | Тип | +| --- | --- | +| `exists` | boolean | +| `uuid` | string или null | +| `name` | string или null | +| `registered` | boolean | +| `telegramLinked` | boolean | +| `telegramId` | int64 или null | +| `telegramTag` | string или null | +| `discordLinked` | boolean | +| `discordId` | string или null | + +### `PlayerProfile` + +Все поля обязательны; идентификаторы внешних аккаунтов nullable. + +| Поле | Тип | +| --- | --- | +| `uuid` | string | +| `name` | string | +| `registered` | boolean | +| `online` | boolean | +| `telegramId` | int64 или null | +| `telegramTag` | string или null | +| `discordId` | string или null | + +### `TelegramLinkRequest` + +Необязательные nullable-поля: `telegramId: int64`, `telegramTag: string`. + +### `DiscordLinkRequest` + +Необязательное nullable-поле: `discordId: string`. + +### `Player` + +| Поле | Тип | required | +| --- | --- | --- | +| `uuid` | string | да | +| `name` | string или null | да | + +### `MyPlayerStateResponse` + +Обязательные поля: `uuid: string`, `name: string`. + +## Пример привязки Discord напрямую + +Проверка текущей связи: + +```http +POST /internal/players/status HTTP/1.1 +Host: api.shlakoblock.com +X-Internal-Secret: +Content-Type: application/json + +{"discordId":"123456789012345678"} +``` + +Запись Discord ID игроку: + +```http +POST /internal/players/11111111-1111-1111-1111-111111111111/discord HTTP/1.1 +Host: api.shlakoblock.com +X-Internal-Secret: +Content-Type: application/json + +{"discordId":"123456789012345678"} +``` diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..02f3e17 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,6 @@ +group=org.kilka +version=1.0 +description=Discord account linking and role permission synchronization for ShlakoBlock +org.gradle.configuration-cache=true +org.gradle.parallel=true +org.gradle.caching=true diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..d997cfc Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..a351597 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..0262dcb --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/b631911858264c0b6e4d6603d677ff5218766cee/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..e509b2d --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..d45f4f9 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "ShbDiscordBot" diff --git a/src/main/java/org/kilka/shbDiscordBot/ShbDiscordBot.java b/src/main/java/org/kilka/shbDiscordBot/ShbDiscordBot.java new file mode 100644 index 0000000..6d61529 --- /dev/null +++ b/src/main/java/org/kilka/shbDiscordBot/ShbDiscordBot.java @@ -0,0 +1,266 @@ +package org.kilka.shbDiscordBot; + +import net.luckperms.api.LuckPerms; +import org.bukkit.Bukkit; +import org.bukkit.command.PluginCommand; +import org.bukkit.plugin.RegisteredServiceProvider; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.scheduler.BukkitTask; +import org.kilka.shbDiscordBot.api.ShlakoblockApiClient; +import org.kilka.shbDiscordBot.command.LinkCommand; +import org.kilka.shbDiscordBot.command.ReloadCommand; +import org.kilka.shbDiscordBot.config.PluginConfig; +import org.kilka.shbDiscordBot.discord.DiscordBotService; +import org.kilka.shbDiscordBot.link.LinkCodeService; +import org.kilka.shbDiscordBot.permissions.RolePermissionSync; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Level; + +public final class ShbDiscordBot extends JavaPlugin { + private ExecutorService ioExecutor; + private LinkCodeService codeService; + private DiscordBotService discordBot; + private BukkitTask roleSyncTask; + private final AtomicBoolean asyncDisableStarted = new AtomicBoolean(); + private final AtomicBoolean reloadInProgress = new AtomicBoolean(); + private volatile boolean stopping; + + @Override + public void onEnable() { + stopping = false; + try { + registerReloadCommand(); + PluginConfig config = PluginConfig.load(this); + startRuntime(config); + } catch (Exception exception) { + getLogger().log(Level.SEVERE, "Не удалось запустить ShbDiscordBot: " + exception.getMessage(), exception); + shutdownResources(); + Bukkit.getPluginManager().disablePlugin(this); + } + } + + @Override + public void onDisable() { + stopping = true; + shutdownResources(); + } + + private void startRuntime(PluginConfig config) { + LuckPerms luckPerms = requireLuckPerms(); + asyncDisableStarted.set(false); + + ioExecutor = Executors.newFixedThreadPool( + Math.max(2, config.maxConcurrentRequests()), + namedDaemonThreadFactory("shb-discord-io-") + ); + codeService = new LinkCodeService(config.codeTtl(), config.maxFailedAttemptsPerMinute()); + ShlakoblockApiClient api = new ShlakoblockApiClient( + config.apiBaseUri(), + config.internalSecret(), + config.connectTimeout(), + config.requestTimeout(), + ioExecutor + ); + RolePermissionSync roleSync = new RolePermissionSync( + api, + luckPerms, + config.rolePermissions(), + config.maxConcurrentRequests(), + getLogger() + ); + discordBot = new DiscordBotService( + config.discordToken(), + config.guildId(), + api, + codeService, + roleSync, + getLogger(), + this::disableFromAsyncFailure + ); + + PluginCommand linkCommand = requireCommand("link"); + linkCommand.setExecutor(new LinkCommand(this, codeService, api, discordBot, roleSync)); + + DiscordBotService runtimeBot = discordBot; + runtimeBot.ready().thenAccept(guild -> roleSync.syncGuild(guild)); + runtimeBot.start(); + + long intervalTicks = config.roleSyncInterval().toSeconds() * 20L; + roleSyncTask = Bukkit.getScheduler().runTaskTimerAsynchronously(this, () -> runtimeBot.ready() + .thenAccept(roleSync::syncGuild), intervalTicks, intervalTicks); + + if (config.rolePermissions().isEmpty()) { + getLogger().warning("role-sync.mappings пуст: привязка работает, но LuckPerms permissions не выдаются"); + } else { + getLogger().info("Загружено сопоставлений Discord-ролей: " + config.rolePermissions().size() + + "; интервал синхронизации: " + config.roleSyncInterval().toSeconds() + " сек."); + } + } + + private void registerReloadCommand() { + PluginCommand command = requireCommand("sdb"); + ReloadCommand executor = new ReloadCommand(this::reloadRuntime); + command.setExecutor(executor); + command.setTabCompleter(executor); + } + + private void reloadRuntime(org.bukkit.command.CommandSender sender) { + if (!reloadInProgress.compareAndSet(false, true)) { + sender.sendMessage("ShbDiscordBot уже перезагружается."); + return; + } + + final PluginConfig newConfig; + try { + reloadConfig(); + newConfig = PluginConfig.load(this); + } catch (Exception exception) { + reloadInProgress.set(false); + getLogger().log(Level.WARNING, "Новый config.yml не принят: " + exception.getMessage(), exception); + sender.sendMessage("Конфиг содержит ошибку: " + exception.getMessage() + + ". Текущая конфигурация продолжает работать."); + return; + } + + sender.sendMessage("Полная перезагрузка ShbDiscordBot запущена; активные коды будут сброшены…"); + setLinkCommandUnavailable(); + BukkitTask oldRoleSyncTask = roleSyncTask; + roleSyncTask = null; + if (oldRoleSyncTask != null) { + oldRoleSyncTask.cancel(); + } + LinkCodeService oldCodeService = codeService; + if (oldCodeService != null) { + oldCodeService.clear(); + } + DiscordBotService oldDiscordBot = discordBot; + ExecutorService oldExecutor = ioExecutor; + + CompletableFuture stopped = oldDiscordBot == null || oldExecutor == null + ? CompletableFuture.completedFuture(null) + : oldDiscordBot.shutdownAndAwaitAsync(oldExecutor); + stopped.whenComplete((ignored, shutdownError) -> { + if (oldExecutor != null) { + oldExecutor.shutdownNow(); + } + if (stopping) { + return; + } + try { + Bukkit.getScheduler().runTask(this, + () -> finishReload(sender, newConfig, shutdownError)); + } catch (RuntimeException exception) { + getLogger().log(Level.WARNING, "Не удалось завершить перезагрузку ShbDiscordBot", exception); + reloadInProgress.set(false); + } + }); + } + + private void finishReload( + org.bukkit.command.CommandSender sender, + PluginConfig newConfig, + Throwable shutdownError + ) { + ioExecutor = null; + codeService = null; + discordBot = null; + try { + if (shutdownError != null) { + getLogger().log(Level.WARNING, "Предыдущее Discord-соединение завершилось с ошибкой", shutdownError); + } + startRuntime(newConfig); + sender.sendMessage("ShbDiscordBot полностью перезагружен. Конфиг применён, синхронизация запущена."); + } catch (Exception exception) { + getLogger().log(Level.SEVERE, "Не удалось запустить ShbDiscordBot после reload", exception); + shutdownRuntimeResources(); + setLinkCommandUnavailable(); + sender.sendMessage("Не удалось запустить сервисы после reload: " + exception.getMessage() + + ". Исправьте конфиг и повторите /sdb reload."); + } finally { + reloadInProgress.set(false); + } + } + + private void setLinkCommandUnavailable() { + requireCommand("link").setExecutor((sender, command, label, args) -> { + sender.sendMessage("ShbDiscordBot перезагружается или не запущен. Попробуйте позже."); + return true; + }); + } + + private PluginCommand requireCommand(String name) { + PluginCommand command = getCommand(name); + if (command == null) { + throw new IllegalStateException("Команда " + name + " отсутствует в plugin.yml"); + } + return command; + } + + private LuckPerms requireLuckPerms() { + RegisteredServiceProvider provider = getServer().getServicesManager().getRegistration(LuckPerms.class); + if (provider == null) { + throw new IllegalStateException("LuckPerms API не зарегистрирован"); + } + return provider.getProvider(); + } + + private void disableFromAsyncFailure(String reason) { + getLogger().severe(reason); + if (!asyncDisableStarted.compareAndSet(false, true)) { + return; + } + + DiscordBotService currentBot = discordBot; + ExecutorService currentExecutor = ioExecutor; + if (currentBot == null || currentExecutor == null) { + Bukkit.getScheduler().runTask(this, () -> Bukkit.getPluginManager().disablePlugin(this)); + return; + } + + currentBot.shutdownAndAwaitAsync(currentExecutor).whenComplete((ignored, error) -> { + if (error != null) { + getLogger().log(Level.WARNING, "Ошибка при завершении Discord-соединения", error); + } + Bukkit.getScheduler().runTask(this, () -> Bukkit.getPluginManager().disablePlugin(this)); + }); + } + + private void shutdownResources() { + Bukkit.getScheduler().cancelTasks(this); + shutdownRuntimeResources(); + } + + private void shutdownRuntimeResources() { + if (roleSyncTask != null) { + roleSyncTask.cancel(); + roleSyncTask = null; + } + if (codeService != null) { + codeService.clear(); + codeService = null; + } + if (discordBot != null) { + discordBot.shutdown(); + discordBot = null; + } + if (ioExecutor != null) { + ioExecutor.shutdownNow(); + ioExecutor = null; + } + } + + private static ThreadFactory namedDaemonThreadFactory(String prefix) { + AtomicInteger sequence = new AtomicInteger(); + return runnable -> { + Thread thread = new Thread(runnable, prefix + sequence.incrementAndGet()); + thread.setDaemon(true); + return thread; + }; + } +} diff --git a/src/main/java/org/kilka/shbDiscordBot/api/ApiException.java b/src/main/java/org/kilka/shbDiscordBot/api/ApiException.java new file mode 100644 index 0000000..94529bb --- /dev/null +++ b/src/main/java/org/kilka/shbDiscordBot/api/ApiException.java @@ -0,0 +1,26 @@ +package org.kilka.shbDiscordBot.api; + +public final class ApiException extends RuntimeException { + private final int statusCode; + private final boolean retryable; + + public ApiException(String message, int statusCode, boolean retryable) { + super(message); + this.statusCode = statusCode; + this.retryable = retryable; + } + + public ApiException(String message, boolean retryable, Throwable cause) { + super(message, cause); + this.statusCode = -1; + this.retryable = retryable; + } + + public int statusCode() { + return statusCode; + } + + public boolean retryable() { + return retryable; + } +} diff --git a/src/main/java/org/kilka/shbDiscordBot/api/PlayerProfile.java b/src/main/java/org/kilka/shbDiscordBot/api/PlayerProfile.java new file mode 100644 index 0000000..2895b15 --- /dev/null +++ b/src/main/java/org/kilka/shbDiscordBot/api/PlayerProfile.java @@ -0,0 +1,10 @@ +package org.kilka.shbDiscordBot.api; + +public record PlayerProfile( + String uuid, + String name, + boolean registered, + boolean online, + String discordId +) { +} diff --git a/src/main/java/org/kilka/shbDiscordBot/api/PlayerStatus.java b/src/main/java/org/kilka/shbDiscordBot/api/PlayerStatus.java new file mode 100644 index 0000000..7a626d4 --- /dev/null +++ b/src/main/java/org/kilka/shbDiscordBot/api/PlayerStatus.java @@ -0,0 +1,11 @@ +package org.kilka.shbDiscordBot.api; + +public record PlayerStatus( + boolean exists, + String uuid, + String name, + boolean registered, + boolean discordLinked, + String discordId +) { +} diff --git a/src/main/java/org/kilka/shbDiscordBot/api/ShlakoblockApi.java b/src/main/java/org/kilka/shbDiscordBot/api/ShlakoblockApi.java new file mode 100644 index 0000000..f77227b --- /dev/null +++ b/src/main/java/org/kilka/shbDiscordBot/api/ShlakoblockApi.java @@ -0,0 +1,12 @@ +package org.kilka.shbDiscordBot.api; + +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +public interface ShlakoblockApi { + CompletableFuture findByDiscordId(String discordId); + + CompletableFuture findByUuid(UUID uuid); + + CompletableFuture linkDiscord(UUID uuid, String discordId); +} diff --git a/src/main/java/org/kilka/shbDiscordBot/api/ShlakoblockApiClient.java b/src/main/java/org/kilka/shbDiscordBot/api/ShlakoblockApiClient.java new file mode 100644 index 0000000..5036b27 --- /dev/null +++ b/src/main/java/org/kilka/shbDiscordBot/api/ShlakoblockApiClient.java @@ -0,0 +1,153 @@ +package org.kilka.shbDiscordBot.api; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; + +public final class ShlakoblockApiClient implements ShlakoblockApi { + private static final String INTERNAL_SECRET_HEADER = "X-Internal-Secret"; + + private final URI baseUri; + private final String internalSecret; + private final Duration requestTimeout; + private final HttpClient httpClient; + private final ObjectMapper objectMapper; + + public ShlakoblockApiClient( + URI baseUri, + String internalSecret, + Duration connectTimeout, + Duration requestTimeout, + java.util.concurrent.Executor executor + ) { + this.baseUri = baseUri; + this.internalSecret = internalSecret; + this.requestTimeout = requestTimeout; + this.httpClient = HttpClient.newBuilder() + .connectTimeout(connectTimeout) + .executor(executor) + .build(); + this.objectMapper = new ObjectMapper(); + } + + @Override + public CompletableFuture findByDiscordId(String discordId) { + return post("/internal/players/status", new StatusRequest(null, discordId), StatusResponse.class) + .thenApply(StatusResponse::toDomain); + } + + @Override + public CompletableFuture findByUuid(UUID uuid) { + return post("/internal/players/status", new StatusRequest(uuid.toString(), null), StatusResponse.class) + .thenApply(StatusResponse::toDomain); + } + + @Override + public CompletableFuture linkDiscord(UUID uuid, String discordId) { + String path = "/internal/players/" + uuid + "/discord"; + return post(path, new DiscordLinkRequest(discordId), ProfileResponse.class) + .thenApply(ProfileResponse::toDomain); + } + + private CompletableFuture post(String path, Object body, Class responseType) { + final String json; + try { + json = objectMapper.writeValueAsString(body); + } catch (IOException exception) { + return CompletableFuture.failedFuture(new ApiException("Не удалось сериализовать API-запрос", false, exception)); + } + + HttpRequest request = HttpRequest.newBuilder(resolve(path)) + .timeout(requestTimeout) + .header("Accept", "application/json") + .header("Content-Type", "application/json") + .header(INTERNAL_SECRET_HEADER, internalSecret) + .POST(HttpRequest.BodyPublishers.ofString(json)) + .build(); + + return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()) + .handle((response, error) -> { + if (error != null) { + throw new CompletionException(new ApiException("ShbUtils API недоступен", true, unwrap(error))); + } + int statusCode = response.statusCode(); + if (statusCode < 200 || statusCode >= 300) { + boolean retryable = statusCode == 429 || statusCode >= 500; + throw new CompletionException(new ApiException("ShbUtils API ответил HTTP " + statusCode, statusCode, retryable)); + } + try { + return objectMapper.readValue(response.body(), responseType); + } catch (IOException exception) { + throw new CompletionException(new ApiException("ShbUtils API вернул некорректный JSON", false, exception)); + } + }); + } + + private URI resolve(String path) { + String origin = baseUri.toString(); + while (origin.endsWith("/")) { + origin = origin.substring(0, origin.length() - 1); + } + return URI.create(origin + path); + } + + private static Throwable unwrap(Throwable throwable) { + Throwable current = throwable; + while ((current instanceof CompletionException || current instanceof java.util.concurrent.ExecutionException) + && current.getCause() != null) { + current = current.getCause(); + } + return current; + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + private record StatusRequest(String uuid, String discordId) { + } + + private record DiscordLinkRequest(String discordId) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private record StatusResponse( + boolean exists, + String uuid, + String name, + Boolean registered, + Boolean discordLinked, + String discordId + ) { + private PlayerStatus toDomain() { + return new PlayerStatus( + exists, + uuid, + name, + Boolean.TRUE.equals(registered), + Boolean.TRUE.equals(discordLinked), + discordId + ); + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private record ProfileResponse( + String uuid, + String name, + boolean registered, + boolean online, + String discordId + ) { + private PlayerProfile toDomain() { + return new PlayerProfile(uuid, name, registered, online, discordId); + } + } +} diff --git a/src/main/java/org/kilka/shbDiscordBot/command/LinkCommand.java b/src/main/java/org/kilka/shbDiscordBot/command/LinkCommand.java new file mode 100644 index 0000000..3893582 --- /dev/null +++ b/src/main/java/org/kilka/shbDiscordBot/command/LinkCommand.java @@ -0,0 +1,186 @@ +package org.kilka.shbDiscordBot.command; + +import net.dv8tion.jda.api.entities.Member; +import org.bukkit.Bukkit; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; +import org.kilka.shbDiscordBot.api.ApiException; +import org.kilka.shbDiscordBot.api.PlayerProfile; +import org.kilka.shbDiscordBot.api.PlayerStatus; +import org.kilka.shbDiscordBot.api.ShlakoblockApi; +import org.kilka.shbDiscordBot.discord.DiscordBotService; +import org.kilka.shbDiscordBot.link.LinkCodeService; +import org.kilka.shbDiscordBot.permissions.RolePermissionSync; +import org.bukkit.plugin.java.JavaPlugin; + +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.logging.Level; + +public final class LinkCommand implements CommandExecutor { + private final JavaPlugin plugin; + private final LinkCodeService codeService; + private final ShlakoblockApi api; + private final DiscordBotService discord; + private final RolePermissionSync permissionSync; + + public LinkCommand( + JavaPlugin plugin, + LinkCodeService codeService, + ShlakoblockApi api, + DiscordBotService discord, + RolePermissionSync permissionSync + ) { + this.plugin = plugin; + this.codeService = codeService; + this.api = api; + this.discord = discord; + this.permissionSync = permissionSync; + } + + @Override + public boolean onCommand( + @NotNull CommandSender sender, + @NotNull Command command, + @NotNull String label, + @NotNull String[] args + ) { + if (!(sender instanceof Player player)) { + sender.sendMessage("Эту команду может использовать только игрок."); + return true; + } + if (args.length != 1 || !args[0].matches("\\d{6}")) { + player.sendMessage("Использование: /link <6-значный код>"); + return true; + } + + UUID playerUuid = player.getUniqueId(); + LinkCodeService.ReservationResult result = codeService.reserve(playerUuid, args[0]); + if (result.status() == LinkCodeService.ReservationResult.Status.RATE_LIMITED) { + player.sendMessage("Слишком много неверных попыток. Подождите минуту."); + return true; + } + if (result.status() == LinkCodeService.ReservationResult.Status.INVALID) { + player.sendMessage("Код неверный, уже используется или истёк."); + return true; + } + + LinkCodeService.Reservation reservation = result.reservation(); + player.sendMessage("Проверяем код и выполняем привязку…"); + performLink(playerUuid, reservation); + return true; + } + + private void performLink(UUID playerUuid, LinkCodeService.Reservation reservation) { + discord.findMember(reservation.discordId()) + .exceptionally(error -> { + throw new CompletionException(new LinkRejectedException( + "Не удалось подтвердить участие Discord-пользователя на сервере. Попробуйте позже.", false + )); + }) + .thenCompose(member -> api.findByUuid(playerUuid) + .thenApply(status -> new MemberAndStatus(member, status))) + .thenCompose(pair -> validateMinecraft(pair.status()) + .thenCompose(ignored -> api.findByDiscordId(reservation.discordId())) + .thenApply(discordStatus -> new LinkContext(pair.member(), discordStatus))) + .thenCompose(context -> validateDiscord(context.status()) + .thenCompose(ignored -> api.linkDiscord(playerUuid, reservation.discordId())) + .thenApply(profile -> new LinkSuccess(context.member(), profile))) + .whenComplete((success, error) -> { + if (error == null) { + codeService.complete(reservation); + tellPlayer(playerUuid, "Discord успешно привязан к Minecraft-аккаунту " + success.profile().name() + "."); + discord.sendDirectMessage(reservation.discordId(), + "Привязка завершена: ваш Discord связан с Minecraft-аккаунтом **" + + success.profile().name() + "**."); + permissionSync.syncMember(success.member()); + return; + } + + Throwable cause = unwrap(error); + boolean consume = isPermanent(cause); + if (consume) { + codeService.complete(reservation); + } else { + codeService.release(reservation); + } + + if (cause instanceof LinkRejectedException rejected) { + tellPlayer(playerUuid, rejected.getMessage()); + } else { + plugin.getLogger().log(Level.WARNING, "Ошибка привязки Discord для Minecraft UUID " + playerUuid, cause); + tellPlayer(playerUuid, "Сервис привязки временно недоступен. Попробуйте тот же код позже."); + } + }); + } + + private CompletableFuture validateMinecraft(PlayerStatus status) { + if (!status.exists()) { + return CompletableFuture.failedFuture(new LinkRejectedException( + "Ваш Minecraft-аккаунт ещё не зарегистрирован в сервисе.", true + )); + } + if (status.discordLinked()) { + return CompletableFuture.failedFuture(new LinkRejectedException( + "К этому Minecraft-аккаунту уже привязан Discord. Перепривязка запрещена.", true + )); + } + return CompletableFuture.completedFuture(null); + } + + private CompletableFuture validateDiscord(PlayerStatus status) { + if (status.exists() && status.discordLinked()) { + return CompletableFuture.failedFuture(new LinkRejectedException( + "Этот Discord-аккаунт уже привязан. Перепривязка запрещена.", true + )); + } + return CompletableFuture.completedFuture(null); + } + + private boolean isPermanent(Throwable cause) { + if (cause instanceof LinkRejectedException rejected) { + return rejected.consumeCode; + } + return cause instanceof ApiException apiException && !apiException.retryable(); + } + + private void tellPlayer(UUID playerUuid, String message) { + Bukkit.getScheduler().runTask(plugin, () -> { + Player player = Bukkit.getPlayer(playerUuid); + if (player != null) { + player.sendMessage(message); + } + }); + } + + private static Throwable unwrap(Throwable throwable) { + Throwable current = throwable; + while ((current instanceof CompletionException || current instanceof java.util.concurrent.ExecutionException) + && current.getCause() != null) { + current = current.getCause(); + } + return current; + } + + private record MemberAndStatus(Member member, PlayerStatus status) { + } + + private record LinkContext(Member member, PlayerStatus status) { + } + + private record LinkSuccess(Member member, PlayerProfile profile) { + } + + private static final class LinkRejectedException extends RuntimeException { + private final boolean consumeCode; + + private LinkRejectedException(String message, boolean consumeCode) { + super(message); + this.consumeCode = consumeCode; + } + } +} diff --git a/src/main/java/org/kilka/shbDiscordBot/command/ReloadCommand.java b/src/main/java/org/kilka/shbDiscordBot/command/ReloadCommand.java new file mode 100644 index 0000000..24445ad --- /dev/null +++ b/src/main/java/org/kilka/shbDiscordBot/command/ReloadCommand.java @@ -0,0 +1,46 @@ +package org.kilka.shbDiscordBot.command; + +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabCompleter; +import org.jetbrains.annotations.NotNull; + +import java.util.List; +import java.util.function.Consumer; + +public final class ReloadCommand implements CommandExecutor, TabCompleter { + private final Consumer reloadAction; + + public ReloadCommand(Consumer reloadAction) { + this.reloadAction = reloadAction; + } + + @Override + public boolean onCommand( + @NotNull CommandSender sender, + @NotNull Command command, + @NotNull String label, + @NotNull String[] args + ) { + if (args.length != 1 || !args[0].equalsIgnoreCase("reload")) { + sender.sendMessage("Использование: /" + label + " reload"); + return true; + } + reloadAction.accept(sender); + return true; + } + + @Override + public List onTabComplete( + @NotNull CommandSender sender, + @NotNull Command command, + @NotNull String alias, + @NotNull String[] args + ) { + if (args.length == 1 && "reload".startsWith(args[0].toLowerCase(java.util.Locale.ROOT))) { + return List.of("reload"); + } + return List.of(); + } +} diff --git a/src/main/java/org/kilka/shbDiscordBot/config/PluginConfig.java b/src/main/java/org/kilka/shbDiscordBot/config/PluginConfig.java new file mode 100644 index 0000000..45f872a --- /dev/null +++ b/src/main/java/org/kilka/shbDiscordBot/config/PluginConfig.java @@ -0,0 +1,128 @@ +package org.kilka.shbDiscordBot.config; + +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.plugin.java.JavaPlugin; + +import java.net.URI; +import java.time.Duration; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +public record PluginConfig( + String discordToken, + String guildId, + URI apiBaseUri, + String internalSecret, + Duration connectTimeout, + Duration requestTimeout, + Duration codeTtl, + int maxFailedAttemptsPerMinute, + Duration roleSyncInterval, + int maxConcurrentRequests, + Map> rolePermissions +) { + public static PluginConfig load(JavaPlugin plugin) { + plugin.saveDefaultConfig(); + FileConfiguration config = plugin.getConfig(); + + String token = required(config, "discord.token"); + String guildId = snowflake(required(config, "discord.guild-id"), "discord.guild-id"); + String internalSecret = required(config, "api.internal-secret"); + + URI baseUri; + try { + baseUri = URI.create(required(config, "api.base-url")); + } catch (IllegalArgumentException exception) { + throw new ConfigException("api.base-url должен быть корректным URI", exception); + } + if (!Set.of("http", "https").contains(baseUri.getScheme()) || baseUri.getHost() == null) { + throw new ConfigException("api.base-url должен быть абсолютным HTTP(S) URI"); + } + + Duration connectTimeout = seconds(config, "api.connect-timeout-seconds", 1, 120); + Duration requestTimeout = seconds(config, "api.request-timeout-seconds", 1, 300); + Duration codeTtl = seconds(config, "link.code-ttl-seconds", 30, 3_600); + int maxAttempts = integer(config, "link.max-failed-attempts-per-minute", 1, 100); + Duration syncInterval = seconds(config, "role-sync.interval-seconds", 30, 86_400); + int concurrency = integer(config, "role-sync.max-concurrent-requests", 1, 32); + + Map> mappings = new LinkedHashMap<>(); + ConfigurationSection section = config.getConfigurationSection("role-sync.mappings"); + if (section != null) { + for (String roleId : section.getKeys(false)) { + String validatedRoleId = snowflake(roleId, "role-sync.mappings role ID"); + Set permissions = new LinkedHashSet<>(); + for (String permission : section.getStringList(roleId)) { + String normalized = permission.trim(); + if (normalized.isEmpty() || normalized.contains(" ")) { + throw new ConfigException("Некорректный permission для Discord-роли " + roleId); + } + permissions.add(normalized); + } + if (permissions.isEmpty()) { + throw new ConfigException("Для Discord-роли " + roleId + " не задано ни одного permission"); + } + mappings.put(validatedRoleId, Collections.unmodifiableSet(permissions)); + } + } + + return new PluginConfig( + token, + guildId, + baseUri, + internalSecret, + connectTimeout, + requestTimeout, + codeTtl, + maxAttempts, + syncInterval, + concurrency, + Collections.unmodifiableMap(mappings) + ); + } + + private static String required(FileConfiguration config, String path) { + String value = config.getString(path, "").trim(); + if (value.isEmpty()) { + throw new ConfigException("Не заполнен обязательный параметр " + path); + } + return value; + } + + private static Duration seconds(FileConfiguration config, String path, int min, int max) { + return Duration.ofSeconds(integer(config, path, min, max)); + } + + private static int integer(FileConfiguration config, String path, int min, int max) { + int value = config.getInt(path, -1); + if (value < min || value > max) { + throw new ConfigException(path + " должен быть в диапазоне " + min + ".." + max); + } + return value; + } + + private static String snowflake(String value, String path) { + try { + if (Long.parseUnsignedLong(value) == 0) { + throw new NumberFormatException("zero"); + } + return value; + } catch (NumberFormatException exception) { + throw new ConfigException(path + " должен быть положительным Discord snowflake ID", exception); + } + } + + public static final class ConfigException extends IllegalArgumentException { + public ConfigException(String message) { + super(message); + } + + public ConfigException(String message, Throwable cause) { + super(message, cause); + } + } +} diff --git a/src/main/java/org/kilka/shbDiscordBot/discord/DiscordBotService.java b/src/main/java/org/kilka/shbDiscordBot/discord/DiscordBotService.java new file mode 100644 index 0000000..cc0adec --- /dev/null +++ b/src/main/java/org/kilka/shbDiscordBot/discord/DiscordBotService.java @@ -0,0 +1,224 @@ +package org.kilka.shbDiscordBot.discord; + +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.JDABuilder; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.events.guild.member.GuildMemberRoleAddEvent; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.events.session.ReadyEvent; +import net.dv8tion.jda.api.events.session.ShutdownEvent; +import net.dv8tion.jda.api.hooks.ListenerAdapter; +import net.dv8tion.jda.api.interactions.InteractionContextType; +import net.dv8tion.jda.api.interactions.commands.build.Commands; +import net.dv8tion.jda.api.requests.GatewayIntent; +import net.dv8tion.jda.api.requests.CloseCode; +import net.dv8tion.jda.api.utils.ChunkingFilter; +import net.dv8tion.jda.api.utils.MemberCachePolicy; +import org.jetbrains.annotations.NotNull; +import org.kilka.shbDiscordBot.api.ShlakoblockApi; +import org.kilka.shbDiscordBot.link.LinkCodeService; +import org.kilka.shbDiscordBot.permissions.RolePermissionSync; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.Executor; +import java.util.function.Consumer; +import java.util.logging.Level; +import java.util.logging.Logger; + +public final class DiscordBotService extends ListenerAdapter { + private final String token; + private final String guildId; + private final ShlakoblockApi api; + private final LinkCodeService codeService; + private final RolePermissionSync permissionSync; + private final Logger logger; + private final Consumer fatalErrorHandler; + private final CompletableFuture ready = new CompletableFuture<>(); + + private volatile JDA jda; + private volatile Guild guild; + private volatile boolean shuttingDown; + + public DiscordBotService( + String token, + String guildId, + ShlakoblockApi api, + LinkCodeService codeService, + RolePermissionSync permissionSync, + Logger logger, + Consumer fatalErrorHandler + ) { + this.token = token; + this.guildId = guildId; + this.api = api; + this.codeService = codeService; + this.permissionSync = permissionSync; + this.logger = logger; + this.fatalErrorHandler = fatalErrorHandler; + } + + public void start() { + jda = JDABuilder.createDefault(token) + .enableIntents(GatewayIntent.GUILD_MEMBERS) + .setMemberCachePolicy(MemberCachePolicy.ALL) + .setChunkingFilter(ChunkingFilter.ALL) + .addEventListeners(this) + .build(); + } + + public CompletableFuture ready() { + return ready; + } + + public CompletableFuture findMember(String discordId) { + Guild currentGuild = guild; + if (currentGuild == null) { + return CompletableFuture.failedFuture(new IllegalStateException("Discord-бот ещё не готов")); + } + // A freshly retrieved member avoids granting permissions from stale role data. + return currentGuild.retrieveMemberById(discordId).useCache(false).submit(); + } + + public CompletableFuture sendDirectMessage(String discordId, String message) { + JDA currentJda = jda; + if (currentJda == null) { + return CompletableFuture.completedFuture(null); + } + return currentJda.retrieveUserById(discordId).submit() + .thenCompose(user -> user.openPrivateChannel().submit()) + .thenCompose(channel -> channel.sendMessage(message).submit()) + .thenApply(ignored -> (Void) null) + .exceptionally(error -> { + logger.log(Level.FINE, "Не удалось отправить личное сообщение Discord-пользователю " + discordId, unwrap(error)); + return null; + }); + } + + @Override + public void onReady(@NotNull ReadyEvent event) { + Guild configuredGuild = event.getJDA().getGuildById(guildId); + if (configuredGuild == null) { + String visibleGuilds = event.getJDA().getGuilds().stream() + .map(item -> item.getName() + " (" + item.getId() + ")") + .collect(java.util.stream.Collectors.joining(", ")); + if (visibleGuilds.isEmpty()) { + visibleGuilds = "нет доступных guild"; + } + String message = "Discord-бот не видит настроенный guild " + guildId + + ". Проверьте discord.guild-id, bot token и приглашение со scope bot. Видимые guild: " + + visibleGuilds; + ready.completeExceptionally(new IllegalStateException(message)); + fatalErrorHandler.accept(message); + return; + } + guild = configuredGuild; + event.getJDA().updateCommands() + .addCommands(Commands.slash("auth", "Получить код для привязки Minecraft-аккаунта") + .setContexts(InteractionContextType.BOT_DM)) + .queue( + ignored -> logger.info("Discord-команда /auth зарегистрирована"), + error -> logger.log(Level.SEVERE, "Не удалось зарегистрировать Discord-команду /auth", error) + ); + ready.complete(configuredGuild); + logger.info("Discord-бот готов; guild: " + configuredGuild.getName()); + } + + @Override + public void onSlashCommandInteraction(@NotNull SlashCommandInteractionEvent event) { + if (!event.getName().equals("auth")) { + return; + } + if (event.isFromGuild()) { + event.reply("Используйте `/auth` в личных сообщениях с ботом.").setEphemeral(true).queue(); + return; + } + + event.deferReply().queue(hook -> findMember(event.getUser().getId()) + .thenCompose(member -> api.findByDiscordId(member.getId())) + .thenApply(status -> { + if (status.exists() && status.discordLinked()) { + return "Этот Discord-аккаунт уже привязан. Для смены привязки обратитесь к администратору."; + } + LinkCodeService.IssuedCode issued = codeService.issue(event.getUser().getId()); + long expiresAt = issued.expiresAt().getEpochSecond(); + return "Ваш код привязки: **" + issued.code() + "**\n" + + "Зайдите на Minecraft-сервер и выполните `/link " + issued.code() + "`.\n" + + "Код одноразовый и истекает ."; + }) + .exceptionally(error -> { + Throwable cause = unwrap(error); + logger.log(Level.WARNING, "Не удалось выдать код Discord-пользователю " + event.getUser().getId(), cause); + return "Не удалось выдать код. Убедитесь, что вы состоите на Discord-сервере, или попробуйте позже."; + }) + .thenAccept(message -> hook.editOriginal(message).queue())); + } + + @Override + public void onGuildMemberRoleAdd(@NotNull GuildMemberRoleAddEvent event) { + if (!event.getGuild().getId().equals(guildId)) { + return; + } + if (event.getRoles().stream().anyMatch(role -> permissionSync.isMappedRole(role.getId()))) { + permissionSync.syncMember(event.getMember()); + } + } + + @Override + public void onShutdown(@NotNull ShutdownEvent event) { + if (shuttingDown) { + return; + } + CloseCode closeCode = event.getCloseCode(); + if (closeCode == CloseCode.AUTHENTICATION_FAILED + || closeCode == CloseCode.INVALID_INTENTS + || closeCode == CloseCode.DISALLOWED_INTENTS) { + String message = "Discord отключил бота: " + closeCode.getMeaning(); + ready.completeExceptionally(new IllegalStateException(message)); + fatalErrorHandler.accept(message); + } + } + + public void shutdown() { + shuttingDown = true; + JDA currentJda = detachJda(); + if (currentJda != null) { + currentJda.shutdownNow(); + } + } + + public CompletableFuture shutdownAndAwaitAsync(Executor executor) { + shuttingDown = true; + JDA currentJda = detachJda(); + if (currentJda == null) { + return CompletableFuture.completedFuture(null); + } + + currentJda.shutdownNow(); + return CompletableFuture.runAsync(() -> { + try { + currentJda.awaitShutdown(); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new CompletionException(exception); + } + }, executor); + } + + private synchronized JDA detachJda() { + JDA currentJda = jda; + jda = null; + guild = null; + return currentJda; + } + + private static Throwable unwrap(Throwable throwable) { + Throwable current = throwable; + while ((current instanceof java.util.concurrent.CompletionException + || current instanceof java.util.concurrent.ExecutionException) && current.getCause() != null) { + current = current.getCause(); + } + return current; + } +} diff --git a/src/main/java/org/kilka/shbDiscordBot/link/LinkCodeService.java b/src/main/java/org/kilka/shbDiscordBot/link/LinkCodeService.java new file mode 100644 index 0000000..82621e8 --- /dev/null +++ b/src/main/java/org/kilka/shbDiscordBot/link/LinkCodeService.java @@ -0,0 +1,201 @@ +package org.kilka.shbDiscordBot.link; + +import java.security.SecureRandom; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.function.IntSupplier; + +public final class LinkCodeService { + private static final int CODE_SPACE = 1_000_000; + + private final Duration ttl; + private final int maxFailedAttempts; + private final Clock clock; + private final IntSupplier randomCode; + private final Map entriesByCode = new HashMap<>(); + private final Map codeByDiscordId = new HashMap<>(); + private final Map attemptsByPlayer = new HashMap<>(); + private final Set reservedPlayers = new java.util.HashSet<>(); + + public LinkCodeService(Duration ttl, int maxFailedAttempts) { + SecureRandom secureRandom = new SecureRandom(); + this.ttl = Objects.requireNonNull(ttl, "ttl"); + this.maxFailedAttempts = maxFailedAttempts; + this.clock = Clock.systemUTC(); + this.randomCode = () -> secureRandom.nextInt(CODE_SPACE); + validateArguments(); + } + + LinkCodeService(Duration ttl, int maxFailedAttempts, Clock clock, IntSupplier randomCode) { + this.ttl = Objects.requireNonNull(ttl, "ttl"); + this.maxFailedAttempts = maxFailedAttempts; + this.clock = Objects.requireNonNull(clock, "clock"); + this.randomCode = Objects.requireNonNull(randomCode, "randomCode"); + validateArguments(); + } + + public synchronized IssuedCode issue(String discordId) { + Objects.requireNonNull(discordId, "discordId"); + purgeExpired(); + + String previousCode = codeByDiscordId.remove(discordId); + if (previousCode != null) { + entriesByCode.remove(previousCode); + } + + for (int attempts = 0; attempts < CODE_SPACE; attempts++) { + int value = Math.floorMod(randomCode.getAsInt(), CODE_SPACE); + String code = "%06d".formatted(value); + if (!entriesByCode.containsKey(code)) { + Instant expiresAt = clock.instant().plus(ttl); + entriesByCode.put(code, new Entry(discordId, expiresAt)); + codeByDiscordId.put(discordId, code); + return new IssuedCode(code, expiresAt); + } + } + throw new IllegalStateException("Не осталось свободных кодов привязки"); + } + + public synchronized ReservationResult reserve(UUID playerUuid, String code) { + Objects.requireNonNull(playerUuid, "playerUuid"); + Objects.requireNonNull(code, "code"); + purgeExpired(); + + Instant now = clock.instant(); + AttemptWindow attemptWindow = attemptsByPlayer.computeIfAbsent(playerUuid, ignored -> new AttemptWindow(now)); + if (!now.isBefore(attemptWindow.startedAt.plus(Duration.ofMinutes(1)))) { + attemptWindow = new AttemptWindow(now); + attemptsByPlayer.put(playerUuid, attemptWindow); + } + if (attemptWindow.failures >= maxFailedAttempts) { + return ReservationResult.rateLimited(); + } + + Entry entry = entriesByCode.get(code); + if (entry == null || entry.reservedBy != null || reservedPlayers.contains(playerUuid)) { + attemptWindow.failures++; + return ReservationResult.invalid(); + } + + entry.reservedBy = playerUuid; + reservedPlayers.add(playerUuid); + return ReservationResult.reserved(new Reservation(code, entry.discordId, playerUuid, entry.expiresAt)); + } + + public synchronized void complete(Reservation reservation) { + Entry entry = matchingEntry(reservation); + reservedPlayers.remove(reservation.playerUuid()); + if (entry == null) { + return; + } + entriesByCode.remove(reservation.code()); + codeByDiscordId.remove(entry.discordId, reservation.code()); + } + + public synchronized void release(Reservation reservation) { + Entry entry = matchingEntry(reservation); + reservedPlayers.remove(reservation.playerUuid()); + if (entry != null) { + entry.reservedBy = null; + } + } + + public synchronized int activeCodeCount() { + purgeExpired(); + return entriesByCode.size(); + } + + public synchronized void clear() { + entriesByCode.clear(); + codeByDiscordId.clear(); + attemptsByPlayer.clear(); + reservedPlayers.clear(); + } + + private Entry matchingEntry(Reservation reservation) { + Entry entry = entriesByCode.get(reservation.code()); + if (entry == null || !entry.discordId.equals(reservation.discordId()) + || !reservation.playerUuid().equals(entry.reservedBy)) { + return null; + } + return entry; + } + + private void purgeExpired() { + Instant now = clock.instant(); + Iterator> iterator = entriesByCode.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry item = iterator.next(); + if (!now.isBefore(item.getValue().expiresAt)) { + iterator.remove(); + codeByDiscordId.remove(item.getValue().discordId, item.getKey()); + if (item.getValue().reservedBy != null) { + reservedPlayers.remove(item.getValue().reservedBy); + } + } + } + attemptsByPlayer.entrySet().removeIf(item -> !now.isBefore(item.getValue().startedAt.plus(Duration.ofMinutes(1)))); + } + + private void validateArguments() { + if (ttl.isZero() || ttl.isNegative()) { + throw new IllegalArgumentException("ttl must be positive"); + } + if (maxFailedAttempts < 1) { + throw new IllegalArgumentException("maxFailedAttempts must be positive"); + } + } + + public record IssuedCode(String code, Instant expiresAt) { + } + + public record Reservation(String code, String discordId, UUID playerUuid, Instant expiresAt) { + } + + public record ReservationResult(Status status, Reservation reservation) { + public static ReservationResult reserved(Reservation reservation) { + return new ReservationResult(Status.RESERVED, reservation); + } + + public static ReservationResult invalid() { + return new ReservationResult(Status.INVALID, null); + } + + public static ReservationResult rateLimited() { + return new ReservationResult(Status.RATE_LIMITED, null); + } + + public enum Status { + RESERVED, + INVALID, + RATE_LIMITED + } + } + + private static final class Entry { + private final String discordId; + private final Instant expiresAt; + private UUID reservedBy; + + private Entry(String discordId, Instant expiresAt) { + this.discordId = discordId; + this.expiresAt = expiresAt; + } + } + + private static final class AttemptWindow { + private final Instant startedAt; + private int failures; + + private AttemptWindow(Instant startedAt) { + this.startedAt = startedAt; + } + } +} diff --git a/src/main/java/org/kilka/shbDiscordBot/permissions/RolePermissionSync.java b/src/main/java/org/kilka/shbDiscordBot/permissions/RolePermissionSync.java new file mode 100644 index 0000000..a3395cf --- /dev/null +++ b/src/main/java/org/kilka/shbDiscordBot/permissions/RolePermissionSync.java @@ -0,0 +1,181 @@ +package org.kilka.shbDiscordBot.permissions; + +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.Role; +import net.luckperms.api.LuckPerms; +import net.luckperms.api.model.user.User; +import net.luckperms.api.node.Node; +import org.kilka.shbDiscordBot.api.PlayerStatus; +import org.kilka.shbDiscordBot.api.ShlakoblockApi; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.Level; +import java.util.logging.Logger; + +public final class RolePermissionSync { + private final ShlakoblockApi api; + private final LuckPerms luckPerms; + private final Map> mappings; + private final int maxConcurrency; + private final Logger logger; + private final AtomicBoolean fullSyncRunning = new AtomicBoolean(); + + public RolePermissionSync( + ShlakoblockApi api, + LuckPerms luckPerms, + Map> mappings, + int maxConcurrency, + Logger logger + ) { + this.api = api; + this.luckPerms = luckPerms; + this.mappings = mappings; + this.maxConcurrency = maxConcurrency; + this.logger = logger; + } + + public CompletableFuture syncGuild(Guild guild) { + if (mappings.isEmpty()) { + return CompletableFuture.completedFuture(null); + } + if (!fullSyncRunning.compareAndSet(false, true)) { + logger.info("Периодическая синхронизация ролей пропущена: предыдущий проход ещё выполняется"); + return CompletableFuture.completedFuture(null); + } + + logger.info("Запущена синхронизация Discord-ролей; обновляем список участников guild"); + return loadMembers(guild) + .thenCompose(members -> { + List candidates = members.stream() + .filter(member -> !member.getUser().isBot()) + .filter(member -> !permissionsFor(member).isEmpty()) + .toList(); + logger.info("Синхронизация Discord-ролей: участников с настроенными ролями — " + + candidates.size()); + return syncBatch(candidates, 0); + }) + .whenComplete((ignored, error) -> { + fullSyncRunning.set(false); + if (error != null) { + logger.log(Level.WARNING, "Фоновая синхронизация Discord-ролей завершилась с ошибкой", unwrap(error)); + } else { + logger.info("Синхронизация Discord-ролей завершена"); + } + }); + } + + public CompletableFuture syncMember(Member member) { + Set permissions = permissionsFor(member); + if (member.getUser().isBot() || permissions.isEmpty()) { + return CompletableFuture.completedFuture(null); + } + + return api.findByDiscordId(member.getId()) + .thenCompose(status -> grant(status, permissions)) + .exceptionally(error -> { + logger.log(Level.WARNING, "Не удалось синхронизировать Discord-пользователя " + member.getId(), unwrap(error)); + return null; + }); + } + + public boolean isMappedRole(String roleId) { + return mappings.containsKey(roleId); + } + + private CompletableFuture syncBatch(List members, int offset) { + if (offset >= members.size()) { + return CompletableFuture.completedFuture(null); + } + + int end = Math.min(offset + maxConcurrency, members.size()); + List> futures = new ArrayList<>(end - offset); + for (int index = offset; index < end; index++) { + futures.add(syncMember(members.get(index))); + } + return CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new)) + .thenCompose(ignored -> syncBatch(members, end)); + } + + private CompletableFuture grant(PlayerStatus status, Set permissions) { + if (!status.exists() || !status.discordLinked() || status.uuid() == null) { + return CompletableFuture.completedFuture(null); + } + + final UUID uuid; + try { + uuid = UUID.fromString(status.uuid()); + } catch (IllegalArgumentException exception) { + logger.warning("API вернул некорректный UUID для привязанного Discord-пользователя"); + return CompletableFuture.completedFuture(null); + } + + return luckPerms.getUserManager().loadUser(uuid) + .thenCompose(user -> addNodesAndSave(user, permissions)) + .thenAccept(changed -> { + if (changed) { + logger.info("Выданы LuckPerms permissions игроку " + uuid + ": " + + String.join(", ", permissions)); + } + }); + } + + private CompletableFuture addNodesAndSave(User user, Set permissions) { + boolean changed = false; + for (String permission : permissions) { + Node node = luckPerms.getNodeBuilderRegistry() + .forPermission() + .permission(permission) + .value(true) + .build(); + changed |= user.data().add(node).wasSuccessful(); + } + if (!changed) { + return CompletableFuture.completedFuture(false); + } + return luckPerms.getUserManager().saveUser(user).thenApply(ignored -> true); + } + + private CompletableFuture> loadMembers(Guild guild) { + CompletableFuture> future = new CompletableFuture<>(); + try { + guild.loadMembers() + .onSuccess(future::complete) + .onError(future::completeExceptionally); + } catch (RuntimeException exception) { + future.completeExceptionally(exception); + } + return future; + } + + private Set permissionsFor(Member member) { + return permissionsForRoleIds(member.getRoles().stream().map(Role::getId).toList()); + } + + Set permissionsForRoleIds(Iterable roleIds) { + Set permissions = new HashSet<>(); + for (String roleId : roleIds) { + Set mapped = mappings.get(roleId); + if (mapped != null) { + permissions.addAll(mapped); + } + } + return permissions; + } + + private static Throwable unwrap(Throwable throwable) { + Throwable current = throwable; + while ((current instanceof java.util.concurrent.CompletionException + || current instanceof java.util.concurrent.ExecutionException) && current.getCause() != null) { + current = current.getCause(); + } + return current; + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml new file mode 100644 index 0000000..af68659 --- /dev/null +++ b/src/main/resources/config.yml @@ -0,0 +1,22 @@ +discord: + # Discord bot token. Keep the deployed config private and never commit a real token. + token: "" + guild-id: "" + +api: + base-url: "https://api.shlakoblock.com" + # Value sent in the X-Internal-Secret header. + internal-secret: "" + connect-timeout-seconds: 5 + request-timeout-seconds: 10 + +link: + code-ttl-seconds: 600 + max-failed-attempts-per-minute: 5 + +role-sync: + interval-seconds: 300 + max-concurrent-requests: 4 + mappings: + # "123456789012345678": + # - "example.permission" diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml new file mode 100644 index 0000000..84381a5 --- /dev/null +++ b/src/main/resources/plugin.yml @@ -0,0 +1,30 @@ +name: ShbDiscordBot +description: $description +prefix: SDB +version: '${version}' + +main: org.kilka.shbDiscordBot.ShbDiscordBot +api-version: '26.1.2' +load: POSTWORLD + +authors: [ Kilka_v_HJIebe ] +depend: [ LuckPerms ] + +commands: + link: + description: Привязать Discord-аккаунт по коду из личных сообщений бота + usage: /link <6-значный код> + permission: shbdiscordbot.command.link + sdb: + description: Административные команды ShbDiscordBot + usage: /sdb reload + aliases: [ shbdiscordbot ] + permission: shbdiscordbot.command.reload + +permissions: + shbdiscordbot.command.link: + description: Разрешает использовать команду /link + default: true + shbdiscordbot.command.reload: + description: Разрешает полностью перезагружать конфигурацию и сервисы ShbDiscordBot + default: op diff --git a/src/test/java/org/kilka/shbDiscordBot/api/ShlakoblockApiClientTest.java b/src/test/java/org/kilka/shbDiscordBot/api/ShlakoblockApiClientTest.java new file mode 100644 index 0000000..0bde95f --- /dev/null +++ b/src/test/java/org/kilka/shbDiscordBot/api/ShlakoblockApiClientTest.java @@ -0,0 +1,140 @@ +package org.kilka.shbDiscordBot.api; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.UUID; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ShlakoblockApiClientTest { + private HttpServer server; + private ExecutorService executor; + private volatile RequestCapture request; + private volatile int responseStatus; + private volatile String responseBody; + private ShlakoblockApiClient client; + + @BeforeEach + void setUp() throws IOException { + responseStatus = 200; + responseBody = "{}"; + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", this::handle); + server.start(); + executor = Executors.newFixedThreadPool(2); + client = new ShlakoblockApiClient( + URI.create("http://127.0.0.1:" + server.getAddress().getPort()), + "test-secret", + Duration.ofSeconds(2), + Duration.ofSeconds(2), + executor + ); + } + + @AfterEach + void tearDown() { + server.stop(0); + executor.shutdownNow(); + } + + @Test + void sendsDiscordStatusRequestWithInternalSecret() { + responseBody = """ + {"exists":true,"uuid":"11111111-1111-1111-1111-111111111111","name":"Player", + "registered":true,"discordLinked":true,"discordId":"123456789012345678"} + """; + + PlayerStatus status = client.findByDiscordId("123456789012345678").join(); + + assertTrue(status.exists()); + assertTrue(status.discordLinked()); + assertEquals("POST", request.method()); + assertEquals("/internal/players/status", request.path()); + assertEquals("test-secret", request.secret()); + assertTrue(request.body().contains("\"discordId\":\"123456789012345678\"")); + assertFalse(request.body().contains("\"uuid\"")); + } + + @Test + void sendsLinkRequestToUuidPath() { + UUID uuid = UUID.fromString("11111111-1111-1111-1111-111111111111"); + responseBody = """ + {"uuid":"11111111-1111-1111-1111-111111111111","name":"Player", + "registered":true,"online":true,"discordId":"123456789012345678"} + """; + + PlayerProfile profile = client.linkDiscord(uuid, "123456789012345678").join(); + + assertEquals("Player", profile.name()); + assertEquals("/internal/players/" + uuid + "/discord", request.path()); + assertEquals("{\"discordId\":\"123456789012345678\"}", request.body()); + } + + @Test + void classifiesServerErrorAsRetryable() { + responseStatus = 503; + + Throwable cause = failureOf(client.findByDiscordId("123456789012345678")); + + ApiException exception = assertInstanceOf(ApiException.class, cause); + assertEquals(503, exception.statusCode()); + assertTrue(exception.retryable()); + } + + @Test + void classifiesUnauthorizedAsPermanent() { + responseStatus = 401; + + Throwable cause = failureOf(client.findByDiscordId("123456789012345678")); + + ApiException exception = assertInstanceOf(ApiException.class, cause); + assertEquals(401, exception.statusCode()); + assertFalse(exception.retryable()); + } + + private Throwable failureOf(java.util.concurrent.CompletableFuture future) { + try { + future.join(); + throw new AssertionError("Expected request failure"); + } catch (CompletionException exception) { + Throwable cause = exception; + while (cause instanceof CompletionException && cause.getCause() != null) { + cause = cause.getCause(); + } + return cause; + } + } + + private void handle(HttpExchange exchange) throws IOException { + String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + request = new RequestCapture( + exchange.getRequestMethod(), + exchange.getRequestURI().getPath(), + exchange.getRequestHeaders().getFirst("X-Internal-Secret"), + body + ); + byte[] response = responseBody.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(responseStatus, response.length); + exchange.getResponseBody().write(response); + exchange.close(); + } + + private record RequestCapture(String method, String path, String secret, String body) { + } +} diff --git a/src/test/java/org/kilka/shbDiscordBot/link/LinkCodeServiceTest.java b/src/test/java/org/kilka/shbDiscordBot/link/LinkCodeServiceTest.java new file mode 100644 index 0000000..a797075 --- /dev/null +++ b/src/test/java/org/kilka/shbDiscordBot/link/LinkCodeServiceTest.java @@ -0,0 +1,172 @@ +package org.kilka.shbDiscordBot.link; + +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.IntSupplier; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +class LinkCodeServiceTest { + @Test + void formatsLeadingZeroAndReplacesPreviousCode() { + MutableClock clock = new MutableClock(); + AtomicInteger sequence = new AtomicInteger(42); + LinkCodeService service = new LinkCodeService(Duration.ofMinutes(10), 5, clock, sequence::getAndIncrement); + + LinkCodeService.IssuedCode first = service.issue("discord-1"); + LinkCodeService.IssuedCode second = service.issue("discord-1"); + + assertEquals("000042", first.code()); + assertEquals("000043", second.code()); + assertEquals(1, service.activeCodeCount()); + assertEquals(LinkCodeService.ReservationResult.Status.INVALID, + service.reserve(UUID.randomUUID(), first.code()).status()); + assertEquals(LinkCodeService.ReservationResult.Status.RESERVED, + service.reserve(UUID.randomUUID(), second.code()).status()); + } + + @Test + void avoidsCollisionBetweenActiveCodes() { + MutableClock clock = new MutableClock(); + int[] values = {7, 7, 8}; + AtomicInteger index = new AtomicInteger(); + IntSupplier supplier = () -> values[index.getAndIncrement()]; + LinkCodeService service = new LinkCodeService(Duration.ofMinutes(10), 5, clock, supplier); + + String first = service.issue("discord-1").code(); + String second = service.issue("discord-2").code(); + + assertNotEquals(first, second); + assertEquals("000008", second); + } + + @Test + void expiresAndConsumesCode() { + MutableClock clock = new MutableClock(); + LinkCodeService service = new LinkCodeService(Duration.ofMinutes(10), 5, clock, () -> 123456); + String expired = service.issue("discord-1").code(); + clock.advance(Duration.ofMinutes(10)); + + assertEquals(LinkCodeService.ReservationResult.Status.INVALID, + service.reserve(UUID.randomUUID(), expired).status()); + + clock.advance(Duration.ofSeconds(1)); + String active = service.issue("discord-1").code(); + LinkCodeService.Reservation reservation = service.reserve(UUID.randomUUID(), active).reservation(); + service.complete(reservation); + + assertEquals(0, service.activeCodeCount()); + assertEquals(LinkCodeService.ReservationResult.Status.INVALID, + service.reserve(UUID.randomUUID(), active).status()); + } + + @Test + void releasesReservationAfterTransientFailure() { + LinkCodeService service = new LinkCodeService(Duration.ofMinutes(10), 5, new MutableClock(), () -> 654321); + String code = service.issue("discord-1").code(); + LinkCodeService.Reservation first = service.reserve(UUID.randomUUID(), code).reservation(); + + assertEquals(LinkCodeService.ReservationResult.Status.INVALID, + service.reserve(UUID.randomUUID(), code).status()); + service.release(first); + assertEquals(LinkCodeService.ReservationResult.Status.RESERVED, + service.reserve(UUID.randomUUID(), code).status()); + } + + @Test + void rateLimitsFailedAttemptsForOneMinute() { + MutableClock clock = new MutableClock(); + LinkCodeService service = new LinkCodeService(Duration.ofMinutes(10), 2, clock, () -> 111111); + UUID player = UUID.randomUUID(); + + assertEquals(LinkCodeService.ReservationResult.Status.INVALID, service.reserve(player, "000000").status()); + assertEquals(LinkCodeService.ReservationResult.Status.INVALID, service.reserve(player, "000001").status()); + assertEquals(LinkCodeService.ReservationResult.Status.RATE_LIMITED, service.reserve(player, "000002").status()); + + clock.advance(Duration.ofMinutes(1)); + assertEquals(LinkCodeService.ReservationResult.Status.INVALID, service.reserve(player, "000003").status()); + } + + @Test + void onlyOneConcurrentReservationSucceeds() throws InterruptedException { + LinkCodeService service = new LinkCodeService(Duration.ofMinutes(10), 5, new MutableClock(), () -> 222222); + String code = service.issue("discord-1").code(); + CountDownLatch start = new CountDownLatch(1); + AtomicInteger successes = new AtomicInteger(); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + for (int index = 0; index < 2; index++) { + executor.submit(() -> { + try { + start.await(); + if (service.reserve(UUID.randomUUID(), code).status() + == LinkCodeService.ReservationResult.Status.RESERVED) { + successes.incrementAndGet(); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + } + }); + } + start.countDown(); + executor.shutdown(); + while (!executor.isTerminated()) { + Thread.onSpinWait(); + } + } finally { + executor.shutdownNow(); + } + assertEquals(1, successes.get()); + } + + @Test + void preventsTwoLinksForSameMinecraftUuidAtOnce() { + AtomicInteger sequence = new AtomicInteger(100); + LinkCodeService service = new LinkCodeService( + Duration.ofMinutes(10), 5, new MutableClock(), sequence::getAndIncrement + ); + String first = service.issue("discord-1").code(); + String second = service.issue("discord-2").code(); + UUID player = UUID.randomUUID(); + + LinkCodeService.Reservation reservation = service.reserve(player, first).reservation(); + assertEquals(LinkCodeService.ReservationResult.Status.INVALID, service.reserve(player, second).status()); + + service.release(reservation); + assertEquals(LinkCodeService.ReservationResult.Status.RESERVED, service.reserve(player, second).status()); + } + + private static final class MutableClock extends Clock { + private Instant instant = Instant.parse("2026-07-22T00:00:00Z"); + + void advance(Duration duration) { + instant = instant.plus(duration); + } + + @Override + public ZoneId getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return instant; + } + } +} diff --git a/src/test/java/org/kilka/shbDiscordBot/permissions/RolePermissionSyncTest.java b/src/test/java/org/kilka/shbDiscordBot/permissions/RolePermissionSyncTest.java new file mode 100644 index 0000000..2441a8f --- /dev/null +++ b/src/test/java/org/kilka/shbDiscordBot/permissions/RolePermissionSyncTest.java @@ -0,0 +1,177 @@ +package org.kilka.shbDiscordBot.permissions; + +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.Role; +import net.dv8tion.jda.api.entities.User; +import net.luckperms.api.LuckPerms; +import net.luckperms.api.model.data.DataMutateResult; +import net.luckperms.api.model.data.NodeMap; +import net.luckperms.api.model.user.UserManager; +import net.luckperms.api.node.Node; +import net.luckperms.api.node.NodeBuilderRegistry; +import net.luckperms.api.node.types.PermissionNode; +import net.dv8tion.jda.api.utils.concurrent.Task; +import org.kilka.shbDiscordBot.api.PlayerStatus; +import org.kilka.shbDiscordBot.api.ShlakoblockApi; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; +import java.util.logging.Logger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class RolePermissionSyncTest { + @Test + void unionsPermissionsFromAllMappedRoles() { + RolePermissionSync sync = new RolePermissionSync( + null, + null, + Map.of( + "role-a", Set.of("server.vip", "server.chat.color"), + "role-b", Set.of("server.vip", "server.fly") + ), + 2, + Logger.getAnonymousLogger() + ); + + Set permissions = sync.permissionsForRoleIds(List.of("role-a", "unknown", "role-b")); + + assertEquals(Set.of("server.vip", "server.chat.color", "server.fly"), permissions); + } + + @Test + void returnsEmptySetWhenNoRoleMatches() { + RolePermissionSync sync = new RolePermissionSync( + null, + null, + Map.of("role-a", Set.of("server.vip")), + 2, + Logger.getAnonymousLogger() + ); + + assertEquals(Set.of(), sync.permissionsForRoleIds(List.of("other-role"))); + } + + @Test + void grantsMappedPermissionsAndSavesLuckPermsUser() { + UUID uuid = UUID.fromString("11111111-1111-1111-1111-111111111111"); + ShlakoblockApi api = mock(ShlakoblockApi.class); + LuckPerms luckPerms = mock(LuckPerms.class); + UserManager userManager = mock(UserManager.class); + NodeBuilderRegistry nodeBuilderRegistry = mock(NodeBuilderRegistry.class); + PermissionNode.Builder permissionNodeBuilder = mock(PermissionNode.Builder.class); + PermissionNode permissionNode = mock(PermissionNode.class); + net.luckperms.api.model.user.User luckPermsUser = mock(net.luckperms.api.model.user.User.class); + NodeMap userData = mock(NodeMap.class); + DataMutateResult mutateResult = mock(DataMutateResult.class); + Member member = member("discord-user", "role-a"); + + when(api.findByDiscordId("discord-user")).thenReturn(CompletableFuture.completedFuture( + new PlayerStatus(true, uuid.toString(), "Player", true, true, "discord-user") + )); + when(luckPerms.getUserManager()).thenReturn(userManager); + when(luckPerms.getNodeBuilderRegistry()).thenReturn(nodeBuilderRegistry); + when(nodeBuilderRegistry.forPermission()).thenReturn(permissionNodeBuilder); + when(permissionNodeBuilder.permission(any(String.class))).thenReturn(permissionNodeBuilder); + when(permissionNodeBuilder.value(true)).thenReturn(permissionNodeBuilder); + when(permissionNodeBuilder.build()).thenReturn(permissionNode); + when(userManager.loadUser(uuid)).thenReturn(CompletableFuture.completedFuture(luckPermsUser)); + when(luckPermsUser.data()).thenReturn(userData); + when(userData.add(any(Node.class))).thenReturn(mutateResult); + when(mutateResult.wasSuccessful()).thenReturn(true); + when(userManager.saveUser(luckPermsUser)).thenReturn(CompletableFuture.completedFuture(null)); + + RolePermissionSync sync = new RolePermissionSync( + api, + luckPerms, + Map.of("role-a", Set.of("server.vip", "server.fly")), + 2, + Logger.getAnonymousLogger() + ); + sync.syncMember(member).join(); + + verify(userData, org.mockito.Mockito.times(2)).add(any(Node.class)); + verify(userManager).saveUser(luckPermsUser); + verify(userData, never()).remove(any(Node.class)); + } + + @Test + void skipsLuckPermsForUnlinkedDiscordMember() { + ShlakoblockApi api = mock(ShlakoblockApi.class); + LuckPerms luckPerms = mock(LuckPerms.class); + UserManager userManager = mock(UserManager.class); + Member member = member("discord-user", "role-a"); + when(api.findByDiscordId("discord-user")).thenReturn(CompletableFuture.completedFuture( + new PlayerStatus(false, null, null, false, false, null) + )); + when(luckPerms.getUserManager()).thenReturn(userManager); + + RolePermissionSync sync = new RolePermissionSync( + api, + luckPerms, + Map.of("role-a", Set.of("server.vip")), + 2, + Logger.getAnonymousLogger() + ); + sync.syncMember(member).join(); + + verify(userManager, never()).loadUser(any(UUID.class)); + } + + @SuppressWarnings("unchecked") + @Test + void fullSyncReloadsGuildMembersBeforeCheckingRoles() { + ShlakoblockApi api = mock(ShlakoblockApi.class); + LuckPerms luckPerms = mock(LuckPerms.class); + Guild guild = mock(Guild.class); + Member member = member("discord-user", "role-a"); + Task> loadTask = mock(Task.class); + + when(guild.loadMembers()).thenReturn(loadTask); + when(loadTask.onSuccess(any())).thenAnswer(invocation -> { + Consumer> callback = invocation.getArgument(0); + callback.accept(List.of(member)); + return loadTask; + }); + when(loadTask.onError(any())).thenReturn(loadTask); + when(api.findByDiscordId("discord-user")).thenReturn(CompletableFuture.completedFuture( + new PlayerStatus(false, null, null, false, false, null) + )); + + RolePermissionSync sync = new RolePermissionSync( + api, + luckPerms, + Map.of("role-a", Set.of("server.vip")), + 2, + Logger.getAnonymousLogger() + ); + sync.syncGuild(guild).join(); + + verify(guild).loadMembers(); + verify(api).findByDiscordId("discord-user"); + verify(guild, never()).getMembers(); + } + + private static Member member(String discordId, String roleId) { + Member member = mock(Member.class); + User discordUser = mock(User.class); + Role role = mock(Role.class); + when(member.getId()).thenReturn(discordId); + when(member.getUser()).thenReturn(discordUser); + when(discordUser.isBot()).thenReturn(false); + when(member.getRoles()).thenReturn(List.of(role)); + when(role.getId()).thenReturn(roleId); + return member; + } +}