first commit
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
.gradle
|
||||||
|
.idea
|
||||||
|
build
|
||||||
|
run
|
||||||
50
AGENTS.md
Normal file
50
AGENTS.md
Normal 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
65
README.md
Normal 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
62
build.gradle.kts
Normal 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
367
docs/API.md
Normal 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
6
gradle.properties
Normal 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
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
248
gradlew
vendored
Executable 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
93
gradlew.bat
vendored
Normal 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
1
settings.gradle.kts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
rootProject.name = "ShbDiscordBot"
|
||||||
266
src/main/java/org/kilka/shbDiscordBot/ShbDiscordBot.java
Normal file
266
src/main/java/org/kilka/shbDiscordBot/ShbDiscordBot.java
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/main/java/org/kilka/shbDiscordBot/api/ApiException.java
Normal file
26
src/main/java/org/kilka/shbDiscordBot/api/ApiException.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/main/java/org/kilka/shbDiscordBot/api/PlayerProfile.java
Normal file
10
src/main/java/org/kilka/shbDiscordBot/api/PlayerProfile.java
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
package org.kilka.shbDiscordBot.api;
|
||||||
|
|
||||||
|
public record PlayerProfile(
|
||||||
|
String uuid,
|
||||||
|
String name,
|
||||||
|
boolean registered,
|
||||||
|
boolean online,
|
||||||
|
String discordId
|
||||||
|
) {
|
||||||
|
}
|
||||||
11
src/main/java/org/kilka/shbDiscordBot/api/PlayerStatus.java
Normal file
11
src/main/java/org/kilka/shbDiscordBot/api/PlayerStatus.java
Normal 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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
186
src/main/java/org/kilka/shbDiscordBot/command/LinkCommand.java
Normal file
186
src/main/java/org/kilka/shbDiscordBot/command/LinkCommand.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
128
src/main/java/org/kilka/shbDiscordBot/config/PluginConfig.java
Normal file
128
src/main/java/org/kilka/shbDiscordBot/config/PluginConfig.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
201
src/main/java/org/kilka/shbDiscordBot/link/LinkCodeService.java
Normal file
201
src/main/java/org/kilka/shbDiscordBot/link/LinkCodeService.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/main/resources/config.yml
Normal file
22
src/main/resources/config.yml
Normal 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"
|
||||||
30
src/main/resources/plugin.yml
Normal file
30
src/main/resources/plugin.yml
Normal 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
|
||||||
@@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user