Compare commits

260 Commits
Author SHA1 Message Date
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
anthorob a507b471b4 cleanup 2026-09-10 08:32:16 -04:00
anthorob 868dbea8df Refactor CommandProcessor to enhance modularity and extensibility
Decomposed the monolithic `dispatch()` method into modular components using `CommandPrecondition`, `ResultHandler`, and `UsageValidator`. Replaced inline checks and tightly-coupled logic with composable, testable classes, eliminating `instanceof` dependencies. Preserved existing behavior and improved maintainability for future feature additions.
2026-09-10 08:24:21 -04:00
anthorob dd1c4dd42f Refactor thread management for improved safety and performance
Replaced unbounded thread pools in `ThreadManager` with bounded `ThreadPoolExecutor` to prevent memory exhaustion under load. Refactored shutdown logic to handle exceptions per-executor, set threads to daemon mode in `BaseThreadFactory`, and aligned synchronization patterns across multiple classes (`TaskManagerImpl`, `ChannelMessagePurger`, and `MusicManager`). Addressed lock cleanup races, double unlock issues, and static collection thread-safety concerns.
2026-09-10 07:57:49 -04:00
anthorob 64f4890931 Refactor settings architecture and remove singleton pattern
Reworked `Settings` class to replace the static singleton pattern with constructor injection of `SettingsProvider`, improving testability and modularity. Updated all affected classes, tests, and configurations to align with the new architecture.
2026-09-10 05:59:55 -04:00
anthorob 1d737a2a4f Migrate scheduled tasks to a new package and architecture
Refactored scheduled tasks and moved them to a dedicated `scheduled` package. Replaced the legacy `ScheduledTaskManager` and `ScheduledTask` definitions with a redesigned architecture to improve maintainability. Updated related classes and configurations to adopt the new structure.
2026-09-09 15:10:33 -04:00
anthorob 1b981abdf7 Add unit tests and refactor music service classes 2026-09-06 16:21:45 -04:00
anthorob b8786c8dc9 Remove deprecated classes and static singleton-based architecture 2026-09-06 07:38:02 -04:00
anthorob 54a27883d1 Merge pull request 'Update ghcr.io/kikkia/yt-cipher:master Docker digest to 4c5ec38' (#3) from renovate/ghcr.io-kikkia-yt-cipher-master into main
CI / Package (push) Successful in 45s
CI / Docker Build and Push (main) (push) Successful in 19s
CI / Docker Build and Push (release) (push) Has been skipped
Reviewed-on: #3
2026-08-24 17:27:27 -04:00
anthorob 196f0be044 Merge pull request 'Update dependency dev.lavalink.youtube:v2 to v1.18.2' (#27) from renovate/dev.lavalink.youtube-v2-1.x into main
CI / Package (push) Successful in 42s
CI / Docker Build and Push (main) (push) Successful in 19s
CI / Docker Build and Push (release) (push) Has been skipped
Reviewed-on: #27
2026-08-24 17:26:03 -04:00
renovate-bot 571713a9ec Update dependency dev.lavalink.youtube:v2 to v1.18.2
CI / Package (pull_request) Successful in 1m0s
CI / Docker Build and Push (main) (pull_request) Has been skipped
CI / Docker Build and Push (release) (pull_request) Has been skipped
2026-08-24 21:02:40 +00:00
anthorob aa587ddf3c expand GitHub Actions updates to include digest changes
CI / Package (push) Successful in 43s
CI / Docker Build and Push (main) (push) Successful in 19s
CI / Docker Build and Push (release) (push) Has been skipped
2026-08-24 16:49:38 -04:00
anthorob f8ebe28cb4 rename .gitea to .github for renovate integration
CI / Package (push) Successful in 44s
CI / Docker Build and Push (main) (push) Successful in 19s
CI / Docker Build and Push (release) (push) Has been skipped
2026-08-24 16:46:45 -04:00
anthorob 1544cda9d2 update Renovate config for Maven dependency grouping
CI / Package (push) Successful in 1m4s
CI / Docker Build and Push (main) (push) Successful in 3m16s
CI / Docker Build and Push (release) (push) Has been skipped
2026-08-24 16:39:18 -04:00
renovate-bot 703f503191 Update ghcr.io/kikkia/yt-cipher:master Docker digest to 4c5ec38
CI / Package (pull_request) Successful in 49s
CI / Docker Build and Push (main) (pull_request) Has been skipped
CI / Docker Build and Push (release) (pull_request) Has been skipped
2026-08-06 06:02:33 +00:00
anthorob 52bb9c026b group GitHub Actions updates by type
CI / Package (push) Successful in 42s
CI / Docker Build and Push (main) (push) Successful in 18s
CI / Docker Build and Push (release) (push) Has been skipped
2026-05-13 00:52:01 -04:00
anthorob ad77cf630e update Maven dependencies
CI / Package (push) Successful in 1m7s
CI / Docker Build and Push (main) (push) Successful in 41s
CI / Docker Build and Push (release) (push) Has been skipped
2026-05-12 23:37:18 -04:00
anthorob 62fc2097c2 enable Maven cache in CI workflow
CI / Package (push) Successful in 2m0s
CI / Docker Build and Push (main) (push) Successful in 2m18s
CI / Docker Build and Push (release) (push) Has been skipped
2026-04-01 11:45:00 -04:00
anthorob b96e5b2880 Merge pull request 'chore(deps): pin dependencies' (#12) from renovate/pin-dependencies into main
CI / Package (push) Successful in 1m3s
CI / Docker Build and Push (main) (push) Successful in 2m17s
CI / Docker Build and Push (release) (push) Has been skipped
Reviewed-on: #12
2026-03-18 07:02:27 -04:00
renovate-bot b3bd6a45fe chore(deps): pin dependencies
CI / Package (pull_request) Successful in 2m7s
CI / Docker Build and Push (main) (pull_request) Has been skipped
CI / Docker Build and Push (release) (pull_request) Has been skipped
2026-03-18 10:56:02 +00:00
anthorob 83e618c6a3 fix workflow
CI / Package (push) Successful in 1m1s
CI / Docker Build and Push (main) (push) Successful in 41s
CI / Docker Build and Push (release) (push) Has been skipped
2026-03-18 06:46:21 -04:00
anthorob 078c540327 fix workflow
CI / Package (push) Successful in 1m6s
CI / Docker Build and Push (main) (push) Failing after 1m54s
CI / Docker Build and Push (release) (push) Has been skipped
2026-03-18 06:42:10 -04:00
anthorob e021a713b2 fix workflow
CI / Package (push) Failing after 1m14s
CI / Docker Build and Push (main) (push) Has been skipped
CI / Docker Build and Push (release) (push) Has been skipped
2026-03-18 06:38:29 -04:00
anthorob 91aaafeb40 fix workflow
CI / Docker Build and Push (main) (push) Has been cancelled
CI / Docker Build and Push (release) (push) Has been cancelled
CI / Package (push) Has been cancelled
2026-03-18 06:25:16 -04:00
anthorob 99457b40fc fix maven cache in workflow
CI / Package (push) Has been cancelled
CI / Docker Build and Push (main) (push) Has been cancelled
CI / Docker Build and Push (release) (push) Has been cancelled
2026-03-18 06:23:33 -04:00
anthorob 78a359ba09 fix maven cache in workflow
CI / Test (push) Has been cancelled
CI / Package (push) Has been cancelled
CI / Docker Build and Push (main) (push) Has been cancelled
CI / Docker Build and Push (release) (push) Has been cancelled
CI / Compile (push) Has been cancelled
2026-03-18 06:02:44 -04:00
anthorob 2a4dc09a9e switch to gitea worflow
CI / Test (push) Has been cancelled
CI / Package (push) Has been cancelled
CI / Docker Build and Push (main) (push) Has been cancelled
CI / Docker Build and Push (release) (push) Has been cancelled
CI / Compile (push) Has been cancelled
2026-03-18 05:38:47 -04:00
anthorob c442619531 remove harcoded html tag, parses next chunks to find track 2026-03-15 01:41:18 -04:00
anthorob d6fa27923b add compose override to gitignore 2026-03-09 05:07:53 -04:00
anthorob ec8cccf5e5 Merge branch 'renovate/pin-dependencies' into 'main'
chore(deps): pin dependencies

See merge request anthorob/netneutrality!116
2026-03-09 07:51:00 +00:00
Renovate Bot 7ca6215f47 chore(deps): pin dependencies 2026-03-09 07:49:18 +00:00
anthorob 82f6ea4f4d Merge branch '15-job-failed-8956' into 'main'
Resolve "Job Failed #8956"

Closes #15

See merge request anthorob/netneutrality!118
2026-03-09 07:30:25 +00:00
anthorob 1a0434ffed Resolve "Job Failed #8956" 2026-03-09 07:30:25 +00:00
anthorob 25a5df0743 improve ci 2026-03-09 03:06:16 -04:00
anthorob d8bd5ecfdb switch to ubuntu for runtime 2026-03-04 00:14:20 -05:00
anthorob 8101a02807 Merge branch '14-move-to-java-25-and-use-jdave' into 'main'
move to jdk 25

Closes #14

See merge request anthorob/netneutrality!115
2026-03-04 04:57:53 +00:00
anthorob d3da68f4d4 move to jdk 25 2026-03-04 04:57:53 +00:00
anthorob 03a1920ccb Merge branch 'renovate/mariadb-11' into 'main'
chore(deps): update mariadb:11 docker digest to 1cac849

See merge request anthorob/netneutrality!104
2025-12-19 09:32:56 +00:00
Renovate 4d753f3ddf chore(deps): update mariadb:11 docker digest to 1cac849 2025-12-19 09:32:56 +00:00
anthorob 71563abaf2 Merge branch 'renovate/net.dv8tion-jda-6.x' into 'main'
fix(deps): update dependency net.dv8tion:jda to v6

See merge request anthorob/netneutrality!97
2025-12-19 09:02:07 +00:00
Renovate 2c96f8ce85 fix(deps): update dependency net.dv8tion:jda to v6 2025-12-19 09:02:07 +00:00
anthorob cecd7baf3f Merge branch 'renovate/major-hibernate-orm' into 'main'
fix(deps): update hibernate orm to v7 (major)

See merge request anthorob/netneutrality!99
2025-11-22 07:36:00 +00:00
Renovate e35f739cf9 fix(deps): update hibernate orm to v7 (major) 2025-11-22 07:36:00 +00:00
anthorob f32968c0b5 Merge branch 'renovate/org.apache.maven.plugins-maven-jar-plugin-3.x' into 'main'
chore(deps): update dependency org.apache.maven.plugins:maven-jar-plugin to v3.5.0

See merge request anthorob/netneutrality!105
2025-11-22 06:58:20 +00:00
anthorob c13b731cd5 Merge branch 'renovate/org.gitlab4j-gitlab4j-api-6.x' into 'main'
fix(deps): update dependency org.gitlab4j:gitlab4j-api to v6.2.0

See merge request anthorob/netneutrality!100
2025-11-22 06:37:35 +00:00
Renovate Bot 4be5be2a47 chore(deps): update dependency org.apache.maven.plugins:maven-jar-plugin to v3.5.0 2025-11-15 09:04:20 +00:00
anthorob 3d0c4a169e Merge branch 'renovate/dev.lavalink.youtube-v2-1.x' into 'main'
fix(deps): update dependency dev.lavalink.youtube:v2 to v1.16.0

See merge request anthorob/netneutrality!93
2025-11-14 17:45:54 +00:00
Renovate e8c331fa63 fix(deps): update dependency dev.lavalink.youtube:v2 to v1.16.0 2025-11-14 17:45:54 +00:00
anthorob 303dc594b7 Merge branch 'renovate/quay.io-buildah-stable' into 'main'
chore(deps): update quay.io/buildah/stable docker digest to dd77a04

See merge request anthorob/netneutrality!102
2025-11-14 17:41:42 +00:00
Renovate Bot 3d9c25e637 chore(deps): update quay.io/buildah/stable docker digest to dd77a04 2025-11-14 16:34:27 +00:00
anthorob a4eb81d7de Merge branch 'renovate/pin-dependencies' into 'main'
chore(deps): pin ghcr.io/kikkia/yt-cipher docker tag to 4fbb0d9

See merge request anthorob/netneutrality!86
2025-11-12 12:18:32 +00:00
anthorob 9c34f3a4f4 Merge branch 'renovate/mariadb-11' into 'main'
chore(deps): update mariadb:11 docker digest to 404ebf2

See merge request anthorob/netneutrality!95
2025-11-12 12:15:49 +00:00
anthorob 7ef68cf968 Merge branch 'renovate/quay.io-buildah-stable' into 'main'
chore(deps): update quay.io/buildah/stable docker digest to 428cc6a

See merge request anthorob/netneutrality!87
2025-11-12 12:15:32 +00:00
anthorob 0224ba67fe Merge branch 'renovate/org.json-json-20250517.x' into 'main'
fix(deps): update dependency org.json:json to v20250517

See merge request anthorob/netneutrality!98
2025-11-12 12:15:11 +00:00
Renovate Bot 1c5afca21c fix(deps): update dependency org.gitlab4j:gitlab4j-api to v6.2.0 2025-11-11 17:04:10 +00:00
Renovate Bot 8c0062f31f chore(deps): update quay.io/buildah/stable docker digest to 428cc6a 2025-11-11 16:34:08 +00:00
Renovate Bot 2027ae296c fix(deps): update dependency org.json:json to v20250517 2025-11-11 04:04:20 +00:00
anthorob 3c50feb597 Merge branch 'renovate/com.mysql-mysql-connector-j-9.x' into 'main'
fix(deps): update dependency com.mysql:mysql-connector-j to v9.5.0

See merge request anthorob/netneutrality!90
2025-11-11 02:21:54 +00:00
anthorob c3f5807cea Merge branch 'renovate/okhttp-monorepo' into 'main'
fix(deps): update dependency com.squareup.okhttp3:okhttp-jvm to v5.3.0

See merge request anthorob/netneutrality!91
2025-11-11 02:21:18 +00:00
anthorob 6e8da65580 Merge branch 'renovate/hibernate-orm' into 'main'
fix(deps): update hibernate orm to v6.6.34.final

See merge request anthorob/netneutrality!89
2025-11-11 02:20:31 +00:00
anthorob 13b2f61261 Merge branch 'renovate/apache-commons' into 'main'
fix(deps): update dependency commons-io:commons-io to v2.21.0

See merge request anthorob/netneutrality!92
2025-11-11 02:20:02 +00:00
anthorob 6271e218cd Merge branch 'renovate/io.sentry-sentry-8.x' into 'main'
fix(deps): update dependency io.sentry:sentry to v8.25.0

See merge request anthorob/netneutrality!94
2025-11-11 02:19:19 +00:00
Renovate Bot 6f4977b466 chore(deps): update mariadb:11 docker digest to 404ebf2 2025-11-11 02:04:33 +00:00
anthorob f691fcd2ee Merge branch 'renovate/org.junit.jupiter-junit-jupiter-6.x' into 'main'
chore(deps): update dependency org.junit.jupiter:junit-jupiter to v6.0.1

See merge request anthorob/netneutrality!88
2025-11-11 02:02:48 +00:00
anthorob d03b02c9f9 Edit renovate.json 2025-11-11 01:28:49 +00:00
Renovate Bot db7be88f13 fix(deps): update dependency io.sentry:sentry to v8.25.0 2025-11-10 16:13:33 +00:00
Renovate Bot 2b3ca9ae18 fix(deps): update dependency commons-io:commons-io to v2.21.0 2025-11-10 16:04:33 +00:00
Renovate Bot d555a3144d fix(deps): update dependency com.squareup.okhttp3:okhttp-jvm to v5.3.0 2025-11-10 09:34:53 +00:00
Renovate Bot 60d50d99bc fix(deps): update dependency com.mysql:mysql-connector-j to v9.5.0 2025-11-10 09:34:49 +00:00
Renovate Bot a54f20e990 fix(deps): update hibernate orm to v6.6.34.final 2025-11-03 09:34:32 +00:00
Renovate Bot fac0aedc5e chore(deps): update dependency org.junit.jupiter:junit-jupiter to v6.0.1 2025-11-03 09:34:28 +00:00
Renovate Bot 0a693f4bc3 chore(deps): pin ghcr.io/kikkia/yt-cipher docker tag to 4fbb0d9 2025-11-03 09:34:25 +00:00
anthorob 8fa6de233e Merge branch 'command-processor-testing' into 'main'
Command processor testing

See merge request anthorob/netneutrality!85
2025-10-27 07:08:56 +00:00
anthorob 6fc3dc9577 Command processor testing 2025-10-27 07:08:56 +00:00
anthorob c19287d580 Merge branch 'renovate/dev.lavalink.youtube-v2-1.x' into 'main'
fix(deps): update dependency dev.lavalink.youtube:v2 to v1.15.0

See merge request anthorob/netneutrality!80
2025-10-26 15:43:39 +00:00
Renovate 3a678ae859 fix(deps): update dependency dev.lavalink.youtube:v2 to v1.15.0 2025-10-26 15:43:39 +00:00
anthorob 69e9ae982b Merge branch 'renovate/quay.io-buildah-stable' into 'main'
chore(deps): update quay.io/buildah/stable docker digest to 9fa1327

See merge request anthorob/netneutrality!73
2025-10-23 06:43:40 +00:00
anthorob 3a846ed846 Merge branch 'renovate/org.junit.jupiter-junit-jupiter-6.x' into 'main'
chore(deps): update dependency org.junit.jupiter:junit-jupiter to v6

See merge request anthorob/netneutrality!83
2025-10-23 06:43:18 +00:00
anthorob 7d6ad97951 Merge branch 'renovate/io.sentry-sentry-8.x' into 'main'
fix(deps): update dependency io.sentry:sentry to v8.24.0

See merge request anthorob/netneutrality!82
2025-10-23 06:39:37 +00:00
anthorob 450444f37b Merge branch 'renovate/net.fellbaum-jemoji-1.x' into 'main'
fix(deps): update dependency net.fellbaum:jemoji to v1.7.5

See merge request anthorob/netneutrality!70
2025-10-23 06:39:20 +00:00
anthorob b5bbbb7907 Merge branch 'renovate/log4j2-monorepo' into 'main'
fix(deps): update log4j2 monorepo to v2.25.2

See merge request anthorob/netneutrality!60
2025-10-23 06:39:07 +00:00
Renovate Bot f830f8468c chore(deps): update dependency org.junit.jupiter:junit-jupiter to v6 2025-10-23 06:24:53 +00:00
Renovate Bot 316e5f6b4a fix(deps): update log4j2 monorepo to v2.25.2 2025-10-23 06:24:50 +00:00
Renovate Bot 437cf3a460 fix(deps): update dependency io.sentry:sentry to v8.24.0 2025-10-23 06:24:47 +00:00
Renovate Bot 822c6297a5 fix(deps): update dependency net.fellbaum:jemoji to v1.7.5 2025-10-23 06:24:44 +00:00
anthorob d459b3b735 Merge branch 'renovate/major-okhttp-monorepo' into 'main'
fix(deps): update dependency com.squareup.okhttp3:okhttp to v5

See merge request anthorob/netneutrality!61
2025-10-22 21:47:31 +00:00
Renovate 6b2d64388d fix(deps): update dependency com.squareup.okhttp3:okhttp to v5 2025-10-22 21:47:31 +00:00
anthorob aad528c5c7 Merge branch 'renovate/flyway' into 'main'
fix(deps): update flyway to v11.14.1

See merge request anthorob/netneutrality!49
2025-10-22 21:27:32 +00:00
Renovate 47ced9d0f2 fix(deps): update flyway to v11.14.1 2025-10-22 21:27:32 +00:00
anthorob 86c94db9aa Merge branch 'renovate/apache-commons' into 'main'
fix(deps): update apache commons

See merge request anthorob/netneutrality!81
2025-10-22 21:26:37 +00:00
Renovate Bot 5171cee268 fix(deps): update apache commons 2025-10-22 20:56:33 +00:00
anthorob f345505578 Merge branch 'renovate/pin-dependencies' into 'main'
chore(deps): pin mariadb docker tag to ae61197

See merge request anthorob/netneutrality!79
2025-10-22 20:48:35 +00:00
anthorob 4f64254e60 Merge branch 'renovate/hibernate-orm' into 'main'
fix(deps): update hibernate orm

See merge request anthorob/netneutrality!71
2025-10-22 20:47:38 +00:00
anthorob 5678cf52d8 fix renovate 2025-10-22 16:29:49 -04:00
Renovate Bot c96e0d88b5 fix(deps): update hibernate orm 2025-10-22 20:23:00 +00:00
Renovate Bot 3791667aac chore(deps): update quay.io/buildah/stable docker digest to 9fa1327 2025-10-22 20:22:47 +00:00
Renovate Bot 813d0dde02 chore(deps): pin mariadb docker tag to ae61197 2025-10-22 20:22:46 +00:00
anthorob b78322e521 Merge branch 'scheduled-tasks' into 'main'
Implement scheduled tasks

See merge request anthorob/netneutrality!78
2025-10-22 20:11:03 +00:00
anthorob 78002d9299 Implement scheduled tasks 2025-10-22 20:11:03 +00:00
anthorob 78a149b546 Merge branch 'renovate/org.apache.maven.plugins-maven-shade-plugin-3.x' into 'main'
chore(deps): update dependency org.apache.maven.plugins:maven-shade-plugin to v3.6.1

See merge request anthorob/netneutrality!67
2025-10-05 08:27:01 +00:00
anthorob 178054dd23 Merge branch 'renovate/pin-dependencies' into 'main'
chore(deps): pin dependencies

See merge request anthorob/netneutrality!65
2025-10-05 08:22:15 +00:00
anthorob 9c09edc88d Merge branch 'renovate/org.apache.maven.plugins-maven-surefire-plugin-3.x' into 'main'
chore(deps): update dependency org.apache.maven.plugins:maven-surefire-plugin to v3.5.4

See merge request anthorob/netneutrality!68
2025-10-05 07:52:25 +00:00
anthorob b5e8aa7bd0 Merge branch 'renovate/mockito-monorepo' into 'main'
chore(deps): update dependency org.mockito:mockito-core to v5.20.0

See merge request anthorob/netneutrality!72
2025-10-05 07:52:08 +00:00
anthorob 230aca1620 Merge branch 'renovate/com.google.code.gson-gson-2.x' into 'main'
fix(deps): update dependency com.google.code.gson:gson to v2.13.2

See merge request anthorob/netneutrality!69
2025-10-05 07:51:52 +00:00
Renovate Bot 508e3fb5da chore(deps): pin dependencies 2025-10-05 07:19:29 +00:00
Renovate Bot c195f1f56b chore(deps): update dependency org.mockito:mockito-core to v5.20.0 2025-10-04 14:56:28 +00:00
Renovate Bot 55f7314b05 fix(deps): update dependency com.google.code.gson:gson to v2.13.2 2025-10-03 14:13:49 +00:00
anthorob c3b9a36557 Merge branch 'renovate/maven-wrapper-3.x' into 'main'
chore(deps): update dependency maven-wrapper to v3.3.4

See merge request anthorob/netneutrality!66
2025-10-03 14:05:15 +00:00
Renovate Bot 4b45f802f7 chore(deps): update dependency org.apache.maven.plugins:maven-surefire-plugin to v3.5.4 2025-10-03 13:48:47 +00:00
Renovate Bot 1b80d5aa5a chore(deps): update dependency org.apache.maven.plugins:maven-shade-plugin to v3.6.1 2025-10-03 13:48:46 +00:00
Renovate Bot 83ae5bf657 chore(deps): update dependency maven-wrapper to v3.3.4 2025-10-03 13:38:25 +00:00
anthorob e2a7aa79f6 Merge branch 'group-hibernate-renovate' into 'main'
Group Hibernate MR by Renovate

See merge request anthorob/netneutrality!64
2025-10-03 13:34:32 +00:00
anthorob e186c44185 Group Hibernate MR by Renovate 2025-10-03 13:34:32 +00:00
anthorob de96a679e6 Merge branch 'renovate/maven-3.x' into 'main'
chore(deps): update dependency maven to v3.9.11

See merge request anthorob/netneutrality!63
2025-10-02 14:48:12 +00:00
anthorob 9589f484ff Merge branch 'renovate/io.sentry-sentry-8.x' into 'main'
fix(deps): update dependency io.sentry:sentry to v8.22.0

See merge request anthorob/netneutrality!54
2025-10-02 14:43:28 +00:00
anthorob af7dec9c79 Merge branch 'renovate/org.gitlab4j-gitlab4j-api-6.x' into 'main'
fix(deps): update dependency org.gitlab4j:gitlab4j-api to v6.1.0

See merge request anthorob/netneutrality!51
2025-10-02 14:43:14 +00:00
Renovate a6123b9656 fix(deps): update dependency org.gitlab4j:gitlab4j-api to v6.1.0 2025-10-02 14:43:14 +00:00
anthorob e940da94e2 Merge branch 'renovate/org.junit.jupiter-junit-jupiter-5.x' into 'main'
chore(deps): update dependency org.junit.jupiter:junit-jupiter to v5.14.0

See merge request anthorob/netneutrality!59
2025-10-02 14:42:46 +00:00
Renovate Bot dad51621c5 fix(deps): update dependency io.sentry:sentry to v8.22.0 2025-09-30 08:24:16 +00:00
Renovate Bot 7003cfbd39 chore(deps): update dependency org.junit.jupiter:junit-jupiter to v5.14.0 2025-09-30 08:24:12 +00:00
Renovate Bot 0b057de598 chore(deps): update dependency maven to v3.9.11 2025-07-28 08:34:23 +00:00
anthorob ff70af988a Merge branch 'renovate/dev.arbjerg-lavaplayer-2.x' into 'main'
fix(deps): update dependency dev.arbjerg:lavaplayer to v2.2.4

See merge request anthorob/netneutrality!55
2025-07-26 01:12:59 +00:00
Renovate Bot f3c144d2b1 fix(deps): update dependency dev.arbjerg:lavaplayer to v2.2.4 2025-07-04 16:53:39 +00:00
anthorob 716c6e9d93 Merge branch 'renovate/org.junit.jupiter-junit-jupiter-5.x' into 'main'
chore(deps): update dependency org.junit.jupiter:junit-jupiter to v5.13.2

See merge request anthorob/netneutrality!57
2025-07-04 16:49:52 +00:00
anthorob 6172182ed5 Merge branch 'renovate/dev.lavalink.youtube-v2-1.x' into 'main'
fix(deps): update dependency dev.lavalink.youtube:v2 to v1.13.3

See merge request anthorob/netneutrality!53
2025-07-04 16:45:50 +00:00
Renovate fc4dd64dff fix(deps): update dependency dev.lavalink.youtube:v2 to v1.13.3 2025-07-04 16:45:50 +00:00
Renovate Bot c40430fa9b chore(deps): update dependency org.junit.jupiter:junit-jupiter to v5.13.2 2025-06-30 08:33:52 +00:00
anthorob 40a73fd62d Merge branch 'renovate/net.dv8tion-jda-5.x' into 'main'
fix(deps): update dependency net.dv8tion:jda to v5.6.1

See merge request anthorob/netneutrality!52
2025-06-22 00:32:47 +00:00
Renovate 39111235f7 fix(deps): update dependency net.dv8tion:jda to v5.6.1 2025-06-22 00:32:47 +00:00
anthorob aee731bebe Merge branch 'renovate/com.mysql-mysql-connector-j-9.x' into 'main'
fix(deps): update dependency com.mysql:mysql-connector-j to v9.3.0

See merge request anthorob/netneutrality!45
2025-06-09 14:57:49 +00:00
anthorob e50730298d Merge branch 'renovate/maven-3.x' into 'main'
chore(deps): update dependency maven to v3.9.10

See merge request anthorob/netneutrality!50
2025-06-09 14:53:16 +00:00
anthorob d26fea7b9c Merge branch 'renovate/eclipse-temurin-21.x' into 'main'
chore(deps): update eclipse-temurin docker tag to v21.0.7_6-jdk-alpine

See merge request anthorob/netneutrality!38
2025-06-09 14:52:04 +00:00
Renovate f6ba9233be chore(deps): update eclipse-temurin docker tag to v21.0.7_6-jdk-alpine 2025-06-09 14:52:04 +00:00
anthorob 1423b6105a Merge branch 'renovate/junit5-monorepo' into 'main'
chore(deps): update dependency org.junit.jupiter:junit-jupiter to v5.13.1

See merge request anthorob/netneutrality!48
2025-06-09 13:06:53 +00:00
anthorob eb765952e4 Merge branch 'renovate/io.sentry-sentry-8.x' into 'main'
fix(deps): update dependency io.sentry:sentry to v8.13.2

See merge request anthorob/netneutrality!46
2025-06-09 13:06:11 +00:00
anthorob 8e1154319b Merge branch 'renovate/org.hibernate.orm-hibernate-hikaricp-6.x' into 'main'
fix(deps): update dependency org.hibernate.orm:hibernate-hikaricp to v6.6.17.final

See merge request anthorob/netneutrality!27
2025-06-09 13:05:40 +00:00
anthorob a7f5508c41 Merge branch 'renovate/org.hibernate.orm-hibernate-core-6.x' into 'main'
fix(deps): update dependency org.hibernate.orm:hibernate-core to v6.6.17.final

See merge request anthorob/netneutrality!26
2025-06-09 13:05:23 +00:00
anthorob d2a14637f0 Merge branch 'renovate/net.fellbaum-jemoji-1.x' into 'main'
fix(deps): update dependency net.fellbaum:jemoji to v1.7.4

See merge request anthorob/netneutrality!34
2025-06-09 13:04:48 +00:00
anthorob 539cd723b2 Merge branch 'renovate/net.dv8tion-jda-5.x' into 'main'
fix(deps): update dependency net.dv8tion:jda to v5.5.1

See merge request anthorob/netneutrality!47
2025-06-09 13:00:07 +00:00
Renovate c95f461300 fix(deps): update dependency net.dv8tion:jda to v5.5.1 2025-06-09 13:00:07 +00:00
Renovate Bot 52fcd8434e chore(deps): update dependency org.junit.jupiter:junit-jupiter to v5.13.1 2025-06-09 08:33:52 +00:00
Renovate Bot 1d8b9eb873 chore(deps): update dependency maven to v3.9.10 2025-06-09 08:33:47 +00:00
Renovate Bot 91f8c2b6bb fix(deps): update dependency io.sentry:sentry to v8.13.2 2025-06-03 13:59:07 +00:00
Renovate Bot 60e6453354 fix(deps): update dependency com.mysql:mysql-connector-j to v9.3.0 2025-06-03 13:59:04 +00:00
Renovate Bot 0120932e59 fix(deps): update dependency org.hibernate.orm:hibernate-hikaricp to v6.6.17.final 2025-06-03 13:59:00 +00:00
Renovate Bot 56df16c3d5 fix(deps): update dependency org.hibernate.orm:hibernate-core to v6.6.17.final 2025-06-03 13:58:57 +00:00
Renovate Bot 251ac6e171 fix(deps): update dependency net.fellbaum:jemoji to v1.7.4 2025-06-03 13:58:54 +00:00
anthorob 194a19cec2 Merge branch 'renovate/org.gitlab4j-gitlab4j-api-6.x' into 'main'
fix(deps): update dependency org.gitlab4j:gitlab4j-api to v6.0.0-rc.10

See merge request anthorob/netneutrality!39
2025-06-03 13:55:57 +00:00
Renovate 069002a534 fix(deps): update dependency org.gitlab4j:gitlab4j-api to v6.0.0-rc.10 2025-06-03 13:55:57 +00:00
anthorob 7c960ddbdc Merge branch 'renovate/commons-io-commons-io-2.x' into 'main'
fix(deps): update dependency commons-io:commons-io to v2.19.0

See merge request anthorob/netneutrality!36
2025-05-22 15:57:56 +00:00
anthorob 13dabe4bda Merge branch 'renovate/com.google.code.gson-gson-2.x' into 'main'
fix(deps): update dependency com.google.code.gson:gson to v2.13.1

See merge request anthorob/netneutrality!35
2025-05-22 15:57:26 +00:00
anthorob 1d099ad67e Merge branch 'renovate/junit5-monorepo' into 'main'
fix(deps): update dependency org.junit.jupiter:junit-jupiter to v5.12.2

See merge request anthorob/netneutrality!28
2025-05-22 15:57:02 +00:00
anthorob acb11c3d9a Merge branch 'renovate/flyway' into 'main'
fix(deps): update flyway to v11.8.2

See merge request anthorob/netneutrality!30
2025-05-22 15:47:10 +00:00
Renovate 86f9c6bc77 fix(deps): update flyway to v11.8.2 2025-05-22 15:47:10 +00:00
anthorob 59106ea59e Merge branch 'renovate/io.sentry-sentry-8.x' into 'main'
fix(deps): update dependency io.sentry:sentry to v8.12.0

See merge request anthorob/netneutrality!29
2025-05-22 14:06:08 +00:00
Renovate Bot d04897d603 fix(deps): update dependency io.sentry:sentry to v8.12.0 2025-05-22 13:24:32 +00:00
Renovate Bot af8df43295 fix(deps): update dependency commons-io:commons-io to v2.19.0 2025-05-22 13:24:30 +00:00
Renovate Bot 9d9a973201 fix(deps): update dependency com.google.code.gson:gson to v2.13.1 2025-05-22 13:24:27 +00:00
Renovate Bot 5cc35c463e fix(deps): update dependency org.junit.jupiter:junit-jupiter to v5.12.2 2025-05-22 13:24:25 +00:00
anthorob 24053bf5a7 Merge branch '9-flyway-migrations' into 'master'
Resolve "Flyway migrations"

Closes #9

See merge request anthorob/netneutrality!43
2025-05-22 13:09:36 +00:00
anthorob 41d6de7219 Resolve "Flyway migrations" 2025-05-22 13:09:36 +00:00
anthorob fae59dd9bb Merge branch 'fix-music-player' into 'master'
Fix music player

See merge request anthorob/netneutrality!42
2025-05-21 16:30:43 +00:00
anthorob c055ac84c6 Fix music player 2025-05-21 16:30:43 +00:00
anthorob d88c1b380a Merge branch '13-bug-report-netneutrality-3h' into 'master'
Fix docker build issue

Closes #13

See merge request anthorob/netneutrality!41
2025-04-28 01:22:03 +00:00
anthorob 5b11e68f8a Fix docker build issue 2025-04-28 01:22:03 +00:00
anthorob 29be1c987a Merge branch '13-bug-report-netneutrality-3h' into 'master'
Fix NETNEUTRALITY-3H

Closes #13

See merge request anthorob/netneutrality!40
2025-04-28 01:14:12 +00:00
anthorob 070eeb986f Fix NETNEUTRALITY-3H 2025-04-28 01:14:12 +00:00
anthorob f9b385ec86 Merge branch 'renovate/net.dv8tion-jda-5.x' into 'master'
fix(deps): update dependency net.dv8tion:jda to v5.4.0

See merge request anthorob/netneutrality!37
2025-04-24 06:37:58 +00:00
Renovate 8e4a6841cc fix(deps): update dependency net.dv8tion:jda to v5.4.0 2025-04-24 06:37:58 +00:00
anthorob 0d735f3744 Merge branch 'renovate/net.dv8tion-jda-5.x' into 'master'
fix(deps): update dependency net.dv8tion:jda to v5.3.2

See merge request anthorob/netneutrality!33
2025-04-20 16:50:44 +00:00
Renovate 030d816caf fix(deps): update dependency net.dv8tion:jda to v5.3.2 2025-04-20 16:50:44 +00:00
anthorob 50e5e704b7 Merge branch 'renovate/net.dv8tion-jda-5.x' into 'master'
fix(deps): update dependency net.dv8tion:jda to v5.3.1

See merge request anthorob/netneutrality!32
2025-03-31 15:38:25 +00:00
Renovate ffd2f05509 fix(deps): update dependency net.dv8tion:jda to v5.3.1 2025-03-31 15:38:25 +00:00
anthorob 1efab2eb5b Merge branch '12-bug-report-netneutrality-3f' into 'master'
Resolve "Bug report: NETNEUTRALITY-3F"

Closes #12

See merge request anthorob/netneutrality!31
2025-03-26 18:25:25 +00:00
anthorob ef2cd9769e Resolve "Bug report: NETNEUTRALITY-3F" 2025-03-26 18:25:25 +00:00
anthorob c6666bbbc1 Merge branch 'renovate/eclipse-temurin-21.x' into 'master'
chore(deps): update eclipse-temurin docker tag to v21

See merge request anthorob/netneutrality!19
2025-03-16 13:23:14 +00:00
Renovate 85624a974d chore(deps): update eclipse-temurin docker tag to v21 2025-03-16 13:23:13 +00:00
anthorob a37e0b47ab Merge branch 'renovate/net.dv8tion-jda-5.x' into 'master'
fix(deps): update dependency net.dv8tion:jda to v5.3.0

See merge request anthorob/netneutrality!15
2025-03-12 01:20:06 +00:00
Renovate 143e96ec46 fix(deps): update dependency net.dv8tion:jda to v5.3.0 2025-03-12 01:20:06 +00:00
anthorob 19f889e885 Merge branch 'renovate/eclipse-temurin-17.x' into 'master'
chore(deps): update eclipse-temurin docker tag

See merge request anthorob/netneutrality!9
2025-03-11 15:13:48 +00:00
anthorob 864f92b766 Merge branch 'renovate/major-flyway' into 'master'
fix(deps): update flyway to v11 (major)

See merge request anthorob/netneutrality!25
2025-03-11 15:03:22 +00:00
Renovate 6d1af8094e fix(deps): update flyway to v11 (major) 2025-03-11 15:03:22 +00:00
Renovate Bot c145848c14 chore(deps): update eclipse-temurin docker tag 2025-03-11 10:01:18 +00:00
anthorob 119c997265 Merge branch 'renovate/org.gitlab4j-gitlab4j-api-6.x' into 'master'
fix(deps): update dependency org.gitlab4j:gitlab4j-api to v6.0.0-rc.9

See merge request anthorob/netneutrality!6
2025-03-11 10:00:02 +00:00
Renovate b9051a0f74 fix(deps): update dependency org.gitlab4j:gitlab4j-api to v6.0.0-rc.9 2025-03-11 10:00:02 +00:00
anthorob 649c1cc8ae Merge branch 'renovate/org.hibernate.orm-hibernate-core-6.x' into 'master'
fix(deps): update dependency org.hibernate.orm:hibernate-core to v6.6.10.final

See merge request anthorob/netneutrality!20
2025-03-11 09:43:37 +00:00
anthorob 2e45054ade Merge branch 'renovate/org.hibernate.orm-hibernate-hikaricp-6.x' into 'master'
fix(deps): update dependency org.hibernate.orm:hibernate-hikaricp to v6.6.10.final

See merge request anthorob/netneutrality!21
2025-03-11 09:43:24 +00:00
anthorob 65f17b67ec Merge branch 'renovate/io.sentry-sentry-8.x' into 'master'
fix(deps): update dependency io.sentry:sentry to v8

See merge request anthorob/netneutrality!23
2025-03-11 09:35:42 +00:00
anthorob 52eed29f27 Merge branch 'renovate/org.json-json-20250107.x' into 'master'
fix(deps): update dependency org.json:json to v20250107

See merge request anthorob/netneutrality!24
2025-03-11 06:47:06 +00:00
anthorob bf4d0d7f6a Merge branch 'renovate/flyway' into 'master'
fix(deps): update flyway to v10.22.0

See merge request anthorob/netneutrality!22
2025-03-11 06:46:53 +00:00
Renovate 35ee744673 fix(deps): update flyway to v10.22.0 2025-03-11 06:46:53 +00:00
Renovate Bot ac4b0b52df fix(deps): update dependency org.json:json to v20250107 2025-03-11 06:25:03 +00:00
Renovate Bot 04eb4d4c6e fix(deps): update dependency io.sentry:sentry to v8 2025-03-11 06:25:00 +00:00
Renovate Bot 6aa5261a03 fix(deps): update dependency org.hibernate.orm:hibernate-hikaricp to v6.6.10.final 2025-03-11 06:24:52 +00:00
Renovate Bot 464cbc5fd7 fix(deps): update dependency org.hibernate.orm:hibernate-core to v6.6.10.final 2025-03-11 06:24:47 +00:00
anthorob ecd4af7dc6 Merge branch 'renovate/log4j2-monorepo' into 'master'
fix(deps): update log4j2 monorepo to v2.24.3

See merge request anthorob/netneutrality!18
2025-03-11 06:22:49 +00:00
anthorob 6d312b664e Merge branch 'renovate/junit5-monorepo' into 'master'
fix(deps): update dependency org.junit.jupiter:junit-jupiter to v5.12.0

See merge request anthorob/netneutrality!17
2025-03-11 06:22:30 +00:00
anthorob d2207d4cac Merge branch 'renovate/org.apache.commons-commons-lang3-3.x' into 'master'
fix(deps): update dependency org.apache.commons:commons-lang3 to v3.17.0

See merge request anthorob/netneutrality!16
2025-03-11 06:22:16 +00:00
Renovate Bot 37d80f53ca fix(deps): update log4j2 monorepo to v2.24.3 2025-03-11 04:35:11 +00:00
Renovate Bot e6d0354334 fix(deps): update dependency org.junit.jupiter:junit-jupiter to v5.12.0 2025-03-11 04:35:08 +00:00
Renovate Bot a209eab8ff fix(deps): update dependency org.apache.commons:commons-lang3 to v3.17.0 2025-03-11 04:35:07 +00:00
anthorob c2b9f55408 Merge branch 'renovate/commons-codec-commons-codec-1.x' into 'master'
fix(deps): update dependency commons-codec:commons-codec to v1.18.0

See merge request anthorob/netneutrality!12
2025-03-11 04:31:35 +00:00
anthorob 3813ce3691 Merge branch 'renovate/commons-io-commons-io-2.x' into 'master'
fix(deps): update dependency commons-io:commons-io to v2.18.0

See merge request anthorob/netneutrality!13
2025-03-11 04:31:22 +00:00
anthorob 1eac4c96f2 Merge branch 'renovate/maven-wrapper-3.x' into 'master'
chore(deps): update dependency maven-wrapper to v3.3.2

See merge request anthorob/netneutrality!10
2025-03-11 04:31:10 +00:00
Renovate Bot b4d5ca75e6 fix(deps): update dependency commons-io:commons-io to v2.18.0 2025-03-11 04:25:22 +00:00
Renovate Bot 1be3167cb9 fix(deps): update dependency commons-codec:commons-codec to v1.18.0 2025-03-11 04:25:19 +00:00
Renovate Bot 61e96746be chore(deps): update dependency maven-wrapper to v3.3.2 2025-03-11 04:25:19 +00:00
anthorob dfd678757b Merge branch 'renovate/net.fellbaum-jemoji-1.x' into 'master'
fix(deps): update dependency net.fellbaum:jemoji to v1.7.1

See merge request anthorob/netneutrality!5
2025-03-11 04:23:23 +00:00
anthorob 093796211c Merge branch 'renovate/com.mysql-mysql-connector-j-9.x' into 'master'
fix(deps): update dependency com.mysql:mysql-connector-j to v9.2.0

See merge request anthorob/netneutrality!11
2025-03-11 04:19:41 +00:00
anthorob 178cca5b1b Merge branch 'renovate/dev.arbjerg-lavaplayer-2.x' into 'master'
fix(deps): update dependency dev.arbjerg:lavaplayer to v2.2.3

See merge request anthorob/netneutrality!4
2025-03-11 03:19:25 +00:00
anthorob 970806213d Merge branch 'renovate/maven-3.x' into 'master'
chore(deps): update dependency maven to v3.9.9

See merge request anthorob/netneutrality!8
2025-03-11 03:06:36 +00:00
Renovate Bot 0a069157b0 fix(deps): update dependency com.mysql:mysql-connector-j to v9.2.0 2025-03-10 08:33:44 +00:00
Renovate Bot ef7282f777 chore(deps): update dependency maven to v3.9.9 2025-03-10 07:33:22 +00:00
Renovate Bot eef8886a6d fix(deps): update dependency net.fellbaum:jemoji to v1.7.1 2025-03-09 20:22:30 +00:00
Renovate Bot 138385c895 fix(deps): update dependency dev.arbjerg:lavaplayer to v2.2.3 2025-03-09 20:22:29 +00:00
anthorob 4a14098ade Merge branch 'develop' into 'master'
Version 3.0

See merge request anthorob/netneutrality!3
2025-03-09 20:08:43 +00:00
anthorob 1242118b0d Version 3.0 2025-03-09 20:08:43 +00:00
312 changed files with 17577 additions and 4628 deletions

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
.git
.github
.idea
.mvn
src
classes
logs
target/*
!target/netneutrality.jar
*.iml
README.md
checkstyle.xml
mvnw
mvnw.cmd
pom.xml
+14 -3
View File
@@ -1,17 +1,28 @@
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_USER=root
MYSQL_PASSWORD=
MYSQL_DATABASE=
MYSQL_PORT=3306
MYSQL_USER=java
MYSQL_PASSWORD=password
MYSQL_DATABASE=netneutrality
NETNEUTRALITY_LAVALINK_URI=ws://lavalink:2333
NETNEUTRALITY_LAVALINK_PASSWORD=youshallnotpass
NETNEUTRALITY_LAVALINK_NAME=dev-player
NETNEUTRALITY_PO_TOKEN=
NETNEUTRALITY_VISITOR_DATA=
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=
+133
View File
@@ -0,0 +1,133 @@
name: CI
on:
push:
branches:
- "main"
tags:
- '*'
pull_request:
branches:
- "main"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
package:
name: Package
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up JDK
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
with:
distribution: 'temurin'
java-version: '25'
cache: 'maven'
- name: Build package
run: |
chmod +x ./mvnw
./mvnw --batch-mode --update-snapshots verify
- name: Upload test reports
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: junit-reports
path: |
**/target/*-reports/TEST-*.xml
if-no-files-found: ignore
- name: Upload Maven artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: maven-artifacts
path: |
target/netneutrality.jar
startup.sh
if-no-files-found: error
maven-publish:
name: Publish Maven Package
runs-on: ubuntu-24.04
timeout-minutes: 15
needs: package
if: ${{ github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')) }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up JDK
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
with:
distribution: 'temurin'
java-version: '25'
cache: 'maven'
server-id: gitea
server-username: REGISTRY_USER
server-password: REGISTRY_PASSWORD
- name: Publish Maven package
run: |
chmod +x ./mvnw
./mvnw --batch-mode deploy -DskipTests
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
docker:
name: Docker Build and Push
runs-on: ubuntu-24.04
timeout-minutes: 15
needs: package
if: ${{ github.event_name == 'push' }}
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Download build artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: maven-artifacts
path: .
- name: Compute Docker tags
id: meta
shell: bash
run: |
REGISTRY="${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USER }}/netneutrality"
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
ver="${GITHUB_REF_NAME#v}"
echo "tags=${REGISTRY}:${ver},${REGISTRY}:latest" >> "$GITHUB_OUTPUT"
else
echo "tags=${REGISTRY}:main" >> "$GITHUB_OUTPUT"
fi
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to registry
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4
with:
registry: ${{ secrets.REGISTRY_URL }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
+2 -1
View File
@@ -33,15 +33,16 @@ hs_err_pid*
.idea/
*.iml
*.iws
.junie/plans
# Mac
.DS_Store
# Maven
log/
target/
#log4j
logs/
.env
docker-compose.override.yml
-54
View File
@@ -1,54 +0,0 @@
image: eclipse-temurin:17-jdk-alpine
variables:
MAVEN_OPTS: >-
-Dhttps.protocols=TLSv1.2
-Dmaven.repo.local=$CI_PROJECT_DIR/.m2/repository
-Dorg.slf4j.simpleLogger.showDateTime=true
-Djava.awt.headless=true
MAVEN_CLI_OPTS: >-
--batch-mode
--errors
--fail-at-end
--show-version
--no-transfer-progress
-DinstallAtEnd=true
-DdeployAtEnd=true
cache:
key: "maven-$CI_COMMIT_REF_SLUG"
paths:
- .m2/repository
stages:
- compile
- test
- package
compile:
stage: compile
script:
- "chmod +x ./mvnw"
- "./mvnw $MAVEN_CLI_OPTS compile"
test:
stage: test
script:
- "chmod +x ./mvnw"
- "./mvnw $MAVEN_CLI_OPTS test"
artifacts:
when: always
reports:
junit:
- "**/target/*-reports/TEST-*.xml"
package:
stage: package
script:
- "chmod +x ./mvnw"
- "./mvnw $MAVEN_CLI_OPTS package -DskipTests=true"
artifacts:
name: "Maven artifacts from $CI_PROJECT_NAME on $CI_COMMIT_REF_SLUG"
paths:
- "**/target"
+3 -18
View File
@@ -1,18 +1,3 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.6/apache-maven-3.9.6-bin.zip
wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar
wrapperVersion=3.3.4
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.15/apache-maven-3.9.15-bin.zip
+3 -14
View File
@@ -1,17 +1,6 @@
FROM eclipse-temurin:17-jdk-alpine AS build
FROM eclipse-temurin:25-jre@sha256:ff82c74a630c08b417502c4c18189e62074edbb28eb1f2d31b799ac138c01058
COPY .mvn/ .mvn
COPY mvnw pom.xml ./
COPY src ./src
RUN ["chmod", "+x", "./mvnw"]
RUN ./mvnw install
FROM eclipse-temurin:17-jre-alpine
RUN apk add netcat-openbsd
RUN apk add curl
RUN apt-get update && apt-get install -y --no-install-recommends netcat-openbsd curl && rm -rf /var/lib/apt/lists/*
WORKDIR /app
@@ -19,7 +8,7 @@ ARG API_PORT=9000
EXPOSE ${API_PORT}
COPY startup.sh ./
COPY --from=build /target/netneutrality-jar-with-dependencies.jar ./
COPY target/netneutrality.jar ./
RUN ["chmod", "+x", "./startup.sh"]
CMD ["./startup.sh"]
+15 -4
View File
@@ -1,17 +1,28 @@
NETNEUTRALITY_TAG=latest
NETNEUTRALITY_BOT_ENV=dev
NETNEUTRALITY_BOT_TOKEN=
NETNEUTRALITY_BOT_PREFIX=
NETNEUTRALITY_BOT_PREFIX=$
NETNEUTRALITY_BOT_OWNER=
NETNEUTRALITY_BOT_INVITE=
NETNEUTRALITY_API_PORT=9000
MYSQL_HOST=127.0.0.1
MYSQL_USER=root
MYSQL_PASSWORD=
MYSQL_DATABASE=
MYSQL_PORT=3306
MYSQL_USER=java
MYSQL_PASSWORD=password
MYSQL_DATABASE=netneutrality
MYSQL_ROOT_PASSWORD=password
MARIADB_AUTO_UPGRADE=1
NETNEUTRALITY_PO_TOKEN=
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=
+37
View File
@@ -0,0 +1,37 @@
<?xml version="1.0"?>
<!DOCTYPE module PUBLIC
"-//Checkstyle//DTD Checkstyle Configuration 1.3//EN"
"https://checkstyle.org/dtds/configuration_1_3.dtd">
<module name="Checker">
<property name="severity" value="warning"/>
<!-- Allows tab characters -->
<module name="FileTabCharacter">
<property name="eachLine" value="false"/>
</module>
<module name="TreeWalker">
<!-- Indentation with tabs -->
<module name="Indentation">
<property name="basicOffset" value="4"/>
<property name="tabWidth" value="4"/>
<property name="forceStrictCondition" value="false"/>
</module>
<!-- Common useful checks -->
<module name="EmptyBlock">
<property name="option" value="text"/>
</module>
<module name="NeedBraces"/>
<module name="AvoidStarImport"/>
<module name="UnusedImports"/>
<module name="EqualsHashCode"/>
<!-- Relaxed Javadoc rules -->
<module name="JavadocMethod">
<property name="allowMissingParamTags" value="true"/>
<property name="allowMissingReturnTag" value="true"/>
</module>
</module>
</module>
-64
View File
@@ -1,64 +0,0 @@
services:
db:
image: mariadb:lts
container_name: db
restart: unless-stopped
volumes:
- netneutrality-db:/var/lib/mysql
- ./sql:/docker-entrypoint-initdb.d
environment:
MYSQL_USER: 'java'
MYSQL_PASSWORD: 'password'
MYSQL_DATABASE: 'netneutrality'
MYSQL_ROOT_PASSWORD: 'password'
MARIADB_AUTO_UPGRADE: '1'
ports:
- "3306"
networks:
- internal
bot:
container_name: bot
build: ./
restart: unless-stopped
depends_on:
- db
volumes:
- ./logs:/app/logs
environment:
MYSQL_HOST: 'db'
MYSQL_USER: 'java'
MYSQL_PASSWORD: 'password'
MYSQL_DATABASE: 'netneutrality'
ports:
- "9000:9000"
networks:
- internal
webapp:
image: bot-webapp:dev
build: ../netneutrality-webapp
restart: always
depends_on:
- db
- bot
volumes:
- ./logs:/app/logs
environment:
DB_CONNECTION: 'mysql'
DB_HOST: 'db'
DB_PORT: '3306'
DB_DATABASE: 'netneutrality'
DB_USERNAME: 'java'
DB_PASSWORD: 'password'
BOT_URL: 'http://bot:9000'
ports:
- '8080:80'
networks:
- internal
volumes:
netneutrality-db:
networks:
internal:
+71 -27
View File
@@ -1,44 +1,88 @@
services:
db:
image: mariadb:lts
netneutrality-db:
image: mariadb:11@sha256:1cac8492bd78b1ec693238dc600be173397efd7b55eabc725abc281dc855b482
container_name: db
restart: unless-stopped
networks:
- netneutrality
volumes:
- netneutrality-db:/var/lib/mysql
- ./sql:/docker-entrypoint-initdb.d
environment:
MYSQL_USER: 'java'
MYSQL_PASSWORD: 'password'
MYSQL_DATABASE: 'netneutrality'
MYSQL_ROOT_PASSWORD: 'password'
MARIADB_AUTO_UPGRADE: '1'
ports:
- "3306:3306"
networks:
- internal
- MYSQL_USER=${MYSQL_USER:-java}
- MYSQL_PASSWORD=${MYSQL_PASSWORD:-password}
- MYSQL_DATABASE=${MYSQL_DATABASE:-netneutrality}
- MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD:-password}
- MARIADB_AUTO_UPGRADE=1
bot:
netneutrality-bot:
image: gitea.bobert.me/anthorob/netneutrality:${NETNEUTRALITY_TAG:-latest}
container_name: bot
build: ./
restart: unless-stopped
env_file: .env
networks:
- netneutrality
depends_on:
- db
- netneutrality-db
- lavalink
volumes:
- ./logs:/app/logs
environment:
MYSQL_HOST: 'db'
MYSQL_PORT: '3306'
MYSQL_USER: 'java'
MYSQL_PASSWORD: 'password'
MYSQL_DATABASE: 'netneutrality'
env_file: ".env"
ports:
- "9000:9000"
networks:
- internal
- 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:
netneutrality-db:
- ./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
restart: unless-stopped
networks:
- 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:
internal:
netneutrality: null
+37
View File
@@ -0,0 +1,37 @@
# Net Neutrality — Technical Documentation
This directory contains detailed technical documentation for the **Net Neutrality** Discord bot
(`me.bobert:netneutrality`, version `3.0.0`).
It is intended for developers working on the codebase. It documents every major system and
subsystem, how they fit together, and the end-to-end flows that tie them.
## Table of contents
| Document | Subsystem |
|---|---|
| [architecture.md](architecture.md) | High-level architecture, startup/shutdown lifecycle, tech stack |
| [events.md](events.md) | Event handling & routing (`EventsHandler`) |
| [contexts.md](contexts.md) | Contexts — the abstraction over Discord events |
| [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 — Lavalink node & client, queue, event dispatch |
| [web-api.md](web-api.md) | HTTP Web API — internal control & introspection server |
| [threading.md](threading.md) | Threading & scheduling infrastructure |
| [utilities-and-bootstrap.md](utilities-and-bootstrap.md) | Utilities, configuration, logging, exceptions |
## How to read this documentation
Start with [architecture.md](architecture.md) for the big picture and the startup sequence.
Then read [events.md](events.md) and [contexts.md](contexts.md), because almost everything else
is triggered through a **Context** produced by the **EventsHandler**. From there, the two primary
execution paths are [commands.md](commands.md) and [tasks.md](tasks.md).
## Conventions
- Class names are given fully qualified on first mention, then by simple name.
- Database entity classes are prefixed with `O` (e.g. `OGuild`), and are documented in
[persistence.md](persistence.md).
- Environment variables / configuration keys use the `NETNEUTRALITY_*` and `MYSQL_*` prefixes and
are documented in [utilities-and-bootstrap.md](utilities-and-bootstrap.md).
+162
View File
@@ -0,0 +1,162 @@
# Architecture Overview
Net Neutrality is a Java 25 Discord bot built on top of [JDA](https://github.com/discord-jda/JDA).
It is packaged as a single fat JAR (via the Maven Shade plugin) whose entry point is
`me.bobert.netneutrality.App`.
## Big picture
Discord sends **events** over the gateway. A single JDA `EventListener`
(`me.bobert.netneutrality.hooks.EventsHandler`) receives every event and turns it into a
**Context** — a high-level abstraction over that event. Each context is then routed to one of three
execution paths:
```
┌──────────────────────────────┐
Discord Gateway ───────▶│ EventsHandler │
(JDA events) │ event class → Context factory│
└───────────────┬──────────────┘
│ builds a Context
┌─────────────────────────┼───────────────────────────┐
▼ ▼ ▼
CommandContext TaskContext (no Context)
│ │ lambda /
▼ ▼ consumer runs
CommandProcessor TaskManager.handle() directly
.dispatch() (lookup task by id) (e.g. GuildJoin)
│ │
▼ ▼
Command.execute() Task.execute()
```
1. **Command flow** — the context is a `CommandContext`; it is handed to `CommandProcessor` which
resolves and runs a `Command`. See [commands.md](commands.md).
2. **Task flow** — the context is a `TaskContext`; it is handed to `TaskManager` which looks up a
`Task` by its **id** and runs it. See [tasks.md](tasks.md).
3. **Lambda flow** — for simple events (e.g. `GuildJoinEvent`, `GuildLeaveEvent`) a consumer runs
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) (a remote Lavalink node), the [web API](web-api.md), and the [threading/scheduling](threading.md)
infrastructure.
## Technology stack
| Concern | Library | Version |
|---|---|---|
| Discord API | `net.dv8tion:JDA` | 6.4.1 |
| 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 |
| Emoji parsing | `net.fellbaum:jemoji` | 1.7.6 |
| 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).
## Startup lifecycle
The entry point is `App.main()` followed by the `App()` constructor which calls
`startup()`. Bootstrap is driven by a hand-rolled
[`ApplicationContext`](utilities-and-bootstrap.md#applicationcontext--lifecycle) that owns wired
instances and starts every [`Lifecycle`](utilities-and-bootstrap.md#applicationcontext--lifecycle)
component in ascending priority order.
1. **Print banner / read env** — reads `NETNEUTRALITY_BOT_ENV` (default `dev`) via the early-bootstrap
`Settings.getInstance()` singleton (the only remaining process-wide settings access point; it is
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 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).
3. `context.start()` — start every `Lifecycle` component in ascending priority order.
4. Register a JVM shutdown hook calling `shutdown()` → `context.stop()`.
### Lifecycle priorities (startup order)
| Priority | Component | What `start()` does |
|---|---|---|
| 10 | `SessionManager` (`SessionProvider`) | Build Hibernate `SessionFactory` + run Flyway migrations. Binds itself into `JpaUtils`. |
| 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`) | 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. |
| 70 | `WebApi` | Start the internal HTTP server (`NETNEUTRALITY_API_PORT`, default 9000). |
Commands and tasks that need shared services declare them as constructor parameters
(`MusicService`, `TaskManager`, `GuildServiceProvider`, `SettingsProvider`, `CommandRegistry`, …).
`ReflectionHelper` picks the richest public constructor whose parameter types all resolve from the
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 `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
`App.shutdown()` (invoked by the JVM shutdown hook, `ShutdownCommand`, or the HTTP
`/actions/shutdown` endpoint) calls `context.stop()`, which stops every registered `Lifecycle`
component in the **exact reverse** of start order. Failures in one component are logged and do not
block the others. Effectively this means:
1. `WebApi.stop()` — stop the HTTP server and its executor.
2. Post-JDA adapter — no-op.
3. JDA adapter — `jda.shutdown()`.
4. `ScheduledTaskManager.stop()` — cancel all scheduled tasks.
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.
## Configuration & build
- **Config sources** — `Settings` loads `bot.properties` and overlays environment variables (env
vars win). See [utilities-and-bootstrap.md](utilities-and-bootstrap.md#settings). An example file
lives at `bot.properties.example`.
- **Build** — `./mvnw package` produces `target/netneutrality.jar` (Shade fat JAR, main class
`me.bobert.netneutrality.App`). Checkstyle (`checkstyle.xml`) runs in the `test` phase
and fails the build on violations.
- **Run** — the preferred method is Docker Compose (`docker compose build && docker compose up -d`),
which also provisions the MariaDB database. See `README.md` and `docker-compose.yml`.
## 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` |
| `…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` |
| `…tasks.*` | Concrete task implementations (`git`, `help`, `music`, `reaction`) |
| `…db` | Session/migration, `Repository`, `JpaUtils`, `GSettings` |
| `…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, scheduler, track loading) |
| `…utils` | `Settings` / `SettingsProvider`, `ChannelMessagePurger`, `MusicUtils` |
| `…shared.util` | Cross-cutting helpers |
| `…exceptions` | Custom exception hierarchy |
+169
View File
@@ -0,0 +1,169 @@
# Commands
**Packages:** `me.bobert.netneutrality.framework.command` (framework),
`me.bobert.netneutrality.feature.*.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
kinds:
- **Slash commands** — first-class Discord application commands. Their structure (options,
subcommands, groups) has rules defined by Discord and must be configured **procedurally** via
`CommandOptionsBuilder`.
- **Message (prefix) commands** — a plain message beginning with the configured prefix
(`NETNEUTRALITY_BOT_PREFIX`, default `$`).
A core goal of the framework is that **both kinds are authored the same way**: a command declares its
metadata and options once, and the framework adapts it to either interaction style. Most of that
adaptation logic lives in `CommandProcessor`.
## The `Command` contract
`Command` (interface) defines:
- Metadata — `getCommandName()`, `getDescription()`, `getCategory()`
([`CommandCategory`](#commandcategory)).
- Type — `getType()` (`SLASH` by default).
- Permissions — `needAdmin()`, `canBeExecutedBy(...)`, guild-only / slash-only constraints.
- Option declaration — `registerCommandOptions(CommandOptionsBuilder)` (procedural option setup).
- Execution — `execute(CommandContext) : CommandExecutionResult`.
### Base implementations (`core.command.impl`)
| Class | Adds |
|---|---|
| `AbstractCommand` | Default `Command` implementation; type defaults to `SLASH`; basic permission logic. |
| `AdminCommand` | Sets `needAdmin() = true` and category `ADMINISTRATION`. |
| `MusicCommand` | Guild-only; verifies the guild's music role and music channel before execution. |
| `MessageMenuCommand` | Discord **message** context-menu command; skips option registration; wraps the target `Message`. |
| `UserMenuCommand` | Discord **user** context-menu command; skips option registration; wraps the target `User`. |
## Unified options
To let one command definition serve both slash and message input, values are read through a wrapper
interface:
- `CommandOption` (interface) — uniform accessors: `getAsString()`, `getAsInt()`, `getAsMember()`,
`getAsBoolean()`, … .
- `SlashCommandOption` — wraps JDA's `OptionMapping` (Discord already parsed the value).
- `MessageCommandOption` — wraps a value parsed manually from the message text.
### Procedural option configuration — `CommandOptionsBuilder`
`CommandOptionsBuilder` implements JDA's `SlashCommandData` and acts as a proxy over JDA's internal
`CommandDataImpl`. A command builds its argument surface in `registerCommandOptions(builder)` by
adding options, subcommands, and subcommand groups. The same declaration is used both to:
1. register the slash command with Discord (see [registration](#registration-lifecycle)), and
2. drive the message-command parser (which tries to match tokens in the message against the declared
`OptionData`).
## Command management
### `CommandManager`
Responsible for **discovery** and **registration**.
- **Discovery** — at `init()`, uses [`ReflectionHelper`](utilities-and-bootstrap.md#reflectionhelper)
to scan for `Command` implementations and instantiate them.
- **Storage** — commands are kept in a `ConcurrentHashMap<String, CommandContainer>` keyed by command
name.
- **Slash registration** — `registerSlashCommands(JDA)` converts each command's declaration into JDA
`SlashCommandData` and pushes it to Discord's API.
### `CommandContainer`
Wraps a `Command` instance together with its JDA metadata (`CommandDataImpl`). It also builds the
"usage" string shown by the help command.
### `CommandCategory`
Enum grouping commands for help/display purposes (e.g. `ADMINISTRATION`, `UTILITY`, `MUSIC`,
`CRYPTO`, and an unlisted/hidden group for owner/debug commands).
### `CommandExecutionResult`
The value returned by `execute(...)`. It encodes the outcome and any response payload:
- `success(...)` — normal success (optionally with a message/embed to send).
- `error(...)` / `warning(...)` — failure/advisory outcomes with a message.
- `none()` — nothing to send synchronously (the command handles its own async response, e.g. music).
- May also carry a follow-up **`Task`** so a command can install an interactive
[task](tasks.md) (e.g. buttons) as part of its result.
## The dispatch flow — `CommandProcessor`
`CommandProcessor` is constructed with the `CommandContext` and driven by `dispatch()`:
1. **Resolution** — match the invoked name against a `CommandContainer` in `CommandManager`.
2. **Validation**
- command enabled?
- guild-only / slash-only constraints satisfied?
- **permissions** — `canBeExecutedBy(...)` and, for admin commands, `needAdmin()` verified against
the bot owner (`NETNEUTRALITY_BOT_OWNER`) and the member's guild-admin status.
3. **Usage parsing (`checkUsage`)** — detect whether a subcommand or subcommand group is being
invoked and record the active subcommand on the context.
4. **Option parsing**
- **Slash** — Discord already parsed the arguments; map each `OptionMapping` to a
`SlashCommandOption`.
- **Message** — `MessageCommandContext` tokenizes the message and matches tokens against the
declared `OptionData` (resolving mentions to members/roles, parsing integers/booleans, etc.).
5. **Execution** — call `command.execute(context)`.
6. **Result handling** — act on the returned `CommandExecutionResult`: send success/error/warning
responses, or, if a `Task` was returned, register it with the [`TaskManager`](tasks.md).
Errors are surfaced through the exception hierarchy
([`CommandExecutionException`](utilities-and-bootstrap.md#exceptions), and
`GracefulContextError` for user-facing, non-logged failures).
## Registration lifecycle
```
App.startup()
├─ CommandManager.init() // reflective discovery → ConcurrentHashMap
└─ CommandManager.registerSlashCommands(jda) // push SlashCommandData to Discord
```
At runtime, an incoming command event becomes a `CommandContext` in the
[`EventsHandler`](events.md), which calls `new CommandProcessor(context).dispatch()`.
## Command catalog
Concrete commands live under `me.bobert.netneutrality.feature.*.command`, grouped by feature package:
| Command | Package | Type | Description |
|---|---|---|---|
| `BlacklistCommand` | `admin` | Slash | Manage the server's word blacklist. |
| `ReactionCommand` | `admin` | Slash | Manage emoji-reaction roles on the server. |
| `HelpCommand` | `basic` | Slash | List every command with its usage. |
| `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). |
| `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. |
| `ClearQueueCommand` / `ShuffleCommand` | `music` | Slash | Clear / shuffle the queue. |
| `JoinCommand` / `LeaveCommand` | `music` | Slash | Join / leave the voice channel. |
| `CurrentlyPlayingCommand` | `music` | Slash | Show the currently playing track. |
| `ResumeCommand` / `StopCommand` / `VolumeCommand` | `music` | Slash | Playback controls. |
| `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). |
### Representative commands
**`PingCommand` (simplest slash command)** — extends `AbstractCommand`, overrides `execute` to return
`CommandExecutionResult.success(...)` containing the JDA gateway ping. It illustrates the minimal
execution path (no options, synchronous response).
**`ConfigureCommand` (complex slash command)** — uses `registerCommandOptions` to declare several
`SubcommandData` (e.g. music-volume, error-logging). In `execute`, it switches on
`context.getSubcommand()` and reads typed values via `context.getOption(name, resolver)`, then
persists them through the [guild settings service](persistence.md#service-layer).
**`PlayCommand` (music)** — extends `MusicCommand` (so it enforces guild-only + music role/channel).
It declares a single required `STRING` query option, delegates loading to
[`MusicManager`](music.md), and returns `CommandExecutionResult.none()` because the audio subsystem
produces the user-facing response asynchronously (often installing a
[`LoadPlaylistTask`](tasks.md#concrete-task-catalog) with choice buttons).
+154
View File
@@ -0,0 +1,154 @@
# Contexts
**Packages:** `me.bobert.netneutrality.framework.context` (framework),
`me.bobert.netneutrality.framework.context` (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
API for reading who/where the event came from and for replying to it.
Contexts are created either by the [`EventsHandler`](events.md) when Discord triggers an event, or by
another context further down a flow (for example, a reply produces an [`AsyncContext`](#asynccontext)
that can be edited later). A context can be handled by one of three flows depending on its type and
the event it wraps: the [command processor](commands.md), the [task manager](tasks.md), or a simple
lambda. **The id of a context only matters for tasks** — the task manager uses `getId()` to locate
the `Task` that should run (see [tasks.md](tasks.md)).
## Type hierarchy
```
Context (interface)
├── InteractionContext (interface) ── reply/defer/ack via IReplyCallback + InteractionHook
├── NoMessageContext (interface) ── defaults for events without a triggering message
│ └── MessageContext (interface) ── adds a triggering Message (prefix commands, replies)
│
└── AbstractContext (abstract, implements Context) ── stores JDA/Guild/Channel/Member/User
├── CommandContext (abstract) ── command flow; holds CommandOptions + subcommand metadata
│ ├── SlashCommandContext
│ ├── MessageCommandContext
│ ├── MessageMenuContext
│ └── UserMenuContext
├── TaskContext (abstract) ── task flow; abstract getId(); contextData state map
│ ├── ButtonTaskContext
│ ├── EmojiTaskContext
│ ├── MessageTaskContext
│ └── ModalTaskContext
├── AsyncContext ── wraps the result of a previous reply (edit/follow-up)
└── MusicChannelContext ── synthetic context targeting a guild's music channel
```
## Core framework types
### `Context` (interface)
The root contract for every context. Notable methods:
- `getJDA()`, `getGuild()`, `getChannel()`, `getGuildUser()` (`Member`), `getDiscordUser()` (`User`)
- `reply(...)` — unified send; overloads support plain text, embeds, message components, an
`ephemeral` flag, and an async callback that receives an [`AsyncContext`](#asynccontext) for
follow-up edits.
- `defer(ephemeral, consumer)` — signals "thinking…" and later completes the response.
- `ack(ephemeral)` — minimal acknowledgement of the event.
- `getInteractionHook()` — the JDA `InteractionHook` when the underlying event is an interaction.
### `AbstractContext` (abstract class)
Base implementation that stores the common JDA entities (`JDA`, `Guild`, `MessageChannel`, `Member`,
`User`) and exposes them via the `Context` getters. Its constructor also sets the **Sentry user**
(id + effective name) so any error captured while handling this context is attributed to the right
Discord user:
```java
if (user != null) {
io.sentry.protocol.User sentryUser = new io.sentry.protocol.User();
sentryUser.setId(user.getId());
sentryUser.setUsername(user.getEffectiveName());
Sentry.setUser(sentryUser);
}
```
### `InteractionContext` (interface)
Behavioral mix-in for events that are JDA `Interaction`s (slash commands, buttons, modals, context
menus). It implements `reply`/`defer`/`ack` in terms of `IReplyCallback` and `InteractionHook`, so
these contexts can reply ephemerally, defer, and edit the original response.
### `NoMessageContext` / `MessageContext` (interfaces)
- `NoMessageContext` — provides sensible defaults for events that have **no** triggering message
(e.g. reactions, interactions).
- `MessageContext extends NoMessageContext` — adds access to a triggering `Message`, used by prefix
commands and by replies that drive step tasks.
## Command contexts
### `CommandContext`
Abstract base for the [command flow](commands.md). It carries the parsed
[`CommandOption`s](commands.md#unified-options) and the active subcommand / subcommand-group metadata,
and exposes type-safe option access, e.g. `getOption(name, resolver)`. Concrete subclasses adapt a
specific JDA event to this API.
| Class | Wraps | Notes |
|-------------------------|----------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------|
| `SlashCommandContext` | `SlashCommandInteractionEvent` | Options already parsed by Discord; maps `OptionMapping` → `SlashCommandOption`. Interaction-based replies. |
| `MessageCommandContext` | `MessageReceivedEvent` (prefix) | Contains the **manual parser** that turns the raw message string into `CommandOption`s (mentions, integers, booleans, …). Message-based replies. |
| `MessageMenuContext` | `MessageContextInteractionEvent` | "Apps → …" action on a message; exposes the target `Message`. |
| `UserMenuContext` | `UserContextInteractionEvent` | "Apps → …" action on a user; exposes the target `User`. |
## Task contexts
### `TaskContext`
Abstract base for the [task flow](tasks.md). Two things distinguish it:
- **`getId()` (abstract)** — the id used by `TaskManager` to find the matching `Task`. How the id is
derived depends on the concrete subtype (see the table below and [tasks.md](tasks.md#the-task-id)).
- **`contextData` map** — a small state bag used to carry values between steps of a multi-step flow.
| Class | Wraps | `getId()` derived from |
|----------------------|------------------------------------|------------------------------------------------------------|
| `ButtonTaskContext` | `ButtonInteractionEvent` | `getComponentId()` of the clicked button |
| `ModalTaskContext` | `ModalInteractionEvent` | modal `customId` |
| `EmojiTaskContext` | `MessageReactionAddEvent` | composite `guildId:messageId` |
| `MessageTaskContext` | `MessageReceivedEvent` (no prefix) | the id of the message being replied to (drives step tasks) |
## Reply mechanism & `AsyncContext`
### `AsyncContext`
`me.bobert.netneutrality.framework.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:
```java
context.reply("Initial response", async -> {
// 'async' is an AsyncContext for the message just sent
async.edit("Updated response");
});
```
Under the hood:
- **Interactions** — edits/follow-ups go through `hook.editOriginal()` / `hook.sendMessage()`.
- **Messages** — edits/follow-ups go through `message.editMessage()` / `message.reply()`.
This is what makes the command and task code path-agnostic: authors write against `Context` /
`AsyncContext` and never branch on "was this a slash command or a text message?".
### `MusicChannelContext`
A synthetic `AbstractContext` that targets a guild's configured **music channel** rather than the
channel an event came from. The music subsystem uses it to post now-playing / queue messages into the
correct channel even when playback state changes without a fresh user interaction. See
[music.md](music.md).
## End-to-end summary
| Event | Context | Flow | Async result wraps |
|---------------------------------|-------------------------|---------|--------------------|
| `SlashCommandInteractionEvent` | `SlashCommandContext` | Command | `InteractionHook` |
| `MessageReceivedEvent` (prefix) | `MessageCommandContext` | Command | `Message` |
| `ButtonInteractionEvent` | `ButtonTaskContext` | Task | `InteractionHook` |
| `MessageReactionAddEvent` | `EmojiTaskContext` | Task | `Message` |
| `ModalInteractionEvent` | `ModalTaskContext` | Task | `InteractionHook` |
+102
View File
@@ -0,0 +1,102 @@
# Event Handling & Routing
**Package:** `me.bobert.netneutrality.platform.event`
**Primary class:** `EventsHandler`
`EventsHandler` is the single JDA `EventListener` registered on the `JDA` instance
(`jda.addEventListener(new EventsHandler())` in `App.startup()`). It is the funnel through
which **every** gateway event passes. Its job is to translate a raw JDA event into a
[Context](contexts.md) and route that context to the correct subsystem.
## Listener registry
Listeners are held in a static, thread-safe map keyed by the JDA event class:
```java
private static final Map<Class<? extends Event>, Function<? extends Event, Context>> listeners
= new ConcurrentHashMap<>();
```
Each entry is a **factory function**: given an event of that type, it returns a `Context` (or `null`).
The map is populated once in a `static {}` block.
### `addListener` vs `addGenericListener`
- `addListener(Class<T>, Function<? super T, Context>)` — registers a factory that produces a
`Context` to be routed onward (command or task flow).
- `addGenericListener(Class<T>, Consumer<T>)` — registers a factory that runs a side-effecting
consumer and returns `null`, so nothing is routed onward. This is the **lambda flow** for simple
events.
## Registered events
| JDA event | Produces | Flow |
|---|---|---|
| `MessageReceivedEvent` (prefix match) | `MessageCommandContext` | Command |
| `MessageReceivedEvent` (no prefix) | `MessageTaskContext` | Task |
| `SlashCommandInteractionEvent` | `SlashCommandContext` | Command |
| `UserContextInteractionEvent` | `UserMenuContext` | Command |
| `MessageContextInteractionEvent` | `MessageMenuContext` | Command |
| `ButtonInteractionEvent` | `ButtonTaskContext` | Task |
| `ModalInteractionEvent` | `ModalTaskContext` | Task |
| `MessageReactionAddEvent` | `EmojiTaskContext` | Task |
| `GuildJoinEvent` | — (consumer) | Lambda |
| `GuildLeaveEvent` | — (consumer) | Lambda |
### Message routing detail
For `MessageReceivedEvent` the factory:
1. Ignores messages authored by bots and webhook messages (returns `null`).
2. Reads the configured prefix (`NETNEUTRALITY_BOT_PREFIX`, default `$`).
3. If the trimmed message starts with the prefix → **`MessageCommandContext`** (command flow).
4. Otherwise → **`MessageTaskContext`** (task flow). This lets ordinary messages participate in the
task system, e.g. as a reply that advances a [step task](tasks.md#33-step-tasks).
### Guild lifecycle (lambda flow)
- `GuildJoinEvent` — resolves `GuildService.get(guild)`; if debug logging is enabled for that guild,
sends a notice to the debug channel.
- `GuildLeaveEvent` — `GuildService.get(guild).deleteGuild()` removes the guild's stored data.
## Dispatch algorithm
```java
@Override
public void onEvent(@Nonnull GenericEvent event) {
handleEvent((Event) event);
}
private void handleEvent(Event event) {
Class<?> eventClass = event.getClass();
while (eventClass != null) {
if (listeners.containsKey(eventClass)) {
Context context = listeners.get(eventClass).apply(event);
if (context instanceof CommandContext c) handleCommand(c);
if (context instanceof TaskContext t) handleTask(t);
break;
}
eventClass = eventClass.getSuperclass(); // walk up the class hierarchy
}
}
```
Key points:
- **Superclass walk** — if the exact event class is not registered, the handler walks up the
superclass chain until it finds a match. This means a listener registered for a base event type
also catches subclasses.
- **Routing by context type** — the resulting object is routed purely by its runtime type:
a `CommandContext` goes to `CommandProcessor.dispatch()`, a `TaskContext` goes to
`TaskManager.getInstance().handle(context)`.
- **`null` contexts** (bot messages, consumer-only events) are simply not routed anywhere.
## Extending the event system
To handle a new event type:
1. Add an `addListener(SomeEvent.class, SomeContext::new)` (or `addGenericListener(...)` for a
consumer-only side effect) to the `static {}` block.
2. If it should run the command or task flow, make the produced context extend
[`CommandContext`](contexts.md#commandcontext) or [`TaskContext`](contexts.md#taskcontext)
respectively.
+170
View File
@@ -0,0 +1,170 @@
# Music
**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. 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 |
|---|---|
| `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` | 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. |
`MusicServiceImpl` keeps a `Map<String, MusicManager>` of managers keyed by guild id, so each guild
has its own link, scheduler, and queue.
## Startup
`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)
```
/play query
│
▼
MusicManager.play(query, ctx)
│ SourceFinder identifies the source and builds the identifier
▼
link.loadItem(identifier).subscribe(new TrackLoadingHandler(...))
│
├─ 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 nothing is playing, send createOrUpdatePlayer().setTrack(...); else append
│
▼
Lavalink node decodes and streams the audio to the Discord voice gateway
│
▼ (track ends)
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. 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** — 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.
## Running the node
`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 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
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
[commands.md](commands.md#command-catalog).
+133
View File
@@ -0,0 +1,133 @@
# Persistence & Database
**Packages:** `me.bobert.netneutrality.platform.persistence` (session/migration),
`me.bobert.netneutrality.db` (entities, repositories, services).
**Resources:** `hibernate.cfg.xml`, `db/migration/V1__initial_schema.sql`,
`db/migration/V2__task_type_id.sql`.
## Stack
| Concern | Technology |
|------------|-------------------------------------------------------------------------------|
| ORM | Hibernate 7.1.9 (Jakarta JPA) |
| Engine | MariaDB 11 (per `docker-compose.yml`), MySQL Connector/J 9.7.0 driver, InnoDB |
| Pooling | HikariCP (via `hibernate-hikaricp`) |
| Migrations | Flyway 11.20.3 (`flyway-core` + `flyway-mysql`) |
Configuration is externalized via `Settings` (env vars / `bot.properties`):
`MYSQL_HOST`, `MYSQL_PORT`, `MYSQL_USER`, `MYSQL_PASSWORD`, `MYSQL_DATABASE`.
## Session & migration lifecycle
Both are wired in at [startup](architecture.md#startup-lifecycle):
1. **`SessionManager.getSessionFactory()`** — lazily builds the Hibernate `SessionFactory`. It reads
the static config from `hibernate.cfg.xml` (dialect `MySQLDialect`, driver
`com.mysql.cj.jdbc.Driver`, the Hikari connection provider, and the mapped entity list) and
overrides the JDBC url/user/password from environment variables:
```java
settings.put(Environment.JAKARTA_JDBC_URL,
String.format("jdbc:mysql://%s:%s/%s", dbHost, dbPort, dbName));
settings.put(Environment.JAKARTA_JDBC_USER, dbUser);
settings.put(Environment.JAKARTA_JDBC_PASSWORD, dbPass);
```
2. **`DatabaseMigration.migrate()`** — runs `flyway.migrate()` against the same database, applying the
versioned scripts under `src/main/resources/db/migration/` (starting with
`V1__initial_schema.sql`). The Flyway Maven plugin (`flyway-dev.sh`/`.ps1`) is available for local
development, using `FLYWAY_URL` / `FLYWAY_USER` / `FLYWAY_PASSWORD`.
3. **`SessionManager.shutdown()`** — destroys the service registry / releases the pool on shutdown.
Repositories obtain sessions through the factory; transactional access uses helpers in
[`JpaUtils`](utilities-and-bootstrap.md) (open session, run in a transaction, close).
## Entity catalog
Entities live in `me.bobert.netneutrality.data.entity` 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 |
|-----------------|------------------|--------------------------|--------------------------------------------------------------------------------------------------------|
| `OGuild` | `guilds` | `guild_id` (String) | Central entity; one-to-many with settings, blacklist, log messages, logging. |
| `OGuildSetting` | `guild_settings` | `id` (auto int) | Many-to-one `OGuild`; key/value guild settings. |
| `OBlacklist` | `blacklist` | `id` (auto int) | Many-to-one `OGuild`; blacklisted words. |
| `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`, `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
```
┌───────────────┐
│ OGuild │ (guilds.guild_id)
└───────┬───────┘
┌───────────┬───────┼────────────┬─────────────┐
▼ ▼ ▼ ▼ ▼
OGuildSetting OBlacklist OLogging OLogMessage (…)
guild_settings blacklist logging log_message
(FK guild) (FK guild) (PK guild+ (FK guild)
channel)
OTask (tasks) OWebUser (web_users) ── standalone
```
`OTask` and `OWebUser` are not tied to the guild graph.
## Repository layer
A lightweight generic repository pattern is used.
- **`Repository<O>`** (interface) — generic CRUD: `findAll()`, `findById(String)`, `save(O)` (via
`session.merge()`), `deleteBy(String)`.
- **`BlacklistRepository`** — find-by-guild and case-insensitive word lookups.
- **`GuildRepository`** — CRUD for guilds.
- **`GuildSettingRepository`** — set/get setting values; removes rows that equal the default value.
- **`MessageRepository`** — message logging / edit history.
- **`TaskRepository`** — CRUD for `OTask` rows (used by the task persistence flow).
## Service layer
Services wrap repositories with business logic and **caching**.
- **`GuildService`** — gateway for guild operations; caches one `GuildService` per guild id
(`ConcurrentHashMap`); `findOrCreate` guarantees a guild row exists; `deleteGuild()` on leave;
`isDebugEnabled()` / debug-channel helpers used by [`EventsHandler`](events.md) and
[`LogHandler`](utilities-and-bootstrap.md#loghandler).
- **`BlacklistService`** — maintains a `blacklistCache` (guild id → set of words) for fast message
filtering.
- **`GuildSettingService`** — reads/writes guild settings backed by [`GSettings`](#gsettings); caches
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, 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
`GSettings` is an **enum** of the available per-guild configuration keys and their defaults:
| Setting | Default |
|-------------------------|---------|
| `MUSIC_ROLE` | (none) |
| `MUSIC_CHANNEL` | (none) |
| `MUSIC_VOLUME` | `30` |
| `MUSIC_MAXPLAYLISTSIZE` | `20` |
`GuildSettingService` looks up an `OGuildSetting` row by the enum `name()`; if no row exists for a
guild/setting, the enum's `defaultValue` is used. This is how `MusicCommand` obtains the guild's music
role/channel and how `ConfigureCommand` persists changes.
## Schema (`V1__initial_schema.sql`)
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 `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.
+291
View File
@@ -0,0 +1,291 @@
# Tasks
**Packages:** `me.bobert.netneutrality.framework.task` (framework),
`me.bobert.netneutrality.feature.*.task` (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
[command](commands.md) which is looked up by name.
The original motivation was **Discord interactions** (buttons). When a user clicks a button, Discord
sends a `ButtonInteractionEvent` whose id is `getComponentId()`. A `Task` with that same id was
inserted earlier (typically by the command that created the button), so the `TaskManager` can find
the task and execute it. The system has since grown to cover several task subtypes:
| Subtype | Trigger | Id source |
|----------------------------------------------|------------------------------------|----------------------------------------|
| [Button task](#button-emoji-and-modal-tasks) | `ButtonInteractionEvent` | button `componentId` |
| [Modal task](#button-emoji-and-modal-tasks) | `ModalInteractionEvent` | modal `customId` |
| [Emoji task](#button-emoji-and-modal-tasks) | `MessageReactionAddEvent` | composite `guildId:messageId` |
| [Step task](#33-step-tasks) | replied-to message | last-message id (assigned dynamically) |
| [Built-in task](#32-built-in-tasks) | button with a hardcoded id | hardcoded id |
| [Persistable task](#31-persistable-tasks) | any of the above, restored from DB | as stored |
| [Scheduled task](#34-scheduled-tasks) | timer | n/a (time-based, not id-based) |
## The `Task` id
The id is the **primary lookup key** used by `TaskManager`. How it is derived depends on the subtype:
- **Buttons** → the button's `componentId`.
- **Modals** → the modal's `customId`.
- **Emoji reactions** → a composite `guildId:messageId` (a reaction is tied to a specific message).
- **Step tasks** → start life with a random UUID, then re-key themselves to the DM/last-message id
once that message exists (see [step tasks](#33-step-tasks)).
## `Task` contract & lifecycle
`Task` (interface) defines `execute(TaskContext)` and returns a **`Lifecycle`** value that tells the
`TaskManager` what to do with the task afterwards:
| Lifecycle | Meaning |
|----------------------------|-------------------------------------------------------------------|
| `BUILTIN` | Task is static and stays registered indefinitely. |
| `KEEP` | Keep the task in memory after execution (can run again). |
| `REMOVE_SELF` | Remove this task after execution (one-shot). |
| `REMOVE_SELF_AND_CHILDREN` | Remove this task and all of its [child tasks](#trait-interfaces). |
### Task base classes (`core.tasks.impl`)
```
AbstractTask (implements Task)
├── ButtonTask ── executed from a ButtonTaskContext
├── EmojiTask ── executed from an EmojiTaskContext
├── ModalTask ── executed from a ModalTaskContext
├── GenericCustomIdTask ── task keyed by an explicit custom id
├── GenericRandomIdTask ── task keyed by a generated random id
├── AbstractBuiltInTask ── base for hardcoded, reflectively-loaded tasks
├── AbstractPersistableTask ── base for DB-backed tasks (implements PersistableTask)
└── AbstractStepTask ── base for multi-step Q&A flows (implements StepTask)
```
## `TaskManager`
`me.bobert.netneutrality.framework.task.TaskManager` is the registry, lookup, and execution
coordinator.
### Registration — `insert`
- Adds the task to the active map keyed by its id.
- If the task is a [`PersistableTask`](#31-persistable-tasks), it is also **saved to the database**
via [`TaskService`](persistence.md#service-layer).
- If the task is a [`StepTask`](#33-step-tasks) without a message id yet, it is held in a separate
`pendingTasks` collection until its id is assigned.
### Execution — `handle(TaskContext)`
1. Look up the task by `context.getId()`.
2. For ordinary (non-built-in, non-persistable) tasks, acquire a **`ReentrantLock`** so the same task
cannot be executed concurrently (prevents double-clicks / races). Tasks that share a
[parent](#trait-interfaces) share the same lock id.
3. Call `task.execute(context)`.
4. Apply the returned `Lifecycle` (keep / remove / remove-with-children); persistable tasks removed
here are also deleted from the DB.
5. If the task is [`Deletable`](#trait-interfaces), delete the original message/interaction.
6. Unless the task is [`NotAcknowledgeable`](#trait-interfaces), acknowledge the interaction.
### Startup — `init`
Called from `App.startup()`:
1. Reflectively load all [`BuiltInTask`](#32-built-in-tasks) implementations.
2. Reflectively load all [`ScheduledTask`](#34-scheduled-tasks) implementations and register them
with [`ScheduledTaskManager`](threading.md).
3. Load persistable tasks from the database (via `TaskService`) and reconstruct them (see
[persistence rules](#31-persistable-tasks)).
## Button, emoji and modal tasks
These are the "interaction" tasks and the original purpose of the subsystem:
- **`ButtonTask`** — executed when a button whose `componentId` matches the task id is clicked. The
matching context is a [`ButtonTaskContext`](contexts.md#task-contexts).
- **`ModalTask`** — executed when a modal is submitted; matched by the modal `customId`. A modal is
typically opened by another interaction (e.g. `ReportIssueTask` opens a title/description modal).
- **`EmojiTask`** — executed when a reaction is added to a specific message; matched by the composite
`guildId:messageId` id.
The generic helpers `GenericCustomIdTask` / `GenericRandomIdTask` let a command install an ad-hoc,
function-backed task under a chosen or generated id (e.g. the generic `AcceptButton` / `CancelButton`
under `tasks/generic`).
## 3.1 Persistable tasks
**Classes:** `PersistableTask` (interface), `AbstractPersistableTask`,
`core.tasks.persistence.PersistableTaskRegistry`; entity
[`OTask`](persistence.md#entity-catalog).
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 contract
```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();
}
```
- **`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.
### `PersistableTaskRegistry` — discovery and restore
`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.
`create(typeId, payload)` then produces a **fresh** instance per row:
```java
PersistableTask task = ReflectionHelper.instantiate(type, context);
task.restore(payload);
```
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`.
> **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.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`.
Built-in tasks are hardcoded tasks with hardcoded ids, conceptually similar to commands. They are
loaded reflectively at startup (in `TaskManager.init()`) and stay registered for the lifetime of the
bot (`Lifecycle.BUILTIN`).
A button that triggers a built-in task is created from its class:
```java
Button button = BuiltInTask.createButton(SomeBuiltInTask.class);
```
This produces a JDA `Button` whose component id equals the built-in task's hardcoded id, so clicking
it routes straight to that task. Examples: the help buttons `BotInfo`, `CommandHelp`, `ReportBug`
(`tasks/help`).
## 3.3 Step tasks
**Classes:** `StepTask` (interface), `AbstractStepTask`.
Step tasks ask the user a series of questions and run logic based on each answer. Because Discord has
no native "conversation" primitive, the abstraction tracks the **last message id** and only runs the
next step when *that* message is replied to.
Flow:
1. A step task is created (e.g. by a setup command) and sends a prompt message; initially it is keyed
by a random UUID and held in `pendingTasks`.
2. Once the prompt message exists, the task **re-keys itself to that message's id** and moves into the
active `tasks` map.
3. When the user replies to that message, the incoming `MessageReceivedEvent` becomes a
[`MessageTaskContext`](contexts.md#task-contexts) whose id is the replied-to message id — so
`TaskManager` finds the step task.
4. `executeStep` validates/processes the answer and returns a `StepResult`:
success, failure, or **next step** (which installs the following step task).
`AbstractStepTask` provides answer validation keyed to a step "type" (e.g. a role id, an emoji, …).
## 3.4 Scheduled tasks
**Classes:** `ScheduledTask`, coordinated by
[`ScheduledTaskManager`](threading.md#scheduledtaskmanager).
Scheduled tasks are time-based background jobs (as opposed to id-based interaction tasks). They are
loaded reflectively at startup and registered with `ScheduledTaskManager`, which schedules them at a
fixed interval derived from the task's `TimeFrame` (e.g. minutely / hourly / daily / custom) and
cancels them all on shutdown.
> **Status:** the scheduled-task abstraction is intended to eventually replace the ad-hoc
> [`ThreadManager`](threading.md) threads and is still evolving. The concrete scheduled tasks that
> exist today are listed in the [catalog](#concrete-task-catalog).
## Trait interfaces
Tasks can opt into extra behavior by implementing marker/behavior interfaces in
`core.tasks.trait`:
| Trait | Effect |
|----------------------|------------------------------------------------------------------------------------------|
| `Deletable` | After successful execution, `TaskManager` deletes the original message/interaction. |
| `NotAcknowledgeable` | Suppresses the default acknowledgement (the task sends its own response). |
| `WithButton` | The task can render itself as a JDA `Button` (`createButton()`). |
| `WithChildren` | The task tracks child tasks and can clean them up together (`REMOVE_SELF_AND_CHILDREN`). |
| `WithParent` | Links a child task to a parent; children share the parent's execution lock. |
## Concrete task catalog
| Task | Package | Base type | Description |
|-----------------------------|-------------|------------------------------|--------------------------------------------------------------|
| `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. |
| `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. |
| `LoadPlaylistTask` | `music` | `ButtonTask` (with children) | Present playlist/search results as choice buttons. |
| `LoadTrackFromPlaylistTask` | `music` | `ButtonTask` | Load one specific track from the results. |
| `EmojiRoleFirstStep` | `reaction` | `AbstractStepTask` | Ask for the target role id. |
| `EmojiRoleSecondStep` | `reaction` | `AbstractStepTask` | Ask for the emoji to map. |
| `EmojiRoleModify` | `reaction` | `AbstractStepTask` | Modify an existing emoji-role mapping. |
| `EmojiRoleRemoveStep` | `reaction` | `AbstractStepTask` | Remove an emoji-role mapping. |
| `MusicManagerCleanup` | `scheduled` | `ScheduledTask` | Evict unused per-guild music managers. |
| `MusicPlayerAutoDisconnect` | `scheduled` | `ScheduledTask` | Disconnect from voice when idle. |
| `TaskCleanup` | `scheduled` | `ScheduledTask` | Periodically remove expired non-persistable tasks. |
### Worked example — emoji-role setup (step → persistable)
1. `EmojiRoleFirstStep` sends a prompt: "reply with the role id"; `AbstractStepTask` validates it is a
real role in the guild.
2. `executeStep` returns `StepResult.next(new EmojiRoleSecondStep(...))`, carrying the chosen role.
3. `EmojiRoleSecondStep` asks for the emoji; on a valid answer it creates an
`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
[`PersistableTaskRegistry`](#persistabletaskregistry--discovery-and-restore).
### Worked example — GitLab 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`.
### Worked example — music results (parent/children)
1. `LoadPlaylistTask` posts a message with a button per result (children:
`LoadTrackFromPlaylistTask` / `AcceptButton`).
2. All children share the parent's lock id ([`WithParent`](#trait-interfaces)), so simultaneous
clicks are serialized.
3. Selecting a result can return `REMOVE_SELF_AND_CHILDREN` to tear down the whole interaction.
+46
View File
@@ -0,0 +1,46 @@
# Threading & Scheduling
**Package:** `me.bobert.netneutrality.platform.concurrency`
This subsystem centralizes asynchronous execution and recurring jobs.
## Classes
| Class | Responsibility |
|---|---|
| `ThreadManager` | Static registry of `ExecutorService` instances shared across the bot. |
| `BaseThreadFactory` | Custom `ThreadFactory` giving threads meaningful names and an uncaught-exception handler. |
| `ScheduledTaskManager` | Schedules and cancels recurring [scheduled tasks](tasks.md#34-scheduled-tasks). |
## `ThreadManager`
- `getScheduledExecutor()` — a `ScheduledThreadPool` sized to the number of CPU cores.
- `getExecutor(name)` — returns (and caches) a named thread pool for ad-hoc async work.
- `shutdownExecutor()` — shuts the executors down cleanly; called from
[`App.shutdown()`](architecture.md#shutdown-lifecycle).
## `BaseThreadFactory`
Names created threads (e.g. `web-api-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.
## `ScheduledTaskManager`
Coordinates [`ScheduledTask`](tasks.md#34-scheduled-tasks) instances (discovered reflectively at
startup by [`TaskManager.init()`](tasks.md#taskmanager)):
- `schedule(ScheduledTask task)` — schedules the task at a fixed delay/interval derived from its
`TimeFrame` (minutely / hourly / daily / custom), using the shared scheduled executor.
- `cancelAll()` — cancels every scheduled task; called first in
[`App.shutdown()`](architecture.md#shutdown-lifecycle).
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
> threads created via `ThreadManager`, giving a single, uniform way to express recurring background
> work. It is still evolving (see [tasks.md §3.4](tasks.md#34-scheduled-tasks)).
+164
View File
@@ -0,0 +1,164 @@
# Utilities, Bootstrap, Logging & Exceptions
This document covers the cross-cutting glue of the bot: the entry points, configuration, logging /
error tracking, the shared utility helpers, and the custom exception hierarchy.
## Bootstrap
**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()`. |
| `core.BotState` | Work-in-progress global state helper: exposes a `SAFE` flag and `dump()`, which serializes per-guild/music state to `state.json`. Bound to `GuildServiceProvider` / `MusicService` during bootstrap. |
The full startup and shutdown sequences are documented in
[architecture.md](architecture.md#startup-lifecycle).
### `ApplicationContext` + `Lifecycle`
Domain services are constructed once in `App.buildContext()`, registered with the context,
and resolved later by type:
- `register(component)` — indexes the concrete class; if the component implements `Lifecycle` it
participates in ordered start/stop.
- `get(Class<T>)` — returns the first registered instance assignable to `T` (interfaces work).
- `start()` — sorts `Lifecycle` components by ascending `priority()` and starts them.
- `stop()` — stops them in the exact reverse order; individual failures are logged and swallowed so
remaining components still tear down.
Constructor injection for reflectively loaded commands/tasks is handled by
[`ReflectionHelper`](#reflectionhelper): the richest public constructor whose parameter types all
resolve via `context.get(type)` is preferred; otherwise the no-arg constructor is used.
**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` |
There are **no** `getInstance()` / `instance()` shims on the managers above. The only intentional
static access points left are:
- `Settings.getInstance()` — early bootstrap only (`main()`, Sentry, token validation) before the
context exists; the same instance is registered as `SettingsProvider` for everything else.
- `App.getInstance()` — application root used by a couple of shutdown entry points
(`ShutdownCommand`). Prefer constructor injection of the needed collaborators where possible.
- `JpaUtils.bind(SessionProvider)` / `LogHandler.bind(GuildServiceProvider)` /
`ChannelMessagePurger.bind(ExecutorProvider)` — single well-defined static bind points for deep
utility graphs that are not constructor-injected (repositories, static log helpers).
## Configuration
### `Settings` / `SettingsProvider`
**Class:** `me.bobert.netneutrality.platform.config.Settings` implements
`me.bobert.netneutrality.platform.config.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).
- **Early bootstrap:** `Settings.getInstance()` (process-wide, created once before the context).
- **Runtime injection:** depend on `SettingsProvider` and let `ApplicationContext` supply the same
instance. Prefer this path in commands, tasks, handlers, and services.
- `Settings.getVersion()` / `Settings.getDiscordInvite()` — thin static helpers used by the banner /
invite strings; they delegate to the bootstrap instance.
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 `$`). |
| `NETNEUTRALITY_BOT_OWNER` | Bot owner user id (used for owner-only commands / permissions). |
| `NETNEUTRALITY_BOT_INVITE` | Invite link surfaced by help/info. |
| `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_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.*`
| Class | Purpose |
|---|---|
| `Settings` | Configuration (see above). |
| `ReflectionHelper` | Classpath scanning / reflective instantiation (see below). |
| `ContextUtils` | Boilerplate helpers for building/handling [contexts](contexts.md). |
| `GuildUtils` | Guild-related helpers (lookups, permission checks). |
| `JpaUtils` | Session/transaction helpers for the [persistence](persistence.md) layer. Bound once to a `SessionProvider` during bootstrap (`JpaUtils.bind`). |
| `MusicUtils` | Voice-channel connectivity and DJ/permission checks for [music](music.md). Callers pass the guild `MusicManager` (no static music lookup). |
| `MiscUtils` | Assorted small helpers. |
| `ChannelMessagePurger` | Bulk message deletion (used by the purge command). Bound once to an `ExecutorProvider` during bootstrap. |
| `MessageFunction` | Standardized message/embed formatting. Help text builders take a `CommandRegistry` rather than a static command manager. |
| `UserFunction` | Standardized user/string formatting. |
### `ReflectionHelper`
Built on the `org.reflections` library, `ReflectionHelper` discovers and instantiates implementation
classes in specific packages. It is the mechanism behind the bot's "drop a class in the right package
and it's picked up" model, used to auto-load:
- [`Command`](commands.md) implementations (`CommandManager` / `CommandRegistry.start()`),
- [`BuiltInTask`](tasks.md#32-built-in-tasks) and [`ScheduledTask`](tasks.md#34-scheduled-tasks)
implementations (`TaskManager.start()`).
When given an `ApplicationContext`, `newInstance(Class, ApplicationContext)` selects the public
constructor with the most parameters that can all be resolved via `context.get(parameterType)`. If
no such constructor exists it falls back to the no-arg constructor. That is how music/admin/react
commands receive `MusicService`, `GuildServiceProvider`, `TaskManager`, etc. without static
singletons.
## Logging & error tracking
### `LogHandler`
**Class:** `me.bobert.netneutrality.platform.logging.LogHandler`.
`LogHandler` is the unified logging/error entry point. It routes messages and exceptions to:
1. **Log4j2** (configured by `log4j2.properties`; SLF4J is bridged via `log4j-slf4j2-impl`),
2. **Sentry** (when `NETNEUTRALITY_SENTRY_DSN` is configured), and
3. optionally a per-guild **Discord debug channel** (e.g. `sendMessageToDebugChannel(guild, msg)`),
gated by [`GuildService.isDebugEnabled()`](persistence.md#service-layer) /
[`OLogging`](persistence.md#entity-catalog) configuration.
The Sentry **user** context is attached automatically when a
[`AbstractContext`](contexts.md#abstractcontext-abstract-class) is created, so captured errors are
attributed to the Discord user that triggered them.
## Exception hierarchy
**Package:** `me.bobert.netneutrality.exceptions`
```
RuntimeException
└── ContextException ── carries a Context (guild/channel/user)
├── CommandExecutionException ── thrown during command processing
└── TaskExecutionException ── thrown during task execution
GracefulContextError ── user-facing, non-logged failure → ephemeral reply
ExecutorException ── Thread.UncaughtExceptionHandler → routes crashes to LogHandler
```
- **`ContextException`** — base runtime exception that carries the [`Context`](contexts.md) in which
it occurred, so downstream handling can respond in the right channel and attribute the error.
- **`CommandExecutionException`** / **`TaskExecutionException`** — specializations thrown by the
[command](commands.md) and [task](tasks.md) flows respectively.
- **`GracefulContextError`** — signals an expected, user-facing problem (e.g. bad input); when caught
it produces an ephemeral message to the user instead of a full error log/Sentry event.
- **`ExecutorException`** — an `UncaughtExceptionHandler` installed by
[`BaseThreadFactory`](threading.md#basethreadfactory) so exceptions escaping worker threads are still
captured by [`LogHandler`](#loghandler).
+52
View File
@@ -0,0 +1,52 @@
# 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).
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.
## 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:
- 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.
On `stop()` the server and its executor are shut down gracefully.
## Handler model
`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.
| Handler | Base path | Endpoints |
|---|---|---|
| `RootHandler` | `/` | Simple HTML "OK" landing response. |
| `GuildHandler` | `/guilds` | `/guilds/owner` — guild ids + owner ids (JSON); `/guilds/channels/{guildId}` — text channels (JSON); `/guilds/roles/{guildId}` — roles (JSON). |
| `ActionHandler` | `/actions` | `/actions/status` — JDA health + gateway ping; `/actions/shutdown` — graceful shutdown. |
| `SettingsHandler` | `/settings` | `/settings/catalog` — every known setting with its default value and description; `/settings/current/{guildId}` — current guild settings; `/settings/reload/{guildId}` — invalidate & reload the settings cache. |
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).
## 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.
## 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.
+42
View File
@@ -0,0 +1,42 @@
param (
[ValidateSet("migrate", "info", "validate", "clean", "baseline", "repair")]
[string]$Command = "info"
)
$envFile = ".env"
if (-not (Test-Path $envFile)) {
Write-Error ".env file not found in the current directory."
exit 1
}
Get-Content $envFile | ForEach-Object {
if ($_ -match "^\s*([A-Za-z_][A-Za-z0-9_]*)=(.*)$") {
$name = $matches[1]
$value = $matches[2]
[System.Environment]::SetEnvironmentVariable($name, $value)
}
}
$MYSQL_HOST = $env:MYSQL_HOST
$MYSQL_PORT = $env:MYSQL_PORT
$MYSQL_DATABASE = $env:MYSQL_DATABASE
if (-not $MYSQL_HOST -or -not $MYSQL_PORT -or -not $MYSQL_DATABASE) {
Write-Error "MYSQL_HOST, MYSQL_PORT, or MYSQL_DATABASE missing from .env"
exit 1
}
$env:FLYWAY_URL = "jdbc:mysql://${MYSQL_HOST}:${MYSQL_PORT}/${MYSQL_DATABASE}"
$env:FLYWAY_USER = $env:MYSQL_USER
$env:FLYWAY_PASSWORD = $env:MYSQL_PASSWORD
Write-Host "Ensuring database '$MYSQL_DATABASE' exists..."
$mysqlCommand = "CREATE DATABASE IF NOT EXISTS $MYSQL_DATABASE CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
& mysql --host=$MYSQL_HOST --port=$MYSQL_PORT --user=$env:MYSQL_USER --password=$env:MYSQL_PASSWORD -e $mysqlCommand
if ($LASTEXITCODE -ne 0) {
Write-Error "Failed to create database or connect to MySQL. Check your credentials or server status."
exit 1
}
.\mvnw flyway:$Command
+33
View File
@@ -0,0 +1,33 @@
#!/bin/bash
set -e
COMMAND=${1:-info}
ENV_FILE=".env"
if [[ ! -f "$ENV_FILE" ]]; then
echo ".env file not found in the current directory."
exit 1
fi
# Export variables from .env
export $(grep -E '^[A-Za-z_][A-Za-z0-9_]*=' "$ENV_FILE" | xargs)
if [[ -z "$MYSQL_HOST" || -z "$MYSQL_PORT" || -z "$MYSQL_DATABASE" ]]; then
echo "MYSQL_HOST, MYSQL_PORT, or MYSQL_DATABASE missing from .env"
exit 1
fi
export FLYWAY_URL="jdbc:mysql://${MYSQL_HOST}:${MYSQL_PORT}/${MYSQL_DATABASE}"
export FLYWAY_USER="$MYSQL_USER"
export FLYWAY_PASSWORD="$MYSQL_PASSWORD"
echo "Ensuring database '$MYSQL_DATABASE' exists..."
mysql -h"$MYSQL_HOST" -P"$MYSQL_PORT" -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" \
-e "CREATE DATABASE IF NOT EXISTS \`$MYSQL_DATABASE\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
if [[ $? -ne 0 ]]; then
echo "Failed to create database or connect to MySQL. Check your credentials or server status."
exit 1
fi
./mvnw flyway:"$COMMAND"
+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
Vendored
+227 -240
View File
@@ -19,290 +19,277 @@
# ----------------------------------------------------------------------------
# ----------------------------------------------------------------------------
# Apache Maven Wrapper startup batch script, version 3.2.0
#
# Required ENV vars:
# ------------------
# JAVA_HOME - location of a JDK home dir
# Apache Maven Wrapper startup batch script, version 3.3.4
#
# Optional ENV vars
# -----------------
# MAVEN_OPTS - parameters passed to the Java VM when running Maven
# e.g. to debug Maven itself, use
# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000
# MAVEN_SKIP_RC - flag to disable loading of mavenrc files
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
# MVNW_REPOURL - repo url base for downloading maven distribution
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
# ----------------------------------------------------------------------------
if [ -z "$MAVEN_SKIP_RC" ] ; then
set -euf
[ "${MVNW_VERBOSE-}" != debug ] || set -x
if [ -f /usr/local/etc/mavenrc ] ; then
. /usr/local/etc/mavenrc
fi
if [ -f /etc/mavenrc ] ; then
. /etc/mavenrc
fi
if [ -f "$HOME/.mavenrc" ] ; then
. "$HOME/.mavenrc"
fi
fi
# OS specific support. $var _must_ be set to either true or false.
cygwin=false;
darwin=false;
mingw=false
# OS specific support.
native_path() { printf %s\\n "$1"; }
case "$(uname)" in
CYGWIN*) cygwin=true ;;
MINGW*) mingw=true;;
Darwin*) darwin=true
# Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home
# See https://developer.apple.com/library/mac/qa/qa1170/_index.html
if [ -z "$JAVA_HOME" ]; then
if [ -x "/usr/libexec/java_home" ]; then
JAVA_HOME="$(/usr/libexec/java_home)"; export JAVA_HOME
else
JAVA_HOME="/Library/Java/Home"; export JAVA_HOME
fi
fi
CYGWIN* | MINGW*)
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
native_path() { cygpath --path --windows "$1"; }
;;
esac
if [ -z "$JAVA_HOME" ] ; then
if [ -r /etc/gentoo-release ] ; then
JAVA_HOME=$(java-config --jre-home)
fi
fi
# For Cygwin, ensure paths are in UNIX format before anything is touched
if $cygwin ; then
[ -n "$JAVA_HOME" ] &&
JAVA_HOME=$(cygpath --unix "$JAVA_HOME")
[ -n "$CLASSPATH" ] &&
CLASSPATH=$(cygpath --path --unix "$CLASSPATH")
fi
# For Mingw, ensure paths are in UNIX format before anything is touched
if $mingw ; then
[ -n "$JAVA_HOME" ] && [ -d "$JAVA_HOME" ] &&
JAVA_HOME="$(cd "$JAVA_HOME" || (echo "cannot cd into $JAVA_HOME."; exit 1); pwd)"
fi
if [ -z "$JAVA_HOME" ]; then
javaExecutable="$(which javac)"
if [ -n "$javaExecutable" ] && ! [ "$(expr "\"$javaExecutable\"" : '\([^ ]*\)')" = "no" ]; then
# readlink(1) is not available as standard on Solaris 10.
readLink=$(which readlink)
if [ ! "$(expr "$readLink" : '\([^ ]*\)')" = "no" ]; then
if $darwin ; then
javaHome="$(dirname "\"$javaExecutable\"")"
javaExecutable="$(cd "\"$javaHome\"" && pwd -P)/javac"
else
javaExecutable="$(readlink -f "\"$javaExecutable\"")"
fi
javaHome="$(dirname "\"$javaExecutable\"")"
javaHome=$(expr "$javaHome" : '\(.*\)/bin')
JAVA_HOME="$javaHome"
export JAVA_HOME
fi
fi
fi
if [ -z "$JAVACMD" ] ; then
if [ -n "$JAVA_HOME" ] ; then
# set JAVACMD and JAVACCMD
set_java_home() {
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
if [ -n "${JAVA_HOME-}" ]; then
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
JAVACCMD="$JAVA_HOME/jre/sh/javac"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
else
JAVACMD="$(\unset -f command 2>/dev/null; \command -v java)"
fi
fi
JAVACCMD="$JAVA_HOME/bin/javac"
if [ ! -x "$JAVACMD" ] ; then
echo "Error: JAVA_HOME is not defined correctly." >&2
echo " We cannot execute $JAVACMD" >&2
exit 1
fi
if [ -z "$JAVA_HOME" ] ; then
echo "Warning: JAVA_HOME environment variable is not set."
fi
# traverses directory structure from process work directory to filesystem root
# first directory with .mvn subdirectory is considered project base directory
find_maven_basedir() {
if [ -z "$1" ]
then
echo "Path not specified to find_maven_basedir"
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
return 1
fi
fi
else
JAVACMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v java
)" || :
JAVACCMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v javac
)" || :
basedir="$1"
wdir="$1"
while [ "$wdir" != '/' ] ; do
if [ -d "$wdir"/.mvn ] ; then
basedir=$wdir
break
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
return 1
fi
# workaround for JBEAP-8937 (on Solaris 10/Sparc)
if [ -d "${wdir}" ]; then
wdir=$(cd "$wdir/.." || exit 1; pwd)
fi
# end of workaround
}
# hash string like Java String::hashCode
hash_string() {
str="${1:-}" h=0
while [ -n "$str" ]; do
char="${str%"${str#?}"}"
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
str="${str#?}"
done
printf '%s' "$(cd "$basedir" || exit 1; pwd)"
printf %x\\n $h
}
# concatenates all lines of a file
concat_lines() {
if [ -f "$1" ]; then
# Remove \r in case we run on Windows within Git Bash
# and check out the repository with auto CRLF management
# enabled. Otherwise, we may read lines that are delimited with
# \r\n and produce $'-Xarg\r' rather than -Xarg due to word
# splitting rules.
tr -s '\r\n' ' ' < "$1"
fi
verbose() { :; }
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
die() {
printf %s\\n "$1" >&2
exit 1
}
log() {
if [ "$MVNW_VERBOSE" = true ]; then
printf '%s\n' "$1"
fi
trim() {
# MWRAPPER-139:
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
# Needed for removing poorly interpreted newline sequences when running in more
# exotic environments such as mingw bash on Windows.
printf "%s" "${1}" | tr -d '[:space:]'
}
BASE_DIR=$(find_maven_basedir "$(dirname "$0")")
if [ -z "$BASE_DIR" ]; then
exit 1;
fi
scriptDir="$(dirname "$0")"
scriptName="$(basename "$0")"
MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"}; export MAVEN_PROJECTBASEDIR
log "$MAVEN_PROJECTBASEDIR"
##########################################################################################
# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central
# This allows using the maven wrapper in projects that prohibit checking in binary data.
##########################################################################################
wrapperJarPath="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar"
if [ -r "$wrapperJarPath" ]; then
log "Found $wrapperJarPath"
else
log "Couldn't find $wrapperJarPath, downloading it ..."
if [ -n "$MVNW_REPOURL" ]; then
wrapperUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar"
else
wrapperUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar"
fi
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
while IFS="=" read -r key value; do
# Remove '\r' from value to allow usage on windows as IFS does not consider '\r' as a separator ( considers space, tab, new line ('\n'), and custom '=' )
safeValue=$(echo "$value" | tr -d '\r')
case "$key" in (wrapperUrl) wrapperUrl="$safeValue"; break ;;
case "${key-}" in
distributionUrl) distributionUrl=$(trim "${value-}") ;;
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
esac
done < "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties"
log "Downloading from: $wrapperUrl"
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
if $cygwin; then
wrapperJarPath=$(cygpath --path --windows "$wrapperJarPath")
fi
if command -v wget > /dev/null; then
log "Found wget ... using wget"
[ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--quiet"
if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then
wget $QUIET "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath"
else
wget $QUIET --http-user="$MVNW_USERNAME" --http-password="$MVNW_PASSWORD" "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath"
fi
elif command -v curl > /dev/null; then
log "Found curl ... using curl"
[ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--silent"
if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then
curl $QUIET -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath"
else
curl $QUIET --user "$MVNW_USERNAME:$MVNW_PASSWORD" -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath"
fi
else
log "Falling back to using Java to download"
javaSource="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.java"
javaClass="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.class"
# For Cygwin, switch paths to Windows format before running javac
if $cygwin; then
javaSource=$(cygpath --path --windows "$javaSource")
javaClass=$(cygpath --path --windows "$javaClass")
fi
if [ -e "$javaSource" ]; then
if [ ! -e "$javaClass" ]; then
log " - Compiling MavenWrapperDownloader.java ..."
("$JAVA_HOME/bin/javac" "$javaSource")
fi
if [ -e "$javaClass" ]; then
log " - Running MavenWrapperDownloader.java ..."
("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$wrapperUrl" "$wrapperJarPath") || rm -f "$wrapperJarPath"
fi
fi
fi
fi
##########################################################################################
# End of extension
##########################################################################################
# If specified, validate the SHA-256 sum of the Maven wrapper jar file
wrapperSha256Sum=""
while IFS="=" read -r key value; do
case "$key" in (wrapperSha256Sum) wrapperSha256Sum=$value; break ;;
case "${distributionUrl##*/}" in
maven-mvnd-*bin.*)
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
*)
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
distributionPlatform=linux-amd64
;;
esac
done < "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties"
if [ -n "$wrapperSha256Sum" ]; then
wrapperSha256Result=false
if command -v sha256sum > /dev/null; then
if echo "$wrapperSha256Sum $wrapperJarPath" | sha256sum -c > /dev/null 2>&1; then
wrapperSha256Result=true
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
;;
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
esac
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
distributionUrlName="${distributionUrl##*/}"
distributionUrlNameMain="${distributionUrlName%.*}"
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
exec_maven() {
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
}
if [ -d "$MAVEN_HOME" ]; then
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
exec_maven "$@"
fi
case "${distributionUrl-}" in
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
esac
# prepare tmp dir
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
trap clean HUP INT TERM EXIT
else
die "cannot create temp dir"
fi
mkdir -p -- "${MAVEN_HOME%/*}"
# Download and Install Apache Maven
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
verbose "Downloading from: $distributionUrl"
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
# select .zip or .tar.gz
if ! command -v unzip >/dev/null; then
distributionUrl="${distributionUrl%.zip}.tar.gz"
distributionUrlName="${distributionUrl##*/}"
fi
# verbose opt
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
# normalize http auth
case "${MVNW_PASSWORD:+has-password}" in
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
esac
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
verbose "Found wget ... using wget"
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
verbose "Found curl ... using curl"
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
elif set_java_home; then
verbose "Falling back to use Java to download"
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
cat >"$javaSource" <<-END
public class Downloader extends java.net.Authenticator
{
protected java.net.PasswordAuthentication getPasswordAuthentication()
{
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
}
public static void main( String[] args ) throws Exception
{
setDefault( new Downloader() );
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
}
}
END
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
verbose " - Compiling Downloader.java ..."
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
verbose " - Running Downloader.java ..."
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
fi
# If specified, validate the SHA-256 sum of the Maven distribution zip file
if [ -n "${distributionSha256Sum-}" ]; then
distributionSha256Result=false
if [ "$MVN_CMD" = mvnd.sh ]; then
echo "Checksum validation is not supported for maven-mvnd." >&2
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
elif command -v sha256sum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
distributionSha256Result=true
fi
elif command -v shasum >/dev/null; then
if echo "$wrapperSha256Sum $wrapperJarPath" | shasum -a 256 -c > /dev/null 2>&1; then
wrapperSha256Result=true
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
distributionSha256Result=true
fi
else
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available."
echo "Please install either command, or disable validation by removing 'wrapperSha256Sum' from your maven-wrapper.properties."
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
fi
if [ $wrapperSha256Result = false ]; then
echo "Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised." >&2
echo "Investigate or delete $wrapperJarPath to attempt a clean download." >&2
echo "If you updated your Maven version, you need to update the specified wrapperSha256Sum property." >&2
if [ $distributionSha256Result = false ]; then
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
exit 1
fi
fi
MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS"
# For Cygwin, switch paths to Windows format before running java
if $cygwin; then
[ -n "$JAVA_HOME" ] &&
JAVA_HOME=$(cygpath --path --windows "$JAVA_HOME")
[ -n "$CLASSPATH" ] &&
CLASSPATH=$(cygpath --path --windows "$CLASSPATH")
[ -n "$MAVEN_PROJECTBASEDIR" ] &&
MAVEN_PROJECTBASEDIR=$(cygpath --path --windows "$MAVEN_PROJECTBASEDIR")
# unzip and move
if command -v unzip >/dev/null; then
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
else
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
fi
# Provide a "standardized" way to retrieve the CLI args that will
# work with both Windows and non-Windows executions.
MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $*"
export MAVEN_CMD_LINE_ARGS
# Find the actual extracted directory name (handles snapshots where filename != directory name)
actualDistributionDir=""
WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain
# First try the expected directory name (for regular distributions)
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
actualDistributionDir="$distributionUrlNameMain"
fi
fi
# shellcheck disable=SC2086 # safe args
exec "$JAVACMD" \
$MAVEN_OPTS \
$MAVEN_DEBUG_OPTS \
-classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \
"-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \
${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@"
# If not found, search for any directory with the Maven executable (for snapshots)
if [ -z "$actualDistributionDir" ]; then
# enable globbing to iterate over items
set +f
for dir in "$TMP_DOWNLOAD_DIR"/*; do
if [ -d "$dir" ]; then
if [ -f "$dir/bin/$MVN_CMD" ]; then
actualDistributionDir="$(basename "$dir")"
break
fi
fi
done
set -f
fi
if [ -z "$actualDistributionDir" ]; then
verbose "Contents of $TMP_DOWNLOAD_DIR:"
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
die "Could not find Maven distribution directory in extracted archive"
fi
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
clean || :
exec_maven "$@"
Vendored
+153 -169
View File
@@ -1,3 +1,4 @@
<# : batch portion
@REM ----------------------------------------------------------------------------
@REM Licensed to the Apache Software Foundation (ASF) under one
@REM or more contributor license agreements. See the NOTICE file
@@ -18,188 +19,171 @@
@REM ----------------------------------------------------------------------------
@REM ----------------------------------------------------------------------------
@REM Apache Maven Wrapper startup batch script, version 3.2.0
@REM
@REM Required ENV vars:
@REM JAVA_HOME - location of a JDK home dir
@REM Apache Maven Wrapper startup batch script, version 3.3.4
@REM
@REM Optional ENV vars
@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands
@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending
@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven
@REM e.g. to debug Maven itself, use
@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000
@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files
@REM MVNW_REPOURL - repo url base for downloading maven distribution
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
@REM ----------------------------------------------------------------------------
@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on'
@echo off
@REM set title of command window
title %0
@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on'
@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO%
@REM set %HOME% to equivalent of $HOME
if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%")
@REM Execute a user defined script before this one
if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre
@REM check for pre script, once with legacy .bat ending and once with .cmd ending
if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %*
if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %*
:skipRcPre
@setlocal
set ERROR_CODE=0
@REM To isolate internal variables from possible post scripts, we use another setlocal
@setlocal
@REM ==== START VALIDATION ====
if not "%JAVA_HOME%" == "" goto OkJHome
echo.
echo Error: JAVA_HOME not found in your environment. >&2
echo Please set the JAVA_HOME variable in your environment to match the >&2
echo location of your Java installation. >&2
echo.
goto error
:OkJHome
if exist "%JAVA_HOME%\bin\java.exe" goto init
echo.
echo Error: JAVA_HOME is set to an invalid directory. >&2
echo JAVA_HOME = "%JAVA_HOME%" >&2
echo Please set the JAVA_HOME variable in your environment to match the >&2
echo location of your Java installation. >&2
echo.
goto error
@REM ==== END VALIDATION ====
:init
@REM Find the project base dir, i.e. the directory that contains the folder ".mvn".
@REM Fallback to current working directory if not found.
set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR%
IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir
set EXEC_DIR=%CD%
set WDIR=%EXEC_DIR%
:findBaseDir
IF EXIST "%WDIR%"\.mvn goto baseDirFound
cd ..
IF "%WDIR%"=="%CD%" goto baseDirNotFound
set WDIR=%CD%
goto findBaseDir
:baseDirFound
set MAVEN_PROJECTBASEDIR=%WDIR%
cd "%EXEC_DIR%"
goto endDetectBaseDir
:baseDirNotFound
set MAVEN_PROJECTBASEDIR=%EXEC_DIR%
cd "%EXEC_DIR%"
:endDetectBaseDir
IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig
@setlocal EnableExtensions EnableDelayedExpansion
for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a
@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS%
:endReadAdditionalConfig
SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe"
set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar"
set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain
set WRAPPER_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar"
FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO (
IF "%%A"=="wrapperUrl" SET WRAPPER_URL=%%B
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
@SET __MVNW_CMD__=
@SET __MVNW_ERROR__=
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
@SET PSModulePath=
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
)
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
@SET __MVNW_PSMODULEP_SAVE=
@SET __MVNW_ARG0_NAME__=
@SET MVNW_USERNAME=
@SET MVNW_PASSWORD=
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
@echo Cannot start maven from wrapper >&2 && exit /b 1
@GOTO :EOF
: end batch / begin powershell #>
@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central
@REM This allows using the maven wrapper in projects that prohibit checking in binary data.
if exist %WRAPPER_JAR% (
if "%MVNW_VERBOSE%" == "true" (
echo Found %WRAPPER_JAR%
)
) else (
if not "%MVNW_REPOURL%" == "" (
SET WRAPPER_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar"
)
if "%MVNW_VERBOSE%" == "true" (
echo Couldn't find %WRAPPER_JAR%, downloading it ...
echo Downloading from: %WRAPPER_URL%
)
$ErrorActionPreference = "Stop"
if ($env:MVNW_VERBOSE -eq "true") {
$VerbosePreference = "Continue"
}
powershell -Command "&{"^
"$webclient = new-object System.Net.WebClient;"^
"if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^
"$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^
"}"^
"[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%WRAPPER_URL%', '%WRAPPER_JAR%')"^
"}"
if "%MVNW_VERBOSE%" == "true" (
echo Finished downloading %WRAPPER_JAR%
)
)
@REM End of extension
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
if (!$distributionUrl) {
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
}
@REM If specified, validate the SHA-256 sum of the Maven wrapper jar file
SET WRAPPER_SHA_256_SUM=""
FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO (
IF "%%A"=="wrapperSha256Sum" SET WRAPPER_SHA_256_SUM=%%B
)
IF NOT %WRAPPER_SHA_256_SUM%=="" (
powershell -Command "&{"^
"$hash = (Get-FileHash \"%WRAPPER_JAR%\" -Algorithm SHA256).Hash.ToLower();"^
"If('%WRAPPER_SHA_256_SUM%' -ne $hash){"^
" Write-Output 'Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised.';"^
" Write-Output 'Investigate or delete %WRAPPER_JAR% to attempt a clean download.';"^
" Write-Output 'If you updated your Maven version, you need to update the specified wrapperSha256Sum property.';"^
" exit 1;"^
"}"^
"}"
if ERRORLEVEL 1 goto error
)
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
"maven-mvnd-*" {
$USE_MVND = $true
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
$MVN_CMD = "mvnd.cmd"
break
}
default {
$USE_MVND = $false
$MVN_CMD = $script -replace '^mvnw','mvn'
break
}
}
@REM Provide a "standardized" way to retrieve the CLI args that will
@REM work with both Windows and non-Windows executions.
set MAVEN_CMD_LINE_ARGS=%*
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
if ($env:MVNW_REPOURL) {
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
}
$distributionUrlName = $distributionUrl -replace '^.*/',''
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
%MAVEN_JAVA_EXE% ^
%JVM_CONFIG_MAVEN_PROPS% ^
%MAVEN_OPTS% ^
%MAVEN_DEBUG_OPTS% ^
-classpath %WRAPPER_JAR% ^
"-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^
%WRAPPER_LAUNCHER% %MAVEN_CONFIG% %*
if ERRORLEVEL 1 goto error
goto end
$MAVEN_M2_PATH = "$HOME/.m2"
if ($env:MAVEN_USER_HOME) {
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
}
:error
set ERROR_CODE=1
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
}
:end
@endlocal & set ERROR_CODE=%ERROR_CODE%
$MAVEN_WRAPPER_DISTS = $null
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
} else {
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
}
if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost
@REM check for post script, once with legacy .bat ending and once with .cmd ending
if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat"
if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd"
:skipRcPost
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on'
if "%MAVEN_BATCH_PAUSE%"=="on" pause
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
exit $?
}
if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE%
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
}
cmd /C exit /B %ERROR_CODE%
# prepare tmp dir
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
trap {
if ($TMP_DOWNLOAD_DIR.Exists) {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
}
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
# Download and Install Apache Maven
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
Write-Verbose "Downloading from: $distributionUrl"
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
$webclient = New-Object System.Net.WebClient
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
}
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
# If specified, validate the SHA-256 sum of the Maven distribution zip file
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
if ($distributionSha256Sum) {
if ($USE_MVND) {
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
}
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
}
}
# unzip and move
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
# Find the actual extracted directory name (handles snapshots where filename != directory name)
$actualDistributionDir = ""
# First try the expected directory name (for regular distributions)
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
$actualDistributionDir = $distributionUrlNameMain
}
# If not found, search for any directory with the Maven executable (for snapshots)
if (!$actualDistributionDir) {
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
if (Test-Path -Path $testPath -PathType Leaf) {
$actualDistributionDir = $_.Name
}
}
}
if (!$actualDistributionDir) {
Write-Error "Could not find Maven distribution directory in extracted archive"
}
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
try {
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
} catch {
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
Write-Error "fail to move MAVEN_HOME"
}
} finally {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
+208 -59
View File
@@ -12,11 +12,13 @@
<packaging>jar</packaging>
<properties>
<main.class>me.bobert.netneutrality.NetNeutrality</main.class>
<java.version>17</java.version>
<main.class>me.bobert.netneutrality.App</main.class>
<java.version>25</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<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>
@@ -28,52 +30,143 @@
</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>
<version>3.5.5</version>
<configuration>
<argLine>
-javaagent:"${settings.localRepository}/org/mockito/mockito-core/${mockito.version}/mockito-core-${mockito.version}.jar"
</argLine>
</configuration>
</plugin>
<plugin>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-maven-plugin</artifactId>
<version>10.17.0</version>
<version>11.20.3</version>
<configuration>
<url>jdbc:mysql://localhost:3306/netneutrality</url>
<user>root</user>
<password></password>
<!--suppress UnresolvedMavenProperty -->
<url>${env.FLYWAY_URL}</url>
<!--suppress UnresolvedMavenProperty -->
<user>${env.FLYWAY_USER}</user>
<!--suppress UnresolvedMavenProperty -->
<password>${env.FLYWAY_PASSWORD}</password>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-resources-plugin</artifactId>
<version>3.3.1</version>
<version>3.5.0</version>
<configuration>
<propertiesEncoding>UTF-8</propertiesEncoding>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
<version>3.7.1</version>
<artifactId>maven-jar-plugin</artifactId>
<version>3.5.0</version>
<configuration>
<archive>
<manifestEntries>
<Multi-Release>true</Multi-Release>
</manifestEntries>
<manifest>
<addClasspath>true</addClasspath>
<mainClass>${main.class}</mainClass>
</manifest>
</archive>
<descriptorRefs>
<descriptorRef>jar-with-dependencies</descriptorRef>
</descriptorRefs>
<finalName>netneutrality</finalName>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.6.2</version>
<executions>
<execution>
<id>make-assembly</id> <!-- this is used for inheritance merges -->
<phase>package</phase> <!-- bind to the packaging phase -->
<phase>package</phase>
<goals>
<goal>single</goal>
<goal>shade</goal>
</goals>
<configuration>
<finalName>netneutrality</finalName>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>module-info.class</exclude>
<exclude>META-INF/versions/*/module-info.class</exclude>
<exclude>META-INF/MANIFEST.MF</exclude>
<exclude>META-INF/LICENSE*</exclude>
<exclude>META-INF/NOTICE*</exclude>
<exclude>META-INF/DEPENDENCIES</exclude>
<exclude>META-INF/jandex.idx</exclude>
<exclude>META-INF/versions/9/OSGI-INF/MANIFEST.MF</exclude>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
</excludes>
</filter>
<filter>
<artifact>com.squareup.okhttp3:okhttp</artifact>
<excludes>
<exclude>**</exclude>
</excludes>
</filter>
</filters>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>${main.class}</mainClass>
</transformer>
<transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<manifestEntries>
<Multi-Release>true</Multi-Release>
</manifestEntries>
</transformer>
</transformers>
<createDependencyReducedPom>false</createDependencyReducedPom>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-checkstyle-plugin</artifactId>
<version>3.6.0</version>
<executions>
<execution>
<id>checkstyle</id>
<phase>test</phase>
<goals>
<goal>check</goal>
</goals>
</execution>
</executions>
<configuration>
<configLocation>checkstyle.xml</configLocation>
<failOnViolation>true</failOnViolation>
<linkXRef>false</linkXRef>
<outputFile>target/checkstyle-result.xml</outputFile>
<outputFormat>xml</outputFormat>
</configuration>
</plugin>
</plugins>
</build>
@@ -100,71 +193,108 @@
<dependency>
<groupId>net.dv8tion</groupId>
<artifactId>JDA</artifactId>
<version>5.1.2</version>
<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.2</version>
</dependency>
<dependency>
<groupId>dev.lavalink.youtube</groupId>
<artifactId>v2</artifactId>
<version>1.11.5</version>
<artifactId>lavalink-client</artifactId>
<version>3.4.0</version>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.12.0</version>
<artifactId>okhttp-jvm</artifactId>
<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>
<version>6.5.2.Final</version>
<version>7.1.9.Final</version>
</dependency>
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-hikaricp</artifactId>
<version>6.5.2.Final</version>
<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>
<version>9.0.0</version>
<version>9.7.0</version>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
<version>10.17.0</version>
<version>11.20.3</version>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-mysql</artifactId>
<version>10.17.0</version>
<version>11.20.3</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.sentry</groupId>
<artifactId>sentry</artifactId>
<version>7.12.1</version>
<version>8.41.0</version>
</dependency>
<dependency>
<groupId>org.gitlab4j</groupId>
<artifactId>gitlab4j-api</artifactId>
<version>6.0.0-rc.5</version>
</dependency>
<dependency>
<groupId>net.fellbaum</groupId>
<artifactId>jemoji</artifactId>
<version>1.7.0</version>
<version>6.2.0</version>
<exclusions>
<!-- Clashes with the Servlet API used by Javalin's Jetty 12 in the shaded jar. -->
<exclusion>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
<version>20240303</version>
<groupId>net.fellbaum</groupId>
<artifactId>jemoji</artifactId>
<version>1.7.6</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.21.2</version>
</dependency>
<dependency>
@@ -176,41 +306,60 @@
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-api</artifactId>
<version>2.23.1</version>
<version>2.26.0</version>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<version>2.23.1</version>
<version>2.26.0</version>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-slf4j2-impl</artifactId>
<version>2.23.1</version>
<version>2.26.0</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.15.0</version>
</dependency>
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<version>1.17.0</version>
<version>3.19.0</version>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.16.1</version>
<version>2.21.0</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.0</version>
<version>6.0.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>${mockito.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<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>
+58 -1
View File
@@ -1,6 +1,63 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"renovate/renovate-config"
"local>renovate/renovate-config",
"group:monorepos",
"group:flyway"
],
"packageRules": [
{
"matchPackageNames": [
"mariadb"
],
"automerge": false,
"dependencyDashboardApproval": true
},
{
"matchManagers": ["maven"],
"extends": ["schedule:monthly"]
},
{
"groupName": "maven plugins",
"matchManagers": ["maven"],
"matchDepTypes": ["plugin"],
"extends": ["schedule:weekly"]
},
{
"groupName": "hibernate orm",
"matchPackageNames": [
"org.hibernate.orm:**"
],
"extends": ["schedule:monthly"]
},
{
"groupName": "apache commons",
"matchPackageNames": [
"commons-**",
"org.apache.commons:**"
],
"extends": ["schedule:monthly"]
},
{
"matchManagers": ["maven"],
"matchDepTypes": ["dependency", "parentPom"],
"matchPackageNames": [
"net.dv8tion:JDA",
"dev.arbjerg:lavaplayer",
"dev.lavalink.youtube:**",
"club.minnced:jdave-*"
],
"schedule": ["at any time"]
},
{
"groupName": "github actions (minor/patch)",
"matchManagers": ["github-actions"],
"matchUpdateTypes": ["minor", "patch", "digest"]
},
{
"groupName": "github actions (major)",
"matchManagers": ["github-actions"],
"matchUpdateTypes": ["major"]
}
]
}
@@ -0,0 +1,243 @@
package me.bobert.netneutrality;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import dev.arbjerg.lavalink.libraries.jda.JDAVoiceUpdateListener;
import io.sentry.Sentry;
import me.bobert.netneutrality.bootstrap.ApplicationContext;
import me.bobert.netneutrality.bootstrap.Lifecycle;
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.core.tasks.TaskManagerImpl;
import me.bobert.netneutrality.core.scheduled.ScheduledTaskManager;
import me.bobert.netneutrality.core.thread.ThreadManager;
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.EventsHandler;
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.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.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;
import org.apache.logging.log4j.core.config.Configurator;
public class App {
private static final Logger logger = LogManager.getLogger(App.class);
private static App instance;
private final Settings settings;
private JDA jda;
private MusicService music;
private final ApplicationContext context = new ApplicationContext();
static void main() {
Settings settings = new Settings();
String version = settings.getMavenVersion();
WebModule.setDefaultVersion(version);
logger.info("Starting App v{}", version);
String env = settings.get("NETNEUTRALITY_BOT_ENV", "dev");
String sentryDsn = settings.get("NETNEUTRALITY_SENTRY_DSN");
if (sentryDsn != null) {
logger.info("Found Sentry DSN in config, setting up the SDK..");
Sentry.init(options -> {
options.setDsn(sentryDsn);
options.setEnvironment(env);
options.setTracesSampleRate(1.0);
options.setDebug(false);
options.setRelease(version);
});
}
if ("dev".equals(env)) {
Configurator.setLevel("net.dv8tion.jda", Level.DEBUG);
Configurator.setLevel("dev.arbjerg.lavalink", Level.DEBUG);
}
instance = new App(settings);
}
public App(Settings settings) {
this.settings = settings;
startup();
}
private void startup() {
String token = settings.get("NETNEUTRALITY_BOT_TOKEN");
if (token == null) {
throw new RuntimeException("No bot token provided, exiting.");
}
buildContext();
try {
context.start();
} catch (Exception e) {
LogHandler.log(e);
// 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));
}
/**
* Registers domain services and remaining startup/shutdown adapters with the
* {@link ApplicationContext}. Ascending {@link Lifecycle#priority()} drives startup order; the
* context stops components in the exact reverse order.
*/
private void buildContext() {
// Config first so SettingsProvider is resolvable for constructor injection.
context.register((SettingsProvider) settings);
// Allow ApplicationContext itself to be injected into components that need it.
context.register(context);
// 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());
ChannelMessagePurger.bind(executors);
// Scheduled tasks (priority 55): discovers and schedules all ScheduledTask implementations
// on start; cancelled early on shutdown, before executors.
context.register(new ScheduledTaskManager(executors, context));
// Command registry (priority 30).
CommandRegistry commands = context.register(new CommandManager(context));
// Task registry (priority 30).
TaskManager tasks = context.register(new TaskManagerImpl(context, new TaskRepository(sessions)));
// Per-guild service cache (not a Lifecycle component).
GuildServiceProvider guildServices = context.register(
new GuildServiceProviderImpl(guildRepository, blacklistRepository, guildSettingRepository));
LogHandler.bind(guildServices);
UserFunction.bind(guildServices);
DisabledCommandPrecondition.bind(guildServices);
// Music sources (priority 40): initialise the shared player manager; disconnect on shutdown.
music = context.register(new MusicServiceImpl(settings, guildServices, tasks, executors));
BotState.bind(guildServices, music);
// JDA gateway; disconnected on shutdown.
context.register(lifecycle(60, this::initJDA, this::disconnect));
// Register slash commands, attach the event handler and set the presence once JDA is ready.
context.register(lifecycle(65, () -> {
commands.registerSlashCommands(jda);
jda.addEventListener(new EventsHandler(settings, guildServices, commands, tasks));
jda.getPresence().setActivity(Activity.watching("you"));
}, () -> { }));
// Web API (priority 70) — Lifecycle with graceful stop.
context.register(new WebApi(settings, guildServices, music, this::getJDA, this::shutdown));
}
private static Lifecycle lifecycle(int priority, ThrowingRunnable start, Runnable stop) {
return new Lifecycle() {
@Override
public void start() throws Exception {
start.run();
}
@Override
public void stop() {
stop.run();
}
@Override
public int priority() {
return priority;
}
};
}
@FunctionalInterface
private interface ThrowingRunnable {
void run() throws Exception;
}
public static App getInstance() {
return instance;
}
public void initJDA() {
String token = settings.get("NETNEUTRALITY_BOT_TOKEN");
if (token == null) {
SessionManager sessions = context.get(SessionManager.class);
if (sessions != null) {
sessions.stop();
}
throw new RuntimeException("No bot token provided, exiting.");
}
try {
jda = JDABuilder
.createDefault(token)
.enableIntents(
GatewayIntent.GUILD_MEMBERS,
GatewayIntent.GUILD_MESSAGE_REACTIONS,
GatewayIntent.GUILD_VOICE_STATES,
GatewayIntent.MESSAGE_CONTENT)
// Voice state/server updates are handed to Lavalink instead of JDA's audio stack;
// DAVE encryption is handled by the node.
.setVoiceDispatchInterceptor(new JDAVoiceUpdateListener(music.getClient()))
.setActivity(Activity.watching("me load..."))
.build();
jda.awaitReady();
} catch (InvalidTokenException | IllegalArgumentException | InterruptedException e) {
if (e instanceof InterruptedException) {
Thread.currentThread().interrupt();
}
LogHandler.log(e);
throw new IllegalStateException("Unable to connect to Discord", e);
}
}
public void shutdown() {
logger.info("[Net Neutrality] Shutting down...");
context.stop();
logger.info("[Net Neutrality] Shutdown complete");
}
public JDA getJDA() {
return jda;
}
private void disconnect() {
if (jda != null) {
jda.shutdown();
}
}
}
@@ -1,126 +0,0 @@
package me.bobert.netneutrality;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import io.sentry.Sentry;
import me.bobert.netneutrality.core.LogHandler;
import me.bobert.netneutrality.core.command.CommandManager;
import me.bobert.netneutrality.core.db.SessionManager;
import me.bobert.netneutrality.core.tasks.TaskManager;
import me.bobert.netneutrality.core.thread.ThreadManager;
import me.bobert.netneutrality.hooks.EventsHandler;
import me.bobert.netneutrality.http.server.WebApi;
import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.thread.ShutdownThread;
import me.bobert.netneutrality.utils.Settings;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.JDABuilder;
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;
import org.apache.logging.log4j.core.config.Configurator;
public class NetNeutrality {
private static final Logger logger = LogManager.getLogger(NetNeutrality.class);
public static final String VERSION = Settings.getVersion();
private static NetNeutrality instance;
private static JDA jda;
public static void main(String[] args) {
System.out.println("Starting NetNeutrality v" + VERSION);
String env = Settings.getInstance().get("NETNEUTRALITY_BOT_ENV", "dev");
String sentryDsn = Settings.getInstance().get("NETNEUTRALITY_SENTRY_DSN");
if (sentryDsn != null) {
logger.info("Found Sentry DSN in config, setting up the SDK..");
Sentry.init(options -> {
options.setDsn(sentryDsn);
options.setEnvironment(env);
options.setTracesSampleRate(1.0);
options.setDebug(false);
options.setRelease(VERSION);
});
}
if ("dev".equals(env)) {
Configurator.setLevel("net.dv8tion.jda", Level.DEBUG);
}
instance = new NetNeutrality();
}
public NetNeutrality() {
startup();
}
private void startup() {
String token = Settings.getInstance().get("NETNEUTRALITY_BOT_TOKEN");
if (token == null) {
throw new RuntimeException("No bot token provided, exiting.");
}
// DatabaseMigration.migrate();
SessionManager.getSessionFactory();
CommandManager.init();
MusicManager.initPlayerManager();
TaskManager.getInstance().init();
initJDA();
CommandManager.registerSlashCommands(jda);
jda.addEventListener(new EventsHandler());
new WebApi(Integer.parseInt(Settings.getInstance().get("NETNEUTRALITY_API_PORT", "9000")));
jda.getPresence().setActivity(Activity.watching("you"));
Runtime.getRuntime().addShutdownHook(new ShutdownThread(this));
}
public static NetNeutrality getInstance() {
return instance;
}
public void initJDA() {
String token = Settings.getInstance().get("NETNEUTRALITY_BOT_TOKEN");
if (token == null) {
SessionManager.shutdown();
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)
.setActivity(Activity.watching("me load..."))
.build();
jda.awaitReady();
} catch (InvalidTokenException | IllegalArgumentException | InterruptedException e) {
LogHandler.log(e);
System.exit(1);
}
}
public void shutdown() {
System.out.println("[Net Neutrality] Shutting down...");
MusicManager.disconnectPlayers();
ThreadManager.shutdownExecutor();
disconnect();
SessionManager.shutdown();
System.out.println("[Net Neutrality] Shutdown complete");
}
public JDA getJDA() {
return jda;
}
private void disconnect() {
if (jda != null) {
jda.shutdown();
}
}
}
@@ -0,0 +1,99 @@
package me.bobert.netneutrality.bootstrap;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
/**
* A small hand-rolled application container that owns wired instances and drives ordered
* startup/shutdown.
*
* <p>Components implementing {@link Lifecycle} are started in ascending {@link Lifecycle#priority()}
* order and stopped in the exact reverse order, giving symmetric, deterministic startup and
* shutdown without any DI framework.
*/
public final class ApplicationContext {
private static final Logger logger = LogManager.getLogger(ApplicationContext.class);
private final List<Lifecycle> components = new ArrayList<>();
private final Map<Class<?>, Object> instances = new HashMap<>();
/**
* Registers a component with the context. If the component implements {@link Lifecycle} it will
* participate in ordered start/stop. The instance is also indexed by its concrete type and can
* be resolved later via {@link #get(Class)}.
*
* @param component the component to register
* @param <T> the component type
* @return the same component, for fluent wiring
*/
public <T> T register(T component) {
if (component instanceof Lifecycle lifecycle) {
components.add(lifecycle);
}
instances.put(component.getClass(), component);
return component;
}
/**
* Resolves a previously registered instance assignable to the given type.
*
* @param type the requested type
* @param <T> the requested type
* @return the matching instance, or {@code null} if none was registered
*/
public <T> T get(Class<T> type) {
Object direct = instances.get(type);
if (direct != null) {
return type.cast(direct);
}
for (Object instance : instances.values()) {
if (type.isInstance(instance)) {
return type.cast(instance);
}
}
return null;
}
/**
* Starts all registered {@link Lifecycle} components in ascending priority order.
*
* @throws Exception if any component fails to start
*/
public void start() throws Exception {
components.sort(Comparator.comparingInt(Lifecycle::priority));
for (Lifecycle component : components) {
logger.debug("Starting component {} (priority {})", component.name(), component.priority());
component.start();
}
}
/**
* Stops all registered {@link Lifecycle} components in the reverse of their start order. Failures
* are logged and do not prevent the remaining components from being stopped.
*/
public void stop() {
List<Lifecycle> reversed = new ArrayList<>(components);
reversed.sort(Comparator.comparingInt(Lifecycle::priority));
Collections.reverse(reversed);
for (Lifecycle component : reversed) {
try {
logger.debug("Stopping component {} (priority {})", component.name(), component.priority());
component.stop();
} catch (Exception e) {
logger.error("Error while stopping component {}", component.name(), e);
}
}
}
}
@@ -0,0 +1,95 @@
package me.bobert.netneutrality.bootstrap;
/*
* Copyright (c) anthorob
*
* 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 me.bobert.netneutrality.utils.Json;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.util.Map;
public class BotState {
public static boolean SAFE = false;
private static volatile GuildServiceProvider guildServices;
private static volatile MusicService musicService;
/**
* Binds providers used by the optional state dump. Called once during bootstrap.
*
* @param guildServices the guild service provider
* @param musicService the music service
*/
public static void bind(GuildServiceProvider guildServices, MusicService musicService) {
BotState.guildServices = guildServices;
BotState.musicService = musicService;
}
//TODO: is it needed?
private static void saveGuildsState() {
if (guildServices == null || musicService == null) {
return;
}
Map<String, GuildService> services = guildServices.snapshot();
ObjectNode main = Json.object();
ArrayNode guildsArray = Json.array();
for (MusicManager manager : musicService.getManagers()) {
TrackScheduler trackScheduler = manager.getScheduler();
}
for (GuildService service : services.values()) {
ObjectNode guild = Json.object();
ObjectNode music = Json.object();
guild.put("id", service.getGuildId());
// MessageChannel currentChannel = trackScheduler.getTextChannel();
// if (currentChannel == null) {
// music.put("current_channel", "null");
// } else {
// music.put("current_channel", currentChannel.getId());
// }
guild.set("music", music);
guildsArray.add(guild);
}
main.set("guilds", guildsArray);
File state = new File("state.json");
try {
if (!state.exists()) {
state.createNewFile();
}
try (FileWriter writer = new FileWriter(state)) {
writer.write(main.toString());
}
} catch (IOException e) {
e.printStackTrace();
}
}
public static void dump() {
if (!SAFE) {
saveGuildsState();
}
}
}
@@ -0,0 +1,56 @@
package me.bobert.netneutrality.bootstrap;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
/**
* Contract for a component whose lifecycle is owned and driven by the {@link ApplicationContext}.
*
* <p>Components are {@linkplain #start() started} in ascending {@link #priority()} order and
* {@linkplain #stop() stopped} in the exact reverse order, giving symmetric, deterministic startup
* and shutdown without any DI framework. A lower {@code priority()} means the component is started
* earlier and, consequently, stopped later.
*/
public interface Lifecycle {
/**
* Brings the component up. Called exactly once, during {@link ApplicationContext#start()}.
*
* @throws Exception if the component cannot be started; startup is aborted
*/
void start() throws Exception;
/**
* Tears the component down, releasing whatever {@link #start()} acquired. Called at most once,
* during {@link ApplicationContext#stop()}. Implementations should be null-safe / idempotent so
* they can cope with a component that never fully started.
*
* <p>The default is a no-op for components that hold no resources.
*
* @throws Exception if teardown fails; the context logs it and continues stopping the rest
*/
default void stop() throws Exception {
}
/**
* Relative ordering weight. Lower values start earlier and stop later; equal values keep their
* registration order. Defaults to {@code 0}.
*
* @return the priority of this component
*/
default int priority() {
return 0;
}
/**
* Human-readable name used in lifecycle diagnostics/logging. Defaults to the simple class name.
*
* @return a short label identifying this component
*/
default String name() {
return getClass().getSimpleName();
}
}
@@ -0,0 +1,129 @@
package me.bobert.netneutrality.bootstrap;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.reflections.Reflections;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Modifier;
import java.util.Set;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
public class ReflectionHelper {
private static final Logger logger = LogManager.getLogger(ReflectionHelper.class);
private static final BiConsumer<Class<?>, Exception> DEFAULT_FAILURE = (aClass, e) -> logger.error("Failed to load class: {}", aClass.getName(), e);
public static <E> void loadClassesFromPackage(String packageName, Class<E> clazz, Consumer<E> onInstanciated, BiConsumer<Class<?>, Exception> onFailed) {
loadClassesFromPackage(packageName, clazz, null, onInstanciated, onFailed);
}
/**
* Discovers concrete subtypes of {@code clazz} under {@code packageName} and instantiates each
* one. When a non-null {@link ApplicationContext} is provided, prefers the public constructor
* with the most parameters whose every parameter type is resolvable via
* {@link ApplicationContext#get(Class)}; otherwise (or when no such constructor exists) falls
* back to the no-arg constructor.
*
* @param packageName root package to scan
* @param clazz base type of classes to load
* @param context optional DI context used for constructor injection; {@code null} keeps
* legacy no-arg construction only
* @param onInstanciated callback invoked with each successfully created instance
* @param onFailed callback invoked when a class cannot be instantiated
* @param <E> the base type
*/
public static <E> void loadClassesFromPackage(String packageName, Class<E> clazz, ApplicationContext context, Consumer<E> onInstanciated, BiConsumer<Class<?>, Exception> onFailed) {
Set<Class<? extends E>> classes = new Reflections(packageName).getSubTypesOf(clazz);
for (Class<? extends E> s : classes) {
try {
if (Modifier.isAbstract(s.getModifiers())) {
continue;
}
E instance = instantiate(s, context);
onInstanciated.accept(instance);
} catch (InstantiationException | IllegalAccessException | IllegalArgumentException
| InvocationTargetException | NoSuchMethodException | SecurityException e) {
onFailed.accept(s, e);
}
}
}
/**
* Instantiates {@code type} using context-based constructor injection when possible.
*
* <p>Selection algorithm: among public constructors, pick the one with the most parameters for
* which every parameter type resolves to a non-null value via {@code context.get(paramType)}.
* If no such constructor exists (or {@code context} is null), fall back to the public no-arg
* constructor.
*/
@SuppressWarnings("unchecked")
public static <E> E instantiate(Class<? extends E> type, ApplicationContext context)
throws InstantiationException, IllegalAccessException, InvocationTargetException, NoSuchMethodException {
if (context != null) {
Constructor<?> best = null;
Object[] bestArgs = null;
for (Constructor<?> ctor : type.getConstructors()) {
Class<?>[] paramTypes = ctor.getParameterTypes();
if (paramTypes.length == 0) {
continue;
}
Object[] args = new Object[paramTypes.length];
boolean resolvable = true;
for (int i = 0; i < paramTypes.length; i++) {
Object dependency = context.get(paramTypes[i]);
if (dependency == null) {
resolvable = false;
break;
}
args[i] = dependency;
}
if (resolvable && (best == null || paramTypes.length > best.getParameterCount())) {
best = ctor;
bestArgs = args;
}
}
if (best != null) {
return (E) best.newInstance(bestArgs);
}
}
return type.getConstructor().newInstance();
}
public static <E> void loadCommands(Class<E> clazz, Consumer<E> onInstanciated, BiConsumer<Class<?>, Exception> onFailed) {
loadClassesFromPackage("me.bobert.netneutrality", clazz, onInstanciated, onFailed);
}
public static <E> void loadCommands(Class<E> clazz, Consumer<E> onInstanciated) {
loadClassesFromPackage("me.bobert.netneutrality", clazz, onInstanciated, DEFAULT_FAILURE);
}
public static <E> void loadCommands(Class<E> clazz, ApplicationContext context, Consumer<E> onInstanciated) {
loadClassesFromPackage("me.bobert.netneutrality", clazz, context, onInstanciated, DEFAULT_FAILURE);
}
public static <E> void loadTasks(Class<E> clazz, Consumer<E> onInstanciated, BiConsumer<Class<?>, Exception> onFailed) {
loadClassesFromPackage("me.bobert.netneutrality", clazz, onInstanciated, onFailed);
}
public static <E> void loadTasks(Class<E> clazz, Consumer<E> onInstanciated) {
loadClassesFromPackage("me.bobert.netneutrality", clazz, onInstanciated, DEFAULT_FAILURE);
}
public static <E> void loadTasks(Class<E> clazz, ApplicationContext context, Consumer<E> onInstanciated) {
loadClassesFromPackage("me.bobert.netneutrality", clazz, context, onInstanciated, DEFAULT_FAILURE);
}
}
@@ -5,20 +5,33 @@ package me.bobert.netneutrality.command.admin;
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.command.AbstractAdminCommand;
import me.bobert.netneutrality.core.command.CommandExecutionResult;
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.command.options.CommandOptionsBuilder;
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.GuildService;
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;
public class BlacklistCommand extends AdminCommand {
private static final Logger logger = LogManager.getLogger(BlacklistCommand.class);
private final GuildServiceProvider guildServices;
public BlacklistCommand(GuildServiceProvider guildServices) {
Objects.requireNonNull(guildServices, "GuildServiceProvider must not be null");
this.guildServices = guildServices;
}
public class BlacklistCommand extends AbstractAdminCommand {
@Override
public boolean isEnabled() {
@@ -55,16 +68,16 @@ public class BlacklistCommand extends AbstractAdminCommand {
public CommandExecutionResult execute(CommandContext context) {
String sub = context.getSubcommand();
String word = context.getOption("word", "", CommandOption::getAsString);
BlacklistService service = GuildService.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()) {
@@ -74,13 +87,32 @@ public class BlacklistCommand extends AbstractAdminCommand {
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));
};
}
}
@@ -0,0 +1,78 @@
package me.bobert.netneutrality.command.admin;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.command.CommandCategory;
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.command.options.CommandOption;
import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.utils.ChannelMessagePurger;
import net.dv8tion.jda.api.entities.channel.middleman.GuildMessageChannel;
import net.dv8tion.jda.api.interactions.commands.OptionType;
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
public class PurgeCommand extends AbstractCommand {
@Override
public boolean needAdmin() {
return true;
}
@Override
public boolean isEnabled() {
return false;
}
@Override
public boolean isGuildOnly() {
return true;
}
@Override
public String getCommandName() {
return "purge";
}
@Override
public String getDescription() {
return "Delete messages from the channel you are currently on";
}
@Override
public void registerCommandOptions(CommandOptionsBuilder builder) {
builder.addOptions(
new OptionData(OptionType.INTEGER, "to-purge", "Number of messages to delete (max 2500)", true)
.setRequiredRange(1, 2500));
}
@Override
public CommandCategory getCategory() {
return CommandCategory.ADMINISTRATION;
}
@Override
public CommandExecutionResult execute(CommandContext context) {
int toDelete = context.getOption("to-purge", 0, CommandOption::getAsInt);
context.ephemeral();
if (toDelete > 0) {
GuildMessageChannel messageChannel = (GuildMessageChannel) context.getChannel();
ChannelMessagePurger channelMessagePurger = ChannelMessagePurger.getPurger(messageChannel);
boolean lock = channelMessagePurger.canLockBeAcquired();
channelMessagePurger.purgeMessages(context, toDelete);
context.reply("Processing purge command.. This can take some time.");
if (!lock) {
context.reply("Another purge is currently in progress on this channel, please wait before purging again");
}
}
return CommandExecutionResult.none();
}
}
@@ -5,13 +5,12 @@ package me.bobert.netneutrality.command.admin;
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.command.AbstractAdminCommand;
import me.bobert.netneutrality.core.command.CommandExecutionResult;
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.command.options.CommandOptionsBuilder;
import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.core.tasks.TaskManager;
import me.bobert.netneutrality.tasks.emoji.EmojiReactionRoleTask;
import me.bobert.netneutrality.tasks.reaction.EmojiReactionRoleTask;
import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.entities.Role;
import net.dv8tion.jda.api.interactions.commands.OptionType;
@@ -23,8 +22,17 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
import me.bobert.netneutrality.core.tasks.TaskManager;
public class ReactionCommand extends AdminCommand {
private final TaskManager taskManager;
public ReactionCommand(TaskManager taskManager) {
Objects.requireNonNull(taskManager, "TaskManager must not be null");
this.taskManager = taskManager;
}
public class ReactionCommand extends AbstractAdminCommand {
@Override
public boolean isEnabled() {
@@ -93,11 +101,11 @@ public class ReactionCommand extends AbstractAdminCommand {
private CommandExecutionResult executeEmoji(CommandContext context, String guildId, String messageId, String sub) {
String id = String.format("%s:%s", guildId, messageId);
EmojiReactionRoleTask task = (EmojiReactionRoleTask) TaskManager.getInstance().getTask(id);
EmojiReactionRoleTask task = (EmojiReactionRoleTask) taskManager.getTask(id);
switch (sub) {
case "list" -> {
List<EmojiReactionRoleTask> tasks = TaskManager.getInstance().findTasksByPartialId(EmojiReactionRoleTask.class, guildId);
List<EmojiReactionRoleTask> tasks = taskManager.findTasksByPartialId(EmojiReactionRoleTask.class, guildId);
if (tasks.isEmpty()) {
return CommandExecutionResult.info("No tasks found for this guild");
}
@@ -5,23 +5,40 @@ package me.bobert.netneutrality.command.basic;
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.command.*;
import me.bobert.netneutrality.core.command.CommandCategory;
import me.bobert.netneutrality.core.command.CommandContainer;
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.command.options.CommandOption;
import me.bobert.netneutrality.core.command.options.CommandOptionsBuilder;
import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.core.tasks.BuiltInTask;
import me.bobert.netneutrality.core.tasks.trait.WithButton;
import me.bobert.netneutrality.core.command.CommandRegistry;
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.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;
import net.dv8tion.jda.api.interactions.components.ActionRow;
import net.dv8tion.jda.api.interactions.components.buttons.Button;
import java.util.List;
public class HelpCommand extends AbstractCommand {
private final CommandRegistry commands;
private final TaskManager taskManager;
public HelpCommand() {
this(null, null);
}
public HelpCommand(CommandRegistry commands, TaskManager taskManager) {
this.commands = commands;
this.taskManager = taskManager;
}
@Override
public boolean needAdmin() {
return false;
@@ -61,15 +78,15 @@ public class HelpCommand extends AbstractCommand {
public CommandExecutionResult execute(CommandContext context) {
String command = context.getOption("command", null, CommandOption::getAsString);
if (command == null) {
Button commands = BuiltInTask.createButton(CommandHelp.class);
Button info = BuiltInTask.createButton(BotInfo.class);
Button reportbug = BuiltInTask.createButton(ReportBug.class);
ActionRow row = ActionRow.of(commands, info, reportbug);
Button commandsBtn = createTaskButton(CommandHelp.class);
Button info = createTaskButton(BotInfo.class);
Button reportbug = createTaskButton(ReportBug.class);
ActionRow row = ActionRow.of(commandsBtn, info, reportbug);
context.replyEphemeral("Thanks for using the Net Neutrality Bot! What do you need help with?", List.of(row));
return CommandExecutionResult.none();
}
CommandContainer container = CommandManager.getCommandContainer(command);
CommandContainer container = commands.getCommandContainer(command);
if (container.getCommand() == null) {
return CommandExecutionResult.warn("Command does not exist");
}
@@ -77,4 +94,12 @@ public class HelpCommand extends AbstractCommand {
context.replyEphemeral(MessageFunction.buildCommandHelpMessage(container));
return CommandExecutionResult.none();
}
private Button createTaskButton(Class<? extends WithButton> clazz) {
WithButton withButton = taskManager.findButtonTaskByClass(clazz);
if (withButton == null) {
throw new IllegalArgumentException("Task " + clazz.getName() + " was not registered by the task manager.");
}
return withButton.createButton();
}
}
@@ -5,10 +5,10 @@ package me.bobert.netneutrality.command.basic;
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.command.AbstractCommand;
import me.bobert.netneutrality.core.command.CommandCategory;
import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.options.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.impl.AbstractCommand;
import me.bobert.netneutrality.core.context.CommandContext;
public class PingCommand extends AbstractCommand {
@@ -5,25 +5,47 @@ package me.bobert.netneutrality.command.bot;
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.command.AbstractAdminCommand;
import me.bobert.netneutrality.core.command.CommandExecutionResult;
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.command.options.CommandOptionsBuilder;
import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.db.GSettings;
import me.bobert.netneutrality.db.service.GuildService;
import me.bobert.netneutrality.music.MusicManager;
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 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;
public ConfigureCommand() {
this(null, null);
}
public ConfigureCommand(GuildServiceProvider guildServices, MusicService musicService) {
this.guildServices = guildServices;
this.musicService = musicService;
}
public class ConfigureCommand extends AbstractAdminCommand {
@Override
public boolean isEnabled() {
@@ -56,69 +78,186 @@ public class ConfigureCommand extends AbstractAdminCommand {
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 = GuildService.get(context.getGuild().getId());
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));
MusicManager.get(context.getGuild()).setVolume(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()) {
@@ -128,13 +267,12 @@ public class ConfigureCommand extends AbstractAdminCommand {
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,17 +5,18 @@ 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.AbstractCommand;
import me.bobert.netneutrality.core.command.CommandCategory;
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.command.options.CommandOption;
import me.bobert.netneutrality.core.command.options.CommandOptionsBuilder;
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);
@@ -1 +1 @@
*.java
**/*.java
@@ -5,19 +5,31 @@ package me.bobert.netneutrality.command.git;
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.context.command.SlashCommandContext;
import me.bobert.netneutrality.core.command.AbstractCommand;
import me.bobert.netneutrality.core.command.CommandCategory;
import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.options.CommandOptionsBuilder;
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.tasks.git.ReportIssueTask;
import me.bobert.netneutrality.utils.Settings;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import me.bobert.netneutrality.core.tasks.TaskManager;
import me.bobert.netneutrality.utils.SettingsProvider;
public class ReportIssueCommand extends AbstractCommand {
private final SettingsProvider settings;
private final TaskManager taskManager;
public ReportIssueCommand(SettingsProvider settings, TaskManager taskManager) {
Objects.requireNonNull(settings, "SettingsProvider must not be null");
Objects.requireNonNull(taskManager, "TaskManager must not be null");
this.settings = settings;
this.taskManager = taskManager;
}
@Override
public boolean needAdmin() {
return false;
@@ -60,16 +72,16 @@ public class ReportIssueCommand extends AbstractCommand {
@Override
public CommandExecutionResult execute(CommandContext context) {
String gitlabHost = Settings.getInstance().get("GITLAB_HOST");
String gitlabToken = Settings.getInstance().get("GITLAB_TOKEN");
String projectId = Settings.getInstance().get("GITLAB_PROJECT_ID");
String gitlabHost = settings.get("GITLAB_HOST");
String gitlabToken = settings.get("GITLAB_TOKEN");
String projectId = settings.get("GITLAB_PROJECT_ID");
if (gitlabHost == null || gitlabToken == null || projectId == 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()) {
return CommandExecutionResult.task(new ReportIssueTask(context));
return CommandExecutionResult.task(new ReportIssueTask(taskManager, context, settings));
}
return CommandExecutionResult.error("Unable to create bug reports at this time, please try again later.");
@@ -6,14 +6,31 @@ package me.bobert.netneutrality.command.music;
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.command.AbstractMusicCommand;
import me.bobert.netneutrality.core.command.CommandCategory;
import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.options.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.impl.MusicCommand;
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;
public class BitconnectCommand extends AbstractMusicCommand {
public class BitconnectCommand extends MusicCommand {
private final MusicService musicService;
public BitconnectCommand() {
this(null, null);
}
public BitconnectCommand(MusicService musicService) {
this(musicService, null);
}
public BitconnectCommand(MusicService musicService, GuildServiceProvider guildServices) {
super(guildServices);
this.musicService = musicService;
}
@Override
public boolean isEnabled() {
@@ -42,7 +59,7 @@ public class BitconnectCommand extends AbstractMusicCommand {
@Override
public CommandExecutionResult execute(CommandContext context) {
MusicManager manager = MusicManager.get(context.getGuild());
MusicManager manager = musicService.get(context.getGuild());
context.ephemeral();
context.deleteMessage();
@@ -6,14 +6,31 @@ package me.bobert.netneutrality.command.music;
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.command.AbstractMusicCommand;
import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.options.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.impl.MusicCommand;
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.utils.MusicUtils;
public class ClearQueueCommand extends AbstractMusicCommand {
public class ClearQueueCommand extends MusicCommand {
private final MusicService musicService;
public ClearQueueCommand() {
this(null, null);
}
public ClearQueueCommand(MusicService musicService) {
this(musicService, null);
}
public ClearQueueCommand(MusicService musicService, GuildServiceProvider guildServices) {
super(guildServices);
this.musicService = musicService;
}
@Override
public boolean needAdmin() {
@@ -42,11 +59,11 @@ public class ClearQueueCommand extends AbstractMusicCommand {
@Override
public CommandExecutionResult execute(CommandContext context) {
MusicManager manager = MusicManager.get(context.getGuild());
MusicManager manager = musicService.get(context.getGuild());
context.ack();
if (MusicUtils.checkIfConnected(context)) {
return manager.getQueueManager().clearQueue();
if (MusicUtils.checkIfConnected(context, manager)) {
return CommandExecutionResult.from(manager.getQueueManager().clearQueue());
}
return CommandExecutionResult.none();
@@ -6,15 +6,32 @@ package me.bobert.netneutrality.command.music;
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.command.AbstractMusicCommand;
import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.options.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.impl.MusicCommand;
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.utils.MessageFunction;
import me.bobert.netneutrality.utils.MusicUtils;
public class CurrentlyPlayingCommand extends AbstractMusicCommand {
public class CurrentlyPlayingCommand extends MusicCommand {
private final MusicService musicService;
public CurrentlyPlayingCommand() {
this(null, null);
}
public CurrentlyPlayingCommand(MusicService musicService) {
this(musicService, null);
}
public CurrentlyPlayingCommand(MusicService musicService, GuildServiceProvider guildServices) {
super(guildServices);
this.musicService = musicService;
}
@Override
public boolean isEnabled() {
@@ -38,10 +55,10 @@ public class CurrentlyPlayingCommand extends AbstractMusicCommand {
@Override
public CommandExecutionResult execute(CommandContext context) {
MusicManager manager = MusicManager.get(context.getGuild());
MusicManager manager = musicService.get(context.getGuild());
context.ack();
if (MusicUtils.checkIfConnected(context)) {
if (MusicUtils.checkIfConnected(context, manager)) {
if (!manager.isCurrentlyPlaying()) {
return CommandExecutionResult.info("Bot isn't playing any tracks right now");
}
@@ -6,13 +6,31 @@ package me.bobert.netneutrality.command.music;
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.command.AbstractMusicCommand;
import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.options.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.impl.MusicCommand;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.utils.MusicUtils;
public class JoinCommand extends AbstractMusicCommand {
public class JoinCommand extends MusicCommand {
private final MusicService musicService;
public JoinCommand() {
this(null, null);
}
public JoinCommand(MusicService musicService) {
this(musicService, null);
}
public JoinCommand(MusicService musicService, GuildServiceProvider guildServices) {
super(guildServices);
this.musicService = musicService;
}
@Override
public boolean isEnabled() {
@@ -36,7 +54,9 @@ public class JoinCommand extends AbstractMusicCommand {
@Override
public CommandExecutionResult execute(CommandContext context) {
if (MusicUtils.connect(context)) {
MusicManager manager = musicService.get(context.getGuild());
if (MusicUtils.connect(context, manager)) {
context.reply("Joining current voice channel");
}
@@ -6,14 +6,38 @@ package me.bobert.netneutrality.command.music;
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.command.AbstractMusicCommand;
import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.options.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.impl.MusicCommand;
import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.utils.MusicUtils;
public class LeaveCommand extends AbstractMusicCommand {
/**
* Leaves the voice channel for the current guild.
*
* <p>Representative command migrated to constructor injection of {@link MusicService} (and optional
* {@link GuildServiceProvider} for the {@link MusicCommand} base). The no-arg constructor remains as
* a fallback for non-DI loaders.
*/
public class LeaveCommand extends MusicCommand {
private final MusicService musicService;
public LeaveCommand() {
this(null, null);
}
public LeaveCommand(MusicService musicService) {
this(musicService, null);
}
public LeaveCommand(MusicService musicService, GuildServiceProvider guildServices) {
super(guildServices);
this.musicService = musicService;
}
@Override
public boolean isEnabled() {
@@ -37,9 +61,9 @@ public class LeaveCommand extends AbstractMusicCommand {
@Override
public CommandExecutionResult execute(CommandContext context) {
MusicManager manager = MusicManager.get(context.getGuild());
MusicManager manager = musicService.get(context.getGuild());
if (MusicUtils.checkIfConnected(context)) {
if (MusicUtils.checkIfConnected(context, manager)) {
context.reply("Leaving voice channel and clearing queue");
manager.disconnect();
}
@@ -6,15 +6,32 @@ package me.bobert.netneutrality.command.music;
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.command.AbstractMusicCommand;
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.db.service.GuildServiceProvider;
import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.core.command.options.CommandOption;
import me.bobert.netneutrality.core.command.options.CommandOptionsBuilder;
import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.music.MusicManager;
import net.dv8tion.jda.api.interactions.commands.OptionType;
public class PlayCommand extends AbstractMusicCommand {
public class PlayCommand extends MusicCommand {
private final MusicService musicService;
public PlayCommand() {
this(null, null);
}
public PlayCommand(MusicService musicService) {
this(musicService, null);
}
public PlayCommand(MusicService musicService, GuildServiceProvider guildServices) {
super(guildServices);
this.musicService = musicService;
}
@Override
public boolean isEnabled() {
@@ -38,7 +55,7 @@ public class PlayCommand extends AbstractMusicCommand {
@Override
public CommandExecutionResult execute(CommandContext context) {
MusicManager manager = MusicManager.get(context.getGuild());
MusicManager manager = musicService.get(context.getGuild());
String query = context.getOption("query", CommandOption::getAsString);
context.ack();
@@ -5,10 +5,12 @@ package me.bobert.netneutrality.command.music;
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.command.AbstractMusicCommand;
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.db.service.GuildServiceProvider;
import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.core.command.options.CommandOption;
import me.bobert.netneutrality.core.command.options.CommandOptionsBuilder;
import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.utils.MessageFunction;
@@ -18,7 +20,22 @@ 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;
public class QueueCommand extends AbstractMusicCommand {
public class QueueCommand extends MusicCommand {
private final MusicService musicService;
public QueueCommand() {
this(null, null);
}
public QueueCommand(MusicService musicService) {
this(musicService, null);
}
public QueueCommand(MusicService musicService, GuildServiceProvider guildServices) {
super(guildServices);
this.musicService = musicService;
}
@Override
public boolean isEnabled() {
@@ -58,26 +75,26 @@ public class QueueCommand extends AbstractMusicCommand {
@Override
public CommandExecutionResult execute(CommandContext context) {
MusicManager manager = MusicManager.get(context.getGuild());
MusicManager manager = musicService.get(context.getGuild());
String sub = context.getSubcommand();
context.ack();
if (MusicUtils.checkIfConnected(context)) {
if (MusicUtils.checkIfConnected(context, manager)) {
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" -> {
@@ -95,7 +112,7 @@ public class QueueCommand extends AbstractMusicCommand {
return CommandExecutionResult.warn("The user isn't from this guild");
}
return manager.getQueueManager().removeTracksFromUser(member);
return CommandExecutionResult.from(manager.getQueueManager().removeTracksFromUser(member));
}
}
}
@@ -4,14 +4,37 @@ package me.bobert.netneutrality.command.music;/*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.command.AbstractMusicCommand;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.options.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.impl.MusicCommand;
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.utils.MusicUtils;
public class ResumeCommand extends AbstractMusicCommand {
public class ResumeCommand extends MusicCommand {
private final MusicService musicService;
public ResumeCommand() {
this(null, null);
}
public ResumeCommand(MusicService musicService) {
this(musicService, null);
}
public ResumeCommand(MusicService musicService, GuildServiceProvider guildServices) {
super(guildServices);
this.musicService = musicService;
}
@Override
public boolean isEnabled() {
return true;
@@ -34,10 +57,10 @@ public class ResumeCommand extends AbstractMusicCommand {
@Override
public CommandExecutionResult execute(CommandContext context) {
MusicManager manager = MusicManager.get(context.getGuild());
MusicManager manager = musicService.get(context.getGuild());
if (MusicUtils.checkIfConnected(context)) {
return manager.resume();
if (MusicUtils.checkIfConnected(context, manager)) {
return CommandExecutionResult.from(manager.resume());
}
return CommandExecutionResult.none();
@@ -6,14 +6,31 @@ package me.bobert.netneutrality.command.music;
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.command.AbstractMusicCommand;
import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.options.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.impl.MusicCommand;
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.utils.MusicUtils;
public class ShuffleCommand extends AbstractMusicCommand {
public class ShuffleCommand extends MusicCommand {
private final MusicService musicService;
public ShuffleCommand() {
this(null, null);
}
public ShuffleCommand(MusicService musicService) {
this(musicService, null);
}
public ShuffleCommand(MusicService musicService, GuildServiceProvider guildServices) {
super(guildServices);
this.musicService = musicService;
}
@Override
public boolean isEnabled() {
return true;
@@ -36,11 +53,11 @@ public class ShuffleCommand extends AbstractMusicCommand {
@Override
public CommandExecutionResult execute(CommandContext context) {
MusicManager manager = MusicManager.get(context.getGuild());
MusicManager manager = musicService.get(context.getGuild());
context.ack();
if (MusicUtils.checkIfConnected(context)) {
return manager.getQueueManager().shuffle();
if (MusicUtils.checkIfConnected(context, manager)) {
return CommandExecutionResult.from(manager.getQueueManager().shuffle());
}
return CommandExecutionResult.none();
@@ -6,10 +6,12 @@ package me.bobert.netneutrality.command.music;
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.command.AbstractMusicCommand;
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.db.service.GuildServiceProvider;
import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.core.command.options.CommandOption;
import me.bobert.netneutrality.core.command.options.CommandOptionsBuilder;
import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.utils.MusicUtils;
@@ -17,7 +19,22 @@ 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 SkipCommand extends AbstractMusicCommand {
public class SkipCommand extends MusicCommand {
private final MusicService musicService;
public SkipCommand() {
this(null, null);
}
public SkipCommand(MusicService musicService) {
this(musicService, null);
}
public SkipCommand(MusicService musicService, GuildServiceProvider guildServices) {
super(guildServices);
this.musicService = musicService;
}
@Override
public boolean isEnabled() {
@@ -42,15 +59,15 @@ public class SkipCommand extends AbstractMusicCommand {
@Override
public CommandExecutionResult execute(CommandContext context) {
MusicManager manager = MusicManager.get(context.getGuild());
MusicManager manager = musicService.get(context.getGuild());
context.ack();
if (MusicUtils.checkIfConnected(context)) {
if (MusicUtils.checkIfConnected(context, manager)) {
int skips = context.getOption("skip", 1, CommandOption::getAsInt);
if (UserFunction.isAdmin(context) || MusicUtils.isUserDj(context)) {
return manager.getQueueManager().skip(skips);
if (UserFunction.isAdmin(context) || MusicUtils.isUserDj(context, manager)) {
return CommandExecutionResult.from(manager.getQueueManager().skip(skips));
} else {
return manager.voteSkip(context);
return CommandExecutionResult.from(manager.voteSkip(context.getGuildUser()));
}
}
@@ -6,15 +6,32 @@ package me.bobert.netneutrality.command.music;
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.command.AbstractMusicCommand;
import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.options.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.impl.MusicCommand;
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.utils.MusicUtils;
import me.bobert.netneutrality.utils.UserFunction;
public class StopCommand extends AbstractMusicCommand {
public class StopCommand extends MusicCommand {
private final MusicService musicService;
public StopCommand() {
this(null, null);
}
public StopCommand(MusicService musicService) {
this(musicService, null);
}
public StopCommand(MusicService musicService, GuildServiceProvider guildServices) {
super(guildServices);
this.musicService = musicService;
}
@Override
public boolean isEnabled() {
@@ -38,10 +55,10 @@ public class StopCommand extends AbstractMusicCommand {
@Override
public CommandExecutionResult execute(CommandContext context) {
MusicManager manager = MusicManager.get(context.getGuild());
MusicManager manager = musicService.get(context.getGuild());
context.ack();
if (MusicUtils.checkIfConnected(context)) {
if (MusicUtils.checkIfConnected(context, manager)) {
if (UserFunction.isAdmin(context)) {
manager.stopTrack();
context.reply("Stopping current track");
@@ -5,15 +5,32 @@ package me.bobert.netneutrality.command.music;
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.command.AbstractMusicCommand;
import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.options.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.impl.MusicCommand;
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.utils.MessageFunction;
import me.bobert.netneutrality.utils.MusicUtils;
public class ViewQueueCommand extends AbstractMusicCommand {
public class ViewQueueCommand extends MusicCommand {
private final MusicService musicService;
public ViewQueueCommand() {
this(null, null);
}
public ViewQueueCommand(MusicService musicService) {
this(musicService, null);
}
public ViewQueueCommand(MusicService musicService, GuildServiceProvider guildServices) {
super(guildServices);
this.musicService = musicService;
}
@Override
public boolean isEnabled() {
@@ -37,10 +54,10 @@ public class ViewQueueCommand extends AbstractMusicCommand {
@Override
public CommandExecutionResult execute(CommandContext context) {
MusicManager manager = MusicManager.get(context.getGuild());
MusicManager manager = musicService.get(context.getGuild());
context.ack();
if (MusicUtils.checkIfConnected(context)) {
if (MusicUtils.checkIfConnected(context, manager)) {
int queueSize = manager.getQueueSize();
if (queueSize == 0) {
return CommandExecutionResult.warn("Queue is empty!");
@@ -6,10 +6,12 @@ package me.bobert.netneutrality.command.music;
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.command.AbstractMusicCommand;
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.db.service.GuildServiceProvider;
import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.core.command.options.CommandOption;
import me.bobert.netneutrality.core.command.options.CommandOptionsBuilder;
import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.utils.MusicUtils;
@@ -17,7 +19,22 @@ 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 VolumeCommand extends AbstractMusicCommand {
public class VolumeCommand extends MusicCommand {
private final MusicService musicService;
public VolumeCommand() {
this(null, null);
}
public VolumeCommand(MusicService musicService) {
this(musicService, null);
}
public VolumeCommand(MusicService musicService, GuildServiceProvider guildServices) {
super(guildServices);
this.musicService = musicService;
}
@Override
public boolean isEnabled() {
@@ -42,11 +59,11 @@ public class VolumeCommand extends AbstractMusicCommand {
@Override
public CommandExecutionResult execute(CommandContext context) {
MusicManager manager = MusicManager.get(context.getGuild());
MusicManager manager = musicService.get(context.getGuild());
int volume = context.getOption("volume", 30, CommandOption::getAsInt);
if (MusicUtils.checkIfConnected(context)) {
if (UserFunction.isAdmin(context) || MusicUtils.isUserDj(context)) {
if (MusicUtils.checkIfConnected(context, manager)) {
if (UserFunction.isAdmin(context) || MusicUtils.isUserDj(context, manager)) {
manager.setVolume(volume);
return CommandExecutionResult.success(String.format("Volume set to %s", volume));
}
@@ -5,14 +5,31 @@ package me.bobert.netneutrality.command.music;
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.command.AbstractMusicCommand;
import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.options.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.impl.MusicCommand;
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.utils.MusicUtils;
public class VoteSkipCommand extends AbstractMusicCommand {
public class VoteSkipCommand extends MusicCommand {
private final MusicService musicService;
public VoteSkipCommand() {
this(null, null);
}
public VoteSkipCommand(MusicService musicService) {
this(musicService, null);
}
public VoteSkipCommand(MusicService musicService, GuildServiceProvider guildServices) {
super(guildServices);
this.musicService = musicService;
}
@Override
public boolean isEnabled() {
@@ -36,10 +53,10 @@ public class VoteSkipCommand extends AbstractMusicCommand {
@Override
public CommandExecutionResult execute(CommandContext context) {
MusicManager manager = MusicManager.get(context.getGuild());
MusicManager manager = musicService.get(context.getGuild());
if (MusicUtils.checkIfConnected(context)) {
return manager.voteSkip(context);
if (MusicUtils.checkIfConnected(context, manager)) {
return CommandExecutionResult.from(manager.voteSkip(context.getGuildUser()));
}
return CommandExecutionResult.none();
@@ -6,25 +6,35 @@ package me.bobert.netneutrality.command.react;
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.command.AbstractMessageMenuCommand;
import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.impl.MessageMenuCommand;
import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.core.tasks.TaskManager;
import me.bobert.netneutrality.tasks.emoji.EmojiReactionRoleTask;
import me.bobert.netneutrality.tasks.reaction.EmojiReactionRoleTask;
import me.bobert.netneutrality.tasks.reaction.EmojiRoleFirstStep;
import net.dv8tion.jda.api.entities.Message;
import me.bobert.netneutrality.core.tasks.TaskManager;
import java.util.Objects;
public class CreateEmojiReaction extends MessageMenuCommand {
private final TaskManager taskManager;
public CreateEmojiReaction(TaskManager taskManager) {
Objects.requireNonNull(taskManager, "TaskManager must not be null");
this.taskManager = taskManager;
}
public class CreateEmojiReaction extends AbstractMessageMenuCommand {
@Override
public CommandExecutionResult execute(Message target, CommandContext context) {
EmojiReactionRoleTask task = TaskManager.getInstance().findTaskByPartialId(EmojiReactionRoleTask.class, target.getId()).orElse(null);
EmojiReactionRoleTask task = taskManager.findTaskByPartialId(EmojiReactionRoleTask.class, target.getId()).orElse(null);
if (task != null) {
context.replyEphemeral("This message is already registered with the bot, please use the modify command instead");
return CommandExecutionResult.none();
}
return CommandExecutionResult.task(new EmojiRoleFirstStep(target));
return CommandExecutionResult.task(new EmojiRoleFirstStep(taskManager, target));
}
@Override
@@ -6,25 +6,35 @@ package me.bobert.netneutrality.command.react;
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.command.AbstractMessageMenuCommand;
import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.impl.MessageMenuCommand;
import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.core.tasks.TaskManager;
import me.bobert.netneutrality.tasks.emoji.EmojiReactionRoleTask;
import me.bobert.netneutrality.tasks.reaction.EmojiReactionRoleTask;
import me.bobert.netneutrality.tasks.reaction.EmojiRoleModify;
import net.dv8tion.jda.api.entities.Message;
import me.bobert.netneutrality.core.tasks.TaskManager;
import java.util.Objects;
public class ModifyEmojiReaction extends MessageMenuCommand {
private final TaskManager taskManager;
public ModifyEmojiReaction(TaskManager taskManager) {
Objects.requireNonNull(taskManager, "TaskManager must not be null");
this.taskManager = taskManager;
}
public class ModifyEmojiReaction extends AbstractMessageMenuCommand {
@Override
public CommandExecutionResult execute(Message target, CommandContext context) {
EmojiReactionRoleTask task = TaskManager.getInstance().findTaskByPartialId(EmojiReactionRoleTask.class, target.getId()).orElse(null);
EmojiReactionRoleTask task = taskManager.findTaskByPartialId(EmojiReactionRoleTask.class, target.getId()).orElse(null);
if (task == null) {
context.replyEphemeral("This message is not registered with the bot, please use the create command instead");
return CommandExecutionResult.none();
}
return CommandExecutionResult.task(new EmojiRoleModify(target, task));
return CommandExecutionResult.task(new EmojiRoleModify(taskManager, target, task));
}
@Override
@@ -6,11 +6,12 @@ package me.bobert.netneutrality.context;
*/
import me.bobert.netneutrality.core.context.AbstractContext;
import me.bobert.netneutrality.core.context.AsyncContext;
import me.bobert.netneutrality.core.context.NoMessageContext;
import net.dv8tion.jda.api.components.MessageTopLevelComponent;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.channel.middleman.GuildMessageChannel;
import net.dv8tion.jda.api.events.Event;
import net.dv8tion.jda.api.interactions.components.LayoutComponent;
import net.dv8tion.jda.api.utils.messages.MessageCreateData;
import java.util.List;
@@ -43,7 +44,7 @@ public class MusicChannelContext extends AbstractContext implements NoMessageCon
}
@Override
public void reply(MessageCreateData messageCreateData, boolean ephemeral, List<LayoutComponent> components, Consumer<AsyncContext> consumer) {
public void reply(MessageCreateData messageCreateData, boolean ephemeral, List<MessageTopLevelComponent> components, Consumer<AsyncContext> consumer) {
if (getChannel().canTalk()) {
getChannel().sendMessage(messageCreateData).setComponents(components).queue(message -> consumer.accept(new AsyncContext(message)));
}
@@ -9,8 +9,12 @@ import me.bobert.netneutrality.core.command.options.CommandOption;
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.Settings;
import net.dv8tion.jda.api.entities.*;
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;
import net.dv8tion.jda.api.entities.Role;
import net.dv8tion.jda.api.entities.channel.ChannelType;
import net.dv8tion.jda.api.entities.channel.middleman.GuildChannel;
import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
@@ -19,16 +23,28 @@ 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;
public MessageCommandContext(MessageReceivedEvent event) {
super(event.getJDA(), event.isFromGuild() ? event.getGuild() : null, event.getChannel(), event.getMember(), event.getAuthor());
private final SettingsProvider settings;
public MessageCommandContext(MessageReceivedEvent event, SettingsProvider settings) {
super(event.getJDA(), event.isFromGuild() ? event.getGuild() : null, event.getChannel(), event.getMember(), event.getAuthor(), settings);
this.settings = settings;
this.event = event;
this.message = event.getMessage();
}
@@ -62,27 +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 -> {
String str = args[index];
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 {
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 ((str.contains("<@") || str.contains("<#")) && str.contains(">")) {
invalidateContext(data, "expected a string at position " + (index + 1) + " but a role/user/channel was provided");
break;
}
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();
@@ -90,156 +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();
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")) {
value = Boolean.parseBoolean(str);
String str = args[index++];
if (!str.equalsIgnoreCase("true") && !str.equalsIgnoreCase("false")) {
invalidateContext(data, "expected true or false");
break;
}
invalidateContext(data, "expected true or false");
value = Boolean.parseBoolean(str);
}
case NUMBER -> {
double num = Long.parseLong(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.contains(channel.getType())) {
invalidateContext(data, "not a valid channel type at index " + (index + 1) +
if (!requiredChannelTypes.isEmpty() && !requiredChannelTypes.contains(channel.getType())) {
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 (data.isRequired()) {
// 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) {
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) {
@@ -261,7 +344,7 @@ public class MessageCommandContext extends CommandContext implements MessageCont
@Override
public String parseCommandName() {
String rawMessage = message.getContentRaw().trim();
String prefix = Settings.getInstance().getPrefix();
String prefix = settings.getPrefix();
if (rawMessage.startsWith(prefix)) {
return getFullCommand().split(" ", 2)[0];
@@ -273,7 +356,7 @@ public class MessageCommandContext extends CommandContext implements MessageCont
@Override
public String getFullCommand() {
String rawMessage = message.getContentRaw().trim();
String prefix = Settings.getInstance().getPrefix();
String prefix = settings.getPrefix();
return rawMessage.replaceFirst("^[" + prefix + "\\s]+", "").trim();
}
@@ -9,6 +9,7 @@ package me.bobert.netneutrality.context.command;
import me.bobert.netneutrality.core.command.options.CommandOption;
import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.core.context.InteractionContext;
import me.bobert.netneutrality.utils.SettingsProvider;
import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.entities.channel.ChannelType;
import net.dv8tion.jda.api.events.Event;
@@ -20,8 +21,13 @@ import java.util.List;
public class MessageMenuContext extends CommandContext implements InteractionContext {
private final MessageContextInteractionEvent event;
public MessageMenuContext(MessageContextInteractionEvent event) {
super(event.getJDA(), event.getGuild(), event.getChannel(), event.getMember(), event.getUser());
@Override
public boolean isInteraction() {
return true;
}
public MessageMenuContext(MessageContextInteractionEvent event, SettingsProvider settings) {
super(event.getJDA(), event.getGuild(), event.getChannel(), event.getMember(), event.getUser(), settings);
this.event = event;
}
@@ -9,6 +9,7 @@ import me.bobert.netneutrality.core.command.options.CommandOption;
import me.bobert.netneutrality.core.command.options.SlashCommandOption;
import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.core.context.InteractionContext;
import me.bobert.netneutrality.utils.SettingsProvider;
import net.dv8tion.jda.api.entities.channel.ChannelType;
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
@@ -19,11 +20,16 @@ import java.util.stream.Collectors;
public class SlashCommandContext extends CommandContext implements InteractionContext {
private final SlashCommandInteractionEvent event;
public SlashCommandContext(SlashCommandInteractionEvent event) {
super(event.getJDA(), event.getGuild(), event.getChannel(), event.getMember(), event.getUser());
public SlashCommandContext(SlashCommandInteractionEvent event, SettingsProvider settings) {
super(event.getJDA(), event.getGuild(), event.getChannel(), event.getMember(), event.getUser(), settings);
this.event = event;
}
@Override
public boolean isInteraction() {
return true;
}
@Override
public SlashCommandInteractionEvent getEvent() {
return event;
@@ -9,6 +9,7 @@ package me.bobert.netneutrality.context.command;
import me.bobert.netneutrality.core.command.options.CommandOption;
import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.core.context.InteractionContext;
import me.bobert.netneutrality.utils.SettingsProvider;
import net.dv8tion.jda.api.entities.User;
import net.dv8tion.jda.api.entities.channel.ChannelType;
import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel;
@@ -21,8 +22,13 @@ import java.util.List;
public class UserMenuContext extends CommandContext implements InteractionContext {
private final UserContextInteractionEvent event;
public UserMenuContext(UserContextInteractionEvent event) {
super(event.getJDA(), event.getGuild(), (MessageChannel) event.getChannel(), event.getMember(), event.getUser());
@Override
public boolean isInteraction() {
return true;
}
public UserMenuContext(UserContextInteractionEvent event, SettingsProvider settings) {
super(event.getJDA(), event.getGuild(), (MessageChannel) event.getChannel(), event.getMember(), event.getUser(), settings);
this.event = event;
}
@@ -5,14 +5,11 @@ package me.bobert.netneutrality.context.tasks;
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.context.AsyncContext;
import me.bobert.netneutrality.core.context.AsyncContext;
import me.bobert.netneutrality.core.context.NoMessageContext;
import me.bobert.netneutrality.core.context.TaskContext;
import net.dv8tion.jda.api.entities.channel.ChannelType;
import net.dv8tion.jda.api.components.MessageTopLevelComponent;
import net.dv8tion.jda.api.events.message.react.MessageReactionAddEvent;
import net.dv8tion.jda.api.interactions.InteractionHook;
import net.dv8tion.jda.api.interactions.components.LayoutComponent;
import net.dv8tion.jda.api.interactions.modals.Modal;
import net.dv8tion.jda.api.utils.messages.MessageCreateData;
import java.util.List;
@@ -45,7 +42,7 @@ public class EmojiTaskContext extends TaskContext implements NoMessageContext {
}
@Override
public void reply(MessageCreateData messageCreateData, boolean ephemeral, List<LayoutComponent> components, Consumer<AsyncContext> consumer) {
public void reply(MessageCreateData messageCreateData, boolean ephemeral, List<MessageTopLevelComponent> components, Consumer<AsyncContext> consumer) {
if (getChannel() != null && getChannel().canTalk()) {
getChannel().sendMessage(messageCreateData).setComponents(components).queue(message -> consumer.accept(new AsyncContext(message)));
}
@@ -1,74 +0,0 @@
package me.bobert.netneutrality.core;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.db.service.GuildService;
import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.music.TrackScheduler;
import org.json.JSONArray;
import org.json.JSONObject;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.util.Map;
public class BotState {
public static boolean SAFE = false;
//TODO: is it needed?
private static void saveGuildsState() {
Map<String, GuildService> services = GuildService.getServices();
JSONObject main = new JSONObject();
JSONArray guildsArray = new JSONArray();
for (MusicManager manager : MusicManager.getManagers()) {
TrackScheduler trackScheduler = manager.getScheduler();
}
for (GuildService service : services.values()) {
JSONObject guild = new JSONObject();
JSONObject music = new JSONObject();
guild.put("id", service.getEntity().getGuildId());
// MessageChannel currentChannel = trackScheduler.getTextChannel();
// if (currentChannel == null) {
// music.put("current_channel", "null");
// } else {
// music.put("current_channel", currentChannel.getId());
// }
guild.put("music", music);
guildsArray.put(guild);
}
main.put("guilds", guildsArray);
File state = new File("state.json");
try {
if (!state.exists()) {
state.createNewFile();
}
try (FileWriter writer = new FileWriter(state)) {
writer.write(main.toString());
}
} catch (IOException e) {
e.printStackTrace();
}
}
public static void dump() {
if (!SAFE) {
saveGuildsState();
}
}
}
@@ -5,11 +5,12 @@ 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.db.service.GuildService;
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.utils.MessageFunction;
import net.dv8tion.jda.api.entities.Guild;
@@ -17,9 +18,52 @@ 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 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) {
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
@@ -27,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) {
@@ -58,36 +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) {
if (GuildService.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();
}
}
@@ -5,7 +5,6 @@ package me.bobert.netneutrality.core.command;
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.command.options.CommandOptionsBuilder;
import me.bobert.netneutrality.core.context.CommandContext;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel;
@@ -5,35 +5,178 @@ package me.bobert.netneutrality.core.command;
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.command.options.CommandOptions;
import me.bobert.netneutrality.core.command.options.CommandOptionsBuilder;
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;
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 final Command command;
private CommandOptions commandOptions;
private CommandDataImpl commandData;
private List<OptionData> options;
private List<SubcommandData> subcommands;
private 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);
}
}
public Command getCommand() {
return command;
}
public CommandOptions getCommandOptions() {
return commandOptions;
public SlashCommandData getSlashCommandData() {
return commandData;
}
public void init() {
initOptions();
public List<OptionData> getOptions() {
return options;
}
private void initOptions() {
if (command != null) {
CommandOptionsBuilder commandOptionsBuilder = new CommandOptionsBuilder(command.getType(), command);
command.registerCommandOptions(commandOptionsBuilder);
commandOptions = commandOptionsBuilder.build();
public List<SubcommandData> getSubcommands() {
return subcommands;
}
public List<SubcommandGroupData> getSubcommandGroups() {
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();
}
}
@@ -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();
};
}
}
@@ -5,24 +5,56 @@ package me.bobert.netneutrality.core.command;
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.bootstrap.ApplicationContext;
import me.bobert.netneutrality.bootstrap.ReflectionHelper;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.interactions.InteractionContextType;
import net.dv8tion.jda.api.interactions.commands.build.CommandData;
import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData;
import net.dv8tion.jda.api.requests.restaction.CommandListUpdateAction;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.reflections.Reflections;
import java.lang.reflect.Modifier;
import java.util.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
public class CommandManager {
/**
* Instance-based {@link CommandRegistry} owning the discovered command map.
*/
public class CommandManager implements CommandRegistry {
private static final Logger logger = LogManager.getLogger(CommandManager.class);
public static final Map<String, CommandContainer> commands = new ConcurrentHashMap<>();
public static void init() {
private final Map<String, CommandContainer> commands = new ConcurrentHashMap<>();
private final ApplicationContext context;
/**
* Creates a command manager that can inject context-registered dependencies into reflectively
* loaded commands.
*
* @param context the application context; must not be {@code null}
*/
public CommandManager(ApplicationContext context) {
Objects.requireNonNull(context, "ApplicationContext must not be null");
this.context = context;
}
@Override
public int priority() {
return 30;
}
@Override
public void start() {
init();
}
public void init() {
logger.info("Loading commands..");
initCommands();
@@ -30,48 +62,56 @@ public class CommandManager {
logger.info("Loaded {} commands!", commands.size());
}
public static Command getCommand(String commandString) {
return getCommandContainer(commandString).getCommand();
@Override
public CommandContainer getCommandContainer(String name) {
return commands.getOrDefault(name, CommandContainer.NULL_COMMAND);
}
public static CommandContainer getCommandContainer(String commandString) {
return commands.getOrDefault(commandString, CommandContainer.NULL_COMMAND);
@Override
public boolean hasCommand(String name) {
return commands.containsKey(name);
}
public static void initCommands() {
Set<Class<? extends Command>> classes = new Reflections("me.bobert.netneutrality.command").getSubTypesOf(Command.class);
for (Class<? extends Command> s : classes) {
try {
if (Modifier.isAbstract(s.getModifiers())) {
continue;
/**
* Exposes the live command map for call sites (and tests) that still iterate or mutate it
* directly. Prefer {@link #hasCommand(String)} / {@link #getCommandContainer(String)} where
* possible.
*
* @return the registered commands keyed by name
*/
@Override
public Map<String, CommandContainer> getCommands() {
return commands;
}
Command command = s.getConstructor().newInstance();
public void initCommands() {
Consumer<Command> onLoaded = command -> {
if (!command.isEnabled()) {
continue;
logger.info("Skipping command {} as it is disabled", command.getClass().getName());
return;
}
if (command.getCommandName() == null) {
throw new IllegalArgumentException("Command name is null");
logger.error("Failed to load command {}: Command name is null", command.getClass().getName());
return;
}
if (command.getDescription() == null && command.getType() == Command.Type.SLASH) {
throw new IllegalArgumentException("Command description is null");
logger.error("Failed to load command {}: Command description is null", command.getClass().getName());
return;
}
CommandContainer container = new CommandContainer(command);
container.init();
if (!commands.containsKey(command.getCommandName())) {
commands.put(command.getCommandName(), container);
}
} catch (Exception e) {
logger.error("Failed to load command {}: {}", s.getSimpleName(), e.getMessage());
}
}
};
ReflectionHelper.loadCommands(Command.class, context, onLoaded);
}
public static void registerSlashCommands(JDA jda) {
@Override
public void registerSlashCommands(JDA jda) {
CommandListUpdateAction commandListUpdateAction = jda.updateCommands();
List<CommandData> slashCommands = new ArrayList<>();
@@ -83,9 +123,9 @@ public class CommandManager {
logger.debug("Registering slash command: {}", command.getCommandName());
try {
SlashCommandData data = container.getCommandOptions().getSlashCommandData();
SlashCommandData data = container.getSlashCommandData();
data.setDefaultPermissions(command.getSlashCommandPermission());
data.setGuildOnly(command.isGuildOnly());
data.setContexts(command.isGuildOnly() ? Set.of(InteractionContextType.GUILD) : Set.of(InteractionContextType.GUILD, InteractionContextType.BOT_DM));
slashCommands.add(data);
} catch (Exception e) {
@@ -1,30 +1,35 @@
package me.bobert.netneutrality.core.command.options;
package me.bobert.netneutrality.core.command;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.command.Command;
import net.dv8tion.jda.api.interactions.DiscordLocale;
import net.dv8tion.jda.api.interactions.IntegrationType;
import net.dv8tion.jda.api.interactions.InteractionContextType;
import net.dv8tion.jda.api.interactions.commands.DefaultMemberPermissions;
import net.dv8tion.jda.api.interactions.commands.build.*;
import net.dv8tion.jda.api.interactions.commands.build.Commands;
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;
import net.dv8tion.jda.api.interactions.commands.build.SubcommandGroupData;
import net.dv8tion.jda.api.interactions.commands.localization.LocalizationFunction;
import net.dv8tion.jda.api.interactions.commands.localization.LocalizationMap;
import net.dv8tion.jda.api.utils.data.DataObject;
import net.dv8tion.jda.internal.interactions.CommandDataImpl;
import javax.annotation.Nonnull;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Predicate;
public class CommandOptionsBuilder implements SlashCommandData {
private final Command command;
private CommandDataImpl commandData;
public CommandOptionsBuilder(Command.Type type, Command command) {
this.command = command;
if (type == Command.Type.SLASH) {
commandData = (CommandDataImpl) Commands.slash(command.getCommandName(), command.getDescription());
@@ -39,8 +44,8 @@ public class CommandOptionsBuilder implements SlashCommandData {
}
}
public CommandOptions build() {
return new CommandOptions(command, commandData);
public CommandDataImpl build() {
return commandData;
}
@Override
@@ -69,8 +74,13 @@ public class CommandOptionsBuilder implements SlashCommandData {
}
@Override
public @Nonnull SlashCommandData setGuildOnly(boolean guildOnly) {
return commandData.setGuildOnly(guildOnly);
public @Nonnull SlashCommandData setContexts(@Nonnull Collection<InteractionContextType> contexts) {
return commandData.setContexts(contexts);
}
@Override
public @Nonnull SlashCommandData setIntegrationTypes(@Nonnull Collection<IntegrationType> integrationTypes) {
return commandData.setIntegrationTypes(integrationTypes);
}
@Override
@@ -99,8 +109,13 @@ public class CommandOptionsBuilder implements SlashCommandData {
}
@Override
public boolean isGuildOnly() {
return commandData.isGuildOnly();
public @Nonnull Set<InteractionContextType> getContexts() {
return commandData.getContexts();
}
@Override
public @Nonnull Set<IntegrationType> getIntegrationTypes() {
return commandData.getIntegrationTypes();
}
@Override
@@ -6,74 +6,84 @@ package me.bobert.netneutrality.core.command;
*/
import io.sentry.Sentry;
import me.bobert.netneutrality.context.command.SlashCommandContext;
import me.bobert.netneutrality.core.LogHandler;
import me.bobert.netneutrality.core.command.options.CommandOptions;
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;
import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.core.context.InteractionContext;
import me.bobert.netneutrality.core.tasks.Task;
import me.bobert.netneutrality.core.tasks.TaskManager;
import me.bobert.netneutrality.exceptions.CommandExecutionException;
import me.bobert.netneutrality.exceptions.GracefulContextError;
import me.bobert.netneutrality.core.tasks.TaskManager;
import me.bobert.netneutrality.utils.UserFunction;
import net.dv8tion.jda.api.interactions.commands.OptionType;
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
import net.dv8tion.jda.api.interactions.commands.build.SubcommandData;
import net.dv8tion.jda.api.interactions.commands.build.SubcommandGroupData;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import static java.util.function.Predicate.not;
import static me.bobert.netneutrality.core.command.CommandExecutionResult.Result.*;
import java.util.Objects;
public class CommandProcessor {
private static final Logger logger = LogManager.getLogger(CommandProcessor.class);
private final CommandContext context;
private final CommandRegistry commands;
private final TaskManager taskManager;
private final List<CommandPrecondition> preconditions;
private final UsageValidator usageValidator;
private final CommandResultHandler resultHandler;
public CommandProcessor(CommandContext context) {
public CommandProcessor(CommandContext context, CommandRegistry commands, TaskManager taskManager) {
this(context, commands, taskManager, defaultPreconditions(), new UsageValidator(), new CommandResultHandler());
}
public CommandProcessor(CommandContext context, CommandRegistry commands, TaskManager taskManager,
List<CommandPrecondition> preconditions, UsageValidator usageValidator,
CommandResultHandler resultHandler) {
this.context = context;
this.commands = Objects.requireNonNull(commands, "commands");
this.taskManager = Objects.requireNonNull(taskManager, "taskManager");
this.preconditions = Objects.requireNonNull(preconditions, "preconditions");
this.usageValidator = Objects.requireNonNull(usageValidator, "usageValidator");
this.resultHandler = Objects.requireNonNull(resultHandler, "resultHandler");
}
private static List<CommandPrecondition> defaultPreconditions() {
return List.of(
new GuildOnlyPrecondition(),
new DisabledCommandPrecondition(),
new SlashCommandOnlyPrecondition(),
new PermissionPrecondition()
);
}
public void dispatch() {
if (!CommandManager.commands.containsKey(context.parseCommandName())) {
if (!commands.hasCommand(context.parseCommandName())) {
return;
}
CommandContainer container = CommandManager.getCommandContainer(context.parseCommandName());
CommandContainer container = commands.getCommandContainer(context.parseCommandName());
Command command = container.getCommand();
if (command == null) {
throw new IllegalArgumentException("Context has no command");
}
context.setCommand(command);
if (command.isGuildOnly() && !context.isFromGuild()) {
context.replyEphemeral("This command is only available in a guild");
context.deleteMessage();
for (CommandPrecondition precondition : preconditions) {
if (!precondition.check(context, command)) {
return;
}
if (command.isSlashCommandOnly() && !(context instanceof InteractionContext)) {
context.replyEphemeral("This command is only available as a slash command");
return;
}
try {
logger.info("Context ID: {}", context.getId());
logger.debug("Context ID: {}", context.getId());
logger.info("Executing command {} on guild {}", command.getCommandName(), context.getGuild() == null ? "PrivateMessage" : context.getGuild().getId());
configureSentry(command);
if (command.getType() == Command.Type.SLASH && !canExecute(command)) {
context.reply("You are not powerful enough, scrub");
return;
}
if (command.getType() == Command.Type.SLASH && !checkUsage(container.getCommandOptions())) {
if (command.getType() == Command.Type.SLASH && !usageValidator.validate(context, container)) {
return;
}
@@ -88,31 +98,7 @@ public class CommandProcessor {
}
CommandExecutionResult commandExecutionResult = command.execute(context);
if (commandExecutionResult == null) {
throw new RuntimeException("CommandExecutionResult is null");
}
CommandExecutionResult.Result result = commandExecutionResult.getResult();
if (result == NOACTION) {
return;
}
if (result == TASK) {
TaskManager.getInstance().insert(context, commandExecutionResult.getTask());
}
String message = commandExecutionResult.getMessage();
if (result == ERROR) {
logger.error("Context[{}]: Error from command: {}", context.getId(), message, commandExecutionResult.getException());
}
if (result == WARN) {
logger.warn("Context[{}]: Warning from command: {}", context.getId(), message);
}
if (message != null) {
context.reply(message);
}
resultHandler.handle(context, commandExecutionResult, taskManager);
} catch (GracefulContextError exception) {
logger.error(exception.getMessage());
context.replyEphemeral(exception.getMessage());
@@ -123,127 +109,6 @@ public class CommandProcessor {
}
}
private boolean checkUsage(CommandOptions commandOptions) {
List<SubcommandGroupData> groups = commandOptions.getSubcommandGroups();
List<SubcommandData> subcommands = commandOptions.getSubcommands();
List<OptionData> options = commandOptions.getOptions();
String[] possibleCommands = Arrays.stream(context.getFullCommand().split(" ")).skip(1).toArray(String[]::new);
if (possibleCommands.length == 0) {
if (!groups.isEmpty()) {
// missing subcommand group
context.reply(invalidUsage("missing subcommand group"));
return false;
}
if (!subcommands.isEmpty()) {
// missing subcommand
context.reply(invalidUsage("missing subcommand"));
return false;
}
return checkOptions(possibleCommands, 0, options);
}
SubcommandGroupData group = groups.stream().filter(data -> data.getName().equals(possibleCommands[0])).findFirst().orElse(null);
if (group == null) {
SubcommandData subcommand = subcommands.stream().filter(data -> data.getName().equals(possibleCommands[0])).findFirst().orElse(null);
if (subcommand == null) {
if (!groups.isEmpty()) {
// no group found but one is expected
context.reply(invalidUsage("unknown subcommand group"));
return false;
}
if (!subcommands.isEmpty()) {
// no subcommand found but one is expected
context.reply(invalidUsage("unknown subcommand"));
return false;
}
// no group found, no subcommand found, command with options only
return checkOptions(possibleCommands, 0, options);
}
// no group found, subcommand found
context.setSubcommand(subcommand);
return checkOptions(possibleCommands, 1, subcommand.getOptions());
}
if (possibleCommands.length < 2) {
// missing subcommand for group
context.reply(invalidUsage("missing subcommand for command group"));
return false;
}
SubcommandData subcommand = group.getSubcommands().stream().filter(data -> data.getName().equals(possibleCommands[1])).findFirst().orElse(null);
if (subcommand == null) {
// group found, no subcommand found
context.reply(invalidUsage("unknown subcommand in command group"));
return false;
}
// group found, subcommand found
context.setSubcommandGroup(group);
context.setSubcommand(subcommand);
return checkOptions(possibleCommands, 2, subcommand.getOptions());
}
private boolean checkOptions(String[] args, int skip, List<OptionData> options) {
if (context instanceof InteractionContext) {
context.setCurrentCommandOptions(args, options);
return true; // slash commands are validated by discord
}
List<String> arguments = new ArrayList<>(Arrays.stream(args).skip(skip).toList());
int required = options.stream().filter(OptionData::isRequired).toList().size();
int optional = options.stream().filter(not(OptionData::isRequired)).toList().size();
if (arguments.size() < required) {
context.reply(invalidUsage("missing one or more required arguments"));
return false;
}
// check if last option is a string, if it is then combine all remaining strings into one
// ["@user", "you", "are", "banned"] becomes ["@user", "you are banned"]
if (!options.isEmpty() && arguments.size() > 1) {
OptionData last = options.get(options.size() - 1);
if (last.getType() == OptionType.STRING) {
List<String> lastArgs = arguments.stream().skip(options.size() - 1).toList();
arguments.removeAll(lastArgs);
arguments.add(String.join(" ", lastArgs));
}
}
if (arguments.size() > required + optional) {
context.reply(invalidUsage("too many arguments"));
return false;
}
context.setCurrentCommandOptions(arguments.toArray(String[]::new), options);
return true;
}
private boolean canExecute(Command command) {
if (command.canBeExecutedFrom(context.getChannel())) {
return true;
}
if (context instanceof SlashCommandContext) {
return true; // no further checks since slash perms are handled by guild settings on discord
}
if (command.canBeExecutedBy(context.getGuildUser())) {
return true;
}
return command.needAdmin() && UserFunction.isAdmin(context.getGuildUser());
}
private String invalidUsage(String message) {
return String.format("Invalid usage for command: %s\n" +
"Use **/help %s** for information on how to use this command", message, context.getCommand().getCommandName());
}
private void configureSentry(Command command) {
Sentry.configureScope(scope -> {
scope.setTag("command", command.getCommandName());
@@ -0,0 +1,51 @@
package me.bobert.netneutrality.core.command;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.bootstrap.Lifecycle;
import net.dv8tion.jda.api.JDA;
import java.util.Map;
/**
* Owns the bot command registry and slash-command registration against Discord.
*
* <p>As a {@link Lifecycle} component it discovers and loads commands on {@link #start()}.
*/
public interface CommandRegistry extends Lifecycle {
/**
* Returns the container for the named command, or a null-object container if unknown.
*
* @param name the command name
* @return the command container
*/
CommandContainer getCommandContainer(String name);
/**
* Whether a command with the given name is registered.
*
* @param name the command name
* @return {@code true} if registered
*/
boolean hasCommand(String name);
/**
* Pushes every enabled slash command up to Discord via the given JDA instance.
*
* @param jda the connected JDA client
*/
void registerSlashCommands(JDA jda);
/**
* Returns the live command map keyed by command name.
*
* @return the registered commands
*/
Map<String, CommandContainer> getCommands();
}
@@ -0,0 +1,49 @@
package me.bobert.netneutrality.core.command;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.core.tasks.TaskManager;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import static me.bobert.netneutrality.core.command.CommandExecutionResult.Result.ERROR;
import static me.bobert.netneutrality.core.command.CommandExecutionResult.Result.NOACTION;
import static me.bobert.netneutrality.core.command.CommandExecutionResult.Result.TASK;
import static me.bobert.netneutrality.core.command.CommandExecutionResult.Result.WARN;
public class CommandResultHandler {
private static final Logger logger = LogManager.getLogger(CommandResultHandler.class);
public void handle(CommandContext context, CommandExecutionResult commandExecutionResult, TaskManager taskManager) {
if (commandExecutionResult == null) {
throw new RuntimeException("CommandExecutionResult is null");
}
CommandExecutionResult.Result result = commandExecutionResult.getResult();
if (result == NOACTION) {
return;
}
if (result == TASK) {
taskManager.insert(context, commandExecutionResult.getTask());
}
String message = commandExecutionResult.getMessage();
if (result == ERROR) {
logger.error("Context[{}]: Error from command: {}", context.getId(), message, commandExecutionResult.getException());
}
if (result == WARN) {
logger.warn("Context[{}]: Warning from command: {}", context.getId(), message);
}
if (message != null) {
context.reply(message);
}
}
}
@@ -0,0 +1,97 @@
package me.bobert.netneutrality.core.command;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.context.CommandContext;
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.ArrayList;
import java.util.Arrays;
import java.util.List;
public class UsageValidator {
public boolean validate(CommandContext context, CommandContainer container) {
List<SubcommandGroupData> groups = container.getSubcommandGroups();
List<SubcommandData> subcommands = container.getSubcommands();
List<OptionData> options = container.getOptions();
String[] possibleCommands = Arrays.stream(context.getFullCommand().split(" ")).skip(1).toArray(String[]::new);
if (possibleCommands.length == 0) {
if (!groups.isEmpty()) {
context.reply(invalidUsage("missing subcommand group", context));
return false;
}
if (!subcommands.isEmpty()) {
context.reply(invalidUsage("missing subcommand", context));
return false;
}
return checkOptions(context, possibleCommands, 0, options);
}
SubcommandGroupData group = groups.stream().filter(data -> data.getName().equals(possibleCommands[0])).findFirst().orElse(null);
if (group == null) {
SubcommandData subcommand = subcommands.stream().filter(data -> data.getName().equals(possibleCommands[0])).findFirst().orElse(null);
if (subcommand == null) {
if (!groups.isEmpty()) {
context.reply(invalidUsage("unknown subcommand group", context));
return false;
}
if (!subcommands.isEmpty()) {
context.reply(invalidUsage("unknown subcommand", context));
return false;
}
return checkOptions(context, possibleCommands, 0, options);
}
context.setSubcommand(subcommand);
return checkOptions(context, possibleCommands, 1, subcommand.getOptions());
}
if (possibleCommands.length < 2) {
context.reply(invalidUsage("missing subcommand for command group", context));
return false;
}
SubcommandData subcommand = group.getSubcommands().stream().filter(data -> data.getName().equals(possibleCommands[1])).findFirst().orElse(null);
if (subcommand == null) {
context.reply(invalidUsage("unknown subcommand in command group", context));
return false;
}
context.setSubcommandGroup(group);
context.setSubcommand(subcommand);
return checkOptions(context, possibleCommands, 2, subcommand.getOptions());
}
private boolean checkOptions(CommandContext context, String[] args, int skip, List<OptionData> options) {
if (context.isInteraction()) {
context.setCurrentCommandOptions(args, options);
return true;
}
List<String> arguments = new ArrayList<>(Arrays.stream(args).skip(skip).toList());
int required = options.stream().filter(OptionData::isRequired).toList().size();
if (arguments.size() < required) {
context.reply(invalidUsage("missing one or more required arguments", context));
return false;
}
context.setCurrentCommandOptions(arguments.toArray(String[]::new), options);
return true;
}
private String invalidUsage(String message, CommandContext context) {
return String.format("Invalid usage for command: %s\n" +
"Use **/help %s** for information on how to use this command", message, context.getCommand().getCommandName());
}
}
@@ -1,4 +1,4 @@
package me.bobert.netneutrality.core.command;
package me.bobert.netneutrality.core.command.impl;
/*
* Copyright (c) anthorob
@@ -6,6 +6,7 @@ package me.bobert.netneutrality.core.command;
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.command.Command;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel;
import net.dv8tion.jda.api.interactions.commands.DefaultMemberPermissions;
@@ -1,11 +1,13 @@
package me.bobert.netneutrality.core.command;
package me.bobert.netneutrality.core.command.impl;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
public abstract class AbstractAdminCommand extends AbstractCommand {
import me.bobert.netneutrality.core.command.CommandCategory;
public abstract class AdminCommand extends AbstractCommand {
@Override
public boolean needAdmin() {
@@ -1,4 +1,4 @@
package me.bobert.netneutrality.core.command;
package me.bobert.netneutrality.core.command.impl;
/*
* Copyright (c) anthorob
@@ -7,11 +7,13 @@ package me.bobert.netneutrality.core.command;
*/
import me.bobert.netneutrality.context.command.MessageMenuContext;
import me.bobert.netneutrality.core.command.options.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.CommandCategory;
import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
import me.bobert.netneutrality.core.context.CommandContext;
import net.dv8tion.jda.api.entities.Message;
public abstract class AbstractMessageMenuCommand extends AbstractCommand {
public abstract class MessageMenuCommand extends AbstractCommand {
@Override
public String getDescription() {
return null;
@@ -1,10 +1,12 @@
package me.bobert.netneutrality.core.command;
package me.bobert.netneutrality.core.command.impl;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.command.CommandCategory;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.db.GSettings;
import me.bobert.netneutrality.db.service.GuildService;
import me.bobert.netneutrality.utils.UserFunction;
@@ -15,7 +17,44 @@ 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;
public abstract class AbstractMusicCommand extends AbstractCommand {
import java.util.Objects;
public abstract class MusicCommand extends AbstractCommand {
/**
* Injected guild-service provider when constructed via DI.
*/
private final GuildServiceProvider guildServices;
/**
* Preferred constructor for DI.
*
* @param guildServices the shared guild service provider, must not be {@code null}
*/
public MusicCommand(GuildServiceProvider guildServices) {
Objects.requireNonNull(guildServices, "GuildServiceProvider must not be null");
this.guildServices = guildServices;
}
/**
* Resolves the {@link GuildService} for a guild via the injected provider.
*
* @param guild the guild
* @return the guild service
*/
protected final GuildService guildService(Guild guild) {
return guildServices.get(guild.getId());
}
/**
* Resolves the {@link GuildService} for a guild id via the injected provider.
*
* @param guildId the guild id
* @return the guild service
*/
protected final GuildService guildService(String guildId) {
return guildServices.get(guildId);
}
@Override
public boolean needAdmin() {
@@ -40,7 +79,7 @@ public abstract class AbstractMusicCommand extends AbstractCommand {
Guild guild = member.getGuild();
String roleDb = GuildService.get(guild.getId()).getGuildSettingService().getSetting(GSettings.MUSIC_ROLE);
String roleDb = guildService(guild.getId()).getGuildSettingService().getOrDefault(GSettings.MUSIC_ROLE);
if (roleDb != null && !roleDb.isEmpty()) {
if (UserFunction.isAdmin(member)) {
@@ -50,7 +89,7 @@ public abstract class AbstractMusicCommand extends AbstractCommand {
Role neededRole = guild.getRoleById(roleDb);
if (neededRole == null) {
// Role doesn't exist on guild anymore, remove from db
GuildService.get(guild.getId()).getGuildSettingService().setSettingValue(GSettings.MUSIC_ROLE, null);
guildService(guild.getId()).getGuildSettingService().set(GSettings.MUSIC_ROLE, null);
return true;
}
@@ -63,10 +102,11 @@ public abstract class AbstractMusicCommand extends AbstractCommand {
@Override
public boolean canBeExecutedFrom(MessageChannel channel) {
if (channel instanceof GuildMessageChannel guildMessageChannel) {
if (channel.getType() != ChannelType.TEXT)
if (channel.getType() != ChannelType.TEXT) {
return false;
}
String channelDb = GuildService.get(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());
}
@@ -1,4 +1,4 @@
package me.bobert.netneutrality.core.command;
package me.bobert.netneutrality.core.command.impl;
/*
* Copyright (c) anthorob
@@ -7,11 +7,12 @@ package me.bobert.netneutrality.core.command;
*/
import me.bobert.netneutrality.context.command.UserMenuContext;
import me.bobert.netneutrality.core.command.options.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
import me.bobert.netneutrality.core.context.CommandContext;
import net.dv8tion.jda.api.entities.User;
public abstract class AbstractUserMenuCommand extends AbstractCommand {
public abstract class UserMenuCommand extends AbstractCommand {
@Override
public String getDescription() {
return null;
@@ -5,7 +5,11 @@ package me.bobert.netneutrality.core.command.options;
* https://discord.gg/qUa6zNFZsd
*/
import net.dv8tion.jda.api.entities.*;
import net.dv8tion.jda.api.entities.IMentionable;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.entities.Role;
import net.dv8tion.jda.api.entities.User;
import net.dv8tion.jda.api.entities.channel.ChannelType;
import net.dv8tion.jda.api.entities.channel.unions.GuildChannelUnion;
import net.dv8tion.jda.api.interactions.commands.OptionType;
@@ -29,10 +33,8 @@ public interface CommandOption {
* <br>This is represented as an {@link Message.Attachment#isEphemeral() ephemeral} attachment which will only be hosted for up to 2 weeks.
* If you want a permanent reference, you must download it.
*
* @throws IllegalStateException
* If this option {@link #getType() type} is not {@link OptionType#ATTACHMENT}
*
* @return {@link net.dv8tion.jda.api.entities.Message.Attachment Attachment}
* @throws IllegalStateException If this option {@link #getType() type} is not {@link OptionType#ATTACHMENT}
*/
Message.Attachment getAsAttachment();
@@ -48,10 +50,8 @@ public interface CommandOption {
/**
* The boolean value.
*
* @throws IllegalStateException
* If this option is not of type {@link OptionType#BOOLEAN BOOLEAN}
*
* @return The boolean value
* @throws IllegalStateException If this option is not of type {@link OptionType#BOOLEAN BOOLEAN}
*/
boolean getAsBoolean();
@@ -59,12 +59,9 @@ public interface CommandOption {
* The long value for this option.
* <br>This will be the ID of any resolved entity such as {@link Role} or {@link Member}.
*
* @throws IllegalStateException
* If this option {@link #getType() type} cannot be converted to a long
* @throws NumberFormatException
* If this option is of type {@link OptionType#STRING STRING} and could not be parsed to a valid long value
*
* @return The long value
* @throws IllegalStateException If this option {@link #getType() type} cannot be converted to a long
* @throws NumberFormatException If this option is of type {@link OptionType#STRING STRING} and could not be parsed to a valid long value
*/
long getAsLong();
@@ -74,36 +71,27 @@ public interface CommandOption {
*
* <p><b>It is highly recommended to assert int values by using {@link OptionData#setRequiredRange(long, long)}</b>
*
* @throws IllegalStateException
* If this option {@link #getType() type} cannot be converted to a long
* @throws NumberFormatException
* If this option is of type {@link OptionType#STRING STRING} and could not be parsed to a valid long value
* @throws ArithmeticException
* If the provided integer value cannot fit into a 32bit signed int
*
* @return The int value
* @throws IllegalStateException If this option {@link #getType() type} cannot be converted to a long
* @throws NumberFormatException If this option is of type {@link OptionType#STRING STRING} and could not be parsed to a valid long value
* @throws ArithmeticException If the provided integer value cannot fit into a 32bit signed int
*/
int getAsInt();
/**
* The double value for this option.
*
* @throws IllegalStateException
* If this option {@link #getType() type} cannot be converted to a double
* @throws NumberFormatException
* If this option is of type {@link OptionType#STRING STRING} and could not be parsed to a valid double value
*
* @return The double value
* @throws IllegalStateException If this option {@link #getType() type} cannot be converted to a double
* @throws NumberFormatException If this option is of type {@link OptionType#STRING STRING} and could not be parsed to a valid double value
*/
double getAsDouble();
/**
* The resolved {@link IMentionable} instance for this option value.
*
* @throws IllegalStateException
* If the mentioned entity is not resolvable
*
* @return The resolved {@link IMentionable}
* @throws IllegalStateException If the mentioned entity is not resolvable
*/
IMentionable getAsMentionable();
@@ -111,42 +99,34 @@ public interface CommandOption {
* The resolved {@link Member} for this option value.
* <br>Note that {@link OptionType#USER OptionType.USER} can also accept users that are not members of a guild, in which case this will be null!
*
* @throws IllegalStateException
* If this option is not of type {@link OptionType#USER USER} or {@link OptionType#MENTIONABLE MENTIONABLE}
*
* @return The resolved {@link Member}, or null
* @throws IllegalStateException If this option is not of type {@link OptionType#USER USER} or {@link OptionType#MENTIONABLE MENTIONABLE}
*/
Member getAsMember();
/**
* The resolved {@link User} for this option value.
*
* @throws IllegalStateException
* If this option is not of type {@link OptionType#USER USER} or
* {@link OptionType#MENTIONABLE MENTIONABLE} without a resolved user
*
* @return The resolved {@link User}
* @throws IllegalStateException If this option is not of type {@link OptionType#USER USER} or
* {@link OptionType#MENTIONABLE MENTIONABLE} without a resolved user
*/
User getAsUser();
/**
* The resolved {@link Role} for this option value.
*
* @throws IllegalStateException
* If this option is not of type {@link OptionType#ROLE ROLE} or
* {@link OptionType#MENTIONABLE MENTIONABLE} without a resolved role
*
* @return The resolved {@link Role}
* @throws IllegalStateException If this option is not of type {@link OptionType#ROLE ROLE} or
* {@link OptionType#MENTIONABLE MENTIONABLE} without a resolved role
*/
Role getAsRole();
/**
* The {@link ChannelType} for the resolved channel.
*
* @throws IllegalStateException
* If this option is not of type {@link OptionType#CHANNEL CHANNEL}
*
* @return The {@link ChannelType}
* @throws IllegalStateException If this option is not of type {@link OptionType#CHANNEL CHANNEL}
*/
ChannelType getChannelType();
@@ -154,11 +134,9 @@ public interface CommandOption {
* The resolved {@link net.dv8tion.jda.api.entities.channel.middleman.GuildChannel} for this option value.
* <br>Note that {@link OptionType#CHANNEL OptionType.CHANNEL} can accept channels of any type!
*
* @throws IllegalStateException
* If this option is not of type {@link OptionType#CHANNEL CHANNEL}
* or could not be resolved for unexpected reasons
*
* @return The resolved {@link net.dv8tion.jda.api.entities.channel.middleman.GuildChannel}
* @throws IllegalStateException If this option is not of type {@link OptionType#CHANNEL CHANNEL}
* or could not be resolved for unexpected reasons
*/
GuildChannelUnion getAsChannel();
}
@@ -1,170 +0,0 @@
package me.bobert.netneutrality.core.command.options;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.command.Command;
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;
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 CommandOptions {
private final Command command;
private final CommandDataImpl commandData;
private final List<OptionData> options;
private final List<SubcommandData> subcommands;
private final List<SubcommandGroupData> subcommandGroups;
public CommandOptions(Command command, CommandDataImpl commandData) {
this.command = command;
this.commandData = commandData;
this.options = commandData.getOptions();
this.subcommands = commandData.getSubcommands();
this.subcommandGroups = commandData.getSubcommandGroups();
}
public Command getCommand() {
return command;
}
public SlashCommandData getSlashCommandData() {
return commandData;
}
public List<OptionData> getOptions() {
return options;
}
public List<SubcommandData> getSubcommands() {
return subcommands;
}
public List<SubcommandGroupData> getSubcommandGroups() {
return subcommandGroups;
}
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();
}
}
@@ -5,7 +5,11 @@ package me.bobert.netneutrality.core.command.options;
* https://discord.gg/qUa6zNFZsd
*/
import net.dv8tion.jda.api.entities.*;
import net.dv8tion.jda.api.entities.IMentionable;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.entities.Role;
import net.dv8tion.jda.api.entities.User;
import net.dv8tion.jda.api.entities.channel.ChannelType;
import net.dv8tion.jda.api.entities.channel.unions.GuildChannelUnion;
import net.dv8tion.jda.api.interactions.commands.OptionType;
@@ -116,6 +120,10 @@ public class MessageCommandOption implements CommandOption {
@Override
public User getAsUser() {
if (value instanceof Member) {
return ((Member) value).getUser();
}
if (value instanceof User) {
return (User) value;
}
@@ -5,7 +5,11 @@ package me.bobert.netneutrality.core.command.options;
* https://discord.gg/qUa6zNFZsd
*/
import net.dv8tion.jda.api.entities.*;
import net.dv8tion.jda.api.entities.IMentionable;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.entities.Role;
import net.dv8tion.jda.api.entities.User;
import net.dv8tion.jda.api.entities.channel.ChannelType;
import net.dv8tion.jda.api.entities.channel.unions.GuildChannelUnion;
import net.dv8tion.jda.api.interactions.commands.OptionMapping;
@@ -0,0 +1,17 @@
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;
public interface CommandPrecondition {
/**
* Returns true if the precondition passes and execution should continue,
* or false if it failed (the implementation is responsible for sending any reply).
*/
boolean check(CommandContext context, Command command);
}
@@ -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;
}
}
@@ -0,0 +1,23 @@
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;
public class GuildOnlyPrecondition implements CommandPrecondition {
@Override
public boolean check(CommandContext context, Command command) {
if (command.isGuildOnly() && !context.isFromGuild()) {
context.replyEphemeral("This command is only available in a guild");
context.deleteMessage();
return false;
}
return true;
}
}
@@ -0,0 +1,41 @@
package me.bobert.netneutrality.core.command.precondition;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.command.Command;
import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.utils.UserFunction;
public class PermissionPrecondition implements CommandPrecondition {
@Override
public boolean check(CommandContext context, Command command) {
if (command.getType() != Command.Type.SLASH) {
return true;
}
if (context.canSkipPermissionCheck()) {
return true;
}
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;
}
}
@@ -0,0 +1,22 @@
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;
public class SlashCommandOnlyPrecondition implements CommandPrecondition {
@Override
public boolean check(CommandContext context, Command command) {
if (command.isSlashCommandOnly() && !context.isInteraction()) {
context.replyEphemeral("This command is only available as a slash command");
return false;
}
return true;
}
}
@@ -6,7 +6,6 @@ package me.bobert.netneutrality.core.context;
*/
import io.sentry.Sentry;
import me.bobert.netneutrality.context.command.SlashCommandContext;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member;
@@ -1,17 +1,16 @@
package me.bobert.netneutrality.context;
package me.bobert.netneutrality.core.context;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.context.AbstractContext;
import net.dv8tion.jda.api.components.MessageTopLevelComponent;
import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.entities.channel.ChannelType;
import net.dv8tion.jda.api.events.Event;
import net.dv8tion.jda.api.interactions.InteractionHook;
import net.dv8tion.jda.api.interactions.components.LayoutComponent;
import net.dv8tion.jda.api.interactions.modals.Modal;
import net.dv8tion.jda.api.modals.Modal;
import net.dv8tion.jda.api.utils.messages.MessageCreateData;
import net.dv8tion.jda.api.utils.messages.MessageEditData;
@@ -97,7 +96,7 @@ public class AsyncContext extends AbstractContext {
}
@Override
public void reply(MessageCreateData messageCreateData, boolean ephemeral, List<LayoutComponent> components, Consumer<AsyncContext> consumer) {
public void reply(MessageCreateData messageCreateData, boolean ephemeral, List<MessageTopLevelComponent> components, Consumer<AsyncContext> consumer) {
if (hook != null) {
hook.sendMessage(messageCreateData).queue();
}
@@ -118,7 +117,7 @@ public class AsyncContext extends AbstractContext {
}
@Override
public void edit(MessageEditData messageEditData, boolean ephemeral, List<LayoutComponent> components, Consumer<AsyncContext> consumer) {
public void edit(MessageEditData messageEditData, boolean ephemeral, List<MessageTopLevelComponent> components, Consumer<AsyncContext> consumer) {
if (hook != null) {
hook.editOriginal(messageEditData).queue();
}
@@ -7,7 +7,7 @@ package me.bobert.netneutrality.core.context;
import me.bobert.netneutrality.core.command.Command;
import me.bobert.netneutrality.core.command.options.CommandOption;
import me.bobert.netneutrality.utils.Settings;
import me.bobert.netneutrality.utils.SettingsProvider;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member;
@@ -17,7 +17,12 @@ 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.*;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.stream.Collectors;
@@ -28,10 +33,12 @@ public abstract class CommandContext extends AbstractContext {
private SubcommandData subcommand;
private final Map<OptionData, String> invalidOptions = new HashMap<>();
private final List<CommandOption> options;
private final SettingsProvider settings;
public CommandContext(JDA jda, Guild guild, MessageChannel channel, Member member, User user) {
public CommandContext(JDA jda, Guild guild, MessageChannel channel, Member member, User user, SettingsProvider settings) {
super(jda, guild, channel, member, user);
this.options = new ArrayList<>();
this.settings = settings;
}
public Command getCommand() {
@@ -70,16 +77,20 @@ public abstract class CommandContext extends AbstractContext {
public <T> T getOption(String name, Supplier<? extends T> fallback, Function<? super CommandOption, ? extends T> resolver) {
Objects.requireNonNull(resolver, "resolver cannot be null");
CommandOption mapping = getOption(name);
if (mapping != null)
if (mapping != null) {
return resolver.apply(mapping);
}
return fallback == null ? null : fallback.get();
}
public <T> T getOption(String name, T fallback, Function<? super CommandOption, ? extends T> resolver) {
Objects.requireNonNull(resolver, "resolver cannot be null");
CommandOption mapping = getOption(name);
if (mapping != null)
if (mapping != null) {
return resolver.apply(mapping);
}
return fallback;
}
@@ -89,7 +100,7 @@ public abstract class CommandContext extends AbstractContext {
public CommandOption getOption(String name) {
List<CommandOption> options = getOptionsByName(name);
return options.isEmpty() ? null : options.get(0);
return options.isEmpty() ? null : options.getFirst();
}
public List<CommandOption> getOptionsByName(String name) {
@@ -122,7 +133,7 @@ public abstract class CommandContext extends AbstractContext {
}
public boolean isMessageFromCreator() {
String creatorId = Settings.getInstance().get("NETNEUTRALITY_BOT_OWNER");
String creatorId = settings.get("NETNEUTRALITY_BOT_OWNER");
return creatorId.equals(getDiscordUser().getId());
}
@@ -133,6 +144,11 @@ public abstract class CommandContext extends AbstractContext {
public abstract String getFullCommand();
public boolean isInteraction() {
return false;
}
public boolean canSkipPermissionCheck() {
return false;
}
}
@@ -5,16 +5,19 @@ package me.bobert.netneutrality.core.context;
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.context.AsyncContext;
import me.bobert.netneutrality.utils.ContextUtils;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.entities.*;
import net.dv8tion.jda.api.components.MessageTopLevelComponent;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.entities.MessageEmbed;
import net.dv8tion.jda.api.entities.User;
import net.dv8tion.jda.api.entities.channel.ChannelType;
import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel;
import net.dv8tion.jda.api.events.Event;
import net.dv8tion.jda.api.interactions.InteractionHook;
import net.dv8tion.jda.api.interactions.components.LayoutComponent;
import net.dv8tion.jda.api.interactions.modals.Modal;
import net.dv8tion.jda.api.modals.Modal;
import net.dv8tion.jda.api.utils.messages.MessageCreateData;
import net.dv8tion.jda.api.utils.messages.MessageEditData;
@@ -48,7 +51,7 @@ public interface Context {
void reply(Modal modal);
void reply(MessageCreateData messageCreateData, boolean ephemeral, List<LayoutComponent> components, Consumer<AsyncContext> consumer);
void reply(MessageCreateData messageCreateData, boolean ephemeral, List<MessageTopLevelComponent> components, Consumer<AsyncContext> consumer);
void defer(boolean ephemeral, Consumer<AsyncContext> consumer);
@@ -66,7 +69,7 @@ public interface Context {
reply(messageCreateData, false, List.of(), asyncMessage -> {});
}
default void reply(MessageCreateData messageCreateData, List<LayoutComponent> components) {
default void reply(MessageCreateData messageCreateData, List<MessageTopLevelComponent> components) {
reply(messageCreateData, false, components, asyncMessage -> {});
}
@@ -90,7 +93,7 @@ public interface Context {
reply(MessageCreateData.fromEmbeds(embeds));
}
default void edit(MessageEditData messageEditData, boolean ephemeral, List<LayoutComponent> components, Consumer<AsyncContext> consumer) {
default void edit(MessageEditData messageEditData, boolean ephemeral, List<MessageTopLevelComponent> components, Consumer<AsyncContext> consumer) {
if (isAcknowledged() && getInteractionHook() != null) {
getInteractionHook().editOriginal(messageEditData).setComponents(components).queue(message1 -> consumer.accept(new AsyncContext(message1)));
return;
@@ -99,7 +102,7 @@ public interface Context {
reply(MessageCreateData.fromEditData(messageEditData), ephemeral, components, consumer);
}
default void edit(MessageEditData messageEditData, List<LayoutComponent> components) {
default void edit(MessageEditData messageEditData, List<MessageTopLevelComponent> components) {
edit(messageEditData, false, components, asyncMessage -> {});
}
@@ -115,11 +118,11 @@ public interface Context {
edit(List.of(embed), List.of());
}
default void edit(List<MessageEmbed> embeds, List<LayoutComponent> components) {
default void edit(List<MessageEmbed> embeds, List<MessageTopLevelComponent> components) {
edit(MessageEditData.fromEmbeds(embeds), components);
}
default void edit(MessageEmbed embed, List<LayoutComponent> components) {
default void edit(MessageEmbed embed, List<MessageTopLevelComponent> components) {
edit(List.of(embed), components);
}
@@ -135,7 +138,7 @@ public interface Context {
defer(false, consumer);
}
default void replyEphemeral(MessageCreateData messageCreateData, List<LayoutComponent> components) {
default void replyEphemeral(MessageCreateData messageCreateData, List<MessageTopLevelComponent> components) {
reply(messageCreateData, true, components, asyncMessage -> {});
}
@@ -147,7 +150,11 @@ public interface Context {
replyEphemeralWithLimit(message);
}
default void replyEphemeral(String message, List<LayoutComponent> components) {
default void replyEphemeral(String message, MessageTopLevelComponent component) {
replyEphemeral(message, List.of(component));
}
default void replyEphemeral(String message, List<MessageTopLevelComponent> components) {
replyEphemeral(MessageCreateData.fromContent(message), components);
}
@@ -155,7 +162,7 @@ public interface Context {
replyEphemeral(MessageCreateData.fromEmbeds(embeds));
}
default void sendMessageInPrivate(MessageCreateData messageCreateData, List<LayoutComponent> components, Consumer<AsyncContext> consumer) {
default void sendMessageInPrivate(MessageCreateData messageCreateData, List<MessageTopLevelComponent> components, Consumer<AsyncContext> consumer) {
User user = getDiscordUser();
if (user != null) {
user.openPrivateChannel()
@@ -164,11 +171,11 @@ public interface Context {
}
}
default void sendMessageInPrivate(String message, List<LayoutComponent> components, Consumer<AsyncContext> consumer) {
default void sendMessageInPrivate(String message, List<MessageTopLevelComponent> components, Consumer<AsyncContext> consumer) {
sendMessageInPrivate(MessageCreateData.fromContent(message), components, consumer);
}
default void sendMessageInPrivate(MessageCreateData messageCreateData, List<LayoutComponent> components) {
default void sendMessageInPrivate(MessageCreateData messageCreateData, List<MessageTopLevelComponent> components) {
sendMessageInPrivate(messageCreateData, components, asyncContext -> {});
}
@@ -5,13 +5,12 @@ package me.bobert.netneutrality.core.context;
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.context.AsyncContext;
import net.dv8tion.jda.api.components.MessageTopLevelComponent;
import net.dv8tion.jda.api.interactions.Interaction;
import net.dv8tion.jda.api.interactions.InteractionHook;
import net.dv8tion.jda.api.interactions.callbacks.IModalCallback;
import net.dv8tion.jda.api.interactions.callbacks.IReplyCallback;
import net.dv8tion.jda.api.interactions.components.LayoutComponent;
import net.dv8tion.jda.api.interactions.modals.Modal;
import net.dv8tion.jda.api.modals.Modal;
import net.dv8tion.jda.api.utils.messages.MessageCreateData;
import java.util.List;
@@ -58,7 +57,7 @@ public interface InteractionContext extends Context {
}
@Override
default void reply(MessageCreateData messageCreateData, boolean ephemeral, List<LayoutComponent> components, Consumer<AsyncContext> consumer) {
default void reply(MessageCreateData messageCreateData, boolean ephemeral, List<MessageTopLevelComponent> components, Consumer<AsyncContext> consumer) {
if (isAcknowledged()) {
getInteractionHook().sendMessage(messageCreateData).setEphemeral(ephemeral).setComponents(components).queue(message -> consumer.accept(new AsyncContext(message)));
return;
@@ -6,10 +6,9 @@ package me.bobert.netneutrality.core.context;
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.context.AsyncContext;
import net.dv8tion.jda.api.components.MessageTopLevelComponent;
import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.entities.channel.ChannelType;
import net.dv8tion.jda.api.interactions.components.LayoutComponent;
import net.dv8tion.jda.api.utils.messages.MessageCreateData;
import java.util.List;
@@ -32,7 +31,7 @@ public interface MessageContext extends NoMessageContext {
}
@Override
default void reply(MessageCreateData messageCreateData, boolean ephemeral, List<LayoutComponent> components, Consumer<AsyncContext> consumer) {
default void reply(MessageCreateData messageCreateData, boolean ephemeral, List<MessageTopLevelComponent> components, Consumer<AsyncContext> consumer) {
if (ephemeral && getType() != ChannelType.PRIVATE) {
sendMessageInPrivate(messageCreateData, components);
return;
@@ -6,10 +6,9 @@ package me.bobert.netneutrality.core.context;
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.context.AsyncContext;
import net.dv8tion.jda.api.entities.channel.ChannelType;
import net.dv8tion.jda.api.interactions.InteractionHook;
import net.dv8tion.jda.api.interactions.modals.Modal;
import net.dv8tion.jda.api.modals.Modal;
import java.util.function.Consumer;
@@ -1,34 +0,0 @@
package me.bobert.netneutrality.core.db;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.utils.Settings;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.flywaydb.core.Flyway;
public class DatabaseMigration {
private final static Logger logger = LogManager.getLogger(DatabaseMigration.class);
public static void migrate() {
String dbName = Settings.getInstance().get("MYSQL_DATABASE", "netneutrality");
String dbUser = Settings.getInstance().get("MYSQL_USER", "root");
String dbPass = Settings.getInstance().get("MYSQL_PASSWORD", "");
String dbHost = Settings.getInstance().get("MYSQL_HOST", "localhost");
try {
Flyway flyway = Flyway.configure()
.dataSource(String.format("jdbc:mysql://%s:3306/%s", dbHost, dbName), dbUser, dbPass)
.baselineOnMigrate(true)
.load();
flyway.migrate();
} catch (Exception e) {
logger.error(e);
System.exit(1);
}
}
}
Loaded 100 of 312 files, more files were not shown because too many files have changed in this diff. Show more