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
anthorob 4cf7c47645 Remove Lavaplayer and migrate to Lavalink
Replaced the bot's audio handling system by removing Lavaplayer dependencies and migrating to a Lavalink v4 node. Updated `MusicManager`, `TrackScheduler`, and related classes to utilize the `LavalinkClient`. Refactored playlist and track loading logic, task integrations, and event dispatch to integrate with the remote node. Deprecated the custom Udio source, preserving references for a future plugin port. Documented configuration and deployment details for the new audio architecture.
2026-09-23 15:08:06 -04:00
anthorob 01bfe22d22 Migrate HTTP server to Javalin and remove legacy handlers
Replaced the existing HttpServer implementation and associated handler classes (`AbstractHandler`, `ActionHandler`, `GuildHandler`, `RootHandler`) with a Javalin-based routing system. Improved modularity by introducing `ActionRoutes` and `GuildRoutes` for route definitions, and centralized JSON mapping using `GsonJsonMapper`. Simplified caching via the new `ResponseCache` utility, updated `WebModule` for cleaner timeout handling, and extended test coverage for API endpoints.
2026-09-23 12:07:38 -04:00
anthorob 5f6265db01 Improve task lifecycle handling and optimize TaskManagerImpl
Redesigned task lifecycle management to properly cascade parent-child removals and synchronize database persistence on task deletion. Refactored task management to address memory leaks, concurrency collisions, and pending-task cleanup issues. Enhanced lookup performance, logging accuracy, and test coverage with a comprehensive unit test suite validating all task scenarios.
2026-09-23 10:07:26 -04:00
280 changed files with 15517 additions and 5318 deletions

No files matched your search

