diff --git a/common/src/main/java/com/discordsrv/common/channel/ChannelConfigHelper.java b/common/src/main/java/com/discordsrv/common/channel/ChannelConfigHelper.java index a47fe86e..f8c6754b 100644 --- a/common/src/main/java/com/discordsrv/common/channel/ChannelConfigHelper.java +++ b/common/src/main/java/com/discordsrv/common/channel/ChannelConfigHelper.java @@ -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() { @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); diff --git a/common/src/main/java/com/discordsrv/common/channel/ChannelLockingModule.java b/common/src/main/java/com/discordsrv/common/channel/ChannelLockingModule.java index 12bd7ae9..9a2cce70 100644 --- a/common/src/main/java/com/discordsrv/common/channel/ChannelLockingModule.java +++ b/common/src/main/java/com/discordsrv/common/channel/ChannelLockingModule.java @@ -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 { 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 { } 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; } diff --git a/common/src/main/java/com/discordsrv/common/destination/DestinationLookupHelper.java b/common/src/main/java/com/discordsrv/common/destination/DestinationLookupHelper.java index a9ab591e..a4f02c0b 100644 --- a/common/src/main/java/com/discordsrv/common/destination/DestinationLookupHelper.java +++ b/common/src/main/java/com/discordsrv/common/destination/DestinationLookupHelper.java @@ -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 unarchiveThread(DiscordThreadChannel channel, boolean logFailures) { ThreadChannel jdaChannel = channel.asJDA(); - if ((jdaChannel.isLocked() || !jdaChannel.isOwner()) && !jdaChannel.getGuild().getSelfMember().hasPermission(jdaChannel, Permission.MANAGE_THREADS)) { + + EnumSet 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; }); diff --git a/common/src/main/java/com/discordsrv/common/discord/api/entity/channel/DiscordForumChannelImpl.java b/common/src/main/java/com/discordsrv/common/discord/api/entity/channel/DiscordForumChannelImpl.java index 42c866b4..fa5053f3 100644 --- a/common/src/main/java/com/discordsrv/common/discord/api/entity/channel/DiscordForumChannelImpl.java +++ b/common/src/main/java/com/discordsrv/common/discord/api/entity/channel/DiscordForumChannelImpl.java @@ -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()) + ")"; + } } diff --git a/common/src/main/java/com/discordsrv/common/discord/util/DiscordPermissionUtil.java b/common/src/main/java/com/discordsrv/common/discord/util/DiscordPermissionUtil.java new file mode 100644 index 00000000..2beca28c --- /dev/null +++ b/common/src/main/java/com/discordsrv/common/discord/util/DiscordPermissionUtil.java @@ -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 permissions) { + if (channel instanceof ThreadChannel) { + channel = ((ThreadChannel) channel).getParentChannel(); + } + EnumSet missingPermissions = checkMissingPermissions(channel, permissions); + return format(missingPermissions, "#" + channel.getName()); + } + + public static EnumSet checkMissingPermissions(GuildChannel channel, Collection permissions) { + if (channel instanceof ThreadChannel) { + channel = ((ThreadChannel) channel).getParentChannel(); + } + EnumSet 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 permissions) { + EnumSet missingPermissions = checkMissingPermissions(guild, permissions); + return format(missingPermissions, guild.getName()); + } + + public static EnumSet checkMissingPermissions(Guild guild, Collection permissions) { + EnumSet missingPermissions = EnumSet.noneOf(Permission.class); + for (Permission permission : permissions) { + if (!guild.getSelfMember().hasPermission(permission)) { + missingPermissions.add(permission); + } + } + return missingPermissions; + } + + private static String format(EnumSet permissions, String where) { + if (permissions.isEmpty()) { + return null; + } + + return "the bot is lacking permissions in " + where + ": " + + permissions.stream().map(Permission::getName).collect(Collectors.joining(", ")); + } +} diff --git a/common/src/main/java/com/discordsrv/common/messageforwarding/game/AbstractGameMessageModule.java b/common/src/main/java/com/discordsrv/common/messageforwarding/game/AbstractGameMessageModule.java index 85639bf2..2da394f0 100644 --- a/common/src/main/java/com/discordsrv/common/messageforwarding/game/AbstractGameMessageModule.java +++ b/common/src/main/java/com/discordsrv/common/messageforwarding/game/AbstractGameMessageModule.java @@ -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. + *

