add message snipe feature
All checks were successful
Deploy to Dokku / docker (ubuntu-latest) (push) Successful in 39s

This commit is contained in:
Lee 2024-07-01 21:20:39 +01:00
parent 727a4c9a6f
commit 8b451c6ee5
16 changed files with 562 additions and 3 deletions

16
pom.xml
View File

@ -108,6 +108,22 @@
<version>5.2.4</version>
</dependency>
<!-- Redis for caching -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<exclusions>
<exclusion>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>
<!-- Libraries -->
<dependency>

View File

@ -18,6 +18,7 @@ public enum Category {
SERVER(Emoji.fromFormatted("U+1F5A5"), "Server", false),
UTILITY(Emoji.fromFormatted("U+1F6E0"), "Utility", false),
MUSIC(Emoji.fromFormatted("U+1F3B5"), "Music", false),
SNIPE(Emoji.fromFormatted("U+1F4A3"), "Snipe", false),
BEAT_SABER(Emoji.fromFormatted("U+1FA84"), "Beat Saber", false),
BOT_ADMIN(null, null, true);

View File

@ -4,6 +4,8 @@ import cc.fascinated.bat.BatApplication;
import lombok.Getter;
import lombok.SneakyThrows;
import org.bson.Document;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.HashMap;
import java.util.Map;
@ -13,6 +15,7 @@ import java.util.Map;
*/
@Getter
public abstract class ProfileHolder {
private static final Logger log = LoggerFactory.getLogger(ProfileHolder.class);
/**
* The profiles for the holder
*/
@ -38,6 +41,8 @@ public abstract class ProfileHolder {
Serializable profile = getProfiles().get(clazz.getSimpleName());
if (profile == null) {
T newProfile = clazz.cast(clazz.getDeclaredConstructors()[0].newInstance());
log.info("instance of profiles: {}", document.get("profiles").getClass().getSimpleName());
Document profiles = document.get("profiles", new org.bson.Document());
Document profileDocument = (Document) profiles.get(clazz.getSimpleName());

View File

@ -0,0 +1,73 @@
package cc.fascinated.bat.config;
import lombok.NonNull;
import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.jedis.JedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
/**
* @author Fascinated (fascinated7)
*/
@Configuration
@Log4j2(topic = "Redis")
public class RedisConfig {
/**
* The Redis server host.
*/
@Value("${spring.data.redis.host}")
private String host;
/**
* The Redis server port.
*/
@Value("${spring.data.redis.port}")
private int port;
/**
* The Redis database index.
*/
@Value("${spring.data.redis.database}")
private int database;
/**
* The optional Redis password.
*/
@Value("${spring.data.redis.auth}")
private String auth;
/**
* Build the config to use for Redis.
*
* @return the config
* @see RedisTemplate for config
*/
@Bean @NonNull
public RedisTemplate<String, Object> redisTemplate() {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(jedisConnectionFactory());
return template;
}
/**
* Build the connection factory to use
* when making connections to Redis.
*
* @return the built factory
* @see JedisConnectionFactory for factory
*/
@Bean @NonNull
public JedisConnectionFactory jedisConnectionFactory() {
log.info("Connecting to Redis at {}:{}/{}", host, port, database);
RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(host, port);
config.setDatabase(database);
if (!auth.trim().isEmpty()) { // Auth with our provided password
log.info("Using auth...");
config.setPassword(auth);
}
return new JedisConnectionFactory(config);
}
}

View File

@ -2,6 +2,7 @@ package cc.fascinated.bat.event;
import cc.fascinated.bat.model.BatGuild;
import cc.fascinated.bat.model.BatUser;
import cc.fascinated.bat.model.DiscordMessage;
import cc.fascinated.bat.model.token.beatsaber.scoresaber.ScoreSaberLeaderboardToken;
import cc.fascinated.bat.model.token.beatsaber.scoresaber.ScoreSaberPlayerScoreToken;
import cc.fascinated.bat.model.token.beatsaber.scoresaber.ScoreSaberScoreToken;
@ -12,7 +13,9 @@ import net.dv8tion.jda.api.events.guild.member.update.GuildMemberUpdateNicknameE
import net.dv8tion.jda.api.events.interaction.ModalInteractionEvent;
import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent;
import net.dv8tion.jda.api.events.interaction.component.StringSelectInteractionEvent;
import net.dv8tion.jda.api.events.message.MessageDeleteEvent;
import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
import net.dv8tion.jda.api.events.message.MessageUpdateEvent;
import net.dv8tion.jda.api.events.user.update.UserUpdateGlobalNameEvent;
/**
@ -57,6 +60,24 @@ public interface EventListener {
default void onGuildMessageReceive(@NonNull BatGuild guild, @NonNull BatUser user, @NonNull MessageReceivedEvent event) {
}
/**
* Called when a user updates a message
*
* @param guild the guild that the message was updated in
* @param user the user that updated the message
*/
default void onGuildMessageEdit(@NonNull BatGuild guild, @NonNull BatUser user, @NonNull MessageUpdateEvent event) {
}
/**
* Called when a user deletes a message
*
* @param guild the guild that the message was deleted in
* @param user the user that deleted the message
*/
default void onGuildMessageDelete(@NonNull BatGuild guild, BatUser user, DiscordMessage message, @NonNull MessageDeleteEvent event) {
}
/**
* Called when a user selects a string
*

View File

@ -8,7 +8,6 @@ import cc.fascinated.bat.common.NumberFormatter;
import cc.fascinated.bat.model.BatGuild;
import cc.fascinated.bat.model.BatUser;
import lombok.NonNull;
import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.OnlineStatus;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member;

View File

@ -0,0 +1,123 @@
package cc.fascinated.bat.features.messagesnipe;
import cc.fascinated.bat.command.Category;
import cc.fascinated.bat.event.EventListener;
import cc.fascinated.bat.features.Feature;
import cc.fascinated.bat.features.messagesnipe.command.MessageSnipeCommand;
import cc.fascinated.bat.model.BatGuild;
import cc.fascinated.bat.model.BatUser;
import cc.fascinated.bat.model.DiscordMessage;
import cc.fascinated.bat.service.CommandService;
import lombok.NonNull;
import net.dv8tion.jda.api.events.message.MessageDeleteEvent;
import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
import net.dv8tion.jda.api.events.message.MessageUpdateEvent;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Component;
import java.util.*;
/**
* @author Fascinated (fascinated7)
*/
@Component
public class MessageSnipeFeature extends Feature implements EventListener {
/**
* The sniped messages for each guild
*/
private static final Map<BatGuild, List<SnipedMessage>> snipedMessages = new HashMap<>();
@Autowired
public MessageSnipeFeature(@NonNull ApplicationContext context, @NonNull CommandService commandService) {
super("Message Snipe", false, Category.SNIPE);
super.registerCommand(commandService, context.getBean(MessageSnipeCommand.class));
}
/**
* Clears the sniped messages for the given guild
*
* @param guild the guild
* @return if the sniped messages were cleared
*/
public static boolean clearSnipedMessages(BatGuild guild) {
if (snipedMessages.containsKey(guild)) {
snipedMessages.remove(guild);
return true;
}
return false;
}
/**
* Gets the sniped messages for the given guild
*
* @param guild the guild
* @return the sniped messages for the given guild
*/
public static SnipedMessage getDeletedMessage(BatGuild guild, String channelId) {
List<SnipedMessage> messages = snipedMessages.getOrDefault(guild, new ArrayList<>());
for (SnipedMessage message : messages) {
if (message.getDeletedDate() != null // Check if the message was deleted
&& message.getMessage().getChannel().getId().equals(channelId)) {
return message;
}
}
return null;
}
/**
* Gets the sniped message with the given id
*
* @param guild the guild
* @param messageId the id of the message
* @return the sniped message with the given id
*/
private SnipedMessage getSnipedMessage(BatGuild guild, String messageId) {
List<SnipedMessage> messages = snipedMessages.getOrDefault(guild, new ArrayList<>());
for (SnipedMessage message : messages) {
if (message.getMessage().getId().equals(messageId)) {
return message;
}
}
return null;
}
@Override
public void onGuildMessageReceive(@NonNull BatGuild guild, @NonNull BatUser user, @NonNull MessageReceivedEvent event) {
if (event.getAuthor().isBot()) return;
List<SnipedMessage> messages = snipedMessages.getOrDefault(guild, new ArrayList<>());
if (messages.size() >= 10) {
messages.remove(0);
}
messages.add(new SnipedMessage(event.getMessage(), null));
snipedMessages.put(guild, messages);
}
@Override
public void onGuildMessageDelete(@NonNull BatGuild guild, BatUser user, DiscordMessage message, @NonNull MessageDeleteEvent event) {
List<SnipedMessage> messages = snipedMessages.getOrDefault(guild, new ArrayList<>());
if (messages.size() >= 10) {
messages.remove(0);
}
SnipedMessage snipedMessage = getSnipedMessage(guild, event.getMessageId());
if (snipedMessage == null) {
return;
}
snipedMessage.setDeletedDate(new Date());
}
@Override
public void onGuildMessageEdit(@NonNull BatGuild guild, @NonNull BatUser user, @NonNull MessageUpdateEvent event) {
List<SnipedMessage> messages = snipedMessages.getOrDefault(guild, new ArrayList<>());
if (messages.size() >= 10) {
messages.remove(0);
}
SnipedMessage snipedMessage = getSnipedMessage(guild, event.getMessageId());
if (snipedMessage == null) {
return;
}
snipedMessage.setMessage(event.getMessage());
}
}

View File

@ -0,0 +1,26 @@
package cc.fascinated.bat.features.messagesnipe;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;
import net.dv8tion.jda.api.entities.Message;
import java.util.Date;
/**
* @author Fascinated (fascinated7)
*/
@AllArgsConstructor
@Getter
@Setter
public class SnipedMessage {
/**
* The message that was sniped
*/
private Message message;
/**
* The date when the message was deleted
*/
private Date deletedDate;
}

View File

@ -0,0 +1,36 @@
package cc.fascinated.bat.features.messagesnipe.command;
import cc.fascinated.bat.command.BatSubCommand;
import cc.fascinated.bat.command.CommandInfo;
import cc.fascinated.bat.common.EmbedUtils;
import cc.fascinated.bat.features.messagesnipe.MessageSnipeFeature;
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.SlashCommandInteraction;
import org.springframework.stereotype.Component;
/**
* @author Fascinated (fascinated7)
*/
@Component("messagesnipe:clear.sub")
@CommandInfo(name = "clear", description = "Clears the known sniped messages for this guild", requiredPermissions = Permission.MESSAGE_MANAGE)
public class ClearSubCommand extends BatSubCommand {
@Override
public void execute(BatGuild guild, @NonNull BatUser user, @NonNull MessageChannel channel, Member member, @NonNull SlashCommandInteraction event) {
boolean cleared = MessageSnipeFeature.clearSnipedMessages(guild);
if (!cleared) {
event.replyEmbeds(EmbedUtils.errorEmbed()
.setDescription("There are no messages to clear in this guild")
.build()).queue();
return;
}
event.replyEmbeds(EmbedUtils.successEmbed()
.setDescription("Successfully cleared the sniped messages for this guild")
.build()).queue();
}
}

View File

@ -0,0 +1,50 @@
package cc.fascinated.bat.features.messagesnipe.command;
import cc.fascinated.bat.command.BatSubCommand;
import cc.fascinated.bat.command.CommandInfo;
import cc.fascinated.bat.common.EmbedUtils;
import cc.fascinated.bat.features.messagesnipe.MessageSnipeFeature;
import cc.fascinated.bat.features.messagesnipe.SnipedMessage;
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.User;
import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel;
import net.dv8tion.jda.api.interactions.commands.SlashCommandInteraction;
import org.springframework.stereotype.Component;
/**
* @author Fascinated (fascinated7)
*/
@Component
@CommandInfo(name = "deleted", description = "Snipe the last deleted message in this channel")
public class DeletedSubCommand extends BatSubCommand {
@Override
public void execute(BatGuild guild, @NonNull BatUser user, @NonNull MessageChannel channel, Member member, @NonNull SlashCommandInteraction event) {
SnipedMessage message = MessageSnipeFeature.getDeletedMessage(guild, channel.getId());
if (message == null) {
event.replyEmbeds(EmbedUtils.errorEmbed()
.setDescription("There are no deleted messages to snipe in this channel")
.build()).queue();
return;
}
User author = message.getMessage().getAuthor();
event.replyEmbeds(EmbedUtils.genericEmbed()
.setDescription("""
**Deleted Message Snipe**
Author: **%s** (%s)
Deleted: <t:%d:R>
Content:
```
%s
```
""".formatted(
author.getAsMention(),
author.getId(),
message.getDeletedDate().getTime() / 1000,
message.getMessage().getContentRaw()
)).build()).queue();
}
}

View File

@ -0,0 +1,19 @@
package cc.fascinated.bat.features.messagesnipe.command;
import cc.fascinated.bat.command.BatCommand;
import cc.fascinated.bat.command.CommandInfo;
import lombok.NonNull;
import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Component;
/**
* @author Fascinated (fascinated7)
*/
@Component
@CommandInfo(name = "snipe", description = "Snipe messages")
public class MessageSnipeCommand extends BatCommand {
public MessageSnipeCommand(@NonNull ApplicationContext context) {
super.addSubCommand(context.getBean(DeletedSubCommand.class));
super.addSubCommand(context.getBean(ClearSubCommand.class));
}
}

View File

@ -0,0 +1,49 @@
package cc.fascinated.bat.model;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;
import org.springframework.data.redis.core.RedisHash;
/**
* @author Fascinated (fascinated7)
*/
@AllArgsConstructor
@Getter @Setter
@RedisHash(value = "DiscordMessage", timeToLive = 86400) // 24 hours
public class DiscordMessage {
/**
* The snowflake ID of the message
*/
private final String id;
/**
* The timestamp when the message was sent
*/
private final long timestamp;
/**
* The snowflake ID of the channel the message was sent in
*/
private final String channelId;
/**
* The snowflake ID of the guild the message was sent in
*/
private final String guildId;
/**
* The snowflake ID of the author of the message
*/
private final String authorId;
/**
* The content of the message
*/
private String content;
/**
* Whether the message was deleted
*/
private boolean deleted;
}

View File

@ -0,0 +1,9 @@
package cc.fascinated.bat.repository;
import cc.fascinated.bat.model.DiscordMessage;
import org.springframework.data.repository.CrudRepository;
/**
* @author Fascinated (fascinated7)
*/
public interface DiscordMessageRepository extends CrudRepository<DiscordMessage, String> {}

View File

@ -0,0 +1,95 @@
package cc.fascinated.bat.service;
import cc.fascinated.bat.event.EventListener;
import cc.fascinated.bat.model.BatGuild;
import cc.fascinated.bat.model.BatUser;
import cc.fascinated.bat.model.DiscordMessage;
import cc.fascinated.bat.repository.DiscordMessageRepository;
import lombok.NonNull;
import lombok.extern.log4j.Log4j2;
import net.dv8tion.jda.api.events.message.MessageDeleteEvent;
import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
import net.dv8tion.jda.api.events.message.MessageUpdateEvent;
import net.dv8tion.jda.api.hooks.ListenerAdapter;
import org.jetbrains.annotations.NotNull;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.DependsOn;
import org.springframework.stereotype.Service;
import java.util.Optional;
/**
* @author Fascinated (fascinated7)
*/
@Service
@Log4j2
@DependsOn("discordService")
public class DiscordMessageService extends ListenerAdapter {
private final DiscordMessageRepository discordMessageRepository;
private final GuildService guildService;
private final UserService userService;
@Autowired
public DiscordMessageService(DiscordMessageRepository discordMessageRepository, @NonNull GuildService guildService,
@NonNull UserService userService) {
this.discordMessageRepository = discordMessageRepository;
this.guildService = guildService;
this.userService = userService;
DiscordService.JDA.addEventListener(this);
}
/**
* Gets the message with the given id
*
* @param messageId the id of the message
* @return the message with the given id
*/
public DiscordMessage getMessage(String messageId) {
Optional<DiscordMessage> optionalMessage = discordMessageRepository.findById(messageId);
return optionalMessage.orElse(null);
}
@Override
public void onMessageReceived(@NotNull MessageReceivedEvent event) {
discordMessageRepository.save(new DiscordMessage(
event.getMessageId(),
event.getMessage().getTimeCreated().toInstant().toEpochMilli(),
event.getChannel().getId(),
event.getGuild().getId(),
event.getAuthor().getId(),
event.getMessage().getContentRaw(),
false
));
}
@Override
public void onMessageUpdate(@NotNull MessageUpdateEvent event) {
Optional<DiscordMessage> message = discordMessageRepository.findById(event.getMessageId());
if (message.isPresent()) {
DiscordMessage discordMessage = message.get();
if (discordMessage.getContent().equals(event.getMessage().getContentRaw())) {
return;
}
discordMessage.setContent(event.getMessage().getContentRaw());
discordMessageRepository.save(discordMessage);
}
BatGuild guild = guildService.getGuild(event.getGuild().getId());
BatUser user = userService.getUser(event.getAuthor().getId());
for (EventListener listener : EventService.LISTENERS) {
listener.onGuildMessageEdit(guild, user, event);
}
}
@Override
public void onMessageDelete(@NotNull MessageDeleteEvent event) {
Optional<DiscordMessage> optionalMessage = discordMessageRepository.findById(event.getMessageId());
if (optionalMessage.isEmpty()) {
return;
}
DiscordMessage message = optionalMessage.get();
message.setDeleted(true);
discordMessageRepository.save(message);
}
}

View File

@ -3,6 +3,7 @@ package cc.fascinated.bat.service;
import cc.fascinated.bat.event.EventListener;
import cc.fascinated.bat.model.BatGuild;
import cc.fascinated.bat.model.BatUser;
import cc.fascinated.bat.model.DiscordMessage;
import lombok.NonNull;
import lombok.extern.log4j.Log4j2;
import net.dv8tion.jda.api.events.guild.member.GuildMemberJoinEvent;
@ -11,7 +12,9 @@ import net.dv8tion.jda.api.events.guild.member.update.GuildMemberUpdateNicknameE
import net.dv8tion.jda.api.events.interaction.ModalInteractionEvent;
import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent;
import net.dv8tion.jda.api.events.interaction.component.StringSelectInteractionEvent;
import net.dv8tion.jda.api.events.message.MessageDeleteEvent;
import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
import net.dv8tion.jda.api.events.message.MessageUpdateEvent;
import net.dv8tion.jda.api.events.user.update.UserUpdateGlobalNameEvent;
import net.dv8tion.jda.api.hooks.ListenerAdapter;
import org.jetbrains.annotations.NotNull;
@ -36,11 +39,14 @@ public class EventService extends ListenerAdapter {
public static final Set<EventListener> LISTENERS = new HashSet<>();
private final GuildService guildService;
private final UserService userService;
private final DiscordMessageService discordMessageService;
@Autowired
public EventService(@NonNull GuildService guildService, @NonNull UserService userService, @NonNull ApplicationContext context) {
public EventService(@NonNull ApplicationContext context, @NonNull GuildService guildService, @NonNull UserService userService,
@NonNull DiscordMessageService discordMessageService) {
this.guildService = guildService;
this.userService = userService;
this.discordMessageService = discordMessageService;
DiscordService.JDA.addEventListener(this);
context.getBeansOfType(EventListener.class).values().forEach(this::registerListeners);
@ -95,6 +101,30 @@ public class EventService extends ListenerAdapter {
}
}
@Override
public void onMessageUpdate(@NotNull MessageUpdateEvent event) {
if (event.getAuthor().isBot()) {
return;
}
BatGuild guild = guildService.getGuild(event.getGuild().getId());
BatUser user = userService.getUser(event.getAuthor().getId());
for (EventListener listener : LISTENERS) {
listener.onGuildMessageEdit(guild, user, event);
}
}
@Override
public void onMessageDelete(@NotNull MessageDeleteEvent event) {
BatGuild guild = guildService.getGuild(event.getGuild().getId());
DiscordMessage message = discordMessageService.getMessage(event.getMessageId());
BatUser user = message == null ? null : userService.getUser(message.getAuthorId());
for (EventListener listener : LISTENERS) {
listener.onGuildMessageDelete(guild, user, message, event);
}
}
@Override
public void onStringSelectInteraction(StringSelectInteractionEvent event) {
if (event.getUser().isBot()) {

View File

@ -27,4 +27,11 @@ spring:
mongodb:
uri: "mongodb://bat:p4$$w0rd@localhost:27017"
database: "bat"
auto-index-creation: true # Automatically create collection indexes
auto-index-creation: true # Automatically create collection indexes
# Redis - This is used for caching
redis:
host: "localhost"
port: 6379
database: 0
auth: "" # Leave blank for no auth