+1 -8
View File
@@ -1,15 +1,8 @@
.git
.github
.idea
.mvn
src
classes
logs
target/*
!target/netneutrality.jar
target
*.iml
README.md
checkstyle.xml
mvnw
mvnw.cmd
pom.xml
+14 -7
View File
@@ -1,26 +1,33 @@
NETNEUTRALITY_TAG=latest
NETNEUTRALITY_BOT_ENV=dev
NETNEUTRALITY_BOT_TOKEN=
NETNEUTRALITY_BOT_PREFIX=$
NETNEUTRALITY_BOT_OWNER=
NETNEUTRALITY_BOT_INVITE=
NETNEUTRALITY_API_PORT=9000
NETNEUTRALITY_API_KEY=
MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306
MYSQL_USER=java
MYSQL_PASSWORD=password
MYSQL_DATABASE=netneutrality
MYSQL_ROOT_PASSWORD=password
MARIADB_AUTO_UPGRADE=1
NETNEUTRALITY_LAVALINK_URI=ws://lavalink:2333
NETNEUTRALITY_LAVALINK_PASSWORD=youshallnotpass
NETNEUTRALITY_LAVALINK_NAME=dev-player
NETNEUTRALITY_PO_TOKEN=
NETNEUTRALITY_VISITOR_DATA=
NETNEUTRALITY_CIPHER_URL=http://localhost:8001
NETNEUTRALITY_OAUTH_TOKEN=
NETNEUTRALITY_CIPHER_URL=http://ejs-api:8001
NETNEUTRALITY_CIPHER_PASSWORD=
NETNEUTRALITY_WEBPO_URL=http://webpo:8080
NETNEUTRALITY_WEBPO_PASSWORD=
NETNEUTRALITY_SENTRY_DSN=
GITLAB_HOST=
GITLAB_TOKEN=
GITLAB_PROJECT_ID=
NETNEUTRALITY_WEBHOOK_URL=
NETNEUTRALITY_WEBHOOK_SECRET=
GITEA_HOST=
GITEA_TOKEN=
GITEA_OWNER=
GITEA_REPO=
+1
View File
@@ -50,6 +50,7 @@ jobs:
name: maven-artifacts
path: |
target/netneutrality.jar
target/lib
startup.sh
if-no-files-found: error
+1 -1
View File
@@ -33,12 +33,12 @@ hs_err_pid*
.idea/
*.iml
*.iws
.junie/plans
# Mac
.DS_Store
# Maven
log/
target/
#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()`
@@ -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
RUN apt-get update && apt-get install -y --no-install-recommends netcat-openbsd curl && rm -rf /var/lib/apt/lists/*
@@ -8,7 +19,8 @@ ARG API_PORT=9000
EXPOSE ${API_PORT}
COPY startup.sh ./
COPY target/netneutrality.jar ./
COPY --from=build /build/target/lib ./lib
COPY --from=build /build/target/netneutrality.jar ./
RUN ["chmod", "+x", "./startup.sh"]
CMD ["./startup.sh"]
+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.
This is a personal project, so I offer no support on how to build it.
A custom Discord bot written in Java. It uses [JDA](https://github.com/discord-jda/JDA) for Discord, a [Lavalink](https://github.com/lavalink-devs/Lavalink) node for music playback, MariaDB/MySQL for persistence, and exposes an HTTP API built with Javalin.
This is a personal project. No support is offered for building or running it.
The preferred method for running the bot is by using Docker Compose like this:
## Table of Contents
- [Requirements](#requirements)
- [Configuration](#configuration)
- [Running with Docker Compose](#running-with-docker-compose)
- [Running Locally](#running-locally)
- [Testing](#testing)
- [License](#license)
## Requirements
- Docker and Docker Compose (recommended), or
- JDK 25 and Maven (the included `mvnw` wrapper works) for local runs
- A MariaDB/MySQL database
- A Lavalink node, version 4.2.0 or newer
- A Discord bot token
## Configuration
All settings are provided as environment variables. A template with every supported variable is available in [`bot.properties.example`](bot.properties.example).
Key variables:
| Variable | Description |
| --- | --- |
| `NETNEUTRALITY_BOT_TOKEN` | Discord bot token |
| `NETNEUTRALITY_BOT_PREFIX` | Command prefix |
| `NETNEUTRALITY_BOT_OWNER` | Discord user ID of the bot owner |
| `NETNEUTRALITY_API_PORT` | Port for the HTTP API (default `9000`) |
| `MYSQL_HOST`, `MYSQL_PORT`, `MYSQL_USER`, `MYSQL_PASSWORD`, `MYSQL_DATABASE` | Database connection settings |
| `NETNEUTRALITY_LAVALINK_URI`, `NETNEUTRALITY_LAVALINK_PASSWORD` | Lavalink connection settings |
| `NETNEUTRALITY_SENTRY_DSN` | Optional Sentry DSN for error reporting |
| `GITEA_HOST`, `GITEA_TOKEN`, `GITEA_OWNER`, `GITEA_REPO` | Optional Gitea settings for issue reporting |
| `NETNEUTRALITY_WEBHOOK_URL`, `NETNEUTRALITY_WEBHOOK_SECRET` | Optional frontend webhook URL and HMAC secret (see below) |
### Webhook events
When `NETNEUTRALITY_WEBHOOK_URL` is set, the bot POSTs JSON music-state events (`TRACK_START`, `TRACK_END`, `QUEUE_UPDATED`, `PLAYER_PAUSED`, `PLAYER_RESUMED`, `VOLUME_CHANGED`, `VOICE_CONNECTED`, `VOICE_DISCONNECTED`) to it asynchronously. Each event has `type`, `guildId`, `timestamp`, a per-guild `seq` and a `payload`. Requests carry an `X-Signature: sha256=<hex HMAC-SHA256 of the body>` header computed with `NETNEUTRALITY_WEBHOOK_SECRET`. Failed deliveries are retried 3 times with exponential backoff and then dropped, so the frontend should still do a full resync on load and periodically. The polling endpoints remain available.
`PLAYER_SEEKED` is sent after a seek with payload `{ position }` (milliseconds). `VOTE_SKIP_UPDATED` is sent after a vote that doesn't skip the track yet, with payload `{ userId, votes, threshold }` (`threshold` is the percentage of listeners needed). A vote that does skip produces the usual `TRACK_END` / `TRACK_START` / `QUEUE_UPDATED` events instead.
`TRACK_START` / `TRACK_END` payloads are `{ identifier, title, author, length, uri, artworkUrl }` (plus `reason` on `TRACK_END`). `uri` and `artworkUrl` may be `null` when the source doesn't provide them.
`VOICE_CONNECTED` / `VOICE_DISCONNECTED` are sent for the bot itself and for any member joining or leaving the voice channel the bot is currently in. Their payload is `{ channelId, userId, username, nickname, bot, self }`, where `nickname` is the member's effective display name in the guild, `bot` marks bot accounts and `self` marks this bot's own voice changes.
## Running with Docker Compose
Docker Compose is the preferred way to run the bot. It starts the bot, the database, Lavalink, and the supporting YouTube cipher and PO token services.
1. Copy `bot.properties.example` and fill in the values (see [Configuration](#configuration)).
2. Build and start the stack:
```bash
docker compose build
docker compose up -d
```
The environment variables needed for the bot to run are available in the file **bot.properties.example**
If running locally without Docker, you need to copy the example file to **bot.properties** and fill the values.
3. View logs:
```bash
docker compose logs -f netneutrality-bot
```
Logs are also written to the `logs` directory.
## Running Locally
1. Copy `bot.properties.example` to `bot.properties` and fill in the values.
2. Make sure a database and a Lavalink node are reachable at the configured addresses.
3. Build the project:
```bash
./mvnw package
```
4. Run the bot:
```bash
java -jar target/netneutrality.jar
```
Dependencies are copied to `target/lib` during packaging and referenced by the jar manifest.
## Testing
```bash
./mvnw test
```
Database tests use Testcontainers, so Docker must be running. Checkstyle runs during the `test` phase.
## License
See the [LICENSE](LICENSE) file.
+12 -3
View File
@@ -6,6 +6,8 @@ NETNEUTRALITY_BOT_PREFIX=$
NETNEUTRALITY_BOT_OWNER=
NETNEUTRALITY_BOT_INVITE=
NETNEUTRALITY_API_PORT=9000
# Optional: number of gateway shards. Leave unset to use Discord's recommended count.
# NETNEUTRALITY_SHARD_TOTAL=1
MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306
@@ -20,7 +22,14 @@ NETNEUTRALITY_VISITOR_DATA=
NETNEUTRALITY_CIPHER_URL=http://localhost:8001
NETNEUTRALITY_CIPHER_PASSWORD=
NETNEUTRALITY_LAVALINK_URI=ws://lavalink:2333
NETNEUTRALITY_LAVALINK_PASSWORD=youshallnotpass
NETNEUTRALITY_LAVALINK_NAME=main
NETNEUTRALITY_SENTRY_DSN=
GITLAB_HOST=
GITLAB_TOKEN=
GITLAB_PROJECT_ID=
NETNEUTRALITY_WEBHOOK_URL=
NETNEUTRALITY_WEBHOOK_SECRET=
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
+56 -6
View File
@@ -13,6 +13,12 @@ services:
- MYSQL_DATABASE=${MYSQL_DATABASE:-netneutrality}
- MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD:-password}
- MARIADB_AUTO_UPGRADE=1
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 5s
timeout: 5s
retries: 20
start_period: 30s
netneutrality-bot:
image: gitea.bobert.me/anthorob/netneutrality:${NETNEUTRALITY_TAG:-latest}
@@ -22,15 +28,42 @@ services:
networks:
- netneutrality
depends_on:
- netneutrality-db
netneutrality-db:
condition: service_healthy
lavalink:
condition: service_started
volumes:
- ./logs:/app/logs
environment:
MYSQL_HOST: netneutrality-db
MYSQL_PORT: 3306
MYSQL_USER: java
MYSQL_PASSWORD: password
MYSQL_DATABASE: netneutrality
- MYSQL_HOST=${MYSQL_HOST:-netneutrality-db}
- MYSQL_PORT=${MYSQL_PORT:-3306}
- MYSQL_USER=${MYSQL_USER:-java}
- MYSQL_PASSWORD=${MYSQL_PASSWORD:-password}
- MYSQL_DATABASE=${MYSQL_DATABASE:-netneutrality}
- API_PORT=${NETNEUTRALITY_API_PORT:-9000}
# Lavalink >= 4.2.0 is required: the node handles DAVE end-to-end encryption,
# so the bot no longer ships native encoders.
lavalink:
image: ghcr.io/lavalink-devs/lavalink:4.2
container_name: lavalink
restart: unless-stopped
networks:
- netneutrality
depends_on:
- ejs-api
- webpo
volumes:
- ./lavalink/application.yml:/opt/Lavalink/application.yml:ro
environment:
- SERVER_PASSWORD=${NETNEUTRALITY_LAVALINK_PASSWORD:-youshallnotpass}
- NETNEUTRALITY_CIPHER_URL=${NETNEUTRALITY_CIPHER_URL:-http://ejs-api:8001}
- NETNEUTRALITY_CIPHER_PASSWORD=${NETNEUTRALITY_CIPHER_PASSWORD:-}
- NETNEUTRALITY_OAUTH_TOKEN=${NETNEUTRALITY_OAUTH_TOKEN:-}
- NETNEUTRALITY_POT_URL=${NETNEUTRALITY_POT_URL:-http://webpo:8080}
- NETNEUTRALITY_PO_TOKEN=${NETNEUTRALITY_PO_TOKEN:-}
- NETNEUTRALITY_VISITOR_DATA=${NETNEUTRALITY_VISITOR_DATA:-}
ejs-api:
image: ghcr.io/kikkia/yt-cipher:master@sha256:4c5ec381ff57336cfc24822d2b526a5ad7cd0171f61065a1bbc87bb736842449
container_name: ejs_signature_api
@@ -39,8 +72,25 @@ services:
- netneutrality
# environment: # UNCOMMENT THIS LINE AND API_TOKEN TO ADD A PASSWORD
# - API_TOKEN=test
webpo:
image: ghcr.io/ashton045/webpo-generator
container_name: webpo-generator
environment:
- HOST=${HOST:-::}
- PORT=${PORT:-8080}
- WORKERS=${WORKERS:-1}
- QUEUE_SIZE=${QUEUE_SIZE:-32}
- MAX_PENDING_REQUESTS=${MAX_PENDING_REQUESTS:-256}
- REQUEST_TIMEOUT=${REQUEST_TIMEOUT:-30000}
- CACHE_SIZE=${CACHE_SIZE:-100}
- CACHE_TTL=${CACHE_TTL:-300000}
- VISITOR_TTL=${VISITOR_TTL:-600000}
- API_TOKEN=
restart: unless-stopped
volumes:
netneutrality-db: null
netneutrality-lavalink-plugins: null
networks:
netneutrality: null
+2 -2
View File
@@ -16,8 +16,8 @@ subsystem, how they fit together, and the end-to-end flows that tie them.
| [commands.md](commands.md) | Commands — message & slash commands, dispatch, options |
| [tasks.md](tasks.md) | Tasks — button/emoji/modal/step/built-in/persistable/scheduled tasks |
| [persistence.md](persistence.md) | Database — Hibernate/JPA, Flyway, entities, repositories, services |
| [music.md](music.md) | Music — Lavaplayer pipeline, queue, custom Udio source |
| [web-api.md](web-api.md) | HTTP Web API — internal control & introspection server |
| [music.md](music.md) | Music — Lavalink node & client, queue, event dispatch |
| [web-api.md](web-api.md) | HTTP Web API — Javalin server for control, introspection & music |
| [threading.md](threading.md) | Threading & scheduling infrastructure |
| [utilities-and-bootstrap.md](utilities-and-bootstrap.md) | Utilities, configuration, logging, exceptions |
+15 -16
View File
@@ -37,7 +37,7 @@ execution paths:
directly and no context is routed onward. See [events.md](events.md).
Around this core are the supporting subsystems: [persistence](persistence.md) (Hibernate + Flyway),
[music](music.md) (Lavaplayer), the [web API](web-api.md), and the [threading/scheduling](threading.md)
[music](music.md) (a remote Lavalink node), the [web API](web-api.md), and the [threading/scheduling](threading.md)
infrastructure.
## Technology stack
@@ -45,15 +45,13 @@ infrastructure.
| Concern | Library | Version |
|---|---|---|
| Discord API | `net.dv8tion:JDA` | 6.4.1 |
| Voice / DAVE (E2EE) | `club.minnced:jdave-*` | 0.1.8 |
| Audio engine | `dev.arbjerg:lavaplayer` | 2.2.6 |
| YouTube source | `dev.lavalink.youtube:v2` | 1.18.2 |
| Audio engine | Lavalink v4 node (`ghcr.io/lavalink-devs/lavalink`) | 4.2 |
| Audio client | `dev.arbjerg:lavalink-client` | 3.4.0 |
| ORM | `org.hibernate.orm:hibernate-core` | 7.1.9.Final |
| Connection pool | `hibernate-hikaricp` (HikariCP) | 7.1.9.Final |
| JDBC driver | `com.mysql:mysql-connector-j` | 9.7.0 |
| DB migrations | `org.flywaydb:flyway-core` / `flyway-mysql` | 11.20.3 |
| 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 |
| JSON | `org.json:json` + `com.google.code.gson:gson` | — |
| Classpath scanning | `org.reflections:reflections` | 0.10.2 |
@@ -61,8 +59,8 @@ infrastructure.
| Logging | Log4j2 + `log4j-slf4j2-impl` | 2.26.0 |
| Testing | JUnit Jupiter 6 + Mockito 5 | — |
The HTTP **server** for the internal API uses the JDK built-in
`com.sun.net.httpserver.HttpServer` (no external framework); see [web-api.md](web-api.md).
The HTTP **server** for the internal API uses embedded Javalin (with OpenAPI/Swagger plugins); see
[web-api.md](web-api.md).
## Startup lifecycle
@@ -77,7 +75,7 @@ component in ascending priority order.
also registered into the context as `SettingsProvider` for constructor injection).
2. **Sentry** — if `NETNEUTRALITY_SENTRY_DSN` is set, initialize the Sentry SDK
(environment, release = `VERSION`, traces sample rate 1.0).
3. **Log levels** — in `dev`, raise JDA / Lavalink-YouTube / sedmelluq logging to `DEBUG`.
3. **Log levels** — in `dev`, raise JDA and Lavalink client logging to `DEBUG`.
4. `startup()`:
1. Validate `NETNEUTRALITY_BOT_TOKEN` is present (throws if missing).
2. `buildContext()` — register domain services and lifecycle adapters (see priorities below).
@@ -92,7 +90,7 @@ component in ascending priority order.
| 20 | `ThreadManager` (`ExecutorProvider`) | No-op start (executors are lazy). Bound into `ChannelMessagePurger`. |
| 30 | `CommandManager` (`CommandRegistry`) | Reflectively discover/register `Command` implementations with context-aware constructor injection. |
| 30 | `TaskManagerImpl` (`TaskManager`) | Reflectively load built-in & scheduled tasks (DI) and reload persistable tasks from the DB. |
| 40 | `MusicServiceImpl` (`MusicService`) | Initialise the shared Lavaplayer `AudioPlayerManager`. |
| 40 | `MusicServiceImpl` (`MusicService`) | Create the shared `LavalinkClient`, register the node and subscribe to its events. |
| 55 | `ScheduledTaskManager` | No-op start (tasks are scheduled by `TaskManager` during its own start). |
| 60 | JDA adapter | Build `JDA` with gateway intents and await ready. |
| 65 | Post-JDA adapter | Register slash commands, attach `EventsHandler` (injected providers), set presence. |
@@ -106,8 +104,10 @@ context (falling back to a no-arg constructor when none match).
### Gateway intents
`initJDA()` enables: `GUILD_MEMBERS`, `GUILD_MESSAGE_REACTIONS`, `GUILD_VOICE_STATES`,
`MESSAGE_CONTENT`. It also installs the DAVE (end-to-end encrypted voice) session factory via
`AudioModuleConfig().withDaveSessionFactory(new JDaveSessionFactory())`.
`MESSAGE_CONTENT`. It also installs `new JDAVoiceUpdateListener(music.getClient())` as the voice
dispatch interceptor, so voice server/state updates are forwarded to Lavalink instead of JDA's own
audio stack. DAVE (end-to-end encrypted voice) is handled by the node, which is why the bot no
longer ships native audio encoders.
## Shutdown lifecycle
@@ -120,7 +120,7 @@ block the others. Effectively this means:
2. Post-JDA adapter — no-op.
3. JDA adapter — `jda.shutdown()`.
4. `ScheduledTaskManager.stop()` — cancel all scheduled tasks.
5. `MusicServiceImpl.stop()` — disconnect players / leave voice channels.
5. `MusicServiceImpl.stop()` — dispose the event subscriptions, destroy the players and close the Lavalink client.
6. `CommandManager` / `TaskManagerImpl` — no-op stop today.
7. `ThreadManager.stop()` — shut down the shared executors.
8. `SessionManager.stop()` — release the Hibernate registry / connection pool.
@@ -139,14 +139,14 @@ block the others. Effectively this means:
## Package map
| Package | Contents |
|---|---|
|---------------------------------------------------|---------------------------------------------------------------------------------------------------------|
| `me.bobert.netneutrality` | Entry point (`App`) |
| `…bootstrap` | `ApplicationContext`, `Lifecycle`, `BotState`, `ReflectionHelper` |
| `…core.command` | Command engine (registry, processor, manager, options, impl bases) |
| `…core.context` | Context interfaces, abstract bases (`CommandContext`, `TaskContext`, etc.) |
| `…core.tasks` | Task engine (manager, subtypes, traits, persistence, generic buttons) |
| `…core.thread` | Executors & scheduling (`ThreadManager`, `ScheduledTaskManager`, `BaseThreadFactory`) |
| `…core` | `LogHandler`, `NetNeutralityBot` |
| `…core` | `LogHandler` |
| `…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` | `MusicChannelContext` |
@@ -155,7 +155,6 @@ block the others. Effectively this means:
| `…db.entities` / `…db.repository` / `…db.service` | Persistence model, repositories, services |
| `…hooks` | `EventsHandler` |
| `…http` | Internal web API and HTTP client module |
| `…music` | Music domain (service, manager, queue, sources, tracks) |
| `…music` | Music domain (service, manager, queue, scheduler, track loading) |
| `…utils` | `Settings` / `SettingsProvider`, `ChannelMessagePurger`, `MusicUtils` |
| `…shared.util` | Cross-cutting helpers |
| `…exceptions` | Custom exception hierarchy |
+6 -5
View File
@@ -1,7 +1,7 @@
# Commands
# Commands
**Packages:** `me.bobert.netneutrality.framework.command` (framework),
`me.bobert.netneutrality.feature.*.command` (concrete commands)
**Packages:** `me.bobert.netneutrality.core.command` (framework),
`me.bobert.netneutrality.command.*` (concrete commands)
A **Command** is what a user executes on Discord. It receives a
[`CommandContext`](contexts.md#commandcontext) and runs logic based on it. There are two primary
@@ -129,7 +129,7 @@ At runtime, an incoming command event becomes a `CommandContext` in the
## Command catalog
Concrete commands live under `me.bobert.netneutrality.feature.*.command`, grouped by feature package:
Concrete commands live under `me.bobert.netneutrality.command.*`, grouped by feature package:
| Command | Package | Type | Description |
|---|---|---|---|
@@ -139,7 +139,7 @@ Concrete commands live under `me.bobert.netneutrality.feature.*.command`, groupe
| `PingCommand` | `basic` | Slash | Reply with the gateway ping. |
| `ConfigureCommand` | `bot` | Slash | Configure the bot for the server (subcommands). |
| `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. |
| `QueueCommand` / `ViewQueueCommand` | `music` | Slash | View/manage the queue. |
| `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. |
| `CurrentlyPlayingCommand` | `music` | Slash | Show the currently playing track. |
| `ResumeCommand` / `StopCommand` / `VolumeCommand` | `music` | Slash | Playback controls. |
| `AutoplayCommand` | `music` | Slash | Toggle autoplay: queues the YouTube radio mix of the last track so music keeps playing. |
| `BitconnectCommand` | `music` | Slash | Novelty music command. |
| `CreateEmojiReaction` / `ModifyEmojiReaction` | `react` | Message menu | Create/modify emoji-role mappings from a message. |
| `DatabaseCommand`, `PurgeCommand`, `ShutdownCommand`, `JoinClanCommand`, `TestCommand`, `SlashCommandTest`, `TrackStuckTest` | `custom` | Mixed | Owner/debug and personal utilities (the `custom` package is gitignored per-deployment). |
+3 -3
View File
@@ -1,7 +1,7 @@
# Contexts
**Packages:** `me.bobert.netneutrality.framework.context` (framework),
`me.bobert.netneutrality.framework.context` (concrete implementations)
**Packages:** `me.bobert.netneutrality.core.context` (framework),
`me.bobert.netneutrality.context.command` / `me.bobert.netneutrality.context.tasks` (concrete implementations)
A **Context** is a high-level abstraction over a Discord event. It hides the differences between the
many JDA event types (slash command, message, button, modal, reaction, …) behind a single, uniform
@@ -117,7 +117,7 @@ Abstract base for the [task flow](tasks.md). Two things distinguish it:
### `AsyncContext`
`me.bobert.netneutrality.framework.context.AsyncContext` wraps the **result** of a previous reply — either an
`me.bobert.netneutrality.core.context.AsyncContext` wraps the **result** of a previous reply — either an
`InteractionHook` (for interactions) or a `Message` (for message replies) — so follow-up actions are
expressed the same way regardless of the original trigger:
+16 -1
View File
@@ -1,6 +1,6 @@
# Event Handling & Routing
**Package:** `me.bobert.netneutrality.platform.event`
**Package:** `me.bobert.netneutrality.hooks`
**Primary class:** `EventsHandler`
`EventsHandler` is the single JDA `EventListener` registered on the `JDA` instance
@@ -91,6 +91,21 @@ Key points:
`TaskManager.getInstance().handle(context)`.
- **`null` contexts** (bot messages, consumer-only events) are simply not routed anywhere.
## Webhook music events
`MusicEvent` instances are delivered by `WebhookDispatcher`. Every event carries `type`, `guildId`,
`timestamp` and `seq`; the payloads of the queue and player events are:
| Type | Payload |
|---|---|
| `QUEUE_UPDATED` | `size` (int), `change` (`added`/`playlist_added`/`removed`/`moved`/`cleared`/`shuffled`), `items` (list). Each item has `index` (0-based position, before the mutation for removals), `identifier`, `title`, `author`, `duration_ms`, `uri`, `artwork_url`, `requester_id` and `playlist` (`{name, identifier, track_count}` or `null` when the track was not queued from a playlist). `moved` items also carry `fromIndex` and `toIndex`. `cleared` and `shuffled` have an empty `items` (reload the queue on `shuffled`). `playlist_added` is emitted once per playlist load (instead of one `added` event per track), lists only the tracks actually added (duplicates are skipped) and carries a top-level `playlist` object (`name`, `identifier`, `track_count`, the number actually added). |
| `QUEUE_SHUFFLED` | `size` |
| `TRACK_ERROR` | `track` (`identifier`, `title`, `author`, `length`, `uri`, `artworkUrl`), `message`, `severity` |
| `TRACK_STUCK` | `track` (same fields as above), `thresholdMs` |
| `PLAYER_POSITION` | `position` (ms), `timestamp` (node time, epoch ms), `connected`, `ping` (ms). Sent from Lavalink `PlayerUpdateEvent`, at most once every 5 seconds per guild and only while a track is playing. |
Consumers must handle the `playlist_added` change value and the per-item `playlist` field. The REST queue listing (`GET /music/{guildId}/queue`) also reports `playlist` on every entry.
## Extending the event system
To handle a new event type:
+116 -39
View File
@@ -1,35 +1,51 @@
# Music
**Package:** `me.bobert.netneutrality.feature.music`
**Engine:** [Lavaplayer](https://github.com/lavalink-devs/lavaplayer) 2.2.6 + the Lavalink YouTube
source 1.18.2.
**Package:** `me.bobert.netneutrality.music`
**Engine:** a remote [Lavalink](https://lavalink.dev) v4 node driven by
[`dev.arbjerg:lavalink-client`](https://github.com/lavalink-devs/Lavalink-Client) 3.4.0.
The music subsystem provides per-guild audio playback: searching/loading tracks from multiple
sources, queueing, scheduling, and streaming audio into a Discord voice channel.
sources, queueing, scheduling, and streaming audio into a Discord voice channel. Since the
migration to Lavalink, **decoding, source resolution and voice sending happen on the node**, not in
the bot process. The bot only sends REST/WebSocket commands and reacts to the node's events, which
means new sources (YouTube, LavaSrc, …) are added by configuring server-side plugins rather than by
changing Java code.
## Key classes
| Class | Responsibility |
|---|---|
| `MusicManager` | Central per-guild coordinator; owns the `AudioPlayer` and `TrackScheduler`. |
| `MusicServiceImpl` | Owns the shared `LavalinkClient`, registers the node, and dispatches its event flux. |
| `MusicManager` | Central per-guild coordinator; resolves the guild's `Link` and exposes a synchronous facade. |
| `QueueManager` | Holds the guild's track queue (`LinkedBlockingDeque<TrackInfo>`); enqueue/skip/shuffle. |
| `TrackScheduler` | `AudioEventAdapter`; reacts to Lavaplayer events (`onTrackEnd`, `onTrackStuck`). |
| `TrackLoadingHandler` | `AudioLoadResultHandler`; handles track/playlist/no-match/failure results. |
| `TrackInfo` | Value object pairing an `AudioTrack` with its requester (`Member`). |
| `SourceFinder` | Identifies the source/type of a query (YouTube, Udio, HTTP, …). |
| `AudioPlayerSendHandler` | Bridges Lavaplayer output to JDA's `AudioSendHandler`. |
| `source/UdioAudioSourceManager` | Custom Lavaplayer source manager for udio.com. |
| `source/UdioAudioTrack` | Udio track; delegates playback to `Mp3AudioTrack`. |
| `TrackScheduler` | Event sink for `onTrackStart` / `onTrackEnd` / `onTrackException` / `onTrackStuck`; caches the current track. |
| `TrackLoadingHandler` | `AbstractAudioLoadResultHandler`; handles track/playlist/search/no-match/failure results. |
| `TrackInfo` | Value object pairing a Lavalink `Track` with its requester (`Member`). |
| `SourceFinder` | Identifies the source/type of a query (YouTube, SoundCloud, HTTP, …). |
| `StuckTrackRecoveryHandler` | Retry-then-skip logic for tracks the node reports as stuck. |
`MusicManager` keeps a static `Map<String, MusicManager>` of managers keyed by guild id, so each guild
has its own player, scheduler, and queue.
`MusicServiceImpl` keeps a `Map<String, MusicManager>` of managers keyed by guild id, so each guild
has its own link, scheduler, and queue.
## Startup
`MusicManager.initPlayerManager()` (called from
[`App.startup()`](architecture.md#startup-lifecycle)) creates the Lavaplayer
`AudioPlayerManager` and registers the source managers (YouTube, SoundCloud, the custom
[Udio source](#custom-udio-source), and HTTP).
`MusicServiceImpl` is a priority-40 [`Lifecycle`](architecture.md#startup-lifecycle) component. Its
constructor creates the `LavalinkClient` (the bot user id is derived from the token) and `start()`
registers the configured node. **A node that is unreachable never prevents the bot from booting**:
the client retries in the background and music commands reply with an error in the meantime.
Because the client must exist before JDA is built, `App.initJDA()` (priority 60) installs
`new JDAVoiceUpdateListener(music.getClient())` as the gateway's voice dispatch interceptor. JDA's
`AudioManager` is therefore **not** used for playback; voice connections are opened and closed
through `JDA#getDirectAudioController()`.
### Settings
| Setting | Default | Meaning |
|---|---|---|
| `NETNEUTRALITY_LAVALINK_URI` | `ws://lavalink:2333` | WebSocket URI of the node |
| `NETNEUTRALITY_LAVALINK_PASSWORD` | — | Node password; music is disabled when missing |
| `NETNEUTRALITY_LAVALINK_NAME` | `main` | Node name used in logs |
## Audio pipeline (end-to-end)
@@ -38,50 +54,111 @@ has its own player, scheduler, and queue.
│
▼
MusicManager.play(query, ctx)
│ SourceFinder identifies the source
│ SourceFinder identifies the source and builds the identifier
▼
audioPlayerManager.loadItemOrdered(guild, identifier, new TrackLoadingHandler(...))
link.loadItem(identifier).subscribe(new TrackLoadingHandler(...))
│
├─ trackLoaded ──▶ connect to voice (MusicUtils) ──▶ QueueManager.queue(track, member)
├─ playlistLoaded / searchResults ──▶ install a LoadPlaylistTask (choice buttons)
├─ ontrackLoaded ──▶ connect to voice (MusicUtils) ──▶ QueueManager.queue(track, member)
├─ onPlaylistLoaded / onSearchResultLoaded ──▶ install a LoadPlaylistTask (choice buttons)
├─ noMatches ──▶ reply "nothing found"
└─ loadFailed ──▶ reply / log error
│
▼
QueueManager: if the player is idle, start immediately; else append to the deque
QueueManager: if nothing is playing, send createOrUpdatePlayer().setTrack(...); else append
│
▼
AudioPlayerSendHandler streams PCM/Opus frames to the voice channel
Lavalink node decodes and streams the audio to the Discord voice gateway
│
▼ (track ends)
TrackScheduler.onTrackEnd ──▶ QueueManager.startNextTrack()
node event ──▶ MusicServiceImpl dispatcher ──▶ TrackScheduler.onTrackEnd ──▶ QueueManager.startNextTrack()
```
- **Event dispatch** — `MusicServiceImpl` holds a single subscription per event type
(`TrackStartEvent`, `TrackEndEvent`, `TrackExceptionEvent`, `TrackStuckEvent`,
`WebSocketClosedEvent`) and routes each event to the owning guild's `TrackScheduler`. All
subscriptions are disposed in `stop()`.
- **Synchronous facade** — every Lavalink operation returns a reactive `Mono`. `MusicManager`
subscribes internally so the 13 music commands keep their existing synchronous signatures;
failures are reported through `LogHandler` and the attached `MusicChannelContext`.
- **Current track** — the node's player state is only readable via REST, so the last
`TrackStartEvent` is cached on the scheduler and used by `getCurrentlyPlaying()` and by the queue
to decide whether playback must be started.
- **Requester tracking** — each queued item is a `TrackInfo` (track + requesting `Member`), used for
now-playing/queue displays and vote-skip.
- **Playlists / search** — playlist and multi-result loads produce a
now-playing/queue displays and vote-skip. Duplicate rejection compares the encoded track payload.
- **Playlists / search** — playlist and search loads produce a
[`LoadPlaylistTask`](tasks.md#concrete-task-catalog) with per-result buttons
(`LoadTrackFromPlaylistTask`), tying the music and [task](tasks.md) subsystems together. Playlist
size is bounded by the [`MUSIC_MAXPLAYLISTSIZE`](persistence.md#gsettings) guild setting.
- **Stuck tracks** — `TrackScheduler.onTrackStuck` attempts recovery (reload/skip).
- **Stuck tracks** — the first `TrackStuckEvent` re-queues the track and seeks back to the last
known position; a second consecutive event skips to the next track.
- **Output** — playback messages are posted through a
[`MusicChannelContext`](contexts.md#musicchannelcontext) so they land in the guild's configured
music channel.
## Custom Udio source
## Running the node
`UdioAudioSourceManager` scrapes udio.com track pages: it extracts track metadata (title, artist,
duration) and a direct playback URL from the Next.js payload (`self.__next_f.push(...)` calls) in the
page HTML. Because Udio playback URLs expire, `UdioAudioTrack` **re-fetches** the URL during the
`process` phase of the pipeline before streaming, delegating the actual decode/playback to Lavaplayer's
`Mp3AudioTrack`.
`docker-compose.yml` defines a `lavalink` service (image `ghcr.io/lavalink-devs/lavalink:4.2`, the
minimum version that handles DAVE end-to-end encrypted voice) on the `netneutrality` network, with
`lavalink/application.yml` mounted read-only at `/opt/Lavalink/application.yml`. The bot depends on
it and the node depends on the existing `ejs-api` (yt-cipher) container.
```bash
docker compose up -d lavalink
docker compose logs -f lavalink # wait for "Lavalink is ready to accept connections"
```
### Adding a server-side plugin
Plugins are declared in `lavalink/application.yml` and downloaded by the node on boot into the
`netneutrality-lavalink-plugins` volume — no bot rebuild is required:
```yaml
lavalink:
plugins:
- dependency: "dev.lavalink.youtube:youtube-plugin:1.18.2"
snapshot: false
```
A plugin that is not on the default Lavalink repository can be pulled from any Maven repository by
adding `repository:` to the entry. The node currently runs a JitPack build of the SABR-enabled
YouTube fork ([`ashton045/youtube-source`](https://jitpack.io/#ashton045/youtube-source), branch
`feat/sabr-support`) — no local build of the fork is required, JitPack publishes a ready Lavalink
plugin jar:
```yaml
lavalink:
plugins:
- dependency: "com.github.ashton045.youtube-source:youtube-plugin:17619654b1"
repository: "https://jitpack.io"
snapshot: false
```
The node logs `Failed to check for updates for youtube-plugin: Not Found` on boot; this is harmless,
JitPack publishes no `maven-metadata.xml` for commit-pinned versions.
Plugin-specific configuration goes under the top-level `plugins:` key. The YouTube plugin is
configured there with the remote cipher URL, poToken and visitorData, which used to live in
`MusicServiceImpl.start()`.
## Udio support (deferred)
Udio playback is **temporarily unavailable**. The previous implementation was a client-side
Lavaplayer `AudioSourceManager`, and Lavalink can only resolve sources that live on the server.
`SourceFinder` still recognises Udio URLs but returns no identifier, and `MusicManager.play` replies
that Udio is temporarily unsupported.
The original `UdioAudioSourceManager`, `UdioAudioTrack` and their test have been preserved verbatim
under [`docs/reference/udio-lavalink-plugin/`](reference/udio-lavalink-plugin) — they are **not
compiled** — as the reference for a future Lavalink **server plugin** port. That plugin is out of
scope of this migration and is tracked as a follow-up.
## Guild lifecycle & cleanup
- `MusicPlayerAutoDisconnect` (scheduled task) leaves voice when the player has been idle.
- `MusicManagerCleanup` (scheduled task) evicts unused per-guild `MusicManager` instances from the
static map.
- On shutdown, `MusicManager.disconnectPlayers()` stops all players and disconnects from voice
- `MusicPlayerAutoDisconnect` (scheduled task) leaves voice when the channel is empty or the queue
has run out; the connected channel is read from the bot's gateway voice state.
- `MusicManagerCleanup` (scheduled task) evicts unused per-guild `MusicManager` instances.
- On shutdown, `MusicServiceImpl.stop()` disposes the event subscriptions, clears the queues,
destroys every player and closes the client
(called from [`App.shutdown()`](architecture.md#shutdown-lifecycle)).
## Related commands
@@ -89,5 +166,5 @@ page HTML. Because Udio playback URLs expire, `UdioAudioTrack` **re-fetches** th
All music commands extend [`MusicCommand`](commands.md#base-implementations-corecommandimpl) (guild
only + music role/channel enforced): `PlayCommand`, `SkipCommand`/`VoteSkipCommand`,
`QueueCommand`/`ViewQueueCommand`, `ClearQueueCommand`, `ShuffleCommand`, `JoinCommand`/`LeaveCommand`,
`CurrentlyPlayingCommand`, `ResumeCommand`/`StopCommand`, `VolumeCommand`, `BitconnectCommand`. See
`CurrentlyPlayingCommand`, `ResumeCommand`/`StopCommand`, `VolumeCommand`, `AutoplayCommand`, `BitconnectCommand`. See
[commands.md](commands.md#command-catalog).
+12 -7
View File
@@ -1,8 +1,9 @@
# Persistence & Database
**Packages:** `me.bobert.netneutrality.platform.persistence` (session/migration),
**Packages:** `me.bobert.netneutrality.db` (session/migration),
`me.bobert.netneutrality.db` (entities, repositories, services).
**Resources:** `hibernate.cfg.xml`, `db/migration/V1__initial_schema.sql`.
**Resources:** `hibernate.cfg.xml`, `db/migration/V1__initial_schema.sql`,
`db/migration/V2__task_type_id.sql`.
## Stack
@@ -44,7 +45,7 @@ Repositories obtain sessions through the factory; transactional access uses help
## Entity catalog
Entities live in `me.bobert.netneutrality.data.entity` and are prefixed with `O`. `OGuild` is the hub
Entities live in `me.bobert.netneutrality.db.entities` and are prefixed with `O`. `OGuild` is the hub
of a star-shaped model; most other tables reference `guild_id` with `ON DELETE CASCADE`.
| Entity | Table | Key | Relationships / notes |
@@ -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. |
| `OLoggingId` | — (embeddable) | — | `@Embeddable`/`@IdClass` for the composite key. |
| `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`.* |
### ER overview
@@ -102,8 +103,9 @@ Services wrap repositories with business logic and **caching**.
values in `settingsCache` and reloads on change; used by the
[web API settings endpoints](web-api.md).
- **`TaskService`** — serializes [`PersistableTask`](tasks.md#31-persistable-tasks) instances to JSON
for the `tasks` table and reconstructs them at startup via
[`TaskRegistry`/`TaskFactory`](tasks.md#31-persistable-tasks).
for the `tasks` table, keyed by the task's stable `typeId()` in `task_class`, and reconstructs them
at startup via [`PersistableTaskRegistry`](tasks.md#persistabletaskregistry--discovery-and-restore).
Rows with an unknown `task_class` are logged and skipped.
## 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`,
`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
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.
+52 -77
View File
@@ -1,7 +1,7 @@
# Tasks
# Tasks
**Packages:** `me.bobert.netneutrality.framework.task` (framework),
`me.bobert.netneutrality.feature.*.task` (concrete tasks)
**Packages:** `me.bobert.netneutrality.core.tasks` (framework),
`me.bobert.netneutrality.tasks.*` (concrete tasks)
Tasks are one of the more advanced concepts in the bot. A **Task** is a unit of logic that is looked
up and executed by **id** when a matching Discord event arrives, as opposed to a
@@ -60,7 +60,7 @@ AbstractTask (implements Task)
## `TaskManager`
`me.bobert.netneutrality.framework.task.TaskManager` is the registry, lookup, and execution
`me.bobert.netneutrality.core.tasks.TaskManager` is the registry, lookup, and execution
coordinator.
### Registration — `insert`
@@ -111,94 +111,70 @@ under `tasks/generic`).
## 3.1 Persistable tasks
**Classes:** `PersistableTask` (interface), `AbstractPersistableTask`,
`core.tasks.persistence.TaskRegistry`, `core.tasks.persistence.TaskFactory`;
resource `src/main/resources/persistable-tasks.json`; entity
`core.tasks.persistence.PersistableTaskRegistry`; entity
[`OTask`](persistence.md#entity-catalog).
Persistable tasks survive restarts: they are serialized to the database and rebuilt on startup. The
rules for *how* a persistable task can be defined and reconstructed live in `TaskRegistry` and
`TaskFactory`.
Persistable tasks survive restarts: they are serialized to the database and rebuilt on startup.
There is **no mapping file** — task types are discovered on the classpath and each task knows how to
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"]
}
]
```java
public interface PersistableTask extends Task {
default String typeId() { return getName(); } // stable logical id stored in the DB
void restore(JSONObject payload); // rebuild state from a previous toPayload()
void insert(CommandContext context);
void save();
void delete();
String toPayload();
}
```
Schema:
- **`typeId()`** is the value written to `tasks.task_class`. It defaults to `getName()`, so a package
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.
| 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 |
### `PersistableTaskRegistry` — discovery and restore
Supported types (`TaskFactory.ParamType`): `string` → `String`, `int` → `Integer`, `map` → `Map`.
`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.
### `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:
`create(typeId, payload)` then produces a **fresh** instance per row:
```java
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;
}
});
PersistableTask task = ReflectionHelper.instantiate(type, context);
task.restore(payload);
```
Entries missing `class`, `params`, or `types` are logged and skipped. Consumers later look up a
creator via `TaskRegistry.getTaskCreator(className)`.
Because instantiation goes through the DI context, a restored task needs a constructor whose
parameters are all resolvable from the `ApplicationContext` — in practice `(TaskManager)`. Fields
that only exist in the payload (ids, mappings) are assigned by `restore()` before the instance is
handed to the `TaskManager`.
### `TaskFactory` — reconstructing an instance
`TaskFactory.createTaskInstance(className, params, types, payload)`:
1. Validates that `params.length == types.length`.
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
return (PersistableTask) clazz.getConstructor(classTypes).newInstance(classParams);
```
> **Rule of thumb for adding a persistable task:** create a class that implements `PersistableTask`
> (usually by extending `AbstractPersistableTask`), give it a constructor whose parameters match the
> `params`/`types` you declare, ensure it serializes those same fields into its JSON payload, and add
> a matching entry to `persistable-tasks.json`. The payload is stored in the
> [`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
```
insert(task) ──▶ TaskService serializes payload ──▶ OTask row (task_id, class, payload JSON)
startup ──▶ TaskService reads OTask rows ──▶ TaskRegistry.getTaskCreator(class)
.fromPayload(payload) ──▶ TaskFactory ──▶ instance
insert(task) ──▶ TaskService.persistTask ──▶ OTask row (task_id, task_class = typeId, payload JSON)
startup ──▶ PersistableTaskRegistry.scan() (typeId ──▶ class)
──▶ 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
**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. |
| `AcceptButton` | `generic` | `ButtonTask` | Generic "accept" 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. |
| `CommandHelp` | `help` | `AbstractBuiltInTask` | Show command help + the bot prefix. |
| `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`.
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
on the next startup via the [`persistable-tasks.json`](#the-mapping-file--persistable-tasksjson)
mapping.
on the next startup via the
[`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.
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
configuration) and creates an issue, then returns `Lifecycle.REMOVE_SELF`.
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`.
### Worked example — music results (parent/children)
+6 -5
View File
@@ -1,6 +1,6 @@
# Threading & Scheduling
**Package:** `me.bobert.netneutrality.platform.concurrency`
**Packages:** `me.bobert.netneutrality.core.thread` and `me.bobert.netneutrality.core.scheduled`
This subsystem centralizes asynchronous execution and recurring jobs.
@@ -21,11 +21,11 @@ This subsystem centralizes asynchronous execution and recurring jobs.
## `BaseThreadFactory`
Names created threads (e.g. `web-api-1`) so they are identifiable in logs and stack dumps, and installs
Names created threads (e.g. `pool-1`) so they are identifiable in logs and stack dumps, and installs
an [`ExecutorException`](utilities-and-bootstrap.md#exceptions) as the thread's
`UncaughtExceptionHandler` so crashes on worker threads are still routed through
[`LogHandler`](utilities-and-bootstrap.md#loghandler). It is used both by `ThreadManager` and by the
[Web API](web-api.md) server pool.
[`LogHandler`](utilities-and-bootstrap.md#loghandler). It is used by `ThreadManager`; the
[Web API](web-api.md) runs on Javalin's own thread pool instead.
## `ScheduledTaskManager`
@@ -37,7 +37,8 @@ startup by [`TaskManager.init()`](tasks.md#taskmanager)):
- `cancelAll()` — cancels every scheduled task; called first in
[`App.shutdown()`](architecture.md#shutdown-lifecycle).
The existing scheduled tasks are `MusicManagerCleanup`, `MusicPlayerAutoDisconnect`, and
The existing scheduled tasks are `MusicManagerCleanup`, `MusicPlayerAutoDisconnect` (which reads the
bot's gateway voice state rather than JDA's audio manager, see [music.md](music.md)), and
`TaskCleanup` (see the [task catalog](tasks.md#concrete-task-catalog)).
> **Direction of travel:** the scheduled-task abstraction is intended to eventually replace ad-hoc
+18 -18
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`
| 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). |
| `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()`. |
| `NetNeutralityBot` | Currently an **empty placeholder** class under `framework` (reserved name; no logic yet). |
| `core.BotState` | Work-in-progress global state helper: exposes a `SAFE` flag and `dump()`, which serializes per-guild/music state to `state.json`. Bound to `GuildServiceProvider` / `MusicService` during bootstrap. |
| `bootstrap.BotState` | Work-in-progress global state helper: exposes a `SAFE` flag and `dump()`, which serializes per-guild/music state to `state.json`. Bound to `GuildServiceProvider` / `MusicService` during bootstrap. |
The full startup and shutdown sequences are documented in
[architecture.md](architecture.md#startup-lifecycle).
@@ -37,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):
| Interface | Typical implementor |
|---|---|
| `platform.config.SettingsProvider` | `utils.Settings` |
| `platform.persistence.SessionProvider` | `core.db.SessionManager` |
| `platform.concurrency.ExecutorProvider` | `core.thread.ThreadManager` |
| `framework.command.CommandRegistry` | `core.command.CommandManager` |
| `framework.task.TaskManager` | `core.tasks.TaskManagerImpl` |
| `feature.music.MusicService` | `music.MusicServiceImpl` |
| `data.service.GuildServiceProvider` | `db.service.GuildServiceProviderImpl` |
|-----------------------------------------|---------------------------------------|
| `utils.SettingsProvider` | `utils.Settings` |
| `db.SessionProvider` | `db.SessionManager` |
| `core.thread.ExecutorProvider` | `core.thread.ThreadManager` |
| `core.command.CommandRegistry` | `core.command.CommandManager` |
| `core.tasks.TaskManager` | `core.tasks.TaskManagerImpl` |
| `music.MusicService` | `music.MusicServiceImpl` |
| `db.service.GuildServiceProvider` | `db.service.GuildServiceProviderImpl` |
There are **no** `getInstance()` / `instance()` shims on the managers above. The only intentional
static access points left are:
@@ -61,8 +60,8 @@ static access points left are:
### `Settings` / `SettingsProvider`
**Class:** `me.bobert.netneutrality.platform.config.Settings` implements
`me.bobert.netneutrality.platform.config.SettingsProvider`.
**Class:** `me.bobert.netneutrality.utils.Settings` implements
`me.bobert.netneutrality.utils.SettingsProvider`.
`Settings` is the single source of truth for configuration. It loads values from the `bot.properties`
file and overlays **environment variables** on top (environment variables win).
@@ -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:
| Key | Purpose |
|---|---|
|------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------|
| `NETNEUTRALITY_BOT_ENV` | `dev` / `prod`; controls debug logging and the Sentry environment. |
| `NETNEUTRALITY_BOT_TOKEN` | Discord bot token (**required**). |
| `NETNEUTRALITY_BOT_PREFIX` | Message-command prefix (default `$`). |
@@ -85,12 +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`). |
| `MYSQL_*` | Database connection settings (see [persistence.md](persistence.md)). |
| `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)). |
| `NETNEUTRALITY_PO_TOKEN` / `NETNEUTRALITY_VISITOR_DATA` / `NETNEUTRALITY_CIPHER_*` | YouTube playback auth/cipher settings for the [music](music.md) source. |
| `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_PO_TOKEN` / `NETNEUTRALITY_VISITOR_DATA` / `NETNEUTRALITY_CIPHER_*` | YouTube auth/cipher settings, consumed by the node via `lavalink/application.yml`. |
## Utility helpers
**Package:** `me.bobert.netneutrality.shared.util` (helpers) / `me.bobert.netneutrality.bootstrap` / `me.bobert.netneutrality.platform.*`
**Package:** `me.bobert.netneutrality.utils` (helpers) / `me.bobert.netneutrality.bootstrap` / `me.bobert.netneutrality.exceptions`
| Class | Purpose |
|---|---|
@@ -125,7 +125,7 @@ singletons.
### `LogHandler`
**Class:** `me.bobert.netneutrality.platform.logging.LogHandler`.
**Class:** `me.bobert.netneutrality.core.LogHandler`.
`LogHandler` is the unified logging/error entry point. It routes messages and exceptions to:
+71 -30
View File
@@ -1,51 +1,92 @@
# HTTP Web API
**Package:** `me.bobert.netneutrality.platform.web` (client module) and
`me.bobert.netneutrality.platform.web.server` (server).
**Server:** JDK built-in `com.sun.net.httpserver.HttpServer` (no external web framework).
**Package:** `me.bobert.netneutrality.http` (outbound client) and
`me.bobert.netneutrality.http.server` (server).
**Server:** embedded [Javalin](https://javalin.io) 7 with the OpenAPI and Swagger UI plugins.
The bot exposes a small internal HTTP API for health monitoring, control, and guild introspection —
intended for an external dashboard/companion, not for end users in Discord.
The bot exposes an internal HTTP API for health monitoring, control, guild introspection, and music
control — intended for an external dashboard/companion, not for end users in Discord.
## Server startup
`WebApi` is a [`Lifecycle`](utilities-and-bootstrap.md#applicationcontext--lifecycle) component
(priority 70) registered in [`App.buildContext()`](architecture.md#startup-lifecycle).
It receives `SettingsProvider`, `GuildServiceProvider`, and suppliers for the live `JDA` /
shutdown callback via its constructor. On `start()` it:
It receives `SettingsProvider`, `GuildServiceProvider`, `MusicService`, and suppliers for the live
`JDA` / shutdown callback via its constructor. On `start()` it:
- reads the port from `NETNEUTRALITY_API_PORT` (default `9000`),
- creates an `HttpServer` bound to that port,
- installs an `Executor` backed by a fixed thread pool of 10 threads named via
[`BaseThreadFactory`](threading.md),
- registers the handler contexts (below), and
- starts the server.
- creates a `Javalin` app (Javalin 7 requires routes and exception/error handlers to be registered
upfront inside `Javalin.create`),
- registers the `OpenApiPlugin` (title "Net Neutrality API", `X-API-Key` security scheme) and the
`SwaggerPlugin`,
- installs the `ApiKeyFilter` as a `before` handler,
- registers the route groups (below), the exception handlers and the 404 handler, and
- starts the server on the port.
On `stop()` the server and its executor are shut down gracefully.
On `stop()` the Javalin app is stopped.
## Handler model
## Authentication
`AbstractHandler` (implements `HttpHandler`) is the base for all handlers and provides helpers such as
`sendJsonResponse(...)` and `sendStringResponse(...)`. Concrete handlers extend it and are mounted at
a path prefix.
`ApiKeyFilter` compares the `X-API-Key` request header with the `NETNEUTRALITY_API_KEY` setting.
A missing/invalid key — or no configured key at all — yields `401` ("Missing or invalid API key").
Public paths that skip the check: `/`, and anything starting with `/openapi`, `/swagger` or
`/webjars` (Swagger UI and its assets).
| Handler | Base path | Endpoints |
|---|---|---|
| `RootHandler` | `/` | Simple HTML "OK" landing response. |
| `GuildHandler` | `/guilds` | `/guilds/owner` — guild ids + owner ids (JSON); `/guilds/channels/{guildId}` — text channels (JSON); `/guilds/roles/{guildId}` — roles (JSON). |
| `ActionHandler` | `/actions` | `/actions/status` — JDA health + gateway ping; `/actions/shutdown` — graceful shutdown; `/actions/settings/current/{guildId}` — current guild settings; `/actions/settings/reload/{guildId}` — invalidate & reload the settings cache. |
## Error handling
The action/guild handlers read live state from the [`JDA`](architecture.md) instance and from the
[persistence services](persistence.md#service-layer) (e.g. `GuildSettingService` for the settings
endpoints).
- `HttpResponseException` (e.g. `BadRequestResponse`, `NotFoundResponse`) →
`{"status":"error","message":"..."}` with the exception's status.
- Any other exception → logged, `500` with `{"status":"error"}`.
- Unknown paths → `404` with `{"status":"error","message":"Not found"}`.
## Route groups
Each group is a `*Routes` class with a static `register(RoutesConfig, ...)` method; handlers are
annotated with `@OpenApi` so they appear in the generated OpenAPI document. Request/response shapes
live in the `http.server.dto` package.
| Class | Endpoints |
|---|---|
| `WebApi` | `GET /` — HTML liveness message. |
| `GuildRoutes` | `GET /guilds` (optional `user_id` query restricts to mutual guilds); `GET /guilds/owner`; `GET /guilds/{guildId}/owner`; `/channels`; `/roles`; `/members/{userId}`, `/members/{userId}/access`, `/members/{userId}/roles`, `/members/{userId}/admin`; `/voice-channels`, `/voice-channels/{channelId}`; `GET /guilds/{guildId}/config`; `PUT /guilds/{guildId}/debug`, `/logging`, `/log-channel`. |
| `ActionRoutes` | `GET /actions/status` — JDA health + gateway ping; `POST /actions/shutdown` — graceful shutdown. |
| `SettingsRoutes` | `GET /settings/catalog` — every known setting with default and description; `GET /settings/current/{guildId}` — current guild settings; `PUT /settings/current/{guildId}/{name}` — update a setting; `PUT /settings/reload/{guildId}` — invalidate & reload the settings cache. |
| `StatsRoutes` | `GET /stats/bot`; `GET /stats/guilds/{guildId}`. |
| `MusicRoutes` | See below. |
### Music endpoints
Mirror the slash commands (see [music.md](music.md)); announcements are also posted to the guild's
music channel when a context is attached.
| Method & path | Purpose |
|---|---|
| `GET /music/sources` | Supported music sources. |
| `GET /music/{guildId}/status` | Connection, playback, current track, position, queue size. |
| `GET /music/{guildId}/queue` | Queued tracks. |
| `POST /music/{guildId}/search` | Resolve a query (optional `searchEngine`) into candidates; returns a `search_id`. Nothing is queued. |
| `POST /music/{guildId}/queue` | Queue a candidate by `searchId` + `index` (or all with `queueAll=true`) for `userId`; the bot must already be in a voice channel. |
| `DELETE /music/{guildId}/queue` | Clear the queue, or remove one track with `?position=` (1-based). |
| `PATCH /music/{guildId}/queue/reorder` | Move a track (`fromPosition` → `toPosition`). |
| `POST /music/{guildId}/skip`, `/stop`, `/pause`, `/resume`, `/shuffle`, `/leave` | Playback controls. |
| `GET` / `PUT /music/{guildId}/volume` | Read / set volume (0–100). |
| `GET` / `PUT /music/{guildId}/autoplay` | Read / set autoplay (`{"enabled": true}`). In-memory, resets when the player is destroyed. |
| `PUT /music/{guildId}/seek` | Seek to `positionMs`. |
| `GET /music/{guildId}/vote-skip` | Vote-skip status. |
| `POST /music/{guildId}/vote-skip/{userId}` | Cast a vote on behalf of a member. |
| `GET /music/{guildId}/dj/{userId}` | Whether the member is currently allowed to act as DJ. |
Search results are held in `PendingSearchResults` and expire after a TTL (returned as
`search_id_ttl_seconds`).
## HTTP client — `WebModule`
`WebModule` wraps an `OkHttpClient` for the bot's own outbound HTTP calls (e.g. checking for updates
via GitLab). It keeps a small `LinkedHashMap`-based response cache limited to 50 entries.
`WebModule` (package `me.bobert.netneutrality.http`) wraps an `OkHttpClient` for the bot's own
outbound HTTP calls (e.g. checking for updates via GitLab). Responses are cached in
`ResponseCache`, a `LinkedHashMap`-based LRU limited to 50 entries.
## Notes
- There is no authentication layer documented on these endpoints; the API is expected to be bound to a
trusted/internal network (e.g. within the Docker network). `/actions/shutdown` is destructive, so the
port should not be exposed publicly.
- All routes except the public ones require the API key. Even so, the API is expected to be bound to
a trusted/internal network (e.g. within the Docker network); `/actions/shutdown` is destructive,
so the port should not be exposed publicly.
+65
View File
@@ -0,0 +1,65 @@
server:
port: 2333
address: 0.0.0.0
lavalink:
plugins:
# Replacement for the in-process dev.lavalink.youtube:v2 source manager.
# Custom SABR-enabled fork (ashton045/youtube-source, feat/sabr-support) built by JitPack.
- dependency: "com.github.ashton045.youtube-source:youtube-plugin:33d50abd5f"
repository: "https://jitpack.io"
snapshot: false
server:
password: "${SERVER_PASSWORD:youshallnotpass}"
sources:
# Handled by the youtube-plugin below, must stay disabled here.
youtube: false
soundcloud: true
bandcamp: false
twitch: false
vimeo: false
nico: false
http: true
local: false
filters:
volume: true
bufferDurationMs: 400
frameBufferDurationMs: 5000
opusEncodingQuality: 10
resamplingQuality: LOW
trackStuckThresholdMs: 10000
useSeekGhosting: true
youtubePlaylistLoadLimit: 6
playerUpdateInterval: 5
youtubeSearchEnabled: true
soundcloudSearchEnabled: true
plugins:
youtube:
enabled: true
allowSearch: true
allowDirectVideoIds: true
allowDirectPlaylistIds: true
clients:
- TV
- MWEB
- WEB
- MUSIC
- WEBEMBEDDED
- ANDROID_VR
- TVHTML5_SIMPLY
oauth:
enabled: true
refreshToken: "${NETNEUTRALITY_OAUTH_TOKEN}"
remoteCipher:
url: "${NETNEUTRALITY_CIPHER_URL:http://ejs-api:8001}"
password: "${NETNEUTRALITY_CIPHER_PASSWORD:}"
remotePot:
url: "${NETNEUTRALITY_POT_URL:http://webpo:8080}"
logging:
level:
root: INFO
lavalink: DEBUG
# Set to INFO to see the OAuth flow instructions (device code / verification URL) in the terminal.
dev.lavalink.youtube.http.YoutubeOauth2Handler: INFO
+87 -58
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"
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.target>25</maven.compiler.target>
<mockito.version>5.23.0</mockito.version>
<testcontainers.version>2.0.5</testcontainers.version>
</properties>
<build>
@@ -29,6 +30,27 @@
</resources>
<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>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
@@ -44,8 +66,11 @@
<artifactId>flyway-maven-plugin</artifactId>
<version>11.20.3</version>
<configuration>
<!--suppress UnresolvedMavenProperty -->
<url>${env.FLYWAY_URL}</url>
<!--suppress UnresolvedMavenProperty -->
<user>${env.FLYWAY_USER}</user>
<!--suppress UnresolvedMavenProperty -->
<password>${env.FLYWAY_PASSWORD}</password>
</configuration>
</plugin>
@@ -62,9 +87,12 @@
<artifactId>maven-jar-plugin</artifactId>
<version>3.5.0</version>
<configuration>
<finalName>netneutrality</finalName>
<archive>
<manifest>
<mainClass>${main.class}</mainClass>
<addClasspath>true</addClasspath>
<classpathPrefix>lib/</classpathPrefix>
</manifest>
</archive>
</configuration>
@@ -72,28 +100,18 @@
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.6.2</version>
<artifactId>maven-dependency-plugin</artifactId>
<version>3.8.1</version>
<executions>
<execution>
<phase>package</phase>
<id>copy-libs</id>
<phase>prepare-package</phase>
<goals>
<goal>shade</goal>
<goal>copy-dependencies</goal>
</goals>
<configuration>
<finalName>netneutrality</finalName>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>${main.class}</mainClass>
</transformer>
<transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<manifestEntries>
<Multi-Release>true</Multi-Release>
</manifestEntries>
</transformer>
</transformers>
<createDependencyReducedPom>false</createDependencyReducedPom>
<outputDirectory>${project.build.directory}/lib</outputDirectory>
<includeScope>runtime</includeScope>
</configuration>
</execution>
</executions>
@@ -146,35 +164,11 @@
<artifactId>JDA</artifactId>
<version>6.4.1</version>
</dependency>
<!-- Lavalink v4 client; audio decoding happens on the remote node. -->
<dependency>
<groupId>dev.arbjerg</groupId>
<artifactId>lavaplayer</artifactId>
<version>2.2.7</version>
</dependency>
<dependency>
<groupId>dev.lavalink.youtube</groupId>
<artifactId>v2</artifactId>
<version>1.18.2</version>
</dependency>
<dependency>
<groupId>club.minnced</groupId>
<artifactId>jdave-api</artifactId>
<version>0.1.8</version>
</dependency>
<dependency>
<groupId>club.minnced</groupId>
<artifactId>jdave-native-linux-x86-64</artifactId>
<version>0.1.8</version>
</dependency>
<dependency>
<groupId>club.minnced</groupId>
<artifactId>jdave-native-win-x86-64</artifactId>
<version>0.1.8</version>
</dependency>
<dependency>
<groupId>club.minnced</groupId>
<artifactId>jdave-native-darwin</artifactId>
<version>0.1.8</version>
<artifactId>lavalink-client</artifactId>
<version>3.4.0</version>
</dependency>
<dependency>
@@ -183,6 +177,34 @@
<version>5.3.2</version>
</dependency>
<dependency>
<groupId>io.javalin</groupId>
<artifactId>javalin</artifactId>
<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>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-core</artifactId>
@@ -193,6 +215,11 @@
<artifactId>hibernate-hikaricp</artifactId>
<version>7.1.9.Final</version>
</dependency>
<dependency>
<groupId>jakarta.data</groupId>
<artifactId>jakarta.data-api</artifactId>
<version>1.0.1</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
@@ -215,11 +242,6 @@
<artifactId>sentry</artifactId>
<version>8.41.0</version>
</dependency>
<dependency>
<groupId>org.gitlab4j</groupId>
<artifactId>gitlab4j-api</artifactId>
<version>6.2.0</version>
</dependency>
<dependency>
<groupId>net.fellbaum</groupId>
@@ -228,14 +250,9 @@
</dependency>
<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
<version>20250517</version>
</dependency>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.14.0</version>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.21.2</version>
</dependency>
<dependency>
@@ -289,6 +306,18 @@
<version>${mockito.version}</version>
<scope>test</scope>
</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>
</project>
+53 -64
View File
@@ -6,7 +6,6 @@ package me.bobert.netneutrality;
* https://discord.gg/qUa6zNFZsd
*/
import club.minnced.discord.jdave.interop.JDaveSessionFactory;
import io.sentry.Sentry;
import me.bobert.netneutrality.bootstrap.ApplicationContext;
import me.bobert.netneutrality.bootstrap.Lifecycle;
@@ -14,6 +13,9 @@ import me.bobert.netneutrality.bootstrap.BotState;
import me.bobert.netneutrality.core.LogHandler;
import me.bobert.netneutrality.core.command.CommandManager;
import me.bobert.netneutrality.db.SessionManager;
import me.bobert.netneutrality.events.EventPublisher;
import me.bobert.netneutrality.events.NoopEventPublisher;
import me.bobert.netneutrality.events.WebhookDispatcher;
import me.bobert.netneutrality.core.tasks.TaskManagerImpl;
import me.bobert.netneutrality.core.scheduled.ScheduledTaskManager;
import me.bobert.netneutrality.core.thread.ThreadManager;
@@ -21,22 +23,31 @@ import me.bobert.netneutrality.db.service.GuildServiceProviderImpl;
import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.core.command.CommandRegistry;
import me.bobert.netneutrality.core.tasks.TaskManager;
import me.bobert.netneutrality.hooks.EventListenerHandler;
import me.bobert.netneutrality.hooks.EventsHandler;
import me.bobert.netneutrality.hooks.GuildLifecycleHandler;
import me.bobert.netneutrality.hooks.InteractionHandlers;
import me.bobert.netneutrality.hooks.MessageReceivedHandler;
import me.bobert.netneutrality.hooks.ReadyHandler;
import me.bobert.netneutrality.hooks.VoiceEventPublisher;
import java.util.ArrayList;
import java.util.List;
import me.bobert.netneutrality.http.server.WebApi;
import me.bobert.netneutrality.music.MusicServiceImpl;
import me.bobert.netneutrality.core.thread.ExecutorProvider;
import me.bobert.netneutrality.utils.SettingsProvider;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
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.http.WebModule;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.JDABuilder;
import net.dv8tion.jda.api.audio.AudioModuleConfig;
import me.bobert.netneutrality.shard.ShardService;
import net.dv8tion.jda.api.entities.Activity;
import net.dv8tion.jda.api.exceptions.InvalidTokenException;
import net.dv8tion.jda.api.requests.GatewayIntent;
import org.apache.logging.log4j.Level;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
@@ -46,7 +57,7 @@ public class App {
private static final Logger logger = LogManager.getLogger(App.class);
private static App instance;
private final Settings settings;
private JDA jda;
private EventPublisher events = NoopEventPublisher.INSTANCE;
private final ApplicationContext context = new ApplicationContext();
static void main() {
@@ -71,8 +82,7 @@ public class App {
if ("dev".equals(env)) {
Configurator.setLevel("net.dv8tion.jda", Level.DEBUG);
Configurator.setLevel("dev.lavalink.youtube", Level.DEBUG);
Configurator.setLevel("com.sedmelluq.discord", Level.DEBUG);
Configurator.setLevel("dev.arbjerg.lavalink", Level.DEBUG);
}
instance = new App(settings);
@@ -95,7 +105,9 @@ public class App {
context.start();
} catch (Exception 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));
@@ -113,9 +125,11 @@ public class App {
// Allow ApplicationContext itself to be injected into components that need it.
context.register(context);
// Persistence: build the SessionFactory + run migrations; released last on shutdown.
// SessionManager binds itself into JpaUtils for repository access.
context.register(new SessionManager(settings));
// Persistence: run migrations + build the SessionFactory; released last on shutdown.
SessionManager sessions = 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.
ExecutorProvider executors = context.register(new ThreadManager());
@@ -129,29 +143,42 @@ public class App {
CommandRegistry commands = context.register(new CommandManager(context));
// 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).
GuildServiceProvider guildServices = context.register(new GuildServiceProviderImpl());
GuildServiceProvider guildServices = context.register(
new GuildServiceProviderImpl(guildRepository, blacklistRepository, guildSettingRepository));
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.
MusicService music = context.register(new MusicServiceImpl(settings, guildServices, tasks));
// Webhook event dispatch (priority 50); stays a no-op publisher when no URL is configured.
String webhookUrl = settings.get("NETNEUTRALITY_WEBHOOK_URL");
if (webhookUrl != null && !webhookUrl.isBlank()) {
events = context.register(new WebhookDispatcher(webhookUrl, settings.get("NETNEUTRALITY_WEBHOOK_SECRET")));
}
// Music (priority 62, above the shards so it stops first): disconnect players while JDA is still up.
MusicService music = context.register(new MusicServiceImpl(settings, guildServices, tasks, executors, events));
BotState.bind(guildServices, music);
// JDA gateway; disconnected on shutdown.
context.register(lifecycle(60, this::initJDA, this::disconnect));
// JDA shards (priority 60); the events handler is attached to every shard before it connects.
List<EventListenerHandler<?>> handlers = new ArrayList<>();
handlers.add(new MessageReceivedHandler(settings, guildServices));
handlers.addAll(InteractionHandlers.create(settings));
handlers.add(new ReadyHandler(commands));
handlers.addAll(new GuildLifecycleHandler(guildServices).handlers());
handlers.add(new VoiceEventPublisher(events));
ShardService shards = context.register(new ShardService(
settings, music, new EventsHandler(handlers, commands, tasks)));
// Register slash commands, attach the event handler and set the presence once JDA is ready.
// Slash commands are registered from EventsHandler on ReadyEvent; set the presence here.
context.register(lifecycle(65, () -> {
commands.registerSlashCommands(jda);
jda.addEventListener(new EventsHandler(settings, guildServices, commands, tasks));
jda.getPresence().setActivity(Activity.watching("you"));
shards.setActivity(Activity.watching("you"));
}, () -> { }));
// Web API (priority 70) — Lifecycle with graceful stop.
context.register(new WebApi(settings, guildServices, this::getJDA, this::shutdown));
context.register(new WebApi(settings, guildServices, music, shards, this::shutdown));
}
private static Lifecycle lifecycle(int priority, ThrowingRunnable start, Runnable stop) {
@@ -182,48 +209,10 @@ public class App {
return instance;
}
public void initJDA() {
String token = settings.get("NETNEUTRALITY_BOT_TOKEN");
if (token == null) {
SessionManager sessions = context.get(SessionManager.class);
if (sessions != null) {
sessions.stop();
}
throw new RuntimeException("No bot token provided, exiting.");
}
try {
jda = JDABuilder
.createDefault(token)
.enableIntents(
GatewayIntent.GUILD_MEMBERS,
GatewayIntent.GUILD_MESSAGE_REACTIONS,
GatewayIntent.GUILD_VOICE_STATES,
GatewayIntent.MESSAGE_CONTENT)
.setAudioModuleConfig(
new AudioModuleConfig().withDaveSessionFactory(new JDaveSessionFactory()))
.setActivity(Activity.watching("me load..."))
.build();
jda.awaitReady();
} catch (InvalidTokenException | IllegalArgumentException | InterruptedException e) {
LogHandler.log(e);
System.exit(1);
}
}
public void shutdown() {
logger.info("[Net Neutrality] Shutting down...");
context.stop();
logger.info("[Net Neutrality] Shutdown complete");
}
public JDA getJDA() {
return jda;
}
private void disconnect() {
if (jda != null) {
jda.shutdown();
}
}
}
@@ -5,13 +5,14 @@ package me.bobert.netneutrality.bootstrap;
* 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.GuildService;
import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.music.TrackScheduler;
import org.json.JSONArray;
import org.json.JSONObject;
import me.bobert.netneutrality.utils.Json;
import java.io.File;
import java.io.FileWriter;
@@ -41,10 +42,10 @@ public class BotState {
return;
}
Map<String, GuildService> services = guildServices.getServices();
Map<String, GuildService> services = guildServices.snapshot();
JSONObject main = new JSONObject();
JSONArray guildsArray = new JSONArray();
ObjectNode main = Json.object();
ArrayNode guildsArray = Json.array();
for (MusicManager manager : musicService.getManagers()) {
TrackScheduler trackScheduler = manager.getScheduler();
@@ -53,9 +54,9 @@ public class BotState {
for (GuildService service : services.values()) {
JSONObject guild = new JSONObject();
JSONObject music = new JSONObject();
guild.put("id", service.getEntity().getGuildId());
ObjectNode guild = Json.object();
ObjectNode music = Json.object();
guild.put("id", service.getGuildId());
// MessageChannel currentChannel = trackScheduler.getTextChannel();
// if (currentChannel == null) {
@@ -64,12 +65,12 @@ public class BotState {
// 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");
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.options.CommandOption;
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.GuildServiceProvider;
import net.dv8tion.jda.api.interactions.commands.OptionType;
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.Objects;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
public class BlacklistCommand extends AdminCommand {
private static final Logger logger = LogManager.getLogger(BlacklistCommand.class);
private final GuildServiceProvider guildServices;
@@ -64,16 +68,16 @@ public class BlacklistCommand extends AdminCommand {
public CommandExecutionResult execute(CommandContext context) {
String sub = context.getSubcommand();
String word = context.getOption("word", "", CommandOption::getAsString);
BlacklistService service = guildServices.get(context.getGuild()).getBlacklistService();
BlacklistService service = guildServices.get(context.getGuild().getId()).getBlacklistService();
switch (sub) {
case "get" -> context.async("Retrieving list, please wait...", asyncMessage -> {
List<OBlacklist> blacklists = service.getBlacklistForGuild();
List<BlacklistEntry> blacklists = service.list();
StringBuilder sb = new StringBuilder();
sb.append("List of blacklisted words for this server: \n");
for (OBlacklist bl : blacklists) {
sb.append(bl.getWord()).append(" -> ").append(bl.getEnabled() ? "enabled" : "disabled").append("\n");
for (BlacklistEntry bl : blacklists) {
sb.append(bl.word()).append(" -> ").append(bl.enabled() ? "enabled" : "disabled").append("\n");
}
if (blacklists.isEmpty()) {
@@ -83,13 +87,32 @@ public class BlacklistCommand extends AdminCommand {
asyncMessage.edit(sb.toString());
});
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" -> {
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();
}
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.CommandHelp;
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.buttons.Button;
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.db.GSettings;
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.channel.concrete.TextChannel;
import net.dv8tion.jda.api.entities.channel.unions.GuildChannelUnion;
import net.dv8tion.jda.api.interactions.commands.OptionType;
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
import net.dv8tion.jda.api.interactions.commands.build.SubcommandData;
import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder;
import java.util.Map;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
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 {
private static final Logger logger = LogManager.getLogger(ConfigureCommand.class);
private final GuildServiceProvider guildServices;
private final MusicService musicService;
@@ -70,69 +78,186 @@ public class ConfigureCommand extends AdminCommand {
new SubcommandData("music-role", "Role needed to use music commands")
.addOption(OptionType.ROLE, "role", "Name of role", true),
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
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();
GuildService guildService = guildServices.get(context.getGuild().getId());
GuildSettingService settingService = guildService.getGuildSettingService();
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);
assert role != null;
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");
}
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.error("Unknown subcommand: music-role");
}
if ("music-channel".equals(sub)) {
private CommandExecutionResult configureMusicChannel(CommandContext context, GuildSettingService settingService) {
GuildChannelUnion channel = context.getOption("channel", CommandOption::getAsChannel);
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");
}
if (channel instanceof TextChannel) {
if (channel.getGuild().getId().equals(context.getGuild().getId())) {
guildService.getGuildSettingService().setSettingValue(GSettings.MUSIC_CHANNEL, channel.getId());
if (channel instanceof TextChannel && channel.getGuild().getId().equals(context.getGuild().getId())) {
settingService.set(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.error("Unknown subcommand: music-channel");
}
}
if ("music-volume".equals(sub)) {
private CommandExecutionResult configureMusicVolume(CommandContext context, GuildSettingService settingService) {
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);
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);
guildService.updateDebugMode(debug);
return CommandExecutionResult.success("Error logging is now %s for this guild", (debug ? "enabled" : "disabled"));
}
if ("show-all".equals(sub)) {
Map<String, String> settings = guildService.getGuildSettingService().getSettings();
private CommandExecutionResult showAll(CommandContext context, GuildSettingService settingService) {
Map<String, String> settings = settingService.snapshot();
MessageCreateBuilder builder = new MessageCreateBuilder();
for (GSettings o : GSettings.values()) {
@@ -142,13 +267,12 @@ public class ConfigureCommand extends AdminCommand {
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");
}
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
*/
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import me.bobert.netneutrality.core.LogHandler;
import me.bobert.netneutrality.core.command.CommandCategory;
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 net.dv8tion.jda.api.interactions.commands.OptionType;
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
import org.json.JSONObject;
import java.text.NumberFormat;
import java.util.Currency;
@@ -69,7 +70,7 @@ public class CryptoPriceCommand extends AbstractCommand {
@Override
public CommandExecutionResult execute(CommandContext context) {
Locale usa = new Locale("en", "US");
Locale usa = Locale.US;
NumberFormat dollarFormat = NumberFormat.getCurrencyInstance(usa);
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();
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");
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);
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) {
try {
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")
.getJSONObject("exchanges")
.getJSONObject(exchange)
.getJSONObject("pairs");
JsonNode pairs = json.path("Data")
.path("exchanges")
.path(exchange)
.path("pairs");
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.impl.AbstractCommand;
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 java.util.Objects;
import java.util.concurrent.TimeUnit;
import me.bobert.netneutrality.core.tasks.TaskManager;
import me.bobert.netneutrality.utils.SettingsProvider;
@@ -72,15 +71,16 @@ public class ReportIssueCommand extends AbstractCommand {
@Override
public CommandExecutionResult execute(CommandContext context) {
String gitlabHost = settings.get("GITLAB_HOST");
String gitlabToken = settings.get("GITLAB_TOKEN");
String projectId = settings.get("GITLAB_PROJECT_ID");
String host = settings.get("GITEA_HOST");
String token = settings.get("GITEA_TOKEN");
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.");
}
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));
}
@@ -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();
if (MusicUtils.checkIfConnected(context, manager)) {
return manager.getQueueManager().clearQueue();
return CommandExecutionResult.from(manager.getQueueManager().clearQueue());
}
return CommandExecutionResult.none();
@@ -13,7 +13,7 @@ import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.core.context.CommandContext;
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;
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.context.CommandContext;
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 net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.interactions.commands.OptionType;
@@ -83,18 +83,18 @@ public class QueueCommand extends MusicCommand {
switch (sub) {
case "clear-all" -> {
return manager.getQueueManager().clearQueue();
return CommandExecutionResult.from(manager.getQueueManager().clearQueue());
}
case "delete-track" -> {
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 "shuffle" -> {
return manager.getQueueManager().shuffle();
return CommandExecutionResult.from(manager.getQueueManager().shuffle());
}
case "from-user" -> {
@@ -112,7 +112,7 @@ public class QueueCommand extends MusicCommand {
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());
if (MusicUtils.checkIfConnected(context, manager)) {
return manager.resume();
return CommandExecutionResult.from(manager.resume());
}
return CommandExecutionResult.none();
@@ -57,7 +57,7 @@ public class ShuffleCommand extends MusicCommand {
context.ack();
if (MusicUtils.checkIfConnected(context, manager)) {
return manager.getQueueManager().shuffle();
return CommandExecutionResult.from(manager.getQueueManager().shuffle());
}
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.music.MusicManager;
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.build.OptionData;
@@ -65,9 +65,9 @@ public class SkipCommand extends MusicCommand {
if (MusicUtils.checkIfConnected(context, manager)) {
int skips = context.getOption("skip", 1, CommandOption::getAsInt);
if (UserFunction.isAdmin(context) || MusicUtils.isUserDj(context, manager)) {
return manager.getQueueManager().skip(skips);
return CommandExecutionResult.from(manager.getQueueManager().skip(skips));
} 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.music.MusicManager;
import me.bobert.netneutrality.utils.MusicUtils;
import me.bobert.netneutrality.shared.util.UserFunction;
import me.bobert.netneutrality.utils.UserFunction;
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.core.context.CommandContext;
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;
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.music.MusicManager;
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.build.OptionData;
@@ -56,7 +56,7 @@ public class VoteSkipCommand extends MusicCommand {
MusicManager manager = musicService.get(context.getGuild());
if (MusicUtils.checkIfConnected(context, manager)) {
return manager.voteSkip(context);
return CommandExecutionResult.from(manager.voteSkip(context.getGuildUser()));
}
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.MessageContext;
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.Mentions;
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 java.util.ArrayList;
import java.util.EnumSet;
import java.util.List;
import java.util.Set;
import java.util.StringJoiner;
import java.util.regex.Pattern;
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 Message message;
@@ -61,7 +71,6 @@ public class MessageCommandContext extends CommandContext implements MessageCont
@Override
public List<CommandOption> parseOptions(String[] args, List<OptionData> commandOptions) {
List<CommandOption> options = new ArrayList<>();
int required = commandOptions.stream().filter(OptionData::isRequired).toList().size();
Mentions mentions = message.getMentions();
int roleMentionIndex = 0;
int userMentionIndex = 0;
@@ -69,73 +78,48 @@ public class MessageCommandContext extends CommandContext implements MessageCont
int channelMentionIndex = 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();
Set<ChannelType> requiredChannelTypes = data.getChannelTypes();
List<Command.Choice> choices = data.getChoices();
int position = index + 1;
Object value = null;
try {
if (index >= args.length) {
if (data.isRequired()) {
invalidateContext(data, "missing required argument at index " + position);
}
continue;
}
switch (type) {
case STRING -> {
int currentOptionIndex = commandOptions.indexOf(data);
boolean nextOptionIsString = false;
if (currentOptionIndex + 1 < commandOptions.size()) {
OptionData nextOption = commandOptions.get(currentOptionIndex + 1);
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;
String str;
if (i + 1 < commandOptions.size() && commandOptions.get(i + 1).getType() == OptionType.STRING) {
// The next option is also a string, so only the current argument belongs to this one
str = args[index++];
} else {
// Next option is also a string, so just take the current argument
if (index < args.length) {
strBuilder.append(args[index]);
}
StringJoiner joiner = new StringJoiner(" ");
while (index < args.length && !stopsTextCapture(args[index], commandOptions, i)) {
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;
}
String str = strBuilder.toString();
if (!choices.isEmpty()) {
List<Command.Choice> matched = choices.stream().filter(choice -> choice.getAsString().equalsIgnoreCase(str)).toList();
if (matched.isEmpty()) {
if (!choices.isEmpty() && choices.stream().noneMatch(choice -> choice.getAsString().equalsIgnoreCase(str))) {
invalidateContext(data, "not a valid choice, valid choices are: " + validChoices(choices));
}
break;
}
Integer min = data.getMinLength();
@@ -143,165 +127,202 @@ public class MessageCommandContext extends CommandContext implements MessageCont
if (min != null && str.length() < min) {
invalidateContext(data, "minimum length for argument is " + min);
break;
}
if (max != null && str.length() > max) {
invalidateContext(data, "maximum length for argument is " + max);
break;
}
value = str;
}
case INTEGER -> {
long num = Long.parseLong(args[index]);
if (!choices.isEmpty()) {
List<Command.Choice> matched = choices.stream().filter(choice -> choice.getAsLong() == num).toList();
if (matched.isEmpty()) {
invalidateContext(data, "not a valid choice, valid choices are: " + validChoices(choices));
String str = args[index++];
if (!INTEGER_PATTERN.matcher(str).matches()) {
invalidateContext(data, "the argument provided is not a number.");
break;
}
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 max = data.getMaxValue();
if (min != null && num < min.longValue()) {
value = 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);
invalidateContext(data, "minimum value for argument is " + min.longValue());
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");
break;
}
value = Boolean.parseBoolean(str);
}
case NUMBER -> {
double num = Double.parseDouble(args[index]);
if (!choices.isEmpty()) {
List<Command.Choice> matched = choices.stream().filter(choice -> choice.getAsDouble() == num).toList();
if (matched.isEmpty()) {
String str = args[index++];
if (!NUMBER_PATTERN.matcher(str).matches()) {
invalidateContext(data, "the argument provided is not a number.");
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));
break;
}
}
Number max = data.getMaxValue();
Number min = data.getMinValue();
value = num;
Number max = data.getMaxValue();
if (min != null && num < min.doubleValue()) {
value = min.doubleValue();
} else if (max != null && num > max.doubleValue()) {
value = max.doubleValue();
invalidateContext(data, "minimum value for argument is " + min.doubleValue());
break;
}
if (max != null && num > max.doubleValue()) {
invalidateContext(data, "maximum value for argument is " + max.doubleValue());
break;
}
value = num;
}
case ROLE -> {
if (mentions.getRoles().size() > roleMentionIndex) {
Role role = mentions.getRoles().get(roleMentionIndex++);
if (role.getGuild().getId().equals(getGuild().getId())) {
value = role;
}
index++;
Role role = mentions.getRoles().size() > roleMentionIndex ? mentions.getRoles().get(roleMentionIndex++) : null;
if (role == null || !isFromCurrentGuild(role.getGuild())) {
invalidateContext(data, "provided argument at position " + position + " is not a valid role.");
break;
}
invalidateContext(data, "provided argument at position " + (index + 1) + " is not a valid role.");
value = role;
}
case USER -> {
if (mentions.getMembers().size() > memberMentionIndex) {
Member member = mentions.getMembers().get(memberMentionIndex++);
if (member.getGuild().getId().equals(getGuild().getId())) {
value = member;
break;
}
index++;
value = resolveUser(mentions, memberMentionIndex, userMentionIndex);
if (value instanceof Member) {
memberMentionIndex++;
} else if (value != null) {
userMentionIndex++;
}
if (mentions.getUsers().size() > userMentionIndex) {
value = mentions.getUsers().get(userMentionIndex++);
break;
if (value == null) {
invalidateContext(data, "provided argument at position " + position + " is not a valid user.");
}
invalidateContext(data, "provided argument at position " + (index + 1) + " is not a valid user.");
}
case MENTIONABLE -> {
index++;
if (mentions.getRoles().size() > 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;
}
break;
}
if (mentions.getMembers().size() > memberMentionIndex) {
Member member = mentions.getMembers().get(memberMentionIndex++);
if (member.getGuild().getId().equals(getGuild().getId())) {
value = member;
value = resolveUser(mentions, memberMentionIndex, userMentionIndex);
if (value instanceof Member) {
memberMentionIndex++;
} 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 -> {
if (mentions.getChannels().size() > channelMentionIndex) {
GuildChannel channel = mentions.getChannels().get(channelMentionIndex++);
index++;
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())) {
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));
break;
}
if (channel.getGuild().getId().equals(getGuild().getId())) {
value = channel;
break;
}
}
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.");
default -> index++;
}
if (value != null) {
options.add(new MessageCommandOption(data, value));
}
index++;
}
// Check if there are leftover arguments that weren't consumed
if (index < args.length) {
if (options.size() >= required) {
invalidateContext(commandOptions.getLast(), "too many arguments");
}
OptionData lastOption = commandOptions.isEmpty() ? LEFTOVER_ARGUMENTS : commandOptions.getLast();
invalidateContext(lastOption, "too many arguments");
}
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) {
StringJoiner joiner = new StringJoiner(", ");
for (Command.Choice choice : choices) {
@@ -30,11 +30,6 @@ public class SlashCommandContext extends CommandContext implements InteractionCo
return true;
}
@Override
public boolean canSkipPermissionCheck() {
return true;
}
@Override
public SlashCommandInteractionEvent getEvent() {
return event;
@@ -5,32 +5,65 @@ package me.bobert.netneutrality.core;
* https://discord.gg/qUa6zNFZsd
*/
import io.sentry.Sentry;
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.core.log.DiscordDebugChannelLogSink;
import me.bobert.netneutrality.core.log.Log4jLogSink;
import me.bobert.netneutrality.core.log.LogSink;
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.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.channel.concrete.TextChannel;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.util.concurrent.CopyOnWriteArrayList;
public class LogHandler {
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.
* Internally registers (or replaces) the Discord debug-channel {@link LogSink}.
*
* @param provider the live guild service 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
@@ -38,17 +71,15 @@ public class LogHandler {
* @param e The exceptions to log
*/
public static void log(Exception e) {
Sentry.captureException(e);
LoggedException event = LoggedException.of(e);
if (e instanceof ContextException exception) {
logInternally(exception);
tryLogToDebugChannel(exception);
tryGracefulContextError(exception);
return;
for (LogSink sink : sinks) {
safelyHandle(sink, event);
}
logger.error(e.getMessage(), e);
if (e instanceof ContextException exception) {
tryGracefulContextError(exception);
}
}
public static void log(String s, Exception e) {
@@ -69,37 +100,11 @@ public class LogHandler {
}
private static 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);
}
}
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());
}
private static void safelyHandle(LogSink sink, LoggedException event) {
try {
sink.handle(event);
} catch (Throwable t) {
logger.error("Log sink {} failed to handle exception", sink.getClass().getSimpleName(), t);
}
}
@@ -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 {
enum Type {
SLASH, USER, MESSAGE
CHAT, USER, MESSAGE
}
boolean needAdmin();
@@ -5,7 +5,6 @@ package me.bobert.netneutrality.core.command;
* https://discord.gg/qUa6zNFZsd
*/
import net.dv8tion.jda.api.entities.channel.ChannelType;
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData;
import net.dv8tion.jda.api.interactions.commands.build.SubcommandData;
@@ -13,25 +12,47 @@ import net.dv8tion.jda.api.interactions.commands.build.SubcommandGroupData;
import net.dv8tion.jda.internal.interactions.CommandDataImpl;
import java.util.List;
import java.util.Set;
import java.util.StringJoiner;
public class CommandContainer {
public static final CommandContainer NULL_COMMAND = new CommandContainer(null);
private static final CommandUsageFormatter USAGE_FORMATTER = new CommandUsageFormatter();
private final Command command;
private CommandDataImpl commandData;
private List<OptionData> options;
private List<SubcommandData> subcommands;
private List<SubcommandGroupData> subcommandGroups;
private final CommandDataImpl commandData;
private final List<OptionData> options;
private final List<SubcommandData> subcommands;
private final List<SubcommandGroupData> subcommandGroups;
public CommandContainer(Command command) {
this.command = command;
if (command != null) {
CommandOptionsBuilder commandOptionsBuilder = new CommandOptionsBuilder(command.getType(), command);
command.registerCommandOptions(commandOptionsBuilder);
initContainer(commandOptionsBuilder);
this(command, build(command));
}
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() {
@@ -54,129 +75,11 @@ public class CommandContainer {
return subcommandGroups;
}
private void initContainer(CommandOptionsBuilder builder) {
this.commandData = builder.build();
this.options = commandData.getOptions();
this.subcommands = commandData.getSubcommands();
this.subcommandGroups = commandData.getSubcommandGroups();
}
public boolean isEmpty() {
return options.isEmpty() && subcommands.isEmpty() && subcommandGroups.isEmpty();
}
public String printUsage() {
StringBuilder usage = new StringBuilder();
if (!options.isEmpty()) {
usage.append("- Usage: /").append(command.getCommandName());
for (OptionData option : options) {
usage.append(" <").append(option.getName()).append(">");
}
usage.append(printOptions(options, null, false));
}
if (!subcommands.isEmpty()) {
for (SubcommandData subcommand : subcommands) {
usage.append("- Usage: /").append(command.getCommandName()).append(" ").append(subcommand.getName());
for (OptionData option : subcommand.getOptions()) {
usage.append(" <").append(option.getName()).append(">");
}
usage.append(" (").append(subcommand.getDescription()).append(")");
usage.append(printOptions(subcommand.getOptions(), null, false));
usage.append("\n");
}
}
if (!subcommandGroups.isEmpty()) {
for (SubcommandGroupData subcommandGroup : subcommandGroups) {
usage.append("- Subcommand: /").append(command.getCommandName())
.append(" ").append(subcommandGroup.getName())
.append(" (").append(subcommandGroup.getDescription()).append(")")
.append("\n");
for (SubcommandData subcommand : subcommandGroup.getSubcommands()) {
usage.append(" - Usage: /").append(command.getCommandName())
.append(" ").append(subcommandGroup.getName())
.append(" ").append(subcommand.getName());
for (OptionData option : subcommand.getOptions()) {
usage.append(" <").append(option.getName()).append(">");
}
usage.append(printOptions(subcommand.getOptions(), subcommand.getDescription(), true));
usage.append("\n");
}
}
}
if (!isEmpty()) {
usage.append("\n\n-# *: required argument");
}
return usage.toString();
}
private String printOptions(List<OptionData> options, String description, boolean group) {
StringBuilder usage = new StringBuilder();
if (description != null) {
usage.append(" (").append(description).append(")");
}
for (OptionData option : options) {
usage.append("\n");
if (group) {
usage.append(" ");
}
usage.append(" - ");
if (option.isRequired()) {
usage.append("*");
}
usage.append(option.getName()).append(": ").append(option.getDescription());
if (option.getMinValue() != null) {
usage.append(" [Min value: ").append(option.getMinValue().longValue()).append("]");
}
if (option.getMaxValue() != null) {
usage.append(" [Max value: ").append(option.getMaxValue().longValue()).append("]");
}
if (option.getMinLength() != null) {
usage.append(" [Min length: ").append(option.getMinLength().longValue()).append("]");
}
if (option.getMaxLength() != null) {
usage.append(" [Max length: ").append(option.getMaxLength().longValue()).append("]");
}
List<net.dv8tion.jda.api.interactions.commands.Command.Choice> choices = option.getChoices();
if (!choices.isEmpty()) {
StringJoiner joiner = new StringJoiner(", ");
for (net.dv8tion.jda.api.interactions.commands.Command.Choice choice : choices) {
joiner.add(choice.getAsString());
}
usage.append(" [Available choices: ").append(joiner).append("]");
}
Set<ChannelType> requiredChannelTypes = option.getChannelTypes();
if (!requiredChannelTypes.isEmpty()) {
StringJoiner joiner = new StringJoiner(", ");
for (ChannelType type : requiredChannelTypes) {
joiner.add(type.name());
}
usage.append(" [Required channel types: ").append(joiner).append("]");
}
}
return usage.toString();
return USAGE_FORMATTER.format(this);
}
}
@@ -6,6 +6,7 @@ package me.bobert.netneutrality.core.command;
*/
import me.bobert.netneutrality.core.tasks.Task;
import me.bobert.netneutrality.music.QueueActionResult;
public class CommandExecutionResult {
@@ -80,4 +81,18 @@ public class CommandExecutionResult {
public static CommandExecutionResult task(Task task) {
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() {
logger.info("Loading commands..");
initCommands();
ReflectionHelper.loadCommands(Command.class, context, this::onCommandLoaded);
logger.info("Loaded {} commands!", commands.size());
}
@@ -84,8 +84,7 @@ public class CommandManager implements CommandRegistry {
return commands;
}
public void initCommands() {
Consumer<Command> onLoaded = command -> {
void onCommandLoaded(Command command) {
if (!command.isEnabled()) {
logger.info("Skipping command {} as it is disabled", command.getClass().getName());
return;
@@ -96,7 +95,7 @@ public class CommandManager implements CommandRegistry {
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());
return;
}
@@ -105,9 +104,6 @@ public class CommandManager implements CommandRegistry {
if (!commands.containsKey(command.getCommandName())) {
commands.put(command.getCommandName(), container);
}
};
ReflectionHelper.loadCommands(Command.class, context, onLoaded);
}
@Override
@@ -31,7 +31,7 @@ public class CommandOptionsBuilder implements SlashCommandData {
public CommandOptionsBuilder(Command.Type type, Command command) {
if (type == Command.Type.SLASH) {
if (type == Command.Type.CHAT) {
commandData = (CommandDataImpl) Commands.slash(command.getCommandName(), command.getDescription());
}
@@ -8,6 +8,7 @@ package me.bobert.netneutrality.core.command;
import io.sentry.Sentry;
import me.bobert.netneutrality.core.LogHandler;
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.PermissionPrecondition;
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.GracefulContextError;
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 org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
@@ -52,6 +53,7 @@ public class CommandProcessor {
private static List<CommandPrecondition> defaultPreconditions() {
return List.of(
new GuildOnlyPrecondition(),
new DisabledCommandPrecondition(),
new SlashCommandOnlyPrecondition(),
new PermissionPrecondition()
);
@@ -81,7 +83,7 @@ public class CommandProcessor {
logger.info("Executing command {} on guild {}", command.getCommandName(), context.getGuild() == null ? "PrivateMessage" : context.getGuild().getId());
configureSentry(command);
if (command.getType() == Command.Type.SLASH && !usageValidator.validate(context, container)) {
if (command.getType() == Command.Type.CHAT && !usageValidator.validate(context, container)) {
return;
}
@@ -0,0 +1,151 @@
package me.bobert.netneutrality.core.command;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import net.dv8tion.jda.api.entities.channel.ChannelType;
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
import net.dv8tion.jda.api.interactions.commands.build.SubcommandData;
import net.dv8tion.jda.api.interactions.commands.build.SubcommandGroupData;
import java.util.List;
import java.util.Set;
import java.util.StringJoiner;
final class CommandUsageFormatter {
private static final String REQUIRED_LEGEND = "\n\n-# *: required argument";
String format(CommandContainer container) {
if (container.isEmpty()) {
return "";
}
String commandName = container.getCommand().getCommandName();
StringBuilder usage = new StringBuilder();
appendOptions(usage, commandName, container.getOptions());
appendSubcommands(usage, commandName, container.getSubcommands());
appendGroups(usage, commandName, container.getSubcommandGroups());
usage.append(REQUIRED_LEGEND);
return usage.toString();
}
private void appendOptions(StringBuilder usage, String commandName, List<OptionData> options) {
if (options.isEmpty()) {
return;
}
usage.append("- Usage: /").append(commandName);
appendArgs(usage, options);
usage.append(describeOptions(options, null, false));
}
private void appendSubcommands(StringBuilder usage, String commandName, List<SubcommandData> subcommands) {
for (SubcommandData subcommand : subcommands) {
usage.append("- Usage: /").append(commandName).append(" ").append(subcommand.getName());
appendArgs(usage, subcommand.getOptions());
usage.append(" (").append(subcommand.getDescription()).append(")");
usage.append(describeOptions(subcommand.getOptions(), null, false));
usage.append("\n");
}
}
private void appendGroups(StringBuilder usage, String commandName, List<SubcommandGroupData> groups) {
for (SubcommandGroupData group : groups) {
usage.append("- Subcommand: /").append(commandName)
.append(" ").append(group.getName())
.append(" (").append(group.getDescription()).append(")")
.append("\n");
for (SubcommandData subcommand : group.getSubcommands()) {
usage.append(" - Usage: /").append(commandName)
.append(" ").append(group.getName())
.append(" ").append(subcommand.getName());
appendArgs(usage, subcommand.getOptions());
usage.append(describeOptions(subcommand.getOptions(), subcommand.getDescription(), true));
usage.append("\n");
}
}
}
private void appendArgs(StringBuilder usage, List<OptionData> options) {
for (OptionData option : options) {
usage.append(" <").append(option.getName()).append(">");
}
}
private String describeOptions(List<OptionData> options, String description, boolean group) {
StringBuilder usage = new StringBuilder();
if (description != null) {
usage.append(" (").append(description).append(")");
}
for (OptionData option : options) {
usage.append("\n");
if (group) {
usage.append(" ");
}
usage.append(" - ");
if (option.isRequired()) {
usage.append("*");
}
usage.append(option.getName()).append(": ").append(option.getDescription());
appendConstraints(usage, option);
appendChoices(usage, option);
appendChannelTypes(usage, option);
}
return usage.toString();
}
private void appendConstraints(StringBuilder usage, OptionData option) {
if (option.getMinValue() != null) {
usage.append(" [Min value: ").append(option.getMinValue().longValue()).append("]");
}
if (option.getMaxValue() != null) {
usage.append(" [Max value: ").append(option.getMaxValue().longValue()).append("]");
}
if (option.getMinLength() != null) {
usage.append(" [Min length: ").append(option.getMinLength().longValue()).append("]");
}
if (option.getMaxLength() != null) {
usage.append(" [Max length: ").append(option.getMaxLength().longValue()).append("]");
}
}
private void appendChoices(StringBuilder usage, OptionData option) {
List<net.dv8tion.jda.api.interactions.commands.Command.Choice> choices = option.getChoices();
if (choices.isEmpty()) {
return;
}
StringJoiner joiner = new StringJoiner(", ");
for (net.dv8tion.jda.api.interactions.commands.Command.Choice choice : choices) {
joiner.add(choice.getAsString());
}
usage.append(" [Available choices: ").append(joiner).append("]");
}
private void appendChannelTypes(StringBuilder usage, OptionData option) {
Set<ChannelType> types = option.getChannelTypes();
if (types.isEmpty()) {
return;
}
StringJoiner joiner = new StringJoiner(", ");
for (ChannelType type : types) {
joiner.add(type.name());
}
usage.append(" [Required channel types: ").append(joiner).append("]");
}
}
@@ -34,7 +34,7 @@ public abstract class AbstractCommand implements Command {
@Override
public Type getType() {
return Type.SLASH;
return Type.CHAT;
}
@Override
@@ -9,10 +9,9 @@ import me.bobert.netneutrality.core.command.CommandCategory;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.db.GSettings;
import me.bobert.netneutrality.db.service.GuildService;
import me.bobert.netneutrality.shared.util.UserFunction;
import me.bobert.netneutrality.utils.MusicAccess;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.Role;
import net.dv8tion.jda.api.entities.channel.ChannelType;
import net.dv8tion.jda.api.entities.channel.middleman.GuildMessageChannel;
import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel;
@@ -43,7 +42,7 @@ public abstract class MusicCommand extends AbstractCommand {
* @return the guild service
*/
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;
}
Guild guild = 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;
return MusicAccess.canUse(member, guildService(member.getGuild()));
}
@Override
@@ -106,7 +86,7 @@ public abstract class MusicCommand extends AbstractCommand {
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()) {
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.context.CommandContext;
import me.bobert.netneutrality.shared.util.UserFunction;
import me.bobert.netneutrality.utils.UserFunction;
public class PermissionPrecondition implements CommandPrecondition {
@Override
public boolean check(CommandContext context, Command command) {
if (command.getType() != Command.Type.SLASH) {
if (command.getType() != Command.Type.CHAT) {
return true;
}
@@ -21,19 +21,21 @@ public class PermissionPrecondition implements CommandPrecondition {
return true;
}
if (command.canBeExecutedFrom(context.getChannel())) {
return true;
}
if (command.canBeExecutedBy(context.getGuildUser())) {
return true;
}
if (command.needAdmin() && UserFunction.isAdmin(context.getGuildUser())) {
return true;
}
context.reply("You are not powerful enough, scrub");
if (!command.canBeExecutedFrom(context.getChannel())) {
context.replyEphemeral("This command cannot be executed in the current channel");
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
*/
import me.bobert.netneutrality.shared.util.ContextUtils;
import me.bobert.netneutrality.utils.ContextUtils;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.components.MessageTopLevelComponent;
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();
Runnable runnable = () -> {
logger.info("Executing scheduled task: {}", taskId);
logger.debug("Executing scheduled task: {}", taskId);
try {
task.run();
@@ -5,6 +5,7 @@ package me.bobert.netneutrality.core.tasks;
* https://discord.gg/qUa6zNFZsd
*/
import com.fasterxml.jackson.databind.node.ObjectNode;
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.
*/
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 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.WithChildren;
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.exceptions.GracefulContextError;
import me.bobert.netneutrality.exceptions.TaskExecutionException;
@@ -23,13 +25,13 @@ import net.dv8tion.jda.api.interactions.InteractionHook;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Consumer;
import java.util.stream.Collectors;
@@ -41,25 +43,28 @@ public class TaskManagerImpl implements TaskManager {
private static final Logger logger = LogManager.getLogger(TaskManagerImpl.class);
private final TaskService taskService;
private final Map<String, Task> tasks;
private final List<Task> pendingTasks;
private final Map<String, Task> pendingTasks;
private final Map<String, CommandContext> deletableTasksContext;
private final Map<String, ReentrantLock> tasksLock;
private final ApplicationContext context;
private final PersistableTaskRegistry persistableTaskRegistry;
/**
* Creates a task manager that can inject context-registered dependencies into reflectively
* loaded tasks.
*
* @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");
this.tasks = new ConcurrentHashMap<>();
this.pendingTasks = new CopyOnWriteArrayList<>();
this.pendingTasks = new ConcurrentHashMap<>();
this.deletableTasksContext = new ConcurrentHashMap<>();
this.tasksLock = new ConcurrentHashMap<>();
this.taskService = new TaskService(this);
this.context = context;
this.persistableTaskRegistry = new PersistableTaskRegistry(context);
this.taskService = new TaskService(this, persistableTaskRegistry, taskRepository);
}
@Override
@@ -76,6 +81,7 @@ public class TaskManagerImpl implements TaskManager {
logger.info("Initializing task manager...");
loadBuiltInTasks();
persistableTaskRegistry.scan();
taskService.loadTasks();
}
@@ -86,7 +92,14 @@ public class TaskManagerImpl implements TaskManager {
@Override
public Task getTask(String id) {
return tasks.get(id);
if (id == null) {
return null;
}
Task task = tasks.get(id);
if (task != null) {
return task;
}
return pendingTasks.get(id);
}
/**
@@ -96,12 +109,17 @@ public class TaskManagerImpl implements TaskManager {
*/
@Override
public boolean hasTask(String id) {
return tasks.containsKey(id) || pendingTasks.stream().anyMatch(task -> task.getId().equals(id));
if (id == null) {
return false;
}
return tasks.containsKey(id) || pendingTasks.containsKey(id);
}
@Override
public Collection<Task> getTasks() {
return tasks.values();
List<Task> allTasks = new ArrayList<>(tasks.values());
allTasks.addAll(pendingTasks.values());
return allTasks;
}
@Override
@@ -123,7 +141,7 @@ public class TaskManagerImpl implements TaskManager {
}
if (task instanceof StepTask) {
pendingTasks.add(task);
pendingTasks.put(id, task);
} else {
tasks.put(id, task);
}
@@ -136,7 +154,7 @@ public class TaskManagerImpl implements TaskManager {
if (task instanceof WithChildren taskWithChildren) {
List<Task> childrens = taskWithChildren.getChildTasks();
if (childrens == null || childrens.isEmpty()) {
logger.warn("Parent task '{}' doesn't create any childs", task.getName());
logger.warn("Parent task '{}' doesn't create any children", task.getName());
return;
}
@@ -148,23 +166,37 @@ public class TaskManagerImpl implements TaskManager {
@Override
public void activateTask(Task task, String oldId) {
if (pendingTasks.remove(task)) {
if (task == null) {
return;
}
boolean removed = oldId != null && pendingTasks.remove(oldId) != null;
if (!removed) {
removed = pendingTasks.remove(task.getId()) != null;
}
if (!removed) {
pendingTasks.values().remove(task);
}
tasks.put(task.getId(), task);
logger.info("Updating task {} with new id: {} (was: {})",
task.getName(), task.getId(), oldId);
}
}
@Override
public void removeTask(Task task) {
if (task == null) {
return;
}
String id = task.getId();
if (tasks.remove(id) != null) {
boolean removed = tasks.remove(id) != null;
removed |= pendingTasks.remove(id) != null;
removed |= pendingTasks.values().remove(task);
if (removed) {
logger.debug("Removing task: {}", id);
ReentrantLock lock = tasksLock.remove(task.getLockId());
if (lock != null) {
logger.debug("Cleaning up lock for removed task: {}", task.getId());
if (task instanceof PersistableTask persistableTask) {
taskService.unpersistTask(persistableTask);
}
}
}
@@ -186,11 +218,11 @@ public class TaskManagerImpl implements TaskManager {
try {
if (lock != null) {
logger.debug("Aquiring lock for task: {}", taskId);
logger.debug("Acquiring lock for task: {}", taskId);
lock.lock();
if (!tasks.containsKey(taskId)) {
logger.debug("Task {} is no longer available after aquiring lock", taskId);
logger.debug("Task {} is no longer available after acquiring lock", taskId);
return;
}
}
@@ -224,16 +256,25 @@ public class TaskManagerImpl implements TaskManager {
@Override
public <E extends Task> Optional<E> findTaskByPartialId(Class<E> clazz, String partialId) {
List<E> tasks = findTasksByPartialId(clazz, partialId);
return Optional.ofNullable(tasks.isEmpty() ? null : tasks.getFirst());
if (clazz == null || partialId == null) {
return Optional.empty();
}
return tasks.values().stream()
.filter(clazz::isInstance)
.map(clazz::cast)
.filter(task -> task.getId() != null && task.getId().contains(partialId))
.findFirst();
}
@Override
public <E extends Task> List<E> findTasksByPartialId(Class<E> clazz, String partialId) {
if (clazz == null || partialId == null) {
return List.of();
}
return tasks.values().stream()
.filter(clazz::isInstance)
.map(clazz::cast)
.filter(task -> task.getId().contains(partialId))
.filter(task -> task.getId() != null && task.getId().contains(partialId))
.collect(Collectors.toList());
}
@@ -272,6 +313,9 @@ public class TaskManagerImpl implements TaskManager {
private void deleteInteraction(Task task) {
if (task == null) {
return;
}
if (task instanceof WithParent<?> taskWithParent) {
Task parent = taskWithParent.getParentTask();
@@ -289,18 +333,16 @@ public class TaskManagerImpl implements TaskManager {
private void deleteInteraction(String taskId, Deletable deletable) {
logger.debug("Deleting interaction: {}", taskId);
CommandContext context = deletableTasksContext.get(taskId);
if (context != null) {
deletableTasksContext.remove(taskId);
InteractionHook hook = context.getInteractionHook();
CommandContext commandContext = deletableTasksContext.remove(taskId);
if (commandContext != null) {
InteractionHook hook = commandContext.getInteractionHook();
if (hook != null) {
if (hook.isExpired()) {
context.replyEphemeral("This interaction has expired, can't remove it");
}
if (!hook.isExpired()) {
hook.deleteOriginal().queue();
commandContext.replyEphemeral("This interaction has expired, can't remove it");
} else {
hook.deleteOriginal().queue(null, error ->
logger.warn("Failed to delete interaction for task {}: {}", taskId, error.getMessage())
);
}
}
}
@@ -319,12 +361,32 @@ public class TaskManagerImpl implements TaskManager {
break;
case REMOVE_SELF_AND_CHILDREN:
if (task instanceof WithChildren taskWithChildren) {
for (Task child : taskWithChildren.getChildTasks()) {
if (task instanceof WithParent<?> taskWithParent) {
Task parent = taskWithParent.getParentTask();
if (parent != null) {
if (parent instanceof WithChildren parentWithChildren) {
List<Task> childTasks = parentWithChildren.getChildTasks();
if (childTasks != null) {
for (Task child : childTasks) {
removeTask(child);
}
}
}
removeTask(parent);
} else {
removeTask(task);
}
} else if (task instanceof WithChildren taskWithChildren) {
List<Task> childTasks = taskWithChildren.getChildTasks();
if (childTasks != null) {
for (Task child : childTasks) {
removeTask(child);
}
}
removeTask(task);
} else {
removeTask(task);
}
break;
case KEEP:
@@ -5,10 +5,11 @@ package me.bobert.netneutrality.core.tasks.impl;
* https://discord.gg/qUa6zNFZsd
*/
import com.fasterxml.jackson.databind.node.ObjectNode;
import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.core.tasks.PersistableTask;
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 {
@@ -60,9 +61,33 @@ public abstract class AbstractPersistableTask extends AbstractTask implements Pe
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
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();
}
@@ -5,6 +5,8 @@ package me.bobert.netneutrality.core.tasks.impl;
* 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.core.tasks.TaskManager;
@@ -13,9 +15,9 @@ import java.util.concurrent.ConcurrentHashMap;
public abstract class EmojiTask extends AbstractPersistableTask {
private final Map<String, String> emojiMapping = new ConcurrentHashMap<>();
private final String guildId;
private final String channelId;
private final String messageId;
private String guildId;
private String channelId;
private String messageId;
public EmojiTask(TaskManager taskManager, String guildId, String channelId, String messageId) {
super(taskManager);
@@ -24,6 +26,45 @@ public abstract class EmojiTask extends AbstractPersistableTask {
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() {
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;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.utils.SettingsProvider;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.flywaydb.core.Flyway;
import org.flywaydb.core.api.output.MigrateResult;
public class DatabaseMigration {
private final static Logger logger = LogManager.getLogger(DatabaseMigration.class);
public final class DatabaseMigration {
private static final Logger logger = LogManager.getLogger(DatabaseMigration.class);
public static void migrate(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 dbUrl = String.format("jdbc:mysql://%s:%s/%s", dbHost, dbPort, dbName);
private DatabaseMigration() {
}
/**
* 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 {
logger.info("Running database migrations..");
Flyway flyway = Flyway.configure()
.dataSource(dbUrl, dbUser, dbPass)
.dataSource(config.url(), config.user(), config.password())
.load();
MigrateResult result = flyway.migrate();
logger.info("Database migrations finished successfully! (total: {})", result.migrationsExecuted);
} catch (Exception e) {
logger.error(e);
System.exit(1);
throw new IllegalStateException("Database migration failed", e);
}
}
}
@@ -1,23 +1,64 @@
package me.bobert.netneutrality.db;
/*
* Copyright (c) anthorob
*
* 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 {
//TODO: description
MUSIC_ROLE("MUSIC_ROLE", ""),
MUSIC_CHANNEL("MUSIC_CHANNEL", ""),
MUSIC_VOLUME("MUSIC_VOLUME", "30"),
MUSIC_MAXPLAYLISTSIZE("MUSIC_MAXPLAYLISTSIZE", "20");
/** The role required to use music commands; empty means everyone may use them. */
MUSIC_ROLE("MUSIC_ROLE", "", "The role required to use music commands; empty means everyone may use them", SettingType.ROLE),
/** Id of the only text channel music commands may be used in; empty means any channel. */
MUSIC_CHANNEL("MUSIC_CHANNEL", "", "Id of the only text channel music commands may be used in; empty means any channel", SettingType.CHANNEL),
/** 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 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.defaultValue = defaultValue;
this.description = description;
this.type = type;
this.allowedValues = allowedValues;
}
public String getName() {
@@ -27,4 +68,16 @@ public enum GSettings {
public String getDefaultValue() {
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;
/*
* Copyright (c) anthorob
*
@@ -10,7 +11,6 @@ import me.bobert.netneutrality.utils.SettingsProvider;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.hibernate.SessionFactory;
import org.hibernate.boot.Metadata;
import org.hibernate.boot.MetadataSources;
import org.hibernate.boot.registry.StandardServiceRegistry;
import org.hibernate.boot.registry.StandardServiceRegistryBuilder;
@@ -18,24 +18,34 @@ import org.hibernate.cfg.Environment;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
/**
* Instance-based {@link SessionProvider} owning the Hibernate {@link SessionFactory} and its
* service registry.
*
* <p>On construction the instance is bound into {@link JpaUtils} so repositories and other static
* persistence helpers can open sessions without a global {@code SessionManager} singleton.
* <p>{@link #start()} first applies the Flyway migrations and only then builds the session factory,
* which validates the mapped entities against the migrated schema ({@code hbm2ddl.auto=validate}).
*/
public class SessionManager implements SessionProvider {
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 SessionFactory sessionFactory;
private volatile SessionFactory sessionFactory;
public SessionManager(SettingsProvider settings) {
this.settings = settings;
JpaUtils.bind(this);
this(DatabaseConfig.from(settings), !"dev".equals(settings.get("NETNEUTRALITY_BOT_ENV", "dev")));
}
/**
* @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
@@ -44,55 +54,58 @@ public class SessionManager implements SessionProvider {
}
@Override
public void start() {
getSessionFactory();
DatabaseMigration.migrate(settings);
public synchronized void start() {
if (sessionFactory != null) {
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
public void stop() {
public synchronized void stop() {
SessionFactory factory = sessionFactory;
sessionFactory = null;
if (factory != null) {
factory.close();
}
if (registry != null) {
StandardServiceRegistryBuilder.destroy(registry);
registry = null;
sessionFactory = null;
}
}
@Override
public SessionFactory getSessionFactory() {
if (sessionFactory == null) {
try {
StandardServiceRegistryBuilder registryBuilder = new StandardServiceRegistryBuilder().configure();
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);
SessionFactory factory = sessionFactory;
if (factory == null) {
throw new IllegalStateException("SessionManager has not been started");
}
stop();
logger.error("Error connecting to database. Shutting down..");
System.exit(1);
}
}
return sessionFactory;
return factory;
}
}
@@ -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.ManyToOne;
import jakarta.persistence.Table;
import org.hibernate.Hibernate;
import java.util.Objects;
@Entity
@Table(name = "blacklist")
@@ -76,4 +79,26 @@ public class OBlacklist {
public void setId(Integer 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.OneToMany;
import jakarta.persistence.Table;
import org.hibernate.Hibernate;
import java.util.LinkedHashSet;
import java.util.Optional;
import java.util.Objects;
import java.util.Set;
@Entity
@@ -33,6 +34,12 @@ public class OGuild {
@Column(name = "log_channel")
private String logChannel;
@Column(name = "guild_name")
private String guildName;
@Column(name = "guild_icon_hash")
private String guildIconHash;
@OneToMany(mappedBy = "guild")
private Set<OGuildSetting> guildSettings = new LinkedHashSet<>();
@@ -43,14 +50,14 @@ public class OGuild {
private Set<OLogMessage> logMessages = new LinkedHashSet<>();
@OneToMany(mappedBy = "guild")
private Set<OLogging> OLoggings = new LinkedHashSet<>();
private Set<OLogging> loggings = new LinkedHashSet<>();
public Set<OLogging> getLoggings() {
return OLoggings;
return loggings;
}
public void setLoggings(Set<OLogging> OLoggings) {
this.OLoggings = OLoggings;
public void setLoggings(Set<OLogging> loggings) {
this.loggings = loggings;
}
public Set<OLogMessage> getLogMessages() {
@@ -110,13 +117,41 @@ public class OGuild {
this.guildId = guildId;
}
public OGuildSetting getSettingByName(String name) {
Optional<OGuildSetting> setting = getGuildSettings()
.stream()
.filter(setting1 -> setting1.getName().equalsIgnoreCase(name))
.findFirst();
return setting.orElse(null);
public String getGuildName() {
return guildName;
}
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.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.Lob;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.NamedQueries;
import jakarta.persistence.NamedQuery;
import jakarta.persistence.Table;
import org.hibernate.Hibernate;
import java.util.Objects;
@Entity
@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 {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@@ -33,8 +30,7 @@ public class OGuildSetting {
@Column(name = "name", nullable = false)
private String name;
@Lob
@Column(name = "value", nullable = false)
@Column(name = "value", nullable = false, columnDefinition = "text")
private String value;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@@ -72,4 +68,26 @@ public class OGuildSetting {
public void setId(Integer 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();
}
}
@@ -16,8 +16,10 @@ import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import org.hibernate.Hibernate;
import java.time.Instant;
import java.util.Objects;
@Entity
@Table(name = "log_message")
@@ -113,4 +115,26 @@ public class OLogMessage {
public void setId(Integer 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;
}
OLogMessage other = (OLogMessage) 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();
}
}
@@ -14,6 +14,9 @@ import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.MapsId;
import jakarta.persistence.Table;
import org.hibernate.Hibernate;
import java.util.Objects;
@Entity
@Table(name = "logging")
@@ -52,4 +55,26 @@ public class OLogging {
public void setId(OLoggingId 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;
}
OLogging other = (OLogging) 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();
}
}
@@ -10,9 +10,11 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import org.hibernate.Hibernate;
import java.util.Objects;
@Entity
@Table(name = "tasks", uniqueConstraints = {@UniqueConstraint(columnNames = {"task_id"})})
@@ -25,11 +27,13 @@ public class OTask {
@Column(name = "task_id", nullable = false)
private String taskId;
/**
* Stable logical task type id ({@code PersistableTask#typeId()}), not a class name.
*/
@Column(name = "task_class", nullable = false)
private String taskClass;
@Lob
@Column(name = "payload")
@Column(name = "payload", columnDefinition = "text")
private String payload;
public Integer getId() {
@@ -64,4 +68,25 @@ public class OTask {
this.payload = payload;
}
/**
* 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;
}
OTask other = (OTask) 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();
}
}
@@ -9,11 +9,12 @@ package me.bobert.netneutrality.db.entities;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
import org.hibernate.annotations.ColumnDefault;
import org.hibernate.Hibernate;
import java.time.Instant;
import java.util.Objects;
@Entity
@Table(name = "web_users")
@@ -22,8 +23,7 @@ public class OWebUser {
@Column(name = "id", nullable = false)
private String id;
@Lob
@Column(name = "email", nullable = false)
@Column(name = "email", nullable = false, columnDefinition = "mediumtext")
private String email;
@Column(name = "token", nullable = false)
@@ -87,4 +87,25 @@ public class OWebUser {
this.rememberToken = rememberToken;
}
/**
* 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;
}
OWebUser other = (OWebUser) 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();
}
}
@@ -1,104 +1,143 @@
package me.bobert.netneutrality.db.repository;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.db.Repository;
import me.bobert.netneutrality.db.SessionProvider;
import me.bobert.netneutrality.db.TransactionRunner;
import me.bobert.netneutrality.db.data.BlacklistQueries_;
import me.bobert.netneutrality.db.entities.OBlacklist;
import me.bobert.netneutrality.db.entities.OGuild;
import me.bobert.netneutrality.db.JpaUtils;
import net.dv8tion.jda.api.entities.Member;
import org.hibernate.Session;
import org.hibernate.query.Query;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
public class BlacklistRepository implements Repository<OBlacklist> {
public class BlacklistRepository {
@Override
public List<OBlacklist> findAll() {
List<OBlacklist> blacklists = new ArrayList<>();
JpaUtils.runWithSession(session -> {
Query<OBlacklist> query = session.createQuery("FROM OBlacklist", OBlacklist.class);
blacklists.addAll(query.list());
});
return blacklists;
/**
* Outcome of {@link #enableWord(String, String, String)}.
*/
public enum EnableOutcome {
/** A new row was inserted. */
CREATED,
/** An existing, disabled row was re-enabled. */
ENABLED,
/** The word was already enabled. */
UNCHANGED
}
@Override
public OBlacklist findById(String id) {
return null;
}
private final TransactionRunner tx;
@Override
public void deleteBy(String id) {
JpaUtils.runWithTransaction(session -> {
Query<OBlacklist> query = session.createQuery("SELECT o FROM OBlacklist o WHERE o.id = :blacklistId", OBlacklist.class);
query.setParameter("blacklistId", id);
OBlacklist oTask = query.uniqueResult();
if (oTask != null) {
session.remove(oTask);
}
});
public BlacklistRepository(SessionProvider sessions) {
this.tx = new TransactionRunner(sessions);
}
public List<OBlacklist> findByGuildId(String guildId) {
List<OBlacklist> blacklists = new ArrayList<>();
JpaUtils.runWithSession(session -> {
Query<OBlacklist> query = session.createQuery("SELECT o FROM OBlacklist o WHERE o.guild.guildId = :guildId", OBlacklist.class);
query.setParameter("guildId", guildId);
blacklists.addAll(query.list());
});
return blacklists;
return tx.read(session -> new BlacklistQueries_(session).byGuild(guildId));
}
public void add(OGuild oGuild, Member member, String word) {
JpaUtils.runWithTransaction(session -> {
OBlacklist bl = getWordFromDb(oGuild.getGuildId(), word);
if (bl == null) {
bl = new OBlacklist();
bl.setGuild(oGuild);
bl.setCreatorId(member.getId());
bl.setWord(word);
bl.setEnabled(true);
session.persist(bl);
} else {
bl.setEnabled(true);
session.merge(bl);
public Set<String> findEnabledWords(String guildId) {
return tx.read(session -> Set.copyOf(new BlacklistQueries_(session).enabledWords(guildId)));
}
/**
* Adds the word to the guild's blacklist, or re-enables it if it already exists. Lookup and
* write happen in the same transaction.
*
* @param guildId the guild id (the guild row must exist)
* @param creatorId the user adding the word
* @param word the word
* @return what changed
*/
public EnableOutcome enableWord(String guildId, String creatorId, String word) {
return tx.inTx(session -> {
BlacklistQueries_ queries = new BlacklistQueries_(session);
List<OBlacklist> existing = queries.findWord(guildId, word);
if (existing.isEmpty()) {
OGuild guild = new OGuild();
guild.setGuildId(guildId);
OBlacklist blacklist = new OBlacklist();
blacklist.setGuild(guild);
blacklist.setCreatorId(creatorId);
blacklist.setWord(word);
blacklist.setEnabled(true);
queries.insert(blacklist);
return EnableOutcome.CREATED;
}
List<OBlacklist> changed = new ArrayList<>();
for (OBlacklist blacklist : existing) {
if (!Boolean.TRUE.equals(blacklist.getEnabled())) {
blacklist.setEnabled(true);
changed.add(blacklist);
}
}
if (changed.isEmpty()) {
return EnableOutcome.UNCHANGED;
}
queries.updateAll(changed);
return EnableOutcome.ENABLED;
});
}
public void delete(OGuild oGuild, String word) {
JpaUtils.runWithTransaction(session -> {
OBlacklist bl = getWordFromDb(oGuild.getGuildId(), word);
if (bl != null) {
session.remove(bl);
}
/**
* Deletes the word (case-insensitive) from the guild's blacklist.
*
* @param guildId the guild id
* @param word the word
* @return {@code true} if at least one row was deleted
*/
public boolean deleteWord(String guildId, String word) {
return tx.inTx(session -> {
BlacklistQueries_ queries = new BlacklistQueries_(session);
List<OBlacklist> existing = queries.findWord(guildId, word);
queries.deleteAll(existing);
return !existing.isEmpty();
});
}
private OBlacklist getWordFromDb(String guildId, String word) {
try (Session session = JpaUtils.openSession()) {
Query<OBlacklist> query = session.createQuery("SELECT o FROM OBlacklist o WHERE upper(o.word) = upper(:word) AND o.guild.guildId = :guildId", OBlacklist.class);
query.setParameter("guildId", guildId);
query.setParameter("word", word);
OBlacklist blacklist = query.uniqueResult();
session.close();
return blacklist;
public List<OBlacklist> findAll() {
return tx.read(session -> new BlacklistQueries_(session).findAll().toList());
}
public Optional<OBlacklist> findById(Integer id) {
Objects.requireNonNull(id, "id");
return tx.read(session -> new BlacklistQueries_(session).findById(id));
}
/**
* Inserts or updates the entity.
*
* @param entity the entity to save
* @return the saved state, which callers should use from then on
*/
public OBlacklist save(OBlacklist entity) {
Objects.requireNonNull(entity, "entity");
return tx.inTx(session -> new BlacklistQueries_(session).save(entity));
}
/**
* Deletes the entity with the given id, if it exists.
*
* @param id the identifier
* @return {@code true} if a row was deleted
*/
public boolean deleteById(Integer id) {
Objects.requireNonNull(id, "id");
return tx.inTx(session -> {
BlacklistQueries_ queries = new BlacklistQueries_(session);
return queries.findById(id)
.map(entity -> {
queries.delete(entity);
return true;
})
.orElse(false);
});
}
}
@@ -1,8 +1,4 @@
package me.bobert.netneutrality.db.repository;/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
package me.bobert.netneutrality.db.repository;
/*
* Copyright (c) anthorob
@@ -10,49 +6,76 @@ package me.bobert.netneutrality.db.repository;/*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.db.Repository;
import me.bobert.netneutrality.db.SessionProvider;
import me.bobert.netneutrality.db.TransactionRunner;
import me.bobert.netneutrality.db.data.GuildQueries_;
import me.bobert.netneutrality.db.entities.OGuild;
import me.bobert.netneutrality.db.JpaUtils;
import org.hibernate.Session;
import org.hibernate.query.Query;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
public class GuildRepository implements Repository<OGuild> {
@Override
public List<OGuild> findAll() {
List<OGuild> guilds = new ArrayList<>();
public class GuildRepository {
JpaUtils.runWithSession(session -> {
Query<OGuild> query = session.createQuery("FROM OGuild", OGuild.class);
private final TransactionRunner tx;
guilds.addAll(query.list());
public GuildRepository(SessionProvider sessions) {
this.tx = new TransactionRunner(sessions);
}
/**
* Returns the guild row, inserting it first if it does not exist yet.
*
* @param guildId the Discord guild id
* @return the persistent guild
*/
public OGuild findOrCreate(String guildId) {
return tx.inTx(session -> {
GuildQueries_ queries = new GuildQueries_(session);
return queries.findById(guildId).orElseGet(() -> {
OGuild guild = new OGuild();
guild.setGuildId(guildId);
return queries.insert(guild);
});
});
return guilds;
}
@Override
public OGuild findById(String id) {
try (Session session = JpaUtils.openSession()) {
Query<OGuild> query = session.createQuery("SELECT o FROM OGuild o WHERE o.guildId = :guildId", OGuild.class);
query.setParameter("guildId", id);
return query.uniqueResult();
}
public List<OGuild> findAll() {
return tx.read(session -> new GuildQueries_(session).findAll().toList());
}
@Override
public void deleteBy(String id) {
JpaUtils.runWithTransaction(session -> {
Query<OGuild> query = session.createQuery("SELECT o FROM OGuild o WHERE o.guildId = :guildId", OGuild.class);
query.setParameter("guildId", id);
OGuild oGuild = query.uniqueResult();
if (oGuild != null) {
session.remove(oGuild);
public Optional<OGuild> findById(String id) {
Objects.requireNonNull(id, "id");
return tx.read(session -> new GuildQueries_(session).findById(id));
}
/**
* Inserts or updates the entity.
*
* @param entity the entity to save
* @return the saved state, which callers should use from then on
*/
public OGuild save(OGuild entity) {
Objects.requireNonNull(entity, "entity");
return tx.inTx(session -> new GuildQueries_(session).save(entity));
}
/**
* Deletes the entity with the given id, if it exists.
*
* @param id the identifier
* @return {@code true} if a row was deleted
*/
public boolean deleteById(String id) {
Objects.requireNonNull(id, "id");
return tx.inTx(session -> {
GuildQueries_ queries = new GuildQueries_(session);
return queries.findById(id)
.map(entity -> {
queries.delete(entity);
return true;
})
.orElse(false);
});
}
}
Loaded 100 of 280 files, more files were not shown because too many files have changed in this diff. Show more