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
anthorob a23e3843ed Filter out public roles in guild routes 2026-10-09 13:16:54 -04:00
anthorob 1f126f29ad Switch to SIGTERM for graceful shutdown in startup.sh
Replaced the `curl` HTTP shutdown call with a `kill -TERM` signal to ensure the JVM shutdown hook executes cleanly. Added a `wait` command to block until the process finishes, enabling a more reliable and proper termination process.
2026-10-09 12:51:35 -04:00
anthorob e0ec43813d Add admin status check endpoint to guild routes
Introduced `/guilds/members/{guildId}/{userId}/admin` endpoint to determine if a guild member is an admin. Added `AdminStatusResponse` DTO and documented the new route with OpenAPI annotations. This feature allows for efficient admin status verification with fallback to REST fetch on cache miss.
2026-10-09 12:43:44 -04:00
anthorob 861b3f90e7 Refine VoteSkipService warnings and remove redundant announce calls
Updated the warning message in `VoteSkipService` to provide more precise feedback when attempting to vote on an empty queue. Removed an unnecessary `announce` call in `MusicRoutes`'s `resumePlayback` for cleaner and more concise code execution.
2026-10-09 03:16:09 -04:00
anthorob b7d19b6d56 Modify database URL to create missing databases
Updated the database connection URL to include the `createDatabaseIfNotExist=true` parameter. This ensures the database is automatically created if it does not already exist, improving usability and reducing manual configuration steps.
2026-10-09 02:41:02 -04:00
anthorob 4b006956b5 Add filters to Maven shade plugin configuration
Added filters to exclude specific files and artifacts from the shaded JAR to reduce unnecessary metadata and dependencies. These changes optimize the final build output and ensure a cleaner, more efficient artifact.
2026-10-04 15:18:45 -04:00
anthorob 2293629f6f Add logger configuration for Lavalink client
Introduced a dedicated logger `dev.arbjerg.lavalink` with `info` level and disabled additivity. Updated the log4j2 configuration to log Lavalink client activity to both console and rolling file appenders, ensuring better log separation and monitoring.
2026-10-04 15:13:15 -04:00
anthorob 5b1df7038c fix PermissionPrecondition 2026-10-04 15:05:11 -04:00
anthorob 88389f3db0 Remove redundant announce calls from MusicRoutes 2026-10-03 18:35:30 -04:00
anthorob c2a24f0133 Clear queue on leave operation
Added a call to `getQueueManager().clearQueue()` in the leave method of `MusicManager`. This ensures the queue is properly cleared when the bot disconnects from a guild, improving consistency and preventing leftover queue data.
2026-10-03 18:26:27 -04:00
anthorob db86c133e6 Add new endpoints for queue management and playback control
Introduced `/music/{guildId}/queue` DELETE, `/music/{guildId}/leave`, and `/music/{guildId}/shuffle` endpoints for enhanced queue and playback operations. Added corresponding DTOs (`DeleteQueueResponse`, `LeaveResponse`, `ShuffleResponse`) and updated `MusicRoutes` with OpenAPI documentation. These changes improve queue handling, bot disconnection, and randomization functionality for guilds.
2026-10-03 18:21:10 -04:00
anthorob 44b284fde8 Add GET /music/{guildId}/volume endpoint
Introduced a new endpoint to retrieve the current volume level for a guild's music player. Updated `MusicRoutes` and `MusicManager` with the necessary logic and added OpenAPI documentation for the route. This enhances API completeness by providing both read and write access to the volume setting.
2026-10-03 15:46:34 -04:00
anthorob 9812e6da41 Add DJ status check endpoint to music routes
Introduced `/music/{guildId}/dj/{userId}` endpoint to verify if a user has DJ permissions in a specific guild. Added `DjStatusResponse` DTO, integrated the logic into `MusicManager`, and provided OpenAPI documentation for the new route.
2026-10-03 14:34:32 -04:00
anthorob 30ff1602b2 Add seek and reorder queue functionality to music routes
Introduced `/music/{guildId}/seek` and `/music/{guildId}/queue/reorder` endpoints, enabling users to seek to a specific position in the currently playing track and reorder tracks in the queue. Added corresponding request/response DTOs and OpenAPI documentation. Updated `QueueManager` with a `reorder` method and added comprehensive unit tests for edge cases and validation.
2026-10-03 14:06:29 -04:00
anthorob 470f5a33aa Refactor music commands to return QueueActionResult
Replaced `CommandExecutionResult` with `QueueActionResult` in music-related commands, improving result clarity and alignment with queue-specific operations. Updated command logic, tests, and REST API endpoints to use the new result type, ensuring consistent handling and messaging.
2026-10-03 13:54:04 -04:00
anthorob 9cd1317659 Support bulk queuing with queueAll in /music/{guildId}/queue
Extended the queue endpoint to allow bulk queuing of all search candidates by specifying `queueAll=true`. Updated relevant request and response DTOs, OpenAPI documentation, and endpoint logic to handle bulk queuing while maintaining input validation for individual candidates.
2026-10-03 12:17:37 -04:00
anthorob 27aa0a1d55 Add artwork_url to music track summaries
Extended the music track summary object in `MusicRoutes` to include the `artwork_url` field. This provides front-end clients with direct access to track artwork, improving UI and metadata presentation.
2026-10-03 12:04:55 -04:00
anthorob 715c5a5fb9 Add search_id_ttl_seconds to pending music responses
Enhanced pending response bodies in `MusicRoutes` to include `search_id_ttl_seconds`, indicating the time-to-live for `search_id`. Updated `QueueResponse` DTO and comments to support the new field. Introduced `ttlSeconds` method in `PendingSearchResults` for consistent TTL retrieval across components.
2026-10-03 12:02:14 -04:00
anthorob cd83fcdae6 Handle unavailable audio node in MusicManager
Added a check for null `getLink()` in `MusicManager` before attempting connection to the audio channel. This ensures users receive a clear error message if the audio node is unavailable, improving error handling and user feedback.
2026-10-03 11:51:17 -04:00
anthorob 1dca5b898a Rename PlayRequest/PlayResponse to QueueRequest/QueueResponse
Updated DTOs and endpoint logic to reflect the separation of search result resolution and playback queuing. Adjusted references to `/play` and `/play/select` routes, replacing them with clearer `/search` and `/queue` endpoints in comments, documentation, and API definitions.
2026-10-03 11:23:59 -04:00
anthorob a605a834b4 Refactor REST API music playback flow
Replaced `/play` and `/play/select` endpoints with more streamlined `/search` and `/queue` routes. Updated `MusicManager` and related components to separate query resolution from playback queuing, requiring explicit confirmation for each candidate. Introduced `SearchRequest`, `SearchResultHandler`, and revamped `PlayOutcome` to improve clarity and flexibility.
2026-10-03 11:21:31 -04:00
anthorob 0e68886eb2 Honor explicit search prefixes in SourceFinder
Updated `SourceFinder` to respect explicit `ytsearch:`, `ytmsearch:`, and `scsearch:` prefixes regardless of the configured search engine. Added unit tests to ensure correct behavior and consistent identifier resolution when explicit prefixes are provided.
2026-10-03 09:45:00 -04:00
anthorob d53cbbecd4 Add REST-driven music playback and pause/resume support
Introduced `/music/{guildId}/play` and `/music/{guildId}/play/select/{searchId}` POST routes for initiating and selecting music playback via the REST API. Added `/music/{guildId}/pause` and `/music/{guildId}/resume` POST routes to control playback state. Updated the music system with `RestTrackLoadingHandler`, `PlayOutcome`, `PendingSearchResults`, and DTOs for OpenAPI integration. Enhanced `MusicManager` to support REST-driven play requests with robust error handling and improved track loading logic.
2026-10-02 21:17:36 -04:00
anthorob 6d62a38b0b Add configurable music search engine support
Introduced a configurable `MUSIC_SEARCH_ENGINE` option for determining the default search engine used for non-URL queries (YouTube, YouTube Music, or SoundCloud). Updated `SourceFinder` and related components to support this feature, enhanced query resolution logic, and improved flexibility in music searches. Added `SourceFinderTest` for thorough unit test coverage, ensuring correct behavior across search engines.
2026-10-02 10:52:08 -04:00
anthorob 22621e3912 Add /music/sources endpoint to list playable music sources
Introduced a new GET route `/music/sources` to retrieve supported music source types. Added `MusicSourceResponse` DTO for OpenAPI schema integration and implemented filtering logic to exclude unsupported types. Updated `MusicRoutes` to register the new endpoint.
2026-09-30 20:56:37 -04:00
anthorob 88298380f8 Add logging configuration endpoints for guilds
Introduced `/guilds/config/{guildId}`, `/guilds/debug/{guildId}`, `/guilds/logging/{guildId}`, and `/guilds/log-channel/{guildId}` endpoints to manage guild-level logging settings via API. Updated `GuildService` with methods for handling debug, logging mode, and log channel configuration. Integrated new routes into `GuildRoutes` and updated OpenAPI schemas with corresponding DTOs.
2026-09-30 12:43:45 -04:00
anthorob 0f96051fd0 Add music API endpoints and update OpenAPI responses
Introduced `/music/*` routes for managing music player actions via HTTP, including status, queue, skip, stop, volume control, and vote-skip functionality. Refactored settings and guild routes to use new `Response` DTOs for precise OpenAPI schema definitions. Updated `WebApi` to register music routes and ensure proper integration with the existing `MusicService` logic.
2026-09-30 05:20:35 -04:00
anthorob e9c6f403e5 refactor 2026-09-30 01:33:19 -04:00
anthorob 0145383dfd refactor 2026-09-29 13:07:02 -04:00
anthorob 9c8da6ab7b missing 2026-09-29 12:02:53 -04:00
anthorob 41276c40f6 Add setting types and validation for guild settings
Introduced `SettingType` to categorize guild settings and enforce validation rules for each type (e.g., integer, boolean, percentage, enum). Updated `GSettings` to include type and allowed values, enhanced `/settings/catalog` to expose additional metadata, and added robust validation logic to reject invalid setting updates. Verified backward compatibility and minimal impact on existing settings.
2026-09-29 12:02:36 -04:00
anthorob 57b8e4669b Introduce a pluggable logging system with LogSink interfaces
Added adaptable logging capabilities using `LogSink` implementations, including Log4j, Sentry, and Discord debug channel handlers. Centralized exception handling in `LogHandler`, introducing methods for registering and deregistering custom sinks. Implemented `LoggedException` to encapsulate exceptions with optional context and message, and refactored existing logging logic for modularity, fail-safety, and extensibility.
2026-09-29 12:02:27 -04:00
anthorob 60d7a66b42 Introduce timezone support and configurable settings for music bot
Added support for timezone-specific formatting, leveraging the new `TimeUtils` utility. Enhanced music bot functionality with configurable settings such as idle timeout, vote-skip threshold, DJ role, and more. Refactored configuration commands for modularity and added safe fallbacks for invalid configurations.
2026-09-29 10:41:09 -04:00
anthorob a8b353ea73 Add OpenAPI-based API documentation and new endpoints
Integrated OpenAPI and Swagger plugins for comprehensive API documentation. Added new routes, including settings management (`/settings/*`), guild metadata endpoints (`/guilds/*`), and detailed action status handling. Updated database schema to support guild metadata persistence, revised command subcommand handling for better modularity, and enhanced API key-based authentication for all routes.
2026-09-29 10:32:28 -04:00
anthorob 35fc415d91 Improve argument parsing in MessageCommandContext
Revised argument parsing to handle edge cases like text concatenation, invalid numeric input, and mention handling more robustly. Introduced utility methods for validating argument input and resolving mentions to improve readability and maintainability. Added tests (`UsageValidatorTest`) to verify proper execution of subcommand and argument validation.
2026-09-26 10:47:21 -04:00
anthorob ffc15e8044 Remove outdated Udio plugin references and related code 2026-09-26 02:41:02 -04:00
anthorob 38bbf01e9f update gitignore 2026-09-25 12:54:06 -04:00
anthorob 6abda9196b deprecated api 2026-09-25 12:51:18 -04:00
anthorob bceab4b2b9 Refactor repository layer using Jakarta Data
Replaced custom repository logic with Jakarta Data interfaces to reduce boilerplate and enhance compile-time query validation. Removed `AbstractRepository` and introduced `TransactionRunner` for consistent transactional support. Updated all repository facades to delegate query operations to generated interfaces while retaining the same API, ensuring backward compatibility. Added dependencies for `jakarta.data-api` and Hibernate's `hibernate-processor` for annotation-based query generation. Verified functional parity and improved testability across the repository layer.
2026-09-25 12:48:48 -04:00
anthorob 677b3f8013 Remove JpaUtils and refactor repository access
Eliminated the static `JpaUtils` utility and transitioned to explicit repository management for improved modularity and testability. Updated `GuildService`, `TaskManagerImpl`, and related classes to use dependency-injected repositories. Simplified configuration by aligning repository definitions with existing service patterns and ensuring proper session handling through `SessionManager`. Verified that all affected components function as expected after refactoring.
2026-09-25 12:17:19 -04:00
anthorob 62de32ecfc Migrate from Gson and org.json to Jackson
Replaced Gson and org.json usage across the codebase with a unified Jackson-based implementation through a shared `Json` utility. Updated all JSON serialization and deserialization logic (`GsonJsonMapper`, HTTP client, task persistence, command handling, tests) to use Jackson's `ObjectMapper`. Removed the outdated dependencies from `pom.xml` and verified symmetry in payload serialization.
2026-09-25 08:18:43 -04:00
anthorob 05b4a2ab85 Refactor persistable-task system with self-registering tasks
Replaced metadata-driven reflection with self-describing `PersistableTask` types and a new `PersistableTaskRegistry`. Removed `persistable-tasks.json` and `TaskRegistry`, migrating to type-safe DI instantiation and explicit `restore()` methods. Fixed task restoration issues, ensuring `EmojiReactionRoleTask` and its `emojiMapping` now persist and restore correctly. Introduced `typeId()` for stable task identification, updated payload serialization for symmetry, and applied database migration to map FQCNs to logical IDs. Added unit tests for robust type discovery, payload symmetry, and registry behavior, ensuring no breaking changes.
2026-09-25 07:35:45 -04:00
anthorob 9f2e15a622 Remove NetNeutralityBot placeholder. 2026-09-25 07:00:24 -04:00
anthorob ffb1095f23 Replace System.exit with exceptions for safer shutdown
Refactored various parts of the application to replace calls to `System.exit` with appropriate `IllegalStateException` usage. Improved modularity and testability by delegating error handling to specific exception cases instead of terminating the JVM directly. Updated related components to ensure proper cleanup and graceful shutdown behavior during failures.
2026-09-25 06:46:31 -04:00
anthorob 4277db3922 Refactor shared.util package to utils and update imports
Moved all classes from the `shared.util` package to `utils` for improved consistency and clarity. Updated imports across all affected files to reference the new package structure. This change simplifies the codebase by aligning utility classes under a single, consistent namespace. Verified functionality and ensured no breaking changes.
2026-09-25 06:38:58 -04:00
anthorob 36c4acb13a Upgrade to Javalin 7 and refactor route registration
Updated the dependency to Javalin v7.2.3 and refactored route registration to use the new `RoutesConfig` API. Adjusted `GuildRoutes`, `ActionRoutes`, and `WebApi` to align with updated conventions and centralized route definitions. Improved modularity, reduced redundancy, and ensured backward compatibility by migrating exception and error handler logic. Updated tests to comply with the refactored APIs and structure.
2026-09-25 06:36:00 -04:00
275 changed files with 13607 additions and 5278 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
+7 -3
View File
@@ -5,6 +5,7 @@ NETNEUTRALITY_BOT_PREFIX=$
NETNEUTRALITY_BOT_OWNER= NETNEUTRALITY_BOT_OWNER=
NETNEUTRALITY_BOT_INVITE= NETNEUTRALITY_BOT_INVITE=
NETNEUTRALITY_API_PORT=9000 NETNEUTRALITY_API_PORT=9000
NETNEUTRALITY_API_KEY=
MYSQL_HOST=127.0.0.1 MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306 MYSQL_PORT=3306
@@ -24,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
+1 -1
View File
@@ -33,12 +33,12 @@ hs_err_pid*
.idea/ .idea/
*.iml *.iml
*.iws *.iws
.junie/plans
# Mac # Mac
.DS_Store .DS_Store
# Maven # Maven
log/
target/ target/
#log4j #log4j
-178
View File
@@ -1,178 +0,0 @@
---
sessionId: session-260910-075746-nw8q
---
# Requirements
### Overview & Goals
Refactor `CommandProcessor` to break its tightly coupled `dispatch()` method into composable, extensible pieces so that new preconditions, permission strategies, and result handlers can be added without modifying the processor itself.
### Scope
**In Scope**
- Decompose `CommandProcessor.dispatch()` into a pipeline of single-responsibility components
- Eliminate `instanceof` checks for `InteractionContext` / `SlashCommandContext` inside the processor
- Preserve all existing behavior and keep the existing test suite green
**Out of Scope**
- Changes to the `Command` interface or individual command implementations
- Changes to `CommandContext` subclass hierarchy
- Changes to `CommandContainer`, `CommandRegistry`, or `CommandExecutionResult`
# Technical Design
### Current Implementation
`CommandProcessor.dispatch()` (~80 lines, `CommandProcessor.java:48-129`) performs **five concerns** sequentially:
1. **Command lookup** — resolve command from registry
2. **Precondition checks** — guild-only, slash-only, via `instanceof` checks against `InteractionContext`
3. **Permission check** — `canExecute()` with `instanceof SlashCommandContext`
4. **Usage/argument validation** — `checkUsage()` + `checkOptions()`, again branching on `instanceof InteractionContext`
5. **Execution & result handling** — execute command, handle TASK/ERROR/WARN/NOACTION results, catch exceptions
The `instanceof` checks (lines 66, 197, 220) tightly couple the processor to specific context subtypes, making it impossible to add new context types without modifying the processor.
### Key Decisions
1. **Introduce a `CommandPrecondition` interface** — each check (guild-only, slash-only, permissions) becomes a standalone, testable precondition that can be composed into an ordered list. This replaces the inline if-chains and eliminates instanceof checks from the processor.
2. **Move context-type-specific logic into the context itself** — instead of `instanceof` checks in the processor, add polymorphic methods to `CommandContext` (e.g., `supportsSlashOnly()`, `canSkipPermissionCheck()`, `validateOptions()`). Each context subclass overrides as needed.
3. **Extract result handling into a `ResultHandler`** — the if-chain for TASK/ERROR/WARN/NOACTION becomes a dedicated component, making it easy to add new result types.
4. **Keep changes incremental** — refactor in place without changing the public API of `CommandProcessor` (constructor + `dispatch()` remain the same). The pipeline is internal.
### Proposed Changes
#### 1. `CommandPrecondition` interface
```java
// New file: core/command/precondition/CommandPrecondition.java
public interface CommandPrecondition {
/** Returns null if passed, or an error message to reply with. */
String check(CommandContext context, Command command);
}
```
Concrete implementations:
- `GuildOnlyPrecondition` — replaces lines 60-64
- `SlashCommandOnlyPrecondition` — replaces lines 66-69, uses `context.supportsSlashOnly()` instead of instanceof
- `PermissionPrecondition` — replaces `canExecute()` (lines 215-229), uses `context.canSkipPermissionCheck()` instead of instanceof
#### 2. Polymorphic methods on `CommandContext`
Add to `CommandContext`:
- `boolean isInteraction()` — default `false`, overridden to `true` in `InteractionContext`
- `boolean canSkipPermissionCheck()` — default `false`, overridden in `SlashCommandContext`
This eliminates all `instanceof` checks from the processor.
#### 3. `CommandResultHandler`
```java
// New file: core/command/CommandResultHandler.java
public class CommandResultHandler {
void handle(CommandContext context, CommandExecutionResult result, TaskManager taskManager);
}
```
Encapsulates the TASK/ERROR/WARN/NOACTION/message reply logic from lines 100-120.
#### 4. Refactored `CommandProcessor.dispatch()`
The method becomes:
```java
public void dispatch() {
CommandContainer container = resolveCommand();
if (container == null) return;
Command command = container.getCommand();
context.setCommand(command);
configureSentry(command);
for (CommandPrecondition precondition : preconditions) {
String error = precondition.check(context, command);
if (error != null) { context.replyEphemeral(error); return; }
}
if (!usageValidator.validate(context, container)) return;
try {
CommandExecutionResult result = command.execute(context);
resultHandler.handle(context, result, taskManager);
} catch (...) { /* existing error handling */ }
}
```
### Architecture Diagram
```mermaid
graph LR
D[dispatch] --> R[resolveCommand]
R --> P[Preconditions Loop]
P --> GuildOnly
P --> SlashOnly
P --> Permission
P --> UV[UsageValidator]
UV --> E[command.execute]
E --> RH[ResultHandler]
```
### File Structure
| File | Action |
|------|--------|
| `core/command/precondition/CommandPrecondition.java` | **New** — interface |
| `core/command/precondition/GuildOnlyPrecondition.java` | **New** |
| `core/command/precondition/SlashCommandOnlyPrecondition.java` | **New** |
| `core/command/precondition/PermissionPrecondition.java` | **New** |
| `core/command/CommandResultHandler.java` | **New** |
| `core/command/UsageValidator.java` | **New** — extracted from `checkUsage()`/`checkOptions()` |
| `core/command/CommandProcessor.java` | **Modified** — delegate to above components |
| `core/context/CommandContext.java` | **Modified** — add `isInteraction()`, `canSkipPermissionCheck()` |
| `core/context/InteractionContext.java` | **Modified** — override `isInteraction()` |
| `context/command/SlashCommandContext.java` | **Modified** — override `canSkipPermissionCheck()` |
| `CommandProcessorTest.java` | **Modified** — update construction, add unit tests for new components |
### Risks
- **Behavioral regression** — the refactor must preserve exact reply messages and ordering. Mitigated by the existing 1000+ line test suite.
- **Precondition ordering matters** — guild-only must run before permission check. The list order is explicit and documented.
# Testing
### Validation Approach
- All existing `CommandProcessorTest` tests must pass unchanged (behavior preservation).
- New unit tests for each precondition, the result handler, and the usage validator in isolation.
### Key Scenarios
- Preconditions short-circuit in order (guild-only before permissions)
- Custom precondition can be added to the list and is invoked
- ResultHandler correctly dispatches TASK to TaskManager, logs ERROR/WARN
- UsageValidator handles all subcommand/group/option combinations
### Edge Cases
- Null command from container
- Empty precondition list (all checks pass)
- InteractionContext skips option validation in UsageValidator
# Delivery Steps
### ✓ Step 1: Add polymorphic methods to CommandContext hierarchy
CommandContext subclasses expose their capabilities without requiring instanceof checks.
- Add `isInteraction()` (default `false`) and `canSkipPermissionCheck()` (default `false`) to `CommandContext.java`
- Override `isInteraction()` to return `true` in `InteractionContext.java`
- Override `canSkipPermissionCheck()` to return `true` in `SlashCommandContext.java`
- Verify existing tests still compile and pass
### ✓ Step 2: Extract preconditions into CommandPrecondition interface and implementations
Precondition checks become standalone, composable components.
- Create `CommandPrecondition` interface in `core/command/precondition/`
- Implement `GuildOnlyPrecondition` — checks `command.isGuildOnly()` and `context.isFromGuild()`
- Implement `SlashCommandOnlyPrecondition` — checks `command.isSlashCommandOnly()` and `context.isInteraction()`
- Implement `PermissionPrecondition` — replaces `canExecute()`, uses `context.canSkipPermissionCheck()` instead of instanceof
- Add unit tests for each precondition
### ✓ Step 3: Extract UsageValidator and CommandResultHandler
Usage validation and result handling become dedicated classes.
- Extract `checkUsage()` and `checkOptions()` into `UsageValidator.java`, replacing `instanceof InteractionContext` with `context.isInteraction()`
- Extract result-handling if-chain (TASK/ERROR/WARN/NOACTION + reply) into `CommandResultHandler.java`
- Add unit tests for both classes
### ✓ Step 4: Refactor CommandProcessor to use the new components
CommandProcessor.dispatch() becomes a thin orchestrator delegating to the extracted components.
- Replace inline precondition checks with a `List<CommandPrecondition>` loop
- Replace `checkUsage()`/`checkOptions()` calls with `UsageValidator`
- Replace result-handling if-chain with `CommandResultHandler`
- Remove the now-unused private methods (`canExecute`, `checkUsage`, `checkOptions`)
- Keep constructor backward-compatible or provide a builder/factory for custom precondition lists
- Run full `CommandProcessorTest` suite to confirm behavioral equivalence
-302
View File
@@ -1,302 +0,0 @@
---
sessionId: session-260826-092009-17wb
---
# Requirements
### Overview & Goals
Replace the singleton/static-heavy bootstrap of the bot with an explicit, deterministic **lifecycle-managed application** driven by a small hand-rolled `ApplicationContext` and a `Lifecycle` contract. Dependencies are wired through **constructors** (including for reflectively-loaded commands and tasks), and **interfaces** are introduced for the key services so the code becomes testable, maintainable, and mockable — without adopting Spring Boot or any DI framework.
The current design (documented in `docs/architecture.md`) uses:
- `NetNeutrality` as a static singleton holding `instance` and `jda`, with a manual, order-sensitive `startup()`/`shutdown()` chain.
- Static singletons everywhere: `Settings.getInstance()`, `SessionManager` (static `getSessionFactory`/`shutdown`), `CommandManager` (static `init`/maps), `TaskManager.getInstance()`, `MusicManager` (static factory + static `playerManager`), `ThreadManager` (static executors), `ScheduledTaskManager` (static), `GuildService.get(...)` (static cache).
- `ReflectionHelper` instantiating commands/tasks via **no-arg constructors**, which forces them to reach out to singletons.
### Scope
**In Scope**
- A `Lifecycle` interface (`start()`/`stop()`/`priority()`) and an `ApplicationContext` that owns instances, wires them, and drives ordered startup/shutdown.
- Converting the core singletons to constructor-injected **instances**.
- Introducing **interfaces for key services** (settings, persistence/session, executors, command registry, task manager, music service, guild-service provider) — decision confirmed with the user.
- Reworking `ReflectionHelper` + command/task base classes to use **constructor injection** (decision confirmed).
- Symmetric, deterministic startup/shutdown replacing `NetNeutrality.startup()/shutdown()` and the JVM shutdown hook.
- **Reorganizing the package layout** into `bootstrap` / `framework` / `platform` / `feature` / `data` / `shared` (see the Package Structure tab), done in the same effort as the DI refactor so imports are touched once. This includes renaming the entry point `NetNeutrality` → `App`.
**Out of Scope**
- Introducing Spring Boot, Guice, or Dagger (explicitly rejected — manual context chosen).
- Full interface-per-service/repository abstraction (only key services get interfaces).
- Dropping the `O*`/`G*` entity name prefixes (optional cosmetic churn, deferred).
- Behavioral/feature changes to commands, tasks, music, or the web API — this is a structural refactor only.
- Database schema / Flyway migration changes.
### User Stories
- As a maintainer, I want services created and wired in one explicit place so the dependency graph and startup order are obvious and not implicit.
- As a maintainer, I want symmetric `start()`/`stop()` for every component so shutdown reliably tears down what startup created, in reverse order.
- As a developer, I want commands/tasks to receive their dependencies via constructor so they no longer call `getInstance()` and can be unit-tested with mocks.
- As a developer, I want key services behind interfaces so I can mock them in tests (building on the existing JUnit 5 + Mockito setup).
### Functional Requirements
- The bot must start and shut down with identical externally-observable behavior to today (same ordered steps from `docs/architecture.md`).
- Startup order must be preserved: persistence/session → migrations → executors → command/task registries → music sources → JDA → web API → presence.
- Shutdown must run in reverse of startup: cancel scheduled tasks → disconnect music players → shut down executors → shut down JDA → release the Hibernate registry.
- Commands and tasks discovered by `ReflectionHelper` must continue to auto-load ("drop a class in the package" model) but now receive dependencies via constructor.
- No public command/task behavior changes.
### Non-Functional Requirements
- **No new runtime dependencies** (manual container only).
- **Incremental & low-risk**: migrate one area at a time using temporary static shims that delegate to the context, so the build stays green throughout.
- **Compatibility**: fat-JAR packaging and `main` entry point (`me.bobert.netneutrality.NetNeutrality`) remain unchanged.
- **Testability**: instance + interface-based services must be mockable with Mockito.
# Technical Design
### Current Implementation
- **Bootstrap** — `NetNeutrality` (`src/main/java/me/bobert/netneutrality/NetNeutrality.java`): static `instance`, static `jda`, `main()` builds `Settings` implicitly, constructor calls `startup()` which chains `SessionManager.getSessionFactory()` → `DatabaseMigration.migrate()` → `CommandManager.init()` → `MusicManager.initPlayerManager()` → `TaskManager.getInstance().init()` → `initJDA()` → `CommandManager.registerSlashCommands(jda)` → `new EventsHandler()` → `new WebApi(port)` → presence → shutdown hook. `shutdown()` runs `ScheduledTaskManager.cancelAll()` → `MusicManager.disconnectPlayers()` → `ThreadManager.shutdownExecutor()` → `disconnect()` → `SessionManager.shutdown()`.
- **Singletons/statics**: `Settings` (`utils/Settings.java`), `SessionManager` (`core/db/SessionManager.java`, all-static), `CommandManager` (`core/command/CommandManager.java`, static `commands` map + static `init`), `TaskManager` (`core/tasks/TaskManager.java`, `getInstance()`), `MusicManager` (`music/MusicManager.java`, static `managers` map + static `playerManager` + static `get`/`initPlayerManager`/`disconnectPlayers`), `ThreadManager` (`core/thread/ThreadManager.java`, static executors), `ScheduledTaskManager`, `GuildService` (`db/service/GuildService.java`, static `services` cache).
- **Reflective instantiation** — `ReflectionHelper.loadClassesFromPackage(...)` calls `s.getConstructor().newInstance()` (no-arg), used by `CommandManager.initCommands()` and `TaskManager.loadBuiltInTasks()/loadScheduledTasks()`.
- **Consumers of singletons** — ~110 call sites across commands (`command/**`), tasks (`tasks/**`), contexts (`context/**`, `core/context/**`), HTTP handlers (`http/server/**`), `EventsHandler`, repositories/services (`db/**`), etc.
### Key Decisions (confirmed with user)
1. **Manual `ApplicationContext` + `Lifecycle`** — no DI framework; the context owns instances, wires them via constructors, and drives ordered `start()`/`stop()`.
2. **Constructor injection for commands/tasks** — `ReflectionHelper` resolves a constructor that accepts the context (or specific dependencies) and passes them in; commands/tasks store deps as final fields instead of calling `getInstance()`.
3. **Lifecycle + key service interfaces** — introduce a `Lifecycle` interface plus interfaces for the main services (settings, persistence/session, executors, command registry, task manager, music service, guild-service provider). Repositories and minor helpers stay concrete.
4. **Package restructure to `bootstrap`/`framework`/`platform`/`feature`/`data`/`shared`** — separate code by reason-of-change (engine vs. cross-cutting infra vs. concrete features vs. persistence model vs. leaf helpers), consolidate each feature vertical (e.g. music) into one package, and dissolve the catch-all `utils`. Performed right after the DI wiring, via the IDE's safe "Move Class" refactor, so the ~110 import updates happen once. Full details in the Package Structure tab.
### Proposed Changes
**1. Lifecycle contract** — new `me.bobert.netneutrality.bootstrap.Lifecycle`:
```java
public interface Lifecycle {
void start() throws Exception;
default void stop() {}
default int priority() { return 0; } // lower = started earlier, stopped later
}
```
**2. Application context** — new `me.bobert.netneutrality.bootstrap.ApplicationContext`:
```java
public final class ApplicationContext {
private final List<Lifecycle> components = new ArrayList<>();
public <T> T register(T component) {
if (component instanceof Lifecycle l) components.add(l);
return component;
}
public void start() throws Exception {
components.sort(Comparator.comparingInt(Lifecycle::priority));
for (Lifecycle c : components) c.start();
}
public void stop() {
var reversed = new ArrayList<>(components);
Collections.reverse(reversed);
reversed.forEach(Lifecycle::stop);
}
}
```
The context also exposes typed getters for wired services so `ReflectionHelper`/`EventsHandler`/contexts can resolve dependencies.
**3. Convert singletons to instances behind interfaces** (priority values in parentheses set start order):
- `SettingsProvider` ← `Settings` (config; constructed first, not itself a `Lifecycle`).
- `SessionProvider`/persistence ← `SessionManager` instance implementing `Lifecycle` (10): `start()` builds the `SessionFactory` + runs `DatabaseMigration.migrate()`; `stop()` destroys the registry.
- `ExecutorProvider` ← `ThreadManager` instance implementing `Lifecycle` (20): `stop()` = current `shutdownExecutor()`.
- `CommandRegistry` ← `CommandManager` instance implementing `Lifecycle` (30): `start()` = `init()`; `registerSlashCommands(jda)` called after JDA is ready.
- `TaskManager` interface ← impl implementing `Lifecycle` (30): `start()` = current `init()`.
- Scheduled tasks ← `ScheduledTaskManager` instance implementing `Lifecycle`: `stop()` = `cancelAll()`.
- `MusicService` ← new factory owning the shared `AudioPlayerManager` + per-guild `MusicManager` map, implementing `Lifecycle` (40): `start()` = `initPlayerManager()`; `stop()` = `disconnectPlayers()`. Per-guild `MusicManager` becomes a plain instance created by the factory.
- `GuildServiceProvider` ← registry owning the guild cache (replaces static `GuildService.get`).
- `WebApi` becomes a `Lifecycle` (60): `start()` binds the HTTP server; `stop()` stops it.
- JDA is built by the context (replacing static `jda`) and exposed via a getter/provider.
**4. Rework reflective instantiation** — extend `ReflectionHelper` so `loadClassesFromPackage` accepts a factory/`ApplicationContext` and instantiates via a constructor that takes the needed dependencies (falling back to no-arg for classes that declare it). `CommandManager.initCommands()` and `TaskManager.loadBuiltInTasks()/loadScheduledTasks()` pass the context so commands/tasks are constructor-injected.
**5. Migrate consumers off `getInstance()`** — command/task base classes (`core/tasks/impl/AbstractTask.java`, `AbstractStepTask`, `AbstractPersistableTask`, `core/command/impl/MusicCommand.java`) and contexts (`core/context/AbstractContext.java`, `context/**`) take dependencies via constructor. `EventsHandler` is constructed with the context and passes services when building contexts. During migration, keep **temporary static shims** (e.g. `Settings.getInstance()` delegating to the context instance) so untouched call sites keep compiling; remove them at the end.
**6. New bootstrap** — `NetNeutrality.main()` builds the `ApplicationContext`, constructs and `register()`s services in dependency order, calls `context.start()`, and installs **one** shutdown hook calling `context.stop()`. Static `instance`/`jda` are removed.
### Data Models / Contracts
Key new interfaces (illustrative signatures):
```java
interface SettingsProvider { String get(String key); String get(String key, String def); String getPrefix(); }
interface SessionProvider extends Lifecycle { org.hibernate.SessionFactory getSessionFactory(); }
interface ExecutorProvider extends Lifecycle { java.util.concurrent.ExecutorService getExecutor(String name); java.util.concurrent.ScheduledExecutorService getScheduledExecutor(); }
interface CommandRegistry extends Lifecycle { CommandContainer getCommandContainer(String name); void registerSlashCommands(JDA jda); }
interface TaskManager extends Lifecycle { Task getTask(String id); void insert(CommandContext ctx, Task task); void handle(TaskContext ctx); /* ... */ }
interface MusicService extends Lifecycle { MusicManager get(net.dv8tion.jda.api.entities.Guild guild); }
interface GuildServiceProvider { GuildService get(String guildId); }
```
### File Structure
New DI classes are created directly in their **target** packages (from the Package Structure tab) so they are not moved twice.
- **Added**: `bootstrap/Lifecycle.java`, `bootstrap/ApplicationContext.java`; service interfaces `platform/config/SettingsProvider.java`, `platform/persistence/SessionProvider.java`, `platform/concurrency/ExecutorProvider.java`, `framework/command/CommandRegistry.java`, `framework/task/TaskManager.java` [interface] + impl, `feature/music/MusicService.java`, `data/service/GuildServiceProvider.java`.
- **Moved / renamed (package restructure)**: `core` → `framework` (engine only); `core.db` + `db` → `platform.persistence` + `data`; `core.thread` → `platform.concurrency`; `hooks` → `platform.event`; `http` → `platform.web`; `utils` dissolved into `platform.config` / `platform.persistence` / `bootstrap` / `shared.util`; `NetNeutrality` → `App`. See the Package Structure tab.
- **Modified**: `App.java` (bootstrap), `ReflectionHelper.java` (constructor injection), all converted managers/services, command/task base classes, `EventsHandler.java`, `WebApi.java`, HTTP handlers, `getInstance()` call sites, and `pom.xml` / `Dockerfile` / `startup.sh` for the `mainClass` rename.
- **Docs**: update `docs/architecture.md` (startup/shutdown + package map) and `docs/utilities-and-bootstrap.md` (bootstrap) to reflect the context and new layout.
### Architecture Diagram
```mermaid
graph TD
Main["NetNeutrality.main()"] --> Ctx[ApplicationContext]
Ctx -->|constructs & wires| Settings[SettingsProvider]
Ctx -->|register + start/stop| Session[SessionProvider]
Ctx -->|register + start/stop| Exec[ExecutorProvider]
Ctx -->|register + start/stop| Cmd[CommandRegistry]
Ctx -->|register + start/stop| Tasks[TaskManager]
Ctx -->|register + start/stop| Music[MusicService]
Ctx -->|register + start/stop| Web[WebApi]
Ctx -->|builds| JDA[JDA]
Cmd -->|constructor-injects| Refl[ReflectionHelper]
Tasks -->|constructor-injects| Refl
Refl -->|ctor deps| Impl[Commands / Tasks]
JDA -->|events| Events[EventsHandler]
Events -->|builds contexts with deps| Cmd
Events --> Tasks
```
### Risks
- **Large call-site surface (~110 usages)** — mitigated by temporary static shims delegating to context instances, migrating area-by-area, keeping the build green.
- **Startup ordering regressions** — mitigated by encoding order via `Lifecycle.priority()` and mirroring the exact sequence from `docs/architecture.md`.
- **Reflective constructor resolution** — commands/tasks must expose a consistent injectable constructor; mitigate by supporting a no-arg fallback and validating all classes load at startup.
- **Per-guild `MusicManager` vs shared factory** — clarify that the shared `AudioPlayerManager` and cache move to the `MusicService`, while per-guild `MusicManager` stays a lightweight instance.
- **`SessionManager.System.exit(1)` on DB failure** — preserve fail-fast behavior inside the new `SessionProvider.start()`.
# Package Structure
### Current Layout Problems
The tree today mixes three organizing principles at once: abstraction-vs-impl (`core.command`↔`command`, `core.context`↔`context`, `core.tasks`↔`tasks`), infra-vs-data (`core.db`↔`db`), and no split at all (`music`, `http`, `utils`, `hooks`). Consequences:
- `core` is simultaneously "the engine" and "misc" — it holds the framework sub-packages **and** loose classes (`LogHandler`, `BotState`).
- A single feature is scattered: music lives in `core.command.impl.MusicCommand`, `command.music`, `context.MusicChannelContext`, `tasks.music`, and `music/` — five places.
- `utils` is a catch-all mixing config (`Settings`), reflection (`ReflectionHelper`), JPA glue (`JpaUtils`), and formatting (`MessageFunction`, `UserFunction`).
- Naming drift: `db.entities` (plural) vs. the singular convention elsewhere.
### Guiding Principle
Separate code by the reason it changes:
- **framework** — the reusable bot engine (command/task/context machinery); rarely changes, knows nothing about specific commands.
- **platform** — cross-cutting technical services (persistence, concurrency, config, logging, event dispatch, web); changes when tech/tooling changes.
- **feature** — concrete commands + tasks + contexts + domain logic, grouped **by feature vertical**; changes most often.
- **data** — the persistence model (entities, repositories, services).
- **shared** — genuinely cross-feature leaf helpers.
### Target Layout
```
me.bobert.netneutrality
├── App.java // was NetNeutrality (main + bootstrap entry)
├── bootstrap/ // ApplicationContext, Lifecycle, ReflectionHelper (composition root)
├── framework/ // the reusable engine (was core, minus the misc)
│ ├── command/ // Command, CommandProcessor, CommandRegistry, options, build, impl bases
│ ├── context/ // Context hierarchy, reply/
│ └── task/ // Task, TaskManager, StepTask/PersistableTask, persistence/, trait/, impl bases
├── platform/ // cross-cutting technical services (horizontals)
│ ├── config/ // Settings / SettingsProvider
│ ├── persistence/ // SessionProvider, DatabaseMigration, Repository base, JpaUtils
│ ├── concurrency/ // ExecutorProvider, ScheduledTaskManager, BaseThreadFactory
│ ├── logging/ // LogHandler
│ ├── event/ // EventsHandler (was hooks)
│ └── web/ // WebApi, handlers, WebModule (was http)
├── feature/ // concrete features, one vertical per package
│ ├── music/ // MusicService/MusicManager, QueueManager, scheduler, source/, + music commands/tasks/contexts
│ ├── moderation/ // blacklist, purge, admin commands + related services
│ ├── reaction/ // reaction commands + emoji-role tasks
│ ├── git/ // report-issue command + task
│ ├── crypto/
│ └── help/
├── data/ // the persistence model (was the "db" half of the split)
│ ├── entity/ // O* entities (entities → entity, singular)
│ ├── repository/
│ └── service/ // GuildService, GuildServiceProvider, BlacklistService, …
├── shared/util/ // MiscUtils, MessageFunction, UserFunction, GuildUtils, ContextUtils
└── exceptions/ // unchanged (already coherent)
```
### Key Moves
- `core` → `framework`, now containing **only** the engine (no `LogHandler`, no `BotState`, no `db`, no `thread`).
- `core.db` (infra) → `platform.persistence`; `db` (model) → `data` — resolves the "`db` means two things" problem. `db.entities` → `data.entity`.
- `core.thread` → `platform.concurrency`; `hooks` → `platform.event`; `http` → `platform.web`.
- `utils` dissolved: config → `platform.config`, JPA glue → `platform.persistence`, reflection → `bootstrap`, formatting/misc → `shared.util`.
- All music code consolidates under `feature.music`; the other verticals group their command/task/context/service triplets similarly.
- `LogHandler` → `platform.logging`; `BotState` → `bootstrap` (composition-root-adjacent state).
### Sequencing & Rationale
The repackage is done **immediately after the DI wiring, in the same effort**: the DI refactor already renames managers (`CommandManager`→`CommandRegistry`, `SessionManager`→`SessionProvider`, `ThreadManager`→`ExecutorProvider`) and touches imports at ~110 call sites, so combining them avoids editing the same imports twice. Moves use the IDE's safe **"Move Class"** refactor (references update automatically), one subsystem at a time, building after each. New DI classes are created directly in their target packages rather than moved later.
### Lower-Risk Variant (optional fallback)
If full feature-first grouping feels too large in one pass, a high-value subset fixes ~80% of the confusion with far fewer moves: rename `core` → `framework`, extract `platform` (evict `LogHandler`, `BotState`, `core.db`, `core.thread`, `hooks`, `http`), and dissolve `utils` — while leaving the concrete `command`/`task`/`context` packages where they are. The trade-off is that a single feature stays spread across those layer packages.
### Risks
- **Move touches nearly every import** — mitigated by IDE "Move Class" (auto-updates references), one subsystem per step, and a build after each move.
- **Main-class rename `NetNeutrality` → `App`** — must update the Shade `mainClass` in `pom.xml`, the `Dockerfile`/`startup.sh` entry, and docs atomically, or the fat JAR won't launch.
- **Feature-first churn** — grouping music/moderation/etc. is the largest move; the lower-risk variant above is the fallback if needed.
# Testing
### Validation Approach
The refactor is behavior-preserving, so validation focuses on: (a) the project compiles and the fat JAR builds after each stage, (b) existing tests still pass, and (c) new unit tests prove services are now mockable/instance-based. Use the existing Maven + JUnit 5 + Mockito setup (`./mvnw test`); Checkstyle runs in the `test` phase and must stay green.
### Key Scenarios
- **Build & Checkstyle** — `./mvnw package` succeeds after every stage (shade fat JAR, main class unchanged).
- **ApplicationContext ordering** — a unit test registers fake `Lifecycle` components with different `priority()` values and asserts `start()` runs ascending and `stop()` runs descending.
- **Command/task loading** — assert `CommandManager`/`TaskManager` still discover and instantiate all implementations via `ReflectionHelper` with constructor injection (no `getInstance()` calls at runtime).
- **Existing tests** — `CommandProcessorTest`, `SettingsTest`, and any others continue to pass; update them to use injected instances/mocks instead of wrestling with singletons.
### Edge Cases
- Missing `NETNEUTRALITY_BOT_TOKEN` → startup still fails fast with a clear error.
- DB connection failure inside `SessionProvider.start()` → preserves current fail-fast (Sentry capture in non-dev + exit).
- A command/task class that only declares a no-arg constructor → `ReflectionHelper` still loads it via the fallback path.
- Shutdown with services that never fully started → `stop()` must be null-safe (mirroring current guards like `jda != null`, `registry != null`).
### Test Changes
- **Add**: `ApplicationContextTest` (ordering + symmetric start/stop), a `ReflectionHelper` constructor-injection test.
- **Update**: `SettingsTest` and `CommandProcessorTest` to construct instances/mocks of the new interfaces instead of relying on static singletons.
- **Skip**: no integration test against a live Discord gateway or real MySQL (out of scope).
# Delivery Steps
### ✓ Step 1: Introduce Lifecycle contract and ApplicationContext bootstrap
The app boots through an `ApplicationContext` that drives ordered start/stop, while behavior stays identical via temporary shims.
- Add `me.bobert.netneutrality.bootstrap.Lifecycle` interface with `start()`, `stop()` (default no-op), and `priority()` (default 0, lower = started earlier / stopped later).
- Add `me.bobert.netneutrality.bootstrap.ApplicationContext` that registers components, sorts by `priority()`, starts ascending and stops descending, and exposes typed getters for wired services.
- Refactor `NetNeutrality.main()`/`startup()` to build the context and register the existing (still static) steps as thin `Lifecycle` adapters, install a single shutdown hook calling `context.stop()`, and remove static `instance`/`jda` (temporarily bridged where needed).
- Add `ApplicationContextTest` asserting ascending start and descending stop ordering.
### ✓ Step 2: Convert foundational services to instances behind interfaces
Configuration, persistence, and executors are constructor-injected instances behind interfaces, wired into the context with correct lifecycle order.
- Introduce `SettingsProvider` implemented by `Settings` (instance, constructed first); keep `Settings.getInstance()` as a temporary shim delegating to the context instance.
- Convert `core/db/SessionManager` into a `SessionProvider` instance implementing `Lifecycle` (priority ~10): `start()` builds the `SessionFactory` and runs `DatabaseMigration.migrate()`, `stop()` destroys the registry; preserve fail-fast on DB errors.
- Convert `core/thread/ThreadManager` into an `ExecutorProvider` instance implementing `Lifecycle` (priority ~20) with `stop()` = current `shutdownExecutor()`.
- Update `JpaUtils`/`Repository`/`db/**` call sites andhihims to use the injected providers.
### ✓ Step 3: Convert domain managers (command, task, scheduled, music, guild) to instances behind interfaces
The command/task/music/guild managers are instance-based `Lifecycle` components behind interfaces, registered in the context.
- Introduce `CommandRegistry` (impl = `CommandManager`, priority ~30): `start()` = `init()`; `registerSlashCommands(jda)` invoked after JDA is ready.
- Introduce a `TaskManager` interface + impl (priority ~30) implementing `Lifecycle` with `start()` = current `init()`; convert `ScheduledTaskManager` to an instance whose `stop()` = `cancelAll()`.
- Introduce `MusicService` factory owning the shared `AudioPlayerManager` and per-guild `MusicManager` map (priority ~40): `start()` = `initPlayerManager()`, `stop()` = `disconnectPlayers()`; make per-guild `MusicManager` a plain instance created by the factory.
- Introduce `GuildServiceProvider` owning the guild cache, replacing static `GuildService.get(...)`.
- Keep static shims on each so untouched call sites keep compiling.
### ✓ Step 4: Enable constructor injection for reflectively-loaded commands and tasks
Commands and tasks receive their dependencies via constructor instead of calling `getInstance()`.
- Extend `ReflectionHelper.loadClassesFromPackage` to accept the `ApplicationContext` and instantiate via a constructor taking the required dependencies, with a no-arg fallback for classes that declare it.
- Update `CommandManager.initCommands()` and `TaskManager.loadBuiltInTasks()/loadScheduledTasks()` to pass the context.
- Update command/task base classes (`core/tasks/impl/AbstractTask`, `AbstractStepTask`, `AbstractPersistableTask`, `core/command/impl/MusicCommand`) and contexts (`core/context/AbstractContext`, `context/**`, `EventsHandler`) to store injected dependencies as final fields.
- Add a `ReflectionHelper` test proving constructor injection and no-arg fallback both work.
### ✓ Step 5: Remove static shims, finalize interfaces, and update tests/docs
All singletons/`getInstance()` accessors are gone; services are wired only through the context, and tests/docs reflect the new model.
- Migrate remaining `getInstance()`/static call sites (commands, tasks, HTTP handlers in `http/server/**`, `LogHandler`, `BotState`) to injected interfaces and delete the temporary static shims and singleton fields.
- Convert `WebApi` into a `Lifecycle` component (priority ~60) with `start()`/`stop()` for the HTTP server.
- Update `SettingsTest` and `CommandProcessorTest` to use injected instances/mocks of the new interfaces; ensure `./mvnw package` (including Checkstyle) is green.
- Update `docs/architecture.md` (startup/shutdown lifecycle) and `docs/utilities-and-bootstrap.md` (bootstrap) to describe the `ApplicationContext` + `Lifecycle` design.
### ✓ Step 6: Reorganize packages into bootstrap/framework/platform/feature/data/shared
The codebase is regrouped by reason-of-change into the target layout (see the Package Structure tab), with all references updated and the build green.
- Rename the entry point `NetNeutrality` → `App`, and update the Shade `mainClass` in `pom.xml`, plus the `Dockerfile`/`startup.sh` launch entry.
- Using the IDE's safe "Move Class" refactor, one subsystem per step: `core` → `framework` (engine only); `core.db` + `db` → `platform.persistence` + `data` (`db.entities` → `data.entity`); `core.thread` → `platform.concurrency`; `hooks` → `platform.event`; `http` → `platform.web`.
- Dissolve `utils`: `Settings`/`SettingsProvider` → `platform.config`, `JpaUtils` → `platform.persistence`, `ReflectionHelper` → `bootstrap`, formatting/misc helpers → `shared.util`; move `LogHandler` → `platform.logging` and `BotState` → `bootstrap`.
- Consolidate each feature vertical (music, moderation, reaction, git, crypto, help) into `feature.*`, pulling its commands/tasks/contexts/services together.
- Build and run tests after each subsystem move; update `docs/architecture.md` "Package map" to the new layout.
-95
View File
@@ -1,95 +0,0 @@
---
sessionId: session-260905-173932-36te
---
# Requirements
### Overview & Goals
Improve the `MusicManager` class for better maintainability, testability, and separation of concerns.
### Scope
**In Scope:**
- Refactor `MusicManager` to reduce responsibilities
- Extract vote-skip logic into a dedicated service
- Fix synchronization strategy
- Improve error handling
- Eliminate magic numbers
**Out of Scope:**
- Changing `QueueManager` or `TrackScheduler` internals (beyond reducing circular refs)
- Changing external behavior or command interfaces
# Technical Design
### Current Issues
1. **Vote-skip business logic in MusicManager** (lines 113–145): The `voteSkip()` method contains complex voting logic (threshold calculation, member counting, skip decisions) that doesn't belong in a player manager. Magic number `2.1` is used as a vote divisor with no explanation.
2. **Circular dependencies**: `MusicManager` → `TrackScheduler` → `QueueManager` → `TrackScheduler` → `MusicManager`. This makes testing and reasoning about the code difficult.
3. **Overly broad `synchronized` methods**: Nearly every public method is `synchronized` on `this`, even simple getters like `getCurrentlyPlaying()` and `isCurrentlyPlaying()`. This creates unnecessary contention. Methods like `getQueueSize()` aren't synchronized at all, creating inconsistency.
4. **Poor error handling in `connect()`** (line 80): Throws a bare `RuntimeException` when `audioChannel` is null instead of using `IllegalArgumentException` or returning a `CommandExecutionResult`.
5. **`baseVolume` parsed from String without error handling** (line 44): `Integer.parseInt()` can throw `NumberFormatException` with no fallback.
### Proposed Changes
#### 1. Extract `VoteSkipService`
- New class: `me.bobert.netneutrality.music.VoteSkipService`
- Moves all vote-skip logic out of `MusicManager`
- Replace magic `2.1` with a named constant `VOTE_THRESHOLD_DIVISOR` or configurable guild setting
- `MusicManager.voteSkip()` delegates to the new service
#### 2. Reduce circular dependencies
- `TrackScheduler` and `QueueManager` should not need to call back into `MusicManager` for `disconnect()`. Instead, use a callback/listener interface (e.g., `MusicManagerCallback`) that `TrackScheduler` holds, breaking the direct circular reference.
#### 3. Fix synchronization
- Remove `synchronized` from simple delegating methods (`getCurrentlyPlaying()`, `isCurrentlyPlaying()`, `resume()`, `stopTrack()`) — `AudioPlayer` is already thread-safe.
- Keep `synchronized` only on methods that coordinate multiple state changes (`connect()`, `disconnect()`, `play()`).
- Make `getQueueSize()` consistent with the chosen strategy.
#### 4. Improve error handling
- `connect()`: Replace `RuntimeException` with `IllegalArgumentException("audioChannel must not be null")`
- `baseVolume`: Add try-catch with a sensible default (e.g., 50) and a warning log.
#### 5. Extract constants
- `VOTE_THRESHOLD_DIVISOR = 2.1` (or make configurable)
- `MIN_MEMBERS_FOR_VOTE = 3`
- `DEFAULT_VOLUME = 50`
### File Structure
| File | Action |
|------|--------|
| `music/MusicManager.java` | Modify — remove vote logic, fix sync, fix error handling |
| `music/VoteSkipService.java` | **New** — extracted vote-skip logic |
| `music/MusicManagerCallback.java` | **New** — interface to break circular dependency |
| `music/TrackScheduler.java` | Modify — use callback interface instead of direct `MusicManager` reference where possible |
| `music/QueueManager.java` | Modify — remove `getMusicManager()` call chain for disconnect |
# Delivery Steps
### ✓ Step 1: Extract VoteSkipService and constants
Vote-skip logic is isolated in a new service class and magic numbers are replaced with named constants.
- Create `VoteSkipService.java` in `me.bobert.netneutrality.music`
- Move the vote-skip logic from `MusicManager.voteSkip()` into `VoteSkipService.voteSkip(CommandContext, QueueManager, AudioChannel)`
- Define constants: `VOTE_THRESHOLD_DIVISOR`, `MIN_MEMBERS_FOR_VOTE`
- Update `MusicManager.voteSkip()` to delegate to the new service
- Add `DEFAULT_VOLUME` constant and wrap `baseVolume` parsing in try-catch with fallback
### ✓ Step 2: Fix synchronization and error handling
Synchronization is applied only where needed and error handling is improved.
- Remove `synchronized` from simple delegating methods: `getCurrentlyPlaying()`, `isCurrentlyPlaying()`, `resume()`, `stopTrack()`, `setVolume()`
- Keep `synchronized` on `connect()`, `disconnect()`, `play()`, `isNotConnectedTo()`
- Replace `RuntimeException` in `connect()` with `IllegalArgumentException`
- Ensure `getQueueSize()` follows the same synchronization strategy as other methods
### ✓ Step 3: Reduce circular dependencies between MusicManager, TrackScheduler, and QueueManager
The circular reference chain is broken using a callback interface.
- Create `MusicManagerCallback.java` interface with method `void onQueueEmpty()` (or similar) to replace direct `MusicManager.disconnect()` calls from `QueueManager`
- Have `MusicManager` implement the callback interface
- Update `TrackScheduler` constructor to accept the callback interface instead of the full `MusicManager` where only disconnect/connection behavior is needed
- Update `QueueManager.clearQueue()` to use the callback instead of `scheduler.getMusicManager().disconnect()`
-213
View File
@@ -1,213 +0,0 @@
---
sessionId: session-260919-132150-1vmv
---
# Requirements
### Overview & Goals
The `TaskManager` subsystem in Net Neutrality manages the in-memory registry, execution dispatch, locking, persistence, and lifecycle transitions for Discord interaction tasks (buttons, modals, reactions, step-by-step flows, and scheduled background tasks).
This plan addresses several bugs, synchronization oversights, memory leak potentials, and architectural discrepancies between `docs/tasks.md` and `TaskManagerImpl.java`:
1. **Fix Parent-Child Lifecycle Propagation**: Correctly handle `REMOVE_SELF_AND_CHILDREN` when triggered by child tasks (`WithParent`).
2. **Synchronize DB Persistence on Removal**: Ensure `PersistableTask` entries are purged from the database via `TaskService.unpersistTask()` when removed.
3. **Prevent Memory Leaks in Pending Tasks**: Unify `pendingTasks` tracking and ensure `TaskCleanup` cleans up expired pending step tasks.
4. **Fix Concurrency & Lock Cleanup Hazards**: Eliminate race conditions where lock eviction during task execution allows duplicate concurrent runs.
5. **Optimize Lookup Performance**: Replace eager full-collection queries with short-circuiting streams for partial ID lookups.
6. **Improve Test Coverage**: Introduce thorough unit tests covering all task lifecycle paths, locking behavior, and persistence hooks.
### Scope
- **In Scope**:
- `me.bobert.netneutrality.core.tasks.TaskManager` interface.
- `me.bobert.netneutrality.core.tasks.TaskManagerImpl` implementation.
- `me.bobert.netneutrality.scheduled.cleanup.TaskCleanup` expiration handling.
- Interaction deletion error-handling callbacks on `InteractionHook`.
- Comprehensive unit test suite for `TaskManagerImpl`.
- **Out of Scope**:
- Refactoring concrete task business logic (e.g., individual music or reaction commands).
- Schema changes to the `OTask` entity or database migrations.
### Functional Requirements
- **FR-1**: When any child task implementing `WithParent` returns `Lifecycle.REMOVE_SELF_AND_CHILDREN`, `TaskManager` must remove the parent task and all associated child tasks from the registry.
- **FR-2**: When any `PersistableTask` is removed from `TaskManager` (via lifecycle completion, manual removal, or cancellation), `TaskManager` must call `taskService.unpersistTask()` so the task is deleted from persistent storage.
- **FR-3**: Pending step tasks must be tracked reliably, exposed for expiration checks, and cleaned up by `TaskCleanup` if abandoned.
- **FR-4**: Concurrent task invocations sharing the same `lockId` must be serialized safely without lock collisions or premature lock disposal.
- **FR-5**: `findTaskByPartialId` must short-circuit upon finding the first match without allocating full intermediate lists.
- **FR-6**: Interaction deletion via `Deletable` must gracefully handle expired hooks and Discord API errors asynchronously.
# Technical Design
### Current Implementation
`TaskManagerImpl` (`me.bobert.netneutrality.core.tasks.TaskManagerImpl`) implements `TaskManager` and `Lifecycle`. It maintains:
- `tasks`: `ConcurrentHashMap<String, Task>` for active tasks.
- `pendingTasks`: `CopyOnWriteArrayList<Task>` for unassigned step tasks.
- `deletableTasksContext`: `ConcurrentHashMap<String, CommandContext>` for tracking interaction hooks to delete.
- `tasksLock`: `ConcurrentHashMap<String, ReentrantLock>` for per-task/per-parent synchronization.
- `taskService`: `TaskService` for loading and persisting `PersistableTask` entities.
### Key Issues & Decisions
#### 1. Parent/Child Task Lifecycle Resolution
- **Issue**: `handleTaskLifecycle` only checks `if (task instanceof WithChildren)` for `REMOVE_SELF_AND_CHILDREN`. When a button task (which implements `WithParent<P>`, such as `LoadTrackFromPlaylistTask`) returns `REMOVE_SELF_AND_CHILDREN`, the check evaluates to `false`, leaving the parent and sibling buttons active in memory.
- **Decision**: In `handleTaskLifecycle`, check if the task implements `WithParent<?>`. If so, obtain the parent task (`taskWithParent.getParentTask()`) and remove the parent along with all of its child tasks (`parent.getChildTasks()`). If the task directly implements `WithChildren`, remove all its child tasks and itself.
#### 2. Automatic Persistence Synchronization
- **Issue**: `docs/tasks.md` specifies that persistable tasks removed after execution are deleted from the database. Currently `TaskManagerImpl.removeTask` only removes from `tasks` map and never calls `taskService.unpersistTask(persistableTask)`.
- **Decision**: In `removeTask(Task task)`, check `if (task instanceof PersistableTask persistableTask)` and invoke `taskService.unpersistTask(persistableTask)`.
#### 3. Pending Task Registry & Cleanup
- **Issue**: `pendingTasks` is stored in a `CopyOnWriteArrayList<Task>`. `getTasks()` ignores it, preventing `TaskCleanup` from pruning expired step tasks. `removeTask()` also ignores `pendingTasks`.
- **Decision**: Store `pendingTasks` in a `ConcurrentHashMap<String, Task>` (or unify with active tasks / pending lookup). Update `getTasks()` or provide a method to include pending tasks in cleanup routines. Update `removeTask(Task task)` to remove from `pendingTasks` as well as `tasks`.
#### 4. Concurrency & Lock Management
- **Issue**: `removeTask` calls `tasksLock.remove(task.getLockId())` immediately. If another thread is waiting or about to acquire the lock for a sibling task, it can get a new lock instance from `computeIfAbsent`, breaking mutual exclusion.
- **Decision**: Maintain lock instances safely; do not remove locks eagerly while in the middle of executing handlers. Persistable tasks with state should also participate in locking if needed, or maintain clear locking semantics.
#### 5. Lookup API Optimization
- **Issue**: `findTaskByPartialId` calls `findTasksByPartialId`, instantiating and populating a full `ArrayList` before taking the first element.
- **Decision**: Implement `findTaskByPartialId` with `.filter(...).findFirst()` directly on the stream.
### Architecture & Component Interaction Diagram
```mermaid
graph TD
subgraph Discord Events
BE[ButtonInteractionEvent] --> EH[EventsHandler]
ME[ModalInteractionEvent] --> EH
RE[MessageReactionAddEvent] --> EH
MSG[MessageReceivedEvent] --> EH
end
subgraph TaskManager Subsystem
EH -->|TaskContext| TM[TaskManagerImpl]
TM -->|Acquire Lock| TL[tasksLock Map]
TM -->|Lookup Task| TR[tasks & pendingTasks Maps]
TM -->|Execute| T[Task.execute]
T -->|Return Lifecycle| TM
TM -->|Cascade Cleanup| LC[handleTaskLifecycle]
LC -->|WithParent / WithChildren| RM[removeTask]
RM -->|Unpersist| TS[TaskService]
TS -->|Delete| DB[(Database / OTask)]
RM -->|Deletable| DH[InteractionHook.deleteOriginal]
end
subgraph Background Maintenance
TC[TaskCleanup] -->|Periodic Poll| TM
TM -->|Prune Expired| RM
end
```
### Proposed Code Changes & Contracts
#### `TaskManagerImpl.java`
```java
private void handleTaskLifecycle(Task task, Task.Lifecycle status) {
if (status == null) {
throw new IllegalArgumentException("Task lifecycle status cannot be null");
}
switch (status) {
case REMOVE_SELF -> removeTask(task);
case REMOVE_SELF_AND_CHILDREN -> {
if (task instanceof WithParent<?> taskWithParent) {
Task parent = taskWithParent.getParentTask();
if (parent instanceof WithChildren parentWithChildren) {
for (Task child : parentWithChildren.getChildTasks()) {
removeTask(child);
}
}
removeTask(parent);
} else if (task instanceof WithChildren taskWithChildren) {
for (Task child : taskWithChildren.getChildTasks()) {
removeTask(child);
}
removeTask(task);
} else {
removeTask(task);
}
}
case KEEP, PARENT, BUILTIN -> {
// Maintained in active registry
}
}
}
@Override
public void removeTask(Task task) {
if (task == null) return;
String id = task.getId();
boolean removed = (tasks.remove(id) != null);
removed |= (pendingTasks.remove(task) != null);
if (removed) {
logger.debug("Removing task: {}", id);
if (task instanceof PersistableTask persistableTask) {
taskService.unpersistTask(persistableTask);
}
}
}
```
### File Structure Changes
- **Modified**: `src/main/java/me/bobert/netneutrality/core/tasks/TaskManagerImpl.java`
- **Modified**: `src/main/java/me/bobert/netneutrality/scheduled/cleanup/TaskCleanup.java`
- **Added**: `src/test/java/me/bobert/netneutrality/core/tasks/TaskManagerImplTest.java`
# Testing
### Validation Approach
Automated unit tests will be created in `src/test/java/me/bobert/netneutrality/core/tasks/TaskManagerImplTest.java` utilizing JUnit 5, AssertJ, and Mockito to validate all functional requirements without requiring live Discord connections or database instances.
### Key Scenarios
1. **Task Registration & Lookup**:
- Insert standard `Task`, verify `getTask(id)` and `hasTask(id)` return `true`.
- Insert duplicate task ID, verify rejected with user notification.
2. **Parent / Child Lifecycle (`REMOVE_SELF_AND_CHILDREN`)**:
- Create parent task implementing `WithChildren` and child task implementing `WithParent`.
- Execute child task returning `REMOVE_SELF_AND_CHILDREN`.
- Verify parent task and all sibling child tasks are removed from the registry.
3. **Persistable Task DB Synchronization**:
- Insert `PersistableTask`, verify `TaskService.persistTask` is called.
- Remove `PersistableTask` via lifecycle completion or `removeTask()`, verify `TaskService.unpersistTask` is invoked.
4. **Step Task Flow**:
- Insert `StepTask` into pending registry.
- Reassign ID via `activateTask(task, oldId)`, verify task moves from pending to active registry under new ID.
5. **Partial ID Search**:
- Query `findTaskByPartialId` and verify it matches correct instance without scanning beyond first match.
6. **Task Expiration & Cleanup**:
- Mock expired active and pending tasks, trigger `TaskCleanup.run()`, verify expired tasks are pruned from both collections.
7. **Concurrency & Locking**:
- Simulate concurrent executions for tasks sharing a `lockId`, confirming mutual exclusion.
# Delivery Steps
### ✓ Step 1: Fix Task Lifecycle Hierarchy and DB Persistence Synchronization
`TaskManagerImpl` correctly propagates parent-child lifecycle actions and synchronizes task deletions with `TaskService`.
- Update `handleTaskLifecycle` in `TaskManagerImpl.java` to support `REMOVE_SELF_AND_CHILDREN` for tasks implementing `WithParent`, resolving the parent task and removing both the parent and all sibling child tasks.
- Modify `removeTask(Task task)` in `TaskManagerImpl.java` to invoke `taskService.unpersistTask(persistableTask)` when `task instanceof PersistableTask`, ensuring persisted records in the database are purged when tasks finish.
- Refactor `deleteInteraction(Task task)` in `TaskManagerImpl.java` to cleanly handle `Deletable` logic for both standalone and parented tasks, adding failure logging to `InteractionHook.deleteOriginal().queue()`.
### ✓ Step 2: Refactor Pending Task Management and Concurrency Locking
Pending tasks and active task locks are managed safely without race conditions or memory leaks.
- Refactor `pendingTasks` in `TaskManagerImpl.java` to use a `ConcurrentHashMap<String, Task>` keyed by task ID for O(1) lookups and consistent state management.
- Update `removeTask(Task task)`, `getTask(String id)`, and `getTasks()` in `TaskManagerImpl.java` to include pending tasks so they can be retrieved, removed, and cleaned up when expired.
- Update `TaskCleanup.java` to ensure expired pending tasks are identified and cleaned up.
- Improve lock management in `TaskManagerImpl.handle(TaskContext context)` so that lock eviction does not interfere with concurrently waiting threads acquiring new lock instances.
### ✓ Step 3: Optimize Task Query Performance and Diagnostic Logging
Task lookup methods use short-circuiting stream pipelines, and diagnostic logging is corrected.
- Optimize `findTaskByPartialId` in `TaskManagerImpl.java` to use `.findFirst()` directly rather than allocating a full list via `findTasksByPartialId`.
- Fix typos in log messages and warnings across `TaskManagerImpl.java` (e.g. `"Aquiring lock"`, `"childs"`).
- Ensure consistent ephemeral fallback messages and handling when unacknowledged interactions or missing tasks are encountered.
### ✓ Step 4: Add Comprehensive Unit Tests for Task Management
A complete unit test suite validates `TaskManagerImpl` functionality across all task types, lifecycles, and concurrency scenarios.
- Create `src/test/java/me/bobert/netneutrality/core/tasks/TaskManagerImplTest.java` using JUnit 5 and Mockito.
- Implement tests for standard task registration (`insert`), lookup (`getTask`, `findTaskByPartialId`), and removal (`removeTask`).
- Implement tests for parent-child tasks (`WithChildren`, `WithParent`), verifying lock inheritance and `REMOVE_SELF_AND_CHILDREN` cascading removals.
- Implement tests for `StepTask` lifecycle (pending insertion, ID reassignment via `activateTask`, and expiration).
- Implement tests for `PersistableTask` lifecycle, verifying automatic database persistence on insert and unpersisting on removal.
- Implement tests verifying thread synchronization and lock acquisition during task execution.
-188
View File
@@ -1,188 +0,0 @@
---
sessionId: session-260923-111902-5uwc
---
# Requirements
### Overview & Goals
Replace the hand-rolled `com.sun.net.httpserver` stack in `me.bobert.netneutrality.http.server` with Javalin, so the bot's embedded API needs far fewer custom classes and gains correct status codes, JSON handling and error responses. Additionally simplify the outbound `WebModule` HTTP client.
### Scope
**In scope**
- `WebApi` becomes a thin Javalin-based `Lifecycle` component (same priority 70, same `NETNEUTRALITY_API_PORT` setting).
- Delete `AbstractHandler`, `RootHandler`, `ActionHandler`, `GuildHandler`; their logic moves into small route-registration units.
- Standardise the API layer on **Gson** — remove `org.json` (`JSONObject`/`JSONArray`) usage from the HTTP server package.
- Correct HTTP semantics: 404 for unknown guilds/paths, 405 for wrong methods, 500 on exceptions, UTF-8 safe bodies.
- Simplify `WebModule` (outbound OkHttp wrapper): extract the static cache, remove duplicated/unused members.
**Out of scope**
- Changing the public URL shape of existing endpoints (`/`, `/actions/*`, `/guilds/*`) — paths and payload keys stay backwards compatible.
- Authentication/authorisation for the API.
- Migrating `org.json` usage elsewhere in the project (e.g. task classes using `WebModule.getAsObject()`).
### Functional Requirements
- `GET /` → HTML liveness page (unchanged text).
- `GET /actions/status` → `{"health":...,"ping":...}`.
- `POST /actions/shutdown` → runs the shutdown hook and exits (accept `GET` too for compatibility if desired).
- `GET /actions/settings/current/{guildId}` → `{"status":"ok","settings":{...}}`, or 404 + `{"status":"error"}` for an unknown guild.
- `POST /actions/settings/reload/{guildId}` → reloads the guild setting cache, `{"status":"true"}`; 404 for an unknown guild.
- `GET /guilds/owner` → array of `{guild_id, owner_id}`.
- `GET /guilds/channels/{guildId}` and `GET /guilds/roles/{guildId}` → arrays of id/name objects; 404 for an unknown guild.
- Requests arriving before JDA is ready must return 503 rather than throwing an NPE.
### Non-Functional Requirements
- Startup/shutdown stays deterministic through `ApplicationContext` — `stop()` must release the port.
- Shaded jar must still run (`maven-shade-plugin`); Javalin/Jetty service files need merging.
- No behavioural regression for existing consumers of the endpoints.
# Technical Design
### Current Implementation
- `WebApi.java` (92 lines) implements `Lifecycle` (priority 70), creates an `HttpServer`, registers three contexts and a fixed pool built from `BaseThreadFactory("web-api")`. Constructed in `App.java:154`.
- `AbstractHandler` offers `sendStringResponse`/`sendJsonResponse`; both hardcode status 200 and use `str.length()` instead of byte length (breaks non-ASCII).
- `ActionHandler` / `GuildHandler` route via `if (path.startsWith(...))` chains and parse ids with `getRequestURI().toString().split("/")`; they mix Gson and `org.json`, never check the HTTP method, and call `jda.getGuildById()` twice in places.
- `WebModule` is an unrelated *outbound* OkHttp wrapper with a builder, a static LRU cache and a `CachedResponse` record; it builds a brand-new `OkHttpClient` per request and has three redundant timeout setters plus unused `elapsedTime`/`errorMessage` accessors.
### Key Decisions
1. **Javalin** (6.x, JDK 17+ / Java 25 compatible) as the embedded server — removes routing, method matching, error mapping and content-type handling code.
2. **Gson only** in the HTTP layer: register a `GsonJsonMapper` on the Javalin config so `ctx.json(obj)` works with plain records/maps; `org.json` disappears from `http/server`.
3. **Route groups instead of handler classes**: `ActionRoutes` and `GuildRoutes` expose a single `static void register(Javalin app, deps...)`, so `AbstractHandler`, `RootHandler`, `ActionHandler`, `GuildHandler` are all deleted (4 classes → 2 tiny registrars).
4. **Guild lookup guard**: one private helper resolves `{guildId}` → `Guild` or throws `NotFoundResponse`, replacing repeated null checks.
5. **WebModule**: keep the builder API used by callers, but move the cache into a package-private `ResponseCache`, share a single static `OkHttpClient` and derive per-request timeouts with `newBuilder()`.
### Proposed Changes
**`http/server/WebApi.java`**
```java
public class WebApi implements Lifecycle {
private Javalin app;
@Override public int priority() { return 70; }
@Override public void start() {
int port = Integer.parseInt(settings.get("NETNEUTRALITY_API_PORT", "9000"));
app = Javalin.create(cfg -> cfg.jsonMapper(new GsonJsonMapper()));
app.get("/", ctx -> ctx.html(LIVENESS_HTML));
ActionRoutes.register(app, jdaSupplier, guildServices, shutdownHook);
GuildRoutes.register(app, jdaSupplier);
app.exception(Exception.class, (e, ctx) -> { log.error(...); ctx.status(500).json(Map.of("status","error")); });
app.start(port);
}
@Override public void stop() { if (app != null) { app.stop(); app = null; } }
}
```
**`http/server/GuildRoutes.java`** — `/guilds/owner`, `/guilds/channels/{guildId}`, `/guilds/roles/{guildId}` mapped to `List<Map<String,String>>` and returned via `ctx.json(...)`.
**`http/server/ActionRoutes.java`** — `/actions/status`, `/actions/shutdown`, `/actions/settings/current/{guildId}`, `/actions/settings/reload/{guildId}`.
**`http/server/GsonJsonMapper.java`** — ~15 lines implementing Javalin's `JsonMapper` over a shared `Gson`.
**`http/WebModule.java`** — shared client, cache extracted, redundant setters/accessors dropped; public builder methods used by callers remain.
### Data Models / Contracts
Small records replace ad-hoc JSON building, serialized by Gson with the existing key names:
```java
record GuildOwner(String guild_id, String owner_id) {}
record NamedEntity(String channel_id, String channel_name) {}
record StatusResponse(String health, String ping) {}
```
### File Structure
```
src/main/java/me/bobert/netneutrality/http/
WebModule.java (modified)
server/WebApi.java (rewritten, Javalin)
server/GsonJsonMapper.java (new)
server/ActionRoutes.java (new)
server/GuildRoutes.java (new)
server/AbstractHandler.java (deleted)
server/RootHandler.java (deleted)
server/ActionHandler.java (deleted)
server/GuildHandler.java (deleted)
pom.xml (+ io.javalin:javalin, shade transformer)
```
### Architecture Diagram
```mermaid
graph LR
App-->ApplicationContext
ApplicationContext-->WebApi
WebApi-->Javalin
WebApi-->GsonJsonMapper
Javalin-->ActionRoutes
Javalin-->GuildRoutes
ActionRoutes-->GuildServiceProvider
GuildRoutes-->JDA
```
### Risks
- **Shaded jar**: Jetty/Javalin rely on `META-INF/services`; the shade plugin needs `ServicesResourceTransformer`, otherwise startup fails only in the packaged build.
- **Jar size / SLF4J binding**: Jetty logs through SLF4J; project already has log4j + slf4j — check for duplicate bindings.
- **JDA not ready**: `jdaSupplier.get()` may be null; guard with a 503 mapping.
- `System.exit(0)` in the shutdown route must run after the response is flushed.
# Testing
### Validation Approach
Build with `./mvnw -q verify` (checkstyle included), then start the app/`WebApi` on an ephemeral port and exercise every endpoint with `curl`, comparing payload shape against the pre-refactor behaviour.
### Key Scenarios
- `GET /` returns 200 and the HTML liveness text.
- `GET /actions/status` returns 200 `application/json` with `health` and `ping`.
- `GET /guilds/owner` returns a JSON array with `guild_id`/`owner_id` keys.
- `GET /guilds/channels/{id}` and `/guilds/roles/{id}` return arrays with `channel_id`/`channel_name`.
- `GET /actions/settings/current/{id}` returns `{"status":"ok","settings":{...}}`.
### Edge Cases
- Unknown guild id → 404 with `{"status":"error"}` (previously 200).
- Unknown path → 404 (previously echoed the path with 200).
- Wrong method on a route → 405.
- Non-ASCII content in settings values round-trips correctly (old `str.length()` bug).
- `Lifecycle.stop()` frees the port — a second `start()` on the same port succeeds.
### Test Changes
- Add a lightweight JUnit test starting `WebApi` on port 0 with a mocked `Supplier<JDA>` and `GuildServiceProvider`, hitting the routes with Javalin's test client or `java.net.http.HttpClient`.
- Add a `WebModule` test covering cache hit/expiry after the cache extraction.
# Delivery Steps
### ✓ Step 1: Add Javalin dependency and Gson JSON mapper
The project builds with Javalin available and a Gson-backed JSON mapper ready for use.
- Add `io.javalin:javalin` (6.x) to `pom.xml` dependencies.
- Add `ServicesResourceTransformer` to the `maven-shade-plugin` configuration so Jetty/Javalin service files survive shading.
- Create `http/server/GsonJsonMapper.java` implementing Javalin's `JsonMapper` over a shared `Gson` instance.
- Verify `./mvnw -q package` succeeds and the shaded jar still starts.
### ✓ Step 2: Rewrite WebApi as a Javalin Lifecycle component
`WebApi` starts and stops a Javalin server while keeping its Lifecycle contract and the root endpoint.
- Replace the `HttpServer`/`ExecutorService` fields with a `Javalin app` field.
- Build the instance in `start()` with the `GsonJsonMapper`, reading `NETNEUTRALITY_API_PORT` from `SettingsProvider` as today.
- Register `GET /` returning the liveness HTML and delete `RootHandler.java`.
- Add global `exception` and `error` handlers returning 500/404 JSON, plus a 503 guard when `jdaSupplier.get()` is null.
- Implement `stop()` via `app.stop()`; keep `priority() == 70` and the `App.java` construction site unchanged.
### ✓ Step 3: Migrate guild endpoints to GuildRoutes
All `/guilds/*` endpoints are served by Javalin route lambdas with proper status codes and Gson serialization.
- Create `http/server/GuildRoutes.register(Javalin, Supplier<JDA>)`.
- Map `GET /guilds/owner`, `GET /guilds/channels/{guildId}`, `GET /guilds/roles/{guildId}` using path params instead of `split("/")`.
- Return `List<record>` payloads via `ctx.json(...)`, preserving the existing `guild_id`/`owner_id`/`channel_id`/`channel_name` keys.
- Resolve guilds through a shared helper that throws `NotFoundResponse` for unknown ids (single `getGuildById` call).
- Delete `GuildHandler.java`.
### ✓ Step 4: Migrate action endpoints to ActionRoutes
All `/actions/*` endpoints run on Javalin, with `org.json` fully removed from the HTTP server package.
- Create `http/server/ActionRoutes.register(Javalin, Supplier<JDA>, GuildServiceProvider, Runnable)`.
- Map `/actions/status`, `/actions/shutdown`, `/actions/settings/current/{guildId}`, `/actions/settings/reload/{guildId}`.
- Replace `JSONObject`/`JSONArray` with Gson-serialized maps/records keeping the same response keys.
- Ensure the shutdown route flushes its response before running the hook and exiting.
- Delete `ActionHandler.java` and `AbstractHandler.java`, and add endpoint tests starting `WebApi` on port 0 with mocked JDA/guild services.
### ✓ Step 5: Simplify the outbound WebModule client
`WebModule` keeps its builder API but sheds duplicated state and its inlined cache.
- Extract the static LRU cache and `CachedResponse` into a package-private `ResponseCache` class in `http/`.
- Share a single static `OkHttpClient` and derive per-request timeouts with `newBuilder()` instead of constructing a client per build.
- Collapse the redundant `timeout`/`connectTimeout`/`readTimeout`/`writeTimeout` setters and remove unused accessors (`getErrorMessage`, `elapsedTime` exposure) after checking call sites.
- Add a unit test covering cache hit, expiry and POST bypass; verify existing `WebModule` callers still compile.
-249
View File
@@ -1,249 +0,0 @@
---
sessionId: session-260923-114918-ce7w
---
# Requirements
### Overview & Goals
Replace the in-process **Lavaplayer** audio stack with a remote **Lavalink v4** node driven by the official `dev.arbjerg:lavalink-client`. Audio decoding, source resolution and voice sending move out of the bot process to the Lavalink node, which can then be extended with server-side plugins (YouTube source, LavaSrc, etc.) without touching the bot.
All dependency/plugin examples from the Lavalink docs are Gradle-based and will be **translated to Maven** (`pom.xml` `<repositories>` / `<dependencies>`); the Lavalink node itself is configured via a YAML file mounted into the container, not via any build script.
### Scope
**In Scope**
- Swap `dev.arbjerg:lavaplayer` + `dev.lavalink.youtube:v2` for `dev.arbjerg:lavalink-client` in `pom.xml`.
- Rewrite the `me.bobert.netneutrality.music` package against Lavalink: `MusicServiceImpl`, `MusicManager`, `TrackScheduler`, `QueueManager`, `TrackInfo`, `TrackLoadingHandler`, `StuckTrackRecoveryHandler`; delete `AudioPlayerSendHandler`.
- Route voice connections through Lavalink's `JDAVoiceUpdateListener` / `DirectAudioController` instead of `Guild#getAudioManager()`.
- Add a `lavalink` service plus `lavalink/application.yml` to `docker-compose.yml`, wired to the existing `ejs-api` (yt-cipher) container.
- Update `MessageFunction`, `LoadPlaylistTask`, `LoadTrackFromPlaylistTask` and the music command classes to the new track type.
- Update the affected tests and the docs under `docs/`.
**Out of Scope**
- Porting `music/source/UdioAudioSourceManager` to a Lavalink server plugin — captured as a documented follow-up. Udio playback is temporarily unavailable; the existing code stays in the repo as the porting reference.
- Any change to command UX, queue semantics, DJ rules or vote-skip behaviour.
- Audio filters/equalizer, node load balancing across multiple nodes, and session resuming (Lavalink supports these; not enabled here).
### User Stories
- As a bot operator, I want audio to be served by a separate Lavalink node so that I can add source plugins and restart the bot without interrupting the audio pipeline's configuration.
- As a user, I want `/play`, `/skip`, `/queue`, `/volume`, `/stop`, `/resume`, `/shuffle` and vote-skip to behave exactly as before the migration.
- As a maintainer, I want YouTube cipher/poToken configuration to live in the node's `application.yml` rather than in Java code.
### Functional Requirements
- All 13 commands in `command/music/` keep their current signatures, replies and `CommandExecutionResult` semantics.
- `SourceFinder` keeps classifying URLs and prefixing searches with `ytsearch:`; identifiers are passed to Lavalink's `loadItem` unchanged.
- Queue ordering, duplicate rejection, auto-start on first enqueue, auto-disconnect on empty queue and the two-strike stuck-track recovery are preserved.
- If the Lavalink node is unreachable at startup, the bot still starts; music commands reply with a clear error instead of crashing.
- Udio URLs reply with a "temporarily unsupported" message rather than failing silently.
### Non-Functional Requirements
- The bot no longer ships native audio encoders: the `jdave-api` / `jdave-native-*` dependencies and the `AudioModuleConfig` DAVE wiring in `App.initJDA()` are removed, since DAVE is handled by the node (Lavalink ≥ 4.2.0).
- New settings are read through the existing `SettingsProvider` and documented in `bot.properties.example`.
- Checkstyle (`checkstyle.xml`, bound to the `test` phase) must keep passing.
# Technical Design
### Current Implementation
- `MusicServiceImpl` (priority-40 `Lifecycle`, registered in `App.buildContext()` line 140) owns a `DefaultAudioPlayerManager` and registers YouTube (with `setRemoteCipher(...)`), SoundCloud, Udio and HTTP source managers in `start()`; `stop()` clears queues and calls `player.destroy()`.
- `MusicManager` creates an `AudioPlayer` per guild, sets `AudioPlayerSendHandler` on `guild.getAudioManager()`, opens/closes the voice connection itself, and calls `playerManager.loadItemOrdered(this, identifier, new TrackLoadingHandler(...))`.
- `TrackScheduler extends AudioEventAdapter` and receives `onTrackStart/End/Exception/Stuck` callbacks directly from the player.
- `QueueManager` holds a `Deque<TrackInfo>` and drives playback with `player.startTrack(track.makeClone(), false)` / `player.stopTrack()`.
- `StuckTrackRecoveryHandler` relies on `AudioTrack#makeClone`, `getPosition()` and `setPosition()`.
- `com.sedmelluq.*` types appear in 14 main sources and 5 tests (`QueueManagerTest`, `TrackInfoTest`, `TrackSchedulerTest`, `VoteSkipServiceTest`, `UdioAudioSourceManagerTest`).
- `pom.xml` already declares the `https://maven.lavalink.dev/releases` repository (lines 136–140).
- `docker-compose.yml` runs `db`, `bot` and `ejs_signature_api` (yt-cipher) on the `netneutrality` network; there is no Lavalink node.
### Key Decisions
1. **Official client** — `dev.arbjerg:lavalink-client` (v4-only). `VoiceDispatchInterceptor` is verified present in the bundled JDA 6.4.1 jar, so `JDAVoiceUpdateListener` attaches cleanly.
2. **Central event dispatcher** — `MusicServiceImpl` holds the single subscription to the client's event flux and routes each event to the owning guild's `TrackScheduler`. One `Disposable` to dispose in `stop()`.
3. **Synchronous facade** — `MusicManager` subscribes to the returned `Mono`s internally; command classes keep their current synchronous signatures. Failures are reported via `LogHandler` and the attached `MusicChannelContext`.
4. **Lavalink `Track` everywhere** — `dev.arbjerg.lavalink.protocol.v4.Track` replaces `AudioTrack` in `TrackInfo`, `QueueManager`, `MessageFunction` and the playlist tasks. `Track` is an immutable data class, so `makeClone()` disappears and `setPosition` becomes `player.setPosition(...)` on the Lavalink player.
5. **Node in compose** — a `lavalink` service with a mounted `application.yml`; YouTube cipher/poToken config moves out of `MusicServiceImpl.start()` into that YAML.
6. **Udio deferred** — `music/source/**` is excluded from compilation-relevant wiring and the `Udio` branch of `SourceFinder.getIdentifier` returns `null` with a friendly reply; the classes remain for the future plugin port.
### Proposed Changes
**Maven (`pom.xml`)** — translated from the docs' Gradle snippets:
```xml
<dependency>
<groupId>dev.arbjerg</groupId>
<artifactId>lavalink-client</artifactId>
<version>3.4.0</version>
</dependency>
```
resolved from the already-present `arbjergDev-releases` repository. Removed: `dev.arbjerg:lavaplayer`, `dev.lavalink.youtube:v2`, `club.minnced:jdave-*`.
**`MusicServiceImpl`**
- Field `LavalinkClient client = new LavalinkClient(Helpers.getUserIdFromToken(token))`.
- `start()` registers one node (`client.addNode(...)` with URI, password and name from settings) and opens the dispatcher subscription:
`client.on(TrackStartEvent.class)`, `TrackEndEvent`, `TrackExceptionEvent`, `TrackStuckEvent`, `WebSocketClosedEvent` → look up `managers.get(event.getGuildId())` → forward to its `TrackScheduler`.
- `stop()` disposes the subscriptions, clears queues, destroys players and closes the client.
- Exposes `LavalinkClient getClient()` in place of `getPlayerManager()`.
**`App`**
- `initJDA()` adds `.setVoiceDispatchInterceptor(new JDAVoiceUpdateListener(client))` and drops `.setAudioModuleConfig(new AudioModuleConfig().withDaveSessionFactory(new JDaveSessionFactory()))`.
- Ordering: the `LavalinkClient` must exist before JDA is built, which the existing priorities already guarantee (music = 40, JDA = 60).
- Dev log levels switch from `com.sedmelluq.discord` / `dev.lavalink.youtube` to `dev.arbjerg.lavalink`.
**`MusicManager`**
- Holds `LavalinkClient client` and resolves `Link link = client.getOrCreateLink(guildId)`.
- `connect(AudioChannel)` → `jda.getDirectAudioController().connect(channel)` + `setSelfDeafened`; `disconnect()` → `getDirectAudioController().disconnect(guild)` and `link.destroyPlayer()`.
- `isConnected()` / `isNotConnectedTo(...)` read `guild.getSelfMember().getVoiceState().getChannel()` — **`Guild#getAudioManager()` must not be used** with Lavalink.
- `play(...)` → `link.loadItem(identifier).subscribe(new TrackLoadingHandler(...))`.
- `getCurrentlyPlaying()` returns the cached `Track` from the last `TrackStartEvent` (the REST player state is not synchronously readable).
- `setVolume` / `stopTrack` → `link.getPlayer().flatMap(p -> p.setVolume(v).setEncodedTrack(null))` subscribed internally.
**`TrackScheduler`**
- No longer extends `AudioEventAdapter`; exposes `onTrackStart(Track)`, `onTrackEnd(Track, AudioTrackEndReason)`, `onTrackException`, `onTrackStuck(Track, long)` called by the dispatcher.
- The existing `endReason` branching maps 1:1 onto Lavalink's `AudioTrackEndReason` (`FINISHED`, `LOAD_FAILED`, `STOPPED`, `REPLACED`, `CLEANUP`).
**`QueueManager`**
- `player.getPlayingTrack() == null` becomes a `volatile Track currentTrack` maintained by scheduler events (avoids a blocking REST call inside the `synchronized (queue)` blocks).
- `startTrack(track, noInterrupt)` becomes `link.createOrUpdatePlayer().setTrack(track).setNoReplace(...)`; `makeClone()` calls are dropped since `Track` is immutable.
**`TrackLoadingHandler`** — implements lavalink-client's `AbstractAudioLoadResultHandler`: `ontrackLoaded` / `onPlaylistLoaded` / `onSearchResultLoaded` / `noMatches` / `loadFailed(LoadFailed)`. `onSearchResultLoaded` takes the first result, matching today's `ytsearch:` behaviour.
**`StuckTrackRecoveryHandler`** — re-queues the same immutable `Track` and restores position via `player.setPosition(lastPosition)`; the second consecutive strike still skips.
### Data Models / Contracts
```java
// MusicManager (synchronous facade, unchanged call sites)
public synchronized void play(String query, CommandContext ctx, boolean silent);
public synchronized void stopTrack();
public synchronized void setVolume(int volume);
public synchronized Track getCurrentlyPlaying();
// TrackScheduler (invoked by the dispatcher)
void onTrackStart(Track track);
void onTrackEnd(Track track, AudioTrackEndReason endReason);
void onTrackStuck(Track track, long thresholdMs);
// TrackInfo
public record TrackInfo(Track track, Member requester) { }
```
New settings (read via `SettingsProvider`, documented in `bot.properties.example`):
`NETNEUTRALITY_LAVALINK_URI` (default `ws://lavalink:2333`), `NETNEUTRALITY_LAVALINK_PASSWORD`, `NETNEUTRALITY_LAVALINK_NAME`.
### File Structure
| Path | Change |
|---|---|
| `pom.xml` | swap lavaplayer/youtube-v2/jdave → `lavalink-client` |
| `docker-compose.yml` | add `lavalink` service |
| `lavalink/application.yml` | **new** — node + youtube-source plugin + yt-cipher config |
| `bot.properties.example` | new Lavalink settings |
| `music/MusicServiceImpl.java` | client lifecycle + event dispatcher |
| `music/MusicManager.java` | `Link`/`DirectAudioController` based |
| `music/TrackScheduler.java` | plain event sink |
| `music/QueueManager.java`, `TrackInfo.java`, `TrackLoadingHandler.java`, `StuckTrackRecoveryHandler.java` | `Track` type + REST player calls |
| `music/AudioPlayerSendHandler.java` | **deleted** |
| `music/SourceFinder.java` | Udio → unsupported |
| `App.java` | voice interceptor, drop DAVE wiring, log levels |
| `shared/util/MessageFunction.java`, `tasks/music/LoadPlaylistTask.java`, `tasks/music/LoadTrackFromPlaylistTask.java` | `Track` type |
| `src/test/.../music/*` | updated fixtures |
| `docs/music.md`, `docs/architecture.md`, `docs/README.md` | rewritten sections |
### Architecture Diagram
```mermaid
graph LR
JDA[JDA gateway] -->|voice state| VIL[JDAVoiceUpdateListener]
VIL --> C[LavalinkClient]
C -->|REST and WS| N[Lavalink node]
N --> V[Discord voice]
CMD[music commands] --> MM[MusicManager]
MM -->|Link ops| C
MM --> QM[QueueManager]
C -->|event flux| DISP[MusicServiceImpl dispatcher]
DISP --> TS[TrackScheduler]
TS --> QM
```
### Risks
- **Voice API confusion**: any remaining `guild.getAudioManager()` call silently breaks playback; all call sites (`MusicManager`, `MusicUtils`, `BotState`) must be swept.
- **Blocking on reactor**: subscribing inside `synchronized` blocks in `QueueManager` risks deadlock — hence the cached `currentTrack` instead of a REST read.
- **Node availability**: `MusicServiceImpl.start()` must not throw when the node is down, or `App.startup()` exits with code 1.
- **Feature regressions**: Udio support pauses; direct-MP3 and SoundCloud must be re-verified against the node's enabled sources.
- **DAVE**: removing JDave requires Lavalink ≥ 4.2.0 — the compose image must be pinned accordingly.
# Testing
### Validation Approach
- `./mvnw -q verify` must compile and pass Checkstyle plus the updated unit tests.
- Unit tests keep using mocks: `QueueManagerTest` and `TrackSchedulerTest` mock the Lavalink `Link`/`LavalinkPlayer` and assert on the interactions instead of on `AudioPlayer`.
- A grep sweep confirms no `com.sedmelluq` / `dev.lavalink.youtube` import survives outside `music/source/**`.
- Manual smoke run with `docker compose up` to confirm the bot reaches the node and plays a YouTube URL.
### Key Scenarios
- Enqueue on an empty queue starts playback immediately; enqueue while playing appends.
- `TrackEndEvent(FINISHED)` advances to the next track; an empty queue triggers `onQueueEmpty()` → disconnect.
- `skip(n)`, `shuffle`, `clearQueue`, `removeTracksFromUser` and `removeFromQueue(index)` return the same `CommandExecutionResult` messages as before.
- `ytsearch:` queries resolve through `onSearchResultLoaded` and queue the first hit.
- Volume from `GSettings.MUSIC_VOLUME` is applied on connect, with the 50 fallback on a malformed value.
### Edge Cases
- Lavalink node unreachable at startup → bot boots, music commands reply with an error.
- Node disconnects mid-playback → `WebSocketClosedEvent` is logged and the guild state is cleaned up.
- Stuck track: first event re-queues and seeks, second consecutive event skips.
- Udio URL → explicit "temporarily unsupported" reply.
- Duplicate track enqueue is still rejected by `TrackInfo` equality.
### Test Changes
- Update `QueueManagerTest`, `TrackSchedulerTest`, `TrackInfoTest`, `VoteSkipServiceTest` to the Lavalink `Track` type and mocked players.
- Keep `UdioAudioSourceManagerTest` as-is while the source classes remain in the repo for the future plugin port.
# Delivery Steps
### ✓ Step 1: Swap Maven dependencies and stand up a Lavalink node
The project builds against `lavalink-client` and `docker compose up` starts a reachable Lavalink v4 node.
- Replace `dev.arbjerg:lavaplayer`, `dev.lavalink.youtube:v2` and the four `club.minnced:jdave-*` entries in `pom.xml` with `dev.arbjerg:lavalink-client` (Gradle docs snippet translated to Maven, using the existing `arbjergDev-releases` repository).
- Add a `lavalink` service to `docker-compose.yml` on the `netneutrality` network, pinned to an image with DAVE support (≥ 4.2.0), with the bot depending on it.
- Create `lavalink/application.yml` mounted into the node: server port/password, the youtube-source plugin, and the remote cipher pointed at the existing `ejs-api` container plus poToken/visitorData.
- Add `NETNEUTRALITY_LAVALINK_URI`, `_PASSWORD` and `_NAME` to `bot.properties.example`.
### ✓ Step 2: Wire LavalinkClient into the bootstrap and JDA voice handling
The bot connects to the node on startup and routes Discord voice through Lavalink instead of JDA's audio manager.
- Rework `MusicServiceImpl` to own a `LavalinkClient`, registering the configured node in `start()` without throwing when it is unreachable.
- Dispose the client and its subscriptions in `stop()`, keeping the priority-40 `Lifecycle` contract used by `App.buildContext()`.
- Add `.setVoiceDispatchInterceptor(new JDAVoiceUpdateListener(client))` in `App.initJDA()` and remove the `AudioModuleConfig`/`JDaveSessionFactory` wiring.
- Update the dev-mode log levels in `App.main` from `com.sedmelluq.discord`/`dev.lavalink.youtube` to `dev.arbjerg.lavalink`.
- Replace `getPlayerManager()` with `getClient()` and fix `BotState`'s usage.
### ✓ Step 3: Rebuild MusicManager on Links and the DirectAudioController
Per-guild connect, disconnect and player control go through Lavalink while command signatures stay unchanged.
- Replace the `AudioPlayer` field in `MusicManager` with a `Link` obtained from `client.getOrCreateLink(guildId)`.
- Implement `connect`/`disconnect` via `jda.getDirectAudioController()` and `link.destroyPlayer()`, deleting the `guild.getAudioManager()` calls and the `AudioPlayerSendHandler` class.
- Base `isConnected()` and `isNotConnectedTo(...)` on `getSelfMember().getVoiceState().getChannel()`, and update `MusicUtils.connect`/`checkIfConnected` accordingly.
- Keep the synchronous facade: `stopTrack`, `setVolume` and `play` subscribe to the returned `Mono`s internally and report failures through `LogHandler`.
- Apply the `GSettings.MUSIC_VOLUME` base volume on connect with the existing default-50 fallback.
### ✓ Step 4: Migrate queue, track loading and message rendering to the Lavalink Track type
Tracks are represented by `dev.arbjerg.lavalink.protocol.v4.Track` end to end and loading uses Lavalink's REST resolver.
- Change `TrackInfo` to wrap `Track` + `Member`, preserving equality-based duplicate rejection.
- Update `QueueManager` to drive playback with `createOrUpdatePlayer().setTrack(...)`/`setNoReplace(...)`, dropping `makeClone()` and replacing `player.getPlayingTrack()` checks with a scheduler-maintained `volatile Track currentTrack`.
- Reimplement `TrackLoadingHandler` on `AbstractAudioLoadResultHandler`, mapping `onSearchResultLoaded` to the first hit to preserve the `ytsearch:` behaviour.
- Update `MessageFunction`, `LoadPlaylistTask` and `LoadTrackFromPlaylistTask` to the new track type.
- Make `SourceFinder` return no identifier for Udio URLs and have `MusicManager.play` reply that Udio is temporarily unsupported.
### ✓ Step 5: Route Lavalink events to TrackScheduler and restore stuck-track recovery
Track lifecycle events from the node drive queue advancement, error reporting and stuck recovery exactly as before.
- Add a central dispatcher in `MusicServiceImpl` subscribing once to `TrackStartEvent`, `TrackEndEvent`, `TrackExceptionEvent`, `TrackStuckEvent` and `WebSocketClosedEvent`, routing by guild id to the cached `MusicManager`.
- Convert `TrackScheduler` from an `AudioEventAdapter` subclass into a plain sink with `onTrackStart`/`onTrackEnd`/`onTrackException`/`onTrackStuck`, keeping the existing `FINISHED`/`LOAD_FAILED`/`STOPPED` branching and `MusicChannelContext` attachment.
- Rework `StuckTrackRecoveryHandler` to re-queue the immutable `Track` and seek with `player.setPosition(...)`, retaining the retry-then-skip rule.
- Update `QueueManagerTest`, `TrackSchedulerTest`, `TrackInfoTest` and `VoteSkipServiceTest` to mock `Link`/`LavalinkPlayer`, and verify `./mvnw verify` passes Checkstyle and the suite.
### ✓ Step 6: Update documentation and record the Udio plugin follow-up
The docs describe the Lavalink-based architecture and the deferred Udio work is explicit.
- Rewrite the Lavaplayer sections of `docs/music.md` and `docs/architecture.md` to cover the node, the client, the dispatcher and the new settings.
- Refresh the music mentions in `docs/README.md`, `docs/threading.md` and `docs/utilities-and-bootstrap.md`.
- Document how to run the node locally and how to add server-side plugins via `lavalink/application.yml`.
- Add a follow-up note stating that `music/source/UdioAudioSourceManager` and `UdioAudioTrack` remain only as the reference for a future Lavalink server plugin and that Udio playback is disabled until then.
@@ -1,132 +0,0 @@
---
sessionId: session-260906-073726-sbxb
---
# Requirements
### Overview & Goals
Improve the code quality, correctness, and maintainability of `QueueManager`, `TrackScheduler`, `TrackInfo`, and `VoteSkipService` — the core music queue and playback scheduling subsystem.
### Scope
**In Scope:**
- Fix thread-safety issues (check-then-act race conditions in `QueueManager`)
- Simplify confusing queue insertion logic in `QueueManager.queue()`
- Separate concerns: extract command-result formatting from queue data operations
- Fix `TrackInfo` using wrong logger (`QueueManager.class` instead of `TrackInfo.class`)
- Clean up `TrackScheduler.onTrackStuck()` complexity
- Add unit tests for `QueueManager`, `TrackScheduler`, `TrackInfo`, and `VoteSkipService`
- Expose skip-vote state through `TrackInfo` more cleanly (encapsulate the `Set<String>` skips)
**Out of Scope:**
- Changes to `MusicManager`, `MusicServiceImpl`, or audio source/loading classes
- New user-facing features or commands
- Changes to `MusicManagerCallback` interface
# Technical Design
### Current Implementation
**`QueueManager`** (`src/main/java/me/bobert/netneutrality/music/QueueManager.java`, 286 lines):
- Manages a `LinkedBlockingDeque<TrackInfo>` with manual `synchronized` blocks
- Multiple methods check state outside the synchronized block, then re-check inside (e.g., `clearQueue()` L81 vs L86, `skip()` L197 vs L203, `shuffle()` L229 vs L233, `removeFromQueue()` L254 vs L259) — the outer checks are racy and misleading
- `queue()` method (L135-158) has confusing branching: `addFirst` + `add` can result in the same track being added twice when `player.getPlayingTrack() == null && !queue.isEmpty()`
- `clearQueue()` calls `callback.onQueueEmpty()` before checking if queue is empty (L79)
- `removeTracksFromUser()` iterates the queue outside synchronized (L97), then again inside (L102)
- `skip(int)` silently resets `nbTrack` to 1 if it exceeds queue size (L209) — surprising behavior
- Mixes queue data operations with `CommandExecutionResult` formatting
**`TrackScheduler`** (`src/main/java/me/bobert/netneutrality/music/TrackScheduler.java`, 175 lines):
- `onTrackStuck()` (L129-173) has deeply nested logic with inline async recovery — hard to follow and test
- Circular dependency: creates `QueueManager` in constructor, which takes `this` back
- `context` field is accessed from multiple threads but only `attachContext`/`detachContext` are synchronized — `getContext()`, `getDj()` are not
**`TrackInfo`** (`src/main/java/me/bobert/netneutrality/music/TrackInfo.java`, 83 lines):
- Logger incorrectly uses `QueueManager.class` (L26) instead of `TrackInfo.class`
- `getSkips()` returns mutable `Set<String>` — breaks encapsulation
- Duplicate copyright block (L1-5 and L7-11)
**`VoteSkipService`** (`src/main/java/me/bobert/netneutrality/music/VoteSkipService.java`, 57 lines):
- Relatively clean, but directly calls `queueManager.skip()` mixing vote logic with queue mutation
- `info.addSkip()` uses `context.getGuildUser()` but `info.getSkips().size()` check uses raw set — no race protection
### Proposed Changes
#### QueueManager
1. **Remove all outer unsynchronized checks** — move all logic inside a single `synchronized` block per method to eliminate check-then-act races
2. **Rewrite `queue()` method** — simplify to: add to queue, if nothing playing start the head track. Remove the confusing `addFirst`/`add` dual-path
3. **Fix `clearQueue()`** — move `callback.onQueueEmpty()` call to after the queue is confirmed non-empty and cleared
4. **Fix `skip(int)`** — when `nbTrack > queue.size()`, skip all remaining tracks instead of silently resetting to 1
5. **Fix `removeTracksFromUser()`** — do all filtering inside synchronized block
6. **Return queue operation results as data** (e.g., number removed, track name) and let callers build `CommandExecutionResult` — or at minimum, document that this class returns command results
#### TrackScheduler
1. **Extract stuck-track recovery** into a dedicated `StuckTrackRecoveryHandler` class to reduce `onTrackStuck()` complexity
2. **Make `context` access thread-safe** — synchronize `getContext()` and `getDj()`, or use `volatile`
3. **Add `lastStuckTrack` reset** on track start or successful recovery to prevent stale state
#### TrackInfo
1. **Fix logger** — change to `TrackInfo.class`
2. **Remove duplicate copyright block**
3. **Encapsulate skips** — return `Collections.unmodifiableSet(skips)` from `getSkips()`, add `getSkipCount()` method
#### VoteSkipService
1. **Synchronize vote-skip check** — ensure `addSkip` + threshold check is atomic (or accept eventual consistency and document it)
### File Structure
All changes in `src/main/java/me/bobert/netneutrality/music/`:
- **Modified:** `QueueManager.java`, `TrackScheduler.java`, `TrackInfo.java`, `VoteSkipService.java`
- **New:** `StuckTrackRecoveryHandler.java`
- **New tests:** `src/test/java/me/bobert/netneutrality/command/music/QueueManagerTest.java`, `TrackSchedulerTest.java`, `TrackInfoTest.java`, `VoteSkipServiceTest.java`
# Testing
### Validation Approach
Add unit tests for all four classes using JUnit 5 and Mockito (already in project dependencies via the existing test classes).
### Key Scenarios
- **QueueManager:** queue/dequeue ordering, skip single/multiple, shuffle preserves current track, removeFromQueue bounds, clearQueue idempotency, removeTracksFromUser correctness
- **TrackInfo:** skip vote tracking, duplicate skip prevention, unmodifiable skips set, equals/hashCode
- **VoteSkipService:** threshold calculation, skip when below min members, vote counting, duplicate vote handling
- **TrackScheduler:** onTrackEnd triggers correct queue action per end reason, onTrackStuck recovery flow
### Edge Cases
- Queue with single track: skip, shuffle, remove
- Skip count exceeding queue size
- Empty queue operations
- Concurrent vote-skip from same user
# Delivery Steps
### ✓ Step 1: Fix TrackInfo issues
TrackInfo has correct logger, clean copyright, and encapsulated skip state.
- Fix logger from `QueueManager.class` to `TrackInfo.class` (line 26)
- Remove duplicate copyright block (lines 7-11)
- Change `getSkips()` to return `Collections.unmodifiableSet(skips)`
- Add `getSkipCount()` convenience method
- Add `TrackInfoTest.java` covering skip tracking, duplicate prevention, equals/hashCode, and unmodifiable set enforcement
### ✓ Step 2: Fix QueueManager thread-safety and queue logic
QueueManager has correct synchronization and simplified queue insertion.
- Remove all outer unsynchronized pre-checks in `clearQueue()`, `skip()`, `shuffle()`, `removeFromQueue()`, `removeTracksFromUser()` — consolidate into single synchronized blocks
- Rewrite `queue(AudioTrack, Member)` to eliminate the confusing `addFirst`/`add` dual-path: simply add to tail, then start playback if nothing is playing
- Fix `clearQueue()` to call `callback.onQueueEmpty()` only after confirming queue was non-empty
- Fix `skip(int)` to skip all remaining when `nbTrack > queue.size()` instead of silently resetting to 1
- Fix `removeTracksFromUser()` to do all filtering inside synchronized block
- Add `QueueManagerTest.java` covering queue ordering, skip, shuffle, remove, clear, and edge cases
### ✓ Step 3: Extract stuck-track recovery and fix TrackScheduler thread-safety
TrackScheduler is simplified with extracted recovery logic and thread-safe context access.
- Create `StuckTrackRecoveryHandler.java` encapsulating the retry-or-skip logic currently in `onTrackStuck()` (lines 129-173)
- Simplify `onTrackStuck()` to delegate to the new handler
- Reset `lastStuckTrack` in `onTrackStart()` to prevent stale state
- Synchronize `getContext()` and `getDj()` methods (or make `context` volatile)
- Add `TrackSchedulerTest.java` covering onTrackEnd behavior per end reason and stuck-track recovery delegation
### ✓ Step 4: Improve VoteSkipService robustness and add tests
VoteSkipService has documented concurrency behavior and full test coverage.
- Add documentation comment about the vote-then-check atomicity assumption
- Add `VoteSkipServiceTest.java` covering: threshold math, auto-skip below min members, vote accumulation, duplicate vote from same user, skip trigger when threshold exceeded
-91
View File
@@ -1,91 +0,0 @@
---
sessionId: session-260909-154047-1h6n
---
# Requirements
### Overview & Goals
Remove the singleton pattern from `Settings` so it becomes a plain instantiable class. All access should go through constructor injection of `SettingsProvider`, except for the single bootstrap site in `App.main()` which creates the instance.
### Scope
**In Scope:**
- Remove `static INSTANCE` field and `getInstance()` from `Settings`
- Remove static helper methods `getDiscordInvite()` and `getVersion()` from `Settings`
- Migrate all remaining `Settings.getInstance()` call sites to use injected `SettingsProvider`
- Update the test to construct `Settings` directly
**Out of Scope:**
- Changing the `SettingsProvider` interface
- Introducing a DI framework
- Changing how properties are loaded
# Technical Design
### Current Implementation
- `Settings` is a lazy singleton (`getInstance()`) implementing `SettingsProvider`
- `SettingsProvider` interface already exists and is injected into most classes via `ApplicationContext`
- `App.buildContext()` already registers the Settings instance as `SettingsProvider` in the context
- **Remaining direct singleton usages (9 call sites):**
- `App.java` — `VERSION` static field (line 51), `main()` bootstrap (lines 61-62), `startup()` (line 89), `buildContext()` (line 112), `initJDA()` (line 190)
- `CommandContext.java` — `isMessageFromCreator()` (line 134)
- `WebModule.java` — `createRequest()` (line 289) via `Settings.getVersion()`
- `ReportBug.java` — `executeTask()` (line 34) via `Settings.getDiscordInvite()`
- `SettingsTest.java` — `getMavenVersion()` test (line 17)
### Proposed Changes
#### 1. `Settings.java`
- Make constructor `public`
- Remove `static INSTANCE` field, `getInstance()`, `getDiscordInvite()`, `getVersion()`
- Keep `getMavenVersion()` as an instance method
- Add `getDiscordInvite()` as an instance method on `SettingsProvider` (or keep it as a convenience on `Settings`)
#### 2. `App.java`
- Create `Settings` via `new Settings()` at the top of `main()`; pass it into `App` constructor or store as a field
- Replace `VERSION` static field with an instance field initialized from the settings instance
- Thread the `SettingsProvider` through `startup()`, `buildContext()`, `initJDA()`
#### 3. `CommandContext.java`
- Add a `SettingsProvider` field, accept it via constructor parameter
- Update `isMessageFromCreator()` to use the injected provider
- Update subclasses (`MessageCommandContext`, `SlashCommandContext`, etc.) to pass it through
#### 4. `WebModule.java`
- Accept a version string or `SettingsProvider` in the `Builder`; replace `Settings.getVersion()` call
#### 5. `ReportBug.java`
- Accept `SettingsProvider` via constructor injection; replace `Settings.getDiscordInvite()` with `settings.get("NETNEUTRALITY_BOT_INVITE", ...)`
#### 6. `SettingsTest.java`
- Use `new Settings()` instead of `Settings.getInstance()`
### Risks
- `CommandContext` is abstract with multiple subclasses — all constructors need updating
- `WebModule.Builder` is used in many places including inside `Settings.getGitVersion()` itself — the version string approach (pass a `String` rather than the full provider) keeps this simpler
# Delivery Steps
### ✓ Step 1: Remove singleton from Settings class
Settings can be instantiated with `new Settings()` and no longer holds static state.
- Make the `Settings` constructor `public`
- Remove the `static INSTANCE` field and `getInstance()` method
- Remove static helpers `getDiscordInvite()` and `getVersion()`
- Move `getDiscordInvite()` logic to an instance method (or inline at call sites)
- Keep `getMavenVersion()` as-is (already instance method)
### ✓ Step 2: Migrate App.java bootstrap to explicit instantiation
App creates and threads a single Settings instance through its lifecycle methods.
- Create `new Settings()` at the start of `main()` and store as a field on `App`
- Replace `Settings.getInstance()` calls in `main()`, `startup()`, `buildContext()`, `initJDA()` with the field
- Replace the `VERSION` static field with an instance field or a local derived from the settings instance
### ✓ Step 3: Inject SettingsProvider into remaining call sites
CommandContext, WebModule, and ReportBug no longer reach for the singleton.
- Add `SettingsProvider` constructor parameter to `CommandContext`; update `isMessageFromCreator()` to use it
- Update `CommandContext` subclasses (`MessageCommandContext`, `SlashCommandContext`, etc.) to pass the provider through
- In `WebModule.Builder`, accept a version string parameter; replace `Settings.getVersion()` in `createRequest()`
- In `ReportBug`, add `SettingsProvider` constructor parameter; replace `Settings.getDiscordInvite()` with `settings.get(...)`
- Update `SettingsTest` to use `new Settings()` directly
-214
View File
@@ -1,214 +0,0 @@
---
sessionId: session-260905-145156-xyio
---
# Requirements
### Overview & Goals
Revise the package structure section of the lifecycle refactor plan to use a hybrid layout that keeps the new `bootstrap/` package but reverts the rest to the familiar old-style naming and organization.
### Scope
**In Scope**
- Reverting `framework/` → `core/` (engine: command, context, task machinery)
- Reverting `platform/` hierarchy → flat old-style packages: `db/` (persistence + entities + repositories + services), `hooks/` (events), `http/` (web API), `music/` (music domain)
- Reverting `feature/` vertical grouping → separate top-level `command/`, `tasks/`, `context/` packages with subcategory folders (e.g. `command/music/`, `tasks/scheduled/`)
- Restoring `utils/` for remaining helpers (Settings, JpaUtils, MusicUtils, ChannelMessagePurger)
- Keeping `bootstrap/` (ApplicationContext, Lifecycle, BotState, ReflectionHelper) — this is new and stays
- Keeping `exceptions/` — unchanged
- Keeping `shared/util/` (ContextUtils, GuildUtils, MessageFunction, MiscUtils, UserFunction)
**Out of Scope**
- Any behavioral or DI/lifecycle logic changes — this is purely a package restructure
- Changing the DI refactor plan's other sections (Lifecycle contract, ApplicationContext, constructor injection, interfaces)
### Target Layout
```
me.bobert.netneutrality
├── App.java
├── bootstrap/ # NEW — keep as-is
│ ├── ApplicationContext.java
│ ├── BotState.java
│ ├── Lifecycle.java
│ └── ReflectionHelper.java
├── core/ # was framework/ → revert to core/
│ ├── command/ # Command, CommandProcessor, CommandRegistry, CommandManager, options/, impl/
│ ├── context/ # Context hierarchy, abstract bases
│ └── tasks/ # Task, TaskManager, StepTask, PersistableTask, persistence/, trait/, impl/
├── command/ # concrete command implementations (was feature/*/command/)
│ ├── admin/
│ ├── basic/
│ ├── bot/
│ ├── crypto/
│ ├── custom/
│ ├── git/
│ ├── music/
│ └── react/
├── context/ # concrete context implementations (was framework/context/command/ + task/)
│ ├── command/
│ ├── tasks/
│ └── MusicChannelContext.java
├── tasks/ # concrete task implementations (was feature/*/task/)
│ ├── emoji/
│ ├── generic/
│ ├── git/
│ ├── help/
│ ├── music/
│ ├── reaction/
│ └── scheduled/
├── db/ # persistence model (was data/ + platform/persistence/)
│ ├── entities/ # O* entities
│ ├── repository/
│ ├── service/ # GuildService, GuildServiceProvider, BlacklistService, etc.
│ ├── GSettings.java
│ ├── Repository.java
│ ├── DatabaseMigration.java
│ ├── SessionManager.java # SessionProvider impl
│ └── JpaUtils.java
├── hooks/ # was platform/event/
│ └── EventsHandler.java
├── http/ # was platform/web/
│ ├── server/
│ └── WebModule.java
├── music/ # music domain (was feature/music/ minus commands/tasks)
│ ├── AudioPlayerSendHandler.java
│ ├── MusicManager.java
│ ├── MusicService.java
│ ├── QueueManager.java
│ ├── SourceFinder.java
│ ├── source/
│ ├── TrackInfo.java
│ ├── TrackLoadingHandler.java
│ └── TrackScheduler.java
├── utils/ # remaining helpers not in shared/util/
│ ├── Settings.java # SettingsProvider impl
│ ├── ChannelMessagePurger.java
│ ├── JpaUtils.java # (or in db/, see note)
│ └── MusicUtils.java
├── shared/util/ # NEW — keep as-is
│ ├── ContextUtils.java
│ ├── GuildUtils.java
│ ├── MessageFunction.java
│ ├── MiscUtils.java
│ └── UserFunction.java
├── exceptions/ # unchanged
└── core/thread/ OR thread/ # ThreadManager, ScheduledTaskManager, BaseThreadFactory
```
**Note:** `core/thread/` was the old location for threading. The old layout also had a top-level `thread/` for `MusicManagerCleanupThread`. Both can be restored as they were.
### Key Moves (current → proposed)
| Current (new) | Proposed (hybrid) |
|---|---|
| `framework/command/` | `core/command/` |
| `framework/context/` | `core/context/` |
| `framework/task/` | `core/tasks/` |
| `framework/NetNeutralityBot.java` | remove or merge into App |
| `platform/config/Settings.java` | `utils/Settings.java` |
| `platform/persistence/*` | `db/` (SessionManager, DatabaseMigration, JpaUtils, Repository) |
| `platform/concurrency/*` | `core/thread/` |
| `platform/logging/LogHandler.java` | `core/LogHandler.java` |
| `platform/event/EventsHandler.java` | `hooks/EventsHandler.java` |
| `platform/web/*` | `http/*` |
| `data/entity/` | `db/entities/` |
| `data/repository/` | `db/repository/` |
| `data/service/` | `db/service/` |
| `feature/music/command/*` | `command/music/` |
| `feature/music/*.java` (domain) | `music/` |
| `feature/moderation/command/*` | `command/admin/` |
| `feature/moderation/ChannelMessagePurger.java` | `utils/ChannelMessagePurger.java` |
| `feature/reaction/command/*` | `command/react/` |
| `feature/reaction/task/*` | `tasks/reaction/` |
| `feature/git/command/*` | `command/git/` |
| `feature/git/task/*` | `tasks/git/` |
| `feature/crypto/command/*` | `command/crypto/` |
| `feature/help/command/*` | `command/basic/` + `command/bot/` |
| `feature/help/task/*` | `tasks/help/` |
| `feature/custom/command/*` | `command/custom/` |
| `bootstrap/` | `bootstrap/` (no change) |
| `shared/util/` | `shared/util/` (no change) |
| `exceptions/` | `exceptions/` (no change) |
# Technical Design
### Current Implementation
The codebase currently uses the post-refactor layout with:
- `bootstrap/` — ApplicationContext, Lifecycle, BotState, ReflectionHelper
- `framework/` — command, context, task engine
- `platform/` — config, persistence, concurrency, logging, event, web
- `feature/` — music, moderation, reaction, git, crypto, help, custom (feature-vertical grouping)
- `data/` — entity, repository, service
- `shared/util/` — cross-cutting helpers
- `exceptions/`
All DI/lifecycle wiring (constructor injection, interfaces like `SettingsProvider`, `SessionProvider`, `ExecutorProvider`, `CommandRegistry`, `TaskManager`, `MusicService`, `GuildServiceProvider`) remains intact — only package locations change.
### Key Decisions
1. **Revert engine package name**: `framework/` → `core/` — matches the original naming the team is familiar with.
2. **Flatten infrastructure**: dissolve `platform/` hierarchy back to `db/`, `hooks/`, `http/`, `core/thread/`, `utils/` — removes the extra nesting that added cognitive overhead.
3. **Separate top-level command/tasks/context**: undo feature-vertical grouping in `feature/` — concrete implementations go back to `command/<category>/`, `tasks/<category>/`, `context/` as before.
4. **Keep bootstrap/ and shared/util/**: these are genuinely new and useful additions with no old equivalent.
5. **Restore `db/entities/`**: revert `data/entity/` back to `db/entities/` (plural, matching old convention).
6. **Service interfaces stay**: `SettingsProvider`, `SessionProvider`, `ExecutorProvider`, etc. remain — they just live in their reverted package locations (e.g. `SettingsProvider` in `utils/` alongside `Settings`).
### Proposed Changes
This is a pure move/rename refactor using IDE safe "Move Class" operations. No logic changes. The DI wiring, lifecycle priorities, constructor injection, and all interfaces remain exactly as they are — only their package declarations and import statements change.
### Risks
- **Import churn** — same risk as the original restructure; mitigated by IDE "Move Class" refactor, one subsystem at a time, build after each.
- **Interface co-location** — service interfaces (e.g. `SessionProvider`) now live alongside their impls in `db/` or `utils/` rather than in a separate `platform/` package. This is acceptable for this project's size.
# Delivery Steps
### ✓ Step 1: Move engine packages from framework/ back to core/
The `framework/` package is renamed back to `core/` with its original subpackage names.
- Move `framework/command/` → `core/command/` (all engine classes: Command, CommandProcessor, CommandRegistry, CommandManager, CommandCategory, CommandContainer, CommandExecutionResult, CommandOptionsBuilder, impl/, options/)
- Move `framework/context/` → `core/context/` (Context, AbstractContext, CommandContext, TaskContext, InteractionContext, MessageContext, NoMessageContext, AsyncContext)
- Move `framework/task/` → `core/tasks/` (Task, TaskManager interface + impl, StepTask, PersistableTask, BuiltInTask, ScheduledTask, TaskCleanup, persistence/, trait/, impl/, generic/)
- Move `framework/NetNeutralityBot.java` into `core/` or merge into App if it's just a shell
- Move `platform/logging/LogHandler.java` → `core/LogHandler.java`
- Move `platform/concurrency/*` → `core/thread/` (ThreadManager/ExecutorProvider, ScheduledTaskManager, BaseThreadFactory)
- Update all imports; verify build with `./mvnw compile`
### ✓ Step 2: Move persistence and infrastructure packages back to db/, hooks/, http/, utils/
Infrastructure packages are flattened back to their original top-level locations.
- Move `data/entity/` → `db/entities/` (restore plural naming)
- Move `data/repository/` → `db/repository/`
- Move `data/service/` → `db/service/` (GuildService, GuildServiceProvider, GuildSettingService, BlacklistService, TaskService)
- Move `data/GSettings.java` → `db/GSettings.java`
- Move `platform/persistence/` contents → `db/` (SessionManager/SessionProvider, DatabaseMigration, Repository, JpaUtils, SessionProvider interface)
- Move `platform/config/Settings.java` + `SettingsProvider` → `utils/`
- Move `platform/event/EventsHandler.java` → `hooks/EventsHandler.java`
- Move `platform/web/` → `http/` (WebApi, server/, WebModule)
- Move `feature/moderation/ChannelMessagePurger.java` → `utils/ChannelMessagePurger.java`
- Move `feature/music/MusicUtils.java` → `utils/MusicUtils.java` (if it exists)
- Delete empty `platform/` and `data/` directories
- Update all imports; verify build with `./mvnw compile`
### ✓ Step 3: Restore music domain package and split feature/ into command/, tasks/, context/
Feature-vertical grouping is dissolved back into separate top-level packages.
- Move `feature/music/` domain classes (MusicManager, MusicService, QueueManager, AudioPlayerSendHandler, SourceFinder, source/, TrackInfo, TrackLoadingHandler, TrackScheduler, MusicChannelContext) → `music/`
- Move concrete context classes from `framework/context/command/` → `context/command/` and `framework/context/task/` → `context/tasks/`; move MusicChannelContext → `context/MusicChannelContext.java`
- Move `feature/music/command/*` → `command/music/`
- Move `feature/moderation/command/*` → `command/admin/`
- Move `feature/reaction/command/*` → `command/react/`
- Move `feature/git/command/*` → `command/git/`
- Move `feature/crypto/command/*` → `command/crypto/`
- Move `feature/help/command/*` → `command/basic/` and `command/bot/` (matching old categorization)
- Move `feature/custom/command/*` → `command/custom/`
- Move `feature/reaction/task/*` → `tasks/reaction/`
- Move `feature/git/task/*` → `tasks/git/`
- Move `feature/help/task/*` → `tasks/help/`
- Move `feature/music/task/*` → `tasks/music/`
- Move scheduled tasks → `tasks/scheduled/`
- Delete empty `feature/` directory
- Update all imports; verify full build with `./mvnw package`
- Update `docs/architecture.md` package map table to reflect the new hybrid layout
@@ -1,99 +0,0 @@
---
sessionId: session-260910-054543-zckm
---
# Requirements
### Overview & Goals
Analyze and fix thread-safety issues and improve the `ExecutorProvider` / `ThreadManager` infrastructure and all code that interacts with it.
### Scope
#### In Scope
- Fix thread-safety bugs in `ThreadManager`, `TaskManagerImpl`, `ChannelMessagePurger`, `MusicManager`
- Improve executor pool configuration (bounded pools, proper shutdown)
- Clean up inconsistent synchronization patterns
#### Out of Scope
- Rewriting the task/command architecture
- Changing the `QueueManager` synchronization (it's consistent and correct, just verbose)
# Technical Design
### Current Issues Found
#### 1. `ThreadManager` (`core/thread/ThreadManager.java`)
- **Unbounded cached thread pools** — `newCachedThreadPool()` creates unlimited threads; under load (e.g., many guilds purging simultaneously) this can exhaust memory.
- **Shutdown bug (line 66-69)** — If `awaitTermination` throws `InterruptedException` on the *first* executor, the `catch` block exits the loop and remaining executors only get `shutdownNow()` without a prior `shutdown()` call. Each executor should be shut down independently.
- **No daemon threads** — `BaseThreadFactory` doesn't set `thread.setDaemon(true)`, so leaked threads can prevent JVM exit.
#### 2. `TaskManagerImpl` (`core/tasks/TaskManagerImpl.java`)
- **Null values in `ConcurrentHashMap` (line 178-184)** — `computeIfAbsent` returns `null` for `PersistableTask`/`BuiltInTask`, but `ConcurrentHashMap` does **not** permit null values. This will throw `NullPointerException` at runtime.
- **Mixed synchronization** — `getTasks()` is `synchronized` on `this`, but all mutations use `ConcurrentHashMap` operations (no `synchronized`). The `synchronized` keyword on `getTasks()` provides no actual protection since `insert()`, `removeTask()`, etc. don't synchronize on `this`.
- **`hasTask()` race (line 93-95)** — Check-then-act across two collections (`tasks` and `pendingTasks`) without synchronization.
- **`handle()` lock cleanup race (line 191-194)** — After acquiring the lock, it checks `tasks.containsKey()` and removes from `tasksLock`, but another thread could be waiting on the same lock and will find it removed.
#### 3. `ChannelMessagePurger` (`utils/ChannelMessagePurger.java`)
- **Double unlock bug** — `purgeMessages()` acquires `lock` via `tryLock()` (line 68) and releases it in `finally` (line 79). But `safePurge()` also calls `lock.unlock()` (line 154), causing an `IllegalMonitorStateException` or releasing a lock that a *different* thread now holds.
- **Non-thread-safe static `HashMap`** (line 40) — `instances` is a plain `HashMap` accessed from multiple threads via `computeIfAbsent`. Should be `ConcurrentHashMap`.
- **Static mutable `ExecutorProvider` binding** — The `volatile` field + `bind()` pattern works but is fragile; a null check race exists between `canLockBeAcquired` callers and `purgeMessages`.
#### 4. `MusicManager` (`music/MusicManager.java`)
- **Inconsistent synchronization** — `isConnected()`, `connect()`, `disconnect()`, `play()` are `synchronized`, but `isCurrentlyPlaying()` (line 114), `getCurrentlyPlaying()` (line 110), `stopTrack()` (line 138), `resume()` (line 142) are not. If these are called from event threads concurrently with `disconnect()`, they can see stale state.
#### 5. `BaseThreadFactory` (`core/thread/BaseThreadFactory.java`)
- Threads are not set as daemon threads, which can prevent clean JVM shutdown if an executor leaks.
### Proposed Changes
#### `ThreadManager`
- Replace `newCachedThreadPool()` with `newFixedThreadPool()` or a bounded `ThreadPoolExecutor` with a reasonable max (e.g., `availableProcessors() * 2`).
- Fix shutdown loop: wrap each executor's `shutdown()`/`awaitTermination()` in its own try-catch so one failure doesn't skip the rest.
- Consider adding a `getExecutorStatus()` method for observability.
#### `BaseThreadFactory`
- Set `thread.setDaemon(true)` to prevent JVM hang on unclean shutdown.
#### `TaskManagerImpl`
- Fix `computeIfAbsent` null return: use a sentinel or simply don't store an entry for tasks that don't need locking (use `get()` + null check instead).
- Remove misleading `synchronized` on `getTasks()` or make all task mutations consistently synchronized.
- Guard `hasTask()` with proper synchronization or accept eventual consistency and document it.
- Fix lock cleanup race in `handle()` — don't remove from `tasksLock` inside the lock-held section; let `removeTask()` handle cleanup.
#### `ChannelMessagePurger`
- Remove the `lock.unlock()` call on line 154 inside `safePurge()` — the `finally` block in `purgeMessages()` already handles it.
- Change `instances` from `HashMap` to `ConcurrentHashMap`.
#### `MusicManager`
- Either synchronize all public methods that touch `player`/`AudioManager` state, or remove `synchronized` from methods where JDA already guarantees thread safety and document the threading model.
# Delivery Steps
### ✓ Step 1: Fix ThreadManager and BaseThreadFactory
ThreadManager uses bounded pools and has correct shutdown; BaseThreadFactory produces daemon threads.
- Replace `Executors.newCachedThreadPool()` in `ThreadManager.getExecutor()` with a bounded `ThreadPoolExecutor` (core=0, max=availableProcessors*2, 60s keepAlive, LinkedBlockingQueue).
- Fix `stop()` shutdown loop: wrap each executor's `shutdown()`+`awaitTermination()` in its own try-catch so an `InterruptedException` on one executor doesn't skip the rest.
- In `BaseThreadFactory.newThread()`, add `thread.setDaemon(true)`.
- Verify the `getScheduledExecutor()` pool size is still appropriate.
### ✓ Step 2: Fix TaskManagerImpl thread-safety bugs
TaskManagerImpl has no null-in-ConcurrentHashMap violations and consistent synchronization.
- Replace the `computeIfAbsent` that returns null (line 178-184) with a pattern that avoids storing null: check task type first, only call `computeIfAbsent` when a lock is actually needed.
- Remove the misleading `synchronized` keyword from `getTasks()`, or switch to a consistent locking strategy across all mutation methods.
- Fix the lock cleanup race in `handle()`: do not remove from `tasksLock` at line 193; let `removeTask()` be the sole owner of lock cleanup.
- Document the threading contract for `hasTask()` (check-then-act across two collections is inherently racy).
### ✓ Step 3: Fix ChannelMessagePurger concurrency bugs
ChannelMessagePurger has no double-unlock and uses thread-safe collections.
- Remove the redundant `lock.unlock()` call on line 154 in `safePurge()` — the `finally` block in `purgeMessages()` (line 79) already releases the lock.
- Change the static `instances` field (line 40) from `HashMap` to `ConcurrentHashMap`.
- Remove the `synchronized` keyword from `countAllChannelMessages()` and `safePurge()` since the `ReentrantLock` already provides mutual exclusion.
### ✓ Step 4: Harmonize MusicManager synchronization
MusicManager has a consistent and documented threading model.
- Add `synchronized` to `isCurrentlyPlaying()`, `getCurrentlyPlaying()`, `stopTrack()`, and `resume()` to match the existing pattern on `connect()`/`disconnect()`/`play()`.
- Alternatively, if JDA's `AudioPlayer` is already thread-safe, remove `synchronized` from all methods and add a class-level Javadoc explaining the threading model.
- Investigate which approach is correct by checking Lavaplayer's `AudioPlayer` thread-safety guarantees, then apply consistently.
+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 |
+4 -6
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
@@ -140,14 +139,14 @@ block the others. Effectively this means:
## Package map ## Package map
| Package | Contents | | Package | Contents |
|---|---| |---------------------------------------------------|---------------------------------------------------------------------------------------------------------|
| `me.bobert.netneutrality` | Entry point (`App`) | | `me.bobert.netneutrality` | Entry point (`App`) |
| `…bootstrap` | `ApplicationContext`, `Lifecycle`, `BotState`, `ReflectionHelper` | | `…bootstrap` | `ApplicationContext`, `Lifecycle`, `BotState`, `ReflectionHelper` |
| `…core.command` | Command engine (registry, processor, manager, options, impl bases) | | `…core.command` | Command engine (registry, processor, manager, options, impl bases) |
| `…core.context` | Context interfaces, abstract bases (`CommandContext`, `TaskContext`, etc.) | | `…core.context` | Context interfaces, abstract bases (`CommandContext`, `TaskContext`, etc.) |
| `…core.tasks` | Task engine (manager, subtypes, traits, persistence, generic buttons) | | `…core.tasks` | Task engine (manager, subtypes, traits, persistence, generic buttons) |
| `…core.thread` | Executors & scheduling (`ThreadManager`, `ScheduledTaskManager`, `BaseThreadFactory`) | | `…core.thread` | Executors & scheduling (`ThreadManager`, `ScheduledTaskManager`, `BaseThreadFactory`) |
| `…core` | `LogHandler`, `NetNeutralityBot` | | `…core` | `LogHandler` |
| `…command.*` | Concrete command implementations (`admin`, `basic`, `bot`, `crypto`, `custom`, `git`, `music`, `react`) | | `…command.*` | Concrete command implementations (`admin`, `basic`, `bot`, `crypto`, `custom`, `git`, `music`, `react`) |
| `…context.command` / `…context.tasks` | Concrete context implementations (slash, message, button, modal, emoji, etc.) | | `…context.command` / `…context.tasks` | Concrete context implementations (slash, message, button, modal, emoji, etc.) |
| `…context` | `MusicChannelContext` | | `…context` | `MusicChannelContext` |
@@ -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).
+12 -7
View File
@@ -1,8 +1,9 @@
# 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`.
## Stack ## Stack
@@ -44,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 |
@@ -55,7 +56,7 @@ of a star-shaped model; most other tables reference `guild_id` with `ON DELETE C
| `OLogging` | `logging` | `OLoggingId` (composite) | Composite key `guild_id` + `channel_id`; `@MapsId` to `OGuild`; debug/error log channel config. | | `OLogging` | `logging` | `OLoggingId` (composite) | Composite key `guild_id` + `channel_id`; `@MapsId` to `OGuild`; debug/error log channel config. |
| `OLoggingId` | — (embeddable) | — | `@Embeddable`/`@IdClass` for the composite key. | | `OLoggingId` | — (embeddable) | — | `@Embeddable`/`@IdClass` for the composite key. |
| `OLogMessage` | `log_message` | `id` (auto int) | Many-to-one `OGuild`; message logging history. | | `OLogMessage` | `log_message` | `id` (auto int) | Many-to-one `OGuild`; message logging history. |
| `OTask` | `tasks` | `id` (auto int) | [Persistable task](tasks.md#31-persistable-tasks) state; unique `task_id`, class name, JSON `payload`. | | `OTask` | `tasks` | `id` (auto int) | [Persistable task](tasks.md#31-persistable-tasks) state; unique `task_id`, `task_class` holds the logical `typeId`, JSON `payload`. |
| `OWebUser` | `web_users` | `id` (String) | Web dashboard user (tokens/expiry). *Present in schema & code but not listed in `hibernate.cfg.xml`.* | | `OWebUser` | `web_users` | `id` (String) | Web dashboard user (tokens/expiry). *Present in schema & code but not listed in `hibernate.cfg.xml`.* |
### ER overview ### ER overview
@@ -102,8 +103,9 @@ Services wrap repositories with business logic and **caching**.
values in `settingsCache` and reloads on change; used by the values in `settingsCache` and reloads on change; used by the
[web API settings endpoints](web-api.md). [web API settings endpoints](web-api.md).
- **`TaskService`** — serializes [`PersistableTask`](tasks.md#31-persistable-tasks) instances to JSON - **`TaskService`** — serializes [`PersistableTask`](tasks.md#31-persistable-tasks) instances to JSON
for the `tasks` table and reconstructs them at startup via for the `tasks` table, keyed by the task's stable `typeId()` in `task_class`, and reconstructs them
[`TaskRegistry`/`TaskFactory`](tasks.md#31-persistable-tasks). at startup via [`PersistableTaskRegistry`](tasks.md#persistabletaskregistry--discovery-and-restore).
Rows with an unknown `task_class` are logged and skipped.
## GSettings ## GSettings
@@ -125,4 +127,7 @@ role/channel and how `ConfigureCommand` persists changes.
The initial migration creates the tables backing the entities above (`guilds`, `guild_settings`, The initial migration creates the tables backing the entities above (`guilds`, `guild_settings`,
`blacklist`, `logging`, `log_message`, `tasks`, `web_users`), with `guild_id` foreign keys and `blacklist`, `logging`, `log_message`, `tasks`, `web_users`), with `guild_id` foreign keys and
`ON DELETE CASCADE` from the guild-scoped tables to `guilds`. Subsequent schema changes should be `ON DELETE CASCADE` from the guild-scoped tables to `guilds`. Subsequent schema changes should be
added as new `V2__…`, `V3__…` Flyway scripts in the same directory. added as new `V3__…`, `V4__…` Flyway scripts in the same directory.
`V2__task_type_id.sql` rewrites `tasks.task_class` from the old fully-qualified class names to the
stable logical `typeId` (e.g. `emoji-reaction-role`), so previously persisted rows remain loadable.
@@ -1,43 +0,0 @@
# Udio source — follow-up reference
These files are **not part of the build**. They are the original client-side Lavaplayer
implementation of the udio.com source, kept verbatim as the reference for a future port.
| File | Was |
|---|---|
| `UdioAudioSourceManager.java` | `me.bobert.netneutrality.music.source.UdioAudioSourceManager` |
| `UdioAudioTrack.java` | `me.bobert.netneutrality.music.source.UdioAudioTrack` |
| `UdioAudioSourceManagerTest.java` | its unit test |
## Why they were moved
The bot no longer decodes audio itself: after the [migration to Lavalink](../../music.md), source
resolution happens on the node. A Lavaplayer `AudioSourceManager` living in the bot process can
never be used by Lavalink, and these classes cannot even compile now that the `dev.arbjerg:lavaplayer`
dependency has been dropped from `pom.xml`.
## Status
**Udio playback is disabled.** `SourceFinder` still classifies Udio URLs, but
`SourceFinder.getIdentifier` returns no identifier for them and `MusicManager.play` replies that
Udio is temporarily unsupported.
## Follow-up
Port `UdioAudioSourceManager` / `UdioAudioTrack` into a standalone **Lavalink server plugin**
(its own build and published artifact), then enable it by adding the dependency to
`lavalink/application.yml`:
```yaml
lavalink:
plugins:
- dependency: "<group>:udio-plugin:<version>"
```
Once the node resolves `https://www.udio.com/songs/...` identifiers, the only bot-side change is to
restore `Udio` in the `SourceFinder.getIdentifier` switch and drop the "temporarily unsupported"
reply in `MusicManager.play`.
The scraping logic itself still applies: the manager extracts metadata and a direct playback URL
from the Next.js payload (`self.__next_f.push(...)`) of the track page, and the URL must be
re-fetched right before playback because it expires.
@@ -1,237 +0,0 @@
package me.bobert.netneutrality.music.source;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager;
import com.sedmelluq.discord.lavaplayer.source.AudioSourceManager;
import com.sedmelluq.discord.lavaplayer.tools.ExceptionTools;
import com.sedmelluq.discord.lavaplayer.tools.FriendlyException;
import com.sedmelluq.discord.lavaplayer.tools.JsonBrowser;
import com.sedmelluq.discord.lavaplayer.tools.io.HttpClientTools;
import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterface;
import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterfaceManager;
import com.sedmelluq.discord.lavaplayer.track.AudioItem;
import com.sedmelluq.discord.lavaplayer.track.AudioReference;
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo;
import org.apache.commons.io.IOUtils;
import org.apache.http.HttpStatus;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.io.DataInput;
import java.io.DataOutput;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static com.sedmelluq.discord.lavaplayer.tools.FriendlyException.Severity.SUSPICIOUS;
public class UdioAudioSourceManager implements AudioSourceManager {
private static final Logger log = LogManager.getLogger(UdioAudioSourceManager.class);
private static final String TRACK_URL_REGEX = "^https://www.udio.com/songs/[0-9A-Za-z]+$";
private static final Pattern trackUrlPattern = Pattern.compile(TRACK_URL_REGEX);
private static final Pattern NEXT_PUSH_PATTERN = Pattern.compile(
"self\\.__next_f\\.push\\(\\[(\\d+),\"(.*?)\"]\\)",
Pattern.DOTALL
);
private final HttpInterfaceManager httpInterfaceManager;
public UdioAudioSourceManager() {
httpInterfaceManager = HttpClientTools.createDefaultThreadLocalManager();
}
@Override
public String getSourceName() {
return "udio";
}
@Override
public AudioItem loadItem(AudioPlayerManager manager, AudioReference reference) {
if (!trackUrlPattern.matcher(reference.identifier).matches()) {
return null;
}
try (HttpInterface httpInterface = httpInterfaceManager.getInterface()) {
return loadFromTrackPage(httpInterface, reference.identifier);
} catch (IOException e) {
throw new FriendlyException("Loading Udio track information failed.", SUSPICIOUS, e);
}
}
@Override
public boolean isTrackEncodable(AudioTrack track) {
return true;
}
@Override
public void encodeTrack(AudioTrack track, DataOutput output) {
}
@Override
public AudioTrack decodeTrack(AudioTrackInfo trackInfo, DataInput input) {
return new UdioAudioTrack(trackInfo, this);
}
@Override
public void shutdown() {
ExceptionTools.closeWithWarnings(httpInterfaceManager);
}
public HttpInterface getHttpInterface() {
return httpInterfaceManager.getInterface();
}
private AudioItem loadFromTrackPage(HttpInterface httpInterface, String trackUrl) throws IOException {
try (CloseableHttpResponse response = httpInterface.execute(new HttpGet(trackUrl))) {
int statusCode = response.getStatusLine().getStatusCode();
if (statusCode == HttpStatus.SC_NOT_FOUND) {
return AudioReference.NO_TRACK;
} else if (!HttpClientTools.isSuccessWithContent(statusCode)) {
throw new FriendlyException("Server responded with an error.", SUSPICIOUS,
new IllegalStateException("Response code is " + statusCode));
}
return loadTrackFromPageContent(trackUrl, IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8));
}
}
private AudioTrack loadTrackFromPageContent(String trackUrl, String content) throws IOException {
JsonBrowser trackInfo = loadTrackInfoFromPageContent(content);
if (trackInfo == null) {
log.debug("Udio HTML: {}", content);
throw new FriendlyException("Udio: Failed to extract track information from web page.", SUSPICIOUS, null);
}
String title = trackInfo.get("title").text();
String artist = trackInfo.get("artist").text();
long duration = (long) (trackInfo.get("duration").as(Double.class) * 1000.0);
String playbackUrl = trackInfo.get("song_path").text();
String artworkUrl = getArtworkUrlFromImagePath(trackInfo);
AudioTrackInfo audioTrackInfo = new AudioTrackInfo(title, artist, duration, trackUrl, false, playbackUrl, artworkUrl, null);
return new UdioAudioTrack(audioTrackInfo, this);
}
protected JsonBrowser loadTrackInfoFromPageContent(String content) throws IOException {
Matcher matcher = NEXT_PUSH_PATTERN.matcher(content);
while (matcher.find()) {
String payload = matcher.group(2);
JsonBrowser trackInfo = tryExtractTrackFromNextChunk(payload);
if (trackInfo != null) {
return trackInfo;
}
}
return null;
}
private JsonBrowser tryExtractTrackFromNextChunk(String payload) {
try {
int trackKeyIndex = payload.indexOf("\\\"track\\\":{");
if (trackKeyIndex < 0) {
return null;
}
int objectStart = payload.indexOf('{', trackKeyIndex);
if (objectStart < 0) {
return null;
}
String escapedTrackJson = extractJsonObjectFromString(payload, objectStart);
if (escapedTrackJson == null) {
return null;
}
String trackJson = unescapeJsonStringFragment(escapedTrackJson);
JsonBrowser track = JsonBrowser.parse(trackJson);
if (isValidTrack(track)) {
return track;
}
} catch (Exception e) {
log.debug("Skipping unparsable track payload fragment", e);
}
return null;
}
/**
* Extracts a JSON object from a given string starting at the specified index. This method parses the string
* to locate and retrieve the JSON object, ensuring proper handling of nested structures and escaped characters.
*
* @param text The input string containing the JSON object to be extracted.
* @param startIndex The index at which to start searching for the JSON object within the input string.
* @return The extracted JSON object as a string, or {@code null} if no valid JSON object is found.
*/
private String extractJsonObjectFromString(String text, int startIndex) {
boolean escaped = false;
boolean inString = false;
int depth = 0;
for (int i = startIndex; i < text.length(); i++) {
char c = text.charAt(i);
if (escaped) {
escaped = false;
continue;
}
if (c == '\\') {
escaped = true;
continue;
}
if (c == '"') {
inString = !inString;
continue;
}
if (inString) {
continue;
}
if (c == '{') {
depth++;
} else if (c == '}') {
depth--;
if (depth == 0) {
return text.substring(startIndex, i + 1);
}
}
}
return null;
}
private String unescapeJsonStringFragment(String value) {
return value.replace("\\\"", "\"").replace("\\\\", "\\");
}
private boolean isValidTrack(JsonBrowser track) {
return track != null
&& !track.isNull()
&& !track.get("title").isNull()
&& !track.get("artist").isNull()
&& !track.get("duration").isNull()
&& !track.get("song_path").isNull()
&& !track.get("image_path").isNull();
}
private String getArtworkUrlFromImagePath(JsonBrowser trackInfo) {
String imagePath = trackInfo.get("image_path").text();
String artworkOptions = "width=3840,quality=75";
return imagePath.replace("public", artworkOptions);
}
}
@@ -1,39 +0,0 @@
package me.bobert.netneutrality.music.source;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import com.sedmelluq.discord.lavaplayer.track.AudioItem;
import com.sedmelluq.discord.lavaplayer.track.AudioReference;
import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
public class UdioAudioSourceManagerTest {
@Test
public void loadItem() {
AudioReference reference = new AudioReference("https://www.udio.com/songs/eNmZxqpMt9MRrjFUgYDgyi", null);
UdioAudioSourceManager udioAudioSourceManager = new UdioAudioSourceManager();
AudioItem loaded = udioAudioSourceManager.loadItem(null, reference);
assertNotNull(loaded);
UdioAudioTrack audioTrack = (UdioAudioTrack) loaded;
AudioTrackInfo info = audioTrack.getInfo();
assertNotNull(info);
assertNotNull(info.artworkUrl);
String artworkOptions = "width=3840,quality=75";
assertTrue(info.artworkUrl.contains(artworkOptions));
}
}
@@ -1,80 +0,0 @@
package me.bobert.netneutrality.music.source;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import com.sedmelluq.discord.lavaplayer.container.mp3.Mp3AudioTrack;
import com.sedmelluq.discord.lavaplayer.tools.FriendlyException;
import com.sedmelluq.discord.lavaplayer.tools.JsonBrowser;
import com.sedmelluq.discord.lavaplayer.tools.io.HttpClientTools;
import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterface;
import com.sedmelluq.discord.lavaplayer.tools.io.PersistentHttpStream;
import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo;
import com.sedmelluq.discord.lavaplayer.track.DelegatedAudioTrack;
import com.sedmelluq.discord.lavaplayer.track.playback.LocalAudioTrackExecutor;
import org.apache.commons.io.IOUtils;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.io.IOException;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import static com.sedmelluq.discord.lavaplayer.tools.FriendlyException.Severity.SUSPICIOUS;
public class UdioAudioTrack extends DelegatedAudioTrack {
private static final Logger log = LogManager.getLogger(UdioAudioTrack.class);
private final UdioAudioSourceManager sourceManager;
/**
* @param trackInfo Track info
* @param sourceManager Source manager which was used to find this track
*/
public UdioAudioTrack(AudioTrackInfo trackInfo, UdioAudioSourceManager sourceManager) {
super(trackInfo);
this.sourceManager = sourceManager;
}
@Override
public void process(LocalAudioTrackExecutor localExecutor) throws Exception {
try (HttpInterface httpInterface = sourceManager.getHttpInterface()) {
String playbackUrl = loadPlaybackUrl(httpInterface);
log.debug("Starting Udio track from URL: {}", playbackUrl);
try (PersistentHttpStream stream = new PersistentHttpStream(httpInterface, new URI(playbackUrl), null)) {
processDelegate(new Mp3AudioTrack(trackInfo, stream), localExecutor);
}
}
}
private String loadPlaybackUrl(HttpInterface httpInterface) throws IOException {
JsonBrowser trackInfo = loadTrackInfo(httpInterface);
if (trackInfo == null) {
throw new FriendlyException("Udio: Failed to extract track information from web page.", SUSPICIOUS, null);
}
return trackInfo.get("song_path").text();
}
private JsonBrowser loadTrackInfo(HttpInterface httpInterface) throws IOException {
try (CloseableHttpResponse response = httpInterface.execute(new HttpGet(trackInfo.identifier))) {
int statusCode = response.getStatusLine().getStatusCode();
if (!HttpClientTools.isSuccessWithContent(statusCode)) {
throw new FriendlyException("Server responded with an error.", SUSPICIOUS,
new IllegalStateException("Response code for player config is " + statusCode));
}
return sourceManager.loadTrackInfoFromPageContent(IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8));
}
}
}
+52 -77
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`
@@ -111,94 +111,70 @@ under `tasks/generic`).
## 3.1 Persistable tasks ## 3.1 Persistable tasks
**Classes:** `PersistableTask` (interface), `AbstractPersistableTask`, **Classes:** `PersistableTask` (interface), `AbstractPersistableTask`,
`core.tasks.persistence.TaskRegistry`, `core.tasks.persistence.TaskFactory`; `core.tasks.persistence.PersistableTaskRegistry`; entity
resource `src/main/resources/persistable-tasks.json`; entity
[`OTask`](persistence.md#entity-catalog). [`OTask`](persistence.md#entity-catalog).
Persistable tasks survive restarts: they are serialized to the database and rebuilt on startup. The Persistable tasks survive restarts: they are serialized to the database and rebuilt on startup.
rules for *how* a persistable task can be defined and reconstructed live in `TaskRegistry` and There is **no mapping file** — task types are discovered on the classpath and each task knows how to
`TaskFactory`. rebuild itself.
### The mapping file — `persistable-tasks.json` ### The contract
The file declares, for each persistable task class, the constructor parameters and their types:
```json
{
"tasks": [
{
"class": "me.bobert.netneutrality.feature.reaction.task.EmojiReactionRoleTask",
"params": ["guildId", "channelId", "messageId", "emojiMapping"],
"types": ["string", "string", "string", "map"]
}
]
}
```
Schema:
| Key | Meaning |
|----------|------------------------------------------------------------------|
| `tasks` | array of task mappings |
| `class` | fully-qualified class name; **must** implement `PersistableTask` |
| `params` | ordered list of payload field names |
| `types` | parallel list of types for those fields |
Supported types (`TaskFactory.ParamType`): `string` → `String`, `int` → `Integer`, `map` → `Map`.
### `TaskRegistry` — loading the mappings
`TaskRegistry.loadTaskConfig()` reads `persistable-tasks.json` from the classpath and, for each entry,
registers a `TaskCreator` (a functional interface `PersistableTask fromPayload(JSONObject payload)`)
keyed by class name:
```java ```java
taskCreators.put(className, payload -> { public interface PersistableTask extends Task {
try { default String typeId() { return getName(); } // stable logical id stored in the DB
return TaskFactory.createTaskInstance(className, params, types, payload); void restore(JSONObject payload); // rebuild state from a previous toPayload()
} catch (Exception e) { void insert(CommandContext context);
logger.error("Failed to create instance of '{}': {}", className, e.getMessage()); void save();
return null; void delete();
String toPayload();
} }
});
``` ```
Entries missing `class`, `params`, or `types` are logged and skipped. Consumers later look up a - **`typeId()`** is the value written to `tasks.task_class`. It defaults to `getName()`, so a package
creator via `TaskRegistry.getTaskCreator(className)`. move or class rename never breaks restoring existing rows.
- **`restore(JSONObject)`** is the counterpart of `toPayload()`. `AbstractPersistableTask` provides a
no-op default, so only tasks that carry extra state override it.
- **`toPayload()`** serializes the bean getters (`new JSONObject(this)`) and always includes `id` and
`lockId`, which `TaskService.validateTask` verifies against the stored row.
### `TaskFactory` — reconstructing an instance ### `PersistableTaskRegistry` — discovery and restore
`TaskFactory.createTaskInstance(className, params, types, payload)`: `PersistableTaskRegistry.scan()` runs once during `TaskManagerImpl.init()` and uses the same
`ReflectionHelper.loadTasks(..., ApplicationContext, ...)` discovery used for
[built-in](#32-built-in-tasks) and [scheduled](#34-scheduled-tasks) tasks: every concrete
`PersistableTask` under `me.bobert.netneutrality` is instantiated once and indexed by its `typeId()`.
Two types declaring the same id fail fast at startup with both class names in the message.
1. Validates that `params.length == types.length`. `create(typeId, payload)` then produces a **fresh** instance per row:
2. Loads the class and verifies it implements `PersistableTask`.
3. For each declared parameter, converts the JSON `types[i]` to a `ParamType`, then extracts
`payload[params[i]]` as the corresponding Java type:
- `string` → `payload.getString(name)`
- `int` → `payload.getInt(name)`
- `map` → `payload.getJSONObject(name).toMap()`
4. Reflectively resolves the constructor whose parameter types match `types` and instantiates it with
the extracted values.
```java ```java
return (PersistableTask) clazz.getConstructor(classTypes).newInstance(classParams); PersistableTask task = ReflectionHelper.instantiate(type, context);
task.restore(payload);
``` ```
> **Rule of thumb for adding a persistable task:** create a class that implements `PersistableTask` Because instantiation goes through the DI context, a restored task needs a constructor whose
> (usually by extending `AbstractPersistableTask`), give it a constructor whose parameters match the parameters are all resolvable from the `ApplicationContext` — in practice `(TaskManager)`. Fields
> `params`/`types` you declare, ensure it serializes those same fields into its JSON payload, and add that only exist in the payload (ids, mappings) are assigned by `restore()` before the instance is
> a matching entry to `persistable-tasks.json`. The payload is stored in the handed to the `TaskManager`.
> [`OTask.payload`](persistence.md#entity-catalog) column and reconstructed via the flow above on
> startup. > **Adding a persistable task:** write **one class** extending `AbstractPersistableTask` (or
> `EmojiTask`), give it a `(TaskManager)` constructor for the registry plus whatever constructors the
> feature code needs, and override `restore()` if it holds state beyond the bean getters. Nothing
> else has to be registered — the scan picks it up and `getName()` becomes its `typeId()`.
### Persistence round-trip ### Persistence round-trip
``` ```
insert(task) ──▶ TaskService serializes payload ──▶ OTask row (task_id, class, payload JSON) insert(task) ──▶ TaskService.persistTask ──▶ OTask row (task_id, task_class = typeId, payload JSON)
startup ──▶ TaskService reads OTask rows ──▶ TaskRegistry.getTaskCreator(class) startup ──▶ PersistableTaskRegistry.scan() (typeId ──▶ class)
.fromPayload(payload) ──▶ TaskFactory ──▶ instance ──▶ TaskService reads OTask rows ──▶ registry.create(task_class, payload)
──▶ DI instantiate ──▶ task.restore(payload)
``` ```
Rows whose `task_class` is not a registered `typeId` are logged and skipped; the remaining rows still
load.
## 3.2 Built-in tasks ## 3.2 Built-in tasks
**Classes:** `BuiltInTask`, `AbstractBuiltInTask`. **Classes:** `BuiltInTask`, `AbstractBuiltInTask`.
@@ -273,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. |
@@ -296,15 +272,14 @@ Tasks can opt into extra behavior by implementing marker/behavior interfaces in
`EmojiReactionRoleTask`. `EmojiReactionRoleTask`.
4. That `EmojiReactionRoleTask` is `insert`ed into `TaskManager` — because it is a 4. That `EmojiReactionRoleTask` is `insert`ed into `TaskManager` — because it is a
[persistable task](#31-persistable-tasks) it is written to the `tasks` table and will be restored [persistable task](#31-persistable-tasks) it is written to the `tasks` table and will be restored
on the next startup via the [`persistable-tasks.json`](#the-mapping-file--persistable-tasksjson) on the next startup via the
mapping. [`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`
+16 -17
View File
@@ -8,12 +8,11 @@ error tracking, the shared utility helpers, and the custom exception hierarchy.
**Package:** `me.bobert.netneutrality` / `me.bobert.netneutrality.bootstrap` **Package:** `me.bobert.netneutrality` / `me.bobert.netneutrality.bootstrap`
| Class | Role | | Class | Role |
|---|---| |--------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `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()`. |
| `NetNeutralityBot` | Currently an **empty placeholder** class under `framework` (reserved name; no logic yet). | | `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. |
| `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. |
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,14 +36,14 @@ resolve via `context.get(type)` is preferred; otherwise the no-arg constructor i
**Interfaces used for injection** (implemented by the concrete managers registered above): **Interfaces used for injection** (implemented by the concrete managers registered above):
| 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:
@@ -61,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).
@@ -76,7 +75,7 @@ file and overlays **environment variables** on top (environment variables win).
An example configuration file is `bot.properties.example`. Key configuration values: An example configuration file is `bot.properties.example`. Key configuration values:
| Key | Purpose | | Key | Purpose |
|---|---| |------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------|
| `NETNEUTRALITY_BOT_ENV` | `dev` / `prod`; controls debug logging and the Sentry environment. | | `NETNEUTRALITY_BOT_ENV` | `dev` / `prod`; controls debug logging and the Sentry environment. |
| `NETNEUTRALITY_BOT_TOKEN` | Discord bot token (**required**). | | `NETNEUTRALITY_BOT_TOKEN` | Discord bot token (**required**). |
| `NETNEUTRALITY_BOT_PREFIX` | Message-command prefix (default `$`). | | `NETNEUTRALITY_BOT_PREFIX` | Message-command prefix (default `$`). |
@@ -85,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 |
|---|---| |---|---|
@@ -126,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 -30
View File
@@ -1,51 +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; `/actions/settings/current/{guildId}` — current guild settings; `/actions/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.
+79 -39
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">
@@ -18,6 +18,7 @@
<maven.compiler.source>25</maven.compiler.source> <maven.compiler.source>25</maven.compiler.source>
<maven.compiler.target>25</maven.compiler.target> <maven.compiler.target>25</maven.compiler.target>
<mockito.version>5.23.0</mockito.version> <mockito.version>5.23.0</mockito.version>
<testcontainers.version>2.0.5</testcontainers.version>
</properties> </properties>
<build> <build>
@@ -29,6 +30,27 @@
</resources> </resources>
<plugins> <plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.14.1</version>
<configuration>
<release>${java.version}</release>
<proc>full</proc>
<annotationProcessorPaths>
<path>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-processor</artifactId>
<version>7.1.9.Final</version>
</path>
<path>
<groupId>io.javalin.community.openapi</groupId>
<artifactId>openapi-annotation-processor</artifactId>
<version>7.2.3</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin> <plugin>
<groupId>org.apache.maven.plugins</groupId> <groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId> <artifactId>maven-surefire-plugin</artifactId>
@@ -44,8 +66,11 @@
<artifactId>flyway-maven-plugin</artifactId> <artifactId>flyway-maven-plugin</artifactId>
<version>11.20.3</version> <version>11.20.3</version>
<configuration> <configuration>
<!--suppress UnresolvedMavenProperty -->
<url>${env.FLYWAY_URL}</url> <url>${env.FLYWAY_URL}</url>
<!--suppress UnresolvedMavenProperty -->
<user>${env.FLYWAY_USER}</user> <user>${env.FLYWAY_USER}</user>
<!--suppress UnresolvedMavenProperty -->
<password>${env.FLYWAY_PASSWORD}</password> <password>${env.FLYWAY_PASSWORD}</password>
</configuration> </configuration>
</plugin> </plugin>
@@ -62,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>
@@ -72,28 +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>
<transformers> <includeScope>runtime</includeScope>
<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>
@@ -159,10 +177,32 @@
<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>
<version>6.7.0</version> <version>7.2.3</version>
</dependency>
<dependency>
<groupId>io.javalin.community.openapi</groupId>
<artifactId>openapi-specification</artifactId>
<version>7.2.3</version>
</dependency>
<dependency>
<groupId>io.javalin.community.openapi</groupId>
<artifactId>javalin-openapi-plugin</artifactId>
<version>7.2.3</version>
</dependency>
<dependency>
<groupId>io.javalin.community.openapi</groupId>
<artifactId>openapi-generator</artifactId>
<version>7.2.3</version>
</dependency>
<dependency>
<groupId>io.javalin.community.openapi</groupId>
<artifactId>javalin-swagger-plugin</artifactId>
<version>7.2.3</version>
</dependency> </dependency>
<dependency> <dependency>
@@ -175,6 +215,11 @@
<artifactId>hibernate-hikaricp</artifactId> <artifactId>hibernate-hikaricp</artifactId>
<version>7.1.9.Final</version> <version>7.1.9.Final</version>
</dependency> </dependency>
<dependency>
<groupId>jakarta.data</groupId>
<artifactId>jakarta.data-api</artifactId>
<version>1.0.1</version>
</dependency>
<dependency> <dependency>
<groupId>com.mysql</groupId> <groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId> <artifactId>mysql-connector-j</artifactId>
@@ -197,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 5 API used by Javalin's Jetty 11 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>
@@ -217,14 +250,9 @@
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.json</groupId> <groupId>com.fasterxml.jackson.core</groupId>
<artifactId>json</artifactId> <artifactId>jackson-databind</artifactId>
<version>20250517</version> <version>2.21.2</version>
</dependency>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.14.0</version>
</dependency> </dependency>
<dependency> <dependency>
@@ -278,6 +306,18 @@
<version>${mockito.version}</version> <version>${mockito.version}</version>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-junit-jupiter</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-mysql</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
</project> </project>
+52 -63
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,21 +23,31 @@ 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;
import me.bobert.netneutrality.utils.SettingsProvider; import me.bobert.netneutrality.utils.SettingsProvider;
import me.bobert.netneutrality.db.service.GuildServiceProvider; import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.utils.ChannelMessagePurger; import me.bobert.netneutrality.utils.ChannelMessagePurger;
import me.bobert.netneutrality.db.JpaUtils; import me.bobert.netneutrality.utils.UserFunction;
import me.bobert.netneutrality.core.command.precondition.DisabledCommandPrecondition;
import me.bobert.netneutrality.db.repository.BlacklistRepository;
import me.bobert.netneutrality.db.repository.GuildRepository;
import me.bobert.netneutrality.db.repository.GuildSettingRepository;
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;
@@ -45,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() {
@@ -94,7 +105,9 @@ public class App {
context.start(); context.start();
} catch (Exception e) { } catch (Exception e) {
LogHandler.log(e); LogHandler.log(e);
System.exit(1); // Roll back the components that did start so no non-daemon thread keeps the JVM alive.
context.stop();
throw new IllegalStateException("Startup failed", e);
} }
Runtime.getRuntime().addShutdownHook(new Thread(this::shutdown)); Runtime.getRuntime().addShutdownHook(new Thread(this::shutdown));
@@ -112,9 +125,11 @@ public class App {
// Allow ApplicationContext itself to be injected into components that need it. // Allow ApplicationContext itself to be injected into components that need it.
context.register(context); context.register(context);
// Persistence: build the SessionFactory + run migrations; released last on shutdown. // Persistence: run migrations + build the SessionFactory; released last on shutdown.
// SessionManager binds itself into JpaUtils for repository access. SessionManager sessions = context.register(new SessionManager(settings));
context.register(new SessionManager(settings)); GuildRepository guildRepository = new GuildRepository(sessions);
BlacklistRepository blacklistRepository = new BlacklistRepository(sessions);
GuildSettingRepository guildSettingRepository = new GuildSettingRepository(sessions);
// Executors: no explicit start today; shut down after music players are disconnected. // Executors: no explicit start today; shut down after music players are disconnected.
ExecutorProvider executors = context.register(new ThreadManager()); ExecutorProvider executors = context.register(new ThreadManager());
@@ -128,29 +143,42 @@ public class App {
CommandRegistry commands = context.register(new CommandManager(context)); CommandRegistry commands = context.register(new CommandManager(context));
// Task registry (priority 30). // Task registry (priority 30).
TaskManager tasks = context.register(new TaskManagerImpl(context)); TaskManager tasks = context.register(new TaskManagerImpl(context, new TaskRepository(sessions)));
// Per-guild service cache (not a Lifecycle component). // Per-guild service cache (not a Lifecycle component).
GuildServiceProvider guildServices = context.register(new GuildServiceProviderImpl()); GuildServiceProvider guildServices = context.register(
new GuildServiceProviderImpl(guildRepository, blacklistRepository, guildSettingRepository));
LogHandler.bind(guildServices); LogHandler.bind(guildServices);
JpaUtils.bindGuildServiceLookup(guildServices::get); UserFunction.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, 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) {
@@ -181,49 +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) {
LogHandler.log(e);
System.exit(1);
}
}
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();
}
}
} }
@@ -5,13 +5,14 @@ package me.bobert.netneutrality.bootstrap;
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import me.bobert.netneutrality.db.service.GuildServiceProvider; import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.db.service.GuildService; import me.bobert.netneutrality.db.service.GuildService;
import me.bobert.netneutrality.music.MusicService; import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.music.MusicManager; import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.music.TrackScheduler; import me.bobert.netneutrality.music.TrackScheduler;
import org.json.JSONArray; import me.bobert.netneutrality.utils.Json;
import org.json.JSONObject;
import java.io.File; import java.io.File;
import java.io.FileWriter; import java.io.FileWriter;
@@ -41,10 +42,10 @@ public class BotState {
return; return;
} }
Map<String, GuildService> services = guildServices.getServices(); Map<String, GuildService> services = guildServices.snapshot();
JSONObject main = new JSONObject(); ObjectNode main = Json.object();
JSONArray guildsArray = new JSONArray(); ArrayNode guildsArray = Json.array();
for (MusicManager manager : musicService.getManagers()) { for (MusicManager manager : musicService.getManagers()) {
TrackScheduler trackScheduler = manager.getScheduler(); TrackScheduler trackScheduler = manager.getScheduler();
@@ -53,9 +54,9 @@ public class BotState {
for (GuildService service : services.values()) { for (GuildService service : services.values()) {
JSONObject guild = new JSONObject(); ObjectNode guild = Json.object();
JSONObject music = new JSONObject(); ObjectNode music = Json.object();
guild.put("id", service.getEntity().getGuildId()); guild.put("id", service.getGuildId());
// MessageChannel currentChannel = trackScheduler.getTextChannel(); // MessageChannel currentChannel = trackScheduler.getTextChannel();
// if (currentChannel == null) { // if (currentChannel == null) {
@@ -64,12 +65,12 @@ public class BotState {
// music.put("current_channel", currentChannel.getId()); // music.put("current_channel", currentChannel.getId());
// } // }
guild.put("music", music); guild.set("music", music);
guildsArray.put(guild); guildsArray.add(guild);
} }
main.put("guilds", guildsArray); main.set("guilds", guildsArray);
File state = new File("state.json"); File state = new File("state.json");
try { try {
@@ -10,16 +10,20 @@ import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.impl.AdminCommand; import me.bobert.netneutrality.core.command.impl.AdminCommand;
import me.bobert.netneutrality.core.command.options.CommandOption; import me.bobert.netneutrality.core.command.options.CommandOption;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.db.entities.OBlacklist; import me.bobert.netneutrality.db.service.BlacklistEntry;
import me.bobert.netneutrality.db.service.BlacklistResult;
import me.bobert.netneutrality.db.service.BlacklistService; import me.bobert.netneutrality.db.service.BlacklistService;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import net.dv8tion.jda.api.interactions.commands.OptionType; import net.dv8tion.jda.api.interactions.commands.OptionType;
import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; import net.dv8tion.jda.api.interactions.commands.build.SubcommandData;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
public class BlacklistCommand extends AdminCommand { public class BlacklistCommand extends AdminCommand {
private static final Logger logger = LogManager.getLogger(BlacklistCommand.class);
private final GuildServiceProvider guildServices; private final GuildServiceProvider guildServices;
@@ -64,16 +68,16 @@ public class BlacklistCommand extends AdminCommand {
public CommandExecutionResult execute(CommandContext context) { public CommandExecutionResult execute(CommandContext context) {
String sub = context.getSubcommand(); String sub = context.getSubcommand();
String word = context.getOption("word", "", CommandOption::getAsString); String word = context.getOption("word", "", CommandOption::getAsString);
BlacklistService service = guildServices.get(context.getGuild()).getBlacklistService(); BlacklistService service = guildServices.get(context.getGuild().getId()).getBlacklistService();
switch (sub) { switch (sub) {
case "get" -> context.async("Retrieving list, please wait...", asyncMessage -> { case "get" -> context.async("Retrieving list, please wait...", asyncMessage -> {
List<OBlacklist> blacklists = service.getBlacklistForGuild(); List<BlacklistEntry> blacklists = service.list();
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
sb.append("List of blacklisted words for this server: \n"); sb.append("List of blacklisted words for this server: \n");
for (OBlacklist bl : blacklists) { for (BlacklistEntry bl : blacklists) {
sb.append(bl.getWord()).append(" -> ").append(bl.getEnabled() ? "enabled" : "disabled").append("\n"); sb.append(bl.word()).append(" -> ").append(bl.enabled() ? "enabled" : "disabled").append("\n");
} }
if (blacklists.isEmpty()) { if (blacklists.isEmpty()) {
@@ -83,13 +87,32 @@ public class BlacklistCommand extends AdminCommand {
asyncMessage.edit(sb.toString()); asyncMessage.edit(sb.toString());
}); });
case "add" -> { case "add" -> {
return service.add(context.getGuildUser(), word); try {
return toReply(service.add(context.getGuildUser().getId(), word), word);
} catch (RuntimeException e) {
logger.error("Failed to add word to blacklist", e);
return CommandExecutionResult.warn("Failed to add word to blacklist: " + e.getMessage());
}
} }
case "remove" -> { case "remove" -> {
return service.delete(word); try {
return toReply(service.remove(word), word);
} catch (RuntimeException e) {
logger.error("Failed to remove word from blacklist", e);
return CommandExecutionResult.warn("Failed to remove word from blacklist: " + e.getMessage());
}
} }
} }
return CommandExecutionResult.none(); return CommandExecutionResult.none();
} }
static CommandExecutionResult toReply(BlacklistResult result, String word) {
return switch (result) {
case ADDED, REENABLED -> CommandExecutionResult.success("Added the word [%s] to the blacklist.", word);
case ALREADY_EXISTS -> CommandExecutionResult.warn(String.format("The word [%s] is already in the blacklist.", word));
case REMOVED -> CommandExecutionResult.success("Removed the word [%s] from the blacklist.", word);
case NOT_FOUND -> CommandExecutionResult.warn(String.format("The word [%s] is not in the blacklist.", word));
};
}
} }
@@ -18,7 +18,7 @@ import me.bobert.netneutrality.core.tasks.TaskManager;
import me.bobert.netneutrality.tasks.help.BotInfo; import me.bobert.netneutrality.tasks.help.BotInfo;
import me.bobert.netneutrality.tasks.help.CommandHelp; import me.bobert.netneutrality.tasks.help.CommandHelp;
import me.bobert.netneutrality.tasks.help.ReportBug; import me.bobert.netneutrality.tasks.help.ReportBug;
import me.bobert.netneutrality.shared.util.MessageFunction; import me.bobert.netneutrality.utils.MessageFunction;
import net.dv8tion.jda.api.components.actionrow.ActionRow; import net.dv8tion.jda.api.components.actionrow.ActionRow;
import net.dv8tion.jda.api.components.buttons.Button; import net.dv8tion.jda.api.components.buttons.Button;
import net.dv8tion.jda.api.interactions.commands.OptionType; import net.dv8tion.jda.api.interactions.commands.OptionType;
@@ -12,19 +12,27 @@ import me.bobert.netneutrality.core.command.options.CommandOption;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
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.db.service.GuildSettingService;
import net.dv8tion.jda.api.entities.Role; import net.dv8tion.jda.api.entities.Role;
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
import net.dv8tion.jda.api.entities.channel.unions.GuildChannelUnion; import net.dv8tion.jda.api.entities.channel.unions.GuildChannelUnion;
import net.dv8tion.jda.api.interactions.commands.OptionType; 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.OptionData;
import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; import net.dv8tion.jda.api.interactions.commands.build.SubcommandData;
import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder;
import java.util.Map;
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 net.dv8tion.jda.api.utils.messages.MessageCreateBuilder;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
public class ConfigureCommand extends AdminCommand { public class ConfigureCommand extends AdminCommand {
private static final Logger logger = LogManager.getLogger(ConfigureCommand.class);
private final GuildServiceProvider guildServices; private final GuildServiceProvider guildServices;
private final MusicService musicService; private final MusicService musicService;
@@ -70,69 +78,186 @@ public class ConfigureCommand extends AdminCommand {
new SubcommandData("music-role", "Role needed to use music commands") new SubcommandData("music-role", "Role needed to use music commands")
.addOption(OptionType.ROLE, "role", "Name of role", true), .addOption(OptionType.ROLE, "role", "Name of role", true),
new SubcommandData("music-channel", "Only allow music commands on this channel") new SubcommandData("music-channel", "Only allow music commands on this channel")
.addOption(OptionType.CHANNEL, "channel", "Leave empty to allow everywhere") .addOption(OptionType.CHANNEL, "channel", "Leave empty to allow everywhere"),
new SubcommandData("music-max-playlist-size", "Playlist size above which confirmation is asked before queueing")
.addOptions(new OptionData(OptionType.INTEGER, "size", "Number of tracks", true)
.setRequiredRange(1, 1000)),
new SubcommandData("music-dj-role", "Role that may control music playback without being the current DJ")
.addOption(OptionType.ROLE, "role", "Leave empty to clear"),
new SubcommandData("music-vote-skip-threshold", "Percentage of listeners required to approve a vote-skip")
.addOptions(new OptionData(OptionType.INTEGER, "percent", "Percentage (1-100)", true)
.setRequiredRange(1, 100)),
new SubcommandData("music-idle-timeout", "Minutes of inactivity before the bot auto-disconnects")
.addOptions(new OptionData(OptionType.INTEGER, "minutes", "Number of minutes", true)
.setRequiredRange(1, 1440)),
new SubcommandData("command-prefix", "Override the bot's message command prefix on this server")
.addOption(OptionType.STRING, "prefix", "Leave empty to use the bot-wide default"),
new SubcommandData("admin-role", "Role granted admin permissions, in addition to Discord permissions")
.addOption(OptionType.ROLE, "role", "Leave empty to clear"),
new SubcommandData("disabled-commands", "Comma-separated list of command names disabled on this server")
.addOption(OptionType.STRING, "commands", "Leave empty to re-enable everything"),
new SubcommandData("disable-command", "Disable a single command on this server")
.addOption(OptionType.STRING, "name", "Name of the command to disable", true),
new SubcommandData("enable-command", "Re-enable a single command on this server")
.addOption(OptionType.STRING, "name", "Name of the command to re-enable", true),
new SubcommandData("log-level", "Minimum severity of events sent to the log channel")
.addOptions(new OptionData(OptionType.STRING, "level", "Severity level", true)
.addChoice("ERROR", "ERROR").addChoice("WARN", "WARN").addChoice("INFO", "INFO")),
new SubcommandData("locale", "Preferred locale for the bot's replies on this server")
.addOption(OptionType.STRING, "locale", "Locale code, e.g. en, fr", true),
new SubcommandData("timezone", "Timezone used to display timestamps on this server")
.addOption(OptionType.STRING, "timezone", "Timezone id, e.g. UTC, Europe/Paris", true),
new SubcommandData("music-search-engine", "Default search engine used when a play query is not a direct url")
.addOptions(new OptionData(OptionType.STRING, "engine", "Search engine", true)
.addChoice("YouTube", "YOUTUBE").addChoice("YouTube Music", "YTMUSIC").addChoice("SoundCloud", "SOUNDCLOUD"))
); );
} }
@Override @Override
public CommandExecutionResult execute(CommandContext context) { public CommandExecutionResult execute(CommandContext context) {
try {
return configure(context);
} catch (RuntimeException e) {
logger.error("Failed to update configuration", e);
return CommandExecutionResult.warn("Failed to update configuration: " + e.getMessage());
}
}
private CommandExecutionResult configure(CommandContext context) {
String sub = context.getSubcommand(); String sub = context.getSubcommand();
GuildService guildService = guildServices.get(context.getGuild().getId()); GuildService guildService = guildServices.get(context.getGuild().getId());
GuildSettingService settingService = guildService.getGuildSettingService();
context.ephemeral(); context.ephemeral();
if ("music-role".equals(sub)) { return switch (sub) {
case "music-role" -> configureMusicRole(context, settingService);
case "music-channel" -> configureMusicChannel(context, settingService);
case "music-volume" -> configureMusicVolume(context, settingService);
case "error-logging" -> configureErrorLogging(context, guildService);
case "music-max-playlist-size" -> configureIntSetting(context, settingService, GSettings.MUSIC_MAXPLAYLISTSIZE, "size", "Maximum playlist size before confirmation is now %d");
case "music-dj-role" -> configureRoleSetting(context, settingService, GSettings.MUSIC_DJ_ROLE, "No dedicated DJ role is set anymore", "Successfully set %s as the DJ role");
case "music-vote-skip-threshold" -> configureIntSetting(context, settingService, GSettings.MUSIC_VOTE_SKIP_THRESHOLD, "percent", "Vote-skip threshold is now %d%%");
case "music-idle-timeout" -> configureIntSetting(context, settingService, GSettings.MUSIC_IDLE_TIMEOUT, "minutes", "Idle disconnect timeout is now %d minutes");
case "command-prefix" -> configureStringSetting(context, settingService, GSettings.COMMAND_PREFIX, "prefix", "Command prefix is now reset to the bot-wide default", "Command prefix is now set to %s");
case "admin-role" -> configureRoleSetting(context, settingService, GSettings.ADMIN_ROLE, "No dedicated admin role is set anymore", "Successfully set %s as the admin role");
case "disabled-commands" -> configureStringSetting(context, settingService, GSettings.DISABLED_COMMANDS, "commands", "All commands are now re-enabled", "Disabled commands are now: %s");
case "disable-command" -> toggleDisabledCommand(context, settingService, true);
case "enable-command" -> toggleDisabledCommand(context, settingService, false);
case "log-level" -> configureStringSetting(context, settingService, GSettings.LOG_LEVEL, "level", "Log level reset to default", "Log level is now %s");
case "locale" -> configureStringSetting(context, settingService, GSettings.LOCALE, "locale", "Locale reset to default", "Locale is now %s");
case "timezone" -> configureStringSetting(context, settingService, GSettings.TIMEZONE, "timezone", "Timezone reset to default", "Timezone is now %s");
case "music-search-engine" -> configureStringSetting(context, settingService, GSettings.MUSIC_SEARCH_ENGINE, "engine", "Default search engine reset to YouTube", "Default search engine is now %s");
case "show-all" -> showAll(context, settingService);
default -> CommandExecutionResult.error("Unknown subcommand: " + sub);
};
}
private CommandExecutionResult configureIntSetting(CommandContext context, GuildSettingService settingService, GSettings setting, String optionName, String successFormat) {
int value = context.getOption(optionName, Integer.parseInt(setting.getDefaultValue()), CommandOption::getAsInt);
settingService.set(setting, String.valueOf(value));
return CommandExecutionResult.success(successFormat, value);
}
private CommandExecutionResult configureStringSetting(CommandContext context, GuildSettingService settingService, GSettings setting, String optionName, String clearedMessage, String successFormat) {
String value = context.getOption(optionName, "", CommandOption::getAsString);
if (value.isEmpty()) {
settingService.set(setting, null);
return CommandExecutionResult.success(clearedMessage);
}
settingService.set(setting, value);
return CommandExecutionResult.success(successFormat, value);
}
private CommandExecutionResult toggleDisabledCommand(CommandContext context, GuildSettingService settingService, boolean disable) {
String name = context.getOption("name", "", CommandOption::getAsString).trim();
Set<String> disabledCommands = Arrays.stream(settingService.getOrDefault(GSettings.DISABLED_COMMANDS).split(","))
.map(String::trim)
.filter(value -> !value.isEmpty())
.collect(Collectors.toCollection(LinkedHashSet::new));
if (disable) {
if (disabledCommands.stream().noneMatch(value -> value.equalsIgnoreCase(name))) {
disabledCommands.add(name);
}
} else {
disabledCommands.removeIf(value -> value.equalsIgnoreCase(name));
}
settingService.set(GSettings.DISABLED_COMMANDS, disabledCommands.isEmpty() ? null : String.join(",", disabledCommands));
return CommandExecutionResult.success(disable ? "Command %s is now disabled on this server" : "Command %s is now re-enabled on this server", name);
}
private CommandExecutionResult configureRoleSetting(CommandContext context, GuildSettingService settingService, GSettings setting, String clearedMessage, String successFormat) {
Role role = context.getOption("role", CommandOption::getAsRole);
if (role == null) {
settingService.set(setting, null);
return CommandExecutionResult.success(clearedMessage);
}
settingService.set(setting, role.getId());
return CommandExecutionResult.success(successFormat, role.getAsMention());
}
private CommandExecutionResult configureMusicRole(CommandContext context, GuildSettingService settingService) {
Role role = context.getOption("role", CommandOption::getAsRole); Role role = context.getOption("role", CommandOption::getAsRole);
assert role != null; assert role != null;
if (role.getPosition() == -1) { if (role.getPosition() == -1) {
guildService.getGuildSettingService().setSettingValue(GSettings.MUSIC_ROLE, null); settingService.set(GSettings.MUSIC_ROLE, null);
return CommandExecutionResult.success("Everyone can now use music commands"); return CommandExecutionResult.success("Everyone can now use music commands");
} }
if (role.getGuild().getId().equals(context.getGuild().getId())) { if (role.getGuild().getId().equals(context.getGuild().getId())) {
guildService.getGuildSettingService().setSettingValue(GSettings.MUSIC_ROLE, role.getId()); settingService.set(GSettings.MUSIC_ROLE, role.getId());
return CommandExecutionResult.success(String.format("Successfully set %s as the role needed for music command", role.getAsMention())); return CommandExecutionResult.success(String.format("Successfully set %s as the role needed for music command", role.getAsMention()));
} }
return CommandExecutionResult.error("Unknown subcommand: music-role");
} }
if ("music-channel".equals(sub)) { private CommandExecutionResult configureMusicChannel(CommandContext context, GuildSettingService settingService) {
GuildChannelUnion channel = context.getOption("channel", CommandOption::getAsChannel); GuildChannelUnion channel = context.getOption("channel", CommandOption::getAsChannel);
if (channel == null) { if (channel == null) {
guildService.getGuildSettingService().setSettingValue(GSettings.MUSIC_CHANNEL, null); settingService.set(GSettings.MUSIC_CHANNEL, null);
return CommandExecutionResult.success("No channel restrictions for music commands"); return CommandExecutionResult.success("No channel restrictions for music commands");
} }
if (channel instanceof TextChannel) { if (channel instanceof TextChannel && channel.getGuild().getId().equals(context.getGuild().getId())) {
if (channel.getGuild().getId().equals(context.getGuild().getId())) { settingService.set(GSettings.MUSIC_CHANNEL, channel.getId());
guildService.getGuildSettingService().setSettingValue(GSettings.MUSIC_CHANNEL, channel.getId());
return CommandExecutionResult.success(String.format("Successfully set %s as the channel required to execute music command", channel.getAsMention())); return CommandExecutionResult.success(String.format("Successfully set %s as the channel required to execute music command", channel.getAsMention()));
} }
return CommandExecutionResult.error("Unknown subcommand: music-channel");
} }
} private CommandExecutionResult configureMusicVolume(CommandContext context, GuildSettingService settingService) {
if ("music-volume".equals(sub)) {
int volume = context.getOption("volume", 30, CommandOption::getAsInt); int volume = context.getOption("volume", 30, CommandOption::getAsInt);
guildService.getGuildSettingService().setSettingValue(GSettings.MUSIC_VOLUME, String.valueOf(volume)); settingService.set(GSettings.MUSIC_VOLUME, String.valueOf(volume));
musicService.get(context.getGuild()).setVolume(volume); musicService.get(context.getGuild()).setVolume(volume);
return CommandExecutionResult.success(String.format("Default volume for music player is now %d", volume)); return CommandExecutionResult.success(String.format("Default volume for music player is now %d", volume));
} }
if ("error-logging".equals(sub)) { private CommandExecutionResult configureErrorLogging(CommandContext context, GuildService guildService) {
boolean debug = context.getOption("debug", true, CommandOption::getAsBoolean); boolean debug = context.getOption("debug", true, CommandOption::getAsBoolean);
guildService.updateDebugMode(debug); guildService.updateDebugMode(debug);
return CommandExecutionResult.success("Error logging is now %s for this guild", (debug ? "enabled" : "disabled")); return CommandExecutionResult.success("Error logging is now %s for this guild", (debug ? "enabled" : "disabled"));
} }
if ("show-all".equals(sub)) { private CommandExecutionResult showAll(CommandContext context, GuildSettingService settingService) {
Map<String, String> settings = guildService.getGuildSettingService().getSettings(); Map<String, String> settings = settingService.snapshot();
MessageCreateBuilder builder = new MessageCreateBuilder(); MessageCreateBuilder builder = new MessageCreateBuilder();
for (GSettings o : GSettings.values()) { for (GSettings o : GSettings.values()) {
@@ -142,13 +267,12 @@ public class ConfigureCommand extends AdminCommand {
value = settings.get(o.getName()); value = settings.get(o.getName());
} }
builder.addContent(o.getName()).addContent(": ").addContent(value); builder.addContent(o.getName()).addContent(" (").addContent(o.getDescription()).addContent("): ").addContent(value);
builder.addContent("\n"); builder.addContent("\n");
} }
context.replyEphemeral(builder.build()); context.replyEphemeral(builder.build());
}
return CommandExecutionResult.error("Unknown subcommand: " + sub); return CommandExecutionResult.success("Displayed current configuration");
} }
} }
@@ -5,6 +5,8 @@ package me.bobert.netneutrality.command.crypto;
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import me.bobert.netneutrality.core.LogHandler; import me.bobert.netneutrality.core.LogHandler;
import me.bobert.netneutrality.core.command.CommandCategory; import me.bobert.netneutrality.core.command.CommandCategory;
import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.command.CommandExecutionResult;
@@ -15,7 +17,6 @@ import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.http.WebModule; import me.bobert.netneutrality.http.WebModule;
import net.dv8tion.jda.api.interactions.commands.OptionType; 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.OptionData;
import org.json.JSONObject;
import java.text.NumberFormat; import java.text.NumberFormat;
import java.util.Currency; import java.util.Currency;
@@ -69,7 +70,7 @@ public class CryptoPriceCommand extends AbstractCommand {
@Override @Override
public CommandExecutionResult execute(CommandContext context) { public CommandExecutionResult execute(CommandContext context) {
Locale usa = new Locale("en", "US"); Locale usa = Locale.US;
NumberFormat dollarFormat = NumberFormat.getCurrencyInstance(usa); NumberFormat dollarFormat = NumberFormat.getCurrencyInstance(usa);
dollarFormat.setMaximumFractionDigits(24); dollarFormat.setMaximumFractionDigits(24);
@@ -91,14 +92,19 @@ public class CryptoPriceCommand extends AbstractCommand {
} }
WebModule http = new WebModule.Builder(String.format(baseURL, symbol, finalCurrency)).cache(30, TimeUnit.MINUTES).build().request(); WebModule http = new WebModule.Builder(String.format(baseURL, symbol, finalCurrency)).cache(30, TimeUnit.MINUTES).build().request();
JSONObject json = http.getAsObject(); ObjectNode json = http.getAsObject();
if (json.isEmpty() || (json.has("Response") && json.getString("Response").equalsIgnoreCase("Error"))) { if (json.isEmpty() || json.path("Response").asText("").equalsIgnoreCase("Error")) {
asyncMessage.edit("Crypto doesn't exist.. yet"); asyncMessage.edit("Crypto doesn't exist.. yet");
return; return;
} }
double value = json.getDouble(finalCurrency); JsonNode priceNode = json.path(finalCurrency);
if (!priceNode.isNumber()) {
throw new IllegalStateException("Missing price for currency " + finalCurrency);
}
double value = priceNode.asDouble();
String price = dollarFormat.format(value); String price = dollarFormat.format(value);
asyncMessage.edit(String.format("The current price of %s in %s is %s", symbol, finalCurrency, price)); asyncMessage.edit(String.format("The current price of %s in %s is %s", symbol, finalCurrency, price));
@@ -115,12 +121,12 @@ public class CryptoPriceCommand extends AbstractCommand {
private boolean validateSymbol(String symbol) { private boolean validateSymbol(String symbol) {
try { try {
WebModule http = new WebModule.Builder(exchangeApi).get().cache(1, TimeUnit.DAYS).build().request(); WebModule http = new WebModule.Builder(exchangeApi).get().cache(1, TimeUnit.DAYS).build().request();
JSONObject json = http.getAsObject(); ObjectNode json = http.getAsObject();
JSONObject pairs = json.getJSONObject("Data") JsonNode pairs = json.path("Data")
.getJSONObject("exchanges") .path("exchanges")
.getJSONObject(exchange) .path(exchange)
.getJSONObject("pairs"); .path("pairs");
return pairs.has(symbol); return pairs.has(symbol);
@@ -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();
}
}
@@ -63,7 +63,7 @@ public class ClearQueueCommand extends MusicCommand {
context.ack(); context.ack();
if (MusicUtils.checkIfConnected(context, manager)) { if (MusicUtils.checkIfConnected(context, manager)) {
return manager.getQueueManager().clearQueue(); return CommandExecutionResult.from(manager.getQueueManager().clearQueue());
} }
return CommandExecutionResult.none(); return CommandExecutionResult.none();
@@ -13,7 +13,7 @@ import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.music.MusicService; import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.music.MusicManager; import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.shared.util.MessageFunction; import me.bobert.netneutrality.utils.MessageFunction;
import me.bobert.netneutrality.utils.MusicUtils; import me.bobert.netneutrality.utils.MusicUtils;
public class CurrentlyPlayingCommand extends MusicCommand { public class CurrentlyPlayingCommand extends MusicCommand {
@@ -13,7 +13,7 @@ import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.core.command.options.CommandOption; import me.bobert.netneutrality.core.command.options.CommandOption;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.music.MusicManager; import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.shared.util.MessageFunction; import me.bobert.netneutrality.utils.MessageFunction;
import me.bobert.netneutrality.utils.MusicUtils; import me.bobert.netneutrality.utils.MusicUtils;
import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.interactions.commands.OptionType; import net.dv8tion.jda.api.interactions.commands.OptionType;
@@ -83,18 +83,18 @@ public class QueueCommand extends MusicCommand {
switch (sub) { switch (sub) {
case "clear-all" -> { case "clear-all" -> {
return manager.getQueueManager().clearQueue(); return CommandExecutionResult.from(manager.getQueueManager().clearQueue());
} }
case "delete-track" -> { case "delete-track" -> {
int position = context.getOption("position", 0, CommandOption::getAsInt); int position = context.getOption("position", 0, CommandOption::getAsInt);
return manager.getQueueManager().removeFromQueue(position); return CommandExecutionResult.from(manager.getQueueManager().removeFromQueue(position));
} }
case "show-queue" -> context.reply(MessageFunction.buildViewQueueMessage(manager)); case "show-queue" -> context.reply(MessageFunction.buildViewQueueMessage(manager));
case "shuffle" -> { case "shuffle" -> {
return manager.getQueueManager().shuffle(); return CommandExecutionResult.from(manager.getQueueManager().shuffle());
} }
case "from-user" -> { case "from-user" -> {
@@ -112,7 +112,7 @@ public class QueueCommand extends MusicCommand {
return CommandExecutionResult.warn("The user isn't from this guild"); return CommandExecutionResult.warn("The user isn't from this guild");
} }
return manager.getQueueManager().removeTracksFromUser(member); return CommandExecutionResult.from(manager.getQueueManager().removeTracksFromUser(member));
} }
} }
} }
@@ -60,7 +60,7 @@ public class ResumeCommand extends MusicCommand {
MusicManager manager = musicService.get(context.getGuild()); MusicManager manager = musicService.get(context.getGuild());
if (MusicUtils.checkIfConnected(context, manager)) { if (MusicUtils.checkIfConnected(context, manager)) {
return manager.resume(); return CommandExecutionResult.from(manager.resume());
} }
return CommandExecutionResult.none(); return CommandExecutionResult.none();
@@ -57,7 +57,7 @@ public class ShuffleCommand extends MusicCommand {
context.ack(); context.ack();
if (MusicUtils.checkIfConnected(context, manager)) { if (MusicUtils.checkIfConnected(context, manager)) {
return manager.getQueueManager().shuffle(); return CommandExecutionResult.from(manager.getQueueManager().shuffle());
} }
return CommandExecutionResult.none(); return CommandExecutionResult.none();
@@ -15,7 +15,7 @@ import me.bobert.netneutrality.core.command.options.CommandOption;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.music.MusicManager; import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.utils.MusicUtils; import me.bobert.netneutrality.utils.MusicUtils;
import me.bobert.netneutrality.shared.util.UserFunction; import me.bobert.netneutrality.utils.UserFunction;
import net.dv8tion.jda.api.interactions.commands.OptionType; 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.OptionData;
@@ -65,9 +65,9 @@ public class SkipCommand extends MusicCommand {
if (MusicUtils.checkIfConnected(context, manager)) { if (MusicUtils.checkIfConnected(context, manager)) {
int skips = context.getOption("skip", 1, CommandOption::getAsInt); int skips = context.getOption("skip", 1, CommandOption::getAsInt);
if (UserFunction.isAdmin(context) || MusicUtils.isUserDj(context, manager)) { if (UserFunction.isAdmin(context) || MusicUtils.isUserDj(context, manager)) {
return manager.getQueueManager().skip(skips); return CommandExecutionResult.from(manager.getQueueManager().skip(skips));
} else { } else {
return manager.voteSkip(context); return CommandExecutionResult.from(manager.voteSkip(context.getGuildUser()));
} }
} }
@@ -14,7 +14,7 @@ import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.music.MusicManager; import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.utils.MusicUtils; import me.bobert.netneutrality.utils.MusicUtils;
import me.bobert.netneutrality.shared.util.UserFunction; import me.bobert.netneutrality.utils.UserFunction;
public class StopCommand extends MusicCommand { public class StopCommand extends MusicCommand {
@@ -12,7 +12,7 @@ import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.music.MusicService; import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.music.MusicManager; import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.shared.util.MessageFunction; import me.bobert.netneutrality.utils.MessageFunction;
import me.bobert.netneutrality.utils.MusicUtils; import me.bobert.netneutrality.utils.MusicUtils;
public class ViewQueueCommand extends MusicCommand { public class ViewQueueCommand extends MusicCommand {
@@ -15,7 +15,7 @@ import me.bobert.netneutrality.core.command.options.CommandOption;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.music.MusicManager; import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.utils.MusicUtils; import me.bobert.netneutrality.utils.MusicUtils;
import me.bobert.netneutrality.shared.util.UserFunction; import me.bobert.netneutrality.utils.UserFunction;
import net.dv8tion.jda.api.interactions.commands.OptionType; 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.OptionData;
@@ -56,7 +56,7 @@ public class VoteSkipCommand extends MusicCommand {
MusicManager manager = musicService.get(context.getGuild()); MusicManager manager = musicService.get(context.getGuild());
if (MusicUtils.checkIfConnected(context, manager)) { if (MusicUtils.checkIfConnected(context, manager)) {
return manager.voteSkip(context); return CommandExecutionResult.from(manager.voteSkip(context.getGuildUser()));
} }
return CommandExecutionResult.none(); return CommandExecutionResult.none();
@@ -10,6 +10,7 @@ import me.bobert.netneutrality.core.command.options.MessageCommandOption;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.core.context.MessageContext; import me.bobert.netneutrality.core.context.MessageContext;
import me.bobert.netneutrality.utils.SettingsProvider; import me.bobert.netneutrality.utils.SettingsProvider;
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.Mentions; import net.dv8tion.jda.api.entities.Mentions;
import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.Message;
@@ -22,11 +23,20 @@ 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.OptionData;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.StringJoiner; import java.util.StringJoiner;
import java.util.regex.Pattern;
public class MessageCommandContext extends CommandContext implements MessageContext { public class MessageCommandContext extends CommandContext implements MessageContext {
private static final Pattern INTEGER_PATTERN = Pattern.compile("[-+]?\\d+");
private static final Pattern NUMBER_PATTERN = Pattern.compile("[-+]?\\d+(\\.\\d+)?");
private static final Pattern MENTION_PATTERN = Pattern.compile("<(@[!&]?|#)\\d+>");
private static final Set<OptionType> MENTION_TYPES =
EnumSet.of(OptionType.USER, OptionType.ROLE, OptionType.MENTIONABLE, OptionType.CHANNEL);
private static final OptionData LEFTOVER_ARGUMENTS = new OptionData(OptionType.STRING, "arguments", "arguments");
private final MessageReceivedEvent event; private final MessageReceivedEvent event;
private final Message message; private final Message message;
@@ -61,7 +71,6 @@ public class MessageCommandContext extends CommandContext implements MessageCont
@Override @Override
public List<CommandOption> parseOptions(String[] args, List<OptionData> commandOptions) { public List<CommandOption> parseOptions(String[] args, List<OptionData> commandOptions) {
List<CommandOption> options = new ArrayList<>(); List<CommandOption> options = new ArrayList<>();
int required = commandOptions.stream().filter(OptionData::isRequired).toList().size();
Mentions mentions = message.getMentions(); Mentions mentions = message.getMentions();
int roleMentionIndex = 0; int roleMentionIndex = 0;
int userMentionIndex = 0; int userMentionIndex = 0;
@@ -69,73 +78,48 @@ public class MessageCommandContext extends CommandContext implements MessageCont
int channelMentionIndex = 0; int channelMentionIndex = 0;
int index = 0; int index = 0;
for (OptionData data : commandOptions) { for (int i = 0; i < commandOptions.size(); i++) {
OptionData data = commandOptions.get(i);
OptionType type = data.getType(); OptionType type = data.getType();
Set<ChannelType> requiredChannelTypes = data.getChannelTypes(); Set<ChannelType> requiredChannelTypes = data.getChannelTypes();
List<Command.Choice> choices = data.getChoices(); List<Command.Choice> choices = data.getChoices();
int position = index + 1;
Object value = null; Object value = null;
try { if (index >= args.length) {
if (data.isRequired()) {
invalidateContext(data, "missing required argument at index " + position);
}
continue;
}
switch (type) { switch (type) {
case STRING -> { case STRING -> {
int currentOptionIndex = commandOptions.indexOf(data); String str;
boolean nextOptionIsString = false; if (i + 1 < commandOptions.size() && commandOptions.get(i + 1).getType() == OptionType.STRING) {
if (currentOptionIndex + 1 < commandOptions.size()) { // The next option is also a string, so only the current argument belongs to this one
OptionData nextOption = commandOptions.get(currentOptionIndex + 1); str = args[index++];
nextOptionIsString = nextOption.getType() == OptionType.STRING;
}
StringBuilder strBuilder = new StringBuilder();
if (!nextOptionIsString) {
while (index < args.length) {
String nextArg = args[index];
if ((nextArg.contains("<@") || nextArg.contains("<#") || nextArg.contains("<@&")) && nextArg.contains(">")) {
break;
}
if (nextArg.equalsIgnoreCase("true") || nextArg.equalsIgnoreCase("false")) {
break;
}
try {
Long.parseLong(nextArg);
break;
} catch (NumberFormatException ignored) {}
try {
Double.parseDouble(nextArg);
break;
} catch (NumberFormatException ignored) {}
if (!strBuilder.isEmpty()) {
strBuilder.append(" ");
}
strBuilder.append(nextArg);
index++;
}
// Update index to skip the concatenated strings
index = index - 1;
} else { } else {
// Next option is also a string, so just take the current argument StringJoiner joiner = new StringJoiner(" ");
if (index < args.length) { while (index < args.length && !stopsTextCapture(args[index], commandOptions, i)) {
strBuilder.append(args[index]); joiner.add(args[index++]);
} }
str = joiner.toString();
}
if (str.isEmpty()) {
if (data.isRequired()) {
invalidateContext(data, "missing required argument at index " + position);
} }
if (strBuilder.isEmpty()) {
break; break;
} }
String str = strBuilder.toString(); if (!choices.isEmpty() && choices.stream().noneMatch(choice -> choice.getAsString().equalsIgnoreCase(str))) {
if (!choices.isEmpty()) {
List<Command.Choice> matched = choices.stream().filter(choice -> choice.getAsString().equalsIgnoreCase(str)).toList();
if (matched.isEmpty()) {
invalidateContext(data, "not a valid choice, valid choices are: " + validChoices(choices)); invalidateContext(data, "not a valid choice, valid choices are: " + validChoices(choices));
} break;
} }
Integer min = data.getMinLength(); Integer min = data.getMinLength();
@@ -143,165 +127,202 @@ public class MessageCommandContext extends CommandContext implements MessageCont
if (min != null && str.length() < min) { if (min != null && str.length() < min) {
invalidateContext(data, "minimum length for argument is " + min); invalidateContext(data, "minimum length for argument is " + min);
break;
} }
if (max != null && str.length() > max) { if (max != null && str.length() > max) {
invalidateContext(data, "maximum length for argument is " + max); invalidateContext(data, "maximum length for argument is " + max);
break;
} }
value = str; value = str;
} }
case INTEGER -> { case INTEGER -> {
long num = Long.parseLong(args[index]); String str = args[index++];
if (!choices.isEmpty()) { if (!INTEGER_PATTERN.matcher(str).matches()) {
List<Command.Choice> matched = choices.stream().filter(choice -> choice.getAsLong() == num).toList(); invalidateContext(data, "the argument provided is not a number.");
if (matched.isEmpty()) { break;
invalidateContext(data, "not a valid choice, valid choices are: " + validChoices(choices)); }
long num = Long.parseLong(str);
if (!choices.isEmpty() && choices.stream().noneMatch(choice -> choice.getAsLong() == num)) {
invalidateContext(data, "not a valid choice, valid choices are: " + validChoices(choices));
break;
} }
}
value = num;
Number max = data.getMaxValue();
Number min = data.getMinValue(); Number min = data.getMinValue();
Number max = data.getMaxValue();
if (min != null && num < min.longValue()) { if (min != null && num < min.longValue()) {
value = min.longValue(); invalidateContext(data, "minimum value for argument is " + min.longValue());
} else if (max != null && num > max.longValue()) {
value = max.longValue();
}
}
case BOOLEAN -> {
String str = args[index];
if (str.equalsIgnoreCase("true") || str.equalsIgnoreCase("false")) {
value = Boolean.parseBoolean(str);
break; break;
} }
if (max != null && num > max.longValue()) {
invalidateContext(data, "maximum value for argument is " + max.longValue());
break;
}
value = num;
}
case BOOLEAN -> {
String str = args[index++];
if (!str.equalsIgnoreCase("true") && !str.equalsIgnoreCase("false")) {
invalidateContext(data, "expected true or false"); invalidateContext(data, "expected true or false");
break;
}
value = Boolean.parseBoolean(str);
} }
case NUMBER -> { case NUMBER -> {
double num = Double.parseDouble(args[index]); String str = args[index++];
if (!choices.isEmpty()) { if (!NUMBER_PATTERN.matcher(str).matches()) {
List<Command.Choice> matched = choices.stream().filter(choice -> choice.getAsDouble() == num).toList(); invalidateContext(data, "the argument provided is not a number.");
if (matched.isEmpty()) { break;
}
double num = Double.parseDouble(str);
if (!choices.isEmpty() && choices.stream().noneMatch(choice -> choice.getAsDouble() == num)) {
invalidateContext(data, "not a valid choice, valid choices are: " + validChoices(choices)); invalidateContext(data, "not a valid choice, valid choices are: " + validChoices(choices));
break; break;
} }
}
Number max = data.getMaxValue();
Number min = data.getMinValue(); Number min = data.getMinValue();
value = num; Number max = data.getMaxValue();
if (min != null && num < min.doubleValue()) { if (min != null && num < min.doubleValue()) {
value = min.doubleValue(); invalidateContext(data, "minimum value for argument is " + min.doubleValue());
} else if (max != null && num > max.doubleValue()) { break;
value = max.doubleValue();
} }
if (max != null && num > max.doubleValue()) {
invalidateContext(data, "maximum value for argument is " + max.doubleValue());
break;
}
value = num;
} }
case ROLE -> { case ROLE -> {
if (mentions.getRoles().size() > roleMentionIndex) { index++;
Role role = mentions.getRoles().get(roleMentionIndex++); Role role = mentions.getRoles().size() > roleMentionIndex ? mentions.getRoles().get(roleMentionIndex++) : null;
if (role.getGuild().getId().equals(getGuild().getId())) { if (role == null || !isFromCurrentGuild(role.getGuild())) {
value = role; invalidateContext(data, "provided argument at position " + position + " is not a valid role.");
}
break; break;
} }
invalidateContext(data, "provided argument at position " + (index + 1) + " is not a valid role."); value = role;
} }
case USER -> { case USER -> {
if (mentions.getMembers().size() > memberMentionIndex) { index++;
Member member = mentions.getMembers().get(memberMentionIndex++); value = resolveUser(mentions, memberMentionIndex, userMentionIndex);
if (member.getGuild().getId().equals(getGuild().getId())) { if (value instanceof Member) {
value = member; memberMentionIndex++;
break; } else if (value != null) {
} userMentionIndex++;
} }
if (mentions.getUsers().size() > userMentionIndex) { if (value == null) {
value = mentions.getUsers().get(userMentionIndex++); invalidateContext(data, "provided argument at position " + position + " is not a valid user.");
break;
} }
invalidateContext(data, "provided argument at position " + (index + 1) + " is not a valid user.");
} }
case MENTIONABLE -> { case MENTIONABLE -> {
index++;
if (mentions.getRoles().size() > roleMentionIndex) { if (mentions.getRoles().size() > roleMentionIndex) {
Role role = mentions.getRoles().get(roleMentionIndex++); Role role = mentions.getRoles().get(roleMentionIndex++);
if (role.getGuild().getId().equals(getGuild().getId())) { if (!isFromCurrentGuild(role.getGuild())) {
invalidateContext(data, "provided argument at position " + position + " is not a valid role.");
break;
}
value = role; value = role;
}
break; break;
} }
if (mentions.getMembers().size() > memberMentionIndex) { value = resolveUser(mentions, memberMentionIndex, userMentionIndex);
Member member = mentions.getMembers().get(memberMentionIndex++); if (value instanceof Member) {
if (member.getGuild().getId().equals(getGuild().getId())) { memberMentionIndex++;
value = member; } else if (value != null) {
userMentionIndex++;
} }
break; if (value == null) {
invalidateContext(data, "provided argument at position " + position + " is not a valid user or role.");
} }
if (mentions.getUsers().size() > userMentionIndex) {
value = mentions.getUsers().get(userMentionIndex++);
break;
}
invalidateContext(data, "provided argument at position " + (index + 1) + " is not a valid user or role.");
} }
case CHANNEL -> { case CHANNEL -> {
if (mentions.getChannels().size() > channelMentionIndex) { index++;
GuildChannel channel = mentions.getChannels().get(channelMentionIndex++); GuildChannel channel = mentions.getChannels().size() > channelMentionIndex ? mentions.getChannels().get(channelMentionIndex++) : null;
if (channel == null || !isFromCurrentGuild(channel.getGuild())) {
invalidateContext(data, "provided argument at position " + position + " is not a valid channel.");
break;
}
if (!requiredChannelTypes.isEmpty() && !requiredChannelTypes.contains(channel.getType())) { if (!requiredChannelTypes.isEmpty() && !requiredChannelTypes.contains(channel.getType())) {
invalidateContext(data, "not a valid channel type at index " + (index + 1) + invalidateContext(data, "not a valid channel type at index " + position +
" , valid types are: " + validTypes(requiredChannelTypes)); " , valid types are: " + validTypes(requiredChannelTypes));
break; break;
} }
if (channel.getGuild().getId().equals(getGuild().getId())) {
value = channel; value = channel;
break;
} }
} default -> index++;
invalidateContext(data, "provided argument at position " + (index + 1) + " is not a valid channel.");
}
}
} catch (IndexOutOfBoundsException e) {
if (options.size() < required) {
invalidateContext(data, "missing required argument at index " + (index + 1));
}
if (data.isRequired() && index < required) {
// This should never happen because required options are validated before calling parseOptions()
throw new IllegalArgumentException("Required option " + data.getName() + " was not found at index " + index);
}
} catch (NumberFormatException ignored) {
invalidateContext(data, "the argument provided is not a number.");
} }
if (value != null) { if (value != null) {
options.add(new MessageCommandOption(data, value)); options.add(new MessageCommandOption(data, value));
} }
index++;
} }
// Check if there are leftover arguments that weren't consumed // Check if there are leftover arguments that weren't consumed
if (index < args.length) { if (index < args.length) {
if (options.size() >= required) { OptionData lastOption = commandOptions.isEmpty() ? LEFTOVER_ARGUMENTS : commandOptions.getLast();
invalidateContext(commandOptions.getLast(), "too many arguments"); invalidateContext(lastOption, "too many arguments");
}
} }
return options; return options;
} }
/**
* A string option greedily consumes the remaining arguments, it has to stop as soon as an argument
* belongs to one of the following options.
*/
private boolean stopsTextCapture(String arg, List<OptionData> commandOptions, int currentOptionIndex) {
if (NUMBER_PATTERN.matcher(arg).matches()) {
return true;
}
List<OptionData> remaining = commandOptions.subList(currentOptionIndex + 1, commandOptions.size());
if (arg.equalsIgnoreCase("true") || arg.equalsIgnoreCase("false")) {
return remaining.stream().anyMatch(option -> option.getType() == OptionType.BOOLEAN);
}
if (MENTION_PATTERN.matcher(arg).matches()) {
return remaining.stream().anyMatch(option -> MENTION_TYPES.contains(option.getType()));
}
return false;
}
private Object resolveUser(Mentions mentions, int memberMentionIndex, int userMentionIndex) {
if (mentions.getMembers().size() > memberMentionIndex) {
Member member = mentions.getMembers().get(memberMentionIndex);
if (isFromCurrentGuild(member.getGuild())) {
return member;
}
}
if (mentions.getUsers().size() > userMentionIndex) {
return mentions.getUsers().get(userMentionIndex);
}
return null;
}
private boolean isFromCurrentGuild(Guild guild) {
return getGuild() != null && guild != null && guild.getId().equals(getGuild().getId());
}
private String validChoices(List<Command.Choice> choices) { private String validChoices(List<Command.Choice> choices) {
StringJoiner joiner = new StringJoiner(", "); StringJoiner joiner = new StringJoiner(", ");
for (Command.Choice choice : choices) { for (Command.Choice choice : choices) {
@@ -30,11 +30,6 @@ public class SlashCommandContext extends CommandContext implements InteractionCo
return true; return true;
} }
@Override
public boolean canSkipPermissionCheck() {
return true;
}
@Override @Override
public SlashCommandInteractionEvent getEvent() { public SlashCommandInteractionEvent getEvent() {
return event; return event;
@@ -5,32 +5,65 @@ package me.bobert.netneutrality.core;
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import io.sentry.Sentry; import me.bobert.netneutrality.core.log.DiscordDebugChannelLogSink;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.log.Log4jLogSink;
import me.bobert.netneutrality.core.context.Context; import me.bobert.netneutrality.core.log.LogSink;
import me.bobert.netneutrality.core.context.TaskContext; import me.bobert.netneutrality.core.log.LoggedException;
import me.bobert.netneutrality.core.log.SentryLogSink;
import me.bobert.netneutrality.db.service.GuildServiceProvider; import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.exceptions.ContextException; import me.bobert.netneutrality.exceptions.ContextException;
import me.bobert.netneutrality.shared.util.MessageFunction; import me.bobert.netneutrality.utils.MessageFunction;
import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
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.CopyOnWriteArrayList;
public class LogHandler { public class LogHandler {
private static final Logger logger = LogManager.getLogger(LogHandler.class); private static final Logger logger = LogManager.getLogger(LogHandler.class);
private static volatile GuildServiceProvider guildServices; private static final CopyOnWriteArrayList<LogSink> sinks = new CopyOnWriteArrayList<>();
private static volatile DiscordDebugChannelLogSink discordSink;
static {
sinks.add(new Log4jLogSink());
sinks.add(new SentryLogSink());
}
/** /**
* Binds the guild-service provider used for debug-channel logging. Called once during bootstrap. * Binds the guild-service provider used for debug-channel logging. Called once during bootstrap.
* Internally registers (or replaces) the Discord debug-channel {@link LogSink}.
* *
* @param provider the live guild service provider * @param provider the live guild service provider
*/ */
public static void bind(GuildServiceProvider provider) { public static void bind(GuildServiceProvider provider) {
guildServices = provider; if (discordSink != null) {
sinks.remove(discordSink);
} }
discordSink = new DiscordDebugChannelLogSink(provider);
sinks.add(discordSink);
}
/**
* Registers an additional log destination. Sinks are notified for every exception logged via
* {@link #log(Exception)}.
*
* @param sink the sink to register
*/
public static void addSink(LogSink sink) {
sinks.add(sink);
}
/**
* Unregisters a previously registered log destination.
*
* @param sink the sink to remove
*/
public static void removeSink(LogSink sink) {
sinks.remove(sink);
}
/** /**
* Log an exception accordingly * Log an exception accordingly
@@ -38,17 +71,15 @@ public class LogHandler {
* @param e The exceptions to log * @param e The exceptions to log
*/ */
public static void log(Exception e) { public static void log(Exception e) {
Sentry.captureException(e); LoggedException event = LoggedException.of(e);
if (e instanceof ContextException exception) { for (LogSink sink : sinks) {
logInternally(exception); safelyHandle(sink, event);
tryLogToDebugChannel(exception);
tryGracefulContextError(exception);
return;
} }
logger.error(e.getMessage(), e); if (e instanceof ContextException exception) {
tryGracefulContextError(exception);
}
} }
public static void log(String s, Exception e) { public static void log(String s, Exception e) {
@@ -69,37 +100,11 @@ public class LogHandler {
} }
private static void logInternally(ContextException exception) { private static void safelyHandle(LogSink sink, LoggedException event) {
Context context = exception.getContext(); try {
String guildId = context.getGuild() == null ? "PrivateMessage" : context.getGuild().getId(); sink.handle(event);
} catch (Throwable t) {
logger.error("Exception thrown from context (guild: {}, user: {}): {}", guildId, logger.error("Log sink {} failed to handle exception", sink.getClass().getSimpleName(), t);
context.getDiscordUser().getId(),
exception.getMessage()
);
if (context instanceof CommandContext commandContext) {
logger.error("Caused by command: {}", commandContext.getCommand().getCommandName(), exception);
}
if (context instanceof TaskContext taskContext) {
logger.error("Caused by task with ID: {}", taskContext.getId(), exception);
}
}
private static void tryLogToDebugChannel(ContextException exception) {
Context context = exception.getContext();
if (context.getGuild() != null) {
GuildServiceProvider provider = guildServices;
if (provider != null && provider.get(context.getGuild()).isDebugEnabled()) {
context.getGuild()
.getTextChannels()
.stream()
.filter(textChannel -> textChannel.getName().equals("debug") || textChannel.getName().equals("log"))
.filter(TextChannel::canTalk)
.forEach(textChannel -> textChannel.sendMessageEmbeds(MessageFunction.buildDebugMessage(exception)).queue());
}
} }
} }
@@ -0,0 +1,43 @@
package me.bobert.netneutrality.core;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
/**
* Severity ordering used to gate what gets sent to a guild's log channel, from least to most verbose:
* {@code ERROR} &lt; {@code WARN} &lt; {@code INFO}.
*/
public enum LogSeverity {
ERROR,
WARN,
INFO;
/**
* Parses a severity name, falling back to {@link #ERROR} (the most restrictive level) when the
* value is missing or unrecognized.
*
* @param value the raw severity name (case-insensitive)
* @return the matching severity, or {@link #ERROR} as a safe default
*/
public static LogSeverity parse(String value) {
if (value == null || value.isEmpty()) {
return ERROR;
}
try {
return LogSeverity.valueOf(value.toUpperCase());
} catch (IllegalArgumentException e) {
return ERROR;
}
}
/**
* @param configured the guild's configured minimum severity
* @return {@code true} when this severity should be sent given the configured minimum
*/
public boolean meetsThreshold(LogSeverity configured) {
return this.ordinal() <= configured.ordinal();
}
}
@@ -1,20 +0,0 @@
package me.bobert.netneutrality.core;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.bootstrap.Lifecycle;
public class NetNeutralityBot implements Lifecycle {
@Override
public void start() throws Exception {
}
@Override
public int priority() {
return 0;
}
}
@@ -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,25 +12,47 @@ 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) {
CommandOptionsBuilder commandOptionsBuilder = new CommandOptionsBuilder(command.getType(), command);
command.registerCommandOptions(commandOptionsBuilder);
initContainer(commandOptionsBuilder);
} }
private CommandContainer(Command command, CommandDataImpl commandData) {
this.command = command;
this.commandData = commandData;
if (commandData != null) {
this.options = commandData.getOptions();
this.subcommands = commandData.getSubcommands();
this.subcommandGroups = commandData.getSubcommandGroups();
} else {
this.options = List.of();
this.subcommands = List.of();
this.subcommandGroups = List.of();
}
}
public static CommandContainer of(Command command) {
return new CommandContainer(command);
}
private static CommandDataImpl build(Command command) {
if (command == null) {
return null;
}
CommandOptionsBuilder builder = new CommandOptionsBuilder(command.getType(), command);
command.registerCommandOptions(builder);
return builder.build();
} }
public Command getCommand() { public Command getCommand() {
@@ -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();
} }
} }
@@ -6,6 +6,7 @@ package me.bobert.netneutrality.core.command;
*/ */
import me.bobert.netneutrality.core.tasks.Task; import me.bobert.netneutrality.core.tasks.Task;
import me.bobert.netneutrality.music.QueueActionResult;
public class CommandExecutionResult { public class CommandExecutionResult {
@@ -80,4 +81,18 @@ public class CommandExecutionResult {
public static CommandExecutionResult task(Task task) { public static CommandExecutionResult task(Task task) {
return new CommandExecutionResult(Result.TASK, null, task, null); return new CommandExecutionResult(Result.TASK, null, task, null);
} }
/**
* Adapts a {@link QueueActionResult} returned by {@link me.bobert.netneutrality.music.QueueManager}
* into a {@link CommandExecutionResult} so it can be surfaced as a Discord reply or HTTP response.
*/
public static CommandExecutionResult from(QueueActionResult result) {
return switch (result.getStatus()) {
case SUCCESS -> success(result.getMessage());
case INFO -> info(result.getMessage());
case WARN -> warn(result.getMessage());
case ERROR -> error(result.getMessage());
case NOACTION -> none();
};
}
} }
@@ -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,8 +84,7 @@ 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;
@@ -96,7 +95,7 @@ public class CommandManager implements CommandRegistry {
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;
} }
@@ -105,9 +104,6 @@ public class CommandManager implements CommandRegistry {
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());
} }
@@ -8,6 +8,7 @@ package me.bobert.netneutrality.core.command;
import io.sentry.Sentry; import io.sentry.Sentry;
import me.bobert.netneutrality.core.LogHandler; import me.bobert.netneutrality.core.LogHandler;
import me.bobert.netneutrality.core.command.precondition.CommandPrecondition; import me.bobert.netneutrality.core.command.precondition.CommandPrecondition;
import me.bobert.netneutrality.core.command.precondition.DisabledCommandPrecondition;
import me.bobert.netneutrality.core.command.precondition.GuildOnlyPrecondition; import me.bobert.netneutrality.core.command.precondition.GuildOnlyPrecondition;
import me.bobert.netneutrality.core.command.precondition.PermissionPrecondition; import me.bobert.netneutrality.core.command.precondition.PermissionPrecondition;
import me.bobert.netneutrality.core.command.precondition.SlashCommandOnlyPrecondition; import me.bobert.netneutrality.core.command.precondition.SlashCommandOnlyPrecondition;
@@ -15,7 +16,7 @@ import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.exceptions.CommandExecutionException; import me.bobert.netneutrality.exceptions.CommandExecutionException;
import me.bobert.netneutrality.exceptions.GracefulContextError; import me.bobert.netneutrality.exceptions.GracefulContextError;
import me.bobert.netneutrality.core.tasks.TaskManager; import me.bobert.netneutrality.core.tasks.TaskManager;
import me.bobert.netneutrality.shared.util.UserFunction; import me.bobert.netneutrality.utils.UserFunction;
import net.dv8tion.jda.api.interactions.commands.build.OptionData; import net.dv8tion.jda.api.interactions.commands.build.OptionData;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
@@ -52,6 +53,7 @@ public class CommandProcessor {
private static List<CommandPrecondition> defaultPreconditions() { private static List<CommandPrecondition> defaultPreconditions() {
return List.of( return List.of(
new GuildOnlyPrecondition(), new GuildOnlyPrecondition(),
new DisabledCommandPrecondition(),
new SlashCommandOnlyPrecondition(), new SlashCommandOnlyPrecondition(),
new PermissionPrecondition() new PermissionPrecondition()
); );
@@ -81,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.shared.util.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;
@@ -43,7 +42,7 @@ public abstract class MusicCommand extends AbstractCommand {
* @return the guild service * @return the guild service
*/ */
protected final GuildService guildService(Guild guild) { protected final GuildService guildService(Guild guild) {
return guildServices.get(guild); return guildServices.get(guild.getId());
} }
/** /**
@@ -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().getSetting(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().setSettingValue(GSettings.MUSIC_ROLE, null);
return true;
}
return member.getRoles().contains(neededRole);
}
return true;
} }
@Override @Override
@@ -106,7 +86,7 @@ public abstract class MusicCommand extends AbstractCommand {
return false; return false;
} }
String channelDb = guildService(guildMessageChannel.getGuild()).getGuildSettingService().getSetting(GSettings.MUSIC_CHANNEL); String channelDb = guildService(guildMessageChannel.getGuild()).getGuildSettingService().getOrDefault(GSettings.MUSIC_CHANNEL);
if (channelDb != null && !channelDb.isEmpty()) { if (channelDb != null && !channelDb.isEmpty()) {
return channelDb.equals(channel.getId()); return channelDb.equals(channel.getId());
} }
@@ -0,0 +1,52 @@
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.GuildServiceProvider;
import java.util.Arrays;
public class DisabledCommandPrecondition implements CommandPrecondition {
private static volatile GuildServiceProvider guildServices;
/**
* Binds the guild-service provider used to resolve the guild's configured {@code DISABLED_COMMANDS}.
* Called once during bootstrap.
*
* @param provider the live guild service provider
*/
public static void bind(GuildServiceProvider provider) {
guildServices = provider;
}
@Override
public boolean check(CommandContext context, Command command) {
GuildServiceProvider provider = guildServices;
if (provider == null || context.getGuild() == null) {
return true;
}
String disabledCommands = provider.get(context.getGuild().getId()).getGuildSettingService().getOrDefault(GSettings.DISABLED_COMMANDS);
if (disabledCommands.isEmpty()) {
return true;
}
boolean disabled = Arrays.stream(disabledCommands.split(","))
.map(String::trim)
.anyMatch(name -> name.equalsIgnoreCase(command.getCommandName()));
if (disabled) {
context.replyEphemeral("This command is disabled on this server");
return false;
}
return true;
}
}
@@ -7,13 +7,13 @@ package me.bobert.netneutrality.core.command.precondition;
import me.bobert.netneutrality.core.command.Command; import me.bobert.netneutrality.core.command.Command;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.shared.util.UserFunction; import me.bobert.netneutrality.utils.UserFunction;
public class PermissionPrecondition implements CommandPrecondition { 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;
} }
@@ -21,19 +21,21 @@ public class PermissionPrecondition implements CommandPrecondition {
return true; return true;
} }
if (command.canBeExecutedFrom(context.getChannel())) { if (!command.canBeExecutedFrom(context.getChannel())) {
return true; context.replyEphemeral("This command cannot be executed in the current channel");
}
if (command.canBeExecutedBy(context.getGuildUser())) {
return true;
}
if (command.needAdmin() && UserFunction.isAdmin(context.getGuildUser())) {
return true;
}
context.reply("You are not powerful enough, scrub");
return false; return false;
} }
if (command.needAdmin() && !UserFunction.isAdmin(context.getGuildUser())) {
context.replyEphemeral("You are not powerful enough, scrub");
return false;
}
if (!command.canBeExecutedBy(context.getGuildUser())) {
context.replyEphemeral("You are not powerful enough, scrub");
return false;
}
return true;
}
} }
@@ -5,7 +5,7 @@ package me.bobert.netneutrality.core.context;
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import me.bobert.netneutrality.shared.util.ContextUtils; import me.bobert.netneutrality.utils.ContextUtils;
import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.components.MessageTopLevelComponent; import net.dv8tion.jda.api.components.MessageTopLevelComponent;
import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.Guild;
@@ -0,0 +1,63 @@
package me.bobert.netneutrality.core.log;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.LogSeverity;
import me.bobert.netneutrality.core.context.Context;
import me.bobert.netneutrality.db.GSettings;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.db.service.GuildSettingService;
import me.bobert.netneutrality.exceptions.ContextException;
import me.bobert.netneutrality.utils.MessageFunction;
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
/**
* A {@link LogSink} that posts {@link ContextException}s to a guild's debug/log channel, subject to
* {@link GuildServiceProvider#isDebugEnabled} and the guild's configured {@link LogSeverity} threshold.
*/
public class DiscordDebugChannelLogSink implements LogSink {
private final GuildServiceProvider guildServices;
/**
* @param guildServices the guild service provider used to look up debug settings per guild
*/
public DiscordDebugChannelLogSink(GuildServiceProvider guildServices) {
this.guildServices = guildServices;
}
@Override
public void handle(LoggedException event) {
if (!(event.exception() instanceof ContextException exception)) {
return;
}
Context context = exception.getContext();
if (context.getGuild() == null) {
return;
}
if (guildServices == null || !guildServices.get(context.getGuild().getId()).isDebugEnabled()) {
return;
}
GuildSettingService settingService = guildServices.get(context.getGuild().getId()).getGuildSettingService();
LogSeverity configured = LogSeverity.parse(settingService.getOrDefault(GSettings.LOG_LEVEL));
if (!LogSeverity.ERROR.meetsThreshold(configured)) {
return;
}
String timezone = settingService.getOrDefault(GSettings.TIMEZONE);
context.getGuild()
.getTextChannels()
.stream()
.filter(textChannel -> textChannel.getName().equals("debug") || textChannel.getName().equals("log"))
.filter(TextChannel::canTalk)
.forEach(textChannel -> textChannel.sendMessageEmbeds(MessageFunction.buildDebugMessage(exception, timezone)).queue());
}
}
@@ -0,0 +1,53 @@
package me.bobert.netneutrality.core.log;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.core.context.Context;
import me.bobert.netneutrality.core.context.TaskContext;
import me.bobert.netneutrality.exceptions.ContextException;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
/**
* A {@link LogSink} that logs every exception via Log4j, including contextual details
* (guild, user, command/task) when the exception is a {@link ContextException}.
*/
public class Log4jLogSink implements LogSink {
private static final Logger logger = LogManager.getLogger(Log4jLogSink.class);
@Override
public void handle(LoggedException event) {
Exception exception = event.exception();
if (exception instanceof ContextException contextException) {
logInternally(contextException);
return;
}
logger.error(exception.getMessage(), exception);
}
private void logInternally(ContextException exception) {
Context context = exception.getContext();
String guildId = context.getGuild() == null ? "PrivateMessage" : context.getGuild().getId();
logger.error("Exception thrown from context (guild: {}, user: {}): {}", guildId,
context.getDiscordUser().getId(),
exception.getMessage()
);
if (context instanceof CommandContext commandContext) {
logger.error("Caused by command: {}", commandContext.getCommand().getCommandName(), exception);
}
if (context instanceof TaskContext taskContext) {
logger.error("Caused by task with ID: {}", taskContext.getId(), exception);
}
}
}
@@ -0,0 +1,22 @@
package me.bobert.netneutrality.core.log;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
/**
* A pluggable log destination. Implementations decide internally whether and how to act on a
* {@link LoggedException}. Failures thrown by a sink are caught and isolated by the dispatcher
* (e.g. {@code LogHandler}) so one broken sink never blocks the others.
*/
public interface LogSink {
/**
* Handles a logged exception event. Implementations should not throw checked exceptions that
* would prevent other sinks from running; any thrown exception is caught by the dispatcher.
*
* @param event the logged exception event
*/
void handle(LoggedException event);
}
@@ -0,0 +1,35 @@
package me.bobert.netneutrality.core.log;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.context.Context;
/**
* Immutable event payload dispatched to every registered {@link LogSink}.
*
* @param exception the exception being logged
* @param context the context the exception occurred in, or {@code null} if none
* @param message an optional additional message, or {@code null} if none
*/
public record LoggedException(Exception exception, Context context, String message) {
/**
* @param e the exception to log
* @return a {@link LoggedException} with no context or message
*/
public static LoggedException of(Exception e) {
return new LoggedException(e, null, null);
}
/**
* @param message the message to attach to the exception
* @param e the exception to log
* @return a {@link LoggedException} with no context
*/
public static LoggedException of(String message, Exception e) {
return new LoggedException(e, null, message);
}
}
@@ -0,0 +1,19 @@
package me.bobert.netneutrality.core.log;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import io.sentry.Sentry;
/**
* A {@link LogSink} that reports every logged exception to Sentry.
*/
public class SentryLogSink implements LogSink {
@Override
public void handle(LoggedException event) {
Sentry.captureException(event.exception());
}
}
@@ -65,7 +65,7 @@ public class ScheduledTaskManager implements Lifecycle {
ScheduledTask.TimeFrame timeFrame = task.getTimeFrame(); ScheduledTask.TimeFrame timeFrame = task.getTimeFrame();
Runnable runnable = () -> { Runnable runnable = () -> {
logger.info("Executing scheduled task: {}", taskId); logger.debug("Executing scheduled task: {}", taskId);
try { try {
task.run(); task.run();
@@ -5,6 +5,7 @@ package me.bobert.netneutrality.core.tasks;
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import com.fasterxml.jackson.databind.node.ObjectNode;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
/** /**
@@ -13,6 +14,23 @@ import me.bobert.netneutrality.core.context.CommandContext;
* so that it can be stored in a database. * so that it can be stored in a database.
*/ */
public interface PersistableTask extends Task { public interface PersistableTask extends Task {
/**
* Stable logical type id stored in {@code tasks.task_class}.
* Defaults to {@link Task#getName()} so package moves and class renames are not breaking changes.
*
* @return the stable type id of this task type
*/
default String typeId() {
return getName();
}
/**
* Rebuilds this instance's state from a payload previously produced by {@link #toPayload()}.
*
* @param payload the persisted payload, must not be {@code null}
*/
void restore(ObjectNode payload);
void insert(CommandContext context); void insert(CommandContext context);
void save(); void save();
@@ -15,6 +15,8 @@ import me.bobert.netneutrality.core.tasks.trait.NotAcknowledgeable;
import me.bobert.netneutrality.core.tasks.trait.WithButton; import me.bobert.netneutrality.core.tasks.trait.WithButton;
import me.bobert.netneutrality.core.tasks.trait.WithChildren; import me.bobert.netneutrality.core.tasks.trait.WithChildren;
import me.bobert.netneutrality.core.tasks.trait.WithParent; import me.bobert.netneutrality.core.tasks.trait.WithParent;
import me.bobert.netneutrality.core.tasks.persistence.PersistableTaskRegistry;
import me.bobert.netneutrality.db.repository.TaskRepository;
import me.bobert.netneutrality.db.service.TaskService; import me.bobert.netneutrality.db.service.TaskService;
import me.bobert.netneutrality.exceptions.GracefulContextError; import me.bobert.netneutrality.exceptions.GracefulContextError;
import me.bobert.netneutrality.exceptions.TaskExecutionException; import me.bobert.netneutrality.exceptions.TaskExecutionException;
@@ -45,21 +47,24 @@ public class TaskManagerImpl implements TaskManager {
private final Map<String, CommandContext> deletableTasksContext; private final Map<String, CommandContext> deletableTasksContext;
private final Map<String, ReentrantLock> tasksLock; private final Map<String, ReentrantLock> tasksLock;
private final ApplicationContext context; private final ApplicationContext context;
private final PersistableTaskRegistry persistableTaskRegistry;
/** /**
* Creates a task manager that can inject context-registered dependencies into reflectively * Creates a task manager that can inject context-registered dependencies into reflectively
* loaded tasks. * loaded tasks.
* *
* @param context the application context; must not be {@code null} * @param context the application context; must not be {@code null}
* @param taskRepository the repository persisting {@link PersistableTask}s
*/ */
public TaskManagerImpl(ApplicationContext context) { public TaskManagerImpl(ApplicationContext context, TaskRepository taskRepository) {
Objects.requireNonNull(context, "ApplicationContext must not be null"); Objects.requireNonNull(context, "ApplicationContext must not be null");
this.tasks = new ConcurrentHashMap<>(); this.tasks = new ConcurrentHashMap<>();
this.pendingTasks = new ConcurrentHashMap<>(); this.pendingTasks = new ConcurrentHashMap<>();
this.deletableTasksContext = new ConcurrentHashMap<>(); this.deletableTasksContext = new ConcurrentHashMap<>();
this.tasksLock = new ConcurrentHashMap<>(); this.tasksLock = new ConcurrentHashMap<>();
this.taskService = new TaskService(this);
this.context = context; this.context = context;
this.persistableTaskRegistry = new PersistableTaskRegistry(context);
this.taskService = new TaskService(this, persistableTaskRegistry, taskRepository);
} }
@Override @Override
@@ -76,6 +81,7 @@ public class TaskManagerImpl implements TaskManager {
logger.info("Initializing task manager..."); logger.info("Initializing task manager...");
loadBuiltInTasks(); loadBuiltInTasks();
persistableTaskRegistry.scan();
taskService.loadTasks(); taskService.loadTasks();
} }
@@ -5,10 +5,11 @@ package me.bobert.netneutrality.core.tasks.impl;
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import com.fasterxml.jackson.databind.node.ObjectNode;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.core.tasks.PersistableTask; import me.bobert.netneutrality.core.tasks.PersistableTask;
import me.bobert.netneutrality.core.tasks.TaskManager; import me.bobert.netneutrality.core.tasks.TaskManager;
import org.json.JSONObject; import me.bobert.netneutrality.utils.Json;
public abstract class AbstractPersistableTask extends AbstractTask implements PersistableTask { public abstract class AbstractPersistableTask extends AbstractTask implements PersistableTask {
@@ -60,9 +61,33 @@ public abstract class AbstractPersistableTask extends AbstractTask implements Pe
taskManager().removeTask(this); taskManager().removeTask(this);
} }
/**
* No-op default: only tasks carrying extra state need to override this.
*
* @param payload the persisted payload
*/
@Override
public void restore(ObjectNode payload) {
// Nothing to restore by default
}
/**
* No-op default: only tasks carrying extra state need to override this.
*
* @param payload the payload being written
*/
protected void writePayload(ObjectNode payload) {
// Nothing extra to write by default
}
@Override @Override
public String toPayload() { public String toPayload() {
JSONObject payload = new JSONObject(this); ObjectNode payload = Json.object();
payload.put("id", getId());
payload.put("lockId", getLockId());
payload.put("name", getName());
writePayload(payload);
return payload.toString(); return payload.toString();
} }
@@ -5,6 +5,8 @@ package me.bobert.netneutrality.core.tasks.impl;
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import me.bobert.netneutrality.context.tasks.EmojiTaskContext; import me.bobert.netneutrality.context.tasks.EmojiTaskContext;
import me.bobert.netneutrality.core.tasks.TaskManager; import me.bobert.netneutrality.core.tasks.TaskManager;
@@ -13,9 +15,9 @@ import java.util.concurrent.ConcurrentHashMap;
public abstract class EmojiTask extends AbstractPersistableTask { public abstract class EmojiTask extends AbstractPersistableTask {
private final Map<String, String> emojiMapping = new ConcurrentHashMap<>(); private final Map<String, String> emojiMapping = new ConcurrentHashMap<>();
private final String guildId; private String guildId;
private final String channelId; private String channelId;
private final String messageId; private String messageId;
public EmojiTask(TaskManager taskManager, String guildId, String channelId, String messageId) { public EmojiTask(TaskManager taskManager, String guildId, String channelId, String messageId) {
super(taskManager); super(taskManager);
@@ -24,6 +26,45 @@ public abstract class EmojiTask extends AbstractPersistableTask {
this.messageId = messageId; this.messageId = messageId;
} }
/**
* Constructor used by the persistable task registry: the message coordinates and the emoji
* mapping are hydrated afterwards by {@link #restore(ObjectNode)}.
*
* @param taskManager the application task manager, must not be {@code null}
*/
public EmojiTask(TaskManager taskManager) {
super(taskManager);
}
@Override
public void restore(ObjectNode payload) {
this.guildId = payload.path("guildId").asText(null);
this.channelId = payload.path("channelId").asText(null);
this.messageId = payload.path("messageId").asText(null);
emojiMapping.clear();
JsonNode mapping = payload.path("emojiMapping");
if (mapping.isObject()) {
mapping.properties().forEach(entry -> emojiMapping.put(entry.getKey(), entry.getValue().asText()));
}
}
@Override
protected void writePayload(ObjectNode payload) {
putIfPresent(payload, "guildId", guildId);
putIfPresent(payload, "channelId", channelId);
putIfPresent(payload, "messageId", messageId);
ObjectNode mapping = payload.putObject("emojiMapping");
emojiMapping.forEach(mapping::put);
}
private static void putIfPresent(ObjectNode payload, String key, String value) {
if (value != null) {
payload.put(key, value);
}
}
public String getGuildId() { public String getGuildId() {
return guildId; return guildId;
} }
@@ -0,0 +1,102 @@
package me.bobert.netneutrality.core.tasks.persistence;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import com.fasterxml.jackson.databind.node.ObjectNode;
import me.bobert.netneutrality.bootstrap.ApplicationContext;
import me.bobert.netneutrality.bootstrap.ReflectionHelper;
import me.bobert.netneutrality.core.tasks.PersistableTask;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.util.Collections;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/**
* Registry of the persistable task types available on the classpath.
*
* <p>Types are discovered by scanning for concrete {@link PersistableTask} implementations and are
* keyed by their {@link PersistableTask#typeId()}, which is the value stored in
* {@code tasks.task_class}. Restoring a row creates a fresh instance through the regular DI path
* and lets the task hydrate itself from its payload.
*/
public final class PersistableTaskRegistry {
private static final Logger logger = LogManager.getLogger(PersistableTaskRegistry.class);
private final Map<String, Class<? extends PersistableTask>> typesById = new ConcurrentHashMap<>();
private final ApplicationContext context;
/**
* @param context the DI context used to instantiate discovered task types, must not be {@code null}
*/
public PersistableTaskRegistry(ApplicationContext context) {
this.context = Objects.requireNonNull(context, "ApplicationContext must not be null");
}
/**
* Discovers every concrete {@link PersistableTask} implementation and registers it by its type id.
*
* @throws IllegalStateException if two task types declare the same type id
*/
public void scan() {
typesById.clear();
ReflectionHelper.loadTasks(PersistableTask.class, context, this::register);
logger.info("Registered {} persistable task type(s): {}", typesById.size(), typesById.keySet());
}
void register(PersistableTask prototype) {
String typeId = prototype.typeId();
Class<? extends PersistableTask> type = prototype.getClass();
Class<? extends PersistableTask> previous = typesById.putIfAbsent(typeId, type);
if (previous != null && !previous.equals(type)) {
throw new IllegalStateException(String.format(
"Duplicate persistable task type id '%s' declared by %s and %s",
typeId, previous.getName(), type.getName()));
}
}
/**
* @param typeId the type id to look up
* @return {@code true} if a task type is registered under the given id
*/
public boolean isRegistered(String typeId) {
return typeId != null && typesById.containsKey(typeId);
}
/**
* @return the registered type ids
*/
public Set<String> getTypeIds() {
return Collections.unmodifiableSet(typesById.keySet());
}
/**
* Creates a fresh task instance for the given type id and hydrates it from the payload.
*
* @param typeId the registered type id
* @param payload the payload previously produced by {@link PersistableTask#toPayload()}
* @return the restored task
* @throws IllegalArgumentException if no task type is registered under {@code typeId}
* @throws ReflectiveOperationException if the task type cannot be instantiated
*/
public PersistableTask create(String typeId, ObjectNode payload) throws ReflectiveOperationException {
Class<? extends PersistableTask> type = typesById.get(typeId);
if (type == null) {
throw new IllegalArgumentException("No persistable task type registered for id: " + typeId);
}
PersistableTask task = ReflectionHelper.instantiate(type, context);
task.restore(payload);
return task;
}
}
@@ -1,81 +0,0 @@
package me.bobert.netneutrality.core.tasks.persistence;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.tasks.PersistableTask;
import org.json.JSONArray;
import org.json.JSONObject;
import java.lang.reflect.InvocationTargetException;
import java.util.Arrays;
import java.util.Map;
public class TaskFactory {
public enum ParamType {
STRING(String.class),
INT(Integer.class),
MAP(Map.class);
private final Class<?> typeClass;
ParamType(Class<?> typeClass) {
this.typeClass = typeClass;
}
public Class<?> getTypeClass() {
return typeClass;
}
public static ParamType fromString(String type) {
return switch (type.toLowerCase()) {
case "string" -> STRING;
case "int" -> INT;
case "map" -> MAP;
default -> throw new IllegalArgumentException("Unsupported type: " + type);
};
}
}
public static PersistableTask createTaskInstance(String className, JSONArray params, JSONArray types, JSONObject payload) throws Exception {
if (params.length() != types.length()) {
throw new RuntimeException("Wrong number of parameters and types found in task mappings");
}
Class<?> clazz = Class.forName(className);
if (!PersistableTask.class.isAssignableFrom(clazz)) {
throw new IllegalArgumentException("Class does not implement PersistableTask.");
}
Class<?>[] classTypes = new Class<?>[types.length()];
Object[] classParams = new Object[params.length()];
for (int i = 0; i < params.length(); i++) {
ParamType paramType = ParamType.fromString(types.getString(i));
String paramName = params.getString(i);
classTypes[i] = paramType.getTypeClass();
classParams[i] = extractValue(paramType, paramName, payload);
}
try {
return (PersistableTask) clazz.getConstructor(classTypes).newInstance(classParams);
} catch (NoSuchMethodException e) {
throw new NoSuchMethodException("No matching constructor found with parameters: " + Arrays.toString(classTypes));
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("Invalid arguments for constructor. Expected: " + Arrays.toString(classParams), e);
} catch (InvocationTargetException e) {
throw new RuntimeException("Constructor threw an exception", e.getCause());
}
}
private static Object extractValue(ParamType type, String paramName, JSONObject payload) {
return switch (type) {
case STRING -> payload.getString(paramName);
case INT -> payload.getInt(paramName);
case MAP -> payload.getJSONObject(paramName).toMap();
};
}
}
@@ -1,94 +0,0 @@
package me.bobert.netneutrality.core.tasks.persistence;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.tasks.PersistableTask;
import org.apache.commons.io.IOUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.json.JSONArray;
import org.json.JSONObject;
import java.io.FileNotFoundException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
public class TaskRegistry {
@FunctionalInterface
public interface TaskCreator {
PersistableTask fromPayload(JSONObject payload);
}
private static final Logger logger = LogManager.getLogger(TaskRegistry.class);
private static final Map<String, TaskCreator> taskCreators = new HashMap<>();
private static final String FILE_NAME = "persistable-tasks.json";
// JSON keys as constants
private static final String KEY_TASKS = "tasks";
private static final String KEY_CLASS = "class";
private static final String KEY_PARAMS = "params";
private static final String KEY_TYPES = "types";
public static TaskCreator getTaskCreator(String className) {
return taskCreators.get(className);
}
public static void loadTaskConfig() {
logger.info("Reading task mappings from {}..", FILE_NAME);
try (InputStream is = TaskFactory.class.getClassLoader().getResourceAsStream(FILE_NAME)) {
if (is == null) {
throw new FileNotFoundException("Could not load: " + FILE_NAME);
}
String jsonString = IOUtils.toString(is, StandardCharsets.UTF_8);
JSONObject json = new JSONObject(jsonString);
JSONArray tasksArray = json.optJSONArray(KEY_TASKS);
if (tasksArray == null) {
logger.warn("No tasks found in JSON: {}", FILE_NAME);
return;
}
for (int i = 0; i < tasksArray.length(); i++) {
JSONObject taskJson = tasksArray.getJSONObject(i);
String className = taskJson.optString(KEY_CLASS, "").trim();
if (className.isEmpty()) {
logger.warn("Missing JSON key '{}' at index {}", KEY_CLASS, i);
continue;
}
JSONArray params = taskJson.optJSONArray(KEY_PARAMS);
if (params == null) {
logger.warn("Missing JSON key '{}' for class: {}", KEY_PARAMS, className);
continue;
}
JSONArray types = taskJson.optJSONArray(KEY_TYPES);
if (types == null) {
logger.warn("Missing JSON key '{}' for class: {}", KEY_TYPES, className);
continue;
}
taskCreators.put(className, payload -> {
try {
return TaskFactory.createTaskInstance(className, params, types, payload);
} catch (Exception e) {
logger.error("Failed to create instance of '{}': {}", className, e.getMessage());
return null;
}
});
}
logger.info("{} mappings registered", taskCreators.size());
} catch (Exception e) {
logger.error(e);
}
}
}
@@ -0,0 +1,49 @@
package me.bobert.netneutrality.db;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.utils.SettingsProvider;
import java.util.Objects;
/**
* JDBC connection settings shared by Flyway ({@link DatabaseMigration}) and Hibernate
* ({@link SessionManager}), so both always connect to the same database.
*
* @param url the JDBC url
* @param user the database user
* @param password the database password (never logged, see {@link #toString()})
*/
public record DatabaseConfig(String url, String user, String password) {
public DatabaseConfig {
Objects.requireNonNull(url, "url");
Objects.requireNonNull(user, "user");
password = password == null ? "" : password;
}
/**
* Builds the config from the {@code MYSQL_*} settings.
*
* @param settings the bot settings
* @return the database config
*/
public static DatabaseConfig from(SettingsProvider settings) {
String dbName = settings.get("MYSQL_DATABASE", "netneutrality");
String dbUser = settings.get("MYSQL_USER", "root");
String dbPass = settings.get("MYSQL_PASSWORD", "");
String dbHost = settings.get("MYSQL_HOST", "localhost");
String dbPort = settings.get("MYSQL_PORT", "3306");
String url = String.format("jdbc:mysql://%s:%s/%s?createDatabaseIfNotExist=true", dbHost, dbPort, dbName);
return new DatabaseConfig(url, dbUser, dbPass);
}
@Override
public String toString() {
return "DatabaseConfig[url=" + url + ", user=" + user + ", password=***]";
}
}
@@ -1,38 +1,39 @@
package me.bobert.netneutrality.db; package me.bobert.netneutrality.db;
/* /*
* Copyright (c) anthorob * Copyright (c) anthorob
* *
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import me.bobert.netneutrality.utils.SettingsProvider;
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.flywaydb.core.Flyway; import org.flywaydb.core.Flyway;
import org.flywaydb.core.api.output.MigrateResult; import org.flywaydb.core.api.output.MigrateResult;
public class DatabaseMigration { public final class DatabaseMigration {
private final static Logger logger = LogManager.getLogger(DatabaseMigration.class); private static final Logger logger = LogManager.getLogger(DatabaseMigration.class);
public static void migrate(SettingsProvider settings) { private DatabaseMigration() {
String dbName = settings.get("MYSQL_DATABASE", "netneutrality"); }
String dbUser = settings.get("MYSQL_USER", "root");
String dbPass = settings.get("MYSQL_PASSWORD", "");
String dbHost = settings.get("MYSQL_HOST", "localhost");
String dbPort = settings.get("MYSQL_PORT", "3306");
String dbUrl = String.format("jdbc:mysql://%s:%s/%s", dbHost, dbPort, dbName);
/**
* Applies all pending Flyway migrations to the configured database.
*
* @param config the database connection settings
* @throws IllegalStateException if the migration fails
*/
public static void migrate(DatabaseConfig config) {
try { try {
logger.info("Running database migrations.."); logger.info("Running database migrations..");
Flyway flyway = Flyway.configure() Flyway flyway = Flyway.configure()
.dataSource(dbUrl, dbUser, dbPass) .dataSource(config.url(), config.user(), config.password())
.load(); .load();
MigrateResult result = flyway.migrate(); MigrateResult result = flyway.migrate();
logger.info("Database migrations finished successfully! (total: {})", result.migrationsExecuted); logger.info("Database migrations finished successfully! (total: {})", result.migrationsExecuted);
} catch (Exception e) { } catch (Exception e) {
logger.error(e); throw new IllegalStateException("Database migration failed", e);
System.exit(1);
} }
} }
} }
@@ -1,23 +1,64 @@
package me.bobert.netneutrality.db; package me.bobert.netneutrality.db;
/* /*
* Copyright (c) anthorob * Copyright (c) anthorob
* *
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
/**
* Per-guild settings stored in the {@code guild_settings} table.
*
* <p>Only values that differ from the default are stored; setting a value back to its default (or
* {@code null}) deletes the row. {@link #getName()} is the key persisted in the {@code name}
* column and must never change for an existing setting.
*/
public enum GSettings { public enum GSettings {
//TODO: description /** The role required to use music commands; empty means everyone may use them. */
MUSIC_ROLE("MUSIC_ROLE", ""), MUSIC_ROLE("MUSIC_ROLE", "", "The role required to use music commands; empty means everyone may use them", SettingType.ROLE),
MUSIC_CHANNEL("MUSIC_CHANNEL", ""), /** Id of the only text channel music commands may be used in; empty means any channel. */
MUSIC_VOLUME("MUSIC_VOLUME", "30"), MUSIC_CHANNEL("MUSIC_CHANNEL", "", "Id of the only text channel music commands may be used in; empty means any channel", SettingType.CHANNEL),
MUSIC_MAXPLAYLISTSIZE("MUSIC_MAXPLAYLISTSIZE", "20"); /** Default playback volume (0-100) applied when a music player is created. */
MUSIC_VOLUME("MUSIC_VOLUME", "30", "Default playback volume (0-100) applied when a music player is created", SettingType.INTEGER),
/** Playlist size above which the bot asks for confirmation before queueing every track. */
MUSIC_MAXPLAYLISTSIZE("MUSIC_MAXPLAYLISTSIZE", "20", "Playlist size above which the bot asks for confirmation before queueing every track", SettingType.INTEGER),
/** Role that may skip/stop/adjust volume without needing to be the current DJ; empty means the built-in DJ rule only. */
MUSIC_DJ_ROLE("MUSIC_DJ_ROLE", "", "Role that may control music playback without being the current DJ; empty means the built-in DJ rule only", SettingType.ROLE),
/** Percentage (0-100) of connected listeners required to approve a vote-skip. */
MUSIC_VOTE_SKIP_THRESHOLD("MUSIC_VOTE_SKIP_THRESHOLD", "50", "Percentage (0-100) of connected listeners required to approve a vote-skip", SettingType.PERCENTAGE),
/** Minutes of voice channel inactivity before the bot automatically disconnects. */
MUSIC_IDLE_TIMEOUT("MUSIC_IDLE_TIMEOUT", "10", "Minutes of voice channel inactivity before the bot automatically disconnects", SettingType.INTEGER),
/** Per-guild override of the bot's message command prefix; empty means the bot-wide default. */
COMMAND_PREFIX("COMMAND_PREFIX", "", "Per-guild override of the bot's message command prefix; empty means the bot-wide default", SettingType.STRING),
/** Role granted admin permissions for admin-only commands, in addition to Discord's own permission checks. */
ADMIN_ROLE("ADMIN_ROLE", "", "Role granted admin permissions for admin-only commands, in addition to Discord's own permission checks", SettingType.ROLE),
/** Comma-separated list of command names disabled on this guild. */
DISABLED_COMMANDS("DISABLED_COMMANDS", "", "Comma-separated list of command names disabled on this guild", SettingType.STRING),
/** Minimum severity of events sent to the log channel (e.g. ERROR, WARN, INFO). */
LOG_LEVEL("LOG_LEVEL", "ERROR", "Minimum severity of events sent to the log channel (e.g. ERROR, WARN, INFO)", SettingType.ENUM, "ERROR", "WARN", "INFO", "DEBUG"),
/** Preferred locale for the bot's replies on this guild. */
LOCALE("LOCALE", "en", "Preferred locale for the bot's replies on this guild", SettingType.STRING),
/** Timezone used to display timestamps for this guild. */
TIMEZONE("TIMEZONE", "UTC", "Timezone used to display timestamps for this guild", SettingType.STRING),
/** Default search engine used when a play query is not a direct url (YOUTUBE, YTMUSIC or SOUNDCLOUD). */
MUSIC_SEARCH_ENGINE("MUSIC_SEARCH_ENGINE", "YOUTUBE", "Default search engine used when a play query is not a direct url", SettingType.ENUM, "YOUTUBE", "YTMUSIC", "SOUNDCLOUD");
private final String name; private final String name;
private final String defaultValue; private final String defaultValue;
private final String description;
private final SettingType type;
private final String[] allowedValues;
GSettings(String name, String defaultValue) { GSettings(String name, String defaultValue, String description, SettingType type) {
this(name, defaultValue, description, type, new String[0]);
}
GSettings(String name, String defaultValue, String description, SettingType type, String... allowedValues) {
this.name = name; this.name = name;
this.defaultValue = defaultValue; this.defaultValue = defaultValue;
this.description = description;
this.type = type;
this.allowedValues = allowedValues;
} }
public String getName() { public String getName() {
@@ -27,4 +68,16 @@ public enum GSettings {
public String getDefaultValue() { public String getDefaultValue() {
return defaultValue; return defaultValue;
} }
public String getDescription() {
return description;
}
public SettingType getType() {
return type;
}
public String[] getAllowedValues() {
return allowedValues;
}
} }
@@ -1,158 +0,0 @@
package me.bobert.netneutrality.db;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.db.entities.OGuild;
import me.bobert.netneutrality.db.service.GuildService;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.hibernate.HibernateException;
import org.hibernate.ObjectNotFoundException;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.Transaction;
import org.hibernate.exception.ConstraintViolationException;
import java.io.Serializable;
import java.util.Objects;
import java.util.function.Consumer;
import java.util.function.Function;
/**
* Static Hibernate helpers used by repositories and domain services.
*
* <p>Because repositories are created deep in the object graph without constructor injection,
* this class holds a single {@link SessionProvider} bound once during bootstrap via
* {@link #bind(SessionProvider)}. That is the only global access point for sessions — there is no
* {@code SessionManager.instance()} shim.
*/
public class JpaUtils {
private static final Logger logger = LogManager.getLogger(JpaUtils.class);
private static volatile SessionProvider sessions;
private static volatile Function<String, GuildService> guildServiceLookup;
private JpaUtils() {
}
/**
* Binds the application {@link SessionProvider}. Called once from {@code SessionManager}'s
* constructor during context registration.
*
* @param provider the live session provider
*/
public static void bind(SessionProvider provider) {
sessions = Objects.requireNonNull(provider, "SessionProvider");
}
/**
* Optional guild-service lookup used when recovering a missing {@link OGuild} entity. Bound from
* bootstrap once the {@code GuildServiceProvider} is available.
*
* @param lookup guild-id → service resolver
*/
public static void bindGuildServiceLookup(Function<String, GuildService> lookup) {
guildServiceLookup = Objects.requireNonNull(lookup, "guildServiceLookup");
}
private static SessionFactory factory() {
SessionProvider provider = sessions;
if (provider == null) {
throw new IllegalStateException("JpaUtils has not been bound to a SessionProvider");
}
return provider.getSessionFactory();
}
/**
* Tries to read the persistent state into the object.
* <p>
* If the object is not found in the database and is of type {@link OGuild}, tries to recreate entity and throws
* an exception to block execution.
* <p>
*
* @param session The session in which to load the persistent object
* @param object The persistent object
* @param id Identifier
*/
public static void tryLoad(Session session, Object object, Serializable id) {
try {
session.load(object, id);
} catch (HibernateException exception) {
if (exception instanceof ObjectNotFoundException) {
if (object instanceof OGuild oGuild) {
String cachedId = oGuild.getGuildId();
logger.error("Trying to load Guild[{}] while entity doesn't exist in database, reloading..", cachedId);
Function<String, GuildService> lookup = guildServiceLookup;
if (lookup == null) {
session.close();
throw exception;
}
GuildService service = lookup.apply(cachedId);
service.reloadEntity();
service.getBlacklistService().removeGuildBlacklist();
logger.info("Guild[{}] reloaded", cachedId);
}
}
session.close();
throw exception;
}
}
public static Session openSession() {
return factory().openSession();
}
public static void runWithSession(Consumer<Session> consumer) {
try (Session session = factory().openSession()) {
consumer.accept(session);
}
}
public static void runWithTransaction(Consumer<Session> consumer) {
try (Session session = factory().openSession()) {
session.beginTransaction();
consumer.accept(session);
session.getTransaction().commit();
}
}
public static void updateWithTransaction(Object persistantEntity, Runnable runnable) {
try (Session session = factory().openSession()) {
Transaction transaction = session.beginTransaction();
try {
runnable.run();
session.merge(persistantEntity);
transaction.commit();
} catch (ConstraintViolationException e) {
logger.error(e);
throw e;
} catch (Exception e) {
logger.error(e);
transaction.rollback();
}
}
}
/**
* Loads the entity and relations from the database and executes the given consumer.
* This will load all Lazy relationships defined in the entity.
*
* @param persistantEntity The entity to load
* @param id The identifier for the entity
* @param consumer The comsumer, which exposes the session
*/
public static void getWithSession(Object persistantEntity, Serializable id, Consumer<Session> consumer) {
try (Session session = factory().openSession()) {
tryLoad(session, persistantEntity, id);
consumer.accept(session);
}
}
}
@@ -1,43 +0,0 @@
package me.bobert.netneutrality.db;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.hibernate.Session;
import org.hibernate.Transaction;
import org.hibernate.exception.ConstraintViolationException;
import java.util.List;
public interface Repository<O> {
Logger logger = LogManager.getLogger(Repository.class);
List<O> findAll();
O findById(String id);
default void save(O entity) {
Transaction transaction = null;
try (Session session = JpaUtils.openSession()) {
transaction = session.beginTransaction();
session.merge(entity);
transaction.commit();
} catch (ConstraintViolationException e) {
logger.error(e);
throw e;
} catch (Exception e) {
logger.error(e);
if (transaction != null) {
transaction.rollback();
}
}
}
void deleteBy(String id);
}
@@ -1,4 +1,5 @@
package me.bobert.netneutrality.db; package me.bobert.netneutrality.db;
/* /*
* Copyright (c) anthorob * Copyright (c) anthorob
* *
@@ -10,7 +11,6 @@ import me.bobert.netneutrality.utils.SettingsProvider;
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.hibernate.SessionFactory; import org.hibernate.SessionFactory;
import org.hibernate.boot.Metadata;
import org.hibernate.boot.MetadataSources; import org.hibernate.boot.MetadataSources;
import org.hibernate.boot.registry.StandardServiceRegistry; import org.hibernate.boot.registry.StandardServiceRegistry;
import org.hibernate.boot.registry.StandardServiceRegistryBuilder; import org.hibernate.boot.registry.StandardServiceRegistryBuilder;
@@ -18,24 +18,34 @@ import org.hibernate.cfg.Environment;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.Objects;
/** /**
* Instance-based {@link SessionProvider} owning the Hibernate {@link SessionFactory} and its * Instance-based {@link SessionProvider} owning the Hibernate {@link SessionFactory} and its
* service registry. * service registry.
* *
* <p>On construction the instance is bound into {@link JpaUtils} so repositories and other static * <p>{@link #start()} first applies the Flyway migrations and only then builds the session factory,
* persistence helpers can open sessions without a global {@code SessionManager} singleton. * which validates the mapped entities against the migrated schema ({@code hbm2ddl.auto=validate}).
*/ */
public class SessionManager implements SessionProvider { public class SessionManager implements SessionProvider {
private static final Logger logger = LogManager.getLogger(SessionManager.class); private static final Logger logger = LogManager.getLogger(SessionManager.class);
private final SettingsProvider settings; private final DatabaseConfig config;
private final boolean reportErrors;
private StandardServiceRegistry registry; private StandardServiceRegistry registry;
private SessionFactory sessionFactory; private volatile SessionFactory sessionFactory;
public SessionManager(SettingsProvider settings) { public SessionManager(SettingsProvider settings) {
this.settings = settings; this(DatabaseConfig.from(settings), !"dev".equals(settings.get("NETNEUTRALITY_BOT_ENV", "dev")));
JpaUtils.bind(this); }
/**
* @param config the database connection settings
* @param reportErrors whether startup failures are reported to Sentry
*/
public SessionManager(DatabaseConfig config, boolean reportErrors) {
this.config = Objects.requireNonNull(config, "config");
this.reportErrors = reportErrors;
} }
@Override @Override
@@ -44,55 +54,58 @@ public class SessionManager implements SessionProvider {
} }
@Override @Override
public void start() { public synchronized void start() {
getSessionFactory(); if (sessionFactory != null) {
DatabaseMigration.migrate(settings); return;
}
try {
logger.info("Connecting to database '{}' with user '{}'", config.url(), config.user());
DatabaseMigration.migrate(config);
sessionFactory = buildSessionFactory();
} catch (RuntimeException e) {
if (reportErrors) {
Sentry.captureException(e);
}
stop();
logger.error("Error connecting to database. Shutting down..", e);
throw new IllegalStateException("Unable to connect to the database", e);
}
}
private SessionFactory buildSessionFactory() {
Map<String, Object> settings = new HashMap<>();
settings.put(Environment.JAKARTA_JDBC_URL, config.url());
settings.put(Environment.JAKARTA_JDBC_USER, config.user());
settings.put(Environment.JAKARTA_JDBC_PASSWORD, config.password());
registry = new StandardServiceRegistryBuilder()
.configure()
.applySettings(settings)
.build();
return new MetadataSources(registry).buildMetadata().buildSessionFactory();
} }
@Override @Override
public void stop() { public synchronized void stop() {
SessionFactory factory = sessionFactory;
sessionFactory = null;
if (factory != null) {
factory.close();
}
if (registry != null) { if (registry != null) {
StandardServiceRegistryBuilder.destroy(registry); StandardServiceRegistryBuilder.destroy(registry);
registry = null; registry = null;
sessionFactory = null;
} }
} }
@Override @Override
public SessionFactory getSessionFactory() { public SessionFactory getSessionFactory() {
if (sessionFactory == null) { SessionFactory factory = sessionFactory;
try { if (factory == null) {
StandardServiceRegistryBuilder registryBuilder = new StandardServiceRegistryBuilder().configure(); throw new IllegalStateException("SessionManager has not been started");
Map<String, Object> config = new HashMap<>();
String dbName = settings.get("MYSQL_DATABASE", "netneutrality");
String dbUser = settings.get("MYSQL_USER", "root");
String dbPass = settings.get("MYSQL_PASSWORD", "");
String dbHost = settings.get("MYSQL_HOST", "localhost");
String dbPort = settings.get("MYSQL_PORT", "3306");
logger.info("Connecting to database '{}' at {}:{} with user '{}'", dbName, dbHost, dbPort, dbUser);
config.put(Environment.JAKARTA_JDBC_URL, String.format("jdbc:mysql://%s:%s/%s", dbHost, dbPort, dbName));
config.put(Environment.JAKARTA_JDBC_USER, dbUser);
config.put(Environment.JAKARTA_JDBC_PASSWORD, dbPass);
registryBuilder.applySettings(config);
registry = registryBuilder.build();
MetadataSources sources = new MetadataSources(registry);
Metadata metadata = sources.getMetadataBuilder().build();
sessionFactory = metadata.getSessionFactoryBuilder().build();
} catch (Exception e) {
String env = settings.get("NETNEUTRALITY_BOT_ENV", "dev");
if (!"dev".equals(env)) {
Sentry.captureException(e);
} }
return factory;
stop();
logger.error("Error connecting to database. Shutting down..");
System.exit(1);
}
}
return sessionFactory;
} }
} }
@@ -0,0 +1,21 @@
package me.bobert.netneutrality.db;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
/**
* Classification of a {@link GSettings} value, used to validate incoming values and to let
* clients (e.g. a web app) render the appropriate input widget.
*/
public enum SettingType {
STRING,
INTEGER,
PERCENTAGE,
BOOLEAN,
ROLE,
CHANNEL,
ENUM
}
@@ -0,0 +1,35 @@
package me.bobert.netneutrality.db;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import org.hibernate.StatelessSession;
import java.util.Objects;
import java.util.function.Function;
/**
* Runs work on a Hibernate {@link StatelessSession}, the session type used by the generated
* Jakarta Data repositories.
*
* <p>{@link #inTx(Function)} commits on success and rolls back and rethrows on failure.
*/
public class TransactionRunner {
private final SessionProvider sessions;
public TransactionRunner(SessionProvider sessions) {
this.sessions = Objects.requireNonNull(sessions, "sessions");
}
public <R> R read(Function<StatelessSession, R> work) {
return sessions.getSessionFactory().fromStatelessSession(work);
}
public <R> R inTx(Function<StatelessSession, R> work) {
return sessions.getSessionFactory().fromStatelessTransaction(work);
}
}
@@ -0,0 +1,27 @@
package me.bobert.netneutrality.db.data;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import jakarta.data.repository.CrudRepository;
import jakarta.data.repository.Query;
import jakarta.data.repository.Repository;
import me.bobert.netneutrality.db.entities.OBlacklist;
import java.util.List;
@Repository
public interface BlacklistQueries extends CrudRepository<OBlacklist, Integer> {
@Query("from OBlacklist where guild.guildId = :guildId order by id")
List<OBlacklist> byGuild(String guildId);
@Query("select word from OBlacklist where guild.guildId = :guildId and enabled = true")
List<String> enabledWords(String guildId);
@Query("from OBlacklist where upper(word) = upper(:word) and guild.guildId = :guildId")
List<OBlacklist> findWord(String guildId, String word);
}
@@ -0,0 +1,15 @@
package me.bobert.netneutrality.db.data;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import jakarta.data.repository.CrudRepository;
import jakarta.data.repository.Repository;
import me.bobert.netneutrality.db.entities.OGuild;
@Repository
public interface GuildQueries extends CrudRepository<OGuild, String> {
}
@@ -0,0 +1,24 @@
package me.bobert.netneutrality.db.data;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import jakarta.data.repository.CrudRepository;
import jakarta.data.repository.Query;
import jakarta.data.repository.Repository;
import me.bobert.netneutrality.db.entities.OGuildSetting;
import java.util.List;
@Repository
public interface GuildSettingQueries extends CrudRepository<OGuildSetting, Integer> {
@Query("from OGuildSetting where guild.guildId = :guildId")
List<OGuildSetting> byGuild(String guildId);
@Query("from OGuildSetting where guild.guildId = :guildId and upper(name) = upper(:name)")
List<OGuildSetting> byName(String guildId, String name);
}
@@ -0,0 +1,30 @@
package me.bobert.netneutrality.db.data;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import jakarta.data.Limit;
import jakarta.data.repository.CrudRepository;
import jakarta.data.repository.Find;
import jakarta.data.repository.Query;
import jakarta.data.repository.Repository;
import me.bobert.netneutrality.db.entities.OLogMessage;
import java.util.List;
import java.util.Optional;
@Repository
public interface MessageQueries extends CrudRepository<OLogMessage, Integer> {
@Find
Optional<OLogMessage> byMessageId(String messageId);
@Query("delete from OLogMessage where messageId = :messageId")
int deleteByMessageId(String messageId);
@Query("from OLogMessage where guild.guildId = :guildId and userId = :userId order by dateSent desc")
List<OLogMessage> byUser(String userId, String guildId, Limit limit);
}
@@ -0,0 +1,25 @@
package me.bobert.netneutrality.db.data;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import jakarta.data.repository.CrudRepository;
import jakarta.data.repository.Find;
import jakarta.data.repository.Query;
import jakarta.data.repository.Repository;
import me.bobert.netneutrality.db.entities.OTask;
import java.util.Optional;
@Repository
public interface TaskQueries extends CrudRepository<OTask, Integer> {
@Find
Optional<OTask> byTaskId(String taskId);
@Query("delete from OTask where taskId = :taskId")
int deleteByTaskId(String taskId);
}
@@ -15,6 +15,9 @@ import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn; import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne; import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import org.hibernate.Hibernate;
import java.util.Objects;
@Entity @Entity
@Table(name = "blacklist") @Table(name = "blacklist")
@@ -76,4 +79,26 @@ public class OBlacklist {
public void setId(Integer id) { public void setId(Integer id) {
this.id = id; this.id = id;
} }
/**
* Identity is the database id: two instances are equal when they have the same non-null id,
* regardless of Hibernate proxies. Transient instances (no id yet) are only equal to themselves.
*/
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) {
return false;
}
OBlacklist other = (OBlacklist) o;
return getId() != null && Objects.equals(getId(), other.getId());
}
@Override
public int hashCode() {
// Constant per class so the hash never changes when the id is assigned on persist.
return Hibernate.getClass(this).hashCode();
}
} }
@@ -11,9 +11,10 @@ import jakarta.persistence.Entity;
import jakarta.persistence.Id; import jakarta.persistence.Id;
import jakarta.persistence.OneToMany; import jakarta.persistence.OneToMany;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import org.hibernate.Hibernate;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.Optional; import java.util.Objects;
import java.util.Set; import java.util.Set;
@Entity @Entity
@@ -33,6 +34,12 @@ public class OGuild {
@Column(name = "log_channel") @Column(name = "log_channel")
private String logChannel; private String logChannel;
@Column(name = "guild_name")
private String guildName;
@Column(name = "guild_icon_hash")
private String guildIconHash;
@OneToMany(mappedBy = "guild") @OneToMany(mappedBy = "guild")
private Set<OGuildSetting> guildSettings = new LinkedHashSet<>(); private Set<OGuildSetting> guildSettings = new LinkedHashSet<>();
@@ -43,14 +50,14 @@ public class OGuild {
private Set<OLogMessage> logMessages = new LinkedHashSet<>(); private Set<OLogMessage> logMessages = new LinkedHashSet<>();
@OneToMany(mappedBy = "guild") @OneToMany(mappedBy = "guild")
private Set<OLogging> OLoggings = new LinkedHashSet<>(); private Set<OLogging> loggings = new LinkedHashSet<>();
public Set<OLogging> getLoggings() { public Set<OLogging> getLoggings() {
return OLoggings; return loggings;
} }
public void setLoggings(Set<OLogging> OLoggings) { public void setLoggings(Set<OLogging> loggings) {
this.OLoggings = OLoggings; this.loggings = loggings;
} }
public Set<OLogMessage> getLogMessages() { public Set<OLogMessage> getLogMessages() {
@@ -110,13 +117,41 @@ public class OGuild {
this.guildId = guildId; this.guildId = guildId;
} }
public OGuildSetting getSettingByName(String name) { public String getGuildName() {
Optional<OGuildSetting> setting = getGuildSettings() return guildName;
.stream()
.filter(setting1 -> setting1.getName().equalsIgnoreCase(name))
.findFirst();
return setting.orElse(null);
} }
public void setGuildName(String guildName) {
this.guildName = guildName;
}
public String getGuildIconHash() {
return guildIconHash;
}
public void setGuildIconHash(String guildIconHash) {
this.guildIconHash = guildIconHash;
}
/**
* Identity is the database id: two instances are equal when they have the same non-null id,
* regardless of Hibernate proxies. Transient instances (no id yet) are only equal to themselves.
*/
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) {
return false;
}
OGuild other = (OGuild) o;
return getGuildId() != null && Objects.equals(getGuildId(), other.getGuildId());
}
@Override
public int hashCode() {
// Constant per class so the hash never changes when the id is assigned on persist.
return Hibernate.getClass(this).hashCode();
}
} }
@@ -13,17 +13,14 @@ import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType; import jakarta.persistence.GenerationType;
import jakarta.persistence.Id; import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn; import jakarta.persistence.JoinColumn;
import jakarta.persistence.Lob;
import jakarta.persistence.ManyToOne; import jakarta.persistence.ManyToOne;
import jakarta.persistence.NamedQueries;
import jakarta.persistence.NamedQuery;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import org.hibernate.Hibernate;
import java.util.Objects;
@Entity @Entity
@Table(name = "guild_settings") @Table(name = "guild_settings")
@NamedQueries({
@NamedQuery(name = "settings_findByName", query = "select o from OGuildSetting o where o.guild.id = :id and upper(o.name) = upper(:name)")
})
public class OGuildSetting { public class OGuildSetting {
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
@@ -33,8 +30,7 @@ public class OGuildSetting {
@Column(name = "name", nullable = false) @Column(name = "name", nullable = false)
private String name; private String name;
@Lob @Column(name = "value", nullable = false, columnDefinition = "text")
@Column(name = "value", nullable = false)
private String value; private String value;
@ManyToOne(fetch = FetchType.LAZY, optional = false) @ManyToOne(fetch = FetchType.LAZY, optional = false)
@@ -72,4 +68,26 @@ public class OGuildSetting {
public void setId(Integer id) { public void setId(Integer id) {
this.id = id; this.id = id;
} }
/**
* Identity is the database id: two instances are equal when they have the same non-null id,
* regardless of Hibernate proxies. Transient instances (no id yet) are only equal to themselves.
*/
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) {
return false;
}
OGuildSetting other = (OGuildSetting) o;
return getId() != null && Objects.equals(getId(), other.getId());
}
@Override
public int hashCode() {
// Constant per class so the hash never changes when the id is assigned on persist.
return Hibernate.getClass(this).hashCode();
}
} }
Loaded 100 of 275 files, more files were not shown because too many files have changed in this diff. Show more