diff --git a/.agents/skills/architecture/SKILL.md b/.agents/skills/architecture/SKILL.md new file mode 100644 index 0000000..1ee26cf --- /dev/null +++ b/.agents/skills/architecture/SKILL.md @@ -0,0 +1,145 @@ +--- +name: architecture +description: IMPORTANT: Load when adding new commands, services, or features. Defines the Clean Architecture patterns used in ECIBotKt. Covers Koin DI, Command Pattern, guild isolation, and RemoteResponse wrapper. Must read before touching any code. +--- + +## When to use me +- When adding new commands, services, or features +- When understanding how DI modules are organized +- When working with guild-specific state or RemoteResponse + +## Not intended for +- Code quality checks → use `code-quality` +- Testing patterns → use `testing` +- Discord API specifics (embeds, voice) → use `discord-integration` + +--- + +## Architecture Layers + +``` +Presentation (commands/) → Application (services/) → Domain (data/, domain/) → Infrastructure (bot/, di/, utils) +``` + +### 1. Presentation — `commands/` +- Interfaces: `Command`, `SubCommand`, `GroupCommand`, `Component`, `Autocomplete` +- Handle Discord interactions only — no business logic + +```kotlin +class RadioGroupCommand(...) : GroupCommand, Component, Autocomplete { + override suspend fun onRegisterCommand(...) { } + override suspend fun onExecute(interaction, response) { } + override suspend fun onInteract(interaction) { } + override suspend fun onAutoComplete(interaction) { } +} +``` + +### 2. Application — `services/` +- Single-responsibility service classes +- Return `RemoteResponse` for all operations + +```kotlin +class RadioService( + private val httpClient: HttpClient, + private val configService: ConfigService +) { + suspend fun getCountryCodes(): RemoteResponse { } +} +``` + +### 3. Domain — `data/`, `domain/` +- DTOs with `@Serializable`, `@JsonIgnoreUnknownKeys` +- Business objects (TrackBO, etc.) +- Error types and response wrappers + +### 4. Infrastructure — `bot/`, `di/`, `utils/` +- `Bot.kt`: Discord connection and event routing +- Koin modules for DI +- HTTP clients, logging, extensions + +--- + +## Dependency Injection (Koin) + +### Module Organization +```kotlin +// BotModule.kt +single { Bot(get(), get()) } + +// ServicesModule.kt +single { RadioService(get(), get()) } + +// CommandModule.kt +single { RadioGroupCommand(get(), get(), ...) } +``` + +### Rules +- Constructor injection only +- `single` for stateful services, `factory` for stateless +- Group related dependencies in modules +- Define interfaces for testability + +--- + +## Guild Isolation Pattern + +Each Discord server has isolated state via map with guild ID as key: + +```kotlin +class GuildQueueService { + private val guildPlayers = mutableMapOf() + + fun getOrCreateLavaPlayerService(interaction): GuildLavaPlayerService { + return guildPlayers.getOrPut(interaction.guildId) { ... } + } +} +``` + +--- + +## RemoteResponse Wrapper + +All service methods return `RemoteResponse`: + +```kotlin +sealed class RemoteResponse { + data class Success(val data: T) : RemoteResponse() + data class Error(val error: ErrorType) : RemoteResponse() +} +``` + +Handle in commands: +```kotlin +when (val result = service.call()) { + is RemoteResponse.Success -> { /* Handle success */ } + is RemoteResponse.Error -> { /* Show localized error */ } +} +``` + +--- + +## Extension Functions + +Add functionality to existing classes: +```kotlin +// Extension on Discord types +fun ChatInputCommandInteraction.getGuildId(): Snowflake = ... + +// Extension on business objects +fun TrackBO.getDisplayTrackName(): String = ... +``` + +--- + +## Critical Rules + +1. **Never mix layers**: Presentation code shouldn't access infrastructure directly +2. **Use DI**: Don't create dependencies manually, inject them +3. **Handle errors**: Always use RemoteResponse wrapper for service calls +4. **Localize everything**: User-facing strings must use localization +5. **Test services**: Business logic must have unit tests + +--- + +## References +- `.github/instructions/architecture.instructions.md` — full context diff --git a/.agents/skills/code-quality/SKILL.md b/.agents/skills/code-quality/SKILL.md new file mode 100644 index 0000000..41b9d2e --- /dev/null +++ b/.agents/skills/code-quality/SKILL.md @@ -0,0 +1,281 @@ +--- +name: code-quality +description: CRITICAL: Load for ALL code changes. Defines detekt rules and refactoring patterns for ECIBotKt. Covers import ordering, constants extraction, expression body, autocomplete prioritization. Violations = failing CI. Must follow before ANY commit. +--- + +## When to use me +- When writing or reviewing Kotlin code for formatting compliance +- When refactoring duplicated code or extracting constants +- When implementing autocomplete with proper prioritization + +## Not intended for +- Running build/test/lint gates → use `quality-check` +- Testing patterns → use `testing` +- Architecture patterns → use `architecture` + +--- + +## Running Detekt + +```bash +# Check for issues +./gradlew detekt + +# Auto-fix issues +./gradlew detekt --auto-correct +``` + +**PRs cannot be merged if detekt fails.** + +--- + +## Detekt Rules + +### 1. Import Ordering + +Alphabetical order within groups. + +**Wrong:** +```kotlin +import dev.kord.core.entity.interaction.SubCommand +import dev.kord.core.entity.interaction.GroupCommand +``` + +**Correct:** +```kotlin +import dev.kord.core.entity.interaction.GroupCommand +import dev.kord.core.entity.interaction.SubCommand +``` + +### 2. Final Newline + +Files must end with empty newline. + +### 3. Blank Lines Between When Conditions + +```kotlin +when (result) { + is Success -> { + doSomething() + } + + is Error -> { + handleError() + } +} +``` + +### 4. No Trailing Spaces + +Remove spaces at end of lines. Blank lines should be truly empty. + +### 5. No Empty First Line in Method Block + +```kotlin +fun myMethod() { + doSomething() // No blank line here +} +``` + +### 6. No Blank Line Before Closing Brace + +```kotlin +fun myMethod() { + doSomething() +} // No blank line before this +``` + +### 7. Comment Wrapping + +Block comments on their own lines: + +```kotlin +/* This is a value */ +val x = 5 +``` + +Not: `val x = 5 /* this is a value */` + +--- + +## EditorConfig + +```ini +[*] +charset = utf-8 +indent_style = space +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true +``` + +--- + +## Refactoring Patterns + +### 1. Eliminate Duplication with Default Parameters + +**Before** (duplicate handlers): +```kotlin +fun loadAndPlay(url: String) { + audioPlayerManager.loadItem(url, getAudioLoadResultHandler(url)) +} +fun loadAndPlayNext(url: String) { + audioPlayerManager.loadItem(url, getAudioLoadResultHandlerNext(url)) +} +``` + +**After** (50%+ code reduction): +```kotlin +fun loadAndPlay(url: String, addToFront: Boolean = false) { + audioPlayerManager.loadItem(url, getAudioLoadResultHandler(url, addToFront)) +} +``` + +### 2. Search Algorithm Prioritization + +Exact match first, then fuzzy: + +```kotlin +fun findTrack(searchTerm: String): Track? { + val normalized = searchTerm.lowercase() + + // First pass: exact match + val exactMatchIndex = queue.indexOfFirst { + it.getDisplayTrackName().lowercase() == normalized + } + + // Second pass: contains match (only if no exact match) + val trackIndex = if (exactMatchIndex != -1) { + exactMatchIndex + } else { + queue.indexOfFirst { + it.getDisplayTrackName().lowercase().contains(normalized) + } + } + + return if (trackIndex != -1) queue[trackIndex] else null +} +``` + +### 3. Autocomplete Ordering Strategy + +Discord has 25-item limit. Order matters: + +```kotlin +override suspend fun onAutoComplete(interaction: AutoCompleteInteraction) { + val input = interaction.command.strings[ARGUMENT_NAME].orEmpty() + + // First: sounds that start with input (sorted alphabetically) + val startsWithMatches = allFiles + .filter { it.nameWithoutExtension.startsWith(input, ignoreCase = true) } + .sortedBy { it.nameWithoutExtension.lowercase() } + + // Then: sounds that contain input (sorted alphabetically) + val containsMatches = allFiles + .filter { + !it.nameWithoutExtension.startsWith(input, ignoreCase = true) && + it.nameWithoutExtension.contains(input, ignoreCase = true) + } + .sortedBy { it.nameWithoutExtension.lowercase() } + + // Combine: startsWith first, then contains, up to 25 + val suggestions = (startsWithMatches + containsMatches) + .take(25) + .map { file -> + Choice.StringChoice( + name = file.nameWithoutExtension.take(100), + nameLocalizations = Optional.Missing(), + value = file.nameWithoutExtension.take(100) + ) + } + + interaction.suggest(suggestions) +} +``` + +### 4. Extracting Constants + +**Before:** +```kotlin +val protocolEndIndex = url.indexOf("://") +val protocol = if (protocolEndIndex != -1) { + url.take(protocolEndIndex + 3) +} else { + "https://" +} +``` + +**After:** +```kotlin +private const val PROTOCOL_SEPARATOR = "://" +private const val DEFAULT_PROTOCOL = "https://" +private const val NOT_FOUND_INDEX = -1 + +val protocolEndIndex = url.indexOf(PROTOCOL_SEPARATOR) +val protocol = if (protocolEndIndex != NOT_FOUND_INDEX) { + url.take(protocolEndIndex + PROTOCOL_SEPARATOR.length) +} else { + DEFAULT_PROTOCOL +} +``` + +### 5. Expression Body for Simple Functions + +**Before:** +```kotlin +fun String.transformUrl(): String { + return UrlTransformer.transformMonochromeToTidal(this) +} +``` + +**After:** +```kotlin +fun String.transformUrl(): String = UrlTransformer.transformMonochromeToTidal(this) +``` + +**Rule:** Single return statement = use expression body. + +--- + +## Updating Tests After API Changes + +When changing method signatures: + +```kotlin +// Update mock declarations +justRun { loadAndPlayNext(any()) } // Before +justRun { loadAndPlay(any(), any()) } // After + +// Update verification calls +coVerify { lavaPlayerService.loadAndPlayNext(expectedUrl) } // Before +coVerify { lavaPlayerService.loadAndPlay(expectedUrl, addToFront = true) } // After +``` + +--- + +## PR Checklist + +Before submitting: +- [ ] `./gradlew detekt` passes +- [ ] All existing tests pass +- [ ] No trailing spaces or formatting issues +- [ ] Code duplication is minimized +- [ ] Constants extracted (no magic strings/numbers) +- [ ] Expression body used for simple functions +- [ ] Backward compatibility maintained (if applicable) + +--- + +## Anti-patterns to Avoid + +1. **The Boolean Trap**: `fun process(data: Data, flag1: Boolean, flag2: Boolean)` → Use enums +2. **Over-generalization**: Making methods too generic → Keep focused, use composition +3. **Breaking Changes**: Changing signatures without defaults → Use default parameters + +--- + +## References +- `.github/instructions/code-quality.instructions.md` — full context +- detekt: https://detekt.dev/ +- Kotlin Coding Conventions: https://kotlinlang.org/docs/coding-conventions.html diff --git a/.agents/skills/dependencies/SKILL.md b/.agents/skills/dependencies/SKILL.md new file mode 100644 index 0000000..63b21c8 --- /dev/null +++ b/.agents/skills/dependencies/SKILL.md @@ -0,0 +1,190 @@ +--- +name: dependencies +description: IMPORTANT: Load when adding/updating dependencies or troubleshooting class errors. Covers Gradle version catalog, local JAR Kord SNAPSHOT setup, transitive dependencies. Missing transitive deps = runtime crashes. +--- + +## When to use me +- When adding or updating dependencies +- When troubleshooting missing class errors at runtime +- When migrating from local JARs to Maven (when Kord voice encryption merges) + +## Not intended for +- Code quality checks → use `code-quality` +- Build/test gates → use `quality-check` +- Architecture patterns → use `architecture` + +--- + +## Version Catalog + +Dependencies are defined in `gradle/libs.versions.toml`: + +```toml +[versions] +kotlin = "2.1.21" +ktor = "3.1.3" +koin = "4.0.2" + +[libraries] +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +koin-core = { module = "io.insert-koin:koin-core" } + +[plugins] +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +``` + +### Usage in build.gradle.kts + +```kotlin +dependencies { + implementation(libs.ktor.client.core) + implementation(libs.koin.core) +} +``` + +--- + +## Local JAR Dependencies (Kord SNAPSHOT) + +### When to Use +- SNAPSHOT versions not in Maven Central +- Custom-built libraries +- Pre-release features (like Kord voice encryption) + +### Setup + +**1. Copy JARs to `libs/` folder:** +```bash +mkdir -p libs +cp /path/to/jars/*.jar libs/ +``` + +**2. Configure repository in build.gradle.kts:** +```kotlin +repositories { + flatDir { + dirs("libs") + } + mavenLocal() // Keep for transitive dependencies + mavenCentral() +} +``` + +**3. Add dependency:** +```kotlin +dependencies { + implementation(fileTree("libs") { include("kord-*.jar") }) +} +``` + +### CRITICAL: Transitive Dependencies + +`flatDir` doesn't resolve transitive dependencies. You must add them manually: + +```kotlin +// Transitive deps required by local Kord JARs +implementation(libs.kotlinx.datetime) +implementation(libs.kord.cache) +implementation(libs.kord.cache.map) +implementation(libs.ktor.client.okhttp) +``` + +### Version Catalog for Transitive Deps + +```toml +[versions] +kord-cache = "0.5.4" +kotlinx-datetime = "0.6.1" + +[libraries] +kord-cache = { module = "dev.kord.cache:cache-api", version.ref = "kord-cache" } +kord-cache-map = { module = "dev.kord.cache:cache-map", version.ref = "kord-cache" } +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } +``` + +--- + +## Key Dependencies + +### Discord Integration +- **Kord**: Discord API wrapper (local SNAPSHOT with voice encryption) +- Kord sub-modules: core, voice, rest, gateway, common + +### HTTP & Networking +- **Ktor**: HTTP client framework + - Client core, CIO engine, logging + - Content negotiation, JSON serialization + - OkHttp engine (required by Kord) + +### Audio Processing +- **Lavaplayer**: Audio playback (2.2.6) +- **Lavaplayer-youtube**: YouTube support +- **Lavaplayer-lavasrc**: Additional sources (Spotify, Deezer) + +### DI +- **Koin**: Lightweight DI (4.0.2) + +### Serialization +- **Kotlinx Serialization**: JSON (1.8.1) + +### Testing +- **Mockk**: Mocking framework (1.14.2) +- **Kotlinx Coroutines Test**: Coroutine testing +- **JUnit Jupiter**: Test framework + +--- + +## Build Commands + +```bash +# Refresh dependencies +./gradlew --refresh-dependencies + +# Check dependency tree +./gradlew dependencies + +# Build project +./gradlew build + +# Create fat JAR +./gradlew fatJar +``` + +--- + +## Troubleshooting + +| Error | Fix | +|-------|-----| +| `NoClassDefFoundError: kotlinx/datetime/Instant` | Add `kotlinx-datetime` dependency | +| `ClassNotFoundException: io.ktor.client.engine.okhttp.OkHttp` | Add `ktor-client-okhttp` dependency | +| Version conflicts | Check: `./gradlew dependencies --configuration runtimeClasspath` | + +--- + +## Migration from Local to Maven + +When Kord releases voice encryption: + +1. Remove JARs from `libs/` +2. Remove `flatDir` repository +3. Uncomment Kord entries in version catalog +4. Remove manual transitive dependencies +5. Update to stable version in version catalog +6. Run tests to verify + +--- + +## Best Practices + +1. Use version catalog for all dependencies +2. Document temporary dependencies with TODO comments +3. Keep JARs out of git (add to .gitignore if needed) +4. Test after dependency changes +5. Pin versions — avoid `+` or dynamic versions + +--- + +## References +- `.github/instructions/dependencies.instructions.md` — full context +- Version catalog: `gradle/libs.versions.toml` diff --git a/.agents/skills/discord-integration/SKILL.md b/.agents/skills/discord-integration/SKILL.md new file mode 100644 index 0000000..aa55b24 --- /dev/null +++ b/.agents/skills/discord-integration/SKILL.md @@ -0,0 +1,280 @@ +--- +name: discord-integration +description: CRITICAL: Load when adding new Discord commands. Contains the 8-step command registration checklist — missing ANY step breaks the command. Covers Kord, embeds, voice, and caching. Failure to follow = broken commands. +--- + +## When to use me +- When adding new Discord slash commands +- When working with embeds, buttons, or autocomplete +- When handling voice channels or audio playback +- When implementing caching for API calls + +## Not intended for +- Code quality checks → use `code-quality` +- Testing Discord interactions → use `testing` +- Architecture patterns → use `architecture` + +--- + +## CRITICAL: Command Registration Checklist + +Adding a new command requires registration in **8 places**. Missing any step breaks the command. + +### Step 1: Command Name (`CommandName.kt`) + +```kotlin +sealed class CommandName(val commandName: String) { + data object Play : CommandName("play") + data object Sound : CommandName("sound") // ADD HERE + data object Queue : CommandName("queue") +} +``` + +### Step 2: Localization Keys (`LocalizationKeys.kt`) + +```kotlin +object LocalizationKeys { + const val SOUND_COMMAND_DESCRIPTION = "sound_command_description" + const val SOUND_COMMAND_INPUT_DESCRIPTION = "sound_command_input_description" +} +``` + +### Step 3: Localization Strings + +**`lang.yml`** (English): +```yaml +sound_command_description: Plays a local sound by name +sound_command_input_description: Name of the local sound to play +``` + +**`lang_es-ES.yml`** (Spanish): +```yaml +sound_command_description: Reproduce un sonido local por nombre +sound_command_input_description: Nombre del sonido local a reproducir +``` + +### Step 4: Command Implementation + +```kotlin +class SoundCommand( + private val guildQueueService: GuildQueueService, + private val localizationService: LocalizationService +) : Command, Autocomplete { + + override fun onRegisterCommand(commandBuilder: GlobalMultiApplicationCommandBuilder) { + // Register command with Discord + } + + override suspend fun onExecute(interaction, response) { + // Handle command execution + } + + override suspend fun onAutoComplete(interaction) { + // Handle autocomplete (optional) + } +} +``` + +### Step 5: DI Registration (`CommandModule.kt`) + +```kotlin +val commandModule = module { + factoryOf(::PlayCommand) + factoryOf(::SoundCommand) // ADD HERE + factoryOf(::QueueCommand) +} +``` + +### Step 6: CommandHandlerService (4 sub-steps!) + +**a) Import:** +```kotlin +import es.wokis.commands.sound.SoundCommand +``` + +**b) Constructor:** +```kotlin +class CommandHandlerServiceImpl( + private val playCommand: PlayCommand, + private val soundCommand: SoundCommand, // ADD HERE + private val queueCommand: QueueCommand, +) : CommandHandlerService +``` + +**c) `onRegisterSimpleCommand()`:** +```kotlin +override fun onRegisterSimpleCommand(commandBuilder: GlobalMultiApplicationCommandBuilder) { + playCommand.onRegisterCommand(commandBuilder) + soundCommand.onRegisterCommand(commandBuilder) // ADD HERE +} +``` + +**d) `onExecute()`:** +```kotlin +override suspend fun onExecute(interaction, response) { + when (val commandName = interaction.command.rootName) { + CommandName.Play.commandName -> playCommand.onExecute(interaction, response) + CommandName.Sound.commandName -> soundCommand.onExecute(interaction, response) // ADD HERE + } +} +``` + +**e) `onAutocomplete()` (if applicable):** +```kotlin +override suspend fun onAutocomplete(interaction: AutoCompleteInteraction) { + when (val commandName = interaction.command.rootName) { + CommandName.Sound.commandName -> soundCommand.onAutoComplete(interaction) + } +} +``` + +### Step 7: Update Tests (`CommandHandlerServiceTest.kt`) + +```kotlin +private val soundCommand: SoundCommand = mockk() // ADD HERE + +private val commandHandlerService = CommandHandlerServiceImpl( + playCommand = playCommand, + soundCommand = soundCommand, // ADD HERE +) + +@Test +fun `When onRegisterSimpleCommand is called Then register all commands`() { + justRun { soundCommand.onRegisterCommand(any()) } // ADD HERE + + verify(exactly = 1) { + soundCommand.onRegisterCommand(commandBuilder) // ADD HERE + } +} +``` + +### Step 8: Create Command Tests + +Create tests in `src/test/kotlin/commands//`: +```kotlin +class SoundCommandTest { + // Test: Command registration + // Test: Command execution + // Test: Autocomplete + // Test: Error handling +} +``` + +--- + +## Embeds + +```kotlin +response.respond { + embed { + title = "Title" + description = "Description" + color = Color(0x01B05B) + + field { + name = "Field Name" + value = "Field Value" + inline = true + } + + footer { + text = "Footer text" + } + } +} +``` + +### Paginated Embeds + +```kotlin +createPaginatedEmbedMessage( + locale = locale, + localizationService = localizationService, + title = "Title", + currentPage = 1, + currentPageContent = pageContent, // List (columns) + columns = 3, // Max 3 inline columns + pageCount = totalPages, + previousButtonCustomId = "prev_btn", + nextButtonCustomId = "next_btn" +) +``` + +### Limits +- Fields: max 25 per embed +- Field name: 256 chars +- Field value: 1024 chars +- Description: 4096 chars + +--- + +## Button Interactions + +```kotlin +override suspend fun onInteract(interaction: ComponentInteraction) { + val customId = (interaction as? ButtonInteraction)?.component?.customId + + when (customId) { + "prev_btn" -> handlePrevious() + "next_btn" -> handleNext() + } +} +``` + +--- + +## Voice Channels + +```kotlin +suspend fun connectToVoiceChannel() { + voiceChannel.connect { + audioProvider { + audioPlayer.provide(20, TimeUnit.MILLISECONDS)?.let { + AudioFrame.fromData(it.data) + } + } + } +} +``` + +--- + +## Caching Strategy + +For static data like country codes (75x faster: 150ms → 2ms): + +```kotlin +class RadioService { + private var cachedCountryCodes: RadioCountryCodeDTO? = null + private var cacheTimestamp: Long = 0 + private val CACHE_EXPIRATION_MS = 3600000L // 1 hour + + suspend fun getCountryCodes(): RemoteResponse { + val currentTime = System.currentTimeMillis() + + if (cachedCountryCodes != null && (currentTime - cacheTimestamp) < CACHE_EXPIRATION_MS) { + return RemoteResponse.Success(cachedCountryCodes!!) + } + + return apiCall().also { + cachedCountryCodes = it + cacheTimestamp = currentTime + } + } +} +``` + +--- + +## Troubleshooting + +| Problem | Cause | Fix | +|---------|-------|-----| +| Command doesn't appear | Missing `onRegisterSimpleCommand()` | Add command registration | +| "Unknown command" error | Missing `onExecute()` case | Add `when` branch | +| Autocomplete doesn't work | Missing `onAutoComplete()` case | Add `when` branch + check `autocomplete = true` | + +--- + +## References +- `.github/instructions/discord-integration.instructions.md` — full context diff --git a/.agents/skills/localization/SKILL.md b/.agents/skills/localization/SKILL.md new file mode 100644 index 0000000..aa57dbc --- /dev/null +++ b/.agents/skills/localization/SKILL.md @@ -0,0 +1,167 @@ +--- +name: localization +description: IMPORTANT: Load when adding user-facing strings or new languages. Defines the YAML-based localization system for ECIBotKt. Covers key generation, LocalizationService usage, and adding new locales. All user-facing strings MUST be localized. +--- + +## When to use me +- When adding user-facing strings (command descriptions, error messages, embed text) +- When adding new languages to the bot +- When debugging missing translation keys + +## Not intended for +- Code quality checks → use `code-quality` +- Command registration (where to add keys) → use `discord-integration` +- Testing localization → use `testing` + +--- + +## How It Works + +### YAML Files + +Located in `src/main/resources/lang/`: +``` +lang/ +├── lang.yml # Default (English) +└── lang_es-ES.yml # Spanish +``` + +### Key-Value Format + +```yaml +# lang.yml +play_command_description: Play the sound with that name or the given url +now_playing: Now playing %s on %s +queue_embed_footer: Page %d of %d +``` + +### Auto-Generated Keys + +```bash +./gradlew generateLangClass +``` + +Creates `LocalizationKeys.kt`: +```kotlin +object LocalizationKeys { + const val PLAY_COMMAND_DESCRIPTION = "play_command_description" + const val NOW_PLAYING = "now_playing" + const val QUEUE_EMBED_FOOTER = "queue_embed_footer" +} +``` + +--- + +## Adding New Strings + +### Step 1: Add to both YAML files + +```yaml +# lang.yml +my_new_key: This is the English text + +# lang_es-ES.yml +my_new_key: Este es el texto en español +``` + +### Step 2: Generate keys + +```bash +./gradlew generateLangClass +``` + +### Step 3: Use in code + +```kotlin +// Simple string +val text = localizationService.getString( + key = LocalizationKeys.MY_NEW_KEY, + locale = interaction.guildLocale.orDefaultLocale() +) + +// Formatted string with arguments +val formatted = localizationService.getStringFormat( + key = LocalizationKeys.QUEUE_EMBED_FOOTER, + locale = locale, + arguments = arrayOf(currentPage, totalPages) +) + +// All language variants for Discord +descriptionLocalizations = localizationService.getLocalizations( + LocalizationKeys.PLAY_COMMAND_DESCRIPTION +) +``` + +--- + +## LocalizationService Methods + +| Method | Use For | +|--------|---------| +| `getString(key, locale)` | Simple text without formatting | +| `getStringFormat(key, locale, arguments)` | Text with placeholders (%s, %d) | +| `getLocalizations(key)` | All language variants for Discord | + +### Default Locale + +```kotlin +import es.wokis.utils.orDefaultLocale + +val locale = interaction.guildLocale.orDefaultLocale() +``` + +--- + +## Formatting Placeholders + +```yaml +my_key: Hello %s, you have %d messages +embed_footer: Page %d of %d +queue_description: Currently there are %d sounds in the queue of %s +``` + +```kotlin +localizationService.getStringFormat( + key = LocalizationKeys.MY_KEY, + locale = locale, + arguments = arrayOf("User", 5) +) +// Result: "Hello User, you have 5 messages" +``` + +--- + +## Adding New Languages + +1. Create new file: `lang_xx-XX.yml` (e.g., `lang_fr-FR.yml`) +2. Copy all keys from `lang.yml` +3. Translate values +4. Update `LocalizationService` to support new locale +5. Test with Discord client in that language + +--- + +## Best Practices + +1. **Always add to both language files** — Keep English and Spanish in sync +2. **Use descriptive keys** — `radio_play_found` better than `message_1` +3. **Group related keys** — Keep radio commands together +4. **Reuse keys when possible** — Don't duplicate similar strings +5. **Generate keys after editing YAML** — Run `generateLangClass` +6. **Test both languages** — Verify text displays correctly + +--- + +## Troubleshooting + +| Problem | Fix | +|---------|-----| +| `Exception: Key 'my_key' not found` | Run `./gradlew generateLangClass` | +| Missing translation | Add key to both `lang.yml` and `lang_es-ES.yml` | +| LSP errors in YAML | Expected due to special characters — no runtime impact | + +--- + +## References +- `.github/instructions/localization.instructions.md` — full context +- Generated file: `src/main/kotlin/localization/LocalizationKeys.kt` diff --git a/.agents/skills/quality-check/SKILL.md b/.agents/skills/quality-check/SKILL.md new file mode 100644 index 0000000..a7a097b --- /dev/null +++ b/.agents/skills/quality-check/SKILL.md @@ -0,0 +1,123 @@ +--- +name: quality-check +description: CRITICAL: Load BEFORE opening any PR. Missing this skill = failing detekt/test gates and rejected PRs. Validates all blockers: build, detekt, tests, coverage. Not for day-to-day coding — pre-PR validation only. +--- + +## When to use me +- At the end of a task before opening a PR +- During PR review to identify blockers and validate locally + +## Not intended for +- Day-to-day coding guidance → use `architecture`, `testing`, `code-quality` +- Code review → use `code-review` + +--- + +## Quality Gates (MUST) + +| Gate | Command | Status | +|------|---------|--------| +| Build | `./gradlew build` | Must pass | +| Detekt | `./gradlew detekt` | Must pass | +| Tests | `./gradlew test` | Must pass | +| Coverage | `./gradlew jacocoTestReport` | Must not drop | + +--- + +## Step 0 — Detect Changed Files (MANDATORY) + +```bash +git diff --name-only HEAD +``` + +Read **every changed file** before running gates. + +--- + +## Step 1 — Build + +```bash +./gradlew build +``` + +Must compile without errors. Set timeout: 600000ms (10 min). + +--- + +## Step 2 — Detekt (Auto-correct first) + +Detekt has auto-correctable issues. Fix first: + +```bash +# Auto-fix +./gradlew detekt --auto-correct + +# Check remaining +./gradlew detekt +``` + +Set timeout: 300000ms (5 min). + +--- + +## Step 3 — Tests + +```bash +# All tests +./gradlew test + +# Specific test class +./gradlew test --tests "RadioCountryCodesCommandTest" + +# Pattern +./gradlew test --tests "*Radio*" +``` + +Set timeout: 600000ms (10 min). + +--- + +## Step 4 — Coverage (Optional but Recommended) + +```bash +./gradlew jacocoTestReport +open build/reports/jacoco/test/html/index.html +``` + +**Requirements:** +- Minimum: 80% line coverage for new code +- Target: 100% for critical paths + +--- + +## What to Check (by change type) + +| Change Type | Required Gates | +|-------------|----------------| +| Logic changes | Build + Tests + Coverage | +| UI/Embed changes | Build + Detekt | +| New commands | Build + Tests + Detekt (all 8 registration steps) | +| Dependency changes | Build + Full test suite | +| Localization | Build (keys must be generated) | + +--- + +## CRITICAL: Run Sequentially + +``` +Build → Detekt → Tests → Coverage +``` + +Never run in parallel. Order matters — don't test code that doesn't compile. + +--- + +## Reporting Format + +- **BLOCKER**: Failing build, failing tests, detekt errors, coverage drops +- **WARNING**: Non-blocking improvements + +--- + +## References +- `.github/copilot-instructions.md` — build commands diff --git a/.agents/skills/testing/SKILL.md b/.agents/skills/testing/SKILL.md new file mode 100644 index 0000000..0d92dce --- /dev/null +++ b/.agents/skills/testing/SKILL.md @@ -0,0 +1,308 @@ +--- +name: testing +description: IMPORTANT: Load when writing tests or debugging test failures. Defines testing conventions for ECIBotKt. Covers MockK patterns, BDD naming, coroutine testing with runTest, and Discord interaction mocking. All logic changes REQUIRE tests. +--- + +## When to use me +- When writing unit tests for commands, services, or data classes +- When mocking Discord interactions or services +- When debugging test failures (MockK, coroutine issues) + +## Not intended for +- Code quality checks → use `code-quality` +- Build/test gates → use `quality-check` +- Architecture patterns → use `architecture` + +--- + +## Test Frameworks + +- **Kotlin Test** (JUnit 5 platform) +- **Mockk** (1.14.2) for mocking +- **Kotlinx Coroutines Test** for async testing + +--- + +## Test Structure + +``` +src/test/kotlin/ +├── commands/ +│ ├── lavaplayer/ +│ ├── play/ +│ ├── player/ +│ ├── queue/ +│ ├── radio/ +│ └── ... +├── data/ +├── services/ +│ ├── lavaplayer/ +│ ├── localization/ +│ ├── queue/ +│ └── radio/ +└── utils/ +``` + +--- + +## BDD Test Naming + +Pattern: `Given [context] When [action] Then [expected result]` + +```kotlin +@Test +fun `Given empty queue When skip Then show error`() = runTest { + // Given + // When + // Then +} +``` + +Examples: +- `Given valid radio name When play Then add to queue` +- `Given error response When search Then show error message` +- `Given spanish locale When execute Then use spanish text` + +--- + +## Basic Test Structure + +```kotlin +package commands.radio.subcommands + +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test + +class RadioCountryCodesCommandTest { + + @Test + fun `Given X When Y Then Z`() = runTest { + // Given - Setup mocks and test data + + // When - Execute the action + + // Then - Verify results + } +} +``` + +--- + +## MockK Patterns + +### Creating Mocks + +```kotlin +val radioService: RadioService = mockk() +val localizationService: LocalizationService = mockk() +``` + +### Regular Functions + +```kotlin +every { + localizationService.getString(any(), any()) +} returns "Localized Text" +``` + +### Suspend Functions + +```kotlin +coEvery { + radioService.getCountryCodes() +} returns RemoteResponse.Success(data) +``` + +### Verifying Calls + +```kotlin +// Regular +verify(exactly = 1) { mock.method() } + +// Suspend +coVerify(exactly = 1) { mock.suspendMethod() } +``` + +### Allow Unit Returns + +```kotlin +justRun { command.onRegisterCommand(any()) } +``` + +### Reference Table + +| Function | Use For | +|----------|---------| +| `every { }` | Regular (non-suspend) functions | +| `coEvery { }` | Suspend functions | +| `verify { }` | Verify regular calls | +| `coVerify { }` | Verify suspend calls | +| `justRun { }` | Allow Unit return suspend/regular calls | +| `mockk()` | Create mock objects | +| `mockk(relaxed = true)` | Create mock with default returns (avoid) | + +--- + +## Mocking Discord Interactions + +```kotlin +import dev.kord.common.Locale +import dev.kord.core.entity.interaction.ChatInputCommandInteraction +import io.mockk.every +import io.mockk.mockk +import mock.mockedKord + +val interaction = mockk { + every { kord } returns mockedKord + every { guildLocale } returns Locale.ENGLISH_UNITED_STATES + every { id } returns Snowflake(123) +} + +val response = mockk() +``` + +--- + +## Mocking Common Services + +### ConfigService + +```kotlin +val configService: ConfigService = mockk { + every { config } returns mockk { + every { debug } returns false + } +} +``` + +### LocalizationService + +```kotlin +every { + localizationService.getString(any(), any()) +} returns "Localized String" + +every { + localizationService.getStringFormat(any(), any(), *anyVararg()) +} returns "Formatted String" +``` + +--- + +## Testing Commands + +```kotlin +@Test +fun `Given valid input When execute Then success`() = runTest { + // Given + val command = RadioCountryCodesCommand(radioService, localizationService) + + // When + command.onExecute(interaction, mockedResponse) + + // Then + coVerify(exactly = 1) { + radioService.getCountryCodes() + } +} +``` + +--- + +## Testing Error Handling + +```kotlin +@Test +fun `Given error response When execute Then show error`() = runTest { + // Given + coEvery { service.method() } returns RemoteResponse.Error(ErrorType(...)) + + // When + command.onExecute(interaction, response) + + // Then + coVerify { + localizationService.getString(LocalizationKeys.ERROR_KEY, any()) + } +} +``` + +--- + +## Testing Services + +Mock HTTP client for service tests: + +```kotlin +@Test +fun `Given valid response When getCountryCodes Then return success`() = runTest { + // Given + val jsonResponse = """{"countryCode": ["US", "ES"]}""" + val httpClient = getMockedHttpClient(jsonResponse) + val service = RadioService(httpClient, configService) + + // When + val result = service.getCountryCodes() + + // Then + assertTrue(result is RemoteResponse.Success) +} +``` + +--- + +## Running Tests + +```bash +# All tests +./gradlew test + +# Single test class +./gradlew test --tests "RadioCountryCodesCommandTest" + +# With pattern +./gradlew test --tests "*Radio*" + +# Coverage report +./gradlew jacocoTestReport +``` + +--- + +## Coverage Requirements + +- **Minimum**: 80% line coverage for new code +- **Target**: 100% for critical paths +- **Exceptions**: Discord API wrappers (hard to test) + +--- + +## Golden Rules + +1. **Test behavior, not implementation** — Don't test private methods +2. **One assertion per test** (ideally) +3. **Use descriptive names** — `Given...When...Then...` +4. **Mock external dependencies** — Don't call real Discord API +5. **Test both success and failure paths** +6. **Keep tests independent** — Don't rely on execution order +7. **Use `runTest` for coroutines** — Always for suspend functions +8. **Verify interactions** — Use `coVerify` to ensure methods were called +9. **NEVER use `lateinit var`** — Create instances inline as `val` +10. **ALWAYS specify verification count** — `verify(exactly = n)` + +--- + +## Troubleshooting + +| Problem | Cause | Fix | +|---------|-------|-----| +| "No answer found" | Missing mock for suspend function | Use `coEvery` instead of `every` | +| "verify was not called" | Verification on wrong thread | Use `coVerify` for suspend functions | +| Test hangs | Coroutine not completing | Ensure all coroutines complete within `runTest` | + +--- + +## References +- `.github/instructions/testing.instructions.md` — full context +- Mockk: https://mockk.io/ +- Coroutines testing: https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-test/ diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 2d9a9d8..992ed25 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -63,10 +63,10 @@ Country codes are cached for 1 hour to reduce API calls: - See `discord-integration.instructions.md` for implementation ### Code Quality -- **ktlint**: Strict code style enforcement +- **detekt**: Strict code style enforcement - **Jacoco**: Code coverage reporting - **SonarCloud**: Quality gates in CI/CD -- All code must pass ktlint checks before merge +- All code must pass detekt checks before merge ### Testing Strategy - **Framework**: Kotlin Test with Mockk @@ -85,7 +85,7 @@ Country codes are cached for 1 hour to reduce API calls: ### Common Tasks - **Build**: `./gradlew build` - **Test**: `./gradlew test` -- **Ktlint**: `./gradlew ktlintCheck` +- **Detekt**: `./gradlew detekt` - **Coverage**: `./gradlew jacocoTestReport` ### Adding New Features @@ -93,7 +93,7 @@ Country codes are cached for 1 hour to reduce API calls: 2. Use Koin for dependency injection 3. Add localization keys for all user-facing strings 4. Write tests for new functionality -5. Ensure ktlint compliance +5. Ensure detekt compliance ## Related Documentation diff --git a/.github/instructions/code-quality.instructions.md b/.github/instructions/code-quality.instructions.md index c34554c..d0bc2a0 100644 --- a/.github/instructions/code-quality.instructions.md +++ b/.github/instructions/code-quality.instructions.md @@ -1,19 +1,19 @@ # Code Quality Instructions -This document covers both **formatting standards** (ktlint) and **refactoring patterns** for maintaining high-quality code in ECIBotKt. +This document covers both **formatting standards** (detekt) and **refactoring patterns** for maintaining high-quality code in ECIBotKt. --- -## Part 1: Code Formatting (ktlint) +## Part 1: Code Formatting (detekt) -### Running Ktlint +### Running Detekt ```bash # Check for issues -./gradlew ktlintCheck +./gradlew detekt # Auto-fix issues (when possible) -./gradlew ktlintFormat +./gradlew detekt --auto-correct ``` ### Common Formatting Issues @@ -157,9 +157,9 @@ val x = 5 ### IDE Integration **IntelliJ IDEA**: -1. Install ktlint plugin -2. Enable "Reformat with ktlint on save" -3. Configure to use project's ktlint settings +1. Install detekt plugin +2. Enable "Reformat with detekt on save" +3. Configure to use project's detekt settings **VS Code**: 1. Install "Kotlin Language" extension @@ -180,7 +180,7 @@ trim_trailing_whitespace = true ### CI/CD Integration -Ktlint runs in GitHub Actions. **PRs cannot be merged if ktlint fails.** +Detekt runs in GitHub Actions. **PRs cannot be merged if detekt fails.** --- @@ -357,7 +357,7 @@ coVerify { lavaPlayerService.loadAndPlay(expectedUrl, addToFront = true) } / Before submitting a PR, verify: -- [ ] `./gradlew ktlintCheck` passes +- [ ] `./gradlew detekt` passes - [ ] All existing tests pass - [ ] No trailing spaces or formatting issues - [ ] Code duplication is minimized @@ -380,7 +380,7 @@ Before submitting a PR, verify: ## References -- ktlint: https://pinterest.github.io/ktlint/ +- detekt: https://detekt.dev/ - Kotlin Coding Conventions: https://kotlinlang.org/docs/coding-conventions.html - Refactoring Guru: https://refactoring.guru/ - EditorConfig: https://editorconfig.org/ diff --git a/.github/workflows/detekt.yml b/.github/workflows/detekt.yml new file mode 100644 index 0000000..ad0bb44 --- /dev/null +++ b/.github/workflows/detekt.yml @@ -0,0 +1,15 @@ +name: detekt + +on: [pull_request] + +jobs: + detekt: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@master + - uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + - uses: gradle/actions/setup-gradle@v4 + - run: ./gradlew detekt diff --git a/.github/workflows/ktlint.yaml b/.github/workflows/ktlint.yaml deleted file mode 100644 index f5d886b..0000000 --- a/.github/workflows/ktlint.yaml +++ /dev/null @@ -1,28 +0,0 @@ -name: ktlint - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -on: [pull_request] -jobs: - ktlint: - name: Check Code Quality - runs-on: ubuntu-latest - - permissions: - checks: write - contents: read - pull-requests: write - - steps: - - name: Clone repo - uses: actions/checkout@master - with: - fetch-depth: 1 - - name: ktlint - uses: ScaCap/action-ktlint@master - with: - github_token: ${{ secrets.github_token }} - reporter: github-pr-review - fail_on_error: true diff --git a/build.gradle.kts b/build.gradle.kts index c41fe6e..bf2850e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,6 +8,13 @@ plugins { alias(libs.plugins.sonarqube) alias(libs.plugins.plugin.serialization) alias(libs.plugins.idea.ext) + alias(libs.plugins.detekt) +} + +detekt { + buildUponDefaultConfig = true + allRules = false + config.setFrom(files("$projectDir/detekt.yml")) } group = "es.wokis" @@ -75,6 +82,9 @@ dependencies { testImplementation(libs.mockk) testImplementation(libs.kotlin.coroutines.test) testImplementation(libs.junit.parametrized) + + // Detekt + detektPlugins(libs.detekt.formatting) } tasks.test { diff --git a/detekt.yml b/detekt.yml new file mode 100644 index 0000000..c5d13e2 --- /dev/null +++ b/detekt.yml @@ -0,0 +1,72 @@ +build: + maxIssues: 15 + excludeCorrectable: false + +config: + validation: true + warningsAsErrors: false + +complexity: + active: true + LongParameterList: + active: true + functionThreshold: 10 + constructorThreshold: 12 + TooManyFunctions: + active: true + thresholdInFiles: 20 + thresholdInClasses: 20 + +coroutines: + active: true + +empty-blocks: + active: true + +exceptions: + active: true + TooGenericExceptionCaught: + active: false + TooGenericExceptionThrown: + active: true + +formatting: + active: true + autoCorrect: true + MaximumLineLength: + active: true + maxLineLength: 140 + Indentation: + active: true + indentSize: 4 + NoWildcardImports: + active: false + +naming: + active: true + InvalidPackageDeclaration: + active: false + FunctionNaming: + active: true + VariableNaming: + active: true + +performance: + active: true + +potential-bugs: + active: true + +style: + active: true + MagicNumber: + excludes: ['**/test/**'] + ignoreNumbers: ['-1', '0', '1', '2', '60', '100', '1000'] + MaxLineLength: + active: true + maxLineLength: 140 + WildcardImport: + active: false + ReturnCount: + active: true + max: 3 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9cdaa51..7d7b5fc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,4 +1,5 @@ [versions] +detekt = "1.23.7" jacoco = "0.8.12" # Kord is now loaded from local libs/ folder as SNAPSHOT JARs # See libs/ directory for the JAR files @@ -51,9 +52,11 @@ koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin" } koin-core = { module = "io.insert-koin:koin-core" } kotlinx-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" } junit-parametrized = { module = "org.junit.jupiter:junit-jupiter-params", version.ref = "junit-parametized" } +detekt-formatting = { group = "io.gitlab.arturbosch.detekt", name = "detekt-formatting", version.ref = "detekt" } [plugins] kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } plugin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } sonarqube = { id = "org.sonarqube", version.ref = "sonarqube" } idea-ext = {id = "org.jetbrains.gradle.plugin.idea-ext", version.ref = "idea-ext"} +detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } diff --git a/src/main/kotlin/commands/CommandName.kt b/src/main/kotlin/commands/CommandName.kt index b0f54d1..033ee12 100644 --- a/src/main/kotlin/commands/CommandName.kt +++ b/src/main/kotlin/commands/CommandName.kt @@ -13,6 +13,11 @@ sealed class CommandName(val commandName: String) { data object Next : CommandName("next") data object Disconnect : CommandName("disconnect") data object Locale : CommandName("locale") + data object Config : CommandName("config") { + data object Reload : CommandName("reload") + data object Set : CommandName("set") + data object Get : CommandName("get") + } data object Radio : CommandName("radio") { data object Play : CommandName("play") data object List : CommandName("list") diff --git a/src/main/kotlin/commands/GroupCommand.kt b/src/main/kotlin/commands/GroupCommand.kt index e80463c..084ef5d 100644 --- a/src/main/kotlin/commands/GroupCommand.kt +++ b/src/main/kotlin/commands/GroupCommand.kt @@ -3,12 +3,13 @@ package es.wokis.commands import dev.kord.core.Kord import dev.kord.core.behavior.interaction.response.DeferredPublicMessageInteractionResponseBehavior import dev.kord.core.entity.interaction.ChatInputCommandInteraction -import dev.kord.rest.builder.interaction.GlobalChatInputCreateBuilder -import dev.kord.rest.builder.interaction.GlobalMultiApplicationCommandBuilder interface GroupCommand { suspend fun onRegisterCommand(kord: Kord) - suspend fun onExecute(interaction: ChatInputCommandInteraction, response: DeferredPublicMessageInteractionResponseBehavior) + suspend fun onExecute( + interaction: ChatInputCommandInteraction, + response: DeferredPublicMessageInteractionResponseBehavior + ) } diff --git a/src/main/kotlin/commands/commons/PaginatedEmbedUtils.kt b/src/main/kotlin/commands/commons/PaginatedEmbedUtils.kt index b45ebb9..5282721 100644 --- a/src/main/kotlin/commands/commons/PaginatedEmbedUtils.kt +++ b/src/main/kotlin/commands/commons/PaginatedEmbedUtils.kt @@ -14,6 +14,10 @@ import es.wokis.localization.LocalizationKeys import es.wokis.services.localization.LocalizationService import es.wokis.utils.takeIfNotEmpty +private const val PAGINATED_EMBED_COLOR = 0x01B05B + +// TODO: Refactor to use data class instead of many parameters (issue: #detekt-suppress) +@Suppress("LongParameterList", "ForbiddenComment") suspend fun AbstractMessageModifyBuilder.createPaginatedEmbedMessage( guildId: Snowflake?, discordLocale: Locale?, @@ -67,7 +71,7 @@ private suspend fun AbstractMessageModifyBuilder.createEmbed( embedDescription?.let { description = it } - color = Color(0x01B05B) + color = Color(PAGINATED_EMBED_COLOR) if (currentPageContent.isNullOrEmpty().not()) { for (column in (0 until columns)) { val displayMessage = currentPageContent?.getOrNull(column)?.takeIfNotEmpty() ?: BLANK_SPACE diff --git a/src/main/kotlin/commands/config/ConfigGroupCommand.kt b/src/main/kotlin/commands/config/ConfigGroupCommand.kt new file mode 100644 index 0000000..476bc62 --- /dev/null +++ b/src/main/kotlin/commands/config/ConfigGroupCommand.kt @@ -0,0 +1,63 @@ +package es.wokis.commands.config + +import dev.kord.core.Kord +import dev.kord.core.behavior.interaction.response.DeferredPublicMessageInteractionResponseBehavior +import dev.kord.core.behavior.interaction.response.respond +import dev.kord.core.entity.interaction.AutoCompleteInteraction +import dev.kord.core.entity.interaction.ChatInputCommandInteraction +import es.wokis.commands.Autocomplete +import es.wokis.commands.CommandName +import es.wokis.commands.Component +import es.wokis.commands.GroupCommand +import es.wokis.localization.LocalizationKeys +import es.wokis.services.localization.LocalizationService +import dev.kord.core.entity.interaction.SubCommand as KordSubCommand + +class ConfigGroupCommand( + private val configReloadCommand: ConfigReloadCommand, + private val configSetCommand: ConfigSetCommand, + private val configGetCommand: ConfigGetCommand, + private val localizationService: LocalizationService +) : GroupCommand, Component, Autocomplete { + + override suspend fun onRegisterCommand(kord: Kord) { + kord.createGlobalChatInputCommand( + CommandName.Config.commandName, + localizationService.getString(LocalizationKeys.CONFIG_COMMAND_DESCRIPTION) + ) { + descriptionLocalizations = localizationService.getLocalizations(LocalizationKeys.CONFIG_COMMAND_DESCRIPTION) + configReloadCommand.onRegisterCommand(this) + configSetCommand.onRegisterCommand(this) + configGetCommand.onRegisterCommand(this) + } + } + + override suspend fun onExecute( + interaction: ChatInputCommandInteraction, + response: DeferredPublicMessageInteractionResponseBehavior + ) { + val commandName = (interaction.command as? KordSubCommand)?.name + commandName?.let { + when (commandName) { + CommandName.Config.Reload.commandName -> configReloadCommand.onExecute(interaction, response) + CommandName.Config.Set.commandName -> configSetCommand.onExecute(interaction, response) + CommandName.Config.Get.commandName -> configGetCommand.onExecute(interaction, response) + } + } ?: response.respond { + val guildId = interaction.data.guildId.value + val discordLocale = interaction.guildLocale + content = localizationService.getString(LocalizationKeys.ERROR_UNEXPECTED, guildId = guildId, discordLocale = discordLocale) + } + } + + // Config commands don't handle button interactions, only slash commands + @Suppress("EmptyFunctionBlock") + override suspend fun onInteract(interaction: dev.kord.core.entity.interaction.ComponentInteraction) = Unit + + override suspend fun onAutoComplete(interaction: AutoCompleteInteraction) { + val subCommandName = (interaction.command as? KordSubCommand)?.name + when (subCommandName) { + CommandName.Config.Get.commandName -> configGetCommand.onAutoComplete(interaction) + } + } +} diff --git a/src/main/kotlin/commands/config/subcommands/get/ConfigGetCommand.kt b/src/main/kotlin/commands/config/subcommands/get/ConfigGetCommand.kt new file mode 100644 index 0000000..971d7a2 --- /dev/null +++ b/src/main/kotlin/commands/config/subcommands/get/ConfigGetCommand.kt @@ -0,0 +1,105 @@ +package es.wokis.commands.config + +import dev.kord.common.entity.Choice +import dev.kord.common.entity.optional.Optional +import dev.kord.core.behavior.interaction.response.DeferredPublicMessageInteractionResponseBehavior +import dev.kord.core.behavior.interaction.response.respond +import dev.kord.core.behavior.interaction.suggest +import dev.kord.core.entity.interaction.AutoCompleteInteraction +import dev.kord.core.entity.interaction.ChatInputCommandInteraction +import dev.kord.rest.builder.interaction.GlobalChatInputCreateBuilder +import dev.kord.rest.builder.interaction.string +import dev.kord.rest.builder.interaction.subCommand +import es.wokis.commands.Autocomplete +import es.wokis.commands.CommandName +import es.wokis.commands.SubCommand +import es.wokis.localization.LocalizationKeys +import es.wokis.services.config.ConfigService +import es.wokis.services.localization.LocalizationService + +private const val ARGUMENT_SECTION = "section" +private const val AUTOCOMPLETE_SUGGESTION_LIMIT = 25 + +class ConfigGetCommand( + private val configService: ConfigService, + private val localizationService: LocalizationService +) : SubCommand, Autocomplete { + + private val validSections = listOf("database", "youtube", "deezer", "spotify", "tidal", "kokoro") + + override suspend fun onRegisterCommand(builder: GlobalChatInputCreateBuilder) { + builder.apply { + subCommand( + CommandName.Config.Get.commandName, + localizationService.getString(LocalizationKeys.CONFIG_GET_COMMAND_DESCRIPTION) + ) { + descriptionLocalizations = localizationService.getLocalizations(LocalizationKeys.CONFIG_GET_SECTION_DESCRIPTION) + string( + ARGUMENT_SECTION, + localizationService.getString(LocalizationKeys.CONFIG_GET_SECTION_DESCRIPTION) + ) { + descriptionLocalizations = localizationService.getLocalizations(LocalizationKeys.CONFIG_GET_SECTION_DESCRIPTION) + required = true + autocomplete = true + } + } + } + } + + override suspend fun onExecute( + interaction: ChatInputCommandInteraction, + response: DeferredPublicMessageInteractionResponseBehavior + ) { + val guildId = interaction.data.guildId.value + val discordLocale = interaction.guildLocale + val section = interaction.command.strings[ARGUMENT_SECTION] + + if (section == null || section !in validSections) { + response.respond { + content = localizationService.getString( + LocalizationKeys.CONFIG_INVALID_SECTION, + guildId = guildId, + discordLocale = discordLocale + ) + } + return + } + + val config = configService.config + val sectionData = when (section) { + "database" -> config.database + "youtube" -> config.youtube + "deezer" -> config.deezer + "spotify" -> config.spotify + "tidal" -> config.tidal + "kokoro" -> config.kokoro + else -> null + } + + response.respond { + content = localizationService.getStringFormat( + LocalizationKeys.CONFIG_GET_DISPLAY, + guildId = guildId, + discordLocale = discordLocale, + arguments = arrayOf(section, sectionData.toString()) + ) + } + } + + override suspend fun onAutoComplete(interaction: AutoCompleteInteraction) { + val input = interaction.command.strings[ARGUMENT_SECTION].orEmpty().lowercase() + + val suggestions = validSections + .filter { it.lowercase().contains(input) } + .take(AUTOCOMPLETE_SUGGESTION_LIMIT) + .map { section -> + Choice.StringChoice( + name = section, + nameLocalizations = Optional.Missing(), + value = section + ) + } + + interaction.suggest(suggestions) + } +} diff --git a/src/main/kotlin/commands/config/subcommands/reload/ConfigReloadCommand.kt b/src/main/kotlin/commands/config/subcommands/reload/ConfigReloadCommand.kt new file mode 100644 index 0000000..f277b20 --- /dev/null +++ b/src/main/kotlin/commands/config/subcommands/reload/ConfigReloadCommand.kt @@ -0,0 +1,54 @@ +package es.wokis.commands.config + +import dev.kord.core.behavior.interaction.response.DeferredPublicMessageInteractionResponseBehavior +import dev.kord.core.behavior.interaction.response.respond +import dev.kord.core.entity.interaction.ChatInputCommandInteraction +import dev.kord.rest.builder.interaction.GlobalChatInputCreateBuilder +import dev.kord.rest.builder.interaction.subCommand +import es.wokis.commands.CommandName +import es.wokis.commands.SubCommand +import es.wokis.localization.LocalizationKeys +import es.wokis.services.config.ConfigService +import es.wokis.services.localization.LocalizationService +import es.wokis.utils.Log + +class ConfigReloadCommand( + private val configService: ConfigService, + private val localizationService: LocalizationService +) : SubCommand { + + override suspend fun onRegisterCommand(builder: GlobalChatInputCreateBuilder) { + builder.apply { + subCommand( + CommandName.Config.Reload.commandName, + localizationService.getString(LocalizationKeys.CONFIG_RELOAD_COMMAND_DESCRIPTION) + ) { + descriptionLocalizations = localizationService.getLocalizations(LocalizationKeys.CONFIG_RELOAD_COMMAND_DESCRIPTION) + } + } + } + + override suspend fun onExecute( + interaction: ChatInputCommandInteraction, + response: DeferredPublicMessageInteractionResponseBehavior + ) { + val guildId = interaction.data.guildId.value + val discordLocale = interaction.guildLocale + try { + configService.reload() + Log.info("Configuration reloaded via command by user ${interaction.user.id}") + response.respond { + content = localizationService.getString( + LocalizationKeys.CONFIG_RELOAD_SUCCESS, + guildId = guildId, + discordLocale = discordLocale + ) + } + } catch (e: Exception) { + Log.error("Failed to reload config via command", e) + response.respond { + content = localizationService.getString(LocalizationKeys.ERROR_UNEXPECTED, guildId = guildId, discordLocale = discordLocale) + } + } + } +} diff --git a/src/main/kotlin/commands/config/subcommands/set/ConfigSetCommand.kt b/src/main/kotlin/commands/config/subcommands/set/ConfigSetCommand.kt new file mode 100644 index 0000000..25d3080 --- /dev/null +++ b/src/main/kotlin/commands/config/subcommands/set/ConfigSetCommand.kt @@ -0,0 +1,173 @@ +package es.wokis.commands.config + +import dev.kord.core.behavior.interaction.response.DeferredPublicMessageInteractionResponseBehavior +import dev.kord.core.behavior.interaction.response.respond +import dev.kord.core.entity.interaction.ChatInputCommandInteraction +import dev.kord.rest.builder.interaction.GlobalChatInputCreateBuilder +import dev.kord.rest.builder.interaction.string +import dev.kord.rest.builder.interaction.subCommand +import es.wokis.commands.CommandName +import es.wokis.commands.SubCommand +import es.wokis.localization.LocalizationKeys +import es.wokis.services.config.ConfigService +import es.wokis.services.localization.LocalizationService +import es.wokis.utils.Log +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import java.io.File + +private const val ARGUMENT_SECTION = "section" +private const val ARGUMENT_KEY = "key" +private const val ARGUMENT_VALUE = "value" +private const val CONFIG_PATH = "./data/config.json" + +class ConfigSetCommand( + private val configService: ConfigService, + private val localizationService: LocalizationService +) : SubCommand { + + private val validSections = mapOf( + "database" to listOf("enabled", "username", "password", "database"), + "youtube" to listOf("enabled", "oauth2_token", "po_token", "visitor_data", "remote_cipher_url", "remote_cipher_password"), + "deezer" to listOf("enabled", "master_decryption_key", "arl_token"), + "spotify" to listOf("enabled", "client_id", "client_secret", "custom_endpoint"), + "tidal" to listOf("enabled", "country_code", "token"), + "kokoro" to listOf("enabled", "base_url", "default_voice", "default_speed", "default_lang_code") + ) + + override suspend fun onRegisterCommand(builder: GlobalChatInputCreateBuilder) { + builder.apply { + subCommand( + CommandName.Config.Set.commandName, + localizationService.getString(LocalizationKeys.CONFIG_SET_COMMAND_DESCRIPTION) + ) { + descriptionLocalizations = localizationService.getLocalizations(LocalizationKeys.CONFIG_SET_COMMAND_DESCRIPTION) + string( + ARGUMENT_SECTION, + localizationService.getString(LocalizationKeys.CONFIG_SET_SECTION_DESCRIPTION) + ) { + descriptionLocalizations = localizationService.getLocalizations(LocalizationKeys.CONFIG_SET_SECTION_DESCRIPTION) + required = true + } + string(ARGUMENT_KEY, localizationService.getString(LocalizationKeys.CONFIG_SET_KEY_DESCRIPTION)) { + descriptionLocalizations = localizationService.getLocalizations(LocalizationKeys.CONFIG_SET_KEY_DESCRIPTION) + required = true + } + string(ARGUMENT_VALUE, localizationService.getString(LocalizationKeys.CONFIG_SET_VALUE_DESCRIPTION)) { + descriptionLocalizations = localizationService.getLocalizations(LocalizationKeys.CONFIG_SET_VALUE_DESCRIPTION) + required = true + } + } + } + } + + // TODO: Refactor to reduce complexity (issue: #detekt-suppress) + @Suppress("ReturnCount", "LongMethod", "ForbiddenComment") + override suspend fun onExecute( + interaction: ChatInputCommandInteraction, + response: DeferredPublicMessageInteractionResponseBehavior + ) { + val guildId = interaction.data.guildId.value + val discordLocale = interaction.guildLocale + val section = interaction.command.strings[ARGUMENT_SECTION] + val key = interaction.command.strings[ARGUMENT_KEY] + val value = interaction.command.strings[ARGUMENT_VALUE] + + if (section == null || section !in validSections) { + response.respond { + content = localizationService.getString( + LocalizationKeys.CONFIG_INVALID_SECTION, + guildId = guildId, + discordLocale = discordLocale + ) + } + return + } + + if (key == null || validSections[section]?.contains(key) != true) { + response.respond { + content = localizationService.getString( + LocalizationKeys.CONFIG_INVALID_KEY, + guildId = guildId, + discordLocale = discordLocale + ) + } + return + } + + if (value.isNullOrEmpty()) { + response.respond { + content = localizationService.getString( + LocalizationKeys.ERROR_NO_CONTENT_PROVIDED, + guildId = guildId, + discordLocale = discordLocale + ) + } + return + } + + if (section == "discord_bot_token" || (section == "database" && key == "password")) { + response.respond { + content = localizationService.getString( + LocalizationKeys.CONFIG_CANNOT_MODIFY_TOKEN, + guildId = guildId, + discordLocale = discordLocale + ) + } + return + } + + try { + updateConfigValue(section, key, value) + configService.reload() + Log.info("Config updated via command: $section.$key = $value by user ${interaction.user.id}") + response.respond { + content = localizationService.getStringFormat( + LocalizationKeys.CONFIG_SET_SUCCESS, + guildId = guildId, + discordLocale = discordLocale, + arguments = arrayOf("$section.$key", value) + ) + } + } catch (e: Exception) { + Log.error("Failed to update config via command", e) + response.respond { + content = localizationService.getString(LocalizationKeys.ERROR_UNEXPECTED, guildId = guildId, discordLocale = discordLocale) + } + } + } + + private fun updateConfigValue(section: String, key: String, value: String?) { + check(!value.isNullOrEmpty()) { "Value cannot be null or empty" } + val configFile = File(CONFIG_PATH) + val json = Json { prettyPrint = true } + val configJson = json.parseToJsonElement(configFile.readText()) as JsonObject + val sectionJson = checkNotNull(configJson[section] as? JsonObject) { "Section $section not found in config" } + + val updatedSectionJson = JsonObject( + sectionJson.toMutableMap().apply { + val newValue = when { + value.lowercase() == "true" -> JsonPrimitive(true) + value.lowercase() == "false" -> JsonPrimitive(false) + value.toDoubleOrNull() != null -> JsonPrimitive(value.toDouble()) + else -> JsonPrimitive(value) + } + put(key, newValue) + } + ) + + val updatedConfigJson = JsonObject( + configJson.toMutableMap().apply { + put(section, updatedSectionJson) + } + ) + + configFile.writeText( + json.encodeToString( + JsonObject.serializer(), + updatedConfigJson + ) + ) + } +} diff --git a/src/main/kotlin/commands/disconnect/DisconnectCommand.kt b/src/main/kotlin/commands/disconnect/DisconnectCommand.kt index cff2499..a464960 100644 --- a/src/main/kotlin/commands/disconnect/DisconnectCommand.kt +++ b/src/main/kotlin/commands/disconnect/DisconnectCommand.kt @@ -19,7 +19,9 @@ class DisconnectCommand( commandBuilder.apply { input( name = CommandName.Disconnect.commandName, - description = localizationService.getLocalizations(LocalizationKeys.DISCONNECT_COMMAND_DESCRIPTION).values.first() + description = localizationService.getLocalizations( + LocalizationKeys.DISCONNECT_COMMAND_DESCRIPTION + ).values.first() ) { descriptionLocalizations = localizationService.getLocalizations(LocalizationKeys.DISCONNECT_COMMAND_DESCRIPTION) } diff --git a/src/main/kotlin/commands/locale/LocaleCommand.kt b/src/main/kotlin/commands/locale/LocaleCommand.kt index 617c7d7..4851030 100644 --- a/src/main/kotlin/commands/locale/LocaleCommand.kt +++ b/src/main/kotlin/commands/locale/LocaleCommand.kt @@ -22,6 +22,8 @@ import es.wokis.localization.LocalizationKeys import es.wokis.services.localization.LocalizationService private const val ARGUMENT_LOCALE = "locale" +private const val AUTOCOMPLETE_SUGGESTION_LIMIT = 25 +private const val LOCALE_CODE_MAX_LENGTH = 100 class LocaleCommand( private val localizationService: LocalizationService, @@ -32,13 +34,17 @@ class LocaleCommand( commandBuilder.apply { input( name = CommandName.Locale.commandName, - description = localizationService.getLocalizations(LocalizationKeys.LOCALE_COMMAND_DESCRIPTION).values.first() + description = localizationService.getLocalizations( + LocalizationKeys.LOCALE_COMMAND_DESCRIPTION + ).values.first() ) { descriptionLocalizations = localizationService.getLocalizations(LocalizationKeys.LOCALE_COMMAND_DESCRIPTION) defaultMemberPermissions = Permissions(Permission.Administrator) string( name = ARGUMENT_LOCALE, - description = localizationService.getLocalizations(LocalizationKeys.LOCALE_COMMAND_INPUT_DESCRIPTION).values.first() + description = localizationService.getLocalizations( + LocalizationKeys.LOCALE_COMMAND_INPUT_DESCRIPTION + ).values.first() ) { descriptionLocalizations = localizationService.getLocalizations(LocalizationKeys.LOCALE_COMMAND_INPUT_DESCRIPTION) required = true @@ -111,10 +117,10 @@ class LocaleCommand( code.lowercase().contains(input) } } - .take(25) + .take(AUTOCOMPLETE_SUGGESTION_LIMIT) .map { (code, _) -> Choice.StringChoice( - name = code.take(100), + name = code.take(LOCALE_CODE_MAX_LENGTH), nameLocalizations = Optional.Missing(), value = code ) diff --git a/src/main/kotlin/commands/next/NextCommand.kt b/src/main/kotlin/commands/next/NextCommand.kt index e381d2f..434a02a 100644 --- a/src/main/kotlin/commands/next/NextCommand.kt +++ b/src/main/kotlin/commands/next/NextCommand.kt @@ -26,12 +26,16 @@ class NextCommand( commandBuilder.apply { input( name = CommandName.Next.commandName, - description = localizationService.getLocalizations(LocalizationKeys.NEXT_COMMAND_DESCRIPTION).values.first() + description = localizationService.getLocalizations( + LocalizationKeys.NEXT_COMMAND_DESCRIPTION + ).values.first() ) { descriptionLocalizations = localizationService.getLocalizations(LocalizationKeys.NEXT_COMMAND_DESCRIPTION) string( name = ARGUMENT_NAME, - description = localizationService.getLocalizations(LocalizationKeys.NEXT_COMMAND_INPUT_DESCRIPTION).values.first() + description = localizationService.getLocalizations( + LocalizationKeys.NEXT_COMMAND_INPUT_DESCRIPTION + ).values.first() ) { descriptionLocalizations = localizationService.getLocalizations(LocalizationKeys.NEXT_COMMAND_INPUT_DESCRIPTION) required = true diff --git a/src/main/kotlin/commands/play/PlayCommand.kt b/src/main/kotlin/commands/play/PlayCommand.kt index 9611ce2..05ed7f2 100644 --- a/src/main/kotlin/commands/play/PlayCommand.kt +++ b/src/main/kotlin/commands/play/PlayCommand.kt @@ -25,12 +25,16 @@ class PlayCommand( commandBuilder.apply { input( name = CommandName.Play.commandName, - description = localizationService.getLocalizations(LocalizationKeys.PLAY_COMMAND_DESCRIPTION).values.first() + description = localizationService.getLocalizations( + LocalizationKeys.PLAY_COMMAND_DESCRIPTION + ).values.first() ) { descriptionLocalizations = localizationService.getLocalizations(LocalizationKeys.PLAY_COMMAND_DESCRIPTION) string( name = ARGUMENT_NAME, - description = localizationService.getLocalizations(LocalizationKeys.PLAY_COMMAND_INPUT_DESCRIPTION).values.first() + description = localizationService.getLocalizations( + LocalizationKeys.PLAY_COMMAND_INPUT_DESCRIPTION + ).values.first() ) { descriptionLocalizations = localizationService.getLocalizations(LocalizationKeys.PLAY_COMMAND_INPUT_DESCRIPTION) required = true diff --git a/src/main/kotlin/commands/player/PlayerCommand.kt b/src/main/kotlin/commands/player/PlayerCommand.kt index 1bacdf1..30fac61 100644 --- a/src/main/kotlin/commands/player/PlayerCommand.kt +++ b/src/main/kotlin/commands/player/PlayerCommand.kt @@ -1,5 +1,7 @@ package es.wokis.commands.player +import dev.kord.common.Locale +import dev.kord.common.entity.Snowflake import dev.kord.core.behavior.edit import dev.kord.core.behavior.interaction.response.DeferredPublicMessageInteractionResponseBehavior import dev.kord.core.behavior.interaction.response.respond @@ -7,8 +9,6 @@ import dev.kord.core.entity.interaction.ButtonInteraction import dev.kord.core.entity.interaction.ChatInputCommandInteraction import dev.kord.core.entity.interaction.ComponentInteraction import dev.kord.rest.builder.interaction.GlobalMultiApplicationCommandBuilder -import dev.kord.common.Locale -import dev.kord.common.entity.Snowflake import es.wokis.commands.Command import es.wokis.commands.CommandName import es.wokis.commands.Component @@ -32,7 +32,9 @@ class PlayerCommand( commandBuilder.apply { input( name = CommandName.Player.commandName, - description = localizationService.getLocalizations(LocalizationKeys.PLAYER_COMMAND_DESCRIPTION).values.first() + description = localizationService.getLocalizations( + LocalizationKeys.PLAYER_COMMAND_DESCRIPTION + ).values.first() ) { descriptionLocalizations = localizationService.getLocalizations(LocalizationKeys.PLAYER_COMMAND_DESCRIPTION) } diff --git a/src/main/kotlin/commands/player/PlayerCommons.kt b/src/main/kotlin/commands/player/PlayerCommons.kt index dd49eb4..e03734c 100644 --- a/src/main/kotlin/commands/player/PlayerCommons.kt +++ b/src/main/kotlin/commands/player/PlayerCommons.kt @@ -19,7 +19,12 @@ import kotlin.time.DurationUnit import kotlin.time.toDuration private const val ENABLE_PLAYBACK_POSITION = false +private const val PLAYER_EMBED_COLOR = 0x01B05B +private const val PLAYBACK_BAR_LENGTH = 9 +private const val QUEUE_DISPLAY_LIMIT = 3 +// TODO: Refactor to reduce method length (issue: #detekt-suppress) +@Suppress("LongMethod", "ForbiddenComment") suspend fun MessageBuilder.createPlayerEmbed( guildId: Snowflake?, discordLocale: Locale?, @@ -39,27 +44,42 @@ suspend fun MessageBuilder.createPlayerEmbed( thumbnail { url = currentTrack?.customFavicon ?: currentTrack?.audioTrack?.info?.artworkUrl.orEmpty() } - color = Color(0x01B05B) + color = Color(PLAYER_EMBED_COLOR) currentTrack?.let { val duration = it.audioTrack.duration.toDisplayDuration() val currentSeek = it.audioTrack.position.toDisplayDuration() field { - name = localizationService.getString(key = LocalizationKeys.PLAYER_CURRENT_TRACK, guildId = guildId, discordLocale = discordLocale) + name = localizationService.getString( + key = LocalizationKeys.PLAYER_CURRENT_TRACK, + guildId = guildId, + discordLocale = discordLocale + ) value = it.getTrackName() } if (ENABLE_PLAYBACK_POSITION) { - // TODO: Take a look in the future to solve discord update request error or delete it field { - name = localizationService.getString(key = LocalizationKeys.PLAYER_PLAYBACK_POSITION, guildId = guildId, discordLocale = discordLocale) + name = localizationService.getString( + key = LocalizationKeys.PLAYER_PLAYBACK_POSITION, + guildId = guildId, + discordLocale = discordLocale + ) value = "`$currentSeek ${generatePlayerPosition(it.audioTrack.position, it.audioTrack.duration)} $duration`" } } field { - name = localizationService.getString(key = LocalizationKeys.PLAYER_TRACK_DURATION, guildId = guildId, discordLocale = discordLocale) + name = localizationService.getString( + key = LocalizationKeys.PLAYER_TRACK_DURATION, + guildId = guildId, + discordLocale = discordLocale + ) value = duration.takeUnless { currentTrack.audioTrack.duration == DURATION_MS_UNKNOWN } ?: localizationService.getString( - key = if (currentTrack.audioTrack.info.isStream) LocalizationKeys.PLAYER_TRACK_DURATION_STREAM else LocalizationKeys.PLAYER_TRACK_DURATION_UNKNOWN, + key = if (currentTrack.audioTrack.info.isStream) { + LocalizationKeys.PLAYER_TRACK_DURATION_STREAM + } else { + LocalizationKeys.PLAYER_TRACK_DURATION_UNKNOWN + }, guildId = guildId, discordLocale = discordLocale ) @@ -67,11 +87,19 @@ suspend fun MessageBuilder.createPlayerEmbed( } if (queue.isNotEmpty()) { field { - name = localizationService.getString(key = LocalizationKeys.PLAYER_SERVER_QUEUE, guildId = guildId, discordLocale = discordLocale) + name = localizationService.getString( + key = LocalizationKeys.PLAYER_SERVER_QUEUE, + guildId = guildId, + discordLocale = discordLocale + ) value = queue.getDisplayQueue(localizationService, guildId, discordLocale) } } else if (currentTrack == null) { - description = localizationService.getString(key = LocalizationKeys.PLAYER_SERVER_QUEUE_EMPTY, guildId = guildId, discordLocale = discordLocale) + description = localizationService.getString( + key = LocalizationKeys.PLAYER_SERVER_QUEUE_EMPTY, + guildId = guildId, + discordLocale = discordLocale + ) } } components = if (queue.isNotEmpty() || currentTrack != null) { @@ -92,72 +120,124 @@ private fun TrackBO.getTrackName() = customName?.let { private fun generatePlayerPosition(currentSeek: Long, maxDuration: Long): String { val safeMaxDuration = maxDuration.takeUnless { it == 0L } ?: 1 - val position = ((currentSeek / 1000f) / (safeMaxDuration / 1000f) * 9).toInt() + 1 - val playerString = "─────────".split("").toMutableList().apply { + val position = ((currentSeek / 1000f) / (safeMaxDuration / 1000f) * PLAYBACK_BAR_LENGTH).toInt() + 1 + val playerString = "─".repeat(PLAYBACK_BAR_LENGTH).split("").toMutableList().apply { add(position, "●") }.joinToString(separator = "") return playerString } -private suspend fun createPlayerComponents(localizationService: LocalizationService, guildId: Snowflake?, discordLocale: Locale?, isPaused: Boolean): MutableList = - mutableListOf( - ActionRowBuilder().apply { - if (isPaused) { - interactionButton( - style = ButtonStyle.Secondary, - customId = ComponentsEnum.PLAYER_RESUME.customId - ) { - label = localizationService.getString(key = LocalizationKeys.PLAYER_RESUME, guildId = guildId, discordLocale = discordLocale) - emoji = DiscordPartialEmoji(name = "▶️") - } - } else { - interactionButton( - style = ButtonStyle.Secondary, - customId = ComponentsEnum.PLAYER_PAUSE.customId - ) { - label = localizationService.getString(key = LocalizationKeys.PLAYER_PAUSE, guildId = guildId, discordLocale = discordLocale) - emoji = DiscordPartialEmoji(name = "⏸") - } - } - interactionButton( - style = ButtonStyle.Secondary, - customId = ComponentsEnum.PLAYER_SKIP.customId - ) { - label = localizationService.getString(key = LocalizationKeys.PLAYER_SKIP, guildId = guildId, discordLocale = discordLocale) - emoji = DiscordPartialEmoji(name = "⏭") - } +// TODO: Refactor to reduce method length (issue: #detekt-suppress) +@Suppress("LongMethod", "ForbiddenComment") +private suspend fun createPlayerComponents( + localizationService: LocalizationService, + guildId: Snowflake?, + discordLocale: Locale?, + isPaused: Boolean +): MutableList = mutableListOf( + ActionRowBuilder().apply { + if (isPaused) { interactionButton( style = ButtonStyle.Secondary, - customId = ComponentsEnum.PLAYER_SHUFFLE.customId + customId = ComponentsEnum.PLAYER_RESUME.customId ) { - label = localizationService.getString(key = LocalizationKeys.PLAYER_SHUFFLE, guildId = guildId, discordLocale = discordLocale) - emoji = DiscordPartialEmoji(name = "\uD83D\uDD00") + label = localizationService.getString( + key = LocalizationKeys.PLAYER_RESUME, + guildId = guildId, + discordLocale = discordLocale + ) + emoji = DiscordPartialEmoji(name = "▶️") } + } else { interactionButton( style = ButtonStyle.Secondary, - customId = ComponentsEnum.PLAYER_RECONNECT.customId + customId = ComponentsEnum.PLAYER_PAUSE.customId ) { - label = localizationService.getString(key = LocalizationKeys.PLAYER_RECONNECT, guildId = guildId, discordLocale = discordLocale) - emoji = DiscordPartialEmoji(name = "🔄") - } - interactionButton( - style = ButtonStyle.Danger, - customId = ComponentsEnum.PLAYER_DISCONNECT.customId - ) { - label = localizationService.getString(key = LocalizationKeys.PLAYER_DISCONNECT, guildId = guildId, discordLocale = discordLocale) + label = localizationService.getString( + key = LocalizationKeys.PLAYER_PAUSE, + guildId = guildId, + discordLocale = discordLocale + ) + emoji = DiscordPartialEmoji(name = "⏸") } } - ) + interactionButton( + style = ButtonStyle.Secondary, + customId = ComponentsEnum.PLAYER_SKIP.customId + ) { + label = localizationService.getString( + key = LocalizationKeys.PLAYER_SKIP, + guildId = guildId, + discordLocale = discordLocale + ) + emoji = DiscordPartialEmoji(name = "⏭") + } + interactionButton( + style = ButtonStyle.Secondary, + customId = ComponentsEnum.PLAYER_SHUFFLE.customId + ) { + label = localizationService.getString( + key = LocalizationKeys.PLAYER_SHUFFLE, + guildId = guildId, + discordLocale = discordLocale + ) + emoji = DiscordPartialEmoji(name = "\uD83D\uDD00") + } + interactionButton( + style = ButtonStyle.Secondary, + customId = ComponentsEnum.PLAYER_RECONNECT.customId + ) { + label = localizationService.getString( + key = LocalizationKeys.PLAYER_RECONNECT, + guildId = guildId, + discordLocale = discordLocale + ) + emoji = DiscordPartialEmoji(name = "🔄") + } + interactionButton( + style = ButtonStyle.Danger, + customId = ComponentsEnum.PLAYER_DISCONNECT.customId + ) { + label = localizationService.getString( + key = LocalizationKeys.PLAYER_DISCONNECT, + guildId = guildId, + discordLocale = discordLocale + ) + } + } +) -private suspend fun List.getDisplayQueue(localizationService: LocalizationService, guildId: Snowflake?, discordLocale: Locale?): String { +private suspend fun List.getDisplayQueue( + localizationService: LocalizationService, + guildId: Snowflake?, + discordLocale: Locale? +): String { val firstTrack = getOrNull(0) val secondTrack = getOrNull(1) val thirdTrack = getOrNull(2) - val queueRemaining = (size - 3).takeIf { it > 0 } - val duration = filterNot { it.audioTrack.info.isStream || it.audioTrack.duration == DURATION_MS_UNKNOWN }.sumOf { it.audioTrack.duration }.toDisplayDuration() + val queueRemaining = (size - QUEUE_DISPLAY_LIMIT).takeIf { it > 0 } + val duration = filterNot { + it.audioTrack.info.isStream || it.audioTrack.duration == DURATION_MS_UNKNOWN + }.sumOf { it.audioTrack.duration }.toDisplayDuration() return firstTrack?.getDisplayNameAndDuration(localizationService, guildId, discordLocale) - ?.plus(secondTrack?.let { "\n${it.getDisplayNameAndDuration(localizationService, guildId, discordLocale)}" }.orEmpty()) - ?.plus(thirdTrack?.let { "\n${it.getDisplayNameAndDuration(localizationService, guildId, discordLocale)}" }.orEmpty()) + ?.plus( + secondTrack?.let { + "\n${it.getDisplayNameAndDuration( + localizationService, + guildId, + discordLocale + )}" + }.orEmpty() + ) + ?.plus( + thirdTrack?.let { + "\n${it.getDisplayNameAndDuration( + localizationService, + guildId, + discordLocale + )}" + }.orEmpty() + ) ?.plus( queueRemaining?.let { "\n".plus( @@ -182,7 +262,11 @@ private suspend fun List.getDisplayQueue(localizationService: Localizat ).orEmpty() } -private suspend fun TrackBO.getDisplayNameAndDuration(localizationService: LocalizationService, guildId: Snowflake?, discordLocale: Locale?): String { +private suspend fun TrackBO.getDisplayNameAndDuration( + localizationService: LocalizationService, + guildId: Snowflake?, + discordLocale: Locale? +): String { val displayDuration = if (audioTrack.duration != DURATION_MS_UNKNOWN) { audioTrack.duration.toDisplayDuration() } else { @@ -191,6 +275,8 @@ private suspend fun TrackBO.getDisplayNameAndDuration(localizationService: Local return "${getDisplayTrackName()} ($displayDuration)" } -private fun Long.toDisplayDuration() = toDuration(DurationUnit.MILLISECONDS).toComponents { hours, minutes, seconds, _ -> +private fun Long.toDisplayDuration() = toDuration( + DurationUnit.MILLISECONDS +).toComponents { hours, minutes, seconds, _ -> "%02d:%02d:%02d".format(hours, minutes, seconds) } diff --git a/src/main/kotlin/commands/queue/QueueCommand.kt b/src/main/kotlin/commands/queue/QueueCommand.kt index e68f56a..b6198c8 100644 --- a/src/main/kotlin/commands/queue/QueueCommand.kt +++ b/src/main/kotlin/commands/queue/QueueCommand.kt @@ -1,6 +1,7 @@ package es.wokis.commands.queue -import com.sedmelluq.discord.lavaplayer.track.AudioTrack +import dev.kord.common.Locale +import dev.kord.common.entity.Snowflake import dev.kord.core.behavior.edit import dev.kord.core.behavior.interaction.response.DeferredPublicMessageInteractionResponseBehavior import dev.kord.core.behavior.interaction.response.respond @@ -9,8 +10,6 @@ import dev.kord.core.entity.interaction.ChatInputCommandInteraction import dev.kord.core.entity.interaction.ComponentInteraction import dev.kord.rest.builder.interaction.GlobalMultiApplicationCommandBuilder import dev.kord.rest.builder.message.EmbedBuilder -import dev.kord.common.Locale -import dev.kord.common.entity.Snowflake import es.wokis.commands.Command import es.wokis.commands.CommandName import es.wokis.commands.Component @@ -30,7 +29,9 @@ class QueueCommand( commandBuilder.apply { input( name = CommandName.Queue.commandName, - description = localizationService.getLocalizations(LocalizationKeys.QUEUE_COMMAND_DESCRIPTION).values.first() + description = localizationService.getLocalizations( + LocalizationKeys.QUEUE_COMMAND_DESCRIPTION + ).values.first() ) { descriptionLocalizations = localizationService.getLocalizations(LocalizationKeys.QUEUE_COMMAND_DESCRIPTION) } diff --git a/src/main/kotlin/commands/radio/RadioGroupCommand.kt b/src/main/kotlin/commands/radio/RadioGroupCommand.kt index 5905a9f..44c1f95 100644 --- a/src/main/kotlin/commands/radio/RadioGroupCommand.kt +++ b/src/main/kotlin/commands/radio/RadioGroupCommand.kt @@ -33,7 +33,10 @@ class RadioGroupCommand( ) : GroupCommand, Component, Autocomplete { override suspend fun onRegisterCommand(kord: Kord) { - kord.createGlobalChatInputCommand(CommandName.Radio.commandName, localizationService.getLocalizations(LocalizationKeys.RADIO_COMMAND_DESCRIPTION).values.first()) { + kord.createGlobalChatInputCommand( + CommandName.Radio.commandName, + localizationService.getLocalizations(LocalizationKeys.RADIO_COMMAND_DESCRIPTION).values.first() + ) { descriptionLocalizations = localizationService.getLocalizations(LocalizationKeys.RADIO_COMMAND_DESCRIPTION) radioPlayCommand.onRegisterCommand(this) radioListCommand.onRegisterCommand(this) @@ -64,12 +67,18 @@ class RadioGroupCommand( } override suspend fun onInteract(interaction: ComponentInteraction) { - val customId = (interaction as? ButtonInteraction)?.component?.customId?.split(CUSTOM_COMPONENT_SEPARATOR)?.firstOrNull() + val customId = (interaction as? ButtonInteraction)?.component?.customId?.split( + CUSTOM_COMPONENT_SEPARATOR + )?.firstOrNull() when (customId) { - ComponentsEnum.RADIO_LIST_NEXT.customId, ComponentsEnum.RADIO_LIST_PREVIOUS.customId -> radioListCommand.onInteract(interaction) - ComponentsEnum.RADIO_SEARCH_NAME_NEXT.customId, ComponentsEnum.RADIO_SEARCH_NAME_PREVIOUS.customId -> radioSearchGroupCommand.onInteract(interaction) - ComponentsEnum.RADIO_SEARCH_COUNTRY_CODE_NEXT.customId, ComponentsEnum.RADIO_SEARCH_COUNTRY_CODE_PREVIOUS.customId -> radioSearchGroupCommand.onInteract(interaction) + ComponentsEnum.RADIO_LIST_NEXT.customId, + ComponentsEnum.RADIO_LIST_PREVIOUS.customId -> radioListCommand.onInteract(interaction) + ComponentsEnum.RADIO_SEARCH_NAME_NEXT.customId, + ComponentsEnum.RADIO_SEARCH_NAME_PREVIOUS.customId -> radioSearchGroupCommand.onInteract(interaction) + ComponentsEnum.RADIO_SEARCH_COUNTRY_CODE_NEXT.customId, + ComponentsEnum.RADIO_SEARCH_COUNTRY_CODE_PREVIOUS.customId -> + radioSearchGroupCommand.onInteract(interaction) } } diff --git a/src/main/kotlin/commands/radio/RadioUtils.kt b/src/main/kotlin/commands/radio/RadioUtils.kt index 6ab4e23..d8aaa1f 100644 --- a/src/main/kotlin/commands/radio/RadioUtils.kt +++ b/src/main/kotlin/commands/radio/RadioUtils.kt @@ -1,7 +1,5 @@ package es.wokis.commands.radio -import dev.kord.common.Locale -import dev.kord.common.entity.Snowflake import dev.kord.core.behavior.edit import dev.kord.core.behavior.interaction.response.DeferredPublicMessageInteractionResponseBehavior import dev.kord.core.behavior.interaction.response.respond @@ -17,9 +15,10 @@ import es.wokis.services.localization.LocalizationService import es.wokis.utils.takeAtMost private const val RADIO_LIST_COLUMNS = 3 +private const val MAX_RADIO_NAME_LENGTH = 20 fun List.chunked(columns: Int): List = map { - (if (it.radioName.contains(Regex("^[#*-]"))) "\\" else "").plus(it.radioName.takeAtMost(20)) + (if (it.radioName.contains(Regex("^[#*-]"))) "\\" else "").plus(it.radioName.takeAtMost(MAX_RADIO_NAME_LENGTH)) }.chunked((size / columns).coerceAtLeast(1)).map { it.joinToString(separator = "$BLANK_SPACE$BLANK_SPACE\n") } @@ -42,7 +41,11 @@ suspend fun onExecuteRadioListCommand( discordLocale = discordLocale, localizationService = localizationService, title = localizationService.getString(LocalizationKeys.RADIO_LIST_EMBED_TITLE, guildId, discordLocale), - description = localizationService.getString(LocalizationKeys.RADIO_LIST_EMBED_DESCRIPTION, guildId, discordLocale), + description = localizationService.getString( + LocalizationKeys.RADIO_LIST_EMBED_DESCRIPTION, + guildId, + discordLocale + ), currentPage = 1, currentPageContent = radioPageContent, columns = RADIO_LIST_COLUMNS, @@ -77,7 +80,11 @@ suspend fun onInteractRadioListCommand( discordLocale = discordLocale, localizationService = localizationService, title = localizationService.getString(LocalizationKeys.RADIO_LIST_EMBED_TITLE, guildId, discordLocale), - description = localizationService.getString(LocalizationKeys.RADIO_LIST_EMBED_DESCRIPTION, guildId, discordLocale), + description = localizationService.getString( + LocalizationKeys.RADIO_LIST_EMBED_DESCRIPTION, + guildId, + discordLocale + ), currentPage = currentPage, currentPageContent = radioPageContent, columns = RADIO_LIST_COLUMNS, diff --git a/src/main/kotlin/commands/radio/subcommands/countrycodes/RadioCountryCodesCommand.kt b/src/main/kotlin/commands/radio/subcommands/countrycodes/RadioCountryCodesCommand.kt index d9d5384..524e886 100644 --- a/src/main/kotlin/commands/radio/subcommands/countrycodes/RadioCountryCodesCommand.kt +++ b/src/main/kotlin/commands/radio/subcommands/countrycodes/RadioCountryCodesCommand.kt @@ -6,7 +6,6 @@ import dev.kord.core.behavior.interaction.response.respond import dev.kord.core.entity.interaction.ChatInputCommandInteraction import dev.kord.rest.builder.interaction.GlobalChatInputCreateBuilder import dev.kord.rest.builder.interaction.subCommand -import dev.kord.rest.builder.message.EmbedBuilder import dev.kord.rest.builder.message.embed import es.wokis.commands.CommandName import es.wokis.commands.SubCommand @@ -19,12 +18,14 @@ import es.wokis.services.radio.RadioService private const val MAX_FIELD_LENGTH = 1000 // Stay under 1024 limit private const val MAX_FIELDS = 25 // Discord embed limit private const val ITEMS_PER_LINE = 8 +private const val EMBED_COLOR = 0x01B05B +private const val FLAG_EMOJI_OFFSET = 0x1F1E6 private fun getFlagEmoji(countryCode: String): String = if (countryCode == "UNK") { "❓" } else { countryCode.uppercase().map { char -> - Character.toChars(0x1F1E6 + (char - 'A')).joinToString("") + Character.toChars(FLAG_EMOJI_OFFSET + (char - 'A')).joinToString("") }.joinToString("") } @@ -75,7 +76,12 @@ class RadioCountryCodesCommand( override suspend fun onRegisterCommand(builder: GlobalChatInputCreateBuilder) { builder.apply { - subCommand(CommandName.Radio.CountryCodes.commandName, localizationService.getLocalizations(LocalizationKeys.RADIO_COUNTRYCODES_COMMAND_DESCRIPTION).values.first()) { + subCommand( + CommandName.Radio.CountryCodes.commandName, + localizationService.getLocalizations( + LocalizationKeys.RADIO_COUNTRYCODES_COMMAND_DESCRIPTION + ).values.first() + ) { descriptionLocalizations = localizationService.getLocalizations(LocalizationKeys.RADIO_COUNTRYCODES_COMMAND_DESCRIPTION) } } @@ -112,7 +118,7 @@ class RadioCountryCodesCommand( guildId = guildId, discordLocale = discordLocale ) - color = Color(0x01B05B) + color = Color(EMBED_COLOR) fieldGroups.forEach { fieldContent -> field { diff --git a/src/main/kotlin/commands/radio/subcommands/list/RadioListCommand.kt b/src/main/kotlin/commands/radio/subcommands/list/RadioListCommand.kt index 4173328..628868c 100644 --- a/src/main/kotlin/commands/radio/subcommands/list/RadioListCommand.kt +++ b/src/main/kotlin/commands/radio/subcommands/list/RadioListCommand.kt @@ -22,7 +22,10 @@ class RadioListCommand( override suspend fun onRegisterCommand(builder: GlobalChatInputCreateBuilder) { builder.apply { - subCommand(CommandName.Radio.List.commandName, localizationService.getLocalizations(LocalizationKeys.RADIO_LIST_COMMAND_DESCRIPTION).values.first()) { + subCommand( + CommandName.Radio.List.commandName, + localizationService.getLocalizations(LocalizationKeys.RADIO_LIST_COMMAND_DESCRIPTION).values.first() + ) { descriptionLocalizations = localizationService.getLocalizations(LocalizationKeys.RADIO_LIST_COMMAND_DESCRIPTION) } } diff --git a/src/main/kotlin/commands/radio/subcommands/play/RadioPlayCommand.kt b/src/main/kotlin/commands/radio/subcommands/play/RadioPlayCommand.kt index 6127f1e..66e1a87 100644 --- a/src/main/kotlin/commands/radio/subcommands/play/RadioPlayCommand.kt +++ b/src/main/kotlin/commands/radio/subcommands/play/RadioPlayCommand.kt @@ -32,9 +32,15 @@ class RadioPlayCommand( override suspend fun onRegisterCommand(builder: GlobalChatInputCreateBuilder) { builder.apply { - subCommand(CommandName.Radio.Play.commandName, localizationService.getLocalizations(LocalizationKeys.RADIO_PLAY_COMMAND_DESCRIPTION).values.first()) { + subCommand( + CommandName.Radio.Play.commandName, + localizationService.getLocalizations(LocalizationKeys.RADIO_PLAY_COMMAND_DESCRIPTION).values.first() + ) { descriptionLocalizations = localizationService.getLocalizations(LocalizationKeys.RADIO_PLAY_COMMAND_DESCRIPTION) - string(RADIO_INPUT_NAME, localizationService.getLocalizations(LocalizationKeys.RADIO_PLAY_INPUT_DESCRIPTION).values.first()) { + string( + RADIO_INPUT_NAME, + localizationService.getLocalizations(LocalizationKeys.RADIO_PLAY_INPUT_DESCRIPTION).values.first() + ) { descriptionLocalizations = localizationService.getLocalizations(LocalizationKeys.RADIO_PLAY_INPUT_DESCRIPTION) required = true autocomplete = true diff --git a/src/main/kotlin/commands/radio/subcommands/random/RadioRandomCommand.kt b/src/main/kotlin/commands/radio/subcommands/random/RadioRandomCommand.kt index 113dd66..491ef34 100644 --- a/src/main/kotlin/commands/radio/subcommands/random/RadioRandomCommand.kt +++ b/src/main/kotlin/commands/radio/subcommands/random/RadioRandomCommand.kt @@ -7,7 +7,6 @@ import dev.kord.rest.builder.interaction.GlobalChatInputCreateBuilder import dev.kord.rest.builder.interaction.subCommand import es.wokis.commands.CommandName import es.wokis.commands.SubCommand -import es.wokis.data.radio.RadioDTO import es.wokis.data.response.RemoteResponse import es.wokis.localization.LocalizationKeys import es.wokis.services.localization.LocalizationService @@ -22,7 +21,10 @@ class RadioRandomCommand( override suspend fun onRegisterCommand(builder: GlobalChatInputCreateBuilder) { builder.apply { - subCommand(CommandName.Radio.Random.commandName, localizationService.getLocalizations(LocalizationKeys.RADIO_RANDOM_COMMAND_DESCRIPTION).values.first()) { + subCommand( + CommandName.Radio.Random.commandName, + localizationService.getLocalizations(LocalizationKeys.RADIO_RANDOM_COMMAND_DESCRIPTION).values.first() + ) { descriptionLocalizations = localizationService.getLocalizations(LocalizationKeys.RADIO_RANDOM_COMMAND_DESCRIPTION) } } diff --git a/src/main/kotlin/commands/radio/subcommands/search/RadioSearchCountryCodeCommand.kt b/src/main/kotlin/commands/radio/subcommands/search/RadioSearchCountryCodeCommand.kt index 5eee712..c8ef588 100644 --- a/src/main/kotlin/commands/radio/subcommands/search/RadioSearchCountryCodeCommand.kt +++ b/src/main/kotlin/commands/radio/subcommands/search/RadioSearchCountryCodeCommand.kt @@ -4,7 +4,6 @@ import dev.kord.common.entity.Choice import dev.kord.common.entity.optional.Optional import dev.kord.core.behavior.interaction.response.DeferredPublicMessageInteractionResponseBehavior import dev.kord.core.behavior.interaction.suggest -import dev.kord.core.entity.component.ButtonComponent import dev.kord.core.entity.interaction.AutoCompleteInteraction import dev.kord.core.entity.interaction.ButtonInteraction import dev.kord.core.entity.interaction.ChatInputCommandInteraction @@ -20,9 +19,10 @@ import es.wokis.constants.CUSTOM_COMPONENT_SEPARATOR import es.wokis.data.response.RemoteResponse import es.wokis.services.localization.LocalizationService import es.wokis.services.radio.RadioService -import es.wokis.utils.takeAtMost import es.wokis.utils.takeIfNotEmpty +private const val AUTOCOMPLETE_SUGGESTION_LIMIT = 25 + class RadioSearchCountryCodeCommand( private val radioService: RadioService, private val localizationService: LocalizationService @@ -72,7 +72,7 @@ class RadioSearchCountryCodeCommand( val countryCodes = (radioService.getCountryCodes() as? RemoteResponse.Success)?.data?.countryCodes.orEmpty() val filteredCodes = countryCodes.filter { code -> code.contains(input, ignoreCase = true) - }.take(25) + }.take(AUTOCOMPLETE_SUGGESTION_LIMIT) val choices = filteredCodes.map { code -> Choice.StringChoice(code, Optional.Missing(), code) } diff --git a/src/main/kotlin/commands/radio/subcommands/search/RadioSearchGroupCommand.kt b/src/main/kotlin/commands/radio/subcommands/search/RadioSearchGroupCommand.kt index 851a76c..dd9b2ca 100644 --- a/src/main/kotlin/commands/radio/subcommands/search/RadioSearchGroupCommand.kt +++ b/src/main/kotlin/commands/radio/subcommands/search/RadioSearchGroupCommand.kt @@ -26,19 +26,45 @@ class RadioSearchGroupCommand( override suspend fun onRegisterCommand(builder: GlobalChatInputCreateBuilder) { builder.apply { - group(CommandName.Radio.Search.commandName, localizationService.getLocalizations(LocalizationKeys.RADIO_SEARCH_COMMAND_DESCRIPTION).values.first()) { + group( + CommandName.Radio.Search.commandName, + localizationService.getLocalizations(LocalizationKeys.RADIO_SEARCH_COMMAND_DESCRIPTION).values.first() + ) { descriptionLocalizations = localizationService.getLocalizations(LocalizationKeys.RADIO_SEARCH_COMMAND_DESCRIPTION) - subCommand(CommandName.Radio.Search.Name.commandName, localizationService.getLocalizations(LocalizationKeys.RADIO_SEARCH_NAME_COMMAND_DESCRIPTION).values.first()) { + subCommand( + CommandName.Radio.Search.Name.commandName, + localizationService.getLocalizations( + LocalizationKeys.RADIO_SEARCH_NAME_COMMAND_DESCRIPTION + ).values.first() + ) { descriptionLocalizations = localizationService.getLocalizations(LocalizationKeys.RADIO_SEARCH_NAME_COMMAND_DESCRIPTION) - string("name", localizationService.getLocalizations(LocalizationKeys.RADIO_SEARCH_NAME_INPUT_DESCRIPTION).values.first()) { - descriptionLocalizations = localizationService.getLocalizations(LocalizationKeys.RADIO_SEARCH_NAME_INPUT_DESCRIPTION) + string( + "name", + localizationService.getLocalizations( + LocalizationKeys.RADIO_SEARCH_NAME_INPUT_DESCRIPTION + ).values.first() + ) { + descriptionLocalizations = + localizationService.getLocalizations(LocalizationKeys.RADIO_SEARCH_NAME_INPUT_DESCRIPTION) required = true } } - subCommand(CommandName.Radio.Search.CountryCode.commandName, localizationService.getLocalizations(LocalizationKeys.RADIO_SEARCH_COUNTRYCODE_COMMAND_DESCRIPTION).values.first()) { - descriptionLocalizations = localizationService.getLocalizations(LocalizationKeys.RADIO_SEARCH_COUNTRYCODE_COMMAND_DESCRIPTION) - string("countrycode", localizationService.getLocalizations(LocalizationKeys.RADIO_SEARCH_COUNTRYCODE_INPUT_DESCRIPTION).values.first()) { - descriptionLocalizations = localizationService.getLocalizations(LocalizationKeys.RADIO_SEARCH_COUNTRYCODE_INPUT_DESCRIPTION) + subCommand( + CommandName.Radio.Search.CountryCode.commandName, + localizationService.getLocalizations( + LocalizationKeys.RADIO_SEARCH_COUNTRYCODE_COMMAND_DESCRIPTION + ).values.first() + ) { + descriptionLocalizations = + localizationService.getLocalizations(LocalizationKeys.RADIO_SEARCH_COUNTRYCODE_COMMAND_DESCRIPTION) + string( + "countrycode", + localizationService.getLocalizations( + LocalizationKeys.RADIO_SEARCH_COUNTRYCODE_INPUT_DESCRIPTION + ).values.first() + ) { + descriptionLocalizations = + localizationService.getLocalizations(LocalizationKeys.RADIO_SEARCH_COUNTRYCODE_INPUT_DESCRIPTION) required = true autocomplete = true } @@ -54,17 +80,24 @@ class RadioSearchGroupCommand( (interaction.command as? GroupCommand)?.name?.let { commandName -> when (commandName) { CommandName.Radio.Search.Name.commandName -> radioSearchNameCommand.onExecute(interaction, response) - CommandName.Radio.Search.CountryCode.commandName -> radioSearchCountryCodeCommand.onExecute(interaction, response) + CommandName.Radio.Search.CountryCode.commandName -> radioSearchCountryCodeCommand.onExecute( + interaction, + response + ) } } } override suspend fun onInteract(interaction: ComponentInteraction) { - val customId = (interaction as? ButtonInteraction)?.component?.customId?.split(CUSTOM_COMPONENT_SEPARATOR)?.firstOrNull() + val customId = (interaction as? ButtonInteraction)?.component?.customId?.split( + CUSTOM_COMPONENT_SEPARATOR + )?.firstOrNull() when (customId) { ComponentsEnum.RADIO_SEARCH_COUNTRY_CODE_NEXT.customId, - ComponentsEnum.RADIO_SEARCH_COUNTRY_CODE_PREVIOUS.customId -> radioSearchCountryCodeCommand.onInteract(interaction) + ComponentsEnum.RADIO_SEARCH_COUNTRY_CODE_PREVIOUS.customId -> radioSearchCountryCodeCommand.onInteract( + interaction + ) ComponentsEnum.RADIO_SEARCH_NAME_PREVIOUS.customId, ComponentsEnum.RADIO_SEARCH_COUNTRY_CODE_NEXT.customId -> radioSearchNameCommand.onInteract(interaction) @@ -75,4 +108,3 @@ class RadioSearchGroupCommand( radioSearchCountryCodeCommand.onAutoComplete(interaction) } } - diff --git a/src/main/kotlin/commands/reconnect/ReconnectCommand.kt b/src/main/kotlin/commands/reconnect/ReconnectCommand.kt index 5204cf6..5b65eff 100644 --- a/src/main/kotlin/commands/reconnect/ReconnectCommand.kt +++ b/src/main/kotlin/commands/reconnect/ReconnectCommand.kt @@ -17,7 +17,9 @@ class ReconnectCommand( override fun onRegisterCommand(commandBuilder: GlobalMultiApplicationCommandBuilder) { commandBuilder.input( name = CommandName.Reconnect.commandName, - description = localizationService.getLocalizations(LocalizationKeys.RECONNECT_COMMAND_DESCRIPTION).values.first() + description = localizationService.getLocalizations( + LocalizationKeys.RECONNECT_COMMAND_DESCRIPTION + ).values.first() ) { descriptionLocalizations = localizationService.getLocalizations(LocalizationKeys.RECONNECT_COMMAND_DESCRIPTION) } diff --git a/src/main/kotlin/commands/shuffle/ShuffleCommand.kt b/src/main/kotlin/commands/shuffle/ShuffleCommand.kt index 868fb20..04f1757 100644 --- a/src/main/kotlin/commands/shuffle/ShuffleCommand.kt +++ b/src/main/kotlin/commands/shuffle/ShuffleCommand.kt @@ -19,7 +19,9 @@ class ShuffleCommand( commandBuilder.apply { input( name = CommandName.Shuffle.commandName, - description = localizationService.getLocalizations(LocalizationKeys.SHUFFLE_COMMAND_DESCRIPTION).values.first() + description = localizationService.getLocalizations( + LocalizationKeys.SHUFFLE_COMMAND_DESCRIPTION + ).values.first() ) { descriptionLocalizations = localizationService.getLocalizations(LocalizationKeys.SHUFFLE_COMMAND_DESCRIPTION) } diff --git a/src/main/kotlin/commands/skip/SkipCommand.kt b/src/main/kotlin/commands/skip/SkipCommand.kt index c4a1aa9..2dff424 100644 --- a/src/main/kotlin/commands/skip/SkipCommand.kt +++ b/src/main/kotlin/commands/skip/SkipCommand.kt @@ -19,7 +19,9 @@ class SkipCommand( commandBuilder.apply { input( name = CommandName.Skip.commandName, - description = localizationService.getLocalizations(LocalizationKeys.SKIP_COMMAND_DESCRIPTION).values.first() + description = localizationService.getLocalizations( + LocalizationKeys.SKIP_COMMAND_DESCRIPTION + ).values.first() ) { descriptionLocalizations = localizationService.getLocalizations(LocalizationKeys.SKIP_COMMAND_DESCRIPTION) } diff --git a/src/main/kotlin/commands/sound/SoundCommand.kt b/src/main/kotlin/commands/sound/SoundCommand.kt index 32f5d34..e9a90ce 100644 --- a/src/main/kotlin/commands/sound/SoundCommand.kt +++ b/src/main/kotlin/commands/sound/SoundCommand.kt @@ -22,6 +22,8 @@ import java.io.File private const val ARGUMENT_NAME = "name" private const val AUDIO_FOLDER = "./audio/" private const val AUDIO_EXTENSION = ".mp3" +private const val AUTOCOMPLETE_SOUND_LIMIT = 25 +private const val SOUND_NAME_MAX_LENGTH = 100 class SoundCommand( private val guildQueueService: GuildQueueService, @@ -32,12 +34,16 @@ class SoundCommand( commandBuilder.apply { input( name = CommandName.Sound.commandName, - description = localizationService.getLocalizations(LocalizationKeys.SOUND_COMMAND_DESCRIPTION).values.first() + description = localizationService.getLocalizations( + LocalizationKeys.SOUND_COMMAND_DESCRIPTION + ).values.first() ) { descriptionLocalizations = localizationService.getLocalizations(LocalizationKeys.SOUND_COMMAND_DESCRIPTION) string( name = ARGUMENT_NAME, - description = localizationService.getLocalizations(LocalizationKeys.SOUND_COMMAND_INPUT_DESCRIPTION).values.first() + description = localizationService.getLocalizations( + LocalizationKeys.SOUND_COMMAND_INPUT_DESCRIPTION + ).values.first() ) { descriptionLocalizations = localizationService.getLocalizations(LocalizationKeys.SOUND_COMMAND_INPUT_DESCRIPTION) required = true @@ -108,18 +114,18 @@ class SoundCommand( val containsMatches = allFiles .filter { file -> !file.nameWithoutExtension.startsWith(input, ignoreCase = true) && - file.nameWithoutExtension.contains(input, ignoreCase = true) + file.nameWithoutExtension.contains(input, ignoreCase = true) } .sortedBy { it.nameWithoutExtension.lowercase() } // Combine: startsWith first, then contains, up to 25 total val sounds = (startsWithMatches + containsMatches) - .take(25) + .take(AUTOCOMPLETE_SOUND_LIMIT) .map { file -> Choice.StringChoice( - name = file.nameWithoutExtension.take(100), + name = file.nameWithoutExtension.take(SOUND_NAME_MAX_LENGTH), nameLocalizations = Optional.Missing(), - value = file.nameWithoutExtension.take(100) + value = file.nameWithoutExtension.take(SOUND_NAME_MAX_LENGTH) ) } .toList() diff --git a/src/main/kotlin/commands/sounds/SoundsCommand.kt b/src/main/kotlin/commands/sounds/SoundsCommand.kt index c345c63..b39fd3b 100644 --- a/src/main/kotlin/commands/sounds/SoundsCommand.kt +++ b/src/main/kotlin/commands/sounds/SoundsCommand.kt @@ -1,5 +1,7 @@ package es.wokis.commands.sounds +import dev.kord.common.Locale +import dev.kord.common.entity.Snowflake import dev.kord.core.behavior.edit import dev.kord.core.behavior.interaction.response.DeferredPublicMessageInteractionResponseBehavior import dev.kord.core.behavior.interaction.response.respond @@ -8,8 +10,6 @@ import dev.kord.core.entity.interaction.ChatInputCommandInteraction import dev.kord.core.entity.interaction.ComponentInteraction import dev.kord.rest.builder.interaction.GlobalMultiApplicationCommandBuilder import dev.kord.rest.builder.message.EmbedBuilder -import dev.kord.common.Locale -import dev.kord.common.entity.Snowflake import es.wokis.commands.Command import es.wokis.commands.CommandName import es.wokis.commands.Component @@ -23,6 +23,7 @@ import java.io.File private const val SOUNDS_PATH = "./audio" private const val MAX_SOUNDS_PER_COLUMN = 50 +private const val SOUNDS_COLUMNS = 3 class SoundsCommand( private val localizationService: LocalizationService @@ -46,16 +47,19 @@ class SoundsCommand( val discordLocale = interaction.guildLocale val sounds: List = getSoundFilesSorted() val displaySounds = getDisplaySounds(sounds).sorted() - val title = localizationService.getString(key = LocalizationKeys.SOUNDS_EMBED_TITLE, guildId = guildId, discordLocale = discordLocale) + val title = localizationService.getString( + key = LocalizationKeys.SOUNDS_EMBED_TITLE, + guildId = guildId, + discordLocale = discordLocale + ) val description = localizationService.getStringFormat( key = LocalizationKeys.SOUNDS_EMBED_DESCRIPTION, guildId = guildId, discordLocale = discordLocale, arguments = arrayOf(sounds.size) ) - val columns = 3 - val currentPageContent = displaySounds.chunked(columns).firstOrNull() - val pageCount = displaySounds.size / columns + val currentPageContent = displaySounds.chunked(SOUNDS_COLUMNS).firstOrNull() + val pageCount = displaySounds.size / SOUNDS_COLUMNS response.respond { createPaginatedEmbedMessage( guildId = guildId, @@ -64,7 +68,7 @@ class SoundsCommand( title = title, description = description, currentPage = 1, - columns = columns, + columns = SOUNDS_COLUMNS, currentPageContent = currentPageContent, pageCount = pageCount, previousButtonCustomId = ComponentsEnum.SOUNDS_PREVIOUS.customId, @@ -80,12 +84,11 @@ class SoundsCommand( val updatePageBy = if (interactionCustomId == ComponentsEnum.QUEUE_PREVIOUS.customId) -1 else 1 val sounds: List = getSoundFilesSorted() val displayQueue = getDisplaySounds(sounds) - val columns = 3 - val pageCount = displayQueue.size / columns + val pageCount = displayQueue.size / SOUNDS_COLUMNS val currentPage = interaction.message.embeds.firstOrNull() ?.footer?.text?.split(" ")?.get(1)?.toIntOrNull()?.plus(updatePageBy) ?.takeUnless { it > pageCount } ?: 1 - val displaySoundsPage = displayQueue.chunked(columns).getOrNull(currentPage - 1) + val displaySoundsPage = displayQueue.chunked(SOUNDS_COLUMNS).getOrNull(currentPage - 1) updateQueueMessage( interaction = interaction, guildId = guildId, @@ -93,8 +96,7 @@ class SoundsCommand( currentPage = currentPage, soundsCount = sounds.size, displaySoundsPage = displaySoundsPage, - pageCount = pageCount, - columns = columns + pageCount = pageCount ) } @@ -109,7 +111,9 @@ class SoundsCommand( val soundName = sound.nameWithoutExtension val separator = "$BLANK_SPACE$BLANK_SPACE\n" val appendDisplayTrackName = separator.plus(soundName) - if (currentString.split(separator).size < MAX_SOUNDS_PER_COLUMN && currentString.length + appendDisplayTrackName.length <= EmbedBuilder.Field.Limits.value) { + val withinColumnLimit = currentString.split(separator).size < MAX_SOUNDS_PER_COLUMN + val withinLengthLimit = currentString.length + appendDisplayTrackName.length <= EmbedBuilder.Field.Limits.value + if (withinColumnLimit && withinLengthLimit) { currentString += appendDisplayTrackName } else { displaySounds.add(currentString) @@ -129,10 +133,13 @@ class SoundsCommand( currentPage: Int, soundsCount: Int, displaySoundsPage: List?, - pageCount: Int, - columns: Int + pageCount: Int ) { - val title = localizationService.getString(key = LocalizationKeys.SOUNDS_EMBED_TITLE, guildId = guildId, discordLocale = discordLocale) + val title = localizationService.getString( + key = LocalizationKeys.SOUNDS_EMBED_TITLE, + guildId = guildId, + discordLocale = discordLocale + ) val description = localizationService.getStringFormat( key = LocalizationKeys.SOUNDS_EMBED_DESCRIPTION, guildId = guildId, @@ -147,7 +154,7 @@ class SoundsCommand( title = title, description = description, currentPage = currentPage, - columns = columns, + columns = SOUNDS_COLUMNS, currentPageContent = displaySoundsPage, pageCount = pageCount, previousButtonCustomId = ComponentsEnum.SOUNDS_PREVIOUS.customId, diff --git a/src/main/kotlin/commands/tts/TTSCommand.kt b/src/main/kotlin/commands/tts/TTSCommand.kt index 5a1080e..0b9b860 100644 --- a/src/main/kotlin/commands/tts/TTSCommand.kt +++ b/src/main/kotlin/commands/tts/TTSCommand.kt @@ -24,12 +24,16 @@ class TTSCommand( commandBuilder.apply { input( name = CommandName.Tts.commandName, - description = localizationService.getLocalizations(LocalizationKeys.TTS_COMMAND_DESCRIPTION).values.first() + description = localizationService.getLocalizations( + LocalizationKeys.TTS_COMMAND_DESCRIPTION + ).values.first() ) { descriptionLocalizations = localizationService.getLocalizations(LocalizationKeys.TTS_COMMAND_DESCRIPTION) string( name = TTS_ARGUMENT_NAME, - description = localizationService.getLocalizations(LocalizationKeys.TTS_COMMAND_INPUT_DESCRIPTION).values.first() + description = localizationService.getLocalizations( + LocalizationKeys.TTS_COMMAND_INPUT_DESCRIPTION + ).values.first() ) { descriptionLocalizations = localizationService.getLocalizations(LocalizationKeys.TTS_COMMAND_INPUT_DESCRIPTION) required = true diff --git a/src/main/kotlin/data/locale/GuildLocale.kt b/src/main/kotlin/data/locale/GuildLocalesContainer.kt similarity index 100% rename from src/main/kotlin/data/locale/GuildLocale.kt rename to src/main/kotlin/data/locale/GuildLocalesContainer.kt diff --git a/src/main/kotlin/data/response/RemoteResponseExtensions.kt b/src/main/kotlin/data/response/RemoteResponseExtensions.kt index a6525e7..eb0dc6c 100644 --- a/src/main/kotlin/data/response/RemoteResponseExtensions.kt +++ b/src/main/kotlin/data/response/RemoteResponseExtensions.kt @@ -15,7 +15,7 @@ import es.wokis.exceptions.toException * @throws IllegalStateException if success but data is null */ fun RemoteResponse.getOrThrow(): T = when (this) { - is RemoteResponse.Success -> data ?: throw IllegalStateException("Success response but data is null") + is RemoteResponse.Success -> data ?: error("Success response but data is null") is RemoteResponse.Error -> throw error.toException() } diff --git a/src/main/kotlin/di/CommandModule.kt b/src/main/kotlin/di/CommandModule.kt index 5e7babd..11e8b23 100644 --- a/src/main/kotlin/di/CommandModule.kt +++ b/src/main/kotlin/di/CommandModule.kt @@ -1,8 +1,15 @@ package es.wokis.di -import es.wokis.commands.queue.QueueCommand import commands.play.PlayCommand +import es.wokis.commands.config.ConfigGetCommand +import es.wokis.commands.config.ConfigGroupCommand +import es.wokis.commands.config.ConfigReloadCommand +import es.wokis.commands.config.ConfigSetCommand +import es.wokis.commands.disconnect.DisconnectCommand +import es.wokis.commands.locale.LocaleCommand +import es.wokis.commands.next.NextCommand import es.wokis.commands.player.PlayerCommand +import es.wokis.commands.queue.QueueCommand import es.wokis.commands.radio.RadioGroupCommand import es.wokis.commands.radio.subcommands.countrycodes.RadioCountryCodesCommand import es.wokis.commands.radio.subcommands.list.RadioListCommand @@ -11,15 +18,12 @@ import es.wokis.commands.radio.subcommands.random.RadioRandomCommand import es.wokis.commands.radio.subcommands.search.RadioSearchCountryCodeCommand import es.wokis.commands.radio.subcommands.search.RadioSearchGroupCommand import es.wokis.commands.radio.subcommands.search.RadioSearchNameCommand +import es.wokis.commands.reconnect.ReconnectCommand import es.wokis.commands.shuffle.ShuffleCommand import es.wokis.commands.skip.SkipCommand import es.wokis.commands.sound.SoundCommand import es.wokis.commands.sounds.SoundsCommand -import es.wokis.commands.next.NextCommand -import es.wokis.commands.reconnect.ReconnectCommand -import es.wokis.commands.disconnect.DisconnectCommand import es.wokis.commands.tts.TTSCommand -import es.wokis.commands.locale.LocaleCommand import org.koin.core.module.dsl.factoryOf import org.koin.dsl.module @@ -43,5 +47,9 @@ val commandModule = module { factoryOf(::RadioSearchCountryCodeCommand) factoryOf(::RadioRandomCommand) factoryOf(::RadioCountryCodesCommand) + factoryOf(::ConfigGroupCommand) + factoryOf(::ConfigReloadCommand) + factoryOf(::ConfigSetCommand) + factoryOf(::ConfigGetCommand) factoryOf(::LocaleCommand) } diff --git a/src/main/kotlin/di/ServicesModule.kt b/src/main/kotlin/di/ServicesModule.kt index 19d0a17..acbe740 100644 --- a/src/main/kotlin/di/ServicesModule.kt +++ b/src/main/kotlin/di/ServicesModule.kt @@ -4,13 +4,14 @@ import es.wokis.dispatchers.AppDispatchers import es.wokis.dispatchers.AppDispatchersImpl import es.wokis.services.commands.CommandHandlerService import es.wokis.services.commands.CommandHandlerServiceImpl +import es.wokis.services.config.ConfigMigrationService import es.wokis.services.config.ConfigService import es.wokis.services.error.ErrorHandlerService -import es.wokis.services.localization.LocalizationService import es.wokis.services.lavaplayer.AudioPlayerManagerProvider +import es.wokis.services.localization.LocalizationService +import es.wokis.services.player.PlayerChannelService import es.wokis.services.processor.MessageProcessorService import es.wokis.services.queue.GuildQueueService -import es.wokis.services.player.PlayerChannelService import es.wokis.services.radio.RadioService import es.wokis.services.tts.TTSService import org.koin.core.module.dsl.bind @@ -19,6 +20,7 @@ import org.koin.core.module.dsl.singleOf import org.koin.dsl.module val servicesModule = module { + singleOf(::ConfigMigrationService) singleOf(::ConfigService) factoryOf(::MessageProcessorService) factoryOf(::AudioPlayerManagerProvider) diff --git a/src/main/kotlin/domain/GetFloweryVoicesUseCase.kt b/src/main/kotlin/domain/GetFloweryVoicesUseCase.kt index c84bb31..a73167d 100644 --- a/src/main/kotlin/domain/GetFloweryVoicesUseCase.kt +++ b/src/main/kotlin/domain/GetFloweryVoicesUseCase.kt @@ -14,6 +14,8 @@ class GetFloweryVoicesUseCase( override suspend fun invoke(): List = ErrorManagementWrapper.wrap { httpClient.get("https://api.flowery.pw/v1/tts/voices").body() }.let { response -> - (response as? RemoteResponse.Success)?.data?.voices?.filter { it.language.code.contains("es-") }?.map { it.id }.orEmpty() + (response as? RemoteResponse.Success)?.data?.voices?.filter { + it.language.code.contains("es-") + }?.map { it.id }.orEmpty() } } diff --git a/src/main/kotlin/exceptions/BotExceptions.kt b/src/main/kotlin/exceptions/BotException.kt similarity index 98% rename from src/main/kotlin/exceptions/BotExceptions.kt rename to src/main/kotlin/exceptions/BotException.kt index 0d8e1a1..2f976da 100644 --- a/src/main/kotlin/exceptions/BotExceptions.kt +++ b/src/main/kotlin/exceptions/BotException.kt @@ -8,6 +8,8 @@ import es.wokis.localization.LocalizationKeys * All custom exceptions must extend one of the sealed subclasses. * This allows for exhaustive when expressions and better type safety. */ +// TODO: Consider redesign to avoid vararg spreading (issue: #detekt-suppress) +@Suppress("SpreadOperator", "ForbiddenComment") sealed class BotException( message: String, cause: Throwable? = null diff --git a/src/main/kotlin/exceptions/ConfigValidationExceptions.kt b/src/main/kotlin/exceptions/ConfigValidationExceptions.kt index 503809d..87273b5 100644 --- a/src/main/kotlin/exceptions/ConfigValidationExceptions.kt +++ b/src/main/kotlin/exceptions/ConfigValidationExceptions.kt @@ -1,4 +1,6 @@ package es.wokis.exceptions class EmptyDiscordTokenException : IllegalArgumentException("Discord token shouldn't be empty") -class EmptyDeezerMasterDecryptionKeyException : IllegalArgumentException("Deezer is enabled but no master decryption key was provided") +class EmptyDeezerMasterDecryptionKeyException : IllegalArgumentException( + "Deezer is enabled but no master decryption key was provided" +) diff --git a/src/main/kotlin/localization/LocalizationKeys.kt b/src/main/kotlin/localization/LocalizationKeys.kt index 8c9d8c5..acc47bf 100644 --- a/src/main/kotlin/localization/LocalizationKeys.kt +++ b/src/main/kotlin/localization/LocalizationKeys.kt @@ -111,4 +111,19 @@ object LocalizationKeys { const val LOCALE_COMMAND_SUCCESS = "locale_command_success" const val LOCALE_COMMAND_RESET_SUCCESS = "locale_command_reset_success" const val LOCALE_COMMAND_INVALID_LOCALE = "locale_command_invalid_locale" + const val CONFIG_COMMAND_DESCRIPTION = "config_command_description" + const val CONFIG_RELOAD_COMMAND_DESCRIPTION = "config_reload_command_description" + const val CONFIG_RELOAD_SUCCESS = "config_reload_success" + const val CONFIG_SET_COMMAND_DESCRIPTION = "config_set_command_description" + const val CONFIG_SET_SECTION_DESCRIPTION = "config_set_section_description" + const val CONFIG_SET_KEY_DESCRIPTION = "config_set_key_description" + const val CONFIG_SET_VALUE_DESCRIPTION = "config_set_value_description" + const val CONFIG_SET_SUCCESS = "config_set_success" + const val CONFIG_GET_COMMAND_DESCRIPTION = "config_get_command_description" + const val CONFIG_GET_SECTION_DESCRIPTION = "config_get_section_description" + const val CONFIG_GET_SUCCESS = "config_get_success" + const val CONFIG_GET_DISPLAY = "config_get_display" + const val CONFIG_INVALID_SECTION = "config_invalid_section" + const val CONFIG_INVALID_KEY = "config_invalid_key" + const val CONFIG_CANNOT_MODIFY_TOKEN = "config_cannot_modify_token" } diff --git a/src/main/kotlin/services/commands/CommandHandlerService.kt b/src/main/kotlin/services/commands/CommandHandlerService.kt index 7a46161..1933f05 100644 --- a/src/main/kotlin/services/commands/CommandHandlerService.kt +++ b/src/main/kotlin/services/commands/CommandHandlerService.kt @@ -1,24 +1,25 @@ package es.wokis.services.commands +import commands.play.PlayCommand import dev.kord.common.Locale import dev.kord.common.entity.Snowflake +import dev.kord.core.Kord import dev.kord.core.behavior.interaction.response.DeferredPublicMessageInteractionResponseBehavior import dev.kord.core.behavior.interaction.response.respond +import dev.kord.core.entity.interaction.AutoCompleteInteraction import dev.kord.core.entity.interaction.ButtonInteraction import dev.kord.core.entity.interaction.ChatInputCommandInteraction import dev.kord.rest.builder.interaction.GlobalMultiApplicationCommandBuilder import es.wokis.commands.CommandName import es.wokis.commands.ComponentsEnum -import es.wokis.commands.queue.QueueCommand -import commands.play.PlayCommand -import dev.kord.core.Kord -import dev.kord.core.entity.interaction.AutoCompleteInteraction +import es.wokis.commands.config.ConfigGroupCommand +import es.wokis.commands.disconnect.DisconnectCommand +import es.wokis.commands.locale.LocaleCommand +import es.wokis.commands.next.NextCommand import es.wokis.commands.player.PlayerCommand +import es.wokis.commands.queue.QueueCommand import es.wokis.commands.radio.RadioGroupCommand -import es.wokis.commands.next.NextCommand -import es.wokis.commands.disconnect.DisconnectCommand import es.wokis.commands.reconnect.ReconnectCommand -import es.wokis.commands.locale.LocaleCommand import es.wokis.commands.shuffle.ShuffleCommand import es.wokis.commands.skip.SkipCommand import es.wokis.commands.sound.SoundCommand @@ -45,6 +46,8 @@ interface CommandHandlerService { suspend fun onAutocomplete(interaction: AutoCompleteInteraction) } +// TODO: Consider using a command registry pattern (issue: #detekt-suppress) +@Suppress("LongParameterList", "ForbiddenComment") class CommandHandlerServiceImpl( private val playCommand: PlayCommand, private val soundCommand: SoundCommand, @@ -59,6 +62,7 @@ class CommandHandlerServiceImpl( private val disconnectCommand: DisconnectCommand, private val localeCommand: LocaleCommand, private val radioGroupCommand: RadioGroupCommand, + private val configGroupCommand: ConfigGroupCommand, private val localizationService: LocalizationService, private val errorHandlerService: ErrorHandlerService ) : CommandHandlerService { @@ -80,8 +84,11 @@ class CommandHandlerServiceImpl( override suspend fun onRegisterGroupCommand(kord: Kord) { radioGroupCommand.onRegisterCommand(kord) + configGroupCommand.onRegisterCommand(kord) } + // TODO: Refactor to reduce complexity (issue: #detekt-suppress) + @Suppress("CyclomaticComplexMethod", "ForbiddenComment") override suspend fun onExecute( interaction: ChatInputCommandInteraction, response: DeferredPublicMessageInteractionResponseBehavior @@ -98,11 +105,17 @@ class CommandHandlerServiceImpl( CommandName.Player.commandName -> playerCommand.onExecute(interaction, response) CommandName.Sounds.commandName -> soundsCommand.onExecute(interaction, response) CommandName.Radio.commandName -> radioGroupCommand.onExecute(interaction, response) + CommandName.Config.commandName -> configGroupCommand.onExecute(interaction, response) CommandName.Reconnect.commandName -> reconnectCommand.onExecute(interaction, response) CommandName.Next.commandName -> nextCommand.onExecute(interaction, response) CommandName.Disconnect.commandName -> disconnectCommand.onExecute(interaction, response) CommandName.Locale.commandName -> localeCommand.onExecute(interaction, response) - else -> respondUnknownCommand(response, interaction.data.guildId.value, interaction.guildLocale, commandName) + else -> respondUnknownCommand( + response, + interaction.data.guildId.value, + interaction.guildLocale, + commandName + ) } } catch (exception: Throwable) { errorHandlerService.handleCommandError(exception, interaction, response, commandName) @@ -117,16 +130,26 @@ class CommandHandlerServiceImpl( try { when (ComponentsEnum.forCustomId(customId)) { - ComponentsEnum.QUEUE_NEXT, ComponentsEnum.QUEUE_PREVIOUS -> queueCommand.onInteract(interaction) - - ComponentsEnum.PLAYER_RESUME, ComponentsEnum.PLAYER_PAUSE, ComponentsEnum.PLAYER_SKIP, - ComponentsEnum.PLAYER_DISCONNECT, ComponentsEnum.PLAYER_SHUFFLE, ComponentsEnum.PLAYER_RECONNECT -> playerCommand.onInteract(interaction) - - ComponentsEnum.SOUNDS_NEXT, ComponentsEnum.SOUNDS_PREVIOUS -> soundsCommand.onInteract(interaction) - - ComponentsEnum.RADIO_LIST_NEXT, ComponentsEnum.RADIO_LIST_PREVIOUS, ComponentsEnum.RADIO_SEARCH_NAME_NEXT, - ComponentsEnum.RADIO_SEARCH_NAME_PREVIOUS, ComponentsEnum.RADIO_SEARCH_COUNTRY_CODE_NEXT, - ComponentsEnum.RADIO_SEARCH_COUNTRY_CODE_PREVIOUS, ComponentsEnum.RADIO_COUNTRYCODES_NEXT, + ComponentsEnum.QUEUE_NEXT, + ComponentsEnum.QUEUE_PREVIOUS -> queueCommand.onInteract(interaction) + + ComponentsEnum.PLAYER_RESUME, + ComponentsEnum.PLAYER_PAUSE, + ComponentsEnum.PLAYER_SKIP, + ComponentsEnum.PLAYER_DISCONNECT, + ComponentsEnum.PLAYER_SHUFFLE, + ComponentsEnum.PLAYER_RECONNECT -> playerCommand.onInteract(interaction) + + ComponentsEnum.SOUNDS_NEXT, + ComponentsEnum.SOUNDS_PREVIOUS -> soundsCommand.onInteract(interaction) + + ComponentsEnum.RADIO_LIST_NEXT, + ComponentsEnum.RADIO_LIST_PREVIOUS, + ComponentsEnum.RADIO_SEARCH_NAME_NEXT, + ComponentsEnum.RADIO_SEARCH_NAME_PREVIOUS, + ComponentsEnum.RADIO_SEARCH_COUNTRY_CODE_NEXT, + ComponentsEnum.RADIO_SEARCH_COUNTRY_CODE_PREVIOUS, + ComponentsEnum.RADIO_COUNTRYCODES_NEXT, ComponentsEnum.RADIO_COUNTRYCODES_PREVIOUS -> radioGroupCommand.onInteract(interaction) null -> Unit @@ -142,6 +165,7 @@ class CommandHandlerServiceImpl( when (commandName) { CommandName.Sound.commandName -> soundCommand.onAutoComplete(interaction) CommandName.Radio.commandName -> radioGroupCommand.onAutoComplete(interaction) + CommandName.Config.commandName -> configGroupCommand.onAutoComplete(interaction) CommandName.Locale.commandName -> localeCommand.onAutoComplete(interaction) } } catch (exception: Throwable) { diff --git a/src/main/kotlin/services/config/ConfigBO.kt b/src/main/kotlin/services/config/ConfigBO.kt index 2c90834..bb7067f 100644 --- a/src/main/kotlin/services/config/ConfigBO.kt +++ b/src/main/kotlin/services/config/ConfigBO.kt @@ -13,8 +13,6 @@ data class Config( val database: DatabaseConfig, @SerialName("youtube") val youtube: YouTubeConfig, - @SerialName("hugging_chat") - val huggingChat: HuggingChatConfig, @SerialName("deezer") val deezer: DeezerConfig, @SerialName("spotify") @@ -53,16 +51,6 @@ data class YouTubeConfig( val remoteCipherPassword: String? ) -@Serializable -data class HuggingChatConfig( - @SerialName("enabled") - val enabled: Boolean, - @SerialName("user") - val user: String, - @SerialName("password") - val password: String -) - @Serializable data class DeezerConfig( @SerialName("enabled") @@ -97,6 +85,8 @@ data class TidalConfig( @Serializable data class KokoroConfig( + @SerialName("enabled") + val enabled: Boolean, @SerialName("base_url") val baseUrl: String, @SerialName("default_voice") diff --git a/src/main/kotlin/services/config/ConfigMigrationService.kt b/src/main/kotlin/services/config/ConfigMigrationService.kt new file mode 100644 index 0000000..8ed0d51 --- /dev/null +++ b/src/main/kotlin/services/config/ConfigMigrationService.kt @@ -0,0 +1,161 @@ +package es.wokis.services.config + +import es.wokis.utils.Log +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import java.io.File +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +private const val ARCHIVE_PATH = "./data/archive/" +private const val CONFIG_TEMPLATE_PATH = "/template/config_template.json" + +class ConfigMigrationService(private val json: Json) { + + fun migrateConfig(configFile: File): Config { + Log.info("Starting config migration...") + + check(configFile.exists()) { "Config file not found at ${configFile.absolutePath}" } + + val oldConfigJson = configFile.readText() + + backupConfig(oldConfigJson) + + val migratedJson = migrateJson(oldConfigJson) + + configFile.writeText(migratedJson) + + Log.info("Config migration completed successfully.") + + return json.decodeFromString(migratedJson) + } + + private fun backupConfig(configContent: String) { + val archiveDir = File(ARCHIVE_PATH) + if (!archiveDir.exists()) { + archiveDir.mkdirs() + } + + val timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss")) + val backupFile = File("$ARCHIVE_PATH/config-$timestamp.json") + + backupFile.writeText(configContent) + Log.info("Config backed up to: ${backupFile.absolutePath}") + } + + private fun migrateJson(oldJsonString: String): String { + val templateJson = getTemplateJson() + val oldJson = json.parseToJsonElement(oldJsonString) as JsonObject + + val migratedObject = JsonObjectBuilder.buildMergedJson(oldJson, templateJson) + + return json.encodeToString( + JsonObject.serializer(), + migratedObject + ) + } + + private fun getTemplateJson(): JsonObject { + val templateStream = {}::class.java.getResourceAsStream(CONFIG_TEMPLATE_PATH) + ?: error("Config template not found") + + return json.parseToJsonElement(templateStream.bufferedReader().use { it.readText() }) as JsonObject + } +} + +object JsonObjectBuilder { + + fun buildMergedJson(oldJson: JsonObject, templateJson: JsonObject): JsonObject { + val result = mutableMapOf() + + for ((key, templateValue) in templateJson.entries) { + val oldValue = oldJson[key] + + when { + oldValue == null -> { + result[key] = templateValue + } + + templateValue is JsonObject && oldValue is JsonObject -> { + result[key] = mergeObject(oldValue, templateValue) + } + + else -> { + result[key] = oldValue + } + } + } + + return JsonObject(result) + } + + private fun mergeObject(oldObject: JsonObject, templateObject: JsonObject): JsonObject { + val result = mutableMapOf() + val hasEnabledField = templateObject.containsKey("enabled") + + val oldObjectHasData = hasNonEmptyValues(oldObject) + + for ((key, templateValue) in templateObject.entries) { + val oldValue = oldObject[key] + + when { + oldValue == null -> { + if (key == "enabled" && hasEnabledField && oldObjectHasData) { + result[key] = JsonPrimitive(true) + } else { + result[key] = templateValue + } + } + + templateValue is JsonObject && oldValue is JsonObject -> { + result[key] = mergeObject(oldValue, templateValue) + } + + templateValue is JsonArray && oldValue is JsonArray -> { + result[key] = mergeArray(oldValue, templateValue) + } + + else -> { + result[key] = oldValue + } + } + } + + return JsonObject(result) + } + + @Suppress("ReturnCount") + private fun hasNonEmptyValues(obj: JsonObject): Boolean { + for ((key, value) in obj.entries) { + if (key == "enabled") continue + + when (value) { + is JsonPrimitive -> { + if (!value.content.isNullOrBlank()) { + return true + } + } + + is JsonObject -> { + if (hasNonEmptyValues(value)) { + return true + } + } + + is JsonArray -> { + if (value.isNotEmpty()) { + return true + } + } + } + } + return false + } + + private fun mergeArray(oldArray: JsonArray, @Suppress("UNUSED_PARAMETER") templateArray: JsonArray): JsonArray { + return oldArray + } +} diff --git a/src/main/kotlin/services/config/ConfigService.kt b/src/main/kotlin/services/config/ConfigService.kt index 1b198dc..757f567 100644 --- a/src/main/kotlin/services/config/ConfigService.kt +++ b/src/main/kotlin/services/config/ConfigService.kt @@ -2,6 +2,7 @@ package es.wokis.services.config import es.wokis.exceptions.EmptyDeezerMasterDecryptionKeyException import es.wokis.exceptions.EmptyDiscordTokenException +import es.wokis.utils.Log import es.wokis.utils.getOrCreateFile import kotlinx.serialization.json.Json @@ -9,17 +10,35 @@ private const val CONFIG_PATH = "./data/" private const val FILE_NAME = "config.json" private val CONFIG_TEMPLATE = {}::class.java.getResourceAsStream("/template/config_template.json") -class ConfigService { +class ConfigService( + private val json: Json, + private val migrationService: ConfigMigrationService +) { val config: Config = loadFromFile() private fun loadFromFile(): Config { val file = getOrCreateFile(CONFIG_PATH, FILE_NAME, CONFIG_TEMPLATE) - val config = Json.decodeFromString(file.readText()) - config.validate() - return config + return try { + val config = json.decodeFromString(file.readText()) + config.validate() + config + } catch (e: Exception) { + Log.warning("Failed to parse config file: ${e.message}. Attempting migration...") + try { + val migratedConfig = migrationService.migrateConfig(file) + migratedConfig.validate() + migratedConfig + } catch (migrationError: Exception) { + Log.error("Config migration failed: ${migrationError.message}") + throw e + } + } } + fun reload(): Config { + return loadFromFile() + } } fun Config.validate() { diff --git a/src/main/kotlin/services/error/ErrorHandlerService.kt b/src/main/kotlin/services/error/ErrorHandlerService.kt index ecc3342..062f4fb 100644 --- a/src/main/kotlin/services/error/ErrorHandlerService.kt +++ b/src/main/kotlin/services/error/ErrorHandlerService.kt @@ -18,9 +18,9 @@ import java.time.format.DateTimeFormatter private const val MAX_DISCORD_MESSAGE_LENGTH = 2000 private const val STACK_TRACE_MAX_LENGTH = 1900 -private const val ERROR_HEADER_LENGTH = 100 private const val TIMESTAMP_PATTERN = "yyyy-MM-dd HH:mm:ss" private const val TRUNCATED_SUFFIX = "\n… (truncated)" +private const val TRUNCATION_BUFFER = 3 /** * Enum representing different types of interactions that can generate errors. @@ -132,8 +132,16 @@ class ErrorHandlerService( commandName: String?, interactionType: InteractionType ) { - val guildInfo = if (interaction.data.guildId.value != null) "Guild(${interaction.data.guildId.value})" else "DM" - val context = "${interactionType.displayName} Error: ${commandName ?: "Unknown"} | User: ${interaction.user.username} (${interaction.user.id}) | Guild: $guildInfo" + val guildInfo = if (interaction.data.guildId.value != null) { + "Guild(${interaction.data.guildId.value})" + } else { + "DM" + } + val context = listOf( + "${interactionType.displayName} Error: ${commandName ?: "Unknown"}", + "User: ${interaction.user.username} (${interaction.user.id})", + "Guild: $guildInfo" + ).joinToString(" | ") Log.error(context, exception) } @@ -153,7 +161,7 @@ class ErrorHandlerService( // Truncate if necessary val truncatedMessage = if (errorMessage.length > MAX_DISCORD_MESSAGE_LENGTH) { - errorMessage.take(MAX_DISCORD_MESSAGE_LENGTH - 3) + "…" + errorMessage.take(MAX_DISCORD_MESSAGE_LENGTH - TRUNCATION_BUFFER) + "…" } else { errorMessage } diff --git a/src/main/kotlin/services/lavaplayer/AudioPlayerManagerProvider.kt b/src/main/kotlin/services/lavaplayer/AudioPlayerManagerProvider.kt index ccff2d1..d01ac37 100644 --- a/src/main/kotlin/services/lavaplayer/AudioPlayerManagerProvider.kt +++ b/src/main/kotlin/services/lavaplayer/AudioPlayerManagerProvider.kt @@ -23,6 +23,8 @@ class AudioPlayerManagerProvider( private val configService: ConfigService ) { + // TODO: Refactor to reduce complexity (issue: #detekt-suppress) + @Suppress("LongMethod", "CyclomaticComplexMethod", "NestedBlockDepth", "ForbiddenComment") fun createAudioPlayerManager(): AudioPlayerManager = DefaultAudioPlayerManager().apply { val config = configService.config val trackResolverProviders = buildList { @@ -47,7 +49,12 @@ class AudioPlayerManagerProvider( } } this.registerSourceManager( - YoutubeAudioSourceManager(youtubeOptions, TvHtml5EmbeddedWithThumbnail(), WebWithThumbnail(), MusicWithThumbnail()).apply { + YoutubeAudioSourceManager( + youtubeOptions, + TvHtml5EmbeddedWithThumbnail(), + WebWithThumbnail(), + MusicWithThumbnail() + ).apply { setPlaylistPageCount(Integer.MAX_VALUE) useOauth2(config.youtube.oauth2Token, true) Web.setPoTokenAndVisitorData( diff --git a/src/main/kotlin/services/lavaplayer/GuildLavaPlayerService.kt b/src/main/kotlin/services/lavaplayer/GuildLavaPlayerService.kt index 485ac97..af693bc 100644 --- a/src/main/kotlin/services/lavaplayer/GuildLavaPlayerService.kt +++ b/src/main/kotlin/services/lavaplayer/GuildLavaPlayerService.kt @@ -31,8 +31,8 @@ import es.wokis.utils.Log import es.wokis.utils.createCoroutineScope import es.wokis.utils.getDisplayTrackName import es.wokis.utils.getLocale -import es.wokis.utils.toSanitizedMarkdownLink import es.wokis.utils.isValidUrl +import es.wokis.utils.toSanitizedMarkdownLink import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel import java.util.* @@ -47,9 +47,11 @@ private const val FIRST_BACK_OFF_DELAY = "250ms" private const val MAX_BACK_OFF_DELAY = "2s" private const val MAX_BACK_OFF_RETRIES = 5 private const val UNKNOWN_ERROR = "Unknown error" -private const val SEEK_UPDATE_DELAY = 3000L private const val RECONNECT_DELAY = 500L +private const val FRAME_TIMEOUT_MS = 20L +// TODO: Consider splitting into smaller classes (issue: #detekt-suppress) +@Suppress("TooManyFunctions", "ForbiddenComment") class GuildLavaPlayerService( appDispatchers: AppDispatchers, private val textChannel: MessageChannel, @@ -78,12 +80,12 @@ class GuildLavaPlayerService( private var playerMessage: Message? = null private var seekTimerJob: Job? = null private val updateSeekChannel = Channel(Channel.CONFLATED) - private var frameTimeOut = 20L + private var frameTimeOut = FRAME_TIMEOUT_MS private var currentTrack: TrackBO? = null init { coroutineScope.launch { - for (event in updateSeekChannel) { + for (ignored in updateSeekChannel) { updatePlayerEmbed() } } @@ -165,9 +167,7 @@ class GuildLavaPlayerService( } } - fun searchAndPlay(searchTerm: String) { - // TODO: Implement on next steps - } + fun searchAndPlay(@Suppress("UNUSED_PARAMETER") searchTerm: String): Unit = Unit suspend fun playRadio(radioName: String, radioUrl: String, customFavicon: String) { audioPlayerManager.loadItemSync(radioUrl)?.let { item -> item as? AudioTrack }?.let { @@ -198,7 +198,7 @@ class GuildLavaPlayerService( fun isPaused(): Boolean = player.isPaused fun resume() { - frameTimeOut = 20L + frameTimeOut = FRAME_TIMEOUT_MS player.isPaused = false } @@ -298,17 +298,6 @@ class GuildLavaPlayerService( leaveTimer = null } - // TODO: Take a look in the future to solve discord update request error or delete it - private fun startSeekUpdateTimer() { - if (playerMessage == null) return - resetSeekTimerJob() - seekTimerJob = coroutineScope.launch { - while (true) { - updateSeekChannel.send(Unit) - delay(SEEK_UPDATE_DELAY) - } - } - } private suspend fun sendNowPlayingMessage( discordLocale: Locale?, voiceChannelName: String @@ -323,7 +312,6 @@ class GuildLavaPlayerService( ) } - private fun queue(tracks: List, addToFront: Boolean = false) { resetLeaveTimer() if (addToFront) { @@ -448,10 +436,17 @@ class GuildLavaPlayerService( message.edit { content = if (playlistUrl?.isValidUrl() == true) { localizationService.getStringFormat( - key = if (addToFront) LocalizationKeys.NEXT_ADDED_SONGS_TO_QUEUE_WITH_LINK else LocalizationKeys.ADDED_SONGS_TO_QUEUE_WITH_LINK, + key = if (addToFront) { + LocalizationKeys.NEXT_ADDED_SONGS_TO_QUEUE_WITH_LINK + } else { + LocalizationKeys.ADDED_SONGS_TO_QUEUE_WITH_LINK + }, guildId = guildId, discordLocale = discordLocale, - arguments = arrayOf(playlist.name.toSanitizedMarkdownLink(playlistUrl), playlist.tracks.size) + arguments = arrayOf( + playlist.name.toSanitizedMarkdownLink(playlistUrl), + playlist.tracks.size + ) ) } else { localizationService.getStringFormat( @@ -473,14 +468,24 @@ class GuildLavaPlayerService( textChannel.createMessage( if (track.info.uri.isValidUrl()) { localizationService.getStringFormat( - key = if (addToFront) LocalizationKeys.NEXT_ADDED_TO_QUEUE_WITH_LINK else LocalizationKeys.ADDED_TRACK_TO_QUEUE_WITH_LINK, + key = if (addToFront) { + LocalizationKeys.NEXT_ADDED_TO_QUEUE_WITH_LINK + } else { + LocalizationKeys.ADDED_TRACK_TO_QUEUE_WITH_LINK + }, guildId = guildId, discordLocale = discordLocale, - arguments = arrayOf(currentTrack.getDisplayTrackName().toSanitizedMarkdownLink(track.info.uri)) + arguments = arrayOf( + currentTrack.getDisplayTrackName().toSanitizedMarkdownLink(track.info.uri) + ) ) } else { localizationService.getStringFormat( - key = if (addToFront) LocalizationKeys.NEXT_ADDED_TO_QUEUE else LocalizationKeys.ADDED_TRACK_TO_QUEUE, + key = if (addToFront) { + LocalizationKeys.NEXT_ADDED_TO_QUEUE + } else { + LocalizationKeys.ADDED_TRACK_TO_QUEUE + }, guildId = guildId, discordLocale = discordLocale, arguments = arrayOf(currentTrack.getDisplayTrackName()) @@ -543,7 +548,11 @@ class GuildLavaPlayerService( private suspend fun updatePlayerEmbed() { playerMessage?.let { - val guildName = textChannel.data.guildId.value?.let { guildId -> textChannel.kord.getGuild(guildId) }?.name.orEmpty() + val guildName = textChannel.data.guildId.value?.let { guildId -> + textChannel.kord.getGuild( + guildId + ) + }?.name.orEmpty() try { it.edit { createPlayerEmbed( diff --git a/src/main/kotlin/services/lavaplayer/manager/KokoroAudioTrack.kt b/src/main/kotlin/services/lavaplayer/manager/KokoroAudioTrack.kt index e5499e7..7255639 100644 --- a/src/main/kotlin/services/lavaplayer/manager/KokoroAudioTrack.kt +++ b/src/main/kotlin/services/lavaplayer/manager/KokoroAudioTrack.kt @@ -25,7 +25,7 @@ class KokoroAudioTrack( var langCode: String = "" override fun process(localExecutor: LocalAudioTrackExecutor) { - if (baseUrl.isEmpty()) throw IllegalStateException("Base URL not set for KokoroAudioTrack") + check(baseUrl.isNotEmpty()) { "Base URL not set for KokoroAudioTrack" } sourceManager.getHttpInterface().use { httpInterface -> val post = HttpPost(URI.create("$baseUrl$KOKORO_SPEECH_ENDPOINT").normalize()).apply { diff --git a/src/main/kotlin/services/lavaplayer/manager/KokoroSourceManager.kt b/src/main/kotlin/services/lavaplayer/manager/KokoroSourceManager.kt index def9434..7a6be8c 100644 --- a/src/main/kotlin/services/lavaplayer/manager/KokoroSourceManager.kt +++ b/src/main/kotlin/services/lavaplayer/manager/KokoroSourceManager.kt @@ -21,6 +21,9 @@ import kotlin.text.Charsets.UTF_8 private const val DEFAULT_VOICE = "em_santa" private const val DEFAULT_LANG_CODE = "e" +private const val CONNECT_TIMEOUT_MS = 30000 +private const val SOCKET_TIMEOUT_MS = 120000 +private const val TTS_TITLE_MAX_LENGTH = 30 class KokoroSourceManager : AudioSourceManager, HttpConfigurable { private val httpInterfaceManager: HttpInterfaceManager = HttpClientTools.createDefaultThreadLocalManager() @@ -31,13 +34,11 @@ class KokoroSourceManager : AudioSourceManager, HttpConfigurable { var defaultLangCode: String = "" init { - // Configure extended timeouts for TTS generation - // Long text can take 30+ seconds to generate configureRequests { config -> RequestConfig.copy(config) - .setConnectTimeout(30000) // 30 seconds to establish connection - .setSocketTimeout(120000) // 2 minutes for data transfer (long TTS generation) - .setConnectionRequestTimeout(30000) // 30 seconds to get connection from pool + .setConnectTimeout(CONNECT_TIMEOUT_MS) + .setSocketTimeout(SOCKET_TIMEOUT_MS) + .setConnectionRequestTimeout(CONNECT_TIMEOUT_MS) .build() } } @@ -79,7 +80,7 @@ class KokoroSourceManager : AudioSourceManager, HttpConfigurable { override fun isTrackEncodable(track: AudioTrack): Boolean = true - override fun encodeTrack(track: AudioTrack, output: DataOutput) {} + override fun encodeTrack(track: AudioTrack, output: DataOutput) = Unit override fun decodeTrack(trackInfo: AudioTrackInfo, input: DataInput): AudioTrack = KokoroAudioTrack(trackInfo, this) @@ -99,9 +100,8 @@ class KokoroSourceManager : AudioSourceManager, HttpConfigurable { fun getHttpInterface() = httpInterfaceManager.getInterface() private fun buildTrackInfo(reference: AudioReference, rawText: String): AudioTrackInfo { - // Generate title: "TTS Message: first 30 chars…" - val title = if (rawText.length > 30) { - "TTS Message: ${rawText.take(30)}…" + val title = if (rawText.length > TTS_TITLE_MAX_LENGTH) { + "TTS Message: ${rawText.take(TTS_TITLE_MAX_LENGTH)}…" } else { "TTS Message: $rawText" } diff --git a/src/main/kotlin/services/localization/LocalizationService.kt b/src/main/kotlin/services/localization/LocalizationService.kt index 2400a21..0d3efa7 100644 --- a/src/main/kotlin/services/localization/LocalizationService.kt +++ b/src/main/kotlin/services/localization/LocalizationService.kt @@ -19,7 +19,9 @@ class LocalizationService( private val localizedStrings: Map> = loadLanguages() - fun getLocalizations(key: String): MutableMap = localizedStrings[key]?.associate { it.locale to it.value }?.toMutableMap() + fun getLocalizations(key: String): MutableMap = localizedStrings[key]?.associate { + it.locale to it.value + }?.toMutableMap() ?: throw NoLocalizationFoundException(key) suspend fun getString( diff --git a/src/main/kotlin/services/player/PlayerChannelService.kt b/src/main/kotlin/services/player/PlayerChannelService.kt index d9b91c3..62bb25f 100644 --- a/src/main/kotlin/services/player/PlayerChannelService.kt +++ b/src/main/kotlin/services/player/PlayerChannelService.kt @@ -36,6 +36,7 @@ class PlayerChannelService { * @param buildMessage Function to build the message content * @return Result containing the PlayerChannelResult or an error */ + @Suppress("ReturnCount") suspend fun sendPlayerMessage( interaction: ApplicationCommandInteraction, buildMessage: suspend MessageCreateBuilder.() -> Unit @@ -87,7 +88,9 @@ class PlayerChannelService { ) ) }.also { - Log.info("$TAG: Successfully created #player channel '${it.name}' (ID: ${it.id}) in guild '${guild.name}'") + Log.info( + "$TAG: Successfully created #player channel '${it.name}' (ID: ${it.id}) in guild '${guild.name}'" + ) } } catch (e: Exception) { Log.error("$TAG: Unexpected error creating #player channel in guild '${guild.name}': ${e.message}", e) diff --git a/src/main/kotlin/services/queue/GuildQueueService.kt b/src/main/kotlin/services/queue/GuildQueueService.kt index 73056c7..ac2899d 100644 --- a/src/main/kotlin/services/queue/GuildQueueService.kt +++ b/src/main/kotlin/services/queue/GuildQueueService.kt @@ -20,6 +20,8 @@ class GuildQueueService( private val guildQueues: MutableMap = mutableMapOf() + // TODO: Refactor to reduce throw statements (issue: #detekt-suppress) + @Suppress("ThrowsCount", "ForbiddenComment") suspend fun getOrCreateLavaPlayerService(interaction: ApplicationCommandInteraction): GuildLavaPlayerService { val voiceChannel = interaction.getMemberVoiceChannel(interaction.kord) ?: throw BotException.UserException.NotInVoiceChannelException() diff --git a/src/main/kotlin/utils/AudioTrackUtils.kt b/src/main/kotlin/utils/AudioTrackUtils.kt index df70748..c99e404 100644 --- a/src/main/kotlin/utils/AudioTrackUtils.kt +++ b/src/main/kotlin/utils/AudioTrackUtils.kt @@ -1,6 +1,5 @@ package es.wokis.utils -import com.sedmelluq.discord.lavaplayer.track.AudioTrack import es.wokis.services.lavaplayer.model.TrackBO fun TrackBO.getDisplayTrackName(): String = when { diff --git a/src/main/kotlin/utils/InteractionExtensions.kt b/src/main/kotlin/utils/InteractionExtensions.kt index 8e0cd4b..fb0d8ed 100644 --- a/src/main/kotlin/utils/InteractionExtensions.kt +++ b/src/main/kotlin/utils/InteractionExtensions.kt @@ -22,4 +22,3 @@ fun ChatInputCommandInteraction.getArgument( @Throws(IllegalArgumentException::class) suspend fun Interaction.getGuildName(): String = data.guildId.value?.let { kord.getGuild(it) }?.name ?: throw IllegalArgumentException("guild id is null") - diff --git a/src/main/kotlin/utils/StringUtils.kt b/src/main/kotlin/utils/StringUtils.kt index ba2ae26..25a8995 100644 --- a/src/main/kotlin/utils/StringUtils.kt +++ b/src/main/kotlin/utils/StringUtils.kt @@ -4,7 +4,8 @@ import java.net.URLEncoder private const val URL_ENCODED_SPACE = "+" private const val SPACE_UTF_8 = "%20" -private const val EMOJI_REGEX_PATTERN = "(\\p{IsEmoji_Presentation}|\\p{IsEmoji_Modifier}|\\p{IsEmoji_Modifier_Base}|\\p{IsEmoji_Component}|\\p{IsExtended_Pictographic})+" +private const val EMOJI_REGEX_PATTERN = "(\\p{IsEmoji_Presentation}|\\p{IsEmoji_Modifier}|" + + "\\p{IsEmoji_Modifier_Base}|\\p{IsEmoji_Component}|\\p{IsExtended_Pictographic})+" private const val URL_PATTERN_REGEX_PATTERN = "https?://[^\\s]+" private val PROBLEMATIC_MARKDOWN_PATTERNS_REGEX = Regex("$EMOJI_REGEX_PATTERN|$URL_PATTERN_REGEX_PATTERN") diff --git a/src/main/resources/lang/lang.yml b/src/main/resources/lang/lang.yml index bedb80c..27cb5f2 100644 --- a/src/main/resources/lang/lang.yml +++ b/src/main/resources/lang/lang.yml @@ -108,3 +108,18 @@ locale_command_input_description: Locale to set (use autocomplete to see availab locale_command_success: Guild locale has been set to %s locale_command_reset_success: Guild locale has been reset to Discord's default locale_command_invalid_locale: Invalid locale provided. Please use the autocomplete options. +config_command_description: Manage bot configuration +config_reload_command_description: Reload configuration from file +config_reload_success: Configuration reloaded successfully +config_set_command_description: Set a configuration value +config_set_section_description: Configuration section +config_set_key_description: Configuration key +config_set_value_description: New value +config_set_success: Config %s updated to: %s +config_get_command_description: Get configuration section +config_get_section_description: Configuration section to display +config_get_success: Configuration section retrieved +config_get_display: "%s: %s" +config_invalid_section: Invalid section. Valid sections: database, youtube, deezer, spotify, tidal, kokoro +config_invalid_key: Invalid key for the selected section +config_cannot_modify_token: You cannot modify this value diff --git a/src/main/resources/lang/lang_es-ES.yml b/src/main/resources/lang/lang_es-ES.yml index a0bac80..aafa79a 100644 --- a/src/main/resources/lang/lang_es-ES.yml +++ b/src/main/resources/lang/lang_es-ES.yml @@ -103,6 +103,21 @@ error_no_track_playing: No hay ninguna pista reproduciéndose error_api_unexpected: Ocurrió un error al comunicarse con servicios externos. Por favor, inténtalo de nuevo más tarde. error_unexpected: Ocurrió un error inesperado. Por favor, inténtalo de nuevo más tarde. error_unexpected_with_debug: Ocurrió un error inesperado: %s +config_command_description: Gestionar la configuración del bot +config_reload_command_description: Recargar configuración desde archivo +config_reload_success: Configuración recargada correctamente +config_set_command_description: Establecer un valor de configuración +config_set_section_description: Sección de configuración +config_set_key_description: Clave de configuración +config_set_value_description: Nuevo valor +config_set_success: Config %s actualizado a: %s +config_get_command_description: Obtener sección de configuración +config_get_section_description: Sección de configuración a mostrar +config_get_success: Sección de configuración obtenida +config_get_display: "%s: %s" +config_invalid_section: Sección inválida. Secciones válidas: database, youtube, deezer, spotify, tidal, kokoro +config_invalid_key: Clave inválida para la sección seleccionada +config_cannot_modify_token: No puedes modificar este valor locale_command_description: Establecer el idioma preferido para este servidor locale_command_input_description: Idioma a establecer (usa el autocompletado para ver las opciones disponibles) locale_command_success: El idioma del servidor se ha establecido a %s diff --git a/src/main/resources/template/config_template.json b/src/main/resources/template/config_template.json index 8b12955..2c69adc 100644 --- a/src/main/resources/template/config_template.json +++ b/src/main/resources/template/config_template.json @@ -15,11 +15,6 @@ "remote_cipher_url": null, "remote_cipher_password": null }, - "hugging_chat": { - "enabled": false, - "user": "hugging_chat_user", - "password": "hugging_chat_password" - }, "deezer": { "enabled": false, "master_decryption_key": "", @@ -37,9 +32,10 @@ "token": "" }, "kokoro": { + "enabled": false, "base_url": "", - "default_voice": "", + "default_voice": "am_santa", "default_speed": 1.0, - "default_lang_code": "" + "default_lang_code": "en" } } diff --git a/src/test/kotlin/commands/locale/LocaleCommandTest.kt b/src/test/kotlin/commands/locale/LocaleCommandTest.kt index 8b17266..0ae4876 100644 --- a/src/test/kotlin/commands/locale/LocaleCommandTest.kt +++ b/src/test/kotlin/commands/locale/LocaleCommandTest.kt @@ -7,7 +7,6 @@ import dev.kord.core.behavior.interaction.response.DeferredPublicMessageInteract import dev.kord.core.entity.interaction.ChatInputCommandInteraction import dev.kord.core.supplier.EntitySupplyStrategy import es.wokis.commands.locale.LocaleCommand -import es.wokis.domain.locale.GetGuildLocaleUseCase import es.wokis.domain.locale.SetGuildLocaleUseCase import es.wokis.exceptions.BotException import es.wokis.services.localization.LocalizationService diff --git a/src/test/kotlin/commands/next/NextCommandTest.kt b/src/test/kotlin/commands/next/NextCommandTest.kt index 83e42fe..2102db0 100644 --- a/src/test/kotlin/commands/next/NextCommandTest.kt +++ b/src/test/kotlin/commands/next/NextCommandTest.kt @@ -2,8 +2,6 @@ package commands.next import dev.kord.common.Locale import dev.kord.core.entity.interaction.ChatInputCommandInteraction -import dev.kord.rest.builder.interaction.GlobalMultiApplicationCommandBuilder -import es.wokis.commands.CommandName import es.wokis.commands.next.NextCommand import es.wokis.services.lavaplayer.GuildLavaPlayerService import es.wokis.services.lavaplayer.model.TrackBO diff --git a/src/test/kotlin/commands/play/PlayCommandTest.kt b/src/test/kotlin/commands/play/PlayCommandTest.kt index 68a58aa..804e117 100644 --- a/src/test/kotlin/commands/play/PlayCommandTest.kt +++ b/src/test/kotlin/commands/play/PlayCommandTest.kt @@ -32,14 +32,9 @@ class PlayCommandTest { at io.mockk.impl.recording.states.VerifyingState.recordingDone(VerifyingState.kt:42) at io.mockk.impl.recording.CommonCallRecorder.done(CommonCallRecorder.kt:47) at io.mockk.impl.eval.RecordedBlockEvaluator.record(RecordedBlockEvaluator.kt:63) - at io.mockk.impl.eval.VerifyBlockEvaluator.verify(VerifyBlockEvaluator.kt:30) - at io.mockk.MockKDsl.internalVerify(API.kt:120) - at io.mockk.MockKKt.verify(MockK.kt:218) - at io.mockk.MockKKt.verify$default(MockK.kt:209) - at commands.test.TestCommandTest.Given command When onRegisterCommand is called Then register test command(TestCommandTest.kt:32) - at java.base/java.lang.reflect.Method.invoke(Method.java:569) - at java.base/java.util.ArrayList.forEach(ArrayList.java:1511) - at java.base/java.util.ArrayList.forEach(ArrayList.java:1511) + at io.mockk.impl.eval.VerifyBlockEvaluator.verify + at io.mockk.MockKDsl.internalVerify + at io.mockk.MockKKt.verify */ @Test @Ignore("Mockk fails") diff --git a/src/test/kotlin/commands/player/PlayerCommandTest.kt b/src/test/kotlin/commands/player/PlayerCommandTest.kt index 1e933fc..d7a16ba 100644 --- a/src/test/kotlin/commands/player/PlayerCommandTest.kt +++ b/src/test/kotlin/commands/player/PlayerCommandTest.kt @@ -1,5 +1,7 @@ package commands.player +import com.sedmelluq.discord.lavaplayer.track.AudioTrack +import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo import dev.kord.common.Locale import dev.kord.common.entity.Snowflake import dev.kord.core.Kord @@ -9,8 +11,6 @@ import dev.kord.core.entity.channel.TextChannel import dev.kord.core.entity.interaction.ButtonInteraction import dev.kord.core.entity.interaction.ChatInputCommandInteraction import dev.kord.core.supplier.EntitySupplyStrategy -import com.sedmelluq.discord.lavaplayer.track.AudioTrack -import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo import es.wokis.commands.ComponentsEnum import es.wokis.commands.player.PlayerCommand import es.wokis.services.lavaplayer.GuildLavaPlayerService @@ -22,7 +22,6 @@ import io.mockk.* import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Test import services.player.result.PlayerChannelResult -import kotlin.test.Ignore private fun createMockTrackBO(): TrackBO { val audioTrack = mockk { diff --git a/src/test/kotlin/commands/queue/QueueCommandTest.kt b/src/test/kotlin/commands/queue/QueueCommandTest.kt index 85fe268..508002e 100644 --- a/src/test/kotlin/commands/queue/QueueCommandTest.kt +++ b/src/test/kotlin/commands/queue/QueueCommandTest.kt @@ -16,9 +16,9 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk -import io.mockk.coEvery as coEvery import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Test +import io.mockk.coEvery as coEvery class QueueCommandTest { diff --git a/src/test/kotlin/commands/radio/subcommands/play/RadioPlayCommandTest.kt b/src/test/kotlin/commands/radio/subcommands/play/RadioPlayCommandTest.kt index a8fe4af..6185b1e 100644 --- a/src/test/kotlin/commands/radio/subcommands/play/RadioPlayCommandTest.kt +++ b/src/test/kotlin/commands/radio/subcommands/play/RadioPlayCommandTest.kt @@ -6,7 +6,6 @@ import dev.kord.core.entity.interaction.AutoCompleteInteraction import dev.kord.core.entity.interaction.ChatInputCommandInteraction import es.wokis.commands.radio.subcommands.play.RadioPlayCommand import es.wokis.data.radio.RadioDTO -import es.wokis.data.response.ErrorType import es.wokis.data.response.RemoteResponse import es.wokis.services.lavaplayer.GuildLavaPlayerService import es.wokis.services.localization.LocalizationService @@ -164,11 +163,15 @@ class RadioPlayCommandTest { countryCode = "ES" ) ) - val interaction = mockk(relaxed = true) { + val interaction = mockk { every { kord } returns mockedKord every { id } returns Snowflake(123456789) every { token } returns "test-token" every { command.strings["radio"] } returns input + every { guildLocale } returns Locale.ENGLISH_UNITED_STATES + every { data } returns mockk { + every { guildId.value } returns null + } } coEvery { @@ -187,11 +190,15 @@ class RadioPlayCommandTest { @Test fun `Given autocomplete with empty input When onAutoComplete Then return empty list`() = runTest { // Given - val interaction = mockk(relaxed = true) { + val interaction = mockk { every { kord } returns mockedKord every { id } returns Snowflake(123456789) every { token } returns "test-token" every { command.strings["radio"] } returns "" + every { guildLocale } returns Locale.ENGLISH_UNITED_STATES + every { data } returns mockk { + every { guildId.value } returns null + } } // When diff --git a/src/test/kotlin/commands/radio/subcommands/search/RadioSearchGroupCommandTest.kt b/src/test/kotlin/commands/radio/subcommands/search/RadioSearchGroupCommandTest.kt index cd39b30..587641f 100644 --- a/src/test/kotlin/commands/radio/subcommands/search/RadioSearchGroupCommandTest.kt +++ b/src/test/kotlin/commands/radio/subcommands/search/RadioSearchGroupCommandTest.kt @@ -8,7 +8,6 @@ import es.wokis.commands.ComponentsEnum import es.wokis.commands.radio.subcommands.search.RadioSearchCountryCodeCommand import es.wokis.commands.radio.subcommands.search.RadioSearchGroupCommand import es.wokis.commands.radio.subcommands.search.RadioSearchNameCommand -import es.wokis.localization.LocalizationKeys import es.wokis.services.localization.LocalizationService import io.mockk.coEvery import io.mockk.coJustRun diff --git a/src/test/kotlin/commands/tts/TTSCommandTest.kt b/src/test/kotlin/commands/tts/TTSCommandTest.kt index c9a78eb..120fceb 100644 --- a/src/test/kotlin/commands/tts/TTSCommandTest.kt +++ b/src/test/kotlin/commands/tts/TTSCommandTest.kt @@ -10,7 +10,6 @@ import dev.kord.rest.builder.interaction.GlobalMultiApplicationCommandBuilder import dev.kord.rest.builder.interaction.string import es.wokis.commands.CommandName import es.wokis.commands.tts.TTSCommand -import es.wokis.localization.LocalizationKeys import es.wokis.services.lavaplayer.GuildLavaPlayerService import es.wokis.services.localization.LocalizationService import es.wokis.services.queue.GuildQueueService @@ -40,8 +39,7 @@ class TTSCommandTest { ) /* - Verification failed: call 6 of 6: List(child of #4#6).add(eq(dev.kord.rest.builder.interaction.ChatInputCreateBuilderImpl@30cb489a))) was not called - java.lang.AssertionError: Verification failed: call 6 of 6: List(child of #4#6).add(eq(dev.kord.rest.builder.interaction.ChatInputCreateBuilderImpl@30cb489a))) was not called + Verification failed: call 6 of 6: List(child of #4#6).add(eq(ChatInputCreateBuilderImpl)) was not called */ @Test diff --git a/src/test/kotlin/es/wokis/commands/config/ConfigGetCommandTest.kt b/src/test/kotlin/es/wokis/commands/config/ConfigGetCommandTest.kt new file mode 100644 index 0000000..fbe7803 --- /dev/null +++ b/src/test/kotlin/es/wokis/commands/config/ConfigGetCommandTest.kt @@ -0,0 +1,217 @@ +package es.wokis.commands.config + +import dev.kord.common.Locale +import dev.kord.core.entity.interaction.AutoCompleteInteraction +import dev.kord.core.entity.interaction.ChatInputCommandInteraction +import es.wokis.localization.LocalizationKeys +import es.wokis.services.config.Config +import es.wokis.services.config.ConfigService +import es.wokis.services.localization.LocalizationService +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import mock.mockedKord +import mock.mockedResponse +import org.junit.jupiter.api.Test + +class ConfigGetCommandTest { + + private val configService: ConfigService = mockk() + private val localizationService: LocalizationService = mockk() + + private val configGetCommand = ConfigGetCommand( + configService = configService, + localizationService = localizationService + ) + + @Test + fun `Given valid section When onExecute Then display config`() = runTest { + // Given + val mockConfig = createMockConfig() + val interaction = mockk { + every { kord } returns mockedKord + every { guildLocale } returns Locale.ENGLISH_UNITED_STATES + every { data } returns mockk { + every { guildId.value } returns null + } + every { command.strings["section"] } returns "database" + } + + every { configService.config } returns mockConfig + coEvery { + localizationService.getStringFormat( + LocalizationKeys.CONFIG_GET_DISPLAY, + any(), + any(), + *anyVararg() + ) + } returns "Database config displayed" + + // When + configGetCommand.onExecute(interaction, mockedResponse) + + // Then + coVerify(exactly = 1) { + localizationService.getStringFormat( + LocalizationKeys.CONFIG_GET_DISPLAY, + guildId = null, + discordLocale = Locale.ENGLISH_UNITED_STATES, + arguments = arrayOf("database", mockConfig.database.toString()) + ) + } + } + + @Test + fun `Given invalid section When onExecute Then show error`() = runTest { + // Given + val interaction = mockk { + every { kord } returns mockedKord + every { guildLocale } returns Locale.ENGLISH_UNITED_STATES + every { data } returns mockk { + every { guildId.value } returns null + } + every { command.strings["section"] } returns "invalidsection" + } + + coEvery { + localizationService.getString( + LocalizationKeys.CONFIG_INVALID_SECTION, + any(), + any() + ) + } returns "Invalid section provided" + + // When + configGetCommand.onExecute(interaction, mockedResponse) + + // Then + coVerify(exactly = 1) { + localizationService.getString( + LocalizationKeys.CONFIG_INVALID_SECTION, + guildId = null, + discordLocale = Locale.ENGLISH_UNITED_STATES + ) + } + } + + @Test + fun `Given null section When onExecute Then show error`() = runTest { + // Given + val interaction = mockk { + every { kord } returns mockedKord + every { guildLocale } returns Locale.ENGLISH_UNITED_STATES + every { data } returns mockk { + every { guildId.value } returns null + } + every { command.strings["section"] } returns null + } + + coEvery { + localizationService.getString( + LocalizationKeys.CONFIG_INVALID_SECTION, + any(), + any() + ) + } returns "Invalid section provided" + + // When + configGetCommand.onExecute(interaction, mockedResponse) + + // Then + coVerify(exactly = 1) { + localizationService.getString( + LocalizationKeys.CONFIG_INVALID_SECTION, + guildId = null, + discordLocale = Locale.ENGLISH_UNITED_STATES + ) + } + } + + @Test + fun `Given autocomplete with empty input When onAutoComplete Then completes successfully`() = runTest { + // Given + val interaction = mockk { + every { kord } returns mockedKord + every { command.strings["section"] } returns "" + every { token } returns "mock_token" + every { id } returns mockk() + } + + // When & Then - should complete without exception + configGetCommand.onAutoComplete(interaction) + } + + @Test + fun `Given autocomplete with partial input When onAutoComplete Then completes successfully`() = runTest { + // Given + val interaction = mockk { + every { kord } returns mockedKord + every { command.strings["section"] } returns "sp" + every { token } returns "mock_token" + every { id } returns mockk() + } + + // When & Then - should complete without exception + configGetCommand.onAutoComplete(interaction) + } + + @Test + fun `Given autocomplete with no matches When onAutoComplete Then completes successfully`() = runTest { + // Given + val interaction = mockk { + every { kord } returns mockedKord + every { command.strings["section"] } returns "xyz" + every { token } returns "mock_token" + every { id } returns mockk() + } + + // When & Then - should complete without exception + configGetCommand.onAutoComplete(interaction) + } + + private fun createMockConfig(): Config { + return Config( + discordBotToken = "test-token", + debug = false, + database = es.wokis.services.config.DatabaseConfig( + enabled = true, + username = "testuser", + password = "testpass", + database = "testdb" + ), + youtube = es.wokis.services.config.YouTubeConfig( + enabled = true, + oauth2Token = "yt-token", + poToken = null, + visitorData = null, + remoteCipherUrl = null, + remoteCipherPassword = null + ), + deezer = es.wokis.services.config.DeezerConfig( + enabled = true, + masterDecryptionKey = "deezer-key", + arlToken = "deezer-token" + ), + spotify = es.wokis.services.config.SpotifyConfig( + enabled = true, + clientId = "spotify-client-id", + clientSecret = "spotify-client-secret", + customEndpoint = "https://api.spotify.com" + ), + tidal = es.wokis.services.config.TidalConfig( + enabled = true, + countryCode = "US", + token = "tidal-token" + ), + kokoro = es.wokis.services.config.KokoroConfig( + enabled = true, + baseUrl = "http://localhost:8080", + defaultVoice = "af_bella", + defaultSpeed = 1.0f, + defaultLangCode = "en-us" + ) + ) + } +} diff --git a/src/test/kotlin/es/wokis/commands/config/ConfigGroupCommandTest.kt b/src/test/kotlin/es/wokis/commands/config/ConfigGroupCommandTest.kt new file mode 100644 index 0000000..27c96c1 --- /dev/null +++ b/src/test/kotlin/es/wokis/commands/config/ConfigGroupCommandTest.kt @@ -0,0 +1,210 @@ +package es.wokis.commands.config + +import dev.kord.common.Locale +import dev.kord.core.entity.interaction.AutoCompleteInteraction +import dev.kord.core.entity.interaction.ChatInputCommandInteraction +import dev.kord.core.entity.interaction.SubCommand +import es.wokis.commands.CommandName +import es.wokis.services.localization.LocalizationService +import io.mockk.coEvery +import io.mockk.coJustRun +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import mock.mockedResponse +import org.junit.jupiter.api.Test + +class ConfigGroupCommandTest { + + private val configReloadCommand: ConfigReloadCommand = mockk { + coJustRun { onExecute(any(), any()) } + } + private val configSetCommand: ConfigSetCommand = mockk { + coJustRun { onExecute(any(), any()) } + } + private val configGetCommand: ConfigGetCommand = mockk { + coJustRun { onExecute(any(), any()) } + coJustRun { onAutoComplete(any()) } + } + private val localizationService: LocalizationService = mockk { + coEvery { getString(any(), any(), any()) } returns "Error message" + every { getLocalizations(any()) } returns mutableMapOf() + } + + private val configGroupCommand = ConfigGroupCommand( + configReloadCommand = configReloadCommand, + configSetCommand = configSetCommand, + configGetCommand = configGetCommand, + localizationService = localizationService + ) + + @Test + fun `Given get subcommand When onExecute Then delegate to config get command`() = runTest { + // Given + val commandName = CommandName.Config.Get.commandName + val interaction = mockk { + every { command } returns mockk { + every { name } returns commandName + } + every { data } returns mockk { + every { guildId.value } returns null + } + every { guildLocale } returns Locale.ENGLISH_UNITED_STATES + } + + // When + configGroupCommand.onExecute(interaction, mockedResponse) + + // Then + coVerify(exactly = 1) { + configGetCommand.onExecute(interaction, mockedResponse) + } + } + + @Test + fun `Given set subcommand When onExecute Then delegate to config set command`() = runTest { + // Given + val commandName = CommandName.Config.Set.commandName + val interaction = mockk { + every { command } returns mockk { + every { name } returns commandName + } + every { data } returns mockk { + every { guildId.value } returns null + } + every { guildLocale } returns Locale.ENGLISH_UNITED_STATES + } + + // When + configGroupCommand.onExecute(interaction, mockedResponse) + + // Then + coVerify(exactly = 1) { + configSetCommand.onExecute(interaction, mockedResponse) + } + } + + @Test + fun `Given reload subcommand When onExecute Then delegate to config reload command`() = runTest { + // Given + val commandName = CommandName.Config.Reload.commandName + val interaction = mockk { + every { command } returns mockk { + every { name } returns commandName + } + every { data } returns mockk { + every { guildId.value } returns null + } + every { guildLocale } returns Locale.ENGLISH_UNITED_STATES + } + + // When + configGroupCommand.onExecute(interaction, mockedResponse) + + // Then + coVerify(exactly = 1) { + configReloadCommand.onExecute(interaction, mockedResponse) + } + } + + @Test + fun `Given unknown subcommand When onExecute Then do not delegate to any command`() = runTest { + // Given + val interaction = mockk { + every { command } returns mockk { + every { name } returns "unknown" + } + every { data } returns mockk { + every { guildId.value } returns null + } + every { guildLocale } returns Locale.ENGLISH_UNITED_STATES + } + + // When + configGroupCommand.onExecute(interaction, mockedResponse) + + // Then + coVerify(exactly = 0) { + configReloadCommand.onExecute(any(), any()) + configSetCommand.onExecute(any(), any()) + configGetCommand.onExecute(any(), any()) + } + // Note: Current implementation silently ignores unknown subcommands + } + + @Test + fun `Given get subcommand When onAutoComplete Then delegate to config get command`() = runTest { + // Given + val commandName = CommandName.Config.Get.commandName + val interaction = mockk { + every { command } returns mockk { + every { name } returns commandName + } + } + + // When + configGroupCommand.onAutoComplete(interaction) + + // Then + coVerify(exactly = 1) { + configGetCommand.onAutoComplete(interaction) + } + } + + @Test + fun `Given set subcommand When onAutoComplete Then do not delegate`() = runTest { + // Given + val commandName = CommandName.Config.Set.commandName + val interaction = mockk { + every { command } returns mockk { + every { name } returns commandName + } + } + + // When + configGroupCommand.onAutoComplete(interaction) + + // Then + coVerify(exactly = 0) { + configGetCommand.onAutoComplete(any()) + } + } + + @Test + fun `Given reload subcommand When onAutoComplete Then do not delegate`() = runTest { + // Given + val commandName = CommandName.Config.Reload.commandName + val interaction = mockk { + every { command } returns mockk { + every { name } returns commandName + } + } + + // When + configGroupCommand.onAutoComplete(interaction) + + // Then + coVerify(exactly = 0) { + configGetCommand.onAutoComplete(any()) + } + } + + @Test + fun `Given unknown subcommand When onAutoComplete Then do not delegate`() = runTest { + // Given + val interaction = mockk { + every { command } returns mockk { + every { name } returns "unknown" + } + } + + // When + configGroupCommand.onAutoComplete(interaction) + + // Then + coVerify(exactly = 0) { + configGetCommand.onAutoComplete(any()) + } + } +} diff --git a/src/test/kotlin/es/wokis/commands/config/ConfigReloadCommandTest.kt b/src/test/kotlin/es/wokis/commands/config/ConfigReloadCommandTest.kt new file mode 100644 index 0000000..9279802 --- /dev/null +++ b/src/test/kotlin/es/wokis/commands/config/ConfigReloadCommandTest.kt @@ -0,0 +1,158 @@ +package es.wokis.commands.config + +import dev.kord.common.Locale +import dev.kord.core.entity.interaction.ChatInputCommandInteraction +import es.wokis.localization.LocalizationKeys +import es.wokis.services.config.ConfigService +import es.wokis.services.localization.LocalizationService +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import mock.mockedKord +import mock.mockedResponse +import org.junit.jupiter.api.Test + +class ConfigReloadCommandTest { + + private val configService: ConfigService = mockk { + every { reload() } returns mockk() + } + private val localizationService: LocalizationService = mockk() + + private val configReloadCommand = ConfigReloadCommand( + configService = configService, + localizationService = localizationService + ) + + @Test + fun `Given reload succeeds When onExecute Then show success message`() = runTest { + // Given + val interaction = mockk { + every { kord } returns mockedKord + every { guildLocale } returns Locale.ENGLISH_UNITED_STATES + every { data } returns mockk { + every { guildId.value } returns null + } + every { user } returns mockk { + every { id } returns mockk { + every { value } returns 12345u + } + } + } + + coEvery { + localizationService.getString( + LocalizationKeys.CONFIG_RELOAD_SUCCESS, + guildId = null, + discordLocale = Locale.ENGLISH_UNITED_STATES + ) + } returns "Configuration reloaded successfully" + coEvery { + localizationService.getString( + LocalizationKeys.ERROR_UNEXPECTED, + guildId = null, + discordLocale = Locale.ENGLISH_UNITED_STATES + ) + } returns "An unexpected error occurred" + + // When + configReloadCommand.onExecute(interaction, mockedResponse) + + // Then + verify(exactly = 1) { configService.reload() } + coVerify(exactly = 1) { + localizationService.getString( + LocalizationKeys.CONFIG_RELOAD_SUCCESS, + guildId = null, + discordLocale = Locale.ENGLISH_UNITED_STATES + ) + } + } + + @Test + fun `Given reload throws exception When onExecute Then show error message`() = runTest { + // Given + val interaction = mockk { + every { kord } returns mockedKord + every { guildLocale } returns Locale.ENGLISH_UNITED_STATES + every { data } returns mockk { + every { guildId.value } returns null + } + every { user } returns mockk { + every { id } returns mockk { + every { value } returns 12345u + } + } + } + + every { configService.reload() } throws RuntimeException("Config file not found") + coEvery { + localizationService.getString( + LocalizationKeys.ERROR_UNEXPECTED, + guildId = null, + discordLocale = Locale.ENGLISH_UNITED_STATES + ) + } returns "An unexpected error occurred" + + // When + configReloadCommand.onExecute(interaction, mockedResponse) + + // Then + coVerify(exactly = 1) { configService.reload() } + coVerify(exactly = 1) { + localizationService.getString( + LocalizationKeys.ERROR_UNEXPECTED, + guildId = null, + discordLocale = Locale.ENGLISH_UNITED_STATES + ) + } + } + + @Test + fun `Given spanish locale When onExecute Then use spanish locale`() = runTest { + // Given + val interaction = mockk { + every { kord } returns mockedKord + every { guildLocale } returns Locale.SPANISH_SPAIN + every { data } returns mockk { + every { guildId.value } returns null + } + every { user } returns mockk { + every { id } returns mockk { + every { value } returns 12345u + } + } + } + + coEvery { + localizationService.getString( + LocalizationKeys.CONFIG_RELOAD_SUCCESS, + guildId = null, + discordLocale = Locale.SPANISH_SPAIN + ) + } returns "Configuración recargada correctamente" + coEvery { + localizationService.getString( + LocalizationKeys.ERROR_UNEXPECTED, + guildId = null, + discordLocale = Locale.SPANISH_SPAIN + ) + } returns "An unexpected error occurred" + + // When + configReloadCommand.onExecute(interaction, mockedResponse) + + // Then + verify(exactly = 1) { configService.reload() } + coVerify(exactly = 1) { + localizationService.getString( + LocalizationKeys.CONFIG_RELOAD_SUCCESS, + guildId = null, + discordLocale = Locale.SPANISH_SPAIN + ) + } + } +} diff --git a/src/test/kotlin/es/wokis/commands/config/ConfigSetCommandTest.kt b/src/test/kotlin/es/wokis/commands/config/ConfigSetCommandTest.kt new file mode 100644 index 0000000..0a163c3 --- /dev/null +++ b/src/test/kotlin/es/wokis/commands/config/ConfigSetCommandTest.kt @@ -0,0 +1,328 @@ +package es.wokis.commands.config + +import dev.kord.common.Locale +import dev.kord.core.entity.interaction.ChatInputCommandInteraction +import es.wokis.localization.LocalizationKeys +import es.wokis.services.config.ConfigService +import es.wokis.services.localization.LocalizationService +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import mock.mockedKord +import mock.mockedResponse +import org.junit.jupiter.api.Test + +private const val ARGUMENT_SECTION = "section" +private const val ARGUMENT_KEY = "key" +private const val ARGUMENT_VALUE = "value" + +class ConfigSetCommandTest { + + private val configService: ConfigService = mockk() + private val localizationService: LocalizationService = mockk() + + private val configSetCommand = ConfigSetCommand( + configService = configService, + localizationService = localizationService + ) + + // Note: The success path test (Given valid section/key/value) is omitted + // because updateConfigValue() is a private method that reads from a file. + // Testing this would require file system setup or refactoring. + // All error paths are tested below. + + @Test + fun `Given invalid section When onExecute Then show invalid section error`() = runTest { + // Given + val interaction = createMockInteraction( + section = "invalidsection", + key = "enabled", + value = "true" + ) + + coEvery { + localizationService.getString( + LocalizationKeys.CONFIG_INVALID_SECTION, + any(), + any() + ) + } returns "Invalid section" + + // When + configSetCommand.onExecute(interaction, mockedResponse) + + // Then + coVerify(exactly = 1) { + localizationService.getString( + LocalizationKeys.CONFIG_INVALID_SECTION, + guildId = null, + discordLocale = Locale.ENGLISH_UNITED_STATES + ) + } + } + + @Test + fun `Given null section When onExecute Then show invalid section error`() = runTest { + // Given + val interaction = createMockInteraction( + section = null, + key = "enabled", + value = "true" + ) + + coEvery { + localizationService.getString( + LocalizationKeys.CONFIG_INVALID_SECTION, + any(), + any() + ) + } returns "Invalid section" + + // When + configSetCommand.onExecute(interaction, mockedResponse) + + // Then + coVerify(exactly = 1) { + localizationService.getString( + LocalizationKeys.CONFIG_INVALID_SECTION, + guildId = null, + discordLocale = Locale.ENGLISH_UNITED_STATES + ) + } + } + + @Test + fun `Given invalid key for section When onExecute Then show invalid key error`() = runTest { + // Given + val interaction = createMockInteraction( + section = "database", + key = "invalidkey", + value = "true" + ) + + coEvery { + localizationService.getString( + LocalizationKeys.CONFIG_INVALID_KEY, + any(), + any() + ) + } returns "Invalid key" + + // When + configSetCommand.onExecute(interaction, mockedResponse) + + // Then + coVerify(exactly = 1) { + localizationService.getString( + LocalizationKeys.CONFIG_INVALID_KEY, + guildId = null, + discordLocale = Locale.ENGLISH_UNITED_STATES + ) + } + } + + @Test + fun `Given null key for section When onExecute Then show invalid key error`() = runTest { + // Given + val interaction = createMockInteraction( + section = "database", + key = null, + value = "true" + ) + + coEvery { + localizationService.getString( + LocalizationKeys.CONFIG_INVALID_KEY, + any(), + any() + ) + } returns "Invalid key" + + // When + configSetCommand.onExecute(interaction, mockedResponse) + + // Then + coVerify(exactly = 1) { + localizationService.getString( + LocalizationKeys.CONFIG_INVALID_KEY, + guildId = null, + discordLocale = Locale.ENGLISH_UNITED_STATES + ) + } + } + + @Test + fun `Given empty value When onExecute Then show no content error`() = runTest { + // Given + val interaction = createMockInteraction( + section = "database", + key = "username", + value = "" + ) + + coEvery { + localizationService.getString( + LocalizationKeys.ERROR_NO_CONTENT_PROVIDED, + any(), + any() + ) + } returns "No content provided" + + // When + configSetCommand.onExecute(interaction, mockedResponse) + + // Then + coVerify(exactly = 1) { + localizationService.getString( + LocalizationKeys.ERROR_NO_CONTENT_PROVIDED, + guildId = null, + discordLocale = Locale.ENGLISH_UNITED_STATES + ) + } + } + + @Test + fun `Given null value When onExecute Then show no content error`() = runTest { + // Given + val interaction = createMockInteraction( + section = "database", + key = "username", + value = null + ) + + coEvery { + localizationService.getString( + LocalizationKeys.ERROR_NO_CONTENT_PROVIDED, + any(), + any() + ) + } returns "No content provided" + + // When + configSetCommand.onExecute(interaction, mockedResponse) + + // Then + coVerify(exactly = 1) { + localizationService.getString( + LocalizationKeys.ERROR_NO_CONTENT_PROVIDED, + guildId = null, + discordLocale = Locale.ENGLISH_UNITED_STATES + ) + } + } + + @Test + fun `Given discord_bot_token as section When onExecute Then show invalid section error`() = runTest { + // Given + val interaction = createMockInteraction( + section = "discord_bot_token", + key = "token", + value = "newtoken" + ) + + coEvery { + localizationService.getString( + LocalizationKeys.CONFIG_INVALID_SECTION, + any(), + any() + ) + } returns "Invalid section" + + // When + configSetCommand.onExecute(interaction, mockedResponse) + + // Then + coVerify(exactly = 1) { + localizationService.getString( + LocalizationKeys.CONFIG_INVALID_SECTION, + guildId = null, + discordLocale = Locale.ENGLISH_UNITED_STATES + ) + } + } + + @Test + fun `Given database password key When onExecute Then show cannot modify error`() = runTest { + // Given + val interaction = createMockInteraction( + section = "database", + key = "password", + value = "newpassword" + ) + + coEvery { + localizationService.getString( + LocalizationKeys.CONFIG_CANNOT_MODIFY_TOKEN, + any(), + any() + ) + } returns "Cannot modify token" + + // When + configSetCommand.onExecute(interaction, mockedResponse) + + // Then + coVerify(exactly = 1) { + localizationService.getString( + LocalizationKeys.CONFIG_CANNOT_MODIFY_TOKEN, + guildId = null, + discordLocale = Locale.ENGLISH_UNITED_STATES + ) + } + } + + @Test + fun `Given exception during reload When onExecute Then show unexpected error`() = runTest { + // Given + val interaction = createMockInteraction( + section = "spotify", + key = "enabled", + value = "true" + ) + + coEvery { + localizationService.getString( + LocalizationKeys.ERROR_UNEXPECTED, + any(), + any() + ) + } returns "Unexpected error" + every { configService.reload() } throws RuntimeException("Reload failed") + + // When + configSetCommand.onExecute(interaction, mockedResponse) + + // Then + coVerify(exactly = 1) { + localizationService.getString( + LocalizationKeys.ERROR_UNEXPECTED, + guildId = null, + discordLocale = Locale.ENGLISH_UNITED_STATES + ) + } + } + + private fun createMockInteraction(section: String?, key: String?, value: String?): ChatInputCommandInteraction { + val stringsMap = mutableMapOf().apply { + section?.let { put(ARGUMENT_SECTION, it) } + key?.let { put(ARGUMENT_KEY, it) } + value?.let { put(ARGUMENT_VALUE, it) } + } + + return mockk { + every { kord } returns mockedKord + every { guildLocale } returns Locale.ENGLISH_UNITED_STATES + every { data } returns mockk { + every { guildId.value } returns null + } + every { command } returns mockk { + every { strings } returns stringsMap + } + every { user } returns mockk { + every { id } returns mockk() + } + } + } +} diff --git a/src/test/kotlin/exceptions/BotExceptionsTest.kt b/src/test/kotlin/exceptions/BotExceptionsTest.kt index a9ec2a9..9bb19e1 100644 --- a/src/test/kotlin/exceptions/BotExceptionsTest.kt +++ b/src/test/kotlin/exceptions/BotExceptionsTest.kt @@ -6,7 +6,6 @@ import es.wokis.exceptions.toException import es.wokis.localization.LocalizationKeys import org.junit.jupiter.api.Test import kotlin.test.assertEquals -import kotlin.test.assertNotNull import kotlin.test.assertTrue class BotExceptionsTest { diff --git a/src/test/kotlin/mock/DiscordMocks.kt b/src/test/kotlin/mock/DiscordMocks.kt index 73ac78a..f8238e9 100644 --- a/src/test/kotlin/mock/DiscordMocks.kt +++ b/src/test/kotlin/mock/DiscordMocks.kt @@ -9,7 +9,6 @@ import dev.kord.core.behavior.interaction.response.DeferredPublicMessageInteract import dev.kord.core.entity.Message import dev.kord.core.entity.channel.MessageChannel import dev.kord.core.supplier.EntitySupplyStrategy -import dev.kord.rest.builder.message.create.UserMessageCreateBuilder import dev.kord.rest.json.request.MultipartInteractionResponseModifyRequest import io.mockk.coEvery import io.mockk.coJustRun diff --git a/src/test/kotlin/services/commands/CommandHandlerServiceTest.kt b/src/test/kotlin/services/commands/CommandHandlerServiceTest.kt index 74c4352..6091827 100644 --- a/src/test/kotlin/services/commands/CommandHandlerServiceTest.kt +++ b/src/test/kotlin/services/commands/CommandHandlerServiceTest.kt @@ -1,25 +1,26 @@ package services.commands +import commands.play.PlayCommand import dev.kord.core.behavior.interaction.response.DeferredPublicMessageInteractionResponseBehavior +import dev.kord.core.entity.interaction.AutoCompleteInteraction import dev.kord.core.entity.interaction.ButtonInteraction import dev.kord.core.entity.interaction.ChatInputCommandInteraction import dev.kord.rest.builder.interaction.GlobalMultiApplicationCommandBuilder import es.wokis.commands.CommandName import es.wokis.commands.ComponentsEnum -import es.wokis.commands.queue.QueueCommand -import commands.play.PlayCommand +import es.wokis.commands.config.ConfigGroupCommand +import es.wokis.commands.disconnect.DisconnectCommand +import es.wokis.commands.locale.LocaleCommand +import es.wokis.commands.next.NextCommand import es.wokis.commands.player.PlayerCommand +import es.wokis.commands.queue.QueueCommand import es.wokis.commands.radio.RadioGroupCommand +import es.wokis.commands.reconnect.ReconnectCommand import es.wokis.commands.shuffle.ShuffleCommand import es.wokis.commands.skip.SkipCommand -import dev.kord.core.entity.interaction.AutoCompleteInteraction -import es.wokis.commands.next.NextCommand -import es.wokis.commands.disconnect.DisconnectCommand -import es.wokis.commands.sounds.SoundsCommand import es.wokis.commands.sound.SoundCommand -import es.wokis.commands.reconnect.ReconnectCommand +import es.wokis.commands.sounds.SoundsCommand import es.wokis.commands.tts.TTSCommand -import es.wokis.commands.locale.LocaleCommand import es.wokis.services.commands.CommandHandlerServiceImpl import es.wokis.services.error.ErrorHandlerService import es.wokis.services.localization.LocalizationService @@ -43,6 +44,7 @@ class CommandHandlerServiceTest { private val localeCommand: LocaleCommand = mockk() private val localizationService: LocalizationService = mockk() private val radioGroupCommand: RadioGroupCommand = mockk() + private val configGroupCommand: ConfigGroupCommand = mockk() private val errorHandlerService: ErrorHandlerService = mockk() private val commandHandlerService = CommandHandlerServiceImpl( @@ -56,6 +58,7 @@ class CommandHandlerServiceTest { playerCommand = playerCommand, soundsCommand = soundsCommand, radioGroupCommand = radioGroupCommand, + configGroupCommand = configGroupCommand, reconnectCommand = reconnectCommand, nextCommand = nextCommand, disconnectCommand = disconnectCommand, diff --git a/src/test/kotlin/services/config/ConfigMigrationServiceTest.kt b/src/test/kotlin/services/config/ConfigMigrationServiceTest.kt new file mode 100644 index 0000000..6c99778 --- /dev/null +++ b/src/test/kotlin/services/config/ConfigMigrationServiceTest.kt @@ -0,0 +1,245 @@ +package es.wokis.services.config + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class ConfigMigrationServiceTest { + + // TODO: Refactor test to be shorter (issue: #detekt-suppress) + @Suppress("LongMethod", "ForbiddenComment") + @Test + fun `Given old config without hugging_chat When buildMergedJson is called Then return merged config`() { + // Given + val oldJson = JsonObject( + mapOf( + "discord_bot_token" to JsonPrimitive("test_token"), + "debug" to JsonPrimitive(false), + "database" to JsonObject( + mapOf( + "enabled" to JsonPrimitive(true), + "username" to JsonPrimitive("user"), + "password" to JsonPrimitive("pass"), + "database" to JsonPrimitive("db") + ) + ), + "youtube" to JsonObject( + mapOf( + "enabled" to JsonPrimitive(true) + ) + ), + "deezer" to JsonObject( + mapOf( + "enabled" to JsonPrimitive(false) + ) + ) + ) + ) + + val templateJson = JsonObject( + mapOf( + "discord_bot_token" to JsonPrimitive(""), + "debug" to JsonPrimitive(false), + "database" to JsonObject( + mapOf( + "enabled" to JsonPrimitive(false), + "username" to JsonPrimitive("db_username"), + "password" to JsonPrimitive("db_password"), + "database" to JsonPrimitive("db_database") + ) + ), + "youtube" to JsonObject( + mapOf( + "enabled" to JsonPrimitive(false), + "oauth2_token" to JsonPrimitive(null) + ) + ), + "deezer" to JsonObject( + mapOf( + "enabled" to JsonPrimitive(false), + "master_decryption_key" to JsonPrimitive(""), + "arl_token" to JsonPrimitive("") + ) + ), + "spotify" to JsonObject( + mapOf( + "enabled" to JsonPrimitive(false), + "client_id" to JsonPrimitive(""), + "client_secret" to JsonPrimitive(""), + "custom_endpoint" to JsonPrimitive("") + ) + ), + "tidal" to JsonObject( + mapOf( + "enabled" to JsonPrimitive(false), + "country_code" to JsonPrimitive("ES"), + "token" to JsonPrimitive("") + ) + ), + "kokoro" to JsonObject( + mapOf( + "enabled" to JsonPrimitive(false), + "base_url" to JsonPrimitive(""), + "default_voice" to JsonPrimitive("am_santa"), + "default_speed" to JsonPrimitive(1.0f), + "default_lang_code" to JsonPrimitive("en") + ) + ) + ) + ) + + // When + val result = JsonObjectBuilder.buildMergedJson(oldJson, templateJson) + + // Then + assertTrue(result.containsKey("discord_bot_token")) + assertTrue(result.containsKey("database")) + assertTrue(result.containsKey("youtube")) + assertTrue(result.containsKey("spotify")) + assertTrue(result.containsKey("tidal")) + assertTrue(result.containsKey("kokoro")) + } + + @Test + fun `Given old config with enabled section containing data When buildMergedJson is called Then enabled should be true`() { + // Given + val oldJson = JsonObject( + mapOf( + "discord_bot_token" to JsonPrimitive("test_token"), + "debug" to JsonPrimitive(false), + "kokoro" to JsonObject( + mapOf( + "base_url" to JsonPrimitive("http://localhost:5000"), + "default_voice" to JsonPrimitive("test_voice") + ) + ) + ) + ) + + val templateJson = JsonObject( + mapOf( + "discord_bot_token" to JsonPrimitive(""), + "debug" to JsonPrimitive(false), + "kokoro" to JsonObject( + mapOf( + "enabled" to JsonPrimitive(false), + "base_url" to JsonPrimitive(""), + "default_voice" to JsonPrimitive("am_santa"), + "default_speed" to JsonPrimitive(1.0f), + "default_lang_code" to JsonPrimitive("en") + ) + ) + ) + ) + + // When + val result = JsonObjectBuilder.buildMergedJson(oldJson, templateJson) + + // Then + val kokoro = result["kokoro"] as JsonObject + assertTrue(kokoro["enabled"]?.let { it is JsonPrimitive && it.content == "true" } ?: false) + assertEquals("http://localhost:5000", kokoro["base_url"]?.let { (it as JsonPrimitive).content }) + } + + @Test + fun `Given old config with empty section When buildMergedJson is called Then enabled should be template default`() { + // Given + val oldJson = JsonObject( + mapOf( + "discord_bot_token" to JsonPrimitive("test_token"), + "debug" to JsonPrimitive(false), + "kokoro" to JsonObject( + mapOf() + ) + ) + ) + + val templateJson = JsonObject( + mapOf( + "discord_bot_token" to JsonPrimitive(""), + "debug" to JsonPrimitive(false), + "kokoro" to JsonObject( + mapOf( + "enabled" to JsonPrimitive(false), + "base_url" to JsonPrimitive(""), + "default_voice" to JsonPrimitive("am_santa"), + "default_speed" to JsonPrimitive(1.0f), + "default_lang_code" to JsonPrimitive("en") + ) + ) + ) + ) + + // When + val result = JsonObjectBuilder.buildMergedJson(oldJson, templateJson) + + // Then + val kokoro = result["kokoro"] as JsonObject + assertTrue(kokoro["enabled"]?.let { it is JsonPrimitive && it.content == "false" } ?: false) + } + + @Test + fun `Given old config with new fields missing When buildMergedJson is called Then add missing fields from template`() { + // Given + val oldJson = JsonObject( + mapOf( + "discord_bot_token" to JsonPrimitive("test_token"), + "debug" to JsonPrimitive(false) + ) + ) + + val templateJson = JsonObject( + mapOf( + "discord_bot_token" to JsonPrimitive(""), + "debug" to JsonPrimitive(false), + "database" to JsonObject( + mapOf( + "enabled" to JsonPrimitive(false), + "username" to JsonPrimitive("db_username"), + "password" to JsonPrimitive("db_password"), + "database" to JsonPrimitive("db_database") + ) + ) + ) + ) + + // When + val result = JsonObjectBuilder.buildMergedJson(oldJson, templateJson) + + // Then + assertTrue(result.containsKey("database")) + val database = result["database"] as JsonObject + assertTrue(database["enabled"]?.let { it is JsonPrimitive && it.content == "false" } ?: false) + assertEquals("db_username", database["username"]?.let { (it as JsonPrimitive).content }) + } + + @Test + fun `Given old config with unknown keys When buildMergedJson is called Then remove unknown keys`() { + // Given + val oldJson = JsonObject( + mapOf( + "discord_bot_token" to JsonPrimitive("test_token"), + "debug" to JsonPrimitive(false), + "unknown_section" to JsonObject( + mapOf( + "key" to JsonPrimitive("value") + ) + ) + ) + ) + + val templateJson = JsonObject( + mapOf( + "discord_bot_token" to JsonPrimitive(""), + "debug" to JsonPrimitive(false) + ) + ) + + // When + val result = JsonObjectBuilder.buildMergedJson(oldJson, templateJson) + + // Then + assertFalse(result.containsKey("unknown_section")) + } +} diff --git a/src/test/kotlin/services/config/ConfigServiceTest.kt b/src/test/kotlin/services/config/ConfigServiceTest.kt index 7c71bac..4396cb3 100644 --- a/src/test/kotlin/services/config/ConfigServiceTest.kt +++ b/src/test/kotlin/services/config/ConfigServiceTest.kt @@ -47,7 +47,7 @@ class ConfigServiceTest { } @Test - fun `Given config with Deezer enabled but empty decryption key When config is validated Then throw EmptyDeezerMasterDecryptionKeyException`() { + fun `Given empty decryption key When Deezer validated Then throw exception`() { // Given val config = mockk(relaxed = true) { every { discordBotToken } returns "abc123" diff --git a/src/test/kotlin/services/lavaplayer/manager/KokoroAudioTrackTest.kt b/src/test/kotlin/services/lavaplayer/manager/KokoroAudioTrackTest.kt index 272894c..b8320b5 100644 --- a/src/test/kotlin/services/lavaplayer/manager/KokoroAudioTrackTest.kt +++ b/src/test/kotlin/services/lavaplayer/manager/KokoroAudioTrackTest.kt @@ -6,7 +6,6 @@ import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith -import kotlin.test.assertNotNull import kotlin.test.assertTrue class KokoroAudioTrackTest { diff --git a/src/test/kotlin/services/lavaplayer/manager/KokoroSourceManagerTest.kt b/src/test/kotlin/services/lavaplayer/manager/KokoroSourceManagerTest.kt index 923546c..4dbf2d0 100644 --- a/src/test/kotlin/services/lavaplayer/manager/KokoroSourceManagerTest.kt +++ b/src/test/kotlin/services/lavaplayer/manager/KokoroSourceManagerTest.kt @@ -3,15 +3,9 @@ package services.lavaplayer.manager import com.sedmelluq.discord.lavaplayer.player.DefaultAudioPlayerManager import com.sedmelluq.discord.lavaplayer.track.AudioReference import com.sedmelluq.discord.lavaplayer.track.AudioTrack -import io.mockk.every import io.mockk.mockk -import io.mockk.spyk -import org.apache.http.client.methods.CloseableHttpResponse -import org.apache.http.impl.client.CloseableHttpClient import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows import kotlin.test.assertEquals -import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue diff --git a/src/test/kotlin/services/lavaplayer/manager/PostAudioStreamTest.kt b/src/test/kotlin/services/lavaplayer/manager/PostAudioStreamTest.kt index 37b8c20..c305be6 100644 --- a/src/test/kotlin/services/lavaplayer/manager/PostAudioStreamTest.kt +++ b/src/test/kotlin/services/lavaplayer/manager/PostAudioStreamTest.kt @@ -4,11 +4,9 @@ import com.sedmelluq.discord.lavaplayer.tools.Units import org.apache.http.Header import org.apache.http.HeaderIterator import org.apache.http.HttpEntity -import org.apache.http.HttpResponse import org.apache.http.ProtocolVersion import org.apache.http.StatusLine import org.apache.http.client.methods.CloseableHttpResponse -import org.apache.http.message.BasicHeader import org.apache.http.params.HttpParams import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows @@ -38,32 +36,32 @@ class PostAudioStreamTest { closed = true } override fun getStatusLine(): StatusLine? = null - override fun setStatusLine(statusLine: StatusLine?) {} - override fun setStatusLine(ver: ProtocolVersion?, code: Int) {} - override fun setStatusLine(ver: ProtocolVersion?, code: Int, reason: String?) {} - override fun setReasonPhrase(reason: String?) {} + override fun setStatusLine(statusLine: StatusLine?) = Unit + override fun setStatusLine(ver: ProtocolVersion?, code: Int) = Unit + override fun setStatusLine(ver: ProtocolVersion?, code: Int, reason: String?) = Unit + override fun setReasonPhrase(reason: String?) = Unit override fun getEntity(): HttpEntity = entity - override fun setEntity(entity: HttpEntity?) {} + override fun setEntity(entity: HttpEntity?) = Unit override fun getLocale(): Locale? = null - override fun setLocale(loc: Locale?) {} + override fun setLocale(loc: Locale?) = Unit override fun getProtocolVersion(): ProtocolVersion? = null override fun containsHeader(name: String?): Boolean = false override fun getHeaders(name: String?): Array
= emptyArray() override fun getFirstHeader(name: String?): Header? = null override fun getLastHeader(name: String?): Header? = null override fun getAllHeaders(): Array
= emptyArray() - override fun addHeader(header: Header?) {} - override fun addHeader(name: String?, value: String?) {} - override fun setHeader(header: Header?) {} - override fun setHeader(name: String?, value: String?) {} - override fun setHeaders(headers: Array?) {} - override fun removeHeader(header: Header?) {} - override fun removeHeaders(name: String?) {} + override fun addHeader(header: Header?) = Unit + override fun addHeader(name: String?, value: String?) = Unit + override fun setHeader(header: Header?) = Unit + override fun setHeader(name: String?, value: String?) = Unit + override fun setHeaders(headers: Array?) = Unit + override fun removeHeader(header: Header?) = Unit + override fun removeHeaders(name: String?) = Unit override fun headerIterator(): HeaderIterator = createHeaderIterator() override fun headerIterator(name: String?): HeaderIterator = createHeaderIterator() override fun getParams(): HttpParams? = null - override fun setParams(params: HttpParams?) {} - override fun setStatusCode(code: Int) {} + override fun setParams(params: HttpParams?) = Unit + override fun setStatusCode(code: Int) = Unit } private fun createEntity(testData: ByteArray): HttpEntity = object : HttpEntity { @@ -73,11 +71,11 @@ class PostAudioStreamTest { override fun getContentType() = null override fun getContentEncoding() = null override fun getContent() = ByteArrayInputStream(testData) - override fun writeTo(out: OutputStream?) {} + override fun writeTo(out: OutputStream?) = Unit override fun isStreaming() = true @Deprecated("Deprecated in Java") - override fun consumeContent() {} + override fun consumeContent() = Unit } private fun createEntityWithCustomStream(stream: InputStream): HttpEntity = object : HttpEntity { @@ -87,11 +85,11 @@ class PostAudioStreamTest { override fun getContentType() = null override fun getContentEncoding() = null override fun getContent() = stream - override fun writeTo(out: OutputStream?) {} + override fun writeTo(out: OutputStream?) = Unit override fun isStreaming() = true @Deprecated("Deprecated in Java") - override fun consumeContent() {} + override fun consumeContent() = Unit } @Test @@ -209,32 +207,32 @@ class PostAudioStreamTest { responseClosed = true } override fun getStatusLine(): StatusLine? = null - override fun setStatusLine(statusLine: StatusLine?) {} - override fun setStatusLine(ver: ProtocolVersion?, code: Int) {} - override fun setStatusLine(ver: ProtocolVersion?, code: Int, reason: String?) {} - override fun setReasonPhrase(reason: String?) {} + override fun setStatusLine(statusLine: StatusLine?) = Unit + override fun setStatusLine(ver: ProtocolVersion?, code: Int) = Unit + override fun setStatusLine(ver: ProtocolVersion?, code: Int, reason: String?) = Unit + override fun setReasonPhrase(reason: String?) = Unit override fun getEntity() = createEntityWithCustomStream(customStream) - override fun setEntity(entity: HttpEntity?) {} + override fun setEntity(entity: HttpEntity?) = Unit override fun getLocale(): Locale? = null - override fun setLocale(loc: Locale?) {} + override fun setLocale(loc: Locale?) = Unit override fun getProtocolVersion(): ProtocolVersion? = null override fun containsHeader(name: String?): Boolean = false override fun getHeaders(name: String?): Array
= emptyArray() override fun getFirstHeader(name: String?): Header? = null override fun getLastHeader(name: String?): Header? = null override fun getAllHeaders(): Array
= emptyArray() - override fun addHeader(header: Header?) {} - override fun addHeader(name: String?, value: String?) {} - override fun setHeader(header: Header?) {} - override fun setHeader(name: String?, value: String?) {} - override fun setHeaders(headers: Array?) {} - override fun removeHeader(header: Header?) {} - override fun removeHeaders(name: String?) {} + override fun addHeader(header: Header?) = Unit + override fun addHeader(name: String?, value: String?) = Unit + override fun setHeader(header: Header?) = Unit + override fun setHeader(name: String?, value: String?) = Unit + override fun setHeaders(headers: Array?) = Unit + override fun removeHeader(header: Header?) = Unit + override fun removeHeaders(name: String?) = Unit override fun headerIterator(): HeaderIterator = createHeaderIterator() override fun headerIterator(name: String?): HeaderIterator = createHeaderIterator() override fun getParams(): HttpParams? = null - override fun setParams(params: HttpParams?) {} - override fun setStatusCode(code: Int) {} + override fun setParams(params: HttpParams?) = Unit + override fun setStatusCode(code: Int) = Unit } val stream = PostAudioStream(response, 100L) @@ -357,32 +355,32 @@ class PostAudioStreamTest { responseClosed = true } override fun getStatusLine(): StatusLine? = null - override fun setStatusLine(statusLine: StatusLine?) {} - override fun setStatusLine(ver: ProtocolVersion?, code: Int) {} - override fun setStatusLine(ver: ProtocolVersion?, code: Int, reason: String?) {} - override fun setReasonPhrase(reason: String?) {} + override fun setStatusLine(statusLine: StatusLine?) = Unit + override fun setStatusLine(ver: ProtocolVersion?, code: Int) = Unit + override fun setStatusLine(ver: ProtocolVersion?, code: Int, reason: String?) = Unit + override fun setReasonPhrase(reason: String?) = Unit override fun getEntity() = createEntityWithCustomStream(customStream) - override fun setEntity(entity: HttpEntity?) {} + override fun setEntity(entity: HttpEntity?) = Unit override fun getLocale(): Locale? = null - override fun setLocale(loc: Locale?) {} + override fun setLocale(loc: Locale?) = Unit override fun getProtocolVersion(): ProtocolVersion? = null override fun containsHeader(name: String?): Boolean = false override fun getHeaders(name: String?): Array
= emptyArray() override fun getFirstHeader(name: String?): Header? = null override fun getLastHeader(name: String?): Header? = null override fun getAllHeaders(): Array
= emptyArray() - override fun addHeader(header: Header?) {} - override fun addHeader(name: String?, value: String?) {} - override fun setHeader(header: Header?) {} - override fun setHeader(name: String?, value: String?) {} - override fun setHeaders(headers: Array?) {} - override fun removeHeader(header: Header?) {} - override fun removeHeaders(name: String?) {} + override fun addHeader(header: Header?) = Unit + override fun addHeader(name: String?, value: String?) = Unit + override fun setHeader(header: Header?) = Unit + override fun setHeader(name: String?, value: String?) = Unit + override fun setHeaders(headers: Array?) = Unit + override fun removeHeader(header: Header?) = Unit + override fun removeHeaders(name: String?) = Unit override fun headerIterator(): HeaderIterator = createHeaderIterator() override fun headerIterator(name: String?): HeaderIterator = createHeaderIterator() override fun getParams(): HttpParams? = null - override fun setParams(params: HttpParams?) {} - override fun setStatusCode(code: Int) {} + override fun setParams(params: HttpParams?) = Unit + override fun setStatusCode(code: Int) = Unit } val stream = PostAudioStream(response, 100L) @@ -399,37 +397,37 @@ class PostAudioStreamTest { val customStream = object : InputStream() { override fun read() = -1 - override fun close() {} + override fun close() = Unit } val response = object : CloseableHttpResponse { override fun close() = throw IOException("Response close failed") override fun getStatusLine(): StatusLine? = null - override fun setStatusLine(statusLine: StatusLine?) {} - override fun setStatusLine(ver: ProtocolVersion?, code: Int) {} - override fun setStatusLine(ver: ProtocolVersion?, code: Int, reason: String?) {} - override fun setReasonPhrase(reason: String?) {} + override fun setStatusLine(statusLine: StatusLine?) = Unit + override fun setStatusLine(ver: ProtocolVersion?, code: Int) = Unit + override fun setStatusLine(ver: ProtocolVersion?, code: Int, reason: String?) = Unit + override fun setReasonPhrase(reason: String?) = Unit override fun getEntity() = createEntityWithCustomStream(customStream) - override fun setEntity(entity: HttpEntity?) {} + override fun setEntity(entity: HttpEntity?) = Unit override fun getLocale(): Locale? = null - override fun setLocale(loc: Locale?) {} + override fun setLocale(loc: Locale?) = Unit override fun getProtocolVersion(): ProtocolVersion? = null override fun containsHeader(name: String?): Boolean = false override fun getHeaders(name: String?): Array
= emptyArray() override fun getFirstHeader(name: String?): Header? = null override fun getLastHeader(name: String?): Header? = null override fun getAllHeaders(): Array
= emptyArray() - override fun addHeader(header: Header?) {} - override fun addHeader(name: String?, value: String?) {} - override fun setHeader(header: Header?) {} - override fun setHeader(name: String?, value: String?) {} - override fun setHeaders(headers: Array?) {} - override fun removeHeader(header: Header?) {} - override fun removeHeaders(name: String?) {} + override fun addHeader(header: Header?) = Unit + override fun addHeader(name: String?, value: String?) = Unit + override fun setHeader(header: Header?) = Unit + override fun setHeader(name: String?, value: String?) = Unit + override fun setHeaders(headers: Array?) = Unit + override fun removeHeader(header: Header?) = Unit + override fun removeHeaders(name: String?) = Unit override fun headerIterator(): HeaderIterator = createHeaderIterator() override fun headerIterator(name: String?): HeaderIterator = createHeaderIterator() override fun getParams(): HttpParams? = null - override fun setParams(params: HttpParams?) {} - override fun setStatusCode(code: Int) {} + override fun setParams(params: HttpParams?) = Unit + override fun setStatusCode(code: Int) = Unit } val stream = PostAudioStream(response, 100L) diff --git a/src/test/kotlin/services/localization/LocalizationServiceTest.kt b/src/test/kotlin/services/localization/LocalizationServiceTest.kt index 13e7801..e50d8c5 100644 --- a/src/test/kotlin/services/localization/LocalizationServiceTest.kt +++ b/src/test/kotlin/services/localization/LocalizationServiceTest.kt @@ -145,7 +145,11 @@ class LocalizationServiceTest { val result = "esta string tiene argumentos, yay!" // When - val actual = localizationService.getStringFormat(key, discordLocale = Locale.SPANISH_SPAIN, arguments = arrayOf("yay!")) + val actual = localizationService.getStringFormat( + key, + discordLocale = Locale.SPANISH_SPAIN, + arguments = arrayOf("yay!") + ) // Then assertEquals(result, actual) @@ -173,7 +177,11 @@ class LocalizationServiceTest { val result = "this string has arguments, yay!" // When - val actual = localizationService.getStringFormat(key, discordLocale = Locale.FRENCH, arguments = arrayOf("yay!")) + val actual = localizationService.getStringFormat( + key, + discordLocale = Locale.FRENCH, + arguments = arrayOf("yay!") + ) // Then assertEquals(result, actual) diff --git a/src/test/kotlin/services/processor/MessageProcessorServiceTest.kt b/src/test/kotlin/services/processor/MessageProcessorServiceTest.kt index bda70c6..b143641 100644 --- a/src/test/kotlin/services/processor/MessageProcessorServiceTest.kt +++ b/src/test/kotlin/services/processor/MessageProcessorServiceTest.kt @@ -184,7 +184,10 @@ class MessageProcessorServiceTest { } } // Note: Query parameters are kept for girlcockx.com as they are required for embeds - val editedMessage = "Post enviado por $authorMention con el enlace arreglado:\nhttps://girlcockx.com/elonmusk/status/12345?t=abc123&s=19" + val editedMessage = buildString { + append("Post enviado por $authorMention con el enlace arreglado:\n") + append("https://girlcockx.com/elonmusk/status/12345?t=abc123&s=19") + } coEvery { localizationService.getStringFormat(any(), any(), any(), *anyVararg()) } returns editedMessage coJustRun { message.delete() } diff --git a/src/test/kotlin/services/queue/GuildQueueServiceTest.kt b/src/test/kotlin/services/queue/GuildQueueServiceTest.kt index fc22d48..378b76f 100644 --- a/src/test/kotlin/services/queue/GuildQueueServiceTest.kt +++ b/src/test/kotlin/services/queue/GuildQueueServiceTest.kt @@ -8,10 +8,10 @@ import dev.kord.core.behavior.channel.BaseVoiceChannelBehavior import dev.kord.core.entity.channel.MessageChannel import dev.kord.core.entity.interaction.ApplicationCommandInteraction import dev.kord.core.supplier.EntitySupplyStrategy +import es.wokis.exceptions.BotException import es.wokis.services.lavaplayer.AudioPlayerManagerProvider import es.wokis.services.lavaplayer.GuildLavaPlayerService import es.wokis.services.localization.LocalizationService -import es.wokis.exceptions.BotException import es.wokis.services.queue.GuildQueueService import io.mockk.coEvery import io.mockk.every @@ -64,7 +64,7 @@ class GuildQueueServiceTest { } @Test - fun `Given dispatcher When getOrCreateLavaPlayerService is called for two different guilds Then create and return GuildLavaPlayer for each Guild`() { + fun `Given two guilds When getOrCreateLavaPlayerService Then create separate players`() { // Given val guildId1 = Snowflake(123) val guildId2 = Snowflake(456) @@ -95,7 +95,7 @@ class GuildQueueServiceTest { } @Test - fun `Given dispatcher When getOrCreateLavaPlayerService is called for the same guild twice Then create and return the same GuildLavaPlayer`() { + fun `Given same guild twice When getOrCreateLavaPlayerService Then return same player`() { // Given val guildId1 = Snowflake(123) val guildId2 = Snowflake(123) diff --git a/src/test/kotlin/services/radio/RadioServiceTest.kt b/src/test/kotlin/services/radio/RadioServiceTest.kt index 5319793..69794b3 100644 --- a/src/test/kotlin/services/radio/RadioServiceTest.kt +++ b/src/test/kotlin/services/radio/RadioServiceTest.kt @@ -1,13 +1,11 @@ package services.radio -import es.wokis.data.radio.RadioDTO import es.wokis.data.response.RemoteResponse import es.wokis.services.config.ConfigService import es.wokis.services.radio.RadioService import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.test.runTest -import kotlinx.serialization.json.Json import mock.getMockedHttpClient import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue diff --git a/src/test/kotlin/utils/InteractionExtensionsTest.kt b/src/test/kotlin/utils/InteractionExtensionsTest.kt index 4ddc675..e2f89dc 100644 --- a/src/test/kotlin/utils/InteractionExtensionsTest.kt +++ b/src/test/kotlin/utils/InteractionExtensionsTest.kt @@ -145,7 +145,7 @@ class InteractionExtensionsTest { } @Test - fun `Given interaction with member with voice state without present voice channel When getMemberVoiceChannel is called Then return null`() = runTest { + fun `Given no voice channel When getMemberVoiceChannel Then return null`() = runTest { // Given val interaction = mockk { every { data } returns mockk { diff --git a/src/test/kotlin/utils/UrlTransformerTest.kt b/src/test/kotlin/utils/UrlTransformerTest.kt index 753e40a..7f6ea88 100644 --- a/src/test/kotlin/utils/UrlTransformerTest.kt +++ b/src/test/kotlin/utils/UrlTransformerTest.kt @@ -1,6 +1,5 @@ package utils -import es.wokis.utils.UrlTransformer import es.wokis.utils.transformUrl import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test