diff --git a/src/main/java/cc/fascinated/bat/features/statschannel/StatsChannel.java b/src/main/java/cc/fascinated/bat/features/statschannel/StatsChannel.java new file mode 100644 index 0000000..784b71a --- /dev/null +++ b/src/main/java/cc/fascinated/bat/features/statschannel/StatsChannel.java @@ -0,0 +1,25 @@ +package cc.fascinated.bat.features.statschannel; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; + +/** + * @author Fascinated (fascinated7) + */ +@AllArgsConstructor +@Getter +@Setter +public class StatsChannel { + /** + * The display of the stats channel + */ + private final String display; + + /** + * The last value of the stats channel, used to compare + * the new value to the old value, so we don't have to + * update the channel every time + */ + private String lastValue; +} diff --git a/src/main/java/cc/fascinated/bat/features/statschannel/StatsChannelFeature.java b/src/main/java/cc/fascinated/bat/features/statschannel/StatsChannelFeature.java new file mode 100644 index 0000000..6215655 --- /dev/null +++ b/src/main/java/cc/fascinated/bat/features/statschannel/StatsChannelFeature.java @@ -0,0 +1,67 @@ +package cc.fascinated.bat.features.statschannel; + +import cc.fascinated.bat.features.Feature; +import cc.fascinated.bat.features.FeatureProfile; +import cc.fascinated.bat.features.statschannel.command.StatsChannelCommand; +import cc.fascinated.bat.model.BatGuild; +import cc.fascinated.bat.service.CommandService; +import cc.fascinated.bat.service.DiscordService; +import cc.fascinated.bat.service.GuildService; +import lombok.NonNull; +import lombok.extern.log4j.Log4j2; +import net.dv8tion.jda.api.entities.Guild; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * @author Fascinated (fascinated7) + */ +@Component +@Log4j2(topic = "Stats Channel Feature") +public class StatsChannelFeature extends Feature { + private final GuildService guildService; + + @Autowired + public StatsChannelFeature(@NonNull ApplicationContext context, @NonNull CommandService commandService, @NonNull GuildService guildService) { + super("Stats Channels", FeatureProfile.FeatureState.DISABLED, true); + this.guildService = guildService; + + super.registerCommand(commandService, context.getBean(StatsChannelCommand.class)); + } + + /** + * Scheduled task to update the stats channel + * This will run every hour + */ + @Scheduled(cron = "0 0 * * * *") + public void updateStatsChannel() { + for (Guild guild : DiscordService.JDA.getGuilds()) { + BatGuild batGuild = guildService.getGuild(guild.getId()); + assert batGuild != null; + if (batGuild.getFeatureProfile().isFeatureDisabled(this)) { + continue; + } + StatsChannelProfile profile = batGuild.getStatsChannelProfile(); + profile.refreshChannels(batGuild); + } + } + + /** + * Get the placeholders that the user can use + * + * @param replacements What to replace the placeholders using + * @return The placeholders + */ + public static String getPlaceholders(Object... replacements) { + StringBuilder builder = new StringBuilder(); + builder.append("**Available Placeholders:**\n"); + for (StatsPlaceholders placeholder : StatsPlaceholders.values()) { + String placeholderString = placeholder.getPlaceholder(); + builder.append("`").append(placeholderString).append("` - ") + .append(StatsPlaceholders.replaceAllPlaceholders(placeholderString, replacements)).append("\n"); + } + return builder.toString(); + } +} diff --git a/src/main/java/cc/fascinated/bat/features/statschannel/StatsChannelProfile.java b/src/main/java/cc/fascinated/bat/features/statschannel/StatsChannelProfile.java new file mode 100644 index 0000000..13d90e5 --- /dev/null +++ b/src/main/java/cc/fascinated/bat/features/statschannel/StatsChannelProfile.java @@ -0,0 +1,119 @@ +package cc.fascinated.bat.features.statschannel; + +import cc.fascinated.bat.common.Serializable; +import cc.fascinated.bat.model.BatGuild; +import cc.fascinated.bat.service.DiscordService; +import com.google.gson.Gson; +import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel; +import org.bson.Document; + +import java.util.HashMap; +import java.util.Map; + +/** + * @author Fascinated (fascinated7) + */ +public class StatsChannelProfile extends Serializable { + /** + * The channels to show stats on. + */ + private final Map channels = new HashMap<>(); + + /** + * Gets the channels to show stats on. + * + * @return the channels + */ + public Map getChannels() { + Map textChannels = new HashMap<>(); + for (Map.Entry entry : channels.entrySet()) { + VoiceChannel textChannel = DiscordService.JDA.getVoiceChannelById(entry.getKey()); + if (textChannel != null) { + textChannels.put(textChannel, entry.getValue()); + } + } + return textChannels; + } + + /** + * Gets a channel + * + * @param id the id of the channel + * @return the channel + */ + public VoiceChannel getChannel(String id) { + if (!channels.containsKey(id)) { + return null; + } + return DiscordService.JDA.getVoiceChannelById(id); + } + + /** + * Adds a new stats channel + * + * @param channel the channel + * @param text the text to display + */ + public void addChannel(VoiceChannel channel, String text) { + channels.put(channel.getId(), new StatsChannel(text, "")); + } + + /** + * Removes a channel + * + * @param channel the channel + */ + public void removeChannel(VoiceChannel channel) { + channels.remove(channel.getId()); + } + + /** + * Updates all the stats channels for the guild + */ + public void refreshChannels(BatGuild guild) { + for (Map.Entry entry : this.channels.entrySet()) { + VoiceChannel channel = DiscordService.JDA.getVoiceChannelById(entry.getKey()); + if (channel == null) { + continue; + } + StatsChannel statsChannel = entry.getValue(); + String display = StatsPlaceholders.replaceAllPlaceholders(statsChannel.getDisplay(), guild); + if (statsChannel.getLastValue().equals(display)) { // Don't update if the value is the same + continue; + } + channel.getManager().setName(display).queue(); + } + } + + @Override + public void load(Document document, Gson gson) { + Document channelsDocument = (Document) document.getOrDefault("channels", new Document()); + for (Map.Entry entry : channelsDocument.entrySet()) { + Document channelDocument = (Document) entry.getValue(); + channels.put(entry.getKey(), new StatsChannel( + channelDocument.getString("display"), + channelDocument.getString("lastValue") + )); + } + } + + @Override + public Document serialize(Gson gson) { + Document document = new Document(); + Document channelsDocument = new Document(); + for (Map.Entry entry : channels.entrySet()) { + StatsChannel statsChannel = entry.getValue(); + Document channelDocument = new Document(); + channelDocument.put("display", statsChannel.getDisplay()); + channelDocument.put("lastValue", statsChannel.getLastValue()); + channelsDocument.put(entry.getKey(), channelDocument); + } + document.put("channels", channelsDocument); + return document; + } + + @Override + public void reset() { + channels.clear(); + } +} diff --git a/src/main/java/cc/fascinated/bat/features/statschannel/StatsPlaceholders.java b/src/main/java/cc/fascinated/bat/features/statschannel/StatsPlaceholders.java new file mode 100644 index 0000000..9ae1f5c --- /dev/null +++ b/src/main/java/cc/fascinated/bat/features/statschannel/StatsPlaceholders.java @@ -0,0 +1,81 @@ +package cc.fascinated.bat.features.statschannel; + +import cc.fascinated.bat.common.NumberFormatter; +import cc.fascinated.bat.model.BatGuild; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * @author Fascinated (fascinated7) + */ +@AllArgsConstructor +@Getter +public enum StatsPlaceholders { + MEMBER_COUNT("{member_count}", BatGuild.class) { + @Override + public String replacePlaceholder(Object object) { + return NumberFormatter.simpleFormat(((BatGuild) object).getDiscordGuild().getMembers().size()); + } + }, + BOT_COUNT("{bot_count}", BatGuild.class) { + @Override + public String replacePlaceholder(Object object) { + return NumberFormatter.simpleFormat(((BatGuild) object).getDiscordGuild().getMembers().stream() + .filter(member -> member.getUser().isBot()).count()); + } + }, + CHANNEL_COUNT("{channel_count}", BatGuild.class) { + @Override + public String replacePlaceholder(Object object) { + return NumberFormatter.simpleFormat(((BatGuild) object).getDiscordGuild().getChannels().size()); + } + }, + ROLE_COUNT("{role_count}", BatGuild.class) { + @Override + public String replacePlaceholder(Object object) { + return NumberFormatter.simpleFormat(((BatGuild) object).getDiscordGuild().getRoles().size()); + } + }; + + /** + * The placeholder string that will get replaced + */ + private final String placeholder; + + /** + * The class that the placeholder is associated with + */ + private final Class clazz; + + /** + * Replaces the placeholder with the string based on the overridden method + * + * @param object The object to replace the placeholder with + * @return The string with the placeholder replaced + */ + public String replacePlaceholder(Object object) { + if (clazz != null && !clazz.isInstance(object)) { + throw new IllegalArgumentException("Object is not an instance of " + clazz.getName()); + } + return null; + } + + /** + * Replaces all placeholders in a message with the objects provided + * + * @param message The message to replace the placeholders in + * @param objects The objects to replace the placeholders with + * @return The message with the placeholders replaced + */ + public static String replaceAllPlaceholders(String message, Object... objects) { + for (StatsPlaceholders placeholder : values()) { + for (Object object : objects) { + if (placeholder.getClazz() != null && !placeholder.getClazz().isInstance(object)) { + continue; + } + message = message.replace(placeholder.getPlaceholder(), placeholder.replacePlaceholder(object)); + } + } + return message; + } +} diff --git a/src/main/java/cc/fascinated/bat/features/statschannel/command/AddSubCommand.java b/src/main/java/cc/fascinated/bat/features/statschannel/command/AddSubCommand.java new file mode 100644 index 0000000..6b6e999 --- /dev/null +++ b/src/main/java/cc/fascinated/bat/features/statschannel/command/AddSubCommand.java @@ -0,0 +1,78 @@ +package cc.fascinated.bat.features.statschannel.command; + +import cc.fascinated.bat.Emojis; +import cc.fascinated.bat.command.BatCommand; +import cc.fascinated.bat.command.CommandInfo; +import cc.fascinated.bat.common.EmbedDescriptionBuilder; +import cc.fascinated.bat.common.EmbedUtils; +import cc.fascinated.bat.features.statschannel.StatsChannelProfile; +import cc.fascinated.bat.model.BatGuild; +import cc.fascinated.bat.model.BatUser; +import lombok.NonNull; +import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.SlashCommandInteraction; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import org.springframework.stereotype.Component; + +import java.util.Collections; + +/** + * @author Fascinated (fascinated7) + */ +@Component("stats-channel.add:sub") +@CommandInfo( + name = "add", + description = "Add a stats channel" +) +public class AddSubCommand extends BatCommand { + public AddSubCommand() { + super.addOptions(new OptionData(OptionType.STRING, "display", "The text to display in the stats channel", true)); + } + + @Override + public void execute(BatGuild guild, @NonNull BatUser user, @NonNull MessageChannel channel, Member member, @NonNull SlashCommandInteraction event) { + OptionMapping displayOption = event.getOption("display"); + assert displayOption != null; + + String display = displayOption.getAsString(); + if (display.length() > 24) { + event.replyEmbeds(EmbedUtils.errorEmbed() + .setDescription("%s The channel name can only be a maximum of 32 characters".formatted(Emojis.CROSS_MARK_EMOJI)) + .build()).queue(); + return; + } + + StatsChannelProfile profile = guild.getStatsChannelProfile(); + if (profile.getChannels().size() >= 5) { + event.replyEmbeds(EmbedUtils.errorEmbed() + .setDescription("%s You can only have a maximum of 5 stats channels".formatted(Emojis.CROSS_MARK_EMOJI)) + .build()).queue(); + return; + } + + guild.getDiscordGuild().createVoiceChannel("stats channel").queue(voiceChannel -> { + // Disallow everyone from connecting + voiceChannel.getManager().putPermissionOverride( + guild.getDiscordGuild().getPublicRole(), + null, + Collections.singleton(Permission.VOICE_CONNECT) + ).queue(); + + profile.addChannel(voiceChannel, display); + profile.refreshChannels(guild); // For the stat channels to be updated + + event.replyEmbeds(EmbedUtils.successEmbed() + .setDescription(new EmbedDescriptionBuilder("%s The stats chanel %s has been created".formatted( + Emojis.CHECK_MARK_EMOJI, + voiceChannel.getAsMention() + )) + .appendLine("Display: `%s`".formatted(display), true) + .build()) + .build()).queue(); + }); + } +} diff --git a/src/main/java/cc/fascinated/bat/features/statschannel/command/CurrentSubCommand.java b/src/main/java/cc/fascinated/bat/features/statschannel/command/CurrentSubCommand.java new file mode 100644 index 0000000..d595882 --- /dev/null +++ b/src/main/java/cc/fascinated/bat/features/statschannel/command/CurrentSubCommand.java @@ -0,0 +1,49 @@ +package cc.fascinated.bat.features.statschannel.command; + +import cc.fascinated.bat.command.BatCommand; +import cc.fascinated.bat.command.CommandInfo; +import cc.fascinated.bat.common.EmbedDescriptionBuilder; +import cc.fascinated.bat.common.EmbedUtils; +import cc.fascinated.bat.features.statschannel.StatsChannel; +import cc.fascinated.bat.features.statschannel.StatsChannelFeature; +import cc.fascinated.bat.features.statschannel.StatsChannelProfile; +import cc.fascinated.bat.model.BatGuild; +import cc.fascinated.bat.model.BatUser; +import lombok.NonNull; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel; +import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; +import net.dv8tion.jda.api.interactions.commands.SlashCommandInteraction; +import org.springframework.stereotype.Component; + +import java.util.Map; + +/** + * @author Fascinated (fascinated7) + */ +@Component("stats-channel.current:sub") +@CommandInfo( + name = "current", + description = "Shows the current stats channels" +) +public class CurrentSubCommand extends BatCommand { + @Override + public void execute(BatGuild guild, @NonNull BatUser user, @NonNull MessageChannel channel, Member member, @NonNull SlashCommandInteraction event) { + StatsChannelProfile profile = guild.getStatsChannelProfile(); + EmbedDescriptionBuilder builder = new EmbedDescriptionBuilder("Stats Channels"); + if (profile.getChannels().isEmpty()) { + builder.appendLine("You have no stats channels set up", true); + } else { + for (Map.Entry entry : profile.getChannels().entrySet()) { + builder.appendLine("%s: `%s`".formatted(entry.getKey().getAsMention(), entry.getValue().getDisplay()), true); + } + } + builder.emptyLine(); + builder.appendLine(StatsChannelFeature.getPlaceholders(guild), false); + + event.replyEmbeds(EmbedUtils.successEmbed() + .setDescription(builder.build()) + .build()) + .queue(); + } +} diff --git a/src/main/java/cc/fascinated/bat/features/statschannel/command/RemoveSubCommand.java b/src/main/java/cc/fascinated/bat/features/statschannel/command/RemoveSubCommand.java new file mode 100644 index 0000000..071ef7b --- /dev/null +++ b/src/main/java/cc/fascinated/bat/features/statschannel/command/RemoveSubCommand.java @@ -0,0 +1,64 @@ +package cc.fascinated.bat.features.statschannel.command; + +import cc.fascinated.bat.Emojis; +import cc.fascinated.bat.command.BatCommand; +import cc.fascinated.bat.command.CommandInfo; +import cc.fascinated.bat.common.EmbedUtils; +import cc.fascinated.bat.features.statschannel.StatsChannelProfile; +import cc.fascinated.bat.model.BatGuild; +import cc.fascinated.bat.model.BatUser; +import lombok.NonNull; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.channel.ChannelType; +import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel; +import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; +import net.dv8tion.jda.api.entities.channel.unions.GuildChannelUnion; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.SlashCommandInteraction; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import org.springframework.stereotype.Component; + +/** + * @author Fascinated (fascinated7) + */ +@Component("stats-channel.remove:sub") +@CommandInfo( + name = "remove", + description = "Remove a stats channel" +) +public class RemoveSubCommand extends BatCommand { + public RemoveSubCommand() { + super.addOptions(new OptionData(OptionType.CHANNEL, "channel", "The stats channel to remove", true)); + } + + @Override + public void execute(BatGuild guild, @NonNull BatUser user, @NonNull MessageChannel channel, Member member, @NonNull SlashCommandInteraction event) { + OptionMapping channelOption = event.getOption("channel"); + assert channelOption != null; + + GuildChannelUnion channelUnion = channelOption.getAsChannel(); + if (channelUnion.getType() != ChannelType.VOICE) { + event.replyEmbeds(EmbedUtils.errorEmbed() + .setDescription("%s The channel must be a voice channel".formatted(Emojis.CROSS_MARK_EMOJI)) + .build()).queue(); + return; + } + + StatsChannelProfile profile = guild.getStatsChannelProfile(); + VoiceChannel statsChannel = profile.getChannel(channelUnion.getId()); + if (statsChannel == null) { + event.replyEmbeds(EmbedUtils.errorEmbed() + .setDescription("%s The channel is not a stats channel".formatted(Emojis.CROSS_MARK_EMOJI)) + .build()).queue(); + return; + } + + statsChannel.delete().queue(then -> { + profile.removeChannel(statsChannel); + event.replyEmbeds(EmbedUtils.successEmbed() + .setDescription("%s The stats channel has been removed".formatted(Emojis.CHECK_MARK_EMOJI)) + .build()).queue(); + }); + } +} diff --git a/src/main/java/cc/fascinated/bat/features/statschannel/command/StatsChannelCommand.java b/src/main/java/cc/fascinated/bat/features/statschannel/command/StatsChannelCommand.java new file mode 100644 index 0000000..844c9e5 --- /dev/null +++ b/src/main/java/cc/fascinated/bat/features/statschannel/command/StatsChannelCommand.java @@ -0,0 +1,29 @@ +package cc.fascinated.bat.features.statschannel.command; + +import cc.fascinated.bat.command.BatCommand; +import cc.fascinated.bat.command.CommandInfo; +import lombok.NonNull; +import net.dv8tion.jda.api.Permission; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.stereotype.Component; + +/** + * @author Fascinated (fascinated7) + */ +@Component +@CommandInfo( + name = "stats-channel", + description = "Change your stats channel settings", + requiredPermissions = Permission.MANAGE_SERVER +) +public class StatsChannelCommand extends BatCommand { + @Autowired + public StatsChannelCommand(@NonNull ApplicationContext context) { + super.addSubCommands( + context.getBean(AddSubCommand.class), + context.getBean(RemoveSubCommand.class), + context.getBean(CurrentSubCommand.class) + ); + } +} diff --git a/src/main/java/cc/fascinated/bat/model/BatGuild.java b/src/main/java/cc/fascinated/bat/model/BatGuild.java index 8331e0b..5748f4b 100644 --- a/src/main/java/cc/fascinated/bat/model/BatGuild.java +++ b/src/main/java/cc/fascinated/bat/model/BatGuild.java @@ -11,6 +11,7 @@ import cc.fascinated.bat.features.logging.LogProfile; import cc.fascinated.bat.features.minecraft.MinecraftProfile; import cc.fascinated.bat.features.namehistory.profile.guild.NameHistoryProfile; import cc.fascinated.bat.features.reminder.ReminderProfile; +import cc.fascinated.bat.features.statschannel.StatsChannelProfile; import cc.fascinated.bat.features.welcomer.WelcomerProfile; import cc.fascinated.bat.premium.PremiumProfile; import cc.fascinated.bat.service.DiscordService; @@ -180,6 +181,15 @@ public class BatGuild extends ProfileHolder { return getProfile(MinecraftProfile.class); } + /** + * Gets the stats channel profile + * + * @return the stats channel profile + */ + public StatsChannelProfile getStatsChannelProfile() { + return getProfile(StatsChannelProfile.class); + } + /** * Saves the user */