/* * To change this license header, choose License Headers in Project Properties. * To change this template file, choose Tools | Templates * and open the template in the editor. */ package moe.nekojimi.chords; import com.amihaiemil.eoyaml.Yaml; import com.amihaiemil.eoyaml.YamlMapping; import com.beust.jcommander.JCommander; import com.neovisionaries.ws.client.WebSocketFactory; import java.io.File; import java.io.IOException; import java.net.*; import java.nio.file.Files; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.*; import java.util.function.BiConsumer; import java.util.function.Consumer; import java.util.logging.Level; import java.util.logging.Logger; import javax.security.auth.login.LoginException; import moe.nekojimi.chords.commands.*; import moe.nekojimi.musicsearcher.providers.MetaSearcher; import moe.nekojimi.musicsearcher.providers.Searcher; import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.JDABuilder; import net.dv8tion.jda.api.entities.*; import net.dv8tion.jda.api.entities.channel.middleman.AudioChannel; import net.dv8tion.jda.api.events.guild.voice.GuildVoiceUpdateEvent; import net.dv8tion.jda.api.events.message.MessageEmbedEvent; import net.dv8tion.jda.api.events.message.MessageReceivedEvent; import net.dv8tion.jda.api.hooks.ListenerAdapter; import net.dv8tion.jda.api.managers.AudioManager; import net.dv8tion.jda.api.requests.GatewayIntent; import net.dv8tion.jda.api.utils.Compression; import net.dv8tion.jda.api.utils.cache.CacheFlag; import net.dv8tion.jda.internal.utils.IOUtil; import okhttp3.OkHttpClient; /** * The Chords music Discord bot. * * @author Nekojimi */ public final class Chords extends ListenerAdapter { private static final CommandOptions options = new CommandOptions(); private static Settings settings; private final File dataDirectory; private final File playlistsDirectory; private MusicHandler musicHandler; private final Downloader downloader; private final Searcher searcher; private final QueueManager queueManager; // private Recommender recommender; private JDA jda; private final Map commands = new HashMap<>(); private final Command helpCommand; private PlayCommand playCommand; private AudioChannel currentVoiceChannel = null; private final Map playlists = new HashMap<>(); /** * @param args the command line arguments */ public static void main(String[] args) throws LoginException, IOException { JCommander commander = JCommander.newBuilder() .args(args) .acceptUnknownOptions(true) .addObject(options) .build(); // We only need 2 gateway intents enabled for this example: EnumSet intents = EnumSet.of( // We need messages in guilds to accept commands from users GatewayIntent.GUILD_MESSAGES, // We need voice states to connect to the voice channel GatewayIntent.GUILD_VOICE_STATES, GatewayIntent.GUILD_MEMBERS, GatewayIntent.MESSAGE_CONTENT ); settings = new Settings(new File(options.getSettingsPath())); JDABuilder builder = JDABuilder.createDefault(settings.getDiscordToken(), intents); builder.enableIntents(GatewayIntent.MESSAGE_CONTENT); if (settings.getLocalAddr() != null) { System.out.println("Using local address: " + settings.getLocalAddr()); // make local binding socket factory final LocalBindSocketFactory localBindSocketFactory = new LocalBindSocketFactory(); localBindSocketFactory.setLocalAddress(InetAddress.getByName(settings.getLocalAddr())); // install local socket factory for HTTP OkHttpClient.Builder httpBuilder = IOUtil.newHttpClientBuilder(); httpBuilder.socketFactory(localBindSocketFactory); builder.setHttpClientBuilder(httpBuilder); // install local socket factory for websockets final WebSocketFactory webSocketFactory = new WebSocketFactory(); webSocketFactory.setSocketFactory(localBindSocketFactory); builder.setWebsocketFactory(webSocketFactory); } // Disable parts of the cache builder.disableCache(CacheFlag.MEMBER_OVERRIDES); // Enable the bulk delete event builder.setBulkDeleteSplittingEnabled(false); // Disable compression (not recommended) builder.setCompression(Compression.NONE); // Set activity (like "playing Something") builder.setActivity(Activity.playing("music!")); final Chords listener = new Chords(); builder.addEventListeners(listener); builder.setAutoReconnect(true); JDA jda = builder.build(); listener.setJda(jda); } private final Consumer nowPlayingConsumer = new NowPlayingConsumer(); private final BiConsumer downloaderMessageHandler = new DownloaderMessageHandler(); public Chords() throws IOException { log("INFO", "Starting up..."); // init dirs dataDirectory = new File(System.getProperty("user.dir")); playlistsDirectory = initDirectory(dataDirectory, "playlists"); // init downloader downloader = new Downloader(); downloader.setMessageHandler(downloaderMessageHandler); // init searcher searcher = MetaSearcher.loadYAML(new File("searchproviders.yml")); // init queue manager queueManager = new QueueManager(); queueManager.addSource(downloader); // init commands addCommand(new JoinCommand(this)); addCommand(new LeaveCommand(this)); playCommand = new PlayCommand(this); addCommand(playCommand); addCommand(new QueueCommand(this)); addCommand(new RemoveCommand(this)); addCommand(new RestartCommand(this)); addCommand(new SkipCommand(this)); helpCommand = new HelpCommand(this); addCommand(helpCommand); // load playlists loadPlaylists(); log("INFO", "Started OK!"); } private void addCommand(Command command) { commands.put(command.getKeyword(), command); } public void setJda(JDA jda) { this.jda = jda; } @Override public void onGuildVoiceUpdate(GuildVoiceUpdateEvent event) { super.onGuildVoiceUpdate(event); if (this.currentVoiceChannel == null) return; final AudioChannel channelLeft = event.getChannelLeft(); if (channelLeft != null && channelLeft == currentVoiceChannel) { if (channelLeft.getMembers().size() <= 1) // just us now disconnect(); } } @Override public void onMessageReceived(MessageReceivedEvent event) { super.onMessageReceived(event); //To change body of generated methods, choose Tools | Templates. Message message = event.getMessage(); User author = message.getAuthor(); String content = message.getContentRaw(); Guild guild = event.getGuild(); // Ignore message if it's not in a music channel if (!event.getChannel().getName().contains("music")) return; // Ignore message if bot if (author.isBot()) return; log("MESG", "G:" + guild.getName() + " A:" + author.getName() + " C:" + content); Invocation invocation = null; try { try { URL parseURL = new URL(content.trim()); invocation = new Invocation(event, List.of(parseURL.toExternalForm())); playCommand.call(invocation); return; } catch (MalformedURLException ex) { // not a URL, then } String[] split = content.split("\\s+"); List args = new ArrayList<>(); Collections.addAll(args, split); args.remove(0); invocation = new Invocation(event, args); invocation.setRequestMessage(message); String cmd = split[0].toLowerCase(); if (cmd.startsWith("!")) { cmd = cmd.substring(1); // strip prefix char if (commands.containsKey(cmd)) { Command command = commands.get(cmd); command.call(invocation); } else { helpCommand.call(invocation); } } else if (!event.getMessage().getAttachments().isEmpty()) { for (Message.Attachment attach : event.getMessage().getAttachments()) { if (attach.getContentType().startsWith("audio") || attach.getContentType().startsWith("video")) { try { invocation = new Invocation(event, List.of(new URL(attach.getUrl()).toExternalForm())); invocation.setRequestMessage(message); playCommand.call(invocation); } catch (MalformedURLException ex) { Logger.getLogger(Chords.class.getName()).log(Level.WARNING, null, ex); } } } } } catch (Exception ex) { if (invocation != null) invocation.respond("Error: " + ex.getMessage()); else event.getChannel().sendMessage("Error: " + ex.getMessage()).queue(); log("UERR", "Command error:" + ex.getMessage()); } // TODO: this will handle uploading files, maybe } public void queueDownload(TrackRequest request) { request.getInvocation().respond("Request pending..."); downloader.accept(request); } public void setStatus(Track nowPlaying) { jda.getPresence().setActivity(Activity.listening(nowPlaying.toString())); } public void clearStatus() { jda.getPresence().setActivity(null); } /** * Connect to requested channel and start echo handler * * @param channel * The channel to connect to */ public void connectTo(AudioChannel channel) { Guild guild = channel.getGuild(); // Get an audio manager for this guild, this will be created upon first use for each guild AudioManager audioManager = guild.getAudioManager(); musicHandler = new MusicHandler(); queueManager.setHandler(musicHandler); // Create our Send/Receive handler for the audio connection // EchoHandler handler = new EchoHandler(); // The order of the following instructions does not matter! // Set the sending handler to our echo system audioManager.setSendingHandler(musicHandler); // Set the receiving handler to the same echo system, otherwise we can't echo anything // audioManager.setReceivingHandler(handler); // Connect to the voice channel audioManager.openAudioConnection(channel); currentVoiceChannel = channel; musicHandler.setNowPlayingConsumer(nowPlayingConsumer); } public void disconnect() { if (currentVoiceChannel != null) { Guild guild = currentVoiceChannel.getGuild(); AudioManager audioManager = guild.getAudioManager(); audioManager.setSendingHandler(null); audioManager.closeAudioConnection(); musicHandler = null; currentVoiceChannel = null; } } public void log(String type, String message) { System.out.println(type + " " + LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME) + "\t" + message); } public File initDirectory(File parent, String name) throws IOException { File ret = new File(parent, name); if (!ret.exists()) Files.createDirectories(ret.toPath()); if (!ret.canRead()) throw new RuntimeException("Cannot read directory " + ret.getAbsolutePath() + "!"); if (!ret.canWrite()) throw new RuntimeException("Cannot write to directory " + ret.getAbsolutePath() + "!"); return ret; } private void loadPlaylists() { File[] files = playlistsDirectory.listFiles((File file, String name) -> name.endsWith(".yaml")); for (File file : files) { try { YamlMapping map = Yaml.createYamlInput(file).readYamlMapping(); Playlist playlist = Playlist.fromYaml(map); playlists.put(playlist.getName(), playlist); } catch (IOException ex) { Logger.getLogger(Chords.class.getName()).log(Level.SEVERE, null, ex); } } } public void setRecommenderEnabled(boolean enabled) { // if (recommender == null && enabled) // { //// recommender = new Recommender(); // downloader.addSource(recommender); // } else if (recommender != null && !enabled) // { //// downloader.removeSource(recommender); // recommender = null; // } } public MusicHandler getMusicHandler() { return musicHandler; } public Downloader getDownloader() { return downloader; } public Searcher getSearcher() { return searcher; } public JDA getJda() { return jda; } public AudioChannel getCurrentVoiceChannel() { return currentVoiceChannel; } public QueueManager getQueueManager() { return queueManager; } public static Settings getSettings() { return settings; } public static CommandOptions getOptions() { return options; } public File getDataDirectory() { return dataDirectory; } public File getPlaylistsDirectory() { return playlistsDirectory; } public Map getCommands() { return commands; } public Map getPlaylists() { return playlists; } // public Recommender getRecommender() // { // return recommender; // } private class DownloaderMessageHandler implements BiConsumer { private static final String PROGRESS_SYMBOLS = " ▏▎▍▌▋▊▉█"; private double lastProgressUpdate = 0.0; private boolean initialUpdateSent = false; /* ⠀⡀⣀⣠⣤⣦⣶⣾⣿ 🌑🌘🌗🌖🌕 🌩🌨🌧🌦🌥🌤🌞 ○◔◑◕● □🞎🞏🞐🞑🞒🞓■ □▤▥▦▧▨▩■ ▁▂▃▄▅▆▇█ */ // private static final String PROGRESS_SYMBOLS = " ⠀⡀⣀⣠⣤⣦⣶⣾⣿"; public DownloaderMessageHandler() { } @Override public void accept(TrackRequest request, Exception ex) { boolean shouldUpdate = false; String response = ""; if (request.getTracks().size() > 1) response += "Downloading " + request.getTracks().size() + " tracks:\n"; for (Track track : request.getTracks()) { // TextChannel channel = jda.getTextChannelById(track.getRequestedIn()); // String bracketNo = "[" + track.getNumber() + "] "; if (ex == null) if (track.getLocation() != null && track.getProgress() >= 100) { response += ("Finished downloading " + track + "!"); log("DOWN", "Downloaded " + track); shouldUpdate = true; } else { Format format = track.getBestFormat(); String formatDetails = ""; if (format != null) { final int bitrate = format.getSampleRate() / 1000; final long size = format.getSize(); String sizeFmt = (size <= 0 ? "?.??" : String.format("%.2f", size / (1024.0 * 1024.0))) + "MiB"; String bitFmt = (bitrate <= 0 ? "??" : bitrate) + "k"; formatDetails = " (" + bitFmt + ", " + sizeFmt + ")"; } String progressDetails = ""; if (track.getProgress() >= 0) { if (track.getProgress() >= lastProgressUpdate + 5.0) { shouldUpdate = true; lastProgressUpdate = track.getProgress(); } progressDetails = " [" + String.format("%.1f", track.getProgress()) + "%]"; } response += ("Now downloading " + track + formatDetails + progressDetails + " ..."); log("DOWN", "Downloading " + track + "..."); } else { response += ("Failed to download " + track + "! Reason: " + ex.getMessage()); log("DOWN", "Failed to download " + track + "! Reason: " + ex.getMessage()); shouldUpdate = true; } response += "\n"; } if (!response.isEmpty() && (shouldUpdate || !initialUpdateSent)) { request.getInvocation().respond(response); initialUpdateSent = true; } } } private class NowPlayingConsumer implements Consumer { public NowPlayingConsumer() { } @Override public void accept(Track track) { if (track != null) jda.getPresence().setActivity(Activity.of(Activity.ActivityType.LISTENING, track.toString())); else jda.getPresence().setActivity(null); // if (recommender != null) // recommender.addSeed(track); } } }