Compare commits

..
Author SHA1 Message Date
anthorob 363e02f409 Delay autoplay triggering until the current track nears its end. 2026-10-31 17:09:46 -04:00
anthorob d43e032c37 fix 2026-10-31 16:15:24 -04:00
anthorob d97c4c6da2 **Add autoplay support for seamless music playback**
Introduced autoplay functionality to keep music playing when the queue ends. Added new `/autoplay` API endpoints, command handling, and service implementations. Enhanced `TrackScheduler`, queue operations, and testing to support autoplay behavior. Integrated YouTube radio mix as the initial provider.
2026-10-31 16:13:04 -04:00
anthorob 764adb6cbc Refactor event handling into modular handlers
Introduce discrete event handlers (`InteractionHandlers`, `GuildLifecycleHandler`, `MessageReceivedHandler`, and more) to replace centralized logic in `EventsHandler`. Simplify handler registration, improve separation of concerns, and enable better testability. Added unit tests to verify isolated execution and failure resilience of handlers.
2026-10-30 22:31:25 -04:00
anthorob e989bff9e3 Remove search results limit in SearchResultHandler 2026-10-30 09:01:39 -04:00
anthorob 0863e0723b **Add playlist identifier to LoadPlaylistTask**
Introduced a new `playlistIdentifier` field in `LoadPlaylistTask` to enhance playlist tracking. Updated relevant method calls to include and utilize the identifier, ensuring more robust metadata management and consistency across the codebase.
2026-10-28 16:55:58 -04:00
anthorob 142cc77ec8 **Add playlist metadata support to queue and events**
Enhanced queueing functionality to include playlist metadata, such as name, identifier, and track count. Updated event payloads to handle playlist information and introduced a new `playlist_added` event for streamlined notifications. Adjusted tests and documentation to reflect these changes.
2026-10-28 16:34:38 -04:00
anthorob d376e245b7 Add author and duration to queue metadata
Included track author and duration in queue metadata. Adjusted key naming for consistency with snake_case formatting.
2026-10-28 04:48:54 -04:00
anthorob 6e99fe5477 **Add artwork URL to queue metadata**
Enhanced queue metadata by including the track's artwork URL in the serialized output. This allows clients to display album art or related visuals alongside track information.
2026-10-28 04:21:13 -04:00
anthorob dba7a84819 **Fix nickname retrieval in GuildRoutes**
Replaced `getNickname()` with `getEffectiveName()` to ensure the correct name is retrieved, falling back to the username when no nickname is set. This improves accuracy and consistency in member summaries.
2026-10-28 03:58:31 -04:00
anthorob 87ba09b001 **Improve event handling with new music and queue updates**
Added new `MusicEvent` types, including `TRACK_ERROR`, `TRACK_STUCK`, and `PLAYER_POSITION`, providing enhanced diagnostics and feedback. Improved queue mutation notifications by introducing granular events like `added`, `removed`, `moved`, `cleared`, and `shuffled`. Updated tests and documentation to reflect the changes, ensuring consistency and clarity across the event system.
2026-10-28 03:58:19 -04:00
anthorob 75b495b08c **Replace JDA with ShardProvider for improved sharding**
Refactored core classes and APIs to use `ShardProvider` instead of `JDA` directly. Added `ShardService` for managing shard connections and activity, improving scalability and separation of concerns. Integrated test coverage to validate shard-level operations and bot endpoints.
2026-10-28 02:59:24 -04:00
anthorob b0664e8835 **Add webhook event publishing for music state changes**
Introduced webhook event publishing to push music-state updates to a configured endpoint. Implemented `WebhookDispatcher` and `NoopEventPublisher` for dispatching events asynchronously or disabling them when no URL is set. Added handling of events like track start/end, queue updates, volume changes, and voice connection changes, enhancing integration with external systems.
2026-10-26 11:43:07 -04:00
anthorob 7443cbfb4b **Refactor command usage printing into CommandUsageFormatter**
Extracted command usage formatting logic from `CommandContainer` into a dedicated `CommandUsageFormatter` class, improving separation of concerns and maintainability. Updated `CommandManager` initialization and added comprehensive unit tests to validate the formatter's functionality.
2026-10-26 09:49:56 -04:00
anthorob f96c2677b6 **Add unit tests for command framework preconditions**
Introduced comprehensive unit tests for precondition classes, including `DisabledCommandPrecondition`, `GuildOnlyPrecondition`, `SlashCommandOnlyPrecondition`, and `PermissionPrecondition`. Also added integration tests for `CommandManager` and `CommandProcessor` to validate functionality.
2026-10-24 05:39:06 -04:00
anthorob 8e4e77cd31 Rename SLASH type to CHAT in command framework.
Replaced all references to the `SLASH` command type with `CHAT` for improved clarity and alignment with its intended functionality. Updated relevant classes, methods, and test cases to reflect this change.
2026-10-24 05:32:51 -04:00
anthorob 10462a08a4 remove old files 2026-10-19 23:55:17 -04:00
anthorob 389af563b9 update docs 2026-10-19 08:21:42 -04:00
anthorob d3211c6702 Add vote-skip status endpoint and response DTO
Introduced the `GET /music/{guildId}/vote-skip` endpoint to retrieve the current vote-skip status of the playing track. Added `VoteSkipStatusResponse` DTO and OpenAPI schema for clear response structure. Updated `MusicManager` to expose the vote-skip threshold and integrated related logic into `MusicRoutes`.
2026-10-18 09:46:08 -04:00
anthorob f6ba30639a Add identifier field to PendingPlaylist in PlayOutcome
Updated `PendingPlaylist` to include a new `identifier` field, enabling more precise playlist identification. Adjusted `SearchResultHandler` and `MusicRoutes` to use and expose this field, ensuring integration across the system and improving API consistency.
2026-10-18 08:23:56 -04:00
anthorob 780016b731 Split QueueResponse and introduce SearchResponse
Decoupled `QueueResponse` and `SearchResponse` to better align with their distinct API endpoints and simplify response structures. Updated `PlayOutcome.PendingPlaylist` and `SearchResultHandler` to include playlist `name`, ensuring consistency across related features and improving response clarity. Adjusted OpenAPI documentation and routes to reflect the separation.
2026-10-18 05:35:57 -04:00
anthorob 01bf94c60a Switch to maven-dependency-plugin and adjust build output
Replaced `maven-shade-plugin` with `maven-dependency-plugin` for dependency management, simplifying the build process and separating runtime dependencies into a dedicated `lib/` directory. Updated `Dockerfile` and GitHub Actions config to account for these changes, ensuring all dependencies are correctly packaged and deployed.
2026-10-18 02:10:38 -04:00
anthorob dad2e0356e Introduce multistage Docker build for optimized image size
Implemented a multistage Docker build, separating build and runtime stages to reduce the final image size and improve security. Adjusted `.dockerignore` to exclude unnecessary files from the build context and updated the base JRE image to use the build output efficiently.
2026-10-18 02:04:56 -04:00
anthorob d0ea0535e2 Add health checks and dependency conditions in docker-compose.yml
Introduced health checks for `netneutrality-db` to ensure proper readiness before dependent services start. Replaced static sleep in `startup.sh` with dynamic database availability checks, improving startup reliability and reducing downtime risks.
2026-10-18 01:53:10 -04:00
anthorob 78d6ade17a Optimize isUserDj logic 2026-10-18 01:49:46 -04:00
anthorob cb47a1622f Refactor MusicAccess permission checks
Reordered admin check in `MusicAccess` for efficiency by prioritizing it before role-related logic. This improves readability and minimizes unnecessary role lookups for admin users.
2026-10-14 08:32:32 -04:00
anthorob 545c09d502 clean 2026-10-14 08:24:33 -04:00
anthorob 6134c893ab fix 2026-10-14 08:12:12 -04:00
anthorob 91b6de708d Standardize guild API endpoints and add music access checks
Unified paths in guild-related endpoints to include consistent `{guildId}` structure, improving endpoint clarity and alignment with REST routing conventions. Added centralized `MusicAccess` utility to streamline music feature permission checks, replacing scattered logic in `MusicCommand`. Updated OpenAPI documentation and added tests for new and updated behaviors.
2026-10-14 08:02:31 -04:00
anthorob bedc8b9f02 Switch to MariaDBDialect in Hibernate configuration
Replaced `MySQLDialect` with `MariaDBDialect` in `hibernate.cfg.xml` to ensure compatibility with MariaDB. This change aligns the Hibernate dialect with the underlying database and avoids potential SQL compatibility issues.
2026-10-13 13:18:10 -04:00
anthorob abdd2d1656 Replace GitLab integration with Gitea in issue reporting
Migrated issue reporting functionality from GitLab to Gitea. Updated environment variables, dependencies, and configuration references to support Gitea. Introduced `GiteaClient` for communication with the Gitea API and refactored related code and tests to align with the new integration.
2026-10-13 13:16:54 -04:00
112 changed files with 5102 additions and 829 deletions

No files matched your search

