├── src └── main │ ├── resources │ ├── application.properties │ └── db │ │ └── migration │ │ ├── V2__custom-prefixes.sql │ │ └── V1__volume.sql │ └── kotlin │ └── dev │ └── arbjerg │ └── ukulele │ ├── audio │ ├── track.kt │ ├── PlayerRegistry.kt │ ├── LavaplayerConfig.kt │ ├── TrackQueue.kt │ └── Player.kt │ ├── config │ ├── BotProps.kt │ └── DatabaseConfig.kt │ ├── UkuleleApplication.kt │ ├── command │ ├── SayCommand.kt │ ├── ShuffleCommand.kt │ ├── ResumeCommand.kt │ ├── PauseCommand.kt │ ├── RepeatCommand.kt │ ├── StopCommand.kt │ ├── VolumeCommand.kt │ ├── HelpCommand.kt │ ├── PrefixCommand.kt │ ├── SkipCommand.kt │ ├── QueueCommand.kt │ ├── NowPlayingCommand.kt │ ├── SeekCommand.kt │ └── PlayCommand.kt │ ├── jda │ ├── Command.kt │ ├── EventHandler.kt │ ├── CommandContext.kt │ ├── JdaConfig.kt │ └── CommandManager.kt │ ├── utils │ └── TextUtils.kt │ ├── features │ └── HelpContext.kt │ └── data │ └── guildProperties.kt ├── gradle.properties ├── settings.gradle.kts ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── ukulele ├── ukulele.bat ├── Dockerfile ├── docker-compose.yml ├── ukulele.example.yml ├── .gitignore ├── LICENSE ├── .teamcity ├── settings.kts └── pom.xml ├── .github └── workflows │ └── build.yml ├── README.md ├── gradlew.bat ├── CONTRIBUTING.md └── gradlew /src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx256m 2 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "ukulele" 2 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freyacodes/ukulele/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /ukulele: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | ./gradlew --no-daemon build 3 | cd `dirname "$0"` 4 | java -jar "build/libs/ukulele.jar" 5 | -------------------------------------------------------------------------------- /src/main/resources/db/migration/V2__custom-prefixes.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE guild_properties 2 | ADD COLUMN prefix char default null; -------------------------------------------------------------------------------- /src/main/resources/db/migration/V1__volume.sql: -------------------------------------------------------------------------------- 1 | create table guild_properties 2 | ( 3 | guild_id bigint not null, 4 | volume int not null default 100 5 | ); 6 | -------------------------------------------------------------------------------- /ukulele.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | ::Use the -d flag to run the bot in detached mode. 3 | 4 | call gradlew.bat --no-daemon build 5 | if "%1"=="-d" (start javaw -jar "build\libs\ukulele.jar") else (java -jar "build\libs\ukulele.jar") 6 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-6.6.1-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM openjdk:11 2 | RUN groupadd -r -g 999 ukulele && useradd -rd /opt/ukulele -g ukulele -u 999 -ms /bin/bash ukulele 3 | COPY --chown=ukulele:ukulele build/libs/ukulele.jar /opt/ukulele/ukulele.jar 4 | USER ukulele 5 | WORKDIR /opt/ukulele/ 6 | ENTRYPOINT ["java", "-jar", "/opt/ukulele/ukulele.jar"] -------------------------------------------------------------------------------- /src/main/kotlin/dev/arbjerg/ukulele/audio/track.kt: -------------------------------------------------------------------------------- 1 | package dev.arbjerg.ukulele.audio 2 | 3 | import com.sedmelluq.discord.lavaplayer.track.AudioTrack 4 | 5 | var AudioTrack.meta: TrackMeta 6 | get() = this.userData as TrackMeta 7 | set(data) { this.userData = data } 8 | 9 | class TrackMeta 10 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | 3 | services: 4 | ukulele: 5 | image: ghcr.io/freyacodes/ukulele:master 6 | restart: always 7 | environment: 8 | CONFIG_DATABASE: ./db/database # Database location overwrite so mounting works 9 | volumes: 10 | - ./ukulele.yml:/opt/ukulele/ukulele.yml 11 | - ./db:/opt/ukulele/db 12 | -------------------------------------------------------------------------------- /ukulele.example.yml: -------------------------------------------------------------------------------- 1 | config: 2 | token: "" # Your discord bot token 3 | shards: 1 # Number of shards to create. 1 works for most bots 4 | prefix: "::" # Prefix to invoke commands with 5 | database: "./database" # Database filename 6 | game: "" # Status message shown when your bot is online 7 | trackDurationLimit: 0 # Maximum limit of track duration in minutes. Set to 0 for unlimited 8 | announceTracks: false # Announce the start of a track 9 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/arbjerg/ukulele/config/BotProps.kt: -------------------------------------------------------------------------------- 1 | package dev.arbjerg.ukulele.config 2 | 3 | import org.springframework.boot.context.properties.ConfigurationProperties 4 | 5 | @ConfigurationProperties("config") 6 | class BotProps( 7 | var token: String = "", 8 | var shards: Int = 1, 9 | var prefix: String = "::", 10 | var database: String = "./database", 11 | var game: String = "", 12 | var trackDurationLimit: Int = 0, 13 | var announceTracks: Boolean = false 14 | ) -------------------------------------------------------------------------------- /src/main/kotlin/dev/arbjerg/ukulele/audio/PlayerRegistry.kt: -------------------------------------------------------------------------------- 1 | package dev.arbjerg.ukulele.audio 2 | 3 | import dev.arbjerg.ukulele.data.GuildProperties 4 | import net.dv8tion.jda.api.entities.Guild 5 | import org.springframework.stereotype.Service 6 | 7 | @Service 8 | class PlayerRegistry(val playerBeans: Player.Beans) { 9 | 10 | private val players = mutableMapOf() 11 | 12 | fun get(guild: Guild, guildProperties: GuildProperties) = players.computeIfAbsent(guild.idLong) { Player(playerBeans, guildProperties) } 13 | 14 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/arbjerg/ukulele/UkuleleApplication.kt: -------------------------------------------------------------------------------- 1 | package dev.arbjerg.ukulele 2 | 3 | import org.springframework.boot.autoconfigure.SpringBootApplication 4 | import org.springframework.boot.context.properties.ConfigurationPropertiesScan 5 | import org.springframework.boot.runApplication 6 | 7 | @SpringBootApplication 8 | @ConfigurationPropertiesScan 9 | class UkuleleApplication 10 | 11 | fun main(args: Array) { 12 | System.setProperty("spring.config.name", "ukulele") 13 | System.setProperty("spring.config.title", "ukulele") 14 | runApplication(*args) 15 | } 16 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/arbjerg/ukulele/command/SayCommand.kt: -------------------------------------------------------------------------------- 1 | package dev.arbjerg.ukulele.command 2 | 3 | import dev.arbjerg.ukulele.features.HelpContext 4 | import dev.arbjerg.ukulele.jda.Command 5 | import dev.arbjerg.ukulele.jda.CommandContext 6 | import org.springframework.stereotype.Component 7 | 8 | @Component 9 | class SayCommand : Command("say") { 10 | override suspend fun CommandContext.invoke() { 11 | reply(argumentText) 12 | } 13 | 14 | override fun HelpContext.provideHelp() { 15 | addUsage("") 16 | addDescription("Repeats the given text") 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.mv.db 2 | *.trace.db 3 | HELP.md 4 | .gradle 5 | build/ 6 | !gradle/wrapper/gradle-wrapper.jar 7 | !**/src/main/**/build/ 8 | !**/src/test/**/build/ 9 | ukulele.yml 10 | 11 | ### STS ### 12 | .apt_generated 13 | .classpath 14 | .factorypath 15 | .project 16 | .settings 17 | .springBeans 18 | .sts4-cache 19 | bin/ 20 | !**/src/main/**/bin/ 21 | !**/src/test/**/bin/ 22 | 23 | ### IntelliJ IDEA ### 24 | .idea 25 | *.iws 26 | *.iml 27 | *.ipr 28 | out/ 29 | !**/src/main/**/out/ 30 | !**/src/test/**/out/ 31 | 32 | ### NetBeans ### 33 | /nbproject/private/ 34 | /nbbuild/ 35 | /dist/ 36 | /nbdist/ 37 | /.nb-gradle/ 38 | 39 | ### VS Code ### 40 | .vscode/ 41 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/arbjerg/ukulele/jda/Command.kt: -------------------------------------------------------------------------------- 1 | package dev.arbjerg.ukulele.jda 2 | 3 | import dev.arbjerg.ukulele.features.HelpContext 4 | import org.slf4j.Logger 5 | import org.slf4j.LoggerFactory 6 | 7 | abstract class Command(val name: String, vararg val aliases: String) { 8 | 9 | val log: Logger = LoggerFactory.getLogger(javaClass) 10 | 11 | suspend fun invoke0(ctx: CommandContext) { 12 | ctx.apply { invoke() } 13 | } 14 | 15 | fun provideHelp0(ctx: HelpContext) { 16 | ctx.apply { provideHelp() } 17 | } 18 | 19 | abstract suspend fun CommandContext.invoke() 20 | abstract fun HelpContext.provideHelp() 21 | 22 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/arbjerg/ukulele/command/ShuffleCommand.kt: -------------------------------------------------------------------------------- 1 | package dev.arbjerg.ukulele.command 2 | 3 | import dev.arbjerg.ukulele.features.HelpContext 4 | import dev.arbjerg.ukulele.jda.Command 5 | import dev.arbjerg.ukulele.jda.CommandContext 6 | import org.springframework.stereotype.Component 7 | 8 | @Component 9 | class ShuffleCommand : Command("shuffle") { 10 | override suspend fun CommandContext.invoke() { 11 | player.shuffle() 12 | reply("This list has been shuffled.") 13 | } 14 | 15 | override fun HelpContext.provideHelp() { 16 | addUsage("") 17 | addDescription("Shuffles the remaining tracks in the list.") 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/arbjerg/ukulele/audio/LavaplayerConfig.kt: -------------------------------------------------------------------------------- 1 | package dev.arbjerg.ukulele.audio 2 | 3 | import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager 4 | import com.sedmelluq.discord.lavaplayer.player.DefaultAudioPlayerManager 5 | import com.sedmelluq.discord.lavaplayer.source.AudioSourceManagers 6 | import org.springframework.context.annotation.Bean 7 | import org.springframework.context.annotation.Configuration 8 | 9 | @Configuration 10 | class LavaplayerConfig { 11 | 12 | @Bean 13 | fun playerManager(): AudioPlayerManager { 14 | val apm = DefaultAudioPlayerManager() 15 | AudioSourceManagers.registerRemoteSources(apm) 16 | return apm 17 | } 18 | 19 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/arbjerg/ukulele/command/ResumeCommand.kt: -------------------------------------------------------------------------------- 1 | package dev.arbjerg.ukulele.command 2 | 3 | import dev.arbjerg.ukulele.features.HelpContext 4 | import dev.arbjerg.ukulele.jda.Command 5 | import dev.arbjerg.ukulele.jda.CommandContext 6 | import org.springframework.stereotype.Component 7 | 8 | @Component 9 | class ResumeCommand : Command ("resume") { 10 | override suspend fun CommandContext.invoke() { 11 | if (!player.isPaused) return reply("Player is already playing.") 12 | 13 | player.resume() 14 | reply("Playback has been resumed.") 15 | } 16 | 17 | override fun HelpContext.provideHelp() { 18 | addUsage("") 19 | addDescription("Resumes the playback.") 20 | } 21 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/arbjerg/ukulele/command/PauseCommand.kt: -------------------------------------------------------------------------------- 1 | package dev.arbjerg.ukulele.command 2 | 3 | import dev.arbjerg.ukulele.features.HelpContext 4 | import dev.arbjerg.ukulele.jda.Command 5 | import dev.arbjerg.ukulele.jda.CommandContext 6 | import org.springframework.stereotype.Component 7 | 8 | @Component 9 | class PauseCommand : Command ("pause") { 10 | override suspend fun CommandContext.invoke() { 11 | if (player.isPaused) return reply("Player already paused. Use `resume` to continue playback.") 12 | 13 | player.pause() 14 | reply("Playback has been paused.") 15 | } 16 | 17 | override fun HelpContext.provideHelp() { 18 | addUsage("") 19 | addDescription("Pauses the playback.") 20 | } 21 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/arbjerg/ukulele/utils/TextUtils.kt: -------------------------------------------------------------------------------- 1 | package dev.arbjerg.ukulele.utils 2 | 3 | import java.util.concurrent.TimeUnit 4 | 5 | object TextUtils { 6 | fun humanReadableTime(length: Long): String { 7 | return if (length < 3600000) { 8 | String.format("%02d:%02d", TimeUnit.MILLISECONDS.toMinutes(length), 9 | TimeUnit.MILLISECONDS.toSeconds(length) % TimeUnit.MINUTES.toSeconds(1)) 10 | } else { 11 | String.format("%02d:%02d:%02d", TimeUnit.MILLISECONDS.toHours(length), 12 | TimeUnit.MILLISECONDS.toMinutes(length) % TimeUnit.HOURS.toMinutes(1), 13 | TimeUnit.MILLISECONDS.toSeconds(length) % TimeUnit.MINUTES.toSeconds(1)) 14 | } 15 | } 16 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/arbjerg/ukulele/command/RepeatCommand.kt: -------------------------------------------------------------------------------- 1 | package dev.arbjerg.ukulele.command 2 | 3 | import dev.arbjerg.ukulele.features.HelpContext 4 | import dev.arbjerg.ukulele.jda.Command 5 | import dev.arbjerg.ukulele.jda.CommandContext 6 | import org.springframework.stereotype.Component 7 | 8 | @Component 9 | class RepeatCommand : Command ("repeat", "loop", "r") { 10 | override suspend fun CommandContext.invoke() { 11 | player.isRepeating = !player.isRepeating 12 | if (player.isRepeating) {reply("Repeating is now enabled.")} 13 | else{reply("Repeating is now disabled.")} 14 | } 15 | 16 | override fun HelpContext.provideHelp() { 17 | addUsage("") 18 | addDescription("Toggles the repeat of the queue.") 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/arbjerg/ukulele/command/StopCommand.kt: -------------------------------------------------------------------------------- 1 | package dev.arbjerg.ukulele.command 2 | 3 | import dev.arbjerg.ukulele.features.HelpContext 4 | import dev.arbjerg.ukulele.jda.Command 5 | import dev.arbjerg.ukulele.jda.CommandContext 6 | import org.springframework.stereotype.Component 7 | 8 | @Component 9 | class StopCommand : Command("stop") { 10 | override suspend fun CommandContext.invoke() { 11 | val skipped = player.tracks.size 12 | 13 | player.stop() 14 | guild.audioManager.closeAudioConnection() 15 | 16 | reply("Player stopped. Removed **$skipped** tracks.") 17 | } 18 | 19 | override fun HelpContext.provideHelp() { 20 | addUsage("") 21 | addDescription("Clear all tracks from the queue and disconnect the player.") 22 | } 23 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/arbjerg/ukulele/audio/TrackQueue.kt: -------------------------------------------------------------------------------- 1 | package dev.arbjerg.ukulele.audio 2 | 3 | import com.sedmelluq.discord.lavaplayer.track.AudioTrack 4 | 5 | class TrackQueue { 6 | 7 | private val queue = mutableListOf() 8 | val tracks: List get() = queue 9 | val duration: Long get() = queue.filterNot { it.info.isStream }.sumOf { it.info.length } // Streams don't have a valid time. 10 | 11 | fun add(vararg tracks: AudioTrack) { queue.addAll(tracks) } 12 | fun take() = queue.removeFirstOrNull() 13 | fun peek() = queue.firstOrNull() 14 | fun clear() = queue.clear() 15 | 16 | fun removeRange(range: IntRange): List { 17 | val list = queue.slice(range) 18 | queue.removeAll(list) 19 | return list 20 | } 21 | 22 | fun shuffle() { 23 | queue.shuffle() 24 | } 25 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/arbjerg/ukulele/features/HelpContext.kt: -------------------------------------------------------------------------------- 1 | package dev.arbjerg.ukulele.features 2 | 3 | import dev.arbjerg.ukulele.jda.Command 4 | import dev.arbjerg.ukulele.jda.CommandContext 5 | import net.dv8tion.jda.api.MessageBuilder 6 | 7 | class HelpContext(private val commandContext: CommandContext, private val command: Command) { 8 | private val lines = mutableListOf() 9 | 10 | fun addUsage(usage: String) = addUsages(usage) 11 | 12 | fun addUsages(vararg usages: String) { 13 | if (usages.isEmpty()) throw IllegalArgumentException("Expected at least one usage!") 14 | lines.add(usages.joinToString(" OR ") { 15 | commandContext.prefix + command.name + " " + it.trim() 16 | }) 17 | } 18 | 19 | fun addDescription(text: String) { 20 | lines.add("# " + text.trim()) 21 | } 22 | 23 | fun buildMessage() = MessageBuilder() 24 | .appendCodeBlock(lines.joinToString(separator = "\n"), "md") 25 | .build() 26 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/arbjerg/ukulele/config/DatabaseConfig.kt: -------------------------------------------------------------------------------- 1 | package dev.arbjerg.ukulele.config 2 | 3 | import io.r2dbc.h2.H2ConnectionConfiguration 4 | import io.r2dbc.h2.H2ConnectionFactory 5 | import io.r2dbc.spi.ConnectionFactory 6 | import org.flywaydb.core.Flyway 7 | import org.springframework.context.annotation.Bean 8 | import org.springframework.context.annotation.Configuration 9 | import org.springframework.core.env.Environment 10 | 11 | @Configuration 12 | class DatabaseConfig(private val botProps: BotProps, private val env: Environment) { 13 | 14 | @Bean 15 | fun connectionFactory(): ConnectionFactory = H2ConnectionFactory(H2ConnectionConfiguration.builder() 16 | .file(botProps.database + ";DATABASE_TO_UPPER=false") 17 | .build()) 18 | 19 | @Bean(initMethod = "migrate") 20 | fun flyway(): Flyway { 21 | return Flyway(Flyway.configure().dataSource( 22 | "jdbc:h2:" + botProps.database + ";DATABASE_TO_UPPER=false", 23 | "", 24 | "" 25 | )) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/arbjerg/ukulele/jda/EventHandler.kt: -------------------------------------------------------------------------------- 1 | package dev.arbjerg.ukulele.jda 2 | 3 | import net.dv8tion.jda.api.events.ReadyEvent 4 | import net.dv8tion.jda.api.events.StatusChangeEvent 5 | import net.dv8tion.jda.api.events.message.guild.GuildMessageReceivedEvent 6 | import net.dv8tion.jda.api.hooks.ListenerAdapter 7 | import org.slf4j.Logger 8 | import org.slf4j.LoggerFactory 9 | import org.springframework.stereotype.Service 10 | 11 | @Service 12 | class EventHandler(private val commandManager: CommandManager) : ListenerAdapter() { 13 | 14 | private val log: Logger = LoggerFactory.getLogger(EventHandler::class.java) 15 | 16 | override fun onGuildMessageReceived(event: GuildMessageReceivedEvent) { 17 | if (event.isWebhookMessage || event.author.isBot) return 18 | commandManager.onMessage(event.guild, event.channel, event.member!!, event.message) 19 | } 20 | 21 | override fun onStatusChange(event: StatusChangeEvent) { 22 | log.info("{}: {} -> {}", event.entity.shardInfo, event.oldStatus, event.newStatus) 23 | } 24 | 25 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 F. Arbjerg and contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/arbjerg/ukulele/command/VolumeCommand.kt: -------------------------------------------------------------------------------- 1 | package dev.arbjerg.ukulele.command 2 | 3 | import dev.arbjerg.ukulele.audio.PlayerRegistry 4 | import dev.arbjerg.ukulele.features.HelpContext 5 | import dev.arbjerg.ukulele.jda.Command 6 | import dev.arbjerg.ukulele.jda.CommandContext 7 | import org.springframework.stereotype.Component 8 | 9 | @Component 10 | class VolumeCommand(val players: PlayerRegistry) : Command("volume", "v") { 11 | override suspend fun CommandContext.invoke() { 12 | if (argumentText.isBlank()) return reply("The volume is set to ${player.volume}%.") 13 | 14 | val num = argumentText.removeSuffix("%") 15 | .toIntOrNull() 16 | ?: return replyHelp() 17 | 18 | val formerVolume = player.volume 19 | player.volume = num 20 | reply("Changed volume from ${formerVolume}% to ${player.volume}%.") 21 | } 22 | 23 | override fun HelpContext.provideHelp() { 24 | addUsage("") 25 | addDescription("Displays the current volume.") 26 | addUsage("<0-150>%") 27 | addDescription("Sets the volume to the given percentage.") 28 | } 29 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/arbjerg/ukulele/command/HelpCommand.kt: -------------------------------------------------------------------------------- 1 | package dev.arbjerg.ukulele.command 2 | 3 | import dev.arbjerg.ukulele.features.HelpContext 4 | import dev.arbjerg.ukulele.jda.Command 5 | import dev.arbjerg.ukulele.jda.CommandContext 6 | import net.dv8tion.jda.api.MessageBuilder 7 | import org.springframework.stereotype.Component 8 | 9 | @Component 10 | class HelpCommand : Command("help") { 11 | override suspend fun CommandContext.invoke() { 12 | if (argumentText.isNotBlank()) { 13 | replyHelp(beans.commandManager[argumentText.trim()] ?: command) 14 | } else { 15 | val msg = MessageBuilder() 16 | .append("Available commands:") 17 | .appendCodeBlock(buildString { 18 | beans.commandManager.getCommands().forEach { 19 | appendLine((listOf(it.name) + it.aliases).joinToString()) 20 | } 21 | }, "") 22 | .append("\nUse \"${trigger} \" to see more details.") 23 | replyMsg(msg.build()) 24 | } 25 | } 26 | 27 | override fun HelpContext.provideHelp() { 28 | addUsage("") 29 | addDescription("Displays a list of commands and aliases.") 30 | addUsage("") 31 | addDescription("Displays help about a specific command.") 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/arbjerg/ukulele/command/PrefixCommand.kt: -------------------------------------------------------------------------------- 1 | package dev.arbjerg.ukulele.command 2 | 3 | import dev.arbjerg.ukulele.config.BotProps 4 | import dev.arbjerg.ukulele.data.GuildPropertiesService 5 | import dev.arbjerg.ukulele.features.HelpContext 6 | import dev.arbjerg.ukulele.jda.Command 7 | import dev.arbjerg.ukulele.jda.CommandContext 8 | import org.springframework.stereotype.Component 9 | 10 | @Component 11 | class PrefixCommand(val guildPropertiesService: GuildPropertiesService, val botProps: BotProps) : Command("prefix") { 12 | override suspend fun CommandContext.invoke() = when { 13 | argumentText == "reset" -> { 14 | guildPropertiesService.transformAwait(guild.idLong) { it.prefix = null } 15 | reply("Reset prefix to `${botProps.prefix}`") 16 | } 17 | argumentText.isNotBlank() -> { 18 | val props = guildPropertiesService.transformAwait(guild.idLong) { it.prefix = argumentText } 19 | reply("Set prefix to `${props.prefix}`") 20 | } 21 | else -> { 22 | replyHelp() 23 | } 24 | } 25 | 26 | override fun HelpContext.provideHelp() { 27 | addUsage("") 28 | addDescription("Set command prefix to ") 29 | addUsage("reset") 30 | addDescription("Resets the prefix to the default " + botProps.prefix) 31 | } 32 | } -------------------------------------------------------------------------------- /.teamcity/settings.kts: -------------------------------------------------------------------------------- 1 | import jetbrains.buildServer.configs.kotlin.v2019_2.* 2 | import jetbrains.buildServer.configs.kotlin.v2019_2.buildSteps.gradle 3 | import jetbrains.buildServer.configs.kotlin.v2019_2.triggers.vcs 4 | 5 | /* 6 | The settings script is an entry point for defining a TeamCity 7 | project hierarchy. The script should contain a single call to the 8 | project() function with a Project instance or an init function as 9 | an argument. 10 | 11 | VcsRoots, BuildTypes, Templates, and subprojects can be 12 | registered inside the project using the vcsRoot(), buildType(), 13 | template(), and subProject() methods respectively. 14 | 15 | To debug settings scripts in command-line, run the 16 | 17 | mvnDebug org.jetbrains.teamcity:teamcity-configs-maven-plugin:generate 18 | 19 | command and attach your debugger to the port 8000. 20 | 21 | To debug in IntelliJ Idea, open the 'Maven Projects' tool window (View 22 | -> Tool Windows -> Maven Projects), find the generate task node 23 | (Plugins -> teamcity-configs -> teamcity-configs:generate), the 24 | 'Debug' option is available in the context menu for the task. 25 | */ 26 | 27 | version = "2019.2" 28 | 29 | project { 30 | 31 | buildType(Build) 32 | } 33 | 34 | object Build : BuildType({ 35 | name = "Build" 36 | 37 | artifactRules = "build/libs/ukulele.jar" 38 | 39 | vcs { 40 | root(DslContext.settingsRoot) 41 | } 42 | 43 | steps { 44 | gradle { 45 | tasks = "clean build" 46 | buildFile = "" 47 | gradleWrapperPath = "" 48 | } 49 | } 50 | 51 | triggers { 52 | vcs { 53 | } 54 | } 55 | }) 56 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a Java project with Gradle 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-gradle 3 | 4 | name: Gradle Build 5 | 6 | on: [push] 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Set up JDK 1.8 15 | uses: actions/setup-java@v1 16 | with: 17 | java-version: 1.8 18 | - name: Cache Gradle packages 19 | uses: actions/cache@v2 20 | with: 21 | path: ~/.gradle/caches 22 | key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }} 23 | restore-keys: ${{ runner.os }}-gradle 24 | - name: Grant execute permission for gradlew 25 | run: chmod +x gradlew 26 | - name: Build with Gradle 27 | run: ./gradlew build 28 | - name: Docker Login 29 | uses: docker/login-action@v1.6.0 30 | if: github.repository == 'freyacodes/ukulele' 31 | with: 32 | registry: ghcr.io 33 | username: ${{github.repository_owner}} 34 | password: ${{secrets.DOCKER_TOKEN}} 35 | - uses: nelonoel/branch-name@v1.0.1 36 | name: Get branch name 37 | if: github.repository == 'freyacodes/ukulele' 38 | - name: Docker build and push 39 | uses: docker/build-push-action@v2.2.0 40 | if: github.repository == 'freyacodes/ukulele' 41 | with: 42 | tags: ghcr.io/freyacodes/ukulele:${{env.BRANCH_NAME}} 43 | load: true 44 | context: . 45 | - run: docker push ghcr.io/freyacodes/ukulele:${{env.BRANCH_NAME}} 46 | if: github.repository == 'freyacodes/ukulele' 47 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/arbjerg/ukulele/command/SkipCommand.kt: -------------------------------------------------------------------------------- 1 | package dev.arbjerg.ukulele.command 2 | 3 | import com.sedmelluq.discord.lavaplayer.track.AudioTrack 4 | import dev.arbjerg.ukulele.audio.PlayerRegistry 5 | import dev.arbjerg.ukulele.features.HelpContext 6 | import dev.arbjerg.ukulele.jda.Command 7 | import dev.arbjerg.ukulele.jda.CommandContext 8 | import org.springframework.stereotype.Component 9 | 10 | @Component 11 | class SkipCommand : Command("skip", "s") { 12 | override suspend fun CommandContext.invoke() { 13 | when { 14 | argumentText.isBlank() -> skipNext() 15 | argumentText.toIntOrNull() != null -> skipIndex(argumentText.toInt()) 16 | argumentText.split("\\s+".toRegex()).size == 2 -> skipRange() 17 | } 18 | } 19 | 20 | private fun CommandContext.skipNext() { 21 | printSkipped(player.skip(0..0)) 22 | } 23 | 24 | private fun CommandContext.skipIndex(i: Int) { 25 | val ind = (i-1).coerceAtLeast(0) 26 | printSkipped(player.skip(ind..ind)) 27 | } 28 | 29 | private fun CommandContext.skipRange() { 30 | val args = argumentText.split("\\s+".toRegex()) 31 | 32 | val n1 = (args[0].toInt() - 1).coerceAtLeast(0) 33 | val n2 = (args[1].toInt() - 1).coerceAtLeast(0) 34 | printSkipped(player.skip(n1..n2)) 35 | } 36 | 37 | private fun CommandContext.printSkipped(skipped: List) = when(skipped.size) { 38 | 0 -> replyHelp() 39 | 1 -> reply("Skipped `${skipped.first().info.title}`") 40 | else -> reply("Skipped `${skipped.size} tracks`") 41 | } 42 | 43 | override fun HelpContext.provideHelp() { 44 | addUsage("[count]") 45 | addDescription("Skips a number of tracks.") 46 | addDescription("Defaults to the first track if no number is given.") 47 | addUsage(" ") 48 | addDescription("Skips a range of tracks.") 49 | } 50 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/arbjerg/ukulele/jda/CommandContext.kt: -------------------------------------------------------------------------------- 1 | package dev.arbjerg.ukulele.jda 2 | 3 | import dev.arbjerg.ukulele.audio.Player 4 | import dev.arbjerg.ukulele.audio.PlayerRegistry 5 | import dev.arbjerg.ukulele.config.BotProps 6 | import dev.arbjerg.ukulele.data.GuildProperties 7 | import dev.arbjerg.ukulele.features.HelpContext 8 | import net.dv8tion.jda.api.entities.Guild 9 | import net.dv8tion.jda.api.entities.Member 10 | import net.dv8tion.jda.api.entities.Message 11 | import net.dv8tion.jda.api.entities.MessageEmbed 12 | import net.dv8tion.jda.api.entities.TextChannel 13 | import org.springframework.stereotype.Component 14 | 15 | class CommandContext( 16 | val beans: Beans, 17 | val guildProperties: GuildProperties, 18 | val guild: Guild, 19 | val channel: TextChannel, 20 | val invoker: Member, 21 | val message: Message, 22 | val command: Command, 23 | val prefix: String, 24 | /** Prefix + command name */ 25 | val trigger: String 26 | ) { 27 | @Component 28 | class Beans( 29 | val players: PlayerRegistry, 30 | val botProps: BotProps 31 | ) { 32 | lateinit var commandManager: CommandManager 33 | } 34 | 35 | val player: Player by lazy { beans.players.get(guild, guildProperties) } 36 | 37 | /** The command argument text after the trigger */ 38 | val argumentText: String by lazy { 39 | message.contentRaw.drop(trigger.length).trim() 40 | } 41 | val selfMember: Member get() = guild.selfMember 42 | 43 | fun reply(msg: String) { 44 | channel.sendMessage(msg).queue() 45 | } 46 | 47 | fun replyMsg(msg: Message) { 48 | channel.sendMessage(msg).queue() 49 | } 50 | 51 | fun replyEmbed(embed: MessageEmbed) { 52 | channel.sendMessage(embed).queue() 53 | } 54 | 55 | fun replyHelp(forCommand: Command = command) { 56 | val help = HelpContext(this, forCommand) 57 | forCommand.provideHelp0(help) 58 | channel.sendMessage(help.buildMessage()).queue() 59 | } 60 | 61 | fun handleException(t: Throwable) { 62 | command.log.error("Handled exception occurred", t) 63 | reply("An exception occurred!\n`${t.message}`") 64 | } 65 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/arbjerg/ukulele/jda/JdaConfig.kt: -------------------------------------------------------------------------------- 1 | package dev.arbjerg.ukulele.jda 2 | 3 | import dev.arbjerg.ukulele.config.BotProps 4 | import net.dv8tion.jda.api.entities.Activity 5 | import net.dv8tion.jda.api.sharding.ShardManager 6 | import org.springframework.context.annotation.Bean 7 | import org.springframework.context.annotation.Configuration 8 | import net.dv8tion.jda.api.requests.GatewayIntent.* 9 | import net.dv8tion.jda.api.requests.restaction.MessageAction 10 | import net.dv8tion.jda.api.sharding.DefaultShardManagerBuilder 11 | import net.dv8tion.jda.api.utils.cache.CacheFlag 12 | import javax.security.auth.login.LoginException 13 | import kotlin.concurrent.thread 14 | 15 | @Configuration 16 | class JdaConfig { 17 | 18 | init { 19 | MessageAction.setDefaultMentions(emptyList()) 20 | } 21 | 22 | @Bean 23 | fun shardManager(botProps: BotProps, eventHandler: EventHandler): ShardManager { 24 | if (botProps.token.isBlank()) throw RuntimeException("Discord token not configured!") 25 | val activity = if (botProps.game.isBlank()) Activity.playing("music") else Activity.playing(botProps.game) 26 | 27 | 28 | val intents = listOf( 29 | GUILD_VOICE_STATES, 30 | GUILD_MESSAGES, 31 | GUILD_BANS, 32 | DIRECT_MESSAGES 33 | ) 34 | 35 | val builder = DefaultShardManagerBuilder.create(botProps.token, intents) 36 | .disableCache(CacheFlag.ACTIVITY, CacheFlag.EMOTE, CacheFlag.CLIENT_STATUS) 37 | .setBulkDeleteSplittingEnabled(false) 38 | .setEnableShutdownHook(false) 39 | .setAutoReconnect(true) 40 | .setShardsTotal(botProps.shards) 41 | .addEventListeners(eventHandler) 42 | .setActivity(activity) 43 | 44 | val shardManager: ShardManager 45 | try { 46 | shardManager = builder.build() 47 | } catch (e: LoginException) { 48 | throw RuntimeException("Failed to log in to Discord! Is your token invalid?", e) 49 | } 50 | 51 | Runtime.getRuntime().addShutdownHook(thread(start = false) { 52 | shardManager.guildCache.forEach { 53 | if (it.audioManager.isConnected) it.audioManager.closeAudioConnection() 54 | } 55 | }) 56 | 57 | return shardManager 58 | } 59 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/arbjerg/ukulele/command/QueueCommand.kt: -------------------------------------------------------------------------------- 1 | package dev.arbjerg.ukulele.command 2 | 3 | import com.sedmelluq.discord.lavaplayer.track.AudioTrack 4 | import dev.arbjerg.ukulele.audio.Player 5 | import dev.arbjerg.ukulele.audio.PlayerRegistry 6 | import dev.arbjerg.ukulele.features.HelpContext 7 | import dev.arbjerg.ukulele.jda.Command 8 | import dev.arbjerg.ukulele.jda.CommandContext 9 | import dev.arbjerg.ukulele.utils.TextUtils 10 | import org.springframework.stereotype.Component 11 | 12 | @Component 13 | class QueueCommand( 14 | private val players: PlayerRegistry 15 | ) : Command("queue", "q", "list", "l") { 16 | 17 | private val pageSize = 10 18 | 19 | override suspend fun CommandContext.invoke() { 20 | reply(printQueue(player, argumentText.toIntOrNull() ?: 1)) 21 | } 22 | 23 | private fun printQueue(player: Player, pageIndex: Int): String { 24 | val totalDuration = player.remainingDuration 25 | val tracks = player.tracks 26 | if (tracks.isEmpty()) 27 | return "The queue is empty." 28 | 29 | return buildString { 30 | append(paginateQueue(tracks, pageIndex)) 31 | append("\nThere are **${tracks.size}** tracks with a remaining length of ") 32 | 33 | if (tracks.any{ it.info.isStream }) { 34 | append("**${TextUtils.humanReadableTime(totalDuration)}** in the queue excluding streams.") 35 | } else { 36 | append("**${TextUtils.humanReadableTime(totalDuration)}** in the queue.") 37 | } 38 | } 39 | } 40 | 41 | private fun paginateQueue(tracks: List, index: Int) = buildString { 42 | val pageCount: Int = (tracks.size + pageSize - 1) / pageSize 43 | val pageIndex = index.coerceIn(1..pageCount) 44 | 45 | //Add header 46 | append("Page **$pageIndex** of **$pageCount**\n\n") 47 | 48 | val offset = pageSize * (pageIndex - 1) 49 | val pageEnd = (offset + pageSize).coerceAtMost(tracks.size) 50 | 51 | tracks.subList(offset, pageEnd).forEachIndexed { i, t -> 52 | appendLine("`[${offset + i + 1}]` **${t.info.title}** `[${if (t.info.isStream) "Live" else TextUtils.humanReadableTime(t.duration)}]`") 53 | } 54 | } 55 | 56 | override fun HelpContext.provideHelp() { 57 | addUsage("[page]") 58 | addDescription("Displays the queue, by default for page 1") 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/arbjerg/ukulele/data/guildProperties.kt: -------------------------------------------------------------------------------- 1 | package dev.arbjerg.ukulele.data 2 | 3 | import com.github.benmanes.caffeine.cache.AsyncLoadingCache 4 | import com.github.benmanes.caffeine.cache.Caffeine 5 | import kotlinx.coroutines.reactive.awaitSingle 6 | import org.slf4j.Logger 7 | import org.slf4j.LoggerFactory 8 | import org.springframework.data.annotation.Id 9 | import org.springframework.data.annotation.Transient 10 | import org.springframework.data.domain.Persistable 11 | import org.springframework.data.relational.core.mapping.Table 12 | import org.springframework.data.repository.reactive.ReactiveCrudRepository 13 | import org.springframework.stereotype.Service 14 | import reactor.core.publisher.Mono 15 | import reactor.kotlin.core.publisher.toMono 16 | import java.time.Duration 17 | 18 | @Service 19 | class GuildPropertiesService(private val repo: GuildPropertiesRepository) { 20 | private val log: Logger = LoggerFactory.getLogger(GuildPropertiesService::class.java) 21 | 22 | private val cache: AsyncLoadingCache = Caffeine.newBuilder() 23 | .expireAfterAccess(Duration.ofMinutes(10)) 24 | .buildAsync { id, _ -> 25 | repo.findById(id) 26 | .defaultIfEmpty(GuildProperties(id).apply { new = true }) 27 | .toFuture() } 28 | 29 | fun get(guildId: Long) = cache[guildId].toMono() 30 | suspend fun getAwait(guildId: Long): GuildProperties = get(guildId).awaitSingle() 31 | 32 | fun transform(guildId: Long, func: (GuildProperties) -> Unit): Mono = cache[guildId] 33 | .toMono() 34 | .map { func(it); it } 35 | .flatMap { repo.save(it) } 36 | .map { 37 | it.apply { new = false } 38 | log.info("Updated guild properties: {}", it) 39 | it 40 | } 41 | .doOnSuccess { cache.synchronous().put(it.guildId, it) } 42 | 43 | suspend fun transformAwait(guildId: Long, func: (GuildProperties) -> Unit): GuildProperties = transform(guildId, func).awaitSingle() 44 | } 45 | 46 | @Table("guild_properties") 47 | data class GuildProperties( 48 | @Id val guildId: Long, 49 | var volume: Int = 100, 50 | var prefix: String? = null 51 | ) : Persistable { 52 | @Transient var new: Boolean = false 53 | override fun getId() = guildId 54 | override fun isNew() = new 55 | } 56 | 57 | interface GuildPropertiesRepository : ReactiveCrudRepository -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ukulele 2 | ...and his music was electric. 3 | 4 | Ukulele is a bot made by the creator and collaborators of FredBoat. The concept is to replicate FredBoat while keeping it simple. The original stack is engineered for serving millions of servers, and is thus too complex to selfhost. 5 | 6 | The bot is self-contained and only requires Java 11 to run. 7 | 8 | This is currently work-in-progress. 9 | 10 | ## Features 11 | - Basic player commands (::play, ::list, ::skip, ::shuffle) 12 | - Volume command 13 | - Zero-maintenance embedded database 14 | 15 | ## Host it yourself 16 | 17 | ### Manual 18 | - Install Java 11 19 | - Make a copy of `ukulele.example.yml` and rename it to `ukulele.yml` 20 | - Input the bot token [(guide)](https://discordjs.guide/preparations/setting-up-a-bot-application.html) 21 | - Run `./ukulele` to build and run the application (Windows users use the .bat files via commandline) 22 | 23 | ### Using Docker 24 | #### Requirements 25 | - Docker (Engine: 18.06.0+) 26 | - Docker-Compose 27 | 28 | #### Running 29 | ```shell script 30 | # Create DB directory and own it to 999 31 | mkdir db && chown -R 999 db/ 32 | 33 | # Copy ukulele config file 34 | cp ukulele.example.yml ukulele.yml 35 | # Open ukulele.yml and make config changes 36 | 37 | # Now simply run run docker-compose 38 | docker-compose up -d 39 | ``` 40 | 41 | To run the container in detached mode simply add `-d` to the arguments of the run command. 42 | 43 | ### AUR Package ![AUR version](https://img.shields.io/aur/version/ukulele-git) 44 | https://aur.archlinux.org/packages/ukulele-git/ 45 | 46 | This Arch package provides a systemd service for ukulele, and places the files for ukulele in the correct places according to the [Arch Package Guidelines](https://wiki.archlinux.org/title/Arch_package_guidelines#Directories). This installation method is only relevant if you have an arch-based system. 47 | 48 | - Install the package either using an AUR helper (paru, yay, etc), or following the [guide](https://wiki.archlinux.org/title/Arch_User_Repository#Installing_and_upgrading_packages) on the Arch Wiki. 49 | - Edit the config file (`/etc/ukulele/ukulele.yml`) as required. 50 | - As noted when installing the package, the discord bot token must be set in the config file ([guide](https://discordjs.guide/preparations/setting-up-a-bot-application.html)) 51 | - Start/enable the `ukulele.service` as required ([wiki](https://wiki.archlinux.org/title/Systemd#Using_units)) 52 | 53 | ## Contributing 54 | Pull requests are welcome! Look through the issues and/or create one if you have an idea. 55 | 56 | Please read [CONTRIBUTING.md](CONTRIBUTING.md) 57 | 58 | ## Make your own changes (More info soon) 59 | - Change code 60 | - `./gradlew clean build` 61 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/arbjerg/ukulele/jda/CommandManager.kt: -------------------------------------------------------------------------------- 1 | package dev.arbjerg.ukulele.jda 2 | 3 | import dev.arbjerg.ukulele.config.BotProps 4 | import dev.arbjerg.ukulele.data.GuildPropertiesService 5 | import kotlinx.coroutines.GlobalScope 6 | import kotlinx.coroutines.launch 7 | import net.dv8tion.jda.api.entities.Guild 8 | import net.dv8tion.jda.api.entities.Member 9 | import net.dv8tion.jda.api.entities.Message 10 | import net.dv8tion.jda.api.entities.TextChannel 11 | import org.slf4j.Logger 12 | import org.slf4j.LoggerFactory 13 | import org.springframework.stereotype.Service 14 | 15 | @Service 16 | class CommandManager( 17 | private val contextBeans: CommandContext.Beans, 18 | private val guildProperties: GuildPropertiesService, 19 | private val botProps: BotProps, 20 | commands: Collection 21 | ) { 22 | 23 | private final val registry: Map 24 | private val log: Logger = LoggerFactory.getLogger(CommandManager::class.java) 25 | 26 | init { 27 | val map = mutableMapOf() 28 | commands.forEach { c -> 29 | map[c.name] = c 30 | c.aliases.forEach { map[it] = c } 31 | } 32 | registry = map 33 | log.info("Registered ${commands.size} commands with ${registry.size} names") 34 | @Suppress("LeakingThis") 35 | contextBeans.commandManager = this 36 | } 37 | 38 | operator fun get(commandName: String) = registry[commandName] 39 | 40 | fun getCommands() = registry.values.distinct() 41 | 42 | fun onMessage(guild: Guild, channel: TextChannel, member: Member, message: Message) { 43 | GlobalScope.launch { 44 | val guildProperties = guildProperties.getAwait(guild.idLong) 45 | val prefix = guildProperties.prefix ?: botProps.prefix 46 | 47 | val name: String 48 | val trigger: String 49 | 50 | // match result: a mention of us at the beginning 51 | val mention = Regex("^(<@!?${guild.getSelfMember().getId()}>\\s*)").find(message.contentRaw)?.value 52 | if (mention != null) { 53 | val commandText = message.contentRaw.drop(mention.length) 54 | if (commandText.isEmpty()) { 55 | channel.sendMessage("The prefix here is `${prefix}`, or just mention me followed by a command.").queue() 56 | return@launch 57 | } 58 | 59 | name = commandText.trim().takeWhile { !it.isWhitespace() } 60 | trigger = mention + name 61 | } else if (message.contentRaw.startsWith(prefix)) { 62 | name = message.contentRaw.drop(prefix.length) 63 | .takeWhile { !it.isWhitespace() } 64 | trigger = prefix + name 65 | } else { 66 | return@launch 67 | } 68 | 69 | val command = registry[name] ?: return@launch 70 | val ctx = CommandContext(contextBeans, guildProperties, guild, channel, member, message, command, prefix, trigger) 71 | 72 | log.info("Invocation: ${message.contentRaw}") 73 | command.invoke0(ctx) 74 | } 75 | } 76 | 77 | } 78 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Ukulele 2 | Contributions are welcome! Below are some technical details on how to work on the project. 3 | 4 | ## Setting up your workspace 5 | To get started you only need JDK 11 or up. Gradle will automatically install itself. 6 | 7 | Clone this git repository. You can then import this Gradle project into your favourite IDE. Intellij IDEA is recommended. 8 | 9 | You can build the project with `./gradlew clean build`. If you're a Windows user, you can use `gradlew.bat` instead. 10 | A self-contained .jar file will be built in `build/libs/ukulele.jar`. 11 | 12 | ## Working with Kotlin 13 | Kotlin is a very flexible language. It is particularly easy to learn if you already know Java. 14 | 15 | Notable differences to Java: 16 | * Improved type system (null-safety and better generics) 17 | * More oriented towards functional programming 18 | * More concise (shorter) code 19 | * Coroutines for async functions 20 | 21 | If you're new to Kotlin, I recommend the following reading: 22 | * [Basic syntax](https://kotlinlang.org/docs/reference/basic-syntax.html) 23 | * [Kotlin Koans](https://kotlinlang.org/docs/tutorials/koans.html) (Interactive) 24 | 25 | ## Working with Spring Boot 26 | Ukulele uses Spring Boot as a framework. The basic concept is that we can declare components (beans, services, etc) to 27 | be depended on by other beans. This is called inversion of control. It allows us to reduce coupling between components. 28 | 29 | All `Command`s are beans. Another bean is the `CommandManager`, which depends on all Command beans. Beans are automatically 30 | created by Spring Boot. 31 | 32 | Documentation: https://spring.io/projects/spring-boot#learn 33 | 34 | ## Developing commands 35 | As mentioned above, Commands are Spring beans (they're annotated with `@Component`). Simply defining your command is 36 | enough to register it in the `CommandManager`. 37 | 38 | Example: 39 | ```kotlin 40 | @Component 41 | class SayCommand : Command("say") { 42 | override suspend fun CommandContext.invoke() { 43 | reply(argumentText) 44 | } 45 | } 46 | ``` 47 | 48 | The above is a simple `Command` that simply echoes back whatever the user said. The command name is provided as a 49 | constructor argument to the parent type. 50 | 51 | The `invoke()` is a little special because it receives `CommandContext` as its receiver type. The function acts as if 52 | it was run within the scope of the given `CommandContext`. `reply(String)` is a function of `CommandContext`. 53 | 54 | `CommandContext` tells you a lot about where and how the command is getting invoked. It also contains several convenience 55 | functions. `argumentText` is a convenience property that contains the message following the command trigger. 56 | 57 | ### Constructor arguments 58 | As commands are Spring beans, you can depend on other Spring beans with them. `PlayerRegistry` and `AudioPlayerManager` 59 | are beans that we have declared elsewhere. You can depend on them by simply adding them to the constructor. 60 | 61 | Below is also an example of setting an alias (i.e. `p` for `play`). 62 | 63 | ```kotlin 64 | @Component 65 | class PlayCommand( 66 | val players: PlayerRegistry, 67 | val apm: AudioPlayerManager 68 | ) : Command("play", "p") { 69 | // ... 70 | } 71 | ``` 72 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/arbjerg/ukulele/command/NowPlayingCommand.kt: -------------------------------------------------------------------------------- 1 | package dev.arbjerg.ukulele.command 2 | 3 | import com.sedmelluq.discord.lavaplayer.source.soundcloud.SoundCloudAudioTrack 4 | import com.sedmelluq.discord.lavaplayer.source.twitch.TwitchStreamAudioTrack 5 | import com.sedmelluq.discord.lavaplayer.source.youtube.YoutubeAudioTrack 6 | import com.sedmelluq.discord.lavaplayer.track.AudioTrack 7 | import dev.arbjerg.ukulele.features.HelpContext 8 | import dev.arbjerg.ukulele.jda.Command 9 | import dev.arbjerg.ukulele.jda.CommandContext 10 | import dev.arbjerg.ukulele.utils.TextUtils 11 | import net.dv8tion.jda.api.EmbedBuilder 12 | import net.dv8tion.jda.api.entities.MessageEmbed 13 | import org.springframework.stereotype.Component 14 | import java.awt.Color 15 | 16 | @Component 17 | class NowPlayingCommand : Command ("nowplaying", "np") { 18 | 19 | override suspend fun CommandContext.invoke() { 20 | if (player.tracks.isEmpty()) 21 | return reply("Not playing anything.") 22 | 23 | replyEmbed(buildEmbed(player.tracks[0])) 24 | } 25 | 26 | fun buildEmbed(track: AudioTrack): MessageEmbed { 27 | return when(track){ 28 | is YoutubeAudioTrack -> GetEmbed(track).youtube() 29 | is SoundCloudAudioTrack -> GetEmbed(track).soundcloud() 30 | is TwitchStreamAudioTrack -> GetEmbed(track).twitch() 31 | else -> GetEmbed(track).default() 32 | } 33 | } 34 | 35 | private class GetEmbed(val track: AudioTrack) { 36 | val timeField = if (track.info.isStream) "[Live]" else "[${TextUtils.humanReadableTime(track.position)} / ${TextUtils.humanReadableTime(track.info.length)}]" 37 | 38 | //Set up common parts of the embed 39 | val message = EmbedBuilder() 40 | .setTitle(track.info.title, track.info.uri) 41 | .setFooter("Source: ${track.sourceManager.sourceName}") 42 | 43 | //Prepare embeds for overrides. 44 | fun youtube(): MessageEmbed { 45 | message.setColor(YOUTUBE_RED) 46 | message.addField("Time", timeField, true) 47 | return message.build() 48 | } 49 | 50 | fun soundcloud(): MessageEmbed { 51 | message.setColor(SOUNDCLOUD_ORANGE) 52 | message.addField("Time", timeField, true) 53 | return message.build() 54 | } 55 | 56 | fun twitch(): MessageEmbed { 57 | message.setColor(TWITCH_PURPLE) 58 | return message.build() 59 | } 60 | 61 | fun default(): MessageEmbed { 62 | message.setTitle(track.info.title) // Show just the title of the radio station. Weird uri jank. 63 | message.setColor(DEFAULT_GREY) 64 | message.addField("Time", timeField, true) 65 | return message.build() 66 | } 67 | } 68 | 69 | override fun HelpContext.provideHelp() { 70 | addUsage("") 71 | addDescription("Displays information about the currently playing song.") 72 | } 73 | 74 | private companion object { 75 | val YOUTUBE_RED = Color(205, 32, 31).rgb 76 | val SOUNDCLOUD_ORANGE = Color(255, 85, 0).rgb 77 | val TWITCH_PURPLE = Color(100, 65, 164).rgb 78 | val DEFAULT_GREY = Color(100, 100, 100).rgb 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/arbjerg/ukulele/command/SeekCommand.kt: -------------------------------------------------------------------------------- 1 | package dev.arbjerg.ukulele.command 2 | 3 | import dev.arbjerg.ukulele.features.HelpContext 4 | import dev.arbjerg.ukulele.jda.Command 5 | import dev.arbjerg.ukulele.jda.CommandContext 6 | import org.springframework.stereotype.Component 7 | import java.util.regex.Pattern 8 | 9 | @Component 10 | class SeekCommand : Command ("seek") { 11 | 12 | override suspend fun CommandContext.invoke() { 13 | val track = player.tracks.firstOrNull() ?: return reply("Not playing anything.") 14 | 15 | if (!track.isSeekable) 16 | return reply("This track is not seekable") 17 | 18 | if (argumentText.isBlank()) 19 | return replyHelp() 20 | 21 | val newPosition = parseTimeString(argumentText) ?: return replyHelp() 22 | if (newPosition > track.info.length) { 23 | player.skip(0..0) 24 | reply("Skipped `${track.info.title}`") 25 | } else { 26 | player.seek(newPosition) 27 | reply("Seeking `${track.info.title}` to ${formatTime(newPosition)}") 28 | } 29 | } 30 | 31 | private val timestampPattern: Pattern = Pattern.compile("^(\\d?\\d)(?::([0-5]?\\d))?(?::([0-5]?\\d))?$") 32 | 33 | fun parseTimeString(str: String): Long? { 34 | var millis: Long = 0 35 | var seconds: Long = 0 36 | var minutes: Long = 0 37 | var hours: Long = 0 38 | val m = timestampPattern.matcher(str) 39 | if(!m.find()) return null 40 | 41 | var capturedGroups = 0 42 | if (m.group(1) != null) capturedGroups++ 43 | if (m.group(2) != null) capturedGroups++ 44 | if (m.group(3) != null) capturedGroups++ 45 | when (capturedGroups) { 46 | 0 -> return null 47 | 1 -> seconds = m.group(1).toLongOrNull() ?: 0 48 | 2 -> { 49 | minutes = m.group(1).toLongOrNull() ?: 0 50 | seconds = m.group(2).toLongOrNull() ?: 0 51 | } 52 | 3 -> { 53 | hours = m.group(1).toLongOrNull() ?: 0 54 | minutes = m.group(2).toLongOrNull() ?: 0 55 | seconds = m.group(3).toLongOrNull() ?: 0 56 | } 57 | } 58 | minutes += hours * 60 59 | seconds += minutes * 60 60 | millis = seconds * 1000 61 | return millis 62 | } 63 | 64 | fun formatTime(millis: Long): String? { 65 | if (millis == Long.MAX_VALUE) { 66 | return "LIVE" 67 | } 68 | val t = millis / 1000L 69 | val sec = (t % 60L).toInt() 70 | val min = (t % 3600L / 60L).toInt() 71 | val hrs = (t / 3600L).toInt() 72 | val timestamp: String = if (hrs != 0) { 73 | forceTwoDigits(hrs).toString() + ":" + forceTwoDigits(min) + ":" + forceTwoDigits(sec) 74 | } else { 75 | forceTwoDigits(min).toString() + ":" + forceTwoDigits(sec) 76 | } 77 | return timestamp 78 | } 79 | 80 | private fun forceTwoDigits(i: Int): String? { 81 | return if (i < 10) "0$i" else i.toString() 82 | } 83 | 84 | override fun HelpContext.provideHelp() { 85 | addUsage("[[hh:]mm:]ss") 86 | addDescription("Seeks the current track to the selected position") 87 | } 88 | } -------------------------------------------------------------------------------- /.teamcity/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4.0.0 4 | Public_Ukulele Config DSL Script 5 | Public_Ukulele 6 | Public_Ukulele_dsl 7 | 1.0-SNAPSHOT 8 | 9 | 10 | org.jetbrains.teamcity 11 | configs-dsl-kotlin-parent 12 | 1.0-SNAPSHOT 13 | 14 | 15 | 16 | 17 | jetbrains-all 18 | https://download.jetbrains.com/teamcity-repository 19 | 20 | true 21 | 22 | 23 | 24 | teamcity-server 25 | https://ci.fredboat.com/app/dsl-plugins-repository 26 | 27 | true 28 | 29 | 30 | 31 | 32 | 33 | 34 | JetBrains 35 | https://download.jetbrains.com/teamcity-repository 36 | 37 | 38 | 39 | 40 | ${basedir} 41 | 42 | 43 | kotlin-maven-plugin 44 | org.jetbrains.kotlin 45 | ${kotlin.version} 46 | 47 | 48 | 49 | 50 | compile 51 | process-sources 52 | 53 | compile 54 | 55 | 56 | 57 | test-compile 58 | process-test-sources 59 | 60 | test-compile 61 | 62 | 63 | 64 | 65 | 66 | org.jetbrains.teamcity 67 | teamcity-configs-maven-plugin 68 | ${teamcity.dsl.version} 69 | 70 | kotlin 71 | target/generated-configs 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | org.jetbrains.teamcity 80 | configs-dsl-kotlin 81 | ${teamcity.dsl.version} 82 | compile 83 | 84 | 85 | org.jetbrains.teamcity 86 | configs-dsl-kotlin-plugins 87 | 1.0-SNAPSHOT 88 | pom 89 | compile 90 | 91 | 92 | org.jetbrains.kotlin 93 | kotlin-stdlib-jdk8 94 | ${kotlin.version} 95 | compile 96 | 97 | 98 | org.jetbrains.kotlin 99 | kotlin-script-runtime 100 | ${kotlin.version} 101 | compile 102 | 103 | 104 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/arbjerg/ukulele/command/PlayCommand.kt: -------------------------------------------------------------------------------- 1 | package dev.arbjerg.ukulele.command 2 | 3 | import com.sedmelluq.discord.lavaplayer.player.AudioLoadResultHandler 4 | import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager 5 | import com.sedmelluq.discord.lavaplayer.tools.FriendlyException 6 | import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist 7 | import com.sedmelluq.discord.lavaplayer.track.AudioTrack 8 | import dev.arbjerg.ukulele.audio.Player 9 | import dev.arbjerg.ukulele.audio.PlayerRegistry 10 | import dev.arbjerg.ukulele.config.BotProps 11 | import dev.arbjerg.ukulele.features.HelpContext 12 | import dev.arbjerg.ukulele.jda.Command 13 | import dev.arbjerg.ukulele.jda.CommandContext 14 | import net.dv8tion.jda.api.Permission 15 | import org.springframework.stereotype.Component 16 | 17 | @Component 18 | class PlayCommand( 19 | val players: PlayerRegistry, 20 | val apm: AudioPlayerManager, 21 | val botProps: BotProps 22 | ) : Command("play", "p") { 23 | override suspend fun CommandContext.invoke() { 24 | if (!ensureVoiceChannel()) return 25 | 26 | var identifier = argumentText 27 | if (!checkValidUrl(identifier)) { 28 | identifier = "ytsearch:$identifier" 29 | } 30 | 31 | players.get(guild, guildProperties).lastChannel = channel 32 | apm.loadItem(identifier, Loader(this, player, identifier)) 33 | } 34 | 35 | fun CommandContext.ensureVoiceChannel(): Boolean { 36 | val ourVc = guild.selfMember.voiceState?.channel 37 | val theirVc = invoker.voiceState?.channel 38 | 39 | if (ourVc == null && theirVc == null) { 40 | reply("You need to be in a voice channel") 41 | return false 42 | } 43 | 44 | if (ourVc != theirVc && theirVc != null) { 45 | val canTalk = selfMember.hasPermission(Permission.VOICE_CONNECT, Permission.VOICE_SPEAK) 46 | if (!canTalk) { 47 | reply("I need permission to connect and speak in ${theirVc.name}") 48 | return false 49 | } 50 | 51 | guild.audioManager.openAudioConnection(theirVc) 52 | guild.audioManager.sendingHandler = player 53 | return true 54 | } 55 | 56 | return ourVc != null 57 | } 58 | 59 | fun checkValidUrl(url: String): Boolean { 60 | return url.startsWith("http://") 61 | || url.startsWith("https://") 62 | } 63 | 64 | inner class Loader( 65 | private val ctx: CommandContext, 66 | private val player: Player, 67 | private val identifier: String 68 | ) : AudioLoadResultHandler { 69 | override fun trackLoaded(track: AudioTrack) { 70 | if (track.isOverDurationLimit) { 71 | ctx.reply("Refusing to play `${track.info.title}` because it is over ${botProps.trackDurationLimit} minutes long") 72 | return 73 | } 74 | val started = player.add(track) 75 | if (started) { 76 | ctx.reply("Started playing `${track.info.title}`") 77 | } else { 78 | ctx.reply("Added `${track.info.title}`") 79 | } 80 | } 81 | 82 | override fun playlistLoaded(playlist: AudioPlaylist) { 83 | val accepted = playlist.tracks.filter { !it.isOverDurationLimit } 84 | val filteredCount = playlist.tracks.size - accepted.size 85 | if (accepted.isEmpty()) { 86 | ctx.reply("Refusing to play $filteredCount tracks because because they are all over ${botProps.trackDurationLimit} minutes long") 87 | return 88 | } 89 | 90 | if (identifier.startsWith("ytsearch") || identifier.startsWith("ytmsearch") || identifier.startsWith("scsearch:")) { 91 | this.trackLoaded(accepted.component1()); 92 | return 93 | } 94 | 95 | player.add(*accepted.toTypedArray()) 96 | ctx.reply(buildString { 97 | append("Added `${accepted.size}` tracks from `${playlist.name}`.") 98 | if (filteredCount != 0) append(" `$filteredCount` tracks have been ignored because they are over ${botProps.trackDurationLimit} minutes long") 99 | }) 100 | } 101 | 102 | override fun noMatches() { 103 | ctx.reply("Nothing found for “$identifier”") 104 | } 105 | 106 | override fun loadFailed(exception: FriendlyException) { 107 | ctx.handleException(exception) 108 | } 109 | 110 | private val AudioTrack.isOverDurationLimit: Boolean 111 | get() = botProps.trackDurationLimit > 0 && botProps.trackDurationLimit <= (duration / 60000) 112 | } 113 | 114 | override fun HelpContext.provideHelp() { 115 | addUsage("") 116 | addDescription("Add the given track to the queue") 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/arbjerg/ukulele/audio/Player.kt: -------------------------------------------------------------------------------- 1 | package dev.arbjerg.ukulele.audio 2 | 3 | import com.sedmelluq.discord.lavaplayer.player.AudioPlayer 4 | import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager 5 | import com.sedmelluq.discord.lavaplayer.player.event.AudioEventAdapter 6 | import com.sedmelluq.discord.lavaplayer.tools.FriendlyException 7 | import com.sedmelluq.discord.lavaplayer.track.AudioTrack 8 | import com.sedmelluq.discord.lavaplayer.track.AudioTrackEndReason 9 | import com.sedmelluq.discord.lavaplayer.track.playback.MutableAudioFrame 10 | import dev.arbjerg.ukulele.command.NowPlayingCommand 11 | import dev.arbjerg.ukulele.config.BotProps 12 | import dev.arbjerg.ukulele.data.GuildProperties 13 | import dev.arbjerg.ukulele.data.GuildPropertiesService 14 | import net.dv8tion.jda.api.audio.AudioSendHandler 15 | import net.dv8tion.jda.api.entities.TextChannel 16 | import org.slf4j.Logger 17 | import org.slf4j.LoggerFactory 18 | import org.springframework.stereotype.Component 19 | import java.nio.Buffer 20 | import java.nio.ByteBuffer 21 | 22 | 23 | class Player(val beans: Beans, guildProperties: GuildProperties) : AudioEventAdapter(), AudioSendHandler { 24 | @Component 25 | class Beans( 26 | val apm: AudioPlayerManager, 27 | val guildProperties: GuildPropertiesService, 28 | val nowPlayingCommand: NowPlayingCommand, 29 | val botProps: BotProps 30 | ) 31 | 32 | private val guildId = guildProperties.guildId 33 | private val queue = TrackQueue() 34 | private val player = beans.apm.createPlayer().apply { 35 | addListener(this@Player) 36 | volume = guildProperties.volume 37 | } 38 | private val buffer = ByteBuffer.allocate(1024) 39 | private val frame: MutableAudioFrame = MutableAudioFrame().apply { setBuffer(buffer) } 40 | private val log: Logger = LoggerFactory.getLogger(Player::class.java) 41 | var volume: Int 42 | get() = player.volume 43 | set(value) { 44 | player.volume = value 45 | beans.guildProperties.transform(guildId) { 46 | it.volume = player.volume 47 | }.subscribe() 48 | } 49 | 50 | val tracks: List get() { 51 | val tracks = queue.tracks.toMutableList() 52 | player.playingTrack?.let { tracks.add(0, it) } 53 | return tracks 54 | } 55 | 56 | val remainingDuration: Long get() { 57 | var duration = 0L 58 | if (player.playingTrack != null && !player.playingTrack.info.isStream) 59 | player.playingTrack?.let { duration = it.info.length - it.position } 60 | return duration + queue.duration 61 | } 62 | 63 | val isPaused : Boolean 64 | get() = player.isPaused 65 | 66 | var isRepeating : Boolean = false 67 | 68 | var lastChannel: TextChannel? = null 69 | 70 | /** 71 | * @return whether or not we started playing 72 | */ 73 | fun add(vararg tracks: AudioTrack): Boolean { 74 | queue.add(*tracks) 75 | if (player.playingTrack == null) { 76 | player.playTrack(queue.take()!!) 77 | return true 78 | } 79 | return false 80 | } 81 | 82 | fun skip(range: IntRange): List { 83 | val rangeFirst = range.first.coerceAtMost(queue.tracks.size) 84 | val rangeLast = range.last.coerceAtMost(queue.tracks.size) 85 | val skipped = mutableListOf() 86 | var newRange = rangeFirst .. rangeLast 87 | // Skip the first track if it is stored here 88 | if (newRange.contains(0) && player.playingTrack != null) { 89 | skipped.add(player.playingTrack) 90 | // Reduce range if found 91 | newRange = 0 .. rangeLast - 1 92 | } else { 93 | newRange = newRange.first - 1 .. newRange.last - 1 94 | } 95 | if (newRange.last >= 0) skipped.addAll(queue.removeRange(newRange)) 96 | if (skipped.first() == player.playingTrack) { 97 | if(isRepeating){ 98 | queue.add(player.playingTrack.makeClone()) 99 | } 100 | player.stopTrack() 101 | } 102 | return skipped 103 | } 104 | 105 | fun pause() { 106 | player.isPaused = true 107 | } 108 | 109 | fun resume() { 110 | player.isPaused = false 111 | } 112 | 113 | fun shuffle() { 114 | queue.shuffle() 115 | } 116 | 117 | fun stop() { 118 | queue.clear() 119 | player.stopTrack() 120 | } 121 | 122 | fun seek(position: Long) { 123 | player.playingTrack.position = position 124 | } 125 | 126 | override fun onTrackStart(player: AudioPlayer, track: AudioTrack) { 127 | if (beans.botProps.announceTracks) { 128 | lastChannel?.sendMessage(beans.nowPlayingCommand.buildEmbed(track))?.queue() 129 | } 130 | } 131 | 132 | override fun onTrackEnd(player: AudioPlayer, track: AudioTrack, endReason: AudioTrackEndReason) { 133 | if (isRepeating && endReason.mayStartNext) { 134 | queue.add(track.makeClone()) 135 | } 136 | val new = queue.take() ?: return 137 | player.playTrack(new) 138 | } 139 | 140 | override fun onTrackException(player: AudioPlayer, track: AudioTrack, exception: FriendlyException) { 141 | log.error("Track exception", exception) 142 | } 143 | 144 | override fun onTrackStuck(player: AudioPlayer, track: AudioTrack, thresholdMs: Long) { 145 | log.error("Track $track got stuck!") 146 | } 147 | 148 | override fun canProvide(): Boolean { 149 | return player.provide(frame) 150 | } 151 | 152 | override fun provide20MsAudio(): ByteBuffer { 153 | // flip to make it a read buffer 154 | (buffer as Buffer).flip() 155 | return buffer 156 | } 157 | 158 | override fun isOpus() = true 159 | } 160 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ]; do 30 | ls=$(ls -ld "$PRG") 31 | link=$(expr "$ls" : '.*-> \(.*\)$') 32 | if expr "$link" : '/.*' >/dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=$(dirname "$PRG")"/$link" 36 | fi 37 | done 38 | SAVED="$(pwd)" 39 | cd "$(dirname \"$PRG\")/" >/dev/null 40 | APP_HOME="$(pwd -P)" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=$(basename "$0") 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn() { 53 | echo "$*" 54 | } 55 | 56 | die() { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "$(uname)" in 69 | CYGWIN*) 70 | cygwin=true 71 | ;; 72 | Darwin*) 73 | darwin=true 74 | ;; 75 | MINGW*) 76 | msys=true 77 | ;; 78 | NONSTOP*) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | # Determine the Java command to use to start the JVM. 86 | if [ -n "$JAVA_HOME" ]; then 87 | if [ -x "$JAVA_HOME/jre/sh/java" ]; then 88 | # IBM's JDK on AIX uses strange locations for the executables 89 | JAVACMD="$JAVA_HOME/jre/sh/java" 90 | else 91 | JAVACMD="$JAVA_HOME/bin/java" 92 | fi 93 | if [ ! -x "$JAVACMD" ]; then 94 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 95 | 96 | Please set the JAVA_HOME variable in your environment to match the 97 | location of your Java installation." 98 | fi 99 | else 100 | JAVACMD="java" 101 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 102 | 103 | Please set the JAVA_HOME variable in your environment to match the 104 | location of your Java installation." 105 | fi 106 | 107 | # Increase the maximum file descriptors if we can. 108 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ]; then 109 | MAX_FD_LIMIT=$(ulimit -H -n) 110 | if [ $? -eq 0 ]; then 111 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ]; then 112 | MAX_FD="$MAX_FD_LIMIT" 113 | fi 114 | ulimit -n $MAX_FD 115 | if [ $? -ne 0 ]; then 116 | warn "Could not set maximum file descriptor limit: $MAX_FD" 117 | fi 118 | else 119 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 120 | fi 121 | fi 122 | 123 | # For Darwin, add options to specify how the application appears in the dock 124 | if $darwin; then 125 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 126 | fi 127 | 128 | # For Cygwin or MSYS, switch paths to Windows format before running java 129 | if [ "$cygwin" = "true" -o "$msys" = "true" ]; then 130 | APP_HOME=$(cygpath --path --mixed "$APP_HOME") 131 | CLASSPATH=$(cygpath --path --mixed "$CLASSPATH") 132 | 133 | JAVACMD=$(cygpath --unix "$JAVACMD") 134 | 135 | # We build the pattern for arguments to be converted via cygpath 136 | ROOTDIRSRAW=$(find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null) 137 | SEP="" 138 | for dir in $ROOTDIRSRAW; do 139 | ROOTDIRS="$ROOTDIRS$SEP$dir" 140 | SEP="|" 141 | done 142 | OURCYGPATTERN="(^($ROOTDIRS))" 143 | # Add a user-defined pattern to the cygpath arguments 144 | if [ "$GRADLE_CYGPATTERN" != "" ]; then 145 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 146 | fi 147 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 148 | i=0 149 | for arg in "$@"; do 150 | CHECK=$(echo "$arg" | egrep -c "$OURCYGPATTERN" -) 151 | CHECK2=$(echo "$arg" | egrep -c "^-") ### Determine if an option 152 | 153 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ]; then ### Added a condition 154 | eval $(echo args$i)=$(cygpath --path --ignore --mixed "$arg") 155 | else 156 | eval $(echo args$i)="\"$arg\"" 157 | fi 158 | i=$(expr $i + 1) 159 | done 160 | case $i in 161 | 0) set -- ;; 162 | 1) set -- "$args0" ;; 163 | 2) set -- "$args0" "$args1" ;; 164 | 3) set -- "$args0" "$args1" "$args2" ;; 165 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 166 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 167 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 168 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 169 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 170 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 171 | esac 172 | fi 173 | 174 | # Escape application args 175 | save() { 176 | for i; do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/"; done 177 | echo " " 178 | } 179 | APP_ARGS=$(save "$@") 180 | 181 | # Collect all arguments for the java command, following the shell quoting and substitution rules 182 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 183 | 184 | exec "$JAVACMD" "$@" 185 | --------------------------------------------------------------------------------