+ * 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 config model + * @param the event indicating a message was received from in-game, of type {@link AbstractGameMessageReceiveEvent} + */ public abstract class AbstractGameMessageModule extends AbstractModule { public AbstractGameMessageModule(DiscordSRV discordSRV, String loggerName) { @@ -86,6 +106,10 @@ public abstract class AbstractGameMessageModule, DiscordGuildMessageChannel> messageFutures; - messageFutures = sendMessageToChannels( + List> messageFutures = sendMessageToChannels( moduleConfig, player, format, messageChannels, event, // Context config, player ); - return CompletableFuture.allOf(messageFutures.keySet().toArray(new CompletableFuture[0])) - .whenComplete((vo, t2) -> { - Set messages = new LinkedHashSet<>(); - for (Map.Entry, DiscordGuildMessageChannel> entry : messageFutures.entrySet()) { - CompletableFuture future = entry.getKey(); - if (future.isCompletedExceptionally()) { - future.exceptionally(t -> { - if (t instanceof CompletionException) { - t = t.getCause(); - } - ErrorCallbackContext.context("Failed to deliver a message to " + entry.getValue()).accept(t); - TestHelper.fail(t); - return null; - }); - // Ignore ones that failed - continue; - } + return CompletableFutureUtil.combine(messageFutures).whenComplete((vo, t2) -> { + Set messages = new LinkedHashSet<>(); + for (CompletableFuture future : messageFutures) { + ReceivedDiscordMessage message = future.join(); + if (message != null) { + messages.add(message); + } + } - // They are all done, so joining will return the result instantly - messages.add(future.join()); - } + if (messages.isEmpty()) { + // Nothing was delivered + return; + } - if (messages.isEmpty()) { - // Nothing was delivered - return; - } - - postClusterToEventBus(new ReceivedDiscordMessageClusterImpl(messages)); - }) - .exceptionally(t -> { - if (t instanceof CompletionException) { - return null; - } - discordSRV.logger().error("Failed to publish to event bus", t); - TestHelper.fail(t); - return null; - }); + postClusterToEventBus(new ReceivedDiscordMessageClusterImpl(messages)); + }).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, DiscordGuildMessageChannel> sendMessageToChannels( + public List> sendMessageToChannels( T config, IPlayer player, SendableDiscordMessage.Builder format, @@ -179,16 +185,52 @@ public abstract class AbstractGameMessageModule, DiscordGuildMessageChannel> futures = new LinkedHashMap<>(); + List> futures = new ArrayList<>(); for (DiscordGuildMessageChannel channel : channels) { - futures.put(channel.sendMessage(discordMessage), channel); + futures.add(sendMessageToChannel(channel, discordMessage)); } return futures; } + @Nullable + protected final CompletableFuture 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); } diff --git a/common/src/main/java/com/discordsrv/common/messageforwarding/game/StartMessageModule.java b/common/src/main/java/com/discordsrv/common/messageforwarding/game/StartMessageModule.java index 409a9cfb..26d1d452 100644 --- a/common/src/main/java/com/discordsrv/common/messageforwarding/game/StartMessageModule.java +++ b/common/src/main/java/com/discordsrv/common/messageforwarding/game/StartMessageModule.java @@ -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 { @@ -53,7 +53,7 @@ public class StartMessageModule extends AbstractGameMessageModule, DiscordGuildMessageChannel> sendMessageToChannels( + public List> sendMessageToChannels( StartMessageConfig config, IPlayer player, SendableDiscordMessage.Builder format, @@ -62,7 +62,7 @@ public class StartMessageModule extends AbstractGameMessageModule, DiscordGuildMessageChannel> sendMessageToChannels( + public List> sendMessageToChannels( StopMessageConfig config, IPlayer player, SendableDiscordMessage.Builder format, @@ -65,7 +65,7 @@ public class StopMessageModule extends AbstractGameMessageModule, DiscordGuildMessageChannel> sendMessageToChannels( + public List> sendMessageToChannels( MinecraftToDiscordChatConfig config, IPlayer player, SendableDiscordMessage.Builder format, @@ -95,7 +95,7 @@ public class MinecraftToDiscordChatModule extends AbstractGameMessageModule, DiscordGuildMessageChannel> futures = new LinkedHashMap<>(); + List> futures = new ArrayList<>(); // Format messages per-Guild for (Map.Entry> entry : channelMap.entrySet()) { @@ -103,7 +103,7 @@ public class MinecraftToDiscordChatModule extends AbstractGameMessageModule 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))); } }