+1 -8
View File
@@ -1,15 +1,8 @@
.git .git
.github .github
.idea .idea
.mvn
src
classes classes
logs logs
target/* target
!target/netneutrality.jar
*.iml *.iml
README.md README.md
checkstyle.xml
mvnw
mvnw.cmd
pom.xml
+6 -3
View File
@@ -25,6 +25,9 @@ NETNEUTRALITY_WEBPO_URL=http://webpo:8080
NETNEUTRALITY_WEBPO_PASSWORD= NETNEUTRALITY_WEBPO_PASSWORD=
NETNEUTRALITY_SENTRY_DSN= NETNEUTRALITY_SENTRY_DSN=
GITLAB_HOST= NETNEUTRALITY_WEBHOOK_URL=
GITLAB_TOKEN= NETNEUTRALITY_WEBHOOK_SECRET=
GITLAB_PROJECT_ID= GITEA_HOST=
GITEA_TOKEN=
GITEA_OWNER=
GITEA_REPO=
+1
View File
@@ -50,6 +50,7 @@ jobs:
name: maven-artifacts name: maven-artifacts
path: | path: |
target/netneutrality.jar target/netneutrality.jar
target/lib
startup.sh startup.sh
if-no-files-found: error if-no-files-found: error
+13 -1
View File
@@ -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 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/* 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} EXPOSE ${API_PORT}
COPY startup.sh ./ 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"] RUN ["chmod", "+x", "./startup.sh"]
CMD ["./startup.sh"] CMD ["./startup.sh"]
+91 -6
View File
@@ -1,15 +1,100 @@
# Net Neutrality Discord bot # Net Neutrality Discord Bot
This is the source code for my custom discord bot. 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, so I offer no support on how to build it.
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 ```bash
docker compose build docker compose build
docker compose up -d docker compose up -d
``` ```
The environment variables needed for the bot to run are available in the file **bot.properties.example** 3. View logs:
If running locally without Docker, you need to copy the example file to **bot.properties** and fill the values.
```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.
+8 -3
View File
@@ -6,6 +6,8 @@ NETNEUTRALITY_BOT_PREFIX=$
NETNEUTRALITY_BOT_OWNER= NETNEUTRALITY_BOT_OWNER=
NETNEUTRALITY_BOT_INVITE= NETNEUTRALITY_BOT_INVITE=
NETNEUTRALITY_API_PORT=9000 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_HOST=127.0.0.1
MYSQL_PORT=3306 MYSQL_PORT=3306
@@ -25,6 +27,9 @@ NETNEUTRALITY_LAVALINK_PASSWORD=youshallnotpass
NETNEUTRALITY_LAVALINK_NAME=main NETNEUTRALITY_LAVALINK_NAME=main
NETNEUTRALITY_SENTRY_DSN= NETNEUTRALITY_SENTRY_DSN=
GITLAB_HOST= NETNEUTRALITY_WEBHOOK_URL=
GITLAB_TOKEN= NETNEUTRALITY_WEBHOOK_SECRET=
GITLAB_PROJECT_ID= GITEA_HOST=
GITEA_TOKEN=
GITEA_OWNER=
GITEA_REPO=
-2
View File
@@ -1,2 +0,0 @@
#!/bin/bash
./mvnw versions:commit
-2
View File
@@ -1,2 +0,0 @@
#!/bin/bash
./mvnw versions:set -DnewVersion=$CZ_PRE_NEW_VERSION
+10 -2
View File
@@ -13,6 +13,12 @@ services:
- MYSQL_DATABASE=${MYSQL_DATABASE:-netneutrality} - MYSQL_DATABASE=${MYSQL_DATABASE:-netneutrality}
- MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD:-password} - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD:-password}
- MARIADB_AUTO_UPGRADE=1 - MARIADB_AUTO_UPGRADE=1
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 5s
timeout: 5s
retries: 20
start_period: 30s
netneutrality-bot: netneutrality-bot:
image: gitea.bobert.me/anthorob/netneutrality:${NETNEUTRALITY_TAG:-latest} image: gitea.bobert.me/anthorob/netneutrality:${NETNEUTRALITY_TAG:-latest}
@@ -22,8 +28,10 @@ services:
networks: networks:
- netneutrality - netneutrality
depends_on: depends_on:
- netneutrality-db netneutrality-db:
- lavalink condition: service_healthy
lavalink:
condition: service_started
volumes: volumes:
- ./logs:/app/logs - ./logs:/app/logs
environment: environment:
+1 -1
View File
@@ -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 | | [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 | | [persistence.md](persistence.md) | Database — Hibernate/JPA, Flyway, entities, repositories, services |
| [music.md](music.md) | Music — Lavalink node & client, queue, event dispatch | | [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 | | [threading.md](threading.md) | Threading & scheduling infrastructure |
| [utilities-and-bootstrap.md](utilities-and-bootstrap.md) | Utilities, configuration, logging, exceptions | | [utilities-and-bootstrap.md](utilities-and-bootstrap.md) | Utilities, configuration, logging, exceptions |
+2 -4
View File
@@ -52,7 +52,6 @@ infrastructure.
| JDBC driver | `com.mysql:mysql-connector-j` | 9.7.0 | | JDBC driver | `com.mysql:mysql-connector-j` | 9.7.0 |
| DB migrations | `org.flywaydb:flyway-core` / `flyway-mysql` | 11.20.3 | | DB migrations | `org.flywaydb:flyway-core` / `flyway-mysql` | 11.20.3 |
| Error tracking | `io.sentry:sentry` | 8.41.0 | | Error tracking | `io.sentry:sentry` | 8.41.0 |
| GitLab integration | `org.gitlab4j:gitlab4j-api` | 6.2.0 |
| HTTP client | `com.squareup.okhttp3:okhttp-jvm` | 5.3.2 | | HTTP client | `com.squareup.okhttp3:okhttp-jvm` | 5.3.2 |
| JSON | `org.json:json` + `com.google.code.gson:gson` | — | | JSON | `org.json:json` + `com.google.code.gson:gson` | — |
| Classpath scanning | `org.reflections:reflections` | 0.10.2 | | Classpath scanning | `org.reflections:reflections` | 0.10.2 |
@@ -60,8 +59,8 @@ infrastructure.
| Logging | Log4j2 + `log4j-slf4j2-impl` | 2.26.0 | | Logging | Log4j2 + `log4j-slf4j2-impl` | 2.26.0 |
| Testing | JUnit Jupiter 6 + Mockito 5 | — | | Testing | JUnit Jupiter 6 + Mockito 5 | — |
The HTTP **server** for the internal API uses the JDK built-in The HTTP **server** for the internal API uses embedded Javalin (with OpenAPI/Swagger plugins); see
`com.sun.net.httpserver.HttpServer` (no external framework); see [web-api.md](web-api.md). [web-api.md](web-api.md).
## Startup lifecycle ## Startup lifecycle
@@ -158,5 +157,4 @@ block the others. Effectively this means:
| `…http` | Internal web API and HTTP client module | | `…http` | Internal web API and HTTP client module |
| `…music` | Music domain (service, manager, queue, scheduler, track loading) | | `…music` | Music domain (service, manager, queue, scheduler, track loading) |
| `…utils` | `Settings` / `SettingsProvider`, `ChannelMessagePurger`, `MusicUtils` | | `…utils` | `Settings` / `SettingsProvider`, `ChannelMessagePurger`, `MusicUtils` |
| `…shared.util` | Cross-cutting helpers |
| `…exceptions` | Custom exception hierarchy | | `…exceptions` | Custom exception hierarchy |
+6 -5
View File
@@ -1,7 +1,7 @@
# Commands # Commands
**Packages:** `me.bobert.netneutrality.framework.command` (framework), **Packages:** `me.bobert.netneutrality.core.command` (framework),
`me.bobert.netneutrality.feature.*.command` (concrete commands) `me.bobert.netneutrality.command.*` (concrete commands)
A **Command** is what a user executes on Discord. It receives a 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 [`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 ## 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 | | Command | Package | Type | Description |
|---|---|---|---| |---|---|---|---|
@@ -139,7 +139,7 @@ Concrete commands live under `me.bobert.netneutrality.feature.*.command`, groupe
| `PingCommand` | `basic` | Slash | Reply with the gateway ping. | | `PingCommand` | `basic` | Slash | Reply with the gateway ping. |
| `ConfigureCommand` | `bot` | Slash | Configure the bot for the server (subcommands). | | `ConfigureCommand` | `bot` | Slash | Configure the bot for the server (subcommands). |
| `CryptoPriceCommand` | `crypto` | Slash | Fetch cryptocurrency prices. | | `CryptoPriceCommand` | `crypto` | Slash | Fetch cryptocurrency prices. |
| `ReportIssueCommand` | `git` | Slash | Report a bug (opens the GitLab report flow). | | `ReportIssueCommand` | `git` | Slash | Report a bug (opens the Gitea report flow). |
| `PlayCommand` | `music` | Slash | Search/queue and start playback. | | `PlayCommand` | `music` | Slash | Search/queue and start playback. |
| `QueueCommand` / `ViewQueueCommand` | `music` | Slash | View/manage the queue. | | `QueueCommand` / `ViewQueueCommand` | `music` | Slash | View/manage the queue. |
| `SkipCommand` / `VoteSkipCommand` | `music` | Slash | Skip (or vote-skip) the current track. | | `SkipCommand` / `VoteSkipCommand` | `music` | Slash | Skip (or vote-skip) the current track. |
@@ -147,6 +147,7 @@ Concrete commands live under `me.bobert.netneutrality.feature.*.command`, groupe
| `JoinCommand` / `LeaveCommand` | `music` | Slash | Join / leave the voice channel. | | `JoinCommand` / `LeaveCommand` | `music` | Slash | Join / leave the voice channel. |
| `CurrentlyPlayingCommand` | `music` | Slash | Show the currently playing track. | | `CurrentlyPlayingCommand` | `music` | Slash | Show the currently playing track. |
| `ResumeCommand` / `StopCommand` / `VolumeCommand` | `music` | Slash | Playback controls. | | `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. | | `BitconnectCommand` | `music` | Slash | Novelty music command. |
| `CreateEmojiReaction` / `ModifyEmojiReaction` | `react` | Message menu | Create/modify emoji-role mappings from a message. | | `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). | | `DatabaseCommand`, `PurgeCommand`, `ShutdownCommand`, `JoinClanCommand`, `TestCommand`, `SlashCommandTest`, `TrackStuckTest` | `custom` | Mixed | Owner/debug and personal utilities (the `custom` package is gitignored per-deployment). |
+3 -3
View File
@@ -1,7 +1,7 @@
# Contexts # Contexts
**Packages:** `me.bobert.netneutrality.framework.context` (framework), **Packages:** `me.bobert.netneutrality.core.context` (framework),
`me.bobert.netneutrality.framework.context` (concrete implementations) `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 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 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` ### `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 `InteractionHook` (for interactions) or a `Message` (for message replies) — so follow-up actions are
expressed the same way regardless of the original trigger: expressed the same way regardless of the original trigger:
+16 -1
View File
@@ -1,6 +1,6 @@
# Event Handling & Routing # Event Handling & Routing
**Package:** `me.bobert.netneutrality.platform.event` **Package:** `me.bobert.netneutrality.hooks`
**Primary class:** `EventsHandler` **Primary class:** `EventsHandler`
`EventsHandler` is the single JDA `EventListener` registered on the `JDA` instance `EventsHandler` is the single JDA `EventListener` registered on the `JDA` instance
@@ -91,6 +91,21 @@ Key points:
`TaskManager.getInstance().handle(context)`. `TaskManager.getInstance().handle(context)`.
- **`null` contexts** (bot messages, consumer-only events) are simply not routed anywhere. - **`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 ## Extending the event system
To handle a new event type: To handle a new event type:
+1 -1
View File
@@ -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 All music commands extend [`MusicCommand`](commands.md#base-implementations-corecommandimpl) (guild
only + music role/channel enforced): `PlayCommand`, `SkipCommand`/`VoteSkipCommand`, only + music role/channel enforced): `PlayCommand`, `SkipCommand`/`VoteSkipCommand`,
`QueueCommand`/`ViewQueueCommand`, `ClearQueueCommand`, `ShuffleCommand`, `JoinCommand`/`LeaveCommand`, `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). [commands.md](commands.md#command-catalog).
+2 -2
View File
@@ -1,6 +1,6 @@
# Persistence & Database # Persistence & Database
**Packages:** `me.bobert.netneutrality.platform.persistence` (session/migration), **Packages:** `me.bobert.netneutrality.db` (session/migration),
`me.bobert.netneutrality.db` (entities, repositories, services). `me.bobert.netneutrality.db` (entities, repositories, services).
**Resources:** `hibernate.cfg.xml`, `db/migration/V1__initial_schema.sql`, **Resources:** `hibernate.cfg.xml`, `db/migration/V1__initial_schema.sql`,
`db/migration/V2__task_type_id.sql`. `db/migration/V2__task_type_id.sql`.
@@ -45,7 +45,7 @@ Repositories obtain sessions through the factory; transactional access uses help
## Entity catalog ## 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`. of a star-shaped model; most other tables reference `guild_id` with `ON DELETE CASCADE`.
| Entity | Table | Key | Relationships / notes | | Entity | Table | Key | Relationships / notes |
+7 -8
View File
@@ -1,7 +1,7 @@
# Tasks # Tasks
**Packages:** `me.bobert.netneutrality.framework.task` (framework), **Packages:** `me.bobert.netneutrality.core.tasks` (framework),
`me.bobert.netneutrality.feature.*.task` (concrete tasks) `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 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 up and executed by **id** when a matching Discord event arrives, as opposed to a
@@ -60,7 +60,7 @@ AbstractTask (implements Task)
## `TaskManager` ## `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. coordinator.
### Registration — `insert` ### Registration — `insert`
@@ -249,7 +249,7 @@ Tasks can opt into extra behavior by implementing marker/behavior interfaces in
| `EmojiReactionRoleTask` | `emoji` | `EmojiTask` (persistable) | Assign/remove a role when a user reacts with a mapped emoji. | | `EmojiReactionRoleTask` | `emoji` | `EmojiTask` (persistable) | Assign/remove a role when a user reacts with a mapped emoji. |
| `AcceptButton` | `generic` | `ButtonTask` | Generic "accept" button running a supplied function. | | `AcceptButton` | `generic` | `ButtonTask` | Generic "accept" button running a supplied function. |
| `CancelButton` | `generic` | `ButtonTask` | Generic "cancel" button running a supplied function. | | `CancelButton` | `generic` | `ButtonTask` | Generic "cancel" button running a supplied function. |
| `ReportIssueTask` | `git` | `ModalTask` | Modal to report a bug; creates a GitLab issue. | | `ReportIssueTask` | `git` | `ModalTask` | Modal to report a bug; creates a Gitea issue. |
| `BotInfo` | `help` | `AbstractBuiltInTask` | Show bot info + invite link. | | `BotInfo` | `help` | `AbstractBuiltInTask` | Show bot info + invite link. |
| `CommandHelp` | `help` | `AbstractBuiltInTask` | Show command help + the bot prefix. | | `CommandHelp` | `help` | `AbstractBuiltInTask` | Show command help + the bot prefix. |
| `ReportBug` | `help` | `AbstractBuiltInTask` | Start a bug-report flow / provide the report link. | | `ReportBug` | `help` | `AbstractBuiltInTask` | Start a bug-report flow / provide the report link. |
@@ -275,12 +275,11 @@ Tasks can opt into extra behavior by implementing marker/behavior interfaces in
on the next startup via the on the next startup via the
[`PersistableTaskRegistry`](#persistabletaskregistry--discovery-and-restore). [`PersistableTaskRegistry`](#persistabletaskregistry--discovery-and-restore).
### Worked example — GitLab bug report (modal) ### Worked example — Gitea bug report (modal)
1. `ReportIssueTask.replyToInteraction` opens a modal with **title** and **description** fields. 1. `ReportIssueTask.replyToInteraction` opens a modal with **title** and **description** fields.
2. On submit, `ModalTaskContext` routes back to the task; `execute` reads the field values. 2. On submit, `ModalTaskContext` routes back to the task; `execute` reads the field values.
3. The task connects to GitLab (using `GITLAB_HOST` / `GITLAB_TOKEN` / `GITLAB_PROJECT_ID` from 3. The task calls the Gitea API via `GiteaClient` (using `GITEA_HOST` / `GITEA_TOKEN` /`r`n `GITEA_OWNER` / `GITEA_REPO` from configuration) and creates an issue, then returns `Lifecycle.REMOVE_SELF`.
configuration) and creates an issue, then returns `Lifecycle.REMOVE_SELF`.
### Worked example — music results (parent/children) ### Worked example — music results (parent/children)
+4 -4
View File
@@ -1,6 +1,6 @@
# Threading & Scheduling # 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. This subsystem centralizes asynchronous execution and recurring jobs.
@@ -21,11 +21,11 @@ This subsystem centralizes asynchronous execution and recurring jobs.
## `BaseThreadFactory` ## `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 an [`ExecutorException`](utilities-and-bootstrap.md#exceptions) as the thread's
`UncaughtExceptionHandler` so crashes on worker threads are still routed through `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 [`LogHandler`](utilities-and-bootstrap.md#loghandler). It is used by `ThreadManager`; the
[Web API](web-api.md) server pool. [Web API](web-api.md) runs on Javalin's own thread pool instead.
## `ScheduledTaskManager` ## `ScheduledTaskManager`
+13 -13
View File
@@ -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). | | `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.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()`. | | `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 The full startup and shutdown sequences are documented in
[architecture.md](architecture.md#startup-lifecycle). [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 | | Interface | Typical implementor |
|-----------------------------------------|---------------------------------------| |-----------------------------------------|---------------------------------------|
| `platform.config.SettingsProvider` | `utils.Settings` | | `utils.SettingsProvider` | `utils.Settings` |
| `platform.persistence.SessionProvider` | `core.db.SessionManager` | | `db.SessionProvider` | `db.SessionManager` |
| `platform.concurrency.ExecutorProvider` | `core.thread.ThreadManager` | | `core.thread.ExecutorProvider` | `core.thread.ThreadManager` |
| `framework.command.CommandRegistry` | `core.command.CommandManager` | | `core.command.CommandRegistry` | `core.command.CommandManager` |
| `framework.task.TaskManager` | `core.tasks.TaskManagerImpl` | | `core.tasks.TaskManager` | `core.tasks.TaskManagerImpl` |
| `feature.music.MusicService` | `music.MusicServiceImpl` | | `music.MusicService` | `music.MusicServiceImpl` |
| `data.service.GuildServiceProvider` | `db.service.GuildServiceProviderImpl` | | `db.service.GuildServiceProvider` | `db.service.GuildServiceProviderImpl` |
There are **no** `getInstance()` / `instance()` shims on the managers above. The only intentional There are **no** `getInstance()` / `instance()` shims on the managers above. The only intentional
static access points left are: static access points left are:
@@ -60,8 +60,8 @@ static access points left are:
### `Settings` / `SettingsProvider` ### `Settings` / `SettingsProvider`
**Class:** `me.bobert.netneutrality.platform.config.Settings` implements **Class:** `me.bobert.netneutrality.utils.Settings` implements
`me.bobert.netneutrality.platform.config.SettingsProvider`. `me.bobert.netneutrality.utils.SettingsProvider`.
`Settings` is the single source of truth for configuration. It loads values from the `bot.properties` `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). file and overlays **environment variables** on top (environment variables win).
@@ -84,13 +84,13 @@ An example configuration file is `bot.properties.example`. Key configuration val
| `NETNEUTRALITY_API_PORT` | Port for the [Web API](web-api.md) (default `9000`). | | `NETNEUTRALITY_API_PORT` | Port for the [Web API](web-api.md) (default `9000`). |
| `MYSQL_*` | Database connection settings (see [persistence.md](persistence.md)). | | `MYSQL_*` | Database connection settings (see [persistence.md](persistence.md)). |
| `NETNEUTRALITY_SENTRY_DSN` | Enables Sentry when present. | | `NETNEUTRALITY_SENTRY_DSN` | Enables Sentry when present. |
| `GITLAB_HOST` / `GITLAB_TOKEN` / `GITLAB_PROJECT_ID` | GitLab issue reporting ([`ReportIssueTask`](tasks.md#worked-example--gitlab-bug-report-modal)). | | `GITEA_HOST` / `GITEA_TOKEN` / `GITEA_OWNER` / `GITEA_REPO` | Gitea issue reporting ([`ReportIssueTask`](tasks.md#worked-example--gitea-bug-report-modal)). Token needs `write:issue`. |
| `NETNEUTRALITY_LAVALINK_URI` / `_PASSWORD` / `_NAME` | Connection settings for the [Lavalink node](music.md#settings). | | `NETNEUTRALITY_LAVALINK_URI` / `_PASSWORD` / `_NAME` | Connection settings for the [Lavalink node](music.md#settings). |
| `NETNEUTRALITY_PO_TOKEN` / `NETNEUTRALITY_VISITOR_DATA` / `NETNEUTRALITY_CIPHER_*` | YouTube auth/cipher settings, consumed by the node via `lavalink/application.yml`. | | `NETNEUTRALITY_PO_TOKEN` / `NETNEUTRALITY_VISITOR_DATA` / `NETNEUTRALITY_CIPHER_*` | YouTube auth/cipher settings, consumed by the node via `lavalink/application.yml`. |
## Utility helpers ## 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 | | Class | Purpose |
|---|---| |---|---|
@@ -125,7 +125,7 @@ singletons.
### `LogHandler` ### `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: `LogHandler` is the unified logging/error entry point. It routes messages and exceptions to:
+71 -31
View File
@@ -1,52 +1,92 @@
# HTTP Web API # HTTP Web API
**Package:** `me.bobert.netneutrality.platform.web` (client module) and **Package:** `me.bobert.netneutrality.http` (outbound client) and
`me.bobert.netneutrality.platform.web.server` (server). `me.bobert.netneutrality.http.server` (server).
**Server:** JDK built-in `com.sun.net.httpserver.HttpServer` (no external web framework). **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 — The bot exposes an internal HTTP API for health monitoring, control, guild introspection, and music
intended for an external dashboard/companion, not for end users in Discord. control — intended for an external dashboard/companion, not for end users in Discord.
## Server startup ## Server startup
`WebApi` is a [`Lifecycle`](utilities-and-bootstrap.md#applicationcontext--lifecycle) component `WebApi` is a [`Lifecycle`](utilities-and-bootstrap.md#applicationcontext--lifecycle) component
(priority 70) registered in [`App.buildContext()`](architecture.md#startup-lifecycle). (priority 70) registered in [`App.buildContext()`](architecture.md#startup-lifecycle).
It receives `SettingsProvider`, `GuildServiceProvider`, and suppliers for the live `JDA` / It receives `SettingsProvider`, `GuildServiceProvider`, `MusicService`, and suppliers for the live
shutdown callback via its constructor. On `start()` it: `JDA` / shutdown callback via its constructor. On `start()` it:
- reads the port from `NETNEUTRALITY_API_PORT` (default `9000`), - reads the port from `NETNEUTRALITY_API_PORT` (default `9000`),
- creates an `HttpServer` bound to that port, - creates a `Javalin` app (Javalin 7 requires routes and exception/error handlers to be registered
- installs an `Executor` backed by a fixed thread pool of 10 threads named via upfront inside `Javalin.create`),
[`BaseThreadFactory`](threading.md), - registers the `OpenApiPlugin` (title "Net Neutrality API", `X-API-Key` security scheme) and the
- registers the handler contexts (below), and `SwaggerPlugin`,
- starts the server. - 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 `ApiKeyFilter` compares the `X-API-Key` request header with the `NETNEUTRALITY_API_KEY` setting.
`sendJsonResponse(...)` and `sendStringResponse(...)`. Concrete handlers extend it and are mounted at A missing/invalid key — or no configured key at all — yields `401` ("Missing or invalid API key").
a path prefix. Public paths that skip the check: `/`, and anything starting with `/openapi`, `/swagger` or
`/webjars` (Swagger UI and its assets).
| Handler | Base path | Endpoints | ## Error handling
|---|---|---|
| `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. |
The action/guild handlers read live state from the [`JDA`](architecture.md) instance and from the - `HttpResponseException` (e.g. `BadRequestResponse`, `NotFoundResponse`) →
[persistence services](persistence.md#service-layer) (e.g. `GuildSettingService` for the settings `{"status":"error","message":"..."}` with the exception's status.
endpoints). - 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` ## HTTP client — `WebModule`
`WebModule` wraps an `OkHttpClient` for the bot's own outbound HTTP calls (e.g. checking for updates `WebModule` (package `me.bobert.netneutrality.http`) wraps an `OkHttpClient` for the bot's own
via GitLab). It keeps a small `LinkedHashMap`-based response cache limited to 50 entries. outbound HTTP calls (e.g. checking for updates via GitLab). Responses are cached in
`ResponseCache`, a `LinkedHashMap`-based LRU limited to 50 entries.
## Notes ## Notes
- There is no authentication layer documented on these endpoints; the API is expected to be bound to a - All routes except the public ones require the API key. Even so, the API is expected to be bound to
trusted/internal network (e.g. within the Docker network). `/actions/shutdown` is destructive, so the a trusted/internal network (e.g. within the Docker network); `/actions/shutdown` is destructive,
port should not be exposed publicly. so the port should not be exposed publicly.
+12 -54
View File
@@ -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" <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"> 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> <artifactId>maven-jar-plugin</artifactId>
<version>3.5.0</version> <version>3.5.0</version>
<configuration> <configuration>
<finalName>netneutrality</finalName>
<archive> <archive>
<manifest> <manifest>
<mainClass>${main.class}</mainClass> <mainClass>${main.class}</mainClass>
<addClasspath>true</addClasspath>
<classpathPrefix>lib/</classpathPrefix>
</manifest> </manifest>
</archive> </archive>
</configuration> </configuration>
@@ -97,52 +100,18 @@
<plugin> <plugin>
<groupId>org.apache.maven.plugins</groupId> <groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId> <artifactId>maven-dependency-plugin</artifactId>
<version>3.6.2</version> <version>3.8.1</version>
<executions> <executions>
<execution> <execution>
<phase>package</phase> <id>copy-libs</id>
<phase>prepare-package</phase>
<goals> <goals>
<goal>shade</goal> <goal>copy-dependencies</goal>
</goals> </goals>
<configuration> <configuration>
<finalName>netneutrality</finalName> <outputDirectory>${project.build.directory}/lib</outputDirectory>
<filters> <includeScope>runtime</includeScope>
<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>
</configuration> </configuration>
</execution> </execution>
</executions> </executions>
@@ -208,6 +177,7 @@
<version>5.3.2</version> <version>5.3.2</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>io.javalin</groupId> <groupId>io.javalin</groupId>
<artifactId>javalin</artifactId> <artifactId>javalin</artifactId>
@@ -272,18 +242,6 @@
<artifactId>sentry</artifactId> <artifactId>sentry</artifactId>
<version>8.41.0</version> <version>8.41.0</version>
</dependency> </dependency>
<dependency>
<groupId>org.gitlab4j</groupId>
<artifactId>gitlab4j-api</artifactId>
<version>6.2.0</version>
<exclusions>
<!-- Clashes with the Servlet API used by Javalin's Jetty 12 in the shaded jar. -->
<exclusion>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency> <dependency>
<groupId>net.fellbaum</groupId> <groupId>net.fellbaum</groupId>
+33 -58
View File
@@ -6,7 +6,6 @@ package me.bobert.netneutrality;
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import dev.arbjerg.lavalink.libraries.jda.JDAVoiceUpdateListener;
import io.sentry.Sentry; import io.sentry.Sentry;
import me.bobert.netneutrality.bootstrap.ApplicationContext; import me.bobert.netneutrality.bootstrap.ApplicationContext;
import me.bobert.netneutrality.bootstrap.Lifecycle; 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.LogHandler;
import me.bobert.netneutrality.core.command.CommandManager; import me.bobert.netneutrality.core.command.CommandManager;
import me.bobert.netneutrality.db.SessionManager; 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.tasks.TaskManagerImpl;
import me.bobert.netneutrality.core.scheduled.ScheduledTaskManager; import me.bobert.netneutrality.core.scheduled.ScheduledTaskManager;
import me.bobert.netneutrality.core.thread.ThreadManager; 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.music.MusicService;
import me.bobert.netneutrality.core.command.CommandRegistry; import me.bobert.netneutrality.core.command.CommandRegistry;
import me.bobert.netneutrality.core.tasks.TaskManager; import me.bobert.netneutrality.core.tasks.TaskManager;
import me.bobert.netneutrality.hooks.EventListenerHandler;
import me.bobert.netneutrality.hooks.EventsHandler; 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.http.server.WebApi;
import me.bobert.netneutrality.music.MusicServiceImpl; import me.bobert.netneutrality.music.MusicServiceImpl;
import me.bobert.netneutrality.core.thread.ExecutorProvider; 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.db.repository.TaskRepository;
import me.bobert.netneutrality.utils.Settings; import me.bobert.netneutrality.utils.Settings;
import me.bobert.netneutrality.http.WebModule; import me.bobert.netneutrality.http.WebModule;
import net.dv8tion.jda.api.JDA; import me.bobert.netneutrality.shard.ShardService;
import net.dv8tion.jda.api.JDABuilder;
import net.dv8tion.jda.api.entities.Activity; 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.Level;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
@@ -50,8 +57,7 @@ public class App {
private static final Logger logger = LogManager.getLogger(App.class); private static final Logger logger = LogManager.getLogger(App.class);
private static App instance; private static App instance;
private final Settings settings; private final Settings settings;
private JDA jda; private EventPublisher events = NoopEventPublisher.INSTANCE;
private MusicService music;
private final ApplicationContext context = new ApplicationContext(); private final ApplicationContext context = new ApplicationContext();
static void main() { static void main() {
@@ -146,22 +152,33 @@ public class App {
UserFunction.bind(guildServices); UserFunction.bind(guildServices);
DisabledCommandPrecondition.bind(guildServices); DisabledCommandPrecondition.bind(guildServices);
// Music sources (priority 40): initialise the shared player manager; disconnect on shutdown. // Webhook event dispatch (priority 50); stays a no-op publisher when no URL is configured.
music = context.register(new MusicServiceImpl(settings, guildServices, tasks, executors)); 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); BotState.bind(guildServices, music);
// JDA gateway; disconnected on shutdown. // JDA shards (priority 60); the events handler is attached to every shard before it connects.
context.register(lifecycle(60, this::initJDA, this::disconnect)); 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, () -> { context.register(lifecycle(65, () -> {
commands.registerSlashCommands(jda); shards.setActivity(Activity.watching("you"));
jda.addEventListener(new EventsHandler(settings, guildServices, commands, tasks));
jda.getPresence().setActivity(Activity.watching("you"));
}, () -> { })); }, () -> { }));
// Web API (priority 70) — Lifecycle with graceful stop. // 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) { private static Lifecycle lifecycle(int priority, ThrowingRunnable start, Runnable stop) {
@@ -192,52 +209,10 @@ public class App {
return instance; 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() { public void shutdown() {
logger.info("[Net Neutrality] Shutting down..."); logger.info("[Net Neutrality] Shutting down...");
context.stop(); context.stop();
logger.info("[Net Neutrality] Shutdown complete"); logger.info("[Net Neutrality] Shutdown complete");
} }
public JDA getJDA() {
return jda;
}
private void disconnect() {
if (jda != null) {
jda.shutdown();
}
}
} }
@@ -10,11 +10,10 @@ import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.CommandOptionsBuilder; import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.impl.AbstractCommand; import me.bobert.netneutrality.core.command.impl.AbstractCommand;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.http.WebModule; import me.bobert.netneutrality.utils.GiteaClient;
import me.bobert.netneutrality.tasks.git.ReportIssueTask; import me.bobert.netneutrality.tasks.git.ReportIssueTask;
import java.util.Objects; import java.util.Objects;
import java.util.concurrent.TimeUnit;
import me.bobert.netneutrality.core.tasks.TaskManager; import me.bobert.netneutrality.core.tasks.TaskManager;
import me.bobert.netneutrality.utils.SettingsProvider; import me.bobert.netneutrality.utils.SettingsProvider;
@@ -72,15 +71,16 @@ public class ReportIssueCommand extends AbstractCommand {
@Override @Override
public CommandExecutionResult execute(CommandContext context) { public CommandExecutionResult execute(CommandContext context) {
String gitlabHost = settings.get("GITLAB_HOST"); String host = settings.get("GITEA_HOST");
String gitlabToken = settings.get("GITLAB_TOKEN"); String token = settings.get("GITEA_TOKEN");
String projectId = settings.get("GITLAB_PROJECT_ID"); String owner = settings.get("GITEA_OWNER");
String repo = settings.get("GITEA_REPO");
if (gitlabHost == null || gitlabToken == null || projectId == null) { if (host == null || token == null || owner == null || repo == null) {
return CommandExecutionResult.error("Command is not configured, failed to create issue."); return CommandExecutionResult.error("Command is not configured, failed to create issue.");
} }
if (new WebModule.Builder(gitlabHost).ping().timeout(2, TimeUnit.SECONDS).build().request().isSuccess()) { if (new GiteaClient(host, token, owner, repo).ping()) {
return CommandExecutionResult.task(new ReportIssueTask(taskManager, context, settings)); return CommandExecutionResult.task(new ReportIssueTask(taskManager, context, settings));
} }
@@ -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 { public interface Command {
enum Type { enum Type {
SLASH, USER, MESSAGE CHAT, USER, MESSAGE
} }
boolean needAdmin(); boolean needAdmin();
@@ -5,7 +5,6 @@ package me.bobert.netneutrality.core.command;
* https://discord.gg/qUa6zNFZsd * 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.OptionData;
import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData; import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData;
import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; 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 net.dv8tion.jda.internal.interactions.CommandDataImpl;
import java.util.List; import java.util.List;
import java.util.Set;
import java.util.StringJoiner;
public class CommandContainer { public class CommandContainer {
public static final CommandContainer NULL_COMMAND = new CommandContainer(null); public static final CommandContainer NULL_COMMAND = new CommandContainer(null);
private static final CommandUsageFormatter USAGE_FORMATTER = new CommandUsageFormatter();
private final Command command; private final Command command;
private CommandDataImpl commandData; private final CommandDataImpl commandData;
private List<OptionData> options; private final List<OptionData> options;
private List<SubcommandData> subcommands; private final List<SubcommandData> subcommands;
private List<SubcommandGroupData> subcommandGroups; private final List<SubcommandGroupData> subcommandGroups;
public CommandContainer(Command command) { public CommandContainer(Command command) {
this.command = command; this(command, build(command));
}
if (command != null) { private CommandContainer(Command command, CommandDataImpl commandData) {
CommandOptionsBuilder commandOptionsBuilder = new CommandOptionsBuilder(command.getType(), command); this.command = command;
command.registerCommandOptions(commandOptionsBuilder); this.commandData = commandData;
initContainer(commandOptionsBuilder);
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() { public Command getCommand() {
return command; return command;
} }
@@ -54,129 +75,11 @@ public class CommandContainer {
return subcommandGroups; 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() { public boolean isEmpty() {
return options.isEmpty() && subcommands.isEmpty() && subcommandGroups.isEmpty(); return options.isEmpty() && subcommands.isEmpty() && subcommandGroups.isEmpty();
} }
public String printUsage() { public String printUsage() {
StringBuilder usage = new StringBuilder(); return USAGE_FORMATTER.format(this);
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();
} }
} }
@@ -57,7 +57,7 @@ public class CommandManager implements CommandRegistry {
public void init() { public void init() {
logger.info("Loading commands.."); logger.info("Loading commands..");
initCommands(); ReflectionHelper.loadCommands(Command.class, context, this::onCommandLoaded);
logger.info("Loaded {} commands!", commands.size()); logger.info("Loaded {} commands!", commands.size());
} }
@@ -84,30 +84,26 @@ public class CommandManager implements CommandRegistry {
return commands; return commands;
} }
public void initCommands() { void onCommandLoaded(Command command) {
Consumer<Command> onLoaded = command -> { if (!command.isEnabled()) {
if (!command.isEnabled()) { logger.info("Skipping command {} as it is disabled", command.getClass().getName());
logger.info("Skipping command {} as it is disabled", command.getClass().getName()); return;
return; }
}
if (command.getCommandName() == null) { if (command.getCommandName() == null) {
logger.error("Failed to load command {}: Command name is null", command.getClass().getName()); logger.error("Failed to load command {}: Command name is null", command.getClass().getName());
return; return;
} }
if (command.getDescription() == null && command.getType() == Command.Type.SLASH) { if (command.getDescription() == null && command.getType() == Command.Type.CHAT) {
logger.error("Failed to load command {}: Command description is null", command.getClass().getName()); logger.error("Failed to load command {}: Command description is null", command.getClass().getName());
return; return;
} }
CommandContainer container = new CommandContainer(command); CommandContainer container = new CommandContainer(command);
if (!commands.containsKey(command.getCommandName())) { if (!commands.containsKey(command.getCommandName())) {
commands.put(command.getCommandName(), container); commands.put(command.getCommandName(), container);
} }
};
ReflectionHelper.loadCommands(Command.class, context, onLoaded);
} }
@Override @Override
@@ -31,7 +31,7 @@ public class CommandOptionsBuilder implements SlashCommandData {
public CommandOptionsBuilder(Command.Type type, Command command) { 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()); 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()); logger.info("Executing command {} on guild {}", command.getCommandName(), context.getGuild() == null ? "PrivateMessage" : context.getGuild().getId());
configureSentry(command); configureSentry(command);
if (command.getType() == Command.Type.SLASH && !usageValidator.validate(context, container)) { if (command.getType() == Command.Type.CHAT && !usageValidator.validate(context, container)) {
return; 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 @Override
public Type getType() { public Type getType() {
return Type.SLASH; return Type.CHAT;
} }
@Override @Override
@@ -9,10 +9,9 @@ import me.bobert.netneutrality.core.command.CommandCategory;
import me.bobert.netneutrality.db.service.GuildServiceProvider; import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.db.GSettings; import me.bobert.netneutrality.db.GSettings;
import me.bobert.netneutrality.db.service.GuildService; 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.Guild;
import net.dv8tion.jda.api.entities.Member; 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.ChannelType;
import net.dv8tion.jda.api.entities.channel.middleman.GuildMessageChannel; import net.dv8tion.jda.api.entities.channel.middleman.GuildMessageChannel;
import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel;
@@ -77,26 +76,7 @@ public abstract class MusicCommand extends AbstractCommand {
return true; return true;
} }
Guild guild = member.getGuild(); return MusicAccess.canUse(member, guildService(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;
} }
@Override @Override
@@ -13,7 +13,7 @@ public class PermissionPrecondition implements CommandPrecondition {
@Override @Override
public boolean check(CommandContext context, Command command) { public boolean check(CommandContext context, Command command) {
if (command.getType() != Command.Type.SLASH) { if (command.getType() != Command.Type.CHAT) {
return true; 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 * 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.CommandProcessor;
import me.bobert.netneutrality.core.command.CommandRegistry;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.core.context.Context; import me.bobert.netneutrality.core.context.Context;
import me.bobert.netneutrality.core.context.TaskContext; 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.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.Event;
import net.dv8tion.jda.api.events.GenericEvent; 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 net.dv8tion.jda.api.hooks.EventListener;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import javax.annotation.Nonnull; 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.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.function.Consumer; import java.util.function.Consumer;
import java.util.function.Function; 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 { public class EventsHandler implements EventListener {
private static final Logger logger = LoggerFactory.getLogger(EventsHandler.class); private static final Logger logger = LoggerFactory.getLogger(EventsHandler.class);
private final Map<Class<? extends Event>, Function<? extends Event, Context>> listeners = new ConcurrentHashMap<>(); private final Map<Class<?>, List<EventListenerHandler<?>>> handlers = new ConcurrentHashMap<>();
private final SettingsProvider settings;
private final GuildServiceProvider guildServices;
private final CommandRegistry commands; private final CommandRegistry commands;
private final TaskManager taskManager; private final TaskManager taskManager;
public EventsHandler( public EventsHandler(
SettingsProvider settings, List<EventListenerHandler<?>> handlers,
GuildServiceProvider guildServices,
CommandRegistry commands, CommandRegistry commands,
TaskManager taskManager TaskManager taskManager
) { ) {
this.settings = Objects.requireNonNull(settings, "settings");
this.guildServices = Objects.requireNonNull(guildServices, "guildServices");
this.commands = Objects.requireNonNull(commands, "commands"); this.commands = Objects.requireNonNull(commands, "commands");
this.taskManager = Objects.requireNonNull(taskManager, "taskManager"); this.taskManager = Objects.requireNonNull(taskManager, "taskManager");
registerListeners(); Objects.requireNonNull(handlers, "handlers").forEach(this::register);
} }
private void registerListeners() { public void register(EventListenerHandler<?> handler) {
addListener(MessageReceivedEvent.class, event -> { Class<?> type = Objects.requireNonNull(handler, "handler").eventType();
if (event.getAuthor().isBot()) { logger.info("Registering handler {} for {}", handler.getClass().getSimpleName(), type.getSimpleName());
return null; handlers.computeIfAbsent(type, k -> new CopyOnWriteArrayList<>()).add(handler);
}
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()));
} }
/** /**
* Deletes the guild's data and drops its cached service. The service is evicted even when the * Register a listener for an event associated with a {@link Context}.
* 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);
}
}
/**
* 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) { public <T extends Event> void addListener(Class<T> eventClass, Function<? super T, Context> function) {
logger.info("Registering listener for {}", eventClass.getSimpleName()); register(new ContextEventHandler<T>() {
listeners.put(eventClass, (Function<? extends Event, Context>) function); @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 * Adds a side-effect listener for a specified event type.
* 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
*/ */
public <T extends Event> void addGenericListener(Class<T> eventClass, Consumer<T> consumer) { public <T extends Event> void addGenericListener(Class<T> eventClass, Consumer<T> consumer) {
addListener(eventClass, t -> { register(new EventListenerHandler<T>() {
consumer.accept(t); @Override
return null; 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") @SuppressWarnings("unchecked")
private void handleEvent(Event event) { private void handleEvent(Event event) {
Class<?> eventClass = event.getClass(); for (Class<?> type : hierarchy(event.getClass())) {
List<EventListenerHandler<?>> matching = handlers.get(type);
while (eventClass != null) { if (matching == null) {
if (listeners.containsKey(eventClass)) { continue;
Function<Event, Context> function = (Function<Event, Context>) listeners.get(eventClass); }
Context context = function.apply(event); for (EventListenerHandler<?> raw : matching) {
try {
if (context instanceof CommandContext commandContext) { EventListenerHandler<Event> handler = (EventListenerHandler<Event>) raw;
handleCommand(commandContext); handler.handle(event);
} if (handler instanceof ContextEventHandler<Event> contextHandler) {
route(contextHandler.toContext(event));
if (context instanceof TaskContext taskContext) { }
handleTask(taskContext); } catch (Exception e) {
} logger.error("Handler {} failed for {}", raw.getClass().getName(), event.getClass().getSimpleName(), e);
}
break;
} }
eventClass = eventClass.getSuperclass();
} }
} }
private void handleCommand(CommandContext context) { private void route(Context context) {
CommandProcessor processor = new CommandProcessor(context, commands, taskManager); if (context instanceof CommandContext commandContext) {
processor.dispatch(); new CommandProcessor(commandContext, commands, taskManager).dispatch();
}
if (context instanceof TaskContext taskContext) {
taskManager.handle(taskContext);
}
} }
private void handleTask(TaskContext context) { private static Set<Class<?>> hierarchy(Class<?> start) {
taskManager.handle(context); 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.OpenApiContent;
import io.javalin.openapi.OpenApiResponse; import io.javalin.openapi.OpenApiResponse;
import me.bobert.netneutrality.http.server.dto.StatusResponse; 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.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
import java.util.Map; import java.util.Map;
import java.util.function.Supplier;
/** /**
* Registers the {@code /actions/*} endpoints on a {@link RoutesConfig} instance. * Registers the {@code /actions/*} endpoints on a {@link RoutesConfig} instance.
@@ -34,10 +33,10 @@ public final class ActionRoutes {
public static void register( public static void register(
RoutesConfig routes, RoutesConfig routes,
Supplier<JDA> jdaSupplier, ShardProvider shards,
Runnable shutdownHook Runnable shutdownHook
) { ) {
routes.get("/actions/status", ctx -> status(jdaSupplier, ctx)); routes.get("/actions/status", ctx -> status(shards, ctx));
Handler shutdown = ctx -> shutdown(ctx, shutdownHook); Handler shutdown = ctx -> shutdown(ctx, shutdownHook);
routes.post("/actions/shutdown", shutdown); routes.post("/actions/shutdown", shutdown);
@@ -50,11 +49,12 @@ public final class ActionRoutes {
tags = {"Actions"}, tags = {"Actions"},
responses = @OpenApiResponse(status = "200", content = @OpenApiContent(from = StatusResponse.class)) responses = @OpenApiResponse(status = "200", content = @OpenApiContent(from = StatusResponse.class))
) )
private static void status(Supplier<JDA> jdaSupplier, Context ctx) { private static void status(ShardProvider shards, Context ctx) {
JDA jda = GuildRoutes.jda(jdaSupplier); ShardProvider provider = GuildRoutes.shards(shards);
ctx.json(Map.of( ctx.json(Map.of(
"health", String.valueOf(jda.getStatus()), "health", String.valueOf(provider.getWorstStatus()),
"ping", String.valueOf(jda.getGatewayPing()) "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.GuildLogChannelUpdateRequest;
import me.bobert.netneutrality.http.server.dto.GuildOwnerEntryResponse; import me.bobert.netneutrality.http.server.dto.GuildOwnerEntryResponse;
import me.bobert.netneutrality.http.server.dto.GuildSummaryResponse; 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.MemberRolesResponse;
import me.bobert.netneutrality.http.server.dto.MemberSummaryResponse; import me.bobert.netneutrality.http.server.dto.MemberSummaryResponse;
import me.bobert.netneutrality.http.server.dto.OwnerResponse; import me.bobert.netneutrality.http.server.dto.OwnerResponse;
import me.bobert.netneutrality.http.server.dto.RoleSummaryResponse; import me.bobert.netneutrality.http.server.dto.RoleSummaryResponse;
import me.bobert.netneutrality.http.server.dto.VoiceChannelSummaryResponse; import me.bobert.netneutrality.http.server.dto.VoiceChannelSummaryResponse;
import me.bobert.netneutrality.utils.MusicAccess;
import me.bobert.netneutrality.utils.UserFunction; 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.Guild;
import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.Role; 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.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; 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 * 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() { private GuildRoutes() {
} }
public static void register(RoutesConfig routes, Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices) { public static void register(RoutesConfig routes, ShardProvider shards, GuildServiceProvider guildServices) {
routes.get("/guilds", ctx -> guilds(jdaSupplier, ctx)); routes.get("/guilds", ctx -> guilds(shards, guildServices, ctx));
routes.get("/guilds/owner", ctx -> owner(jdaSupplier, ctx)); routes.get("/guilds/owner", ctx -> owner(shards, ctx));
routes.get("/guilds/owner/{guildId}", ctx -> ownerOf(jdaSupplier, ctx)); routes.get("/guilds/{guildId}/owner", ctx -> ownerOf(shards, ctx));
routes.get("/guilds/channels/{guildId}", ctx -> channels(jdaSupplier, ctx)); routes.get("/guilds/{guildId}/channels", ctx -> channels(shards, ctx));
routes.get("/guilds/roles/{guildId}", ctx -> roles(jdaSupplier, ctx)); routes.get("/guilds/{guildId}/roles", ctx -> roles(shards, ctx));
routes.get("/guilds/members/{guildId}/{userId}", ctx -> member(jdaSupplier, ctx)); routes.get("/guilds/{guildId}/members/{userId}/access", ctx -> memberAccess(shards, guildServices, ctx));
routes.get("/guilds/members/{guildId}/{userId}/roles", ctx -> memberRoles(jdaSupplier, ctx)); routes.get("/guilds/{guildId}/members/{userId}", ctx -> member(shards, ctx));
routes.get("/guilds/members/{guildId}/{userId}/admin", ctx -> memberAdmin(jdaSupplier, ctx)); routes.get("/guilds/{guildId}/members/{userId}/roles", ctx -> memberRoles(shards, ctx));
routes.get("/guilds/voice-channels/{guildId}", ctx -> voiceChannels(jdaSupplier, ctx)); routes.get("/guilds/{guildId}/members/{userId}/admin", ctx -> memberAdmin(shards, ctx));
routes.get("/guilds/voice-channels/{guildId}/{channelId}", ctx -> voiceChannel(jdaSupplier, ctx)); routes.get("/guilds/{guildId}/voice-channels", ctx -> voiceChannels(shards, ctx));
routes.get("/guilds/config/{guildId}", ctx -> config(jdaSupplier, guildServices, ctx)); routes.get("/guilds/{guildId}/voice-channels/{channelId}", ctx -> voiceChannel(shards, ctx));
routes.put("/guilds/debug/{guildId}", ctx -> updateDebug(jdaSupplier, guildServices, ctx)); routes.get("/guilds/{guildId}/config", ctx -> config(shards, guildServices, ctx));
routes.put("/guilds/logging/{guildId}", ctx -> updateLogging(jdaSupplier, guildServices, ctx)); routes.put("/guilds/{guildId}/debug", ctx -> updateDebug(shards, guildServices, ctx));
routes.put("/guilds/log-channel/{guildId}", ctx -> updateLogChannel(jdaSupplier, guildServices, ctx)); routes.put("/guilds/{guildId}/logging", ctx -> updateLogging(shards, guildServices, ctx));
routes.put("/guilds/{guildId}/log-channel", ctx -> updateLogChannel(shards, guildServices, ctx));
} }
@OpenApi( @OpenApi(
path = "/guilds", path = "/guilds",
methods = HttpMethod.GET, 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"}, 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( ctx.json(
jda(jdaSupplier).getGuilds().stream() shards(shards).getGuilds().stream()
.map(GuildRoutes::guildSummary) .map(GuildRoutes::guildSummary)
.toList() .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 * 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. * when it is not present in the cache.
@@ -117,16 +175,16 @@ public final class GuildRoutes {
tags = {"Guilds"}, tags = {"Guilds"},
responses = @OpenApiResponse(status = "200", content = @OpenApiContent(from = GuildOwnerEntryResponse[].class)) 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( ctx.json(
jda(jdaSupplier).getGuilds().stream() shards(shards).getGuilds().stream()
.map(guild -> ordered("guild_id", guild.getId(), "owner_id", guild.getOwnerId())) .map(guild -> ordered("guild_id", guild.getId(), "owner_id", guild.getOwnerId()))
.toList() .toList()
); );
} }
@OpenApi( @OpenApi(
path = "/guilds/owner/{guildId}", path = "/guilds/{guildId}/owner",
methods = HttpMethod.GET, methods = HttpMethod.GET,
summary = "Get a guild's owner, falling back to a REST fetch on a member cache miss", summary = "Get a guild's owner, falling back to a REST fetch on a member cache miss",
tags = {"Guilds"}, tags = {"Guilds"},
@@ -136,10 +194,10 @@ public final class GuildRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
} }
) )
private static void ownerOf(Supplier<JDA> jdaSupplier, Context ctx) { private static void ownerOf(ShardProvider shards, Context ctx) {
Guild guild = guild(jdaSupplier, ctx); Guild guild = guild(shards, ctx);
CacheOrFetch.<Member>resolve( CacheOrFetch.resolve(
ctx, ctx,
guild::getOwner, guild::getOwner,
guild::retrieveOwner, guild::retrieveOwner,
@@ -148,7 +206,7 @@ public final class GuildRoutes {
} }
@OpenApi( @OpenApi(
path = "/guilds/channels/{guildId}", path = "/guilds/{guildId}/channels",
methods = HttpMethod.GET, methods = HttpMethod.GET,
summary = "List the text channels of a guild", summary = "List the text channels of a guild",
tags = {"Guilds"}, tags = {"Guilds"},
@@ -158,16 +216,16 @@ public final class GuildRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) @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( ctx.json(
guild(jdaSupplier, ctx).getTextChannels().stream() guild(shards, ctx).getTextChannels().stream()
.map(channel -> ordered("channel_id", channel.getId(), "channel_name", channel.getName())) .map(channel -> ordered("channel_id", channel.getId(), "channel_name", channel.getName()))
.toList() .toList()
); );
} }
@OpenApi( @OpenApi(
path = "/guilds/roles/{guildId}", path = "/guilds/{guildId}/roles",
methods = HttpMethod.GET, methods = HttpMethod.GET,
summary = "List the roles of a guild", summary = "List the roles of a guild",
tags = {"Guilds"}, tags = {"Guilds"},
@@ -177,9 +235,9 @@ public final class GuildRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) @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( ctx.json(
guild(jdaSupplier, ctx).getRoles().stream() guild(shards, ctx).getRoles().stream()
.filter(role -> !role.isManaged() && !role.isPublicRole()) .filter(role -> !role.isManaged() && !role.isPublicRole())
.map(role -> ordered("role_id", role.getId(), "role_name", role.getName())) .map(role -> ordered("role_id", role.getId(), "role_name", role.getName()))
.toList() .toList()
@@ -187,7 +245,7 @@ public final class GuildRoutes {
} }
@OpenApi( @OpenApi(
path = "/guilds/members/{guildId}/{userId}", path = "/guilds/{guildId}/members/{userId}",
methods = HttpMethod.GET, methods = HttpMethod.GET,
summary = "Get a guild member, falling back to a REST fetch on a member cache miss", summary = "Get a guild member, falling back to a REST fetch on a member cache miss",
tags = {"Guilds"}, tags = {"Guilds"},
@@ -200,11 +258,11 @@ public final class GuildRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
} }
) )
private static void member(Supplier<JDA> jdaSupplier, Context ctx) { private static void member(ShardProvider shards, Context ctx) {
Guild guild = guild(jdaSupplier, ctx); Guild guild = guild(shards, ctx);
String userId = ctx.pathParam("userId"); String userId = ctx.pathParam("userId");
CacheOrFetch.<Member>resolve( CacheOrFetch.resolve(
ctx, ctx,
() -> guild.getMemberById(userId), () -> guild.getMemberById(userId),
() -> guild.retrieveMemberById(userId), () -> guild.retrieveMemberById(userId),
@@ -213,7 +271,7 @@ public final class GuildRoutes {
} }
@OpenApi( @OpenApi(
path = "/guilds/members/{guildId}/{userId}/roles", path = "/guilds/{guildId}/members/{userId}/roles",
methods = HttpMethod.GET, methods = HttpMethod.GET,
summary = "List the roles of a guild member, falling back to a REST fetch on a member cache miss", summary = "List the roles of a guild member, falling back to a REST fetch on a member cache miss",
tags = {"Guilds"}, tags = {"Guilds"},
@@ -226,11 +284,11 @@ public final class GuildRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
} }
) )
private static void memberRoles(Supplier<JDA> jdaSupplier, Context ctx) { private static void memberRoles(ShardProvider shards, Context ctx) {
Guild guild = guild(jdaSupplier, ctx); Guild guild = guild(shards, ctx);
String userId = ctx.pathParam("userId"); String userId = ctx.pathParam("userId");
CacheOrFetch.<Member>resolve( CacheOrFetch.resolve(
ctx, ctx,
() -> guild.getMemberById(userId), () -> guild.getMemberById(userId),
() -> guild.retrieveMemberById(userId), () -> guild.retrieveMemberById(userId),
@@ -239,7 +297,7 @@ public final class GuildRoutes {
} }
@OpenApi( @OpenApi(
path = "/guilds/members/{guildId}/{userId}/admin", path = "/guilds/{guildId}/members/{userId}/admin",
methods = HttpMethod.GET, 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", 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"}, tags = {"Guilds"},
@@ -252,11 +310,11 @@ public final class GuildRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
} }
) )
private static void memberAdmin(Supplier<JDA> jdaSupplier, Context ctx) { private static void memberAdmin(ShardProvider shards, Context ctx) {
Guild guild = guild(jdaSupplier, ctx); Guild guild = guild(shards, ctx);
String userId = ctx.pathParam("userId"); String userId = ctx.pathParam("userId");
CacheOrFetch.<Member>resolve( CacheOrFetch.resolve(
ctx, ctx,
() -> guild.getMemberById(userId), () -> guild.getMemberById(userId),
() -> guild.retrieveMemberById(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) { private static Map<String, Object> memberSummary(Member member) {
Map<String, Object> summary = new LinkedHashMap<>(); 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) { private static Map<String, Object> memberRolesSummary(Guild guild, Member member) {
Map<String, Object> summary = new LinkedHashMap<>(); Map<String, Object> summary = new LinkedHashMap<>();
@@ -298,7 +402,7 @@ public final class GuildRoutes {
} }
@OpenApi( @OpenApi(
path = "/guilds/voice-channels/{guildId}", path = "/guilds/{guildId}/voice-channels",
methods = HttpMethod.GET, methods = HttpMethod.GET,
summary = "List the voice channels of a guild, including the members currently connected", summary = "List the voice channels of a guild, including the members currently connected",
tags = {"Guilds"}, tags = {"Guilds"},
@@ -308,16 +412,16 @@ public final class GuildRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) @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( ctx.json(
guild(jdaSupplier, ctx).getVoiceChannels().stream() guild(shards, ctx).getVoiceChannels().stream()
.map(GuildRoutes::voiceChannelSummary) .map(GuildRoutes::voiceChannelSummary)
.toList() .toList()
); );
} }
@OpenApi( @OpenApi(
path = "/guilds/voice-channels/{guildId}/{channelId}", path = "/guilds/{guildId}/voice-channels/{channelId}",
methods = HttpMethod.GET, methods = HttpMethod.GET,
summary = "Get a voice channel, including the members currently connected", summary = "Get a voice channel, including the members currently connected",
tags = {"Guilds"}, tags = {"Guilds"},
@@ -330,8 +434,8 @@ public final class GuildRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
} }
) )
private static void voiceChannel(Supplier<JDA> jdaSupplier, Context ctx) { private static void voiceChannel(ShardProvider shards, Context ctx) {
ctx.json(voiceChannelSummary(voiceChannel(guild(jdaSupplier, ctx), ctx))); ctx.json(voiceChannelSummary(voiceChannel(guild(shards, ctx), ctx)));
} }
/** /**
@@ -367,7 +471,7 @@ public final class GuildRoutes {
Map<String, String> summary = new LinkedHashMap<>(); Map<String, String> summary = new LinkedHashMap<>();
summary.put("user_id", member.getId()); summary.put("user_id", member.getId());
summary.put("username", member.getUser().getName()); summary.put("username", member.getUser().getName());
summary.put("nickname", member.getNickname()); summary.put("nickname", member.getEffectiveName());
return summary; return summary;
} }
@@ -382,7 +486,7 @@ public final class GuildRoutes {
} }
@OpenApi( @OpenApi(
path = "/guilds/config/{guildId}", path = "/guilds/{guildId}/config",
methods = HttpMethod.GET, methods = HttpMethod.GET,
summary = "Get a guild's debug/logging configuration", summary = "Get a guild's debug/logging configuration",
tags = {"Guilds"}, tags = {"Guilds"},
@@ -392,13 +496,13 @@ public final class GuildRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
} }
) )
private static void config(Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices, Context ctx) { private static void config(ShardProvider shards, GuildServiceProvider guildServices, Context ctx) {
Guild guild = guild(jdaSupplier, ctx); Guild guild = guild(shards, ctx);
ctx.json(guildConfig(guild, guildServices)); ctx.json(guildConfig(guild, guildServices));
} }
@OpenApi( @OpenApi(
path = "/guilds/debug/{guildId}", path = "/guilds/{guildId}/debug",
methods = HttpMethod.PUT, methods = HttpMethod.PUT,
summary = "Enable or disable debug/error logging for a guild", summary = "Enable or disable debug/error logging for a guild",
tags = {"Guilds"}, tags = {"Guilds"},
@@ -410,8 +514,8 @@ public final class GuildRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
} }
) )
private static void updateDebug(Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices, Context ctx) { private static void updateDebug(ShardProvider shards, GuildServiceProvider guildServices, Context ctx) {
Guild guild = guild(jdaSupplier, ctx); Guild guild = guild(shards, ctx);
boolean value = booleanValue(ctx); boolean value = booleanValue(ctx);
GuildService guildService = guildServices.get(guild.getId()); GuildService guildService = guildServices.get(guild.getId());
@@ -421,7 +525,7 @@ public final class GuildRoutes {
} }
@OpenApi( @OpenApi(
path = "/guilds/logging/{guildId}", path = "/guilds/{guildId}/logging",
methods = HttpMethod.PUT, methods = HttpMethod.PUT,
summary = "Enable or disable logging for a guild", summary = "Enable or disable logging for a guild",
tags = {"Guilds"}, tags = {"Guilds"},
@@ -433,8 +537,8 @@ public final class GuildRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
} }
) )
private static void updateLogging(Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices, Context ctx) { private static void updateLogging(ShardProvider shards, GuildServiceProvider guildServices, Context ctx) {
Guild guild = guild(jdaSupplier, ctx); Guild guild = guild(shards, ctx);
boolean value = booleanValue(ctx); boolean value = booleanValue(ctx);
GuildService guildService = guildServices.get(guild.getId()); GuildService guildService = guildServices.get(guild.getId());
@@ -444,7 +548,7 @@ public final class GuildRoutes {
} }
@OpenApi( @OpenApi(
path = "/guilds/log-channel/{guildId}", path = "/guilds/{guildId}/log-channel",
methods = HttpMethod.PUT, methods = HttpMethod.PUT,
summary = "Set (or clear) the log channel for a guild", summary = "Set (or clear) the log channel for a guild",
tags = {"Guilds"}, tags = {"Guilds"},
@@ -456,8 +560,8 @@ public final class GuildRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
} }
) )
private static void updateLogChannel(Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices, Context ctx) { private static void updateLogChannel(ShardProvider shards, GuildServiceProvider guildServices, Context ctx) {
Guild guild = guild(jdaSupplier, ctx); Guild guild = guild(shards, ctx);
String channelId = ctx.bodyAsClass(Map.class).get("value") instanceof String s ? s : null; String channelId = ctx.bodyAsClass(Map.class).get("value") instanceof String s ? s : null;
if (channelId != null && !channelId.isBlank() && guild.getTextChannelById(channelId) == 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) { private static GuildConfigResponse guildConfig(Guild guild, GuildServiceProvider guildServices) {
GuildService guildService = guildServices.get(guild.getId()); 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) { static ShardProvider shards(ShardProvider shards) {
JDA jda = jdaSupplier.get(); if (shards == null || !shards.isReady()) {
if (jda == null) {
throw new ServiceUnavailableResponse("Discord client is not ready"); throw new ServiceUnavailableResponse("Discord client is not ready");
} }
return jda; return shards;
} }
/** /**
* Resolves the {@code guildId} path parameter or fails with 404. * Resolves the {@code guildId} path parameter or fails with 404.
*/ */
static Guild guild(Supplier<JDA> jdaSupplier, Context ctx) { static Guild guild(ShardProvider shards, Context ctx) {
Guild guild = jda(jdaSupplier).getGuildById(ctx.pathParam("guildId")); Guild guild = shards(shards).getGuildById(ctx.pathParam("guildId"));
if (guild == null) { if (guild == null) {
throw new NotFoundResponse("Unknown guild"); throw new NotFoundResponse("Unknown guild");
} }
@@ -17,6 +17,8 @@ import io.javalin.openapi.OpenApiContent;
import io.javalin.openapi.OpenApiParam; import io.javalin.openapi.OpenApiParam;
import io.javalin.openapi.OpenApiRequestBody; import io.javalin.openapi.OpenApiRequestBody;
import io.javalin.openapi.OpenApiResponse; 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.CommandResultResponse;
import me.bobert.netneutrality.http.server.dto.DeleteQueueResponse; import me.bobert.netneutrality.http.server.dto.DeleteQueueResponse;
import me.bobert.netneutrality.http.server.dto.DjStatusResponse; 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.ReorderRequest;
import me.bobert.netneutrality.http.server.dto.ReorderResponse; import me.bobert.netneutrality.http.server.dto.ReorderResponse;
import me.bobert.netneutrality.http.server.dto.SearchRequest; 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.SeekRequest;
import me.bobert.netneutrality.http.server.dto.SeekResponse; import me.bobert.netneutrality.http.server.dto.SeekResponse;
import me.bobert.netneutrality.http.server.dto.ShuffleResponse; 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.VolumeRequest;
import me.bobert.netneutrality.http.server.dto.VolumeResponse; import me.bobert.netneutrality.http.server.dto.VolumeResponse;
import me.bobert.netneutrality.music.MusicManager; import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.music.MusicService; import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.music.PendingSearchResults; import me.bobert.netneutrality.music.PendingSearchResults;
import me.bobert.netneutrality.music.PlayOutcome; import me.bobert.netneutrality.music.PlayOutcome;
import me.bobert.netneutrality.music.PlaylistInfo;
import me.bobert.netneutrality.music.QueueActionResult; import me.bobert.netneutrality.music.QueueActionResult;
import me.bobert.netneutrality.music.SourceFinder; import me.bobert.netneutrality.music.SourceFinder;
import me.bobert.netneutrality.music.TrackInfo; import me.bobert.netneutrality.music.TrackInfo;
import me.bobert.netneutrality.utils.MusicUtils; 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.Guild;
import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.channel.middleman.AudioChannel; import net.dv8tion.jda.api.entities.channel.middleman.AudioChannel;
@@ -53,7 +58,6 @@ import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.function.Supplier;
/** /**
* Registers the {@code /music/*} endpoints on a {@link RoutesConfig} instance. * Registers the {@code /music/*} endpoints on a {@link RoutesConfig} instance.
@@ -67,25 +71,28 @@ public final class MusicRoutes {
private 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/sources", MusicRoutes::sources);
routes.get("/music/{guildId}/status", ctx -> status(jdaSupplier, musicService, ctx)); routes.get("/music/{guildId}/status", ctx -> status(shards, musicService, ctx));
routes.get("/music/{guildId}/queue", ctx -> queue(jdaSupplier, musicService, ctx)); routes.get("/music/{guildId}/queue", ctx -> queue(shards, musicService, ctx));
routes.post("/music/{guildId}/skip", ctx -> skip(jdaSupplier, musicService, ctx)); routes.post("/music/{guildId}/skip", ctx -> skip(shards, musicService, ctx));
routes.post("/music/{guildId}/stop", ctx -> stop(jdaSupplier, musicService, ctx)); routes.post("/music/{guildId}/stop", ctx -> stop(shards, musicService, ctx));
routes.post("/music/{guildId}/pause", ctx -> pause(jdaSupplier, musicService, ctx)); routes.post("/music/{guildId}/pause", ctx -> pause(shards, musicService, ctx));
routes.post("/music/{guildId}/resume", ctx -> resumePlayback(jdaSupplier, musicService, ctx)); routes.post("/music/{guildId}/resume", ctx -> resumePlayback(shards, musicService, ctx));
routes.get("/music/{guildId}/volume", ctx -> getVolume(jdaSupplier, musicService, ctx)); routes.get("/music/{guildId}/volume", ctx -> getVolume(shards, musicService, ctx));
routes.put("/music/{guildId}/volume", ctx -> volume(jdaSupplier, musicService, ctx)); routes.put("/music/{guildId}/volume", ctx -> volume(shards, musicService, ctx));
routes.put("/music/{guildId}/seek", ctx -> seek(jdaSupplier, musicService, ctx)); routes.get("/music/{guildId}/autoplay", ctx -> getAutoplay(shards, musicService, ctx));
routes.post("/music/{guildId}/vote-skip/{userId}", ctx -> voteSkip(jdaSupplier, musicService, ctx)); routes.put("/music/{guildId}/autoplay", ctx -> autoplay(shards, musicService, ctx));
routes.get("/music/{guildId}/dj/{userId}", ctx -> dj(jdaSupplier, musicService, ctx)); routes.put("/music/{guildId}/seek", ctx -> seek(shards, musicService, ctx));
routes.post("/music/{guildId}/queue", ctx -> play(jdaSupplier, musicService, ctx)); routes.get("/music/{guildId}/vote-skip", ctx -> voteSkipStatus(shards, musicService, ctx));
routes.delete("/music/{guildId}/queue", ctx -> deleteQueue(jdaSupplier, musicService, ctx)); routes.post("/music/{guildId}/vote-skip/{userId}", ctx -> voteSkip(shards, musicService, ctx));
routes.patch("/music/{guildId}/queue/reorder", ctx -> reorder(jdaSupplier, musicService, ctx)); routes.get("/music/{guildId}/dj/{userId}", ctx -> dj(shards, musicService, ctx));
routes.post("/music/{guildId}/search", ctx -> search(jdaSupplier, musicService, ctx)); routes.post("/music/{guildId}/queue", ctx -> play(shards, musicService, ctx));
routes.post("/music/{guildId}/leave", ctx -> leave(jdaSupplier, musicService, ctx)); routes.delete("/music/{guildId}/queue", ctx -> deleteQueue(shards, musicService, ctx));
routes.post("/music/{guildId}/shuffle", ctx -> shuffle(jdaSupplier, 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( @OpenApi(
@@ -115,8 +122,8 @@ public final class MusicRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
} }
) )
private static void status(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) { private static void status(ShardProvider shards, MusicService musicService, Context ctx) {
MusicManager manager = manager(jdaSupplier, musicService, ctx); MusicManager manager = manager(shards, musicService, ctx);
AudioChannel channel = manager.getConnectedChannel(); AudioChannel channel = manager.getConnectedChannel();
Map<String, Object> body = new LinkedHashMap<>(); Map<String, Object> body = new LinkedHashMap<>();
@@ -143,8 +150,8 @@ public final class MusicRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
} }
) )
private static void queue(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) { private static void queue(ShardProvider shards, MusicService musicService, Context ctx) {
MusicManager manager = manager(jdaSupplier, musicService, ctx); MusicManager manager = manager(shards, musicService, ctx);
ctx.json( ctx.json(
manager.getQueueManager().getQueue().stream() manager.getQueueManager().getQueue().stream()
@@ -164,8 +171,8 @@ public final class MusicRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
} }
) )
private static void skip(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) { private static void skip(ShardProvider shards, MusicService musicService, Context ctx) {
MusicManager manager = manager(jdaSupplier, musicService, ctx); MusicManager manager = manager(shards, musicService, ctx);
QueueActionResult result = manager.getQueueManager().skip(); QueueActionResult result = manager.getQueueManager().skip();
announce(manager, result.getMessage()); announce(manager, result.getMessage());
ctx.json(resultBody(result)); ctx.json(resultBody(result));
@@ -182,8 +189,8 @@ public final class MusicRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
} }
) )
private static void stop(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) { private static void stop(ShardProvider shards, MusicService musicService, Context ctx) {
MusicManager manager = manager(jdaSupplier, musicService, ctx); MusicManager manager = manager(shards, musicService, ctx);
manager.stopTrack(); manager.stopTrack();
announce(manager, "Stopping current track"); announce(manager, "Stopping current track");
ctx.json(Map.of("status", "ok", "message", "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)) @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
} }
) )
private static void pause(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) { private static void pause(ShardProvider shards, MusicService musicService, Context ctx) {
MusicManager manager = manager(jdaSupplier, musicService, ctx); MusicManager manager = manager(shards, musicService, ctx);
manager.pauseTrack(); manager.pauseTrack();
announce(manager, "Pausing current track"); announce(manager, "Pausing current track");
ctx.json(Map.of("status", "ok", "message", "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)) @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
} }
) )
private static void resumePlayback(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) { private static void resumePlayback(ShardProvider shards, MusicService musicService, Context ctx) {
MusicManager manager = manager(jdaSupplier, musicService, ctx); MusicManager manager = manager(shards, musicService, ctx);
manager.unpauseTrack(); manager.unpauseTrack();
ctx.json(Map.of("status", "ok", "message", "Resuming current track")); 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)) @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
} }
) )
private static void getVolume(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) { private static void getVolume(ShardProvider shards, MusicService musicService, Context ctx) {
MusicManager manager = manager(jdaSupplier, musicService, ctx); MusicManager manager = manager(shards, musicService, ctx);
ctx.json(Map.of("status", "ok", "volume", manager.getVolume())); 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)) @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
} }
) )
private static void volume(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) { private static void volume(ShardProvider shards, MusicService musicService, Context ctx) {
MusicManager manager = manager(jdaSupplier, musicService, ctx); MusicManager manager = manager(shards, musicService, ctx);
int newVolume = volume(ctx); int newVolume = volume(ctx);
manager.setVolume(newVolume); manager.setVolume(newVolume);
@@ -281,6 +288,46 @@ public final class MusicRoutes {
return volume; 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( @OpenApi(
path = "/music/{guildId}/seek", path = "/music/{guildId}/seek",
methods = HttpMethod.PUT, methods = HttpMethod.PUT,
@@ -294,8 +341,8 @@ public final class MusicRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
} }
) )
private static void seek(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) { private static void seek(ShardProvider shards, MusicService musicService, Context ctx) {
MusicManager manager = manager(jdaSupplier, musicService, ctx); MusicManager manager = manager(shards, musicService, ctx);
long positionMs = seekPosition(ctx, manager); long positionMs = seekPosition(ctx, manager);
manager.seek(positionMs); manager.seek(positionMs);
@@ -327,6 +374,31 @@ public final class MusicRoutes {
return positionMs; 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( @OpenApi(
path = "/music/{guildId}/vote-skip/{userId}", path = "/music/{guildId}/vote-skip/{userId}",
methods = HttpMethod.POST, methods = HttpMethod.POST,
@@ -341,8 +413,8 @@ public final class MusicRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
} }
) )
private static void voteSkip(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) { private static void voteSkip(ShardProvider shards, MusicService musicService, Context ctx) {
Guild guild = GuildRoutes.guild(jdaSupplier, ctx); Guild guild = GuildRoutes.guild(shards, ctx);
MusicManager manager = musicService.get(guild); MusicManager manager = musicService.get(guild);
String userId = ctx.pathParam("userId"); String userId = ctx.pathParam("userId");
@@ -372,12 +444,12 @@ public final class MusicRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
} }
) )
private static void dj(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) { private static void dj(ShardProvider shards, MusicService musicService, Context ctx) {
Guild guild = GuildRoutes.guild(jdaSupplier, ctx); Guild guild = GuildRoutes.guild(shards, ctx);
MusicManager manager = musicService.get(guild); MusicManager manager = musicService.get(guild);
String userId = ctx.pathParam("userId"); String userId = ctx.pathParam("userId");
CacheOrFetch.<Member>resolve( CacheOrFetch.resolve(
ctx, ctx,
() -> guild.getMemberById(userId), () -> guild.getMemberById(userId),
() -> guild.retrieveMemberById(userId), () -> guild.retrieveMemberById(userId),
@@ -398,8 +470,8 @@ public final class MusicRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
} }
) )
private static void play(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) { private static void play(ShardProvider shards, MusicService musicService, Context ctx) {
Guild guild = GuildRoutes.guild(jdaSupplier, ctx); Guild guild = GuildRoutes.guild(shards, ctx);
MusicManager manager = musicService.get(guild); MusicManager manager = musicService.get(guild);
QueueRequest body = ctx.bodyAsClass(QueueRequest.class); QueueRequest body = ctx.bodyAsClass(QueueRequest.class);
String userId = requireNonBlank(body.userId(), "userId"); String userId = requireNonBlank(body.userId(), "userId");
@@ -416,13 +488,13 @@ public final class MusicRoutes {
pathParams = @OpenApiParam(name = "guildId", description = "The Discord guild id"), pathParams = @OpenApiParam(name = "guildId", description = "The Discord guild id"),
requestBody = @OpenApiRequestBody(content = @OpenApiContent(from = SearchRequest.class)), requestBody = @OpenApiRequestBody(content = @OpenApiContent(from = SearchRequest.class)),
responses = { 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 = "400", content = @OpenApiContent(from = ErrorResponse.class)),
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
} }
) )
private static void search(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) { private static void search(ShardProvider shards, MusicService musicService, Context ctx) {
Guild guild = GuildRoutes.guild(jdaSupplier, ctx); Guild guild = GuildRoutes.guild(shards, ctx);
MusicManager manager = musicService.get(guild); MusicManager manager = musicService.get(guild);
SearchRequest body = ctx.bodyAsClass(SearchRequest.class); SearchRequest body = ctx.bodyAsClass(SearchRequest.class);
String query = requireNonBlank(body.query(), "query"); 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 * 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. * Exposed via the {@code /music/{guildId}/queue} POST endpoint.
*/ */
private static void playSelect(Guild guild, MusicManager manager, QueueRequest body, String userId, Context ctx) { 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"); throw new BadRequestResponse("index is required when searchId is provided unless queueAll is true");
} }
List<Track> candidates = PendingSearchResults.consume(searchId, guild.getId()); PendingSearchResults.Consumed consumed = PendingSearchResults.consumeWithPlaylist(searchId, guild.getId());
if (candidates == null) { if (consumed == null) {
throw new NotFoundResponse("Unknown or expired searchId"); throw new NotFoundResponse("Unknown or expired searchId");
} }
List<Track> candidates = consumed.candidates();
PlaylistInfo playlist = consumed.playlist();
if (!queueAll && (index < 0 || index >= candidates.size())) { if (!queueAll && (index < 0 || index >= candidates.size())) {
throw new BadRequestResponse("index is out of range"); throw new BadRequestResponse("index is out of range");
@@ -479,7 +553,7 @@ public final class MusicRoutes {
if (queueAll) { if (queueAll) {
ctx.future(() -> resolveMember(guild, userId) ctx.future(() -> resolveMember(guild, userId)
.thenApply(member -> { .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", announce(manager, String.format("Queued %d track%s requested by %s",
candidates.size(), candidates.size() > 1 ? "s" : "", member.getEffectiveName())); candidates.size(), candidates.size() > 1 ? "s" : "", member.getEffectiveName()));
ctx.json(queuedAllBody(candidates, manager.getQueueSize())); 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", summary = "Clear the queue, or remove a single track from it when a position is provided",
tags = {"Music"}, tags = {"Music"},
pathParams = @OpenApiParam(name = "guildId", description = "The Discord guild id"), 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 = { responses = {
@OpenApiResponse(status = "200", content = @OpenApiContent(from = DeleteQueueResponse.class)), @OpenApiResponse(status = "200", content = @OpenApiContent(from = DeleteQueueResponse.class)),
@OpenApiResponse(status = "400", content = @OpenApiContent(from = ErrorResponse.class)), @OpenApiResponse(status = "400", content = @OpenApiContent(from = ErrorResponse.class)),
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
} }
) )
private static void deleteQueue(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) { private static void deleteQueue(ShardProvider shards, MusicService musicService, Context ctx) {
MusicManager manager = manager(jdaSupplier, musicService, ctx); MusicManager manager = manager(shards, musicService, ctx);
String position = ctx.queryParam("position"); String position = ctx.queryParam("position");
QueueActionResult result; QueueActionResult result;
@@ -550,8 +624,8 @@ public final class MusicRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
} }
) )
private static void leave(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) { private static void leave(ShardProvider shards, MusicService musicService, Context ctx) {
MusicManager manager = manager(jdaSupplier, musicService, ctx); MusicManager manager = manager(shards, musicService, ctx);
manager.disconnect(); manager.disconnect();
announce(manager, "Leaving voice channel"); announce(manager, "Leaving voice channel");
ctx.json(new LeaveResponse("ok", "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)) @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
} }
) )
private static void shuffle(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) { private static void shuffle(ShardProvider shards, MusicService musicService, Context ctx) {
MusicManager manager = manager(jdaSupplier, musicService, ctx); MusicManager manager = manager(shards, musicService, ctx);
QueueActionResult result = manager.getQueueManager().shuffle(); QueueActionResult result = manager.getQueueManager().shuffle();
announce(manager, result.getMessage()); announce(manager, result.getMessage());
ctx.json(resultBodyWithQueue(result, manager)); ctx.json(resultBodyWithQueue(result, manager));
@@ -588,8 +662,8 @@ public final class MusicRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
} }
) )
private static void reorder(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) { private static void reorder(ShardProvider shards, MusicService musicService, Context ctx) {
MusicManager manager = manager(jdaSupplier, musicService, ctx); MusicManager manager = manager(shards, musicService, ctx);
ReorderRequest body = ctx.bodyAsClass(ReorderRequest.class); ReorderRequest body = ctx.bodyAsClass(ReorderRequest.class);
QueueActionResult result = manager.getQueueManager().reorder(body.fromPosition(), body.toPosition()); QueueActionResult result = manager.getQueueManager().reorder(body.fromPosition(), body.toPosition());
@@ -642,6 +716,8 @@ public final class MusicRoutes {
body.put("status", "pending_playlist"); body.put("status", "pending_playlist");
body.put("search_id", pendingPlaylist.searchId()); body.put("search_id", pendingPlaylist.searchId());
body.put("search_id_ttl_seconds", PendingSearchResults.ttlSeconds()); 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("tracks", pendingPlaylist.tracks().stream().map(MusicRoutes::trackSummary).toList());
body.put("truncated", pendingPlaylist.truncated()); body.put("truncated", pendingPlaylist.truncated());
yield body; yield body;
@@ -687,8 +763,8 @@ public final class MusicRoutes {
/** /**
* Resolves the {@link MusicManager} for the guild path parameter, creating it on first use. * 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) { private static MusicManager manager(ShardProvider shards, MusicService musicService, Context ctx) {
return musicService.get(GuildRoutes.guild(jdaSupplier, ctx)); return musicService.get(GuildRoutes.guild(shards, ctx));
} }
/** /**
@@ -751,6 +827,16 @@ public final class MusicRoutes {
Map<String, Object> summary = trackSummary(info.getTrack()); Map<String, Object> summary = trackSummary(info.getTrack());
summary.put("requester_id", info.getRequester().getId()); summary.put("requester_id", info.getRequester().getId());
summary.put("skip_count", info.getSkipCount()); 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; 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.SettingsReloadResponse;
import me.bobert.netneutrality.http.server.dto.SettingsUpdateRequest; import me.bobert.netneutrality.http.server.dto.SettingsUpdateRequest;
import me.bobert.netneutrality.http.server.dto.SettingsUpdateResponse; 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 net.dv8tion.jda.api.entities.Guild;
import java.util.Arrays; import java.util.Arrays;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.function.Supplier;
/** /**
* Registers the {@code /settings/*} endpoints on a {@link RoutesConfig} instance. * Registers the {@code /settings/*} endpoints on a {@link RoutesConfig} instance.
@@ -41,14 +40,14 @@ public final class SettingsRoutes {
private 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/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( @OpenApi(
@@ -87,8 +86,8 @@ public final class SettingsRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
} }
) )
private static void settingsCurrent(Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices, Context ctx) { private static void settingsCurrent(ShardProvider shards, GuildServiceProvider guildServices, Context ctx) {
Guild guild = GuildRoutes.guild(jdaSupplier, ctx); Guild guild = GuildRoutes.guild(shards, ctx);
Map<String, String> settings = guildServices.get(guild.getId()).getGuildSettingService().snapshot(); Map<String, String> settings = guildServices.get(guild.getId()).getGuildSettingService().snapshot();
ctx.json(Map.of("status", "ok", "settings", settings)); ctx.json(Map.of("status", "ok", "settings", settings));
@@ -110,8 +109,8 @@ public final class SettingsRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
} }
) )
private static void settingsUpdate(Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices, Context ctx) { private static void settingsUpdate(ShardProvider shards, GuildServiceProvider guildServices, Context ctx) {
Guild guild = GuildRoutes.guild(jdaSupplier, ctx); Guild guild = GuildRoutes.guild(shards, ctx);
GSettings setting = setting(ctx.pathParam("name")); GSettings setting = setting(ctx.pathParam("name"));
String value = ctx.bodyAsClass(Map.class).get("value") instanceof String s ? s : null; 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)) @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
} }
) )
private static void settingsReload(Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices, Context ctx) { private static void settingsReload(ShardProvider shards, GuildServiceProvider guildServices, Context ctx) {
Guild guild = GuildRoutes.guild(jdaSupplier, ctx); Guild guild = GuildRoutes.guild(shards, ctx);
guildServices.get(guild.getId()).getGuildSettingService().reloadSettingsCache(); guildServices.get(guild.getId()).getGuildSettingService().reloadSettingsCache();
ctx.json(Map.of("status", true)); 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.BotStatsResponse;
import me.bobert.netneutrality.http.server.dto.ErrorResponse; import me.bobert.netneutrality.http.server.dto.ErrorResponse;
import me.bobert.netneutrality.http.server.dto.GuildStatsResponse; 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 net.dv8tion.jda.api.entities.Guild;
import java.util.Map; import java.util.Map;
import java.util.function.Supplier;
/** /**
* Registers the {@code /stats/*} endpoints on a {@link RoutesConfig} instance. * Registers the {@code /stats/*} endpoints on a {@link RoutesConfig} instance.
@@ -32,9 +31,9 @@ public final class StatsRoutes {
private StatsRoutes() { private StatsRoutes() {
} }
public static void register(RoutesConfig routes, Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices) { public static void register(RoutesConfig routes, ShardProvider shards, GuildServiceProvider guildServices) {
routes.get("/stats/bot", ctx -> bot(jdaSupplier, ctx)); routes.get("/stats/bot", ctx -> bot(shards, ctx));
routes.get("/stats/guilds/{guildId}", ctx -> guild(jdaSupplier, guildServices, ctx)); routes.get("/stats/guilds/{guildId}", ctx -> guild(shards, guildServices, ctx));
} }
@OpenApi( @OpenApi(
@@ -44,13 +43,14 @@ public final class StatsRoutes {
tags = {"Stats"}, tags = {"Stats"},
responses = @OpenApiResponse(status = "200", content = @OpenApiContent(from = BotStatsResponse.class)) responses = @OpenApiResponse(status = "200", content = @OpenApiContent(from = BotStatsResponse.class))
) )
private static void bot(Supplier<JDA> jdaSupplier, Context ctx) { private static void bot(ShardProvider shards, Context ctx) {
JDA jda = GuildRoutes.jda(jdaSupplier); ShardProvider provider = GuildRoutes.shards(shards);
ctx.json(Map.of( ctx.json(Map.of(
"guild_count", jda.getGuilds().size(), "guild_count", provider.getGuilds().size(),
"gateway_ping", jda.getGatewayPing(), "gateway_ping", (long) provider.getAveragePing(),
"status", String.valueOf(jda.getStatus()), "status", String.valueOf(provider.getWorstStatus()),
"shard_count", provider.getShardCount(),
"uptime_seconds", uptimeSeconds() "uptime_seconds", uptimeSeconds()
)); ));
} }
@@ -66,8 +66,8 @@ public final class StatsRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class)) @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
} }
) )
private static void guild(Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices, Context ctx) { private static void guild(ShardProvider shards, GuildServiceProvider guildServices, Context ctx) {
Guild guild = GuildRoutes.guild(jdaSupplier, ctx); Guild guild = GuildRoutes.guild(shards, ctx);
GuildService guildService = guildServices.get(guild.getId()); GuildService guildService = guildServices.get(guild.getId());
ctx.json(Map.of( 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.db.service.GuildServiceProvider;
import me.bobert.netneutrality.music.MusicService; import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.utils.SettingsProvider; 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.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
import java.util.Map; import java.util.Map;
import java.util.function.Supplier;
/** /**
* Embedded HTTP API exposed as a {@link Lifecycle} component (priority 70, after JDA). * 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 SettingsProvider settings;
private final GuildServiceProvider guildServices; private final GuildServiceProvider guildServices;
private final MusicService musicService; private final MusicService musicService;
private final Supplier<JDA> jdaSupplier; private final ShardProvider shards;
private final Runnable shutdownHook; private final Runnable shutdownHook;
private Javalin app; private Javalin app;
@@ -40,13 +39,13 @@ public class WebApi implements Lifecycle {
SettingsProvider settings, SettingsProvider settings,
GuildServiceProvider guildServices, GuildServiceProvider guildServices,
MusicService musicService, MusicService musicService,
Supplier<JDA> jdaSupplier, ShardProvider shards,
Runnable shutdownHook Runnable shutdownHook
) { ) {
this.settings = settings; this.settings = settings;
this.guildServices = guildServices; this.guildServices = guildServices;
this.musicService = musicService; this.musicService = musicService;
this.jdaSupplier = jdaSupplier; this.shards = shards;
this.shutdownHook = shutdownHook; this.shutdownHook = shutdownHook;
} }
@@ -66,7 +65,7 @@ public class WebApi implements Lifecycle {
config.startup.showJavalinBanner = false; config.startup.showJavalinBanner = false;
config.registerPlugin(new OpenApiPlugin(pluginConfig -> pluginConfig.withDefinitionConfiguration( config.registerPlugin(new OpenApiPlugin(pluginConfig -> pluginConfig.withDefinitionConfiguration(
(version, definition) -> definition (_, definition) -> definition
.info(info -> info.title("Net Neutrality API")) .info(info -> info.title("Net Neutrality API"))
.withApiKeyAuth("ApiKeyAuth", "X-API-Key") .withApiKeyAuth("ApiKeyAuth", "X-API-Key")
.withGlobalSecurity("ApiKeyAuth") .withGlobalSecurity("ApiKeyAuth")
@@ -77,11 +76,11 @@ public class WebApi implements Lifecycle {
config.routes.before(new ApiKeyFilter(settings)); config.routes.before(new ApiKeyFilter(settings));
config.routes.get("/", ctx -> ctx.html(LIVENESS_HTML)); config.routes.get("/", ctx -> ctx.html(LIVENESS_HTML));
GuildRoutes.register(config.routes, jdaSupplier, guildServices); GuildRoutes.register(config.routes, shards, guildServices);
ActionRoutes.register(config.routes, jdaSupplier, shutdownHook); ActionRoutes.register(config.routes, shards, shutdownHook);
SettingsRoutes.register(config.routes, jdaSupplier, guildServices); SettingsRoutes.register(config.routes, shards, guildServices);
StatsRoutes.register(config.routes, jdaSupplier, guildServices); StatsRoutes.register(config.routes, shards, guildServices);
MusicRoutes.register(config.routes, jdaSupplier, musicService); MusicRoutes.register(config.routes, shards, musicService);
config.routes.exception(HttpResponseException.class, (e, ctx) -> config.routes.exception(HttpResponseException.class, (e, ctx) ->
ctx.status(e.getStatus()).json(Map.of("status", "error", "message", e.getMessage())) 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( public record AdminStatusResponse(
String guildId, 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. * entry.
*/ */
public record ChannelSummaryResponse( public record ChannelSummaryResponse(
@@ -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. * request bodies.
*/ */
public record GuildBooleanUpdateRequest( 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. * debug/logging/log-channel update responses.
*/ */
public record GuildConfigResponse( public record GuildConfigResponse(
@@ -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( public record GuildLogChannelUpdateRequest(
String value 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( public record MemberRolesResponse(
String guild_id, 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( public record MemberSummaryResponse(
String user_id, 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( public record OwnerResponse(
String guild_id, String guild_id,
@@ -9,28 +9,17 @@ package me.bobert.netneutrality.http.server.dto;
import java.util.List; import java.util.List;
/** /**
* OpenAPI schema for the {@code /music/{guildId}/queue} and {@code /music/{guildId}/search} * OpenAPI schema for the {@code /music/{guildId}/queue} response.
* responses.
* *
* <p>Nothing is ever queued directly by {@code /music/{guildId}/search}: every resolved query is * <p>Fields are nullable since only a subset is populated depending on {@code status}: {@code
* stashed as a pending candidate, to be confirmed via a follow-up {@code /music/{guildId}/queue} * queued} fills {@code track}/{@code queue_position} (confirmed selection) and {@code queued_all}
* call carrying {@code searchId}/{@code index} (or {@code queueAll}). Fields are nullable since only a subset is * fills {@code tracks}/{@code queue_position} (confirmed bulk selection; {@code queue_position} is
* populated depending on {@code status}: {@code queued} fills {@code track}/{@code queue_position} (confirmed * the resulting queue size).
* 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.
*/ */
public record QueueResponse( public record QueueResponse(
String status, String status,
TrackSummaryResponse track, TrackSummaryResponse track,
Integer queue_position, Integer queue_position,
String search_id, List<TrackSummaryResponse> tracks
Long search_id_ttl_seconds,
List<TrackSummaryResponse> tracks,
Boolean truncated,
List<TrackSummaryResponse> candidates
) { ) {
} }
@@ -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. * response.
*/ */
public record RoleSummaryResponse( 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
) {
}
@@ -7,8 +7,8 @@ package me.bobert.netneutrality.http.server.dto;
*/ */
/** /**
* OpenAPI schema for the {@code /guilds/voice-channels/{guildId}} and * OpenAPI schema for the {@code /guilds/{guildId}/voice-channels} and
* {@code /guilds/voice-channels/{guildId}/{channelId}} responses. * {@code /guilds/{guildId}/voice-channels/{channelId}} responses.
*/ */
public record VoiceChannelSummaryResponse( public record VoiceChannelSummaryResponse(
String channel_id, 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.LogManager;
import org.apache.logging.log4j.Logger; 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.concurrent.CompletableFuture;
import java.util.function.Consumer; import java.util.function.Consumer;
@@ -49,8 +53,15 @@ public class MusicManager implements MusicManagerCallback {
private final GuildService guildService; private final GuildService guildService;
private final TaskManager taskManager; private final TaskManager taskManager;
private final VoteSkipService voteSkipService; private final VoteSkipService voteSkipService;
private final EventPublisher events;
MusicManager(Guild guild, LavalinkClient client, GuildService guildService, TaskManager taskManager) { 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.guild = guild;
this.client = client; this.client = client;
this.scheduler = new TrackScheduler(this); this.scheduler = new TrackScheduler(this);
@@ -88,6 +99,10 @@ public class MusicManager implements MusicManagerCallback {
return voteSkipService; return voteSkipService;
} }
public EventPublisher getEvents() {
return events;
}
public Guild getGuild() { public Guild getGuild() {
return guild; return guild;
} }
@@ -324,6 +339,7 @@ public class MusicManager implements MusicManagerCallback {
*/ */
public synchronized void pauseTrack() { public synchronized void pauseTrack() {
updatePlayer("pause track", builder -> builder.setPaused(true)); 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() { public synchronized void unpauseTrack() {
updatePlayer("unpause track", builder -> builder.setPaused(false)); 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) { public synchronized QueueActionResult voteSkip(Member member) {
int threshold = parseVoteSkipThreshold(guildService); 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) { private static int parseVoteSkipThreshold(GuildService guildService) {
@@ -366,6 +398,7 @@ public class MusicManager implements MusicManagerCallback {
public void setVolume(int volume) { public void setVolume(int volume) {
updatePlayer("set volume", builder -> builder.setVolume(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) { public void seek(long positionMs) {
updatePlayer("seek to " + positionMs, builder -> builder.setPosition(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() { public int getQueueSize() {
@@ -10,6 +10,7 @@ import dev.arbjerg.lavalink.client.Helpers;
import dev.arbjerg.lavalink.client.LavalinkClient; import dev.arbjerg.lavalink.client.LavalinkClient;
import dev.arbjerg.lavalink.client.LavalinkNode; import dev.arbjerg.lavalink.client.LavalinkNode;
import dev.arbjerg.lavalink.client.NodeOptions; 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.ReadyEvent;
import dev.arbjerg.lavalink.client.event.TrackEndEvent; import dev.arbjerg.lavalink.client.event.TrackEndEvent;
import dev.arbjerg.lavalink.client.event.TrackExceptionEvent; 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.ScheduledFuture;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.function.Consumer; 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 * 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 GuildServiceProvider guildServices;
private final TaskManager taskManager; private final TaskManager taskManager;
private final ExecutorProvider executors; private final ExecutorProvider executors;
private final EventPublisher events;
private final Map<String, MusicManager> managers = new ConcurrentHashMap<>(); private final Map<String, MusicManager> managers = new ConcurrentHashMap<>();
private final List<Disposable> subscriptions = new ArrayList<>(); private final List<Disposable> subscriptions = new ArrayList<>();
private final LavalinkClient client; private final LavalinkClient client;
@@ -61,6 +65,12 @@ public class MusicServiceImpl implements MusicService {
public MusicServiceImpl(SettingsProvider settings, GuildServiceProvider guildServices, TaskManager taskManager, public MusicServiceImpl(SettingsProvider settings, GuildServiceProvider guildServices, TaskManager taskManager,
ExecutorProvider executors) { 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.settings = settings;
this.guildServices = guildServices; this.guildServices = guildServices;
this.taskManager = taskManager; this.taskManager = taskManager;
@@ -70,7 +80,9 @@ public class MusicServiceImpl implements MusicService {
@Override @Override
public int priority() { 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 @Override
@@ -105,7 +117,7 @@ public class MusicServiceImpl implements MusicService {
public MusicManager get(Guild guild) { public MusicManager get(Guild guild) {
return managers.computeIfAbsent( return managers.computeIfAbsent(
guild.getId(), 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) subscriptions.add(client.on(TrackStuckEvent.class)
.subscribe(event -> dispatch(event.getGuildId(), scheduler -> scheduler.onTrackStuck(event.getTrack(), event.getThresholdMs())))); .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) subscriptions.add(client.on(WebSocketClosedEvent.class)
.subscribe(event -> dispatch(event.getGuildId(), scheduler -> scheduler.onWebSocketClosed(event.getCode(), event.getReason(), event.getByRemote())))); .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 * @return the generated search id
*/ */
public static String put(String guildId, List<Track> candidates) { 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(); sweep();
String searchId = UUID.randomUUID().toString(); 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; return searchId;
} }
@@ -62,6 +69,14 @@ public final class PendingSearchResults {
* belongs to a different guild * belongs to a different guild
*/ */
public static List<Track> consume(String searchId, String guildId) { 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(); sweep();
Entry entry = entries.remove(searchId); Entry entry = entries.remove(searchId);
@@ -69,7 +84,10 @@ public final class PendingSearchResults {
return null; return null;
} }
return entry.candidates(); return new Consumed(entry.candidates(), entry.playlist());
}
public record Consumed(List<Track> candidates, PlaylistInfo playlist) {
} }
private static void sweep() { private static void sweep() {
@@ -77,6 +95,6 @@ public final class PendingSearchResults {
entries.entrySet().removeIf(entry -> now.isAfter(entry.getValue().expiresAt())); 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 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 { 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 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.Guild;
import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.Member;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
@@ -14,7 +15,9 @@ import org.apache.logging.log4j.Logger;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.Deque; import java.util.Deque;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.concurrent.LinkedBlockingDeque; import java.util.concurrent.LinkedBlockingDeque;
import java.util.function.Predicate; import java.util.function.Predicate;
@@ -61,10 +64,55 @@ public class QueueManager {
public void removeHeadOfQueue() { public void removeHeadOfQueue() {
synchronized (queue) { 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() { public boolean isEmpty() {
synchronized (queue) { synchronized (queue) {
return queue.isEmpty(); return queue.isEmpty();
@@ -79,6 +127,7 @@ public class QueueManager {
} }
queue.clear(); queue.clear();
notifyQueueUpdated("cleared", List.of());
scheduler.getCallback().onQueueEmpty(); scheduler.getCallback().onQueueEmpty();
return QueueActionResult.success(String.format("Removed %d track%s from the queue", queueSize, (queueSize > 1 ? "s" : ""))); 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(); 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())); queue.removeIf(trackInfo -> member.equals(trackInfo.getRequester()));
notifyQueueUpdated("removed", removed);
if (first != null && member.equals(first.getRequester())) { if (first != null && member.equals(first.getRequester())) {
skip(1); skip(1);
@@ -116,7 +174,83 @@ public class QueueManager {
} }
public void queue(List<Track> tracks, Member member) { 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) { public void queue(Track track, Member member) {
@@ -134,6 +268,7 @@ public class QueueManager {
} else { } else {
queue.add(info); queue.add(info);
} }
notifyQueueUpdated("added", List.of(item(queue.peekFirst() == info ? 0 : queue.size() - 1, info)));
if (idle) { if (idle) {
TrackInfo head = queue.peek(); TrackInfo head = queue.peek();
@@ -181,7 +316,8 @@ public class QueueManager {
public void startNextTrack() { public void startNextTrack() {
synchronized (queue) { synchronized (queue) {
queue.poll(); TrackInfo polled = queue.poll();
notifyQueueUpdated("removed", polled == null ? List.of() : List.of(item(0, polled)));
TrackInfo next = queue.peek(); TrackInfo next = queue.peek();
if (next != null) { if (next != null) {
@@ -243,6 +379,12 @@ public class QueueManager {
queue.clear(); queue.clear();
queue.add(current); queue.add(current);
queue.addAll(newQueue); 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."); return QueueActionResult.success("The queue is now shuffled.");
} }
@@ -262,10 +404,11 @@ public class QueueManager {
if (index > 1) { if (index > 1) {
List<TrackInfo> newQueue = new ArrayList<>(queue); List<TrackInfo> newQueue = new ArrayList<>(queue);
newQueue.remove(index - 1); TrackInfo removedInfo = newQueue.remove(index - 1);
queue.clear(); queue.clear();
queue.addAll(newQueue); queue.addAll(newQueue);
notifyQueueUpdated("removed", List.of(item(index - 1, removedInfo)));
return QueueActionResult.success(String.format("Removed track #%d from the queue", index)); return QueueActionResult.success(String.format("Removed track #%d from the queue", index));
} }
@@ -309,6 +452,10 @@ public class QueueManager {
queue.clear(); queue.clear();
queue.addAll(newQueue); 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)); 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 { public class SearchResultHandler extends AbstractAudioLoadResultHandler {
private static final Logger logger = LogManager.getLogger(SearchResultHandler.class); private static final Logger logger = LogManager.getLogger(SearchResultHandler.class);
private static final int SEARCH_RESULTS_LIMIT = 5;
private final String identifier; private final String identifier;
private final Guild guild; private final Guild guild;
@@ -65,8 +64,9 @@ public class SearchResultHandler extends AbstractAudioLoadResultHandler {
boolean truncated = tracks.size() > maxPlaylistSize; boolean truncated = tracks.size() > maxPlaylistSize;
List<Track> candidates = truncated ? tracks.subList(0, maxPlaylistSize) : tracks; List<Track> candidates = truncated ? tracks.subList(0, maxPlaylistSize) : tracks;
String searchId = PendingSearchResults.put(guild.getId(), candidates); String searchId = PendingSearchResults.put(guild.getId(), candidates,
future.complete(new PlayOutcome.PendingPlaylist(searchId, candidates, truncated)); new PlaylistInfo(identifier, result.getInfo().getName(), candidates.size()));
future.complete(new PlayOutcome.PendingPlaylist(searchId, identifier, result.getInfo().getName(), candidates, truncated));
} }
@Override @Override
@@ -79,7 +79,7 @@ public class SearchResultHandler extends AbstractAudioLoadResultHandler {
return; return;
} }
List<Track> candidates = tracks.stream().limit(SEARCH_RESULTS_LIMIT).toList(); List<Track> candidates = tracks.stream().toList();
String searchId = PendingSearchResults.put(guild.getId(), candidates); String searchId = PendingSearchResults.put(guild.getId(), candidates);
future.complete(new PlayOutcome.PendingSearch(searchId, candidates)); future.complete(new PlayOutcome.PendingSearch(searchId, candidates));
} }
@@ -21,12 +21,23 @@ public class TrackInfo {
private final Set<String> skips; private final Set<String> skips;
private static final Logger logger = LogManager.getLogger(TrackInfo.class); private static final Logger logger = LogManager.getLogger(TrackInfo.class);
private final PlaylistInfo playlist;
public TrackInfo(Track track, Member member) { public TrackInfo(Track track, Member member) {
this(track, member, null);
}
public TrackInfo(Track track, Member member, PlaylistInfo playlist) {
this.track = track; this.track = track;
this.member = member; this.member = member;
this.playlist = playlist;
this.skips = new HashSet<>(); this.skips = new HashSet<>();
} }
public PlaylistInfo getPlaylist() {
return playlist;
}
public Track getTrack() { public Track getTrack() {
return track; return track;
} }
@@ -67,7 +67,7 @@ public class TrackLoadingHandler extends AbstractAudioLoadResultHandler {
public void onPlaylistLoaded(PlaylistLoaded result) { public void onPlaylistLoaded(PlaylistLoaded result) {
logger.debug("Guild {}: playlist loaded: {}", guild.getId(), identifier); 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; return;
} }
new LoadPlaylistTask("Search results", tracks, true, type, manager).insert(context); new LoadPlaylistTask(identifier, "Search results", tracks, true, type, manager).insert(context);
} }
@Override @Override
@@ -11,12 +11,15 @@ import dev.arbjerg.lavalink.protocol.v4.Message.EmittedEvent.TrackEndEvent;
import me.bobert.netneutrality.context.MusicChannelContext; import me.bobert.netneutrality.context.MusicChannelContext;
import me.bobert.netneutrality.core.LogHandler; import me.bobert.netneutrality.core.LogHandler;
import me.bobert.netneutrality.core.context.Context; import me.bobert.netneutrality.core.context.Context;
import me.bobert.netneutrality.events.MusicEvent;
import me.bobert.netneutrality.exceptions.ContextException; import me.bobert.netneutrality.exceptions.ContextException;
import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.Member;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
import java.util.concurrent.atomic.AtomicBoolean;
/** /**
* Sink for the track lifecycle events emitted by the Lavalink node. * Sink for the track lifecycle events emitted by the Lavalink node.
* *
@@ -32,8 +35,19 @@ public class TrackScheduler {
private volatile Context context; private volatile Context context;
private volatile Track currentTrack; private volatile Track currentTrack;
private final StuckTrackRecoveryHandler stuckTrackRecoveryHandler; 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) { public TrackScheduler(MusicManager manager) {
this(manager, new AutoplayService());
}
public TrackScheduler(MusicManager manager, AutoplayService autoplayService) {
this.autoplayService = autoplayService;
this.manager = manager; this.manager = manager;
this.callback = manager; this.callback = manager;
this.guild = manager.getGuild(); this.guild = manager.getGuild();
@@ -73,6 +87,37 @@ public class TrackScheduler {
this.currentTrack = track; 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() { public Member getDj() {
Context ctx = context; Context ctx = context;
if (ctx != null) { if (ctx != null) {
@@ -102,6 +147,7 @@ public class TrackScheduler {
logger.debug("Guild {}: starting track: {}", guild.getId(), track.getInfo().getIdentifier()); logger.debug("Guild {}: starting track: {}", guild.getId(), track.getInfo().getIdentifier());
currentTrack = track; currentTrack = track;
stuckTrackRecoveryHandler.reset(); stuckTrackRecoveryHandler.reset();
publish(MusicEvent.Type.TRACK_START, track, null);
} }
public void onTrackEnd(Track track, TrackEndEvent.AudioTrackEndReason endReason) { public void onTrackEnd(Track track, TrackEndEvent.AudioTrackEndReason endReason) {
@@ -110,6 +156,7 @@ public class TrackScheduler {
if (endReason != TrackEndEvent.AudioTrackEndReason.REPLACED) { if (endReason != TrackEndEvent.AudioTrackEndReason.REPLACED) {
currentTrack = null; currentTrack = null;
} }
publish(MusicEvent.Type.TRACK_END, track, endReason.name());
if (endReason == TrackEndEvent.AudioTrackEndReason.LOAD_FAILED) { if (endReason == TrackEndEvent.AudioTrackEndReason.LOAD_FAILED) {
logger.debug("Guild {}: failed loading track: {}", guild.getId(), track.getInfo().getIdentifier()); 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 // 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) { public void onTrackException(Track track, TrackException exception) {
logger.error("Guild {}: exception while playing track: {}", guild.getId(), track.getInfo().getIdentifier()); 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( LogHandler.log(new ContextException(
String.format("Error while playing track: %s", track.getInfo().getTitle()), String.format("Error while playing track: %s", track.getInfo().getTitle()),
getContext(), getContext(),
@@ -146,9 +223,37 @@ public class TrackScheduler {
public void onTrackStuck(Track track, long thresholdMs) { public void onTrackStuck(Track track, long thresholdMs) {
logger.error("Guild {}: currently playing track is stuck: {} (threshold {}ms)", guild.getId(), track.getInfo().getIdentifier(), 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); 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) { public void onWebSocketClosed(int code, String reason, boolean byRemote) {
logger.warn("Guild {}: voice websocket closed with code {} ({}), by remote: {}", guild.getId(), code, reason, byRemote); logger.warn("Guild {}: voice websocket closed with code {} ({}), by remote: {}", guild.getId(), code, reason, byRemote);
currentTrack = null; 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);
}
}
@@ -21,9 +21,9 @@ import net.dv8tion.jda.api.interactions.modals.ModalMapping;
import net.dv8tion.jda.api.modals.Modal; import net.dv8tion.jda.api.modals.Modal;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
import org.gitlab4j.api.GitLabApi; import me.bobert.netneutrality.utils.GiteaClient;
import org.gitlab4j.api.GitLabApiException;
import org.gitlab4j.api.models.Issue; import java.io.IOException;
public class ReportIssueTask extends ModalTask { public class ReportIssueTask extends ModalTask {
private static final Logger logger = LogManager.getLogger(ReportIssueTask.class); private static final Logger logger = LogManager.getLogger(ReportIssueTask.class);
@@ -72,14 +72,16 @@ public class ReportIssueTask extends ModalTask {
public Lifecycle execute(TaskContext context) { public Lifecycle execute(TaskContext context) {
context.ephemeral(); context.ephemeral();
String gitlabHost = settings.get("GITLAB_HOST"); String host = settings.get("GITEA_HOST");
String gitlabToken = settings.get("GITLAB_TOKEN"); String token = settings.get("GITEA_TOKEN");
String projectId = settings.get("GITLAB_PROJECT_ID"); String owner = settings.get("GITEA_OWNER");
String repo = settings.get("GITEA_REPO");
if (gitlabHost == null || gitlabHost.isBlank() if (host == null || host.isBlank()
|| gitlabToken == null || gitlabToken.isBlank() || token == null || token.isBlank()
|| projectId == null || projectId.isBlank()) { || owner == null || owner.isBlank()
logger.error("Gitlab is not configured in the bot settings, can't execute task"); || repo == null || repo.isBlank()) {
logger.error("Gitea is not configured in the bot settings, can't execute task");
context.reply("Command is not configured, failed to create issue."); context.reply("Command is not configured, failed to create issue.");
return null; return null;
} }
@@ -93,8 +95,7 @@ public class ReportIssueTask extends ModalTask {
} }
User user = context.getDiscordUser(); User user = context.getDiscordUser();
try (GitLabApi gitLabApi = new GitLabApi(gitlabHost, gitlabToken)) { try {
gitLabApi.setRequestTimeout(1000, 5000);
String title = String.format("Bug report: %s", modalTitle.getAsString()); String title = String.format("Bug report: %s", modalTitle.getAsString());
String description = "Issue reported from user of bot\n\n" + String description = "Issue reported from user of bot\n\n" +
@@ -102,11 +103,11 @@ public class ReportIssueTask extends ModalTask {
"Last Sentry ID: " + Sentry.getLastEventId() + "\n\n" + "Last Sentry ID: " + Sentry.getLastEventId() + "\n\n" +
"Description from user: \n\n" + modalDescription.getAsString(); "Description from user: \n\n" + modalDescription.getAsString();
Issue issue = gitLabApi.getIssuesApi().createIssue(projectId, title, description); String url = new GiteaClient(host, token, owner, repo).createIssue(title, description);
context.reply(String.format("Issue was successfully created: %s", issue.getWebUrl())); context.reply(String.format("Issue was successfully created: %s", url));
} catch (GitLabApiException e) { } catch (IOException e) {
context.reply("Failed to create issue."); context.reply("Failed to create issue.");
LogHandler.log(e); LogHandler.log(e);
} }
@@ -13,6 +13,7 @@ import me.bobert.netneutrality.core.tasks.trait.Deletable;
import me.bobert.netneutrality.core.tasks.trait.WithChildren; import me.bobert.netneutrality.core.tasks.trait.WithChildren;
import me.bobert.netneutrality.db.GSettings; import me.bobert.netneutrality.db.GSettings;
import me.bobert.netneutrality.music.MusicManager; import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.music.PlaylistInfo;
import me.bobert.netneutrality.music.SourceFinder; import me.bobert.netneutrality.music.SourceFinder;
import me.bobert.netneutrality.core.tasks.generic.AcceptButton; import me.bobert.netneutrality.core.tasks.generic.AcceptButton;
import me.bobert.netneutrality.core.tasks.generic.CancelButton; import me.bobert.netneutrality.core.tasks.generic.CancelButton;
@@ -27,15 +28,17 @@ import java.util.List;
public class LoadPlaylistTask extends ButtonTask implements WithChildren, Deletable { public class LoadPlaylistTask extends ButtonTask implements WithChildren, Deletable {
private final List<Task> childrens = new ArrayList<>(); private final List<Task> childrens = new ArrayList<>();
private final String playlistIdentifier;
private final String playlistName; private final String playlistName;
private final List<Track> playlistTracks; private final List<Track> playlistTracks;
private final boolean searchResult; private final boolean searchResult;
private final SourceFinder.AudioSourceType sourceType; private final SourceFinder.AudioSourceType sourceType;
private final MusicManager musicManager; 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) { SourceFinder.AudioSourceType sourceType, MusicManager musicManager) {
super(musicManager.getTaskManager(), null, null); super(musicManager.getTaskManager(), null, null);
this.playlistIdentifier = playlistIdentifier;
this.playlistName = playlistName; this.playlistName = playlistName;
this.playlistTracks = playlistTracks; this.playlistTracks = playlistTracks;
this.searchResult = searchResult; this.searchResult = searchResult;
@@ -93,7 +96,8 @@ public class LoadPlaylistTask extends ButtonTask implements WithChildren, Deleta
// Load playlist normally // Load playlist normally
if (MusicUtils.connect(context, musicManager)) { 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)); 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(); Member member = context.getGuildUser();
if (MusicUtils.connect(context, musicManager)) { 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)); context.reply(MessageFunction.buildAddingPlaylistMessage(tracks, playlistName, member, SourceFinder.AudioSourceType.Youtube));
return Lifecycle.REMOVE_SELF_AND_CHILDREN; return Lifecycle.REMOVE_SELF_AND_CHILDREN;
@@ -0,0 +1,88 @@
package me.bobert.netneutrality.utils;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import java.io.IOException;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
public class GiteaClient {
private static final MediaType JSON = MediaType.get("application/json; charset=utf-8");
private static final ObjectMapper MAPPER = new ObjectMapper();
private final String host;
private final String token;
private final String owner;
private final String repo;
private final OkHttpClient client;
public GiteaClient(String host, String token, String owner, String repo) {
this(host, token, owner, repo, new OkHttpClient.Builder()
.connectTimeout(Duration.ofSeconds(1))
.readTimeout(Duration.ofSeconds(5))
.build());
}
public GiteaClient(String host, String token, String owner, String repo, OkHttpClient client) {
Objects.requireNonNull(host, "host");
this.host = host.endsWith("/") ? host.replaceAll("/+$", "") : host;
this.token = Objects.requireNonNull(token, "token");
this.owner = Objects.requireNonNull(owner, "owner");
this.repo = Objects.requireNonNull(repo, "repo");
this.client = Objects.requireNonNull(client, "client");
}
public boolean ping() {
Request request = new Request.Builder().url(host + "/api/v1/version").get().build();
try (Response response = client.newCall(request).execute()) {
return response.isSuccessful();
} catch (IOException e) {
return false;
}
}
/**
* Creates an issue and returns its html url.
*
* @throws IOException on network failure, non-201 response or malformed response
*/
public String createIssue(String title, String body) throws IOException {
Map<String, String> payload = new LinkedHashMap<>();
payload.put("title", title);
payload.put("body", body);
Request request = new Request.Builder()
.url(host + "/api/v1/repos/" + owner + "/" + repo + "/issues")
.header("Authorization", "token " + token)
.header("Accept", "application/json")
.post(RequestBody.create(MAPPER.writeValueAsString(payload), JSON))
.build();
try (Response response = client.newCall(request).execute()) {
if (response.code() != 201) {
throw new IOException("Gitea returned HTTP " + response.code() + " when creating issue");
}
if (response.body() == null) {
throw new IOException("Gitea returned an empty response");
}
JsonNode url = MAPPER.readTree(response.body().string()).get("html_url");
if (url == null || !url.isTextual()) {
throw new IOException("Gitea response did not contain html_url");
}
return url.asText();
}
}
}
@@ -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 * https://discord.gg/qUa6zNFZsd
*/ */
import me.bobert.netneutrality.context.command.SlashCommandContext;
import me.bobert.netneutrality.core.context.Context; import me.bobert.netneutrality.core.context.Context;
import me.bobert.netneutrality.db.GSettings; import me.bobert.netneutrality.db.GSettings;
import me.bobert.netneutrality.exceptions.ContextException; import me.bobert.netneutrality.exceptions.ContextException;
@@ -94,14 +93,14 @@ public class MusicUtils {
} }
public static boolean isUserDj(Context context, MusicManager manager) { public static boolean isUserDj(Context context, MusicManager manager) {
if (context instanceof SlashCommandContext) {
return true;
}
return isUserDj(context.getGuildUser(), manager); return isUserDj(context.getGuildUser(), manager);
} }
public static boolean isUserDj(Member member, MusicManager 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); String djRoleId = manager.getGuildService().getGuildSettingService().getOrDefault(GSettings.MUSIC_DJ_ROLE);
if (djRoleId != null && !djRoleId.isEmpty() && member.getRoles().stream().anyMatch(role -> role.getId().equals(djRoleId))) { if (djRoleId != null && !djRoleId.isEmpty() && member.getRoles().stream().anyMatch(role -> role.getId().equals(djRoleId))) {
return true; return true;
+1 -1
View File
@@ -4,7 +4,7 @@
<hibernate-configuration> <hibernate-configuration>
<session-factory> <session-factory>
<property name="hibernate.dialect">org.hibernate.dialect.MySQLDialect</property> <property name="hibernate.dialect">org.hibernate.dialect.MariaDBDialect</property>
<property name="hibernate.connection.driver_class">com.mysql.cj.jdbc.Driver</property> <property name="hibernate.connection.driver_class">com.mysql.cj.jdbc.Driver</property>
<property name="hibernate.connection.provider_class">org.hibernate.hikaricp.internal.HikariCPConnectionProvider</property> <property name="hibernate.connection.provider_class">org.hibernate.hikaricp.internal.HikariCPConnectionProvider</property>
<!-- The schema is owned by Flyway; Hibernate only checks that the entities match it. --> <!-- The schema is owned by Flyway; Hibernate only checks that the entities match it. -->
@@ -6,57 +6,85 @@ package me.bobert.netneutrality.command.git;
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import org.gitlab4j.api.GitLabApi; import com.sun.net.httpserver.HttpServer;
import org.gitlab4j.api.IssuesApi; import me.bobert.netneutrality.utils.GiteaClient;
import org.gitlab4j.api.models.Issue; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.util.UUID; import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.mockito.ArgumentMatchers.eq; import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.*; import static org.junit.jupiter.api.Assertions.assertTrue;
class ReportIssueCommandTest { class ReportIssueCommandTest {
private GitLabApi mockGitLabApi; private HttpServer server;
private IssuesApi mockIssuesApi; private final AtomicReference<String> lastAuth = new AtomicReference<>();
private final AtomicReference<String> lastBody = new AtomicReference<>();
private volatile int status = 201;
private volatile String response = "{\"html_url\":\"http://x/o/r/issues/1\"}";
@BeforeEach @BeforeEach
void setup() { void setup() throws IOException {
mockGitLabApi = mock(GitLabApi.class); server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
mockIssuesApi = mock(IssuesApi.class); server.createContext("/api/v1/version", ex -> {
ex.sendResponseHeaders(200, -1);
ex.close();
});
server.createContext("/api/v1/repos/o/r/issues", ex -> {
lastAuth.set(ex.getRequestHeaders().getFirst("Authorization"));
lastBody.set(new String(ex.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
byte[] bytes = response.getBytes(StandardCharsets.UTF_8);
ex.sendResponseHeaders(status, bytes.length);
ex.getResponseBody().write(bytes);
ex.close();
});
server.start();
}
when(mockGitLabApi.getIssuesApi()).thenReturn(mockIssuesApi); @AfterEach
void teardown() {
server.stop(0);
}
private GiteaClient client() {
return new GiteaClient("http://127.0.0.1:" + server.getAddress().getPort() + "/", "tok", "o", "r");
} }
@Test @Test
void testCreateIssueMocked() throws Exception { void createIssueReturnsHtmlUrl() throws IOException {
int projectId = 123456; assertEquals("http://x/o/r/issues/1", client().createIssue("t", "b"));
String modalTitle = "Bug report: mocked bug report"; assertEquals("token tok", lastAuth.get());
String modalDescription = "This is a mocked description"; assertTrue(lastBody.get().contains("\"title\":\"t\""));
String userName = "TestUser"; }
String userId = UUID.randomUUID().toString();
String sentryId = UUID.randomUUID().toString();
String expectedTitle = String.format("Bug report: %s", modalTitle); @Test
String expectedDescription = "Issue reported from user of bot\n\n" + void nonCreatedStatusThrows() {
"User: " + userName + String.format("[%s]", userId) + "\n\n" + status = 401;
"Last Sentry ID: " + sentryId + "\n\n" + assertThrows(IOException.class, () -> client().createIssue("t", "b"));
"Description from user: \n\n" + modalDescription; }
Issue mockedIssue = new Issue(); @Test
mockedIssue.setTitle(expectedTitle); void malformedJsonThrows() {
mockedIssue.setDescription(expectedDescription); response = "not json";
assertThrows(IOException.class, () -> client().createIssue("t", "b"));
}
when(mockIssuesApi.createIssue(eq(projectId), eq(expectedTitle), eq(expectedDescription))).thenReturn(mockedIssue); @Test
void missingHtmlUrlThrows() {
response = "{}";
assertThrows(IOException.class, () -> client().createIssue("t", "b"));
}
Issue created = mockGitLabApi.getIssuesApi().createIssue(projectId, expectedTitle, expectedDescription); @Test
assertNotNull(created); void pingWorksAndFailsWhenDown() {
assertEquals(expectedTitle, created.getTitle()); assertTrue(client().ping());
assertEquals(expectedDescription, created.getDescription()); server.stop(0);
assertFalse(client().ping());
verify(mockIssuesApi).createIssue(projectId, expectedTitle, expectedDescription);
} }
} }
@@ -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"));
}
}
@@ -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(commands.getCommandContainer(COMMAND_NAME)).thenReturn(container);
when(container.getCommand()).thenReturn(command); when(container.getCommand()).thenReturn(command);
when(command.getCommandName()).thenReturn(COMMAND_NAME); 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(command.execute(context)).thenReturn(result);
when(usageValidator.validate(context, container)).thenReturn(true); 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)));
}
}
@@ -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));
}
}
@@ -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();
}
}
Loaded 100 of 112 files, more files were not shown because too many files have changed in this diff. Show more