Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
363e02f409 | ||
|
|
d43e032c37 | ||
|
|
d97c4c6da2 | ||
|
|
764adb6cbc | ||
|
|
e989bff9e3 | ||
|
|
0863e0723b | ||
|
|
142cc77ec8 | ||
|
|
d376e245b7 | ||
|
|
6e99fe5477 | ||
|
|
dba7a84819 | ||
|
|
87ba09b001 | ||
|
|
75b495b08c | ||
|
|
b0664e8835 | ||
|
|
7443cbfb4b | ||
|
|
f96c2677b6 | ||
|
|
8e4e77cd31 | ||
|
|
10462a08a4 | ||
|
|
389af563b9 | ||
|
|
d3211c6702 | ||
|
|
f6ba30639a | ||
|
|
780016b731 | ||
|
|
01bf94c60a | ||
|
|
dad2e0356e | ||
|
|
d0ea0535e2 | ||
|
|
78d6ade17a | ||
|
|
cb47a1622f | ||
|
|
545c09d502 | ||
|
|
6134c893ab | ||
|
|
91b6de708d |
No files matched your search
+1
-8
@@ -1,15 +1,8 @@
|
||||
.git
|
||||
.github
|
||||
.idea
|
||||
.mvn
|
||||
src
|
||||
classes
|
||||
logs
|
||||
target/*
|
||||
!target/netneutrality.jar
|
||||
target
|
||||
*.iml
|
||||
README.md
|
||||
checkstyle.xml
|
||||
mvnw
|
||||
mvnw.cmd
|
||||
pom.xml
|
||||
@@ -25,6 +25,8 @@ NETNEUTRALITY_WEBPO_URL=http://webpo:8080
|
||||
NETNEUTRALITY_WEBPO_PASSWORD=
|
||||
|
||||
NETNEUTRALITY_SENTRY_DSN=
|
||||
NETNEUTRALITY_WEBHOOK_URL=
|
||||
NETNEUTRALITY_WEBHOOK_SECRET=
|
||||
GITEA_HOST=
|
||||
GITEA_TOKEN=
|
||||
GITEA_OWNER=
|
||||
|
||||
@@ -50,6 +50,7 @@ jobs:
|
||||
name: maven-artifacts
|
||||
path: |
|
||||
target/netneutrality.jar
|
||||
target/lib
|
||||
startup.sh
|
||||
if-no-files-found: error
|
||||
|
||||
|
||||
+14
-2
@@ -1,3 +1,14 @@
|
||||
FROM eclipse-temurin:25-jdk AS build
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
COPY .mvn .mvn
|
||||
COPY mvnw pom.xml checkstyle.xml ./
|
||||
RUN chmod +x mvnw && ./mvnw -B -q dependency:go-offline
|
||||
|
||||
COPY src src
|
||||
RUN ./mvnw -B -q package -DskipTests
|
||||
|
||||
FROM eclipse-temurin:25-jre@sha256:ff82c74a630c08b417502c4c18189e62074edbb28eb1f2d31b799ac138c01058
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends netcat-openbsd curl && rm -rf /var/lib/apt/lists/*
|
||||
@@ -8,7 +19,8 @@ ARG API_PORT=9000
|
||||
EXPOSE ${API_PORT}
|
||||
|
||||
COPY startup.sh ./
|
||||
COPY target/netneutrality.jar ./
|
||||
COPY --from=build /build/target/lib ./lib
|
||||
COPY --from=build /build/target/netneutrality.jar ./
|
||||
|
||||
RUN ["chmod", "+x", "./startup.sh"]
|
||||
CMD ["./startup.sh"]
|
||||
CMD ["./startup.sh"]
|
||||
@@ -1,15 +1,100 @@
|
||||
# Net Neutrality Discord bot
|
||||
# Net Neutrality Discord Bot
|
||||
|
||||
This is the source code for my custom discord bot.
|
||||
This is a personal project, so I offer no support on how to build it.
|
||||
A custom Discord bot written in Java. It uses [JDA](https://github.com/discord-jda/JDA) for Discord, a [Lavalink](https://github.com/lavalink-devs/Lavalink) node for music playback, MariaDB/MySQL for persistence, and exposes an HTTP API built with Javalin.
|
||||
|
||||
This is a personal project. No support is offered for building or running it.
|
||||
|
||||
The preferred method for running the bot is by using Docker Compose like this:
|
||||
## Table of Contents
|
||||
|
||||
- [Requirements](#requirements)
|
||||
- [Configuration](#configuration)
|
||||
- [Running with Docker Compose](#running-with-docker-compose)
|
||||
- [Running Locally](#running-locally)
|
||||
- [Testing](#testing)
|
||||
- [License](#license)
|
||||
|
||||
## Requirements
|
||||
|
||||
- Docker and Docker Compose (recommended), or
|
||||
- JDK 25 and Maven (the included `mvnw` wrapper works) for local runs
|
||||
- A MariaDB/MySQL database
|
||||
- A Lavalink node, version 4.2.0 or newer
|
||||
- A Discord bot token
|
||||
|
||||
## Configuration
|
||||
|
||||
All settings are provided as environment variables. A template with every supported variable is available in [`bot.properties.example`](bot.properties.example).
|
||||
|
||||
Key variables:
|
||||
|
||||
| Variable | Description |
|
||||
| --- | --- |
|
||||
| `NETNEUTRALITY_BOT_TOKEN` | Discord bot token |
|
||||
| `NETNEUTRALITY_BOT_PREFIX` | Command prefix |
|
||||
| `NETNEUTRALITY_BOT_OWNER` | Discord user ID of the bot owner |
|
||||
| `NETNEUTRALITY_API_PORT` | Port for the HTTP API (default `9000`) |
|
||||
| `MYSQL_HOST`, `MYSQL_PORT`, `MYSQL_USER`, `MYSQL_PASSWORD`, `MYSQL_DATABASE` | Database connection settings |
|
||||
| `NETNEUTRALITY_LAVALINK_URI`, `NETNEUTRALITY_LAVALINK_PASSWORD` | Lavalink connection settings |
|
||||
| `NETNEUTRALITY_SENTRY_DSN` | Optional Sentry DSN for error reporting |
|
||||
| `GITEA_HOST`, `GITEA_TOKEN`, `GITEA_OWNER`, `GITEA_REPO` | Optional Gitea settings for issue reporting |
|
||||
| `NETNEUTRALITY_WEBHOOK_URL`, `NETNEUTRALITY_WEBHOOK_SECRET` | Optional frontend webhook URL and HMAC secret (see below) |
|
||||
|
||||
### Webhook events
|
||||
|
||||
When `NETNEUTRALITY_WEBHOOK_URL` is set, the bot POSTs JSON music-state events (`TRACK_START`, `TRACK_END`, `QUEUE_UPDATED`, `PLAYER_PAUSED`, `PLAYER_RESUMED`, `VOLUME_CHANGED`, `VOICE_CONNECTED`, `VOICE_DISCONNECTED`) to it asynchronously. Each event has `type`, `guildId`, `timestamp`, a per-guild `seq` and a `payload`. Requests carry an `X-Signature: sha256=<hex HMAC-SHA256 of the body>` header computed with `NETNEUTRALITY_WEBHOOK_SECRET`. Failed deliveries are retried 3 times with exponential backoff and then dropped, so the frontend should still do a full resync on load and periodically. The polling endpoints remain available.
|
||||
|
||||
`PLAYER_SEEKED` is sent after a seek with payload `{ position }` (milliseconds). `VOTE_SKIP_UPDATED` is sent after a vote that doesn't skip the track yet, with payload `{ userId, votes, threshold }` (`threshold` is the percentage of listeners needed). A vote that does skip produces the usual `TRACK_END` / `TRACK_START` / `QUEUE_UPDATED` events instead.
|
||||
|
||||
`TRACK_START` / `TRACK_END` payloads are `{ identifier, title, author, length, uri, artworkUrl }` (plus `reason` on `TRACK_END`). `uri` and `artworkUrl` may be `null` when the source doesn't provide them.
|
||||
|
||||
`VOICE_CONNECTED` / `VOICE_DISCONNECTED` are sent for the bot itself and for any member joining or leaving the voice channel the bot is currently in. Their payload is `{ channelId, userId, username, nickname, bot, self }`, where `nickname` is the member's effective display name in the guild, `bot` marks bot accounts and `self` marks this bot's own voice changes.
|
||||
|
||||
## Running with Docker Compose
|
||||
|
||||
Docker Compose is the preferred way to run the bot. It starts the bot, the database, Lavalink, and the supporting YouTube cipher and PO token services.
|
||||
|
||||
1. Copy `bot.properties.example` and fill in the values (see [Configuration](#configuration)).
|
||||
2. Build and start the stack:
|
||||
|
||||
```bash
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The environment variables needed for the bot to run are available in the file **bot.properties.example**
|
||||
If running locally without Docker, you need to copy the example file to **bot.properties** and fill the values.
|
||||
3. View logs:
|
||||
|
||||
```bash
|
||||
docker compose logs -f netneutrality-bot
|
||||
```
|
||||
|
||||
Logs are also written to the `logs` directory.
|
||||
|
||||
## Running Locally
|
||||
|
||||
1. Copy `bot.properties.example` to `bot.properties` and fill in the values.
|
||||
2. Make sure a database and a Lavalink node are reachable at the configured addresses.
|
||||
3. Build the project:
|
||||
|
||||
```bash
|
||||
./mvnw package
|
||||
```
|
||||
|
||||
4. Run the bot:
|
||||
|
||||
```bash
|
||||
java -jar target/netneutrality.jar
|
||||
```
|
||||
|
||||
Dependencies are copied to `target/lib` during packaging and referenced by the jar manifest.
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
./mvnw test
|
||||
```
|
||||
|
||||
Database tests use Testcontainers, so Docker must be running. Checkstyle runs during the `test` phase.
|
||||
|
||||
## License
|
||||
|
||||
See the [LICENSE](LICENSE) file.
|
||||
@@ -6,6 +6,8 @@ NETNEUTRALITY_BOT_PREFIX=$
|
||||
NETNEUTRALITY_BOT_OWNER=
|
||||
NETNEUTRALITY_BOT_INVITE=
|
||||
NETNEUTRALITY_API_PORT=9000
|
||||
# Optional: number of gateway shards. Leave unset to use Discord's recommended count.
|
||||
# NETNEUTRALITY_SHARD_TOTAL=1
|
||||
|
||||
MYSQL_HOST=127.0.0.1
|
||||
MYSQL_PORT=3306
|
||||
@@ -25,6 +27,8 @@ NETNEUTRALITY_LAVALINK_PASSWORD=youshallnotpass
|
||||
NETNEUTRALITY_LAVALINK_NAME=main
|
||||
|
||||
NETNEUTRALITY_SENTRY_DSN=
|
||||
NETNEUTRALITY_WEBHOOK_URL=
|
||||
NETNEUTRALITY_WEBHOOK_SECRET=
|
||||
GITEA_HOST=
|
||||
GITEA_TOKEN=
|
||||
GITEA_OWNER=
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
#!/bin/bash
|
||||
./mvnw versions:commit
|
||||
@@ -1,2 +0,0 @@
|
||||
#!/bin/bash
|
||||
./mvnw versions:set -DnewVersion=$CZ_PRE_NEW_VERSION
|
||||
+10
-2
@@ -13,6 +13,12 @@ services:
|
||||
- MYSQL_DATABASE=${MYSQL_DATABASE:-netneutrality}
|
||||
- MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD:-password}
|
||||
- MARIADB_AUTO_UPGRADE=1
|
||||
healthcheck:
|
||||
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
start_period: 30s
|
||||
|
||||
netneutrality-bot:
|
||||
image: gitea.bobert.me/anthorob/netneutrality:${NETNEUTRALITY_TAG:-latest}
|
||||
@@ -22,8 +28,10 @@ services:
|
||||
networks:
|
||||
- netneutrality
|
||||
depends_on:
|
||||
- netneutrality-db
|
||||
- lavalink
|
||||
netneutrality-db:
|
||||
condition: service_healthy
|
||||
lavalink:
|
||||
condition: service_started
|
||||
volumes:
|
||||
- ./logs:/app/logs
|
||||
environment:
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ subsystem, how they fit together, and the end-to-end flows that tie them.
|
||||
| [tasks.md](tasks.md) | Tasks — button/emoji/modal/step/built-in/persistable/scheduled tasks |
|
||||
| [persistence.md](persistence.md) | Database — Hibernate/JPA, Flyway, entities, repositories, services |
|
||||
| [music.md](music.md) | Music — Lavalink node & client, queue, event dispatch |
|
||||
| [web-api.md](web-api.md) | HTTP Web API — internal control & introspection server |
|
||||
| [web-api.md](web-api.md) | HTTP Web API — Javalin server for control, introspection & music |
|
||||
| [threading.md](threading.md) | Threading & scheduling infrastructure |
|
||||
| [utilities-and-bootstrap.md](utilities-and-bootstrap.md) | Utilities, configuration, logging, exceptions |
|
||||
|
||||
|
||||
@@ -59,8 +59,8 @@ infrastructure.
|
||||
| Logging | Log4j2 + `log4j-slf4j2-impl` | 2.26.0 |
|
||||
| Testing | JUnit Jupiter 6 + Mockito 5 | — |
|
||||
|
||||
The HTTP **server** for the internal API uses the JDK built-in
|
||||
`com.sun.net.httpserver.HttpServer` (no external framework); see [web-api.md](web-api.md).
|
||||
The HTTP **server** for the internal API uses embedded Javalin (with OpenAPI/Swagger plugins); see
|
||||
[web-api.md](web-api.md).
|
||||
|
||||
## Startup lifecycle
|
||||
|
||||
@@ -157,5 +157,4 @@ block the others. Effectively this means:
|
||||
| `…http` | Internal web API and HTTP client module |
|
||||
| `…music` | Music domain (service, manager, queue, scheduler, track loading) |
|
||||
| `…utils` | `Settings` / `SettingsProvider`, `ChannelMessagePurger`, `MusicUtils` |
|
||||
| `…shared.util` | Cross-cutting helpers |
|
||||
| `…exceptions` | Custom exception hierarchy |
|
||||
+4
-3
@@ -1,7 +1,7 @@
|
||||
# Commands
|
||||
|
||||
**Packages:** `me.bobert.netneutrality.framework.command` (framework),
|
||||
`me.bobert.netneutrality.feature.*.command` (concrete commands)
|
||||
**Packages:** `me.bobert.netneutrality.core.command` (framework),
|
||||
`me.bobert.netneutrality.command.*` (concrete commands)
|
||||
|
||||
A **Command** is what a user executes on Discord. It receives a
|
||||
[`CommandContext`](contexts.md#commandcontext) and runs logic based on it. There are two primary
|
||||
@@ -129,7 +129,7 @@ At runtime, an incoming command event becomes a `CommandContext` in the
|
||||
|
||||
## Command catalog
|
||||
|
||||
Concrete commands live under `me.bobert.netneutrality.feature.*.command`, grouped by feature package:
|
||||
Concrete commands live under `me.bobert.netneutrality.command.*`, grouped by feature package:
|
||||
|
||||
| Command | Package | Type | Description |
|
||||
|---|---|---|---|
|
||||
@@ -147,6 +147,7 @@ Concrete commands live under `me.bobert.netneutrality.feature.*.command`, groupe
|
||||
| `JoinCommand` / `LeaveCommand` | `music` | Slash | Join / leave the voice channel. |
|
||||
| `CurrentlyPlayingCommand` | `music` | Slash | Show the currently playing track. |
|
||||
| `ResumeCommand` / `StopCommand` / `VolumeCommand` | `music` | Slash | Playback controls. |
|
||||
| `AutoplayCommand` | `music` | Slash | Toggle autoplay: queues the YouTube radio mix of the last track so music keeps playing. |
|
||||
| `BitconnectCommand` | `music` | Slash | Novelty music command. |
|
||||
| `CreateEmojiReaction` / `ModifyEmojiReaction` | `react` | Message menu | Create/modify emoji-role mappings from a message. |
|
||||
| `DatabaseCommand`, `PurgeCommand`, `ShutdownCommand`, `JoinClanCommand`, `TestCommand`, `SlashCommandTest`, `TrackStuckTest` | `custom` | Mixed | Owner/debug and personal utilities (the `custom` package is gitignored per-deployment). |
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
# Contexts
|
||||
|
||||
**Packages:** `me.bobert.netneutrality.framework.context` (framework),
|
||||
`me.bobert.netneutrality.framework.context` (concrete implementations)
|
||||
**Packages:** `me.bobert.netneutrality.core.context` (framework),
|
||||
`me.bobert.netneutrality.context.command` / `me.bobert.netneutrality.context.tasks` (concrete implementations)
|
||||
|
||||
A **Context** is a high-level abstraction over a Discord event. It hides the differences between the
|
||||
many JDA event types (slash command, message, button, modal, reaction, …) behind a single, uniform
|
||||
@@ -117,7 +117,7 @@ Abstract base for the [task flow](tasks.md). Two things distinguish it:
|
||||
|
||||
### `AsyncContext`
|
||||
|
||||
`me.bobert.netneutrality.framework.context.AsyncContext` wraps the **result** of a previous reply — either an
|
||||
`me.bobert.netneutrality.core.context.AsyncContext` wraps the **result** of a previous reply — either an
|
||||
`InteractionHook` (for interactions) or a `Message` (for message replies) — so follow-up actions are
|
||||
expressed the same way regardless of the original trigger:
|
||||
|
||||
|
||||
+16
-1
@@ -1,6 +1,6 @@
|
||||
# Event Handling & Routing
|
||||
|
||||
**Package:** `me.bobert.netneutrality.platform.event`
|
||||
**Package:** `me.bobert.netneutrality.hooks`
|
||||
**Primary class:** `EventsHandler`
|
||||
|
||||
`EventsHandler` is the single JDA `EventListener` registered on the `JDA` instance
|
||||
@@ -91,6 +91,21 @@ Key points:
|
||||
`TaskManager.getInstance().handle(context)`.
|
||||
- **`null` contexts** (bot messages, consumer-only events) are simply not routed anywhere.
|
||||
|
||||
## Webhook music events
|
||||
|
||||
`MusicEvent` instances are delivered by `WebhookDispatcher`. Every event carries `type`, `guildId`,
|
||||
`timestamp` and `seq`; the payloads of the queue and player events are:
|
||||
|
||||
| Type | Payload |
|
||||
|---|---|
|
||||
| `QUEUE_UPDATED` | `size` (int), `change` (`added`/`playlist_added`/`removed`/`moved`/`cleared`/`shuffled`), `items` (list). Each item has `index` (0-based position, before the mutation for removals), `identifier`, `title`, `author`, `duration_ms`, `uri`, `artwork_url`, `requester_id` and `playlist` (`{name, identifier, track_count}` or `null` when the track was not queued from a playlist). `moved` items also carry `fromIndex` and `toIndex`. `cleared` and `shuffled` have an empty `items` (reload the queue on `shuffled`). `playlist_added` is emitted once per playlist load (instead of one `added` event per track), lists only the tracks actually added (duplicates are skipped) and carries a top-level `playlist` object (`name`, `identifier`, `track_count`, the number actually added). |
|
||||
| `QUEUE_SHUFFLED` | `size` |
|
||||
| `TRACK_ERROR` | `track` (`identifier`, `title`, `author`, `length`, `uri`, `artworkUrl`), `message`, `severity` |
|
||||
| `TRACK_STUCK` | `track` (same fields as above), `thresholdMs` |
|
||||
| `PLAYER_POSITION` | `position` (ms), `timestamp` (node time, epoch ms), `connected`, `ping` (ms). Sent from Lavalink `PlayerUpdateEvent`, at most once every 5 seconds per guild and only while a track is playing. |
|
||||
|
||||
Consumers must handle the `playlist_added` change value and the per-item `playlist` field. The REST queue listing (`GET /music/{guildId}/queue`) also reports `playlist` on every entry.
|
||||
|
||||
## Extending the event system
|
||||
|
||||
To handle a new event type:
|
||||
|
||||
+1
-1
@@ -166,5 +166,5 @@ scope of this migration and is tracked as a follow-up.
|
||||
All music commands extend [`MusicCommand`](commands.md#base-implementations-corecommandimpl) (guild
|
||||
only + music role/channel enforced): `PlayCommand`, `SkipCommand`/`VoteSkipCommand`,
|
||||
`QueueCommand`/`ViewQueueCommand`, `ClearQueueCommand`, `ShuffleCommand`, `JoinCommand`/`LeaveCommand`,
|
||||
`CurrentlyPlayingCommand`, `ResumeCommand`/`StopCommand`, `VolumeCommand`, `BitconnectCommand`. See
|
||||
`CurrentlyPlayingCommand`, `ResumeCommand`/`StopCommand`, `VolumeCommand`, `AutoplayCommand`, `BitconnectCommand`. See
|
||||
[commands.md](commands.md#command-catalog).
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# Persistence & Database
|
||||
|
||||
**Packages:** `me.bobert.netneutrality.platform.persistence` (session/migration),
|
||||
**Packages:** `me.bobert.netneutrality.db` (session/migration),
|
||||
`me.bobert.netneutrality.db` (entities, repositories, services).
|
||||
**Resources:** `hibernate.cfg.xml`, `db/migration/V1__initial_schema.sql`,
|
||||
`db/migration/V2__task_type_id.sql`.
|
||||
@@ -45,7 +45,7 @@ Repositories obtain sessions through the factory; transactional access uses help
|
||||
|
||||
## Entity catalog
|
||||
|
||||
Entities live in `me.bobert.netneutrality.data.entity` and are prefixed with `O`. `OGuild` is the hub
|
||||
Entities live in `me.bobert.netneutrality.db.entities` and are prefixed with `O`. `OGuild` is the hub
|
||||
of a star-shaped model; most other tables reference `guild_id` with `ON DELETE CASCADE`.
|
||||
|
||||
| Entity | Table | Key | Relationships / notes |
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
# Tasks
|
||||
|
||||
**Packages:** `me.bobert.netneutrality.framework.task` (framework),
|
||||
`me.bobert.netneutrality.feature.*.task` (concrete tasks)
|
||||
**Packages:** `me.bobert.netneutrality.core.tasks` (framework),
|
||||
`me.bobert.netneutrality.tasks.*` (concrete tasks)
|
||||
|
||||
Tasks are one of the more advanced concepts in the bot. A **Task** is a unit of logic that is looked
|
||||
up and executed by **id** when a matching Discord event arrives, as opposed to a
|
||||
@@ -60,7 +60,7 @@ AbstractTask (implements Task)
|
||||
|
||||
## `TaskManager`
|
||||
|
||||
`me.bobert.netneutrality.framework.task.TaskManager` is the registry, lookup, and execution
|
||||
`me.bobert.netneutrality.core.tasks.TaskManager` is the registry, lookup, and execution
|
||||
coordinator.
|
||||
|
||||
### Registration — `insert`
|
||||
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
# Threading & Scheduling
|
||||
|
||||
**Package:** `me.bobert.netneutrality.platform.concurrency`
|
||||
**Packages:** `me.bobert.netneutrality.core.thread` and `me.bobert.netneutrality.core.scheduled`
|
||||
|
||||
This subsystem centralizes asynchronous execution and recurring jobs.
|
||||
|
||||
@@ -21,11 +21,11 @@ This subsystem centralizes asynchronous execution and recurring jobs.
|
||||
|
||||
## `BaseThreadFactory`
|
||||
|
||||
Names created threads (e.g. `web-api-1`) so they are identifiable in logs and stack dumps, and installs
|
||||
Names created threads (e.g. `pool-1`) so they are identifiable in logs and stack dumps, and installs
|
||||
an [`ExecutorException`](utilities-and-bootstrap.md#exceptions) as the thread's
|
||||
`UncaughtExceptionHandler` so crashes on worker threads are still routed through
|
||||
[`LogHandler`](utilities-and-bootstrap.md#loghandler). It is used both by `ThreadManager` and by the
|
||||
[Web API](web-api.md) server pool.
|
||||
[`LogHandler`](utilities-and-bootstrap.md#loghandler). It is used by `ThreadManager`; the
|
||||
[Web API](web-api.md) runs on Javalin's own thread pool instead.
|
||||
|
||||
## `ScheduledTaskManager`
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ error tracking, the shared utility helpers, and the custom exception hierarchy.
|
||||
| `App` | The application: holds the process instance and the `JDA` handle, builds the `ApplicationContext`, runs `context.start()` / `context.stop()`, and is the JAR main class (`main()`). See [architecture.md](architecture.md). |
|
||||
| `bootstrap.ApplicationContext` | Hand-rolled container: `register(T)`, `get(Class<T>)`, ordered `start()` / `stop()` for `Lifecycle` components. |
|
||||
| `bootstrap.Lifecycle` | Optional component contract: `start() throws Exception`, default `stop()`, default `priority()`. |
|
||||
| `core.BotState` | Work-in-progress global state helper: exposes a `SAFE` flag and `dump()`, which serializes per-guild/music state to `state.json`. Bound to `GuildServiceProvider` / `MusicService` during bootstrap. |
|
||||
| `bootstrap.BotState` | Work-in-progress global state helper: exposes a `SAFE` flag and `dump()`, which serializes per-guild/music state to `state.json`. Bound to `GuildServiceProvider` / `MusicService` during bootstrap. |
|
||||
|
||||
The full startup and shutdown sequences are documented in
|
||||
[architecture.md](architecture.md#startup-lifecycle).
|
||||
@@ -37,13 +37,13 @@ resolve via `context.get(type)` is preferred; otherwise the no-arg constructor i
|
||||
|
||||
| Interface | Typical implementor |
|
||||
|-----------------------------------------|---------------------------------------|
|
||||
| `platform.config.SettingsProvider` | `utils.Settings` |
|
||||
| `platform.persistence.SessionProvider` | `core.db.SessionManager` |
|
||||
| `platform.concurrency.ExecutorProvider` | `core.thread.ThreadManager` |
|
||||
| `framework.command.CommandRegistry` | `core.command.CommandManager` |
|
||||
| `framework.task.TaskManager` | `core.tasks.TaskManagerImpl` |
|
||||
| `feature.music.MusicService` | `music.MusicServiceImpl` |
|
||||
| `data.service.GuildServiceProvider` | `db.service.GuildServiceProviderImpl` |
|
||||
| `utils.SettingsProvider` | `utils.Settings` |
|
||||
| `db.SessionProvider` | `db.SessionManager` |
|
||||
| `core.thread.ExecutorProvider` | `core.thread.ThreadManager` |
|
||||
| `core.command.CommandRegistry` | `core.command.CommandManager` |
|
||||
| `core.tasks.TaskManager` | `core.tasks.TaskManagerImpl` |
|
||||
| `music.MusicService` | `music.MusicServiceImpl` |
|
||||
| `db.service.GuildServiceProvider` | `db.service.GuildServiceProviderImpl` |
|
||||
|
||||
There are **no** `getInstance()` / `instance()` shims on the managers above. The only intentional
|
||||
static access points left are:
|
||||
@@ -60,8 +60,8 @@ static access points left are:
|
||||
|
||||
### `Settings` / `SettingsProvider`
|
||||
|
||||
**Class:** `me.bobert.netneutrality.platform.config.Settings` implements
|
||||
`me.bobert.netneutrality.platform.config.SettingsProvider`.
|
||||
**Class:** `me.bobert.netneutrality.utils.Settings` implements
|
||||
`me.bobert.netneutrality.utils.SettingsProvider`.
|
||||
|
||||
`Settings` is the single source of truth for configuration. It loads values from the `bot.properties`
|
||||
file and overlays **environment variables** on top (environment variables win).
|
||||
@@ -90,7 +90,7 @@ An example configuration file is `bot.properties.example`. Key configuration val
|
||||
|
||||
## Utility helpers
|
||||
|
||||
**Package:** `me.bobert.netneutrality.shared.util` (helpers) / `me.bobert.netneutrality.bootstrap` / `me.bobert.netneutrality.platform.*`
|
||||
**Package:** `me.bobert.netneutrality.utils` (helpers) / `me.bobert.netneutrality.bootstrap` / `me.bobert.netneutrality.exceptions`
|
||||
|
||||
| Class | Purpose |
|
||||
|---|---|
|
||||
@@ -125,7 +125,7 @@ singletons.
|
||||
|
||||
### `LogHandler`
|
||||
|
||||
**Class:** `me.bobert.netneutrality.platform.logging.LogHandler`.
|
||||
**Class:** `me.bobert.netneutrality.core.LogHandler`.
|
||||
|
||||
`LogHandler` is the unified logging/error entry point. It routes messages and exceptions to:
|
||||
|
||||
|
||||
+71
-31
@@ -1,52 +1,92 @@
|
||||
# HTTP Web API
|
||||
|
||||
**Package:** `me.bobert.netneutrality.platform.web` (client module) and
|
||||
`me.bobert.netneutrality.platform.web.server` (server).
|
||||
**Server:** JDK built-in `com.sun.net.httpserver.HttpServer` (no external web framework).
|
||||
**Package:** `me.bobert.netneutrality.http` (outbound client) and
|
||||
`me.bobert.netneutrality.http.server` (server).
|
||||
**Server:** embedded [Javalin](https://javalin.io) 7 with the OpenAPI and Swagger UI plugins.
|
||||
|
||||
The bot exposes a small internal HTTP API for health monitoring, control, and guild introspection —
|
||||
intended for an external dashboard/companion, not for end users in Discord.
|
||||
The bot exposes an internal HTTP API for health monitoring, control, guild introspection, and music
|
||||
control — intended for an external dashboard/companion, not for end users in Discord.
|
||||
|
||||
## Server startup
|
||||
|
||||
`WebApi` is a [`Lifecycle`](utilities-and-bootstrap.md#applicationcontext--lifecycle) component
|
||||
(priority 70) registered in [`App.buildContext()`](architecture.md#startup-lifecycle).
|
||||
It receives `SettingsProvider`, `GuildServiceProvider`, and suppliers for the live `JDA` /
|
||||
shutdown callback via its constructor. On `start()` it:
|
||||
It receives `SettingsProvider`, `GuildServiceProvider`, `MusicService`, and suppliers for the live
|
||||
`JDA` / shutdown callback via its constructor. On `start()` it:
|
||||
|
||||
- reads the port from `NETNEUTRALITY_API_PORT` (default `9000`),
|
||||
- creates an `HttpServer` bound to that port,
|
||||
- installs an `Executor` backed by a fixed thread pool of 10 threads named via
|
||||
[`BaseThreadFactory`](threading.md),
|
||||
- registers the handler contexts (below), and
|
||||
- starts the server.
|
||||
- creates a `Javalin` app (Javalin 7 requires routes and exception/error handlers to be registered
|
||||
upfront inside `Javalin.create`),
|
||||
- registers the `OpenApiPlugin` (title "Net Neutrality API", `X-API-Key` security scheme) and the
|
||||
`SwaggerPlugin`,
|
||||
- installs the `ApiKeyFilter` as a `before` handler,
|
||||
- registers the route groups (below), the exception handlers and the 404 handler, and
|
||||
- starts the server on the port.
|
||||
|
||||
On `stop()` the server and its executor are shut down gracefully.
|
||||
On `stop()` the Javalin app is stopped.
|
||||
|
||||
## Handler model
|
||||
## Authentication
|
||||
|
||||
`AbstractHandler` (implements `HttpHandler`) is the base for all handlers and provides helpers such as
|
||||
`sendJsonResponse(...)` and `sendStringResponse(...)`. Concrete handlers extend it and are mounted at
|
||||
a path prefix.
|
||||
`ApiKeyFilter` compares the `X-API-Key` request header with the `NETNEUTRALITY_API_KEY` setting.
|
||||
A missing/invalid key — or no configured key at all — yields `401` ("Missing or invalid API key").
|
||||
Public paths that skip the check: `/`, and anything starting with `/openapi`, `/swagger` or
|
||||
`/webjars` (Swagger UI and its assets).
|
||||
|
||||
| Handler | Base path | Endpoints |
|
||||
|---|---|---|
|
||||
| `RootHandler` | `/` | Simple HTML "OK" landing response. |
|
||||
| `GuildHandler` | `/guilds` | `/guilds/owner` — guild ids + owner ids (JSON); `/guilds/channels/{guildId}` — text channels (JSON); `/guilds/roles/{guildId}` — roles (JSON). |
|
||||
| `ActionHandler` | `/actions` | `/actions/status` — JDA health + gateway ping; `/actions/shutdown` — graceful shutdown. |
|
||||
| `SettingsHandler` | `/settings` | `/settings/catalog` — every known setting with its default value and description; `/settings/current/{guildId}` — current guild settings; `/settings/reload/{guildId}` — invalidate & reload the settings cache. |
|
||||
## Error handling
|
||||
|
||||
The action/guild handlers read live state from the [`JDA`](architecture.md) instance and from the
|
||||
[persistence services](persistence.md#service-layer) (e.g. `GuildSettingService` for the settings
|
||||
endpoints).
|
||||
- `HttpResponseException` (e.g. `BadRequestResponse`, `NotFoundResponse`) →
|
||||
`{"status":"error","message":"..."}` with the exception's status.
|
||||
- Any other exception → logged, `500` with `{"status":"error"}`.
|
||||
- Unknown paths → `404` with `{"status":"error","message":"Not found"}`.
|
||||
|
||||
## Route groups
|
||||
|
||||
Each group is a `*Routes` class with a static `register(RoutesConfig, ...)` method; handlers are
|
||||
annotated with `@OpenApi` so they appear in the generated OpenAPI document. Request/response shapes
|
||||
live in the `http.server.dto` package.
|
||||
|
||||
| Class | Endpoints |
|
||||
|---|---|
|
||||
| `WebApi` | `GET /` — HTML liveness message. |
|
||||
| `GuildRoutes` | `GET /guilds` (optional `user_id` query restricts to mutual guilds); `GET /guilds/owner`; `GET /guilds/{guildId}/owner`; `/channels`; `/roles`; `/members/{userId}`, `/members/{userId}/access`, `/members/{userId}/roles`, `/members/{userId}/admin`; `/voice-channels`, `/voice-channels/{channelId}`; `GET /guilds/{guildId}/config`; `PUT /guilds/{guildId}/debug`, `/logging`, `/log-channel`. |
|
||||
| `ActionRoutes` | `GET /actions/status` — JDA health + gateway ping; `POST /actions/shutdown` — graceful shutdown. |
|
||||
| `SettingsRoutes` | `GET /settings/catalog` — every known setting with default and description; `GET /settings/current/{guildId}` — current guild settings; `PUT /settings/current/{guildId}/{name}` — update a setting; `PUT /settings/reload/{guildId}` — invalidate & reload the settings cache. |
|
||||
| `StatsRoutes` | `GET /stats/bot`; `GET /stats/guilds/{guildId}`. |
|
||||
| `MusicRoutes` | See below. |
|
||||
|
||||
### Music endpoints
|
||||
|
||||
Mirror the slash commands (see [music.md](music.md)); announcements are also posted to the guild's
|
||||
music channel when a context is attached.
|
||||
|
||||
| Method & path | Purpose |
|
||||
|---|---|
|
||||
| `GET /music/sources` | Supported music sources. |
|
||||
| `GET /music/{guildId}/status` | Connection, playback, current track, position, queue size. |
|
||||
| `GET /music/{guildId}/queue` | Queued tracks. |
|
||||
| `POST /music/{guildId}/search` | Resolve a query (optional `searchEngine`) into candidates; returns a `search_id`. Nothing is queued. |
|
||||
| `POST /music/{guildId}/queue` | Queue a candidate by `searchId` + `index` (or all with `queueAll=true`) for `userId`; the bot must already be in a voice channel. |
|
||||
| `DELETE /music/{guildId}/queue` | Clear the queue, or remove one track with `?position=` (1-based). |
|
||||
| `PATCH /music/{guildId}/queue/reorder` | Move a track (`fromPosition` → `toPosition`). |
|
||||
| `POST /music/{guildId}/skip`, `/stop`, `/pause`, `/resume`, `/shuffle`, `/leave` | Playback controls. |
|
||||
| `GET` / `PUT /music/{guildId}/volume` | Read / set volume (0–100). |
|
||||
| `GET` / `PUT /music/{guildId}/autoplay` | Read / set autoplay (`{"enabled": true}`). In-memory, resets when the player is destroyed. |
|
||||
| `PUT /music/{guildId}/seek` | Seek to `positionMs`. |
|
||||
| `GET /music/{guildId}/vote-skip` | Vote-skip status. |
|
||||
| `POST /music/{guildId}/vote-skip/{userId}` | Cast a vote on behalf of a member. |
|
||||
| `GET /music/{guildId}/dj/{userId}` | Whether the member is currently allowed to act as DJ. |
|
||||
|
||||
Search results are held in `PendingSearchResults` and expire after a TTL (returned as
|
||||
`search_id_ttl_seconds`).
|
||||
|
||||
## HTTP client — `WebModule`
|
||||
|
||||
`WebModule` wraps an `OkHttpClient` for the bot's own outbound HTTP calls (e.g. checking for updates
|
||||
via GitLab). It keeps a small `LinkedHashMap`-based response cache limited to 50 entries.
|
||||
`WebModule` (package `me.bobert.netneutrality.http`) wraps an `OkHttpClient` for the bot's own
|
||||
outbound HTTP calls (e.g. checking for updates via GitLab). Responses are cached in
|
||||
`ResponseCache`, a `LinkedHashMap`-based LRU limited to 50 entries.
|
||||
|
||||
## Notes
|
||||
|
||||
- There is no authentication layer documented on these endpoints; the API is expected to be bound to a
|
||||
trusted/internal network (e.g. within the Docker network). `/actions/shutdown` is destructive, so the
|
||||
port should not be exposed publicly.
|
||||
- All routes except the public ones require the API key. Even so, the API is expected to be bound to
|
||||
a trusted/internal network (e.g. within the Docker network); `/actions/shutdown` is destructive,
|
||||
so the port should not be exposed publicly.
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
@@ -87,9 +87,12 @@
|
||||
<artifactId>maven-jar-plugin</artifactId>
|
||||
<version>3.5.0</version>
|
||||
<configuration>
|
||||
<finalName>netneutrality</finalName>
|
||||
<archive>
|
||||
<manifest>
|
||||
<mainClass>${main.class}</mainClass>
|
||||
<addClasspath>true</addClasspath>
|
||||
<classpathPrefix>lib/</classpathPrefix>
|
||||
</manifest>
|
||||
</archive>
|
||||
</configuration>
|
||||
@@ -97,52 +100,18 @@
|
||||
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-shade-plugin</artifactId>
|
||||
<version>3.6.2</version>
|
||||
<artifactId>maven-dependency-plugin</artifactId>
|
||||
<version>3.8.1</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>package</phase>
|
||||
<id>copy-libs</id>
|
||||
<phase>prepare-package</phase>
|
||||
<goals>
|
||||
<goal>shade</goal>
|
||||
<goal>copy-dependencies</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<finalName>netneutrality</finalName>
|
||||
<filters>
|
||||
<filter>
|
||||
<artifact>*:*</artifact>
|
||||
<excludes>
|
||||
<exclude>module-info.class</exclude>
|
||||
<exclude>META-INF/versions/*/module-info.class</exclude>
|
||||
<exclude>META-INF/MANIFEST.MF</exclude>
|
||||
<exclude>META-INF/LICENSE*</exclude>
|
||||
<exclude>META-INF/NOTICE*</exclude>
|
||||
<exclude>META-INF/DEPENDENCIES</exclude>
|
||||
<exclude>META-INF/jandex.idx</exclude>
|
||||
<exclude>META-INF/versions/9/OSGI-INF/MANIFEST.MF</exclude>
|
||||
<exclude>META-INF/*.SF</exclude>
|
||||
<exclude>META-INF/*.DSA</exclude>
|
||||
<exclude>META-INF/*.RSA</exclude>
|
||||
</excludes>
|
||||
</filter>
|
||||
<filter>
|
||||
<artifact>com.squareup.okhttp3:okhttp</artifact>
|
||||
<excludes>
|
||||
<exclude>**</exclude>
|
||||
</excludes>
|
||||
</filter>
|
||||
</filters>
|
||||
<transformers>
|
||||
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||
<mainClass>${main.class}</mainClass>
|
||||
</transformer>
|
||||
<transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
|
||||
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||
<manifestEntries>
|
||||
<Multi-Release>true</Multi-Release>
|
||||
</manifestEntries>
|
||||
</transformer>
|
||||
</transformers>
|
||||
<createDependencyReducedPom>false</createDependencyReducedPom>
|
||||
<outputDirectory>${project.build.directory}/lib</outputDirectory>
|
||||
<includeScope>runtime</includeScope>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
@@ -208,6 +177,7 @@
|
||||
<version>5.3.2</version>
|
||||
</dependency>
|
||||
|
||||
|
||||
<dependency>
|
||||
<groupId>io.javalin</groupId>
|
||||
<artifactId>javalin</artifactId>
|
||||
@@ -350,4 +320,4 @@
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
</project>
|
||||
@@ -6,7 +6,6 @@ package me.bobert.netneutrality;
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
import dev.arbjerg.lavalink.libraries.jda.JDAVoiceUpdateListener;
|
||||
import io.sentry.Sentry;
|
||||
import me.bobert.netneutrality.bootstrap.ApplicationContext;
|
||||
import me.bobert.netneutrality.bootstrap.Lifecycle;
|
||||
@@ -14,6 +13,9 @@ import me.bobert.netneutrality.bootstrap.BotState;
|
||||
import me.bobert.netneutrality.core.LogHandler;
|
||||
import me.bobert.netneutrality.core.command.CommandManager;
|
||||
import me.bobert.netneutrality.db.SessionManager;
|
||||
import me.bobert.netneutrality.events.EventPublisher;
|
||||
import me.bobert.netneutrality.events.NoopEventPublisher;
|
||||
import me.bobert.netneutrality.events.WebhookDispatcher;
|
||||
import me.bobert.netneutrality.core.tasks.TaskManagerImpl;
|
||||
import me.bobert.netneutrality.core.scheduled.ScheduledTaskManager;
|
||||
import me.bobert.netneutrality.core.thread.ThreadManager;
|
||||
@@ -21,7 +23,15 @@ import me.bobert.netneutrality.db.service.GuildServiceProviderImpl;
|
||||
import me.bobert.netneutrality.music.MusicService;
|
||||
import me.bobert.netneutrality.core.command.CommandRegistry;
|
||||
import me.bobert.netneutrality.core.tasks.TaskManager;
|
||||
import me.bobert.netneutrality.hooks.EventListenerHandler;
|
||||
import me.bobert.netneutrality.hooks.EventsHandler;
|
||||
import me.bobert.netneutrality.hooks.GuildLifecycleHandler;
|
||||
import me.bobert.netneutrality.hooks.InteractionHandlers;
|
||||
import me.bobert.netneutrality.hooks.MessageReceivedHandler;
|
||||
import me.bobert.netneutrality.hooks.ReadyHandler;
|
||||
import me.bobert.netneutrality.hooks.VoiceEventPublisher;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import me.bobert.netneutrality.http.server.WebApi;
|
||||
import me.bobert.netneutrality.music.MusicServiceImpl;
|
||||
import me.bobert.netneutrality.core.thread.ExecutorProvider;
|
||||
@@ -36,11 +46,8 @@ import me.bobert.netneutrality.db.repository.GuildSettingRepository;
|
||||
import me.bobert.netneutrality.db.repository.TaskRepository;
|
||||
import me.bobert.netneutrality.utils.Settings;
|
||||
import me.bobert.netneutrality.http.WebModule;
|
||||
import net.dv8tion.jda.api.JDA;
|
||||
import net.dv8tion.jda.api.JDABuilder;
|
||||
import me.bobert.netneutrality.shard.ShardService;
|
||||
import net.dv8tion.jda.api.entities.Activity;
|
||||
import net.dv8tion.jda.api.exceptions.InvalidTokenException;
|
||||
import net.dv8tion.jda.api.requests.GatewayIntent;
|
||||
import org.apache.logging.log4j.Level;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
@@ -50,8 +57,7 @@ public class App {
|
||||
private static final Logger logger = LogManager.getLogger(App.class);
|
||||
private static App instance;
|
||||
private final Settings settings;
|
||||
private JDA jda;
|
||||
private MusicService music;
|
||||
private EventPublisher events = NoopEventPublisher.INSTANCE;
|
||||
private final ApplicationContext context = new ApplicationContext();
|
||||
|
||||
static void main() {
|
||||
@@ -146,22 +152,33 @@ public class App {
|
||||
UserFunction.bind(guildServices);
|
||||
DisabledCommandPrecondition.bind(guildServices);
|
||||
|
||||
// Music sources (priority 40): initialise the shared player manager; disconnect on shutdown.
|
||||
music = context.register(new MusicServiceImpl(settings, guildServices, tasks, executors));
|
||||
// Webhook event dispatch (priority 50); stays a no-op publisher when no URL is configured.
|
||||
String webhookUrl = settings.get("NETNEUTRALITY_WEBHOOK_URL");
|
||||
if (webhookUrl != null && !webhookUrl.isBlank()) {
|
||||
events = context.register(new WebhookDispatcher(webhookUrl, settings.get("NETNEUTRALITY_WEBHOOK_SECRET")));
|
||||
}
|
||||
|
||||
// Music (priority 62, above the shards so it stops first): disconnect players while JDA is still up.
|
||||
MusicService music = context.register(new MusicServiceImpl(settings, guildServices, tasks, executors, events));
|
||||
BotState.bind(guildServices, music);
|
||||
|
||||
// JDA gateway; disconnected on shutdown.
|
||||
context.register(lifecycle(60, this::initJDA, this::disconnect));
|
||||
// JDA shards (priority 60); the events handler is attached to every shard before it connects.
|
||||
List<EventListenerHandler<?>> handlers = new ArrayList<>();
|
||||
handlers.add(new MessageReceivedHandler(settings, guildServices));
|
||||
handlers.addAll(InteractionHandlers.create(settings));
|
||||
handlers.add(new ReadyHandler(commands));
|
||||
handlers.addAll(new GuildLifecycleHandler(guildServices).handlers());
|
||||
handlers.add(new VoiceEventPublisher(events));
|
||||
ShardService shards = context.register(new ShardService(
|
||||
settings, music, new EventsHandler(handlers, commands, tasks)));
|
||||
|
||||
// Register slash commands, attach the event handler and set the presence once JDA is ready.
|
||||
// Slash commands are registered from EventsHandler on ReadyEvent; set the presence here.
|
||||
context.register(lifecycle(65, () -> {
|
||||
commands.registerSlashCommands(jda);
|
||||
jda.addEventListener(new EventsHandler(settings, guildServices, commands, tasks));
|
||||
jda.getPresence().setActivity(Activity.watching("you"));
|
||||
shards.setActivity(Activity.watching("you"));
|
||||
}, () -> { }));
|
||||
|
||||
// Web API (priority 70) — Lifecycle with graceful stop.
|
||||
context.register(new WebApi(settings, guildServices, music, this::getJDA, this::shutdown));
|
||||
context.register(new WebApi(settings, guildServices, music, shards, this::shutdown));
|
||||
}
|
||||
|
||||
private static Lifecycle lifecycle(int priority, ThrowingRunnable start, Runnable stop) {
|
||||
@@ -192,52 +209,10 @@ public class App {
|
||||
return instance;
|
||||
}
|
||||
|
||||
public void initJDA() {
|
||||
String token = settings.get("NETNEUTRALITY_BOT_TOKEN");
|
||||
if (token == null) {
|
||||
SessionManager sessions = context.get(SessionManager.class);
|
||||
if (sessions != null) {
|
||||
sessions.stop();
|
||||
}
|
||||
throw new RuntimeException("No bot token provided, exiting.");
|
||||
}
|
||||
|
||||
try {
|
||||
jda = JDABuilder
|
||||
.createDefault(token)
|
||||
.enableIntents(
|
||||
GatewayIntent.GUILD_MEMBERS,
|
||||
GatewayIntent.GUILD_MESSAGE_REACTIONS,
|
||||
GatewayIntent.GUILD_VOICE_STATES,
|
||||
GatewayIntent.MESSAGE_CONTENT)
|
||||
// Voice state/server updates are handed to Lavalink instead of JDA's audio stack;
|
||||
// DAVE encryption is handled by the node.
|
||||
.setVoiceDispatchInterceptor(new JDAVoiceUpdateListener(music.getClient()))
|
||||
.setActivity(Activity.watching("me load..."))
|
||||
.build();
|
||||
jda.awaitReady();
|
||||
} catch (InvalidTokenException | IllegalArgumentException | InterruptedException e) {
|
||||
if (e instanceof InterruptedException) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
LogHandler.log(e);
|
||||
throw new IllegalStateException("Unable to connect to Discord", e);
|
||||
}
|
||||
}
|
||||
|
||||
public void shutdown() {
|
||||
logger.info("[Net Neutrality] Shutting down...");
|
||||
context.stop();
|
||||
logger.info("[Net Neutrality] Shutdown complete");
|
||||
}
|
||||
|
||||
public JDA getJDA() {
|
||||
return jda;
|
||||
}
|
||||
|
||||
private void disconnect() {
|
||||
if (jda != null) {
|
||||
jda.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package me.bobert.netneutrality.command.music;
|
||||
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
*
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
import me.bobert.netneutrality.core.command.CommandExecutionResult;
|
||||
import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
|
||||
import me.bobert.netneutrality.core.command.impl.MusicCommand;
|
||||
import me.bobert.netneutrality.core.command.options.CommandOption;
|
||||
import me.bobert.netneutrality.core.context.CommandContext;
|
||||
import me.bobert.netneutrality.db.service.GuildServiceProvider;
|
||||
import me.bobert.netneutrality.music.MusicManager;
|
||||
import me.bobert.netneutrality.music.MusicService;
|
||||
import me.bobert.netneutrality.music.TrackScheduler;
|
||||
import me.bobert.netneutrality.utils.MusicUtils;
|
||||
import me.bobert.netneutrality.utils.UserFunction;
|
||||
import net.dv8tion.jda.api.interactions.commands.OptionType;
|
||||
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
|
||||
|
||||
public class AutoplayCommand extends MusicCommand {
|
||||
|
||||
private final MusicService musicService;
|
||||
|
||||
public AutoplayCommand() {
|
||||
this(null, null);
|
||||
}
|
||||
|
||||
public AutoplayCommand(MusicService musicService) {
|
||||
this(musicService, null);
|
||||
}
|
||||
|
||||
public AutoplayCommand(MusicService musicService, GuildServiceProvider guildServices) {
|
||||
super(guildServices);
|
||||
this.musicService = musicService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEnabled() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getCommandName() {
|
||||
return "autoplay";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDescription() {
|
||||
return "Toggles autoplay, which keeps the music going with similar tracks when the queue ends";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerCommandOptions(CommandOptionsBuilder builder) {
|
||||
builder.addOptions(new OptionData(OptionType.BOOLEAN, "enabled", "Turn autoplay on or off; omit to toggle", false));
|
||||
}
|
||||
|
||||
@Override
|
||||
public CommandExecutionResult execute(CommandContext context) {
|
||||
MusicManager manager = musicService.get(context.getGuild());
|
||||
Boolean requested = context.getOption("enabled", CommandOption::getAsBoolean);
|
||||
|
||||
if (MusicUtils.checkIfConnected(context, manager)) {
|
||||
if (UserFunction.isAdmin(context) || MusicUtils.isUserDj(context, manager)) {
|
||||
TrackScheduler scheduler = manager.getScheduler();
|
||||
boolean enabled = requested != null ? requested : !scheduler.isAutoplayEnabled();
|
||||
scheduler.setAutoplayEnabled(enabled);
|
||||
return CommandExecutionResult.success("Autoplay is now " + (enabled ? "enabled" : "disabled"));
|
||||
}
|
||||
}
|
||||
|
||||
return CommandExecutionResult.none();
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import net.dv8tion.jda.api.interactions.commands.DefaultMemberPermissions;
|
||||
|
||||
public interface Command {
|
||||
enum Type {
|
||||
SLASH, USER, MESSAGE
|
||||
CHAT, USER, MESSAGE
|
||||
}
|
||||
|
||||
boolean needAdmin();
|
||||
|
||||
@@ -5,7 +5,6 @@ package me.bobert.netneutrality.core.command;
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
import net.dv8tion.jda.api.entities.channel.ChannelType;
|
||||
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
|
||||
import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData;
|
||||
import net.dv8tion.jda.api.interactions.commands.build.SubcommandData;
|
||||
@@ -13,27 +12,49 @@ import net.dv8tion.jda.api.interactions.commands.build.SubcommandGroupData;
|
||||
import net.dv8tion.jda.internal.interactions.CommandDataImpl;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.StringJoiner;
|
||||
|
||||
public class CommandContainer {
|
||||
public static final CommandContainer NULL_COMMAND = new CommandContainer(null);
|
||||
private static final CommandUsageFormatter USAGE_FORMATTER = new CommandUsageFormatter();
|
||||
private final Command command;
|
||||
private CommandDataImpl commandData;
|
||||
private List<OptionData> options;
|
||||
private List<SubcommandData> subcommands;
|
||||
private List<SubcommandGroupData> subcommandGroups;
|
||||
private final CommandDataImpl commandData;
|
||||
private final List<OptionData> options;
|
||||
private final List<SubcommandData> subcommands;
|
||||
private final List<SubcommandGroupData> subcommandGroups;
|
||||
|
||||
public CommandContainer(Command command) {
|
||||
this.command = command;
|
||||
this(command, build(command));
|
||||
}
|
||||
|
||||
if (command != null) {
|
||||
CommandOptionsBuilder commandOptionsBuilder = new CommandOptionsBuilder(command.getType(), command);
|
||||
command.registerCommandOptions(commandOptionsBuilder);
|
||||
initContainer(commandOptionsBuilder);
|
||||
private CommandContainer(Command command, CommandDataImpl commandData) {
|
||||
this.command = command;
|
||||
this.commandData = commandData;
|
||||
|
||||
if (commandData != null) {
|
||||
this.options = commandData.getOptions();
|
||||
this.subcommands = commandData.getSubcommands();
|
||||
this.subcommandGroups = commandData.getSubcommandGroups();
|
||||
} else {
|
||||
this.options = List.of();
|
||||
this.subcommands = List.of();
|
||||
this.subcommandGroups = List.of();
|
||||
}
|
||||
}
|
||||
|
||||
public static CommandContainer of(Command command) {
|
||||
return new CommandContainer(command);
|
||||
}
|
||||
|
||||
private static CommandDataImpl build(Command command) {
|
||||
if (command == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
CommandOptionsBuilder builder = new CommandOptionsBuilder(command.getType(), command);
|
||||
command.registerCommandOptions(builder);
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
public Command getCommand() {
|
||||
return command;
|
||||
}
|
||||
@@ -54,129 +75,11 @@ public class CommandContainer {
|
||||
return subcommandGroups;
|
||||
}
|
||||
|
||||
private void initContainer(CommandOptionsBuilder builder) {
|
||||
this.commandData = builder.build();
|
||||
this.options = commandData.getOptions();
|
||||
this.subcommands = commandData.getSubcommands();
|
||||
this.subcommandGroups = commandData.getSubcommandGroups();
|
||||
}
|
||||
|
||||
public boolean isEmpty() {
|
||||
return options.isEmpty() && subcommands.isEmpty() && subcommandGroups.isEmpty();
|
||||
}
|
||||
|
||||
public String printUsage() {
|
||||
StringBuilder usage = new StringBuilder();
|
||||
if (!options.isEmpty()) {
|
||||
usage.append("- Usage: /").append(command.getCommandName());
|
||||
for (OptionData option : options) {
|
||||
usage.append(" <").append(option.getName()).append(">");
|
||||
}
|
||||
|
||||
usage.append(printOptions(options, null, false));
|
||||
}
|
||||
|
||||
if (!subcommands.isEmpty()) {
|
||||
for (SubcommandData subcommand : subcommands) {
|
||||
usage.append("- Usage: /").append(command.getCommandName()).append(" ").append(subcommand.getName());
|
||||
|
||||
for (OptionData option : subcommand.getOptions()) {
|
||||
usage.append(" <").append(option.getName()).append(">");
|
||||
}
|
||||
|
||||
usage.append(" (").append(subcommand.getDescription()).append(")");
|
||||
usage.append(printOptions(subcommand.getOptions(), null, false));
|
||||
|
||||
usage.append("\n");
|
||||
}
|
||||
}
|
||||
|
||||
if (!subcommandGroups.isEmpty()) {
|
||||
for (SubcommandGroupData subcommandGroup : subcommandGroups) {
|
||||
usage.append("- Subcommand: /").append(command.getCommandName())
|
||||
.append(" ").append(subcommandGroup.getName())
|
||||
.append(" (").append(subcommandGroup.getDescription()).append(")")
|
||||
.append("\n");
|
||||
|
||||
for (SubcommandData subcommand : subcommandGroup.getSubcommands()) {
|
||||
usage.append(" - Usage: /").append(command.getCommandName())
|
||||
.append(" ").append(subcommandGroup.getName())
|
||||
.append(" ").append(subcommand.getName());
|
||||
|
||||
for (OptionData option : subcommand.getOptions()) {
|
||||
usage.append(" <").append(option.getName()).append(">");
|
||||
}
|
||||
|
||||
usage.append(printOptions(subcommand.getOptions(), subcommand.getDescription(), true));
|
||||
|
||||
usage.append("\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isEmpty()) {
|
||||
usage.append("\n\n-# *: required argument");
|
||||
}
|
||||
|
||||
return usage.toString();
|
||||
}
|
||||
|
||||
private String printOptions(List<OptionData> options, String description, boolean group) {
|
||||
StringBuilder usage = new StringBuilder();
|
||||
|
||||
if (description != null) {
|
||||
usage.append(" (").append(description).append(")");
|
||||
}
|
||||
|
||||
for (OptionData option : options) {
|
||||
usage.append("\n");
|
||||
if (group) {
|
||||
usage.append(" ");
|
||||
}
|
||||
|
||||
usage.append(" - ");
|
||||
if (option.isRequired()) {
|
||||
usage.append("*");
|
||||
}
|
||||
usage.append(option.getName()).append(": ").append(option.getDescription());
|
||||
|
||||
if (option.getMinValue() != null) {
|
||||
usage.append(" [Min value: ").append(option.getMinValue().longValue()).append("]");
|
||||
}
|
||||
|
||||
if (option.getMaxValue() != null) {
|
||||
usage.append(" [Max value: ").append(option.getMaxValue().longValue()).append("]");
|
||||
}
|
||||
|
||||
if (option.getMinLength() != null) {
|
||||
usage.append(" [Min length: ").append(option.getMinLength().longValue()).append("]");
|
||||
}
|
||||
|
||||
if (option.getMaxLength() != null) {
|
||||
usage.append(" [Max length: ").append(option.getMaxLength().longValue()).append("]");
|
||||
}
|
||||
|
||||
List<net.dv8tion.jda.api.interactions.commands.Command.Choice> choices = option.getChoices();
|
||||
if (!choices.isEmpty()) {
|
||||
StringJoiner joiner = new StringJoiner(", ");
|
||||
for (net.dv8tion.jda.api.interactions.commands.Command.Choice choice : choices) {
|
||||
joiner.add(choice.getAsString());
|
||||
}
|
||||
|
||||
usage.append(" [Available choices: ").append(joiner).append("]");
|
||||
}
|
||||
|
||||
Set<ChannelType> requiredChannelTypes = option.getChannelTypes();
|
||||
if (!requiredChannelTypes.isEmpty()) {
|
||||
StringJoiner joiner = new StringJoiner(", ");
|
||||
for (ChannelType type : requiredChannelTypes) {
|
||||
joiner.add(type.name());
|
||||
}
|
||||
|
||||
usage.append(" [Required channel types: ").append(joiner).append("]");
|
||||
}
|
||||
}
|
||||
|
||||
return usage.toString();
|
||||
return USAGE_FORMATTER.format(this);
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,7 @@ public class CommandManager implements CommandRegistry {
|
||||
public void init() {
|
||||
logger.info("Loading commands..");
|
||||
|
||||
initCommands();
|
||||
ReflectionHelper.loadCommands(Command.class, context, this::onCommandLoaded);
|
||||
|
||||
logger.info("Loaded {} commands!", commands.size());
|
||||
}
|
||||
@@ -84,30 +84,26 @@ public class CommandManager implements CommandRegistry {
|
||||
return commands;
|
||||
}
|
||||
|
||||
public void initCommands() {
|
||||
Consumer<Command> onLoaded = command -> {
|
||||
if (!command.isEnabled()) {
|
||||
logger.info("Skipping command {} as it is disabled", command.getClass().getName());
|
||||
return;
|
||||
}
|
||||
void onCommandLoaded(Command command) {
|
||||
if (!command.isEnabled()) {
|
||||
logger.info("Skipping command {} as it is disabled", command.getClass().getName());
|
||||
return;
|
||||
}
|
||||
|
||||
if (command.getCommandName() == null) {
|
||||
logger.error("Failed to load command {}: Command name is null", command.getClass().getName());
|
||||
return;
|
||||
}
|
||||
if (command.getCommandName() == null) {
|
||||
logger.error("Failed to load command {}: Command name is null", command.getClass().getName());
|
||||
return;
|
||||
}
|
||||
|
||||
if (command.getDescription() == null && command.getType() == Command.Type.SLASH) {
|
||||
logger.error("Failed to load command {}: Command description is null", command.getClass().getName());
|
||||
return;
|
||||
}
|
||||
if (command.getDescription() == null && command.getType() == Command.Type.CHAT) {
|
||||
logger.error("Failed to load command {}: Command description is null", command.getClass().getName());
|
||||
return;
|
||||
}
|
||||
|
||||
CommandContainer container = new CommandContainer(command);
|
||||
if (!commands.containsKey(command.getCommandName())) {
|
||||
commands.put(command.getCommandName(), container);
|
||||
}
|
||||
};
|
||||
|
||||
ReflectionHelper.loadCommands(Command.class, context, onLoaded);
|
||||
CommandContainer container = new CommandContainer(command);
|
||||
if (!commands.containsKey(command.getCommandName())) {
|
||||
commands.put(command.getCommandName(), container);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -31,7 +31,7 @@ public class CommandOptionsBuilder implements SlashCommandData {
|
||||
|
||||
public CommandOptionsBuilder(Command.Type type, Command command) {
|
||||
|
||||
if (type == Command.Type.SLASH) {
|
||||
if (type == Command.Type.CHAT) {
|
||||
commandData = (CommandDataImpl) Commands.slash(command.getCommandName(), command.getDescription());
|
||||
}
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ public class CommandProcessor {
|
||||
logger.info("Executing command {} on guild {}", command.getCommandName(), context.getGuild() == null ? "PrivateMessage" : context.getGuild().getId());
|
||||
configureSentry(command);
|
||||
|
||||
if (command.getType() == Command.Type.SLASH && !usageValidator.validate(context, container)) {
|
||||
if (command.getType() == Command.Type.CHAT && !usageValidator.validate(context, container)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package me.bobert.netneutrality.core.command;
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
*
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
import net.dv8tion.jda.api.entities.channel.ChannelType;
|
||||
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
|
||||
import net.dv8tion.jda.api.interactions.commands.build.SubcommandData;
|
||||
import net.dv8tion.jda.api.interactions.commands.build.SubcommandGroupData;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.StringJoiner;
|
||||
|
||||
final class CommandUsageFormatter {
|
||||
private static final String REQUIRED_LEGEND = "\n\n-# *: required argument";
|
||||
|
||||
String format(CommandContainer container) {
|
||||
if (container.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
String commandName = container.getCommand().getCommandName();
|
||||
StringBuilder usage = new StringBuilder();
|
||||
appendOptions(usage, commandName, container.getOptions());
|
||||
appendSubcommands(usage, commandName, container.getSubcommands());
|
||||
appendGroups(usage, commandName, container.getSubcommandGroups());
|
||||
usage.append(REQUIRED_LEGEND);
|
||||
|
||||
return usage.toString();
|
||||
}
|
||||
|
||||
private void appendOptions(StringBuilder usage, String commandName, List<OptionData> options) {
|
||||
if (options.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
usage.append("- Usage: /").append(commandName);
|
||||
appendArgs(usage, options);
|
||||
usage.append(describeOptions(options, null, false));
|
||||
}
|
||||
|
||||
private void appendSubcommands(StringBuilder usage, String commandName, List<SubcommandData> subcommands) {
|
||||
for (SubcommandData subcommand : subcommands) {
|
||||
usage.append("- Usage: /").append(commandName).append(" ").append(subcommand.getName());
|
||||
appendArgs(usage, subcommand.getOptions());
|
||||
usage.append(" (").append(subcommand.getDescription()).append(")");
|
||||
usage.append(describeOptions(subcommand.getOptions(), null, false));
|
||||
usage.append("\n");
|
||||
}
|
||||
}
|
||||
|
||||
private void appendGroups(StringBuilder usage, String commandName, List<SubcommandGroupData> groups) {
|
||||
for (SubcommandGroupData group : groups) {
|
||||
usage.append("- Subcommand: /").append(commandName)
|
||||
.append(" ").append(group.getName())
|
||||
.append(" (").append(group.getDescription()).append(")")
|
||||
.append("\n");
|
||||
|
||||
for (SubcommandData subcommand : group.getSubcommands()) {
|
||||
usage.append(" - Usage: /").append(commandName)
|
||||
.append(" ").append(group.getName())
|
||||
.append(" ").append(subcommand.getName());
|
||||
appendArgs(usage, subcommand.getOptions());
|
||||
usage.append(describeOptions(subcommand.getOptions(), subcommand.getDescription(), true));
|
||||
usage.append("\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void appendArgs(StringBuilder usage, List<OptionData> options) {
|
||||
for (OptionData option : options) {
|
||||
usage.append(" <").append(option.getName()).append(">");
|
||||
}
|
||||
}
|
||||
|
||||
private String describeOptions(List<OptionData> options, String description, boolean group) {
|
||||
StringBuilder usage = new StringBuilder();
|
||||
|
||||
if (description != null) {
|
||||
usage.append(" (").append(description).append(")");
|
||||
}
|
||||
|
||||
for (OptionData option : options) {
|
||||
usage.append("\n");
|
||||
if (group) {
|
||||
usage.append(" ");
|
||||
}
|
||||
|
||||
usage.append(" - ");
|
||||
if (option.isRequired()) {
|
||||
usage.append("*");
|
||||
}
|
||||
usage.append(option.getName()).append(": ").append(option.getDescription());
|
||||
|
||||
appendConstraints(usage, option);
|
||||
appendChoices(usage, option);
|
||||
appendChannelTypes(usage, option);
|
||||
}
|
||||
|
||||
return usage.toString();
|
||||
}
|
||||
|
||||
private void appendConstraints(StringBuilder usage, OptionData option) {
|
||||
if (option.getMinValue() != null) {
|
||||
usage.append(" [Min value: ").append(option.getMinValue().longValue()).append("]");
|
||||
}
|
||||
|
||||
if (option.getMaxValue() != null) {
|
||||
usage.append(" [Max value: ").append(option.getMaxValue().longValue()).append("]");
|
||||
}
|
||||
|
||||
if (option.getMinLength() != null) {
|
||||
usage.append(" [Min length: ").append(option.getMinLength().longValue()).append("]");
|
||||
}
|
||||
|
||||
if (option.getMaxLength() != null) {
|
||||
usage.append(" [Max length: ").append(option.getMaxLength().longValue()).append("]");
|
||||
}
|
||||
}
|
||||
|
||||
private void appendChoices(StringBuilder usage, OptionData option) {
|
||||
List<net.dv8tion.jda.api.interactions.commands.Command.Choice> choices = option.getChoices();
|
||||
if (choices.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
StringJoiner joiner = new StringJoiner(", ");
|
||||
for (net.dv8tion.jda.api.interactions.commands.Command.Choice choice : choices) {
|
||||
joiner.add(choice.getAsString());
|
||||
}
|
||||
|
||||
usage.append(" [Available choices: ").append(joiner).append("]");
|
||||
}
|
||||
|
||||
private void appendChannelTypes(StringBuilder usage, OptionData option) {
|
||||
Set<ChannelType> types = option.getChannelTypes();
|
||||
if (types.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
StringJoiner joiner = new StringJoiner(", ");
|
||||
for (ChannelType type : types) {
|
||||
joiner.add(type.name());
|
||||
}
|
||||
|
||||
usage.append(" [Required channel types: ").append(joiner).append("]");
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ public abstract class AbstractCommand implements Command {
|
||||
|
||||
@Override
|
||||
public Type getType() {
|
||||
return Type.SLASH;
|
||||
return Type.CHAT;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -9,10 +9,9 @@ import me.bobert.netneutrality.core.command.CommandCategory;
|
||||
import me.bobert.netneutrality.db.service.GuildServiceProvider;
|
||||
import me.bobert.netneutrality.db.GSettings;
|
||||
import me.bobert.netneutrality.db.service.GuildService;
|
||||
import me.bobert.netneutrality.utils.UserFunction;
|
||||
import me.bobert.netneutrality.utils.MusicAccess;
|
||||
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.channel.ChannelType;
|
||||
import net.dv8tion.jda.api.entities.channel.middleman.GuildMessageChannel;
|
||||
import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel;
|
||||
@@ -77,26 +76,7 @@ public abstract class MusicCommand extends AbstractCommand {
|
||||
return true;
|
||||
}
|
||||
|
||||
Guild guild = member.getGuild();
|
||||
|
||||
String roleDb = guildService(guild.getId()).getGuildSettingService().getOrDefault(GSettings.MUSIC_ROLE);
|
||||
|
||||
if (roleDb != null && !roleDb.isEmpty()) {
|
||||
if (UserFunction.isAdmin(member)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
Role neededRole = guild.getRoleById(roleDb);
|
||||
if (neededRole == null) {
|
||||
// Role doesn't exist on guild anymore, remove from db
|
||||
guildService(guild.getId()).getGuildSettingService().set(GSettings.MUSIC_ROLE, null);
|
||||
return true;
|
||||
}
|
||||
|
||||
return member.getRoles().contains(neededRole);
|
||||
}
|
||||
|
||||
return true;
|
||||
return MusicAccess.canUse(member, guildService(member.getGuild()));
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ public class PermissionPrecondition implements CommandPrecondition {
|
||||
|
||||
@Override
|
||||
public boolean check(CommandContext context, Command command) {
|
||||
if (command.getType() != Command.Type.SLASH) {
|
||||
if (command.getType() != Command.Type.CHAT) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package me.bobert.netneutrality.events;
|
||||
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
*
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
/**
|
||||
* Sink for {@link MusicEvent}s. Implementations must never block the calling thread.
|
||||
*/
|
||||
public interface EventPublisher {
|
||||
|
||||
void publish(MusicEvent event);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package me.bobert.netneutrality.events;
|
||||
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
*
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
/**
|
||||
* A music-state change pushed to the frontend webhook.
|
||||
*
|
||||
* @param type the kind of change
|
||||
* @param guildId the guild the change belongs to
|
||||
* @param timestamp epoch millis when the event was created
|
||||
* @param seq per-guild, monotonically increasing sequence number (assigned by the dispatcher)
|
||||
* @param payload event specific data, serialised as JSON
|
||||
*/
|
||||
public record MusicEvent(Type type, String guildId, long timestamp, long seq, Object payload) {
|
||||
|
||||
public enum Type {
|
||||
TRACK_START,
|
||||
TRACK_END,
|
||||
QUEUE_UPDATED,
|
||||
PLAYER_PAUSED,
|
||||
PLAYER_RESUMED,
|
||||
VOLUME_CHANGED,
|
||||
PLAYER_SEEKED,
|
||||
VOTE_SKIP_UPDATED,
|
||||
VOICE_CONNECTED,
|
||||
VOICE_DISCONNECTED,
|
||||
QUEUE_SHUFFLED,
|
||||
TRACK_ERROR,
|
||||
TRACK_STUCK,
|
||||
PLAYER_POSITION
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an unsequenced event stamped with the current time.
|
||||
*/
|
||||
public static MusicEvent of(Type type, String guildId, Object payload) {
|
||||
return new MusicEvent(type, guildId, System.currentTimeMillis(), 0L, payload);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package me.bobert.netneutrality.events;
|
||||
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
*
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
/**
|
||||
* Publisher that discards every event; used when webhooks are disabled.
|
||||
*/
|
||||
public final class NoopEventPublisher implements EventPublisher {
|
||||
|
||||
public static final NoopEventPublisher INSTANCE = new NoopEventPublisher();
|
||||
|
||||
@Override
|
||||
public void publish(MusicEvent event) {
|
||||
// intentionally empty
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package me.bobert.netneutrality.events;
|
||||
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
*
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import me.bobert.netneutrality.bootstrap.Lifecycle;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.LinkedBlockingDeque;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* Delivers {@link MusicEvent}s to a single static webhook URL. Events are queued in a bounded
|
||||
* buffer (oldest dropped on overflow) and sent in order by one worker thread, signed with
|
||||
* HMAC-SHA256 in the {@code X-Signature} header. Failed deliveries are retried with exponential
|
||||
* backoff and then dropped.
|
||||
*/
|
||||
public class WebhookDispatcher implements EventPublisher, Lifecycle {
|
||||
|
||||
private static final Logger logger = LogManager.getLogger(WebhookDispatcher.class);
|
||||
private static final MediaType JSON = MediaType.get("application/json; charset=utf-8");
|
||||
private static final String HMAC = "HmacSHA256";
|
||||
|
||||
private final String url;
|
||||
private final String secret;
|
||||
private final OkHttpClient client;
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
private final int maxAttempts;
|
||||
private final long baseBackoffMillis;
|
||||
private final LinkedBlockingDeque<MusicEvent> queue;
|
||||
private final Map<String, AtomicLong> sequences = new ConcurrentHashMap<>();
|
||||
private volatile boolean running;
|
||||
private Thread worker;
|
||||
|
||||
public WebhookDispatcher(String url, String secret) {
|
||||
this(url, secret, new OkHttpClient.Builder()
|
||||
.callTimeout(Duration.ofSeconds(10))
|
||||
.build(), 1024, 3, 500);
|
||||
}
|
||||
|
||||
public WebhookDispatcher(String url, String secret, OkHttpClient client, int capacity,
|
||||
int retries, long baseBackoffMillis) {
|
||||
this.url = url;
|
||||
this.secret = secret == null ? "" : secret;
|
||||
this.client = client;
|
||||
this.queue = new LinkedBlockingDeque<>(capacity);
|
||||
this.maxAttempts = retries + 1;
|
||||
this.baseBackoffMillis = baseBackoffMillis;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int priority() {
|
||||
return 50;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
running = true;
|
||||
worker = new Thread(this::run, "webhook-dispatcher");
|
||||
worker.setDaemon(true);
|
||||
worker.start();
|
||||
logger.info("Webhook dispatcher enabled, posting to {}", url);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
running = false;
|
||||
if (worker != null) {
|
||||
worker.interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void publish(MusicEvent event) {
|
||||
if (event == null) {
|
||||
return;
|
||||
}
|
||||
long seq = sequences.computeIfAbsent(event.guildId(), _ -> new AtomicLong()).incrementAndGet();
|
||||
MusicEvent sequenced = new MusicEvent(event.type(), event.guildId(), event.timestamp(), seq, event.payload());
|
||||
while (!queue.offerLast(sequenced)) {
|
||||
MusicEvent dropped = queue.pollFirst();
|
||||
if (dropped != null) {
|
||||
logger.warn("Webhook queue full, dropped {} for guild {} (seq {})",
|
||||
dropped.type(), dropped.guildId(), dropped.seq());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void run() {
|
||||
while (running) {
|
||||
try {
|
||||
MusicEvent event = queue.pollFirst(1, TimeUnit.SECONDS);
|
||||
if (event != null) {
|
||||
deliver(event);
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return;
|
||||
} catch (Exception e) {
|
||||
logger.error("Unexpected webhook dispatch error", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void deliver(MusicEvent event) throws InterruptedException {
|
||||
byte[] body;
|
||||
try {
|
||||
body = mapper.writeValueAsBytes(event);
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to serialise {} event, dropping", event.type(), e);
|
||||
return;
|
||||
}
|
||||
|
||||
Request request = new Request.Builder()
|
||||
.url(url)
|
||||
.header("X-Signature", "sha256=" + sign(body))
|
||||
.post(RequestBody.create(body, JSON))
|
||||
.build();
|
||||
|
||||
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try (Response response = client.newCall(request).execute()) {
|
||||
if (response.isSuccessful()) {
|
||||
return;
|
||||
}
|
||||
logger.warn("Webhook returned {} for {} (attempt {}/{})",
|
||||
response.code(), event.type(), attempt, maxAttempts);
|
||||
} catch (Exception e) {
|
||||
logger.warn("Webhook delivery failed for {} (attempt {}/{}): {}",
|
||||
event.type(), attempt, maxAttempts, e.getMessage());
|
||||
}
|
||||
if (attempt < maxAttempts) {
|
||||
Thread.sleep(baseBackoffMillis << (attempt - 1));
|
||||
}
|
||||
}
|
||||
logger.error("Dropping {} event for guild {} (seq {}) after {} attempts",
|
||||
event.type(), event.guildId(), event.seq(), maxAttempts);
|
||||
}
|
||||
|
||||
String sign(byte[] body) {
|
||||
try {
|
||||
Mac mac = Mac.getInstance(HMAC);
|
||||
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC));
|
||||
return HexFormat.of().formatHex(mac.doFinal(body));
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Unable to sign webhook payload", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package me.bobert.netneutrality.hooks;
|
||||
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
*
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
import me.bobert.netneutrality.core.context.Context;
|
||||
import net.dv8tion.jda.api.events.Event;
|
||||
|
||||
/**
|
||||
* A handler that turns an event into a {@link Context}, which the dispatcher routes to the
|
||||
* command or task pipeline. May return {@code null} to ignore the event.
|
||||
*/
|
||||
public interface ContextEventHandler<T extends Event> extends EventListenerHandler<T> {
|
||||
Context toContext(T event);
|
||||
|
||||
@Override
|
||||
default void handle(T event) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package me.bobert.netneutrality.hooks;
|
||||
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
*
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
import net.dv8tion.jda.api.events.Event;
|
||||
|
||||
/**
|
||||
* A side-effect handler for one JDA event type. Several handlers may listen to the same event.
|
||||
*/
|
||||
public interface EventListenerHandler<T extends Event> {
|
||||
Class<T> eventType();
|
||||
|
||||
void handle(T event);
|
||||
}
|
||||
@@ -6,159 +6,90 @@ package me.bobert.netneutrality.hooks;
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
import me.bobert.netneutrality.context.command.MessageCommandContext;
|
||||
import me.bobert.netneutrality.context.command.MessageMenuContext;
|
||||
import me.bobert.netneutrality.context.command.SlashCommandContext;
|
||||
import me.bobert.netneutrality.context.command.UserMenuContext;
|
||||
import me.bobert.netneutrality.context.tasks.ButtonTaskContext;
|
||||
import me.bobert.netneutrality.context.tasks.EmojiTaskContext;
|
||||
import me.bobert.netneutrality.context.tasks.MessageTaskContext;
|
||||
import me.bobert.netneutrality.context.tasks.ModalTaskContext;
|
||||
import me.bobert.netneutrality.core.LogHandler;
|
||||
import me.bobert.netneutrality.core.command.CommandProcessor;
|
||||
import me.bobert.netneutrality.core.command.CommandRegistry;
|
||||
import me.bobert.netneutrality.core.context.CommandContext;
|
||||
import me.bobert.netneutrality.core.context.Context;
|
||||
import me.bobert.netneutrality.core.context.TaskContext;
|
||||
import me.bobert.netneutrality.db.service.GuildServiceProvider;
|
||||
import me.bobert.netneutrality.db.service.GuildService;
|
||||
import me.bobert.netneutrality.core.command.CommandRegistry;
|
||||
import me.bobert.netneutrality.core.tasks.TaskManager;
|
||||
import me.bobert.netneutrality.db.GSettings;
|
||||
import me.bobert.netneutrality.utils.SettingsProvider;
|
||||
import net.dv8tion.jda.api.entities.Message;
|
||||
import net.dv8tion.jda.api.events.Event;
|
||||
import net.dv8tion.jda.api.events.GenericEvent;
|
||||
import net.dv8tion.jda.api.events.guild.GuildJoinEvent;
|
||||
import net.dv8tion.jda.api.events.guild.GuildLeaveEvent;
|
||||
import net.dv8tion.jda.api.events.guild.update.GuildUpdateIconEvent;
|
||||
import net.dv8tion.jda.api.events.guild.update.GuildUpdateNameEvent;
|
||||
import net.dv8tion.jda.api.events.interaction.ModalInteractionEvent;
|
||||
import net.dv8tion.jda.api.events.interaction.command.MessageContextInteractionEvent;
|
||||
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
|
||||
import net.dv8tion.jda.api.events.interaction.command.UserContextInteractionEvent;
|
||||
import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent;
|
||||
import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
|
||||
import net.dv8tion.jda.api.events.message.react.MessageReactionAddEvent;
|
||||
import net.dv8tion.jda.api.hooks.EventListener;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Deque;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* Thin dispatcher: calls every registered {@link EventListenerHandler} for an event (walking its
|
||||
* class hierarchy and interfaces), isolates failures per handler and routes any produced
|
||||
* {@link Context} to the command or task pipeline.
|
||||
*/
|
||||
public class EventsHandler implements EventListener {
|
||||
private static final Logger logger = LoggerFactory.getLogger(EventsHandler.class);
|
||||
|
||||
private final Map<Class<? extends Event>, Function<? extends Event, Context>> listeners = new ConcurrentHashMap<>();
|
||||
private final SettingsProvider settings;
|
||||
private final GuildServiceProvider guildServices;
|
||||
private final Map<Class<?>, List<EventListenerHandler<?>>> handlers = new ConcurrentHashMap<>();
|
||||
private final CommandRegistry commands;
|
||||
private final TaskManager taskManager;
|
||||
|
||||
public EventsHandler(
|
||||
SettingsProvider settings,
|
||||
GuildServiceProvider guildServices,
|
||||
List<EventListenerHandler<?>> handlers,
|
||||
CommandRegistry commands,
|
||||
TaskManager taskManager
|
||||
) {
|
||||
this.settings = Objects.requireNonNull(settings, "settings");
|
||||
this.guildServices = Objects.requireNonNull(guildServices, "guildServices");
|
||||
this.commands = Objects.requireNonNull(commands, "commands");
|
||||
this.taskManager = Objects.requireNonNull(taskManager, "taskManager");
|
||||
registerListeners();
|
||||
Objects.requireNonNull(handlers, "handlers").forEach(this::register);
|
||||
}
|
||||
|
||||
private void registerListeners() {
|
||||
addListener(MessageReceivedEvent.class, event -> {
|
||||
if (event.getAuthor().isBot()) {
|
||||
return null;
|
||||
}
|
||||
if (event.isWebhookMessage()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Message message = event.getMessage();
|
||||
String rawMessage = message.getContentDisplay().trim();
|
||||
String prefix = settings.get("NETNEUTRALITY_BOT_PREFIX", "$");
|
||||
if (event.isFromGuild()) {
|
||||
String guildPrefix = guildServices.get(event.getGuild().getId()).getGuildSettingService().getOrDefault(GSettings.COMMAND_PREFIX);
|
||||
if (!guildPrefix.isEmpty()) {
|
||||
prefix = guildPrefix;
|
||||
}
|
||||
}
|
||||
if (rawMessage.startsWith(prefix)) {
|
||||
return new MessageCommandContext(event, settings);
|
||||
}
|
||||
|
||||
return new MessageTaskContext(event);
|
||||
});
|
||||
|
||||
addListener(UserContextInteractionEvent.class, event -> new UserMenuContext(event, settings));
|
||||
addListener(MessageContextInteractionEvent.class, event -> new MessageMenuContext(event, settings));
|
||||
addListener(SlashCommandInteractionEvent.class, event -> new SlashCommandContext(event, settings));
|
||||
addListener(ModalInteractionEvent.class, ModalTaskContext::new);
|
||||
addListener(ButtonInteractionEvent.class, ButtonTaskContext::new);
|
||||
addListener(MessageReactionAddEvent.class, EmojiTaskContext::new);
|
||||
addGenericListener(GuildJoinEvent.class, event -> {
|
||||
GuildService service = guildServices.get(event.getGuild().getId());
|
||||
service.updateMetadata(event.getGuild().getName(), event.getGuild().getIconId());
|
||||
|
||||
if (service.isDebugEnabled()) {
|
||||
LogHandler.sendMessageToDebugChannel(event.getGuild(), "Error logging enabled on this channel");
|
||||
}
|
||||
});
|
||||
addGenericListener(GuildLeaveEvent.class, event -> onGuildLeave(event.getGuild().getId()));
|
||||
addGenericListener(GuildUpdateNameEvent.class, event ->
|
||||
guildServices.get(event.getGuild().getId()).updateMetadata(event.getNewName(), event.getGuild().getIconId()));
|
||||
addGenericListener(GuildUpdateIconEvent.class, event ->
|
||||
guildServices.get(event.getGuild().getId()).updateMetadata(event.getGuild().getName(), event.getNewIconId()));
|
||||
public void register(EventListenerHandler<?> handler) {
|
||||
Class<?> type = Objects.requireNonNull(handler, "handler").eventType();
|
||||
logger.info("Registering handler {} for {}", handler.getClass().getSimpleName(), type.getSimpleName());
|
||||
handlers.computeIfAbsent(type, k -> new CopyOnWriteArrayList<>()).add(handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the guild's data and drops its cached service. The service is evicted even when the
|
||||
* delete fails, so a stale cache entry never outlives the guild membership.
|
||||
*
|
||||
* @param guildId the id of the guild the bot left
|
||||
* Register a listener for an event associated with a {@link Context}.
|
||||
*/
|
||||
void onGuildLeave(String guildId) {
|
||||
try {
|
||||
guildServices.get(guildId).deleteGuild();
|
||||
} catch (RuntimeException e) {
|
||||
logger.error("Failed to delete data of guild {}", guildId, e);
|
||||
} finally {
|
||||
guildServices.evict(guildId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a listener for an event assocated with a {@link Context}.
|
||||
*
|
||||
* @param eventClass the class type of the event to listen for
|
||||
* @param function the function to handle the event, which takes an event of the specified type and returns a {@link Context}
|
||||
* @param <T> the type of the event that extends the {@link Event} class
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public <T extends Event> void addListener(Class<T> eventClass, Function<? super T, Context> function) {
|
||||
logger.info("Registering listener for {}", eventClass.getSimpleName());
|
||||
listeners.put(eventClass, (Function<? extends Event, Context>) function);
|
||||
register(new ContextEventHandler<T>() {
|
||||
@Override
|
||||
public Class<T> eventType() {
|
||||
return eventClass;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Context toContext(T event) {
|
||||
return function.apply(event);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a generic listener for a specified event type. The consumer will be invoked
|
||||
* whenever an event of the specified type is detected.
|
||||
*
|
||||
* @param eventClass the class type of the event to listen for
|
||||
* @param consumer the consumer to handle the event, which takes an event of the specified type and performs an action
|
||||
* @param <T> the type of the event that extends the {@link Event} class
|
||||
* Adds a side-effect listener for a specified event type.
|
||||
*/
|
||||
public <T extends Event> void addGenericListener(Class<T> eventClass, Consumer<T> consumer) {
|
||||
addListener(eventClass, t -> {
|
||||
consumer.accept(t);
|
||||
return null;
|
||||
register(new EventListenerHandler<T>() {
|
||||
@Override
|
||||
public Class<T> eventType() {
|
||||
return eventClass;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handle(T event) {
|
||||
consumer.accept(event);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -169,34 +100,48 @@ public class EventsHandler implements EventListener {
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void handleEvent(Event event) {
|
||||
Class<?> eventClass = event.getClass();
|
||||
|
||||
while (eventClass != null) {
|
||||
if (listeners.containsKey(eventClass)) {
|
||||
Function<Event, Context> function = (Function<Event, Context>) listeners.get(eventClass);
|
||||
Context context = function.apply(event);
|
||||
|
||||
if (context instanceof CommandContext commandContext) {
|
||||
handleCommand(commandContext);
|
||||
}
|
||||
|
||||
if (context instanceof TaskContext taskContext) {
|
||||
handleTask(taskContext);
|
||||
}
|
||||
|
||||
break;
|
||||
for (Class<?> type : hierarchy(event.getClass())) {
|
||||
List<EventListenerHandler<?>> matching = handlers.get(type);
|
||||
if (matching == null) {
|
||||
continue;
|
||||
}
|
||||
for (EventListenerHandler<?> raw : matching) {
|
||||
try {
|
||||
EventListenerHandler<Event> handler = (EventListenerHandler<Event>) raw;
|
||||
handler.handle(event);
|
||||
if (handler instanceof ContextEventHandler<Event> contextHandler) {
|
||||
route(contextHandler.toContext(event));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("Handler {} failed for {}", raw.getClass().getName(), event.getClass().getSimpleName(), e);
|
||||
}
|
||||
}
|
||||
|
||||
eventClass = eventClass.getSuperclass();
|
||||
}
|
||||
}
|
||||
|
||||
private void handleCommand(CommandContext context) {
|
||||
CommandProcessor processor = new CommandProcessor(context, commands, taskManager);
|
||||
processor.dispatch();
|
||||
private void route(Context context) {
|
||||
if (context instanceof CommandContext commandContext) {
|
||||
new CommandProcessor(commandContext, commands, taskManager).dispatch();
|
||||
}
|
||||
if (context instanceof TaskContext taskContext) {
|
||||
taskManager.handle(taskContext);
|
||||
}
|
||||
}
|
||||
|
||||
private void handleTask(TaskContext context) {
|
||||
taskManager.handle(context);
|
||||
private static Set<Class<?>> hierarchy(Class<?> start) {
|
||||
Set<Class<?>> result = new LinkedHashSet<>();
|
||||
Deque<Class<?>> queue = new ArrayDeque<>();
|
||||
queue.add(start);
|
||||
while (!queue.isEmpty()) {
|
||||
Class<?> type = queue.poll();
|
||||
if (!result.add(type)) {
|
||||
continue;
|
||||
}
|
||||
if (type.getSuperclass() != null) {
|
||||
queue.add(type.getSuperclass());
|
||||
}
|
||||
queue.addAll(List.of(type.getInterfaces()));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package me.bobert.netneutrality.hooks;
|
||||
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
*
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
import me.bobert.netneutrality.core.LogHandler;
|
||||
import me.bobert.netneutrality.db.service.GuildService;
|
||||
import me.bobert.netneutrality.db.service.GuildServiceProvider;
|
||||
import net.dv8tion.jda.api.events.guild.GuildJoinEvent;
|
||||
import net.dv8tion.jda.api.events.guild.GuildLeaveEvent;
|
||||
import net.dv8tion.jda.api.events.guild.update.GuildUpdateIconEvent;
|
||||
import net.dv8tion.jda.api.events.guild.update.GuildUpdateNameEvent;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Guild join, leave and name/icon update handling.
|
||||
*/
|
||||
public class GuildLifecycleHandler {
|
||||
private static final Logger logger = LoggerFactory.getLogger(GuildLifecycleHandler.class);
|
||||
|
||||
private final GuildServiceProvider guildServices;
|
||||
|
||||
public GuildLifecycleHandler(GuildServiceProvider guildServices) {
|
||||
this.guildServices = Objects.requireNonNull(guildServices, "guildServices");
|
||||
}
|
||||
|
||||
public List<EventListenerHandler<?>> handlers() {
|
||||
return List.of(
|
||||
new Join(), new Leave(), new NameUpdate(), new IconUpdate());
|
||||
}
|
||||
|
||||
void onGuildJoin(GuildJoinEvent event) {
|
||||
GuildService service = guildServices.get(event.getGuild().getId());
|
||||
service.updateMetadata(event.getGuild().getName(), event.getGuild().getIconId());
|
||||
|
||||
if (service.isDebugEnabled()) {
|
||||
LogHandler.sendMessageToDebugChannel(event.getGuild(), "Error logging enabled on this channel");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the guild's data and drops its cached service. The service is evicted even when the
|
||||
* delete fails, so a stale cache entry never outlives the guild membership.
|
||||
*
|
||||
* @param guildId the id of the guild the bot left
|
||||
*/
|
||||
void onGuildLeave(String guildId) {
|
||||
try {
|
||||
guildServices.get(guildId).deleteGuild();
|
||||
} catch (RuntimeException e) {
|
||||
logger.error("Failed to delete data of guild {}", guildId, e);
|
||||
} finally {
|
||||
guildServices.evict(guildId);
|
||||
}
|
||||
}
|
||||
|
||||
private class Join implements EventListenerHandler<GuildJoinEvent> {
|
||||
@Override
|
||||
public Class<GuildJoinEvent> eventType() {
|
||||
return GuildJoinEvent.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handle(GuildJoinEvent event) {
|
||||
onGuildJoin(event);
|
||||
}
|
||||
}
|
||||
|
||||
private class Leave implements EventListenerHandler<GuildLeaveEvent> {
|
||||
@Override
|
||||
public Class<GuildLeaveEvent> eventType() {
|
||||
return GuildLeaveEvent.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handle(GuildLeaveEvent event) {
|
||||
onGuildLeave(event.getGuild().getId());
|
||||
}
|
||||
}
|
||||
|
||||
private class NameUpdate implements EventListenerHandler<GuildUpdateNameEvent> {
|
||||
@Override
|
||||
public Class<GuildUpdateNameEvent> eventType() {
|
||||
return GuildUpdateNameEvent.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handle(GuildUpdateNameEvent event) {
|
||||
guildServices.get(event.getGuild().getId())
|
||||
.updateMetadata(event.getNewName(), event.getGuild().getIconId());
|
||||
}
|
||||
}
|
||||
|
||||
private class IconUpdate implements EventListenerHandler<GuildUpdateIconEvent> {
|
||||
@Override
|
||||
public Class<GuildUpdateIconEvent> eventType() {
|
||||
return GuildUpdateIconEvent.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handle(GuildUpdateIconEvent event) {
|
||||
guildServices.get(event.getGuild().getId())
|
||||
.updateMetadata(event.getGuild().getName(), event.getNewIconId());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package me.bobert.netneutrality.hooks;
|
||||
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
*
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
import me.bobert.netneutrality.context.command.MessageMenuContext;
|
||||
import me.bobert.netneutrality.context.command.SlashCommandContext;
|
||||
import me.bobert.netneutrality.context.command.UserMenuContext;
|
||||
import me.bobert.netneutrality.context.tasks.ButtonTaskContext;
|
||||
import me.bobert.netneutrality.context.tasks.EmojiTaskContext;
|
||||
import me.bobert.netneutrality.context.tasks.ModalTaskContext;
|
||||
import me.bobert.netneutrality.core.context.Context;
|
||||
import me.bobert.netneutrality.utils.SettingsProvider;
|
||||
import net.dv8tion.jda.api.events.Event;
|
||||
import net.dv8tion.jda.api.events.interaction.ModalInteractionEvent;
|
||||
import net.dv8tion.jda.api.events.interaction.command.MessageContextInteractionEvent;
|
||||
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
|
||||
import net.dv8tion.jda.api.events.interaction.command.UserContextInteractionEvent;
|
||||
import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent;
|
||||
import net.dv8tion.jda.api.events.message.react.MessageReactionAddEvent;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* Slash, menu, modal, button and reaction events, each mapped to its context.
|
||||
*/
|
||||
public final class InteractionHandlers {
|
||||
private InteractionHandlers() {
|
||||
}
|
||||
|
||||
public static List<EventListenerHandler<?>> create(SettingsProvider settings) {
|
||||
Objects.requireNonNull(settings, "settings");
|
||||
return List.of(
|
||||
of(UserContextInteractionEvent.class, e -> new UserMenuContext(e, settings)),
|
||||
of(MessageContextInteractionEvent.class, e -> new MessageMenuContext(e, settings)),
|
||||
of(SlashCommandInteractionEvent.class, e -> new SlashCommandContext(e, settings)),
|
||||
of(ModalInteractionEvent.class, ModalTaskContext::new),
|
||||
of(ButtonInteractionEvent.class, ButtonTaskContext::new),
|
||||
of(MessageReactionAddEvent.class, EmojiTaskContext::new));
|
||||
}
|
||||
|
||||
private static <T extends Event> ContextEventHandler<T> of(Class<T> type, Function<T, Context> mapper) {
|
||||
return new ContextEventHandler<>() {
|
||||
@Override
|
||||
public Class<T> eventType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Context toContext(T event) {
|
||||
return mapper.apply(event);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package me.bobert.netneutrality.hooks;
|
||||
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
*
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
import me.bobert.netneutrality.context.command.MessageCommandContext;
|
||||
import me.bobert.netneutrality.context.tasks.MessageTaskContext;
|
||||
import me.bobert.netneutrality.core.context.Context;
|
||||
import me.bobert.netneutrality.db.GSettings;
|
||||
import me.bobert.netneutrality.db.service.GuildServiceProvider;
|
||||
import me.bobert.netneutrality.utils.SettingsProvider;
|
||||
import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Resolves the prefix and decides whether a message is a command or a plain task message.
|
||||
*/
|
||||
public class MessageReceivedHandler implements ContextEventHandler<MessageReceivedEvent> {
|
||||
private final SettingsProvider settings;
|
||||
private final GuildServiceProvider guildServices;
|
||||
|
||||
public MessageReceivedHandler(SettingsProvider settings, GuildServiceProvider guildServices) {
|
||||
this.settings = Objects.requireNonNull(settings, "settings");
|
||||
this.guildServices = Objects.requireNonNull(guildServices, "guildServices");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<MessageReceivedEvent> eventType() {
|
||||
return MessageReceivedEvent.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Context toContext(MessageReceivedEvent event) {
|
||||
if (event.getAuthor().isBot() || event.isWebhookMessage()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String rawMessage = event.getMessage().getContentDisplay().trim();
|
||||
String prefix = settings.get("NETNEUTRALITY_BOT_PREFIX", "$");
|
||||
if (event.isFromGuild()) {
|
||||
String guildPrefix = guildServices.get(event.getGuild().getId()).getGuildSettingService()
|
||||
.getOrDefault(GSettings.COMMAND_PREFIX);
|
||||
if (!guildPrefix.isEmpty()) {
|
||||
prefix = guildPrefix;
|
||||
}
|
||||
}
|
||||
if (rawMessage.startsWith(prefix)) {
|
||||
return new MessageCommandContext(event, settings);
|
||||
}
|
||||
|
||||
return new MessageTaskContext(event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package me.bobert.netneutrality.hooks;
|
||||
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
*
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
import me.bobert.netneutrality.core.command.CommandRegistry;
|
||||
import net.dv8tion.jda.api.events.session.ReadyEvent;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Registers the global slash commands once the first shard is ready.
|
||||
*/
|
||||
public class ReadyHandler implements EventListenerHandler<ReadyEvent> {
|
||||
private final CommandRegistry commands;
|
||||
|
||||
public ReadyHandler(CommandRegistry commands) {
|
||||
this.commands = Objects.requireNonNull(commands, "commands");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<ReadyEvent> eventType() {
|
||||
return ReadyEvent.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handle(ReadyEvent event) {
|
||||
// Global commands are shared by all shards, so only register them once.
|
||||
if (event.getJDA().getShardInfo().getShardId() == 0) {
|
||||
commands.registerSlashCommands(event.getJDA());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package me.bobert.netneutrality.hooks;
|
||||
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
*
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
import me.bobert.netneutrality.events.EventPublisher;
|
||||
import me.bobert.netneutrality.events.MusicEvent;
|
||||
import net.dv8tion.jda.api.entities.GuildVoiceState;
|
||||
import net.dv8tion.jda.api.entities.channel.unions.AudioChannelUnion;
|
||||
import net.dv8tion.jda.api.events.guild.voice.GuildVoiceUpdateEvent;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Publishes connect/disconnect events for the bot itself and for members entering or leaving
|
||||
* the voice channel the bot is currently in. The payload carries {@code userId}, {@code bot}
|
||||
* (whether the member is a bot) and {@code self} (whether it is this bot).
|
||||
*/
|
||||
public class VoiceEventPublisher implements EventListenerHandler<GuildVoiceUpdateEvent> {
|
||||
private final EventPublisher events;
|
||||
|
||||
public VoiceEventPublisher(EventPublisher events) {
|
||||
this.events = Objects.requireNonNull(events, "events");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<GuildVoiceUpdateEvent> eventType() {
|
||||
return GuildVoiceUpdateEvent.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handle(GuildVoiceUpdateEvent event) {
|
||||
onVoiceUpdate(event);
|
||||
}
|
||||
|
||||
void onVoiceUpdate(GuildVoiceUpdateEvent event) {
|
||||
boolean self = event.getEntity().getId().equals(event.getJDA().getSelfUser().getId());
|
||||
AudioChannelUnion left = event.getChannelLeft();
|
||||
AudioChannelUnion joined = event.getChannelJoined();
|
||||
|
||||
String botChannelId = null;
|
||||
if (!self) {
|
||||
GuildVoiceState botState = event.getGuild().getSelfMember().getVoiceState();
|
||||
if (botState == null || botState.getChannel() == null) {
|
||||
return;
|
||||
}
|
||||
botChannelId = botState.getChannel().getId();
|
||||
}
|
||||
|
||||
String guildId = event.getGuild().getId();
|
||||
if (left != null && (self || left.getId().equals(botChannelId))) {
|
||||
events.publish(MusicEvent.of(MusicEvent.Type.VOICE_DISCONNECTED, guildId,
|
||||
voicePayload(event, left.getId(), self)));
|
||||
}
|
||||
if (joined != null && (self || joined.getId().equals(botChannelId))) {
|
||||
events.publish(MusicEvent.of(MusicEvent.Type.VOICE_CONNECTED, guildId,
|
||||
voicePayload(event, joined.getId(), self)));
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, Object> voicePayload(GuildVoiceUpdateEvent event, String channelId, boolean self) {
|
||||
return Map.of(
|
||||
"channelId", channelId,
|
||||
"userId", event.getEntity().getId(),
|
||||
"username", event.getEntity().getUser().getName(),
|
||||
"nickname", event.getEntity().getEffectiveName(),
|
||||
"bot", event.getEntity().getUser().isBot(),
|
||||
"self", self);
|
||||
}
|
||||
}
|
||||
@@ -14,12 +14,11 @@ import io.javalin.openapi.OpenApi;
|
||||
import io.javalin.openapi.OpenApiContent;
|
||||
import io.javalin.openapi.OpenApiResponse;
|
||||
import me.bobert.netneutrality.http.server.dto.StatusResponse;
|
||||
import net.dv8tion.jda.api.JDA;
|
||||
import me.bobert.netneutrality.shard.ShardProvider;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* Registers the {@code /actions/*} endpoints on a {@link RoutesConfig} instance.
|
||||
@@ -34,10 +33,10 @@ public final class ActionRoutes {
|
||||
|
||||
public static void register(
|
||||
RoutesConfig routes,
|
||||
Supplier<JDA> jdaSupplier,
|
||||
ShardProvider shards,
|
||||
Runnable shutdownHook
|
||||
) {
|
||||
routes.get("/actions/status", ctx -> status(jdaSupplier, ctx));
|
||||
routes.get("/actions/status", ctx -> status(shards, ctx));
|
||||
|
||||
Handler shutdown = ctx -> shutdown(ctx, shutdownHook);
|
||||
routes.post("/actions/shutdown", shutdown);
|
||||
@@ -50,11 +49,12 @@ public final class ActionRoutes {
|
||||
tags = {"Actions"},
|
||||
responses = @OpenApiResponse(status = "200", content = @OpenApiContent(from = StatusResponse.class))
|
||||
)
|
||||
private static void status(Supplier<JDA> jdaSupplier, Context ctx) {
|
||||
JDA jda = GuildRoutes.jda(jdaSupplier);
|
||||
private static void status(ShardProvider shards, Context ctx) {
|
||||
ShardProvider provider = GuildRoutes.shards(shards);
|
||||
ctx.json(Map.of(
|
||||
"health", String.valueOf(jda.getStatus()),
|
||||
"ping", String.valueOf(jda.getGatewayPing())
|
||||
"health", String.valueOf(provider.getWorstStatus()),
|
||||
"ping", String.valueOf((long) provider.getAveragePing()),
|
||||
"shard_count", provider.getShardCount()
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -27,13 +27,15 @@ import me.bobert.netneutrality.http.server.dto.GuildConfigResponse;
|
||||
import me.bobert.netneutrality.http.server.dto.GuildLogChannelUpdateRequest;
|
||||
import me.bobert.netneutrality.http.server.dto.GuildOwnerEntryResponse;
|
||||
import me.bobert.netneutrality.http.server.dto.GuildSummaryResponse;
|
||||
import me.bobert.netneutrality.http.server.dto.MemberAccessResponse;
|
||||
import me.bobert.netneutrality.http.server.dto.MemberRolesResponse;
|
||||
import me.bobert.netneutrality.http.server.dto.MemberSummaryResponse;
|
||||
import me.bobert.netneutrality.http.server.dto.OwnerResponse;
|
||||
import me.bobert.netneutrality.http.server.dto.RoleSummaryResponse;
|
||||
import me.bobert.netneutrality.http.server.dto.VoiceChannelSummaryResponse;
|
||||
import me.bobert.netneutrality.utils.MusicAccess;
|
||||
import me.bobert.netneutrality.utils.UserFunction;
|
||||
import net.dv8tion.jda.api.JDA;
|
||||
import me.bobert.netneutrality.shard.ShardProvider;
|
||||
import net.dv8tion.jda.api.entities.Guild;
|
||||
import net.dv8tion.jda.api.entities.Member;
|
||||
import net.dv8tion.jda.api.entities.Role;
|
||||
@@ -42,7 +44,9 @@ import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* Registers the read-only {@code /guilds/*} endpoints, as well as the debug/logging/log-channel
|
||||
@@ -53,38 +57,92 @@ public final class GuildRoutes {
|
||||
private GuildRoutes() {
|
||||
}
|
||||
|
||||
public static void register(RoutesConfig routes, Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices) {
|
||||
routes.get("/guilds", ctx -> guilds(jdaSupplier, ctx));
|
||||
routes.get("/guilds/owner", ctx -> owner(jdaSupplier, ctx));
|
||||
routes.get("/guilds/owner/{guildId}", ctx -> ownerOf(jdaSupplier, ctx));
|
||||
routes.get("/guilds/channels/{guildId}", ctx -> channels(jdaSupplier, ctx));
|
||||
routes.get("/guilds/roles/{guildId}", ctx -> roles(jdaSupplier, ctx));
|
||||
routes.get("/guilds/members/{guildId}/{userId}", ctx -> member(jdaSupplier, ctx));
|
||||
routes.get("/guilds/members/{guildId}/{userId}/roles", ctx -> memberRoles(jdaSupplier, ctx));
|
||||
routes.get("/guilds/members/{guildId}/{userId}/admin", ctx -> memberAdmin(jdaSupplier, ctx));
|
||||
routes.get("/guilds/voice-channels/{guildId}", ctx -> voiceChannels(jdaSupplier, ctx));
|
||||
routes.get("/guilds/voice-channels/{guildId}/{channelId}", ctx -> voiceChannel(jdaSupplier, ctx));
|
||||
routes.get("/guilds/config/{guildId}", ctx -> config(jdaSupplier, guildServices, ctx));
|
||||
routes.put("/guilds/debug/{guildId}", ctx -> updateDebug(jdaSupplier, guildServices, ctx));
|
||||
routes.put("/guilds/logging/{guildId}", ctx -> updateLogging(jdaSupplier, guildServices, ctx));
|
||||
routes.put("/guilds/log-channel/{guildId}", ctx -> updateLogChannel(jdaSupplier, guildServices, ctx));
|
||||
public static void register(RoutesConfig routes, ShardProvider shards, GuildServiceProvider guildServices) {
|
||||
routes.get("/guilds", ctx -> guilds(shards, guildServices, ctx));
|
||||
routes.get("/guilds/owner", ctx -> owner(shards, ctx));
|
||||
routes.get("/guilds/{guildId}/owner", ctx -> ownerOf(shards, ctx));
|
||||
routes.get("/guilds/{guildId}/channels", ctx -> channels(shards, ctx));
|
||||
routes.get("/guilds/{guildId}/roles", ctx -> roles(shards, ctx));
|
||||
routes.get("/guilds/{guildId}/members/{userId}/access", ctx -> memberAccess(shards, guildServices, ctx));
|
||||
routes.get("/guilds/{guildId}/members/{userId}", ctx -> member(shards, ctx));
|
||||
routes.get("/guilds/{guildId}/members/{userId}/roles", ctx -> memberRoles(shards, ctx));
|
||||
routes.get("/guilds/{guildId}/members/{userId}/admin", ctx -> memberAdmin(shards, ctx));
|
||||
routes.get("/guilds/{guildId}/voice-channels", ctx -> voiceChannels(shards, ctx));
|
||||
routes.get("/guilds/{guildId}/voice-channels/{channelId}", ctx -> voiceChannel(shards, ctx));
|
||||
routes.get("/guilds/{guildId}/config", ctx -> config(shards, guildServices, ctx));
|
||||
routes.put("/guilds/{guildId}/debug", ctx -> updateDebug(shards, guildServices, ctx));
|
||||
routes.put("/guilds/{guildId}/logging", ctx -> updateLogging(shards, guildServices, ctx));
|
||||
routes.put("/guilds/{guildId}/log-channel", ctx -> updateLogChannel(shards, guildServices, ctx));
|
||||
}
|
||||
|
||||
@OpenApi(
|
||||
path = "/guilds",
|
||||
methods = HttpMethod.GET,
|
||||
summary = "List every guild the bot is currently in",
|
||||
summary = "List every guild the bot is in, or only the guilds shared with a user when user_id is given",
|
||||
tags = {"Guilds"},
|
||||
responses = @OpenApiResponse(status = "200", content = @OpenApiContent(from = GuildSummaryResponse[].class))
|
||||
queryParams = @OpenApiParam(name = "user_id", description = "Optional Discord user id; restricts the list to mutual guilds and switches to the user-guild shape"),
|
||||
responses = {
|
||||
@OpenApiResponse(status = "200", content = @OpenApiContent(from = GuildSummaryResponse[].class)),
|
||||
@OpenApiResponse(status = "400", content = @OpenApiContent(from = ErrorResponse.class))
|
||||
}
|
||||
)
|
||||
private static void guilds(Supplier<JDA> jdaSupplier, Context ctx) {
|
||||
private static void guilds(ShardProvider shards, GuildServiceProvider guildServices, Context ctx) {
|
||||
String userId = ctx.queryParam("user_id");
|
||||
if (userId != null) {
|
||||
userGuilds(shards(shards), guildServices, userId, ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.json(
|
||||
jda(jdaSupplier).getGuilds().stream()
|
||||
shards(shards).getGuilds().stream()
|
||||
.map(GuildRoutes::guildSummary)
|
||||
.toList()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists the guilds shared by the bot and the given user, with the user's access flags. Members
|
||||
* missing from the cache are resolved through REST; a failed lookup means "not a member".
|
||||
*/
|
||||
private static void userGuilds(ShardProvider shards, GuildServiceProvider guildServices, String userId, Context ctx) {
|
||||
if (!userId.matches("\\d{1,20}")) {
|
||||
throw new BadRequestResponse("user_id must be a numeric Discord id");
|
||||
}
|
||||
|
||||
List<CompletableFuture<Optional<Map<String, Object>>>> lookups = shards.getGuilds().stream()
|
||||
.map(guild -> resolveMember(guild, userId)
|
||||
.thenApply(member -> Optional.ofNullable(member)
|
||||
.map(m -> userGuildEntry(guild, m, guildServices.get(guild.getId()))))
|
||||
.exceptionally(_ -> Optional.empty()))
|
||||
.toList();
|
||||
|
||||
ctx.future(() -> CompletableFuture.allOf(lookups.toArray(new CompletableFuture[0])).thenApply(ignored -> {
|
||||
ctx.json(lookups.stream().map(CompletableFuture::join).flatMap(Optional::stream).toList());
|
||||
return null;
|
||||
}));
|
||||
}
|
||||
|
||||
private static CompletableFuture<Member> resolveMember(Guild guild, String userId) {
|
||||
Member cached = guild.getMemberById(userId);
|
||||
if (cached != null) {
|
||||
return CompletableFuture.completedFuture(cached);
|
||||
}
|
||||
|
||||
return guild.retrieveMemberById(userId).submit();
|
||||
}
|
||||
|
||||
private static Map<String, Object> userGuildEntry(Guild guild, Member member, GuildService guildService) {
|
||||
boolean owner = guild.getOwnerIdLong() == member.getIdLong();
|
||||
Map<String, Object> entry = new LinkedHashMap<>();
|
||||
entry.put("id", guild.getId());
|
||||
entry.put("name", guild.getName());
|
||||
entry.put("icon", guild.getIconUrl());
|
||||
entry.put("is_owner", owner);
|
||||
entry.put("is_admin", owner || UserFunction.isAdmin(member));
|
||||
entry.put("can_use_music", MusicAccess.canUse(member, guildService));
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the {@code /guilds} entry for a guild, falling back to a REST fetch for the owner id
|
||||
* when it is not present in the cache.
|
||||
@@ -117,16 +175,16 @@ public final class GuildRoutes {
|
||||
tags = {"Guilds"},
|
||||
responses = @OpenApiResponse(status = "200", content = @OpenApiContent(from = GuildOwnerEntryResponse[].class))
|
||||
)
|
||||
private static void owner(Supplier<JDA> jdaSupplier, Context ctx) {
|
||||
private static void owner(ShardProvider shards, Context ctx) {
|
||||
ctx.json(
|
||||
jda(jdaSupplier).getGuilds().stream()
|
||||
shards(shards).getGuilds().stream()
|
||||
.map(guild -> ordered("guild_id", guild.getId(), "owner_id", guild.getOwnerId()))
|
||||
.toList()
|
||||
);
|
||||
}
|
||||
|
||||
@OpenApi(
|
||||
path = "/guilds/owner/{guildId}",
|
||||
path = "/guilds/{guildId}/owner",
|
||||
methods = HttpMethod.GET,
|
||||
summary = "Get a guild's owner, falling back to a REST fetch on a member cache miss",
|
||||
tags = {"Guilds"},
|
||||
@@ -136,10 +194,10 @@ public final class GuildRoutes {
|
||||
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
|
||||
}
|
||||
)
|
||||
private static void ownerOf(Supplier<JDA> jdaSupplier, Context ctx) {
|
||||
Guild guild = guild(jdaSupplier, ctx);
|
||||
private static void ownerOf(ShardProvider shards, Context ctx) {
|
||||
Guild guild = guild(shards, ctx);
|
||||
|
||||
CacheOrFetch.<Member>resolve(
|
||||
CacheOrFetch.resolve(
|
||||
ctx,
|
||||
guild::getOwner,
|
||||
guild::retrieveOwner,
|
||||
@@ -148,7 +206,7 @@ public final class GuildRoutes {
|
||||
}
|
||||
|
||||
@OpenApi(
|
||||
path = "/guilds/channels/{guildId}",
|
||||
path = "/guilds/{guildId}/channels",
|
||||
methods = HttpMethod.GET,
|
||||
summary = "List the text channels of a guild",
|
||||
tags = {"Guilds"},
|
||||
@@ -158,16 +216,16 @@ public final class GuildRoutes {
|
||||
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
|
||||
}
|
||||
)
|
||||
private static void channels(Supplier<JDA> jdaSupplier, Context ctx) {
|
||||
private static void channels(ShardProvider shards, Context ctx) {
|
||||
ctx.json(
|
||||
guild(jdaSupplier, ctx).getTextChannels().stream()
|
||||
guild(shards, ctx).getTextChannels().stream()
|
||||
.map(channel -> ordered("channel_id", channel.getId(), "channel_name", channel.getName()))
|
||||
.toList()
|
||||
);
|
||||
}
|
||||
|
||||
@OpenApi(
|
||||
path = "/guilds/roles/{guildId}",
|
||||
path = "/guilds/{guildId}/roles",
|
||||
methods = HttpMethod.GET,
|
||||
summary = "List the roles of a guild",
|
||||
tags = {"Guilds"},
|
||||
@@ -177,9 +235,9 @@ public final class GuildRoutes {
|
||||
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
|
||||
}
|
||||
)
|
||||
private static void roles(Supplier<JDA> jdaSupplier, Context ctx) {
|
||||
private static void roles(ShardProvider shards, Context ctx) {
|
||||
ctx.json(
|
||||
guild(jdaSupplier, ctx).getRoles().stream()
|
||||
guild(shards, ctx).getRoles().stream()
|
||||
.filter(role -> !role.isManaged() && !role.isPublicRole())
|
||||
.map(role -> ordered("role_id", role.getId(), "role_name", role.getName()))
|
||||
.toList()
|
||||
@@ -187,7 +245,7 @@ public final class GuildRoutes {
|
||||
}
|
||||
|
||||
@OpenApi(
|
||||
path = "/guilds/members/{guildId}/{userId}",
|
||||
path = "/guilds/{guildId}/members/{userId}",
|
||||
methods = HttpMethod.GET,
|
||||
summary = "Get a guild member, falling back to a REST fetch on a member cache miss",
|
||||
tags = {"Guilds"},
|
||||
@@ -200,11 +258,11 @@ public final class GuildRoutes {
|
||||
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
|
||||
}
|
||||
)
|
||||
private static void member(Supplier<JDA> jdaSupplier, Context ctx) {
|
||||
Guild guild = guild(jdaSupplier, ctx);
|
||||
private static void member(ShardProvider shards, Context ctx) {
|
||||
Guild guild = guild(shards, ctx);
|
||||
String userId = ctx.pathParam("userId");
|
||||
|
||||
CacheOrFetch.<Member>resolve(
|
||||
CacheOrFetch.resolve(
|
||||
ctx,
|
||||
() -> guild.getMemberById(userId),
|
||||
() -> guild.retrieveMemberById(userId),
|
||||
@@ -213,7 +271,7 @@ public final class GuildRoutes {
|
||||
}
|
||||
|
||||
@OpenApi(
|
||||
path = "/guilds/members/{guildId}/{userId}/roles",
|
||||
path = "/guilds/{guildId}/members/{userId}/roles",
|
||||
methods = HttpMethod.GET,
|
||||
summary = "List the roles of a guild member, falling back to a REST fetch on a member cache miss",
|
||||
tags = {"Guilds"},
|
||||
@@ -226,11 +284,11 @@ public final class GuildRoutes {
|
||||
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
|
||||
}
|
||||
)
|
||||
private static void memberRoles(Supplier<JDA> jdaSupplier, Context ctx) {
|
||||
Guild guild = guild(jdaSupplier, ctx);
|
||||
private static void memberRoles(ShardProvider shards, Context ctx) {
|
||||
Guild guild = guild(shards, ctx);
|
||||
String userId = ctx.pathParam("userId");
|
||||
|
||||
CacheOrFetch.<Member>resolve(
|
||||
CacheOrFetch.resolve(
|
||||
ctx,
|
||||
() -> guild.getMemberById(userId),
|
||||
() -> guild.retrieveMemberById(userId),
|
||||
@@ -239,7 +297,7 @@ public final class GuildRoutes {
|
||||
}
|
||||
|
||||
@OpenApi(
|
||||
path = "/guilds/members/{guildId}/{userId}/admin",
|
||||
path = "/guilds/{guildId}/members/{userId}/admin",
|
||||
methods = HttpMethod.GET,
|
||||
summary = "Check whether a guild member is an admin of the guild, falling back to a REST fetch on a member cache miss",
|
||||
tags = {"Guilds"},
|
||||
@@ -252,11 +310,11 @@ public final class GuildRoutes {
|
||||
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
|
||||
}
|
||||
)
|
||||
private static void memberAdmin(Supplier<JDA> jdaSupplier, Context ctx) {
|
||||
Guild guild = guild(jdaSupplier, ctx);
|
||||
private static void memberAdmin(ShardProvider shards, Context ctx) {
|
||||
Guild guild = guild(shards, ctx);
|
||||
String userId = ctx.pathParam("userId");
|
||||
|
||||
CacheOrFetch.<Member>resolve(
|
||||
CacheOrFetch.resolve(
|
||||
ctx,
|
||||
() -> guild.getMemberById(userId),
|
||||
() -> guild.retrieveMemberById(userId),
|
||||
@@ -270,8 +328,54 @@ public final class GuildRoutes {
|
||||
);
|
||||
}
|
||||
|
||||
@OpenApi(
|
||||
path = "/guilds/{guildId}/members/{userId}/access",
|
||||
methods = HttpMethod.GET,
|
||||
summary = "Get a member's roles, admin/owner flags, music access and presence in one call",
|
||||
tags = {"Guilds"},
|
||||
pathParams = {
|
||||
@io.javalin.openapi.OpenApiParam(name = "guildId", description = "The Discord guild id"),
|
||||
@io.javalin.openapi.OpenApiParam(name = "userId", description = "The Discord user id")
|
||||
},
|
||||
responses = {
|
||||
@OpenApiResponse(status = "200", content = @OpenApiContent(from = MemberAccessResponse.class)),
|
||||
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
|
||||
}
|
||||
)
|
||||
private static void memberAccess(ShardProvider shards, GuildServiceProvider guildServices, Context ctx) {
|
||||
Guild guild = guild(shards, ctx);
|
||||
String userId = ctx.pathParam("userId");
|
||||
|
||||
Function<Member, Map<String, Object>> toBody = member -> {
|
||||
boolean owner = guild.getOwnerIdLong() == member.getIdLong();
|
||||
Map<String, Object> summary = new LinkedHashMap<>();
|
||||
summary.put("guild_id", guild.getId());
|
||||
summary.put("user_id", member.getId());
|
||||
summary.put("role_ids", member.getRoles().stream().map(Role::getId).toList());
|
||||
summary.put("is_owner", owner);
|
||||
summary.put("is_admin", owner || UserFunction.isAdmin(member));
|
||||
summary.put("can_use_music", MusicAccess.canUse(member, guildServices.get(guild.getId())));
|
||||
return summary;
|
||||
};
|
||||
|
||||
Member cached = guild.getMemberById(userId);
|
||||
if (cached != null) {
|
||||
ctx.json(toBody.apply(cached));
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.future(() -> guild.retrieveMemberById(userId).submit().handle((member, error) -> {
|
||||
if (error != null || member == null) {
|
||||
ctx.status(404).json(Map.of("status", "error", "message", "Member not found in guild"));
|
||||
} else {
|
||||
ctx.json(toBody.apply(member));
|
||||
}
|
||||
return null;
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the {@code /guilds/members/{guildId}/{userId}} response for a resolved member.
|
||||
* Builds the {@code /guilds/{guildId}/members/{userId}} response for a resolved member.
|
||||
*/
|
||||
private static Map<String, Object> memberSummary(Member member) {
|
||||
Map<String, Object> summary = new LinkedHashMap<>();
|
||||
@@ -283,7 +387,7 @@ public final class GuildRoutes {
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the {@code /guilds/members/{guildId}/{userId}/roles} response for a resolved member.
|
||||
* Builds the {@code /guilds/{guildId}/members/{userId}/roles} response for a resolved member.
|
||||
*/
|
||||
private static Map<String, Object> memberRolesSummary(Guild guild, Member member) {
|
||||
Map<String, Object> summary = new LinkedHashMap<>();
|
||||
@@ -298,7 +402,7 @@ public final class GuildRoutes {
|
||||
}
|
||||
|
||||
@OpenApi(
|
||||
path = "/guilds/voice-channels/{guildId}",
|
||||
path = "/guilds/{guildId}/voice-channels",
|
||||
methods = HttpMethod.GET,
|
||||
summary = "List the voice channels of a guild, including the members currently connected",
|
||||
tags = {"Guilds"},
|
||||
@@ -308,16 +412,16 @@ public final class GuildRoutes {
|
||||
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
|
||||
}
|
||||
)
|
||||
private static void voiceChannels(Supplier<JDA> jdaSupplier, Context ctx) {
|
||||
private static void voiceChannels(ShardProvider shards, Context ctx) {
|
||||
ctx.json(
|
||||
guild(jdaSupplier, ctx).getVoiceChannels().stream()
|
||||
guild(shards, ctx).getVoiceChannels().stream()
|
||||
.map(GuildRoutes::voiceChannelSummary)
|
||||
.toList()
|
||||
);
|
||||
}
|
||||
|
||||
@OpenApi(
|
||||
path = "/guilds/voice-channels/{guildId}/{channelId}",
|
||||
path = "/guilds/{guildId}/voice-channels/{channelId}",
|
||||
methods = HttpMethod.GET,
|
||||
summary = "Get a voice channel, including the members currently connected",
|
||||
tags = {"Guilds"},
|
||||
@@ -330,8 +434,8 @@ public final class GuildRoutes {
|
||||
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
|
||||
}
|
||||
)
|
||||
private static void voiceChannel(Supplier<JDA> jdaSupplier, Context ctx) {
|
||||
ctx.json(voiceChannelSummary(voiceChannel(guild(jdaSupplier, ctx), ctx)));
|
||||
private static void voiceChannel(ShardProvider shards, Context ctx) {
|
||||
ctx.json(voiceChannelSummary(voiceChannel(guild(shards, ctx), ctx)));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -367,7 +471,7 @@ public final class GuildRoutes {
|
||||
Map<String, String> summary = new LinkedHashMap<>();
|
||||
summary.put("user_id", member.getId());
|
||||
summary.put("username", member.getUser().getName());
|
||||
summary.put("nickname", member.getNickname());
|
||||
summary.put("nickname", member.getEffectiveName());
|
||||
return summary;
|
||||
}
|
||||
|
||||
@@ -382,7 +486,7 @@ public final class GuildRoutes {
|
||||
}
|
||||
|
||||
@OpenApi(
|
||||
path = "/guilds/config/{guildId}",
|
||||
path = "/guilds/{guildId}/config",
|
||||
methods = HttpMethod.GET,
|
||||
summary = "Get a guild's debug/logging configuration",
|
||||
tags = {"Guilds"},
|
||||
@@ -392,13 +496,13 @@ public final class GuildRoutes {
|
||||
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
|
||||
}
|
||||
)
|
||||
private static void config(Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices, Context ctx) {
|
||||
Guild guild = guild(jdaSupplier, ctx);
|
||||
private static void config(ShardProvider shards, GuildServiceProvider guildServices, Context ctx) {
|
||||
Guild guild = guild(shards, ctx);
|
||||
ctx.json(guildConfig(guild, guildServices));
|
||||
}
|
||||
|
||||
@OpenApi(
|
||||
path = "/guilds/debug/{guildId}",
|
||||
path = "/guilds/{guildId}/debug",
|
||||
methods = HttpMethod.PUT,
|
||||
summary = "Enable or disable debug/error logging for a guild",
|
||||
tags = {"Guilds"},
|
||||
@@ -410,8 +514,8 @@ public final class GuildRoutes {
|
||||
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
|
||||
}
|
||||
)
|
||||
private static void updateDebug(Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices, Context ctx) {
|
||||
Guild guild = guild(jdaSupplier, ctx);
|
||||
private static void updateDebug(ShardProvider shards, GuildServiceProvider guildServices, Context ctx) {
|
||||
Guild guild = guild(shards, ctx);
|
||||
boolean value = booleanValue(ctx);
|
||||
|
||||
GuildService guildService = guildServices.get(guild.getId());
|
||||
@@ -421,7 +525,7 @@ public final class GuildRoutes {
|
||||
}
|
||||
|
||||
@OpenApi(
|
||||
path = "/guilds/logging/{guildId}",
|
||||
path = "/guilds/{guildId}/logging",
|
||||
methods = HttpMethod.PUT,
|
||||
summary = "Enable or disable logging for a guild",
|
||||
tags = {"Guilds"},
|
||||
@@ -433,8 +537,8 @@ public final class GuildRoutes {
|
||||
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
|
||||
}
|
||||
)
|
||||
private static void updateLogging(Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices, Context ctx) {
|
||||
Guild guild = guild(jdaSupplier, ctx);
|
||||
private static void updateLogging(ShardProvider shards, GuildServiceProvider guildServices, Context ctx) {
|
||||
Guild guild = guild(shards, ctx);
|
||||
boolean value = booleanValue(ctx);
|
||||
|
||||
GuildService guildService = guildServices.get(guild.getId());
|
||||
@@ -444,7 +548,7 @@ public final class GuildRoutes {
|
||||
}
|
||||
|
||||
@OpenApi(
|
||||
path = "/guilds/log-channel/{guildId}",
|
||||
path = "/guilds/{guildId}/log-channel",
|
||||
methods = HttpMethod.PUT,
|
||||
summary = "Set (or clear) the log channel for a guild",
|
||||
tags = {"Guilds"},
|
||||
@@ -456,8 +560,8 @@ public final class GuildRoutes {
|
||||
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
|
||||
}
|
||||
)
|
||||
private static void updateLogChannel(Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices, Context ctx) {
|
||||
Guild guild = guild(jdaSupplier, ctx);
|
||||
private static void updateLogChannel(ShardProvider shards, GuildServiceProvider guildServices, Context ctx) {
|
||||
Guild guild = guild(shards, ctx);
|
||||
String channelId = ctx.bodyAsClass(Map.class).get("value") instanceof String s ? s : null;
|
||||
|
||||
if (channelId != null && !channelId.isBlank() && guild.getTextChannelById(channelId) == null) {
|
||||
@@ -482,7 +586,7 @@ public final class GuildRoutes {
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the {@code /guilds/config/{guildId}} response for a guild's current configuration.
|
||||
* Builds the {@code /guilds/{guildId}/config} response for a guild's current configuration.
|
||||
*/
|
||||
private static GuildConfigResponse guildConfig(Guild guild, GuildServiceProvider guildServices) {
|
||||
GuildService guildService = guildServices.get(guild.getId());
|
||||
@@ -495,21 +599,20 @@ public final class GuildRoutes {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the JDA client or fails with 503 while it is not ready yet.
|
||||
* Resolves the shard provider or fails with 503 while the shards are not ready yet.
|
||||
*/
|
||||
static JDA jda(Supplier<JDA> jdaSupplier) {
|
||||
JDA jda = jdaSupplier.get();
|
||||
if (jda == null) {
|
||||
static ShardProvider shards(ShardProvider shards) {
|
||||
if (shards == null || !shards.isReady()) {
|
||||
throw new ServiceUnavailableResponse("Discord client is not ready");
|
||||
}
|
||||
return jda;
|
||||
return shards;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the {@code guildId} path parameter or fails with 404.
|
||||
*/
|
||||
static Guild guild(Supplier<JDA> jdaSupplier, Context ctx) {
|
||||
Guild guild = jda(jdaSupplier).getGuildById(ctx.pathParam("guildId"));
|
||||
static Guild guild(ShardProvider shards, Context ctx) {
|
||||
Guild guild = shards(shards).getGuildById(ctx.pathParam("guildId"));
|
||||
if (guild == null) {
|
||||
throw new NotFoundResponse("Unknown guild");
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ import io.javalin.openapi.OpenApiContent;
|
||||
import io.javalin.openapi.OpenApiParam;
|
||||
import io.javalin.openapi.OpenApiRequestBody;
|
||||
import io.javalin.openapi.OpenApiResponse;
|
||||
import me.bobert.netneutrality.http.server.dto.AutoplayRequest;
|
||||
import me.bobert.netneutrality.http.server.dto.AutoplayResponse;
|
||||
import me.bobert.netneutrality.http.server.dto.CommandResultResponse;
|
||||
import me.bobert.netneutrality.http.server.dto.DeleteQueueResponse;
|
||||
import me.bobert.netneutrality.http.server.dto.DjStatusResponse;
|
||||
@@ -30,20 +32,23 @@ import me.bobert.netneutrality.http.server.dto.QueuedTrackResponse;
|
||||
import me.bobert.netneutrality.http.server.dto.ReorderRequest;
|
||||
import me.bobert.netneutrality.http.server.dto.ReorderResponse;
|
||||
import me.bobert.netneutrality.http.server.dto.SearchRequest;
|
||||
import me.bobert.netneutrality.http.server.dto.SearchResponse;
|
||||
import me.bobert.netneutrality.http.server.dto.SeekRequest;
|
||||
import me.bobert.netneutrality.http.server.dto.SeekResponse;
|
||||
import me.bobert.netneutrality.http.server.dto.ShuffleResponse;
|
||||
import me.bobert.netneutrality.http.server.dto.VoteSkipStatusResponse;
|
||||
import me.bobert.netneutrality.http.server.dto.VolumeRequest;
|
||||
import me.bobert.netneutrality.http.server.dto.VolumeResponse;
|
||||
import me.bobert.netneutrality.music.MusicManager;
|
||||
import me.bobert.netneutrality.music.MusicService;
|
||||
import me.bobert.netneutrality.music.PendingSearchResults;
|
||||
import me.bobert.netneutrality.music.PlayOutcome;
|
||||
import me.bobert.netneutrality.music.PlaylistInfo;
|
||||
import me.bobert.netneutrality.music.QueueActionResult;
|
||||
import me.bobert.netneutrality.music.SourceFinder;
|
||||
import me.bobert.netneutrality.music.TrackInfo;
|
||||
import me.bobert.netneutrality.utils.MusicUtils;
|
||||
import net.dv8tion.jda.api.JDA;
|
||||
import me.bobert.netneutrality.shard.ShardProvider;
|
||||
import net.dv8tion.jda.api.entities.Guild;
|
||||
import net.dv8tion.jda.api.entities.Member;
|
||||
import net.dv8tion.jda.api.entities.channel.middleman.AudioChannel;
|
||||
@@ -53,7 +58,6 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* Registers the {@code /music/*} endpoints on a {@link RoutesConfig} instance.
|
||||
@@ -67,25 +71,28 @@ public final class MusicRoutes {
|
||||
private MusicRoutes() {
|
||||
}
|
||||
|
||||
public static void register(RoutesConfig routes, Supplier<JDA> jdaSupplier, MusicService musicService) {
|
||||
public static void register(RoutesConfig routes, ShardProvider shards, MusicService musicService) {
|
||||
routes.get("/music/sources", MusicRoutes::sources);
|
||||
routes.get("/music/{guildId}/status", ctx -> status(jdaSupplier, musicService, ctx));
|
||||
routes.get("/music/{guildId}/queue", ctx -> queue(jdaSupplier, musicService, ctx));
|
||||
routes.post("/music/{guildId}/skip", ctx -> skip(jdaSupplier, musicService, ctx));
|
||||
routes.post("/music/{guildId}/stop", ctx -> stop(jdaSupplier, musicService, ctx));
|
||||
routes.post("/music/{guildId}/pause", ctx -> pause(jdaSupplier, musicService, ctx));
|
||||
routes.post("/music/{guildId}/resume", ctx -> resumePlayback(jdaSupplier, musicService, ctx));
|
||||
routes.get("/music/{guildId}/volume", ctx -> getVolume(jdaSupplier, musicService, ctx));
|
||||
routes.put("/music/{guildId}/volume", ctx -> volume(jdaSupplier, musicService, ctx));
|
||||
routes.put("/music/{guildId}/seek", ctx -> seek(jdaSupplier, musicService, ctx));
|
||||
routes.post("/music/{guildId}/vote-skip/{userId}", ctx -> voteSkip(jdaSupplier, musicService, ctx));
|
||||
routes.get("/music/{guildId}/dj/{userId}", ctx -> dj(jdaSupplier, musicService, ctx));
|
||||
routes.post("/music/{guildId}/queue", ctx -> play(jdaSupplier, musicService, ctx));
|
||||
routes.delete("/music/{guildId}/queue", ctx -> deleteQueue(jdaSupplier, musicService, ctx));
|
||||
routes.patch("/music/{guildId}/queue/reorder", ctx -> reorder(jdaSupplier, musicService, ctx));
|
||||
routes.post("/music/{guildId}/search", ctx -> search(jdaSupplier, musicService, ctx));
|
||||
routes.post("/music/{guildId}/leave", ctx -> leave(jdaSupplier, musicService, ctx));
|
||||
routes.post("/music/{guildId}/shuffle", ctx -> shuffle(jdaSupplier, musicService, ctx));
|
||||
routes.get("/music/{guildId}/status", ctx -> status(shards, musicService, ctx));
|
||||
routes.get("/music/{guildId}/queue", ctx -> queue(shards, musicService, ctx));
|
||||
routes.post("/music/{guildId}/skip", ctx -> skip(shards, musicService, ctx));
|
||||
routes.post("/music/{guildId}/stop", ctx -> stop(shards, musicService, ctx));
|
||||
routes.post("/music/{guildId}/pause", ctx -> pause(shards, musicService, ctx));
|
||||
routes.post("/music/{guildId}/resume", ctx -> resumePlayback(shards, musicService, ctx));
|
||||
routes.get("/music/{guildId}/volume", ctx -> getVolume(shards, musicService, ctx));
|
||||
routes.put("/music/{guildId}/volume", ctx -> volume(shards, musicService, ctx));
|
||||
routes.get("/music/{guildId}/autoplay", ctx -> getAutoplay(shards, musicService, ctx));
|
||||
routes.put("/music/{guildId}/autoplay", ctx -> autoplay(shards, musicService, ctx));
|
||||
routes.put("/music/{guildId}/seek", ctx -> seek(shards, musicService, ctx));
|
||||
routes.get("/music/{guildId}/vote-skip", ctx -> voteSkipStatus(shards, musicService, ctx));
|
||||
routes.post("/music/{guildId}/vote-skip/{userId}", ctx -> voteSkip(shards, musicService, ctx));
|
||||
routes.get("/music/{guildId}/dj/{userId}", ctx -> dj(shards, musicService, ctx));
|
||||
routes.post("/music/{guildId}/queue", ctx -> play(shards, musicService, ctx));
|
||||
routes.delete("/music/{guildId}/queue", ctx -> deleteQueue(shards, musicService, ctx));
|
||||
routes.patch("/music/{guildId}/queue/reorder", ctx -> reorder(shards, musicService, ctx));
|
||||
routes.post("/music/{guildId}/search", ctx -> search(shards, musicService, ctx));
|
||||
routes.post("/music/{guildId}/leave", ctx -> leave(shards, musicService, ctx));
|
||||
routes.post("/music/{guildId}/shuffle", ctx -> shuffle(shards, musicService, ctx));
|
||||
}
|
||||
|
||||
@OpenApi(
|
||||
@@ -115,8 +122,8 @@ public final class MusicRoutes {
|
||||
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
|
||||
}
|
||||
)
|
||||
private static void status(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) {
|
||||
MusicManager manager = manager(jdaSupplier, musicService, ctx);
|
||||
private static void status(ShardProvider shards, MusicService musicService, Context ctx) {
|
||||
MusicManager manager = manager(shards, musicService, ctx);
|
||||
AudioChannel channel = manager.getConnectedChannel();
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
@@ -143,8 +150,8 @@ public final class MusicRoutes {
|
||||
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
|
||||
}
|
||||
)
|
||||
private static void queue(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) {
|
||||
MusicManager manager = manager(jdaSupplier, musicService, ctx);
|
||||
private static void queue(ShardProvider shards, MusicService musicService, Context ctx) {
|
||||
MusicManager manager = manager(shards, musicService, ctx);
|
||||
|
||||
ctx.json(
|
||||
manager.getQueueManager().getQueue().stream()
|
||||
@@ -164,8 +171,8 @@ public final class MusicRoutes {
|
||||
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
|
||||
}
|
||||
)
|
||||
private static void skip(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) {
|
||||
MusicManager manager = manager(jdaSupplier, musicService, ctx);
|
||||
private static void skip(ShardProvider shards, MusicService musicService, Context ctx) {
|
||||
MusicManager manager = manager(shards, musicService, ctx);
|
||||
QueueActionResult result = manager.getQueueManager().skip();
|
||||
announce(manager, result.getMessage());
|
||||
ctx.json(resultBody(result));
|
||||
@@ -182,8 +189,8 @@ public final class MusicRoutes {
|
||||
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
|
||||
}
|
||||
)
|
||||
private static void stop(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) {
|
||||
MusicManager manager = manager(jdaSupplier, musicService, ctx);
|
||||
private static void stop(ShardProvider shards, MusicService musicService, Context ctx) {
|
||||
MusicManager manager = manager(shards, musicService, ctx);
|
||||
manager.stopTrack();
|
||||
announce(manager, "Stopping current track");
|
||||
ctx.json(Map.of("status", "ok", "message", "Stopping current track"));
|
||||
@@ -200,8 +207,8 @@ public final class MusicRoutes {
|
||||
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
|
||||
}
|
||||
)
|
||||
private static void pause(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) {
|
||||
MusicManager manager = manager(jdaSupplier, musicService, ctx);
|
||||
private static void pause(ShardProvider shards, MusicService musicService, Context ctx) {
|
||||
MusicManager manager = manager(shards, musicService, ctx);
|
||||
manager.pauseTrack();
|
||||
announce(manager, "Pausing current track");
|
||||
ctx.json(Map.of("status", "ok", "message", "Pausing current track"));
|
||||
@@ -218,8 +225,8 @@ public final class MusicRoutes {
|
||||
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
|
||||
}
|
||||
)
|
||||
private static void resumePlayback(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) {
|
||||
MusicManager manager = manager(jdaSupplier, musicService, ctx);
|
||||
private static void resumePlayback(ShardProvider shards, MusicService musicService, Context ctx) {
|
||||
MusicManager manager = manager(shards, musicService, ctx);
|
||||
manager.unpauseTrack();
|
||||
ctx.json(Map.of("status", "ok", "message", "Resuming current track"));
|
||||
}
|
||||
@@ -235,8 +242,8 @@ public final class MusicRoutes {
|
||||
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
|
||||
}
|
||||
)
|
||||
private static void getVolume(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) {
|
||||
MusicManager manager = manager(jdaSupplier, musicService, ctx);
|
||||
private static void getVolume(ShardProvider shards, MusicService musicService, Context ctx) {
|
||||
MusicManager manager = manager(shards, musicService, ctx);
|
||||
ctx.json(Map.of("status", "ok", "volume", manager.getVolume()));
|
||||
}
|
||||
|
||||
@@ -253,8 +260,8 @@ public final class MusicRoutes {
|
||||
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
|
||||
}
|
||||
)
|
||||
private static void volume(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) {
|
||||
MusicManager manager = manager(jdaSupplier, musicService, ctx);
|
||||
private static void volume(ShardProvider shards, MusicService musicService, Context ctx) {
|
||||
MusicManager manager = manager(shards, musicService, ctx);
|
||||
int newVolume = volume(ctx);
|
||||
|
||||
manager.setVolume(newVolume);
|
||||
@@ -281,6 +288,46 @@ public final class MusicRoutes {
|
||||
return volume;
|
||||
}
|
||||
|
||||
@OpenApi(
|
||||
path = "/music/{guildId}/autoplay",
|
||||
methods = HttpMethod.GET,
|
||||
summary = "Get whether autoplay is enabled for a guild",
|
||||
tags = {"Music"},
|
||||
pathParams = @OpenApiParam(name = "guildId", description = "The Discord guild id"),
|
||||
responses = {
|
||||
@OpenApiResponse(status = "200", content = @OpenApiContent(from = AutoplayResponse.class)),
|
||||
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
|
||||
}
|
||||
)
|
||||
private static void getAutoplay(ShardProvider shards, MusicService musicService, Context ctx) {
|
||||
MusicManager manager = manager(shards, musicService, ctx);
|
||||
ctx.json(new AutoplayResponse(manager.getScheduler().isAutoplayEnabled()));
|
||||
}
|
||||
|
||||
@OpenApi(
|
||||
path = "/music/{guildId}/autoplay",
|
||||
methods = HttpMethod.PUT,
|
||||
summary = "Enable or disable autoplay for a guild",
|
||||
tags = {"Music"},
|
||||
pathParams = @OpenApiParam(name = "guildId", description = "The Discord guild id"),
|
||||
requestBody = @OpenApiRequestBody(content = @OpenApiContent(from = AutoplayRequest.class)),
|
||||
responses = {
|
||||
@OpenApiResponse(status = "200", content = @OpenApiContent(from = AutoplayResponse.class)),
|
||||
@OpenApiResponse(status = "400", content = @OpenApiContent(from = ErrorResponse.class)),
|
||||
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
|
||||
}
|
||||
)
|
||||
private static void autoplay(ShardProvider shards, MusicService musicService, Context ctx) {
|
||||
MusicManager manager = manager(shards, musicService, ctx);
|
||||
Object enabled = ctx.bodyAsClass(Map.class).get("enabled");
|
||||
if (!(enabled instanceof Boolean value)) {
|
||||
throw new BadRequestResponse("enabled must be a boolean");
|
||||
}
|
||||
|
||||
manager.getScheduler().setAutoplayEnabled(value);
|
||||
ctx.json(new AutoplayResponse(value));
|
||||
}
|
||||
|
||||
@OpenApi(
|
||||
path = "/music/{guildId}/seek",
|
||||
methods = HttpMethod.PUT,
|
||||
@@ -294,8 +341,8 @@ public final class MusicRoutes {
|
||||
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
|
||||
}
|
||||
)
|
||||
private static void seek(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) {
|
||||
MusicManager manager = manager(jdaSupplier, musicService, ctx);
|
||||
private static void seek(ShardProvider shards, MusicService musicService, Context ctx) {
|
||||
MusicManager manager = manager(shards, musicService, ctx);
|
||||
long positionMs = seekPosition(ctx, manager);
|
||||
|
||||
manager.seek(positionMs);
|
||||
@@ -327,6 +374,31 @@ public final class MusicRoutes {
|
||||
return positionMs;
|
||||
}
|
||||
|
||||
@OpenApi(
|
||||
path = "/music/{guildId}/vote-skip",
|
||||
methods = HttpMethod.GET,
|
||||
summary = "Get the current vote-skip status of the playing track",
|
||||
tags = {"Music"},
|
||||
pathParams = @OpenApiParam(name = "guildId", description = "The Discord guild id"),
|
||||
responses = {
|
||||
@OpenApiResponse(status = "200", content = @OpenApiContent(from = VoteSkipStatusResponse.class)),
|
||||
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
|
||||
}
|
||||
)
|
||||
private static void voteSkipStatus(ShardProvider shards, MusicService musicService, Context ctx) {
|
||||
MusicManager manager = manager(shards, musicService, ctx);
|
||||
AudioChannel channel = manager.getConnectedChannel();
|
||||
TrackInfo head = manager.getQueueManager().peekHeadOfQueue();
|
||||
int threshold = manager.getVoteSkipThreshold();
|
||||
|
||||
boolean active = channel != null && head != null && manager.isCurrentlyPlaying();
|
||||
int listeners = channel == null ? 0 : Math.max(0, channel.getMembers().size() - 1);
|
||||
int votes = head == null ? 0 : head.getSkipCount();
|
||||
int needed = (int) Math.ceil(listeners * (threshold / 100.0));
|
||||
|
||||
ctx.json(new VoteSkipStatusResponse(active, votes, needed, listeners, threshold));
|
||||
}
|
||||
|
||||
@OpenApi(
|
||||
path = "/music/{guildId}/vote-skip/{userId}",
|
||||
methods = HttpMethod.POST,
|
||||
@@ -341,8 +413,8 @@ public final class MusicRoutes {
|
||||
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
|
||||
}
|
||||
)
|
||||
private static void voteSkip(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) {
|
||||
Guild guild = GuildRoutes.guild(jdaSupplier, ctx);
|
||||
private static void voteSkip(ShardProvider shards, MusicService musicService, Context ctx) {
|
||||
Guild guild = GuildRoutes.guild(shards, ctx);
|
||||
MusicManager manager = musicService.get(guild);
|
||||
String userId = ctx.pathParam("userId");
|
||||
|
||||
@@ -372,12 +444,12 @@ public final class MusicRoutes {
|
||||
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
|
||||
}
|
||||
)
|
||||
private static void dj(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) {
|
||||
Guild guild = GuildRoutes.guild(jdaSupplier, ctx);
|
||||
private static void dj(ShardProvider shards, MusicService musicService, Context ctx) {
|
||||
Guild guild = GuildRoutes.guild(shards, ctx);
|
||||
MusicManager manager = musicService.get(guild);
|
||||
String userId = ctx.pathParam("userId");
|
||||
|
||||
CacheOrFetch.<Member>resolve(
|
||||
CacheOrFetch.resolve(
|
||||
ctx,
|
||||
() -> guild.getMemberById(userId),
|
||||
() -> guild.retrieveMemberById(userId),
|
||||
@@ -398,8 +470,8 @@ public final class MusicRoutes {
|
||||
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
|
||||
}
|
||||
)
|
||||
private static void play(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) {
|
||||
Guild guild = GuildRoutes.guild(jdaSupplier, ctx);
|
||||
private static void play(ShardProvider shards, MusicService musicService, Context ctx) {
|
||||
Guild guild = GuildRoutes.guild(shards, ctx);
|
||||
MusicManager manager = musicService.get(guild);
|
||||
QueueRequest body = ctx.bodyAsClass(QueueRequest.class);
|
||||
String userId = requireNonBlank(body.userId(), "userId");
|
||||
@@ -416,13 +488,13 @@ public final class MusicRoutes {
|
||||
pathParams = @OpenApiParam(name = "guildId", description = "The Discord guild id"),
|
||||
requestBody = @OpenApiRequestBody(content = @OpenApiContent(from = SearchRequest.class)),
|
||||
responses = {
|
||||
@OpenApiResponse(status = "200", content = @OpenApiContent(from = QueueResponse.class)),
|
||||
@OpenApiResponse(status = "200", content = @OpenApiContent(from = SearchResponse.class)),
|
||||
@OpenApiResponse(status = "400", content = @OpenApiContent(from = ErrorResponse.class)),
|
||||
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
|
||||
}
|
||||
)
|
||||
private static void search(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) {
|
||||
Guild guild = GuildRoutes.guild(jdaSupplier, ctx);
|
||||
private static void search(ShardProvider shards, MusicService musicService, Context ctx) {
|
||||
Guild guild = GuildRoutes.guild(shards, ctx);
|
||||
MusicManager manager = musicService.get(guild);
|
||||
SearchRequest body = ctx.bodyAsClass(SearchRequest.class);
|
||||
String query = requireNonBlank(body.query(), "query");
|
||||
@@ -452,7 +524,7 @@ public final class MusicRoutes {
|
||||
|
||||
/**
|
||||
* Queues a previously returned search candidate for a guild member, invoked from {@link
|
||||
* #play(Supplier, MusicService, Context)} when the request body carries a {@code searchId}.
|
||||
* #play(ShardProvider, MusicService, Context)} when the request body carries a {@code searchId}.
|
||||
* Exposed via the {@code /music/{guildId}/queue} POST endpoint.
|
||||
*/
|
||||
private static void playSelect(Guild guild, MusicManager manager, QueueRequest body, String userId, Context ctx) {
|
||||
@@ -463,10 +535,12 @@ public final class MusicRoutes {
|
||||
throw new BadRequestResponse("index is required when searchId is provided unless queueAll is true");
|
||||
}
|
||||
|
||||
List<Track> candidates = PendingSearchResults.consume(searchId, guild.getId());
|
||||
if (candidates == null) {
|
||||
PendingSearchResults.Consumed consumed = PendingSearchResults.consumeWithPlaylist(searchId, guild.getId());
|
||||
if (consumed == null) {
|
||||
throw new NotFoundResponse("Unknown or expired searchId");
|
||||
}
|
||||
List<Track> candidates = consumed.candidates();
|
||||
PlaylistInfo playlist = consumed.playlist();
|
||||
|
||||
if (!queueAll && (index < 0 || index >= candidates.size())) {
|
||||
throw new BadRequestResponse("index is out of range");
|
||||
@@ -479,7 +553,7 @@ public final class MusicRoutes {
|
||||
if (queueAll) {
|
||||
ctx.future(() -> resolveMember(guild, userId)
|
||||
.thenApply(member -> {
|
||||
candidates.forEach(candidate -> manager.getQueueManager().queue(candidate, member));
|
||||
manager.getQueueManager().queue(candidates, member, playlist);
|
||||
announce(manager, String.format("Queued %d track%s requested by %s",
|
||||
candidates.size(), candidates.size() > 1 ? "s" : "", member.getEffectiveName()));
|
||||
ctx.json(queuedAllBody(candidates, manager.getQueueSize()));
|
||||
@@ -506,15 +580,15 @@ public final class MusicRoutes {
|
||||
summary = "Clear the queue, or remove a single track from it when a position is provided",
|
||||
tags = {"Music"},
|
||||
pathParams = @OpenApiParam(name = "guildId", description = "The Discord guild id"),
|
||||
queryParams = @OpenApiParam(name = "position", description = "1-based position of the track to remove (position 1 is the currently playing track); omit to clear the whole queue", required = false),
|
||||
queryParams = @OpenApiParam(name = "position", description = "1-based position of the track to remove (position 1 is the currently playing track); omit to clear the whole queue"),
|
||||
responses = {
|
||||
@OpenApiResponse(status = "200", content = @OpenApiContent(from = DeleteQueueResponse.class)),
|
||||
@OpenApiResponse(status = "400", content = @OpenApiContent(from = ErrorResponse.class)),
|
||||
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
|
||||
}
|
||||
)
|
||||
private static void deleteQueue(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) {
|
||||
MusicManager manager = manager(jdaSupplier, musicService, ctx);
|
||||
private static void deleteQueue(ShardProvider shards, MusicService musicService, Context ctx) {
|
||||
MusicManager manager = manager(shards, musicService, ctx);
|
||||
String position = ctx.queryParam("position");
|
||||
|
||||
QueueActionResult result;
|
||||
@@ -550,8 +624,8 @@ public final class MusicRoutes {
|
||||
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
|
||||
}
|
||||
)
|
||||
private static void leave(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) {
|
||||
MusicManager manager = manager(jdaSupplier, musicService, ctx);
|
||||
private static void leave(ShardProvider shards, MusicService musicService, Context ctx) {
|
||||
MusicManager manager = manager(shards, musicService, ctx);
|
||||
manager.disconnect();
|
||||
announce(manager, "Leaving voice channel");
|
||||
ctx.json(new LeaveResponse("ok", "Leaving voice channel"));
|
||||
@@ -568,8 +642,8 @@ public final class MusicRoutes {
|
||||
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
|
||||
}
|
||||
)
|
||||
private static void shuffle(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) {
|
||||
MusicManager manager = manager(jdaSupplier, musicService, ctx);
|
||||
private static void shuffle(ShardProvider shards, MusicService musicService, Context ctx) {
|
||||
MusicManager manager = manager(shards, musicService, ctx);
|
||||
QueueActionResult result = manager.getQueueManager().shuffle();
|
||||
announce(manager, result.getMessage());
|
||||
ctx.json(resultBodyWithQueue(result, manager));
|
||||
@@ -588,8 +662,8 @@ public final class MusicRoutes {
|
||||
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
|
||||
}
|
||||
)
|
||||
private static void reorder(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) {
|
||||
MusicManager manager = manager(jdaSupplier, musicService, ctx);
|
||||
private static void reorder(ShardProvider shards, MusicService musicService, Context ctx) {
|
||||
MusicManager manager = manager(shards, musicService, ctx);
|
||||
ReorderRequest body = ctx.bodyAsClass(ReorderRequest.class);
|
||||
|
||||
QueueActionResult result = manager.getQueueManager().reorder(body.fromPosition(), body.toPosition());
|
||||
@@ -642,6 +716,8 @@ public final class MusicRoutes {
|
||||
body.put("status", "pending_playlist");
|
||||
body.put("search_id", pendingPlaylist.searchId());
|
||||
body.put("search_id_ttl_seconds", PendingSearchResults.ttlSeconds());
|
||||
body.put("identifier", pendingPlaylist.identifier());
|
||||
body.put("name", pendingPlaylist.name());
|
||||
body.put("tracks", pendingPlaylist.tracks().stream().map(MusicRoutes::trackSummary).toList());
|
||||
body.put("truncated", pendingPlaylist.truncated());
|
||||
yield body;
|
||||
@@ -687,8 +763,8 @@ public final class MusicRoutes {
|
||||
/**
|
||||
* Resolves the {@link MusicManager} for the guild path parameter, creating it on first use.
|
||||
*/
|
||||
private static MusicManager manager(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) {
|
||||
return musicService.get(GuildRoutes.guild(jdaSupplier, ctx));
|
||||
private static MusicManager manager(ShardProvider shards, MusicService musicService, Context ctx) {
|
||||
return musicService.get(GuildRoutes.guild(shards, ctx));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -751,6 +827,16 @@ public final class MusicRoutes {
|
||||
Map<String, Object> summary = trackSummary(info.getTrack());
|
||||
summary.put("requester_id", info.getRequester().getId());
|
||||
summary.put("skip_count", info.getSkipCount());
|
||||
PlaylistInfo playlist = info.getPlaylist();
|
||||
if (playlist == null) {
|
||||
summary.put("playlist", null);
|
||||
} else {
|
||||
Map<String, Object> playlistMap = new LinkedHashMap<>();
|
||||
playlistMap.put("name", playlist.name());
|
||||
playlistMap.put("identifier", playlist.identifier());
|
||||
playlistMap.put("track_count", playlist.trackCount());
|
||||
summary.put("playlist", playlistMap);
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
}
|
||||
@@ -25,13 +25,12 @@ import me.bobert.netneutrality.http.server.dto.SettingsCurrentResponse;
|
||||
import me.bobert.netneutrality.http.server.dto.SettingsReloadResponse;
|
||||
import me.bobert.netneutrality.http.server.dto.SettingsUpdateRequest;
|
||||
import me.bobert.netneutrality.http.server.dto.SettingsUpdateResponse;
|
||||
import net.dv8tion.jda.api.JDA;
|
||||
import me.bobert.netneutrality.shard.ShardProvider;
|
||||
import net.dv8tion.jda.api.entities.Guild;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* Registers the {@code /settings/*} endpoints on a {@link RoutesConfig} instance.
|
||||
@@ -41,14 +40,14 @@ public final class SettingsRoutes {
|
||||
private SettingsRoutes() {
|
||||
}
|
||||
|
||||
public static void register(RoutesConfig routes, Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices) {
|
||||
public static void register(RoutesConfig routes, ShardProvider shards, GuildServiceProvider guildServices) {
|
||||
routes.get("/settings/catalog", SettingsRoutes::settingsCatalog);
|
||||
|
||||
routes.get("/settings/current/{guildId}", ctx -> settingsCurrent(jdaSupplier, guildServices, ctx));
|
||||
routes.get("/settings/current/{guildId}", ctx -> settingsCurrent(shards, guildServices, ctx));
|
||||
|
||||
routes.put("/settings/current/{guildId}/{name}", ctx -> settingsUpdate(jdaSupplier, guildServices, ctx));
|
||||
routes.put("/settings/current/{guildId}/{name}", ctx -> settingsUpdate(shards, guildServices, ctx));
|
||||
|
||||
routes.put("/settings/reload/{guildId}", ctx -> settingsReload(jdaSupplier, guildServices, ctx));
|
||||
routes.put("/settings/reload/{guildId}", ctx -> settingsReload(shards, guildServices, ctx));
|
||||
}
|
||||
|
||||
@OpenApi(
|
||||
@@ -87,8 +86,8 @@ public final class SettingsRoutes {
|
||||
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
|
||||
}
|
||||
)
|
||||
private static void settingsCurrent(Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices, Context ctx) {
|
||||
Guild guild = GuildRoutes.guild(jdaSupplier, ctx);
|
||||
private static void settingsCurrent(ShardProvider shards, GuildServiceProvider guildServices, Context ctx) {
|
||||
Guild guild = GuildRoutes.guild(shards, ctx);
|
||||
Map<String, String> settings = guildServices.get(guild.getId()).getGuildSettingService().snapshot();
|
||||
|
||||
ctx.json(Map.of("status", "ok", "settings", settings));
|
||||
@@ -110,8 +109,8 @@ public final class SettingsRoutes {
|
||||
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
|
||||
}
|
||||
)
|
||||
private static void settingsUpdate(Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices, Context ctx) {
|
||||
Guild guild = GuildRoutes.guild(jdaSupplier, ctx);
|
||||
private static void settingsUpdate(ShardProvider shards, GuildServiceProvider guildServices, Context ctx) {
|
||||
Guild guild = GuildRoutes.guild(shards, ctx);
|
||||
GSettings setting = setting(ctx.pathParam("name"));
|
||||
String value = ctx.bodyAsClass(Map.class).get("value") instanceof String s ? s : null;
|
||||
|
||||
@@ -195,8 +194,8 @@ public final class SettingsRoutes {
|
||||
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
|
||||
}
|
||||
)
|
||||
private static void settingsReload(Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices, Context ctx) {
|
||||
Guild guild = GuildRoutes.guild(jdaSupplier, ctx);
|
||||
private static void settingsReload(ShardProvider shards, GuildServiceProvider guildServices, Context ctx) {
|
||||
Guild guild = GuildRoutes.guild(shards, ctx);
|
||||
guildServices.get(guild.getId()).getGuildSettingService().reloadSettingsCache();
|
||||
|
||||
ctx.json(Map.of("status", true));
|
||||
|
||||
@@ -18,11 +18,10 @@ import me.bobert.netneutrality.db.service.GuildServiceProvider;
|
||||
import me.bobert.netneutrality.http.server.dto.BotStatsResponse;
|
||||
import me.bobert.netneutrality.http.server.dto.ErrorResponse;
|
||||
import me.bobert.netneutrality.http.server.dto.GuildStatsResponse;
|
||||
import net.dv8tion.jda.api.JDA;
|
||||
import me.bobert.netneutrality.shard.ShardProvider;
|
||||
import net.dv8tion.jda.api.entities.Guild;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* Registers the {@code /stats/*} endpoints on a {@link RoutesConfig} instance.
|
||||
@@ -32,9 +31,9 @@ public final class StatsRoutes {
|
||||
private StatsRoutes() {
|
||||
}
|
||||
|
||||
public static void register(RoutesConfig routes, Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices) {
|
||||
routes.get("/stats/bot", ctx -> bot(jdaSupplier, ctx));
|
||||
routes.get("/stats/guilds/{guildId}", ctx -> guild(jdaSupplier, guildServices, ctx));
|
||||
public static void register(RoutesConfig routes, ShardProvider shards, GuildServiceProvider guildServices) {
|
||||
routes.get("/stats/bot", ctx -> bot(shards, ctx));
|
||||
routes.get("/stats/guilds/{guildId}", ctx -> guild(shards, guildServices, ctx));
|
||||
}
|
||||
|
||||
@OpenApi(
|
||||
@@ -44,13 +43,14 @@ public final class StatsRoutes {
|
||||
tags = {"Stats"},
|
||||
responses = @OpenApiResponse(status = "200", content = @OpenApiContent(from = BotStatsResponse.class))
|
||||
)
|
||||
private static void bot(Supplier<JDA> jdaSupplier, Context ctx) {
|
||||
JDA jda = GuildRoutes.jda(jdaSupplier);
|
||||
private static void bot(ShardProvider shards, Context ctx) {
|
||||
ShardProvider provider = GuildRoutes.shards(shards);
|
||||
|
||||
ctx.json(Map.of(
|
||||
"guild_count", jda.getGuilds().size(),
|
||||
"gateway_ping", jda.getGatewayPing(),
|
||||
"status", String.valueOf(jda.getStatus()),
|
||||
"guild_count", provider.getGuilds().size(),
|
||||
"gateway_ping", (long) provider.getAveragePing(),
|
||||
"status", String.valueOf(provider.getWorstStatus()),
|
||||
"shard_count", provider.getShardCount(),
|
||||
"uptime_seconds", uptimeSeconds()
|
||||
));
|
||||
}
|
||||
@@ -66,8 +66,8 @@ public final class StatsRoutes {
|
||||
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
|
||||
}
|
||||
)
|
||||
private static void guild(Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices, Context ctx) {
|
||||
Guild guild = GuildRoutes.guild(jdaSupplier, ctx);
|
||||
private static void guild(ShardProvider shards, GuildServiceProvider guildServices, Context ctx) {
|
||||
Guild guild = GuildRoutes.guild(shards, ctx);
|
||||
GuildService guildService = guildServices.get(guild.getId());
|
||||
|
||||
ctx.json(Map.of(
|
||||
|
||||
@@ -14,12 +14,11 @@ import me.bobert.netneutrality.bootstrap.Lifecycle;
|
||||
import me.bobert.netneutrality.db.service.GuildServiceProvider;
|
||||
import me.bobert.netneutrality.music.MusicService;
|
||||
import me.bobert.netneutrality.utils.SettingsProvider;
|
||||
import net.dv8tion.jda.api.JDA;
|
||||
import me.bobert.netneutrality.shard.ShardProvider;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* Embedded HTTP API exposed as a {@link Lifecycle} component (priority 70, after JDA).
|
||||
@@ -32,7 +31,7 @@ public class WebApi implements Lifecycle {
|
||||
private final SettingsProvider settings;
|
||||
private final GuildServiceProvider guildServices;
|
||||
private final MusicService musicService;
|
||||
private final Supplier<JDA> jdaSupplier;
|
||||
private final ShardProvider shards;
|
||||
private final Runnable shutdownHook;
|
||||
private Javalin app;
|
||||
|
||||
@@ -40,13 +39,13 @@ public class WebApi implements Lifecycle {
|
||||
SettingsProvider settings,
|
||||
GuildServiceProvider guildServices,
|
||||
MusicService musicService,
|
||||
Supplier<JDA> jdaSupplier,
|
||||
ShardProvider shards,
|
||||
Runnable shutdownHook
|
||||
) {
|
||||
this.settings = settings;
|
||||
this.guildServices = guildServices;
|
||||
this.musicService = musicService;
|
||||
this.jdaSupplier = jdaSupplier;
|
||||
this.shards = shards;
|
||||
this.shutdownHook = shutdownHook;
|
||||
}
|
||||
|
||||
@@ -66,7 +65,7 @@ public class WebApi implements Lifecycle {
|
||||
config.startup.showJavalinBanner = false;
|
||||
|
||||
config.registerPlugin(new OpenApiPlugin(pluginConfig -> pluginConfig.withDefinitionConfiguration(
|
||||
(version, definition) -> definition
|
||||
(_, definition) -> definition
|
||||
.info(info -> info.title("Net Neutrality API"))
|
||||
.withApiKeyAuth("ApiKeyAuth", "X-API-Key")
|
||||
.withGlobalSecurity("ApiKeyAuth")
|
||||
@@ -77,11 +76,11 @@ public class WebApi implements Lifecycle {
|
||||
config.routes.before(new ApiKeyFilter(settings));
|
||||
|
||||
config.routes.get("/", ctx -> ctx.html(LIVENESS_HTML));
|
||||
GuildRoutes.register(config.routes, jdaSupplier, guildServices);
|
||||
ActionRoutes.register(config.routes, jdaSupplier, shutdownHook);
|
||||
SettingsRoutes.register(config.routes, jdaSupplier, guildServices);
|
||||
StatsRoutes.register(config.routes, jdaSupplier, guildServices);
|
||||
MusicRoutes.register(config.routes, jdaSupplier, musicService);
|
||||
GuildRoutes.register(config.routes, shards, guildServices);
|
||||
ActionRoutes.register(config.routes, shards, shutdownHook);
|
||||
SettingsRoutes.register(config.routes, shards, guildServices);
|
||||
StatsRoutes.register(config.routes, shards, guildServices);
|
||||
MusicRoutes.register(config.routes, shards, musicService);
|
||||
|
||||
config.routes.exception(HttpResponseException.class, (e, ctx) ->
|
||||
ctx.status(e.getStatus()).json(Map.of("status", "error", "message", e.getMessage()))
|
||||
|
||||
@@ -7,7 +7,7 @@ package me.bobert.netneutrality.http.server.dto;
|
||||
*/
|
||||
|
||||
/**
|
||||
* OpenAPI schema for the {@code /guilds/members/{guildId}/{userId}/admin} response.
|
||||
* OpenAPI schema for the {@code /guilds/{guildId}/members/{userId}/admin} response.
|
||||
*/
|
||||
public record AdminStatusResponse(
|
||||
String guildId,
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package me.bobert.netneutrality.http.server.dto;
|
||||
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
*
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
/**
|
||||
* OpenAPI schema for the {@code /music/{guildId}/autoplay} request body.
|
||||
*/
|
||||
public record AutoplayRequest(
|
||||
Boolean enabled
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package me.bobert.netneutrality.http.server.dto;
|
||||
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
*
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
/**
|
||||
* OpenAPI schema for the {@code /music/{guildId}/autoplay} response.
|
||||
*/
|
||||
public record AutoplayResponse(
|
||||
boolean enabled
|
||||
) {
|
||||
}
|
||||
@@ -7,7 +7,7 @@ package me.bobert.netneutrality.http.server.dto;
|
||||
*/
|
||||
|
||||
/**
|
||||
* OpenAPI schema for a {@code /guilds/channels/{guildId}} and {@code /guilds/roles/{guildId}}
|
||||
* OpenAPI schema for a {@code /guilds/{guildId}/channels} and {@code /guilds/{guildId}/roles}
|
||||
* entry.
|
||||
*/
|
||||
public record ChannelSummaryResponse(
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ package me.bobert.netneutrality.http.server.dto;
|
||||
*/
|
||||
|
||||
/**
|
||||
* OpenAPI schema for the {@code /guilds/debug/{guildId}} and {@code /guilds/logging/{guildId}}
|
||||
* OpenAPI schema for the {@code /guilds/{guildId}/debug} and {@code /guilds/{guildId}/logging}
|
||||
* request bodies.
|
||||
*/
|
||||
public record GuildBooleanUpdateRequest(
|
||||
|
||||
@@ -7,7 +7,7 @@ package me.bobert.netneutrality.http.server.dto;
|
||||
*/
|
||||
|
||||
/**
|
||||
* OpenAPI schema for the {@code /guilds/config/{guildId}} response, as well as the
|
||||
* OpenAPI schema for the {@code /guilds/{guildId}/config} response, as well as the
|
||||
* debug/logging/log-channel update responses.
|
||||
*/
|
||||
public record GuildConfigResponse(
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ package me.bobert.netneutrality.http.server.dto;
|
||||
*/
|
||||
|
||||
/**
|
||||
* OpenAPI schema for the {@code /guilds/log-channel/{guildId}} request body.
|
||||
* OpenAPI schema for the {@code /guilds/{guildId}/log-channel} request body.
|
||||
*/
|
||||
public record GuildLogChannelUpdateRequest(
|
||||
String value
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package me.bobert.netneutrality.http.server.dto;
|
||||
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
*
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* OpenAPI schema for {@code /guilds/{guildId}/members/{userId}/access}.
|
||||
*/
|
||||
public record MemberAccessResponse(
|
||||
String guild_id,
|
||||
String user_id,
|
||||
List<String> role_ids,
|
||||
boolean is_owner,
|
||||
boolean is_admin,
|
||||
boolean can_use_music
|
||||
) {
|
||||
}
|
||||
@@ -7,7 +7,7 @@ package me.bobert.netneutrality.http.server.dto;
|
||||
*/
|
||||
|
||||
/**
|
||||
* OpenAPI schema for the {@code /guilds/members/{guildId}/{userId}/roles} response.
|
||||
* OpenAPI schema for the {@code /guilds/{guildId}/members/{userId}/roles} response.
|
||||
*/
|
||||
public record MemberRolesResponse(
|
||||
String guild_id,
|
||||
|
||||
@@ -7,7 +7,7 @@ package me.bobert.netneutrality.http.server.dto;
|
||||
*/
|
||||
|
||||
/**
|
||||
* OpenAPI schema for the {@code /guilds/members/{guildId}/{userId}} response.
|
||||
* OpenAPI schema for the {@code /guilds/{guildId}/members/{userId}} response.
|
||||
*/
|
||||
public record MemberSummaryResponse(
|
||||
String user_id,
|
||||
|
||||
@@ -7,7 +7,7 @@ package me.bobert.netneutrality.http.server.dto;
|
||||
*/
|
||||
|
||||
/**
|
||||
* OpenAPI schema for the {@code /guilds/owner/{guildId}} response.
|
||||
* OpenAPI schema for the {@code /guilds/{guildId}/owner} response.
|
||||
*/
|
||||
public record OwnerResponse(
|
||||
String guild_id,
|
||||
|
||||
@@ -9,28 +9,17 @@ package me.bobert.netneutrality.http.server.dto;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* OpenAPI schema for the {@code /music/{guildId}/queue} and {@code /music/{guildId}/search}
|
||||
* responses.
|
||||
* OpenAPI schema for the {@code /music/{guildId}/queue} response.
|
||||
*
|
||||
* <p>Nothing is ever queued directly by {@code /music/{guildId}/search}: every resolved query is
|
||||
* stashed as a pending candidate, to be confirmed via a follow-up {@code /music/{guildId}/queue}
|
||||
* call carrying {@code searchId}/{@code index} (or {@code queueAll}). Fields are nullable since only a subset is
|
||||
* populated depending on {@code status}: {@code queued} fills {@code track}/{@code queue_position} (confirmed
|
||||
* selection), {@code queued_all} fills {@code tracks}/{@code queue_position} (confirmed bulk selection; {@code
|
||||
* queue_position} is the resulting queue size), {@code pending_track} fills {@code search_id}/{@code
|
||||
* search_id_ttl_seconds}/{@code track}, {@code pending_playlist} fills {@code search_id}/{@code
|
||||
* search_id_ttl_seconds}/{@code tracks}/{@code truncated}, and {@code pending_search} fills {@code search_id}/{@code
|
||||
* search_id_ttl_seconds}/{@code candidates}.
|
||||
* {@code search_id_ttl_seconds} indicates how many seconds remain before the {@code search_id} expires.
|
||||
* <p>Fields are nullable since only a subset is populated depending on {@code status}: {@code
|
||||
* queued} fills {@code track}/{@code queue_position} (confirmed selection) and {@code queued_all}
|
||||
* fills {@code tracks}/{@code queue_position} (confirmed bulk selection; {@code queue_position} is
|
||||
* the resulting queue size).
|
||||
*/
|
||||
public record QueueResponse(
|
||||
String status,
|
||||
TrackSummaryResponse track,
|
||||
Integer queue_position,
|
||||
String search_id,
|
||||
Long search_id_ttl_seconds,
|
||||
List<TrackSummaryResponse> tracks,
|
||||
Boolean truncated,
|
||||
List<TrackSummaryResponse> candidates
|
||||
List<TrackSummaryResponse> tracks
|
||||
) {
|
||||
}
|
||||
@@ -7,7 +7,7 @@ package me.bobert.netneutrality.http.server.dto;
|
||||
*/
|
||||
|
||||
/**
|
||||
* OpenAPI schema for a role entry of the {@code /guilds/members/{guildId}/{userId}/roles}
|
||||
* OpenAPI schema for a role entry of the {@code /guilds/{guildId}/members/{userId}/roles}
|
||||
* response.
|
||||
*/
|
||||
public record RoleSummaryResponse(
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package me.bobert.netneutrality.http.server.dto;
|
||||
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
*
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* OpenAPI schema for the {@code /music/{guildId}/search} response.
|
||||
*
|
||||
* <p>Nothing is ever queued directly by {@code /music/{guildId}/search}: every resolved query is
|
||||
* stashed as a pending candidate, to be confirmed via a follow-up {@code /music/{guildId}/queue}
|
||||
* call carrying {@code searchId}/{@code index} (or {@code queueAll}). Fields are nullable since only a
|
||||
* subset is populated depending on {@code status}: {@code pending_track} fills {@code search_id}/{@code
|
||||
* search_id_ttl_seconds}/{@code track}, {@code pending_playlist} fills {@code search_id}/{@code
|
||||
* search_id_ttl_seconds}/{@code name}/{@code tracks}/{@code truncated}, and {@code pending_search} fills
|
||||
* {@code search_id}/{@code search_id_ttl_seconds}/{@code candidates}. {@code search_id_ttl_seconds}
|
||||
* indicates how many seconds remain before the {@code search_id} expires.
|
||||
*/
|
||||
public record SearchResponse(
|
||||
String status,
|
||||
String search_id,
|
||||
Long search_id_ttl_seconds,
|
||||
TrackSummaryResponse track,
|
||||
String name,
|
||||
List<TrackSummaryResponse> tracks,
|
||||
Boolean truncated,
|
||||
List<TrackSummaryResponse> candidates
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package me.bobert.netneutrality.http.server.dto;
|
||||
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
*
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
/**
|
||||
* OpenAPI schema for a {@code /guilds?user_id=} entry.
|
||||
*/
|
||||
public record UserGuildResponse(
|
||||
String id,
|
||||
String name,
|
||||
String icon,
|
||||
boolean is_owner,
|
||||
boolean is_admin,
|
||||
boolean can_use_music
|
||||
) {
|
||||
}
|
||||
+2
-2
@@ -7,8 +7,8 @@ package me.bobert.netneutrality.http.server.dto;
|
||||
*/
|
||||
|
||||
/**
|
||||
* OpenAPI schema for the {@code /guilds/voice-channels/{guildId}} and
|
||||
* {@code /guilds/voice-channels/{guildId}/{channelId}} responses.
|
||||
* OpenAPI schema for the {@code /guilds/{guildId}/voice-channels} and
|
||||
* {@code /guilds/{guildId}/voice-channels/{channelId}} responses.
|
||||
*/
|
||||
public record VoiceChannelSummaryResponse(
|
||||
String channel_id,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package me.bobert.netneutrality.http.server.dto;
|
||||
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
*
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
/**
|
||||
* OpenAPI schema for the {@code GET /music/{guildId}/vote-skip} response.
|
||||
*/
|
||||
public record VoteSkipStatusResponse(
|
||||
boolean active,
|
||||
int votes,
|
||||
int votesNeeded,
|
||||
int listeners,
|
||||
int thresholdPercent
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package me.bobert.netneutrality.music;
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
*
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
import dev.arbjerg.lavalink.client.AbstractAudioLoadResultHandler;
|
||||
import dev.arbjerg.lavalink.client.player.LoadFailed;
|
||||
import dev.arbjerg.lavalink.client.player.PlaylistLoaded;
|
||||
import dev.arbjerg.lavalink.client.player.SearchResult;
|
||||
import dev.arbjerg.lavalink.client.player.Track;
|
||||
import dev.arbjerg.lavalink.client.player.TrackLoaded;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Queues the tracks of an autoplay mix in the background. Unlike {@link TrackLoadingHandler}
|
||||
* it has no user to reply to: failures and empty results are only logged.
|
||||
*/
|
||||
public class AutoplayLoadHandler extends AbstractAudioLoadResultHandler {
|
||||
private static final Logger logger = LogManager.getLogger(AutoplayLoadHandler.class);
|
||||
|
||||
private final TrackScheduler scheduler;
|
||||
private final Track seed;
|
||||
|
||||
public AutoplayLoadHandler(TrackScheduler scheduler, Track seed) {
|
||||
this.scheduler = scheduler;
|
||||
this.seed = seed;
|
||||
}
|
||||
|
||||
private String guildId() {
|
||||
return scheduler.getGuild().getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPlaylistLoaded(PlaylistLoaded result) {
|
||||
try {
|
||||
String seedId = seed.getInfo().getIdentifier();
|
||||
List<Track> tracks = result.getTracks().stream()
|
||||
.filter(track -> !Objects.equals(track.getInfo().getIdentifier(), seedId))
|
||||
.toList();
|
||||
|
||||
if (tracks.isEmpty()) {
|
||||
logger.debug("Guild {}: autoplay mix is empty", guildId());
|
||||
return;
|
||||
}
|
||||
|
||||
TrackInfo seedInfo = scheduler.getQueueManager().peekHeadOfQueue();
|
||||
PlaylistInfo playlist = new PlaylistInfo(null, "Autoplay: " + seed.getInfo().getTitle(), tracks.size());
|
||||
scheduler.getQueueManager().queueAutoplay(
|
||||
tracks, seedInfo == null ? null : seedInfo.getRequester(), playlist);
|
||||
} finally {
|
||||
scheduler.endAutoplayLoad();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void ontrackLoaded(TrackLoaded result) {
|
||||
logger.debug("Guild {}: autoplay resolved a single track, ignoring", guildId());
|
||||
scheduler.endAutoplayLoad();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSearchResultLoaded(SearchResult result) {
|
||||
logger.debug("Guild {}: autoplay resolved search results, ignoring", guildId());
|
||||
scheduler.endAutoplayLoad();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void noMatches() {
|
||||
logger.debug("Guild {}: autoplay found no matches", guildId());
|
||||
scheduler.endAutoplayLoad();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void loadFailed(LoadFailed result) {
|
||||
logger.debug("Guild {}: autoplay load failed: {}", guildId(), result.getException().getMessage());
|
||||
scheduler.endAutoplayLoad();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package me.bobert.netneutrality.music;
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
*
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
import dev.arbjerg.lavalink.client.player.Track;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Source-specific strategy that turns the last track of the queue into a Lavalink identifier
|
||||
* whose tracks are queued next (e.g. a radio mix). Register new sources in
|
||||
* {@link AutoplayService}.
|
||||
*/
|
||||
public interface AutoplayProvider {
|
||||
|
||||
/**
|
||||
* @param seed the track autoplay is seeded from
|
||||
* @return whether this provider can build recommendations for the track's source
|
||||
*/
|
||||
boolean supports(Track seed);
|
||||
|
||||
/**
|
||||
* @param seed the track autoplay is seeded from
|
||||
* @return the identifier to load through Lavalink, or empty when none can be built
|
||||
*/
|
||||
Optional<String> radioIdentifier(Track seed);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package me.bobert.netneutrality.music;
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
*
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
import dev.arbjerg.lavalink.client.Link;
|
||||
import dev.arbjerg.lavalink.client.player.Track;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Picks the {@link AutoplayProvider} matching a seed track and loads its recommendations
|
||||
* in the background. At most one load per scheduler is in flight at a time.
|
||||
*/
|
||||
public class AutoplayService {
|
||||
private static final Logger logger = LogManager.getLogger(AutoplayService.class);
|
||||
|
||||
private final List<AutoplayProvider> providers;
|
||||
|
||||
public AutoplayService() {
|
||||
this(List.of(new YoutubeAutoplayProvider()));
|
||||
}
|
||||
|
||||
public AutoplayService(List<AutoplayProvider> providers) {
|
||||
this.providers = List.copyOf(providers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts loading recommendations for the seed track; does nothing when a load is already
|
||||
* running, no provider supports the track or the node is unavailable.
|
||||
*/
|
||||
public void loadFor(TrackScheduler scheduler, Track seed) {
|
||||
Optional<String> identifier = providers.stream()
|
||||
.filter(provider -> provider.supports(seed))
|
||||
.findFirst()
|
||||
.flatMap(provider -> provider.radioIdentifier(seed));
|
||||
|
||||
String guildId = scheduler.getGuild().getId();
|
||||
if (identifier.isEmpty()) {
|
||||
logger.debug("Guild {}: no autoplay provider for track {}", guildId, seed.getInfo().getIdentifier());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!scheduler.tryBeginAutoplayLoad()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Link link = scheduler.getMusicManager().getLink();
|
||||
if (link == null) {
|
||||
logger.debug("Guild {}: autoplay skipped, node unavailable", guildId);
|
||||
scheduler.endAutoplayLoad();
|
||||
return;
|
||||
}
|
||||
|
||||
link.loadItem(identifier.get()).subscribe(
|
||||
new AutoplayLoadHandler(scheduler, seed),
|
||||
error -> {
|
||||
logger.debug("Guild {}: autoplay load failed: {}", guildId, error.getMessage());
|
||||
scheduler.endAutoplayLoad();
|
||||
}
|
||||
);
|
||||
} catch (RuntimeException e) {
|
||||
logger.debug("Guild {}: autoplay load could not start: {}", guildId, e.getMessage());
|
||||
scheduler.endAutoplayLoad();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,10 @@ import net.dv8tion.jda.api.entities.channel.middleman.AudioChannel;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import me.bobert.netneutrality.events.EventPublisher;
|
||||
import me.bobert.netneutrality.events.MusicEvent;
|
||||
import me.bobert.netneutrality.events.NoopEventPublisher;
|
||||
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
@@ -49,8 +53,15 @@ public class MusicManager implements MusicManagerCallback {
|
||||
private final GuildService guildService;
|
||||
private final TaskManager taskManager;
|
||||
private final VoteSkipService voteSkipService;
|
||||
private final EventPublisher events;
|
||||
|
||||
MusicManager(Guild guild, LavalinkClient client, GuildService guildService, TaskManager taskManager) {
|
||||
this(guild, client, guildService, taskManager, NoopEventPublisher.INSTANCE);
|
||||
}
|
||||
|
||||
MusicManager(Guild guild, LavalinkClient client, GuildService guildService, TaskManager taskManager,
|
||||
EventPublisher events) {
|
||||
this.events = events;
|
||||
this.guild = guild;
|
||||
this.client = client;
|
||||
this.scheduler = new TrackScheduler(this);
|
||||
@@ -88,6 +99,10 @@ public class MusicManager implements MusicManagerCallback {
|
||||
return voteSkipService;
|
||||
}
|
||||
|
||||
public EventPublisher getEvents() {
|
||||
return events;
|
||||
}
|
||||
|
||||
public Guild getGuild() {
|
||||
return guild;
|
||||
}
|
||||
@@ -324,6 +339,7 @@ public class MusicManager implements MusicManagerCallback {
|
||||
*/
|
||||
public synchronized void pauseTrack() {
|
||||
updatePlayer("pause track", builder -> builder.setPaused(true));
|
||||
events.publish(MusicEvent.of(MusicEvent.Type.PLAYER_PAUSED, guild.getId(), null));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -331,6 +347,7 @@ public class MusicManager implements MusicManagerCallback {
|
||||
*/
|
||||
public synchronized void unpauseTrack() {
|
||||
updatePlayer("unpause track", builder -> builder.setPaused(false));
|
||||
events.publish(MusicEvent.of(MusicEvent.Type.PLAYER_RESUMED, guild.getId(), null));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -353,7 +370,22 @@ public class MusicManager implements MusicManagerCallback {
|
||||
*/
|
||||
public synchronized QueueActionResult voteSkip(Member member) {
|
||||
int threshold = parseVoteSkipThreshold(guildService);
|
||||
return voteSkipService.voteSkip(member, guild.getId(), getQueueManager(), getConnectedChannel(), isCurrentlyPlaying(), threshold);
|
||||
TrackInfo before = getQueueManager().peekHeadOfQueue();
|
||||
QueueActionResult result = voteSkipService.voteSkip(member, guild.getId(), getQueueManager(), getConnectedChannel(), isCurrentlyPlaying(), threshold);
|
||||
if (before != null && before == getQueueManager().peekHeadOfQueue()) {
|
||||
events.publish(MusicEvent.of(MusicEvent.Type.VOTE_SKIP_UPDATED, guild.getId(), java.util.Map.of(
|
||||
"userId", member.getId(),
|
||||
"votes", before.getSkipCount(),
|
||||
"threshold", threshold)));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the configured vote-skip threshold as a percentage of connected listeners.
|
||||
*/
|
||||
public int getVoteSkipThreshold() {
|
||||
return parseVoteSkipThreshold(guildService);
|
||||
}
|
||||
|
||||
private static int parseVoteSkipThreshold(GuildService guildService) {
|
||||
@@ -366,6 +398,7 @@ public class MusicManager implements MusicManagerCallback {
|
||||
|
||||
public void setVolume(int volume) {
|
||||
updatePlayer("set volume", builder -> builder.setVolume(volume));
|
||||
events.publish(MusicEvent.of(MusicEvent.Type.VOLUME_CHANGED, guild.getId(), java.util.Map.of("volume", volume)));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -387,6 +420,7 @@ public class MusicManager implements MusicManagerCallback {
|
||||
*/
|
||||
public void seek(long positionMs) {
|
||||
updatePlayer("seek to " + positionMs, builder -> builder.setPosition(positionMs));
|
||||
events.publish(MusicEvent.of(MusicEvent.Type.PLAYER_SEEKED, guild.getId(), java.util.Map.of("position", positionMs)));
|
||||
}
|
||||
|
||||
public int getQueueSize() {
|
||||
|
||||
@@ -10,6 +10,7 @@ import dev.arbjerg.lavalink.client.Helpers;
|
||||
import dev.arbjerg.lavalink.client.LavalinkClient;
|
||||
import dev.arbjerg.lavalink.client.LavalinkNode;
|
||||
import dev.arbjerg.lavalink.client.NodeOptions;
|
||||
import dev.arbjerg.lavalink.client.event.PlayerUpdateEvent;
|
||||
import dev.arbjerg.lavalink.client.event.ReadyEvent;
|
||||
import dev.arbjerg.lavalink.client.event.TrackEndEvent;
|
||||
import dev.arbjerg.lavalink.client.event.TrackExceptionEvent;
|
||||
@@ -34,6 +35,8 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Consumer;
|
||||
import me.bobert.netneutrality.events.EventPublisher;
|
||||
import me.bobert.netneutrality.events.NoopEventPublisher;
|
||||
|
||||
/**
|
||||
* Instance-based {@link MusicService} owning the shared {@link LavalinkClient} and the
|
||||
@@ -54,6 +57,7 @@ public class MusicServiceImpl implements MusicService {
|
||||
private final GuildServiceProvider guildServices;
|
||||
private final TaskManager taskManager;
|
||||
private final ExecutorProvider executors;
|
||||
private final EventPublisher events;
|
||||
private final Map<String, MusicManager> managers = new ConcurrentHashMap<>();
|
||||
private final List<Disposable> subscriptions = new ArrayList<>();
|
||||
private final LavalinkClient client;
|
||||
@@ -61,6 +65,12 @@ public class MusicServiceImpl implements MusicService {
|
||||
|
||||
public MusicServiceImpl(SettingsProvider settings, GuildServiceProvider guildServices, TaskManager taskManager,
|
||||
ExecutorProvider executors) {
|
||||
this(settings, guildServices, taskManager, executors, NoopEventPublisher.INSTANCE);
|
||||
}
|
||||
|
||||
public MusicServiceImpl(SettingsProvider settings, GuildServiceProvider guildServices, TaskManager taskManager,
|
||||
ExecutorProvider executors, EventPublisher events) {
|
||||
this.events = events;
|
||||
this.settings = settings;
|
||||
this.guildServices = guildServices;
|
||||
this.taskManager = taskManager;
|
||||
@@ -70,7 +80,9 @@ public class MusicServiceImpl implements MusicService {
|
||||
|
||||
@Override
|
||||
public int priority() {
|
||||
return 40;
|
||||
// Must be above ShardService (60): shutdown runs in reverse order, and disconnecting players
|
||||
// needs JDA's direct audio controller, so music has to stop before the shards do.
|
||||
return 62;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -105,7 +117,7 @@ public class MusicServiceImpl implements MusicService {
|
||||
public MusicManager get(Guild guild) {
|
||||
return managers.computeIfAbsent(
|
||||
guild.getId(),
|
||||
id -> new MusicManager(guild, client, guildServices.get(id), taskManager)
|
||||
id -> new MusicManager(guild, client, guildServices.get(id), taskManager, events)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -200,6 +212,11 @@ public class MusicServiceImpl implements MusicService {
|
||||
subscriptions.add(client.on(TrackStuckEvent.class)
|
||||
.subscribe(event -> dispatch(event.getGuildId(), scheduler -> scheduler.onTrackStuck(event.getTrack(), event.getThresholdMs()))));
|
||||
|
||||
subscriptions.add(client.on(PlayerUpdateEvent.class)
|
||||
.subscribe(event -> dispatch(event.getGuildId(), scheduler -> scheduler.onPlayerUpdate(
|
||||
event.getState().getTime(), event.getState().getPosition(),
|
||||
event.getState().getConnected(), event.getState().getPing()))));
|
||||
|
||||
subscriptions.add(client.on(WebSocketClosedEvent.class)
|
||||
.subscribe(event -> dispatch(event.getGuildId(), scheduler -> scheduler.onWebSocketClosed(event.getCode(), event.getReason(), event.getByRemote()))));
|
||||
}
|
||||
|
||||
@@ -46,10 +46,17 @@ public final class PendingSearchResults {
|
||||
* @return the generated search id
|
||||
*/
|
||||
public static String put(String guildId, List<Track> candidates) {
|
||||
return put(guildId, candidates, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the given candidates together with the playlist they came from (may be null).
|
||||
*/
|
||||
public static String put(String guildId, List<Track> candidates, PlaylistInfo playlist) {
|
||||
sweep();
|
||||
|
||||
String searchId = UUID.randomUUID().toString();
|
||||
entries.put(searchId, new Entry(guildId, candidates, Instant.now().plus(TTL)));
|
||||
entries.put(searchId, new Entry(guildId, candidates, playlist, Instant.now().plus(TTL)));
|
||||
return searchId;
|
||||
}
|
||||
|
||||
@@ -62,6 +69,14 @@ public final class PendingSearchResults {
|
||||
* belongs to a different guild
|
||||
*/
|
||||
public static List<Track> consume(String searchId, String guildId) {
|
||||
Consumed consumed = consumeWithPlaylist(searchId, guildId);
|
||||
return consumed == null ? null : consumed.candidates();
|
||||
}
|
||||
|
||||
/**
|
||||
* Like {@link #consume(String, String)} but also returns the originating playlist, if any.
|
||||
*/
|
||||
public static Consumed consumeWithPlaylist(String searchId, String guildId) {
|
||||
sweep();
|
||||
|
||||
Entry entry = entries.remove(searchId);
|
||||
@@ -69,7 +84,10 @@ public final class PendingSearchResults {
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.candidates();
|
||||
return new Consumed(entry.candidates(), entry.playlist());
|
||||
}
|
||||
|
||||
public record Consumed(List<Track> candidates, PlaylistInfo playlist) {
|
||||
}
|
||||
|
||||
private static void sweep() {
|
||||
@@ -77,6 +95,6 @@ public final class PendingSearchResults {
|
||||
entries.entrySet().removeIf(entry -> now.isAfter(entry.getValue().expiresAt()));
|
||||
}
|
||||
|
||||
private record Entry(String guildId, List<Track> candidates, Instant expiresAt) {
|
||||
private record Entry(String guildId, List<Track> candidates, PlaylistInfo playlist, Instant expiresAt) {
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ public sealed interface PlayOutcome {
|
||||
record PendingTrack(String searchId, Track track) implements PlayOutcome {
|
||||
}
|
||||
|
||||
record PendingPlaylist(String searchId, List<Track> tracks, boolean truncated) implements PlayOutcome {
|
||||
record PendingPlaylist(String searchId, String identifier, String name, List<Track> tracks, boolean truncated) implements PlayOutcome {
|
||||
}
|
||||
|
||||
record PendingSearch(String searchId, List<Track> candidates) implements PlayOutcome {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package me.bobert.netneutrality.music;
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
*
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
/**
|
||||
* Describes the playlist a queued track originated from.
|
||||
*
|
||||
* @param identifier the playlist identifier, or null when unknown
|
||||
* @param name the playlist display name
|
||||
* @param trackCount number of tracks in the playlist
|
||||
*/
|
||||
public record PlaylistInfo(String identifier, String name, int trackCount) {
|
||||
}
|
||||
@@ -6,6 +6,7 @@ package me.bobert.netneutrality.music;
|
||||
*/
|
||||
|
||||
import dev.arbjerg.lavalink.client.player.Track;
|
||||
import me.bobert.netneutrality.events.MusicEvent;
|
||||
import net.dv8tion.jda.api.entities.Guild;
|
||||
import net.dv8tion.jda.api.entities.Member;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
@@ -14,7 +15,9 @@ import org.apache.logging.log4j.Logger;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Deque;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.LinkedBlockingDeque;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
@@ -61,10 +64,55 @@ public class QueueManager {
|
||||
|
||||
public void removeHeadOfQueue() {
|
||||
synchronized (queue) {
|
||||
queue.poll();
|
||||
TrackInfo head = queue.poll();
|
||||
notifyQueueUpdated("removed", head == null ? List.of() : List.of(item(0, head)));
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, Object> item(int index, TrackInfo info) {
|
||||
Map<String, Object> item = new HashMap<>();
|
||||
item.put("index", index);
|
||||
item.put("identifier", info.getTrack().getInfo().getIdentifier());
|
||||
item.put("title", info.getTrack().getInfo().getTitle());
|
||||
item.put("author", info.getTrack().getInfo().getAuthor());
|
||||
item.put("duration_ms", info.getTrack().getInfo().getLength());
|
||||
item.put("uri", info.getTrack().getInfo().getUri());
|
||||
item.put("artwork_url", info.getTrack().getInfo().getArtworkUrl());
|
||||
item.put("requester_id", info.getRequester() == null ? null : info.getRequester().getId());
|
||||
item.put("playlist", playlistMap(info.getPlaylist()));
|
||||
return item;
|
||||
}
|
||||
|
||||
private static Map<String, Object> playlistMap(PlaylistInfo playlist) {
|
||||
if (playlist == null) {
|
||||
return null;
|
||||
}
|
||||
Map<String, Object> map = new HashMap<>();
|
||||
map.put("name", playlist.name());
|
||||
map.put("identifier", playlist.identifier());
|
||||
map.put("track_count", playlist.trackCount());
|
||||
return map;
|
||||
}
|
||||
|
||||
private void notifyQueueUpdated(String change, List<Map<String, Object>> items) {
|
||||
notifyQueueUpdated(change, items, null);
|
||||
}
|
||||
|
||||
private void notifyQueueUpdated(String change, List<Map<String, Object>> items, PlaylistInfo playlist) {
|
||||
MusicManager musicManager = scheduler.getMusicManager();
|
||||
if (musicManager == null || musicManager.getEvents() == null) {
|
||||
return;
|
||||
}
|
||||
Map<String, Object> payload = new HashMap<>();
|
||||
payload.put("size", queue.size());
|
||||
payload.put("change", change);
|
||||
payload.put("items", items);
|
||||
if (playlist != null) {
|
||||
payload.put("playlist", playlistMap(playlist));
|
||||
}
|
||||
musicManager.getEvents().publish(MusicEvent.of(MusicEvent.Type.QUEUE_UPDATED, guild.getId(), payload));
|
||||
}
|
||||
|
||||
public boolean isEmpty() {
|
||||
synchronized (queue) {
|
||||
return queue.isEmpty();
|
||||
@@ -79,6 +127,7 @@ public class QueueManager {
|
||||
}
|
||||
|
||||
queue.clear();
|
||||
notifyQueueUpdated("cleared", List.of());
|
||||
scheduler.getCallback().onQueueEmpty();
|
||||
return QueueActionResult.success(String.format("Removed %d track%s from the queue", queueSize, (queueSize > 1 ? "s" : "")));
|
||||
}
|
||||
@@ -96,7 +145,16 @@ public class QueueManager {
|
||||
}
|
||||
|
||||
TrackInfo first = queue.peek();
|
||||
List<Map<String, Object>> removed = new ArrayList<>();
|
||||
int position = 0;
|
||||
for (TrackInfo trackInfo : queue) {
|
||||
if (member.equals(trackInfo.getRequester())) {
|
||||
removed.add(item(position, trackInfo));
|
||||
}
|
||||
position++;
|
||||
}
|
||||
queue.removeIf(trackInfo -> member.equals(trackInfo.getRequester()));
|
||||
notifyQueueUpdated("removed", removed);
|
||||
|
||||
if (first != null && member.equals(first.getRequester())) {
|
||||
skip(1);
|
||||
@@ -116,7 +174,83 @@ public class QueueManager {
|
||||
}
|
||||
|
||||
public void queue(List<Track> tracks, Member member) {
|
||||
tracks.forEach(track -> queue(track, member));
|
||||
queue(tracks, member, null);
|
||||
}
|
||||
|
||||
public void queue(List<Track> tracks, Member member, PlaylistInfo playlist) {
|
||||
if (playlist == null) {
|
||||
tracks.forEach(track -> queue(track, member));
|
||||
return;
|
||||
}
|
||||
|
||||
synchronized (queue) {
|
||||
boolean idle = scheduler.getCurrentTrack() == null;
|
||||
List<TrackInfo> added = new ArrayList<>();
|
||||
|
||||
for (Track track : tracks) {
|
||||
TrackInfo info = new TrackInfo(track, member, playlist);
|
||||
if (queue.contains(info)) {
|
||||
continue;
|
||||
}
|
||||
queue.add(info);
|
||||
added.add(info);
|
||||
}
|
||||
|
||||
if (added.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
PlaylistInfo actual = new PlaylistInfo(playlist.identifier(), playlist.name(), added.size());
|
||||
List<TrackInfo> all = new ArrayList<>(queue);
|
||||
List<Map<String, Object>> items = new ArrayList<>();
|
||||
for (TrackInfo info : added) {
|
||||
items.add(item(all.indexOf(info), info));
|
||||
}
|
||||
notifyQueueUpdated("playlist_added", items, actual);
|
||||
|
||||
if (idle) {
|
||||
TrackInfo head = queue.peek();
|
||||
if (head != null) {
|
||||
startTrack(head.getTrack(), true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends autoplay tracks behind the playing one. The result of a background load is only
|
||||
* applied when autoplay is still enabled and the playing track is still the last one;
|
||||
* otherwise it is discarded. Never starts playback.
|
||||
*/
|
||||
public void queueAutoplay(List<Track> tracks, Member member, PlaylistInfo playlist) {
|
||||
synchronized (queue) {
|
||||
if (!scheduler.isAutoplayEnabled() || queue.size() > 1 || scheduler.getCurrentTrack() == null) {
|
||||
logger.debug("Guild {}: discarding autoplay result", guild.getId());
|
||||
return;
|
||||
}
|
||||
|
||||
List<TrackInfo> added = new ArrayList<>();
|
||||
for (Track track : tracks) {
|
||||
TrackInfo info = new TrackInfo(track, member, playlist);
|
||||
if (queue.contains(info)) {
|
||||
continue;
|
||||
}
|
||||
queue.add(info);
|
||||
added.add(info);
|
||||
}
|
||||
|
||||
if (added.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
PlaylistInfo actual = new PlaylistInfo(playlist.identifier(), playlist.name(), added.size());
|
||||
List<TrackInfo> all = new ArrayList<>(queue);
|
||||
List<Map<String, Object>> items = new ArrayList<>();
|
||||
for (TrackInfo info : added) {
|
||||
items.add(item(all.indexOf(info), info));
|
||||
}
|
||||
notifyQueueUpdated("playlist_added", items, actual);
|
||||
}
|
||||
}
|
||||
|
||||
public void queue(Track track, Member member) {
|
||||
@@ -134,6 +268,7 @@ public class QueueManager {
|
||||
} else {
|
||||
queue.add(info);
|
||||
}
|
||||
notifyQueueUpdated("added", List.of(item(queue.peekFirst() == info ? 0 : queue.size() - 1, info)));
|
||||
|
||||
if (idle) {
|
||||
TrackInfo head = queue.peek();
|
||||
@@ -181,7 +316,8 @@ public class QueueManager {
|
||||
|
||||
public void startNextTrack() {
|
||||
synchronized (queue) {
|
||||
queue.poll();
|
||||
TrackInfo polled = queue.poll();
|
||||
notifyQueueUpdated("removed", polled == null ? List.of() : List.of(item(0, polled)));
|
||||
TrackInfo next = queue.peek();
|
||||
|
||||
if (next != null) {
|
||||
@@ -243,6 +379,12 @@ public class QueueManager {
|
||||
queue.clear();
|
||||
queue.add(current);
|
||||
queue.addAll(newQueue);
|
||||
notifyQueueUpdated("shuffled", List.of());
|
||||
MusicManager musicManager = scheduler.getMusicManager();
|
||||
if (musicManager != null && musicManager.getEvents() != null) {
|
||||
musicManager.getEvents().publish(MusicEvent.of(MusicEvent.Type.QUEUE_SHUFFLED, guild.getId(),
|
||||
Map.of("size", queue.size())));
|
||||
}
|
||||
|
||||
return QueueActionResult.success("The queue is now shuffled.");
|
||||
}
|
||||
@@ -262,10 +404,11 @@ public class QueueManager {
|
||||
|
||||
if (index > 1) {
|
||||
List<TrackInfo> newQueue = new ArrayList<>(queue);
|
||||
newQueue.remove(index - 1);
|
||||
TrackInfo removedInfo = newQueue.remove(index - 1);
|
||||
|
||||
queue.clear();
|
||||
queue.addAll(newQueue);
|
||||
notifyQueueUpdated("removed", List.of(item(index - 1, removedInfo)));
|
||||
|
||||
return QueueActionResult.success(String.format("Removed track #%d from the queue", index));
|
||||
}
|
||||
@@ -309,6 +452,10 @@ public class QueueManager {
|
||||
|
||||
queue.clear();
|
||||
queue.addAll(newQueue);
|
||||
Map<String, Object> movedItem = item(toIndex - 1, moved);
|
||||
movedItem.put("fromIndex", fromIndex - 1);
|
||||
movedItem.put("toIndex", toIndex - 1);
|
||||
notifyQueueUpdated("moved", List.of(movedItem));
|
||||
|
||||
return QueueActionResult.success(String.format("Moved track #%d to position #%d", fromIndex, toIndex));
|
||||
}
|
||||
|
||||
@@ -33,7 +33,6 @@ import java.util.concurrent.CompletableFuture;
|
||||
*/
|
||||
public class SearchResultHandler extends AbstractAudioLoadResultHandler {
|
||||
private static final Logger logger = LogManager.getLogger(SearchResultHandler.class);
|
||||
private static final int SEARCH_RESULTS_LIMIT = 5;
|
||||
|
||||
private final String identifier;
|
||||
private final Guild guild;
|
||||
@@ -65,8 +64,9 @@ public class SearchResultHandler extends AbstractAudioLoadResultHandler {
|
||||
boolean truncated = tracks.size() > maxPlaylistSize;
|
||||
List<Track> candidates = truncated ? tracks.subList(0, maxPlaylistSize) : tracks;
|
||||
|
||||
String searchId = PendingSearchResults.put(guild.getId(), candidates);
|
||||
future.complete(new PlayOutcome.PendingPlaylist(searchId, candidates, truncated));
|
||||
String searchId = PendingSearchResults.put(guild.getId(), candidates,
|
||||
new PlaylistInfo(identifier, result.getInfo().getName(), candidates.size()));
|
||||
future.complete(new PlayOutcome.PendingPlaylist(searchId, identifier, result.getInfo().getName(), candidates, truncated));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -79,7 +79,7 @@ public class SearchResultHandler extends AbstractAudioLoadResultHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
List<Track> candidates = tracks.stream().limit(SEARCH_RESULTS_LIMIT).toList();
|
||||
List<Track> candidates = tracks.stream().toList();
|
||||
String searchId = PendingSearchResults.put(guild.getId(), candidates);
|
||||
future.complete(new PlayOutcome.PendingSearch(searchId, candidates));
|
||||
}
|
||||
|
||||
@@ -21,12 +21,23 @@ public class TrackInfo {
|
||||
private final Set<String> skips;
|
||||
private static final Logger logger = LogManager.getLogger(TrackInfo.class);
|
||||
|
||||
private final PlaylistInfo playlist;
|
||||
|
||||
public TrackInfo(Track track, Member member) {
|
||||
this(track, member, null);
|
||||
}
|
||||
|
||||
public TrackInfo(Track track, Member member, PlaylistInfo playlist) {
|
||||
this.track = track;
|
||||
this.member = member;
|
||||
this.playlist = playlist;
|
||||
this.skips = new HashSet<>();
|
||||
}
|
||||
|
||||
public PlaylistInfo getPlaylist() {
|
||||
return playlist;
|
||||
}
|
||||
|
||||
public Track getTrack() {
|
||||
return track;
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ public class TrackLoadingHandler extends AbstractAudioLoadResultHandler {
|
||||
public void onPlaylistLoaded(PlaylistLoaded result) {
|
||||
logger.debug("Guild {}: playlist loaded: {}", guild.getId(), identifier);
|
||||
|
||||
new LoadPlaylistTask(result.getInfo().getName(), result.getTracks(), false, type, manager).insert(context);
|
||||
new LoadPlaylistTask(identifier, result.getInfo().getName(), result.getTracks(), false, type, manager).insert(context);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,7 +84,7 @@ public class TrackLoadingHandler extends AbstractAudioLoadResultHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
new LoadPlaylistTask("Search results", tracks, true, type, manager).insert(context);
|
||||
new LoadPlaylistTask(identifier, "Search results", tracks, true, type, manager).insert(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -11,12 +11,15 @@ import dev.arbjerg.lavalink.protocol.v4.Message.EmittedEvent.TrackEndEvent;
|
||||
import me.bobert.netneutrality.context.MusicChannelContext;
|
||||
import me.bobert.netneutrality.core.LogHandler;
|
||||
import me.bobert.netneutrality.core.context.Context;
|
||||
import me.bobert.netneutrality.events.MusicEvent;
|
||||
import me.bobert.netneutrality.exceptions.ContextException;
|
||||
import net.dv8tion.jda.api.entities.Guild;
|
||||
import net.dv8tion.jda.api.entities.Member;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* Sink for the track lifecycle events emitted by the Lavalink node.
|
||||
*
|
||||
@@ -32,8 +35,19 @@ public class TrackScheduler {
|
||||
private volatile Context context;
|
||||
private volatile Track currentTrack;
|
||||
private final StuckTrackRecoveryHandler stuckTrackRecoveryHandler;
|
||||
private static final long POSITION_THROTTLE_MS = 5000;
|
||||
private volatile long lastPositionSent = Long.MIN_VALUE;
|
||||
private static final long AUTOPLAY_REMAINING_MS = 30_000;
|
||||
private final AutoplayService autoplayService;
|
||||
private volatile boolean autoplayEnabled;
|
||||
private final AtomicBoolean autoplayLoading = new AtomicBoolean();
|
||||
|
||||
public TrackScheduler(MusicManager manager) {
|
||||
this(manager, new AutoplayService());
|
||||
}
|
||||
|
||||
public TrackScheduler(MusicManager manager, AutoplayService autoplayService) {
|
||||
this.autoplayService = autoplayService;
|
||||
this.manager = manager;
|
||||
this.callback = manager;
|
||||
this.guild = manager.getGuild();
|
||||
@@ -73,6 +87,37 @@ public class TrackScheduler {
|
||||
this.currentTrack = track;
|
||||
}
|
||||
|
||||
public boolean isAutoplayEnabled() {
|
||||
return autoplayEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables or disables autoplay. Recommendations are only loaded once the last track of the
|
||||
* queue is close to its end, so users still get a chance to queue their own tracks.
|
||||
*/
|
||||
public void setAutoplayEnabled(boolean enabled) {
|
||||
this.autoplayEnabled = enabled;
|
||||
}
|
||||
|
||||
boolean tryBeginAutoplayLoad() {
|
||||
return autoplayLoading.compareAndSet(false, true);
|
||||
}
|
||||
|
||||
void endAutoplayLoad() {
|
||||
autoplayLoading.set(false);
|
||||
}
|
||||
|
||||
private void maybeAutoplay(long position) {
|
||||
Track track = currentTrack;
|
||||
if (!autoplayEnabled || track == null || queueManager.getQueueSize() > 1) {
|
||||
return;
|
||||
}
|
||||
long length = track.getInfo().getLength();
|
||||
if (length > 0 && length - position <= AUTOPLAY_REMAINING_MS) {
|
||||
autoplayService.loadFor(this, track);
|
||||
}
|
||||
}
|
||||
|
||||
public Member getDj() {
|
||||
Context ctx = context;
|
||||
if (ctx != null) {
|
||||
@@ -102,6 +147,7 @@ public class TrackScheduler {
|
||||
logger.debug("Guild {}: starting track: {}", guild.getId(), track.getInfo().getIdentifier());
|
||||
currentTrack = track;
|
||||
stuckTrackRecoveryHandler.reset();
|
||||
publish(MusicEvent.Type.TRACK_START, track, null);
|
||||
}
|
||||
|
||||
public void onTrackEnd(Track track, TrackEndEvent.AudioTrackEndReason endReason) {
|
||||
@@ -110,6 +156,7 @@ public class TrackScheduler {
|
||||
if (endReason != TrackEndEvent.AudioTrackEndReason.REPLACED) {
|
||||
currentTrack = null;
|
||||
}
|
||||
publish(MusicEvent.Type.TRACK_END, track, endReason.name());
|
||||
|
||||
if (endReason == TrackEndEvent.AudioTrackEndReason.LOAD_FAILED) {
|
||||
logger.debug("Guild {}: failed loading track: {}", guild.getId(), track.getInfo().getIdentifier());
|
||||
@@ -134,9 +181,39 @@ public class TrackScheduler {
|
||||
// clone of this back to your queue
|
||||
}
|
||||
|
||||
private static java.util.Map<String, Object> trackPayload(Track track) {
|
||||
java.util.Map<String, Object> payload = new java.util.HashMap<>();
|
||||
payload.put("identifier", track.getInfo().getIdentifier());
|
||||
payload.put("title", track.getInfo().getTitle());
|
||||
payload.put("author", track.getInfo().getAuthor());
|
||||
payload.put("length", track.getInfo().getLength());
|
||||
payload.put("uri", track.getInfo().getUri());
|
||||
payload.put("artworkUrl", track.getInfo().getArtworkUrl());
|
||||
return payload;
|
||||
}
|
||||
|
||||
private void publish(MusicEvent.Type type, Track track, String reason) {
|
||||
if (manager.getEvents() == null) {
|
||||
return;
|
||||
}
|
||||
java.util.Map<String, Object> payload = trackPayload(track);
|
||||
if (reason != null) {
|
||||
payload.put("reason", reason);
|
||||
}
|
||||
manager.getEvents().publish(MusicEvent.of(type, guild.getId(), payload));
|
||||
}
|
||||
|
||||
public void onTrackException(Track track, TrackException exception) {
|
||||
logger.error("Guild {}: exception while playing track: {}", guild.getId(), track.getInfo().getIdentifier());
|
||||
|
||||
if (manager.getEvents() != null) {
|
||||
java.util.Map<String, Object> payload = new java.util.HashMap<>();
|
||||
payload.put("track", trackPayload(track));
|
||||
payload.put("message", exception.getMessage());
|
||||
payload.put("severity", exception.getSeverity() != null ? exception.getSeverity().name() : null);
|
||||
manager.getEvents().publish(MusicEvent.of(MusicEvent.Type.TRACK_ERROR, guild.getId(), payload));
|
||||
}
|
||||
|
||||
LogHandler.log(new ContextException(
|
||||
String.format("Error while playing track: %s", track.getInfo().getTitle()),
|
||||
getContext(),
|
||||
@@ -146,9 +223,37 @@ public class TrackScheduler {
|
||||
|
||||
public void onTrackStuck(Track track, long thresholdMs) {
|
||||
logger.error("Guild {}: currently playing track is stuck: {} (threshold {}ms)", guild.getId(), track.getInfo().getIdentifier(), thresholdMs);
|
||||
if (manager.getEvents() != null) {
|
||||
java.util.Map<String, Object> payload = new java.util.HashMap<>();
|
||||
payload.put("track", trackPayload(track));
|
||||
payload.put("thresholdMs", thresholdMs);
|
||||
manager.getEvents().publish(MusicEvent.of(MusicEvent.Type.TRACK_STUCK, guild.getId(), payload));
|
||||
}
|
||||
stuckTrackRecoveryHandler.handle(track, context);
|
||||
}
|
||||
|
||||
public void onPlayerUpdate(long timestamp, long position, boolean connected, long ping) {
|
||||
onPlayerUpdate(timestamp, position, connected, ping, System.currentTimeMillis());
|
||||
}
|
||||
|
||||
void onPlayerUpdate(long timestamp, long position, boolean connected, long ping, long now) {
|
||||
maybeAutoplay(position);
|
||||
if (manager.getEvents() == null || currentTrack == null) {
|
||||
return;
|
||||
}
|
||||
if (lastPositionSent != Long.MIN_VALUE && now - lastPositionSent < POSITION_THROTTLE_MS) {
|
||||
return;
|
||||
}
|
||||
lastPositionSent = now;
|
||||
|
||||
java.util.Map<String, Object> payload = new java.util.HashMap<>();
|
||||
payload.put("position", position);
|
||||
payload.put("timestamp", timestamp);
|
||||
payload.put("connected", connected);
|
||||
payload.put("ping", ping);
|
||||
manager.getEvents().publish(MusicEvent.of(MusicEvent.Type.PLAYER_POSITION, guild.getId(), payload));
|
||||
}
|
||||
|
||||
public void onWebSocketClosed(int code, String reason, boolean byRemote) {
|
||||
logger.warn("Guild {}: voice websocket closed with code {} ({}), by remote: {}", guild.getId(), code, reason, byRemote);
|
||||
currentTrack = null;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package me.bobert.netneutrality.music;
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
*
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
import dev.arbjerg.lavalink.client.player.Track;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Autoplay through the YouTube radio mix of the seed track.
|
||||
*/
|
||||
public class YoutubeAutoplayProvider implements AutoplayProvider {
|
||||
private static final Pattern VIDEO_ID = Pattern.compile("(?:[?&]v=|youtu\\.be/)([-_a-zA-Z0-9]{11})");
|
||||
|
||||
@Override
|
||||
public boolean supports(Track seed) {
|
||||
return videoId(seed).isPresent();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> radioIdentifier(Track seed) {
|
||||
return videoId(seed).map(id -> "https://www.youtube.com/watch?v=" + id + "&list=RD" + id);
|
||||
}
|
||||
|
||||
private static Optional<String> videoId(Track seed) {
|
||||
if ("youtube".equalsIgnoreCase(seed.getInfo().getSourceName())) {
|
||||
String identifier = seed.getInfo().getIdentifier();
|
||||
return identifier == null || identifier.isBlank() ? Optional.empty() : Optional.of(identifier);
|
||||
}
|
||||
|
||||
String uri = seed.getInfo().getUri();
|
||||
if (uri != null && SourceFinder.getSource(uri) == SourceFinder.AudioSourceType.Youtube) {
|
||||
Matcher matcher = VIDEO_ID.matcher(uri);
|
||||
if (matcher.find()) {
|
||||
return Optional.of(matcher.group(1));
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package me.bobert.netneutrality.shard;
|
||||
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
*
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
import net.dv8tion.jda.api.JDA;
|
||||
import net.dv8tion.jda.api.entities.Guild;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Read-only, shard-aggregating view of the Discord gateway connections.
|
||||
*/
|
||||
public interface ShardProvider {
|
||||
|
||||
/**
|
||||
* @return {@code true} once every shard has finished starting up and until shutdown
|
||||
*/
|
||||
boolean isReady();
|
||||
|
||||
/**
|
||||
* Looks a guild up on whichever shard owns it.
|
||||
*
|
||||
* @return the guild, or {@code null} if no shard knows it
|
||||
*/
|
||||
Guild getGuildById(String id);
|
||||
|
||||
/**
|
||||
* @return the guilds of all shards
|
||||
*/
|
||||
List<Guild> getGuilds();
|
||||
|
||||
int getShardCount();
|
||||
|
||||
/**
|
||||
* @return the mean gateway ping across shards, or {@code -1} if unknown
|
||||
*/
|
||||
double getAveragePing();
|
||||
|
||||
/**
|
||||
* @return {@code CONNECTED} when every shard is connected, otherwise the most degraded status
|
||||
*/
|
||||
JDA.Status getWorstStatus();
|
||||
|
||||
/**
|
||||
* @return an arbitrary shard (for global operations such as command registration), or {@code null} if not ready
|
||||
*/
|
||||
JDA getAnyShard();
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package me.bobert.netneutrality.shard;
|
||||
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
*
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
import dev.arbjerg.lavalink.libraries.jda.JDAVoiceUpdateListener;
|
||||
import me.bobert.netneutrality.bootstrap.Lifecycle;
|
||||
import me.bobert.netneutrality.core.LogHandler;
|
||||
import me.bobert.netneutrality.music.MusicService;
|
||||
import me.bobert.netneutrality.utils.SettingsProvider;
|
||||
import net.dv8tion.jda.api.JDA;
|
||||
import net.dv8tion.jda.api.entities.Activity;
|
||||
import net.dv8tion.jda.api.entities.Guild;
|
||||
import net.dv8tion.jda.api.exceptions.InvalidTokenException;
|
||||
import net.dv8tion.jda.api.hooks.EventListener;
|
||||
import net.dv8tion.jda.api.requests.GatewayIntent;
|
||||
import net.dv8tion.jda.api.sharding.DefaultShardManagerBuilder;
|
||||
import net.dv8tion.jda.api.sharding.ShardManager;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Owns the JDA {@link ShardManager}. The shard count is Discord's recommendation unless
|
||||
* {@code NETNEUTRALITY_SHARD_TOTAL} overrides it.
|
||||
*/
|
||||
public final class ShardService implements Lifecycle, ShardProvider {
|
||||
public static final String SHARD_TOTAL_KEY = "NETNEUTRALITY_SHARD_TOTAL";
|
||||
|
||||
private static final Logger logger = LogManager.getLogger(ShardService.class);
|
||||
|
||||
private final SettingsProvider settings;
|
||||
private final MusicService music;
|
||||
private final EventListener eventsHandler;
|
||||
private volatile ShardManager shardManager;
|
||||
private volatile boolean ready;
|
||||
|
||||
public ShardService(SettingsProvider settings, MusicService music, EventListener eventsHandler) {
|
||||
this.settings = Objects.requireNonNull(settings, "settings");
|
||||
this.music = Objects.requireNonNull(music, "music");
|
||||
this.eventsHandler = Objects.requireNonNull(eventsHandler, "eventsHandler");
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps an already-built manager; used by tests.
|
||||
*/
|
||||
ShardService(ShardManager shardManager) {
|
||||
this.settings = null;
|
||||
this.music = null;
|
||||
this.eventsHandler = null;
|
||||
this.shardManager = shardManager;
|
||||
this.ready = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() throws Exception {
|
||||
String token = settings.get("NETNEUTRALITY_BOT_TOKEN");
|
||||
if (token == null) {
|
||||
throw new IllegalStateException("No bot token provided, exiting.");
|
||||
}
|
||||
|
||||
try {
|
||||
DefaultShardManagerBuilder builder = DefaultShardManagerBuilder
|
||||
.createDefault(token)
|
||||
.enableIntents(
|
||||
GatewayIntent.GUILD_MEMBERS,
|
||||
GatewayIntent.GUILD_MESSAGE_REACTIONS,
|
||||
GatewayIntent.GUILD_VOICE_STATES,
|
||||
GatewayIntent.MESSAGE_CONTENT)
|
||||
// Voice state/server updates are handed to Lavalink instead of JDA's audio stack;
|
||||
// DAVE encryption is handled by the node.
|
||||
.setVoiceDispatchInterceptor(new JDAVoiceUpdateListener(music.getClient()))
|
||||
// Attached before connecting so no shard misses early events.
|
||||
.addEventListeners(eventsHandler)
|
||||
.setActivity(Activity.watching("me load..."));
|
||||
|
||||
int total = parseShardTotal(settings.get(SHARD_TOTAL_KEY));
|
||||
if (total > 0) {
|
||||
builder.setShardsTotal(total);
|
||||
}
|
||||
|
||||
shardManager = builder.build();
|
||||
awaitAllShards(shardManager);
|
||||
ready = true;
|
||||
logger.info("{} shard(s) ready", shardManager.getShardsTotal());
|
||||
} catch (InvalidTokenException | IllegalArgumentException e) {
|
||||
LogHandler.log(e);
|
||||
throw new IllegalStateException("Unable to connect to Discord", e);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
LogHandler.log(e);
|
||||
throw new IllegalStateException("Unable to connect to Discord", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
ready = false;
|
||||
ShardManager manager = shardManager;
|
||||
if (manager != null) {
|
||||
manager.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int priority() {
|
||||
return 60;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the configured shard count, or {@code -1} (automatic) when missing or invalid
|
||||
*/
|
||||
static int parseShardTotal(String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return -1;
|
||||
}
|
||||
try {
|
||||
int value = Integer.parseInt(raw.trim());
|
||||
if (value > 0) {
|
||||
return value;
|
||||
}
|
||||
} catch (NumberFormatException ignored) {
|
||||
// falls through to the warning below
|
||||
}
|
||||
logger.warn("Invalid {} '{}', using Discord's recommended shard count", SHARD_TOTAL_KEY, raw);
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static void awaitAllShards(ShardManager manager) throws InterruptedException {
|
||||
int total = manager.getShardsTotal();
|
||||
for (int id = 0; id < total; id++) {
|
||||
JDA shard = manager.getShardById(id);
|
||||
// Shards are started asynchronously (one identify per 5s), so wait for each to appear.
|
||||
while (shard == null) {
|
||||
Thread.sleep(100);
|
||||
shard = manager.getShardById(id);
|
||||
}
|
||||
shard.awaitReady();
|
||||
}
|
||||
}
|
||||
|
||||
public void setActivity(Activity activity) {
|
||||
ShardManager manager = shardManager;
|
||||
if (manager != null) {
|
||||
manager.setActivity(activity);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isReady() {
|
||||
return ready && shardManager != null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Guild getGuildById(String id) {
|
||||
ShardManager manager = shardManager;
|
||||
return manager == null ? null : manager.getGuildById(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Guild> getGuilds() {
|
||||
ShardManager manager = shardManager;
|
||||
return manager == null ? List.of() : manager.getGuilds();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getShardCount() {
|
||||
ShardManager manager = shardManager;
|
||||
return manager == null ? 0 : manager.getShardsTotal();
|
||||
}
|
||||
|
||||
@Override
|
||||
public double getAveragePing() {
|
||||
ShardManager manager = shardManager;
|
||||
return manager == null ? -1 : manager.getAverageGatewayPing();
|
||||
}
|
||||
|
||||
@Override
|
||||
public JDA.Status getWorstStatus() {
|
||||
ShardManager manager = shardManager;
|
||||
if (manager == null) {
|
||||
return JDA.Status.INITIALIZING;
|
||||
}
|
||||
return manager.getShards().stream()
|
||||
.map(JDA::getStatus)
|
||||
.filter(status -> status != JDA.Status.CONNECTED)
|
||||
.max(Comparator.naturalOrder())
|
||||
.orElse(JDA.Status.CONNECTED);
|
||||
}
|
||||
|
||||
@Override
|
||||
public JDA getAnyShard() {
|
||||
ShardManager manager = shardManager;
|
||||
if (manager == null || !ready) {
|
||||
return null;
|
||||
}
|
||||
JDA first = manager.getShardById(0);
|
||||
return first != null ? first : manager.getShards().stream().findFirst().orElse(null);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import me.bobert.netneutrality.core.tasks.trait.Deletable;
|
||||
import me.bobert.netneutrality.core.tasks.trait.WithChildren;
|
||||
import me.bobert.netneutrality.db.GSettings;
|
||||
import me.bobert.netneutrality.music.MusicManager;
|
||||
import me.bobert.netneutrality.music.PlaylistInfo;
|
||||
import me.bobert.netneutrality.music.SourceFinder;
|
||||
import me.bobert.netneutrality.core.tasks.generic.AcceptButton;
|
||||
import me.bobert.netneutrality.core.tasks.generic.CancelButton;
|
||||
@@ -27,15 +28,17 @@ import java.util.List;
|
||||
|
||||
public class LoadPlaylistTask extends ButtonTask implements WithChildren, Deletable {
|
||||
private final List<Task> childrens = new ArrayList<>();
|
||||
private final String playlistIdentifier;
|
||||
private final String playlistName;
|
||||
private final List<Track> playlistTracks;
|
||||
private final boolean searchResult;
|
||||
private final SourceFinder.AudioSourceType sourceType;
|
||||
private final MusicManager musicManager;
|
||||
|
||||
public LoadPlaylistTask(String playlistName, List<Track> playlistTracks, boolean searchResult,
|
||||
public LoadPlaylistTask(String playlistIdentifier, String playlistName, List<Track> playlistTracks, boolean searchResult,
|
||||
SourceFinder.AudioSourceType sourceType, MusicManager musicManager) {
|
||||
super(musicManager.getTaskManager(), null, null);
|
||||
this.playlistIdentifier = playlistIdentifier;
|
||||
this.playlistName = playlistName;
|
||||
this.playlistTracks = playlistTracks;
|
||||
this.searchResult = searchResult;
|
||||
@@ -93,7 +96,8 @@ public class LoadPlaylistTask extends ButtonTask implements WithChildren, Deleta
|
||||
|
||||
// Load playlist normally
|
||||
if (MusicUtils.connect(context, musicManager)) {
|
||||
musicManager.getQueueManager().queue(tracks, context.getGuildUser());
|
||||
musicManager.getQueueManager().queue(tracks, context.getGuildUser(),
|
||||
new PlaylistInfo(playlistIdentifier, playlistName, tracks.size()));
|
||||
|
||||
context.edit(MessageFunction.buildAddingPlaylistMessage(tracks, playlistName, context.getGuildUser(), sourceType));
|
||||
}
|
||||
@@ -104,7 +108,7 @@ public class LoadPlaylistTask extends ButtonTask implements WithChildren, Deleta
|
||||
Member member = context.getGuildUser();
|
||||
|
||||
if (MusicUtils.connect(context, musicManager)) {
|
||||
musicManager.getQueueManager().queue(tracks, member);
|
||||
musicManager.getQueueManager().queue(tracks, member, new PlaylistInfo(playlistIdentifier, playlistName, tracks.size()));
|
||||
context.reply(MessageFunction.buildAddingPlaylistMessage(tracks, playlistName, member, SourceFinder.AudioSourceType.Youtube));
|
||||
|
||||
return Lifecycle.REMOVE_SELF_AND_CHILDREN;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package me.bobert.netneutrality.utils;
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
*
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
import me.bobert.netneutrality.db.GSettings;
|
||||
import me.bobert.netneutrality.db.service.GuildService;
|
||||
import net.dv8tion.jda.api.entities.Guild;
|
||||
import net.dv8tion.jda.api.entities.Member;
|
||||
import net.dv8tion.jda.api.entities.Role;
|
||||
|
||||
/**
|
||||
* Single source of truth for the music-access rule.
|
||||
*/
|
||||
public final class MusicAccess {
|
||||
|
||||
private MusicAccess() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the member may use music features in their guild.
|
||||
*
|
||||
* @param member the member, {@code null} is allowed
|
||||
* @param guildService the guild service of the member's guild
|
||||
* @return true when access is allowed
|
||||
*/
|
||||
public static boolean canUse(Member member, GuildService guildService) {
|
||||
if (member == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
Guild guild = member.getGuild();
|
||||
if (UserFunction.isAdmin(member)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
String roleDb = guildService.getGuildSettingService().getOrDefault(GSettings.MUSIC_ROLE);
|
||||
if (roleDb != null && !roleDb.isEmpty()) {
|
||||
Role neededRole = guild.getRoleById(roleDb);
|
||||
if (neededRole == null) {
|
||||
// Role doesn't exist on guild anymore, remove from db
|
||||
guildService.getGuildSettingService().set(GSettings.MUSIC_ROLE, null);
|
||||
return true;
|
||||
}
|
||||
|
||||
return member.getRoles().contains(neededRole);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import me.bobert.netneutrality.context.MusicChannelContext;
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
import me.bobert.netneutrality.context.command.SlashCommandContext;
|
||||
import me.bobert.netneutrality.core.context.Context;
|
||||
import me.bobert.netneutrality.db.GSettings;
|
||||
import me.bobert.netneutrality.exceptions.ContextException;
|
||||
@@ -94,14 +93,14 @@ public class MusicUtils {
|
||||
}
|
||||
|
||||
public static boolean isUserDj(Context context, MusicManager manager) {
|
||||
if (context instanceof SlashCommandContext) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return isUserDj(context.getGuildUser(), manager);
|
||||
}
|
||||
|
||||
public static boolean isUserDj(Member member, MusicManager manager) {
|
||||
if (UserFunction.isAdmin(member)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
String djRoleId = manager.getGuildService().getGuildSettingService().getOrDefault(GSettings.MUSIC_DJ_ROLE);
|
||||
if (djRoleId != null && !djRoleId.isEmpty() && member.getRoles().stream().anyMatch(role -> role.getId().equals(djRoleId))) {
|
||||
return true;
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
package me.bobert.netneutrality.core.command;
|
||||
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
*
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
import net.dv8tion.jda.api.entities.channel.ChannelType;
|
||||
import net.dv8tion.jda.api.interactions.commands.OptionType;
|
||||
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
|
||||
import net.dv8tion.jda.api.interactions.commands.build.SubcommandData;
|
||||
import net.dv8tion.jda.api.interactions.commands.build.SubcommandGroupData;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class CommandContainerTest {
|
||||
|
||||
@Mock
|
||||
private Command command;
|
||||
|
||||
@BeforeEach
|
||||
void setup() {
|
||||
when(command.getCommandName()).thenReturn("test");
|
||||
when(command.getDescription()).thenReturn("A test command");
|
||||
when(command.getType()).thenReturn(Command.Type.CHAT);
|
||||
}
|
||||
|
||||
private CommandContainer container(Consumer<CommandOptionsBuilder> registration) {
|
||||
doAnswer(invocation -> {
|
||||
registration.accept(invocation.getArgument(0));
|
||||
return null;
|
||||
}).when(command).registerCommandOptions(org.mockito.ArgumentMatchers.any());
|
||||
|
||||
return new CommandContainer(command);
|
||||
}
|
||||
|
||||
// ===== NULL_COMMAND and construction =====
|
||||
|
||||
@Test
|
||||
void nullCommandShouldHaveNoCommand() {
|
||||
assertNull(CommandContainer.NULL_COMMAND.getCommand());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullCommandShouldBeEmptyAndPrintNothing() {
|
||||
assertTrue(CommandContainer.NULL_COMMAND.isEmpty());
|
||||
assertEquals("", CommandContainer.NULL_COMMAND.printUsage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ofShouldBuildTheSameAsTheConstructor() {
|
||||
doAnswer(invocation -> {
|
||||
((CommandOptionsBuilder) invocation.getArgument(0))
|
||||
.addOptions(new OptionData(OptionType.STRING, "text", "Text"));
|
||||
return null;
|
||||
}).when(command).registerCommandOptions(org.mockito.ArgumentMatchers.any());
|
||||
|
||||
CommandContainer container = CommandContainer.of(command);
|
||||
|
||||
assertSame(command, container.getCommand());
|
||||
assertEquals(1, container.getOptions().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldExposeTheCommand() {
|
||||
assertSame(command, container(_ -> { }).getCommand());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldExposeTheBuiltSlashData() {
|
||||
CommandContainer container = container(_ -> { });
|
||||
|
||||
assertNotNull(container.getSlashCommandData());
|
||||
assertEquals("test", container.getSlashCommandData().getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldExposeTheRegisteredOptionsSubcommandsAndGroups() {
|
||||
CommandContainer container = container(builder -> {
|
||||
builder.addOptions(new OptionData(OptionType.STRING, "text", "Text"));
|
||||
});
|
||||
|
||||
assertEquals(1, container.getOptions().size());
|
||||
assertTrue(container.getSubcommands().isEmpty());
|
||||
assertTrue(container.getSubcommandGroups().isEmpty());
|
||||
}
|
||||
|
||||
// ===== isEmpty =====
|
||||
|
||||
@Test
|
||||
void shouldBeEmptyWithoutAnyOptions() {
|
||||
assertTrue(container(_ -> { }).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotBeEmptyWithOptions() {
|
||||
assertFalse(container(builder -> builder.addOptions(new OptionData(OptionType.STRING, "text", "Text"))).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotBeEmptyWithSubcommands() {
|
||||
assertFalse(container(builder -> builder.addSubcommands(new SubcommandData("add", "Adds"))).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotBeEmptyWithSubcommandGroups() {
|
||||
CommandContainer container = container(builder -> builder.addSubcommandGroups(
|
||||
new SubcommandGroupData("grp", "Group").addSubcommands(new SubcommandData("add", "Adds"))));
|
||||
|
||||
assertFalse(container.isEmpty());
|
||||
}
|
||||
|
||||
// ===== printUsage =====
|
||||
|
||||
@Test
|
||||
void shouldPrintNothingWithoutOptions() {
|
||||
assertEquals("", container(_ -> { }).printUsage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPrintPlainOptions() {
|
||||
CommandContainer container = container(builder -> builder.addOptions(
|
||||
new OptionData(OptionType.INTEGER, "amount", "Amount", true),
|
||||
new OptionData(OptionType.STRING, "reason", "Reason")));
|
||||
|
||||
assertEquals("- Usage: /test <amount> <reason>\n"
|
||||
+ " - *amount: Amount\n"
|
||||
+ " - reason: Reason\n\n"
|
||||
+ "-# *: required argument", container.printUsage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPrintMinAndMaxValues() {
|
||||
CommandContainer container = container(builder -> builder.addOptions(
|
||||
new OptionData(OptionType.INTEGER, "amount", "Amount").setMinValue(1).setMaxValue(10)));
|
||||
|
||||
String usage = container.printUsage();
|
||||
|
||||
assertTrue(usage.contains("amount: Amount [Min value: 1] [Max value: 10]"), usage);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPrintMinAndMaxLengths() {
|
||||
CommandContainer container = container(builder -> builder.addOptions(
|
||||
new OptionData(OptionType.STRING, "text", "Text").setMinLength(2).setMaxLength(20)));
|
||||
|
||||
String usage = container.printUsage();
|
||||
|
||||
assertTrue(usage.contains("text: Text [Min length: 2] [Max length: 20]"), usage);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPrintChoices() {
|
||||
CommandContainer container = container(builder -> builder.addOptions(
|
||||
new OptionData(OptionType.STRING, "mode", "Mode").addChoice("One", "one").addChoice("Two", "two")));
|
||||
|
||||
String usage = container.printUsage();
|
||||
|
||||
assertTrue(usage.contains("[Available choices: one, two]"), usage);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPrintRequiredChannelTypes() {
|
||||
CommandContainer container = container(builder -> builder.addOptions(
|
||||
new OptionData(OptionType.CHANNEL, "channel", "Channel").setChannelTypes(ChannelType.TEXT)));
|
||||
|
||||
String usage = container.printUsage();
|
||||
|
||||
assertTrue(usage.contains("[Required channel types: TEXT]"), usage);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPrintSubcommands() {
|
||||
CommandContainer container = container(builder -> builder.addSubcommands(
|
||||
new SubcommandData("add", "Adds").addOptions(new OptionData(OptionType.STRING, "name", "Name", true))));
|
||||
|
||||
String usage = container.printUsage();
|
||||
|
||||
assertTrue(usage.contains("- Usage: /test add <name> (Adds)\n - *name: Name"), usage);
|
||||
assertTrue(usage.endsWith("-# *: required argument"), usage);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPrintSubcommandGroups() {
|
||||
CommandContainer container = container(builder -> builder.addSubcommandGroups(
|
||||
new SubcommandGroupData("grp", "Group").addSubcommands(
|
||||
new SubcommandData("add", "Adds").addOptions(new OptionData(OptionType.STRING, "name", "Name", true)))));
|
||||
|
||||
String usage = container.printUsage();
|
||||
|
||||
assertTrue(usage.contains("- Subcommand: /test grp (Group)\n"), usage);
|
||||
assertTrue(usage.contains(" - Usage: /test grp add <name> (Adds)\n - *name: Name"), usage);
|
||||
assertTrue(usage.endsWith("-# *: required argument"), usage);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPrintExactSubcommandUsage() {
|
||||
CommandContainer container = container(builder -> builder.addSubcommands(
|
||||
new SubcommandData("add", "Adds").addOptions(new OptionData(OptionType.STRING, "name", "Name", true))));
|
||||
|
||||
assertEquals("- Usage: /test add <name> (Adds)\n - *name: Name\n\n\n-# *: required argument",
|
||||
container.printUsage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPrintExactSubcommandGroupUsage() {
|
||||
CommandContainer container = container(builder -> builder.addSubcommandGroups(
|
||||
new SubcommandGroupData("grp", "Group").addSubcommands(
|
||||
new SubcommandData("add", "Adds").addOptions(new OptionData(OptionType.STRING, "name", "Name", true)))));
|
||||
|
||||
assertEquals("- Subcommand: /test grp (Group)\n"
|
||||
+ " - Usage: /test grp add <name> (Adds)\n"
|
||||
+ " - *name: Name\n\n\n-# *: required argument", container.printUsage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPrintExactAnnotations() {
|
||||
CommandContainer container = container(builder -> builder.addOptions(
|
||||
new OptionData(OptionType.STRING, "mode", "Mode").setMinLength(1).setMaxLength(5)
|
||||
.addChoice("One", "one").addChoice("Two", "two"),
|
||||
new OptionData(OptionType.INTEGER, "n", "N").setMinValue(1).setMaxValue(9),
|
||||
new OptionData(OptionType.CHANNEL, "ch", "Ch").setChannelTypes(ChannelType.TEXT)));
|
||||
|
||||
assertEquals("- Usage: /test <mode> <n> <ch>\n"
|
||||
+ " - mode: Mode [Min length: 1] [Max length: 5] [Available choices: one, two]\n"
|
||||
+ " - n: N [Min value: 1] [Max value: 9]\n"
|
||||
+ " - ch: Ch [Required channel types: TEXT]\n\n"
|
||||
+ "-# *: required argument", container.printUsage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotPrintTheRequiredLegendForUsagelessCommands() {
|
||||
assertFalse(container(_ -> { }).printUsage().contains("required argument"));
|
||||
}
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
package me.bobert.netneutrality.core.command;
|
||||
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
*
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
import me.bobert.netneutrality.core.context.CommandContext;
|
||||
import me.bobert.netneutrality.core.tasks.TaskManager;
|
||||
import me.bobert.netneutrality.db.service.GuildServiceProvider;
|
||||
import me.bobert.netneutrality.core.command.precondition.DisabledCommandPrecondition;
|
||||
import me.bobert.netneutrality.utils.UserFunction;
|
||||
import net.dv8tion.jda.api.entities.Guild;
|
||||
import net.dv8tion.jda.api.entities.Member;
|
||||
import net.dv8tion.jda.api.entities.User;
|
||||
import net.dv8tion.jda.api.entities.channel.unions.MessageChannelUnion;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Dispatches through a real {@link CommandProcessor} with the default preconditions, usage validator and
|
||||
* result handler, so only the Discord entities, the registry and the command itself are mocked.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class CommandDispatchIntegrationTest {
|
||||
private static final String COMMAND_NAME = "test";
|
||||
|
||||
@Mock
|
||||
private CommandContext context;
|
||||
|
||||
@Mock
|
||||
private CommandRegistry commands;
|
||||
|
||||
@Mock
|
||||
private TaskManager taskManager;
|
||||
|
||||
@Mock
|
||||
private Command command;
|
||||
|
||||
@Mock
|
||||
private Guild guild;
|
||||
|
||||
@Mock
|
||||
private Member member;
|
||||
|
||||
@Mock
|
||||
private User user;
|
||||
|
||||
@Mock
|
||||
private MessageChannelUnion channel;
|
||||
|
||||
private CommandProcessor processor;
|
||||
|
||||
@BeforeEach
|
||||
void setup() {
|
||||
UserFunction.bind((GuildServiceProvider) null);
|
||||
DisabledCommandPrecondition.bind(null);
|
||||
|
||||
when(command.getCommandName()).thenReturn(COMMAND_NAME);
|
||||
when(command.getDescription()).thenReturn("A test command");
|
||||
when(command.getType()).thenReturn(Command.Type.CHAT);
|
||||
when(command.isEnabled()).thenReturn(true);
|
||||
when(command.canBeExecutedFrom(channel)).thenReturn(true);
|
||||
when(command.canBeExecutedBy(member)).thenReturn(true);
|
||||
when(command.execute(context)).thenReturn(CommandExecutionResult.success("Executed"));
|
||||
|
||||
CommandContainer container = new CommandContainer(command);
|
||||
when(commands.hasCommand(COMMAND_NAME)).thenReturn(true);
|
||||
when(commands.getCommandContainer(COMMAND_NAME)).thenReturn(container);
|
||||
|
||||
when(context.parseCommandName()).thenReturn(COMMAND_NAME);
|
||||
when(context.getFullCommand()).thenReturn(COMMAND_NAME);
|
||||
when(context.getId()).thenReturn("context-id");
|
||||
when(context.getGuild()).thenReturn(guild);
|
||||
when(context.isFromGuild()).thenReturn(true);
|
||||
when(context.getGuildUser()).thenReturn(member);
|
||||
when(context.getDiscordUser()).thenReturn(user);
|
||||
when(context.getChannel()).thenReturn(channel);
|
||||
when(context.isValid()).thenReturn(true);
|
||||
when(guild.getId()).thenReturn("111");
|
||||
when(member.getId()).thenReturn("222");
|
||||
when(user.getId()).thenReturn("222");
|
||||
when(channel.getId()).thenReturn("333");
|
||||
|
||||
processor = new CommandProcessor(context, commands, taskManager);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
UserFunction.bind((GuildServiceProvider) null);
|
||||
DisabledCommandPrecondition.bind(null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldBlockANonAdminOnAnAdminCommand() {
|
||||
when(command.needAdmin()).thenReturn(true);
|
||||
|
||||
processor.dispatch();
|
||||
|
||||
verify(context).replyEphemeral("You are not powerful enough, scrub");
|
||||
verify(command, never()).execute(any());
|
||||
verify(context, never()).reply("Executed");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRunAnAllowedCommandToCompletion() {
|
||||
processor.dispatch();
|
||||
|
||||
verify(context).setCommand(command);
|
||||
verify(command).execute(context);
|
||||
verify(context).reply("Executed");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldBlockAGuildOnlyCommandInPrivateMessages() {
|
||||
when(command.isGuildOnly()).thenReturn(true);
|
||||
when(context.isFromGuild()).thenReturn(false);
|
||||
|
||||
processor.dispatch();
|
||||
|
||||
verify(context).replyEphemeral("This command is only available in a guild");
|
||||
verify(context).deleteMessage();
|
||||
verify(command, never()).execute(any());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package me.bobert.netneutrality.core.command;
|
||||
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
*
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
import me.bobert.netneutrality.bootstrap.ApplicationContext;
|
||||
import net.dv8tion.jda.api.JDA;
|
||||
import net.dv8tion.jda.api.interactions.InteractionContextType;
|
||||
import net.dv8tion.jda.api.interactions.commands.DefaultMemberPermissions;
|
||||
import net.dv8tion.jda.api.interactions.commands.build.CommandData;
|
||||
import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData;
|
||||
import net.dv8tion.jda.api.requests.restaction.CommandListUpdateAction;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.RETURNS_SELF;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class CommandManagerTest {
|
||||
|
||||
@Mock
|
||||
private ApplicationContext applicationContext;
|
||||
|
||||
@Mock
|
||||
private JDA jda;
|
||||
|
||||
private CommandListUpdateAction updateAction;
|
||||
|
||||
private CommandManager manager;
|
||||
|
||||
@BeforeEach
|
||||
void setup() {
|
||||
manager = new CommandManager(applicationContext);
|
||||
|
||||
updateAction = mock(CommandListUpdateAction.class, RETURNS_SELF);
|
||||
when(jda.updateCommands()).thenReturn(updateAction);
|
||||
}
|
||||
|
||||
private Command command(String name, String description) {
|
||||
Command command = mock(Command.class);
|
||||
when(command.getCommandName()).thenReturn(name);
|
||||
when(command.getDescription()).thenReturn(description);
|
||||
when(command.getType()).thenReturn(Command.Type.CHAT);
|
||||
when(command.isEnabled()).thenReturn(true);
|
||||
when(command.isSlashCommand()).thenReturn(true);
|
||||
when(command.isGuildOnly()).thenReturn(false);
|
||||
when(command.getSlashCommandPermission()).thenReturn(DefaultMemberPermissions.ENABLED);
|
||||
return command;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<CommandData> registeredCommands() {
|
||||
manager.registerSlashCommands(jda);
|
||||
|
||||
ArgumentCaptor<Collection<CommandData>> captor = ArgumentCaptor.forClass(Collection.class);
|
||||
verify(updateAction).addCommands(captor.capture());
|
||||
verify(updateAction).queue();
|
||||
return List.copyOf(captor.getValue());
|
||||
}
|
||||
|
||||
// ===== Construction =====
|
||||
|
||||
@Test
|
||||
void shouldRequireTheApplicationContext() {
|
||||
assertThrows(NullPointerException.class, () -> new CommandManager(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldStartAfterTheOtherRegistries() {
|
||||
assertEquals(30, manager.priority());
|
||||
}
|
||||
|
||||
// ===== Loading =====
|
||||
|
||||
@Test
|
||||
void shouldRegisterAnEnabledCommand() {
|
||||
Command ping = command("ping", "Pings");
|
||||
|
||||
manager.onCommandLoaded(ping);
|
||||
|
||||
assertTrue(manager.hasCommand("ping"));
|
||||
assertSame(ping, manager.getCommandContainer("ping").getCommand());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldSkipDisabledCommands() {
|
||||
Command ping = command("ping", "Pings");
|
||||
when(ping.isEnabled()).thenReturn(false);
|
||||
|
||||
manager.onCommandLoaded(ping);
|
||||
|
||||
assertFalse(manager.hasCommand("ping"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldSkipCommandsWithoutAName() {
|
||||
manager.onCommandLoaded(command(null, "Pings"));
|
||||
|
||||
assertTrue(manager.getCommands().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldSkipChatCommandsWithoutADescription() {
|
||||
manager.onCommandLoaded(command("ping", null));
|
||||
|
||||
assertFalse(manager.hasCommand("ping"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldKeepMessageCommandsWithoutADescription() {
|
||||
Command menu = command("menu", null);
|
||||
when(menu.getType()).thenReturn(Command.Type.MESSAGE);
|
||||
|
||||
manager.onCommandLoaded(menu);
|
||||
|
||||
assertTrue(manager.hasCommand("menu"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldKeepTheFirstCommandWhenNamesAreDuplicated() {
|
||||
Command first = command("ping", "First");
|
||||
Command second = command("ping", "Second");
|
||||
|
||||
manager.onCommandLoaded(first);
|
||||
manager.onCommandLoaded(second);
|
||||
|
||||
assertEquals(1, manager.getCommands().size());
|
||||
assertSame(first, manager.getCommandContainer("ping").getCommand());
|
||||
}
|
||||
|
||||
// ===== Lookups =====
|
||||
|
||||
@Test
|
||||
void shouldNotHaveAnUnknownCommand() {
|
||||
assertFalse(manager.hasCommand("unknown"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnTheNullCommandForAnUnknownName() {
|
||||
assertSame(CommandContainer.NULL_COMMAND, manager.getCommandContainer("unknown"));
|
||||
}
|
||||
|
||||
// ===== Slash command registration =====
|
||||
|
||||
@Test
|
||||
void shouldRegisterEnabledSlashCommands() {
|
||||
manager.onCommandLoaded(command("ping", "Pings"));
|
||||
|
||||
List<CommandData> registered = registeredCommands();
|
||||
|
||||
assertEquals(1, registered.size());
|
||||
assertEquals("ping", registered.getFirst().getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRegisterNothingWithoutCommands() {
|
||||
assertTrue(registeredCommands().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotRegisterCommandsThatAreNotSlashCommands() {
|
||||
Command ping = command("ping", "Pings");
|
||||
manager.onCommandLoaded(ping);
|
||||
when(ping.isSlashCommand()).thenReturn(false);
|
||||
|
||||
assertTrue(registeredCommands().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotRegisterCommandsDisabledAfterLoading() {
|
||||
Command ping = command("ping", "Pings");
|
||||
manager.onCommandLoaded(ping);
|
||||
when(ping.isEnabled()).thenReturn(false);
|
||||
|
||||
assertTrue(registeredCommands().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRestrictGuildOnlyCommandsToGuilds() {
|
||||
Command ping = command("ping", "Pings");
|
||||
when(ping.isGuildOnly()).thenReturn(true);
|
||||
manager.onCommandLoaded(ping);
|
||||
|
||||
SlashCommandData data = (SlashCommandData) registeredCommands().getFirst();
|
||||
|
||||
assertEquals(Set.of(InteractionContextType.GUILD), data.getContexts());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAllowOtherCommandsInGuildsAndBotDms() {
|
||||
manager.onCommandLoaded(command("ping", "Pings"));
|
||||
|
||||
SlashCommandData data = (SlashCommandData) registeredCommands().getFirst();
|
||||
|
||||
assertEquals(Set.of(InteractionContextType.GUILD, InteractionContextType.BOT_DM), data.getContexts());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldApplyTheDefaultPermissionOfTheCommand() {
|
||||
Command ping = command("ping", "Pings");
|
||||
when(ping.getSlashCommandPermission()).thenReturn(DefaultMemberPermissions.DISABLED);
|
||||
manager.onCommandLoaded(ping);
|
||||
|
||||
SlashCommandData data = (SlashCommandData) registeredCommands().getFirst();
|
||||
|
||||
assertEquals(DefaultMemberPermissions.DISABLED, data.getDefaultPermissions());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldKeepRegisteringWhenOneCommandFails() {
|
||||
Command broken = command("broken", "Broken");
|
||||
CommandContainer brokenContainer = mock(CommandContainer.class);
|
||||
when(brokenContainer.getCommand()).thenReturn(broken);
|
||||
when(brokenContainer.getSlashCommandData()).thenThrow(new IllegalStateException("boom"));
|
||||
manager.getCommands().put("broken", brokenContainer);
|
||||
manager.onCommandLoaded(command("ping", "Pings"));
|
||||
|
||||
List<CommandData> registered = registeredCommands();
|
||||
|
||||
assertEquals(1, registered.size());
|
||||
assertEquals("ping", registered.getFirst().getName());
|
||||
verify(brokenContainer).getSlashCommandData();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package me.bobert.netneutrality.core.command;
|
||||
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
*
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
import net.dv8tion.jda.api.interactions.commands.OptionType;
|
||||
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
|
||||
import net.dv8tion.jda.api.interactions.commands.build.SubcommandData;
|
||||
import net.dv8tion.jda.api.interactions.commands.build.SubcommandGroupData;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class CommandOptionsBuilderTest {
|
||||
|
||||
@Mock
|
||||
private Command command;
|
||||
|
||||
@BeforeEach
|
||||
void setup() {
|
||||
when(command.getCommandName()).thenReturn("test");
|
||||
when(command.getDescription()).thenReturn("A test command");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldBuildSlashDataForChatCommands() {
|
||||
CommandOptionsBuilder builder = new CommandOptionsBuilder(Command.Type.CHAT, command);
|
||||
|
||||
assertNotNull(builder.build());
|
||||
assertEquals(net.dv8tion.jda.api.interactions.commands.Command.Type.SLASH, builder.getType());
|
||||
assertEquals("test", builder.getName());
|
||||
assertEquals("A test command", builder.getDescription());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldBuildUserContextMenuDataForUserCommands() {
|
||||
CommandOptionsBuilder builder = new CommandOptionsBuilder(Command.Type.USER, command);
|
||||
|
||||
assertEquals(net.dv8tion.jda.api.interactions.commands.Command.Type.USER, builder.build().getType());
|
||||
assertEquals("test", builder.build().getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldBuildMessageContextMenuDataForMessageCommands() {
|
||||
CommandOptionsBuilder builder = new CommandOptionsBuilder(Command.Type.MESSAGE, command);
|
||||
|
||||
assertEquals(net.dv8tion.jda.api.interactions.commands.Command.Type.MESSAGE, builder.build().getType());
|
||||
assertEquals("test", builder.build().getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldStartWithoutOptions() {
|
||||
CommandOptionsBuilder builder = new CommandOptionsBuilder(Command.Type.CHAT, command);
|
||||
|
||||
assertTrue(builder.getOptions().isEmpty());
|
||||
assertTrue(builder.getSubcommands().isEmpty());
|
||||
assertTrue(builder.getSubcommandGroups().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDelegateAddedOptions() {
|
||||
CommandOptionsBuilder builder = new CommandOptionsBuilder(Command.Type.CHAT, command);
|
||||
|
||||
builder.addOptions(new OptionData(OptionType.STRING, "text", "Some text", true));
|
||||
|
||||
assertEquals(1, builder.build().getOptions().size());
|
||||
assertEquals("text", builder.getOptions().getFirst().getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDelegateAddedSubcommandsAndGroups() {
|
||||
CommandOptionsBuilder builder = new CommandOptionsBuilder(Command.Type.CHAT, command);
|
||||
|
||||
builder.addSubcommands(new SubcommandData("add", "Adds"));
|
||||
builder.addSubcommandGroups(new SubcommandGroupData("grp", "Group").addSubcommands(new SubcommandData("sub", "Sub")));
|
||||
|
||||
assertEquals("add", builder.getSubcommands().getFirst().getName());
|
||||
assertEquals("grp", builder.getSubcommandGroups().getFirst().getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDelegateSettersToTheBuiltData() {
|
||||
CommandOptionsBuilder builder = new CommandOptionsBuilder(Command.Type.CHAT, command);
|
||||
|
||||
builder.setNSFW(true);
|
||||
builder.setDescription("Changed");
|
||||
|
||||
assertTrue(builder.build().isNSFW());
|
||||
assertEquals("Changed", builder.build().getDescription());
|
||||
}
|
||||
}
|
||||
@@ -109,7 +109,7 @@ class CommandProcessorTest {
|
||||
when(commands.getCommandContainer(COMMAND_NAME)).thenReturn(container);
|
||||
when(container.getCommand()).thenReturn(command);
|
||||
when(command.getCommandName()).thenReturn(COMMAND_NAME);
|
||||
when(command.getType()).thenReturn(Command.Type.SLASH);
|
||||
when(command.getType()).thenReturn(Command.Type.CHAT);
|
||||
when(command.execute(context)).thenReturn(result);
|
||||
when(usageValidator.validate(context, container)).thenReturn(true);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package me.bobert.netneutrality.core.command;
|
||||
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
*
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
import me.bobert.netneutrality.core.context.CommandContext;
|
||||
import me.bobert.netneutrality.core.tasks.Task;
|
||||
import me.bobert.netneutrality.core.tasks.TaskManager;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class CommandResultHandlerTest {
|
||||
|
||||
@Mock
|
||||
private CommandContext context;
|
||||
|
||||
@Mock
|
||||
private TaskManager taskManager;
|
||||
|
||||
@Mock
|
||||
private Task task;
|
||||
|
||||
private final CommandResultHandler handler = new CommandResultHandler();
|
||||
|
||||
@Test
|
||||
void shouldDoNothingForNoAction() {
|
||||
handler.handle(context, CommandExecutionResult.none(), taskManager);
|
||||
|
||||
verify(context, never()).reply(anyString());
|
||||
verifyNoInteractions(taskManager);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldInsertTheTaskForATaskResult() {
|
||||
handler.handle(context, CommandExecutionResult.task(task), taskManager);
|
||||
|
||||
verify(taskManager).insert(context, task);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotReplyForATaskResultWithoutMessage() {
|
||||
handler.handle(context, CommandExecutionResult.task(task), taskManager);
|
||||
|
||||
verify(context, never()).reply(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReplyForASuccess() {
|
||||
handler.handle(context, CommandExecutionResult.success("Done"), taskManager);
|
||||
|
||||
verify(context).reply("Done");
|
||||
verify(taskManager, never()).insert(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReplyForAnInfo() {
|
||||
handler.handle(context, CommandExecutionResult.info("FYI"), taskManager);
|
||||
|
||||
verify(context).reply("FYI");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReplyForAWarning() {
|
||||
handler.handle(context, CommandExecutionResult.warn("Careful"), taskManager);
|
||||
|
||||
verify(context).reply("Careful");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReplyForAnError() {
|
||||
handler.handle(context, CommandExecutionResult.error("Broken", new IllegalStateException("boom")), taskManager);
|
||||
|
||||
verify(context).reply("Broken");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReplyForAnErrorWithoutException() {
|
||||
handler.handle(context, CommandExecutionResult.error("Broken"), taskManager);
|
||||
|
||||
verify(context).reply("Broken");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotReplyWhenTheMessageIsNull() {
|
||||
handler.handle(context, CommandExecutionResult.success((String) null), taskManager);
|
||||
|
||||
verify(context, never()).reply(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFailForANullResult() {
|
||||
when(context.getId()).thenReturn("context-id");
|
||||
|
||||
assertThrows(RuntimeException.class, () -> handler.handle(context, null, taskManager));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package me.bobert.netneutrality.core.command;
|
||||
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
*
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
import net.dv8tion.jda.api.interactions.commands.OptionType;
|
||||
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class CommandUsageFormatterTest {
|
||||
|
||||
private final CommandUsageFormatter formatter = new CommandUsageFormatter();
|
||||
|
||||
private Command command() {
|
||||
Command command = mock(Command.class);
|
||||
when(command.getCommandName()).thenReturn("test");
|
||||
when(command.getDescription()).thenReturn("A test command");
|
||||
when(command.getType()).thenReturn(Command.Type.CHAT);
|
||||
return command;
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFormatEmptyContainerAsEmptyString() {
|
||||
assertEquals("", formatter.format(new CommandContainer(command())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFormatOptionsWithRequiredMarkerAndLegend() {
|
||||
Command command = command();
|
||||
doAnswer(invocation -> {
|
||||
CommandOptionsBuilder builder = invocation.getArgument(0);
|
||||
builder.addOptions(new OptionData(OptionType.STRING, "text", "Text", true));
|
||||
return null;
|
||||
}).when(command).registerCommandOptions(org.mockito.ArgumentMatchers.any());
|
||||
|
||||
assertEquals("- Usage: /test <text>\n - *text: Text\n\n-# *: required argument",
|
||||
formatter.format(new CommandContainer(command)));
|
||||
}
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
package me.bobert.netneutrality.core.command.precondition;
|
||||
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
*
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
import me.bobert.netneutrality.core.command.Command;
|
||||
import me.bobert.netneutrality.core.context.CommandContext;
|
||||
import me.bobert.netneutrality.db.GSettings;
|
||||
import me.bobert.netneutrality.db.service.GuildService;
|
||||
import me.bobert.netneutrality.db.service.GuildServiceProvider;
|
||||
import me.bobert.netneutrality.db.service.GuildSettingService;
|
||||
import net.dv8tion.jda.api.entities.Guild;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class DisabledCommandPreconditionTest {
|
||||
|
||||
@Mock
|
||||
private CommandContext context;
|
||||
|
||||
@Mock
|
||||
private Command command;
|
||||
|
||||
@Mock
|
||||
private Guild guild;
|
||||
|
||||
@Mock
|
||||
private GuildServiceProvider provider;
|
||||
|
||||
@Mock
|
||||
private GuildService guildService;
|
||||
|
||||
@Mock
|
||||
private GuildSettingService settingService;
|
||||
|
||||
private final DisabledCommandPrecondition precondition = new DisabledCommandPrecondition();
|
||||
|
||||
@BeforeEach
|
||||
void setup() {
|
||||
when(context.getGuild()).thenReturn(guild);
|
||||
when(guild.getId()).thenReturn("111");
|
||||
when(command.getCommandName()).thenReturn("ping");
|
||||
when(provider.get("111")).thenReturn(guildService);
|
||||
when(guildService.getGuildSettingService()).thenReturn(settingService);
|
||||
when(settingService.getOrDefault(GSettings.DISABLED_COMMANDS)).thenReturn("");
|
||||
|
||||
DisabledCommandPrecondition.bind(provider);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
DisabledCommandPrecondition.bind(null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPassWhenNoProviderIsBound() {
|
||||
DisabledCommandPrecondition.bind(null);
|
||||
|
||||
assertTrue(precondition.check(context, command));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPassOutsideAGuild() {
|
||||
when(context.getGuild()).thenReturn(null);
|
||||
|
||||
assertTrue(precondition.check(context, command));
|
||||
|
||||
verify(context, never()).replyEphemeral(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPassWhenNoCommandIsDisabled() {
|
||||
assertTrue(precondition.check(context, command));
|
||||
|
||||
verify(context, never()).replyEphemeral(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPassWhenAnotherCommandIsDisabled() {
|
||||
when(settingService.getOrDefault(GSettings.DISABLED_COMMANDS)).thenReturn("play,skip");
|
||||
|
||||
assertTrue(precondition.check(context, command));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldBlockADisabledCommand() {
|
||||
when(settingService.getOrDefault(GSettings.DISABLED_COMMANDS)).thenReturn("play,ping");
|
||||
|
||||
assertFalse(precondition.check(context, command));
|
||||
|
||||
verify(context).replyEphemeral("This command is disabled on this server");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldIgnoreCaseAndWhitespaceWhenMatching() {
|
||||
when(settingService.getOrDefault(GSettings.DISABLED_COMMANDS)).thenReturn("play, PING ");
|
||||
|
||||
assertFalse(precondition.check(context, command));
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package me.bobert.netneutrality.core.command.precondition;
|
||||
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
*
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
import me.bobert.netneutrality.core.command.Command;
|
||||
import me.bobert.netneutrality.core.context.CommandContext;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class GuildOnlyPreconditionTest {
|
||||
|
||||
@Mock
|
||||
private CommandContext context;
|
||||
|
||||
@Mock
|
||||
private Command command;
|
||||
|
||||
private final GuildOnlyPrecondition precondition = new GuildOnlyPrecondition();
|
||||
|
||||
@Test
|
||||
void shouldPassGuildOnlyCommandInAGuild() {
|
||||
when(command.isGuildOnly()).thenReturn(true);
|
||||
when(context.isFromGuild()).thenReturn(true);
|
||||
|
||||
assertTrue(precondition.check(context, command));
|
||||
|
||||
verify(context, never()).replyEphemeral(org.mockito.ArgumentMatchers.anyString());
|
||||
verify(context, never()).deleteMessage();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldBlockGuildOnlyCommandOutsideAGuild() {
|
||||
when(command.isGuildOnly()).thenReturn(true);
|
||||
when(context.isFromGuild()).thenReturn(false);
|
||||
|
||||
assertFalse(precondition.check(context, command));
|
||||
|
||||
verify(context).replyEphemeral("This command is only available in a guild");
|
||||
verify(context).deleteMessage();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPassCommandThatIsNotGuildOnlyOutsideAGuild() {
|
||||
when(command.isGuildOnly()).thenReturn(false);
|
||||
when(context.isFromGuild()).thenReturn(false);
|
||||
|
||||
assertTrue(precondition.check(context, command));
|
||||
|
||||
verify(context, never()).replyEphemeral(org.mockito.ArgumentMatchers.anyString());
|
||||
verify(context, never()).deleteMessage();
|
||||
}
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
package me.bobert.netneutrality.core.command.precondition;
|
||||
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
*
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
import me.bobert.netneutrality.core.command.Command;
|
||||
import me.bobert.netneutrality.core.context.CommandContext;
|
||||
import me.bobert.netneutrality.utils.UserFunction;
|
||||
import net.dv8tion.jda.api.Permission;
|
||||
import net.dv8tion.jda.api.entities.Member;
|
||||
import net.dv8tion.jda.api.entities.Role;
|
||||
import net.dv8tion.jda.api.entities.channel.unions.MessageChannelUnion;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class PermissionPreconditionTest {
|
||||
private static final String DENIED = "You are not powerful enough, scrub";
|
||||
|
||||
@Mock
|
||||
private CommandContext context;
|
||||
|
||||
@Mock
|
||||
private Command command;
|
||||
|
||||
@Mock
|
||||
private Member member;
|
||||
|
||||
@Mock
|
||||
private Role role;
|
||||
|
||||
@Mock
|
||||
private MessageChannelUnion channel;
|
||||
|
||||
private final PermissionPrecondition precondition = new PermissionPrecondition();
|
||||
|
||||
@BeforeEach
|
||||
void setup() {
|
||||
UserFunction.bind(null);
|
||||
|
||||
when(command.getType()).thenReturn(Command.Type.CHAT);
|
||||
when(context.canSkipPermissionCheck()).thenReturn(false);
|
||||
when(context.getChannel()).thenReturn(channel);
|
||||
when(context.getGuildUser()).thenReturn(member);
|
||||
when(command.canBeExecutedFrom(channel)).thenReturn(true);
|
||||
when(command.needAdmin()).thenReturn(false);
|
||||
when(command.canBeExecutedBy(member)).thenReturn(true);
|
||||
when(member.getRoles()).thenReturn(List.of());
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
UserFunction.bind(null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldSkipTheCheckForMessageCommands() {
|
||||
when(command.getType()).thenReturn(Command.Type.MESSAGE);
|
||||
when(command.canBeExecutedFrom(channel)).thenReturn(false);
|
||||
when(command.canBeExecutedBy(member)).thenReturn(false);
|
||||
|
||||
assertTrue(precondition.check(context, command));
|
||||
|
||||
verify(context, never()).replyEphemeral(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPassWhenThePermissionCheckCanBeSkipped() {
|
||||
when(context.canSkipPermissionCheck()).thenReturn(true);
|
||||
when(command.canBeExecutedFrom(channel)).thenReturn(false);
|
||||
when(command.canBeExecutedBy(member)).thenReturn(false);
|
||||
|
||||
assertTrue(precondition.check(context, command));
|
||||
|
||||
verify(context, never()).replyEphemeral(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldBlockWhenTheCommandCannotRunInTheChannel() {
|
||||
when(command.canBeExecutedFrom(channel)).thenReturn(false);
|
||||
|
||||
assertFalse(precondition.check(context, command));
|
||||
|
||||
verify(context).replyEphemeral("This command cannot be executed in the current channel");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldBlockAnAdminCommandForANonAdmin() {
|
||||
when(command.needAdmin()).thenReturn(true);
|
||||
|
||||
assertFalse(precondition.check(context, command));
|
||||
|
||||
verify(context).replyEphemeral(DENIED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPassAnAdminCommandForAnAdmin() {
|
||||
when(command.needAdmin()).thenReturn(true);
|
||||
when(role.getPermissions()).thenReturn(EnumSet.of(Permission.ADMINISTRATOR));
|
||||
when(member.getRoles()).thenReturn(List.of(role));
|
||||
|
||||
assertTrue(precondition.check(context, command));
|
||||
|
||||
verify(context, never()).replyEphemeral(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldBlockWhenTheMemberCannotExecuteTheCommand() {
|
||||
when(command.canBeExecutedBy(member)).thenReturn(false);
|
||||
|
||||
assertFalse(precondition.check(context, command));
|
||||
|
||||
verify(context).replyEphemeral(DENIED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPassWhenEveryCheckSucceeds() {
|
||||
assertTrue(precondition.check(context, command));
|
||||
|
||||
verify(context, never()).replyEphemeral(anyString());
|
||||
}
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package me.bobert.netneutrality.core.command.precondition;
|
||||
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
*
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
import me.bobert.netneutrality.core.command.Command;
|
||||
import me.bobert.netneutrality.core.context.CommandContext;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class SlashCommandOnlyPreconditionTest {
|
||||
|
||||
@Mock
|
||||
private CommandContext context;
|
||||
|
||||
@Mock
|
||||
private Command command;
|
||||
|
||||
private final SlashCommandOnlyPrecondition precondition = new SlashCommandOnlyPrecondition();
|
||||
|
||||
@Test
|
||||
void shouldPassSlashOnlyCommandFromAnInteraction() {
|
||||
when(command.isSlashCommandOnly()).thenReturn(true);
|
||||
when(context.isInteraction()).thenReturn(true);
|
||||
|
||||
assertTrue(precondition.check(context, command));
|
||||
|
||||
verify(context, never()).replyEphemeral(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldBlockSlashOnlyCommandSentAsAMessage() {
|
||||
when(command.isSlashCommandOnly()).thenReturn(true);
|
||||
when(context.isInteraction()).thenReturn(false);
|
||||
|
||||
assertFalse(precondition.check(context, command));
|
||||
|
||||
verify(context).replyEphemeral("This command is only available as a slash command");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPassCommandThatIsNotSlashOnlyAsAMessage() {
|
||||
when(command.isSlashCommandOnly()).thenReturn(false);
|
||||
when(context.isInteraction()).thenReturn(false);
|
||||
|
||||
assertTrue(precondition.check(context, command));
|
||||
|
||||
verify(context, never()).replyEphemeral(anyString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package me.bobert.netneutrality.events;
|
||||
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import okhttp3.OkHttpClient;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.BlockingQueue;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class WebhookDispatcherTest {
|
||||
|
||||
private record Received(String signature, String body) {
|
||||
}
|
||||
|
||||
private HttpServer server;
|
||||
private WebhookDispatcher dispatcher;
|
||||
private final BlockingQueue<Received> received = new LinkedBlockingQueue<>();
|
||||
private final AtomicInteger failuresLeft = new AtomicInteger();
|
||||
|
||||
@BeforeEach
|
||||
void setup() throws Exception {
|
||||
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/hook", exchange -> {
|
||||
String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
|
||||
received.add(new Received(exchange.getRequestHeaders().getFirst("X-Signature"), body));
|
||||
int code = failuresLeft.getAndUpdate(n -> Math.max(0, n - 1)) > 0 ? 500 : 200;
|
||||
exchange.sendResponseHeaders(code, -1);
|
||||
exchange.close();
|
||||
});
|
||||
server.start();
|
||||
dispatcher = new WebhookDispatcher("http://127.0.0.1:" + server.getAddress().getPort() + "/hook",
|
||||
"s3cret", new OkHttpClient(), 16, 2, 10);
|
||||
dispatcher.start();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void teardown() {
|
||||
dispatcher.stop();
|
||||
server.stop(0);
|
||||
}
|
||||
|
||||
private Received next() throws InterruptedException {
|
||||
return received.poll(5, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldSignAndDeliverEvent() throws Exception {
|
||||
dispatcher.publish(MusicEvent.of(MusicEvent.Type.TRACK_START, "1", null));
|
||||
|
||||
Received r = next();
|
||||
assertNotNull(r);
|
||||
assertEquals("sha256=" + dispatcher.sign(r.body().getBytes(StandardCharsets.UTF_8)), r.signature());
|
||||
assertTrue(r.body().contains("TRACK_START"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRetryThenSucceed() throws Exception {
|
||||
failuresLeft.set(1);
|
||||
|
||||
dispatcher.publish(MusicEvent.of(MusicEvent.Type.QUEUE_UPDATED, "1", null));
|
||||
|
||||
assertNotNull(next());
|
||||
assertNotNull(next());
|
||||
assertNull(received.poll(300, TimeUnit.MILLISECONDS));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDropAfterRetriesExhausted() throws Exception {
|
||||
failuresLeft.set(100);
|
||||
|
||||
dispatcher.publish(MusicEvent.of(MusicEvent.Type.VOLUME_CHANGED, "1", null));
|
||||
|
||||
for (int i = 0; i < 3; i++) {
|
||||
assertNotNull(next());
|
||||
}
|
||||
assertNull(received.poll(300, TimeUnit.MILLISECONDS));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAssignPerGuildSequenceInOrder() throws Exception {
|
||||
dispatcher.publish(MusicEvent.of(MusicEvent.Type.TRACK_START, "1", null));
|
||||
dispatcher.publish(MusicEvent.of(MusicEvent.Type.TRACK_END, "1", null));
|
||||
dispatcher.publish(MusicEvent.of(MusicEvent.Type.TRACK_START, "2", null));
|
||||
|
||||
List<String> expected = List.of("\"seq\":1", "\"seq\":2", "\"seq\":1");
|
||||
for (String seq : expected) {
|
||||
Received r = next();
|
||||
assertNotNull(r);
|
||||
assertTrue(r.body().contains(seq), r.body());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void noopPublisherShouldMakeNoCalls() throws Exception {
|
||||
NoopEventPublisher.INSTANCE.publish(MusicEvent.of(MusicEvent.Type.TRACK_START, "1", null));
|
||||
|
||||
assertNull(received.poll(200, TimeUnit.MILLISECONDS));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package me.bobert.netneutrality.hooks;
|
||||
|
||||
import me.bobert.netneutrality.core.command.CommandRegistry;
|
||||
import me.bobert.netneutrality.core.tasks.TaskManager;
|
||||
import net.dv8tion.jda.api.events.Event;
|
||||
import net.dv8tion.jda.api.events.session.ReadyEvent;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
class EventsHandlerTest {
|
||||
private final CommandRegistry commands = mock(CommandRegistry.class);
|
||||
private final TaskManager tasks = mock(TaskManager.class);
|
||||
|
||||
@Test
|
||||
void multipleHandlersRunAndFailureIsIsolated() {
|
||||
List<String> calls = new ArrayList<>();
|
||||
EventsHandler dispatcher = new EventsHandler(List.of(), commands, tasks);
|
||||
dispatcher.addGenericListener(ReadyEvent.class, e -> {
|
||||
calls.add("first");
|
||||
throw new IllegalStateException("boom");
|
||||
});
|
||||
dispatcher.addGenericListener(ReadyEvent.class, e -> calls.add("second"));
|
||||
dispatcher.addGenericListener(Event.class, e -> calls.add("super"));
|
||||
|
||||
dispatcher.onEvent(mock(ReadyEvent.class));
|
||||
|
||||
assertEquals(List.of("first", "second", "super"), calls);
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,13 @@ import me.bobert.netneutrality.db.service.GuildServiceProvider;
|
||||
import me.bobert.netneutrality.db.service.GuildSettingService;
|
||||
import me.bobert.netneutrality.music.MusicService;
|
||||
import me.bobert.netneutrality.utils.SettingsProvider;
|
||||
import me.bobert.netneutrality.shard.ShardProvider;
|
||||
import net.dv8tion.jda.api.JDA;
|
||||
import net.dv8tion.jda.api.Permission;
|
||||
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.requests.restaction.CacheRestAction;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -24,8 +29,10 @@ import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
@@ -42,6 +49,7 @@ class WebApiTest {
|
||||
private WebApi webApi;
|
||||
private int port;
|
||||
private GuildSettingService settingService;
|
||||
private ShardProvider shards;
|
||||
private final HttpClient http = HttpClient.newHttpClient();
|
||||
|
||||
@BeforeEach
|
||||
@@ -56,14 +64,31 @@ class WebApiTest {
|
||||
when(guild.getMemberCount()).thenReturn(7);
|
||||
when(guild.getChannels()).thenReturn(List.of());
|
||||
when(guild.getRoles()).thenReturn(List.of());
|
||||
when(guild.getOwnerIdLong()).thenReturn(42L);
|
||||
|
||||
JDA jda = mock(JDA.class);
|
||||
when(jda.getGuilds()).thenReturn(List.of(guild));
|
||||
when(jda.getGuildById(GUILD_ID)).thenReturn(guild);
|
||||
when(jda.getGuildById(any(String.class))).thenAnswer(inv ->
|
||||
Member plain = mockMember(guild, 100L, List.of());
|
||||
Role manageServer = mock(Role.class);
|
||||
when(manageServer.getPermissions()).thenReturn(EnumSet.of(Permission.MANAGE_SERVER));
|
||||
Member admin = mockMember(guild, 200L, List.of(manageServer));
|
||||
Member owner = mockMember(guild, 42L, List.of());
|
||||
when(guild.getMemberById(any(String.class))).thenAnswer(inv -> switch ((String) inv.getArgument(0)) {
|
||||
case "100" -> plain;
|
||||
case "200" -> admin;
|
||||
case "42" -> owner;
|
||||
default -> null;
|
||||
});
|
||||
CacheRestAction<Member> failing = mock(CacheRestAction.class);
|
||||
when(failing.submit()).thenReturn(CompletableFuture.failedFuture(new IllegalStateException("unknown member")));
|
||||
when(guild.retrieveMemberById(any(String.class))).thenReturn(failing);
|
||||
|
||||
shards = mock(ShardProvider.class);
|
||||
when(shards.isReady()).thenReturn(true);
|
||||
when(shards.getGuilds()).thenReturn(List.of(guild));
|
||||
when(shards.getGuildById(any(String.class))).thenAnswer(inv ->
|
||||
GUILD_ID.equals(inv.getArgument(0)) ? guild : null);
|
||||
when(jda.getStatus()).thenReturn(JDA.Status.CONNECTED);
|
||||
when(jda.getGatewayPing()).thenReturn(12L);
|
||||
when(shards.getWorstStatus()).thenReturn(JDA.Status.CONNECTED);
|
||||
when(shards.getAveragePing()).thenReturn(12.0);
|
||||
when(shards.getShardCount()).thenReturn(3);
|
||||
|
||||
settingService = mock(GuildSettingService.class);
|
||||
when(settingService.snapshot()).thenReturn(Map.of("prefix", "ü!"));
|
||||
@@ -85,7 +110,7 @@ class WebApiTest {
|
||||
|
||||
MusicService musicService = mock(MusicService.class);
|
||||
|
||||
webApi = new WebApi(settings, provider, musicService, () -> jda, () -> { });
|
||||
webApi = new WebApi(settings, provider, musicService, shards, () -> { });
|
||||
webApi.start();
|
||||
}
|
||||
|
||||
@@ -135,6 +160,16 @@ class WebApiTest {
|
||||
assertEquals(200, response.statusCode());
|
||||
assertTrue(response.body().contains("\"health\":\"CONNECTED\""));
|
||||
assertTrue(response.body().contains("\"ping\":\"12\""));
|
||||
assertTrue(response.body().contains("\"shard_count\":3"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void routesReturnServiceUnavailableWhenShardsAreNotReady() throws Exception {
|
||||
when(shards.isReady()).thenReturn(false);
|
||||
|
||||
assertEquals(503, get("/stats/bot").statusCode());
|
||||
assertEquals(503, get("/actions/status").statusCode());
|
||||
assertEquals(503, get("/guilds").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -157,6 +192,70 @@ class WebApiTest {
|
||||
assertTrue(response.body().contains("\"owner_id\":\"42\""));
|
||||
}
|
||||
|
||||
private static Member mockMember(Guild guild, long id, List<Role> roles) {
|
||||
Member member = mock(Member.class);
|
||||
when(member.getId()).thenReturn(String.valueOf(id));
|
||||
when(member.getIdLong()).thenReturn(id);
|
||||
when(member.getGuild()).thenReturn(guild);
|
||||
when(member.getRoles()).thenReturn(roles);
|
||||
return member;
|
||||
}
|
||||
|
||||
@Test
|
||||
void userGuildsReturnsAccessFlagsForPlainMember() throws Exception {
|
||||
HttpResponse<String> response = get("/guilds?user_id=100");
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals("[{\"id\":\"" + GUILD_ID + "\",\"name\":\"My Guild\",\"icon\":\"https://example.com/icon.png\","
|
||||
+ "\"is_owner\":false,\"is_admin\":false,\"can_use_music\":true}]", response.body());
|
||||
}
|
||||
|
||||
@Test
|
||||
void userGuildsFlagsManageServerAsAdmin() throws Exception {
|
||||
HttpResponse<String> response = get("/guilds?user_id=200");
|
||||
|
||||
assertTrue(response.body().contains("\"is_owner\":false,\"is_admin\":true"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void userGuildsFlagsOwner() throws Exception {
|
||||
HttpResponse<String> response = get("/guilds?user_id=42");
|
||||
|
||||
assertTrue(response.body().contains("\"is_owner\":true,\"is_admin\":true"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void userGuildsForUnknownUserIsEmpty() throws Exception {
|
||||
HttpResponse<String> response = get("/guilds?user_id=999");
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals("[]", response.body());
|
||||
}
|
||||
|
||||
@Test
|
||||
void userGuildsWithMalformedUserIdIsRejected() throws Exception {
|
||||
assertEquals(400, get("/guilds?user_id=abc").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void memberAccessReturnsRolesAndFlags() throws Exception {
|
||||
HttpResponse<String> response = get("/guilds/" + GUILD_ID + "/members/200/access");
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertTrue(response.body().contains("\"user_id\":\"200\""));
|
||||
assertTrue(response.body().contains("\"is_owner\":false,\"is_admin\":true,\"can_use_music\":true"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void memberAccessForUserNotInGuildIsNotFound() throws Exception {
|
||||
assertEquals(404, get("/guilds/" + GUILD_ID + "/members/999/access").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void userGuildsRequiresApiKey() throws Exception {
|
||||
assertEquals(401, getWithoutKey("/guilds?user_id=100").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void guildsRequiresApiKey() throws Exception {
|
||||
assertEquals(401, getWithoutKey("/guilds").statusCode());
|
||||
@@ -170,6 +269,7 @@ class WebApiTest {
|
||||
assertTrue(response.body().contains("\"guild_count\":1"));
|
||||
assertTrue(response.body().contains("\"status\":\"CONNECTED\""));
|
||||
assertTrue(response.body().contains("\"gateway_ping\":12"));
|
||||
assertTrue(response.body().contains("\"shard_count\":3"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -206,7 +306,7 @@ class WebApiTest {
|
||||
|
||||
@Test
|
||||
void unknownGuildReturnsNotFound() throws Exception {
|
||||
HttpResponse<String> response = get("/guilds/channels/999");
|
||||
HttpResponse<String> response = get("/guilds/999/channels");
|
||||
|
||||
assertEquals(404, response.statusCode());
|
||||
assertTrue(response.body().contains("\"status\":\"error\""));
|
||||
|
||||
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user