first commit

This commit is contained in:
2026-08-07 13:41:43 +03:00
commit dd7f61a958
28 changed files with 2888 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.gradle
.idea
build
run

50
AGENTS.md Normal file
View File

@@ -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 <six-digit-code>` 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.

65
README.md Normal file
View File

@@ -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** до повторного запуска плагина.

62
build.gradle.kts Normal file
View File

@@ -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)
}
}
}

367
docs/API.md Normal file
View File

@@ -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: <shared-secret>
```
При отсутствующем или неверном секрете возвращается `401 invalid_internal_secret`.
### Player endpoints
Пути `/api/*` требуют bearer-токен, выданный `POST /internal/auth/start`:
```http
Authorization: Bearer <token>
```
Хотя схема `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: <secret>
Content-Type: application/json
```
Запись Discord ID игроку:
```http
POST /internal/players/11111111-1111-1111-1111-111111111111/discord HTTP/1.1
Host: api.shlakoblock.com
X-Internal-Secret: <secret>
Content-Type: application/json
```
{"discordId":"123456789012345678"}
```

6
gradle.properties Normal file
View File

@@ -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

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -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

248
gradlew vendored Executable file
View File

@@ -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" "$@"

93
gradlew.bat vendored Normal file
View File

@@ -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

1
settings.gradle.kts Normal file
View File

@@ -0,0 +1 @@
rootProject.name = "ShbDiscordBot"

View File

@@ -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<Void> 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<LuckPerms> 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;
};
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,10 @@
package org.kilka.shbDiscordBot.api;
public record PlayerProfile(
String uuid,
String name,
boolean registered,
boolean online,
String discordId
) {
}

View File

@@ -0,0 +1,11 @@
package org.kilka.shbDiscordBot.api;
public record PlayerStatus(
boolean exists,
String uuid,
String name,
boolean registered,
boolean discordLinked,
String discordId
) {
}

View File

@@ -0,0 +1,12 @@
package org.kilka.shbDiscordBot.api;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
public interface ShlakoblockApi {
CompletableFuture<PlayerStatus> findByDiscordId(String discordId);
CompletableFuture<PlayerStatus> findByUuid(UUID uuid);
CompletableFuture<PlayerProfile> linkDiscord(UUID uuid, String discordId);
}

View File

@@ -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<PlayerStatus> findByDiscordId(String discordId) {
return post("/internal/players/status", new StatusRequest(null, discordId), StatusResponse.class)
.thenApply(StatusResponse::toDomain);
}
@Override
public CompletableFuture<PlayerStatus> findByUuid(UUID uuid) {
return post("/internal/players/status", new StatusRequest(uuid.toString(), null), StatusResponse.class)
.thenApply(StatusResponse::toDomain);
}
@Override
public CompletableFuture<PlayerProfile> linkDiscord(UUID uuid, String discordId) {
String path = "/internal/players/" + uuid + "/discord";
return post(path, new DiscordLinkRequest(discordId), ProfileResponse.class)
.thenApply(ProfileResponse::toDomain);
}
private <T> CompletableFuture<T> post(String path, Object body, Class<T> 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);
}
}
}

View File

@@ -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<Void> 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<Void> 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;
}
}
}

View File

@@ -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<CommandSender> reloadAction;
public ReloadCommand(Consumer<CommandSender> 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<String> 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();
}
}

View File

@@ -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<String, Set<String>> 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<String, Set<String>> 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<String> 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);
}
}
}

View File

@@ -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<String> fatalErrorHandler;
private final CompletableFuture<Guild> 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<String> 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<Guild> ready() {
return ready;
}
public CompletableFuture<Member> 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<Void> 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"
+ "Код одноразовый и истекает <t:" + expiresAt + ":R>.";
})
.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<Void> 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;
}
}

View File

@@ -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<String, Entry> entriesByCode = new HashMap<>();
private final Map<String, String> codeByDiscordId = new HashMap<>();
private final Map<UUID, AttemptWindow> attemptsByPlayer = new HashMap<>();
private final Set<UUID> 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<Map.Entry<String, Entry>> iterator = entriesByCode.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, 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;
}
}
}

View File

@@ -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<String, Set<String>> mappings;
private final int maxConcurrency;
private final Logger logger;
private final AtomicBoolean fullSyncRunning = new AtomicBoolean();
public RolePermissionSync(
ShlakoblockApi api,
LuckPerms luckPerms,
Map<String, Set<String>> mappings,
int maxConcurrency,
Logger logger
) {
this.api = api;
this.luckPerms = luckPerms;
this.mappings = mappings;
this.maxConcurrency = maxConcurrency;
this.logger = logger;
}
public CompletableFuture<Void> 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<Member> 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<Void> syncMember(Member member) {
Set<String> 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<Void> syncBatch(List<Member> members, int offset) {
if (offset >= members.size()) {
return CompletableFuture.completedFuture(null);
}
int end = Math.min(offset + maxConcurrency, members.size());
List<CompletableFuture<Void>> 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<Void> grant(PlayerStatus status, Set<String> 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<Boolean> addNodesAndSave(User user, Set<String> 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<List<Member>> loadMembers(Guild guild) {
CompletableFuture<List<Member>> future = new CompletableFuture<>();
try {
guild.loadMembers()
.onSuccess(future::complete)
.onError(future::completeExceptionally);
} catch (RuntimeException exception) {
future.completeExceptionally(exception);
}
return future;
}
private Set<String> permissionsFor(Member member) {
return permissionsForRoleIds(member.getRoles().stream().map(Role::getId).toList());
}
Set<String> permissionsForRoleIds(Iterable<String> roleIds) {
Set<String> permissions = new HashSet<>();
for (String roleId : roleIds) {
Set<String> 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;
}
}

View File

@@ -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"

View File

@@ -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

View File

@@ -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) {
}
}

View File

@@ -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;
}
}
}

View File

@@ -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<String> 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<List<Member>> loadTask = mock(Task.class);
when(guild.loadMembers()).thenReturn(loadTask);
when(loadTask.onSuccess(any())).thenAnswer(invocation -> {
Consumer<List<Member>> 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;
}
}