Not logged in.  Login/Logout/Register | List snippets | | Create snippet | Upload image | Upload data

636
LINES

< > BotCompany Repo | #1034055 // MKLab FM Bot with JDA 4.2 [using sharded classes]

JavaX source code (Dynamic Module) - run with: Stefan's OS

!7

static MapSO _renameClasses = litmap(
  "DynDiscordHopper" := "DynShardedDiscordHopper",
  "DynServerAwareDiscordBot" := "DynShardedServerAwareDiscordBot",
  "DynTalkBot2" := "DynShardedTalkBot2");

set flag JDA40.

// 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 DynShardedTalkBot2<.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=<RNDINT>";
  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: <https://tinyurl.com/musikcolabradiofm>
Support channel: <https://discord.gg/u6E4muY>
Bot by: <https://BotCompany.de>
You like this bot? Donate: <https://paypal.me/BotCompany>
]]);

  switchable S voteLink = "\n\nPlease help us grow by voting here: <https://discordbots.org/bot/625377645982384128>";
  
  switchable S npMsg = "Now playing: <track> - 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<byte[]> 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 DynShardedTalkBot2.ByServer {
    bool shouldBePlaying;
    long voiceChannelID, lastVoiceChannelID;
    long songChannelID;
    
    transient S voiceChannelName;
    transient VoiceChannel vc;
    transient MyAudioSendHandler sendHandler;
    transient Set<Long> 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: <message>` - 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 <track>...` - 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<Guild> 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<Member> 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<VoiceChannel> allChannels = guild.getVoiceChannels();
      L<VoiceChannel> 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<VoiceChannel> 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<VoiceChannel> 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("<track>", 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<Guild> guilds = discord.getGuilds();
    for (Guild guild : guilds) pcall {
      ByServer bs = getByServer(guild);
      if (bs != null)
        bs.hardRestart(guild);
    }
  }*/
  
  S dmOwners(S msg) {
    L<Guild> 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("<RNDINT>", 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<Guild> 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<Long> 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);
  }
}

Author comment

Began life as a copy of #1030356

download  show line numbers  debug dex  old transpilations   

Travelled to 2 computer(s): bhatertpkbcr, mqqgnosmbjvj

No comments. add comment

-
Snippet ID: #1034055
Snippet name: MKLab FM Bot with JDA 4.2 [using sharded classes]
Eternal ID of this version: #1034055/1
Text MD5: 514ccbc82ecb6fedfbde596d2596b4ac
Author: stefan
Category: javax
Type: JavaX source code (Dynamic Module)
Public (visible to everyone): Yes
Archived (hidden from active list): No
Created/modified: 2022-01-19 17:08:06
Source code size: 22867 bytes / 636 lines
Pitched / IR pitched: No / No
Views / Downloads: 103 / 121
Referenced in: