!7 /*static MapSO _renameClasses = litmap( "DynDiscordHopper" := "DynShardedDiscordHopper", "DynServerAwareDiscordBot" := "DynShardedServerAwareDiscordBot", "DynTalkBot2" := "DynShardedTalkBot2");*/ set flag JDA40. !include once #1034054 // DynTalkBot2 with sharding !include once #1034056 // DynServerAwareDiscordBot !include once #1034057 // DynDiscordHopper // TODO: on restart, check if we actually are in vc with voiceChannelID // (if permissions have changed) // Changes in this version: // uploadImagesAsFiles // !!!listeners fixed // check if embed fields are longer than 1k // Note: dm owners only takes ONE LINE // TODO: send dms only once when owner has two guilds !include once #1030357 // Discord Audio cmodule MKLabFM extends DynTalkBot2<.ByServer> { switchable S radioURL = "http://streamlive2.hearthis.at:8080/26450.ogg"; switchable S currentSongURL = "https://widgets.autopo.st/widgets/public/MKLabFM/nowplaying.php"; switchable S coverArtURL = "http://95.154.196.33:26687/playingart?sid=1&rand="; switchable bool enableShadowStop = true; switchable bool putSongInStatus = true; transient double bufferSeconds = 30.0; transient int grabInterval = 15; // try to grab frames quicker to see if it fixes the jumps transient double considerDownInterval = 60.0; // restart stream when no frames received for this long transient double autoRestartInterval = 20.0; switchable S madeBy = trim([[ MKLab: Support channel: Bot by: You like this bot? Donate: ]]); switchable S voteLink = "\n\nPlease help us grow by voting here: "; switchable S npMsg = "Now playing: - Listen at }{ https://tinyurl.com/mklab-live-fm }{ #edm #music #radio #ibiza #dance #chillout #psy #live #miami #memes #detroit #techno #dj #synthwave #housemusic #deephouse #onair"; switchable bool uploadImagesAsFiles = true; transient SimpleCircularBuffer buffer; transient AudioPlayer player; switchable transient bool simulateStreamDown, restartNow; transient long lastFrameReceived, lastActualFrameReceived; // sys time transient long moduleStarted; // sys time transient NotTooOften postRestarting = onlyEveryHour(); transient S currentSong; // stats transient new AtomicLong framesReceived; transient new AtomicLong canProvideCalls; transient new AtomicLong provideCalls; transient new AtomicLong dataSent; O _getReloadData() { ret currentSong; } void _setReloadData(S data) { currentSong = data; } void init { super.init(); myName = "!!!"; makeByServer = () -> new ByServer; dropPunctuation = false; preprocessAtSelfToMyName = true; ngbCommands = false; sendToPostedLinesCRUD = false; // First thing, start the stream, so we can restart the radios initStream(); onDiscordStarted(r { temp enter(); print("Bot back up"); for (Long id, ByServer bs : cloneMap(dataByServer)) { pcall { bs.restart(discord.getGuildById(id)); }} postInChannel(nextGenBotsTestRoomID(), "Rebooted " + computerID() + "/" + dm_moduleID()); }); onDiscordEvent(e -> { if (e cast GenericGuildVoiceEvent) getByServer(e.getGuild()).handleVoiceUpdateEvent(e); }); doEveryAndNow(20.0, r grabSong); doEvery(60.0, r updateVoiceChannelCount_allServers); } void initStream { buffer = new SimpleCircularBuffer(iround(bufferSeconds*50)); player = lavaPlayer_createPlayer(); lavaPlayer_printPlayerEvents(player); print("Have player"); doEvery(grabInterval, r { temp enter(); if (!simulateStreamDown) { AudioFrame frame = player.provide(); if (frame != null) { buffer.add(frame.getData()); inc(framesReceived); lastFrameReceived = lastActualFrameReceived = sysNow(); } } }); doEvery(1.0, r { temp enter(); if (discord == null) ret; if (restartNow || elapsedSeconds(lastFrameReceived) >= autoRestartInterval) { if (postRestarting.yo()) postInChannel(nextGenBotsTestRoomID(), "Restarting radio stream"); simulateStreamDown = restartNow = false; lastFrameReceived = sysNow(); loadTrack(); lastFrameReceived = sysNow(); } }); } class ByServer extends DynTalkBot2.ByServer { bool shouldBePlaying; long voiceChannelID, lastVoiceChannelID; long songChannelID; transient S voiceChannelName; transient VoiceChannel vc; transient MyAudioSendHandler sendHandler; transient Set usersInVoiceChannel = synchroSet(); synchronized S processSimplifiedLine(S s, O... _) enter { try { ret processSimplifiedLine2(s, _); } catch print e { if (cic(str(e), "InsufficientPermission")) ret "There were insufficient permissions to perform this action."; ret "Sorry, an error occurred trying to perform this action."; } } synchronized S processSimplifiedLine2(S s, O... _) { try answer super.processSimplifiedLine(s, _); // add authorized users etc. new Matches m; print("myPrefix", quote(myPrefix())); s = dropMyPrefixOrNull(s); print("dropped", quote(s)); if (s == null) null; optPar Message msg; Guild guild = msg.getGuild(); Member member = msg == null ? null : msg.getMember(); if (eqic(s, "help")) { optPar MessageChannel channel; S syntax = trim([[ `@me play | start` - start playing `@me stop | pause` - stop playing `@me leave` - leave voice channel `@me help` - show this help `@me np` - show currently playing song `@me join` - join any voice channel `@me set channel` - post now playing track names in current channel `@me stop posting` - stop posting track names [MORE] ]].replace("\n[MORE]", renderChannelListIfNotOne(guild)) .replace("@me ", myPrefix())) + voteLink; new EmbedBuilder eb; eb.setColor(Color.yellow); eb.setAuthor("MKLab FM Bot", null, null); eb.setTitle("Purpose", null); eb.setDescription("I play MKLab FM, the best Electronic Dance Music Radio, in a voice channel of your choice."); discordEmbed_addField(eb, "Syntax", syntax); if (authed(_)) eb.addField("Admin commands", rtrim([[ `@me guild count/guild names` - show stats `@me reboot` - reboot the whole bot (if music stream is completely broken for some reason) `@me dm owners: ` - DM all guild owners where radio is playing `@me stream url http...` - set stream URL. current: $STREAMURL `@me np url http...` - set current song URL. current: $NPURL `@me cover url http...` - set cover art URL. current: $COVERURL `@me cover url -` - disable cover art `@me np msg Playing ...` - set nowplaying message. current: $NPMSG `@me listeners` - full list of listeners ]]) .replace("@me ", myPrefix()) .replace("$STREAMURL", backtickQuote(radioURL)) .replace("$NPURL", backtickQuote(currentSongURL)) .replace("$NPMSG", backtickQuote(npMsg)) .replace("$COVERURL", backtickQuote(coverArtURL)), false); eb.addField("Made by", madeBy, false); eb.setThumbnail(imageSnippetURL(#1102722)); channel.sendMessage(eb.build()).queue(); null; } if (guild == null) null; if (authed(_)) { /*if "restart radios" ret "OK" with restartRadios();*/ if "reboot" { doAfter(2.0, rEnter dm_reload); ret "OK, rebooting bot"; } if "simulate stream down" { simulateStreamDown = true; ret "OK"; } if (swic_trim(s, "dm owners:", m)) ret dmOwners($1); if (swic_trim(s, "stream url ", m) && isWebURL(m.rest())) { if (!MKLabFM.this.setField(radioURL := m.rest())) ret "No change"; restartNow = true; ret "OK, restarting with radio URL " + radioURL; } if (swic_trim(s, "np url ", m) && isWebURL(m.rest())) { if (!MKLabFM.this.setField(currentSongURL := m.rest())) ret "No change"; ret "Current song URL changed to " + currentSongURL; } if (swic_trim(s, "cover url ", m)) { if (!MKLabFM.this.setField(coverArtURL := m.rest())) ret "No change"; ret "Cover art URL changed to " + coverArtURL; } if (swic_trim(s, "np msg ", m)) { if (!MKLabFM.this.setField(npMsg := m.rest())) ret "No change"; ret "Nowplaying message changed to " + npMsg; } if "listeners" ret or2(listeningUsers_full(), "No listeners"); } if (matchX2("play ...|start...|radio|radio on", s)) ret action_play(guild, member); if (matchX2("stop ...|pause ...|quiet|silence|shut up|... off", s) && !match("stop posting", s)) ret action_stop(guild); if (matchX2("leave|leave channel|leave voice channel", s)) ret action_leave(guild); if (matchX2("join|join channel|join voice channel", s)) ret action_join(guild, member); if (matchX2("... channel *", s, m) && isInteger($1)) ret action_selectChannel(guild, parseInt($1)); if (matchX2("np|n p|... song ...|... playing ...", s)) ret "MKLab FM is currently playing: " + (nempty(currentSong) ? backtickQuote(currentSong) : "Hmm. Not sure, I think something went wrong."); if (matchX2("set channel|post here|set np", s)) { long channelID = longPar channelID(_); if (channelID == 0) ret "Please do this in a channel"; if (songChannelID != channelID) { songChannelID = channelID; change(); } doAfter(2.0, r postSong); ret "OK, I will post the currently playing tracks in this channel. Type `" + atSelfOrMyName_space() + "stop posting` to stop."; } if (matchX2("stop posting|no channel", s)) { songChannelID = 0; change(); ret "OK"; } if "users in channel" ret "Users in voice channel: " + n2(usersInVoiceChannel); if "internal stats" ret "Frames received: " + n2(framesReceived!) + ", canProvide calls: " + n2(canProvideCalls!) + ", provide calls: " + n2(provideCalls!) + ", bytes sent: " + n2(dataSent!); null; } S renderGuildCount() { L guilds = discord.getGuilds(); int shouldPlayCount = 0, shadowCount = 0; for (Guild guild : guilds) { ByServer bs = getByServer(guild.getIdLong(), true); if (bs != null) { //if (bs.playing) ++playCount; if (bs.shouldBePlaying) { ++shouldPlayCount; if (bs.shadowStopped()) ++shadowCount; } } } ret super.renderGuildCount() + ". " + nRadios(shouldPlayCount-shadowCount) + " playing to " + n2(listeningUsers(), "listener") + ", " + shadowCount + " playing without listeners"; } void joinVoiceChannel(Guild guild) { vc = guild.getVoiceChannelById(voiceChannelID); guild.getAudioManager().openAudioConnection(vc); voiceChannelName = vc.getName(); updateVoiceChannelCount(); } void updateVoiceChannelCount { if (vc == null) ret with usersInVoiceChannel.clear(); L members = vc.getMembers(); int n = l(usersInVoiceChannel); replaceCollection(usersInVoiceChannel, mapNonNulls_pcall(members, m -> { User user = m.getUser(); ret user.isBot() ? null : user.getIdLong(); })); int n2 = l(usersInVoiceChannel); if (n != n2) print(guildID + " voice channel count corrected: " + n + " -> " + n2); } bool shadowStopped() { ret enableShadowStop && empty(usersInVoiceChannel); } class MyAudioSendHandler implements AudioSendHandler { long counter = -1; byte[] data; public bool canProvide() enter { inc(canProvideCalls); if (!shouldBePlaying) false; if (shadowStopped()) { counter = -1; false; } synchronized(buffer) { if (!buffer.isFull()) false; // out of range? go back to middle data = buffer.get(counter); if (data == null) { long oldCounter = counter; counter = buffer.getBase()+buffer.size()/2; if (oldCounter >= 0) printWithTime("RESETTING " + oldCounter + " => " + counter + " (delta=" + (counter-oldCounter) + ")"); data = buffer.get(counter); } ++counter; true; } } public java.nio.ByteBuffer provide20MsAudio() { inc(provideCalls); inc(dataSent, l(data)); java.nio.ByteBuffer buf = java.nio.ByteBuffer.wrap(data); ret buf; } public bool isOpus() { true; } } // when already in channel S startPlaying(Guild guild) { AudioManager am = guild.getAudioManager(); sendHandler = new MyAudioSendHandler; am.setSendingHandler(sendHandler); ret "Playing radio in channel " + quote(voiceChannelName); } // try to select the best channel // -the one the user is in, or // -the one that was used before, or // -just any channel. // returns error message or null S selectAVoiceChannel(Guild guild, Member member) { print("selectAVoiceChannel member=" + member); if (member != null) { GuildVoiceState vs = member.getVoiceState(); VoiceChannel vc = vs.getChannel(); print("selectAVoiceChannel vc=" + vc); if (vc != null) { voiceChannelID = lastVoiceChannelID = vc.getIdLong(); ret null with change(); } } if (voiceChannelID != 0) null; L allChannels = guild.getVoiceChannels(); L channels = filter(allChannels, c -> isJoinableVoiceChannel(guild, c)); if (empty(channels)) if (empty(allChannels)) ret "Please create a voice channel."; else ret "Sorry, I am not allowed to enter any of your voice channels. Maybe my invite link was wrong?"; // TODO: show better link // Check if lastVoiceChannelID still exists if (lastVoiceChannelID != 0 && !hasWhereMethodReturns getIdLong(channels, lastVoiceChannelID)) { lastVoiceChannelID = 0; change(); } voiceChannelID = lastVoiceChannelID != 0 ? lastVoiceChannelID : first(channels).getIdLong(); change(); null; } S action_play(Guild guild, Member member) { if (shouldBePlaying && sendHandler != null) ret "I am playing already!"; try answer selectAVoiceChannel(guild, member); joinVoiceChannel(guild); shouldBePlaying = true; change(); ret startPlaying(guild); } S action_stop(Guild guild) { if (shouldBePlaying) { shouldBePlaying = false; change(); ret "OK, radio switched off"; } ret "I am stopped, I think. No?"; } S action_leave(Guild guild) { voiceChannelID = 0; change(); this.vc = null; GuildVoiceState state = getSelfMember(guild).getVoiceState(); VoiceChannel vc = state.getChannel(); if (vc == null) ret "I don't think I am in any voice channel"; guild.getAudioManager().closeAudioConnection(); ret "OK, leaving channel " + quote(vc.getName()); } S action_join(Guild guild, Member member) { GuildVoiceState state = getSelfMember(guild).getVoiceState(); VoiceChannel vc = state.getChannel(); try answer selectAVoiceChannel(guild, member); joinVoiceChannel(guild); ret "OK, joined voice channel " + quote(voiceChannelName); } S action_selectChannel(Guild guild, int i) { L channels = guild.getVoiceChannels(); channels = filter(channels, c -> isJoinableVoiceChannel(guild, c)); VoiceChannel vc = _get(channels, i-1); if (vc == null) ret "Sorry, I count only " + nChannels(channels); voiceChannelID = lastVoiceChannelID = vc.getIdLong(); change(); joinVoiceChannel(guild); ret "OK, joined voice channel " + quote(voiceChannelName); } S renderChannelListIfNotOne(Guild guild) { if (guild == null) ret ""; L channels = guild.getVoiceChannels(); channels = filter(channels, c -> isJoinableVoiceChannel(guild, c)); if (empty(channels)) ret "\nNote: You don't have any voice channels."; if (l(channels) == 1) ret ""; new LS out; for i over channels: out.add("`@me join channel " + (i+1) + "` - join channel " + quote(channels.get(i).getName())); ret "\n" + lines_rtrim(out); } bool isJoinableVoiceChannel(Guild guild, VoiceChannel vc) { if (vc == null || guild == null) false; try { ret guild.getSelfMember().hasPermission(vc, Permission.VOICE_CONNECT); } catch print e { ret false; } } // normal restart (on bot load) void restart(Guild guild) { if (guild == null) ret; // no longer in guild if (shouldBePlaying) { print("RESTARTING RADIO in " + guild); print(action_play(guild, null)); } else if (voiceChannelID != 0) { print("Rejoining voice channel in " + guild); print(action_join(guild, null)); } } // restart with stop (after stream interruption) - no longer needed /*void hardRestart(Guild guild) { restart(guild); }*/ void postSong { if (songChannelID == 0) ret; postInChannel(songChannelID, npMsg.replace("", backtickQuote(currentSong))); if (isWebURL(coverArtURL)) { S imageURL = appendQueryToURL(coverArtURL, rand := randomInt()); if (uploadImagesAsFiles) uploadFileInChannel(songChannelID, loadBinaryPage(imageURL), "cover-art.jpg", "", null); else postImage(getChannel(songChannelID), imageURL, ""); } } void handleUserJoined(long userID, VoiceChannel joined) { if (joined != null && joined.getIdLong() == voiceChannelID) { usersInVoiceChannel.add(userID); print(guildID + " adding user to voice channel: " + userID + " (n=" + l(usersInVoiceChannel)); } } void handleVoiceUpdateEvent(GenericGuildVoiceEvent e) { User user = ((GenericGuildVoiceEvent) e).getMember().getUser(); if (user.isBot()) ret; long userID = user.getIdLong(); if (e cast GuildVoiceUpdateEvent) { VoiceChannel left = e.getChannelLeft(); if (e.getChannelLeft().getIdLong() == voiceChannelID) { usersInVoiceChannel.remove(userID); print(guildID + " removed user from voice channel: " + userID + " (n=" + l(usersInVoiceChannel)); } } if (e cast GuildVoiceMoveEvent) handleUserJoined(userID, e.getChannelJoined()); if (e cast GuildVoiceJoinEvent) handleUserJoined(userID, e.getChannelJoined()); } } // end of ByServer /*void restartRadios { L guilds = discord.getGuilds(); for (Guild guild : guilds) pcall { ByServer bs = getByServer(guild); if (bs != null) bs.hardRestart(guild); } }*/ S dmOwners(S msg) { L guilds = discord.getGuilds(); int n = 0; for (Guild guild : guilds) pcall { ByServer bs = getByServer(guild.getIdLong(), true); if (bs != null && bs.shouldBePlaying) { gazelle_dmGuildOwner(guild, msg); ++n; } } ret n2(n, "guild owner") + " contacted."; } void loadTrack { if (!isURL(radioURL)) ret; lavaPlayer_loadItem(module(), lavaPlayer_playerManager(), player, radioURL.replace("", str(randomInt()))); } S currentSong() { temp tempSetTL(loadPage_sizeLimit, 128*1024L); if (l(currentSongURL) <= 1) ret ""; if (cic(currentSongURL, "autopo.st/")) ret trackTitleFromAutoPostWidget(loadPageWithTimeout(currentSongURL, 10.0)); if (cic(currentSongURL, "caster.fm/")) ret (S) mapGet(jsonDecodeMap(loadPageWithHeaders(currentSongURL, "X-Requested-With", "XMLHttpRequest")), "playing"); if (endsWith(currentSongURL, "status.xsl")) ret trackTitleFromIceCastStatusXSL(loadPageWithTimeout(currentSongURL, 10.0)); ret loadPageWithTimeout(currentSongURL, 10.0); } void grabSong enter { if (discord == null) ret; S song = currentSong(); bool post = nempty(currentSong) && nempty(song) && neq(currentSong, song); currentSong = song; if (post) postSong_allServers(); if (putSongInStatus) { S s = currentSong; if (elapsedSeconds(lastActualFrameReceived) >= considerDownInterval && elapsedSeconds(moduleStarted) >= considerDownInterval) s = "Stream currently down"; if (nempty(s)) gazelle_discord_playingGame(s); } } void postSong_allServers { L guilds = discord.getGuilds(); for (Guild guild : guilds) pcall { ByServer bs = getByServer(guild); if (bs != null) bs.postSong(); } } void updateVoiceChannelCount_allServers { if (discord == null) ret; for (ByServer bs : cloneValues(dataByServer)) bs.updateVoiceChannelCount(); } int listeningUsers() { int n = 0; for (Guild guild : discord.getGuilds()) { ByServer bs = getByServer(guild.getIdLong(), true); if (bs != null && bs.shouldBePlaying) n += l(bs.usersInVoiceChannel); } ret n; } S listeningUsers_full() { new LS out; for (Guild guild : discord.getGuilds()) { ByServer bs = getByServer(guild.getIdLong(), true); if (bs != null && bs.shouldBePlaying) { L users = cloneList(bs.usersInVoiceChannel); if (empty(users)) continue; out.add(guild.getName() + ": playing to " + joinWithComma(map(users, id -> or2(discord_memberIDToEffectiveName(guild, id), str(id))))); } } ret lines(out); } }