Improve game chat handling flow, add DiscordPermissionUtil

This commit is contained in:
Vankka 2024-06-21 15:29:12 +03:00
parent c9c101b803
commit 501d638744
No known key found for this signature in database
GPG Key ID: 62E48025ED4E7EBB
9 changed files with 199 additions and 70 deletions

View File

@ -33,8 +33,8 @@ import com.discordsrv.common.config.main.generic.ThreadConfig;
import com.github.benmanes.caffeine.cache.CacheLoader;
import com.github.benmanes.caffeine.cache.LoadingCache;
import org.apache.commons.lang3.tuple.Pair;
import org.checkerframework.checker.nullness.qual.NonNull;
import org.checkerframework.checker.nullness.qual.Nullable;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.spongepowered.configurate.CommentedConfigurationNode;
import org.spongepowered.configurate.objectmapping.ObjectMapper;
import org.spongepowered.configurate.serialize.SerializationException;
@ -65,7 +65,7 @@ public class ChannelConfigHelper {
.build(new CacheLoader<String, GameChannel>() {
@Override
public @Nullable GameChannel load(@NonNull String channelName) {
public @Nullable GameChannel load(@NotNull String channelName) {
GameChannelLookupEvent event = new GameChannelLookupEvent(null, channelName);
discordSRV.eventBus().publish(event);
if (!event.isProcessed()) {
@ -199,11 +199,13 @@ public class ChannelConfigHelper {
return channelConfigs;
}
public BaseChannelConfig get(GameChannel gameChannel) {
@Nullable
public BaseChannelConfig get(@NotNull GameChannel gameChannel) {
return resolve(gameChannel.getOwnerName(), gameChannel.getChannelName());
}
public BaseChannelConfig resolve(String ownerName, String channelName) {
@Nullable
public BaseChannelConfig resolve(@Nullable String ownerName, @NotNull String channelName) {
if (ownerName != null) {
ownerName = ownerName.toLowerCase(Locale.ROOT);

View File

@ -19,12 +19,12 @@
package com.discordsrv.common.channel;
import com.discordsrv.api.discord.entity.channel.DiscordGuildMessageChannel;
import com.discordsrv.api.discord.entity.channel.DiscordMessageChannel;
import com.discordsrv.api.discord.entity.channel.DiscordThreadChannel;
import com.discordsrv.common.DiscordSRV;
import com.discordsrv.common.config.main.channels.ChannelLockingConfig;
import com.discordsrv.common.config.main.channels.base.BaseChannelConfig;
import com.discordsrv.common.config.main.channels.base.IChannelConfig;
import com.discordsrv.common.discord.util.DiscordPermissionUtil;
import com.discordsrv.common.module.type.AbstractModule;
import net.dv8tion.jda.api.Permission;
import net.dv8tion.jda.api.entities.Guild;
@ -55,7 +55,6 @@ public class ChannelLockingModule extends AbstractModule<DiscordSRV> {
doForAllChannels((config, channelConfig) -> {
ChannelLockingConfig shutdownConfig = config.channelLocking;
ChannelLockingConfig.Channels channels = shutdownConfig.channels;
ChannelLockingConfig.Threads threads = shutdownConfig.threads;
discordSRV.destinations()
.lookupDestination(((IChannelConfig) config).destination(), false, true)
@ -131,8 +130,9 @@ public class ChannelLockingModule extends AbstractModule<DiscordSRV> {
}
Guild guild = messageChannel.getGuild();
if (!guild.getSelfMember().hasPermission(messageChannel, Permission.MANAGE_PERMISSIONS)) {
logger().error("Cannot change permissions of " + channel + ": lacking \"Manage Permissions\" permission");
String missingPermissions = DiscordPermissionUtil.missingPermissionsString(messageChannel, Permission.VIEW_CHANNEL, Permission.MANAGE_PERMISSIONS);
if (missingPermissions != null) {
logger().error("Cannot lock #" + channel.getName() + ": " + missingPermissions);
return;
}

View File

@ -5,6 +5,7 @@ import com.discordsrv.api.discord.entity.message.SendableDiscordMessage;
import com.discordsrv.common.DiscordSRV;
import com.discordsrv.common.config.main.generic.DestinationConfig;
import com.discordsrv.common.config.main.generic.ThreadConfig;
import com.discordsrv.common.discord.util.DiscordPermissionUtil;
import com.discordsrv.common.logging.Logger;
import com.discordsrv.common.logging.NamedLogger;
import net.dv8tion.jda.api.Permission;
@ -113,7 +114,7 @@ public class DestinationLookupHelper {
return createThread(threadContainer, threadConfig, logFailures);
}).exceptionally(t -> {
if (logFailures) {
logger.error("Failed to lookup threads in channel ID " + Long.toUnsignedString(channelId), t);
logger.error("Failed to lookup threads in channel #" + threadContainer.getName(), t);
}
return null;
});
@ -164,11 +165,15 @@ public class DestinationLookupHelper {
boolean privateThread = !forum && threadConfig.privateThread;
IThreadContainer container = threadContainer.getAsJDAThreadContainer();
if (!container.getGuild().getSelfMember().hasPermission(container, privateThread ? Permission.CREATE_PRIVATE_THREADS : Permission.CREATE_PUBLIC_THREADS)) {
String missingPermissions = DiscordPermissionUtil.missingPermissionsString(
container,
Permission.VIEW_CHANNEL,
privateThread ? Permission.CREATE_PRIVATE_THREADS : Permission.CREATE_PUBLIC_THREADS
);
if (missingPermissions != null) {
if (logFailures) {
logger.error("Failed to create thread \"" + threadConfig.threadName + "\" "
+ "in channel ID " + Long.toUnsignedString(threadContainer.getId())
+ ": lacking \"Create " + (privateThread ? "Private" : "Public") + " Threads\" permission");
+ "in channel #" + threadContainer.getName() + ": " + missingPermissions);
}
return CompletableFuture.completedFuture(null);
}
@ -185,7 +190,7 @@ public class DestinationLookupHelper {
return future.exceptionally(t -> {
if (logFailures) {
logger.error("Failed to create thread \"" + threadConfig.threadName + "\" "
+ "in channel ID " + Long.toUnsignedString(threadContainer.getId()), t);
+ "in channel #" + threadContainer.getName(), t);
}
return null;
});
@ -193,11 +198,17 @@ public class DestinationLookupHelper {
private CompletableFuture<DiscordThreadChannel> unarchiveThread(DiscordThreadChannel channel, boolean logFailures) {
ThreadChannel jdaChannel = channel.asJDA();
if ((jdaChannel.isLocked() || !jdaChannel.isOwner()) && !jdaChannel.getGuild().getSelfMember().hasPermission(jdaChannel, Permission.MANAGE_THREADS)) {
EnumSet<Permission> requiredPermissions = EnumSet.of(Permission.VIEW_CHANNEL);
if (jdaChannel.isLocked() || !jdaChannel.isOwner()) {
requiredPermissions.add(Permission.MANAGE_THREADS);
}
String missingPermissions = DiscordPermissionUtil.missingPermissionsString(jdaChannel, requiredPermissions);
if (missingPermissions != null) {
if (logFailures) {
logger.error("Cannot unarchive thread \"" + channel.getName() + "\" "
+ "in channel ID " + Long.toUnsignedString(channel.getParentChannel().getId())
+ ": lacking \"Manage Threads\" permission");
+ "in channel #" + channel.getParentChannel().getName() + ": " + missingPermissions);
}
return CompletableFuture.completedFuture(null);
}
@ -210,7 +221,7 @@ public class DestinationLookupHelper {
).thenApply(v -> channel).exceptionally(t -> {
if (logFailures) {
logger.error("Failed to unarchive thread \"" + channel.getName() + "\" "
+ "in channel ID " + Long.toUnsignedString(channel.getParentChannel().getId()), t);
+ "in channel #" + channel.getParentChannel().getName(), t);
}
return null;
});

View File

@ -135,4 +135,9 @@ public class DiscordForumChannelImpl implements DiscordForumChannel {
public DiscordChannelType getType() {
return DiscordChannelType.FORUM;
}
@Override
public String toString() {
return "Forum:" + getName() + "(" + Long.toUnsignedString(getId()) + ")";
}
}

View File

@ -0,0 +1,69 @@
package com.discordsrv.common.discord.util;
import net.dv8tion.jda.api.Permission;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.channel.concrete.ThreadChannel;
import net.dv8tion.jda.api.entities.channel.middleman.GuildChannel;
import java.util.Arrays;
import java.util.Collection;
import java.util.EnumSet;
import java.util.stream.Collectors;
public final class DiscordPermissionUtil {
private DiscordPermissionUtil() {}
public static String missingPermissionsString(GuildChannel channel, Permission... permissions) {
return missingPermissionsString(channel, Arrays.asList(permissions));
}
public static String missingPermissionsString(GuildChannel channel, Collection<Permission> permissions) {
if (channel instanceof ThreadChannel) {
channel = ((ThreadChannel) channel).getParentChannel();
}
EnumSet<Permission> missingPermissions = checkMissingPermissions(channel, permissions);
return format(missingPermissions, "#" + channel.getName());
}
public static EnumSet<Permission> checkMissingPermissions(GuildChannel channel, Collection<Permission> permissions) {
if (channel instanceof ThreadChannel) {
channel = ((ThreadChannel) channel).getParentChannel();
}
EnumSet<Permission> missingPermissions = EnumSet.noneOf(Permission.class);
for (Permission permission : permissions) {
if (!channel.getGuild().getSelfMember().hasPermission(channel, permission)) {
missingPermissions.add(permission);
}
}
return missingPermissions;
}
public static String missingPermissionsString(Guild guild, Permission... permissions) {
return missingPermissionsString(guild, Arrays.asList(permissions));
}
public static String missingPermissionsString(Guild guild, Collection<Permission> permissions) {
EnumSet<Permission> missingPermissions = checkMissingPermissions(guild, permissions);
return format(missingPermissions, guild.getName());
}
public static EnumSet<Permission> checkMissingPermissions(Guild guild, Collection<Permission> permissions) {
EnumSet<Permission> missingPermissions = EnumSet.noneOf(Permission.class);
for (Permission permission : permissions) {
if (!guild.getSelfMember().hasPermission(permission)) {
missingPermissions.add(permission);
}
}
return missingPermissions;
}
private static String format(EnumSet<Permission> permissions, String where) {
if (permissions.isEmpty()) {
return null;
}
return "the bot is lacking permissions in " + where + ": "
+ permissions.stream().map(Permission::getName).collect(Collectors.joining(", "));
}
}

View File

@ -20,7 +20,9 @@ package com.discordsrv.common.messageforwarding.game;
import com.discordsrv.api.channel.GameChannel;
import com.discordsrv.api.discord.connection.jda.errorresponse.ErrorCallbackContext;
import com.discordsrv.api.discord.entity.channel.DiscordGuildChannel;
import com.discordsrv.api.discord.entity.channel.DiscordGuildMessageChannel;
import com.discordsrv.api.discord.entity.channel.DiscordThreadChannel;
import com.discordsrv.api.discord.entity.message.ReceivedDiscordMessage;
import com.discordsrv.api.discord.entity.message.ReceivedDiscordMessageCluster;
import com.discordsrv.api.discord.entity.message.SendableDiscordMessage;
@ -31,18 +33,36 @@ import com.discordsrv.common.config.main.channels.base.BaseChannelConfig;
import com.discordsrv.common.config.main.channels.base.IChannelConfig;
import com.discordsrv.common.config.main.generic.IMessageConfig;
import com.discordsrv.common.discord.api.entity.message.ReceivedDiscordMessageClusterImpl;
import com.discordsrv.common.discord.util.DiscordPermissionUtil;
import com.discordsrv.common.future.util.CompletableFutureUtil;
import com.discordsrv.common.logging.NamedLogger;
import com.discordsrv.common.module.type.AbstractModule;
import com.discordsrv.common.player.IPlayer;
import com.discordsrv.common.testing.TestHelper;
import net.dv8tion.jda.api.Permission;
import net.dv8tion.jda.api.entities.channel.concrete.ThreadChannel;
import net.dv8tion.jda.api.entities.channel.middleman.GuildChannel;
import net.dv8tion.jda.api.entities.channel.middleman.GuildMessageChannel;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
/**
* An abstracted flow to send in-game messages to a given destination and publish the results to the event bus.
* <p>
* Order of operations:
* - Event (E generic) is received, implementation calls {@link #process(AbstractGameMessageReceiveEvent, DiscordSRVPlayer, GameChannel)}
* - {@link IPlayer} and {@link BaseChannelConfig} (uses {@link #mapConfig(AbstractGameMessageReceiveEvent, BaseChannelConfig)} are resolved, then {@link #forwardToChannel(AbstractGameMessageReceiveEvent, IPlayer, BaseChannelConfig)} is called
* - Destinations are looked up and {@link #sendMessageToChannels} gets called
* - {@link #setPlaceholders(IMessageConfig, AbstractGameMessageReceiveEvent, SendableDiscordMessage.Formatter)} is called to set any additional placeholders
* - {@link #sendMessageToChannel(DiscordGuildMessageChannel, SendableDiscordMessage)} is called (once per channel) to send messages to individual channels
* - {@link #postClusterToEventBus(ReceivedDiscordMessageCluster)} is called with all messages that were sent (if any messages were sent)
*
* @param <T> config model
* @param <E> the event indicating a message was received from in-game, of type {@link AbstractGameMessageReceiveEvent}
*/
public abstract class AbstractGameMessageModule<T extends IMessageConfig, E extends AbstractGameMessageReceiveEvent> extends AbstractModule<DiscordSRV> {
public AbstractGameMessageModule(DiscordSRV discordSRV, String loggerName) {
@ -86,6 +106,10 @@ public abstract class AbstractGameMessageModule<T extends IMessageConfig, E exte
}
BaseChannelConfig channelConfig = discordSRV.channelConfig().get(channel);
if (channelConfig == null) {
return CompletableFuture.completedFuture(null);
}
return forwardToChannel(event, srvPlayer, channelConfig);
}
@ -111,33 +135,19 @@ public abstract class AbstractGameMessageModule<T extends IMessageConfig, E exte
return CompletableFuture.completedFuture(null);
}
Map<CompletableFuture<ReceivedDiscordMessage>, DiscordGuildMessageChannel> messageFutures;
messageFutures = sendMessageToChannels(
List<CompletableFuture<ReceivedDiscordMessage>> messageFutures = sendMessageToChannels(
moduleConfig, player, format, messageChannels, event,
// Context
config, player
);
return CompletableFuture.allOf(messageFutures.keySet().toArray(new CompletableFuture[0]))
.whenComplete((vo, t2) -> {
return CompletableFutureUtil.combine(messageFutures).whenComplete((vo, t2) -> {
Set<ReceivedDiscordMessage> messages = new LinkedHashSet<>();
for (Map.Entry<CompletableFuture<ReceivedDiscordMessage>, DiscordGuildMessageChannel> entry : messageFutures.entrySet()) {
CompletableFuture<ReceivedDiscordMessage> future = entry.getKey();
if (future.isCompletedExceptionally()) {
future.exceptionally(t -> {
if (t instanceof CompletionException) {
t = t.getCause();
for (CompletableFuture<ReceivedDiscordMessage> future : messageFutures) {
ReceivedDiscordMessage message = future.join();
if (message != null) {
messages.add(message);
}
ErrorCallbackContext.context("Failed to deliver a message to " + entry.getValue()).accept(t);
TestHelper.fail(t);
return null;
});
// Ignore ones that failed
continue;
}
// They are all done, so joining will return the result instantly
messages.add(future.join());
}
if (messages.isEmpty()) {
@ -146,23 +156,19 @@ public abstract class AbstractGameMessageModule<T extends IMessageConfig, E exte
}
postClusterToEventBus(new ReceivedDiscordMessageClusterImpl(messages));
})
.exceptionally(t -> {
if (t instanceof CompletionException) {
return null;
}
}).exceptionally(t -> {
discordSRV.logger().error("Failed to publish to event bus", t);
TestHelper.fail(t);
return null;
});
}).thenApply(v -> (Void) null);
}).exceptionally(t -> {
discordSRV.logger().error("Error in sending message", t);
discordSRV.logger().error("Error in forwarding message", t);
TestHelper.fail(t);
return null;
});
}
public Map<CompletableFuture<ReceivedDiscordMessage>, DiscordGuildMessageChannel> sendMessageToChannels(
public List<CompletableFuture<ReceivedDiscordMessage>> sendMessageToChannels(
T config,
IPlayer player,
SendableDiscordMessage.Builder format,
@ -179,16 +185,52 @@ public abstract class AbstractGameMessageModule<T extends IMessageConfig, E exte
SendableDiscordMessage discordMessage = formatter
.build();
if (discordMessage.isEmpty()) {
return Collections.emptyMap();
return Collections.emptyList();
}
Map<CompletableFuture<ReceivedDiscordMessage>, DiscordGuildMessageChannel> futures = new LinkedHashMap<>();
List<CompletableFuture<ReceivedDiscordMessage>> futures = new ArrayList<>();
for (DiscordGuildMessageChannel channel : channels) {
futures.put(channel.sendMessage(discordMessage), channel);
futures.add(sendMessageToChannel(channel, discordMessage));
}
return futures;
}
@Nullable
protected final CompletableFuture<ReceivedDiscordMessage> sendMessageToChannel(DiscordGuildMessageChannel channel, SendableDiscordMessage message) {
GuildChannel permissionChannel = (GuildMessageChannel) channel.getAsJDAMessageChannel();
Permission sendPermission;
if (message.isWebhookMessage()) {
if (permissionChannel instanceof ThreadChannel) {
permissionChannel = ((ThreadChannel) permissionChannel).getParentChannel();
}
sendPermission = Permission.MANAGE_WEBHOOKS;
} else {
sendPermission = permissionChannel instanceof ThreadChannel
? Permission.MESSAGE_SEND_IN_THREADS
: Permission.MESSAGE_SEND;
}
String missingPermissions = DiscordPermissionUtil.missingPermissionsString(permissionChannel, Permission.VIEW_CHANNEL, sendPermission);
if (missingPermissions != null) {
logger().error("Failed to send message to " + describeDestination(channel) + ": " + missingPermissions);
return CompletableFuture.completedFuture(null);
}
return channel.sendMessage(message).exceptionally(t -> {
ErrorCallbackContext.context("Failed to deliver a message to " + describeDestination(channel)).accept(t);
TestHelper.fail(t);
return null;
});
}
private String describeDestination(DiscordGuildChannel channel) {
if (channel instanceof DiscordThreadChannel) {
return "\"" + channel.getName() + "\" in #" + ((DiscordThreadChannel) channel).getParentChannel().getName();
}
return "#" + channel.getName();
}
public abstract void setPlaceholders(T config, E event, SendableDiscordMessage.Formatter formatter);
}

View File

@ -30,7 +30,7 @@ import com.discordsrv.common.player.IPlayer;
import java.util.Collection;
import java.util.Collections;
import java.util.Map;
import java.util.List;
import java.util.concurrent.CompletableFuture;
public class StartMessageModule extends AbstractGameMessageModule<StartMessageConfig, AbstractGameMessageReceiveEvent> {
@ -53,7 +53,7 @@ public class StartMessageModule extends AbstractGameMessageModule<StartMessageCo
public void postClusterToEventBus(ReceivedDiscordMessageCluster cluster) {}
@Override
public Map<CompletableFuture<ReceivedDiscordMessage>, DiscordGuildMessageChannel> sendMessageToChannels(
public List<CompletableFuture<ReceivedDiscordMessage>> sendMessageToChannels(
StartMessageConfig config,
IPlayer player,
SendableDiscordMessage.Builder format,
@ -62,7 +62,7 @@ public class StartMessageModule extends AbstractGameMessageModule<StartMessageCo
Object... context
) {
if (!config.enabled) {
return Collections.emptyMap();
return Collections.emptyList();
}
return super.sendMessageToChannels(config, player, format, channels, event, context);
}

View File

@ -30,7 +30,7 @@ import com.discordsrv.common.player.IPlayer;
import java.util.Collection;
import java.util.Collections;
import java.util.Map;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
@ -56,7 +56,7 @@ public class StopMessageModule extends AbstractGameMessageModule<StopMessageConf
public void postClusterToEventBus(ReceivedDiscordMessageCluster cluster) {}
@Override
public Map<CompletableFuture<ReceivedDiscordMessage>, DiscordGuildMessageChannel> sendMessageToChannels(
public List<CompletableFuture<ReceivedDiscordMessage>> sendMessageToChannels(
StopMessageConfig config,
IPlayer player,
SendableDiscordMessage.Builder format,
@ -65,7 +65,7 @@ public class StopMessageModule extends AbstractGameMessageModule<StopMessageConf
Object... context
) {
if (!config.enabled) {
return Collections.emptyMap();
return Collections.emptyList();
}
return super.sendMessageToChannels(config, player, format, channels, event, context);
}

View File

@ -78,7 +78,7 @@ public class MinecraftToDiscordChatModule extends AbstractGameMessageModule<Mine
}
@Override
public Map<CompletableFuture<ReceivedDiscordMessage>, DiscordGuildMessageChannel> sendMessageToChannels(
public List<CompletableFuture<ReceivedDiscordMessage>> sendMessageToChannels(
MinecraftToDiscordChatConfig config,
IPlayer player,
SendableDiscordMessage.Builder format,
@ -95,7 +95,7 @@ public class MinecraftToDiscordChatModule extends AbstractGameMessageModule<Mine
}
Component message = ComponentUtil.fromAPI(event.getMessage());
Map<CompletableFuture<ReceivedDiscordMessage>, DiscordGuildMessageChannel> futures = new LinkedHashMap<>();
List<CompletableFuture<ReceivedDiscordMessage>> futures = new ArrayList<>();
// Format messages per-Guild
for (Map.Entry<DiscordGuild, Set<DiscordGuildMessageChannel>> entry : channelMap.entrySet()) {
@ -103,7 +103,7 @@ public class MinecraftToDiscordChatModule extends AbstractGameMessageModule<Mine
CompletableFuture<SendableDiscordMessage> messageFuture = getMessageForGuild(config, format, guild, message, player, context);
for (DiscordGuildMessageChannel channel : entry.getValue()) {
futures.put(messageFuture.thenCompose(channel::sendMessage), channel);
futures.add(messageFuture.thenCompose(msg -> sendMessageToChannel(channel, msg)));
}
}