Added opt-in mechanism for receiving pings on mentions. (Closes #2)
All checks were successful
Build Distribution / Build (push) Successful in 42s

This commit is contained in:
2024-09-15 13:00:33 -04:00
parent b3ea0d9bfd
commit fd26714ed2
13 changed files with 224 additions and 69 deletions

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2022 Logan Fick
* Copyright 2024 Logan Fick
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at: https://www.apache.org/licenses/LICENSE-2.0
*
@@ -12,6 +12,7 @@ import dev.logal.crabstero.listeners.*;
import org.javacord.api.DiscordApi;
import org.javacord.api.DiscordApiBuilder;
import org.javacord.api.entity.intent.Intent;
import org.javacord.api.interaction.SlashCommand;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import redis.clients.jedis.JedisPool;
@@ -51,6 +52,7 @@ public final class Crabstero {
builder.setIntents(Intent.GUILDS, Intent.GUILD_MESSAGES, Intent.MESSAGE_CONTENT);
builder.addListener(new InteractionCreate(this));
builder.addListener(new MessageCreate(this));
builder.addListener(new RoleChangePermissions(this));
builder.addListener(new ServerBecomesAvailable(this));
@@ -62,6 +64,11 @@ public final class Crabstero {
this.discordApi = builder.login().join();
this.discordApi.setMessageCacheSize(0, 1);
SlashCommand.with("pingme", "Opts into (or back out of) receiving pings for generated message which mention you.")
.setDefaultEnabledForEveryone()
.createGlobal(this.discordApi)
.join();
}
/**

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2022 Logan Fick
* Copyright 2024 Logan Fick
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at: https://www.apache.org/licenses/LICENSE-2.0
*

View File

@@ -0,0 +1,96 @@
/*
* Copyright 2024 Logan Fick
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at: https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
package dev.logal.crabstero.listeners;
import dev.logal.crabstero.Crabstero;
import dev.logal.crabstero.utils.FlagManager;
import org.javacord.api.entity.message.MessageFlag;
import org.javacord.api.event.interaction.InteractionCreateEvent;
import org.javacord.api.interaction.SlashCommandInteraction;
import org.javacord.api.interaction.callback.InteractionImmediateResponseBuilder;
import org.javacord.api.listener.interaction.InteractionCreateListener;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Optional;
/**
* Handles responding to interactions.
*/
public class InteractionCreate implements InteractionCreateListener {
private static final String ALLOW_PINGS_FLAG = "allowPings";
private static final Logger logger = LoggerFactory.getLogger(InteractionCreate.class);
private final FlagManager flagManager;
/**
* Creates a new interaction create handler owned by a given instance of Crabstero.
*
* @param crabstero The instance of Crabstero.
*/
public InteractionCreate(final Crabstero crabstero) {
this.flagManager = new FlagManager(crabstero);
}
/**
* Executes slash commands and responds to the users who run them.
* <p>
* All non-slash command interactions are ignored.
*
* @param event The event.
*/
@Override
public void onInteractionCreate(final InteractionCreateEvent event) {
final Optional<SlashCommandInteraction> potentialCommandInteraction = event.getSlashCommandInteraction();
// Is this interaction not a slash command?
if (potentialCommandInteraction.isEmpty()) {
return; // Only slash commands are used, so ignore this interaction.
}
final SlashCommandInteraction interaction = potentialCommandInteraction.get();
final InteractionImmediateResponseBuilder response = interaction.createImmediateResponder();
final String commandName = interaction.getFullCommandName();
// Since access activity is involved, put all command execution in a try/catch so we always respond to the user if something bad happens.
try {
// `/pingme` command.
if (commandName.equalsIgnoreCase("pingme")) {
// Is this user opted in to receiving pings for mentions?
if (flagManager.isFlagSet(interaction.getUser(), ALLOW_PINGS_FLAG)) {
// Yes. Revert their opt-in.
flagManager.clearFlag(interaction.getUser(), ALLOW_PINGS_FLAG);
response.setContent("I will no longer ping you for messages which mention you. If you decide to opt back in, run `/pingme` any time.");
} else {
// No. Opt them in.
flagManager.setFlag(interaction.getUser(), ALLOW_PINGS_FLAG);
response.setContent("I will now ping you for messages which mention you. If you change your mind, run `/pingme` any time.");
}
} else {
// The only way I think this could be executed is if a new command is registered with the API in the Crabstero class, but this class isn't reflected to know it exists.
// As long as the Crabstero class and this class are in sync, I don't think this should ever happen.
logger.warn("Received slash command interaction for command '{}' which is not implemented!", commandName);
response.setContent("I don't know how to execute that command. Please contact the bot administrator as this shouldn't happen.");
}
} catch (Exception exception) {
logger.error("An exception occurred while attempting to execute slash command responder for command '{}'.", commandName, exception);
response.setContent("An error occurred while executing your command. Please try again later.");
}
// Make the slash command response only visible to the executor.
response.setFlags(MessageFlag.EPHEMERAL);
// Finally, fire off the response to the user!
response.respond();
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2022 Logan Fick
* Copyright 2024 Logan Fick
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at: https://www.apache.org/licenses/LICENSE-2.0
*

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2022 Logan Fick
* Copyright 2024 Logan Fick
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at: https://www.apache.org/licenses/LICENSE-2.0
*
@@ -40,7 +40,7 @@ public final class RoleChangePermissions implements RoleChangePermissionsListene
if (event.getRole().hasUser(event.getApi().getYourself())) {
// Queue every textable channel for message history ingestion.
event.getServer().getRegularChannels().forEach((channel) -> {
if (channel instanceof final TextableRegularServerChannel textableChannel){
if (channel instanceof final TextableRegularServerChannel textableChannel) {
this.crabstero.getWorkerPool().submit(new ChannelHistoryIngestionTask(textableChannel, crabstero));
}
});

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2022 Logan Fick
* Copyright 2024 Logan Fick
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at: https://www.apache.org/licenses/LICENSE-2.0
*
@@ -38,7 +38,7 @@ public final class ServerBecomesAvailable implements ServerBecomesAvailableListe
public void onServerBecomesAvailable(final ServerBecomesAvailableEvent event) {
// Queue every textable channel for message history ingestion.
event.getServer().getRegularChannels().forEach((channel) -> {
if (channel instanceof final TextableRegularServerChannel textableChannel){
if (channel instanceof final TextableRegularServerChannel textableChannel) {
this.crabstero.getWorkerPool().submit(new ChannelHistoryIngestionTask(textableChannel, crabstero));
}
});

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2022 Logan Fick
* Copyright 2024 Logan Fick
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at: https://www.apache.org/licenses/LICENSE-2.0
*
@@ -38,7 +38,7 @@ public final class ServerChannelChangeOverwrittenPermissions implements ServerCh
public void onServerChannelChangeOverwrittenPermissions(final ServerChannelChangeOverwrittenPermissionsEvent event) {
// Queue every textable channel for message history ingestion.
event.getServer().getRegularChannels().forEach((channel) -> {
if (channel instanceof final TextableRegularServerChannel textableChannel){
if (channel instanceof final TextableRegularServerChannel textableChannel) {
this.crabstero.getWorkerPool().submit(new ChannelHistoryIngestionTask(textableChannel, crabstero));
}
});

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2022 Logan Fick
* Copyright 2024 Logan Fick
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at: https://www.apache.org/licenses/LICENSE-2.0
*
@@ -64,7 +64,7 @@ public final class ServerJoin implements ServerJoinListener {
// Queue every textable channel for message history ingestion.
event.getServer().getRegularChannels().forEach((channel) -> {
if (channel instanceof final TextableRegularServerChannel textableChannel){
if (channel instanceof final TextableRegularServerChannel textableChannel) {
this.crabstero.getWorkerPool().submit(new ChannelHistoryIngestionTask(textableChannel, crabstero));
}
});

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2022 Logan Fick
* Copyright 2024 Logan Fick
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at: https://www.apache.org/licenses/LICENSE-2.0
*
@@ -40,7 +40,7 @@ public final class UserRoleAdd implements UserRoleAddListener {
if (event.getUser().isYourself()) {
// Queue every textable channel for message history ingestion.
event.getServer().getRegularChannels().forEach((channel) -> {
if (channel instanceof final TextableRegularServerChannel textableChannel){
if (channel instanceof final TextableRegularServerChannel textableChannel) {
this.crabstero.getWorkerPool().submit(new ChannelHistoryIngestionTask(textableChannel, crabstero));
}
});

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2022 Logan Fick
* Copyright 2024 Logan Fick
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at: https://www.apache.org/licenses/LICENSE-2.0
*
@@ -10,7 +10,6 @@ package dev.logal.crabstero.tasks;
import dev.logal.crabstero.Crabstero;
import dev.logal.crabstero.utils.MarkovChainMessages;
import org.javacord.api.entity.channel.ServerTextChannel;
import org.javacord.api.entity.channel.TextableRegularServerChannel;
import org.javacord.api.entity.message.Message;
import org.slf4j.Logger;

View File

@@ -18,6 +18,10 @@ import redis.clients.jedis.Jedis;
* Provides ability to set and check for flags on text channels, servers, and users.
*/
public class FlagManager {
public static final String CHANNEL_DB_PREFIX = "";
public static final String SERVER_DB_PREFIX = "s";
public static final String USER_DB_PREFIX = "u";
private final Crabstero crabstero; // The Crabstero instance using this configuration manager.
/**
@@ -29,39 +33,61 @@ public class FlagManager {
this.crabstero = crabstero;
}
/**
* Sets a flag of a given name on a given ID in the namespace of a prefix.
* This should only be used if only an ID is known instead of a reference to a more abstract object.
*
* @param prefix The prefix.
* @param ID The ID of the entity within that prefix namespace to set the flag on.
* @param flag The flag to set.
*/
public void setFlag(final String prefix, final String ID, final String flag) {
try (final Jedis jedis = this.crabstero.getJedisPool().getResource()) {
jedis.set(prefix + ID + ":flags:" + flag, "");
}
}
/**
* Sets a flag of a given name on the given text channel.
*
* @param channel The text channel to set the flag on.
* @param flag The flag to set.
* @param flag The name of the flag to set.
*/
public void setFlag(final TextChannel channel, String flag){
try (final Jedis jedis = this.crabstero.getJedisPool().getResource()) {
jedis.set(channel.getIdAsString() + ":flags:" + flag, "");
}
public void setFlag(final TextChannel channel, String flag) {
this.setFlag(CHANNEL_DB_PREFIX, channel.getIdAsString(), flag);
}
/**
* Sets a flag of a given name on the given server.
*
* @param server The server to set the flag on.
* @param flag The flag to set.
* @param flag The name of the flag to set.
*/
public void setFlag(final Server server, String flag){
try (final Jedis jedis = this.crabstero.getJedisPool().getResource()) {
jedis.set("s" + server.getIdAsString() + ":flags:" + flag, "");
}
public void setFlag(final Server server, String flag) {
this.setFlag(SERVER_DB_PREFIX, server.getIdAsString(), flag);
}
/**
* Sets a flag of a given name on the given user.
*
* @param user The user to set the flag on.
* @param flag The flag to set.
* @param flag The name of the flag to set.
*/
public void setFlag(final User user, String flag){
public void setFlag(final User user, String flag) {
this.setFlag(USER_DB_PREFIX, user.getIdAsString(), flag);
}
/**
* Clears the flag of a given name on a given ID in the namespace of a prefix.
* This should only be used if only an ID is known instead of a reference to a more abstract object.
*
* @param prefix The prefix.
* @param ID The ID of the entity within that prefix namespace to clear the flag on.
* @param flag The name of the flag to clear.
*/
public void clearFlag(final String prefix, final String ID, final String flag) {
try (final Jedis jedis = this.crabstero.getJedisPool().getResource()) {
jedis.set("u" + user.getIdAsString() + ":flags:" + flag, "");
jedis.del(prefix + ID + ":flags:" + flag);
}
}
@@ -69,24 +95,20 @@ public class FlagManager {
* Clears the flag of a given name on the given text channel.
*
* @param channel The text channel to clear the flag on.
* @param flag The name of the flag to clear.
* @param flag The name of the flag to clear.
*/
public void clearFlag(final TextChannel channel, String flag){
try (final Jedis jedis = this.crabstero.getJedisPool().getResource()) {
jedis.del(channel.getIdAsString() + ":flags:" + flag);
}
public void clearFlag(final TextChannel channel, String flag) {
this.clearFlag(CHANNEL_DB_PREFIX, channel.getIdAsString(), flag);
}
/**
* Clears the flag of a given name on the given server.
*
* @param server The server to clear the flag on.
* @param flag The name of the flag to clear.
* @param flag The name of the flag to clear.
*/
public void clearFlag(final Server server, String flag){
try (final Jedis jedis = this.crabstero.getJedisPool().getResource()) {
jedis.del("s" + server.getIdAsString() + ":flags:" + flag);
}
public void clearFlag(final Server server, String flag) {
this.clearFlag(SERVER_DB_PREFIX, server.getIdAsString(), flag);
}
/**
@@ -95,9 +117,22 @@ public class FlagManager {
* @param user The user to clear the flag on.
* @param flag The name of the flag to clear.
*/
public void clearFlag(final User user, String flag){
public void clearFlag(final User user, String flag) {
this.clearFlag(USER_DB_PREFIX, user.getIdAsString(), flag);
}
/**
* Checks whether a flag of a given name is set on a given ID in the namespace of a prefix.
* This should only be used if only an ID is known instead of a reference to a more abstract object.
*
* @param prefix The prefix.
* @param ID The ID of the entity within that prefix namespace to check on.
* @param flag The name of the flag to check for.
* @return A boolean indicating whether the flag is set.
*/
public boolean isFlagSet(final String prefix, final String ID, final String flag) {
try (final Jedis jedis = this.crabstero.getJedisPool().getResource()) {
jedis.del("u" + user.getIdAsString() + ":flags:" + flag);
return jedis.exists(prefix + ID + ":flags:" + flag);
}
}
@@ -105,26 +140,22 @@ public class FlagManager {
* Checks whether a flag of a given name is set on a given text channel.
*
* @param channel The text channel to check on.
* @param flag The name of the flag to check for.
* @param flag The name of the flag to check for.
* @return A boolean indicating whether the flag is set.
*/
public boolean isFlagSet(final TextChannel channel, String flag){
try (final Jedis jedis = this.crabstero.getJedisPool().getResource()) {
return jedis.exists(channel.getIdAsString() + ":flags:" + flag);
}
public boolean isFlagSet(final TextChannel channel, String flag) {
return this.isFlagSet(CHANNEL_DB_PREFIX, channel.getIdAsString(), flag);
}
/**
* Checks whether a flag of a given name is set on a given server.
*
* @param server The server to check on.
* @param flag The name of the flag to check for.
* @param flag The name of the flag to check for.
* @return A boolean indicating whether the flag is set.
*/
public boolean isFlagSet(final Server server, String flag){
try (final Jedis jedis = this.crabstero.getJedisPool().getResource()) {
return jedis.exists("s" + server.getIdAsString() + ":flags:" + flag);
}
public boolean isFlagSet(final Server server, String flag) {
return this.isFlagSet(SERVER_DB_PREFIX, server.getIdAsString(), flag);
}
/**
@@ -134,9 +165,7 @@ public class FlagManager {
* @param flag The name of the flag to check for.
* @return A boolean indicating whether the flag is set.
*/
public boolean isFlagSet(final User user, String flag){
try (final Jedis jedis = this.crabstero.getJedisPool().getResource()) {
return jedis.exists("u" + user.getIdAsString() + ":flags:" + flag);
}
public boolean isFlagSet(final User user, String flag) {
return this.isFlagSet(USER_DB_PREFIX, user.getIdAsString(), flag);
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2022 Logan Fick
* Copyright 2024 Logan Fick
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at: https://www.apache.org/licenses/LICENSE-2.0
*

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2022 Logan Fick
* Copyright 2024 Logan Fick
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at: https://www.apache.org/licenses/LICENSE-2.0
*
@@ -15,7 +15,6 @@ import org.javacord.api.entity.message.MessageAuthor;
import org.javacord.api.entity.message.MessageBuilder;
import org.javacord.api.entity.message.embed.Embed;
import org.javacord.api.entity.message.embed.EmbedBuilder;
import org.javacord.api.entity.message.mention.AllowedMentions;
import org.javacord.api.entity.message.mention.AllowedMentionsBuilder;
import org.javacord.api.util.logging.ExceptionLogger;
import redis.clients.jedis.Jedis;
@@ -23,6 +22,8 @@ import redis.clients.jedis.Jedis;
import java.security.SecureRandom;
import java.util.List;
import java.util.Random;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Assists with generating Discord messages in response to other users and ingesting raw messages.
@@ -30,22 +31,24 @@ import java.util.Random;
public class MarkovChainMessages {
private static final String NO_REPLY_FLAG = "noReply";
private static final String NO_INGEST_FLAG = "noIngest";
private static final String ALLOW_PINGS_FLAG = "allowPings";
private static final String USER_WITH_OPTIONAL_NICKNAME_REGEX = "<@!?(?<id>\\d{17,20})>"; // Copied from discordjs/discord-api-types: https://github.com/discordjs/discord-api-types/blob/7fe434114e91c80ed79f0204ae6c73047672d55d/globals.ts#L30
private final Crabstero crabstero;
private final FlagManager flagManager;
private final AllowedMentions allowedMentions;
private final Pattern mentionPattern;
private final Random rng = new SecureRandom();
/**
* Creates a new Markov Chain message assistant owned by a given instance of Crabstero.
*
* @param crabstero The instance of Crabstero.
*/
public MarkovChainMessages(final Crabstero crabstero) {
this.crabstero = crabstero;
this.flagManager = new FlagManager(crabstero);
// Set up an allowed mentions filter which blocks any mentions from generating notifications to users.
final AllowedMentionsBuilder builder = new AllowedMentionsBuilder();
builder.setMentionEveryoneAndHere(false);
builder.setMentionRoles(false);
builder.setMentionUsers(false);
this.allowedMentions = builder.build();
this.mentionPattern = Pattern.compile(USER_WITH_OPTIONAL_NICKNAME_REGEX);
}
/**
@@ -66,7 +69,7 @@ public class MarkovChainMessages {
// TODO: The blind get()s are safe for now as MessageCreate does the necessary checks, but this could be done better.
if (flagManager.isFlagSet(channel, NO_REPLY_FLAG) ||
flagManager.isFlagSet(message.getServer().get(), NO_REPLY_FLAG) ||
flagManager.isFlagSet(message.getAuthor().asUser().get(), NO_REPLY_FLAG)){
flagManager.isFlagSet(message.getAuthor().asUser().get(), NO_REPLY_FLAG)) {
// Yes, don't reply.
return;
}
@@ -88,7 +91,8 @@ public class MarkovChainMessages {
response.replyTo(message);
// Generate a new body.
response.setContent(markovChain.generate(750, 1000));
final String body = markovChain.generate(750, 1000);
response.setContent(body);
// Was a random number greater than 0.95 (5% of the time) and does Crabstero have permission to use embeds in this channel?
if (this.rng.nextDouble() >= 0.95 && channel.canYouEmbedLinks()) {
@@ -110,8 +114,28 @@ public class MarkovChainMessages {
response.setEmbed(embed);
}
// Set the allowed mentions filter which blocks all mentions from generating notifications.
response.setAllowedMentions(allowedMentions);
// Build an allowed mentions filter which, by default, does not allow mentions in the response to generate pings.
final AllowedMentionsBuilder builder = new AllowedMentionsBuilder();
builder.setMentionEveryoneAndHere(false);
builder.setMentionRoles(false);
builder.setMentionUsers(false);
// Search for mentions in the generated body sentence.
Matcher mentionMatcher = mentionPattern.matcher(body);
// For each detected mention in the body sentence...
while (mentionMatcher.find()) {
// Get the user ID from the mention
String id = mentionMatcher.group("id");
// Did this mentioned user opt into receiving pings for mentions?
if (this.flagManager.isFlagSet(FlagManager.USER_DB_PREFIX, id, ALLOW_PINGS_FLAG)) {
builder.addUser(id); // Add the user to the filter to allow a ping to generate.
}
}
// Attach the final allowed mentions filter to the response.
response.setAllowedMentions(builder.build());
// Send the response.
response.send(channel).exceptionally(ExceptionLogger.get());
@@ -134,7 +158,7 @@ public class MarkovChainMessages {
// TODO: The blind get()s are safe for now as MessageCreate does the necessary checks, but this could be done better.
if (flagManager.isFlagSet(message.getChannel(), NO_INGEST_FLAG) ||
flagManager.isFlagSet(message.getServer().get(), NO_INGEST_FLAG) ||
flagManager.isFlagSet(message.getAuthor().asUser().get(), NO_INGEST_FLAG)){
flagManager.isFlagSet(message.getAuthor().asUser().get(), NO_INGEST_FLAG)) {
// Yes, don't reply.
return;
}